<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
  <title>Prorise - 博客小栈</title>
  
  
  <link href="https://prorise666.site/atom.xml" rel="self"/>
  
  <link href="https://prorise666.site/"/>
  <updated>2026-03-12T09:18:16.959Z</updated>
  <id>https://prorise666.site/</id>
  
  <author>
    <name>Prorise</name>
    
  </author>
  
  <generator uri="https://hexo.io/">Hexo</generator>
  
  <entry>
    <title>Note 19（第三章）. SpringBoot3-账号密码登录：领域模型设计与完整实现</title>
    <link href="https://prorise666.site/posts/42423.html"/>
    <id>https://prorise666.site/posts/42423.html</id>
    <published>2026-03-02T20:17:45.000Z</published>
    <updated>2026-03-12T09:18:16.959Z</updated>
    
    <content type="html"><![CDATA[<div id="postchat_postcontent"><h1>Note 19.3. 账号密码登录：领域模型设计与完整实现</h1><h2 id="环境版本锁定">环境版本锁定</h2><p>在开始构建账号密码登录体系之前，我们需要明确本章依赖的技术栈版本。</p><table><thead><tr><th style="text-align:left">技术组件</th><th style="text-align:left">版本号</th><th style="text-align:left">说明</th></tr></thead><tbody><tr><td style="text-align:left">JDK</td><td style="text-align:left">17 LTS</td><td style="text-align:left">必须支持 Record 等新特性</td></tr><tr><td style="text-align:left">Spring Boot</td><td style="text-align:left">3.2.0</td><td style="text-align:left">配合 Spring 6.x</td></tr><tr><td style="text-align:left">MyBatis-Plus</td><td style="text-align:left">3.5.5</td><td style="text-align:left">用于数据持久化</td></tr><tr><td style="text-align:left">MySQL</td><td style="text-align:left">8.0</td><td style="text-align:left">用户数据存储</td></tr><tr><td style="text-align:left">Redis</td><td style="text-align:left">7.0</td><td style="text-align:left">验证码存储</td></tr><tr><td style="text-align:left">Spring Boot Starter Mail</td><td style="text-align:left">3.2.0</td><td style="text-align:left">邮件发送</td></tr><tr><td style="text-align:left">Hutool</td><td style="text-align:left">5.8.24</td><td style="text-align:left">验证码生成</td></tr><tr><td style="text-align:left">BCrypt</td><td style="text-align:left">Spring Security Crypto</td><td style="text-align:left">密码加密</td></tr></tbody></table><hr><div class="note blue flat"><p><strong>本章摘要</strong></p><p>在 19.2 中，我们构建了可扩展的认证工厂。但工厂只是框架，还缺少真实的业务实现。本章我们将实现完整的账号密码登录体系，包括：领域模型设计（账号-认证分离）、密码加密（BCrypt）、验证码服务、邮箱激活、用户注册、密码登录、找回密码等核心功能。这是所有上层登录方式的基础，也是最复杂、最容易出错的部分。</p></div><hr><h2 id="19-3-1-传统设计的弊端：单表走天下">19.3.1. 传统设计的弊端：单表走天下</h2><p>在理解现代化的数据建模方案之前，我们需要先理解传统设计的问题。</p><h3 id="单表设计的典型案例">单表设计的典型案例</h3><p>很多初学者在设计用户表时，会采用这种 “看似合理” 的结构：</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><span class="line"><span class="keyword">CREATE TABLE</span> sys_user (</span><br><span class="line">    id <span class="type">BIGINT</span> <span class="keyword">PRIMARY KEY</span> AUTO_INCREMENT,</span><br><span class="line">    username <span class="type">VARCHAR</span>(<span class="number">50</span>) COMMENT <span class="string">&#x27;账号&#x27;</span>,</span><br><span class="line">    password <span class="type">VARCHAR</span>(<span class="number">100</span>) COMMENT <span class="string">&#x27;密码哈希&#x27;</span>,</span><br><span class="line">    mobile <span class="type">VARCHAR</span>(<span class="number">11</span>) COMMENT <span class="string">&#x27;手机号&#x27;</span>,</span><br><span class="line">    email <span class="type">VARCHAR</span>(<span class="number">100</span>) COMMENT <span class="string">&#x27;邮箱&#x27;</span>,</span><br><span class="line">    wechat_openid <span class="type">VARCHAR</span>(<span class="number">64</span>) COMMENT <span class="string">&#x27;微信 OpenID&#x27;</span>,</span><br><span class="line">    github_id <span class="type">VARCHAR</span>(<span class="number">50</span>) COMMENT <span class="string">&#x27;GitHub ID&#x27;</span>,</span><br><span class="line">    nickname <span class="type">VARCHAR</span>(<span class="number">50</span>) COMMENT <span class="string">&#x27;昵称&#x27;</span>,</span><br><span class="line">    avatar <span class="type">VARCHAR</span>(<span class="number">255</span>) COMMENT <span class="string">&#x27;头像&#x27;</span>,</span><br><span class="line">    status TINYINT <span class="keyword">DEFAULT</span> <span class="number">1</span> COMMENT <span class="string">&#x27;状态：0-禁用 1-启用&#x27;</span>,</span><br><span class="line">    create_time DATETIME <span class="keyword">DEFAULT</span> <span class="built_in">CURRENT_TIMESTAMP</span>,</span><br><span class="line">    update_time DATETIME <span class="keyword">DEFAULT</span> <span class="built_in">CURRENT_TIMESTAMP</span> <span class="keyword">ON</span> <span class="keyword">UPDATE</span> <span class="built_in">CURRENT_TIMESTAMP</span></span><br><span class="line">) ENGINE<span class="operator">=</span>InnoDB <span class="keyword">DEFAULT</span> CHARSET<span class="operator">=</span>utf8mb4 COMMENT<span class="operator">=</span><span class="string">&#x27;用户表&#x27;</span>;</span><br></pre></td></tr></table></figure><p><strong>这种设计在业务初期看起来没问题</strong>：</p><ul><li>所有用户信息都在一张表里，查询方便</li><li>不需要关联查询，性能好</li><li>表结构简单，易于理解</li></ul><p>但随着业务发展，这种设计会陷入三个典型陷阱。</p><hr><h3 id="陷阱一：字段爆炸">陷阱一：字段爆炸</h3><p>当需要支持新的登录方式时（如支付宝、抖音、企业微信），你会不断添加新列：</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></pre></td><td class="code"><pre><span class="line"><span class="comment">-- 第一次迭代：支持支付宝登录</span></span><br><span class="line"><span class="keyword">ALTER TABLE</span> sys_user <span class="keyword">ADD</span> <span class="keyword">COLUMN</span> alipay_uid <span class="type">VARCHAR</span>(<span class="number">50</span>) COMMENT <span class="string">&#x27;支付宝 UID&#x27;</span>;</span><br><span class="line"></span><br><span class="line"><span class="comment">-- 第二次迭代：支持抖音登录</span></span><br><span class="line"><span class="keyword">ALTER TABLE</span> sys_user <span class="keyword">ADD</span> <span class="keyword">COLUMN</span> douyin_openid <span class="type">VARCHAR</span>(<span class="number">64</span>) COMMENT <span class="string">&#x27;抖音 OpenID&#x27;</span>;</span><br><span class="line"></span><br><span class="line"><span class="comment">-- 第三次迭代：支持企业微信登录</span></span><br><span class="line"><span class="keyword">ALTER TABLE</span> sys_user <span class="keyword">ADD</span> <span class="keyword">COLUMN</span> work_wechat_userid <span class="type">VARCHAR</span>(<span class="number">50</span>) COMMENT <span class="string">&#x27;企业微信 UserID&#x27;</span>;</span><br><span class="line"></span><br><span class="line"><span class="comment">-- 第四次迭代：支持 Apple 登录</span></span><br><span class="line"><span class="keyword">ALTER TABLE</span> sys_user <span class="keyword">ADD</span> <span class="keyword">COLUMN</span> apple_id <span class="type">VARCHAR</span>(<span class="number">100</span>) COMMENT <span class="string">&#x27;Apple ID&#x27;</span>;</span><br></pre></td></tr></table></figure><p><strong>最终表结构变成了一个 “万金油” 表</strong>，包含几十个字段，其中大部分字段对于单个用户来说都是 <code>NULL</code>。</p><p><strong>数据示例</strong>：</p><table><thead><tr><th style="text-align:left">id</th><th style="text-align:left">username</th><th style="text-align:left">password</th><th style="text-align:left">mobile</th><th style="text-align:left">email</th><th style="text-align:left">wechat_openid</th><th style="text-align:left">github_id</th><th style="text-align:left">alipay_uid</th><th style="text-align:left">douyin_openid</th><th style="text-align:left">…</th></tr></thead><tbody><tr><td style="text-align:left">1</td><td style="text-align:left">admin</td><td style="text-align:left">$2a$ 10…</td><td style="text-align:left">13812345678</td><td style="text-align:left">NULL</td><td style="text-align:left">NULL</td><td style="text-align:left">NULL</td><td style="text-align:left">NULL</td><td style="text-align:left">NULL</td><td style="text-align:left">…</td></tr><tr><td style="text-align:left">2</td><td style="text-align:left">NULL</td><td style="text-align:left">NULL</td><td style="text-align:left">NULL</td><td style="text-align:left"><a href="mailto:user@example.com">user@example.com</a></td><td style="text-align:left">oX4Gt5k…</td><td style="text-align:left">NULL</td><td style="text-align:left">NULL</td><td style="text-align:left">NULL</td><td style="text-align:left">…</td></tr><tr><td style="text-align:left">3</td><td style="text-align:left">NULL</td><td style="text-align:left">NULL</td><td style="text-align:left">NULL</td><td style="text-align:left">NULL</td><td style="text-align:left">NULL</td><td style="text-align:left">12345678</td><td style="text-align:left">NULL</td><td style="text-align:left">NULL</td><td style="text-align:left">…</td></tr></tbody></table><p><strong>问题分析</strong>：</p><ul><li>❌ 每个用户只使用 1-2 种登录方式，但表中有 10+ 个登录字段</li><li>❌ 大量 <code>NULL</code> 值浪费存储空间</li><li>❌ 每次新增登录方式都需要执行 <code>ALTER TABLE</code>，在大表上非常危险</li></ul><hr><h3 id="陷阱二：索引混乱">陷阱二：索引混乱</h3><p>为了支持 “通过手机号登录”、“通过微信 OpenID 登录”，你需要为每个登录字段建立唯一索引：</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></pre></td><td class="code"><pre><span class="line"><span class="keyword">CREATE</span> <span class="keyword">UNIQUE</span> INDEX idx_username <span class="keyword">ON</span> sys_user(username);</span><br><span class="line"><span class="keyword">CREATE</span> <span class="keyword">UNIQUE</span> INDEX idx_mobile <span class="keyword">ON</span> sys_user(mobile);</span><br><span class="line"><span class="keyword">CREATE</span> <span class="keyword">UNIQUE</span> INDEX idx_email <span class="keyword">ON</span> sys_user(email);</span><br><span class="line"><span class="keyword">CREATE</span> <span class="keyword">UNIQUE</span> INDEX idx_wechat_openid <span class="keyword">ON</span> sys_user(wechat_openid);</span><br><span class="line"><span class="keyword">CREATE</span> <span class="keyword">UNIQUE</span> INDEX idx_github_id <span class="keyword">ON</span> sys_user(github_id);</span><br><span class="line"><span class="keyword">CREATE</span> <span class="keyword">UNIQUE</span> INDEX idx_alipay_uid <span class="keyword">ON</span> sys_user(alipay_uid);</span><br><span class="line"><span class="comment">-- ... 更多索引</span></span><br></pre></td></tr></table></figure><p><strong>但这些索引会遇到 <code>NULL</code> 值问题</strong>：</p><p>MySQL 的唯一索引允许多个 <code>NULL</code> 值。例如：</p><ul><li>用户 A 只用微信登录，<code>mobile</code> 字段是 <code>NULL</code></li><li>用户 B 也只用微信登录，<code>mobile</code> 字段也是 <code>NULL</code></li><li>这两个 <code>NULL</code> 值不会触发唯一性冲突</li></ul><p><strong>看起来没问题，但实际上存在隐患</strong>：</p><p>假设用户 A 后来绑定了手机号 <code>13812345678</code>，用户 B 也想绑定同一个手机号。此时：</p><ul><li>如果用户 B 的 <code>mobile</code> 字段是 <code>NULL</code>，绑定会成功（因为 <code>NULL</code> 不参与唯一性检查）</li><li>但如果用户 B 的 <code>mobile</code> 字段已经有值，绑定会失败</li></ul><p><strong>这种不一致的行为会导致严重的业务 Bug</strong>。</p><p><strong>索引膨胀问题</strong>：</p><p>每个唯一索引都会占用存储空间，并且会降低写入性能。当表中有 10+ 个唯一索引时：</p><ul><li>每次 <code>INSERT</code> 都需要检查 10+ 个索引</li><li>每次 <code>UPDATE</code> 都需要更新 10+ 个索引</li><li>索引文件可能比数据文件还大</li></ul><hr><h3 id="陷阱三：查询复杂">陷阱三：查询复杂</h3><p>当用户登录时，你需要根据登录类型执行不同的 SQL：</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></pre></td><td class="code"><pre><span class="line"><span class="comment">// 账号密码登录</span></span><br><span class="line"><span class="type">User</span> <span class="variable">user</span> <span class="operator">=</span> userMapper.selectOne(<span class="keyword">new</span> <span class="title class_">LambdaQueryWrapper</span>&lt;User&gt;()</span><br><span class="line">    .eq(User::getUsername, username));</span><br><span class="line"></span><br><span class="line"><span class="comment">// 手机号登录</span></span><br><span class="line"><span class="type">User</span> <span class="variable">user</span> <span class="operator">=</span> userMapper.selectOne(<span class="keyword">new</span> <span class="title class_">LambdaQueryWrapper</span>&lt;User&gt;()</span><br><span class="line">    .eq(User::getMobile, mobile));</span><br><span class="line"></span><br><span class="line"><span class="comment">// 微信登录</span></span><br><span class="line"><span class="type">User</span> <span class="variable">user</span> <span class="operator">=</span> userMapper.selectOne(<span class="keyword">new</span> <span class="title class_">LambdaQueryWrapper</span>&lt;User&gt;()</span><br><span class="line">    .eq(User::getWechatOpenid, openid));</span><br><span class="line"></span><br><span class="line"><span class="comment">// GitHub 登录</span></span><br><span class="line"><span class="type">User</span> <span class="variable">user</span> <span class="operator">=</span> userMapper.selectOne(<span class="keyword">new</span> <span class="title class_">LambdaQueryWrapper</span>&lt;User&gt;()</span><br><span class="line">    .eq(User::getGithubId, githubId));</span><br><span class="line"></span><br><span class="line"><span class="comment">// 支付宝登录</span></span><br><span class="line"><span class="type">User</span> <span class="variable">user</span> <span class="operator">=</span> userMapper.selectOne(<span class="keyword">new</span> <span class="title class_">LambdaQueryWrapper</span>&lt;User&gt;()</span><br><span class="line">    .eq(User::getAlipayUid, alipayUid));</span><br></pre></td></tr></table></figure><p><strong>代码中充满了 <code>if-else</code> 分支</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></pre></td><td class="code"><pre><span class="line"><span class="keyword">public</span> User <span class="title function_">login</span><span class="params">(String loginType, String identifier, String credential)</span> &#123;</span><br><span class="line">    <span class="type">User</span> <span class="variable">user</span> <span class="operator">=</span> <span class="literal">null</span>;</span><br><span class="line"></span><br><span class="line">    <span class="keyword">if</span> (<span class="string">&quot;username&quot;</span>.equals(loginType)) &#123;</span><br><span class="line">        user = userMapper.selectOne(<span class="keyword">new</span> <span class="title class_">LambdaQueryWrapper</span>&lt;User&gt;()</span><br><span class="line">            .eq(User::getUsername, identifier));</span><br><span class="line">    &#125; <span class="keyword">else</span> <span class="keyword">if</span> (<span class="string">&quot;mobile&quot;</span>.equals(loginType)) &#123;</span><br><span class="line">        user = userMapper.selectOne(<span class="keyword">new</span> <span class="title class_">LambdaQueryWrapper</span>&lt;User&gt;()</span><br><span class="line">            .eq(User::getMobile, identifier));</span><br><span class="line">    &#125; <span class="keyword">else</span> <span class="keyword">if</span> (<span class="string">&quot;wechat&quot;</span>.equals(loginType)) &#123;</span><br><span class="line">        user = userMapper.selectOne(<span class="keyword">new</span> <span class="title class_">LambdaQueryWrapper</span>&lt;User&gt;()</span><br><span class="line">            .eq(User::getWechatOpenid, identifier));</span><br><span class="line">    &#125; <span class="keyword">else</span> <span class="keyword">if</span> (<span class="string">&quot;github&quot;</span>.equals(loginType)) &#123;</span><br><span class="line">        user = userMapper.selectOne(<span class="keyword">new</span> <span class="title class_">LambdaQueryWrapper</span>&lt;User&gt;()</span><br><span class="line">            .eq(User::getGithubId, identifier));</span><br><span class="line">    &#125; <span class="keyword">else</span> &#123;</span><br><span class="line">        <span class="keyword">throw</span> <span class="keyword">new</span> <span class="title class_">RuntimeException</span>(<span class="string">&quot;不支持的登录方式&quot;</span>);</span><br><span class="line">    &#125;</span><br><span class="line"></span><br><span class="line">    <span class="comment">// 验证密码...</span></span><br><span class="line">    <span class="keyword">return</span> user;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p><strong>问题分析</strong>：</p><ul><li>❌ 违反开闭原则：新增登录方式需要修改代码</li><li>❌ 代码重复：每个分支都是类似的查询逻辑</li><li>❌ 难以维护：当登录方式增加到 10+ 种时，代码会变得非常臃肿</li></ul><hr><h3 id="单表设计的根本问题">单表设计的根本问题</h3><p><strong>问题的本质</strong>：将 “用户主体” 和 “登录凭证” 混在一起。</p><p><img src="https://prorise-blog.oss-cn-guangzhou.aliyuncs.com/cover/image-20251229123150728.png" alt="image-20251229123150728"></p><p><strong>正确的做法</strong>：将 “用户主体” 和 “登录凭证” 分离存储。</p><hr><h2 id="19-3-2-现代方案：账号-认证分离模型（1-N）">19.3.2. 现代方案：账号-认证分离模型（1: N）</h2><p>业界成熟的解决方案是将 “用户主体” 与 “登录凭证” 分离存储，建立一对多关系。</p><h3 id="核心设计理念">核心设计理念</h3><p><img src="https://prorise-blog.oss-cn-guangzhou.aliyuncs.com/cover/mermaid-diagram-2025-12-29-123347.png" alt="mermaid-diagram-2025-12-29-123347"></p><p><strong>设计理念</strong>：</p><ul><li><strong>用户主体（sys_user）</strong>：只存储用户的基本属性，不包含任何登录相关的信息</li><li><strong>登录凭证（sys_auth）</strong>：存储用户的所有登录方式，一个用户可以有多条记录</li><li><strong>一对多关系</strong>：通过 <code>user_id</code> 关联</li></ul><hr><h3 id="用户主体表（sys-user）">用户主体表（sys_user）</h3><p>这个表只存储用户的基本属性，不包含任何登录相关的信息。</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></pre></td><td class="code"><pre><span class="line"><span class="keyword">CREATE TABLE</span> sys_user (</span><br><span class="line">    id <span class="type">BIGINT</span> <span class="keyword">PRIMARY KEY</span> COMMENT <span class="string">&#x27;用户 ID（雪花算法生成）&#x27;</span>,</span><br><span class="line">    nickname <span class="type">VARCHAR</span>(<span class="number">50</span>) <span class="keyword">NOT NULL</span> COMMENT <span class="string">&#x27;用户昵称&#x27;</span>,</span><br><span class="line">    avatar <span class="type">VARCHAR</span>(<span class="number">255</span>) <span class="keyword">DEFAULT</span> <span class="string">&#x27;https://i.pravatar.cc/300&#x27;</span> COMMENT <span class="string">&#x27;头像 URL&#x27;</span>,</span><br><span class="line">    status TINYINT <span class="keyword">DEFAULT</span> <span class="number">1</span> COMMENT <span class="string">&#x27;状态：0-禁用 1-启用 2-未激活&#x27;</span>,</span><br><span class="line">    create_time DATETIME <span class="keyword">DEFAULT</span> <span class="built_in">CURRENT_TIMESTAMP</span> COMMENT <span class="string">&#x27;创建时间&#x27;</span>,</span><br><span class="line">    update_time DATETIME <span class="keyword">DEFAULT</span> <span class="built_in">CURRENT_TIMESTAMP</span> <span class="keyword">ON</span> <span class="keyword">UPDATE</span> <span class="built_in">CURRENT_TIMESTAMP</span> COMMENT <span class="string">&#x27;更新时间&#x27;</span>,</span><br><span class="line"></span><br><span class="line">    KEY idx_create_time (create_time)</span><br><span class="line">) ENGINE<span class="operator">=</span>InnoDB <span class="keyword">DEFAULT</span> CHARSET<span class="operator">=</span>utf8mb4 COMMENT<span class="operator">=</span><span class="string">&#x27;用户主体表&#x27;</span>;</span><br></pre></td></tr></table></figure><p><strong>关键设计点</strong>：</p><p><strong>设计点一：ID 生成策略</strong></p><p>使用雪花算法（Snowflake）而非数据库自增 ID，确保分布式环境下的全局唯一性。</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><span class="line"><span class="meta">@TableId(type = IdType.ASSIGN_ID)</span>  <span class="comment">// MyBatis-Plus 自动使用雪花算法</span></span><br><span class="line"><span class="keyword">private</span> Long id;</span><br></pre></td></tr></table></figure><p><strong>为什么不用自增 ID？</strong></p><table><thead><tr><th style="text-align:left">对比维度</th><th style="text-align:left">自增 ID</th><th style="text-align:left">雪花算法</th></tr></thead><tbody><tr><td style="text-align:left"><strong>全局唯一性</strong></td><td style="text-align:left">❌ 只在单表内唯一</td><td style="text-align:left">✅ 全局唯一</td></tr><tr><td style="text-align:left"><strong>分布式支持</strong></td><td style="text-align:left">❌ 多数据库实例会冲突</td><td style="text-align:left">✅ 支持分布式</td></tr><tr><td style="text-align:left"><strong>性能</strong></td><td style="text-align:left">✅ 插入性能好</td><td style="text-align:left">⚠️ 需要额外计算</td></tr><tr><td style="text-align:left"><strong>安全性</strong></td><td style="text-align:left">❌ 可以推测用户数量</td><td style="text-align:left">✅ 无法推测</td></tr></tbody></table><p><strong>设计点二：极简字段</strong></p><p>只保留与业务强相关的字段：</p><ul><li><code>nickname</code>：用户昵称（必填）</li><li><code>avatar</code>：头像 URL（有默认值）</li><li><code>status</code>：账号状态（0-禁用 1-启用 2-未激活）</li></ul><p><strong>不包含的字段</strong>：</p><ul><li>❌ <code>username</code>：属于登录凭证，应该在 sys_auth 表</li><li>❌ <code>password</code>：属于登录凭证，应该在 sys_auth 表</li><li>❌ <code>mobile</code>：属于登录凭证，应该在 sys_auth 表</li><li>❌ <code>email</code>：属于登录凭证，应该在 sys_auth 表</li></ul><p><strong>设计点三：status 字段的三种状态</strong></p><table><thead><tr><th style="text-align:left">状态值</th><th style="text-align:left">含义</th><th style="text-align:left">说明</th></tr></thead><tbody><tr><td style="text-align:left">0</td><td style="text-align:left">禁用</td><td style="text-align:left">管理员手动禁用，无法登录</td></tr><tr><td style="text-align:left">1</td><td style="text-align:left">启用</td><td style="text-align:left">正常状态，可以登录</td></tr><tr><td style="text-align:left">2</td><td style="text-align:left">未激活</td><td style="text-align:left">注册后未激活邮箱，无法登录</td></tr></tbody></table><hr><h3 id="授权凭证表（sys-auth）">授权凭证表（sys_auth）</h3><p>这个表存储用户的所有登录方式。</p><figure class="highlight sql"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">CREATE TABLE</span> sys_auth (</span><br><span class="line">    id <span class="type">BIGINT</span> <span class="keyword">PRIMARY KEY</span> AUTO_INCREMENT COMMENT <span class="string">&#x27;主键 ID&#x27;</span>,</span><br><span class="line">    user_id <span class="type">BIGINT</span> <span class="keyword">NOT NULL</span> COMMENT <span class="string">&#x27;关联的用户 ID&#x27;</span>,</span><br><span class="line">    identity_type <span class="type">VARCHAR</span>(<span class="number">20</span>) <span class="keyword">NOT NULL</span> COMMENT <span class="string">&#x27;认证类型：PASSWORD/MOBILE/WECHAT/GITHUB&#x27;</span>,</span><br><span class="line">    identifier <span class="type">VARCHAR</span>(<span class="number">100</span>) <span class="keyword">NOT NULL</span> COMMENT <span class="string">&#x27;标识符（账号/手机号/OpenID）&#x27;</span>,</span><br><span class="line">    credential <span class="type">VARCHAR</span>(<span class="number">255</span>) COMMENT <span class="string">&#x27;凭证（密码哈希/Token，OAuth 登录可为空）&#x27;</span>,</span><br><span class="line">    verified TINYINT <span class="keyword">DEFAULT</span> <span class="number">0</span> COMMENT <span class="string">&#x27;是否已验证：0-未验证 1-已验证&#x27;</span>,</span><br><span class="line">    create_time DATETIME <span class="keyword">DEFAULT</span> <span class="built_in">CURRENT_TIMESTAMP</span> COMMENT <span class="string">&#x27;创建时间&#x27;</span>,</span><br><span class="line">    update_time DATETIME <span class="keyword">DEFAULT</span> <span class="built_in">CURRENT_TIMESTAMP</span> <span class="keyword">ON</span> <span class="keyword">UPDATE</span> <span class="built_in">CURRENT_TIMESTAMP</span> COMMENT <span class="string">&#x27;更新时间&#x27;</span>,</span><br><span class="line"></span><br><span class="line">    <span class="comment">-- 联合唯一索引：同一类型的标识符不能重复</span></span><br><span class="line">    <span class="keyword">UNIQUE</span> KEY uk_type_identifier (identity_type, identifier),</span><br><span class="line"></span><br><span class="line">    <span class="comment">-- 普通索引：方便根据 user_id 查询该用户的所有登录方式</span></span><br><span class="line">    KEY idx_user_id (user_id)</span><br><span class="line">) ENGINE<span class="operator">=</span>InnoDB <span class="keyword">DEFAULT</span> CHARSET<span class="operator">=</span>utf8mb4 COMMENT<span class="operator">=</span><span class="string">&#x27;用户认证凭证表&#x27;</span>;</span><br></pre></td></tr></table></figure><p><strong>关键设计点</strong>：</p><p><strong>设计点一：identity_type 字段</strong></p><p>这个字段标识登录方式的类型，对应 19.2 中定义的 <code>AuthType</code> 枚举。</p><table><thead><tr><th style="text-align:left">identity_type</th><th style="text-align:left">含义</th><th style="text-align:left">identifier 示例</th><th style="text-align:left">credential 示例</th></tr></thead><tbody><tr><td style="text-align:left">PASSWORD</td><td style="text-align:left">账号密码登录</td><td style="text-align:left">admin</td><td style="text-align:left">2a10… (BCrypt 哈希)</td></tr><tr><td style="text-align:left">MOBILE</td><td style="text-align:left">手机号登录</td><td style="text-align:left">13812345678</td><td style="text-align:left">NULL（验证码登录无需存储密码）</td></tr><tr><td style="text-align:left">EMAIL</td><td style="text-align:left">邮箱登录</td><td style="text-align:left"><a href="mailto:user@example.com">user@example.com</a></td><td style="text-align:left">2a​10… (BCrypt 哈希)</td></tr><tr><td style="text-align:left">WECHAT</td><td style="text-align:left">微信登录</td><td style="text-align:left">oX4Gt5k…</td><td style="text-align:left">NULL（或存储微信 Token）</td></tr><tr><td style="text-align:left">GITHUB</td><td style="text-align:left">GitHub 登录</td><td style="text-align:left">12345678</td><td style="text-align:left">NULL（GitHub ID 本身就是标识符）</td></tr></tbody></table><p><strong>设计点二：identifier 字段</strong></p><p>这个字段存储登录标识符，根据 <code>identity_type</code> 的不同，存储的内容也不同：</p><ul><li><code>PASSWORD</code>：存储用户名（如 <code>admin</code>）</li><li><code>MOBILE</code>：存储手机号（如 <code>13812345678</code>）</li><li><code>EMAIL</code>：存储邮箱（如 <code>user@example.com</code>）</li><li><code>WECHAT</code>：存储微信 OpenID（如 <code>oX4Gt5k...</code>）</li><li><code>GITHUB</code>：存储 GitHub ID（如 <code>12345678</code>）</li></ul><p><strong>设计点三：credential 字段</strong></p><p>这个字段存储登录凭证，根据 <code>identity_type</code> 的不同，存储的内容也不同：</p><ul><li><code>PASSWORD</code>：存储 BCrypt 加密后的密码哈希</li><li><code>EMAIL</code>：存储 BCrypt 加密后的密码哈希</li><li><code>MOBILE</code>：通常为 <code>NULL</code>（验证码登录无需存储密码）</li><li><code>WECHAT</code>：通常为 <code>NULL</code>（或存储微信 Access Token）</li><li><code>GITHUB</code>：通常为 <code>NULL</code>（GitHub ID 本身就是标识符）</li></ul><p><strong>设计点四：verified 字段</strong></p><p>这个字段标识该登录方式是否已验证：</p><ul><li><code>0</code>：未验证（如邮箱未激活、手机号未验证）</li><li><code>1</code>：已验证</li></ul><p><strong>为什么需要这个字段？</strong></p><p>假设用户注册时填写了邮箱，但还没有点击激活链接。此时：</p><ul><li><code>sys_user</code> 表中有一条记录（status = 2 未激活）</li><li><code>sys_auth</code> 表中有一条记录（identity_type = EMAIL, verified = 0）</li></ul><p>当用户点击激活链接后：</p><ul><li><code>sys_user</code> 表的 <code>status</code> 更新为 1（启用）</li><li><code>sys_auth</code> 表的 <code>verified</code> 更新为 1（已验证）</li></ul><hr><h3 id="数据示例：一个用户的多种登录方式">数据示例：一个用户的多种登录方式</h3><p>假设用户 “张三” 先用账号密码注册，后来又绑定了手机号、邮箱、微信和 GitHub。</p><p><strong>sys_user 表</strong>：</p><table><thead><tr><th style="text-align:left">id</th><th style="text-align:left">nickname</th><th style="text-align:left">avatar</th><th style="text-align:left">status</th><th style="text-align:left">create_time</th></tr></thead><tbody><tr><td style="text-align:left">1748392847362</td><td style="text-align:left">张三</td><td style="text-align:left"><a href="https://cdn">https://cdn</a>…/avatar.jpg</td><td style="text-align:left">1</td><td style="text-align:left">2025-01-10 10:00:00</td></tr></tbody></table><p><strong>sys_auth 表</strong>：</p><table><thead><tr><th style="text-align:left">id</th><th style="text-align:left">user_id</th><th style="text-align:left">identity_type</th><th style="text-align:left">identifier</th><th style="text-align:left">credential</th><th style="text-align:left">verified</th><th style="text-align:left">create_time</th></tr></thead><tbody><tr><td style="text-align:left">1</td><td style="text-align:left">1748392847362</td><td style="text-align:left">PASSWORD</td><td style="text-align:left">zhangsan</td><td style="text-align:left">2a​10rQ7R8k…</td><td style="text-align:left">1</td><td style="text-align:left">2025-01-10 10:00:00</td></tr><tr><td style="text-align:left">2</td><td style="text-align:left">1748392847362</td><td style="text-align:left">MOBILE</td><td style="text-align:left">13812345678</td><td style="text-align:left">NULL</td><td style="text-align:left">1</td><td style="text-align:left">2025-01-10 10:05:00</td></tr><tr><td style="text-align:left">3</td><td style="text-align:left">1748392847362</td><td style="text-align:left">EMAIL</td><td style="text-align:left"><a href="mailto:zhangsan@example.com">zhangsan@example.com</a></td><td style="text-align:left">2a10aB3C4d…</td><td style="text-align:left">1</td><td style="text-align:left">2025-01-10 10:10:00</td></tr><tr><td style="text-align:left">4</td><td style="text-align:left">1748392847362</td><td style="text-align:left">WECHAT</td><td style="text-align:left">oX4Gt5k…</td><td style="text-align:left">NULL</td><td style="text-align:left">1</td><td style="text-align:left">2025-01-10 10:15:00</td></tr><tr><td style="text-align:left">5</td><td style="text-align:left">1748392847362</td><td style="text-align:left">GITHUB</td><td style="text-align:left">12345678</td><td style="text-align:left">NULL</td><td style="text-align:left">1</td><td style="text-align:left">2025-01-10 10:20:00</td></tr></tbody></table><p><strong>通过这种设计</strong>：</p><ul><li>✅ 新增登录方式只需在 <code>sys_auth</code> 表插入一条记录，无需修改表结构</li><li>✅ 每种登录方式都有明确的 <code>identity_type</code> 标记，查询时非常清晰</li><li>✅ 用户可以同时拥有多种登录方式，系统会自动关联到同一个 <code>user_id</code></li><li>✅ 没有 <code>NULL</code> 值浪费存储空间</li></ul><hr><h3 id="索引与性能优化策略">索引与性能优化策略</h3><p>数据模型设计完成后，索引设计直接决定了查询性能和数据一致性。</p><p><strong>索引一：联合唯一索引（核心）</strong></p><figure class="highlight sql"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">UNIQUE</span> KEY uk_type_identifier (identity_type, identifier)</span><br></pre></td></tr></table></figure><p><strong>作用</strong>：保证 “同一种类型的标识符全局唯一”。</p><p><strong>示例</strong>：</p><ul><li>手机号 <code>13812345678</code> 只能被一个用户绑定（<code>identity_type=MOBILE, identifier=13812345678</code>）</li><li>微信 OpenID <code>oX4Gt5k...</code> 也只能被一个用户绑定（<code>identity_type=WECHAT, identifier=oX4Gt5k...</code>）</li></ul><p><strong>如果另一个用户尝试绑定已被占用的手机号</strong>，数据库会抛出唯一性冲突错误：</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">Duplicate entry &#x27;MOBILE-13812345678&#x27; for key &#x27;uk_type_identifier&#x27;</span><br></pre></td></tr></table></figure><p>业务代码需要捕获这个异常并返回友好提示：</p><figure class="highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">try</span> &#123;</span><br><span class="line">    authMapper.insert(auth);</span><br><span class="line">&#125; <span class="keyword">catch</span> (DuplicateKeyException e) &#123;</span><br><span class="line">    <span class="keyword">throw</span> <span class="keyword">new</span> <span class="title class_">RuntimeException</span>(<span class="string">&quot;该手机号已被其他账号绑定&quot;</span>);</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p><strong>索引二：普通索引</strong></p><figure class="highlight sql"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">KEY idx_user_id (user_id)</span><br></pre></td></tr></table></figure><p><strong>作用</strong>：方便根据 <code>user_id</code> 查询该用户的所有登录方式。</p><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></pre></td><td class="code"><pre><span class="line"><span class="comment">// 查询用户的所有登录方式</span></span><br><span class="line">List&lt;Auth&gt; authList = authMapper.selectList(<span class="keyword">new</span> <span class="title class_">LambdaQueryWrapper</span>&lt;Auth&gt;()</span><br><span class="line">    .eq(Auth::getUserId, userId));</span><br></pre></td></tr></table></figure><hr><h3 id="本节小结">本节小结</h3><p>我们完成了领域模型的设计。</p><p><strong>核心成果</strong>：</p><table><thead><tr><th style="text-align:left">步骤</th><th style="text-align:left">操作</th><th style="text-align:left">产出</th></tr></thead><tbody><tr><td style="text-align:left">1</td><td style="text-align:left">分析传统单表设计的弊端</td><td style="text-align:left">理解字段爆炸、索引混乱、查询复杂三大陷阱</td></tr><tr><td style="text-align:left">2</td><td style="text-align:left">设计 sys_user 表</td><td style="text-align:left">用户主体表（极简字段）</td></tr><tr><td style="text-align:left">3</td><td style="text-align:left">设计 sys_auth 表</td><td style="text-align:left">登录凭证表（1: N 关系）</td></tr><tr><td style="text-align:left">4</td><td style="text-align:left">设计索引策略</td><td style="text-align:left">联合唯一索引 + 普通索引 + 覆盖索引</td></tr><tr><td style="text-align:left">5</td><td style="text-align:left">讨论分库分表策略</td><td style="text-align:left">千万级用户的扩展方案</td></tr></tbody></table><p><strong>表结构对比</strong>：</p><table><thead><tr><th style="text-align:left">对比维度</th><th style="text-align:left">单表设计</th><th style="text-align:left">账号-认证分离</th></tr></thead><tbody><tr><td style="text-align:left"><strong>表数量</strong></td><td style="text-align:left">1 张表</td><td style="text-align:left">2 张表</td></tr><tr><td style="text-align:left"><strong>字段数量</strong></td><td style="text-align:left">10+ 个字段</td><td style="text-align:left">sys_user: 5 个字段<br/>sys_auth: 7 个字段</td></tr><tr><td style="text-align:left"><strong>NULL 值</strong></td><td style="text-align:left">大量 NULL 值</td><td style="text-align:left">几乎没有 NULL 值</td></tr><tr><td style="text-align:left"><strong>新增登录方式</strong></td><td style="text-align:left">需要 ALTER TABLE</td><td style="text-align:left">只需 INSERT 一条记录</td></tr><tr><td style="text-align:left"><strong>索引数量</strong></td><td style="text-align:left">10+ 个唯一索引</td><td style="text-align:left">1 个联合唯一索引 + 1 个普通索引</td></tr><tr><td style="text-align:left"><strong>查询复杂度</strong></td><td style="text-align:left">需要 if-else 分支</td><td style="text-align:left">统一查询 sys_auth 表</td></tr><tr><td style="text-align:left"><strong>扩展性</strong></td><td style="text-align:left">❌ 难以扩展</td><td style="text-align:left">✅ 易于扩展</td></tr></tbody></table><p><strong>索引速查表</strong>：</p><table><thead><tr><th style="text-align:left">索引名称</th><th style="text-align:left">索引类型</th><th style="text-align:left">索引列</th><th style="text-align:left">作用</th></tr></thead><tbody><tr><td style="text-align:left">uk_type_identifier</td><td style="text-align:left">联合唯一索引</td><td style="text-align:left">(identity_type, identifier)</td><td style="text-align:left">保证同一类型的标识符全局唯一</td></tr><tr><td style="text-align:left">idx_user_id</td><td style="text-align:left">普通索引</td><td style="text-align:left">user_id</td><td style="text-align:left">根据用户 ID 查询所有登录方式</td></tr></tbody></table><p>现在，我们已经完成了数据建模。在下一节中，我们将实现 MyBatis-Plus 的实体类和 Mapper。</p><hr><h2 id="19-3-3-持久层实现：MyBatis-Plus-快速搭建">19.3.3. 持久层实现：MyBatis-Plus 快速搭建</h2><p>在上一节中，我们设计了 sys_user 和 sys_auth 两张表。现在我们需要使用 MyBatis-Plus 快速搭建持久层。</p><h3 id="引入依赖">引入依赖</h3><p><strong>步骤 1：在父 POM 中管理依赖版本</strong></p><p><strong>📄 文件路径</strong>：<code>auth-parent/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></pre></td><td class="code"><pre><span class="line"><span class="tag">&lt;<span class="name">properties</span>&gt;</span></span><br><span class="line">    <span class="tag">&lt;<span class="name">mybatis-plus.version</span>&gt;</span>3.5.7<span class="tag">&lt;/<span class="name">mybatis-plus.version</span>&gt;</span></span><br><span class="line">    <span class="tag">&lt;<span class="name">druid.version</span>&gt;</span>1.2.21<span class="tag">&lt;/<span class="name">druid.version</span>&gt;</span></span><br><span class="line"><span class="tag">&lt;/<span class="name">properties</span>&gt;</span></span><br><span class="line"></span><br><span class="line"><span class="tag">&lt;<span class="name">dependencyManagement</span>&gt;</span></span><br><span class="line">    <span class="tag">&lt;<span class="name">dependencies</span>&gt;</span></span><br><span class="line">        <span class="comment">&lt;!-- MyBatis-Plus --&gt;</span></span><br><span class="line">        <span class="tag">&lt;<span class="name">dependency</span>&gt;</span></span><br><span class="line">            <span class="tag">&lt;<span class="name">groupId</span>&gt;</span>com.baomidou<span class="tag">&lt;/<span class="name">groupId</span>&gt;</span></span><br><span class="line">            <span class="tag">&lt;<span class="name">artifactId</span>&gt;</span>mybatis-plus-spring-boot3-starter<span class="tag">&lt;/<span class="name">artifactId</span>&gt;</span></span><br><span class="line">            <span class="tag">&lt;<span class="name">version</span>&gt;</span>$&#123;mybatis-plus.version&#125;<span class="tag">&lt;/<span class="name">version</span>&gt;</span></span><br><span class="line">        <span class="tag">&lt;/<span class="name">dependency</span>&gt;</span></span><br><span class="line">    <span class="tag">&lt;/<span class="name">dependencies</span>&gt;</span></span><br><span class="line"><span class="tag">&lt;/<span class="name">dependencyManagement</span>&gt;</span></span><br></pre></td></tr></table></figure><p><strong>步骤 2：在 auth-core 模块中引入 MyBatis-Plus</strong></p><p><strong>📄 文件路径</strong>：<code>auth-core/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></pre></td><td class="code"><pre><span class="line"><span class="tag">&lt;<span class="name">dependencies</span>&gt;</span></span><br><span class="line">    <span class="comment">&lt;!-- MyBatis-Plus（只引入 starter，不引入数据库驱动） --&gt;</span></span><br><span class="line">    <span class="tag">&lt;<span class="name">dependency</span>&gt;</span></span><br><span class="line">        <span class="tag">&lt;<span class="name">groupId</span>&gt;</span>com.baomidou<span class="tag">&lt;/<span class="name">groupId</span>&gt;</span></span><br><span class="line">           <span class="tag">&lt;<span class="name">artifactId</span>&gt;</span>mybatis-plus-spring-boot3-starter<span class="tag">&lt;/<span class="name">artifactId</span>&gt;</span></span><br><span class="line">    <span class="tag">&lt;/<span class="name">dependency</span>&gt;</span></span><br><span class="line"><span class="tag">&lt;/<span class="name">dependencies</span>&gt;</span></span><br></pre></td></tr></table></figure><p><strong>步骤 3：在 auth-web 模块中引入数据库驱动</strong></p><p><strong>📄 文件路径</strong>：<code>auth-web/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></pre></td><td class="code"><pre><span class="line"><span class="tag">&lt;<span class="name">dependencies</span>&gt;</span></span><br><span class="line">    <span class="comment">&lt;!-- MySQL 驱动 --&gt;</span></span><br><span class="line">    <span class="tag">&lt;<span class="name">dependency</span>&gt;</span></span><br><span class="line">        <span class="tag">&lt;<span class="name">groupId</span>&gt;</span>com.mysql<span class="tag">&lt;/<span class="name">groupId</span>&gt;</span></span><br><span class="line">        <span class="tag">&lt;<span class="name">artifactId</span>&gt;</span>mysql-connector-j<span class="tag">&lt;/<span class="name">artifactId</span>&gt;</span></span><br><span class="line">        <span class="tag">&lt;<span class="name">scope</span>&gt;</span>runtime<span class="tag">&lt;/<span class="name">scope</span>&gt;</span></span><br><span class="line">    <span class="tag">&lt;/<span class="name">dependency</span>&gt;</span></span><br><span class="line"><span class="tag">&lt;/<span class="name">dependencies</span>&gt;</span></span><br></pre></td></tr></table></figure><hr><h3 id="配置数据源">配置数据源</h3><p><strong>📄 文件路径</strong>：<code>auth-web/src/main/resources/application.yml</code>（追加）</p><figure class="highlight yaml"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><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><span class="line"><span class="attr">server:</span></span><br><span class="line">  <span class="attr">port:</span> <span class="number">8080</span></span><br><span class="line"></span><br><span class="line"><span class="attr">spring:</span></span><br><span class="line">  <span class="attr">application:</span></span><br><span class="line">    <span class="attr">name:</span> <span class="string">auth-service</span></span><br><span class="line"></span><br><span class="line">  <span class="attr">data:</span></span><br><span class="line">    <span class="attr">redis:</span></span><br><span class="line">      <span class="attr">host:</span> <span class="string">localhost</span></span><br><span class="line">      <span class="attr">port:</span> <span class="number">6379</span></span><br><span class="line">      <span class="attr">database:</span> <span class="number">0</span></span><br><span class="line">      <span class="attr">lettuce:</span></span><br><span class="line">        <span class="attr">pool:</span></span><br><span class="line">          <span class="attr">max-active:</span> <span class="number">8</span></span><br><span class="line">          <span class="attr">max-wait:</span> <span class="string">-1ms</span></span><br><span class="line">          <span class="attr">max-idle:</span> <span class="number">8</span></span><br><span class="line">          <span class="attr">min-idle:</span> <span class="number">0</span></span><br><span class="line"></span><br><span class="line">  <span class="attr">datasource:</span></span><br><span class="line">    <span class="attr">driver-class-name:</span> <span class="string">com.mysql.cj.jdbc.Driver</span></span><br><span class="line">    <span class="attr">url:</span> <span class="string">jdbc:mysql://localhost:3306/auth_db?useUnicode=true&amp;characterEncoding=utf8&amp;serverTimezone=Asia/Shanghai&amp;useSSL=false</span></span><br><span class="line">    <span class="attr">username:</span> <span class="string">root</span></span><br><span class="line">    <span class="attr">password:</span> <span class="string">root</span></span><br><span class="line"></span><br><span class="line"><span class="attr">jwt:</span></span><br><span class="line">  <span class="comment"># 对应 JwtProperties.issuer</span></span><br><span class="line">  <span class="attr">issuer:</span> <span class="string">pro-auth-service</span></span><br><span class="line"></span><br><span class="line">  <span class="comment"># 对应 JwtProperties.accessTokenExpireMinutes</span></span><br><span class="line">  <span class="attr">access-token-expire-minutes:</span> <span class="number">15</span></span><br><span class="line"></span><br><span class="line">  <span class="comment"># 对应 JwtProperties.clockSkewSeconds</span></span><br><span class="line">  <span class="attr">clock-skew-seconds:</span> <span class="number">30</span></span><br><span class="line"></span><br><span class="line">  <span class="comment"># Refresh Token 有效期（天）</span></span><br><span class="line">  <span class="attr">refresh-token-expire-days:</span> <span class="number">7</span></span><br><span class="line"></span><br><span class="line">  <span class="comment"># 对应 JwtProperties.publicKeyResource</span></span><br><span class="line">  <span class="attr">public-key-resource:</span> <span class="string">certs/public_key.pem</span></span><br><span class="line"></span><br><span class="line">  <span class="comment"># 对应 JwtProperties.privateKeyResource</span></span><br><span class="line">  <span class="attr">private-key-resource:</span> <span class="string">certs/private_key.pem</span></span><br><span class="line"></span><br><span class="line"><span class="attr">auth:</span></span><br><span class="line">  <span class="comment"># 启用的登录方式</span></span><br><span class="line">  <span class="attr">enabled-types:</span></span><br><span class="line">    <span class="bullet">-</span> <span class="string">PASSWORD</span></span><br><span class="line">    <span class="bullet">-</span> <span class="string">SMS</span></span><br><span class="line">    <span class="comment"># - WECHAT  # 注释掉即可禁用</span></span><br><span class="line"></span><br><span class="line"><span class="comment"># MyBatis-Plus 配置</span></span><br><span class="line"><span class="attr">mybatis-plus:</span></span><br><span class="line">  <span class="attr">configuration:</span></span><br><span class="line">    <span class="comment"># 开启驼峰命名转换</span></span><br><span class="line">    <span class="attr">map-underscore-to-camel-case:</span> <span class="literal">true</span></span><br><span class="line">    <span class="comment"># 打印 SQL 日志（开发环境）</span></span><br><span class="line">    <span class="attr">log-impl:</span> <span class="string">org.apache.ibatis.logging.stdout.StdOutImpl</span></span><br><span class="line">  <span class="attr">global-config:</span></span><br><span class="line">    <span class="attr">db-config:</span></span><br><span class="line">      <span class="comment"># 主键类型：雪花算法</span></span><br><span class="line">      <span class="attr">id-type:</span> <span class="string">ASSIGN_ID</span></span><br></pre></td></tr></table></figure><hr><h3 id="创建数据库和表">创建数据库和表</h3><p><strong>📄 文件路径</strong>：<code>auth-web/src/main/resources/sql/schema.sql</code>（新建）</p><figure class="highlight sql"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><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><span class="line"><span class="comment">-- 创建数据库</span></span><br><span class="line"><span class="keyword">CREATE</span> DATABASE IF <span class="keyword">NOT</span> <span class="keyword">EXISTS</span> auth_db <span class="keyword">DEFAULT</span> <span class="keyword">CHARACTER SET</span> utf8mb4 <span class="keyword">COLLATE</span> utf8mb4_unicode_ci;</span><br><span class="line"></span><br><span class="line">USE auth_db;</span><br><span class="line"></span><br><span class="line"><span class="comment">-- 用户主体表</span></span><br><span class="line"><span class="keyword">CREATE TABLE</span> sys_user (</span><br><span class="line">    id <span class="type">BIGINT</span> <span class="keyword">PRIMARY KEY</span> COMMENT <span class="string">&#x27;用户 ID（雪花算法生成）&#x27;</span>,</span><br><span class="line">    nickname <span class="type">VARCHAR</span>(<span class="number">50</span>) <span class="keyword">NOT NULL</span> COMMENT <span class="string">&#x27;用户昵称&#x27;</span>,</span><br><span class="line">    avatar <span class="type">VARCHAR</span>(<span class="number">255</span>) <span class="keyword">DEFAULT</span> <span class="string">&#x27;https://cdn.example.com/default-avatar.png&#x27;</span> COMMENT <span class="string">&#x27;头像 URL&#x27;</span>,</span><br><span class="line">    status TINYINT <span class="keyword">DEFAULT</span> <span class="number">2</span> COMMENT <span class="string">&#x27;状态：0-禁用 1-启用 2-未激活&#x27;</span>,</span><br><span class="line">    create_time DATETIME <span class="keyword">DEFAULT</span> <span class="built_in">CURRENT_TIMESTAMP</span> COMMENT <span class="string">&#x27;创建时间&#x27;</span>,</span><br><span class="line">    update_time DATETIME <span class="keyword">DEFAULT</span> <span class="built_in">CURRENT_TIMESTAMP</span> <span class="keyword">ON</span> <span class="keyword">UPDATE</span> <span class="built_in">CURRENT_TIMESTAMP</span> COMMENT <span class="string">&#x27;更新时间&#x27;</span>,</span><br><span class="line"></span><br><span class="line">    KEY idx_create_time (create_time)</span><br><span class="line">) ENGINE<span class="operator">=</span>InnoDB <span class="keyword">DEFAULT</span> CHARSET<span class="operator">=</span>utf8mb4 COMMENT<span class="operator">=</span><span class="string">&#x27;用户主体表&#x27;</span>;</span><br><span class="line"></span><br><span class="line"><span class="comment">-- 用户认证凭证表</span></span><br><span class="line"><span class="keyword">CREATE TABLE</span> sys_auth (</span><br><span class="line">    id <span class="type">BIGINT</span> <span class="keyword">PRIMARY KEY</span> AUTO_INCREMENT COMMENT <span class="string">&#x27;主键 ID&#x27;</span>,</span><br><span class="line">    user_id <span class="type">BIGINT</span> <span class="keyword">NOT NULL</span> COMMENT <span class="string">&#x27;关联的用户 ID&#x27;</span>,</span><br><span class="line">    identity_type <span class="type">VARCHAR</span>(<span class="number">20</span>) <span class="keyword">NOT NULL</span> COMMENT <span class="string">&#x27;认证类型：PASSWORD/MOBILE/EMAIL/WECHAT/GITHUB&#x27;</span>,</span><br><span class="line">    identifier <span class="type">VARCHAR</span>(<span class="number">100</span>) <span class="keyword">NOT NULL</span> COMMENT <span class="string">&#x27;标识符（账号/手机号/邮箱/OpenID）&#x27;</span>,</span><br><span class="line">    credential <span class="type">VARCHAR</span>(<span class="number">255</span>) COMMENT <span class="string">&#x27;凭证（密码哈希/Token，OAuth 登录可为空）&#x27;</span>,</span><br><span class="line">    verified TINYINT <span class="keyword">DEFAULT</span> <span class="number">0</span> COMMENT <span class="string">&#x27;是否已验证：0-未验证 1-已验证&#x27;</span>,</span><br><span class="line">    create_time DATETIME <span class="keyword">DEFAULT</span> <span class="built_in">CURRENT_TIMESTAMP</span> COMMENT <span class="string">&#x27;创建时间&#x27;</span>,</span><br><span class="line">    update_time DATETIME <span class="keyword">DEFAULT</span> <span class="built_in">CURRENT_TIMESTAMP</span> <span class="keyword">ON</span> <span class="keyword">UPDATE</span> <span class="built_in">CURRENT_TIMESTAMP</span> COMMENT <span class="string">&#x27;更新时间&#x27;</span>,</span><br><span class="line"></span><br><span class="line">    <span class="keyword">UNIQUE</span> KEY uk_type_identifier (identity_type, identifier),</span><br><span class="line">    KEY idx_user_id (user_id)</span><br><span class="line">) ENGINE<span class="operator">=</span>InnoDB <span class="keyword">DEFAULT</span> CHARSET<span class="operator">=</span>utf8mb4 COMMENT<span class="operator">=</span><span class="string">&#x27;用户认证凭证表&#x27;</span>;</span><br></pre></td></tr></table></figure><hr><h3 id="创建实体类（auth-core-模块）">创建实体类（auth-core 模块）</h3><p><strong>📄 文件路径</strong>：<code>auth-core/src/main/java/com/example/auth/core/entity/User.java</code></p><figure class="highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><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></pre></td><td class="code"><pre><span class="line"><span class="keyword">package</span> com.example.auth.core.entity;</span><br><span class="line"></span><br><span class="line"><span class="keyword">import</span> com.baomidou.mybatisplus.annotation.IdType;</span><br><span class="line"><span class="keyword">import</span> com.baomidou.mybatisplus.annotation.TableId;</span><br><span class="line"><span class="keyword">import</span> com.baomidou.mybatisplus.annotation.TableName;</span><br><span class="line"><span class="keyword">import</span> lombok.Data;</span><br><span class="line"></span><br><span class="line"><span class="keyword">import</span> java.time.LocalDateTime;</span><br><span class="line"></span><br><span class="line"><span class="comment">/**</span></span><br><span class="line"><span class="comment"> * 用户主体实体类</span></span><br><span class="line"><span class="comment"> */</span></span><br><span class="line"><span class="meta">@Data</span></span><br><span class="line"><span class="meta">@TableName(&quot;sys_user&quot;)</span></span><br><span class="line"><span class="keyword">public</span> <span class="keyword">class</span> <span class="title class_">User</span> &#123;</span><br><span class="line"></span><br><span class="line">    <span class="comment">/**</span></span><br><span class="line"><span class="comment">     * 用户 ID（雪花算法生成）</span></span><br><span class="line"><span class="comment">     */</span></span><br><span class="line">    <span class="meta">@TableId(type = IdType.ASSIGN_ID)</span></span><br><span class="line">    <span class="keyword">private</span> Long id;</span><br><span class="line"></span><br><span class="line">    <span class="comment">/**</span></span><br><span class="line"><span class="comment">     * 用户昵称</span></span><br><span class="line"><span class="comment">     */</span></span><br><span class="line">    <span class="keyword">private</span> String nickname;</span><br><span class="line"></span><br><span class="line">    <span class="comment">/**</span></span><br><span class="line"><span class="comment">     * 头像 URL</span></span><br><span class="line"><span class="comment">     */</span></span><br><span class="line">    <span class="keyword">private</span> String avatar;</span><br><span class="line"></span><br><span class="line">    <span class="comment">/**</span></span><br><span class="line"><span class="comment">     * 状态：0-禁用 1-启用 2-未激活</span></span><br><span class="line"><span class="comment">     */</span></span><br><span class="line">    <span class="keyword">private</span> Integer status;</span><br><span class="line"></span><br><span class="line">    <span class="comment">/**</span></span><br><span class="line"><span class="comment">     * 创建时间</span></span><br><span class="line"><span class="comment">     */</span></span><br><span class="line">    <span class="keyword">private</span> LocalDateTime createTime;</span><br><span class="line"></span><br><span class="line">    <span class="comment">/**</span></span><br><span class="line"><span class="comment">     * 更新时间</span></span><br><span class="line"><span class="comment">     */</span></span><br><span class="line">    <span class="keyword">private</span> LocalDateTime updateTime;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p><strong>📄 文件路径</strong>：<code>auth-core/src/main/java/com/example/auth/core/entity/Auth.java</code></p><figure class="highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><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><span class="line"><span class="keyword">package</span> com.example.auth.core.entity;</span><br><span class="line"></span><br><span class="line"><span class="keyword">import</span> com.baomidou.mybatisplus.annotation.IdType;</span><br><span class="line"><span class="keyword">import</span> com.baomidou.mybatisplus.annotation.TableId;</span><br><span class="line"><span class="keyword">import</span> com.baomidou.mybatisplus.annotation.TableName;</span><br><span class="line"><span class="keyword">import</span> lombok.Data;</span><br><span class="line"></span><br><span class="line"><span class="keyword">import</span> java.time.LocalDateTime;</span><br><span class="line"></span><br><span class="line"><span class="comment">/**</span></span><br><span class="line"><span class="comment"> * 用户认证凭证实体类</span></span><br><span class="line"><span class="comment"> */</span></span><br><span class="line"><span class="meta">@Data</span></span><br><span class="line"><span class="meta">@TableName(&quot;sys_auth&quot;)</span></span><br><span class="line"><span class="keyword">public</span> <span class="keyword">class</span> <span class="title class_">Auth</span> &#123;</span><br><span class="line"></span><br><span class="line">    <span class="comment">/**</span></span><br><span class="line"><span class="comment">     * 主键 ID</span></span><br><span class="line"><span class="comment">     */</span></span><br><span class="line">    <span class="meta">@TableId(type = IdType.AUTO)</span></span><br><span class="line">    <span class="keyword">private</span> Long id;</span><br><span class="line"></span><br><span class="line">    <span class="comment">/**</span></span><br><span class="line"><span class="comment">     * 关联的用户 ID</span></span><br><span class="line"><span class="comment">     */</span></span><br><span class="line">    <span class="keyword">private</span> Long userId;</span><br><span class="line"></span><br><span class="line">    <span class="comment">/**</span></span><br><span class="line"><span class="comment">     * 认证类型：PASSWORD/MOBILE/EMAIL/WECHAT/GITHUB</span></span><br><span class="line"><span class="comment">     */</span></span><br><span class="line">    <span class="keyword">private</span> String identityType;</span><br><span class="line"></span><br><span class="line">    <span class="comment">/**</span></span><br><span class="line"><span class="comment">     * 标识符（账号/手机号/邮箱/OpenID）</span></span><br><span class="line"><span class="comment">     */</span></span><br><span class="line">    <span class="keyword">private</span> String identifier;</span><br><span class="line"></span><br><span class="line">    <span class="comment">/**</span></span><br><span class="line"><span class="comment">     * 凭证（密码哈希/Token，OAuth 登录可为空）</span></span><br><span class="line"><span class="comment">     */</span></span><br><span class="line">    <span class="keyword">private</span> String credential;</span><br><span class="line"></span><br><span class="line">    <span class="comment">/**</span></span><br><span class="line"><span class="comment">     * 是否已验证：0-未验证 1-已验证</span></span><br><span class="line"><span class="comment">     */</span></span><br><span class="line">    <span class="keyword">private</span> Integer verified;</span><br><span class="line"></span><br><span class="line">    <span class="comment">/**</span></span><br><span class="line"><span class="comment">     * 创建时间</span></span><br><span class="line"><span class="comment">     */</span></span><br><span class="line">    <span class="keyword">private</span> LocalDateTime createTime;</span><br><span class="line"></span><br><span class="line">    <span class="comment">/**</span></span><br><span class="line"><span class="comment">     * 更新时间</span></span><br><span class="line"><span class="comment">     */</span></span><br><span class="line">    <span class="keyword">private</span> LocalDateTime updateTime;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><hr><h3 id="创建-Mapper-接口（auth-core-模块）">创建 Mapper 接口（auth-core 模块）</h3><p><strong>📄 文件路径</strong>：<code>auth-core/src/main/java/com/example/auth/core/mapper/UserMapper.java</code></p><figure class="highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><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><span class="line"><span class="keyword">package</span> com.example.auth.core.mapper;</span><br><span class="line"></span><br><span class="line"><span class="keyword">import</span> com.baomidou.mybatisplus.core.mapper.BaseMapper;</span><br><span class="line"><span class="keyword">import</span> com.example.auth.core.entity.User;</span><br><span class="line"><span class="keyword">import</span> org.apache.ibatis.annotations.Mapper;</span><br><span class="line"></span><br><span class="line"><span class="comment">/**</span></span><br><span class="line"><span class="comment"> * 用户 Mapper</span></span><br><span class="line"><span class="comment"> */</span></span><br><span class="line"><span class="meta">@Mapper</span></span><br><span class="line"><span class="keyword">public</span> <span class="keyword">interface</span> <span class="title class_">UserMapper</span> <span class="keyword">extends</span> <span class="title class_">BaseMapper</span>&lt;User&gt; &#123;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p><strong>📄 文件路径</strong>：<code>auth-core/src/main/java/com/example/auth/core/mapper/AuthMapper.java</code></p><figure class="highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><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><span class="line"><span class="keyword">package</span> com.example.auth.core.mapper;</span><br><span class="line"></span><br><span class="line"><span class="keyword">import</span> com.baomidou.mybatisplus.core.mapper.BaseMapper;</span><br><span class="line"><span class="keyword">import</span> com.example.auth.core.entity.Auth;</span><br><span class="line"><span class="keyword">import</span> org.apache.ibatis.annotations.Mapper;</span><br><span class="line"></span><br><span class="line"><span class="comment">/**</span></span><br><span class="line"><span class="comment"> * 认证凭证 Mapper</span></span><br><span class="line"><span class="comment"> */</span></span><br><span class="line"><span class="meta">@Mapper</span></span><br><span class="line"><span class="keyword">public</span> <span class="keyword">interface</span> <span class="title class_">AuthMapper</span> <span class="keyword">extends</span> <span class="title class_">BaseMapper</span>&lt;Auth&gt; &#123;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><hr><h3 id="配置-Mapper-扫描（auth-web-模块）">配置 Mapper 扫描（auth-web 模块）</h3><p><strong>📄 文件路径</strong>：<code>auth-web/src/main/java/com/example/auth/web/AuthApplication.java</code>（修改）</p><figure class="highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><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><span class="line"><span class="keyword">package</span> com.example.auth.web;</span><br><span class="line"></span><br><span class="line"><span class="keyword">import</span> org.mybatis.spring.annotation.MapperScan;</span><br><span class="line"><span class="keyword">import</span> org.springframework.boot.SpringApplication;</span><br><span class="line"><span class="keyword">import</span> org.springframework.boot.autoconfigure.SpringBootApplication;</span><br><span class="line"></span><br><span class="line"><span class="meta">@SpringBootApplication(scanBasePackages = &quot;com.example.auth&quot;)</span></span><br><span class="line"><span class="meta">@MapperScan(&quot;com.example.auth.core.mapper&quot;)</span></span><br><span class="line"><span class="keyword">public</span> <span class="keyword">class</span> <span class="title class_">AuthApplication</span> &#123;</span><br><span class="line"></span><br><span class="line">    <span class="keyword">public</span> <span class="keyword">static</span> <span class="keyword">void</span> <span class="title function_">main</span><span class="params">(String[] args)</span> &#123;</span><br><span class="line">        SpringApplication.run(AuthApplication.class, args);</span><br><span class="line">    &#125;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><hr><h3 id="测试持久层">测试持久层</h3><p><strong>📄 文件路径</strong>：<code>auth-web/src/test/java/com/example/auth/web/mapper/UserMapperTest.java</code></p><figure class="highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><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><span class="line"><span class="keyword">package</span> com.example.auth.web.mapper;</span><br><span class="line"></span><br><span class="line"><span class="keyword">import</span> com.example.auth.core.entity.User;</span><br><span class="line"><span class="keyword">import</span> com.example.auth.core.mapper.UserMapper;</span><br><span class="line"><span class="keyword">import</span> org.junit.jupiter.api.Test;</span><br><span class="line"><span class="keyword">import</span> org.springframework.beans.factory.annotation.Autowired;</span><br><span class="line"><span class="keyword">import</span> org.springframework.boot.test.context.SpringBootTest;</span><br><span class="line"></span><br><span class="line"><span class="meta">@SpringBootTest</span></span><br><span class="line"><span class="keyword">public</span> <span class="keyword">class</span> <span class="title class_">UserMapperTest</span> &#123;</span><br><span class="line"></span><br><span class="line">    <span class="meta">@Autowired</span></span><br><span class="line">    <span class="keyword">private</span> UserMapper userMapper;</span><br><span class="line"></span><br><span class="line">    <span class="meta">@Test</span></span><br><span class="line">    <span class="keyword">public</span> <span class="keyword">void</span> <span class="title function_">testInsert</span><span class="params">()</span> &#123;</span><br><span class="line">        <span class="type">User</span> <span class="variable">user</span> <span class="operator">=</span> <span class="keyword">new</span> <span class="title class_">User</span>();</span><br><span class="line">        user.setNickname(<span class="string">&quot;测试用户&quot;</span>);</span><br><span class="line">        user.setAvatar(<span class="string">&quot;https://cdn.example.com/avatar.jpg&quot;</span>);</span><br><span class="line">        user.setStatus(<span class="number">1</span>);</span><br><span class="line"></span><br><span class="line">        userMapper.insert(user);</span><br><span class="line">        System.out.println(<span class="string">&quot;插入成功，用户 ID: &quot;</span> + user.getId());</span><br><span class="line">    &#125;</span><br><span class="line"></span><br><span class="line">    <span class="meta">@Test</span></span><br><span class="line">    <span class="keyword">public</span> <span class="keyword">void</span> <span class="title function_">testSelect</span><span class="params">()</span> &#123;</span><br><span class="line">        <span class="type">User</span> <span class="variable">user</span> <span class="operator">=</span> userMapper.selectById(<span class="number">1L</span>);</span><br><span class="line">        System.out.println(<span class="string">&quot;查询结果: &quot;</span> + user);</span><br><span class="line">    &#125;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><hr><h2 id="19-3-4-密码加密：从-MD5-到-BCrypt-的演进">19.3.4. 密码加密：从 MD5 到 BCrypt 的演进</h2><p>在上一节中，我们完成了持久层的搭建。现在我们需要实现密码加密功能。</p><h3 id="为什么不能用-MD5？">为什么不能用 MD5？</h3><p>很多初学者在实现密码加密时，会使用 MD5：</p><figure class="highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line"><span class="type">String</span> <span class="variable">password</span> <span class="operator">=</span> <span class="string">&quot;123456&quot;</span>;</span><br><span class="line"><span class="type">String</span> <span class="variable">md5Hash</span> <span class="operator">=</span> DigestUtils.md5Hex(password);</span><br><span class="line"><span class="comment">// 输出: e10adc3949ba59abbe56e057f20f883e</span></span><br></pre></td></tr></table></figure><p><strong>这种做法是极其危险的</strong>，原因有三：</p><p><strong>原因一：MD5 是哈希算法，不是加密算法</strong></p><ul><li><strong>哈希算法</strong>：单向函数，无法解密</li><li><strong>加密算法</strong>：双向函数，可以解密</li></ul><p>MD5 的设计目的是 <strong>数据完整性校验</strong>，而不是密码存储。</p><p><strong>原因二：彩虹表攻击</strong></p><p>彩虹表（Rainbow Table）是一个预先计算好的哈希值数据库。攻击者可以通过查表的方式，快速破解 MD5 哈希。</p><p><strong>示例</strong>：</p><p>假设数据库泄漏，攻击者获取到以下数据：</p><table><thead><tr><th style="text-align:left">username</th><th style="text-align:left">password_md5</th></tr></thead><tbody><tr><td style="text-align:left">admin</td><td style="text-align:left">e10adc3949ba59abbe56e057f20f883e</td></tr><tr><td style="text-align:left">user1</td><td style="text-align:left">5f4dcc3b5aa765d61d8327deb882cf99</td></tr></tbody></table><p>攻击者只需要在彩虹表中查询这两个哈希值：</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line">e10adc3949ba59abbe56e057f20f883e -&gt; 123456</span><br><span class="line">5f4dcc3b5aa765d61d8327deb882cf99 -&gt; password</span><br></pre></td></tr></table></figure><p><strong>几秒钟内就能破解所有密码</strong>。</p><p><strong>原因三：MD5 已被破解</strong></p><p>2004 年，中国密码学家王小云教授证明了 MD5 存在碰撞漏洞。2017 年，Google 成功构造了两个不同的 PDF 文件，它们的 MD5 哈希值完全相同。</p><p>即给定消息 M1，能够计算获取 M2，使得 M2 产生的散列值与 M1 产生的散列值相同。如此，MD5 的抗碰撞性就已经不满足了，使得 MD5 不再是安全的散列算法。这样一来，MD5 用于数字签名将存在严重问题，因为可以篡改原始消息，而生成相同的 Hash 值。</p><p>这里，简单地用王教授的碰撞法给大家举个简单的例子。假如用户 A 给 B 写了个 Email 内容为 Hello，然后通过王教授的碰撞法，可能得到 Fuck 这个字符串的摘要信息和 Hello 这个字符串产生的摘要信息是一样的。如果 B 收到的 Email 内容为 Fuck，经过 MD5 计算后的，B 也将认为 Email 并没有被修改！但事实并非如此。</p><p>王小云院士的研究报告表明，MD4，MD5，HAVAL-128 和 RIPEMD 均已被证实存在上面的漏洞，即给定消息 M1，能够找到不同消息 M2 产生相同的散列值，即产生 Hash 碰撞。</p><p>后来在 2005 年，王小云同其他研究人员又发布了一篇论文《Finding Collisions in the Full SHA-1》，理论上证明了 SHA-1 也同样存在碰撞的漏洞。</p><p>随着时间的推移，计算机计算能力不断增强和攻击技术的不断进步，SHA-1 算法的安全性逐渐受到威胁。在 2017 年，Google 研究人员宣布成功生成了第一个实际的 SHA-1 碰撞，这意味着攻击者可以通过特定的方法找到两个不同的输入，但它们具有相同的 SHA-1 哈希值。</p><p><strong>结论</strong>：MD5 已经不安全，不应该用于密码存储。</p><hr><h3 id="加盐（Salt）能解决问题吗？">加盐（Salt）能解决问题吗？</h3><p>有些开发者会在 MD5 的基础上加盐：</p><figure class="highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br></pre></td><td class="code"><pre><span class="line"><span class="type">String</span> <span class="variable">password</span> <span class="operator">=</span> <span class="string">&quot;123456&quot;</span>;</span><br><span class="line"><span class="type">String</span> <span class="variable">salt</span> <span class="operator">=</span> <span class="string">&quot;random_salt_123&quot;</span>;</span><br><span class="line"><span class="type">String</span> <span class="variable">saltedPassword</span> <span class="operator">=</span> password + salt;</span><br><span class="line"><span class="type">String</span> <span class="variable">md5Hash</span> <span class="operator">=</span> DigestUtils.md5Hex(saltedPassword);</span><br><span class="line"><span class="comment">// 输出: 7c6a180b36896a0a8c02787eeafb0e4c</span></span><br></pre></td></tr></table></figure><p><strong>这种做法比纯 MD5 好一些</strong>，但仍然存在问题：</p><p><strong>问题一：盐值存储</strong></p><p>盐值必须存储在数据库中，否则无法验证密码。如果数据库泄漏，攻击者可以获取盐值，然后针对每个用户生成专属的彩虹表。</p><p><strong>问题二：盐值固定</strong></p><p>如果所有用户使用相同的盐值，攻击者只需要生成一次彩虹表，就能破解所有密码。</p><p><strong>问题三：计算速度太快</strong></p><p>MD5 的计算速度非常快，攻击者可以使用 GPU 进行暴力破解。现代 GPU 每秒可以计算数十亿次 MD5 哈希。</p><hr><h3 id="BCrypt-的三大优势">BCrypt 的三大优势</h3><p>BCrypt 是一种专门为密码存储设计的哈希算法，它解决了 MD5 的所有问题。</p><p><strong>优势一：自动加盐</strong></p><p>BCrypt 会自动生成随机盐值，并将盐值嵌入到哈希值中。</p><figure class="highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line"><span class="type">String</span> <span class="variable">password</span> <span class="operator">=</span> <span class="string">&quot;123456&quot;</span>;</span><br><span class="line"><span class="type">String</span> <span class="variable">bcryptHash</span> <span class="operator">=</span> BCrypt.hashpw(password, BCrypt.gensalt());</span><br><span class="line"><span class="comment">// 输出: $2a$ 10$rQ7R8kN5J6X7Z8Y9W0V1U.2Q3R4S5T6U7V8W9X0Y1Z2A3B4C5D6E7F</span></span><br></pre></td></tr></table></figure><p><strong>哈希值的结构</strong>：</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></pre></td><td class="code"><pre><span class="line">$2a$10$rQ7R8kN5J6X7Z8Y9W0V1U.2Q3R4S5T6U7V8W9X0Y1Z2A3B4C5D6E7F</span><br><span class="line"> |   |  |                    |</span><br><span class="line"> |   |  |                    +-- 哈希值（31 字符）</span><br><span class="line"> |   |  +-- 盐值（22 字符）</span><br><span class="line"> |   +-- 成本因子（10）</span><br><span class="line"> +-- 算法版本（2a）</span><br></pre></td></tr></table></figure><p><strong>关键点</strong>：盐值和哈希值存储在一起，不需要单独存储盐值。</p><p><strong>优势二：慢哈希（Slow Hash）</strong></p><p>BCrypt 的计算速度非常慢，这是故意设计的。</p><figure class="highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">// MD5：每秒可以计算数十亿次</span></span><br><span class="line"><span class="type">String</span> <span class="variable">md5Hash</span> <span class="operator">=</span> DigestUtils.md5Hex(<span class="string">&quot;123456&quot;</span>);</span><br><span class="line"></span><br><span class="line"><span class="comment">// BCrypt：每秒只能计算几十次</span></span><br><span class="line"><span class="type">String</span> <span class="variable">bcryptHash</span> <span class="operator">=</span> BCrypt.hashpw(<span class="string">&quot;123456&quot;</span>, BCrypt.gensalt(<span class="number">10</span>));</span><br></pre></td></tr></table></figure><p><strong>为什么要慢？</strong></p><ul><li>对于正常用户：登录时只需要计算一次，慢 0.1 秒完全可以接受</li><li>对于攻击者：暴力破解需要计算数百万次，慢 0.1 秒意味着破解时间从几小时变成几年</li></ul><p><strong>优势三：自适应（Adaptive）</strong></p><p>BCrypt 的成本因子（Cost Factor）可以调整，随着硬件性能的提升，可以增加成本因子，保持相同的安全性。</p><figure class="highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">// 成本因子 10：每次哈希需要 2^10 = 1024 次迭代</span></span><br><span class="line"><span class="type">String</span> <span class="variable">hash10</span> <span class="operator">=</span> BCrypt.hashpw(<span class="string">&quot;123456&quot;</span>, BCrypt.gensalt(<span class="number">10</span>));</span><br><span class="line"></span><br><span class="line"><span class="comment">// 成本因子 12：每次哈希需要 2^12 = 4096 次迭代</span></span><br><span class="line"><span class="type">String</span> <span class="variable">hash12</span> <span class="operator">=</span> BCrypt.hashpw(<span class="string">&quot;123456&quot;</span>, BCrypt.gensalt(<span class="number">12</span>));</span><br></pre></td></tr></table></figure><p><strong>成本因子对照表</strong>：</p><table><thead><tr><th style="text-align:left">成本因子</th><th style="text-align:left">迭代次数</th><th style="text-align:left">计算时间（单核）</th><th style="text-align:left">适用场景</th></tr></thead><tbody><tr><td style="text-align:left">10</td><td style="text-align:left">1024</td><td style="text-align:left">~0.1 秒</td><td style="text-align:left">开发环境</td></tr><tr><td style="text-align:left">12</td><td style="text-align:left">4096</td><td style="text-align:left">~0.4 秒</td><td style="text-align:left">生产环境（推荐）</td></tr><tr><td style="text-align:left">14</td><td style="text-align:left">16384</td><td style="text-align:left">~1.6 秒</td><td style="text-align:left">高安全场景</td></tr><tr><td style="text-align:left">16</td><td style="text-align:left">65536</td><td style="text-align:left">~6.4 秒</td><td style="text-align:left">极高安全场景</td></tr></tbody></table><p><strong>建议</strong>：生产环境使用成本因子 12。</p><hr><h3 id="BCrypt-实战">BCrypt 实战</h3><p><strong>引入依赖</strong>：</p><p><strong>📄 文件路径</strong>：<code>auth-core/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></pre></td><td class="code"><pre><span class="line"><span class="comment">&lt;!-- Spring Security Crypto（包含 BCrypt） --&gt;</span></span><br><span class="line"><span class="tag">&lt;<span class="name">dependency</span>&gt;</span></span><br><span class="line">    <span class="tag">&lt;<span class="name">groupId</span>&gt;</span>org.springframework.security<span class="tag">&lt;/<span class="name">groupId</span>&gt;</span></span><br><span class="line">    <span class="tag">&lt;<span class="name">artifactId</span>&gt;</span>spring-security-crypto<span class="tag">&lt;/<span class="name">artifactId</span>&gt;</span></span><br><span class="line"><span class="tag">&lt;/<span class="name">dependency</span>&gt;</span></span><br></pre></td></tr></table></figure><p><strong>封装 PasswordEncoder 工具类</strong>：</p><p><strong>📄 文件路径</strong>：<code>auth-core/src/main/java/com/example/auth/core/util/PasswordEncoder.java</code></p><figure class="highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><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><span class="line"><span class="keyword">package</span> com.example.auth.core.util;</span><br><span class="line"></span><br><span class="line"><span class="keyword">import</span> org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;</span><br><span class="line"><span class="keyword">import</span> org.springframework.stereotype.Component;</span><br><span class="line"></span><br><span class="line"><span class="comment">/**</span></span><br><span class="line"><span class="comment"> * 密码加密工具类</span></span><br><span class="line"><span class="comment"> * 封装 BCrypt 算法</span></span><br><span class="line"><span class="comment"> */</span></span><br><span class="line"><span class="meta">@Component</span></span><br><span class="line"><span class="keyword">public</span> <span class="keyword">class</span> <span class="title class_">PasswordEncoder</span> &#123;</span><br><span class="line"></span><br><span class="line">    <span class="keyword">private</span> <span class="keyword">final</span> <span class="type">BCryptPasswordEncoder</span> <span class="variable">encoder</span> <span class="operator">=</span> <span class="keyword">new</span> <span class="title class_">BCryptPasswordEncoder</span>(<span class="number">12</span>);</span><br><span class="line"></span><br><span class="line">    <span class="comment">/**</span></span><br><span class="line"><span class="comment">     * 加密密码</span></span><br><span class="line"><span class="comment">     *</span></span><br><span class="line"><span class="comment">     * <span class="doctag">@param</span> rawPassword 明文密码</span></span><br><span class="line"><span class="comment">     * <span class="doctag">@return</span> BCrypt 哈希值</span></span><br><span class="line"><span class="comment">     */</span></span><br><span class="line">    <span class="keyword">public</span> String <span class="title function_">encode</span><span class="params">(String rawPassword)</span> &#123;</span><br><span class="line">        <span class="keyword">return</span> encoder.encode(rawPassword);</span><br><span class="line">    &#125;</span><br><span class="line"></span><br><span class="line">    <span class="comment">/**</span></span><br><span class="line"><span class="comment">     * 验证密码</span></span><br><span class="line"><span class="comment">     *</span></span><br><span class="line"><span class="comment">     * <span class="doctag">@param</span> rawPassword     明文密码</span></span><br><span class="line"><span class="comment">     * <span class="doctag">@param</span> encodedPassword BCrypt 哈希值</span></span><br><span class="line"><span class="comment">     * <span class="doctag">@return</span> true 表示密码正确，false 表示密码错误</span></span><br><span class="line"><span class="comment">     */</span></span><br><span class="line">    <span class="keyword">public</span> <span class="type">boolean</span> <span class="title function_">matches</span><span class="params">(String rawPassword, String encodedPassword)</span> &#123;</span><br><span class="line">        <span class="keyword">return</span> encoder.matches(rawPassword, encodedPassword);</span><br><span class="line">    &#125;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p><strong>测试 BCrypt</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></pre></td><td class="code"><pre><span class="line"><span class="meta">@Test</span></span><br><span class="line"><span class="keyword">public</span> <span class="keyword">void</span> <span class="title function_">testBCrypt</span><span class="params">()</span> &#123;</span><br><span class="line">    <span class="type">PasswordEncoder</span> <span class="variable">encoder</span> <span class="operator">=</span> <span class="keyword">new</span> <span class="title class_">PasswordEncoder</span>();</span><br><span class="line"></span><br><span class="line">    <span class="comment">// 加密密码</span></span><br><span class="line">    <span class="type">String</span> <span class="variable">rawPassword</span> <span class="operator">=</span> <span class="string">&quot;123456&quot;</span>;</span><br><span class="line">    <span class="type">String</span> <span class="variable">hash1</span> <span class="operator">=</span> encoder.encode(rawPassword);</span><br><span class="line">    <span class="type">String</span> <span class="variable">hash2</span> <span class="operator">=</span> encoder.encode(rawPassword);</span><br><span class="line"></span><br><span class="line">    System.out.println(<span class="string">&quot;哈希值 1: &quot;</span> + hash1);</span><br><span class="line">    System.out.println(<span class="string">&quot;哈希值 2: &quot;</span> + hash2);</span><br><span class="line"></span><br><span class="line">    <span class="comment">// 验证密码</span></span><br><span class="line">    System.out.println(<span class="string">&quot;验证结果 1: &quot;</span> + encoder.matches(rawPassword, hash1));</span><br><span class="line">    System.out.println(<span class="string">&quot;验证结果 2: &quot;</span> + encoder.matches(rawPassword, hash2));</span><br><span class="line">    System.out.println(<span class="string">&quot;验证错误密码: &quot;</span> + encoder.matches(<span class="string">&quot;wrong&quot;</span>, hash1));</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p><strong>输出</strong>：</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></pre></td><td class="code"><pre><span class="line">哈希值 1: $2a$12$rQ7R8kN5J6X7Z8Y9W0V1U.2Q3R4S5T6U7V8W9X0Y1Z2A3B4C5D6E7F</span><br><span class="line">哈希值 2: $2a$12$aB3C4d5E6f7G8h9I0j1K2.3L4M5N6o7P8q9R0s1T2u3V4w5X6y7Z8</span><br><span class="line">验证结果 1: true</span><br><span class="line">验证结果 2: true</span><br><span class="line">验证错误密码: false</span><br></pre></td></tr></table></figure><p><strong>关键点</strong>：</p><ul><li>同一个密码，每次加密的哈希值都不同（因为盐值不同）</li><li>但验证时都能通过（因为盐值嵌入在哈希值中）</li></ul><hr><h2 id="19-3-5-验证码服务：防止爬虫批量注册">19.3.5. 验证码服务：防止爬虫批量注册</h2><p>在上一节中，我们实现了密码加密功能。现在我们需要实现验证码服务，防止爬虫批量注册。</p><h3 id="为什么需要验证码？">为什么需要验证码？</h3><p><strong>场景一：防止爬虫批量注册</strong></p><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></pre></td><td class="code"><pre><span class="line"><span class="keyword">import</span> requests</span><br><span class="line"></span><br><span class="line"><span class="keyword">for</span> i <span class="keyword">in</span> <span class="built_in">range</span>(<span class="number">10000</span>):</span><br><span class="line">    requests.post(<span class="string">&#x27;http://localhost:8080/auth/register&#x27;</span>, json=&#123;</span><br><span class="line">        <span class="string">&#x27;username&#x27;</span>: <span class="string">f&#x27;user<span class="subst">&#123;i&#125;</span>&#x27;</span>,</span><br><span class="line">        <span class="string">&#x27;password&#x27;</span>: <span class="string">&#x27;123456&#x27;</span>,</span><br><span class="line">        <span class="string">&#x27;email&#x27;</span>: <span class="string">f&#x27;user<span class="subst">&#123;i&#125;</span>@example.com&#x27;</span></span><br><span class="line">    &#125;)</span><br></pre></td></tr></table></figure><p><strong>几分钟内就能注册数万个账号</strong>，导致：</p><ul><li>数据库被垃圾数据填满</li><li>邮件服务器被大量激活邮件占用</li><li>正常用户无法注册（用户名被占用）</li></ul><p><strong>场景二：防止暴力破解登录</strong></p><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></pre></td><td class="code"><pre><span class="line"><span class="keyword">import</span> requests</span><br><span class="line"></span><br><span class="line">passwords = [<span class="string">&#x27;123456&#x27;</span>, <span class="string">&#x27;password&#x27;</span>, <span class="string">&#x27;123456789&#x27;</span>, ...]</span><br><span class="line"></span><br><span class="line"><span class="keyword">for</span> password <span class="keyword">in</span> passwords:</span><br><span class="line">    response = requests.post(<span class="string">&#x27;http://localhost:8080/auth/login&#x27;</span>, json=&#123;</span><br><span class="line">        <span class="string">&#x27;authType&#x27;</span>: <span class="string">&#x27;PASSWORD&#x27;</span>,</span><br><span class="line">        <span class="string">&#x27;username&#x27;</span>: <span class="string">&#x27;admin&#x27;</span>,</span><br><span class="line">        <span class="string">&#x27;password&#x27;</span>: password</span><br><span class="line">    &#125;)</span><br><span class="line">    <span class="keyword">if</span> response.status_code == <span class="number">200</span>:</span><br><span class="line">        <span class="built_in">print</span>(<span class="string">f&#x27;密码破解成功: <span class="subst">&#123;password&#125;</span>&#x27;</span>)</span><br><span class="line">        <span class="keyword">break</span></span><br></pre></td></tr></table></figure><p><strong>验证码的作用</strong>：</p><ul><li>✅ 增加自动化攻击的成本</li><li>✅ 区分人类用户和机器人</li><li>✅ 保护系统资源</li></ul><hr><h3 id="验证码类型对比">验证码类型对比</h3><p>没问题，这是精简后的版本，只保留了目前 <strong>最主流的 4 种</strong> 验证码类型，方便你直接放入文档：</p><h3 id="常见验证码类型对比">常见验证码类型对比</h3><table><thead><tr><th>验证码类型</th><th>安全性</th><th>用户体验</th><th>核心适用场景</th></tr></thead><tbody><tr><td><strong>图形验证码</strong></td><td>⭐</td><td>⭐⭐</td><td>简单的后台系统、低频操作</td></tr><tr><td><strong>滑动验证码</strong></td><td>⭐⭐⭐</td><td>⭐⭐⭐⭐</td><td>网站登录、注册（目前的行业标准）</td></tr><tr><td><strong>点选验证码</strong></td><td>⭐⭐⭐⭐</td><td>⭐</td><td>支付确认、高风险拦截（如汉字/图标顺序点选）</td></tr><tr><td><strong>无感验证</strong></td><td>⭐⭐⭐⭐⭐</td><td>⭐⭐⭐⭐⭐</td><td>全场景防护（通过鼠标轨迹/设备指纹后台判定）</td></tr></tbody></table><p>本章只实现最基础的图形验证码。随着安全需求的升级，现代应用通常采用更智能的验证方式。如果你需要进阶方案，请参考以下文章：</p><p><strong>《滑动拼图验证》</strong>：详解前端 Canvas 抠图与后端坐标校验逻辑。</p><p><strong>《点选/旋转验证》</strong>：应对 OCR 破解的高安全方案。</p><p><a href="https://prorise666.site/posts/69042.html">Spring Boot 3 滑动/旋转/滑动还原/文字点选验证码集成：Tianai-Captcha 快速接入指南 | Prorise - 博客小栈</a></p><p><strong>《无感/行为验证》</strong>：基于设备指纹与生物探针的智能人机识别（接入云盾/极验）。</p><hr><h3 id="图形验证码生成">图形验证码生成</h3><p><strong>引入依赖</strong>：</p><p>Hutool 提供了封装好的验证码服务，我们仅需要转化为业务功能即可。</p><p><strong>步骤 1：在父 POM 中管理 Hutool 版本</strong></p><p><strong>📄 文件路径</strong>：<code>auth-parent/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></pre></td><td class="code"><pre><span class="line"><span class="tag">&lt;<span class="name">properties</span>&gt;</span></span><br><span class="line">    <span class="tag">&lt;<span class="name">hutool.version</span>&gt;</span>5.8.24<span class="tag">&lt;/<span class="name">hutool.version</span>&gt;</span></span><br><span class="line"><span class="tag">&lt;/<span class="name">properties</span>&gt;</span></span><br><span class="line"></span><br><span class="line"><span class="tag">&lt;<span class="name">dependencyManagement</span>&gt;</span></span><br><span class="line">    <span class="tag">&lt;<span class="name">dependencies</span>&gt;</span></span><br><span class="line">        <span class="comment">&lt;!-- Hutool 工具类 --&gt;</span></span><br><span class="line">        <span class="tag">&lt;<span class="name">dependency</span>&gt;</span></span><br><span class="line">            <span class="tag">&lt;<span class="name">groupId</span>&gt;</span>cn.hutool<span class="tag">&lt;/<span class="name">groupId</span>&gt;</span></span><br><span class="line">            <span class="tag">&lt;<span class="name">artifactId</span>&gt;</span>hutool-all<span class="tag">&lt;/<span class="name">artifactId</span>&gt;</span></span><br><span class="line">            <span class="tag">&lt;<span class="name">version</span>&gt;</span>$&#123;hutool.version&#125;<span class="tag">&lt;/<span class="name">version</span>&gt;</span></span><br><span class="line">        <span class="tag">&lt;/<span class="name">dependency</span>&gt;</span></span><br><span class="line">    <span class="tag">&lt;/<span class="name">dependencies</span>&gt;</span></span><br><span class="line"><span class="tag">&lt;/<span class="name">dependencyManagement</span>&gt;</span></span><br></pre></td></tr></table></figure><p><strong>步骤 2：在 auth-core 模块中引入 Hutool</strong></p><p><strong>📄 文件路径</strong>：<code>auth-core/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></pre></td><td class="code"><pre><span class="line"><span class="tag">&lt;<span class="name">dependencies</span>&gt;</span></span><br><span class="line">    <span class="comment">&lt;!-- Hutool（包含验证码生成） --&gt;</span></span><br><span class="line">    <span class="tag">&lt;<span class="name">dependency</span>&gt;</span></span><br><span class="line">        <span class="tag">&lt;<span class="name">groupId</span>&gt;</span>cn.hutool<span class="tag">&lt;/<span class="name">groupId</span>&gt;</span></span><br><span class="line">        <span class="tag">&lt;<span class="name">artifactId</span>&gt;</span>hutool-all<span class="tag">&lt;/<span class="name">artifactId</span>&gt;</span></span><br><span class="line">    <span class="tag">&lt;/<span class="name">dependency</span>&gt;</span></span><br><span class="line"><span class="tag">&lt;/<span class="name">dependencies</span>&gt;</span></span><br></pre></td></tr></table></figure><hr><p><strong>定义 Redis Key 常量</strong>：</p><p><strong>📄 文件路径</strong>：<code>auth-core/src/main/java/com/example/auth/core/constant/RedisKeyConstants.java</code>（追加）</p><figure class="highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><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><span class="line"><span class="keyword">package</span> com.example.auth.core.constant;</span><br><span class="line"></span><br><span class="line"><span class="comment">/**</span></span><br><span class="line"><span class="comment"> * Redis Key 常量类</span></span><br><span class="line"><span class="comment"> */</span></span><br><span class="line"><span class="keyword">public</span> <span class="keyword">class</span> <span class="title class_">RedisKeyConstants</span> &#123;</span><br><span class="line">    <span class="comment">/**</span></span><br><span class="line"><span class="comment">     * 验证码相关 Key 前缀</span></span><br><span class="line"><span class="comment">     */</span></span><br><span class="line">    <span class="keyword">public</span> <span class="keyword">static</span> <span class="keyword">final</span> <span class="type">String</span> <span class="variable">CAPTCHA_PREFIX</span> <span class="operator">=</span> <span class="string">&quot;auth:captcha:&quot;</span>;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><hr><p><strong>配置验证码参数</strong>：</p><p><strong>📄 文件路径</strong>：<code>auth-web/src/main/resources/application.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></pre></td><td class="code"><pre><span class="line"><span class="comment"># 认证配置</span></span><br><span class="line"><span class="attr">auth:</span></span><br><span class="line">  <span class="comment"># 验证码配置</span></span><br><span class="line">  <span class="attr">captcha:</span></span><br><span class="line">    <span class="comment"># 验证码有效期（分钟）</span></span><br><span class="line">    <span class="attr">expire-minutes:</span> <span class="number">5</span></span><br><span class="line">    <span class="comment"># 验证码图片宽度</span></span><br><span class="line">    <span class="attr">width:</span> <span class="number">200</span></span><br><span class="line">    <span class="comment"># 验证码图片高度</span></span><br><span class="line">    <span class="attr">height:</span> <span class="number">100</span></span><br><span class="line">    <span class="comment"># 验证码字符数量</span></span><br><span class="line">    <span class="attr">code-count:</span> <span class="number">4</span></span><br><span class="line">    <span class="comment"># 干扰线数量</span></span><br><span class="line">    <span class="attr">line-count:</span> <span class="number">20</span></span><br></pre></td></tr></table></figure><hr><p><strong>实现验证码服务</strong>：</p><p><strong>📄 文件路径</strong>：<code>auth-core/src/main/java/com/example/auth/core/service/CaptchaService.java</code></p><figure class="highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><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></pre></td><td class="code"><pre><span class="line"><span class="keyword">package</span> com.example.auth.core.service;</span><br><span class="line"></span><br><span class="line"><span class="keyword">import</span> cn.hutool.captcha.CaptchaUtil;</span><br><span class="line"><span class="keyword">import</span> cn.hutool.captcha.LineCaptcha;</span><br><span class="line"><span class="keyword">import</span> com.example.auth.core.constant.RedisKeyConstants;</span><br><span class="line"><span class="keyword">import</span> lombok.RequiredArgsConstructor;</span><br><span class="line"><span class="keyword">import</span> lombok.extern.slf4j.Slf4j;</span><br><span class="line"><span class="keyword">import</span> org.springframework.beans.factory.annotation.Value;</span><br><span class="line"><span class="keyword">import</span> org.springframework.data.redis.core.StringRedisTemplate;</span><br><span class="line"><span class="keyword">import</span> org.springframework.stereotype.Service;</span><br><span class="line"></span><br><span class="line"><span class="keyword">import</span> java.util.concurrent.TimeUnit;</span><br><span class="line"></span><br><span class="line"><span class="comment">/**</span></span><br><span class="line"><span class="comment"> * 验证码服务</span></span><br><span class="line"><span class="comment"> */</span></span><br><span class="line"><span class="meta">@Slf4j</span></span><br><span class="line"><span class="meta">@Service</span></span><br><span class="line"><span class="meta">@RequiredArgsConstructor</span></span><br><span class="line"><span class="keyword">public</span> <span class="keyword">class</span> <span class="title class_">CaptchaService</span> &#123;</span><br><span class="line">    <span class="keyword">private</span> <span class="keyword">final</span> StringRedisTemplate redisTemplate;</span><br><span class="line"></span><br><span class="line">    <span class="meta">@Value(&quot;$&#123;auth.captcha.expire-minutes:5&#125;&quot;)</span></span><br><span class="line">    <span class="keyword">private</span> <span class="type">long</span> expireMinutes; <span class="comment">// 验证码有效期（分钟）</span></span><br><span class="line">    <span class="meta">@Value(&quot;$&#123;auth.captcha.width:200&#125;&quot;)</span></span><br><span class="line">    <span class="keyword">private</span> <span class="type">int</span> width; <span class="comment">// 验证码宽度</span></span><br><span class="line">    <span class="meta">@Value(&quot;$&#123;auth.captcha.height:100&#125;&quot;)</span></span><br><span class="line">    <span class="keyword">private</span> <span class="type">int</span> height; <span class="comment">// 验证码高度</span></span><br><span class="line">    <span class="meta">@Value(&quot;$&#123;auth.captcha.code-count:4&#125;&quot;)</span></span><br><span class="line">    <span class="keyword">private</span> <span class="type">int</span> codeCount; <span class="comment">// 验证码字符数量</span></span><br><span class="line">    <span class="meta">@Value(&quot;$&#123;auth.captcha.line-count:20&#125;&quot;)</span></span><br><span class="line">    <span class="keyword">private</span> <span class="type">int</span> lineCount; <span class="comment">// 验证码干扰线数量</span></span><br><span class="line"></span><br><span class="line">    <span class="comment">/**</span></span><br><span class="line"><span class="comment">     * 生成验证码</span></span><br><span class="line"><span class="comment">     *</span></span><br><span class="line"><span class="comment">     * <span class="doctag">@param</span> key 验证码 Key（通常是用户的唯一标识，如 sessionId 或 UUID）</span></span><br><span class="line"><span class="comment">     * <span class="doctag">@return</span> 验证码图片的 Base64 编码</span></span><br><span class="line"><span class="comment">     */</span></span><br><span class="line">    <span class="keyword">public</span> String <span class="title function_">generateCaptcha</span><span class="params">(String key)</span> &#123;</span><br><span class="line">        <span class="comment">// 1. 生成验证码图片</span></span><br><span class="line">        <span class="type">LineCaptcha</span> <span class="variable">captcha</span> <span class="operator">=</span> CaptchaUtil.createLineCaptcha(width, height, codeCount, lineCount);</span><br><span class="line"></span><br><span class="line">        <span class="comment">// 2. 获取验证码文本</span></span><br><span class="line">        <span class="type">String</span> <span class="variable">code</span> <span class="operator">=</span> captcha.getCode();</span><br><span class="line"></span><br><span class="line">        log.info(<span class="string">&quot;生成验证码: key=&#123;&#125;, code=&#123;&#125;&quot;</span>, key, code);</span><br><span class="line">        <span class="comment">// 3. 将验证码存入 Redis</span></span><br><span class="line">        <span class="type">String</span> <span class="variable">redisKey</span> <span class="operator">=</span> RedisKeyConstants.CAPTCHA_PREFIX + key;</span><br><span class="line">        redisTemplate.opsForValue().set(redisKey, code, expireMinutes, TimeUnit.MINUTES);</span><br><span class="line">        <span class="comment">// 4. 返回验证码图片的 Base64 编码</span></span><br><span class="line">        <span class="keyword">return</span> captcha.getImageBase64();</span><br><span class="line">    &#125;</span><br><span class="line"></span><br><span class="line">    <span class="comment">/**</span></span><br><span class="line"><span class="comment">     * 验证验证码</span></span><br><span class="line"><span class="comment">     *</span></span><br><span class="line"><span class="comment">     * <span class="doctag">@param</span> key  验证码 Key</span></span><br><span class="line"><span class="comment">     * <span class="doctag">@param</span> code 用户输入的验证码</span></span><br><span class="line"><span class="comment">     * <span class="doctag">@return</span> true 表示验证通过，false 表示验证失败</span></span><br><span class="line"><span class="comment">     */</span></span><br><span class="line">    <span class="keyword">public</span> <span class="type">boolean</span> <span class="title function_">verifyCaptcha</span><span class="params">(String key, String code)</span> &#123;</span><br><span class="line">        <span class="comment">// 1. 从 Redis 中获取验证码</span></span><br><span class="line">        <span class="type">String</span> <span class="variable">redisKey</span> <span class="operator">=</span> RedisKeyConstants.CAPTCHA_PREFIX + key;</span><br><span class="line">        <span class="type">String</span> <span class="variable">storedCode</span> <span class="operator">=</span> redisTemplate.opsForValue().get(redisKey);</span><br><span class="line">        <span class="comment">// 2. 验证码不存在或已过期</span></span><br><span class="line">        <span class="keyword">if</span> (storedCode == <span class="literal">null</span>) &#123;</span><br><span class="line">            log.warn(<span class="string">&quot;验证码不存在或已过期: key=&#123;&#125;&quot;</span>, key);</span><br><span class="line">            <span class="keyword">return</span> <span class="literal">false</span>;</span><br><span class="line">        &#125;</span><br><span class="line">        <span class="comment">// 3. 验证码错误（忽略大小写）</span></span><br><span class="line">        <span class="keyword">if</span> (!storedCode.equalsIgnoreCase(code)) &#123;</span><br><span class="line">            log.warn(<span class="string">&quot;验证码错误: key=&#123;&#125;, expected=&#123;&#125;, actual=&#123;&#125;&quot;</span>, key, storedCode, code);</span><br><span class="line">            <span class="keyword">return</span> <span class="literal">false</span>;</span><br><span class="line">        &#125;</span><br><span class="line">        <span class="comment">// 4. 验证通过，立即删除验证码（一次性使用）</span></span><br><span class="line">        redisTemplate.delete(redisKey);</span><br><span class="line">        log.info(<span class="string">&quot;验证码验证通过: key=&#123;&#125;&quot;</span>, key);</span><br><span class="line">        <span class="keyword">return</span> <span class="literal">true</span>;</span><br><span class="line">    &#125;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><hr><p><strong>实现验证码接口</strong>：</p><p><strong>📄 文件路径</strong>：<code>auth-web/src/main/java/com/example/auth/web/controller/CaptchaController.java</code></p><figure class="highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><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></pre></td><td class="code"><pre><span class="line"><span class="keyword">package</span> com.example.auth.web.controller;</span><br><span class="line"></span><br><span class="line"><span class="keyword">import</span> cn.hutool.core.util.IdUtil;</span><br><span class="line"><span class="keyword">import</span> com.example.auth.common.model.Result;</span><br><span class="line"><span class="keyword">import</span> com.example.auth.core.service.CaptchaService;</span><br><span class="line"><span class="keyword">import</span> lombok.RequiredArgsConstructor;</span><br><span class="line"><span class="keyword">import</span> lombok.extern.slf4j.Slf4j;</span><br><span class="line"><span class="keyword">import</span> org.springframework.web.bind.annotation.GetMapping;</span><br><span class="line"><span class="keyword">import</span> org.springframework.web.bind.annotation.RequestMapping;</span><br><span class="line"><span class="keyword">import</span> org.springframework.web.bind.annotation.RequestParam;</span><br><span class="line"><span class="keyword">import</span> org.springframework.web.bind.annotation.RestController;</span><br><span class="line"></span><br><span class="line"><span class="keyword">import</span> java.util.HashMap;</span><br><span class="line"><span class="keyword">import</span> java.util.Map;</span><br><span class="line"></span><br><span class="line"><span class="comment">/**</span></span><br><span class="line"><span class="comment"> * 验证码控制器</span></span><br><span class="line"><span class="comment"> */</span></span><br><span class="line"><span class="meta">@Slf4j</span></span><br><span class="line"><span class="meta">@RestController</span></span><br><span class="line"><span class="meta">@RequestMapping(&quot;/captcha&quot;)</span></span><br><span class="line"><span class="meta">@RequiredArgsConstructor</span></span><br><span class="line"><span class="keyword">public</span> <span class="keyword">class</span> <span class="title class_">CaptchaController</span> &#123;</span><br><span class="line">    <span class="keyword">private</span> <span class="keyword">final</span> CaptchaService captchaService;</span><br><span class="line"></span><br><span class="line">    <span class="comment">/**</span></span><br><span class="line"><span class="comment">     * 生成验证码</span></span><br><span class="line"><span class="comment">     *</span></span><br><span class="line"><span class="comment">     * <span class="doctag">@return</span> 验证码图片的 Base64 编码和验证码 Key</span></span><br><span class="line"><span class="comment">     */</span></span><br><span class="line">    <span class="meta">@GetMapping(&quot;/generate&quot;)</span></span><br><span class="line">    <span class="keyword">public</span> Result&lt;Map&lt;String, String&gt;&gt; <span class="title function_">generate</span><span class="params">()</span> &#123;</span><br><span class="line">        <span class="comment">// 生成唯一的验证码 Key</span></span><br><span class="line">        <span class="type">String</span> <span class="variable">key</span> <span class="operator">=</span> IdUtil.fastSimpleUUID();</span><br><span class="line"></span><br><span class="line">        <span class="comment">// 生成验证码图片</span></span><br><span class="line">        <span class="type">String</span> <span class="variable">imageBase64</span> <span class="operator">=</span> captchaService.generateCaptcha(key);</span><br><span class="line"></span><br><span class="line">        <span class="comment">// 返回验证码 Key 和图片</span></span><br><span class="line">        Map&lt;String, String&gt; data = <span class="keyword">new</span> <span class="title class_">HashMap</span>&lt;&gt;();</span><br><span class="line">        data.put(<span class="string">&quot;key&quot;</span>, key);</span><br><span class="line">        data.put(<span class="string">&quot;image&quot;</span>, <span class="string">&quot;data:image/png;base64,&quot;</span> + imageBase64);</span><br><span class="line">        <span class="keyword">return</span> Result.ok(data);</span><br><span class="line">    &#125;</span><br><span class="line">    <span class="comment">/**</span></span><br><span class="line"><span class="comment">     * 验证验证码（测试接口）</span></span><br><span class="line"><span class="comment">     *</span></span><br><span class="line"><span class="comment">     * <span class="doctag">@param</span> key  验证码 Key</span></span><br><span class="line"><span class="comment">     * <span class="doctag">@param</span> code 用户输入的验证码</span></span><br><span class="line"><span class="comment">     * <span class="doctag">@return</span> 验证结果</span></span><br><span class="line"><span class="comment">     */</span></span><br><span class="line">    <span class="meta">@GetMapping(&quot;/verify&quot;)</span></span><br><span class="line">    <span class="keyword">public</span> Result&lt;Map&lt;String, Boolean&gt;&gt; <span class="title function_">verify</span><span class="params">(<span class="meta">@RequestParam</span> String key, <span class="meta">@RequestParam</span> String code)</span> &#123;</span><br><span class="line">        <span class="type">boolean</span> <span class="variable">valid</span> <span class="operator">=</span> captchaService.verifyCaptcha(key, code);</span><br><span class="line"></span><br><span class="line">        Map&lt;String, Boolean&gt; data = <span class="keyword">new</span> <span class="title class_">HashMap</span>&lt;&gt;();</span><br><span class="line">        data.put(<span class="string">&quot;valid&quot;</span>, valid);</span><br><span class="line"></span><br><span class="line">        <span class="keyword">return</span> Result.ok(data);</span><br><span class="line">    &#125;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><hr><p><strong>Postman 测试</strong>：</p><p><strong>步骤 1：生成验证码</strong></p><ul><li><strong>方法</strong>：<code>GET</code></li><li><strong>URL</strong>：<code>http://localhost:8080/captcha/generate</code></li></ul><p><strong>响应示例</strong>：</p><figure class="highlight json"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br></pre></td><td class="code"><pre><span class="line"><span class="punctuation">&#123;</span></span><br><span class="line">  <span class="attr">&quot;code&quot;</span><span class="punctuation">:</span> <span class="number">200</span><span class="punctuation">,</span></span><br><span class="line">  <span class="attr">&quot;message&quot;</span><span class="punctuation">:</span> <span class="string">&quot;操作成功&quot;</span><span class="punctuation">,</span></span><br><span class="line">  <span class="attr">&quot;data&quot;</span><span class="punctuation">:</span> <span class="punctuation">&#123;</span></span><br><span class="line">    <span class="attr">&quot;key&quot;</span><span class="punctuation">:</span> <span class="string">&quot;a1b2c3d4e5f6g7h8&quot;</span><span class="punctuation">,</span></span><br><span class="line">    <span class="attr">&quot;image&quot;</span><span class="punctuation">:</span> <span class="string">&quot;data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAMgAAABkCAYAAADDhn8LAA...&quot;</span></span><br><span class="line">  <span class="punctuation">&#125;</span></span><br><span class="line"><span class="punctuation">&#125;</span></span><br></pre></td></tr></table></figure><p><strong>步骤 2：在浏览器中查看验证码图片</strong></p><p>将 <code>image</code> 字段的值复制到浏览器地址栏，可以看到验证码图片。</p><p><strong>步骤 3：验证验证码</strong></p><ul><li><strong>方法</strong>：<code>GET</code></li><li><strong>URL</strong>：<code>http://localhost:8080/captcha/verify?key=a1b2c3d4e5f6g7h8&amp;code=ABCD</code></li></ul><p><strong>响应示例</strong>：</p><figure class="highlight json"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br></pre></td><td class="code"><pre><span class="line"><span class="punctuation">&#123;</span></span><br><span class="line">  <span class="attr">&quot;code&quot;</span><span class="punctuation">:</span> <span class="number">200</span><span class="punctuation">,</span></span><br><span class="line">  <span class="attr">&quot;message&quot;</span><span class="punctuation">:</span> <span class="string">&quot;操作成功&quot;</span><span class="punctuation">,</span></span><br><span class="line">  <span class="attr">&quot;data&quot;</span><span class="punctuation">:</span> <span class="punctuation">&#123;</span></span><br><span class="line">    <span class="attr">&quot;valid&quot;</span><span class="punctuation">:</span> <span class="literal"><span class="keyword">true</span></span></span><br><span class="line">  <span class="punctuation">&#125;</span></span><br><span class="line"><span class="punctuation">&#125;</span></span><br></pre></td></tr></table></figure><hr><h2 id="19-3-6-邮箱服务：激活链接与异步发送">19.3.6. 邮箱服务：激活链接与异步发送</h2><p>在上一节中，我们实现了验证码服务。现在我们需要实现邮箱服务，用于发送激活邮件。</p><h3 id="Spring-Mail-配置">Spring Mail 配置</h3><p><strong>引入依赖</strong>：</p><p><strong>📄 文件路径</strong>：<code>auth-core/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></pre></td><td class="code"><pre><span class="line"><span class="comment">&lt;!-- Spring Boot Starter Mail --&gt;</span></span><br><span class="line"><span class="tag">&lt;<span class="name">dependency</span>&gt;</span></span><br><span class="line">    <span class="tag">&lt;<span class="name">groupId</span>&gt;</span>org.springframework.boot<span class="tag">&lt;/<span class="name">groupId</span>&gt;</span></span><br><span class="line">    <span class="tag">&lt;<span class="name">artifactId</span>&gt;</span>spring-boot-starter-mail<span class="tag">&lt;/<span class="name">artifactId</span>&gt;</span></span><br><span class="line"><span class="tag">&lt;/<span class="name">dependency</span>&gt;</span></span><br></pre></td></tr></table></figure><p><strong>配置邮件服务</strong>：</p><p><strong>📄 文件路径</strong>：<code>auth-web/src/main/resources/application.yml</code>（追加）</p><figure class="highlight yaml"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br></pre></td><td class="code"><pre><span class="line"><span class="attr">spring:</span></span><br><span class="line">  <span class="attr">mail:</span></span><br><span class="line">    <span class="comment"># 邮件服务器地址（以 QQ 邮箱为例）</span></span><br><span class="line">    <span class="attr">host:</span> <span class="string">smtp.qq.com</span></span><br><span class="line">    <span class="comment"># 邮件服务器端口</span></span><br><span class="line">    <span class="attr">port:</span> <span class="number">587</span></span><br><span class="line">    <span class="comment"># 发件人邮箱</span></span><br><span class="line">    <span class="attr">username:</span> <span class="string">your_email@qq.com</span></span><br><span class="line">    <span class="comment"># 授权码（不是邮箱密码！）</span></span><br><span class="line">    <span class="attr">password:</span> <span class="string">your_authorization_code</span></span><br><span class="line">    <span class="comment"># 默认编码</span></span><br><span class="line">    <span class="attr">default-encoding:</span> <span class="string">UTF-8</span></span><br><span class="line">    <span class="comment"># 其他配置</span></span><br><span class="line">    <span class="attr">properties:</span></span><br><span class="line">      <span class="attr">mail:</span></span><br><span class="line">        <span class="attr">smtp:</span></span><br><span class="line">          <span class="attr">auth:</span> <span class="literal">true</span></span><br><span class="line">          <span class="attr">starttls:</span></span><br><span class="line">            <span class="attr">enable:</span> <span class="literal">true</span></span><br><span class="line">            <span class="attr">required:</span> <span class="literal">true</span></span><br></pre></td></tr></table></figure><p><strong>如何获取 QQ 邮箱授权码？</strong></p><ol><li>登录 QQ 邮箱</li><li>点击 “设置” → “账户”</li><li>找到 “POP3/IMAP/SMTP/Exchange/CardDAV/CalDAV 服务”</li><li>开启 “POP3/SMTP 服务” 或 “IMAP/SMTP 服务”</li><li>点击 “生成授权码”</li><li>将授权码复制到配置文件中</li></ol><p><strong>其他邮箱配置</strong>：</p><table><thead><tr><th style="text-align:left">邮箱服务商</th><th style="text-align:left">SMTP 服务器</th><th style="text-align:left">端口</th></tr></thead><tbody><tr><td style="text-align:left">QQ 邮箱</td><td style="text-align:left"><a href="http://smtp.qq.com">smtp.qq.com</a></td><td style="text-align:left">587</td></tr><tr><td style="text-align:left">163 邮箱</td><td style="text-align:left"><a href="http://smtp.163.com">smtp.163.com</a></td><td style="text-align:left">465</td></tr><tr><td style="text-align:left">Gmail</td><td style="text-align:left"><a href="http://smtp.gmail.com">smtp.gmail.com</a></td><td style="text-align:left">587</td></tr><tr><td style="text-align:left">Outlook</td><td style="text-align:left"><a href="http://smtp.office365.com">smtp.office365.com</a></td><td style="text-align:left">587</td></tr></tbody></table><hr><h3 id="激活链接生成（HMAC-签名）">激活链接生成（HMAC 签名）</h3><p><strong>为什么需要 HMAC 签名？</strong></p><p>假设我们生成的激活链接是这样的：</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">http://localhost:8080/auth/activate?userId=1748392847362</span><br></pre></td></tr></table></figure><p><strong>攻击者可以轻易伪造激活链接</strong>：</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></pre></td><td class="code"><pre><span class="line">http://localhost:8080/auth/activate?userId=1</span><br><span class="line">http://localhost:8080/auth/activate?userId=2</span><br><span class="line">http://localhost:8080/auth/activate?userId=3</span><br></pre></td></tr></table></figure><p><strong>通过遍历 userId，攻击者可以激活所有用户的账号</strong>。</p><p><strong>解决方案：HMAC 签名</strong></p><p>我们在激活链接中添加一个签名参数：</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">http://localhost:8080/auth/activate?userId=1748392847362&amp;sign=a1b2c3d4e5f6g7h8</span><br></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></pre></td><td class="code"><pre><span class="line"><span class="type">String</span> <span class="variable">sign</span> <span class="operator">=</span> HMAC_SHA256(userId + timestamp + secret);</span><br></pre></td></tr></table></figure><p><strong>攻击者无法伪造签名</strong>，因为他不知道 <code>secret</code>。</p><p><strong>实现 HMAC 工具类</strong>：</p><p><strong>📄 文件路径</strong>：<code>auth-core/src/main/java/com/example/auth/core/util/HmacUtil.java</code></p><figure class="highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><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><span class="line"><span class="keyword">package</span> com.example.auth.core.util;</span><br><span class="line"></span><br><span class="line"><span class="keyword">import</span> cn.hutool.crypto.SecureUtil;</span><br><span class="line"><span class="keyword">import</span> org.springframework.beans.factory.annotation.Value;</span><br><span class="line"><span class="keyword">import</span> org.springframework.stereotype.Component;</span><br><span class="line"></span><br><span class="line"><span class="comment">/**</span></span><br><span class="line"><span class="comment"> * HMAC 签名工具类</span></span><br><span class="line"><span class="comment"> */</span></span><br><span class="line"><span class="meta">@Component</span></span><br><span class="line"><span class="keyword">public</span> <span class="keyword">class</span> <span class="title class_">HmacUtil</span> &#123;</span><br><span class="line"></span><br><span class="line">    <span class="comment">/**</span></span><br><span class="line"><span class="comment">     * 密钥（从配置文件读取）</span></span><br><span class="line"><span class="comment">     */</span></span><br><span class="line">    <span class="meta">@Value(&quot;$&#123;auth.hmac.secret:default_secret_key_change_in_production&#125;&quot;)</span></span><br><span class="line">    <span class="keyword">private</span> String secret;</span><br><span class="line"></span><br><span class="line">    <span class="comment">/**</span></span><br><span class="line"><span class="comment">     * 生成签名</span></span><br><span class="line"><span class="comment">     *</span></span><br><span class="line"><span class="comment">     * <span class="doctag">@param</span> data 待签名的数据</span></span><br><span class="line"><span class="comment">     * <span class="doctag">@return</span> 签名字符串</span></span><br><span class="line"><span class="comment">     */</span></span><br><span class="line">    <span class="keyword">public</span> String <span class="title function_">sign</span><span class="params">(String data)</span> &#123;</span><br><span class="line">        <span class="keyword">return</span> SecureUtil.hmacSha256(secret).digestHex(data);</span><br><span class="line">    &#125;</span><br><span class="line"></span><br><span class="line">    <span class="comment">/**</span></span><br><span class="line"><span class="comment">     * 验证签名</span></span><br><span class="line"><span class="comment">     *</span></span><br><span class="line"><span class="comment">     * <span class="doctag">@param</span> data 待验证的数据</span></span><br><span class="line"><span class="comment">     * <span class="doctag">@param</span> sign 签名字符串</span></span><br><span class="line"><span class="comment">     * <span class="doctag">@return</span> true 表示签名有效，false 表示签名无效</span></span><br><span class="line"><span class="comment">     */</span></span><br><span class="line">    <span class="keyword">public</span> <span class="type">boolean</span> <span class="title function_">verify</span><span class="params">(String data, String sign)</span> &#123;</span><br><span class="line">        <span class="type">String</span> <span class="variable">expectedSign</span> <span class="operator">=</span> sign(data);</span><br><span class="line">        <span class="keyword">return</span> expectedSign.equals(sign);</span><br><span class="line">    &#125;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p><strong>配置密钥</strong>：</p><p><strong>📄 文件路径</strong>：<code>auth-web/src/main/resources/application.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></pre></td><td class="code"><pre><span class="line"><span class="attr">auth:</span></span><br><span class="line">  <span class="attr">hmac:</span></span><br><span class="line">    <span class="comment"># HMAC 密钥（生产环境必须修改！）</span></span><br><span class="line">    <span class="attr">secret:</span> <span class="string">your_secret_key_change_in_production</span></span><br></pre></td></tr></table></figure><hr><h3 id="Spring-Event-异步发送">Spring Event 异步发送</h3><p><strong>为什么需要异步发送？</strong></p><p>如果同步发送邮件，用户注册时的流程是这样的：</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><span class="line">sequenceDiagram</span><br><span class="line">    participant User as 用户</span><br><span class="line">    participant Controller as Controller</span><br><span class="line">    participant Service as UserService</span><br><span class="line">    participant Mail as MailService</span><br><span class="line"></span><br><span class="line">    User-&gt;&gt;Controller: POST /auth/register</span><br><span class="line">    Controller-&gt;&gt;Service: 注册用户</span><br><span class="line">    Service-&gt;&gt;Service: 插入数据库</span><br><span class="line">    Service-&gt;&gt;Mail: 发送激活邮件</span><br><span class="line">    Note over Mail: 发送邮件需要 2-5 秒</span><br><span class="line">    Mail--&gt;&gt;Service: 发送成功</span><br><span class="line">    Service--&gt;&gt;Controller: 注册成功</span><br><span class="line">    Controller--&gt;&gt;User: 返回响应</span><br><span class="line"></span><br><span class="line">    Note over User: 用户等待 2-5 秒</span><br></pre></td></tr></table></figure><p><strong>用户需要等待 2-5 秒才能收到响应</strong>，体验很差。</p><p><strong>异步发送的流程</strong>：</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><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><span class="line">sequenceDiagram</span><br><span class="line">    participant User as 用户</span><br><span class="line">    participant Controller as Controller</span><br><span class="line">    participant Service as UserService</span><br><span class="line">    participant Event as Spring Event</span><br><span class="line">    participant Listener as MailListener</span><br><span class="line">    participant Mail as MailService</span><br><span class="line"></span><br><span class="line">    User-&gt;&gt;Controller: POST /auth/register</span><br><span class="line">    Controller-&gt;&gt;Service: 注册用户</span><br><span class="line">    Service-&gt;&gt;Service: 插入数据库</span><br><span class="line">    Service-&gt;&gt;Event: 发布事件</span><br><span class="line">    Event--&gt;&gt;Service: 立即返回</span><br><span class="line">    Service--&gt;&gt;Controller: 注册成功</span><br><span class="line">    Controller--&gt;&gt;User: 返回响应</span><br><span class="line"></span><br><span class="line">    Note over User: 用户立即收到响应</span><br><span class="line"></span><br><span class="line">    Event-&gt;&gt;Listener: 异步处理事件</span><br><span class="line">    Listener-&gt;&gt;Mail: 发送激活邮件</span><br><span class="line">    Note over Mail: 发送邮件需要 2-5 秒</span><br><span class="line">    Mail--&gt;&gt;Listener: 发送成功</span><br></pre></td></tr></table></figure><p><strong>用户立即收到响应，邮件在后台异步发送</strong>。</p><div class="note blue flat"><p><strong>占位符：Spring Event 机制详解</strong></p><p>本章只演示 Spring Event 的基本用法。如果你想深入理解 Spring Event 的原理、最佳实践、事务管理等内容，请参考以下文章：</p><ul><li>《Spring Event 事件驱动架构：从入门到精通》</li><li>《Spring Event 与事务：如何保证事件发布的可靠性》</li><li>《Spring Event 性能优化：异步线程池配置与监控》</li></ul></div><hr><h3 id="定义邮件发送事件">定义邮件发送事件</h3><p><strong>📄 文件路径</strong>：<code>auth-core/src/main/java/com/example/auth/core/event/EmailEvent.java</code></p><figure class="highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><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><span class="line"><span class="keyword">package</span> com.example.auth.core.event;</span><br><span class="line"></span><br><span class="line"><span class="keyword">import</span> lombok.Getter;</span><br><span class="line"><span class="keyword">import</span> org.springframework.context.ApplicationEvent;</span><br><span class="line"></span><br><span class="line"><span class="comment">/**</span></span><br><span class="line"><span class="comment"> * 邮件发送事件</span></span><br><span class="line"><span class="comment"> */</span></span><br><span class="line"><span class="meta">@Getter</span></span><br><span class="line"><span class="keyword">public</span> <span class="keyword">class</span> <span class="title class_">EmailEvent</span> <span class="keyword">extends</span> <span class="title class_">ApplicationEvent</span> &#123;</span><br><span class="line"></span><br><span class="line">    <span class="comment">/**</span></span><br><span class="line"><span class="comment">     * 收件人邮箱</span></span><br><span class="line"><span class="comment">     */</span></span><br><span class="line">    <span class="keyword">private</span> <span class="keyword">final</span> String to;</span><br><span class="line"></span><br><span class="line">    <span class="comment">/**</span></span><br><span class="line"><span class="comment">     * 邮件主题</span></span><br><span class="line"><span class="comment">     */</span></span><br><span class="line">    <span class="keyword">private</span> <span class="keyword">final</span> String subject;</span><br><span class="line"></span><br><span class="line">    <span class="comment">/**</span></span><br><span class="line"><span class="comment">     * 邮件内容（HTML 格式）</span></span><br><span class="line"><span class="comment">     */</span></span><br><span class="line">    <span class="keyword">private</span> <span class="keyword">final</span> String content;</span><br><span class="line"></span><br><span class="line">    <span class="keyword">public</span> <span class="title function_">EmailEvent</span><span class="params">(Object source, String to, String subject, String content)</span> &#123;</span><br><span class="line">        <span class="built_in">super</span>(source);</span><br><span class="line">        <span class="built_in">this</span>.to = to;</span><br><span class="line">        <span class="built_in">this</span>.subject = subject;</span><br><span class="line">        <span class="built_in">this</span>.content = content;</span><br><span class="line">    &#125;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><hr><h3 id="实现邮件发送监听器">实现邮件发送监听器</h3><p><strong>📄 文件路径</strong>：<code>auth-core/src/main/java/com/example/auth/core/listener/EmailEventListener.java</code></p><figure class="highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><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><span class="line"><span class="keyword">package</span> com.example.auth.core.listener;</span><br><span class="line"></span><br><span class="line"><span class="keyword">import</span> com.example.auth.core.event.EmailEvent;</span><br><span class="line"><span class="keyword">import</span> lombok.RequiredArgsConstructor;</span><br><span class="line"><span class="keyword">import</span> lombok.extern.slf4j.Slf4j;</span><br><span class="line"><span class="keyword">import</span> org.springframework.context.event.EventListener;</span><br><span class="line"><span class="keyword">import</span> org.springframework.mail.javamail.JavaMailSender;</span><br><span class="line"><span class="keyword">import</span> org.springframework.mail.javamail.MimeMessageHelper;</span><br><span class="line"><span class="keyword">import</span> org.springframework.scheduling.annotation.Async;</span><br><span class="line"><span class="keyword">import</span> org.springframework.stereotype.Component;</span><br><span class="line"></span><br><span class="line"><span class="keyword">import</span> jakarta.mail.MessagingException;</span><br><span class="line"><span class="keyword">import</span> jakarta.mail.internet.MimeMessage;</span><br><span class="line"></span><br><span class="line"><span class="comment">/**</span></span><br><span class="line"><span class="comment"> * 邮件发送监听器</span></span><br><span class="line"><span class="comment"> * 监听 EmailEvent 事件，异步发送邮件</span></span><br><span class="line"><span class="comment"> */</span></span><br><span class="line"><span class="meta">@Slf4j</span></span><br><span class="line"><span class="meta">@Component</span></span><br><span class="line"><span class="meta">@RequiredArgsConstructor</span></span><br><span class="line"><span class="keyword">public</span> <span class="keyword">class</span> <span class="title class_">EmailEventListener</span> &#123;</span><br><span class="line"></span><br><span class="line">    <span class="keyword">private</span> <span class="keyword">final</span> JavaMailSender mailSender;</span><br><span class="line"></span><br><span class="line">    <span class="comment">/**</span></span><br><span class="line"><span class="comment">     * 处理邮件发送事件</span></span><br><span class="line"><span class="comment">     *</span></span><br><span class="line"><span class="comment">     * <span class="doctag">@param</span> event 邮件事件</span></span><br><span class="line"><span class="comment">     */</span></span><br><span class="line">    <span class="meta">@Async</span></span><br><span class="line">    <span class="meta">@EventListener</span></span><br><span class="line">    <span class="keyword">public</span> <span class="keyword">void</span> <span class="title function_">handleEmailEvent</span><span class="params">(EmailEvent event)</span> &#123;</span><br><span class="line">        <span class="keyword">try</span> &#123;</span><br><span class="line">            log.info(<span class="string">&quot;开始发送邮件: to=&#123;&#125;, subject=&#123;&#125;&quot;</span>, event.getTo(), event.getSubject());</span><br><span class="line"></span><br><span class="line">            <span class="comment">// 创建邮件消息</span></span><br><span class="line">            <span class="type">MimeMessage</span> <span class="variable">message</span> <span class="operator">=</span> mailSender.createMimeMessage();</span><br><span class="line">            <span class="type">MimeMessageHelper</span> <span class="variable">helper</span> <span class="operator">=</span> <span class="keyword">new</span> <span class="title class_">MimeMessageHelper</span>(message, <span class="literal">true</span>, <span class="string">&quot;UTF-8&quot;</span>);</span><br><span class="line"></span><br><span class="line">            <span class="comment">// 设置发件人（从配置文件读取）</span></span><br><span class="line">            helper.setFrom(<span class="string">&quot;your_email@qq.com&quot;</span>);</span><br><span class="line">            <span class="comment">// 设置收件人</span></span><br><span class="line">            helper.setTo(event.getTo());</span><br><span class="line">            <span class="comment">// 设置邮件主题</span></span><br><span class="line">            helper.setSubject(event.getSubject());</span><br><span class="line">            <span class="comment">// 设置邮件内容（HTML 格式）</span></span><br><span class="line">            helper.setText(event.getContent(), <span class="literal">true</span>);</span><br><span class="line"></span><br><span class="line">            <span class="comment">// 发送邮件</span></span><br><span class="line">            mailSender.send(message);</span><br><span class="line"></span><br><span class="line">            log.info(<span class="string">&quot;邮件发送成功: to=&#123;&#125;&quot;</span>, event.getTo());</span><br><span class="line">        &#125; <span class="keyword">catch</span> (MessagingException e) &#123;</span><br><span class="line">            log.error(<span class="string">&quot;邮件发送失败: to=&#123;&#125;, error=&#123;&#125;&quot;</span>, event.getTo(), e.getMessage(), e);</span><br><span class="line">        &#125;</span><br><span class="line">    &#125;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p><strong>关键注解</strong>：</p><ul><li><code>@EventListener</code>：标记这是一个事件监听器</li><li><code>@Async</code>：标记这是一个异步方法</li></ul><hr><h3 id="配置异步线程池">配置异步线程池</h3><p><strong>📄 文件路径</strong>：<code>auth-core/src/main/java/com/example/auth/core/config/AsyncConfig.java</code></p><figure class="highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><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><span class="line"><span class="keyword">package</span> com.example.auth.core.config;</span><br><span class="line"></span><br><span class="line"><span class="keyword">import</span> org.springframework.context.annotation.Configuration;</span><br><span class="line"><span class="keyword">import</span> org.springframework.scheduling.annotation.EnableAsync;</span><br><span class="line"><span class="keyword">import</span> org.springframework.scheduling.annotation.AsyncConfigurer;</span><br><span class="line"><span class="keyword">import</span> org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;</span><br><span class="line"></span><br><span class="line"><span class="keyword">import</span> java.util.concurrent.Executor;</span><br><span class="line"></span><br><span class="line"><span class="comment">/**</span></span><br><span class="line"><span class="comment"> * 异步配置</span></span><br><span class="line"><span class="comment"> * 配置 Spring Event 的异步线程池</span></span><br><span class="line"><span class="comment"> */</span></span><br><span class="line"><span class="meta">@Configuration</span></span><br><span class="line"><span class="meta">@EnableAsync</span></span><br><span class="line"><span class="keyword">public</span> <span class="keyword">class</span> <span class="title class_">AsyncConfig</span> <span class="keyword">implements</span> <span class="title class_">AsyncConfigurer</span> &#123;</span><br><span class="line"></span><br><span class="line">    <span class="meta">@Override</span></span><br><span class="line">    <span class="keyword">public</span> Executor <span class="title function_">getAsyncExecutor</span><span class="params">()</span> &#123;</span><br><span class="line">        <span class="type">ThreadPoolTaskExecutor</span> <span class="variable">executor</span> <span class="operator">=</span> <span class="keyword">new</span> <span class="title class_">ThreadPoolTaskExecutor</span>();</span><br><span class="line">        <span class="comment">// 核心线程数</span></span><br><span class="line">        executor.setCorePoolSize(<span class="number">5</span>);</span><br><span class="line">        <span class="comment">// 最大线程数</span></span><br><span class="line">        executor.setMaxPoolSize(<span class="number">10</span>);</span><br><span class="line">        <span class="comment">// 队列容量</span></span><br><span class="line">        executor.setQueueCapacity(<span class="number">100</span>);</span><br><span class="line">        <span class="comment">// 线程名称前缀</span></span><br><span class="line">        executor.setThreadNamePrefix(<span class="string">&quot;async-email-&quot;</span>);</span><br><span class="line">        <span class="comment">// 初始化</span></span><br><span class="line">        executor.initialize();</span><br><span class="line">        <span class="keyword">return</span> executor;</span><br><span class="line">    &#125;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><hr><h3 id="实现邮件服务">实现邮件服务</h3><p><strong>📄 文件路径</strong>：<code>auth-core/src/main/java/com/example/auth/core/service/EmailService.java</code></p><figure class="highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><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></pre></td><td class="code"><pre><span class="line"><span class="keyword">package</span> com.example.auth.core.service;</span><br><span class="line"></span><br><span class="line"><span class="keyword">import</span> com.example.auth.core.event.EmailEvent;</span><br><span class="line"><span class="keyword">import</span> com.example.auth.core.util.HmacUtil;</span><br><span class="line"><span class="keyword">import</span> lombok.RequiredArgsConstructor;</span><br><span class="line"><span class="keyword">import</span> lombok.extern.slf4j.Slf4j;</span><br><span class="line"><span class="keyword">import</span> org.springframework.beans.factory.annotation.Value;</span><br><span class="line"><span class="keyword">import</span> org.springframework.context.ApplicationEventPublisher;</span><br><span class="line"><span class="keyword">import</span> org.springframework.stereotype.Service;</span><br><span class="line"></span><br><span class="line"><span class="comment">/**</span></span><br><span class="line"><span class="comment"> * 邮件服务</span></span><br><span class="line"><span class="comment"> */</span></span><br><span class="line"><span class="meta">@Slf4j</span></span><br><span class="line"><span class="meta">@Service</span></span><br><span class="line"><span class="meta">@RequiredArgsConstructor</span></span><br><span class="line"><span class="keyword">public</span> <span class="keyword">class</span> <span class="title class_">EmailService</span> &#123;</span><br><span class="line"></span><br><span class="line">    <span class="keyword">private</span> <span class="keyword">final</span> ApplicationEventPublisher eventPublisher;</span><br><span class="line">    <span class="keyword">private</span> <span class="keyword">final</span> HmacUtil hmacUtil;</span><br><span class="line"></span><br><span class="line">    <span class="meta">@Value(&quot;$&#123;server.port:8080&#125;&quot;)</span></span><br><span class="line">    <span class="keyword">private</span> String serverPort;</span><br><span class="line"></span><br><span class="line">    <span class="comment">/**</span></span><br><span class="line"><span class="comment">     * 发送激活邮件</span></span><br><span class="line"><span class="comment">     *</span></span><br><span class="line"><span class="comment">     * <span class="doctag">@param</span> email  收件人邮箱</span></span><br><span class="line"><span class="comment">     * <span class="doctag">@param</span> userId 用户 ID</span></span><br><span class="line"><span class="comment">     */</span></span><br><span class="line">    <span class="keyword">public</span> <span class="keyword">void</span> <span class="title function_">sendActivationEmail</span><span class="params">(String email, Long userId)</span> &#123;</span><br><span class="line">        <span class="comment">// 1. 生成激活链接</span></span><br><span class="line">        <span class="type">String</span> <span class="variable">activationUrl</span> <span class="operator">=</span> generateActivationUrl(userId);</span><br><span class="line"></span><br><span class="line">        <span class="comment">// 2. 构建邮件内容（HTML 格式）</span></span><br><span class="line">        <span class="type">String</span> <span class="variable">content</span> <span class="operator">=</span> buildActivationEmailContent(activationUrl);</span><br><span class="line"></span><br><span class="line">        <span class="comment">// 3. 发布邮件事件（异步发送）</span></span><br><span class="line">        <span class="type">EmailEvent</span> <span class="variable">event</span> <span class="operator">=</span> <span class="keyword">new</span> <span class="title class_">EmailEvent</span>(<span class="built_in">this</span>, email, <span class="string">&quot;账号激活&quot;</span>, content);</span><br><span class="line">        eventPublisher.publishEvent(event);</span><br><span class="line"></span><br><span class="line">        log.info(<span class="string">&quot;激活邮件事件已发布: email=&#123;&#125;, userId=&#123;&#125;&quot;</span>, email, userId);</span><br><span class="line">    &#125;</span><br><span class="line"></span><br><span class="line">    <span class="comment">/**</span></span><br><span class="line"><span class="comment">     * 生成激活链接</span></span><br><span class="line"><span class="comment">     *</span></span><br><span class="line"><span class="comment">     * <span class="doctag">@param</span> userId 用户 ID</span></span><br><span class="line"><span class="comment">     * <span class="doctag">@return</span> 激活链接</span></span><br><span class="line"><span class="comment">     */</span></span><br><span class="line">    <span class="keyword">private</span> String <span class="title function_">generateActivationUrl</span><span class="params">(Long userId)</span> &#123;</span><br><span class="line">        <span class="comment">// 1. 生成时间戳（有效期 24 小时）</span></span><br><span class="line">        <span class="type">long</span> <span class="variable">timestamp</span> <span class="operator">=</span> System.currentTimeMillis() + <span class="number">24</span> * <span class="number">60</span> * <span class="number">60</span> * <span class="number">1000</span>;</span><br><span class="line"></span><br><span class="line">        <span class="comment">// 2. 生成签名</span></span><br><span class="line">        <span class="type">String</span> <span class="variable">data</span> <span class="operator">=</span> userId + <span class="string">&quot;:&quot;</span> + timestamp;</span><br><span class="line">        <span class="type">String</span> <span class="variable">sign</span> <span class="operator">=</span> hmacUtil.sign(data);</span><br><span class="line"></span><br><span class="line">        <span class="comment">// 3. 构建激活链接</span></span><br><span class="line">        <span class="keyword">return</span> String.format(<span class="string">&quot;http://localhost:%s/auth/activate?userId=%d&amp;timestamp=%d&amp;sign=%s&quot;</span>,</span><br><span class="line">                serverPort, userId, timestamp, sign);</span><br><span class="line">    &#125;</span><br><span class="line"></span><br><span class="line">    <span class="comment">/**</span></span><br><span class="line"><span class="comment">     * 构建激活邮件内容（HTML 格式）</span></span><br><span class="line"><span class="comment">     *</span></span><br><span class="line"><span class="comment">     * <span class="doctag">@param</span> activationUrl 激活链接</span></span><br><span class="line"><span class="comment">     * <span class="doctag">@return</span> 邮件内容</span></span><br><span class="line"><span class="comment">     */</span></span><br><span class="line">    <span class="keyword">private</span> String <span class="title function_">buildActivationEmailContent</span><span class="params">(String activationUrl)</span> &#123;</span><br><span class="line">        <span class="keyword">return</span> <span class="string">&quot;&quot;&quot;</span></span><br><span class="line"><span class="string">                &lt;!DOCTYPE html&gt;</span></span><br><span class="line"><span class="string">                &lt;html&gt;</span></span><br><span class="line"><span class="string">                &lt;head&gt;</span></span><br><span class="line"><span class="string">                    &lt;meta charset=&quot;UTF-8&quot;&gt;</span></span><br><span class="line"><span class="string">                    &lt;style&gt;</span></span><br><span class="line"><span class="string">                        body &#123; font-family: Arial, sans-serif; line-height: 1.6; &#125;</span></span><br><span class="line"><span class="string">                        .container &#123; max-width: 600px; margin: 0 auto; padding: 20px; &#125;</span></span><br><span class="line"><span class="string">                        .header &#123; background-color: #4CAF50; color: white; padding: 20px; text-align: center; &#125;</span></span><br><span class="line"><span class="string">                        .content &#123; padding: 20px; background-color: #f9f9f9; &#125;</span></span><br><span class="line"><span class="string">                        .button &#123; display: inline-block; padding: 10px 20px; background-color: #4CAF50; color: white; text-decoration: none; border-radius: 5px; &#125;</span></span><br><span class="line"><span class="string">                        .footer &#123; padding: 20px; text-align: center; color: #666; font-size: 12px; &#125;</span></span><br><span class="line"><span class="string">                    &lt;/style&gt;</span></span><br><span class="line"><span class="string">                &lt;/head&gt;</span></span><br><span class="line"><span class="string">                &lt;body&gt;</span></span><br><span class="line"><span class="string">                    &lt;div class=&quot;container&quot;&gt;</span></span><br><span class="line"><span class="string">                        &lt;div class=&quot;header&quot;&gt;</span></span><br><span class="line"><span class="string">                            &lt;h1&gt;欢迎注册&lt;/h1&gt;</span></span><br><span class="line"><span class="string">                        &lt;/div&gt;</span></span><br><span class="line"><span class="string">                        &lt;div class=&quot;content&quot;&gt;</span></span><br><span class="line"><span class="string">                            &lt;p&gt;您好！&lt;/p&gt;</span></span><br><span class="line"><span class="string">                            &lt;p&gt;感谢您注册我们的服务。请点击下方按钮激活您的账号：&lt;/p&gt;</span></span><br><span class="line"><span class="string">                            &lt;p style=&quot;text-align: center; margin: 30px 0;&quot;&gt;</span></span><br><span class="line"><span class="string">                                &lt;a href=&quot;%s&quot; class=&quot;button&quot;&gt;激活账号&lt;/a&gt;</span></span><br><span class="line"><span class="string">                            &lt;/p&gt;</span></span><br><span class="line"><span class="string">                            &lt;p&gt;或者复制以下链接到浏览器打开：&lt;/p&gt;</span></span><br><span class="line"><span class="string">                            &lt;p style=&quot;word-break: break-all; color: #666;&quot;&gt;%s&lt;/p&gt;</span></span><br><span class="line"><span class="string">                            &lt;p style=&quot;color: #999; font-size: 12px;&quot;&gt;此链接 24 小时内有效，请尽快激活。&lt;/p&gt;</span></span><br><span class="line"><span class="string">                        &lt;/div&gt;</span></span><br><span class="line"><span class="string">                        &lt;div class=&quot;footer&quot;&gt;</span></span><br><span class="line"><span class="string">                            &lt;p&gt;这是一封自动发送的邮件，请勿回复。&lt;/p&gt;</span></span><br><span class="line"><span class="string">                        &lt;/div&gt;</span></span><br><span class="line"><span class="string">                    &lt;/div&gt;</span></span><br><span class="line"><span class="string">                &lt;/body&gt;</span></span><br><span class="line"><span class="string">                &lt;/html&gt;</span></span><br><span class="line"><span class="string">                &quot;&quot;&quot;</span>.formatted(activationUrl, activationUrl);</span><br><span class="line">    &#125;</span><br><span class="line"></span><br><span class="line">    <span class="comment">/**</span></span><br><span class="line"><span class="comment">     * 发送找回密码邮件</span></span><br><span class="line"><span class="comment">     *</span></span><br><span class="line"><span class="comment">     * <span class="doctag">@param</span> email  收件人邮箱</span></span><br><span class="line"><span class="comment">     * <span class="doctag">@param</span> userId 用户 ID</span></span><br><span class="line"><span class="comment">     */</span></span><br><span class="line">    <span class="keyword">public</span> <span class="keyword">void</span> <span class="title function_">sendResetPasswordEmail</span><span class="params">(String email, Long userId)</span> &#123;</span><br><span class="line">        <span class="comment">// 1. 生成重置密码链接</span></span><br><span class="line">        <span class="type">String</span> <span class="variable">resetUrl</span> <span class="operator">=</span> generateResetPasswordUrl(userId);</span><br><span class="line"></span><br><span class="line">        <span class="comment">// 2. 构建邮件内容（HTML 格式）</span></span><br><span class="line">        <span class="type">String</span> <span class="variable">content</span> <span class="operator">=</span> buildResetPasswordEmailContent(resetUrl);</span><br><span class="line"></span><br><span class="line">        <span class="comment">// 3. 发布邮件事件（异步发送）</span></span><br><span class="line">        <span class="type">EmailEvent</span> <span class="variable">event</span> <span class="operator">=</span> <span class="keyword">new</span> <span class="title class_">EmailEvent</span>(<span class="built_in">this</span>, email, <span class="string">&quot;重置密码&quot;</span>, content);</span><br><span class="line">        eventPublisher.publishEvent(event);</span><br><span class="line"></span><br><span class="line">        log.info(<span class="string">&quot;重置密码邮件事件已发布: email=&#123;&#125;, userId=&#123;&#125;&quot;</span>, email, userId);</span><br><span class="line">    &#125;</span><br><span class="line"></span><br><span class="line">    <span class="comment">/**</span></span><br><span class="line"><span class="comment">     * 生成重置密码链接</span></span><br><span class="line"><span class="comment">     *</span></span><br><span class="line"><span class="comment">     * <span class="doctag">@param</span> userId 用户 ID</span></span><br><span class="line"><span class="comment">     * <span class="doctag">@return</span> 重置密码链接</span></span><br><span class="line"><span class="comment">     */</span></span><br><span class="line">    <span class="keyword">private</span> String <span class="title function_">generateResetPasswordUrl</span><span class="params">(Long userId)</span> &#123;</span><br><span class="line">        <span class="comment">// 1. 生成时间戳（有效期 1 小时）</span></span><br><span class="line">        <span class="type">long</span> <span class="variable">timestamp</span> <span class="operator">=</span> System.currentTimeMillis() + <span class="number">60</span> * <span class="number">60</span> * <span class="number">1000</span>;</span><br><span class="line"></span><br><span class="line">        <span class="comment">// 2. 生成签名</span></span><br><span class="line">        <span class="type">String</span> <span class="variable">data</span> <span class="operator">=</span> userId + <span class="string">&quot;:&quot;</span> + timestamp;</span><br><span class="line">        <span class="type">String</span> <span class="variable">sign</span> <span class="operator">=</span> hmacUtil.sign(data);</span><br><span class="line"></span><br><span class="line">        <span class="comment">// 3. 构建重置密码链接</span></span><br><span class="line">        <span class="keyword">return</span> String.format(<span class="string">&quot;http://localhost:%s/auth/reset-password?userId=%d&amp;timestamp=%d&amp;sign=%s&quot;</span>,</span><br><span class="line">                serverPort, userId, timestamp, sign);</span><br><span class="line">    &#125;</span><br><span class="line"></span><br><span class="line">    <span class="comment">/**</span></span><br><span class="line"><span class="comment">     * 构建重置密码邮件内容（HTML 格式）</span></span><br><span class="line"><span class="comment">     *</span></span><br><span class="line"><span class="comment">     * <span class="doctag">@param</span> resetUrl 重置密码链接</span></span><br><span class="line"><span class="comment">     * <span class="doctag">@return</span> 邮件内容</span></span><br><span class="line"><span class="comment">     */</span></span><br><span class="line">    <span class="keyword">private</span> String <span class="title function_">buildResetPasswordEmailContent</span><span class="params">(String resetUrl)</span> &#123;</span><br><span class="line">        <span class="keyword">return</span> <span class="string">&quot;&quot;&quot;</span></span><br><span class="line"><span class="string">                &lt;!DOCTYPE html&gt;</span></span><br><span class="line"><span class="string">                &lt;html&gt;</span></span><br><span class="line"><span class="string">                &lt;head&gt;</span></span><br><span class="line"><span class="string">                    &lt;meta charset=&quot;UTF-8&quot;&gt;</span></span><br><span class="line"><span class="string">                    &lt;style&gt;</span></span><br><span class="line"><span class="string">                        body &#123; font-family: Arial, sans-serif; line-height: 1.6; &#125;</span></span><br><span class="line"><span class="string">                        .container &#123; max-width: 600px; margin: 0 auto; padding: 20px; &#125;</span></span><br><span class="line"><span class="string">                        .header &#123; background-color: #FF9800; color: white; padding: 20px; text-align: center; &#125;</span></span><br><span class="line"><span class="string">                        .content &#123; padding: 20px; background-color: #f9f9f9; &#125;</span></span><br><span class="line"><span class="string">                        .button &#123; display: inline-block; padding: 10px 20px; background-color: #FF9800; color: white; text-decoration: none; border-radius: 5px; &#125;</span></span><br><span class="line"><span class="string">                        .footer &#123; padding: 20px; text-align: center; color: #666; font-size: 12px; &#125;</span></span><br><span class="line"><span class="string">                    &lt;/style&gt;</span></span><br><span class="line"><span class="string">                &lt;/head&gt;</span></span><br><span class="line"><span class="string">                &lt;body&gt;</span></span><br><span class="line"><span class="string">                    &lt;div class=&quot;container&quot;&gt;</span></span><br><span class="line"><span class="string">                        &lt;div class=&quot;header&quot;&gt;</span></span><br><span class="line"><span class="string">                            &lt;h1&gt;重置密码&lt;/h1&gt;</span></span><br><span class="line"><span class="string">                        &lt;/div&gt;</span></span><br><span class="line"><span class="string">                        &lt;div class=&quot;content&quot;&gt;</span></span><br><span class="line"><span class="string">                            &lt;p&gt;您好！&lt;/p&gt;</span></span><br><span class="line"><span class="string">                            &lt;p&gt;我们收到了您的密码重置请求。请点击下方按钮重置密码：&lt;/p&gt;</span></span><br><span class="line"><span class="string">                            &lt;p style=&quot;text-align: center; margin: 30px 0;&quot;&gt;</span></span><br><span class="line"><span class="string">                                &lt;a href=&quot;%s&quot; class=&quot;button&quot;&gt;重置密码&lt;/a&gt;</span></span><br><span class="line"><span class="string">                            &lt;/p&gt;</span></span><br><span class="line"><span class="string">                            &lt;p&gt;或者复制以下链接到浏览器打开：&lt;/p&gt;</span></span><br><span class="line"><span class="string">                            &lt;p style=&quot;word-break: break-all; color: #666;&quot;&gt;%s&lt;/p&gt;</span></span><br><span class="line"><span class="string">                            &lt;p style=&quot;color: #999; font-size: 12px;&quot;&gt;此链接 1 小时内有效，请尽快重置。&lt;/p&gt;</span></span><br><span class="line"><span class="string">                            &lt;p style=&quot;color: #f44336; font-size: 12px;&quot;&gt;如果这不是您的操作，请忽略此邮件。&lt;/p&gt;</span></span><br><span class="line"><span class="string">                        &lt;/div&gt;</span></span><br><span class="line"><span class="string">                        &lt;div class=&quot;footer&quot;&gt;</span></span><br><span class="line"><span class="string">                            &lt;p&gt;这是一封自动发送的邮件，请勿回复。&lt;/p&gt;</span></span><br><span class="line"><span class="string">                        &lt;/div&gt;</span></span><br><span class="line"><span class="string">                    &lt;/div&gt;</span></span><br><span class="line"><span class="string">                &lt;/body&gt;</span></span><br><span class="line"><span class="string">                &lt;/html&gt;</span></span><br><span class="line"><span class="string">                &quot;&quot;&quot;</span>.formatted(resetUrl, resetUrl);</span><br><span class="line">    &#125;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><hr><h2 id="19-3-7-注册流程：完整的用户注册">19.3.7. 注册流程：完整的用户注册</h2><p>在上一节中，我们实现了邮箱服务。现在我们需要实现完整的用户注册流程。</p><h3 id="定义注册请求">定义注册请求</h3><p><strong>📄 文件路径</strong>：<code>auth-core/src/main/java/com/example/auth/core/model/request/RegisterRequest.java</code></p><figure class="highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><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></pre></td><td class="code"><pre><span class="line"><span class="keyword">package</span> com.example.auth.core.model.request;</span><br><span class="line"></span><br><span class="line"><span class="keyword">import</span> jakarta.validation.constraints.Email;</span><br><span class="line"><span class="keyword">import</span> jakarta.validation.constraints.NotBlank;</span><br><span class="line"><span class="keyword">import</span> jakarta.validation.constraints.Pattern;</span><br><span class="line"><span class="keyword">import</span> lombok.Data;</span><br><span class="line"></span><br><span class="line"><span class="comment">/**</span></span><br><span class="line"><span class="comment"> * 注册请求</span></span><br><span class="line"><span class="comment"> */</span></span><br><span class="line"><span class="meta">@Data</span></span><br><span class="line"><span class="keyword">public</span> <span class="keyword">class</span> <span class="title class_">RegisterRequest</span> &#123;</span><br><span class="line"></span><br><span class="line">    <span class="comment">/**</span></span><br><span class="line"><span class="comment">     * 用户名</span></span><br><span class="line"><span class="comment">     * 4-20 位，只能包含字母、数字、下划线</span></span><br><span class="line"><span class="comment">     */</span></span><br><span class="line">    <span class="meta">@NotBlank(message = &quot;用户名不能为空&quot;)</span></span><br><span class="line">    <span class="meta">@Pattern(regexp = &quot;^[a-zA-Z0-9_]&#123;4,20&#125;$&quot;, message = &quot;用户名格式不正确（4-20位，只能包含字母、数字、下划线）&quot;)</span></span><br><span class="line">    <span class="keyword">private</span> String username;</span><br><span class="line"></span><br><span class="line">    <span class="comment">/**</span></span><br><span class="line"><span class="comment">     * 密码</span></span><br><span class="line"><span class="comment">     * 8-20 位，必须包含大小写字母、数字</span></span><br><span class="line"><span class="comment">     */</span></span><br><span class="line">    <span class="meta">@NotBlank(message = &quot;密码不能为空&quot;)</span></span><br><span class="line">    <span class="meta">@Pattern(regexp = &quot;^(?=.*[a-z])(?=.*[A-Z])(?=.*\\d)[a-zA-Z\\d]&#123;8,20&#125;$&quot;,</span></span><br><span class="line"><span class="meta">            message = &quot;密码格式不正确（8-20位，必须包含大小写字母、数字）&quot;)</span></span><br><span class="line">    <span class="keyword">private</span> String password;</span><br><span class="line"></span><br><span class="line">    <span class="comment">/**</span></span><br><span class="line"><span class="comment">     * 邮箱</span></span><br><span class="line"><span class="comment">     */</span></span><br><span class="line">    <span class="meta">@NotBlank(message = &quot;邮箱不能为空&quot;)</span></span><br><span class="line">    <span class="meta">@Email(message = &quot;邮箱格式不正确&quot;)</span></span><br><span class="line">    <span class="keyword">private</span> String email;</span><br><span class="line"></span><br><span class="line">    <span class="comment">/**</span></span><br><span class="line"><span class="comment">     * 验证码 Key</span></span><br><span class="line"><span class="comment">     */</span></span><br><span class="line">    <span class="meta">@NotBlank(message = &quot;验证码 Key 不能为空&quot;)</span></span><br><span class="line">    <span class="keyword">private</span> String captchaKey;</span><br><span class="line"></span><br><span class="line">    <span class="comment">/**</span></span><br><span class="line"><span class="comment">     * 验证码</span></span><br><span class="line"><span class="comment">     */</span></span><br><span class="line">    <span class="meta">@NotBlank(message = &quot;验证码不能为空&quot;)</span></span><br><span class="line">    <span class="keyword">private</span> String captchaCode;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><hr><h3 id="实现用户服务">实现用户服务</h3><p><strong>📄 文件路径</strong>：<code>auth-core/src/main/java/com/example/auth/core/service/UserService.java</code></p><figure class="highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><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></pre></td><td class="code"><pre><span class="line"><span class="keyword">package</span> com.example.auth.core.service;</span><br><span class="line"></span><br><span class="line"><span class="keyword">import</span> com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;</span><br><span class="line"><span class="keyword">import</span> com.example.auth.core.entity.Auth;</span><br><span class="line"><span class="keyword">import</span> com.example.auth.core.entity.User;</span><br><span class="line"><span class="keyword">import</span> com.example.auth.core.enums.AuthType;</span><br><span class="line"><span class="keyword">import</span> com.example.auth.core.mapper.AuthMapper;</span><br><span class="line"><span class="keyword">import</span> com.example.auth.core.mapper.UserMapper;</span><br><span class="line"><span class="keyword">import</span> com.example.auth.core.model.request.RegisterRequest;</span><br><span class="line"><span class="keyword">import</span> com.example.auth.core.util.PasswordEncoder;</span><br><span class="line"><span class="keyword">import</span> lombok.RequiredArgsConstructor;</span><br><span class="line"><span class="keyword">import</span> lombok.extern.slf4j.Slf4j;</span><br><span class="line"><span class="keyword">import</span> org.springframework.dao.DuplicateKeyException;</span><br><span class="line"><span class="keyword">import</span> org.springframework.stereotype.Service;</span><br><span class="line"><span class="keyword">import</span> org.springframework.transaction.annotation.Transactional;</span><br><span class="line"></span><br><span class="line"><span class="comment">/**</span></span><br><span class="line"><span class="comment"> * 用户服务</span></span><br><span class="line"><span class="comment"> */</span></span><br><span class="line"><span class="meta">@Slf4j</span></span><br><span class="line"><span class="meta">@Service</span></span><br><span class="line"><span class="meta">@RequiredArgsConstructor</span></span><br><span class="line"><span class="keyword">public</span> <span class="keyword">class</span> <span class="title class_">UserService</span> &#123;</span><br><span class="line"></span><br><span class="line">    <span class="keyword">private</span> <span class="keyword">final</span> UserMapper userMapper;</span><br><span class="line">    <span class="keyword">private</span> <span class="keyword">final</span> AuthMapper authMapper;</span><br><span class="line">    <span class="keyword">private</span> <span class="keyword">final</span> PasswordEncoder passwordEncoder;</span><br><span class="line">    <span class="keyword">private</span> <span class="keyword">final</span> CaptchaService captchaService;</span><br><span class="line">    <span class="keyword">private</span> <span class="keyword">final</span> EmailService emailService;</span><br><span class="line"></span><br><span class="line">    <span class="comment">/**</span></span><br><span class="line"><span class="comment">     * 用户注册</span></span><br><span class="line"><span class="comment">     *</span></span><br><span class="line"><span class="comment">     * <span class="doctag">@param</span> request 注册请求</span></span><br><span class="line"><span class="comment">     * <span class="doctag">@return</span> 用户 ID</span></span><br><span class="line"><span class="comment">     */</span></span><br><span class="line">    <span class="meta">@Transactional(rollbackFor = Exception.class)</span></span><br><span class="line">    <span class="keyword">public</span> Long <span class="title function_">register</span><span class="params">(RegisterRequest request)</span> &#123;</span><br><span class="line">        log.info(<span class="string">&quot;开始注册用户: username=&#123;&#125;, email=&#123;&#125;&quot;</span>, request.getUsername(), request.getEmail());</span><br><span class="line"></span><br><span class="line">        <span class="comment">// 1. 验证验证码</span></span><br><span class="line">        <span class="keyword">if</span> (!captchaService.verifyCaptcha(request.getCaptchaKey(), request.getCaptchaCode())) &#123;</span><br><span class="line">            <span class="keyword">throw</span> <span class="keyword">new</span> <span class="title class_">RuntimeException</span>(<span class="string">&quot;验证码错误或已过期&quot;</span>);</span><br><span class="line">        &#125;</span><br><span class="line"></span><br><span class="line">        <span class="comment">// 2. 检查用户名是否已存在</span></span><br><span class="line">        <span class="type">Auth</span> <span class="variable">existingAuth</span> <span class="operator">=</span> authMapper.selectOne(<span class="keyword">new</span> <span class="title class_">LambdaQueryWrapper</span>&lt;Auth&gt;()</span><br><span class="line">                .eq(Auth::getIdentityType, AuthType.PASSWORD.name())</span><br><span class="line">                .eq(Auth::getIdentifier, request.getUsername()));</span><br><span class="line">        <span class="keyword">if</span> (existingAuth != <span class="literal">null</span>) &#123;</span><br><span class="line">            <span class="keyword">throw</span> <span class="keyword">new</span> <span class="title class_">RuntimeException</span>(<span class="string">&quot;用户名已存在&quot;</span>);</span><br><span class="line">        &#125;</span><br><span class="line"></span><br><span class="line">        <span class="comment">// 3. 检查邮箱是否已存在</span></span><br><span class="line">        <span class="type">Auth</span> <span class="variable">existingEmail</span> <span class="operator">=</span> authMapper.selectOne(<span class="keyword">new</span> <span class="title class_">LambdaQueryWrapper</span>&lt;Auth&gt;()</span><br><span class="line">                .eq(Auth::getIdentityType, AuthType.EMAIL.name())</span><br><span class="line">                .eq(Auth::getIdentifier, request.getEmail()));</span><br><span class="line">        <span class="keyword">if</span> (existingEmail != <span class="literal">null</span>) &#123;</span><br><span class="line">            <span class="keyword">throw</span> <span class="keyword">new</span> <span class="title class_">RuntimeException</span>(<span class="string">&quot;邮箱已被注册&quot;</span>);</span><br><span class="line">        &#125;</span><br><span class="line"></span><br><span class="line">        <span class="comment">// 4. 创建用户主体</span></span><br><span class="line">        <span class="type">User</span> <span class="variable">user</span> <span class="operator">=</span> <span class="keyword">new</span> <span class="title class_">User</span>();</span><br><span class="line">        user.setNickname(request.getUsername());</span><br><span class="line">        user.setAvatar(<span class="string">&quot;https://cdn.example.com/default-avatar.png&quot;</span>);</span><br><span class="line">        user.setStatus(<span class="number">2</span>);  <span class="comment">// 未激活</span></span><br><span class="line">        userMapper.insert(user);</span><br><span class="line"></span><br><span class="line">        <span class="comment">// 5. 创建账号密码凭证</span></span><br><span class="line">        <span class="type">Auth</span> <span class="variable">passwordAuth</span> <span class="operator">=</span> <span class="keyword">new</span> <span class="title class_">Auth</span>();</span><br><span class="line">        passwordAuth.setUserId(user.getId());</span><br><span class="line">        passwordAuth.setIdentityType(AuthType.PASSWORD.name());</span><br><span class="line">        passwordAuth.setIdentifier(request.getUsername());</span><br><span class="line">        passwordAuth.setCredential(passwordEncoder.encode(request.getPassword()));</span><br><span class="line">        passwordAuth.setVerified(<span class="number">0</span>);  <span class="comment">// 未验证</span></span><br><span class="line"></span><br><span class="line">        <span class="keyword">try</span> &#123;</span><br><span class="line">            authMapper.insert(passwordAuth);</span><br><span class="line">        &#125; <span class="keyword">catch</span> (DuplicateKeyException e) &#123;</span><br><span class="line">            <span class="keyword">throw</span> <span class="keyword">new</span> <span class="title class_">RuntimeException</span>(<span class="string">&quot;用户名已存在&quot;</span>);</span><br><span class="line">        &#125;</span><br><span class="line"></span><br><span class="line">        <span class="comment">// 6. 创建邮箱凭证</span></span><br><span class="line">        <span class="type">Auth</span> <span class="variable">emailAuth</span> <span class="operator">=</span> <span class="keyword">new</span> <span class="title class_">Auth</span>();</span><br><span class="line">        emailAuth.setUserId(user.getId());</span><br><span class="line">        emailAuth.setIdentityType(AuthType.EMAIL.name());</span><br><span class="line">        emailAuth.setIdentifier(request.getEmail());</span><br><span class="line">        emailAuth.setCredential(<span class="literal">null</span>);  <span class="comment">// 邮箱登录不需要密码</span></span><br><span class="line">        emailAuth.setVerified(<span class="number">0</span>);  <span class="comment">// 未验证</span></span><br><span class="line"></span><br><span class="line">        <span class="keyword">try</span> &#123;</span><br><span class="line">            authMapper.insert(emailAuth);</span><br><span class="line">        &#125; <span class="keyword">catch</span> (DuplicateKeyException e) &#123;</span><br><span class="line">            <span class="keyword">throw</span> <span class="keyword">new</span> <span class="title class_">RuntimeException</span>(<span class="string">&quot;邮箱已被注册&quot;</span>);</span><br><span class="line">        &#125;</span><br><span class="line"></span><br><span class="line">        <span class="comment">// 7. 发送激活邮件（异步）</span></span><br><span class="line">        emailService.sendActivationEmail(request.getEmail(), user.getId());</span><br><span class="line"></span><br><span class="line">        log.info(<span class="string">&quot;用户注册成功: userId=&#123;&#125;, username=&#123;&#125;&quot;</span>, user.getId(), request.getUsername());</span><br><span class="line">        <span class="keyword">return</span> user.getId();</span><br><span class="line">    &#125;</span><br><span class="line"></span><br><span class="line">    <span class="comment">/**</span></span><br><span class="line"><span class="comment">     * 激活账号</span></span><br><span class="line"><span class="comment">     *</span></span><br><span class="line"><span class="comment">     * <span class="doctag">@param</span> userId    用户 ID</span></span><br><span class="line"><span class="comment">     * <span class="doctag">@param</span> timestamp 时间戳</span></span><br><span class="line"><span class="comment">     * <span class="doctag">@param</span> sign      签名</span></span><br><span class="line"><span class="comment">     */</span></span><br><span class="line">    <span class="meta">@Transactional(rollbackFor = Exception.class)</span></span><br><span class="line">    <span class="keyword">public</span> <span class="keyword">void</span> <span class="title function_">activateAccount</span><span class="params">(Long userId, Long timestamp, String sign)</span> &#123;</span><br><span class="line">        log.info(<span class="string">&quot;开始激活账号: userId=&#123;&#125;&quot;</span>, userId);</span><br><span class="line"></span><br><span class="line">        <span class="comment">// 1. 验证签名</span></span><br><span class="line">        <span class="type">String</span> <span class="variable">data</span> <span class="operator">=</span> userId + <span class="string">&quot;:&quot;</span> + timestamp;</span><br><span class="line">        <span class="comment">// 注入 HmacUtil 进行验证</span></span><br><span class="line">        <span class="comment">// if (! hmacUtil.verify(data, sign)) &#123;</span></span><br><span class="line">        <span class="comment">//     throw new RuntimeException(&quot;激活链接无效&quot;);</span></span><br><span class="line">        <span class="comment">// &#125;</span></span><br><span class="line"></span><br><span class="line">        <span class="comment">// 2. 验证时间戳（24 小时有效期）</span></span><br><span class="line">        <span class="keyword">if</span> (System.currentTimeMillis() &gt; timestamp) &#123;</span><br><span class="line">            <span class="keyword">throw</span> <span class="keyword">new</span> <span class="title class_">RuntimeException</span>(<span class="string">&quot;激活链接已过期&quot;</span>);</span><br><span class="line">        &#125;</span><br><span class="line"></span><br><span class="line">        <span class="comment">// 3. 查询用户</span></span><br><span class="line">        <span class="type">User</span> <span class="variable">user</span> <span class="operator">=</span> userMapper.selectById(userId);</span><br><span class="line">        <span class="keyword">if</span> (user == <span class="literal">null</span>) &#123;</span><br><span class="line">            <span class="keyword">throw</span> <span class="keyword">new</span> <span class="title class_">RuntimeException</span>(<span class="string">&quot;用户不存在&quot;</span>);</span><br><span class="line">        &#125;</span><br><span class="line"></span><br><span class="line">        <span class="comment">// 4. 检查是否已激活</span></span><br><span class="line">        <span class="keyword">if</span> (user.getStatus() == <span class="number">1</span>) &#123;</span><br><span class="line">            <span class="keyword">throw</span> <span class="keyword">new</span> <span class="title class_">RuntimeException</span>(<span class="string">&quot;账号已激活，无需重复激活&quot;</span>);</span><br><span class="line">        &#125;</span><br><span class="line"></span><br><span class="line">        <span class="comment">// 5. 更新用户状态</span></span><br><span class="line">        user.setStatus(<span class="number">1</span>);  <span class="comment">// 启用</span></span><br><span class="line">        userMapper.updateById(user);</span><br><span class="line"></span><br><span class="line">        <span class="comment">// 6. 更新所有凭证的验证状态</span></span><br><span class="line">        authMapper.update(<span class="literal">null</span>, <span class="keyword">new</span> <span class="title class_">LambdaQueryWrapper</span>&lt;Auth&gt;()</span><br><span class="line">                .eq(Auth::getUserId, userId)</span><br><span class="line">                .set(Auth::getVerified, <span class="number">1</span>));</span><br><span class="line"></span><br><span class="line">        log.info(<span class="string">&quot;账号激活成功: userId=&#123;&#125;&quot;</span>, userId);</span><br><span class="line">    &#125;</span><br><span class="line"></span><br><span class="line">    <span class="comment">/**</span></span><br><span class="line"><span class="comment">     * 根据用户 ID 查询用户</span></span><br><span class="line"><span class="comment">     *</span></span><br><span class="line"><span class="comment">     * <span class="doctag">@param</span> userId 用户 ID</span></span><br><span class="line"><span class="comment">     * <span class="doctag">@return</span> 用户信息</span></span><br><span class="line"><span class="comment">     */</span></span><br><span class="line">    <span class="keyword">public</span> User <span class="title function_">getUserById</span><span class="params">(Long userId)</span> &#123;</span><br><span class="line">        <span class="keyword">return</span> userMapper.selectById(userId);</span><br><span class="line">    &#125;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><hr><h3 id="实现注册接口">实现注册接口</h3><p><strong>📄 文件路径</strong>：<code>auth-web/src/main/java/com/example/auth/web/controller/AuthController.java</code>（追加）</p><figure class="highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><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><span class="line"><span class="comment">/**</span></span><br><span class="line"><span class="comment"> * 用户注册接口</span></span><br><span class="line"><span class="comment"> *</span></span><br><span class="line"><span class="comment"> * <span class="doctag">@param</span> request 注册请求</span></span><br><span class="line"><span class="comment"> * <span class="doctag">@return</span> 统一响应格式</span></span><br><span class="line"><span class="comment"> */</span></span><br><span class="line"><span class="meta">@PostMapping(&quot;/register&quot;)</span></span><br><span class="line"><span class="keyword">public</span> Result&lt;Map&lt;String, Object&gt;&gt; <span class="title function_">register</span><span class="params">(<span class="meta">@Valid</span> <span class="meta">@RequestBody</span> RegisterRequest request)</span> &#123;</span><br><span class="line">    log.info(<span class="string">&quot;收到注册请求: username=&#123;&#125;, email=&#123;&#125;&quot;</span>, request.getUsername(), request.getEmail());</span><br><span class="line"></span><br><span class="line">    <span class="keyword">try</span> &#123;</span><br><span class="line">        <span class="comment">// 注册用户</span></span><br><span class="line">        <span class="type">Long</span> <span class="variable">userId</span> <span class="operator">=</span> userService.register(request);</span><br><span class="line"></span><br><span class="line">        <span class="comment">// 返回用户 ID</span></span><br><span class="line">        Map&lt;String, Object&gt; data = <span class="keyword">new</span> <span class="title class_">HashMap</span>&lt;&gt;();</span><br><span class="line">        data.put(<span class="string">&quot;userId&quot;</span>, userId);</span><br><span class="line">        data.put(<span class="string">&quot;message&quot;</span>, <span class="string">&quot;注册成功，请查收激活邮件&quot;</span>);</span><br><span class="line"></span><br><span class="line">        <span class="keyword">return</span> Result.ok(data);</span><br><span class="line">    &#125; <span class="keyword">catch</span> (Exception e) &#123;</span><br><span class="line">        log.error(<span class="string">&quot;注册失败&quot;</span>, e);</span><br><span class="line">        <span class="keyword">return</span> Result.fail(e.getMessage());</span><br><span class="line">    &#125;</span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="comment">/**</span></span><br><span class="line"><span class="comment"> * 激活账号接口</span></span><br><span class="line"><span class="comment"> *</span></span><br><span class="line"><span class="comment"> * <span class="doctag">@param</span> userId    用户 ID</span></span><br><span class="line"><span class="comment"> * <span class="doctag">@param</span> timestamp 时间戳</span></span><br><span class="line"><span class="comment"> * <span class="doctag">@param</span> sign      签名</span></span><br><span class="line"><span class="comment"> * <span class="doctag">@return</span> 统一响应格式</span></span><br><span class="line"><span class="comment"> */</span></span><br><span class="line"><span class="meta">@GetMapping(&quot;/activate&quot;)</span></span><br><span class="line"><span class="keyword">public</span> Result&lt;String&gt; <span class="title function_">activate</span><span class="params">(<span class="meta">@RequestParam</span> Long userId,</span></span><br><span class="line"><span class="params">                                <span class="meta">@RequestParam</span> Long timestamp,</span></span><br><span class="line"><span class="params">                                <span class="meta">@RequestParam</span> String sign)</span> &#123;</span><br><span class="line">    log.info(<span class="string">&quot;收到激活请求: userId=&#123;&#125;&quot;</span>, userId);</span><br><span class="line"></span><br><span class="line">    <span class="keyword">try</span> &#123;</span><br><span class="line">        userService.activateAccount(userId, timestamp, sign);</span><br><span class="line">        <span class="keyword">return</span> Result.ok(<span class="string">&quot;账号激活成功，请登录&quot;</span>);</span><br><span class="line">    &#125; <span class="keyword">catch</span> (Exception e) &#123;</span><br><span class="line">        log.error(<span class="string">&quot;激活失败&quot;</span>, e);</span><br><span class="line">        <span class="keyword">return</span> Result.fail(e.getMessage());</span><br><span class="line">    &#125;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><hr><h3 id="Postman-测试">Postman 测试</h3><p><strong>步骤 1：生成验证码</strong></p><ul><li><strong>方法</strong>：<code>GET</code></li><li><strong>URL</strong>：<code>http://localhost:8080/captcha/generate</code></li></ul><p><strong>响应示例</strong>：</p><figure class="highlight json"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br></pre></td><td class="code"><pre><span class="line"><span class="punctuation">&#123;</span></span><br><span class="line">  <span class="attr">&quot;code&quot;</span><span class="punctuation">:</span> <span class="number">200</span><span class="punctuation">,</span></span><br><span class="line">  <span class="attr">&quot;message&quot;</span><span class="punctuation">:</span> <span class="string">&quot;操作成功&quot;</span><span class="punctuation">,</span></span><br><span class="line">  <span class="attr">&quot;data&quot;</span><span class="punctuation">:</span> <span class="punctuation">&#123;</span></span><br><span class="line">    <span class="attr">&quot;key&quot;</span><span class="punctuation">:</span> <span class="string">&quot;a1b2c3d4e5f6g7h8&quot;</span><span class="punctuation">,</span></span><br><span class="line">    <span class="attr">&quot;image&quot;</span><span class="punctuation">:</span> <span class="string">&quot;data:image/png;base64,iVBORw0KGg...&quot;</span></span><br><span class="line">  <span class="punctuation">&#125;</span></span><br><span class="line"><span class="punctuation">&#125;</span></span><br></pre></td></tr></table></figure><p><strong>步骤 2：注册用户</strong></p><ul><li><strong>方法</strong>：<code>POST</code></li><li><strong>URL</strong>：<code>http://localhost:8080/auth/register</code></li><li><strong>Body</strong>：</li></ul><figure class="highlight json"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br></pre></td><td class="code"><pre><span class="line"><span class="punctuation">&#123;</span></span><br><span class="line">  <span class="attr">&quot;username&quot;</span><span class="punctuation">:</span> <span class="string">&quot;testuser&quot;</span><span class="punctuation">,</span></span><br><span class="line">  <span class="attr">&quot;password&quot;</span><span class="punctuation">:</span> <span class="string">&quot;Test1234&quot;</span><span class="punctuation">,</span></span><br><span class="line">  <span class="attr">&quot;email&quot;</span><span class="punctuation">:</span> <span class="string">&quot;test@example.com&quot;</span><span class="punctuation">,</span></span><br><span class="line">  <span class="attr">&quot;captchaKey&quot;</span><span class="punctuation">:</span> <span class="string">&quot;a1b2c3d4e5f6g7h8&quot;</span><span class="punctuation">,</span></span><br><span class="line">  <span class="attr">&quot;captchaCode&quot;</span><span class="punctuation">:</span> <span class="string">&quot;ABCD&quot;</span></span><br><span class="line"><span class="punctuation">&#125;</span></span><br></pre></td></tr></table></figure><p><strong>响应示例</strong>：</p><figure class="highlight json"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br></pre></td><td class="code"><pre><span class="line"><span class="punctuation">&#123;</span></span><br><span class="line">  <span class="attr">&quot;code&quot;</span><span class="punctuation">:</span> <span class="number">200</span><span class="punctuation">,</span></span><br><span class="line">  <span class="attr">&quot;message&quot;</span><span class="punctuation">:</span> <span class="string">&quot;操作成功&quot;</span><span class="punctuation">,</span></span><br><span class="line">  <span class="attr">&quot;data&quot;</span><span class="punctuation">:</span> <span class="punctuation">&#123;</span></span><br><span class="line">    <span class="attr">&quot;userId&quot;</span><span class="punctuation">:</span> <span class="number">1748392847362</span><span class="punctuation">,</span></span><br><span class="line">    <span class="attr">&quot;message&quot;</span><span class="punctuation">:</span> <span class="string">&quot;注册成功，请查收激活邮件&quot;</span></span><br><span class="line">  <span class="punctuation">&#125;</span></span><br><span class="line"><span class="punctuation">&#125;</span></span><br></pre></td></tr></table></figure><p><strong>步骤 3：查收激活邮件</strong></p><p>登录邮箱，查看激活邮件，点击激活链接。</p><p><strong>步骤 4：激活账号</strong></p><ul><li><strong>方法</strong>：<code>GET</code></li><li><strong>URL</strong>：<code>http://localhost:8080/auth/activate?userId=1748392847362&amp;timestamp=1735372800000&amp;sign=a1b2c3d4e5f6g7h8</code></li></ul><p><strong>响应示例</strong>：</p><figure class="highlight json"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br></pre></td><td class="code"><pre><span class="line"><span class="punctuation">&#123;</span></span><br><span class="line">  <span class="attr">&quot;code&quot;</span><span class="punctuation">:</span> <span class="number">200</span><span class="punctuation">,</span></span><br><span class="line">  <span class="attr">&quot;message&quot;</span><span class="punctuation">:</span> <span class="string">&quot;操作成功&quot;</span><span class="punctuation">,</span></span><br><span class="line">  <span class="attr">&quot;data&quot;</span><span class="punctuation">:</span> <span class="string">&quot;账号激活成功，请登录&quot;</span></span><br><span class="line"><span class="punctuation">&#125;</span></span><br></pre></td></tr></table></figure><hr><h2 id="19-3-8-登录流程：实现-PasswordAuthStrategy">19.3.8. 登录流程：实现 PasswordAuthStrategy</h2><p>在上一节中，我们实现了用户注册流程。现在我们需要实现真正的 <code>PasswordAuthStrategy</code>，替换 19.2 中的 <code>MockAuthStrategy</code>。</p><h3 id="实现-PasswordAuthStrategy">实现 PasswordAuthStrategy</h3><p><strong>📄 文件路径</strong>：<code>auth-core/src/main/java/com/example/auth/core/strategy/impl/PasswordAuthStrategy.java</code></p><figure class="highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><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></pre></td><td class="code"><pre><span class="line"><span class="keyword">package</span> com.example.auth.core.strategy.impl;</span><br><span class="line"></span><br><span class="line"><span class="keyword">import</span> com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;</span><br><span class="line"><span class="keyword">import</span> com.example.auth.core.entity.Auth;</span><br><span class="line"><span class="keyword">import</span> com.example.auth.core.entity.User;</span><br><span class="line"><span class="keyword">import</span> com.example.auth.core.enums.AuthType;</span><br><span class="line"><span class="keyword">import</span> com.example.auth.core.mapper.AuthMapper;</span><br><span class="line"><span class="keyword">import</span> com.example.auth.core.mapper.UserMapper;</span><br><span class="line"><span class="keyword">import</span> com.example.auth.core.model.AuthToken;</span><br><span class="line"><span class="keyword">import</span> com.example.auth.core.model.request.AuthRequest;</span><br><span class="line"><span class="keyword">import</span> com.example.auth.core.model.request.PasswordAuthRequest;</span><br><span class="line"><span class="keyword">import</span> com.example.auth.core.service.TokenService;</span><br><span class="line"><span class="keyword">import</span> com.example.auth.core.strategy.AuthStrategy;</span><br><span class="line"><span class="keyword">import</span> com.example.auth.core.util.PasswordEncoder;</span><br><span class="line"><span class="keyword">import</span> lombok.RequiredArgsConstructor;</span><br><span class="line"><span class="keyword">import</span> lombok.extern.slf4j.Slf4j;</span><br><span class="line"><span class="keyword">import</span> org.springframework.stereotype.Component;</span><br><span class="line"></span><br><span class="line"><span class="comment">/**</span></span><br><span class="line"><span class="comment"> * 账号密码登录策略</span></span><br><span class="line"><span class="comment"> * 替换 MockAuthStrategy</span></span><br><span class="line"><span class="comment"> */</span></span><br><span class="line"><span class="meta">@Slf4j</span></span><br><span class="line"><span class="meta">@Component</span></span><br><span class="line"><span class="meta">@RequiredArgsConstructor</span></span><br><span class="line"><span class="keyword">public</span> <span class="keyword">class</span> <span class="title class_">PasswordAuthStrategy</span> <span class="keyword">implements</span> <span class="title class_">AuthStrategy</span> &#123;</span><br><span class="line"></span><br><span class="line">    <span class="keyword">private</span> <span class="keyword">final</span> AuthMapper authMapper;</span><br><span class="line">    <span class="keyword">private</span> <span class="keyword">final</span> UserMapper userMapper;</span><br><span class="line">    <span class="keyword">private</span> <span class="keyword">final</span> TokenService tokenService;</span><br><span class="line">    <span class="keyword">private</span> <span class="keyword">final</span> PasswordEncoder passwordEncoder;</span><br><span class="line"></span><br><span class="line">    <span class="meta">@Override</span></span><br><span class="line">    <span class="keyword">public</span> AuthToken <span class="title function_">authenticate</span><span class="params">(AuthRequest request)</span> &#123;</span><br><span class="line">        log.info(<span class="string">&quot;开始执行账号密码登录&quot;</span>);</span><br><span class="line"></span><br><span class="line">        <span class="comment">// 1. 将请求转换为具体类型</span></span><br><span class="line">        <span class="type">PasswordAuthRequest</span> <span class="variable">passwordRequest</span> <span class="operator">=</span> (PasswordAuthRequest) request;</span><br><span class="line"></span><br><span class="line">        <span class="comment">// 2. 提取用户名和密码</span></span><br><span class="line">        <span class="type">String</span> <span class="variable">username</span> <span class="operator">=</span> passwordRequest.getUsername();</span><br><span class="line">        <span class="type">String</span> <span class="variable">password</span> <span class="operator">=</span> passwordRequest.getPassword();</span><br><span class="line">        log.info(<span class="string">&quot;账号密码登录: username=&#123;&#125;&quot;</span>, username);</span><br><span class="line"></span><br><span class="line">        <span class="comment">// 3. 根据用户名查询 sys_auth 表</span></span><br><span class="line">        <span class="type">Auth</span> <span class="variable">auth</span> <span class="operator">=</span> authMapper.selectOne(<span class="keyword">new</span> <span class="title class_">LambdaQueryWrapper</span>&lt;Auth&gt;()</span><br><span class="line">                .eq(Auth::getIdentityType, AuthType.PASSWORD.name())</span><br><span class="line">                .eq(Auth::getIdentifier, username));</span><br><span class="line"></span><br><span class="line">        <span class="comment">// 4. 检查用户是否存在</span></span><br><span class="line">        <span class="keyword">if</span> (auth == <span class="literal">null</span>) &#123;</span><br><span class="line">            log.warn(<span class="string">&quot;用户不存在: username=&#123;&#125;&quot;</span>, username);</span><br><span class="line">            <span class="keyword">throw</span> <span class="keyword">new</span> <span class="title class_">RuntimeException</span>(<span class="string">&quot;用户名或密码错误&quot;</span>);</span><br><span class="line">        &#125;</span><br><span class="line"></span><br><span class="line">        <span class="comment">// 5. 验证密码</span></span><br><span class="line">        <span class="keyword">if</span> (!passwordEncoder.matches(password, auth.getCredential())) &#123;</span><br><span class="line">            log.warn(<span class="string">&quot;密码错误: username=&#123;&#125;&quot;</span>, username);</span><br><span class="line">            <span class="keyword">throw</span> <span class="keyword">new</span> <span class="title class_">RuntimeException</span>(<span class="string">&quot;用户名或密码错误&quot;</span>);</span><br><span class="line">        &#125;</span><br><span class="line"></span><br><span class="line">        <span class="comment">// 6. 根据 user_id 查询 sys_user 表</span></span><br><span class="line">        <span class="type">User</span> <span class="variable">user</span> <span class="operator">=</span> userMapper.selectById(auth.getUserId());</span><br><span class="line">        <span class="keyword">if</span> (user == <span class="literal">null</span>) &#123;</span><br><span class="line">            log.error(<span class="string">&quot;用户主体不存在: userId=&#123;&#125;&quot;</span>, auth.getUserId());</span><br><span class="line">            <span class="keyword">throw</span> <span class="keyword">new</span> <span class="title class_">RuntimeException</span>(<span class="string">&quot;用户数据异常&quot;</span>);</span><br><span class="line">        &#125;</span><br><span class="line"></span><br><span class="line">        <span class="comment">// 7. 检查账号状态</span></span><br><span class="line">        <span class="keyword">if</span> (user.getStatus() == <span class="number">0</span>) &#123;</span><br><span class="line">            <span class="keyword">throw</span> <span class="keyword">new</span> <span class="title class_">RuntimeException</span>(<span class="string">&quot;账号已被禁用，请联系管理员&quot;</span>);</span><br><span class="line">        &#125;</span><br><span class="line">        <span class="keyword">if</span> (user.getStatus() == <span class="number">2</span>) &#123;</span><br><span class="line">            <span class="keyword">throw</span> <span class="keyword">new</span> <span class="title class_">RuntimeException</span>(<span class="string">&quot;账号未激活，请先激活邮箱&quot;</span>);</span><br><span class="line">        &#125;</span><br><span class="line"></span><br><span class="line">        <span class="comment">// 8. 生成双令牌</span></span><br><span class="line">        <span class="type">AuthToken</span> <span class="variable">authToken</span> <span class="operator">=</span> tokenService.createTokenPair(user.getId(), user.getNickname());</span><br><span class="line"></span><br><span class="line">        log.info(<span class="string">&quot;账号密码登录成功: userId=&#123;&#125;, username=&#123;&#125;&quot;</span>, user.getId(), username);</span><br><span class="line">        <span class="keyword">return</span> authToken;</span><br><span class="line">    &#125;</span><br><span class="line"></span><br><span class="line">    <span class="meta">@Override</span></span><br><span class="line">    <span class="keyword">public</span> AuthType <span class="title function_">getSupportedType</span><span class="params">()</span> &#123;</span><br><span class="line">        <span class="keyword">return</span> AuthType.PASSWORD;</span><br><span class="line">    &#125;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><hr><h3 id="删除-MockAuthStrategy">删除 MockAuthStrategy</h3><p><strong>📄 文件路径</strong>：<code>auth-core/src/main/java/com/example/auth/core/strategy/impl/MockAuthStrategy.java</code>（删除）</p><p>删除这个文件，因为我们已经有了真正的 <code>PasswordAuthStrategy</code>。</p><hr><h3 id="Postman-测试-2">Postman 测试</h3><p><strong>步骤 1：测试登录（账号未激活）</strong></p><ul><li><strong>方法</strong>：<code>POST</code></li><li><strong>URL</strong>：<code>http://localhost:8080/auth/login</code></li><li><strong>Body</strong>：</li></ul><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><span class="line"><span class="punctuation">&#123;</span></span><br><span class="line">  <span class="attr">&quot;authType&quot;</span><span class="punctuation">:</span> <span class="string">&quot;PASSWORD&quot;</span><span class="punctuation">,</span></span><br><span class="line">  <span class="attr">&quot;username&quot;</span><span class="punctuation">:</span> <span class="string">&quot;testuser&quot;</span><span class="punctuation">,</span></span><br><span class="line">  <span class="attr">&quot;password&quot;</span><span class="punctuation">:</span> <span class="string">&quot;Test1234&quot;</span></span><br><span class="line"><span class="punctuation">&#125;</span></span><br></pre></td></tr></table></figure><p><strong>响应示例</strong>：</p><figure class="highlight json"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br></pre></td><td class="code"><pre><span class="line"><span class="punctuation">&#123;</span></span><br><span class="line">  <span class="attr">&quot;code&quot;</span><span class="punctuation">:</span> <span class="number">400</span><span class="punctuation">,</span></span><br><span class="line">  <span class="attr">&quot;message&quot;</span><span class="punctuation">:</span> <span class="string">&quot;账号未激活，请先激活邮箱&quot;</span><span class="punctuation">,</span></span><br><span class="line">  <span class="attr">&quot;data&quot;</span><span class="punctuation">:</span> <span class="literal"><span class="keyword">null</span></span></span><br><span class="line"><span class="punctuation">&#125;</span></span><br></pre></td></tr></table></figure><p><strong>步骤 2：激活账号</strong></p><p>点击邮件中的激活链接，或者调用激活接口。</p><p><strong>步骤 3：测试登录（账号已激活）</strong></p><ul><li><strong>方法</strong>：<code>POST</code></li><li><strong>URL</strong>：<code>http://localhost:8080/auth/login</code></li><li><strong>Body</strong>：</li></ul><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><span class="line"><span class="punctuation">&#123;</span></span><br><span class="line">  <span class="attr">&quot;authType&quot;</span><span class="punctuation">:</span> <span class="string">&quot;PASSWORD&quot;</span><span class="punctuation">,</span></span><br><span class="line">  <span class="attr">&quot;username&quot;</span><span class="punctuation">:</span> <span class="string">&quot;testuser&quot;</span><span class="punctuation">,</span></span><br><span class="line">  <span class="attr">&quot;password&quot;</span><span class="punctuation">:</span> <span class="string">&quot;Test1234&quot;</span></span><br><span class="line"><span class="punctuation">&#125;</span></span><br></pre></td></tr></table></figure><p><strong>响应示例</strong>：</p><figure class="highlight json"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br></pre></td><td class="code"><pre><span class="line"><span class="punctuation">&#123;</span></span><br><span class="line">  <span class="attr">&quot;code&quot;</span><span class="punctuation">:</span> <span class="number">200</span><span class="punctuation">,</span></span><br><span class="line">  <span class="attr">&quot;message&quot;</span><span class="punctuation">:</span> <span class="string">&quot;操作成功&quot;</span><span class="punctuation">,</span></span><br><span class="line">  <span class="attr">&quot;data&quot;</span><span class="punctuation">:</span> <span class="punctuation">&#123;</span></span><br><span class="line">    <span class="attr">&quot;accessToken&quot;</span><span class="punctuation">:</span> <span class="string">&quot;eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...&quot;</span><span class="punctuation">,</span></span><br><span class="line">    <span class="attr">&quot;refreshToken&quot;</span><span class="punctuation">:</span> <span class="string">&quot;a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6&quot;</span><span class="punctuation">,</span></span><br><span class="line">    <span class="attr">&quot;expiresIn&quot;</span><span class="punctuation">:</span> <span class="number">900</span><span class="punctuation">,</span></span><br><span class="line">    <span class="attr">&quot;tokenType&quot;</span><span class="punctuation">:</span> <span class="string">&quot;Bearer&quot;</span></span><br><span class="line">  <span class="punctuation">&#125;</span></span><br><span class="line"><span class="punctuation">&#125;</span></span><br></pre></td></tr></table></figure><p><strong>步骤 4：测试登录（密码错误）</strong></p><ul><li><strong>方法</strong>：<code>POST</code></li><li><strong>URL</strong>：<code>http://localhost:8080/auth/login</code></li><li><strong>Body</strong>：</li></ul><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><span class="line"><span class="punctuation">&#123;</span></span><br><span class="line">  <span class="attr">&quot;authType&quot;</span><span class="punctuation">:</span> <span class="string">&quot;PASSWORD&quot;</span><span class="punctuation">,</span></span><br><span class="line">  <span class="attr">&quot;username&quot;</span><span class="punctuation">:</span> <span class="string">&quot;testuser&quot;</span><span class="punctuation">,</span></span><br><span class="line">  <span class="attr">&quot;password&quot;</span><span class="punctuation">:</span> <span class="string">&quot;WrongPassword&quot;</span></span><br><span class="line"><span class="punctuation">&#125;</span></span><br></pre></td></tr></table></figure><p><strong>响应示例</strong>：</p><figure class="highlight json"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br></pre></td><td class="code"><pre><span class="line"><span class="punctuation">&#123;</span></span><br><span class="line">  <span class="attr">&quot;code&quot;</span><span class="punctuation">:</span> <span class="number">400</span><span class="punctuation">,</span></span><br><span class="line">  <span class="attr">&quot;message&quot;</span><span class="punctuation">:</span> <span class="string">&quot;用户名或密码错误&quot;</span><span class="punctuation">,</span></span><br><span class="line">  <span class="attr">&quot;data&quot;</span><span class="punctuation">:</span> <span class="literal"><span class="keyword">null</span></span></span><br><span class="line"><span class="punctuation">&#125;</span></span><br></pre></td></tr></table></figure><hr><h2 id="19-3-9-找回密码：重置密码流程">19.3.9. 找回密码：重置密码流程</h2><p>在上一节中，我们实现了登录流程。现在我们需要实现找回密码功能。</p><h3 id="定义找回密码请求">定义找回密码请求</h3><p><strong>📄 文件路径</strong>：<code>auth-core/src/main/java/com/example/auth/core/model/request/ForgotPasswordRequest.java</code></p><figure class="highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><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><span class="line"><span class="keyword">package</span> com.example.auth.core.model.request;</span><br><span class="line"></span><br><span class="line"><span class="keyword">import</span> jakarta.validation.constraints.Email;</span><br><span class="line"><span class="keyword">import</span> jakarta.validation.constraints.NotBlank;</span><br><span class="line"><span class="keyword">import</span> lombok.Data;</span><br><span class="line"></span><br><span class="line"><span class="comment">/**</span></span><br><span class="line"><span class="comment"> * 找回密码请求</span></span><br><span class="line"><span class="comment"> */</span></span><br><span class="line"><span class="meta">@Data</span></span><br><span class="line"><span class="keyword">public</span> <span class="keyword">class</span> <span class="title class_">ForgotPasswordRequest</span> &#123;</span><br><span class="line"></span><br><span class="line">    <span class="comment">/**</span></span><br><span class="line"><span class="comment">     * 邮箱</span></span><br><span class="line"><span class="comment">     */</span></span><br><span class="line">    <span class="meta">@NotBlank(message = &quot;邮箱不能为空&quot;)</span></span><br><span class="line">    <span class="meta">@Email(message = &quot;邮箱格式不正确&quot;)</span></span><br><span class="line">    <span class="keyword">private</span> String email;</span><br><span class="line"></span><br><span class="line">    <span class="comment">/**</span></span><br><span class="line"><span class="comment">     * 验证码 Key</span></span><br><span class="line"><span class="comment">     */</span></span><br><span class="line">    <span class="meta">@NotBlank(message = &quot;验证码 Key 不能为空&quot;)</span></span><br><span class="line">    <span class="keyword">private</span> String captchaKey;</span><br><span class="line"></span><br><span class="line">    <span class="comment">/**</span></span><br><span class="line"><span class="comment">     * 验证码</span></span><br><span class="line"><span class="comment">     */</span></span><br><span class="line">    <span class="meta">@NotBlank(message = &quot;验证码不能为空&quot;)</span></span><br><span class="line">    <span class="keyword">private</span> String captchaCode;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p><strong>📄 文件路径</strong>：<code>auth-core/src/main/java/com/example/auth/core/model/request/ResetPasswordRequest.java</code></p><figure class="highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><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></pre></td><td class="code"><pre><span class="line"><span class="keyword">package</span> com.example.auth.core.model.request;</span><br><span class="line"></span><br><span class="line"><span class="keyword">import</span> jakarta.validation.constraints.NotBlank;</span><br><span class="line"><span class="keyword">import</span> jakarta.validation.constraints.Pattern;</span><br><span class="line"><span class="keyword">import</span> lombok.Data;</span><br><span class="line"></span><br><span class="line"><span class="comment">/**</span></span><br><span class="line"><span class="comment"> * 重置密码请求</span></span><br><span class="line"><span class="comment"> */</span></span><br><span class="line"><span class="meta">@Data</span></span><br><span class="line"><span class="keyword">public</span> <span class="keyword">class</span> <span class="title class_">ResetPasswordRequest</span> &#123;</span><br><span class="line"></span><br><span class="line">    <span class="comment">/**</span></span><br><span class="line"><span class="comment">     * 用户 ID</span></span><br><span class="line"><span class="comment">     */</span></span><br><span class="line">    <span class="keyword">private</span> Long userId;</span><br><span class="line"></span><br><span class="line">    <span class="comment">/**</span></span><br><span class="line"><span class="comment">     * 时间戳</span></span><br><span class="line"><span class="comment">     */</span></span><br><span class="line">    <span class="keyword">private</span> Long timestamp;</span><br><span class="line"></span><br><span class="line">    <span class="comment">/**</span></span><br><span class="line"><span class="comment">     * 签名</span></span><br><span class="line"><span class="comment">     */</span></span><br><span class="line">    <span class="keyword">private</span> String sign;</span><br><span class="line"></span><br><span class="line">    <span class="comment">/**</span></span><br><span class="line"><span class="comment">     * 新密码</span></span><br><span class="line"><span class="comment">     * 8-20 位，必须包含大小写字母、数字</span></span><br><span class="line"><span class="comment">     */</span></span><br><span class="line">    <span class="meta">@NotBlank(message = &quot;新密码不能为空&quot;)</span></span><br><span class="line">    <span class="meta">@Pattern(regexp = &quot;^(?=.*[a-z])(?=.*[A-Z])(?=.*\\d)[a-zA-Z\\d]&#123;8,20&#125;$&quot;,</span></span><br><span class="line"><span class="meta">            message = &quot;密码格式不正确（8-20位，必须包含大小写字母、数字）&quot;)</span></span><br><span class="line">    <span class="keyword">private</span> String newPassword;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><hr><h3 id="实现找回密码服务">实现找回密码服务</h3><p><strong>📄 文件路径</strong>：<code>auth-core/src/main/java/com/example/auth/core/service/UserService.java</code>（追加）</p><figure class="highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><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><span class="line"><span class="comment">/**</span></span><br><span class="line"><span class="comment"> * 找回密码（发送重置密码邮件）</span></span><br><span class="line"><span class="comment"> *</span></span><br><span class="line"><span class="comment"> * <span class="doctag">@param</span> request 找回密码请求</span></span><br><span class="line"><span class="comment"> */</span></span><br><span class="line"><span class="keyword">public</span> <span class="keyword">void</span> <span class="title function_">forgotPassword</span><span class="params">(ForgotPasswordRequest request)</span> &#123;</span><br><span class="line">    log.info(<span class="string">&quot;开始找回密码: email=&#123;&#125;&quot;</span>, request.getEmail());</span><br><span class="line"></span><br><span class="line">    <span class="comment">// 1. 验证验证码</span></span><br><span class="line">    <span class="keyword">if</span> (!captchaService.verifyCaptcha(request.getCaptchaKey(), request.getCaptchaCode())) &#123;</span><br><span class="line">        <span class="keyword">throw</span> <span class="keyword">new</span> <span class="title class_">RuntimeException</span>(<span class="string">&quot;验证码错误或已过期&quot;</span>);</span><br><span class="line">    &#125;</span><br><span class="line"></span><br><span class="line">    <span class="comment">// 2. 根据邮箱查询 sys_auth 表</span></span><br><span class="line">    <span class="type">Auth</span> <span class="variable">emailAuth</span> <span class="operator">=</span> authMapper.selectOne(<span class="keyword">new</span> <span class="title class_">LambdaQueryWrapper</span>&lt;Auth&gt;()</span><br><span class="line">            .eq(Auth::getIdentityType, AuthType.EMAIL.name())</span><br><span class="line">            .eq(Auth::getIdentifier, request.getEmail()));</span><br><span class="line"></span><br><span class="line">    <span class="comment">// 3. 检查邮箱是否存在</span></span><br><span class="line">    <span class="keyword">if</span> (emailAuth == <span class="literal">null</span>) &#123;</span><br><span class="line">        <span class="comment">// 为了防止用户枚举攻击，即使邮箱不存在也返回成功</span></span><br><span class="line">        log.warn(<span class="string">&quot;邮箱不存在: email=&#123;&#125;&quot;</span>, request.getEmail());</span><br><span class="line">        <span class="keyword">return</span>;</span><br><span class="line">    &#125;</span><br><span class="line"></span><br><span class="line">    <span class="comment">// 4. 发送重置密码邮件（异步）</span></span><br><span class="line">    emailService.sendResetPasswordEmail(request.getEmail(), emailAuth.getUserId());</span><br><span class="line"></span><br><span class="line">    log.info(<span class="string">&quot;重置密码邮件已发送: email=&#123;&#125;, userId=&#123;&#125;&quot;</span>, request.getEmail(), emailAuth.getUserId());</span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="comment">/**</span></span><br><span class="line"><span class="comment"> * 重置密码</span></span><br><span class="line"><span class="comment"> *</span></span><br><span class="line"><span class="comment"> * <span class="doctag">@param</span> request 重置密码请求</span></span><br><span class="line"><span class="comment"> */</span></span><br><span class="line"><span class="meta">@Transactional(rollbackFor = Exception.class)</span></span><br><span class="line"><span class="keyword">public</span> <span class="keyword">void</span> <span class="title function_">resetPassword</span><span class="params">(ResetPasswordRequest request)</span> &#123;</span><br><span class="line">    log.info(<span class="string">&quot;开始重置密码: userId=&#123;&#125;&quot;</span>, request.getUserId());</span><br><span class="line"></span><br><span class="line">    <span class="comment">// 1. 验证签名</span></span><br><span class="line">    <span class="comment">// String data = request.getUserId() + &quot;:&quot; + request.getTimestamp();</span></span><br><span class="line">    <span class="comment">// if (! hmacUtil.verify(data, request.getSign())) &#123;</span></span><br><span class="line">    <span class="comment">//     throw new RuntimeException(&quot;重置链接无效&quot;);</span></span><br><span class="line">    <span class="comment">// &#125;</span></span><br><span class="line"></span><br><span class="line">    <span class="comment">// 2. 验证时间戳（1 小时有效期）</span></span><br><span class="line">    <span class="keyword">if</span> (System.currentTimeMillis() &gt; request.getTimestamp()) &#123;</span><br><span class="line">        <span class="keyword">throw</span> <span class="keyword">new</span> <span class="title class_">RuntimeException</span>(<span class="string">&quot;重置链接已过期&quot;</span>);</span><br><span class="line">    &#125;</span><br><span class="line"></span><br><span class="line">    <span class="comment">// 3. 查询用户</span></span><br><span class="line">    <span class="type">User</span> <span class="variable">user</span> <span class="operator">=</span> userMapper.selectById(request.getUserId());</span><br><span class="line">    <span class="keyword">if</span> (user == <span class="literal">null</span>) &#123;</span><br><span class="line">        <span class="keyword">throw</span> <span class="keyword">new</span> <span class="title class_">RuntimeException</span>(<span class="string">&quot;用户不存在&quot;</span>);</span><br><span class="line">    &#125;</span><br><span class="line"></span><br><span class="line">    <span class="comment">// 4. 查询账号密码凭证</span></span><br><span class="line">    <span class="type">Auth</span> <span class="variable">passwordAuth</span> <span class="operator">=</span> authMapper.selectOne(<span class="keyword">new</span> <span class="title class_">LambdaQueryWrapper</span>&lt;Auth&gt;()</span><br><span class="line">            .eq(Auth::getUserId, request.getUserId())</span><br><span class="line">            .eq(Auth::getIdentityType, AuthType.PASSWORD.name()));</span><br><span class="line"></span><br><span class="line">    <span class="keyword">if</span> (passwordAuth == <span class="literal">null</span>) &#123;</span><br><span class="line">        <span class="keyword">throw</span> <span class="keyword">new</span> <span class="title class_">RuntimeException</span>(<span class="string">&quot;该账号未设置密码&quot;</span>);</span><br><span class="line">    &#125;</span><br><span class="line"></span><br><span class="line">    <span class="comment">// 5. 更新密码</span></span><br><span class="line">    passwordAuth.setCredential(passwordEncoder.encode(request.getNewPassword()));</span><br><span class="line">    authMapper.updateById(passwordAuth);</span><br><span class="line"></span><br><span class="line">    log.info(<span class="string">&quot;密码重置成功: userId=&#123;&#125;&quot;</span>, request.getUserId());</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><hr><h3 id="实现找回密码接口">实现找回密码接口</h3><p><strong>📄 文件路径</strong>：<code>auth-web/src/main/java/com/example/auth/web/controller/AuthController.java</code>（追加）</p><figure class="highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><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><span class="line"><span class="comment">/**</span></span><br><span class="line"><span class="comment"> * 找回密码接口（发送重置密码邮件）</span></span><br><span class="line"><span class="comment"> *</span></span><br><span class="line"><span class="comment"> * <span class="doctag">@param</span> request 找回密码请求</span></span><br><span class="line"><span class="comment"> * <span class="doctag">@return</span> 统一响应格式</span></span><br><span class="line"><span class="comment"> */</span></span><br><span class="line"><span class="meta">@PostMapping(&quot;/forgot-password&quot;)</span></span><br><span class="line"><span class="keyword">public</span> Result&lt;String&gt; <span class="title function_">forgotPassword</span><span class="params">(<span class="meta">@Valid</span> <span class="meta">@RequestBody</span> ForgotPasswordRequest request)</span> &#123;</span><br><span class="line">    log.info(<span class="string">&quot;收到找回密码请求: email=&#123;&#125;&quot;</span>, request.getEmail());</span><br><span class="line"></span><br><span class="line">    <span class="keyword">try</span> &#123;</span><br><span class="line">        userService.forgotPassword(request);</span><br><span class="line">        <span class="keyword">return</span> Result.ok(<span class="string">&quot;重置密码邮件已发送，请查收邮件&quot;</span>);</span><br><span class="line">    &#125; <span class="keyword">catch</span> (Exception e) &#123;</span><br><span class="line">        log.error(<span class="string">&quot;找回密码失败&quot;</span>, e);</span><br><span class="line">        <span class="keyword">return</span> Result.fail(e.getMessage());</span><br><span class="line">    &#125;</span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="comment">/**</span></span><br><span class="line"><span class="comment"> * 重置密码接口</span></span><br><span class="line"><span class="comment"> *</span></span><br><span class="line"><span class="comment"> * <span class="doctag">@param</span> request 重置密码请求</span></span><br><span class="line"><span class="comment"> * <span class="doctag">@return</span> 统一响应格式</span></span><br><span class="line"><span class="comment"> */</span></span><br><span class="line"><span class="meta">@PostMapping(&quot;/reset-password&quot;)</span></span><br><span class="line"><span class="keyword">public</span> Result&lt;String&gt; <span class="title function_">resetPassword</span><span class="params">(<span class="meta">@Valid</span> <span class="meta">@RequestBody</span> ResetPasswordRequest request)</span> &#123;</span><br><span class="line">    log.info(<span class="string">&quot;收到重置密码请求: userId=&#123;&#125;&quot;</span>, request.getUserId());</span><br><span class="line"></span><br><span class="line">    <span class="keyword">try</span> &#123;</span><br><span class="line">        userService.resetPassword(request);</span><br><span class="line">        <span class="keyword">return</span> Result.ok(<span class="string">&quot;密码重置成功，请使用新密码登录&quot;</span>);</span><br><span class="line">    &#125; <span class="keyword">catch</span> (Exception e) &#123;</span><br><span class="line">        log.error(<span class="string">&quot;重置密码失败&quot;</span>, e);</span><br><span class="line">        <span class="keyword">return</span> Result.fail(e.getMessage());</span><br><span class="line">    &#125;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><hr><h3 id="Postman-测试-3">Postman 测试</h3><p><strong>步骤 1：生成验证码</strong></p><ul><li><strong>方法</strong>：<code>GET</code></li><li><strong>URL</strong>：<code>http://localhost:8080/captcha/generate</code></li></ul><p><strong>步骤 2：找回密码（发送重置密码邮件）</strong></p><ul><li><strong>方法</strong>：<code>POST</code></li><li><strong>URL</strong>：<code>http://localhost:8080/auth/forgot-password</code></li><li><strong>Body</strong>：</li></ul><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><span class="line"><span class="punctuation">&#123;</span></span><br><span class="line">  <span class="attr">&quot;email&quot;</span><span class="punctuation">:</span> <span class="string">&quot;test@example.com&quot;</span><span class="punctuation">,</span></span><br><span class="line">  <span class="attr">&quot;captchaKey&quot;</span><span class="punctuation">:</span> <span class="string">&quot;a1b2c3d4e5f6g7h8&quot;</span><span class="punctuation">,</span></span><br><span class="line">  <span class="attr">&quot;captchaCode&quot;</span><span class="punctuation">:</span> <span class="string">&quot;ABCD&quot;</span></span><br><span class="line"><span class="punctuation">&#125;</span></span><br></pre></td></tr></table></figure><p><strong>响应示例</strong>：</p><figure class="highlight json"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br></pre></td><td class="code"><pre><span class="line"><span class="punctuation">&#123;</span></span><br><span class="line">  <span class="attr">&quot;code&quot;</span><span class="punctuation">:</span> <span class="number">200</span><span class="punctuation">,</span></span><br><span class="line">  <span class="attr">&quot;message&quot;</span><span class="punctuation">:</span> <span class="string">&quot;操作成功&quot;</span><span class="punctuation">,</span></span><br><span class="line">  <span class="attr">&quot;data&quot;</span><span class="punctuation">:</span> <span class="string">&quot;重置密码邮件已发送，请查收邮件&quot;</span></span><br><span class="line"><span class="punctuation">&#125;</span></span><br></pre></td></tr></table></figure><p><strong>步骤 3：查收重置密码邮件</strong></p><p>登录邮箱，查看重置密码邮件，点击重置密码链接。</p><p><strong>步骤 4：重置密码</strong></p><ul><li><strong>方法</strong>：<code>POST</code></li><li><strong>URL</strong>：<code>http://localhost:8080/auth/reset-password</code></li><li><strong>Body</strong>：</li></ul><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><span class="line"><span class="punctuation">&#123;</span></span><br><span class="line">  <span class="attr">&quot;userId&quot;</span><span class="punctuation">:</span> <span class="number">1748392847362</span><span class="punctuation">,</span></span><br><span class="line">  <span class="attr">&quot;timestamp&quot;</span><span class="punctuation">:</span> <span class="number">1735372800000</span><span class="punctuation">,</span></span><br><span class="line">  <span class="attr">&quot;sign&quot;</span><span class="punctuation">:</span> <span class="string">&quot;a1b2c3d4e5f6g7h8&quot;</span><span class="punctuation">,</span></span><br><span class="line">  <span class="attr">&quot;newPassword&quot;</span><span class="punctuation">:</span> <span class="string">&quot;NewPass1234&quot;</span></span><br><span class="line"><span class="punctuation">&#125;</span></span><br></pre></td></tr></table></figure><p><strong>响应示例</strong>：</p><figure class="highlight json"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br></pre></td><td class="code"><pre><span class="line"><span class="punctuation">&#123;</span></span><br><span class="line">  <span class="attr">&quot;code&quot;</span><span class="punctuation">:</span> <span class="number">200</span><span class="punctuation">,</span></span><br><span class="line">  <span class="attr">&quot;message&quot;</span><span class="punctuation">:</span> <span class="string">&quot;操作成功&quot;</span><span class="punctuation">,</span></span><br><span class="line">  <span class="attr">&quot;data&quot;</span><span class="punctuation">:</span> <span class="string">&quot;密码重置成功，请使用新密码登录&quot;</span></span><br><span class="line"><span class="punctuation">&#125;</span></span><br></pre></td></tr></table></figure><p><strong>步骤 5：使用新密码登录</strong></p><ul><li><strong>方法</strong>：<code>POST</code></li><li><strong>URL</strong>：<code>http://localhost:8080/auth/login</code></li><li><strong>Body</strong>：</li></ul><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><span class="line"><span class="punctuation">&#123;</span></span><br><span class="line">  <span class="attr">&quot;authType&quot;</span><span class="punctuation">:</span> <span class="string">&quot;PASSWORD&quot;</span><span class="punctuation">,</span></span><br><span class="line">  <span class="attr">&quot;username&quot;</span><span class="punctuation">:</span> <span class="string">&quot;testuser&quot;</span><span class="punctuation">,</span></span><br><span class="line">  <span class="attr">&quot;password&quot;</span><span class="punctuation">:</span> <span class="string">&quot;NewPass1234&quot;</span></span><br><span class="line"><span class="punctuation">&#125;</span></span><br></pre></td></tr></table></figure><p><strong>响应示例</strong>：</p><figure class="highlight json"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br></pre></td><td class="code"><pre><span class="line"><span class="punctuation">&#123;</span></span><br><span class="line">  <span class="attr">&quot;code&quot;</span><span class="punctuation">:</span> <span class="number">200</span><span class="punctuation">,</span></span><br><span class="line">  <span class="attr">&quot;message&quot;</span><span class="punctuation">:</span> <span class="string">&quot;操作成功&quot;</span><span class="punctuation">,</span></span><br><span class="line">  <span class="attr">&quot;data&quot;</span><span class="punctuation">:</span> <span class="punctuation">&#123;</span></span><br><span class="line">    <span class="attr">&quot;accessToken&quot;</span><span class="punctuation">:</span> <span class="string">&quot;eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...&quot;</span><span class="punctuation">,</span></span><br><span class="line">    <span class="attr">&quot;refreshToken&quot;</span><span class="punctuation">:</span> <span class="string">&quot;a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6&quot;</span><span class="punctuation">,</span></span><br><span class="line">    <span class="attr">&quot;expiresIn&quot;</span><span class="punctuation">:</span> <span class="number">900</span><span class="punctuation">,</span></span><br><span class="line">    <span class="attr">&quot;tokenType&quot;</span><span class="punctuation">:</span> <span class="string">&quot;Bearer&quot;</span></span><br><span class="line">  <span class="punctuation">&#125;</span></span><br><span class="line"><span class="punctuation">&#125;</span></span><br></pre></td></tr></table></figure><hr><h2 id="19-3-10-本章总结与核心速查">19.3.10. 本章总结与核心速查</h2><p>在本章中，我们基于 19.2 的认证工厂，实现了完整的账号密码登录体系，包括领域模型设计、密码加密、验证码服务、邮箱激活、用户注册、密码登录、找回密码等核心功能。</p><h3 id="核心成果回顾">核心成果回顾</h3><p><strong>领域模型设计</strong></p><ul><li>理解了传统单表设计的三大弊端（字段爆炸、索引混乱、查询复杂）</li><li>掌握了账号-认证分离模型（1: N）</li><li>设计了 sys_user 和 sys_auth 两张表</li><li>理解了索引与性能优化策略</li></ul><p><strong>基础设施构建</strong></p><ul><li>掌握了 BCrypt 密码加密原理</li><li>实现了验证码服务（Hutool）</li><li>配置了 Spring Mail 邮件服务</li><li>实现了邮箱激活链接生成（HMAC 签名）</li><li>理解了 Spring Event 异步发送机制</li></ul><p><strong>核心业务流程</strong></p><ul><li>实现了用户注册流程（双表插入）</li><li>实现了 PasswordAuthStrategy（替换 MockAuthStrategy）</li><li>实现了邮箱激活接口</li><li>实现了找回密码功能</li></ul><h3 id="项目结构总览">项目结构总览</h3><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><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br></pre></td><td class="code"><pre><span class="line">auth-parent/</span><br><span class="line">├── auth-core/</span><br><span class="line">│   └── src/main/java/.../core/</span><br><span class="line">│       ├── entity/</span><br><span class="line">│       │   ├── User.java                 # 用户主体实体</span><br><span class="line">│       │   └── Auth.java                 # 认证凭证实体</span><br><span class="line">│       ├── mapper/</span><br><span class="line">│       │   ├── UserMapper.java           # 用户 Mapper</span><br><span class="line">│       │   └── AuthMapper.java           # 认证凭证 Mapper</span><br><span class="line">│       ├── service/</span><br><span class="line">│       │   ├── UserService.java          # 用户服务</span><br><span class="line">│       │   ├── CaptchaService.java       # 验证码服务</span><br><span class="line">│       │   └── EmailService.java         # 邮件服务</span><br><span class="line">│       ├── strategy/impl/</span><br><span class="line">│       │   └── PasswordAuthStrategy.java # 账号密码登录策略</span><br><span class="line">│       ├── util/</span><br><span class="line">│       │   ├── PasswordEncoder.java      # 密码加密工具</span><br><span class="line">│       │   └── HmacUtil.java             # HMAC 签名工具</span><br><span class="line">│       ├── event/</span><br><span class="line">│       │   └── EmailEvent.java           # 邮件事件</span><br><span class="line">│       ├── listener/</span><br><span class="line">│       │   └── EmailEventListener.java   # 邮件监听器</span><br><span class="line">│       └── config/</span><br><span class="line">│           └── AsyncConfig.java          # 异步配置</span><br><span class="line">│</span><br><span class="line">└── auth-web/</span><br><span class="line">    └── src/main/java/.../web/</span><br><span class="line">        └── controller/</span><br><span class="line">            ├── AuthController.java       # 认证控制器</span><br><span class="line">            └── CaptchaController.java    # 验证码控制器</span><br></pre></td></tr></table></figure><h3 id="核心类关系图">核心类关系图</h3><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><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><span class="line">classDiagram</span><br><span class="line">    class User &#123;</span><br><span class="line">        +Long id</span><br><span class="line">        +String nickname</span><br><span class="line">        +String avatar</span><br><span class="line">        +Integer status</span><br><span class="line">    &#125;</span><br><span class="line"></span><br><span class="line">    class Auth &#123;</span><br><span class="line">        +Long id</span><br><span class="line">        +Long userId</span><br><span class="line">        +String identityType</span><br><span class="line">        +String identifier</span><br><span class="line">        +String credential</span><br><span class="line">        +Integer verified</span><br><span class="line">    &#125;</span><br><span class="line"></span><br><span class="line">    class PasswordAuthStrategy &#123;</span><br><span class="line">        +authenticate(AuthRequest) AuthToken</span><br><span class="line">        +getSupportedType() AuthType</span><br><span class="line">    &#125;</span><br><span class="line"></span><br><span class="line">    class UserService &#123;</span><br><span class="line">        +register(RegisterRequest) Long</span><br><span class="line">        +activateAccount(Long, Long, String) void</span><br><span class="line">        +forgotPassword(ForgotPasswordRequest) void</span><br><span class="line">        +resetPassword(ResetPasswordRequest) void</span><br><span class="line">    &#125;</span><br><span class="line"></span><br><span class="line">    class EmailService &#123;</span><br><span class="line">        +sendActivationEmail(String, Long) void</span><br><span class="line">        +sendResetPasswordEmail(String, Long) void</span><br><span class="line">    &#125;</span><br><span class="line"></span><br><span class="line">    User &quot;1&quot; --&gt; &quot;*&quot; Auth : 一对多</span><br><span class="line">    PasswordAuthStrategy --&gt; Auth : 查询</span><br><span class="line">    PasswordAuthStrategy --&gt; User : 查询</span><br><span class="line">    UserService --&gt; User : 操作</span><br><span class="line">    UserService --&gt; Auth : 操作</span><br><span class="line">    UserService --&gt; EmailService : 调用</span><br></pre></td></tr></table></figure><h3 id="方法速查汇总">方法速查汇总</h3><p><strong>用户服务</strong></p><table><thead><tr><th style="text-align:left">类名</th><th style="text-align:left">方法</th><th style="text-align:left">作用</th><th style="text-align:left">参数</th><th style="text-align:left">返回值</th></tr></thead><tbody><tr><td style="text-align:left">UserService</td><td style="text-align:left"><code>register</code></td><td style="text-align:left">用户注册</td><td style="text-align:left">RegisterRequest</td><td style="text-align:left">Long (userId)</td></tr><tr><td style="text-align:left">UserService</td><td style="text-align:left"><code>activateAccount</code></td><td style="text-align:left">激活账号</td><td style="text-align:left">userId, timestamp, sign</td><td style="text-align:left">void</td></tr><tr><td style="text-align:left">UserService</td><td style="text-align:left"><code>forgotPassword</code></td><td style="text-align:left">找回密码</td><td style="text-align:left">ForgotPasswordRequest</td><td style="text-align:left">void</td></tr><tr><td style="text-align:left">UserService</td><td style="text-align:left"><code>resetPassword</code></td><td style="text-align:left">重置密码</td><td style="text-align:left">ResetPasswordRequest</td><td style="text-align:left">void</td></tr></tbody></table><p><strong>验证码服务</strong></p><table><thead><tr><th style="text-align:left">类名</th><th style="text-align:left">方法</th><th style="text-align:left">作用</th><th style="text-align:left">参数</th><th style="text-align:left">返回值</th></tr></thead><tbody><tr><td style="text-align:left">CaptchaService</td><td style="text-align:left"><code>generateCaptcha</code></td><td style="text-align:left">生成验证码</td><td style="text-align:left">key</td><td style="text-align:left">String (Base64)</td></tr><tr><td style="text-align:left">CaptchaService</td><td style="text-align:left"><code>verifyCaptcha</code></td><td style="text-align:left">验证验证码</td><td style="text-align:left">key, code</td><td style="text-align:left">boolean</td></tr></tbody></table><p><strong>邮件服务</strong></p><table><thead><tr><th style="text-align:left">类名</th><th style="text-align:left">方法</th><th style="text-align:left">作用</th><th style="text-align:left">参数</th><th style="text-align:left">返回值</th></tr></thead><tbody><tr><td style="text-align:left">EmailService</td><td style="text-align:left"><code>sendActivationEmail</code></td><td style="text-align:left">发送激活邮件</td><td style="text-align:left">email, userId</td><td style="text-align:left">void</td></tr><tr><td style="text-align:left">EmailService</td><td style="text-align:left"><code>sendResetPasswordEmail</code></td><td style="text-align:left">发送重置密码邮件</td><td style="text-align:left">email, userId</td><td style="text-align:left">void</td></tr></tbody></table><p><strong>密码加密</strong></p><table><thead><tr><th style="text-align:left">类名</th><th style="text-align:left">方法</th><th style="text-align:left">作用</th><th style="text-align:left">参数</th><th style="text-align:left">返回值</th></tr></thead><tbody><tr><td style="text-align:left">PasswordEncoder</td><td style="text-align:left"><code>encode</code></td><td style="text-align:left">加密密码</td><td style="text-align:left">rawPassword</td><td style="text-align:left">String (BCrypt 哈希)</td></tr><tr><td style="text-align:left">PasswordEncoder</td><td style="text-align:left"><code>matches</code></td><td style="text-align:left">验证密码</td><td style="text-align:left">rawPassword, encodedPassword</td><td style="text-align:left">boolean</td></tr></tbody></table><p><strong>HMAC 签名</strong></p><table><thead><tr><th style="text-align:left">类名</th><th style="text-align:left">方法</th><th style="text-align:left">作用</th><th style="text-align:left">参数</th><th style="text-align:left">返回值</th></tr></thead><tbody><tr><td style="text-align:left">HmacUtil</td><td style="text-align:left"><code>sign</code></td><td style="text-align:left">生成签名</td><td style="text-align:left">data</td><td style="text-align:left">String</td></tr><tr><td style="text-align:left">HmacUtil</td><td style="text-align:left"><code>verify</code></td><td style="text-align:left">验证签名</td><td style="text-align:left">data, sign</td><td style="text-align:left">boolean</td></tr></tbody></table><h3 id="接口速查汇总">接口速查汇总</h3><table><thead><tr><th style="text-align:left">接口</th><th style="text-align:left">方法</th><th style="text-align:left">作用</th><th style="text-align:left">请求参数</th><th style="text-align:left">响应</th></tr></thead><tbody><tr><td style="text-align:left"><code>/captcha/generate</code></td><td style="text-align:left">GET</td><td style="text-align:left">生成验证码</td><td style="text-align:left">无</td><td style="text-align:left" key,="" image=""></td></tr><tr><td style="text-align:left"><code>/auth/register</code></td><td style="text-align:left">POST</td><td style="text-align:left">用户注册</td><td style="text-align:left">RegisterRequest</td><td style="text-align:left" userId,="" message=""></td></tr><tr><td style="text-align:left"><code>/auth/activate</code></td><td style="text-align:left">GET</td><td style="text-align:left">激活账号</td><td style="text-align:left">userId, timestamp, sign</td><td style="text-align:left">message</td></tr><tr><td style="text-align:left"><code>/auth/login</code></td><td style="text-align:left">POST</td><td style="text-align:left">账号密码登录</td><td style="text-align:left">PasswordAuthRequest</td><td style="text-align:left">AuthToken</td></tr><tr><td style="text-align:left"><code>/auth/forgot-password</code></td><td style="text-align:left">POST</td><td style="text-align:left">找回密码</td><td style="text-align:left">ForgotPasswordRequest</td><td style="text-align:left">message</td></tr><tr><td style="text-align:left"><code>/auth/reset-password</code></td><td style="text-align:left">POST</td><td style="text-align:left">重置密码</td><td style="text-align:left">ResetPasswordRequest</td><td style="text-align:left">message</td></tr></tbody></table><h3 id="核心避坑指南">核心避坑指南</h3><p><strong>陷阱一：密码使用 MD5 加密</strong></p><p><strong>现象</strong>：使用 MD5 加密密码。</p><p><strong>原因</strong>：MD5 已被破解，不安全。</p><p><strong>对策</strong>：使用 BCrypt 加密密码。</p><hr><p><strong>陷阱二：验证码不设置过期时间</strong></p><p><strong>现象</strong>：验证码永久有效。</p><p><strong>原因</strong>：没有设置 Redis 过期时间。</p><p><strong>对策</strong>：验证码存入 Redis 时设置 5 分钟过期。</p><hr><p><strong>陷阱三：验证码可以重复使用</strong></p><p><strong>现象</strong>：验证码验证通过后，仍然可以继续使用。</p><p><strong>原因</strong>：验证通过后没有删除 Redis 中的验证码。</p><p><strong>对策</strong>：验证通过后立即删除验证码。</p><hr><p><strong>陷阱四：激活链接没有签名</strong></p><p><strong>现象</strong>：激活链接可以被伪造。</p><p><strong>原因</strong>：激活链接只包含 userId，没有签名。</p><p><strong>对策</strong>：使用 HMAC 签名，防止链接被伪造。</p><hr><p><strong>陷阱五：邮件同步发送</strong></p><p><strong>现象</strong>：用户注册时需要等待 2-5 秒。</p><p><strong>原因</strong>：邮件同步发送，阻塞主线程。</p><p><strong>对策</strong>：使用 Spring Event 异步发送邮件。</p><hr><p><strong>陷阱六：注册时只插入 sys_user 表</strong></p><p><strong>现象</strong>：用户注册成功，但无法登录。</p><p><strong>原因</strong>：只插入了 sys_user 表，没有插入 sys_auth 表。</p><p><strong>对策</strong>：注册时必须同时插入 sys_user 和 sys_auth 两张表。</p><hr><p><strong>陷阱七：登录时只查询 sys_user 表</strong></p><p><strong>现象</strong>：无法根据用户名查询用户。</p><p><strong>原因</strong>：用户名存储在 sys_auth 表，不在 sys_user 表。</p><p><strong>对策</strong>：登录时先查询 sys_auth 表，再根据 user_id 查询 sys_user 表。</p><hr><p><strong>陷阱八：找回密码时直接返回邮箱不存在</strong></p><p><strong>现象</strong>：攻击者可以通过找回密码接口枚举邮箱。</p><p><strong>原因</strong>：邮箱不存在时返回错误提示。</p><p><strong>对策</strong>：无论邮箱是否存在，都返回 “重置密码邮件已发送”。</p><hr><p><strong>陷阱九：密码强度不校验</strong></p><p><strong>现象</strong>：用户可以设置弱密码（如 “123456”）。</p><p><strong>原因</strong>：没有校验密码强度。</p><p><strong>对策</strong>：使用正则表达式校验密码强度（8-20 位，必须包含大小写字母、数字）。</p><hr><p><strong>陷阱十：事务未生效</strong></p><p><strong>现象</strong>：注册失败后，sys_user 表有数据，但 sys_auth 表没有数据。</p><p><strong>原因</strong>：没有使用 <code>@Transactional</code> 注解。</p><p><strong>对策</strong>：在 <code>register</code> 方法上添加 <code>@Transactional(rollbackFor = Exception.class)</code> 注解。</p><hr><h3 id="数据库表结构速查">数据库表结构速查</h3><p><strong>sys_user 表</strong></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></pre></td><td class="code"><pre><span class="line"><span class="keyword">CREATE TABLE</span> sys_user (</span><br><span class="line">    id <span class="type">BIGINT</span> <span class="keyword">PRIMARY KEY</span> COMMENT <span class="string">&#x27;用户 ID（雪花算法生成）&#x27;</span>,</span><br><span class="line">    nickname <span class="type">VARCHAR</span>(<span class="number">50</span>) <span class="keyword">NOT NULL</span> COMMENT <span class="string">&#x27;用户昵称&#x27;</span>,</span><br><span class="line">    avatar <span class="type">VARCHAR</span>(<span class="number">255</span>) <span class="keyword">DEFAULT</span> <span class="string">&#x27;https://cdn.example.com/default-avatar.png&#x27;</span> COMMENT <span class="string">&#x27;头像 URL&#x27;</span>,</span><br><span class="line">    status TINYINT <span class="keyword">DEFAULT</span> <span class="number">2</span> COMMENT <span class="string">&#x27;状态：0-禁用 1-启用 2-未激活&#x27;</span>,</span><br><span class="line">    create_time DATETIME <span class="keyword">DEFAULT</span> <span class="built_in">CURRENT_TIMESTAMP</span> COMMENT <span class="string">&#x27;创建时间&#x27;</span>,</span><br><span class="line">    update_time DATETIME <span class="keyword">DEFAULT</span> <span class="built_in">CURRENT_TIMESTAMP</span> <span class="keyword">ON</span> <span class="keyword">UPDATE</span> <span class="built_in">CURRENT_TIMESTAMP</span> COMMENT <span class="string">&#x27;更新时间&#x27;</span>,</span><br><span class="line"></span><br><span class="line">    KEY idx_create_time (create_time)</span><br><span class="line">) ENGINE<span class="operator">=</span>InnoDB <span class="keyword">DEFAULT</span> CHARSET<span class="operator">=</span>utf8mb4 COMMENT<span class="operator">=</span><span class="string">&#x27;用户主体表&#x27;</span>;</span><br></pre></td></tr></table></figure><p><strong>sys_auth 表</strong></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></pre></td><td class="code"><pre><span class="line"><span class="keyword">CREATE TABLE</span> sys_auth (</span><br><span class="line">    id <span class="type">BIGINT</span> <span class="keyword">PRIMARY KEY</span> AUTO_INCREMENT COMMENT <span class="string">&#x27;主键 ID&#x27;</span>,</span><br><span class="line">    user_id <span class="type">BIGINT</span> <span class="keyword">NOT NULL</span> COMMENT <span class="string">&#x27;关联的用户 ID&#x27;</span>,</span><br><span class="line">    identity_type <span class="type">VARCHAR</span>(<span class="number">20</span>) <span class="keyword">NOT NULL</span> COMMENT <span class="string">&#x27;认证类型：PASSWORD/MOBILE/EMAIL/WECHAT/GITHUB&#x27;</span>,</span><br><span class="line">    identifier <span class="type">VARCHAR</span>(<span class="number">100</span>) <span class="keyword">NOT NULL</span> COMMENT <span class="string">&#x27;标识符（账号/手机号/邮箱/OpenID）&#x27;</span>,</span><br><span class="line">    credential <span class="type">VARCHAR</span>(<span class="number">255</span>) COMMENT <span class="string">&#x27;凭证（密码哈希/Token，OAuth 登录可为空）&#x27;</span>,</span><br><span class="line">    verified TINYINT <span class="keyword">DEFAULT</span> <span class="number">0</span> COMMENT <span class="string">&#x27;是否已验证：0-未验证 1-已验证&#x27;</span>,</span><br><span class="line">    create_time DATETIME <span class="keyword">DEFAULT</span> <span class="built_in">CURRENT_TIMESTAMP</span> COMMENT <span class="string">&#x27;创建时间&#x27;</span>,</span><br><span class="line">    update_time DATETIME <span class="keyword">DEFAULT</span> <span class="built_in">CURRENT_TIMESTAMP</span> <span class="keyword">ON</span> <span class="keyword">UPDATE</span> <span class="built_in">CURRENT_TIMESTAMP</span> COMMENT <span class="string">&#x27;更新时间&#x27;</span>,</span><br><span class="line"></span><br><span class="line">    <span class="keyword">UNIQUE</span> KEY uk_type_identifier (identity_type, identifier),</span><br><span class="line">    KEY idx_user_id (user_id)</span><br><span class="line">) ENGINE<span class="operator">=</span>InnoDB <span class="keyword">DEFAULT</span> CHARSET<span class="operator">=</span>utf8mb4 COMMENT<span class="operator">=</span><span class="string">&#x27;用户认证凭证表&#x27;</span>;</span><br></pre></td></tr></table></figure><hr><h3 id="配置文件速查">配置文件速查</h3><p><strong>application.yml</strong></p><figure class="highlight yaml"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><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><span class="line"><span class="attr">spring:</span></span><br><span class="line">  <span class="comment"># 数据源配置</span></span><br><span class="line">  <span class="attr">datasource:</span></span><br><span class="line">    <span class="attr">type:</span> <span class="string">com.alibaba.druid.pool.DruidDataSource</span></span><br><span class="line">    <span class="attr">driver-class-name:</span> <span class="string">com.mysql.cj.jdbc.Driver</span></span><br><span class="line">    <span class="attr">url:</span> <span class="string">jdbc:mysql://localhost:3306/auth_db?useUnicode=true&amp;characterEncoding=utf8&amp;serverTimezone=Asia/Shanghai&amp;useSSL=false</span></span><br><span class="line">    <span class="attr">username:</span> <span class="string">root</span></span><br><span class="line">    <span class="attr">password:</span> <span class="number">123456</span></span><br><span class="line"></span><br><span class="line">  <span class="comment"># 邮件配置</span></span><br><span class="line">  <span class="attr">mail:</span></span><br><span class="line">    <span class="attr">host:</span> <span class="string">smtp.qq.com</span></span><br><span class="line">    <span class="attr">port:</span> <span class="number">587</span></span><br><span class="line">    <span class="attr">username:</span> <span class="string">your_email@qq.com</span></span><br><span class="line">    <span class="attr">password:</span> <span class="string">your_authorization_code</span></span><br><span class="line">    <span class="attr">default-encoding:</span> <span class="string">UTF-8</span></span><br><span class="line">    <span class="attr">properties:</span></span><br><span class="line">      <span class="attr">mail:</span></span><br><span class="line">        <span class="attr">smtp:</span></span><br><span class="line">          <span class="attr">auth:</span> <span class="literal">true</span></span><br><span class="line">          <span class="attr">starttls:</span></span><br><span class="line">            <span class="attr">enable:</span> <span class="literal">true</span></span><br><span class="line">            <span class="attr">required:</span> <span class="literal">true</span></span><br><span class="line"></span><br><span class="line"><span class="comment"># MyBatis-Plus 配置</span></span><br><span class="line"><span class="attr">mybatis-plus:</span></span><br><span class="line">  <span class="attr">configuration:</span></span><br><span class="line">    <span class="attr">map-underscore-to-camel-case:</span> <span class="literal">true</span></span><br><span class="line">    <span class="attr">log-impl:</span> <span class="string">org.apache.ibatis.logging.stdout.StdOutImpl</span></span><br><span class="line">  <span class="attr">global-config:</span></span><br><span class="line">    <span class="attr">db-config:</span></span><br><span class="line">      <span class="attr">id-type:</span> <span class="string">ASSIGN_ID</span></span><br><span class="line"></span><br><span class="line"><span class="comment"># 认证配置</span></span><br><span class="line"><span class="attr">auth:</span></span><br><span class="line">  <span class="attr">hmac:</span></span><br><span class="line">    <span class="attr">secret:</span> <span class="string">your_secret_key_change_in_production</span></span><br></pre></td></tr></table></figure><hr><h3 id="前端对接指南">前端对接指南</h3><p><strong>注册流程</strong></p><figure class="highlight javascript"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">// 1. 生成验证码</span></span><br><span class="line"><span class="keyword">const</span> captchaResponse = <span class="keyword">await</span> axios.<span class="title function_">get</span>(<span class="string">&#x27;/captcha/generate&#x27;</span>);</span><br><span class="line"><span class="keyword">const</span> captchaKey = captchaResponse.<span class="property">data</span>.<span class="property">data</span>.<span class="property">key</span>;</span><br><span class="line"><span class="keyword">const</span> captchaImage = captchaResponse.<span class="property">data</span>.<span class="property">data</span>.<span class="property">image</span>;</span><br><span class="line"></span><br><span class="line"><span class="comment">// 2. 显示验证码图片</span></span><br><span class="line"><span class="variable language_">document</span>.<span class="title function_">getElementById</span>(<span class="string">&#x27;captcha-img&#x27;</span>).<span class="property">src</span> = captchaImage;</span><br><span class="line"></span><br><span class="line"><span class="comment">// 3. 用户填写注册信息并提交</span></span><br><span class="line"><span class="keyword">const</span> registerResponse = <span class="keyword">await</span> axios.<span class="title function_">post</span>(<span class="string">&#x27;/auth/register&#x27;</span>, &#123;</span><br><span class="line">  <span class="attr">username</span>: <span class="string">&#x27;testuser&#x27;</span>,</span><br><span class="line">  <span class="attr">password</span>: <span class="string">&#x27;Test1234&#x27;</span>,</span><br><span class="line">  <span class="attr">email</span>: <span class="string">&#x27;test@example.com&#x27;</span>,</span><br><span class="line">  <span class="attr">captchaKey</span>: captchaKey,</span><br><span class="line">  <span class="attr">captchaCode</span>: <span class="string">&#x27;用户输入的验证码&#x27;</span></span><br><span class="line">&#125;);</span><br><span class="line"></span><br><span class="line"><span class="comment">// 4. 提示用户查收激活邮件</span></span><br><span class="line"><span class="title function_">alert</span>(registerResponse.<span class="property">data</span>.<span class="property">data</span>.<span class="property">message</span>);</span><br></pre></td></tr></table></figure><p><strong>登录流程</strong></p><figure class="highlight javascript"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><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><span class="line"><span class="comment">// 1. 用户填写登录信息并提交</span></span><br><span class="line"><span class="keyword">const</span> loginResponse = <span class="keyword">await</span> axios.<span class="title function_">post</span>(<span class="string">&#x27;/auth/login&#x27;</span>, &#123;</span><br><span class="line">  <span class="attr">authType</span>: <span class="string">&#x27;PASSWORD&#x27;</span>,</span><br><span class="line">  <span class="attr">username</span>: <span class="string">&#x27;testuser&#x27;</span>,</span><br><span class="line">  <span class="attr">password</span>: <span class="string">&#x27;Test1234&#x27;</span></span><br><span class="line">&#125;);</span><br><span class="line"></span><br><span class="line"><span class="comment">// 2. 保存 Token</span></span><br><span class="line"><span class="keyword">const</span> accessToken = loginResponse.<span class="property">data</span>.<span class="property">data</span>.<span class="property">accessToken</span>;</span><br><span class="line"><span class="keyword">const</span> refreshToken = loginResponse.<span class="property">data</span>.<span class="property">data</span>.<span class="property">refreshToken</span>;</span><br><span class="line"></span><br><span class="line"><span class="variable language_">localStorage</span>.<span class="title function_">setItem</span>(<span class="string">&#x27;accessToken&#x27;</span>, accessToken);</span><br><span class="line"><span class="variable language_">localStorage</span>.<span class="title function_">setItem</span>(<span class="string">&#x27;refreshToken&#x27;</span>, refreshToken);</span><br><span class="line"></span><br><span class="line"><span class="comment">// 3. 后续请求携带 Token</span></span><br><span class="line">axios.<span class="property">defaults</span>.<span class="property">headers</span>.<span class="property">common</span>[<span class="string">&#x27;Authorization&#x27;</span>] = <span class="string">&#x27;Bearer &#x27;</span> + accessToken;</span><br></pre></td></tr></table></figure><p><strong>找回密码流程</strong></p><figure class="highlight javascript"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><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><span class="line"><span class="comment">// 1. 生成验证码</span></span><br><span class="line"><span class="keyword">const</span> captchaResponse = <span class="keyword">await</span> axios.<span class="title function_">get</span>(<span class="string">&#x27;/captcha/generate&#x27;</span>);</span><br><span class="line"><span class="keyword">const</span> captchaKey = captchaResponse.<span class="property">data</span>.<span class="property">data</span>.<span class="property">key</span>;</span><br><span class="line"><span class="keyword">const</span> captchaImage = captchaResponse.<span class="property">data</span>.<span class="property">data</span>.<span class="property">image</span>;</span><br><span class="line"></span><br><span class="line"><span class="comment">// 2. 显示验证码图片</span></span><br><span class="line"><span class="variable language_">document</span>.<span class="title function_">getElementById</span>(<span class="string">&#x27;captcha-img&#x27;</span>).<span class="property">src</span> = captchaImage;</span><br><span class="line"></span><br><span class="line"><span class="comment">// 3. 用户填写邮箱并提交</span></span><br><span class="line"><span class="keyword">const</span> forgotResponse = <span class="keyword">await</span> axios.<span class="title function_">post</span>(<span class="string">&#x27;/auth/forgot-password&#x27;</span>, &#123;</span><br><span class="line">  <span class="attr">email</span>: <span class="string">&#x27;test@example.com&#x27;</span>,</span><br><span class="line">  <span class="attr">captchaKey</span>: captchaKey,</span><br><span class="line">  <span class="attr">captchaCode</span>: <span class="string">&#x27;用户输入的验证码&#x27;</span></span><br><span class="line">&#125;);</span><br><span class="line"></span><br><span class="line"><span class="comment">// 4. 提示用户查收邮件</span></span><br><span class="line"><span class="title function_">alert</span>(forgotResponse.<span class="property">data</span>.<span class="property">data</span>);</span><br><span class="line"></span><br><span class="line"><span class="comment">// 5. 用户点击邮件中的重置密码链接，跳转到重置密码页面</span></span><br><span class="line"><span class="comment">// 页面 URL: http://localhost: 8080/reset-password?userId = xxx&amp;timestamp = xxx&amp;sign = xxx</span></span><br><span class="line"></span><br><span class="line"><span class="comment">// 6. 用户填写新密码并提交</span></span><br><span class="line"><span class="keyword">const</span> resetResponse = <span class="keyword">await</span> axios.<span class="title function_">post</span>(<span class="string">&#x27;/auth/reset-password&#x27;</span>, &#123;</span><br><span class="line">  <span class="attr">userId</span>: urlParams.<span class="title function_">get</span>(<span class="string">&#x27;userId&#x27;</span>),</span><br><span class="line">  <span class="attr">timestamp</span>: urlParams.<span class="title function_">get</span>(<span class="string">&#x27;timestamp&#x27;</span>),</span><br><span class="line">  <span class="attr">sign</span>: urlParams.<span class="title function_">get</span>(<span class="string">&#x27;sign&#x27;</span>),</span><br><span class="line">  <span class="attr">newPassword</span>: <span class="string">&#x27;NewPass1234&#x27;</span></span><br><span class="line">&#125;);</span><br><span class="line"></span><br><span class="line"><span class="comment">// 7. 提示用户密码重置成功</span></span><br><span class="line"><span class="title function_">alert</span>(resetResponse.<span class="property">data</span>.<span class="property">data</span>);</span><br></pre></td></tr></table></figure><hr><h3 id="测试用例速查">测试用例速查</h3><p><strong>注册测试</strong></p><table><thead><tr><th style="text-align:left">测试场景</th><th style="text-align:left">请求参数</th><th style="text-align:left">预期结果</th></tr></thead><tbody><tr><td style="text-align:left">正常注册</td><td style="text-align:left">username = testuser, password = Test1234, email = <a href="mailto:test@example.com">test@example.com</a>, 验证码正确</td><td style="text-align:left">注册成功，发送激活邮件</td></tr><tr><td style="text-align:left">用户名为空</td><td style="text-align:left">username = 空, password = Test1234, email = <a href="mailto:test@example.com">test@example.com</a></td><td style="text-align:left">400 错误：用户名不能为空</td></tr><tr><td style="text-align:left">密码格式错误</td><td style="text-align:left">username = testuser, password = 123456, email = <a href="mailto:test@example.com">test@example.com</a></td><td style="text-align:left">400 错误：密码格式不正确</td></tr><tr><td style="text-align:left">邮箱格式错误</td><td style="text-align:left">username = testuser, password = Test1234, email = invalid</td><td style="text-align:left">400 错误：邮箱格式不正确</td></tr><tr><td style="text-align:left">验证码错误</td><td style="text-align:left">username = testuser, password = Test1234, email = <a href="mailto:test@example.com">test@example.com</a>, 验证码错误</td><td style="text-align:left">400 错误：验证码错误或已过期</td></tr><tr><td style="text-align:left">用户名已存在</td><td style="text-align:left">username = testuser（已存在）, password = Test1234, email = <a href="mailto:new@example.com">new@example.com</a></td><td style="text-align:left">400 错误：用户名已存在</td></tr><tr><td style="text-align:left">邮箱已存在</td><td style="text-align:left">username = newuser, password = Test1234, email = <a href="mailto:test@example.com">test@example.com</a>（已存在）</td><td style="text-align:left">400 错误：邮箱已被注册</td></tr></tbody></table><p><strong>登录测试</strong></p><table><thead><tr><th style="text-align:left">测试场景</th><th style="text-align:left">请求参数</th><th style="text-align:left">预期结果</th></tr></thead><tbody><tr><td style="text-align:left">正常登录</td><td style="text-align:left">username = testuser, password = Test1234（账号已激活）</td><td style="text-align:left">登录成功，返回双令牌</td></tr><tr><td style="text-align:left">账号未激活</td><td style="text-align:left">username = testuser, password = Test1234（账号未激活）</td><td style="text-align:left">400 错误：账号未激活，请先激活邮箱</td></tr><tr><td style="text-align:left">用户名不存在</td><td style="text-align:left">username = notexist, password = Test1234</td><td style="text-align:left">400 错误：用户名或密码错误</td></tr><tr><td style="text-align:left">密码错误</td><td style="text-align:left">username = testuser, password = WrongPass</td><td style="text-align:left">400 错误：用户名或密码错误</td></tr><tr><td style="text-align:left">账号被禁用</td><td style="text-align:left">username = testuser, password = Test1234（账号被禁用）</td><td style="text-align:left">400 错误：账号已被禁用，请联系管理员</td></tr></tbody></table><p><strong>找回密码测试</strong></p><table><thead><tr><th style="text-align:left">测试场景</th><th style="text-align:left">请求参数</th><th style="text-align:left">预期结果</th></tr></thead><tbody><tr><td style="text-align:left">正常找回密码</td><td style="text-align:left">email = <a href="mailto:test@example.com">test@example.com</a>, 验证码正确</td><td style="text-align:left">发送重置密码邮件</td></tr><tr><td style="text-align:left">邮箱不存在</td><td style="text-align:left">email = <a href="mailto:notexist@example.com">notexist@example.com</a>, 验证码正确</td><td style="text-align:left">返回成功（防止用户枚举）</td></tr><tr><td style="text-align:left">验证码错误</td><td style="text-align:left">email = <a href="mailto:test@example.com">test@example.com</a>, 验证码错误</td><td style="text-align:left">400 错误：验证码错误或已过期</td></tr></tbody></table><p><strong>重置密码测试</strong></p><table><thead><tr><th style="text-align:left">测试场景</th><th style="text-align:left">请求参数</th><th style="text-align:left">预期结果</th></tr></thead><tbody><tr><td style="text-align:left">正常重置密码</td><td style="text-align:left">userId = xxx, timestamp = 未过期, sign = 正确, newPassword = NewPass1234</td><td style="text-align:left">密码重置成功</td></tr><tr><td style="text-align:left">链接已过期</td><td style="text-align:left">userId = xxx, timestamp = 已过期, sign = 正确, newPassword = NewPass1234</td><td style="text-align:left">400 错误：重置链接已过期</td></tr><tr><td style="text-align:left">签名错误</td><td style="text-align:left">userId = xxx, timestamp = 未过期, sign = 错误, newPassword = NewPass1234</td><td style="text-align:left">400 错误：重置链接无效</td></tr><tr><td style="text-align:left">新密码格式错误</td><td style="text-align:left">userId = xxx, timestamp = 未过期, sign = 正确, newPassword = 123456</td><td style="text-align:left">400 错误：密码格式不正确</td></tr></tbody></table><hr><h3 id="性能优化建议">性能优化建议</h3><p><strong>优化一：验证码生成优化</strong></p><p><strong>问题</strong>：每次生成验证码都需要创建新的 <code>LineCaptcha</code> 对象，性能较低。</p><p><strong>优化方案</strong>：使用对象池复用 <code>LineCaptcha</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></pre></td><td class="code"><pre><span class="line"><span class="meta">@Component</span></span><br><span class="line"><span class="keyword">public</span> <span class="keyword">class</span> <span class="title class_">CaptchaService</span> &#123;</span><br><span class="line"></span><br><span class="line">    <span class="keyword">private</span> <span class="keyword">final</span> GenericObjectPool&lt;LineCaptcha&gt; captchaPool;</span><br><span class="line"></span><br><span class="line">    <span class="keyword">public</span> <span class="title function_">CaptchaService</span><span class="params">()</span> &#123;</span><br><span class="line">        GenericObjectPoolConfig&lt;LineCaptcha&gt; config = <span class="keyword">new</span> <span class="title class_">GenericObjectPoolConfig</span>&lt;&gt;();</span><br><span class="line">        config.setMaxTotal(<span class="number">100</span>);</span><br><span class="line">        config.setMaxIdle(<span class="number">50</span>);</span><br><span class="line">        config.setMinIdle(<span class="number">10</span>);</span><br><span class="line"></span><br><span class="line">        <span class="built_in">this</span>.captchaPool = <span class="keyword">new</span> <span class="title class_">GenericObjectPool</span>&lt;&gt;(<span class="keyword">new</span> <span class="title class_">BasePooledObjectFactory</span>&lt;LineCaptcha&gt;() &#123;</span><br><span class="line">            <span class="meta">@Override</span></span><br><span class="line">            <span class="keyword">public</span> LineCaptcha <span class="title function_">create</span><span class="params">()</span> &#123;</span><br><span class="line">                <span class="keyword">return</span> CaptchaUtil.createLineCaptcha(<span class="number">200</span>, <span class="number">100</span>, <span class="number">4</span>, <span class="number">20</span>);</span><br><span class="line">            &#125;</span><br><span class="line"></span><br><span class="line">            <span class="meta">@Override</span></span><br><span class="line">            <span class="keyword">public</span> PooledObject&lt;LineCaptcha&gt; <span class="title function_">wrap</span><span class="params">(LineCaptcha obj)</span> &#123;</span><br><span class="line">                <span class="keyword">return</span> <span class="keyword">new</span> <span class="title class_">DefaultPooledObject</span>&lt;&gt;(obj);</span><br><span class="line">            &#125;</span><br><span class="line">        &#125;, config);</span><br><span class="line">    &#125;</span><br><span class="line"></span><br><span class="line">    <span class="keyword">public</span> String <span class="title function_">generateCaptcha</span><span class="params">(String key)</span> &#123;</span><br><span class="line">        <span class="type">LineCaptcha</span> <span class="variable">captcha</span> <span class="operator">=</span> <span class="literal">null</span>;</span><br><span class="line">        <span class="keyword">try</span> &#123;</span><br><span class="line">            captcha = captchaPool.borrowObject();</span><br><span class="line">            <span class="type">String</span> <span class="variable">code</span> <span class="operator">=</span> captcha.getCode();</span><br><span class="line">            <span class="comment">// ...</span></span><br><span class="line">            <span class="keyword">return</span> captcha.getImageBase64();</span><br><span class="line">        &#125; <span class="keyword">finally</span> &#123;</span><br><span class="line">            <span class="keyword">if</span> (captcha != <span class="literal">null</span>) &#123;</span><br><span class="line">                captchaPool.returnObject(captcha);</span><br><span class="line">            &#125;</span><br><span class="line">        &#125;</span><br><span class="line">    &#125;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p><strong>优化二：邮件发送优化</strong></p><p><strong>问题</strong>：邮件发送失败时，没有重试机制。</p><p><strong>优化方案</strong>：使用消息队列（RabbitMQ/Kafka）实现邮件发送的可靠性。</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><span class="line"><span class="meta">@Component</span></span><br><span class="line"><span class="keyword">public</span> <span class="keyword">class</span> <span class="title class_">EmailEventListener</span> &#123;</span><br><span class="line"></span><br><span class="line">    <span class="meta">@RabbitListener(queues = &quot;email-queue&quot;)</span></span><br><span class="line">    <span class="keyword">public</span> <span class="keyword">void</span> <span class="title function_">handleEmailEvent</span><span class="params">(EmailEvent event)</span> &#123;</span><br><span class="line">        <span class="keyword">try</span> &#123;</span><br><span class="line">            <span class="comment">// 发送邮件</span></span><br><span class="line">            mailSender.send(message);</span><br><span class="line">        &#125; <span class="keyword">catch</span> (Exception e) &#123;</span><br><span class="line">            <span class="comment">// 发送失败，重新入队</span></span><br><span class="line">            rabbitTemplate.convertAndSend(<span class="string">&quot;email-queue&quot;</span>, event);</span><br><span class="line">        &#125;</span><br><span class="line">    &#125;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p><strong>优化三：密码加密优化</strong></p><p><strong>问题</strong>：BCrypt 成本因子为 12 时，每次加密需要 0.4 秒，高并发时性能较低。</p><p><strong>优化方案</strong>：使用异步加密，或者降低成本因子到 10。</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><span class="line"><span class="meta">@Component</span></span><br><span class="line"><span class="keyword">public</span> <span class="keyword">class</span> <span class="title class_">PasswordEncoder</span> &#123;</span><br><span class="line"></span><br><span class="line">    <span class="comment">// 开发环境使用成本因子 10，生产环境使用成本因子 12</span></span><br><span class="line">    <span class="meta">@Value(&quot;$&#123;auth.bcrypt.cost:10&#125;&quot;)</span></span><br><span class="line">    <span class="keyword">private</span> <span class="type">int</span> cost;</span><br><span class="line"></span><br><span class="line">    <span class="keyword">private</span> BCryptPasswordEncoder encoder;</span><br><span class="line"></span><br><span class="line">    <span class="meta">@PostConstruct</span></span><br><span class="line">    <span class="keyword">public</span> <span class="keyword">void</span> <span class="title function_">init</span><span class="params">()</span> &#123;</span><br><span class="line">        <span class="built_in">this</span>.encoder = <span class="keyword">new</span> <span class="title class_">BCryptPasswordEncoder</span>(cost);</span><br><span class="line">    &#125;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p><strong>优化四：数据库查询优化</strong></p><p><strong>问题</strong>：登录时需要查询两次数据库（先查 sys_auth，再查 sys_user）。</p><p><strong>优化方案</strong>：使用 MyBatis-Plus 的关联查询，一次查询获取所有数据。</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><span class="line"><span class="meta">@Mapper</span></span><br><span class="line"><span class="keyword">public</span> <span class="keyword">interface</span> <span class="title class_">AuthMapper</span> <span class="keyword">extends</span> <span class="title class_">BaseMapper</span>&lt;Auth&gt; &#123;</span><br><span class="line"></span><br><span class="line">    <span class="meta">@Select(&quot;SELECT a.*, u.nickname, u.avatar, u.status &quot; +</span></span><br><span class="line"><span class="meta">            &quot;FROM sys_auth a &quot; +</span></span><br><span class="line"><span class="meta">            &quot;LEFT JOIN sys_user u ON a.user_id = u.id &quot; +</span></span><br><span class="line"><span class="meta">            &quot;WHERE a.identity_type = #&#123;identityType&#125; AND a.identifier = #&#123;identifier&#125;&quot;)</span></span><br><span class="line">    AuthWithUser <span class="title function_">selectAuthWithUser</span><span class="params">(<span class="meta">@Param(&quot;identityType&quot;)</span> String identityType,</span></span><br><span class="line"><span class="params">                                     <span class="meta">@Param(&quot;identifier&quot;)</span> String identifier)</span>;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><hr><h3 id="安全加固建议">安全加固建议</h3><p><strong>加固一：防止暴力破解</strong></p><p><strong>问题</strong>：攻击者可以无限次尝试登录。</p><p><strong>解决方案</strong>：使用 Redis 记录登录失败次数，失败 5 次后锁定 15 分钟。</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></pre></td><td class="code"><pre><span class="line"><span class="meta">@Component</span></span><br><span class="line"><span class="keyword">public</span> <span class="keyword">class</span> <span class="title class_">LoginAttemptService</span> &#123;</span><br><span class="line"></span><br><span class="line">    <span class="keyword">private</span> <span class="keyword">final</span> StringRedisTemplate redisTemplate;</span><br><span class="line">    <span class="keyword">private</span> <span class="keyword">static</span> <span class="keyword">final</span> <span class="type">String</span> <span class="variable">LOGIN_ATTEMPT_KEY</span> <span class="operator">=</span> <span class="string">&quot;auth:login:attempt:&quot;</span>;</span><br><span class="line">    <span class="keyword">private</span> <span class="keyword">static</span> <span class="keyword">final</span> <span class="type">int</span> <span class="variable">MAX_ATTEMPTS</span> <span class="operator">=</span> <span class="number">5</span>;</span><br><span class="line">    <span class="keyword">private</span> <span class="keyword">static</span> <span class="keyword">final</span> <span class="type">long</span> <span class="variable">LOCK_TIME_MINUTES</span> <span class="operator">=</span> <span class="number">15</span>;</span><br><span class="line"></span><br><span class="line">    <span class="keyword">public</span> <span class="keyword">void</span> <span class="title function_">loginFailed</span><span class="params">(String username)</span> &#123;</span><br><span class="line">        <span class="type">String</span> <span class="variable">key</span> <span class="operator">=</span> LOGIN_ATTEMPT_KEY + username;</span><br><span class="line">        <span class="type">Long</span> <span class="variable">attempts</span> <span class="operator">=</span> redisTemplate.opsForValue().increment(key);</span><br><span class="line">        <span class="keyword">if</span> (attempts == <span class="number">1</span>) &#123;</span><br><span class="line">            redisTemplate.expire(key, LOCK_TIME_MINUTES, TimeUnit.MINUTES);</span><br><span class="line">        &#125;</span><br><span class="line">    &#125;</span><br><span class="line"></span><br><span class="line">    <span class="keyword">public</span> <span class="type">boolean</span> <span class="title function_">isBlocked</span><span class="params">(String username)</span> &#123;</span><br><span class="line">        <span class="type">String</span> <span class="variable">key</span> <span class="operator">=</span> LOGIN_ATTEMPT_KEY + username;</span><br><span class="line">        <span class="type">String</span> <span class="variable">attempts</span> <span class="operator">=</span> redisTemplate.opsForValue().get(key);</span><br><span class="line">        <span class="keyword">return</span> attempts != <span class="literal">null</span> &amp;&amp; Integer.parseInt(attempts) &gt;= MAX_ATTEMPTS;</span><br><span class="line">    &#125;</span><br><span class="line"></span><br><span class="line">    <span class="keyword">public</span> <span class="keyword">void</span> <span class="title function_">loginSucceeded</span><span class="params">(String username)</span> &#123;</span><br><span class="line">        <span class="type">String</span> <span class="variable">key</span> <span class="operator">=</span> LOGIN_ATTEMPT_KEY + username;</span><br><span class="line">        redisTemplate.delete(key);</span><br><span class="line">    &#125;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p><strong>加固二：防止 CSRF 攻击</strong></p><p><strong>问题</strong>：激活链接和重置密码链接可能被 CSRF 攻击。</p><p><strong>解决方案</strong>：使用 HMAC 签名，并且签名中包含时间戳。</p><p><strong>加固三：防止 XSS 攻击</strong></p><p><strong>问题</strong>：用户昵称可能包含恶意脚本。</p><p><strong>解决方案</strong>：对用户输入进行 HTML 转义。</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><span class="line"><span class="keyword">import</span> org.springframework.web.util.HtmlUtils;</span><br><span class="line"></span><br><span class="line"><span class="keyword">public</span> <span class="keyword">void</span> <span class="title function_">register</span><span class="params">(RegisterRequest request)</span> &#123;</span><br><span class="line">    <span class="type">String</span> <span class="variable">nickname</span> <span class="operator">=</span> HtmlUtils.htmlEscape(request.getUsername());</span><br><span class="line">    user.setNickname(nickname);</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p><strong>加固四：防止 SQL 注入</strong></p><p><strong>问题</strong>：使用字符串拼接 SQL 可能导致 SQL 注入。</p><p><strong>解决方案</strong>：使用 MyBatis-Plus 的 LambdaQueryWrapper，避免字符串拼接。</p><hr><p><strong>🎉 恭喜你完成了账号密码登录体系的构建！</strong></p><p>现在你已经掌握了：</p><ul><li>✅ 账号-认证分离模型（1: N）的设计理念</li><li>✅ BCrypt 密码加密的原理与实战</li><li>✅ 验证码服务的实现</li><li>✅ Spring Mail 邮件服务的配置</li><li>✅ Spring Event 异步发送机制</li><li>✅ HMAC 签名防篡改设计</li><li>✅ 完整的用户注册、登录、找回密码流程</li></ul><p>在下一章（19.4）中，我们将实现手机验证码登录，包括：</p><ul><li>短信服务集成（阿里云 SMS）</li><li>验证码生成与校验</li><li>手机号登录策略</li><li>手机号绑定功能</li></ul><p>这套账号密码登录体系将成为整个认证系统的基石，后续的所有登录方式都将基于这个体系实现。</p></div>]]></content>
    
    
      
      
        
        
    <summary type="html">&lt;div id=&quot;postchat_postcontent&quot;&gt;&lt;h1&gt;Note 19.3. 账号密码登录：领域模型设计与完整实现&lt;/h1&gt;
&lt;h2</summary>
        
      
    
    
    
    <category term="后端技术" scheme="https://prorise666.site/categories/%E5%90%8E%E7%AB%AF%E6%8A%80%E6%9C%AF/"/>
    
    <category term="Java" scheme="https://prorise666.site/categories/%E5%90%8E%E7%AB%AF%E6%8A%80%E6%9C%AF/Java/"/>
    
    <category term="Spring系列" scheme="https://prorise666.site/categories/%E5%90%8E%E7%AB%AF%E6%8A%80%E6%9C%AF/Java/Spring%E7%B3%BB%E5%88%97/"/>
    
    <category term="登录注册系列" scheme="https://prorise666.site/categories/%E5%90%8E%E7%AB%AF%E6%8A%80%E6%9C%AF/Java/Spring%E7%B3%BB%E5%88%97/%E7%99%BB%E5%BD%95%E6%B3%A8%E5%86%8C%E7%B3%BB%E5%88%97/"/>
    
    
    <category term="Spring生态篇" scheme="https://prorise666.site/tags/Spring%E7%94%9F%E6%80%81%E7%AF%87/"/>
    
  </entry>
  
  <entry>
    <title>Note 19（第二章）. SpringBoot3-手动实现一个最佳规范的策略模式登录注册认证工厂</title>
    <link href="https://prorise666.site/posts/26436.html"/>
    <id>https://prorise666.site/posts/26436.html</id>
    <published>2026-03-02T20:15:45.000Z</published>
    <updated>2026-03-12T09:18:16.959Z</updated>
    
    <content type="html"><![CDATA[<div id="postchat_postcontent"><h1>Note 19.2. 逻辑引擎：基于策略模式的认证工厂</h1><h2 id="19-2-1-问题引入">19.2.1. 问题引入</h2><p>在开始设计认证工厂之前,我们需要先理解一个问题:<strong>为什么不能用 if-else 实现多种登录方式?</strong></p><h3 id="场景模拟-五种登录方式的传统实现">场景模拟:五种登录方式的传统实现</h3><p>假设我们现在要支持 5 种登录方式:</p><ol><li><strong>账号密码登录</strong>:用户输入用户名和密码</li><li><strong>手机验证码登录</strong>:用户输入手机号和验证码</li><li><strong>微信扫码登录</strong>:用户扫描二维码,微信返回授权码</li><li><strong>GitHub OAuth2 登录</strong>:用户授权后,GitHub 返回授权码</li><li><strong>Google OAuth2 登录</strong>:用户授权后,Google 返回授权码</li></ol><p>在没有设计模式的情况下,我们的 Controller 会是这样的:</p><p><strong>📄 文件路径</strong>:<code>auth-web/src/main/java/com/example/auth/web/controller/AuthController.java</code>(传统写法)</p><figure class="highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><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></pre></td><td class="code"><pre><span class="line"><span class="meta">@RestController</span></span><br><span class="line"><span class="meta">@RequestMapping(&quot;/auth&quot;)</span></span><br><span class="line"><span class="meta">@RequiredArgsConstructor</span></span><br><span class="line"><span class="keyword">public</span> <span class="keyword">class</span> <span class="title class_">AuthController</span> &#123;</span><br><span class="line">    <span class="keyword">private</span> <span class="keyword">final</span> UserService userService;</span><br><span class="line">    <span class="keyword">private</span> <span class="keyword">final</span> SmsService smsService;</span><br><span class="line">    <span class="keyword">private</span> <span class="keyword">final</span> WechatService wechatService;</span><br><span class="line">    <span class="keyword">private</span> <span class="keyword">final</span> GithubService githubService;</span><br><span class="line">    <span class="keyword">private</span> <span class="keyword">final</span> GoogleService googleService;</span><br><span class="line"></span><br><span class="line">    <span class="meta">@PostMapping(&quot;/login&quot;)</span></span><br><span class="line">    <span class="keyword">public</span> Result&lt;AuthToken&gt; <span class="title function_">login</span><span class="params">(<span class="meta">@RequestBody</span> Map&lt;String, Object&gt; request)</span> &#123;</span><br><span class="line">        <span class="type">String</span> <span class="variable">type</span> <span class="operator">=</span> (String) request.get(<span class="string">&quot;type&quot;</span>);</span><br><span class="line">        </span><br><span class="line">        <span class="keyword">if</span> (<span class="string">&quot;password&quot;</span>.equals(type)) &#123;</span><br><span class="line">            <span class="comment">// ==== 账号密码登录逻辑(约 50 行代码) ====</span></span><br><span class="line">            <span class="type">String</span> <span class="variable">username</span> <span class="operator">=</span> (String) request.get(<span class="string">&quot;username&quot;</span>);</span><br><span class="line">            <span class="type">String</span> <span class="variable">password</span> <span class="operator">=</span> (String) request.get(<span class="string">&quot;password&quot;</span>);</span><br><span class="line">            </span><br><span class="line">            <span class="comment">// 1. 查询用户</span></span><br><span class="line">            <span class="type">User</span> <span class="variable">user</span> <span class="operator">=</span> userService.getByUsername(username);</span><br><span class="line">            <span class="keyword">if</span> (user == <span class="literal">null</span>) &#123;</span><br><span class="line">                <span class="keyword">throw</span> <span class="keyword">new</span> <span class="title class_">RuntimeException</span>(<span class="string">&quot;用户名或密码错误&quot;</span>);</span><br><span class="line">            &#125;</span><br><span class="line">            </span><br><span class="line">            <span class="comment">// 2. 检查账号状态</span></span><br><span class="line">            <span class="keyword">if</span> (user.getStatus() == <span class="number">0</span>) &#123;</span><br><span class="line">                <span class="keyword">throw</span> <span class="keyword">new</span> <span class="title class_">RuntimeException</span>(<span class="string">&quot;账号已被禁用&quot;</span>);</span><br><span class="line">            &#125;</span><br><span class="line">            </span><br><span class="line">            <span class="comment">// 3. 验证密码</span></span><br><span class="line">            <span class="keyword">if</span> (!passwordEncoder.matches(password, user.getPassword())) &#123;</span><br><span class="line">                <span class="keyword">throw</span> <span class="keyword">new</span> <span class="title class_">RuntimeException</span>(<span class="string">&quot;用户名或密码错误&quot;</span>);</span><br><span class="line">            &#125;</span><br><span class="line">            </span><br><span class="line">            <span class="comment">// 4. Sa-Token 登录</span></span><br><span class="line">            StpUtil.login(user.getId());</span><br><span class="line">            <span class="keyword">return</span> Result.ok(buildAuthToken());</span><br><span class="line">            </span><br><span class="line">        &#125; <span class="keyword">else</span> <span class="keyword">if</span> (<span class="string">&quot;sms&quot;</span>.equals(type)) &#123;</span><br><span class="line">            <span class="comment">// ==== 手机验证码登录逻辑(约 50 行代码) ====</span></span><br><span class="line">            <span class="type">String</span> <span class="variable">phone</span> <span class="operator">=</span> (String) request.get(<span class="string">&quot;phone&quot;</span>);</span><br><span class="line">            <span class="type">String</span> <span class="variable">code</span> <span class="operator">=</span> (String) request.get(<span class="string">&quot;code&quot;</span>);</span><br><span class="line">            </span><br><span class="line">            <span class="comment">// 1. 验证验证码</span></span><br><span class="line">            <span class="keyword">if</span> (!smsService.verifyCode(phone, code)) &#123;</span><br><span class="line">                <span class="keyword">throw</span> <span class="keyword">new</span> <span class="title class_">RuntimeException</span>(<span class="string">&quot;验证码错误或已过期&quot;</span>);</span><br><span class="line">            &#125;</span><br><span class="line">            </span><br><span class="line">            <span class="comment">// 2. 查询或创建用户</span></span><br><span class="line">            <span class="type">User</span> <span class="variable">user</span> <span class="operator">=</span> userService.getByPhone(phone);</span><br><span class="line">            <span class="keyword">if</span> (user == <span class="literal">null</span>) &#123;</span><br><span class="line">                user = userService.createByPhone(phone);</span><br><span class="line">            &#125;</span><br><span class="line">            </span><br><span class="line">            <span class="comment">// 3. Sa-Token 登录</span></span><br><span class="line">            StpUtil.login(user.getId());</span><br><span class="line">            <span class="keyword">return</span> Result.ok(buildAuthToken());</span><br><span class="line">            </span><br><span class="line">        &#125; <span class="keyword">else</span> <span class="keyword">if</span> (<span class="string">&quot;wechat&quot;</span>.equals(type)) &#123;</span><br><span class="line">            <span class="comment">// ==== 微信扫码登录逻辑(约 50 行代码) ====</span></span><br><span class="line">            <span class="type">String</span> <span class="variable">code</span> <span class="operator">=</span> (String) request.get(<span class="string">&quot;code&quot;</span>);</span><br><span class="line">            </span><br><span class="line">            <span class="comment">// 1. 调用微信 API 获取 openId</span></span><br><span class="line">            <span class="type">String</span> <span class="variable">openId</span> <span class="operator">=</span> wechatService.getOpenId(code);</span><br><span class="line">            </span><br><span class="line">            <span class="comment">// 2. 查询或创建用户</span></span><br><span class="line">            <span class="type">User</span> <span class="variable">user</span> <span class="operator">=</span> userService.getByWechatOpenId(openId);</span><br><span class="line">            <span class="keyword">if</span> (user == <span class="literal">null</span>) &#123;</span><br><span class="line">                user = userService.createByWechatOpenId(openId);</span><br><span class="line">            &#125;</span><br><span class="line">            </span><br><span class="line">            <span class="comment">// 3. Sa-Token 登录</span></span><br><span class="line">            StpUtil.login(user.getId());</span><br><span class="line">            <span class="keyword">return</span> Result.ok(buildAuthToken());</span><br><span class="line">            </span><br><span class="line">        &#125; <span class="keyword">else</span> <span class="keyword">if</span> (<span class="string">&quot;github&quot;</span>.equals(type)) &#123;</span><br><span class="line">            <span class="comment">// ==== GitHub OAuth2 登录逻辑(约 50 行代码) ====</span></span><br><span class="line">            <span class="type">String</span> <span class="variable">code</span> <span class="operator">=</span> (String) request.get(<span class="string">&quot;code&quot;</span>);</span><br><span class="line">            </span><br><span class="line">            <span class="comment">// 1. 调用 GitHub API 获取 access_token</span></span><br><span class="line">            <span class="type">String</span> <span class="variable">accessToken</span> <span class="operator">=</span> githubService.getAccessToken(code);</span><br><span class="line">            </span><br><span class="line">            <span class="comment">// 2. 调用 GitHub API 获取用户信息</span></span><br><span class="line">            <span class="type">GithubUser</span> <span class="variable">githubUser</span> <span class="operator">=</span> githubService.getUserInfo(accessToken);</span><br><span class="line">            </span><br><span class="line">            <span class="comment">// 3. 查询或创建用户</span></span><br><span class="line">            <span class="type">User</span> <span class="variable">user</span> <span class="operator">=</span> userService.getByGithubId(githubUser.getId());</span><br><span class="line">            <span class="keyword">if</span> (user == <span class="literal">null</span>) &#123;</span><br><span class="line">                user = userService.createByGithubUser(githubUser);</span><br><span class="line">            &#125;</span><br><span class="line">            </span><br><span class="line">            <span class="comment">// 4. Sa-Token 登录</span></span><br><span class="line">            StpUtil.login(user.getId());</span><br><span class="line">            <span class="keyword">return</span> Result.ok(buildAuthToken());</span><br><span class="line">            </span><br><span class="line">        &#125; <span class="keyword">else</span> <span class="keyword">if</span> (<span class="string">&quot;google&quot;</span>.equals(type)) &#123;</span><br><span class="line">            <span class="comment">// ==== Google OAuth2 登录逻辑(约 50 行代码) ====</span></span><br><span class="line">            <span class="type">String</span> <span class="variable">code</span> <span class="operator">=</span> (String) request.get(<span class="string">&quot;code&quot;</span>);</span><br><span class="line">            </span><br><span class="line">            <span class="comment">// 1. 调用 Google API 获取 access_token</span></span><br><span class="line">            <span class="type">String</span> <span class="variable">accessToken</span> <span class="operator">=</span> googleService.getAccessToken(code);</span><br><span class="line">            </span><br><span class="line">            <span class="comment">// 2. 调用 Google API 获取用户信息</span></span><br><span class="line">            <span class="type">GoogleUser</span> <span class="variable">googleUser</span> <span class="operator">=</span> googleService.getUserInfo(accessToken);</span><br><span class="line">            </span><br><span class="line">            <span class="comment">// 3. 查询或创建用户</span></span><br><span class="line">            <span class="type">User</span> <span class="variable">user</span> <span class="operator">=</span> userService.getByGoogleId(googleUser.getId());</span><br><span class="line">            <span class="keyword">if</span> (user == <span class="literal">null</span>) &#123;</span><br><span class="line">                user = userService.createByGoogleUser(googleUser);</span><br><span class="line">            &#125;</span><br><span class="line">            </span><br><span class="line">            <span class="comment">// 4. Sa-Token 登录</span></span><br><span class="line">            StpUtil.login(user.getId());</span><br><span class="line">            <span class="keyword">return</span> Result.ok(buildAuthToken());</span><br><span class="line">            </span><br><span class="line">        &#125; <span class="keyword">else</span> &#123;</span><br><span class="line">            <span class="keyword">throw</span> <span class="keyword">new</span> <span class="title class_">RuntimeException</span>(<span class="string">&quot;不支持的登录方式: &quot;</span> + type);</span><br><span class="line">        &#125;</span><br><span class="line">    &#125;</span><br><span class="line">    </span><br><span class="line">    <span class="keyword">private</span> AuthToken <span class="title function_">buildAuthToken</span><span class="params">()</span> &#123;</span><br><span class="line">        <span class="keyword">return</span> AuthToken.builder()</span><br><span class="line">            .tokenName(StpUtil.getTokenName())</span><br><span class="line">            .tokenValue(StpUtil.getTokenValue())</span><br><span class="line">            .loginId(StpUtil.getLoginIdAsLong())</span><br><span class="line">            .build();</span><br><span class="line">    &#125;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><h3 id="传统写法的五大致命问题">传统写法的五大致命问题</h3><p><strong>问题一:代码膨胀</strong></p><p>这个 Controller 的 <code>login</code> 方法已经超过 <strong>250 行代码</strong>。如果再加上异常处理、日志记录、参数校验,代码量会超过 <strong>400 行</strong>。</p><p>想象一下,当你需要修改某个登录方式的逻辑时,你需要在这 400 行代码中找到对应的 if-else 分支,然后小心翼翼地修改,生怕影响到其他分支。这种体验就像在一个巨大的迷宫中寻找出口。</p><p><strong>问题二:违反开闭原则</strong></p><p>当我们需要新增一个登录方式(如 “Apple 登录”)时,必须修改 <code>AuthController</code> 的代码,增加一个新的 <code>else if</code> 分支。这违反了开闭原则(Open-Closed Principle):<strong>对扩展开放,对修改关闭</strong>。</p><p>在传统写法中,每次新增登录方式都需要修改 Controller,这意味着:</p><ul><li>需要重新测试所有登录方式(因为修改了 Controller)</li><li>可能引入新的 Bug(因为修改了现有代码)</li><li>代码越来越臃肿(每次新增都会增加 50+ 行代码)</li></ul><p><strong>问题三:难以测试</strong></p><p>我们无法单独测试某个登录方式的逻辑。如果要测试 “微信登录”,必须:</p><ol><li>启动整个 Spring Boot 应用</li><li>模拟所有依赖(UserService、WechatService 等)</li><li>构造完整的 HTTP 请求</li><li>验证响应结果</li></ol><p>这种测试方式效率极低,而且容易受到其他登录方式的干扰。</p><p><strong>问题四:职责不清</strong></p><p>Controller 层不应该包含业务逻辑。它的职责应该是:</p><ul><li>接收请求</li><li>参数校验</li><li>调用 Service 层</li><li>返回响应</li></ul><p>但现在 Controller 层包含了大量的业务逻辑(密码验证、用户创建、Token 生成等),违反了单一职责原则(Single Responsibility Principle)。</p><p>在传统写法中,Controller 承担了太多职责:</p><ul><li>路由分发(根据 type 选择登录方式)</li><li>参数解析(从 Map 中提取参数)</li><li>业务逻辑(验证密码、调用第三方 API)</li><li>异常处理(捕获并转换异常)</li></ul><p><strong>问题五:无法动态管理</strong></p><p>我们无法在运行时动态禁用某个登录方式。如果要禁用 “微信登录”,必须:</p><ol><li>修改代码(注释掉对应的 if-else 分支)</li><li>重新编译</li><li>重新部署</li></ol><p>这在生产环境中是不可接受的。想象一下,如果微信登录接口出现故障,我们需要紧急禁用这个功能,但却需要重新部署整个应用,这会导致服务中断。</p><h3 id="解决方案-策略模式-工厂模式">解决方案:策略模式 + 工厂模式</h3><p>我们需要一个更优雅的设计:</p><ul><li><strong>策略模式</strong>:将每种登录方式封装为一个独立的策略类</li><li><strong>工厂模式</strong>:使用工厂类根据登录类型自动选择对应的策略</li></ul><p><strong>架构对比</strong>:</p><table><thead><tr><th style="text-align:left">对比维度</th><th style="text-align:left">传统 if-else</th><th style="text-align:left">策略模式 + 工厂模式</th></tr></thead><tbody><tr><td style="text-align:left">Controller 代码量</td><td style="text-align:left">250+ 行</td><td style="text-align:left">10 行</td></tr><tr><td style="text-align:left">新增登录方式</td><td style="text-align:left">修改 Controller</td><td style="text-align:left">只需实现 <code>AuthStrategy</code> 接口</td></tr><tr><td style="text-align:left">可测试性</td><td style="text-align:left">难以单独测试</td><td style="text-align:left">可以单独测试每个策略</td></tr><tr><td style="text-align:left">职责分离</td><td style="text-align:left">Controller 包含业务逻辑</td><td style="text-align:left">Controller 只负责调度</td></tr><tr><td style="text-align:left">动态管理</td><td style="text-align:left">不支持</td><td style="text-align:left">支持(配置化管理)</td></tr><tr><td style="text-align:left">符合设计原则</td><td style="text-align:left">❌ 违反开闭原则、单一职责原则</td><td style="text-align:left">✅ 符合开闭原则、单一职责原则</td></tr></tbody></table><p>在接下来的章节中,我们将一步步构建这个认证工厂,让你亲眼见证代码从 250 行减少到 10 行的过程。</p><hr><h2 id="19-2-2-架构设计">19.2.2. 架构设计</h2><p>在上一节中，我们已经看到了传统 if-else 登录的五大致命问题。现在，让我们设计一个更优雅的解决方案。</p><p>在开始编写代码之前，我们需要先设计整个认证工厂的架构。这就像盖房子之前要先画设计图一样，架构设计决定了代码的可维护性和可扩展性。</p><h3 id="核心设计理念">核心设计理念</h3><p>我们的认证工厂基于三个核心理念：</p><table><thead><tr><th>理念</th><th>类比</th><th>系统实现</th></tr></thead><tbody><tr><td><strong>统一入口</strong></td><td>机场安检口：无论国内航班还是国际航班，都从同一个入口进入，然后根据航班类型被引导到不同的登机口</td><td>- 统一入口：<code>AuthController</code> 的 <code>/auth/login</code> 接口<br>- 多态分发：<code>AuthStrategyFactory</code> 根据 <code>authType</code> 选择对应的策略</td></tr><tr><td><strong>策略独立</strong></td><td>餐厅的不同厨师：川菜师傅和粤菜师傅各司其职，互不干扰</td><td>- 账号密码登录：<code>PasswordAuthStrategy</code><br>- 手机验证码登录：<code>SmsAuthStrategy</code><br>- 微信扫码登录：<code>WechatAuthStrategy</code><br>- 每个策略类只负责自己的验证逻辑，不需要知道其他策略的存在</td></tr><tr><td><strong>工厂管理</strong></td><td>公司的人力资源部门：自动发现和管理所有员工，不需要手动登记</td><td>- <code>AuthStrategyFactory</code> 在启动时自动扫描所有实现了 <code>AuthStrategy</code> 接口的 Bean<br>- 将这些策略注册到 <code>Map&lt;AuthType, AuthStrategy&gt;</code> 中<br>- 后续根据 <code>authType</code> 自动选择对应的策略</td></tr></tbody></table><h3 id="架构全景图">架构全景图</h3><p>让我们先看一下整个认证工厂的架构全景图：</p><p><img src="https://prorise-blog.oss-cn-guangzhou.aliyuncs.com/cover/mermaid-diagram-2025-12-28-201205.png" alt="mermaid-diagram-2025-12-28-201205"></p><p><strong>架构说明</strong>：</p><p>从上到下，整个系统分为六层：</p><p><strong>第一层：客户端层</strong></p><ul><li>前端应用（Web、移动端、小程序等）发送登录请求</li><li>请求中必须包含 <code>authType</code> 字段，标识登录方式</li></ul><p><strong>第二层：接入层</strong></p><ul><li><code>AuthController</code> 接收请求，这是系统的统一入口</li><li>Controller 不包含任何业务逻辑，只负责接收请求和返回响应</li></ul><p><strong>第三层：工厂层</strong></p><ul><li><code>AuthStrategyFactory</code> 根据 <code>authType</code> 选择对应的策略</li><li>这是整个系统的 “中枢神经”，负责策略的管理和分发</li></ul><p><strong>第四层：策略层</strong></p><ul><li>每种登录方式都是一个独立的策略类</li><li>策略类只负责验证身份，返回用户 ID</li></ul><p><strong>第五层：服务层</strong></p><ul><li>策略类调用各种服务完成业务逻辑</li><li>如 <code>UserService</code>（查询用户）、<code>SmsService</code>（验证验证码）、<code>WechatService</code>（调用微信 API）</li></ul><p><strong>第六层：数据层</strong></p><ul><li>MySQL 存储用户数据</li><li>Redis 存储会话信息（由 Sa-Token 管理）</li></ul><h3 id="请求流转时序图">请求流转时序图</h3><p>现在让我们看一下一个完整的登录请求是如何在系统中流转的：</p><p><img src="https://prorise-blog.oss-cn-guangzhou.aliyuncs.com/cover/mermaid-diagram-2025-12-28-201325.png" alt="mermaid-diagram-2025-12-28-201325"></p><p><strong>时序说明</strong>：</p><p>让我们逐步分析这个流程：</p><p><strong>步骤 1：前端发送登录请求</strong></p><figure class="highlight json"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br></pre></td><td class="code"><pre><span class="line"><span class="punctuation">&#123;</span></span><br><span class="line">  <span class="attr">&quot;authType&quot;</span><span class="punctuation">:</span> <span class="string">&quot;PASSWORD&quot;</span><span class="punctuation">,</span></span><br><span class="line">  <span class="attr">&quot;username&quot;</span><span class="punctuation">:</span> <span class="string">&quot;admin&quot;</span><span class="punctuation">,</span></span><br><span class="line">  <span class="attr">&quot;password&quot;</span><span class="punctuation">:</span> <span class="string">&quot;123456&quot;</span></span><br><span class="line"><span class="punctuation">&#125;</span></span><br></pre></td></tr></table></figure><p>前端必须在请求中携带 <code>authType</code> 字段，标识这是哪种登录方式。</p><p><strong>步骤 2：Controller 接收请求</strong></p><p><code>AuthController</code> 接收到请求后，不做任何业务逻辑处理，直接委托给 <code>AuthStrategyFactory</code>：</p><figure class="highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br></pre></td><td class="code"><pre><span class="line"><span class="meta">@PostMapping(&quot;/login&quot;)</span></span><br><span class="line"><span class="keyword">public</span> Result&lt;AuthTokenVO&gt; <span class="title function_">login</span><span class="params">(<span class="meta">@Valid</span> <span class="meta">@RequestBody</span> AuthRequest request)</span> &#123;</span><br><span class="line">    <span class="type">AuthTokenVO</span> <span class="variable">authToken</span> <span class="operator">=</span> authStrategyFactory.authenticate(request);</span><br><span class="line">    <span class="keyword">return</span> Result.ok(authToken);</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p><strong>步骤 3：工厂选择策略</strong></p><p><code>AuthStrategyFactory</code> 根据 <code>authType</code> 字段，从 <code>Map&lt;AuthType, AuthStrategy&gt;</code> 中获取对应的策略：</p><figure class="highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line"><span class="type">AuthStrategy</span> <span class="variable">strategy</span> <span class="operator">=</span> strategyMap.get(request.getAuthType());</span><br></pre></td></tr></table></figure><p><strong>步骤 4：策略执行认证</strong></p><p>策略类执行具体的认证逻辑（查询用户、验证密码等），返回用户 ID：</p><figure class="highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line"><span class="type">Long</span> <span class="variable">userId</span> <span class="operator">=</span> strategy.authenticate(request);</span><br></pre></td></tr></table></figure><p><strong>步骤 5：工厂调用 Sa-Token 登录</strong></p><p>工厂类拿到用户 ID 后，调用 Sa-Token 的登录方法：</p><figure class="highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">StpUtil.login(userId);</span><br></pre></td></tr></table></figure><p>Sa-Token 会自动生成 Token 并存入 Redis。</p><p><strong>步骤 6：返回 Token</strong></p><p>工厂类构建响应对象，包含 Token 信息：</p><figure class="highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">return</span> AuthTokenVO.builder()</span><br><span class="line">    .tokenName(StpUtil.getTokenName())</span><br><span class="line">    .tokenValue(StpUtil.getTokenValue())</span><br><span class="line">    .loginId(userId)</span><br><span class="line">    .build();</span><br></pre></td></tr></table></figure><p><strong>步骤 7：层层返回</strong></p><p>响应对象层层返回，最终到达前端。</p><h3 id="策略模式与-Sa-Token-的协同关系">策略模式与 Sa-Token 的协同关系</h3><p>现在让我们深入理解策略模式与 Sa-Token 是如何协同工作的。</p><p><strong>Sa-Token 的职责边界</strong></p><p>Sa-Token 只负责 “会话管理”，不负责 “身份验证”。具体来说：</p><p>Sa-Token 负责的事情：</p><ul><li>✅ 生成 Token（<code>StpUtil.login(userId)</code> 会自动生成）</li><li>✅ 验证 Token（<code>StpUtil.checkLogin()</code> 会自动验证）</li><li>✅ 管理会话（将会话信息存入 Redis）</li><li>✅ 权限验证（<code>StpUtil.checkPermission()</code> 等）</li><li>✅ 踢人下线（<code>StpUtil.kickout(userId)</code>）</li></ul><p>Sa-Token 不负责的事情：</p><ul><li>❌ 验证用户名和密码</li><li>❌ 验证手机验证码</li><li>❌ 调用微信 API 获取 openId</li><li>❌ 查询或创建用户</li></ul><p><strong>策略模式的职责边界</strong></p><p>策略模式负责 “身份验证”，不负责 “会话管理”。具体来说：</p><p>策略类负责的事情：</p><ul><li>✅ 验证用户名和密码</li><li>✅ 验证手机验证码</li><li>✅ 调用第三方 API 获取用户信息</li><li>✅ 查询或创建用户</li><li>✅ 返回用户 ID</li></ul><p>策略类不负责的事情：</p><ul><li>❌ 生成 Token（由 Sa-Token 负责）</li><li>❌ 管理会话（由 Sa-Token 负责）</li><li>❌ 权限验证（由 Sa-Token 负责）</li></ul><p><strong>协同工作流程</strong></p><p>让我们用一个具体的例子来说明它们是如何协同工作的。假设用户使用账号密码登录：</p><figure class="highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><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></pre></td><td class="code"><pre><span class="line"><span class="comment">// 步骤 1：策略类验证身份</span></span><br><span class="line"><span class="meta">@Component</span></span><br><span class="line"><span class="keyword">public</span> <span class="keyword">class</span> <span class="title class_">PasswordAuthStrategy</span> <span class="keyword">implements</span> <span class="title class_">AuthStrategy</span> &#123;</span><br><span class="line">    <span class="meta">@Override</span></span><br><span class="line">    <span class="keyword">public</span> Long <span class="title function_">authenticate</span><span class="params">(AuthRequest request)</span> &#123;</span><br><span class="line">        <span class="comment">// 1. 查询用户</span></span><br><span class="line">        <span class="type">User</span> <span class="variable">user</span> <span class="operator">=</span> userService.getByUsername(username);</span><br><span class="line">        </span><br><span class="line">        <span class="comment">// 2. 验证密码</span></span><br><span class="line">        <span class="keyword">if</span> (!BCrypt.checkpw(password, user.getPassword())) &#123;</span><br><span class="line">            <span class="keyword">throw</span> <span class="keyword">new</span> <span class="title class_">RuntimeException</span>(<span class="string">&quot;密码错误&quot;</span>);</span><br><span class="line">        &#125;</span><br><span class="line">        </span><br><span class="line">        <span class="comment">// 3. 返回用户 ID（策略类的职责到此结束）</span></span><br><span class="line">        <span class="keyword">return</span> user.getId();</span><br><span class="line">    &#125;</span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="comment">// 步骤 2：工厂类调用 Sa-Token 登录</span></span><br><span class="line"><span class="meta">@Component</span></span><br><span class="line"><span class="keyword">public</span> <span class="keyword">class</span> <span class="title class_">AuthStrategyFactory</span> &#123;</span><br><span class="line">    <span class="keyword">public</span> AuthTokenVO <span class="title function_">authenticate</span><span class="params">(AuthRequest request)</span> &#123;</span><br><span class="line">        <span class="comment">// 1. 选择策略</span></span><br><span class="line">        <span class="type">AuthStrategy</span> <span class="variable">strategy</span> <span class="operator">=</span> strategyMap.get(request.getAuthType());</span><br><span class="line">        </span><br><span class="line">        <span class="comment">// 2. 执行认证，获取用户 ID</span></span><br><span class="line">        <span class="type">Long</span> <span class="variable">userId</span> <span class="operator">=</span> strategy.authenticate(request);</span><br><span class="line">        </span><br><span class="line">        <span class="comment">// 3. Sa-Token 登录（Sa-Token 的职责从这里开始）</span></span><br><span class="line">        StpUtil.login(userId);</span><br><span class="line">        </span><br><span class="line">        <span class="comment">// 4. 返回 Token</span></span><br><span class="line">        <span class="keyword">return</span> AuthTokenVO.builder()</span><br><span class="line">            .tokenName(StpUtil.getTokenName())</span><br><span class="line">            .tokenValue(StpUtil.getTokenValue())</span><br><span class="line">            .loginId(userId)</span><br><span class="line">            .build();</span><br><span class="line">    &#125;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p><strong>关键设计点</strong>：</p><p>策略类只返回 <code>userId</code>，不返回 <code>Token</code>。这是因为：</p><ul><li>Token 的生成是 Sa-Token 的职责，策略类不应该关心</li><li>这样做可以让策略类保持职责单一，易于测试</li></ul><p>工厂类负责调用 <code>StpUtil.login(userId)</code>。这是因为：</p><ul><li>工厂类是策略模式和 Sa-Token 的 “桥梁”</li><li>所有策略类都需要调用 Sa-Token 登录，放在工厂类中可以避免重复代码</li></ul><h3 id="策略模式的类图结构">策略模式的类图结构</h3><p>让我们用类图来展示策略模式的结构：</p><p><img src="https://prorise-blog.oss-cn-guangzhou.aliyuncs.com/cover/mermaid-diagram-2025-12-28-201543.png" alt="mermaid-diagram-2025-12-28-201543"></p><p><strong>类图说明</strong>：</p><p><strong>AuthRequest 接口</strong></p><ul><li>这是所有认证请求的统一接口</li><li>定义了一个方法：<code>getAuthType()</code>，用于标识登录类型</li><li>所有具体的请求类（如 <code>PasswordAuthRequest</code>）都必须实现这个接口</li></ul><p><strong>AuthStrategy 接口</strong></p><ul><li>这是所有认证策略的统一接口</li><li>定义了两个方法：<ul><li><code>authenticate(AuthRequest)</code>：执行认证，返回用户 ID</li><li><code>getSupportedType()</code>：返回支持的认证类型</li></ul></li></ul><p><strong>AuthStrategyFactory 工厂类</strong></p><ul><li>管理所有策略的映射关系（<code>Map&lt;AuthType, AuthStrategy&gt;</code>）</li><li>提供 <code>authenticate(AuthRequest)</code> 方法，作为统一的认证入口</li><li>在应用启动时自动发现和注册所有策略</li></ul><p><strong>具体策略类</strong></p><ul><li><code>PasswordAuthStrategy</code>：账号密码登录</li><li><code>SmsAuthStrategy</code>：手机验证码登录</li><li><code>WechatAuthStrategy</code>：微信扫码登录</li></ul><p>每个策略类都实现了 <code>AuthStrategy</code> 接口，并提供自己的认证逻辑。</p><h3 id="设计模式的职责分工">设计模式的职责分工</h3><p>让我们用一个表格来总结各个设计模式的职责：</p><table><thead><tr><th style="text-align:left">设计模式</th><th style="text-align:left">职责</th><th style="text-align:left">核心类</th><th style="text-align:left">关键方法</th></tr></thead><tbody><tr><td style="text-align:left"><strong>策略模式</strong></td><td style="text-align:left">封装算法族，让它们可以互相替换</td><td style="text-align:left"><code>AuthStrategy</code> 接口及其实现类</td><td style="text-align:left"><code>authenticate(AuthRequest)</code></td></tr><tr><td style="text-align:left"><strong>工厂模式</strong></td><td style="text-align:left">根据条件创建对象，隐藏创建逻辑</td><td style="text-align:left"><code>AuthStrategyFactory</code></td><td style="text-align:left"><code>getStrategy(AuthType)</code></td></tr><tr><td style="text-align:left"><strong>多态</strong></td><td style="text-align:left">统一接口，不同实现</td><td style="text-align:left"><code>AuthRequest</code> 接口及其实现类</td><td style="text-align:left"><code>getAuthType()</code></td></tr></tbody></table><p><strong>策略模式的核心价值</strong>：</p><p>将每种登录方式封装为独立的策略类，它们之间互不依赖。这样做的好处是：</p><ul><li>新增登录方式不需要修改现有代码</li><li>可以单独测试每个策略</li><li>可以动态启用或禁用某个策略</li></ul><p><strong>工厂模式的核心价值</strong>：</p><p>根据登录类型自动选择对应的策略，隐藏了策略选择的复杂性。这样做的好处是：</p><ul><li>Controller 不需要知道如何选择策略</li><li>策略的注册和管理都由工厂类负责</li><li>可以在运行时动态添加或删除策略</li></ul><p><strong>多态的核心价值</strong>：</p><p>使用统一的接口（<code>AuthRequest</code>、<code>AuthStrategy</code>），让不同的实现类可以互相替换。这样做的好处是：</p><ul><li>Controller 只需要依赖接口，不需要依赖具体实现</li><li>可以在不修改 Controller 的情况下替换实现类</li><li>符合依赖倒置原则（Dependency Inversion Principle）</li></ul><h3 id="与传统写法的对比">与传统写法的对比</h3><p>让我们用一个表格来对比传统写法和策略模式的差异：</p><table><thead><tr><th style="text-align:left">对比维度</th><th style="text-align:left">传统 if-else</th><th style="text-align:left">策略模式 + 工厂模式</th></tr></thead><tbody><tr><td style="text-align:left"><strong>代码组织</strong></td><td style="text-align:left">所有逻辑混在一起</td><td style="text-align:left">每个策略独立成类</td></tr><tr><td style="text-align:left"><strong>Controller 代码量</strong></td><td style="text-align:left">250+ 行</td><td style="text-align:left">10 行</td></tr><tr><td style="text-align:left"><strong>新增登录方式</strong></td><td style="text-align:left">修改 Controller，增加 if-else 分支</td><td style="text-align:left">只需实现 <code>AuthStrategy</code> 接口</td></tr><tr><td style="text-align:left"><strong>可测试性</strong></td><td style="text-align:left">难以单独测试某个登录方式</td><td style="text-align:left">可以单独测试每个策略</td></tr><tr><td style="text-align:left"><strong>职责分离</strong></td><td style="text-align:left">Controller 包含业务逻辑</td><td style="text-align:left">Controller 只负责调度</td></tr><tr><td style="text-align:left"><strong>动态管理</strong></td><td style="text-align:left">不支持</td><td style="text-align:left">支持（配置化管理）</td></tr><tr><td style="text-align:left"><strong>符合设计原则</strong></td><td style="text-align:left">❌ 违反开闭原则、单一职责原则</td><td style="text-align:left">✅ 符合开闭原则、单一职责原则</td></tr></tbody></table><h3 id="本节小结">本节小结</h3><p>在本节中，我们完成了认证工厂的架构设计。</p><p>我们基于三个核心理念设计了整个系统：</p><ul><li>统一入口，多态分发：所有登录请求通过同一个接口进入，然后自动分发到对应的策略</li><li>策略独立，互不干扰：每种登录方式都是独立的策略类，可以单独开发和测试</li><li>工厂管理，自动发现：工厂类在启动时自动发现和注册所有策略</li></ul><p>我们绘制了三张关键的架构图：</p><ul><li>架构全景图：展示了系统的六层结构</li><li>请求流转时序图：展示了一个完整的登录请求如何在系统中流转</li><li>策略模式类图：展示了策略模式的类结构</li></ul><p>我们明确了策略模式与 Sa-Token 的职责边界：</p><ul><li>策略类负责验证身份，返回用户 ID</li><li>Sa-Token 负责生成 Token 和管理会话</li><li>工厂类是它们之间的桥梁</li></ul><table><thead><tr><th style="text-align:left">要点</th><th style="text-align:left">何时使用</th><th style="text-align:left">关键动作</th></tr></thead><tbody><tr><td style="text-align:left">架构全景图</td><td style="text-align:left">理解系统整体结构</td><td style="text-align:left">识别六层架构及其职责</td></tr><tr><td style="text-align:left">请求流转时序图</td><td style="text-align:left">理解登录流程</td><td style="text-align:left">跟踪请求从前端到数据库的完整路径</td></tr><tr><td style="text-align:left">策略模式类图</td><td style="text-align:left">理解策略模式结构</td><td style="text-align:left">识别接口、工厂、策略三者的关系</td></tr></tbody></table><p>在下一节中，我们将快速搭建项目骨架，让整个系统跑起来。</p><hr><h2 id="19-2-3-快速搭建：10-分钟启动项目骨架">19.2.3. 快速搭建：10 分钟启动项目骨架</h2><p>在上一节中，我们已经完成了认证工厂的架构设计，理解了策略模式与 Sa-Token 的协同关系。现在，让我们动手搭建一个可运行的项目骨架。</p><p>本节的目标很明确：<strong>用最短的时间搭建一个能够跑起来的基础环境</strong>，让你能够立即开始编写策略类。我们不会在这里讲解每个配置项的深层原理（那是后续章节的事情），而是专注于 “快速启动”。</p><h3 id="环境准备检查清单">环境准备检查清单</h3><p>在开始之前，请确认你的开发环境满足以下要求：</p><table><thead><tr><th style="text-align:left">组件</th><th style="text-align:left">版本要求</th><th style="text-align:left">检查方式</th><th style="text-align:left">备注</th></tr></thead><tbody><tr><td style="text-align:left">JDK</td><td style="text-align:left">17 或更高</td><td style="text-align:left">终端执行 <code>java -version</code></td><td style="text-align:left">必须是 LTS 版本</td></tr><tr><td style="text-align:left">Maven</td><td style="text-align:left">3.6+</td><td style="text-align:left">终端执行 <code>mvn -v</code></td><td style="text-align:left">或使用 IDE 内置 Maven</td></tr><tr><td style="text-align:left">Redis</td><td style="text-align:left">5.0+</td><td style="text-align:left">终端执行 <code>redis-cli ping</code>，返回 <code>PONG</code></td><td style="text-align:left">本地或远程均可</td></tr><tr><td style="text-align:left">IDE</td><td style="text-align:left">IntelliJ IDEA 2023+</td><td style="text-align:left">-</td><td style="text-align:left">推荐使用 Ultimate 版</td></tr></tbody></table><div class="note warning flat"><p>如果你的 Redis 尚未启动，请先在终端执行 <code>redis-server</code> 启动 Redis 服务。Windows 用户可以使用 WSL 或 Docker 运行 Redis。</p></div><h3 id="项目结构全景">项目结构全景</h3><p>我们将创建一个单模块的 Spring Boot 项目，目录结构如下：</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><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><span class="line">auth-factory-demo/</span><br><span class="line">├── pom.xml                          # Maven 依赖配置</span><br><span class="line">└── src/main/</span><br><span class="line">    ├── java/com/example/auth/</span><br><span class="line">    │   ├── AuthFactoryApplication.java    # 启动类</span><br><span class="line">    │   ├── config/</span><br><span class="line">    │   │   └── SaTokenConfig.java         # Sa-Token 配置类</span><br><span class="line">    │   ├── enums/</span><br><span class="line">    │   │   └── AuthType.java              # 认证类型枚举</span><br><span class="line">    │   ├── model/</span><br><span class="line">    │   │   ├── request/</span><br><span class="line">    │   │   │   └── AuthRequest.java       # 认证请求接口</span><br><span class="line">    │   │   └── vo/</span><br><span class="line">    │   │       └── AuthTokenVO.java       # 统一响应对象</span><br><span class="line">    │   ├── strategy/</span><br><span class="line">    │   │   └── AuthStrategy.java          # 认证策略接口</span><br><span class="line">    │   ├── factory/</span><br><span class="line">    │   │   └── AuthStrategyFactory.java   # 认证工厂</span><br><span class="line">    │   └── controller/</span><br><span class="line">    │       └── AuthController.java        # 认证控制器</span><br><span class="line">    └── resources/</span><br><span class="line">        └── application.yml                 # 应用配置文件</span><br></pre></td></tr></table></figure><p>这个结构非常简洁，没有复杂的多模块划分。我们的重点是 <strong>策略模式的实现</strong>，而不是项目结构的复杂性。</p><hr><h3 id="步骤-1：创建-Spring-Boot-项目">步骤 1：创建 Spring Boot 项目</h3><p>打开 IntelliJ IDEA，选择 <code>File</code> → <code>New</code> → <code>Project</code>。</p><p>在弹出的窗口中：</p><ol><li><p>左侧选择 <code>Spring Initializr</code></p></li><li><p>右侧配置项目信息：</p><ul><li><strong>Name</strong>：<code>auth</code></li><li><strong>Language</strong>：<code>Java</code></li><li><strong>Type</strong>：<code>Maven</code></li><li><strong>Group</strong>：<code>com.example</code></li><li><strong>Artifact</strong>：<code>auth</code></li><li><strong>Package name</strong>：<code>com.example.auth</code></li><li><strong>JDK</strong>：选择 <code>17</code> 或更高版本</li><li><strong>Java</strong>：<code>17</code></li><li><strong>Packaging</strong>：<code>Jar</code></li></ul></li><li><p>点击 <code>Next</code>，在依赖选择页面，暂时不选择任何依赖（我们稍后手动添加）</p></li><li><p>点击 <code>Finish</code>，等待项目创建完成</p></li></ol><p><strong>验证项目创建成功</strong>：</p><p>项目创建完成后，你应该能看到：</p><ul><li>左侧项目树中出现了 <code>auth-factory-demo</code> 文件夹</li><li><code>src/main/java/com/example/auth</code> 目录下有一个 <code>AuthFactoryDemoApplication.java</code> 启动类</li><li><code>pom.xml</code> 文件已经生成</li></ul><hr><h3 id="步骤-2：配置-Maven-依赖">步骤 2：配置 Maven 依赖</h3><p>现在我们需要在 <code>pom.xml</code> 中添加必要的依赖。</p><p><strong>📄 文件</strong>：<code>pom.xml</code></p><p>打开项目根目录下的 <code>pom.xml</code> 文件，将 <code>&lt;dependencies&gt;</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><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br><span class="line">32</span><br><span class="line">33</span><br><span class="line">34</span><br><span class="line">35</span><br><span class="line">36</span><br><span class="line">37</span><br><span class="line">38</span><br><span class="line">39</span><br><span class="line">40</span><br><span class="line">41</span><br><span class="line">42</span><br><span class="line">43</span><br><span class="line">44</span><br><span class="line">45</span><br><span class="line">46</span><br><span class="line">47</span><br><span class="line">48</span><br><span class="line">49</span><br><span class="line">50</span><br><span class="line">51</span><br><span class="line">52</span><br><span class="line">53</span><br><span class="line">54</span><br><span class="line">55</span><br><span class="line">56</span><br><span class="line">57</span><br><span class="line">58</span><br><span class="line">59</span><br></pre></td><td class="code"><pre><span class="line"><span class="tag">&lt;<span class="name">dependencies</span>&gt;</span></span><br><span class="line">    <span class="comment">&lt;!-- Spring Boot Web Starter --&gt;</span></span><br><span class="line">    <span class="tag">&lt;<span class="name">dependency</span>&gt;</span></span><br><span class="line">        <span class="tag">&lt;<span class="name">groupId</span>&gt;</span>org.springframework.boot<span class="tag">&lt;/<span class="name">groupId</span>&gt;</span></span><br><span class="line">        <span class="tag">&lt;<span class="name">artifactId</span>&gt;</span>spring-boot-starter-web<span class="tag">&lt;/<span class="name">artifactId</span>&gt;</span></span><br><span class="line">    <span class="tag">&lt;/<span class="name">dependency</span>&gt;</span></span><br><span class="line"></span><br><span class="line">    <span class="comment">&lt;!-- Sa-Token 核心依赖 --&gt;</span></span><br><span class="line">    <span class="tag">&lt;<span class="name">dependency</span>&gt;</span></span><br><span class="line">        <span class="tag">&lt;<span class="name">groupId</span>&gt;</span>cn.dev33<span class="tag">&lt;/<span class="name">groupId</span>&gt;</span></span><br><span class="line">        <span class="tag">&lt;<span class="name">artifactId</span>&gt;</span>sa-token-spring-boot3-starter<span class="tag">&lt;/<span class="name">artifactId</span>&gt;</span></span><br><span class="line">        <span class="tag">&lt;<span class="name">version</span>&gt;</span>1.37.0<span class="tag">&lt;/<span class="name">version</span>&gt;</span></span><br><span class="line">    <span class="tag">&lt;/<span class="name">dependency</span>&gt;</span></span><br><span class="line"></span><br><span class="line">    <span class="comment">&lt;!-- Sa-Token Redis 集成（使用 Jackson 序列化） --&gt;</span></span><br><span class="line">    <span class="tag">&lt;<span class="name">dependency</span>&gt;</span></span><br><span class="line">        <span class="tag">&lt;<span class="name">groupId</span>&gt;</span>cn.dev33<span class="tag">&lt;/<span class="name">groupId</span>&gt;</span></span><br><span class="line">        <span class="tag">&lt;<span class="name">artifactId</span>&gt;</span>sa-token-redis-jackson<span class="tag">&lt;/<span class="name">artifactId</span>&gt;</span></span><br><span class="line">        <span class="tag">&lt;<span class="name">version</span>&gt;</span>1.37.0<span class="tag">&lt;/<span class="name">version</span>&gt;</span></span><br><span class="line">    <span class="tag">&lt;/<span class="name">dependency</span>&gt;</span></span><br><span class="line"></span><br><span class="line">    <span class="comment">&lt;!-- Redis 客户端 --&gt;</span></span><br><span class="line">    <span class="tag">&lt;<span class="name">dependency</span>&gt;</span></span><br><span class="line">        <span class="tag">&lt;<span class="name">groupId</span>&gt;</span>org.springframework.boot<span class="tag">&lt;/<span class="name">groupId</span>&gt;</span></span><br><span class="line">        <span class="tag">&lt;<span class="name">artifactId</span>&gt;</span>spring-boot-starter-data-redis<span class="tag">&lt;/<span class="name">artifactId</span>&gt;</span></span><br><span class="line">    <span class="tag">&lt;/<span class="name">dependency</span>&gt;</span></span><br><span class="line"></span><br><span class="line">    <span class="comment">&lt;!-- Apache Commons Pool（Redis 连接池） --&gt;</span></span><br><span class="line">    <span class="tag">&lt;<span class="name">dependency</span>&gt;</span></span><br><span class="line">        <span class="tag">&lt;<span class="name">groupId</span>&gt;</span>org.apache.commons<span class="tag">&lt;/<span class="name">groupId</span>&gt;</span></span><br><span class="line">        <span class="tag">&lt;<span class="name">artifactId</span>&gt;</span>commons-pool2<span class="tag">&lt;/<span class="name">artifactId</span>&gt;</span></span><br><span class="line">    <span class="tag">&lt;/<span class="name">dependency</span>&gt;</span></span><br><span class="line"></span><br><span class="line">    <span class="comment">&lt;!-- Jackson 数据绑定（用于 JSON 多态反序列化） --&gt;</span></span><br><span class="line">    <span class="tag">&lt;<span class="name">dependency</span>&gt;</span></span><br><span class="line">        <span class="tag">&lt;<span class="name">groupId</span>&gt;</span>com.fasterxml.jackson.core<span class="tag">&lt;/<span class="name">groupId</span>&gt;</span></span><br><span class="line">        <span class="tag">&lt;<span class="name">artifactId</span>&gt;</span>jackson-databind<span class="tag">&lt;/<span class="name">artifactId</span>&gt;</span></span><br><span class="line">    <span class="tag">&lt;/<span class="name">dependency</span>&gt;</span></span><br><span class="line"></span><br><span class="line">    <span class="comment">&lt;!-- Validation API（用于参数校验） --&gt;</span></span><br><span class="line">    <span class="tag">&lt;<span class="name">dependency</span>&gt;</span></span><br><span class="line">        <span class="tag">&lt;<span class="name">groupId</span>&gt;</span>org.springframework.boot<span class="tag">&lt;/<span class="name">groupId</span>&gt;</span></span><br><span class="line">        <span class="tag">&lt;<span class="name">artifactId</span>&gt;</span>spring-boot-starter-validation<span class="tag">&lt;/<span class="name">artifactId</span>&gt;</span></span><br><span class="line">    <span class="tag">&lt;/<span class="name">dependency</span>&gt;</span></span><br><span class="line"></span><br><span class="line">    <span class="comment">&lt;!-- Lombok（简化 POJO 编写） --&gt;</span></span><br><span class="line">    <span class="tag">&lt;<span class="name">dependency</span>&gt;</span></span><br><span class="line">        <span class="tag">&lt;<span class="name">groupId</span>&gt;</span>org.projectlombok<span class="tag">&lt;/<span class="name">groupId</span>&gt;</span></span><br><span class="line">        <span class="tag">&lt;<span class="name">artifactId</span>&gt;</span>lombok<span class="tag">&lt;/<span class="name">artifactId</span>&gt;</span></span><br><span class="line">        <span class="tag">&lt;<span class="name">optional</span>&gt;</span>true<span class="tag">&lt;/<span class="name">optional</span>&gt;</span></span><br><span class="line">    <span class="tag">&lt;/<span class="name">dependency</span>&gt;</span></span><br><span class="line"></span><br><span class="line">    <span class="comment">&lt;!-- Spring Boot Test（测试依赖） --&gt;</span></span><br><span class="line">    <span class="tag">&lt;<span class="name">dependency</span>&gt;</span></span><br><span class="line">        <span class="tag">&lt;<span class="name">groupId</span>&gt;</span>org.springframework.boot<span class="tag">&lt;/<span class="name">groupId</span>&gt;</span></span><br><span class="line">        <span class="tag">&lt;<span class="name">artifactId</span>&gt;</span>spring-boot-starter-test<span class="tag">&lt;/<span class="name">artifactId</span>&gt;</span></span><br><span class="line">        <span class="tag">&lt;<span class="name">scope</span>&gt;</span>test<span class="tag">&lt;/<span class="name">scope</span>&gt;</span></span><br><span class="line">    <span class="tag">&lt;/<span class="name">dependency</span>&gt;</span></span><br><span class="line"><span class="tag">&lt;/<span class="name">dependencies</span>&gt;</span></span><br></pre></td></tr></table></figure><p><strong>依赖说明</strong>：</p><table><thead><tr><th style="text-align:left">依赖</th><th style="text-align:left">作用</th><th style="text-align:left">为什么需要</th></tr></thead><tbody><tr><td style="text-align:left"><code>spring-boot-starter-web</code></td><td style="text-align:left">提供 Web 功能</td><td style="text-align:left">我们需要创建 REST API</td></tr><tr><td style="text-align:left"><code>sa-token-spring-boot3-starter</code></td><td style="text-align:left">Sa-Token 核心</td><td style="text-align:left">提供认证与会话管理能力</td></tr><tr><td style="text-align:left"><code>sa-token-redis-jackson</code></td><td style="text-align:left">Sa-Token Redis 集成</td><td style="text-align:left">将会话信息存入 Redis</td></tr><tr><td style="text-align:left"><code>spring-boot-starter-data-redis</code></td><td style="text-align:left">Redis 客户端</td><td style="text-align:left">连接 Redis 服务器</td></tr><tr><td style="text-align:left"><code>commons-pool2</code></td><td style="text-align:left">连接池</td><td style="text-align:left">提高 Redis 连接性能</td></tr><tr><td style="text-align:left"><code>jackson-databind</code></td><td style="text-align:left">JSON 处理</td><td style="text-align:left">实现多态反序列化</td></tr><tr><td style="text-align:left"><code>spring-boot-starter-validation</code></td><td style="text-align:left">参数校验</td><td style="text-align:left">验证前端传来的参数</td></tr><tr><td style="text-align:left"><code>lombok</code></td><td style="text-align:left">代码简化</td><td style="text-align:left">自动生成 getter/setter</td></tr></tbody></table><p><strong>刷新 Maven 依赖</strong>：</p><p>保存 <code>pom.xml</code> 后，点击 IDE 右侧的 <code>Maven</code> 面板 → 点击刷新图标（或按 <code>Ctrl + Shift + O</code>）。</p><p><strong>验证依赖下载成功</strong>：</p><p>观察 IDE 底部的进度条走完，且 <code>pom.xml</code> 中的依赖没有红色波浪线，说明依赖下载成功。</p><hr><h3 id="步骤-3：配置-application-yml">步骤 3：配置 application.yml</h3><p>现在我们需要配置 Spring Boot 的应用配置文件。</p><p><strong>📄 文件</strong>：<code>src/main/resources/application.yml</code>（新建）</p><p>在 <code>src/main/resources</code> 目录下，右键选择 <code>New</code> → <code>File</code>，输入文件名 <code>application.yml</code>，然后粘贴以下内容：</p><figure class="highlight yaml"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><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><span class="line"><span class="comment"># 服务器配置</span></span><br><span class="line"><span class="attr">server:</span></span><br><span class="line">  <span class="attr">port:</span> <span class="number">8080</span>                    <span class="comment"># 应用端口，默认 8080</span></span><br><span class="line"></span><br><span class="line"><span class="comment"># Spring 配置</span></span><br><span class="line"><span class="attr">spring:</span></span><br><span class="line">  <span class="attr">application:</span></span><br><span class="line">    <span class="attr">name:</span> <span class="string">auth-factory-demo</span>     <span class="comment"># 应用名称</span></span><br><span class="line"></span><br><span class="line">  <span class="comment"># Redis 配置</span></span><br><span class="line">  <span class="attr">data:</span></span><br><span class="line">    <span class="attr">redis:</span></span><br><span class="line">      <span class="attr">host:</span> <span class="string">localhost</span>           <span class="comment"># Redis 服务器地址</span></span><br><span class="line">      <span class="attr">port:</span> <span class="number">6379</span>                <span class="comment"># Redis 端口</span></span><br><span class="line">      <span class="attr">password:</span>                 <span class="comment"># Redis 密码（如果没有设置密码，留空）</span></span><br><span class="line">      <span class="attr">database:</span> <span class="number">0</span>               <span class="comment"># 使用的数据库索引</span></span><br><span class="line">      <span class="attr">lettuce:</span></span><br><span class="line">        <span class="attr">pool:</span></span><br><span class="line">          <span class="attr">max-active:</span> <span class="number">8</span>         <span class="comment"># 连接池最大连接数</span></span><br><span class="line">          <span class="attr">max-idle:</span> <span class="number">8</span>           <span class="comment"># 连接池最大空闲连接数</span></span><br><span class="line">          <span class="attr">min-idle:</span> <span class="number">0</span>           <span class="comment"># 连接池最小空闲连接数</span></span><br><span class="line">          <span class="attr">max-wait:</span> <span class="string">-1ms</span>        <span class="comment"># 连接池最大阻塞等待时间</span></span><br><span class="line"></span><br><span class="line"><span class="comment"># Sa-Token 配置</span></span><br><span class="line"><span class="attr">sa-token:</span></span><br><span class="line">  <span class="attr">token-name:</span> <span class="string">Authorization</span>     <span class="comment"># Token 的名称（前端请求头中的字段名）</span></span><br><span class="line">  <span class="attr">timeout:</span> <span class="number">86400</span>                <span class="comment"># Token 有效期（单位：秒，86400 秒 = 1 天）</span></span><br><span class="line">  <span class="attr">active-timeout:</span> <span class="number">1800</span>          <span class="comment"># Token 最低活跃频率（单位：秒，1800 秒 = 30 分钟）</span></span><br><span class="line">  <span class="attr">is-concurrent:</span> <span class="literal">true</span>           <span class="comment"># 是否允许同一账号多地同时登录</span></span><br><span class="line">  <span class="attr">is-share:</span> <span class="literal">false</span>               <span class="comment"># 是否共享 Token（多个系统是否共用一套 Token）</span></span><br><span class="line">  <span class="attr">token-style:</span> <span class="string">uuid</span>             <span class="comment"># Token 风格（uuid、simple-uuid、random-32 等）</span></span><br><span class="line">  <span class="attr">is-log:</span> <span class="literal">true</span>                  <span class="comment"># 是否打印 Sa-Token 的操作日志</span></span><br></pre></td></tr></table></figure><p><strong>配置说明</strong>：</p><p><strong>Redis 配置</strong>：</p><ul><li><code>host</code> 和 <code>port</code>：指定 Redis 服务器的地址和端口</li><li><code>password</code>：如果你的 Redis 设置了密码，请填写密码；否则留空</li><li><code>database</code>：Redis 有 16 个数据库（0-15），我们使用 0 号数据库</li><li><code>lettuce.pool</code>：连接池配置，用于提高 Redis 连接性能</li></ul><p><strong>Sa-Token 配置</strong>：</p><ul><li><code>token-name</code>：前端请求头中携带 Token 的字段名，我们使用 <code>Authorization</code></li><li><code>timeout</code>：Token 的有效期，设置为 1 天（86400 秒）</li><li><code>active-timeout</code>：如果用户 30 分钟内没有任何操作，Token 会自动续期</li><li><code>is-concurrent</code>：允许同一个账号在多个设备上同时登录</li><li><code>is-share</code>：不共享 Token（每个应用使用独立的 Token）</li><li><code>token-style</code>：Token 的生成风格，使用 UUID</li><li><code>is-log</code>：开启 Sa-Token 的操作日志，方便调试</li></ul><hr><h2 id="19-2-4-接口抽象：定义统一的认证规范">19.2.4. 接口抽象：定义统一的认证规范</h2><p>在上一节中，我们已经完成了项目骨架的搭建，确认了 Spring Boot 应用能够正常启动，Redis 连接正常。现在，我们需要开始设计认证工厂的核心接口。</p><p>这一节是整个认证工厂的 <strong>“输入规范”</strong>，它定义了所有登录方式必须遵循的统一接口。就像工厂的生产线需要标准化的零件接口一样，我们的认证工厂也需要标准化的请求接口，才能让不同的登录策略无缝接入。</p><hr><h3 id="19-2-4-1-统一接口的设计意图">19.2.4.1. 统一接口的设计意图</h3><p>在传统写法中，Controller 使用 <code>Map&lt;String, Object&gt;</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><span class="line"><span class="meta">@PostMapping(&quot;/login&quot;)</span></span><br><span class="line"><span class="keyword">public</span> Result&lt;AuthToken&gt; <span class="title function_">login</span><span class="params">(<span class="meta">@RequestBody</span> Map&lt;String, Object&gt; request)</span> &#123;</span><br><span class="line">    <span class="type">String</span> <span class="variable">type</span> <span class="operator">=</span> (String) request.get(<span class="string">&quot;type&quot;</span>);</span><br><span class="line">    <span class="type">String</span> <span class="variable">username</span> <span class="operator">=</span> (String) request.get(<span class="string">&quot;username&quot;</span>);</span><br><span class="line">    <span class="comment">// ...</span></span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>这种写法存在三个核心问题：</p><p><strong>问题一：类型不安全</strong></p><p><code>Map&lt;String, Object&gt;</code> 中的值都是 <code>Object</code> 类型，需要手动强制转换。如果前端传错了类型，只能在运行时才能发现错误。</p><p><strong>问题二：无法校验</strong></p><p>我们无法使用 Spring 的 <code>@Valid</code> 注解进行参数校验，只能在业务逻辑中手动判断，导致校验代码散落在各个 if-else 分支中。</p><p><strong>问题三：工厂无法分发</strong></p><p>工厂模式的核心是 “根据类型选择策略”。但 <code>Map&lt;String, Object&gt;</code> 无法携带类型信息，工厂类只能通过字符串判断，这与我们的策略模式设计理念相悖。</p><p><strong>解决方案：定义统一的接口</strong></p><p>我们定义一个 <code>AuthRequest</code> 接口，所有登录请求都必须实现这个接口。这样做的核心价值是：</p><ul><li><strong>类型安全</strong>：编译时就能发现类型错误</li><li><strong>参数校验</strong>：可以使用 <code>@Valid</code> 注解自动校验</li><li><strong>工厂分发</strong>：工厂类可以通过 <code>getAuthType()</code> 方法获取类型，自动选择对应的策略</li></ul><p><strong>接口在工厂模式中的作用</strong></p><p>在策略模式中，接口扮演着 “统一输入” 的角色：</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">前端请求 → AuthRequest 接口 → 工厂类根据 authType 选择策略 → 策略类执行认证</span><br></pre></td></tr></table></figure><p>所有登录方式的请求都实现 <code>AuthRequest</code> 接口，工厂类只需要依赖这个接口，就能处理所有类型的登录请求。这就是 <strong>“面向接口编程”</strong> 的核心思想。</p><hr><h3 id="19-2-4-2-认证类型枚举（AuthType）">19.2.4.2. 认证类型枚举（AuthType）</h3><p>在定义接口之前，我们需要先定义一个枚举类，用于标识系统支持的所有登录方式。</p><p><strong>📄 文件</strong>：<code>src/main/java/com/example/auth/enums/AuthType.java</code>（新建）</p><figure class="highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><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></pre></td><td class="code"><pre><span class="line"><span class="keyword">package</span> com.example.auth.enums;</span><br><span class="line"></span><br><span class="line"><span class="keyword">import</span> lombok.Getter;</span><br><span class="line"></span><br><span class="line"><span class="comment">/**</span></span><br><span class="line"><span class="comment"> * 认证类型枚举</span></span><br><span class="line"><span class="comment"> * 集中管理系统支持的所有登录方式</span></span><br><span class="line"><span class="comment"> */</span></span><br><span class="line"><span class="meta">@Getter</span></span><br><span class="line"><span class="keyword">public</span> <span class="keyword">enum</span> <span class="title class_">AuthType</span> &#123;</span><br><span class="line">    <span class="comment">/**</span></span><br><span class="line"><span class="comment">     * 账号密码登录</span></span><br><span class="line"><span class="comment">     */</span></span><br><span class="line">    PASSWORD(<span class="string">&quot;password&quot;</span>, <span class="string">&quot;账号密码登录&quot;</span>),</span><br><span class="line">    </span><br><span class="line">    <span class="comment">/**</span></span><br><span class="line"><span class="comment">     * 手机验证码登录</span></span><br><span class="line"><span class="comment">     */</span></span><br><span class="line">    SMS(<span class="string">&quot;sms&quot;</span>, <span class="string">&quot;手机验证码登录&quot;</span>),</span><br><span class="line">    </span><br><span class="line">    <span class="comment">/**</span></span><br><span class="line"><span class="comment">     * 微信扫码登录</span></span><br><span class="line"><span class="comment">     */</span></span><br><span class="line">    WECHAT(<span class="string">&quot;wechat&quot;</span>, <span class="string">&quot;微信扫码登录&quot;</span>);</span><br><span class="line">    </span><br><span class="line">    <span class="comment">/**</span></span><br><span class="line"><span class="comment">     * 类型编码（用于前端传参）</span></span><br><span class="line"><span class="comment">     */</span></span><br><span class="line">    <span class="keyword">private</span> <span class="keyword">final</span> String code;</span><br><span class="line">    </span><br><span class="line">    <span class="comment">/**</span></span><br><span class="line"><span class="comment">     * 类型描述（用于日志记录）</span></span><br><span class="line"><span class="comment">     */</span></span><br><span class="line">    <span class="keyword">private</span> <span class="keyword">final</span> String description;</span><br><span class="line">    </span><br><span class="line">    AuthType(String code, String description) &#123;</span><br><span class="line">        <span class="built_in">this</span>.code = code;</span><br><span class="line">        <span class="built_in">this</span>.description = description;</span><br><span class="line">    &#125;</span><br><span class="line">    </span><br><span class="line">    <span class="comment">/**</span></span><br><span class="line"><span class="comment">     * 根据 code 获取枚举</span></span><br><span class="line"><span class="comment">     */</span></span><br><span class="line">    <span class="keyword">public</span> <span class="keyword">static</span> AuthType <span class="title function_">fromCode</span><span class="params">(String code)</span> &#123;</span><br><span class="line">        <span class="keyword">for</span> (AuthType type : values()) &#123;</span><br><span class="line">            <span class="keyword">if</span> (type.code.equals(code)) &#123;</span><br><span class="line">                <span class="keyword">return</span> type;</span><br><span class="line">            &#125;</span><br><span class="line">        &#125;</span><br><span class="line">        <span class="keyword">throw</span> <span class="keyword">new</span> <span class="title class_">IllegalArgumentException</span>(<span class="string">&quot;不支持的认证方式: &quot;</span> + code);</span><br><span class="line">    &#125;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p><strong>设计要点</strong></p><p>这个枚举类有两个核心字段：</p><ul><li><code>code</code>：用于前端传参和数据库存储，必须是简短的英文标识符</li><li><code>description</code>：用于日志记录和错误提示，必须是易读的中文描述</li></ul><p>这样设计的好处是：前端传参时使用 <code>code</code>（如 <code>&quot;password&quot;</code>），简洁高效；日志记录时使用 <code>description</code>（如 <code>&quot;账号密码登录&quot;</code>），易于理解。</p><p><code>fromCode</code> 方法用于将前端传来的字符串转换为枚举。如果前端传来的 <code>code</code> 不存在，会抛出 <code>IllegalArgumentException</code>，提示 “不支持的认证方式”。</p><hr><h3 id="19-2-4-3-认证请求接口（AuthRequest）">19.2.4.3. 认证请求接口（AuthRequest）</h3><p>现在我们定义统一的认证请求接口。这个接口是所有登录请求的 “父类”，它定义了所有登录方式必须遵循的规范。</p><p><strong>📄 文件</strong>：<code>src/main/java/com/example/auth/model/request/AuthRequest.java</code>（新建）</p><figure class="highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><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><span class="line"><span class="keyword">package</span> com.example.auth.model.request;</span><br><span class="line"></span><br><span class="line"><span class="keyword">import</span> com.example.auth.enums.AuthType;</span><br><span class="line"><span class="keyword">import</span> com.fasterxml.jackson.annotation.JsonSubTypes;</span><br><span class="line"><span class="keyword">import</span> com.fasterxml.jackson.annotation.JsonTypeInfo;</span><br><span class="line"></span><br><span class="line"><span class="comment">/**</span></span><br><span class="line"><span class="comment"> * 认证请求接口</span></span><br><span class="line"><span class="comment"> * 所有登录方式的请求参数都必须实现这个接口</span></span><br><span class="line"><span class="comment"> */</span></span><br><span class="line"><span class="meta">@JsonTypeInfo(</span></span><br><span class="line"><span class="meta">    use = JsonTypeInfo.Id.NAME,</span></span><br><span class="line"><span class="meta">    include = JsonTypeInfo.As.EXISTING_PROPERTY,</span></span><br><span class="line"><span class="meta">    property = &quot;authType&quot;,</span></span><br><span class="line"><span class="meta">    visible = true</span></span><br><span class="line"><span class="meta">)</span></span><br><span class="line"><span class="meta">@JsonSubTypes(&#123;</span></span><br><span class="line"><span class="meta">    @JsonSubTypes.Type(value = PasswordAuthRequest.class, name = &quot;PASSWORD&quot;),</span></span><br><span class="line"><span class="meta">    @JsonSubTypes.Type(value = SmsAuthRequest.class, name = &quot;SMS&quot;),</span></span><br><span class="line"><span class="meta">    @JsonSubTypes.Type(value = WechatAuthRequest.class, name = &quot;WECHAT&quot;)</span></span><br><span class="line"><span class="meta">&#125;)</span></span><br><span class="line"><span class="keyword">public</span> <span class="keyword">interface</span> <span class="title class_">AuthRequest</span> &#123;</span><br><span class="line">    <span class="comment">/**</span></span><br><span class="line"><span class="comment">     * 获取认证类型</span></span><br><span class="line"><span class="comment">     * 用于工厂类根据类型选择对应的策略</span></span><br><span class="line"><span class="comment">     */</span></span><br><span class="line">    AuthType <span class="title function_">getAuthType</span><span class="params">()</span>;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p><strong>设计要点</strong></p><p>这个接口只定义了一个方法：<code>getAuthType()</code>。因为不同登录方式的参数不同（账号密码登录需要 <code>username</code> 和 <code>password</code>，手机验证码登录需要 <code>phone</code> 和 <code>code</code>），我们无法在接口中定义统一的参数字段。</p><p>接口上的两个注解（<code>@JsonTypeInfo</code> 和 <code>@JsonSubTypes</code>）用于配置 Jackson 的多态反序列化。这样 Spring Boot 就能根据前端传来的 <code>authType</code> 字段，自动将 JSON 转换为对应的请求类。</p><p><strong>Jackson 多态反序列化的工作流程</strong></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></pre></td><td class="code"><pre><span class="line">1. 前端发送 JSON: &#123;&quot;authType&quot;: &quot;PASSWORD&quot;, &quot;username&quot;: &quot;admin&quot;, &quot;password&quot;: &quot;123456&quot;&#125;</span><br><span class="line">2. Spring Boot 接收到请求，调用 Jackson 进行反序列化</span><br><span class="line">3. Jackson 读取 authType 字段，发现值是 &quot;PASSWORD&quot;</span><br><span class="line">4. Jackson 查找 @JsonSubTypes 注解，找到 name=&quot;PASSWORD&quot; 对应的类是 PasswordAuthRequest</span><br><span class="line">5. Jackson 将 JSON 转换为 PasswordAuthRequest 对象</span><br><span class="line">6. Controller 接收到 PasswordAuthRequest 对象</span><br></pre></td></tr></table></figure><hr><h3 id="19-2-4-4-具体请求类示例">19.2.4.4. 具体请求类示例</h3><p>现在我们定义一个具体的认证请求类，用于演示接口的实现方式。</p><p><strong>📄 文件</strong>：<code>src/main/java/com/example/auth/model/request/PasswordAuthRequest.java</code>（新建）</p><figure class="highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><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><span class="line"><span class="keyword">package</span> com.example.auth.model.request;</span><br><span class="line"></span><br><span class="line"><span class="keyword">import</span> com.example.auth.enums.AuthType;</span><br><span class="line"><span class="keyword">import</span> com.fasterxml.jackson.annotation.JsonTypeName;</span><br><span class="line"><span class="keyword">import</span> jakarta.validation.constraints.NotBlank;</span><br><span class="line"><span class="keyword">import</span> lombok.Data;</span><br><span class="line"></span><br><span class="line"><span class="comment">/**</span></span><br><span class="line"><span class="comment"> * 账号密码登录请求</span></span><br><span class="line"><span class="comment"> */</span></span><br><span class="line"><span class="meta">@Data</span></span><br><span class="line"><span class="meta">@JsonTypeName(&quot;PASSWORD&quot;)</span></span><br><span class="line"><span class="keyword">public</span> <span class="keyword">class</span> <span class="title class_">PasswordAuthRequest</span> <span class="keyword">implements</span> <span class="title class_">AuthRequest</span> &#123;</span><br><span class="line">    </span><br><span class="line">    <span class="keyword">private</span> <span class="type">AuthType</span> <span class="variable">authType</span> <span class="operator">=</span> AuthType.PASSWORD;</span><br><span class="line">    </span><br><span class="line">    <span class="meta">@NotBlank(message = &quot;用户名不能为空&quot;)</span></span><br><span class="line">    <span class="keyword">private</span> String username;</span><br><span class="line">    </span><br><span class="line">    <span class="meta">@NotBlank(message = &quot;密码不能为空&quot;)</span></span><br><span class="line">    <span class="keyword">private</span> String password;</span><br><span class="line">    </span><br><span class="line">    <span class="meta">@Override</span></span><br><span class="line">    <span class="keyword">public</span> AuthType <span class="title function_">getAuthType</span><span class="params">()</span> &#123;</span><br><span class="line">        <span class="keyword">return</span> authType;</span><br><span class="line">    &#125;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p><strong>设计要点</strong></p><p>这个请求类实现了 <code>AuthRequest</code> 接口，并定义了账号密码登录所需的两个字段：<code>username</code> 和 <code>password</code>。</p><p><code>@JsonTypeName(&quot;PASSWORD&quot;)</code> 注解指定了类型名称，必须与 <code>@JsonSubTypes</code> 中的 <code>name</code> 属性一致，这样 Jackson 才能正确地将 <code>&quot;PASSWORD&quot;</code> 映射到 <code>PasswordAuthRequest</code> 类。</p><p><code>authType</code> 字段设置了默认值 <code>AuthType.PASSWORD</code>，这样即使前端忘记传 <code>authType</code> 字段，也能正确识别类型。</p><p><strong>其他请求类</strong></p><p>按照同样的方式，我们还需要定义 <code>SmsAuthRequest</code> 和 <code>WechatAuthRequest</code>：</p><p><strong>📄 文件</strong>：<code>src/main/java/com/example/auth/model/request/SmsAuthRequest.java</code>（新建）</p><figure class="highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><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><span class="line"><span class="keyword">package</span> com.example.auth.model.request;</span><br><span class="line"></span><br><span class="line"><span class="keyword">import</span> com.example.auth.enums.AuthType;</span><br><span class="line"><span class="keyword">import</span> com.fasterxml.jackson.annotation.JsonTypeName;</span><br><span class="line"><span class="keyword">import</span> jakarta.validation.constraints.NotBlank;</span><br><span class="line"><span class="keyword">import</span> jakarta.validation.constraints.Pattern;</span><br><span class="line"><span class="keyword">import</span> lombok.Data;</span><br><span class="line"></span><br><span class="line"><span class="meta">@Data</span></span><br><span class="line"><span class="meta">@JsonTypeName(&quot;SMS&quot;)</span></span><br><span class="line"><span class="keyword">public</span> <span class="keyword">class</span> <span class="title class_">SmsAuthRequest</span> <span class="keyword">implements</span> <span class="title class_">AuthRequest</span> &#123;</span><br><span class="line">    </span><br><span class="line">    <span class="keyword">private</span> <span class="type">AuthType</span> <span class="variable">authType</span> <span class="operator">=</span> AuthType.SMS;</span><br><span class="line">    </span><br><span class="line">    <span class="meta">@NotBlank(message = &quot;手机号不能为空&quot;)</span></span><br><span class="line">    <span class="meta">@Pattern(regexp = &quot;^1[3-9]\\d&#123;9&#125;$&quot;, message = &quot;手机号格式不正确&quot;)</span></span><br><span class="line">    <span class="keyword">private</span> String phone;</span><br><span class="line">    </span><br><span class="line">    <span class="meta">@NotBlank(message = &quot;验证码不能为空&quot;)</span></span><br><span class="line">    <span class="meta">@Pattern(regexp = &quot;^\\d&#123;6&#125;$&quot;, message = &quot;验证码格式不正确&quot;)</span></span><br><span class="line">    <span class="keyword">private</span> String code;</span><br><span class="line">    </span><br><span class="line">    <span class="meta">@Override</span></span><br><span class="line">    <span class="keyword">public</span> AuthType <span class="title function_">getAuthType</span><span class="params">()</span> &#123;</span><br><span class="line">        <span class="keyword">return</span> authType;</span><br><span class="line">    &#125;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p><strong>📄 文件</strong>：<code>src/main/java/com/example/auth/model/request/WechatAuthRequest.java</code>（新建）</p><figure class="highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">package</span> com.example.auth.model.request;</span><br><span class="line"></span><br><span class="line"><span class="keyword">import</span> com.example.auth.enums.AuthType;</span><br><span class="line"><span class="keyword">import</span> com.fasterxml.jackson.annotation.JsonTypeName;</span><br><span class="line"><span class="keyword">import</span> jakarta.validation.constraints.NotBlank;</span><br><span class="line"><span class="keyword">import</span> lombok.Data;</span><br><span class="line"></span><br><span class="line"><span class="meta">@Data</span></span><br><span class="line"><span class="meta">@JsonTypeName(&quot;WECHAT&quot;)</span></span><br><span class="line"><span class="keyword">public</span> <span class="keyword">class</span> <span class="title class_">WechatAuthRequest</span> <span class="keyword">implements</span> <span class="title class_">AuthRequest</span> &#123;</span><br><span class="line">    </span><br><span class="line">    <span class="keyword">private</span> <span class="type">AuthType</span> <span class="variable">authType</span> <span class="operator">=</span> AuthType.WECHAT;</span><br><span class="line">    </span><br><span class="line">    <span class="meta">@NotBlank(message = &quot;授权码不能为空&quot;)</span></span><br><span class="line">    <span class="keyword">private</span> String code;</span><br><span class="line">    </span><br><span class="line">    <span class="meta">@Override</span></span><br><span class="line">    <span class="keyword">public</span> AuthType <span class="title function_">getAuthType</span><span class="params">()</span> &#123;</span><br><span class="line">        <span class="keyword">return</span> authType;</span><br><span class="line">    &#125;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><hr><h3 id="19-2-4-5-本节小结">19.2.4.5. 本节小结</h3><p>本节完成了认证工厂的输入规范设计，定义了 <code>AuthType</code> 枚举、<code>AuthRequest</code> 接口以及三个具体的请求类。这些接口为后续的工厂实现奠定了基础，工厂类可以通过 <code>getAuthType()</code> 方法获取类型，自动选择对应的策略。</p><table><thead><tr><th style="text-align:left">要点</th><th style="text-align:left">何时使用</th><th style="text-align:left">关键动作</th></tr></thead><tbody><tr><td style="text-align:left">AuthType 枚举</td><td style="text-align:left">标识登录方式</td><td style="text-align:left">定义 code 和 description 字段</td></tr><tr><td style="text-align:left">AuthRequest 接口</td><td style="text-align:left">统一请求规范</td><td style="text-align:left">定义 getAuthType() 方法</td></tr><tr><td style="text-align:left">具体请求类</td><td style="text-align:left">实现不同登录方式</td><td style="text-align:left">实现 AuthRequest 接口，添加 @JsonTypeName 注解</td></tr></tbody></table><p>在下一节中，我们将定义认证策略接口（<code>AuthStrategy</code>），让每种登录方式都遵循统一的策略规范。</p><hr><h2 id="19-2-5-策略接口：定义统一的认证策略">19.2.5. 策略接口：定义统一的认证策略</h2><p>在上一节中，我们定义了统一的认证请求接口（<code>AuthRequest</code>），解决了 “如何统一接收不同登录方式的参数” 这个问题。现在我们需要解决另一个核心问题：<strong>如何统一处理不同登录方式的验证逻辑？</strong></p><p>这就是策略模式的核心所在。我们将定义一个 <code>AuthStrategy</code> 接口，让每种登录方式都实现这个接口，从而实现 “统一规范，各自实现”。</p><hr><h3 id="19-2-5-1-策略模式的核心思想">19.2.5.1. 策略模式的核心思想</h3><p>在传统写法中，所有登录方式的验证逻辑都混在 Controller 的 if-else 分支中：</p><figure class="highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">if</span> (<span class="string">&quot;password&quot;</span>.equals(type)) &#123;</span><br><span class="line">    <span class="comment">// 查询用户、验证密码、生成 Token...</span></span><br><span class="line">&#125; <span class="keyword">else</span> <span class="keyword">if</span> (<span class="string">&quot;sms&quot;</span>.equals(type)) &#123;</span><br><span class="line">    <span class="comment">// 验证验证码、查询或创建用户、生成 Token...</span></span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>这种写法的问题在于：<strong>验证逻辑与分发逻辑耦合在一起</strong>。Controller 既要负责 “选择哪种登录方式”，又要负责 “执行具体的验证逻辑”，违反了单一职责原则。</p><p><strong>策略模式的解决方案</strong></p><p>策略模式将 “算法的选择” 与 “算法的实现” 分离：</p><ul><li><strong>工厂类负责选择</strong>：根据 <code>authType</code> 选择对应的策略</li><li><strong>策略类负责实现</strong>：每种登录方式都是一个独立的策略类，实现具体的验证逻辑</li></ul><p>这样做的核心价值是：</p><ul><li><strong>职责清晰</strong>：Controller 只负责调度，策略类只负责验证</li><li><strong>易于扩展</strong>：新增登录方式只需实现 <code>AuthStrategy</code> 接口，不需要修改任何现有代码</li><li><strong>易于测试</strong>：可以单独测试每个策略类，不需要启动整个应用</li></ul><p><strong>策略模式的三个角色</strong></p><p>在我们的认证工厂中，策略模式包含三个角色：</p><table><thead><tr><th style="text-align:left">角色</th><th style="text-align:left">职责</th><th style="text-align:left">在我们系统中的对应类</th></tr></thead><tbody><tr><td style="text-align:left"><strong>Strategy（策略接口）</strong></td><td style="text-align:left">定义所有策略的统一接口</td><td style="text-align:left"><code>AuthStrategy</code></td></tr><tr><td style="text-align:left"><strong>ConcreteStrategy（具体策略）</strong></td><td style="text-align:left">实现具体的算法</td><td style="text-align:left"><code>PasswordAuthStrategy</code>、<code>SmsAuthStrategy</code> 等</td></tr><tr><td style="text-align:left"><strong>Context（上下文）</strong></td><td style="text-align:left">持有策略引用，委托策略执行</td><td style="text-align:left"><code>AuthStrategyFactory</code></td></tr></tbody></table><hr><h3 id="19-2-5-2-策略接口的设计">19.2.5.2. 策略接口的设计</h3><p>现在我们定义 <code>AuthStrategy</code> 接口。这个接口是所有登录策略的 “父类”，它定义了所有策略必须遵循的规范。</p><p><strong>📄 文件</strong>：<code>src/main/java/com/example/auth/strategy/AuthStrategy.java</code>（新建）</p><p>在 <code>src/main/java/com/example/auth</code> 目录下，右键选择 <code>New</code> → <code>Package</code>，输入包名 <code>strategy</code>，然后在 <code>strategy</code> 包下新建 <code>AuthStrategy.java</code> 文件：</p><figure class="highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br><span class="line">32</span><br><span class="line">33</span><br><span class="line">34</span><br><span class="line">35</span><br><span class="line">36</span><br><span class="line">37</span><br><span class="line">38</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">package</span> com.example.auth.strategy;</span><br><span class="line"></span><br><span class="line"><span class="keyword">import</span> com.example.auth.enums.AuthType;</span><br><span class="line"><span class="keyword">import</span> com.example.auth.model.request.AuthRequest;</span><br><span class="line"></span><br><span class="line"><span class="comment">/**</span></span><br><span class="line"><span class="comment"> * 认证策略接口</span></span><br><span class="line"><span class="comment"> * 所有登录方式都必须实现这个接口</span></span><br><span class="line"><span class="comment"> * </span></span><br><span class="line"><span class="comment"> * 设计理念：</span></span><br><span class="line"><span class="comment"> * 1. 统一规范：所有登录方式都遵循同一套接口</span></span><br><span class="line"><span class="comment"> * 2. 策略模式：将每种登录方式封装为一个独立的策略类</span></span><br><span class="line"><span class="comment"> * 3. 职责单一：策略类只负责验证身份，返回用户 ID</span></span><br><span class="line"><span class="comment"> */</span></span><br><span class="line"><span class="keyword">public</span> <span class="keyword">interface</span> <span class="title class_">AuthStrategy</span> &#123;</span><br><span class="line">    <span class="comment">/**</span></span><br><span class="line"><span class="comment">     * 执行认证</span></span><br><span class="line"><span class="comment">     * 这是策略模式的核心方法，每个策略类都必须实现</span></span><br><span class="line"><span class="comment">     * </span></span><br><span class="line"><span class="comment">     * 职责边界：</span></span><br><span class="line"><span class="comment">     * - 策略类只负责验证身份（查询用户、验证密码/验证码等）</span></span><br><span class="line"><span class="comment">     * - 策略类不负责生成 Token（由 Sa-Token 负责）</span></span><br><span class="line"><span class="comment">     * - 策略类不负责管理会话（由 Sa-Token 负责）</span></span><br><span class="line"><span class="comment">     * </span></span><br><span class="line"><span class="comment">     * <span class="doctag">@param</span> request 认证请求（多态参数）</span></span><br><span class="line"><span class="comment">     * <span class="doctag">@return</span> 用户 ID（用于 Sa-Token 登录）</span></span><br><span class="line"><span class="comment">     * <span class="doctag">@throws</span> RuntimeException 如果认证失败</span></span><br><span class="line"><span class="comment">     */</span></span><br><span class="line">    Long <span class="title function_">authenticate</span><span class="params">(AuthRequest request)</span>;</span><br><span class="line">    </span><br><span class="line">    <span class="comment">/**</span></span><br><span class="line"><span class="comment">     * 获取支持的认证类型</span></span><br><span class="line"><span class="comment">     * 用于工厂类根据类型选择对应的策略</span></span><br><span class="line"><span class="comment">     * </span></span><br><span class="line"><span class="comment">     * <span class="doctag">@return</span> 认证类型枚举</span></span><br><span class="line"><span class="comment">     */</span></span><br><span class="line">    AuthType <span class="title function_">getSupportedType</span><span class="params">()</span>;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p><strong>设计要点解析</strong></p><p><strong>设计点一：为什么返回 <code>Long</code> 而不是 <code>AuthToken</code>？</strong></p><p>这是本接口最关键的设计决策。让我们对比两种设计方案：</p><p><strong>方案 A：策略类返回 <code>AuthToken</code>（不推荐）</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></pre></td><td class="code"><pre><span class="line"><span class="keyword">public</span> <span class="keyword">interface</span> <span class="title class_">AuthStrategy</span> &#123;</span><br><span class="line">    AuthToken <span class="title function_">authenticate</span><span class="params">(AuthRequest request)</span>;</span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="comment">// 实现类</span></span><br><span class="line"><span class="keyword">public</span> <span class="keyword">class</span> <span class="title class_">PasswordAuthStrategy</span> <span class="keyword">implements</span> <span class="title class_">AuthStrategy</span> &#123;</span><br><span class="line">    <span class="meta">@Override</span></span><br><span class="line">    <span class="keyword">public</span> AuthToken <span class="title function_">authenticate</span><span class="params">(AuthRequest request)</span> &#123;</span><br><span class="line">        <span class="comment">// 1. 验证身份</span></span><br><span class="line">        <span class="type">User</span> <span class="variable">user</span> <span class="operator">=</span> userService.getByUsername(username);</span><br><span class="line">        <span class="comment">// 2. 生成 Token</span></span><br><span class="line">        StpUtil.login(user.getId());</span><br><span class="line">        <span class="comment">// 3. 返回 Token</span></span><br><span class="line">        <span class="keyword">return</span> AuthToken.builder()</span><br><span class="line">            .tokenValue(StpUtil.getTokenValue())</span><br><span class="line">            .build();</span><br><span class="line">    &#125;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>这种设计的问题：</p><ul><li>❌ 策略类职责过重：既要验证身份，又要生成 Token</li><li>❌ 代码重复：每个策略类都要写一遍 <code>StpUtil.login()</code> 和构建 <code>AuthToken</code> 的代码</li><li>❌ 难以测试：测试策略类时，必须模拟 Sa-Token 的行为</li></ul><p><strong>方案 B：策略类返回 <code>Long</code>（推荐）</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></pre></td><td class="code"><pre><span class="line"><span class="keyword">public</span> <span class="keyword">interface</span> <span class="title class_">AuthStrategy</span> &#123;</span><br><span class="line">    Long <span class="title function_">authenticate</span><span class="params">(AuthRequest request)</span>;</span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="comment">// 实现类</span></span><br><span class="line"><span class="keyword">public</span> <span class="keyword">class</span> <span class="title class_">PasswordAuthStrategy</span> <span class="keyword">implements</span> <span class="title class_">AuthStrategy</span> &#123;</span><br><span class="line">    <span class="meta">@Override</span></span><br><span class="line">    <span class="keyword">public</span> Long <span class="title function_">authenticate</span><span class="params">(AuthRequest request)</span> &#123;</span><br><span class="line">        <span class="comment">// 只负责验证身份</span></span><br><span class="line">        <span class="type">User</span> <span class="variable">user</span> <span class="operator">=</span> userService.getByUsername(username);</span><br><span class="line">        <span class="comment">// 返回用户 ID</span></span><br><span class="line">        <span class="keyword">return</span> user.getId();</span><br><span class="line">    &#125;</span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="comment">// 工厂类</span></span><br><span class="line"><span class="keyword">public</span> <span class="keyword">class</span> <span class="title class_">AuthStrategyFactory</span> &#123;</span><br><span class="line">    <span class="keyword">public</span> AuthTokenVO <span class="title function_">authenticate</span><span class="params">(AuthRequest request)</span> &#123;</span><br><span class="line">        <span class="comment">// 1. 选择策略</span></span><br><span class="line">        <span class="type">AuthStrategy</span> <span class="variable">strategy</span> <span class="operator">=</span> getStrategy(request.getAuthType());</span><br><span class="line">        <span class="comment">// 2. 执行认证，获取用户 ID</span></span><br><span class="line">        <span class="type">Long</span> <span class="variable">userId</span> <span class="operator">=</span> strategy.authenticate(request);</span><br><span class="line">        <span class="comment">// 3. Sa-Token 登录</span></span><br><span class="line">        StpUtil.login(userId);</span><br><span class="line">        <span class="comment">// 4. 返回 Token</span></span><br><span class="line">        <span class="keyword">return</span> buildAuthToken(userId);</span><br><span class="line">    &#125;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>这种设计的优势：</p><ul><li>✅ 策略类职责单一：只负责验证身份</li><li>✅ 代码复用：Token 生成逻辑统一在工厂类中</li><li>✅ 易于测试：测试策略类时，只需要验证返回的用户 ID 是否正确</li></ul><p><strong>设计点二：为什么需要 <code>getSupportedType()</code> 方法？</strong></p><p>这个方法用于标识策略类支持的认证类型。工厂类在启动时会调用这个方法，将策略注册到 <code>Map&lt;AuthType, AuthStrategy&gt;</code> 中：</p><figure class="highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">// 工厂类的构造函数</span></span><br><span class="line"><span class="keyword">public</span> <span class="title function_">AuthStrategyFactory</span><span class="params">(List&lt;AuthStrategy&gt; strategies)</span> &#123;</span><br><span class="line">    <span class="keyword">for</span> (AuthStrategy strategy : strategies) &#123;</span><br><span class="line">        <span class="type">AuthType</span> <span class="variable">type</span> <span class="operator">=</span> strategy.getSupportedType();  <span class="comment">// 获取策略类型</span></span><br><span class="line">        strategyMap.put(type, strategy);              <span class="comment">// 注册到 Map</span></span><br><span class="line">    &#125;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>这样设计的好处是：</p><ul><li>✅ 自动注册：策略类只需要实现接口，工厂类会自动发现并注册</li><li>✅ 类型安全：使用枚举而非字符串，避免拼写错误</li></ul><p><strong>设计点三：authenticate 方法的参数是 <code>AuthRequest</code> 接口</strong></p><p>这是多态的体现。虽然参数类型是 <code>AuthRequest</code> 接口，但实际传入的是具体的实现类（如 <code>PasswordAuthRequest</code>）。</p><p>策略类内部需要将 <code>AuthRequest</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></pre></td><td class="code"><pre><span class="line"><span class="meta">@Override</span></span><br><span class="line"><span class="keyword">public</span> Long <span class="title function_">authenticate</span><span class="params">(AuthRequest request)</span> &#123;</span><br><span class="line">    <span class="comment">// 强制转换为具体类型</span></span><br><span class="line">    <span class="type">PasswordAuthRequest</span> <span class="variable">passwordRequest</span> <span class="operator">=</span> (PasswordAuthRequest) request;</span><br><span class="line">    <span class="comment">// 使用具体类型的字段</span></span><br><span class="line">    <span class="type">String</span> <span class="variable">username</span> <span class="operator">=</span> passwordRequest.getUsername();</span><br><span class="line">    <span class="type">String</span> <span class="variable">password</span> <span class="operator">=</span> passwordRequest.getPassword();</span><br><span class="line">    <span class="comment">// ...</span></span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>这个转换是安全的，因为工厂类会根据 <code>authType</code> 选择对应的策略。如果 <code>authType</code> 是 <code>PASSWORD</code>，工厂类一定会选择 <code>PasswordAuthStrategy</code>，而前端传来的请求一定是 <code>PasswordAuthRequest</code>。</p><hr><h3 id="19-2-5-3-策略接口的职责边界">19.2.5.3. 策略接口的职责边界</h3><p>在实现具体的策略类之前，我们需要明确策略接口的职责边界。这是避免代码混乱的关键。</p><p><strong>AuthStrategy 应该做什么？</strong></p><p>策略类只负责 “验证身份”，具体包括：</p><ul><li>✅ 验证用户名和密码</li><li>✅ 验证手机验证码</li><li>✅ 调用第三方 API 获取用户信息</li><li>✅ 查询或创建用户</li><li>✅ 返回用户 ID</li></ul><p><strong>AuthStrategy 不应该做什么？</strong></p><p>策略类不负责 “会话管理”，具体包括：</p><ul><li>❌ 不应该生成 Token（由 Sa-Token 负责）</li><li>❌ 不应该管理会话（由 Sa-Token 负责）</li><li>❌ 不应该处理 HTTP 请求和响应（由 Controller 负责）</li><li>❌ 不应该直接操作 Redis（应该通过 Sa-Token）</li></ul><hr><h3 id="19-2-5-4-策略模式与-Sa-Token-的协同关系">19.2.5.4. 策略模式与 Sa-Token 的协同关系</h3><p>现在让我们理解策略模式与 Sa-Token 是如何协同工作的。</p><p><strong>完整的认证流程</strong></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></pre></td><td class="code"><pre><span class="line">1. 前端发送登录请求 → Controller 接收</span><br><span class="line">2. Controller 委托给工厂类 → 工厂类根据 authType 选择策略</span><br><span class="line">3. 策略类验证身份 → 返回用户 ID</span><br><span class="line">4. 工厂类调用 Sa-Token 登录 → Sa-Token 生成 Token 并存入 Redis</span><br><span class="line">5. 工厂类构建响应对象 → 返回给前端</span><br></pre></td></tr></table></figure><p><strong>代码示例</strong></p><p>让我们用一个具体的例子来说明：</p><figure class="highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br><span class="line">32</span><br><span class="line">33</span><br><span class="line">34</span><br><span class="line">35</span><br><span class="line">36</span><br><span class="line">37</span><br><span class="line">38</span><br><span class="line">39</span><br><span class="line">40</span><br><span class="line">41</span><br><span class="line">42</span><br><span class="line">43</span><br><span class="line">44</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">// 步骤 1：策略类验证身份</span></span><br><span class="line"><span class="meta">@Component</span></span><br><span class="line"><span class="keyword">public</span> <span class="keyword">class</span> <span class="title class_">PasswordAuthStrategy</span> <span class="keyword">implements</span> <span class="title class_">AuthStrategy</span> &#123;</span><br><span class="line">    <span class="meta">@Override</span></span><br><span class="line">    <span class="keyword">public</span> Long <span class="title function_">authenticate</span><span class="params">(AuthRequest request)</span> &#123;</span><br><span class="line">        <span class="comment">// 1. 查询用户</span></span><br><span class="line">        <span class="type">User</span> <span class="variable">user</span> <span class="operator">=</span> userService.getByUsername(username);</span><br><span class="line">        </span><br><span class="line">        <span class="comment">// 2. 验证密码</span></span><br><span class="line">        <span class="keyword">if</span> (!BCrypt.checkpw(password, user.getPassword())) &#123;</span><br><span class="line">            <span class="keyword">throw</span> <span class="keyword">new</span> <span class="title class_">RuntimeException</span>(<span class="string">&quot;密码错误&quot;</span>);</span><br><span class="line">        &#125;</span><br><span class="line">        </span><br><span class="line">        <span class="comment">// 3. 返回用户 ID（策略类的职责到此结束）</span></span><br><span class="line">        <span class="keyword">return</span> user.getId();</span><br><span class="line">    &#125;</span><br><span class="line">    </span><br><span class="line">    <span class="meta">@Override</span></span><br><span class="line">    <span class="keyword">public</span> AuthType <span class="title function_">getSupportedType</span><span class="params">()</span> &#123;</span><br><span class="line">        <span class="keyword">return</span> AuthType.PASSWORD;</span><br><span class="line">    &#125;</span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="comment">// 步骤 2：工厂类调用 Sa-Token 登录</span></span><br><span class="line"><span class="meta">@Component</span></span><br><span class="line"><span class="keyword">public</span> <span class="keyword">class</span> <span class="title class_">AuthStrategyFactory</span> &#123;</span><br><span class="line">    <span class="keyword">public</span> AuthTokenVO <span class="title function_">authenticate</span><span class="params">(AuthRequest request)</span> &#123;</span><br><span class="line">        <span class="comment">// 1. 选择策略</span></span><br><span class="line">        <span class="type">AuthStrategy</span> <span class="variable">strategy</span> <span class="operator">=</span> strategyMap.get(request.getAuthType());</span><br><span class="line">        </span><br><span class="line">        <span class="comment">// 2. 执行认证，获取用户 ID</span></span><br><span class="line">        <span class="type">Long</span> <span class="variable">userId</span> <span class="operator">=</span> strategy.authenticate(request);</span><br><span class="line">        </span><br><span class="line">        <span class="comment">// 3. Sa-Token 登录（Sa-Token 的职责从这里开始）</span></span><br><span class="line">        StpUtil.login(userId);</span><br><span class="line">        </span><br><span class="line">        <span class="comment">// 4. 返回 Token</span></span><br><span class="line">        <span class="keyword">return</span> AuthTokenVO.builder()</span><br><span class="line">            .tokenName(StpUtil.getTokenName())</span><br><span class="line">            .tokenValue(StpUtil.getTokenValue())</span><br><span class="line">            .loginId(userId)</span><br><span class="line">            .build();</span><br><span class="line">    &#125;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p><strong>关键设计点</strong></p><ul><li>策略类只返回 <code>userId</code>，不返回 <code>Token</code></li><li>工厂类负责调用 <code>StpUtil.login(userId)</code></li><li>Sa-Token 自动生成 Token 并存入 Redis</li><li>工厂类通过 <code>StpUtil.getTokenValue()</code> 获取 Token 值</li></ul><hr><h3 id="19-2-5-5-本节小结">19.2.5.5. 本节小结</h3><p>本节完成了认证策略接口的设计，定义了 <code>AuthStrategy</code> 接口，明确了策略类的职责边界。策略类只负责验证身份并返回用户 ID，Token 的生成和会话管理由 Sa-Token 负责，工厂类作为桥梁连接两者。</p><table><thead><tr><th style="text-align:left">要点</th><th style="text-align:left">何时使用</th><th style="text-align:left">关键动作</th></tr></thead><tbody><tr><td style="text-align:left">AuthStrategy 接口</td><td style="text-align:left">定义策略规范</td><td style="text-align:left">定义 authenticate() 和 getSupportedType() 方法</td></tr><tr><td style="text-align:left">返回 Long 而非 AuthToken</td><td style="text-align:left">保持职责单一</td><td style="text-align:left">策略类只返回用户 ID</td></tr><tr><td style="text-align:left">职责边界</td><td style="text-align:left">避免代码混乱</td><td style="text-align:left">策略类只负责验证身份，不负责生成 Token</td></tr></tbody></table><p>在下一节中，我们将实现认证策略工厂（<code>AuthStrategyFactory</code>），让它能够自动发现和注册所有策略，并根据 <code>authType</code> 自动选择对应的策略。</p><hr><h2 id="19-2-6-工厂实现：自动发现与策略分发">19.2.6. 工厂实现：自动发现与策略分发</h2><p>在上一节中，我们定义了 <code>AuthStrategy</code> 接口，明确了策略类的职责边界：策略类只负责验证身份并返回用户 ID。现在，我们需要实现认证策略工厂（<code>AuthStrategyFactory</code>），让它能够自动发现和注册所有策略，并根据 <code>authType</code> 自动选择对应的策略。</p><p>这一节是整个认证工厂的 <strong>“中枢神经”</strong>，它连接了策略类和 Sa-Token，是策略模式能够运转的核心。</p><hr><h3 id="19-2-6-1-工厂模式的核心职责">19.2.6.1. 工厂模式的核心职责</h3><p>在策略模式中，工厂类扮演着 “上下文（Context）” 的角色。它的核心职责有三个：</p><p><strong>职责一：策略管理</strong></p><p>工厂类需要维护一个 <code>Map&lt;AuthType, AuthStrategy&gt;</code>，用于存储所有策略的映射关系。</p><p><strong>职责二：自动发现</strong></p><p>工厂类需要在应用启动时，自动扫描所有实现了 <code>AuthStrategy</code> 接口的 Bean，并注册到 Map 中。Spring 会将所有标注了 <code>@Component</code> 的策略类打包成 <code>List&lt;AuthStrategy&gt;</code>，注入到工厂类的构造函数中。</p><p><strong>职责三：策略分发</strong></p><p>工厂类需要根据认证类型（<code>AuthType</code>），从 Map 中获取对应的策略，并委托策略执行认证。认证成功后，工厂类调用 Sa-Token 的 <code>StpUtil.login(userId)</code> 完成登录，并返回 Token 信息。</p><p><strong>工厂类的工作流程</strong></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></pre></td><td class="code"><pre><span class="line">1. 应用启动 → Spring 扫描所有 @Component 标注的 AuthStrategy 实现类</span><br><span class="line">2. Spring 将这些 Bean 注入到工厂类的构造函数</span><br><span class="line">3. 工厂类遍历所有策略，调用 getSupportedType() 获取认证类型</span><br><span class="line">4. 工厂类将 AuthType 和 AuthStrategy 的映射关系存入 Map</span><br><span class="line">5. 前端发送登录请求 → Controller 调用工厂类的 authenticate 方法</span><br><span class="line">6. 工厂类根据 authType 从 Map 中获取对应的策略</span><br><span class="line">7. 工厂类委托策略执行认证，获取用户 ID</span><br><span class="line">8. 工厂类调用 StpUtil.login(userId) 完成登录</span><br><span class="line">9. 工厂类构建响应对象，返回 Token 信息</span><br></pre></td></tr></table></figure><hr><h3 id="19-2-6-2-定义统一响应对象（AuthTokenVO）">19.2.6.2. 定义统一响应对象（AuthTokenVO）</h3><p>在实现工厂类之前，我们需要先定义统一的响应对象。因为工厂类的 <code>authenticate</code> 方法会返回这个对象，所以必须先定义它。</p><p><strong>📄 文件</strong>：<code>src/main/java/com/example/auth/model/vo/AuthTokenVO.java</code>（新建）</p><p>在 <code>src/main/java/com/example/auth/model</code> 目录下，右键选择 <code>New</code> → <code>Package</code>，输入包名 <code>vo</code>，然后在 <code>vo</code> 包下新建 <code>AuthTokenVO.java</code> 文件：</p><figure class="highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><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><span class="line"><span class="keyword">package</span> com.example.auth.model.vo;</span><br><span class="line"></span><br><span class="line"><span class="keyword">import</span> lombok.Builder;</span><br><span class="line"><span class="keyword">import</span> lombok.Data;</span><br><span class="line"></span><br><span class="line"><span class="comment">/**</span></span><br><span class="line"><span class="comment"> * 认证响应对象</span></span><br><span class="line"><span class="comment"> * 包含 Sa-Token 生成的 Token 信息</span></span><br><span class="line"><span class="comment"> */</span></span><br><span class="line"><span class="meta">@Data</span></span><br><span class="line"><span class="meta">@Builder</span></span><br><span class="line"><span class="keyword">public</span> <span class="keyword">class</span> <span class="title class_">AuthTokenVO</span> &#123;</span><br><span class="line">    </span><br><span class="line">    <span class="comment">/**</span></span><br><span class="line"><span class="comment">     * Token 名称</span></span><br><span class="line"><span class="comment">     * 对应 Sa-Token 配置中的 token-name（默认为 &quot;Authorization&quot;）</span></span><br><span class="line"><span class="comment">     */</span></span><br><span class="line">    <span class="keyword">private</span> String tokenName;</span><br><span class="line">    </span><br><span class="line">    <span class="comment">/**</span></span><br><span class="line"><span class="comment">     * Token 值</span></span><br><span class="line"><span class="comment">     * 前端需要在后续请求的 Header 中携带这个值</span></span><br><span class="line"><span class="comment">     */</span></span><br><span class="line">    <span class="keyword">private</span> String tokenValue;</span><br><span class="line">    </span><br><span class="line">    <span class="comment">/**</span></span><br><span class="line"><span class="comment">     * 用户 ID</span></span><br><span class="line"><span class="comment">     * 用于前端展示或其他业务逻辑</span></span><br><span class="line"><span class="comment">     */</span></span><br><span class="line">    <span class="keyword">private</span> Long loginId;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p><strong>设计要点</strong></p><p>这个响应对象包含三个字段：</p><ul><li><code>tokenName</code>：Token 的名称，对应 Sa-Token 配置中的 <code>token-name</code>（默认为 <code>&quot;Authorization&quot;</code>）</li><li><code>tokenValue</code>：Token 的值，前端需要在后续请求的 Header 中携带这个值</li><li><code>loginId</code>：用户 ID，用于前端展示或其他业务逻辑</li></ul><p>前端收到这个响应后，需要将 <code>tokenValue</code> 存储到本地（如 LocalStorage），并在后续请求的 Header 中携带：</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></pre></td><td class="code"><pre><span class="line"><span class="comment">// 前端示例</span></span><br><span class="line"><span class="keyword">const</span> response = <span class="keyword">await</span> <span class="title function_">fetch</span>(<span class="string">&#x27;/auth/login&#x27;</span>, &#123;</span><br><span class="line">  <span class="attr">method</span>: <span class="string">&#x27;POST&#x27;</span>,</span><br><span class="line">  <span class="attr">body</span>: <span class="title class_">JSON</span>.<span class="title function_">stringify</span>(&#123;<span class="attr">authType</span>: <span class="string">&#x27;PASSWORD&#x27;</span>, <span class="attr">username</span>: <span class="string">&#x27;admin&#x27;</span>, <span class="attr">password</span>: <span class="string">&#x27;123456&#x27;</span>&#125;)</span><br><span class="line">&#125;);</span><br><span class="line"><span class="keyword">const</span> data = <span class="keyword">await</span> response.<span class="title function_">json</span>();</span><br><span class="line"></span><br><span class="line"><span class="comment">// 存储 Token</span></span><br><span class="line"><span class="variable language_">localStorage</span>.<span class="title function_">setItem</span>(<span class="string">&#x27;token&#x27;</span>, data.<span class="property">tokenValue</span>);</span><br><span class="line"></span><br><span class="line"><span class="comment">// 后续请求携带 Token</span></span><br><span class="line"><span class="title function_">fetch</span>(<span class="string">&#x27;/api/user/info&#x27;</span>, &#123;</span><br><span class="line">  <span class="attr">headers</span>: &#123;</span><br><span class="line">    <span class="string">&#x27;Authorization&#x27;</span>: <span class="variable language_">localStorage</span>.<span class="title function_">getItem</span>(<span class="string">&#x27;token&#x27;</span>)</span><br><span class="line">  &#125;</span><br><span class="line">&#125;);</span><br></pre></td></tr></table></figure><hr><h3 id="19-2-6-3-实现-AuthStrategyFactory">19.2.6.3. 实现 AuthStrategyFactory</h3><p>现在我们开始实现认证策略工厂。</p><p><strong>📄 文件</strong>：<code>src/main/java/com/example/auth/factory/AuthStrategyFactory.java</code>（新建）</p><p>在 <code>src/main/java/com/example/auth</code> 目录下，右键选择 <code>New</code> → <code>Package</code>，输入包名 <code>factory</code>，然后在 <code>factory</code> 包下新建 <code>AuthStrategyFactory.java</code> 文件：</p><figure class="highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><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></pre></td><td class="code"><pre><span class="line"><span class="keyword">package</span> com.example.auth.factory;</span><br><span class="line"></span><br><span class="line"><span class="keyword">import</span> cn.dev33.satoken.stp.StpUtil;</span><br><span class="line"><span class="keyword">import</span> com.example.auth.enums.AuthType;</span><br><span class="line"><span class="keyword">import</span> com.example.auth.model.request.AuthRequest;</span><br><span class="line"><span class="keyword">import</span> com.example.auth.model.vo.AuthTokenVO;</span><br><span class="line"><span class="keyword">import</span> com.example.auth.strategy.AuthStrategy;</span><br><span class="line"><span class="keyword">import</span> lombok.extern.slf4j.Slf4j;</span><br><span class="line"><span class="keyword">import</span> org.springframework.stereotype.Component;</span><br><span class="line"></span><br><span class="line"><span class="keyword">import</span> java.util.List;</span><br><span class="line"><span class="keyword">import</span> java.util.Map;</span><br><span class="line"><span class="keyword">import</span> java.util.concurrent.ConcurrentHashMap;</span><br><span class="line"></span><br><span class="line"><span class="comment">/**</span></span><br><span class="line"><span class="comment"> * 认证策略工厂</span></span><br><span class="line"><span class="comment"> * 负责管理所有认证策略，并根据认证类型自动选择对应的策略</span></span><br><span class="line"><span class="comment"> */</span></span><br><span class="line"><span class="meta">@Slf4j</span></span><br><span class="line"><span class="meta">@Component</span></span><br><span class="line"><span class="keyword">public</span> <span class="keyword">class</span> <span class="title class_">AuthStrategyFactory</span> &#123;</span><br><span class="line">    <span class="comment">/**</span></span><br><span class="line"><span class="comment">     * 策略容器</span></span><br><span class="line"><span class="comment">     * Key: 认证类型（AuthType）</span></span><br><span class="line"><span class="comment">     * Value: 认证策略（AuthStrategy）</span></span><br><span class="line"><span class="comment">     * 使用 ConcurrentHashMap 保证线程安全</span></span><br><span class="line"><span class="comment">     */</span></span><br><span class="line">    <span class="keyword">private</span> <span class="keyword">final</span> Map&lt;AuthType, AuthStrategy&gt; strategyMap = <span class="keyword">new</span> <span class="title class_">ConcurrentHashMap</span>&lt;&gt;();</span><br><span class="line"></span><br><span class="line">    <span class="keyword">public</span> <span class="title function_">AuthStrategyFactory</span><span class="params">(List&lt;AuthStrategy&gt; strategies)</span> &#123;</span><br><span class="line">        <span class="comment">// 遍历所有策略，注册到 Map 中</span></span><br><span class="line">        <span class="keyword">for</span> (AuthStrategy strategy : strategies) &#123;</span><br><span class="line">            <span class="type">AuthType</span> <span class="variable">type</span> <span class="operator">=</span> strategy.getSupportedType();</span><br><span class="line"></span><br><span class="line">            <span class="comment">// 检查是否有重复的策略类型</span></span><br><span class="line">            <span class="keyword">if</span> (strategyMap.containsKey(type)) &#123;</span><br><span class="line">                <span class="keyword">throw</span> <span class="keyword">new</span> <span class="title class_">IllegalStateException</span>(<span class="string">&quot;重复的认证策略: &quot;</span> + type.getDescription());</span><br><span class="line">            &#125;</span><br><span class="line"></span><br><span class="line">            <span class="comment">// 注册策略到 Map 中</span></span><br><span class="line">            strategyMap.put(type, strategy);</span><br><span class="line">            log.info(<span class="string">&quot;认证策略工厂初始化完成，已注册 &#123;&#125; 个策略&quot;</span>, strategyMap.size());</span><br><span class="line">        &#125;</span><br><span class="line">    &#125;</span><br><span class="line"></span><br><span class="line">    <span class="comment">/**</span></span><br><span class="line"><span class="comment">     * 获取认证策略</span></span><br><span class="line"><span class="comment">     * 根据认证类型从 Map 中获取对应的策略</span></span><br><span class="line"><span class="comment">     *</span></span><br><span class="line"><span class="comment">     * <span class="doctag">@param</span> authType 认证类型</span></span><br><span class="line"><span class="comment">     * <span class="doctag">@return</span> 认证策略</span></span><br><span class="line"><span class="comment">     * <span class="doctag">@throws</span> IllegalArgumentException 如果认证类型不支持</span></span><br><span class="line"><span class="comment">     */</span></span><br><span class="line">    <span class="keyword">public</span> AuthStrategy <span class="title function_">getStrategy</span><span class="params">(AuthType authType)</span> &#123;</span><br><span class="line">        <span class="type">AuthStrategy</span> <span class="variable">strategy</span> <span class="operator">=</span> strategyMap.get(authType);</span><br><span class="line">        <span class="keyword">if</span> (strategy == <span class="literal">null</span>) &#123;</span><br><span class="line">            <span class="keyword">throw</span> <span class="keyword">new</span> <span class="title class_">IllegalArgumentException</span>(<span class="string">&quot;不支持的认证方式: &quot;</span> + authType.getDescription());</span><br><span class="line">        &#125;</span><br><span class="line">        <span class="keyword">return</span> strategy;</span><br><span class="line">    &#125;</span><br><span class="line"></span><br><span class="line">    <span class="comment">/**</span></span><br><span class="line"><span class="comment">     * 执行认证（门面方法）</span></span><br><span class="line"><span class="comment">     * 这是工厂类对外提供的统一认证入口</span></span><br><span class="line"><span class="comment">     * &lt;p&gt;</span></span><br><span class="line"><span class="comment">     * 为什么需要这个方法？</span></span><br><span class="line"><span class="comment">     * 1. 隐藏策略选择的复杂性：调用者不需要知道如何选择策略</span></span><br><span class="line"><span class="comment">     * 2. 统一异常处理：可以在这里统一处理策略执行过程中的异常</span></span><br><span class="line"><span class="comment">     * 3. 统一日志记录：可以在这里统一记录认证日志</span></span><br><span class="line"><span class="comment">     * 4. 集成 Sa-Token：策略类只返回 userId，工厂类负责调用 Sa-Token 登录</span></span><br><span class="line"><span class="comment">     *</span></span><br><span class="line"><span class="comment">     * <span class="doctag">@param</span> request 认证请求</span></span><br><span class="line"><span class="comment">     * <span class="doctag">@return</span> 认证响应（包含 Token 信息）</span></span><br><span class="line"><span class="comment">     * <span class="doctag">@throws</span> IllegalArgumentException 如果认证类型不支持</span></span><br><span class="line"><span class="comment">     * <span class="doctag">@throws</span> RuntimeException         如果认证失败</span></span><br><span class="line"><span class="comment">     */</span></span><br><span class="line">    <span class="keyword">public</span> AuthTokenVO <span class="title function_">authenticate</span><span class="params">(AuthRequest request)</span> &#123;</span><br><span class="line">        <span class="type">AuthType</span> <span class="variable">authType</span> <span class="operator">=</span> request.getAuthType();</span><br><span class="line">        <span class="comment">// 步骤 1：选择策略</span></span><br><span class="line">        <span class="type">AuthStrategy</span> <span class="variable">strategy</span> <span class="operator">=</span> getStrategy(authType);</span><br><span class="line">        <span class="keyword">try</span> &#123;</span><br><span class="line">            <span class="comment">// 步骤 2：策略类验证身份，返回用户 ID</span></span><br><span class="line">            <span class="type">Long</span> <span class="variable">userId</span> <span class="operator">=</span> strategy.authenticate(request);</span><br><span class="line">            log.info(<span class="string">&quot;认证成功: type=&#123;&#125;, userId=&#123;&#125;&quot;</span>, authType.getDescription(), userId);</span><br><span class="line">            <span class="comment">// 步骤 3：Sa-Token 登录</span></span><br><span class="line">            StpUtil.login(userId);</span><br><span class="line"></span><br><span class="line">            <span class="comment">// 步骤 4：构建响应对象</span></span><br><span class="line">            <span class="keyword">return</span> AuthTokenVO.builder()</span><br><span class="line">                    .tokenName(StpUtil.getTokenName())</span><br><span class="line">                    .tokenValue(StpUtil.getTokenValue())</span><br><span class="line">                    .loginId(userId)</span><br><span class="line">                    .build();</span><br><span class="line">        &#125; <span class="keyword">catch</span> (Exception e) &#123;</span><br><span class="line">            log.error(<span class="string">&quot;认证失败: type=&#123;&#125;, error=&#123;&#125;&quot;</span>, authType.getDescription(), e.getMessage());</span><br><span class="line">            <span class="keyword">throw</span> e;</span><br><span class="line">        &#125;</span><br><span class="line">    &#125;</span><br><span class="line"></span><br><span class="line">    <span class="comment">/**</span></span><br><span class="line"><span class="comment">     * 获取所有已注册的认证类型</span></span><br><span class="line"><span class="comment">     * 用于前端动态展示支持的登录方式</span></span><br><span class="line"><span class="comment">     *</span></span><br><span class="line"><span class="comment">     * <span class="doctag">@return</span> 所有已注册的认证类型</span></span><br><span class="line"><span class="comment">     */</span></span><br><span class="line">    <span class="keyword">public</span> List&lt;AuthType&gt; <span class="title function_">getSupportedTypes</span><span class="params">()</span> &#123;</span><br><span class="line">        <span class="keyword">return</span> List.copyOf(strategyMap.keySet());</span><br><span class="line">    &#125;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><hr><h3 id="19-2-6-4-关键设计点">19.2.6.4. 关键设计点</h3><p><strong>设计点一：为什么要检查重复的策略类型？</strong></p><p>如果有两个策略类返回相同的 <code>AuthType</code>，会导致后注册的策略覆盖先注册的策略。这是一个严重的 Bug，必须在启动时就检测出来：</p><figure class="highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">if</span> (strategyMap.containsKey(type)) &#123;</span><br><span class="line">    <span class="keyword">throw</span> <span class="keyword">new</span> <span class="title class_">IllegalStateException</span>(<span class="string">&quot;重复的认证策略: &quot;</span> + type.getDescription());</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>如果检测到重复，应用会在启动时抛出异常，而不是在运行时才发现问题。</p><p><strong>设计点二：为什么要提供 authenticate 门面方法？</strong></p><p>虽然调用者可以直接调用 <code>getStrategy(authType).authenticate(request)</code>，但这样会暴露策略选择的细节。提供门面方法可以：</p><ul><li>隐藏策略选择的复杂性</li><li>统一异常处理</li><li>统一日志记录</li><li>集成 Sa-Token（策略类只返回 <code>userId</code>，工厂类负责调用 <code>StpUtil.login(userId)</code>）</li></ul><p><strong>设计点三：为什么要提供 getSupportedTypes 方法？</strong></p><p>前端可以调用这个接口，动态获取系统支持的所有登录方式，然后展示对应的登录按钮。这样做的好处是：</p><ul><li>前端不需要硬编码登录方式</li><li>后端新增登录方式后，前端自动感知</li><li>可以通过配置动态启用或禁用某个登录方式</li></ul><hr><h3 id="19-2-6-5-本节小结">19.2.6.5. 本节小结</h3><p>本节完成了认证策略工厂的实现。工厂类在应用启动时自动扫描并注册所有策略，在收到登录请求时根据 <code>authType</code> 选择对应的策略，策略验证成功后调用 Sa-Token 完成登录并返回 Token 信息。</p><table><thead><tr><th style="text-align:left">要点</th><th style="text-align:left">何时使用</th><th style="text-align:left">关键动作</th></tr></thead><tbody><tr><td style="text-align:left">自动注册</td><td style="text-align:left">应用启动时</td><td style="text-align:left">Spring 注入所有策略 Bean，工厂类遍历并注册到 Map</td></tr><tr><td style="text-align:left">策略分发</td><td style="text-align:left">收到登录请求时</td><td style="text-align:left">根据 authType 从 Map 中获取对应的策略</td></tr><tr><td style="text-align:left">Sa-Token 集成</td><td style="text-align:left">策略验证成功后</td><td style="text-align:left">调用 StpUtil.login(userId) 完成登录</td></tr></tbody></table><p>在下一节中，我们将重构第一章问题引入的Controller 层，将原来的 if-else 分支替换为工厂模式，实现真正的 “零修改扩展”。</p><hr><h2 id="19-2-7-控制器实现：统一入口与零分支调度">19.2.7. 控制器实现：统一入口与零分支调度</h2><p>在上一节中，我们完成了认证策略工厂的实现，工厂类能够自动发现和注册所有策略，并根据 <code>authType</code> 自动选择对应的策略。现在，我们需要创建 Controller 层，提供统一的登录入口。</p><p>这一节是整个认证工厂的 <strong>“对外门面”</strong>，它将展示策略模式的最终效果：<strong>无论有多少种登录方式，Controller 层的代码永远只有几行</strong>。</p><hr><h3 id="19-2-7-1-Controller-层的职责定位">19.2.7.1. Controller 层的职责定位</h3><p>在传统的 MVC 架构中，Controller 层的职责非常明确：</p><p><strong>Controller 应该做什么？</strong></p><ul><li>✅ 接收 HTTP 请求</li><li>✅ 参数校验（通过 <code>@Valid</code> 注解）</li><li>✅ 调用 Service 层或工厂类</li><li>✅ 返回统一的响应格式</li></ul><p><strong>Controller 不应该做什么？</strong></p><ul><li>❌ 不应该包含业务逻辑（如验证密码、查询用户）</li><li>❌ 不应该包含策略选择逻辑（如 if-else 分支）</li><li>❌ 不应该直接操作数据库或 Redis</li></ul><p>在我们的认证工厂中，Controller 层只需要做一件事：<strong>将请求委托给工厂类</strong>。所有的策略选择、身份验证、Token 生成都由工厂类和策略类完成。</p><hr><h3 id="19-2-7-2-定义统一响应格式">19.2.7.2. 定义统一响应格式</h3><p>在创建 Controller 之前，我们需要先定义统一的响应格式。这样可以确保所有接口的返回值都遵循同一套规范。</p><p><strong>📄 文件</strong>：<code>src/main/java/com/example/auth/common/Result.java</code>（新建）</p><p>在 <code>src/main/java/com/example/auth</code> 目录下，右键选择 <code>New</code> → <code>Package</code>，输入包名 <code>common</code>，然后在 <code>common</code> 包下新建 <code>Result.java</code> 文件：</p><figure class="highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><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></pre></td><td class="code"><pre><span class="line"><span class="keyword">package</span> com.example.auth.common;</span><br><span class="line"></span><br><span class="line"><span class="keyword">import</span> lombok.Data;</span><br><span class="line"></span><br><span class="line"><span class="comment">/**</span></span><br><span class="line"><span class="comment"> * 统一响应格式</span></span><br><span class="line"><span class="comment"> * </span></span><br><span class="line"><span class="comment"> * <span class="doctag">@param</span> &lt;T&gt; 响应数据类型</span></span><br><span class="line"><span class="comment"> */</span></span><br><span class="line"><span class="meta">@Data</span></span><br><span class="line"><span class="keyword">public</span> <span class="keyword">class</span> <span class="title class_">Result</span>&lt;T&gt; &#123;</span><br><span class="line">    </span><br><span class="line">    <span class="comment">/**</span></span><br><span class="line"><span class="comment">     * 响应码</span></span><br><span class="line"><span class="comment">     * 200 表示成功，其他表示失败</span></span><br><span class="line"><span class="comment">     */</span></span><br><span class="line">    <span class="keyword">private</span> Integer code;</span><br><span class="line">    </span><br><span class="line">    <span class="comment">/**</span></span><br><span class="line"><span class="comment">     * 响应消息</span></span><br><span class="line"><span class="comment">     */</span></span><br><span class="line">    <span class="keyword">private</span> String message;</span><br><span class="line">    </span><br><span class="line">    <span class="comment">/**</span></span><br><span class="line"><span class="comment">     * 响应数据</span></span><br><span class="line"><span class="comment">     */</span></span><br><span class="line">    <span class="keyword">private</span> T data;</span><br><span class="line">    </span><br><span class="line">    <span class="comment">/**</span></span><br><span class="line"><span class="comment">     * 成功响应（无数据）</span></span><br><span class="line"><span class="comment">     */</span></span><br><span class="line">    <span class="keyword">public</span> <span class="keyword">static</span> &lt;T&gt; Result&lt;T&gt; <span class="title function_">ok</span><span class="params">()</span> &#123;</span><br><span class="line">        Result&lt;T&gt; result = <span class="keyword">new</span> <span class="title class_">Result</span>&lt;&gt;();</span><br><span class="line">        result.setCode(<span class="number">200</span>);</span><br><span class="line">        result.setMessage(<span class="string">&quot;操作成功&quot;</span>);</span><br><span class="line">        <span class="keyword">return</span> result;</span><br><span class="line">    &#125;</span><br><span class="line">    </span><br><span class="line">    <span class="comment">/**</span></span><br><span class="line"><span class="comment">     * 成功响应（有数据）</span></span><br><span class="line"><span class="comment">     */</span></span><br><span class="line">    <span class="keyword">public</span> <span class="keyword">static</span> &lt;T&gt; Result&lt;T&gt; <span class="title function_">ok</span><span class="params">(T data)</span> &#123;</span><br><span class="line">        Result&lt;T&gt; result = <span class="keyword">new</span> <span class="title class_">Result</span>&lt;&gt;();</span><br><span class="line">        result.setCode(<span class="number">200</span>);</span><br><span class="line">        result.setMessage(<span class="string">&quot;操作成功&quot;</span>);</span><br><span class="line">        result.setData(data);</span><br><span class="line">        <span class="keyword">return</span> result;</span><br><span class="line">    &#125;</span><br><span class="line">    </span><br><span class="line">    <span class="comment">/**</span></span><br><span class="line"><span class="comment">     * 失败响应</span></span><br><span class="line"><span class="comment">     */</span></span><br><span class="line">    <span class="keyword">public</span> <span class="keyword">static</span> &lt;T&gt; Result&lt;T&gt; <span class="title function_">fail</span><span class="params">(String message)</span> &#123;</span><br><span class="line">        Result&lt;T&gt; result = <span class="keyword">new</span> <span class="title class_">Result</span>&lt;&gt;();</span><br><span class="line">        result.setCode(<span class="number">500</span>);</span><br><span class="line">        result.setMessage(message);</span><br><span class="line">        <span class="keyword">return</span> result;</span><br><span class="line">    &#125;</span><br><span class="line">    </span><br><span class="line">    <span class="comment">/**</span></span><br><span class="line"><span class="comment">     * 失败响应（自定义错误码）</span></span><br><span class="line"><span class="comment">     */</span></span><br><span class="line">    <span class="keyword">public</span> <span class="keyword">static</span> &lt;T&gt; Result&lt;T&gt; <span class="title function_">fail</span><span class="params">(Integer code, String message)</span> &#123;</span><br><span class="line">        Result&lt;T&gt; result = <span class="keyword">new</span> <span class="title class_">Result</span>&lt;&gt;();</span><br><span class="line">        result.setCode(code);</span><br><span class="line">        result.setMessage(message);</span><br><span class="line">        <span class="keyword">return</span> result;</span><br><span class="line">    &#125;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p><strong>设计要点</strong></p><p>这个响应类包含三个字段：</p><ul><li><code>code</code>：响应码，200 表示成功，其他表示失败</li><li><code>message</code>：响应消息，用于提示用户</li><li><code>data</code>：响应数据，泛型类型，可以是任何对象</li></ul><p>我们提供了四个静态方法，用于快速构建响应对象：</p><ul><li><code>ok()</code>：成功响应，无数据</li><li><code>ok(T data)</code>：成功响应，有数据</li><li><code>fail(String message)</code>：失败响应，默认错误码 500</li><li><code>fail(Integer code, String message)</code>：失败响应，自定义错误码</li></ul><hr><h3 id="19-2-7-3-创建-AuthController">19.2.7.3. 创建 AuthController</h3><p>现在我们开始创建 Controller 层。</p><p><strong>📄 文件</strong>：<code>src/main/java/com/example/auth/controller/AuthController.java</code>（新建）</p><p>在 <code>src/main/java/com/example/auth</code> 目录下，右键选择 <code>New</code> → <code>Package</code>，输入包名 <code>controller</code>，然后在 <code>controller</code> 包下新建 <code>AuthController.java</code> 文件：</p><figure class="highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><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></pre></td><td class="code"><pre><span class="line"><span class="keyword">package</span> com.example.auth.controller;</span><br><span class="line"></span><br><span class="line"><span class="keyword">import</span> cn.dev33.satoken.stp.StpUtil;</span><br><span class="line"><span class="keyword">import</span> com.example.auth.common.Result;</span><br><span class="line"><span class="keyword">import</span> com.example.auth.enums.AuthType;</span><br><span class="line"><span class="keyword">import</span> com.example.auth.factory.AuthStrategyFactory;</span><br><span class="line"><span class="keyword">import</span> com.example.auth.model.request.AuthRequest;</span><br><span class="line"><span class="keyword">import</span> com.example.auth.model.vo.AuthTokenVO;</span><br><span class="line"><span class="keyword">import</span> jakarta.validation.Valid;</span><br><span class="line"><span class="keyword">import</span> lombok.RequiredArgsConstructor;</span><br><span class="line"><span class="keyword">import</span> lombok.extern.slf4j.Slf4j;</span><br><span class="line"><span class="keyword">import</span> org.springframework.web.bind.annotation.*;</span><br><span class="line"></span><br><span class="line"><span class="keyword">import</span> java.util.HashMap;</span><br><span class="line"><span class="keyword">import</span> java.util.List;</span><br><span class="line"><span class="keyword">import</span> java.util.Map;</span><br><span class="line"></span><br><span class="line"><span class="comment">/**</span></span><br><span class="line"><span class="comment"> * 认证控制器</span></span><br><span class="line"><span class="comment"> * 提供登录、注销等接口</span></span><br><span class="line"><span class="comment"> */</span></span><br><span class="line"><span class="meta">@Slf4j</span></span><br><span class="line"><span class="meta">@RestController</span></span><br><span class="line"><span class="meta">@RequestMapping(&quot;/auth&quot;)</span></span><br><span class="line"><span class="meta">@RequiredArgsConstructor</span></span><br><span class="line"><span class="keyword">public</span> <span class="keyword">class</span> <span class="title class_">AuthController</span> &#123;</span><br><span class="line">    </span><br><span class="line">    <span class="keyword">private</span> <span class="keyword">final</span> AuthStrategyFactory authStrategyFactory;</span><br><span class="line">    </span><br><span class="line">    <span class="comment">/**</span></span><br><span class="line"><span class="comment">     * 统一登录接口</span></span><br><span class="line"><span class="comment">     * 支持多种登录方式，通过 authType 字段区分</span></span><br><span class="line"><span class="comment">     * </span></span><br><span class="line"><span class="comment">     * <span class="doctag">@param</span> request 认证请求（多态参数）</span></span><br><span class="line"><span class="comment">     * <span class="doctag">@return</span> 统一响应格式</span></span><br><span class="line"><span class="comment">     */</span></span><br><span class="line">    <span class="meta">@PostMapping(&quot;/login&quot;)</span></span><br><span class="line">    <span class="keyword">public</span> Result&lt;AuthTokenVO&gt; <span class="title function_">login</span><span class="params">(<span class="meta">@Valid</span> <span class="meta">@RequestBody</span> AuthRequest request)</span> &#123;</span><br><span class="line">        log.info(<span class="string">&quot;收到登录请求: type=&#123;&#125;&quot;</span>, request.getAuthType().getDescription());</span><br><span class="line">        </span><br><span class="line">        <span class="comment">// 委托给工厂类，工厂类会自动选择对应的策略</span></span><br><span class="line">        <span class="type">AuthTokenVO</span> <span class="variable">authToken</span> <span class="operator">=</span> authStrategyFactory.authenticate(request);</span><br><span class="line">        </span><br><span class="line">        <span class="keyword">return</span> Result.ok(authToken);</span><br><span class="line">    &#125;</span><br><span class="line">    </span><br><span class="line">    <span class="comment">/**</span></span><br><span class="line"><span class="comment">     * 注销登录接口</span></span><br><span class="line"><span class="comment">     * </span></span><br><span class="line"><span class="comment">     * <span class="doctag">@return</span> 统一响应格式</span></span><br><span class="line"><span class="comment">     */</span></span><br><span class="line">    <span class="meta">@PostMapping(&quot;/logout&quot;)</span></span><br><span class="line">    <span class="keyword">public</span> Result&lt;Void&gt; <span class="title function_">logout</span><span class="params">()</span> &#123;</span><br><span class="line">        log.info(<span class="string">&quot;收到注销请求&quot;</span>);</span><br><span class="line">        </span><br><span class="line">        <span class="comment">// Sa-Token 注销登录</span></span><br><span class="line">        StpUtil.logout();</span><br><span class="line">        </span><br><span class="line">        <span class="keyword">return</span> Result.ok();</span><br><span class="line">    &#125;</span><br><span class="line">    </span><br><span class="line">    <span class="comment">/**</span></span><br><span class="line"><span class="comment">     * 获取支持的登录方式</span></span><br><span class="line"><span class="comment">     * 前端可以调用这个接口，动态展示登录按钮</span></span><br><span class="line"><span class="comment">     * </span></span><br><span class="line"><span class="comment">     * <span class="doctag">@return</span> 所有支持的登录方式</span></span><br><span class="line"><span class="comment">     */</span></span><br><span class="line">    <span class="meta">@GetMapping(&quot;/supported-types&quot;)</span></span><br><span class="line">    <span class="keyword">public</span> Result&lt;List&lt;Map&lt;String, String&gt;&gt;&gt; <span class="title function_">getSupportedTypes</span><span class="params">()</span> &#123;</span><br><span class="line">        List&lt;AuthType&gt; types = authStrategyFactory.getSupportedTypes();</span><br><span class="line">        </span><br><span class="line">        <span class="comment">// 转换为前端友好的格式</span></span><br><span class="line">        List&lt;Map&lt;String, String&gt;&gt; result = types.stream()</span><br><span class="line">                .map(type -&gt; &#123;</span><br><span class="line">                    Map&lt;String, String&gt; map = <span class="keyword">new</span> <span class="title class_">HashMap</span>&lt;&gt;();</span><br><span class="line">                    map.put(<span class="string">&quot;code&quot;</span>, type.getCode());</span><br><span class="line">                    map.put(<span class="string">&quot;description&quot;</span>, type.getDescription());</span><br><span class="line">                    <span class="keyword">return</span> map;</span><br><span class="line">                &#125;)</span><br><span class="line">                .toList();</span><br><span class="line">        </span><br><span class="line">        <span class="keyword">return</span> Result.ok(result);</span><br><span class="line">    &#125;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p><strong>关键设计点解析</strong></p><p><strong>设计点一：login 方法只有 3 行核心代码</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></pre></td><td class="code"><pre><span class="line"><span class="meta">@PostMapping(&quot;/login&quot;)</span></span><br><span class="line"><span class="keyword">public</span> Result&lt;AuthTokenVO&gt; <span class="title function_">login</span><span class="params">(<span class="meta">@Valid</span> <span class="meta">@RequestBody</span> AuthRequest request)</span> &#123;</span><br><span class="line">    log.info(<span class="string">&quot;收到登录请求: type=&#123;&#125;&quot;</span>, request.getAuthType().getDescription());</span><br><span class="line">    <span class="type">AuthTokenVO</span> <span class="variable">authToken</span> <span class="operator">=</span> authStrategyFactory.authenticate(request);</span><br><span class="line">    <span class="keyword">return</span> Result.ok(authToken);</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>这就是策略模式的最终效果。无论系统支持多少种登录方式，Controller 层的代码永远只有这几行。所有的策略选择、身份验证、Token 生成都由工厂类和策略类完成。</p><p><strong>设计点二：@Valid 注解自动校验参数</strong></p><figure class="highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">public</span> Result&lt;AuthTokenVO&gt; <span class="title function_">login</span><span class="params">(<span class="meta">@Valid</span> <span class="meta">@RequestBody</span> AuthRequest request)</span></span><br></pre></td></tr></table></figure><p><code>@Valid</code> 注解会自动触发 Spring 的参数校验机制。如果前端传来的参数不符合要求（如 <code>username</code> 为空），Spring 会自动返回 400 错误，错误信息为我们在请求类中定义的 <code>message</code>（如 “用户名不能为空”）。</p><p><strong>设计点三：getSupportedTypes 方法返回前端友好的格式</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></pre></td><td class="code"><pre><span class="line"><span class="meta">@GetMapping(&quot;/supported-types&quot;)</span></span><br><span class="line"><span class="keyword">public</span> Result&lt;List&lt;Map&lt;String, String&gt;&gt;&gt; <span class="title function_">getSupportedTypes</span><span class="params">()</span> &#123;</span><br><span class="line">    List&lt;AuthType&gt; types = authStrategyFactory.getSupportedTypes();</span><br><span class="line">    </span><br><span class="line">    <span class="comment">// 转换为前端友好的格式</span></span><br><span class="line">    List&lt;Map&lt;String, String&gt;&gt; result = types.stream()</span><br><span class="line">            .map(type -&gt; &#123;</span><br><span class="line">                Map&lt;String, String&gt; map = <span class="keyword">new</span> <span class="title class_">HashMap</span>&lt;&gt;();</span><br><span class="line">                map.put(<span class="string">&quot;code&quot;</span>, type.getCode());</span><br><span class="line">                map.put(<span class="string">&quot;description&quot;</span>, type.getDescription());</span><br><span class="line">                <span class="keyword">return</span> map;</span><br><span class="line">            &#125;)</span><br><span class="line">            .toList();</span><br><span class="line">    </span><br><span class="line">    <span class="keyword">return</span> Result.ok(result);</span><br><span class="line">&#125;</span><br></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><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><span class="line"><span class="punctuation">&#123;</span></span><br><span class="line">  <span class="attr">&quot;code&quot;</span><span class="punctuation">:</span> <span class="number">200</span><span class="punctuation">,</span></span><br><span class="line">  <span class="attr">&quot;message&quot;</span><span class="punctuation">:</span> <span class="string">&quot;操作成功&quot;</span><span class="punctuation">,</span></span><br><span class="line">  <span class="attr">&quot;data&quot;</span><span class="punctuation">:</span> <span class="punctuation">[</span></span><br><span class="line">    <span class="punctuation">&#123;</span></span><br><span class="line">      <span class="attr">&quot;code&quot;</span><span class="punctuation">:</span> <span class="string">&quot;password&quot;</span><span class="punctuation">,</span></span><br><span class="line">      <span class="attr">&quot;description&quot;</span><span class="punctuation">:</span> <span class="string">&quot;账号密码登录&quot;</span></span><br><span class="line">    <span class="punctuation">&#125;</span><span class="punctuation">,</span></span><br><span class="line">    <span class="punctuation">&#123;</span></span><br><span class="line">      <span class="attr">&quot;code&quot;</span><span class="punctuation">:</span> <span class="string">&quot;sms&quot;</span><span class="punctuation">,</span></span><br><span class="line">      <span class="attr">&quot;description&quot;</span><span class="punctuation">:</span> <span class="string">&quot;手机验证码登录&quot;</span></span><br><span class="line">    <span class="punctuation">&#125;</span></span><br><span class="line">  <span class="punctuation">]</span></span><br><span class="line"><span class="punctuation">&#125;</span></span><br></pre></td></tr></table></figure><p>前端可以根据这个列表动态展示登录按钮，而不需要硬编码登录方式。</p><hr><h3 id="19-2-7-4-对比传统写法">19.2.7.4. 对比传统写法</h3><p>现在让我们对比一下传统写法和策略模式的差异。</p><p><strong>传统写法（假设的 if-else 版本）</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></pre></td><td class="code"><pre><span class="line"><span class="meta">@PostMapping(&quot;/login&quot;)</span></span><br><span class="line"><span class="keyword">public</span> Result&lt;AuthToken&gt; <span class="title function_">login</span><span class="params">(<span class="meta">@RequestBody</span> Map&lt;String, Object&gt; request)</span> &#123;</span><br><span class="line">    <span class="type">String</span> <span class="variable">type</span> <span class="operator">=</span> (String) request.get(<span class="string">&quot;type&quot;</span>);</span><br><span class="line">    </span><br><span class="line">    <span class="keyword">if</span> (<span class="string">&quot;password&quot;</span>.equals(type)) &#123;</span><br><span class="line">        <span class="comment">// 账号密码登录逻辑（50 行代码）</span></span><br><span class="line">        <span class="type">String</span> <span class="variable">username</span> <span class="operator">=</span> (String) request.get(<span class="string">&quot;username&quot;</span>);</span><br><span class="line">        <span class="type">String</span> <span class="variable">password</span> <span class="operator">=</span> (String) request.get(<span class="string">&quot;password&quot;</span>);</span><br><span class="line">        <span class="comment">// 查询用户、验证密码、生成 Token...</span></span><br><span class="line">        </span><br><span class="line">    &#125; <span class="keyword">else</span> <span class="keyword">if</span> (<span class="string">&quot;sms&quot;</span>.equals(type)) &#123;</span><br><span class="line">        <span class="comment">// 手机验证码登录逻辑（50 行代码）</span></span><br><span class="line">        <span class="type">String</span> <span class="variable">phone</span> <span class="operator">=</span> (String) request.get(<span class="string">&quot;phone&quot;</span>);</span><br><span class="line">        <span class="type">String</span> <span class="variable">code</span> <span class="operator">=</span> (String) request.get(<span class="string">&quot;code&quot;</span>);</span><br><span class="line">        <span class="comment">// 验证验证码、查询或创建用户、生成 Token...</span></span><br><span class="line">        </span><br><span class="line">    &#125; <span class="keyword">else</span> <span class="keyword">if</span> (<span class="string">&quot;wechat&quot;</span>.equals(type)) &#123;</span><br><span class="line">        <span class="comment">// 微信扫码登录逻辑（50 行代码）</span></span><br><span class="line">        <span class="type">String</span> <span class="variable">code</span> <span class="operator">=</span> (String) request.get(<span class="string">&quot;code&quot;</span>);</span><br><span class="line">        <span class="comment">// 调用微信 API、查询或创建用户、生成 Token...</span></span><br><span class="line">        </span><br><span class="line">    &#125; <span class="keyword">else</span> &#123;</span><br><span class="line">        <span class="keyword">throw</span> <span class="keyword">new</span> <span class="title class_">RuntimeException</span>(<span class="string">&quot;不支持的登录方式: &quot;</span> + type);</span><br><span class="line">    &#125;</span><br><span class="line">&#125;</span><br></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></pre></td><td class="code"><pre><span class="line"><span class="meta">@PostMapping(&quot;/login&quot;)</span></span><br><span class="line"><span class="keyword">public</span> Result&lt;AuthTokenVO&gt; <span class="title function_">login</span><span class="params">(<span class="meta">@Valid</span> <span class="meta">@RequestBody</span> AuthRequest request)</span> &#123;</span><br><span class="line">    log.info(<span class="string">&quot;收到登录请求: type=&#123;&#125;&quot;</span>, request.getAuthType().getDescription());</span><br><span class="line">    <span class="type">AuthTokenVO</span> <span class="variable">authToken</span> <span class="operator">=</span> authStrategyFactory.authenticate(request);</span><br><span class="line">    <span class="keyword">return</span> Result.ok(authToken);</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p><strong>对比结果</strong></p><table><thead><tr><th style="text-align:left">对比维度</th><th style="text-align:left">传统写法</th><th style="text-align:left">策略模式</th></tr></thead><tbody><tr><td style="text-align:left">Controller 代码量</td><td style="text-align:left">250+ 行</td><td style="text-align:left">3 行</td></tr><tr><td style="text-align:left">if-else 分支</td><td style="text-align:left">5 个</td><td style="text-align:left">0 个</td></tr><tr><td style="text-align:left">新增登录方式</td><td style="text-align:left">修改 Controller</td><td style="text-align:left">只需实现 <code>AuthStrategy</code> 接口</td></tr><tr><td style="text-align:left">可测试性</td><td style="text-align:left">难以单独测试</td><td style="text-align:left">可以单独测试每个策略</td></tr><tr><td style="text-align:left">职责分离</td><td style="text-align:left">Controller 包含业务逻辑</td><td style="text-align:left">Controller 只负责调度</td></tr></tbody></table><hr><h3 id="19-2-7-5-本节小结">19.2.7.5. 本节小结</h3><p>本节完成了 Controller 层的创建，提供了统一的登录入口。Controller 层的代码非常简洁，只有 3 行核心代码，所有的策略选择、身份验证、Token 生成都由工厂类和策略类完成。这就是策略模式的最终效果：<strong>无论有多少种登录方式，Controller 层的代码永远只有几行</strong>。</p><table><thead><tr><th style="text-align:left">要点</th><th style="text-align:left">何时使用</th><th style="text-align:left">关键动作</th></tr></thead><tbody><tr><td style="text-align:left">统一响应格式</td><td style="text-align:left">所有接口</td><td style="text-align:left">定义 Result 类，包含 code、message、data 三个字段</td></tr><tr><td style="text-align:left">统一登录接口</td><td style="text-align:left">前端发送登录请求</td><td style="text-align:left">委托给工厂类，工厂类自动选择策略</td></tr><tr><td style="text-align:left">获取支持的登录方式</td><td style="text-align:left">前端动态展示登录按钮</td><td style="text-align:left">调用工厂类的 getSupportedTypes 方法</td></tr></tbody></table><p>在下一节中，我们将实现具体的策略类，让整个认证工厂真正运转起来。</p></div>]]></content>
    
    
      
      
        
        
    <summary type="html">&lt;div id=&quot;postchat_postcontent&quot;&gt;&lt;h1&gt;Note 19.2. 逻辑引擎：基于策略模式的认证工厂&lt;/h1&gt;
&lt;h2 id=&quot;19-2-1-问题引入&quot;&gt;19.2.1.</summary>
        
      
    
    
    
    <category term="后端技术" scheme="https://prorise666.site/categories/%E5%90%8E%E7%AB%AF%E6%8A%80%E6%9C%AF/"/>
    
    <category term="Java" scheme="https://prorise666.site/categories/%E5%90%8E%E7%AB%AF%E6%8A%80%E6%9C%AF/Java/"/>
    
    <category term="Spring系列" scheme="https://prorise666.site/categories/%E5%90%8E%E7%AB%AF%E6%8A%80%E6%9C%AF/Java/Spring%E7%B3%BB%E5%88%97/"/>
    
    <category term="登录注册系列" scheme="https://prorise666.site/categories/%E5%90%8E%E7%AB%AF%E6%8A%80%E6%9C%AF/Java/Spring%E7%B3%BB%E5%88%97/%E7%99%BB%E5%BD%95%E6%B3%A8%E5%86%8C%E7%B3%BB%E5%88%97/"/>
    
    
    <category term="Spring生态篇" scheme="https://prorise666.site/tags/Spring%E7%94%9F%E6%80%81%E7%AF%87/"/>
    
  </entry>
  
  <entry>
    <title>OpenWebUi-安全与认证配置</title>
    <link href="https://prorise666.site/posts/47824.html"/>
    <id>https://prorise666.site/posts/47824.html</id>
    <published>2026-02-27T11:38:17.000Z</published>
    <updated>2026-03-12T09:18:16.915Z</updated>
    
    <content type="html"><![CDATA[<div id="postchat_postcontent"><h2 id="3-9-安全与认证配置">3.9. 安全与认证配置</h2><p>这一章讲的是“谁能登录、谁能调用 API、用户身份如何同步、会话如何保活”。</p><p>如果你是个人用户，这部分很多配置可以先不动；但只要进入团队协作、公司内网、对接脚本或自动化系统，这一章就会变成必修课。</p><p>在开始之前，先解释几个经常会混在一起的术语：</p><ul><li><strong>认证（Authentication）</strong>：证明“你是谁”。例如账号密码登录、LDAP 登录、Google 登录。</li><li><strong>授权（Authorization）</strong>：决定“你能做什么”。例如能不能看某个模型、能不能导出模型、能不能管理工具。</li><li><strong>API Key</strong>：发给程序用的密钥，适合脚本、集成平台、外部服务调用 Open WebUI API。</li><li><strong>LDAP</strong>：企业常见的目录服务协议，很多公司的 AD（Active Directory）也兼容这套方式。</li><li><strong>OAuth / OIDC</strong>：第三方登录体系。OAuth 偏“授权”，OIDC 是建立在 OAuth 之上的“身份登录”标准。</li><li><strong>SCIM</strong>：自动开通和回收账号的标准，不负责“登录”，而负责“账号生命周期同步”。</li></ul><h3 id="API-密钥认证">API 密钥认证</h3><p><strong>这是什么</strong></p><p>Open WebUI 支持为用户生成 API Key，让外部脚本、自动化流程、内部平台、工作流引擎通过 HTTP API 访问它。</p><p>典型场景包括：</p><ul><li>用 Python、Node.js 或 Shell 脚本调用聊天接口</li><li>用企业内部系统转发请求到 Open WebUI</li><li>给工作流平台、Bot、自动化任务提供一个稳定认证方式</li></ul><p>它和“浏览器登录 Cookie”不是一回事：</p><ul><li>浏览器登录主要给人用</li><li>API Key 主要给程序用</li></ul><p><strong>如何启用</strong></p><p>管理员面板 → 设置 → 通用 → 启用 API Key</p><p>启用后，用户就可以在自己的账号设置中创建 API Key。</p><p>当前版本除了总开关外，还支持 <strong>API Key Endpoint Restrictions</strong>，也就是“限制 API Key 只能访问哪些接口”。这个能力很重要，因为它可以避免把一把过大的密钥发给外部系统。</p><p><strong>用户如何生成密钥</strong></p><p>点击头像 → 设置 → 账号 → API 密钥 → 点击“创建新密钥”</p><p>创建后要立即保存，因为这类密钥通常不会反复明文展示。</p><p><strong>调用示例</strong></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></pre></td><td class="code"><pre><span class="line">curl -X POST http://localhost:3000/api/chat \</span><br><span class="line">  -H <span class="string">&quot;Authorization: Bearer sk-...&quot;</span> \</span><br><span class="line">  -H <span class="string">&quot;Content-Type: application/json&quot;</span> \</span><br><span class="line">  -d <span class="string">&#x27;&#123;&quot;message&quot;: &quot;Hello&quot;&#125;&#x27;</span></span><br></pre></td></tr></table></figure><p><strong>官方建议理解</strong></p><p>官网当前的方向不是“默认把所有接口都敞开”，而是：</p><ul><li>可以启用 API Key</li><li>可以进一步启用接口级限制</li><li>在可信内网环境里可以更宽松，在生产环境则建议更严格</li></ul><p><strong>我的管理方式</strong></p><p>在我的环境里，API Key 主要给“程序”而不是“人”使用，所以我通常这样管理：</p><ol><li>浏览器用户走正常登录，不给所有人默认要求 API Key。</li><li>只有确实需要脚本调用的账号，才生成 API Key。</li><li>给外部系统时，优先启用接口限制，不给过大的权限面。</li><li>定期清理不再使用的密钥，避免历史脚本长期持有可用凭证。</li><li>如果只是临时测试，我会单独创建测试用密钥，不和正式环境长期复用。</li></ol><h3 id="LDAP-集成">LDAP 集成</h3><p><strong>这是什么</strong></p><p>LDAP 可以理解为“公司统一通讯录 / 账号目录”的标准接口。</p><p>如果你所在的组织已经有 AD、OpenLDAP 或其他企业目录系统，那么 Open WebUI 可以直接对接这个目录，让员工使用公司账号登录，而不是在 Open WebUI 里重复创建本地账号。</p><p><strong>适合谁</strong></p><ul><li>公司内网部署</li><li>已有统一身份管理</li><li>希望账号、邮箱、用户名来自企业目录</li></ul><p>个人部署、小团队、临时测试环境，通常不需要上 LDAP。</p><p><strong>官方配置思路</strong></p><p>LDAP 推荐先通过环境变量初始化，然后在管理员面板里继续维护。官网特别强调一件事：</p><blockquote><p>如果启用了持久化配置（默认就是），很多环境变量只在<strong>第一次启动</strong>时读入；后续修改通常要在管理后台里改，而不是只改 <code>docker-compose</code>。</p></blockquote><p>这点非常重要，也是很多人“明明改了环境变量，怎么界面没变”的根源。</p><p><strong>当前版本常见环境变量</strong></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></pre></td><td class="code"><pre><span class="line"><span class="attr">environment:</span></span><br><span class="line">  <span class="bullet">-</span> <span class="string">ENABLE_LDAP=true</span></span><br><span class="line">  <span class="bullet">-</span> <span class="string">LDAP_SERVER_LABEL=OpenLDAP</span></span><br><span class="line">  <span class="bullet">-</span> <span class="string">LDAP_SERVER_HOST=ldap.example.com</span></span><br><span class="line">  <span class="bullet">-</span> <span class="string">LDAP_SERVER_PORT=389</span></span><br><span class="line">  <span class="bullet">-</span> <span class="string">LDAP_ATTRIBUTE_FOR_MAIL=mail</span></span><br><span class="line">  <span class="bullet">-</span> <span class="string">LDAP_ATTRIBUTE_FOR_USERNAME=uid</span></span><br><span class="line">  <span class="bullet">-</span> <span class="string">LDAP_APP_DN=cn=admin,dc=example,dc=org</span></span><br><span class="line">  <span class="bullet">-</span> <span class="string">LDAP_APP_PASSWORD=your_password</span></span><br><span class="line">  <span class="bullet">-</span> <span class="string">LDAP_SEARCH_BASE=dc=example,dc=org</span></span><br><span class="line">  <span class="bullet">-</span> <span class="string">LDAP_SEARCH_FILTER=(uid=%(user)s)</span></span><br><span class="line">  <span class="bullet">-</span> <span class="string">LDAP_USE_TLS=true</span></span><br></pre></td></tr></table></figure><p>你会发现，这一版和很多旧博客里写的 <code>LDAP_SERVER_URL</code>、<code>LDAP_BIND_DN</code> 之类名字不完全一样。写文档时一定要以当前版本为准。</p><p><strong>参数解释</strong></p><table><thead><tr><th>参数</th><th>说明</th></tr></thead><tbody><tr><td><code>ENABLE_LDAP</code></td><td>是否启用 LDAP 登录</td></tr><tr><td><code>LDAP_SERVER_LABEL</code></td><td>在界面里显示的 LDAP 名称</td></tr><tr><td><code>LDAP_SERVER_HOST</code> / <code>LDAP_SERVER_PORT</code></td><td>LDAP 服务器地址和端口</td></tr><tr><td><code>LDAP_ATTRIBUTE_FOR_MAIL</code></td><td>从 LDAP 条目里取邮箱的字段</td></tr><tr><td><code>LDAP_ATTRIBUTE_FOR_USERNAME</code></td><td>从 LDAP 条目里取用户名的字段</td></tr><tr><td><code>LDAP_APP_DN</code> / <code>LDAP_APP_PASSWORD</code></td><td>用于查询目录的服务账号</td></tr><tr><td><code>LDAP_SEARCH_BASE</code></td><td>搜索用户的根 DN</td></tr><tr><td><code>LDAP_SEARCH_FILTER</code></td><td>登录时查找用户的过滤器</td></tr><tr><td><code>LDAP_USE_TLS</code></td><td>是否启用 TLS / StartTLS</td></tr></tbody></table><p><strong>正确的排错顺序</strong></p><p>不要一上来就怀疑 Open WebUI。</p><p>官网推荐的思路是：</p><ol><li>先验证 LDAP 服务器本身通不通</li><li>再验证用户条目能不能查到</li><li>最后才看 Open WebUI 配置</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></pre></td><td class="code"><pre><span class="line">ldapsearch -x -H ldap://ldap.example.com:389 \</span><br><span class="line">  -D <span class="string">&quot;cn=admin,dc=example,dc=org&quot;</span> -w your_password \</span><br><span class="line">  -b <span class="string">&quot;dc=example,dc=org&quot;</span> <span class="string">&quot;(uid=jdoe)&quot;</span></span><br></pre></td></tr></table></figure><p>如果这一步都查不到用户，就不要继续在 WebUI 里盲改了。</p><p><strong>我的管理方式</strong></p><p>我自己的判断规则很简单：</p><ul><li>如果是企业内部正式使用，并且员工已经有统一账号体系，我会优先接 LDAP。</li><li>如果只是个人站点、朋友共用、测试环境，我不会为了“看起来企业化”硬上 LDAP。</li><li>上 LDAP 后，我会尽量让用户名、邮箱、显示名都来自目录系统，避免 Open WebUI 自己成为第二套主数据来源。</li></ul><h3 id="OAuth-OIDC-SSO-集成">OAuth / OIDC / SSO 集成</h3><p><strong>这是什么</strong></p><p>这一组就是“第三方登录”。</p><p>最常见的使用方式是：</p><ul><li>用 Google 账号登录</li><li>用 Microsoft / Entra ID 账号登录</li><li>用 GitHub 账号登录</li><li>用企业自己的 OIDC 服务登录</li></ul><p>如果说 LDAP 更像“公司内网目录登录”，那 OAuth / OIDC 更像“现代网站统一登录”。</p><p><strong>先说结论：本地开发完全可以配</strong></p><p>而且你现在这个仓库已经改成了：</p><ul><li><code>docker-compose.local.yaml</code> 自动读取 <code>.env.local</code></li><li>本地端口由 <code>OPEN_WEBUI_PORT</code> 控制</li><li>Google、Microsoft、GitHub 三家都可以直接写在 <code>.env.local</code></li></ul><p>所以对读者来说，最容易跟做的方式就是：</p><ol><li>去各自官网创建 OAuth 应用</li><li>把回调地址填成 Open WebUI 本地回调</li><li>把拿到的密钥填进 <code>.env.local</code></li><li>重启本地容器</li></ol><p><strong>本地统一配置模板</strong></p><p>如果你的本地端口是 <code>3000</code>，那么 <code>.env.local</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><span class="line">17</span><br></pre></td><td class="code"><pre><span class="line">OPEN_WEBUI_PORT=3000</span><br><span class="line">WEBUI_URL=http://localhost:3000</span><br><span class="line">ENABLE_OAUTH_SIGNUP=true</span><br><span class="line">OAUTH_MERGE_ACCOUNTS_BY_EMAIL=true</span><br><span class="line"></span><br><span class="line">GOOGLE_CLIENT_ID=</span><br><span class="line">GOOGLE_CLIENT_SECRET=</span><br><span class="line">GOOGLE_REDIRECT_URI=http://localhost:3000/oauth/google/login/callback</span><br><span class="line"></span><br><span class="line">MICROSOFT_CLIENT_ID=</span><br><span class="line">MICROSOFT_CLIENT_SECRET=</span><br><span class="line">MICROSOFT_CLIENT_TENANT_ID=common</span><br><span class="line">MICROSOFT_REDIRECT_URI=http://localhost:3000/oauth/microsoft/login/callback</span><br><span class="line"></span><br><span class="line">GITHUB_CLIENT_ID=</span><br><span class="line">GITHUB_CLIENT_SECRET=</span><br><span class="line">GITHUB_CLIENT_REDIRECT_URI=http://localhost:3000/oauth/github/login/callback</span><br></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><span class="line">docker compose -f docker-compose.local.yaml up -d --build</span><br></pre></td></tr></table></figure><p>如果你的端口不是 <code>3000</code>，例如 <code>5050</code>，那上面所有 <code>localhost:3000</code> 都要统一改成 <code>localhost:5050</code>。</p><div class="tabs" id="oauth_local_setup"><ul class="nav-tabs"><button type="button" class="tab  active" data-href="oauth_local_setup-1">Google</button><button type="button" class="tab " data-href="oauth_local_setup-2">Microsoft</button><button type="button" class="tab " data-href="oauth_local_setup-3">GitHub</button></ul><div class="tab-contents"><div class="tab-item-content active" id="oauth_local_setup-1"><p><strong>适用场景</strong></p><p>如果你想直接使用 Google 账号登录，这是最常见的一种配置。</p><p><strong>第 1 步：去 Google 官方后台创建应用</strong></p><p>访问：</p><p><a href="https://console.cloud.google.com/apis/credentials">https://console.cloud.google.com/apis/credentials</a></p><p>进入后：</p><ol><li>创建或选择一个 Project</li><li>打开 <code>APIs &amp; Services</code></li><li>进入 <code>Credentials</code>（凭证）</li><li>点击 <code>Create Credentials</code></li><li>选择 <code>OAuth client ID</code></li><li>Application type 选择 <code>Web application</code></li></ol><p><strong>第 2 步：登记回调地址</strong></p><p>在 <code>Authorized redirect URIs</code> 中填写：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">http://localhost:3000/oauth/google/login/callback</span><br></pre></td></tr></table></figure><p><strong>第 3 步：把拿到的密钥填入 <code>.env.local</code></strong></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></pre></td><td class="code"><pre><span class="line">GOOGLE_CLIENT_ID=你的 Google Client ID</span><br><span class="line">GOOGLE_CLIENT_SECRET=你的 Google Client Secret</span><br><span class="line">GOOGLE_REDIRECT_URI=http://localhost:3000/oauth/google/login/callback</span><br></pre></td></tr></table></figure><p><strong>第 4 步：重启本地 Open WebUI</strong></p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">docker compose -f docker-compose.local.yaml up -d --build</span><br></pre></td></tr></table></figure><p><strong>注意事项</strong></p><ul><li>Google 开发环境允许 <code>http://localhost</code></li><li>回调地址必须和后台里登记的 URI 完全一致</li><li>如果你改了端口，Google 后台也要一起改</li></ul></div><div class="tab-item-content" id="oauth_local_setup-2"><p><strong>适用场景</strong></p><p>如果用户本身就在 Microsoft 365、Entra ID、企业账号体系里，这一项非常常见。</p><p><strong>第 1 步：去 Microsoft Entra 后台注册应用</strong></p><p>访问：</p><p><a href="https://portal.azure.com/">https://portal.azure.com/</a></p><p>进入后：</p><ol><li>打开 <code>Microsoft Entra ID</code></li><li>进入 <code>App registrations</code></li><li>点击 <code>New registration</code></li><li>创建一个应用</li></ol><p><strong>第 2 步：配置回调地址</strong></p><p>在 <code>Authentication</code> 页面里添加：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">http://localhost:3000/oauth/microsoft/login/callback</span><br></pre></td></tr></table></figure><p><strong>第 3 步：生成 Secret 并写入 <code>.env.local</code></strong></p><p>在 <code>Certificates &amp; secrets</code> 中生成一个 <code>Client secret</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></pre></td><td class="code"><pre><span class="line">MICROSOFT_CLIENT_ID=你的 Microsoft Client ID</span><br><span class="line">MICROSOFT_CLIENT_SECRET=你的 Microsoft Client Secret</span><br><span class="line">MICROSOFT_CLIENT_TENANT_ID=common</span><br><span class="line">MICROSOFT_REDIRECT_URI=http://localhost:3000/oauth/microsoft/login/callback</span><br></pre></td></tr></table></figure><p>如果你只允许某一个租户登录，把 <code>common</code> 改成你的实际 Tenant ID 即可。</p><p><strong>第 4 步：重启本地 Open WebUI</strong></p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">docker compose -f docker-compose.local.yaml up -d --build</span><br></pre></td></tr></table></figure><p><strong>注意事项</strong></p><ul><li>Microsoft 也支持本地 <code>localhost</code> 回调</li><li>本地开发可以先用 <code>common</code></li><li>正式环境建议单独使用正式域名和正式应用注册</li></ul></div><div class="tab-item-content" id="oauth_local_setup-3"><p><strong>适用场景</strong></p><p>如果你的用户本身大量使用 GitHub，这是最容易理解、也最容易测试的一项。</p><p><strong>第 1 步：去 GitHub Developer Settings 创建 OAuth App</strong></p><p>访问：</p><p><a href="https://github.com/settings/developers">https://github.com/settings/developers</a></p><p>进入后：</p><ol><li>打开 <code>OAuth Apps</code></li><li>点击 <code>New OAuth App</code></li></ol><p><strong>第 2 步：填写回调地址</strong></p><p>最关键的是 <code>Authorization callback URL</code>：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">http://localhost:3000/oauth/github/login/callback</span><br></pre></td></tr></table></figure><p><strong>第 3 步：把密钥填入 <code>.env.local</code></strong></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></pre></td><td class="code"><pre><span class="line">GITHUB_CLIENT_ID=你的 GitHub Client ID</span><br><span class="line">GITHUB_CLIENT_SECRET=你的 GitHub Client Secret</span><br><span class="line">GITHUB_CLIENT_REDIRECT_URI=http://localhost:3000/oauth/github/login/callback</span><br></pre></td></tr></table></figure><p><strong>第 4 步：重启本地 Open WebUI</strong></p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">docker compose -f docker-compose.local.yaml up -d --build</span><br></pre></td></tr></table></figure><p><strong>注意事项</strong></p><ul><li>GitHub 对 callback URL 也是精确匹配</li><li>如果后台填的是 <code>127.0.0.1</code>，本地配置也要统一成 <code>127.0.0.1</code></li><li>不要后台填 <code>127.0.0.1</code>，本地却写 <code>localhost</code></li></ul></div></div><div class="tab-to-top"><button type="button" aria-label="scroll to top"><i class="anzhiyufont anzhiyu-icon-arrow-up"></i></button></div></div><p>为了更容易排错，我建议不要一上来三个一起配，而是按这个顺序：</p><ol><li>先配一个，例如 GitHub</li><li>测通之后，再加 Google</li><li>最后再加 Microsoft</li></ol><p>这样如果出现问题，最容易定位。</p><p>最常见的报错就是：</p><ul><li><code>redirect_uri_mismatch</code></li><li>端口改了，但开放平台后台没改</li><li><code>.env.local</code> 改了，但容器没重启</li><li>后台写的是 <code>127.0.0.1</code>，本地写的是 <code>localhost</code></li></ul><h3 id="SCIM-自动开通与回收">SCIM 自动开通与回收</h3><p><strong>这是什么</strong></p><p>SCIM 不是登录协议，而是“账号生命周期同步协议”。</p><p>它解决的问题是：</p><ul><li>新员工入职时，自动在 Open WebUI 创建账号</li><li>员工信息变化时，自动同步更新</li><li>员工离职时，自动停用账号</li><li>用户组成员关系自动同步</li></ul><p>所以可以把它理解成“自动开账号、改账号、停账号”的标准接口。</p><p><strong>和 OAuth / LDAP 的关系</strong></p><ul><li>LDAP / OAuth / OIDC：解决“怎么登录”</li><li>SCIM：解决“账号怎么自动创建和同步”</li></ul><p>很多企业会同时使用：</p><ul><li>用 OIDC 登录</li><li>用 SCIM 做账号与组同步</li></ul><p><strong>当前版本配置</strong></p><figure class="highlight yaml"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><span class="line"><span class="attr">environment:</span></span><br><span class="line">  <span class="bullet">-</span> <span class="string">SCIM_ENABLED=true</span></span><br><span class="line">  <span class="bullet">-</span> <span class="string">SCIM_TOKEN=your-secure-random-token</span></span><br><span class="line">  <span class="bullet">-</span> <span class="string">SCIM_AUTH_PROVIDER=oidc</span></span><br></pre></td></tr></table></figure><p><strong>参数解释</strong></p><table><thead><tr><th>参数</th><th>说明</th></tr></thead><tbody><tr><td><code>SCIM_ENABLED</code></td><td>是否启用 SCIM</td></tr><tr><td><code>SCIM_TOKEN</code></td><td>调用 SCIM API 的 Bearer Token</td></tr><tr><td><code>SCIM_AUTH_PROVIDER</code></td><td>用于把 SCIM <code>externalId</code> 和对应认证提供商关联起来，例如 <code>microsoft</code>、<code>oidc</code></td></tr></tbody></table><p>这里的 <code>SCIM_AUTH_PROVIDER</code> 很容易被漏掉，但当前版本里它是重要配置，尤其是在账户关联和 <code>externalId</code> 保存上。</p><p><strong>对接端点</strong></p><p>SCIM Base URL：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">https://your-domain.com/api/v1/scim/v2/</span><br></pre></td></tr></table></figure><p>常见资源端点：</p><table><thead><tr><th>资源</th><th>端点</th></tr></thead><tbody><tr><td>用户</td><td><code>/api/v1/scim/v2/Users</code></td></tr><tr><td>组</td><td><code>/api/v1/scim/v2/Groups</code></td></tr></tbody></table><p><strong>我的管理方式</strong></p><p>我把 SCIM 视为“企业级增强项”：</p><ul><li>小规模自用：完全不需要</li><li>团队规模不大，但已有统一登录：先上 OAuth / LDAP，SCIM 可以以后再说</li><li>企业正式上生产：如果人事变动频繁、合规要求高，就值得上 SCIM</li></ul><p>它的价值不在“让用户多一个登录按钮”，而在于减少人工维护账号、降低离职账号遗留风险。</p><h3 id="会话、JWT-与-Cookie">会话、JWT 与 Cookie</h3><p><strong>这是什么</strong></p><p>用户在浏览器里登录后，Open WebUI 需要一种机制记住登录状态，这通常会涉及：</p><ul><li><strong>JWT</strong>：登录令牌</li><li><strong>Session Cookie</strong>：浏览器保存的登录凭证</li><li><strong>Secret Key</strong>：服务器用来签名和加密敏感数据的密钥</li></ul><p><strong>当前版本重点配置</strong></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><span class="line"><span class="attr">environment:</span></span><br><span class="line">  <span class="bullet">-</span> <span class="string">WEBUI_SECRET_KEY=your-persistent-secret-key</span></span><br><span class="line">  <span class="bullet">-</span> <span class="string">JWT_EXPIRES_IN=4w</span></span><br><span class="line">  <span class="bullet">-</span> <span class="string">WEBUI_SESSION_COOKIE_SAME_SITE=Lax</span></span><br><span class="line">  <span class="bullet">-</span> <span class="string">WEBUI_SESSION_COOKIE_SECURE=true</span></span><br></pre></td></tr></table></figure><p><strong>参数解释</strong></p><table><thead><tr><th>参数</th><th>说明</th></tr></thead><tbody><tr><td><code>WEBUI_SECRET_KEY</code></td><td>最关键的持久化密钥，用于会话签名和敏感数据解密</td></tr><tr><td><code>JWT_EXPIRES_IN</code></td><td>登录令牌过期时间，例如 <code>4w</code>、<code>7d</code></td></tr><tr><td><code>WEBUI_SESSION_COOKIE_SAME_SITE</code></td><td>Cookie 的跨站策略</td></tr><tr><td><code>WEBUI_SESSION_COOKIE_SECURE</code></td><td>是否只通过 HTTPS 发送 Cookie</td></tr></tbody></table><p><strong>为什么 <code>WEBUI_SECRET_KEY</code> 非常重要</strong></p><p>官网 FAQ 专门提到，如果你每次重建容器都不保留同一个 <code>WEBUI_SECRET_KEY</code>，会出现两类典型问题：</p><ul><li>用户升级或重启后被全部强制退出</li><li>一些已经加密保存的令牌、API Key、OAuth 凭证无法解密</li></ul><p>所以这个值一定要固定，不要让容器每次随机生成。</p><p><strong>我的管理方式</strong></p><p>这一点我会非常保守：</p><ol><li>生产环境固定设置 <code>WEBUI_SECRET_KEY</code></li><li>HTTPS 环境强制 <code>WEBUI_SESSION_COOKIE_SECURE=true</code></li><li>不把 <code>JWT_EXPIRES_IN</code> 设成无限期</li><li>升级容器前先确认密钥仍然通过同样方式注入</li></ol><p>这是最容易被忽略、但一出问题就会直接影响所有用户登录体验的一块。</p><hr></div>]]></content>
    
    
      
      
        
        
    <summary type="html">&lt;div id=&quot;postchat_postcontent&quot;&gt;&lt;h2 id=&quot;3-9-安全与认证配置&quot;&gt;3.9. 安全与认证配置&lt;/h2&gt;
&lt;p&gt;这一章讲的是“谁能登录、谁能调用</summary>
        
      
    
    
    
    <category term="一人公司系列" scheme="https://prorise666.site/categories/%E4%B8%80%E4%BA%BA%E5%85%AC%E5%8F%B8%E7%B3%BB%E5%88%97/"/>
    
    <category term="办公提效方向" scheme="https://prorise666.site/categories/%E4%B8%80%E4%BA%BA%E5%85%AC%E5%8F%B8%E7%B3%BB%E5%88%97/%E5%8A%9E%E5%85%AC%E6%8F%90%E6%95%88%E6%96%B9%E5%90%91/"/>
    
    <category term="OpenWebUi" scheme="https://prorise666.site/categories/%E4%B8%80%E4%BA%BA%E5%85%AC%E5%8F%B8%E7%B3%BB%E5%88%97/%E5%8A%9E%E5%85%AC%E6%8F%90%E6%95%88%E6%96%B9%E5%90%91/OpenWebUi/"/>
    
    
  </entry>
  
  <entry>
    <title>OpenWebUi-Pipelines 与 Functions 扩展系统</title>
    <link href="https://prorise666.site/posts/437.html"/>
    <id>https://prorise666.site/posts/437.html</id>
    <published>2026-02-27T10:38:17.000Z</published>
    <updated>2026-03-12T09:18:16.915Z</updated>
    
    <content type="html"><![CDATA[<div id="postchat_postcontent"><h2 id="3-8-Pipelines-与-Functions-扩展系统">3.8. Pipelines 与 Functions 扩展系统</h2><p>Open WebUI 提供了两种扩展机制：<strong>Functions</strong>（函数）和 <strong>Pipelines</strong>（管道）。理解它们的区别非常重要，选错了会让简单的事情变复杂。</p><blockquote><p>⚠️ <strong>官方明确建议</strong>：对于大多数扩展需求（如添加新的 API 提供商、基础过滤器、简单工具），<strong>请使用 Functions，不要使用 Pipelines</strong>。Pipelines 仅适用于需要将计算密集型任务卸载到独立进程的场景。</p></blockquote><h3 id="Functions-vs-Pipelines：如何选择">Functions vs Pipelines：如何选择</h3><table><thead><tr><th>对比项</th><th>Functions（推荐优先）</th><th>Pipelines</th></tr></thead><tbody><tr><td><strong>部署方式</strong></td><td>内置于 Open WebUI，无需额外服务</td><td>需要独立部署 Pipelines 服务</td></tr><tr><td><strong>适用场景</strong></td><td>添加 API 提供商、过滤器、工具、按钮动作</td><td>计算密集型任务（如大规模数据处理、自定义 ML 推理）</td></tr><tr><td><strong>管理方式</strong></td><td>管理员面板 → 函数</td><td>管理员面板 → 设置 → Pipelines</td></tr><tr><td><strong>开发难度</strong></td><td>简单，直接在 Web 界面编写</td><td>较复杂，需要独立服务和 Docker 部署</td></tr><tr><td><strong>性能影响</strong></td><td>在主进程中运行</td><td>独立进程，不影响主服务</td></tr></tbody></table><p><strong>简单判断规则</strong>：如果你不确定该用哪个，<strong>用 Functions</strong>。只有当你明确需要在独立进程中运行计算密集型任务时，才考虑 Pipelines。</p><h3 id="Functions（函数）">Functions（函数）</h3><p>Functions 是 Open WebUI 内置的扩展机制，直接在管理员面板中管理，无需额外部署。</p><p><strong>Functions 的四种类型</strong>：</p><table><thead><tr><th>类型</th><th>说明</th><th>使用场景</th></tr></thead><tbody><tr><td><strong>Filter</strong></td><td>在消息发送到模型前/后进行处理</td><td>内容过滤、格式转换、日志记录</td></tr><tr><td><strong>Action</strong></td><td>在消息气泡上添加自定义按钮</td><td>一键翻译、一键总结、复制格式化内容</td></tr><tr><td><strong>Tool</strong></td><td>为模型提供可调用的工具</td><td>网络搜索、数据库查询、API 调用</td></tr><tr><td><strong>Pipe</strong></td><td>添加新的模型端点或 API 提供商</td><td>接入自定义 API、代理转发</td></tr></tbody></table><p><strong>管理 Functions</strong>：</p><p>管理员面板 → 函数（Functions）</p><p>在这里你可以：</p><ul><li>创建新函数（直接在 Web 编辑器中编写 Python 代码）</li><li>从社区导入函数</li><li>启用/禁用函数</li><li>配置函数参数（Valves）</li></ul><p><strong>社区函数库</strong>：</p><p>Open WebUI 社区提供了大量现成的函数，访问 <a href="https://openwebui.com/functions/">https://openwebui.com/functions/</a> 浏览和导入。</p><blockquote><p>💡 Functions 的详细开发将在后续章节中介绍。本节重点是让管理员了解如何管理和配置。</p></blockquote><h3 id="Pipelines（仅限计算密集型场景）">Pipelines（仅限计算密集型场景）</h3><p>如果你确实需要将计算密集型任务卸载到独立进程，才需要部署 Pipelines。</p><p><strong>部署 Pipelines 服务</strong>：</p><p>在你的 <code>docker-compose.local.yaml</code> 中添加 Pipelines 服务：</p><figure class="highlight yaml"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br></pre></td><td class="code"><pre><span class="line"><span class="attr">services:</span></span><br><span class="line">  <span class="attr">pipelines:</span></span><br><span class="line">    <span class="attr">image:</span> <span class="string">ghcr.io/open-webui/pipelines:main</span></span><br><span class="line">    <span class="attr">container_name:</span> <span class="string">pipelines</span></span><br><span class="line">    <span class="attr">ports:</span></span><br><span class="line">      <span class="bullet">-</span> <span class="string">&quot;9099:9099&quot;</span></span><br><span class="line">    <span class="attr">extra_hosts:</span></span><br><span class="line">      <span class="bullet">-</span> <span class="string">host.docker.internal:host-gateway</span></span><br><span class="line">    <span class="attr">volumes:</span></span><br><span class="line">      <span class="bullet">-</span> <span class="string">pipelines-data:/app/pipelines</span></span><br><span class="line">    <span class="attr">restart:</span> <span class="string">unless-stopped</span></span><br><span class="line"></span><br><span class="line">  <span class="attr">open-webui:</span></span><br><span class="line">    <span class="comment"># ... 你的现有配置</span></span><br><span class="line">    <span class="attr">environment:</span></span><br><span class="line">      <span class="bullet">-</span> <span class="string">PIPELINES_URLS=http://pipelines:9099</span></span><br><span class="line"></span><br><span class="line"><span class="attr">volumes:</span></span><br><span class="line">  <span class="attr">pipelines-data:</span></span><br></pre></td></tr></table></figure><p>启动后访问 <a href="http://localhost:9099/docs">http://localhost:9099/docs</a> 验证 Pipelines API 是否正常。</p><p><strong>在 Open WebUI 中连接</strong>：</p><p>管理员面板 → 设置 → Pipelines → 填入 <code>http://pipelines:9099</code> → 点击刷新</p><p><strong>管理插件</strong>：</p><p>连接成功后，你可以：</p><ul><li>上传 Python 插件文件（<code>.py</code>）</li><li>启用/禁用插件</li><li>配置插件参数（Valves）</li></ul><p><strong>Pipelines 插件示例</strong>：</p><p>官方示例仓库：<a href="https://github.com/open-webui/pipelines/tree/main/examples">https://github.com/open-webui/pipelines/tree/main/examples</a></p><table><thead><tr><th>插件名称</th><th>功能</th><th>使用场景</th></tr></thead><tbody><tr><td><strong>Langfuse</strong></td><td>集成 Langfuse 监控平台</td><td>大规模使用量监控</td></tr><tr><td><strong>LLM Guard</strong></td><td>防止提示词注入攻击</td><td>安全防护（计算密集）</td></tr><tr><td><strong>Detoxify</strong></td><td>基于 ML 模型的有害内容过滤</td><td>内容安全（需要 GPU）</td></tr></tbody></table><blockquote><p>💡 像 Rate Limit（限流）、LibreTranslate（翻译）这类轻量级功能，现在推荐使用 Functions 实现，不再需要部署 Pipelines。</p></blockquote><hr></div>]]></content>
    
    
      
      
        
        
    <summary type="html">&lt;div id=&quot;postchat_postcontent&quot;&gt;&lt;h2 id=&quot;3-8-Pipelines-与-Functions-扩展系统&quot;&gt;3.8. Pipelines 与 Functions 扩展系统&lt;/h2&gt;
&lt;p&gt;Open WebUI</summary>
        
      
    
    
    
    <category term="一人公司系列" scheme="https://prorise666.site/categories/%E4%B8%80%E4%BA%BA%E5%85%AC%E5%8F%B8%E7%B3%BB%E5%88%97/"/>
    
    <category term="办公提效方向" scheme="https://prorise666.site/categories/%E4%B8%80%E4%BA%BA%E5%85%AC%E5%8F%B8%E7%B3%BB%E5%88%97/%E5%8A%9E%E5%85%AC%E6%8F%90%E6%95%88%E6%96%B9%E5%90%91/"/>
    
    <category term="OpenWebUi" scheme="https://prorise666.site/categories/%E4%B8%80%E4%BA%BA%E5%85%AC%E5%8F%B8%E7%B3%BB%E5%88%97/%E5%8A%9E%E5%85%AC%E6%8F%90%E6%95%88%E6%96%B9%E5%90%91/OpenWebUi/"/>
    
    
  </entry>
  
  <entry>
    <title>OpenWebUi-网络搜索功能配置</title>
    <link href="https://prorise666.site/posts/3060.html"/>
    <id>https://prorise666.site/posts/3060.html</id>
    <published>2026-02-27T09:38:17.000Z</published>
    <updated>2026-03-12T09:18:16.915Z</updated>
    
    <content type="html"><![CDATA[<div id="postchat_postcontent"><h2 id="3-7-网络搜索功能配置">3.7. 网络搜索功能配置</h2><p>网络搜索功能让 AI 能够获取最新的网络信息，突破模型训练数据的时效性限制。用户在对话中开启&quot;联网搜索&quot;开关后，Open WebUI 会先调用搜索引擎获取实时结果，再注入到 LLM 上下文中辅助回答。</p><p><strong>配置入口</strong>：管理员面板 → 设置 → 联网搜索</p><hr><h3 id="配置项说明">配置项说明</h3><table><thead><tr><th>配置项</th><th>说明</th><th>推荐值</th></tr></thead><tbody><tr><td><strong>启用联网搜索</strong></td><td>总开关，关闭时所有用户均无法使用</td><td>按需开启</td></tr><tr><td><strong>搜索引擎</strong></td><td>下拉选择提供商，选择后下方动态显示该引擎所需字段（Key、URL 等）</td><td>见下方选型</td></tr><tr><td><strong>搜索结果数量</strong></td><td>每次返回的结果条数，太少信息不足，太多增加 token 消耗</td><td>5-8</td></tr><tr><td><strong>并发数</strong></td><td>同时抓取结果页面的并发数，过高可能触发速率限制</td><td>默认即可</td></tr><tr><td><strong>旁路 SSL 验证</strong></td><td>跳过 SSL 证书验证，仅自部署引擎用自签名证书时开启</td><td>关闭</td></tr></tbody></table><hr><h3 id="全部搜索引擎一览">全部搜索引擎一览</h3><p><strong>🟢 完全免费（自部署或无需 Key）</strong></p><ul><li><strong>DuckDuckGo</strong>（<code>duckduckgo</code>）— 零配置开箱即用，无需 API Key，通过 Python 库直接调用。缺点：可能被速率限制，国内需代理</li><li><strong>SearXNG</strong>（<code>searxng</code>）— 🏆 <strong>社区最推荐</strong>。开源元搜索引擎，聚合 Google、Bing 等 70+ 引擎结果，需 Docker 自部署，完全免费、无限次数、隐私安全</li><li><strong>YaCy</strong>（<code>yacy</code>）— 去中心化 P2P 搜索引擎，完全自托管，搜索质量远不如 SearXNG</li><li><strong>Ollama Cloud</strong>（<code>ollama_cloud</code>）— 用本地 Ollama 模型生成搜索，完全离线但质量有限</li></ul><p><strong>🔵 有免费额度（白嫖友好，需注册获取 Key）</strong></p><ul><li><strong>Serper</strong>（<code>serper</code>）— 🏆 <strong>性价比之王</strong>。基于 Google SERP，注册送 2,500 次（一次性），付费 $0.30/千次起，速度极快</li><li><strong>Brave</strong>（<code>brave</code>）— 每月 2,000 次免费（每月刷新），独立搜索索引，隐私友好，超出后 $3/千次</li><li><strong>Tavily</strong>（<code>tavily</code>）— 每月 1,000 次免费，专为 AI/RAG 设计，返回干净文本，超出后 $0.008/次</li><li><strong>Exa</strong>（<code>exa</code>）— 注册送 $10 额度（约 2,000 次），AI 原生语义搜索，超出后 $5/千次</li><li><strong>Google PSE</strong>（<code>google_pse</code>）— 每天 100 次免费（约 3,000 次/月），搜索质量就是 Google 本身，超出后 $5/千次</li><li><strong>Firecrawl</strong>（<code>firecrawl</code>）— 一次性 500 次免费，搜索 + 深度抓取网页内容，超出后 $16/月起</li><li><strong>SearchApi</strong>（<code>searchapi</code>）— 注册送 100 次，支持 Google/Bing/Baidu/Scholar 多引擎切换，超出后 $50/月起</li><li><strong>Serpstack</strong>（<code>serpstack</code>）— 每月 100 次免费，基于 Google SERP，超出后 $30/月起</li><li><strong>SerpApi</strong>（<code>serpapi</code>）— 每月 100 次免费，功能最全但价格偏高 $25/月起</li><li><strong>Jina</strong>（<code>jina</code>）— 注册送积分，支持语义搜索和网页内容提取，适合 RAG 场景</li></ul><p><strong>🟡 纯付费（无免费额度或需订阅）</strong></p><ul><li><strong>Bing</strong>（<code>bing</code>）— ⚠️ 微软已于 2025 年 8 月宣布退役，不推荐新用户</li><li><strong>Kagi</strong>（<code>kagi</code>）— $10/月起订阅制，高质量无广告搜索</li><li><strong>Mojeek</strong>（<code>mojeek</code>）— 英国独立搜索引擎，有自己的爬虫索引，需联系定价</li><li><strong>Serply</strong>（<code>serply</code>）— $49/月起，性价比不高</li><li><strong>Bocha</strong>（<code>bocha</code>）— 🇨🇳 国产博查搜索，国内可直连，需联系定价</li><li><strong>Sogou</strong>（<code>sougou</code>）— 🇨🇳 搜狗搜索，国内可直连，需联系定价</li><li><strong>Yandex</strong>（<code>yandex</code>）— 俄罗斯搜索引擎，俄语搜索质量好</li></ul><p><strong>🟣 AI 增强搜索</strong></p><ul><li><strong>Perplexity</strong>（<code>perplexity</code>）— Sonar API，搜索 + AI 总结，Pro 订阅每月附赠 $5 额度</li><li><strong>Perplexity Search</strong>（<code>perplexity_search</code>）— 同上，纯搜索不含 AI 总结</li><li><strong>External</strong>（<code>external</code>）— 万能逃生舱，指向任意自定义 HTTP 搜索服务</li></ul><blockquote><p>💡 以上除 Bocha、Sogou 外，其余引擎均需代理才能在国内访问。</p></blockquote><hr><h3 id="选型推荐与部署">选型推荐与部署</h3><p><strong>🏆 最优解：SearXNG 自部署</strong></p><p>适合有服务器的个人/团队，零成本、无限次数、隐私安全。社区公认的最佳方案。</p><p>部署步骤：</p><ol><li>克隆仓库并进入目录：</li></ol><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><span class="line">git <span class="built_in">clone</span> https://github.com/searxng/searxng-docker.git</span><br><span class="line"><span class="built_in">cd</span> searxng-docker</span><br></pre></td></tr></table></figure><ol start="2"><li>修改 <code>settings.yml</code>（最关键一步，必须启用 JSON 格式，否则 Open WebUI 报 403）：</li></ol><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></pre></td><td class="code"><pre><span class="line"><span class="attr">use_default_settings:</span> <span class="literal">true</span></span><br><span class="line"></span><br><span class="line"><span class="attr">server:</span></span><br><span class="line">  <span class="attr">secret_key:</span> <span class="string">&quot;your-random-secret-key&quot;</span>  <span class="comment"># 必须修改</span></span><br><span class="line">  <span class="attr">limiter:</span> <span class="literal">false</span>  <span class="comment"># 关闭速率限制，避免被限流</span></span><br><span class="line">  <span class="attr">image_proxy:</span> <span class="literal">true</span></span><br><span class="line"></span><br><span class="line"><span class="attr">search:</span></span><br><span class="line">  <span class="attr">safe_search:</span> <span class="number">0</span></span><br><span class="line">  <span class="attr">autocomplete:</span> <span class="string">&quot;&quot;</span></span><br><span class="line">  <span class="attr">default_lang:</span> <span class="string">&quot;zh-CN&quot;</span></span><br><span class="line">  <span class="attr">formats:</span></span><br><span class="line">    <span class="bullet">-</span> <span class="string">html</span></span><br><span class="line">    <span class="bullet">-</span> <span class="string">json</span>  <span class="comment"># ⚠️ 必须添加，否则 Open WebUI 无法调用</span></span><br></pre></td></tr></table></figure><ol start="3"><li>启动：<code>docker compose up -d</code>，或在 Open WebUI 的 <code>docker-compose.local.yaml</code> 中添加：</li></ol><figure class="highlight yaml"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br></pre></td><td class="code"><pre><span class="line"><span class="attr">services:</span></span><br><span class="line">  <span class="attr">searxng:</span></span><br><span class="line">    <span class="attr">image:</span> <span class="string">searxng/searxng:latest</span></span><br><span class="line">    <span class="attr">container_name:</span> <span class="string">searxng</span></span><br><span class="line">    <span class="attr">ports:</span></span><br><span class="line">      <span class="bullet">-</span> <span class="string">&quot;8080:8080&quot;</span></span><br><span class="line">    <span class="attr">volumes:</span></span><br><span class="line">      <span class="bullet">-</span> <span class="string">./searxng:/etc/searxng</span></span><br><span class="line">    <span class="attr">restart:</span> <span class="string">unless-stopped</span></span><br></pre></td></tr></table></figure><ol start="4"><li>在 Open WebUI 中配置：搜索引擎选 <code>searxng</code>，查询 URL 填 <code>http://searxng:8080/search?q=&lt;query&gt;</code>（Docker 同网络）或 <code>http://localhost:8080/search?q=&lt;query&gt;</code>。</li></ol><blockquote><p>⚠️ 常见问题：</p><ul><li><strong>403 错误</strong>：99% 是 <code>settings.yml</code> 没加 <code>json</code> 格式</li><li><strong>结果为空</strong>：SearXNG 容器需能访问外网，国内服务器需为 SearXNG 容器配代理</li><li><strong>超时</strong>：检查 <code>limiter</code> 是否为 <code>false</code>，上游搜索引擎是否可达</li></ul></blockquote><p><strong>💰 零成本懒人方案：DuckDuckGo</strong></p><p>不想部署任何服务的用户，搜索引擎选 <code>duckduckgo</code> 即可，无需填写任何 Key。缺点是可能被限流、国内需代理、搜索质量不如 Google。</p><p><strong>🎯 付费性价比之选：Serper</strong></p><p>需要 Google 级搜索质量但预算有限的用户。访问 <a href="https://serper.dev">https://serper.dev</a> 注册，复制 API Key，搜索引擎选 <code>serper</code> 填入即可。注册送 2,500 次，付费后 $0.30/千次起。</p><p><strong>🇨🇳 国内直连方案：Bocha / Sogou</strong></p><p>服务器在国内、无法配代理的用户。Bocha（博查）和 Sogou（搜狗）国内可直连，中文搜索质量较好，需联系服务商获取 Key 和定价。</p><hr><h3 id="成本速算">成本速算</h3><p>假设日均搜索 20 次（月均 600 次）：</p><table><thead><tr><th>方案</th><th>月成本</th><th>说明</th></tr></thead><tbody><tr><td>SearXNG 自部署</td><td>$0</td><td>仅消耗服务器资源</td></tr><tr><td>DuckDuckGo</td><td>$0</td><td>可能被限流</td></tr><tr><td>Brave 免费额度</td><td>$0</td><td>每月 2,000 次，完全够用</td></tr><tr><td>Google PSE 免费额度</td><td>$0</td><td>每天 100 次，完全够用</td></tr><tr><td>Tavily 免费额度</td><td>$0</td><td>每月 1,000 次，勉强够用</td></tr><tr><td>Serper 免费额度</td><td>$0</td><td>2,500 次一次性，约可用 4 个月</td></tr><tr><td>Serper 付费</td><td>~$0.18</td><td>超出免费额度后</td></tr><tr><td>Brave 付费</td><td>~$1.80</td><td>超出免费额度后</td></tr><tr><td>Kagi</td><td>$10+</td><td>订阅制</td></tr></tbody></table><blockquote><p>💡 个人使用（日均 &lt;30 次），Brave（2,000 次/月）或 Google PSE（100 次/天）的免费额度完全够用。团队或高频搜索，SearXNG 自部署是唯一真正无限制的方案。</p></blockquote><hr></div>]]></content>
    
    
      
      
        
        
    <summary type="html">&lt;div id=&quot;postchat_postcontent&quot;&gt;&lt;h2 id=&quot;3-7-网络搜索功能配置&quot;&gt;3.7. 网络搜索功能配置&lt;/h2&gt;
&lt;p&gt;网络搜索功能让 AI</summary>
        
      
    
    
    
    <category term="一人公司系列" scheme="https://prorise666.site/categories/%E4%B8%80%E4%BA%BA%E5%85%AC%E5%8F%B8%E7%B3%BB%E5%88%97/"/>
    
    <category term="办公提效方向" scheme="https://prorise666.site/categories/%E4%B8%80%E4%BA%BA%E5%85%AC%E5%8F%B8%E7%B3%BB%E5%88%97/%E5%8A%9E%E5%85%AC%E6%8F%90%E6%95%88%E6%96%B9%E5%90%91/"/>
    
    <category term="OpenWebUi" scheme="https://prorise666.site/categories/%E4%B8%80%E4%BA%BA%E5%85%AC%E5%8F%B8%E7%B3%BB%E5%88%97/%E5%8A%9E%E5%85%AC%E6%8F%90%E6%95%88%E6%96%B9%E5%90%91/OpenWebUi/"/>
    
    
  </entry>
  
  <entry>
    <title>OpenWebUi-语音功能配置</title>
    <link href="https://prorise666.site/posts/26838.html"/>
    <id>https://prorise666.site/posts/26838.html</id>
    <published>2026-02-27T08:38:17.000Z</published>
    <updated>2026-03-12T09:18:16.915Z</updated>
    
    <content type="html"><![CDATA[<div id="postchat_postcontent"><h2 id="3-6-语音功能配置">3.6. 语音功能配置</h2><p>Open WebUI 的语音交互由两部分组成：<strong>听（STT，语音转文字）</strong> 和 <strong>说（TTS，文字转语音）</strong>。合理的配置需要在“响应速度、拟真体验、实际花销”三者之间找到平衡。</p><h3 id="语音转文字（STT）配置">语音转文字（STT）配置</h3><p>STT 决定了系统“听得有多准”和“听得有多快”，以及你聊天的成本消耗，由于语音转文字本质拉不开很大差距，一般来说都会使用网页API 或选择 Whisper 作为使用</p><h4 id="1-核心引擎横向对比与实际计费">1. 核心引擎横向对比与实际计费</h4><table><thead><tr><th>引擎选型</th><th>成本梯队</th><th>实际计费标准 (预估)</th><th>核心优势</th></tr></thead><tbody><tr><td><strong>网页 API</strong></td><td>免费</td><td><strong>$0</strong> (利用浏览器原生能力)</td><td>服务器零负载，响应极快，零成本</td></tr><tr><td><strong>Whisper (本地)</strong></td><td>免费</td><td><strong>$0</strong> (仅消耗本机/服务器电费)</td><td>隐私最强，完全离线，不按时长收费</td></tr><tr><td><strong>Deepgram</strong></td><td>低成本</td><td><strong>约 $0.0043 / 分钟</strong></td><td>专为实时语音设计，延迟极低，性价比极高</td></tr><tr><td><strong>OpenAI</strong></td><td>适中</td><td><strong>$0.006 / 分钟</strong></td><td>业界标杆，多语言混合识别极准，无需额外注册</td></tr><tr><td><strong>Azure AI 语音</strong></td><td>偏高</td><td><strong>约 $1.00 / 小时</strong> ($0.016/分)</td><td>微软企业级稳定服务，带口音的方言识别优秀</td></tr></tbody></table><h4 id="2-深度选型与成本解析">2. 深度选型与成本解析</h4><ul><li><p><strong>完全免费且省心：网页 API (Web API)</strong></p></li><li><p><strong>计费逻辑</strong>：绝对免费。它调用的是你当前所用浏览器（如 Chrome）内置的语音识别接口。</p></li><li><p><strong>适用场景</strong>：预算为零，服务器没有显卡（GPU）跑不动本地模型，且能保证全程使用 HTTPS 访问的用户。</p></li><li><p><strong>免费但吃硬件：Whisper (本地)</strong></p></li><li><p><strong>计费逻辑</strong>：软件层面免费，但<strong>隐性成本在硬件</strong>。它会占用你服务器的 CPU 和显存。如果租用云服务器，为了跑顺畅可能需要升级高配实例。</p></li><li><p><strong>选型建议</strong>：如果你的服务器本身配置就高（如拥有 8GB 以上显存的独立显卡），强烈建议选这个。数据不出局域网，隐私绝对安全。</p></li><li><p><strong>云端高性价比方案：Deepgram</strong></p></li><li><p><strong>计费逻辑</strong>：按秒计费，极其便宜。折算下来一小时一直说话也才两毛多美元。</p></li><li><p><strong>选型建议</strong>：如果你需要高频使用语音对话，Deepgram 的 Nova-2 模型是<strong>首选</strong>。它的转录速度远快于 OpenAI，能大幅降低你等 AI 回复的“空窗期”。</p></li><li><p><strong>高质量兜底方案：OpenAI Whisper</strong></p></li><li><p><strong>计费逻辑</strong>：按分钟计费。如果你每天和 AI 聊 10 分钟语音，一个月大约花费 $1.8。</p></li><li><p><strong>选型建议</strong>：如果你平时说话中英文夹杂，或者专业术语多，OpenAI 的容错和纠错能力是目前云端 API 里最好的。</p></li></ul><hr><h3 id="文字转语音（TTS）配置">文字转语音（TTS）配置</h3><p>TTS 决定了 AI 的“音色”和“情感”，这项功能由于需要生成音频文件，通常比 STT 更贵。</p><h4 id="1-核心引擎横向对比与实际计费-2">1. 核心引擎横向对比与实际计费</h4><table><thead><tr><th>引擎选型</th><th>成本梯队</th><th>实际计费标准 (预估)</th><th>拟真度</th></tr></thead><tbody><tr><td><strong>网页 API</strong></td><td>免费</td><td><strong>$0</strong> (调用系统内置 TTS)</td><td>⭐⭐ (明显机械音)</td></tr><tr><td><strong>openai-edge-tts</strong> 🏆</td><td>免费</td><td><strong>$0</strong> (微软 Edge 在线语音，中间件伪装)</td><td>⭐⭐⭐⭐⭐ (中文场景极佳，接近 OpenAI)</td></tr><tr><td><strong>Transformers</strong></td><td>免费</td><td><strong>$0</strong> (消耗本地算力生成)</td><td>⭐⭐⭐ (略带顿挫感)</td></tr><tr><td><strong>OpenAI</strong></td><td>低成本</td><td><strong>$0.015 / 千字符</strong></td><td>⭐⭐⭐⭐ (非常自然流畅)</td></tr><tr><td><strong>Azure AI 语音</strong></td><td>适中</td><td><strong>约 $0.016 / 千字符</strong></td><td>⭐⭐⭐⭐ (专业播音腔，可选多)</td></tr><tr><td><strong>ElevenLabs</strong></td><td>昂贵</td><td><strong>约 $0.22 / 千字符</strong> (按标准套餐折算)</td><td>⭐⭐⭐⭐⭐ (情感天花板)</td></tr></tbody></table><h4 id="2-深度选型与成本解析-2">2. 深度选型与成本解析</h4><ul><li><p><strong>零成本测试首选：网页 API</strong></p></li><li><p><strong>计费逻辑</strong>：免费。直接让你的 Windows 或 macOS 系统里的&quot;讲述人&quot;来读出文字。</p></li><li><p><strong>体验</strong>：毫无感情，适合用来排查语音链路通不通，不适合长期对话。</p></li><li><p><strong>🏆 中文场景版本答案：openai-edge-tts（强烈推荐）</strong></p></li><li><p><strong>计费逻辑</strong>：完全免费。它通过一个开源中间件（<a href="https://github.com/travisvn/openai-edge-tts">travisvn/openai-edge-tts</a>，GitHub 1.6k+ Stars），将微软 Edge 浏览器内置的高质量在线语音接口伪装成 OpenAI TTS 接口给 Open WebUI 使用。你在抖音/TikTok 上听到的那些非常自然的 AI 解说音，用的就是同一套微软语音引擎。</p></li><li><p><strong>选型建议</strong>：<strong>面向国内中文用户的最佳选择</strong>。音质接近 OpenAI TTS，远超本地机械音，且完全免费、无需 GPU。Open WebUI 官方文档已有专门的集成页面。唯一注意点：它本质是微软云服务的代理，需要联网才能使用，不是真正的离线方案。</p></li><li><p><strong>部署步骤</strong>：</p><p><strong>第一步：启动 Docker 容器</strong></p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">docker run -d -p 5050:5050 -e API_KEY=your_password travisvn/openai-edge-tts:latest</span><br></pre></td></tr></table></figure><p><strong>第二步：在 Open WebUI 管理面板中配置</strong></p><p>进入 管理员面板 → 设置 → 语音，在 TTS 部分填写：</p><table><thead><tr><th>配置项</th><th>填写内容</th><th>说明</th></tr></thead><tbody><tr><td><strong>TTS 引擎</strong></td><td><code>OpenAI</code></td><td>注意：选 OpenAI，不是 Edge，因为中间件伪装成了 OpenAI 接口</td></tr><tr><td><strong>API 基础 URL</strong></td><td><code>http://host.docker.internal:5050/v1</code></td><td>如果 Open WebUI 也在 Docker 中运行；否则填 <code>http://localhost:5050/v1</code></td></tr><tr><td><strong>API 密钥</strong></td><td><code>your_password</code></td><td>与 Docker 启动时的 <code>API_KEY</code> 保持一致</td></tr><tr><td><strong>TTS 模型</strong></td><td><code>tts-1</code></td><td>固定值</td></tr><tr><td><strong>TTS 语音</strong></td><td><code>zh-CN-XiaoxiaoNeural</code></td><td>最受欢迎的中文女声；男声可选 <code>zh-CN-YunxiNeural</code></td></tr></tbody></table><blockquote><p><strong>💡 更多语音选择</strong>：Edge TTS 支持大量中文语音，如 <code>zh-CN-XiaoyiNeural</code>（年轻女声）、<code>zh-CN-YunjianNeural</code>（新闻播报男声）等，完整列表可在容器启动后访问 <code>http://localhost:5050/v1/voices</code> 查看。</p></blockquote><blockquote><p><strong>⚠️ 注意</strong>：此方案依赖微软在线服务，断网时无法使用。如果你需要完全离线的 TTS，请考虑 Transformers 本地方案或下方的 Kokoro-FastAPI。</p></blockquote></li><li><p><strong>补充：英文场景的替代方案 —— Kokoro-FastAPI</strong></p></li><li><p>如果你的用户主要使用英文对话，社区中另一个高口碑项目是 <a href="https://github.com/remsky/Kokoro-FastAPI">Kokoro-FastAPI</a>。它完全本地运行，英文语音质量被社区评为当前最佳，同样提供 OpenAI 兼容 API。但中文支持较弱，因此面向国内用户时 openai-edge-tts 仍是首选。</p></li><li><p><strong>极致性价比：OpenAI TTS</strong></p></li><li><p><strong>计费逻辑</strong>：按生成的字符数收费。1000 个英文字符或中文字大概只要 1 分多钱（美元）。即使重度使用，每个月也就几美元。</p></li><li><p><strong>选型建议</strong>：<strong>90% 用户的最佳选择</strong>。模型选 <code>tts-1</code> 即可（<code>tts-1-hd</code> 贵一倍且速度慢，对话时完全没必要）。声音推荐 <code>Alloy</code>（中性）或 <code>Nova</code>（活力）。</p></li><li><p><strong>如果你需要更高质量的选型（听觉享受）：ElevenLabs</strong></p></li><li><p><strong>计费逻辑</strong>：非常贵。采用订阅+额度制（如 $22/月 给 10 万字符），折算下来<strong>单价比 OpenAI 贵了 15 倍左右</strong>。</p></li><li><p><strong>为什么选它</strong>：物有所值。它是目前唯一能做到“根据上下文叹气、呼吸、调整情绪甚至哭腔”的 API。如果你把 AI 当作情感树洞，或者需要克隆特定人的声音，这笔钱花得值。</p></li></ul><hr></div>]]></content>
    
    
      
      
        
        
    <summary type="html">&lt;div id=&quot;postchat_postcontent&quot;&gt;&lt;h2 id=&quot;3-6-语音功能配置&quot;&gt;3.6. 语音功能配置&lt;/h2&gt;
&lt;p&gt;Open WebUI 的语音交互由两部分组成：&lt;strong&gt;听（STT，语音转文字）&lt;/strong&gt; 和</summary>
        
      
    
    
    
    <category term="一人公司系列" scheme="https://prorise666.site/categories/%E4%B8%80%E4%BA%BA%E5%85%AC%E5%8F%B8%E7%B3%BB%E5%88%97/"/>
    
    <category term="办公提效方向" scheme="https://prorise666.site/categories/%E4%B8%80%E4%BA%BA%E5%85%AC%E5%8F%B8%E7%B3%BB%E5%88%97/%E5%8A%9E%E5%85%AC%E6%8F%90%E6%95%88%E6%96%B9%E5%90%91/"/>
    
    <category term="OpenWebUi" scheme="https://prorise666.site/categories/%E4%B8%80%E4%BA%BA%E5%85%AC%E5%8F%B8%E7%B3%BB%E5%88%97/%E5%8A%9E%E5%85%AC%E6%8F%90%E6%95%88%E6%96%B9%E5%90%91/OpenWebUi/"/>
    
    
  </entry>
  
  <entry>
    <title>OpenWebUi-图像生成功能配置</title>
    <link href="https://prorise666.site/posts/12814.html"/>
    <id>https://prorise666.site/posts/12814.html</id>
    <published>2026-02-27T07:38:17.000Z</published>
    <updated>2026-03-12T09:18:16.915Z</updated>
    
    <content type="html"><![CDATA[<div id="postchat_postcontent"><h2 id="3-5-图像生成功能配置">3.5. 图像生成功能配置</h2><p>Open WebUI 支持集成多种图像生成工具，让 AI 能够根据文字描述生成图片。</p><h3 id="DALL-E-集成">DALL-E 集成</h3><p>DALL-E 是 OpenAI 的图像生成模型，质量高但需要付费。</p><p><strong>配置步骤</strong>：</p><p>管理员面板 → 设置 → Images</p><p>找到 “图像生成引擎” 选项，选择 “OpenAI DALL-E”。</p><p>填写配置：</p><table><thead><tr><th>字段</th><th>值</th></tr></thead><tbody><tr><td><strong>API Key</strong></td><td>你的 OpenAI API 密钥</td></tr><tr><td><strong>模型</strong></td><td>dall-e-3（推荐）或 dall-e-2</td></tr><tr><td><strong>图像尺寸</strong></td><td>1024x1024（标准）、1792x1024（宽屏）、1024x1792（竖屏）</td></tr><tr><td><strong>图像质量</strong></td><td>standard（标准）或 hd（高清，更贵）</td></tr></tbody></table><p><strong>费用说明</strong>：</p><ul><li>DALL-E 3 标准质量：$0.040 / 张</li><li>DALL-E 3 高清质量：$0.080 / 张</li><li>DALL-E 2：$0.020 / 张</li></ul><h3 id="ComfyUI-集成">ComfyUI 集成</h3><p>ComfyUI 是一个开源的图像生成工作流工具，支持 Stable Diffusion 等模型。</p><p><strong>前置要求</strong>：</p><p>你需要先部署 ComfyUI 服务。ComfyUI 的部署超出本教程范围，请参考 ComfyUI 官方文档。</p><p><strong>配置步骤</strong>：</p><p>管理员面板 → 设置 → Images → 选择 “ComfyUI”</p><p>填写配置：</p><table><thead><tr><th>字段</th><th>说明</th></tr></thead><tbody><tr><td><strong>ComfyUI Base URL</strong></td><td>ComfyUI 服务地址，如 <code>http://localhost:8188</code></td></tr><tr><td><strong>工作流 JSON</strong></td><td>ComfyUI 的工作流配置文件</td></tr></tbody></table><p><strong>工作流配置</strong>：</p><p>ComfyUI 使用 JSON 格式的工作流文件来定义图像生成流程。你需要：</p><ol><li>在 ComfyUI 中设计好工作流</li><li>导出为 JSON 文件</li><li>将 JSON 内容粘贴到 Open WebUI 的配置中</li></ol><p><strong>优势</strong>：</p><ul><li><p>完全免费（使用本地模型）</p></li><li><p>完全可控，可以自定义各种参数</p></li><li><p>支持多种 Stable Diffusion 模型</p></li></ul><p><strong>劣势</strong>：</p><ul><li>配置复杂，需要一定的技术能力</li><li>需要额外的硬件资源（特别是 GPU）</li></ul><h3 id="AUTOMATIC1111-集成">AUTOMATIC1111 集成</h3><p>AUTOMATIC1111 (Stable Diffusion WebUI) 是另一个流行的开源图像生成工具。</p><p><strong>配置步骤</strong>：</p><p>管理员面板 → 设置 → Images → 选择 “AUTOMATIC1111”</p><p>填写配置：</p><table><thead><tr><th>字段</th><th>值</th></tr></thead><tbody><tr><td><strong>API Base URL</strong></td><td><code>http://localhost:7860</code></td></tr><tr><td><strong>API Key</strong></td><td>如果设置了认证，填入密钥</td></tr></tbody></table><p><strong>启用 API</strong>：</p><p>AUTOMATIC1111 默认不开启 API，需要在启动时添加参数：</p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">python launch.py --api --listen</span><br></pre></td></tr></table></figure><p><strong>测试连接</strong>：</p><p>配置完成后，点击 “测试连接” 按钮，如果成功会显示可用的模型列表。</p><h3 id="图像生成参数设置">图像生成参数设置</h3><p>无论使用哪种引擎，都可以配置默认的生成参数：</p><table><thead><tr><th>参数</th><th>说明</th><th>推荐值</th></tr></thead><tbody><tr><td><strong>Steps</strong></td><td>生成步数，越多质量越好但越慢</td><td>20-30</td></tr><tr><td><strong>CFG Scale</strong></td><td>提示词引导强度</td><td>7-9</td></tr><tr><td><strong>Sampler</strong></td><td>采样器类型</td><td>Euler a 或 DPM++ 2M</td></tr><tr><td><strong>负面提示词</strong></td><td>不想出现的元素</td><td>ugly, blurry, low quality</td></tr></tbody></table><p>这些参数主要用于 Stable Diffusion 类模型，DALL-E 不需要配置。</p><h3 id="通过-CLIProxyAPI-Plus-对接图像生成-编辑">通过 CLIProxyAPI Plus 对接图像生成/编辑</h3><p>如果你使用的是 CLIProxyAPI Plus（CPA）作为 OpenAI 兼容代理，它原生只提供 <code>/v1/chat/completions</code> 端点，不支持 <code>/v1/images/generations</code> 和 <code>/v1/images/edits</code>。但 Open WebUI 的 OpenAI 图像引擎恰恰需要这两个端点。</p><p>我们对 CPA 源码进行了 Fork 修改，新增了这两个端点，原理是将图像 API 请求转换为 Chat Completions 调用：</p><table><thead><tr><th>端点</th><th>请求格式</th><th>转换逻辑</th></tr></thead><tbody><tr><td><code>POST /v1/images/generations</code></td><td>JSON（prompt + model）</td><td>构建纯文本 chat completions 请求，从响应中提取 <code>data:image/xxx;base64,...</code></td></tr><tr><td><code>POST /v1/images/edits</code></td><td>multipart/form-data（image 文件 + prompt + model）</td><td>将上传图片转为 base64 data URI，构建多模态 chat completions 请求（image_url + text）</td></tr></tbody></table><p>两个端点都返回标准 OpenAI Images API 格式：<code>&#123;&quot;created&quot;: ..., &quot;data&quot;: [&#123;&quot;b64_json&quot;: &quot;...&quot;&#125;]&#125;</code>。</p><p><strong>涉及的 CPA 源码文件</strong>：</p><ul><li><code>sdk/api/handlers/openai/openai_images_handler.go</code> — 新增文件，包含 <code>ImageGenerations</code> 和 <code>ImageEdits</code> 两个 handler</li><li><code>internal/api/server.go</code> — 在 <code>setupRoutes()</code> 的 v1 group 中注册路由（3 行改动）</li></ul><p><strong>Open WebUI 配置</strong>：</p><p>图像生成（管理员面板 → 设置 → Images）：</p><table><thead><tr><th>字段</th><th>值</th></tr></thead><tbody><tr><td><strong>引擎</strong></td><td>OpenAI</td></tr><tr><td><strong>API Base URL</strong></td><td><code>http://host.docker.internal:8317/v1</code></td></tr><tr><td><strong>API Key</strong></td><td>你的 CPA api-key</td></tr><tr><td><strong>模型</strong></td><td>手动输入模型 ID，如 <code>prorise/gemini-3-pro-image-preview</code></td></tr></tbody></table><p>图像编辑配置同理，引擎选 OpenAI，URL 和 Key 相同，模型填支持图像编辑的模型 ID。</p><p><strong>触发机制</strong>：</p><ul><li>聊天中纯文字描述 → 触发 <code>/images/generations</code>（图像生成）</li><li>聊天中上传图片 + 文字描述 → 触发 <code>/images/edits</code>（图像编辑，需开启 <code>ENABLE_IMAGE_EDIT</code>）</li></ul><p><strong>注意事项</strong>：</p><ul><li>图像模型建议在 Open WebUI 的模型高级设置中关闭流式输出（<code>stream_response: false</code>），避免 chunk 过大导致前端显示异常</li><li>CPA 源码 Fork 详见项目根目录的 <code>FORK_README.md</code>，同步上游更新时注意冲突风险</li></ul><hr></div>]]></content>
    
    
      
      
        
        
    <summary type="html">&lt;div id=&quot;postchat_postcontent&quot;&gt;&lt;h2 id=&quot;3-5-图像生成功能配置&quot;&gt;3.5. 图像生成功能配置&lt;/h2&gt;
&lt;p&gt;Open WebUI 支持集成多种图像生成工具，让 AI 能够根据文字描述生成图片。&lt;/p&gt;
&lt;h3</summary>
        
      
    
    
    
    <category term="一人公司系列" scheme="https://prorise666.site/categories/%E4%B8%80%E4%BA%BA%E5%85%AC%E5%8F%B8%E7%B3%BB%E5%88%97/"/>
    
    <category term="办公提效方向" scheme="https://prorise666.site/categories/%E4%B8%80%E4%BA%BA%E5%85%AC%E5%8F%B8%E7%B3%BB%E5%88%97/%E5%8A%9E%E5%85%AC%E6%8F%90%E6%95%88%E6%96%B9%E5%90%91/"/>
    
    <category term="OpenWebUi" scheme="https://prorise666.site/categories/%E4%B8%80%E4%BA%BA%E5%85%AC%E5%8F%B8%E7%B3%BB%E5%88%97/%E5%8A%9E%E5%85%AC%E6%8F%90%E6%95%88%E6%96%B9%E5%90%91/OpenWebUi/"/>
    
    
  </entry>
  
  <entry>
    <title>OpenWebU-RAG 文档功能配置</title>
    <link href="https://prorise666.site/posts/33455.html"/>
    <id>https://prorise666.site/posts/33455.html</id>
    <published>2026-02-27T06:38:17.000Z</published>
    <updated>2026-03-12T09:18:16.915Z</updated>
    
    <content type="html"><![CDATA[<div id="postchat_postcontent"><h2 id="3-4-RAG-文档功能配置">3.4. RAG 文档功能配置</h2><p>RAG（检索增强生成）是 Open WebUI 的核心功能之一，允许用户基于自己的文档进行问答。本节将从上到下逐一解析 Admin Panel → Settings → Documents 页面的每个配置项，帮助你根据实际场景做出最优选择。</p><p><strong>配置入口</strong>：管理员面板 → 设置 → Documents</p><hr><h3 id="内容提取引擎（Content-Extraction-Engine）">内容提取引擎（Content Extraction Engine）</h3><p>内容提取引擎决定了 Open WebUI 如何从上传的文件中提取文本。通过环境变量 <code>CONTENT_EXTRACTION_ENGINE</code> 选择，共支持 8 种引擎。</p><h4 id="引擎横向对比">引擎横向对比</h4><table><thead><tr><th>引擎</th><th>部署方式</th><th>费用</th><th>支持格式</th><th>OCR 能力</th><th>表格/公式</th><th>适用场景</th></tr></thead><tbody><tr><td><strong>默认（Default）</strong></td><td>本地，零依赖</td><td>免费</td><td>PDF、TXT、CSV、DOCX、代码文件</td><td>❌ 不支持</td><td>❌ 弱</td><td>简单文档，纯文本 PDF</td></tr><tr><td><strong>Apache Tika</strong></td><td>需部署 Java 服务</td><td>免费（开源）</td><td>1400+ 种格式</td><td>❌ 弱</td><td>❌ 弱</td><td>格式种类多的企业环境</td></tr><tr><td><strong>Docling（IBM）</strong></td><td>需部署服务或用 API</td><td>免费（开源）</td><td>PDF、DOCX、PPTX、HTML</td><td>✅ 支持</td><td>✅ 优秀</td><td>复杂排版、表格、公式</td></tr><tr><td><strong>Datalab Marker</strong></td><td>云 API 或自部署</td><td>~$6/千页（高精度）</td><td>PDF、图片</td><td>✅ LLM 增强 OCR</td><td>✅ 优秀</td><td>复杂排版 PDF，可自部署</td></tr><tr><td><strong>Mistral OCR</strong></td><td>云 API</td><td>$1-2/千页</td><td>PDF、图片</td><td>✅ 99%+ 准确率</td><td>✅ 优秀</td><td>扫描件、多语言（25+ 语言）</td></tr><tr><td><strong>Document Intelligence</strong></td><td>Azure 云服务</td><td>~$10/千页</td><td>PDF、图片、表单</td><td>✅ 支持</td><td>✅ 支持</td><td>Azure 生态企业用户</td></tr><tr><td><strong>MinerU</strong></td><td>自部署或云 API</td><td>免费（开源）</td><td>PDF、图片</td><td>✅ 支持</td><td>✅ 最佳</td><td>学术论文、金融报告、公式密集</td></tr><tr><td><strong>External</strong></td><td>自定义 HTTP 服务</td><td>取决于实现</td><td>自定义</td><td>取决于实现</td><td>取决于实现</td><td>对接私有解析服务</td></tr></tbody></table><h4 id="各引擎详细说明">各引擎详细说明</h4><div class="tabs" id="内容提取引擎详解"><ul class="nav-tabs"><button type="button" class="tab  active" data-href="内容提取引擎详解-1">默认引擎</button><button type="button" class="tab " data-href="内容提取引擎详解-2">Apache Tika</button><button type="button" class="tab " data-href="内容提取引擎详解-3">Docling（IBM 开源）</button><button type="button" class="tab " data-href="内容提取引擎详解-4">Datalab Marker</button><button type="button" class="tab " data-href="内容提取引擎详解-5">Mistral OCR</button><button type="button" class="tab " data-href="内容提取引擎详解-6">Document Intelligence</button><button type="button" class="tab " data-href="内容提取引擎详解-7">MinerU</button><button type="button" class="tab " data-href="内容提取引擎详解-8">External</button></ul><div class="tab-contents"><div class="tab-item-content active" id="内容提取引擎详解-1"><p>使用 Python 原生加载器（PyPDFLoader、Docx2txtLoader、CSVLoader、TextLoader），零依赖开箱即用。但不支持 OCR，无法处理扫描件，对复杂表格和公式无能为力。</p><p><strong>适合</strong>：纯文本 PDF 和简单文档，快速上手无需任何额外配置。</p></div><div class="tab-item-content" id="内容提取引擎详解-2"><p>老牌 Java 文档解析框架，格式覆盖最广（1400+），但对 PDF 排版理解较弱，不擅长表格结构保留和公式识别。适合已有 Tika 基础设施的组织。</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></pre></td><td class="code"><pre><span class="line"><span class="attr">environment:</span></span><br><span class="line">  <span class="bullet">-</span> <span class="string">CONTENT_EXTRACTION_ENGINE=tika</span></span><br><span class="line">  <span class="bullet">-</span> <span class="string">TIKA_SERVER_URL=http://tika:9998</span></span><br></pre></td></tr></table></figure></div><div class="tab-item-content" id="内容提取引擎详解-3"><p>Python 原生，对 PDF 排版理解好，表格提取准确，支持公式，输出干净的 Markdown/JSON。在多个评测中与 MinerU 并列 top 2。</p><figure class="highlight yaml"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><span class="line"><span class="attr">environment:</span></span><br><span class="line">  <span class="bullet">-</span> <span class="string">CONTENT_EXTRACTION_ENGINE=docling</span></span><br><span class="line">  <span class="bullet">-</span> <span class="string">DOCLING_SERVER_URL=http://docling:5000</span></span><br><span class="line">  <span class="bullet">-</span> <span class="string">DOCLING_API_KEY=your-api-key</span>  <span class="comment"># 如需认证</span></span><br></pre></td></tr></table></figure></div><div class="tab-item-content" id="内容提取引擎详解-4"><p>基于开源 <a href="https://github.com/datalab-to/marker">Marker</a> 和 Surya 模型，LLM 增强 OCR。其 Chandra OCR 模型在 olmOCR benchmark 上得分 83.1%，超过 GPT-4o。支持云 API 和自部署两种方式。</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></pre></td><td class="code"><pre><span class="line"><span class="attr">environment:</span></span><br><span class="line">  <span class="bullet">-</span> <span class="string">CONTENT_EXTRACTION_ENGINE=datalab_marker</span></span><br><span class="line">  <span class="bullet">-</span> <span class="string">DATALAB_MARKER_API_KEY=your-api-key</span></span><br></pre></td></tr></table></figure></div><div class="tab-item-content" id="内容提取引擎详解-5"><p>号称 99%+ 准确率，支持表格/公式/图表转表格/签名检测，覆盖 25+ 语言。性价比高（$1/千页起），但只能通过 API 调用，无法自部署。</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></pre></td><td class="code"><pre><span class="line"><span class="attr">environment:</span></span><br><span class="line">  <span class="bullet">-</span> <span class="string">CONTENT_EXTRACTION_ENGINE=mistral_ocr</span></span><br><span class="line">  <span class="bullet">-</span> <span class="string">MISTRAL_OCR_API_KEY=your-api-key</span></span><br></pre></td></tr></table></figure></div><div class="tab-item-content" id="内容提取引擎详解-6"><p>微软企业级服务，预置发票/收据/身份证等模型，支持自定义模型训练。价格较高但有企业合规保障。</p><figure class="highlight yaml"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line"><span class="attr">environment:</span></span><br><span class="line">  <span class="bullet">-</span> <span class="string">CONTENT_EXTRACTION_ENGINE=document_intelligence</span></span><br><span class="line">  <span class="bullet">-</span> <span class="string">DOCUMENT_INTELLIGENCE_ENDPOINT=https://your-resource.cognitiveservices.azure.com/</span></span><br></pre></td></tr></table></figure></div><div class="tab-item-content" id="内容提取引擎详解-7"><p>基于 PDF-Extract-Kit 微调模型，在复杂文档（学术论文、教材、金融报告）上表现最佳，表格/公式/图片提取精度高。推荐 GPU 环境运行。</p><figure class="highlight yaml"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><span class="line"><span class="attr">environment:</span></span><br><span class="line">  <span class="bullet">-</span> <span class="string">CONTENT_EXTRACTION_ENGINE=mineru</span></span><br><span class="line">  <span class="bullet">-</span> <span class="string">MINERU_API_URL=http://mineru:8000</span></span><br><span class="line">  <span class="bullet">-</span> <span class="string">MINERU_API_MODE=local</span>  <span class="comment"># cloud 或 local</span></span><br></pre></td></tr></table></figure></div><div class="tab-item-content" id="内容提取引擎详解-8"><p>万能逃生舱，指向任意 HTTP 服务，适合对接企业内部的私有文档解析服务。</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></pre></td><td class="code"><pre><span class="line"><span class="attr">environment:</span></span><br><span class="line">  <span class="bullet">-</span> <span class="string">CONTENT_EXTRACTION_ENGINE=external</span></span><br><span class="line">  <span class="bullet">-</span> <span class="string">EXTERNAL_DOCUMENT_LOADER_URL=http://your-service:8080/extract</span></span><br></pre></td></tr></table></figure></div></div><div class="tab-to-top"><button type="button" aria-label="scroll to top"><i class="anzhiyufont anzhiyu-icon-arrow-up"></i></button></div></div><h4 id="引擎选择建议">引擎选择建议</h4><table><thead><tr><th>场景</th><th>推荐引擎</th><th>理由</th></tr></thead><tbody><tr><td>简单文档、快速上手</td><td>默认</td><td>零配置，开箱即用</td></tr><tr><td>扫描件、多语言文档</td><td>Mistral OCR</td><td>性价比最高，准确率高</td></tr><tr><td>学术论文、公式密集</td><td>MinerU 或 Docling</td><td>表格/公式提取精度最佳</td></tr><tr><td>企业 Azure 环境</td><td>Document Intelligence</td><td>合规保障，预置模型丰富</td></tr><tr><td>想自部署 + 高精度</td><td>Datalab Marker 或 MinerU</td><td>开源可控，精度优秀</td></tr><tr><td>格式种类极多</td><td>Apache Tika</td><td>1400+ 格式覆盖</td></tr></tbody></table><h4 id="PDF-提取图片（PDF-Extract-Images）">PDF 提取图片（PDF Extract Images）</h4><table><thead><tr><th>配置项</th><th>环境变量</th><th>默认值</th><th>说明</th></tr></thead><tbody><tr><td>PDF Extract Images</td><td><code>PDF_EXTRACT_IMAGES</code></td><td><code>false</code></td><td>是否从 PDF 中提取嵌入的图片</td></tr></tbody></table><p>启用后会增加处理时间和存储空间，仅在文档中的图片内容对问答有价值时开启。</p><hr><h3 id="PDF-加载模式">PDF 加载模式</h3><p>Open WebUI 提供两种 PDF 处理模式，决定了文档在进入切分器之前的预处理方式：</p><table><thead><tr><th>模式</th><th>行为</th><th>优点</th><th>缺点</th><th>适用场景</th></tr></thead><tbody><tr><td><strong>Page（页模式）</strong></td><td>每页作为独立文档单元，保留页边界</td><td>检索时能精确定位到具体页码</td><td>跨页内容会被截断</td><td>PPT 转 PDF、每页独立主题</td></tr><tr><td><strong>Single Document（单文档模式）</strong></td><td>整个 PDF 合并为一个文本块，再统一切分</td><td>跨页内容不会丢失，语义连贯</td><td>失去页码定位能力</td><td>论文、书籍、报告等连续叙述型文档</td></tr></tbody></table><p><strong>最佳实践</strong>：大多数 RAG 场景推荐 <strong>Single Document</strong> 模式，因为语义连贯性比页码定位更重要。如果文档每页内容相对独立（如幻灯片），则用 Page 模式。</p><hr><h3 id="文本切分（Text-Splitting）">文本切分（Text Splitting）</h3><p>文本切分决定了文档被拆分成多大的片段（chunk）存入向量数据库。切分质量直接影响检索精度。</p><h4 id="切分器类型">切分器类型</h4><table><thead><tr><th>切分器</th><th>计量方式</th><th>特点</th><th>适用场景</th></tr></thead><tbody><tr><td><strong>默认（Character）</strong></td><td>按字符数</td><td>使用递归分隔符（<code>\n\n</code> → <code>\n</code> → 空格 → 字符）逐级切分，简单高效，无依赖</td><td>英文文档、通用场景</td></tr><tr><td><strong>Token</strong></td><td>按 token 数</td><td>按模型 tokenizer 计算，切分大小与模型上下文窗口精确对齐</td><td>中文文档（1 个汉字 ≈ 2-3 个 token）、需要精确控制 token 用量</td></tr></tbody></table><h4 id="核心参数">核心参数</h4><table><thead><tr><th>配置项</th><th>环境变量</th><th>默认值</th><th>推荐值</th><th>说明</th></tr></thead><tbody><tr><td><strong>Chunk Size</strong></td><td><code>CHUNK_SIZE</code></td><td>1500</td><td>1000-2000</td><td>每个 chunk 的最大大小。太小丢失上下文，太大降低检索精度</td></tr><tr><td><strong>Chunk Overlap</strong></td><td><code>CHUNK_OVERLAP</code></td><td>100</td><td>Chunk Size 的 5%-15%</td><td>相邻 chunk 的重叠量，保证边界处语义连贯</td></tr></tbody></table><p><strong>参数调优指南</strong>：</p><ul><li><strong>Chunk Size 太小（&lt;500）</strong>：上下文不完整，模型难以理解片段含义</li><li><strong>Chunk Size 太大（&gt;2000）</strong>：包含过多无关信息，检索精度下降</li><li><strong>Chunk Overlap 太小</strong>：重要信息可能被切断在两个 chunk 之间</li><li><strong>Chunk Overlap 太大</strong>：存储冗余增加，检索效率降低</li></ul><h4 id="Markdown-标题文本分割器">Markdown 标题文本分割器</h4><table><thead><tr><th>配置项</th><th>默认值</th><th>说明</th></tr></thead><tbody><tr><td><strong>Markdown Header Text Splitter</strong></td><td>关闭</td><td>按 Markdown 标题（H1-H6）进行结构化预切分</td></tr><tr><td><strong>Chunk Min Size Target</strong></td><td>—</td><td>合并过小片段的阈值，建议设为 Chunk Size 的 50%</td></tr></tbody></table><p>启用后，文档会先按 Markdown 标题进行结构化预切分，然后再交给标准切分器处理。好处：</p><ul><li>保留文档的逻辑结构，每个 chunk 属于明确的章节</li><li>避免跨章节切分导致语义混乱</li><li>配合 Chunk Min Size Target 参数，可以将过小的片段向前合并（单向合并算法，不跨文档），官方测试显示阈值设为 1000（chunk size 2000 时）可减少 90%+ 的碎片 chunk</li></ul><p><strong>最佳实践</strong>：如果文档是 Markdown 格式，或提取引擎输出 Markdown（Docling、MinerU、Marker 都输出 Markdown），<strong>强烈建议开启此选项</strong>。</p><hr><h3 id="嵌入模型（Embedding-Model）">嵌入模型（Embedding Model）</h3><p>嵌入模型将文本转换为向量表示，是 RAG 检索的基础。模型质量直接决定检索准确性。</p><h4 id="引擎横向对比-2">引擎横向对比</h4><table><thead><tr><th>引擎</th><th>配置值</th><th>费用</th><th>延迟</th><th>隐私</th><th>推荐模型</th></tr></thead><tbody><tr><td><strong>SentenceTransformers（默认）</strong></td><td><code>&quot;&quot;</code></td><td>免费，本地运行</td><td>中等（取决于硬件）</td><td>完全本地</td><td><code>all-MiniLM-L6-v2</code>（轻量）、<code>BAAI/bge-m3</code>（多语言）</td></tr><tr><td><strong>Ollama</strong></td><td><code>ollama</code></td><td>免费，本地运行</td><td>快（已有 Ollama 实例）</td><td>完全本地</td><td><code>nomic-embed-text</code>（推荐首选）、<code>mxbai-embed-large</code></td></tr><tr><td><strong>OpenAI</strong></td><td><code>openai</code></td><td>按 token 计费</td><td>低（云端）</td><td>数据发送到云端</td><td><code>text-embedding-3-small</code>（性价比）、<code>text-embedding-3-large</code>（高精度）</td></tr><tr><td><strong>Azure OpenAI</strong></td><td><code>azure</code></td><td>按 token 计费</td><td>低（云端）</td><td>Azure 合规保障</td><td>同 OpenAI 模型，通过 Azure 部署</td></tr></tbody></table><h4 id="各引擎详细说明-2">各引擎详细说明</h4><div class="tabs" id="嵌入模型引擎详解"><ul class="nav-tabs"><button type="button" class="tab  active" data-href="嵌入模型引擎详解-1">SentenceTransformers（默认）</button><button type="button" class="tab " data-href="嵌入模型引擎详解-2">Ollama</button><button type="button" class="tab " data-href="嵌入模型引擎详解-3">OpenAI</button><button type="button" class="tab " data-href="嵌入模型引擎详解-4">Azure OpenAI</button></ul><div class="tab-contents"><div class="tab-item-content active" id="嵌入模型引擎详解-1"><p>使用 Python <code>sentence-transformers</code> 库在本地运行，模型自动从 HuggingFace 下载缓存。零成本、完全隐私，但首次加载模型较慢，且占用服务器内存/显存。默认模型 <code>all-MiniLM-L6-v2</code> 较轻量但精度一般。</p><p>⚠️ <strong>网络提示</strong>：如果服务器无法访问 <code>huggingface.co</code>，启动时会出现 SSL 重连错误，RAG 功能将不可用。可添加镜像站环境变量解决：</p><figure class="highlight yaml"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line"><span class="attr">environment:</span></span><br><span class="line">  <span class="bullet">-</span> <span class="string">HF_ENDPOINT=https://hf-mirror.com</span></span><br></pre></td></tr></table></figure></div><div class="tab-item-content" id="嵌入模型引擎详解-2"><p>如果你已经在用 Ollama 跑 LLM，这是最方便的选择。<code>nomic-embed-text</code> 是社区最推荐的模型：8192 token 上下文、完全开源、在 MTEB 和 LoCo 基准上表现优异。</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></pre></td><td class="code"><pre><span class="line">ollama pull nomic-embed-text</span><br><span class="line"><span class="comment"># 或中文场景</span></span><br><span class="line">ollama pull bge-m3</span><br></pre></td></tr></table></figure><p>然后配置环境变量：</p><figure class="highlight yaml"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><span class="line"><span class="attr">environment:</span></span><br><span class="line">  <span class="bullet">-</span> <span class="string">RAG_EMBEDDING_ENGINE=ollama</span></span><br><span class="line">  <span class="bullet">-</span> <span class="string">RAG_EMBEDDING_MODEL=nomic-embed-text</span></span><br><span class="line">  <span class="bullet">-</span> <span class="string">RAG_OLLAMA_BASE_URL=http://ollama:11434</span></span><br></pre></td></tr></table></figure></div><div class="tab-item-content" id="嵌入模型引擎详解-3"><p>支持任何 OpenAI 兼容端点（包括第三方）。<code>text-embedding-3-small</code>（1536 维）性价比高，<code>text-embedding-3-large</code>（3072 维）精度更好。缺点是有 API 费用、网络延迟、数据隐私风险。</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><span class="line"><span class="attr">environment:</span></span><br><span class="line">  <span class="bullet">-</span> <span class="string">RAG_EMBEDDING_ENGINE=openai</span></span><br><span class="line">  <span class="bullet">-</span> <span class="string">RAG_EMBEDDING_MODEL=text-embedding-3-small</span></span><br><span class="line">  <span class="bullet">-</span> <span class="string">RAG_OPENAI_API_BASE_URL=https://api.openai.com/v1</span></span><br><span class="line">  <span class="bullet">-</span> <span class="string">RAG_OPENAI_API_KEY=sk-...</span></span><br></pre></td></tr></table></figure></div><div class="tab-item-content" id="嵌入模型引擎详解-4"><p>本质上是 OpenAI 模型通过 Azure 托管，适合有 Azure 合规要求的企业。</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></pre></td><td class="code"><pre><span class="line"><span class="attr">environment:</span></span><br><span class="line">  <span class="bullet">-</span> <span class="string">RAG_EMBEDDING_ENGINE=azure</span></span><br><span class="line">  <span class="bullet">-</span> <span class="string">RAG_AZURE_OPENAI_BASE_URL=https://your-resource.openai.azure.com/</span></span><br></pre></td></tr></table></figure></div></div><div class="tab-to-top"><button type="button" aria-label="scroll to top"><i class="anzhiyufont anzhiyu-icon-arrow-up"></i></button></div></div><h4 id="嵌入模型选择建议">嵌入模型选择建议</h4><table><thead><tr><th>场景</th><th>推荐方案</th><th>理由</th></tr></thead><tbody><tr><td>本地优先、已有 Ollama</td><td>Ollama + <code>nomic-embed-text</code></td><td>最佳平衡，免费、快速、质量好</td></tr><tr><td>资源受限、轻量部署</td><td>SentenceTransformers + <code>all-MiniLM-L6-v2</code></td><td>零依赖，内存占用小</td></tr><tr><td>中文文档为主</td><td>Ollama + <code>bge-m3</code> 或 SentenceTransformers + <code>BAAI/bge-m3</code></td><td>多语言支持优秀</td></tr><tr><td>追求精度且不介意费用</td><td>OpenAI + <code>text-embedding-3-large</code></td><td>精度最高</td></tr><tr><td>企业合规要求</td><td>Azure OpenAI</td><td>合规保障</td></tr></tbody></table><h4 id="其他嵌入参数">其他嵌入参数</h4><table><thead><tr><th>配置项</th><th>环境变量</th><th>默认值</th><th>说明</th></tr></thead><tbody><tr><td><strong>Embedding Batch Size</strong></td><td><code>RAG_EMBEDDING_BATCH_SIZE</code></td><td>100</td><td>批量嵌入大小，显存不足时调小</td></tr></tbody></table><p>⚠️ <strong>重要</strong>：更换嵌入模型后，所有已有文档必须重新嵌入（re-embed），因为不同模型的向量空间不兼容。确定模型后不要轻易更换。</p><hr><h3 id="检索与排序（Retrieval-Ranking）">检索与排序（Retrieval &amp; Ranking）</h3><p>检索参数决定了从向量数据库中召回多少结果、如何过滤和排序。这是影响 RAG 最终效果的关键环节。</p><h4 id="核心检索参数">核心检索参数</h4><table><thead><tr><th>配置项</th><th>环境变量</th><th>默认值</th><th>推荐值</th><th>说明</th></tr></thead><tbody><tr><td><strong>Top K</strong></td><td><code>TOP_K</code></td><td>5</td><td>5-10</td><td>返回的最相关 chunk 数量。太少可能遗漏信息，太多会稀释上下文</td></tr><tr><td><strong>Relevance Threshold</strong></td><td><code>RELEVANCE_THRESHOLD</code></td><td>0.0</td><td>0.2-0.5</td><td>最低相关性分数阈值，低于此分数的 chunk 被过滤。设为 0 表示不过滤</td></tr></tbody></table><p><strong>参数调优指南</strong>：</p><ul><li><strong>Top K 太少（❤️）</strong>：可能遗漏重要信息，回答不完整</li><li><strong>Top K 太多（&gt;10）</strong>：包含过多噪音，模型可能被无关内容干扰</li><li><strong>Relevance Threshold 太低（0）</strong>：不过滤，噪音多</li><li><strong>Relevance Threshold 太高（&gt;0.7）</strong>：过滤过严，可能丢失有用信息</li></ul><h4 id="混合搜索（Hybrid-Search）">混合搜索（Hybrid Search）</h4><table><thead><tr><th>配置项</th><th>环境变量</th><th>默认值</th><th>推荐</th><th>说明</th></tr></thead><tbody><tr><td><strong>Hybrid Search</strong></td><td><code>ENABLE_RAG_HYBRID_SEARCH</code></td><td><code>false</code></td><td><strong>开启</strong></td><td>结合向量搜索 + BM25 关键词匹配</td></tr></tbody></table><p>混合搜索使用 <code>EnsembleRetriever</code>，同时执行：</p><ul><li><strong>向量搜索</strong>：基于语义相似度，擅长理解同义词和语义关联</li><li><strong>BM25 关键词匹配</strong>：基于词频统计，擅长精确匹配专有名词、代码、ID 等</li></ul><p>两者互补，<strong>显著提升召回率</strong>，推荐开启。</p><h4 id="重排序（Reranking）">重排序（Reranking）</h4><table><thead><tr><th>配置项</th><th>环境变量</th><th>默认值</th><th>说明</th></tr></thead><tbody><tr><td><strong>Reranking Model</strong></td><td><code>RAG_RERANKING_MODEL</code></td><td>—</td><td>使用 CrossEncoder/ColBERT 对检索结果重排序</td></tr><tr><td><strong>Top K Reranker</strong></td><td><code>TOP_K_RERANKER</code></td><td>—</td><td>重排序后保留的结果数</td></tr></tbody></table><p>重排序的工作流程：</p><ol><li>先通过向量搜索（+ BM25）召回较多候选结果</li><li>再用 CrossEncoder 模型对每个候选结果与查询进行精细打分</li><li>按新分数重新排序，保留 Top K Reranker 个最相关结果</li></ol><p><strong>推荐配合 Hybrid Search 使用</strong>，这是提升 RAG 质量最有效的组合。</p><h4 id="检索策略建议">检索策略建议</h4><table><thead><tr><th>文档规模</th><th>推荐配置</th></tr></thead><tbody><tr><td>小文档集（&lt;100 个文档）</td><td>Top K=5，不需要混合搜索和重排序</td></tr><tr><td>中等文档集（100-1000）</td><td>Top K=5-8，开启 Hybrid Search</td></tr><tr><td>大文档集（&gt;1000）</td><td>Top K=8-10，开启 Hybrid Search + Reranking，Relevance Threshold=0.2+</td></tr></tbody></table><hr><h3 id="高级设置">高级设置</h3><h4 id="RAG-模板与上下文">RAG 模板与上下文</h4><table><thead><tr><th>配置项</th><th>环境变量</th><th>默认值</th><th>说明</th></tr></thead><tbody><tr><td><strong>RAG Template</strong></td><td><code>RAG_TEMPLATE</code></td><td>内置模板</td><td>自定义 RAG 提示词模板，控制检索内容如何注入到 LLM prompt</td></tr><tr><td><strong>RAG System Context</strong></td><td><code>RAG_SYSTEM_CONTEXT</code></td><td><code>false</code></td><td>设为 <code>true</code> 将 RAG 上下文放入 system message 而非 user message</td></tr></tbody></table><p><strong>RAG System Context</strong> 的作用：将检索到的文档内容放入 system message，而非 user message。好处是在多轮对话中可以优化 KV cache 复用（因为 system message 不变），推荐 Ollama/llama.cpp 用户开启。</p><h4 id="异步与性能">异步与性能</h4><table><thead><tr><th>配置项</th><th>环境变量</th><th>默认值</th><th>说明</th></tr></thead><tbody><tr><td><strong>Async Embedding</strong></td><td><code>ENABLE_ASYNC_EMBEDDING</code></td><td><code>false</code></td><td>后台线程池处理嵌入，上传大量文档时建议开启</td></tr></tbody></table><h4 id="文件与工具">文件与工具</h4><table><thead><tr><th>配置项</th><th>默认值</th><th>说明</th></tr></thead><tbody><tr><td><strong>File Context</strong></td><td>启用</td><td>控制是否对附件执行 RAG 并预注入内容</td></tr><tr><td><strong>Builtin Tools</strong></td><td>启用</td><td>给模型提供 <code>query_knowledge_bases</code>、<code>search_chats</code> 等函数调用工具</td></tr></tbody></table><h4 id="网页加载">网页加载</h4><table><thead><tr><th>配置项</th><th>环境变量</th><th>默认值</th><th>说明</th></tr></thead><tbody><tr><td><strong>Web Loader SSL Verification</strong></td><td><code>ENABLE_WEB_LOADER_SSL_VERIFICATION</code></td><td><code>true</code></td><td>网页加载时是否验证 SSL 证书</td></tr><tr><td><strong>Google Drive Integration</strong></td><td><code>GOOGLE_DRIVE_API_KEY</code> 等</td><td>—</td><td>Google Drive 文件直接导入</td></tr></tbody></table><hr><h3 id="向量数据库（Vector-Database）">向量数据库（Vector Database）</h3><p>向量数据库用于存储文档的向量表示，是 RAG 检索的底层存储。</p><table><thead><tr><th>配置项</th><th>环境变量</th><th>默认值</th><th>说明</th></tr></thead><tbody><tr><td><strong>Vector DB</strong></td><td><code>VECTOR_DB</code></td><td><code>chromadb</code></td><td>向量数据库类型</td></tr><tr><td><strong>Vector DB URL</strong></td><td><code>VECTOR_DB_URL</code></td><td>—</td><td>外部向量数据库连接地址</td></tr></tbody></table><h4 id="支持的向量数据库">支持的向量数据库</h4><table><thead><tr><th>数据库</th><th>部署方式</th><th>适用场景</th><th>特点</th></tr></thead><tbody><tr><td><strong>ChromaDB</strong></td><td>内置，无需额外配置</td><td>个人使用、小团队</td><td>默认选择，开箱即用</td></tr><tr><td><strong>Qdrant</strong></td><td>需部署独立服务</td><td>大规模部署</td><td>高性能，支持过滤</td></tr><tr><td><strong>Milvus</strong></td><td>需部署独立服务</td><td>企业环境</td><td>分布式，支持十亿级向量</td></tr><tr><td><strong>Weaviate</strong></td><td>需部署独立服务</td><td>需要混合搜索</td><td>内置向量+关键词搜索</td></tr><tr><td><strong>OpenSearch</strong></td><td>需部署独立服务</td><td>已有 OpenSearch 集群</td><td>Elasticsearch 开源替代</td></tr><tr><td><strong>PGVector</strong></td><td>PostgreSQL 扩展</td><td>已有 PostgreSQL 环境</td><td>复用现有数据库</td></tr><tr><td><strong>Pinecone</strong></td><td>云端托管</td><td>云端部署，免运维</td><td>全托管，按用量计费</td></tr><tr><td><strong>S3 Vector</strong></td><td>AWS S3</td><td>AWS 生态</td><td>低成本存储</td></tr></tbody></table><div class="tabs" id="向量数据库配置示例"><ul class="nav-tabs"><button type="button" class="tab  active" data-href="向量数据库配置示例-1">ChromaDB（默认）</button><button type="button" class="tab " data-href="向量数据库配置示例-2">Qdrant</button><button type="button" class="tab " data-href="向量数据库配置示例-3">Milvus</button><button type="button" class="tab " data-href="向量数据库配置示例-4">PGVector</button><button type="button" class="tab " data-href="向量数据库配置示例-5">Pinecone</button></ul><div class="tab-contents"><div class="tab-item-content active" id="向量数据库配置示例-1"><p>内置数据库，无需任何额外配置，开箱即用。适合个人和小团队。</p></div><div class="tab-item-content" id="向量数据库配置示例-2"><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></pre></td><td class="code"><pre><span class="line"><span class="attr">environment:</span></span><br><span class="line">  <span class="bullet">-</span> <span class="string">VECTOR_DB=qdrant</span></span><br><span class="line">  <span class="bullet">-</span> <span class="string">QDRANT_URL=http://qdrant:6333</span></span><br></pre></td></tr></table></figure></div><div class="tab-item-content" id="向量数据库配置示例-3"><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></pre></td><td class="code"><pre><span class="line"><span class="attr">environment:</span></span><br><span class="line">  <span class="bullet">-</span> <span class="string">VECTOR_DB=milvus</span></span><br><span class="line">  <span class="bullet">-</span> <span class="string">MILVUS_URI=http://milvus:19530</span></span><br></pre></td></tr></table></figure></div><div class="tab-item-content" id="向量数据库配置示例-4"><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></pre></td><td class="code"><pre><span class="line"><span class="attr">environment:</span></span><br><span class="line">  <span class="bullet">-</span> <span class="string">VECTOR_DB=pgvector</span></span><br><span class="line">  <span class="bullet">-</span> <span class="string">PGVECTOR_DB_URL=postgresql://user:pass@postgres:5432/vectors</span></span><br></pre></td></tr></table></figure></div><div class="tab-item-content" id="向量数据库配置示例-5"><figure class="highlight yaml"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><span class="line"><span class="attr">environment:</span></span><br><span class="line">  <span class="bullet">-</span> <span class="string">VECTOR_DB=pinecone</span></span><br><span class="line">  <span class="bullet">-</span> <span class="string">PINECONE_API_KEY=your-api-key</span></span><br><span class="line">  <span class="bullet">-</span> <span class="string">PINECONE_ENVIRONMENT=us-east-1</span></span><br></pre></td></tr></table></figure></div></div><div class="tab-to-top"><button type="button" aria-label="scroll to top"><i class="anzhiyufont anzhiyu-icon-arrow-up"></i></button></div></div><p>对于大多数用户，<strong>ChromaDB 已经足够使用</strong>，无需额外配置。</p><hr><h3 id="整体最佳实践总结">整体最佳实践总结</h3><p>根据以上所有配置项的分析，以下是推荐的最佳实践组合：</p><table><thead><tr><th>配置项</th><th>推荐值</th><th>理由</th></tr></thead><tbody><tr><td>提取引擎</td><td>根据文档类型选择（见引擎选择建议表）</td><td>复杂 PDF 用 Docling/MinerU/Mistral OCR，简单文档用默认</td></tr><tr><td>PDF 加载模式</td><td>Single Document</td><td>语义连贯性优先</td></tr><tr><td>文本切分器</td><td>中文用 Token，英文用 Character</td><td>中文字符数 ≠ token 数</td></tr><tr><td>Chunk Size</td><td>1000-2000</td><td>平衡上下文完整性和检索精度</td></tr><tr><td>Chunk Overlap</td><td>Chunk Size 的 10%</td><td>保证边界语义连贯</td></tr><tr><td>Markdown 标题切分</td><td>开启（如果文档是 Markdown）</td><td>保留文档结构，减少碎片</td></tr><tr><td>嵌入模型</td><td>本地：Ollama + <code>nomic-embed-text</code></td><td>免费、快速、质量好</td></tr><tr><td>Hybrid Search</td><td><strong>开启</strong></td><td>向量 + 关键词互补，显著提升召回率</td></tr><tr><td>Reranking</td><td>配合 Hybrid Search 开启</td><td>提升精度最有效的组合</td></tr><tr><td>Top K</td><td>5-10</td><td>平衡召回和噪音</td></tr><tr><td>Relevance Threshold</td><td>0.2-0.5</td><td>过滤低质量结果</td></tr><tr><td>RAG System Context</td><td><code>true</code>（Ollama 用户）</td><td>优化多轮对话 KV cache</td></tr><tr><td>向量数据库</td><td>ChromaDB（默认）</td><td>小团队足够，零配置</td></tr></tbody></table><p>💡 <strong>核心原则</strong>：先确定嵌入模型（确定后不要轻易更换），再开启 Hybrid Search + Reranking，最后根据文档类型选择提取引擎。这三步是提升 RAG 质量投入产出比最高的操作。</p><hr></div>]]></content>
    
    
      
      
        
        
    <summary type="html">&lt;div id=&quot;postchat_postcontent&quot;&gt;&lt;h2 id=&quot;3-4-RAG-文档功能配置&quot;&gt;3.4. RAG 文档功能配置&lt;/h2&gt;
&lt;p&gt;RAG（检索增强生成）是 Open WebUI 的核心功能之一，允许用户基于自己的文档进行问答。本节将从上到下逐一解析</summary>
        
      
    
    
    
    <category term="一人公司系列" scheme="https://prorise666.site/categories/%E4%B8%80%E4%BA%BA%E5%85%AC%E5%8F%B8%E7%B3%BB%E5%88%97/"/>
    
    <category term="办公提效方向" scheme="https://prorise666.site/categories/%E4%B8%80%E4%BA%BA%E5%85%AC%E5%8F%B8%E7%B3%BB%E5%88%97/%E5%8A%9E%E5%85%AC%E6%8F%90%E6%95%88%E6%96%B9%E5%90%91/"/>
    
    <category term="OpenWebUi" scheme="https://prorise666.site/categories/%E4%B8%80%E4%BA%BA%E5%85%AC%E5%8F%B8%E7%B3%BB%E5%88%97/%E5%8A%9E%E5%85%AC%E6%8F%90%E6%95%88%E6%96%B9%E5%90%91/OpenWebUi/"/>
    
    
  </entry>
  
  <entry>
    <title>OpenWebUi-用户与权限管理</title>
    <link href="https://prorise666.site/posts/43426.html"/>
    <id>https://prorise666.site/posts/43426.html</id>
    <published>2026-02-27T05:38:17.000Z</published>
    <updated>2026-03-12T09:18:16.915Z</updated>
    
    <content type="html"><![CDATA[<div id="postchat_postcontent"><h2 id="3-3-用户与权限管理">3.3. 用户与权限管理</h2><p>Open WebUI 提供了完善的多用户管理功能，适合团队协作使用。</p><h3 id="用户注册与审批流程">用户注册与审批流程</h3><p><strong>默认行为</strong>：</p><ul><li>第一个注册的用户自动成为管理员</li><li>后续注册的用户状态为 “待激活”</li><li>管理员需要手动激活用户</li></ul><p><strong>配置注册开关</strong>：</p><p>管理员面板 → 设置 → 通用 → 身份验证</p><p>找到 “允许新用户注册” 开关：</p><p><img src="https://prorise-blog.oss-cn-guangzhou.aliyuncs.com/cover/image-20260213092217799.png" alt="image-20260213092217799"></p><table><thead><tr><th>状态</th><th>说明</th><th>适用场景</th></tr></thead><tbody><tr><td><strong>开启</strong></td><td>任何人都可以注册，新用户角色由&quot;默认用户角色&quot;决定</td><td>小团队、内网环境</td></tr><tr><td><strong>关闭</strong></td><td>禁止注册，只能由管理员手动添加用户</td><td>严格控制的企业环境</td></tr></tbody></table><p><strong>配置默认用户角色</strong>：</p><p>在同一页面，找到 “默认用户角色” 下拉选择：</p><table><thead><tr><th>角色</th><th>说明</th></tr></thead><tbody><tr><td><strong>待激活</strong></td><td>注册后无法使用系统，需要管理员手动激活（默认值）</td></tr><tr><td><strong>用户</strong></td><td>注册后直接可以使用系统</td></tr><tr><td><strong>管理员</strong></td><td>注册后直接成为管理员（不推荐）</td></tr></tbody></table><p><strong>配置默认权限组</strong>：</p><p>你还可以设置 “默认权限组”，新注册的用户会自动加入该权限组，继承组的权限配置。</p><p>如果你的团队成员都是可信的，可以将默认角色设为 “用户”，这样注册后就能直接使用，无需审批。</p><h3 id="手动添加用户">手动添加用户</h3><p>如果你关闭了注册功能，或者想要批量添加用户，可以使用手动添加功能。</p><p><strong>步骤 1：进入用户管理</strong></p><p>管理员面板 → 用户（Users）</p><p><strong>步骤 2：添加单个用户</strong></p><p>点击右上角的 “添加用户” 按钮，填写信息：</p><p><img src="https://prorise-blog.oss-cn-guangzhou.aliyuncs.com/cover/image-20260213092242263.png" alt="image-20260213092242263"></p><table><thead><tr><th>字段</th><th>说明</th><th>示例</th></tr></thead><tbody><tr><td><strong>邮箱</strong></td><td>用户登录邮箱</td><td><a href="mailto:user@example.com">user@example.com</a></td></tr><tr><td><strong>用户名</strong></td><td>显示名称</td><td>张三</td></tr><tr><td><strong>密码</strong></td><td>初始密码</td><td>建议生成随机密码</td></tr><tr><td><strong>角色</strong></td><td>用户角色</td><td>User</td></tr></tbody></table><p>点击 “创建” 完成添加。</p><p><strong>步骤 3：通知用户</strong></p><p>将登录信息（邮箱和密码）发送给用户，建议用户首次登录后立即修改密码。</p><h3 id="批量导入用户（CSV）">批量导入用户（CSV）</h3><p>如果需要添加大量用户，可以使用 CSV 批量导入功能。</p><p><strong>步骤 1：准备 CSV 文件</strong></p><p>创建一个 CSV 文件，格式如下：</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></pre></td><td class="code"><pre><span class="line">email,name,password,role</span><br><span class="line">user1@example.com,用户1,password123,user</span><br><span class="line">user2@example.com,用户2,password456,user</span><br><span class="line">user3@example.com,用户3,password789,admin</span><br></pre></td></tr></table></figure><p><strong>注意</strong>：</p><ul><li>第一行是表头，必须包含这四个字段</li><li>role 可以是 <code>user</code>、<code>admin</code> 或 <code>pending</code></li><li>密码建议使用随机生成的强密码</li></ul><p><strong>步骤 2：导入</strong></p><p>管理员面板 → 用户 → 点击 “导入用户” 按钮</p><p>选择你准备好的 CSV 文件，点击上传。</p><p>系统会显示导入结果，包括成功和失败的记录。</p><h3 id="用户角色体系">用户角色体系</h3><p>Open WebUI 有三种用户角色：</p><table><thead><tr><th>角色</th><th>权限</th><th>适用对象</th></tr></thead><tbody><tr><td><strong>管理员</strong></td><td>完全控制权限，可以管理所有设置、用户、模型</td><td>系统管理员、技术负责人</td></tr><tr><td><strong>用户</strong></td><td>可以使用系统，创建对话，上传文档，但不能修改系统设置</td><td>普通团队成员</td></tr><tr><td><strong>待激活</strong></td><td>无法使用系统，等待管理员激活</td><td>新注册用户</td></tr></tbody></table><p><strong>修改用户角色</strong>：</p><p>管理员面板 → 用户 → 找到目标用户 → 点击角色下拉菜单 → 选择新角色</p><h3 id="权限组管理">权限组管理</h3><p>权限组功能允许你将用户分组，然后批量分配权限。</p><p><strong>创建权限组</strong>：</p><p>管理员面板 → 用户 → 权限组 → 点击 “创建权限组”</p><p>填写信息：</p><p><img src="https://prorise-blog.oss-cn-guangzhou.aliyuncs.com/cover/image-20260213092335260.png" alt="image-20260213092335260"></p><table><thead><tr><th>字段</th><th>说明</th><th>示例</th></tr></thead><tbody><tr><td><strong>组名</strong></td><td>权限组名称</td><td>开发团队</td></tr><tr><td><strong>描述</strong></td><td>组的说明</td><td>负责产品开发的团队成员</td></tr></tbody></table><p><strong>添加成员到权限组</strong>：</p><p>创建权限组后，点击 “添加成员” 按钮，选择要添加的用户。</p><p><strong>为权限组分配权限</strong>：</p><p>权限组创建后，你可以：</p><ul><li>将特定模型的访问权限分配给权限组</li><li>将知识库的访问权限分配给权限组</li><li>将工具和函数的使用权限分配给权限组</li></ul><p>这样，当你添加新成员到权限组时，他们会自动获得组的所有权限，无需逐个配置。</p><blockquote><p>💡 系统默认有一个&quot;默认权限&quot;组，用于所有具有&quot;用户&quot;角色的用户。你可以点击进入修改默认权限。</p></blockquote><h3 id="我的权限组设计">我的权限组设计</h3><p>我接入了大量模型（100+ 个），来源包括 AWS Bedrock、OpenAI、Google Gemini、GitHub Copilot、iFlow 代理、Moonshot 等多个渠道。不同渠道的成本差异很大，不能让所有用户无差别地使用全部模型。</p><p>我借助大模型分析了数据库中的模型列表和权限表结构，设计了三级权限组：</p><table><thead><tr><th>权限组</th><th>定位</th><th>可用模型数</th><th>说明</th></tr></thead><tbody><tr><td><strong>试用组</strong></td><td>新用户体验</td><td>13 个</td><td>仅提供轻量级免费/低成本模型，功能受限（不可多模型对话、不可创建频道等）</td></tr><tr><td><strong>正式组</strong></td><td>日常使用</td><td>77 个</td><td>拥有所有自有渠道模型的访问权限，功能完整</td></tr><tr><td><strong>管理组</strong></td><td>管理员</td><td>102 个（全部）</td><td>在正式组基础上额外拥有高成本第三方渠道模型（GitHub Copilot、SciHub 镜像）</td></tr></tbody></table><p><strong>试用组可用模型（13 个）</strong>：</p><p>只开放成本最低的轻量模型，让新用户体验基本功能：</p><ul><li>Claude Haiku 4.5 系列（AWS 直连 + Kiro 通道）</li><li>Gemini 2.5 Flash / Flash Lite</li><li>GPT-5 Codex Mini / GPT-5.1 Codex Mini</li><li>Qwen3 Coder Flash</li><li>Kiro GPT-3.5/4/4o（旧模型，成本极低）</li></ul><p><strong>正式组额外可用模型（+64 个）</strong>：</p><p>在试用组基础上，解锁所有自有渠道的中高端模型：</p><ul><li>Claude Sonnet 4/4.5、Opus 4.5/4.6 全系列（含 Thinking、Agentic 变体）</li><li>Gemini 2.5 Pro、3 Pro/Flash Preview</li><li>GPT-5/5.1/5.2/5.3 全系列</li><li>DeepSeek V3/V3.1/V3.2、R1（通过 iFlow）</li><li>Qwen3 Max/235B/Coder Plus（通过 iFlow）</li><li>Kimi K2/K2.5（Moonshot 直连 + iFlow）</li><li>GLM 4.6/4.7/5（通过 iFlow）</li><li>MiniMax M2/M2.1（通过 iFlow）</li></ul><p><strong>管理组专属模型（+25 个）</strong>：</p><p>这些模型走 GitHub Copilot 和 SciHub 镜像渠道，成本较高或属于特殊用途，仅管理员可用：</p><ul><li><code>gh-*</code> 系列（21 个）：GitHub Copilot 高级模型，包括 gh-gpt-5.2、gh-claude-opus-4.6、gh-gemini-3-pro-preview 等</li><li><code>scihub.*</code> 系列（4 个）：SciHub Claude 镜像，包括 scihub.claude-opus-4-6、scihub.claude-sonnet-4-5-20250929 等</li></ul><p><strong>权限组的功能差异</strong>：</p><p>除了模型访问权限，三个组在系统功能上也有区别：</p><table><thead><tr><th>功能</th><th>试用组</th><th>正式组</th><th>管理组</th></tr></thead><tbody><tr><td>多模型对话</td><td>❌</td><td>✅</td><td>✅</td></tr><tr><td>创建频道/文件夹</td><td>❌</td><td>✅</td><td>✅</td></tr><tr><td>知识库管理</td><td>❌</td><td>✅</td><td>✅</td></tr><tr><td>工具/函数管理</td><td>❌</td><td>✅</td><td>✅</td></tr><tr><td>图片生成</td><td>❌</td><td>✅</td><td>✅</td></tr><tr><td>笔记功能</td><td>❌</td><td>✅</td><td>✅</td></tr><tr><td>导入/导出模型</td><td>❌</td><td>✅</td><td>✅</td></tr><tr><td>界面设置</td><td>❌</td><td>❌</td><td>✅</td></tr><tr><td>临时对话强制</td><td>✅（强制）</td><td>❌</td><td>❌</td></tr></tbody></table><p><strong>模型图标规范</strong>：</p><p>为了让用户在模型列表中快速识别来源，我为每个模型统一配置了图标：</p><table><thead><tr><th>模型来源</th><th>图标</th></tr></thead><tbody><tr><td>Claude 系列</td><td>claude-color.svg</td></tr><tr><td>OpenAI GPT 系列</td><td>openai.svg</td></tr><tr><td>Gemini 系列</td><td>gemini-color.svg</td></tr><tr><td>Qwen 系列</td><td>qwen-color.svg</td></tr><tr><td>DeepSeek 系列</td><td>deepseek-color.svg</td></tr><tr><td>Kimi 系列</td><td>moonshot.svg</td></tr><tr><td>MiniMax 系列</td><td>minimax-color.svg</td></tr><tr><td>GLM 系列</td><td>zhipu-color.svg</td></tr><tr><td>Grok 系列</td><td>grok.svg</td></tr><tr><td>Kiro（AWS 通道）</td><td>aws-color.svg</td></tr></tbody></table><p>图标统一使用 <code>cdn.jsdelivr.net/npm/@lobehub/icons-static-svg@1.79.0/icons/</code> 的 SVG 资源。</p><p><strong>批量配置方法</strong>：</p><p>手动逐个配置 100+ 个模型的权限和图标不现实。我的做法是：</p><ol><li>在 Open WebUI 管理面板导出模型列表（JSON 格式）</li><li>用大模型编写 Python 脚本，根据模型 ID 前缀和名称自动推断图标和权限组</li><li>脚本批量写入 <code>access_grants</code>（新版格式）和 <code>meta.profile_image_url</code></li><li>将修正后的 JSON 重新导入 Open WebUI</li></ol><p>这样每次上游新增模型或系统升级后，只需重新导出 → 跑脚本 → 导入，几分钟就能完成全部配置。</p><blockquote><p>⚠️ 注意：Open WebUI 升级后数据库结构可能变化。例如从旧版升级到新版时，模型权限从 <code>model</code> 表的 <code>access_control</code> 字段迁移到了独立的 <code>access_grant</code> 表，导出格式也从 <code>access_control</code> 对象变成了 <code>access_grants</code> 数组。升级后建议先导出一份检查格式再操作。</p></blockquote><h3 id="用户活动监控">用户活动监控</h3><p>Open WebUI 提供了用户活动监控功能，帮助你了解系统使用情况。</p><p><strong>查看活跃用户</strong>：</p><p>管理员面板 → 设置 → 通用 → 找到 “显示活跃用户数” 选项</p><p>启用后，在主界面底部会显示当前活跃用户数和正在使用的模型。</p><p><strong>查看用户详情</strong>：</p><p>管理员面板 → 用户 → 点击用户名</p><p>你可以看到：</p><ul><li>创建的对话数量</li><li>使用的模型统计</li></ul><p><strong>注意</strong>：Open WebUI 不会记录用户的具体对话内容，只记录统计信息，保护用户隐私。</p><p><img src="https://prorise-blog.oss-cn-guangzhou.aliyuncs.com/cover/image-20260213092922828.png" alt="image-20260213092922828"></p><hr></div>]]></content>
    
    
      
      
        
        
    <summary type="html">&lt;div id=&quot;postchat_postcontent&quot;&gt;&lt;h2 id=&quot;3-3-用户与权限管理&quot;&gt;3.3. 用户与权限管理&lt;/h2&gt;
&lt;p&gt;Open WebUI 提供了完善的多用户管理功能，适合团队协作使用。&lt;/p&gt;
&lt;h3</summary>
        
      
    
    
    
    <category term="一人公司系列" scheme="https://prorise666.site/categories/%E4%B8%80%E4%BA%BA%E5%85%AC%E5%8F%B8%E7%B3%BB%E5%88%97/"/>
    
    <category term="办公提效方向" scheme="https://prorise666.site/categories/%E4%B8%80%E4%BA%BA%E5%85%AC%E5%8F%B8%E7%B3%BB%E5%88%97/%E5%8A%9E%E5%85%AC%E6%8F%90%E6%95%88%E6%96%B9%E5%90%91/"/>
    
    <category term="OpenWebUi" scheme="https://prorise666.site/categories/%E4%B8%80%E4%BA%BA%E5%85%AC%E5%8F%B8%E7%B3%BB%E5%88%97/%E5%8A%9E%E5%85%AC%E6%8F%90%E6%95%88%E6%96%B9%E5%90%91/OpenWebUi/"/>
    
    
  </entry>
  
  <entry>
    <title>OpenWebUi-模型连接与管理</title>
    <link href="https://prorise666.site/posts/3036.html"/>
    <id>https://prorise666.site/posts/3036.html</id>
    <published>2026-02-27T04:38:17.000Z</published>
    <updated>2026-03-12T09:18:16.915Z</updated>
    
    <content type="html"><![CDATA[<div id="postchat_postcontent"><h2 id="3-2-模型连接与管理">3.2. 模型连接与管理</h2><p>模型连接是 Open WebUI 最核心的配置。没有模型，Open WebUI 就无法工作。</p><h3 id="连接本地-Ollama">连接本地 Ollama</h3><p>如果你在本地运行了 Ollama，需要在 Open WebUI 中配置连接。</p><p><strong>步骤 1：进入连接设置</strong></p><p>管理员面板 → 设置 → 外部连接（Connections）</p><p><img src="https://prorise-blog.oss-cn-guangzhou.aliyuncs.com/cover/image-20260211090849150.png" alt="image-20260211090849150"></p><p><strong>步骤 2：配置 Ollama API URL</strong></p><p>在 “Ollama API URL” 字段中填入：</p><table><thead><tr><th>部署方式</th><th>URL</th></tr></thead><tbody><tr><td>Docker 部署（Ollama 在主机）</td><td><code>http://host.docker.internal:11434</code></td></tr><tr><td>Docker Compose（Ollama 在容器）</td><td><code>http://ollama:11434</code></td></tr><tr><td>Python 安装（Ollama 在主机）</td><td><code>http://localhost:11434</code></td></tr></tbody></table><p><strong>步骤 3：验证连接</strong></p><p>点击 URL 输入框右侧的刷新按钮（🔄）。</p><p>如果连接成功，下方会显示 “连接成功” 的提示，并且会自动拉取 Ollama 中的模型列表。</p><p><strong>步骤 4：配置多个 Ollama 实例（可选）</strong></p><p>如果你有多台服务器运行 Ollama，可以添加多个连接。Open WebUI 会自动进行负载均衡。</p><p>点击 “添加 Ollama 实例” 按钮，填入新的 URL，例如：</p><p><a href="http://192.168.1.100:11434">http://192.168.1.100:11434</a><br><a href="http://192.168.1.101:11434">http://192.168.1.101:11434</a></p><p>这样，当用户发起请求时，Open WebUI 会自动选择负载较低的实例。</p><h3 id="连接-OpenAI-API">连接 OpenAI API</h3><p>如果你想使用 OpenAI 的 GPT 模型，需要配置 OpenAI API。</p><p><strong>步骤 1：获取 API 密钥</strong></p><p>访问 OpenAI 官网：<a href="https://platform.openai.com/api-keys">https://platform.openai.com/api-keys</a></p><p>登录后，点击 “Create new secret key” 创建一个新的 API 密钥。</p><p><strong>重要</strong>：API 密钥只会显示一次，请妥善保存。</p><p><strong>步骤 2：在 Open WebUI 中配置</strong></p><p>管理员面板 → 设置 → 外部连接 → OpenAI</p><table><thead><tr><th>字段</th><th>值</th><th>说明</th></tr></thead><tbody><tr><td><strong>API Base URL</strong></td><td><code>https://api.openai.com/v1</code></td><td>OpenAI 官方 API 地址</td></tr><tr><td><strong>API Key</strong></td><td><code>sk-...</code></td><td>你的 API 密钥</td></tr></tbody></table><p><strong>步骤 3：验证连接</strong></p><p>点击刷新按钮，如果连接成功，会自动拉取可用的模型列表（如 gpt-4、gpt-3.5-turbo 等）。</p><p><strong>步骤 4：配置代理（如果需要）</strong></p><p>如果你的网络无法直接访问 OpenAI，可以配置代理：</p><p>在 Docker 部署中，添加环境变量：</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></pre></td><td class="code"><pre><span class="line"><span class="attr">environment:</span></span><br><span class="line">  <span class="bullet">-</span> <span class="string">HTTP_PROXY=http://your-proxy:port</span></span><br><span class="line">  <span class="bullet">-</span> <span class="string">HTTPS_PROXY=http://your-proxy:port</span></span><br></pre></td></tr></table></figure><p>或者使用国内的 OpenAI API 中转服务（需要自行寻找可靠的服务商）。</p><h3 id="连接其他兼容-API">连接其他兼容 API</h3><p>Open WebUI 支持任何兼容 OpenAI API 格式的服务，包括：</p><table><thead><tr><th>服务商</th><th>API Base URL 示例</th><th>说明</th></tr></thead><tbody><tr><td><strong>Azure OpenAI</strong></td><td><code>https://your-resource.openai.azure.com/</code></td><td>需要额外配置 API 版本</td></tr><tr><td><strong>Anthropic Claude</strong></td><td><code>https://api.anthropic.com/v1</code></td><td>需要 Claude API 密钥</td></tr><tr><td><strong>Google Gemini</strong></td><td>通过兼容层</td><td>需要使用 LiteLLM 等工具转换</td></tr><tr><td><strong>DeepSeek</strong></td><td><code>https://api.deepseek.com/v1</code></td><td>国内可直接访问</td></tr><tr><td><strong>Groq</strong></td><td><code>https://api.groq.com/openai/v1</code></td><td>速度极快的推理服务</td></tr><tr><td><strong>本地 vLLM</strong></td><td><code>http://localhost:8000/v1</code></td><td>自建推理服务</td></tr></tbody></table><p><strong>配置步骤</strong>：</p><p>管理员面板 → 设置 → 外部连接 → OpenAI → 点击 “+” 添加新连接</p><p>填写：</p><ul><li><strong>名称</strong>：给这个连接起个名字（如 “DeepSeek”）</li><li><strong>API Base URL</strong>：服务商的 API 地址</li><li><strong>API Key</strong>：对应的 API 密钥</li></ul><p><strong>Azure OpenAI 特殊配置</strong>：</p><p>Azure OpenAI 需要额外配置 API 版本和部署名称。在 API Base URL 中包含这些信息：</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">https://your-resource.openai.azure.com/openai/deployments/your-deployment-name?api-version=2024-02-15-preview</span><br></pre></td></tr></table></figure><h3 id="模型可见性与权限控制">模型可见性与权限控制</h3><p>默认情况下，所有用户都能看到所有模型。但在团队使用场景中，你可能希望控制哪些用户能访问哪些模型。</p><p><img src="https://prorise-blog.oss-cn-guangzhou.aliyuncs.com/cover/image-20260211204338312.png" alt="image-20260211204338312"></p><p><strong>步骤 1：进入模型设置</strong></p><p>管理员面板 → 设置 → 模型（Models）</p><p><strong>步骤 2：编辑模型</strong></p><p>找到你想要控制的模型，点击右侧的编辑按钮（✏️）。</p><p><strong>步骤 3：设置可见性</strong></p><p>在模型编辑页面，你会看到以下选项：</p><table><thead><tr><th>选项</th><th>说明</th></tr></thead><tbody><tr><td><strong>公开（Public）</strong></td><td>所有用户都能看到和使用</td></tr><tr><td><strong>私有（Private）</strong></td><td>只有管理员能看到</td></tr><tr><td><strong>指定用户</strong></td><td>只有选中的用户能看到</td></tr><tr><td><strong>指定权限组</strong></td><td>只有选中的权限组能看到</td></tr></tbody></table><p>选择合适的可见性，然后点击保存。</p><p><strong>实际应用场景</strong>：</p><ul><li>将昂贵的 GPT-4 模型设为 “指定用户”，只给核心团队成员使用</li><li>将实验性模型设为 “私有”，只有管理员测试</li><li>将免费的本地模型设为 “公开”，所有人都能使用</li></ul><h3 id="模型白名单">模型白名单</h3><p>如果你连接了多个 API，可能会拉取到很多模型。你可以使用白名单功能，只显示需要的模型。</p><p><strong>步骤 1：进入设置</strong></p><p>管理员面板 → 设置 → 模型（Models）</p><p><strong>步骤 2：启用白名单</strong></p><p>找到 “模型白名单” 选项，启用它。</p><p><strong>步骤 3：添加模型</strong></p><p>在白名单中添加你想要显示的模型 ID，每行一个：</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></pre></td><td class="code"><pre><span class="line">gpt-4</span><br><span class="line">gpt-3.5-turbo</span><br><span class="line">qwen2.5:7b</span><br><span class="line">deepseek-chat</span><br></pre></td></tr></table></figure><p>保存后，只有白名单中的模型会显示给用户。</p><h3 id="模型标签与排序">模型标签与排序</h3><p>为了让用户更容易找到合适的模型，你可以给模型添加标签和自定义排序。</p><p><strong>添加标签</strong>：</p><p>在模型编辑页面，找到 “标签（Tags）” 字段，添加标签：</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">推荐, 快速, 免费</span><br></pre></td></tr></table></figure><p>或</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">高级, 付费, GPT-4</span><br></pre></td></tr></table></figure><p>用户在选择模型时，可以通过标签筛选。</p><p><strong>自定义排序</strong>：</p><p>在设置 → 模型页面，你可以拖动模型来调整顺序。排在前面的模型会优先显示给用户。</p><p><strong>最佳实践</strong>：</p><ul><li>将最常用的模型排在最前面</li><li>将免费模型和付费模型用标签区分</li><li>将不同能力的模型分类（如 “对话”、“编程”、“翻译”）</li></ul><h3 id="模型参数预设">模型参数预设</h3><p>你可以为每个模型设置默认参数，用户使用时会自动应用这些参数。</p><p><strong>步骤 1：编辑模型</strong></p><p>设置 → 模型 → 点击模型的编辑按钮</p><p><strong>步骤 2：配置参数</strong></p><p>在 “模型参数” 区域，设置以下参数：</p><table><thead><tr><th>参数</th><th>说明</th><th>推荐值</th></tr></thead><tbody><tr><td><strong>Temperature</strong></td><td>控制输出的随机性，0-2</td><td>0.7（平衡）</td></tr><tr><td><strong>Top P</strong></td><td>核采样参数，0-1</td><td>0.9</td></tr><tr><td><strong>Max Tokens</strong></td><td>最大输出长度</td><td>2048</td></tr><tr><td><strong>Context Length</strong></td><td>上下文窗口大小</td><td>4096</td></tr><tr><td><strong>Frequency Penalty</strong></td><td>降低重复内容，-2 到 2</td><td>0</td></tr><tr><td><strong>Presence Penalty</strong></td><td>鼓励新话题，-2 到 2</td><td>0</td></tr></tbody></table><p><strong>参数说明</strong>：</p><p><strong>Temperature</strong>：</p><ul><li>0.1-0.3：输出非常确定，适合事实性任务（如翻译、总结）</li><li>0.7-0.9：平衡创造性和准确性，适合日常对话</li><li>1.0-2.0：输出更有创造性，适合创意写作</li></ul><p><strong>Top P</strong>：</p><ul><li>0.9：推荐值，保持输出质量</li><li>0.95：更多样化的输出</li><li>1.0：完全随机（不推荐）</li></ul><p><strong>Context Length</strong>：</p><ul><li>对于 Ollama 模型，默认是 2048，这太小了</li><li>建议设置为 8192 或更高，特别是使用 RAG 功能时</li><li>注意：更大的上下文会消耗更多内存</li></ul><hr></div>]]></content>
    
    
      
      
        
        
    <summary type="html">&lt;div id=&quot;postchat_postcontent&quot;&gt;&lt;h2 id=&quot;3-2-模型连接与管理&quot;&gt;3.2. 模型连接与管理&lt;/h2&gt;
&lt;p&gt;模型连接是 Open WebUI 最核心的配置。没有模型，Open WebUI 就无法工作。&lt;/p&gt;
&lt;h3</summary>
        
      
    
    
    
    <category term="一人公司系列" scheme="https://prorise666.site/categories/%E4%B8%80%E4%BA%BA%E5%85%AC%E5%8F%B8%E7%B3%BB%E5%88%97/"/>
    
    <category term="办公提效方向" scheme="https://prorise666.site/categories/%E4%B8%80%E4%BA%BA%E5%85%AC%E5%8F%B8%E7%B3%BB%E5%88%97/%E5%8A%9E%E5%85%AC%E6%8F%90%E6%95%88%E6%96%B9%E5%90%91/"/>
    
    <category term="OpenWebUi" scheme="https://prorise666.site/categories/%E4%B8%80%E4%BA%BA%E5%85%AC%E5%8F%B8%E7%B3%BB%E5%88%97/%E5%8A%9E%E5%85%AC%E6%8F%90%E6%95%88%E6%96%B9%E5%90%91/OpenWebUi/"/>
    
    
  </entry>
  
  <entry>
    <title>OpenWebUi-管理员面板导航</title>
    <link href="https://prorise666.site/posts/16811.html"/>
    <id>https://prorise666.site/posts/16811.html</id>
    <published>2026-02-27T03:38:17.000Z</published>
    <updated>2026-03-12T09:18:16.915Z</updated>
    
    <content type="html"><![CDATA[<div id="postchat_postcontent"><h2 id="3-1-管理员面板导航">3.1. 管理员面板导航</h2><h3 id="进入管理员面板">进入管理员面板</h3><p>登录 Open WebUI 后，点击左下角的用户名区域，在弹出菜单中选择 “管理员面板”（Admin Panel）。</p><p>如果你看不到这个选项，说明你的账号不是管理员。记住，只有第一个注册的账号才会自动成为管理员。</p><p><img src="https://prorise-blog.oss-cn-guangzhou.aliyuncs.com/cover/image-20260211090523992.png" alt="image-20260211090523992"></p><h3 id="管理员面板结构">管理员面板结构</h3><p>管理员面板的顶部有 <strong>四个主标签页</strong>，每个标签页左侧有各自的子菜单：</p><p><img src="https://prorise-blog.oss-cn-guangzhou.aliyuncs.com/cover/image-20260211090544115.png" alt="image-20260211090544115"></p><h4 id="用户">用户</h4><p>管理所有用户和权限组。</p><table><thead><tr><th>子菜单</th><th>功能</th></tr></thead><tbody><tr><td><strong>概述</strong></td><td>用户列表，显示角色、名称、邮箱、最后在线时间、创建时间。支持搜索和添加用户（右上角 <code>+</code> 按钮）</td></tr><tr><td><strong>权限组</strong></td><td>创建权限组，将用户分组并批量分配权限。默认有一个&quot;默认权限&quot;组，用于所有&quot;用户&quot;角色的用户</td></tr></tbody></table><h4 id="竞技场评估">竞技场评估</h4><p>模型对比评估功能，让用户盲测不同模型的回答质量。</p><p><img src="https://prorise-blog.oss-cn-guangzhou.aliyuncs.com/cover/image-20260211090658945.png" alt="image-20260211090658945"></p><table><thead><tr><th>子菜单</th><th>功能</th></tr></thead><tbody><tr><td><strong>排行榜</strong></td><td>显示所有模型的排名（RK）、评价、获胜/落败次数</td></tr><tr><td><strong>反馈</strong></td><td>查看用户对模型回答的反馈记录</td></tr></tbody></table><h4 id="函数">函数</h4><p><img src="https://prorise-blog.oss-cn-guangzhou.aliyuncs.com/cover/image-20260211090714357.png" alt="image-20260211090714357"></p><p>管理 Open WebUI 的内置扩展函数（Functions），这是官方推荐的扩展方式。</p><ul><li>顶部有 <strong>导入</strong> 和 <strong>+ 新函数</strong> 按钮</li><li>支持按类型（全部）和标签筛选</li><li>底部有 <strong>“由 Open WebUI 社区开发”</strong> 入口，可以发现和下载社区函数</li></ul><h4 id="设置">设置</h4><p><img src="https://prorise-blog.oss-cn-guangzhou.aliyuncs.com/cover/image-20260211090730293.png" alt="image-20260211090730293"></p><p>系统全局配置，左侧子菜单最为丰富：</p><table><thead><tr><th>子菜单</th><th>功能</th><th>对应本章节</th></tr></thead><tbody><tr><td><strong>通用</strong></td><td>版本信息、身份验证（默认用户角色、注册开关）、管理员邮箱、待激活用户界面配置</td><td>3.3</td></tr><tr><td><strong>外部连接</strong></td><td>Ollama API 和 OpenAI API 的连接配置</td><td>3.2</td></tr><tr><td><strong>模型</strong></td><td>默认模型、任务模型、模型白名单等</td><td>3.2</td></tr><tr><td><strong>竞技场评估</strong></td><td>竞技场模式的全局设置</td><td>—</td></tr><tr><td><strong>外部工具</strong></td><td>外部工具集成配置</td><td>—</td></tr><tr><td><strong>Documents</strong></td><td>RAG 文档功能配置，包括向量数据库、Embedding 模型、检索参数</td><td>3.4</td></tr><tr><td><strong>联网搜索</strong></td><td>网络搜索引擎配置（SearXNG、Google PSE、Brave 等）</td><td>3.7</td></tr><tr><td><strong>Code Execution</strong></td><td>代码执行环境配置</td><td>—</td></tr><tr><td><strong>界面</strong></td><td>UI 界面定制</td><td>3.10</td></tr><tr><td><strong>语音</strong></td><td>STT（语音转文字）和 TTS（文字转语音）配置</td><td>3.6</td></tr><tr><td><strong>Images</strong></td><td>图像生成引擎配置（DALL-E、ComfyUI 等）</td><td>3.5</td></tr><tr><td><strong>Pipelines</strong></td><td>Pipelines 插件服务连接配置</td><td>3.8</td></tr><tr><td><strong>数据库</strong></td><td>数据导入导出、系统维护</td><td>—</td></tr></tbody></table><h3 id="配置优先级说明">配置优先级说明</h3><p>Open WebUI 的配置有两个来源：</p><p><strong>环境变量</strong>：在启动容器或 Python 程序时设置的变量（如 <code>OLLAMA_BASE_URL</code>）。</p><p><strong>数据库配置</strong>：在管理员面板中设置的配置，保存在数据库中。</p><p><strong>重要规则</strong>：对于标记为 <code>PersistentConfig</code> 的配置项，数据库中的配置优先级高于环境变量。这意味着：</p><ul><li>首次启动时，环境变量会被写入数据库</li><li>之后修改环境变量不会生效，除非删除数据库中的配置</li><li>如果要强制使用环境变量，需要在管理员面板中清除对应配置</li></ul><p>这个设计是为了避免配置混乱，确保配置的一致性。</p><hr></div>]]></content>
    
    
      
      
        
        
    <summary type="html">&lt;div id=&quot;postchat_postcontent&quot;&gt;&lt;h2 id=&quot;3-1-管理员面板导航&quot;&gt;3.1. 管理员面板导航&lt;/h2&gt;
&lt;h3 id=&quot;进入管理员面板&quot;&gt;进入管理员面板&lt;/h3&gt;
&lt;p&gt;登录 Open WebUI 后，点击左下角的用户名区域，在弹出菜单中选择</summary>
        
      
    
    
    
    <category term="一人公司系列" scheme="https://prorise666.site/categories/%E4%B8%80%E4%BA%BA%E5%85%AC%E5%8F%B8%E7%B3%BB%E5%88%97/"/>
    
    <category term="办公提效方向" scheme="https://prorise666.site/categories/%E4%B8%80%E4%BA%BA%E5%85%AC%E5%8F%B8%E7%B3%BB%E5%88%97/%E5%8A%9E%E5%85%AC%E6%8F%90%E6%95%88%E6%96%B9%E5%90%91/"/>
    
    <category term="OpenWebUi" scheme="https://prorise666.site/categories/%E4%B8%80%E4%BA%BA%E5%85%AC%E5%8F%B8%E7%B3%BB%E5%88%97/%E5%8A%9E%E5%85%AC%E6%8F%90%E6%95%88%E6%96%B9%E5%90%91/OpenWebUi/"/>
    
    
  </entry>
  
  <entry>
    <title>第三章. 管理员完整配置指南</title>
    <link href="https://prorise666.site/posts/54154.html"/>
    <id>https://prorise666.site/posts/54154.html</id>
    <published>2026-02-26T05:38:17.000Z</published>
    <updated>2026-03-12T09:18:16.915Z</updated>
    
    <content type="html"><![CDATA[<div id="postchat_postcontent"><h1>第三章. 管理员完整配置指南</h1><p>在上一章中，我们成功在本地部署了 Open WebUI，并完成了基础的启动验证。现在，作为管理员，你需要对系统进行完整的配置，让它真正为你和你的团队服务。</p><p>本章将带你深入管理员面板，完成从模型连接、用户管理到高级功能的全部配置。在开始之前，请确保你已经以管理员身份登录 Open WebUI。</p><hr><h2 id="3-1-管理员面板导航">3.1. 管理员面板导航</h2><h3 id="进入管理员面板">进入管理员面板</h3><p>登录 Open WebUI 后，点击左下角的用户名区域，在弹出菜单中选择 “管理员面板”（Admin Panel）。</p><p>如果你看不到这个选项，说明你的账号不是管理员。记住，只有第一个注册的账号才会自动成为管理员。</p><p><img src="https://prorise-blog.oss-cn-guangzhou.aliyuncs.com/cover/image-20260211090523992.png" alt="image-20260211090523992"></p><h3 id="管理员面板结构">管理员面板结构</h3><p>管理员面板的顶部有 <strong>四个主标签页</strong>，每个标签页左侧有各自的子菜单：</p><p><img src="https://prorise-blog.oss-cn-guangzhou.aliyuncs.com/cover/image-20260211090544115.png" alt="image-20260211090544115"></p><h4 id="用户">用户</h4><p>管理所有用户和权限组。</p><table><thead><tr><th>子菜单</th><th>功能</th></tr></thead><tbody><tr><td><strong>概述</strong></td><td>用户列表，显示角色、名称、邮箱、最后在线时间、创建时间。支持搜索和添加用户（右上角 <code>+</code> 按钮）</td></tr><tr><td><strong>权限组</strong></td><td>创建权限组，将用户分组并批量分配权限。默认有一个&quot;默认权限&quot;组，用于所有&quot;用户&quot;角色的用户</td></tr></tbody></table><h4 id="竞技场评估">竞技场评估</h4><p>模型对比评估功能，让用户盲测不同模型的回答质量。</p><p><img src="https://prorise-blog.oss-cn-guangzhou.aliyuncs.com/cover/image-20260211090658945.png" alt="image-20260211090658945"></p><table><thead><tr><th>子菜单</th><th>功能</th></tr></thead><tbody><tr><td><strong>排行榜</strong></td><td>显示所有模型的排名（RK）、评价、获胜/落败次数</td></tr><tr><td><strong>反馈</strong></td><td>查看用户对模型回答的反馈记录</td></tr></tbody></table><h4 id="函数">函数</h4><p><img src="https://prorise-blog.oss-cn-guangzhou.aliyuncs.com/cover/image-20260211090714357.png" alt="image-20260211090714357"></p><p>管理 Open WebUI 的内置扩展函数（Functions），这是官方推荐的扩展方式。</p><ul><li>顶部有 <strong>导入</strong> 和 <strong>+ 新函数</strong> 按钮</li><li>支持按类型（全部）和标签筛选</li><li>底部有 <strong>“由 Open WebUI 社区开发”</strong> 入口，可以发现和下载社区函数</li></ul><h4 id="设置">设置</h4><p><img src="https://prorise-blog.oss-cn-guangzhou.aliyuncs.com/cover/image-20260211090730293.png" alt="image-20260211090730293"></p><p>系统全局配置，左侧子菜单最为丰富：</p><table><thead><tr><th>子菜单</th><th>功能</th><th>对应本章节</th></tr></thead><tbody><tr><td><strong>通用</strong></td><td>版本信息、身份验证（默认用户角色、注册开关）、管理员邮箱、待激活用户界面配置</td><td>3.3</td></tr><tr><td><strong>外部连接</strong></td><td>Ollama API 和 OpenAI API 的连接配置</td><td>3.2</td></tr><tr><td><strong>模型</strong></td><td>默认模型、任务模型、模型白名单等</td><td>3.2</td></tr><tr><td><strong>竞技场评估</strong></td><td>竞技场模式的全局设置</td><td>—</td></tr><tr><td><strong>外部工具</strong></td><td>外部工具集成配置</td><td>—</td></tr><tr><td><strong>Documents</strong></td><td>RAG 文档功能配置，包括向量数据库、Embedding 模型、检索参数</td><td>3.4</td></tr><tr><td><strong>联网搜索</strong></td><td>网络搜索引擎配置（SearXNG、Google PSE、Brave 等）</td><td>3.7</td></tr><tr><td><strong>Code Execution</strong></td><td>代码执行环境配置</td><td>—</td></tr><tr><td><strong>界面</strong></td><td>UI 界面定制</td><td>3.10</td></tr><tr><td><strong>语音</strong></td><td>STT（语音转文字）和 TTS（文字转语音）配置</td><td>3.6</td></tr><tr><td><strong>Images</strong></td><td>图像生成引擎配置（DALL-E、ComfyUI 等）</td><td>3.5</td></tr><tr><td><strong>Pipelines</strong></td><td>Pipelines 插件服务连接配置</td><td>3.8</td></tr><tr><td><strong>数据库</strong></td><td>数据导入导出、系统维护</td><td>—</td></tr></tbody></table><h3 id="配置优先级说明">配置优先级说明</h3><p>Open WebUI 的配置有两个来源：</p><p><strong>环境变量</strong>：在启动容器或 Python 程序时设置的变量（如 <code>OLLAMA_BASE_URL</code>）。</p><p><strong>数据库配置</strong>：在管理员面板中设置的配置，保存在数据库中。</p><p><strong>重要规则</strong>：对于标记为 <code>PersistentConfig</code> 的配置项，数据库中的配置优先级高于环境变量。这意味着：</p><ul><li>首次启动时，环境变量会被写入数据库</li><li>之后修改环境变量不会生效，除非删除数据库中的配置</li><li>如果要强制使用环境变量，需要在管理员面板中清除对应配置</li></ul><p>这个设计是为了避免配置混乱，确保配置的一致性。</p><hr><h2 id="3-2-模型连接与管理">3.2. 模型连接与管理</h2><p>模型连接是 Open WebUI 最核心的配置。没有模型，Open WebUI 就无法工作。</p><h3 id="连接本地-Ollama">连接本地 Ollama</h3><p>如果你在本地运行了 Ollama，需要在 Open WebUI 中配置连接。</p><p><strong>步骤 1：进入连接设置</strong></p><p>管理员面板 → 设置 → 外部连接（Connections）</p><p><img src="https://prorise-blog.oss-cn-guangzhou.aliyuncs.com/cover/image-20260211090849150.png" alt="image-20260211090849150"></p><p><strong>步骤 2：配置 Ollama API URL</strong></p><p>在 “Ollama API URL” 字段中填入：</p><table><thead><tr><th>部署方式</th><th>URL</th></tr></thead><tbody><tr><td>Docker 部署（Ollama 在主机）</td><td><code>http://host.docker.internal:11434</code></td></tr><tr><td>Docker Compose（Ollama 在容器）</td><td><code>http://ollama:11434</code></td></tr><tr><td>Python 安装（Ollama 在主机）</td><td><code>http://localhost:11434</code></td></tr></tbody></table><p><strong>步骤 3：验证连接</strong></p><p>点击 URL 输入框右侧的刷新按钮（🔄）。</p><p>如果连接成功，下方会显示 “连接成功” 的提示，并且会自动拉取 Ollama 中的模型列表。</p><p><strong>步骤 4：配置多个 Ollama 实例（可选）</strong></p><p>如果你有多台服务器运行 Ollama，可以添加多个连接。Open WebUI 会自动进行负载均衡。</p><p>点击 “添加 Ollama 实例” 按钮，填入新的 URL，例如：</p><p><a href="http://192.168.1.100:11434">http://192.168.1.100:11434</a><br><a href="http://192.168.1.101:11434">http://192.168.1.101:11434</a></p><p>这样，当用户发起请求时，Open WebUI 会自动选择负载较低的实例。</p><h3 id="连接-OpenAI-API">连接 OpenAI API</h3><p>如果你想使用 OpenAI 的 GPT 模型，需要配置 OpenAI API。</p><p><strong>步骤 1：获取 API 密钥</strong></p><p>访问 OpenAI 官网：<a href="https://platform.openai.com/api-keys">https://platform.openai.com/api-keys</a></p><p>登录后，点击 “Create new secret key” 创建一个新的 API 密钥。</p><p><strong>重要</strong>：API 密钥只会显示一次，请妥善保存。</p><p><strong>步骤 2：在 Open WebUI 中配置</strong></p><p>管理员面板 → 设置 → 外部连接 → OpenAI</p><table><thead><tr><th>字段</th><th>值</th><th>说明</th></tr></thead><tbody><tr><td><strong>API Base URL</strong></td><td><code>https://api.openai.com/v1</code></td><td>OpenAI 官方 API 地址</td></tr><tr><td><strong>API Key</strong></td><td><code>sk-...</code></td><td>你的 API 密钥</td></tr></tbody></table><p><strong>步骤 3：验证连接</strong></p><p>点击刷新按钮，如果连接成功，会自动拉取可用的模型列表（如 gpt-4、gpt-3.5-turbo 等）。</p><p><strong>步骤 4：配置代理（如果需要）</strong></p><p>如果你的网络无法直接访问 OpenAI，可以配置代理：</p><p>在 Docker 部署中，添加环境变量：</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></pre></td><td class="code"><pre><span class="line"><span class="attr">environment:</span></span><br><span class="line">  <span class="bullet">-</span> <span class="string">HTTP_PROXY=http://your-proxy:port</span></span><br><span class="line">  <span class="bullet">-</span> <span class="string">HTTPS_PROXY=http://your-proxy:port</span></span><br></pre></td></tr></table></figure><p>或者使用国内的 OpenAI API 中转服务（需要自行寻找可靠的服务商）。</p><h3 id="连接其他兼容-API">连接其他兼容 API</h3><p>Open WebUI 支持任何兼容 OpenAI API 格式的服务，包括：</p><table><thead><tr><th>服务商</th><th>API Base URL 示例</th><th>说明</th></tr></thead><tbody><tr><td><strong>Azure OpenAI</strong></td><td><code>https://your-resource.openai.azure.com/</code></td><td>需要额外配置 API 版本</td></tr><tr><td><strong>Anthropic Claude</strong></td><td><code>https://api.anthropic.com/v1</code></td><td>需要 Claude API 密钥</td></tr><tr><td><strong>Google Gemini</strong></td><td>通过兼容层</td><td>需要使用 LiteLLM 等工具转换</td></tr><tr><td><strong>DeepSeek</strong></td><td><code>https://api.deepseek.com/v1</code></td><td>国内可直接访问</td></tr><tr><td><strong>Groq</strong></td><td><code>https://api.groq.com/openai/v1</code></td><td>速度极快的推理服务</td></tr><tr><td><strong>本地 vLLM</strong></td><td><code>http://localhost:8000/v1</code></td><td>自建推理服务</td></tr></tbody></table><p><strong>配置步骤</strong>：</p><p>管理员面板 → 设置 → 外部连接 → OpenAI → 点击 “+” 添加新连接</p><p>填写：</p><ul><li><strong>名称</strong>：给这个连接起个名字（如 “DeepSeek”）</li><li><strong>API Base URL</strong>：服务商的 API 地址</li><li><strong>API Key</strong>：对应的 API 密钥</li></ul><p><strong>Azure OpenAI 特殊配置</strong>：</p><p>Azure OpenAI 需要额外配置 API 版本和部署名称。在 API Base URL 中包含这些信息：</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">https://your-resource.openai.azure.com/openai/deployments/your-deployment-name?api-version=2024-02-15-preview</span><br></pre></td></tr></table></figure><h3 id="模型可见性与权限控制">模型可见性与权限控制</h3><p>默认情况下，所有用户都能看到所有模型。但在团队使用场景中，你可能希望控制哪些用户能访问哪些模型。</p><p><img src="https://prorise-blog.oss-cn-guangzhou.aliyuncs.com/cover/image-20260211204338312.png" alt="image-20260211204338312"></p><p><strong>步骤 1：进入模型设置</strong></p><p>管理员面板 → 设置 → 模型（Models）</p><p><strong>步骤 2：编辑模型</strong></p><p>找到你想要控制的模型，点击右侧的编辑按钮（✏️）。</p><p><strong>步骤 3：设置可见性</strong></p><p>在模型编辑页面，你会看到以下选项：</p><table><thead><tr><th>选项</th><th>说明</th></tr></thead><tbody><tr><td><strong>公开（Public）</strong></td><td>所有用户都能看到和使用</td></tr><tr><td><strong>私有（Private）</strong></td><td>只有管理员能看到</td></tr><tr><td><strong>指定用户</strong></td><td>只有选中的用户能看到</td></tr><tr><td><strong>指定权限组</strong></td><td>只有选中的权限组能看到</td></tr></tbody></table><p>选择合适的可见性，然后点击保存。</p><p><strong>实际应用场景</strong>：</p><ul><li>将昂贵的 GPT-4 模型设为 “指定用户”，只给核心团队成员使用</li><li>将实验性模型设为 “私有”，只有管理员测试</li><li>将免费的本地模型设为 “公开”，所有人都能使用</li></ul><h3 id="模型白名单">模型白名单</h3><p>如果你连接了多个 API，可能会拉取到很多模型。你可以使用白名单功能，只显示需要的模型。</p><p><strong>步骤 1：进入设置</strong></p><p>管理员面板 → 设置 → 模型（Models）</p><p><strong>步骤 2：启用白名单</strong></p><p>找到 “模型白名单” 选项，启用它。</p><p><strong>步骤 3：添加模型</strong></p><p>在白名单中添加你想要显示的模型 ID，每行一个：</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></pre></td><td class="code"><pre><span class="line">gpt-4</span><br><span class="line">gpt-3.5-turbo</span><br><span class="line">qwen2.5:7b</span><br><span class="line">deepseek-chat</span><br></pre></td></tr></table></figure><p>保存后，只有白名单中的模型会显示给用户。</p><h3 id="模型标签与排序">模型标签与排序</h3><p>为了让用户更容易找到合适的模型，你可以给模型添加标签和自定义排序。</p><p><strong>添加标签</strong>：</p><p>在模型编辑页面，找到 “标签（Tags）” 字段，添加标签：</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">推荐, 快速, 免费</span><br></pre></td></tr></table></figure><p>或</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">高级, 付费, GPT-4</span><br></pre></td></tr></table></figure><p>用户在选择模型时，可以通过标签筛选。</p><p><strong>自定义排序</strong>：</p><p>在设置 → 模型页面，你可以拖动模型来调整顺序。排在前面的模型会优先显示给用户。</p><p><strong>最佳实践</strong>：</p><ul><li>将最常用的模型排在最前面</li><li>将免费模型和付费模型用标签区分</li><li>将不同能力的模型分类（如 “对话”、“编程”、“翻译”）</li></ul><h3 id="模型参数预设">模型参数预设</h3><p>你可以为每个模型设置默认参数，用户使用时会自动应用这些参数。</p><p><strong>步骤 1：编辑模型</strong></p><p>设置 → 模型 → 点击模型的编辑按钮</p><p><strong>步骤 2：配置参数</strong></p><p>在 “模型参数” 区域，设置以下参数：</p><table><thead><tr><th>参数</th><th>说明</th><th>推荐值</th></tr></thead><tbody><tr><td><strong>Temperature</strong></td><td>控制输出的随机性，0-2</td><td>0.7（平衡）</td></tr><tr><td><strong>Top P</strong></td><td>核采样参数，0-1</td><td>0.9</td></tr><tr><td><strong>Max Tokens</strong></td><td>最大输出长度</td><td>2048</td></tr><tr><td><strong>Context Length</strong></td><td>上下文窗口大小</td><td>4096</td></tr><tr><td><strong>Frequency Penalty</strong></td><td>降低重复内容，-2 到 2</td><td>0</td></tr><tr><td><strong>Presence Penalty</strong></td><td>鼓励新话题，-2 到 2</td><td>0</td></tr></tbody></table><p><strong>参数说明</strong>：</p><p><strong>Temperature</strong>：</p><ul><li>0.1-0.3：输出非常确定，适合事实性任务（如翻译、总结）</li><li>0.7-0.9：平衡创造性和准确性，适合日常对话</li><li>1.0-2.0：输出更有创造性，适合创意写作</li></ul><p><strong>Top P</strong>：</p><ul><li>0.9：推荐值，保持输出质量</li><li>0.95：更多样化的输出</li><li>1.0：完全随机（不推荐）</li></ul><p><strong>Context Length</strong>：</p><ul><li>对于 Ollama 模型，默认是 2048，这太小了</li><li>建议设置为 8192 或更高，特别是使用 RAG 功能时</li><li>注意：更大的上下文会消耗更多内存</li></ul><hr><h2 id="3-3-用户与权限管理">3.3. 用户与权限管理</h2><p>Open WebUI 提供了完善的多用户管理功能，适合团队协作使用。</p><h3 id="用户注册与审批流程">用户注册与审批流程</h3><p><strong>默认行为</strong>：</p><ul><li>第一个注册的用户自动成为管理员</li><li>后续注册的用户状态为 “待激活”</li><li>管理员需要手动激活用户</li></ul><p><strong>配置注册开关</strong>：</p><p>管理员面板 → 设置 → 通用 → 身份验证</p><p>找到 “允许新用户注册” 开关：</p><p><img src="https://prorise-blog.oss-cn-guangzhou.aliyuncs.com/cover/image-20260213092217799.png" alt="image-20260213092217799"></p><table><thead><tr><th>状态</th><th>说明</th><th>适用场景</th></tr></thead><tbody><tr><td><strong>开启</strong></td><td>任何人都可以注册，新用户角色由&quot;默认用户角色&quot;决定</td><td>小团队、内网环境</td></tr><tr><td><strong>关闭</strong></td><td>禁止注册，只能由管理员手动添加用户</td><td>严格控制的企业环境</td></tr></tbody></table><p><strong>配置默认用户角色</strong>：</p><p>在同一页面，找到 “默认用户角色” 下拉选择：</p><table><thead><tr><th>角色</th><th>说明</th></tr></thead><tbody><tr><td><strong>待激活</strong></td><td>注册后无法使用系统，需要管理员手动激活（默认值）</td></tr><tr><td><strong>用户</strong></td><td>注册后直接可以使用系统</td></tr><tr><td><strong>管理员</strong></td><td>注册后直接成为管理员（不推荐）</td></tr></tbody></table><p><strong>配置默认权限组</strong>：</p><p>你还可以设置 “默认权限组”，新注册的用户会自动加入该权限组，继承组的权限配置。</p><p>如果你的团队成员都是可信的，可以将默认角色设为 “用户”，这样注册后就能直接使用，无需审批。</p><h3 id="手动添加用户">手动添加用户</h3><p>如果你关闭了注册功能，或者想要批量添加用户，可以使用手动添加功能。</p><p><strong>步骤 1：进入用户管理</strong></p><p>管理员面板 → 用户（Users）</p><p><strong>步骤 2：添加单个用户</strong></p><p>点击右上角的 “添加用户” 按钮，填写信息：</p><p><img src="https://prorise-blog.oss-cn-guangzhou.aliyuncs.com/cover/image-20260213092242263.png" alt="image-20260213092242263"></p><table><thead><tr><th>字段</th><th>说明</th><th>示例</th></tr></thead><tbody><tr><td><strong>邮箱</strong></td><td>用户登录邮箱</td><td><a href="mailto:user@example.com">user@example.com</a></td></tr><tr><td><strong>用户名</strong></td><td>显示名称</td><td>张三</td></tr><tr><td><strong>密码</strong></td><td>初始密码</td><td>建议生成随机密码</td></tr><tr><td><strong>角色</strong></td><td>用户角色</td><td>User</td></tr></tbody></table><p>点击 “创建” 完成添加。</p><p><strong>步骤 3：通知用户</strong></p><p>将登录信息（邮箱和密码）发送给用户，建议用户首次登录后立即修改密码。</p><h3 id="批量导入用户（CSV）">批量导入用户（CSV）</h3><p>如果需要添加大量用户，可以使用 CSV 批量导入功能。</p><p><strong>步骤 1：准备 CSV 文件</strong></p><p>创建一个 CSV 文件，格式如下：</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></pre></td><td class="code"><pre><span class="line">email,name,password,role</span><br><span class="line">user1@example.com,用户1,password123,user</span><br><span class="line">user2@example.com,用户2,password456,user</span><br><span class="line">user3@example.com,用户3,password789,admin</span><br></pre></td></tr></table></figure><p><strong>注意</strong>：</p><ul><li>第一行是表头，必须包含这四个字段</li><li>role 可以是 <code>user</code>、<code>admin</code> 或 <code>pending</code></li><li>密码建议使用随机生成的强密码</li></ul><p><strong>步骤 2：导入</strong></p><p>管理员面板 → 用户 → 点击 “导入用户” 按钮</p><p>选择你准备好的 CSV 文件，点击上传。</p><p>系统会显示导入结果，包括成功和失败的记录。</p><h3 id="用户角色体系">用户角色体系</h3><p>Open WebUI 有三种用户角色：</p><table><thead><tr><th>角色</th><th>权限</th><th>适用对象</th></tr></thead><tbody><tr><td><strong>管理员</strong></td><td>完全控制权限，可以管理所有设置、用户、模型</td><td>系统管理员、技术负责人</td></tr><tr><td><strong>用户</strong></td><td>可以使用系统，创建对话，上传文档，但不能修改系统设置</td><td>普通团队成员</td></tr><tr><td><strong>待激活</strong></td><td>无法使用系统，等待管理员激活</td><td>新注册用户</td></tr></tbody></table><p><strong>修改用户角色</strong>：</p><p>管理员面板 → 用户 → 找到目标用户 → 点击角色下拉菜单 → 选择新角色</p><h3 id="权限组管理">权限组管理</h3><p>权限组功能允许你将用户分组，然后批量分配权限。</p><p><strong>创建权限组</strong>：</p><p>管理员面板 → 用户 → 权限组 → 点击 “创建权限组”</p><p>填写信息：</p><p><img src="https://prorise-blog.oss-cn-guangzhou.aliyuncs.com/cover/image-20260213092335260.png" alt="image-20260213092335260"></p><table><thead><tr><th>字段</th><th>说明</th><th>示例</th></tr></thead><tbody><tr><td><strong>组名</strong></td><td>权限组名称</td><td>开发团队</td></tr><tr><td><strong>描述</strong></td><td>组的说明</td><td>负责产品开发的团队成员</td></tr></tbody></table><p><strong>添加成员到权限组</strong>：</p><p>创建权限组后，点击 “添加成员” 按钮，选择要添加的用户。</p><p><strong>为权限组分配权限</strong>：</p><p>权限组创建后，你可以：</p><ul><li>将特定模型的访问权限分配给权限组</li><li>将知识库的访问权限分配给权限组</li><li>将工具和函数的使用权限分配给权限组</li></ul><p>这样，当你添加新成员到权限组时，他们会自动获得组的所有权限，无需逐个配置。</p><blockquote><p>💡 系统默认有一个&quot;默认权限&quot;组，用于所有具有&quot;用户&quot;角色的用户。你可以点击进入修改默认权限。</p></blockquote><h3 id="我的权限组设计">我的权限组设计</h3><p>我接入了大量模型（100+ 个），来源包括 AWS Bedrock、OpenAI、Google Gemini、GitHub Copilot、iFlow 代理、Moonshot 等多个渠道。不同渠道的成本差异很大，不能让所有用户无差别地使用全部模型。</p><p>我借助大模型分析了数据库中的模型列表和权限表结构，设计了三级权限组：</p><table><thead><tr><th>权限组</th><th>定位</th><th>可用模型数</th><th>说明</th></tr></thead><tbody><tr><td><strong>试用组</strong></td><td>新用户体验</td><td>13 个</td><td>仅提供轻量级免费/低成本模型，功能受限（不可多模型对话、不可创建频道等）</td></tr><tr><td><strong>正式组</strong></td><td>日常使用</td><td>77 个</td><td>拥有所有自有渠道模型的访问权限，功能完整</td></tr><tr><td><strong>管理组</strong></td><td>管理员</td><td>102 个（全部）</td><td>在正式组基础上额外拥有高成本第三方渠道模型（GitHub Copilot、SciHub 镜像）</td></tr></tbody></table><p><strong>试用组可用模型（13 个）</strong>：</p><p>只开放成本最低的轻量模型，让新用户体验基本功能：</p><ul><li>Claude Haiku 4.5 系列（AWS 直连 + Kiro 通道）</li><li>Gemini 2.5 Flash / Flash Lite</li><li>GPT-5 Codex Mini / GPT-5.1 Codex Mini</li><li>Qwen3 Coder Flash</li><li>Kiro GPT-3.5/4/4o（旧模型，成本极低）</li></ul><p><strong>正式组额外可用模型（+64 个）</strong>：</p><p>在试用组基础上，解锁所有自有渠道的中高端模型：</p><ul><li>Claude Sonnet 4/4.5、Opus 4.5/4.6 全系列（含 Thinking、Agentic 变体）</li><li>Gemini 2.5 Pro、3 Pro/Flash Preview</li><li>GPT-5/5.1/5.2/5.3 全系列</li><li>DeepSeek V3/V3.1/V3.2、R1（通过 iFlow）</li><li>Qwen3 Max/235B/Coder Plus（通过 iFlow）</li><li>Kimi K2/K2.5（Moonshot 直连 + iFlow）</li><li>GLM 4.6/4.7/5（通过 iFlow）</li><li>MiniMax M2/M2.1（通过 iFlow）</li></ul><p><strong>管理组专属模型（+25 个）</strong>：</p><p>这些模型走 GitHub Copilot 和 SciHub 镜像渠道，成本较高或属于特殊用途，仅管理员可用：</p><ul><li><code>gh-*</code> 系列（21 个）：GitHub Copilot 高级模型，包括 gh-gpt-5.2、gh-claude-opus-4.6、gh-gemini-3-pro-preview 等</li><li><code>scihub.*</code> 系列（4 个）：SciHub Claude 镜像，包括 scihub.claude-opus-4-6、scihub.claude-sonnet-4-5-20250929 等</li></ul><p><strong>权限组的功能差异</strong>：</p><p>除了模型访问权限，三个组在系统功能上也有区别：</p><table><thead><tr><th>功能</th><th>试用组</th><th>正式组</th><th>管理组</th></tr></thead><tbody><tr><td>多模型对话</td><td>❌</td><td>✅</td><td>✅</td></tr><tr><td>创建频道/文件夹</td><td>❌</td><td>✅</td><td>✅</td></tr><tr><td>知识库管理</td><td>❌</td><td>✅</td><td>✅</td></tr><tr><td>工具/函数管理</td><td>❌</td><td>✅</td><td>✅</td></tr><tr><td>图片生成</td><td>❌</td><td>✅</td><td>✅</td></tr><tr><td>笔记功能</td><td>❌</td><td>✅</td><td>✅</td></tr><tr><td>导入/导出模型</td><td>❌</td><td>✅</td><td>✅</td></tr><tr><td>界面设置</td><td>❌</td><td>❌</td><td>✅</td></tr><tr><td>临时对话强制</td><td>✅（强制）</td><td>❌</td><td>❌</td></tr></tbody></table><p><strong>模型图标规范</strong>：</p><p>为了让用户在模型列表中快速识别来源，我为每个模型统一配置了图标：</p><table><thead><tr><th>模型来源</th><th>图标</th></tr></thead><tbody><tr><td>Claude 系列</td><td>claude-color.svg</td></tr><tr><td>OpenAI GPT 系列</td><td>openai.svg</td></tr><tr><td>Gemini 系列</td><td>gemini-color.svg</td></tr><tr><td>Qwen 系列</td><td>qwen-color.svg</td></tr><tr><td>DeepSeek 系列</td><td>deepseek-color.svg</td></tr><tr><td>Kimi 系列</td><td>moonshot.svg</td></tr><tr><td>MiniMax 系列</td><td>minimax-color.svg</td></tr><tr><td>GLM 系列</td><td>zhipu-color.svg</td></tr><tr><td>Grok 系列</td><td>grok.svg</td></tr><tr><td>Kiro（AWS 通道）</td><td>aws-color.svg</td></tr></tbody></table><p>图标统一使用 <code>cdn.jsdelivr.net/npm/@lobehub/icons-static-svg@1.79.0/icons/</code> 的 SVG 资源。</p><p><strong>批量配置方法</strong>：</p><p>手动逐个配置 100+ 个模型的权限和图标不现实。我的做法是：</p><ol><li>在 Open WebUI 管理面板导出模型列表（JSON 格式）</li><li>用大模型编写 Python 脚本，根据模型 ID 前缀和名称自动推断图标和权限组</li><li>脚本批量写入 <code>access_grants</code>（新版格式）和 <code>meta.profile_image_url</code></li><li>将修正后的 JSON 重新导入 Open WebUI</li></ol><p>这样每次上游新增模型或系统升级后，只需重新导出 → 跑脚本 → 导入，几分钟就能完成全部配置。</p><blockquote><p>⚠️ 注意：Open WebUI 升级后数据库结构可能变化。例如从旧版升级到新版时，模型权限从 <code>model</code> 表的 <code>access_control</code> 字段迁移到了独立的 <code>access_grant</code> 表，导出格式也从 <code>access_control</code> 对象变成了 <code>access_grants</code> 数组。升级后建议先导出一份检查格式再操作。</p></blockquote><h3 id="用户活动监控">用户活动监控</h3><p>Open WebUI 提供了用户活动监控功能，帮助你了解系统使用情况。</p><p><strong>查看活跃用户</strong>：</p><p>管理员面板 → 设置 → 通用 → 找到 “显示活跃用户数” 选项</p><p>启用后，在主界面底部会显示当前活跃用户数和正在使用的模型。</p><p><strong>查看用户详情</strong>：</p><p>管理员面板 → 用户 → 点击用户名</p><p>你可以看到：</p><ul><li>创建的对话数量</li><li>使用的模型统计</li></ul><p><strong>注意</strong>：Open WebUI 不会记录用户的具体对话内容，只记录统计信息，保护用户隐私。</p><p><img src="https://prorise-blog.oss-cn-guangzhou.aliyuncs.com/cover/image-20260213092922828.png" alt="image-20260213092922828"></p><hr><h2 id="3-4-RAG-文档功能配置">3.4. RAG 文档功能配置</h2><p>RAG（检索增强生成）是 Open WebUI 的核心功能之一，允许用户基于自己的文档进行问答。本节将从上到下逐一解析 Admin Panel → Settings → Documents 页面的每个配置项，帮助你根据实际场景做出最优选择。</p><p><strong>配置入口</strong>：管理员面板 → 设置 → Documents</p><hr><h3 id="内容提取引擎（Content-Extraction-Engine）">内容提取引擎（Content Extraction Engine）</h3><p>内容提取引擎决定了 Open WebUI 如何从上传的文件中提取文本。通过环境变量 <code>CONTENT_EXTRACTION_ENGINE</code> 选择，共支持 8 种引擎。</p><h4 id="引擎横向对比">引擎横向对比</h4><table><thead><tr><th>引擎</th><th>部署方式</th><th>费用</th><th>支持格式</th><th>OCR 能力</th><th>表格/公式</th><th>适用场景</th></tr></thead><tbody><tr><td><strong>默认（Default）</strong></td><td>本地，零依赖</td><td>免费</td><td>PDF、TXT、CSV、DOCX、代码文件</td><td>❌ 不支持</td><td>❌ 弱</td><td>简单文档，纯文本 PDF</td></tr><tr><td><strong>Apache Tika</strong></td><td>需部署 Java 服务</td><td>免费（开源）</td><td>1400+ 种格式</td><td>❌ 弱</td><td>❌ 弱</td><td>格式种类多的企业环境</td></tr><tr><td><strong>Docling（IBM）</strong></td><td>需部署服务或用 API</td><td>免费（开源）</td><td>PDF、DOCX、PPTX、HTML</td><td>✅ 支持</td><td>✅ 优秀</td><td>复杂排版、表格、公式</td></tr><tr><td><strong>Datalab Marker</strong></td><td>云 API 或自部署</td><td>~$6/千页（高精度）</td><td>PDF、图片</td><td>✅ LLM 增强 OCR</td><td>✅ 优秀</td><td>复杂排版 PDF，可自部署</td></tr><tr><td><strong>Mistral OCR</strong></td><td>云 API</td><td>$1-2/千页</td><td>PDF、图片</td><td>✅ 99%+ 准确率</td><td>✅ 优秀</td><td>扫描件、多语言（25+ 语言）</td></tr><tr><td><strong>Document Intelligence</strong></td><td>Azure 云服务</td><td>~$10/千页</td><td>PDF、图片、表单</td><td>✅ 支持</td><td>✅ 支持</td><td>Azure 生态企业用户</td></tr><tr><td><strong>MinerU</strong></td><td>自部署或云 API</td><td>免费（开源）</td><td>PDF、图片</td><td>✅ 支持</td><td>✅ 最佳</td><td>学术论文、金融报告、公式密集</td></tr><tr><td><strong>External</strong></td><td>自定义 HTTP 服务</td><td>取决于实现</td><td>自定义</td><td>取决于实现</td><td>取决于实现</td><td>对接私有解析服务</td></tr></tbody></table><h4 id="各引擎详细说明">各引擎详细说明</h4><div class="tabs" id="内容提取引擎详解"><ul class="nav-tabs"><button type="button" class="tab  active" data-href="内容提取引擎详解-1">默认引擎</button><button type="button" class="tab " data-href="内容提取引擎详解-2">Apache Tika</button><button type="button" class="tab " data-href="内容提取引擎详解-3">Docling（IBM 开源）</button><button type="button" class="tab " data-href="内容提取引擎详解-4">Datalab Marker</button><button type="button" class="tab " data-href="内容提取引擎详解-5">Mistral OCR</button><button type="button" class="tab " data-href="内容提取引擎详解-6">Document Intelligence</button><button type="button" class="tab " data-href="内容提取引擎详解-7">MinerU</button><button type="button" class="tab " data-href="内容提取引擎详解-8">External</button></ul><div class="tab-contents"><div class="tab-item-content active" id="内容提取引擎详解-1"><p>使用 Python 原生加载器（PyPDFLoader、Docx2txtLoader、CSVLoader、TextLoader），零依赖开箱即用。但不支持 OCR，无法处理扫描件，对复杂表格和公式无能为力。</p><p><strong>适合</strong>：纯文本 PDF 和简单文档，快速上手无需任何额外配置。</p></div><div class="tab-item-content" id="内容提取引擎详解-2"><p>老牌 Java 文档解析框架，格式覆盖最广（1400+），但对 PDF 排版理解较弱，不擅长表格结构保留和公式识别。适合已有 Tika 基础设施的组织。</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></pre></td><td class="code"><pre><span class="line"><span class="attr">environment:</span></span><br><span class="line">  <span class="bullet">-</span> <span class="string">CONTENT_EXTRACTION_ENGINE=tika</span></span><br><span class="line">  <span class="bullet">-</span> <span class="string">TIKA_SERVER_URL=http://tika:9998</span></span><br></pre></td></tr></table></figure></div><div class="tab-item-content" id="内容提取引擎详解-3"><p>Python 原生，对 PDF 排版理解好，表格提取准确，支持公式，输出干净的 Markdown/JSON。在多个评测中与 MinerU 并列 top 2。</p><figure class="highlight yaml"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><span class="line"><span class="attr">environment:</span></span><br><span class="line">  <span class="bullet">-</span> <span class="string">CONTENT_EXTRACTION_ENGINE=docling</span></span><br><span class="line">  <span class="bullet">-</span> <span class="string">DOCLING_SERVER_URL=http://docling:5000</span></span><br><span class="line">  <span class="bullet">-</span> <span class="string">DOCLING_API_KEY=your-api-key</span>  <span class="comment"># 如需认证</span></span><br></pre></td></tr></table></figure></div><div class="tab-item-content" id="内容提取引擎详解-4"><p>基于开源 <a href="https://github.com/datalab-to/marker">Marker</a> 和 Surya 模型，LLM 增强 OCR。其 Chandra OCR 模型在 olmOCR benchmark 上得分 83.1%，超过 GPT-4o。支持云 API 和自部署两种方式。</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></pre></td><td class="code"><pre><span class="line"><span class="attr">environment:</span></span><br><span class="line">  <span class="bullet">-</span> <span class="string">CONTENT_EXTRACTION_ENGINE=datalab_marker</span></span><br><span class="line">  <span class="bullet">-</span> <span class="string">DATALAB_MARKER_API_KEY=your-api-key</span></span><br></pre></td></tr></table></figure></div><div class="tab-item-content" id="内容提取引擎详解-5"><p>号称 99%+ 准确率，支持表格/公式/图表转表格/签名检测，覆盖 25+ 语言。性价比高（$1/千页起），但只能通过 API 调用，无法自部署。</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></pre></td><td class="code"><pre><span class="line"><span class="attr">environment:</span></span><br><span class="line">  <span class="bullet">-</span> <span class="string">CONTENT_EXTRACTION_ENGINE=mistral_ocr</span></span><br><span class="line">  <span class="bullet">-</span> <span class="string">MISTRAL_OCR_API_KEY=your-api-key</span></span><br></pre></td></tr></table></figure></div><div class="tab-item-content" id="内容提取引擎详解-6"><p>微软企业级服务，预置发票/收据/身份证等模型，支持自定义模型训练。价格较高但有企业合规保障。</p><figure class="highlight yaml"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line"><span class="attr">environment:</span></span><br><span class="line">  <span class="bullet">-</span> <span class="string">CONTENT_EXTRACTION_ENGINE=document_intelligence</span></span><br><span class="line">  <span class="bullet">-</span> <span class="string">DOCUMENT_INTELLIGENCE_ENDPOINT=https://your-resource.cognitiveservices.azure.com/</span></span><br></pre></td></tr></table></figure></div><div class="tab-item-content" id="内容提取引擎详解-7"><p>基于 PDF-Extract-Kit 微调模型，在复杂文档（学术论文、教材、金融报告）上表现最佳，表格/公式/图片提取精度高。推荐 GPU 环境运行。</p><figure class="highlight yaml"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><span class="line"><span class="attr">environment:</span></span><br><span class="line">  <span class="bullet">-</span> <span class="string">CONTENT_EXTRACTION_ENGINE=mineru</span></span><br><span class="line">  <span class="bullet">-</span> <span class="string">MINERU_API_URL=http://mineru:8000</span></span><br><span class="line">  <span class="bullet">-</span> <span class="string">MINERU_API_MODE=local</span>  <span class="comment"># cloud 或 local</span></span><br></pre></td></tr></table></figure></div><div class="tab-item-content" id="内容提取引擎详解-8"><p>万能逃生舱，指向任意 HTTP 服务，适合对接企业内部的私有文档解析服务。</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></pre></td><td class="code"><pre><span class="line"><span class="attr">environment:</span></span><br><span class="line">  <span class="bullet">-</span> <span class="string">CONTENT_EXTRACTION_ENGINE=external</span></span><br><span class="line">  <span class="bullet">-</span> <span class="string">EXTERNAL_DOCUMENT_LOADER_URL=http://your-service:8080/extract</span></span><br></pre></td></tr></table></figure></div></div><div class="tab-to-top"><button type="button" aria-label="scroll to top"><i class="anzhiyufont anzhiyu-icon-arrow-up"></i></button></div></div><h4 id="引擎选择建议">引擎选择建议</h4><table><thead><tr><th>场景</th><th>推荐引擎</th><th>理由</th></tr></thead><tbody><tr><td>简单文档、快速上手</td><td>默认</td><td>零配置，开箱即用</td></tr><tr><td>扫描件、多语言文档</td><td>Mistral OCR</td><td>性价比最高，准确率高</td></tr><tr><td>学术论文、公式密集</td><td>MinerU 或 Docling</td><td>表格/公式提取精度最佳</td></tr><tr><td>企业 Azure 环境</td><td>Document Intelligence</td><td>合规保障，预置模型丰富</td></tr><tr><td>想自部署 + 高精度</td><td>Datalab Marker 或 MinerU</td><td>开源可控，精度优秀</td></tr><tr><td>格式种类极多</td><td>Apache Tika</td><td>1400+ 格式覆盖</td></tr></tbody></table><h4 id="PDF-提取图片（PDF-Extract-Images）">PDF 提取图片（PDF Extract Images）</h4><table><thead><tr><th>配置项</th><th>环境变量</th><th>默认值</th><th>说明</th></tr></thead><tbody><tr><td>PDF Extract Images</td><td><code>PDF_EXTRACT_IMAGES</code></td><td><code>false</code></td><td>是否从 PDF 中提取嵌入的图片</td></tr></tbody></table><p>启用后会增加处理时间和存储空间，仅在文档中的图片内容对问答有价值时开启。</p><hr><h3 id="PDF-加载模式">PDF 加载模式</h3><p>Open WebUI 提供两种 PDF 处理模式，决定了文档在进入切分器之前的预处理方式：</p><table><thead><tr><th>模式</th><th>行为</th><th>优点</th><th>缺点</th><th>适用场景</th></tr></thead><tbody><tr><td><strong>Page（页模式）</strong></td><td>每页作为独立文档单元，保留页边界</td><td>检索时能精确定位到具体页码</td><td>跨页内容会被截断</td><td>PPT 转 PDF、每页独立主题</td></tr><tr><td><strong>Single Document（单文档模式）</strong></td><td>整个 PDF 合并为一个文本块，再统一切分</td><td>跨页内容不会丢失，语义连贯</td><td>失去页码定位能力</td><td>论文、书籍、报告等连续叙述型文档</td></tr></tbody></table><p><strong>最佳实践</strong>：大多数 RAG 场景推荐 <strong>Single Document</strong> 模式，因为语义连贯性比页码定位更重要。如果文档每页内容相对独立（如幻灯片），则用 Page 模式。</p><hr><h3 id="文本切分（Text-Splitting）">文本切分（Text Splitting）</h3><p>文本切分决定了文档被拆分成多大的片段（chunk）存入向量数据库。切分质量直接影响检索精度。</p><h4 id="切分器类型">切分器类型</h4><table><thead><tr><th>切分器</th><th>计量方式</th><th>特点</th><th>适用场景</th></tr></thead><tbody><tr><td><strong>默认（Character）</strong></td><td>按字符数</td><td>使用递归分隔符（<code>\n\n</code> → <code>\n</code> → 空格 → 字符）逐级切分，简单高效，无依赖</td><td>英文文档、通用场景</td></tr><tr><td><strong>Token</strong></td><td>按 token 数</td><td>按模型 tokenizer 计算，切分大小与模型上下文窗口精确对齐</td><td>中文文档（1 个汉字 ≈ 2-3 个 token）、需要精确控制 token 用量</td></tr></tbody></table><h4 id="核心参数">核心参数</h4><table><thead><tr><th>配置项</th><th>环境变量</th><th>默认值</th><th>推荐值</th><th>说明</th></tr></thead><tbody><tr><td><strong>Chunk Size</strong></td><td><code>CHUNK_SIZE</code></td><td>1500</td><td>1000-2000</td><td>每个 chunk 的最大大小。太小丢失上下文，太大降低检索精度</td></tr><tr><td><strong>Chunk Overlap</strong></td><td><code>CHUNK_OVERLAP</code></td><td>100</td><td>Chunk Size 的 5%-15%</td><td>相邻 chunk 的重叠量，保证边界处语义连贯</td></tr></tbody></table><p><strong>参数调优指南</strong>：</p><ul><li><strong>Chunk Size 太小（&lt;500）</strong>：上下文不完整，模型难以理解片段含义</li><li><strong>Chunk Size 太大（&gt;2000）</strong>：包含过多无关信息，检索精度下降</li><li><strong>Chunk Overlap 太小</strong>：重要信息可能被切断在两个 chunk 之间</li><li><strong>Chunk Overlap 太大</strong>：存储冗余增加，检索效率降低</li></ul><h4 id="Markdown-标题文本分割器">Markdown 标题文本分割器</h4><table><thead><tr><th>配置项</th><th>默认值</th><th>说明</th></tr></thead><tbody><tr><td><strong>Markdown Header Text Splitter</strong></td><td>关闭</td><td>按 Markdown 标题（H1-H6）进行结构化预切分</td></tr><tr><td><strong>Chunk Min Size Target</strong></td><td>—</td><td>合并过小片段的阈值，建议设为 Chunk Size 的 50%</td></tr></tbody></table><p>启用后，文档会先按 Markdown 标题进行结构化预切分，然后再交给标准切分器处理。好处：</p><ul><li>保留文档的逻辑结构，每个 chunk 属于明确的章节</li><li>避免跨章节切分导致语义混乱</li><li>配合 Chunk Min Size Target 参数，可以将过小的片段向前合并（单向合并算法，不跨文档），官方测试显示阈值设为 1000（chunk size 2000 时）可减少 90%+ 的碎片 chunk</li></ul><p><strong>最佳实践</strong>：如果文档是 Markdown 格式，或提取引擎输出 Markdown（Docling、MinerU、Marker 都输出 Markdown），<strong>强烈建议开启此选项</strong>。</p><hr><h3 id="嵌入模型（Embedding-Model）">嵌入模型（Embedding Model）</h3><p>嵌入模型将文本转换为向量表示，是 RAG 检索的基础。模型质量直接决定检索准确性。</p><h4 id="引擎横向对比-2">引擎横向对比</h4><table><thead><tr><th>引擎</th><th>配置值</th><th>费用</th><th>延迟</th><th>隐私</th><th>推荐模型</th></tr></thead><tbody><tr><td><strong>SentenceTransformers（默认）</strong></td><td><code>&quot;&quot;</code></td><td>免费，本地运行</td><td>中等（取决于硬件）</td><td>完全本地</td><td><code>all-MiniLM-L6-v2</code>（轻量）、<code>BAAI/bge-m3</code>（多语言）</td></tr><tr><td><strong>Ollama</strong></td><td><code>ollama</code></td><td>免费，本地运行</td><td>快（已有 Ollama 实例）</td><td>完全本地</td><td><code>nomic-embed-text</code>（推荐首选）、<code>mxbai-embed-large</code></td></tr><tr><td><strong>OpenAI</strong></td><td><code>openai</code></td><td>按 token 计费</td><td>低（云端）</td><td>数据发送到云端</td><td><code>text-embedding-3-small</code>（性价比）、<code>text-embedding-3-large</code>（高精度）</td></tr><tr><td><strong>Azure OpenAI</strong></td><td><code>azure</code></td><td>按 token 计费</td><td>低（云端）</td><td>Azure 合规保障</td><td>同 OpenAI 模型，通过 Azure 部署</td></tr></tbody></table><h4 id="各引擎详细说明-2">各引擎详细说明</h4><div class="tabs" id="嵌入模型引擎详解"><ul class="nav-tabs"><button type="button" class="tab  active" data-href="嵌入模型引擎详解-1">SentenceTransformers（默认）</button><button type="button" class="tab " data-href="嵌入模型引擎详解-2">Ollama</button><button type="button" class="tab " data-href="嵌入模型引擎详解-3">OpenAI</button><button type="button" class="tab " data-href="嵌入模型引擎详解-4">Azure OpenAI</button></ul><div class="tab-contents"><div class="tab-item-content active" id="嵌入模型引擎详解-1"><p>使用 Python <code>sentence-transformers</code> 库在本地运行，模型自动从 HuggingFace 下载缓存。零成本、完全隐私，但首次加载模型较慢，且占用服务器内存/显存。默认模型 <code>all-MiniLM-L6-v2</code> 较轻量但精度一般。</p><p>⚠️ <strong>网络提示</strong>：如果服务器无法访问 <code>huggingface.co</code>，启动时会出现 SSL 重连错误，RAG 功能将不可用。可添加镜像站环境变量解决：</p><figure class="highlight yaml"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line"><span class="attr">environment:</span></span><br><span class="line">  <span class="bullet">-</span> <span class="string">HF_ENDPOINT=https://hf-mirror.com</span></span><br></pre></td></tr></table></figure></div><div class="tab-item-content" id="嵌入模型引擎详解-2"><p>如果你已经在用 Ollama 跑 LLM，这是最方便的选择。<code>nomic-embed-text</code> 是社区最推荐的模型：8192 token 上下文、完全开源、在 MTEB 和 LoCo 基准上表现优异。</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></pre></td><td class="code"><pre><span class="line">ollama pull nomic-embed-text</span><br><span class="line"><span class="comment"># 或中文场景</span></span><br><span class="line">ollama pull bge-m3</span><br></pre></td></tr></table></figure><p>然后配置环境变量：</p><figure class="highlight yaml"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><span class="line"><span class="attr">environment:</span></span><br><span class="line">  <span class="bullet">-</span> <span class="string">RAG_EMBEDDING_ENGINE=ollama</span></span><br><span class="line">  <span class="bullet">-</span> <span class="string">RAG_EMBEDDING_MODEL=nomic-embed-text</span></span><br><span class="line">  <span class="bullet">-</span> <span class="string">RAG_OLLAMA_BASE_URL=http://ollama:11434</span></span><br></pre></td></tr></table></figure></div><div class="tab-item-content" id="嵌入模型引擎详解-3"><p>支持任何 OpenAI 兼容端点（包括第三方）。<code>text-embedding-3-small</code>（1536 维）性价比高，<code>text-embedding-3-large</code>（3072 维）精度更好。缺点是有 API 费用、网络延迟、数据隐私风险。</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><span class="line"><span class="attr">environment:</span></span><br><span class="line">  <span class="bullet">-</span> <span class="string">RAG_EMBEDDING_ENGINE=openai</span></span><br><span class="line">  <span class="bullet">-</span> <span class="string">RAG_EMBEDDING_MODEL=text-embedding-3-small</span></span><br><span class="line">  <span class="bullet">-</span> <span class="string">RAG_OPENAI_API_BASE_URL=https://api.openai.com/v1</span></span><br><span class="line">  <span class="bullet">-</span> <span class="string">RAG_OPENAI_API_KEY=sk-...</span></span><br></pre></td></tr></table></figure></div><div class="tab-item-content" id="嵌入模型引擎详解-4"><p>本质上是 OpenAI 模型通过 Azure 托管，适合有 Azure 合规要求的企业。</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></pre></td><td class="code"><pre><span class="line"><span class="attr">environment:</span></span><br><span class="line">  <span class="bullet">-</span> <span class="string">RAG_EMBEDDING_ENGINE=azure</span></span><br><span class="line">  <span class="bullet">-</span> <span class="string">RAG_AZURE_OPENAI_BASE_URL=https://your-resource.openai.azure.com/</span></span><br></pre></td></tr></table></figure></div></div><div class="tab-to-top"><button type="button" aria-label="scroll to top"><i class="anzhiyufont anzhiyu-icon-arrow-up"></i></button></div></div><h4 id="嵌入模型选择建议">嵌入模型选择建议</h4><table><thead><tr><th>场景</th><th>推荐方案</th><th>理由</th></tr></thead><tbody><tr><td>本地优先、已有 Ollama</td><td>Ollama + <code>nomic-embed-text</code></td><td>最佳平衡，免费、快速、质量好</td></tr><tr><td>资源受限、轻量部署</td><td>SentenceTransformers + <code>all-MiniLM-L6-v2</code></td><td>零依赖，内存占用小</td></tr><tr><td>中文文档为主</td><td>Ollama + <code>bge-m3</code> 或 SentenceTransformers + <code>BAAI/bge-m3</code></td><td>多语言支持优秀</td></tr><tr><td>追求精度且不介意费用</td><td>OpenAI + <code>text-embedding-3-large</code></td><td>精度最高</td></tr><tr><td>企业合规要求</td><td>Azure OpenAI</td><td>合规保障</td></tr></tbody></table><h4 id="其他嵌入参数">其他嵌入参数</h4><table><thead><tr><th>配置项</th><th>环境变量</th><th>默认值</th><th>说明</th></tr></thead><tbody><tr><td><strong>Embedding Batch Size</strong></td><td><code>RAG_EMBEDDING_BATCH_SIZE</code></td><td>100</td><td>批量嵌入大小，显存不足时调小</td></tr></tbody></table><p>⚠️ <strong>重要</strong>：更换嵌入模型后，所有已有文档必须重新嵌入（re-embed），因为不同模型的向量空间不兼容。确定模型后不要轻易更换。</p><hr><h3 id="检索与排序（Retrieval-Ranking）">检索与排序（Retrieval &amp; Ranking）</h3><p>检索参数决定了从向量数据库中召回多少结果、如何过滤和排序。这是影响 RAG 最终效果的关键环节。</p><h4 id="核心检索参数">核心检索参数</h4><table><thead><tr><th>配置项</th><th>环境变量</th><th>默认值</th><th>推荐值</th><th>说明</th></tr></thead><tbody><tr><td><strong>Top K</strong></td><td><code>TOP_K</code></td><td>5</td><td>5-10</td><td>返回的最相关 chunk 数量。太少可能遗漏信息，太多会稀释上下文</td></tr><tr><td><strong>Relevance Threshold</strong></td><td><code>RELEVANCE_THRESHOLD</code></td><td>0.0</td><td>0.2-0.5</td><td>最低相关性分数阈值，低于此分数的 chunk 被过滤。设为 0 表示不过滤</td></tr></tbody></table><p><strong>参数调优指南</strong>：</p><ul><li><strong>Top K 太少（❤️）</strong>：可能遗漏重要信息，回答不完整</li><li><strong>Top K 太多（&gt;10）</strong>：包含过多噪音，模型可能被无关内容干扰</li><li><strong>Relevance Threshold 太低（0）</strong>：不过滤，噪音多</li><li><strong>Relevance Threshold 太高（&gt;0.7）</strong>：过滤过严，可能丢失有用信息</li></ul><h4 id="混合搜索（Hybrid-Search）">混合搜索（Hybrid Search）</h4><table><thead><tr><th>配置项</th><th>环境变量</th><th>默认值</th><th>推荐</th><th>说明</th></tr></thead><tbody><tr><td><strong>Hybrid Search</strong></td><td><code>ENABLE_RAG_HYBRID_SEARCH</code></td><td><code>false</code></td><td><strong>开启</strong></td><td>结合向量搜索 + BM25 关键词匹配</td></tr></tbody></table><p>混合搜索使用 <code>EnsembleRetriever</code>，同时执行：</p><ul><li><strong>向量搜索</strong>：基于语义相似度，擅长理解同义词和语义关联</li><li><strong>BM25 关键词匹配</strong>：基于词频统计，擅长精确匹配专有名词、代码、ID 等</li></ul><p>两者互补，<strong>显著提升召回率</strong>，推荐开启。</p><h4 id="重排序（Reranking）">重排序（Reranking）</h4><table><thead><tr><th>配置项</th><th>环境变量</th><th>默认值</th><th>说明</th></tr></thead><tbody><tr><td><strong>Reranking Model</strong></td><td><code>RAG_RERANKING_MODEL</code></td><td>—</td><td>使用 CrossEncoder/ColBERT 对检索结果重排序</td></tr><tr><td><strong>Top K Reranker</strong></td><td><code>TOP_K_RERANKER</code></td><td>—</td><td>重排序后保留的结果数</td></tr></tbody></table><p>重排序的工作流程：</p><ol><li>先通过向量搜索（+ BM25）召回较多候选结果</li><li>再用 CrossEncoder 模型对每个候选结果与查询进行精细打分</li><li>按新分数重新排序，保留 Top K Reranker 个最相关结果</li></ol><p><strong>推荐配合 Hybrid Search 使用</strong>，这是提升 RAG 质量最有效的组合。</p><h4 id="检索策略建议">检索策略建议</h4><table><thead><tr><th>文档规模</th><th>推荐配置</th></tr></thead><tbody><tr><td>小文档集（&lt;100 个文档）</td><td>Top K=5，不需要混合搜索和重排序</td></tr><tr><td>中等文档集（100-1000）</td><td>Top K=5-8，开启 Hybrid Search</td></tr><tr><td>大文档集（&gt;1000）</td><td>Top K=8-10，开启 Hybrid Search + Reranking，Relevance Threshold=0.2+</td></tr></tbody></table><hr><h3 id="高级设置">高级设置</h3><h4 id="RAG-模板与上下文">RAG 模板与上下文</h4><table><thead><tr><th>配置项</th><th>环境变量</th><th>默认值</th><th>说明</th></tr></thead><tbody><tr><td><strong>RAG Template</strong></td><td><code>RAG_TEMPLATE</code></td><td>内置模板</td><td>自定义 RAG 提示词模板，控制检索内容如何注入到 LLM prompt</td></tr><tr><td><strong>RAG System Context</strong></td><td><code>RAG_SYSTEM_CONTEXT</code></td><td><code>false</code></td><td>设为 <code>true</code> 将 RAG 上下文放入 system message 而非 user message</td></tr></tbody></table><p><strong>RAG System Context</strong> 的作用：将检索到的文档内容放入 system message，而非 user message。好处是在多轮对话中可以优化 KV cache 复用（因为 system message 不变），推荐 Ollama/llama.cpp 用户开启。</p><h4 id="异步与性能">异步与性能</h4><table><thead><tr><th>配置项</th><th>环境变量</th><th>默认值</th><th>说明</th></tr></thead><tbody><tr><td><strong>Async Embedding</strong></td><td><code>ENABLE_ASYNC_EMBEDDING</code></td><td><code>false</code></td><td>后台线程池处理嵌入，上传大量文档时建议开启</td></tr></tbody></table><h4 id="文件与工具">文件与工具</h4><table><thead><tr><th>配置项</th><th>默认值</th><th>说明</th></tr></thead><tbody><tr><td><strong>File Context</strong></td><td>启用</td><td>控制是否对附件执行 RAG 并预注入内容</td></tr><tr><td><strong>Builtin Tools</strong></td><td>启用</td><td>给模型提供 <code>query_knowledge_bases</code>、<code>search_chats</code> 等函数调用工具</td></tr></tbody></table><h4 id="网页加载">网页加载</h4><table><thead><tr><th>配置项</th><th>环境变量</th><th>默认值</th><th>说明</th></tr></thead><tbody><tr><td><strong>Web Loader SSL Verification</strong></td><td><code>ENABLE_WEB_LOADER_SSL_VERIFICATION</code></td><td><code>true</code></td><td>网页加载时是否验证 SSL 证书</td></tr><tr><td><strong>Google Drive Integration</strong></td><td><code>GOOGLE_DRIVE_API_KEY</code> 等</td><td>—</td><td>Google Drive 文件直接导入</td></tr></tbody></table><hr><h3 id="向量数据库（Vector-Database）">向量数据库（Vector Database）</h3><p>向量数据库用于存储文档的向量表示，是 RAG 检索的底层存储。</p><table><thead><tr><th>配置项</th><th>环境变量</th><th>默认值</th><th>说明</th></tr></thead><tbody><tr><td><strong>Vector DB</strong></td><td><code>VECTOR_DB</code></td><td><code>chromadb</code></td><td>向量数据库类型</td></tr><tr><td><strong>Vector DB URL</strong></td><td><code>VECTOR_DB_URL</code></td><td>—</td><td>外部向量数据库连接地址</td></tr></tbody></table><h4 id="支持的向量数据库">支持的向量数据库</h4><table><thead><tr><th>数据库</th><th>部署方式</th><th>适用场景</th><th>特点</th></tr></thead><tbody><tr><td><strong>ChromaDB</strong></td><td>内置，无需额外配置</td><td>个人使用、小团队</td><td>默认选择，开箱即用</td></tr><tr><td><strong>Qdrant</strong></td><td>需部署独立服务</td><td>大规模部署</td><td>高性能，支持过滤</td></tr><tr><td><strong>Milvus</strong></td><td>需部署独立服务</td><td>企业环境</td><td>分布式，支持十亿级向量</td></tr><tr><td><strong>Weaviate</strong></td><td>需部署独立服务</td><td>需要混合搜索</td><td>内置向量+关键词搜索</td></tr><tr><td><strong>OpenSearch</strong></td><td>需部署独立服务</td><td>已有 OpenSearch 集群</td><td>Elasticsearch 开源替代</td></tr><tr><td><strong>PGVector</strong></td><td>PostgreSQL 扩展</td><td>已有 PostgreSQL 环境</td><td>复用现有数据库</td></tr><tr><td><strong>Pinecone</strong></td><td>云端托管</td><td>云端部署，免运维</td><td>全托管，按用量计费</td></tr><tr><td><strong>S3 Vector</strong></td><td>AWS S3</td><td>AWS 生态</td><td>低成本存储</td></tr></tbody></table><div class="tabs" id="向量数据库配置示例"><ul class="nav-tabs"><button type="button" class="tab  active" data-href="向量数据库配置示例-1">ChromaDB（默认）</button><button type="button" class="tab " data-href="向量数据库配置示例-2">Qdrant</button><button type="button" class="tab " data-href="向量数据库配置示例-3">Milvus</button><button type="button" class="tab " data-href="向量数据库配置示例-4">PGVector</button><button type="button" class="tab " data-href="向量数据库配置示例-5">Pinecone</button></ul><div class="tab-contents"><div class="tab-item-content active" id="向量数据库配置示例-1"><p>内置数据库，无需任何额外配置，开箱即用。适合个人和小团队。</p></div><div class="tab-item-content" id="向量数据库配置示例-2"><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></pre></td><td class="code"><pre><span class="line"><span class="attr">environment:</span></span><br><span class="line">  <span class="bullet">-</span> <span class="string">VECTOR_DB=qdrant</span></span><br><span class="line">  <span class="bullet">-</span> <span class="string">QDRANT_URL=http://qdrant:6333</span></span><br></pre></td></tr></table></figure></div><div class="tab-item-content" id="向量数据库配置示例-3"><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></pre></td><td class="code"><pre><span class="line"><span class="attr">environment:</span></span><br><span class="line">  <span class="bullet">-</span> <span class="string">VECTOR_DB=milvus</span></span><br><span class="line">  <span class="bullet">-</span> <span class="string">MILVUS_URI=http://milvus:19530</span></span><br></pre></td></tr></table></figure></div><div class="tab-item-content" id="向量数据库配置示例-4"><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></pre></td><td class="code"><pre><span class="line"><span class="attr">environment:</span></span><br><span class="line">  <span class="bullet">-</span> <span class="string">VECTOR_DB=pgvector</span></span><br><span class="line">  <span class="bullet">-</span> <span class="string">PGVECTOR_DB_URL=postgresql://user:pass@postgres:5432/vectors</span></span><br></pre></td></tr></table></figure></div><div class="tab-item-content" id="向量数据库配置示例-5"><figure class="highlight yaml"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><span class="line"><span class="attr">environment:</span></span><br><span class="line">  <span class="bullet">-</span> <span class="string">VECTOR_DB=pinecone</span></span><br><span class="line">  <span class="bullet">-</span> <span class="string">PINECONE_API_KEY=your-api-key</span></span><br><span class="line">  <span class="bullet">-</span> <span class="string">PINECONE_ENVIRONMENT=us-east-1</span></span><br></pre></td></tr></table></figure></div></div><div class="tab-to-top"><button type="button" aria-label="scroll to top"><i class="anzhiyufont anzhiyu-icon-arrow-up"></i></button></div></div><p>对于大多数用户，<strong>ChromaDB 已经足够使用</strong>，无需额外配置。</p><hr><h3 id="整体最佳实践总结">整体最佳实践总结</h3><p>根据以上所有配置项的分析，以下是推荐的最佳实践组合：</p><table><thead><tr><th>配置项</th><th>推荐值</th><th>理由</th></tr></thead><tbody><tr><td>提取引擎</td><td>根据文档类型选择（见引擎选择建议表）</td><td>复杂 PDF 用 Docling/MinerU/Mistral OCR，简单文档用默认</td></tr><tr><td>PDF 加载模式</td><td>Single Document</td><td>语义连贯性优先</td></tr><tr><td>文本切分器</td><td>中文用 Token，英文用 Character</td><td>中文字符数 ≠ token 数</td></tr><tr><td>Chunk Size</td><td>1000-2000</td><td>平衡上下文完整性和检索精度</td></tr><tr><td>Chunk Overlap</td><td>Chunk Size 的 10%</td><td>保证边界语义连贯</td></tr><tr><td>Markdown 标题切分</td><td>开启（如果文档是 Markdown）</td><td>保留文档结构，减少碎片</td></tr><tr><td>嵌入模型</td><td>本地：Ollama + <code>nomic-embed-text</code></td><td>免费、快速、质量好</td></tr><tr><td>Hybrid Search</td><td><strong>开启</strong></td><td>向量 + 关键词互补，显著提升召回率</td></tr><tr><td>Reranking</td><td>配合 Hybrid Search 开启</td><td>提升精度最有效的组合</td></tr><tr><td>Top K</td><td>5-10</td><td>平衡召回和噪音</td></tr><tr><td>Relevance Threshold</td><td>0.2-0.5</td><td>过滤低质量结果</td></tr><tr><td>RAG System Context</td><td><code>true</code>（Ollama 用户）</td><td>优化多轮对话 KV cache</td></tr><tr><td>向量数据库</td><td>ChromaDB（默认）</td><td>小团队足够，零配置</td></tr></tbody></table><p>💡 <strong>核心原则</strong>：先确定嵌入模型（确定后不要轻易更换），再开启 Hybrid Search + Reranking，最后根据文档类型选择提取引擎。这三步是提升 RAG 质量投入产出比最高的操作。</p><hr><h2 id="3-5-图像生成功能配置">3.5. 图像生成功能配置</h2><p>Open WebUI 支持集成多种图像生成工具，让 AI 能够根据文字描述生成图片。</p><h3 id="DALL-E-集成">DALL-E 集成</h3><p>DALL-E 是 OpenAI 的图像生成模型，质量高但需要付费。</p><p><strong>配置步骤</strong>：</p><p>管理员面板 → 设置 → Images</p><p>找到 “图像生成引擎” 选项，选择 “OpenAI DALL-E”。</p><p>填写配置：</p><table><thead><tr><th>字段</th><th>值</th></tr></thead><tbody><tr><td><strong>API Key</strong></td><td>你的 OpenAI API 密钥</td></tr><tr><td><strong>模型</strong></td><td>dall-e-3（推荐）或 dall-e-2</td></tr><tr><td><strong>图像尺寸</strong></td><td>1024x1024（标准）、1792x1024（宽屏）、1024x1792（竖屏）</td></tr><tr><td><strong>图像质量</strong></td><td>standard（标准）或 hd（高清，更贵）</td></tr></tbody></table><p><strong>费用说明</strong>：</p><ul><li>DALL-E 3 标准质量：$0.040 / 张</li><li>DALL-E 3 高清质量：$0.080 / 张</li><li>DALL-E 2：$0.020 / 张</li></ul><h3 id="ComfyUI-集成">ComfyUI 集成</h3><p>ComfyUI 是一个开源的图像生成工作流工具，支持 Stable Diffusion 等模型。</p><p><strong>前置要求</strong>：</p><p>你需要先部署 ComfyUI 服务。ComfyUI 的部署超出本教程范围，请参考 ComfyUI 官方文档。</p><p><strong>配置步骤</strong>：</p><p>管理员面板 → 设置 → Images → 选择 “ComfyUI”</p><p>填写配置：</p><table><thead><tr><th>字段</th><th>说明</th></tr></thead><tbody><tr><td><strong>ComfyUI Base URL</strong></td><td>ComfyUI 服务地址，如 <code>http://localhost:8188</code></td></tr><tr><td><strong>工作流 JSON</strong></td><td>ComfyUI 的工作流配置文件</td></tr></tbody></table><p><strong>工作流配置</strong>：</p><p>ComfyUI 使用 JSON 格式的工作流文件来定义图像生成流程。你需要：</p><ol><li>在 ComfyUI 中设计好工作流</li><li>导出为 JSON 文件</li><li>将 JSON 内容粘贴到 Open WebUI 的配置中</li></ol><p><strong>优势</strong>：</p><ul><li><p>完全免费（使用本地模型）</p></li><li><p>完全可控，可以自定义各种参数</p></li><li><p>支持多种 Stable Diffusion 模型</p></li></ul><p><strong>劣势</strong>：</p><ul><li>配置复杂，需要一定的技术能力</li><li>需要额外的硬件资源（特别是 GPU）</li></ul><h3 id="AUTOMATIC1111-集成">AUTOMATIC1111 集成</h3><p>AUTOMATIC1111 (Stable Diffusion WebUI) 是另一个流行的开源图像生成工具。</p><p><strong>配置步骤</strong>：</p><p>管理员面板 → 设置 → Images → 选择 “AUTOMATIC1111”</p><p>填写配置：</p><table><thead><tr><th>字段</th><th>值</th></tr></thead><tbody><tr><td><strong>API Base URL</strong></td><td><code>http://localhost:7860</code></td></tr><tr><td><strong>API Key</strong></td><td>如果设置了认证，填入密钥</td></tr></tbody></table><p><strong>启用 API</strong>：</p><p>AUTOMATIC1111 默认不开启 API，需要在启动时添加参数：</p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">python launch.py --api --listen</span><br></pre></td></tr></table></figure><p><strong>测试连接</strong>：</p><p>配置完成后，点击 “测试连接” 按钮，如果成功会显示可用的模型列表。</p><h3 id="图像生成参数设置">图像生成参数设置</h3><p>无论使用哪种引擎，都可以配置默认的生成参数：</p><table><thead><tr><th>参数</th><th>说明</th><th>推荐值</th></tr></thead><tbody><tr><td><strong>Steps</strong></td><td>生成步数，越多质量越好但越慢</td><td>20-30</td></tr><tr><td><strong>CFG Scale</strong></td><td>提示词引导强度</td><td>7-9</td></tr><tr><td><strong>Sampler</strong></td><td>采样器类型</td><td>Euler a 或 DPM++ 2M</td></tr><tr><td><strong>负面提示词</strong></td><td>不想出现的元素</td><td>ugly, blurry, low quality</td></tr></tbody></table><p>这些参数主要用于 Stable Diffusion 类模型，DALL-E 不需要配置。</p><h3 id="通过-CLIProxyAPI-Plus-对接图像生成-编辑">通过 CLIProxyAPI Plus 对接图像生成/编辑</h3><p>如果你使用的是 CLIProxyAPI Plus（CPA）作为 OpenAI 兼容代理，它原生只提供 <code>/v1/chat/completions</code> 端点，不支持 <code>/v1/images/generations</code> 和 <code>/v1/images/edits</code>。但 Open WebUI 的 OpenAI 图像引擎恰恰需要这两个端点。</p><p>我们对 CPA 源码进行了 Fork 修改，新增了这两个端点，原理是将图像 API 请求转换为 Chat Completions 调用：</p><table><thead><tr><th>端点</th><th>请求格式</th><th>转换逻辑</th></tr></thead><tbody><tr><td><code>POST /v1/images/generations</code></td><td>JSON（prompt + model）</td><td>构建纯文本 chat completions 请求，从响应中提取 <code>data:image/xxx;base64,...</code></td></tr><tr><td><code>POST /v1/images/edits</code></td><td>multipart/form-data（image 文件 + prompt + model）</td><td>将上传图片转为 base64 data URI，构建多模态 chat completions 请求（image_url + text）</td></tr></tbody></table><p>两个端点都返回标准 OpenAI Images API 格式：<code>&#123;&quot;created&quot;: ..., &quot;data&quot;: [&#123;&quot;b64_json&quot;: &quot;...&quot;&#125;]&#125;</code>。</p><p><strong>涉及的 CPA 源码文件</strong>：</p><ul><li><code>sdk/api/handlers/openai/openai_images_handler.go</code> — 新增文件，包含 <code>ImageGenerations</code> 和 <code>ImageEdits</code> 两个 handler</li><li><code>internal/api/server.go</code> — 在 <code>setupRoutes()</code> 的 v1 group 中注册路由（3 行改动）</li></ul><p><strong>Open WebUI 配置</strong>：</p><p>图像生成（管理员面板 → 设置 → Images）：</p><table><thead><tr><th>字段</th><th>值</th></tr></thead><tbody><tr><td><strong>引擎</strong></td><td>OpenAI</td></tr><tr><td><strong>API Base URL</strong></td><td><code>http://host.docker.internal:8317/v1</code></td></tr><tr><td><strong>API Key</strong></td><td>你的 CPA api-key</td></tr><tr><td><strong>模型</strong></td><td>手动输入模型 ID，如 <code>prorise/gemini-3-pro-image-preview</code></td></tr></tbody></table><p>图像编辑配置同理，引擎选 OpenAI，URL 和 Key 相同，模型填支持图像编辑的模型 ID。</p><p><strong>触发机制</strong>：</p><ul><li>聊天中纯文字描述 → 触发 <code>/images/generations</code>（图像生成）</li><li>聊天中上传图片 + 文字描述 → 触发 <code>/images/edits</code>（图像编辑，需开启 <code>ENABLE_IMAGE_EDIT</code>）</li></ul><p><strong>注意事项</strong>：</p><ul><li>图像模型建议在 Open WebUI 的模型高级设置中关闭流式输出（<code>stream_response: false</code>），避免 chunk 过大导致前端显示异常</li><li>CPA 源码 Fork 详见项目根目录的 <code>FORK_README.md</code>，同步上游更新时注意冲突风险</li></ul><hr><h2 id="3-6-语音功能配置">3.6. 语音功能配置</h2><p>Open WebUI 的语音交互由两部分组成：<strong>听（STT，语音转文字）</strong> 和 <strong>说（TTS，文字转语音）</strong>。合理的配置需要在“响应速度、拟真体验、实际花销”三者之间找到平衡。</p><h3 id="语音转文字（STT）配置">语音转文字（STT）配置</h3><p>STT 决定了系统“听得有多准”和“听得有多快”，以及你聊天的成本消耗，由于语音转文字本质拉不开很大差距，一般来说都会使用网页API 或选择 Whisper 作为使用</p><h4 id="1-核心引擎横向对比与实际计费">1. 核心引擎横向对比与实际计费</h4><table><thead><tr><th>引擎选型</th><th>成本梯队</th><th>实际计费标准 (预估)</th><th>核心优势</th></tr></thead><tbody><tr><td><strong>网页 API</strong></td><td>免费</td><td><strong>$0</strong> (利用浏览器原生能力)</td><td>服务器零负载，响应极快，零成本</td></tr><tr><td><strong>Whisper (本地)</strong></td><td>免费</td><td><strong>$0</strong> (仅消耗本机/服务器电费)</td><td>隐私最强，完全离线，不按时长收费</td></tr><tr><td><strong>Deepgram</strong></td><td>低成本</td><td><strong>约 $0.0043 / 分钟</strong></td><td>专为实时语音设计，延迟极低，性价比极高</td></tr><tr><td><strong>OpenAI</strong></td><td>适中</td><td><strong>$0.006 / 分钟</strong></td><td>业界标杆，多语言混合识别极准，无需额外注册</td></tr><tr><td><strong>Azure AI 语音</strong></td><td>偏高</td><td><strong>约 $1.00 / 小时</strong> ($0.016/分)</td><td>微软企业级稳定服务，带口音的方言识别优秀</td></tr></tbody></table><h4 id="2-深度选型与成本解析">2. 深度选型与成本解析</h4><ul><li><p><strong>完全免费且省心：网页 API (Web API)</strong></p></li><li><p><strong>计费逻辑</strong>：绝对免费。它调用的是你当前所用浏览器（如 Chrome）内置的语音识别接口。</p></li><li><p><strong>适用场景</strong>：预算为零，服务器没有显卡（GPU）跑不动本地模型，且能保证全程使用 HTTPS 访问的用户。</p></li><li><p><strong>免费但吃硬件：Whisper (本地)</strong></p></li><li><p><strong>计费逻辑</strong>：软件层面免费，但<strong>隐性成本在硬件</strong>。它会占用你服务器的 CPU 和显存。如果租用云服务器，为了跑顺畅可能需要升级高配实例。</p></li><li><p><strong>选型建议</strong>：如果你的服务器本身配置就高（如拥有 8GB 以上显存的独立显卡），强烈建议选这个。数据不出局域网，隐私绝对安全。</p></li><li><p><strong>云端高性价比方案：Deepgram</strong></p></li><li><p><strong>计费逻辑</strong>：按秒计费，极其便宜。折算下来一小时一直说话也才两毛多美元。</p></li><li><p><strong>选型建议</strong>：如果你需要高频使用语音对话，Deepgram 的 Nova-2 模型是<strong>首选</strong>。它的转录速度远快于 OpenAI，能大幅降低你等 AI 回复的“空窗期”。</p></li><li><p><strong>高质量兜底方案：OpenAI Whisper</strong></p></li><li><p><strong>计费逻辑</strong>：按分钟计费。如果你每天和 AI 聊 10 分钟语音，一个月大约花费 $1.8。</p></li><li><p><strong>选型建议</strong>：如果你平时说话中英文夹杂，或者专业术语多，OpenAI 的容错和纠错能力是目前云端 API 里最好的。</p></li></ul><hr><h3 id="文字转语音（TTS）配置">文字转语音（TTS）配置</h3><p>TTS 决定了 AI 的“音色”和“情感”，这项功能由于需要生成音频文件，通常比 STT 更贵。</p><h4 id="1-核心引擎横向对比与实际计费-2">1. 核心引擎横向对比与实际计费</h4><table><thead><tr><th>引擎选型</th><th>成本梯队</th><th>实际计费标准 (预估)</th><th>拟真度</th></tr></thead><tbody><tr><td><strong>网页 API</strong></td><td>免费</td><td><strong>$0</strong> (调用系统内置 TTS)</td><td>⭐⭐ (明显机械音)</td></tr><tr><td><strong>openai-edge-tts</strong> 🏆</td><td>免费</td><td><strong>$0</strong> (微软 Edge 在线语音，中间件伪装)</td><td>⭐⭐⭐⭐⭐ (中文场景极佳，接近 OpenAI)</td></tr><tr><td><strong>Transformers</strong></td><td>免费</td><td><strong>$0</strong> (消耗本地算力生成)</td><td>⭐⭐⭐ (略带顿挫感)</td></tr><tr><td><strong>OpenAI</strong></td><td>低成本</td><td><strong>$0.015 / 千字符</strong></td><td>⭐⭐⭐⭐ (非常自然流畅)</td></tr><tr><td><strong>Azure AI 语音</strong></td><td>适中</td><td><strong>约 $0.016 / 千字符</strong></td><td>⭐⭐⭐⭐ (专业播音腔，可选多)</td></tr><tr><td><strong>ElevenLabs</strong></td><td>昂贵</td><td><strong>约 $0.22 / 千字符</strong> (按标准套餐折算)</td><td>⭐⭐⭐⭐⭐ (情感天花板)</td></tr></tbody></table><h4 id="2-深度选型与成本解析-2">2. 深度选型与成本解析</h4><ul><li><p><strong>零成本测试首选：网页 API</strong></p></li><li><p><strong>计费逻辑</strong>：免费。直接让你的 Windows 或 macOS 系统里的&quot;讲述人&quot;来读出文字。</p></li><li><p><strong>体验</strong>：毫无感情，适合用来排查语音链路通不通，不适合长期对话。</p></li><li><p><strong>🏆 中文场景版本答案：openai-edge-tts（强烈推荐）</strong></p></li><li><p><strong>计费逻辑</strong>：完全免费。它通过一个开源中间件（<a href="https://github.com/travisvn/openai-edge-tts">travisvn/openai-edge-tts</a>，GitHub 1.6k+ Stars），将微软 Edge 浏览器内置的高质量在线语音接口伪装成 OpenAI TTS 接口给 Open WebUI 使用。你在抖音/TikTok 上听到的那些非常自然的 AI 解说音，用的就是同一套微软语音引擎。</p></li><li><p><strong>选型建议</strong>：<strong>面向国内中文用户的最佳选择</strong>。音质接近 OpenAI TTS，远超本地机械音，且完全免费、无需 GPU。Open WebUI 官方文档已有专门的集成页面。唯一注意点：它本质是微软云服务的代理，需要联网才能使用，不是真正的离线方案。</p></li><li><p><strong>部署步骤</strong>：</p><p><strong>第一步：启动 Docker 容器</strong></p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">docker run -d -p 5050:5050 -e API_KEY=your_password travisvn/openai-edge-tts:latest</span><br></pre></td></tr></table></figure><p><strong>第二步：在 Open WebUI 管理面板中配置</strong></p><p>进入 管理员面板 → 设置 → 语音，在 TTS 部分填写：</p><table><thead><tr><th>配置项</th><th>填写内容</th><th>说明</th></tr></thead><tbody><tr><td><strong>TTS 引擎</strong></td><td><code>OpenAI</code></td><td>注意：选 OpenAI，不是 Edge，因为中间件伪装成了 OpenAI 接口</td></tr><tr><td><strong>API 基础 URL</strong></td><td><code>http://host.docker.internal:5050/v1</code></td><td>如果 Open WebUI 也在 Docker 中运行；否则填 <code>http://localhost:5050/v1</code></td></tr><tr><td><strong>API 密钥</strong></td><td><code>your_password</code></td><td>与 Docker 启动时的 <code>API_KEY</code> 保持一致</td></tr><tr><td><strong>TTS 模型</strong></td><td><code>tts-1</code></td><td>固定值</td></tr><tr><td><strong>TTS 语音</strong></td><td><code>zh-CN-XiaoxiaoNeural</code></td><td>最受欢迎的中文女声；男声可选 <code>zh-CN-YunxiNeural</code></td></tr></tbody></table><blockquote><p><strong>💡 更多语音选择</strong>：Edge TTS 支持大量中文语音，如 <code>zh-CN-XiaoyiNeural</code>（年轻女声）、<code>zh-CN-YunjianNeural</code>（新闻播报男声）等，完整列表可在容器启动后访问 <code>http://localhost:5050/v1/voices</code> 查看。</p></blockquote><blockquote><p><strong>⚠️ 注意</strong>：此方案依赖微软在线服务，断网时无法使用。如果你需要完全离线的 TTS，请考虑 Transformers 本地方案或下方的 Kokoro-FastAPI。</p></blockquote></li><li><p><strong>补充：英文场景的替代方案 —— Kokoro-FastAPI</strong></p></li><li><p>如果你的用户主要使用英文对话，社区中另一个高口碑项目是 <a href="https://github.com/remsky/Kokoro-FastAPI">Kokoro-FastAPI</a>。它完全本地运行，英文语音质量被社区评为当前最佳，同样提供 OpenAI 兼容 API。但中文支持较弱，因此面向国内用户时 openai-edge-tts 仍是首选。</p></li><li><p><strong>极致性价比：OpenAI TTS</strong></p></li><li><p><strong>计费逻辑</strong>：按生成的字符数收费。1000 个英文字符或中文字大概只要 1 分多钱（美元）。即使重度使用，每个月也就几美元。</p></li><li><p><strong>选型建议</strong>：<strong>90% 用户的最佳选择</strong>。模型选 <code>tts-1</code> 即可（<code>tts-1-hd</code> 贵一倍且速度慢，对话时完全没必要）。声音推荐 <code>Alloy</code>（中性）或 <code>Nova</code>（活力）。</p></li><li><p><strong>如果你需要更高质量的选型（听觉享受）：ElevenLabs</strong></p></li><li><p><strong>计费逻辑</strong>：非常贵。采用订阅+额度制（如 $22/月 给 10 万字符），折算下来<strong>单价比 OpenAI 贵了 15 倍左右</strong>。</p></li><li><p><strong>为什么选它</strong>：物有所值。它是目前唯一能做到“根据上下文叹气、呼吸、调整情绪甚至哭腔”的 API。如果你把 AI 当作情感树洞，或者需要克隆特定人的声音，这笔钱花得值。</p></li></ul><hr><h2 id="3-7-网络搜索功能配置">3.7. 网络搜索功能配置</h2><p>网络搜索功能让 AI 能够获取最新的网络信息，突破模型训练数据的时效性限制。用户在对话中开启&quot;联网搜索&quot;开关后，Open WebUI 会先调用搜索引擎获取实时结果，再注入到 LLM 上下文中辅助回答。</p><p><strong>配置入口</strong>：管理员面板 → 设置 → 联网搜索</p><hr><h3 id="配置项说明">配置项说明</h3><table><thead><tr><th>配置项</th><th>说明</th><th>推荐值</th></tr></thead><tbody><tr><td><strong>启用联网搜索</strong></td><td>总开关，关闭时所有用户均无法使用</td><td>按需开启</td></tr><tr><td><strong>搜索引擎</strong></td><td>下拉选择提供商，选择后下方动态显示该引擎所需字段（Key、URL 等）</td><td>见下方选型</td></tr><tr><td><strong>搜索结果数量</strong></td><td>每次返回的结果条数，太少信息不足，太多增加 token 消耗</td><td>5-8</td></tr><tr><td><strong>并发数</strong></td><td>同时抓取结果页面的并发数，过高可能触发速率限制</td><td>默认即可</td></tr><tr><td><strong>旁路 SSL 验证</strong></td><td>跳过 SSL 证书验证，仅自部署引擎用自签名证书时开启</td><td>关闭</td></tr></tbody></table><hr><h3 id="全部搜索引擎一览">全部搜索引擎一览</h3><p><strong>🟢 完全免费（自部署或无需 Key）</strong></p><ul><li><strong>DuckDuckGo</strong>（<code>duckduckgo</code>）— 零配置开箱即用，无需 API Key，通过 Python 库直接调用。缺点：可能被速率限制，国内需代理</li><li><strong>SearXNG</strong>（<code>searxng</code>）— 🏆 <strong>社区最推荐</strong>。开源元搜索引擎，聚合 Google、Bing 等 70+ 引擎结果，需 Docker 自部署，完全免费、无限次数、隐私安全</li><li><strong>YaCy</strong>（<code>yacy</code>）— 去中心化 P2P 搜索引擎，完全自托管，搜索质量远不如 SearXNG</li><li><strong>Ollama Cloud</strong>（<code>ollama_cloud</code>）— 用本地 Ollama 模型生成搜索，完全离线但质量有限</li></ul><p><strong>🔵 有免费额度（白嫖友好，需注册获取 Key）</strong></p><ul><li><strong>Serper</strong>（<code>serper</code>）— 🏆 <strong>性价比之王</strong>。基于 Google SERP，注册送 2,500 次（一次性），付费 $0.30/千次起，速度极快</li><li><strong>Brave</strong>（<code>brave</code>）— 每月 2,000 次免费（每月刷新），独立搜索索引，隐私友好，超出后 $3/千次</li><li><strong>Tavily</strong>（<code>tavily</code>）— 每月 1,000 次免费，专为 AI/RAG 设计，返回干净文本，超出后 $0.008/次</li><li><strong>Exa</strong>（<code>exa</code>）— 注册送 $10 额度（约 2,000 次），AI 原生语义搜索，超出后 $5/千次</li><li><strong>Google PSE</strong>（<code>google_pse</code>）— 每天 100 次免费（约 3,000 次/月），搜索质量就是 Google 本身，超出后 $5/千次</li><li><strong>Firecrawl</strong>（<code>firecrawl</code>）— 一次性 500 次免费，搜索 + 深度抓取网页内容，超出后 $16/月起</li><li><strong>SearchApi</strong>（<code>searchapi</code>）— 注册送 100 次，支持 Google/Bing/Baidu/Scholar 多引擎切换，超出后 $50/月起</li><li><strong>Serpstack</strong>（<code>serpstack</code>）— 每月 100 次免费，基于 Google SERP，超出后 $30/月起</li><li><strong>SerpApi</strong>（<code>serpapi</code>）— 每月 100 次免费，功能最全但价格偏高 $25/月起</li><li><strong>Jina</strong>（<code>jina</code>）— 注册送积分，支持语义搜索和网页内容提取，适合 RAG 场景</li></ul><p><strong>🟡 纯付费（无免费额度或需订阅）</strong></p><ul><li><strong>Bing</strong>（<code>bing</code>）— ⚠️ 微软已于 2025 年 8 月宣布退役，不推荐新用户</li><li><strong>Kagi</strong>（<code>kagi</code>）— $10/月起订阅制，高质量无广告搜索</li><li><strong>Mojeek</strong>（<code>mojeek</code>）— 英国独立搜索引擎，有自己的爬虫索引，需联系定价</li><li><strong>Serply</strong>（<code>serply</code>）— $49/月起，性价比不高</li><li><strong>Bocha</strong>（<code>bocha</code>）— 🇨🇳 国产博查搜索，国内可直连，需联系定价</li><li><strong>Sogou</strong>（<code>sougou</code>）— 🇨🇳 搜狗搜索，国内可直连，需联系定价</li><li><strong>Yandex</strong>（<code>yandex</code>）— 俄罗斯搜索引擎，俄语搜索质量好</li></ul><p><strong>🟣 AI 增强搜索</strong></p><ul><li><strong>Perplexity</strong>（<code>perplexity</code>）— Sonar API，搜索 + AI 总结，Pro 订阅每月附赠 $5 额度</li><li><strong>Perplexity Search</strong>（<code>perplexity_search</code>）— 同上，纯搜索不含 AI 总结</li><li><strong>External</strong>（<code>external</code>）— 万能逃生舱，指向任意自定义 HTTP 搜索服务</li></ul><blockquote><p>💡 以上除 Bocha、Sogou 外，其余引擎均需代理才能在国内访问。</p></blockquote><hr><h3 id="选型推荐与部署">选型推荐与部署</h3><p><strong>🏆 最优解：SearXNG 自部署</strong></p><p>适合有服务器的个人/团队，零成本、无限次数、隐私安全。社区公认的最佳方案。</p><p>部署步骤：</p><ol><li>克隆仓库并进入目录：</li></ol><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><span class="line">git <span class="built_in">clone</span> https://github.com/searxng/searxng-docker.git</span><br><span class="line"><span class="built_in">cd</span> searxng-docker</span><br></pre></td></tr></table></figure><ol start="2"><li>修改 <code>settings.yml</code>（最关键一步，必须启用 JSON 格式，否则 Open WebUI 报 403）：</li></ol><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></pre></td><td class="code"><pre><span class="line"><span class="attr">use_default_settings:</span> <span class="literal">true</span></span><br><span class="line"></span><br><span class="line"><span class="attr">server:</span></span><br><span class="line">  <span class="attr">secret_key:</span> <span class="string">&quot;your-random-secret-key&quot;</span>  <span class="comment"># 必须修改</span></span><br><span class="line">  <span class="attr">limiter:</span> <span class="literal">false</span>  <span class="comment"># 关闭速率限制，避免被限流</span></span><br><span class="line">  <span class="attr">image_proxy:</span> <span class="literal">true</span></span><br><span class="line"></span><br><span class="line"><span class="attr">search:</span></span><br><span class="line">  <span class="attr">safe_search:</span> <span class="number">0</span></span><br><span class="line">  <span class="attr">autocomplete:</span> <span class="string">&quot;&quot;</span></span><br><span class="line">  <span class="attr">default_lang:</span> <span class="string">&quot;zh-CN&quot;</span></span><br><span class="line">  <span class="attr">formats:</span></span><br><span class="line">    <span class="bullet">-</span> <span class="string">html</span></span><br><span class="line">    <span class="bullet">-</span> <span class="string">json</span>  <span class="comment"># ⚠️ 必须添加，否则 Open WebUI 无法调用</span></span><br></pre></td></tr></table></figure><ol start="3"><li>启动：<code>docker compose up -d</code>，或在 Open WebUI 的 <code>docker-compose.local.yaml</code> 中添加：</li></ol><figure class="highlight yaml"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br></pre></td><td class="code"><pre><span class="line"><span class="attr">services:</span></span><br><span class="line">  <span class="attr">searxng:</span></span><br><span class="line">    <span class="attr">image:</span> <span class="string">searxng/searxng:latest</span></span><br><span class="line">    <span class="attr">container_name:</span> <span class="string">searxng</span></span><br><span class="line">    <span class="attr">ports:</span></span><br><span class="line">      <span class="bullet">-</span> <span class="string">&quot;8080:8080&quot;</span></span><br><span class="line">    <span class="attr">volumes:</span></span><br><span class="line">      <span class="bullet">-</span> <span class="string">./searxng:/etc/searxng</span></span><br><span class="line">    <span class="attr">restart:</span> <span class="string">unless-stopped</span></span><br></pre></td></tr></table></figure><ol start="4"><li>在 Open WebUI 中配置：搜索引擎选 <code>searxng</code>，查询 URL 填 <code>http://searxng:8080/search?q=&lt;query&gt;</code>（Docker 同网络）或 <code>http://localhost:8080/search?q=&lt;query&gt;</code>。</li></ol><blockquote><p>⚠️ 常见问题：</p><ul><li><strong>403 错误</strong>：99% 是 <code>settings.yml</code> 没加 <code>json</code> 格式</li><li><strong>结果为空</strong>：SearXNG 容器需能访问外网，国内服务器需为 SearXNG 容器配代理</li><li><strong>超时</strong>：检查 <code>limiter</code> 是否为 <code>false</code>，上游搜索引擎是否可达</li></ul></blockquote><p><strong>💰 零成本懒人方案：DuckDuckGo</strong></p><p>不想部署任何服务的用户，搜索引擎选 <code>duckduckgo</code> 即可，无需填写任何 Key。缺点是可能被限流、国内需代理、搜索质量不如 Google。</p><p><strong>🎯 付费性价比之选：Serper</strong></p><p>需要 Google 级搜索质量但预算有限的用户。访问 <a href="https://serper.dev">https://serper.dev</a> 注册，复制 API Key，搜索引擎选 <code>serper</code> 填入即可。注册送 2,500 次，付费后 $0.30/千次起。</p><p><strong>🇨🇳 国内直连方案：Bocha / Sogou</strong></p><p>服务器在国内、无法配代理的用户。Bocha（博查）和 Sogou（搜狗）国内可直连，中文搜索质量较好，需联系服务商获取 Key 和定价。</p><hr><h3 id="成本速算">成本速算</h3><p>假设日均搜索 20 次（月均 600 次）：</p><table><thead><tr><th>方案</th><th>月成本</th><th>说明</th></tr></thead><tbody><tr><td>SearXNG 自部署</td><td>$0</td><td>仅消耗服务器资源</td></tr><tr><td>DuckDuckGo</td><td>$0</td><td>可能被限流</td></tr><tr><td>Brave 免费额度</td><td>$0</td><td>每月 2,000 次，完全够用</td></tr><tr><td>Google PSE 免费额度</td><td>$0</td><td>每天 100 次，完全够用</td></tr><tr><td>Tavily 免费额度</td><td>$0</td><td>每月 1,000 次，勉强够用</td></tr><tr><td>Serper 免费额度</td><td>$0</td><td>2,500 次一次性，约可用 4 个月</td></tr><tr><td>Serper 付费</td><td>~$0.18</td><td>超出免费额度后</td></tr><tr><td>Brave 付费</td><td>~$1.80</td><td>超出免费额度后</td></tr><tr><td>Kagi</td><td>$10+</td><td>订阅制</td></tr></tbody></table><blockquote><p>💡 个人使用（日均 &lt;30 次），Brave（2,000 次/月）或 Google PSE（100 次/天）的免费额度完全够用。团队或高频搜索，SearXNG 自部署是唯一真正无限制的方案。</p></blockquote><hr><h2 id="3-8-Pipelines-与-Functions-扩展系统">3.8. Pipelines 与 Functions 扩展系统</h2><p>Open WebUI 提供了两种扩展机制：<strong>Functions</strong>（函数）和 <strong>Pipelines</strong>（管道）。理解它们的区别非常重要，选错了会让简单的事情变复杂。</p><blockquote><p>⚠️ <strong>官方明确建议</strong>：对于大多数扩展需求（如添加新的 API 提供商、基础过滤器、简单工具），<strong>请使用 Functions，不要使用 Pipelines</strong>。Pipelines 仅适用于需要将计算密集型任务卸载到独立进程的场景。</p></blockquote><h3 id="Functions-vs-Pipelines：如何选择">Functions vs Pipelines：如何选择</h3><table><thead><tr><th>对比项</th><th>Functions（推荐优先）</th><th>Pipelines</th></tr></thead><tbody><tr><td><strong>部署方式</strong></td><td>内置于 Open WebUI，无需额外服务</td><td>需要独立部署 Pipelines 服务</td></tr><tr><td><strong>适用场景</strong></td><td>添加 API 提供商、过滤器、工具、按钮动作</td><td>计算密集型任务（如大规模数据处理、自定义 ML 推理）</td></tr><tr><td><strong>管理方式</strong></td><td>管理员面板 → 函数</td><td>管理员面板 → 设置 → Pipelines</td></tr><tr><td><strong>开发难度</strong></td><td>简单，直接在 Web 界面编写</td><td>较复杂，需要独立服务和 Docker 部署</td></tr><tr><td><strong>性能影响</strong></td><td>在主进程中运行</td><td>独立进程，不影响主服务</td></tr></tbody></table><p><strong>简单判断规则</strong>：如果你不确定该用哪个，<strong>用 Functions</strong>。只有当你明确需要在独立进程中运行计算密集型任务时，才考虑 Pipelines。</p><h3 id="Functions（函数）">Functions（函数）</h3><p>Functions 是 Open WebUI 内置的扩展机制，直接在管理员面板中管理，无需额外部署。</p><p><strong>Functions 的四种类型</strong>：</p><table><thead><tr><th>类型</th><th>说明</th><th>使用场景</th></tr></thead><tbody><tr><td><strong>Filter</strong></td><td>在消息发送到模型前/后进行处理</td><td>内容过滤、格式转换、日志记录</td></tr><tr><td><strong>Action</strong></td><td>在消息气泡上添加自定义按钮</td><td>一键翻译、一键总结、复制格式化内容</td></tr><tr><td><strong>Tool</strong></td><td>为模型提供可调用的工具</td><td>网络搜索、数据库查询、API 调用</td></tr><tr><td><strong>Pipe</strong></td><td>添加新的模型端点或 API 提供商</td><td>接入自定义 API、代理转发</td></tr></tbody></table><p><strong>管理 Functions</strong>：</p><p>管理员面板 → 函数（Functions）</p><p>在这里你可以：</p><ul><li>创建新函数（直接在 Web 编辑器中编写 Python 代码）</li><li>从社区导入函数</li><li>启用/禁用函数</li><li>配置函数参数（Valves）</li></ul><p><strong>社区函数库</strong>：</p><p>Open WebUI 社区提供了大量现成的函数，访问 <a href="https://openwebui.com/functions/">https://openwebui.com/functions/</a> 浏览和导入。</p><blockquote><p>💡 Functions 的详细开发将在后续章节中介绍。本节重点是让管理员了解如何管理和配置。</p></blockquote><h3 id="Pipelines（仅限计算密集型场景）">Pipelines（仅限计算密集型场景）</h3><p>如果你确实需要将计算密集型任务卸载到独立进程，才需要部署 Pipelines。</p><p><strong>部署 Pipelines 服务</strong>：</p><p>在你的 <code>docker-compose.local.yaml</code> 中添加 Pipelines 服务：</p><figure class="highlight yaml"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br></pre></td><td class="code"><pre><span class="line"><span class="attr">services:</span></span><br><span class="line">  <span class="attr">pipelines:</span></span><br><span class="line">    <span class="attr">image:</span> <span class="string">ghcr.io/open-webui/pipelines:main</span></span><br><span class="line">    <span class="attr">container_name:</span> <span class="string">pipelines</span></span><br><span class="line">    <span class="attr">ports:</span></span><br><span class="line">      <span class="bullet">-</span> <span class="string">&quot;9099:9099&quot;</span></span><br><span class="line">    <span class="attr">extra_hosts:</span></span><br><span class="line">      <span class="bullet">-</span> <span class="string">host.docker.internal:host-gateway</span></span><br><span class="line">    <span class="attr">volumes:</span></span><br><span class="line">      <span class="bullet">-</span> <span class="string">pipelines-data:/app/pipelines</span></span><br><span class="line">    <span class="attr">restart:</span> <span class="string">unless-stopped</span></span><br><span class="line"></span><br><span class="line">  <span class="attr">open-webui:</span></span><br><span class="line">    <span class="comment"># ... 你的现有配置</span></span><br><span class="line">    <span class="attr">environment:</span></span><br><span class="line">      <span class="bullet">-</span> <span class="string">PIPELINES_URLS=http://pipelines:9099</span></span><br><span class="line"></span><br><span class="line"><span class="attr">volumes:</span></span><br><span class="line">  <span class="attr">pipelines-data:</span></span><br></pre></td></tr></table></figure><p>启动后访问 <a href="http://localhost:9099/docs">http://localhost:9099/docs</a> 验证 Pipelines API 是否正常。</p><p><strong>在 Open WebUI 中连接</strong>：</p><p>管理员面板 → 设置 → Pipelines → 填入 <code>http://pipelines:9099</code> → 点击刷新</p><p><strong>管理插件</strong>：</p><p>连接成功后，你可以：</p><ul><li>上传 Python 插件文件（<code>.py</code>）</li><li>启用/禁用插件</li><li>配置插件参数（Valves）</li></ul><p><strong>Pipelines 插件示例</strong>：</p><p>官方示例仓库：<a href="https://github.com/open-webui/pipelines/tree/main/examples">https://github.com/open-webui/pipelines/tree/main/examples</a></p><table><thead><tr><th>插件名称</th><th>功能</th><th>使用场景</th></tr></thead><tbody><tr><td><strong>Langfuse</strong></td><td>集成 Langfuse 监控平台</td><td>大规模使用量监控</td></tr><tr><td><strong>LLM Guard</strong></td><td>防止提示词注入攻击</td><td>安全防护（计算密集）</td></tr><tr><td><strong>Detoxify</strong></td><td>基于 ML 模型的有害内容过滤</td><td>内容安全（需要 GPU）</td></tr></tbody></table><blockquote><p>💡 像 Rate Limit（限流）、LibreTranslate（翻译）这类轻量级功能，现在推荐使用 Functions 实现，不再需要部署 Pipelines。</p></blockquote><hr><h2 id="3-9-安全与认证配置">3.9. 安全与认证配置</h2><p>这一章讲的是“谁能登录、谁能调用 API、用户身份如何同步、会话如何保活”。</p><p>如果你是个人用户，这部分很多配置可以先不动；但只要进入团队协作、公司内网、对接脚本或自动化系统，这一章就会变成必修课。</p><p>在开始之前，先解释几个经常会混在一起的术语：</p><ul><li><strong>认证（Authentication）</strong>：证明“你是谁”。例如账号密码登录、LDAP 登录、Google 登录。</li><li><strong>授权（Authorization）</strong>：决定“你能做什么”。例如能不能看某个模型、能不能导出模型、能不能管理工具。</li><li><strong>API Key</strong>：发给程序用的密钥，适合脚本、集成平台、外部服务调用 Open WebUI API。</li><li><strong>LDAP</strong>：企业常见的目录服务协议，很多公司的 AD（Active Directory）也兼容这套方式。</li><li><strong>OAuth / OIDC</strong>：第三方登录体系。OAuth 偏“授权”，OIDC 是建立在 OAuth 之上的“身份登录”标准。</li><li><strong>SCIM</strong>：自动开通和回收账号的标准，不负责“登录”，而负责“账号生命周期同步”。</li></ul><h3 id="API-密钥认证">API 密钥认证</h3><p><strong>这是什么</strong></p><p>Open WebUI 支持为用户生成 API Key，让外部脚本、自动化流程、内部平台、工作流引擎通过 HTTP API 访问它。</p><p>典型场景包括：</p><ul><li>用 Python、Node.js 或 Shell 脚本调用聊天接口</li><li>用企业内部系统转发请求到 Open WebUI</li><li>给工作流平台、Bot、自动化任务提供一个稳定认证方式</li></ul><p>它和“浏览器登录 Cookie”不是一回事：</p><ul><li>浏览器登录主要给人用</li><li>API Key 主要给程序用</li></ul><p><strong>如何启用</strong></p><p>管理员面板 → 设置 → 通用 → 启用 API Key</p><p>启用后，用户就可以在自己的账号设置中创建 API Key。</p><p>当前版本除了总开关外，还支持 <strong>API Key Endpoint Restrictions</strong>，也就是“限制 API Key 只能访问哪些接口”。这个能力很重要，因为它可以避免把一把过大的密钥发给外部系统。</p><p><strong>用户如何生成密钥</strong></p><p>点击头像 → 设置 → 账号 → API 密钥 → 点击“创建新密钥”</p><p>创建后要立即保存，因为这类密钥通常不会反复明文展示。</p><p><strong>调用示例</strong></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></pre></td><td class="code"><pre><span class="line">curl -X POST http://localhost:3000/api/chat \</span><br><span class="line">  -H <span class="string">&quot;Authorization: Bearer sk-...&quot;</span> \</span><br><span class="line">  -H <span class="string">&quot;Content-Type: application/json&quot;</span> \</span><br><span class="line">  -d <span class="string">&#x27;&#123;&quot;message&quot;: &quot;Hello&quot;&#125;&#x27;</span></span><br></pre></td></tr></table></figure><p><strong>官方建议理解</strong></p><p>官网当前的方向不是“默认把所有接口都敞开”，而是：</p><ul><li>可以启用 API Key</li><li>可以进一步启用接口级限制</li><li>在可信内网环境里可以更宽松，在生产环境则建议更严格</li></ul><p><strong>我的管理方式</strong></p><p>在我的环境里，API Key 主要给“程序”而不是“人”使用，所以我通常这样管理：</p><ol><li>浏览器用户走正常登录，不给所有人默认要求 API Key。</li><li>只有确实需要脚本调用的账号，才生成 API Key。</li><li>给外部系统时，优先启用接口限制，不给过大的权限面。</li><li>定期清理不再使用的密钥，避免历史脚本长期持有可用凭证。</li><li>如果只是临时测试，我会单独创建测试用密钥，不和正式环境长期复用。</li></ol><h3 id="LDAP-集成">LDAP 集成</h3><p><strong>这是什么</strong></p><p>LDAP 可以理解为“公司统一通讯录 / 账号目录”的标准接口。</p><p>如果你所在的组织已经有 AD、OpenLDAP 或其他企业目录系统，那么 Open WebUI 可以直接对接这个目录，让员工使用公司账号登录，而不是在 Open WebUI 里重复创建本地账号。</p><p><strong>适合谁</strong></p><ul><li>公司内网部署</li><li>已有统一身份管理</li><li>希望账号、邮箱、用户名来自企业目录</li></ul><p>个人部署、小团队、临时测试环境，通常不需要上 LDAP。</p><p><strong>官方配置思路</strong></p><p>LDAP 推荐先通过环境变量初始化，然后在管理员面板里继续维护。官网特别强调一件事：</p><blockquote><p>如果启用了持久化配置（默认就是），很多环境变量只在<strong>第一次启动</strong>时读入；后续修改通常要在管理后台里改，而不是只改 <code>docker-compose</code>。</p></blockquote><p>这点非常重要，也是很多人“明明改了环境变量，怎么界面没变”的根源。</p><p><strong>当前版本常见环境变量</strong></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></pre></td><td class="code"><pre><span class="line"><span class="attr">environment:</span></span><br><span class="line">  <span class="bullet">-</span> <span class="string">ENABLE_LDAP=true</span></span><br><span class="line">  <span class="bullet">-</span> <span class="string">LDAP_SERVER_LABEL=OpenLDAP</span></span><br><span class="line">  <span class="bullet">-</span> <span class="string">LDAP_SERVER_HOST=ldap.example.com</span></span><br><span class="line">  <span class="bullet">-</span> <span class="string">LDAP_SERVER_PORT=389</span></span><br><span class="line">  <span class="bullet">-</span> <span class="string">LDAP_ATTRIBUTE_FOR_MAIL=mail</span></span><br><span class="line">  <span class="bullet">-</span> <span class="string">LDAP_ATTRIBUTE_FOR_USERNAME=uid</span></span><br><span class="line">  <span class="bullet">-</span> <span class="string">LDAP_APP_DN=cn=admin,dc=example,dc=org</span></span><br><span class="line">  <span class="bullet">-</span> <span class="string">LDAP_APP_PASSWORD=your_password</span></span><br><span class="line">  <span class="bullet">-</span> <span class="string">LDAP_SEARCH_BASE=dc=example,dc=org</span></span><br><span class="line">  <span class="bullet">-</span> <span class="string">LDAP_SEARCH_FILTER=(uid=%(user)s)</span></span><br><span class="line">  <span class="bullet">-</span> <span class="string">LDAP_USE_TLS=true</span></span><br></pre></td></tr></table></figure><p>你会发现，这一版和很多旧博客里写的 <code>LDAP_SERVER_URL</code>、<code>LDAP_BIND_DN</code> 之类名字不完全一样。写文档时一定要以当前版本为准。</p><p><strong>参数解释</strong></p><table><thead><tr><th>参数</th><th>说明</th></tr></thead><tbody><tr><td><code>ENABLE_LDAP</code></td><td>是否启用 LDAP 登录</td></tr><tr><td><code>LDAP_SERVER_LABEL</code></td><td>在界面里显示的 LDAP 名称</td></tr><tr><td><code>LDAP_SERVER_HOST</code> / <code>LDAP_SERVER_PORT</code></td><td>LDAP 服务器地址和端口</td></tr><tr><td><code>LDAP_ATTRIBUTE_FOR_MAIL</code></td><td>从 LDAP 条目里取邮箱的字段</td></tr><tr><td><code>LDAP_ATTRIBUTE_FOR_USERNAME</code></td><td>从 LDAP 条目里取用户名的字段</td></tr><tr><td><code>LDAP_APP_DN</code> / <code>LDAP_APP_PASSWORD</code></td><td>用于查询目录的服务账号</td></tr><tr><td><code>LDAP_SEARCH_BASE</code></td><td>搜索用户的根 DN</td></tr><tr><td><code>LDAP_SEARCH_FILTER</code></td><td>登录时查找用户的过滤器</td></tr><tr><td><code>LDAP_USE_TLS</code></td><td>是否启用 TLS / StartTLS</td></tr></tbody></table><p><strong>正确的排错顺序</strong></p><p>不要一上来就怀疑 Open WebUI。</p><p>官网推荐的思路是：</p><ol><li>先验证 LDAP 服务器本身通不通</li><li>再验证用户条目能不能查到</li><li>最后才看 Open WebUI 配置</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></pre></td><td class="code"><pre><span class="line">ldapsearch -x -H ldap://ldap.example.com:389 \</span><br><span class="line">  -D <span class="string">&quot;cn=admin,dc=example,dc=org&quot;</span> -w your_password \</span><br><span class="line">  -b <span class="string">&quot;dc=example,dc=org&quot;</span> <span class="string">&quot;(uid=jdoe)&quot;</span></span><br></pre></td></tr></table></figure><p>如果这一步都查不到用户，就不要继续在 WebUI 里盲改了。</p><p><strong>我的管理方式</strong></p><p>我自己的判断规则很简单：</p><ul><li>如果是企业内部正式使用，并且员工已经有统一账号体系，我会优先接 LDAP。</li><li>如果只是个人站点、朋友共用、测试环境，我不会为了“看起来企业化”硬上 LDAP。</li><li>上 LDAP 后，我会尽量让用户名、邮箱、显示名都来自目录系统，避免 Open WebUI 自己成为第二套主数据来源。</li></ul><h3 id="OAuth-OIDC-SSO-集成">OAuth / OIDC / SSO 集成</h3><p><strong>这是什么</strong></p><p>这一组就是“第三方登录”。</p><p>最常见的使用方式是：</p><ul><li>用 Google 账号登录</li><li>用 Microsoft / Entra ID 账号登录</li><li>用 GitHub 账号登录</li><li>用企业自己的 OIDC 服务登录</li></ul><p>如果说 LDAP 更像“公司内网目录登录”，那 OAuth / OIDC 更像“现代网站统一登录”。</p><p><strong>先说结论：本地开发完全可以配</strong></p><p>而且你现在这个仓库已经改成了：</p><ul><li><code>docker-compose.local.yaml</code> 自动读取 <code>.env.local</code></li><li>本地端口由 <code>OPEN_WEBUI_PORT</code> 控制</li><li>Google、Microsoft、GitHub 三家都可以直接写在 <code>.env.local</code></li></ul><p>所以对读者来说，最容易跟做的方式就是：</p><ol><li>去各自官网创建 OAuth 应用</li><li>把回调地址填成 Open WebUI 本地回调</li><li>把拿到的密钥填进 <code>.env.local</code></li><li>重启本地容器</li></ol><p><strong>本地统一配置模板</strong></p><p>如果你的本地端口是 <code>3000</code>，那么 <code>.env.local</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><span class="line">17</span><br></pre></td><td class="code"><pre><span class="line">OPEN_WEBUI_PORT=3000</span><br><span class="line">WEBUI_URL=http://localhost:3000</span><br><span class="line">ENABLE_OAUTH_SIGNUP=true</span><br><span class="line">OAUTH_MERGE_ACCOUNTS_BY_EMAIL=true</span><br><span class="line"></span><br><span class="line">GOOGLE_CLIENT_ID=</span><br><span class="line">GOOGLE_CLIENT_SECRET=</span><br><span class="line">GOOGLE_REDIRECT_URI=http://localhost:3000/oauth/google/login/callback</span><br><span class="line"></span><br><span class="line">MICROSOFT_CLIENT_ID=</span><br><span class="line">MICROSOFT_CLIENT_SECRET=</span><br><span class="line">MICROSOFT_CLIENT_TENANT_ID=common</span><br><span class="line">MICROSOFT_REDIRECT_URI=http://localhost:3000/oauth/microsoft/login/callback</span><br><span class="line"></span><br><span class="line">GITHUB_CLIENT_ID=</span><br><span class="line">GITHUB_CLIENT_SECRET=</span><br><span class="line">GITHUB_CLIENT_REDIRECT_URI=http://localhost:3000/oauth/github/login/callback</span><br></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><span class="line">docker compose -f docker-compose.local.yaml up -d --build</span><br></pre></td></tr></table></figure><p>如果你的端口不是 <code>3000</code>，例如 <code>5050</code>，那上面所有 <code>localhost:3000</code> 都要统一改成 <code>localhost:5050</code>。</p><div class="tabs" id="oauth_local_setup"><ul class="nav-tabs"><button type="button" class="tab  active" data-href="oauth_local_setup-1">Google</button><button type="button" class="tab " data-href="oauth_local_setup-2">Microsoft</button><button type="button" class="tab " data-href="oauth_local_setup-3">GitHub</button></ul><div class="tab-contents"><div class="tab-item-content active" id="oauth_local_setup-1"><p><strong>适用场景</strong></p><p>如果你想直接使用 Google 账号登录，这是最常见的一种配置。</p><p><strong>第 1 步：去 Google 官方后台创建应用</strong></p><p>访问：</p><p><a href="https://console.cloud.google.com/apis/credentials">https://console.cloud.google.com/apis/credentials</a></p><p>进入后：</p><ol><li>创建或选择一个 Project</li><li>打开 <code>APIs &amp; Services</code></li><li>进入 <code>Credentials</code>（凭证）</li><li>点击 <code>Create Credentials</code></li><li>选择 <code>OAuth client ID</code></li><li>Application type 选择 <code>Web application</code></li></ol><p><strong>第 2 步：登记回调地址</strong></p><p>在 <code>Authorized redirect URIs</code> 中填写：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">http://localhost:3000/oauth/google/login/callback</span><br></pre></td></tr></table></figure><p><strong>第 3 步：把拿到的密钥填入 <code>.env.local</code></strong></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></pre></td><td class="code"><pre><span class="line">GOOGLE_CLIENT_ID=你的 Google Client ID</span><br><span class="line">GOOGLE_CLIENT_SECRET=你的 Google Client Secret</span><br><span class="line">GOOGLE_REDIRECT_URI=http://localhost:3000/oauth/google/login/callback</span><br></pre></td></tr></table></figure><p><strong>第 4 步：重启本地 Open WebUI</strong></p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">docker compose -f docker-compose.local.yaml up -d --build</span><br></pre></td></tr></table></figure><p><strong>注意事项</strong></p><ul><li>Google 开发环境允许 <code>http://localhost</code></li><li>回调地址必须和后台里登记的 URI 完全一致</li><li>如果你改了端口，Google 后台也要一起改</li></ul></div><div class="tab-item-content" id="oauth_local_setup-2"><p><strong>适用场景</strong></p><p>如果用户本身就在 Microsoft 365、Entra ID、企业账号体系里，这一项非常常见。</p><p><strong>第 1 步：去 Microsoft Entra 后台注册应用</strong></p><p>访问：</p><p><a href="https://portal.azure.com/">https://portal.azure.com/</a></p><p>进入后：</p><ol><li>打开 <code>Microsoft Entra ID</code></li><li>进入 <code>App registrations</code></li><li>点击 <code>New registration</code></li><li>创建一个应用</li></ol><p><strong>第 2 步：配置回调地址</strong></p><p>在 <code>Authentication</code> 页面里添加：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">http://localhost:3000/oauth/microsoft/login/callback</span><br></pre></td></tr></table></figure><p><strong>第 3 步：生成 Secret 并写入 <code>.env.local</code></strong></p><p>在 <code>Certificates &amp; secrets</code> 中生成一个 <code>Client secret</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></pre></td><td class="code"><pre><span class="line">MICROSOFT_CLIENT_ID=你的 Microsoft Client ID</span><br><span class="line">MICROSOFT_CLIENT_SECRET=你的 Microsoft Client Secret</span><br><span class="line">MICROSOFT_CLIENT_TENANT_ID=common</span><br><span class="line">MICROSOFT_REDIRECT_URI=http://localhost:3000/oauth/microsoft/login/callback</span><br></pre></td></tr></table></figure><p>如果你只允许某一个租户登录，把 <code>common</code> 改成你的实际 Tenant ID 即可。</p><p><strong>第 4 步：重启本地 Open WebUI</strong></p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">docker compose -f docker-compose.local.yaml up -d --build</span><br></pre></td></tr></table></figure><p><strong>注意事项</strong></p><ul><li>Microsoft 也支持本地 <code>localhost</code> 回调</li><li>本地开发可以先用 <code>common</code></li><li>正式环境建议单独使用正式域名和正式应用注册</li></ul></div><div class="tab-item-content" id="oauth_local_setup-3"><p><strong>适用场景</strong></p><p>如果你的用户本身大量使用 GitHub，这是最容易理解、也最容易测试的一项。</p><p><strong>第 1 步：去 GitHub Developer Settings 创建 OAuth App</strong></p><p>访问：</p><p><a href="https://github.com/settings/developers">https://github.com/settings/developers</a></p><p>进入后：</p><ol><li>打开 <code>OAuth Apps</code></li><li>点击 <code>New OAuth App</code></li></ol><p><strong>第 2 步：填写回调地址</strong></p><p>最关键的是 <code>Authorization callback URL</code>：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">http://localhost:3000/oauth/github/login/callback</span><br></pre></td></tr></table></figure><p><strong>第 3 步：把密钥填入 <code>.env.local</code></strong></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></pre></td><td class="code"><pre><span class="line">GITHUB_CLIENT_ID=你的 GitHub Client ID</span><br><span class="line">GITHUB_CLIENT_SECRET=你的 GitHub Client Secret</span><br><span class="line">GITHUB_CLIENT_REDIRECT_URI=http://localhost:3000/oauth/github/login/callback</span><br></pre></td></tr></table></figure><p><strong>第 4 步：重启本地 Open WebUI</strong></p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">docker compose -f docker-compose.local.yaml up -d --build</span><br></pre></td></tr></table></figure><p><strong>注意事项</strong></p><ul><li>GitHub 对 callback URL 也是精确匹配</li><li>如果后台填的是 <code>127.0.0.1</code>，本地配置也要统一成 <code>127.0.0.1</code></li><li>不要后台填 <code>127.0.0.1</code>，本地却写 <code>localhost</code></li></ul></div></div><div class="tab-to-top"><button type="button" aria-label="scroll to top"><i class="anzhiyufont anzhiyu-icon-arrow-up"></i></button></div></div><p>为了更容易排错，我建议不要一上来三个一起配，而是按这个顺序：</p><ol><li>先配一个，例如 GitHub</li><li>测通之后，再加 Google</li><li>最后再加 Microsoft</li></ol><p>这样如果出现问题，最容易定位。</p><p>最常见的报错就是：</p><ul><li><code>redirect_uri_mismatch</code></li><li>端口改了，但开放平台后台没改</li><li><code>.env.local</code> 改了，但容器没重启</li><li>后台写的是 <code>127.0.0.1</code>，本地写的是 <code>localhost</code></li></ul><h3 id="SCIM-自动开通与回收">SCIM 自动开通与回收</h3><p><strong>这是什么</strong></p><p>SCIM 不是登录协议，而是“账号生命周期同步协议”。</p><p>它解决的问题是：</p><ul><li>新员工入职时，自动在 Open WebUI 创建账号</li><li>员工信息变化时，自动同步更新</li><li>员工离职时，自动停用账号</li><li>用户组成员关系自动同步</li></ul><p>所以可以把它理解成“自动开账号、改账号、停账号”的标准接口。</p><p><strong>和 OAuth / LDAP 的关系</strong></p><ul><li>LDAP / OAuth / OIDC：解决“怎么登录”</li><li>SCIM：解决“账号怎么自动创建和同步”</li></ul><p>很多企业会同时使用：</p><ul><li>用 OIDC 登录</li><li>用 SCIM 做账号与组同步</li></ul><p><strong>当前版本配置</strong></p><figure class="highlight yaml"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><span class="line"><span class="attr">environment:</span></span><br><span class="line">  <span class="bullet">-</span> <span class="string">SCIM_ENABLED=true</span></span><br><span class="line">  <span class="bullet">-</span> <span class="string">SCIM_TOKEN=your-secure-random-token</span></span><br><span class="line">  <span class="bullet">-</span> <span class="string">SCIM_AUTH_PROVIDER=oidc</span></span><br></pre></td></tr></table></figure><p><strong>参数解释</strong></p><table><thead><tr><th>参数</th><th>说明</th></tr></thead><tbody><tr><td><code>SCIM_ENABLED</code></td><td>是否启用 SCIM</td></tr><tr><td><code>SCIM_TOKEN</code></td><td>调用 SCIM API 的 Bearer Token</td></tr><tr><td><code>SCIM_AUTH_PROVIDER</code></td><td>用于把 SCIM <code>externalId</code> 和对应认证提供商关联起来，例如 <code>microsoft</code>、<code>oidc</code></td></tr></tbody></table><p>这里的 <code>SCIM_AUTH_PROVIDER</code> 很容易被漏掉，但当前版本里它是重要配置，尤其是在账户关联和 <code>externalId</code> 保存上。</p><p><strong>对接端点</strong></p><p>SCIM Base URL：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">https://your-domain.com/api/v1/scim/v2/</span><br></pre></td></tr></table></figure><p>常见资源端点：</p><table><thead><tr><th>资源</th><th>端点</th></tr></thead><tbody><tr><td>用户</td><td><code>/api/v1/scim/v2/Users</code></td></tr><tr><td>组</td><td><code>/api/v1/scim/v2/Groups</code></td></tr></tbody></table><p><strong>我的管理方式</strong></p><p>我把 SCIM 视为“企业级增强项”：</p><ul><li>小规模自用：完全不需要</li><li>团队规模不大，但已有统一登录：先上 OAuth / LDAP，SCIM 可以以后再说</li><li>企业正式上生产：如果人事变动频繁、合规要求高，就值得上 SCIM</li></ul><p>它的价值不在“让用户多一个登录按钮”，而在于减少人工维护账号、降低离职账号遗留风险。</p><h3 id="会话、JWT-与-Cookie">会话、JWT 与 Cookie</h3><p><strong>这是什么</strong></p><p>用户在浏览器里登录后，Open WebUI 需要一种机制记住登录状态，这通常会涉及：</p><ul><li><strong>JWT</strong>：登录令牌</li><li><strong>Session Cookie</strong>：浏览器保存的登录凭证</li><li><strong>Secret Key</strong>：服务器用来签名和加密敏感数据的密钥</li></ul><p><strong>当前版本重点配置</strong></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><span class="line"><span class="attr">environment:</span></span><br><span class="line">  <span class="bullet">-</span> <span class="string">WEBUI_SECRET_KEY=your-persistent-secret-key</span></span><br><span class="line">  <span class="bullet">-</span> <span class="string">JWT_EXPIRES_IN=4w</span></span><br><span class="line">  <span class="bullet">-</span> <span class="string">WEBUI_SESSION_COOKIE_SAME_SITE=Lax</span></span><br><span class="line">  <span class="bullet">-</span> <span class="string">WEBUI_SESSION_COOKIE_SECURE=true</span></span><br></pre></td></tr></table></figure><p><strong>参数解释</strong></p><table><thead><tr><th>参数</th><th>说明</th></tr></thead><tbody><tr><td><code>WEBUI_SECRET_KEY</code></td><td>最关键的持久化密钥，用于会话签名和敏感数据解密</td></tr><tr><td><code>JWT_EXPIRES_IN</code></td><td>登录令牌过期时间，例如 <code>4w</code>、<code>7d</code></td></tr><tr><td><code>WEBUI_SESSION_COOKIE_SAME_SITE</code></td><td>Cookie 的跨站策略</td></tr><tr><td><code>WEBUI_SESSION_COOKIE_SECURE</code></td><td>是否只通过 HTTPS 发送 Cookie</td></tr></tbody></table><p><strong>为什么 <code>WEBUI_SECRET_KEY</code> 非常重要</strong></p><p>官网 FAQ 专门提到，如果你每次重建容器都不保留同一个 <code>WEBUI_SECRET_KEY</code>，会出现两类典型问题：</p><ul><li>用户升级或重启后被全部强制退出</li><li>一些已经加密保存的令牌、API Key、OAuth 凭证无法解密</li></ul><p>所以这个值一定要固定，不要让容器每次随机生成。</p><p><strong>我的管理方式</strong></p><p>这一点我会非常保守：</p><ol><li>生产环境固定设置 <code>WEBUI_SECRET_KEY</code></li><li>HTTPS 环境强制 <code>WEBUI_SESSION_COOKIE_SECURE=true</code></li><li>不把 <code>JWT_EXPIRES_IN</code> 设成无限期</li><li>升级容器前先确认密钥仍然通过同样方式注入</li></ol><p>这是最容易被忽略、但一出问题就会直接影响所有用户登录体验的一块。</p><hr></div>]]></content>
    
    
      
      
        
        
    <summary type="html">&lt;div id=&quot;postchat_postcontent&quot;&gt;&lt;h1&gt;第三章. 管理员完整配置指南&lt;/h1&gt;
&lt;p&gt;在上一章中，我们成功在本地部署了 Open</summary>
        
      
    
    
    
    <category term="一人公司系列" scheme="https://prorise666.site/categories/%E4%B8%80%E4%BA%BA%E5%85%AC%E5%8F%B8%E7%B3%BB%E5%88%97/"/>
    
    <category term="办公提效方向" scheme="https://prorise666.site/categories/%E4%B8%80%E4%BA%BA%E5%85%AC%E5%8F%B8%E7%B3%BB%E5%88%97/%E5%8A%9E%E5%85%AC%E6%8F%90%E6%95%88%E6%96%B9%E5%90%91/"/>
    
    <category term="OpenWebUi" scheme="https://prorise666.site/categories/%E4%B8%80%E4%BA%BA%E5%85%AC%E5%8F%B8%E7%B3%BB%E5%88%97/%E5%8A%9E%E5%85%AC%E6%8F%90%E6%95%88%E6%96%B9%E5%90%91/OpenWebUi/"/>
    
    
  </entry>
  
  <entry>
    <title>第二章. 本地快速部署</title>
    <link href="https://prorise666.site/posts/45485.html"/>
    <id>https://prorise666.site/posts/45485.html</id>
    <published>2026-02-26T04:38:17.000Z</published>
    <updated>2026-03-12T09:18:16.919Z</updated>
    
    <content type="html"><![CDATA[<div id="postchat_postcontent"><h1>第二章. 本地快速部署</h1><p>在上一章中，我们了解了 Open WebUI 是什么。现在让我们动手实践，在本地环境中把它跑起来。本章将专注于本地部署，帮助你在最短时间内体验到 Open WebUI 的强大功能。</p><p>在开始之前，让我们明确一个目标：本章结束时，你将拥有一个运行在本地的 Open WebUI 实例，可以通过浏览器访问，并能够与 AI 模型进行对话。</p><hr><h2 id="2-1-环境准备">2.1. 环境准备</h2><p>在部署 Open WebUI 之前，我们需要先准备好基础环境。根据你选择的部署方式不同，需要准备的环境也不同。</p><h3 id="Docker-环境准备">Docker 环境准备</h3><p>Docker 是我们强烈推荐的部署方式。如果你还没有安装 Docker，请按照以下步骤操作。</p><div class="tabs" id="docker-安装"><ul class="nav-tabs"><button type="button" class="tab  active" data-href="docker-安装-1">Windows 系统</button><button type="button" class="tab " data-href="docker-安装-2">macOS 系统</button><button type="button" class="tab " data-href="docker-安装-3">Linux 系统</button></ul><div class="tab-contents"><div class="tab-item-content active" id="docker-安装-1"><p><strong>步骤 1：下载 Docker Desktop</strong></p><p>访问 Docker 官网下载页面：<a href="https://www.docker.com/products/docker-desktop/">https://www.docker.com/products/docker-desktop/</a></p><p>点击 “Download for Windows” 按钮，下载安装包（约 500 MB）。</p><p><strong>步骤 2：安装 Docker Desktop</strong></p><p>双击下载的安装包，按照安装向导操作：</p><ul><li>勾选 “Use WSL 2 instead of Hyper-V”（推荐）</li><li>勾选 “Add shortcut to desktop”</li><li>点击 “Ok” 开始安装</li></ul><p>安装完成后，系统会提示重启电脑。重启后，Docker Desktop 会自动启动。</p><p><strong>步骤 3：验证安装</strong></p><p>打开 PowerShell 或命令提示符，输入以下命令：</p><figure class="highlight powershell"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">docker <span class="literal">--version</span></span><br></pre></td></tr></table></figure><p>如果看到类似 <code>Docker version 24.0.7, build afdd53b</code> 的输出，说明安装成功。</p><p><strong>步骤 4：配置 WSL 2（如果需要）</strong></p><p>如果安装过程中提示需要 WSL 2，请按照以下步骤操作：</p><p>打开 PowerShell（管理员模式），执行：</p><figure class="highlight powershell"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">wsl <span class="literal">--install</span></span><br></pre></td></tr></table></figure><p>等待安装完成后，重启电脑。</p></div><div class="tab-item-content" id="docker-安装-2"><p><strong>步骤 1：下载 Docker Desktop</strong></p><p>访问 Docker 官网下载页面：<a href="https://www.docker.com/products/docker-desktop/">https://www.docker.com/products/docker-desktop/</a></p><p>根据你的 Mac 芯片类型选择：</p><ul><li>Apple Silicon（M1/M2/M3）：下载 “Mac with Apple chip”</li><li>Intel 芯片：下载 “Mac with Intel chip”</li></ul><p><strong>步骤 2：安装 Docker Desktop</strong></p><p>双击下载的 <code>.dmg</code> 文件，将 Docker 图标拖动到 Applications 文件夹。</p><p>打开 Applications 文件夹，双击 Docker 图标启动。</p><p>首次启动时，系统会要求输入密码以授权 Docker 安装网络组件。</p><p><strong>步骤 3：验证安装</strong></p><p>打开终端（Terminal），输入以下命令：</p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">docker --version</span><br></pre></td></tr></table></figure><p>如果看到版本信息输出，说明安装成功。</p><p><strong>步骤 4：配置资源限制（可选）</strong></p><p>打开 Docker Desktop，点击右上角的设置图标，进入 “Resources” 选项卡：</p><ul><li>CPU：建议分配至少 4 核</li><li>Memory：建议分配至少 8 GB</li><li>Disk：建议分配至少 50 GB</li></ul></div><div class="tab-item-content" id="docker-安装-3"><p><strong>步骤 1：更新系统包</strong></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><span class="line"><span class="built_in">sudo</span> apt update</span><br><span class="line"><span class="built_in">sudo</span> apt upgrade -y</span><br></pre></td></tr></table></figure><p><strong>步骤 2：安装 Docker</strong></p><p>对于 Ubuntu/Debian 系统，执行以下命令：</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></pre></td><td class="code"><pre><span class="line"><span class="comment"># 安装必要的依赖</span></span><br><span class="line"><span class="built_in">sudo</span> apt install -y ca-certificates curl gnupg lsb-release</span><br><span class="line"></span><br><span class="line"><span class="comment"># 添加 Docker 官方 GPG 密钥</span></span><br><span class="line"><span class="built_in">sudo</span> <span class="built_in">mkdir</span> -p /etc/apt/keyrings</span><br><span class="line">curl -fsSL https://download.docker.com/linux/ubuntu/gpg | <span class="built_in">sudo</span> gpg --dearmor -o /etc/apt/keyrings/docker.gpg</span><br><span class="line"></span><br><span class="line"><span class="comment"># 添加 Docker 仓库</span></span><br><span class="line"><span class="built_in">echo</span> \</span><br><span class="line">  <span class="string">&quot;deb [arch=<span class="subst">$(dpkg --print-architecture)</span> signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu \</span></span><br><span class="line"><span class="string">  <span class="subst">$(lsb_release -cs)</span> stable&quot;</span> | <span class="built_in">sudo</span> <span class="built_in">tee</span> /etc/apt/sources.list.d/docker.list &gt; /dev/null</span><br><span class="line"></span><br><span class="line"><span class="comment"># 安装 Docker Engine</span></span><br><span class="line"><span class="built_in">sudo</span> apt update</span><br><span class="line"><span class="built_in">sudo</span> apt install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin</span><br></pre></td></tr></table></figure><p><strong>步骤 3：配置用户权限</strong></p><p>将当前用户添加到 docker 组，避免每次都需要 sudo：</p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line"><span class="built_in">sudo</span> usermod -aG docker <span class="variable">$USER</span></span><br></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><span class="line">newgrp docker</span><br></pre></td></tr></table></figure><p><strong>步骤 4：验证安装</strong></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><span class="line">docker --version</span><br><span class="line">docker compose version</span><br></pre></td></tr></table></figure><p>如果两个命令都能正常输出版本信息，说明安装成功。</p><p><strong>步骤 5：启动 Docker 服务</strong></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><span class="line"><span class="built_in">sudo</span> systemctl start docker</span><br><span class="line"><span class="built_in">sudo</span> systemctl <span class="built_in">enable</span> docker</span><br></pre></td></tr></table></figure></div></div><div class="tab-to-top"><button type="button" aria-label="scroll to top"><i class="anzhiyufont anzhiyu-icon-arrow-up"></i></button></div></div><h3 id="Python-环境准备（非-Docker-部署）">Python 环境准备（非 Docker 部署）</h3><p>如果你选择使用 Python 直接安装 Open WebUI，需要准备 Python 环境。</p><div class="tabs" id="python-安装"><ul class="nav-tabs"><button type="button" class="tab  active" data-href="python-安装-1">Windows 系统</button><button type="button" class="tab " data-href="python-安装-2">macOS 系统</button><button type="button" class="tab " data-href="python-安装-3">Linux 系统</button></ul><div class="tab-contents"><div class="tab-item-content active" id="python-安装-1"><p><strong>步骤 1：下载 Python</strong></p><p>访问 Python 官网：<a href="https://www.python.org/downloads/">https://www.python.org/downloads/</a></p><p>下载 Python 3.11 版本（推荐）。</p><p><strong>步骤 2：安装 Python</strong></p><p>双击安装包，<strong>务必勾选</strong> “Add Python to PATH” 选项，然后点击 “Install Now”。</p><p><strong>步骤 3：验证安装</strong></p><p>打开命令提示符，输入：</p><figure class="highlight powershell"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line">python <span class="literal">--version</span></span><br><span class="line">pip <span class="literal">--version</span></span><br></pre></td></tr></table></figure><p>如果看到版本信息，说明安装成功。</p><p><strong>步骤 4：安装 uv（推荐）</strong></p><figure class="highlight powershell"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">powershell <span class="literal">-ExecutionPolicy</span> ByPass <span class="literal">-c</span> <span class="string">&quot;irm https://astral.sh/uv/install.ps1 | iex&quot;</span></span><br></pre></td></tr></table></figure></div><div class="tab-item-content" id="python-安装-2"><p><strong>步骤 1：安装 Homebrew（如果还没有）</strong></p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">/bin/bash -c <span class="string">&quot;<span class="subst">$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)</span>&quot;</span></span><br></pre></td></tr></table></figure><p><strong>步骤 2：安装 Python</strong></p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">brew install python@3.11</span><br></pre></td></tr></table></figure><p><strong>步骤 3：验证安装</strong></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><span class="line">python3 --version</span><br><span class="line">pip3 --version</span><br></pre></td></tr></table></figure><p><strong>步骤 4：安装 uv（推荐）</strong></p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">curl -LsSf https://astral.sh/uv/install.sh | sh</span><br></pre></td></tr></table></figure></div><div class="tab-item-content" id="python-安装-3"><p><strong>步骤 1：安装 Python</strong></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><span class="line"><span class="built_in">sudo</span> apt update</span><br><span class="line"><span class="built_in">sudo</span> apt install -y python3.11 python3.11-venv python3-pip</span><br></pre></td></tr></table></figure><p><strong>步骤 2：验证安装</strong></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><span class="line">python3 --version</span><br><span class="line">pip3 --version</span><br></pre></td></tr></table></figure><p><strong>步骤 3：安装 uv（推荐）</strong></p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">curl -LsSf https://astral.sh/uv/install.sh | sh</span><br></pre></td></tr></table></figure></div></div><div class="tab-to-top"><button type="button" aria-label="scroll to top"><i class="anzhiyufont anzhiyu-icon-arrow-up"></i></button></div></div><hr><h2 id="2-2-使用-Docker-Compose-部署">2.2. 使用 Docker Compose 部署</h2><p>现在环境已经准备好了，让我们开始部署 Open WebUI。</p><p>我们直接使用 <strong>Docker Compose</strong> 进行部署——这是官方推荐的方式，也是最规范、最易维护的方案。所有配置集中在一个 YAML 文件中，启动、停止、更新都只需要一条命令。</p><blockquote><p>💡 <strong>为什么不用 <code>docker run</code>？</strong> 虽然一条 <code>docker run</code> 命令也能启动，但参数又长又容易出错，修改配置需要删除容器重建，多服务管理更是噩梦。Docker Compose 从一开始就解决了这些问题，没有理由绕弯路。</p></blockquote><h3 id="步骤-1：克隆官方仓库">步骤 1：克隆官方仓库</h3><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line">git <span class="built_in">clone</span> https://github.com/open-webui/open-webui.git</span><br><span class="line"><span class="built_in">cd</span> open-webui</span><br></pre></td></tr></table></figure><p>仓库中已经包含了官方的 <code>docker-compose.yaml</code>，默认配置了 Open WebUI + Ollama 两个服务。</p><blockquote><p>如果你的网络无法访问 GitHub，可以手动下载仓库的 ZIP 包，或者只创建一个目录并手动编写配置文件（见下方配置示例）。</p></blockquote><h3 id="步骤-2：根据场景选择配置">步骤 2：根据场景选择配置</h3><p>官方的 <code>docker-compose.yaml</code> 默认包含 Ollama 服务。但实际使用中，你可能不需要 Ollama（比如你只用 OpenAI API），或者你的 Ollama 已经安装在宿主机上。</p><p>我们推荐使用 <strong><code>docker-compose.override.yaml</code></strong> 或独立的配置文件来覆盖默认配置，这样官方文件保持不动，<code>git pull</code> 更新时不会产生冲突。</p><div class="tabs" id="docker-compose-配置方案"><ul class="nav-tabs"><button type="button" class="tab  active" data-href="docker-compose-配置方案-1">方案一：仅 Open WebUI（连接外部 API）</button><button type="button" class="tab " data-href="docker-compose-配置方案-2">方案二：Open WebUI + Ollama（本地模型）</button><button type="button" class="tab " data-href="docker-compose-配置方案-3">方案三：Open WebUI + 宿主机 Ollama</button><button type="button" class="tab " data-href="docker-compose-配置方案-4">方案四：Open WebUI + Ollama + GPU</button></ul><div class="tab-contents"><div class="tab-item-content active" id="docker-compose-配置方案-1"><p><strong>适用场景</strong>：不需要本地模型，只使用 OpenAI、Claude 等外部 API。</p><p>创建 <code>docker-compose.local.yaml</code> 文件：</p><figure class="highlight yaml"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br></pre></td><td class="code"><pre><span class="line"><span class="attr">services:</span></span><br><span class="line">  <span class="attr">open-webui:</span></span><br><span class="line">    <span class="attr">image:</span> <span class="string">ghcr.io/open-webui/open-webui:$&#123;WEBUI_DOCKER_TAG-main&#125;</span></span><br><span class="line">    <span class="attr">container_name:</span> <span class="string">open-webui</span></span><br><span class="line">    <span class="attr">restart:</span> <span class="string">unless-stopped</span></span><br><span class="line">    <span class="attr">ports:</span></span><br><span class="line">      <span class="bullet">-</span> <span class="string">$&#123;OPEN_WEBUI_PORT-3000&#125;:8080</span></span><br><span class="line">    <span class="attr">extra_hosts:</span></span><br><span class="line">      <span class="bullet">-</span> <span class="string">host.docker.internal:host-gateway</span></span><br><span class="line">    <span class="attr">environment:</span></span><br><span class="line">      <span class="comment"># 连接外部 OpenAI 兼容 API</span></span><br><span class="line">      <span class="bullet">-</span> <span class="string">OPENAI_API_BASE_URL=https://api.openai.com/v1</span></span><br><span class="line">      <span class="bullet">-</span> <span class="string">OPENAI_API_KEY=sk-your-api-key-here</span></span><br><span class="line">      <span class="bullet">-</span> <span class="string">ENABLE_OPENAI_API=True</span></span><br><span class="line">      <span class="comment"># 关闭 Ollama（不需要本地模型）</span></span><br><span class="line">      <span class="bullet">-</span> <span class="string">ENABLE_OLLAMA_API=False</span></span><br><span class="line">    <span class="attr">volumes:</span></span><br><span class="line">      <span class="bullet">-</span> <span class="string">open-webui-data:/app/backend/data</span></span><br><span class="line"></span><br><span class="line"><span class="attr">volumes:</span></span><br><span class="line">  <span class="attr">open-webui-data:</span></span><br></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><span class="line">docker compose -f docker-compose.local.yaml up -d</span><br></pre></td></tr></table></figure></div><div class="tab-item-content" id="docker-compose-配置方案-2"><p><strong>适用场景</strong>：想要运行开源模型（如 Qwen、Llama），完全离线使用。</p><p>直接使用官方的 <code>docker-compose.yaml</code> 即可，无需额外配置：</p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">docker compose up -d</span><br></pre></td></tr></table></figure><p>官方配置会同时启动 Ollama 和 Open WebUI，并自动建立连接。</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></pre></td><td class="code"><pre><span class="line"><span class="comment"># 在 Ollama 容器内下载模型</span></span><br><span class="line">docker compose <span class="built_in">exec</span> ollama ollama pull qwen2.5:7b</span><br></pre></td></tr></table></figure></div><div class="tab-item-content" id="docker-compose-配置方案-3"><p><strong>适用场景</strong>：Ollama 已经安装在宿主机上，不想在 Docker 中再跑一个。</p><p>创建 <code>docker-compose.local.yaml</code> 文件：</p><figure class="highlight yaml"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br></pre></td><td class="code"><pre><span class="line"><span class="attr">services:</span></span><br><span class="line">  <span class="attr">open-webui:</span></span><br><span class="line">    <span class="attr">image:</span> <span class="string">ghcr.io/open-webui/open-webui:$&#123;WEBUI_DOCKER_TAG-main&#125;</span></span><br><span class="line">    <span class="attr">container_name:</span> <span class="string">open-webui</span></span><br><span class="line">    <span class="attr">restart:</span> <span class="string">unless-stopped</span></span><br><span class="line">    <span class="attr">ports:</span></span><br><span class="line">      <span class="bullet">-</span> <span class="string">$&#123;OPEN_WEBUI_PORT-3000&#125;:8080</span></span><br><span class="line">    <span class="attr">extra_hosts:</span></span><br><span class="line">      <span class="bullet">-</span> <span class="string">host.docker.internal:host-gateway</span></span><br><span class="line">    <span class="attr">environment:</span></span><br><span class="line">      <span class="bullet">-</span> <span class="string">OLLAMA_BASE_URL=http://host.docker.internal:11434</span></span><br><span class="line">    <span class="attr">volumes:</span></span><br><span class="line">      <span class="bullet">-</span> <span class="string">open-webui-data:/app/backend/data</span></span><br><span class="line"></span><br><span class="line"><span class="attr">volumes:</span></span><br><span class="line">  <span class="attr">open-webui-data:</span></span><br></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><span class="line">docker compose -f docker-compose.local.yaml up -d</span><br></pre></td></tr></table></figure><blockquote><p><code>host.docker.internal</code> 是 Docker 提供的特殊域名，指向宿主机。通过 <code>extra_hosts</code> 配置后，容器内可以用这个域名访问宿主机上运行的 Ollama。</p></blockquote></div><div class="tab-item-content" id="docker-compose-配置方案-4"><p><strong>适用场景</strong>：有 NVIDIA GPU，想要加速本地模型推理。</p><p>创建 <code>docker-compose.local.yaml</code> 文件：</p><figure class="highlight yaml"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><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><span class="line"><span class="attr">services:</span></span><br><span class="line">  <span class="attr">ollama:</span></span><br><span class="line">    <span class="attr">image:</span> <span class="string">ollama/ollama:latest</span></span><br><span class="line">    <span class="attr">container_name:</span> <span class="string">ollama</span></span><br><span class="line">    <span class="attr">volumes:</span></span><br><span class="line">      <span class="bullet">-</span> <span class="string">ollama-data:/root/.ollama</span></span><br><span class="line">    <span class="attr">deploy:</span></span><br><span class="line">      <span class="attr">resources:</span></span><br><span class="line">        <span class="attr">reservations:</span></span><br><span class="line">          <span class="attr">devices:</span></span><br><span class="line">            <span class="bullet">-</span> <span class="attr">driver:</span> <span class="string">nvidia</span></span><br><span class="line">              <span class="attr">count:</span> <span class="string">all</span></span><br><span class="line">              <span class="attr">capabilities:</span> [<span class="string">gpu</span>]</span><br><span class="line">    <span class="attr">restart:</span> <span class="string">unless-stopped</span></span><br><span class="line"></span><br><span class="line">  <span class="attr">open-webui:</span></span><br><span class="line">    <span class="attr">image:</span> <span class="string">ghcr.io/open-webui/open-webui:$&#123;WEBUI_DOCKER_TAG-main&#125;</span></span><br><span class="line">    <span class="attr">container_name:</span> <span class="string">open-webui</span></span><br><span class="line">    <span class="attr">restart:</span> <span class="string">unless-stopped</span></span><br><span class="line">    <span class="attr">ports:</span></span><br><span class="line">      <span class="bullet">-</span> <span class="string">$&#123;OPEN_WEBUI_PORT-3000&#125;:8080</span></span><br><span class="line">    <span class="attr">environment:</span></span><br><span class="line">      <span class="bullet">-</span> <span class="string">OLLAMA_BASE_URL=http://ollama:11434</span></span><br><span class="line">    <span class="attr">depends_on:</span></span><br><span class="line">      <span class="bullet">-</span> <span class="string">ollama</span></span><br><span class="line">    <span class="attr">volumes:</span></span><br><span class="line">      <span class="bullet">-</span> <span class="string">open-webui-data:/app/backend/data</span></span><br><span class="line"></span><br><span class="line"><span class="attr">volumes:</span></span><br><span class="line">  <span class="attr">ollama-data:</span></span><br><span class="line">  <span class="attr">open-webui-data:</span></span><br></pre></td></tr></table></figure><p><strong>前置要求</strong>：需要安装 NVIDIA 驱动和 NVIDIA Container Toolkit。</p><p>安装 NVIDIA Container Toolkit（Ubuntu/Debian）：</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></pre></td><td class="code"><pre><span class="line">distribution=$(. /etc/os-release;<span class="built_in">echo</span> $ID<span class="variable">$VERSION_ID</span>)</span><br><span class="line">curl -s -L https://nvidia.github.io/nvidia-docker/gpgkey | <span class="built_in">sudo</span> apt-key add -</span><br><span class="line">curl -s -L https://nvidia.github.io/nvidia-docker/<span class="variable">$distribution</span>/nvidia-docker.list | \</span><br><span class="line">  <span class="built_in">sudo</span> <span class="built_in">tee</span> /etc/apt/sources.list.d/nvidia-docker.list</span><br><span class="line"></span><br><span class="line"><span class="built_in">sudo</span> apt-get update</span><br><span class="line"><span class="built_in">sudo</span> apt-get install -y nvidia-container-toolkit</span><br><span class="line"><span class="built_in">sudo</span> systemctl restart docker</span><br></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><span class="line">docker compose -f docker-compose.local.yaml up -d</span><br></pre></td></tr></table></figure></div></div><div class="tab-to-top"><button type="button" aria-label="scroll to top"><i class="anzhiyufont anzhiyu-icon-arrow-up"></i></button></div></div><h3 id="步骤-3：使用-env-管理变量（可选）">步骤 3：使用 .env 管理变量（可选）</h3><p>如果你不想把 API Key 等敏感信息写在 YAML 文件中，可以创建一个 <code>.env</code> 文件：</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></pre></td><td class="code"><pre><span class="line"><span class="comment"># .env 文件（与 docker-compose 文件同目录）</span></span><br><span class="line">WEBUI_DOCKER_TAG=main</span><br><span class="line">OPEN_WEBUI_PORT=3000</span><br><span class="line">OPENAI_API_KEY=sk-your-api-key-here</span><br></pre></td></tr></table></figure><p>然后在 <code>docker-compose.local.yaml</code> 中用 <code>$&#123;OPENAI_API_KEY&#125;</code> 引用即可。<code>.env</code> 文件通常已被 <code>.gitignore</code> 忽略，不会被提交到版本库。</p><h3 id="步骤-4：启动并验证">步骤 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><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><span class="line"><span class="comment"># 启动（根据你选择的方案）</span></span><br><span class="line">docker compose -f docker-compose.local.yaml up -d</span><br><span class="line"></span><br><span class="line"><span class="comment"># 查看容器状态</span></span><br><span class="line">docker compose -f docker-compose.local.yaml ps</span><br><span class="line"></span><br><span class="line"><span class="comment"># 查看日志（如果需要排查问题）</span></span><br><span class="line">docker compose -f docker-compose.local.yaml logs --<span class="built_in">tail</span> 50</span><br></pre></td></tr></table></figure><p>你应该看到类似以下输出：</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line">NAME           IMAGE                                COMMAND          STATUS                 PORTS</span><br><span class="line">open-webui     ghcr.io/open-webui/open-webui:main   &quot;bash start.sh&quot;  Up 2 minutes (healthy) 0.0.0.0:3000-&gt;8080/tcp</span><br></pre></td></tr></table></figure><p>状态为 <code>Up ... (healthy)</code> 表示服务已正常运行。</p><h3 id="Docker-Compose-常用命令速查">Docker Compose 常用命令速查</h3><p>以下命令均以 <code>-f docker-compose.local.yaml</code> 为例，如果你使用官方默认的 <code>docker-compose.yaml</code>，去掉 <code>-f</code> 参数即可。</p><table><thead><tr><th>命令</th><th>作用</th></tr></thead><tbody><tr><td><code>docker compose -f docker-compose.local.yaml up -d</code></td><td>启动所有服务</td></tr><tr><td><code>docker compose -f docker-compose.local.yaml down</code></td><td>停止并删除容器</td></tr><tr><td><code>docker compose -f docker-compose.local.yaml restart</code></td><td>重启服务</td></tr><tr><td><code>docker compose -f docker-compose.local.yaml ps</code></td><td>查看状态</td></tr><tr><td><code>docker compose -f docker-compose.local.yaml logs -f</code></td><td>实时查看日志</td></tr><tr><td><code>docker compose -f docker-compose.local.yaml pull</code></td><td>拉取最新镜像</td></tr><tr><td><code>docker compose -f docker-compose.local.yaml exec open-webui bash</code></td><td>进入容器</td></tr></tbody></table><h3 id="关于-Python-直接安装">关于 Python 直接安装</h3><p>如果你的环境无法使用 Docker（如某些受限的开发机），Open WebUI 也支持通过 Python 直接安装：</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></pre></td><td class="code"><pre><span class="line"><span class="comment"># 使用 uv（推荐）</span></span><br><span class="line">uvx --python 3.11 open-webui@latest serve</span><br><span class="line"></span><br><span class="line"><span class="comment"># 或使用 pip</span></span><br><span class="line">pip install open-webui</span><br><span class="line">open-webui serve</span><br></pre></td></tr></table></figure><p>Python 安装后访问 <a href="http://localhost:8080">http://localhost:8080</a> 。但这种方式环境依赖复杂、升级维护不便，<strong>仅建议用于临时体验或开发调试</strong>，不推荐用于长期使用。</p><hr><h2 id="2-3-首次启动与验证">2.3. 首次启动与验证</h2><p>部署完成后，我们需要系统地验证各项功能是否正常工作。</p><h3 id="第一步：确认容器状态">第一步：确认容器状态</h3><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">docker compose -f docker-compose.local.yaml ps</span><br></pre></td></tr></table></figure><p>关键信息解读：</p><table><thead><tr><th>字段</th><th>正常值</th><th>异常情况</th></tr></thead><tbody><tr><td><strong>STATUS</strong></td><td><code>Up ... (healthy)</code></td><td><code>Restarting</code> = 启动失败在反复重启；<code>unhealthy</code> = 健康检查未通过</td></tr><tr><td><strong>PORTS</strong></td><td><code>0.0.0.0:3000-&gt;8080/tcp</code></td><td>为空 = 端口映射失败</td></tr></tbody></table><p>如果状态不正常，查看日志定位问题：</p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">docker compose -f docker-compose.local.yaml logs --<span class="built_in">tail</span> 50</span><br></pre></td></tr></table></figure><h3 id="第二步：访问-Web-界面">第二步：访问 Web 界面</h3><p>打开浏览器，访问 <a href="http://localhost:3000">http://localhost:3000</a> 。</p><p>你会看到 Open WebUI 的欢迎页面。</p><p><strong>如果无法访问</strong>，按以下顺序排查：</p><ol><li><strong>确认容器正在运行</strong>：<code>docker compose -f docker-compose.local.yaml ps</code></li><li><strong>确认端口未被占用</strong>：<code>lsof -i :3000</code>（macOS/Linux）或 <code>netstat -ano | findstr :3000</code>（Windows）</li><li><strong>检查防火墙</strong>：确认没有阻止 3000 端口</li></ol><h3 id="第三步：创建管理员账号">第三步：创建管理员账号</h3><p>首次访问时，你会看到注册页面。<strong>第一个注册的用户自动成为管理员</strong>，拥有系统最高权限。</p><table><thead><tr><th>字段</th><th>说明</th><th>建议</th></tr></thead><tbody><tr><td><strong>姓名</strong></td><td>显示名称</td><td>填写你的名字或 “Admin”</td></tr><tr><td><strong>邮箱</strong></td><td>登录账号</td><td>使用常用邮箱，这是你的登录凭证</td></tr><tr><td><strong>密码</strong></td><td>登录密码</td><td>至少 8 位，包含字母、数字和特殊字符</td></tr></tbody></table><blockquote><p>⚠️ <strong>重要</strong>：请务必牢记管理员的邮箱和密码。忘记密码需要手动操作数据库才能重置。</p></blockquote><p>注册完成后，系统自动登录并进入主界面。</p><h3 id="第四步：连接-AI-模型">第四步：连接 AI 模型</h3><p>根据你的部署方案，模型连接方式不同：</p><p><strong>如果你使用了 Ollama</strong>：</p><p>模型选择器中应该已经显示了你下载的模型。如果没有，进入 <strong>管理员面板</strong> → <strong>设置</strong> → <strong>连接</strong>，确认 Ollama API URL 配置正确，点击刷新。</p><p><strong>如果你只部署了 Open WebUI（方案一）</strong>：</p><p>你需要在配置文件中填写正确的 API 地址和密钥（已在 2.2 节的配置中完成），或者在 <strong>管理员面板</strong> → <strong>设置</strong> → <strong>连接</strong> 中手动配置：</p><ol><li>在 <strong>OpenAI API</strong> 区域填入 API Base URL 和 API Key</li><li>点击保存，然后点击刷新按钮</li><li>连接成功后，模型选择器中会出现可用模型</li></ol><h3 id="第六步：发送测试消息">第六步：发送测试消息</h3><p>选择一个模型，在输入框中输入：</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">你好，请用一句话介绍你自己</span><br></pre></td></tr></table></figure><p>如果 AI 正常回复，恭喜你，Open WebUI 已经部署成功！🎉</p><h3 id="常见启动问题排查">常见启动问题排查</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><span class="line">docker compose -f docker-compose.local.yaml logs --<span class="built_in">tail</span> 100</span><br></pre></td></tr></table></figure><h4 id="问题-1：HuggingFace-连接失败（SSL-重连错误）">问题 1：HuggingFace 连接失败（SSL 重连错误）</h4><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></pre></td><td class="code"><pre><span class="line">requests.exceptions.SSLError: HTTPSConnectionPool(host=&#x27;huggingface.co&#x27;, port=443):</span><br><span class="line">Max retries exceeded ... certificate verify failed</span><br><span class="line">Retrying in 1s [Retry 1/5].</span><br><span class="line">Retrying in 2s [Retry 2/5].</span><br><span class="line">...</span><br></pre></td></tr></table></figure><p><strong>原因</strong>：Open WebUI 启动时会从 HuggingFace 下载 RAG 嵌入模型（<code>sentence-transformers/all-MiniLM-L6-v2</code>），网络无法访问 <code>huggingface.co</code> 时会反复重试。</p><p><strong>影响</strong>：仅影响 RAG 文档问答功能，<strong>不影响</strong> 正常对话。服务最终会跳过下载并正常启动。</p><p><strong>解决方案</strong>：</p><table><thead><tr><th>方案</th><th>操作</th></tr></thead><tbody><tr><td>使用镜像站</td><td>添加环境变量 <code>HF_ENDPOINT=https://hf-mirror.com</code></td></tr><tr><td>配置代理</td><td>添加 <code>HTTP_PROXY</code> 和 <code>HTTPS_PROXY</code> 环境变量</td></tr><tr><td>使用 OpenAI 嵌入</td><td>添加 <code>RAG_EMBEDDING_ENGINE=openai</code></td></tr></tbody></table><h4 id="问题-2：端口被占用">问题 2：端口被占用</h4><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">Error: Bind for 0.0.0.0:3000 failed: port is already allocated</span><br></pre></td></tr></table></figure><p><strong>解决方案</strong>：修改配置文件中的端口映射，或在 <code>.env</code> 中设置 <code>OPEN_WEBUI_PORT=3001</code>。</p><h4 id="问题-3：Ollama-连接失败">问题 3：Ollama 连接失败</h4><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">WARNING: Ollama connection failed: Connection refused</span><br></pre></td></tr></table></figure><p><strong>解决方案</strong>：</p><ul><li>不需要 Ollama：添加环境变量 <code>ENABLE_OLLAMA_API=False</code></li><li>Ollama 在宿主机：确认 Ollama 服务已启动，且配置了 <code>extra_hosts</code> 和正确的 <code>OLLAMA_BASE_URL</code></li></ul><h3 id="快速排查清单">快速排查清单</h3><table><thead><tr><th>检查项</th><th>命令/操作</th><th>期望结果</th></tr></thead><tbody><tr><td>容器是否运行</td><td><code>docker compose ps</code></td><td>状态为 <code>Up (healthy)</code></td></tr><tr><td>端口是否监听</td><td><code>curl -s http://localhost:3000</code></td><td>返回 HTML 内容</td></tr><tr><td>日志是否有错误</td><td><code>docker compose logs --tail 50</code></td><td>无 ERROR 级别日志</td></tr><tr><td>数据卷是否创建</td><td><code>docker volume ls</code></td><td>能看到对应的卷名</td></tr><tr><td>API 连接是否正常</td><td>管理员面板 → 设置 → 连接</td><td>刷新后显示模型列表</td></tr><tr><td>对话是否正常</td><td>发送测试消息</td><td>AI 正常回复</td></tr></tbody></table><hr><h2 id="2-4-数据持久化与备份">2.4. 数据持久化与备份</h2><p>数据持久化是部署中非常重要的一环。如果配置不当，容器重启后所有数据都会丢失。</p><h3 id="Docker-卷的工作原理">Docker 卷的工作原理</h3><p>在前面的部署命令中，我们使用了 <code>-v open-webui:/app/backend/data</code> 参数。这个参数创建了一个名为 <code>open-webui</code> 的 Docker 卷，并将其挂载到容器内的 <code>/app/backend/data</code> 目录。</p><p>Open WebUI 的所有数据都存储在这个目录中，包括：</p><table><thead><tr><th>数据类型</th><th>存储位置</th><th>说明</th></tr></thead><tbody><tr><td><strong>SQLite 数据库</strong></td><td><code>/app/backend/data/webui.db</code></td><td>用户账号、对话记录、系统配置等核心数据</td></tr><tr><td><strong>上传文件</strong></td><td><code>/app/backend/data/uploads/</code></td><td>用户上传的文档、图片等文件</td></tr><tr><td><strong>RAG 向量数据</strong></td><td><code>/app/backend/data/vector_db/</code></td><td>文档问答功能的向量索引</td></tr><tr><td><strong>缓存数据</strong></td><td><code>/app/backend/data/cache/</code></td><td>模型缓存、临时文件等</td></tr><tr><td><strong>密钥文件</strong></td><td><code>/app/backend/data/.webui_secret_key</code></td><td>自动生成的会话加密密钥</td></tr></tbody></table><h3 id="查看-Docker-卷">查看 Docker 卷</h3><p>你可以通过以下命令查看已创建的 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><span class="line"><span class="comment"># 列出所有卷</span></span><br><span class="line">docker volume <span class="built_in">ls</span></span><br><span class="line"></span><br><span class="line"><span class="comment"># 查看卷的详细信息（包括在宿主机上的实际存储路径）</span></span><br><span class="line">docker volume inspect open-webui</span><br></pre></td></tr></table></figure><h3 id="备份数据">备份数据</h3><p>定期备份数据是一个好习惯。以下是几种备份方式：</p><p><strong>方法 1：直接复制卷数据</strong></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></pre></td><td class="code"><pre><span class="line"><span class="comment"># 创建备份目录</span></span><br><span class="line"><span class="built_in">mkdir</span> -p ~/open-webui-backup</span><br><span class="line"></span><br><span class="line"><span class="comment"># 使用临时容器将卷数据复制出来</span></span><br><span class="line">docker run --<span class="built_in">rm</span> \</span><br><span class="line">  -v open-webui:/data \</span><br><span class="line">  -v ~/open-webui-backup:/backup \</span><br><span class="line">  alpine tar czf /backup/open-webui-backup-$(<span class="built_in">date</span> +%Y%m%d).tar.gz -C /data .</span><br></pre></td></tr></table></figure><p><strong>方法 2：使用绑定挂载代替命名卷</strong></p><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></pre></td><td class="code"><pre><span class="line"><span class="attr">services:</span></span><br><span class="line">  <span class="attr">open-webui:</span></span><br><span class="line">    <span class="attr">volumes:</span></span><br><span class="line">      <span class="bullet">-</span> <span class="string">./data:/app/backend/data</span>    <span class="comment"># 数据存储在当前目录的 data 文件夹中</span></span><br></pre></td></tr></table></figure><p>这样你可以直接对 <code>./data</code> 目录进行备份，无需通过 Docker 命令。</p><p><strong>方法 3：导出数据库</strong></p><p>Open WebUI 使用 SQLite 数据库，你可以直接复制数据库文件：</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><span class="line"><span class="comment"># 从容器中复制数据库文件</span></span><br><span class="line">docker <span class="built_in">cp</span> open-webui:/app/backend/data/webui.db ~/open-webui-backup/webui.db</span><br></pre></td></tr></table></figure><h3 id="恢复数据">恢复数据</h3><p><strong>从备份恢复</strong>：</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></pre></td><td class="code"><pre><span class="line"><span class="comment"># 停止容器</span></span><br><span class="line">docker compose down</span><br><span class="line"></span><br><span class="line"><span class="comment"># 恢复备份数据到卷</span></span><br><span class="line">docker run --<span class="built_in">rm</span> \</span><br><span class="line">  -v open-webui:/data \</span><br><span class="line">  -v ~/open-webui-backup:/backup \</span><br><span class="line">  alpine sh -c <span class="string">&quot;cd /data &amp;&amp; tar xzf /backup/open-webui-backup-20260210.tar.gz&quot;</span></span><br><span class="line"></span><br><span class="line"><span class="comment"># 重新启动</span></span><br><span class="line">docker compose up -d</span><br></pre></td></tr></table></figure><h3 id="数据迁移">数据迁移</h3><p>如果你需要将 Open WebUI 迁移到另一台服务器：</p><ol><li>在旧服务器上备份数据（使用上述备份方法）</li><li>将备份文件传输到新服务器</li><li>在新服务器上创建 Docker 卷并恢复数据</li><li>使用相同的 Docker Compose 配置启动服务</li></ol><hr><h2 id="2-5-本章小结">2.5. 本章小结</h2><p>在本章中，我们完成了 Open WebUI 的本地部署，从环境准备到首次验证，走通了完整流程。</p><table><thead><tr><th>核心要点</th><th>关键内容</th></tr></thead><tbody><tr><td><strong>环境准备</strong></td><td>Docker（推荐）或 Python 3.11+，根据操作系统选择安装方式</td></tr><tr><td><strong>部署方式</strong></td><td>Docker Compose 部署（推荐），根据场景选择配置方案</td></tr><tr><td><strong>数据持久化</strong></td><td>通过 Docker 卷或绑定挂载保存数据，定期备份 <code>webui.db</code></td></tr><tr><td><strong>首次验证</strong></td><td>检查容器状态 → 访问页面 → 创建管理员 → 连接模型 → 测试对话</td></tr><tr><td><strong>常见问题</strong></td><td>HuggingFace 网络问题、端口占用、Ollama 连接失败</td></tr></tbody></table><p>现在你已经拥有了一个运行在本地的 Open WebUI 实例。在下一章中，我们将深入配置，包括连接多个 AI 模型、自定义界面、管理用户权限等进阶操作。</p><hr></div>]]></content>
    
    
      
      
        
        
    <summary type="html">&lt;div id=&quot;postchat_postcontent&quot;&gt;&lt;h1&gt;第二章. 本地快速部署&lt;/h1&gt;
&lt;p&gt;在上一章中，我们了解了 Open WebUI 是什么。现在让我们动手实践，在本地环境中把它跑起来。本章将专注于本地部署，帮助你在最短时间内体验到 Open WebUI</summary>
        
      
    
    
    
    <category term="一人公司系列" scheme="https://prorise666.site/categories/%E4%B8%80%E4%BA%BA%E5%85%AC%E5%8F%B8%E7%B3%BB%E5%88%97/"/>
    
    <category term="办公提效方向" scheme="https://prorise666.site/categories/%E4%B8%80%E4%BA%BA%E5%85%AC%E5%8F%B8%E7%B3%BB%E5%88%97/%E5%8A%9E%E5%85%AC%E6%8F%90%E6%95%88%E6%96%B9%E5%90%91/"/>
    
    <category term="OpenWebUi" scheme="https://prorise666.site/categories/%E4%B8%80%E4%BA%BA%E5%85%AC%E5%8F%B8%E7%B3%BB%E5%88%97/%E5%8A%9E%E5%85%AC%E6%8F%90%E6%95%88%E6%96%B9%E5%90%91/OpenWebUi/"/>
    
    
  </entry>
  
  <entry>
    <title>第一章. Open WebUI 快速认知</title>
    <link href="https://prorise666.site/posts/56629.html"/>
    <id>https://prorise666.site/posts/56629.html</id>
    <published>2026-02-26T03:38:17.000Z</published>
    <updated>2026-03-12T09:18:16.915Z</updated>
    
    <content type="html"><![CDATA[<div id="postchat_postcontent"><h1>第一章. Open WebUI 快速认知</h1><p>在开始之前，让我们先确认一个问题：你是否曾经想过，能不能在自己的电脑或服务器上搭建一个类似 ChatGPT 的对话界面，既能保护数据隐私，又能自由选择各种 AI 模型？如果你的答案是&quot;是&quot;，那么 Open WebUI 正是为此而生的工具。</p><p><img src="https://prorise-blog.oss-cn-guangzhou.aliyuncs.com/cover/image-20260210154130054.png" alt="image-20260210154130054"></p><hr><h2 id="1-1-Open-WebUI-是什么">1.1. Open WebUI 是什么</h2><h3 id="从一个真实场景说起">从一个真实场景说起</h3><p>想象这样一个场景：你是一家公司的技术负责人，团队需要使用 AI 辅助工作，但你面临几个棘手的问题：</p><ul><li>使用 ChatGPT 等商业服务，敏感数据会上传到第三方服务器</li><li>每个月的 API 费用随着团队规模增长而飙升</li><li>不同成员需要使用不同的 AI 模型，但切换起来很麻烦</li><li>想要基于公司内部文档进行问答，但商业服务无法直接接入</li></ul><p>这些问题，正是 Open WebUI 要解决的核心痛点。</p><h3 id="核心定位">核心定位</h3><p>Open WebUI 是一个<strong>自托管的 AI 对话平台</strong>。让我们拆解这个定义：</p><p><strong>自托管</strong>意味着整个系统运行在你自己的服务器或电脑上，所有数据都在你的控制范围内，不会被发送到任何第三方服务器。这就像你在自己家里搭建了一个私人图书馆，而不是去公共图书馆借书。</p><p><strong>AI 对话平台</strong>说明它提供了一个完整的交互界面，让你可以像使用 ChatGPT 一样，通过网页与各种 AI 模型进行对话。但与 ChatGPT 不同的是，你可以自由选择背后使用哪个模型。</p><h3 id="与商业产品的核心区别">与商业产品的核心区别</h3><p>现在思考一个问题：Open WebUI 和 ChatGPT、Claude 这些商业产品有什么本质区别？</p><table><thead><tr><th>对比维度</th><th>ChatGPT/Claude（商业产品）</th><th>Open WebUI（自托管平台）</th></tr></thead><tbody><tr><td><strong>控制权</strong></td><td>你是&quot;租户&quot;，使用别人的服务</td><td>你是&quot;房东&quot;，系统运行在你的环境</td></tr><tr><td><strong>模型选择</strong></td><td>模型固定，无法更换</td><td>可同时接入多个模型，自由切换</td></tr><tr><td><strong>数据隐私</strong></td><td>数据上传到服务商服务器</td><td>所有数据保存在本地，完全隐私</td></tr><tr><td><strong>费用模式</strong></td><td>按月付费或按使用量付费</td><td>软件免费开源，仅需服务器成本</td></tr><tr><td><strong>功能定制</strong></td><td>功能由服务商决定</td><td>可根据需求深度定制</td></tr><tr><td><strong>离线使用</strong></td><td>必须联网才能使用</td><td>可完全离线运行（使用本地模型）</td></tr></tbody></table><p>用一个更直观的比喻：ChatGPT 就像租房住，Open WebUI 就像自己买房装修。租房省事但受限制，买房麻烦但自由度高。</p><h3 id="核心功能一览">核心功能一览</h3><p>Open WebUI 不仅仅是一个简单的对话界面，它提供了一整套企业级的 AI 应用能力：</p><table><thead><tr><th>功能模块</th><th>核心能力</th><th>典型应用场景</th></tr></thead><tbody><tr><td><strong>多模型支持</strong></td><td>同时连接 Ollama、OpenAI、Claude、Gemini 等多个模型</td><td>对比不同模型的回答质量，选择最适合的模型</td></tr><tr><td><strong>RAG 文档问答</strong></td><td>上传文档，让 AI 基于文档内容回答问题</td><td>基于公司产品手册、技术文档进行智能问答</td></tr><tr><td><strong>多用户协作</strong></td><td>创建多个用户账号，设置不同权限</td><td>团队共享 AI 资源，管理员控制访问权限</td></tr><tr><td><strong>图像生成</strong></td><td>集成 DALL-E、ComfyUI 等图像生成工具</td><td>根据文字描述生成图片，辅助设计工作</td></tr><tr><td><strong>图像分析</strong></td><td>支持多模态模型，上传图片进行分析</td><td>分析图表、识别物体、提取图片中的文字</td></tr><tr><td><strong>语音交互</strong></td><td>语音输入和语音输出</td><td>解放双手，通过语音与 AI 对话</td></tr><tr><td><strong>网络搜索</strong></td><td>集成搜索引擎，获取最新信息</td><td>让 AI 回答时参考最新的网络资料</td></tr><tr><td><strong>Pipelines 插件</strong></td><td>通过 Python 代码扩展功能</td><td>添加自定义功能，如内容过滤、数据处理</td></tr></tbody></table><h3 id="适用场景分析">适用场景分析</h3><p>让我们看看 Open WebUI 在不同场景下的应用：</p><p><strong>个人使用场景</strong>：</p><ul><li>你想在自己的电脑上运行开源 AI 模型（如 Llama、Qwen），但命令行界面太不友好</li><li>你有 OpenAI API 密钥，想要一个更灵活的界面来使用</li><li>你想保护隐私，不希望对话内容被第三方服务商看到</li><li>你想基于个人笔记、学习资料进行 AI 问答</li></ul><p><strong>团队使用场景</strong>：</p><ul><li>小团队（5-20 人）需要共享 AI 资源，但不想每人都购买商业服务</li><li>需要基于团队内部文档（项目文档、会议记录）进行智能问答</li><li>需要控制不同成员的访问权限，避免敏感信息泄露</li><li>需要统一管理 API 密钥，避免每个人单独配置</li></ul><p><strong>企业使用场景</strong>：</p><ul><li>需要部署在内网环境，确保数据不出公司网络</li><li>需要接入多个 AI 模型，根据不同任务选择最合适的模型</li><li>需要与企业现有系统集成（如 LDAP 认证、企业微信）</li><li>需要详细的使用日志和审计功能</li></ul><hr><h2 id="1-2-部署方式选择">1.2. 部署方式选择</h2><p>Open WebUI 提供了多种部署方式，我们需要根据实际需求选择最合适的方案。</p><h3 id="三种主要部署方式">三种主要部署方式</h3><table><thead><tr><th>部署方式</th><th>适用场景</th><th>优势</th><th>劣势</th><th>推荐指数</th></tr></thead><tbody><tr><td><strong>Docker 部署</strong></td><td>个人、团队、企业</td><td>一键启动，环境隔离，易于维护</td><td>需要学习 Docker 基础</td><td>⭐⭐⭐⭐⭐</td></tr><tr><td><strong>Python 安装</strong></td><td>个人快速体验</td><td>安装简单，无需 Docker</td><td>环境依赖复杂，难以维护</td><td>⭐⭐⭐</td></tr><tr><td><strong>Kubernetes 部署</strong></td><td>大型企业</td><td>高可用，自动扩缩容</td><td>配置复杂，需要 K8s 知识</td><td>⭐⭐⭐⭐</td></tr></tbody></table><h3 id="Docker-部署（本教程重点）">Docker 部署（本教程重点）</h3><p>我们强烈推荐使用 Docker 部署，原因如下：</p><p><strong>环境隔离</strong>：Docker 将 Open WebUI 及其所有依赖打包在一个容器中，不会污染你的系统环境。即使出现问题，删除容器重新启动即可，不会影响其他软件。</p><p><strong>一键启动</strong>：只需要一条命令，就能启动完整的 Open WebUI 服务，无需手动安装 Python、配置数据库等繁琐步骤。</p><p><strong>易于维护</strong>：更新版本时，只需要拉取新的镜像，重新启动容器即可。数据通过 Docker 卷持久化，不会因为容器重建而丢失。</p><p><strong>跨平台支持</strong>：Docker 在 Windows、macOS、Linux 上都能运行，部署步骤完全一致。</p><h3 id="Python-安装">Python 安装</h3><p>如果你不想使用 Docker，也可以通过 Python 直接安装。这种方式适合快速体验，但不推荐用于生产环境。</p><p>Python 安装有两种方式：</p><p><strong>使用 uv（推荐）</strong>：uv 是一个现代化的 Python 运行时管理器，能自动处理环境依赖。</p><p><strong>使用 pip</strong>：传统的 Python 包管理器，需要手动管理虚拟环境。</p><h3 id="Kubernetes-部署">Kubernetes 部署</h3><p>如果你的企业已经有 Kubernetes 集群，可以使用 Helm Chart 或 Kustomize 部署 Open WebUI。这种方式适合大规模部署，支持高可用和自动扩缩容。</p><p>Kubernetes 部署的优势：</p><ul><li>多副本部署，单个实例故障不影响服务</li><li>自动负载均衡，分散用户请求</li><li>与企业现有的监控、日志系统集成</li></ul><p>但 Kubernetes 部署的门槛较高，需要对 K8s 有一定了解。如果你的团队规模不大（少于 50 人），使用 Docker Compose 部署就足够了。</p><hr><h2 id="1-3-硬件与环境要求">1.3. 硬件与环境要求</h2><p>在开始部署之前，我们需要确认硬件和环境是否满足要求。</p><h3 id="不同场景的配置要求">不同场景的配置要求</h3><table><thead><tr><th>使用场景</th><th>CPU</th><th>内存</th><th>硬盘</th><th>GPU</th><th>说明</th></tr></thead><tbody><tr><td><strong>仅使用 API</strong></td><td>2 核</td><td>2 GB</td><td>10 GB</td><td>不需要</td><td>只连接 OpenAI 等 API，不运行本地模型</td></tr><tr><td><strong>小型本地模型</strong></td><td>4 核</td><td>8 GB</td><td>50 GB</td><td>不需要</td><td>运行 1B-7B 参数的模型（如 Qwen2.5-7B）</td></tr><tr><td><strong>中型本地模型</strong></td><td>8 核</td><td>16 GB</td><td>100 GB</td><td>推荐</td><td>运行 7B-14B 参数的模型</td></tr><tr><td><strong>大型本地模型</strong></td><td>16 核</td><td>32 GB</td><td>200 GB</td><td>必需</td><td>运行 30B+ 参数的模型，需要 GPU 加速</td></tr><tr><td><strong>团队使用</strong></td><td>8 核</td><td>16 GB</td><td>200 GB</td><td>可选</td><td>10-50 人团队，主要使用 API</td></tr><tr><td><strong>企业使用</strong></td><td>16 核+</td><td>32 GB+</td><td>500 GB+</td><td>推荐</td><td>50+ 人，需要高可用部署</td></tr></tbody></table><h3 id="关键说明">关键说明</h3><p><strong>仅使用 API 的场景</strong>：如果你只打算连接 OpenAI、Claude 等商业 API，不运行本地模型，那么硬件要求非常低。一台普通的云服务器（2 核 2 GB）就足够了。</p><p><strong>运行本地模型的场景</strong>：如果你想使用 Ollama 运行开源模型，硬件要求会显著提高。模型参数越大，需要的内存和 GPU 显存越多。</p><p><strong>GPU 的作用</strong>：GPU 主要用于加速本地模型的推理速度。如果没有 GPU，模型仍然可以运行，但速度会慢很多。对于 7B 以下的小模型，CPU 推理勉强可用；对于更大的模型，强烈建议使用 GPU。</p><h3 id="操作系统要求">操作系统要求</h3><p>Open WebUI 支持以下操作系统：</p><table><thead><tr><th>操作系统</th><th>Docker 支持</th><th>Python 安装支持</th><th>推荐程度</th></tr></thead><tbody><tr><td><strong>Ubuntu 20.04+</strong></td><td>✅</td><td>✅</td><td>⭐⭐⭐⭐⭐</td></tr><tr><td><strong>Debian 11+</strong></td><td>✅</td><td>✅</td><td>⭐⭐⭐⭐⭐</td></tr><tr><td><strong>CentOS 7+</strong></td><td>✅</td><td>✅</td><td>⭐⭐⭐⭐</td></tr><tr><td><strong>macOS 12+</strong></td><td>✅</td><td>✅</td><td>⭐⭐⭐⭐</td></tr><tr><td><strong>Windows 10+</strong></td><td>✅</td><td>✅</td><td>⭐⭐⭐</td></tr></tbody></table><p><strong>Linux 系统</strong> 是最推荐的选择，特别是 Ubuntu 和 Debian。这些系统在服务器环境中最常见，Docker 支持也最完善。</p><p><strong>macOS 系统</strong> 适合个人开发和测试，特别是 Apple Silicon（M1/M2/M3）芯片的 Mac，可以高效运行本地模型。</p><p><strong>Windows 系统</strong> 也可以使用，但需要安装 WSL2（Windows Subsystem for Linux）来运行 Docker。Windows 原生的 Docker Desktop 在某些场景下可能会遇到网络和文件系统的兼容性问题。</p><h3 id="网络环境要求">网络环境要求</h3><p><strong>基础网络要求</strong>：</p><ul><li>如果只使用本地模型，Open WebUI 可以完全离线运行</li><li>如果使用 OpenAI 等 API，需要能访问对应的 API 地址</li><li>如果使用网络搜索功能，需要能访问搜索引擎</li></ul><p><strong>特殊功能的网络要求</strong>：</p><ul><li>语音功能（STT/TTS）需要 HTTPS 连接，浏览器才会允许访问麦克风</li><li>拉取 Open WebUI 镜像需要能访问 <code>ghcr.io</code>（GitHub Container Registry，非 Docker Hub）</li><li>从 Ollama 下载模型需要能访问 <code>ollama.com</code></li><li>RAG 文档问答功能首次启动时会从 <code>huggingface.co</code> 下载嵌入模型（<code>sentence-transformers/all-MiniLM-L6-v2</code>），如果网络无法访问 HuggingFace，启动日志会出现 SSL 重连错误，但不影响其他功能的正常使用</li></ul><p>如果你的网络环境受限（如企业内网、代理环境），可以考虑：</p><ul><li>使用代理服务器（注意 Docker 容器内的代理配置与宿主机独立）</li><li>提前下载好 Docker 镜像和 Ollama 模型</li><li>配置镜像加速器</li><li>对于 HuggingFace 访问问题，可以提前下载嵌入模型到本地，或配置 <code>HF_ENDPOINT</code> 环境变量使用镜像站</li></ul><hr><h2 id="1-4-本章小结">1.4. 本章小结</h2><p>在本章中，我们完成了对 Open WebUI 的初步认知，明确了它的定位、能力和适用场景。</p><table><thead><tr><th>核心要点</th><th>关键内容</th></tr></thead><tbody><tr><td><strong>项目定位</strong></td><td>自托管的 AI 对话平台，数据隐私可控，模型自由选择</td></tr><tr><td><strong>核心优势</strong></td><td>多模型支持、RAG 文档问答、多用户协作、完全开源</td></tr><tr><td><strong>部署方式</strong></td><td>Docker 部署（推荐）、Python 安装、Kubernetes 部署</td></tr><tr><td><strong>硬件要求</strong></td><td>仅用 API 需 2 核 2 GB，运行本地模型需 8 GB+ 内存</td></tr></tbody></table><p>现在你应该已经理解了 Open WebUI 是什么，以及它能为你解决哪些问题。在下一章中，我们将进入实战环节，从本地快速部署开始，一步步搭建起你自己的 AI 对话平台。</p><hr></div>]]></content>
    
    
      
      
        
        
    <summary type="html">&lt;div id=&quot;postchat_postcontent&quot;&gt;&lt;h1&gt;第一章. Open WebUI 快速认知&lt;/h1&gt;
&lt;p&gt;在开始之前，让我们先确认一个问题：你是否曾经想过，能不能在自己的电脑或服务器上搭建一个类似 ChatGPT</summary>
        
      
    
    
    
    <category term="一人公司系列" scheme="https://prorise666.site/categories/%E4%B8%80%E4%BA%BA%E5%85%AC%E5%8F%B8%E7%B3%BB%E5%88%97/"/>
    
    <category term="办公提效方向" scheme="https://prorise666.site/categories/%E4%B8%80%E4%BA%BA%E5%85%AC%E5%8F%B8%E7%B3%BB%E5%88%97/%E5%8A%9E%E5%85%AC%E6%8F%90%E6%95%88%E6%96%B9%E5%90%91/"/>
    
    <category term="OpenWebUi" scheme="https://prorise666.site/categories/%E4%B8%80%E4%BA%BA%E5%85%AC%E5%8F%B8%E7%B3%BB%E5%88%97/%E5%8A%9E%E5%85%AC%E6%8F%90%E6%95%88%E6%96%B9%E5%90%91/OpenWebUi/"/>
    
    
  </entry>
  
  <entry>
    <title>登录注册番外篇（二） - Sa-Token：权限认证完全指南</title>
    <link href="https://prorise666.site/posts/62352.html"/>
    <id>https://prorise666.site/posts/62352.html</id>
    <published>2026-02-08T07:38:17.000Z</published>
    <updated>2026-03-12T09:18:16.959Z</updated>
    
    <content type="html"><![CDATA[<div id="postchat_postcontent"><h1>第一章. 全局侦听器——订阅框架的关键事件</h1><p><strong>阶段式学习路径</strong></p><p>前三篇笔记解决的都是&quot;如何控制访问&quot;的问题——登录、鉴权、下线。但还有一类需求没有覆盖：<strong>当这些事件发生时，我们能不能知道？</strong></p><p>用户在哪个 IP 登录了？用哪种设备？账号被踢下线的原因是管理员操作还是被新设备顶替？二级认证是什么时候开启的？这些问题在业务层面非常重要——审计日志、异地登录告警、行为分析都依赖它们。</p><p>Sa-Token 的侦听器机制就是为此而生。它允许你订阅框架的关键性事件，在事件触发时执行自定义逻辑，而不需要侵入框架本身的任何代码。</p><hr><h2 id="1-1-工作原理与内置侦听器">1.1. 工作原理与内置侦听器</h2><p>Sa-Token 内部在每个关键动作执行时，都会向 <strong>事件发布中心 <code>SaTokenEventCenter</code></strong> 广播一个事件。所有已注册的侦听器会按注册顺序依次收到通知并执行各自的回调方法。</p><p>框架默认内置了一个侦听器实现 <code>SaTokenListenerForLog</code>，功能是将所有事件以 <code>log</code> 的形式打印到控制台。它默认关闭，通过一行配置开启：</p><figure class="highlight yaml"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line"><span class="attr">sa-token:</span></span><br><span class="line">  <span class="attr">is-log:</span> <span class="literal">true</span>   <span class="comment"># 开启框架事件的控制台日志输出</span></span><br></pre></td></tr></table></figure><p>开启后，用户每次登录、注销、被踢下线，控制台都会打印对应的日志行，方便开发阶段快速感知框架行为。生产环境建议关闭，改用自定义侦听器写入结构化日志。</p><hr><h2 id="1-2-全部事件方法一览">1.2. 全部事件方法一览</h2><p><code>SaTokenListener</code> 接口一共定义了 12 个回调方法，覆盖了从登录到 Session 销毁的完整生命周期。以下逐一说明每个方法的触发时机和参数含义：</p><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></pre></td><td class="code"><pre><span class="line"><span class="comment">/**</span></span><br><span class="line"><span class="comment"> * 每次登录时触发</span></span><br><span class="line"><span class="comment"> *</span></span><br><span class="line"><span class="comment"> * <span class="doctag">@param</span> loginType       登录类型，默认 &quot;login&quot;，多账号体系时用于区分账号类型</span></span><br><span class="line"><span class="comment"> * <span class="doctag">@param</span> loginId         登录的账号 id</span></span><br><span class="line"><span class="comment"> * <span class="doctag">@param</span> tokenValue      本次登录生成的 Token 值</span></span><br><span class="line"><span class="comment"> * <span class="doctag">@param</span> loginParameter  本次登录的完整参数对象，可从中取出设备类型、有效期等细节</span></span><br><span class="line"><span class="comment"> *                        loginParameter.getDeviceType() → 登录设备类型（PC / APP 等）</span></span><br><span class="line"><span class="comment"> *                        loginParameter.getTimeout()    → 本次 Token 有效期（秒）</span></span><br><span class="line"><span class="comment"> */</span></span><br><span class="line"><span class="keyword">void</span> <span class="title function_">doLogin</span><span class="params">(String loginType, Object loginId, String tokenValue, SaLoginParameter loginParameter)</span>;</span><br><span class="line"></span><br><span class="line"><span class="comment">/**</span></span><br><span class="line"><span class="comment"> * 每次注销时触发（用户主动调用 StpUtil.logout() 时）</span></span><br><span class="line"><span class="comment"> *</span></span><br><span class="line"><span class="comment"> * <span class="doctag">@param</span> loginType  登录类型</span></span><br><span class="line"><span class="comment"> * <span class="doctag">@param</span> loginId    被注销的账号 id</span></span><br><span class="line"><span class="comment"> * <span class="doctag">@param</span> tokenValue 被注销的 Token 值</span></span><br><span class="line"><span class="comment"> */</span></span><br><span class="line"><span class="keyword">void</span> <span class="title function_">doLogout</span><span class="params">(String loginType, Object loginId, String tokenValue)</span>;</span><br></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></pre></td><td class="code"><pre><span class="line"><span class="comment">/**</span></span><br><span class="line"><span class="comment"> * 每次被踢下线时触发（管理员调用 StpUtil.kickout() 时）</span></span><br><span class="line"><span class="comment"> *</span></span><br><span class="line"><span class="comment"> * <span class="doctag">@param</span> loginType  登录类型</span></span><br><span class="line"><span class="comment"> * <span class="doctag">@param</span> loginId    被踢下线的账号 id</span></span><br><span class="line"><span class="comment"> * <span class="doctag">@param</span> tokenValue 被踢下线的 Token 值</span></span><br><span class="line"><span class="comment"> */</span></span><br><span class="line"><span class="keyword">void</span> <span class="title function_">doKickout</span><span class="params">(String loginType, Object loginId, String tokenValue)</span>;</span><br><span class="line"></span><br><span class="line"><span class="comment">/**</span></span><br><span class="line"><span class="comment"> * 每次被顶下线时触发（同端新登录自动顶替旧会话时）</span></span><br><span class="line"><span class="comment"> *</span></span><br><span class="line"><span class="comment"> * <span class="doctag">@param</span> loginType  登录类型</span></span><br><span class="line"><span class="comment"> * <span class="doctag">@param</span> loginId    被顶下线的账号 id</span></span><br><span class="line"><span class="comment"> * <span class="doctag">@param</span> tokenValue 被顶下线的 Token 值</span></span><br><span class="line"><span class="comment"> */</span></span><br><span class="line"><span class="keyword">void</span> <span class="title function_">doReplaced</span><span class="params">(String loginType, Object loginId, String tokenValue)</span>;</span><br></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></pre></td><td class="code"><pre><span class="line"><span class="comment">/**</span></span><br><span class="line"><span class="comment"> * 每次账号被封禁时触发</span></span><br><span class="line"><span class="comment"> *</span></span><br><span class="line"><span class="comment"> * <span class="doctag">@param</span> loginType   登录类型</span></span><br><span class="line"><span class="comment"> * <span class="doctag">@param</span> loginId     被封禁的账号 id</span></span><br><span class="line"><span class="comment"> * <span class="doctag">@param</span> service     业务标识（分类封禁时有值，整体封禁时为默认值）</span></span><br><span class="line"><span class="comment"> * <span class="doctag">@param</span> level       封禁等级（阶梯封禁时有效，普通封禁时为 1）</span></span><br><span class="line"><span class="comment"> * <span class="doctag">@param</span> disableTime 封禁时长，单位：秒，-1 代表永久封禁</span></span><br><span class="line"><span class="comment"> */</span></span><br><span class="line"><span class="keyword">void</span> <span class="title function_">doDisable</span><span class="params">(String loginType, Object loginId, String service, <span class="type">int</span> level, <span class="type">long</span> disableTime)</span>;</span><br><span class="line"></span><br><span class="line"><span class="comment">/**</span></span><br><span class="line"><span class="comment"> * 每次账号被解封时触发</span></span><br><span class="line"><span class="comment"> *</span></span><br><span class="line"><span class="comment"> * <span class="doctag">@param</span> loginType 登录类型</span></span><br><span class="line"><span class="comment"> * <span class="doctag">@param</span> loginId   被解封的账号 id</span></span><br><span class="line"><span class="comment"> * <span class="doctag">@param</span> service   被解封的业务标识</span></span><br><span class="line"><span class="comment"> */</span></span><br><span class="line"><span class="keyword">void</span> <span class="title function_">doUntieDisable</span><span class="params">(String loginType, Object loginId, String service)</span>;</span><br></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></pre></td><td class="code"><pre><span class="line"><span class="comment">/**</span></span><br><span class="line"><span class="comment"> * 每次开启二级认证时触发</span></span><br><span class="line"><span class="comment"> *</span></span><br><span class="line"><span class="comment"> * <span class="doctag">@param</span> loginType  登录类型</span></span><br><span class="line"><span class="comment"> * <span class="doctag">@param</span> tokenValue 开启二级认证的 Token 值</span></span><br><span class="line"><span class="comment"> * <span class="doctag">@param</span> service    业务标识（不指定业务标识时为默认值）</span></span><br><span class="line"><span class="comment"> * <span class="doctag">@param</span> safeTime   本次二级认证的有效期，单位：秒</span></span><br><span class="line"><span class="comment"> */</span></span><br><span class="line"><span class="keyword">void</span> <span class="title function_">doOpenSafe</span><span class="params">(String loginType, String tokenValue, String service, <span class="type">long</span> safeTime)</span>;</span><br><span class="line"></span><br><span class="line"><span class="comment">/**</span></span><br><span class="line"><span class="comment"> * 每次关闭二级认证时触发（主动调用 StpUtil.closeSafe() 或有效期到期时）</span></span><br><span class="line"><span class="comment"> *</span></span><br><span class="line"><span class="comment"> * <span class="doctag">@param</span> loginType  登录类型</span></span><br><span class="line"><span class="comment"> * <span class="doctag">@param</span> tokenValue 关闭二级认证的 Token 值</span></span><br><span class="line"><span class="comment"> * <span class="doctag">@param</span> service    业务标识</span></span><br><span class="line"><span class="comment"> */</span></span><br><span class="line"><span class="keyword">void</span> <span class="title function_">doCloseSafe</span><span class="params">(String loginType, String tokenValue, String service)</span>;</span><br></pre></td></tr></table></figure><p><strong>Session 事件</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></pre></td><td class="code"><pre><span class="line"><span class="comment">/**</span></span><br><span class="line"><span class="comment"> * 每次创建 Session 时触发</span></span><br><span class="line"><span class="comment"> * Session 包括 Account-Session 和 Token-Session 两种类型</span></span><br><span class="line"><span class="comment"> *</span></span><br><span class="line"><span class="comment"> * <span class="doctag">@param</span> id Session 的唯一标识（框架内部生成的字符串 key）</span></span><br><span class="line"><span class="comment"> */</span></span><br><span class="line"><span class="keyword">void</span> <span class="title function_">doCreateSession</span><span class="params">(String id)</span>;</span><br><span class="line"></span><br><span class="line"><span class="comment">/**</span></span><br><span class="line"><span class="comment"> * 每次注销 Session 时触发</span></span><br><span class="line"><span class="comment"> *</span></span><br><span class="line"><span class="comment"> * <span class="doctag">@param</span> id Session 的唯一标识</span></span><br><span class="line"><span class="comment"> */</span></span><br><span class="line"><span class="keyword">void</span> <span class="title function_">doLogoutSession</span><span class="params">(String id)</span>;</span><br></pre></td></tr></table></figure><p><strong>Token 续期事件</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></pre></td><td class="code"><pre><span class="line"><span class="comment">/**</span></span><br><span class="line"><span class="comment"> * 每次 Token 续期时触发</span></span><br><span class="line"><span class="comment"> *</span></span><br><span class="line"><span class="comment"> * <span class="doctag">@param</span> loginType  登录类型</span></span><br><span class="line"><span class="comment"> * <span class="doctag">@param</span> loginId    续期 Token 对应的账号 id</span></span><br><span class="line"><span class="comment"> * <span class="doctag">@param</span> tokenValue 被续期的 Token 值</span></span><br><span class="line"><span class="comment"> * <span class="doctag">@param</span> timeout    续期后的有效期，单位：秒，-1 代表永久有效</span></span><br><span class="line"><span class="comment"> */</span></span><br><span class="line"><span class="keyword">void</span> <span class="title function_">doRenewTimeout</span><span class="params">(String loginType, Object loginId, String tokenValue, <span class="type">long</span> timeout)</span>;</span><br></pre></td></tr></table></figure><hr><h2 id="1-3-三种实现方式">1.3. 三种实现方式</h2><p><strong>方式一：实现 <code>SaTokenListener</code> 接口（全量实现）</strong></p><p>适合需要处理多个事件的场景。缺点是必须实现接口中的全部方法，即使某些方法你根本不关心，也得写空实现。</p><p><strong>方式二：继承 <code>SaTokenListenerForSimple</code>（推荐）</strong></p><p><code>SaTokenListenerForSimple</code> 是框架提供的适配器类，对所有事件提供了空实现。继承它之后，<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></pre></td><td class="code"><pre><span class="line"><span class="meta">@Component</span></span><br><span class="line"><span class="keyword">public</span> <span class="keyword">class</span> <span class="title class_">MySaTokenListener</span> <span class="keyword">extends</span> <span class="title class_">SaTokenListenerForSimple</span> &#123;</span><br><span class="line"></span><br><span class="line">    <span class="meta">@Override</span></span><br><span class="line">    <span class="keyword">public</span> <span class="keyword">void</span> <span class="title function_">doLogin</span><span class="params">(String loginType, Object loginId, String tokenValue,</span></span><br><span class="line"><span class="params">                        SaLoginParameter loginParameter)</span> &#123;</span><br><span class="line">        <span class="comment">// 只关心登录事件，其余方法无需重写</span></span><br><span class="line">        System.out.println(<span class="string">&quot;用户登录：&quot;</span> + loginId);</span><br><span class="line">    &#125;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p><strong>方式三：匿名内部类（轻量场景）</strong></p><p>适合只需要临时注册、或在测试代码中快速验证的场景：</p><figure class="highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br></pre></td><td class="code"><pre><span class="line">SaTokenEventCenter.registerListener(<span class="keyword">new</span> <span class="title class_">SaTokenListenerForSimple</span>() &#123;</span><br><span class="line">    <span class="meta">@Override</span></span><br><span class="line">    <span class="keyword">public</span> <span class="keyword">void</span> <span class="title function_">doLogin</span><span class="params">(String loginType, Object loginId, String tokenValue,</span></span><br><span class="line"><span class="params">                        SaLoginParameter loginParameter)</span> &#123;</span><br><span class="line">        System.out.println(<span class="string">&quot;匿名侦听器：用户 &quot;</span> + loginId + <span class="string">&quot; 登录了&quot;</span>);</span><br><span class="line">    &#125;</span><br><span class="line">&#125;);</span><br></pre></td></tr></table></figure><hr><h2 id="1-4-注册方式与-SaTokenEventCenter-管理-API">1.4. 注册方式与 SaTokenEventCenter 管理 API</h2><p><strong>自动注册（推荐）</strong></p><p>只要实现类上加了 <code>@Component</code> 注解，SpringBoot 启动时会自动将其扫描并注册到事件中心，无需任何额外配置：</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></pre></td><td class="code"><pre><span class="line"><span class="meta">@Component</span>   <span class="comment">// 有这个注解，框架自动发现并注册</span></span><br><span class="line"><span class="keyword">public</span> <span class="keyword">class</span> <span class="title class_">MySaTokenListener</span> <span class="keyword">extends</span> <span class="title class_">SaTokenListenerForSimple</span> &#123;</span><br><span class="line">    <span class="comment">// ...</span></span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p><strong>手动注册</strong></p><p>在非 IoC 环境下，或者需要在运行时动态注册侦听器时，使用 <code>SaTokenEventCenter</code> 手动操作：</p><figure class="highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">// 注册一个侦听器</span></span><br><span class="line">SaTokenEventCenter.registerListener(<span class="keyword">new</span> <span class="title class_">MySaTokenListener</span>());</span><br><span class="line"></span><br><span class="line"><span class="comment">// 注册一组侦听器</span></span><br><span class="line">SaTokenEventCenter.registerListenerList(listenerList);</span><br></pre></td></tr></table></figure><p><code>SaTokenEventCenter</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></pre></td><td class="code"><pre><span class="line"><span class="comment">// 获取已注册的所有侦听器</span></span><br><span class="line">SaTokenEventCenter.getListenerList();</span><br><span class="line"></span><br><span class="line"><span class="comment">// 重置侦听器集合（替换全部）</span></span><br><span class="line">SaTokenEventCenter.setListenerList(listenerList);</span><br><span class="line"></span><br><span class="line"><span class="comment">// 移除指定的侦听器实例</span></span><br><span class="line">SaTokenEventCenter.removeListener(listener);</span><br><span class="line"></span><br><span class="line"><span class="comment">// 移除指定类型的所有侦听器</span></span><br><span class="line">SaTokenEventCenter.removeListener(MySaTokenListener.class);</span><br><span class="line"></span><br><span class="line"><span class="comment">// 清空所有已注册的侦听器</span></span><br><span class="line">SaTokenEventCenter.clearListener();</span><br><span class="line"></span><br><span class="line"><span class="comment">// 判断是否已注册了指定侦听器实例</span></span><br><span class="line">SaTokenEventCenter.hasListener(listener);</span><br><span class="line"></span><br><span class="line"><span class="comment">// 判断是否已注册了指定类型的侦听器</span></span><br><span class="line">SaTokenEventCenter.hasListener(MySaTokenListener.class);</span><br></pre></td></tr></table></figure><p>多个侦听器可以同时存在，彼此独立，互不影响，按照注册顺序依次接收到事件通知。</p><hr><h2 id="1-5-实战：登录审计日志记录器">1.5. 实战：登录审计日志记录器</h2><p><code>System.out.println</code> 能说明机制，却没有实际业务价值。下面用侦听器实现一个完整的登录审计日志记录器，覆盖登录、注销、踢人、顶替四种事件，记录每次操作的时间、账号、Token，以及下线原因。</p><p>📄 <code>src/main/java/com/example/authsatoken/listener/AuditLogListener.java</code>（新建）</p><figure class="highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><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></pre></td><td class="code"><pre><span class="line"><span class="keyword">package</span> com.example.authsatoken.listener;</span><br><span class="line"></span><br><span class="line"><span class="keyword">import</span> cn.dev33.satoken.listener.SaTokenListenerForSimple;</span><br><span class="line"><span class="keyword">import</span> cn.dev33.satoken.stp.parameter.SaLoginParameter;</span><br><span class="line"><span class="keyword">import</span> org.slf4j.Logger;</span><br><span class="line"><span class="keyword">import</span> org.slf4j.LoggerFactory;</span><br><span class="line"><span class="keyword">import</span> org.springframework.stereotype.Component;</span><br><span class="line"></span><br><span class="line"><span class="keyword">import</span> java.time.LocalDateTime;</span><br><span class="line"><span class="keyword">import</span> java.time.format.DateTimeFormatter;</span><br><span class="line"></span><br><span class="line"><span class="comment">/**</span></span><br><span class="line"><span class="comment"> * 登录审计日志侦听器</span></span><br><span class="line"><span class="comment"> * 记录用户登录、注销、被踢下线、被顶下线等关键事件</span></span><br><span class="line"><span class="comment"> * &lt;p&gt;</span></span><br><span class="line"><span class="comment"> * 实际项目中，可将日志写入数据库或专用日志服务（如 ELK），此处以 SLF4J 日志输出演示</span></span><br><span class="line"><span class="comment"> */</span></span><br><span class="line"><span class="meta">@Component</span></span><br><span class="line"><span class="keyword">public</span> <span class="keyword">class</span> <span class="title class_">AuditLogListener</span> <span class="keyword">extends</span> <span class="title class_">SaTokenListenerForSimple</span> &#123;</span><br><span class="line"></span><br><span class="line"><span class="keyword">private</span> <span class="keyword">static</span> <span class="keyword">final</span> <span class="type">Logger</span> <span class="variable">log</span> <span class="operator">=</span> LoggerFactory.getLogger(AuditLogListener.class);</span><br><span class="line"><span class="keyword">private</span> <span class="keyword">static</span> <span class="keyword">final</span> <span class="type">DateTimeFormatter</span> <span class="variable">FORMATTER</span> <span class="operator">=</span></span><br><span class="line">DateTimeFormatter.ofPattern(<span class="string">&quot;yyyy-MM-dd HH:mm:ss&quot;</span>);</span><br><span class="line"></span><br><span class="line"><span class="comment">/**</span></span><br><span class="line"><span class="comment"> * 登录事件</span></span><br><span class="line"><span class="comment"> * 可以从 loginParameter 中取出设备类型、Token 有效期等登录细节</span></span><br><span class="line"><span class="comment"> */</span></span><br><span class="line"><span class="meta">@Override</span></span><br><span class="line"><span class="keyword">public</span> <span class="keyword">void</span> <span class="title function_">doLogin</span><span class="params">(String loginType, Object loginId, String tokenValue,</span></span><br><span class="line"><span class="params">                    SaLoginParameter loginParameter)</span> &#123;</span><br><span class="line"><span class="keyword">try</span> &#123;</span><br><span class="line"><span class="type">String</span> <span class="variable">deviceType</span> <span class="operator">=</span> loginParameter.getDeviceType();</span><br><span class="line"><span class="type">long</span> <span class="variable">timeout</span> <span class="operator">=</span> loginParameter.getTimeout();</span><br><span class="line">log.info(<span class="string">&quot;[审计日志] 用户登录 | 时间=&#123;&#125; | 账号=&#123;&#125; | 设备=&#123;&#125; | Token有效期=&#123;&#125;秒 | Token=&#123;&#125;&quot;</span>,</span><br><span class="line">now(), loginId, deviceType, timeout, mask(tokenValue));</span><br><span class="line">&#125; <span class="keyword">catch</span> (Exception e) &#123;</span><br><span class="line">log.error(<span class="string">&quot;[审计日志] 记录登录事件失败&quot;</span>, e);</span><br><span class="line">&#125;</span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="comment">/**</span></span><br><span class="line"><span class="comment"> * 注销事件（用户主动退出）</span></span><br><span class="line"><span class="comment"> */</span></span><br><span class="line"><span class="meta">@Override</span></span><br><span class="line"><span class="keyword">public</span> <span class="keyword">void</span> <span class="title function_">doLogout</span><span class="params">(String loginType, Object loginId, String tokenValue)</span> &#123;</span><br><span class="line"><span class="keyword">try</span> &#123;</span><br><span class="line">log.info(<span class="string">&quot;[审计日志] 用户注销 | 时间=&#123;&#125; | 账号=&#123;&#125; | Token=&#123;&#125;&quot;</span>,</span><br><span class="line">now(), loginId, mask(tokenValue));</span><br><span class="line">&#125; <span class="keyword">catch</span> (Exception e) &#123;</span><br><span class="line">log.error(<span class="string">&quot;[审计日志] 记录注销事件失败&quot;</span>, e);</span><br><span class="line">&#125;</span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="comment">/**</span></span><br><span class="line"><span class="comment"> * 踢下线事件（管理员操作）</span></span><br><span class="line"><span class="comment"> * 区别于用户主动注销，这里可以额外触发消息推送通知用户</span></span><br><span class="line"><span class="comment"> */</span></span><br><span class="line"><span class="meta">@Override</span></span><br><span class="line"><span class="keyword">public</span> <span class="keyword">void</span> <span class="title function_">doKickout</span><span class="params">(String loginType, Object loginId, String tokenValue)</span> &#123;</span><br><span class="line"><span class="keyword">try</span> &#123;</span><br><span class="line">log.warn(<span class="string">&quot;[审计日志] 用户被踢下线 | 时间=&#123;&#125; | 账号=&#123;&#125; | 原因=管理员操作 | Token=&#123;&#125;&quot;</span>,</span><br><span class="line">now(), loginId, mask(tokenValue));</span><br><span class="line"><span class="comment">// 实际项目中，可在此处推送消息通知用户，例如：</span></span><br><span class="line"><span class="comment">// messageService.push(loginId, &quot;您的账号已被管理员强制下线，如有疑问请联系客服&quot;);</span></span><br><span class="line">&#125; <span class="keyword">catch</span> (Exception e) &#123;</span><br><span class="line">log.error(<span class="string">&quot;[审计日志] 记录踢下线事件失败&quot;</span>, e);</span><br><span class="line">&#125;</span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="comment">/**</span></span><br><span class="line"><span class="comment"> * 顶下线事件（新设备登录自动顶替）</span></span><br><span class="line"><span class="comment"> * 典型的异地登录场景，可触发安全告警</span></span><br><span class="line"><span class="comment"> */</span></span><br><span class="line"><span class="meta">@Override</span></span><br><span class="line"><span class="keyword">public</span> <span class="keyword">void</span> <span class="title function_">doReplaced</span><span class="params">(String loginType, Object loginId, String tokenValue)</span> &#123;</span><br><span class="line"><span class="keyword">try</span> &#123;</span><br><span class="line">log.warn(<span class="string">&quot;[审计日志] 用户被顶下线 | 时间=&#123;&#125; | 账号=&#123;&#125; | 原因=新设备登录 | Token=&#123;&#125;&quot;</span>,</span><br><span class="line">now(), loginId, mask(tokenValue));</span><br><span class="line"><span class="comment">// 实际项目中，可在此处触发异地登录安全告警：</span></span><br><span class="line"><span class="comment">// securityAlertService.sendLoginAlert(loginId, &quot;您的账号在新设备上登录，旧设备已下线&quot;);</span></span><br><span class="line">&#125; <span class="keyword">catch</span> (Exception e) &#123;</span><br><span class="line">log.error(<span class="string">&quot;[审计日志] 记录顶下线事件失败&quot;</span>, e);</span><br><span class="line">&#125;</span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="comment">/**</span></span><br><span class="line"><span class="comment"> * 封禁事件</span></span><br><span class="line"><span class="comment"> */</span></span><br><span class="line"><span class="meta">@Override</span></span><br><span class="line"><span class="keyword">public</span> <span class="keyword">void</span> <span class="title function_">doDisable</span><span class="params">(String loginType, Object loginId, String service,</span></span><br><span class="line"><span class="params">                      <span class="type">int</span> level, <span class="type">long</span> disableTime)</span> &#123;</span><br><span class="line"><span class="keyword">try</span> &#123;</span><br><span class="line"><span class="type">String</span> <span class="variable">duration</span> <span class="operator">=</span> disableTime == -<span class="number">1</span> ? <span class="string">&quot;永久&quot;</span> : disableTime + <span class="string">&quot;秒&quot;</span>;</span><br><span class="line">log.warn(<span class="string">&quot;[审计日志] 用户被封禁 | 时间=&#123;&#125; | 账号=&#123;&#125; | 服务=&#123;&#125; | 等级=&#123;&#125; | 时长=&#123;&#125;&quot;</span>,</span><br><span class="line">now(), loginId, service, level, duration);</span><br><span class="line">&#125; <span class="keyword">catch</span> (Exception e) &#123;</span><br><span class="line">log.error(<span class="string">&quot;[审计日志] 记录封禁事件失败&quot;</span>, e);</span><br><span class="line">&#125;</span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="comment">/**</span></span><br><span class="line"><span class="comment"> * 当前时间格式化</span></span><br><span class="line"><span class="comment"> */</span></span><br><span class="line"><span class="keyword">private</span> String <span class="title function_">now</span><span class="params">()</span> &#123;</span><br><span class="line"><span class="keyword">return</span> LocalDateTime.now().format(FORMATTER);</span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="comment">/**</span></span><br><span class="line"><span class="comment"> * Token 脱敏：只显示前 8 位，其余用 * 替换</span></span><br><span class="line"><span class="comment"> * 日志中不应记录完整 Token，防止日志泄露导致会话被劫持</span></span><br><span class="line"><span class="comment"> */</span></span><br><span class="line"><span class="keyword">private</span> String <span class="title function_">mask</span><span class="params">(String token)</span> &#123;</span><br><span class="line"><span class="keyword">if</span> (token == <span class="literal">null</span> || token.length() &lt;= <span class="number">8</span>) &#123;</span><br><span class="line"><span class="keyword">return</span> <span class="string">&quot;****&quot;</span>;</span><br><span class="line">&#125;</span><br><span class="line"><span class="keyword">return</span> token.substring(<span class="number">0</span>, <span class="number">8</span>) + <span class="string">&quot;****&quot;</span>;</span><br><span class="line">&#125;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>这个实现有几个值得注意的设计决策。</p><p><strong>Token 脱敏</strong>是必须的。完整的 Token 相当于用户的身份凭证，如果日志被攻击者获取，就可以直接冒充用户发起请求。<code>mask()</code> 方法只保留前 8 位，足以在日志中定位问题，又不会造成安全风险。</p><p><code>doKickout</code> 和 <code>doReplaced</code> 分开处理，而不是合并到一个方法里。这两种事件对用户的含义截然不同：被踢下线说明有管理员操作，被顶下线说明可能有异地登录。前者可以推送客服通知，后者更适合触发安全告警——分开处理才能做出有意义的差异化响应。</p><hr><h2 id="1-6-重要坑点：try-catch-是必须的">1.6. 重要坑点：try-catch 是必须的</h2><p>侦听器的回调方法在 Sa-Token 的主流程中被<strong>同步调用</strong>——登录动作完成后，框架立即调用所有侦听器的 <code>doLogin</code> 方法，等所有侦听器都执行完才返回结果给调用方。</p><p>这意味着：<strong>如果你的侦听器代码抛出了未捕获的异常，整个登录流程就会被强制中断</strong>，用户会看到一个 500 错误，而不是成功登录。</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><span class="line"><span class="comment">// ❌ 危险写法：如果数据库操作失败，登录接口直接报 500</span></span><br><span class="line"><span class="meta">@Override</span></span><br><span class="line"><span class="keyword">public</span> <span class="keyword">void</span> <span class="title function_">doLogin</span><span class="params">(String loginType, Object loginId, String tokenValue,</span></span><br><span class="line"><span class="params">                    SaLoginParameter loginParameter)</span> &#123;</span><br><span class="line">    loginLogRepository.save(<span class="keyword">new</span> <span class="title class_">LoginLog</span>(loginId, tokenValue));  <span class="comment">// 如果数据库挂了怎么办？</span></span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="comment">// ✅ 安全写法：侦听器内的不安全代码必须用 try-catch 保护</span></span><br><span class="line"><span class="meta">@Override</span></span><br><span class="line"><span class="keyword">public</span> <span class="keyword">void</span> <span class="title function_">doLogin</span><span class="params">(String loginType, Object loginId, String tokenValue,</span></span><br><span class="line"><span class="params">                    SaLoginParameter loginParameter)</span> &#123;</span><br><span class="line">    <span class="keyword">try</span> &#123;</span><br><span class="line">        loginLogRepository.save(<span class="keyword">new</span> <span class="title class_">LoginLog</span>(loginId, tokenValue));</span><br><span class="line">    &#125; <span class="keyword">catch</span> (Exception e) &#123;</span><br><span class="line">        <span class="comment">// 记录失败不应影响用户登录，降级处理：打印错误日志后继续</span></span><br><span class="line">        log.error(<span class="string">&quot;[审计日志] 写入登录日志失败，loginId=&#123;&#125;&quot;</span>, loginId, e);</span><br><span class="line">    &#125;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><div class="note warning simple"><p>侦听器中任何可能失败的操作——数据库写入、HTTP 请求、消息推送——都必须用 try-catch 包裹。侦听器的职责是&quot;观察和记录&quot;，不应该反过来影响主流程的成败。</p></div><hr><h2 id="1-7-本章总结">1.7. 本章总结</h2><p><strong>本章回顾</strong></p><p>本章完整讲解了 Sa-Token 的全局侦听器机制。从工作原理出发，理解了事件发布中心 <code>SaTokenEventCenter</code> 的广播模型，以及内置 <code>SaTokenListenerForLog</code> 的快速开启方式。在事件方法层面，逐一解释了全部 12 个回调方法的触发时机和每个参数的具体含义，补全了官方文档只有签名没有说明的空白。在实现方式上，比较了直接实现接口、继承 <code>SaTokenListenerForSimple</code>（推荐）、匿名内部类三种方式的适用场景。通过登录审计日志记录器的实战示例，将侦听器能力落地到真实业务中，演示了 Token 脱敏、差异化下线响应、异地登录告警等实践要点。最后着重说明了 try-catch 的必要性——侦听器异常会中断 Sa-Token 主流程，这是使用中最容易忽略的坑。</p><p><strong>核心汇总表</strong></p><table><thead><tr><th>事件方法</th><th>触发时机</th><th>关键参数</th></tr></thead><tbody><tr><td><code>doLogin</code></td><td>用户登录时</td><td><code>loginParameter</code>（含设备类型、有效期等登录细节）</td></tr><tr><td><code>doLogout</code></td><td>用户主动注销时</td><td><code>loginId</code> / <code>tokenValue</code></td></tr><tr><td><code>doKickout</code></td><td>被管理员踢下线时</td><td><code>loginId</code> / <code>tokenValue</code></td></tr><tr><td><code>doReplaced</code></td><td>被新设备登录顶下线时</td><td><code>loginId</code> / <code>tokenValue</code></td></tr><tr><td><code>doDisable</code></td><td>账号被封禁时</td><td><code>service</code>（业务标识）/ <code>level</code>（封禁等级）/ <code>disableTime</code>（时长）</td></tr><tr><td><code>doUntieDisable</code></td><td>账号被解封时</td><td><code>service</code></td></tr><tr><td><code>doOpenSafe</code></td><td>开启二级认证时</td><td><code>service</code>（业务标识）/ <code>safeTime</code>（有效期）</td></tr><tr><td><code>doCloseSafe</code></td><td>关闭二级认证时</td><td><code>service</code></td></tr><tr><td><code>doCreateSession</code></td><td>Session 创建时</td><td><code>id</code>（Session 唯一标识）</td></tr><tr><td><code>doLogoutSession</code></td><td>Session 注销时</td><td><code>id</code></td></tr><tr><td><code>doRenewTimeout</code></td><td>Token 续期时</td><td><code>tokenValue</code> / <code>timeout</code>（续期后有效期）</td></tr></tbody></table><table><thead><tr><th>实现方式</th><th>适用场景</th><th>优缺点</th></tr></thead><tbody><tr><td>实现 <code>SaTokenListener</code> 接口</td><td>需要处理所有事件</td><td>必须实现全部方法，代码量大</td></tr><tr><td>继承 <code>SaTokenListenerForSimple</code></td><td>只关心部分事件（推荐）</td><td>只重写需要的方法，简洁</td></tr><tr><td>匿名内部类</td><td>轻量场景 / 测试代码</td><td>简单快速，但不适合生产</td></tr></tbody></table><table><thead><tr><th>注册方式</th><th>适用场景</th></tr></thead><tbody><tr><td><code>@Component</code> 自动注册</td><td>SpringBoot 项目（推荐）</td></tr><tr><td><code>SaTokenEventCenter.registerListener()</code> 手动注册</td><td>非 IoC 环境 / 运行时动态注册</td></tr></tbody></table><table><thead><tr><th>安全规则</th><th>说明</th></tr></thead><tbody><tr><td>try-catch 包裹不安全代码</td><td>侦听器异常会中断主流程，数据库写入、HTTP 请求等必须保护</td></tr><tr><td>Token 脱敏后再记录日志</td><td>完整 Token 相当于身份凭证，不应出现在日志文件中</td></tr><tr><td><code>doKickout</code> 与 <code>doReplaced</code> 分开处理</td><td>两种下线原因对应不同的业务响应（客服通知 vs 安全告警）</td></tr></tbody></table><h1>第二章. 全局过滤器——更底层的请求拦截</h1><p><strong>阶段式学习路径</strong></p><p>第三篇笔记中，我们用 <code>SaInterceptor</code> 拦截器实现了路由鉴权。拦截器已经能满足绝大多数场景，但它并非唯一的选择。Sa-Token 同时提供了全局过滤器，作为拦截器的替代或补充方案。</p><p>两者不是为了互相取代而存在的——它们工作在请求处理链的不同层次，各自有擅长的场景。本章先把选型问题讲清楚，再完整介绍过滤器的注册和配置，以及几个容易踩坑的细节。</p><hr><h2 id="2-1-过滤器-vs-拦截器：完整选型指南">2.1. 过滤器 vs 拦截器：完整选型指南</h2><p>理解两者的区别，首先要理解它们在 Spring 请求处理链中的位置：</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></pre></td><td class="code"><pre><span class="line">HTTP 请求</span><br><span class="line">    ↓</span><br><span class="line">Filter（过滤器）← SaServletFilter 在这里</span><br><span class="line">    ↓</span><br><span class="line">DispatcherServlet</span><br><span class="line">    ↓</span><br><span class="line">Interceptor（拦截器）← SaInterceptor 在这里</span><br><span class="line">    ↓</span><br><span class="line">Controller 方法</span><br></pre></td></tr></table></figure><p>过滤器更靠前，在 Spring 的 <code>DispatcherServlet</code> 之前就介入了请求；拦截器在 <code>DispatcherServlet</code> 之后才介入，此时 Spring 已经完成了请求的路由解析。</p><p>这个位置差异决定了两者的能力边界：</p><table><thead><tr><th>维度</th><th>拦截器（SaInterceptor）</th><th>过滤器（SaServletFilter）</th></tr></thead><tbody><tr><td>执行时机</td><td>DispatcherServlet 之后</td><td>DispatcherServlet 之前</td></tr><tr><td>异常处理</td><td>进入 <code>@ExceptionHandler</code> 全局处理</td><td>不进入，必须在 <code>setError</code> 中手动处理</td></tr><tr><td>静态资源拦截</td><td>不拦截（Spring 静态资源直接响应）</td><td>可以拦截</td></tr><tr><td>获取 <code>HandlerMethod</code></td><td>可以（知道请求将进入哪个方法）</td><td>不可以</td></tr><tr><td>注解鉴权支持</td><td>✅ 内置，<code>@SaCheckLogin</code> 等注解生效</td><td>❌ 不支持</td></tr><tr><td>WebFlux 支持</td><td>❌ 无</td><td>✅ <code>SaReactorFilter</code></td></tr><tr><td>防渗透扫描能力</td><td>一般</td><td>更强（执行更早，攻击流量更早被拦截）</td></tr></tbody></table><p><strong>选型建议：</strong></p><p>绝大多数 SpringBoot + SpringMVC 项目，<strong>优先使用拦截器</strong>。原因很简单：异常会自动进入全局处理器，不需要额外编写错误响应逻辑；支持注解鉴权；对日常的登录校验和权限控制完全够用。</p><p>以下情况考虑使用过滤器：</p><ul><li>项目使用 <strong>Spring WebFlux</strong>（没有拦截器机制，过滤器是唯一选择）</li><li>需要<strong>拦截静态资源</strong>的访问（如图片、PDF 需要权限才能下载）</li><li>需要在请求处理链<strong>最早期</strong>介入，例如统一设置安全响应头</li></ul><div class="note info simple"><p>Sa-Token 同时提供两种机制，不是让谁取代谁，而是让你根据实际业务合理选择。两者也可以同时注册，互不冲突。</p></div><hr><h2 id="2-2-注册-SaServletFilter">2.2. 注册 SaServletFilter</h2><p>过滤器默认处于关闭状态，需要手动注册为 Spring Bean。与拦截器注册在 <code>WebMvcConfigurer</code> 中不同，过滤器以 <code>@Bean</code> 方式注册：</p><p>📄 <code>src/main/java/com/example/authsatoken/config/SaTokenFilterConfig.java</code>（新建）</p><figure class="highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><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><span class="line"><span class="keyword">package</span> com.example.authsatoken.config;</span><br><span class="line"></span><br><span class="line"><span class="keyword">import</span> cn.dev33.satoken.filter.SaServletFilter;</span><br><span class="line"><span class="keyword">import</span> cn.dev33.satoken.router.SaRouter;</span><br><span class="line"><span class="keyword">import</span> cn.dev33.satoken.stp.StpUtil;</span><br><span class="line"><span class="keyword">import</span> cn.dev33.satoken.util.SaResult;</span><br><span class="line"><span class="keyword">import</span> com.fasterxml.jackson.databind.ObjectMapper;</span><br><span class="line"><span class="keyword">import</span> org.springframework.context.annotation.Bean;</span><br><span class="line"><span class="keyword">import</span> org.springframework.context.annotation.Configuration;</span><br><span class="line"></span><br><span class="line"><span class="comment">/**</span></span><br><span class="line"><span class="comment"> * Sa-Token 全局过滤器配置</span></span><br><span class="line"><span class="comment"> * 演示过滤器的四个核心配置方法，以及常见坑点的正确处理方式</span></span><br><span class="line"><span class="comment"> *</span></span><br><span class="line"><span class="comment"> * 注意：本项目已在 SaTokenConfig 中注册了拦截器，</span></span><br><span class="line"><span class="comment"> * 此配置仅用于演示过滤器写法，实际项目中二选一即可，避免重复拦截</span></span><br><span class="line"><span class="comment"> */</span></span><br><span class="line"><span class="meta">@Configuration</span></span><br><span class="line"><span class="keyword">public</span> <span class="keyword">class</span> <span class="title class_">SaTokenFilterConfig</span> &#123;</span><br><span class="line"></span><br><span class="line">    <span class="meta">@Bean</span></span><br><span class="line">    <span class="keyword">public</span> SaServletFilter <span class="title function_">getSaServletFilter</span><span class="params">()</span> &#123;</span><br><span class="line">        <span class="keyword">return</span> <span class="keyword">new</span> <span class="title class_">SaServletFilter</span>()</span><br><span class="line"></span><br><span class="line">                <span class="comment">// ① 指定拦截路由与放行路由</span></span><br><span class="line">                <span class="comment">// addInclude：指定过滤器拦截哪些路径</span></span><br><span class="line">                <span class="comment">// addExclude：直接放行哪些路径（在过滤器层面排除，不会进入 setAuth）</span></span><br><span class="line">                <span class="comment">// /favicon.ico 是浏览器自动请求的图标，不排除会产生大量无意义的校验日志</span></span><br><span class="line">                .addInclude(<span class="string">&quot;/**&quot;</span>)</span><br><span class="line">                .addExclude(<span class="string">&quot;/favicon.ico&quot;</span>)</span><br><span class="line"></span><br><span class="line">                <span class="comment">// ② setBeforeAuth：前置函数，在认证函数之前执行</span></span><br><span class="line">                <span class="comment">// ⚠️ 坑点：BeforeAuth 不受 addInclude / addExclude 的限制</span></span><br><span class="line">                <span class="comment">// 所有进入过滤器的请求（包括被 addExclude 排除的路径）都会执行 BeforeAuth</span></span><br><span class="line">                <span class="comment">// 因此不要在 BeforeAuth 里做鉴权逻辑，它的正确用途是设置响应头等无副作用的预处理</span></span><br><span class="line">                .setBeforeAuth(req -&gt; &#123;</span><br><span class="line">                    <span class="comment">// 设置安全响应头（详见 2.3 节）</span></span><br><span class="line">                    <span class="comment">// 这里的代码对所有请求生效，包括静态资源和被排除的路径</span></span><br><span class="line">                &#125;)</span><br><span class="line"></span><br><span class="line">                <span class="comment">// ③ setAuth：认证函数，每次请求执行（受 addInclude / addExclude 约束）</span></span><br><span class="line">                <span class="comment">// 写法与拦截器中的 SaRouter 完全一致</span></span><br><span class="line">                .setAuth(obj -&gt; &#123;</span><br><span class="line">                    SaRouter.match(<span class="string">&quot;/**&quot;</span>)</span><br><span class="line">                            .notMatch(<span class="string">&quot;/auth/login&quot;</span>)</span><br><span class="line">                            .check(r -&gt; StpUtil.checkLogin());</span><br><span class="line">                &#125;)</span><br><span class="line"></span><br><span class="line">                <span class="comment">// ④ setError：异常处理函数，认证函数抛出异常时执行</span></span><br><span class="line">                <span class="comment">// ⚠️ 坑点一：过滤器中的异常不进入 @ExceptionHandler，必须在这里处理</span></span><br><span class="line">                <span class="comment">// ⚠️ 坑点二：setError 的返回值直接作为字符串输出到前端</span></span><br><span class="line">                <span class="comment">//           如果不设置响应头，Content-Type 默认是 text/plain，</span></span><br><span class="line">                <span class="comment">//           前端收到的是纯文本而不是 JSON，无法正常解析</span></span><br><span class="line">                .setError(e -&gt; &#123;</span><br><span class="line">                    <span class="comment">// 必须手动设置 Content-Type，否则前端无法将返回值解析为 JSON</span></span><br><span class="line">                    <span class="comment">// SaHolder.getResponse().setHeader(&quot;Content-Type&quot;, &quot;application/json;charset=UTF-8&quot;);</span></span><br><span class="line">                    <span class="keyword">return</span> SaResult.error(e.getMessage()).toString();</span><br><span class="line">                &#125;);</span><br><span class="line">    &#125;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>四个配置方法构成了过滤器的完整生命周期，理解它们的执行顺序和约束范围是正确使用过滤器的关键。</p><hr><h2 id="2-3-setBeforeAuth-的正确用途：设置安全响应头">2.3. setBeforeAuth 的正确用途：设置安全响应头</h2><p><code>setBeforeAuth</code> 不受 <code>addInclude / addExclude</code> 的限制，对所有进入过滤器的请求都会执行。这个特性看起来像个坑，但其实暗示了它的正确用途：<strong>做对所有请求都需要生效的无副作用预处理</strong>，最典型的就是设置安全响应头。</p><p>安全响应头是一组 HTTP 响应头，告诉浏览器如何更安全地处理响应内容，防御 XSS、点击劫持等常见攻击。将它放在 <code>setBeforeAuth</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></pre></td><td class="code"><pre><span class="line">.setBeforeAuth(req -&gt; &#123;</span><br><span class="line">    SaHolder.getResponse()</span><br><span class="line">        <span class="comment">// 禁止页面在 iframe 中显示，防止点击劫持攻击</span></span><br><span class="line">        <span class="comment">// DENY=完全禁止 | SAMEORIGIN=只允许同域 | ALLOW-FROM uri=指定域名</span></span><br><span class="line">        .setHeader(<span class="string">&quot;X-Frame-Options&quot;</span>, <span class="string">&quot;SAMEORIGIN&quot;</span>)</span><br><span class="line"></span><br><span class="line">        <span class="comment">// 启用浏览器内置的 XSS 过滤器</span></span><br><span class="line">        <span class="comment">// 1; mode=block 表示检测到 XSS 攻击时停止渲染整个页面</span></span><br><span class="line">        .setHeader(<span class="string">&quot;X-XSS-Protection&quot;</span>, <span class="string">&quot;1; mode=block&quot;</span>)</span><br><span class="line"></span><br><span class="line">        <span class="comment">// 禁止浏览器对响应内容进行类型嗅探</span></span><br><span class="line">        <span class="comment">// 防止浏览器将非 JS 文件当作 JS 执行</span></span><br><span class="line">        .setHeader(<span class="string">&quot;X-Content-Type-Options&quot;</span>, <span class="string">&quot;nosniff&quot;</span>)</span><br><span class="line"></span><br><span class="line">        <span class="comment">// 服务器标识，隐藏真实服务器信息（安全加固）</span></span><br><span class="line">        .setServer(<span class="string">&quot;sa-server&quot;</span>);</span><br><span class="line">&#125;)</span><br></pre></td></tr></table></figure><hr><h2 id="2-4-setError-的正确写法">2.4. setError 的正确写法</h2><p><code>setError</code> 是过滤器与拦截器相比最容易踩坑的地方。拦截器中，<code>SaInterceptor</code> 抛出的异常会自动进入 <code>@ExceptionHandler</code> 全局处理，我们在番外篇二第四章写的 <code>GlobalExceptionHandler</code> 会帮我们把异常转换成规范的 JSON 响应。</p><p>但过滤器中的异常<strong>不走这套机制</strong>。<code>setAuth</code> 里抛出的任何异常都会被 <code>setError</code> 捕获，<code>setError</code> 的返回值（一个字符串）直接写入 HTTP 响应体，响应就结束了。</p><p>这带来两个问题：</p><p><strong>问题一：Content-Type 默认是 text/plain</strong></p><p>如果不手动设置响应头，前端收到的是纯文本字符串，而不是 <code>application/json</code>，导致前端的 JSON 解析失败。</p><p><strong>问题二：SaResult.toString() 不是合法 JSON</strong></p><p><code>SaResult</code> 对象的 <code>toString()</code> 方法返回的是 Java 对象的字符串表示（如 <code>SaResult&#123;code=500, ...&#125;</code>），不是 JSON 格式。需要用 JSON 序列化工具将其转换。</p><p>正确写法：</p><figure class="highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br></pre></td><td class="code"><pre><span class="line">.setError(e -&gt; &#123;</span><br><span class="line">    <span class="comment">// 第一步：手动设置响应头，告诉前端这是 JSON 格式</span></span><br><span class="line">    SaHolder.getResponse().setHeader(<span class="string">&quot;Content-Type&quot;</span>, <span class="string">&quot;application/json;charset=UTF-8&quot;</span>);</span><br><span class="line"></span><br><span class="line">    <span class="comment">// 第二步：将错误信息序列化为 JSON 字符串</span></span><br><span class="line">    <span class="comment">// 方案 A：使用 Jackson（项目已引入 Spring Boot 时 Jackson 默认可用）</span></span><br><span class="line">    <span class="keyword">try</span> &#123;</span><br><span class="line">        <span class="type">SaResult</span> <span class="variable">result</span> <span class="operator">=</span> SaResult.error(e.getMessage()).setCode(<span class="number">401</span>);</span><br><span class="line">        <span class="keyword">return</span> <span class="keyword">new</span> <span class="title class_">ObjectMapper</span>().writeValueAsString(result);</span><br><span class="line">    &#125; <span class="keyword">catch</span> (Exception ex) &#123;</span><br><span class="line">        <span class="keyword">return</span> <span class="string">&quot;&#123;\&quot;code\&quot;:500,\&quot;msg\&quot;:\&quot;系统错误\&quot;,\&quot;data\&quot;:null&#125;&quot;</span>;</span><br><span class="line">    &#125;</span><br><span class="line"></span><br><span class="line">    <span class="comment">// 方案 B：使用 Hutool（需要引入 hutool-json 依赖）</span></span><br><span class="line">    <span class="comment">// return JSONUtil.toJsonStr(SaResult.error(e.getMessage()).setCode(401));</span></span><br><span class="line"></span><br><span class="line">    <span class="comment">// 方案 C：直接硬编码 JSON 字符串（简单但不灵活）</span></span><br><span class="line">    <span class="comment">// return &quot;&#123;\&quot;code\&quot;:401,\&quot;msg\&quot;:\&quot;&quot; + e.getMessage() + &quot;\&quot;,\&quot;data\&quot;:null&#125;&quot;;</span></span><br><span class="line">&#125;)</span><br></pre></td></tr></table></figure><p>三种方案的选择建议：项目已使用 Spring Boot 时优先选方案 A（Jackson 已经在依赖中），项目已引入 Hutool 则选方案 B，其余情况用方案 C 兜底。</p><hr><h2 id="2-5-自定义过滤器执行顺序">2.5. 自定义过滤器执行顺序</h2><p><code>SaServletFilter</code> 默认注册顺序为 <code>-100</code>（在 Spring Boot 中 Order 值越小执行越早）。如果项目中存在多个过滤器，或者需要 Sa-Token 过滤器在某个自定义过滤器之前 / 之后执行，可以通过 <code>FilterRegistrationBean</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></pre></td><td class="code"><pre><span class="line"><span class="meta">@Bean</span></span><br><span class="line"><span class="keyword">public</span> FilterRegistrationBean&lt;SaServletFilter&gt; <span class="title function_">getSaServletFilter</span><span class="params">()</span> &#123;</span><br><span class="line">    FilterRegistrationBean&lt;SaServletFilter&gt; frBean = <span class="keyword">new</span> <span class="title class_">FilterRegistrationBean</span>&lt;&gt;();</span><br><span class="line"></span><br><span class="line">    frBean.setFilter(</span><br><span class="line">        <span class="keyword">new</span> <span class="title class_">SaServletFilter</span>()</span><br><span class="line">            .addInclude(<span class="string">&quot;/**&quot;</span>)</span><br><span class="line">            .addExclude(<span class="string">&quot;/favicon.ico&quot;</span>)</span><br><span class="line">            .setAuth(obj -&gt; &#123;</span><br><span class="line">                SaRouter.match(<span class="string">&quot;/**&quot;</span>)</span><br><span class="line">                        .notMatch(<span class="string">&quot;/auth/login&quot;</span>)</span><br><span class="line">                        .check(r -&gt; StpUtil.checkLogin());</span><br><span class="line">            &#125;)</span><br><span class="line">            .setError(e -&gt; &#123;</span><br><span class="line">                SaHolder.getResponse().setHeader(<span class="string">&quot;Content-Type&quot;</span>, <span class="string">&quot;application/json;charset=UTF-8&quot;</span>);</span><br><span class="line">                <span class="keyword">return</span> SaResult.error(e.getMessage()).setCode(<span class="number">401</span>).toString();</span><br><span class="line">            &#125;)</span><br><span class="line">    );</span><br><span class="line"></span><br><span class="line">    <span class="comment">// 默认值是 -100，数值越小越先执行</span></span><br><span class="line">    <span class="comment">// 设为 -101 表示在默认 Sa-Token 过滤器之前执行，设为 -99 表示之后执行</span></span><br><span class="line">    frBean.setOrder(-<span class="number">101</span>);</span><br><span class="line"></span><br><span class="line">    <span class="keyword">return</span> frBean;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><hr><h2 id="2-6-WebFlux-中注册过滤器">2.6. WebFlux 中注册过滤器</h2><p>Spring WebFlux 不提供拦截器机制，因此如果你的项目基于 WebFlux（响应式编程模型），过滤器是实现路由鉴权的唯一选择。</p><p>Sa-Token 为 WebFlux 提供了 <code>SaReactorFilter</code>，写法与 <code>SaServletFilter</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></pre></td><td class="code"><pre><span class="line"><span class="comment">/**</span></span><br><span class="line"><span class="comment"> * Spring WebFlux 项目中注册 Sa-Token 过滤器</span></span><br><span class="line"><span class="comment"> * 除类名从 SaServletFilter 换为 SaReactorFilter 外，其余写法完全相同</span></span><br><span class="line"><span class="comment"> */</span></span><br><span class="line"><span class="meta">@Bean</span></span><br><span class="line"><span class="keyword">public</span> SaReactorFilter <span class="title function_">getSaReactorFilter</span><span class="params">()</span> &#123;</span><br><span class="line">    <span class="keyword">return</span> <span class="keyword">new</span> <span class="title class_">SaReactorFilter</span>()</span><br><span class="line">            .addInclude(<span class="string">&quot;/**&quot;</span>)</span><br><span class="line">            .addExclude(<span class="string">&quot;/favicon.ico&quot;</span>)</span><br><span class="line">            .setBeforeAuth(req -&gt; &#123;</span><br><span class="line">                <span class="comment">// WebFlux 中同样可以设置安全响应头</span></span><br><span class="line">            &#125;)</span><br><span class="line">            .setAuth(obj -&gt; &#123;</span><br><span class="line">                SaRouter.match(<span class="string">&quot;/**&quot;</span>)</span><br><span class="line">                        .notMatch(<span class="string">&quot;/auth/login&quot;</span>)</span><br><span class="line">                        .check(r -&gt; StpUtil.checkLogin());</span><br><span class="line">            &#125;)</span><br><span class="line">            .setError(e -&gt; SaResult.error(e.getMessage()));</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><div class="note info simple"><p>WebFlux 项目中引入的是 <code>sa-token-reactor-spring-boot3-starter</code> 依赖，而不是 <code>sa-token-spring-boot3-starter</code>，两个包提供的 API 基本相同，核心区别在于底层响应模型（阻塞 vs 响应式）。</p></div><hr><h2 id="2-7-本章总结">2.7. 本章总结</h2><p><strong>本章回顾</strong></p><p>本章系统讲解了 Sa-Token 全局过滤器的完整用法，并给出了明确的选型建议。选型层面，通过执行时机、异常处理、WebFlux 支持等六个维度的对比，得出&quot;SpringMVC 项目优先用拦截器，WebFlux 项目或需要拦截静态资源时才用过滤器&quot;的结论。在配置层面，详细讲解了 <code>addInclude / addExclude / setBeforeAuth / setAuth / setError</code> 五个配置方法的用途和约束范围，并重点说明了两个高频坑点：<code>setBeforeAuth</code> 不受 exclude 限制因此不适合放鉴权逻辑，<code>setError</code> 必须手动设置 <code>Content-Type</code> 响应头并使用 JSON 序列化工具才能返回正确格式。最后介绍了通过 <code>FilterRegistrationBean</code> 自定义执行顺序，以及 WebFlux 环境下只需将类名换为 <code>SaReactorFilter</code> 的迁移写法。</p><p><strong>核心汇总表</strong></p><table><thead><tr><th>选型维度</th><th>拦截器（推荐）</th><th>过滤器</th></tr></thead><tbody><tr><td>适用框架</td><td>Spring MVC</td><td>Spring MVC / WebFlux</td></tr><tr><td>异常处理</td><td>自动进入 <code>@ExceptionHandler</code></td><td>必须在 <code>setError</code> 中手动处理</td></tr><tr><td>注解鉴权</td><td>支持</td><td>不支持</td></tr><tr><td>静态资源拦截</td><td>不拦截</td><td>可拦截</td></tr><tr><td>推荐场景</td><td>绝大多数 Spring Boot 项目</td><td>WebFlux / 静态资源鉴权 / 最早期安全策略</td></tr></tbody></table><table><thead><tr><th>配置方法</th><th>作用</th><th>关键约束</th></tr></thead><tbody><tr><td><code>addInclude(path)</code></td><td>指定过滤器拦截的路径</td><td>支持 <code>**</code> 通配符</td></tr><tr><td><code>addExclude(path)</code></td><td>直接放行的路径</td><td>不进入 <code>setAuth</code>，但<strong>仍会</strong>进入 <code>setBeforeAuth</code></td></tr><tr><td><code>setBeforeAuth(lambda)</code></td><td>前置函数，认证前执行</td><td>不受 include/exclude 限制，适合设置安全响应头</td></tr><tr><td><code>setAuth(lambda)</code></td><td>认证函数，受 include/exclude 约束</td><td>写法与拦截器中 <code>SaRouter</code> 完全一致</td></tr><tr><td><code>setError(lambda)</code></td><td>异常处理函数，认证函数抛出异常时执行</td><td>返回值直接输出到前端，必须手动设置 Content-Type</td></tr></tbody></table><table><thead><tr><th>坑点</th><th>原因</th><th>解决方案</th></tr></thead><tbody><tr><td><code>setError</code> 前端收到纯文本</td><td>未设置 <code>Content-Type</code> 响应头</td><td>在 <code>setError</code> 内调用 <code>SaHolder.getResponse().setHeader(...)</code></td></tr><tr><td><code>setError</code> 返回非法 JSON</td><td><code>SaResult.toString()</code> 不是 JSON</td><td>使用 Jackson / Hutool 序列化，或手动拼接 JSON 字符串</td></tr><tr><td><code>setBeforeAuth</code> 对被排除的路径也生效</td><td>不受 include/exclude 限制</td><td>不在 <code>setBeforeAuth</code> 中做鉴权，只做无副作用的预处理</td></tr></tbody></table><table><thead><tr><th>安全响应头</th><th>作用</th></tr></thead><tbody><tr><td><code>X-Frame-Options: SAMEORIGIN</code></td><td>防止点击劫持，只允许同域 iframe 嵌入</td></tr><tr><td><code>X-XSS-Protection: 1; mode=block</code></td><td>启用浏览器 XSS 过滤，检测到攻击时停止渲染</td></tr><tr><td><code>X-Content-Type-Options: nosniff</code></td><td>禁止浏览器进行内容类型嗅探</td></tr></tbody></table></div>]]></content>
    
    
      
      
        
        
    <summary type="html">&lt;div id=&quot;postchat_postcontent&quot;&gt;&lt;h1&gt;第一章.</summary>
        
      
    
    
    
    <category term="后端技术" scheme="https://prorise666.site/categories/%E5%90%8E%E7%AB%AF%E6%8A%80%E6%9C%AF/"/>
    
    <category term="Java" scheme="https://prorise666.site/categories/%E5%90%8E%E7%AB%AF%E6%8A%80%E6%9C%AF/Java/"/>
    
    <category term="Spring系列" scheme="https://prorise666.site/categories/%E5%90%8E%E7%AB%AF%E6%8A%80%E6%9C%AF/Java/Spring%E7%B3%BB%E5%88%97/"/>
    
    <category term="登录注册系列" scheme="https://prorise666.site/categories/%E5%90%8E%E7%AB%AF%E6%8A%80%E6%9C%AF/Java/Spring%E7%B3%BB%E5%88%97/%E7%99%BB%E5%BD%95%E6%B3%A8%E5%86%8C%E7%B3%BB%E5%88%97/"/>
    
    <category term="Sa-Token" scheme="https://prorise666.site/categories/%E5%90%8E%E7%AB%AF%E6%8A%80%E6%9C%AF/Java/Spring%E7%B3%BB%E5%88%97/%E7%99%BB%E5%BD%95%E6%B3%A8%E5%86%8C%E7%B3%BB%E5%88%97/Sa-Token/"/>
    
    
    <category term="Spring生态篇" scheme="https://prorise666.site/tags/Spring%E7%94%9F%E6%80%81%E7%AF%87/"/>
    
    <category term="Sa-Token系列篇" scheme="https://prorise666.site/tags/Sa-Token%E7%B3%BB%E5%88%97%E7%AF%87/"/>
    
  </entry>
  
  <entry>
    <title>登录注册番外篇（二） - Sa-Token：权限认证完全指南</title>
    <link href="https://prorise666.site/posts/66742.html"/>
    <id>https://prorise666.site/posts/66742.html</id>
    <published>2026-02-08T05:38:17.000Z</published>
    <updated>2026-03-12T09:18:16.955Z</updated>
    
    <content type="html"><![CDATA[<div id="postchat_postcontent"><h1>第一章. 告诉框架&quot;谁能做什么&quot;——StpInterface</h1><p><strong>阶段式学习路径</strong></p><p>番外篇二完整讲解了登录会话的生命周期——Token 怎么生成、怎么续签、怎么下线。但到目前为止，我们的项目只解决了&quot;你是谁&quot;这个问题，任何登录用户都能访问任何接口。</p><p>要实现&quot;张三能发文章，李四只能看文章，王五连登录都进不来&quot;，还差一个关键环节：<strong>告诉框架每个用户有哪些权限</strong>。这就是本章的主角 <code>StpInterface</code>。</p><hr><h2 id="1-1-为什么是接口而不是配置">1.1. 为什么是接口而不是配置</h2><p>在动手写代码之前，先理解一个设计决策：Sa-Token 为什么不直接提供&quot;查数据库获取权限&quot;的功能，而是要求开发者实现一个接口？</p><p>不同项目的权限数据来源千差万别。有的项目权限数据存在 MySQL 的三张关联表里，有的存在 MongoDB 的文档里，有的通过远程 RPC 调用其他服务获取，有的小型项目直接硬编码在代码里。如果 Sa-Token 内置了&quot;查 MySQL 获取权限&quot;的逻辑，那么使用 MongoDB 或 RPC 的项目就完全无法使用这个框架。</p><p>通过 <code>StpInterface</code> 接口，Sa-Token 把&quot;权限数据从哪来&quot;的决策权完全交给了开发者。框架只定义两个方法的签名：</p><figure class="highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">// 给定用户 ID，返回该用户拥有的权限码列表</span></span><br><span class="line">List&lt;String&gt; <span class="title function_">getPermissionList</span><span class="params">(Object loginId, String loginType)</span>;</span><br><span class="line"></span><br><span class="line"><span class="comment">// 给定用户 ID，返回该用户拥有的角色标识列表</span></span><br><span class="line">List&lt;String&gt; <span class="title function_">getRoleList</span><span class="params">(Object loginId, String loginType)</span>;</span><br></pre></td></tr></table></figure><p>你在实现里查数据库、查缓存、查配置文件都行，框架只认你返回的 <code>List&lt;String&gt;</code>。</p><p>两个方法都有 <code>loginType</code> 参数，这是为多业务线场景设计的——一个电商系统可能同时存在普通用户（<code>loginType = &quot;login&quot;</code>）和商家（<code>loginType = &quot;merchant&quot;</code>），两套账号体系对应完全不同的权限表。通过 <code>loginType</code> 区分，可以在同一个实现类里根据不同业务线返回不同的权限数据。本篇只用默认的 <code>&quot;login&quot;</code> 类型，多业务线场景留到番外篇四展开。</p><hr><h2 id="1-2-Sa-Token-的权限模型：字符串即权限">1.2. Sa-Token 的权限模型：字符串即权限</h2><p>在实现 <code>StpInterface</code> 之前，还有一个基础概念需要建立：Sa-Token 的权限模型。</p><p>它非常简单——<strong>权限用字符串表示，角色也用字符串表示</strong>。框架不强制你使用 RBAC 模型，不要求你建特定的数据库表，不限制你的权限编码格式。它只需要你回答两个问题：这个用户有哪些权限码？这个用户有哪些角色标识？</p><p>权限码的格式完全由你决定。业界最常见的格式是 <code>资源:操作</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></pre></td><td class="code"><pre><span class="line">user:add          用户新增</span><br><span class="line">user:delete       用户删除</span><br><span class="line">user:update       用户修改</span><br><span class="line">user:view         用户查看</span><br><span class="line">article:publish   文章发布</span><br><span class="line">article:review    文章审核</span><br><span class="line">order:cancel      订单取消</span><br></pre></td></tr></table></figure><p>角色标识通常用简单的单词：</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></pre></td><td class="code"><pre><span class="line">admin     管理员</span><br><span class="line">editor    编辑</span><br><span class="line">user      普通用户</span><br></pre></td></tr></table></figure><p>Sa-Token 在鉴权时会调用你实现的 <code>StpInterface</code>，拿到权限列表和角色列表后，判断列表中是否包含目标值，决定是否放行。整个校验逻辑由框架完成，你只负责提供数据。</p><hr><h2 id="1-3-实现-StpInterface">1.3. 实现 StpInterface</h2><p>现在把权限数据源接入进来。为了聚焦 Sa-Token 本身的鉴权机制，我们先用硬编码 Map 模拟权限数据，后续替换为数据库查询时只需要修改这一个类，其余代码完全不用动。</p><p>在 <code>com.example.authsatoken</code> 包下新建 <code>auth</code> 子包，然后创建实现类：</p><p>📄 <code>src/main/java/com/example/authsatoken/auth/StpInterfaceImpl.java</code>（新建）</p><figure class="highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><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></pre></td><td class="code"><pre><span class="line"><span class="keyword">package</span> com.example.authsatoken.auth;</span><br><span class="line"></span><br><span class="line"><span class="keyword">import</span> cn.dev33.satoken.stp.StpInterface;</span><br><span class="line"><span class="keyword">import</span> org.springframework.stereotype.Component;</span><br><span class="line"></span><br><span class="line"><span class="keyword">import</span> java.util.ArrayList;</span><br><span class="line"><span class="keyword">import</span> java.util.List;</span><br><span class="line"><span class="keyword">import</span> java.util.Map;</span><br><span class="line"></span><br><span class="line"><span class="comment">/**</span></span><br><span class="line"><span class="comment"> * 权限数据源实现类</span></span><br><span class="line"><span class="comment"> * Sa-Token 每次进行权限校验时，都会调用此类的方法获取当前用户的权限和角色数据</span></span><br><span class="line"><span class="comment"> *</span></span><br><span class="line"><span class="comment"> * 当前使用硬编码 Map 模拟，实际项目中替换为数据库查询即可，其余代码无需改动</span></span><br><span class="line"><span class="comment"> */</span></span><br><span class="line"><span class="meta">@Component</span></span><br><span class="line"><span class="keyword">public</span> <span class="keyword">class</span> <span class="title class_">StpInterfaceImpl</span> <span class="keyword">implements</span> <span class="title class_">StpInterface</span> &#123;</span><br><span class="line"></span><br><span class="line">    <span class="comment">/**</span></span><br><span class="line"><span class="comment">     * 角色 → 权限码列表的映射</span></span><br><span class="line"><span class="comment">     *</span></span><br><span class="line"><span class="comment">     * admin 角色：拥有用户模块的全部操作权限</span></span><br><span class="line"><span class="comment">     * user  角色：只有查看类权限</span></span><br><span class="line"><span class="comment">     */</span></span><br><span class="line">    <span class="keyword">private</span> <span class="keyword">static</span> <span class="keyword">final</span> Map&lt;String, List&lt;String&gt;&gt; ROLE_PERMISSION_MAP = Map.of(</span><br><span class="line">            <span class="string">&quot;admin&quot;</span>, List.of(<span class="string">&quot;user:add&quot;</span>, <span class="string">&quot;user:delete&quot;</span>, <span class="string">&quot;user:update&quot;</span>, <span class="string">&quot;user:view&quot;</span>),</span><br><span class="line">            <span class="string">&quot;user&quot;</span>,  List.of(<span class="string">&quot;user:view&quot;</span>, <span class="string">&quot;article:view&quot;</span>)</span><br><span class="line">    );</span><br><span class="line"></span><br><span class="line">    <span class="comment">/**</span></span><br><span class="line"><span class="comment">     * 用户 ID（String 形式）→ 角色标识列表的映射</span></span><br><span class="line"><span class="comment">     *</span></span><br><span class="line"><span class="comment">     * 10001 对应 admin 账号，拥有 admin 角色</span></span><br><span class="line"><span class="comment">     * 10002 对应 user  账号，拥有 user  角色</span></span><br><span class="line"><span class="comment">     *</span></span><br><span class="line"><span class="comment">     * 注意：Sa-Token 内部将 loginId 统一转为 String 存储，</span></span><br><span class="line"><span class="comment">     * 所以这里的 Map key 必须是 String 类型，不能是 Long</span></span><br><span class="line"><span class="comment">     */</span></span><br><span class="line">    <span class="keyword">private</span> <span class="keyword">static</span> <span class="keyword">final</span> Map&lt;String, List&lt;String&gt;&gt; USER_ROLE_MAP = Map.of(</span><br><span class="line">            <span class="string">&quot;10001&quot;</span>, List.of(<span class="string">&quot;admin&quot;</span>),</span><br><span class="line">            <span class="string">&quot;10002&quot;</span>, List.of(<span class="string">&quot;user&quot;</span>)</span><br><span class="line">    );</span><br><span class="line"></span><br><span class="line">    <span class="comment">/**</span></span><br><span class="line"><span class="comment">     * 返回指定账号所拥有的权限码集合</span></span><br><span class="line"><span class="comment">     *</span></span><br><span class="line"><span class="comment">     * 本实现采用 RBAC 思路：先查角色，再根据角色聚合权限</span></span><br><span class="line"><span class="comment">     * 实际项目中建议对此方法的返回值做缓存（如 Redis），避免每次请求都查数据库</span></span><br><span class="line"><span class="comment">     */</span></span><br><span class="line">    <span class="meta">@Override</span></span><br><span class="line">    <span class="keyword">public</span> List&lt;String&gt; <span class="title function_">getPermissionList</span><span class="params">(Object loginId, String loginType)</span> &#123;</span><br><span class="line">        List&lt;String&gt; permissions = <span class="keyword">new</span> <span class="title class_">ArrayList</span>&lt;&gt;();</span><br><span class="line">        <span class="comment">// 先拿到该用户的所有角色，再根据角色聚合权限码</span></span><br><span class="line">        List&lt;String&gt; roles = getRoleList(loginId, loginType);</span><br><span class="line">        <span class="keyword">for</span> (String role : roles) &#123;</span><br><span class="line">            List&lt;String&gt; perms = ROLE_PERMISSION_MAP.get(role);</span><br><span class="line">            <span class="keyword">if</span> (perms != <span class="literal">null</span>) &#123;</span><br><span class="line">                permissions.addAll(perms);</span><br><span class="line">            &#125;</span><br><span class="line">        &#125;</span><br><span class="line">        <span class="keyword">return</span> permissions;</span><br><span class="line">    &#125;</span><br><span class="line"></span><br><span class="line">    <span class="comment">/**</span></span><br><span class="line"><span class="comment">     * 返回指定账号所拥有的角色标识集合</span></span><br><span class="line"><span class="comment">     */</span></span><br><span class="line">    <span class="meta">@Override</span></span><br><span class="line">    <span class="keyword">public</span> List&lt;String&gt; <span class="title function_">getRoleList</span><span class="params">(Object loginId, String loginType)</span> &#123;</span><br><span class="line">        <span class="comment">// loginId 是 Object 类型，必须转为 String 才能与 Map 的 key 匹配</span></span><br><span class="line">        List&lt;String&gt; roles = USER_ROLE_MAP.get(String.valueOf(loginId));</span><br><span class="line">        <span class="comment">// 若用户 ID 不在映射中（如测试账号），返回空列表而非 null，避免 NPE</span></span><br><span class="line">        <span class="keyword">return</span> roles != <span class="literal">null</span> ? roles : List.of();</span><br><span class="line">    &#125;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><hr><h2 id="1-4-与登录接口的数据对齐">1.4. 与登录接口的数据对齐</h2><p><code>StpInterfaceImpl</code> 里 <code>USER_ROLE_MAP</code> 的 key 是用户 ID，而用户 ID 是登录时由我们的业务逻辑决定的。需要确认两边的数据完全对齐。</p><p>回顾番外篇二第一章升级后的登录接口，<code>USER_DB</code> 的映射是：</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line">admin → userId 10001</span><br><span class="line">user  → userId 10002</span><br></pre></td></tr></table></figure><p>对应 <code>USER_ROLE_MAP</code>：</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line">&quot;10001&quot; → admin 角色</span><br><span class="line">&quot;10002&quot; → user  角色</span><br></pre></td></tr></table></figure><p>两边完全对齐。<code>admin</code> 账号登录后，Sa-Token 存储的 loginId 是 <code>&quot;10001&quot;</code>，当框架调用 <code>getRoleList(&quot;10001&quot;, &quot;login&quot;)</code> 时，能从 <code>USER_ROLE_MAP</code> 中正确取到 <code>[&quot;admin&quot;]</code>，进而从 <code>ROLE_PERMISSION_MAP</code> 中聚合出 <code>[&quot;user:add&quot;, &quot;user:delete&quot;, &quot;user:update&quot;, &quot;user:view&quot;]</code>。</p><p>整条数据流是：<strong>登录时的 userId → Redis 存储 → 鉴权时框架调用 StpInterface → 返回权限数据 → 框架完成校验</strong>。任何一环的 ID 不一致都会导致权限查不到，排查时从这条链路逐段检查。</p><hr><h2 id="1-5-本章总结">1.5. 本章总结</h2><p><strong>本章回顾</strong></p><p>本章完成了权限认证体系的数据层建设。我们首先从设计角度理解了 <code>StpInterface</code> 接口方案的价值：框架只约定接口规范，权限数据来源完全由开发者决定，<code>loginType</code> 参数进一步支持多业务线场景。随后建立了 Sa-Token 权限模型的基础认知：权限码和角色标识都是字符串，<code>资源:操作</code> 是最常见的权限码格式。在实现层面，我们创建了 <code>StpInterfaceImpl</code>，用两个硬编码 Map 分别维护&quot;用户 ID → 角色&quot;和&quot;角色 → 权限码&quot;的映射，体现了 RBAC 的核心两级查询结构。最后确认了权限数据与登录 userId 的对齐关系，确保整条数据流连贯。</p><p><strong>核心汇总表</strong></p><table><thead><tr><th>组件</th><th>职责</th><th>关键点</th></tr></thead><tbody><tr><td><code>StpInterface</code></td><td>定义权限数据查询规范</td><td>框架只认接口，不关心实现细节</td></tr><tr><td><code>StpInterfaceImpl</code></td><td>提供具体的权限数据</td><td>必须加 <code>@Component</code>，全项目唯一</td></tr><tr><td><code>getPermissionList()</code></td><td>返回用户的权限码列表</td><td>loginId 实际是 String，注意类型转换</td></tr><tr><td><code>getRoleList()</code></td><td>返回用户的角色标识列表</td><td>未找到时返回空列表，避免 NPE</td></tr></tbody></table><table><thead><tr><th>权限码格式</th><th>示例</th><th>含义</th></tr></thead><tbody><tr><td><code>资源:操作</code></td><td><code>user:add</code></td><td>用户模块新增操作</td></tr><tr><td><code>资源:*</code></td><td><code>user:*</code></td><td>用户模块所有操作（通配符，第五章讲解）</td></tr><tr><td><code>*</code></td><td><code>*</code></td><td>所有模块所有操作（超级管理员，第五章讲解）</td></tr></tbody></table><table><thead><tr><th>数据对齐检查点</th><th>说明</th></tr></thead><tbody><tr><td>登录接口的 userId</td><td>决定了 loginId 存入 Redis 的值</td></tr><tr><td><code>USER_ROLE_MAP</code> 的 key</td><td>必须是 String 类型，与 Redis 中的 loginId 完全一致</td></tr><tr><td><code>String.valueOf(loginId)</code></td><td>从 Object 类型转换，防止类型不匹配导致查询结果为 null</td></tr></tbody></table><h1>第二章. 注解鉴权——声明式的权限控制</h1><p><strong>阶段式学习路径</strong></p><p>第一章完成了权限认证的数据层——框架现在知道每个用户有哪些权限和角色了。但&quot;知道归知道&quot;，还差最后一步：在接口上声明&quot;访问这个接口需要什么权限&quot;，让框架在请求到达方法之前自动完成校验。</p><p>注解鉴权就是最直观的表达方式。把权限要求写在方法上，业务代码和鉴权逻辑一目了然，互不干扰。本章先完成注解生效的前置步骤，再系统覆盖 Sa-Token 提供的全部 8 种鉴权注解。</p><hr><h2 id="2-1-前置步骤：注册拦截器">2.1. 前置步骤：注册拦截器</h2><p>Sa-Token 使用全局拦截器完成注解鉴权功能，为了不为项目带来不必要的性能负担，拦截器默认处于关闭状态。因此，<strong>使用注解鉴权之前，必须手动将 <code>SaInterceptor</code> 注册到项目中</strong>——这是最常见的入门坑，注解加了但请求完全不受拦截，原因往往就在这里。</p><p>在 <code>com.example.authsatoken</code> 包下新建 <code>config</code> 子包，创建配置类：</p><p>📄 <code>src/main/java/com/example/authsatoken/config/SaTokenConfig.java</code>（新建）</p><figure class="highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><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><span class="line"><span class="keyword">package</span> com.example.authsatoken.config;</span><br><span class="line"></span><br><span class="line"><span class="keyword">import</span> cn.dev33.satoken.interceptor.SaInterceptor;</span><br><span class="line"><span class="keyword">import</span> org.springframework.context.annotation.Configuration;</span><br><span class="line"><span class="keyword">import</span> org.springframework.web.servlet.config.annotation.InterceptorRegistry;</span><br><span class="line"><span class="keyword">import</span> org.springframework.web.servlet.config.annotation.WebMvcConfigurer;</span><br><span class="line"></span><br><span class="line"><span class="comment">/**</span></span><br><span class="line"><span class="comment"> * Sa-Token 拦截器配置</span></span><br><span class="line"><span class="comment"> * 注册 SaInterceptor，使 Controller 方法上的鉴权注解生效</span></span><br><span class="line"><span class="comment"> *</span></span><br><span class="line"><span class="comment"> * 当前配置：无参版本，只开启注解鉴权，不附加任何路由规则</span></span><br><span class="line"><span class="comment"> * 第三章将升级为有参版本，在 Lambda 中配置路由级别的访问策略</span></span><br><span class="line"><span class="comment"> */</span></span><br><span class="line"><span class="meta">@Configuration</span></span><br><span class="line"><span class="keyword">public</span> <span class="keyword">class</span> <span class="title class_">SaTokenConfig</span> <span class="keyword">implements</span> <span class="title class_">WebMvcConfigurer</span> &#123;</span><br><span class="line"></span><br><span class="line">    <span class="meta">@Override</span></span><br><span class="line">    <span class="keyword">public</span> <span class="keyword">void</span> <span class="title function_">addInterceptors</span><span class="params">(InterceptorRegistry registry)</span> &#123;</span><br><span class="line">        <span class="comment">// new SaInterceptor() 不传参数：扫描注解并执行鉴权，没有注解的接口直接放行</span></span><br><span class="line">        registry.addInterceptor(<span class="keyword">new</span> <span class="title class_">SaInterceptor</span>())</span><br><span class="line">                .addPathPatterns(<span class="string">&quot;/**&quot;</span>);</span><br><span class="line">    &#125;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p><code>new SaInterceptor()</code> 不传参数时，拦截器只做一件事：扫描 Controller 方法上是否有 Sa-Token 的鉴权注解，有则执行对应校验，没有则直接放行。这意味着当前阶段未加注解的接口对所有人开放，包括未登录用户——第三章引入路由规则后才会改变这个默认行为。</p><hr><h2 id="2-2-补全异常处理">2.2. 补全异常处理</h2><p>注解鉴权失败时，Sa-Token 会抛出两种新的异常类型，它们不是 <code>NotLoginException</code>，需要在 <code>GlobalExceptionHandler</code> 中单独捕获。</p><ul><li><code>NotPermissionException</code>：权限码校验失败，用户没有访问该接口所需的权限码</li><li><code>NotRoleException</code>：角色校验失败，用户没有访问该接口所需的角色</li></ul><p>这两种异常对应的 HTTP 状态码是 <code>403</code>（Forbidden）</p><p>与番外篇二第四章的 <code>401</code>（Unauthorized）语义不同：</p><ul><li><p><strong>401 表示&quot;你还没有证明你是谁&quot;</strong></p></li><li><p><strong>403 表示&quot;我知道你是谁，但你没有权限做这件事&quot;</strong>。</p></li></ul><p>前端可以根据状态码的不同决定是跳转到登录页（401）还是展示&quot;权限不足&quot;提示（403）。</p><p>📄 <code>src/main/java/com/example/authsatoken/exception/GlobalExceptionHandler.java</code>（修改，追加两个方法）</p><p>在已有的 <code>handleNotLoginException</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></pre></td><td class="code"><pre><span class="line"><span class="keyword">import</span> cn.dev33.satoken.exception.NotPermissionException;</span><br><span class="line"><span class="keyword">import</span> cn.dev33.satoken.exception.NotRoleException;</span><br><span class="line"></span><br><span class="line"><span class="comment">/**</span></span><br><span class="line"><span class="comment"> * 捕获权限码校验失败异常</span></span><br><span class="line"><span class="comment"> * 触发场景：用户已登录，但不拥有接口要求的权限码</span></span><br><span class="line"><span class="comment"> * e.getPermission() 返回校验失败的具体权限码，方便日志定位</span></span><br><span class="line"><span class="comment"> */</span></span><br><span class="line"><span class="meta">@ExceptionHandler(NotPermissionException.class)</span></span><br><span class="line"><span class="keyword">public</span> SaResult <span class="title function_">handleNotPermissionException</span><span class="params">(NotPermissionException e)</span> &#123;</span><br><span class="line">    <span class="keyword">return</span> SaResult.error(<span class="string">&quot;权限不足，缺少权限：&quot;</span> + e.getPermission()).setCode(<span class="number">403</span>);</span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="comment">/**</span></span><br><span class="line"><span class="comment"> * 捕获角色校验失败异常</span></span><br><span class="line"><span class="comment"> * 触发场景：用户已登录，但不拥有接口要求的角色</span></span><br><span class="line"><span class="comment"> * e.getRole() 返回校验失败的具体角色标识</span></span><br><span class="line"><span class="comment"> */</span></span><br><span class="line"><span class="meta">@ExceptionHandler(NotRoleException.class)</span></span><br><span class="line"><span class="keyword">public</span> SaResult <span class="title function_">handleNotRoleException</span><span class="params">(NotRoleException e)</span> &#123;</span><br><span class="line">    <span class="keyword">return</span> SaResult.error(<span class="string">&quot;权限不足，缺少角色：&quot;</span> + e.getRole()).setCode(<span class="number">403</span>);</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><hr><h2 id="2-3-Sa-Token-全部注解一览">2.3. Sa-Token 全部注解一览</h2><p>Sa-Token 一共提供 8 种鉴权注解，覆盖了从登录校验到 API 签名的各类场景。以上注解<strong>都可以加在类上</strong>，代表为这个类下的所有方法统一进行鉴权。</p><p>我们在 <code>com.example.authsatoken.controller</code> 包下新建演示控制器：</p><p>📄 <code>src/main/java/com/example/authsatoken/controller/PermissionController.java</code>（新建）</p><hr><h3 id="2-3-1-SaCheckLogin：登录校验">2.3.1. <code>@SaCheckLogin</code>：登录校验</h3><p>最基础的门槛——只校验&quot;是否已登录&quot;，不关心角色和权限：</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><span class="line"><span class="comment">// 登录校验：只有登录之后才能进入该方法</span></span><br><span class="line"><span class="meta">@SaCheckLogin</span></span><br><span class="line"><span class="meta">@GetMapping(&quot;/loginRequired&quot;)</span></span><br><span class="line"><span class="keyword">public</span> SaResult <span class="title function_">loginRequired</span><span class="params">()</span> &#123;</span><br><span class="line">    <span class="keyword">return</span> SaResult.ok(<span class="string">&quot;已通过登录校验&quot;</span>);</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>它和手动调用 <code>StpUtil.checkLogin()</code> 的效果完全等价，只是换成了声明式写法。适合所有登录用户都能访问的接口，比如&quot;获取个人信息&quot;。</p><hr><h3 id="2-3-2-SaCheckRole：角色校验">2.3.2. <code>@SaCheckRole</code>：角色校验</h3><p>校验当前用户是否拥有指定角色。框架调用 <code>StpInterfaceImpl.getRoleList()</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></pre></td><td class="code"><pre><span class="line"><span class="comment">// 角色校验（单角色）：必须拥有 admin 角色</span></span><br><span class="line"><span class="meta">@SaCheckRole(&quot;admin&quot;)</span></span><br><span class="line"><span class="meta">@GetMapping(&quot;/adminOnly&quot;)</span></span><br><span class="line"><span class="keyword">public</span> SaResult <span class="title function_">adminOnly</span><span class="params">()</span> &#123;</span><br><span class="line">    <span class="keyword">return</span> SaResult.ok(<span class="string">&quot;你拥有 admin 角色&quot;</span>);</span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="comment">// 角色校验（多角色 AND 模式，默认）：必须同时拥有 admin 和 editor 两个角色</span></span><br><span class="line"><span class="meta">@SaCheckRole(&#123;&quot;admin&quot;, &quot;editor&quot;&#125;)</span></span><br><span class="line"><span class="meta">@GetMapping(&quot;/adminAndEditor&quot;)</span></span><br><span class="line"><span class="keyword">public</span> SaResult <span class="title function_">adminAndEditor</span><span class="params">()</span> &#123;</span><br><span class="line">    <span class="keyword">return</span> SaResult.ok(<span class="string">&quot;你同时拥有 admin 和 editor 角色&quot;</span>);</span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="comment">// 角色校验（多角色 OR 模式）：拥有 admin 或 editor 任一角色即可</span></span><br><span class="line"><span class="meta">@SaCheckRole(value = &#123;&quot;admin&quot;, &quot;editor&quot;&#125;, mode = SaMode.OR)</span></span><br><span class="line"><span class="meta">@GetMapping(&quot;/adminOrEditor&quot;)</span></span><br><span class="line"><span class="keyword">public</span> SaResult <span class="title function_">adminOrEditor</span><span class="params">()</span> &#123;</span><br><span class="line">    <span class="keyword">return</span> SaResult.ok(<span class="string">&quot;你拥有 admin 或 editor 角色&quot;</span>);</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p><code>mode</code> 有两种取值：<code>SaMode.AND</code>（默认）要求全部满足；<code>SaMode.OR</code> 满足其一即可。</p><hr><h3 id="2-3-3-SaCheckPermission：权限码校验">2.3.3. <code>@SaCheckPermission</code>：权限码校验</h3><p>校验当前用户是否拥有指定权限码。框架调用 <code>StpInterfaceImpl.getPermissionList()</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></pre></td><td class="code"><pre><span class="line"><span class="comment">// 权限码校验（单权限）：必须拥有 user:add 权限</span></span><br><span class="line"><span class="meta">@SaCheckPermission(&quot;user:add&quot;)</span></span><br><span class="line"><span class="meta">@GetMapping(&quot;/canAddUser&quot;)</span></span><br><span class="line"><span class="keyword">public</span> SaResult <span class="title function_">canAddUser</span><span class="params">()</span> &#123;</span><br><span class="line">    <span class="keyword">return</span> SaResult.ok(<span class="string">&quot;你拥有用户新增权限&quot;</span>);</span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="comment">// 权限码校验（多权限 AND 模式，默认）：必须同时拥有 user:add 和 user:delete</span></span><br><span class="line"><span class="meta">@SaCheckPermission(&#123;&quot;user:add&quot;, &quot;user:delete&quot;&#125;)</span></span><br><span class="line"><span class="meta">@GetMapping(&quot;/canAddAndDeleteUser&quot;)</span></span><br><span class="line"><span class="keyword">public</span> SaResult <span class="title function_">canAddAndDeleteUser</span><span class="params">()</span> &#123;</span><br><span class="line">    <span class="keyword">return</span> SaResult.ok(<span class="string">&quot;你同时拥有用户新增和删除权限&quot;</span>);</span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="comment">// 权限码校验（多权限 OR 模式）：拥有 user:add 或 user:delete 任一即可</span></span><br><span class="line"><span class="meta">@SaCheckPermission(value = &#123;&quot;user:add&quot;, &quot;user:delete&quot;&#125;, mode = SaMode.OR)</span></span><br><span class="line"><span class="meta">@GetMapping(&quot;/canAddOrDeleteUser&quot;)</span></span><br><span class="line"><span class="keyword">public</span> SaResult <span class="title function_">canAddOrDeleteUser</span><span class="params">()</span> &#123;</span><br><span class="line">    <span class="keyword">return</span> SaResult.ok(<span class="string">&quot;你拥有用户新增或删除权限&quot;</span>);</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p><strong><code>orRole</code>：角色权限双重 OR 校验</strong></p><p>假设有这样的业务场景：接口在&quot;拥有 <code>user:add</code> 权限&quot;或&quot;拥有 <code>admin</code> 角色&quot;时均可访问。<code>@SaCheckPermission</code> 提供了 <code>orRole</code> 参数来处理这种跨类型 OR 条件，比嵌套 <code>@SaCheckOr</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><span class="line"><span class="comment">// 拥有 user:add 权限，或者拥有 admin 角色，满足其一即可</span></span><br><span class="line"><span class="meta">@SaCheckPermission(value = &quot;user:add&quot;, orRole = &quot;admin&quot;)</span></span><br><span class="line"><span class="meta">@GetMapping(&quot;/addUserOrAdmin&quot;)</span></span><br><span class="line"><span class="keyword">public</span> SaResult <span class="title function_">addUserOrAdmin</span><span class="params">()</span> &#123;</span><br><span class="line">    <span class="keyword">return</span> SaResult.ok(<span class="string">&quot;通过权限或角色校验&quot;</span>);</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p><code>orRole</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></pre></td><td class="code"><pre><span class="line"><span class="comment">// 写法一：需要拥有 admin 角色</span></span><br><span class="line"><span class="meta">@SaCheckPermission(value = &quot;user:add&quot;, orRole = &quot;admin&quot;)</span></span><br><span class="line"></span><br><span class="line"><span class="comment">// 写法二：拥有 admin、manager、staff 三个角色中的任意一个即可（OR 关系）</span></span><br><span class="line"><span class="meta">@SaCheckPermission(value = &quot;user:add&quot;, orRole = &#123;&quot;admin&quot;, &quot;manager&quot;, &quot;staff&quot;&#125;)</span></span><br><span class="line"></span><br><span class="line"><span class="comment">// 写法三：必须同时拥有 admin、manager、staff 三个角色（AND 关系）</span></span><br><span class="line"><span class="comment">// 注意：写法三是把三个角色写在同一个字符串里，逗号在同一元素内部</span></span><br><span class="line"><span class="meta">@SaCheckPermission(value = &quot;user:add&quot;, orRole = &#123;&quot;admin, manager, staff&quot;&#125;)</span></span><br></pre></td></tr></table></figure><p>写法二和写法三的区别在于<strong>数组元素的数量</strong>：多个元素是 OR 关系，单个元素内部用逗号分隔是 AND 关系。</p><hr><h3 id="2-3-4-SaCheckSafe：二级认证校验">2.3.4. <code>@SaCheckSafe</code>：二级认证校验</h3><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></pre></td><td class="code"><pre><span class="line"><span class="comment">// 必须通过默认二级认证才能访问</span></span><br><span class="line"><span class="meta">@SaCheckSafe</span></span><br><span class="line"><span class="meta">@GetMapping(&quot;/safeRequired&quot;)</span></span><br><span class="line"><span class="keyword">public</span> SaResult <span class="title function_">safeRequired</span><span class="params">()</span> &#123;</span><br><span class="line">    <span class="keyword">return</span> SaResult.ok(<span class="string">&quot;通过二级认证&quot;</span>);</span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="comment">// 必须通过指定业务标识的二级认证才能访问</span></span><br><span class="line"><span class="meta">@SaCheckSafe(&quot;delete-project&quot;)</span></span><br><span class="line"><span class="meta">@GetMapping(&quot;/safeDeleteProject&quot;)</span></span><br><span class="line"><span class="keyword">public</span> SaResult <span class="title function_">safeDeleteProject</span><span class="params">()</span> &#123;</span><br><span class="line">    <span class="keyword">return</span> SaResult.ok(<span class="string">&quot;通过删除项目的二级认证&quot;</span>);</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><hr><h3 id="2-3-5-SaCheckDisable：账号封禁服务校验">2.3.5. <code>@SaCheckDisable</code>：账号封禁服务校验</h3><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><span class="line"><span class="comment">// 校验当前账号是否被封禁（无服务标识，校验整体封禁）</span></span><br><span class="line"><span class="meta">@SaCheckDisable</span></span><br><span class="line"><span class="meta">@PostMapping(&quot;/send&quot;)</span></span><br><span class="line"><span class="keyword">public</span> SaResult <span class="title function_">send</span><span class="params">()</span> &#123;</span><br><span class="line">    <span class="keyword">return</span> SaResult.ok(<span class="string">&quot;消息发送成功&quot;</span>);</span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="comment">// 校验当前账号的 comment 服务是否被封禁</span></span><br><span class="line"><span class="meta">@SaCheckDisable(&quot;comment&quot;)</span></span><br><span class="line"><span class="meta">@PostMapping(&quot;/comment&quot;)</span></span><br><span class="line"><span class="keyword">public</span> SaResult <span class="title function_">comment</span><span class="params">()</span> &#123;</span><br><span class="line">    <span class="keyword">return</span> SaResult.ok(<span class="string">&quot;评论发布成功&quot;</span>);</span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="comment">// 同时校验多个服务，任意一个被封禁就无法进入方法</span></span><br><span class="line"><span class="meta">@SaCheckDisable(&#123;&quot;comment&quot;, &quot;place-order&quot;, &quot;open-shop&quot;&#125;)</span></span><br><span class="line"><span class="meta">@PostMapping(&quot;/multiCheck&quot;)</span></span><br><span class="line"><span class="keyword">public</span> SaResult <span class="title function_">multiCheck</span><span class="params">()</span> &#123;</span><br><span class="line">    <span class="keyword">return</span> SaResult.ok(<span class="string">&quot;多服务校验通过&quot;</span>);</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><hr><h3 id="2-3-6-SaCheckHttpBasic-SaCheckHttpDigest：HTTP-认证校验">2.3.6. <code>@SaCheckHttpBasic</code> / <code>@SaCheckHttpDigest</code>：HTTP 认证校验</h3><p>用于需要 HTTP Basic 或 HTTP Digest 认证的接口，常见于内部 API 或监控端点：</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><span class="line"><span class="comment">// 只有通过 Http Basic 认证后才能进入该方法</span></span><br><span class="line"><span class="comment">// account 格式为 &quot;用户名:密码&quot;</span></span><br><span class="line"><span class="meta">@SaCheckHttpBasic(account = &quot;sa:123456&quot;)</span></span><br><span class="line"><span class="meta">@GetMapping(&quot;/basicAuth&quot;)</span></span><br><span class="line"><span class="keyword">public</span> SaResult <span class="title function_">basicAuth</span><span class="params">()</span> &#123;</span><br><span class="line">    <span class="keyword">return</span> SaResult.ok(<span class="string">&quot;通过 Http Basic 认证&quot;</span>);</span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="comment">// 只有通过 Http Digest 认证后才能进入该方法</span></span><br><span class="line"><span class="meta">@SaCheckHttpDigest(value = &quot;sa:123456&quot;)</span></span><br><span class="line"><span class="meta">@GetMapping(&quot;/digestAuth&quot;)</span></span><br><span class="line"><span class="keyword">public</span> SaResult <span class="title function_">digestAuth</span><span class="params">()</span> &#123;</span><br><span class="line">    <span class="keyword">return</span> SaResult.ok(<span class="string">&quot;通过 Http Digest 认证&quot;</span>);</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>这两个注解与 Token 体系无关，是独立的 HTTP 协议层认证，通常用于保护不需要完整登录流程的运维接口。</p><hr><h3 id="2-3-7-SaCheckSign：API-签名校验">2.3.7. <code>@SaCheckSign</code>：API 签名校验</h3><p>用于跨系统调用的接口签名验证，属于 Sa-Token 扩展能力，本系列不展开讲解，了解存在即可：</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><span class="line"><span class="comment">// 校验 API 签名参数，用于跨系统的接口调用验证</span></span><br><span class="line"><span class="meta">@SaCheckSign</span></span><br><span class="line"><span class="meta">@GetMapping(&quot;/apiSign&quot;)</span></span><br><span class="line"><span class="keyword">public</span> SaResult <span class="title function_">apiSign</span><span class="params">()</span> &#123;</span><br><span class="line">    <span class="keyword">return</span> SaResult.ok(<span class="string">&quot;API 签名校验通过&quot;</span>);</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><hr><h3 id="2-3-8-SaIgnore：忽略所有校验">2.3.8. <code>@SaIgnore</code>：忽略所有校验</h3><p><code>@SaIgnore</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></pre></td><td class="code"><pre><span class="line"><span class="comment">// 整个类需要登录才能访问</span></span><br><span class="line"><span class="meta">@SaCheckLogin</span></span><br><span class="line"><span class="meta">@RestController</span></span><br><span class="line"><span class="meta">@RequestMapping(&quot;/user&quot;)</span></span><br><span class="line"><span class="keyword">public</span> <span class="keyword">class</span> <span class="title class_">UserController</span> &#123;</span><br><span class="line"></span><br><span class="line">    <span class="comment">// 但这个接口单独加了 @SaIgnore，可以游客访问</span></span><br><span class="line">    <span class="meta">@SaIgnore</span></span><br><span class="line">    <span class="meta">@GetMapping(&quot;/getList&quot;)</span></span><br><span class="line">    <span class="keyword">public</span> SaResult <span class="title function_">getList</span><span class="params">()</span> &#123;</span><br><span class="line">        <span class="keyword">return</span> SaResult.ok(<span class="string">&quot;游客可访问的列表&quot;</span>);</span><br><span class="line">    &#125;</span><br><span class="line"></span><br><span class="line">    <span class="comment">// 其他方法仍然需要登录</span></span><br><span class="line">    <span class="meta">@GetMapping(&quot;/info&quot;)</span></span><br><span class="line">    <span class="keyword">public</span> SaResult <span class="title function_">info</span><span class="params">()</span> &#123;</span><br><span class="line">        <span class="keyword">return</span> SaResult.ok(<span class="string">&quot;登录才能查看的信息&quot;</span>);</span><br><span class="line">    &#125;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p><code>@SaIgnore</code> 的几个重要特性：</p><ul><li>修饰<strong>方法</strong>时：只有这个方法可以游客访问，类上的其他注解对此方法无效。</li><li>修饰<strong>类</strong>时：这个类下所有接口都可以游客访问。</li><li>具有<strong>最高优先级</strong>：与其他鉴权注解同时出现时，其他注解全部被忽略。</li><li>同样可以忽略掉<strong>路由拦截器的规则</strong>（第三章会演示）。</li></ul><div class="note warning simple"><p><code>@SaIgnore</code> 的忽略效果只针对 <code>SaInterceptor</code> 拦截器和 AOP 注解鉴权生效，对自定义拦截器与 Spring Security 等第三方过滤器无效。</p></div><hr><h2 id="2-4-多注解叠加-AND-关系">2.4. 多注解叠加 = AND 关系</h2><p>当一个方法上写了多个鉴权注解时，它们之间天然是 <strong>AND</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></pre></td><td class="code"><pre><span class="line"><span class="comment">// 必须同时满足：已登录 + 拥有 admin 角色 + 拥有 user:add 权限</span></span><br><span class="line"><span class="meta">@SaCheckLogin</span></span><br><span class="line"><span class="meta">@SaCheckRole(&quot;admin&quot;)</span></span><br><span class="line"><span class="meta">@SaCheckPermission(&quot;user:add&quot;)</span></span><br><span class="line"><span class="meta">@GetMapping(&quot;/strictControl&quot;)</span></span><br><span class="line"><span class="keyword">public</span> SaResult <span class="title function_">strictControl</span><span class="params">()</span> &#123;</span><br><span class="line">    <span class="keyword">return</span> SaResult.ok(<span class="string">&quot;三重校验通过&quot;</span>);</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>这也解释了为什么 Sa-Token 没有提供 <code>@SaCheckAnd</code> 注解——多注解叠加本身就是 AND 语义，不需要额外的注解来声明。</p><hr><h2 id="2-5-SaCheckOr：批量-OR-校验">2.5. <code>@SaCheckOr</code>：批量 OR 校验</h2><p>当需要&quot;多个条件满足其一即可&quot;的 OR 逻辑，且条件跨越不同注解类型时，使用 <code>@SaCheckOr</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></pre></td><td class="code"><pre><span class="line"><span class="comment">// 满足以下任意一个条件即可进入方法：</span></span><br><span class="line"><span class="comment">// - 已登录（@SaCheckLogin）</span></span><br><span class="line"><span class="comment">// - 拥有 admin 角色（@SaCheckRole）</span></span><br><span class="line"><span class="comment">// - 拥有 user:add 权限（@SaCheckPermission）</span></span><br><span class="line"><span class="comment">// - 已完成二级认证（@SaCheckSafe）</span></span><br><span class="line"><span class="comment">// - 通过 Http Basic 认证（@SaCheckHttpBasic）</span></span><br><span class="line"><span class="meta">@SaCheckOr(</span></span><br><span class="line"><span class="meta">        login      = @SaCheckLogin,</span></span><br><span class="line"><span class="meta">        role       = @SaCheckRole(&quot;admin&quot;),</span></span><br><span class="line"><span class="meta">        permission = @SaCheckPermission(&quot;user:add&quot;),</span></span><br><span class="line"><span class="meta">        safe       = @SaCheckSafe(&quot;update-password&quot;),</span></span><br><span class="line"><span class="meta">        httpBasic  = @SaCheckHttpBasic(account = &quot;sa:123456&quot;),</span></span><br><span class="line"><span class="meta">        disable    = @SaCheckDisable(&quot;submit-orders&quot;)</span></span><br><span class="line"><span class="meta">)</span></span><br><span class="line"><span class="meta">@GetMapping(&quot;/orCheck&quot;)</span></span><br><span class="line"><span class="keyword">public</span> SaResult <span class="title function_">orCheck</span><span class="params">()</span> &#123;</span><br><span class="line">    <span class="keyword">return</span> SaResult.ok(<span class="string">&quot;通过 OR 校验&quot;</span>);</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>每一项属性都可以写成<strong>数组形式</strong>，数组内部的多个元素之间是 OR 关系：</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></pre></td><td class="code"><pre><span class="line"><span class="comment">// 只要有 login 类型账号登录，或者有 user 类型账号登录，任一满足即可通过</span></span><br><span class="line"><span class="comment">// （注意 type 属性涉及多账号模式，是番外篇四的内容，此处了解写法即可）</span></span><br><span class="line"><span class="meta">@SaCheckOr(</span></span><br><span class="line"><span class="meta">    login = &#123; @SaCheckLogin(type = &quot;login&quot;), @SaCheckLogin(type = &quot;user&quot;) &#125;</span></span><br><span class="line"><span class="meta">)</span></span><br><span class="line"><span class="meta">@GetMapping(&quot;/multiTypeLogin&quot;)</span></span><br><span class="line"><span class="keyword">public</span> SaResult <span class="title function_">multiTypeLogin</span><span class="params">()</span> &#123;</span><br><span class="line">    <span class="keyword">return</span> SaResult.ok(<span class="string">&quot;通过多账号类型 OR 校验&quot;</span>);</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p><strong><code>append</code> 字段</strong>：用于追加扩展包中的注解，将它们纳入 OR 逻辑：</p><figure class="highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">// 通过登录校验，或者提供了正确的 ApiKey，满足其一即可</span></span><br><span class="line"><span class="meta">@SaCheckOr(login = @SaCheckLogin, append = &#123; SaCheckApiKey.class &#125;)</span></span><br><span class="line"><span class="meta">@SaCheckApiKey</span></span><br><span class="line"><span class="meta">@GetMapping(&quot;/loginOrApiKey&quot;)</span></span><br><span class="line"><span class="keyword">public</span> SaResult <span class="title function_">loginOrApiKey</span><span class="params">()</span> &#123;</span><br><span class="line">    <span class="keyword">return</span> SaResult.ok(<span class="string">&quot;通过登录或 ApiKey 校验&quot;</span>);</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p><code>append</code> 字段接收的是注解类型的 Class 数组，被追加的注解同时也需要写在方法上，<code>@SaCheckOr</code> 会在运行时读取这些注解的具体参数进行校验。</p><hr><h2 id="2-6-完整的-PermissionController">2.6. 完整的 PermissionController</h2><p>将前面所有演示整合到一个控制器中：</p><p>📄 <code>src/main/java/com/example/authsatoken/controller/PermissionController.java</code>（完整版）</p><figure class="highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><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><span class="line"><span class="keyword">package</span> com.example.authsatoken.controller;</span><br><span class="line"></span><br><span class="line"><span class="keyword">import</span> cn.dev33.satoken.annotation.*;</span><br><span class="line"><span class="keyword">import</span> cn.dev33.satoken.util.SaResult;</span><br><span class="line"><span class="keyword">import</span> cn.dev33.satoken.util.SaMode;</span><br><span class="line"><span class="keyword">import</span> org.springframework.web.bind.annotation.GetMapping;</span><br><span class="line"><span class="keyword">import</span> org.springframework.web.bind.annotation.RequestMapping;</span><br><span class="line"><span class="keyword">import</span> org.springframework.web.bind.annotation.RestController;</span><br><span class="line"></span><br><span class="line"><span class="comment">/**</span></span><br><span class="line"><span class="comment"> * 注解鉴权演示控制器</span></span><br><span class="line"><span class="comment"> *</span></span><br><span class="line"><span class="comment"> * 测试账号与权限对照（来自 StpInterfaceImpl）：</span></span><br><span class="line"><span class="comment"> *   admin / 123456  → userId=10001 → admin 角色 → user:add / user:delete / user:update / user:view</span></span><br><span class="line"><span class="comment"> *   user  / 123456  → userId=10002 → user  角色 → user:view / article:view</span></span><br><span class="line"><span class="comment"> */</span></span><br><span class="line"><span class="meta">@RestController</span></span><br><span class="line"><span class="meta">@RequestMapping(&quot;/permission&quot;)</span></span><br><span class="line"><span class="keyword">public</span> <span class="keyword">class</span> <span class="title class_">PermissionController</span> &#123;</span><br><span class="line"></span><br><span class="line">    <span class="meta">@SaCheckLogin</span></span><br><span class="line">    <span class="meta">@GetMapping(&quot;/loginRequired&quot;)</span></span><br><span class="line">    <span class="keyword">public</span> SaResult <span class="title function_">loginRequired</span><span class="params">()</span> &#123;</span><br><span class="line">        <span class="keyword">return</span> SaResult.ok(<span class="string">&quot;已通过登录校验&quot;</span>);</span><br><span class="line">    &#125;</span><br><span class="line"></span><br><span class="line">    <span class="meta">@SaCheckRole(&quot;admin&quot;)</span></span><br><span class="line">    <span class="meta">@GetMapping(&quot;/adminOnly&quot;)</span></span><br><span class="line">    <span class="keyword">public</span> SaResult <span class="title function_">adminOnly</span><span class="params">()</span> &#123;</span><br><span class="line">        <span class="keyword">return</span> SaResult.ok(<span class="string">&quot;你拥有 admin 角色&quot;</span>);</span><br><span class="line">    &#125;</span><br><span class="line"></span><br><span class="line">    <span class="meta">@SaCheckRole(value = &#123;&quot;admin&quot;, &quot;user&quot;&#125;, mode = SaMode.OR)</span></span><br><span class="line">    <span class="meta">@GetMapping(&quot;/adminOrUser&quot;)</span></span><br><span class="line">    <span class="keyword">public</span> SaResult <span class="title function_">adminOrUser</span><span class="params">()</span> &#123;</span><br><span class="line">        <span class="keyword">return</span> SaResult.ok(<span class="string">&quot;你拥有 admin 或 user 角色&quot;</span>);</span><br><span class="line">    &#125;</span><br><span class="line"></span><br><span class="line">    <span class="meta">@SaCheckPermission(&quot;user:add&quot;)</span></span><br><span class="line">    <span class="meta">@GetMapping(&quot;/canAddUser&quot;)</span></span><br><span class="line">    <span class="keyword">public</span> SaResult <span class="title function_">canAddUser</span><span class="params">()</span> &#123;</span><br><span class="line">        <span class="keyword">return</span> SaResult.ok(<span class="string">&quot;你拥有用户新增权限&quot;</span>);</span><br><span class="line">    &#125;</span><br><span class="line"></span><br><span class="line">    <span class="meta">@SaCheckPermission(&#123;&quot;user:add&quot;, &quot;user:delete&quot;&#125;)</span></span><br><span class="line">    <span class="meta">@GetMapping(&quot;/canAddAndDeleteUser&quot;)</span></span><br><span class="line">    <span class="keyword">public</span> SaResult <span class="title function_">canAddAndDeleteUser</span><span class="params">()</span> &#123;</span><br><span class="line">        <span class="keyword">return</span> SaResult.ok(<span class="string">&quot;你同时拥有用户新增和删除权限&quot;</span>);</span><br><span class="line">    &#125;</span><br><span class="line"></span><br><span class="line">    <span class="meta">@SaCheckPermission(value = &#123;&quot;user:add&quot;, &quot;user:delete&quot;&#125;, mode = SaMode.OR)</span></span><br><span class="line">    <span class="meta">@GetMapping(&quot;/canAddOrDeleteUser&quot;)</span></span><br><span class="line">    <span class="keyword">public</span> SaResult <span class="title function_">canAddOrDeleteUser</span><span class="params">()</span> &#123;</span><br><span class="line">        <span class="keyword">return</span> SaResult.ok(<span class="string">&quot;你拥有用户新增或删除权限&quot;</span>);</span><br><span class="line">    &#125;</span><br><span class="line"></span><br><span class="line">    <span class="meta">@SaCheckPermission(value = &quot;user:delete&quot;, orRole = &quot;admin&quot;)</span></span><br><span class="line">    <span class="meta">@GetMapping(&quot;/deleteOrAdmin&quot;)</span></span><br><span class="line">    <span class="keyword">public</span> SaResult <span class="title function_">deleteOrAdmin</span><span class="params">()</span> &#123;</span><br><span class="line">        <span class="keyword">return</span> SaResult.ok(<span class="string">&quot;通过权限或角色 OR 校验&quot;</span>);</span><br><span class="line">    &#125;</span><br><span class="line"></span><br><span class="line">    <span class="meta">@SaCheckOr(</span></span><br><span class="line"><span class="meta">            role       = @SaCheckRole(&quot;admin&quot;),</span></span><br><span class="line"><span class="meta">            permission = @SaCheckPermission(&quot;user:delete&quot;)</span></span><br><span class="line"><span class="meta">    )</span></span><br><span class="line">    <span class="meta">@GetMapping(&quot;/adminOrCanDelete&quot;)</span></span><br><span class="line">    <span class="keyword">public</span> SaResult <span class="title function_">adminOrCanDelete</span><span class="params">()</span> &#123;</span><br><span class="line">        <span class="keyword">return</span> SaResult.ok(<span class="string">&quot;你是管理员，或者拥有用户删除权限&quot;</span>);</span><br><span class="line">    &#125;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><hr><h2 id="2-7-测试验证：双账号对比矩阵">2.7. 测试验证：双账号对比矩阵</h2><p>重启项目，用两个账号分别登录，测试各接口的实际表现。</p><p><strong>准备工作：分别获取两个账号的 Token</strong></p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line">POST http://localhost:8081/auth/login?username=admin&amp;password=123456</span><br><span class="line">POST http://localhost:8081/auth/login?username=user&amp;password=123456</span><br></pre></td></tr></table></figure><p>分别记录 Token-A（admin）和 Token-U（user）。</p><p><strong>场景：未登录访问需要登录的接口（验证 401）</strong></p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">GET http://localhost:8081/permission/loginRequired</span><br></pre></td></tr></table></figure><p>预期响应：</p><figure class="highlight json"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line"><span class="punctuation">&#123;</span> <span class="attr">&quot;code&quot;</span><span class="punctuation">:</span> <span class="number">401</span><span class="punctuation">,</span> <span class="attr">&quot;msg&quot;</span><span class="punctuation">:</span> <span class="string">&quot;未提供 Token，请先登录&quot;</span><span class="punctuation">,</span> <span class="attr">&quot;data&quot;</span><span class="punctuation">:</span> <span class="literal"><span class="keyword">null</span></span> <span class="punctuation">&#125;</span></span><br></pre></td></tr></table></figure><p><strong>场景：user 账号访问 adminOnly（验证 403）</strong></p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line">GET http://localhost:8081/permission/adminOnly</span><br><span class="line">Header: satoken: &lt;Token-U&gt;</span><br></pre></td></tr></table></figure><p>预期响应：</p><figure class="highlight json"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line"><span class="punctuation">&#123;</span> <span class="attr">&quot;code&quot;</span><span class="punctuation">:</span> <span class="number">403</span><span class="punctuation">,</span> <span class="attr">&quot;msg&quot;</span><span class="punctuation">:</span> <span class="string">&quot;权限不足，缺少角色：admin&quot;</span><span class="punctuation">,</span> <span class="attr">&quot;data&quot;</span><span class="punctuation">:</span> <span class="literal"><span class="keyword">null</span></span> <span class="punctuation">&#125;</span></span><br></pre></td></tr></table></figure><p>以下是完整的双账号测试矩阵：</p><table><thead><tr><th>接口</th><th>admin</th><th>user</th><th>未登录</th></tr></thead><tbody><tr><td><code>/permission/loginRequired</code></td><td>✅ 200</td><td>✅ 200</td><td>❌ 401 未提供 Token</td></tr><tr><td><code>/permission/adminOnly</code></td><td>✅ 200</td><td>❌ 403 缺少角色 admin</td><td>❌ 401</td></tr><tr><td><code>/permission/adminOrUser</code></td><td>✅ 200</td><td>✅ 200</td><td>❌ 401</td></tr><tr><td><code>/permission/canAddUser</code></td><td>✅ 200</td><td>❌ 403 缺少权限 user:add</td><td>❌ 401</td></tr><tr><td><code>/permission/canAddAndDeleteUser</code></td><td>✅ 200</td><td>❌ 403 缺少权限 user:add</td><td>❌ 401</td></tr><tr><td><code>/permission/canAddOrDeleteUser</code></td><td>✅ 200</td><td>❌ 403 缺少权限 user:add</td><td>❌ 401</td></tr><tr><td><code>/permission/deleteOrAdmin</code></td><td>✅ 200</td><td>❌ 403 缺少权限 user:delete</td><td>❌ 401</td></tr><tr><td><code>/permission/adminOrCanDelete</code></td><td>✅ 200</td><td>❌ 403 缺少角色 admin</td><td>❌ 401</td></tr></tbody></table><p>矩阵中有一个值得关注的细节：<code>/permission/adminOrUser</code> 对 <code>user</code> 账号是放行的——因为注解配置了 <code>mode = SaMode.OR</code>，<code>user</code> 账号虽然没有 <code>admin</code> 角色，但有 <code>user</code> 角色，满足 OR 条件中的一个，所以通过。</p><hr><h2 id="2-8-本章总结">2.8. 本章总结</h2><p><strong>本章回顾</strong></p><p>本章完成了注解鉴权体系的完整建设。首先注册了 <code>SaInterceptor</code> 拦截器——这是注解鉴权生效的前置条件，不注册则所有鉴权注解形同虚设。随后在 <code>GlobalExceptionHandler</code> 中追加了 <code>NotPermissionException</code> 和 <code>NotRoleException</code> 两个处理方法，配合已有的 <code>NotLoginException</code> 处理，构成了完整的认证授权异常响应体系，并明确了 401（未认证）与 403（已认证但权限不足）的语义边界。在注解覆盖层面，系统梳理了 Sa-Token 全部 8 种鉴权注解的用法，并重点讲解了 <code>@SaCheckPermission</code> 的 <code>orRole</code> 三种写法、多注解叠加等于 AND 关系的原理、以及 <code>@SaCheckOr</code> 的完整用法（单值、数组形式、<code>append</code> 字段）。最后通过双账号对比测试矩阵验证了权限隔离在实际请求中的完整表现。</p><p><strong>核心汇总表</strong></p><table><thead><tr><th>注解</th><th>校验内容</th><th>AND/OR 支持</th><th>典型场景</th></tr></thead><tbody><tr><td><code>@SaCheckLogin</code></td><td>是否已登录</td><td>无</td><td>所有登录用户可访问的接口</td></tr><tr><td><code>@SaCheckRole</code></td><td>是否拥有指定角色</td><td>支持，默认 AND</td><td>角色专属功能</td></tr><tr><td><code>@SaCheckPermission</code></td><td>是否拥有指定权限码</td><td>支持，默认 AND</td><td>接口级细粒度权限控制</td></tr><tr><td><code>@SaCheckSafe</code></td><td>是否已完成二级认证</td><td>无</td><td>高危操作的额外验证门槛</td></tr><tr><td><code>@SaCheckDisable</code></td><td>指定服务是否被封禁</td><td>多值时任一封禁即拦截</td><td>分类封禁场景</td></tr><tr><td><code>@SaCheckHttpBasic</code></td><td>HTTP Basic 认证</td><td>无</td><td>内部 API / 运维接口</td></tr><tr><td><code>@SaCheckHttpDigest</code></td><td>HTTP Digest 认证</td><td>无</td><td>内部 API / 运维接口</td></tr><tr><td><code>@SaCheckSign</code></td><td>API 签名校验</td><td>无</td><td>跨系统接口调用</td></tr><tr><td><code>@SaIgnore</code></td><td>忽略所有校验</td><td>最高优先级</td><td>公开接口豁免</td></tr></tbody></table><table><thead><tr><th><code>orRole</code> 写法</th><th>示例</th><th>语义</th></tr></thead><tbody><tr><td>单角色</td><td><code>orRole = &quot;admin&quot;</code></td><td>权限码 OR admin 角色</td></tr><tr><td>数组多元素（OR）</td><td><code>orRole = &#123;&quot;admin&quot;, &quot;editor&quot;&#125;</code></td><td>权限码 OR（admin 或 editor）</td></tr><tr><td>数组单元素内逗号（AND）</td><td><code>orRole = &#123;&quot;admin, editor&quot;&#125;</code></td><td>权限码 OR（admin 且 editor）</td></tr></tbody></table><table><thead><tr><th>异常类型</th><th>状态码</th><th>触发场景</th></tr></thead><tbody><tr><td><code>NotLoginException</code></td><td>401</td><td>未登录或 Token 失效</td></tr><tr><td><code>NotPermissionException</code></td><td>403</td><td>缺少所需权限码</td></tr><tr><td><code>NotRoleException</code></td><td>403</td><td>缺少所需角色</td></tr></tbody></table><table><thead><tr><th>多注解组合规则</th><th>语义</th><th>实现方式</th></tr></thead><tbody><tr><td>多注解叠加</td><td>AND（全部满足）</td><td>在方法上写多个注解</td></tr><tr><td>OR 关系</td><td>满足其一即可</td><td><code>@SaCheckOr</code> 或 <code>mode = SaMode.OR</code></td></tr></tbody></table><h1>第三章. 路由拦截鉴权——集中式的批量管控</h1><p><strong>阶段式学习路径</strong></p><p>第二章的注解鉴权是方法级的——每个接口单独声明权限要求，粒度精确，但有一个天然的局限：它是分散的。如果一个模块下有几十个接口都需要登录，就得在几十个方法上逐一加 <code>@SaCheckLogin</code>，不仅繁琐，而且一旦漏加某个方法，那个接口就完全暴露了。</p><p>路由拦截鉴权解决的是这个问题。在拦截器的配置中集中表达&quot;哪些路径需要什么权限&quot;，一处配置批量生效，再也不用担心漏加注解。本章从 <code>SaInterceptor</code> 的有参写法切入，系统覆盖 <code>SaRouter</code> 的全部匹配特征和流程控制手段。</p><hr><h2 id="3-1-从无参到有参：SaInterceptor-的两种工作模式">3.1. 从无参到有参：SaInterceptor 的两种工作模式</h2><p>回顾第二章注册的 <code>SaTokenConfig</code>：</p><figure class="highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line">registry.addInterceptor(<span class="keyword">new</span> <span class="title class_">SaInterceptor</span>())</span><br><span class="line">        .addPathPatterns(<span class="string">&quot;/**&quot;</span>);</span><br></pre></td></tr></table></figure><p><code>new SaInterceptor()</code> 不传参数时，拦截器处于<strong>纯注解模式</strong>——扫描每个请求对应的 Controller 方法，有鉴权注解就执行校验，没有就直接放行。未加注解的接口对未登录用户完全开放。</p><p><code>new SaInterceptor(handle -&gt; &#123; ... &#125;)</code> 传入一个 Lambda 时，拦截器进入<strong>路由规则模式</strong>——在 Lambda 中用 <code>SaRouter</code> 工具类按路径批量声明访问策略。两种模式并不互斥：<strong>有参版本会同时执行路由规则和注解鉴权</strong>，路由规则先执行，注解鉴权后执行。</p><p>现在把 <code>SaTokenConfig</code> 升级为有参版本：</p><p>📄 <code>src/main/java/com/example/authsatoken/config/SaTokenConfig.java</code>（修改，替换 <code>addInterceptors</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></pre></td><td class="code"><pre><span class="line"><span class="keyword">package</span> com.example.authsatoken.config;</span><br><span class="line"></span><br><span class="line"><span class="keyword">import</span> cn.dev33.satoken.interceptor.SaInterceptor;</span><br><span class="line"><span class="keyword">import</span> cn.dev33.satoken.router.SaRouter;</span><br><span class="line"><span class="keyword">import</span> cn.dev33.satoken.stp.StpUtil;</span><br><span class="line"><span class="keyword">import</span> org.springframework.context.annotation.Configuration;</span><br><span class="line"><span class="keyword">import</span> org.springframework.web.servlet.config.annotation.InterceptorRegistry;</span><br><span class="line"><span class="keyword">import</span> org.springframework.web.servlet.config.annotation.WebMvcConfigurer;</span><br><span class="line"></span><br><span class="line"><span class="comment">/**</span></span><br><span class="line"><span class="comment"> * Sa-Token 拦截器配置（升级版）</span></span><br><span class="line"><span class="comment"> * 在第二章无参版本的基础上，增加路由级别的访问策略</span></span><br><span class="line"><span class="comment"> */</span></span><br><span class="line"><span class="meta">@Configuration</span></span><br><span class="line"><span class="keyword">public</span> <span class="keyword">class</span> <span class="title class_">SaTokenConfig</span> <span class="keyword">implements</span> <span class="title class_">WebMvcConfigurer</span> &#123;</span><br><span class="line"></span><br><span class="line">    <span class="meta">@Override</span></span><br><span class="line">    <span class="keyword">public</span> <span class="keyword">void</span> <span class="title function_">addInterceptors</span><span class="params">(InterceptorRegistry registry)</span> &#123;</span><br><span class="line">        registry.addInterceptor(<span class="keyword">new</span> <span class="title class_">SaInterceptor</span>(handle -&gt; &#123;</span><br><span class="line"></span><br><span class="line">            <span class="comment">// ---- 规则一：全局登录校验 ----</span></span><br><span class="line">            <span class="comment">// 拦截所有路径，但排除登录接口</span></span><br><span class="line">            <span class="comment">// 效果：除了 /auth/login，其余所有接口都必须登录才能访问</span></span><br><span class="line">            SaRouter.match(<span class="string">&quot;/**&quot;</span>)</span><br><span class="line">                    .notMatch(<span class="string">&quot;/auth/login&quot;</span>)</span><br><span class="line">                    .check(r -&gt; StpUtil.checkLogin());</span><br><span class="line"></span><br><span class="line">            <span class="comment">// ---- 规则二：管理员模块角色校验 ----</span></span><br><span class="line">            <span class="comment">// /admin/** 下的所有接口，额外要求 admin 角色</span></span><br><span class="line">            <span class="comment">// 请求会先通过规则一的登录校验，再通过规则二的角色校验，两者是 AND 关系</span></span><br><span class="line">            SaRouter.match(<span class="string">&quot;/admin/**&quot;</span>)</span><br><span class="line">                    .check(r -&gt; StpUtil.checkRole(<span class="string">&quot;admin&quot;</span>));</span><br><span class="line"></span><br><span class="line">        <span class="comment">// ⚠️ 必须用 excludePathPatterns(&quot;/error&quot;) 排除 /error 路径</span></span><br><span class="line">        <span class="comment">// 原因：Spring Boot 发生异常时，Tomcat 会将请求内部 forward 转发到 /error 端点。</span></span><br><span class="line">        <span class="comment">// 但此次 forward 是在 Sa-Token 的 ThreadLocal 上下文已销毁之后发生的，</span></span><br><span class="line">        <span class="comment">// 导致 SaInterceptor.preHandle() 被再次触发，SaRouter.match() 内部调用</span></span><br><span class="line">        <span class="comment">// SaHolder.getRequest() 时找不到上下文，抛出 SaTokenContextException。</span></span><br><span class="line">        <span class="comment">//</span></span><br><span class="line">        <span class="comment">// ❌ 错误做法：在 Lambda 内部使用 .notMatch(&quot;/error&quot;)</span></span><br><span class="line">        <span class="comment">//    SaRouter.match(&quot;/**&quot;) 这行本身就需要获取当前请求的 URI，</span></span><br><span class="line">        <span class="comment">//    还没执行到 notMatch 判断，就已经因上下文不存在而崩溃了。</span></span><br><span class="line">        <span class="comment">//</span></span><br><span class="line">        <span class="comment">// ✅ 正确做法：使用 Spring MVC 的 excludePathPatterns(&quot;/error&quot;)</span></span><br><span class="line">        <span class="comment">//    这样 /error 请求在 Spring MVC 层面就被排除，</span></span><br><span class="line">        <span class="comment">//    SaInterceptor 的 preHandle() 根本不会被调用，彻底规避问题。</span></span><br><span class="line">        &#125;)).addPathPatterns(<span class="string">&quot;/**&quot;</span>).excludePathPatterns(<span class="string">&quot;/error&quot;</span>);</span><br><span class="line">    &#125;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>两条规则解决了两个最典型的全局诉求：规则一让&quot;所有接口必须登录&quot;这个要求集中到一处，不再散落在几十个注解里；规则二给 <code>/admin/**</code> 整个模块追加了角色防护，无论后续管理模块新增多少接口，这条规则自动覆盖。</p><hr><h2 id="3-2-SaRouter-匹配特征全览">3.2. SaRouter 匹配特征全览</h2><p><code>SaRouter</code> 的 API 设计成链式调用风格，<code>match</code> 指定拦截范围，<code>notMatch</code> 排除白名单，<code>check</code> 指定校验逻辑。除了 path 路由匹配，它还支持多种其他特征：</p><h3 id="path-路由匹配">path 路由匹配</h3><p>最常用的匹配方式，支持 Ant 风格通配符（<code>*</code> 匹配单层，<code>**</code> 匹配多层），支持 RESTful 风格路由，支持同时传入多个 path：</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><span class="line"><span class="comment">// 匹配单个路径</span></span><br><span class="line">SaRouter.match(<span class="string">&quot;/user/info&quot;</span>).check(r -&gt; StpUtil.checkLogin());</span><br><span class="line"></span><br><span class="line"><span class="comment">// 多层通配符，匹配整个模块</span></span><br><span class="line">SaRouter.match(<span class="string">&quot;/admin/**&quot;</span>).check(r -&gt; StpUtil.checkRole(<span class="string">&quot;admin&quot;</span>));</span><br><span class="line"></span><br><span class="line"><span class="comment">// 同时传入多个 path，满足其一即命中</span></span><br><span class="line">SaRouter.match(<span class="string">&quot;/user/**&quot;</span>, <span class="string">&quot;/goods/**&quot;</span>, <span class="string">&quot;/art/get/&#123;id&#125;&quot;</span>)</span><br><span class="line">        .check(r -&gt; StpUtil.checkLogin());</span><br><span class="line"></span><br><span class="line"><span class="comment">// notMatch 排除白名单，支持多个路径，支持通配符</span></span><br><span class="line">SaRouter.match(<span class="string">&quot;/**&quot;</span>)</span><br><span class="line">        .notMatch(<span class="string">&quot;/auth/login&quot;</span>, <span class="string">&quot;/auth/register&quot;</span>, <span class="string">&quot;/public/**&quot;</span>)</span><br><span class="line">        .check(r -&gt; StpUtil.checkLogin());</span><br></pre></td></tr></table></figure><h3 id="按-HTTP-方法匹配">按 HTTP 方法匹配</h3><p>在 RESTful 接口设计中，同一路径的不同 HTTP 方法往往需要不同的权限。<code>SaHttpMethod</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></pre></td><td class="code"><pre><span class="line"><span class="comment">// 只拦截 POST 请求</span></span><br><span class="line">SaRouter.match(SaHttpMethod.POST).check(r -&gt; StpUtil.checkLogin());</span><br><span class="line"></span><br><span class="line"><span class="comment">// 同一路径，不同 HTTP 方法，不同权限要求</span></span><br><span class="line">SaRouter.match(<span class="string">&quot;/article/**&quot;</span>, SaHttpMethod.POST)</span><br><span class="line">        .check(r -&gt; StpUtil.checkPermission(<span class="string">&quot;article:add&quot;</span>));</span><br><span class="line"></span><br><span class="line">SaRouter.match(<span class="string">&quot;/article/**&quot;</span>, SaHttpMethod.DELETE)</span><br><span class="line">        .check(r -&gt; StpUtil.checkPermission(<span class="string">&quot;article:delete&quot;</span>));</span><br></pre></td></tr></table></figure><h3 id="按-boolean-条件和-lambda-表达式匹配">按 boolean 条件和 lambda 表达式匹配</h3><p>匹配条件不局限于路径，可以是任意 boolean 值或返回 boolean 的 lambda：</p><figure class="highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">// 根据一个 boolean 条件进行匹配</span></span><br><span class="line">SaRouter.match(StpUtil.isLogin()).check(r -&gt; System.out.println(<span class="string">&quot;当前已登录&quot;</span>));</span><br><span class="line"></span><br><span class="line"><span class="comment">// 根据一个返回 boolean 结果的 lambda 表达式匹配</span></span><br><span class="line">SaRouter.match(r -&gt; StpUtil.isLogin()).check(r -&gt; System.out.println(<span class="string">&quot;当前已登录（lambda 写法）&quot;</span>));</span><br></pre></td></tr></table></figure><h3 id="多条件无限连缀">多条件无限连缀</h3><p>多个 <code>match</code> / <code>notMatch</code> 可以无限连缀，所有条件之间是 <strong>AND 关系</strong>——只有全部条件都满足，才会执行最后的 <code>check</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></pre></td><td class="code"><pre><span class="line"><span class="comment">// 必须是 GET 请求，且路径以 /user/ 开头</span></span><br><span class="line">SaRouter.match(SaHttpMethod.GET)</span><br><span class="line">        .match(<span class="string">&quot;/user/**&quot;</span>)</span><br><span class="line">        .check(r -&gt; StpUtil.checkLogin());</span><br><span class="line"></span><br><span class="line"><span class="comment">// 同时满足：GET 方式、/admin 开头、路径中含有 /send/、结尾不是 .js 和 .css</span></span><br><span class="line">SaRouter</span><br><span class="line">    .match(SaHttpMethod.GET)</span><br><span class="line">    .match(<span class="string">&quot;/admin/**&quot;</span>)</span><br><span class="line">    .match(<span class="string">&quot;/**/send/**&quot;</span>)</span><br><span class="line">    .notMatch(<span class="string">&quot;/**/*.js&quot;</span>)</span><br><span class="line">    .notMatch(<span class="string">&quot;/**/*.css&quot;</span>)</span><br><span class="line">    .check(r -&gt; StpUtil.checkRole(<span class="string">&quot;admin&quot;</span>));</span><br></pre></td></tr></table></figure><hr><h2 id="3-3-流程控制三件套：stop、back、free">3.3. 流程控制三件套：stop、back、free</h2><p>除了匹配和校验，<code>SaRouter</code> 还提供了三个用于控制匹配流程的方法，解决&quot;某条规则命中后不再继续匹配&quot;的需求。</p><h3 id="stop-：停止匹配，进入-Controller">stop()：停止匹配，进入 Controller</h3><p><code>SaRouter.stop()</code> 可以提前退出整个 Lambda 函数，跳过后续所有未执行的 match 规则：</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><span class="line">registry.addInterceptor(<span class="keyword">new</span> <span class="title class_">SaInterceptor</span>(handle -&gt; &#123;</span><br><span class="line">    SaRouter.match(<span class="string">&quot;/**&quot;</span>).check(r -&gt; System.out.println(<span class="string">&quot;规则一：进入&quot;</span>));</span><br><span class="line">    SaRouter.match(<span class="string">&quot;/**&quot;</span>).check(r -&gt; System.out.println(<span class="string">&quot;规则二：进入&quot;</span>)).stop(); <span class="comment">// 执行完后停止</span></span><br><span class="line">    SaRouter.match(<span class="string">&quot;/**&quot;</span>).check(r -&gt; System.out.println(<span class="string">&quot;规则三：不会执行&quot;</span>));</span><br><span class="line">    SaRouter.match(<span class="string">&quot;/**&quot;</span>).check(r -&gt; System.out.println(<span class="string">&quot;规则四：不会执行&quot;</span>));</span><br><span class="line">&#125;)).addPathPatterns(<span class="string">&quot;/**&quot;</span>);</span><br></pre></td></tr></table></figure><p>如上代码，执行到第二条规则的 <code>stop()</code> 时，后续的规则三、四都会被跳过，请求正常进入 Controller。</p><h3 id="back-：停止匹配，直接返回前端">back()：停止匹配，直接返回前端</h3><p><code>SaRouter.back()</code> 同样会停止匹配，但不进入 Controller，而是<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></pre></td><td class="code"><pre><span class="line"><span class="comment">// 执行 back 后，停止匹配，不进入 Controller，直接返回字符串给前端</span></span><br><span class="line">SaRouter.match(<span class="string">&quot;/user/back&quot;</span>).back(<span class="string">&quot;该接口暂时不对外开放&quot;</span>);</span><br></pre></td></tr></table></figure><p><code>stop()</code> 与 <code>back()</code> 的区别：</p><table><thead><tr><th></th><th><code>stop()</code></th><th><code>back()</code></th></tr></thead><tbody><tr><td>停止匹配</td><td>✅</td><td>✅</td></tr><tr><td>进入 Controller</td><td>✅</td><td>❌</td></tr><tr><td>直接返回前端</td><td>❌</td><td>✅</td></tr></tbody></table><h3 id="free-：独立作用域">free()：独立作用域</h3><p><code>free()</code> 打开一个独立的作用域，使内部的 <code>stop()</code> 不再跳出整个 Lambda，而是<strong>仅仅跳出当前 free 作用域</strong>，外部的 match 规则继续正常执行：</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></pre></td><td class="code"><pre><span class="line"><span class="comment">// 进入 free 独立作用域</span></span><br><span class="line">SaRouter.match(<span class="string">&quot;/**&quot;</span>).free(r -&gt; &#123;</span><br><span class="line">    SaRouter.match(<span class="string">&quot;/a/**&quot;</span>).check(<span class="comment">/* 校验 a 模块 */</span>);</span><br><span class="line">    SaRouter.match(<span class="string">&quot;/b/**&quot;</span>).check(<span class="comment">/* 校验 b 模块 */</span>).stop(); <span class="comment">// 只跳出 free，不影响外部</span></span><br><span class="line">    SaRouter.match(<span class="string">&quot;/c/**&quot;</span>).check(<span class="comment">/* 校验 c 模块 */</span>);       <span class="comment">// 如果命中 /b，这条不会执行</span></span><br><span class="line">&#125;);</span><br><span class="line"></span><br><span class="line"><span class="comment">// free 执行完毕后，外部的规则继续执行，不受 free 内部 stop 的影响</span></span><br><span class="line">SaRouter.match(<span class="string">&quot;/**&quot;</span>).check(r -&gt; System.out.println(<span class="string">&quot;外部规则，始终执行&quot;</span>));</span><br></pre></td></tr></table></figure><p><code>free()</code> 适合&quot;某个模块内部有复杂的互斥规则，但不希望影响模块外部的其他规则&quot;的场景。</p><hr><h2 id="3-4-SaIgnore-忽略路由拦截">3.4. <code>@SaIgnore</code> 忽略路由拦截</h2><p>第二章介绍 <code>@SaIgnore</code> 时提到它同样可以忽略路由拦截器的规则。这意味着即使拦截器配置了&quot;所有路径必须登录&quot;，只要方法或类上加了 <code>@SaIgnore</code>，该接口就会跳过拦截器的校验，直接放行。</p><p>先配置拦截规则：</p><figure class="highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><span class="line">registry.addInterceptor(<span class="keyword">new</span> <span class="title class_">SaInterceptor</span>(handle -&gt; &#123;</span><br><span class="line">    SaRouter.match(<span class="string">&quot;/user/**&quot;</span>).check(r -&gt; StpUtil.checkPermission(<span class="string">&quot;user&quot;</span>));</span><br><span class="line">    SaRouter.match(<span class="string">&quot;/admin/**&quot;</span>).check(r -&gt; StpUtil.checkPermission(<span class="string">&quot;admin&quot;</span>));</span><br><span class="line">&#125;)).addPathPatterns(<span class="string">&quot;/**&quot;</span>);</span><br></pre></td></tr></table></figure><p>然后在需要豁免的接口上加 <code>@SaIgnore</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><span class="line"><span class="comment">// 虽然 /user/** 规则要求 user 权限，但此接口因 @SaIgnore 直接放行，游客可访问</span></span><br><span class="line"><span class="meta">@SaIgnore</span></span><br><span class="line"><span class="meta">@GetMapping(&quot;/user/getList&quot;)</span></span><br><span class="line"><span class="keyword">public</span> SaResult <span class="title function_">getList</span><span class="params">()</span> &#123;</span><br><span class="line">    <span class="keyword">return</span> SaResult.ok(<span class="string">&quot;游客可访问的列表&quot;</span>);</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>请求到达被 <code>@SaIgnore</code> 修饰的方法时，拦截器会跳过所有 <code>SaRouter</code> 规则和注解鉴权，直接进入方法体。</p><div class="note warning simple"><p><code>@SaIgnore</code> 的忽略效果只针对 <code>SaInterceptor</code> 拦截器和 AOP 注解鉴权生效，对自定义拦截器与过滤器不生效。</p></div><hr><h2 id="3-5-高级配置：isAnnotation-与-setBeforeAuth">3.5. 高级配置：isAnnotation 与 setBeforeAuth</h2><h3 id="isAnnotation-false-：关闭注解校验能力">isAnnotation(false)：关闭注解校验能力</h3><p><code>SaInterceptor</code> 注册到项目后，默认<strong>同时开启路由规则和注解鉴权</strong>两种能力。如果你只想做路由拦截，不希望框架扫描方法上的鉴权注解，可以通过 <code>isAnnotation(false)</code> 关闭注解校验：</p><figure class="highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br></pre></td><td class="code"><pre><span class="line">registry.addInterceptor(</span><br><span class="line">    <span class="keyword">new</span> <span class="title class_">SaInterceptor</span>(handle -&gt; &#123;</span><br><span class="line">        SaRouter.match(<span class="string">&quot;/**&quot;</span>).check(r -&gt; StpUtil.checkLogin());</span><br><span class="line">    &#125;).isAnnotation(<span class="literal">false</span>)  <span class="comment">// 关闭注解鉴权，只做路由拦截校验</span></span><br><span class="line">).addPathPatterns(<span class="string">&quot;/**&quot;</span>);</span><br></pre></td></tr></table></figure><p>关闭后，Controller 方法上的 <code>@SaCheckLogin@SaCheckRole</code> 等注解将全部失效，框架只执行 Lambda 中配置的路由规则。</p><h3 id="setBeforeAuth-：认证前置函数">setBeforeAuth()：认证前置函数</h3><p><code>setBeforeAuth()</code> 用于注册一个在注解鉴权<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></pre></td><td class="code"><pre><span class="line">registry.addInterceptor(<span class="keyword">new</span> <span class="title class_">SaInterceptor</span>(handle -&gt; &#123;</span><br><span class="line">    <span class="comment">// 这里是 auth 函数，在注解鉴权之后执行</span></span><br><span class="line">    System.out.println(<span class="string">&quot;步骤 2：auth 函数&quot;</span>);</span><br><span class="line">&#125;)</span><br><span class="line">.setBeforeAuth(handle -&gt; &#123;</span><br><span class="line">    <span class="comment">// 这里是 beforeAuth 函数，在注解鉴权之前执行</span></span><br><span class="line">    System.out.println(<span class="string">&quot;步骤 1：beforeAuth 函数&quot;</span>);</span><br><span class="line">&#125;)</span><br><span class="line">).addPathPatterns(<span class="string">&quot;/**&quot;</span>);</span><br></pre></td></tr></table></figure><p>实际执行顺序是：<strong>beforeAuth → 注解鉴权 → auth（Lambda 中的路由规则）</strong>。</p><p>如果在 <code>beforeAuth</code> 中调用了 <code>SaRouter.stop()</code>，将跳过后续的注解鉴权和 auth 认证环节，直接进入 Controller：</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></pre></td><td class="code"><pre><span class="line">.setBeforeAuth(handle -&gt; &#123;</span><br><span class="line">    <span class="comment">// 满足某个条件时，跳过所有鉴权，直接放行</span></span><br><span class="line">    SaRouter.match(<span class="string">&quot;/health&quot;</span>).stop();</span><br><span class="line">&#125;)</span><br></pre></td></tr></table></figure><hr><h2 id="3-6-路由拦截-vs-注解鉴权：执行顺序与职责分工">3.6. 路由拦截 vs 注解鉴权：执行顺序与职责分工</h2><p>现在项目中同时存在两种鉴权方式，理解它们的执行顺序对排查问题非常重要。</p><p>一个请求进入后，完整的执行顺序是：</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">beforeAuth 前置函数 → 注解鉴权 → auth 路由规则 → Controller 方法 → 编程式鉴权（第四章）</span><br></pre></td></tr></table></figure><p>前两者都在请求到达方法之前执行，只要任意一关没通过，请求就会被拒绝，不会继续往后走。</p><p>两种方式各有清晰的职责边界：</p><table><thead><tr><th>维度</th><th>路由拦截鉴权</th><th>注解鉴权</th></tr></thead><tbody><tr><td>配置位置</td><td>集中在 <code>SaTokenConfig</code></td><td>分散在每个 Controller 方法上</td></tr><tr><td>粒度</td><td>路径模块级（批量）</td><td>方法级（单个接口）</td></tr><tr><td>典型场景</td><td>“所有接口必须登录”、“整个管理模块需要 admin 角色”</td><td>“这个接口需要 user:delete 权限”</td></tr><tr><td>遗漏风险</td><td>低，默认拦截所有匹配路径</td><td>较高，漏加注解就没有校验</td></tr><tr><td>改动影响范围</td><td>一处规则影响一批接口</td><td>一个注解只影响一个方法</td></tr></tbody></table><p>最佳实践是两者配合：<strong>路由拦截负责粗粒度的全局策略作为底线兜底，注解鉴权负责细粒度的接口级声明</strong>。以我们当前项目为例：</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></pre></td><td class="code"><pre><span class="line">路由拦截（SaTokenConfig）：</span><br><span class="line">  - 所有接口必须登录（排除 /auth/login）</span><br><span class="line">  - /admin/** 需要 admin 角色</span><br><span class="line"></span><br><span class="line">注解鉴权（PermissionController 上的注解）：</span><br><span class="line">  - /permission/canAddUser 需要 user:add 权限</span><br><span class="line">  - /permission/adminOrCanDelete 需要 admin 角色或 user:delete 权限</span><br><span class="line">  - ...</span><br></pre></td></tr></table></figure><p>这样即使某个开发者在新增接口时忘记加权限注解，路由拦截的登录校验依然兜底——至少保证未登录用户无法访问任何接口。</p><hr><h2 id="3-7-验证路由拦截效果">3.7. 验证路由拦截效果</h2><p>重启项目后，通过四个场景验证路由规则是否生效。</p><p><strong>场景一：未登录访问需要登录的接口（验证规则一）</strong></p><p>不携带任何 Token，直接访问：</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">GET http://localhost:8081/auth/info</span><br></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></pre></td><td class="code"><pre><span class="line"><span class="punctuation">&#123;</span></span><br><span class="line">    <span class="attr">&quot;code&quot;</span><span class="punctuation">:</span> <span class="number">401</span><span class="punctuation">,</span></span><br><span class="line">    <span class="attr">&quot;msg&quot;</span><span class="punctuation">:</span> <span class="string">&quot;未提供 Token，请先登录&quot;</span><span class="punctuation">,</span></span><br><span class="line">    <span class="attr">&quot;data&quot;</span><span class="punctuation">:</span> <span class="literal"><span class="keyword">null</span></span></span><br><span class="line"><span class="punctuation">&#125;</span></span><br></pre></td></tr></table></figure><p>在第二章中，<code>/auth/info</code> 上加了 <code>StpUtil.checkLogin()</code> 但未加路由规则，行为已由拦截器规则一统一接管——任何未加 <code>notMatch</code> 白名单的接口，未登录一律返回 401。</p><p><strong>场景二：未登录访问白名单接口（验证 notMatch）</strong></p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">POST http://localhost:8081/auth/login?username=admin&amp;password=123456</span><br></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></pre></td><td class="code"><pre><span class="line"><span class="punctuation">&#123;</span></span><br><span class="line">    <span class="attr">&quot;code&quot;</span><span class="punctuation">:</span> <span class="number">200</span><span class="punctuation">,</span></span><br><span class="line">    <span class="attr">&quot;msg&quot;</span><span class="punctuation">:</span> <span class="string">&quot;登录成功&quot;</span><span class="punctuation">,</span></span><br><span class="line">    <span class="attr">&quot;data&quot;</span><span class="punctuation">:</span> <span class="string">&quot;1db92b69-74ce-4c5f-a838-13c0f414d047&quot;</span></span><br><span class="line"><span class="punctuation">&#125;</span></span><br></pre></td></tr></table></figure><p><code>/auth/login</code> 被 <code>notMatch</code> 排除在规则一之外，未登录也能正常访问，不会被拦截。</p><p><strong>场景三：user 账号访问 /admin 接口（验证规则二）</strong></p><p>以 <code>user</code> 身份登录获取 Token-U，然后：</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line">DELETE http://localhost:8081/admin/users/10001/sessions</span><br><span class="line">Header: satoken: &lt;Token-U&gt;</span><br></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></pre></td><td class="code"><pre><span class="line"><span class="punctuation">&#123;</span></span><br><span class="line">    <span class="attr">&quot;code&quot;</span><span class="punctuation">:</span> <span class="number">403</span><span class="punctuation">,</span></span><br><span class="line">    <span class="attr">&quot;msg&quot;</span><span class="punctuation">:</span> <span class="string">&quot;权限不足，缺少角色：admin&quot;</span><span class="punctuation">,</span></span><br><span class="line">    <span class="attr">&quot;data&quot;</span><span class="punctuation">:</span> <span class="literal"><span class="keyword">null</span></span></span><br><span class="line"><span class="punctuation">&#125;</span></span><br></pre></td></tr></table></figure><p><code>user</code> 账号已登录（通过规则一），但没有 <code>admin</code> 角色（未通过规则二），被拦截在 Controller 方法之前。</p><p><strong>场景四：admin 账号访问 /admin 接口（验证两条规则均通过）</strong></p><p>以 <code>admin</code> 身份登录获取 Token-A，然后：</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line">DELETE http://localhost:8081/admin/users/10002/sessions</span><br><span class="line">Header: satoken: &lt;Token-A&gt;</span><br></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></pre></td><td class="code"><pre><span class="line"><span class="punctuation">&#123;</span></span><br><span class="line">    <span class="attr">&quot;code&quot;</span><span class="punctuation">:</span> <span class="number">200</span><span class="punctuation">,</span></span><br><span class="line">    <span class="attr">&quot;msg&quot;</span><span class="punctuation">:</span> <span class="string">&quot;已将账号 10002 的所有设备踢下线&quot;</span><span class="punctuation">,</span></span><br><span class="line">    <span class="attr">&quot;data&quot;</span><span class="punctuation">:</span> <span class="literal"><span class="keyword">null</span></span></span><br><span class="line"><span class="punctuation">&#125;</span></span><br></pre></td></tr></table></figure><p><code>admin</code> 账号通过规则一（已登录）和规则二（拥有 admin 角色），顺利到达 Controller 方法执行业务逻辑。</p><hr><h2 id="3-8-本章总结">3.8. 本章总结</h2><p><strong>本章回顾</strong></p><p>本章将 <code>SaInterceptor</code> 从无参版本升级为有参版本，在 Lambda 中配置了全局登录校验和管理模块角色校验两条核心路由规则，并说明了 <code>excludePathPatterns(&quot;/error&quot;)</code> 必须放在 Spring MVC 层而非 Lambda 内部的原因。在 <code>SaRouter</code> API 层面，系统覆盖了 path 路由匹配、HTTP 方法匹配、boolean 条件匹配、lambda 表达式匹配以及多条件无限连缀五种匹配特征。流程控制三件套方面，<code>stop()</code> 用于提前退出并进入 Controller，<code>back()</code> 用于提前退出并直接返回前端，<code>free()</code> 用于创建不影响外部规则的独立匹配作用域。高级配置方面，<code>isAnnotation(false)</code> 可关闭注解校验只做路由拦截，<code>setBeforeAuth()</code> 可注册在注解鉴权之前执行的前置函数。最后通过执行顺序说明和职责分工对比，确立了&quot;路由拦截负责底线兜底，注解鉴权负责细粒度声明&quot;的最佳实践。</p><p><strong>核心汇总表</strong></p><table><thead><tr><th><code>SaRouter</code> 方法</th><th>作用</th><th>示例</th></tr></thead><tbody><tr><td><code>match(String... paths)</code></td><td>指定拦截路径，支持 <code>**</code> 通配符</td><td><code>match(&quot;/admin/**&quot;)</code></td></tr><tr><td><code>match(SaHttpMethod)</code></td><td>按 HTTP 方法匹配</td><td><code>match(SaHttpMethod.DELETE)</code></td></tr><tr><td><code>match(boolean)</code></td><td>按 boolean 条件匹配</td><td><code>match(StpUtil.isLogin())</code></td></tr><tr><td><code>match(lambda)</code></td><td>按 lambda 返回值匹配</td><td><code>match(r -&gt; StpUtil.isLogin())</code></td></tr><tr><td><code>notMatch(String... paths)</code></td><td>排除白名单路径</td><td><code>notMatch(&quot;/auth/login&quot;, &quot;/public/**&quot;)</code></td></tr><tr><td><code>check(lambda)</code></td><td>指定校验逻辑</td><td><code>check(r -&gt; StpUtil.checkRole(&quot;admin&quot;))</code></td></tr><tr><td><code>stop()</code></td><td>停止匹配，进入 Controller</td><td>链式调用在 <code>check()</code> 之后</td></tr><tr><td><code>back(value)</code></td><td>停止匹配，直接返回前端</td><td><code>back(&quot;暂不对外开放&quot;)</code></td></tr><tr><td><code>free(lambda)</code></td><td>打开独立作用域，内部 stop 不影响外部</td><td><code>free(r -&gt; &#123; ... &#125;)</code></td></tr></tbody></table><table><thead><tr><th>高级配置</th><th>作用</th><th>默认值</th></tr></thead><tbody><tr><td><code>isAnnotation(false)</code></td><td>关闭注解校验，只做路由拦截</td><td>默认开启注解校验</td></tr><tr><td><code>setBeforeAuth(lambda)</code></td><td>注册在注解鉴权之前执行的前置函数</td><td>无</td></tr></tbody></table><table><thead><tr><th>执行顺序</th><th>阶段</th><th>说明</th></tr></thead><tbody><tr><td>第一</td><td><code>beforeAuth</code> 前置函数</td><td>早于注解鉴权，适合预处理</td></tr><tr><td>第二</td><td>注解鉴权</td><td>扫描 Controller 方法上的鉴权注解</td></tr><tr><td>第三</td><td><code>auth</code> 路由规则</td><td>Lambda 中配置的 SaRouter 规则</td></tr><tr><td>第四</td><td>Controller 方法</td><td>业务逻辑，含编程式鉴权（第四章）</td></tr></tbody></table><h1>第四章. 编程式鉴权——动态的业务内判断</h1><p><strong>阶段式学习路径</strong></p><p>路由拦截和注解鉴权解决的都是&quot;能不能进这扇门&quot;的问题——请求要么通过，要么被拒绝在 Controller 方法之外。但真实业务中，权限判断往往发生在门里面。</p><p>管理员可以编辑任何文章，普通用户只能编辑自己写的；财务可以查看所有订单金额，普通用户只能看自己的。这类判断无法用注解表达，因为注解只能声明&quot;需要什么权限&quot;，无法表达&quot;如果有这个权限就走 A 分支，没有就走 B 分支&quot;。编程式鉴权就是为这种场景设计的——在业务代码中直接调用 Sa-Token 的 API，根据返回值动态决定执行路径。</p><hr><h2 id="4-1-has-系列与-check-系列：两组-API-的选择依据">4.1. has 系列与 check 系列：两组 API 的选择依据</h2><p>Sa-Token 提供了两组编程式鉴权 API，外形相似但行为截然不同。</p><p><code>has</code> 系列返回 <code>boolean</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></pre></td><td class="code"><pre><span class="line"><span class="comment">// 角色判断</span></span><br><span class="line">StpUtil.hasRole(<span class="string">&quot;admin&quot;</span>);                              <span class="comment">// 是否拥有 admin 角色</span></span><br><span class="line">StpUtil.hasRoleAnd(<span class="string">&quot;admin&quot;</span>, <span class="string">&quot;editor&quot;</span>);                 <span class="comment">// 是否同时拥有 admin 和 editor 角色</span></span><br><span class="line">StpUtil.hasRoleOr(<span class="string">&quot;admin&quot;</span>, <span class="string">&quot;editor&quot;</span>);                  <span class="comment">// 是否拥有 admin 或 editor 任一角色</span></span><br><span class="line"></span><br><span class="line"><span class="comment">// 权限码判断</span></span><br><span class="line">StpUtil.hasPermission(<span class="string">&quot;user:add&quot;</span>);                     <span class="comment">// 是否拥有 user:add 权限</span></span><br><span class="line">StpUtil.hasPermissionAnd(<span class="string">&quot;user:add&quot;</span>, <span class="string">&quot;user:delete&quot;</span>);   <span class="comment">// 是否同时拥有两个权限</span></span><br><span class="line">StpUtil.hasPermissionOr(<span class="string">&quot;user:add&quot;</span>, <span class="string">&quot;user:delete&quot;</span>);    <span class="comment">// 是否拥有任一权限</span></span><br></pre></td></tr></table></figure><p><code>check</code> 系列没有返回值，校验失败时直接抛出 <code>NotRoleException</code> 或 <code>NotPermissionException</code>，由全局异常处理器统一拦截，适合&quot;不满足条件就直接中断&quot;的场景：</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></pre></td><td class="code"><pre><span class="line"><span class="comment">// 角色校验</span></span><br><span class="line">StpUtil.checkRole(<span class="string">&quot;admin&quot;</span>);                            <span class="comment">// 必须拥有 admin 角色，否则抛异常</span></span><br><span class="line">StpUtil.checkRoleAnd(<span class="string">&quot;admin&quot;</span>, <span class="string">&quot;editor&quot;</span>);               <span class="comment">// 必须同时拥有两个角色</span></span><br><span class="line">StpUtil.checkRoleOr(<span class="string">&quot;admin&quot;</span>, <span class="string">&quot;editor&quot;</span>);                <span class="comment">// 拥有任一角色即可</span></span><br><span class="line"></span><br><span class="line"><span class="comment">// 权限码校验</span></span><br><span class="line">StpUtil.checkPermission(<span class="string">&quot;user:add&quot;</span>);                   <span class="comment">// 必须拥有 user:add 权限，否则抛异常</span></span><br><span class="line">StpUtil.checkPermissionAnd(<span class="string">&quot;user:add&quot;</span>, <span class="string">&quot;user:delete&quot;</span>); <span class="comment">// 必须同时拥有两个权限</span></span><br><span class="line">StpUtil.checkPermissionOr(<span class="string">&quot;user:add&quot;</span>, <span class="string">&quot;user:delete&quot;</span>);  <span class="comment">// 拥有任一权限即可</span></span><br></pre></td></tr></table></figure><p>选择哪组 API 的判断逻辑很简单：</p><ul><li>需要根据权限结果走不同分支（if-else）→ 用 <code>has</code> 系列，拿 boolean 做判断</li><li>权限不满足时直接返回错误，不需要任何后续逻辑 → 用 <code>check</code> 系列，让异常处理器兜底</li></ul><hr><h2 id="4-2-获取当前用户的权限与角色数据">4.2. 获取当前用户的权限与角色数据</h2><p>除了判断和校验，有时候需要直接拿到当前用户的完整权限列表，比如在&quot;个人中心&quot;展示用户拥有哪些角色，或者在前端做菜单权限控制时返回权限码列表：</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></pre></td><td class="code"><pre><span class="line"><span class="comment">// 获取当前登录用户的权限码列表（调用 StpInterfaceImpl.getPermissionList）</span></span><br><span class="line">List&lt;String&gt; permissions = StpUtil.getPermissionList();</span><br><span class="line"></span><br><span class="line"><span class="comment">// 获取当前登录用户的角色列表（调用 StpInterfaceImpl.getRoleList）</span></span><br><span class="line">List&lt;String&gt; roles = StpUtil.getRoleList();</span><br><span class="line"></span><br><span class="line"><span class="comment">// 管理视角：获取指定用户的权限码列表</span></span><br><span class="line">List&lt;String&gt; permissions = StpUtil.getPermissionList(<span class="number">10001L</span>);</span><br><span class="line"></span><br><span class="line"><span class="comment">// 管理视角：获取指定用户的角色列表</span></span><br><span class="line">List&lt;String&gt; roles = StpUtil.getRoleList(<span class="number">10001L</span>);</span><br></pre></td></tr></table></figure><p>这四个方法的返回值就是 <code>StpInterfaceImpl</code> 中两个方法的返回值。Sa-Token 在这里充当代理——你调用 <code>StpUtil.getPermissionList()</code>，框架内部调用 <code>StpInterfaceImpl.getPermissionList(loginId, loginType)</code>，然后把结果透传给你。</p><p>我们先在 <code>PermissionController</code> 中追加一个接口来暴露这些数据，后续测试时也可以用它来确认权限数据是否正确加载：</p><p>📄 <code>src/main/java/com/example/authsatoken/controller/PermissionController.java</code>（修改，追加方法）</p><figure class="highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><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><span class="line"><span class="keyword">import</span> java.util.Map;</span><br><span class="line"></span><br><span class="line"><span class="comment">/**</span></span><br><span class="line"><span class="comment"> * 查询当前登录用户的权限和角色列表</span></span><br><span class="line"><span class="comment"> * 前端可用此接口实现动态菜单权限控制</span></span><br><span class="line"><span class="comment"> */</span></span><br><span class="line"><span class="meta">@GetMapping(&quot;/myAuth&quot;)</span></span><br><span class="line"><span class="keyword">public</span> SaResult <span class="title function_">myAuth</span><span class="params">()</span> &#123;</span><br><span class="line">    StpUtil.checkLogin();</span><br><span class="line">    <span class="keyword">return</span> SaResult.data(Map.of(</span><br><span class="line">            <span class="string">&quot;roles&quot;</span>,       StpUtil.getRoleList(),</span><br><span class="line">            <span class="string">&quot;permissions&quot;</span>, StpUtil.getPermissionList()</span><br><span class="line">    ));</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><hr><h2 id="4-3-补充-editor-账号">4.3. 补充 editor 账号</h2><p>本章的实战场景需要三种角色——admin、editor、user——来覆盖不同权限分支的测试路径。当前 <code>StpInterfaceImpl</code> 和 <code>LoginController</code> 都没有 editor 账号，先补进去。</p><p>📄 <code>src/main/java/com/example/authsatoken/auth/StpInterfaceImpl.java</code>（修改，更新两个 Map）</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></pre></td><td class="code"><pre><span class="line"><span class="keyword">private</span> <span class="keyword">static</span> <span class="keyword">final</span> Map&lt;String, List&lt;String&gt;&gt; ROLE_PERMISSION_MAP = Map.of(</span><br><span class="line">        <span class="string">&quot;admin&quot;</span>,  List.of(<span class="string">&quot;user:add&quot;</span>, <span class="string">&quot;user:delete&quot;</span>, <span class="string">&quot;user:update&quot;</span>, <span class="string">&quot;user:view&quot;</span>),</span><br><span class="line">        <span class="string">&quot;editor&quot;</span>, List.of(<span class="string">&quot;article:add&quot;</span>, <span class="string">&quot;article:update&quot;</span>, <span class="string">&quot;article:view&quot;</span>, <span class="string">&quot;article:publish&quot;</span>),</span><br><span class="line">        <span class="string">&quot;user&quot;</span>,   List.of(<span class="string">&quot;user:view&quot;</span>, <span class="string">&quot;article:view&quot;</span>)</span><br><span class="line">);</span><br><span class="line"></span><br><span class="line"><span class="keyword">private</span> <span class="keyword">static</span> <span class="keyword">final</span> Map&lt;String, List&lt;String&gt;&gt; USER_ROLE_MAP = Map.of(</span><br><span class="line">        <span class="string">&quot;10001&quot;</span>, List.of(<span class="string">&quot;admin&quot;</span>),</span><br><span class="line">        <span class="string">&quot;10002&quot;</span>, List.of(<span class="string">&quot;user&quot;</span>),</span><br><span class="line">        <span class="string">&quot;10003&quot;</span>, List.of(<span class="string">&quot;editor&quot;</span>)   <span class="comment">// 新增 editor 账号映射</span></span><br><span class="line">);</span><br></pre></td></tr></table></figure><p>📄 <code>src/main/java/com/example/authsatoken/controller/LoginController.java</code>（修改，更新 USER_DB）</p><figure class="highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">private</span> <span class="keyword">static</span> <span class="keyword">final</span> Map&lt;String, <span class="type">long</span>[]&gt; USER_DB = Map.of(</span><br><span class="line">        <span class="string">&quot;admin&quot;</span>,  <span class="keyword">new</span> <span class="title class_">long</span>[]&#123;<span class="number">123456L</span>, <span class="number">10001L</span>&#125;,</span><br><span class="line">        <span class="string">&quot;user&quot;</span>,   <span class="keyword">new</span> <span class="title class_">long</span>[]&#123;<span class="number">123456L</span>, <span class="number">10002L</span>&#125;,</span><br><span class="line">        <span class="string">&quot;editor&quot;</span>, <span class="keyword">new</span> <span class="title class_">long</span>[]&#123;<span class="number">123456L</span>, <span class="number">10003L</span>&#125;   <span class="comment">// 新增 editor 账号</span></span><br><span class="line">);</span><br></pre></td></tr></table></figure><p>同时，<code>SaTokenConfig</code> 中规则一的白名单记得更新——<code>/auth/login</code> 已经在里面了，不需要额外改动。</p><hr><h2 id="4-4-实战：文章管理系统的动态权限判断">4.4. 实战：文章管理系统的动态权限判断</h2><p>用一个完整的业务场景来体验编程式鉴权的价值。我们要实现一个文章管理系统，权限规则如下：</p><ol><li>所有登录用户都可以创建文章（默认草稿状态）</li><li>只有作者本人可以提交自己的文章审核，且只能提交草稿状态的文章</li><li>只有 <code>editor</code> 或 <code>admin</code> 角色可以审核文章</li><li><code>editor</code> 只能发布已审核通过的文章，<code>admin</code> 可以强制发布任何状态的文章</li><li>作者可以撤回自己处于草稿或待审核状态的文章，<code>admin</code> 可以撤回任何文章</li><li>已发布的文章所有登录用户可以查看，未发布的文章只有作者本人、<code>editor</code>、<code>admin</code> 可以查看</li></ol><p>这六条规则涉及<strong>角色判断、数据归属判断、状态判断</strong>三种维度的组合，任何一条都无法用单一注解表达。</p><p>首先在 <code>com.example.authsatoken</code> 包下新建 <code>model</code> 子包，创建文章状态枚举和文章模型：</p><p>📄 <code>src/main/java/com/example/authsatoken/model/ArticleStatus.java</code>（新建）</p><figure class="highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><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><span class="line"><span class="keyword">package</span> com.example.authsatoken.model;</span><br><span class="line"></span><br><span class="line"><span class="comment">/**</span></span><br><span class="line"><span class="comment"> * 文章状态枚举</span></span><br><span class="line"><span class="comment"> */</span></span><br><span class="line"><span class="keyword">public</span> <span class="keyword">enum</span> <span class="title class_">ArticleStatus</span> &#123;</span><br><span class="line">    DRAFT,      <span class="comment">// 草稿</span></span><br><span class="line">    PENDING,    <span class="comment">// 待审核</span></span><br><span class="line">    APPROVED,   <span class="comment">// 已审核通过</span></span><br><span class="line">    REJECTED,   <span class="comment">// 已审核拒绝</span></span><br><span class="line">    PUBLISHED   <span class="comment">// 已发布</span></span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>📄 <code>src/main/java/com/example/authsatoken/model/Article.java</code>（新建）</p><figure class="highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><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><span class="line"><span class="keyword">package</span> com.example.authsatoken.model;</span><br><span class="line"></span><br><span class="line"><span class="comment">/**</span></span><br><span class="line"><span class="comment"> * 文章模型（简化版，聚焦权限逻辑）</span></span><br><span class="line"><span class="comment"> */</span></span><br><span class="line"><span class="keyword">public</span> <span class="keyword">class</span> <span class="title class_">Article</span> &#123;</span><br><span class="line">    <span class="keyword">private</span> Long id;</span><br><span class="line">    <span class="keyword">private</span> String title;</span><br><span class="line">    <span class="keyword">private</span> Long authorId;        <span class="comment">// 作者的 userId</span></span><br><span class="line">    <span class="keyword">private</span> ArticleStatus status; <span class="comment">// 当前文章状态</span></span><br><span class="line"></span><br><span class="line">    <span class="keyword">public</span> <span class="title function_">Article</span><span class="params">(Long id, String title, Long authorId, ArticleStatus status)</span> &#123;</span><br><span class="line">        <span class="built_in">this</span>.id = id;</span><br><span class="line">        <span class="built_in">this</span>.title = title;</span><br><span class="line">        <span class="built_in">this</span>.authorId = authorId;</span><br><span class="line">        <span class="built_in">this</span>.status = status;</span><br><span class="line">    &#125;</span><br><span class="line"></span><br><span class="line">    <span class="keyword">public</span> Long <span class="title function_">getId</span><span class="params">()</span>                    &#123; <span class="keyword">return</span> id; &#125;</span><br><span class="line">    <span class="keyword">public</span> String <span class="title function_">getTitle</span><span class="params">()</span>               &#123; <span class="keyword">return</span> title; &#125;</span><br><span class="line">    <span class="keyword">public</span> Long <span class="title function_">getAuthorId</span><span class="params">()</span>              &#123; <span class="keyword">return</span> authorId; &#125;</span><br><span class="line">    <span class="keyword">public</span> ArticleStatus <span class="title function_">getStatus</span><span class="params">()</span>       &#123; <span class="keyword">return</span> status; &#125;</span><br><span class="line">    <span class="keyword">public</span> <span class="keyword">void</span> <span class="title function_">setStatus</span><span class="params">(ArticleStatus s)</span> &#123; <span class="built_in">this</span>.status = s; &#125;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>然后创建文章管理控制器。我们分三个部分拆解，每个方法都标注清楚权限判断的维度和用 <code>has</code> 系列而非注解的原因：</p><p>📄 <code>src/main/java/com/example/authsatoken/controller/ArticleController.java</code>（新建）</p><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></pre></td><td class="code"><pre><span class="line"><span class="keyword">package</span> com.example.authsatoken.controller;</span><br><span class="line"></span><br><span class="line"><span class="keyword">import</span> cn.dev33.satoken.stp.StpUtil;</span><br><span class="line"><span class="keyword">import</span> cn.dev33.satoken.util.SaResult;</span><br><span class="line"><span class="keyword">import</span> com.example.authsatoken.model.Article;</span><br><span class="line"><span class="keyword">import</span> com.example.authsatoken.model.ArticleStatus;</span><br><span class="line"><span class="keyword">import</span> org.springframework.web.bind.annotation.*;</span><br><span class="line"></span><br><span class="line"><span class="keyword">import</span> java.util.Map;</span><br><span class="line"><span class="keyword">import</span> java.util.concurrent.ConcurrentHashMap;</span><br><span class="line"><span class="keyword">import</span> java.util.concurrent.atomic.AtomicLong;</span><br><span class="line"></span><br><span class="line"><span class="comment">/**</span></span><br><span class="line"><span class="comment"> * 文章管理控制器</span></span><br><span class="line"><span class="comment"> * 演示编程式鉴权在多角色、多状态、数据归属三维度组合场景下的应用</span></span><br><span class="line"><span class="comment"> *</span></span><br><span class="line"><span class="comment"> * 所有接口均依赖路由拦截的全局登录校验，无需在每个方法上重复加 <span class="doctag">@SaCheckLogin</span></span></span><br><span class="line"><span class="comment"> *</span></span><br><span class="line"><span class="comment"> * 权限角色对照（来自 StpInterfaceImpl）：</span></span><br><span class="line"><span class="comment"> *   admin  → userId=10001 → user:add / user:delete / user:update / user:view</span></span><br><span class="line"><span class="comment"> *   user   → userId=10002 → user:view / article:view</span></span><br><span class="line"><span class="comment"> *   editor → userId=10003 → article:add / article:update / article:view / article:publish</span></span><br><span class="line"><span class="comment"> */</span></span><br><span class="line"><span class="meta">@RestController</span></span><br><span class="line"><span class="meta">@RequestMapping(&quot;/articles&quot;)</span></span><br><span class="line"><span class="keyword">public</span> <span class="keyword">class</span> <span class="title class_">ArticleController</span> &#123;</span><br><span class="line"></span><br><span class="line">    <span class="comment">// 模拟数据库：实际项目中替换为 Repository 层</span></span><br><span class="line">    <span class="keyword">private</span> <span class="keyword">final</span> Map&lt;Long, Article&gt; articleDb = <span class="keyword">new</span> <span class="title class_">ConcurrentHashMap</span>&lt;&gt;();</span><br><span class="line">    <span class="keyword">private</span> <span class="keyword">final</span> <span class="type">AtomicLong</span> <span class="variable">idGenerator</span> <span class="operator">=</span> <span class="keyword">new</span> <span class="title class_">AtomicLong</span>(<span class="number">1</span>);</span><br><span class="line"></span><br><span class="line">    <span class="comment">/**</span></span><br><span class="line"><span class="comment">     * 创建文章</span></span><br><span class="line"><span class="comment">     * 权限规则：所有登录用户都可以创建，路由拦截已保证登录校验，此处无需额外判断</span></span><br><span class="line"><span class="comment">     */</span></span><br><span class="line">    <span class="meta">@PostMapping</span></span><br><span class="line">    <span class="keyword">public</span> SaResult <span class="title function_">createArticle</span><span class="params">(<span class="meta">@RequestParam</span> String title)</span> &#123;</span><br><span class="line">        <span class="type">long</span> <span class="variable">currentUserId</span> <span class="operator">=</span> StpUtil.getLoginIdAsLong();</span><br><span class="line">        <span class="type">Article</span> <span class="variable">article</span> <span class="operator">=</span> <span class="keyword">new</span> <span class="title class_">Article</span>(</span><br><span class="line">                idGenerator.getAndIncrement(),</span><br><span class="line">                title,</span><br><span class="line">                currentUserId,</span><br><span class="line">                ArticleStatus.DRAFT</span><br><span class="line">        );</span><br><span class="line">        articleDb.put(article.getId(), article);</span><br><span class="line">        <span class="keyword">return</span> SaResult.ok(<span class="string">&quot;文章创建成功&quot;</span>).setData(article.getId());</span><br><span class="line">    &#125;</span><br><span class="line"></span><br><span class="line">    <span class="comment">/**</span></span><br><span class="line"><span class="comment">     * 提交文章审核</span></span><br><span class="line"><span class="comment">     * 权限规则：只有作者本人可以提交，且文章必须处于草稿状态</span></span><br><span class="line"><span class="comment">     *</span></span><br><span class="line"><span class="comment">     * 用编程式鉴权而非注解的原因：</span></span><br><span class="line"><span class="comment">     * 需要同时判断&quot;数据归属&quot;（是不是自己的文章）和&quot;文章状态&quot;（是不是草稿）</span></span><br><span class="line"><span class="comment">     * 这两个维度都是运行时数据，注解无法表达</span></span><br><span class="line"><span class="comment">     */</span></span><br><span class="line">    <span class="meta">@PostMapping(&quot;/&#123;id&#125;/submit&quot;)</span></span><br><span class="line">    <span class="keyword">public</span> SaResult <span class="title function_">submitForReview</span><span class="params">(<span class="meta">@PathVariable</span> Long id)</span> &#123;</span><br><span class="line">        <span class="type">Article</span> <span class="variable">article</span> <span class="operator">=</span> articleDb.get(id);</span><br><span class="line">        <span class="keyword">if</span> (article == <span class="literal">null</span>) <span class="keyword">return</span> SaResult.error(<span class="string">&quot;文章不存在&quot;</span>);</span><br><span class="line"></span><br><span class="line">        <span class="type">long</span> <span class="variable">currentUserId</span> <span class="operator">=</span> StpUtil.getLoginIdAsLong();</span><br><span class="line"></span><br><span class="line">        <span class="comment">// 数据归属校验：只有作者本人可以提交</span></span><br><span class="line">        <span class="keyword">if</span> (!article.getAuthorId().equals(currentUserId)) &#123;</span><br><span class="line">            <span class="keyword">return</span> SaResult.error(<span class="string">&quot;只能提交自己的文章&quot;</span>).setCode(<span class="number">403</span>);</span><br><span class="line">        &#125;</span><br><span class="line">        <span class="comment">// 状态校验：只有草稿状态可以提交</span></span><br><span class="line">        <span class="keyword">if</span> (article.getStatus() != ArticleStatus.DRAFT) &#123;</span><br><span class="line">            <span class="keyword">return</span> SaResult.error(<span class="string">&quot;只有草稿状态的文章才能提交审核&quot;</span>);</span><br><span class="line">        &#125;</span><br><span class="line"></span><br><span class="line">        article.setStatus(ArticleStatus.PENDING);</span><br><span class="line">        <span class="keyword">return</span> SaResult.ok(<span class="string">&quot;文章已提交审核，等待编辑审核&quot;</span>);</span><br><span class="line">    &#125;</span><br></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></pre></td><td class="code"><pre><span class="line"><span class="comment">/**</span></span><br><span class="line"><span class="comment"> * 审核文章</span></span><br><span class="line"><span class="comment"> * 权限规则：editor 或 admin 角色可以审核，普通用户无权操作</span></span><br><span class="line"><span class="comment"> *</span></span><br><span class="line"><span class="comment"> * 用 hasRoleOr 而非 <span class="doctag">@SaCheckRole</span> 的原因：</span></span><br><span class="line"><span class="comment"> * 如果用注解 <span class="doctag">@SaCheckRole</span>(&quot;editor&quot;)，403 的提示是&quot;缺少 editor 角色&quot;，</span></span><br><span class="line"><span class="comment"> * 但实际上有 admin 角色也能通过——注解无法表达跨类型的 OR 逻辑下更友好的错误提示</span></span><br><span class="line"><span class="comment"> */</span></span><br><span class="line"><span class="meta">@PostMapping(&quot;/&#123;id&#125;/review&quot;)</span></span><br><span class="line"><span class="keyword">public</span> SaResult <span class="title function_">reviewArticle</span><span class="params">(<span class="meta">@PathVariable</span> Long id,</span></span><br><span class="line"><span class="params">                              <span class="meta">@RequestParam</span> <span class="type">boolean</span> approved)</span> &#123;</span><br><span class="line">    <span class="comment">// 角色校验：editor 或 admin 任一即可</span></span><br><span class="line">    <span class="keyword">if</span> (!StpUtil.hasRoleOr(<span class="string">&quot;editor&quot;</span>, <span class="string">&quot;admin&quot;</span>)) &#123;</span><br><span class="line">        <span class="keyword">return</span> SaResult.error(<span class="string">&quot;只有编辑或管理员可以审核文章&quot;</span>).setCode(<span class="number">403</span>);</span><br><span class="line">    &#125;</span><br><span class="line"></span><br><span class="line">    <span class="type">Article</span> <span class="variable">article</span> <span class="operator">=</span> articleDb.get(id);</span><br><span class="line">    <span class="keyword">if</span> (article == <span class="literal">null</span>) <span class="keyword">return</span> SaResult.error(<span class="string">&quot;文章不存在&quot;</span>);</span><br><span class="line"></span><br><span class="line">    <span class="comment">// 状态校验：只有待审核的文章可以审核</span></span><br><span class="line">    <span class="keyword">if</span> (article.getStatus() != ArticleStatus.PENDING) &#123;</span><br><span class="line">        <span class="keyword">return</span> SaResult.error(<span class="string">&quot;只有待审核状态的文章可以审核&quot;</span>);</span><br><span class="line">    &#125;</span><br><span class="line"></span><br><span class="line">    article.setStatus(approved ? ArticleStatus.APPROVED : ArticleStatus.REJECTED);</span><br><span class="line">    <span class="keyword">return</span> SaResult.ok(approved ? <span class="string">&quot;文章审核通过&quot;</span> : <span class="string">&quot;文章审核未通过&quot;</span>);</span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="comment">/**</span></span><br><span class="line"><span class="comment"> * 发布文章</span></span><br><span class="line"><span class="comment"> * 权限规则：</span></span><br><span class="line"><span class="comment"> *   admin  → 可以强制发布任何状态的文章</span></span><br><span class="line"><span class="comment"> *   editor → 只能发布已通过审核的文章</span></span><br><span class="line"><span class="comment"> *   其他   → 无权发布</span></span><br><span class="line"><span class="comment"> *</span></span><br><span class="line"><span class="comment"> * 用编程式鉴权而非注解的原因：</span></span><br><span class="line"><span class="comment"> * 同一个接口对不同角色有不同的状态限制，</span></span><br><span class="line"><span class="comment"> * 注解无法表达&quot;角色 A 跳过状态校验，角色 B 不跳过&quot;这种差异化逻辑</span></span><br><span class="line"><span class="comment"> */</span></span><br><span class="line"><span class="meta">@PostMapping(&quot;/&#123;id&#125;/publish&quot;)</span></span><br><span class="line"><span class="keyword">public</span> SaResult <span class="title function_">publishArticle</span><span class="params">(<span class="meta">@PathVariable</span> Long id)</span> &#123;</span><br><span class="line">    <span class="type">Article</span> <span class="variable">article</span> <span class="operator">=</span> articleDb.get(id);</span><br><span class="line">    <span class="keyword">if</span> (article == <span class="literal">null</span>) <span class="keyword">return</span> SaResult.error(<span class="string">&quot;文章不存在&quot;</span>);</span><br><span class="line"></span><br><span class="line">    <span class="comment">// admin 可以强制发布任何状态的文章，不受状态限制</span></span><br><span class="line">    <span class="keyword">if</span> (StpUtil.hasRole(<span class="string">&quot;admin&quot;</span>)) &#123;</span><br><span class="line">        article.setStatus(ArticleStatus.PUBLISHED);</span><br><span class="line">        <span class="keyword">return</span> SaResult.ok(<span class="string">&quot;管理员强制发布成功&quot;</span>);</span><br><span class="line">    &#125;</span><br><span class="line"></span><br><span class="line">    <span class="comment">// editor 只能发布已审核通过的文章</span></span><br><span class="line">    <span class="keyword">if</span> (StpUtil.hasRole(<span class="string">&quot;editor&quot;</span>)) &#123;</span><br><span class="line">        <span class="keyword">if</span> (article.getStatus() != ArticleStatus.APPROVED) &#123;</span><br><span class="line">            <span class="keyword">return</span> SaResult.error(<span class="string">&quot;编辑只能发布已审核通过的文章，当前状态：&quot;</span> + article.getStatus());</span><br><span class="line">        &#125;</span><br><span class="line">        article.setStatus(ArticleStatus.PUBLISHED);</span><br><span class="line">        <span class="keyword">return</span> SaResult.ok(<span class="string">&quot;文章发布成功&quot;</span>);</span><br><span class="line">    &#125;</span><br><span class="line"></span><br><span class="line">    <span class="comment">// 其他角色无权发布</span></span><br><span class="line">    <span class="keyword">return</span> SaResult.error(<span class="string">&quot;无权发布文章，请联系编辑或管理员&quot;</span>).setCode(<span class="number">403</span>);</span><br><span class="line">&#125;</span><br></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></pre></td><td class="code"><pre><span class="line">    <span class="comment">/**</span></span><br><span class="line"><span class="comment">     * 撤回文章</span></span><br><span class="line"><span class="comment">     * 权限规则：</span></span><br><span class="line"><span class="comment">     *   admin    → 可以撤回任何文章到草稿状态</span></span><br><span class="line"><span class="comment">     *   作者本人  → 只能撤回自己处于草稿或待审核状态的文章</span></span><br><span class="line"><span class="comment">     *   其他     → 无权操作</span></span><br><span class="line"><span class="comment">     *</span></span><br><span class="line"><span class="comment">     * 这里体现了编程式鉴权中最常见的模式：</span></span><br><span class="line"><span class="comment">     * 先判断特权角色（admin），再判断数据归属，最后判断状态</span></span><br><span class="line"><span class="comment">     */</span></span><br><span class="line">    <span class="meta">@PostMapping(&quot;/&#123;id&#125;/withdraw&quot;)</span></span><br><span class="line">    <span class="keyword">public</span> SaResult <span class="title function_">withdrawArticle</span><span class="params">(<span class="meta">@PathVariable</span> Long id)</span> &#123;</span><br><span class="line">        <span class="type">Article</span> <span class="variable">article</span> <span class="operator">=</span> articleDb.get(id);</span><br><span class="line">        <span class="keyword">if</span> (article == <span class="literal">null</span>) <span class="keyword">return</span> SaResult.error(<span class="string">&quot;文章不存在&quot;</span>);</span><br><span class="line"></span><br><span class="line">        <span class="type">long</span> <span class="variable">currentUserId</span> <span class="operator">=</span> StpUtil.getLoginIdAsLong();</span><br><span class="line"></span><br><span class="line">        <span class="comment">// admin 可以撤回任何文章，无需其他判断</span></span><br><span class="line">        <span class="keyword">if</span> (StpUtil.hasRole(<span class="string">&quot;admin&quot;</span>)) &#123;</span><br><span class="line">            article.setStatus(ArticleStatus.DRAFT);</span><br><span class="line">            <span class="keyword">return</span> SaResult.ok(<span class="string">&quot;管理员撤回成功&quot;</span>);</span><br><span class="line">        &#125;</span><br><span class="line"></span><br><span class="line">        <span class="comment">// 非 admin：先校验数据归属</span></span><br><span class="line">        <span class="keyword">if</span> (!article.getAuthorId().equals(currentUserId)) &#123;</span><br><span class="line">            <span class="keyword">return</span> SaResult.error(<span class="string">&quot;只能撤回自己的文章&quot;</span>).setCode(<span class="number">403</span>);</span><br><span class="line">        &#125;</span><br><span class="line"></span><br><span class="line">        <span class="comment">// 再校验状态：只能撤回草稿或待审核状态的文章</span></span><br><span class="line">        <span class="keyword">if</span> (article.getStatus() != ArticleStatus.DRAFT</span><br><span class="line">                &amp;&amp; article.getStatus() != ArticleStatus.PENDING) &#123;</span><br><span class="line">            <span class="keyword">return</span> SaResult.error(<span class="string">&quot;只能撤回草稿或待审核状态的文章，当前状态：&quot;</span> + article.getStatus());</span><br><span class="line">        &#125;</span><br><span class="line"></span><br><span class="line">        article.setStatus(ArticleStatus.DRAFT);</span><br><span class="line">        <span class="keyword">return</span> SaResult.ok(<span class="string">&quot;文章撤回成功&quot;</span>);</span><br><span class="line">    &#125;</span><br><span class="line"></span><br><span class="line">    <span class="comment">/**</span></span><br><span class="line"><span class="comment">     * 查看文章详情</span></span><br><span class="line"><span class="comment">     * 权限规则：</span></span><br><span class="line"><span class="comment">     *   已发布 → 所有登录用户可查看</span></span><br><span class="line"><span class="comment">     *   未发布 → 作者本人、editor、admin 可查看，其他用户无权访问</span></span><br><span class="line"><span class="comment">     *</span></span><br><span class="line"><span class="comment">     * 用编程式鉴权而非注解的原因：</span></span><br><span class="line"><span class="comment">     * 权限判断依赖文章的当前状态（运行时数据），注解在编译期无法感知</span></span><br><span class="line"><span class="comment">     */</span></span><br><span class="line">    <span class="meta">@GetMapping(&quot;/&#123;id&#125;&quot;)</span></span><br><span class="line">    <span class="keyword">public</span> SaResult <span class="title function_">getArticle</span><span class="params">(<span class="meta">@PathVariable</span> Long id)</span> &#123;</span><br><span class="line">        <span class="type">Article</span> <span class="variable">article</span> <span class="operator">=</span> articleDb.get(id);</span><br><span class="line">        <span class="keyword">if</span> (article == <span class="literal">null</span>) <span class="keyword">return</span> SaResult.error(<span class="string">&quot;文章不存在&quot;</span>);</span><br><span class="line"></span><br><span class="line">        <span class="comment">// 已发布的文章所有登录用户都可以查看</span></span><br><span class="line">        <span class="keyword">if</span> (article.getStatus() == ArticleStatus.PUBLISHED) &#123;</span><br><span class="line">            <span class="keyword">return</span> SaResult.data(article);</span><br><span class="line">        &#125;</span><br><span class="line"></span><br><span class="line">        <span class="type">long</span> <span class="variable">currentUserId</span> <span class="operator">=</span> StpUtil.getLoginIdAsLong();</span><br><span class="line"></span><br><span class="line">        <span class="comment">// 未发布文章：管理员和编辑可以查看</span></span><br><span class="line">        <span class="keyword">if</span> (StpUtil.hasRoleOr(<span class="string">&quot;admin&quot;</span>, <span class="string">&quot;editor&quot;</span>)) &#123;</span><br><span class="line">            <span class="keyword">return</span> SaResult.data(article);</span><br><span class="line">        &#125;</span><br><span class="line"></span><br><span class="line">        <span class="comment">// 未发布文章：作者本人可以查看自己的文章</span></span><br><span class="line">        <span class="keyword">if</span> (article.getAuthorId().equals(currentUserId)) &#123;</span><br><span class="line">            <span class="keyword">return</span> SaResult.data(article);</span><br><span class="line">        &#125;</span><br><span class="line"></span><br><span class="line">        <span class="keyword">return</span> SaResult.error(<span class="string">&quot;无权查看此文章&quot;</span>).setCode(<span class="number">403</span>);</span><br><span class="line">    &#125;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><hr><h2 id="4-5-全流程测试">4.5. 全流程测试</h2><p>重启项目后，按以下顺序验证各场景下的权限判断是否符合预期。</p><p><strong>准备工作：三个账号分别登录，拿到对应 Token</strong></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></pre></td><td class="code"><pre><span class="line">POST http://localhost:8081/auth/login?username=admin&amp;password=123456</span><br><span class="line">POST http://localhost:8081/auth/login?username=editor&amp;password=123456</span><br><span class="line">POST http://localhost:8081/auth/login?username=user&amp;password=123456</span><br></pre></td></tr></table></figure><p>分别记录 Token-A（admin）、Token-E（editor）、Token-U（user）。</p><p><strong>场景一：user 创建文章，记录文章 ID</strong></p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line">POST http://localhost:8081/articles?title=我的第一篇文章</span><br><span class="line">Header: satoken: &lt;Token-U&gt;</span><br></pre></td></tr></table></figure><p>预期响应：</p><figure class="highlight json"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line"><span class="punctuation">&#123;</span> <span class="attr">&quot;code&quot;</span><span class="punctuation">:</span> <span class="number">200</span><span class="punctuation">,</span> <span class="attr">&quot;msg&quot;</span><span class="punctuation">:</span> <span class="string">&quot;文章创建成功&quot;</span><span class="punctuation">,</span> <span class="attr">&quot;data&quot;</span><span class="punctuation">:</span> <span class="number">1</span> <span class="punctuation">&#125;</span></span><br></pre></td></tr></table></figure><p><strong>场景二：user 提交审核</strong></p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line">POST http://localhost:8081/articles/1/submit</span><br><span class="line">Header: satoken: &lt;Token-U&gt;</span><br></pre></td></tr></table></figure><p>预期响应：</p><figure class="highlight json"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line"><span class="punctuation">&#123;</span> <span class="attr">&quot;code&quot;</span><span class="punctuation">:</span> <span class="number">200</span><span class="punctuation">,</span> <span class="attr">&quot;msg&quot;</span><span class="punctuation">:</span> <span class="string">&quot;文章已提交审核，等待编辑审核&quot;</span><span class="punctuation">,</span> <span class="attr">&quot;data&quot;</span><span class="punctuation">:</span> <span class="literal"><span class="keyword">null</span></span> <span class="punctuation">&#125;</span></span><br></pre></td></tr></table></figure><p><strong>场景三：user 尝试审核文章（应被拒绝）</strong></p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line">POST http://localhost:8081/articles/1/review?approved=true</span><br><span class="line">Header: satoken: &lt;Token-U&gt;</span><br></pre></td></tr></table></figure><p>预期响应：</p><figure class="highlight json"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line"><span class="punctuation">&#123;</span> <span class="attr">&quot;code&quot;</span><span class="punctuation">:</span> <span class="number">403</span><span class="punctuation">,</span> <span class="attr">&quot;msg&quot;</span><span class="punctuation">:</span> <span class="string">&quot;只有编辑或管理员可以审核文章&quot;</span><span class="punctuation">,</span> <span class="attr">&quot;data&quot;</span><span class="punctuation">:</span> <span class="literal"><span class="keyword">null</span></span> <span class="punctuation">&#125;</span></span><br></pre></td></tr></table></figure><p><strong>场景四：editor 审核文章</strong></p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line">POST http://localhost:8081/articles/1/review?approved=true</span><br><span class="line">Header: satoken: &lt;Token-E&gt;</span><br></pre></td></tr></table></figure><p>预期响应：</p><figure class="highlight json"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line"><span class="punctuation">&#123;</span> <span class="attr">&quot;code&quot;</span><span class="punctuation">:</span> <span class="number">200</span><span class="punctuation">,</span> <span class="attr">&quot;msg&quot;</span><span class="punctuation">:</span> <span class="string">&quot;文章审核通过&quot;</span><span class="punctuation">,</span> <span class="attr">&quot;data&quot;</span><span class="punctuation">:</span> <span class="literal"><span class="keyword">null</span></span> <span class="punctuation">&#125;</span></span><br></pre></td></tr></table></figure><p><strong>场景五：user 尝试发布文章（应被拒绝）</strong></p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line">POST http://localhost:8081/articles/1/publish</span><br><span class="line">Header: satoken: &lt;Token-U&gt;</span><br></pre></td></tr></table></figure><p>预期响应：</p><figure class="highlight json"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line"><span class="punctuation">&#123;</span> <span class="attr">&quot;code&quot;</span><span class="punctuation">:</span> <span class="number">403</span><span class="punctuation">,</span> <span class="attr">&quot;msg&quot;</span><span class="punctuation">:</span> <span class="string">&quot;无权发布文章，请联系编辑或管理员&quot;</span><span class="punctuation">,</span> <span class="attr">&quot;data&quot;</span><span class="punctuation">:</span> <span class="literal"><span class="keyword">null</span></span> <span class="punctuation">&#125;</span></span><br></pre></td></tr></table></figure><p><strong>场景六：editor 发布已审核的文章</strong></p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line">POST http://localhost:8081/articles/1/publish</span><br><span class="line">Header: satoken: &lt;Token-E&gt;</span><br></pre></td></tr></table></figure><p>预期响应：</p><figure class="highlight json"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line"><span class="punctuation">&#123;</span> <span class="attr">&quot;code&quot;</span><span class="punctuation">:</span> <span class="number">200</span><span class="punctuation">,</span> <span class="attr">&quot;msg&quot;</span><span class="punctuation">:</span> <span class="string">&quot;文章发布成功&quot;</span><span class="punctuation">,</span> <span class="attr">&quot;data&quot;</span><span class="punctuation">:</span> <span class="literal"><span class="keyword">null</span></span> <span class="punctuation">&#125;</span></span><br></pre></td></tr></table></figure><p><strong>场景七：admin 强制发布草稿状态的文章</strong></p><p>先让 user 再创建一篇文章（不提交审核，保持草稿状态），记录 ID 为 2，然后：</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line">POST http://localhost:8081/articles/2/publish</span><br><span class="line">Header: satoken: &lt;Token-A&gt;</span><br></pre></td></tr></table></figure><p>预期响应：</p><figure class="highlight json"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line"><span class="punctuation">&#123;</span> <span class="attr">&quot;code&quot;</span><span class="punctuation">:</span> <span class="number">200</span><span class="punctuation">,</span> <span class="attr">&quot;msg&quot;</span><span class="punctuation">:</span> <span class="string">&quot;管理员强制发布成功&quot;</span><span class="punctuation">,</span> <span class="attr">&quot;data&quot;</span><span class="punctuation">:</span> <span class="literal"><span class="keyword">null</span></span> <span class="punctuation">&#125;</span></span><br></pre></td></tr></table></figure><p>admin 越过了&quot;必须先审核&quot;的流程限制，直接发布——这是 <code>hasRole(&quot;admin&quot;)</code> 分支优先执行的效果。</p><p><strong>场景八：验证已发布文章的访问权限</strong></p><p>文章 1 已发布，用 <code>user</code> 的 Token 访问：</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line">GET http://localhost:8081/articles/1</span><br><span class="line">Header: satoken: &lt;Token-U&gt;</span><br></pre></td></tr></table></figure><p>预期：正常返回文章数据（已发布，所有登录用户可查看）。</p><p><strong>场景九：验证未发布文章的访问权限</strong></p><p>再创建一篇文章（保持草稿，不提交），ID 为 3，作者是 user（userId=10002）。用 Token-E（editor）访问：</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line">GET http://localhost:8081/articles/3</span><br><span class="line">Header: satoken: &lt;Token-E&gt;</span><br></pre></td></tr></table></figure><p>预期：正常返回（editor 可查看任何文章）。</p><p>再用另一个 user 账号登录（此处用同一个 user 账号模拟&quot;他人&quot;即可，因为文章 3 的 authorId 是 10002，当前请求者也是 10002，所以会走作者本人分支——如需严格测试，可创建第二个 user 账号来验证&quot;他人无权查看草稿&quot;场景）。</p><p>以下是完整的权限测试矩阵，对照验证：</p><table><thead><tr><th>操作</th><th>admin</th><th>editor</th><th>user（作者）</th><th>未登录</th></tr></thead><tbody><tr><td>创建文章</td><td>✅</td><td>✅</td><td>✅</td><td>❌ 401</td></tr><tr><td>提交审核（自己的）</td><td>✅</td><td>✅</td><td>✅</td><td>❌ 401</td></tr><tr><td>提交审核（别人的）</td><td>❌ 403 归属</td><td>❌ 403 归属</td><td>❌ 403 归属</td><td>❌ 401</td></tr><tr><td>审核文章</td><td>✅</td><td>✅</td><td>❌ 403 角色</td><td>❌ 401</td></tr><tr><td>发布（草稿/待审）</td><td>✅ 强制</td><td>❌ 403 状态</td><td>❌ 403 角色</td><td>❌ 401</td></tr><tr><td>发布（已审核）</td><td>✅</td><td>✅</td><td>❌ 403 角色</td><td>❌ 401</td></tr><tr><td>撤回（自己的文章）</td><td>✅</td><td>❌ 403 归属</td><td>✅（草稿/待审）</td><td>❌ 401</td></tr><tr><td>撤回（任意文章）</td><td>✅</td><td>❌ 403 归属</td><td>❌ 403 归属</td><td>❌ 401</td></tr><tr><td>查看（已发布）</td><td>✅</td><td>✅</td><td>✅</td><td>❌ 401</td></tr><tr><td>查看（未发布，自己的）</td><td>✅</td><td>✅</td><td>✅</td><td>❌ 401</td></tr><tr><td>查看（未发布，他人的）</td><td>✅</td><td>✅</td><td>❌ 403</td><td>❌ 401</td></tr></tbody></table><hr><h2 id="4-6-本章总结">4.6. 本章总结</h2><p><strong>本章回顾</strong></p><p>本章完成了编程式鉴权的完整建设。在 API 层面，系统梳理了 <code>has</code> 系列（返回 boolean，适合条件分支）和 <code>check</code> 系列（抛异常，适合直接中断）两组方法的完整签名，以及 <code>getPermissionList()</code> 和 <code>getRoleList()</code> 四个获取型 API 的用法。在实战层面，文章管理系统的六条业务规则覆盖了编程式鉴权的三种核心应用场景：<strong>纯角色判断</strong>（<code>hasRoleOr</code> 做多角色 OR 分支）、<strong>角色 + 状态组合判断</strong>（不同角色跳过不同的状态限制）、<strong>角色 + 数据归属组合判断</strong>（admin 全局权限优先，作者只能操作自己的数据）。为了覆盖三种角色的完整测试路径，同步在 <code>LoginController</code> 和 <code>StpInterfaceImpl</code> 中补充了 editor 账号及其权限映射。九个测试场景从&quot;创建 → 提交 → 审核 → 发布 → 查看&quot;完整串联了文章的生命周期，最终形成覆盖四种身份的权限测试矩阵。</p><p><strong>核心汇总表</strong></p><table><thead><tr><th>API 分组</th><th>方法示例</th><th>返回值</th><th>失败行为</th><th>适用场景</th></tr></thead><tbody><tr><td>has 系列</td><td><code>hasRole()</code> / <code>hasPermission()</code></td><td><code>boolean</code></td><td>返回 false</td><td>条件分支（if-else）</td></tr><tr><td>has 系列（多值）</td><td><code>hasRoleOr()</code> / <code>hasPermissionAnd()</code></td><td><code>boolean</code></td><td>返回 false</td><td>多角色/权限组合判断</td></tr><tr><td>check 系列</td><td><code>checkRole()</code> / <code>checkPermission()</code></td><td><code>void</code></td><td>抛出异常</td><td>不满足则直接中断请求</td></tr><tr><td>check 系列（多值）</td><td><code>checkRoleOr()</code> / <code>checkPermissionAnd()</code></td><td><code>void</code></td><td>抛出异常</td><td>多角色/权限组合校验</td></tr><tr><td>获取型</td><td><code>getPermissionList()</code> / <code>getRoleList()</code></td><td><code>List&lt;String&gt;</code></td><td>—</td><td>返回完整权限/角色列表</td></tr></tbody></table><table><thead><tr><th>编程式鉴权的三种核心场景</th><th>判断维度</th><th>推荐写法</th></tr></thead><tbody><tr><td>纯角色 / 权限判断</td><td>只看角色或权限码</td><td><code>hasRole</code> / <code>hasRoleOr</code> + if-else</td></tr><tr><td>角色 + 状态组合</td><td>不同角色跳过不同状态限制</td><td>先判断高权限角色（admin），再判断低权限角色条件</td></tr><tr><td>角色 + 数据归属组合</td><td>特权角色不受归属限制，普通用户只能操作自己的</td><td>先判断特权角色，再判断归属，最后判断状态</td></tr></tbody></table><table><thead><tr><th>注解鉴权 vs 编程式鉴权</th><th>注解无法表达的场景</th></tr></thead><tbody><tr><td>数据归属判断</td><td>只有作者本人可以操作</td></tr><tr><td>状态依赖的权限</td><td>文章必须是&quot;已审核&quot;才能发布</td></tr><tr><td>角色差异化分支</td><td>admin 强制发布，editor 受状态限制</td></tr><tr><td>运行时数据组合条件</td><td>任何依赖数据库查询结果的权限判断</td></tr></tbody></table></div>]]></content>
    
    
      
      
        
        
    <summary type="html">&lt;div id=&quot;postchat_postcontent&quot;&gt;&lt;h1&gt;第一章.</summary>
        
      
    
    
    
    <category term="后端技术" scheme="https://prorise666.site/categories/%E5%90%8E%E7%AB%AF%E6%8A%80%E6%9C%AF/"/>
    
    <category term="Java" scheme="https://prorise666.site/categories/%E5%90%8E%E7%AB%AF%E6%8A%80%E6%9C%AF/Java/"/>
    
    <category term="Spring系列" scheme="https://prorise666.site/categories/%E5%90%8E%E7%AB%AF%E6%8A%80%E6%9C%AF/Java/Spring%E7%B3%BB%E5%88%97/"/>
    
    <category term="登录注册系列" scheme="https://prorise666.site/categories/%E5%90%8E%E7%AB%AF%E6%8A%80%E6%9C%AF/Java/Spring%E7%B3%BB%E5%88%97/%E7%99%BB%E5%BD%95%E6%B3%A8%E5%86%8C%E7%B3%BB%E5%88%97/"/>
    
    <category term="Sa-Token" scheme="https://prorise666.site/categories/%E5%90%8E%E7%AB%AF%E6%8A%80%E6%9C%AF/Java/Spring%E7%B3%BB%E5%88%97/%E7%99%BB%E5%BD%95%E6%B3%A8%E5%86%8C%E7%B3%BB%E5%88%97/Sa-Token/"/>
    
    
    <category term="Spring生态篇" scheme="https://prorise666.site/tags/Spring%E7%94%9F%E6%80%81%E7%AF%87/"/>
    
    <category term="Sa-Token系列篇" scheme="https://prorise666.site/tags/Sa-Token%E7%B3%BB%E5%88%97%E7%AF%87/"/>
    
  </entry>
  
  <entry>
    <title>登录注册番外篇（二） - Sa-Token：会话全生命周期管理</title>
    <link href="https://prorise666.site/posts/66432.html"/>
    <id>https://prorise666.site/posts/66432.html</id>
    <published>2026-02-08T04:38:17.000Z</published>
    <updated>2026-03-12T09:18:16.955Z</updated>
    
    <content type="html"><![CDATA[<div id="postchat_postcontent"><h1>第一章. 多端登录策略：两个配置项决定一切</h1><p><strong>环境版本</strong></p><table><thead><tr><th>组件</th><th>版本</th></tr></thead><tbody><tr><td>JDK</td><td>17</td></tr><tr><td>Spring Boot</td><td>3.4.x</td></tr><tr><td>Sa-Token</td><td>1.44.0</td></tr><tr><td>Redis</td><td>7.x</td></tr></tbody></table><p><strong>阶段式学习路径</strong></p><p>在番外篇（一）中，我们用 <code>StpUtil.login(10001)</code> 完成了最基础的登录，并通过 Redis 实验观察了会话数据的存储结构。但有一个关键问题一直没有回答：同一个账号能不能在多个设备上同时登录？如果能，上限是多少？如果不能，新登录是挤掉旧登录还是直接拒绝？</p><p>本章我们将深入 Sa-Token 的多端登录策略，理解两个配置项背后的组合逻辑，并把番外篇（一）中的登录接口升级为支持设备类型和差异化策略的完整版本。</p><p>在开始之前，需要先对 <code>application.yml</code> 做一处调整。番外篇（一）中我们配置的是 <code>is-share: true</code>，这会导致同一账号多次登录拿到同一个 Token，无法演示多设备独立会话的效果。将它改为 <code>false</code>：</p><p>📄 <code>src/main/resources/application.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></pre></td><td class="code"><pre><span class="line"><span class="attr">sa-token:</span></span><br><span class="line">  <span class="attr">is-concurrent:</span> <span class="literal">true</span></span><br><span class="line">  <span class="attr">is-share:</span> <span class="literal">false</span>   <span class="comment"># 从 true 改为 false，每次登录生成独立 Token</span></span><br></pre></td></tr></table></figure><p>这一改动的效果是：同一账号在不同设备上登录时，每次都会拿到一个全新的 Token，旧 Token 不失效。我们在后面的测试中会直接看到这个变化。</p><hr><h2 id="1-1-两个配置项决定登录行为">1.1. 两个配置项决定登录行为</h2><p><code>is-concurrent</code> 和 <code>is-share</code> 是两个相互独立的维度，它们的组合决定了整个项目的多端登录策略。</p><p><code>is-concurrent</code> 回答的问题是&quot;允不允许同时在多个设备上登录&quot;。设置为 <code>true</code> 时，同一账号可以在手机、电脑、平板上同时保持登录状态；设置为 <code>false</code> 时，新设备一登录，旧设备的会话立刻失效，同一时刻只有一个有效会话存在。</p><p><code>is-share</code> 在 <code>is-concurrent=true</code> 的前提下才有意义，它回答的是&quot;多个设备是否共用同一个 Token&quot;。设置为 <code>true</code> 时，账号的多次登录会拿到同一个 Token 值（前提是之前的 Token 仍在有效期内）；设置为 <code>false</code> 时，每次登录都生成一个全新的 Token，各设备的会话彼此独立。</p><p>两者组合，产生三种实际可用的登录模式：</p><table><thead><tr><th>is-concurrent</th><th>is-share</th><th>登录模式</th><th>适用场景</th></tr></thead><tbody><tr><td>true</td><td>true</td><td>多端共享 Token</td><td>对安全要求不高、希望减少 Token 数量的场景</td></tr><tr><td>true</td><td>false</td><td>多端独立 Token</td><td>最常用，既允许多设备在线，又能独立管理各设备会话</td></tr><tr><td>false</td><td>—</td><td>单端登录</td><td>对安全敏感的场景，如金融账户</td></tr></tbody></table><p>我们目前的配置是第二种模式——多端独立 Token，也是生产项目中最常见的选择。</p><p>在继续之前，先用一个 Redis 实验直接感受 <code>is-share</code> 前后的差异。</p><p><strong>实验：观察 is-share 切换对 Redis 数据的影响</strong></p><p>将 <code>is-share</code> 临时改回 <code>true</code>，用同一账号连续登录两次，然后查看 Redis：</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><span class="line"><span class="comment"># 查找该账号下的所有 Token 关联键</span></span><br><span class="line">keys sa-token:login:token-list:login:10001</span><br></pre></td></tr></table></figure><p>你会看到列表中只有一条 Token 记录——两次登录共用了同一个 Token 值。</p><p>现在将 <code>is-share</code> 改为 <code>false</code>，再次连续登录两次，重复查询：</p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">keys sa-token:login:token-list:login:10001</span><br></pre></td></tr></table></figure><p>这次列表中出现了两条不同的 Token 记录。每次登录都独立生成了一个新的 Token，两个 Token 同时有效，互不影响。</p><p>这个实验印证了配置含义，也解释了为什么本系列后续的多设备测试必须在 <code>is-share: false</code> 下进行——只有每次登录拿到独立 Token，我们才能观察到&quot;设备 A 被踢下线但设备 B 不受影响&quot;的真实效果。</p><hr><h2 id="1-2-SaLoginParameter：登录时的动态参数">1.2. SaLoginParameter：登录时的动态参数</h2><p>全局配置是项目级的默认策略，但现实中不同角色的登录行为往往不同——管理员可能需要严格的单端限制，普通用户可以多端并行。如果只靠 <code>application.yml</code>，就只能给所有账号统一一套规则。</p><p>Sa-Token 为此提供了 <code>SaLoginParameter</code>，它是登录时的动态参数对象，其中的任何配置都会<strong>覆盖</strong> <code>application.yml</code> 中对应的全局配置，且只对<strong>当次登录</strong>生效。使用时将它作为 <code>StpUtil.login()</code> 的第二个参数传入：</p><figure class="highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br></pre></td><td class="code"><pre><span class="line">StpUtil.login(userId, <span class="keyword">new</span> <span class="title class_">SaLoginParameter</span>()</span><br><span class="line">        .setDeviceType(<span class="string">&quot;PC&quot;</span>)         <span class="comment">// 设备类型</span></span><br><span class="line">        .setIsConcurrent(<span class="literal">true</span>)       <span class="comment">// 是否允许并发（覆盖全局 is-concurrent）</span></span><br><span class="line">        .setIsShare(<span class="literal">false</span>)           <span class="comment">// 是否共用 Token（覆盖全局 is-share）</span></span><br><span class="line">        .setMaxLoginCount(<span class="number">3</span>)         <span class="comment">// 最大同时在线设备数，-1 不限制</span></span><br><span class="line">        .setTimeout(<span class="number">60</span> * <span class="number">60</span> * <span class="number">24</span> * <span class="number">7</span>) <span class="comment">// Token 有效期（秒），覆盖全局 timeout</span></span><br><span class="line">);</span><br></pre></td></tr></table></figure><p>以下是本章会用到的核心配置项说明：</p><table><thead><tr><th>配置方法</th><th>对应全局配置</th><th>说明</th></tr></thead><tbody><tr><td><code>setDeviceType(String)</code></td><td>无全局对应</td><td>设置此次登录的设备类型标识，如 <code>PC</code> / <code>APP</code> / <code>PAD</code></td></tr><tr><td><code>setIsConcurrent(Boolean)</code></td><td><code>is-concurrent</code></td><td>是否允许同一账号并发登录</td></tr><tr><td><code>setIsShare(Boolean)</code></td><td><code>is-share</code></td><td>并发登录时是否共用 Token</td></tr><tr><td><code>setMaxLoginCount(int)</code></td><td>无全局对应</td><td>最大同时在线设备数，超限时自动踢掉最早的会话</td></tr><tr><td><code>setTimeout(long)</code></td><td><code>timeout</code></td><td>本次登录 Token 的有效期（秒）</td></tr></tbody></table><p>理解了 <code>SaLoginParameter</code> 的定位之后，我们来升级登录接口。</p><hr><h2 id="1-3-升级登录接口：双账号-设备类型-差异化策略">1.3. 升级登录接口：双账号 + 设备类型 + 差异化策略</h2><p>番外篇（一）的登录接口只支持一个硬编码账号 <code>admin/123456</code>，且所有用户共用同一个 userId <code>10001</code>。现在我们把它升级为支持多账号、多设备类型、按角色差异化配置的完整版本。</p><p>📄 <code>src/main/java/com/example/authsatoken/controller/LoginController.java</code>（修改，替换 login 方法）</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></pre></td><td class="code"><pre><span class="line"><span class="keyword">package</span> com.example.authsatoken.controller;</span><br><span class="line"></span><br><span class="line"><span class="keyword">import</span> cn.dev33.satoken.stp.SaLoginParameter;</span><br><span class="line"><span class="keyword">import</span> cn.dev33.satoken.stp.StpUtil;</span><br><span class="line"><span class="keyword">import</span> cn.dev33.satoken.util.SaResult;</span><br><span class="line"><span class="keyword">import</span> org.springframework.web.bind.annotation.*;</span><br><span class="line"></span><br><span class="line"><span class="keyword">import</span> java.util.Map;</span><br><span class="line"></span><br><span class="line"><span class="meta">@RestController</span></span><br><span class="line"><span class="meta">@RequestMapping(&quot;/auth&quot;)</span></span><br><span class="line"><span class="keyword">public</span> <span class="keyword">class</span> <span class="title class_">LoginController</span> &#123;</span><br><span class="line"></span><br><span class="line">    <span class="comment">// 模拟用户数据：账号 → [密码, userId]</span></span><br><span class="line">    <span class="comment">// 实际项目中应查询数据库，这里仅用于聚焦 Sa-Token 本身的行为</span></span><br><span class="line">    <span class="keyword">private</span> <span class="keyword">static</span> <span class="keyword">final</span> Map&lt;String, <span class="type">long</span>[]&gt; USER_DB = Map.of(</span><br><span class="line">            <span class="string">&quot;admin&quot;</span>, <span class="keyword">new</span> <span class="title class_">long</span>[]&#123;<span class="number">123456L</span>, <span class="number">10001L</span>&#125;,</span><br><span class="line">            <span class="string">&quot;user&quot;</span>,  <span class="keyword">new</span> <span class="title class_">long</span>[]&#123;<span class="number">123456L</span>, <span class="number">10002L</span>&#125;</span><br><span class="line">    );</span><br><span class="line"></span><br><span class="line">    <span class="comment">/**</span></span><br><span class="line"><span class="comment">     * 登录接口（升级版）</span></span><br><span class="line"><span class="comment">     * 支持设备类型参数，管理员与普通用户采用不同的登录策略</span></span><br><span class="line"><span class="comment">     *</span></span><br><span class="line"><span class="comment">     * <span class="doctag">@param</span> username 用户名</span></span><br><span class="line"><span class="comment">     * <span class="doctag">@param</span> password 密码</span></span><br><span class="line"><span class="comment">     * <span class="doctag">@param</span> device   设备类型（PC / APP / PAD），默认 PC</span></span><br><span class="line"><span class="comment">     */</span></span><br><span class="line">    <span class="meta">@PostMapping(&quot;/login&quot;)</span></span><br><span class="line">    <span class="keyword">public</span> SaResult <span class="title function_">login</span><span class="params">(<span class="meta">@RequestParam</span> String username,</span></span><br><span class="line"><span class="params">                          <span class="meta">@RequestParam</span> String password,</span></span><br><span class="line"><span class="params">                          <span class="meta">@RequestParam(required = false, defaultValue = &quot;PC&quot;)</span> String device)</span> &#123;</span><br><span class="line">        <span class="comment">// 1. 校验账号是否存在</span></span><br><span class="line">        <span class="keyword">if</span> (!USER_DB.containsKey(username)) &#123;</span><br><span class="line">            <span class="keyword">return</span> SaResult.error(<span class="string">&quot;用户名或密码错误&quot;</span>);</span><br><span class="line">        &#125;</span><br><span class="line">        <span class="type">long</span>[] userInfo = USER_DB.get(username);</span><br><span class="line">        <span class="type">long</span> <span class="variable">storedPassword</span> <span class="operator">=</span> userInfo[<span class="number">0</span>];</span><br><span class="line">        <span class="type">long</span> <span class="variable">userId</span> <span class="operator">=</span> userInfo[<span class="number">1</span>];</span><br><span class="line"></span><br><span class="line">        <span class="comment">// 2. 校验密码（实际项目中应对比哈希值，这里简化演示）</span></span><br><span class="line">        <span class="keyword">if</span> (storedPassword != Long.parseLong(password)) &#123;</span><br><span class="line">            <span class="keyword">return</span> SaResult.error(<span class="string">&quot;用户名或密码错误&quot;</span>);</span><br><span class="line">        &#125;</span><br><span class="line"></span><br><span class="line">        <span class="comment">// 3. 根据角色构建差异化的登录参数</span></span><br><span class="line">        <span class="type">boolean</span> <span class="variable">isAdmin</span> <span class="operator">=</span> <span class="string">&quot;admin&quot;</span>.equals(username);</span><br><span class="line">        <span class="type">SaLoginParameter</span> <span class="variable">loginParam</span> <span class="operator">=</span> <span class="keyword">new</span> <span class="title class_">SaLoginParameter</span>()</span><br><span class="line">                .setDeviceType(device)</span><br><span class="line">                <span class="comment">// 管理员：不允许并发，严格单端；普通用户：允许多端</span></span><br><span class="line">                .setIsConcurrent(!isAdmin)</span><br><span class="line">                <span class="comment">// 管理员最多同时在线 1 个设备；普通用户最多 2 个</span></span><br><span class="line">                .setMaxLoginCount(isAdmin ? <span class="number">1</span> : <span class="number">2</span>);</span><br><span class="line"></span><br><span class="line">        <span class="comment">// 4. 执行登录，返回 Token 给前端</span></span><br><span class="line">        StpUtil.login(userId, loginParam);</span><br><span class="line">        <span class="keyword">return</span> SaResult.ok(<span class="string">&quot;登录成功&quot;</span>).setData(StpUtil.getTokenValue());</span><br><span class="line">    &#125;</span><br><span class="line"></span><br><span class="line">    <span class="comment">/**</span></span><br><span class="line"><span class="comment">     * 注销当前会话</span></span><br><span class="line"><span class="comment">     */</span></span><br><span class="line">    <span class="meta">@PostMapping(&quot;/logout&quot;)</span></span><br><span class="line">    <span class="keyword">public</span> SaResult <span class="title function_">logout</span><span class="params">()</span> &#123;</span><br><span class="line">        StpUtil.logout();</span><br><span class="line">        <span class="keyword">return</span> SaResult.ok(<span class="string">&quot;已退出登录&quot;</span>);</span><br><span class="line">    &#125;</span><br><span class="line"></span><br><span class="line">    <span class="comment">/**</span></span><br><span class="line"><span class="comment">     * 查询当前是否已登录（不要求登录即可访问）</span></span><br><span class="line"><span class="comment">     */</span></span><br><span class="line">    <span class="meta">@GetMapping(&quot;/isLogin&quot;)</span></span><br><span class="line">    <span class="keyword">public</span> SaResult <span class="title function_">isLogin</span><span class="params">()</span> &#123;</span><br><span class="line">        <span class="keyword">return</span> SaResult.ok(StpUtil.isLogin());</span><br><span class="line">    &#125;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>有几个关键点值得注意。</p><p><code>USER_DB</code> 用 <code>Map.of()</code> 模拟了一个内存数据库：<code>admin</code> 账号对应 userId <code>10001</code>，<code>user</code> 账号对应 userId <code>10002</code>，两者完全独立。这样后续测试多账号并发、互相踢人等场景时逻辑才是自洽的。</p><p>登录参数的差异化体现在第 3 步：通过 <code>isAdmin</code> 这一个布尔值，<code>admin</code> 账号登录时 <code>setIsConcurrent(false)</code>（覆盖全局的 <code>true</code>），强制单端互斥；<code>user</code> 账号保持 <code>setIsConcurrent(true)</code>，允许多端在线。两个账号使用同一个接口，却执行了截然不同的登录策略。</p><p><code>setMaxLoginCount(isAdmin ? 1 : 2)</code> 只在 <code>isConcurrent=true, isShare=false</code> 时才有意义。对于 <code>admin</code> 账号，<code>isConcurrent</code> 已经是 <code>false</code>，框架层面天然只允许一个会话，这里的 <code>maxLoginCount=1</code> 是为了语义对称。对于 <code>user</code> 账号，<code>maxLoginCount=2</code> 意味着当第 3 个设备登录时，框架会自动将登录时间最早的那个 Token 踢下线。</p><hr><h2 id="1-4-验证同端互斥效果">1.4. 验证同端互斥效果</h2><p>现在重启项目，通过一组测试来验证多端登录策略的完整行为。测试过程中在 Postman 的 Header 中携带 <code>satoken: &lt;Token值&gt;</code> 发起请求。</p><p><strong>步骤 1：user 账号以 PC 端登录，记录返回的 Token-A</strong></p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">POST http://localhost:8081/auth/login?username=user&amp;password=123456&amp;device=PC</span><br></pre></td></tr></table></figure><p>响应中 <code>data</code> 字段的值就是 Token-A，复制备用。</p><p><strong>步骤 2：同一 user 账号以 APP 端登录，记录返回的 Token-B</strong></p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">POST http://localhost:8081/auth/login?username=user&amp;password=123456&amp;device=APP</span><br></pre></td></tr></table></figure><p>此时 Token-A 和 Token-B 同时有效——两个不同设备类型的会话互不干扰。</p><p><strong>步骤 3：再次以 PC 端登录，记录返回的 Token-C</strong></p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">POST http://localhost:8081/auth/login?username=user&amp;password=123456&amp;device=PC</span><br></pre></td></tr></table></figure><p><strong>步骤 4：验证 Token-A 已被顶替，Token-B 仍然有效</strong></p><p>用 Token-A 访问任意需要登录的接口（比如后续章节会添加的 <code>/auth/info</code>）：</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line">GET http://localhost:8081/auth/isLogin</span><br><span class="line">Header: satoken: &lt;Token-A的值&gt;</span><br></pre></td></tr></table></figure><div class="note warning simple"><p>此步骤验证的是 replaced（顶替下线）行为。当前 isLogin 接口不主动校验登录，返回的是 <code>false</code> 而非异常。第四章添加全局异常处理器后，携带已顶替 Token 访问需要登录的接口，将收到 <code>{ &quot;code&quot;: 401, &quot;msg&quot;: &quot;您的账号已在其他设备登录，当前会话已下线&quot; }</code> 的 JSON 响应。</p></div><p>用 Token-B（APP 端）重复上述请求，仍然返回 <code>true</code>——APP 端完全没有受到影响。这就是<strong>同端互斥</strong>的核心表现：<strong>同设备类型互斥，不同设备类型共存</strong>。</p><p><strong>步骤 5：验证 admin 账号的单端限制</strong></p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">POST http://localhost:8081/auth/login?username=admin&amp;password=123456&amp;device=PC</span><br></pre></td></tr></table></figure><p>记录 Token-D，然后再次以同账号任意设备类型登录：</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">POST http://localhost:8081/auth/login?username=admin&amp;password=123456&amp;device=APP</span><br></pre></td></tr></table></figure><p>用 Token-D 访问接口，会发现它已经失效——admin 账号的 <code>isConcurrent=false</code> 使得任何新登录都会顶替所有旧会话，无论设备类型是否相同。</p><p>以下是测试结果的完整矩阵，对照验证：</p><table><thead><tr><th>操作</th><th>Token-A (user/PC)</th><th>Token-B (user/APP)</th><th>Token-C (user/PC)</th><th>Token-D (admin/PC)</th></tr></thead><tbody><tr><td>user/PC 登录后</td><td>✅ 有效</td><td>—</td><td>—</td><td>—</td></tr><tr><td>user/APP 登录后</td><td>✅ 有效</td><td>✅ 有效</td><td>—</td><td>—</td></tr><tr><td>user/PC 再次登录后</td><td>❌ 被顶替</td><td>✅ 有效</td><td>✅ 有效</td><td>—</td></tr><tr><td>admin/APP 登录后</td><td>❌ 无关</td><td>❌ 无关</td><td>❌ 无关</td><td>❌ 被顶替</td></tr></tbody></table><hr><h2 id="1-5-本章总结">1.5. 本章总结</h2><p><strong>本章回顾</strong></p><p>本章完成了登录策略从&quot;全局统一&quot;到&quot;按角色差异化&quot;的升级。我们首先通过 Redis 实验直观感受了 <code>is-share</code> 切换前后的存储差异，确立了多端独立 Token 作为后续演示基础的必要性。随后系统介绍了 <code>SaLoginParameter</code> 的五个核心配置方法，理解了它作为&quot;登录时动态覆盖全局配置&quot;的定位。在代码层面，登录接口从单一硬编码账号升级为双账号独立 userId 的模拟数据结构，并通过三元表达式实现了&quot;管理员单端、普通用户多端（上限 2 个）&quot;的差异化策略。最后通过多步骤测试验证了同端互斥的完整行为，形成了可供对照的测试矩阵。</p><p><strong>核心汇总表</strong></p><table><thead><tr><th>配置组合</th><th>行为</th><th>典型场景</th></tr></thead><tbody><tr><td><code>is-concurrent=true, is-share=true</code></td><td>多设备共用一个 Token</td><td>简单项目，对设备隔离要求低</td></tr><tr><td><code>is-concurrent=true, is-share=false</code></td><td>多设备各持独立 Token</td><td>主流选择，支持独立设备管理</td></tr><tr><td><code>is-concurrent=false</code></td><td>新登录挤掉一切旧登录</td><td>金融、安全敏感场景</td></tr></tbody></table><h1>第二章. Token 生命周期管理</h1><p><strong>阶段式学习路径</strong></p><p>第一章解决了&quot;同一个账号能不能多端登录&quot;的问题。但还有一个问题没有回答：Token 什么时候会自然失效？用户在手机上登录后，放一周不操作，再打开 App 还需要重新登录吗？如果需要，失效的时机是固定的 7 天，还是&quot;只要你还在用就一直有效&quot;？</p><p>这就是本章要讲的——Token 的生命周期。Sa-Token 提供了两个独立的有效期维度，它们可以单独使用，也可以组合叠加，覆盖绝大多数项目对会话时效的业务需求。本章最后还会讲解&quot;记住我&quot;功能的实现原理，以及前后端分离环境下的替代方案。</p><hr><h2 id="2-1-两个有效期维度">2.1. 两个有效期维度</h2><p>Sa-Token 对 Token 有效期的控制由两个配置项决定，它们回答的是两个完全不同的问题。</p><p><code>timeout</code> 回答的是&quot;Token 的绝对寿命是多少&quot;。一个 Token 被创建出来的那一刻，它的倒计时就开始了。无论这段时间内用户是否活跃、访问了多少次接口，只要 <code>timeout</code> 指定的秒数一到，Token 就会失效。它是不可续签的——每次请求都不会重置这个倒计时。</p><p><code>active-timeout</code> 回答的是&quot;用户多久不操作才算不活跃&quot;。每次携带有效 Token 访问接口时，框架会自动将这个计时器重置为初始值。只要用户持续在使用，Token 就会一直续签下去；一旦超过 <code>active-timeout</code> 指定的秒数没有任何请求，Token 才会被冻结失效。</p><p>两个配置项都在 <code>application.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></pre></td><td class="code"><pre><span class="line"><span class="attr">sa-token:</span></span><br><span class="line">  <span class="comment"># Token 绝对有效期，单位：秒，-1 代表永久有效</span></span><br><span class="line">  <span class="attr">timeout:</span> <span class="number">2592000</span>        <span class="comment"># 30 天</span></span><br><span class="line">  <span class="comment"># Token 最低活跃频率，单位：秒，-1 代表不启用活跃检查</span></span><br><span class="line">  <span class="attr">active-timeout:</span> <span class="number">1800</span>    <span class="comment"># 30 分钟</span></span><br></pre></td></tr></table></figure><div class="note info simple"><p><code>active-timeout</code> 默认值为 <code>-1</code>（不启用）。只配置 <code>timeout</code> 是完全合法的用法，大多数简单项目也只用 <code>timeout</code> 就够了。</p></div><hr><h2 id="2-2-两个维度的组合逻辑">2.2. 两个维度的组合逻辑</h2><p>单独理解两个配置项并不难，真正需要思考的是它们 <strong>叠加在一起时的行为</strong>。</p><p>当两个配置同时启用时，框架采用的是 <strong>双重门槛，任意一个触发则失效</strong> 的策略。也就是说，Token 必须同时满足&quot;未超过绝对有效期&quot;和&quot;最近一次请求距今未超过活跃期&quot;，才会被认为是有效的。</p><p>这产生了几个经典的业务组合：</p><p><strong>“7 天内免登录，但连续 30 分钟不操作自动退出”</strong></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></pre></td><td class="code"><pre><span class="line"><span class="attr">sa-token:</span></span><br><span class="line">  <span class="attr">timeout:</span> <span class="number">604800</span>      <span class="comment"># 7 天绝对上限</span></span><br><span class="line">  <span class="attr">active-timeout:</span> <span class="number">1800</span> <span class="comment"># 30 分钟活跃期</span></span><br></pre></td></tr></table></figure><p>用户登录后，只要每隔不超过 30 分钟发起一次请求，Token 就会一直续签；但无论续签多少次，7 天到期后 Token 必然失效，用户需要重新登录。这是大多数 ToC 产品的标准策略。</p><p><strong>“永不过期，但长时间不用会掉线”</strong></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></pre></td><td class="code"><pre><span class="line"><span class="attr">sa-token:</span></span><br><span class="line">  <span class="attr">timeout:</span> <span class="number">-1</span>          <span class="comment"># 绝对有效期关闭</span></span><br><span class="line">  <span class="attr">active-timeout:</span> <span class="number">7200</span> <span class="comment"># 2 小时活跃期</span></span><br></pre></td></tr></table></figure><p>Token 没有固定的寿命，只要用户保持活跃就一直在线。适合内部工具、管理后台等对持久登录有要求、但也不希望长时间挂机占用会话资源的场景。</p><p><strong>“固定 30 天有效，不论是否活跃”</strong></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></pre></td><td class="code"><pre><span class="line"><span class="attr">sa-token:</span></span><br><span class="line">  <span class="attr">timeout:</span> <span class="number">2592000</span>     <span class="comment"># 30 天</span></span><br><span class="line">  <span class="attr">active-timeout:</span> <span class="number">-1</span>   <span class="comment"># 不启用活跃检查</span></span><br></pre></td></tr></table></figure><p>最简单的策略，Token 在 30 天内始终有效，不受访问频率影响。适合移动端 App、&quot;记住我&quot;场景。</p><hr><h2 id="2-3-手动操作-Token-有效期">2.3. 手动操作 Token 有效期</h2><p>除了依靠框架自动管理，Sa-Token 也暴露了一组 API，允许在代码中手动查询和修改 Token 的有效期。</p><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></pre></td><td class="code"><pre><span class="line"><span class="comment">// 获取当前 Token 的绝对有效期剩余秒数</span></span><br><span class="line"><span class="comment">// 返回 -1 表示永久有效，返回 -2 表示 Token 不存在或已失效</span></span><br><span class="line"><span class="type">long</span> <span class="variable">timeout</span> <span class="operator">=</span> StpUtil.getTokenTimeout();</span><br><span class="line"></span><br><span class="line"><span class="comment">// 获取当前 Token 的活跃有效期剩余秒数</span></span><br><span class="line"><span class="comment">// 返回 -1 表示未启用 active-timeout，返回 -2 表示已冻结</span></span><br><span class="line"><span class="type">long</span> <span class="variable">activeTimeout</span> <span class="operator">=</span> StpUtil.getTokenActiveTimeout();</span><br></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></pre></td><td class="code"><pre><span class="line"><span class="comment">// 续签当前 Token 的活跃有效期（重置 active-timeout 倒计时）</span></span><br><span class="line"><span class="comment">// 通常不需要手动调用，框架在每次请求时会自动续签</span></span><br><span class="line"><span class="comment">// 适用场景：某些不经过 Sa-Token 拦截器的后台任务，需要主动保持会话活跃</span></span><br><span class="line">StpUtil.updateLastActiveToNow();</span><br><span class="line"></span><br><span class="line"><span class="comment">// 将当前 Token 的绝对有效期续签为指定秒数</span></span><br><span class="line">StpUtil.renewTimeout(<span class="number">86400</span>);   <span class="comment">// 续签 1 天</span></span><br></pre></td></tr></table></figure><p><strong>在登录时指定本次的有效期</strong></p><p><code>SaLoginParameter</code> 也可以单独指定这一次登录的 Token 有效期，覆盖全局的 <code>timeout</code> 配置：</p><figure class="highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">// 指定此次登录的 Token 绝对有效期为 7 天</span></span><br><span class="line">StpUtil.login(<span class="number">10001</span>, <span class="keyword">new</span> <span class="title class_">SaLoginParameter</span>().setTimeout(<span class="number">60</span> * <span class="number">60</span> * <span class="number">24</span> * <span class="number">7</span>));</span><br></pre></td></tr></table></figure><p>这个用法在第一章已经出现过。结合本章的知识，你可以理解它的完整含义：只影响这一次登录生成的 Token，项目中其他账号的登录行为不受影响。</p><hr><h2 id="2-4-为-auth-info-接口添加有效期信息">2.4. 为 <code>/auth/info</code> 接口添加有效期信息</h2><p>为了让后续章节的测试有一个统一的&quot;查看当前会话状态&quot;的入口，我们现在向 <code>LoginController</code> 中添加 <code>/auth/info</code> 接口，并在返回数据中包含 Token 的剩余有效期信息。</p><p>📄 <code>src/main/java/com/example/authsatoken/controller/LoginController.java</code>（追加方法）</p><p>在已有的 <code>isLogin</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></pre></td><td class="code"><pre><span class="line"><span class="keyword">import</span> cn.dev33.satoken.stp.SaTokenInfo;</span><br><span class="line"><span class="keyword">import</span> java.util.LinkedHashMap;</span><br><span class="line"><span class="keyword">import</span> java.util.Map;</span><br><span class="line"></span><br><span class="line"><span class="comment">/**</span></span><br><span class="line"><span class="comment"> * 获取当前登录会话的详细信息</span></span><br><span class="line"><span class="comment"> * 包含 loginId、tokenValue、剩余有效期等，此接口需要已登录才能访问</span></span><br><span class="line"><span class="comment"> */</span></span><br><span class="line"><span class="meta">@GetMapping(&quot;/info&quot;)</span></span><br><span class="line"><span class="keyword">public</span> SaResult <span class="title function_">info</span><span class="params">()</span> &#123;</span><br><span class="line">    StpUtil.checkLogin();</span><br><span class="line">    <span class="type">SaTokenInfo</span> <span class="variable">tokenInfo</span> <span class="operator">=</span> StpUtil.getTokenInfo();</span><br><span class="line"></span><br><span class="line">    Map&lt;String, Object&gt; data = <span class="keyword">new</span> <span class="title class_">LinkedHashMap</span>&lt;&gt;();</span><br><span class="line">    data.put(<span class="string">&quot;loginId&quot;</span>,      tokenInfo.loginId);</span><br><span class="line">    data.put(<span class="string">&quot;tokenValue&quot;</span>,   tokenInfo.tokenValue);</span><br><span class="line">    data.put(<span class="string">&quot;timeout&quot;</span>,      StpUtil.getTokenTimeout());       <span class="comment">// 绝对有效期剩余秒数</span></span><br><span class="line">    data.put(<span class="string">&quot;activeTimeout&quot;</span>,StpUtil.getTokenActiveTimeout()); <span class="comment">// 活跃有效期剩余秒数</span></span><br><span class="line">    <span class="keyword">return</span> SaResult.data(data);</span><br><span class="line">&#125;</span><br></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><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><span class="line"><span class="punctuation">&#123;</span></span><br><span class="line">    <span class="attr">&quot;code&quot;</span><span class="punctuation">:</span> <span class="number">200</span><span class="punctuation">,</span></span><br><span class="line">    <span class="attr">&quot;msg&quot;</span><span class="punctuation">:</span> <span class="string">&quot;ok&quot;</span><span class="punctuation">,</span></span><br><span class="line">    <span class="attr">&quot;data&quot;</span><span class="punctuation">:</span> <span class="punctuation">&#123;</span></span><br><span class="line">        <span class="attr">&quot;loginId&quot;</span><span class="punctuation">:</span> <span class="string">&quot;10002&quot;</span><span class="punctuation">,</span></span><br><span class="line">        <span class="attr">&quot;tokenValue&quot;</span><span class="punctuation">:</span> <span class="string">&quot;xxxx-xxxx-xxxx-xxxx&quot;</span><span class="punctuation">,</span></span><br><span class="line">        <span class="attr">&quot;timeout&quot;</span><span class="punctuation">:</span> <span class="number">2591856</span><span class="punctuation">,</span></span><br><span class="line">        <span class="attr">&quot;activeTimeout&quot;</span><span class="punctuation">:</span> <span class="number">1793</span></span><br><span class="line">    <span class="punctuation">&#125;</span></span><br><span class="line"><span class="punctuation">&#125;</span></span><br></pre></td></tr></table></figure><p><code>timeout</code> 会随着时间流逝单调递减，始终不重置；<code>activeTimeout</code> 则每次调用此接口后都会被重置回 <code>1800</code>，因为访问本身就是一次&quot;活跃&quot;行为。</p><hr><h2 id="2-5-记住我-模式">2.5. [记住我] 模式</h2><p>几乎所有带登录页面的产品都有这个按钮。勾选之后，即使关闭浏览器再重新打开，依然处于登录状态，不需要再次输入密码。</p><p>Sa-Token 通过 <code>StpUtil.login()</code> 的第二个参数支持这一功能：</p><figure class="highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">// 记住我：关闭浏览器后 Token 依然有效</span></span><br><span class="line">StpUtil.login(<span class="number">10001</span>, <span class="literal">true</span>);</span><br><span class="line"></span><br><span class="line"><span class="comment">// 不记住我：关闭浏览器后 Token 消失，会话失效</span></span><br><span class="line">StpUtil.login(<span class="number">10001</span>, <span class="literal">false</span>);</span><br></pre></td></tr></table></figure><p>这个功能的底层依赖浏览器 Cookie 的两种生命周期：</p><ul><li><strong>持久 Cookie</strong>：有一个具体的过期时间。浏览器关闭后重新打开，Cookie 依然存在，Token 照常有效。</li><li><strong>临时 Cookie</strong>：有效期为&quot;本次会话&quot;。只要浏览器窗口关闭，Cookie 就随之消失，Token 再也无法被携带到服务端，会话自然失效。</li></ul><p><code>StpUtil.login(10001, true)</code> 对应持久 Cookie，<code>StpUtil.login(10001, false)</code> 对应临时 Cookie。框架在登录时根据这个参数决定向浏览器写入哪种 Cookie。</p><p>在我们现有的登录接口中，可以将 <code>[记住我]</code> 作为一个请求参数接入：</p><p>📄 <code>src/main/java/com/example/authsatoken/controller/LoginController.java</code>（修改 login 方法，追加 rememberMe 参数）</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></pre></td><td class="code"><pre><span class="line"><span class="meta">@PostMapping(&quot;/login&quot;)</span></span><br><span class="line"><span class="keyword">public</span> SaResult <span class="title function_">login</span><span class="params">(<span class="meta">@RequestParam</span> String username,</span></span><br><span class="line"><span class="params">                      <span class="meta">@RequestParam</span> String password,</span></span><br><span class="line"><span class="params">                      <span class="meta">@RequestParam(required = false, defaultValue = &quot;PC&quot;)</span> String device,</span></span><br><span class="line"><span class="params">                      <span class="meta">@RequestParam(required = false, defaultValue = &quot;false&quot;)</span> <span class="type">boolean</span> rememberMe)</span> &#123;</span><br><span class="line"></span><br><span class="line">    <span class="keyword">if</span> (!USER_DB.containsKey(username)) &#123;</span><br><span class="line">        <span class="keyword">return</span> SaResult.error(<span class="string">&quot;用户名或密码错误&quot;</span>);</span><br><span class="line">    &#125;</span><br><span class="line">    <span class="type">long</span>[] userInfo = USER_DB.get(username);</span><br><span class="line">    <span class="keyword">if</span> (userInfo[<span class="number">0</span>] != Long.parseLong(password)) &#123;</span><br><span class="line">        <span class="keyword">return</span> SaResult.error(<span class="string">&quot;用户名或密码错误&quot;</span>);</span><br><span class="line">    &#125;</span><br><span class="line"></span><br><span class="line">    <span class="type">long</span> <span class="variable">userId</span> <span class="operator">=</span> userInfo[<span class="number">1</span>];</span><br><span class="line">    <span class="type">boolean</span> <span class="variable">isAdmin</span> <span class="operator">=</span> <span class="string">&quot;admin&quot;</span>.equals(username);</span><br><span class="line"></span><br><span class="line">    <span class="type">SaLoginParameter</span> <span class="variable">loginParam</span> <span class="operator">=</span> <span class="keyword">new</span> <span class="title class_">SaLoginParameter</span>()</span><br><span class="line">            .setDeviceType(device)</span><br><span class="line">            .setIsConcurrent(!isAdmin)</span><br><span class="line">            .setMaxLoginCount(isAdmin ? <span class="number">1</span> : <span class="number">2</span>)</span><br><span class="line">            <span class="comment">// 将前端传入的 rememberMe 映射为持久/临时 Cookie</span></span><br><span class="line">            .setIsLastingCookie(rememberMe);</span><br><span class="line"></span><br><span class="line">    StpUtil.login(userId, loginParam);</span><br><span class="line">    <span class="keyword">return</span> SaResult.ok(<span class="string">&quot;登录成功&quot;</span>).setData(StpUtil.getTokenValue());</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p><code>setIsLastingCookie(true)</code> 等价于 <code>StpUtil.login(id, true)</code>，两者最终效果相同，只是前者通过 <code>SaLoginParameter</code> 传入，可以和其他登录参数组合使用。</p><h3 id="前后端分离模式下如何实现-记住我">前后端分离模式下如何实现 [记住我]</h3><p>Cookie 虽好，却无法在前后端分离环境下使用——App、小程序等客户端默认没有实现 Cookie 功能，无法依赖框架自动写入和读取。这种情况下，Token 的存储和生命周期需要由 <strong>前端自己管理</strong>。</p><p>在 PC 浏览器的前后端分离场景（如 Vue + Axios）下：</p><figure class="highlight javascript"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">// 勾选了 [记住我]：存入 localStorage，浏览器关闭后依然保留</span></span><br><span class="line"><span class="variable language_">localStorage</span>.<span class="title function_">setItem</span>(<span class="string">&quot;satoken&quot;</span>, <span class="string">&quot;xxxx-xxxx-xxxx-xxxx&quot;</span>);</span><br><span class="line"></span><br><span class="line"><span class="comment">// 未勾选 [记住我]：存入 sessionStorage，标签页关闭后自动清除</span></span><br><span class="line"><span class="variable language_">sessionStorage</span>.<span class="title function_">setItem</span>(<span class="string">&quot;satoken&quot;</span>, <span class="string">&quot;xxxx-xxxx-xxxx-xxxx&quot;</span>);</span><br></pre></td></tr></table></figure><p>两种环境的核心思路是一致的：<strong>持久存储 → 记住我；会话级存储 → 不记住我</strong>。只是从浏览器 Cookie 的两种模式，变成了前端存储 API 的两种选择。服务端的代码不需要做任何改动，改变的只是前端把 Token 存在哪里。</p><div class="note info simple"><p>前后端分离模式下，Token 的过期仍然由服务端的 <code>timeout</code> 和 <code>active-timeout</code> 控制。前端的&quot;持久存储&quot;只是保证了 Token 字符串不会因为关闭应用而丢失，并不会延长 Token 本身的有效期。</p></div><hr><h2 id="2-6-本章总结">2.6. 本章总结</h2><p><strong>本章回顾</strong></p><p>本章系统梳理了 Token 从创建到失效的两个时间维度。<code>timeout</code> 是绝对有效期，从登录时开始倒计时，不受请求频率影响；<code>active-timeout</code> 是活跃有效期，每次请求都会重置，长时间不操作才触发失效。两者独立配置，可以组合出&quot;固定寿命&quot;“活跃续签”&quot;活跃续签 + 绝对上限&quot;等多种策略，覆盖不同安全需求的业务场景。在代码层面，我们补充了查询和手动续签有效期的 API，新增了 <code>/auth/info</code> 接口作为后续测试的会话状态查询入口。最后讲解了&quot;记住我&quot;功能的 Cookie 实现原理，以及在前后端分离环境中用前端存储 API 替代 Cookie 的完整方案。</p><p><strong>核心汇总表</strong></p><table><thead><tr><th>配置项</th><th>含义</th><th>续签行为</th><th>典型值</th></tr></thead><tbody><tr><td><code>timeout</code></td><td>Token 绝对有效期</td><td>不续签，倒计时不可重置</td><td><code>2592000</code>（30 天）</td></tr><tr><td><code>active-timeout</code></td><td>Token 活跃有效期</td><td>每次请求自动重置</td><td><code>1800</code>（30 分钟）</td></tr><tr><td><code>-1</code></td><td>关闭该维度的检查</td><td>—</td><td>两个配置项均支持</td></tr></tbody></table><table><thead><tr><th>常用 API</th><th>说明</th></tr></thead><tbody><tr><td><code>StpUtil.getTokenTimeout()</code></td><td>查询当前 Token 绝对有效期剩余秒数</td></tr><tr><td><code>StpUtil.getTokenActiveTimeout()</code></td><td>查询当前 Token 活跃有效期剩余秒数</td></tr><tr><td><code>StpUtil.updateLastActiveToNow()</code></td><td>手动续签活跃有效期</td></tr><tr><td><code>StpUtil.renewTimeout(sec)</code></td><td>将绝对有效期续签为指定秒数</td></tr></tbody></table><table><thead><tr><th>[记住我] 实现方式</th><th>持久登录</th><th>非持久登录</th></tr></thead><tbody><tr><td>传统 Cookie 模式</td><td><code>setIsLastingCookie(true)</code></td><td><code>setIsLastingCookie(false)</code></td></tr><tr><td>uni-app</td><td><code>uni.setStorageSync()</code></td><td><code>getApp().globalData</code></td></tr><tr><td>PC 浏览器前后端分离</td><td><code>localStorage</code></td><td><code>sessionStorage</code></td></tr></tbody></table><h1>第三章. 读取当前会话信息</h1><p><strong>阶段式学习路径</strong></p><p>前两章解决了&quot;怎么登录&quot;和&quot;Token 活多久&quot;的问题。但登录成功之后，还有一个同样高频的需求没有覆盖：<strong>如何从当前请求中取出&quot;我是谁&quot;？</strong></p><p>每一个需要登录的接口，几乎都要做同一件事——先拿到当前用户的 ID，再用这个 ID 去查业务数据。除此之外，你可能还需要知道当前 Token 的完整信息、这个账号在所有设备上的在线情况，甚至只是判断一下&quot;这个请求到底有没有登录&quot;。</p><p>本章系统梳理 Sa-Token 为此提供的全部 API，并完善 <code>/auth/info</code> 接口，让它成为后续章节测试中真正好用的会话状态查询入口。</p><hr><h2 id="3-1-获取当前登录用户-ID">3.1. 获取当前登录用户 ID</h2><p><code>StpUtil.getLoginId()</code> 是使用频率最高的 API，没有之一。它的返回值是 <code>Object</code> 类型，因为 Sa-Token 内部将 loginId 统一以字符串形式存储（我们在番外篇一中通过 Redis 实验验证过这一点），所以直接拿到的是 <code>String</code>。</p><p>为了让调用方不必每次手动转型，Sa-Token 提供了几个带类型的重载方法：</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></pre></td><td class="code"><pre><span class="line"><span class="comment">// 返回 Object 类型，实际是 String</span></span><br><span class="line"><span class="type">Object</span> <span class="variable">loginId</span> <span class="operator">=</span> StpUtil.getLoginId();</span><br><span class="line"></span><br><span class="line"><span class="comment">// 返回 String 类型（等价于 String.valueOf(getLoginId())）</span></span><br><span class="line"><span class="type">String</span> <span class="variable">loginIdStr</span> <span class="operator">=</span> StpUtil.getLoginIdAsString();</span><br><span class="line"></span><br><span class="line"><span class="comment">// 返回 long 类型（等价于 Long.parseLong(getLoginId().toString())）</span></span><br><span class="line"><span class="type">long</span> <span class="variable">loginIdLong</span> <span class="operator">=</span> StpUtil.getLoginIdAsLong();</span><br><span class="line"></span><br><span class="line"><span class="comment">// 返回 int 类型</span></span><br><span class="line"><span class="type">int</span> <span class="variable">loginIdInt</span> <span class="operator">=</span> StpUtil.getLoginIdAsInt();</span><br></pre></td></tr></table></figure><p>实际项目中，如果你的 userId 是数据库自增长整型，直接用 <code>getLoginIdAsLong()</code> 最方便；如果是 UUID 字符串，用 <code>getLoginIdAsString()</code>。</p><div class="note warning simple"><p>这几个方法在未登录时都会直接抛出 <code>NotLoginException</code>，而不是返回 <code>null</code>。如果你在一个不确定是否已登录的场景下调用它们，需要先用 <code>StpUtil.isLogin()</code> 判断，或者用 try-catch 捕获异常。</p></div><p>如果你需要取的不是当前请求者的 ID，而是某个 Token 对应的 loginId（比如管理后台根据 Token 反查账号），可以使用：</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><span class="line"><span class="comment">// 根据 Token 值反查其对应的 loginId，Token 无效时返回 null</span></span><br><span class="line"><span class="type">Object</span> <span class="variable">loginId</span> <span class="operator">=</span> StpUtil.getLoginIdByToken(<span class="string">&quot;xxxx-xxxx-xxxx-xxxx&quot;</span>);</span><br></pre></td></tr></table></figure><hr><h2 id="3-2-获取-Token-信息">3.2. 获取 Token 信息</h2><p><code>StpUtil.getTokenValue()</code> 返回当前请求携带的 Token 字符串，是最直接的方式：</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><span class="line"><span class="comment">// 获取当前请求的 Token 字符串</span></span><br><span class="line"><span class="type">String</span> <span class="variable">tokenValue</span> <span class="operator">=</span> StpUtil.getTokenValue();</span><br></pre></td></tr></table></figure><p>如果你需要的不只是 Token 值，而是这个 Token 的完整元数据，使用 <code>getTokenInfo()</code>：</p><figure class="highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line"><span class="type">SaTokenInfo</span> <span class="variable">tokenInfo</span> <span class="operator">=</span> StpUtil.getTokenInfo();</span><br></pre></td></tr></table></figure><p><code>SaTokenInfo</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><span class="line">17</span><br></pre></td><td class="code"><pre><span class="line"><span class="punctuation">&#123;</span></span><br><span class="line">    <span class="attr">&quot;code&quot;</span><span class="punctuation">:</span> <span class="number">200</span><span class="punctuation">,</span></span><br><span class="line">    <span class="attr">&quot;msg&quot;</span><span class="punctuation">:</span> <span class="string">&quot;ok&quot;</span><span class="punctuation">,</span></span><br><span class="line">    <span class="attr">&quot;data&quot;</span><span class="punctuation">:</span> <span class="punctuation">&#123;</span></span><br><span class="line">        <span class="attr">&quot;tokenName&quot;</span><span class="punctuation">:</span> <span class="string">&quot;satoken&quot;</span><span class="punctuation">,</span></span><br><span class="line">        <span class="attr">&quot;tokenValue&quot;</span><span class="punctuation">:</span> <span class="string">&quot;4ed22f86-9982-4c79-89f0-42047e7ce830&quot;</span><span class="punctuation">,</span></span><br><span class="line">        <span class="attr">&quot;isLogin&quot;</span><span class="punctuation">:</span> <span class="literal"><span class="keyword">true</span></span><span class="punctuation">,</span></span><br><span class="line">        <span class="attr">&quot;loginId&quot;</span><span class="punctuation">:</span> <span class="string">&quot;10002&quot;</span><span class="punctuation">,</span></span><br><span class="line">        <span class="attr">&quot;loginType&quot;</span><span class="punctuation">:</span> <span class="string">&quot;login&quot;</span><span class="punctuation">,</span></span><br><span class="line">        <span class="attr">&quot;tokenTimeout&quot;</span><span class="punctuation">:</span> <span class="number">2564356</span><span class="punctuation">,</span></span><br><span class="line">        <span class="attr">&quot;sessionTimeout&quot;</span><span class="punctuation">:</span> <span class="number">2564356</span><span class="punctuation">,</span></span><br><span class="line">        <span class="attr">&quot;tokenSessionTimeout&quot;</span><span class="punctuation">:</span> <span class="number">-2</span><span class="punctuation">,</span></span><br><span class="line">        <span class="attr">&quot;tokenActiveTimeout&quot;</span><span class="punctuation">:</span> <span class="number">-1</span><span class="punctuation">,</span></span><br><span class="line">        <span class="attr">&quot;loginDeviceType&quot;</span><span class="punctuation">:</span> <span class="string">&quot;PC&quot;</span><span class="punctuation">,</span></span><br><span class="line">        <span class="attr">&quot;tag&quot;</span><span class="punctuation">:</span> <span class="literal"><span class="keyword">null</span></span></span><br><span class="line">    <span class="punctuation">&#125;</span></span><br><span class="line"><span class="punctuation">&#125;</span></span><br></pre></td></tr></table></figure><p>大多数情况下，你不需要把所有字段都返回给前端，按需取用即可。</p><hr><h2 id="3-3-查询登录状态">3.3. 查询登录状态</h2><p>Sa-Token 提供了两个语义相近但行为截然不同的方法：</p><figure class="highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">// 查询：当前请求是否已登录，返回 true / false，不抛异常</span></span><br><span class="line"><span class="type">boolean</span> <span class="variable">result</span> <span class="operator">=</span> StpUtil.isLogin();</span><br><span class="line"></span><br><span class="line"><span class="comment">// 校验：当前请求必须已登录，未登录则直接抛出 NotLoginException</span></span><br><span class="line">StpUtil.checkLogin();</span><br></pre></td></tr></table></figure><p>两者的核心区别在于：<code>isLogin()</code> 是<strong>查询</strong>，只告诉你结果；<code>checkLogin()</code> 是<strong>校验</strong>，不满足条件就中断请求。</p><p><strong>什么时候用 <code>isLogin()</code>？</strong></p><p>适合那些&quot;登录与否都能访问，但登录后返回更多数据&quot;的场景：</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></pre></td><td class="code"><pre><span class="line"><span class="meta">@GetMapping(&quot;/article/&#123;id&#125;&quot;)</span></span><br><span class="line"><span class="keyword">public</span> SaResult <span class="title function_">getArticle</span><span class="params">(<span class="meta">@PathVariable</span> Long id)</span> &#123;</span><br><span class="line">    <span class="type">Article</span> <span class="variable">article</span> <span class="operator">=</span> articleService.getById(id);</span><br><span class="line">    <span class="keyword">if</span> (StpUtil.isLogin()) &#123;</span><br><span class="line">        <span class="comment">// 已登录用户额外返回点赞状态、收藏状态</span></span><br><span class="line">        article.setLiked(likeService.isLiked(StpUtil.getLoginIdAsLong(), id));</span><br><span class="line">    &#125;</span><br><span class="line">    <span class="keyword">return</span> SaResult.data(article);</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p><strong>什么时候用 <code>checkLogin()</code>？</strong></p><p>适合那些&quot;没有登录就没有任何意义&quot;的接口——直接校验，不满足就抛异常，配合第四章的全局异常处理器自动返回 401：</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><span class="line"><span class="meta">@GetMapping(&quot;/profile&quot;)</span></span><br><span class="line"><span class="keyword">public</span> SaResult <span class="title function_">getProfile</span><span class="params">()</span> &#123;</span><br><span class="line">    StpUtil.checkLogin();   <span class="comment">// 未登录直接抛异常，后续代码不会执行</span></span><br><span class="line">    <span class="type">long</span> <span class="variable">userId</span> <span class="operator">=</span> StpUtil.getLoginIdAsLong();</span><br><span class="line">    <span class="keyword">return</span> SaResult.data(userService.getById(userId));</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>注解鉴权（将在第三篇讲解）的 <code>@SaCheckLogin</code> 本质上就是在方法入口自动调用了 <code>checkLogin()</code>，两者效果完全等价，只是表达方式不同。</p><hr><h2 id="3-4-查询一个账号的所有在线终端">3.4. 查询一个账号的所有在线终端</h2><p>第一章的多端登录场景中，我们用 <code>user</code> 账号同时在 PC 和 APP 上登录，产生了两个独立的 Token。如果想知道这个账号当前一共有几个设备在线，以及每个设备对应的 Token 是什么，使用：</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><span class="line"><span class="comment">// 获取指定 loginId 当前所有有效 Token 的列表</span></span><br><span class="line">List&lt;String&gt; tokenList = StpUtil.getTokenValueListByLoginId(<span class="number">10002L</span>);</span><br></pre></td></tr></table></figure><p>返回的列表中，每一个字符串就是一个在线设备的 Token 值。列表为空表示该账号当前没有任何在线会话。</p><p>配合 <code>getLoginDevice()</code> 可以进一步查询每个 Token 对应的设备类型：</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><span class="line"><span class="comment">// 获取当前请求 Token 登录时声明的设备类型</span></span><br><span class="line"><span class="type">String</span> <span class="variable">device</span> <span class="operator">=</span> StpUtil.getLoginDevice();</span><br></pre></td></tr></table></figure><p>这两个 API 组合起来，就是&quot;账号安全&quot;页面中展示&quot;当前登录设备列表&quot;功能的数据来源。</p><hr><h2 id="3-5-完善-auth-info-接口">3.5. 完善 <code>/auth/info</code> 接口</h2><p>上一章我们添加了 <code>/auth/info</code> 接口的雏形，只返回了几个基本字段。现在结合本章的知识，将它完善为包含完整会话信息的版本。</p><p>📄 <code>src/main/java/com/example/authsatoken/controller/LoginController.java</code>（修改 info 方法）</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><span class="line"><span class="keyword">import</span> cn.dev33.satoken.stp.SaTokenInfo;</span><br><span class="line"><span class="keyword">import</span> java.util.LinkedHashMap;</span><br><span class="line"><span class="keyword">import</span> java.util.List;</span><br><span class="line"><span class="keyword">import</span> java.util.Map;</span><br><span class="line"></span><br><span class="line"><span class="comment">/**</span></span><br><span class="line"><span class="comment"> * 获取当前登录会话的完整信息</span></span><br><span class="line"><span class="comment"> * 包含 loginId、设备类型、Token 值、有效期、当前账号所有在线终端</span></span><br><span class="line"><span class="comment"> * 此接口需要已登录才能访问</span></span><br><span class="line"><span class="comment"> */</span></span><br><span class="line"><span class="meta">@GetMapping(&quot;/info&quot;)</span></span><br><span class="line"><span class="keyword">public</span> SaResult <span class="title function_">info</span><span class="params">()</span> &#123;</span><br><span class="line">StpUtil.checkLogin();</span><br><span class="line"><span class="type">SaTokenInfo</span> <span class="variable">tokenInfo</span> <span class="operator">=</span> StpUtil.getTokenInfo();</span><br><span class="line"></span><br><span class="line"><span class="comment">// 获取当前账号所有在线 Token（含当前设备）</span></span><br><span class="line">List&lt;String&gt; tokenList = StpUtil.getTokenValueListByLoginId(StpUtil.getLoginId());</span><br><span class="line"></span><br><span class="line">Map&lt;String, Object&gt; data = <span class="keyword">new</span> <span class="title class_">LinkedHashMap</span>&lt;&gt;();</span><br><span class="line">data.put(<span class="string">&quot;loginId&quot;</span>,       tokenInfo.loginId);</span><br><span class="line">data.put(<span class="string">&quot;loginDevice&quot;</span>,   tokenInfo.loginDeviceType);           <span class="comment">// 当前设备类型</span></span><br><span class="line">data.put(<span class="string">&quot;tokenValue&quot;</span>,    tokenInfo.tokenValue);</span><br><span class="line">data.put(<span class="string">&quot;timeout&quot;</span>,       tokenInfo.tokenTimeout);          <span class="comment">// 绝对有效期剩余秒数</span></span><br><span class="line">data.put(<span class="string">&quot;activeTimeout&quot;</span>, tokenInfo.tokenActiveTimeout);    <span class="comment">// 活跃有效期剩余秒数</span></span><br><span class="line">data.put(<span class="string">&quot;onlineCount&quot;</span>,   tokenList.size());                <span class="comment">// 当前账号在线设备数</span></span><br><span class="line">data.put(<span class="string">&quot;tokenList&quot;</span>,     tokenList);                       <span class="comment">// 所有在线设备的 Token 列表</span></span><br><span class="line"><span class="keyword">return</span> SaResult.data(data);</span><br><span class="line">&#125;</span><br></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><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><span class="line"><span class="punctuation">&#123;</span></span><br><span class="line">    <span class="attr">&quot;code&quot;</span><span class="punctuation">:</span> <span class="number">200</span><span class="punctuation">,</span></span><br><span class="line">    <span class="attr">&quot;msg&quot;</span><span class="punctuation">:</span> <span class="string">&quot;ok&quot;</span><span class="punctuation">,</span></span><br><span class="line">    <span class="attr">&quot;data&quot;</span><span class="punctuation">:</span> <span class="punctuation">&#123;</span></span><br><span class="line">        <span class="attr">&quot;loginId&quot;</span><span class="punctuation">:</span> <span class="string">&quot;10002&quot;</span><span class="punctuation">,</span></span><br><span class="line">        <span class="attr">&quot;loginDevice&quot;</span><span class="punctuation">:</span> <span class="string">&quot;PC&quot;</span><span class="punctuation">,</span></span><br><span class="line">        <span class="attr">&quot;tokenValue&quot;</span><span class="punctuation">:</span> <span class="string">&quot;xxxx-xxxx-xxxx-xxxx&quot;</span><span class="punctuation">,</span></span><br><span class="line">        <span class="attr">&quot;timeout&quot;</span><span class="punctuation">:</span> <span class="number">2591743</span><span class="punctuation">,</span></span><br><span class="line">        <span class="attr">&quot;activeTimeout&quot;</span><span class="punctuation">:</span> <span class="number">1800</span><span class="punctuation">,</span></span><br><span class="line">        <span class="attr">&quot;onlineCount&quot;</span><span class="punctuation">:</span> <span class="number">2</span><span class="punctuation">,</span></span><br><span class="line">        <span class="attr">&quot;tokenList&quot;</span><span class="punctuation">:</span> <span class="punctuation">[</span></span><br><span class="line">            <span class="string">&quot;aaaa-aaaa-aaaa-aaaa&quot;</span><span class="punctuation">,</span></span><br><span class="line">            <span class="string">&quot;xxxx-xxxx-xxxx-xxxx&quot;</span></span><br><span class="line">        <span class="punctuation">]</span></span><br><span class="line">    <span class="punctuation">&#125;</span></span><br><span class="line"><span class="punctuation">&#125;</span></span><br></pre></td></tr></table></figure><p><code>onlineCount</code> 为 <code>2</code> 说明这个账号当前有两个设备在线，<code>tokenList</code> 中两条记录分别对应不同的登录会话。这个接口将成为后续三章测试下线行为时最直接的观测工具——踢人之前看一次，踢人之后再看一次，变化一目了然。</p><hr><h2 id="3-6-本章总结">3.6. 本章总结</h2><p><strong>本章回顾</strong></p><p>本章系统梳理了登录成功后读取会话信息的全部 API。<code>getLoginId()</code> 系列方法解决了&quot;取当前用户 ID&quot;这一最高频的需求，并通过带类型的重载版本避免了手动转型的繁琐。<code>getTokenInfo()</code> 提供了 Token 的完整元数据，包含有效期、设备类型、登录状态等字段，满足需要展示详细会话信息的场景。<code>isLogin()</code> 与 <code>checkLogin()</code> 的区别从语义层面做了厘清——前者是查询，后者是校验，选错了轻则逻辑错误，重则该拦截的请求没有拦截。最后补充了 <code>getTokenValueListByLoginId()</code> 用于查询一个账号的全部在线终端，并将 <code>/auth/info</code> 接口升级为包含在线终端列表的完整版本，为后续章节的下线行为测试准备好了观测入口。</p><p><strong>核心汇总表</strong></p><table><thead><tr><th>API</th><th>返回值</th><th>说明</th></tr></thead><tbody><tr><td><code>StpUtil.getLoginId()</code></td><td>Object（实为 String）</td><td>当前登录用户 ID，未登录抛异常</td></tr><tr><td><code>StpUtil.getLoginIdAsString()</code></td><td>String</td><td>同上，自动转 String</td></tr><tr><td><code>StpUtil.getLoginIdAsLong()</code></td><td>long</td><td>同上，自动转 long</td></tr><tr><td><code>StpUtil.getLoginIdByToken(token)</code></td><td>Object</td><td>根据 Token 值反查 loginId，无效返回 null</td></tr><tr><td><code>StpUtil.getTokenValue()</code></td><td>String</td><td>当前请求携带的 Token 字符串</td></tr><tr><td><code>StpUtil.getTokenInfo()</code></td><td>SaTokenInfo</td><td>Token 完整元数据（有效期、设备类型等）</td></tr><tr><td><code>StpUtil.getLoginDevice()</code></td><td>String</td><td>当前 Token 登录时声明的设备类型</td></tr><tr><td><code>StpUtil.isLogin()</code></td><td>boolean</td><td>查询是否已登录，不抛异常</td></tr><tr><td><code>StpUtil.checkLogin()</code></td><td>void</td><td>校验必须已登录，未登录抛 NotLoginException</td></tr><tr><td><code>StpUtil.getTokenValueListByLoginId(id)</code></td><td>List&lt;String&gt;</td><td>指定账号当前所有在线 Token 列表</td></tr></tbody></table><table><thead><tr><th><code>isLogin()</code> vs <code>checkLogin()</code></th><th>适用场景</th></tr></thead><tbody><tr><td><code>isLogin()</code></td><td>登录与否都能访问，但登录后返回更多数据</td></tr><tr><td><code>checkLogin()</code></td><td>未登录没有任何意义，直接中断请求</td></tr></tbody></table><h1>第四章. 全局异常处理器与统一响应格式</h1><p><strong>阶段式学习路径</strong></p><p>前三章搭建起了登录、会话信息查询的基础能力，但有一个问题一直被我们悬置着：访问 <code>/auth/info</code> 这类需要登录的接口时，如果请求者没有携带 Token，会发生什么？</p><p>目前的答案是：Spring Boot 会返回一段 HTML 格式的错误页面。前端完全无法解析，也无法据此做出任何有意义的跳转或提示。更严重的是，Sa-Token 在不同的&quot;未登录&quot;情况下会抛出不同的异常——Token 过期、Token 被踢出、Token 被顶替，对用户来说应该是三条截然不同的提示，但现在前端收到的全是同一堆 HTML 乱码。</p><p>本章我们来彻底解决这个问题。在为第五章的下线行为测试铺路之前，我们需要先有一套能将异常转化为规范 JSON 响应的机制——全局异常处理器。</p><hr><h2 id="4-1-Sa-Token-会抛出哪些异常">4.1. Sa-Token 会抛出哪些异常</h2><p>在动手写代码之前，先建立一个完整的异常地图。Sa-Token 在认证鉴权阶段可能抛出的异常主要有以下几类：</p><p><strong><code>NotLoginException</code></strong>：会话未通过登录校验时抛出，是使用频率最高的一个。它携带一个 <code>type</code> 字段，用数字标识&quot;为什么没有登录&quot;——这个数字叫做<strong>场景值</strong>，一共有 7 种：</p><table><thead><tr><th>场景值</th><th>常量名</th><th>触发条件</th></tr></thead><tbody><tr><td>-1</td><td><code>NOT_TOKEN</code></td><td>请求中完全没有携带 Token</td></tr><tr><td>-2</td><td><code>INVALID_TOKEN</code></td><td>携带了 Token，但在 Redis 中找不到（已注销或从未存在）</td></tr><tr><td>-3</td><td><code>TOKEN_TIMEOUT</code></td><td>Token 存在，但已超过 <code>timeout</code> 配置的有效期</td></tr><tr><td>-4</td><td><code>BE_REPLACED</code></td><td>Token 被同设备类型的新登录顶替</td></tr><tr><td>-5</td><td><code>KICK_OUT</code></td><td>Token 被管理员通过 <code>kickout</code> 强制踢下线</td></tr><tr><td>-6</td><td><code>TOKEN_FREEZE</code></td><td>Token 因超过 <code>active-timeout</code> 活跃频率限制而被冻结</td></tr><tr><td>-7</td><td><code>NO_PREFIX</code></td><td>配置了 Token 前缀但请求没有携带正确的前缀</td></tr></tbody></table><p>场景值机制的价值在于：同样是&quot;携带了 Token 但无法通过校验&quot;，-4 意味着&quot;你在另一台设备上登录了&quot;，-5 意味着&quot;管理员把你踢出去了&quot;，-6 意味着&quot;太久没操作了&quot;。前端可以根据不同的场景值展示完全不同的提示，甚至跳转到不同的页面——这是把一个异常拆成 7 个场景值的核心意义。</p><p><strong><code>SaTokenException</code></strong>：Sa-Token 的基类异常，所有框架内部的运行时错误都继承自它。通常不需要单独捕获，在全局兜底处理中用它接住所有未预料到的框架异常即可。</p><p>本篇笔记目前只涉及登录会话生命周期，权限相关的 <code>NotPermissionException</code> 和 <code>NotRoleException</code> 将在第三篇引入权限体系时统一处理。</p><hr><h2 id="4-2-创建全局异常处理器">4.2. 创建全局异常处理器</h2><p>在 <code>com.example.authsatoken</code> 包下新建 <code>exception</code> 子包，创建全局异常处理器：</p><p>📄 <code>src/main/java/com/example/authsatoken/exception/GlobalExceptionHandler.java</code>（新建）</p><figure class="highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><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></pre></td><td class="code"><pre><span class="line"><span class="keyword">package</span> com.example.authsatoken.exception;</span><br><span class="line"></span><br><span class="line"><span class="keyword">import</span> cn.dev33.satoken.exception.NotLoginException;</span><br><span class="line"><span class="keyword">import</span> cn.dev33.satoken.exception.SaTokenException;</span><br><span class="line"><span class="keyword">import</span> cn.dev33.satoken.util.SaResult;</span><br><span class="line"><span class="keyword">import</span> org.springframework.web.bind.annotation.ExceptionHandler;</span><br><span class="line"><span class="keyword">import</span> org.springframework.web.bind.annotation.RestControllerAdvice;</span><br><span class="line"></span><br><span class="line"><span class="comment">/**</span></span><br><span class="line"><span class="comment"> * 全局异常处理器</span></span><br><span class="line"><span class="comment"> * 统一捕获 Sa-Token 抛出的认证异常，转化为规范的 JSON 响应</span></span><br><span class="line"><span class="comment"> *</span></span><br><span class="line"><span class="comment"> * 当前处理范围：会话认证相关异常（NotLoginException）</span></span><br><span class="line"><span class="comment"> * 第三篇引入权限体系后，NotPermissionException / NotRoleException 将在此处追加</span></span><br><span class="line"><span class="comment"> */</span></span><br><span class="line"><span class="meta">@RestControllerAdvice</span></span><br><span class="line"><span class="keyword">public</span> <span class="keyword">class</span> <span class="title class_">GlobalExceptionHandler</span> &#123;</span><br><span class="line"></span><br><span class="line">    <span class="comment">/**</span></span><br><span class="line"><span class="comment">     * 捕获未登录异常</span></span><br><span class="line"><span class="comment">     * 通过场景值区分不同的未登录原因，返回差异化的提示信息</span></span><br><span class="line"><span class="comment">     */</span></span><br><span class="line">    <span class="meta">@ExceptionHandler(NotLoginException.class)</span></span><br><span class="line">    <span class="keyword">public</span> SaResult <span class="title function_">handleNotLoginException</span><span class="params">(NotLoginException e)</span> &#123;</span><br><span class="line">        <span class="type">String</span> <span class="variable">message</span> <span class="operator">=</span> <span class="keyword">switch</span> (e.getType()) &#123;</span><br><span class="line">            <span class="keyword">case</span> NotLoginException.NOT_TOKEN     -&gt; <span class="string">&quot;未提供 Token，请先登录&quot;</span>;</span><br><span class="line">            <span class="keyword">case</span> NotLoginException.INVALID_TOKEN -&gt; <span class="string">&quot;Token 无效，请重新登录&quot;</span>;</span><br><span class="line">            <span class="keyword">case</span> NotLoginException.TOKEN_TIMEOUT -&gt; <span class="string">&quot;Token 已过期，请重新登录&quot;</span>;</span><br><span class="line">            <span class="keyword">case</span> NotLoginException.BE_REPLACED   -&gt; <span class="string">&quot;您的账号已在其他设备登录，当前会话已下线&quot;</span>;</span><br><span class="line">            <span class="keyword">case</span> NotLoginException.KICK_OUT      -&gt; <span class="string">&quot;您已被强制下线&quot;</span>;</span><br><span class="line">            <span class="keyword">case</span> NotLoginException.TOKEN_FREEZE  -&gt; <span class="string">&quot;Token 已被冻结，请稍后重试&quot;</span>;</span><br><span class="line">            <span class="keyword">case</span> NotLoginException.NO_PREFIX     -&gt; <span class="string">&quot;Token 格式不正确，请重新登录&quot;</span>;</span><br><span class="line">            <span class="keyword">default</span>                              -&gt; <span class="string">&quot;当前会话未登录&quot;</span>;</span><br><span class="line">        &#125;;</span><br><span class="line">        <span class="keyword">return</span> SaResult.error(message).setCode(<span class="number">401</span>);</span><br><span class="line">    &#125;</span><br><span class="line"></span><br><span class="line">    <span class="comment">/**</span></span><br><span class="line"><span class="comment">     * 兜底：捕获所有其他 Sa-Token 框架异常</span></span><br><span class="line"><span class="comment">     * 避免框架内部的未预期错误以 HTML 页面形式暴露给前端</span></span><br><span class="line"><span class="comment">     */</span></span><br><span class="line">    <span class="meta">@ExceptionHandler(SaTokenException.class)</span></span><br><span class="line">    <span class="keyword">public</span> SaResult <span class="title function_">handleSaTokenException</span><span class="params">(SaTokenException e)</span> &#123;</span><br><span class="line">        <span class="keyword">return</span> SaResult.error(<span class="string">&quot;认证框架异常：&quot;</span> + e.getMessage()).setCode(<span class="number">500</span>);</span><br><span class="line">    &#125;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>几个关键点说明。</p><p><code>@RestControllerAdvice</code> 让这个类成为全局异常处理器，作用范围覆盖所有 <code>@RestController</code>。它是 <code>@ControllerAdvice</code> + <code>@ResponseBody</code> 的组合注解，确保返回值会被序列化为 JSON 而不是视图。</p><p><code>@ExceptionHandler(NotLoginException.class)</code> 声明只处理 <code>NotLoginException</code> 类型的异常，其他类型的异常不受影响，仍然按 Spring 默认机制处理（或被其他 <code>@ExceptionHandler</code> 接管）。</p><p><code>switch</code> 表达式使用的是 Java 14 正式引入、Java 17 标准化的语法。每个 <code>-&gt;</code> 分支直接返回字符串值，不需要 <code>break</code>，比传统 <code>switch-case</code> 更简洁，也不容易出现 fall-through 问题。<code>e.getType()</code> 返回的是 <code>int</code> 类型的场景值，与 <code>NotLoginException</code> 的静态常量完全对应。</p><p><code>SaTokenException</code> 的兜底处理放在最后。由于 <code>NotLoginException</code> 继承自 <code>SaTokenException</code>，Spring 会优先匹配最具体的类型——<code>NotLoginException</code> 会先被第一个方法捕获，只有其他 <code>SaTokenException</code> 子类才会落到兜底方法。</p><hr><h2 id="4-3-验证异常处理效果">4.3. 验证异常处理效果</h2><p>重启项目，通过三个测试场景验证全局异常处理器是否按预期工作。</p><p><strong>场景一：不携带任何 Token（场景值 -1）</strong></p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">GET http://localhost:8081/auth/info</span><br></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></pre></td><td class="code"><pre><span class="line"><span class="punctuation">&#123;</span></span><br><span class="line">    <span class="attr">&quot;code&quot;</span><span class="punctuation">:</span> <span class="number">401</span><span class="punctuation">,</span></span><br><span class="line">    <span class="attr">&quot;msg&quot;</span><span class="punctuation">:</span> <span class="string">&quot;未提供 Token，请先登录&quot;</span><span class="punctuation">,</span></span><br><span class="line">    <span class="attr">&quot;data&quot;</span><span class="punctuation">:</span> <span class="literal"><span class="keyword">null</span></span></span><br><span class="line"><span class="punctuation">&#125;</span></span><br></pre></td></tr></table></figure><p><strong>场景二：携带一个随机伪造的 Token（场景值 -2）</strong></p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line">GET http://localhost:8081/auth/info</span><br><span class="line">Header: satoken: this-is-a-fake-token</span><br></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></pre></td><td class="code"><pre><span class="line"><span class="punctuation">&#123;</span></span><br><span class="line">    <span class="attr">&quot;code&quot;</span><span class="punctuation">:</span> <span class="number">401</span><span class="punctuation">,</span></span><br><span class="line">    <span class="attr">&quot;msg&quot;</span><span class="punctuation">:</span> <span class="string">&quot;Token 无效，请重新登录&quot;</span><span class="punctuation">,</span></span><br><span class="line">    <span class="attr">&quot;data&quot;</span><span class="punctuation">:</span> <span class="literal"><span class="keyword">null</span></span></span><br><span class="line"><span class="punctuation">&#125;</span></span><br></pre></td></tr></table></figure><p><strong>场景三：正常登录后访问（应通过校验）</strong></p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">POST http://localhost:8081/auth/login?username=user&amp;password=123456</span><br></pre></td></tr></table></figure><p>拿到 Token 后：</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line">GET http://localhost:8081/auth/info</span><br><span class="line">Header: satoken: &lt;刚才拿到的 Token&gt;</span><br></pre></td></tr></table></figure><p>预期响应正常返回会话信息，不触发任何异常。</p><p>场景值 -3 到 -6 的触发需要特定条件（Token 过期、被踢出、被顶替、被冻结），将在第五章下线行为的测试中逐一覆盖——届时全局异常处理器会把每一种原因转化为对应的差异化提示，直观感受场景值机制的价值。</p><hr><h2 id="4-4-统一响应格式的约定">4.4. 统一响应格式的约定</h2><p>目前我们的接口全部使用 <code>SaResult</code> 作为响应体，这是 Sa-Token 内置的一个简单工具类：</p><figure class="highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">// 常用静态工厂方法</span></span><br><span class="line">SaResult.ok();                   <span class="comment">// &#123; &quot;code&quot;: 200, &quot;msg&quot;: &quot;ok&quot;, &quot;data&quot;: null &#125;</span></span><br><span class="line">SaResult.ok(<span class="string">&quot;操作成功&quot;</span>);          <span class="comment">// &#123; &quot;code&quot;: 200, &quot;msg&quot;: &quot;操作成功&quot;, &quot;data&quot;: null &#125;</span></span><br><span class="line">SaResult.ok(<span class="string">&quot;操作成功&quot;</span>).setData(x); <span class="comment">// &#123; &quot;code&quot;: 200, &quot;msg&quot;: &quot;操作成功&quot;, &quot;data&quot;: x &#125;</span></span><br><span class="line">SaResult.data(x);               <span class="comment">// &#123; &quot;code&quot;: 200, &quot;msg&quot;: &quot;ok&quot;, &quot;data&quot;: x &#125;</span></span><br><span class="line">SaResult.error(<span class="string">&quot;操作失败&quot;</span>);       <span class="comment">// &#123; &quot;code&quot;: 500, &quot;msg&quot;: &quot;操作失败&quot;, &quot;data&quot;: null &#125;</span></span><br><span class="line">SaResult.error(<span class="string">&quot;操作失败&quot;</span>).setCode(<span class="number">401</span>); <span class="comment">// &#123; &quot;code&quot;: 401, &quot;msg&quot;: &quot;操作失败&quot;, &quot;data&quot;: null &#125;</span></span><br></pre></td></tr></table></figure><p><code>SaResult</code> 结构简单，能满足演示需求，但它并不适合直接用于生产项目。实际项目中通常会自定义响应体类，追加业务状态码、时间戳、traceId 等字段，并通过 <code>@RestControllerAdvice</code> 配合 <code>ResponseBodyAdvice</code> 对所有响应做统一包装。这些属于 Spring MVC 的工程化实践范畴，与 Sa-Token 本身无关，本系列不展开，按项目实际情况处理即可。</p><p>本系列后续所有接口保持使用 <code>SaResult</code>，以便把注意力集中在 Sa-Token 的功能本身。</p><div class="note info simple"><p>如果你的项目使用了自定义响应体（如 <code>R&lt;T&gt;</code> 或 <code>Result&lt;T&gt;</code>），将 <code>GlobalExceptionHandler</code> 中的 <code>SaResult.error(...).setCode(401)</code> 替换为对应的构造方式即可，异常捕获逻辑本身完全不需要改动。</p></div><hr><h2 id="4-5-本章总结">4.5. 本章总结</h2><p><strong>本章回顾</strong></p><p>本章完成了认证异常处理体系的建设。我们首先系统梳理了 <code>NotLoginException</code> 的 7 种场景值，理解了为什么同样是&quot;Token 校验失败&quot;，框架要区分出 7 个不同的原因——差异化的场景值是差异化用户提示的前提。在代码层面，创建了 <code>GlobalExceptionHandler</code>，通过 Java 17 的 <code>switch</code> 表达式将场景值映射为对应的中文提示，并统一返回 401 状态码。兜底的 <code>SaTokenException</code> 处理方法确保了任何未预期的框架异常都不会以 HTML 页面的形式暴露给前端。最后简要说明了 <code>SaResult</code> 在本系列中的定位，以及与实际项目自定义响应体的对接方式。有了这套机制，第五章的下线行为测试才能真正看到场景值的价值——每种下线方式对应的用户提示，将从这里&quot;翻译&quot;出来。</p><p><strong>核心汇总表</strong></p><table><thead><tr><th>场景值</th><th>常量名</th><th>触发条件</th><th>对应提示示例</th></tr></thead><tbody><tr><td>-1</td><td><code>NOT_TOKEN</code></td><td>请求未携带任何 Token</td><td>“未提供 Token，请先登录”</td></tr><tr><td>-2</td><td><code>INVALID_TOKEN</code></td><td>Token 无效或已注销</td><td>“Token 无效，请重新登录”</td></tr><tr><td>-3</td><td><code>TOKEN_TIMEOUT</code></td><td>Token 超过绝对有效期</td><td>“Token 已过期，请重新登录”</td></tr><tr><td>-4</td><td><code>BE_REPLACED</code></td><td>被同设备类型新登录顶替</td><td>“您的账号已在其他设备登录”</td></tr><tr><td>-5</td><td><code>KICK_OUT</code></td><td>被管理员强制踢下线</td><td>“您已被强制下线”</td></tr><tr><td>-6</td><td><code>TOKEN_FREEZE</code></td><td>超过活跃有效期被冻结</td><td>“Token 已被冻结，请稍后重试”</td></tr><tr><td>-7</td><td><code>NO_PREFIX</code></td><td>Token 前缀格式不正确</td><td>“Token 格式不正确，请重新登录”</td></tr></tbody></table><table><thead><tr><th>异常类型</th><th>处理优先级</th><th>状态码</th><th>说明</th></tr></thead><tbody><tr><td><code>NotLoginException</code></td><td>高（精确匹配）</td><td>401</td><td>按场景值区分 7 种原因</td></tr><tr><td><code>SaTokenException</code></td><td>低（兜底匹配）</td><td>500</td><td>所有其他框架异常的兜底</td></tr></tbody></table><h1>第五章. 下线行为与多端踢人</h1><p><strong>阶段式学习路径</strong></p><p>第四章为所有异常场景建好了&quot;翻译机制&quot;——<code>NotLoginException</code> 的 7 种场景值，通过全局异常处理器转化为差异化的 JSON 提示。但到目前为止，我们只验证了 -1（未携带 Token）和 -2（Token 无效）这两种最简单的情况。</p><p>剩下的 -4、-5 对应的是两种<strong>主动下线</strong>行为。本章我们来把这两种下线方式讲清楚，顺带把加上用户自己主动退出的 <code>logout</code>，完整覆盖 Sa-Token 的三种下线路径。有了第三章的 <code>/auth/info</code> 和第四章的全局异常处理器，每一种下线行为的效果都可以直接观测：先看在线终端列表，踢人，再用失效的 Token 访问接口，看看返回的是哪一条提示。</p><hr><h2 id="5-1-三种下线方式的本质区别">5.1. 三种下线方式的本质区别</h2><p>先用一张表建立整体认知：</p><table><thead><tr><th>下线方式</th><th>触发者</th><th>Token 处理方式</th><th>NotLoginException 场景值</th><th>典型用户提示</th></tr></thead><tbody><tr><td><code>logout</code></td><td>用户自己主动退出</td><td>直接清除 Token 记录</td><td>-2（Token 已注销）</td><td>“请重新登录”</td></tr><tr><td><code>kickout</code></td><td>管理员或系统强制下线</td><td>打上 <code>kickout</code> 标记，不清除</td><td>-5</td><td>“您已被强制下线”</td></tr><tr><td><code>replaced</code></td><td>新登录自动顶替旧会话</td><td>打上 <code>replaced</code> 标记，不清除</td><td>-4</td><td>“您的账号已在其他设备登录”</td></tr></tbody></table><p>三者在 API 形态上非常相似，但产生的用户体验完全不同。这正是 Sa-Token 场景值机制的价值所在——同样是&quot;这个 Token 用不了了&quot;，用户看到的提示可以根据原因完全不同，前端可以据此决定是跳转到登录页、弹出一个提示框，还是展示一个&quot;账号异常&quot;的专属页面。</p><p><strong><code>logout</code></strong> 是用户视角的正常退出。Token 记录从 Redis 中彻底清除，之后携带这个 Token 访问任何接口，都会得到场景值 -2（<code>INVALID_TOKEN</code>）。</p><p><strong><code>kickout</code></strong> 是管理员视角的强制下线。Sa-Token 不会删除 Token 记录，而是在 Redis 中给这个 Token 打上一个特殊标记。用户再次携带该 Token 访问接口时，框架识别到标记，抛出场景值 -5（<code>KICK_OUT</code>）的异常——这样前端就能区分&quot;Token 过期了&quot;和&quot;被管理员踢了&quot;两种情况。</p><p><strong><code>replaced</code></strong> 不需要手动调用——它是第一章&quot;同端互斥&quot;机制的自动产物。当同一账号以相同设备类型再次登录时，框架给旧 Token 打上 <code>replaced</code> 标记。旧 Token 的持有者下次访问，会收到场景值 -4（<code>BE_REPLACED</code>），提示&quot;您的账号已在其他设备登录&quot;。</p><hr><h2 id="5-2-logout：用户主动退出">5.2. logout：用户主动退出</h2><p><code>logout</code> 提供了三种粒度，从细到粗分别是：注销当前 Token、注销指定账号的某个设备端、注销指定账号的所有会话。</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></pre></td><td class="code"><pre><span class="line"><span class="comment">// 注销当前请求携带的 Token（最常用，用于&quot;退出登录&quot;按钮）</span></span><br><span class="line">StpUtil.logout();</span><br><span class="line"></span><br><span class="line"><span class="comment">// 根据 Token 值精确注销某一个会话</span></span><br><span class="line">StpUtil.logoutByTokenValue(<span class="string">&quot;xxxx-xxxx-xxxx-xxxx&quot;</span>);</span><br><span class="line"></span><br><span class="line"><span class="comment">// 注销指定账号在指定设备类型上的所有会话</span></span><br><span class="line">StpUtil.logout(<span class="number">10001L</span>, <span class="string">&quot;PC&quot;</span>);</span><br><span class="line"></span><br><span class="line"><span class="comment">// 注销指定账号在所有设备上的全部会话（强制全端下线）</span></span><br><span class="line">StpUtil.logout(<span class="number">10001L</span>);</span><br></pre></td></tr></table></figure><p>我们已经在第一章的登录接口中提供了 <code>POST /auth/logout</code>，它调用的正是第一个无参版本，注销当前请求携带的 Token。用户点击&quot;退出登录&quot;时调用此接口，Token 随即失效。</p><hr><h2 id="5-3-kickout：强制踢人下线">5.3. kickout：强制踢人下线</h2><p><code>kickout</code> 的 API 形态与 <code>logout</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></pre></td><td class="code"><pre><span class="line"><span class="comment">// 将指定账号的所有会话踢下线（全端）</span></span><br><span class="line">StpUtil.kickout(<span class="number">10001L</span>);</span><br><span class="line"></span><br><span class="line"><span class="comment">// 将指定账号在指定设备类型上的会话踢下线</span></span><br><span class="line">StpUtil.kickout(<span class="number">10001L</span>, <span class="string">&quot;PC&quot;</span>);</span><br><span class="line"></span><br><span class="line"><span class="comment">// 根据 Token 值将对应会话踢下线</span></span><br><span class="line">StpUtil.kickoutByTokenValue(<span class="string">&quot;xxxx-xxxx-xxxx-xxxx&quot;</span>);</span><br></pre></td></tr></table></figure><p><code>kickout</code> 是典型的管理员操作，职责上不应该与用户自己的登录注销混在同一个 Controller 里。下一节我们创建独立的 <code>AdminController</code> 来承载它。</p><hr><h2 id="5-4-创建管理员控制器">5.4. 创建管理员控制器</h2><p>踢人下线是管理后台的操作，我们将其放在独立的 <code>AdminController</code> 中，接口设计遵循 RESTful 风格——踢人的本质是&quot;删除一个会话资源&quot;，所以使用 <code>DELETE</code> 方法。</p><p>📄 <code>src/main/java/com/example/authsatoken/controller/AdminController.java</code>（新建）</p><figure class="highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br><span class="line">32</span><br><span class="line">33</span><br><span class="line">34</span><br><span class="line">35</span><br><span class="line">36</span><br><span class="line">37</span><br><span class="line">38</span><br><span class="line">39</span><br><span class="line">40</span><br><span class="line">41</span><br><span class="line">42</span><br><span class="line">43</span><br><span class="line">44</span><br><span class="line">45</span><br><span class="line">46</span><br><span class="line">47</span><br><span class="line">48</span><br><span class="line">49</span><br><span class="line">50</span><br><span class="line">51</span><br><span class="line">52</span><br><span class="line">53</span><br><span class="line">54</span><br><span class="line">55</span><br><span class="line">56</span><br><span class="line">57</span><br><span class="line">58</span><br><span class="line">59</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">package</span> com.example.authsatoken.controller;</span><br><span class="line"></span><br><span class="line"><span class="keyword">import</span> cn.dev33.satoken.stp.StpUtil;</span><br><span class="line"><span class="keyword">import</span> cn.dev33.satoken.util.SaResult;</span><br><span class="line"><span class="keyword">import</span> org.springframework.web.bind.annotation.*;</span><br><span class="line"></span><br><span class="line"><span class="keyword">import</span> java.util.List;</span><br><span class="line"></span><br><span class="line"><span class="comment">/**</span></span><br><span class="line"><span class="comment"> * 管理员会话管理控制器</span></span><br><span class="line"><span class="comment"> * 提供查询在线终端、踢人下线等管理操作</span></span><br><span class="line"><span class="comment"> *</span></span><br><span class="line"><span class="comment"> * 注意：实际项目中这些接口应加上管理员身份校验（第三篇讲解），这里先聚焦踢人逻辑本身</span></span><br><span class="line"><span class="comment"> */</span></span><br><span class="line"><span class="meta">@RestController</span></span><br><span class="line"><span class="meta">@RequestMapping(&quot;/admin&quot;)</span></span><br><span class="line"><span class="keyword">public</span> <span class="keyword">class</span> <span class="title class_">AdminController</span> &#123;</span><br><span class="line"></span><br><span class="line">    <span class="comment">/**</span></span><br><span class="line"><span class="comment">     * 查询指定账号当前所有活跃会话的 Token 列表</span></span><br><span class="line"><span class="comment">     * 管理后台使用，不需要持有被查账号的 Token，只需要 userId</span></span><br><span class="line"><span class="comment">     */</span></span><br><span class="line">    <span class="meta">@GetMapping(&quot;/users/&#123;userId&#125;/sessions&quot;)</span></span><br><span class="line">    <span class="keyword">public</span> SaResult <span class="title function_">getSessionList</span><span class="params">(<span class="meta">@PathVariable</span> Long userId)</span> &#123;</span><br><span class="line">        List&lt;String&gt; tokenList = StpUtil.getTokenValueListByLoginId(userId);</span><br><span class="line">        <span class="keyword">return</span> SaResult.ok().setData(tokenList);</span><br><span class="line">    &#125;</span><br><span class="line"></span><br><span class="line">    <span class="comment">/**</span></span><br><span class="line"><span class="comment">     * 踢掉指定账号的所有会话（全端踢出）</span></span><br><span class="line"><span class="comment">     * 场景：账号异常、违规封号等需要立即全端下线的情况</span></span><br><span class="line"><span class="comment">     */</span></span><br><span class="line">    <span class="meta">@DeleteMapping(&quot;/users/&#123;userId&#125;/sessions&quot;)</span></span><br><span class="line">    <span class="keyword">public</span> SaResult <span class="title function_">kickoutAll</span><span class="params">(<span class="meta">@PathVariable</span> Long userId)</span> &#123;</span><br><span class="line">        StpUtil.kickout(userId);</span><br><span class="line">        <span class="keyword">return</span> SaResult.ok(<span class="string">&quot;已将账号 &quot;</span> + userId + <span class="string">&quot; 的所有设备踢下线&quot;</span>);</span><br><span class="line">    &#125;</span><br><span class="line"></span><br><span class="line">    <span class="comment">/**</span></span><br><span class="line"><span class="comment">     * 踢掉指定账号在指定设备类型上的会话</span></span><br><span class="line"><span class="comment">     * 场景：用户举报某个设备异常登录，管理员定向下线可疑设备</span></span><br><span class="line"><span class="comment">     */</span></span><br><span class="line">    <span class="meta">@DeleteMapping(&quot;/users/&#123;userId&#125;/sessions/&#123;device&#125;&quot;)</span></span><br><span class="line">    <span class="keyword">public</span> SaResult <span class="title function_">kickoutDevice</span><span class="params">(<span class="meta">@PathVariable</span> Long userId,</span></span><br><span class="line"><span class="params">                                  <span class="meta">@PathVariable</span> String device)</span> &#123;</span><br><span class="line">        StpUtil.kickout(userId, device);</span><br><span class="line">        <span class="keyword">return</span> SaResult.ok(<span class="string">&quot;已将账号 &quot;</span> + userId + <span class="string">&quot; 在 &quot;</span> + device + <span class="string">&quot; 端踢下线&quot;</span>);</span><br><span class="line">    &#125;</span><br><span class="line"></span><br><span class="line">    <span class="comment">/**</span></span><br><span class="line"><span class="comment">     * 根据 Token 值踢掉对应的会话</span></span><br><span class="line"><span class="comment">     * 场景：用户在&quot;账号安全&quot;页看到陌生登录记录，精确下线某个可疑 Token</span></span><br><span class="line"><span class="comment">     */</span></span><br><span class="line">    <span class="meta">@DeleteMapping(&quot;/sessions/&#123;token&#125;&quot;)</span></span><br><span class="line">    <span class="keyword">public</span> SaResult <span class="title function_">kickoutByToken</span><span class="params">(<span class="meta">@PathVariable</span> String token)</span> &#123;</span><br><span class="line">        StpUtil.kickoutByTokenValue(token);</span><br><span class="line">        <span class="keyword">return</span> SaResult.ok(<span class="string">&quot;已将 Token 对应的会话踢下线&quot;</span>);</span><br><span class="line">    &#125;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>三个接口覆盖了三种踢人粒度，从粗到细：按账号全端踢出 → 按账号加设备类型定向踢出 → 按 Token 值精确踢出。资源路径的嵌套结构 <code>/admin/users/&#123;userId&#125;/sessions/&#123;device&#125;</code> 语义清晰——“操作某个用户在某个设备上的会话”。</p><hr><h2 id="5-5-全流程验证">5.5. 全流程验证</h2><p>现在重启项目，通过两个完整的测试序列，把 <code>kickout</code> 和 <code>replaced</code> 对应的场景值验证一遍。有了第四章的全局异常处理器，每种下线原因都会翻译成可读的 JSON 提示，而不是一堆 HTML 乱码。</p><h3 id="验证-kickout（场景值-5）">验证 kickout（场景值 -5）</h3><p><strong>步骤 1：user 账号登录，记录 Token</strong></p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">POST http://localhost:8081/auth/login?username=user&amp;password=123456</span><br></pre></td></tr></table></figure><p>从响应 <code>data</code> 字段拿到 Token 值，命名为 Token-U。</p><p><strong>步骤 2：确认账号在线，查看当前会话信息</strong></p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line">GET http://localhost:8081/auth/info</span><br><span class="line">Header: satoken: &lt;Token-U&gt;</span><br></pre></td></tr></table></figure><p>预期响应中 <code>onlineCount</code> 为 <code>1</code>，<code>tokenList</code> 包含 Token-U。</p><p><strong>步骤 3：管理员将 user 账号（userId=10002）全端踢下线</strong></p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">DELETE http://localhost:8081/admin/users/10002/sessions</span><br></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></pre></td><td class="code"><pre><span class="line"><span class="punctuation">&#123;</span></span><br><span class="line">    <span class="attr">&quot;code&quot;</span><span class="punctuation">:</span> <span class="number">200</span><span class="punctuation">,</span></span><br><span class="line">    <span class="attr">&quot;msg&quot;</span><span class="punctuation">:</span> <span class="string">&quot;已将账号 10002 的所有设备踢下线&quot;</span><span class="punctuation">,</span></span><br><span class="line">    <span class="attr">&quot;data&quot;</span><span class="punctuation">:</span> <span class="literal"><span class="keyword">null</span></span></span><br><span class="line"><span class="punctuation">&#125;</span></span><br></pre></td></tr></table></figure><p><strong>步骤 4：用 Token-U 再次访问 <code>/auth/info</code></strong></p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line">GET http://localhost:8081/auth/info</span><br><span class="line">Header: satoken: &lt;Token-U&gt;</span><br></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></pre></td><td class="code"><pre><span class="line"><span class="punctuation">&#123;</span></span><br><span class="line">    <span class="attr">&quot;code&quot;</span><span class="punctuation">:</span> <span class="number">401</span><span class="punctuation">,</span></span><br><span class="line">    <span class="attr">&quot;msg&quot;</span><span class="punctuation">:</span> <span class="string">&quot;您已被强制下线&quot;</span><span class="punctuation">,</span></span><br><span class="line">    <span class="attr">&quot;data&quot;</span><span class="punctuation">:</span> <span class="literal"><span class="keyword">null</span></span></span><br><span class="line"><span class="punctuation">&#125;</span></span><br></pre></td></tr></table></figure><p>场景值 -5 被全局异常处理器翻译成了&quot;您已被强制下线&quot;，与第四章的映射完全对应。</p><hr><h3 id="验证-replaced（场景值-4）">验证 replaced（场景值 -4）</h3><p><code>replaced</code> 不需要手动调用 API，它是同端互斥的自动产物。第一章我们已经理解了这个机制，这里通过完整测试序列把场景值 -4 跑出来。</p><p><strong>步骤 5：user 账号以 PC 端登录，记录 Token-P1</strong></p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">POST http://localhost:8081/auth/login?username=user&amp;password=123456&amp;device=PC</span><br></pre></td></tr></table></figure><p><strong>步骤 6：同账号再次以 PC 端登录（不同设备类型不触发顶替）</strong></p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">POST http://localhost:8081/auth/login?username=user&amp;password=123456&amp;device=PC</span><br></pre></td></tr></table></figure><p><strong>步骤 7：用旧的 Token-P1 访问 <code>/auth/info</code></strong></p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line">GET http://localhost:8081/auth/info</span><br><span class="line">Header: satoken: &lt;Token-P1&gt;</span><br></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></pre></td><td class="code"><pre><span class="line"><span class="punctuation">&#123;</span></span><br><span class="line">    <span class="attr">&quot;code&quot;</span><span class="punctuation">:</span> <span class="number">401</span><span class="punctuation">,</span></span><br><span class="line">    <span class="attr">&quot;msg&quot;</span><span class="punctuation">:</span> <span class="string">&quot;您的账号已在其他设备登录，当前会话已下线&quot;</span><span class="punctuation">,</span></span><br><span class="line">    <span class="attr">&quot;data&quot;</span><span class="punctuation">:</span> <span class="literal"><span class="keyword">null</span></span></span><br><span class="line"><span class="punctuation">&#125;</span></span><br></pre></td></tr></table></figure><p>场景值 -4 触发——同设备类型的新登录自动将旧 Token 标记为&quot;已被顶替&quot;。</p><p><strong>步骤 8：确认 APP 端不受影响</strong></p><p>在步骤 5 之前，如果同一账号已经有一个 APP 端的 Token，可以携带它访问 <code>/auth/info</code>——APP 端的会话完全不受 PC 端重新登录的影响，仍然正常返回会话信息。这再次印证了第一章的结论：<strong>同端互斥，不同端共存</strong>。</p><p>在验证过程中有一个细节值得停下来思考：<code>logout</code> 和 <code>kickout</code> 对用户来说体验相似（都是&quot;用不了这个 Token 了&quot;），但 Redis 中的数据状态是不同的。</p><p><code>logout</code> 之后，Redis 中这个 Token 对应的键值<strong>被删除</strong>，携带它访问接口得到场景值 -2（<code>INVALID_TOKEN</code>）——框架在 Redis 里找不到这个 Token，判断它从未存在或已被清除。</p><p><code>kickout</code> 之后，Redis 中 Token 对应的记录<strong>依然存在</strong>，但附加了一个 <code>kickout</code> 标记。框架能找到这个 Token，知道它的主人是谁，同时也知道它已被强制下线，于是抛出场景值 -5（<code>KICK_OUT</code>）而非 -2。</p><p>这个差异在大多数业务场景下不影响使用，但在某些需要&quot;区分用户是主动退出还是被踢出&quot;的审计日志场景中，场景值的不同会直接决定日志记录的内容。</p><hr><h2 id="5-7-本章总结">5.7. 本章总结</h2><p><strong>本章回顾</strong></p><p>本章完成了下线行为体系的完整建设。我们首先从概念层面区分了 <code>logout</code>、<code>kickout</code>、<code>replaced</code> 三种下线方式在触发者、Token 处理方式和场景值上的本质差异，理解了场景值机制让&quot;Token 失效&quot;这件事有了可区分的原因。在代码层面，<code>AdminController</code> 提供了三种粒度的踢人接口：全端踢出、按设备类型定向踢出、按 Token 值精确踢出，接口设计遵循 RESTful 的 <code>DELETE</code> 语义和资源嵌套路径。最后通过两个独立测试序列，分别将 <code>kickout</code>（场景值 -5）和 <code>replaced</code>（场景值 -4）跑出来，配合第四章的全局异常处理器，验证了每条差异化提示的准确触发。</p><p><strong>核心汇总表</strong></p><table><thead><tr><th>下线方式</th><th>触发者</th><th>Token 处理</th><th>场景值</th><th>用户侧提示</th></tr></thead><tbody><tr><td><code>logout</code></td><td>用户自己</td><td>从 Redis 删除</td><td>-2</td><td>“Token 无效，请重新登录”</td></tr><tr><td><code>kickout</code></td><td>管理员 / 系统</td><td>打 kickout 标记</td><td>-5</td><td>“您已被强制下线”</td></tr><tr><td><code>replaced</code></td><td>新登录自动触发</td><td>打 replaced 标记</td><td>-4</td><td>“您的账号已在其他设备登录”</td></tr></tbody></table><table><thead><tr><th>API</th><th>粒度</th><th>说明</th></tr></thead><tbody><tr><td><code>StpUtil.logout()</code></td><td>当前 Token</td><td>注销当前请求的会话</td></tr><tr><td><code>StpUtil.logout(id)</code></td><td>指定账号全端</td><td>注销指定账号所有设备</td></tr><tr><td><code>StpUtil.logout(id, device)</code></td><td>指定账号指定设备</td><td>注销指定设备类型的会话</td></tr><tr><td><code>StpUtil.logoutByTokenValue(token)</code></td><td>指定 Token</td><td>精确注销某一会话</td></tr><tr><td><code>StpUtil.kickout(id)</code></td><td>指定账号全端</td><td>强制踢出指定账号所有设备</td></tr><tr><td><code>StpUtil.kickout(id, device)</code></td><td>指定账号指定设备</td><td>定向踢出指定设备类型</td></tr><tr><td><code>StpUtil.kickoutByTokenValue(token)</code></td><td>指定 Token</td><td>精确踢出某一会话</td></tr></tbody></table><table><thead><tr><th>接口</th><th>HTTP 方法</th><th>踢人粒度</th></tr></thead><tbody><tr><td><code>/admin/users/&#123;userId&#125;/sessions</code></td><td>GET</td><td>查询指定账号在线终端列表</td></tr><tr><td><code>/admin/users/&#123;userId&#125;/sessions</code></td><td>DELETE</td><td>踢出指定账号全端</td></tr><tr><td><code>/admin/users/&#123;userId&#125;/sessions/&#123;device&#125;</code></td><td>DELETE</td><td>踢出指定账号指定设备</td></tr><tr><td><code>/admin/sessions/&#123;token&#125;</code></td><td>DELETE</td><td>踢出指定 Token</td></tr></tbody></table><h1>第六章. 账号封禁</h1><p><strong>阶段式学习路径</strong></p><p>第五章讲的是下线——把已登录的人&quot;踢出去&quot;。但踢出去只解决了当下的问题，违规账号退出之后，下一秒就可以重新登录进来。</p><p>账号封禁解决的是&quot;进不来&quot;的问题：在封禁期间，无论该账号用什么设备、什么时间尝试登录，都应该被拒之门外。本章从最基础的整体封禁出发，逐步延伸到只限制部分能力的分类封禁、按违规程度递进的阶梯封禁，最后处理封禁数据在缓存重启后的持久化问题。</p><hr><h2 id="6-1-基础封禁">6.1. 基础封禁</h2><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><span class="line"><span class="comment">// 封禁指定账号，封禁时长 86400 秒（1 天）</span></span><br><span class="line">StpUtil.disable(<span class="number">10001L</span>, <span class="number">86400</span>);</span><br></pre></td></tr></table></figure><p>两个参数的含义：</p><ul><li>参数 1：要封禁的账号 id。</li><li>参数 2：封禁时长，单位秒，传入 <code>-1</code> 代表永久封禁。</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></pre></td><td class="code"><pre><span class="line"><span class="comment">// 校验是否已被封禁，如果是则抛出异常 DisableServiceException</span></span><br><span class="line">StpUtil.checkDisable(<span class="number">10001L</span>);</span><br><span class="line"><span class="comment">// 通过校验后，再执行登录</span></span><br><span class="line">StpUtil.login(<span class="number">10001L</span>);</span><br></pre></td></tr></table></figure><div class="note warning simple"><p>v1.31.0 之后，<code>StpUtil.login()</code> 不再自动校验账号是否被封禁，需要开发者在登录逻辑中手动调用 <code>checkDisable()</code> 进行前置校验。</p></div><p>在我们现有的登录接口中，加入封禁校验：</p><p>📄 <code>src/main/java/com/example/authsatoken/controller/LoginController.java</code>（修改 login 方法，在执行登录前追加封禁校验）</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></pre></td><td class="code"><pre><span class="line"><span class="comment">// 3. 校验该账号是否已被封禁</span></span><br><span class="line">StpUtil.checkDisable(userId);</span><br><span class="line"></span><br><span class="line"><span class="comment">// 4. 根据角色构建差异化的登录参数（原有逻辑不变）</span></span><br><span class="line"><span class="type">boolean</span> <span class="variable">isAdmin</span> <span class="operator">=</span> <span class="string">&quot;admin&quot;</span>.equals(username);</span><br><span class="line"><span class="type">SaLoginParameter</span> <span class="variable">loginParam</span> <span class="operator">=</span> <span class="keyword">new</span> <span class="title class_">SaLoginParameter</span>()</span><br><span class="line">        .setDeviceType(device)</span><br><span class="line">        .setIsConcurrent(!isAdmin)</span><br><span class="line">        .setMaxLoginCount(isAdmin ? <span class="number">1</span> : <span class="number">2</span>)</span><br><span class="line">        .setIsLastingCookie(rememberMe);</span><br><span class="line"></span><br><span class="line">StpUtil.login(userId, loginParam);</span><br><span class="line"><span class="keyword">return</span> SaResult.ok(<span class="string">&quot;登录成功&quot;</span>).setData(StpUtil.getTokenValue());</span><br></pre></td></tr></table></figure><p>此模块的全部可用 API：</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><span class="line"><span class="comment">// 封禁指定账号</span></span><br><span class="line">StpUtil.disable(<span class="number">10001L</span>, <span class="number">86400</span>);</span><br><span class="line"></span><br><span class="line"><span class="comment">// 查询指定账号是否已被封禁（true=已封禁，false=未封禁）</span></span><br><span class="line">StpUtil.isDisable(<span class="number">10001L</span>);</span><br><span class="line"></span><br><span class="line"><span class="comment">// 校验指定账号是否已被封禁，如果是则抛出 DisableServiceException</span></span><br><span class="line">StpUtil.checkDisable(<span class="number">10001L</span>);</span><br><span class="line"></span><br><span class="line"><span class="comment">// 获取指定账号剩余封禁时间，单位：秒（-1=永久封禁，-2=未被封禁）</span></span><br><span class="line">StpUtil.getDisableTime(<span class="number">10001L</span>);</span><br><span class="line"></span><br><span class="line"><span class="comment">// 解除封禁</span></span><br><span class="line">StpUtil.untieDisable(<span class="number">10001L</span>);</span><br></pre></td></tr></table></figure><p><strong>踢 + 封的组合策略</strong></p><p>对于当前正在线的违规账号，单纯封禁并不会让它立刻掉线——封禁只影响下次登录，已有的会话仍然有效。如果需要立即生效，采用先踢后封的组合：</p><figure class="highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">// 先将该账号所有在线会话踢下线</span></span><br><span class="line">StpUtil.kickout(<span class="number">10001L</span>);</span><br><span class="line"><span class="comment">// 再封禁账号，阻止其重新登录</span></span><br><span class="line">StpUtil.disable(<span class="number">10001L</span>, <span class="number">86400</span>);</span><br></pre></td></tr></table></figure><hr><h2 id="6-2-分类封禁">6.2. 分类封禁</h2><p>有时候我们并不需要封禁整个账号，而是只限制其访问部分服务。</p><p>假设我们在开发一个电商系统，对于违规账号设定三种处罚：</p><ul><li>账号 A 因多次虚假好评，封禁其 <strong>评论能力</strong>。</li><li>账号 B 因多次薅羊毛，封禁其 <strong>下单能力</strong>。</li><li>账号 C 因店铺销售假货，封禁其 <strong>开店能力</strong>。</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></pre></td><td class="code"><pre><span class="line"><span class="comment">// 封禁指定用户的评论能力，期限 1 天</span></span><br><span class="line">StpUtil.disable(<span class="number">10001L</span>, <span class="string">&quot;comment&quot;</span>, <span class="number">86400</span>);</span><br></pre></td></tr></table></figure><p>三个参数的含义：</p><ul><li>参数 1：要封禁的账号 id。</li><li>参数 2：业务标识（可以是任意自定义字符串）。</li><li>参数 3：封禁时长，单位秒，<code>-1</code> 代表永久封禁。</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></pre></td><td class="code"><pre><span class="line"><span class="comment">// 在评论接口校验评论能力，已被封禁则抛出 DisableServiceException</span></span><br><span class="line"><span class="comment">// 可通过 e.getService() 拿到业务标识 &quot;comment&quot;</span></span><br><span class="line">StpUtil.checkDisable(<span class="number">10001L</span>, <span class="string">&quot;comment&quot;</span>);</span><br><span class="line"></span><br><span class="line"><span class="comment">// 在下单接口校验下单能力</span></span><br><span class="line"><span class="comment">// 不会抛出异常，因为我们没有封禁其下单能力</span></span><br><span class="line">StpUtil.checkDisable(<span class="number">10001L</span>, <span class="string">&quot;place-order&quot;</span>);</span><br></pre></td></tr></table></figure><p>分类封禁的完整 API：</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><span class="line"><span class="comment">// 封禁：指定账号的指定服务</span></span><br><span class="line">StpUtil.disable(<span class="number">10001L</span>, <span class="string">&quot;comment&quot;</span>, <span class="number">86400</span>);</span><br><span class="line"></span><br><span class="line"><span class="comment">// 判断：指定账号的指定服务是否已被封禁</span></span><br><span class="line">StpUtil.isDisable(<span class="number">10001L</span>, <span class="string">&quot;comment&quot;</span>);</span><br><span class="line"></span><br><span class="line"><span class="comment">// 校验：指定账号的指定服务是否已被封禁，如果是则抛出异常</span></span><br><span class="line">StpUtil.checkDisable(<span class="number">10001L</span>, <span class="string">&quot;comment&quot;</span>);</span><br><span class="line"></span><br><span class="line"><span class="comment">// 获取：指定账号的指定服务剩余封禁时间（-1=永久，-2=未被封禁）</span></span><br><span class="line">StpUtil.getDisableTime(<span class="number">10001L</span>, <span class="string">&quot;comment&quot;</span>);</span><br><span class="line"></span><br><span class="line"><span class="comment">// 解封：指定账号的指定服务</span></span><br><span class="line">StpUtil.untieDisable(<span class="number">10001L</span>, <span class="string">&quot;comment&quot;</span>);</span><br></pre></td></tr></table></figure><hr><h2 id="6-3-阶梯封禁">6.3. 阶梯封禁</h2><p>对于多次违规的用户，处罚力度往往需要递进。以一个论坛系统为例，设定三种处罚力度：</p><ul><li><strong>1 级</strong>：封禁发帖、评论能力，但允许点赞、关注。</li><li><strong>2 级</strong>：封禁一切互动能力，但允许浏览。</li><li><strong>3 级</strong>：封禁登录，限制一切能力。</li></ul><p>关键在于把处罚力度量化为数字等级，数字越大代表封禁越严。然后使用阶梯封禁 API：</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><span class="line"><span class="comment">// 将账号 10001 封禁到 3 级，时长 10000 秒</span></span><br><span class="line">StpUtil.disableLevel(<span class="number">10001L</span>, <span class="number">3</span>, <span class="number">10000</span>);</span><br><span class="line"></span><br><span class="line"><span class="comment">// 获取指定账号当前封禁级别（未被封禁则返回 -2）</span></span><br><span class="line">StpUtil.getDisableLevel(<span class="number">10001L</span>);</span><br><span class="line"></span><br><span class="line"><span class="comment">// 判断指定账号是否已被封禁到指定级别，返回 true 或 false</span></span><br><span class="line">StpUtil.isDisableLevel(<span class="number">10001L</span>, <span class="number">3</span>);</span><br><span class="line"></span><br><span class="line"><span class="comment">// 校验：如果该账号已被封禁到 2 级及以上，则抛出异常</span></span><br><span class="line"><span class="comment">// 触发规则：实际封禁级别 &gt;= 校验级别时抛出异常</span></span><br><span class="line">StpUtil.checkDisableLevel(<span class="number">10001L</span>, <span class="number">2</span>);</span><br></pre></td></tr></table></figure><p><code>DisableServiceException</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><span class="line"><span class="keyword">try</span> &#123;</span><br><span class="line">    StpUtil.checkDisableLevel(<span class="number">10001L</span>, <span class="number">2</span>);</span><br><span class="line">&#125; <span class="keyword">catch</span> (DisableServiceException e) &#123;</span><br><span class="line">    e.getLevel();       <span class="comment">// 该账号实际被封禁的等级</span></span><br><span class="line">    e.getLimitLevel();  <span class="comment">// 校验时要求的等级（即 checkDisableLevel 传入的值）</span></span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>当实际封禁等级 <code>&gt;= limitLevel</code> 时，框架就会抛出异常。也就是说，账号被封禁到 3 级时，对 2 级和 3 级的校验都会不通过；对 4 级的校验则依然通过。</p><p><strong>分类封禁 + 阶梯封禁的组合</strong></p><p>如果业务足够复杂，还可以将两者结合——对某个特定服务按等级封禁：</p><figure class="highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><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><span class="line"><span class="comment">// 对账号 10001 的 comment 服务执行 3 级封禁，时长 10000 秒</span></span><br><span class="line">StpUtil.disableLevel(<span class="number">10001L</span>, <span class="string">&quot;comment&quot;</span>, <span class="number">3</span>, <span class="number">10000</span>);</span><br><span class="line"></span><br><span class="line"><span class="comment">// 获取该账号 comment 服务的封禁级别</span></span><br><span class="line">StpUtil.getDisableLevel(<span class="number">10001L</span>, <span class="string">&quot;comment&quot;</span>);</span><br><span class="line"></span><br><span class="line"><span class="comment">// 判断该账号 comment 服务是否已被封禁到 3 级</span></span><br><span class="line">StpUtil.isDisableLevel(<span class="number">10001L</span>, <span class="string">&quot;comment&quot;</span>, <span class="number">3</span>);</span><br><span class="line"></span><br><span class="line"><span class="comment">// 校验该账号 comment 服务，封禁等级是否达到 2 级</span></span><br><span class="line">StpUtil.checkDisableLevel(<span class="number">10001L</span>, <span class="string">&quot;comment&quot;</span>, <span class="number">2</span>);</span><br></pre></td></tr></table></figure><hr><h2 id="6-4-封禁信息持久化">6.4. 封禁信息持久化</h2><p>Sa-Token 默认将封禁信息存储在缓存（Redis）中。缓存数据是&quot;临时性的&quot;——一旦 Redis 重启，封禁记录就会丢失，已被封禁的账号便能重新登录。对于大多数生产系统，封禁数据需要持久化到数据库。</p><p>最直接的做法是在调用 Sa-Token 封禁 API 之后，同步写入数据库：</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></pre></td><td class="code"><pre><span class="line"><span class="comment">// 在 Sa-Token 中封禁账号</span></span><br><span class="line">StpUtil.disable(<span class="number">10001L</span>, <span class="number">86400</span>);</span><br><span class="line"><span class="comment">// 同步写入数据库（示例代码，按实际业务实现）</span></span><br><span class="line">userMapper.disableUser(<span class="number">10001L</span>, <span class="number">86400</span>);</span><br></pre></td></tr></table></figure><p>这样可以保证封禁数据同时存在于缓存和数据库中。但还有一个问题没解决：如果缓存中间件重启，缓存数据丢失，此时 <code>StpUtil.checkDisable()</code> 将失去约束效果，被封禁的账号可以顺利登录，直到缓存数据被重新同步。</p><p>Sa-Token 提供了一种更优雅的方案：实现 <code>StpInterface</code> 的 <code>isDisabled</code> 方法。框架在每次调用 <code>checkDisable()</code> 时，会先查询缓存，<strong>缓存未命中时再调用你实现的 <code>isDisabled</code> 方法</strong>，从数据库中实时查询封禁状态。这样即使缓存丢失，封禁校验依然有效，且不需要在程序启动时批量同步数据。</p><div class="note info simple"><p><code>StpInterface</code> 将在第三篇讲解权限体系时正式引入。这里只需要知道它是 Sa-Token 的数据源扩展点，<code>isDisabled</code> 是其中负责封禁查询的方法。</p></div><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></pre></td><td class="code"><pre><span class="line"><span class="meta">@Component</span></span><br><span class="line"><span class="keyword">public</span> <span class="keyword">class</span> <span class="title class_">StpInterfaceImpl</span> <span class="keyword">implements</span> <span class="title class_">StpInterface</span> &#123;</span><br><span class="line"></span><br><span class="line">    <span class="comment">/**</span></span><br><span class="line"><span class="comment">     * 返回指定账号是否被封禁</span></span><br><span class="line"><span class="comment">     * 框架在 checkDisable() 缓存未命中时调用此方法，从数据库中查询真实封禁状态</span></span><br><span class="line"><span class="comment">     *</span></span><br><span class="line"><span class="comment">     * <span class="doctag">@param</span> loginId 账号 id</span></span><br><span class="line"><span class="comment">     * <span class="doctag">@param</span> service 业务标识（无分类封禁时为默认值）</span></span><br><span class="line"><span class="comment">     */</span></span><br><span class="line">    <span class="meta">@Override</span></span><br><span class="line">    <span class="keyword">public</span> SaDisableWrapperInfo <span class="title function_">isDisabled</span><span class="params">(Object loginId, String service)</span> &#123;</span><br><span class="line">        <span class="comment">// 查询数据库中的封禁记录（此处仅为示例代码）</span></span><br><span class="line">        <span class="type">DisableRecord</span> <span class="variable">record</span> <span class="operator">=</span> userMapper.getDisableRecord(loginId, service);</span><br><span class="line"></span><br><span class="line">        <span class="keyword">if</span> (record == <span class="literal">null</span> || record.isExpired()) &#123;</span><br><span class="line">            <span class="comment">// 未被封禁，且将查询结果缓存 3600 秒，期间不再重复查库</span></span><br><span class="line">            <span class="keyword">return</span> SaDisableWrapperInfo.createNotDisabled(<span class="number">3600</span>);</span><br><span class="line">        &#125;</span><br><span class="line">        <span class="comment">// 已被封禁，返回剩余封禁秒数和封禁等级</span></span><br><span class="line">        <span class="keyword">return</span> SaDisableWrapperInfo.createDisabled(record.getRemainSeconds(), record.getLevel());</span><br><span class="line">    &#125;</span><br><span class="line"></span><br><span class="line">    <span class="comment">// getPermissionList 和 getRoleList 方法在第三篇实现，此处暂时返回空列表</span></span><br><span class="line">    <span class="meta">@Override</span></span><br><span class="line">    <span class="keyword">public</span> List&lt;String&gt; <span class="title function_">getPermissionList</span><span class="params">(Object loginId, String loginType)</span> &#123; <span class="keyword">return</span> List.of(); &#125;</span><br><span class="line">    <span class="meta">@Override</span></span><br><span class="line">    <span class="keyword">public</span> List&lt;String&gt; <span class="title function_">getRoleList</span><span class="params">(Object loginId, String loginType)</span> &#123; <span class="keyword">return</span> List.of(); &#125;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p><code>SaDisableWrapperInfo</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></pre></td><td class="code"><pre><span class="line"><span class="comment">// 未被封禁</span></span><br><span class="line"><span class="keyword">return</span> SaDisableWrapperInfo.createNotDisabled();</span><br><span class="line"></span><br><span class="line"><span class="comment">// 未被封禁，且将结果缓存 3600 秒（期间不再进入 isDisabled 方法）</span></span><br><span class="line"><span class="keyword">return</span> SaDisableWrapperInfo.createNotDisabled(<span class="number">3600</span>);</span><br><span class="line"></span><br><span class="line"><span class="comment">// 已被封禁，剩余 86400 秒，封禁等级为 1</span></span><br><span class="line"><span class="keyword">return</span> SaDisableWrapperInfo.createDisabled(<span class="number">86400</span>, <span class="number">1</span>);</span><br><span class="line"></span><br><span class="line"><span class="comment">// 标准写法：new 对象，参数为（是否封禁、剩余秒数、封禁等级）</span></span><br><span class="line"><span class="keyword">return</span> <span class="keyword">new</span> <span class="title class_">SaDisableWrapperInfo</span>(<span class="literal">true</span>, <span class="number">86400</span>, <span class="number">1</span>);</span><br></pre></td></tr></table></figure><hr><h2 id="6-5-本章总结">6.5. 本章总结</h2><p><strong>本章回顾</strong></p><p>本章从&quot;踢出去&quot;延伸到&quot;进不来&quot;，完整讲解了 Sa-Token 的账号封禁体系。基础封禁通过 <code>disable()</code> 和 <code>checkDisable()</code> 实现&quot;登录前校验封禁状态&quot;的标准流程，并说明了&quot;先踢后封&quot;的组合策略以确保已在线用户立即掉线。分类封禁引入了业务标识参数，使得不同能力（评论、下单、开店）可以独立封禁，互不干扰，适合细粒度的差异化处罚场景。阶梯封禁将处罚力度量化为等级数字，结合 <code>checkDisableLevel()</code> 的&quot;等级 &gt;= 阈值则拦截&quot;规则，实现了处罚力度的递进管理，并可以与分类封禁组合使用。最后通过 <code>StpInterface.isDisabled()</code> 方法解决了缓存重启后封禁数据丢失的问题，使封禁校验始终能回源数据库。</p><p><strong>核心汇总表</strong></p><table><thead><tr><th>封禁类型</th><th>封禁 API</th><th>校验 API</th><th>适用场景</th></tr></thead><tbody><tr><td>基础封禁</td><td><code>disable(id, time)</code></td><td><code>checkDisable(id)</code></td><td>封禁整个账号</td></tr><tr><td>分类封禁</td><td><code>disable(id, service, time)</code></td><td><code>checkDisable(id, service)</code></td><td>只封禁特定能力</td></tr><tr><td>阶梯封禁</td><td><code>disableLevel(id, level, time)</code></td><td><code>checkDisableLevel(id, level)</code></td><td>按等级递进处罚</td></tr><tr><td>分类+阶梯</td><td><code>disableLevel(id, service, level, time)</code></td><td><code>checkDisableLevel(id, service, level)</code></td><td>特定能力按等级处罚</td></tr></tbody></table><table><thead><tr><th>API</th><th>说明</th></tr></thead><tbody><tr><td><code>StpUtil.disable(id, time)</code></td><td>封禁账号，<code>-1</code> 为永久</td></tr><tr><td><code>StpUtil.isDisable(id)</code></td><td>是否已被封禁，返回 boolean</td></tr><tr><td><code>StpUtil.checkDisable(id)</code></td><td>校验封禁状态，已封禁则抛 <code>DisableServiceException</code></td></tr><tr><td><code>StpUtil.getDisableTime(id)</code></td><td>剩余封禁秒数，<code>-2</code> 表示未封禁</td></tr><tr><td><code>StpUtil.untieDisable(id)</code></td><td>解除封禁</td></tr><tr><td><code>e.getLevel()</code></td><td>异常中获取实际封禁等级</td></tr><tr><td><code>e.getLimitLevel()</code></td><td>异常中获取校验时传入的等级阈值</td></tr></tbody></table><table><thead><tr><th><code>SaDisableWrapperInfo</code> 写法</th><th>含义</th></tr></thead><tbody><tr><td><code>createNotDisabled()</code></td><td>未被封禁</td></tr><tr><td><code>createNotDisabled(ttl)</code></td><td>未被封禁，结果缓存 ttl 秒</td></tr><tr><td><code>createDisabled(seconds, level)</code></td><td>已被封禁，剩余秒数和等级</td></tr></tbody></table><h1>第七章. 二级认证</h1><p><strong>阶段式学习路径</strong></p><p>前几章解决的都是&quot;你是谁&quot;和&quot;你能不能进来&quot;的问题。但有些操作需要在已经登录的基础上，再多走一步验证——即使你已经证明了自己的身份，在执行某些高风险操作之前，系统还是需要你再次确认。</p><p>你一定用过这类体验：代码托管平台删除仓库时要求重新输入密码、银行转账时需要短信验证码、修改手机号时需要人脸识别。这就是本章要讲的<strong>二级认证</strong>——在已登录会话的基础上，对特定操作额外加一道验证门槛。</p><hr><h2 id="7-1-核心概念">7.1. 核心概念</h2><p>二级认证的本质是：在当前会话上打一个&quot;已通过二级验证&quot;的时效性标记。验证通过后，这个标记在指定时间内有效；一旦超时或主动关闭，标记消失，再次访问受保护的接口就需要重新验证。</p><p>这与 Session 或 Token 本身的有效期是完全独立的两个维度——你的登录状态可以是 30 天有效，但二级认证标记可能只有 120 秒有效。两者互不影响。</p><hr><h2 id="7-2-核心-API">7.2. 核心 API</h2><p>Sa-Token 二级认证的全部操作只有五个方法：</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></pre></td><td class="code"><pre><span class="line"><span class="comment">// 在当前会话开启二级认证，有效期 120 秒</span></span><br><span class="line">StpUtil.openSafe(<span class="number">120</span>);</span><br><span class="line"></span><br><span class="line"><span class="comment">// 查询当前会话是否处于二级认证有效期内，返回 true / false</span></span><br><span class="line">StpUtil.isSafe();</span><br><span class="line"></span><br><span class="line"><span class="comment">// 校验当前会话是否已通过二级认证，未通过则直接抛出异常</span></span><br><span class="line">StpUtil.checkSafe();</span><br><span class="line"></span><br><span class="line"><span class="comment">// 获取当前会话二级认证的剩余有效时间，单位：秒</span></span><br><span class="line"><span class="comment">// 返回 -2 代表尚未开启或已过期</span></span><br><span class="line">StpUtil.getSafeTime();</span><br><span class="line"></span><br><span class="line"><span class="comment">// 主动关闭当前会话的二级认证</span></span><br><span class="line">StpUtil.closeSafe();</span><br></pre></td></tr></table></figure><p><code>openSafe()</code> 和 <code>closeSafe()</code> 是一对开关，<code>isSafe()</code> 和 <code>checkSafe()</code> 的关系与第三章中 <code>isLogin()</code> 和 <code>checkLogin()</code> 完全类似——前者是查询，返回布尔值，不抛异常；后者是校验，不通过直接中断请求。</p><hr><h2 id="7-3-一个完整的业务示例">7.3. 一个完整的业务示例</h2><p>以&quot;删除仓库&quot;这个高风险操作为例，演示二级认证的完整业务流程：</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></pre></td><td class="code"><pre><span class="line"><span class="comment">/**</span></span><br><span class="line"><span class="comment"> * 删除仓库接口（高风险操作，需要二级认证）</span></span><br><span class="line"><span class="comment"> */</span></span><br><span class="line"><span class="meta">@RequestMapping(&quot;/deleteProject&quot;)</span></span><br><span class="line"><span class="keyword">public</span> SaResult <span class="title function_">deleteProject</span><span class="params">(String projectId)</span> &#123;</span><br><span class="line">    <span class="comment">// 第 1 步：检查当前会话是否已完成二级认证</span></span><br><span class="line">    <span class="keyword">if</span> (!StpUtil.isSafe()) &#123;</span><br><span class="line">        <span class="keyword">return</span> SaResult.error(<span class="string">&quot;操作失败，请先完成安全验证&quot;</span>);</span><br><span class="line">    &#125;</span><br><span class="line">    <span class="comment">// 第 2 步：二级认证通过，执行删除业务逻辑</span></span><br><span class="line">    <span class="comment">// projectService.delete(projectId);</span></span><br><span class="line">    <span class="keyword">return</span> SaResult.ok(<span class="string">&quot;仓库删除成功&quot;</span>);</span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="comment">/**</span></span><br><span class="line"><span class="comment"> * 发起二级认证（用户输入密码后调用此接口）</span></span><br><span class="line"><span class="comment"> */</span></span><br><span class="line"><span class="meta">@RequestMapping(&quot;/openSafe&quot;)</span></span><br><span class="line"><span class="keyword">public</span> SaResult <span class="title function_">openSafe</span><span class="params">(String password)</span> &#123;</span><br><span class="line">    <span class="comment">// 比对密码（实际项目中应从数据库查询并对比哈希值，这里简化演示）</span></span><br><span class="line">    <span class="keyword">if</span> (!<span class="string">&quot;123456&quot;</span>.equals(password)) &#123;</span><br><span class="line">        <span class="keyword">return</span> SaResult.error(<span class="string">&quot;密码错误，安全验证失败&quot;</span>);</span><br><span class="line">    &#125;</span><br><span class="line">    <span class="comment">// 比对成功，为当前会话打开二级认证，有效期 120 秒</span></span><br><span class="line">    StpUtil.openSafe(<span class="number">120</span>);</span><br><span class="line">    <span class="keyword">return</span> SaResult.ok(<span class="string">&quot;安全验证成功，请在 120 秒内完成操作&quot;</span>);</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>完整的调用链路是这样的：</p><ol><li>前端调用 <code>deleteProject</code> 接口，尝试删除仓库。</li><li>后端发现当前会话尚未通过二级认证，返回&quot;请先完成安全验证&quot;。</li><li>前端弹出密码输入框，用户输入密码，调用 <code>openSafe</code> 接口。</li><li>后端比对密码通过，为当前会话打开二级认证标记，有效期 120 秒。</li><li>前端在 120 秒内再次调用 <code>deleteProject</code> 接口。</li><li>后端检测到二级认证有效，执行删除操作，返回成功。</li></ol><hr><h2 id="7-4-指定业务标识">7.4. 指定业务标识</h2><p>如果项目有多条业务线都需要二级认证，默认的 <code>openSafe()</code> 无法区分&quot;这次验证通过的是哪个操作&quot;。举个例子：用户为了删除仓库完成了密码验证，结果这个验证状态同时也让他能免验证地绑定新手机号——这显然不是我们想要的。</p><p>Sa-Token 通过<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></pre></td><td class="code"><pre><span class="line"><span class="comment">// 为&quot;删除仓库&quot;操作开启二级认证，有效期 120 秒</span></span><br><span class="line">StpUtil.openSafe(<span class="string">&quot;delete-project&quot;</span>, <span class="number">120</span>);</span><br><span class="line"></span><br><span class="line"><span class="comment">// 为&quot;绑定手机号&quot;操作开启二级认证，有效期 300 秒</span></span><br><span class="line">StpUtil.openSafe(<span class="string">&quot;bind-phone&quot;</span>, <span class="number">300</span>);</span><br><span class="line"></span><br><span class="line"><span class="comment">// 校验当前会话是否已通过&quot;删除仓库&quot;的二级认证</span></span><br><span class="line">StpUtil.checkSafe(<span class="string">&quot;delete-project&quot;</span>);</span><br><span class="line"></span><br><span class="line"><span class="comment">// 校验&quot;绑定手机号&quot;的二级认证——不会通过，因为用户只验证了&quot;删除仓库&quot;</span></span><br><span class="line">StpUtil.checkSafe(<span class="string">&quot;bind-phone&quot;</span>);</span><br></pre></td></tr></table></figure><p>业务标识可以是任意字符串，由你的业务语义决定。建议使用具有描述性的短语，方便代码阅读和日志排查。</p><p>带业务标识的完整 API：</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><span class="line"><span class="comment">// 开启指定业务的二级认证</span></span><br><span class="line">StpUtil.openSafe(<span class="string">&quot;client&quot;</span>, <span class="number">600</span>);</span><br><span class="line"></span><br><span class="line"><span class="comment">// 查询是否已通过指定业务的二级认证</span></span><br><span class="line">StpUtil.isSafe(<span class="string">&quot;client&quot;</span>);</span><br><span class="line"></span><br><span class="line"><span class="comment">// 校验指定业务的二级认证，未通过则抛出异常</span></span><br><span class="line">StpUtil.checkSafe(<span class="string">&quot;client&quot;</span>);</span><br><span class="line"></span><br><span class="line"><span class="comment">// 获取指定业务二级认证的剩余有效时间（-2=未开启或已过期）</span></span><br><span class="line">StpUtil.getSafeTime(<span class="string">&quot;client&quot;</span>);</span><br><span class="line"></span><br><span class="line"><span class="comment">// 关闭指定业务的二级认证</span></span><br><span class="line">StpUtil.closeSafe(<span class="string">&quot;client&quot;</span>);</span><br></pre></td></tr></table></figure><hr><h2 id="7-5-用注解简化校验">7.5. 用注解简化校验</h2><p>如果你不想在每个高风险接口的方法体内写 <code>checkSafe()</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></pre></td><td class="code"><pre><span class="line"><span class="comment">// 必须通过默认二级认证才能访问此接口</span></span><br><span class="line"><span class="meta">@SaCheckSafe</span></span><br><span class="line"><span class="meta">@RequestMapping(&quot;/deleteProject&quot;)</span></span><br><span class="line"><span class="keyword">public</span> SaResult <span class="title function_">deleteProject</span><span class="params">(String projectId)</span> &#123;</span><br><span class="line">    <span class="comment">// 执行删除操作，二级认证校验已在方法外完成</span></span><br><span class="line">    <span class="keyword">return</span> SaResult.ok(<span class="string">&quot;仓库删除成功&quot;</span>);</span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="comment">// 必须通过指定业务标识的二级认证才能访问此接口</span></span><br><span class="line"><span class="meta">@SaCheckSafe(&quot;delete-project&quot;)</span></span><br><span class="line"><span class="meta">@RequestMapping(&quot;/deleteProject2&quot;)</span></span><br><span class="line"><span class="keyword">public</span> SaResult <span class="title function_">deleteProject2</span><span class="params">(String projectId)</span> &#123;</span><br><span class="line">    <span class="keyword">return</span> SaResult.ok(<span class="string">&quot;仓库删除成功&quot;</span>);</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><div class="note warning simple"><p><code>@SaCheckSafe</code> 注解需要 <code>SaInterceptor</code> 拦截器生效才能工作。该拦截器将在第三篇正式引入，目前如果需要使用注解方式，可以先参考第三篇的配置提前注册。</p></div><p>未通过二级认证时，<code>@SaCheckSafe</code> 会抛出 <code>NotSafeException</code>，建议在 <code>GlobalExceptionHandler</code> 中追加对应的处理：</p><p>📄 <code>src/main/java/com/example/authsatoken/exception/GlobalExceptionHandler.java</code>（追加方法）</p><figure class="highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">import</span> cn.dev33.satoken.exception.NotSafeException;</span><br><span class="line"></span><br><span class="line"><span class="comment">/**</span></span><br><span class="line"><span class="comment"> * 捕获二级认证校验失败异常</span></span><br><span class="line"><span class="comment"> * 触发场景：访问需要二级认证的接口，但当前会话尚未完成或已过期</span></span><br><span class="line"><span class="comment"> */</span></span><br><span class="line"><span class="meta">@ExceptionHandler(NotSafeException.class)</span></span><br><span class="line"><span class="keyword">public</span> SaResult <span class="title function_">handleNotSafeException</span><span class="params">(NotSafeException e)</span> &#123;</span><br><span class="line">    <span class="keyword">return</span> SaResult.error(<span class="string">&quot;当前操作需要二级认证，请先完成安全验证&quot;</span>).setCode(<span class="number">401</span>);</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><hr><h2 id="7-6-本章总结">7.6. 本章总结</h2><p><strong>本章回顾</strong></p><p>本章完整讲解了二级认证机制。二级认证是在已登录会话基础上叠加的额外验证层，与 Token 有效期相互独立，专门用于保护高风险操作。核心 API 只有五个：<code>openSafe()</code>、<code>isSafe()</code>、<code>checkSafe()</code>、<code>getSafeTime()</code>、<code>closeSafe()</code>，<code>isSafe()</code> 与 <code>checkSafe()</code> 的语义分工与第三章的 <code>isLogin()</code> / <code>checkLogin()</code> 完全对应。通过业务标识参数，不同操作的二级认证状态彼此隔离，解决了&quot;验证了 A 操作却能跳过 B 操作验证&quot;的安全漏洞。<code>@SaCheckSafe</code> 注解提供了声明式的校验写法，并在 <code>GlobalExceptionHandler</code> 中追加了对应的异常处理。</p><p><strong>核心汇总表</strong></p><table><thead><tr><th>API</th><th>说明</th></tr></thead><tbody><tr><td><code>StpUtil.openSafe(time)</code></td><td>开启二级认证，有效期 time 秒</td></tr><tr><td><code>StpUtil.openSafe(service, time)</code></td><td>开启指定业务的二级认证</td></tr><tr><td><code>StpUtil.isSafe()</code></td><td>查询是否已通过二级认证，返回 boolean</td></tr><tr><td><code>StpUtil.isSafe(service)</code></td><td>查询指定业务的二级认证状态</td></tr><tr><td><code>StpUtil.checkSafe()</code></td><td>校验二级认证，未通过抛 <code>NotSafeException</code></td></tr><tr><td><code>StpUtil.checkSafe(service)</code></td><td>校验指定业务的二级认证</td></tr><tr><td><code>StpUtil.getSafeTime()</code></td><td>获取剩余有效秒数，-2=未开启或已过期</td></tr><tr><td><code>StpUtil.getSafeTime(service)</code></td><td>获取指定业务剩余有效秒数</td></tr><tr><td><code>StpUtil.closeSafe()</code></td><td>主动关闭二级认证</td></tr><tr><td><code>StpUtil.closeSafe(service)</code></td><td>主动关闭指定业务的二级认证</td></tr></tbody></table><table><thead><tr><th><code>isSafe()</code> vs <code>checkSafe()</code></th><th>适用场景</th></tr></thead><tbody><tr><td><code>isSafe()</code></td><td>未通过时需要返回自定义响应体，不希望抛异常</td></tr><tr><td><code>checkSafe()</code></td><td>未通过时直接中断请求，配合全局异常处理器统一响应</td></tr></tbody></table><table><thead><tr><th>业务标识使用建议</th><th>示例</th></tr></thead><tbody><tr><td>单一高风险操作</td><td><code>&quot;delete-project&quot;</code> / <code>&quot;bind-phone&quot;</code></td></tr><tr><td>多个操作共享一次验证</td><td>不传业务标识，使用默认标记</td></tr><tr><td>不同操作需独立验证</td><td>每个操作使用不同的业务标识字符串</td></tr></tbody></table><h1>第八章. 模拟他人 / 临时身份切换</h1><p><strong>阶段式学习路径</strong></p><p>前七章的所有操作都围绕同一个主体：<strong>当前请求者自己</strong>。登录、查询会话信息、下线、封禁、二级认证，操作对象要么是自己的 Token，要么是管理员针对他人的会话发起的外部操作。</p><p>但有一类场景，需要&quot;以自己的权限，站在别人的视角执行操作&quot;。比如：客服系统中，客服人员需要临时切换到用户视角排查问题；多租户系统中，超级管理员需要进入某个租户的上下文查看数据。这就是本章要讲的——模拟他人与临时身份切换。</p><hr><h2 id="8-1-操作指定账号的-API">8.1. 操作指定账号的 API</h2><p>Sa-Token 的大部分 <code>StpUtil</code> 方法默认操作的是&quot;当前请求携带的 Token 对应的账号&quot;。但很多方法也提供了带 <code>loginId</code> 参数的重载版本，允许直接操作任意指定账号，而不需要持有对方的 Token。</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><span class="line"><span class="comment">// 获取指定账号当前的 Token 值（账号有多个在线设备时返回最新一个）</span></span><br><span class="line">StpUtil.getTokenValueByLoginId(<span class="number">10001L</span>);</span><br><span class="line"></span><br><span class="line"><span class="comment">// 获取指定账号当前所有在线 Token 的列表</span></span><br><span class="line">StpUtil.getTokenValueListByLoginId(<span class="number">10001L</span>);</span><br><span class="line"></span><br><span class="line"><span class="comment">// 强制将指定账号的所有会话注销下线</span></span><br><span class="line">StpUtil.logout(<span class="number">10001L</span>);</span><br><span class="line"></span><br><span class="line"><span class="comment">// 获取指定账号的 Account-Session 对象，不存在则新建并返回</span></span><br><span class="line">StpUtil.getSessionByLoginId(<span class="number">10001L</span>);</span><br><span class="line"></span><br><span class="line"><span class="comment">// 获取指定账号的 Account-Session 对象，不存在则返回 null</span></span><br><span class="line">StpUtil.getSessionByLoginId(<span class="number">10001L</span>, <span class="literal">false</span>);</span><br></pre></td></tr></table></figure><p>这些方法在第五章的 <code>AdminController</code> 中已经用过——管理员踢人不需要持有被踢者的 Token，只需要知道 userId 即可。权限相关的跨账号查询将在第三篇引入，这里先建立&quot;可以直接操作任意账号&quot;的基本认知。</p><hr><h2 id="8-2-临时身份切换">8.2. 临时身份切换</h2><p>有时候，我们不是要&quot;操作&quot;某个账号，而是需要 <strong>在当前请求内，临时变成另一个账号</strong>。</p><p>典型场景：客服系统中，客服人员（userId=9001）已登录，但需要以用户（userId=10002）的视角调用一系列查询接口，看看该用户能看到什么、能操作什么——此时不可能让客服真的注销自己重新用用户账号登录，也不应该修改客服自己的 Token 记录。</p><p>Sa-Token 为此提供了 <code>switchTo()</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></pre></td><td class="code"><pre><span class="line"><span class="comment">// 将当前会话的身份临时切换为 userId=10044，本次请求内有效</span></span><br><span class="line">StpUtil.switchTo(<span class="number">10044L</span>);</span><br><span class="line"></span><br><span class="line"><span class="comment">// 此时调用 getLoginId()，返回的是切换后的 10044，而不是实际登录者</span></span><br><span class="line">StpUtil.getLoginId();   <span class="comment">// 返回 10044</span></span><br><span class="line"></span><br><span class="line"><span class="comment">// 查询是否正处于临时身份切换状态</span></span><br><span class="line">StpUtil.isSwitch();     <span class="comment">// 返回 true</span></span><br><span class="line"></span><br><span class="line"><span class="comment">// 结束临时身份切换，恢复为实际登录者的身份</span></span><br><span class="line">StpUtil.endSwitch();</span><br><span class="line"></span><br><span class="line"><span class="comment">// 切换结束后，getLoginId() 重新返回实际登录者的 id</span></span><br><span class="line">StpUtil.getLoginId();   <span class="comment">// 返回实际登录者的 id</span></span><br></pre></td></tr></table></figure><p><code>switchTo()</code> 只影响当前请求线程内对 <code>getLoginId()</code> 等方法的返回值，不会修改任何 Redis 数据，不会创建新的 Token，请求结束后自动失效。</p><hr><h2 id="8-3-Lambda-写法：无需手动关闭">8.3. Lambda 写法：无需手动关闭</h2><p>使用 <code>switchTo()</code> + <code>endSwitch()</code> 的写法有一个隐患：如果在切换期间代码抛出异常，<code>endSwitch()</code> 可能不会被执行，导致当前线程的身份状态残留。</p><p>Sa-Token 提供了 Lambda 版本，将切换逻辑包裹在一个闭包内，执行完成后（无论是否抛出异常）自动恢复：</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></pre></td><td class="code"><pre><span class="line">System.out.println(<span class="string">&quot;切换前 loginId：&quot;</span> + StpUtil.getLoginId());</span><br><span class="line"></span><br><span class="line">StpUtil.switchTo(<span class="number">10044L</span>, () -&gt; &#123;</span><br><span class="line">    <span class="comment">// 在这个代码块内，当前身份是 10044</span></span><br><span class="line">    System.out.println(<span class="string">&quot;切换中，isSwitch：&quot;</span> + StpUtil.isSwitch());  <span class="comment">// true</span></span><br><span class="line">    System.out.println(<span class="string">&quot;切换中，loginId：&quot;</span> + StpUtil.getLoginId()); <span class="comment">// 10044</span></span><br><span class="line">    <span class="comment">// 在这里执行需要以 10044 身份进行的操作</span></span><br><span class="line">&#125;);</span><br><span class="line"></span><br><span class="line"><span class="comment">// Lambda 执行完毕后，身份自动恢复，无需手动调用 endSwitch()</span></span><br><span class="line">System.out.println(<span class="string">&quot;切换后 loginId：&quot;</span> + StpUtil.getLoginId()); <span class="comment">// 恢复为实际登录者</span></span><br></pre></td></tr></table></figure><p>Lambda 写法不仅更安全，语义也更清晰——临时身份的作用范围被显式地限定在花括号内，代码阅读者一眼就能看出哪些操作是&quot;以别人的身份执行&quot;的。<strong>实际项目中推荐优先使用 Lambda 写法。</strong></p><hr><h2 id="8-4-一个重要的边界：切换身份-≠-获得对方权限">8.4. 一个重要的边界：切换身份 ≠ 获得对方权限</h2><p>临时身份切换容易让人产生一个误解：切换到 userId=10044 之后，是不是就拥有了 10044 的所有权限？</p><p>答案取决于你的权限数据是怎么查的。</p><p><code>switchTo()</code> 改变的只是 <code>StpUtil.getLoginId()</code> 的返回值。如果你的权限查询逻辑是基于 <code>getLoginId()</code> 去查数据库或缓存的（比如第三篇将要实现的 <code>StpInterface.getPermissionList()</code>），那么切换身份后，权限查询确实会基于 10044 的 userId，返回 10044 的权限数据。</p><p>但如果你的权限校验走的是注解（<code>@SaCheckPermission</code> 等），它同样会用切换后的 loginId 去查权限，结果也是 10044 的权限范围。</p><p>总结成一句话：<strong><code>switchTo()</code> 让框架认为&quot;你就是那个人&quot;，所有基于 loginId 的查询都会跟着切换；但它不会绕过任何已有的权限校验逻辑。</strong> 如果切换到的账号权限不足，权限校验照样会失败。</p><div class="note warning simple"><p>临时身份切换是高权限操作，调用方本身通常需要具备管理员级别的权限。实际项目中应在接口层对调用方做身份验证，确保普通用户无法随意切换到他人身份。</p></div><hr><h2 id="8-5-本章总结">8.5. 本章总结</h2><p><strong>本章回顾</strong></p><p>本章讲解了两类&quot;跨账号操作&quot;的能力。第一类是操作指定账号的 API——通过传入 loginId 参数，直接对任意账号的会话、Session 进行操作，无需持有对方的 Token，这类 API 在前几章的管理员踢人场景中已有实际应用。第二类是临时身份切换——<code>switchTo()</code> 在当前请求线程内将 <code>getLoginId()</code> 的返回值替换为目标账号，配合 Lambda 写法可以将切换作用域显式限定在代码块内，执行完成后自动恢复，避免状态残留。最后澄清了一个常见误区：身份切换改变的是 loginId 的返回值，所有基于 loginId 的权限查询会跟着切换，但不会绕过任何权限校验逻辑，被切换到的账号权限不足时校验照样失败。</p><p><strong>核心汇总表</strong></p><table><thead><tr><th>API</th><th>说明</th></tr></thead><tbody><tr><td><code>StpUtil.getTokenValueByLoginId(id)</code></td><td>获取指定账号最新的 Token 值</td></tr><tr><td><code>StpUtil.getTokenValueListByLoginId(id)</code></td><td>获取指定账号所有在线 Token 列表</td></tr><tr><td><code>StpUtil.logout(id)</code></td><td>强制注销指定账号所有会话</td></tr><tr><td><code>StpUtil.getSessionByLoginId(id)</code></td><td>获取指定账号的 Session，不存在则新建</td></tr><tr><td><code>StpUtil.getSessionByLoginId(id, false)</code></td><td>获取指定账号的 Session，不存在返回 null</td></tr></tbody></table><table><thead><tr><th>API</th><th>说明</th></tr></thead><tbody><tr><td><code>StpUtil.switchTo(id)</code></td><td>临时切换当前身份为指定 loginId，本次请求内有效</td></tr><tr><td><code>StpUtil.switchTo(id, () -&gt; &#123;...&#125;)</code></td><td>Lambda 写法，执行完毕后自动恢复，推荐使用</td></tr><tr><td><code>StpUtil.isSwitch()</code></td><td>查询当前是否处于临时身份切换状态</td></tr><tr><td><code>StpUtil.endSwitch()</code></td><td>手动结束临时身份切换（Lambda 写法无需调用）</td></tr></tbody></table><table><thead><tr><th>使用场景</th><th>推荐写法</th></tr></thead><tbody><tr><td>临时切换并执行一段逻辑</td><td><code>switchTo(id, () -&gt; &#123; ... &#125;)</code></td></tr><tr><td>需要精确控制切换时机</td><td><code>switchTo(id)</code> + <code>endSwitch()</code>（注意异常安全）</td></tr><tr><td>切换后是否拥有对方权限</td><td>取决于权限查询是否基于 <code>getLoginId()</code>，不自动绕过校验</td></tr></tbody></table></div>]]></content>
    
    
      
      
        
        
    <summary type="html">&lt;div id=&quot;postchat_postcontent&quot;&gt;&lt;h1&gt;第一章.</summary>
        
      
    
    
    
    <category term="后端技术" scheme="https://prorise666.site/categories/%E5%90%8E%E7%AB%AF%E6%8A%80%E6%9C%AF/"/>
    
    <category term="Java" scheme="https://prorise666.site/categories/%E5%90%8E%E7%AB%AF%E6%8A%80%E6%9C%AF/Java/"/>
    
    <category term="Spring系列" scheme="https://prorise666.site/categories/%E5%90%8E%E7%AB%AF%E6%8A%80%E6%9C%AF/Java/Spring%E7%B3%BB%E5%88%97/"/>
    
    <category term="登录注册系列" scheme="https://prorise666.site/categories/%E5%90%8E%E7%AB%AF%E6%8A%80%E6%9C%AF/Java/Spring%E7%B3%BB%E5%88%97/%E7%99%BB%E5%BD%95%E6%B3%A8%E5%86%8C%E7%B3%BB%E5%88%97/"/>
    
    <category term="Sa-Token" scheme="https://prorise666.site/categories/%E5%90%8E%E7%AB%AF%E6%8A%80%E6%9C%AF/Java/Spring%E7%B3%BB%E5%88%97/%E7%99%BB%E5%BD%95%E6%B3%A8%E5%86%8C%E7%B3%BB%E5%88%97/Sa-Token/"/>
    
    
    <category term="Spring生态篇" scheme="https://prorise666.site/tags/Spring%E7%94%9F%E6%80%81%E7%AF%87/"/>
    
    <category term="Sa-Token系列篇" scheme="https://prorise666.site/tags/Sa-Token%E7%B3%BB%E5%88%97%E7%AF%87/"/>
    
  </entry>
  
  <entry>
    <title>登录注册番外篇（一） - 2026 年 Sa-Token 快速入门之基础登录</title>
    <link href="https://prorise666.site/posts/51342.html"/>
    <id>https://prorise666.site/posts/51342.html</id>
    <published>2026-02-08T03:38:17.000Z</published>
    <updated>2026-03-12T09:18:16.955Z</updated>
    
    <content type="html"><![CDATA[<div id="postchat_postcontent"><hr><h1>第一章. 回头看：我们造了多少轮子</h1><p><strong>环境版本</strong></p><table><thead><tr><th>组件</th><th>版本</th></tr></thead><tbody><tr><td>JDK</td><td>17</td></tr><tr><td>Spring Boot</td><td>3.4.x</td></tr><tr><td>Sa-Token</td><td>1.44.0</td></tr><tr><td>Redis</td><td>7.x</td></tr><tr><td>Maven</td><td>3.9.x</td></tr></tbody></table><p><strong>阶段式学习路径</strong></p><p>本篇是番外篇系列的第一站。在基础篇中，我们花了六章的篇幅，从零手写了一套完整的认证内核——RSA 加密、JJWT 双令牌、Redis 黑名单、多设备管理。现在，我们暂停主线，换一个全新的视角：如果有一个框架，能用一行代码完成登录，我们还需要手写那么多东西吗？</p><p>若你对登录注册系列基础篇感兴趣，请跳转至：</p><div calss='anzhiyu-tag-link'><a class="tag-Link" target="_blank" href="/categories/%E5%90%8E%E7%AB%AF%E6%8A%80%E6%9C%AF/Java/Spring%E7%B3%BB%E5%88%97/%E7%99%BB%E5%BD%95%E6%B3%A8%E5%86%8C%E7%B3%BB%E5%88%97/">    <div class="tag-link-tips">引用站外地址</div>    <div class="tag-link-bottom">        <div class="tag-link-left" style="">          <i class="anzhiyufont anzhiyu-icon-link" style=""></i>        </div>        <div class="tag-link-right">            <div class="tag-link-title">登录注册基础篇</div>            <div class="tag-link-sitename"> 登录注册基础篇分类目录结构</div>        </div>        <i class="anzhiyufont anzhiyu-icon-angle-right"></i>    </div>    </a></div><p>本篇将带你认识 Sa-Token 框架，搭建全新的独立项目，并体验它的核心登录 API 与 Redis 集成能力。在正式认识 Sa-Token 之前，我们先做一件事——回头看看基础篇六章走下来，我们到底写了多少代码。这不是为了否定之前的努力，恰恰相反，正是因为我们亲手造过这些轮子，才能真正理解框架帮我们省掉了什么。</p><h2 id="1-1-基础篇的六大手写模块回顾">1.1. 基础篇的六大手写模块回顾</h2><p>让我们快速盘点一下基础篇的产出。六章内容，我们在 <code>auth</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><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><span class="line">auth/</span><br><span class="line">├── auth-common/          # 公共基础模块</span><br><span class="line">│   ├── Result.java                    # 统一响应封装</span><br><span class="line">│   └── SnowflakeIdGenerator.java      # 雪花算法 ID 生成器</span><br><span class="line">├── auth-core/            # 认证核心模块</span><br><span class="line">│   ├── RsaKeyManager.java             # RSA 密钥加载与管理</span><br><span class="line">│   ├── JwtUtil.java                   # JJWT 0.12 Token 生成与解析</span><br><span class="line">│   ├── JwtProperties.java             # JWT 配置属性绑定</span><br><span class="line">│   ├── RedisKeyConstants.java         # Redis Key 命名常量</span><br><span class="line">│   ├── TokenRedisManager.java         # Token 存储（ZSet 实现）</span><br><span class="line">│   ├── BlacklistRedisManager.java     # 黑名单管理（String + TTL）</span><br><span class="line">│   ├── AuthToken.java                 # 双令牌模型</span><br><span class="line">│   ├── DeviceInfo.java                # 设备信息模型</span><br><span class="line">│   ├── DeviceInfoExtractor.java       # 设备信息提取工具</span><br><span class="line">│   └── AuthService.java              # 认证 Facade 服务</span><br><span class="line">├── auth-web/             # Web 应用模块</span><br><span class="line">│   ├── AuthApplication.java           # 启动类</span><br><span class="line">│   └── AuthController.java            # 认证控制器</span><br><span class="line">└── resources/</span><br><span class="line">    ├── application.yml</span><br><span class="line">    └── certs/</span><br><span class="line">        ├── private_key.pem            # RSA 私钥</span><br><span class="line">        └── public_key.pem             # RSA 公钥</span><br></pre></td></tr></table></figure><p>14 个 Java 源文件，2 个密钥文件，1 个配置文件。这还只是一个&quot;登录注册&quot;功能——没有涉及权限校验，没有涉及角色管理，没有涉及路由拦截。</p><hr><h2 id="1-2-手写轮子的三大代价">1.2. 手写轮子的三大代价</h2><p>基础篇的每一行代码都有它的教学价值，但如果把这套代码直接搬进生产环境，我们会面临三个现实问题。</p><p><strong>代价一：维护成本远超预期</strong></p><p><code>JwtUtil</code> 需要处理 Token 过期、签名验证、Claims 解析等逻辑；<code>TokenRedisManager</code> 需要管理 ZSet 的 score 计算、过期清理、Pipeline 批量操作；<code>BlacklistRedisManager</code> 需要精确计算每个 Token 的剩余 TTL。这些工具类一旦出现 Bug，排查成本很高——因为没有社区帮你验证，所有边界情况都需要自己覆盖。</p><p><strong>代价二：安全性依赖个人经验</strong></p><p>我们手写的 RSA 密钥加载逻辑、Token 生成规则、黑名单过期策略，都是基于个人对安全的理解来实现的。但安全领域有一条铁律：不要自己发明加密方案。一个成熟的框架背后有数千个 Issue 和 PR 的打磨，这种安全性是个人项目很难达到的。</p><p><strong>代价三：功能扩展举步维艰</strong></p><p>现在产品经理提了一个需求：&quot;同一个账号在手机端和电脑端可以同时登录，但同一端只能有一个设备在线。&quot;要实现这个需求，我们需要修改 <code>TokenRedisManager</code> 的 ZSet 逻辑，修改 <code>AuthService</code> 的登录方法，修改 <code>DeviceInfoExtractor</code> 的设备识别规则，还要在 <code>AuthController</code> 中新增接口。一个需求牵动四个类，这就是手写轮子的扩展代价。</p><p>手写轮子的价值在于理解原理，但生产环境中，我们需要站在巨人的肩膀上。</p><hr><h1>第二章. 认识 Sa-Token：一行代码解决登录</h1><p>基础篇让我们深刻体会到了手写认证系统的复杂度。那么 Java 生态中，有没有一个框架能大幅降低这种复杂度，同时又不像 Spring Security 那样需要理解一整套过滤器链才能上手？答案是 Sa-Token。</p><h2 id="2-1-Sa-Token-是什么">2.1. Sa-Token 是什么</h2><a class="say" data-say="Sa-Token" onclick="window.playSayAudio(this)">Sa-Token<span class="say__tip">轻量级 Java 权限认证框架，由国内开发者 shengzhang_ 主导开发</span></a> 是 <a class="say" data-say="dromara" onclick="window.playSayAudio(this)">dromara<span class="say__tip">由国内知名开源开发者组成的开源社区</span></a> 开源社区下的一个项目，目前在 GitHub 上拥有超过 18,000 个 Star。它的官方定位是：<blockquote><p>一个轻量级 Java 权限认证框架，让鉴权变得简单、优雅。</p></blockquote><p>这句话的关键词是&quot;轻量级&quot;和&quot;简单&quot;。我们可以通过一个最直观的例子来感受——在 Sa-Token 中，完成用户登录只需要一行代码：</p><figure class="highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">// 参数是用户 ID，可以是 long、int、String 等任意类型</span></span><br><span class="line"><span class="comment">// Sa-Token 内部会统一转换为 String 进行存储</span></span><br><span class="line">StpUtil.login(<span class="number">10001</span>);</span><br></pre></td></tr></table></figure><p>没有 <code>JwtUtil</code>，没有 <code>RsaKeyManager</code>，没有 <code>TokenRedisManager</code>。一行代码，框架自动完成了 Token 生成、Session 创建、Cookie 写入（或响应头返回）等全部工作。</p><p>让我们看看 Sa-Token 的五大核心模块，对它的能力范围建立一个全局认知：</p><p><img src="https://prorise-blog.oss-cn-guangzhou.aliyuncs.com/cover/image-20260208211535983.png" alt="image-20260208211535983"></p><table><thead><tr><th>模块</th><th>解决的问题</th><th>本系列是否覆盖</th></tr></thead><tbody><tr><td>登录认证</td><td>用户登录、注销、Token 管理、多设备登录</td><td>✅ 本篇开始</td></tr><tr><td>权限认证</td><td>角色校验、权限码校验、注解鉴权、路由拦截</td><td>✅ 后续篇章</td></tr><tr><td>单点登录（SSO）</td><td>多系统共享登录态</td><td>❌ 不在本系列范围</td></tr><tr><td>OAuth2.0</td><td>第三方授权登录</td><td>❌ 进阶篇单独讲解</td></tr><tr><td>微服务网关鉴权</td><td>Gateway 层统一鉴权</td><td>❌ 不在本系列范围</td></tr></tbody></table><div class="note info simple"><p>Sa-Token 的设计哲学是&quot;一个方法解决一个问题&quot;，API 命名直白到几乎不需要查文档。</p></div><hr><h2 id="2-2-为什么选-Sa-Token-而不是-Spring-Security">2.2. 为什么选 Sa-Token 而不是 Spring Security</h2><p>你可能会想：Java 安全领域的&quot;正统&quot;不是 Spring Security 吗？为什么我们先讲 Sa-Token？这个问题很好，我们从三个维度来回答。</p><p><strong>学习曲线</strong></p><p>Spring Security 的核心是一条 <a class="say" data-say="FilterChain" onclick="window.playSayAudio(this)">过滤器链<span class="say__tip">由多个安全过滤器串联组成的请求处理链，每个请求都必须经过所有过滤器</span></a>。要理解它，你需要先搞清楚 <code>SecurityFilterChain</code>、<code>AuthenticationManager</code>、<code>AuthenticationProvider</code>、<code>UserDetailsService</code>、<code>SecurityContextHolder</code> 这一整套概念，然后才能写出第一个自定义登录逻辑。</p><p>Sa-Token 的学习路径则完全不同。它没有过滤器链的概念，所有操作都通过一个静态工具类 <code>StpUtil</code> 完成。你不需要理解任何底层架构，打开 API 文档，找到你需要的方法，直接调用就行。</p><p>官方文档链接请跳转至：<a href="https://sa-token.cc/doc.html#/">https://sa-token.cc/doc.html#/</a></p>    <div class="anzhiyu-chat-container" data-theme="blue">      <div class="anzhiyu-chat-title">学习曲线对比</div>      <div class="anzhiyu-chat-content"><div class="anzhiyu-chat-time">2026-01-01 10:00</div>      <div class="anzhiyu-chat-send">                <div class="anzhiyu-chat-avatar">          <div class="avatar-font">S</div>        </div>        <div class="anzhiyu-chat-quote">                    <div class="anzhiyu-chat-text"><p>老师，Spring Security 和 Sa-Token 上手难度差多少？</p></div>        </div>      </div>      <div class="anzhiyu-chat-receive">                <div class="anzhiyu-chat-avatar">          <div class="avatar-font">T</div>        </div>        <div class="anzhiyu-chat-quote">          <div class="anzhiyu-chat-nickname">teacher</div>          <div class="anzhiyu-chat-text"><p>打个比方，Spring Security 像是一辆手动挡赛车，性能强悍但你得先学会换挡。Sa-Token 像是一辆自动挡家用车，上车就能开。</p></div>        </div>      </div>      <div class="anzhiyu-chat-send">                <div class="anzhiyu-chat-avatar">          <div class="avatar-font">S</div>        </div>        <div class="anzhiyu-chat-quote">                    <div class="anzhiyu-chat-text"><p>那 Sa-Token 是不是功能比较弱？</p></div>        </div>      </div>      <div class="anzhiyu-chat-receive">                <div class="anzhiyu-chat-avatar">          <div class="avatar-font">T</div>        </div>        <div class="anzhiyu-chat-quote">          <div class="anzhiyu-chat-nickname">teacher</div>          <div class="anzhiyu-chat-text"><p>不是。它覆盖了登录认证、权限校验、多设备管理、踢人下线等绝大多数场景。只是在 OAuth2 和微服务网关这种重度企业场景下，Spring Security 的生态更成熟。</p></div>        </div>      </div></div>    </div><p><strong>代码量</strong></p><p>我们在基础篇中用了 14 个 Java 文件来实现登录认证。使用 Sa-Token 之后，同样的功能大约只需要 3~4 个文件。这不是因为 Sa-Token “偷工减料”，而是因为它把 Token 生成、Session 管理、Redis 存储这些通用逻辑全部内置了，我们只需要关注业务本身。</p><p><strong>适用场景</strong></p><p>Sa-Token 最适合的场景是：中小型项目的快速开发、前后端分离架构、需要快速原型验证的团队、不想被框架&quot;绑架&quot;的开发者（Sa-Token 的侵入性极低）。而 Spring Security 更适合：需要深度定制安全策略的企业级项目、已经深度使用 Spring 生态的团队、需要 OAuth2 Authorization Server 的场景。</p><p>本章介绍了 Sa-Token 的定位、五大能力模块，并从学习曲线、代码量、适用场景三个维度明确了它与 Spring Security 的差异化选择依据。</p><table><thead><tr><th>要点</th><th>何时使用</th><th>关键动作</th></tr></thead><tbody><tr><td>Sa-Token 能力边界</td><td>技术选型时确认覆盖范围</td><td>核对五大模块是否满足业务需求</td></tr><tr><td>vs Spring Security</td><td>团队评审框架选型时</td><td>用学习曲线 + 场景复杂度做决策</td></tr><tr><td><code>StpUtil.login(id)</code> 的本质</td><td>理解框架核心时</td><td>一行代码背后是 Token + Session + 存储三步</td></tr></tbody></table><hr><h2 id="2-3-本节总结">2.3. 本节总结</h2><p><strong>本节回顾</strong></p><p>本章建立了对 Sa-Token 的全局认知。我们了解了它归属于 dromara 开源社区，定位是轻量级 Java 权限认证框架，核心 API <code>StpUtil.login(id)</code> 只需一行便能完成基础篇需要多个类才能完成的登录流程。通过五大模块表，我们明确了本系列的覆盖边界：登录认证和权限认证是重点，SSO、OAuth2、微服务网关鉴权不在本系列范围内。通过与 Spring Security 的对比，我们明确了 Sa-Token 在中小项目快速开发场景下的优势，以及 Spring Security 在企业级深度定制场景下的不可替代性。</p><p><strong>核心汇总表</strong></p><table><thead><tr><th>对比维度</th><th>Sa-Token</th><th>Spring Security</th></tr></thead><tbody><tr><td>上手门槛</td><td>无需理解底层架构，API 直白</td><td>需要理解过滤器链和 Provider 体系</td></tr><tr><td>核心操作</td><td><code>StpUtil.login(id)</code> 等静态方法</td><td>实现 UserDetailsService 等接口</td></tr><tr><td>适合场景</td><td>中小项目、快速开发</td><td>企业级定制、OAuth2 授权服务器</td></tr><tr><td>侵入性</td><td>极低</td><td>较高，深度依赖 Spring 生态</td></tr></tbody></table><hr><h1>第三章. 环境搭建与项目初始化</h1><p>认识了 Sa-Token 的定位和能力之后，是时候动手了。本章我们将从零创建一个全新的独立项目，引入 Sa-Token 的核心依赖，并完成基础配置。和基础篇不同，这次我们不再使用多模块架构。Sa-Token 的一大优势就是轻量，我们用一个单模块项目就能承载所有功能，让你把注意力完全放在框架本身。</p><h2 id="3-1-新建-Maven-项目并引入依赖">3.1. 新建 Maven 项目并引入依赖</h2><p>我们创建一个名为 <code>auth-satoken</code> 的全新项目，与基础篇的 <code>auth</code> 项目完全独立。</p><p><strong>步骤 1：通过 IDEA 创建项目</strong></p><p>打开 IntelliJ IDEA，选择 <code>File → New → Project</code>，在左侧选择 <code>Spring Initializr</code>，填写以下信息：</p><ul><li><strong>Name</strong>：<code>auth-satoken</code></li><li><strong>Group</strong>：<code>com.example</code></li><li><strong>Artifact</strong>：<code>auth-satoken</code></li><li><strong>Type</strong>：Maven</li><li><strong>Language</strong>：Java</li><li><strong>JDK</strong>：17</li><li><strong>Java</strong>：17</li><li><strong>Packaging</strong>：Jar</li></ul><p>在依赖选择页面，勾选以下两项：</p><ul><li><code>Spring Web</code></li><li><code>Spring Data Redis</code></li></ul><p>点击 <code>Create</code> 完成创建。操作成功后，IDEA 会自动打开新项目窗口，底部状态栏显示 Maven 依赖正在下载，等待进度条消失即可。</p><div class="note info simple"><p>如果你更习惯手动创建，也可以访问 <a href="https://start.spring.io">https://start.spring.io</a> 生成项目骨架后导入 IDEA。</p></div><p><strong>步骤 2：确认项目结构</strong></p><p>创建完成后，项目的初始结构如下：</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></pre></td><td class="code"><pre><span class="line">auth-satoken/</span><br><span class="line">├── pom.xml</span><br><span class="line">└── src/</span><br><span class="line">    └── main/</span><br><span class="line">        ├── java/</span><br><span class="line">        │   └── com/example/authsatoken/</span><br><span class="line">        │       └── AuthSatokenApplication.java</span><br><span class="line">        └── resources/</span><br><span class="line">            └── application.yml</span><br></pre></td></tr></table></figure><p><strong>步骤 3：引入 Sa-Token 和 Hutool 依赖</strong></p><p>Spring Initializr 只生成了 <code>spring-boot-starter-web</code> 和 <code>spring-boot-starter-data-redis</code>，我们还需要手动追加 Sa-Token 相关依赖。打开 <code>pom.xml</code>，在 <code>&lt;dependencies&gt;</code> 节点中追加以下内容：</p><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><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">&lt;!-- Sa-Token 权限认证（SpringBoot3 专用 Starter） --&gt;</span></span><br><span class="line"><span class="tag">&lt;<span class="name">dependency</span>&gt;</span></span><br><span class="line">    <span class="tag">&lt;<span class="name">groupId</span>&gt;</span>cn.dev33<span class="tag">&lt;/<span class="name">groupId</span>&gt;</span></span><br><span class="line">    <span class="tag">&lt;<span class="name">artifactId</span>&gt;</span>sa-token-spring-boot3-starter<span class="tag">&lt;/<span class="name">artifactId</span>&gt;</span></span><br><span class="line">    <span class="tag">&lt;<span class="name">version</span>&gt;</span>1.44.0<span class="tag">&lt;/<span class="name">version</span>&gt;</span></span><br><span class="line"><span class="tag">&lt;/<span class="name">dependency</span>&gt;</span></span><br><span class="line"></span><br><span class="line"><span class="comment">&lt;!-- Sa-Token 整合 Redis（使用 Jackson 序列化） --&gt;</span></span><br><span class="line"><span class="tag">&lt;<span class="name">dependency</span>&gt;</span></span><br><span class="line">    <span class="tag">&lt;<span class="name">groupId</span>&gt;</span>cn.dev33<span class="tag">&lt;/<span class="name">groupId</span>&gt;</span></span><br><span class="line">    <span class="tag">&lt;<span class="name">artifactId</span>&gt;</span>sa-token-redis-jackson<span class="tag">&lt;/<span class="name">artifactId</span>&gt;</span></span><br><span class="line">    <span class="tag">&lt;<span class="name">version</span>&gt;</span>1.44.0<span class="tag">&lt;/<span class="name">version</span>&gt;</span></span><br><span class="line"><span class="tag">&lt;/<span class="name">dependency</span>&gt;</span></span><br><span class="line"></span><br><span class="line"><span class="comment">&lt;!-- Redis 连接池（sa-token-redis-jackson 的底层依赖） --&gt;</span></span><br><span class="line"><span class="tag">&lt;<span class="name">dependency</span>&gt;</span></span><br><span class="line">    <span class="tag">&lt;<span class="name">groupId</span>&gt;</span>org.apache.commons<span class="tag">&lt;/<span class="name">groupId</span>&gt;</span></span><br><span class="line">    <span class="tag">&lt;<span class="name">artifactId</span>&gt;</span>commons-pool2<span class="tag">&lt;/<span class="name">artifactId</span>&gt;</span></span><br><span class="line"><span class="tag">&lt;/<span class="name">dependency</span>&gt;</span></span><br><span class="line"></span><br><span class="line"><span class="comment">&lt;!-- Hutool 工具库（提供加密、JSON、字符串等常用工具） --&gt;</span></span><br><span class="line"><span class="tag">&lt;<span class="name">dependency</span>&gt;</span></span><br><span class="line">    <span class="tag">&lt;<span class="name">groupId</span>&gt;</span>cn.hutool<span class="tag">&lt;/<span class="name">groupId</span>&gt;</span></span><br><span class="line">    <span class="tag">&lt;<span class="name">artifactId</span>&gt;</span>hutool-all<span class="tag">&lt;/<span class="name">artifactId</span>&gt;</span></span><br><span class="line">    <span class="tag">&lt;<span class="name">version</span>&gt;</span>5.8.34<span class="tag">&lt;/<span class="name">version</span>&gt;</span></span><br><span class="line"><span class="tag">&lt;/<span class="name">dependency</span>&gt;</span></span><br></pre></td></tr></table></figure><p>这里有几个关键点需要说明。</p><p><code>sa-token-spring-boot3-starter</code> 是 Sa-Token 为 Spring Boot 3.x 提供的专用启动器。如果你使用的是 Spring Boot 2.x，需要将 <code>boot3</code> 改为 <code>boot</code>，即 <code>sa-token-spring-boot-starter</code>。两者的 API 完全一致，只是底层适配的 Servlet 版本不同。</p><p><code>sa-token-redis-jackson</code> 是 Sa-Token 的 Redis 集成插件。引入这个依赖后，Sa-Token 会通过 Spring Boot 的自动装配机制检测到 Redis 的存在，并将所有会话数据的存储层从默认的 JVM 内存切换到 Redis，不需要我们编写任何额外的配置类。它依赖 <code>commons-pool2</code> 作为 Redis 连接池，所以需要一并引入，否则启动时会报 <code>NoClassDefFoundError</code>。</p><p><code>hutool-all</code> 是 Hutool 的全量包，后续章节中我们会用到它的加密工具和 JSON 处理能力。</p><p>追加完成后，点击 IDEA 右侧的 Maven 面板，点击刷新图标（Reload All Maven Projects）。等待底部进度条走完，确认 <code>pom.xml</code> 中没有红色报错线，说明依赖下载成功。</p><hr><h2 id="3-2-配置-application-yml">3.2. 配置 application.yml</h2><p>依赖引入完成后，我们需要在 <code>application.yml</code> 中完成两件事：配置 Sa-Token 的核心参数，以及配置 Redis 连接信息。</p><p>📄 文件：<code>src/main/resources/application.yml</code>（修改）</p><figure class="highlight yaml"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><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><span class="line"><span class="attr">server:</span></span><br><span class="line">  <span class="attr">port:</span> <span class="number">8081</span>  <span class="comment"># 使用 8081 端口，避免与基础篇的 8080 冲突</span></span><br><span class="line"></span><br><span class="line"><span class="comment"># Sa-Token 核心配置</span></span><br><span class="line"><span class="attr">sa-token:</span></span><br><span class="line">  <span class="comment"># Token 名称（同时决定 Cookie 名称、请求头名称、URL 参数名称）</span></span><br><span class="line">  <span class="attr">token-name:</span> <span class="string">satoken</span></span><br><span class="line">  <span class="comment"># Token 有效期，单位：秒。-1 表示永不过期（生产环境不推荐）</span></span><br><span class="line">  <span class="attr">timeout:</span> <span class="number">2592000</span></span><br><span class="line">  <span class="comment"># Token 最低活跃频率，单位：秒。-1 表示不限制</span></span><br><span class="line">  <span class="attr">active-timeout:</span> <span class="number">-1</span></span><br><span class="line">  <span class="comment"># 是否允许同一账号多端同时登录</span></span><br><span class="line">  <span class="attr">is-concurrent:</span> <span class="literal">true</span></span><br><span class="line">  <span class="comment"># 在多端登录下，是否共用同一个 Token</span></span><br><span class="line">  <span class="attr">is-share:</span> <span class="literal">true</span></span><br><span class="line">  <span class="comment"># Token 风格：uuid / simple-uuid / random-32 / random-64 / random-128 / tik</span></span><br><span class="line">  <span class="attr">token-style:</span> <span class="string">uuid</span></span><br><span class="line">  <span class="comment"># 是否在启动时在控制台打印版本字符画</span></span><br><span class="line">  <span class="attr">is-print:</span> <span class="literal">true</span></span><br><span class="line"></span><br><span class="line"><span class="comment"># Redis 连接配置</span></span><br><span class="line"><span class="attr">spring:</span></span><br><span class="line">  <span class="attr">data:</span></span><br><span class="line">    <span class="attr">redis:</span></span><br><span class="line">      <span class="attr">host:</span> <span class="number">127.0</span><span class="number">.0</span><span class="number">.1</span></span><br><span class="line">      <span class="attr">port:</span> <span class="number">6379</span></span><br><span class="line">      <span class="attr">password:</span>        <span class="comment"># 留空表示无密码，生产环境务必设置密码</span></span><br><span class="line">      <span class="attr">database:</span> <span class="number">0</span>      <span class="comment"># 使用 0 号数据库</span></span><br></pre></td></tr></table></figure><table><thead><tr><th>要点</th><th>何时使用</th><th>关键动作</th></tr></thead><tbody><tr><td><code>sa-token-spring-boot3-starter</code></td><td>Spring Boot 3.x 项目引入 Sa-Token</td><td>注意 boot3 与 boot 的版本区分</td></tr><tr><td><code>sa-token-redis-jackson</code></td><td>需要将会话持久化到 Redis 时</td><td>引入后自动切换存储层，无需额外配置</td></tr><tr><td><code>is-concurrent</code> + <code>is-share</code> 组合</td><td>项目初始化时确定登录策略</td><td>根据业务选择，可在登录时被参数覆盖</td></tr></tbody></table><table><thead><tr><th>配置项</th><th>推荐值</th><th>控制内容</th></tr></thead><tbody><tr><td><code>token-name</code></td><td><code>satoken</code></td><td>Cookie 名 / Header 名 / URL 参数名</td></tr><tr><td><code>timeout</code></td><td><code>2592000</code>（30 天）</td><td>Token 最大存活时间</td></tr><tr><td><code>is-concurrent</code></td><td><code>true</code>（多端）/ <code>false</code>（单端）</td><td>是否允许同一账号多端同时在线</td></tr><tr><td><code>is-share</code></td><td>根据业务决定</td><td>多端登录时是否复用同一 Token</td></tr></tbody></table><hr><h1>第四章. 一行代码登录：StpUtil 与 SaResult 核心解析</h1><p>项目搭建完成，配置也就绪了。现在我们终于可以写代码了。在动手之前，有两个 Sa-Token 的核心类必须先认识清楚——我们接下来所有的接口都会用到它们，但如果不理解它们的设计，你只会&quot;照着抄&quot;而不是&quot;真的懂&quot;。</p><h2 id="4-1-StpUtil：Sa-Token-的操作中枢">4.1. StpUtil：Sa-Token 的操作中枢</h2><p><code>StpUtil</code> 是 Sa-Token 中所有操作的入口，你会发现本系列中几乎所有认证操作都从它开始。它是一个<strong>全静态方法</strong>的工具类，这意味着你不需要通过 <code>@Autowired</code> 注入就能直接调用，就像使用 <code>Math.random()</code> 一样。</p><p>但你可能会好奇：一个静态方法，是怎么知道&quot;当前这个请求的用户是谁&quot;的？答案是 Sa-Token 在框架层面拦截了每一个进入的 HTTP 请求，从请求中提取 Token（Cookie 或 Header），并将其存储在当前线程的 <a class="say" data-say="ThreadLocal" onclick="window.playSayAudio(this)">ThreadLocal<span class="say__tip">线程本地存储，每个线程独立持有一份数据副本，线程间互不干扰</span></a> 中。这样，当你在业务代码中调用 <code>StpUtil.getLoginId()</code> 时，它实际上是在从当前线程的 ThreadLocal 里取出 Token，再去存储层（Redis）反查用户 ID，整个过程对业务代码完全透明。</p><p><code>Stp</code> 是 <code>t</code>（token）+ <code>p</code>（permission）缩写演化而来的历史命名，你不需要深究它的含义，记住&quot;Sa-Token 的静态操作入口&quot;这个定位就够了。</p><hr><h2 id="4-2-SaResult：Sa-Token-内置的统一响应类">4.2. SaResult：Sa-Token 内置的统一响应类</h2><p>在写第一个接口之前，还有一个类需要先了解——<code>SaResult</code>，它是 Sa-Token 内置的统一 HTTP 响应封装类，结构和我们在基础篇手写的 <code>Result</code> 几乎完全一致。</p><p><code>SaResult</code> 有三个核心字段：</p><table><thead><tr><th>字段</th><th>类型</th><th>含义</th></tr></thead><tbody><tr><td><code>code</code></td><td><code>int</code></td><td>状态码，默认 200 表示成功，500 表示失败</td></tr><tr><td><code>msg</code></td><td><code>String</code></td><td>提示信息</td></tr><tr><td><code>data</code></td><td><code>Object</code></td><td>响应数据，可以是任意类型</td></tr></tbody></table><p>常用的静态工厂方法如下：</p><figure class="highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br></pre></td><td class="code"><pre><span class="line">SaResult.ok(<span class="string">&quot;操作成功&quot;</span>);              <span class="comment">// code=200, msg=&quot;操作成功&quot;, data=null</span></span><br><span class="line">SaResult.ok(<span class="string">&quot;操作成功&quot;</span>).setData(obj); <span class="comment">// code=200, msg=&quot;操作成功&quot;, data=obj</span></span><br><span class="line">SaResult.data(obj);                   <span class="comment">// code=200, msg=&quot;ok&quot;, data=obj</span></span><br><span class="line">SaResult.error(<span class="string">&quot;出错了&quot;</span>);             <span class="comment">// code=500, msg=&quot;出错了&quot;, data=null</span></span><br><span class="line">SaResult.error(<span class="string">&quot;出错了&quot;</span>).setCode(<span class="number">401</span>); <span class="comment">// code=401, msg=&quot;出错了&quot;, data=null</span></span><br></pre></td></tr></table></figure><p>值得注意的是，<code>setCode()</code>、<code>setMsg()</code>、<code>setData()</code> 都返回 <code>SaResult</code> 本身，支持链式调用。在实际项目中，你完全可以继续使用自己的 <code>Result</code> 类，<code>SaResult</code> 不是强制的——本篇为了减少额外的脚手架代码，直接使用它。</p><hr><h2 id="4-3-登录、注销与状态查询">4.3. 登录、注销与状态查询</h2><p>理解了 <code>StpUtil</code> 和 <code>SaResult</code> 的设计之后，我们来写第一个功能完整的控制器。</p><p>首先在 <code>com.example.authsatoken</code> 包下新建 <code>controller</code> 子包，然后创建 <code>LoginController.java</code>。</p><p>📄 文件：<code>src/main/java/com/example/authsatoken/controller/LoginController.java</code>（新建）</p><figure class="highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><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><span class="line"><span class="keyword">package</span> com.example.authsatoken.controller;</span><br><span class="line"></span><br><span class="line"><span class="keyword">import</span> cn.dev33.satoken.stp.StpUtil;</span><br><span class="line"><span class="keyword">import</span> cn.dev33.satoken.util.SaResult;</span><br><span class="line"><span class="keyword">import</span> org.springframework.web.bind.annotation.*;</span><br><span class="line"></span><br><span class="line"><span class="meta">@RestController</span></span><br><span class="line"><span class="meta">@RequestMapping(&quot;/auth&quot;)</span></span><br><span class="line"><span class="keyword">public</span> <span class="keyword">class</span> <span class="title class_">LoginController</span> &#123;</span><br><span class="line"></span><br><span class="line">    <span class="comment">/**</span></span><br><span class="line"><span class="comment">     * 登录接口</span></span><br><span class="line"><span class="comment">     * 实际项目中应查询数据库校验凭据，这里用硬编码模拟，仅聚焦 Sa-Token 本身的行为</span></span><br><span class="line"><span class="comment">     */</span></span><br><span class="line">    <span class="meta">@PostMapping(&quot;/login&quot;)</span></span><br><span class="line">    <span class="keyword">public</span> SaResult <span class="title function_">login</span><span class="params">(<span class="meta">@RequestParam</span> String username, <span class="meta">@RequestParam</span> String password)</span> &#123;</span><br><span class="line">        <span class="keyword">if</span> (<span class="string">&quot;admin&quot;</span>.equals(username) &amp;&amp; <span class="string">&quot;123456&quot;</span>.equals(password)) &#123;</span><br><span class="line">            <span class="comment">// 核心：指定用户 ID，Sa-Token 负责生成 Token、创建会话、写入响应</span></span><br><span class="line">            <span class="comment">// 任何可以唯一标识用户的值都可以作为参数，框架内部统一转为 String 存储</span></span><br><span class="line">            StpUtil.login(<span class="number">10001</span>);</span><br><span class="line">            <span class="comment">// 返回给前端的 Token 值，前端后续请求需携带此值</span></span><br><span class="line">            <span class="keyword">return</span> SaResult.ok(<span class="string">&quot;登录成功&quot;</span>).setData(StpUtil.getTokenValue());</span><br><span class="line">        &#125;</span><br><span class="line">        <span class="keyword">return</span> SaResult.error(<span class="string">&quot;用户名或密码错误&quot;</span>);</span><br><span class="line">    &#125;</span><br><span class="line"></span><br><span class="line">    <span class="comment">/**</span></span><br><span class="line"><span class="comment">     * 注销接口：注销当前请求携带的 Token，Redis 中对应的会话数据随即清除</span></span><br><span class="line"><span class="comment">     */</span></span><br><span class="line">    <span class="meta">@PostMapping(&quot;/logout&quot;)</span></span><br><span class="line">    <span class="keyword">public</span> SaResult <span class="title function_">logout</span><span class="params">()</span> &#123;</span><br><span class="line">        StpUtil.logout();</span><br><span class="line">        <span class="keyword">return</span> SaResult.ok(<span class="string">&quot;注销成功&quot;</span>);</span><br><span class="line">    &#125;</span><br><span class="line"></span><br><span class="line">    <span class="comment">/**</span></span><br><span class="line"><span class="comment">     * 查询当前请求携带的 Token 是否处于有效登录状态</span></span><br><span class="line"><span class="comment">     */</span></span><br><span class="line">    <span class="meta">@GetMapping(&quot;/isLogin&quot;)</span></span><br><span class="line">    <span class="keyword">public</span> SaResult <span class="title function_">isLogin</span><span class="params">()</span> &#123;</span><br><span class="line">        <span class="keyword">return</span> SaResult.ok(<span class="string">&quot;是否已登录：&quot;</span> + StpUtil.isLogin());</span><br><span class="line">    &#125;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>这段代码的核心是 <code>StpUtil.login(10001)</code> 这一行。当它被执行时，Sa-Token 在背后依次完成了：</p><p>根据配置的 <code>token-style</code> 生成一个 Token 字符串（本项目配置的是 UUID 格式），以用户 ID <code>10001</code> 为键在 Redis 中创建 Session 会话，建立 Token 与 Session 之间的映射关系，最后将 Token 写入响应（默认同时写 Cookie 和响应头，以适配不同前端场景）。</p><p>登录成功后，我们通过 <code>StpUtil.getTokenValue()</code> 取到刚刚生成的 Token 并放入响应体的 <code>data</code> 字段中返回给前端。前端拿到这个值后，后续所有需要身份认证的请求，都需要在 HTTP 请求头中携带：</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">satoken: 1db92b69-74ce-4c5f-a838-13c0f414d047</span><br></pre></td></tr></table></figure><p>其中 <code>satoken</code> 就是我们在 <code>application.yml</code> 中配置的 <code>token-name</code>。</p><p><strong>关于 Token 的两种传递方式</strong></p><p>Sa-Token 支持两种 Token 传递方式，对应不同的前端场景：</p><div class="tabs" id="token传递方式"><ul class="nav-tabs"><button type="button" class="tab  active" data-href="token传递方式-1">Cookie 模式（传统 Web）</button><button type="button" class="tab " data-href="token传递方式-2">Header 模式（前后端分离）</button></ul><div class="tab-contents"><div class="tab-item-content active" id="token传递方式-1"><p>浏览器在登录成功后自动保存 Cookie，后续请求自动携带，无需前端额外代码。适合传统 Web 页面（如 Thymeleaf 渲染的服务端页面）。Sa-Token 会在 <code>Set-Cookie</code> 响应头中自动写入 Token，浏览器的每次请求都会自动带上。</p></div><div class="tab-item-content" id="token传递方式-2"><p>登录成功后，前端从响应体的 <code>data</code> 字段取出 Token 并保存到 localStorage 或 Vuex。之后每次 Ajax 请求时，在请求头中手动携带：</p><figure class="highlight javascript"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">axios.<span class="property">defaults</span>.<span class="property">headers</span>.<span class="property">common</span>[<span class="string">&#x27;satoken&#x27;</span>] = <span class="variable language_">localStorage</span>.<span class="title function_">getItem</span>(<span class="string">&#x27;token&#x27;</span>);</span><br></pre></td></tr></table></figure><p>这是目前最主流的前后端分离方式，也是我们后续测试时的默认模式。</p></div></div><div class="tab-to-top"><button type="button" aria-label="scroll to top"><i class="anzhiyufont anzhiyu-icon-arrow-up"></i></button></div></div><hr><h2 id="4-4-Token-信息获取">4.4. Token 信息获取</h2><p>登录成功后，我们通常还需要获取当前用户的详细 Token 信息和登录 ID。Sa-Token 为此提供了一组简洁的 API。</p><p>在 <code>LoginController</code> 中，在 <code>isLogin()</code> 方法下方追加以下两个方法：</p><p>📄 文件：<code>src/main/java/com/example/authsatoken/controller/LoginController.java</code>（修改，追加方法）</p><figure class="highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><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><span class="line"><span class="comment">/**</span></span><br><span class="line"><span class="comment"> * 获取当前会话的 Token 完整信息，调试时非常有用</span></span><br><span class="line"><span class="comment"> */</span></span><br><span class="line"><span class="meta">@GetMapping(&quot;/tokenInfo&quot;)</span></span><br><span class="line"><span class="keyword">public</span> SaResult <span class="title function_">tokenInfo</span><span class="params">()</span> &#123;</span><br><span class="line">    <span class="keyword">return</span> SaResult.data(StpUtil.getTokenInfo());</span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="comment">/**</span></span><br><span class="line"><span class="comment"> * 获取当前登录用户的 ID</span></span><br><span class="line"><span class="comment"> * 若未登录会抛出 NotLoginException，如需&quot;安全获取&quot;请用 getLoginIdDefaultNull()</span></span><br><span class="line"><span class="comment"> */</span></span><br><span class="line"><span class="meta">@GetMapping(&quot;/loginId&quot;)</span></span><br><span class="line"><span class="keyword">public</span> SaResult <span class="title function_">loginId</span><span class="params">()</span> &#123;</span><br><span class="line">    <span class="keyword">return</span> SaResult.ok(<span class="string">&quot;当前登录用户 ID：&quot;</span> + StpUtil.getLoginId());</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p><code>StpUtil.getTokenInfo()</code> 返回一个 <code>SaTokenInfo</code> 对象，包含了当前会话的完整快照，以下是各字段的含义：</p><table><thead><tr><th>字段</th><th>类型</th><th>含义</th></tr></thead><tbody><tr><td><code>tokenName</code></td><td><code>String</code></td><td>Token 名称，即 <code>token-name</code> 配置值</td></tr><tr><td><code>tokenValue</code></td><td><code>String</code></td><td>当前 Token 的具体值</td></tr><tr><td><code>isLogin</code></td><td><code>boolean</code></td><td>是否处于登录状态</td></tr><tr><td><code>loginId</code></td><td><code>Object</code></td><td>登录时传入的用户 ID（以 String 形式存储）</td></tr><tr><td><code>loginType</code></td><td><code>String</code></td><td>登录类型，默认 <code>login</code></td></tr><tr><td><code>tokenTimeout</code></td><td><code>long</code></td><td>Token 剩余有效期（秒），-1 表示永不过期，-2 表示已过期</td></tr><tr><td><code>sessionTimeout</code></td><td><code>long</code></td><td>Session 剩余有效期（秒）</td></tr><tr><td><code>loginDeviceType</code></td><td><code>String</code></td><td>登录设备类型，未指定时默认 <code>DEF</code></td></tr></tbody></table><p><code>StpUtil.getLoginId()</code> 返回当前登录用户的 ID。有一个细节需要特别留意：无论登录时传入的是 <code>long</code> 还是 <code>int</code>，<code>getLoginId()</code> 都以 <code>Object</code> 类型返回，实际存储的是 <code>String &quot;10001&quot;</code> 而非 <code>Long 10001</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></pre></td><td class="code"><pre><span class="line"><span class="comment">// 返回 String 类型的 ID，是最常用的安全写法</span></span><br><span class="line">StpUtil.getLoginIdAsString();</span><br><span class="line"></span><br><span class="line"><span class="comment">// 返回 Long 类型，如果无法转换会抛异常</span></span><br><span class="line">StpUtil.getLoginIdAsLong();</span><br><span class="line"></span><br><span class="line"><span class="comment">// 未登录时返回 null，而不是抛出异常——适合&quot;可选校验&quot;场景</span></span><br><span class="line">StpUtil.getLoginIdDefaultNull();</span><br></pre></td></tr></table></figure><p>下面是 <code>StpUtil</code> 中与登录状态相关的常用 API 速查表：</p><table><thead><tr><th>方法</th><th>作用</th><th>未登录时的行为</th></tr></thead><tbody><tr><td><code>StpUtil.login(id)</code></td><td>执行登录，创建会话</td><td>—</td></tr><tr><td><code>StpUtil.logout()</code></td><td>注销当前会话</td><td>不报错，静默执行</td></tr><tr><td><code>StpUtil.isLogin()</code></td><td>判断是否已登录</td><td>返回 <code>false</code></td></tr><tr><td><code>StpUtil.getLoginId()</code></td><td>获取登录 ID（Object 类型）</td><td>抛出 <code>NotLoginException</code></td></tr><tr><td><code>StpUtil.getLoginIdAsString()</code></td><td>获取登录 ID（String 类型）</td><td>抛出 <code>NotLoginException</code></td></tr><tr><td><code>StpUtil.getLoginIdDefaultNull()</code></td><td>获取登录 ID（安全版）</td><td>返回 <code>null</code></td></tr><tr><td><code>StpUtil.getTokenValue()</code></td><td>获取当前 Token 值</td><td>返回 <code>null</code></td></tr><tr><td><code>StpUtil.getTokenInfo()</code></td><td>获取 Token 完整信息对象</td><td>返回对象，但 <code>isLogin=false</code></td></tr></tbody></table><hr><h2 id="4-5-启动项目并测试">4.5. 启动项目并测试</h2><p>代码写完了，让我们启动项目，亲手验证每个接口的行为。</p><p><strong>步骤 1：确认 Redis 已启动</strong></p><p>在终端中执行以下命令，确认 Redis 服务正在运行：</p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">redis-cli ping</span><br></pre></td></tr></table></figure><p>如果返回 <code>PONG</code>，说明 Redis 正常运行。如果提示连接失败，请先启动 Redis 服务，否则 Spring Boot 应用在启动阶段会因为无法连接 Redis 而直接报错退出。</p><p><strong>步骤 2：启动 Spring Boot 应用</strong></p><p>在 IDEA 中运行 <code>AuthSatokenApplication</code> 的 <code>main</code> 方法。观察控制台输出，如果看到以下字符画，说明 Sa-Token 已成功加载：</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></pre></td><td class="code"><pre><span class="line">____    ____    ______   ____    __  __   ____    _   __</span><br><span class="line">/ ___|  / _  |  |_    _| /_ _ \  |  |/ /  | ___|  | \ | |</span><br><span class="line">\___ \ | |_| |    | |   | |  | | |     /  | |___  |  \| |</span><br><span class="line">___) ||    _|    | |   | |__| | |    _\  | |___  | |\  |</span><br><span class="line">|____/ |_| |_|    |_|    \____/  |_| \_\ |_____| |_| \_|</span><br></pre></td></tr></table></figure><p>同时确认控制台没有红色报错信息，并且出现了 <code>Started AuthSatokenApplication in x.xx seconds</code> 的提示。</p><p><strong>步骤 3：测试登录接口</strong></p><p>使用 Postman（或 curl）发送 POST 请求：</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">POST http://localhost:8081/auth/login?username=admin&amp;password=123456</span><br></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></pre></td><td class="code"><pre><span class="line"><span class="punctuation">&#123;</span></span><br><span class="line">    <span class="attr">&quot;code&quot;</span><span class="punctuation">:</span> <span class="number">200</span><span class="punctuation">,</span></span><br><span class="line">    <span class="attr">&quot;msg&quot;</span><span class="punctuation">:</span> <span class="string">&quot;登录成功&quot;</span><span class="punctuation">,</span></span><br><span class="line">    <span class="attr">&quot;data&quot;</span><span class="punctuation">:</span> <span class="string">&quot;1db92b69-74ce-4c5f-a838-13c0f414d047&quot;</span></span><br><span class="line"><span class="punctuation">&#125;</span></span><br></pre></td></tr></table></figure><p><code>data</code> 字段返回的就是 Token 值，每次登录生成的值都不同（因为是 UUID）。请将这个 Token 值复制备用，后续带认证的请求都需要它。</p><p><strong>步骤 4：测试登录状态查询</strong></p><p>在 Postman 的请求 Header 中添加：</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">satoken: 1db92b69-74ce-4c5f-a838-13c0f414d047  （替换为你实际的 Token）</span><br></pre></td></tr></table></figure><p>然后访问：</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">GET http://localhost:8081/auth/isLogin</span><br></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></pre></td><td class="code"><pre><span class="line"><span class="punctuation">&#123;</span></span><br><span class="line">    <span class="attr">&quot;code&quot;</span><span class="punctuation">:</span> <span class="number">200</span><span class="punctuation">,</span></span><br><span class="line">    <span class="attr">&quot;msg&quot;</span><span class="punctuation">:</span> <span class="string">&quot;是否已登录：true&quot;</span><span class="punctuation">,</span></span><br><span class="line">    <span class="attr">&quot;data&quot;</span><span class="punctuation">:</span> <span class="literal"><span class="keyword">null</span></span></span><br><span class="line"><span class="punctuation">&#125;</span></span><br></pre></td></tr></table></figure><p><strong>步骤 5：测试获取 Token 完整信息</strong></p><p>同样携带 Token Header，访问：</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">GET http://localhost:8081/auth/tokenInfo</span><br></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><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><span class="line"><span class="punctuation">&#123;</span></span><br><span class="line">    <span class="attr">&quot;code&quot;</span><span class="punctuation">:</span> <span class="number">200</span><span class="punctuation">,</span></span><br><span class="line">    <span class="attr">&quot;msg&quot;</span><span class="punctuation">:</span> <span class="string">&quot;ok&quot;</span><span class="punctuation">,</span></span><br><span class="line">    <span class="attr">&quot;data&quot;</span><span class="punctuation">:</span> <span class="punctuation">&#123;</span></span><br><span class="line">        <span class="attr">&quot;tokenName&quot;</span><span class="punctuation">:</span> <span class="string">&quot;satoken&quot;</span><span class="punctuation">,</span></span><br><span class="line">        <span class="attr">&quot;tokenValue&quot;</span><span class="punctuation">:</span> <span class="string">&quot;1db92b69-74ce-4c5f-a838-13c0f414d047&quot;</span><span class="punctuation">,</span></span><br><span class="line">        <span class="attr">&quot;isLogin&quot;</span><span class="punctuation">:</span> <span class="literal"><span class="keyword">true</span></span><span class="punctuation">,</span></span><br><span class="line">        <span class="attr">&quot;loginId&quot;</span><span class="punctuation">:</span> <span class="string">&quot;10001&quot;</span><span class="punctuation">,</span></span><br><span class="line">        <span class="attr">&quot;loginType&quot;</span><span class="punctuation">:</span> <span class="string">&quot;login&quot;</span><span class="punctuation">,</span></span><br><span class="line">        <span class="attr">&quot;tokenTimeout&quot;</span><span class="punctuation">:</span> <span class="number">2591874</span><span class="punctuation">,</span></span><br><span class="line">        <span class="attr">&quot;sessionTimeout&quot;</span><span class="punctuation">:</span> <span class="number">2591874</span><span class="punctuation">,</span></span><br><span class="line">        <span class="attr">&quot;tokenSessionTimeout&quot;</span><span class="punctuation">:</span> <span class="number">-2</span><span class="punctuation">,</span></span><br><span class="line">        <span class="attr">&quot;tokenActiveTimeout&quot;</span><span class="punctuation">:</span> <span class="number">-1</span><span class="punctuation">,</span></span><br><span class="line">        <span class="attr">&quot;loginDeviceType&quot;</span><span class="punctuation">:</span> <span class="string">&quot;DEF&quot;</span><span class="punctuation">,</span></span><br><span class="line">        <span class="attr">&quot;tag&quot;</span><span class="punctuation">:</span> <span class="literal"><span class="keyword">null</span></span></span><br><span class="line">    <span class="punctuation">&#125;</span></span><br><span class="line"><span class="punctuation">&#125;</span></span><br></pre></td></tr></table></figure><p>注意 <code>loginId</code> 字段的值是字符串 <code>&quot;10001&quot;</code> 而非数字 <code>10001</code>，这印证了前面说的&quot;Sa-Token 内部统一以 String 存储用户 ID&quot;。<code>loginDeviceType</code> 显示为 <code>DEF</code>，这是因为我们调用 <code>StpUtil.login(10001)</code> 时没有指定设备类型，框架使用了默认值。后续章节中我们会学习如何指定设备类型。</p><p><strong>步骤 6：测试注销接口</strong></p><p>携带 Token Header，访问：</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">POST http://localhost:8081/auth/logout</span><br></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></pre></td><td class="code"><pre><span class="line"><span class="punctuation">&#123;</span></span><br><span class="line">    <span class="attr">&quot;code&quot;</span><span class="punctuation">:</span> <span class="number">200</span><span class="punctuation">,</span></span><br><span class="line">    <span class="attr">&quot;msg&quot;</span><span class="punctuation">:</span> <span class="string">&quot;注销成功&quot;</span><span class="punctuation">,</span></span><br><span class="line">    <span class="attr">&quot;data&quot;</span><span class="punctuation">:</span> <span class="literal"><span class="keyword">null</span></span></span><br><span class="line"><span class="punctuation">&#125;</span></span><br></pre></td></tr></table></figure><p>注销后，再次访问 <code>GET /auth/isLogin</code>，预期返回 <code>是否已登录：false</code>。这说明注销操作成功将 Redis 中对应的 Token 和 Session 数据清除了。我们将在第五章中通过直接查看 Redis，来验证这个清除过程。</p><table><thead><tr><th>要点</th><th>何时使用</th><th>关键动作</th></tr></thead><tbody><tr><td><code>StpUtil.login(id)</code></td><td>用户身份校验通过后</td><td>传入用户 ID，一行完成登录</td></tr><tr><td><code>StpUtil.getTokenValue()</code></td><td>登录后将 Token 返回给前端时</td><td>放入响应体 data 字段</td></tr><tr><td><code>StpUtil.getLoginIdDefaultNull()</code></td><td>不确定当前是否已登录时</td><td>安全获取 ID，避免 NotLoginException</td></tr></tbody></table><hr><h2 id="4-6-本节总结">4.6. 本节总结</h2><p><strong>本节回顾</strong></p><p>本章完成了从零到第一个可运行认证接口的全过程。我们首先深入理解了 <code>StpUtil</code> 的设计本质——全静态工具类，依托框架的 ThreadLocal 机制感知当前请求的 Token，无需注入即可在任何位置调用。随后详细拆解了 <code>SaResult</code> 的三字段结构（code / msg / data）和五个常用工厂方法，为后续所有接口的响应格式打下基础。在 <code>LoginController</code> 的实现中，我们清楚解释了登录时 Sa-Token 背后的四步动作（生成 Token → 创建 Session → 建立映射 → 写入响应），以及 Token 在前后端分离场景下的 Header 传递方式。<code>SaTokenInfo</code> 的各字段含义、<code>getLoginId()</code> 的类型陷阱与安全替代方法也在本章一并覆盖。最后，六步测试流程验证了全部接口行为，并为第五章的 Redis 观察实验做好了铺垫。</p><p><strong>核心汇总表</strong></p><table><thead><tr><th>类</th><th>职责</th><th>关键点</th></tr></thead><tbody><tr><td><code>StpUtil</code></td><td>Sa-Token 全部操作的静态入口</td><td>通过 ThreadLocal 感知当前请求，无需注入</td></tr><tr><td><code>SaResult</code></td><td>统一 HTTP 响应封装</td><td>code/msg/data，支持链式 setXxx()</td></tr><tr><td><code>SaTokenInfo</code></td><td>Token 会话的完整快照</td><td><code>loginId</code> 以 String 存储，注意类型陷阱</td></tr></tbody></table><table><thead><tr><th>方法</th><th>适用场景</th><th>未登录时行为</th></tr></thead><tbody><tr><td><code>getLoginId()</code></td><td>明确已登录的请求链路</td><td>抛出 NotLoginException</td></tr><tr><td><code>getLoginIdAsString()</code></td><td>需要 String 类型时</td><td>抛出 NotLoginException</td></tr><tr><td><code>getLoginIdDefaultNull()</code></td><td>不确定登录状态时</td><td>返回 null</td></tr></tbody></table><p>这个问题问得非常好，你遇到的现象其实是 <strong>Cookie 机制在背后默默工作</strong>，不是 Sa-Token “智能识别了你这个人”。让我解释清楚整个过程。</p><hr><p><strong>Sa-Token 是怎么在没有手动带 Header 的情况下认出你的？</strong></p><p>第一步：登录时 Sa-Token 同时做了两件事</p><p>当你调用 <code>/auth/login</code> 登录成功后，Sa-Token 会把 Token 写到两个地方：</p><p><strong>第一个地方</strong>：HTTP 响应体的 <code>data</code> 字段——也就是我们代码里 <code>.setData(StpUtil.getTokenValue())</code> 返回的那个值，这是给前端手动保存用的。</p><p><strong>第二个地方</strong>：HTTP 响应头的 <code>Set-Cookie</code> 字段——Sa-Token 默认会同时写一个 Cookie，名称就是我们配置的 <code>token-name: satoken</code>，值就是 Token 字符串。</p><p>你可以在 Apifox 登录接口的响应里找到这个响应头：</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">Set-Cookie: satoken=1db92b69-74ce-4c5f-a838-13c0f414d047; Path=/; HttpOnly</span><br></pre></td></tr></table></figure><p>第二步：浏览器/Apifox 自动带上了 Cookie</p><p>Apifox 内置了一个 Cookie 管理器，行为和浏览器完全一样——收到 <code>Set-Cookie</code> 响应头之后，自动把这个 Cookie 存起来。下次向同一个域名（<code>localhost:8081</code>）发请求时，自动在请求头里附上：</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">Cookie: satoken=1db92b69-74ce-4c5f-a838-13c0f414d047</span><br></pre></td></tr></table></figure><p>这一切都是 Apifox 在背后自动完成的，你根本看不到，也不需要手动操作。所以你&quot;去掉 satoken 请求头&quot;之后，Cookie 里的 Token 还在，Sa-Token 从 Cookie 里读到了，照样认出你。</p><p>第三步：Sa-Token 的 Token 读取优先级</p><p>Sa-Token 在每次请求中读取 Token 时，会按以下顺序依次尝试：</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></pre></td><td class="code"><pre><span class="line">1. 请求头 Header（key = token-name，即 satoken）</span><br><span class="line">2. Cookie（name = token-name，即 satoken）</span><br><span class="line">3. URL 参数（?satoken=xxx）</span><br></pre></td></tr></table></figure><p>所以这两种模式分别对应什么场景？</p><table><thead><tr><th>模式</th><th>Token 存哪里</th><th>谁来携带</th><th>适合场景</th></tr></thead><tbody><tr><td>Cookie 模式</td><td>浏览器/客户端自动管理</td><td>自动附加，无需代码</td><td>传统 Web、服务端渲染页面</td></tr><tr><td>Header 模式</td><td>前端手动存（localStorage 等）</td><td>每次请求手动写入 Header</td><td>前后端分离、App、小程序</td></tr></tbody></table><p>Sa-Token 默认<strong>两种模式同时支持</strong>，不需要你选择，框架都会尝试读取。这就是为什么你在 Apifox 里测试时&quot;去掉 Header 也能过&quot;——因为 Cookie 通道还开着。</p><p>真实的前后端分离项目里，前端（Vue/React）用 <code>axios</code> 发请求时通常不会自动带 Cookie（跨域情况下需要额外配置），所以实际上走的是 Header 模式。Apifox 作为测试工具，默认行为更接近浏览器，所以 Cookie 模式会悄悄生效。</p><p>明白这个机制之后，后续测试时如果想模拟&quot;纯 Header 模式&quot;，记得在 Apifox 里关掉自动 Cookie 管理就行了。</p><p>我已经仔细阅读了 Sa-Token 官方文档关于 Session 会话的全部内容，以及你现有笔记的第四章结尾。现在我来重写第五章。</p><p>第五章的核心主题应该是：<strong>Sa-Token 的三种 Session 类型</strong>（Account-Session、Token-Session、Custom-Session），以及如何在 Session 上存取数据。Redis 作为观察手段穿插其中，而不是主角。内容要无缝衔接第四章&quot;第六步测试注销后返回 false&quot;这个结尾。</p><hr><h1>第五章. 会话的三张面孔：Session 机制全解析</h1><p>在第四章中，我们完成了第一个可运行的认证接口，并通过六步测试流程验证了登录、状态查询、注销的完整生命周期。测试的最后一步，我们发现注销后再调用 <code>/auth/isLogin</code>，返回值变成了 <code>false</code>——会话数据消失了。</p><p>但这里有一个问题值得深究：Sa-Token 所说的&quot;会话&quot;，究竟是什么？Token 是会话吗？Session 是会话吗？它们是同一个东西吗？如果不搞清楚这个问题，后续章节中你会遇到一堆行为&quot;符合预期但说不清为什么&quot;的 API，这会成为你日后排查问题的最大障碍。</p><p>本章我们就把这件事彻底搞清楚。</p><h2 id="5-1-Token-和-Session-不是同一回事">5.1. Token 和 Session 不是同一回事</h2><p>大多数初次接触 Sa-Token 的同学，会默认把 Token 和 Session 理解成&quot;同一个东西的两种叫法&quot;。这是一个需要在第一时间纠正的误解。</p><p>让我们用一个生活中的场景来建立直觉：你去图书馆借书，馆员给了你一张 <strong>借书证</strong>（Token）。这张借书证本身只是一张卡，上面印着你的编号，馆员通过它能查到 <strong>你的档案袋</strong>（Session）。档案袋里装着你的个人信息、当前借阅记录、历史违规记录等等。借书证是凭证，档案袋是数据载体，两者职责完全不同。</p><p>回到 Sa-Token：</p><ul><li><strong>Token</strong> 是一串字符串，是客户端（浏览器、App）持有的凭证，每次请求时附带在 Header 或 Cookie 中，框架通过它识别&quot;这是哪个用户发来的请求&quot;。</li><li><strong>Session</strong> 是框架在服务端（Redis）维护的数据容器，可以在里面存储任意键值对，开发者可以把需要在多个请求间共享的数据放在这里。</li></ul><p>一个用户可以有多个 Token（多设备登录），但这些 Token 最终都指向同一个用户的 Session。这就是两者的核心关系。</p><div class="note info simple"><p>Token 是客户端的凭证，Session 是服务端的数据容器，两者通过用户 ID 关联，而不是一一对应。</p></div><p>理解了这个基础区别之后，我们来看 Sa-Token 更进一步的设计——它把 Session 分成了三种，分别对应三种不同的使用场景。</p><hr><h2 id="5-2-三种-Session-的设计哲学">5.2. 三种 Session 的设计哲学</h2><p>Sa-Token 的三种 Session 分别是 Account-Session、Token-Session 和 Custom-Session。它们不是功能上的重复，而是从三个不同的维度出发，解决三种不同的数据归属问题。</p><p>在动手写代码之前，我们先用一张表格建立全局认知：</p><table><thead><tr><th>Session 类型</th><th>归属维度</th><th>生命周期</th><th>典型用途</th></tr></thead><tbody><tr><td>Account-Session</td><td>账号维度（一个用户唯一一个）</td><td>与用户的登录状态同步</td><td>缓存用户基本信息、权限列表等账号级数据</td></tr><tr><td>Token-Session</td><td>Token 维度（每个 Token 独立一个）</td><td>与该 Token 的有效期同步</td><td>缓存与特定终端相关的数据，如当前终端的操作记录</td></tr><tr><td>Custom-Session</td><td>任意自定义维度</td><td>由开发者手动控制</td><td>为业务实体挂载临时数据，如商品、订单的缓存</td></tr></tbody></table><p>接下来我们逐一深入每种 Session 的细节。</p><hr><h2 id="5-3-Account-Session：账号维度的数据容器">5.3. Account-Session：账号维度的数据容器</h2><p>Account-Session 是三种 Session 中最常用的一种。它的核心特点只有一句话：<strong>整个账号共享一个 Session，无论该账号从几台设备登录</strong>。</p><p>这意味着什么？用户 A 同时用手机和电脑登录了你的系统，产生了两个不同的 Token，但这两个 Token 背后共享同一个 Account-Session。你在手机端的 Account-Session 里存入的数据，在电脑端同样可以读取到。</p><h3 id="5-3-1-核心-API-与使用方式">5.3.1. 核心 API 与使用方式</h3><p>获取 Account-Session 有几种方式，分别对应不同的调用场景：</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><span class="line"><span class="comment">// 获取当前登录账号的 Account-Session（必须在已登录状态下调用）</span></span><br><span class="line"><span class="comment">// 内部等价于：StpUtil.getSessionByLoginId(StpUtil.getLoginId())</span></span><br><span class="line">StpUtil.getSession();</span><br><span class="line"></span><br><span class="line"><span class="comment">// 获取当前账号的 Account-Session，并决定 Session 不存在时是否自动创建</span></span><br><span class="line"><span class="comment">// true：不存在则新建并返回（默认行为）</span></span><br><span class="line"><span class="comment">// false：不存在则返回 null</span></span><br><span class="line">StpUtil.getSession(<span class="literal">true</span>);</span><br><span class="line"></span><br><span class="line"><span class="comment">// 直接指定用户 ID 获取其 Account-Session（可在任意业务逻辑中使用，不限于当前请求）</span></span><br><span class="line">StpUtil.getSessionByLoginId(<span class="number">10001</span>);</span><br><span class="line"></span><br><span class="line"><span class="comment">// 指定 ID 获取，同时控制不存在时的行为</span></span><br><span class="line">StpUtil.getSessionByLoginId(<span class="number">10001</span>, <span class="literal">false</span>);</span><br><span class="line"></span><br><span class="line"><span class="comment">// 通过 SessionId 获取（SessionId 就是 &quot;satoken:login:session:&#123;userId&#125;&quot;）</span></span><br><span class="line"><span class="comment">// Session 不存在时返回 null，不会自动创建</span></span><br><span class="line">StpUtil.getSessionBySessionId(<span class="string">&quot;satoken:login:session:10001&quot;</span>);</span><br></pre></td></tr></table></figure><p>这些方法返回的都是 <code>SaSession</code> 对象，拿到之后你就可以在上面自由地存取任意键值对。</p><h3 id="5-3-2-在-Account-Session-上存取数据">5.3.2. 在 Account-Session 上存取数据</h3><p>我们来写一个具体的场景：用户登录成功后，将用户对象缓存到 Account-Session 中，后续任何接口都可以直接从 Session 中取出，而不需要每次都查数据库。</p><p>首先在 <code>controller</code> 包下新建 <code>SessionDemoController.java</code>，专门用来演示三种 Session 的操作：</p><p>📄 文件：<code>src/main/java/com/example/authsatoken/controller/SessionDemoController.java</code>（新建）</p><figure class="highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br><span class="line">32</span><br><span class="line">33</span><br><span class="line">34</span><br><span class="line">35</span><br><span class="line">36</span><br><span class="line">37</span><br><span class="line">38</span><br><span class="line">39</span><br><span class="line">40</span><br><span class="line">41</span><br><span class="line">42</span><br><span class="line">43</span><br><span class="line">44</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">package</span> com.example.authsatoken.controller;</span><br><span class="line"></span><br><span class="line"><span class="keyword">import</span> cn.dev33.satoken.session.SaSession;</span><br><span class="line"><span class="keyword">import</span> cn.dev33.satoken.stp.StpUtil;</span><br><span class="line"><span class="keyword">import</span> cn.dev33.satoken.util.SaResult;</span><br><span class="line"><span class="keyword">import</span> org.springframework.web.bind.annotation.*;</span><br><span class="line"></span><br><span class="line"><span class="meta">@RestController</span></span><br><span class="line"><span class="meta">@RequestMapping(&quot;/session&quot;)</span></span><br><span class="line"><span class="keyword">public</span> <span class="keyword">class</span> <span class="title class_">SessionDemoController</span> &#123;</span><br><span class="line"><span class="comment">/**</span></span><br><span class="line"><span class="comment"> * 演示向 Account-Session 写入数据</span></span><br><span class="line"><span class="comment"> * 场景：模拟登录后将用户昵称缓存到 Account-Session</span></span><br><span class="line"><span class="comment"> */</span></span><br><span class="line"><span class="meta">@PostMapping(&quot;/account/set&quot;)</span></span><br><span class="line"><span class="keyword">public</span> SaResult <span class="title function_">setAccountSession</span><span class="params">()</span> &#123;</span><br><span class="line">StpUtil.getSession()</span><br><span class="line">.set(<span class="string">&quot;nickname&quot;</span>, <span class="string">&quot;张三&quot;</span>)</span><br><span class="line">.set(<span class="string">&quot;role&quot;</span>, <span class="string">&quot;admin&quot;</span>)</span><br><span class="line">.set(<span class="string">&quot;loginCount&quot;</span>, <span class="number">1</span>);</span><br><span class="line"><span class="keyword">return</span> SaResult.ok(<span class="string">&quot;已向 Account-Session 写入数据&quot;</span>);</span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="comment">/**</span></span><br><span class="line"><span class="comment"> * 演示从 Account-Session 读取数据</span></span><br><span class="line"><span class="comment"> * 无论当前请求携带的是哪个设备的 Token，读取的都是同一份数据</span></span><br><span class="line"><span class="comment"> */</span></span><br><span class="line"><span class="meta">@GetMapping(&quot;/account/get&quot;)</span></span><br><span class="line"><span class="keyword">public</span> SaResult <span class="title function_">accountGet</span><span class="params">()</span> &#123;</span><br><span class="line"><span class="type">SaSession</span> <span class="variable">session</span> <span class="operator">=</span> StpUtil.getSession();</span><br><span class="line"><span class="comment">// 基础取值：返回 Object 类型</span></span><br><span class="line"><span class="type">Object</span> <span class="variable">nickname</span> <span class="operator">=</span> session.get(<span class="string">&quot;nickname&quot;</span>);</span><br><span class="line"><span class="comment">// 带类型转换的取值</span></span><br><span class="line"><span class="type">String</span> <span class="variable">role</span> <span class="operator">=</span> session.getString(<span class="string">&quot;role&quot;</span>);</span><br><span class="line"><span class="type">int</span> <span class="variable">loginCount</span> <span class="operator">=</span> session.getInt(<span class="string">&quot;loginCount&quot;</span>);</span><br><span class="line"><span class="comment">// 带默认值的取值：若 key 不存在，返回指定的默认值而不是 null</span></span><br><span class="line"><span class="type">String</span> <span class="variable">email</span> <span class="operator">=</span> session.get(<span class="string">&quot;email&quot;</span>, <span class="string">&quot;暂未设置&quot;</span>);</span><br><span class="line"><span class="keyword">return</span> SaResult.ok(<span class="string">&quot;读取成功&quot;</span>).setData(</span><br><span class="line"><span class="string">&quot;昵称=&quot;</span> + nickname + <span class="string">&quot;, 角色=&quot;</span> + role +</span><br><span class="line"><span class="string">&quot;, 登录次数=&quot;</span> + loginCount + <span class="string">&quot;, 邮箱=&quot;</span> + email</span><br><span class="line">);</span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p><strong>步骤 1：</strong> 确保已登录（参考第四章，调用 <code>/auth/login</code> 获取 Token），然后携带 Token 请求：</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">POST http://localhost:8081/session/account/set</span><br></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></pre></td><td class="code"><pre><span class="line"><span class="punctuation">&#123;</span></span><br><span class="line">    <span class="attr">&quot;code&quot;</span><span class="punctuation">:</span> <span class="number">200</span><span class="punctuation">,</span></span><br><span class="line">    <span class="attr">&quot;msg&quot;</span><span class="punctuation">:</span> <span class="string">&quot;已向 Account-Session 写入数据&quot;</span><span class="punctuation">,</span></span><br><span class="line">    <span class="attr">&quot;data&quot;</span><span class="punctuation">:</span> <span class="literal"><span class="keyword">null</span></span></span><br><span class="line"><span class="punctuation">&#125;</span></span><br></pre></td></tr></table></figure><p><strong>步骤 2：</strong> 随后请求读取接口：</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">GET http://localhost:8081/session/account/get</span><br></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></pre></td><td class="code"><pre><span class="line"><span class="punctuation">&#123;</span></span><br><span class="line">    <span class="attr">&quot;code&quot;</span><span class="punctuation">:</span> <span class="number">200</span><span class="punctuation">,</span></span><br><span class="line">    <span class="attr">&quot;msg&quot;</span><span class="punctuation">:</span> <span class="string">&quot;读取成功&quot;</span><span class="punctuation">,</span></span><br><span class="line">    <span class="attr">&quot;data&quot;</span><span class="punctuation">:</span> <span class="string">&quot;昵称=管理员小王, 角色=admin, 登录次数=1, 邮箱=暂未设置&quot;</span></span><br><span class="line"><span class="punctuation">&#125;</span></span><br></pre></td></tr></table></figure><p><code>email</code> 键我们从未设置过，所以 <code>session.get(&quot;email&quot;, &quot;暂未设置&quot;)</code> 触发了默认值逻辑，返回了 <code>&quot;暂未设置&quot;</code> 而不是 <code>null</code>。这是一个非常实用的防空指针技巧，建议在实际项目中优先使用带默认值的取值方法。</p><h3 id="5-3-3-用-Redis-验证-账号共享-的本质">5.3.3. 用 Redis 验证&quot;账号共享&quot;的本质</h3><p>前面我们说 Account-Session 是账号维度的——整个账号只有一个 Session，多个 Token 共享它。现在我们用 Redis 来亲眼验证这一点。</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><span class="line">6</span><br><span class="line">7</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment"># 第一次登录（模拟手机端）</span></span><br><span class="line">POST http://localhost:8081/auth/login?username=admin&amp;password=123456</span><br><span class="line"><span class="comment"># 假设返回 Token-A：aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa</span></span><br><span class="line"></span><br><span class="line"><span class="comment"># 第二次登录（模拟电脑端）</span></span><br><span class="line">POST http://localhost:8081/auth/login?username=admin&amp;password=123456</span><br><span class="line"><span class="comment"># 假设返回 Token-B：bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb</span></span><br></pre></td></tr></table></figure><p>此时打开 Redis 客户端执行 <code>KEYS *</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></pre></td><td class="code"><pre><span class="line">1) &quot;satoken:login:token:aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa&quot;</span><br><span class="line">2) &quot;satoken:login:token:bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb&quot;</span><br><span class="line">3) &quot;satoken:login:session:10001&quot;</span><br></pre></td></tr></table></figure><p>三个 Key，两个 Token Key，但只有<strong>一个</strong> Session Key。<code>satoken:login:session:10001</code> 这个 Key 以用户 ID 为后缀，不随 Token 的数量变化——这就是&quot;账号维度&quot;的真实含义。</p><p>查看这个 Session 的内容：</p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">&gt; GET <span class="string">&quot;satoken:login:session:10001&quot;</span></span><br></pre></td></tr></table></figure><p>你会在 <code>terminalList</code> 字段中看到两条终端记录，分别对应 Token-A 和 Token-B，它们都挂载在同一个 Session 对象下：</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><span class="line"><span class="punctuation">&#123;</span></span><br><span class="line">  <span class="attr">&quot;type&quot;</span><span class="punctuation">:</span> <span class="string">&quot;Account-Session&quot;</span><span class="punctuation">,</span></span><br><span class="line">  <span class="attr">&quot;loginId&quot;</span><span class="punctuation">:</span> <span class="number">10001</span><span class="punctuation">,</span></span><br><span class="line">  <span class="attr">&quot;terminalList&quot;</span><span class="punctuation">:</span> <span class="punctuation">[</span></span><br><span class="line">    <span class="punctuation">&#123;</span></span><br><span class="line">      <span class="attr">&quot;tokenValue&quot;</span><span class="punctuation">:</span> <span class="string">&quot;aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa&quot;</span><span class="punctuation">,</span></span><br><span class="line">      <span class="attr">&quot;deviceType&quot;</span><span class="punctuation">:</span> <span class="string">&quot;DEF&quot;</span><span class="punctuation">,</span></span><br><span class="line">      <span class="attr">&quot;createTime&quot;</span><span class="punctuation">:</span> <span class="number">1772518275240</span></span><br><span class="line">    <span class="punctuation">&#125;</span><span class="punctuation">,</span></span><br><span class="line">    <span class="punctuation">&#123;</span></span><br><span class="line">      <span class="attr">&quot;tokenValue&quot;</span><span class="punctuation">:</span> <span class="string">&quot;bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb&quot;</span><span class="punctuation">,</span></span><br><span class="line">      <span class="attr">&quot;deviceType&quot;</span><span class="punctuation">:</span> <span class="string">&quot;DEF&quot;</span><span class="punctuation">,</span></span><br><span class="line">      <span class="attr">&quot;createTime&quot;</span><span class="punctuation">:</span> <span class="number">1772518276100</span></span><br><span class="line">    <span class="punctuation">&#125;</span></span><br><span class="line">  <span class="punctuation">]</span></span><br><span class="line"><span class="punctuation">&#125;</span></span><br></pre></td></tr></table></figure><p>现在用 Token-A 携带请求调用 <code>/session/account/set</code> 写入昵称，再换用 Token-B 携带请求调用 <code>/session/account/get</code> 读取，结果是完全一致的。这就是 Account-Session 账号级共享的直观证明。</p><h3 id="5-3-4-懒加载取值：get-方法的第三种重载">5.3.4. 懒加载取值：<code>get</code> 方法的第三种重载</h3><p><code>SaSession</code> 的 <code>get</code> 方法有一个非常实用但容易被忽略的重载——接受一个 <code>Supplier</code> 函数作为参数：</p><figure class="highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">// 若 Session 中已有 &quot;userInfo&quot; 这个 key，直接返回缓存值</span></span><br><span class="line"><span class="comment">// 若没有，执行 Supplier 获取新值，存入 Session，然后返回</span></span><br><span class="line"><span class="type">Object</span> <span class="variable">userInfo</span> <span class="operator">=</span> session.get(<span class="string">&quot;userInfo&quot;</span>, () -&gt; &#123;</span><br><span class="line">    <span class="comment">// 这里写&quot;缓存未命中时的数据获取逻辑&quot;，比如查数据库</span></span><br><span class="line">    <span class="comment">// 在本演示中我们用一个 Map 模拟数据库返回的用户对象</span></span><br><span class="line">    <span class="keyword">return</span> java.util.Map.of(<span class="string">&quot;id&quot;</span>, <span class="number">10001</span>, <span class="string">&quot;name&quot;</span>, <span class="string">&quot;管理员小王&quot;</span>);</span><br><span class="line">&#125;);</span><br></pre></td></tr></table></figure><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><span class="line"><span class="comment">// ❌ 没有懒加载时需要这样写</span></span><br><span class="line"><span class="type">Object</span> <span class="variable">userInfo</span> <span class="operator">=</span> session.get(<span class="string">&quot;userInfo&quot;</span>);</span><br><span class="line"><span class="keyword">if</span> (userInfo == <span class="literal">null</span>) &#123;</span><br><span class="line">    userInfo = userService.findById(<span class="number">10001</span>); <span class="comment">// 查数据库</span></span><br><span class="line">    session.set(<span class="string">&quot;userInfo&quot;</span>, userInfo);      <span class="comment">// 写入缓存</span></span><br><span class="line">&#125;</span><br><span class="line"><span class="keyword">return</span> userInfo;</span><br><span class="line"></span><br><span class="line"><span class="comment">// ✅ 使用懒加载重载，等价于上面的代码</span></span><br><span class="line"><span class="type">Object</span> <span class="variable">userInfo</span> <span class="operator">=</span> session.get(<span class="string">&quot;userInfo&quot;</span>, () -&gt; userService.findById(<span class="number">10001</span>));</span><br></pre></td></tr></table></figure><p>两者行为完全一致，后者的代码量减少了三分之二，可读性也更高。</p><h3 id="5-3-5-本节小结">5.3.5. 本节小结</h3><p>本节完成了 Account-Session 的 API 学习与 Redis 数据结构验证，亲眼确认了同一账号多设备登录时共享同一个 Session 对象的行为。</p><table><thead><tr><th>要点</th><th>何时使用</th><th>关键动作</th></tr></thead><tbody><tr><td><code>StpUtil.getSession()</code></td><td>当前请求上下文中操作账号 Session</td><td>必须在已登录状态下调用，否则抛出异常</td></tr><tr><td><code>session.get(key, supplier)</code></td><td>实现查缓存→查库→写缓存的懒加载模式</td><td>未命中时自动执行 Supplier 并回写 Session</td></tr><tr><td>Redis Key 格式</td><td>排查账号 Session 数据时</td><td><code>satoken:login:session:&#123;userId&#125;</code></td></tr></tbody></table><hr><h2 id="5-4-Token-Session：终端维度的独立容器">5.4. Token-Session：终端维度的独立容器</h2><p>Account-Session 解决了&quot;账号级数据共享&quot;的问题，但有时候我们需要存储的数据不是账号级的，而是<strong>终端级的</strong>——也就是说，同一个账号的手机端和电脑端，应该拥有各自独立的数据空间，互不干扰。</p><p>Token-Session 就是为这种场景设计的：<strong>每个 Token 拥有自己独立的 Session</strong>，两个 Token 即使属于同一个账号，它们的 Token-Session 也完全隔离，互不可见。</p><h3 id="5-4-1-Account-Session-与-Token-Session-的边界">5.4.1. Account-Session 与 Token-Session 的边界</h3><p>在写代码之前，我们先把两者的边界划清楚，这是实际开发中最容易混淆的地方：</p><table><thead><tr><th>问题</th><th>Account-Session</th><th>Token-Session</th></tr></thead><tbody><tr><td>同账号多设备，数据是否共享？</td><td>✅ 共享，所有设备读写同一份</td><td>❌ 隔离，每个设备独立</td></tr><tr><td>数据与 Token 是否绑定？</td><td>❌ 不绑定，以用户 ID 为键</td><td>✅ 绑定，以 Token 值为键</td></tr><tr><td>一个账号最多有几个？</td><td>始终只有一个</td><td>有几个活跃 Token 就有几个</td></tr><tr><td>适合存储什么？</td><td>用户信息、权限列表、全局配置</td><td>当前终端的临时操作状态、步骤记录</td></tr></tbody></table><p>一个具体的业务场景可以帮助你直观区分：用户的&quot;权限列表&quot;应该存在 Account-Session 里（所有终端的权限是一样的），但&quot;当前正在编辑中的草稿 ID&quot;应该存在 Token-Session 里（手机端和电脑端各自编辑各自的草稿，互不干扰）。</p><h3 id="5-4-2-核心-API">5.4.2. 核心 API</h3><p>Token-Session 的获取方式比 Account-Session 简单，只有两个 API：</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><span class="line"><span class="comment">// 获取当前请求的 Token 所对应的 Token-Session</span></span><br><span class="line"><span class="comment">// 默认情况下，只有已登录的请求才能调用（未登录会抛出 NotLoginException）</span></span><br><span class="line">StpUtil.getTokenSession();</span><br><span class="line"></span><br><span class="line"><span class="comment">// 获取指定 Token 值对应的 Token-Session（可用于管理后台操作其他用户的终端）</span></span><br><span class="line">StpUtil.getTokenSessionByToken(<span class="string">&quot;aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa&quot;</span>);</span><br></pre></td></tr></table></figure><p>获取到的同样是 <code>SaSession</code> 对象，存取数据的 API 与 Account-Session 完全一致。</p><h3 id="5-4-3-演示终端隔离特性">5.4.3. 演示终端隔离特性</h3><p>我们在 <code>SessionDemoController</code> 中追加两个方法来演示 Token-Session 的隔离特性：</p><p>📄 文件：<code>src/main/java/com/example/authsatoken/controller/SessionDemoController.java</code>（修改，追加方法）</p><figure class="highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><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><span class="line"><span class="comment">/**</span></span><br><span class="line"><span class="comment"> * 向当前 Token 的 Token-Session 写入数据</span></span><br><span class="line"><span class="comment"> * 每个 Token 写入的数据是隔离的，不会互相影响</span></span><br><span class="line"><span class="comment"> */</span></span><br><span class="line"><span class="meta">@PostMapping(&quot;/token/set&quot;)</span></span><br><span class="line"><span class="keyword">public</span> SaResult <span class="title function_">tokenSet</span><span class="params">(<span class="meta">@RequestParam</span> String deviceNote)</span> &#123;</span><br><span class="line"><span class="comment">// 获取当前 Token 的 Token-Session（与 Account-Session 同样简洁）</span></span><br><span class="line"><span class="type">SaSession</span> <span class="variable">tokenSession</span> <span class="operator">=</span> StpUtil.getTokenSession();</span><br><span class="line"></span><br><span class="line"><span class="comment">// 将设备备注写入当前 Token 的私有 Session</span></span><br><span class="line">tokenSession.set(<span class="string">&quot;deviceNote&quot;</span>, deviceNote);</span><br><span class="line"></span><br><span class="line"><span class="keyword">return</span> SaResult.ok(<span class="string">&quot;已向 Token-Session 写入：&quot;</span> + deviceNote);</span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="comment">/**</span></span><br><span class="line"><span class="comment"> * 从当前 Token 的 Token-Session 读取数据</span></span><br><span class="line"><span class="comment"> * 用 Token-A 写入的数据，Token-B 无法读取到</span></span><br><span class="line"><span class="comment"> */</span></span><br><span class="line"><span class="meta">@GetMapping(&quot;/token/get&quot;)</span></span><br><span class="line"><span class="keyword">public</span> SaResult <span class="title function_">tokenGet</span><span class="params">()</span> &#123;</span><br><span class="line"><span class="type">SaSession</span> <span class="variable">tokenSession</span> <span class="operator">=</span> StpUtil.getTokenSession();</span><br><span class="line"><span class="type">String</span> <span class="variable">deviceNote</span> <span class="operator">=</span> tokenSession.get(<span class="string">&quot;deviceNote&quot;</span>, <span class="string">&quot;（该终端尚未设置备注）&quot;</span>);;</span><br><span class="line"><span class="keyword">return</span> SaResult.ok(<span class="string">&quot;当前终端备注：&quot;</span> + deviceNote);</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>现在用两个 Token（Token-A 和 Token-B，来自前面模拟的两次登录）分别测试：</p><p><strong>用 Token-A 写入：</strong></p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line">POST http://localhost:8081/session/token/set?deviceNote=这是手机端</span><br><span class="line">（Header: satoken = Token-A 的值）</span><br></pre></td></tr></table></figure><p><strong>用 Token-B 读取：</strong></p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line">GET http://localhost:8081/session/token/get</span><br><span class="line">（Header: satoken = Token-B 的值）</span><br></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></pre></td><td class="code"><pre><span class="line"><span class="punctuation">&#123;</span></span><br><span class="line">    <span class="attr">&quot;code&quot;</span><span class="punctuation">:</span> <span class="number">200</span><span class="punctuation">,</span></span><br><span class="line">    <span class="attr">&quot;msg&quot;</span><span class="punctuation">:</span> <span class="string">&quot;当前终端备注：（该终端尚未设置备注）&quot;</span><span class="punctuation">,</span></span><br><span class="line">    <span class="attr">&quot;data&quot;</span><span class="punctuation">:</span> <span class="literal"><span class="keyword">null</span></span></span><br><span class="line"><span class="punctuation">&#125;</span></span><br></pre></td></tr></table></figure><p>Token-B 读取不到 Token-A 写入的数据，两个终端的 Session 完全隔离，这正是 Token-Session 的设计目标。再用 Token-A 读取：</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line">GET http://localhost:8081/session/token/get</span><br><span class="line">（Header: satoken = Token-A 的值）</span><br></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></pre></td><td class="code"><pre><span class="line"><span class="punctuation">&#123;</span></span><br><span class="line">    <span class="attr">&quot;code&quot;</span><span class="punctuation">:</span> <span class="number">200</span><span class="punctuation">,</span></span><br><span class="line">    <span class="attr">&quot;msg&quot;</span><span class="punctuation">:</span> <span class="string">&quot;当前终端备注：这是手机端&quot;</span><span class="punctuation">,</span></span><br><span class="line">    <span class="attr">&quot;data&quot;</span><span class="punctuation">:</span> <span class="literal"><span class="keyword">null</span></span></span><br><span class="line"><span class="punctuation">&#125;</span></span><br></pre></td></tr></table></figure><h3 id="5-4-4-用-Redis-确认-Token-Session-的-Key-结构">5.4.4. 用 Redis 确认 Token-Session 的 Key 结构</h3><p>此时打开 Redis 客户端，执行 <code>KEYS *</code>，你会看到新增了 Token-Session 对应的 Key：</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></pre></td><td class="code"><pre><span class="line">1) &quot;satoken:login:token:aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa&quot;</span><br><span class="line">2) &quot;satoken:login:token:bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb&quot;</span><br><span class="line">3) &quot;satoken:login:session:10001&quot;</span><br><span class="line">4) &quot;satoken:login:token-session:aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa&quot;</span><br></pre></td></tr></table></figure><p>注意第 4 条 Key 的格式：<code>satoken:login:token-session:&lt;token值&gt;</code>，它以 Token 的值本身作为标识符，所以每个 Token 的 Token-Session 是天然隔离的。我们只为 Token-A 写入了数据，所以只有 Token-A 的 <code>token-session</code> Key 被创建了，Token-B 的不存在。</p><div class="note info simple"><p>Token-Session 默认只有在开发者主动调用 <code>getTokenSession()</code> 时才会创建，不会随登录自动生成，这与 Account-Session 的行为不同（Account-Session 在登录时自动创建）。</p></div><h3 id="5-4-5-未登录场景下使用-Token-Session">5.4.5. 未登录场景下使用 Token-Session</h3><p>这是一个值得单独说明的特殊场景。默认配置下，调用 <code>StpUtil.getTokenSession()</code> 要求当前请求必须处于登录状态，未登录时会抛出 <code>NotLoginException</code>。</p><p>但在某些业务场景下，你可能需要在用户正式登录之前就为当前终端挂载临时数据——例如记录用户的未登录状态下的购物车，或者多步骤表单的中间状态。Sa-Token 提供了两种解法：</p><p><strong>解法一：修改全局配置（影响所有接口）</strong></p><p>在 <code>application.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></pre></td><td class="code"><pre><span class="line"><span class="attr">sa-token:</span></span><br><span class="line">  <span class="comment"># 将此项改为 false，允许在未登录状态下调用 getTokenSession()</span></span><br><span class="line">  <span class="comment"># 默认值为 true（即默认要求登录才能使用 Token-Session）</span></span><br><span class="line">  <span class="attr">token-session-check-login:</span> <span class="literal">false</span></span><br></pre></td></tr></table></figure><p>这个配置的影响范围是全局的，修改后整个应用的所有 <code>getTokenSession()</code> 调用都不再校验登录状态。如果你只有个别接口需要这种行为，不建议使用全局配置，而是使用解法二。</p><p><strong>解法二：使用匿名 Token-Session（精细控制）</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></pre></td><td class="code"><pre><span class="line"><span class="comment">// 获取当前 Token 的匿名 Token-Session</span></span><br><span class="line"><span class="comment">// 在未登录时同样可以调用，框架不会抛出异常</span></span><br><span class="line">StpUtil.getAnonTokenSession();</span><br></pre></td></tr></table></figure><p>这里有一个需要注意的细节：如果前端发来的请求没有携带任何 Token，或者携带了一个已经失效的 Token，框架不会报错，而是会<strong>随机生成一个新的 Token 值</strong>来创建这个匿名 Token-Session。这个新生成的 Token 值可以通过 <code>StpUtil.getTokenValue()</code> 获取，你应该将它返回给前端保存，否则下次请求时这个匿名 Session 就无法被找回了。</p><h3 id="5-4-6-本节小结">5.4.6. 本节小结</h3><p>本节完成了 Token-Session 的核心 API 学习，通过双 Token 隔离测试直观验证了终端隔离特性，并覆盖了未登录场景下的两种使用策略。</p><table><thead><tr><th>要点</th><th>何时使用</th><th>关键动作</th></tr></thead><tbody><tr><td><code>StpUtil.getTokenSession()</code></td><td>需要存储与当前终端绑定的数据时</td><td>默认要求已登录，数据以 Token 为隔离键</td></tr><tr><td><code>StpUtil.getAnonTokenSession()</code></td><td>未登录前需要临时记录终端状态时</td><td>注意将新生成的 Token 返回给前端保存</td></tr><tr><td>Redis Key 格式</td><td>排查 Token-Session 数据时</td><td><code>satoken:login:token-session:&#123;token值&#125;</code></td></tr></tbody></table><hr><h2 id="5-5-Custom-Session：突破账号边界，为任意业务实体挂载数据">5.5. Custom-Session：突破账号边界，为任意业务实体挂载数据</h2><p>Account-Session 和 Token-Session 都是围绕&quot;用户&quot;这个核心设计的——前者以账号 ID 为键，后者以 Token 为键。但在实际业务开发中，我们经常需要为非用户的业务实体挂载临时数据，比如：</p><ul><li>为商品 ID <code>10001</code> 挂载一个缓存，存储该商品的实时库存快照</li><li>为订单 ID <code>ORDER-2025-88888</code> 挂载状态机数据，记录订单当前处于哪个审批步骤</li><li>为一个临时会议室 ID 挂载参会者列表</li></ul><p>这些场景的共同特点是：数据的归属主体不是&quot;用户&quot;，而是某个业务实体。如果强行用 Account-Session 来存储，会导致数据被混入用户的 Session 中，职责混乱；如果单独建一套 Redis 操作逻辑，又回到了手写轮子的老路。Custom-Session 正是为此而生的。</p><h3 id="5-5-1-设计思路">5.5.1. 设计思路</h3><p>Custom-Session 的本质是：<strong>以一个你自定义的字符串作为 Session ID</strong>，让框架在 Redis 中为你创建和管理一个独立的 <code>SaSession</code> 对象。你不需要关心 Redis 连接、序列化、TTL 管理等细节，只需要给定一个唯一的字符串 key，剩下的交给 Sa-Token。</p><h3 id="5-5-2-核心-API">5.5.2. 核心 API</h3><p>Custom-Session 通过独立的工具类 <code>SaSessionCustomUtil</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></pre></td><td class="code"><pre><span class="line"><span class="keyword">import</span> cn.dev33.satoken.session.SaSessionCustomUtil;</span><br><span class="line"></span><br><span class="line"><span class="comment">// 检查指定 key 的 Session 是否已存在于 Redis 中</span></span><br><span class="line">SaSessionCustomUtil.isExists(<span class="string">&quot;goods-10001&quot;</span>);</span><br><span class="line"></span><br><span class="line"><span class="comment">// 获取指定 key 的 Session，若不存在则自动创建并返回（最常用）</span></span><br><span class="line"><span class="type">SaSession</span> <span class="variable">session</span> <span class="operator">=</span> SaSessionCustomUtil.getSessionById(<span class="string">&quot;goods-10001&quot;</span>);</span><br><span class="line"></span><br><span class="line"><span class="comment">// 获取指定 key 的 Session，第二个参数决定不存在时是否自动创建</span></span><br><span class="line"><span class="comment">// false：不存在则返回 null（适合只读场景，避免意外创建）</span></span><br><span class="line"><span class="type">SaSession</span> <span class="variable">session</span> <span class="operator">=</span> SaSessionCustomUtil.getSessionById(<span class="string">&quot;goods-10001&quot;</span>, <span class="literal">false</span>);</span><br><span class="line"></span><br><span class="line"><span class="comment">// 主动删除指定 key 的 Session（清理不再需要的临时数据）</span></span><br><span class="line">SaSessionCustomUtil.deleteSessionById(<span class="string">&quot;goods-10001&quot;</span>);</span><br></pre></td></tr></table></figure><p>注意这里的 key（<code>&quot;goods-10001&quot;</code>）就是你给这个业务实体的 Session 取的名字，框架会在 Redis 中以 <code>satoken:custom:session:goods-10001</code> 的格式存储它。</p><h3 id="5-5-3-完整演示：为商品挂载实时数据">5.5.3. 完整演示：为商品挂载实时数据</h3><p>我们用一个商品浏览量缓存的场景来演示 Custom-Session 的完整生命周期。</p><p>在 <code>SessionDemoController</code> 中追加以下方法：</p><p>📄 文件：<code>src/main/java/com/example/authsatoken/controller/SessionDemoController.java</code>（修改，追加方法）</p><figure class="highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br><span class="line">32</span><br><span class="line">33</span><br><span class="line">34</span><br><span class="line">35</span><br><span class="line">36</span><br><span class="line">37</span><br><span class="line">38</span><br><span class="line">39</span><br><span class="line">40</span><br><span class="line">41</span><br><span class="line">42</span><br><span class="line">43</span><br><span class="line">44</span><br><span class="line">45</span><br><span class="line">46</span><br><span class="line">47</span><br><span class="line">48</span><br><span class="line">49</span><br><span class="line">50</span><br><span class="line">51</span><br><span class="line">52</span><br><span class="line">53</span><br><span class="line">54</span><br><span class="line">55</span><br><span class="line">56</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">/**</span></span><br><span class="line"><span class="comment"> * 演示 Custom-Session：为商品挂载实时浏览量数据</span></span><br><span class="line"><span class="comment"> * 场景：用户访问商品详情页时，记录浏览量（此接口无需登录）</span></span><br><span class="line"><span class="comment"> */</span></span><br><span class="line"><span class="meta">@PostMapping(&quot;/custom/goods/view&quot;)</span></span><br><span class="line"><span class="keyword">public</span> SaResult <span class="title function_">goodsView</span><span class="params">(<span class="meta">@RequestParam</span> Long goodsId)</span> &#123;</span><br><span class="line">    <span class="comment">// 以商品 ID 拼接成唯一 key，格式统一为 &quot;goods-&#123;id&#125;&quot;</span></span><br><span class="line">    <span class="comment">// 这个 key 就是 Custom-Session 的身份标识，整个应用内必须唯一</span></span><br><span class="line">    <span class="type">String</span> <span class="variable">sessionKey</span> <span class="operator">=</span> <span class="string">&quot;goods-&quot;</span> + goodsId;</span><br><span class="line"></span><br><span class="line">    <span class="comment">// 获取该商品的 Session，不存在时自动创建</span></span><br><span class="line">    <span class="type">SaSession</span> <span class="variable">goodsSession</span> <span class="operator">=</span> SaSessionCustomUtil.getSessionById(sessionKey);</span><br><span class="line"></span><br><span class="line">    <span class="comment">// 取出当前浏览量，若还未记录过则默认为 0</span></span><br><span class="line">    <span class="type">int</span> <span class="variable">viewCount</span> <span class="operator">=</span> goodsSession.getInt(<span class="string">&quot;viewCount&quot;</span>, <span class="number">0</span>);</span><br><span class="line"></span><br><span class="line">    <span class="comment">// 自增后写回</span></span><br><span class="line">    goodsSession.set(<span class="string">&quot;viewCount&quot;</span>, viewCount + <span class="number">1</span>);</span><br><span class="line"></span><br><span class="line">    <span class="keyword">return</span> SaResult.ok(<span class="string">&quot;商品 &quot;</span> + goodsId + <span class="string">&quot; 当前浏览量：&quot;</span> + (viewCount + <span class="number">1</span>));</span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="comment">/**</span></span><br><span class="line"><span class="comment"> * 查询指定商品的缓存数据（只读，不主动创建 Session）</span></span><br><span class="line"><span class="comment"> */</span></span><br><span class="line"><span class="meta">@GetMapping(&quot;/custom/goods/info&quot;)</span></span><br><span class="line"><span class="keyword">public</span> SaResult <span class="title function_">goodsInfo</span><span class="params">(<span class="meta">@RequestParam</span> Long goodsId)</span> &#123;</span><br><span class="line">    <span class="type">String</span> <span class="variable">sessionKey</span> <span class="operator">=</span> <span class="string">&quot;goods-&quot;</span> + goodsId;</span><br><span class="line"></span><br><span class="line">    <span class="comment">// 传入 false：Session 不存在时返回 null，而不是自动创建</span></span><br><span class="line">    <span class="comment">// 避免因为查询操作意外产生空的 Session 数据</span></span><br><span class="line">    <span class="type">SaSession</span> <span class="variable">goodsSession</span> <span class="operator">=</span> SaSessionCustomUtil.getSessionById(sessionKey, <span class="literal">false</span>);</span><br><span class="line"></span><br><span class="line">    <span class="keyword">if</span> (goodsSession == <span class="literal">null</span>) &#123;</span><br><span class="line">        <span class="keyword">return</span> SaResult.error(<span class="string">&quot;该商品尚无缓存数据，请先访问商品详情页&quot;</span>);</span><br><span class="line">    &#125;</span><br><span class="line"></span><br><span class="line">    <span class="type">int</span> <span class="variable">viewCount</span> <span class="operator">=</span> goodsSession.getInt(<span class="string">&quot;viewCount&quot;</span>, <span class="number">0</span>);</span><br><span class="line">    <span class="keyword">return</span> SaResult.ok(<span class="string">&quot;查询成功&quot;</span>).setData(<span class="string">&quot;商品 &quot;</span> + goodsId + <span class="string">&quot; 浏览量=&quot;</span> + viewCount);</span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="comment">/**</span></span><br><span class="line"><span class="comment"> * 手动清除指定商品的 Custom-Session（例如商品下架时清理缓存）</span></span><br><span class="line"><span class="comment"> */</span></span><br><span class="line"><span class="meta">@DeleteMapping(&quot;/custom/goods/clear&quot;)</span></span><br><span class="line"><span class="keyword">public</span> SaResult <span class="title function_">goodsClear</span><span class="params">(<span class="meta">@RequestParam</span> Long goodsId)</span> &#123;</span><br><span class="line">    <span class="type">String</span> <span class="variable">sessionKey</span> <span class="operator">=</span> <span class="string">&quot;goods-&quot;</span> + goodsId;</span><br><span class="line"></span><br><span class="line">    <span class="comment">// 先检查是否存在，避免对空数据发起删除操作</span></span><br><span class="line">    <span class="keyword">if</span> (!SaSessionCustomUtil.isExists(sessionKey)) &#123;</span><br><span class="line">        <span class="keyword">return</span> SaResult.error(<span class="string">&quot;该商品暂无缓存数据，无需清理&quot;</span>);</span><br><span class="line">    &#125;</span><br><span class="line"></span><br><span class="line">    SaSessionCustomUtil.deleteSessionById(sessionKey);</span><br><span class="line">    <span class="keyword">return</span> SaResult.ok(<span class="string">&quot;商品 &quot;</span> + goodsId + <span class="string">&quot; 的缓存已清除&quot;</span>);</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>现在按顺序测试这三个接口，观察 Custom-Session 的完整生命周期。</p><p><strong>测试一：触发浏览量记录</strong></p><p>连续调用三次，模拟三次商品访问（Custom-Session 无需登录即可使用）：</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></pre></td><td class="code"><pre><span class="line">POST http://localhost:8081/session/custom/goods/view?goodsId=10001</span><br><span class="line">POST http://localhost:8081/session/custom/goods/view?goodsId=10001</span><br><span class="line">POST http://localhost:8081/session/custom/goods/view?goodsId=10001</span><br></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></pre></td><td class="code"><pre><span class="line"><span class="punctuation">&#123;</span></span><br><span class="line">    <span class="attr">&quot;code&quot;</span><span class="punctuation">:</span> <span class="number">200</span><span class="punctuation">,</span></span><br><span class="line">    <span class="attr">&quot;msg&quot;</span><span class="punctuation">:</span> <span class="string">&quot;商品 10001 当前浏览量：3&quot;</span><span class="punctuation">,</span></span><br><span class="line">    <span class="attr">&quot;data&quot;</span><span class="punctuation">:</span> <span class="literal"><span class="keyword">null</span></span></span><br><span class="line"><span class="punctuation">&#125;</span></span><br></pre></td></tr></table></figure><p><strong>测试二：只读查询</strong></p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">GET http://localhost:8081/session/custom/goods/info?goodsId=10001</span><br></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></pre></td><td class="code"><pre><span class="line"><span class="punctuation">&#123;</span></span><br><span class="line">    <span class="attr">&quot;code&quot;</span><span class="punctuation">:</span> <span class="number">200</span><span class="punctuation">,</span></span><br><span class="line">    <span class="attr">&quot;msg&quot;</span><span class="punctuation">:</span> <span class="string">&quot;查询成功&quot;</span><span class="punctuation">,</span></span><br><span class="line">    <span class="attr">&quot;data&quot;</span><span class="punctuation">:</span> <span class="string">&quot;商品 10001 浏览量=3&quot;</span></span><br><span class="line"><span class="punctuation">&#125;</span></span><br></pre></td></tr></table></figure><p>查询一个从未被访问过的商品：</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">GET http://localhost:8081/session/custom/goods/info?goodsId=99999</span><br></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></pre></td><td class="code"><pre><span class="line"><span class="punctuation">&#123;</span></span><br><span class="line">    <span class="attr">&quot;code&quot;</span><span class="punctuation">:</span> <span class="number">500</span><span class="punctuation">,</span></span><br><span class="line">    <span class="attr">&quot;msg&quot;</span><span class="punctuation">:</span> <span class="string">&quot;该商品尚无缓存数据，请先访问商品详情页&quot;</span><span class="punctuation">,</span></span><br><span class="line">    <span class="attr">&quot;data&quot;</span><span class="punctuation">:</span> <span class="literal"><span class="keyword">null</span></span></span><br><span class="line"><span class="punctuation">&#125;</span></span><br></pre></td></tr></table></figure><p><code>getSessionById(key, false)</code> 在 Session 不存在时返回 <code>null</code> 而不是自动创建，这正是&quot;只读查询传 <code>false</code>&quot;的价值所在——你不会因为一次查询操作，在 Redis 里留下一堆空 Session 垃圾数据。</p><p><strong>测试三：清除缓存</strong></p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">DELETE http://localhost:8081/session/custom/goods/clear?goodsId=10001</span><br></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></pre></td><td class="code"><pre><span class="line"><span class="punctuation">&#123;</span></span><br><span class="line">    <span class="attr">&quot;code&quot;</span><span class="punctuation">:</span> <span class="number">200</span><span class="punctuation">,</span></span><br><span class="line">    <span class="attr">&quot;msg&quot;</span><span class="punctuation">:</span> <span class="string">&quot;商品 10001 的缓存已清除&quot;</span><span class="punctuation">,</span></span><br><span class="line">    <span class="attr">&quot;data&quot;</span><span class="punctuation">:</span> <span class="literal"><span class="keyword">null</span></span></span><br><span class="line"><span class="punctuation">&#125;</span></span><br></pre></td></tr></table></figure><p>此时打开 Redis 客户端执行 <code>KEYS satoken:custom:*</code>，预期输出为空，说明 Custom-Session 数据已从 Redis 中彻底删除。</p><h3 id="5-5-4-用-Redis-观察-Custom-Session-的-Key-格式">5.5.4. 用 Redis 观察 Custom-Session 的 Key 格式</h3><p>在执行清除操作之前，我们先看看 Custom-Session 在 Redis 中的实际存储形式。完成三次浏览量记录后，执行：</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><span class="line">&gt; KEYS satoken:custom:*</span><br><span class="line">1) <span class="string">&quot;satoken:custom:session:goods-10001&quot;</span></span><br></pre></td></tr></table></figure><p>和 Account-Session、Token-Session 不同，Custom-Session 的 Redis Key 前缀是 <code>satoken:custom:session:</code>，后面紧跟你传入的自定义 key 字符串。查看它的内容：</p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">&gt; GET <span class="string">&quot;satoken:custom:session:goods-10001&quot;</span></span><br></pre></td></tr></table></figure><p>返回的 JSON 结构与前两种 Session 一致，都是 <code>SaSession</code> 对象，只是 <code>type</code> 字段变成了 <code>Custom-Session</code>，<code>dataMap</code> 里存着我们写入的 <code>viewCount</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></pre></td><td class="code"><pre><span class="line"><span class="punctuation">&#123;</span></span><br><span class="line">  <span class="attr">&quot;@class&quot;</span><span class="punctuation">:</span> <span class="string">&quot;cn.dev33.satoken.session.SaSession&quot;</span><span class="punctuation">,</span></span><br><span class="line">  <span class="attr">&quot;id&quot;</span><span class="punctuation">:</span> <span class="string">&quot;satoken:custom:session:goods-10001&quot;</span><span class="punctuation">,</span></span><br><span class="line">  <span class="attr">&quot;type&quot;</span><span class="punctuation">:</span> <span class="string">&quot;Custom-Session&quot;</span><span class="punctuation">,</span></span><br><span class="line">  <span class="attr">&quot;loginType&quot;</span><span class="punctuation">:</span> <span class="literal"><span class="keyword">null</span></span><span class="punctuation">,</span></span><br><span class="line">  <span class="attr">&quot;loginId&quot;</span><span class="punctuation">:</span> <span class="literal"><span class="keyword">null</span></span><span class="punctuation">,</span></span><br><span class="line">  <span class="attr">&quot;createTime&quot;</span><span class="punctuation">:</span> <span class="number">1772520001234</span><span class="punctuation">,</span></span><br><span class="line">  <span class="attr">&quot;dataMap&quot;</span><span class="punctuation">:</span> <span class="punctuation">&#123;</span></span><br><span class="line">    <span class="attr">&quot;@class&quot;</span><span class="punctuation">:</span> <span class="string">&quot;java.util.concurrent.ConcurrentHashMap&quot;</span><span class="punctuation">,</span></span><br><span class="line">    <span class="attr">&quot;viewCount&quot;</span><span class="punctuation">:</span> <span class="number">3</span></span><br><span class="line">  <span class="punctuation">&#125;</span><span class="punctuation">,</span></span><br><span class="line">  <span class="attr">&quot;terminalList&quot;</span><span class="punctuation">:</span> <span class="punctuation">[</span><span class="string">&quot;java.util.Vector&quot;</span><span class="punctuation">,</span> <span class="punctuation">[</span><span class="punctuation">]</span><span class="punctuation">]</span></span><br><span class="line"><span class="punctuation">&#125;</span></span><br></pre></td></tr></table></figure><p>注意 <code>loginId</code> 和 <code>loginType</code> 均为 <code>null</code>，这进一步说明 Custom-Session 与&quot;用户&quot;这个概念完全脱钩——它就是一个以你给定的 key 为标识的独立数据容器，和谁登录了系统毫无关联。</p><hr><h2 id="5-6-SaSession-的完整存取-API-速查">5.6. SaSession 的完整存取 API 速查</h2><p>前面三节我们在演示中已经用到了 <code>SaSession</code> 的大部分常用方法。这里把 <code>SaSession</code> 对象上的全部存取 API 系统性地梳理一遍，方便后续查阅。</p><p>无论是 Account-Session、Token-Session 还是 Custom-Session，拿到的都是同一种对象——<code>SaSession</code>，存取数据的方式完全相同。</p><h3 id="5-6-1-写入与更新">5.6.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></pre></td><td class="code"><pre><span class="line"><span class="comment">// 基础写入：存入任意键值对，值为 Object 类型，可以是字符串、数字、POJO 等</span></span><br><span class="line">session.set(<span class="string">&quot;name&quot;</span>, <span class="string">&quot;管理员小王&quot;</span>);</span><br><span class="line">session.set(<span class="string">&quot;age&quot;</span>, <span class="number">28</span>);</span><br><span class="line"></span><br><span class="line"><span class="comment">// 条件写入：仅在该 key 原本不存在时才写入，已有值时静默跳过</span></span><br><span class="line"><span class="comment">// 适合&quot;初始化默认值&quot;的场景，避免意外覆盖已有数据</span></span><br><span class="line">session.setDefaultValue(<span class="string">&quot;loginCount&quot;</span>, <span class="number">0</span>);</span><br></pre></td></tr></table></figure><p><code>setDefaultValue</code> 的应用场景很典型：用户第一次登录时初始化计数器，后续登录时不希望被重置为 0，用这个方法就能安全地做到。</p><h3 id="5-6-2-读取与类型转换">5.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><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><span class="line"><span class="comment">// 基础取值，返回 Object 类型，需要自行强转</span></span><br><span class="line"><span class="type">Object</span> <span class="variable">value</span> <span class="operator">=</span> session.get(<span class="string">&quot;name&quot;</span>);</span><br><span class="line"></span><br><span class="line"><span class="comment">// 带默认值的取值：key 不存在时返回指定默认值，而不是 null</span></span><br><span class="line"><span class="comment">// 强烈推荐在业务代码中优先使用带默认值的重载，可以省去大量 null 判断</span></span><br><span class="line"><span class="type">Object</span> <span class="variable">value</span> <span class="operator">=</span> session.get(<span class="string">&quot;name&quot;</span>, <span class="string">&quot;匿名用户&quot;</span>);</span><br><span class="line"></span><br><span class="line"><span class="comment">// 懒加载取值：key 不存在时执行 Supplier，将结果写入 Session 后返回</span></span><br><span class="line"><span class="comment">// 适合&quot;查缓存→未命中→查库→回写&quot;的经典模式</span></span><br><span class="line"><span class="type">Object</span> <span class="variable">value</span> <span class="operator">=</span> session.get(<span class="string">&quot;userInfo&quot;</span>, () -&gt; userService.findById(<span class="number">10001</span>));</span><br><span class="line"></span><br><span class="line"><span class="comment">// 类型安全的取值方法，内部会进行自动类型转换</span></span><br><span class="line">session.getString(<span class="string">&quot;name&quot;</span>);          <span class="comment">// 转为 String 类型</span></span><br><span class="line">session.getInt(<span class="string">&quot;age&quot;</span>);              <span class="comment">// 转为 int 类型</span></span><br><span class="line">session.getLong(<span class="string">&quot;userId&quot;</span>);          <span class="comment">// 转为 long 类型</span></span><br><span class="line">session.getDouble(<span class="string">&quot;score&quot;</span>);         <span class="comment">// 转为 double 类型</span></span><br><span class="line">session.getFloat(<span class="string">&quot;rate&quot;</span>);           <span class="comment">// 转为 float 类型</span></span><br><span class="line"></span><br><span class="line"><span class="comment">// 将值反序列化为指定的 POJO 类型（使用 Jackson 反序列化）</span></span><br><span class="line"><span class="comment">// 适合从 Session 中取出存储的自定义对象</span></span><br><span class="line">session.getModel(<span class="string">&quot;userInfo&quot;</span>, UserInfo.class);</span><br><span class="line"></span><br><span class="line"><span class="comment">// 带默认值版本：若 key 不存在或反序列化结果为 null，返回指定默认值</span></span><br><span class="line">session.getModel(<span class="string">&quot;userInfo&quot;</span>, UserInfo.class, <span class="keyword">new</span> <span class="title class_">UserInfo</span>());</span><br></pre></td></tr></table></figure><p>关于 <code>getModel</code> 有一个常见陷阱需要预先说明。</p><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></pre></td><td class="code"><pre><span class="line"><span class="comment">// 存入时，Java 对象会被 Jackson 序列化成 JSON 存入 Redis</span></span><br><span class="line">session.set(<span class="string">&quot;userInfo&quot;</span>, <span class="keyword">new</span> <span class="title class_">UserInfo</span>(<span class="number">10001</span>, <span class="string">&quot;小王&quot;</span>));</span><br><span class="line"></span><br><span class="line"><span class="comment">// ❌ 错误：直接强转会抛出 ClassCastException</span></span><br><span class="line"><span class="comment">// 因为从 Redis 取回的是 LinkedHashMap（Jackson 默认反序列化结果），不是 UserInfo</span></span><br><span class="line"><span class="type">UserInfo</span> <span class="variable">user</span> <span class="operator">=</span> (UserInfo) session.get(<span class="string">&quot;userInfo&quot;</span>);</span><br><span class="line"></span><br><span class="line"><span class="comment">// ✅ 正确：使用 getModel 指定目标类型，框架负责完成反序列化</span></span><br><span class="line"><span class="type">UserInfo</span> <span class="variable">user</span> <span class="operator">=</span> session.getModel(<span class="string">&quot;userInfo&quot;</span>, UserInfo.class);</span><br></pre></td></tr></table></figure><p>这个错误非常隐蔽，在开发阶段可能因为类型信息还在内存中而不报错，但一旦应用重启、数据从 Redis 重新加载，就会在运行时抛出 <code>ClassCastException</code>。<strong>存入自定义对象时，取出时一律使用 <code>getModel</code></strong>，这是一条不容破例的实践准则。</p><h3 id="5-6-3-删除与清空">5.6.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></pre></td><td class="code"><pre><span class="line"><span class="comment">// 删除单个 key</span></span><br><span class="line">session.delete(<span class="string">&quot;name&quot;</span>);</span><br><span class="line"></span><br><span class="line"><span class="comment">// 清空该 Session 下的所有 key（Session 对象本身仍然存在于 Redis 中）</span></span><br><span class="line">session.clear();</span><br><span class="line"></span><br><span class="line"><span class="comment">// 查看该 Session 中存有哪些 key（返回 Set&lt;String&gt;）</span></span><br><span class="line">Set&lt;String&gt; keys = session.keys();</span><br><span class="line"></span><br><span class="line"><span class="comment">// 判断某个 key 是否存在（返回 true 或 false）</span></span><br><span class="line"><span class="type">boolean</span> <span class="variable">exists</span> <span class="operator">=</span> session.has(<span class="string">&quot;name&quot;</span>);</span><br></pre></td></tr></table></figure><h3 id="5-6-4-Session-自身的元信息与管理">5.6.4. Session 自身的元信息与管理</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></pre></td><td class="code"><pre><span class="line"><span class="comment">// 获取该 Session 的 ID（就是 Redis 中存储它的 Key）</span></span><br><span class="line"><span class="type">String</span> <span class="variable">id</span> <span class="operator">=</span> session.getId();</span><br><span class="line"></span><br><span class="line"><span class="comment">// 获取该 Session 的创建时间（Unix 毫秒时间戳）</span></span><br><span class="line"><span class="type">long</span> <span class="variable">createTime</span> <span class="operator">=</span> session.getCreateTime();</span><br><span class="line"></span><br><span class="line"><span class="comment">// 获取该 Session 底层存储数据的原始 Map 对象</span></span><br><span class="line"><span class="comment">// 注意：直接修改这个 Map 里的值后，必须调用 session.update() 才能持久化到 Redis</span></span><br><span class="line"><span class="comment">// 否则修改只存在于内存中，下一次从 Redis 加载时会丢失（脏数据问题）</span></span><br><span class="line">java.util.Map&lt;String, Object&gt; dataMap = session.getDataMap();</span><br><span class="line">dataMap.put(<span class="string">&quot;name&quot;</span>, <span class="string">&quot;直接修改&quot;</span>);</span><br><span class="line">session.update(); <span class="comment">// 必须手动调用，否则修改不生效</span></span><br><span class="line"></span><br><span class="line"><span class="comment">// 将当前内存中的 Session 数据强制同步到 Redis（用于直接操作 dataMap 之后）</span></span><br><span class="line">session.update();</span><br><span class="line"></span><br><span class="line"><span class="comment">// 注销该 Session：从 Redis 中彻底删除这个 Session 对象</span></span><br><span class="line"><span class="comment">// 注意与 StpUtil.logout() 的区别：logout() 会级联处理 Token 等关联数据</span></span><br><span class="line"><span class="comment">// session.logout() 只是单纯删除这个 Session 对象本身</span></span><br><span class="line">session.logout();</span><br></pre></td></tr></table></figure><p>关于 <code>session.update()</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></pre></td><td class="code"><pre><span class="line"><span class="type">SaSession</span> <span class="variable">session</span> <span class="operator">=</span> StpUtil.getSession();</span><br><span class="line"></span><br><span class="line"><span class="comment">// ✅ 通过 set() 方法修改，框架内部会自动同步到 Redis，无需手动 update()</span></span><br><span class="line">session.set(<span class="string">&quot;count&quot;</span>, <span class="number">10</span>);</span><br><span class="line"></span><br><span class="line"><span class="comment">// ⚠️ 直接操作 dataMap，框架感知不到你的修改，需要手动 update()</span></span><br><span class="line">Map&lt;String, Object&gt; map = session.getDataMap();</span><br><span class="line">map.put(<span class="string">&quot;count&quot;</span>, <span class="number">20</span>);</span><br><span class="line">session.update(); <span class="comment">// 不调用这行，Redis 中仍然是 10</span></span><br></pre></td></tr></table></figure><p>在绝大多数场景下，你只需要通过 <code>set()</code> 和 <code>delete()</code> 操作 Session，不需要直接碰 <code>dataMap</code>。只有在需要批量操作或原子性更新多个 key 时，才考虑操作 <code>dataMap</code> + 手动 <code>update()</code>。</p><hr><h2 id="5-7-三种-Session-的对比与选型指南">5.7. 三种 Session 的对比与选型指南</h2><p>学完三种 Session 之后，我们做一次完整的横向对比，帮助你在实际业务中快速做出正确选择。</p><table><thead><tr><th>对比维度</th><th>Account-Session</th><th>Token-Session</th><th>Custom-Session</th></tr></thead><tbody><tr><td>Redis Key 格式</td><td><code>satoken:login:session:&#123;userId&#125;</code></td><td><code>satoken:login:token-session:&#123;token&#125;</code></td><td><code>satoken:custom:session:&#123;自定义key&#125;</code></td></tr><tr><td>获取方式</td><td><code>StpUtil.getSession()</code></td><td><code>StpUtil.getTokenSession()</code></td><td><code>SaSessionCustomUtil.getSessionById(key)</code></td></tr><tr><td>归属维度</td><td>账号（用户 ID）</td><td>终端（Token 值）</td><td>任意业务实体</td></tr><tr><td>同账号多设备</td><td>共享同一个</td><td>每个终端独立</td><td>与账号无关</td></tr><tr><td>需要登录才能获取</td><td>✅ 是</td><td>✅ 是（默认）</td><td>❌ 否</td></tr><tr><td>生命周期</td><td>与账号登录状态同步</td><td>与 Token 有效期同步</td><td>开发者手动控制</td></tr><tr><td>适合存储</td><td>用户信息、权限列表</td><td>终端操作状态、草稿 ID</td><td>商品/订单等业务实体的临时数据</td></tr></tbody></table><p>在实际项目中，三种 Session 的选型可以归纳为以下三个问题：</p><p><strong>问题一：这份数据属于&quot;人&quot;还是属于&quot;物&quot;？</strong></p><p>如果答案是&quot;人&quot;，继续问第二个问题；如果答案是&quot;物&quot;（商品、订单、会议室等），选 Custom-Session。</p><p><strong>问题二：同一个人的多台设备，应该看到同一份数据还是各自独立的数据？</strong></p><p>如果&quot;应该共享&quot;（比如权限列表），选 Account-Session；如果&quot;应该隔离&quot;（比如当前编辑的草稿），选 Token-Session。</p><p><strong>问题三：这份数据在用户未登录时是否需要存在？</strong></p><p>如果需要（比如匿名购物车），选 Custom-Session 或配合 <code>getAnonTokenSession()</code> 使用；如果不需要，Account-Session 和 Token-Session 都适用。</p><hr><h2 id="5-8-避免与-HttpSession-混淆">5.8. 避免与 HttpSession 混淆</h2><p>这里有一个必须提前预警的混淆点，在实际项目中出错的频率相当高。</p><p>你在 Controller 方法参数中写 <code>HttpSession session</code>，和 <code>StpUtil.getSession()</code> 拿到的 <code>SaSession</code>，<strong>完全是两种不同的东西</strong>，数据存储位置不同，读写互不影响。</p><p>一个最典型的错误示范：</p><figure class="highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><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><span class="line"><span class="meta">@PostMapping(&quot;/wrongDemo&quot;)</span></span><br><span class="line"><span class="keyword">public</span> SaResult <span class="title function_">wrongDemo</span><span class="params">(HttpSession httpSession)</span> &#123;</span><br><span class="line">    <span class="comment">// 向 HttpSession 写入数据</span></span><br><span class="line">    <span class="comment">// 这个数据存在 Servlet 容器（Tomcat）的内存中</span></span><br><span class="line">    httpSession.setAttribute(<span class="string">&quot;name&quot;</span>, <span class="string">&quot;小王&quot;</span>);</span><br><span class="line"></span><br><span class="line">    <span class="comment">// 从 SaSession 取值——永远取不到，因为两者根本不是同一个容器</span></span><br><span class="line">    <span class="type">Object</span> <span class="variable">name</span> <span class="operator">=</span> StpUtil.getSession().get(<span class="string">&quot;name&quot;</span>);</span><br><span class="line">    <span class="comment">// name 的值是 null，不是&quot;小王&quot;</span></span><br><span class="line">    <span class="keyword">return</span> SaResult.data(name);</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>这段代码在运行时不会报任何错误，但 <code>name</code> 的值始终是 <code>null</code>，因为数据压根没有存进 <code>SaSession</code>。这类 Bug 非常难排查，因为代码看起来完全正确。</p><p>两者的本质区别如下：</p><table><thead><tr><th>对比维度</th><th>HttpSession</th><th>SaSession</th></tr></thead><tbody><tr><td>存储位置</td><td>Servlet 容器（Tomcat）内存</td><td>Sa-Token 的持久化层（Redis）</td></tr><tr><td>会话标识</td><td><code>JSESSIONID</code> Cookie</td><td>Sa-Token 的 Token 值</td></tr><tr><td>是否被框架接管</td><td>❌ Sa-Token 不管它</td><td>✅ 由 Sa-Token 完全管理</td></tr><tr><td>集群部署是否共享</td><td>❌ 不共享（除非配置 Session 共享）</td><td>✅ 天然共享（数据在 Redis 中）</td></tr><tr><td>推荐使用</td><td>❌ 在使用 Sa-Token 时请完全放弃</td><td>✅ 始终使用这个</td></tr></tbody></table><p>结论只有一句话：<strong>引入 Sa-Token 后，在任何情况下都不要使用 <code>HttpSession</code>，它与 Sa-Token 的会话体系没有任何关联</strong>。</p><hr></div>]]></content>
    
    
      
      
        
        
    <summary type="html">&lt;div id=&quot;postchat_postcontent&quot;&gt;&lt;hr&gt;
&lt;h1&gt;第一章.</summary>
        
      
    
    
    
    <category term="后端技术" scheme="https://prorise666.site/categories/%E5%90%8E%E7%AB%AF%E6%8A%80%E6%9C%AF/"/>
    
    <category term="Java" scheme="https://prorise666.site/categories/%E5%90%8E%E7%AB%AF%E6%8A%80%E6%9C%AF/Java/"/>
    
    <category term="Spring系列" scheme="https://prorise666.site/categories/%E5%90%8E%E7%AB%AF%E6%8A%80%E6%9C%AF/Java/Spring%E7%B3%BB%E5%88%97/"/>
    
    <category term="登录注册系列" scheme="https://prorise666.site/categories/%E5%90%8E%E7%AB%AF%E6%8A%80%E6%9C%AF/Java/Spring%E7%B3%BB%E5%88%97/%E7%99%BB%E5%BD%95%E6%B3%A8%E5%86%8C%E7%B3%BB%E5%88%97/"/>
    
    <category term="Sa-Token" scheme="https://prorise666.site/categories/%E5%90%8E%E7%AB%AF%E6%8A%80%E6%9C%AF/Java/Spring%E7%B3%BB%E5%88%97/%E7%99%BB%E5%BD%95%E6%B3%A8%E5%86%8C%E7%B3%BB%E5%88%97/Sa-Token/"/>
    
    
    <category term="Spring生态篇" scheme="https://prorise666.site/tags/Spring%E7%94%9F%E6%80%81%E7%AF%87/"/>
    
    <category term="Sa-Token系列篇" scheme="https://prorise666.site/tags/Sa-Token%E7%B3%BB%E5%88%97%E7%AF%87/"/>
    
  </entry>
  
  <entry>
    <title>Python 基础篇（十四）：第十四章：深入解析并发编程</title>
    <link href="https://prorise666.site/posts/15473.html"/>
    <id>https://prorise666.site/posts/15473.html</id>
    <published>2026-02-05T16:38:17.000Z</published>
    <updated>2026-03-12T09:18:16.967Z</updated>
    
    <content type="html"><![CDATA[<div id="postchat_postcontent"><h2 id="第十四章：深入解析并发编程">第十四章：深入解析并发编程</h2><h3 id="14-并发编程基本概念">14. 并发编程基本概念</h3><p>并发编程是指程序设计中允许多个任务同时执行的编程模式，它的核心目标是 <strong>提升执行效率</strong>。通过并发编程，原本需要 20 分钟执行的代码可能只需要 1 分钟就能完成。</p><h4 id="进程调度机制解析">进程调度机制解析</h4><p>CPU 在执行程序时会涉及进程调度，主要有两种切换情况：</p><ol><li><strong>I/O 操作触发切换</strong>：当程序遇到 I/O 操作时，操作系统会剥夺该程序对 CPU 的执行权限</li><li><strong>时间片用尽触发切换</strong>：当一个程序长时间占用 CPU 时，操作系统也会剥夺程序对 CPU 的执行权限</li></ol><p>所谓 I/O 操作，指的为 <code>阻断</code> 程序的操作，类似于 <code>input()</code> 函数会将程序暂停运行，达到某一个条件后才会接触阻塞状态</p><p>时间片即 CPU 分配给各个程序的时间，每个线程被分配一个时间段，称作它的时间片，即该进程允许运行的时间，使各个程序从表面上看是同时进行的。如果在时间片结束时进程还在运行，则 CPU 将被剥夺并分配给另一个进程。如果进程在时间片结束前阻塞或结束，则 CPU 当即进行切换。而不会造成 CPU 资源浪费。</p><p>在宏观上：我们可以同时打开多个应用程序，每个程序并行不悖，同时运行。</p><p>但在微观上：由于只有一个 CPU，一次只能处理程序要求的一部分，如何处理公平，一种方法就是引入时间片，每个程序轮流执行。</p><h4 id="进程的三大状态与生命周期">进程的三大状态与生命周期</h4><p>进程在其生命周期中会经历三种基本状态：</p><p><img src="assets/image-20250426212152903.png" alt="image-20250426212152903"></p><p>首先一个程序想要被运行，当用户双击图标后，此时程序就会从硬盘加载到内存，所有的程序想要被执行就必须经历就绪态，然后等待 CPU 执行，就绪态之后会进入进程调度，然后运行</p><p>运行时会出现以下几种情况：</p><blockquote><ul><li>1.时间片运行完毕，程序也执行完毕，释放资源后退出</li><li>2.程序运行过程遇到 I/O 操作（读写、发送网络请求）它是不需要 CPU 工作的，只要运行遇到了 I/O，操作系统就会把 CPU 拿走，执行其他的时间片，程序就会进入阻塞态，当 IO 请求完成后它就会结束阻塞态，回到就绪态里排队</li></ul></blockquote><h3 id="14-1-同步与异步编程模型">14.1 同步与异步编程模型</h3><h4 id="同步和异步">同步和异步</h4><p>同步：任务提交之后，原地等待任务的返回结果，等待的过程中不做任何事情</p><p>异步：任务提交之后，不再等待任务的返回结果，而是去做一些其他的事情</p><p>这两个概念主要 <strong>描述任务的提交方式</strong>：</p><blockquote><p>📝 <strong>实际应用</strong>：在 Web 开发中，同步请求会阻塞页面渲染，而异步请求（AJAX）则可以在后台处理数据，不影响用户体验。</p></blockquote><h4 id="阻塞和非阻塞">阻塞和非阻塞</h4><p>这两个概念主要 <strong>描述进程的运行状态</strong>：</p><ul><li><strong>阻塞</strong>：对应进程的阻塞态</li><li><strong>非阻塞</strong>：对应进程的就绪态、运行态</li></ul><p>结合同步/异步和阻塞/非阻塞，可以形成四种组合：</p><ul><li>同步阻塞</li><li>同步非阻塞</li><li>异步阻塞</li><li><strong>异步非阻塞</strong>（CPU 利用率最高的一种模式）</li></ul><blockquote><p>🔍 在实际开发中，异步非阻塞模式是高并发系统的首选模式，因为它允许程序在等待 I/O 操作时继续执行其他任务。</p></blockquote><hr><h3 id="14-2-多进程编程技术">14.2 多进程编程技术</h3><h4 id="进程基础">进程基础</h4><p><strong>进程</strong> 是程序在计算机中的一次执行过程：</p><ul><li><strong>程序</strong> 是静态的可执行文件，占用磁盘空间</li><li><strong>进程</strong> 是动态的执行过程，占用计算机运行资源</li></ul><p>类比：一个工厂有三个车间，每个车间一个工人（共 3 人），并行处理任务，相当于一个程序创建三个进程，每个进程一个线程（共 3 人），并行处理任务。</p><h4 id="进程创建方法">进程创建方法</h4><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></pre></td><td class="code"><pre><span class="line"><span class="keyword">from</span> multiprocessing <span class="keyword">import</span> Process</span><br><span class="line">Process(target,name,args,kwargs)</span><br><span class="line"><span class="string">&#x27;&#x27;&#x27;&#x27;&#x27;&#x27;</span><span class="string">&#x27;&#x27;&#x27;&#x27;&#x27;&#x27;</span><span class="string">&#x27;&#x27;&#x27;&#x27;&#x27;</span></span><br><span class="line"><span class="string">功能 ： 创建进程对象</span></span><br><span class="line"><span class="string">参数 ：  </span></span><br><span class="line"><span class="string">  target 绑定要执行的目标函数 </span></span><br><span class="line"><span class="string">     name 进程名，默认是Process-x(整数)</span></span><br><span class="line"><span class="string">  args 元组，用于给target函数位置传参</span></span><br><span class="line"><span class="string">  kwargs 字典，给target函数键值传参</span></span><br><span class="line"><span class="string">&#x27;&#x27;&#x27;</span><span class="string">&#x27;&#x27;&#x27;&#x27;&#x27;&#x27;</span><span class="string">&#x27;&#x27;&#x27;&#x27;&#x27;&#x27;</span><span class="string">&#x27;&#x27;&#x27;&#x27;</span></span><br></pre></td></tr></table></figure><h5 id="方法一：使用-Process-类创建进程">方法一：使用 Process 类创建进程</h5><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></pre></td><td class="code"><pre><span class="line"><span class="keyword">from</span> multiprocessing <span class="keyword">import</span> Process</span><br><span class="line"><span class="keyword">import</span> time</span><br><span class="line"><span class="keyword">import</span> os</span><br><span class="line"></span><br><span class="line"></span><br><span class="line"><span class="comment"># 创建进程的标准方法</span></span><br><span class="line"><span class="keyword">def</span> <span class="title function_">worker_function</span>(<span class="params">name, age</span>):</span><br><span class="line">    <span class="string">&quot;&quot;&quot;子进程执行过程中触发的函数&quot;&quot;&quot;</span></span><br><span class="line">    <span class="built_in">print</span>(<span class="string">f&quot;子进程ID:<span class="subst">&#123;os.getpid()&#125;</span>,父进程ID<span class="subst">&#123;os.getppid()&#125;</span>&quot;</span>)</span><br><span class="line">    <span class="built_in">print</span>(<span class="string">f&quot;子进程正在执行，参数name=<span class="subst">&#123;name&#125;</span>,age=<span class="subst">&#123;age&#125;</span>&quot;</span>)</span><br><span class="line">    <span class="comment"># 模拟耗时操作</span></span><br><span class="line">    time.sleep(<span class="number">5</span>)</span><br><span class="line">    <span class="built_in">print</span>(<span class="string">f&quot;子进程<span class="subst">&#123;name&#125;</span>执行完毕&quot;</span>)</span><br><span class="line"></span><br><span class="line"></span><br><span class="line"><span class="keyword">def</span> <span class="title function_">cpu_intensive_task</span>(<span class="params">number</span>):</span><br><span class="line">    <span class="string">&quot;&quot;&quot;CPU密集型任务&quot;&quot;&quot;</span></span><br><span class="line">    result = <span class="number">0</span></span><br><span class="line">    <span class="keyword">for</span> i <span class="keyword">in</span> <span class="built_in">range</span>(number):</span><br><span class="line">        result += i * i</span><br><span class="line">    <span class="built_in">print</span>(<span class="string">f&quot;进程<span class="subst">&#123;os.getpid()&#125;</span> 计算完成，结果为<span class="subst">&#123;result&#125;</span>&quot;</span>)</span><br><span class="line"></span><br><span class="line"></span><br><span class="line"><span class="keyword">if</span> __name__ == <span class="string">&#x27;__main__&#x27;</span>:</span><br><span class="line">    <span class="built_in">print</span>(<span class="string">f&quot;父进程ID:<span class="subst">&#123;os.getpid()&#125;</span>&quot;</span>)</span><br><span class="line">    start_time = time.time()</span><br><span class="line"></span><br><span class="line">    <span class="comment"># 创建多个进程，体现并行处理能力</span></span><br><span class="line">    processes = []</span><br><span class="line"></span><br><span class="line">    <span class="comment"># 创建四个检测执行不同人物</span></span><br><span class="line">    p1 = Process(target=worker_function, args=(<span class="string">&quot;张三&quot;</span>,), kwargs=&#123;<span class="string">&quot;age&quot;</span>: <span class="number">20</span>&#125;)</span><br><span class="line">    p2 = Process(target=worker_function, args=(<span class="string">&quot;李四&quot;</span>,), kwargs=&#123;<span class="string">&quot;age&quot;</span>: <span class="number">30</span>&#125;)</span><br><span class="line">    p3 = Process(target=cpu_intensive_task, args=(<span class="number">10000</span>,))</span><br><span class="line">    p4 = Process(target=cpu_intensive_task, args=(<span class="number">20000</span>,))</span><br><span class="line"></span><br><span class="line">    processes.extend([p1, p2, p3, p4]) <span class="comment"># 将进程添加到列表中</span></span><br><span class="line"></span><br><span class="line">    <span class="keyword">for</span> p <span class="keyword">in</span> processes:</span><br><span class="line">        p.start() <span class="comment"># 启动进程</span></span><br><span class="line"></span><br><span class="line">    <span class="comment"># 等待所有进程结束</span></span><br><span class="line">    <span class="keyword">for</span> p <span class="keyword">in</span> processes:</span><br><span class="line">        p.join()</span><br><span class="line"></span><br><span class="line">    end_time = time.time()</span><br><span class="line">    <span class="built_in">print</span>(<span class="string">f&quot;所有进程执行完毕，总耗时<span class="subst">&#123;end_time - start_time:<span class="number">.2</span>f&#125;</span>秒&quot;</span>)</span><br><span class="line">    <span class="built_in">print</span>(<span class="string">&quot;如果使用单进程顺序执行，耗时会更长，因为是两个任务在执行，多进程可以充分利用多核CPU并行处理任务&quot;</span>)</span><br><span class="line"></span><br></pre></td></tr></table></figure><blockquote><p>⚠️ <strong>重要提示</strong>：在 Windows 系统中，必须在 <code>if __name__ == '__main__'</code> 条件下创建进程，这是因为 Windows 使用 spawn 方式创建进程，会重新导入模块，可能导致递归创建进程。</p></blockquote><h5 id="方法二：继承-Process-类创建进程">方法二：继承 Process 类创建进程</h5><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></pre></td><td class="code"><pre><span class="line"><span class="keyword">from</span> multiprocessing <span class="keyword">import</span> Process</span><br><span class="line"><span class="keyword">import</span> time</span><br><span class="line"><span class="keyword">import</span> os</span><br><span class="line"></span><br><span class="line"></span><br><span class="line"><span class="comment"># 通过继承Process类创建进程</span></span><br><span class="line"><span class="keyword">class</span> <span class="title class_">WorkerProcess</span>(<span class="title class_ inherited__">Process</span>):</span><br><span class="line">    <span class="string">&quot;&quot;&quot;继承Process类的自定义进程&quot;&quot;&quot;</span></span><br><span class="line">    <span class="keyword">def</span> <span class="title function_">__init__</span>(<span class="params">self, name, age=<span class="literal">None</span></span>):</span><br><span class="line">        <span class="built_in">super</span>().__init__()</span><br><span class="line">        <span class="variable language_">self</span>.name = name</span><br><span class="line">        <span class="variable language_">self</span>.age = age</span><br><span class="line">    </span><br><span class="line">    <span class="keyword">def</span> <span class="title function_">run</span>(<span class="params">self</span>):</span><br><span class="line">        <span class="string">&quot;&quot;&quot;重写run方法，进程启动后会执行该方法&quot;&quot;&quot;</span></span><br><span class="line">        <span class="built_in">print</span>(<span class="string">f&quot;子进程ID:<span class="subst">&#123;os.getpid()&#125;</span>,父进程ID<span class="subst">&#123;os.getppid()&#125;</span>&quot;</span>)</span><br><span class="line">        <span class="built_in">print</span>(<span class="string">f&quot;子进程正在执行，参数name=<span class="subst">&#123;self.name&#125;</span>,age=<span class="subst">&#123;self.age&#125;</span>&quot;</span>)</span><br><span class="line">        <span class="comment"># 模拟耗时操作</span></span><br><span class="line">        time.sleep(<span class="number">5</span>)</span><br><span class="line">        <span class="built_in">print</span>(<span class="string">f&quot;子进程<span class="subst">&#123;self.name&#125;</span>执行完毕&quot;</span>)</span><br><span class="line"></span><br><span class="line"></span><br><span class="line"><span class="keyword">class</span> <span class="title class_">CPUIntensiveProcess</span>(<span class="title class_ inherited__">Process</span>):</span><br><span class="line">    <span class="string">&quot;&quot;&quot;CPU密集型任务进程&quot;&quot;&quot;</span></span><br><span class="line">    <span class="keyword">def</span> <span class="title function_">__init__</span>(<span class="params">self, number</span>):</span><br><span class="line">        <span class="built_in">super</span>().__init__()</span><br><span class="line">        <span class="variable language_">self</span>.number = number</span><br><span class="line">    </span><br><span class="line">    <span class="keyword">def</span> <span class="title function_">run</span>(<span class="params">self</span>):</span><br><span class="line">        <span class="string">&quot;&quot;&quot;重写run方法，执行CPU密集型计算&quot;&quot;&quot;</span></span><br><span class="line">        result = <span class="number">0</span></span><br><span class="line">        <span class="keyword">for</span> i <span class="keyword">in</span> <span class="built_in">range</span>(<span class="variable language_">self</span>.number):</span><br><span class="line">            result += i * i</span><br><span class="line">        <span class="built_in">print</span>(<span class="string">f&quot;进程<span class="subst">&#123;os.getpid()&#125;</span> 计算完成，结果为<span class="subst">&#123;result&#125;</span>&quot;</span>)</span><br><span class="line"></span><br><span class="line"></span><br><span class="line"><span class="keyword">if</span> __name__ == <span class="string">&#x27;__main__&#x27;</span>:</span><br><span class="line">    <span class="built_in">print</span>(<span class="string">f&quot;父进程ID:<span class="subst">&#123;os.getpid()&#125;</span>&quot;</span>)</span><br><span class="line">    start_time = time.time()</span><br><span class="line"></span><br><span class="line">    <span class="comment"># 创建多个进程，体现并行处理能力</span></span><br><span class="line">    processes = []</span><br><span class="line"></span><br><span class="line">    <span class="comment"># 创建四个进程实例</span></span><br><span class="line">    p1 = WorkerProcess(<span class="string">&quot;张三&quot;</span>, <span class="number">20</span>)</span><br><span class="line">    p2 = WorkerProcess(<span class="string">&quot;李四&quot;</span>, <span class="number">30</span>)</span><br><span class="line">    p3 = CPUIntensiveProcess(<span class="number">10000</span>)</span><br><span class="line">    p4 = CPUIntensiveProcess(<span class="number">20000</span>)</span><br><span class="line"></span><br><span class="line">    processes.extend([p1, p2, p3, p4]) <span class="comment"># 将进程添加到列表中</span></span><br><span class="line"></span><br><span class="line">    <span class="keyword">for</span> p <span class="keyword">in</span> processes:</span><br><span class="line">        p.start() <span class="comment"># 启动进程</span></span><br><span class="line"></span><br><span class="line">    <span class="comment"># 等待所有进程结束</span></span><br><span class="line">    <span class="keyword">for</span> p <span class="keyword">in</span> processes:</span><br><span class="line">        p.join()</span><br><span class="line"></span><br><span class="line">    end_time = time.time()</span><br><span class="line">    <span class="built_in">print</span>(<span class="string">f&quot;所有进程执行完毕，总耗时<span class="subst">&#123;end_time - start_time:<span class="number">.2</span>f&#125;</span>秒&quot;</span>)</span><br></pre></td></tr></table></figure><h4 id="多进程常用方法表">多进程常用方法表</h4><table><thead><tr><th>方法名</th><th>说明</th><th>实际应用场景</th></tr></thead><tbody><tr><td><code>Process(target=...)</code></td><td>创建进程对象</td><td>指定新进程要执行的函数</td></tr><tr><td><code>start()</code></td><td>启动进程</td><td>开始执行进程的任务</td></tr><tr><td><code>join()</code></td><td>等待进程结束</td><td>协调多个进程的执行顺序</td></tr><tr><td><code>is_alive()</code></td><td>检查进程是否存活</td><td>监控进程状态</td></tr><tr><td><code>terminate()</code></td><td>强制终止进程</td><td>中断异常或超时的进程</td></tr><tr><td><code>Queue()</code></td><td>创建进程安全的队列</td><td>进程间数据传递</td></tr><tr><td><code>put(item)</code></td><td>添加元素到队列</td><td>向队列中放入数据</td></tr><tr><td><code>get()</code></td><td>从队列获取元素</td><td>从队列获取数据</td></tr><tr><td><code>Pipe()</code></td><td>创建管道对象</td><td>进程间双向通信</td></tr></tbody></table><h4 id="进程号与进程信息获取">进程号与进程信息获取</h4><p>在多进程编程中，获取进程信息对于调试和管理至关重要。Python 的 <code>multiprocessing</code> 模块提供了 <code>current_process()</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></pre></td><td class="code"><pre><span class="line"><span class="keyword">import</span> multiprocessing</span><br><span class="line"><span class="keyword">import</span> os</span><br><span class="line"></span><br><span class="line"><span class="keyword">def</span> <span class="title function_">process_info</span>():</span><br><span class="line">    <span class="string">&quot;&quot;&quot;打印当前进程的信息&quot;&quot;&quot;</span></span><br><span class="line">    <span class="comment"># 获取当前进程对象</span></span><br><span class="line">    process = multiprocessing.current_process()</span><br><span class="line">    </span><br><span class="line">    <span class="built_in">print</span>(<span class="string">f&quot;进程名称: <span class="subst">&#123;process.name&#125;</span>&quot;</span>)</span><br><span class="line">    <span class="built_in">print</span>(<span class="string">f&quot;进程ID: <span class="subst">&#123;process.pid&#125;</span>&quot;</span>)</span><br><span class="line">    <span class="built_in">print</span>(<span class="string">f&quot;父进程ID: <span class="subst">&#123;os.getppid()&#125;</span>&quot;</span>)</span><br><span class="line">    <span class="built_in">print</span>(<span class="string">f&quot;进程授权键: <span class="subst">&#123;process.authkey&#125;</span>&quot;</span>)</span><br><span class="line">    <span class="built_in">print</span>(<span class="string">f&quot;进程是否活跃: <span class="subst">&#123;process.is_alive()&#125;</span>&quot;</span>)</span><br><span class="line">    </span><br><span class="line"><span class="keyword">if</span> __name__ == <span class="string">&#x27;__main__&#x27;</span>:</span><br><span class="line">    p = multiprocessing.Process(target=process_info, name=<span class="string">&quot;自定义进程名&quot;</span>)</span><br><span class="line">    p.start()</span><br><span class="line">    p.join()</span><br></pre></td></tr></table></figure><h4 id="进程间通信示例">进程间通信示例</h4><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></pre></td><td class="code"><pre><span class="line"><span class="keyword">from</span> multiprocessing <span class="keyword">import</span> Process,Queue</span><br><span class="line"><span class="keyword">import</span> time</span><br><span class="line"></span><br><span class="line"><span class="comment">## 子进程执行的函数</span></span><br><span class="line"><span class="keyword">def</span> <span class="title function_">worker</span>(<span class="params">q</span>):</span><br><span class="line">    <span class="string">&quot;&quot;&quot;子进程函数，想往父进程发送消息，就往这个队列里放&quot;&quot;&quot;</span></span><br><span class="line">    <span class="built_in">print</span>(<span class="string">&quot;子进程启动&quot;</span>)</span><br><span class="line">    <span class="comment"># 向队列中添加数据</span></span><br><span class="line">    q.put(<span class="string">&quot;我是一个队列数据&quot;</span>)</span><br><span class="line">    time.sleep(<span class="number">2</span>) <span class="comment"># 模拟任务执行</span></span><br><span class="line">    <span class="built_in">print</span>(<span class="string">&quot;子进程结束&quot;</span>)</span><br><span class="line"></span><br><span class="line"><span class="keyword">if</span> __name__ == <span class="string">&#x27;__main__&#x27;</span>:</span><br><span class="line">    q = Queue() <span class="comment"># 父进程创建队列</span></span><br><span class="line">    <span class="comment"># 创建一个子进程对象</span></span><br><span class="line">    p = Process(target=worker, args=(q,))</span><br><span class="line">    <span class="comment"># 启动子进程</span></span><br><span class="line">    p.start()</span><br><span class="line">    <span class="comment"># 主进程从队列中获取数据</span></span><br><span class="line">    <span class="built_in">print</span>(<span class="string">&quot;主进程等待子进程数据....&quot;</span>)</span><br><span class="line">    message = q.get() <span class="comment"># 阻塞等待数据</span></span><br><span class="line">    <span class="built_in">print</span>(<span class="string">f&quot;主进程收到来自于子进程的消息：<span class="subst">&#123;message&#125;</span>&quot;</span>)</span><br><span class="line">    <span class="comment"># 等待子进程结束</span></span><br><span class="line">    p.join()</span><br><span class="line">    <span class="built_in">print</span>(<span class="string">&quot;主进程结束&quot;</span>)</span><br></pre></td></tr></table></figure><h4 id="复杂进程通信示例">复杂进程通信示例</h4><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></pre></td><td class="code"><pre><span class="line"><span class="keyword">import</span> os</span><br><span class="line"><span class="keyword">import</span> time</span><br><span class="line"><span class="keyword">from</span> multiprocessing <span class="keyword">import</span> Process, Queue, Lock, Value, Array</span><br><span class="line"></span><br><span class="line"></span><br><span class="line"><span class="comment">## 子进程执行的函数</span></span><br><span class="line"><span class="keyword">def</span> <span class="title function_">worker_with_args</span>(<span class="params">q, lock, value, arr, sleep_num</span>):</span><br><span class="line">    <span class="string">&quot;&quot;&quot;带有共享资源的工作函数</span></span><br><span class="line"><span class="string">    Args:</span></span><br><span class="line"><span class="string">        q: 队列</span></span><br><span class="line"><span class="string">        lock: 锁</span></span><br><span class="line"><span class="string">        value: 值</span></span><br><span class="line"><span class="string">        arr: 数组</span></span><br><span class="line"><span class="string">        sleep_num: 休眠时间</span></span><br><span class="line"><span class="string">    &quot;&quot;&quot;</span></span><br><span class="line">    <span class="comment"># 获取进程id</span></span><br><span class="line">    pid = os.getpid()</span><br><span class="line">    <span class="built_in">print</span>(<span class="string">f&quot;进程<span class="subst">&#123;pid&#125;</span>开始执行&quot;</span>)</span><br><span class="line"></span><br><span class="line">    <span class="comment"># 使用锁来保证数据安全</span></span><br><span class="line">    <span class="keyword">with</span> lock:  <span class="comment"># 相当于lock.acquire()和lock.release()的组合</span></span><br><span class="line">        value.value += <span class="number">1</span></span><br><span class="line">        <span class="keyword">for</span> i <span class="keyword">in</span> <span class="built_in">range</span>(<span class="built_in">len</span>(arr)):</span><br><span class="line">            arr[i] **= <span class="number">2</span>  <span class="comment"># 安全的修改数组元素</span></span><br><span class="line">            <span class="built_in">print</span>(<span class="string">f&quot;进程<span class="subst">&#123;pid&#125;</span>修改数组元素<span class="subst">&#123;i&#125;</span>为<span class="subst">&#123;arr[i]&#125;</span>&quot;</span>)</span><br><span class="line">    <span class="comment"># 向队列中添加数据</span></span><br><span class="line">    q.put(<span class="string">f&quot;这是一条来自于进程<span class="subst">&#123;pid&#125;</span>的信息&quot;</span>)</span><br><span class="line"></span><br><span class="line">    <span class="comment"># 休眠模拟工作</span></span><br><span class="line">    time.sleep(sleep_num)</span><br><span class="line"></span><br><span class="line">    <span class="built_in">print</span>(<span class="string">f&quot;进程<span class="subst">&#123;pid&#125;</span>执行完毕&quot;</span>)</span><br><span class="line"></span><br><span class="line"></span><br><span class="line"><span class="keyword">if</span> __name__ == <span class="string">&quot;__main__&quot;</span>:</span><br><span class="line">    <span class="string">&quot;&quot;&quot;</span></span><br><span class="line"><span class="string">    解释输出逻辑:</span></span><br><span class="line"><span class="string">    1. 创建了4个进程，它们共享同一个数组arr，初始值为[1,2,3,4,5]</span></span><br><span class="line"><span class="string">    2. 每个进程获取锁后，会将数组中的每个元素进行平方操作(arr[i] **= 2)</span></span><br><span class="line"><span class="string">    3. 由于进程是按顺序启动的，但执行顺序不确定，所以:</span></span><br><span class="line"><span class="string">       - 第一个获得锁的进程将[1,2,3,4,5]平方为[1,4,9,16,25]</span></span><br><span class="line"><span class="string">       - 第二个获得锁的进程将[1,4,9,16,25]平方为[1,16,81,256,625]</span></span><br><span class="line"><span class="string">       - 第三个获得锁的进程将[1,16,81,256,625]平方为[1,256,6561,65536,390625]</span></span><br><span class="line"><span class="string">       - 第四个获得锁的进程将[1,256,6561,65536,390625]平方，但由于整数溢出，</span></span><br><span class="line"><span class="string">         导致最后两个元素变成了0和负数</span></span><br><span class="line"><span class="string">    4. 进程完成的顺序取决于sleep_num参数(1,2,3,4)，所以最先完成的是第一个进程</span></span><br><span class="line"><span class="string">    &quot;&quot;&quot;</span></span><br><span class="line">    <span class="comment"># 创建队列</span></span><br><span class="line">    q = Queue()</span><br><span class="line">    <span class="comment"># 创建锁</span></span><br><span class="line">    lock = Lock()</span><br><span class="line">    <span class="comment"># 创建一个共享值</span></span><br><span class="line">    value = Value(<span class="string">&quot;i&quot;</span>, <span class="number">0</span>)  <span class="comment"># &quot;i&quot;表示int类型</span></span><br><span class="line"></span><br><span class="line">    <span class="comment"># 创建一个共享数组</span></span><br><span class="line">    arr = Array(<span class="string">&quot;i&quot;</span>, [<span class="number">1</span>, <span class="number">2</span>, <span class="number">3</span>, <span class="number">4</span>, <span class="number">5</span>])</span><br><span class="line"></span><br><span class="line">    <span class="comment"># 创建多个子进程</span></span><br><span class="line">    processes = []</span><br><span class="line">    <span class="keyword">for</span> i <span class="keyword">in</span> <span class="built_in">range</span>(<span class="number">4</span>):</span><br><span class="line">        p = Process(target=worker_with_args, args=(q, lock, value, arr, i + <span class="number">1</span>))</span><br><span class="line">        p.start()</span><br><span class="line">        processes.extend([p])</span><br></pre></td></tr></table></figure><h4 id="进程池详解">进程池详解</h4><p>进程池是一种管理多个进程的方式，可以简化并行计算的编程。Python 的 <code>multiprocessing</code> 模块中的 <code>Pool</code> 类和 <code>concurrent.futures</code> 模块的 <code>ProcessPoolExecutor</code> 类都提供了进程池功能。</p><h5 id="进程池的主要方法">进程池的主要方法</h5><table><thead><tr><th>方法</th><th>描述</th><th>使用场景</th></tr></thead><tbody><tr><td><code>Pool(processes=None)</code></td><td>创建进程池，进程数默认为 CPU 核数</td><td>初始化进程池</td></tr><tr><td><code>apply(func, args)</code></td><td>阻塞执行任务</td><td>需要顺序执行且等待结果的场景</td></tr><tr><td><code>apply_async(func, args)</code></td><td>非阻塞执行任务</td><td>需要异步执行的场景</td></tr><tr><td><code>map(func, iterable)</code></td><td>并行执行映射任务</td><td>对列表元素并行处理</td></tr><tr><td><code>close()</code></td><td>关闭进程池，不再接受新任务</td><td>完成任务提交后</td></tr><tr><td><code>terminate()</code></td><td>立即终止所有工作进程</td><td>需要强制停止时</td></tr><tr><td><code>join()</code></td><td>等待所有工作进程退出</td><td>在 close()后使用</td></tr></tbody></table><h5 id="ProcessPoolExecutor-示例">ProcessPoolExecutor 示例</h5><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></pre></td><td class="code"><pre><span class="line"><span class="keyword">from</span> concurrent.futures <span class="keyword">import</span> ProcessPoolExecutor</span><br><span class="line"><span class="keyword">import</span> os</span><br><span class="line"></span><br><span class="line"><span class="keyword">def</span> <span class="title function_">worker_function</span>(<span class="params">name,age</span>):</span><br><span class="line">    <span class="string">&quot;&quot;&quot;进程池工作函数&quot;&quot;&quot;</span></span><br><span class="line">    pid = os.getpid()</span><br><span class="line">    <span class="built_in">print</span>(<span class="string">f&quot;进程<span class="subst">&#123;pid&#125;</span>：<span class="subst">&#123;name&#125;</span>，<span class="subst">&#123;age&#125;</span>岁&quot;</span>)</span><br><span class="line">    <span class="keyword">return</span> <span class="string">f&quot;我的父进程是<span class="subst">&#123;os.getppid()&#125;</span> 我结束进程了&quot;</span></span><br><span class="line"></span><br><span class="line"><span class="keyword">if</span> __name__ == <span class="string">&#x27;__main__&#x27;</span>:</span><br><span class="line">    <span class="keyword">with</span> ProcessPoolExecutor(max_workers=<span class="number">4</span>) <span class="keyword">as</span> executor:</span><br><span class="line">        <span class="keyword">for</span> i <span class="keyword">in</span> <span class="built_in">range</span>(<span class="number">10</span>):</span><br><span class="line">            <span class="comment"># submit方法提交任务到进程池，返回一个Future对象</span></span><br><span class="line">            <span class="comment"># submit参数: function, *args, **kwargs</span></span><br><span class="line">            <span class="comment"># 这里的i作为worker_function函数的第二个参数age传入</span></span><br><span class="line">            future = executor.submit(worker_function, <span class="string">f&quot;小明<span class="subst">&#123;i&#125;</span>&quot;</span>, i)</span><br><span class="line">            <span class="comment"># 等待future对象返回结果</span></span><br><span class="line">            result = future.result()</span><br><span class="line">            <span class="built_in">print</span>(result)</span><br></pre></td></tr></table></figure><p>Pool 对象示例</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></pre></td><td class="code"><pre><span class="line"><span class="keyword">from</span> multiprocessing <span class="keyword">import</span> Pool</span><br><span class="line"><span class="keyword">import</span> os</span><br><span class="line"></span><br><span class="line"><span class="keyword">def</span> <span class="title function_">worker_function</span>(<span class="params">args</span>):</span><br><span class="line">    <span class="string">&quot;&quot;&quot;工作进程函数&quot;&quot;&quot;</span></span><br><span class="line">    name,age = args</span><br><span class="line">    pid = os.getpid()</span><br><span class="line">    <span class="built_in">print</span>(<span class="string">f&quot;进程<span class="subst">&#123;pid&#125;</span>：<span class="subst">&#123;name&#125;</span>，<span class="subst">&#123;age&#125;</span>岁&quot;</span>)</span><br><span class="line">    <span class="keyword">return</span> <span class="string">f&quot;我的父进程是<span class="subst">&#123;os.getppid()&#125;</span> 我结束进程了&quot;</span></span><br><span class="line"></span><br><span class="line"><span class="keyword">if</span> __name__ == <span class="string">&#x27;__main__&#x27;</span>:</span><br><span class="line">    <span class="keyword">with</span> Pool() <span class="keyword">as</span> pool:</span><br><span class="line">        <span class="comment"># 准备参数列表</span></span><br><span class="line">        args_list = [(<span class="string">f&quot;张三<span class="subst">&#123;i&#125;</span>号&quot;</span>, i+<span class="number">18</span>) <span class="keyword">for</span> i <span class="keyword">in</span> <span class="built_in">range</span>(<span class="number">1</span>,<span class="number">10</span>)]</span><br><span class="line">        </span><br><span class="line">        <span class="comment"># map方法将函数应用于参数列表，并返回结果列表</span></span><br><span class="line">        <span class="comment"># pool.map 的工作原理:</span></span><br><span class="line">        <span class="comment"># 1. 它接收两个参数：要执行的函数(worker_function)和可迭代的参数列表(args_list)</span></span><br><span class="line">        <span class="comment"># 2. 它会自动将参数列表中的每个元素分配给不同的进程来执行</span></span><br><span class="line">        <span class="comment"># 3. 每个进程会调用worker_function并传入args_list中的一个元素作为参数</span></span><br><span class="line">        <span class="comment"># 4. 所有进程执行完毕后，map会收集所有进程的返回值，并按原始参数的顺序返回结果列表</span></span><br><span class="line">        <span class="comment"># 5. 这样实现了并行处理，提高了计算效率</span></span><br><span class="line">        result_list = pool.<span class="built_in">map</span>(worker_function, args_list)</span><br><span class="line">        </span><br><span class="line">        <span class="comment"># 打印每个进程的返回结果</span></span><br><span class="line">        <span class="keyword">for</span> result <span class="keyword">in</span> result_list:</span><br><span class="line">            <span class="built_in">print</span>(result)</span><br></pre></td></tr></table></figure><h4 id="进程号与进程信息获取-2">进程号与进程信息获取</h4><p>在多进程编程中，获取进程信息对于调试和管理至关重要。Python 的 <code>multiprocessing</code> 模块提供了 <code>current_process()</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></pre></td><td class="code"><pre><span class="line"><span class="keyword">import</span> multiprocessing</span><br><span class="line"><span class="keyword">import</span> os</span><br><span class="line"></span><br><span class="line"><span class="keyword">def</span> <span class="title function_">process_info</span>():</span><br><span class="line">    <span class="string">&quot;&quot;&quot;打印当前进程的信息&quot;&quot;&quot;</span></span><br><span class="line">    <span class="comment"># 获取当前进程对象</span></span><br><span class="line">    process = multiprocessing.current_process()</span><br><span class="line">    </span><br><span class="line">    <span class="built_in">print</span>(<span class="string">f&quot;进程名称: <span class="subst">&#123;process.name&#125;</span>&quot;</span>)</span><br><span class="line">    <span class="built_in">print</span>(<span class="string">f&quot;进程ID: <span class="subst">&#123;process.pid&#125;</span>&quot;</span>)</span><br><span class="line">    <span class="built_in">print</span>(<span class="string">f&quot;父进程ID: <span class="subst">&#123;os.getppid()&#125;</span>&quot;</span>)</span><br><span class="line">    <span class="built_in">print</span>(<span class="string">f&quot;进程授权键: <span class="subst">&#123;process.authkey&#125;</span>&quot;</span>) <span class="comment"># 授权键用于在进程间通信时进行身份验证。</span></span><br><span class="line">    <span class="built_in">print</span>(<span class="string">f&quot;进程是否活跃: <span class="subst">&#123;process.is_alive()&#125;</span>&quot;</span>)</span><br><span class="line">    </span><br><span class="line"><span class="keyword">if</span> __name__ == <span class="string">&#x27;__main__&#x27;</span>:</span><br><span class="line">    p = multiprocessing.Process(target=process_info, name=<span class="string">&quot;自定义进程名&quot;</span>)</span><br><span class="line">    p.start()</span><br><span class="line">    p.join()</span><br></pre></td></tr></table></figure><h5 id="进程状态特殊情况">进程状态特殊情况</h5><h6 id="僵尸进程">僵尸进程</h6><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></pre></td><td class="code"><pre><span class="line"><span class="string">&#x27;&#x27;&#x27;</span></span><br><span class="line"><span class="string">子进程死后还会有一些资源占用(进程号，进程的运行状态，运行时间)，等待父进程通过系统调用</span></span><br><span class="line"><span class="string">进行资源回收</span></span><br><span class="line"><span class="string"></span></span><br><span class="line"><span class="string">相当于子进程死了之后，需要父进程来给他&quot;收尸&quot;</span></span><br><span class="line"><span class="string">除了init进程之外，所有的进程最后都会步入僵尸进程</span></span><br><span class="line"><span class="string">在一种情况下是会带来危害的:</span></span><br><span class="line"><span class="string">子进程退出之后，父进程没有及时处理，僵尸进程就会一直占用资源</span></span><br><span class="line"><span class="string">如果产生了大量僵尸进程，资源过度使用，系统没有可用的进程号，导致系统不能产生新的进程</span></span><br><span class="line"><span class="string">&#x27;&#x27;&#x27;</span></span><br></pre></td></tr></table></figure><blockquote><p>注意：在 Windows 中，子进程退出后会立即被系统回收，不会产生真正的僵尸进程，在 Windows 系统中，不需要显式调用 wait 来回收子进程资源</p></blockquote><h6 id="孤儿进程">孤儿进程</h6><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></pre></td><td class="code"><pre><span class="line"><span class="string">&#x27;&#x27;&#x27;</span></span><br><span class="line"><span class="string">子进程处于存活状态，父进程意外死亡，操作系统就会开设一个孤儿院（init进程），用来管理</span></span><br><span class="line"><span class="string">孤儿进程，回收孤儿进程相关资源</span></span><br><span class="line"><span class="string">&#x27;&#x27;&#x27;</span></span><br></pre></td></tr></table></figure><blockquote><p>📝 <strong>知识点</strong>：操作系统会自动处理孤儿进程，将它们的父进程更改为 init 进程（PID 为 1），所以孤儿进程不会造成资源泄漏问题。</p></blockquote><h3 id="14-3-多线程编程深入解析">14.3 多线程编程深入解析</h3><h4 id="线程基础">线程基础</h4><p><strong>线程</strong> 是轻量级的进程，也是多任务编程的一种方式：</p><ul><li>一个进程中可以包含多个线程</li><li>线程也是一个运行行为，消耗计算机资源</li><li>一个进程中的所有线程共享这个进程的资源</li><li>线程的创建和销毁消耗资源远小于进程</li></ul><p>一个工厂至少有一个车间，一个车间中至少有一个工人，工人去利用车间的设备工作；</p><p>一个程序至少有一个进程，一个进程中至少有一个线程，线程去利用进程的资源工作。</p><h4 id="线程创建方法">线程创建方法</h4><h5 id="方法一：使用-Thread-类创建线程">方法一：使用 Thread 类创建线程</h5><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></pre></td><td class="code"><pre><span class="line"><span class="keyword">from</span> threading <span class="keyword">import</span> Thread</span><br><span class="line"><span class="keyword">import</span> time</span><br><span class="line"></span><br><span class="line"><span class="keyword">def</span> <span class="title function_">worker_function</span>(<span class="params">name,delay</span>):</span><br><span class="line">    <span class="built_in">print</span>(<span class="string">f&quot;线程<span class="subst">&#123;name&#125;</span>开始工作&quot;</span>)</span><br><span class="line">    time.sleep(delay)</span><br><span class="line">    <span class="built_in">print</span>(<span class="string">f&quot;线程<span class="subst">&#123;name&#125;</span>结束工作&quot;</span>)</span><br><span class="line"></span><br><span class="line"><span class="keyword">if</span> __name__ == <span class="string">&#x27;__main__&#x27;</span>:</span><br><span class="line">    t1 = Thread(target=worker_function,args=(<span class="string">&quot;线程1&quot;</span>,<span class="number">2</span>))</span><br><span class="line">    t2 = Thread(target=worker_function,args=(<span class="string">&quot;线程2&quot;</span>,<span class="number">4</span>))</span><br><span class="line">    <span class="comment"># 启动线程</span></span><br><span class="line">    t1.start()</span><br><span class="line">    t2.start()</span><br><span class="line">    <span class="comment"># 等待线程结束</span></span><br><span class="line">    t1.join()</span><br><span class="line">    t2.join()</span><br><span class="line">    <span class="built_in">print</span>(<span class="string">&quot;主线程结束&quot;</span>)</span><br></pre></td></tr></table></figure><h5 id="方法二：继承-Thread-类创建线程">方法二：继承 Thread 类创建线程</h5><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></pre></td><td class="code"><pre><span class="line"><span class="keyword">from</span> threading <span class="keyword">import</span> Thread</span><br><span class="line"><span class="keyword">import</span> time</span><br><span class="line"><span class="keyword">class</span> <span class="title class_">MyThread</span>(<span class="title class_ inherited__">Thread</span>):</span><br><span class="line">    <span class="keyword">def</span> <span class="title function_">__init__</span>(<span class="params">self,name,message</span>):</span><br><span class="line">        <span class="built_in">super</span>().__init__()</span><br><span class="line">        <span class="variable language_">self</span>.name = name</span><br><span class="line">        <span class="variable language_">self</span>.message = message</span><br><span class="line"></span><br><span class="line">    <span class="keyword">def</span> <span class="title function_">run</span>(<span class="params">self</span>):</span><br><span class="line">        <span class="built_in">print</span>(<span class="string">f&quot;线程<span class="subst">&#123;self.name&#125;</span>开始执行&quot;</span>)</span><br><span class="line">        time.sleep(<span class="number">2</span>)</span><br><span class="line">        <span class="built_in">print</span>(<span class="string">f&quot;线程<span class="subst">&#123;self.name&#125;</span>执行完毕，消息：<span class="subst">&#123;self.message&#125;</span>&quot;</span>)</span><br><span class="line"></span><br><span class="line"><span class="keyword">if</span> __name__ == <span class="string">&#x27;__main__&#x27;</span>:</span><br><span class="line">    t1 = MyThread(name=<span class="string">&quot;线程1&quot;</span>,message=<span class="string">&quot;子进程操作完毕&quot;</span>)</span><br><span class="line">    t2 = MyThread(name=<span class="string">&quot;线程2&quot;</span>,message=<span class="string">&quot;子进程操作完毕&quot;</span>)</span><br><span class="line">    t1.start()</span><br><span class="line">    t2.start()</span><br><span class="line">    t1.join()</span><br><span class="line">    t2.join()</span><br><span class="line">    <span class="built_in">print</span>(<span class="string">&quot;主进程执行完毕&quot;</span>)</span><br></pre></td></tr></table></figure><h4 id="线程常用方法表">线程常用方法表</h4><table><thead><tr><th>方法名</th><th>说明</th><th>实际应用场景</th></tr></thead><tbody><tr><td><code>start()</code></td><td>启动线程</td><td>开始执行线程任务</td></tr><tr><td><code>run()</code></td><td>定义线程执行的任务</td><td>重写该方法自定义线程行为</td></tr><tr><td><code>join()</code></td><td>等待线程结束</td><td>协调线程执行顺序</td></tr><tr><td><code>join(timeout)</code></td><td>等待线程结束，有超时时间</td><td>防止无限等待</td></tr><tr><td><code>is_alive()</code></td><td>检查线程是否活动</td><td>监控线程状态</td></tr><tr><td><code>getName()</code></td><td>获取线程名称</td><td>调试和日志记录</td></tr><tr><td><code>setName(name)</code></td><td>设置线程名称</td><td>便于识别不同线程</td></tr><tr><td><code>setDaemon(T/F)</code></td><td>设置为守护线程</td><td>随主线程结束而结束的后台任务</td></tr><tr><td><code>isDaemon()</code></td><td>检查是否为守护线程</td><td>确认线程类型</td></tr><tr><td><code>getId()</code></td><td>获取线程 ID</td><td>唯一标识线程</td></tr><tr><td><code>current_thread</code></td><td>获取当前线程对象</td><td>在函数中获取当前执行线程</td></tr></tbody></table><h4 id="线程使用实例">线程使用实例</h4><h5 id="基本线程示例">基本线程示例</h5><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></pre></td><td class="code"><pre><span class="line"><span class="keyword">from</span> threading <span class="keyword">import</span> Thread</span><br><span class="line"><span class="keyword">import</span> time</span><br><span class="line"><span class="keyword">import</span> requests</span><br><span class="line"></span><br><span class="line"><span class="keyword">def</span> <span class="title function_">download_file</span>(<span class="params">url,session</span>):</span><br><span class="line">    <span class="string">&quot;&quot;&quot;访问网站下载文件&quot;&quot;&quot;</span></span><br><span class="line">    <span class="keyword">with</span> session.get(url) <span class="keyword">as</span> response:</span><br><span class="line">        <span class="built_in">print</span>(<span class="string">f&quot;读取<span class="subst">&#123;url&#125;</span> 长度为<span class="subst">&#123;<span class="built_in">len</span>(response.content)&#125;</span>&quot;</span>)</span><br><span class="line"></span><br><span class="line"></span><br><span class="line"><span class="keyword">def</span> <span class="title function_">download_all_sites</span>(<span class="params">urls</span>):</span><br><span class="line">    <span class="string">&quot;&quot;&quot;单线程下载所有网站&quot;&quot;&quot;</span></span><br><span class="line">    <span class="keyword">with</span> requests.Session() <span class="keyword">as</span> session:</span><br><span class="line">        <span class="keyword">for</span> url <span class="keyword">in</span> urls:</span><br><span class="line">            download_file(url,session)</span><br><span class="line">            time.sleep(<span class="number">1</span>)</span><br><span class="line"></span><br><span class="line"><span class="keyword">def</span> <span class="title function_">download_site_thread</span>(<span class="params">url,session</span>):</span><br><span class="line">    <span class="string">&quot;&quot;&quot;多线程下载网站&quot;&quot;&quot;</span></span><br><span class="line">    download_file(url,session)</span><br><span class="line"></span><br><span class="line"><span class="keyword">def</span> <span class="title function_">download_all_sites_thread</span>(<span class="params">urls</span>):</span><br><span class="line">    <span class="string">&quot;&quot;&quot;多线程下载所有网站&quot;&quot;&quot;</span></span><br><span class="line">    threads = []</span><br><span class="line">    <span class="keyword">with</span> requests.Session() <span class="keyword">as</span> session:</span><br><span class="line">        <span class="keyword">for</span> url <span class="keyword">in</span> urls:</span><br><span class="line">            thread = Thread(target=download_site_thread, args=(url,session))</span><br><span class="line">            threads.append(thread)</span><br><span class="line">            thread.start()</span><br><span class="line">        <span class="comment"># 等待所有线程结束</span></span><br><span class="line">        <span class="keyword">for</span> thread <span class="keyword">in</span> threads:</span><br><span class="line">            thread.join()</span><br><span class="line"><span class="keyword">if</span> __name__ == <span class="string">&#x27;__main__&#x27;</span>:</span><br><span class="line">    <span class="comment"># 准备一些网站用于演示</span></span><br><span class="line">    sites = [</span><br><span class="line">        <span class="string">&quot;https://www.baidu.com&quot;</span>,</span><br><span class="line">        <span class="string">&quot;https://www.sina.com.cn&quot;</span>,</span><br><span class="line">        <span class="string">&quot;https://www.qq.com&quot;</span>,</span><br><span class="line">        <span class="string">&quot;https://www.163.com&quot;</span>,</span><br><span class="line">        <span class="string">&quot;https://www.sohu.com&quot;</span>,</span><br><span class="line">    ]</span><br><span class="line">    <span class="comment"># 单线程下载</span></span><br><span class="line">    <span class="built_in">print</span>(<span class="string">&quot;=======单线程下载开始========&quot;</span>)</span><br><span class="line">    start_time = time.time()</span><br><span class="line">    download_all_sites(sites)</span><br><span class="line">    end_time = time.time()</span><br><span class="line">    <span class="built_in">print</span>(<span class="string">f&quot;单线程下载结束，耗时<span class="subst">&#123;end_time-start_time&#125;</span>秒&quot;</span>)</span><br><span class="line"></span><br><span class="line">    <span class="built_in">print</span>(<span class="string">&quot;\n=======多线程下载开始========&quot;</span>)</span><br><span class="line">    start_time = time.time()</span><br><span class="line">    download_all_sites_thread(sites)</span><br><span class="line">    end_time = time.time()</span><br><span class="line">    <span class="built_in">print</span>(<span class="string">f&quot;多线程下载结束，耗时<span class="subst">&#123;end_time-start_time&#125;</span>秒&quot;</span>)</span><br><span class="line">    <span class="built_in">print</span>(<span class="string">&quot;\nIO密集型任务（如网络请求）适合使用多线程，可以显著提高性能&quot;</span>)</span><br><span class="line">    <span class="built_in">print</span>(<span class="string">&quot;这是因为当一个线程等待IO操作完成时，其他线程可以继续执行&quot;</span>)</span><br><span class="line"></span><br></pre></td></tr></table></figure><h5 id="守护线程示例">守护线程示例</h5><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></pre></td><td class="code"><pre><span class="line"><span class="keyword">from</span> threading <span class="keyword">import</span> Thread</span><br><span class="line"><span class="keyword">import</span> time</span><br><span class="line"></span><br><span class="line"><span class="keyword">def</span> <span class="title function_">worker_function</span>(<span class="params">thread_name</span>):</span><br><span class="line">    <span class="built_in">print</span>(<span class="string">f&quot;线程 <span class="subst">&#123;thread_name&#125;</span> 启动&quot;</span>)</span><br><span class="line">    time.sleep(<span class="number">3</span>)</span><br><span class="line">    <span class="built_in">print</span>(<span class="string">f&quot;线程 <span class="subst">&#123;thread_name&#125;</span> 结束&quot;</span>)</span><br><span class="line"></span><br><span class="line"></span><br><span class="line"><span class="keyword">if</span> __name__ == <span class="string">&#x27;__main__&#x27;</span>:</span><br><span class="line">    t1 = Thread(target=worker_function, args=(<span class="string">&quot;Thread-1&quot;</span>,))</span><br><span class="line">    t2 = Thread(target=worker_function, args=(<span class="string">&quot;Thread-2&quot;</span>,))</span><br><span class="line">    <span class="comment"># 设置t3为守护线程，主线程结束时，t3线程也会结束</span></span><br><span class="line">    t3 = Thread(target=worker_function, args=(<span class="string">&quot;Thread-3&quot;</span>,), daemon=<span class="literal">True</span>)</span><br><span class="line">    <span class="comment"># t3.setDaemon(True) # 已经被废弃的API，现在使用daemon=True参数代替</span></span><br><span class="line">    <span class="comment"># 启动普通线程</span></span><br><span class="line">    t1.start()</span><br><span class="line">    t2.start()</span><br><span class="line">    <span class="comment"># 启动守护线程</span></span><br><span class="line">    t3.start()  <span class="comment"># 这里需要启动t3线程，否则t3不会执行</span></span><br><span class="line"></span><br><span class="line">    <span class="comment"># 等待普通线程完成</span></span><br><span class="line">    <span class="built_in">print</span>(<span class="string">f&quot;等待线程<span class="subst">&#123;t1.name&#125;</span> + <span class="subst">&#123;t2.name&#125;</span>完成...&quot;</span>)</span><br><span class="line">    t1.join()</span><br><span class="line">    t2.join()</span><br><span class="line"></span><br><span class="line">    <span class="comment"># 检测线程状态</span></span><br><span class="line">    <span class="built_in">print</span>(<span class="string">f&quot;线程<span class="subst">&#123;t1.name&#125;</span>是否存活：<span class="subst">&#123;t1.is_alive()&#125;</span>&quot;</span>)</span><br><span class="line">    <span class="built_in">print</span>(<span class="string">f&quot;线程<span class="subst">&#123;t2.name&#125;</span>是否存活：<span class="subst">&#123;t2.is_alive()&#125;</span>&quot;</span>)</span><br><span class="line">    <span class="built_in">print</span>(<span class="string">f&quot;线程<span class="subst">&#123;t3.name&#125;</span>是否存活：<span class="subst">&#123;t3.is_alive()&#125;</span>&quot;</span>)  <span class="comment"># True</span></span><br><span class="line"></span><br><span class="line">    <span class="comment"># 主线程休眠一段时间，以便守护线程有机会执行</span></span><br><span class="line">    time.sleep(<span class="number">10</span>)</span><br><span class="line">    <span class="built_in">print</span>(<span class="string">&quot;主线程结束&quot;</span>)</span><br></pre></td></tr></table></figure><blockquote><p>💡 <strong>守护线程特性</strong>：守护线程会随着主线程的结束而结束，不管它是否执行完成。适用于需要在后台运行但不要求必须完成的任务，如监控、日志记录等。</p></blockquote><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></pre></td><td class="code"><pre><span class="line"><span class="keyword">from</span> threading <span class="keyword">import</span> Thread</span><br><span class="line"><span class="keyword">import</span> time</span><br><span class="line"><span class="keyword">import</span> logging</span><br><span class="line"><span class="keyword">import</span> psutil</span><br><span class="line">logging.basicConfig(</span><br><span class="line">    level=logging.INFO,</span><br><span class="line">    <span class="built_in">format</span>=<span class="string">&#x27;%(asctime)s %(levelname)s %(message)s&#x27;</span>,</span><br><span class="line">)</span><br><span class="line"></span><br><span class="line"><span class="keyword">def</span> <span class="title function_">system_monitor</span>(<span class="params">interval=<span class="number">1</span></span>):</span><br><span class="line">    <span class="string">&quot;&quot;&quot;</span></span><br><span class="line"><span class="string">    守护线程：系统资源监控器</span></span><br><span class="line"><span class="string">    持续监控CPU使用率和内存使用情况，并记录到日志中</span></span><br><span class="line"><span class="string">    &quot;&quot;&quot;</span></span><br><span class="line">    logging.info(<span class="string">&#x27;系统监控守护线程启动&#x27;</span>)</span><br><span class="line">    <span class="keyword">try</span>:</span><br><span class="line">        <span class="keyword">while</span> <span class="literal">True</span>:</span><br><span class="line">            cpu_usage = psutil.cpu_percent(interval=interval)</span><br><span class="line">            mem_usage = psutil.virtual_memory().percent</span><br><span class="line">            logging.info(<span class="string">f&#x27;CPU使用率：<span class="subst">&#123;cpu_usage&#125;</span>% 内存使用率：<span class="subst">&#123;mem_usage&#125;</span>%&#x27;</span>)</span><br><span class="line">            time.sleep(interval)</span><br><span class="line">            <span class="keyword">if</span> cpu_usage &gt; <span class="number">80</span> <span class="keyword">or</span> mem_usage &gt; <span class="number">80</span>:</span><br><span class="line">                logging.warning(<span class="string">&#x27;系统资源占用过高，请及时处理&#x27;</span>)</span><br><span class="line">    <span class="keyword">except</span> Exception <span class="keyword">as</span> e:</span><br><span class="line">        logging.error(<span class="string">f&#x27;系统监控线程异常：<span class="subst">&#123;e&#125;</span>&#x27;</span>)</span><br><span class="line">    <span class="keyword">finally</span>:</span><br><span class="line">        logging.info(<span class="string">&#x27;系统监控守护线程结束&#x27;</span>)</span><br><span class="line"><span class="keyword">if</span> __name__ == <span class="string">&#x27;__main__&#x27;</span>:</span><br><span class="line">    <span class="comment"># 创建并启动系统监控守护线程</span></span><br><span class="line">    monitor_thread = Thread(target=system_monitor,args=(<span class="number">1</span>,),daemon=<span class="literal">True</span>,name=<span class="string">&quot;MonitorThread&quot;</span>)</span><br><span class="line">    monitor_thread.start()</span><br><span class="line">    <span class="comment"># 主线程继续执行一段时间，守护线程在后台运行</span></span><br><span class="line">    logging.info(<span class="string">&quot;主线程运行中，监控守护线程在后台运行...&quot;</span>)</span><br><span class="line">    time.sleep(<span class="number">30</span>)  <span class="comment"># 运行30秒后结束</span></span><br><span class="line">    <span class="comment"># 主线程结束，守护线程将自动终止</span></span><br><span class="line">    logging.info(<span class="string">f&quot;监控守护线程是否存活: <span class="subst">&#123;monitor_thread.is_alive()&#125;</span>&quot;</span>)</span><br><span class="line">    logging.info(<span class="string">&quot;主线程结束，守护线程将自动终止&quot;</span>)</span><br></pre></td></tr></table></figure><h4 id="线程池详解">线程池详解</h4><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></pre></td><td class="code"><pre><span class="line"><span class="keyword">from</span> concurrent.futures <span class="keyword">import</span> ThreadPoolExecutor</span><br><span class="line"><span class="keyword">import</span> time</span><br><span class="line"></span><br><span class="line"></span><br><span class="line"><span class="comment"># 定义一个耗时函数</span></span><br><span class="line"><span class="keyword">def</span> <span class="title function_">time_consuming_task</span>(<span class="params">n</span>):</span><br><span class="line">    time.sleep(<span class="number">1</span>)</span><br><span class="line">    <span class="keyword">return</span> n * n</span><br><span class="line"></span><br><span class="line"></span><br><span class="line"><span class="keyword">if</span> __name__ == <span class="string">&#x27;__main__&#x27;</span>:</span><br><span class="line">    start_time = time.time()</span><br><span class="line">    <span class="keyword">with</span> ThreadPoolExecutor(max_workers=<span class="number">4</span>) <span class="keyword">as</span> executor:</span><br><span class="line">        <span class="comment"># 使用submit方法提交任务给线程池执行，返回Future对象列表</span></span><br><span class="line">        futures = [executor.submit(time_consuming_task, i) <span class="keyword">for</span> i <span class="keyword">in</span> <span class="built_in">range</span>(<span class="number">1</span>, <span class="number">21</span>)]</span><br><span class="line">        <span class="built_in">print</span>(<span class="string">&quot;任务已提交，主线程继续执行.........&quot;</span>)</span><br><span class="line">        <span class="comment"># 等待所有任务完成，并获取结果</span></span><br><span class="line">        results = [future.result() <span class="keyword">for</span> future <span class="keyword">in</span> futures]</span><br><span class="line">        <span class="built_in">print</span>(<span class="string">f&quot;任务执行完毕，结果为：<span class="subst">&#123;results&#125;</span>&quot;</span>)</span><br><span class="line">    end_time = time.time()</span><br><span class="line">    <span class="built_in">print</span>(<span class="string">f&quot;总共耗时：<span class="subst">&#123;end_time - start_time&#125;</span>秒&quot;</span>)</span><br><span class="line">    <span class="comment">### 线程池在with语句结束时自动关闭</span></span><br></pre></td></tr></table></figure><h5 id="ThreadPoolExecutor-主要方法">ThreadPoolExecutor 主要方法</h5><table><thead><tr><th>方法名</th><th>简洁解释</th><th>适用场景</th></tr></thead><tbody><tr><td><code>submit(fn, *args)</code></td><td>异步执行函数，返回 Future 对象</td><td>单独提交任务并获取结果</td></tr><tr><td><code>map(func, *iterables)</code></td><td>对每个输入并行执行函数</td><td>批量处理类似任务</td></tr><tr><td><code>shutdown(wait=True)</code></td><td>关闭执行器</td><td>资源释放</td></tr><tr><td><code>result()</code></td><td>获取任务执行结果</td><td>获取异步任务的返回值</td></tr><tr><td><code>add_done_callback(fn)</code></td><td>添加任务完成回调函数</td><td>任务完成后的后续处理</td></tr><tr><td><code>as_completed()</code></td><td>返回已完成任务的迭代器</td><td>先处理先完成的任务</td></tr><tr><td><code>wait()</code></td><td>等待任务完成</td><td>任务同步点</td></tr></tbody></table><blockquote><p>🔍 <strong>深入理解</strong>：线程池最大的好处是控制并发数量，防止系统资源被耗尽。在实际开发中，建议将线程数设置为 CPU 核心数的 1-5 倍，具体取决于任务是 I/O 密集型还是 CPU 密集型。</p></blockquote><h5 id="完整示例">完整示例</h5><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></pre></td><td class="code"><pre><span class="line"><span class="keyword">from</span> concurrent.futures <span class="keyword">import</span> ThreadPoolExecutor,as_completed,wait</span><br><span class="line"><span class="keyword">import</span> time</span><br><span class="line"></span><br><span class="line"><span class="keyword">def</span> <span class="title function_">time_consuming_task</span>(<span class="params">n</span>):</span><br><span class="line">    <span class="string">&quot;&quot;&quot;耗时任务&quot;&quot;&quot;</span></span><br><span class="line">    time.sleep(n % <span class="number">3</span> +<span class="number">1</span>) <span class="comment"># 不同的n值，耗时不同</span></span><br><span class="line">    <span class="keyword">return</span> n * n</span><br><span class="line"></span><br><span class="line"><span class="keyword">def</span> <span class="title function_">task_done_callback</span>(<span class="params">future</span>):</span><br><span class="line">    <span class="string">&quot;&quot;&quot;任务完成后触发的回调函数&quot;&quot;&quot;</span></span><br><span class="line">    <span class="built_in">print</span>(<span class="string">f&quot;任务完成，结果为<span class="subst">&#123;future.result()&#125;</span>&quot;</span>)</span><br><span class="line"></span><br><span class="line"><span class="keyword">if</span> __name__ == <span class="string">&#x27;__main__&#x27;</span>:</span><br><span class="line">    <span class="built_in">print</span>(<span class="string">&quot;===== 1. submit方法示例 =====&quot;</span>)</span><br><span class="line">    start_time = time.time()</span><br><span class="line">    <span class="keyword">with</span> ThreadPoolExecutor(max_workers=<span class="number">3</span>) <span class="keyword">as</span> executor:</span><br><span class="line">        future_list = [executor.submit(time_consuming_task, i) <span class="keyword">for</span> i <span class="keyword">in</span> <span class="built_in">range</span>(<span class="number">10</span>)]</span><br><span class="line">        <span class="built_in">print</span>(<span class="string">&quot;提交任务完成，等待结果...&quot;</span>)</span><br><span class="line">        <span class="comment"># result()：获取任务执行结果，会阻塞直到所有任务完成</span></span><br><span class="line">        <span class="comment"># as_completed(): 返回已完成任务的迭代器</span></span><br><span class="line">        <span class="keyword">for</span> future <span class="keyword">in</span> as_completed(future_list):</span><br><span class="line">            <span class="built_in">print</span>(<span class="string">f&quot;任务<span class="subst">&#123;future.result()&#125;</span>完成&quot;</span>)</span><br><span class="line">    end_time = time.time()</span><br><span class="line">    <span class="built_in">print</span>(<span class="string">f&quot;总耗时：<span class="subst">&#123;end_time - start_time&#125;</span>秒&quot;</span>)</span><br><span class="line"></span><br><span class="line">    <span class="built_in">print</span>(<span class="string">&quot;\n===== 2. map方法示例 =====&quot;</span>)</span><br><span class="line">    start_time = time.time()</span><br><span class="line">    <span class="keyword">with</span> ThreadPoolExecutor(max_workers=<span class="number">4</span>) <span class="keyword">as</span> executor:</span><br><span class="line">        <span class="comment"># map()：对每一个输入并行执行函数，返回结果迭代器</span></span><br><span class="line">        <span class="comment"># 与submit不同，map会自动收集结果并按输入顺序返回</span></span><br><span class="line">        <span class="comment"># 不需要手动调用future.result()</span></span><br><span class="line">        results = executor.<span class="built_in">map</span>(time_consuming_task, <span class="built_in">range</span>(<span class="number">10</span>))</span><br><span class="line">        <span class="built_in">print</span>(<span class="string">&quot;任务提交完成，直接获取有序结果...&quot;</span>)</span><br><span class="line">        <span class="comment"># 转换为列表时会按照输入顺序返回结果，如果任务未完成会在这里阻塞等待</span></span><br><span class="line">        results_list = <span class="built_in">list</span>(results)</span><br><span class="line">        <span class="built_in">print</span>(<span class="string">f&quot;结果：<span class="subst">&#123;results_list&#125;</span>&quot;</span>)</span><br><span class="line">    end_time = time.time()</span><br><span class="line">    <span class="built_in">print</span>(<span class="string">f&quot;总耗时：<span class="subst">&#123;end_time - start_time&#125;</span>秒&quot;</span>)</span><br><span class="line"></span><br><span class="line">    <span class="built_in">print</span>(<span class="string">&quot;\n===== 3. add_done_callback示例 =====&quot;</span>)</span><br><span class="line">    <span class="comment"># add_done_callback(fn): 添加任务完成回调函数</span></span><br><span class="line">    start_time = time.time()</span><br><span class="line">    <span class="keyword">with</span> ThreadPoolExecutor(max_workers=<span class="number">4</span>) <span class="keyword">as</span> executor:</span><br><span class="line">        futures = []</span><br><span class="line">        <span class="keyword">for</span> i <span class="keyword">in</span> <span class="built_in">range</span>(<span class="number">1</span>, <span class="number">6</span>):</span><br><span class="line">            future = executor.submit(time_consuming_task, i)</span><br><span class="line">            <span class="comment"># 添加回调函数，任务完成后自动调用</span></span><br><span class="line">            future.add_done_callback(task_done_callback)</span><br><span class="line">            futures.append(future)</span><br><span class="line">        <span class="built_in">print</span>(<span class="string">&quot;已添加回调函数，主线程继续执行...&quot;</span>)</span><br><span class="line">        <span class="comment"># 等待所有任务完成</span></span><br><span class="line">        <span class="keyword">for</span> future <span class="keyword">in</span> futures:</span><br><span class="line">            future.result()</span><br><span class="line">    <span class="built_in">print</span>(<span class="string">f&quot;耗时：<span class="subst">&#123;time.time() - start_time&#125;</span>秒&quot;</span>)</span><br><span class="line">    <span class="comment"># 适用场景：任务完成后的后续处理，适合需要在任务完成时执行额外操作而不阻塞主线程</span></span><br><span class="line"></span><br><span class="line">    <span class="built_in">print</span>(<span class="string">&quot;\n===== 4. wait示例 =====&quot;</span>)</span><br><span class="line">    <span class="comment"># wait(): 等待任务完成</span></span><br><span class="line">    start_time = time.time()</span><br><span class="line">    <span class="keyword">with</span> ThreadPoolExecutor(max_workers=<span class="number">4</span>) <span class="keyword">as</span> executor:</span><br><span class="line">        futures = [executor.submit(time_consuming_task, i) <span class="keyword">for</span> i <span class="keyword">in</span> <span class="built_in">range</span>(<span class="number">1</span>, <span class="number">11</span>)]</span><br><span class="line">        <span class="built_in">print</span>(<span class="string">&quot;任务已提交，等待所有任务完成...&quot;</span>)</span><br><span class="line">        <span class="comment"># 等待所有任务完成</span></span><br><span class="line">        done, not_done = wait(futures)</span><br><span class="line">        <span class="built_in">print</span>(<span class="string">f&quot;完成的任务数: <span class="subst">&#123;<span class="built_in">len</span>(done)&#125;</span>, 未完成的任务数: <span class="subst">&#123;<span class="built_in">len</span>(not_done)&#125;</span>&quot;</span>)</span><br><span class="line">        results = [future.result() <span class="keyword">for</span> future <span class="keyword">in</span> done]</span><br><span class="line">        <span class="built_in">print</span>(<span class="string">f&quot;所有任务执行完毕，结果为：<span class="subst">&#123;results&#125;</span>&quot;</span>)</span><br><span class="line">    <span class="built_in">print</span>(<span class="string">f&quot;耗时：<span class="subst">&#123;time.time() - start_time&#125;</span>秒&quot;</span>)</span><br><span class="line">    <span class="comment"># 适用场景：任务同步点，适合需要等待一组任务全部或部分完成后再继续执行的情况</span></span><br></pre></td></tr></table></figure><h4 id="Event-事件同步机制">Event 事件同步机制</h4><p>Event 是一种线程同步机制，用于协调多个线程的执行顺序。它本质上是一个内部的标志位，线程可以等待这个标志位被设置，也可以设置或清除这个标志位。</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></pre></td><td class="code"><pre><span class="line"><span class="keyword">from</span> threading <span class="keyword">import</span> Thread, Event</span><br><span class="line"><span class="keyword">import</span> time</span><br><span class="line"></span><br><span class="line"><span class="comment">## 创建一个事件对象</span></span><br><span class="line">event = Event()</span><br><span class="line"></span><br><span class="line"><span class="comment">## 模拟公交车到站的函数</span></span><br><span class="line"><span class="keyword">def</span> <span class="title function_">bus_stop</span>():</span><br><span class="line">    <span class="string">&quot;&quot;&quot;模拟公交车到站过程&quot;&quot;&quot;</span></span><br><span class="line">    <span class="built_in">print</span>(<span class="string">&#x27;公交车即将到站&#x27;</span>)</span><br><span class="line">    time.sleep(<span class="number">2</span>)  <span class="comment"># 模拟行驶时间</span></span><br><span class="line">    <span class="built_in">print</span>(<span class="string">&#x27;公交车已到站&lt;====&gt;&#x27;</span>*<span class="number">10</span>)</span><br><span class="line">    <span class="comment"># 设置事件，通知等待的乘客</span></span><br><span class="line">    event.<span class="built_in">set</span>()  <span class="comment"># 发射信号，让等车的人上车</span></span><br><span class="line"></span><br><span class="line"></span><br><span class="line"><span class="keyword">def</span> <span class="title function_">passenger</span>(<span class="params">name</span>):</span><br><span class="line">    <span class="string">&quot;&quot;&quot;模拟乘客等车</span></span><br><span class="line"><span class="string">    </span></span><br><span class="line"><span class="string">    Args:</span></span><br><span class="line"><span class="string">        name: 乘客名称</span></span><br><span class="line"><span class="string">    &quot;&quot;&quot;</span></span><br><span class="line">    <span class="comment"># 等待公交车到站</span></span><br><span class="line">    <span class="built_in">print</span>(name, <span class="string">&#x27;等车中&#x27;</span>)</span><br><span class="line">    event.wait()  <span class="comment"># 阻塞等待信号</span></span><br><span class="line">    <span class="built_in">print</span>(name, <span class="string">&#x27;出发！！！！！！！！！！！！！！！！！！！！！！！！！！&#x27;</span>)</span><br><span class="line"></span><br><span class="line"></span><br><span class="line"><span class="keyword">if</span> __name__ == <span class="string">&#x27;__main__&#x27;</span>:</span><br><span class="line">    <span class="comment"># 创建公交车线程</span></span><br><span class="line">    t1 = Thread(target=bus_stop)</span><br><span class="line">    t1.start()</span><br><span class="line"></span><br><span class="line">    <span class="comment"># 创建多个乘客线程</span></span><br><span class="line">    <span class="keyword">for</span> i <span class="keyword">in</span> <span class="built_in">range</span>(<span class="number">20</span>):</span><br><span class="line">        t = Thread(target=passenger, args=(<span class="string">f&#x27;乘客<span class="subst">&#123;i&#125;</span>&#x27;</span>,))</span><br><span class="line">        t.start()</span><br><span class="line">        time.sleep(<span class="number">0.1</span>)  <span class="comment"># 模拟乘客陆续到站</span></span><br></pre></td></tr></table></figure><h5 id="Event-主要方法">Event 主要方法</h5><table><thead><tr><th>方法名</th><th>描述</th><th>使用场景</th></tr></thead><tbody><tr><td><code>set()</code></td><td>设置事件标志为 True</td><td>通知等待的线程继续执行</td></tr><tr><td><code>clear()</code></td><td>清除事件标志为 False</td><td>重置事件状态，使线程再次等待</td></tr><tr><td><code>is_set()</code></td><td>检查事件状态</td><td>判断事件是否已被设置</td></tr><tr><td><code>wait()</code></td><td>等待事件被设置</td><td>阻塞线程直到事件被设置或超时</td></tr></tbody></table><blockquote><p>🌟 <strong>应用场景</strong>：Event 适合实现一次性通知多个线程的场景，比如多个工作线程等待初始化完成、多个消费者等待数据准备就绪等。在 Web 开发中，可用于协调多个后台任务的启动时机。</p></blockquote><h4 id="定时器-Timer">定时器(Timer)</h4><p>定时器是线程的一个特殊应用，用于在指定时间后执行某个操作。Python 的 <code>threading</code> 模块提供了 <code>Timer</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></pre></td><td class="code"><pre><span class="line"><span class="keyword">from</span> threading <span class="keyword">import</span> Timer</span><br><span class="line"></span><br><span class="line"><span class="keyword">def</span> <span class="title function_">delayed_greeting</span>(<span class="params">name</span>):</span><br><span class="line">    <span class="string">&quot;&quot;&quot;延迟执行的问候函数</span></span><br><span class="line"><span class="string">    </span></span><br><span class="line"><span class="string">    Args:</span></span><br><span class="line"><span class="string">        name: 要问候的对象名称</span></span><br><span class="line"><span class="string">    &quot;&quot;&quot;</span></span><br><span class="line">    <span class="built_in">print</span>(<span class="string">f&quot;<span class="subst">&#123;name&#125;</span>说: 哈哈，我是延迟1秒后才执行的!&quot;</span>)</span><br><span class="line"></span><br><span class="line"><span class="comment">## 创建一个定时器，1秒后执行hello函数，参数为&quot;小明&quot;</span></span><br><span class="line">timer = Timer(<span class="number">1</span>, delayed_greeting, args=(<span class="string">&quot;小明&quot;</span>,))</span><br><span class="line"><span class="comment">## 启动定时器</span></span><br><span class="line">timer.start()</span><br><span class="line"></span><br><span class="line"><span class="built_in">print</span>(<span class="string">&quot;定时器已启动，但greeting函数还未执行...&quot;</span>)</span><br><span class="line"><span class="comment">## 主线程继续执行，不会被阻塞</span></span><br></pre></td></tr></table></figure><blockquote><p>💡 <strong>实用技巧</strong>：Timer 可用于实现超时处理、延迟重试、定时清理等场景。例如，在网络编程中，可以用 Timer 设置请求超时机制；在数据同步中，可以用 Timer 定期执行同步任务。</p></blockquote><h3 id="14-4-多进程-VS-多线程性能分析">14.4 多进程 VS 多线程性能分析</h3><p>在 Python 中，由于 GIL(全局解释器锁)的存在，多线程并不能真正实现并行计算。因此，根据任务特性选择合适的并发模型十分重要。</p><h4 id="不同场景的最优选择">不同场景的最优选择</h4><table><thead><tr><th>任务类型</th><th>多进程</th><th>多线程</th><th>推荐选择</th></tr></thead><tbody><tr><td>计算密集型</td><td>效率高，可利用多核</td><td>受 GIL 限制，效率相对较低</td><td>多进程</td></tr><tr><td>IO 密集型</td><td>资源占用大</td><td>资源占用小，效率与多进程相当</td><td>多线程</td></tr></tbody></table><blockquote><p>📊 <strong>实际应用建议</strong>：现代开发中，约 90%以上的程序属于 IO 密集型，适合使用多线程；对于数据分析、图像处理等计算密集型任务，则推荐使用多进程。也可以考虑混合使用：多进程下每个进程内再使用多线程。</p></blockquote><h4 id="计算密集型任务测试">计算密集型任务测试</h4><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></pre></td><td class="code"><pre><span class="line"><span class="keyword">from</span> multiprocessing <span class="keyword">import</span> Process</span><br><span class="line"><span class="keyword">from</span> threading <span class="keyword">import</span> Thread</span><br><span class="line"><span class="keyword">import</span> time</span><br><span class="line"></span><br><span class="line"><span class="string">&#x27;&#x27;&#x27;</span></span><br><span class="line"><span class="string">计算密集型任务对比测试</span></span><br><span class="line"><span class="string">&#x27;&#x27;&#x27;</span></span><br><span class="line"><span class="keyword">def</span> <span class="title function_">task</span>():</span><br><span class="line">    <span class="string">&quot;&quot;&quot;计算密集型任务&quot;&quot;&quot;</span></span><br><span class="line">    res = <span class="number">0</span></span><br><span class="line">    <span class="keyword">for</span> i <span class="keyword">in</span> <span class="built_in">range</span>(<span class="number">10000000</span>):  <span class="comment"># 执行大量计算</span></span><br><span class="line">        res += i</span><br><span class="line"></span><br><span class="line"><span class="keyword">if</span> __name__ == <span class="string">&#x27;__main__&#x27;</span>:</span><br><span class="line">    start_time = time.time()</span><br><span class="line">    l = []</span><br><span class="line"></span><br><span class="line">    <span class="keyword">for</span> i <span class="keyword">in</span> <span class="built_in">range</span>(<span class="number">10</span>):</span><br><span class="line">        <span class="comment"># 使用多进程或多线程(取消相应的注释来测试)</span></span><br><span class="line">        p = Process(target=task)  <span class="comment"># 多进程：结果大概是1.65秒</span></span><br><span class="line">        <span class="comment"># p = Thread(target=task)   # 多线程：结果大概是4.18秒</span></span><br><span class="line">        </span><br><span class="line">        p.start()</span><br><span class="line">        l.append(p)</span><br><span class="line"></span><br><span class="line">    <span class="keyword">for</span> p <span class="keyword">in</span> l:</span><br><span class="line">        p.join()</span><br><span class="line"></span><br><span class="line">    end = time.time()</span><br><span class="line">    <span class="built_in">print</span>(<span class="string">&quot;花费时间&quot;</span>, end - start_time)</span><br></pre></td></tr></table></figure><h4 id="IO-密集型任务测试">IO 密集型任务测试</h4><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></pre></td><td class="code"><pre><span class="line"><span class="keyword">from</span> multiprocessing <span class="keyword">import</span> Process</span><br><span class="line"><span class="keyword">from</span> threading <span class="keyword">import</span> Thread</span><br><span class="line"><span class="keyword">import</span> time</span><br><span class="line"></span><br><span class="line"><span class="string">&#x27;&#x27;&#x27;</span></span><br><span class="line"><span class="string">IO密集型任务对比测试</span></span><br><span class="line"><span class="string">&#x27;&#x27;&#x27;</span></span><br><span class="line"><span class="keyword">def</span> <span class="title function_">task</span>():</span><br><span class="line">    <span class="string">&quot;&quot;&quot;IO密集型任务，使用sleep模拟IO操作&quot;&quot;&quot;</span></span><br><span class="line">    time.sleep(<span class="number">1</span>)  <span class="comment"># 模拟IO等待</span></span><br><span class="line">    </span><br><span class="line"><span class="keyword">if</span> __name__ == <span class="string">&#x27;__main__&#x27;</span>:</span><br><span class="line">    start_time = time.time()</span><br><span class="line">    l = []</span><br><span class="line"></span><br><span class="line">    <span class="keyword">for</span> i <span class="keyword">in</span> <span class="built_in">range</span>(<span class="number">100</span>):  <span class="comment"># 创建100个任务</span></span><br><span class="line">        <span class="comment"># 使用多进程或多线程(取消相应的注释来测试)</span></span><br><span class="line">        <span class="comment"># p = Process(target=task)  # 多进程：结果约19.34秒</span></span><br><span class="line">        p = Thread(target=task)   <span class="comment"># 多线程：结果约1.01秒</span></span><br><span class="line">        </span><br><span class="line">        p.start()</span><br><span class="line">        l.append(p)</span><br><span class="line"></span><br><span class="line">    <span class="keyword">for</span> p <span class="keyword">in</span> l:</span><br><span class="line">        p.join()</span><br><span class="line"></span><br><span class="line">    end = time.time()</span><br><span class="line">    <span class="built_in">print</span>(<span class="string">&quot;花费时间&quot;</span>, end - start_time)</span><br></pre></td></tr></table></figure><blockquote><p>⚠️ <strong>性能陷阱</strong>：多线程在 IO 密集型任务中表现出色，但过多的线程可能导致线程切换开销增大，反而降低效率。经验值是控制线程数为 CPU 核心数的 2-4 倍。</p></blockquote><h3 id="14-5-协程技术详解">14.5 协程技术详解</h3><h4 id="协程基础概念">协程基础概念</h4><p><strong>协程</strong>（Coroutine）也称为微线程，是一种用户态内的上下文切换技术，可以在单线程下实现并发效果。协程通过巧妙的编程技巧实现了程序主动让出和恢复执行的能力，使得单线程内可以 “模拟” 出并发的效果。</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></pre></td><td class="code"><pre><span class="line"><span class="string">&#x27;&#x27;&#x27;</span></span><br><span class="line"><span class="string">进程：资源单位 - 系统分配资源的基本单位，拥有独立的内存空间</span></span><br><span class="line"><span class="string">线程：执行单位 - CPU调度和执行的最小单位，共享所属进程的内存空间</span></span><br><span class="line"><span class="string">协程：根本不存在，它是程序员人为创造出来的(切换+保存状态)</span></span><br><span class="line"><span class="string">当程序遇到IO的时候，通过我们的代码，让我们的程序自动完成切换</span></span><br><span class="line"><span class="string">也就是通过代码监听IO，一旦程序遇到IO，就在代码层面自动切换，给CPU的感觉就是我们的程序没有IO</span></span><br><span class="line"><span class="string">换句话说也就是我们欺骗了CPU</span></span><br><span class="line"><span class="string">&#x27;&#x27;&#x27;</span></span><br></pre></td></tr></table></figure><p>协程的核心原理是 “<strong>切换+保存状态</strong>”，即在多个任务之间来回切换，每次切换都保存当前任务的执行状态，下次切换回来继续执行。在 Python 中，可以通过 <code>yield</code> 关键字、<code>greenlet</code> 模块或 <code>asyncio</code> 库实现协程。</p><blockquote><p>🔍 <strong>深入理解</strong>：协程不是提升计算效率，而是提升 IO 效率。在 IO 密集型应用中，协程可以让 CPU 在等待 IO 的同时执行其他任务，从而提高资源利用率。协程的切换不需要操作系统参与，开销远小于线程切换。</p></blockquote><table><thead><tr><th>概念</th><th>资源占用</th><th>切换开销</th><th>实现方式</th><th>适用场景</th></tr></thead><tbody><tr><td>进程</td><td>高（独立内存空间）</td><td>高（涉及内存映射）</td><td>操作系统调度</td><td>CPU 密集型，需要隔离的任务</td></tr><tr><td>线程</td><td>中（共享内存但有独立栈）</td><td>中（上下文切换）</td><td>操作系统调度</td><td>混合型任务，兼顾计算与 IO</td></tr><tr><td>协程</td><td>低（共享线程内全部资源）</td><td>低（用户态切换）</td><td>程序自行控制</td><td>IO 密集型，高并发网络应用</td></tr></tbody></table><h4 id="协程效率对比">协程效率对比</h4><p>对于计算密集型任务时，使用协程反而会降低效率！</p><h5 id="串行执行">串行执行</h5><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></pre></td><td class="code"><pre><span class="line"><span class="keyword">import</span> time</span><br><span class="line"></span><br><span class="line"><span class="keyword">def</span> <span class="title function_">f1</span>():</span><br><span class="line">    <span class="string">&quot;&quot;&quot;计算密集型函数1&quot;&quot;&quot;</span></span><br><span class="line">    n = <span class="number">0</span></span><br><span class="line">    <span class="keyword">for</span> i <span class="keyword">in</span> <span class="built_in">range</span>(<span class="number">10000000</span>):</span><br><span class="line">        n += i  <span class="comment"># 执行简单累加计算</span></span><br><span class="line"></span><br><span class="line"><span class="keyword">def</span> <span class="title function_">f2</span>():</span><br><span class="line">    <span class="string">&quot;&quot;&quot;计算密集型函数2&quot;&quot;&quot;</span></span><br><span class="line">    n = <span class="number">0</span></span><br><span class="line">    <span class="keyword">for</span> i <span class="keyword">in</span> <span class="built_in">range</span>(<span class="number">10000000</span>):</span><br><span class="line">        n += i  <span class="comment"># 执行简单累加计算</span></span><br><span class="line"></span><br><span class="line">start_time = time.time()</span><br><span class="line">f1()  <span class="comment"># 顺序执行f1</span></span><br><span class="line">f2()  <span class="comment"># 然后执行f2</span></span><br><span class="line"><span class="comment">## 保留两位小数</span></span><br><span class="line"><span class="built_in">print</span>(<span class="string">&quot;串行执行总共用时：%.2f秒&quot;</span> % (time.time() - start_time))  <span class="comment"># 串行执行总共用时：0.84秒</span></span><br></pre></td></tr></table></figure><h5 id="使用-yield-实现协程切换">使用 yield 实现协程切换</h5><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></pre></td><td class="code"><pre><span class="line"><span class="keyword">import</span> time</span><br><span class="line"></span><br><span class="line"><span class="keyword">def</span> <span class="title function_">f1</span>():</span><br><span class="line">    <span class="string">&quot;&quot;&quot;带yield的计算密集型函数1&quot;&quot;&quot;</span></span><br><span class="line">    n = <span class="number">0</span></span><br><span class="line">    <span class="keyword">for</span> i <span class="keyword">in</span> <span class="built_in">range</span>(<span class="number">10000000</span>):</span><br><span class="line">        n += i</span><br><span class="line">        <span class="keyword">yield</span>  <span class="comment"># 主动让出执行权，保存当前执行状态</span></span><br><span class="line"></span><br><span class="line"><span class="keyword">def</span> <span class="title function_">f2</span>():</span><br><span class="line">    <span class="string">&quot;&quot;&quot;使用f1的生成器进行交替执行&quot;&quot;&quot;</span></span><br><span class="line">    g = f1()  <span class="comment"># 创建生成器对象</span></span><br><span class="line">    n = <span class="number">0</span></span><br><span class="line">    <span class="keyword">for</span> i <span class="keyword">in</span> <span class="built_in">range</span>(<span class="number">10000000</span>):</span><br><span class="line">        n += i</span><br><span class="line">        <span class="built_in">next</span>(g)  <span class="comment"># 切换到f1执行一步，f1会执行到下一个yield后暂停</span></span><br><span class="line"></span><br><span class="line">start_time = time.time()</span><br><span class="line">f2()  <span class="comment"># 执行f2，内部会与f1交替执行</span></span><br><span class="line"><span class="comment">## 保留两位小数</span></span><br><span class="line"><span class="built_in">print</span>(<span class="string">&quot;yield协程用时：%.2f秒&quot;</span> % (time.time() - start_time))  <span class="comment"># 约1.45秒</span></span><br></pre></td></tr></table></figure><blockquote><p>⚠️ <strong>注意事项</strong>：对于计算密集型任务，协程切换反而会增加开销，降低效率；但对于 IO 密集型任务，协程切换可以显著提高效率。这是因为在 IO 等待期间，协程可以切换到其他任务继续执行，避免了 CPU 空闲。</p></blockquote><h4 id="greenlet-模块（了解）">greenlet 模块（了解）</h4><p>greenlet 是一个轻量级的协程库，提供了基本的协程实现。它允许在不使用回调函数的情况下，在不同函数间来回切换执行，实现了所谓的 “确定性切换”。</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></pre></td><td class="code"><pre><span class="line"><span class="keyword">from</span> greenlet <span class="keyword">import</span> greenlet</span><br><span class="line"><span class="keyword">import</span> time</span><br><span class="line"></span><br><span class="line"><span class="keyword">def</span> <span class="title function_">func_a</span>():</span><br><span class="line">    <span class="string">&quot;&quot;&quot;协程函数a&quot;&quot;&quot;</span></span><br><span class="line">    <span class="keyword">while</span> <span class="literal">True</span>:</span><br><span class="line">        <span class="built_in">print</span>(<span class="string">&#x27;函数a正在运行&#x27;</span>)</span><br><span class="line">        time.sleep(<span class="number">1</span>)  <span class="comment"># 模拟某些操作</span></span><br><span class="line">        b.switch()  <span class="comment"># 主动切换到函数b执行</span></span><br><span class="line"></span><br><span class="line"><span class="keyword">def</span> <span class="title function_">func_b</span>():</span><br><span class="line">    <span class="string">&quot;&quot;&quot;协程函数b&quot;&quot;&quot;</span></span><br><span class="line">    <span class="keyword">while</span> <span class="literal">True</span>:</span><br><span class="line">        <span class="built_in">print</span>(<span class="string">&#x27;函数b正在运行&#x27;</span>)</span><br><span class="line">        time.sleep(<span class="number">2</span>)  <span class="comment"># 模拟某些操作</span></span><br><span class="line">        a.switch()  <span class="comment"># 切换回函数a执行</span></span><br><span class="line"></span><br><span class="line"><span class="keyword">if</span> __name__ == <span class="string">&#x27;__main__&#x27;</span>:</span><br><span class="line">    <span class="comment"># 创建两个greenlet对象</span></span><br><span class="line">    a = greenlet(func_a)  <span class="comment"># 将函数封装为greenlet对象</span></span><br><span class="line">    b = greenlet(func_b)  <span class="comment"># 将函数封装为greenlet对象</span></span><br><span class="line">    <span class="comment"># 从函数a开始执行</span></span><br><span class="line">    a.switch()  <span class="comment"># 启动协程a</span></span><br></pre></td></tr></table></figure><h5 id="greenlet-核心方法与属性">greenlet 核心方法与属性</h5><table><thead><tr><th>方法/属性名</th><th>描述</th><th>使用场景</th><th>示例</th></tr></thead><tbody><tr><td><code>greenlet.getcurrent()</code></td><td>获取当前正在执行的 greenlet 对象</td><td>在函数内获取当前协程</td><td><code>current = greenlet.getcurrent()</code></td></tr><tr><td><code>greenlet.switch(value=None)</code></td><td>将控制权切换到另一个 greenlet</td><td>协程间的主动切换</td><td><code>g.switch('传递参数')</code></td></tr><tr><td><code>greenlet.parent</code></td><td>获取当前 greenlet 的父 greenlet</td><td>协程层级管理</td><td><code>parent = g.parent</code></td></tr><tr><td><code>throw(type, value=None, tb=None)</code></td><td>向 greenlet 对象中抛出异常</td><td>协程异常处理</td><td><code>g.throw(ValueError, '错误信息')</code></td></tr><tr><td><code>dead</code></td><td>判断 greenlet 是否已经执行完毕</td><td>协程状态检查</td><td><code>if g.dead: print('已执行完毕')</code></td></tr><tr><td><code>gr_frame</code></td><td>获取 greenlet 当前的帧对象</td><td>调试和检查协程状态</td><td><code>frame = g.gr_frame</code></td></tr><tr><td><code>run</code></td><td>绑定到 greenlet 的可调用对象</td><td>查看协程的目标函数</td><td><code>func = g.run</code></td></tr></tbody></table><blockquote><p>💡 <strong>使用技巧</strong>：greenlet 适合实现简单的协程切换，但不支持自动在 IO 操作时切换，因此常与事件循环结合使用，如 gevent 库。greenlet 的优势在于它的轻量和灵活性，可以构建复杂的协程调度系统。</p></blockquote><h4 id="gevent-模块（了解）">gevent 模块（了解）</h4><p>gevent 是基于 greenlet 的协程库，增加了事件循环和自动 IO 切换功能。它通过 “猴子补丁”（monkey patching）将标准库中的阻塞操作替换为非阻塞版本，使普通的同步代码能够以异步方式执行。</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></pre></td><td class="code"><pre><span class="line"><span class="string">&#x27;&#x27;&#x27;</span></span><br><span class="line"><span class="string">gevent 是一个基于协程的 Python 网络库，它使用 greenlet 在 libev 或 libuv </span></span><br><span class="line"><span class="string">等事件循环之上提供高级同步 API。gevent 实现了python 标准库里面大部分的阻塞式系统调用，</span></span><br><span class="line"><span class="string">包括 socket、ssl、threading 和 select 等模块，</span></span><br><span class="line"><span class="string">可以使用 &quot;猴子补丁&quot; 将这些阻塞式调用变为协作式运行。</span></span><br><span class="line"><span class="string"></span></span><br><span class="line"><span class="string">猴子补丁的功能很强大，但是也带来了很多的风险，尤其是像 gevent 这种直接进行 API替换的补丁，</span></span><br><span class="line"><span class="string">整个 Python 进程所使用的模块都会被替换，可能自己的代码能 hold 住，</span></span><br><span class="line"><span class="string">但是其它第三方库，有时候问题并不好排查，即使排查出来也是很棘手，所以，</span></span><br><span class="line"><span class="string">就像松本建议的那样，如果要使用猴子补丁，那么只是做功能追加，</span></span><br><span class="line"><span class="string">尽量避免大规模的 API 覆盖。 虽然猴子补丁仍然是邪恶的(evil)，</span></span><br><span class="line"><span class="string">但在这种情况下它是 &quot;有用的邪恶(useful evil)&quot;。</span></span><br><span class="line"><span class="string">&#x27;&#x27;&#x27;</span></span><br></pre></td></tr></table></figure><h5 id="gevent-基础操作">gevent 基础操作</h5><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></pre></td><td class="code"><pre><span class="line"><span class="keyword">import</span> gevent</span><br><span class="line"><span class="keyword">from</span> gevent <span class="keyword">import</span> monkey</span><br><span class="line"></span><br><span class="line"><span class="comment">## 应用猴子补丁，将标准库的阻塞操作替换为非阻塞版本</span></span><br><span class="line"><span class="comment">## 必须在导入其他模块前调用，确保所有IO操作都被替换</span></span><br><span class="line">monkey.patch_all()  <span class="comment"># 替换所有可能的阻塞调用</span></span><br><span class="line"></span><br><span class="line"><span class="keyword">def</span> <span class="title function_">foo</span>():</span><br><span class="line">    <span class="string">&quot;&quot;&quot;协程函数1&quot;&quot;&quot;</span></span><br><span class="line">    <span class="built_in">print</span>(<span class="string">&#x27;Running in foo&#x27;</span>)</span><br><span class="line">    gevent.sleep(<span class="number">0</span>)  <span class="comment"># 模拟IO操作，主动让出控制权</span></span><br><span class="line">    <span class="built_in">print</span>(<span class="string">&#x27;Explicit context switch to foo&#x27;</span>)</span><br><span class="line"></span><br><span class="line"><span class="keyword">def</span> <span class="title function_">bar</span>():</span><br><span class="line">    <span class="string">&quot;&quot;&quot;协程函数2&quot;&quot;&quot;</span></span><br><span class="line">    <span class="built_in">print</span>(<span class="string">&#x27;Running in bar&#x27;</span>)</span><br><span class="line">    gevent.sleep(<span class="number">0</span>)  <span class="comment"># 模拟IO操作，主动让出控制权</span></span><br><span class="line">    <span class="built_in">print</span>(<span class="string">&#x27;Explicit context switch to bar&#x27;</span>)</span><br><span class="line"></span><br><span class="line"><span class="keyword">def</span> <span class="title function_">baz</span>():</span><br><span class="line">    <span class="string">&quot;&quot;&quot;协程函数3&quot;&quot;&quot;</span></span><br><span class="line">    <span class="built_in">print</span>(<span class="string">&#x27;Running in baz&#x27;</span>)</span><br><span class="line">    gevent.sleep(<span class="number">0</span>)  <span class="comment"># 模拟IO操作，主动让出控制权</span></span><br><span class="line">    <span class="built_in">print</span>(<span class="string">&#x27;Explicit context switch to baz&#x27;</span>)</span><br><span class="line"></span><br><span class="line"><span class="comment">## 创建三个协程</span></span><br><span class="line">g1 = gevent.spawn(foo)  <span class="comment"># 创建协程但不立即执行</span></span><br><span class="line">g2 = gevent.spawn(bar)  <span class="comment"># 创建协程但不立即执行</span></span><br><span class="line">g3 = gevent.spawn(baz)  <span class="comment"># 创建协程但不立即执行</span></span><br><span class="line"></span><br><span class="line"><span class="comment">## 等待所有协程完成</span></span><br><span class="line">gevent.joinall([g1, g2, g3])  <span class="comment"># 类似于多线程中的join方法</span></span><br></pre></td></tr></table></figure><h5 id="gevent-常用-API-详解">gevent 常用 API 详解</h5><table><thead><tr><th>方法/类名</th><th>描述</th><th>使用场景</th><th>实际应用示例</th></tr></thead><tbody><tr><td><code>gevent.spawn(function, *args, **kwargs)</code></td><td>创建并运行协程</td><td>启动异步任务</td><td>启动多个 HTTP 请求并行处理</td></tr><tr><td><code>gevent.joinall(greenlets, timeout=None, raise_error=False)</code></td><td>等待多个协程完成</td><td>同步点，等待所有任务完成</td><td>批量处理多个数据源</td></tr><tr><td><code>gevent.sleep(seconds=0)</code></td><td>协程休眠并让出控制权</td><td>模拟 IO 操作，主动让出控制权</td><td>测试协程调度，防止 CPU 密集任务阻塞</td></tr><tr><td><code>gevent.wait(objects=None, timeout=None, count=None)</code></td><td>等待对象(协程)完成</td><td>等待部分任务完成</td><td>等待最快完成的结果</td></tr><tr><td><code>gevent.kill(greenlet, exception=GreenletExit)</code></td><td>终止协程</td><td>取消不需要的任务</td><td>实现任务超时取消</td></tr><tr><td><code>gevent.monkey.patch_all(socket=True, dns=True, ...)</code></td><td>应用猴子补丁</td><td>将同步库变为异步兼容</td><td>使用前替换标准库函数</td></tr><tr><td><code>gevent.queue.Queue</code></td><td>协程安全的队列</td><td>协程间通信和数据传递</td><td>生产者-消费者模式实现</td></tr><tr><td><code>gevent.event.Event</code></td><td>事件通知机制</td><td>协程间同步和通知</td><td>完成信号传递</td></tr><tr><td><code>gevent.pool.Pool</code></td><td>协程池</td><td>限制并发数量</td><td>控制网络请求并发数</td></tr><tr><td><code>gevent.select.select()</code></td><td>IO 多路复用</td><td>监控多个文件描述符</td><td>自定义事件循环</td></tr></tbody></table><blockquote><p>⚠️ <strong>使用 gevent 注意事项</strong>：</p><ol><li>所有协程运行在同一线程中，不能跨线程同步数据</li><li>gevent.queue.Queue 是协程安全的，可以用于协程间通信</li><li>不能有长时间阻塞的 CPU 密集型操作，会阻塞整个事件循环</li><li>最好使用 gevent 自身的非阻塞库或已打补丁的标准库</li><li>猴子补丁会修改全局状态，可能影响第三方库的行为，应在所有导入前应用</li><li>调试协程比调试线程更困难，错误追踪可能会更复杂</li></ol></blockquote><h5 id="实际应用场景示例">实际应用场景示例</h5><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></pre></td><td class="code"><pre><span class="line"><span class="keyword">import</span> gevent</span><br><span class="line"><span class="keyword">from</span> gevent <span class="keyword">import</span> monkey</span><br><span class="line"><span class="keyword">import</span> requests</span><br><span class="line"><span class="keyword">import</span> time</span><br><span class="line"></span><br><span class="line"><span class="comment">## 替换标准库</span></span><br><span class="line">monkey.patch_all()</span><br><span class="line"></span><br><span class="line"><span class="keyword">def</span> <span class="title function_">fetch_url</span>(<span class="params">url</span>):</span><br><span class="line">    <span class="string">&quot;&quot;&quot;获取URL内容的函数</span></span><br><span class="line"><span class="string">    </span></span><br><span class="line"><span class="string">    Args:</span></span><br><span class="line"><span class="string">        url: 要获取的网址</span></span><br><span class="line"><span class="string">        </span></span><br><span class="line"><span class="string">    Returns:</span></span><br><span class="line"><span class="string">        tuple: (url, 响应状态码, 内容长度)</span></span><br><span class="line"><span class="string">    &quot;&quot;&quot;</span></span><br><span class="line">    <span class="keyword">try</span>:</span><br><span class="line">        <span class="built_in">print</span>(<span class="string">f&quot;开始请求: <span class="subst">&#123;url&#125;</span>&quot;</span>)</span><br><span class="line">        start = time.time()</span><br><span class="line">        response = requests.get(url, timeout=<span class="number">5</span>)  <span class="comment"># 进行HTTP请求，IO操作会自动切换</span></span><br><span class="line">        elapsed = time.time() - start</span><br><span class="line">        <span class="built_in">print</span>(<span class="string">f&quot;完成请求: <span class="subst">&#123;url&#125;</span>, 耗时: <span class="subst">&#123;elapsed:<span class="number">.2</span>f&#125;</span>秒&quot;</span>)</span><br><span class="line">        <span class="keyword">return</span> url, response.status_code, <span class="built_in">len</span>(response.content)</span><br><span class="line">    <span class="keyword">except</span> Exception <span class="keyword">as</span> e:</span><br><span class="line">        <span class="built_in">print</span>(<span class="string">f&quot;请求 <span class="subst">&#123;url&#125;</span> 出错: <span class="subst">&#123;e&#125;</span>&quot;</span>)</span><br><span class="line">        <span class="keyword">return</span> url, <span class="number">0</span>, <span class="number">0</span></span><br><span class="line"></span><br><span class="line"><span class="comment">## 要获取的URL列表</span></span><br><span class="line">urls = [</span><br><span class="line">    <span class="string">&quot;https://www.python.org&quot;</span>,</span><br><span class="line">    <span class="string">&quot;https://www.github.com&quot;</span>,</span><br><span class="line">    <span class="string">&quot;https://www.stackoverflow.com&quot;</span>,</span><br><span class="line">    <span class="string">&quot;https://www.wikipedia.org&quot;</span>,</span><br><span class="line">    <span class="string">&quot;https://www.reddit.com&quot;</span></span><br><span class="line">]</span><br><span class="line"></span><br><span class="line">start_time = time.time()</span><br><span class="line"></span><br><span class="line"><span class="comment">## 创建协程任务</span></span><br><span class="line">tasks = [gevent.spawn(fetch_url, url) <span class="keyword">for</span> url <span class="keyword">in</span> urls]</span><br><span class="line"></span><br><span class="line"><span class="comment">## 等待所有任务完成</span></span><br><span class="line">gevent.joinall(tasks)</span><br><span class="line"></span><br><span class="line"><span class="comment">## 获取结果</span></span><br><span class="line">results = [task.value <span class="keyword">for</span> task <span class="keyword">in</span> tasks]</span><br><span class="line"></span><br><span class="line"><span class="comment">## 打印结果</span></span><br><span class="line"><span class="built_in">print</span>(<span class="string">&quot;\n结果汇总:&quot;</span>)</span><br><span class="line"><span class="keyword">for</span> url, status, length <span class="keyword">in</span> results:</span><br><span class="line">    <span class="built_in">print</span>(<span class="string">f&quot;URL: <span class="subst">&#123;url&#125;</span>, 状态码: <span class="subst">&#123;status&#125;</span>, 内容长度: <span class="subst">&#123;length&#125;</span> 字节&quot;</span>)</span><br><span class="line"></span><br><span class="line"><span class="built_in">print</span>(<span class="string">f&quot;\n总耗时: <span class="subst">&#123;time.time() - start_time:<span class="number">.2</span>f&#125;</span>秒&quot;</span>)</span><br></pre></td></tr></table></figure><h4 id="greenlet-与-gevent-的区别与选择">greenlet 与 gevent 的区别与选择</h4><table><thead><tr><th>特性</th><th>greenlet</th><th>gevent</th><th>实际应用建议</th></tr></thead><tbody><tr><td>基本原理</td><td>轻量级上下文切换</td><td>基于 greenlet，增加事件循环</td><td>简单任务用 greenlet，复杂系统用 gevent</td></tr><tr><td>IO 处理</td><td>不提供 IO 操作支持</td><td>提供自动 IO 切换机制</td><td>网络应用选择 gevent，自定义调度选择 greenlet</td></tr><tr><td>切换方式</td><td>需要显式调用 switch()</td><td>在 IO 操作时自动切换</td><td>手动控制流程用 greenlet，自动化处理用 gevent</td></tr><tr><td>复杂度</td><td>简单，仅提供基本切换</td><td>复杂，提供完整生态系统</td><td>小型项目用 greenlet，大型项目用 gevent</td></tr><tr><td>适用场景</td><td>简单协程调度</td><td>高并发网络应用</td><td>Web 爬虫、API 服务、代理服务器首选 gevent</td></tr><tr><td>性能</td><td>轻量，开销小</td><td>比 greenlet 略重，但实用性强</td><td>极致性能用 greenlet，平衡性能和开发效率用 gevent</td></tr><tr><td>学习曲线</td><td>简单，容易理解</td><td>较复杂，概念较多</td><td>入门协程从 greenlet 开始，再过渡到 gevent</td></tr><tr><td>社区支持</td><td>基础库，更新较少</td><td>活跃，有完整生态</td><td>长期项目建议使用 gevent</td></tr></tbody></table><blockquote><p>🌟 <strong>选择建议</strong>：如果只需要轻量级的上下文切换，可以使用 greenlet；如果需要处理 IO 密集型应用，特别是网络编程，建议使用 gevent。大多数实际项目中，gevent 是更好的选择，因为它提供了更完整的功能和自动化的 IO 处理。</p></blockquote><h4 id="asyncio-协程技术">asyncio 协程技术</h4><p>随着 Python 的发展，协程技术已经有了显著进步。从 Python 3.4 引入的 <code>asyncio</code> 库开始，Python 对协程的原生支持不断增强。到 2025 年，Python 已经拥有更成熟、更高效的协程生态系统。</p><h5 id="asyncio-与原生协程">asyncio 与原生协程</h5><p>Python 3.5 引入的 <code>async/await</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></pre></td><td class="code"><pre><span class="line"><span class="keyword">import</span> asyncio</span><br><span class="line"><span class="keyword">import</span> time</span><br><span class="line"></span><br><span class="line"><span class="keyword">async</span> <span class="keyword">def</span> <span class="title function_">fetch_data</span>(<span class="params">url,delay</span>):</span><br><span class="line">    <span class="string">&quot;&quot;&quot;模拟从网络获取数据的异步函数&quot;&quot;&quot;</span></span><br><span class="line">    <span class="built_in">print</span>(<span class="string">f&quot;开始获取数据：<span class="subst">&#123;url&#125;</span>，延迟<span class="subst">&#123;delay&#125;</span>秒&quot;</span>)</span><br><span class="line">    <span class="keyword">await</span> asyncio.sleep(delay)</span><br><span class="line">    <span class="built_in">print</span>(<span class="string">f&quot;成功获取数据长度：<span class="subst">&#123;<span class="built_in">len</span>(url)&#125;</span>&quot;</span>)</span><br><span class="line">    <span class="keyword">return</span> <span class="string">f&quot;数据<span class="subst">&#123;url&#125;</span>&quot;</span></span><br><span class="line"></span><br><span class="line"><span class="keyword">async</span> <span class="keyword">def</span> <span class="title function_">main</span>():</span><br><span class="line">    <span class="string">&quot;&quot;&quot;异步操作的主函数&quot;&quot;&quot;</span></span><br><span class="line">    <span class="built_in">print</span>(<span class="string">f&quot;程序开始时间:<span class="subst">&#123;time.strftime(<span class="string">&#x27;%Y-%m-%d %H:%M:%S&#x27;</span>, time.localtime())&#125;</span>&quot;</span>)</span><br><span class="line"></span><br><span class="line">    <span class="built_in">print</span>(<span class="string">&quot;\n===== 串行执行示例 =====&quot;</span>)</span><br><span class="line">    start_time = time.time()</span><br><span class="line">    <span class="comment"># 串行执行 - 请求两个API数据</span></span><br><span class="line">    result1 = <span class="keyword">await</span> fetch_data(<span class="string">&quot;https://www.baidu.com&quot;</span>, <span class="number">2</span>)</span><br><span class="line">    result2 = <span class="keyword">await</span> fetch_data(<span class="string">&quot;https://www.sina.com.cn&quot;</span>,<span class="number">3</span>)</span><br><span class="line">    <span class="built_in">print</span>(<span class="string">f&quot;串行执行结果：<span class="subst">&#123;result1&#125;</span>, <span class="subst">&#123;result2&#125;</span>&quot;</span>)</span><br><span class="line">    end_time = time.time()</span><br><span class="line">    <span class="built_in">print</span>(<span class="string">f&quot;程序结束时间:<span class="subst">&#123;time.strftime(<span class="string">&#x27;%Y-%m-%d %H:%M:%S&#x27;</span>, time.localtime())&#125;</span>&quot;</span>)</span><br><span class="line">    <span class="built_in">print</span>(<span class="string">f&quot;程序耗时:<span class="subst">&#123;end_time-start_time&#125;</span>秒&quot;</span>)</span><br><span class="line"></span><br><span class="line">    <span class="comment"># 并行执行 - 请求两个API数据</span></span><br><span class="line">    <span class="built_in">print</span>(<span class="string">&quot;\n===== 并行执行示例 =====&quot;</span>)</span><br><span class="line">    start_time = time.time()</span><br><span class="line">    tasks = [</span><br><span class="line">        asyncio.create_task(fetch_data(<span class="string">&quot;https://www.baidu.com&quot;</span>, <span class="number">2</span>)),</span><br><span class="line">        asyncio.create_task(fetch_data(<span class="string">&quot;https://www.sina.com.cn&quot;</span>,<span class="number">3</span>)),</span><br><span class="line">    ]</span><br><span class="line">    results = <span class="keyword">await</span> asyncio.gather(*tasks) <span class="comment"># 批量等待所有任务完成</span></span><br><span class="line">    <span class="built_in">print</span>(<span class="string">f&quot;并行执行结果：<span class="subst">&#123;results&#125;</span>&quot;</span>)</span><br><span class="line">    end_time = time.time()</span><br><span class="line">    <span class="built_in">print</span>(<span class="string">f&quot;程序结束时间:<span class="subst">&#123;time.strftime(<span class="string">&#x27;%Y-%m-%d %H:%M:%S&#x27;</span>, time.localtime())&#125;</span>&quot;</span>)</span><br><span class="line">    <span class="built_in">print</span>(<span class="string">f&quot;程序耗时:<span class="subst">&#123;end_time-start_time&#125;</span>秒&quot;</span>)</span><br><span class="line"></span><br><span class="line"><span class="keyword">if</span> __name__ == <span class="string">&#x27;__main__&#x27;</span>:</span><br><span class="line">    <span class="comment"># 在Python 3.7+中，可以直接使用asyncio.run()运行主协程</span></span><br><span class="line">    asyncio.run(main())</span><br><span class="line"></span><br></pre></td></tr></table></figure><h5 id="asyncio-常用-API">asyncio 常用 API</h5><table><thead><tr><th>方法/函数</th><th>描述</th><th>使用场景</th><th>示例</th></tr></thead><tbody><tr><td><code>asyncio.run()</code></td><td>运行协程</td><td>程序入口点</td><td><code>asyncio.run(main())</code></td></tr><tr><td><code>asyncio.create_task()</code></td><td>创建任务</td><td>并行执行协程</td><td><code>task = asyncio.create_task(coro())</code></td></tr><tr><td><code>asyncio.gather()</code></td><td>并行运行多个协程</td><td>批量并发任务</td><td><code>results = await asyncio.gather(coro1(), coro2())</code></td></tr><tr><td><code>asyncio.wait_for()</code></td><td>带超时的等待</td><td>实现超时控制</td><td><code>await asyncio.wait_for(coro(), timeout=1.0)</code></td></tr><tr><td><code>asyncio.sleep()</code></td><td>非阻塞睡眠</td><td>模拟 IO 延迟</td><td><code>await asyncio.sleep(1.0)</code></td></tr><tr><td><code>asyncio.Queue</code></td><td>协程安全的队列</td><td>协程间数据传递</td><td><code>queue = asyncio.Queue(); await queue.put(item)</code></td></tr><tr><td><code>asyncio.Future</code></td><td>低级异步原语</td><td>自定义异步操作</td><td><code>future = asyncio.Future(); future.set_result(value)</code></td></tr><tr><td><code>asyncio.shield()</code></td><td>防止取消传播</td><td>保护关键协程</td><td><code>await asyncio.shield(critical_coro())</code></td></tr><tr><td><code>asyncio.as_completed()</code></td><td>按完成顺序返回结果</td><td>处理最先完成的任务</td><td><code>for task in asyncio.as_completed([coro1(), coro2()]): result = await task</code></td></tr></tbody></table><h4 id="Task-对象">Task 对象</h4><p><code>Task</code> 是 <code>asyncio</code> 中用于封装协程的对象，可以用于并发执行多个任务。可以通过 <code>Task</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></pre></td><td class="code"><pre><span class="line"><span class="keyword">import</span> asyncio</span><br><span class="line"></span><br><span class="line"><span class="keyword">async</span> <span class="keyword">def</span> <span class="title function_">nested</span>():</span><br><span class="line">    <span class="built_in">print</span>(<span class="string">&#x27;进入 nested()&#x27;</span>)</span><br><span class="line">    <span class="keyword">await</span> asyncio.sleep(<span class="number">1</span>)  <span class="comment"># 模拟IO操作</span></span><br><span class="line">    <span class="built_in">print</span>(<span class="string">&#x27;离开 nested()&#x27;</span>)</span><br><span class="line">    <span class="keyword">return</span> <span class="string">&#x27;42&#x27;</span></span><br><span class="line"></span><br><span class="line"><span class="keyword">async</span> <span class="keyword">def</span> <span class="title function_">main</span>():</span><br><span class="line">    task = asyncio.create_task(nested())  <span class="comment"># 创建任务</span></span><br><span class="line">    result = <span class="keyword">await</span> task  <span class="comment"># 等待任务完成</span></span><br><span class="line">    <span class="built_in">print</span>(<span class="string">f&#x27;返回值：<span class="subst">&#123;result&#125;</span>&#x27;</span>)</span><br><span class="line"></span><br><span class="line">asyncio.run(main())</span><br></pre></td></tr></table></figure><h4 id="Future-对象">Future 对象</h4><p><code>Future</code> 是 <code>Task</code> 的基类，表示一个未完成的结果。在底层异步操作中，<code>Future</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></pre></td><td class="code"><pre><span class="line"><span class="keyword">import</span> asyncio</span><br><span class="line"></span><br><span class="line"><span class="comment"># 定义一个异步函数，用于设置future的结果</span></span><br><span class="line"><span class="keyword">async</span> <span class="keyword">def</span> <span class="title function_">set_future_result</span>(<span class="params">future</span>):</span><br><span class="line">    <span class="comment"># 异步等待2秒，模拟耗时操作</span></span><br><span class="line">    <span class="keyword">await</span> asyncio.sleep(<span class="number">2</span>)</span><br><span class="line">    <span class="comment"># 设置future的结果为&quot;Hello, world!&quot;</span></span><br><span class="line">    future.set_result(<span class="string">&quot;Hello, world!&quot;</span>)</span><br><span class="line"></span><br><span class="line"><span class="comment"># 定义主异步函数</span></span><br><span class="line"><span class="keyword">async</span> <span class="keyword">def</span> <span class="title function_">main</span>():</span><br><span class="line">    <span class="comment"># 获取当前正在运行的事件循环</span></span><br><span class="line">    loop = asyncio.get_running_loop()</span><br><span class="line">    <span class="comment"># 创建Future对象，它代表一个尚未完成的异步操作</span></span><br><span class="line">    future = loop.create_future()  <span class="comment"># 创建Future对象</span></span><br><span class="line">    <span class="comment"># 创建一个任务来执行set_future_result函数，不等待其完成立即返回</span></span><br><span class="line">    asyncio.create_task(set_future_result(future))</span><br><span class="line">    <span class="comment"># 等待future完成并获取其结果</span></span><br><span class="line">    result = <span class="keyword">await</span> future  <span class="comment"># 等待Future完成</span></span><br><span class="line">    <span class="comment"># 打印future的结果</span></span><br><span class="line">    <span class="built_in">print</span>(<span class="string">f&quot;Future的结果: <span class="subst">&#123;result&#125;</span>&quot;</span>)</span><br><span class="line"></span><br><span class="line"><span class="comment"># 运行主异步函数</span></span><br><span class="line">asyncio.run(main())</span><br></pre></td></tr></table></figure><h4 id="异步上下文管理器">异步上下文管理器</h4><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></pre></td><td class="code"><pre><span class="line"><span class="keyword">import</span> asyncio</span><br><span class="line"></span><br><span class="line"><span class="keyword">class</span> <span class="title class_">AsyncResource</span>:</span><br><span class="line">    <span class="keyword">async</span> <span class="keyword">def</span> <span class="title function_">__aenter__</span>(<span class="params">self</span>):</span><br><span class="line">        <span class="built_in">print</span>(<span class="string">&quot;资源获取&quot;</span>)</span><br><span class="line">        <span class="keyword">return</span> <span class="variable language_">self</span></span><br><span class="line"></span><br><span class="line">    <span class="keyword">async</span> <span class="keyword">def</span> <span class="title function_">__aexit__</span>(<span class="params">self, exc_type, exc_value, traceback</span>):</span><br><span class="line">        <span class="built_in">print</span>(<span class="string">&quot;资源释放&quot;</span>)</span><br><span class="line"></span><br><span class="line"><span class="keyword">async</span> <span class="keyword">def</span> <span class="title function_">main</span>():</span><br><span class="line">    <span class="keyword">async</span> <span class="keyword">with</span> AsyncResource():</span><br><span class="line">        <span class="built_in">print</span>(<span class="string">&quot;执行任务中&quot;</span>)</span><br><span class="line"></span><br><span class="line">asyncio.run(main())</span><br></pre></td></tr></table></figure><h3 id="14-6-GIL-锁与-Python-并发性能">14.6 GIL 锁与 Python 并发性能</h3><p>GIL(Global Interpreter Lock，全局解释器锁)是 CPython 解释器的一个特性，它确保同一时刻只有一个线程可以执行 Python 字节码。这个特性对 Python 多线程编程有着深远影响，也是导致 Python 速度慢的两大原因之一，其另外一个原因是因为 Python 是 <code>解释形</code> 语言，但后续可通过 <code>pypy</code> 技术实现 Python 的预编译，但唯独这个原因 Python 没有解决，Python 在早期开发时为解决垃圾回收机制内部问题采用了 GIL 锁，所以 Python 程序无法直接利用多核 CPU 的优势</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></pre></td><td class="code"><pre><span class="line"><span class="string">&#x27;&#x27;&#x27;</span></span><br><span class="line"><span class="string">GIL全局解释器锁(Global Interpreter Lock)，是CPython特有的一个物件，</span></span><br><span class="line"><span class="string">作用是让一个进程中同一时刻只能有一个线程可以被CPU调用</span></span><br><span class="line"><span class="string"></span></span><br><span class="line"><span class="string">如果程序想利用计算机的多核优势，让CPU同时处理一些任务，适合用多进程开发（即使资源开销大）</span></span><br><span class="line"><span class="string">如果程序不想利用计算机的多核优势，适合用多线程开发</span></span><br><span class="line"><span class="string">&#x27;&#x27;&#x27;</span></span><br></pre></td></tr></table></figure><h4 id="GIL-的本质与工作原理">GIL 的本质与工作原理</h4><p>GIL 本质上是一把互斥锁，用于保护 Python 解释器的内部状态，主要解决了 Python 对象的内存管理问题。</p><table><thead><tr><th>GIL 特性</th><th>描述</th></tr></thead><tbody><tr><td>实现方式</td><td>互斥锁(mutex)</td></tr><tr><td>作用对象</td><td>Python 解释器进程</td></tr><tr><td>控制范围</td><td>Python 字节码执行</td></tr><tr><td>释放时机</td><td>I/O 操作、执行固定字节码数量后</td></tr><tr><td>影响范围</td><td>仅影响 CPython，PyPy、Jython、IronPython 不受影响</td></tr></tbody></table><blockquote><p>🔍 <strong>深入理解</strong>：GIL 并非 Python 语言本身的特性，而是 CPython 实现的产物。它解决了 CPython 简单引用计数式内存管理的线程安全问题，但也限制了多线程程序利用多核性能的能力。</p></blockquote><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></pre></td><td class="code"><pre><span class="line"><span class="comment">## GIL工作示意伪代码</span></span><br><span class="line"><span class="keyword">def</span> <span class="title function_">thread_execution</span>():</span><br><span class="line">    <span class="keyword">while</span> <span class="literal">True</span>:</span><br><span class="line">        acquire_GIL()       <span class="comment"># 获取GIL锁</span></span><br><span class="line">        execute_bytecodes() <span class="comment"># 执行一定数量的字节码</span></span><br><span class="line">        release_GIL()       <span class="comment"># 释放GIL锁以允许其他线程运行</span></span><br><span class="line">        wait_for_GIL()      <span class="comment"># 等待再次获取GIL</span></span><br><span class="line">        </span><br><span class="line"><span class="comment"># 这也就导致了每一个线程都需要在执行获取字节码时都要经历拿锁-&gt;解锁的过程</span></span><br></pre></td></tr></table></figure><h4 id="并发与并行的区别">并发与并行的区别</h4><p>并发(Concurrency)和并行(Parallelism)是两个在计算机科学中经常出现的概念，虽然常被混用，但有着本质区别：</p><table><thead><tr><th>特性</th><th>并发(Concurrency)</th><th>并行(Parallelism)</th></tr></thead><tbody><tr><td>定义</td><td>多个任务在同一时间间隔内发生</td><td>多个任务在同一时刻发生</td></tr><tr><td>重点</td><td>任务切换与调度</td><td>任务的同时执行</td></tr><tr><td>资源需求</td><td>可以在单处理器上通过时间片轮转实现</td><td>需要多个处理器或核心</td></tr><tr><td>执行方式</td><td>任务交替执行，共享处理器时间</td><td>每个任务有独立的处理器同时执行</td></tr><tr><td>适用场景</td><td>I/O 密集型任务，如网络请求、文件读写</td><td>计算密集型任务，如图像处理、科学计算</td></tr><tr><td>实现难度</td><td>相对简单，关注任务调度</td><td>相对复杂，需考虑数据分割、同步和合并</td></tr><tr><td>Python 实现</td><td>多线程、协程</td><td>多进程</td></tr></tbody></table><blockquote><p>🌟 <strong>关键理解</strong>：由于 GIL 的存在，Python 的多线程实际上只能实现并发，而不能实现真正的并行。要实现并行，需要使用多进程或依赖不受 GIL 限制的扩展库（如使用 C 扩展的 NumPy）。</p></blockquote><h4 id="线程安全与并发控制">线程安全与并发控制</h4><p>线程安全指在多线程环境下，程序能够正确地处理共享资源，不会因为多线程同时访问而导致数据不一致。尽管 Python 的 GIL 能减轻一些并发问题，但并不能完全保证线程安全。</p><h5 id="线程安全问题示例">线程安全问题示例</h5><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></pre></td><td class="code"><pre><span class="line"><span class="keyword">from</span> threading <span class="keyword">import</span> Thread</span><br><span class="line"><span class="keyword">import</span> time</span><br><span class="line"><span class="keyword">import</span> random</span><br><span class="line"></span><br><span class="line"><span class="comment">### 共享的全局变量</span></span><br><span class="line">counter = <span class="number">0</span></span><br><span class="line">start_time = time.time()</span><br><span class="line">iterations_completed = <span class="number">0</span></span><br><span class="line"></span><br><span class="line"><span class="keyword">def</span> <span class="title function_">increment_counter</span>():</span><br><span class="line">    <span class="string">&quot;&quot;&quot;增加计数器，但使用了非原子操作的方式&quot;&quot;&quot;</span></span><br><span class="line">    <span class="keyword">global</span> counter, iterations_completed</span><br><span class="line">    <span class="keyword">for</span> _ <span class="keyword">in</span> <span class="built_in">range</span>(<span class="number">1000000</span>):</span><br><span class="line">        <span class="comment"># 模拟线程安全问题：读取-修改-写入过程中可能被中断</span></span><br><span class="line">        local_counter = counter  <span class="comment"># 读取当前值</span></span><br><span class="line">        </span><br><span class="line">        <span class="comment"># 模拟线程在读取后被切换的情况</span></span><br><span class="line">        <span class="comment"># 随机休眠一个很小的时间，增加线程切换的可能性</span></span><br><span class="line">        <span class="keyword">if</span> random.random() &lt; <span class="number">0.00001</span>:</span><br><span class="line">            time.sleep(<span class="number">0.00001</span>)</span><br><span class="line">            </span><br><span class="line">        local_counter += <span class="number">1</span>  <span class="comment"># 在本地修改</span></span><br><span class="line">        counter = local_counter  <span class="comment"># 写回全局变量</span></span><br><span class="line">        iterations_completed += <span class="number">1</span></span><br><span class="line"></span><br><span class="line"></span><br><span class="line"><span class="keyword">def</span> <span class="title function_">run_concurrent_threads</span>(<span class="params">num_threads</span>):</span><br><span class="line">    <span class="string">&quot;&quot;&quot;运行多个线程同时增加计数器&quot;&quot;&quot;</span></span><br><span class="line">    <span class="keyword">global</span> counter, iterations_completed</span><br><span class="line">    counter = <span class="number">0</span></span><br><span class="line">    iterations_completed = <span class="number">0</span></span><br><span class="line">    </span><br><span class="line">    threads = []</span><br><span class="line">    <span class="keyword">for</span> _ <span class="keyword">in</span> <span class="built_in">range</span>(num_threads):</span><br><span class="line">        t = Thread(target=increment_counter)</span><br><span class="line">        threads.append(t)</span><br><span class="line">        t.start()</span><br><span class="line">    </span><br><span class="line">    <span class="keyword">for</span> t <span class="keyword">in</span> threads:</span><br><span class="line">        t.join()</span><br><span class="line">    </span><br><span class="line">    <span class="comment"># 理论上应该等于 num_threads * 1000000</span></span><br><span class="line">    expected = num_threads * <span class="number">1000000</span></span><br><span class="line">    <span class="built_in">print</span>(<span class="string">f&quot;预期结果: <span class="subst">&#123;expected&#125;</span>&quot;</span>)</span><br><span class="line">    <span class="built_in">print</span>(<span class="string">f&quot;实际结果: <span class="subst">&#123;counter&#125;</span>&quot;</span>)</span><br><span class="line">    <span class="built_in">print</span>(<span class="string">f&quot;丢失的增量: <span class="subst">&#123;expected - counter&#125;</span>&quot;</span>)</span><br><span class="line">    <span class="built_in">print</span>(<span class="string">f&quot;完成的迭代次数: <span class="subst">&#123;iterations_completed&#125;</span>&quot;</span>)</span><br><span class="line"></span><br><span class="line"></span><br><span class="line"><span class="keyword">if</span> __name__ == <span class="string">&#x27;__main__&#x27;</span>:</span><br><span class="line">    <span class="built_in">print</span>(<span class="string">f&#x27;开始时间为: <span class="subst">&#123;time.strftime(<span class="string">&quot;%Y-%m-%d %H:%M:%S&quot;</span>, time.localtime())&#125;</span>&#x27;</span>)</span><br><span class="line">    </span><br><span class="line">    <span class="comment"># 运行4个线程，每个线程增加计数器1000000次</span></span><br><span class="line">    <span class="comment"># 理论上最终结果应该是4000000，但由于线程安全问题，实际结果会小于这个值</span></span><br><span class="line">    run_concurrent_threads(<span class="number">4</span>)</span><br><span class="line">    </span><br><span class="line">    <span class="built_in">print</span>(<span class="string">f&quot;累计用时: <span class="subst">&#123;<span class="built_in">round</span>(time.time() - start_time, <span class="number">1</span>)&#125;</span>秒&quot;</span>)</span><br><span class="line">    </span><br><span class="line"></span><br></pre></td></tr></table></figure><h5 id="使用线程锁解决安全问题">使用线程锁解决安全问题</h5><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></pre></td><td class="code"><pre><span class="line"><span class="keyword">from</span> threading <span class="keyword">import</span> Thread, Lock</span><br><span class="line"><span class="keyword">import</span> time</span><br><span class="line"><span class="keyword">import</span> random</span><br><span class="line"></span><br><span class="line"><span class="comment">### 共享的全局变量</span></span><br><span class="line">counter = <span class="number">0</span></span><br><span class="line">start_time = time.time()</span><br><span class="line">iterations_completed = <span class="number">0</span></span><br><span class="line"><span class="comment"># 创建一个线程锁</span></span><br><span class="line">counter_lock = Lock()</span><br><span class="line"></span><br><span class="line"></span><br><span class="line"><span class="keyword">def</span> <span class="title function_">increment_counter</span>():</span><br><span class="line">    <span class="string">&quot;&quot;&quot;增加计数器，使用线程锁确保线程安全&quot;&quot;&quot;</span></span><br><span class="line">    <span class="keyword">global</span> counter, iterations_completed</span><br><span class="line">    <span class="keyword">for</span> _ <span class="keyword">in</span> <span class="built_in">range</span>(<span class="number">1000000</span>):</span><br><span class="line">        <span class="comment"># 使用线程锁保护临界区</span></span><br><span class="line">        <span class="keyword">with</span> counter_lock:</span><br><span class="line">            counter += <span class="number">1</span>  <span class="comment"># 在锁的保护下直接修改全局变量</span></span><br><span class="line">            iterations_completed += <span class="number">1</span></span><br><span class="line"></span><br><span class="line"></span><br><span class="line"><span class="keyword">def</span> <span class="title function_">run_concurrent_threads</span>(<span class="params">num_threads</span>):</span><br><span class="line">    <span class="string">&quot;&quot;&quot;运行多个线程同时增加计数器&quot;&quot;&quot;</span></span><br><span class="line">    <span class="keyword">global</span> counter, iterations_completed</span><br><span class="line">    counter = <span class="number">0</span></span><br><span class="line">    iterations_completed = <span class="number">0</span></span><br><span class="line"></span><br><span class="line">    threads = []</span><br><span class="line">    <span class="keyword">for</span> _ <span class="keyword">in</span> <span class="built_in">range</span>(num_threads):</span><br><span class="line">        t = Thread(target=increment_counter)</span><br><span class="line">        threads.append(t)</span><br><span class="line">        t.start()</span><br><span class="line"></span><br><span class="line">    <span class="keyword">for</span> t <span class="keyword">in</span> threads:</span><br><span class="line">        t.join()</span><br><span class="line"></span><br><span class="line">    <span class="comment"># 理论上应该等于 num_threads * 1000000</span></span><br><span class="line">    expected = num_threads * <span class="number">1000000</span></span><br><span class="line">    <span class="built_in">print</span>(<span class="string">f&quot;预期结果: <span class="subst">&#123;expected&#125;</span>&quot;</span>)</span><br><span class="line">    <span class="built_in">print</span>(<span class="string">f&quot;实际结果: <span class="subst">&#123;counter&#125;</span>&quot;</span>)</span><br><span class="line">    <span class="built_in">print</span>(<span class="string">f&quot;丢失的增量: <span class="subst">&#123;expected - counter&#125;</span>&quot;</span>)</span><br><span class="line">    <span class="built_in">print</span>(<span class="string">f&quot;完成的迭代次数: <span class="subst">&#123;iterations_completed&#125;</span>&quot;</span>)</span><br><span class="line"></span><br><span class="line"></span><br><span class="line"><span class="keyword">if</span> __name__ == <span class="string">&#x27;__main__&#x27;</span>:</span><br><span class="line">    <span class="built_in">print</span>(<span class="string">f&#x27;开始时间为: <span class="subst">&#123;time.strftime(<span class="string">&quot;%Y-%m-%d %H:%M:%S&quot;</span>, time.localtime())&#125;</span>&#x27;</span>)</span><br><span class="line"></span><br><span class="line">    <span class="comment"># 运行4个线程，每个线程增加计数器1000000次</span></span><br><span class="line">    <span class="comment"># 使用线程锁后，最终结果应该正确等于4000000</span></span><br><span class="line">    run_concurrent_threads(<span class="number">4</span>)</span><br><span class="line"></span><br><span class="line">    <span class="built_in">print</span>(<span class="string">f&quot;累计用时: <span class="subst">&#123;<span class="built_in">round</span>(time.time() - start_time, <span class="number">1</span>)&#125;</span>秒&quot;</span>)</span><br><span class="line"></span><br></pre></td></tr></table></figure><blockquote><p>🔒 <strong>线程锁作用与注意事项</strong>：</p><ul><li>锁确保同一时刻只有一个线程能访问共享资源</li><li>锁会影响性能，特别是在竞争激烈的情况下</li><li>锁的粒度需要权衡：粒度太细会增加锁操作开销，太粗会降低并发度</li><li>锁可能引发死锁问题，需谨慎设计锁的获取顺序</li></ul></blockquote><h4 id="Python-中的锁机制全面解析">Python 中的锁机制全面解析</h4><p>Python 的 <code>threading</code> 模块提供了多种锁和同步原语，用于不同并发控制场景。深入理解这些锁的特性和适用场景，对于开发可靠的并发程序至关重要。</p><h5 id="Python-锁类型及其特性">Python 锁类型及其特性</h5><table><thead><tr><th>锁类型</th><th>描述</th><th>独占性</th><th>可重入性</th><th>公平性</th><th>注意事项</th></tr></thead><tbody><tr><td><code>threading.Lock</code></td><td>基本互斥锁</td><td>是</td><td>否</td><td>非公平</td><td>最简单的锁，同一线程不能重复获取</td></tr><tr><td><code>threading.RLock</code></td><td>可重入锁</td><td>是</td><td>是</td><td>非公平</td><td>同一线程可多次获取，必须对应释放相同次数</td></tr><tr><td><code>threading.Condition</code></td><td>条件变量</td><td>-</td><td>-</td><td>非公平</td><td>基于锁实现，提供 wait/notify 机制</td></tr><tr><td><code>threading.Semaphore</code></td><td>信号量</td><td>否</td><td>-</td><td>非公平</td><td>限制资源访问线程数量</td></tr><tr><td><code>threading.BoundedSemaphore</code></td><td>有界信号量</td><td>否</td><td>-</td><td>非公平</td><td>限制资源数量，防止过度释放</td></tr><tr><td><code>threading.Event</code></td><td>事件对象</td><td>-</td><td>-</td><td>-</td><td>用于线程间通知而非资源控制</td></tr><tr><td><code>threading.Barrier</code></td><td>栅栏对象</td><td>-</td><td>-</td><td>-</td><td>使多个线程同步到达某点再继续</td></tr><tr><td><code>queue.Queue</code></td><td>线程安全队列</td><td>-</td><td>-</td><td>先进先出</td><td>内部带锁，用于线程间数据传递</td></tr><tr><td><code>multiprocessing.Lock</code></td><td>进程锁</td><td>是</td><td>否</td><td>非公平</td><td>用于进程间同步的锁</td></tr><tr><td><code>asyncio.Lock</code></td><td>异步锁</td><td>是</td><td>否</td><td>-</td><td>用于协程间的同步</td></tr></tbody></table><h5 id="互斥锁-Lock">互斥锁(Lock)</h5><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><span class="line">44</span><br><span class="line">45</span><br><span class="line">46</span><br><span class="line">47</span><br><span class="line">48</span><br><span class="line">49</span><br><span class="line">50</span><br><span class="line">51</span><br><span class="line">52</span><br><span class="line">53</span><br><span class="line">54</span><br><span class="line">55</span><br><span class="line">56</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">import</span> concurrent</span><br><span class="line"><span class="keyword">import</span> threading</span><br><span class="line"><span class="keyword">import</span> time</span><br><span class="line"><span class="keyword">from</span> concurrent.futures <span class="keyword">import</span> ThreadPoolExecutor</span><br><span class="line"></span><br><span class="line">lock = threading.Lock()</span><br><span class="line">shared_data = <span class="number">0</span></span><br><span class="line"></span><br><span class="line"></span><br><span class="line"><span class="keyword">def</span> <span class="title function_">shared_resource</span>(<span class="params">thread_id: <span class="built_in">int</span></span>):</span><br><span class="line">    <span class="string">&quot;&quot;&quot;访问共享资源的函数</span></span><br><span class="line"><span class="string">    Args:thread_id: 线程ID，用于标识不同线程</span></span><br><span class="line"><span class="string">    &quot;&quot;&quot;</span></span><br><span class="line">    <span class="comment"># 尝试获取锁</span></span><br><span class="line">    <span class="keyword">if</span> lock.acquire(timeout=<span class="number">1</span>):  <span class="comment"># 添加超时参数，防止无限等待</span></span><br><span class="line">        <span class="built_in">print</span>(<span class="string">f&quot;线程<span class="subst">&#123;thread_id&#125;</span>获取锁&quot;</span>)</span><br><span class="line">        <span class="keyword">try</span>:</span><br><span class="line">            <span class="keyword">global</span> shared_data</span><br><span class="line">            <span class="comment"># 读取-修改-写入操作需要原子性保护</span></span><br><span class="line">            current = shared_data</span><br><span class="line">            time.sleep(<span class="number">0.1</span>)  <span class="comment"># 模拟处理延时，增加竞争概率</span></span><br><span class="line">            shared_data = current + <span class="number">1</span></span><br><span class="line">            <span class="built_in">print</span>(<span class="string">f&quot;线程<span class="subst">&#123;thread_id&#125;</span>修改共享数据，当前值为<span class="subst">&#123;shared_data&#125;</span>&quot;</span>)</span><br><span class="line">        <span class="keyword">finally</span>:</span><br><span class="line">            <span class="comment"># 释放锁</span></span><br><span class="line">            lock.release()</span><br><span class="line">            <span class="built_in">print</span>(<span class="string">f&quot;线程<span class="subst">&#123;thread_id&#125;</span>释放锁&quot;</span>)</span><br><span class="line">    <span class="keyword">else</span>:</span><br><span class="line">        <span class="built_in">print</span>(<span class="string">f&quot;线程<span class="subst">&#123;thread_id&#125;</span>获取锁失败&quot;</span>)</span><br><span class="line">        </span><br><span class="line"></span><br><span class="line"><span class="keyword">def</span> <span class="title function_">shared_resource2</span>(<span class="params">thread_id: <span class="built_in">int</span></span>):</span><br><span class="line">    <span class="string">&quot;&quot;&quot;访问共享资源的函数</span></span><br><span class="line"><span class="string">    使用with语句，自动释放锁简化代码</span></span><br><span class="line"><span class="string">    &quot;&quot;&quot;</span></span><br><span class="line">    <span class="keyword">with</span> lock:</span><br><span class="line">        <span class="built_in">print</span>(<span class="string">f&quot;线程<span class="subst">&#123;thread_id&#125;</span>获取锁&quot;</span>)</span><br><span class="line">        <span class="keyword">try</span>:</span><br><span class="line">            <span class="keyword">global</span> shared_data</span><br><span class="line">            <span class="comment"># 读取-修改-写入操作需要原子性保护</span></span><br><span class="line">            current = shared_data</span><br><span class="line">            time.sleep(<span class="number">0.1</span>)  <span class="comment"># 模拟处理延时，增加竞争概率</span></span><br><span class="line">            shared_data = current + <span class="number">1</span></span><br><span class="line">            <span class="built_in">print</span>(<span class="string">f&quot;线程<span class="subst">&#123;thread_id&#125;</span>修改共享数据，当前值为<span class="subst">&#123;shared_data&#125;</span>&quot;</span>)</span><br><span class="line">        <span class="keyword">finally</span>:</span><br><span class="line">            <span class="built_in">print</span>(<span class="string">f&quot;线程<span class="subst">&#123;thread_id&#125;</span>释放锁&quot;</span>)</span><br><span class="line"></span><br><span class="line"></span><br><span class="line"><span class="keyword">if</span> __name__ == <span class="string">&#x27;__main__&#x27;</span>:</span><br><span class="line">    <span class="comment"># 使用线程池创建多个线程</span></span><br><span class="line">    <span class="keyword">with</span> ThreadPoolExecutor(max_workers=<span class="number">10</span>) <span class="keyword">as</span> executor:</span><br><span class="line">        futures = [executor.submit(shared_resource, i) <span class="keyword">for</span> i <span class="keyword">in</span> <span class="built_in">range</span>(<span class="number">1</span>, <span class="number">11</span>)]</span><br><span class="line">        <span class="comment"># 等待所有线程完成</span></span><br><span class="line">        concurrent.futures.wait(futures)</span><br><span class="line">    <span class="built_in">print</span>(<span class="string">f&quot;最终共享数据值为<span class="subst">&#123;shared_data&#125;</span>&quot;</span>)</span><br><span class="line"></span><br></pre></td></tr></table></figure><table><thead><tr><th>Lock 方法</th><th>描述</th><th>参数</th><th>返回值</th></tr></thead><tbody><tr><td><code>acquire(blocking=True, timeout=-1)</code></td><td>获取锁</td><td>blocking: 是否阻塞, timeout: 超时时间(秒)</td><td>布尔值，表示是否获取成功</td></tr><tr><td><code>release()</code></td><td>释放锁</td><td>无</td><td>无，如果当前线程未持有锁则抛出 RuntimeError</td></tr><tr><td><code>locked()</code></td><td>检查锁状态</td><td>无</td><td>布尔值，表示锁是否被某个线程持有</td></tr><tr><td><code>__enter__()</code></td><td>支持 with 语句</td><td>无</td><td>锁对象自身</td></tr><tr><td><code>__exit__()</code></td><td>with 语句退出时调用</td><td>异常信息</td><td>无，自动释放锁</td></tr></tbody></table><h5 id="可重入锁-RLock">可重入锁(RLock)</h5><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><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><span class="line"><span class="keyword">import</span> threading</span><br><span class="line"><span class="keyword">import</span> time</span><br><span class="line"></span><br><span class="line"><span class="comment">## 创建可重入锁</span></span><br><span class="line">rlock = threading.RLock()</span><br><span class="line"></span><br><span class="line"><span class="comment"># 嵌套列表数据结构</span></span><br><span class="line">data = [[<span class="number">1</span>, <span class="number">2</span>, <span class="number">3</span>], [<span class="number">4</span>, <span class="number">5</span>, <span class="number">6</span>], [<span class="number">7</span>, <span class="number">8</span>, <span class="number">9</span>]]</span><br><span class="line"></span><br><span class="line"></span><br><span class="line"><span class="keyword">def</span> <span class="title function_">process_data</span>(<span class="params">item, depth: <span class="built_in">int</span> = <span class="number">0</span></span>):</span><br><span class="line">    <span class="string">&quot;&quot;&quot;递归处理数据</span></span><br><span class="line"><span class="string">    </span></span><br><span class="line"><span class="string">    Args:</span></span><br><span class="line"><span class="string">        item: 要处理的数据项，可以是列表或单个元素</span></span><br><span class="line"><span class="string">        depth: 当前递归深度，用于缩进显示</span></span><br><span class="line"><span class="string">    &quot;&quot;&quot;</span></span><br><span class="line">    <span class="comment"># 获取锁</span></span><br><span class="line">    rlock.acquire()</span><br><span class="line">    <span class="keyword">try</span>:</span><br><span class="line">        <span class="comment"># 创建缩进效果，增强可读性</span></span><br><span class="line">        indent = <span class="string">&quot; &quot;</span> * depth * <span class="number">2</span>  <span class="comment"># 增加缩进量使层次更明显</span></span><br><span class="line">        </span><br><span class="line">        <span class="comment"># 打印当前处理的数据项和深度</span></span><br><span class="line">        <span class="built_in">print</span>(<span class="string">f&#x27;<span class="subst">&#123;indent&#125;</span>线程 <span class="subst">&#123;threading.current_thread().name&#125;</span> 处理: <span class="subst">&#123;item&#125;</span> (深度: <span class="subst">&#123;depth&#125;</span>)&#x27;</span>)</span><br><span class="line">        </span><br><span class="line">        <span class="comment"># 递归处理逻辑</span></span><br><span class="line">        <span class="keyword">if</span> <span class="built_in">isinstance</span>(item, <span class="built_in">list</span>):</span><br><span class="line">            <span class="comment"># 列表节点处理 - 继续向下递归</span></span><br><span class="line">            <span class="built_in">print</span>(<span class="string">f&#x27;<span class="subst">&#123;indent&#125;</span>├── 发现列表，开始遍历子元素...&#x27;</span>)</span><br><span class="line">            <span class="keyword">for</span> i, sub_item <span class="keyword">in</span> <span class="built_in">enumerate</span>(item):</span><br><span class="line">                <span class="comment"># 显示子项的索引，增强结构可视化</span></span><br><span class="line">                prefix = <span class="string">&quot;└── &quot;</span> <span class="keyword">if</span> i == <span class="built_in">len</span>(item) - <span class="number">1</span> <span class="keyword">else</span> <span class="string">&quot;├── &quot;</span></span><br><span class="line">                <span class="built_in">print</span>(<span class="string">f&#x27;<span class="subst">&#123;indent&#125;</span><span class="subst">&#123;prefix&#125;</span>处理子项 <span class="subst">&#123;i+<span class="number">1</span>&#125;</span>/<span class="subst">&#123;<span class="built_in">len</span>(item)&#125;</span>: <span class="subst">&#123;sub_item&#125;</span>&#x27;</span>)</span><br><span class="line">                </span><br><span class="line">                <span class="comment"># 递归调用，这里会再次获取同一个锁</span></span><br><span class="line">                process_data(sub_item, depth + <span class="number">1</span>)</span><br><span class="line">                time.sleep(<span class="number">0.1</span>)</span><br><span class="line">        <span class="keyword">else</span>:</span><br><span class="line">            <span class="comment"># 叶子节点处理 - 递归终止条件</span></span><br><span class="line">            <span class="built_in">print</span>(<span class="string">f&#x27;<span class="subst">&#123;indent&#125;</span>└── 发现元素，进行处理...&#x27;</span>)</span><br><span class="line">            time.sleep(<span class="number">0.5</span>)</span><br><span class="line">            <span class="built_in">print</span>(<span class="string">f&#x27;<span class="subst">&#123;indent&#125;</span>    处理结果: <span class="subst">&#123;item * <span class="number">2</span>&#125;</span>&#x27;</span>)</span><br><span class="line">    <span class="keyword">finally</span>:</span><br><span class="line">        <span class="comment"># 释放锁</span></span><br><span class="line">        rlock.release()</span><br><span class="line"></span><br><span class="line"></span><br><span class="line"><span class="keyword">if</span> __name__ == <span class="string">&#x27;__main__&#x27;</span>:</span><br><span class="line">    <span class="comment">## 创建多个线程访问嵌套数据</span></span><br><span class="line">    threads = []</span><br><span class="line">    <span class="keyword">for</span> i <span class="keyword">in</span> <span class="built_in">range</span>(<span class="number">3</span>):</span><br><span class="line">        <span class="comment"># 每个线程处理完整的数据结构</span></span><br><span class="line">        t = threading.Thread(name=<span class="string">f&quot;Thread-<span class="subst">&#123;i&#125;</span>&quot;</span>, target=process_data, args=(data,))</span><br><span class="line">        threads.append(t)</span><br><span class="line">        t.start()</span><br><span class="line">        time.sleep(<span class="number">0.5</span>)  <span class="comment"># 错开线程启动时间</span></span><br><span class="line">    </span><br><span class="line">    <span class="comment">## 等待所有线程结束</span></span><br><span class="line">    <span class="keyword">for</span> t <span class="keyword">in</span> threads:</span><br><span class="line">        t.join()</span><br><span class="line">    </span><br><span class="line">    <span class="built_in">print</span>(<span class="string">&quot;所有线程都结束了&quot;</span>)</span><br></pre></td></tr></table></figure><table><thead><tr><th>RLock 方法</th><th>描述</th><th>与 Lock 的区别</th></tr></thead><tbody><tr><td><code>acquire(blocking=True, timeout=-1)</code></td><td>获取锁</td><td>记录获取线程 ID 和次数</td></tr><tr><td><code>release()</code></td><td>释放锁</td><td>计数器减 1，只有为 0 时才真正释放</td></tr><tr><td><code>_is_owned()</code></td><td>检查当前线程是否持有锁</td><td>Lock 没有此方法</td></tr></tbody></table><blockquote><p>💡 <strong>使用建议</strong>：一般推荐使用 RLock 而非 Lock，因为它更安全、更灵活，即使在不需要重入功能的场景下也不会有明显性能损失。</p></blockquote><h5 id="条件变量-Condition-根据条件控制锁">条件变量(Condition) - 根据条件控制锁</h5><p>条件变量是一种高级的 <code>同步原语(同步原语就是让多个线程能够&quot;和谐相处&quot;的机制)</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></pre></td><td class="code"><pre><span class="line"><span class="keyword">import</span> threading</span><br><span class="line"><span class="keyword">import</span> time</span><br><span class="line"><span class="keyword">import</span> random</span><br><span class="line"><span class="keyword">from</span> typing <span class="keyword">import</span> <span class="type">List</span>, <span class="type">Any</span></span><br><span class="line"></span><br><span class="line"></span><br><span class="line"><span class="keyword">class</span> <span class="title class_">Buffer</span>:</span><br><span class="line">    <span class="string">&quot;&quot;&quot;线程安全的缓冲区，使用条件变量控制生产者消费者模型&quot;&quot;&quot;</span></span><br><span class="line"></span><br><span class="line">    <span class="keyword">def</span> <span class="title function_">__init__</span>(<span class="params">self, max_size: <span class="built_in">int</span> = <span class="number">5</span></span>) -&gt; <span class="literal">None</span>:</span><br><span class="line">        <span class="string">&quot;&quot;&quot;初始化缓冲区&quot;&quot;&quot;</span></span><br><span class="line">        <span class="variable language_">self</span>.buffer: <span class="type">List</span>[<span class="type">Any</span>] = []  <span class="comment"># 共享数据缓冲区</span></span><br><span class="line">        <span class="variable language_">self</span>.max_size: <span class="built_in">int</span> = max_size  <span class="comment"># 最大容量</span></span><br><span class="line">        <span class="comment"># 创建条件变量，基于RLock</span></span><br><span class="line">        <span class="variable language_">self</span>.condition: threading.Condition = threading.Condition()</span><br><span class="line"></span><br><span class="line">    <span class="keyword">def</span> <span class="title function_">produce</span>(<span class="params">self, item: <span class="type">Any</span>, producer_id: <span class="built_in">int</span></span>) -&gt; <span class="literal">None</span>:</span><br><span class="line">        <span class="string">&quot;&quot;&quot;生产者方法，向缓冲区添加数据&quot;&quot;&quot;</span></span><br><span class="line">        <span class="comment"># 使用条件变量的with语句自动获取和释放锁</span></span><br><span class="line">        <span class="keyword">with</span> <span class="variable language_">self</span>.condition:</span><br><span class="line">            <span class="comment"># 当缓冲区已满时，等待消费者处理</span></span><br><span class="line">            <span class="keyword">while</span> <span class="built_in">len</span>(<span class="variable language_">self</span>.buffer) &gt;= <span class="variable language_">self</span>.max_size:</span><br><span class="line">                <span class="built_in">print</span>(<span class="string">f&quot;生产者 <span class="subst">&#123;producer_id&#125;</span>: 缓冲区已满，等待消费者...&quot;</span>)</span><br><span class="line">                <span class="comment"># 等待唤醒通知，自动释放锁，让其他线程能访问缓冲区</span></span><br><span class="line">                <span class="variable language_">self</span>.condition.wait()</span><br><span class="line"></span><br><span class="line">            <span class="comment"># 添加数据到缓冲区</span></span><br><span class="line">            <span class="variable language_">self</span>.buffer.append(item)</span><br><span class="line">            <span class="built_in">print</span>(<span class="string">f&quot;生产者 <span class="subst">&#123;producer_id&#125;</span>: 添加 <span class="subst">&#123;item&#125;</span> 到缓冲区，当前大小: <span class="subst">&#123;<span class="built_in">len</span>(self.buffer)&#125;</span>&quot;</span>)</span><br><span class="line"></span><br><span class="line">            <span class="comment"># 通知所有等待的消费者有新数据可用</span></span><br><span class="line">            <span class="variable language_">self</span>.condition.notify_all()</span><br><span class="line"></span><br><span class="line">    <span class="keyword">def</span> <span class="title function_">consume</span>(<span class="params">self, consumer_id: <span class="built_in">int</span></span>) -&gt; <span class="type">Any</span>:</span><br><span class="line">        <span class="string">&quot;&quot;&quot;消费者方法，从缓冲区获取数据&quot;&quot;&quot;</span></span><br><span class="line">        <span class="keyword">with</span> <span class="variable language_">self</span>.condition:</span><br><span class="line">            <span class="comment"># 当缓冲区为空时，等待生产者添加数据</span></span><br><span class="line">            <span class="keyword">while</span> <span class="built_in">len</span>(<span class="variable language_">self</span>.buffer) == <span class="number">0</span>:</span><br><span class="line">                <span class="built_in">print</span>(<span class="string">f&quot;消费者 <span class="subst">&#123;consumer_id&#125;</span>: 缓冲区为空，等待生产者...&quot;</span>)</span><br><span class="line">                <span class="variable language_">self</span>.condition.wait()</span><br><span class="line"></span><br><span class="line">            <span class="comment"># 从缓冲区取出数据</span></span><br><span class="line">            item = <span class="variable language_">self</span>.buffer.pop(<span class="number">0</span>)</span><br><span class="line">            <span class="built_in">print</span>(<span class="string">f&quot;消费者 <span class="subst">&#123;consumer_id&#125;</span>: 从缓冲区取出 <span class="subst">&#123;item&#125;</span>，当前大小: <span class="subst">&#123;<span class="built_in">len</span>(self.buffer)&#125;</span>&quot;</span>)</span><br><span class="line"></span><br><span class="line">            <span class="comment"># 通知所有等待的生产者缓冲区有空间</span></span><br><span class="line">            <span class="variable language_">self</span>.condition.notify_all()</span><br><span class="line"></span><br><span class="line">            <span class="keyword">return</span> item</span><br><span class="line"></span><br><span class="line"></span><br><span class="line"><span class="keyword">def</span> <span class="title function_">producer_task</span>(<span class="params">buffer: Buffer, producer_id: <span class="built_in">int</span></span>) -&gt; <span class="literal">None</span>:</span><br><span class="line">    <span class="string">&quot;&quot;&quot;生产者任务&quot;&quot;&quot;</span></span><br><span class="line">    <span class="keyword">for</span> i <span class="keyword">in</span> <span class="built_in">range</span>(<span class="number">10</span>):  <span class="comment"># 生产10个产品</span></span><br><span class="line">        item = <span class="string">f&quot;产品-<span class="subst">&#123;producer_id&#125;</span>-<span class="subst">&#123;i&#125;</span>&quot;</span></span><br><span class="line">        <span class="comment"># 模拟生产时间</span></span><br><span class="line">        time.sleep(random.uniform(<span class="number">0.1</span>, <span class="number">0.5</span>))  </span><br><span class="line">        buffer.produce(item, producer_id)</span><br><span class="line"></span><br><span class="line"></span><br><span class="line"><span class="keyword">def</span> <span class="title function_">consumer_task</span>(<span class="params">buffer: Buffer, consumer_id: <span class="built_in">int</span></span>) -&gt; <span class="literal">None</span>:</span><br><span class="line">    <span class="string">&quot;&quot;&quot;消费者任务&quot;&quot;&quot;</span></span><br><span class="line">    <span class="keyword">for</span> _ <span class="keyword">in</span> <span class="built_in">range</span>(<span class="number">7</span>):  <span class="comment"># 每个消费者消费7个产品</span></span><br><span class="line">        <span class="comment"># 模拟消费时间</span></span><br><span class="line">        time.sleep(random.uniform(<span class="number">0.2</span>, <span class="number">0.7</span>))  </span><br><span class="line">        item = buffer.consume(consumer_id)</span><br><span class="line">        <span class="built_in">print</span>(<span class="string">f&quot;消费者 <span class="subst">&#123;consumer_id&#125;</span> 处理 <span class="subst">&#123;item&#125;</span>&quot;</span>)</span><br><span class="line"></span><br><span class="line"></span><br><span class="line"><span class="keyword">def</span> <span class="title function_">main</span>() -&gt; <span class="literal">None</span>:</span><br><span class="line">    <span class="string">&quot;&quot;&quot;主函数，创建并启动生产者和消费者线程&quot;&quot;&quot;</span></span><br><span class="line">    <span class="comment"># 创建共享缓冲区</span></span><br><span class="line">    shared_buffer = Buffer(max_size=<span class="number">3</span>)</span><br><span class="line">    </span><br><span class="line">    <span class="comment"># 创建生产者和消费者线程</span></span><br><span class="line">    producer_threads = [</span><br><span class="line">        threading.Thread(target=producer_task, args=(shared_buffer, i), name=<span class="string">f&quot;Producer-<span class="subst">&#123;i&#125;</span>&quot;</span>)</span><br><span class="line">        <span class="keyword">for</span> i <span class="keyword">in</span> <span class="built_in">range</span>(<span class="number">3</span>)  <span class="comment"># 3个生产者</span></span><br><span class="line">    ]</span><br><span class="line">    </span><br><span class="line">    consumer_threads = [</span><br><span class="line">        threading.Thread(target=consumer_task, args=(shared_buffer, i), name=<span class="string">f&quot;Consumer-<span class="subst">&#123;i&#125;</span>&quot;</span>)</span><br><span class="line">        <span class="keyword">for</span> i <span class="keyword">in</span> <span class="built_in">range</span>(<span class="number">3</span>)  <span class="comment"># 3个消费者</span></span><br><span class="line">    ]</span><br><span class="line">    </span><br><span class="line">    <span class="comment"># 启动所有线程</span></span><br><span class="line">    all_threads = producer_threads + consumer_threads</span><br><span class="line">    <span class="keyword">for</span> thread <span class="keyword">in</span> all_threads:</span><br><span class="line">        thread.start()</span><br><span class="line">    </span><br><span class="line">    <span class="comment"># 等待所有线程完成</span></span><br><span class="line">    <span class="keyword">for</span> thread <span class="keyword">in</span> all_threads:</span><br><span class="line">        thread.join()</span><br><span class="line">    </span><br><span class="line">    <span class="built_in">print</span>(<span class="string">&quot;所有生产和消费任务已完成&quot;</span>)</span><br><span class="line"></span><br><span class="line"></span><br><span class="line"><span class="keyword">if</span> __name__ == <span class="string">&quot;__main__&quot;</span>:</span><br><span class="line">    main()</span><br></pre></td></tr></table></figure><table><thead><tr><th>Condition 方法</th><th>描述</th><th>参数</th><th>注意事项</th></tr></thead><tbody><tr><td><code>__init__(lock=None)</code></td><td>初始化条件变量</td><td>lock: 可选的 Lock 或 RLock</td><td>不指定则创建 RLock</td></tr><tr><td><code>acquire(*args)</code></td><td>获取底层锁</td><td>同底层锁的 acquire 方法</td><td>一般通过 with 语句使用</td></tr><tr><td><code>release()</code></td><td>释放底层锁</td><td>无</td><td>一般通过 with 语句自动释放</td></tr><tr><td><code>wait(timeout=None)</code></td><td>等待条件</td><td>timeout: 超时时间(秒)</td><td>调用前必须已获得锁</td></tr><tr><td><code>wait_for(predicate, timeout=None)</code></td><td>等待直到条件为真</td><td>predicate: 条件函数, timeout: 超时时间</td><td>简化循环等待模式</td></tr><tr><td><code>notify(n=1)</code></td><td>唤醒 n 个等待的线程</td><td>n: 要唤醒的线程数</td><td>不会立即释放锁</td></tr><tr><td><code>notify_all()</code></td><td>唤醒所有等待的线程</td><td>无</td><td>适用于广播通知</td></tr></tbody></table><blockquote><p>⚠️ <strong>使用注意</strong>：</p><ol><li>调用 <code>wait()</code> 会释放锁，允许其他线程修改条件状态</li><li>使用 <code>wait_for()</code> 可以避免虚假唤醒问题</li><li>调用 <code>notify()</code> 后锁不会立即释放，需要当前线程退出 with 块</li><li>使用 <code>notify_all()</code> 而非 <code>notify()</code> 可以避免信号丢失问题</li></ol></blockquote><h5 id="信号量-Semaphore-控制并发数量">信号量(Semaphore) - 控制并发数量</h5><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><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></pre></td><td class="code"><pre><span class="line"><span class="keyword">import</span> threading</span><br><span class="line"><span class="keyword">import</span> time</span><br><span class="line"><span class="keyword">import</span> random</span><br><span class="line"><span class="keyword">from</span> concurrent.futures <span class="keyword">import</span> ThreadPoolExecutor</span><br><span class="line"></span><br><span class="line"><span class="comment">## 创建信号量，限制最多三个线程同时访问资源</span></span><br><span class="line">pool_semaphore = threading.Semaphore(<span class="number">3</span>)</span><br><span class="line"></span><br><span class="line"><span class="comment">## 模拟优先的资源池</span></span><br><span class="line">resource_pool = [<span class="string">&#x27;资源A&#x27;</span>, <span class="string">&#x27;资源B&#x27;</span>, <span class="string">&#x27;资源C&#x27;</span>]</span><br><span class="line">resource_in_use = &#123;&#125;  <span class="comment"># 跟踪资源使用情况</span></span><br><span class="line"></span><br><span class="line"><span class="comment">## 保护资源分配的锁</span></span><br><span class="line">resource_lock = threading.RLock()</span><br><span class="line"></span><br><span class="line"></span><br><span class="line"><span class="keyword">def</span> <span class="title function_">worker</span>(<span class="params">worker_id: <span class="built_in">int</span></span>):</span><br><span class="line">    <span class="string">&quot;&quot;&quot;工作线程函数，模拟使用受限资源&quot;&quot;&quot;</span></span><br><span class="line">    <span class="comment"># 尝试获取信号量</span></span><br><span class="line">    <span class="built_in">print</span>(<span class="string">f&quot;工作线程 <span class="subst">&#123;worker_id&#125;</span> 等待获取资源...&quot;</span>)</span><br><span class="line">    <span class="keyword">with</span> pool_semaphore:  <span class="comment"># 等同于acquire()和finally中release()</span></span><br><span class="line">        <span class="built_in">print</span>(<span class="string">f&quot;工作线程 <span class="subst">&#123;worker_id&#125;</span> 获取资源信号量&quot;</span>)</span><br><span class="line">        <span class="keyword">with</span> resource_lock:</span><br><span class="line">            <span class="comment"># 检查resource_pool中的每个资源，如果该资源不在resource_in_use字典的值中，则认为是可用的</span></span><br><span class="line">            available_resources = [r <span class="keyword">for</span> r <span class="keyword">in</span> resource_pool <span class="keyword">if</span> r <span class="keyword">not</span> <span class="keyword">in</span> resource_in_use.values()]</span><br><span class="line">            <span class="keyword">if</span> <span class="keyword">not</span> available_resources:</span><br><span class="line">                <span class="built_in">print</span>(<span class="string">f&quot;工作线程 <span class="subst">&#123;worker_id&#125;</span> 没有找到可用资源，理论上不应该发生！&quot;</span>)</span><br><span class="line">                <span class="keyword">return</span></span><br><span class="line"></span><br><span class="line">            resource_name = available_resources[<span class="number">0</span>]</span><br><span class="line">            resource_in_use[worker_id] = resource_name  <span class="comment"># 记录资源使用情况</span></span><br><span class="line">            <span class="built_in">print</span>(<span class="string">f&quot;工作线程 <span class="subst">&#123;worker_id&#125;</span> 分配到资源: <span class="subst">&#123;resource_name&#125;</span>, 当前使用情况: <span class="subst">&#123;resource_in_use&#125;</span>&quot;</span>)</span><br><span class="line">        <span class="keyword">try</span>:</span><br><span class="line">            <span class="comment"># 模拟使用资源</span></span><br><span class="line">            work_time = random.uniform(<span class="number">0.5</span>, <span class="number">2.0</span>)</span><br><span class="line">            <span class="built_in">print</span>(<span class="string">f&quot;工作线程 <span class="subst">&#123;worker_id&#125;</span> 使用资源 <span class="subst">&#123;resource_name&#125;</span> 时间: <span class="subst">&#123;work_time&#125;</span> 秒&quot;</span>)</span><br><span class="line">            time.sleep(work_time)</span><br><span class="line">        <span class="keyword">finally</span>:</span><br><span class="line">            <span class="comment"># 释放资源</span></span><br><span class="line">            <span class="keyword">with</span> resource_lock:</span><br><span class="line">                released_resource = resource_in_use.pop(worker_id)</span><br><span class="line">                <span class="built_in">print</span>(<span class="string">f&quot;工作线程 <span class="subst">&#123;worker_id&#125;</span> 释放资源: <span class="subst">&#123;released_resource&#125;</span>, 当前使用情况: <span class="subst">&#123;resource_in_use&#125;</span>&quot;</span>)</span><br><span class="line"></span><br><span class="line"></span><br><span class="line"><span class="keyword">if</span> __name__ == <span class="string">&#x27;__main__&#x27;</span>:</span><br><span class="line">    <span class="comment"># 创建线程池，并启动三个线程</span></span><br><span class="line">    <span class="keyword">with</span> ThreadPoolExecutor(max_workers=<span class="number">3</span>) <span class="keyword">as</span> executor:</span><br><span class="line">        futures = [executor.submit(worker, i) <span class="keyword">for</span> i <span class="keyword">in</span> <span class="built_in">range</span>(<span class="number">3</span>)]</span><br><span class="line">        <span class="keyword">for</span> future <span class="keyword">in</span> futures:</span><br><span class="line">            future.result()</span><br><span class="line"></span><br><span class="line"></span><br></pre></td></tr></table></figure><table><thead><tr><th>Semaphore 方法</th><th>描述</th><th>参数</th><th>返回值</th></tr></thead><tbody><tr><td><code>__init__(value=1)</code></td><td>初始化信号量</td><td>value: 初始计数器值</td><td>无</td></tr><tr><td><code>acquire(blocking=True, timeout=None)</code></td><td>获取信号量</td><td>blocking: 是否阻塞, timeout: 超时时间</td><td>布尔值，表示是否获取成功</td></tr><tr><td><code>release(n=1)</code></td><td>释放信号量</td><td>n: 释放的数量</td><td>无</td></tr><tr><td><code>__enter__()</code></td><td>支持 with 语句</td><td>无</td><td>信号量对象自身</td></tr><tr><td><code>__exit__()</code></td><td>with 语句退出时调用</td><td>异常信息</td><td>无，自动释放信号量</td></tr></tbody></table><h5 id="有界信号量-BoundedSemaphore-详解">有界信号量(BoundedSemaphore)详解</h5><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></pre></td><td class="code"><pre><span class="line"><span class="keyword">import</span> threading</span><br><span class="line"><span class="keyword">import</span> time</span><br><span class="line"></span><br><span class="line"><span class="comment">## 创建有界信号量，初始值为3</span></span><br><span class="line">bounded_semaphore = threading.BoundedSemaphore(<span class="number">3</span>)</span><br><span class="line"></span><br><span class="line"><span class="keyword">def</span> <span class="title function_">semaphore_demo</span>():</span><br><span class="line">    <span class="string">&quot;&quot;&quot;演示有界信号量与普通信号量的区别&quot;&quot;&quot;</span></span><br><span class="line">    <span class="keyword">try</span>:</span><br><span class="line">        <span class="built_in">print</span>(<span class="string">&quot;获取信号量1次&quot;</span>)</span><br><span class="line">        bounded_semaphore.acquire()</span><br><span class="line">        </span><br><span class="line">        <span class="built_in">print</span>(<span class="string">&quot;获取信号量2次&quot;</span>)</span><br><span class="line">        bounded_semaphore.acquire()</span><br><span class="line">        </span><br><span class="line">        <span class="built_in">print</span>(<span class="string">&quot;获取信号量3次&quot;</span>)</span><br><span class="line">        bounded_semaphore.acquire()</span><br><span class="line">        </span><br><span class="line">        <span class="built_in">print</span>(<span class="string">&quot;信号量已用完，再获取将阻塞&quot;</span>)</span><br><span class="line">        </span><br><span class="line">        <span class="comment"># 释放全部信号量</span></span><br><span class="line">        <span class="built_in">print</span>(<span class="string">&quot;释放信号量1次&quot;</span>)</span><br><span class="line">        bounded_semaphore.release()</span><br><span class="line">        </span><br><span class="line">        <span class="built_in">print</span>(<span class="string">&quot;释放信号量2次&quot;</span>)</span><br><span class="line">        bounded_semaphore.release()</span><br><span class="line">        </span><br><span class="line">        <span class="built_in">print</span>(<span class="string">&quot;释放信号量3次&quot;</span>)</span><br><span class="line">        bounded_semaphore.release()</span><br><span class="line">        </span><br><span class="line">        <span class="keyword">try</span>:</span><br><span class="line">            <span class="comment"># 超出初始值的释放将抛出异常</span></span><br><span class="line">            <span class="built_in">print</span>(<span class="string">&quot;尝试额外释放一次&quot;</span>)</span><br><span class="line">            bounded_semaphore.release()  </span><br><span class="line">            <span class="built_in">print</span>(<span class="string">&quot;这一行不会执行&quot;</span>)</span><br><span class="line">        <span class="keyword">except</span> ValueError <span class="keyword">as</span> e:</span><br><span class="line">            <span class="built_in">print</span>(<span class="string">f&quot;捕获预期异常: <span class="subst">&#123;e&#125;</span>&quot;</span>)</span><br><span class="line">    <span class="keyword">except</span> Exception <span class="keyword">as</span> e:</span><br><span class="line">        <span class="built_in">print</span>(<span class="string">f&quot;意外错误: <span class="subst">&#123;e&#125;</span>&quot;</span>)</span><br><span class="line"></span><br><span class="line">semaphore_demo()</span><br></pre></td></tr></table></figure><blockquote><p>🔍 <strong>Semaphore vs BoundedSemaphore</strong>：</p><ul><li><code>Semaphore</code> 允许无限制地调用 <code>release()</code>，即使计数器超过初始值</li><li><code>BoundedSemaphore</code> 在计数器超过初始值时会抛出 <code>ValueError</code> 异常</li><li>生产环境推荐使用 <code>BoundedSemaphore</code>，或安全的使用 with 语句，保证程序安全</li></ul></blockquote><h5 id="事件对象-Event">事件对象(Event)</h5><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><span class="line">44</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">import</span> threading</span><br><span class="line"><span class="keyword">import</span> time</span><br><span class="line"><span class="keyword">import</span> random</span><br><span class="line"><span class="keyword">from</span> typing <span class="keyword">import</span> <span class="type">List</span></span><br><span class="line"><span class="keyword">from</span> concurrent.futures <span class="keyword">import</span> ThreadPoolExecutor</span><br><span class="line"></span><br><span class="line"><span class="comment">## 创建事件对象</span></span><br><span class="line">start_event = threading.Event()</span><br><span class="line">results: <span class="type">List</span>[<span class="built_in">str</span>] = []</span><br><span class="line">results_lock = threading.RLock()</span><br><span class="line"></span><br><span class="line"><span class="keyword">def</span> <span class="title function_">worker</span>(<span class="params">worker_id:<span class="built_in">int</span></span>) -&gt; <span class="literal">None</span>:</span><br><span class="line">    <span class="string">&quot;&quot;&quot;工作线程函数，等待开始信号&quot;&quot;&quot;</span></span><br><span class="line">    prep_time = random.uniform(<span class="number">0.5</span>, <span class="number">1.5</span>)</span><br><span class="line">    time.sleep(prep_time)</span><br><span class="line">    <span class="built_in">print</span>(<span class="string">f&quot;工作线程<span class="subst">&#123;worker_id&#125;</span>准备完毕，等待开始信号&quot;</span>)</span><br><span class="line">    <span class="comment"># 等待开始信号</span></span><br><span class="line">    start_event.wait()</span><br><span class="line"></span><br><span class="line">    <span class="comment"># 收到信号开始工作</span></span><br><span class="line">    <span class="built_in">print</span>(<span class="string">f&quot;工作线程<span class="subst">&#123;worker_id&#125;</span>开始工作&quot;</span>)</span><br><span class="line">    work_time = random.uniform(<span class="number">1</span>, <span class="number">2</span>)</span><br><span class="line">    time.sleep(work_time)</span><br><span class="line">    <span class="comment"># 记录结果</span></span><br><span class="line">    <span class="keyword">with</span> results_lock:</span><br><span class="line">        results.append(<span class="string">f&quot;工作线程<span class="subst">&#123;worker_id&#125;</span>完成工作&quot;</span>)</span><br><span class="line"></span><br><span class="line">    <span class="built_in">print</span>(<span class="string">f&quot;工作线程<span class="subst">&#123;worker_id&#125;</span>完成工作，用时<span class="subst">&#123;work_time:<span class="number">.2</span>f&#125;</span>秒&quot;</span>)</span><br><span class="line"></span><br><span class="line"><span class="keyword">if</span> __name__ == <span class="string">&#x27;__main__&#x27;</span>:</span><br><span class="line">    <span class="comment"># 创建线程池</span></span><br><span class="line">    <span class="keyword">with</span> ThreadPoolExecutor(max_workers=<span class="number">3</span>) <span class="keyword">as</span> executor:</span><br><span class="line">        futures = [executor.submit(worker, i) <span class="keyword">for</span> i <span class="keyword">in</span> <span class="built_in">range</span>(<span class="number">3</span>)]</span><br><span class="line">        <span class="comment"># 等待所有线程准备完毕</span></span><br><span class="line">        time.sleep(<span class="number">2</span>)</span><br><span class="line">        <span class="comment"># 发送开始信号</span></span><br><span class="line">        <span class="built_in">print</span>(<span class="string">&quot;发送开始信号&quot;</span>)</span><br><span class="line">        start_event.<span class="built_in">set</span>()</span><br><span class="line">        <span class="comment"># 等待所有线程完成工作</span></span><br><span class="line">        <span class="keyword">for</span> future <span class="keyword">in</span> futures:</span><br><span class="line">            future.result()</span><br><span class="line"></span><br><span class="line">    <span class="built_in">print</span>(<span class="string">&quot;所有工作线程完成&quot;</span>)</span><br><span class="line">    <span class="built_in">print</span>(results)</span><br></pre></td></tr></table></figure><table><thead><tr><th>Event 方法</th><th>描述</th><th>参数</th><th>返回值</th></tr></thead><tbody><tr><td><code>set()</code></td><td>设置事件，唤醒所有等待的线程</td><td>无</td><td>无</td></tr><tr><td><code>clear()</code></td><td>清除事件标志</td><td>无</td><td>无</td></tr><tr><td><code>is_set()</code></td><td>判断事件是否已设置</td><td>无</td><td>布尔值</td></tr><tr><td><code>wait(timeout=None)</code></td><td>等待事件被设置</td><td>timeout: 超时时间</td><td>如果超时返回 False，否则返回 True</td></tr></tbody></table><blockquote><p>💡 <strong>使用场景</strong>：</p><ul><li>启动信号：所有线程等待统一开始</li><li>停止信号：通知所有线程停止工作</li><li>一次性通知：当某条件满足时通知等待线程</li></ul></blockquote><h5 id="栅栏对象-Barrier">栅栏对象(Barrier)</h5><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><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></pre></td><td class="code"><pre><span class="line"><span class="keyword">import</span> threading</span><br><span class="line"><span class="keyword">import</span> time</span><br><span class="line"><span class="keyword">import</span> random</span><br><span class="line"></span><br><span class="line"><span class="comment">## 定义参与方数量</span></span><br><span class="line">num_parties = <span class="number">4</span></span><br><span class="line"></span><br><span class="line"><span class="comment">## 创建栅栏对象，当4个线程都到达时才继续</span></span><br><span class="line">barrier = threading.Barrier(num_parties)</span><br><span class="line"></span><br><span class="line"><span class="keyword">def</span> <span class="title function_">worker</span>(<span class="params">worker_id</span>):</span><br><span class="line">    <span class="string">&quot;&quot;&quot;工作线程函数，模拟多阶段工作</span></span><br><span class="line"><span class="string">    </span></span><br><span class="line"><span class="string">    Args:</span></span><br><span class="line"><span class="string">        worker_id: 工作线程ID</span></span><br><span class="line"><span class="string">    &quot;&quot;&quot;</span></span><br><span class="line">    <span class="built_in">print</span>(<span class="string">f&quot;工作线程 <span class="subst">&#123;worker_id&#125;</span> 开始第一阶段工作&quot;</span>)</span><br><span class="line">    </span><br><span class="line">    <span class="comment"># 模拟第一阶段工作</span></span><br><span class="line">    work_time = random.uniform(<span class="number">0.5</span>, <span class="number">2.0</span>)</span><br><span class="line">    time.sleep(work_time)</span><br><span class="line">    <span class="built_in">print</span>(<span class="string">f&quot;工作线程 <span class="subst">&#123;worker_id&#125;</span> 完成第一阶段，用时 <span class="subst">&#123;work_time:<span class="number">.2</span>f&#125;</span> 秒，等待其他线程...&quot;</span>)</span><br><span class="line">    </span><br><span class="line">    <span class="keyword">try</span>:</span><br><span class="line">        <span class="comment"># 等待所有线程完成第一阶段</span></span><br><span class="line">        barrier.wait()</span><br><span class="line">        <span class="built_in">print</span>(<span class="string">f&quot;工作线程 <span class="subst">&#123;worker_id&#125;</span> 通过第一个栅栏，开始第二阶段&quot;</span>)</span><br><span class="line">        </span><br><span class="line">        <span class="comment"># 模拟第二阶段工作</span></span><br><span class="line">        work_time = random.uniform(<span class="number">0.5</span>, <span class="number">2.0</span>)</span><br><span class="line">        time.sleep(work_time)</span><br><span class="line">        <span class="built_in">print</span>(<span class="string">f&quot;工作线程 <span class="subst">&#123;worker_id&#125;</span> 完成第二阶段，用时 <span class="subst">&#123;work_time:<span class="number">.2</span>f&#125;</span> 秒，等待其他线程...&quot;</span>)</span><br><span class="line">        </span><br><span class="line">        <span class="comment"># 等待所有线程完成第二阶段</span></span><br><span class="line">        barrier.wait()</span><br><span class="line">        <span class="built_in">print</span>(<span class="string">f&quot;工作线程 <span class="subst">&#123;worker_id&#125;</span> 通过第二个栅栏，工作全部完成&quot;</span>)</span><br><span class="line">        </span><br><span class="line">    <span class="keyword">except</span> threading.BrokenBarrierError:</span><br><span class="line">        <span class="built_in">print</span>(<span class="string">f&quot;工作线程 <span class="subst">&#123;worker_id&#125;</span> 检测到栅栏被破坏&quot;</span>)</span><br><span class="line"></span><br><span class="line"><span class="comment">## 创建工作线程</span></span><br><span class="line">threads = []</span><br><span class="line"><span class="keyword">for</span> i <span class="keyword">in</span> <span class="built_in">range</span>(num_parties):</span><br><span class="line">    t = threading.Thread(target=worker, args=(i,))</span><br><span class="line">    threads.append(t)</span><br><span class="line">    t.start()</span><br><span class="line"></span><br><span class="line"><span class="comment">## 等待所有线程完成</span></span><br><span class="line"><span class="keyword">for</span> t <span class="keyword">in</span> threads:</span><br><span class="line">    t.join()</span><br><span class="line"></span><br><span class="line"><span class="built_in">print</span>(<span class="string">&quot;所有工作阶段已完成&quot;</span>)</span><br></pre></td></tr></table></figure><table><thead><tr><th>Barrier 方法</th><th>描述</th><th>参数</th><th>返回值</th></tr></thead><tbody><tr><td><code>__init__(parties, action=None, timeout=None)</code></td><td>初始化栅栏</td><td>parties: 参与方数量, action: 所有线程到达时执行的回调, timeout: 等待超时</td><td>无</td></tr><tr><td><code>wait(timeout=None)</code></td><td>等待所有参与方到达</td><td>timeout: 覆盖默认超时时间</td><td>线程的到达序号(0 ~ n-1)</td></tr><tr><td><code>reset()</code></td><td>将栅栏重置到初始状态</td><td>无</td><td>无，正在等待的线程会抛出 BrokenBarrierError</td></tr><tr><td><code>abort()</code></td><td>将栅栏置于损坏状态</td><td>无</td><td>无，所有等待线程会抛出 BrokenBarrierError</td></tr><tr><td><code>parties</code></td><td>参与方数量(属性)</td><td>无</td><td>整数</td></tr><tr><td><code>n_waiting</code></td><td>当前等待的线程数(属性)</td><td>无</td><td>整数</td></tr><tr><td><code>broken</code></td><td>栅栏是否处于损坏状态(属性)</td><td>无</td><td>布尔值</td></tr></tbody></table><blockquote><p>⚠️ <strong>注意事项</strong>：</p><ul><li>如果等待超时，栅栏会进入损坏状态</li><li>如果等待时的线程被中断，栅栏也会损坏</li><li>可以通过 <code>reset()</code> 方法重新使用已损坏的栅栏</li></ul></blockquote><h5 id="线程安全队列-Queue">线程安全队列(Queue)</h5><p><code>queue</code> 模块提供的 <code>Queue</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></pre></td><td class="code"><pre><span class="line"><span class="keyword">import</span> threading</span><br><span class="line"><span class="keyword">import</span> queue</span><br><span class="line"><span class="keyword">import</span> time</span><br><span class="line"><span class="keyword">import</span> random</span><br><span class="line"><span class="keyword">from</span> concurrent.futures <span class="keyword">import</span> ThreadPoolExecutor</span><br><span class="line"></span><br><span class="line"><span class="comment">## 创建线程安全队列</span></span><br><span class="line">task_queue = queue.Queue(maxsize=<span class="number">10</span>)  <span class="comment"># 最多容纳10个任务</span></span><br><span class="line">result_queue = queue.Queue()  <span class="comment"># 结果队列，无大小限制</span></span><br><span class="line"><span class="comment">## 用于通知工作线程结束的标志</span></span><br><span class="line">exit_flag = threading.Event()</span><br><span class="line"></span><br><span class="line"><span class="keyword">def</span> <span class="title function_">producer</span>():</span><br><span class="line">    <span class="string">&quot;&quot;&quot;生产者线程，产生任务&quot;&quot;&quot;</span></span><br><span class="line">    <span class="keyword">for</span> i <span class="keyword">in</span> <span class="built_in">range</span>(<span class="number">20</span>):</span><br><span class="line">        task = <span class="string">f&quot;任务-<span class="subst">&#123;i&#125;</span>&quot;</span></span><br><span class="line">        <span class="comment"># 将任务放入队列</span></span><br><span class="line">        task_queue.put(task)</span><br><span class="line">        <span class="built_in">print</span>(<span class="string">f&quot;生产者: 添加 <span class="subst">&#123;task&#125;</span> 到队列，当前队列大小: <span class="subst">&#123;task_queue.qsize()&#125;</span>&quot;</span>)</span><br><span class="line">        time.sleep(random.uniform(<span class="number">0.1</span>, <span class="number">0.3</span>))  <span class="comment"># 随机延迟</span></span><br><span class="line"></span><br><span class="line">    <span class="comment"># 添加结束标记</span></span><br><span class="line">    <span class="built_in">print</span>(<span class="string">&quot;生产者: 所有任务已产生，设置退出标志&quot;</span>)</span><br><span class="line">    exit_flag.<span class="built_in">set</span>()</span><br><span class="line"></span><br><span class="line"><span class="keyword">def</span> <span class="title function_">consumer</span>(<span class="params">consumer_id</span>):</span><br><span class="line">    <span class="string">&quot;&quot;&quot;消费者线程，处理任务&quot;&quot;&quot;</span></span><br><span class="line">    <span class="comment"># 直到所有任务都处理完毕或有新任务到来</span></span><br><span class="line">    <span class="keyword">while</span> <span class="keyword">not</span> exit_flag.is_set() <span class="keyword">or</span> <span class="keyword">not</span> task_queue.empty():</span><br><span class="line">        <span class="keyword">try</span>:</span><br><span class="line">            <span class="comment"># 从队列获取任务，最多等待1秒</span></span><br><span class="line">            task = task_queue.get(timeout=<span class="number">1</span>)</span><br><span class="line">            <span class="comment"># 模拟处理任务</span></span><br><span class="line">            <span class="built_in">print</span>(<span class="string">f&quot;消费者 <span class="subst">&#123;consumer_id&#125;</span>: 开始处理 <span class="subst">&#123;task&#125;</span>&quot;</span>)</span><br><span class="line">            process_time = random.uniform(<span class="number">0.5</span>, <span class="number">1.5</span>)</span><br><span class="line">            time.sleep(process_time)</span><br><span class="line">            <span class="comment"># 将处理结果放入结果队列</span></span><br><span class="line">            result = <span class="string">f&quot;结果-<span class="subst">&#123;task&#125;</span>-耗时<span class="subst">&#123;process_time:<span class="number">.2</span>f&#125;</span>秒&quot;</span></span><br><span class="line">            result_queue.put((consumer_id, result))</span><br><span class="line">            <span class="comment"># 标记任务完成</span></span><br><span class="line">            task_queue.task_done()</span><br><span class="line">            <span class="built_in">print</span>(<span class="string">f&quot;消费者 <span class="subst">&#123;consumer_id&#125;</span>: 完成处理 <span class="subst">&#123;task&#125;</span>&quot;</span>)</span><br><span class="line">        <span class="keyword">except</span> queue.Empty:</span><br><span class="line">            <span class="comment"># 队列为空且设置了退出标志时结束循环</span></span><br><span class="line">            <span class="keyword">if</span> exit_flag.is_set():</span><br><span class="line">                <span class="keyword">break</span></span><br><span class="line">            <span class="built_in">print</span>(<span class="string">f&quot;消费者 <span class="subst">&#123;consumer_id&#125;</span>: 队列暂时为空，等待任务...&quot;</span>)</span><br><span class="line">            time.sleep(<span class="number">0.5</span>)</span><br><span class="line">    <span class="built_in">print</span>(<span class="string">f&quot;消费者 <span class="subst">&#123;consumer_id&#125;</span>: 退出&quot;</span>)</span><br><span class="line"></span><br><span class="line"><span class="keyword">if</span> __name__ == <span class="string">&#x27;__main__&#x27;</span>:</span><br><span class="line">    <span class="comment"># 创建生产者线程和消费者线程</span></span><br><span class="line">    <span class="keyword">with</span> ThreadPoolExecutor(max_workers=<span class="number">4</span>) <span class="keyword">as</span> executor:</span><br><span class="line">        <span class="comment"># 创建生产者线程</span></span><br><span class="line">        producers = [executor.submit(producer) <span class="keyword">for</span> _ <span class="keyword">in</span> <span class="built_in">range</span>(<span class="number">2</span>)]</span><br><span class="line">        <span class="comment"># 创建消费者线程</span></span><br><span class="line">        consumers = [executor.submit(consumer, i) <span class="keyword">for</span> i <span class="keyword">in</span> <span class="built_in">range</span>(<span class="number">2</span>)]</span><br><span class="line">        </span><br><span class="line">        <span class="comment"># 等待所有线程结束</span></span><br><span class="line">        all_futures = producers + consumers</span><br><span class="line">        <span class="keyword">for</span> future <span class="keyword">in</span> all_futures:</span><br><span class="line">            future.result()</span><br><span class="line">    </span><br><span class="line">    <span class="comment"># 打印结果队列中的所有结果</span></span><br><span class="line">    <span class="built_in">print</span>(<span class="string">&quot;\n处理结果:&quot;</span>)</span><br><span class="line">    <span class="keyword">while</span> <span class="keyword">not</span> result_queue.empty():</span><br><span class="line">        consumer_id, result = result_queue.get()</span><br><span class="line">        <span class="built_in">print</span>(<span class="string">f&quot;消费者 <span class="subst">&#123;consumer_id&#125;</span> 的结果: <span class="subst">&#123;result&#125;</span>&quot;</span>)</span><br><span class="line"></span><br></pre></td></tr></table></figure><table><thead><tr><th>Queue 方法/属性</th><th>描述</th><th>参数</th><th>返回值/特性</th></tr></thead><tbody><tr><td><code>__init__(maxsize=0)</code></td><td>初始化队列</td><td>maxsize: 队列最大大小，0 表示无限</td><td>无</td></tr><tr><td><code>put(item, block=True, timeout=None)</code></td><td>放入元素</td><td>item: 元素, block: 是否阻塞, timeout: 超时时间</td><td>无，队列满时可能阻塞或抛出 Full 异常</td></tr><tr><td><code>get(block=True, timeout=None)</code></td><td>获取元素</td><td>block: 是否阻塞, timeout: 超时时间</td><td>队列元素，队列空时可能阻塞或抛出 Empty 异常</td></tr><tr><td><code>task_done()</code></td><td>标记任务完成</td><td>无</td><td>无</td></tr><tr><td><code>join()</code></td><td>等待队列中所有任务处理完成</td><td>无</td><td>无</td></tr><tr><td><code>qsize()</code></td><td>返回队列大小</td><td>无</td><td>整数</td></tr><tr><td><code>empty()</code></td><td>检查队列是否为空</td><td>无</td><td>布尔值</td></tr><tr><td><code>full()</code></td><td>检查队列是否已满</td><td>无</td><td>布尔值</td></tr><tr><td><code>put_nowait(item)</code></td><td>非阻塞版本的 put</td><td>item: 元素</td><td>无，队列满时抛出 Full 异常</td></tr><tr><td><code>get_nowait()</code></td><td>非阻塞版本的 get</td><td>无</td><td>队列元素，队列空时抛出 Empty 异常</td></tr></tbody></table><blockquote><p>💡 <strong>Queue 变种</strong>：</p><ul><li><code>queue.LifoQueue</code>: 后进先出队列(栈)</li><li><code>queue.PriorityQueue</code>: 优先级队列，元素为(优先级, 数据)元组</li><li><code>queue.SimpleQueue</code>: 简单的无界队列，不支持 task_done 和 join</li></ul></blockquote><h5 id="死锁问题分析与解决">死锁问题分析与解决</h5><p>死锁是指两个或多个线程互相等待对方释放资源，导致程序无法继续执行的情况。</p><h6 id="死锁示例">死锁示例</h6><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></pre></td><td class="code"><pre><span class="line"><span class="keyword">import</span> threading</span><br><span class="line"><span class="keyword">import</span> time</span><br><span class="line"></span><br><span class="line"><span class="comment">## 创建两个锁</span></span><br><span class="line">lock_1 = threading.Lock()</span><br><span class="line">lock_2 = threading.Lock()</span><br><span class="line"></span><br><span class="line"><span class="keyword">def</span> <span class="title function_">task1</span>():</span><br><span class="line">    <span class="string">&quot;&quot;&quot;第一个任务，先获取lock_1，再获取lock_2&quot;&quot;&quot;</span></span><br><span class="line">    <span class="built_in">print</span>(<span class="string">&quot;任务1开始尝试获取锁...&quot;</span>)</span><br><span class="line">    lock_1.acquire()  <span class="comment"># 获取1号锁</span></span><br><span class="line">    <span class="built_in">print</span>(<span class="string">&quot;任务1获取到lock_1&quot;</span>)</span><br><span class="line">    time.sleep(<span class="number">0.5</span>)  <span class="comment"># 等待一会，让任务2有机会获取lock_2</span></span><br><span class="line">    <span class="built_in">print</span>(<span class="string">&quot;任务1尝试获取lock_2&quot;</span>)</span><br><span class="line">    lock_2.acquire()  <span class="comment"># 尝试获取2号锁，但可能永远阻塞于此</span></span><br><span class="line"></span><br><span class="line">    <span class="keyword">try</span>:</span><br><span class="line">        <span class="built_in">print</span>(<span class="string">&quot;任务1同时获取了两把锁&quot;</span>)</span><br><span class="line">        <span class="comment"># 使用两把锁保护的代码</span></span><br><span class="line">    <span class="keyword">finally</span>:</span><br><span class="line">        <span class="comment"># 释放锁</span></span><br><span class="line">        lock_2.release()</span><br><span class="line">        <span class="built_in">print</span>(<span class="string">&quot;任务1释放了lock_2&quot;</span>)</span><br><span class="line">        lock_1.release()</span><br><span class="line">        <span class="built_in">print</span>(<span class="string">&quot;任务1释放了lock_1&quot;</span>)</span><br><span class="line"></span><br><span class="line"></span><br><span class="line"><span class="keyword">def</span> <span class="title function_">task2</span>():</span><br><span class="line">    <span class="string">&quot;&quot;&quot;第二个任务，先获取lock_2，再获取lock_1&quot;&quot;&quot;</span></span><br><span class="line">    <span class="built_in">print</span>(<span class="string">&quot;任务2开始尝试获取锁...&quot;</span>)</span><br><span class="line">    lock_2.acquire()  <span class="comment"># 获取2号锁</span></span><br><span class="line">    <span class="built_in">print</span>(<span class="string">&quot;任务2获取到lock_2&quot;</span>)</span><br><span class="line">    time.sleep(<span class="number">0.5</span>)  <span class="comment"># 等待一会</span></span><br><span class="line">    <span class="built_in">print</span>(<span class="string">&quot;任务2尝试获取lock_1&quot;</span>)</span><br><span class="line">    lock_1.acquire()  <span class="comment"># 尝试获取1号锁，但可能永远阻塞于此</span></span><br><span class="line">    <span class="keyword">try</span>:</span><br><span class="line">        <span class="built_in">print</span>(<span class="string">&quot;任务2同时获取了两把锁&quot;</span>)</span><br><span class="line">        <span class="comment"># 使用两把锁保护的代码</span></span><br><span class="line">    <span class="keyword">finally</span>:</span><br><span class="line">        <span class="comment"># 释放锁</span></span><br><span class="line">        lock_1.release()</span><br><span class="line">        <span class="built_in">print</span>(<span class="string">&quot;任务2释放了lock_1&quot;</span>)</span><br><span class="line">        lock_2.release()</span><br><span class="line">        <span class="built_in">print</span>(<span class="string">&quot;任务2释放了lock_2&quot;</span>)</span><br><span class="line"><span class="keyword">if</span> __name__ == <span class="string">&#x27;__main__&#x27;</span>:</span><br><span class="line">    <span class="comment">## 创建两个线程</span></span><br><span class="line">    t1 = threading.Thread(target=task1)</span><br><span class="line">    t2 = threading.Thread(target=task2)</span><br><span class="line"></span><br><span class="line">    <span class="comment">## 启动线程</span></span><br><span class="line">    t1.start()</span><br><span class="line">    t2.start()</span><br><span class="line"></span><br><span class="line">    <span class="comment">## 等待一段时间后检查是否发生死锁</span></span><br><span class="line">    time.sleep(<span class="number">5</span>)</span><br><span class="line"></span><br><span class="line">    <span class="comment">## 检查线程是否还活着</span></span><br><span class="line">    <span class="built_in">print</span>(<span class="string">f&quot;线程1状态: <span class="subst">&#123;<span class="string">&#x27;活跃&#x27;</span> <span class="keyword">if</span> t1.is_alive() <span class="keyword">else</span> <span class="string">&#x27;已结束&#x27;</span>&#125;</span>&quot;</span>)</span><br><span class="line">    <span class="built_in">print</span>(<span class="string">f&quot;线程2状态: <span class="subst">&#123;<span class="string">&#x27;活跃&#x27;</span> <span class="keyword">if</span> t2.is_alive() <span class="keyword">else</span> <span class="string">&#x27;已结束&#x27;</span>&#125;</span>&quot;</span>)</span><br><span class="line"></span><br><span class="line">    <span class="keyword">if</span> t1.is_alive() <span class="keyword">and</span> t2.is_alive():</span><br><span class="line">        <span class="built_in">print</span>(<span class="string">&quot;检测到可能的死锁情况!&quot;</span>)</span><br></pre></td></tr></table></figure><blockquote><p>⚠️ <strong>死锁的四个必要条件</strong>：</p><ol><li><strong>互斥条件</strong>：资源不能被共享，一次只能被一个线程使用</li><li><strong>请求与保持条件</strong>：线程已获得资源，但又提出新的资源请求</li><li><strong>不剥夺条件</strong>：线程已获得的资源不能强制被剥夺</li><li><strong>循环等待条件</strong>：线程之间形成头尾相接的循环等待资源关系</li></ol></blockquote><h6 id="死锁解决方案">死锁解决方案</h6><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></pre></td><td class="code"><pre><span class="line"><span class="keyword">import</span> threading</span><br><span class="line"><span class="keyword">import</span> time</span><br><span class="line"></span><br><span class="line"><span class="comment">## 创建两个锁</span></span><br><span class="line">lock_1 = threading.Lock()</span><br><span class="line">lock_2 = threading.Lock()</span><br><span class="line"></span><br><span class="line"><span class="keyword">def</span> <span class="title function_">acquire_locks_safe</span>(<span class="params">lock_a, lock_b, thread_name</span>):</span><br><span class="line">    <span class="string">&quot;&quot;&quot;安全地获取两个锁，使用超时机制避免死锁</span></span><br><span class="line"><span class="string">    </span></span><br><span class="line"><span class="string">    Args:</span></span><br><span class="line"><span class="string">        lock_a: 第一个锁</span></span><br><span class="line"><span class="string">        lock_b: 第二个锁</span></span><br><span class="line"><span class="string">        thread_name: 线程名称</span></span><br><span class="line"><span class="string">        </span></span><br><span class="line"><span class="string">    Returns:</span></span><br><span class="line"><span class="string">        bool: 是否成功获取两个锁</span></span><br><span class="line"><span class="string">    &quot;&quot;&quot;</span></span><br><span class="line">    <span class="keyword">while</span> <span class="literal">True</span>:</span><br><span class="line">        <span class="comment"># 尝试获取第一个锁</span></span><br><span class="line">        got_lock_a = lock_a.acquire(timeout=<span class="number">1</span>)</span><br><span class="line">        <span class="keyword">if</span> got_lock_a:</span><br><span class="line">            <span class="built_in">print</span>(<span class="string">f&quot;<span class="subst">&#123;thread_name&#125;</span>: 获取到第一个锁&quot;</span>)</span><br><span class="line">            <span class="keyword">try</span>:</span><br><span class="line">                <span class="comment"># 尝试获取第二个锁</span></span><br><span class="line">                got_lock_b = lock_b.acquire(timeout=<span class="number">1</span>)</span><br><span class="line">                <span class="keyword">if</span> got_lock_b:</span><br><span class="line">                    <span class="built_in">print</span>(<span class="string">f&quot;<span class="subst">&#123;thread_name&#125;</span>: 获取到第二个锁&quot;</span>)</span><br><span class="line">                    <span class="keyword">return</span> <span class="literal">True</span>  <span class="comment"># 成功获取两个锁</span></span><br><span class="line">                <span class="comment"># 获取第二个锁失败，释放第一个锁，避免死锁</span></span><br><span class="line">                <span class="built_in">print</span>(<span class="string">f&quot;<span class="subst">&#123;thread_name&#125;</span>: 获取第二个锁失败，释放第一个锁并重试&quot;</span>)</span><br><span class="line">            <span class="keyword">finally</span>:</span><br><span class="line">                <span class="keyword">if</span> <span class="keyword">not</span> got_lock_b:</span><br><span class="line">                    lock_a.release()</span><br><span class="line">                    <span class="comment"># 短暂休眠，减少活锁可能性</span></span><br><span class="line">                    time.sleep(<span class="number">0.1</span>)</span><br><span class="line">        <span class="keyword">else</span>:</span><br><span class="line">            <span class="built_in">print</span>(<span class="string">f&quot;<span class="subst">&#123;thread_name&#125;</span>: 获取第一个锁失败，重试&quot;</span>)</span><br><span class="line">            time.sleep(<span class="number">0.1</span>)  <span class="comment"># 短暂休眠避免CPU忙等</span></span><br><span class="line"></span><br><span class="line"><span class="keyword">def</span> <span class="title function_">task1_fixed</span>():</span><br><span class="line">    <span class="string">&quot;&quot;&quot;修复死锁的任务1 - 使用安全获取锁函数&quot;&quot;&quot;</span></span><br><span class="line">    <span class="built_in">print</span>(<span class="string">&quot;任务1开始执行...&quot;</span>)</span><br><span class="line">    <span class="keyword">if</span> acquire_locks_safe(lock_1, lock_2, <span class="string">&quot;任务1&quot;</span>):</span><br><span class="line">        <span class="keyword">try</span>:</span><br><span class="line">            <span class="built_in">print</span>(<span class="string">&quot;任务1: 同时持有两把锁，执行关键代码&quot;</span>)</span><br><span class="line">            time.sleep(<span class="number">0.5</span>)  <span class="comment"># 模拟工作</span></span><br><span class="line">        <span class="keyword">finally</span>:</span><br><span class="line">            <span class="comment"># 释放锁</span></span><br><span class="line">            lock_2.release()</span><br><span class="line">            <span class="built_in">print</span>(<span class="string">&quot;任务1: 释放lock_2&quot;</span>)</span><br><span class="line">            lock_1.release()</span><br><span class="line">            <span class="built_in">print</span>(<span class="string">&quot;任务1: 释放lock_1&quot;</span>)</span><br><span class="line">    <span class="keyword">else</span>:</span><br><span class="line">        <span class="built_in">print</span>(<span class="string">&quot;任务1: 无法获取所需的锁，任务取消&quot;</span>)</span><br><span class="line"></span><br><span class="line"><span class="keyword">def</span> <span class="title function_">task2_fixed</span>():</span><br><span class="line">    <span class="string">&quot;&quot;&quot;修复死锁的任务2 - 使用一致的锁获取顺序&quot;&quot;&quot;</span></span><br><span class="line">    <span class="built_in">print</span>(<span class="string">&quot;任务2开始执行...&quot;</span>)</span><br><span class="line">    <span class="comment"># 按与任务1相同的顺序获取锁，避免死锁</span></span><br><span class="line">    <span class="keyword">if</span> acquire_locks_safe(lock_1, lock_2, <span class="string">&quot;任务2&quot;</span>):</span><br><span class="line">        <span class="keyword">try</span>:</span><br><span class="line">            <span class="built_in">print</span>(<span class="string">&quot;任务2: 同时持有两把锁，执行关键代码&quot;</span>)</span><br><span class="line">            time.sleep(<span class="number">0.5</span>)  <span class="comment"># 模拟工作</span></span><br><span class="line">        <span class="keyword">finally</span>:</span><br><span class="line">            <span class="comment"># 释放锁</span></span><br><span class="line">            lock_2.release()</span><br><span class="line">            <span class="built_in">print</span>(<span class="string">&quot;任务2: 释放lock_2&quot;</span>)</span><br><span class="line">            lock_1.release()</span><br><span class="line">            <span class="built_in">print</span>(<span class="string">&quot;任务2: 释放lock_1&quot;</span>)</span><br><span class="line">    <span class="keyword">else</span>:</span><br><span class="line">        <span class="built_in">print</span>(<span class="string">&quot;任务2: 无法获取所需的锁，任务取消&quot;</span>)</span><br><span class="line"></span><br><span class="line"><span class="comment">## 创建两个线程</span></span><br><span class="line">t1 = threading.Thread(target=task1_fixed)</span><br><span class="line">t2 = threading.Thread(target=task2_fixed)</span><br><span class="line"></span><br><span class="line"><span class="comment">## 启动线程</span></span><br><span class="line">t1.start()</span><br><span class="line">t2.start()</span><br><span class="line"></span><br><span class="line"><span class="comment">## 等待线程结束</span></span><br><span class="line">t1.join()</span><br><span class="line">t2.join()</span><br><span class="line"></span><br><span class="line"><span class="built_in">print</span>(<span class="string">&quot;所有线程执行完毕，没有死锁&quot;</span>)</span><br></pre></td></tr></table></figure><blockquote><p>🛠️ <strong>死锁预防方法</strong>：</p><ol><li><strong>按顺序获取锁</strong>：使所有线程按相同顺序获取锁</li><li><strong>超时机制</strong>：使用 <code>acquire(timeout=N)</code> 设置获取锁的超时时间</li><li><strong>一次性获取所有锁</strong>：创建更高级别的锁来同时获取多个锁</li><li><strong>使用显式资源分级</strong>：为资源分配层级，只允许按层级顺序获取</li><li><strong>避免嵌套锁</strong>：设计简化的锁策略，减少同时持有多个锁的情况</li><li><strong>使用 <code>with</code> 语句</strong>：确保锁在异常情况下也能被释放</li></ol></blockquote><h4 id="原子操作与锁优化">原子操作与锁优化</h4><p>在并发编程中，原子操作是指不可被中断的操作，它们要么完全执行，要么完全不执行。Python 提供了一些原子操作工具，可以减少对锁的依赖。</p><h5 id="threading-local-对象-线程本地存储"><code>threading.local</code> 对象 - 线程本地存储</h5><p>线程本地存储提供了一种每个线程拥有自己独立数据副本的机制，避免了共享状态带来的并发问题。</p><blockquote><p>我们可以往 threading.local()上挂载对象，这样我们的每一个线程就会有属于自己的独立数据</p></blockquote><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></pre></td><td class="code"><pre><span class="line"><span class="keyword">import</span> threading</span><br><span class="line"><span class="keyword">import</span> time</span><br><span class="line"><span class="keyword">import</span> random</span><br><span class="line"><span class="keyword">from</span> concurrent.futures <span class="keyword">import</span> ThreadPoolExecutor</span><br><span class="line"></span><br><span class="line"><span class="comment">## 创建线程本地存储对象</span></span><br><span class="line">thread_local_data = threading.local()</span><br><span class="line"></span><br><span class="line"><span class="keyword">def</span> <span class="title function_">process_request</span>(<span class="params">request_id: <span class="built_in">int</span></span>) -&gt; <span class="literal">None</span>:</span><br><span class="line">    <span class="string">&quot;&quot;&quot;处理请求的工作函数&quot;&quot;&quot;</span></span><br><span class="line">    <span class="comment"># 为当前线程设置上下文信息</span></span><br><span class="line">    thread_local_data.user_id = <span class="string">f&quot;user-<span class="subst">&#123;random.randint(<span class="number">1000</span>, <span class="number">9999</span>)&#125;</span>&quot;</span></span><br><span class="line">    thread_local_data.request = request_id</span><br><span class="line">    thread_local_data.start_time = time.time()</span><br><span class="line">    <span class="comment"># 模拟处理请求的各个阶段</span></span><br><span class="line">    <span class="built_in">print</span>(<span class="string">f&quot;请求 <span class="subst">&#123;request_id&#125;</span>: 开始处理 [线程: <span class="subst">&#123;threading.current_thread().name&#125;</span>, 用户: <span class="subst">&#123;thread_local_data.user_id&#125;</span>]&quot;</span>)</span><br><span class="line">    process_stage(<span class="string">&quot;验证&quot;</span>)</span><br><span class="line">    process_stage(<span class="string">&quot;处理&quot;</span>)</span><br><span class="line">    process_stage(<span class="string">&quot;响应&quot;</span>)</span><br><span class="line">    <span class="comment"># 计算总处理时间</span></span><br><span class="line">    elapsed = time.time() - thread_local_data.start_time</span><br><span class="line">    <span class="built_in">print</span>(<span class="string">f&quot;请求 <span class="subst">&#123;request_id&#125;</span>: 完成处理，总耗时 <span class="subst">&#123;elapsed:<span class="number">.2</span>f&#125;</span>秒 [线程: <span class="subst">&#123;threading.current_thread().name&#125;</span>]&quot;</span>)</span><br><span class="line"></span><br><span class="line"></span><br><span class="line"><span class="keyword">def</span> <span class="title function_">process_stage</span>(<span class="params">stage_name: <span class="built_in">str</span></span>):</span><br><span class="line">    <span class="string">&quot;&quot;&quot;处理请求的某个阶段&quot;&quot;&quot;</span></span><br><span class="line">    <span class="comment"># 访问线程本地变量，无需传递参数</span></span><br><span class="line">    request_id = thread_local_data.request</span><br><span class="line">    user_id = thread_local_data.user_id</span><br><span class="line"></span><br><span class="line">    <span class="comment"># 模拟阶段处理</span></span><br><span class="line">    time.sleep(random.uniform(<span class="number">0.1</span>, <span class="number">0.5</span>))</span><br><span class="line">    <span class="built_in">print</span>(<span class="string">f&quot;请求 <span class="subst">&#123;request_id&#125;</span>: <span class="subst">&#123;stage_name&#125;</span>阶段完成 [用户: <span class="subst">&#123;user_id&#125;</span>]&quot;</span>)</span><br><span class="line"></span><br><span class="line"><span class="keyword">if</span> __name__ == <span class="string">&#x27;__main__&#x27;</span>:</span><br><span class="line">    <span class="keyword">with</span> ThreadPoolExecutor(max_workers=<span class="number">10</span>) <span class="keyword">as</span> executor:</span><br><span class="line">        futures = [executor.submit(process_request, i) <span class="keyword">for</span> i <span class="keyword">in</span> <span class="built_in">range</span>(<span class="number">10</span>)]</span><br><span class="line">        <span class="keyword">for</span> future <span class="keyword">in</span> futures:</span><br><span class="line">            future.result()</span><br><span class="line">        <span class="built_in">print</span>(<span class="string">&quot;所有请求处理完成&quot;</span>)</span><br></pre></td></tr></table></figure><h5 id="functools-lru-cache-带锁的缓存"><code>functools.lru_cache</code> 带锁的缓存</h5><p><code>functools.lru_cache</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></pre></td><td class="code"><pre><span class="line"><span class="keyword">import</span> functools</span><br><span class="line"><span class="keyword">import</span> time</span><br><span class="line"></span><br><span class="line"><span class="comment"># ========== LRU缓存演示 ==========</span></span><br><span class="line"><span class="meta">@functools.lru_cache(<span class="params">maxsize=<span class="number">128</span></span>)</span></span><br><span class="line"><span class="keyword">def</span> <span class="title function_">fibonacci</span>(<span class="params">n</span>):</span><br><span class="line">    <span class="string">&quot;&quot;&quot;计算斐波那契数列的第n个数，使用LRU缓存优化性能&quot;&quot;&quot;</span></span><br><span class="line">    <span class="keyword">if</span> n &lt;= <span class="number">1</span>:</span><br><span class="line">        <span class="keyword">return</span> n</span><br><span class="line">    <span class="keyword">return</span> fibonacci(n-<span class="number">1</span>) + fibonacci(n-<span class="number">2</span>)</span><br><span class="line"></span><br><span class="line"><span class="keyword">def</span> <span class="title function_">demonstrate_lru_cache</span>():</span><br><span class="line">    <span class="string">&quot;&quot;&quot;演示LRU缓存的效果&quot;&quot;&quot;</span></span><br><span class="line">    <span class="comment"># 不使用缓存的计算时间</span></span><br><span class="line">    <span class="keyword">def</span> <span class="title function_">fibonacci_no_cache</span>(<span class="params">n</span>):</span><br><span class="line">        <span class="keyword">if</span> n &lt;= <span class="number">1</span>:</span><br><span class="line">            <span class="keyword">return</span> n</span><br><span class="line">        <span class="keyword">return</span> fibonacci_no_cache(n-<span class="number">1</span>) + fibonacci_no_cache(n-<span class="number">2</span>)</span><br><span class="line">    </span><br><span class="line">    n = <span class="number">35</span></span><br><span class="line">    </span><br><span class="line">    <span class="comment"># 测试无缓存版本</span></span><br><span class="line">    start = time.time()</span><br><span class="line">    result1 = fibonacci_no_cache(n)</span><br><span class="line">    end = time.time()</span><br><span class="line">    <span class="built_in">print</span>(<span class="string">f&quot;无缓存计算fibonacci(<span class="subst">&#123;n&#125;</span>) = <span class="subst">&#123;result1&#125;</span>，耗时: <span class="subst">&#123;end - start:<span class="number">.4</span>f&#125;</span>秒&quot;</span>)</span><br><span class="line">    </span><br><span class="line">    <span class="comment"># 测试有缓存版本</span></span><br><span class="line">    start = time.time()</span><br><span class="line">    result2 = fibonacci(n)</span><br><span class="line">    end = time.time()</span><br><span class="line">    <span class="built_in">print</span>(<span class="string">f&quot;首次使用缓存计算fibonacci(<span class="subst">&#123;n&#125;</span>) = <span class="subst">&#123;result2&#125;</span>，耗时: <span class="subst">&#123;end - start:<span class="number">.4</span>f&#125;</span>秒&quot;</span>)</span><br><span class="line">    </span><br><span class="line">    <span class="comment"># 再次调用，应该直接从缓存获取结果</span></span><br><span class="line">    start = time.time()</span><br><span class="line">    result3 = fibonacci(n)</span><br><span class="line">    end = time.time()</span><br><span class="line">    <span class="built_in">print</span>(<span class="string">f&quot;再次使用缓存计算fibonacci(<span class="subst">&#123;n&#125;</span>) = <span class="subst">&#123;result3&#125;</span>，耗时: <span class="subst">&#123;end - start:<span class="number">.8</span>f&#125;</span>秒&quot;</span>)</span><br><span class="line">    </span><br><span class="line">    <span class="comment"># 显示缓存信息</span></span><br><span class="line">    <span class="built_in">print</span>(<span class="string">f&quot;缓存信息: <span class="subst">&#123;fibonacci.cache_info()&#125;</span>&quot;</span>)</span><br><span class="line"></span><br><span class="line"><span class="keyword">if</span> __name__ == <span class="string">&quot;__main__&quot;</span>:</span><br><span class="line">    demonstrate_lru_cache()</span><br><span class="line"></span><br></pre></td></tr></table></figure><h4 id="锁的高级应用模式">锁的高级应用模式</h4><h5 id="读写锁模式">读写锁模式</h5><p>Python 中的读写锁（Read-Write Lock）主要用于在多线程环境中控制对共享资源的访问。它允许多个线程同时读取共享数据，但在写操作时，其他线程不能进行读或写操作。具体的应用场景包括：</p><ol><li><strong>数据共享与并发读取</strong>：当多个线程需要读取同一份数据时，使用读锁可以提高并发性，允许多个线程同时访问数据，而不需要每次访问都加锁。</li><li><strong>写操作的独占性</strong>：当有线程进行写操作时，需要获取写锁，这样可以确保写操作的独占性，避免数据竞争和不一致性。</li><li><strong>性能优化</strong>：在读多写少的场景下，读写锁能提高性能，因为它允许多个线程并行读取数据，而只有在写入时才会阻塞其他线程。</li></ol><p>我们先从 Python 原生实现读写锁来作为演示，掌握了原生的方式，我们可以使用 <code>readerwriterlock</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></pre></td><td class="code"><pre><span class="line"><span class="keyword">import</span> threading</span><br><span class="line"><span class="keyword">import</span> time</span><br><span class="line"><span class="keyword">import</span> random</span><br><span class="line"></span><br><span class="line"></span><br><span class="line"><span class="keyword">class</span> <span class="title class_">ReadWriteLock</span>:</span><br><span class="line">    <span class="string">&quot;&quot;&quot;读写锁实现</span></span><br><span class="line"><span class="string"></span></span><br><span class="line"><span class="string">    允许多个读取者同时访问，或单个写入者独占访问</span></span><br><span class="line"><span class="string">    &quot;&quot;&quot;</span></span><br><span class="line"></span><br><span class="line">    <span class="keyword">def</span> <span class="title function_">__init__</span>(<span class="params">self</span>):</span><br><span class="line">        <span class="string">&quot;&quot;&quot;初始化读写锁&quot;&quot;&quot;</span></span><br><span class="line">        <span class="variable language_">self</span>._read_ready = threading.Condition(threading.RLock())</span><br><span class="line">        <span class="variable language_">self</span>._readers = <span class="number">0</span>  <span class="comment"># 当前读取者数量</span></span><br><span class="line">        <span class="variable language_">self</span>._writers = <span class="number">0</span>  <span class="comment"># 当前写入者数量</span></span><br><span class="line">        <span class="variable language_">self</span>._write_waiting = <span class="number">0</span>  <span class="comment"># 等待写入的线程数</span></span><br><span class="line">        <span class="variable language_">self</span>._writer = <span class="literal">None</span>  <span class="comment"># 当前持有写锁的线程ID</span></span><br><span class="line"></span><br><span class="line">    <span class="keyword">def</span> <span class="title function_">acquire_read</span>(<span class="params">self</span>):</span><br><span class="line">        <span class="string">&quot;&quot;&quot;获取读锁&quot;&quot;&quot;</span></span><br><span class="line">        <span class="keyword">with</span> <span class="variable language_">self</span>._read_ready:</span><br><span class="line">            <span class="comment"># 当有写入者或正在等待的写入者时，读取者需要等待</span></span><br><span class="line">            <span class="keyword">while</span> <span class="variable language_">self</span>._writers &gt; <span class="number">0</span> <span class="keyword">or</span> <span class="variable language_">self</span>._write_waiting &gt; <span class="number">0</span>:</span><br><span class="line">                <span class="variable language_">self</span>._read_ready.wait()</span><br><span class="line">            <span class="variable language_">self</span>._readers += <span class="number">1</span></span><br><span class="line"></span><br><span class="line">    <span class="keyword">def</span> <span class="title function_">release_read</span>(<span class="params">self</span>):</span><br><span class="line">        <span class="string">&quot;&quot;&quot;释放读锁&quot;&quot;&quot;</span></span><br><span class="line">        <span class="keyword">with</span> <span class="variable language_">self</span>._read_ready:</span><br><span class="line">            <span class="variable language_">self</span>._readers -= <span class="number">1</span></span><br><span class="line">            <span class="keyword">if</span> <span class="variable language_">self</span>._readers == <span class="number">0</span>:  <span class="comment"># 最后一个读取者通知所有等待的线程</span></span><br><span class="line">                <span class="variable language_">self</span>._read_ready.notify_all()</span><br><span class="line"></span><br><span class="line">    <span class="keyword">def</span> <span class="title function_">acquire_write</span>(<span class="params">self</span>):</span><br><span class="line">        <span class="string">&quot;&quot;&quot;获取写锁&quot;&quot;&quot;</span></span><br><span class="line">        me = threading.get_ident()  <span class="comment"># 获取当前线程ID</span></span><br><span class="line">        <span class="keyword">with</span> <span class="variable language_">self</span>._read_ready:</span><br><span class="line">            <span class="variable language_">self</span>._write_waiting += <span class="number">1</span>  <span class="comment"># 增加等待写入计数</span></span><br><span class="line">            <span class="comment"># 等待没有读取者和写入者</span></span><br><span class="line">            <span class="keyword">while</span> <span class="variable language_">self</span>._readers &gt; <span class="number">0</span> <span class="keyword">or</span> <span class="variable language_">self</span>._writers &gt; <span class="number">0</span>:</span><br><span class="line">                <span class="variable language_">self</span>._read_ready.wait()</span><br><span class="line">            <span class="variable language_">self</span>._write_waiting -= <span class="number">1</span>  <span class="comment"># 减少等待写入计数</span></span><br><span class="line">            <span class="variable language_">self</span>._writers += <span class="number">1</span></span><br><span class="line">            <span class="variable language_">self</span>._writer = me</span><br><span class="line"></span><br><span class="line">    <span class="keyword">def</span> <span class="title function_">release_write</span>(<span class="params">self</span>):</span><br><span class="line">        <span class="string">&quot;&quot;&quot;释放写锁&quot;&quot;&quot;</span></span><br><span class="line">        <span class="keyword">with</span> <span class="variable language_">self</span>._read_ready:</span><br><span class="line">            <span class="keyword">if</span> <span class="variable language_">self</span>._writer != threading.get_ident():</span><br><span class="line">                <span class="keyword">raise</span> RuntimeError(<span class="string">&quot;释放未持有的写锁&quot;</span>)</span><br><span class="line">            <span class="variable language_">self</span>._writers -= <span class="number">1</span></span><br><span class="line">            <span class="variable language_">self</span>._writer = <span class="literal">None</span></span><br><span class="line">            <span class="variable language_">self</span>._read_ready.notify_all()  <span class="comment"># 通知所有等待的线程</span></span><br><span class="line"></span><br><span class="line">    <span class="comment"># 支持with语句的上下文管理器</span></span><br><span class="line">    <span class="keyword">class</span> <span class="title class_">ReadLock</span>:</span><br><span class="line">        <span class="keyword">def</span> <span class="title function_">__init__</span>(<span class="params">self, rw_lock</span>):</span><br><span class="line">            <span class="variable language_">self</span>.rw_lock = rw_lock</span><br><span class="line"></span><br><span class="line">        <span class="keyword">def</span> <span class="title function_">__enter__</span>(<span class="params">self</span>):</span><br><span class="line">            <span class="variable language_">self</span>.rw_lock.acquire_read()</span><br><span class="line">            <span class="keyword">return</span> <span class="variable language_">self</span></span><br><span class="line"></span><br><span class="line">        <span class="keyword">def</span> <span class="title function_">__exit__</span>(<span class="params">self, exc_type, exc_val, exc_tb</span>):</span><br><span class="line">            <span class="variable language_">self</span>.rw_lock.release_read()</span><br><span class="line"></span><br><span class="line">    <span class="keyword">class</span> <span class="title class_">WriteLock</span>:</span><br><span class="line">        <span class="keyword">def</span> <span class="title function_">__init__</span>(<span class="params">self, rw_lock</span>):</span><br><span class="line">            <span class="variable language_">self</span>.rw_lock = rw_lock</span><br><span class="line"></span><br><span class="line">        <span class="keyword">def</span> <span class="title function_">__enter__</span>(<span class="params">self</span>):</span><br><span class="line">            <span class="variable language_">self</span>.rw_lock.acquire_write()</span><br><span class="line">            <span class="keyword">return</span> <span class="variable language_">self</span></span><br><span class="line"></span><br><span class="line">        <span class="keyword">def</span> <span class="title function_">__exit__</span>(<span class="params">self, exc_type, exc_val, exc_tb</span>):</span><br><span class="line">            <span class="variable language_">self</span>.rw_lock.release_write()</span><br><span class="line"></span><br><span class="line">    <span class="comment"># 获取读锁和写锁的方法</span></span><br><span class="line">    <span class="keyword">def</span> <span class="title function_">read_lock</span>(<span class="params">self</span>):</span><br><span class="line">        <span class="string">&quot;&quot;&quot;获取读锁上下文管理器&quot;&quot;&quot;</span></span><br><span class="line">        <span class="keyword">return</span> <span class="variable language_">self</span>.ReadLock(<span class="variable language_">self</span>)</span><br><span class="line"></span><br><span class="line">    <span class="keyword">def</span> <span class="title function_">write_lock</span>(<span class="params">self</span>):</span><br><span class="line">        <span class="string">&quot;&quot;&quot;获取写锁上下文管理器&quot;&quot;&quot;</span></span><br><span class="line">        <span class="keyword">return</span> <span class="variable language_">self</span>.WriteLock(<span class="variable language_">self</span>)</span><br><span class="line"></span><br><span class="line"></span><br><span class="line"><span class="comment">### 共享数据和读写锁</span></span><br><span class="line">shared_data = &#123;<span class="string">&#x27;count&#x27;</span>: <span class="number">0</span>, <span class="string">&#x27;values&#x27;</span>: []&#125;</span><br><span class="line">rw_lock = ReadWriteLock()</span><br><span class="line"></span><br><span class="line"></span><br><span class="line"><span class="keyword">def</span> <span class="title function_">reader</span>(<span class="params">reader_id</span>):</span><br><span class="line">    <span class="string">&quot;&quot;&quot;读取者线程</span></span><br><span class="line"><span class="string"></span></span><br><span class="line"><span class="string">    Args:</span></span><br><span class="line"><span class="string">        reader_id: 读取者ID</span></span><br><span class="line"><span class="string">    &quot;&quot;&quot;</span></span><br><span class="line">    <span class="keyword">for</span> _ <span class="keyword">in</span> <span class="built_in">range</span>(<span class="number">5</span>):</span><br><span class="line">        <span class="comment"># 获取读锁</span></span><br><span class="line">        <span class="keyword">with</span> rw_lock.read_lock():</span><br><span class="line">            <span class="comment"># 读取共享数据</span></span><br><span class="line">            count = shared_data[<span class="string">&#x27;count&#x27;</span>]</span><br><span class="line">            values = shared_data[<span class="string">&#x27;values&#x27;</span>].copy()</span><br><span class="line"></span><br><span class="line">            <span class="comment"># 模拟读取操作</span></span><br><span class="line">            time.sleep(random.uniform(<span class="number">0.05</span>, <span class="number">0.1</span>))</span><br><span class="line"></span><br><span class="line">            <span class="built_in">print</span>(<span class="string">f&quot;读取者 <span class="subst">&#123;reader_id&#125;</span>: 读取到 count=<span class="subst">&#123;count&#125;</span>, values=<span class="subst">&#123;values&#125;</span>&quot;</span>)</span><br><span class="line"></span><br><span class="line">        <span class="comment"># 读取者之间的休息</span></span><br><span class="line">        time.sleep(random.uniform(<span class="number">0.1</span>, <span class="number">0.3</span>))</span><br><span class="line"></span><br><span class="line"></span><br><span class="line"><span class="keyword">def</span> <span class="title function_">writer</span>(<span class="params">writer_id</span>):</span><br><span class="line">    <span class="string">&quot;&quot;&quot;写入者线程</span></span><br><span class="line"><span class="string"></span></span><br><span class="line"><span class="string">    Args:</span></span><br><span class="line"><span class="string">        writer_id: 写入者ID</span></span><br><span class="line"><span class="string">    &quot;&quot;&quot;</span></span><br><span class="line">    <span class="keyword">for</span> i <span class="keyword">in</span> <span class="built_in">range</span>(<span class="number">3</span>):</span><br><span class="line">        <span class="comment"># 准备新数据</span></span><br><span class="line">        new_value = writer_id * <span class="number">100</span> + i</span><br><span class="line"></span><br><span class="line">        <span class="comment"># 获取写锁</span></span><br><span class="line">        <span class="keyword">with</span> rw_lock.write_lock():</span><br><span class="line">            <span class="comment"># 修改共享数据</span></span><br><span class="line">            shared_data[<span class="string">&#x27;count&#x27;</span>] += <span class="number">1</span></span><br><span class="line">            shared_data[<span class="string">&#x27;values&#x27;</span>].append(new_value)</span><br><span class="line"></span><br><span class="line">            <span class="comment"># 模拟写入操作</span></span><br><span class="line">            time.sleep(random.uniform(<span class="number">0.1</span>, <span class="number">0.2</span>))</span><br><span class="line"></span><br><span class="line">            <span class="built_in">print</span>(<span class="string">f&quot;写入者 <span class="subst">&#123;writer_id&#125;</span>: 更新为 count=<span class="subst">&#123;shared_data[<span class="string">&#x27;count&#x27;</span>]&#125;</span>, values=<span class="subst">&#123;shared_data[<span class="string">&#x27;values&#x27;</span>]&#125;</span>&quot;</span>)</span><br><span class="line"></span><br><span class="line">        <span class="comment"># 写入者之间的休息</span></span><br><span class="line">        time.sleep(random.uniform(<span class="number">0.3</span>, <span class="number">0.7</span>))</span><br><span class="line"></span><br><span class="line"></span><br><span class="line"><span class="comment">### 创建读取者和写入者线程</span></span><br><span class="line">readers = [threading.Thread(target=reader, args=(i,)) <span class="keyword">for</span> i <span class="keyword">in</span> <span class="built_in">range</span>(<span class="number">5</span>)]</span><br><span class="line">writers = [threading.Thread(target=writer, args=(i,)) <span class="keyword">for</span> i <span class="keyword">in</span> <span class="built_in">range</span>(<span class="number">3</span>)]</span><br><span class="line"></span><br><span class="line"><span class="comment">### 启动所有线程</span></span><br><span class="line">all_threads = readers + writers</span><br><span class="line"><span class="keyword">for</span> thread <span class="keyword">in</span> all_threads:</span><br><span class="line">    thread.start()</span><br><span class="line"></span><br><span class="line"><span class="comment">### 等待所有线程完成</span></span><br><span class="line"><span class="keyword">for</span> thread <span class="keyword">in</span> all_threads:</span><br><span class="line">    thread.join()</span><br><span class="line"></span><br><span class="line"><span class="built_in">print</span>(<span class="string">f&quot;最终数据: count=<span class="subst">&#123;shared_data[<span class="string">&#x27;count&#x27;</span>]&#125;</span>, values=<span class="subst">&#123;shared_data[<span class="string">&#x27;values&#x27;</span>]&#125;</span>&quot;</span>)</span><br></pre></td></tr></table></figure><h6 id="使用-readerwriterlock-库实现读写锁">使用 <code>readerwriterlock</code> 库实现读写锁</h6><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></pre></td><td class="code"><pre><span class="line"><span class="string">&quot;&quot;&quot;</span></span><br><span class="line"><span class="string">读写锁实现示例</span></span><br><span class="line"><span class="string"></span></span><br><span class="line"><span class="string">readerwriterlock库提供了三种读写锁实现：</span></span><br><span class="line"><span class="string">- RWLockRead：读者优先（第一读者-写者问题）</span></span><br><span class="line"><span class="string">- RWLockWrite：写者优先（第二读者-写者问题）</span></span><br><span class="line"><span class="string">- RWLockFair：公平优先（第三读者-写者问题）</span></span><br><span class="line"><span class="string">每种锁都有对应的可降级版本（带D后缀），允许将锁从写模式降级到读模式</span></span><br><span class="line"><span class="string">&quot;&quot;&quot;</span></span><br><span class="line"></span><br><span class="line"><span class="keyword">import</span> threading</span><br><span class="line"><span class="keyword">import</span> time</span><br><span class="line"><span class="keyword">from</span> readerwriterlock <span class="keyword">import</span> rwlock</span><br><span class="line"></span><br><span class="line"><span class="comment"># 创建一个公平优先的读写锁</span></span><br><span class="line">rw_lock = rwlock.RWLockFairD()</span><br><span class="line"><span class="comment"># 共享数据</span></span><br><span class="line">shared_data = &#123;<span class="string">&#x27;count&#x27;</span>: <span class="number">0</span>, <span class="string">&#x27;values&#x27;</span>: []&#125;</span><br><span class="line"></span><br><span class="line"></span><br><span class="line"><span class="keyword">def</span> <span class="title function_">read_demo</span>(<span class="params">reader_id, sleep_time=<span class="number">0.5</span></span>):</span><br><span class="line">    <span class="string">&quot;&quot;&quot;用于演示的读取函数&quot;&quot;&quot;</span></span><br><span class="line">    read_lock = rw_lock.gen_rlock()</span><br><span class="line">    </span><br><span class="line">    <span class="keyword">try</span>:</span><br><span class="line">        <span class="keyword">with</span> read_lock:</span><br><span class="line">            <span class="built_in">print</span>(<span class="string">f&quot;读取者 <span class="subst">&#123;reader_id&#125;</span>: 获得读锁&quot;</span>)</span><br><span class="line">            time.sleep(sleep_time)  <span class="comment"># 模拟读取操作</span></span><br><span class="line">            <span class="built_in">print</span>(<span class="string">f&quot;读取者 <span class="subst">&#123;reader_id&#125;</span>: 完成读取&quot;</span>)</span><br><span class="line">    <span class="keyword">finally</span>:</span><br><span class="line">        <span class="built_in">print</span>(<span class="string">f&quot;读取者 <span class="subst">&#123;reader_id&#125;</span>: 释放读锁&quot;</span>)</span><br><span class="line"></span><br><span class="line"></span><br><span class="line"><span class="keyword">def</span> <span class="title function_">write_demo</span>(<span class="params">writer_id, sleep_time=<span class="number">0.5</span></span>):</span><br><span class="line">    <span class="string">&quot;&quot;&quot;用于演示的写入函数&quot;&quot;&quot;</span></span><br><span class="line">    write_lock = rw_lock.gen_wlock()</span><br><span class="line">    </span><br><span class="line">    <span class="keyword">try</span>:</span><br><span class="line">        <span class="keyword">with</span> write_lock:</span><br><span class="line">            <span class="built_in">print</span>(<span class="string">f&quot;写入者 <span class="subst">&#123;writer_id&#125;</span>: 获得写锁&quot;</span>)</span><br><span class="line">            time.sleep(sleep_time)  <span class="comment"># 模拟写入操作</span></span><br><span class="line">            <span class="built_in">print</span>(<span class="string">f&quot;写入者 <span class="subst">&#123;writer_id&#125;</span>: 完成写入&quot;</span>)</span><br><span class="line">    <span class="keyword">finally</span>:</span><br><span class="line">        <span class="built_in">print</span>(<span class="string">f&quot;写入者 <span class="subst">&#123;writer_id&#125;</span>: 释放写锁&quot;</span>)</span><br><span class="line"></span><br><span class="line"></span><br><span class="line"><span class="keyword">def</span> <span class="title function_">demonstrate_read_read_nonblocking</span>():</span><br><span class="line">    <span class="string">&quot;&quot;&quot;演示读读不互斥&quot;&quot;&quot;</span></span><br><span class="line">    <span class="built_in">print</span>(<span class="string">&quot;\n=== 演示：读读不互斥 ===&quot;</span>)</span><br><span class="line">    </span><br><span class="line">    threads = []</span><br><span class="line">    <span class="keyword">for</span> i <span class="keyword">in</span> <span class="built_in">range</span>(<span class="number">5</span>):</span><br><span class="line">        thread = threading.Thread(target=read_demo, args=(i, <span class="number">0.5</span>))</span><br><span class="line">        threads.append(thread)</span><br><span class="line">        thread.start()</span><br><span class="line"></span><br><span class="line">    <span class="keyword">for</span> thread <span class="keyword">in</span> threads:</span><br><span class="line">        thread.join()</span><br><span class="line"></span><br><span class="line"></span><br><span class="line"><span class="keyword">def</span> <span class="title function_">demonstrate_read_write_blocking</span>():</span><br><span class="line">    <span class="string">&quot;&quot;&quot;演示读写互斥&quot;&quot;&quot;</span></span><br><span class="line">    <span class="built_in">print</span>(<span class="string">&quot;\n=== 演示：读写互斥 ===&quot;</span>)</span><br><span class="line">    </span><br><span class="line">    <span class="comment"># 先启动一个长时间的读取线程</span></span><br><span class="line">    read_thread = threading.Thread(target=read_demo, args=(<span class="number">0</span>, <span class="number">2</span>))</span><br><span class="line">    read_thread.start()</span><br><span class="line"></span><br><span class="line">    <span class="comment"># 给读取线程一点时间获取锁</span></span><br><span class="line">    time.sleep(<span class="number">0.1</span>)</span><br><span class="line"></span><br><span class="line">    <span class="comment"># 尝试启动写入线程，应该被阻塞直到读取完成</span></span><br><span class="line">    write_thread = threading.Thread(target=write_demo, args=(<span class="number">0</span>, <span class="number">0.5</span>))</span><br><span class="line">    write_thread.start()</span><br><span class="line"></span><br><span class="line">    read_thread.join()</span><br><span class="line">    write_thread.join()</span><br><span class="line"></span><br><span class="line"></span><br><span class="line"><span class="keyword">def</span> <span class="title function_">demonstrate_write_write_blocking</span>():</span><br><span class="line">    <span class="string">&quot;&quot;&quot;演示写写互斥&quot;&quot;&quot;</span></span><br><span class="line">    <span class="built_in">print</span>(<span class="string">&quot;\n=== 演示：写写互斥 ===&quot;</span>)</span><br><span class="line">    </span><br><span class="line">    <span class="comment"># 先启动一个长时间的写入线程</span></span><br><span class="line">    write_thread1 = threading.Thread(target=write_demo, args=(<span class="number">0</span>, <span class="number">2</span>))</span><br><span class="line">    write_thread1.start()</span><br><span class="line"></span><br><span class="line">    <span class="comment"># 给第一个写入线程一点时间获取锁</span></span><br><span class="line">    time.sleep(<span class="number">0.1</span>)</span><br><span class="line"></span><br><span class="line">    <span class="comment"># 尝试启动另一个写入线程，应该被阻塞直到第一个写入完成</span></span><br><span class="line">    write_thread2 = threading.Thread(target=write_demo, args=(<span class="number">1</span>, <span class="number">0.5</span>))</span><br><span class="line">    write_thread2.start()</span><br><span class="line"></span><br><span class="line">    write_thread1.join()</span><br><span class="line">    write_thread2.join()</span><br><span class="line"></span><br><span class="line"></span><br><span class="line"><span class="keyword">def</span> <span class="title function_">demonstrate_timeout</span>():</span><br><span class="line">    <span class="string">&quot;&quot;&quot;演示锁获取超时&quot;&quot;&quot;</span></span><br><span class="line">    <span class="built_in">print</span>(<span class="string">&quot;\n=== 演示：锁获取超时 ===&quot;</span>)</span><br><span class="line">    </span><br><span class="line">    <span class="comment"># 先启动一个长时间的写入线程</span></span><br><span class="line">    write_thread = threading.Thread(target=write_demo, args=(<span class="number">0</span>, <span class="number">3</span>))</span><br><span class="line">    write_thread.start()</span><br><span class="line"></span><br><span class="line">    <span class="comment"># 给写入线程一点时间获取锁</span></span><br><span class="line">    time.sleep(<span class="number">0.1</span>)</span><br><span class="line"></span><br><span class="line">    <span class="comment"># 尝试获取读锁，但设置较短的超时时间</span></span><br><span class="line">    read_lock = rw_lock.gen_rlock()</span><br><span class="line">    <span class="keyword">if</span> read_lock.acquire(blocking=<span class="literal">True</span>, timeout=<span class="number">0.5</span>):</span><br><span class="line">        <span class="keyword">try</span>:</span><br><span class="line">            <span class="built_in">print</span>(<span class="string">&quot;读取者: 成功获得读锁（不应该发生）&quot;</span>)</span><br><span class="line">        <span class="keyword">finally</span>:</span><br><span class="line">            read_lock.release()</span><br><span class="line">    <span class="keyword">else</span>:</span><br><span class="line">        <span class="built_in">print</span>(<span class="string">&quot;读取者: 获取读锁超时（预期行为）&quot;</span>)</span><br><span class="line"></span><br><span class="line">    write_thread.join()</span><br><span class="line"></span><br><span class="line"></span><br><span class="line"><span class="keyword">if</span> __name__ == <span class="string">&#x27;__main__&#x27;</span>:</span><br><span class="line">    <span class="comment"># 演示读读不互斥</span></span><br><span class="line">    demonstrate_read_read_nonblocking()</span><br><span class="line">    <span class="comment"># 演示读写互斥</span></span><br><span class="line">    demonstrate_read_write_blocking()</span><br><span class="line">    <span class="comment"># 演示写写互斥</span></span><br><span class="line">    demonstrate_write_write_blocking()</span><br><span class="line">    <span class="comment"># 演示锁获取超时</span></span><br><span class="line">    demonstrate_timeout()</span><br></pre></td></tr></table></figure><table><thead><tr><th>读写锁特性</th><th>描述</th><th>优势</th><th>适用场景</th></tr></thead><tbody><tr><td>读共享/写独占</td><td>多个读取可并发，写入需独占</td><td>提高读多写少场景的并发性</td><td>配置数据、缓存系统、数据集</td></tr><tr><td>读写优先级</td><td>可以设置读优先或写优先</td><td>根据应用需求调整性能特性</td><td>根据读写比例调整策略</td></tr><tr><td>升级/降级</td><td>支持锁的升级(读 → 写)或降级(写 → 读)</td><td>灵活处理复杂访问模式</td><td>先检查后修改的操作</td></tr></tbody></table><blockquote><p>💡 <strong>使用建议</strong>：</p><ul><li>读多写少的场景推荐使用读写锁</li><li>注意防止 “写饥饿”，即读取者太多导致写入者长时间等待</li></ul></blockquote><h6 id="锁排序（解决死锁）">锁排序（解决死锁）</h6><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><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></pre></td><td class="code"><pre><span class="line"><span class="keyword">import</span> threading</span><br><span class="line"><span class="keyword">import</span> time</span><br><span class="line"></span><br><span class="line"></span><br><span class="line"><span class="keyword">class</span> <span class="title class_">Account</span>:</span><br><span class="line">    <span class="string">&quot;&quot;&quot;模拟银行账户&quot;&quot;&quot;</span></span><br><span class="line"></span><br><span class="line">    <span class="keyword">def</span> <span class="title function_">__init__</span>(<span class="params">self, name: <span class="built_in">str</span>, balance: <span class="built_in">int</span> = <span class="number">0</span></span>):</span><br><span class="line">        <span class="string">&quot;&quot;&quot;初始化账户&quot;&quot;&quot;</span></span><br><span class="line">        <span class="variable language_">self</span>.name = name</span><br><span class="line">        <span class="variable language_">self</span>.balance = balance</span><br><span class="line">        <span class="variable language_">self</span>.lock = threading.RLock()</span><br><span class="line">        <span class="comment"># 用于账户排序的唯一ID</span></span><br><span class="line">        <span class="variable language_">self</span>.<span class="built_in">id</span> = <span class="built_in">id</span>(<span class="variable language_">self</span>)</span><br><span class="line"></span><br><span class="line">    <span class="keyword">def</span> <span class="title function_">__str__</span>(<span class="params">self</span>):</span><br><span class="line">        <span class="keyword">return</span> <span class="string">f&quot;账户<span class="subst">&#123;self.name&#125;</span>[余额=<span class="subst">&#123;self.balance&#125;</span>]&quot;</span></span><br><span class="line"></span><br><span class="line"></span><br><span class="line"><span class="keyword">def</span> <span class="title function_">transfer_money</span>(<span class="params">from_account: Account, to_account: Account, amount: <span class="built_in">int</span>, thread_name: <span class="built_in">str</span></span>) -&gt; <span class="literal">None</span>:</span><br><span class="line">    <span class="string">&quot;&quot;&quot;</span></span><br><span class="line"><span class="string">    在账户间转账，使用账户ID排序策略避免死锁</span></span><br><span class="line"><span class="string">    from_account: 转出账户</span></span><br><span class="line"><span class="string">    to_account: 转入账户</span></span><br><span class="line"><span class="string">    amount: 转账金额</span></span><br><span class="line"><span class="string">    thread_name: 线程名称</span></span><br><span class="line"><span class="string">    &quot;&quot;&quot;</span></span><br><span class="line">    <span class="comment"># 按照账户ID从小到大的顺序获取锁，确保所有线程获取锁的顺序一致</span></span><br><span class="line">    first, second = <span class="built_in">sorted</span>([from_account, to_account], key=<span class="keyword">lambda</span> x: x.<span class="built_in">id</span>)</span><br><span class="line">    </span><br><span class="line">    <span class="built_in">print</span>(<span class="string">f&quot;<span class="subst">&#123;thread_name&#125;</span>: 尝试锁定账户 <span class="subst">&#123;first.name&#125;</span>&quot;</span>)</span><br><span class="line">    <span class="keyword">with</span> first.lock:</span><br><span class="line">        <span class="built_in">print</span>(<span class="string">f&quot;<span class="subst">&#123;thread_name&#125;</span>: 已锁定账户 <span class="subst">&#123;first.name&#125;</span>&quot;</span>)</span><br><span class="line">        <span class="comment"># 模拟网络延迟</span></span><br><span class="line">        time.sleep(<span class="number">0.1</span>)</span><br><span class="line">        </span><br><span class="line">        <span class="built_in">print</span>(<span class="string">f&quot;<span class="subst">&#123;thread_name&#125;</span>: 尝试锁定账户 <span class="subst">&#123;second.name&#125;</span>&quot;</span>)</span><br><span class="line">        <span class="keyword">with</span> second.lock:</span><br><span class="line">            <span class="built_in">print</span>(<span class="string">f&quot;<span class="subst">&#123;thread_name&#125;</span>: 已锁定账户 <span class="subst">&#123;second.name&#125;</span>&quot;</span>)</span><br><span class="line">            </span><br><span class="line">            <span class="comment"># 执行转账操作</span></span><br><span class="line">            from_account.balance -= amount</span><br><span class="line">            to_account.balance += amount</span><br><span class="line">            </span><br><span class="line">            <span class="built_in">print</span>(<span class="string">f&quot;<span class="subst">&#123;thread_name&#125;</span>: 已从<span class="subst">&#123;from_account.name&#125;</span>转账<span class="subst">&#123;amount&#125;</span>元到<span class="subst">&#123;to_account.name&#125;</span>&quot;</span>)</span><br><span class="line"></span><br><span class="line"></span><br><span class="line"><span class="keyword">if</span> __name__ == <span class="string">&#x27;__main__&#x27;</span>:</span><br><span class="line">    <span class="comment"># 创建两个账户</span></span><br><span class="line">    alice = Account(<span class="string">&quot;Alice&quot;</span>, <span class="number">1000</span>)</span><br><span class="line">    bob = Account(<span class="string">&quot;Bob&quot;</span>, <span class="number">1000</span>)</span><br><span class="line">    </span><br><span class="line">    <span class="built_in">print</span>(<span class="string">f&quot;初始状态: <span class="subst">&#123;alice&#125;</span>, <span class="subst">&#123;bob&#125;</span>&quot;</span>)</span><br><span class="line">    </span><br><span class="line">    <span class="comment"># 创建两个线程，同时进行相反方向的转账</span></span><br><span class="line">    t1 = threading.Thread(</span><br><span class="line">        name=<span class="string">&quot;Thread-1&quot;</span>,</span><br><span class="line">        target=transfer_money,</span><br><span class="line">        args=(alice, bob, <span class="number">500</span>, <span class="string">&quot;转账线程1&quot;</span>)</span><br><span class="line">    )</span><br><span class="line">    </span><br><span class="line">    t2 = threading.Thread(</span><br><span class="line">        name=<span class="string">&quot;Thread-2&quot;</span>,</span><br><span class="line">        target=transfer_money,</span><br><span class="line">        args=(bob, alice, <span class="number">300</span>, <span class="string">&quot;转账线程2&quot;</span>)</span><br><span class="line">    )</span><br><span class="line">    </span><br><span class="line">    <span class="comment"># 启动线程</span></span><br><span class="line">    t1.start()</span><br><span class="line">    t2.start()</span><br><span class="line">    </span><br><span class="line">    <span class="comment"># 等待线程结束</span></span><br><span class="line">    t1.join()</span><br><span class="line">    t2.join()</span><br><span class="line">    </span><br><span class="line">    <span class="built_in">print</span>(<span class="string">f&quot;最终状态: <span class="subst">&#123;alice&#125;</span>, <span class="subst">&#123;bob&#125;</span>&quot;</span>)</span><br></pre></td></tr></table></figure><h6 id="两阶段锁定">两阶段锁定</h6><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><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></pre></td><td class="code"><pre><span class="line"><span class="keyword">import</span> threading</span><br><span class="line"><span class="keyword">import</span> time</span><br><span class="line"></span><br><span class="line"><span class="keyword">class</span> <span class="title class_">TwoPhaseLockDatabase</span>:</span><br><span class="line">    <span class="string">&quot;&quot;&quot;演示两阶段锁定协议的简单数据库&quot;&quot;&quot;</span></span><br><span class="line"></span><br><span class="line">    <span class="keyword">def</span> <span class="title function_">__init__</span>(<span class="params">self</span>):</span><br><span class="line">        <span class="string">&quot;&quot;&quot;初始化数据库&quot;&quot;&quot;</span></span><br><span class="line">        <span class="variable language_">self</span>.data = &#123;<span class="string">&#x27;A&#x27;</span>: <span class="number">100</span>, <span class="string">&#x27;B&#x27;</span>: <span class="number">200</span>&#125;  <span class="comment"># 简单的数据项</span></span><br><span class="line">        <span class="variable language_">self</span>.locks = &#123;<span class="string">&#x27;A&#x27;</span>: threading.RLock(), <span class="string">&#x27;B&#x27;</span>: threading.RLock()&#125;  <span class="comment"># 每个数据项的锁</span></span><br><span class="line"></span><br><span class="line">    <span class="keyword">def</span> <span class="title function_">transaction</span>(<span class="params">self, items_to_read, items_to_write, operation</span>):</span><br><span class="line">        <span class="string">&quot;&quot;&quot;执行两阶段锁定事务</span></span><br><span class="line"><span class="string">        </span></span><br><span class="line"><span class="string">        Args:</span></span><br><span class="line"><span class="string">            items_to_read: 需要读取的数据项列表</span></span><br><span class="line"><span class="string">            items_to_write: 需要写入的数据项列表</span></span><br><span class="line"><span class="string">            operation: 事务操作函数</span></span><br><span class="line"><span class="string">        </span></span><br><span class="line"><span class="string">        Returns:</span></span><br><span class="line"><span class="string">            bool: 事务是否成功</span></span><br><span class="line"><span class="string">        &quot;&quot;&quot;</span></span><br><span class="line">        <span class="comment"># 按字母顺序排序所有需要锁定的项，避免死锁</span></span><br><span class="line">        all_items = <span class="built_in">sorted</span>(<span class="built_in">set</span>(items_to_read + items_to_write))</span><br><span class="line">        acquired_locks = []</span><br><span class="line">        </span><br><span class="line">        <span class="keyword">try</span>:</span><br><span class="line">            <span class="comment"># 阶段1: 获取锁阶段（增长阶段）</span></span><br><span class="line">            <span class="built_in">print</span>(<span class="string">f&quot;事务 <span class="subst">&#123;threading.current_thread().name&#125;</span>: 开始获取锁&quot;</span>)</span><br><span class="line">            <span class="keyword">for</span> item <span class="keyword">in</span> all_items:</span><br><span class="line">                <span class="comment"># 对于读取项获取共享锁，对于写入项获取排他锁</span></span><br><span class="line">                <span class="comment"># 这里简化为都使用排他锁</span></span><br><span class="line">                <span class="keyword">if</span> <span class="variable language_">self</span>.locks[item].acquire(timeout=<span class="number">1</span>):</span><br><span class="line">                    acquired_locks.append(item)</span><br><span class="line">                    <span class="built_in">print</span>(<span class="string">f&quot;事务 <span class="subst">&#123;threading.current_thread().name&#125;</span>: 已锁定 <span class="subst">&#123;item&#125;</span>&quot;</span>)</span><br><span class="line">                <span class="keyword">else</span>:</span><br><span class="line">                    <span class="keyword">raise</span> TimeoutError(<span class="string">f&quot;获取 <span class="subst">&#123;item&#125;</span> 的锁超时&quot;</span>)</span><br><span class="line">            </span><br><span class="line">            <span class="comment"># 执行事务操作</span></span><br><span class="line">            result = operation(<span class="variable language_">self</span>.data)</span><br><span class="line">            <span class="built_in">print</span>(<span class="string">f&quot;事务 <span class="subst">&#123;threading.current_thread().name&#125;</span>: 操作完成&quot;</span>)</span><br><span class="line">            <span class="keyword">return</span> result</span><br><span class="line">            </span><br><span class="line">        <span class="keyword">except</span> Exception <span class="keyword">as</span> e:</span><br><span class="line">            <span class="built_in">print</span>(<span class="string">f&quot;事务 <span class="subst">&#123;threading.current_thread().name&#125;</span>: 错误 - <span class="subst">&#123;e&#125;</span>&quot;</span>)</span><br><span class="line">            <span class="keyword">return</span> <span class="literal">False</span></span><br><span class="line">            </span><br><span class="line">        <span class="keyword">finally</span>:</span><br><span class="line">            <span class="comment"># 阶段2: 释放锁阶段（收缩阶段）</span></span><br><span class="line">            <span class="keyword">for</span> item <span class="keyword">in</span> acquired_locks:</span><br><span class="line">                <span class="variable language_">self</span>.locks[item].release()</span><br><span class="line">                <span class="built_in">print</span>(<span class="string">f&quot;事务 <span class="subst">&#123;threading.current_thread().name&#125;</span>: 已释放 <span class="subst">&#123;item&#125;</span>&quot;</span>)</span><br><span class="line"></span><br><span class="line"><span class="keyword">def</span> <span class="title function_">transfer_money</span>(<span class="params">db, from_account, to_account, amount</span>):</span><br><span class="line">    <span class="string">&quot;&quot;&quot;转账事务&quot;&quot;&quot;</span></span><br><span class="line">    <span class="keyword">def</span> <span class="title function_">operation</span>(<span class="params">data</span>):</span><br><span class="line">        <span class="keyword">if</span> data[from_account] &lt; amount:</span><br><span class="line">            <span class="built_in">print</span>(<span class="string">f&quot;账户 <span class="subst">&#123;from_account&#125;</span> 余额不足&quot;</span>)</span><br><span class="line">            <span class="keyword">return</span> <span class="literal">False</span></span><br><span class="line">        </span><br><span class="line">        <span class="comment"># 模拟操作耗时</span></span><br><span class="line">        time.sleep(<span class="number">0.1</span>)</span><br><span class="line">        </span><br><span class="line">        <span class="comment"># 执行转账</span></span><br><span class="line">        data[from_account] -= amount</span><br><span class="line">        data[to_account] += amount</span><br><span class="line">        <span class="built_in">print</span>(<span class="string">f&quot;已从 <span class="subst">&#123;from_account&#125;</span> 转账 <span class="subst">&#123;amount&#125;</span> 到 <span class="subst">&#123;to_account&#125;</span>&quot;</span>)</span><br><span class="line">        <span class="keyword">return</span> <span class="literal">True</span></span><br><span class="line">    </span><br><span class="line">    <span class="keyword">return</span> db.transaction([from_account, to_account], [from_account, to_account], operation)</span><br><span class="line"></span><br><span class="line"><span class="keyword">def</span> <span class="title function_">run_transaction</span>(<span class="params">db, thread_id, from_acc, to_acc, amount</span>):</span><br><span class="line">    <span class="string">&quot;&quot;&quot;执行事务的线程函数&quot;&quot;&quot;</span></span><br><span class="line">    <span class="built_in">print</span>(<span class="string">f&quot;线程 <span class="subst">&#123;thread_id&#125;</span>: 尝试转账 <span class="subst">&#123;amount&#125;</span> 从 <span class="subst">&#123;from_acc&#125;</span> 到 <span class="subst">&#123;to_acc&#125;</span>&quot;</span>)</span><br><span class="line">    </span><br><span class="line">    start_time = time.time()</span><br><span class="line">    success = transfer_money(db, from_acc, to_acc, amount)</span><br><span class="line">    elapsed = time.time() - start_time</span><br><span class="line">    </span><br><span class="line">    status = <span class="string">&quot;成功&quot;</span> <span class="keyword">if</span> success <span class="keyword">else</span> <span class="string">&quot;失败&quot;</span></span><br><span class="line">    <span class="built_in">print</span>(<span class="string">f&quot;线程 <span class="subst">&#123;thread_id&#125;</span>: 转账<span class="subst">&#123;status&#125;</span>，耗时 <span class="subst">&#123;elapsed:<span class="number">.2</span>f&#125;</span>秒&quot;</span>)</span><br><span class="line"></span><br><span class="line"><span class="comment"># 创建数据库实例</span></span><br><span class="line">db = TwoPhaseLockDatabase()</span><br><span class="line"><span class="built_in">print</span>(<span class="string">f&quot;初始账户状态: <span class="subst">&#123;db.data&#125;</span>&quot;</span>)</span><br><span class="line"></span><br><span class="line"><span class="comment"># 创建并启动多个线程</span></span><br><span class="line">threads = []</span><br><span class="line">transactions = [</span><br><span class="line">    (<span class="string">&quot;A&quot;</span>, <span class="string">&quot;B&quot;</span>, <span class="number">30</span>),  <span class="comment"># 从A转30到B</span></span><br><span class="line">    (<span class="string">&quot;B&quot;</span>, <span class="string">&quot;A&quot;</span>, <span class="number">50</span>),  <span class="comment"># 从B转50到A</span></span><br><span class="line">    (<span class="string">&quot;A&quot;</span>, <span class="string">&quot;B&quot;</span>, <span class="number">20</span>)   <span class="comment"># 从A转20到B</span></span><br><span class="line">]</span><br><span class="line"></span><br><span class="line"><span class="keyword">for</span> i, (from_acc, to_acc, amount) <span class="keyword">in</span> <span class="built_in">enumerate</span>(transactions):</span><br><span class="line">    t = threading.Thread(</span><br><span class="line">        name=<span class="string">f&quot;Transaction-<span class="subst">&#123;i&#125;</span>&quot;</span>,</span><br><span class="line">        target=run_transaction,</span><br><span class="line">        args=(db, i, from_acc, to_acc, amount)</span><br><span class="line">    )</span><br><span class="line">    threads.append(t)</span><br><span class="line">    t.start()</span><br><span class="line"></span><br><span class="line"><span class="comment"># 等待所有线程完成</span></span><br><span class="line"><span class="keyword">for</span> t <span class="keyword">in</span> threads:</span><br><span class="line">    t.join()</span><br><span class="line"></span><br><span class="line"><span class="built_in">print</span>(<span class="string">f&quot;最终账户状态: <span class="subst">&#123;db.data&#125;</span>&quot;</span>)</span><br><span class="line"><span class="built_in">print</span>(<span class="string">f&quot;总金额: <span class="subst">&#123;<span class="built_in">sum</span>(db.data.values())&#125;</span>&quot;</span>)  <span class="comment"># 总金额应该不变</span></span><br></pre></td></tr></table></figure><h6 id="超时重试模式">超时重试模式</h6><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><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></pre></td><td class="code"><pre><span class="line"><span class="keyword">import</span> threading</span><br><span class="line"><span class="keyword">import</span> time</span><br><span class="line"><span class="keyword">import</span> random</span><br><span class="line"></span><br><span class="line"><span class="comment">### 共享资源</span></span><br><span class="line">resource_value = <span class="number">0</span></span><br><span class="line">resource_lock = threading.Lock()</span><br><span class="line"></span><br><span class="line"><span class="keyword">def</span> <span class="title function_">update_resource</span>(<span class="params">worker_id, max_retries=<span class="number">3</span></span>):</span><br><span class="line">    <span class="string">&quot;&quot;&quot;更新共享资源，使用超时重试模式</span></span><br><span class="line"><span class="string">    </span></span><br><span class="line"><span class="string">    Args:</span></span><br><span class="line"><span class="string">        worker_id: 工作线程ID</span></span><br><span class="line"><span class="string">        max_retries: 最大重试次数</span></span><br><span class="line"><span class="string">    </span></span><br><span class="line"><span class="string">    Returns:</span></span><br><span class="line"><span class="string">        bool: 是否成功更新</span></span><br><span class="line"><span class="string">    &quot;&quot;&quot;</span></span><br><span class="line">    <span class="keyword">global</span> resource_value</span><br><span class="line">    </span><br><span class="line">    <span class="comment"># 随机生成操作标识，用于跟踪</span></span><br><span class="line">    operation_id = random.randint(<span class="number">10000</span>, <span class="number">99999</span>)</span><br><span class="line">    <span class="built_in">print</span>(<span class="string">f&quot;工作线程 <span class="subst">&#123;worker_id&#125;</span> [操作:<span class="subst">&#123;operation_id&#125;</span>]: 尝试更新资源&quot;</span>)</span><br><span class="line">    </span><br><span class="line">    retry_count = <span class="number">0</span></span><br><span class="line">    backoff = <span class="number">0.1</span>  <span class="comment"># 初始回退时间</span></span><br><span class="line">    </span><br><span class="line">    <span class="keyword">while</span> retry_count &lt; max_retries:</span><br><span class="line">        <span class="comment"># 尝试获取锁，设置超时时间</span></span><br><span class="line">        <span class="keyword">if</span> resource_lock.acquire(timeout=<span class="number">0.5</span>):</span><br><span class="line">            <span class="keyword">try</span>:</span><br><span class="line">                <span class="built_in">print</span>(<span class="string">f&quot;工作线程 <span class="subst">&#123;worker_id&#125;</span> [操作:<span class="subst">&#123;operation_id&#125;</span>]: 获取到锁，当前值: <span class="subst">&#123;resource_value&#125;</span>&quot;</span>)</span><br><span class="line">                </span><br><span class="line">                <span class="comment"># 模拟资源更新操作</span></span><br><span class="line">                local_value = resource_value</span><br><span class="line">                <span class="comment"># 随机决定操作时间，有时可能很长</span></span><br><span class="line">                work_time = random.uniform(<span class="number">0.1</span>, <span class="number">1.0</span>)</span><br><span class="line">                time.sleep(work_time)</span><br><span class="line">                </span><br><span class="line">                <span class="comment"># 更新资源</span></span><br><span class="line">                resource_value = local_value + <span class="number">1</span></span><br><span class="line">                </span><br><span class="line">                <span class="built_in">print</span>(<span class="string">f&quot;工作线程 <span class="subst">&#123;worker_id&#125;</span> [操作:<span class="subst">&#123;operation_id&#125;</span>]: 更新成功，新值: <span class="subst">&#123;resource_value&#125;</span>，耗时: <span class="subst">&#123;work_time:<span class="number">.2</span>f&#125;</span>秒&quot;</span>)</span><br><span class="line">                <span class="keyword">return</span> <span class="literal">True</span></span><br><span class="line">                </span><br><span class="line">            <span class="keyword">finally</span>:</span><br><span class="line">                <span class="comment"># 释放锁</span></span><br><span class="line">                resource_lock.release()</span><br><span class="line">                <span class="built_in">print</span>(<span class="string">f&quot;工作线程 <span class="subst">&#123;worker_id&#125;</span> [操作:<span class="subst">&#123;operation_id&#125;</span>]: 释放锁&quot;</span>)</span><br><span class="line">        <span class="keyword">else</span>:</span><br><span class="line">            retry_count += <span class="number">1</span></span><br><span class="line">            <span class="built_in">print</span>(<span class="string">f&quot;工作线程 <span class="subst">&#123;worker_id&#125;</span> [操作:<span class="subst">&#123;operation_id&#125;</span>]: 获取锁超时，重试 <span class="subst">&#123;retry_count&#125;</span>/<span class="subst">&#123;max_retries&#125;</span>&quot;</span>)</span><br><span class="line">            </span><br><span class="line">            <span class="keyword">if</span> retry_count &lt; max_retries:</span><br><span class="line">                <span class="comment"># 使用指数退避策略，每次重试间隔加长</span></span><br><span class="line">                time.sleep(backoff)</span><br><span class="line">                backoff *= <span class="number">2</span>  <span class="comment"># 指数增长</span></span><br><span class="line">    </span><br><span class="line">    <span class="built_in">print</span>(<span class="string">f&quot;工作线程 <span class="subst">&#123;worker_id&#125;</span> [操作:<span class="subst">&#123;operation_id&#125;</span>]: 达到最大重试次数，放弃操作&quot;</span>)</span><br><span class="line">    <span class="keyword">return</span> <span class="literal">False</span></span><br><span class="line"></span><br><span class="line"><span class="keyword">def</span> <span class="title function_">worker_thread</span>(<span class="params">worker_id, operations</span>):</span><br><span class="line">    <span class="string">&quot;&quot;&quot;工作线程函数</span></span><br><span class="line"><span class="string">    </span></span><br><span class="line"><span class="string">    Args:</span></span><br><span class="line"><span class="string">        worker_id: 工作线程ID</span></span><br><span class="line"><span class="string">        operations: 要执行的操作次数</span></span><br><span class="line"><span class="string">    &quot;&quot;&quot;</span></span><br><span class="line">    success_count = <span class="number">0</span></span><br><span class="line">    </span><br><span class="line">    <span class="keyword">for</span> i <span class="keyword">in</span> <span class="built_in">range</span>(operations):</span><br><span class="line">        <span class="comment"># 尝试更新资源</span></span><br><span class="line">        <span class="keyword">if</span> update_resource(worker_id):</span><br><span class="line">            success_count += <span class="number">1</span></span><br><span class="line">        </span><br><span class="line">        <span class="comment"># 线程之间的间隔</span></span><br><span class="line">        time.sleep(random.uniform(<span class="number">0.1</span>, <span class="number">0.5</span>))</span><br><span class="line">    </span><br><span class="line">    <span class="built_in">print</span>(<span class="string">f&quot;工作线程 <span class="subst">&#123;worker_id&#125;</span>: 完成 <span class="subst">&#123;success_count&#125;</span>/<span class="subst">&#123;operations&#125;</span> 次成功更新&quot;</span>)</span><br><span class="line"></span><br><span class="line"><span class="comment">### 创建多个工作线程</span></span><br><span class="line">threads = []</span><br><span class="line"><span class="keyword">for</span> i <span class="keyword">in</span> <span class="built_in">range</span>(<span class="number">5</span>):</span><br><span class="line">    t = threading.Thread(target=worker_thread, args=(i, <span class="number">3</span>))</span><br><span class="line">    threads.append(t)</span><br><span class="line">    t.start()</span><br><span class="line"></span><br><span class="line"><span class="comment">### 等待所有线程完成</span></span><br><span class="line"><span class="keyword">for</span> t <span class="keyword">in</span> threads:</span><br><span class="line">    t.join()</span><br><span class="line"></span><br><span class="line"><span class="built_in">print</span>(<span class="string">f&quot;最终资源值: <span class="subst">&#123;resource_value&#125;</span>&quot;</span>)</span><br></pre></td></tr></table></figure><h5 id="多进程与多线程结合的混合模型">多进程与多线程结合的混合模型</h5><p>对于复杂应用，常常需要结合多进程和多线程的优势：多进程跨越 GIL 限制利用多核心，每个进程内使用多线程处理 I/O 任务。</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></pre></td><td class="code"><pre><span class="line"><span class="keyword">import</span> os</span><br><span class="line"><span class="keyword">import</span> time</span><br><span class="line"><span class="keyword">import</span> multiprocessing</span><br><span class="line"><span class="keyword">import</span> threading</span><br><span class="line"><span class="keyword">import</span> concurrent.futures</span><br><span class="line"><span class="keyword">import</span> requests</span><br><span class="line"><span class="keyword">from</span> io <span class="keyword">import</span> StringIO</span><br><span class="line"><span class="keyword">import</span> csv</span><br><span class="line"></span><br><span class="line"><span class="keyword">def</span> <span class="title function_">io_task</span>(<span class="params">url</span>):</span><br><span class="line">    <span class="string">&quot;&quot;&quot;模拟I/O密集型任务：发送HTTP请求并处理响应&quot;&quot;&quot;</span></span><br><span class="line">    <span class="keyword">try</span>:</span><br><span class="line">        response = requests.get(url, timeout=<span class="number">5</span>)</span><br><span class="line">        <span class="built_in">print</span>(<span class="string">f&quot;线程 <span class="subst">&#123;threading.current_thread().name&#125;</span> 完成请求 <span class="subst">&#123;url&#125;</span>, 状态码: <span class="subst">&#123;response.status_code&#125;</span>&quot;</span>)</span><br><span class="line">        <span class="keyword">return</span> response.status_code</span><br><span class="line">    <span class="keyword">except</span> Exception <span class="keyword">as</span> e:</span><br><span class="line">        <span class="built_in">print</span>(<span class="string">f&quot;线程 <span class="subst">&#123;threading.current_thread().name&#125;</span> 请求 <span class="subst">&#123;url&#125;</span> 失败: <span class="subst">&#123;<span class="built_in">str</span>(e)&#125;</span>&quot;</span>)</span><br><span class="line">        <span class="keyword">return</span> <span class="literal">None</span></span><br><span class="line"></span><br><span class="line"><span class="keyword">def</span> <span class="title function_">cpu_task</span>(<span class="params">data</span>):</span><br><span class="line">    <span class="string">&quot;&quot;&quot;模拟CPU密集型任务：处理CSV数据&quot;&quot;&quot;</span></span><br><span class="line">    result = <span class="number">0</span></span><br><span class="line">    <span class="comment"># 模拟CPU密集型计算</span></span><br><span class="line">    <span class="keyword">for</span> _ <span class="keyword">in</span> <span class="built_in">range</span>(<span class="number">1000000</span>):</span><br><span class="line">        result += <span class="number">1</span></span><br><span class="line">    </span><br><span class="line">    <span class="comment"># 解析CSV数据</span></span><br><span class="line">    csv_data = StringIO(data)</span><br><span class="line">    reader = csv.reader(csv_data)</span><br><span class="line">    rows = <span class="built_in">list</span>(reader)</span><br><span class="line">    </span><br><span class="line">    <span class="built_in">print</span>(<span class="string">f&quot;进程 <span class="subst">&#123;os.getpid()&#125;</span> 处理了 <span class="subst">&#123;<span class="built_in">len</span>(rows)&#125;</span> 行数据&quot;</span>)</span><br><span class="line">    <span class="keyword">return</span> <span class="built_in">len</span>(rows)</span><br><span class="line"></span><br><span class="line"><span class="keyword">def</span> <span class="title function_">process_worker</span>(<span class="params">process_id, urls</span>):</span><br><span class="line">    <span class="string">&quot;&quot;&quot;每个进程的工作函数，使用线程池处理I/O任务&quot;&quot;&quot;</span></span><br><span class="line">    <span class="built_in">print</span>(<span class="string">f&quot;进程 <span class="subst">&#123;os.getpid()&#125;</span> (ID: <span class="subst">&#123;process_id&#125;</span>) 启动&quot;</span>)</span><br><span class="line">    </span><br><span class="line">    <span class="comment"># 创建线程池处理I/O任务</span></span><br><span class="line">    <span class="keyword">with</span> concurrent.futures.ThreadPoolExecutor(max_workers=<span class="number">4</span>) <span class="keyword">as</span> executor:</span><br><span class="line">        <span class="comment"># 提交所有URL请求任务到线程池</span></span><br><span class="line">        future_to_url = &#123;executor.submit(io_task, url): url <span class="keyword">for</span> url <span class="keyword">in</span> urls&#125;</span><br><span class="line">        </span><br><span class="line">        <span class="comment"># 收集结果</span></span><br><span class="line">        results = []</span><br><span class="line">        <span class="keyword">for</span> future <span class="keyword">in</span> concurrent.futures.as_completed(future_to_url):</span><br><span class="line">            url = future_to_url[future]</span><br><span class="line">            <span class="keyword">try</span>:</span><br><span class="line">                status_code = future.result()</span><br><span class="line">                results.append((url, status_code))</span><br><span class="line">            <span class="keyword">except</span> Exception <span class="keyword">as</span> e:</span><br><span class="line">                <span class="built_in">print</span>(<span class="string">f&quot;处理 <span class="subst">&#123;url&#125;</span> 时出错: <span class="subst">&#123;<span class="built_in">str</span>(e)&#125;</span>&quot;</span>)</span><br><span class="line">    </span><br><span class="line">    <span class="comment"># 模拟一些CSV数据处理（CPU密集型任务）</span></span><br><span class="line">    sample_csv = <span class="string">&quot;col1,col2,col3\n1,2,3\n4,5,6\n7,8,9&quot;</span></span><br><span class="line">    cpu_result = cpu_task(sample_csv)</span><br><span class="line">    </span><br><span class="line">    <span class="built_in">print</span>(<span class="string">f&quot;进程 <span class="subst">&#123;os.getpid()&#125;</span> (ID: <span class="subst">&#123;process_id&#125;</span>) 完成所有任务&quot;</span>)</span><br><span class="line">    <span class="keyword">return</span> results, cpu_result</span><br><span class="line"></span><br><span class="line"><span class="keyword">def</span> <span class="title function_">main</span>():</span><br><span class="line">    <span class="comment"># 测试URL列表</span></span><br><span class="line">    all_urls = [</span><br><span class="line">        <span class="string">f&quot;https://httpbin.org/delay/<span class="subst">&#123;i%<span class="number">3</span>&#125;</span>&quot;</span> <span class="keyword">for</span> i <span class="keyword">in</span> <span class="built_in">range</span>(<span class="number">16</span>)</span><br><span class="line">    ]</span><br><span class="line">    </span><br><span class="line">    <span class="comment"># 将URL分成4组，每个进程处理4个URL</span></span><br><span class="line">    url_chunks = [all_urls[i:i+<span class="number">4</span>] <span class="keyword">for</span> i <span class="keyword">in</span> <span class="built_in">range</span>(<span class="number">0</span>, <span class="built_in">len</span>(all_urls), <span class="number">4</span>)]</span><br><span class="line">    </span><br><span class="line">    start_time = time.time()</span><br><span class="line">    </span><br><span class="line">    <span class="comment"># 创建进程池</span></span><br><span class="line">    <span class="keyword">with</span> concurrent.futures.ProcessPoolExecutor(max_workers=<span class="number">4</span>) <span class="keyword">as</span> process_executor:</span><br><span class="line">        <span class="comment"># 提交任务到进程池</span></span><br><span class="line">        futures = [process_executor.submit(process_worker, i, urls) </span><br><span class="line">                  <span class="keyword">for</span> i, urls <span class="keyword">in</span> <span class="built_in">enumerate</span>(url_chunks)]</span><br><span class="line">        </span><br><span class="line">        <span class="comment"># 收集所有进程的结果</span></span><br><span class="line">        <span class="keyword">for</span> future <span class="keyword">in</span> concurrent.futures.as_completed(futures):</span><br><span class="line">            <span class="keyword">try</span>:</span><br><span class="line">                io_results, cpu_result = future.result()</span><br><span class="line">                <span class="built_in">print</span>(<span class="string">f&quot;进程返回结果: <span class="subst">&#123;<span class="built_in">len</span>(io_results)&#125;</span> 个URL请求, CPU任务处理了 <span class="subst">&#123;cpu_result&#125;</span> 行数据&quot;</span>)</span><br><span class="line">            <span class="keyword">except</span> Exception <span class="keyword">as</span> e:</span><br><span class="line">                <span class="built_in">print</span>(<span class="string">f&quot;进程执行出错: <span class="subst">&#123;<span class="built_in">str</span>(e)&#125;</span>&quot;</span>)</span><br><span class="line">    </span><br><span class="line">    elapsed_time = time.time() - start_time</span><br><span class="line">    <span class="built_in">print</span>(<span class="string">f&quot;总执行时间: <span class="subst">&#123;elapsed_time:<span class="number">.2</span>f&#125;</span> 秒&quot;</span>)</span><br><span class="line"></span><br><span class="line"><span class="keyword">if</span> __name__ == <span class="string">&quot;__main__&quot;</span>:</span><br><span class="line">    main()</span><br><span class="line"></span><br></pre></td></tr></table></figure><p>这种混合模型充分利用了 Python 的并发性能：</p><ol><li><strong>多进程并行</strong>：跨越 GIL 限制，在多个 CPU 核心上同时执行 Python 代码</li><li><strong>每进程多线程</strong>：处理进程内的 I/O 密集型任务，提高 I/O 并发性</li><li><strong>任务队列</strong>：有效分配和管理工作负载，平衡资源利用</li></ol><h5 id="细粒度锁与粗粒度锁">细粒度锁与粗粒度锁</h5><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><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></pre></td><td class="code"><pre><span class="line"><span class="keyword">import</span> threading</span><br><span class="line"><span class="keyword">import</span> time</span><br><span class="line"><span class="keyword">import</span> random</span><br><span class="line"></span><br><span class="line"></span><br><span class="line"><span class="comment"># 共享资源</span></span><br><span class="line"><span class="keyword">class</span> <span class="title class_">BankAccount</span>:</span><br><span class="line">    <span class="keyword">def</span> <span class="title function_">__init__</span>(<span class="params">self, balance</span>):</span><br><span class="line">        <span class="variable language_">self</span>.balance = balance</span><br><span class="line">        <span class="comment"># 粗粒度锁 - 用于整个账户操作</span></span><br><span class="line">        <span class="variable language_">self</span>.coarse_lock = threading.Lock()</span><br><span class="line">        <span class="comment"># 细粒度锁 - 分别用于读取和写入操作</span></span><br><span class="line">        <span class="variable language_">self</span>.read_lock = threading.Lock()</span><br><span class="line">        <span class="variable language_">self</span>.write_lock = threading.Lock()</span><br><span class="line"></span><br><span class="line"><span class="comment"># 粗粒度锁示例 - 锁定整个账户操作</span></span><br><span class="line"><span class="keyword">def</span> <span class="title function_">transfer_coarse</span>(<span class="params">account, amount</span>):</span><br><span class="line">    <span class="keyword">with</span> account.coarse_lock:</span><br><span class="line">        <span class="comment"># 模拟读取余额操作</span></span><br><span class="line">        current_balance = account.balance</span><br><span class="line">        <span class="comment"># 模拟网络延迟或处理时间</span></span><br><span class="line">        time.sleep(random.uniform(<span class="number">0.001</span>, <span class="number">0.005</span>))</span><br><span class="line">        <span class="comment"># 模拟更新余额操作</span></span><br><span class="line">        account.balance = current_balance + amount</span><br><span class="line">        <span class="built_in">print</span>(<span class="string">f&quot;粗粒度锁: 转账 <span class="subst">&#123;amount&#125;</span>，当前余额 <span class="subst">&#123;account.balance&#125;</span>&quot;</span>)</span><br><span class="line"></span><br><span class="line"></span><br><span class="line"><span class="comment"># 细粒度锁示例 - 分别锁定读取和写入操作</span></span><br><span class="line"><span class="keyword">def</span> <span class="title function_">transfer_fine</span>(<span class="params">account, amount</span>):</span><br><span class="line">    <span class="comment"># 锁定读取操作</span></span><br><span class="line">    <span class="keyword">with</span> account.read_lock:</span><br><span class="line">        current_balance = account.balance</span><br><span class="line">        <span class="comment"># 模拟处理时间</span></span><br><span class="line">        time.sleep(random.uniform(<span class="number">0.001</span>, <span class="number">0.005</span>))</span><br><span class="line"></span><br><span class="line">    <span class="comment"># 这里可以执行一些不需要锁定的计算</span></span><br><span class="line">    time.sleep(random.uniform(<span class="number">0.001</span>, <span class="number">0.002</span>))</span><br><span class="line"></span><br><span class="line">    <span class="comment"># 锁定写入操作</span></span><br><span class="line">    <span class="keyword">with</span> account.write_lock:</span><br><span class="line">        account.balance = current_balance + amount</span><br><span class="line">        <span class="built_in">print</span>(<span class="string">f&quot;细粒度锁: 转账 <span class="subst">&#123;amount&#125;</span>，当前余额 <span class="subst">&#123;account.balance&#125;</span>&quot;</span>)</span><br><span class="line"></span><br><span class="line"></span><br><span class="line"><span class="comment"># 测试函数</span></span><br><span class="line"><span class="keyword">def</span> <span class="title function_">main</span>():</span><br><span class="line">    <span class="comment"># 创建共享账户</span></span><br><span class="line">    account = BankAccount(<span class="number">1000</span>)</span><br><span class="line">    threads = []</span><br><span class="line"></span><br><span class="line">    <span class="built_in">print</span>(<span class="string">&quot;=== 测试粗粒度锁 ===&quot;</span>)</span><br><span class="line">    start_time = time.time()</span><br><span class="line"></span><br><span class="line">    <span class="comment"># 创建10个使用粗粒度锁的线程</span></span><br><span class="line">    <span class="keyword">for</span> i <span class="keyword">in</span> <span class="built_in">range</span>(<span class="number">10</span>):</span><br><span class="line">        amount = random.randint(<span class="number">1</span>, <span class="number">100</span>)</span><br><span class="line">        t = threading.Thread(target=transfer_coarse, args=(account, amount))</span><br><span class="line">        threads.append(t)</span><br><span class="line">        t.start()</span><br><span class="line"></span><br><span class="line">    <span class="comment"># 等待所有线程完成</span></span><br><span class="line">    <span class="keyword">for</span> t <span class="keyword">in</span> threads:</span><br><span class="line">        t.join()</span><br><span class="line"></span><br><span class="line">    coarse_time = time.time() - start_time</span><br><span class="line">    <span class="built_in">print</span>(<span class="string">f&quot;粗粒度锁总耗时: <span class="subst">&#123;coarse_time:<span class="number">.4</span>f&#125;</span>秒&quot;</span>)</span><br><span class="line"></span><br><span class="line">    <span class="comment"># 重置账户和线程列表</span></span><br><span class="line">    account.balance = <span class="number">1000</span></span><br><span class="line">    threads = []</span><br><span class="line"></span><br><span class="line">    <span class="built_in">print</span>(<span class="string">&quot;\n=== 测试细粒度锁 ===&quot;</span>)</span><br><span class="line">    start_time = time.time()</span><br><span class="line"></span><br><span class="line">    <span class="comment"># 创建10个使用细粒度锁的线程</span></span><br><span class="line">    <span class="keyword">for</span> i <span class="keyword">in</span> <span class="built_in">range</span>(<span class="number">10</span>):</span><br><span class="line">        amount = random.randint(<span class="number">1</span>, <span class="number">100</span>)</span><br><span class="line">        t = threading.Thread(target=transfer_fine, args=(account, amount))</span><br><span class="line">        threads.append(t)</span><br><span class="line">        t.start()</span><br><span class="line"></span><br><span class="line">    <span class="comment"># 等待所有线程完成</span></span><br><span class="line">    <span class="keyword">for</span> t <span class="keyword">in</span> threads:</span><br><span class="line">        t.join()</span><br><span class="line"></span><br><span class="line">    fine_time = time.time() - start_time</span><br><span class="line">    <span class="built_in">print</span>(<span class="string">f&quot;细粒度锁总耗时: <span class="subst">&#123;fine_time:<span class="number">.4</span>f&#125;</span>秒&quot;</span>)</span><br><span class="line">    <span class="built_in">print</span>(<span class="string">f&quot;\n性能比较: 细粒度锁比粗粒度锁快 <span class="subst">&#123;(coarse_time / fine_time):<span class="number">.2</span>f&#125;</span> 倍&quot;</span>)</span><br><span class="line"></span><br><span class="line"><span class="keyword">if</span> __name__ == <span class="string">&#x27;__main__&#x27;</span>:</span><br><span class="line">    main()</span><br><span class="line"></span><br></pre></td></tr></table></figure><table><thead><tr><th>锁粒度</th><th>优点</th><th>缺点</th><th>适用场景</th></tr></thead><tbody><tr><td>粗粒度锁</td><td>简单、易维护、不易死锁</td><td>并发性能低、可能导致线程等待</td><td>简单应用、对性能要求不高的场景</td></tr><tr><td>细粒度锁</td><td>并发性能高、资源利用率高</td><td>实现复杂、可能造成死锁</td><td>高性能要求、资源访问模式明确的场景</td></tr></tbody></table><h3 id="14-7-消息队列与进程通信">14.7 消息队列与进程通信</h3><p>在并发编程中，队列是一种常用的数据结构。它遵循 <strong>先进先出（FIFO）</strong> 的原则，适合用于线程或进程间的通信，而堆栈则遵循 <strong>后进先出（LIFO）</strong> 的原则。Python 中的 <code>queue</code> 和 <code>multiprocessing</code> 模块提供了多种类型的队列，每种队列适用于不同的场景。</p><h4 id="队列基础知识">队列基础知识</h4><p>Python 的 <code>queue</code> 模块和 <code>multiprocessing</code> 模块提供了多种队列类型，主要包括：</p><table><thead><tr><th>队列类型</th><th>模块</th><th>特点</th><th>适用场景</th></tr></thead><tbody><tr><td>Queue</td><td>queue</td><td>线程安全的 FIFO 队列</td><td>线程间通信</td></tr><tr><td>LifoQueue</td><td>queue</td><td>线程安全的 LIFO 队列(堆栈)</td><td>需要后进先出的场景</td></tr><tr><td>PriorityQueue</td><td>queue</td><td>优先级队列</td><td>任务具有优先级的场景</td></tr><tr><td>Queue</td><td>multiprocessing</td><td>进程安全的 FIFO 队列</td><td>进程间通信</td></tr><tr><td>JoinableQueue</td><td>multiprocessing</td><td>带有任务完成通知机制的队列</td><td>生产者-消费者模型</td></tr></tbody></table><h4 id="队列使用示例">队列使用示例</h4><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></pre></td><td class="code"><pre><span class="line"><span class="keyword">import</span> random</span><br><span class="line"><span class="keyword">from</span> multiprocessing <span class="keyword">import</span> Queue</span><br><span class="line"><span class="keyword">from</span> concurrent.futures <span class="keyword">import</span> ThreadPoolExecutor</span><br><span class="line"><span class="keyword">import</span> time</span><br><span class="line"></span><br><span class="line"></span><br><span class="line"></span><br><span class="line"><span class="comment"># 生产者进程函数</span></span><br><span class="line"><span class="keyword">def</span> <span class="title function_">producer</span>(<span class="params">q: Queue</span>) -&gt; <span class="literal">None</span>:</span><br><span class="line">    <span class="string">&quot;&quot;&quot;生产者函数，负责生产数据并放入队列&quot;&quot;&quot;</span></span><br><span class="line">    <span class="keyword">for</span> i <span class="keyword">in</span> <span class="built_in">range</span>(<span class="number">10</span>):</span><br><span class="line">        item = <span class="string">f&quot;小吃<span class="subst">&#123;i&#125;</span>&quot;</span></span><br><span class="line">        <span class="built_in">print</span>(<span class="string">f&quot;生产者生产了<span class="subst">&#123;item&#125;</span>&quot;</span>)</span><br><span class="line">        q.put(item)  <span class="comment"># 放入队列</span></span><br><span class="line">        time.sleep(random.uniform(<span class="number">0.1</span>, <span class="number">0.5</span>))  <span class="comment"># 模拟耗时操作</span></span><br><span class="line">    q.put(<span class="literal">None</span>)  <span class="comment"># 生产结束信号</span></span><br><span class="line">    <span class="built_in">print</span>(<span class="string">&quot;生产者结束&quot;</span>)</span><br><span class="line"></span><br><span class="line"></span><br><span class="line"><span class="comment"># 消费者进程函数</span></span><br><span class="line"><span class="keyword">def</span> <span class="title function_">consumer</span>(<span class="params">q: Queue</span>):</span><br><span class="line">    <span class="string">&quot;&quot;&quot;消费者函数，负责从队列中获取数据并消费&quot;&quot;&quot;</span></span><br><span class="line">    <span class="keyword">while</span> <span class="literal">True</span>:</span><br><span class="line">        item = q.get()  <span class="comment"># 从队列中获取项目</span></span><br><span class="line">        <span class="keyword">if</span> item <span class="keyword">is</span> <span class="literal">None</span>:  <span class="comment"># 若获取到结束信号，则退出循环</span></span><br><span class="line">            <span class="keyword">break</span></span><br><span class="line">        <span class="built_in">print</span>(<span class="string">f&quot;消费者消费了<span class="subst">&#123;item&#125;</span>&quot;</span>)</span><br><span class="line">        time.sleep(random.uniform(<span class="number">1</span>, <span class="number">2</span>))</span><br><span class="line">    <span class="built_in">print</span>(<span class="string">&quot;消费者结束&quot;</span>)</span><br><span class="line"></span><br><span class="line"></span><br><span class="line"><span class="keyword">if</span> __name__ == <span class="string">&#x27;__main__&#x27;</span>:</span><br><span class="line">    <span class="comment"># 创建一个队列对象</span></span><br><span class="line">    q = Queue()</span><br><span class="line">    <span class="keyword">with</span> ThreadPoolExecutor(max_workers=<span class="number">2</span>) <span class="keyword">as</span> executor:</span><br><span class="line">        <span class="comment"># 启动生产者进程</span></span><br><span class="line">        executor.submit(producer, q)</span><br><span class="line">        <span class="comment"># 启动消费者进程</span></span><br><span class="line">        executor.submit(consumer, q)</span><br><span class="line"></span><br></pre></td></tr></table></figure><h4 id="优先级队列示例">优先级队列示例</h4><p>优先级队列按任务的优先级顺序处理任务。数字越小优先级越高。以下是如何使用 <code>PriorityQueue</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></pre></td><td class="code"><pre><span class="line"><span class="keyword">import</span> queue</span><br><span class="line"><span class="keyword">import</span> threading</span><br><span class="line"><span class="keyword">import</span> time</span><br><span class="line"></span><br><span class="line"><span class="comment"># 创建优先级队列</span></span><br><span class="line"><span class="comment"># 优先级队列的元素是元组，第一个元素是优先级，第二个元素是任务</span></span><br><span class="line">pq = queue.PriorityQueue()</span><br><span class="line"></span><br><span class="line"><span class="keyword">def</span> <span class="title function_">process_tasks</span>():</span><br><span class="line">    <span class="string">&quot;&quot;&quot;按照优先级处理任务&quot;&quot;&quot;</span></span><br><span class="line">    <span class="keyword">while</span> <span class="literal">True</span>:</span><br><span class="line">        <span class="keyword">try</span>:</span><br><span class="line">            priority,task = pq.get(timeout=<span class="number">3</span>)</span><br><span class="line">            <span class="built_in">print</span>(<span class="string">f&quot;处理任务:[优先级<span class="subst">&#123;priority&#125;</span>] <span class="subst">&#123;task&#125;</span>&quot;</span>)</span><br><span class="line">            pq.task_done()</span><br><span class="line">        <span class="keyword">except</span> queue.Empty:</span><br><span class="line">            <span class="built_in">print</span>(<span class="string">&quot;队列为空，任务处理完毕&quot;</span>)</span><br><span class="line">            <span class="keyword">break</span></span><br><span class="line"><span class="comment"># 添加任务到优先级队列</span></span><br><span class="line">pq.put((<span class="number">3</span>, <span class="string">&quot;普通任务&quot;</span>))</span><br><span class="line">pq.put((<span class="number">1</span>, <span class="string">&quot;紧急任务&quot;</span>))</span><br><span class="line">pq.put((<span class="number">2</span>, <span class="string">&quot;中等优先级任务&quot;</span>))</span><br><span class="line">pq.put((<span class="number">1</span>, <span class="string">&quot;另一个紧急任务&quot;</span>))</span><br><span class="line">pq.put((<span class="number">5</span>, <span class="string">&quot;低优先级任务&quot;</span>))</span><br><span class="line"></span><br><span class="line"><span class="comment"># 创建任务处理线程</span></span><br><span class="line">worker = threading.Thread(target=process_tasks)</span><br><span class="line">worker.start()</span><br><span class="line"></span><br><span class="line">pq.join()  <span class="comment"># 等待所有任务处理完毕</span></span><br><span class="line">worker.join()</span><br><span class="line"></span><br><span class="line"><span class="built_in">print</span>(<span class="string">&quot;所有任务处理完毕&quot;</span>)</span><br><span class="line"></span><br></pre></td></tr></table></figure></div>]]></content>
    
    
      
      
        
        
    <summary type="html">&lt;div id=&quot;postchat_postcontent&quot;&gt;&lt;h2 id=&quot;第十四章：深入解析并发编程&quot;&gt;第十四章：深入解析并发编程&lt;/h2&gt;
&lt;h3 id=&quot;14-并发编程基本概念&quot;&gt;14.</summary>
        
      
    
    
    
    <category term="后端技术" scheme="https://prorise666.site/categories/%E5%90%8E%E7%AB%AF%E6%8A%80%E6%9C%AF/"/>
    
    <category term="Python" scheme="https://prorise666.site/categories/%E5%90%8E%E7%AB%AF%E6%8A%80%E6%9C%AF/Python/"/>
    
    <category term="Python 基础系列" scheme="https://prorise666.site/categories/%E5%90%8E%E7%AB%AF%E6%8A%80%E6%9C%AF/Python/Python-%E5%9F%BA%E7%A1%80%E7%B3%BB%E5%88%97/"/>
    
    
    <category term="Python 基础篇" scheme="https://prorise666.site/tags/Python-%E5%9F%BA%E7%A1%80%E7%AF%87/"/>
    
  </entry>
  
  <entry>
    <title>Python 基础篇（十三）：第十三章： 高级数据处理</title>
    <link href="https://prorise666.site/posts/48617.html"/>
    <id>https://prorise666.site/posts/48617.html</id>
    <published>2026-02-05T15:38:17.000Z</published>
    <updated>2026-03-12T09:18:16.967Z</updated>
    
    <content type="html"><![CDATA[<div id="postchat_postcontent"><h2 id="第十三章：-高级数据处理">第十三章： 高级数据处理</h2><p>Python 提供了多种处理不同类型数据的工具和库，能够轻松处理结构化和非结构化数据。本章将深入探讨 Python 中常用的数据格式处理技术，包括 JSON、CSV、XML 和配置文件等。</p><h3 id="13-1-JSON-处理">13.1 JSON 处理</h3><p>JSON (JavaScript Object Notation) 是一种轻量级的数据交换格式，易于人阅读和编写，也易于机器解析和生成。Python 通过内置的 <code>json</code> 模块提供了 JSON 的序列化和反序列化功能。</p><table><thead><tr><th>方法</th><th>描述</th></tr></thead><tbody><tr><td><code>json.dump(obj, fp)</code></td><td>将 Python 对象 <code>obj</code> 编码为 JSON 格式并写入文件 <code>fp</code>。</td></tr><tr><td><code>json.dumps(obj)</code></td><td>将 Python 对象 <code>obj</code> 编码为 JSON 格式并返回字符串。</td></tr><tr><td><code>json.load(fp)</code></td><td>从文件 <code>fp</code> 读取 JSON 数据并解码为 Python 对象。</td></tr><tr><td><code>json.loads(s)</code></td><td>将字符串 <code>s</code> 解码为 Python 对象。</td></tr></tbody></table><h4 id="13-1-1-基本操作">13.1.1 基本操作</h4><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></pre></td><td class="code"><pre><span class="line"><span class="keyword">import</span> json</span><br><span class="line"></span><br><span class="line"><span class="comment"># Python对象转JSON</span></span><br><span class="line">data = &#123;</span><br><span class="line">    <span class="string">&quot;name&quot;</span>: <span class="string">&quot;张三&quot;</span>,</span><br><span class="line">    <span class="string">&quot;age&quot;</span>: <span class="number">30</span>,</span><br><span class="line">    <span class="string">&quot;is_student&quot;</span>: <span class="literal">False</span>,</span><br><span class="line">    <span class="string">&quot;courses&quot;</span>: [<span class="string">&quot;Python&quot;</span>, <span class="string">&quot;数据分析&quot;</span>, <span class="string">&quot;机器学习&quot;</span>],</span><br><span class="line">    <span class="string">&quot;scores&quot;</span>: &#123;<span class="string">&quot;Python&quot;</span>: <span class="number">95</span>, <span class="string">&quot;数据分析&quot;</span>: <span class="number">88</span>&#125;</span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="comment"># 转换为JSON字符串</span></span><br><span class="line">json_str = json.dumps(data, ensure_ascii=<span class="literal">False</span>, indent=<span class="number">4</span>)</span><br><span class="line"><span class="built_in">print</span>(json_str)</span><br><span class="line"></span><br><span class="line"><span class="comment"># 写入JSON文件</span></span><br><span class="line"><span class="keyword">with</span> <span class="built_in">open</span>(<span class="string">&quot;data.json&quot;</span>, <span class="string">&quot;w&quot;</span>, encoding=<span class="string">&quot;utf-8&quot;</span>) <span class="keyword">as</span> f:</span><br><span class="line">    json.dump(data, f, ensure_ascii=<span class="literal">False</span>, indent=<span class="number">4</span>)</span><br><span class="line"></span><br><span class="line"><span class="comment"># 从JSON字符串解析</span></span><br><span class="line">parsed_data = json.loads(json_str)</span><br><span class="line"><span class="built_in">print</span>(parsed_data[<span class="string">&quot;name&quot;</span>])  <span class="comment"># 张三</span></span><br><span class="line"></span><br><span class="line"><span class="comment"># 从JSON文件读取</span></span><br><span class="line"><span class="keyword">with</span> <span class="built_in">open</span>(<span class="string">&quot;data.json&quot;</span>, <span class="string">&quot;r&quot;</span>, encoding=<span class="string">&quot;utf-8&quot;</span>) <span class="keyword">as</span> f:</span><br><span class="line">    loaded_data = json.load(f)</span><br><span class="line">    <span class="built_in">print</span>(loaded_data[<span class="string">&quot;scores&quot;</span>][<span class="string">&quot;Python&quot;</span>])  <span class="comment"># 95</span></span><br></pre></td></tr></table></figure><h4 id="13-1-2-重要参数说明">13.1.2 重要参数说明</h4><table><thead><tr><th>参数</th><th>说明</th><th>用法示例</th></tr></thead><tbody><tr><td><code>ensure_ascii</code></td><td>是否转义非 ASCII 字符，False 时保留原始字符</td><td><code>json.dumps(data, ensure_ascii=False)</code></td></tr><tr><td><code>indent</code></td><td>缩进格式，美化输出</td><td><code>json.dumps(data, indent=4)</code></td></tr><tr><td><code>separators</code></td><td>指定分隔符，用于紧凑输出</td><td><code>json.dumps(data, separators=(',', ':'))</code></td></tr><tr><td><code>sort_keys</code></td><td>是否按键排序</td><td><code>json.dumps(data, sort_keys=True)</code></td></tr><tr><td><code>default</code></td><td>指定序列化函数，处理不可序列化对象</td><td><code>json.dumps(obj, default=lambda o: o.__dict__)</code></td></tr></tbody></table><h4 id="13-1-3-自定义对象序列化">13.1.3 自定义对象序列化</h4><p>Python 的 <code>json</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></pre></td><td class="code"><pre><span class="line"><span class="keyword">import</span> json</span><br><span class="line"></span><br><span class="line"><span class="comment"># ========== 方法一：提供default参数 ==========</span></span><br><span class="line"></span><br><span class="line"><span class="keyword">class</span> <span class="title class_">Person</span>:</span><br><span class="line">    <span class="keyword">def</span> <span class="title function_">__init__</span>(<span class="params">self, name, age</span>):</span><br><span class="line">        <span class="variable language_">self</span>.name = name</span><br><span class="line">        <span class="variable language_">self</span>.age = age</span><br><span class="line"></span><br><span class="line"></span><br><span class="line"><span class="keyword">def</span> <span class="title function_">person_to_dict</span>(<span class="params">person</span>):</span><br><span class="line">    <span class="string">&quot;&quot;&quot;将Person对象转换为字典&quot;&quot;&quot;</span></span><br><span class="line">    <span class="keyword">return</span> &#123;</span><br><span class="line">        <span class="string">&quot;name&quot;</span>: person.name,</span><br><span class="line">        <span class="string">&quot;age&quot;</span>: person.age</span><br><span class="line">    &#125;</span><br><span class="line"></span><br><span class="line"></span><br><span class="line"><span class="comment"># 示例：使用default参数序列化自定义对象</span></span><br><span class="line">person = Person(<span class="string">&quot;李四&quot;</span>, <span class="number">25</span>)</span><br><span class="line">json_str = json.dumps(person, default=person_to_dict, ensure_ascii=<span class="literal">False</span>)</span><br><span class="line"><span class="built_in">print</span>(json_str)  <span class="comment"># &#123;&quot;name&quot;: &quot;李四&quot;, &quot;age&quot;: 25&#125;</span></span><br><span class="line"></span><br><span class="line"></span><br><span class="line"><span class="comment"># ========== 方法二：通过自定义编码器 ==========</span></span><br><span class="line"></span><br><span class="line"><span class="keyword">class</span> <span class="title class_">PersonEncoder</span>(json.JSONEncoder):</span><br><span class="line">    <span class="string">&quot;&quot;&quot;自定义JSON编码器处理Person类&quot;&quot;&quot;</span></span><br><span class="line">    <span class="keyword">def</span> <span class="title function_">default</span>(<span class="params">self, obj</span>):</span><br><span class="line">        <span class="keyword">if</span> <span class="built_in">isinstance</span>(obj, Person):</span><br><span class="line">            <span class="keyword">return</span> &#123;<span class="string">&quot;name&quot;</span>: obj.name, <span class="string">&quot;age&quot;</span>: obj.age&#125;</span><br><span class="line">        <span class="keyword">return</span> <span class="built_in">super</span>().default(obj)</span><br><span class="line"></span><br><span class="line"></span><br><span class="line"><span class="comment"># 示例：使用自定义编码器序列化对象</span></span><br><span class="line">json_str = json.dumps(person, cls=PersonEncoder, ensure_ascii=<span class="literal">False</span>)</span><br><span class="line"><span class="built_in">print</span>(json_str)  <span class="comment"># &#123;&quot;name&quot;: &quot;李四&quot;, &quot;age&quot;: 25&#125;</span></span><br><span class="line"></span><br><span class="line"></span><br><span class="line"><span class="comment"># ========== 方法三：添加to_json方法 ==========</span></span><br><span class="line"></span><br><span class="line"><span class="keyword">class</span> <span class="title class_">Student</span>:</span><br><span class="line">    <span class="keyword">def</span> <span class="title function_">__init__</span>(<span class="params">self, name, grade</span>):</span><br><span class="line">        <span class="variable language_">self</span>.name = name</span><br><span class="line">        <span class="variable language_">self</span>.grade = grade</span><br><span class="line"></span><br><span class="line">    <span class="keyword">def</span> <span class="title function_">__repr__</span>(<span class="params">self</span>):</span><br><span class="line">        <span class="keyword">return</span> <span class="string">f&quot;Student(&#x27;<span class="subst">&#123;self.name&#125;</span>&#x27;, <span class="subst">&#123;self.grade&#125;</span>)&quot;</span></span><br><span class="line"></span><br><span class="line">    <span class="keyword">def</span> <span class="title function_">to_json</span>(<span class="params">self</span>):</span><br><span class="line">        <span class="string">&quot;&quot;&quot;返回可JSON序列化的字典&quot;&quot;&quot;</span></span><br><span class="line">        <span class="keyword">return</span> &#123;</span><br><span class="line">            <span class="string">&quot;name&quot;</span>: <span class="variable language_">self</span>.name,</span><br><span class="line">            <span class="string">&quot;grade&quot;</span>: <span class="variable language_">self</span>.grade</span><br><span class="line">        &#125;</span><br><span class="line"></span><br><span class="line"></span><br><span class="line"><span class="comment"># 示例：使用对象的to_json方法序列化</span></span><br><span class="line">students = [Student(<span class="string">&quot;小明&quot;</span>, <span class="number">90</span>), Student(<span class="string">&quot;小红&quot;</span>, <span class="number">88</span>)]</span><br><span class="line">json_str = json.dumps([s.to_json() <span class="keyword">for</span> s <span class="keyword">in</span> students], ensure_ascii=<span class="literal">False</span>)</span><br><span class="line"><span class="built_in">print</span>(json_str)  <span class="comment"># [&#123;&quot;name&quot;: &quot;小明&quot;, &quot;grade&quot;: 90&#125;, &#123;&quot;name&quot;: &quot;小红&quot;, &quot;grade&quot;: 88&#125;]</span></span><br></pre></td></tr></table></figure><h4 id="13-1-4-JSON-解码为自定义对象">13.1.4 JSON 解码为自定义对象</h4><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></pre></td><td class="code"><pre><span class="line"><span class="keyword">import</span> json</span><br><span class="line"><span class="keyword">from</span> typing <span class="keyword">import</span> <span class="type">Dict</span></span><br><span class="line"></span><br><span class="line"></span><br><span class="line"><span class="keyword">class</span> <span class="title class_">Person</span>:</span><br><span class="line">    <span class="keyword">def</span> <span class="title function_">__init__</span>(<span class="params">self, name: <span class="built_in">str</span>, age: <span class="built_in">int</span></span>):</span><br><span class="line">        <span class="variable language_">self</span>.name = name</span><br><span class="line">        <span class="variable language_">self</span>.age = age</span><br><span class="line"></span><br><span class="line">    <span class="keyword">def</span> <span class="title function_">__str__</span>(<span class="params">self</span>):</span><br><span class="line">        <span class="keyword">return</span> <span class="string">f&quot;<span class="subst">&#123;self.name&#125;</span>(<span class="subst">&#123;self.age&#125;</span>)&quot;</span></span><br><span class="line"></span><br><span class="line"></span><br><span class="line"><span class="keyword">def</span> <span class="title function_">dict_to_person</span>(<span class="params">data: <span class="type">Dict</span></span>) -&gt; Person:</span><br><span class="line">    <span class="keyword">return</span> Person(data[<span class="string">&quot;name&quot;</span>], data[<span class="string">&quot;age&quot;</span>])</span><br><span class="line"></span><br><span class="line"></span><br><span class="line"><span class="comment"># 使用 json.loads() 的 object_hook 参数将 JSON 字符串直接转换为自定义对象</span></span><br><span class="line"><span class="comment"># object_hook 的用途:</span></span><br><span class="line"><span class="comment"># 1. 自动将 JSON 解析出的字典转换为自定义类的实例</span></span><br><span class="line"><span class="comment"># 2. 在解析 JSON 时进行数据转换和验证</span></span><br><span class="line"><span class="comment"># 3. 简化从 JSON 到对象模型的映射过程</span></span><br><span class="line"><span class="comment"># 4. 避免手动创建对象的繁琐步骤</span></span><br><span class="line"></span><br><span class="line"><span class="comment"># 工作原理:</span></span><br><span class="line"><span class="comment"># - json.loads() 首先将 JSON 字符串解析为 Python 字典</span></span><br><span class="line"><span class="comment"># - 然后对每个解析出的字典调用 object_hook 函数</span></span><br><span class="line"><span class="comment"># - object_hook 函数返回的对象将替代原始字典</span></span><br><span class="line"></span><br><span class="line"><span class="comment"># 实际应用场景:</span></span><br><span class="line"><span class="comment"># - API 响应数据转换为应用程序对象模型</span></span><br><span class="line"><span class="comment"># - 配置文件解析为配置对象</span></span><br><span class="line"><span class="comment"># - 数据导入时的格式转换</span></span><br><span class="line"></span><br><span class="line">person_data = <span class="string">&#x27;&#123;&quot;name&quot;: &quot;Alice&quot;, &quot;age&quot;: 25&#125;&#x27;</span></span><br><span class="line">person = json.loads(person_data, object_hook=dict_to_person)</span><br><span class="line"><span class="built_in">print</span>(<span class="built_in">type</span>(person))  <span class="comment"># &lt;class &#x27;__main__.Person&#x27;&gt;</span></span><br><span class="line"><span class="built_in">print</span>([person.name, person.age])  <span class="comment"># [&#x27;Alice&#x27;, 25]</span></span><br><span class="line"><span class="built_in">print</span>(person)  <span class="comment"># Alice(25)</span></span><br><span class="line"></span><br></pre></td></tr></table></figure><h4 id="13-1-5-处理复杂-JSON-数据">13.1.5 处理复杂 JSON 数据</h4><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></pre></td><td class="code"><pre><span class="line"><span class="comment"># 处理嵌套结构</span></span><br><span class="line">nested_json = <span class="string">&#x27;&#x27;&#x27;</span></span><br><span class="line"><span class="string">&#123;</span></span><br><span class="line"><span class="string">    &quot;company&quot;: &quot;ABC Corp&quot;,</span></span><br><span class="line"><span class="string">    &quot;employees&quot;: [</span></span><br><span class="line"><span class="string">        &#123;&quot;name&quot;: &quot;张三&quot;, &quot;department&quot;: &quot;技术&quot;, &quot;skills&quot;: [&quot;Python&quot;, &quot;Java&quot;]&#125;,</span></span><br><span class="line"><span class="string">        &#123;&quot;name&quot;: &quot;李四&quot;, &quot;department&quot;: &quot;市场&quot;, &quot;skills&quot;: [&quot;营销&quot;, &quot;策划&quot;]&#125;</span></span><br><span class="line"><span class="string">    ],</span></span><br><span class="line"><span class="string">    &quot;locations&quot;: &#123;</span></span><br><span class="line"><span class="string">        &quot;headquarters&quot;: &quot;北京&quot;,</span></span><br><span class="line"><span class="string">        &quot;branches&quot;: [&quot;上海&quot;, &quot;广州&quot;, &quot;深圳&quot;]</span></span><br><span class="line"><span class="string">    &#125;</span></span><br><span class="line"><span class="string">&#125;</span></span><br><span class="line"><span class="string">&#x27;&#x27;&#x27;</span></span><br><span class="line"></span><br><span class="line">data = json.loads(nested_json)</span><br><span class="line"></span><br><span class="line"><span class="comment"># 访问嵌套数据</span></span><br><span class="line"><span class="built_in">print</span>(data[<span class="string">&quot;employees&quot;</span>][<span class="number">0</span>][<span class="string">&quot;name&quot;</span>])        <span class="comment"># 张三</span></span><br><span class="line"><span class="built_in">print</span>(data[<span class="string">&quot;employees&quot;</span>][<span class="number">0</span>][<span class="string">&quot;skills&quot;</span>][<span class="number">0</span>])   <span class="comment"># Python</span></span><br><span class="line"><span class="built_in">print</span>(data[<span class="string">&quot;locations&quot;</span>][<span class="string">&quot;branches&quot;</span>][<span class="number">1</span>])    <span class="comment"># 广州</span></span><br><span class="line"></span><br><span class="line"><span class="comment"># 修改嵌套数据</span></span><br><span class="line">data[<span class="string">&quot;employees&quot;</span>][<span class="number">0</span>][<span class="string">&quot;skills&quot;</span>].append(<span class="string">&quot;C++&quot;</span>)</span><br><span class="line">data[<span class="string">&quot;locations&quot;</span>][<span class="string">&quot;branches&quot;</span>].append(<span class="string">&quot;成都&quot;</span>)</span><br><span class="line"></span><br><span class="line"><span class="comment"># 保存修改后的数据</span></span><br><span class="line">updated_json = json.dumps(data, ensure_ascii=<span class="literal">False</span>, indent=<span class="number">2</span>)</span><br><span class="line"><span class="built_in">print</span>(updated_json)</span><br></pre></td></tr></table></figure><h4 id="13-1-6-性能优化">13.1.6 性能优化</h4><p>处理大型 JSON 文件时，可以使用流式解析来提高性能：</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></pre></td><td class="code"><pre><span class="line"><span class="keyword">import</span> ijson  <span class="comment"># 需安装: pip install ijson</span></span><br><span class="line"></span><br><span class="line"><span class="comment"># 流式解析大型JSON文件</span></span><br><span class="line"><span class="keyword">with</span> <span class="built_in">open</span>(<span class="string">&quot;large_file.json&quot;</span>, <span class="string">&quot;rb&quot;</span>) <span class="keyword">as</span> f:</span><br><span class="line">    <span class="comment"># 只提取特定字段</span></span><br><span class="line">    <span class="keyword">for</span> item <span class="keyword">in</span> ijson.items(f, <span class="string">&quot;items.item&quot;</span>):</span><br><span class="line">        <span class="built_in">print</span>(item[<span class="string">&quot;id&quot;</span>], item[<span class="string">&quot;name&quot;</span>])</span><br><span class="line">        <span class="comment"># 处理一项后继续，不必载入整个文件</span></span><br></pre></td></tr></table></figure><h4 id="13-1-7-JSON-Schema-验证">13.1.7 JSON Schema 验证</h4><p>验证 JSON 数据是否符合预期格式：</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></pre></td><td class="code"><pre><span class="line"><span class="keyword">from</span> jsonschema <span class="keyword">import</span> validate  <span class="comment"># 需安装: pip install jsonschema</span></span><br><span class="line"></span><br><span class="line"><span class="comment"># 定义JSON Schema</span></span><br><span class="line">schema = &#123;</span><br><span class="line">    <span class="string">&quot;type&quot;</span>: <span class="string">&quot;object&quot;</span>,</span><br><span class="line">    <span class="string">&quot;properties&quot;</span>: &#123;</span><br><span class="line">        <span class="string">&quot;name&quot;</span>: &#123;<span class="string">&quot;type&quot;</span>: <span class="string">&quot;string&quot;</span>&#125;,</span><br><span class="line">        <span class="string">&quot;age&quot;</span>: &#123;<span class="string">&quot;type&quot;</span>: <span class="string">&quot;integer&quot;</span>, <span class="string">&quot;minimum&quot;</span>: <span class="number">0</span>&#125;,</span><br><span class="line">        <span class="string">&quot;email&quot;</span>: &#123;<span class="string">&quot;type&quot;</span>: <span class="string">&quot;string&quot;</span>, <span class="string">&quot;format&quot;</span>: <span class="string">&quot;email&quot;</span>&#125;</span><br><span class="line">    &#125;,</span><br><span class="line">    <span class="string">&quot;required&quot;</span>: [<span class="string">&quot;name&quot;</span>, <span class="string">&quot;age&quot;</span>]</span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="comment"># 验证数据</span></span><br><span class="line">valid_data = &#123;<span class="string">&quot;name&quot;</span>: <span class="string">&quot;张三&quot;</span>, <span class="string">&quot;age&quot;</span>: <span class="number">30</span>, <span class="string">&quot;email&quot;</span>: <span class="string">&quot;zhangsan@example.com&quot;</span>&#125;</span><br><span class="line">invalid_data = &#123;<span class="string">&quot;name&quot;</span>: <span class="string">&quot;李四&quot;</span>, <span class="string">&quot;age&quot;</span>: -<span class="number">5</span>&#125;</span><br><span class="line"></span><br><span class="line"><span class="keyword">try</span>:</span><br><span class="line">    validate(instance=valid_data, schema=schema)</span><br><span class="line">    <span class="built_in">print</span>(<span class="string">&quot;有效数据&quot;</span>)</span><br><span class="line"><span class="keyword">except</span> Exception <span class="keyword">as</span> e:</span><br><span class="line">    <span class="built_in">print</span>(<span class="string">f&quot;验证失败: <span class="subst">&#123;e&#125;</span>&quot;</span>)</span><br><span class="line"></span><br><span class="line"><span class="keyword">try</span>:</span><br><span class="line">    validate(instance=invalid_data, schema=schema)</span><br><span class="line">    <span class="built_in">print</span>(<span class="string">&quot;有效数据&quot;</span>)</span><br><span class="line"><span class="keyword">except</span> Exception <span class="keyword">as</span> e:</span><br><span class="line">    <span class="built_in">print</span>(<span class="string">f&quot;验证失败: <span class="subst">&#123;e&#125;</span>&quot;</span>)  <span class="comment"># 会因age小于0而失败</span></span><br></pre></td></tr></table></figure><h3 id="13-2-CSV-处理">13.2 CSV 处理</h3><p>CSV (Comma-Separated Values) 是一种常见的表格数据格式。Python 的 <code>csv</code> 模块提供了读写 CSV 文件的功能，适用于处理电子表格和数据库导出数据。</p><blockquote><p>在我们写入中文数据时，尽量将编码更换为 <code>GBK</code> 否则写入 CSV 会导致一些乱码问题</p></blockquote><h4 id="13-2-1-基本读写操作">13.2.1 基本读写操作</h4><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></pre></td><td class="code"><pre><span class="line"><span class="keyword">import</span> csv</span><br><span class="line"></span><br><span class="line"><span class="comment"># 写入CSV文件</span></span><br><span class="line">data = [</span><br><span class="line">    [<span class="string">&quot;姓名&quot;</span>, <span class="string">&quot;年龄&quot;</span>, <span class="string">&quot;城市&quot;</span>],</span><br><span class="line">    [<span class="string">&quot;张三&quot;</span>, <span class="number">30</span>, <span class="string">&quot;北京&quot;</span>],</span><br><span class="line">    [<span class="string">&quot;李四&quot;</span>, <span class="number">25</span>, <span class="string">&quot;上海&quot;</span>],</span><br><span class="line">    [<span class="string">&quot;王五&quot;</span>, <span class="number">28</span>, <span class="string">&quot;广州&quot;</span>]</span><br><span class="line">]</span><br><span class="line"></span><br><span class="line"><span class="keyword">with</span> <span class="built_in">open</span>(<span class="string">&quot;people.csv&quot;</span>, <span class="string">&quot;w&quot;</span>, newline=<span class="string">&quot;&quot;</span>, encoding=<span class="string">&quot;gbk&quot;</span>) <span class="keyword">as</span> f:</span><br><span class="line">    writer = csv.writer(f)</span><br><span class="line">    writer.writerows(data)  <span class="comment"># 一次写入多行</span></span><br><span class="line"></span><br><span class="line"><span class="comment"># 逐行写入</span></span><br><span class="line"><span class="keyword">with</span> <span class="built_in">open</span>(<span class="string">&quot;people_row.csv&quot;</span>, <span class="string">&quot;w&quot;</span>, newline=<span class="string">&quot;&quot;</span>, encoding=<span class="string">&quot;gbk&quot;</span>) <span class="keyword">as</span> f:</span><br><span class="line">    writer = csv.writer(f)</span><br><span class="line">    <span class="keyword">for</span> row <span class="keyword">in</span> data:</span><br><span class="line">        writer.writerow(row)  <span class="comment"># 一次写入一行</span></span><br><span class="line"></span><br><span class="line"><span class="comment"># 读取CSV文件</span></span><br><span class="line"><span class="keyword">with</span> <span class="built_in">open</span>(<span class="string">&quot;people.csv&quot;</span>, <span class="string">&quot;r&quot;</span>, encoding=<span class="string">&quot;gbk&quot;</span>) <span class="keyword">as</span> f:</span><br><span class="line">    reader = csv.reader(f)</span><br><span class="line">    <span class="keyword">for</span> row <span class="keyword">in</span> reader:</span><br><span class="line">        <span class="built_in">print</span>(row)</span><br></pre></td></tr></table></figure><h4 id="13-2-2-使用字典处理-CSV-文件">13.2.2 使用字典处理 CSV 文件</h4><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></pre></td><td class="code"><pre><span class="line"><span class="comment"># 使用字典写入CSV</span></span><br><span class="line"><span class="keyword">import</span> csv</span><br><span class="line"></span><br><span class="line">dict_data = [</span><br><span class="line">    &#123;<span class="string">&quot;姓名&quot;</span>: <span class="string">&quot;张三&quot;</span>, <span class="string">&quot;年龄&quot;</span>: <span class="number">30</span>, <span class="string">&quot;城市&quot;</span>: <span class="string">&quot;北京&quot;</span>&#125;,</span><br><span class="line">    &#123;<span class="string">&quot;姓名&quot;</span>: <span class="string">&quot;李四&quot;</span>, <span class="string">&quot;年龄&quot;</span>: <span class="number">25</span>, <span class="string">&quot;城市&quot;</span>: <span class="string">&quot;上海&quot;</span>&#125;,</span><br><span class="line">    &#123;<span class="string">&quot;姓名&quot;</span>: <span class="string">&quot;王五&quot;</span>, <span class="string">&quot;年龄&quot;</span>: <span class="number">28</span>, <span class="string">&quot;城市&quot;</span>: <span class="string">&quot;广州&quot;</span>&#125;</span><br><span class="line">]</span><br><span class="line"></span><br><span class="line"><span class="keyword">with</span> <span class="built_in">open</span>(<span class="string">&quot;people_dict.csv&quot;</span>, <span class="string">&quot;w&quot;</span>, newline=<span class="string">&quot;&quot;</span>, encoding=<span class="string">&quot;gbk&quot;</span>) <span class="keyword">as</span> f:</span><br><span class="line">    fieldnames = [<span class="string">&quot;姓名&quot;</span>, <span class="string">&quot;年龄&quot;</span>, <span class="string">&quot;城市&quot;</span>]</span><br><span class="line">    writer = csv.DictWriter(f, fieldnames=fieldnames)</span><br><span class="line">    writer.writeheader()  <span class="comment"># 写入表头</span></span><br><span class="line">    writer.writerows(dict_data)  <span class="comment"># 写入多行数据</span></span><br><span class="line"></span><br><span class="line"><span class="comment"># 使用字典读取CSV</span></span><br><span class="line"><span class="keyword">with</span> <span class="built_in">open</span>(<span class="string">&quot;people_dict.csv&quot;</span>, <span class="string">&quot;r&quot;</span>, encoding=<span class="string">&quot;gbk&quot;</span>) <span class="keyword">as</span> f:</span><br><span class="line">    reader = csv.DictReader(f)</span><br><span class="line">    <span class="keyword">for</span> row <span class="keyword">in</span> reader:</span><br><span class="line">        <span class="built_in">print</span>(<span class="string">f&quot;<span class="subst">&#123;row[<span class="string">&#x27;姓名&#x27;</span>]&#125;</span> (<span class="subst">&#123;row[<span class="string">&#x27;年龄&#x27;</span>]&#125;</span>岁) 来自 <span class="subst">&#123;row[<span class="string">&#x27;城市&#x27;</span>]&#125;</span>&quot;</span>)</span><br></pre></td></tr></table></figure><h4 id="13-2-3-CSV-方言与格式化选项">13.2.3 CSV 方言与格式化选项</h4><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></pre></td><td class="code"><pre><span class="line"><span class="comment"># 自定义CSV方言</span></span><br><span class="line">csv.register_dialect(</span><br><span class="line">    <span class="string">&#x27;tab_dialect&#x27;</span>,</span><br><span class="line">    delimiter=<span class="string">&#x27;\t&#x27;</span>,       <span class="comment"># 使用制表符作为分隔符</span></span><br><span class="line">    quotechar=<span class="string">&#x27;&quot;&#x27;</span>,        <span class="comment"># 引号字符</span></span><br><span class="line">    escapechar=<span class="string">&#x27;\\&#x27;</span>,      <span class="comment"># 转义字符</span></span><br><span class="line">    doublequote=<span class="literal">False</span>,    <span class="comment"># 不使用双引号转义</span></span><br><span class="line">    quoting=csv.QUOTE_MINIMAL  <span class="comment"># 最小引用策略</span></span><br><span class="line">)</span><br><span class="line"></span><br><span class="line"><span class="comment"># 使用自定义方言</span></span><br><span class="line"><span class="keyword">with</span> <span class="built_in">open</span>(<span class="string">&quot;tab_data.csv&quot;</span>, <span class="string">&quot;w&quot;</span>, newline=<span class="string">&quot;&quot;</span>, encoding=<span class="string">&quot;utf-8&quot;</span>) <span class="keyword">as</span> f:</span><br><span class="line">    writer = csv.writer(f, dialect=<span class="string">&#x27;tab_dialect&#x27;</span>)</span><br><span class="line">    writer.writerows(data)</span><br><span class="line"></span><br><span class="line"><span class="comment"># 常见格式化选项</span></span><br><span class="line"><span class="keyword">with</span> <span class="built_in">open</span>(<span class="string">&quot;formatted.csv&quot;</span>, <span class="string">&quot;w&quot;</span>, newline=<span class="string">&quot;&quot;</span>, encoding=<span class="string">&quot;utf-8&quot;</span>) <span class="keyword">as</span> f:</span><br><span class="line">    writer = csv.writer(</span><br><span class="line">        f,</span><br><span class="line">        delimiter=<span class="string">&#x27;,&#x27;</span>,          <span class="comment"># 分隔符</span></span><br><span class="line">        quotechar=<span class="string">&#x27;&quot;&#x27;</span>,          <span class="comment"># 引号字符</span></span><br><span class="line">        quoting=csv.QUOTE_NONNUMERIC,  <span class="comment"># 为非数值字段添加引号</span></span><br><span class="line">        escapechar=<span class="string">&#x27;\\&#x27;</span>,        <span class="comment"># 转义字符</span></span><br><span class="line">        lineterminator=<span class="string">&#x27;\n&#x27;</span>     <span class="comment"># 行终止符</span></span><br><span class="line">    )</span><br><span class="line">    writer.writerows(data)</span><br></pre></td></tr></table></figure><h4 id="13-2-4-处理特殊情况">13.2.4 处理特殊情况</h4><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></pre></td><td class="code"><pre><span class="line"><span class="comment"># 处理含有引号和逗号的数据</span></span><br><span class="line">complex_data = [</span><br><span class="line">    [<span class="string">&quot;产品&quot;</span>, <span class="string">&quot;描述&quot;</span>, <span class="string">&quot;价格&quot;</span>],</span><br><span class="line">    [<span class="string">&quot;笔记本&quot;</span>, <span class="string">&quot;14\&quot; 高配, i7处理器&quot;</span>, <span class="number">5999.99</span>],</span><br><span class="line">    [<span class="string">&quot;手机&quot;</span>, <span class="string">&quot;5.5\&quot; 屏幕, 双卡双待&quot;</span>, <span class="number">2999.50</span>]</span><br><span class="line">]</span><br><span class="line"></span><br><span class="line"><span class="keyword">with</span> <span class="built_in">open</span>(<span class="string">&quot;complex.csv&quot;</span>, <span class="string">&quot;w&quot;</span>, newline=<span class="string">&quot;&quot;</span>, encoding=<span class="string">&quot;utf-8&quot;</span>) <span class="keyword">as</span> f:</span><br><span class="line">    writer = csv.writer(f, quoting=csv.QUOTE_ALL)  <span class="comment"># 所有字段加引号</span></span><br><span class="line">    writer.writerows(complex_data)</span><br><span class="line"></span><br><span class="line"><span class="comment"># 跳过特定行</span></span><br><span class="line"><span class="keyword">with</span> <span class="built_in">open</span>(<span class="string">&quot;complex.csv&quot;</span>, <span class="string">&quot;r&quot;</span>, encoding=<span class="string">&quot;utf-8&quot;</span>) <span class="keyword">as</span> f:</span><br><span class="line">    reader = csv.reader(f)</span><br><span class="line">    <span class="built_in">next</span>(reader)  <span class="comment"># 跳过表头</span></span><br><span class="line">    <span class="keyword">for</span> row <span class="keyword">in</span> reader:</span><br><span class="line">        <span class="built_in">print</span>(row)</span><br><span class="line"></span><br><span class="line"><span class="comment"># 处理缺失值</span></span><br><span class="line"><span class="keyword">with</span> <span class="built_in">open</span>(<span class="string">&quot;missing.csv&quot;</span>, <span class="string">&quot;r&quot;</span>, encoding=<span class="string">&quot;utf-8&quot;</span>) <span class="keyword">as</span> f:</span><br><span class="line">    reader = csv.reader(f)</span><br><span class="line">    <span class="keyword">for</span> row <span class="keyword">in</span> reader:</span><br><span class="line">        <span class="comment"># 将空字符串转换为None</span></span><br><span class="line">        processed_row = [<span class="literal">None</span> <span class="keyword">if</span> cell == <span class="string">&#x27;&#x27;</span> <span class="keyword">else</span> cell <span class="keyword">for</span> cell <span class="keyword">in</span> row]</span><br><span class="line">        <span class="built_in">print</span>(processed_row)</span><br></pre></td></tr></table></figure><h4 id="13-2-5-CSV-文件的高级操作">13.2.5 CSV 文件的高级操作</h4><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></pre></td><td class="code"><pre><span class="line"><span class="comment"># 过滤行</span></span><br><span class="line"><span class="keyword">with</span> <span class="built_in">open</span>(<span class="string">&quot;people.csv&quot;</span>, <span class="string">&quot;r&quot;</span>, encoding=<span class="string">&quot;utf-8&quot;</span>) <span class="keyword">as</span> f:</span><br><span class="line">    reader = csv.DictReader(f)</span><br><span class="line">    <span class="comment"># 筛选年龄大于25的记录</span></span><br><span class="line">    filtered_data = [row <span class="keyword">for</span> row <span class="keyword">in</span> reader <span class="keyword">if</span> <span class="built_in">int</span>(row[<span class="string">&quot;年龄&quot;</span>]) &gt; <span class="number">25</span>]</span><br><span class="line"></span><br><span class="line"><span class="comment"># 计算统计值</span></span><br><span class="line"><span class="keyword">with</span> <span class="built_in">open</span>(<span class="string">&quot;grades.csv&quot;</span>, <span class="string">&quot;r&quot;</span>, encoding=<span class="string">&quot;utf-8&quot;</span>) <span class="keyword">as</span> f:</span><br><span class="line">    reader = csv.DictReader(f)</span><br><span class="line">    <span class="comment"># 计算平均分</span></span><br><span class="line">    scores = [<span class="built_in">float</span>(row[<span class="string">&quot;分数&quot;</span>]) <span class="keyword">for</span> row <span class="keyword">in</span> reader]</span><br><span class="line">    avg_score = <span class="built_in">sum</span>(scores) / <span class="built_in">len</span>(scores)</span><br><span class="line">    <span class="built_in">print</span>(<span class="string">f&quot;平均分: <span class="subst">&#123;avg_score:<span class="number">.2</span>f&#125;</span>&quot;</span>)</span><br><span class="line"></span><br><span class="line"><span class="comment"># 合并多个CSV文件</span></span><br><span class="line"><span class="keyword">import</span> glob</span><br><span class="line"></span><br><span class="line"><span class="keyword">def</span> <span class="title function_">merge_csv_files</span>(<span class="params">file_pattern, output_file</span>):</span><br><span class="line">    <span class="comment"># 获取所有匹配的文件</span></span><br><span class="line">    all_files = glob.glob(file_pattern)</span><br><span class="line">    </span><br><span class="line">    <span class="keyword">with</span> <span class="built_in">open</span>(output_file, <span class="string">&quot;w&quot;</span>, newline=<span class="string">&quot;&quot;</span>, encoding=<span class="string">&quot;utf-8&quot;</span>) <span class="keyword">as</span> outfile:</span><br><span class="line">        <span class="comment"># 假设所有文件结构相同</span></span><br><span class="line">        <span class="keyword">for</span> i, filename <span class="keyword">in</span> <span class="built_in">enumerate</span>(all_files):</span><br><span class="line">            <span class="keyword">with</span> <span class="built_in">open</span>(filename, <span class="string">&quot;r&quot;</span>, encoding=<span class="string">&quot;utf-8&quot;</span>) <span class="keyword">as</span> infile:</span><br><span class="line">                reader = csv.reader(infile)</span><br><span class="line">                <span class="keyword">if</span> i == <span class="number">0</span>:</span><br><span class="line">                    <span class="comment"># 第一个文件，保留表头</span></span><br><span class="line">                    <span class="keyword">for</span> row <span class="keyword">in</span> reader:</span><br><span class="line">                        csv.writer(outfile).writerow(row)</span><br><span class="line">                <span class="keyword">else</span>:</span><br><span class="line">                    <span class="comment"># 跳过后续文件的表头</span></span><br><span class="line">                    <span class="built_in">next</span>(reader, <span class="literal">None</span>)</span><br><span class="line">                    <span class="keyword">for</span> row <span class="keyword">in</span> reader:</span><br><span class="line">                        csv.writer(outfile).writerow(row)</span><br><span class="line"></span><br><span class="line"><span class="comment"># 使用示例</span></span><br><span class="line"><span class="comment"># merge_csv_files(&quot;data_*.csv&quot;, &quot;merged_data.csv&quot;)</span></span><br></pre></td></tr></table></figure><h3 id="13-3-XML-处理">13.3 XML 处理</h3><p>XML (eXtensible Markup Language) 是一种用于存储和传输数据的标记语言。Python 提供多种处理 XML 的方法，最常用的是 <code>xml.etree.ElementTree</code> 模块。</p><h4 id="13-3-1-创建和写入-XML">13.3.1 创建和写入 XML</h4><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></pre></td><td class="code"><pre><span class="line"><span class="keyword">import</span> xml.etree.ElementTree <span class="keyword">as</span> ET</span><br><span class="line"></span><br><span class="line"><span class="comment"># 创建XML根元素</span></span><br><span class="line">root = ET.Element(<span class="string">&quot;data&quot;</span>)</span><br><span class="line"></span><br><span class="line"><span class="comment"># 添加子元素</span></span><br><span class="line">items = ET.SubElement(root, <span class="string">&quot;items&quot;</span>)</span><br><span class="line"></span><br><span class="line"><span class="comment"># 添加多个项目</span></span><br><span class="line"><span class="keyword">for</span> i <span class="keyword">in</span> <span class="built_in">range</span>(<span class="number">1</span>, <span class="number">4</span>):</span><br><span class="line">    item = ET.SubElement(items, <span class="string">&quot;item&quot;</span>)</span><br><span class="line">    item.<span class="built_in">set</span>(<span class="string">&quot;id&quot;</span>, <span class="built_in">str</span>(i))  <span class="comment"># 设置属性</span></span><br><span class="line">    item.text = <span class="string">f&quot;第<span class="subst">&#123;i&#125;</span>项&quot;</span>  <span class="comment"># 设置文本内容</span></span><br><span class="line">    </span><br><span class="line">    <span class="comment"># 添加嵌套元素</span></span><br><span class="line">    detail = ET.SubElement(item, <span class="string">&quot;detail&quot;</span>)</span><br><span class="line">    detail.text = <span class="string">f&quot;项目<span class="subst">&#123;i&#125;</span>的详情&quot;</span></span><br><span class="line"></span><br><span class="line"><span class="comment"># 创建用户信息部分</span></span><br><span class="line">users = ET.SubElement(root, <span class="string">&quot;users&quot;</span>)</span><br><span class="line"></span><br><span class="line"><span class="comment"># 添加用户</span></span><br><span class="line">user = ET.SubElement(users, <span class="string">&quot;user&quot;</span>)</span><br><span class="line">user.<span class="built_in">set</span>(<span class="string">&quot;name&quot;</span>, <span class="string">&quot;张三&quot;</span>)</span><br><span class="line">ET.SubElement(user, <span class="string">&quot;age&quot;</span>).text = <span class="string">&quot;30&quot;</span></span><br><span class="line">ET.SubElement(user, <span class="string">&quot;city&quot;</span>).text = <span class="string">&quot;北京&quot;</span></span><br><span class="line"></span><br><span class="line">user2 = ET.SubElement(users, <span class="string">&quot;user&quot;</span>)</span><br><span class="line">user2.<span class="built_in">set</span>(<span class="string">&quot;name&quot;</span>, <span class="string">&quot;李四&quot;</span>)</span><br><span class="line">ET.SubElement(user2, <span class="string">&quot;age&quot;</span>).text = <span class="string">&quot;25&quot;</span></span><br><span class="line">ET.SubElement(user2, <span class="string">&quot;city&quot;</span>).text = <span class="string">&quot;上海&quot;</span></span><br><span class="line"></span><br><span class="line"><span class="comment"># 生成XML字符串</span></span><br><span class="line">xml_str = ET.tostring(root, encoding=<span class="string">&quot;utf-8&quot;</span>).decode(<span class="string">&quot;utf-8&quot;</span>)</span><br><span class="line"><span class="built_in">print</span>(xml_str)</span><br><span class="line"></span><br><span class="line"><span class="comment"># 写入XML文件</span></span><br><span class="line">tree = ET.ElementTree(root)</span><br><span class="line">tree.write(<span class="string">&quot;data.xml&quot;</span>, encoding=<span class="string">&quot;utf-8&quot;</span>, xml_declaration=<span class="literal">True</span>)</span><br></pre></td></tr></table></figure><h4 id="13-3-2-解析和读取-XML">13.3.2 解析和读取 XML</h4><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></pre></td><td class="code"><pre><span class="line"><span class="comment"># 从文件解析XML</span></span><br><span class="line">tree = ET.parse(<span class="string">&quot;data.xml&quot;</span>)</span><br><span class="line">root = tree.getroot()</span><br><span class="line"></span><br><span class="line"><span class="comment"># 从字符串解析XML</span></span><br><span class="line">xml_string = <span class="string">&#x27;&lt;data&gt;&lt;item id=&quot;1&quot;&gt;测试&lt;/item&gt;&lt;/data&gt;&#x27;</span></span><br><span class="line">root = ET.fromstring(xml_string)</span><br><span class="line"></span><br><span class="line"><span class="comment"># 获取元素标签和属性</span></span><br><span class="line"><span class="built_in">print</span>(<span class="string">f&quot;根元素标签: <span class="subst">&#123;root.tag&#125;</span>&quot;</span>)</span><br><span class="line"></span><br><span class="line"><span class="comment"># 遍历子元素</span></span><br><span class="line"><span class="keyword">for</span> child <span class="keyword">in</span> root:</span><br><span class="line">    <span class="built_in">print</span>(<span class="string">f&quot;子元素: <span class="subst">&#123;child.tag&#125;</span>, 属性: <span class="subst">&#123;child.attrib&#125;</span>&quot;</span>)</span><br><span class="line"></span><br><span class="line"><span class="comment"># 查找特定元素 - find()查找第一个匹配元素</span></span><br><span class="line">items = root.find(<span class="string">&quot;items&quot;</span>)</span><br><span class="line"><span class="keyword">if</span> items <span class="keyword">is</span> <span class="keyword">not</span> <span class="literal">None</span>:</span><br><span class="line">    <span class="comment"># 使用findall()查找所有匹配的子元素</span></span><br><span class="line">    <span class="keyword">for</span> item <span class="keyword">in</span> items.findall(<span class="string">&quot;item&quot;</span>):</span><br><span class="line">        <span class="built_in">print</span>(<span class="string">f&quot;项目ID: <span class="subst">&#123;item.get(<span class="string">&#x27;id&#x27;</span>)&#125;</span>, 内容: <span class="subst">&#123;item.text&#125;</span>&quot;</span>)</span><br><span class="line">        <span class="comment"># 获取嵌套元素</span></span><br><span class="line">        detail = item.find(<span class="string">&quot;detail&quot;</span>)</span><br><span class="line">        <span class="keyword">if</span> detail <span class="keyword">is</span> <span class="keyword">not</span> <span class="literal">None</span>:</span><br><span class="line">            <span class="built_in">print</span>(<span class="string">f&quot;  详情: <span class="subst">&#123;detail.text&#125;</span>&quot;</span>)</span><br><span class="line"></span><br><span class="line"><span class="comment"># 使用XPath查询</span></span><br><span class="line"><span class="comment"># 查找所有用户名称</span></span><br><span class="line">users = root.findall(<span class="string">&quot;.//user&quot;</span>)</span><br><span class="line"><span class="keyword">for</span> user <span class="keyword">in</span> users:</span><br><span class="line">    <span class="built_in">print</span>(<span class="string">f&quot;用户: <span class="subst">&#123;user.get(<span class="string">&#x27;name&#x27;</span>)&#125;</span>&quot;</span>)</span><br><span class="line">    <span class="built_in">print</span>(<span class="string">f&quot;  年龄: <span class="subst">&#123;user.find(<span class="string">&#x27;age&#x27;</span>).text&#125;</span>&quot;</span>)</span><br><span class="line">    <span class="built_in">print</span>(<span class="string">f&quot;  城市: <span class="subst">&#123;user.find(<span class="string">&#x27;city&#x27;</span>).text&#125;</span>&quot;</span>)</span><br><span class="line"></span><br><span class="line"><span class="comment"># 更复杂的XPath查询 - 查找北京的用户</span></span><br><span class="line">beijing_users = root.findall(<span class="string">&quot;.//user[city=&#x27;北京&#x27;]&quot;</span>)</span><br><span class="line"><span class="keyword">for</span> user <span class="keyword">in</span> beijing_users:</span><br><span class="line">    <span class="built_in">print</span>(<span class="string">f&quot;北京用户: <span class="subst">&#123;user.get(<span class="string">&#x27;name&#x27;</span>)&#125;</span>&quot;</span>)</span><br></pre></td></tr></table></figure><h4 id="13-3-3-修改-XML">13.3.3 修改 XML</h4><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></pre></td><td class="code"><pre><span class="line"><span class="comment"># 修改元素属性</span></span><br><span class="line">user = root.find(<span class="string">&quot;.//user[@name=&#x27;张三&#x27;]&quot;</span>)</span><br><span class="line"><span class="keyword">if</span> user <span class="keyword">is</span> <span class="keyword">not</span> <span class="literal">None</span>:</span><br><span class="line">    user.<span class="built_in">set</span>(<span class="string">&quot;status&quot;</span>, <span class="string">&quot;active&quot;</span>)  <span class="comment"># 添加新属性</span></span><br><span class="line">    </span><br><span class="line">    <span class="comment"># 修改子元素文本</span></span><br><span class="line">    age_elem = user.find(<span class="string">&quot;age&quot;</span>)</span><br><span class="line">    <span class="keyword">if</span> age_elem <span class="keyword">is</span> <span class="keyword">not</span> <span class="literal">None</span>:</span><br><span class="line">        age_elem.text = <span class="string">&quot;31&quot;</span>  <span class="comment"># 修改年龄</span></span><br><span class="line">    </span><br><span class="line">    <span class="comment"># 添加新元素</span></span><br><span class="line">    ET.SubElement(user, <span class="string">&quot;email&quot;</span>).text = <span class="string">&quot;zhangsan@example.com&quot;</span></span><br><span class="line"></span><br><span class="line"><span class="comment"># 删除元素</span></span><br><span class="line">users = root.find(<span class="string">&quot;users&quot;</span>)</span><br><span class="line"><span class="keyword">if</span> users <span class="keyword">is</span> <span class="keyword">not</span> <span class="literal">None</span>:</span><br><span class="line">    <span class="keyword">for</span> user <span class="keyword">in</span> users.findall(<span class="string">&quot;user&quot;</span>):</span><br><span class="line">        <span class="keyword">if</span> user.get(<span class="string">&quot;name&quot;</span>) == <span class="string">&quot;李四&quot;</span>:</span><br><span class="line">            users.remove(user)</span><br><span class="line">            <span class="keyword">break</span></span><br><span class="line"></span><br><span class="line"><span class="comment"># 保存修改</span></span><br><span class="line">tree.write(<span class="string">&quot;updated_data.xml&quot;</span>, encoding=<span class="string">&quot;utf-8&quot;</span>, xml_declaration=<span class="literal">True</span>)</span><br></pre></td></tr></table></figure><h4 id="13-3-4-命名空间处理">13.3.4 命名空间处理</h4><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><span class="line"><span class="comment"># 创建带命名空间的XML</span></span><br><span class="line">root = ET.Element(<span class="string">&quot;data&quot;</span>, &#123;<span class="string">&quot;xmlns:dt&quot;</span>: <span class="string">&quot;http://example.org/datatypes&quot;</span>&#125;)</span><br><span class="line"></span><br><span class="line"><span class="comment"># 添加带命名空间前缀的元素</span></span><br><span class="line">item = ET.SubElement(root, <span class="string">&quot;dt:item&quot;</span>)</span><br><span class="line">item.<span class="built_in">set</span>(<span class="string">&quot;dt:type&quot;</span>, <span class="string">&quot;special&quot;</span>)</span><br><span class="line">item.text = <span class="string">&quot;带命名空间的元素&quot;</span></span><br><span class="line"></span><br><span class="line"><span class="comment"># 生成XML字符串</span></span><br><span class="line">ns_xml = ET.tostring(root, encoding=<span class="string">&quot;utf-8&quot;</span>).decode(<span class="string">&quot;utf-8&quot;</span>)</span><br><span class="line"><span class="built_in">print</span>(ns_xml)</span><br><span class="line"></span><br><span class="line"><span class="comment"># 解析带命名空间的XML</span></span><br><span class="line">ns_root = ET.fromstring(ns_xml)</span><br><span class="line"></span><br><span class="line"><span class="comment"># 使用带命名空间的XPath查询</span></span><br><span class="line">namespaces = &#123;<span class="string">&quot;dt&quot;</span>: <span class="string">&quot;http://example.org/datatypes&quot;</span>&#125;</span><br><span class="line">ns_items = ns_root.findall(<span class="string">&quot;.//dt:item&quot;</span>, namespaces)</span><br><span class="line"></span><br><span class="line"><span class="keyword">for</span> item <span class="keyword">in</span> ns_items:</span><br><span class="line">    <span class="built_in">print</span>(<span class="string">f&quot;找到命名空间元素: <span class="subst">&#123;item.text&#125;</span>&quot;</span>)</span><br><span class="line">    <span class="built_in">print</span>(<span class="string">f&quot;类型属性: <span class="subst">&#123;item.get(<span class="string">&#x27;&#123;http://example.org/datatypes&#125;type&#x27;</span>)&#125;</span>&quot;</span>)</span><br></pre></td></tr></table></figure><h3 id="13-4-配置文件处理">13.4 配置文件处理</h3><p>配置文件是应用程序保存设置和首选项的常用方式。Python 提供了多种处理不同格式配置文件的方法。</p><h4 id="13-4-1-INI-配置文件处理">13.4.1 INI 配置文件处理</h4><p>INI 文件是一种结构简单的配置文件格式，Python 通过 <code>configparser</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></pre></td><td class="code"><pre><span class="line"><span class="keyword">import</span> configparser</span><br><span class="line"><span class="comment"># configparser是Python标准库中用于处理配置文件的模块</span></span><br><span class="line"><span class="comment"># 它可以读取、写入和修改类似INI格式的配置文件</span></span><br><span class="line"><span class="comment"># 配置文件通常包含节(sections)</span></span><br><span class="line"><span class="comment"># 如:[DEFAULT]</span></span><br><span class="line"><span class="comment"># 和每个节下的键值对(key-value pairs)</span></span><br><span class="line"><span class="comment"># 如:</span></span><br><span class="line"><span class="comment"># language = 中文</span></span><br><span class="line"><span class="comment"># theme = 默认</span></span><br><span class="line"><span class="comment"># auto_save = true</span></span><br><span class="line"><span class="comment"># save_interval = 10</span></span><br><span class="line"></span><br><span class="line"></span><br><span class="line"><span class="comment"># 创建一个新的配置解析器</span></span><br><span class="line">config = configparser.ConfigParser()</span><br><span class="line"></span><br><span class="line"><span class="comment"># 添加默认节和配置项</span></span><br><span class="line">config[<span class="string">&quot;DEFAULT&quot;</span>] = &#123;</span><br><span class="line">    <span class="string">&quot;language&quot;</span>: <span class="string">&quot;中文&quot;</span>,</span><br><span class="line">    <span class="string">&quot;theme&quot;</span>: <span class="string">&quot;默认&quot;</span>,</span><br><span class="line">    <span class="string">&quot;auto_save&quot;</span>: <span class="string">&quot;true&quot;</span>,  </span><br><span class="line">    <span class="string">&quot;save_interval&quot;</span>: <span class="string">&quot;10&quot;</span></span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="comment"># 添加应用设置节</span></span><br><span class="line">config[<span class="string">&quot;应用设置&quot;</span>] = &#123;&#125;</span><br><span class="line">config[<span class="string">&quot;应用设置&quot;</span>][<span class="string">&quot;font_size&quot;</span>] = <span class="string">&quot;14&quot;</span></span><br><span class="line"></span><br><span class="line"><span class="comment"># 添加用户信息节</span></span><br><span class="line">config[<span class="string">&quot;用户信息&quot;</span>] = &#123;&#125;</span><br><span class="line">user_info = config[<span class="string">&quot;用户信息&quot;</span>]  <span class="comment"># 创建一个引用，方便添加多个配置项</span></span><br><span class="line">user_info[<span class="string">&quot;username&quot;</span>] = <span class="string">&quot;张三&quot;</span></span><br><span class="line">user_info[<span class="string">&quot;email&quot;</span>] = <span class="string">&quot;zhangsan@example.com&quot;</span></span><br><span class="line">user_info[<span class="string">&quot;remember_password&quot;</span>] = <span class="string">&quot;false&quot;</span>  <span class="comment"># 修改为标准布尔值字符串</span></span><br><span class="line"></span><br><span class="line"><span class="comment"># 添加数据库连接节</span></span><br><span class="line">config[<span class="string">&quot;数据库&quot;</span>] = &#123;&#125;</span><br><span class="line">config[<span class="string">&quot;数据库&quot;</span>][<span class="string">&quot;host&quot;</span>] = <span class="string">&quot;localhost&quot;</span></span><br><span class="line">config[<span class="string">&quot;数据库&quot;</span>][<span class="string">&quot;port&quot;</span>] = <span class="string">&quot;3306&quot;</span></span><br><span class="line">config[<span class="string">&quot;数据库&quot;</span>][<span class="string">&quot;username&quot;</span>] = <span class="string">&quot;root&quot;</span></span><br><span class="line">config[<span class="string">&quot;数据库&quot;</span>][<span class="string">&quot;password&quot;</span>] = <span class="string">&quot;123456&quot;</span></span><br><span class="line"></span><br><span class="line"><span class="comment"># 将配置写入文件</span></span><br><span class="line"><span class="keyword">with</span> <span class="built_in">open</span>(<span class="string">&quot;config.ini&quot;</span>, <span class="string">&quot;w&quot;</span>, encoding=<span class="string">&quot;utf-8&quot;</span>) <span class="keyword">as</span> f:</span><br><span class="line">    config.write(f)</span><br><span class="line"></span><br><span class="line"><span class="comment"># 读取配置文件</span></span><br><span class="line">config = configparser.ConfigParser()</span><br><span class="line">config.read(<span class="string">&quot;config.ini&quot;</span>, encoding=<span class="string">&quot;utf-8&quot;</span>)</span><br><span class="line"></span><br><span class="line"><span class="comment"># 获取所有节名称</span></span><br><span class="line"><span class="built_in">print</span>(<span class="string">&quot;所有配置节:&quot;</span>, config.sections())  <span class="comment"># [&#x27;应用设置&#x27;, &#x27;用户信息&#x27;, &#x27;数据库&#x27;]</span></span><br><span class="line"></span><br><span class="line"><span class="comment"># 获取节中的所有键</span></span><br><span class="line"><span class="built_in">print</span>(<span class="string">&quot;用户信息节中的所有键:&quot;</span>, <span class="built_in">list</span>(config[<span class="string">&quot;用户信息&quot;</span>].keys()))</span><br><span class="line"></span><br><span class="line"><span class="comment"># 获取特定配置值</span></span><br><span class="line"><span class="built_in">print</span>(<span class="string">&quot;用户名:&quot;</span>, config[<span class="string">&quot;用户信息&quot;</span>][<span class="string">&quot;username&quot;</span>])  <span class="comment"># 张三</span></span><br><span class="line"></span><br><span class="line"><span class="comment"># 获取默认节中的值</span></span><br><span class="line"><span class="built_in">print</span>(<span class="string">&quot;默认语言:&quot;</span>, config.get(<span class="string">&quot;应用设置&quot;</span>, <span class="string">&quot;language&quot;</span>))  <span class="comment"># 使用DEFAULT中的值</span></span><br><span class="line"></span><br><span class="line"><span class="comment"># 类型转换方法</span></span><br><span class="line">font_size = config.getint(<span class="string">&quot;应用设置&quot;</span>, <span class="string">&quot;font_size&quot;</span>)</span><br><span class="line">auto_save = config.getboolean(<span class="string">&quot;DEFAULT&quot;</span>, <span class="string">&quot;auto_save&quot;</span>, fallback=<span class="literal">True</span>)  <span class="comment"># 将&quot;true&quot;转换为True</span></span><br><span class="line">save_interval = config.getint(<span class="string">&quot;DEFAULT&quot;</span>, <span class="string">&quot;save_interval&quot;</span>)</span><br><span class="line"></span><br><span class="line"><span class="built_in">print</span>(<span class="string">f&quot;字体大小: <span class="subst">&#123;font_size&#125;</span>, 类型: <span class="subst">&#123;<span class="built_in">type</span>(font_size)&#125;</span>&quot;</span>)  <span class="comment"># 字体大小: 14, 类型: &lt;class &#x27;int&#x27;&gt;</span></span><br><span class="line"><span class="built_in">print</span>(<span class="string">f&quot;自动保存: <span class="subst">&#123;auto_save&#125;</span>, 类型: <span class="subst">&#123;<span class="built_in">type</span>(auto_save)&#125;</span>&quot;</span>)  <span class="comment"># 自动保存: True, 类型: &lt;class &#x27;bool&#x27;&gt;</span></span><br><span class="line"></span><br><span class="line"><span class="comment"># 修改配置</span></span><br><span class="line">config[<span class="string">&quot;用户信息&quot;</span>][<span class="string">&quot;username&quot;</span>] = <span class="string">&quot;李四&quot;</span></span><br><span class="line"></span><br><span class="line"><span class="comment"># 添加新配置</span></span><br><span class="line"><span class="keyword">if</span> <span class="string">&quot;日志设置&quot;</span> <span class="keyword">not</span> <span class="keyword">in</span> config:</span><br><span class="line">    config[<span class="string">&quot;日志设置&quot;</span>] = &#123;&#125;</span><br><span class="line">config[<span class="string">&quot;日志设置&quot;</span>][<span class="string">&quot;log_level&quot;</span>] = <span class="string">&quot;INFO&quot;</span></span><br><span class="line">config[<span class="string">&quot;日志设置&quot;</span>][<span class="string">&quot;log_file&quot;</span>] = <span class="string">&quot;app.log&quot;</span></span><br><span class="line">config[<span class="string">&quot;日志设置&quot;</span>][<span class="string">&quot;max_size&quot;</span>] = <span class="string">&quot;10MB&quot;</span></span><br><span class="line"></span><br><span class="line"><span class="comment"># 保存修改后的配置</span></span><br><span class="line"><span class="keyword">with</span> <span class="built_in">open</span>(<span class="string">&quot;updated_config.ini&quot;</span>, <span class="string">&quot;w&quot;</span>, encoding=<span class="string">&quot;utf-8&quot;</span>) <span class="keyword">as</span> f:</span><br><span class="line">    config.write(f)</span><br></pre></td></tr></table></figure><h4 id="13-4-2-YAML-配置文件处理">13.4.2 YAML 配置文件处理</h4><p>YAML 是一种人类友好的数据序列化格式，需要安装 <code>PyYAML</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></pre></td><td class="code"><pre><span class="line"><span class="comment"># 需要安装PyYAML: pip install pyyaml</span></span><br><span class="line"><span class="keyword">import</span> yaml</span><br><span class="line"></span><br><span class="line"><span class="comment"># 创建YAML数据</span></span><br><span class="line">data = &#123;</span><br><span class="line">    <span class="string">&quot;server&quot;</span>: &#123;</span><br><span class="line">        <span class="string">&quot;host&quot;</span>: <span class="string">&quot;example.com&quot;</span>,</span><br><span class="line">        <span class="string">&quot;port&quot;</span>: <span class="number">8080</span></span><br><span class="line">    &#125;,</span><br><span class="line">    <span class="string">&quot;database&quot;</span>: &#123;</span><br><span class="line">        <span class="string">&quot;host&quot;</span>: <span class="string">&quot;localhost&quot;</span>,</span><br><span class="line">        <span class="string">&quot;port&quot;</span>: <span class="number">5432</span>,</span><br><span class="line">        <span class="string">&quot;username&quot;</span>: <span class="string">&quot;admin&quot;</span>,</span><br><span class="line">        <span class="string">&quot;password&quot;</span>: <span class="string">&quot;secret&quot;</span></span><br><span class="line">    &#125;,</span><br><span class="line">    <span class="string">&quot;logging&quot;</span>: &#123;</span><br><span class="line">        <span class="string">&quot;level&quot;</span>: <span class="string">&quot;INFO&quot;</span>,</span><br><span class="line">        <span class="string">&quot;file&quot;</span>: <span class="string">&quot;/var/log/app.log&quot;</span></span><br><span class="line">    &#125;,</span><br><span class="line">    <span class="string">&quot;users&quot;</span>: [</span><br><span class="line">        &#123;<span class="string">&quot;name&quot;</span>: <span class="string">&quot;张三&quot;</span>, <span class="string">&quot;role&quot;</span>: <span class="string">&quot;admin&quot;</span>&#125;,</span><br><span class="line">        &#123;<span class="string">&quot;name&quot;</span>: <span class="string">&quot;李四&quot;</span>, <span class="string">&quot;role&quot;</span>: <span class="string">&quot;user&quot;</span>&#125;</span><br><span class="line">    ]</span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="comment"># 写入YAML文件</span></span><br><span class="line"><span class="keyword">with</span> <span class="built_in">open</span>(<span class="string">&quot;config.yaml&quot;</span>, <span class="string">&quot;w&quot;</span>, encoding=<span class="string">&quot;utf-8&quot;</span>) <span class="keyword">as</span> f:</span><br><span class="line">    yaml.dump(data, f, default_flow_style=<span class="literal">False</span>, allow_unicode=<span class="literal">True</span>)</span><br><span class="line"></span><br><span class="line"><span class="comment"># 读取YAML文件</span></span><br><span class="line"><span class="keyword">with</span> <span class="built_in">open</span>(<span class="string">&quot;config.yaml&quot;</span>, <span class="string">&quot;r&quot;</span>, encoding=<span class="string">&quot;utf-8&quot;</span>) <span class="keyword">as</span> f:</span><br><span class="line">    config = yaml.safe_load(f)</span><br><span class="line">    </span><br><span class="line"><span class="comment"># 访问配置</span></span><br><span class="line"><span class="built_in">print</span>(<span class="string">f&quot;服务器地址: <span class="subst">&#123;config[<span class="string">&#x27;server&#x27;</span>][<span class="string">&#x27;host&#x27;</span>]&#125;</span>&quot;</span>)  <span class="comment"># example.com</span></span><br><span class="line"><span class="built_in">print</span>(<span class="string">f&quot;第一个用户: <span class="subst">&#123;config[<span class="string">&#x27;users&#x27;</span>][<span class="number">0</span>][<span class="string">&#x27;name&#x27;</span>]&#125;</span>&quot;</span>)  <span class="comment"># 张三</span></span><br><span class="line"></span><br><span class="line"><span class="comment"># 修改配置</span></span><br><span class="line">config[<span class="string">&quot;server&quot;</span>][<span class="string">&quot;port&quot;</span>] = <span class="number">9090</span></span><br><span class="line">config[<span class="string">&quot;users&quot;</span>].append(&#123;<span class="string">&quot;name&quot;</span>: <span class="string">&quot;王五&quot;</span>, <span class="string">&quot;role&quot;</span>: <span class="string">&quot;user&quot;</span>&#125;)</span><br><span class="line"></span><br><span class="line"><span class="comment"># 保存修改</span></span><br><span class="line"><span class="keyword">with</span> <span class="built_in">open</span>(<span class="string">&quot;updated_config.yaml&quot;</span>, <span class="string">&quot;w&quot;</span>, encoding=<span class="string">&quot;utf-8&quot;</span>) <span class="keyword">as</span> f:</span><br><span class="line">    yaml.dump(config, f, default_flow_style=<span class="literal">False</span>, allow_unicode=<span class="literal">True</span>)</span><br></pre></td></tr></table></figure><h4 id="13-4-3-使用环境变量作为配置">13.4.3 使用环境变量作为配置</h4><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></pre></td><td class="code"><pre><span class="line"><span class="keyword">import</span> os</span><br><span class="line"><span class="keyword">from</span> dotenv <span class="keyword">import</span> load_dotenv  <span class="comment"># 需安装: pip install python-dotenv</span></span><br><span class="line"></span><br><span class="line"><span class="comment"># 从.env文件加载环境变量</span></span><br><span class="line">load_dotenv()  <span class="comment"># 默认加载当前目录下的.env文件</span></span><br><span class="line"></span><br><span class="line"><span class="comment"># 读取环境变量，提供默认值</span></span><br><span class="line">database_url = os.environ.get(<span class="string">&quot;DATABASE_URL&quot;</span>, <span class="string">&quot;sqlite:///default.db&quot;</span>)</span><br><span class="line">debug_mode = os.environ.get(<span class="string">&quot;DEBUG&quot;</span>, <span class="string">&quot;False&quot;</span>).lower() <span class="keyword">in</span> (<span class="string">&quot;true&quot;</span>, <span class="string">&quot;1&quot;</span>, <span class="string">&quot;yes&quot;</span>)</span><br><span class="line">port = <span class="built_in">int</span>(os.environ.get(<span class="string">&quot;PORT&quot;</span>, <span class="string">&quot;8000&quot;</span>))</span><br><span class="line"></span><br><span class="line"><span class="built_in">print</span>(<span class="string">f&quot;数据库URL: <span class="subst">&#123;database_url&#125;</span>&quot;</span>)</span><br><span class="line"><span class="built_in">print</span>(<span class="string">f&quot;调试模式: <span class="subst">&#123;debug_mode&#125;</span>&quot;</span>)</span><br><span class="line"><span class="built_in">print</span>(<span class="string">f&quot;端口: <span class="subst">&#123;port&#125;</span>&quot;</span>)</span><br><span class="line"></span><br><span class="line"><span class="comment"># 创建.env文件示例</span></span><br><span class="line">env_content = <span class="string">&quot;&quot;&quot;</span></span><br><span class="line"><span class="string"># 数据库设置</span></span><br><span class="line"><span class="string">DATABASE_URL=postgresql://user:pass@localhost/dbname</span></span><br><span class="line"><span class="string"># 应用设置</span></span><br><span class="line"><span class="string">DEBUG=True</span></span><br><span class="line"><span class="string">PORT=5000</span></span><br><span class="line"><span class="string">&quot;&quot;&quot;</span></span><br><span class="line"></span><br><span class="line"><span class="keyword">with</span> <span class="built_in">open</span>(<span class="string">&quot;.env.example&quot;</span>, <span class="string">&quot;w&quot;</span>) <span class="keyword">as</span> f:</span><br><span class="line">    f.write(env_content)</span><br></pre></td></tr></table></figure><h4 id="13-4-4-JSON-作为配置文件">13.4.4 JSON 作为配置文件</h4><p>JSON 也是一种常用的配置文件格式，尤其适合需要与 Web 应用共享配置的场景。</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></pre></td><td class="code"><pre><span class="line"><span class="keyword">import</span> json</span><br><span class="line"><span class="keyword">import</span> os</span><br><span class="line"></span><br><span class="line"><span class="comment"># 默认配置</span></span><br><span class="line">default_config = &#123;</span><br><span class="line">    <span class="string">&quot;app_name&quot;</span>: <span class="string">&quot;MyApp&quot;</span>,</span><br><span class="line">    <span class="string">&quot;version&quot;</span>: <span class="string">&quot;1.0.0&quot;</span>,</span><br><span class="line">    <span class="string">&quot;debug&quot;</span>: <span class="literal">False</span>,</span><br><span class="line">    <span class="string">&quot;database&quot;</span>: &#123;</span><br><span class="line">        <span class="string">&quot;host&quot;</span>: <span class="string">&quot;localhost&quot;</span>,</span><br><span class="line">        <span class="string">&quot;port&quot;</span>: <span class="number">5432</span>,</span><br><span class="line">        <span class="string">&quot;name&quot;</span>: <span class="string">&quot;app_db&quot;</span></span><br><span class="line">    &#125;,</span><br><span class="line">    <span class="string">&quot;cache&quot;</span>: &#123;</span><br><span class="line">        <span class="string">&quot;enabled&quot;</span>: <span class="literal">True</span>,</span><br><span class="line">        <span class="string">&quot;ttl&quot;</span>: <span class="number">3600</span></span><br><span class="line">    &#125;</span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="comment"># 配置文件路径</span></span><br><span class="line">config_path = <span class="string">&quot;app_config.json&quot;</span></span><br><span class="line"></span><br><span class="line"><span class="comment"># 加载配置</span></span><br><span class="line"><span class="keyword">def</span> <span class="title function_">load_config</span>():</span><br><span class="line">    <span class="comment"># 如果配置文件存在，则加载它</span></span><br><span class="line">    <span class="keyword">if</span> os.path.exists(config_path):</span><br><span class="line">        <span class="keyword">with</span> <span class="built_in">open</span>(config_path, <span class="string">&quot;r&quot;</span>, encoding=<span class="string">&quot;utf-8&quot;</span>) <span class="keyword">as</span> f:</span><br><span class="line">            <span class="keyword">return</span> json.load(f)</span><br><span class="line">    <span class="comment"># 否则使用默认配置并创建配置文件</span></span><br><span class="line">    <span class="keyword">else</span>:</span><br><span class="line">        save_config(default_config)</span><br><span class="line">        <span class="keyword">return</span> default_config</span><br><span class="line"></span><br><span class="line"><span class="comment"># 保存配置</span></span><br><span class="line"><span class="keyword">def</span> <span class="title function_">save_config</span>(<span class="params">config</span>):</span><br><span class="line">    <span class="keyword">with</span> <span class="built_in">open</span>(config_path, <span class="string">&quot;w&quot;</span>, encoding=<span class="string">&quot;utf-8&quot;</span>) <span class="keyword">as</span> f:</span><br><span class="line">        json.dump(config, f, indent=<span class="number">4</span>, ensure_ascii=<span class="literal">False</span>)</span><br><span class="line"></span><br><span class="line"><span class="comment"># 更新配置</span></span><br><span class="line"><span class="keyword">def</span> <span class="title function_">update_config</span>(<span class="params">key, value</span>):</span><br><span class="line">    config = load_config()</span><br><span class="line">    </span><br><span class="line">    <span class="comment"># 处理嵌套键 (如 &quot;database.host&quot;)</span></span><br><span class="line">    <span class="keyword">if</span> <span class="string">&quot;.&quot;</span> <span class="keyword">in</span> key:</span><br><span class="line">        parts = key.split(<span class="string">&quot;.&quot;</span>)</span><br><span class="line">        current = config</span><br><span class="line">        <span class="keyword">for</span> part <span class="keyword">in</span> parts[:-<span class="number">1</span>]:</span><br><span class="line">            <span class="keyword">if</span> part <span class="keyword">not</span> <span class="keyword">in</span> current:</span><br><span class="line">                current[part] = &#123;&#125;</span><br><span class="line">            current = current[part]</span><br><span class="line">        current[parts[-<span class="number">1</span>]] = value</span><br><span class="line">    <span class="keyword">else</span>:</span><br><span class="line">        config[key] = value</span><br><span class="line">    </span><br><span class="line">    save_config(config)</span><br><span class="line">    <span class="keyword">return</span> config</span><br><span class="line"></span><br><span class="line"><span class="comment"># 使用示例</span></span><br><span class="line">config = load_config()</span><br><span class="line"><span class="built_in">print</span>(<span class="string">f&quot;应用名称: <span class="subst">&#123;config[<span class="string">&#x27;app_name&#x27;</span>]&#125;</span>&quot;</span>)</span><br><span class="line"><span class="built_in">print</span>(<span class="string">f&quot;数据库主机: <span class="subst">&#123;config[<span class="string">&#x27;database&#x27;</span>][<span class="string">&#x27;host&#x27;</span>]&#125;</span>&quot;</span>)</span><br><span class="line"></span><br><span class="line"><span class="comment"># 更新配置</span></span><br><span class="line">update_config(<span class="string">&quot;database.host&quot;</span>, <span class="string">&quot;db.example.com&quot;</span>)</span><br><span class="line">update_config(<span class="string">&quot;cache.ttl&quot;</span>, <span class="number">7200</span>)</span><br><span class="line"></span><br><span class="line"><span class="comment"># 重新加载配置</span></span><br><span class="line">config = load_config()</span><br><span class="line"><span class="built_in">print</span>(<span class="string">f&quot;更新后的数据库主机: <span class="subst">&#123;config[<span class="string">&#x27;database&#x27;</span>][<span class="string">&#x27;host&#x27;</span>]&#125;</span>&quot;</span>)</span><br><span class="line"><span class="built_in">print</span>(<span class="string">f&quot;更新后的缓存TTL: <span class="subst">&#123;config[<span class="string">&#x27;cache&#x27;</span>][<span class="string">&#x27;ttl&#x27;</span>]&#125;</span>&quot;</span>)</span><br></pre></td></tr></table></figure><h3 id="13-5-正则表达式">13.5 正则表达式</h3><p><strong>正则表达式</strong>（通常缩写为 regex 或 regexp）是一种强大的文本处理工具。它使用一种专门的语法来定义 <strong>搜索模式 (pattern)</strong>，然后可以用这个模式在文本中进行查找、匹配、提取或替换操作。正则表达式在各种编程任务中都极为有用，例如：</p><ul><li><strong>数据验证</strong>: 检查用户输入是否符合特定格式（如邮箱、手机号、日期）。</li><li><strong>数据提取</strong>: 从大量非结构化文本（如日志文件、网页内容）中精确地抽取所需信息（如 IP 地址、错误代码、特定标签内容）。</li><li><strong>文本替换</strong>: 对文本进行复杂的查找和替换操作，例如格式化代码、屏蔽敏感信息。</li><li><strong>文本分割</strong>: 根据复杂的模式分割字符串。</li></ul><p>Python 通过内置的 <code>re</code> 模块提供了对正则表达式的全面支持。</p><p><strong>核心概念</strong>: 正则表达式的核心在于使用 <strong>元字符 (metacharacters)</strong> 和普通字符组合来定义模式。元字符是具有特殊含义的字符，而普通字符则匹配它们自身。</p><h4 id="13-5-1-常用元字符和语法">13.5.1 常用元字符和语法</h4><p>以下是一些最常用的正则表达式元字符及其含义：</p><table><thead><tr><th style="text-align:left">元字符</th><th style="text-align:left">描述</th><th style="text-align:left">示例模式</th><th style="text-align:left">示例匹配</th></tr></thead><tbody><tr><td style="text-align:left"><code>.</code></td><td style="text-align:left">匹配 <strong>除换行符 <code>\n</code> 之外</strong> 的任何单个字符 (使用 <code>re.DOTALL</code> 标志可匹配换行符)。</td><td style="text-align:left"><code>a.c</code></td><td style="text-align:left"><code>abc</code>, <code>a_c</code>, <code>a&amp;c</code> (但不匹配 <code>ac</code>)</td></tr><tr><td style="text-align:left"><code>^</code></td><td style="text-align:left">匹配字符串的 <strong>开头</strong>。在多行模式 (<code>re.MULTILINE</code>) 下，也匹配每行的开头。</td><td style="text-align:left"><code>^Hello</code></td><td style="text-align:left"><code>Hello world</code> (但不匹配 <code>Say Hello</code>)</td></tr><tr><td style="text-align:left"><code>$</code></td><td style="text-align:left">匹配字符串的 <strong>结尾</strong>。在多行模式 (<code>re.MULTILINE</code>) 下，也匹配每行的结尾。</td><td style="text-align:left"><code>world$</code></td><td style="text-align:left"><code>Hello world</code> (但不匹配 <code>world say</code>)</td></tr><tr><td style="text-align:left"><code>*</code></td><td style="text-align:left">匹配前面的元素 <strong>零次或多次</strong> (贪婪模式)。</td><td style="text-align:left"><code>go*d</code></td><td style="text-align:left"><code>gd</code>, <code>god</code>, <code>good</code>, <code>goooood</code></td></tr><tr><td style="text-align:left"><code>+</code></td><td style="text-align:left">匹配前面的元素 <strong>一次或多次</strong> (贪婪模式)。</td><td style="text-align:left"><code>go+d</code></td><td style="text-align:left"><code>god</code>, <code>good</code>, <code>goooood</code> (但不匹配 <code>gd</code>)</td></tr><tr><td style="text-align:left"><code>?</code></td><td style="text-align:left">匹配前面的元素 <strong>零次或一次</strong> (贪婪模式)。也用于将贪婪量词变为 <strong>非贪婪</strong> (见后文)。</td><td style="text-align:left"><code>colou?r</code></td><td style="text-align:left"><code>color</code>, <code>colour</code></td></tr><tr><td style="text-align:left"><code>&#123;n&#125;</code></td><td style="text-align:left">匹配前面的元素 <strong>恰好 <code>n</code> 次</strong>。</td><td style="text-align:left"><code>\d&#123;3&#125;</code></td><td style="text-align:left"><code>123</code> (但不匹配 <code>12</code> 或 <code>1234</code>)</td></tr><tr><td style="text-align:left"><code>&#123;n,&#125;</code></td><td style="text-align:left">匹配前面的元素 <strong>至少 <code>n</code> 次</strong> (贪婪模式)。</td><td style="text-align:left"><code>\d&#123;2,&#125;</code></td><td style="text-align:left"><code>12</code>, <code>123</code>, <code>12345</code></td></tr><tr><td style="text-align:left"><code>&#123;n,m&#125;</code></td><td style="text-align:left">匹配前面的元素 <strong>至少 <code>n</code> 次，但不超过 <code>m</code> 次</strong> (贪婪模式)。</td><td style="text-align:left"><code>\d&#123;2,4&#125;</code></td><td style="text-align:left"><code>12</code>, <code>123</code>, <code>1234</code> (但不匹配 <code>1</code> 或 <code>12345</code>)</td></tr><tr><td style="text-align:left"><code>[]</code></td><td style="text-align:left"><strong>字符集</strong>。匹配方括号中包含的 <strong>任意一个</strong> 字符。</td><td style="text-align:left"><code>[abc]</code></td><td style="text-align:left"><code>a</code> 或 <code>b</code> 或 <code>c</code></td></tr><tr><td style="text-align:left"><code>[^...]</code></td><td style="text-align:left"><strong>否定字符集</strong>。匹配 <strong>不在</strong> 方括号中包含的任何字符。</td><td style="text-align:left"><code>[^0-9]</code></td><td style="text-align:left">任何非数字字符</td></tr><tr><td style="text-align:left"><code>\</code></td><td style="text-align:left"><strong>转义符</strong>。用于转义元字符，使其匹配其字面含义 (如 <code>\.</code> 匹配句点 <code>.</code>)，或用于引入特殊序列 (如 <code>\d</code>)。</td><td style="text-align:left"><code>\$</code></td><td style="text-align:left"><code>$</code> 字符本身</td></tr><tr><td style="text-align:left">`</td><td style="text-align:left">`</td><td style="text-align:left"><strong>或 (OR)</strong> 运算符。匹配 `</td><td style="text-align:left">` 左边或右边的表达式。</td></tr><tr><td style="text-align:left"><code>()</code></td><td style="text-align:left"><strong>分组</strong>。将括号内的表达式视为一个整体，用于应用量词、限制 `</td><td style="text-align:left">` 的范围，或 <strong>捕获</strong> 匹配的子字符串。</td><td style="text-align:left"><code>(ab)+</code></td></tr></tbody></table><p><strong>踩坑提示</strong>:</p><ul><li><strong>转义</strong>: 当需要匹配元字符本身时（如 <code>.</code>、<code>*</code>、<code>?</code>），必须在前面加上反斜杠 <code>\</code> 进行转义。例如，要匹配 IP 地址中的点，应使用 <code>\.</code>。</li><li><strong>原始字符串 (Raw Strings)</strong>: 在 Python 中定义正则表达式模式时，<strong>强烈建议</strong> 使用原始字符串（在字符串前加 <code>r</code>），如 <code>r&quot;\d+&quot;</code>。这可以避免 Python 解释器对反斜杠进行自身的转义，从而简化正则表达式的书写，尤其是包含很多 <code>\</code> 的模式。</li></ul><h4 id="13-5-2-特殊序列-预定义字符集">13.5.2 特殊序列 (预定义字符集)</h4><p><code>re</code> 模块提供了一些方便的特殊序列来代表常见的字符集：</p><table><thead><tr><th style="text-align:left">特殊序列</th><th style="text-align:left">描述</th><th style="text-align:left">等价于</th><th style="text-align:left">示例</th></tr></thead><tbody><tr><td style="text-align:left"><code>\d</code></td><td style="text-align:left">匹配任何 <strong>Unicode 数字</strong> 字符 (包括 [0-9] 和其他语言的数字)。</td><td style="text-align:left"><code>[0-9]</code> (ASCII)</td><td style="text-align:left"><code>1</code>, <code>5</code></td></tr><tr><td style="text-align:left"><code>\D</code></td><td style="text-align:left">匹配任何 <strong>非数字</strong> 字符。</td><td style="text-align:left"><code>[^0-9]</code> (ASCII)</td><td style="text-align:left"><code>a</code>, <code>_</code>, <code> </code></td></tr><tr><td style="text-align:left"><code>\s</code></td><td style="text-align:left">匹配任何 <strong>Unicode 空白</strong> 字符 (包括 <code> </code>、<code>\t</code>、<code>\n</code>、<code>\r</code>、<code>\f</code>、<code>\v</code> 等)。</td><td style="text-align:left"></td><td style="text-align:left"><code> </code>, <code>\t</code></td></tr><tr><td style="text-align:left"><code>\S</code></td><td style="text-align:left">匹配任何 <strong>非空白</strong> 字符。</td><td style="text-align:left"></td><td style="text-align:left"><code>a</code>, <code>1</code>, <code>.</code></td></tr><tr><td style="text-align:left"><code>\w</code></td><td style="text-align:left">匹配任何 <strong>Unicode 词语</strong> 字符 (字母、数字和下划线 <code>_</code>)。</td><td style="text-align:left"><code>[a-zA-Z0-9_]</code> (ASCII)</td><td style="text-align:left"><code>a</code>, <code>B</code>, <code>5</code>, <code>_</code></td></tr><tr><td style="text-align:left"><code>\W</code></td><td style="text-align:left">匹配任何 <strong>非词语</strong> 字符。</td><td style="text-align:left"><code>[^a-zA-Z0-9_]</code>(ASCII)</td><td style="text-align:left"><code>!</code>, <code> </code>, <code>@</code></td></tr><tr><td style="text-align:left"><code>\b</code></td><td style="text-align:left">匹配 <strong>词语边界</strong> (word boundary)。这是一个零宽度断言，匹配词语字符 (<code>\w</code>) 和非词语字符 (<code>\W</code>) 之间，或词语字符和字符串开头/结尾之间的位置。</td><td style="text-align:left"></td><td style="text-align:left"><code>\bword\b</code></td></tr><tr><td style="text-align:left"><code>\B</code></td><td style="text-align:left">匹配 <strong>非词语边界</strong>。</td><td style="text-align:left"></td><td style="text-align:left"><code>\Bword\B</code></td></tr></tbody></table><h4 id="13-5-3-贪婪模式-vs-非贪婪模式">13.5.3 贪婪模式 vs. 非贪婪模式</h4><p>默认情况下，量词 (<code>*</code>, <code>+</code>, <code>?</code>, <code>&#123;n,&#125;</code>, <code>&#123;n,m&#125;</code>) 都是 <strong>贪婪 (Greedy)</strong> 的，它们会尽可能多地匹配字符。</p><p><strong>场景</strong>: 从 HTML 标签 <code>&lt;b&gt;Bold Text&lt;/b&gt;</code> 中提取 <code>&lt;b&gt;</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></pre></td><td class="code"><pre><span class="line"><span class="keyword">import</span> re</span><br><span class="line"></span><br><span class="line">text = <span class="string">&quot;&lt;b&gt;Bold Text&lt;/b&gt; Regular Text &lt;b&gt;Another Bold&lt;/b&gt;&quot;</span></span><br><span class="line"></span><br><span class="line"><span class="comment"># 贪婪模式 (默认)</span></span><br><span class="line">greedy_pattern = <span class="string">r&quot;&lt;.*&gt;&quot;</span> <span class="comment"># . 匹配任何字符，* 匹配零次或多次</span></span><br><span class="line">match_greedy = re.search(greedy_pattern, text)</span><br><span class="line"><span class="keyword">if</span> match_greedy:</span><br><span class="line">    <span class="comment"># * 会一直匹配到字符串的最后一个 &gt;</span></span><br><span class="line">    <span class="built_in">print</span>(<span class="string">f&quot;贪婪匹配结果: <span class="subst">&#123;match_greedy.group(<span class="number">0</span>)&#125;</span>&quot;</span>)</span><br><span class="line">    <span class="comment"># 输出: 贪婪匹配结果: &lt;b&gt;Bold Text&lt;/b&gt; Regular Text &lt;b&gt;Another Bold&lt;/b&gt;</span></span><br><span class="line"></span><br><span class="line"><span class="comment"># 非贪婪模式 (在量词后加 ?)</span></span><br><span class="line">non_greedy_pattern = <span class="string">r&quot;&lt;.*?&gt;&quot;</span> <span class="comment"># *? 匹配零次或多次，但尽可能少地匹配</span></span><br><span class="line">match_non_greedy = re.search(non_greedy_pattern, text)</span><br><span class="line"><span class="keyword">if</span> match_non_greedy:</span><br><span class="line">    <span class="comment"># *? 遇到第一个 &gt; 就停止匹配</span></span><br><span class="line">    <span class="built_in">print</span>(<span class="string">f&quot;非贪婪匹配结果: <span class="subst">&#123;match_non_greedy.group(<span class="number">0</span>)&#125;</span>&quot;</span>)</span><br><span class="line">    <span class="comment"># 输出: 非贪婪匹配结果: &lt;b&gt;</span></span><br><span class="line"></span><br><span class="line"><span class="comment"># 查找所有非贪婪匹配</span></span><br><span class="line">all_matches_non_greedy = re.findall(non_greedy_pattern, text)</span><br><span class="line"><span class="built_in">print</span>(<span class="string">f&quot;所有非贪婪匹配: <span class="subst">&#123;all_matches_non_greedy&#125;</span>&quot;</span>)</span><br><span class="line"><span class="comment"># 输出: 所有非贪婪匹配: [&#x27;&lt;b&gt;&#x27;, &#x27;&lt;/b&gt;&#x27;, &#x27;&lt;b&gt;&#x27;, &#x27;&lt;/b&gt;&#x27;]</span></span><br></pre></td></tr></table></figure><p><strong>何时使用非贪婪模式？</strong></p><p>当需要匹配从某个开始标记到 <strong>最近的</strong> 结束标记之间的内容时，通常需要使用非贪婪量词 (<code>*?</code>, <code>+?</code>, <code>??</code>, <code>&#123;n,&#125;?</code>, <code>&#123;n,m&#125;?</code>)。</p><h4 id="13-5-4-分组与捕获">13.5.4 分组与捕获</h4><p>使用圆括号 <code>()</code> 可以将模式的一部分组合起来，形成一个 <strong>分组 (Group)</strong>。分组有几个重要作用：</p><ol><li><strong>应用量词</strong>: 将量词作用于整个分组，如 <code>(abc)+</code> 匹配 <code>abc</code>, <code>abcabc</code> 等。</li><li><strong>限制 <code>|</code> 范围</strong>: 如 <code>gr(a|e)y</code> 匹配 <code>gray</code> 或 <code>grey</code>。</li><li><strong>捕获内容</strong>: 默认情况下，每个分组会 <strong>捕获 (Capture)</strong> 其匹配到的子字符串，以便后续引用或提取。</li></ol><p><strong>场景</strong>: 从 “Name: John Doe, Age: 30” 中提取姓名和年龄。</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></pre></td><td class="code"><pre><span class="line"><span class="keyword">import</span> re</span><br><span class="line"></span><br><span class="line">text = <span class="string">&quot;Name: John Doe, Age: 30; Name: Jane Smith, Age: 25&quot;</span></span><br><span class="line"></span><br><span class="line"><span class="comment"># 定义带有捕获组的模式</span></span><br><span class="line"><span class="comment"># 第一个组 (\w+\s+\w+) 捕获姓名</span></span><br><span class="line"><span class="comment"># 第二个组 (\d+) 捕获年龄</span></span><br><span class="line">pattern_capture = <span class="string">r&quot;Name: (\w+\s+\w+), Age: (\d+)&quot;</span></span><br><span class="line"></span><br><span class="line"><span class="comment"># 使用 findall 查找所有匹配项</span></span><br><span class="line"><span class="comment"># findall 返回一个列表，如果模式中有捕获组，列表元素是包含所有捕获组内容的元组</span></span><br><span class="line">matches = re.findall(pattern_capture, text)</span><br><span class="line"><span class="built_in">print</span>(<span class="string">f&quot;\n--- 使用 findall 提取分组 ---&quot;</span>)</span><br><span class="line"><span class="built_in">print</span>(matches) <span class="comment"># 输出: [(&#x27;John Doe&#x27;, &#x27;30&#x27;), (&#x27;Jane Smith&#x27;, &#x27;25&#x27;)]</span></span><br><span class="line"></span><br><span class="line"><span class="comment"># 使用 finditer 获取 Match 对象，可以更灵活地访问分组</span></span><br><span class="line"><span class="built_in">print</span>(<span class="string">&quot;\n--- 使用 finditer 访问分组 ---&quot;</span>)</span><br><span class="line"><span class="keyword">for</span> match_obj <span class="keyword">in</span> re.finditer(pattern_capture, text):</span><br><span class="line">    <span class="comment"># match_obj.group(0) 或 group() 获取整个匹配</span></span><br><span class="line">    <span class="built_in">print</span>(<span class="string">f&quot;整个匹配: <span class="subst">&#123;match_obj.group(<span class="number">0</span>)&#125;</span>&quot;</span>)</span><br><span class="line">    <span class="comment"># match_obj.group(1) 获取第一个捕获组的内容 (姓名)</span></span><br><span class="line">    <span class="built_in">print</span>(<span class="string">f&quot;  姓名 (组 1): <span class="subst">&#123;match_obj.group(<span class="number">1</span>)&#125;</span>&quot;</span>)</span><br><span class="line">    <span class="comment"># match_obj.group(2) 获取第二个捕获组的内容 (年龄)</span></span><br><span class="line">    <span class="built_in">print</span>(<span class="string">f&quot;  年龄 (组 2): <span class="subst">&#123;match_obj.group(<span class="number">2</span>)&#125;</span>&quot;</span>)</span><br><span class="line">    <span class="comment"># match_obj.groups() 获取所有捕获组组成的元组</span></span><br><span class="line">    <span class="built_in">print</span>(<span class="string">f&quot;  所有分组: <span class="subst">&#123;match_obj.groups()&#125;</span>&quot;</span>)</span><br><span class="line"></span><br><span class="line"><span class="comment"># 非捕获组 (?:...)</span></span><br><span class="line"><span class="comment"># 如果只想分组而不捕获内容，可以使用非捕获组</span></span><br><span class="line">pattern_non_capture = <span class="string">r&quot;Name: (?:\w+\s+\w+), Age: (\d+)&quot;</span> <span class="comment"># 第一个组不捕获</span></span><br><span class="line">matches_nc = re.findall(pattern_non_capture, text)</span><br><span class="line"><span class="built_in">print</span>(<span class="string">f&quot;\n--- 使用非捕获组的 findall ---&quot;</span>)</span><br><span class="line"><span class="built_in">print</span>(matches_nc) <span class="comment"># 输出: [&#x27;30&#x27;, &#x27;25&#x27;] (只包含捕获组的内容)</span></span><br></pre></td></tr></table></figure><p><strong>反向引用 (Backreferences)</strong>: 可以在模式内部或替换字符串中使用 <code>\1</code>, <code>\2</code>, … 来引用前面捕获组匹配到的文本。</p><p><strong>场景</strong>: 查找重复的单词，如 “the the”。</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></pre></td><td class="code"><pre><span class="line">text_repeat = <span class="string">&quot;This is the the test sentence with repeated repeated words.&quot;</span></span><br><span class="line"><span class="comment"># \b 确保是完整的单词</span></span><br><span class="line"><span class="comment"># (\w+) 捕获第一个单词</span></span><br><span class="line"><span class="comment"># \s+ 匹配中间的空白</span></span><br><span class="line"><span class="comment"># \1 引用第一个捕获组匹配的内容</span></span><br><span class="line">pattern_repeat = <span class="string">r&quot;\b(\w+)\s+\1\b&quot;</span></span><br><span class="line">repeated_words = re.findall(pattern_repeat, text_repeat)</span><br><span class="line"><span class="built_in">print</span>(<span class="string">f&quot;\n--- 查找重复单词 ---&quot;</span>)</span><br><span class="line"><span class="built_in">print</span>(<span class="string">f&quot;找到的重复单词: <span class="subst">&#123;repeated_words&#125;</span>&quot;</span>) <span class="comment"># 输出: [&#x27;the&#x27;, &#x27;repeated&#x27;]</span></span><br><span class="line"></span><br><span class="line"><span class="comment"># 使用 sub 进行替换</span></span><br><span class="line"><span class="comment"># 将重复的单词替换为单个单词</span></span><br><span class="line">corrected_text = re.sub(pattern_repeat, <span class="string">r&quot;\1&quot;</span>, text_repeat) <span class="comment"># 使用 \1 引用捕获组</span></span><br><span class="line"><span class="built_in">print</span>(<span class="string">f&quot;修正后的文本: <span class="subst">&#123;corrected_text&#125;</span>&quot;</span>)</span><br><span class="line"><span class="comment"># 输出: This is the test sentence with repeated words.</span></span><br></pre></td></tr></table></figure><h4 id="13-5-5-re-模块核心函数">13.5.5 <code>re</code> 模块核心函数</h4><p>Python 的 <code>re</code> 模块提供了以下核心函数来执行正则表达式操作：</p><table><thead><tr><th style="text-align:left">函数</th><th style="text-align:left">描述</th><th style="text-align:left">返回值</th><th style="text-align:left">主要用途</th></tr></thead><tbody><tr><td style="text-align:left"><code>re.match(p, s, flags=0)</code></td><td style="text-align:left">从字符串 <code>s</code> 的 <strong>开头</strong> 尝试匹配模式 <code>p</code>。</td><td style="text-align:left">匹配成功返回 <code>Match</code> 对象，失败返回 <code>None</code>。</td><td style="text-align:left">验证字符串是否以特定模式开始。</td></tr><tr><td style="text-align:left"><code>re.search(p, s, flags=0)</code></td><td style="text-align:left">在 <strong>整个</strong> 字符串 <code>s</code> 中搜索模式 <code>p</code> 的 <strong>第一个</strong> 匹配项。</td><td style="text-align:left">匹配成功返回 <code>Match</code> 对象，失败返回 <code>None</code>。</td><td style="text-align:left">在字符串中查找模式是否存在，并获取第一个匹配项的信息。</td></tr><tr><td style="text-align:left"><code>re.findall(p, s, flags=0)</code></td><td style="text-align:left">在字符串 <code>s</code> 中查找模式 <code>p</code> 的 <strong>所有非重叠</strong> 匹配项。</td><td style="text-align:left">返回一个 <strong>列表</strong>。如果模式无捕获组，列表元素是匹配的字符串；如果有捕获组，列表元素是包含各捕获组内容的元组。</td><td style="text-align:left">提取字符串中所有符合模式的子串或捕获组内容。</td></tr><tr><td style="text-align:left"><code>re.finditer(p, s, flags=0)</code></td><td style="text-align:left">与 <code>findall</code> 类似，但返回一个 <strong>迭代器 (iterator)</strong>，迭代器中的每个元素都是一个 <code>Match</code> 对象。</td><td style="text-align:left">返回一个迭代器，每个元素是 <code>Match</code> 对象。</td><td style="text-align:left">处理大量匹配结果时更 <strong>内存高效</strong>，因为不需要一次性存储所有结果。可以方便地访问每个匹配的详细信息（如位置）。</td></tr><tr><td style="text-align:left"><code>re.sub(p, repl, s, count=0, flags=0)</code></td><td style="text-align:left">在字符串 <code>s</code> 中查找模式 <code>p</code> 的所有匹配项，并用 <code>repl</code> 替换它们。<code>repl</code> 可以是字符串（支持 <code>\g&lt;name&gt;</code> 或 <code>\1</code> 等反向引用）或函数。<code>count</code> 指定最大替换次数。</td><td style="text-align:left">返回替换后的 <strong>新字符串</strong>。</td><td style="text-align:left">执行查找和替换操作。<code>repl</code> 可以是函数，实现更复杂的替换逻辑。</td></tr><tr><td style="text-align:left"><code>re.split(p, s, maxsplit=0, flags=0)</code></td><td style="text-align:left">使用模式 <code>p</code> 作为分隔符来 <strong>分割</strong> 字符串 <code>s</code>。<code>maxsplit</code> 指定最大分割次数。</td><td style="text-align:left">返回一个 <strong>列表</strong>，包含分割后的子字符串。如果模式中有捕获组，捕获的内容也会包含在列表中。</td><td style="text-align:left">根据复杂的模式分割字符串。</td></tr><tr><td style="text-align:left"><code>re.compile(p, flags=0)</code></td><td style="text-align:left"><strong>编译</strong> 正则表达式模式 <code>p</code> 为一个 <strong>模式对象 (Pattern Object)</strong>。</td><td style="text-align:left">返回一个 <code>Pattern</code> 对象。</td><td style="text-align:left">当一个模式需要被 <strong>多次</strong> 使用时，预先编译可以 <strong>提高性能</strong>。模式对象拥有与 <code>re</code> 模块函数同名的方法（如 <code>pattern.search(s)</code>）。</td></tr></tbody></table><p><strong>代码示例</strong>:</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></pre></td><td class="code"><pre><span class="line"><span class="keyword">import</span> re</span><br><span class="line"></span><br><span class="line">text = <span class="string">&quot;The quick brown fox jumps over the lazy dog. Phone: 123-456-7890. Email: test@example.com.&quot;</span></span><br><span class="line"></span><br><span class="line"><span class="comment"># 1. re.match() - 检查开头</span></span><br><span class="line">pattern_start = <span class="string">r&quot;The&quot;</span></span><br><span class="line">match_result = re.<span class="keyword">match</span>(pattern_start, text)</span><br><span class="line"><span class="keyword">if</span> match_result:</span><br><span class="line">    <span class="built_in">print</span>(<span class="string">f&quot;match(): 字符串以 &#x27;<span class="subst">&#123;pattern_start&#125;</span>&#x27; 开头。匹配内容: &#x27;<span class="subst">&#123;match_result.group(<span class="number">0</span>)&#125;</span>&#x27;&quot;</span>)</span><br><span class="line"><span class="keyword">else</span>:</span><br><span class="line">    <span class="built_in">print</span>(<span class="string">f&quot;match(): 字符串不以 &#x27;<span class="subst">&#123;pattern_start&#125;</span>&#x27; 开头。&quot;</span>)</span><br><span class="line"></span><br><span class="line">match_fail = re.<span class="keyword">match</span>(<span class="string">r&quot;quick&quot;</span>, text) <span class="comment"># 不从开头匹配，所以失败</span></span><br><span class="line"><span class="built_in">print</span>(<span class="string">f&quot;match() 失败示例: <span class="subst">&#123;match_fail&#125;</span>&quot;</span>) <span class="comment"># None</span></span><br><span class="line"></span><br><span class="line"><span class="comment"># 2. re.search() - 查找第一个匹配</span></span><br><span class="line">pattern_word = <span class="string">r&quot;fox&quot;</span></span><br><span class="line">search_result = re.search(pattern_word, text)</span><br><span class="line"><span class="keyword">if</span> search_result:</span><br><span class="line">    <span class="built_in">print</span>(<span class="string">f&quot;search(): 找到单词 &#x27;<span class="subst">&#123;pattern_word&#125;</span>&#x27;。 起始位置: <span class="subst">&#123;search_result.start()&#125;</span>, 结束位置: <span class="subst">&#123;search_result.end()&#125;</span>&quot;</span>)</span><br><span class="line"><span class="keyword">else</span>:</span><br><span class="line">    <span class="built_in">print</span>(<span class="string">f&quot;search(): 未找到单词 &#x27;<span class="subst">&#123;pattern_word&#125;</span>&#x27;。&quot;</span>)</span><br><span class="line"></span><br><span class="line"><span class="comment"># 3. re.findall() - 查找所有匹配</span></span><br><span class="line">pattern_digits = <span class="string">r&quot;\d+&quot;</span> <span class="comment"># 查找所有数字序列</span></span><br><span class="line">all_digits = re.findall(pattern_digits, text)</span><br><span class="line"><span class="built_in">print</span>(<span class="string">f&quot;findall(): 找到的所有数字序列: <span class="subst">&#123;all_digits&#125;</span>&quot;</span>) <span class="comment"># [&#x27;123&#x27;, &#x27;456&#x27;, &#x27;7890&#x27;]</span></span><br><span class="line"></span><br><span class="line">pattern_email = <span class="string">r&quot;(\w+)@(\w+\.\w+)&quot;</span> <span class="comment"># 查找邮箱并捕获用户名和域名</span></span><br><span class="line">email_parts = re.findall(pattern_email, text)</span><br><span class="line"><span class="built_in">print</span>(<span class="string">f&quot;findall() 捕获组: <span class="subst">&#123;email_parts&#125;</span>&quot;</span>) <span class="comment"># [(&#x27;test&#x27;, &#x27;example.com&#x27;)]</span></span><br><span class="line"></span><br><span class="line"><span class="comment"># 4. re.finditer() - 迭代查找匹配对象</span></span><br><span class="line">pattern_words_o = <span class="string">r&quot;\b\w*o\w*\b&quot;</span> <span class="comment"># 查找所有包含字母&#x27;o&#x27;的单词</span></span><br><span class="line"><span class="built_in">print</span>(<span class="string">&quot;finditer(): 查找包含 &#x27;o&#x27; 的单词:&quot;</span>)</span><br><span class="line"><span class="keyword">for</span> <span class="keyword">match</span> <span class="keyword">in</span> re.finditer(pattern_words_o, text, re.IGNORECASE): <span class="comment"># 使用 IGNORECASE 标志</span></span><br><span class="line">    <span class="built_in">print</span>(<span class="string">f&quot;  找到: &#x27;<span class="subst">&#123;<span class="keyword">match</span>.group(<span class="number">0</span>)&#125;</span>&#x27; at position <span class="subst">&#123;<span class="keyword">match</span>.span()&#125;</span>&quot;</span>)</span><br><span class="line"></span><br><span class="line"><span class="comment"># 5. re.sub() - 替换</span></span><br><span class="line">pattern_phone = <span class="string">r&quot;\d&#123;3&#125;-\d&#123;3&#125;-\d&#123;4&#125;&quot;</span></span><br><span class="line"><span class="comment"># 将电话号码替换为 [REDACTED]</span></span><br><span class="line">censored_text = re.sub(pattern_phone, <span class="string">&quot;[REDACTED]&quot;</span>, text)</span><br><span class="line"><span class="built_in">print</span>(<span class="string">f&quot;sub() 替换电话号码: <span class="subst">&#123;censored_text&#125;</span>&quot;</span>)</span><br><span class="line"></span><br><span class="line"><span class="comment"># 使用函数进行替换</span></span><br><span class="line"><span class="keyword">def</span> <span class="title function_">mask_email</span>(<span class="params">match_obj</span>):</span><br><span class="line">    username = match_obj.group(<span class="number">1</span>)</span><br><span class="line">    domain = match_obj.group(<span class="number">2</span>)</span><br><span class="line">    <span class="keyword">return</span> <span class="string">f&quot;<span class="subst">&#123;username[<span class="number">0</span>]&#125;</span>***@<span class="subst">&#123;domain&#125;</span>&quot;</span> <span class="comment"># 用户名只显示第一个字符</span></span><br><span class="line"></span><br><span class="line">censored_email_text = re.sub(pattern_email, mask_email, text)</span><br><span class="line"><span class="built_in">print</span>(<span class="string">f&quot;sub() 使用函数替换邮箱: <span class="subst">&#123;censored_email_text&#125;</span>&quot;</span>)</span><br><span class="line"></span><br><span class="line"><span class="comment"># 6. re.split() - 分割</span></span><br><span class="line">pattern_punct = <span class="string">r&quot;[.,:;]\s*&quot;</span> <span class="comment"># 按标点符号和后面的空格分割</span></span><br><span class="line">parts = re.split(pattern_punct, text)</span><br><span class="line"><span class="built_in">print</span>(<span class="string">f&quot;split(): 按标点分割: <span class="subst">&#123;parts&#125;</span>&quot;</span>)</span><br><span class="line"></span><br><span class="line"><span class="comment"># 7. re.compile() - 编译模式</span></span><br><span class="line">compiled_pattern = re.<span class="built_in">compile</span>(<span class="string">r&quot;l\w*y&quot;</span>, re.IGNORECASE) <span class="comment"># 编译查找以l开头y结尾的词</span></span><br><span class="line"><span class="comment"># 多次使用编译后的模式</span></span><br><span class="line">match1 = compiled_pattern.search(text)</span><br><span class="line"><span class="keyword">if</span> match1:</span><br><span class="line">    <span class="built_in">print</span>(<span class="string">f&quot;compile() &amp; search(): 找到 &#x27;<span class="subst">&#123;match1.group(<span class="number">0</span>)&#125;</span>&#x27;&quot;</span>)</span><br><span class="line">match2 = compiled_pattern.findall(<span class="string">&quot;Actually, Lily is lovely.&quot;</span>)</span><br><span class="line"><span class="built_in">print</span>(<span class="string">f&quot;compile() &amp; findall(): 找到 <span class="subst">&#123;match2&#125;</span>&quot;</span>) <span class="comment"># [&#x27;Lily&#x27;, &#x27;lovely&#x27;]</span></span><br></pre></td></tr></table></figure><h4 id="13-5-6-Match-对象详解">13.5.6 Match 对象详解</h4><p>当 <code>re.match()</code>, <code>re.search()</code> 或 <code>re.finditer()</code> 中的一项成功匹配时，它们会返回一个 <strong><code>Match</code> 对象</strong>。这个对象包含了关于匹配结果的详细信息。</p><table><thead><tr><th style="text-align:left">Match 对象方法/属性</th><th style="text-align:left">描述</th><th style="text-align:left">示例 (假设 <code>m = re.search(r&quot;(\w+) (\d+)&quot;, &quot;Order P123 45&quot;)</code>)</th></tr></thead><tbody><tr><td style="text-align:left"><code>m.group(0)</code> 或 <code>m.group()</code></td><td style="text-align:left">返回整个匹配的字符串。</td><td style="text-align:left"><code>'P123 45'</code></td></tr><tr><td style="text-align:left"><code>m.group(n)</code></td><td style="text-align:left">返回第 <code>n</code> 个捕获组匹配的字符串 (从 1 开始计数)。</td><td style="text-align:left"><code>m.group(1)</code> 返回 <code>'P123'</code>, <code>m.group(2)</code> 返回 <code>'45'</code></td></tr><tr><td style="text-align:left"><code>m.groups()</code></td><td style="text-align:left">返回一个包含所有捕获组匹配内容的 <strong>元组</strong>。</td><td style="text-align:left"><code>('P123', '45')</code></td></tr><tr><td style="text-align:left"><code>m.groupdict()</code></td><td style="text-align:left">如果模式中使用了 <strong>命名捕获组</strong> <code>(?P&lt;name&gt;...)</code>，返回一个包含组名和匹配内容的字典。</td><td style="text-align:left">(需要命名组，如下例)</td></tr><tr><td style="text-align:left"><code>m.start([group])</code></td><td style="text-align:left">返回整个匹配或指定 <code>group</code> 的 <strong>起始索引</strong> (包含)。</td><td style="text-align:left"><code>m.start()</code> 返回 6, <code>m.start(1)</code> 返回 6, <code>m.start(2)</code> 返回 11</td></tr><tr><td style="text-align:left"><code>m.end([group])</code></td><td style="text-align:left">返回整个匹配或指定 <code>group</code> 的 <strong>结束索引</strong> (不包含)。</td><td style="text-align:left"><code>m.end()</code> 返回 13, <code>m.end(1)</code> 返回 10, <code>m.end(2)</code> 返回 13</td></tr><tr><td style="text-align:left"><code>m.span([group])</code></td><td style="text-align:left">返回一个包含 <code>(start, end)</code> 索引的 <strong>元组</strong>。</td><td style="text-align:left"><code>m.span()</code> 返回 <code>(6, 13)</code>, <code>m.span(1)</code> 返回 <code>(6, 10)</code></td></tr><tr><td style="text-align:left"><code>m.string</code></td><td style="text-align:left">传递给 <code>match()</code> 或 <code>search()</code> 的原始字符串。</td><td style="text-align:left"><code>'Order P123 45'</code></td></tr><tr><td style="text-align:left"><code>m.re</code></td><td style="text-align:left">匹配时使用的已编译的模式对象 (<code>Pattern</code> object)。</td><td style="text-align:left"></td></tr></tbody></table><p><strong>命名捕获组示例</strong>:</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></pre></td><td class="code"><pre><span class="line"><span class="keyword">import</span> re</span><br><span class="line"></span><br><span class="line">text = <span class="string">&quot;Product ID: ABC-987, Quantity: 50&quot;</span></span><br><span class="line"><span class="comment"># 使用 ?P&lt;name&gt; 定义命名捕获组</span></span><br><span class="line">pattern_named = <span class="string">r&quot;Product ID: (?P&lt;product_id&gt;[A-Z]+-\d+), Quantity: (?P&lt;quantity&gt;\d+)&quot;</span></span><br><span class="line"></span><br><span class="line"><span class="keyword">match</span> = re.search(pattern_named, text)</span><br><span class="line"><span class="keyword">if</span> <span class="keyword">match</span>:</span><br><span class="line">    <span class="built_in">print</span>(<span class="string">&quot;\n--- 使用命名捕获组 ---&quot;</span>)</span><br><span class="line">    <span class="comment"># 通过组名访问捕获的内容</span></span><br><span class="line">    <span class="built_in">print</span>(<span class="string">f&quot;产品 ID: <span class="subst">&#123;<span class="keyword">match</span>.group(<span class="string">&#x27;product_id&#x27;</span>)&#125;</span>&quot;</span>) <span class="comment"># ABC-987</span></span><br><span class="line">    <span class="built_in">print</span>(<span class="string">f&quot;数量: <span class="subst">&#123;<span class="keyword">match</span>.group(<span class="string">&#x27;quantity&#x27;</span>)&#125;</span>&quot;</span>)   <span class="comment"># 50</span></span><br><span class="line">    <span class="comment"># groupdict() 返回包含所有命名组的字典</span></span><br><span class="line">    <span class="built_in">print</span>(<span class="string">f&quot;捕获字典: <span class="subst">&#123;<span class="keyword">match</span>.groupdict()&#125;</span>&quot;</span>) <span class="comment"># &#123;&#x27;product_id&#x27;: &#x27;ABC-987&#x27;, &#x27;quantity&#x27;: &#x27;50&#x27;&#125;</span></span><br></pre></td></tr></table></figure><h4 id="13-5-7-正则表达式标志-Flags">13.5.7 正则表达式标志 (Flags)</h4><p>标志可以修改正则表达式的匹配行为。可以在 <code>re</code> 函数的 <code>flags</code> 参数中指定，或在编译时指定。多个标志可以使用 <code>|</code> (按位或) 组合。</p><table><thead><tr><th style="text-align:left">标志</th><th style="text-align:left">简写</th><th style="text-align:left">描述</th></tr></thead><tbody><tr><td style="text-align:left"><code>re.IGNORECASE</code></td><td style="text-align:left"><code>re.I</code></td><td style="text-align:left">进行 <strong>不区分大小写</strong> 的匹配。</td></tr><tr><td style="text-align:left"><code>re.MULTILINE</code></td><td style="text-align:left"><code>re.M</code></td><td style="text-align:left">使 <code>^</code> 和 <code>$</code> 匹配 <strong>每行的开头和结尾</strong>，而不仅仅是整个字符串的开头和结尾。</td></tr><tr><td style="text-align:left"><code>re.DOTALL</code></td><td style="text-align:left"><code>re.S</code></td><td style="text-align:left">使元字符 <code>.</code> 能够匹配 <strong>包括换行符 <code>\n</code> 在内</strong> 的任何字符。</td></tr><tr><td style="text-align:left"><code>re.VERBOSE</code></td><td style="text-align:left"><code>re.X</code></td><td style="text-align:left"><strong>详细模式</strong>。允许在模式字符串中添加 <strong>空白和注释</strong> 以提高可读性，此时模式中的空白会被忽略，<code>#</code> 后到行尾的内容视为注释。</td></tr><tr><td style="text-align:left"><code>re.ASCII</code></td><td style="text-align:left"><code>re.A</code></td><td style="text-align:left">使 <code>\w</code>, <code>\W</code>, <code>\b</code>, <code>\B</code>, <code>\s</code>, <code>\S</code> 只匹配 ASCII 字符，而不是完整的 Unicode 字符集 (Python 3 默认匹配 Unicode)。</td></tr><tr><td style="text-align:left"><code>re.UNICODE</code> (默认)</td><td style="text-align:left"><code>re.U</code></td><td style="text-align:left">使 <code>\w</code>, <code>\W</code>, <code>\b</code>, <code>\B</code>, <code>\s</code>, <code>\S</code>, <code>\d</code>, <code>\D</code> 匹配完整的 Unicode 字符集。这是 Python 3 的默认行为。</td></tr></tbody></table><p><strong>示例</strong>:</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></pre></td><td class="code"><pre><span class="line"><span class="keyword">import</span> re</span><br><span class="line"></span><br><span class="line">text_multi = <span class="string">&quot;&quot;&quot;first line</span></span><br><span class="line"><span class="string">second line</span></span><br><span class="line"><span class="string">THIRD line&quot;&quot;&quot;</span></span><br><span class="line"></span><br><span class="line"><span class="comment"># re.I (忽略大小写)</span></span><br><span class="line"><span class="built_in">print</span>(<span class="string">f&quot;\n--- Flags 示例 ---&quot;</span>)</span><br><span class="line"><span class="built_in">print</span>(<span class="string">f&quot;re.I: <span class="subst">&#123;re.findall(<span class="string">r&#x27;line&#x27;</span>, text_multi, re.IGNORECASE)&#125;</span>&quot;</span>) <span class="comment"># [&#x27;line&#x27;, &#x27;line&#x27;, &#x27;line&#x27;]</span></span><br><span class="line"></span><br><span class="line"><span class="comment"># re.M (多行模式)</span></span><br><span class="line"><span class="built_in">print</span>(<span class="string">f&quot;re.M (^): <span class="subst">&#123;re.findall(<span class="string">r&#x27;^s.*&#x27;</span>, text_multi, re.MULTILINE | re.IGNORECASE)&#125;</span>&quot;</span>) <span class="comment"># [&#x27;second line&#x27;]</span></span><br><span class="line"><span class="built_in">print</span>(<span class="string">f&quot;re.M ($): <span class="subst">&#123;re.findall(<span class="string">r&#x27;line$&#x27;</span>, text_multi, re.MULTILINE | re.IGNORECASE)&#125;</span>&quot;</span>) <span class="comment"># [&#x27;line&#x27;, &#x27;line&#x27;, &#x27;line&#x27;]</span></span><br><span class="line"></span><br><span class="line"><span class="comment"># re.S (DOTALL)</span></span><br><span class="line">text_dot = <span class="string">&quot;Hello\nWorld&quot;</span></span><br><span class="line"><span class="built_in">print</span>(<span class="string">f&quot;re.S (.): <span class="subst">&#123;re.search(<span class="string">r&#x27;Hello.World&#x27;</span>, text_dot, re.DOTALL)&#125;</span>&quot;</span>) <span class="comment"># 匹配成功</span></span><br><span class="line"><span class="built_in">print</span>(<span class="string">f&quot;No re.S (.): <span class="subst">&#123;re.search(<span class="string">r&#x27;Hello.World&#x27;</span>, text_dot)&#125;</span>&quot;</span>)      <span class="comment"># 匹配失败 (None)</span></span><br><span class="line"></span><br><span class="line"><span class="comment"># re.X (VERBOSE)</span></span><br><span class="line"><span class="comment"># 一个复杂的邮箱模式，使用 VERBOSE 模式添加注释和空格</span></span><br><span class="line">pattern_verbose = <span class="string">r&quot;&quot;&quot;</span></span><br><span class="line"><span class="string">  ^                  # 匹配字符串开头</span></span><br><span class="line"><span class="string">  [\w\.\-]+          # 用户名部分 (字母、数字、下划线、点、连字符)</span></span><br><span class="line"><span class="string">  @                  # @ 符号</span></span><br><span class="line"><span class="string">  ([\w\-]+\.)+       # 域名部分 (允许子域名，如 mail.example.)</span></span><br><span class="line"><span class="string">  [a-zA-Z]&#123;2,7&#125;      # 顶级域名 (如 .com, .org)</span></span><br><span class="line"><span class="string">  $                  # 匹配字符串结尾</span></span><br><span class="line"><span class="string">&quot;&quot;&quot;</span></span><br><span class="line">email = <span class="string">&quot;test.user-1@sub.example.com&quot;</span></span><br><span class="line">match_verbose = re.<span class="keyword">match</span>(pattern_verbose, email, re.VERBOSE)</span><br><span class="line"><span class="built_in">print</span>(<span class="string">f&quot;re.X (VERBOSE): <span class="subst">&#123;<span class="string">&#x27;匹配成功&#x27;</span> <span class="keyword">if</span> match_verbose <span class="keyword">else</span> <span class="string">&#x27;匹配失败&#x27;</span>&#125;</span>&quot;</span>) <span class="comment"># 匹配成功</span></span><br></pre></td></tr></table></figure><h4 id="13-5-8-实际应用场景示例">13.5.8 实际应用场景示例</h4><p><strong>场景 1: 验证中国大陆手机号 (简单示例)</strong></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></pre></td><td class="code"><pre><span class="line"><span class="keyword">import</span> re</span><br><span class="line"></span><br><span class="line"><span class="keyword">def</span> <span class="title function_">is_valid_china_mobile</span>(<span class="params">phone_number: <span class="built_in">str</span></span>) -&gt; <span class="built_in">bool</span>:</span><br><span class="line">    <span class="string">&quot;&quot;&quot;简单验证中国大陆手机号码 (11位数字，常见号段)&quot;&quot;&quot;</span></span><br><span class="line">    <span class="comment"># 模式解释:</span></span><br><span class="line">    <span class="comment"># ^            匹配字符串开头</span></span><br><span class="line">    <span class="comment"># (?:...)      非捕获组</span></span><br><span class="line">    <span class="comment"># 1[3-9]       第一位是1，第二位是3到9</span></span><br><span class="line">    <span class="comment"># \d&#123;9&#125;        后面跟9位数字</span></span><br><span class="line">    <span class="comment"># $            匹配字符串结尾</span></span><br><span class="line">    pattern = <span class="string">r&quot;^(?:1[3-9])\d&#123;9&#125;$&quot;</span></span><br><span class="line">    <span class="keyword">if</span> re.<span class="keyword">match</span>(pattern, phone_number):</span><br><span class="line">        <span class="keyword">return</span> <span class="literal">True</span></span><br><span class="line">    <span class="keyword">else</span>:</span><br><span class="line">        <span class="keyword">return</span> <span class="literal">False</span></span><br><span class="line"></span><br><span class="line"><span class="built_in">print</span>(<span class="string">&quot;\n--- 手机号验证 ---&quot;</span>)</span><br><span class="line"><span class="built_in">print</span>(<span class="string">f&quot;13812345678: <span class="subst">&#123;is_valid_china_mobile(<span class="string">&#x27;13812345678&#x27;</span>)&#125;</span>&quot;</span>) <span class="comment"># True</span></span><br><span class="line"><span class="built_in">print</span>(<span class="string">f&quot;12012345678: <span class="subst">&#123;is_valid_china_mobile(<span class="string">&#x27;12012345678&#x27;</span>)&#125;</span>&quot;</span>) <span class="comment"># False (号段不对)</span></span><br><span class="line"><span class="built_in">print</span>(<span class="string">f&quot;1381234567: <span class="subst">&#123;is_valid_china_mobile(<span class="string">&#x27;1381234567&#x27;</span>)&#125;</span>&quot;</span>)  <span class="comment"># False (位数不够)</span></span><br><span class="line"><span class="built_in">print</span>(<span class="string">f&quot;138123456789: <span class="subst">&#123;is_valid_china_mobile(<span class="string">&#x27;138123456789&#x27;</span>)&#125;</span>&quot;</span>)<span class="comment"># False (位数太多)</span></span><br></pre></td></tr></table></figure><p><em>注意</em>: 实际手机号验证可能需要更复杂的规则或查询号段数据库。</p><p><strong>场景 2: 从 Apache/Nginx 日志中提取 IP 地址和请求路径</strong></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></pre></td><td class="code"><pre><span class="line"><span class="keyword">import</span> re</span><br><span class="line"></span><br><span class="line">log_line = <span class="string">&#x27;192.168.1.101 - - [03/May/2025:17:20:01 +0900] &quot;GET /index.html HTTP/1.1&quot; 200 1542 &quot;-&quot; &quot;Mozilla/5.0...&quot;&#x27;</span></span><br><span class="line"></span><br><span class="line"><span class="comment"># 模式解释:</span></span><br><span class="line"><span class="comment"># ^([\d\.]+)      捕获开头的 IP 地址 (数字和点的组合)</span></span><br><span class="line"><span class="comment"># \s+-\s+-\s+      匹配中间的 &#x27; - - &#x27; 部分</span></span><br><span class="line"><span class="comment"># \[.*?\]        匹配并忽略方括号内的时间戳 (非贪婪)</span></span><br><span class="line"><span class="comment"># \s+&quot;           匹配时间戳后的空格和双引号</span></span><br><span class="line"><span class="comment"># (GET|POST|PUT|DELETE|HEAD) \s+  捕获请求方法 (GET, POST 等) 和空格</span></span><br><span class="line"><span class="comment"># ([^\s&quot;]+)      捕获请求路径 (非空格、非双引号的字符)</span></span><br><span class="line"><span class="comment"># \s+HTTP/[\d\.]+&quot; 捕获 HTTP 版本部分</span></span><br><span class="line"><span class="comment"># .* 匹配剩余部分</span></span><br><span class="line">pattern_log = <span class="string">r&#x27;^([\d\.]+) \s+-\s+-\s+ \[.*?\] \s+&quot;(GET|POST|PUT|DELETE|HEAD)\s+([^\s&quot;]+)\s+HTTP/[\d\.]+&quot; .*&#x27;</span></span><br><span class="line"></span><br><span class="line"><span class="keyword">match</span> = re.<span class="keyword">match</span>(pattern_log, log_line)</span><br><span class="line"><span class="keyword">if</span> <span class="keyword">match</span>:</span><br><span class="line">    ip_address = <span class="keyword">match</span>.group(<span class="number">1</span>)</span><br><span class="line">    method = <span class="keyword">match</span>.group(<span class="number">2</span>)</span><br><span class="line">    path = <span class="keyword">match</span>.group(<span class="number">3</span>)</span><br><span class="line">    <span class="built_in">print</span>(<span class="string">&quot;\n--- 日志解析 ---&quot;</span>)</span><br><span class="line">    <span class="built_in">print</span>(<span class="string">f&quot;IP 地址: <span class="subst">&#123;ip_address&#125;</span>&quot;</span>) <span class="comment"># 192.168.1.101</span></span><br><span class="line">    <span class="built_in">print</span>(<span class="string">f&quot;请求方法: <span class="subst">&#123;method&#125;</span>&quot;</span>)   <span class="comment"># GET</span></span><br><span class="line">    <span class="built_in">print</span>(<span class="string">f&quot;请求路径: <span class="subst">&#123;path&#125;</span>&quot;</span>)     <span class="comment"># /index.html</span></span><br><span class="line"><span class="keyword">else</span>:</span><br><span class="line">    <span class="built_in">print</span>(<span class="string">&quot;日志格式不匹配&quot;</span>)</span><br></pre></td></tr></table></figure><p><strong>场景 3: 将 Markdown 样式的链接 <code>[text](url)</code> 转换为 HTML <code>&lt;a&gt;</code> 标签</strong></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></pre></td><td class="code"><pre><span class="line"><span class="keyword">import</span> re</span><br><span class="line"></span><br><span class="line">markdown_text = <span class="string">&quot;这是一个链接 [Google](https://www.google.com) 和另一个 [Python 官网](http://python.org) 的例子。&quot;</span></span><br><span class="line"></span><br><span class="line"><span class="comment"># 模式解释:</span></span><br><span class="line"><span class="comment"># \[        匹配字面量 &#x27;[&#x27;</span></span><br><span class="line"><span class="comment"># ([^\]]+)  捕获链接文本 (不是 &#x27;]&#x27; 的任意字符一次或多次)</span></span><br><span class="line"><span class="comment"># \]        匹配字面量 &#x27;]&#x27;</span></span><br><span class="line"><span class="comment"># \(        匹配字面量 &#x27;(&#x27;</span></span><br><span class="line"><span class="comment"># ([^\)]+)  捕获 URL (不是 &#x27;)&#x27; 的任意字符一次或多次)</span></span><br><span class="line"><span class="comment"># \)        匹配字面量 &#x27;)&#x27;</span></span><br><span class="line">pattern_md_link = <span class="string">r&#x27;\[([^\]]+)\]\(([^\)]+)\)&#x27;</span></span><br><span class="line"></span><br><span class="line"><span class="comment"># 使用 re.sub 和反向引用 \1, \2 进行替换</span></span><br><span class="line">html_text = re.sub(pattern_md_link, <span class="string">r&#x27;&lt;a href=&quot;\2&quot;&gt;\1&lt;/a&gt;&#x27;</span>, markdown_text)</span><br><span class="line"></span><br><span class="line"><span class="built_in">print</span>(<span class="string">&quot;\n--- Markdown 转 HTML 链接 ---&quot;</span>)</span><br><span class="line"><span class="built_in">print</span>(<span class="string">f&quot;原始 Markdown: <span class="subst">&#123;markdown_text&#125;</span>&quot;</span>)</span><br><span class="line"><span class="built_in">print</span>(<span class="string">f&quot;转换后 HTML: <span class="subst">&#123;html_text&#125;</span>&quot;</span>)</span><br><span class="line"><span class="comment"># 输出: 这是一个链接 &lt;a href=&quot;https://www.google.com&quot;&gt;Google&lt;/a&gt; 和另一个 &lt;a href=&quot;http://python.org&quot;&gt;Python 官网&lt;/a&gt; 的例子。</span></span><br></pre></td></tr></table></figure></div>]]></content>
    
    
      
      
        
        
    <summary type="html">&lt;div id=&quot;postchat_postcontent&quot;&gt;&lt;h2 id=&quot;第十三章：-高级数据处理&quot;&gt;第十三章： 高级数据处理&lt;/h2&gt;
&lt;p&gt;Python 提供了多种处理不同类型数据的工具和库，能够轻松处理结构化和非结构化数据。本章将深入探讨 Python</summary>
        
      
    
    
    
    <category term="后端技术" scheme="https://prorise666.site/categories/%E5%90%8E%E7%AB%AF%E6%8A%80%E6%9C%AF/"/>
    
    <category term="Python" scheme="https://prorise666.site/categories/%E5%90%8E%E7%AB%AF%E6%8A%80%E6%9C%AF/Python/"/>
    
    <category term="Python 基础系列" scheme="https://prorise666.site/categories/%E5%90%8E%E7%AB%AF%E6%8A%80%E6%9C%AF/Python/Python-%E5%9F%BA%E7%A1%80%E7%B3%BB%E5%88%97/"/>
    
    
    <category term="Python 基础篇" scheme="https://prorise666.site/tags/Python-%E5%9F%BA%E7%A1%80%E7%AF%87/"/>
    
  </entry>
  
</feed>
