<?xml version="1.0" encoding="utf-8"?><?xml-stylesheet type="text/xsl" href="atom.xsl"?>
<feed xmlns="http://www.w3.org/2005/Atom">
    <id>https://blog.xiaoxiaoliang.top/en/blog</id>
    <title>小小亮的个人网站 Blog</title>
    <updated>2026-04-14T00:00:00.000Z</updated>
    <generator>https://github.com/jpmonette/feed</generator>
    <link rel="alternate" href="https://blog.xiaoxiaoliang.top/en/blog"/>
    <subtitle>小小亮的个人网站 Blog</subtitle>
    <icon>https://blog.xiaoxiaoliang.top/en/img/favicon.ico</icon>
    <entry>
        <title type="html"><![CDATA[深度解析 G1 垃圾收集器：化整为零，打破延迟与吞吐量的博弈]]></title>
        <id>https://blog.xiaoxiaoliang.top/en/blog/g1-garbage-collector</id>
        <link href="https://blog.xiaoxiaoliang.top/en/blog/g1-garbage-collector"/>
        <updated>2026-04-14T00:00:00.000Z</updated>
        <summary type="html"><![CDATA[在 JDK 1.7 时代作为重要进化特征登场，并最终在 JDK 9 中成功上位成为默认垃圾收集器，G1（Garbage-First）无疑是 JVM 垃圾回收史上的一个巨大里程碑。]]></summary>
        <content type="html"><![CDATA[<p>在 JDK 1.7 时代作为重要进化特征登场，并最终在 JDK 9 中成功上位成为<strong>默认垃圾收集器</strong>，G1（Garbage-First）无疑是 JVM 垃圾回收史上的一个巨大里程碑。</p>
<!-- -->
<p>G1 是一款专为<strong>配备多颗处理器及大容量内存的服务端机器</strong>量身打造的收集器。它的终极目标是：<strong>在极高概率满足设定的 GC 停顿时间要求的同时，依然维持高吞吐量的性能特征。</strong> 要想彻底搞懂 G1，我们需要从它的四大核心特点、特殊的内存布局，以及它独有的两种回收模式来层层剖析。</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="一-g1-的四大核心基石">一、 G1 的四大核心基石<a href="https://blog.xiaoxiaoliang.top/en/blog/g1-garbage-collector#%E4%B8%80-g1-%E7%9A%84%E5%9B%9B%E5%A4%A7%E6%A0%B8%E5%BF%83%E5%9F%BA%E7%9F%B3" class="hash-link" aria-label="Direct link to 一、 G1 的四大核心基石" title="Direct link to 一、 G1 的四大核心基石" translate="no">​</a></h2>
<p>G1 能够取代 CMS，主要归功于它在设计上的四个维度的全面进化：</p>
<ol>
<li class=""><strong>并行与并发</strong>：G1 能充分压榨多核 CPU 的硬件优势。在需要 STW（Stop-The-World）的阶段，它使用多个 GC 线程并行工作来缩短停顿时间；而在耗时最长的标记阶段，G1 能让 GC 线程与 Java 用户线程并发执行。</li>
<li class=""><strong>保留分代收集，但打破物理隔离</strong>：G1 不需要其他收集器配合就能独立管理整个堆内存。它依然保留了新生代（Eden/Survivor）和老年代的概念，但在物理上，这些代不再是连续的内存块。</li>
<li class=""><strong>空间整合（告别内存碎片）</strong>：</li>
</ol>
<ul>
<li class=""><strong>从整体看</strong>：G1 是基于“标记-整理”算法实现的，不会产生内存碎片。</li>
<li class=""><strong>从局部看（两个 Region 之间）</strong>：它是基于“标记-复制”算法实现的。这就保证了 G1 运行期间能提供规整的可用内存，极大降低了因为分配大对象找不到连续空间而被迫触发 Full GC 的概率。</li>
</ul>
<ol start="4">
<li class=""><strong>可预测的停顿模型（最大杀手锏）</strong>：G1 允许使用者明确指定一个目标：<strong>期望每次垃圾回收的最大暂停时间不能超过 N 毫秒</strong>（通过参数 <code>-XX:MaxGCPauseMillis=n</code> 配置，默认 200ms）。G1 会在后台建立时间预测模型，尽可能地保证在这个时间限制内完成回收。</li>
</ol>
<hr>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="二-内存布局化整为零的-region-设计">二、 内存布局：化整为零的 Region 设计<a href="https://blog.xiaoxiaoliang.top/en/blog/g1-garbage-collector#%E4%BA%8C-%E5%86%85%E5%AD%98%E5%B8%83%E5%B1%80%E5%8C%96%E6%95%B4%E4%B8%BA%E9%9B%B6%E7%9A%84-region-%E8%AE%BE%E8%AE%A1" class="hash-link" aria-label="Direct link to 二、 内存布局：化整为零的 Region 设计" title="Direct link to 二、 内存布局：化整为零的 Region 设计" translate="no">​</a></h2>
<p>为了实现“可预测的停顿”，G1 彻底颠覆了传统的内存布局。</p>
<p>它将整个 Java 堆划分为了数千个大小相等的独立区域——<strong>Region</strong>。
每一个 Region 都可以根据需要，动态地扮演 <strong>Eden 区</strong>、<strong>Survivor 区</strong> 或 <strong>Old（老年代）区</strong>。此外，G1 还专门设计了一种 <strong>Humongous 区</strong>，用于专门存储体积庞大的大对象（大小超过一个 Region 容量 50% 的对象）。</p>
<p>这种“把内存化整为零”的设计，让 G1 能够以 Region 为最小回收单元，避免了每次都去扫描和清理整个堆。</p>
<hr>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="三-g1-的两种核心回收模式">三、 G1 的两种核心回收模式<a href="https://blog.xiaoxiaoliang.top/en/blog/g1-garbage-collector#%E4%B8%89-g1-%E7%9A%84%E4%B8%A4%E7%A7%8D%E6%A0%B8%E5%BF%83%E5%9B%9E%E6%94%B6%E6%A8%A1%E5%BC%8F" class="hash-link" aria-label="Direct link to 三、 G1 的两种核心回收模式" title="Direct link to 三、 G1 的两种核心回收模式" translate="no">​</a></h2>
<p>结合文档中的深入剖析，G1 实际的运行绝不是简单的“一刀切”，而是主要分为两种回收方式：<strong>Young GC（年轻代回收）</strong> 和 <strong>Mixed GC（混合回收）</strong>。</p>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="1-young-gc年轻代回收">1. Young GC（年轻代回收）<a href="https://blog.xiaoxiaoliang.top/en/blog/g1-garbage-collector#1-young-gc%E5%B9%B4%E8%BD%BB%E4%BB%A3%E5%9B%9E%E6%94%B6" class="hash-link" aria-label="Direct link to 1. Young GC（年轻代回收）" title="Direct link to 1. Young GC（年轻代回收）" translate="no">​</a></h3>
<p>当新生代（主要是 Eden 区）的内存占比达到阈值（最大默认 60%），无法再为新对象分配空间时，就会触发 Young GC。这个过程会发生短暂的 <strong>STW</strong>。</p>
<p><strong>微观执行流程：</strong></p>
<ol>
<li class=""><strong>触发与标记</strong>：新创建的对象都在 Eden 区。触发 GC 后，G1 首先会标记出 Eden 和 Survivor 区域中的所有存活对象。</li>
<li class=""><strong>动态筛选与复制</strong>：这是 G1 最智能的地方！G1 在后台会记录以往每次回收 Eden 和 Survivor 区的平均耗时。</li>
</ol>
<blockquote>
<p><strong>举个硬核的例子：</strong>
假设你配置了 <code>-XX:MaxGCPauseMillis=200</code>（最大停顿 200ms），而 G1 历史统计得出回收一个 Region 平均需要 40ms。那么 G1 经过精确计算，这次回收<strong>最多只能回收 4 个 Region</strong>（40ms * 4 = 160ms &lt; 200ms），以保证不违背停顿时间的承诺。</p>
</blockquote>
<ol start="3">
<li class=""><strong>转移与清空</strong>：根据计算出的数量，G1 选择部分 Region，将里面的存活对象<strong>复制</strong>到一个新的 Survivor 区中（并且对象年龄 +1），然后直接清空原来的旧 Region 区域。后续的 Young GC 会不断重复这个过程，直到对象年龄达标晋升到老年代。</li>
</ol>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="2-混合回收mixed-gc的四大步骤">2. 混合回收（Mixed GC）的四大步骤<a href="https://blog.xiaoxiaoliang.top/en/blog/g1-garbage-collector#2-%E6%B7%B7%E5%90%88%E5%9B%9E%E6%94%B6mixed-gc%E7%9A%84%E5%9B%9B%E5%A4%A7%E6%AD%A5%E9%AA%A4" class="hash-link" aria-label="Direct link to 2. 混合回收（Mixed GC）的四大步骤" title="Direct link to 2. 混合回收（Mixed GC）的四大步骤" translate="no">​</a></h3>
<p>当老年代的堆空间占用达到一定阈值（默认 45%）时，会触发并发标记周期，并最终进入 Mixed GC 阶段。Mixed GC 不仅回收整个年轻代，还会回收<strong>部分老年代</strong>。</p>
<p>它的运作大致分为以下四个经典步骤：</p>
<ol>
<li class=""><strong>初始标记（Initial Mark）</strong>：<strong>短暂停顿（STW）</strong>。仅仅标记从 GC Roots 能直接引用的活跃对象。由于通常借用 Young GC 的 STW 同步完成，所以速度极快。</li>
<li class=""><strong>并发标记（Concurrent Mark）</strong>：<strong>与应用并发运行</strong>。从 GC Roots 的直接引用开始，扫描整个堆，标记所有可达对象。耗时较长，但不会阻塞用户线程。</li>
<li class=""><strong>最终标记（Final Mark / Remark）</strong>：<strong>短暂停顿（STW）</strong>。处理并发标记期间，因为用户线程继续运行而导致引用关系发生变化的残留对象（G1 这里使用的是 SATB 算法）。</li>
<li class=""><strong>筛选回收（Cleanup / Evacuation）</strong>：<strong>短暂停顿（STW）</strong>。这是体现 <strong>Garbage-First（垃圾优先）</strong> 名字由来的核心阶段！</li>
</ol>
<ul>
<li class="">G1 会根据之前并发标记的结果，计算每个 Region 的垃圾占比。</li>
<li class="">它在后台维护了一个<strong>优先列表</strong>，根据用户允许的收集时间（<code>MaxGCPauseMillis</code>），<strong>优先选择回收价值最大（垃圾最多、存活对象最少）的老年代 Region</strong> 进行回收。</li>
<li class="">把选中 Region 中的存活对象复制到新区域，然后清空旧区域。</li>
</ul>
<hr>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="四-g1-的代价与局限性">四、 G1 的代价与局限性<a href="https://blog.xiaoxiaoliang.top/en/blog/g1-garbage-collector#%E5%9B%9B-g1-%E7%9A%84%E4%BB%A3%E4%BB%B7%E4%B8%8E%E5%B1%80%E9%99%90%E6%80%A7" class="hash-link" aria-label="Direct link to 四、 G1 的代价与局限性" title="Direct link to 四、 G1 的代价与局限性" translate="no">​</a></h2>
<p>世界上没有免费的午餐，G1 为了实现这种高效率和可预测性，也付出了不小的底层代价：</p>
<ul>
<li class=""><strong>极高的内存占用</strong>：为了解决 Region 之间的跨代引用问题，每个 Region 必须维护一个叫 <strong>Remembered Set (RSet)</strong> 的数据结构，记录“谁引用了我”。在堆被切成几千个 Region 的情况下，G1 收集器维护这些 RSet 需要消耗堆内存容量的 <strong>10% ~ 20%</strong>。</li>
<li class=""><strong>CPU 负载负担</strong>：为了在并发运行时维护复杂的 RSet 更新，G1 在给对象赋值的底层操作中加入了<strong>写屏障（Write Barrier）</strong>，这些额外的计算逻辑会消耗更多的 CPU 资源。</li>
</ul>
<hr>
<p><strong>总结：</strong>
G1 的出现，完美诠释了“以空间换时间”和“化整为零”的架构哲学。它通过精确掌控 Region 的回收收益和耗时，让 Java 终于拥有了一款在吞吐量与低延迟之间取得极佳平衡的现代垃圾收集器。</p>]]></content>
        <author>
            <name>小小亮</name>
            <uri>https://github.com/xiaoxiaoliang</uri>
        </author>
        <category label="Java" term="Java"/>
        <category label="JVM" term="JVM"/>
    </entry>
    <entry>
        <title type="html"><![CDATA[🚀 实战解析：如何在 Redis 中优雅实现「距离 + 活跃度」的多维混合排序？]]></title>
        <id>https://blog.xiaoxiaoliang.top/en/blog/redis-multi-sort</id>
        <link href="https://blog.xiaoxiaoliang.top/en/blog/redis-multi-sort"/>
        <updated>2026-04-14T00:00:00.000Z</updated>
        <summary type="html"><![CDATA[💡 业务背景与技术痛点]]></summary>
        <content type="html"><![CDATA[<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="-业务背景与技术痛点">💡 业务背景与技术痛点<a href="https://blog.xiaoxiaoliang.top/en/blog/redis-multi-sort#-%E4%B8%9A%E5%8A%A1%E8%83%8C%E6%99%AF%E4%B8%8E%E6%8A%80%E6%9C%AF%E7%97%9B%E7%82%B9" class="hash-link" aria-label="Direct link to 💡 业务背景与技术痛点" title="Direct link to 💡 业务背景与技术痛点" translate="no">​</a></h2>
<p>在社交、O2O 等业务中，<strong>"查找附近的人"</strong> 是一个高频场景。大部分开发者第一反应是使用 Redis 从 3.2 版本引入的 <code>GEO</code> 数据结构。</p>
<!-- -->
<p>使用 <code>GEOADD</code> 写入坐标，使用 <code>GEOSEARCH</code>（或旧版的 <code>GEORADIUS</code>）按距离搜索，看起来一切都很完美。但当产品经理提出新需求时——</p>
<blockquote>
<p><strong>"不仅要离得近，还要优先展示最近活跃过的用户（距离 + 活跃度混合排序）"</strong></p>
</blockquote>
<p>技术痛点就出现了。</p>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="为什么-redis-geo-做不到">为什么 Redis GEO 做不到？<a href="https://blog.xiaoxiaoliang.top/en/blog/redis-multi-sort#%E4%B8%BA%E4%BB%80%E4%B9%88-redis-geo-%E5%81%9A%E4%B8%8D%E5%88%B0" class="hash-link" aria-label="Direct link to 为什么 Redis GEO 做不到？" title="Direct link to 为什么 Redis GEO 做不到？" translate="no">​</a></h3>
<p>Redis <code>GEO</code> 底层的本质是 <code>ZSET</code>（有序集合）。它利用 <strong>GeoHash 算法</strong>将二维经纬度转化为一维的 52 位整数，并<strong>强行占用了 ZSET 唯一的 Score 字段</strong>来存储这个整数。</p>
<div class="language-java codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#0f172a;--prism-background-color:#f1f5f9"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-java codeBlock_bY9V thin-scrollbar" style="color:#0f172a;background-color:#f1f5f9"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#0f172a"><span class="token plain">┌─────────────────────────────────────────────────┐</span><br></div><div class="token-line" style="color:#0f172a"><span class="token plain">│              </span><span class="token class-name" style="color:#0ea5e9">Redis</span><span class="token plain"> </span><span class="token constant" style="color:#0f172a">GEO</span><span class="token plain"> 底层结构                   │</span><br></div><div class="token-line" style="color:#0f172a"><span class="token plain">│                                                   │</span><br></div><div class="token-line" style="color:#0f172a"><span class="token plain">│   </span><span class="token constant" style="color:#0f172a">ZSET</span><span class="token plain"> </span><span class="token class-name" style="color:#0ea5e9">Key</span><span class="token operator" style="color:#475569">:</span><span class="token plain"> </span><span class="token string" style="color:#64748b">"nearby_users"</span><span class="token plain">                        │</span><br></div><div class="token-line" style="color:#0f172a"><span class="token plain">│   ┌──────────┬──────────────────────────┐         │</span><br></div><div class="token-line" style="color:#0f172a"><span class="token plain">│   │  </span><span class="token class-name" style="color:#0ea5e9">Member</span><span class="token plain">  │   </span><span class="token class-name" style="color:#0ea5e9">Score</span><span class="token plain"> </span><span class="token punctuation" style="color:#475569">(</span><span class="token class-name" style="color:#0ea5e9">GeoHash</span><span class="token plain"> 值</span><span class="token punctuation" style="color:#475569">)</span><span class="token plain">      │         │</span><br></div><div class="token-line" style="color:#0f172a"><span class="token plain">│   ├──────────┼──────────────────────────┤         │</span><br></div><div class="token-line" style="color:#0f172a"><span class="token plain">│   │  user</span><span class="token operator" style="color:#475569">:</span><span class="token number" style="color:#B5CEA8">1</span><span class="token plain">  │  </span><span class="token number" style="color:#B5CEA8">4054421060663041</span><span class="token plain">        │  ← 经纬度编码后的整数 │</span><br></div><div class="token-line" style="color:#0f172a"><span class="token plain">│   │  user</span><span class="token operator" style="color:#475569">:</span><span class="token number" style="color:#B5CEA8">2</span><span class="token plain">  │  </span><span class="token number" style="color:#B5CEA8">4054421060812801</span><span class="token plain">        │  ← 被 </span><span class="token class-name" style="color:#0ea5e9">GeoHash</span><span class="token plain"> 占了！ │</span><br></div><div class="token-line" style="color:#0f172a"><span class="token plain">│   │  user</span><span class="token operator" style="color:#475569">:</span><span class="token number" style="color:#B5CEA8">3</span><span class="token plain">  │  </span><span class="token number" style="color:#B5CEA8">4054421061038081</span><span class="token plain">        │  ← 没位置放活跃度了   │</span><br></div><div class="token-line" style="color:#0f172a"><span class="token plain">│   └──────────┴──────────────────────────┘         │</span><br></div><div class="token-line" style="color:#0f172a"><span class="token plain">│                                                   │</span><br></div><div class="token-line" style="color:#0f172a"><span class="token plain">│   ❌ </span><span class="token class-name" style="color:#0ea5e9">Score</span><span class="token plain"> 已被占用，无法再存第二个排序字段         │</span><br></div><div class="token-line" style="color:#0f172a"><span class="token plain">└─────────────────────────────────────────────────┘</span><br></div></code></pre></div></div>
<p>所以，Redis 的原生 GEO 命令<strong>只能按距离排序</strong>，根本无法传入第二个业务字段（如活跃度）进行联合排序。</p>
<p>面对这个限制，业界通常有两套架构演进方案。</p>
<hr>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="方案一经典架构-粗排召回--内存精排应用层计算">方案一：经典架构 ——「粗排召回 + 内存精排」（应用层计算）<a href="https://blog.xiaoxiaoliang.top/en/blog/redis-multi-sort#%E6%96%B9%E6%A1%88%E4%B8%80%E7%BB%8F%E5%85%B8%E6%9E%B6%E6%9E%84-%E7%B2%97%E6%8E%92%E5%8F%AC%E5%9B%9E--%E5%86%85%E5%AD%98%E7%B2%BE%E6%8E%92%E5%BA%94%E7%94%A8%E5%B1%82%E8%AE%A1%E7%AE%97" class="hash-link" aria-label="Direct link to 方案一：经典架构 ——「粗排召回 + 内存精排」（应用层计算）" title="Direct link to 方案一：经典架构 ——「粗排召回 + 内存精排」（应用层计算）" translate="no">​</a></h2>
<p>这是在不升级现有标准版 Redis 基础设施的情况下，<strong>最成熟、落地最广</strong>的架构方案。</p>
<p>核心思想是：<strong>把 Redis 当作「召回引擎」，把 Java 业务服务当作「精排引擎」。</strong></p>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="1-架构流程图">1. 架构流程图<a href="https://blog.xiaoxiaoliang.top/en/blog/redis-multi-sort#1-%E6%9E%B6%E6%9E%84%E6%B5%81%E7%A8%8B%E5%9B%BE" class="hash-link" aria-label="Direct link to 1. 架构流程图" title="Direct link to 1. 架构流程图" translate="no">​</a></h3>
<div class="language-java codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#0f172a;--prism-background-color:#f1f5f9"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-java codeBlock_bY9V thin-scrollbar" style="color:#0f172a;background-color:#f1f5f9"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#0f172a"><span class="token plain">┌──────────┐      ①请求</span><span class="token number" style="color:#B5CEA8">20</span><span class="token plain">条        ┌──────────────────┐</span><br></div><div class="token-line" style="color:#0f172a"><span class="token plain">│          │ ──────────────────────▶ │   </span><span class="token class-name" style="color:#0ea5e9">Java</span><span class="token plain"> 应用服务   │</span><br></div><div class="token-line" style="color:#0f172a"><span class="token plain">│  客户端   │                        │                    │</span><br></div><div class="token-line" style="color:#0f172a"><span class="token plain">│          │ ◀────────────────────── │  ⑤ 返回</span><span class="token class-name" style="color:#0ea5e9">Top20</span><span class="token plain">       │</span><br></div><div class="token-line" style="color:#0f172a"><span class="token plain">└──────────┘      精排后的</span><span class="token number" style="color:#B5CEA8">20</span><span class="token plain">条       └─────┬──────┬───────┘</span><br></div><div class="token-line" style="color:#0f172a"><span class="token plain">                                         │      │</span><br></div><div class="token-line" style="color:#0f172a"><span class="token plain">                            ②</span><span class="token constant" style="color:#0f172a">GEOSEARCH</span><span class="token plain">   │      │ ③</span><span class="token constant" style="color:#0f172a">HMGET</span><span class="token plain"></span><br></div><div class="token-line" style="color:#0f172a"><span class="token plain">                            召回</span><span class="token number" style="color:#B5CEA8">200</span><span class="token plain">条     │      │ 批量查活跃度</span><br></div><div class="token-line" style="color:#0f172a"><span class="token plain">                                         ▼      ▼</span><br></div><div class="token-line" style="color:#0f172a"><span class="token plain">                                   ┌──────────────────┐</span><br></div><div class="token-line" style="color:#0f172a"><span class="token plain">                                   │      </span><span class="token class-name" style="color:#0ea5e9">Redis</span><span class="token plain">        │</span><br></div><div class="token-line" style="color:#0f172a"><span class="token plain">                                   │                    │</span><br></div><div class="token-line" style="color:#0f172a"><span class="token plain">                                   │  </span><span class="token constant" style="color:#0f172a">GEO</span><span class="token operator" style="color:#475569">:</span><span class="token plain"> 坐标数据     │</span><br></div><div class="token-line" style="color:#0f172a"><span class="token plain">                                   │  </span><span class="token constant" style="color:#0f172a">HASH</span><span class="token operator" style="color:#475569">:</span><span class="token plain"> 活跃度数据  │</span><br></div><div class="token-line" style="color:#0f172a"><span class="token plain">                                   └──────────────────┘</span><br></div><div class="token-line" style="color:#0f172a"><span class="token plain">                                         │</span><br></div><div class="token-line" style="color:#0f172a"><span class="token plain">                            ④ </span><span class="token class-name" style="color:#0ea5e9">Java</span><span class="token plain"> 内存中进行</span><br></div><div class="token-line" style="color:#0f172a"><span class="token plain">                            归一化 </span><span class="token operator" style="color:#475569">+</span><span class="token plain"> 加权计算 </span><span class="token operator" style="color:#475569">+</span><span class="token plain"> 排序</span><br></div></code></pre></div></div>
<p>整个流程分为 5 步：</p>
<ol>
<li class=""><strong>扩大召回池 (Recall)</strong>：客户端请求 20 条数据，后端向 Redis 请求 <strong>200 条</strong>（适度放大 10 倍的召回量）。</li>
<li class=""><strong>批量获取活跃度 (Batch Fetch)</strong>：拿到 200 个用户 ID 后，通过 <code>Pipeline</code> 批量从 Hash 中查出活跃度得分。</li>
<li class=""><strong>归一化计算 (Normalize)</strong>：将距离和活跃度分别映射到 <code>[0, 1]</code> 区间，消除量纲差异。</li>
<li class=""><strong>加权打分 (Rerank)</strong>：配合业务权重（如距离 60%，活跃度 40%）计算综合分。</li>
<li class=""><strong>截断返回 (Truncate)</strong>：按综合分降序排列，截取 Top 20 返回前端。</li>
</ol>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="2-mock-数据准备">2. Mock 数据准备<a href="https://blog.xiaoxiaoliang.top/en/blog/redis-multi-sort#2-mock-%E6%95%B0%E6%8D%AE%E5%87%86%E5%A4%87" class="hash-link" aria-label="Direct link to 2. Mock 数据准备" title="Direct link to 2. Mock 数据准备" translate="no">​</a></h3>
<p>假设当前用户"我"的坐标是 <strong>北京天安门 (116.404, 39.915)</strong>，我们先往 Redis 灌入一批模拟用户数据：</p>
<pre tabindex="0" class="codeBlockStandalone_MEMb thin-scrollbar language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#0f172a;--prism-background-color:#f1f5f9"><code class="codeBlockLines_e6Vv"><span class="line"><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic"># ============ 第一步：写入用户坐标到 GEO ============</span></span>
<span class="line"><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic"># GEOADD key longitude latitude member</span></span>
<span class="line"><span style="color:#4078F2;--shiki-dark:#61AFEF">GEOADD</span><span style="color:#50A14F;--shiki-dark:#98C379"> nearby:users</span><span style="color:#986801;--shiki-dark:#D19A66"> 116.408</span><span style="color:#986801;--shiki-dark:#D19A66"> 39.918</span><span style="color:#50A14F;--shiki-dark:#98C379"> user:A</span></span>
<span class="line"><span style="color:#4078F2;--shiki-dark:#61AFEF">GEOADD</span><span style="color:#50A14F;--shiki-dark:#98C379"> nearby:users</span><span style="color:#986801;--shiki-dark:#D19A66"> 116.398</span><span style="color:#986801;--shiki-dark:#D19A66"> 39.910</span><span style="color:#50A14F;--shiki-dark:#98C379"> user:B</span></span>
<span class="line"><span style="color:#4078F2;--shiki-dark:#61AFEF">GEOADD</span><span style="color:#50A14F;--shiki-dark:#98C379"> nearby:users</span><span style="color:#986801;--shiki-dark:#D19A66"> 116.420</span><span style="color:#986801;--shiki-dark:#D19A66"> 39.930</span><span style="color:#50A14F;--shiki-dark:#98C379"> user:C</span></span>
<span class="line"><span style="color:#4078F2;--shiki-dark:#61AFEF">GEOADD</span><span style="color:#50A14F;--shiki-dark:#98C379"> nearby:users</span><span style="color:#986801;--shiki-dark:#D19A66"> 116.450</span><span style="color:#986801;--shiki-dark:#D19A66"> 39.950</span><span style="color:#50A14F;--shiki-dark:#98C379"> user:D</span></span>
<span class="line"><span style="color:#4078F2;--shiki-dark:#61AFEF">GEOADD</span><span style="color:#50A14F;--shiki-dark:#98C379"> nearby:users</span><span style="color:#986801;--shiki-dark:#D19A66"> 116.380</span><span style="color:#986801;--shiki-dark:#D19A66"> 39.900</span><span style="color:#50A14F;--shiki-dark:#98C379"> user:E</span></span>
<span class="line"><span style="color:#4078F2;--shiki-dark:#61AFEF">GEOADD</span><span style="color:#50A14F;--shiki-dark:#98C379"> nearby:users</span><span style="color:#986801;--shiki-dark:#D19A66"> 116.410</span><span style="color:#986801;--shiki-dark:#D19A66"> 39.916</span><span style="color:#50A14F;--shiki-dark:#98C379"> user:F</span></span>
<span class="line"><span style="color:#4078F2;--shiki-dark:#61AFEF">GEOADD</span><span style="color:#50A14F;--shiki-dark:#98C379"> nearby:users</span><span style="color:#986801;--shiki-dark:#D19A66"> 116.500</span><span style="color:#986801;--shiki-dark:#D19A66"> 39.980</span><span style="color:#50A14F;--shiki-dark:#98C379"> user:G</span></span>
<span class="line"><span style="color:#4078F2;--shiki-dark:#61AFEF">GEOADD</span><span style="color:#50A14F;--shiki-dark:#98C379"> nearby:users</span><span style="color:#986801;--shiki-dark:#D19A66"> 116.390</span><span style="color:#986801;--shiki-dark:#D19A66"> 39.905</span><span style="color:#50A14F;--shiki-dark:#98C379"> user:H</span></span>
<span class="line"></span>
<span class="line"><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic"># ============ 第二步：写入用户活跃度到 HASH ============</span></span>
<span class="line"><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic"># 活跃度得分范围 [0, 100]，越高越活跃</span></span>
<span class="line"><span style="color:#4078F2;--shiki-dark:#61AFEF">HSET</span><span style="color:#50A14F;--shiki-dark:#98C379"> user:activity</span><span style="color:#50A14F;--shiki-dark:#98C379"> user:A</span><span style="color:#986801;--shiki-dark:#D19A66"> 30</span></span>
<span class="line"><span style="color:#4078F2;--shiki-dark:#61AFEF">HSET</span><span style="color:#50A14F;--shiki-dark:#98C379"> user:activity</span><span style="color:#50A14F;--shiki-dark:#98C379"> user:B</span><span style="color:#986801;--shiki-dark:#D19A66"> 95</span></span>
<span class="line"><span style="color:#4078F2;--shiki-dark:#61AFEF">HSET</span><span style="color:#50A14F;--shiki-dark:#98C379"> user:activity</span><span style="color:#50A14F;--shiki-dark:#98C379"> user:C</span><span style="color:#986801;--shiki-dark:#D19A66"> 60</span></span>
<span class="line"><span style="color:#4078F2;--shiki-dark:#61AFEF">HSET</span><span style="color:#50A14F;--shiki-dark:#98C379"> user:activity</span><span style="color:#50A14F;--shiki-dark:#98C379"> user:D</span><span style="color:#986801;--shiki-dark:#D19A66"> 85</span></span>
<span class="line"><span style="color:#4078F2;--shiki-dark:#61AFEF">HSET</span><span style="color:#50A14F;--shiki-dark:#98C379"> user:activity</span><span style="color:#50A14F;--shiki-dark:#98C379"> user:E</span><span style="color:#986801;--shiki-dark:#D19A66"> 10</span></span>
<span class="line"><span style="color:#4078F2;--shiki-dark:#61AFEF">HSET</span><span style="color:#50A14F;--shiki-dark:#98C379"> user:activity</span><span style="color:#50A14F;--shiki-dark:#98C379"> user:F</span><span style="color:#986801;--shiki-dark:#D19A66"> 50</span></span>
<span class="line"><span style="color:#4078F2;--shiki-dark:#61AFEF">HSET</span><span style="color:#50A14F;--shiki-dark:#98C379"> user:activity</span><span style="color:#50A14F;--shiki-dark:#98C379"> user:G</span><span style="color:#986801;--shiki-dark:#D19A66"> 90</span></span>
<span class="line"><span style="color:#4078F2;--shiki-dark:#61AFEF">HSET</span><span style="color:#50A14F;--shiki-dark:#98C379"> user:activity</span><span style="color:#50A14F;--shiki-dark:#98C379"> user:H</span><span style="color:#986801;--shiki-dark:#D19A66"> 75</span></span></code></pre>
<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">与我的真实距离(km)</th><th style="text-align:left">活跃度</th></tr></thead><tbody><tr><td style="text-align:left">user<!-- -->:A</td><td style="text-align:left">116.408</td><td style="text-align:left">39.918</td><td style="text-align:left">≈ 0.5</td><td style="text-align:left">30</td></tr><tr><td style="text-align:left">user<!-- -->:F</td><td style="text-align:left">116.410</td><td style="text-align:left">39.916</td><td style="text-align:left">≈ 0.5</td><td style="text-align:left">50</td></tr><tr><td style="text-align:left">user<!-- -->:B</td><td style="text-align:left">116.398</td><td style="text-align:left">39.910</td><td style="text-align:left">≈ 0.7</td><td style="text-align:left">95</td></tr><tr><td style="text-align:left">user<!-- -->:H</td><td style="text-align:left">116.390</td><td style="text-align:left">39.905</td><td style="text-align:left">≈ 1.5</td><td style="text-align:left">75</td></tr><tr><td style="text-align:left">user<!-- -->:C</td><td style="text-align:left">116.420</td><td style="text-align:left">39.930</td><td style="text-align:left">≈ 2.1</td><td style="text-align:left">60</td></tr><tr><td style="text-align:left">user<!-- -->:D</td><td style="text-align:left">116.450</td><td style="text-align:left">39.950</td><td style="text-align:left">≈ 5.3</td><td style="text-align:left">85</td></tr><tr><td style="text-align:left">user<!-- -->:E</td><td style="text-align:left">116.380</td><td style="text-align:left">39.900</td><td style="text-align:left">≈ 2.7</td><td style="text-align:left">10</td></tr><tr><td style="text-align:left">user<!-- -->:G</td><td style="text-align:left">116.500</td><td style="text-align:left">39.980</td><td style="text-align:left">≈ 11.0</td><td style="text-align:left">90</td></tr></tbody></table>
<blockquote>
<p><strong>注意观察</strong>：如果纯按距离排，user<!-- -->:A<!-- --> 排第一（最近但活跃度只有 30）；如果纯按活跃度排，user<!-- -->:B<!-- --> 排第一（活跃度 95 但距离 0.7km）。<strong>混合排序的目标就是在两者间找到平衡。</strong></p>
</blockquote>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="3-完整-java-代码实现">3. 完整 Java 代码实现<a href="https://blog.xiaoxiaoliang.top/en/blog/redis-multi-sort#3-%E5%AE%8C%E6%95%B4-java-%E4%BB%A3%E7%A0%81%E5%AE%9E%E7%8E%B0" class="hash-link" aria-label="Direct link to 3. 完整 Java 代码实现" title="Direct link to 3. 完整 Java 代码实现" translate="no">​</a></h3>
<h4 class="anchor anchorTargetStickyNavbar_Vzrq" id="31-核心数据模型">3.1 核心数据模型<a href="https://blog.xiaoxiaoliang.top/en/blog/redis-multi-sort#31-%E6%A0%B8%E5%BF%83%E6%95%B0%E6%8D%AE%E6%A8%A1%E5%9E%8B" class="hash-link" aria-label="Direct link to 3.1 核心数据模型" title="Direct link to 3.1 核心数据模型" translate="no">​</a></h4>
<pre tabindex="0" class="codeBlockStandalone_MEMb thin-scrollbar language-java codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#0f172a;--prism-background-color:#f1f5f9"><code class="codeBlockLines_e6Vv"><span class="line"><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic">/**</span></span>
<span class="line"><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic"> * 附近用户信息（含距离和活跃度）</span></span>
<span class="line"><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic"> */</span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">public</span><span style="color:#A626A4;--shiki-dark:#C678DD"> record</span><span style="color:#C18401;--shiki-dark:#E5C07B"> NearbyUser</span><span style="color:#383A42;--shiki-dark:#E06C75">(</span></span>
<span class="line"><span style="color:#C18401;--shiki-dark:#E5C07B">    String</span><span style="color:#383A42;--shiki-dark:#E06C75"> userId</span><span style="color:#383A42;--shiki-dark:#ABB2BF">,</span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">    double</span><span style="color:#383A42;--shiki-dark:#E06C75"> distanceKm</span><span style="color:#383A42;--shiki-dark:#ABB2BF">,</span><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic">    // 与"我"的距离（公里）</span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">    int</span><span style="color:#383A42;--shiki-dark:#E06C75"> activityScore</span><span style="color:#383A42;--shiki-dark:#ABB2BF">,</span><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic">    // 活跃度得分 [0, 100]</span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">    double</span><span style="color:#383A42;--shiki-dark:#E06C75"> finalScore     </span><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic">// 综合打分（归一化加权后）</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#E06C75">) </span><span style="color:#383A42;--shiki-dark:#ABB2BF">{</span></span>
<span class="line"><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic">    /**</span></span>
<span class="line"><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic">     * 构造时不传 finalScore，后续计算后赋值</span></span>
<span class="line"><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic">     */</span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">    public</span><span style="color:#4078F2;--shiki-dark:#61AFEF"> NearbyUser</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(</span><span style="color:#C18401;--shiki-dark:#E5C07B">String</span><span style="color:#383A42;--shiki-light-font-style:inherit;--shiki-dark:#E06C75;--shiki-dark-font-style:italic"> userId</span><span style="color:#383A42;--shiki-dark:#ABB2BF">, </span><span style="color:#A626A4;--shiki-dark:#C678DD">double</span><span style="color:#383A42;--shiki-light-font-style:inherit;--shiki-dark:#E06C75;--shiki-dark-font-style:italic"> distanceKm</span><span style="color:#383A42;--shiki-dark:#ABB2BF">, </span><span style="color:#A626A4;--shiki-dark:#C678DD">int</span><span style="color:#383A42;--shiki-light-font-style:inherit;--shiki-dark:#E06C75;--shiki-dark-font-style:italic"> activityScore</span><span style="color:#383A42;--shiki-dark:#ABB2BF">)</span><span style="color:#383A42;--shiki-dark:#ABB2BF"> {</span></span>
<span class="line"><span style="color:#E45649;--shiki-dark:#E5C07B">        this</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(userId, distanceKm, activityScore, </span><span style="color:#986801;--shiki-dark:#D19A66">0.0</span><span style="color:#383A42;--shiki-dark:#ABB2BF">);</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">    }</span></span>
<span class="line"></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">    public</span><span style="color:#C18401;--shiki-dark:#E5C07B"> NearbyUser</span><span style="color:#4078F2;--shiki-dark:#61AFEF"> withFinalScore</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(</span><span style="color:#A626A4;--shiki-dark:#C678DD">double</span><span style="color:#383A42;--shiki-light-font-style:inherit;--shiki-dark:#E06C75;--shiki-dark-font-style:italic"> score</span><span style="color:#383A42;--shiki-dark:#ABB2BF">)</span><span style="color:#383A42;--shiki-dark:#ABB2BF"> {</span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">        return</span><span style="color:#A626A4;--shiki-dark:#C678DD"> new</span><span style="color:#4078F2;--shiki-dark:#61AFEF"> NearbyUser</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(userId, distanceKm, activityScore, score);</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">    }</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">}</span></span></code></pre>
<h4 class="anchor anchorTargetStickyNavbar_Vzrq" id="32-召回--精排服务">3.2 召回 + 精排服务<a href="https://blog.xiaoxiaoliang.top/en/blog/redis-multi-sort#32-%E5%8F%AC%E5%9B%9E--%E7%B2%BE%E6%8E%92%E6%9C%8D%E5%8A%A1" class="hash-link" aria-label="Direct link to 3.2 召回 + 精排服务" title="Direct link to 3.2 召回 + 精排服务" translate="no">​</a></h4>
<pre tabindex="0" class="codeBlockStandalone_MEMb thin-scrollbar language-java codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#0f172a;--prism-background-color:#f1f5f9"><code class="codeBlockLines_e6Vv"><span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">import</span><span style="color:#C18401;--shiki-dark:#E5C07B"> lombok.RequiredArgsConstructor</span><span style="color:#383A42;--shiki-dark:#ABB2BF">;</span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">import</span><span style="color:#C18401;--shiki-dark:#E5C07B"> lombok.extern.slf4j.Slf4j</span><span style="color:#383A42;--shiki-dark:#ABB2BF">;</span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">import</span><span style="color:#C18401;--shiki-dark:#E5C07B"> org.springframework.data.geo.</span><span style="color:#E45649;--shiki-dark:#E5C07B">*</span><span style="color:#383A42;--shiki-dark:#ABB2BF">;</span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">import</span><span style="color:#C18401;--shiki-dark:#E5C07B"> org.springframework.data.redis.connection.RedisGeoCommands</span><span style="color:#383A42;--shiki-dark:#ABB2BF">;</span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">import</span><span style="color:#C18401;--shiki-dark:#E5C07B"> org.springframework.data.redis.core.StringRedisTemplate</span><span style="color:#383A42;--shiki-dark:#ABB2BF">;</span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">import</span><span style="color:#C18401;--shiki-dark:#E5C07B"> org.springframework.stereotype.Service</span><span style="color:#383A42;--shiki-dark:#ABB2BF">;</span></span>
<span class="line"></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">import</span><span style="color:#C18401;--shiki-dark:#E5C07B"> java.util.</span><span style="color:#E45649;--shiki-dark:#E5C07B">*</span><span style="color:#383A42;--shiki-dark:#ABB2BF">;</span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">import</span><span style="color:#C18401;--shiki-dark:#E5C07B"> java.util.stream.Collectors</span><span style="color:#383A42;--shiki-dark:#ABB2BF">;</span></span>
<span class="line"></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">@</span><span style="color:#A626A4;--shiki-dark:#E5C07B">Slf4j</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">@</span><span style="color:#A626A4;--shiki-dark:#E5C07B">Service</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">@</span><span style="color:#A626A4;--shiki-dark:#E5C07B">RequiredArgsConstructor</span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">public</span><span style="color:#A626A4;--shiki-dark:#C678DD"> class</span><span style="color:#C18401;--shiki-dark:#E5C07B"> NearbyUserService</span><span style="color:#383A42;--shiki-dark:#ABB2BF"> {</span></span>
<span class="line"></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">    private</span><span style="color:#A626A4;--shiki-dark:#C678DD"> final</span><span style="color:#C18401;--shiki-dark:#E5C07B"> StringRedisTemplate</span><span style="color:#E45649;--shiki-dark:#E06C75"> redisTemplate</span><span style="color:#383A42;--shiki-dark:#ABB2BF">;</span></span>
<span class="line"></span>
<span class="line"><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic">    /** GEO Key */</span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">    private</span><span style="color:#A626A4;--shiki-dark:#C678DD"> static</span><span style="color:#A626A4;--shiki-dark:#C678DD"> final</span><span style="color:#C18401;--shiki-dark:#E5C07B"> String</span><span style="color:#E45649;--shiki-dark:#E06C75"> GEO_KEY </span><span style="color:#383A42;--shiki-dark:#56B6C2">=</span><span style="color:#50A14F;--shiki-dark:#98C379"> "nearby:users"</span><span style="color:#383A42;--shiki-dark:#ABB2BF">;</span></span>
<span class="line"><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic">    /** 活跃度 Hash Key */</span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">    private</span><span style="color:#A626A4;--shiki-dark:#C678DD"> static</span><span style="color:#A626A4;--shiki-dark:#C678DD"> final</span><span style="color:#C18401;--shiki-dark:#E5C07B"> String</span><span style="color:#E45649;--shiki-dark:#E06C75"> ACTIVITY_KEY </span><span style="color:#383A42;--shiki-dark:#56B6C2">=</span><span style="color:#50A14F;--shiki-dark:#98C379"> "user:activity"</span><span style="color:#383A42;--shiki-dark:#ABB2BF">;</span></span>
<span class="line"><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic">    /** 召回倍率：请求 N 条 → 召回 N * RECALL_RATIO 条 */</span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">    private</span><span style="color:#A626A4;--shiki-dark:#C678DD"> static</span><span style="color:#A626A4;--shiki-dark:#C678DD"> final</span><span style="color:#A626A4;--shiki-dark:#C678DD"> int</span><span style="color:#E45649;--shiki-dark:#E06C75"> RECALL_RATIO </span><span style="color:#383A42;--shiki-dark:#56B6C2">=</span><span style="color:#986801;--shiki-dark:#D19A66"> 10</span><span style="color:#383A42;--shiki-dark:#ABB2BF">;</span></span>
<span class="line"><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic">    /** 排序权重 */</span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">    private</span><span style="color:#A626A4;--shiki-dark:#C678DD"> static</span><span style="color:#A626A4;--shiki-dark:#C678DD"> final</span><span style="color:#A626A4;--shiki-dark:#C678DD"> double</span><span style="color:#E45649;--shiki-dark:#E06C75"> WEIGHT_DISTANCE </span><span style="color:#383A42;--shiki-dark:#56B6C2">=</span><span style="color:#986801;--shiki-dark:#D19A66"> 0.6</span><span style="color:#383A42;--shiki-dark:#ABB2BF">;</span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">    private</span><span style="color:#A626A4;--shiki-dark:#C678DD"> static</span><span style="color:#A626A4;--shiki-dark:#C678DD"> final</span><span style="color:#A626A4;--shiki-dark:#C678DD"> double</span><span style="color:#E45649;--shiki-dark:#E06C75"> WEIGHT_ACTIVITY </span><span style="color:#383A42;--shiki-dark:#56B6C2">=</span><span style="color:#986801;--shiki-dark:#D19A66"> 0.4</span><span style="color:#383A42;--shiki-dark:#ABB2BF">;</span></span>
<span class="line"></span>
<span class="line"><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic">    /**</span></span>
<span class="line"><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic">     * 查询附近的人（距离 + 活跃度混合排序）</span></span>
<span class="line"><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic">     *</span></span>
<span class="line"><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic">     * </span><span style="color:#A626A4;--shiki-light-font-style:italic;--shiki-dark:#C678DD;--shiki-dark-font-style:italic">@param</span><span style="color:#383A42;--shiki-light-font-style:italic;--shiki-dark:#E06C75;--shiki-dark-font-style:italic"> myLon</span><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic">       我的经度</span></span>
<span class="line"><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic">     * </span><span style="color:#A626A4;--shiki-light-font-style:italic;--shiki-dark:#C678DD;--shiki-dark-font-style:italic">@param</span><span style="color:#383A42;--shiki-light-font-style:italic;--shiki-dark:#E06C75;--shiki-dark-font-style:italic"> myLat</span><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic">       我的纬度</span></span>
<span class="line"><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic">     * </span><span style="color:#A626A4;--shiki-light-font-style:italic;--shiki-dark:#C678DD;--shiki-dark-font-style:italic">@param</span><span style="color:#383A42;--shiki-light-font-style:italic;--shiki-dark:#E06C75;--shiki-dark-font-style:italic"> radiusKm</span><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic">    搜索半径（公里）</span></span>
<span class="line"><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic">     * </span><span style="color:#A626A4;--shiki-light-font-style:italic;--shiki-dark:#C678DD;--shiki-dark-font-style:italic">@param</span><span style="color:#383A42;--shiki-light-font-style:italic;--shiki-dark:#E06C75;--shiki-dark-font-style:italic"> pageSize</span><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic">    需要返回的条数</span></span>
<span class="line"><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic">     * </span><span style="color:#A626A4;--shiki-light-font-style:italic;--shiki-dark:#C678DD;--shiki-dark-font-style:italic">@return</span><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic"> 混合排序后的用户列表</span></span>
<span class="line"><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic">     */</span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">    public</span><span style="color:#C18401;--shiki-dark:#E5C07B"> List</span><span style="color:#383A42;--shiki-dark:#ABB2BF">&lt;</span><span style="color:#C18401;--shiki-dark:#E5C07B">NearbyUser</span><span style="color:#383A42;--shiki-dark:#ABB2BF">&gt;</span><span style="color:#4078F2;--shiki-dark:#61AFEF"> findNearbyUsers</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(</span><span style="color:#A626A4;--shiki-dark:#C678DD">double</span><span style="color:#383A42;--shiki-light-font-style:inherit;--shiki-dark:#E06C75;--shiki-dark-font-style:italic"> myLon</span><span style="color:#383A42;--shiki-dark:#ABB2BF">, </span><span style="color:#A626A4;--shiki-dark:#C678DD">double</span><span style="color:#383A42;--shiki-light-font-style:inherit;--shiki-dark:#E06C75;--shiki-dark-font-style:italic"> myLat</span><span style="color:#383A42;--shiki-dark:#ABB2BF">,</span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">                                            double</span><span style="color:#383A42;--shiki-light-font-style:inherit;--shiki-dark:#E06C75;--shiki-dark-font-style:italic"> radiusKm</span><span style="color:#383A42;--shiki-dark:#ABB2BF">, </span><span style="color:#A626A4;--shiki-dark:#C678DD">int</span><span style="color:#383A42;--shiki-light-font-style:inherit;--shiki-dark:#E06C75;--shiki-dark-font-style:italic"> pageSize</span><span style="color:#383A42;--shiki-dark:#ABB2BF">)</span><span style="color:#383A42;--shiki-dark:#ABB2BF"> {</span></span>
<span class="line"><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic">        // ============ Step 1: 扩大召回 ============</span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">        int</span><span style="color:#E45649;--shiki-dark:#E06C75"> recallCount</span><span style="color:#383A42;--shiki-dark:#56B6C2"> =</span><span style="color:#383A42;--shiki-dark:#ABB2BF"> pageSize </span><span style="color:#383A42;--shiki-dark:#56B6C2">*</span><span style="color:#383A42;--shiki-dark:#ABB2BF"> RECALL_RATIO;</span></span>
<span class="line"></span>
<span class="line"><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic">        // 构造搜索参数：按距离升序，限制召回数量</span></span>
<span class="line"><span style="color:#C18401;--shiki-dark:#E5C07B">        RedisGeoCommands</span><span style="color:#E45649;--shiki-dark:#ABB2BF">.</span><span style="color:#C18401;--shiki-dark:#E5C07B">GeoSearchCommandArgs</span><span style="color:#E45649;--shiki-dark:#E06C75"> args</span><span style="color:#383A42;--shiki-dark:#56B6C2"> =</span><span style="color:#C18401;--shiki-dark:#E5C07B"> RedisGeoCommands</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">                .</span><span style="color:#E45649;--shiki-dark:#E5C07B">GeoSearchCommandArgs</span><span style="color:#383A42;--shiki-dark:#ABB2BF">.</span><span style="color:#4078F2;--shiki-dark:#61AFEF">newGeoSearchArgs</span><span style="color:#383A42;--shiki-dark:#ABB2BF">()</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">                .</span><span style="color:#4078F2;--shiki-dark:#61AFEF">includeDistance</span><span style="color:#383A42;--shiki-dark:#ABB2BF">()     </span><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic">// 返回距离</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">                .</span><span style="color:#4078F2;--shiki-dark:#61AFEF">sortAscending</span><span style="color:#383A42;--shiki-dark:#ABB2BF">()      </span><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic">// 距离近的优先</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">                .</span><span style="color:#4078F2;--shiki-dark:#61AFEF">limit</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(recallCount);  </span><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic">// 限制召回数量</span></span>
<span class="line"></span>
<span class="line"><span style="color:#C18401;--shiki-dark:#E5C07B">        GeoResults</span><span style="color:#E45649;--shiki-dark:#ABB2BF">&lt;</span><span style="color:#C18401;--shiki-dark:#E5C07B">RedisGeoCommands</span><span style="color:#E45649;--shiki-dark:#ABB2BF">.</span><span style="color:#C18401;--shiki-dark:#E5C07B">GeoLocation</span><span style="color:#E45649;--shiki-dark:#ABB2BF">&lt;</span><span style="color:#C18401;--shiki-dark:#E5C07B">String</span><span style="color:#E45649;--shiki-dark:#ABB2BF">&gt;&gt; </span><span style="color:#E45649;--shiki-dark:#E06C75">geoResults</span><span style="color:#383A42;--shiki-dark:#56B6C2"> =</span></span>
<span class="line"><span style="color:#E45649;--shiki-dark:#E5C07B">                redisTemplate</span><span style="color:#383A42;--shiki-dark:#ABB2BF">.</span><span style="color:#4078F2;--shiki-dark:#61AFEF">opsForGeo</span><span style="color:#383A42;--shiki-dark:#ABB2BF">().</span><span style="color:#4078F2;--shiki-dark:#61AFEF">search</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">                        GEO_KEY,</span></span>
<span class="line"><span style="color:#E45649;--shiki-dark:#E5C07B">                        GeoReference</span><span style="color:#383A42;--shiki-dark:#ABB2BF">.</span><span style="color:#4078F2;--shiki-dark:#61AFEF">fromCoordinate</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(myLon, myLat),</span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">                        new</span><span style="color:#4078F2;--shiki-dark:#61AFEF"> Distance</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(radiusKm, </span><span style="color:#E45649;--shiki-dark:#E5C07B">Metrics</span><span style="color:#383A42;--shiki-dark:#ABB2BF">.</span><span style="color:#E45649;--shiki-dark:#E5C07B">KILOMETERS</span><span style="color:#383A42;--shiki-dark:#ABB2BF">),</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">                        args</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">                );</span></span>
<span class="line"></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">        if</span><span style="color:#383A42;--shiki-dark:#ABB2BF"> (geoResults </span><span style="color:#383A42;--shiki-dark:#56B6C2">==</span><span style="color:#986801;--shiki-dark:#D19A66"> null</span><span style="color:#383A42;--shiki-dark:#56B6C2"> ||</span><span style="color:#E45649;--shiki-dark:#E5C07B"> geoResults</span><span style="color:#383A42;--shiki-dark:#ABB2BF">.</span><span style="color:#4078F2;--shiki-dark:#61AFEF">getContent</span><span style="color:#383A42;--shiki-dark:#ABB2BF">().</span><span style="color:#4078F2;--shiki-dark:#61AFEF">isEmpty</span><span style="color:#383A42;--shiki-dark:#ABB2BF">()) {</span></span>
<span class="line"><span style="color:#E45649;--shiki-dark:#E5C07B">            log</span><span style="color:#383A42;--shiki-dark:#ABB2BF">.</span><span style="color:#4078F2;--shiki-dark:#61AFEF">info</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(</span><span style="color:#50A14F;--shiki-dark:#98C379">"召回阶段：半径 {}km 内未找到用户"</span><span style="color:#383A42;--shiki-dark:#ABB2BF">, radiusKm);</span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">            return</span><span style="color:#E45649;--shiki-dark:#E5C07B"> Collections</span><span style="color:#383A42;--shiki-dark:#ABB2BF">.</span><span style="color:#4078F2;--shiki-dark:#61AFEF">emptyList</span><span style="color:#383A42;--shiki-dark:#ABB2BF">();</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">        }</span></span>
<span class="line"></span>
<span class="line"><span style="color:#C18401;--shiki-dark:#E5C07B">        List</span><span style="color:#E45649;--shiki-dark:#ABB2BF">&lt;</span><span style="color:#C18401;--shiki-dark:#E5C07B">GeoResult</span><span style="color:#E45649;--shiki-dark:#ABB2BF">&lt;</span><span style="color:#C18401;--shiki-dark:#E5C07B">RedisGeoCommands</span><span style="color:#E45649;--shiki-dark:#ABB2BF">.</span><span style="color:#C18401;--shiki-dark:#E5C07B">GeoLocation</span><span style="color:#E45649;--shiki-dark:#ABB2BF">&lt;</span><span style="color:#C18401;--shiki-dark:#E5C07B">String</span><span style="color:#E45649;--shiki-dark:#ABB2BF">&gt;&gt;&gt; </span><span style="color:#E45649;--shiki-dark:#E06C75">results</span><span style="color:#383A42;--shiki-dark:#56B6C2"> =</span></span>
<span class="line"><span style="color:#E45649;--shiki-dark:#E5C07B">                geoResults</span><span style="color:#383A42;--shiki-dark:#ABB2BF">.</span><span style="color:#4078F2;--shiki-dark:#61AFEF">getContent</span><span style="color:#383A42;--shiki-dark:#ABB2BF">();</span></span>
<span class="line"><span style="color:#E45649;--shiki-dark:#E5C07B">        log</span><span style="color:#383A42;--shiki-dark:#ABB2BF">.</span><span style="color:#4078F2;--shiki-dark:#61AFEF">info</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(</span><span style="color:#50A14F;--shiki-dark:#98C379">"召回阶段：半径 {}km 内召回 {} 个用户"</span><span style="color:#383A42;--shiki-dark:#ABB2BF">, radiusKm, </span><span style="color:#E45649;--shiki-dark:#E5C07B">results</span><span style="color:#383A42;--shiki-dark:#ABB2BF">.</span><span style="color:#4078F2;--shiki-dark:#61AFEF">size</span><span style="color:#383A42;--shiki-dark:#ABB2BF">());</span></span>
<span class="line"></span>
<span class="line"><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic">        // ============ Step 2: 批量获取活跃度 ============</span></span>
<span class="line"><span style="color:#C18401;--shiki-dark:#E5C07B">        List</span><span style="color:#E45649;--shiki-dark:#ABB2BF">&lt;</span><span style="color:#C18401;--shiki-dark:#E5C07B">String</span><span style="color:#E45649;--shiki-dark:#ABB2BF">&gt; </span><span style="color:#E45649;--shiki-dark:#E06C75">userIds</span><span style="color:#383A42;--shiki-dark:#56B6C2"> =</span><span style="color:#E45649;--shiki-dark:#E5C07B"> results</span><span style="color:#383A42;--shiki-dark:#ABB2BF">.</span><span style="color:#4078F2;--shiki-dark:#61AFEF">stream</span><span style="color:#383A42;--shiki-dark:#ABB2BF">()</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">                .</span><span style="color:#4078F2;--shiki-dark:#61AFEF">map</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(r </span><span style="color:#C18401;--shiki-dark:#C678DD">-&gt;</span><span style="color:#E45649;--shiki-dark:#E5C07B"> r</span><span style="color:#383A42;--shiki-dark:#ABB2BF">.</span><span style="color:#4078F2;--shiki-dark:#61AFEF">getContent</span><span style="color:#383A42;--shiki-dark:#ABB2BF">().</span><span style="color:#4078F2;--shiki-dark:#61AFEF">getName</span><span style="color:#383A42;--shiki-dark:#ABB2BF">())</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">                .</span><span style="color:#4078F2;--shiki-dark:#61AFEF">toList</span><span style="color:#383A42;--shiki-dark:#ABB2BF">();</span></span>
<span class="line"></span>
<span class="line"><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic">        // 使用 HMGET 一次网络往返拿到所有活跃度</span></span>
<span class="line"><span style="color:#C18401;--shiki-dark:#E5C07B">        List</span><span style="color:#E45649;--shiki-dark:#ABB2BF">&lt;</span><span style="color:#C18401;--shiki-dark:#E5C07B">Object</span><span style="color:#E45649;--shiki-dark:#ABB2BF">&gt; </span><span style="color:#E45649;--shiki-dark:#E06C75">activityScores</span><span style="color:#383A42;--shiki-dark:#56B6C2"> =</span><span style="color:#E45649;--shiki-dark:#E5C07B"> redisTemplate</span><span style="color:#383A42;--shiki-dark:#ABB2BF">.</span><span style="color:#4078F2;--shiki-dark:#61AFEF">opsForHash</span><span style="color:#383A42;--shiki-dark:#ABB2BF">()</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">                .</span><span style="color:#4078F2;--shiki-dark:#61AFEF">multiGet</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(ACTIVITY_KEY, </span><span style="color:#E45649;--shiki-dark:#E5C07B">Collections</span><span style="color:#383A42;--shiki-dark:#ABB2BF">.</span><span style="color:#4078F2;--shiki-dark:#61AFEF">unmodifiableList</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(userIds));</span></span>
<span class="line"></span>
<span class="line"><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic">        // 组装 Map&lt;userId, activityScore&gt;</span></span>
<span class="line"><span style="color:#C18401;--shiki-dark:#E5C07B">        Map</span><span style="color:#E45649;--shiki-dark:#ABB2BF">&lt;</span><span style="color:#C18401;--shiki-dark:#E5C07B">String</span><span style="color:#E45649;--shiki-dark:#ABB2BF">, </span><span style="color:#C18401;--shiki-dark:#E5C07B">Integer</span><span style="color:#E45649;--shiki-dark:#ABB2BF">&gt; </span><span style="color:#E45649;--shiki-dark:#E06C75">activityMap</span><span style="color:#383A42;--shiki-dark:#56B6C2"> =</span><span style="color:#A626A4;--shiki-dark:#C678DD"> new</span><span style="color:#C18401;--shiki-dark:#E5C07B"> HashMap</span><span style="color:#383A42;--shiki-dark:#ABB2BF">&lt;&gt;();</span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">        for</span><span style="color:#383A42;--shiki-dark:#ABB2BF"> (</span><span style="color:#A626A4;--shiki-dark:#C678DD">int</span><span style="color:#E45649;--shiki-dark:#E06C75"> i</span><span style="color:#383A42;--shiki-dark:#56B6C2"> =</span><span style="color:#986801;--shiki-dark:#D19A66"> 0</span><span style="color:#383A42;--shiki-dark:#ABB2BF">; i </span><span style="color:#383A42;--shiki-dark:#56B6C2">&lt;</span><span style="color:#E45649;--shiki-dark:#E5C07B"> userIds</span><span style="color:#383A42;--shiki-dark:#ABB2BF">.</span><span style="color:#4078F2;--shiki-dark:#61AFEF">size</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(); i++) {</span></span>
<span class="line"><span style="color:#C18401;--shiki-dark:#E5C07B">            Object</span><span style="color:#E45649;--shiki-dark:#E06C75"> raw</span><span style="color:#383A42;--shiki-dark:#56B6C2"> =</span><span style="color:#E45649;--shiki-dark:#E5C07B"> activityScores</span><span style="color:#383A42;--shiki-dark:#ABB2BF">.</span><span style="color:#4078F2;--shiki-dark:#61AFEF">get</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(i);</span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">            int</span><span style="color:#E45649;--shiki-dark:#E06C75"> score</span><span style="color:#383A42;--shiki-dark:#56B6C2"> =</span><span style="color:#383A42;--shiki-dark:#ABB2BF"> (raw </span><span style="color:#383A42;--shiki-dark:#56B6C2">!=</span><span style="color:#986801;--shiki-dark:#D19A66"> null</span><span style="color:#383A42;--shiki-dark:#ABB2BF">) </span><span style="color:#A626A4;--shiki-dark:#C678DD">?</span><span style="color:#E45649;--shiki-dark:#E5C07B"> Integer</span><span style="color:#383A42;--shiki-dark:#ABB2BF">.</span><span style="color:#4078F2;--shiki-dark:#61AFEF">parseInt</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(</span><span style="color:#E45649;--shiki-dark:#E5C07B">raw</span><span style="color:#383A42;--shiki-dark:#ABB2BF">.</span><span style="color:#4078F2;--shiki-dark:#61AFEF">toString</span><span style="color:#383A42;--shiki-dark:#ABB2BF">()) </span><span style="color:#A626A4;--shiki-dark:#C678DD">:</span><span style="color:#986801;--shiki-dark:#D19A66"> 0</span><span style="color:#383A42;--shiki-dark:#ABB2BF">;</span></span>
<span class="line"><span style="color:#E45649;--shiki-dark:#E5C07B">            activityMap</span><span style="color:#383A42;--shiki-dark:#ABB2BF">.</span><span style="color:#4078F2;--shiki-dark:#61AFEF">put</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(</span><span style="color:#E45649;--shiki-dark:#E5C07B">userIds</span><span style="color:#383A42;--shiki-dark:#ABB2BF">.</span><span style="color:#4078F2;--shiki-dark:#61AFEF">get</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(i), score);</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">        }</span></span>
<span class="line"></span>
<span class="line"><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic">        // ============ Step 3: 构建候选列表 ============</span></span>
<span class="line"><span style="color:#C18401;--shiki-dark:#E5C07B">        List</span><span style="color:#E45649;--shiki-dark:#ABB2BF">&lt;</span><span style="color:#C18401;--shiki-dark:#E5C07B">NearbyUser</span><span style="color:#E45649;--shiki-dark:#ABB2BF">&gt; </span><span style="color:#E45649;--shiki-dark:#E06C75">candidates</span><span style="color:#383A42;--shiki-dark:#56B6C2"> =</span><span style="color:#E45649;--shiki-dark:#E5C07B"> results</span><span style="color:#383A42;--shiki-dark:#ABB2BF">.</span><span style="color:#4078F2;--shiki-dark:#61AFEF">stream</span><span style="color:#383A42;--shiki-dark:#ABB2BF">()</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">                .</span><span style="color:#4078F2;--shiki-dark:#61AFEF">map</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(r </span><span style="color:#C18401;--shiki-dark:#C678DD">-&gt;</span><span style="color:#A626A4;--shiki-dark:#C678DD"> new</span><span style="color:#4078F2;--shiki-dark:#61AFEF"> NearbyUser</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(</span></span>
<span class="line"><span style="color:#E45649;--shiki-dark:#E5C07B">                        r</span><span style="color:#383A42;--shiki-dark:#ABB2BF">.</span><span style="color:#4078F2;--shiki-dark:#61AFEF">getContent</span><span style="color:#383A42;--shiki-dark:#ABB2BF">().</span><span style="color:#4078F2;--shiki-dark:#61AFEF">getName</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(),</span></span>
<span class="line"><span style="color:#E45649;--shiki-dark:#E5C07B">                        r</span><span style="color:#383A42;--shiki-dark:#ABB2BF">.</span><span style="color:#4078F2;--shiki-dark:#61AFEF">getDistance</span><span style="color:#383A42;--shiki-dark:#ABB2BF">().</span><span style="color:#4078F2;--shiki-dark:#61AFEF">getValue</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(),</span></span>
<span class="line"><span style="color:#E45649;--shiki-dark:#E5C07B">                        activityMap</span><span style="color:#383A42;--shiki-dark:#ABB2BF">.</span><span style="color:#4078F2;--shiki-dark:#61AFEF">getOrDefault</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(</span><span style="color:#E45649;--shiki-dark:#E5C07B">r</span><span style="color:#383A42;--shiki-dark:#ABB2BF">.</span><span style="color:#4078F2;--shiki-dark:#61AFEF">getContent</span><span style="color:#383A42;--shiki-dark:#ABB2BF">().</span><span style="color:#4078F2;--shiki-dark:#61AFEF">getName</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(), </span><span style="color:#986801;--shiki-dark:#D19A66">0</span><span style="color:#383A42;--shiki-dark:#ABB2BF">)</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">                ))</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">                .</span><span style="color:#4078F2;--shiki-dark:#61AFEF">toList</span><span style="color:#383A42;--shiki-dark:#ABB2BF">();</span></span>
<span class="line"></span>
<span class="line"><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic">        // ============ Step 4: 归一化 + 加权打分 ============</span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">        return</span><span style="color:#4078F2;--shiki-dark:#61AFEF"> rerank</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(candidates, pageSize);</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">    }</span></span>
<span class="line"></span>
<span class="line"><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic">    /**</span></span>
<span class="line"><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic">     * 精排：归一化 + 加权 + 截断</span></span>
<span class="line"><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic">     */</span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">    private</span><span style="color:#C18401;--shiki-dark:#E5C07B"> List</span><span style="color:#383A42;--shiki-dark:#ABB2BF">&lt;</span><span style="color:#C18401;--shiki-dark:#E5C07B">NearbyUser</span><span style="color:#383A42;--shiki-dark:#ABB2BF">&gt;</span><span style="color:#4078F2;--shiki-dark:#61AFEF"> rerank</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(</span><span style="color:#C18401;--shiki-dark:#E5C07B">List</span><span style="color:#383A42;--shiki-dark:#ABB2BF">&lt;</span><span style="color:#C18401;--shiki-dark:#E5C07B">NearbyUser</span><span style="color:#383A42;--shiki-dark:#ABB2BF">&gt; </span><span style="color:#383A42;--shiki-light-font-style:inherit;--shiki-dark:#E06C75;--shiki-dark-font-style:italic">candidates</span><span style="color:#383A42;--shiki-dark:#ABB2BF">, </span><span style="color:#A626A4;--shiki-dark:#C678DD">int</span><span style="color:#383A42;--shiki-light-font-style:inherit;--shiki-dark:#E06C75;--shiki-dark-font-style:italic"> pageSize</span><span style="color:#383A42;--shiki-dark:#ABB2BF">)</span><span style="color:#383A42;--shiki-dark:#ABB2BF"> {</span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">        if</span><span style="color:#383A42;--shiki-dark:#ABB2BF"> (</span><span style="color:#E45649;--shiki-dark:#E5C07B">candidates</span><span style="color:#383A42;--shiki-dark:#ABB2BF">.</span><span style="color:#4078F2;--shiki-dark:#61AFEF">isEmpty</span><span style="color:#383A42;--shiki-dark:#ABB2BF">()) {</span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">            return</span><span style="color:#E45649;--shiki-dark:#E5C07B"> Collections</span><span style="color:#383A42;--shiki-dark:#ABB2BF">.</span><span style="color:#4078F2;--shiki-dark:#61AFEF">emptyList</span><span style="color:#383A42;--shiki-dark:#ABB2BF">();</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">        }</span></span>
<span class="line"></span>
<span class="line"><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic">        // --- 4.1 找出距离和活跃度的最大最小值 ---</span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">        double</span><span style="color:#E45649;--shiki-dark:#E06C75"> maxDist</span><span style="color:#383A42;--shiki-dark:#56B6C2"> =</span><span style="color:#E45649;--shiki-dark:#E5C07B"> candidates</span><span style="color:#383A42;--shiki-dark:#ABB2BF">.</span><span style="color:#4078F2;--shiki-dark:#61AFEF">stream</span><span style="color:#383A42;--shiki-dark:#ABB2BF">()</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">                .</span><span style="color:#4078F2;--shiki-dark:#61AFEF">mapToDouble</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(NearbyUser</span><span style="color:#A626A4;--shiki-dark:#C678DD">::</span><span style="color:#383A42;--shiki-dark:#ABB2BF">distanceKm).</span><span style="color:#4078F2;--shiki-dark:#61AFEF">max</span><span style="color:#383A42;--shiki-dark:#ABB2BF">().</span><span style="color:#4078F2;--shiki-dark:#61AFEF">orElse</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(</span><span style="color:#986801;--shiki-dark:#D19A66">1.0</span><span style="color:#383A42;--shiki-dark:#ABB2BF">);</span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">        double</span><span style="color:#E45649;--shiki-dark:#E06C75"> minDist</span><span style="color:#383A42;--shiki-dark:#56B6C2"> =</span><span style="color:#E45649;--shiki-dark:#E5C07B"> candidates</span><span style="color:#383A42;--shiki-dark:#ABB2BF">.</span><span style="color:#4078F2;--shiki-dark:#61AFEF">stream</span><span style="color:#383A42;--shiki-dark:#ABB2BF">()</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">                .</span><span style="color:#4078F2;--shiki-dark:#61AFEF">mapToDouble</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(NearbyUser</span><span style="color:#A626A4;--shiki-dark:#C678DD">::</span><span style="color:#383A42;--shiki-dark:#ABB2BF">distanceKm).</span><span style="color:#4078F2;--shiki-dark:#61AFEF">min</span><span style="color:#383A42;--shiki-dark:#ABB2BF">().</span><span style="color:#4078F2;--shiki-dark:#61AFEF">orElse</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(</span><span style="color:#986801;--shiki-dark:#D19A66">0.0</span><span style="color:#383A42;--shiki-dark:#ABB2BF">);</span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">        int</span><span style="color:#E45649;--shiki-dark:#E06C75"> maxActivity</span><span style="color:#383A42;--shiki-dark:#56B6C2"> =</span><span style="color:#E45649;--shiki-dark:#E5C07B"> candidates</span><span style="color:#383A42;--shiki-dark:#ABB2BF">.</span><span style="color:#4078F2;--shiki-dark:#61AFEF">stream</span><span style="color:#383A42;--shiki-dark:#ABB2BF">()</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">                .</span><span style="color:#4078F2;--shiki-dark:#61AFEF">mapToInt</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(NearbyUser</span><span style="color:#A626A4;--shiki-dark:#C678DD">::</span><span style="color:#383A42;--shiki-dark:#ABB2BF">activityScore).</span><span style="color:#4078F2;--shiki-dark:#61AFEF">max</span><span style="color:#383A42;--shiki-dark:#ABB2BF">().</span><span style="color:#4078F2;--shiki-dark:#61AFEF">orElse</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(</span><span style="color:#986801;--shiki-dark:#D19A66">1</span><span style="color:#383A42;--shiki-dark:#ABB2BF">);</span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">        int</span><span style="color:#E45649;--shiki-dark:#E06C75"> minActivity</span><span style="color:#383A42;--shiki-dark:#56B6C2"> =</span><span style="color:#E45649;--shiki-dark:#E5C07B"> candidates</span><span style="color:#383A42;--shiki-dark:#ABB2BF">.</span><span style="color:#4078F2;--shiki-dark:#61AFEF">stream</span><span style="color:#383A42;--shiki-dark:#ABB2BF">()</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">                .</span><span style="color:#4078F2;--shiki-dark:#61AFEF">mapToInt</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(NearbyUser</span><span style="color:#A626A4;--shiki-dark:#C678DD">::</span><span style="color:#383A42;--shiki-dark:#ABB2BF">activityScore).</span><span style="color:#4078F2;--shiki-dark:#61AFEF">min</span><span style="color:#383A42;--shiki-dark:#ABB2BF">().</span><span style="color:#4078F2;--shiki-dark:#61AFEF">orElse</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(</span><span style="color:#986801;--shiki-dark:#D19A66">0</span><span style="color:#383A42;--shiki-dark:#ABB2BF">);</span></span>
<span class="line"></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">        double</span><span style="color:#E45649;--shiki-dark:#E06C75"> distRange</span><span style="color:#383A42;--shiki-dark:#56B6C2"> =</span><span style="color:#383A42;--shiki-dark:#ABB2BF"> (maxDist </span><span style="color:#383A42;--shiki-dark:#56B6C2">-</span><span style="color:#383A42;--shiki-dark:#ABB2BF"> minDist) </span><span style="color:#383A42;--shiki-dark:#56B6C2">==</span><span style="color:#986801;--shiki-dark:#D19A66"> 0</span><span style="color:#A626A4;--shiki-dark:#C678DD"> ?</span><span style="color:#986801;--shiki-dark:#D19A66"> 1.0</span><span style="color:#A626A4;--shiki-dark:#C678DD"> :</span><span style="color:#383A42;--shiki-dark:#ABB2BF"> (maxDist </span><span style="color:#383A42;--shiki-dark:#56B6C2">-</span><span style="color:#383A42;--shiki-dark:#ABB2BF"> minDist);</span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">        double</span><span style="color:#E45649;--shiki-dark:#E06C75"> actRange</span><span style="color:#383A42;--shiki-dark:#56B6C2"> =</span><span style="color:#383A42;--shiki-dark:#ABB2BF"> (maxActivity </span><span style="color:#383A42;--shiki-dark:#56B6C2">-</span><span style="color:#383A42;--shiki-dark:#ABB2BF"> minActivity) </span><span style="color:#383A42;--shiki-dark:#56B6C2">==</span><span style="color:#986801;--shiki-dark:#D19A66"> 0</span><span style="color:#A626A4;--shiki-dark:#C678DD"> ?</span><span style="color:#986801;--shiki-dark:#D19A66"> 1.0</span><span style="color:#A626A4;--shiki-dark:#C678DD"> :</span><span style="color:#383A42;--shiki-dark:#ABB2BF"> (maxActivity </span><span style="color:#383A42;--shiki-dark:#56B6C2">-</span><span style="color:#383A42;--shiki-dark:#ABB2BF"> minActivity);</span></span>
<span class="line"></span>
<span class="line"><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic">        // --- 4.2 归一化 + 加权计算 ---</span></span>
<span class="line"><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic">        // 距离：越近得分越高 → distScore = 1 - (dist - minDist) / distRange</span></span>
<span class="line"><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic">        // 活跃度：越高得分越高 → actScore = (act - minActivity) / actRange</span></span>
<span class="line"><span style="color:#C18401;--shiki-dark:#E5C07B">        List</span><span style="color:#E45649;--shiki-dark:#ABB2BF">&lt;</span><span style="color:#C18401;--shiki-dark:#E5C07B">NearbyUser</span><span style="color:#E45649;--shiki-dark:#ABB2BF">&gt; </span><span style="color:#E45649;--shiki-dark:#E06C75">scored</span><span style="color:#383A42;--shiki-dark:#56B6C2"> =</span><span style="color:#E45649;--shiki-dark:#E5C07B"> candidates</span><span style="color:#383A42;--shiki-dark:#ABB2BF">.</span><span style="color:#4078F2;--shiki-dark:#61AFEF">stream</span><span style="color:#383A42;--shiki-dark:#ABB2BF">()</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">                .</span><span style="color:#4078F2;--shiki-dark:#61AFEF">map</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(u </span><span style="color:#C18401;--shiki-dark:#C678DD">-&gt;</span><span style="color:#383A42;--shiki-dark:#ABB2BF"> {</span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">                    double</span><span style="color:#E45649;--shiki-dark:#E06C75"> distScore</span><span style="color:#383A42;--shiki-dark:#56B6C2"> =</span><span style="color:#986801;--shiki-dark:#D19A66"> 1.0</span><span style="color:#383A42;--shiki-dark:#56B6C2"> -</span><span style="color:#383A42;--shiki-dark:#ABB2BF"> (</span><span style="color:#E45649;--shiki-dark:#E5C07B">u</span><span style="color:#383A42;--shiki-dark:#ABB2BF">.</span><span style="color:#4078F2;--shiki-dark:#61AFEF">distanceKm</span><span style="color:#383A42;--shiki-dark:#ABB2BF">() </span><span style="color:#383A42;--shiki-dark:#56B6C2">-</span><span style="color:#383A42;--shiki-dark:#ABB2BF"> minDist) </span><span style="color:#383A42;--shiki-dark:#56B6C2">/</span><span style="color:#383A42;--shiki-dark:#ABB2BF"> distRange;</span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">                    double</span><span style="color:#E45649;--shiki-dark:#E06C75"> actScore</span><span style="color:#383A42;--shiki-dark:#56B6C2"> =</span><span style="color:#383A42;--shiki-dark:#ABB2BF"> (</span><span style="color:#E45649;--shiki-dark:#E5C07B">u</span><span style="color:#383A42;--shiki-dark:#ABB2BF">.</span><span style="color:#4078F2;--shiki-dark:#61AFEF">activityScore</span><span style="color:#383A42;--shiki-dark:#ABB2BF">() </span><span style="color:#383A42;--shiki-dark:#56B6C2">-</span><span style="color:#383A42;--shiki-dark:#ABB2BF"> minActivity) </span><span style="color:#383A42;--shiki-dark:#56B6C2">/</span><span style="color:#383A42;--shiki-dark:#ABB2BF"> actRange;</span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">                    double</span><span style="color:#E45649;--shiki-dark:#E06C75"> finalScore</span><span style="color:#383A42;--shiki-dark:#56B6C2"> =</span><span style="color:#383A42;--shiki-dark:#ABB2BF"> WEIGHT_DISTANCE </span><span style="color:#383A42;--shiki-dark:#56B6C2">*</span><span style="color:#383A42;--shiki-dark:#ABB2BF"> distScore</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#56B6C2">                                      +</span><span style="color:#383A42;--shiki-dark:#ABB2BF"> WEIGHT_ACTIVITY </span><span style="color:#383A42;--shiki-dark:#56B6C2">*</span><span style="color:#383A42;--shiki-dark:#ABB2BF"> actScore;</span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">                    return</span><span style="color:#E45649;--shiki-dark:#E5C07B"> u</span><span style="color:#383A42;--shiki-dark:#ABB2BF">.</span><span style="color:#4078F2;--shiki-dark:#61AFEF">withFinalScore</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(finalScore);</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">                })</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">                .</span><span style="color:#4078F2;--shiki-dark:#61AFEF">toList</span><span style="color:#383A42;--shiki-dark:#ABB2BF">();</span></span>
<span class="line"></span>
<span class="line"><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic">        // --- 4.3 按 finalScore 降序排列，截断返回 ---</span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">        return</span><span style="color:#E45649;--shiki-dark:#E5C07B"> scored</span><span style="color:#383A42;--shiki-dark:#ABB2BF">.</span><span style="color:#4078F2;--shiki-dark:#61AFEF">stream</span><span style="color:#383A42;--shiki-dark:#ABB2BF">()</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">                .</span><span style="color:#4078F2;--shiki-dark:#61AFEF">sorted</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(</span><span style="color:#E45649;--shiki-dark:#E5C07B">Comparator</span><span style="color:#383A42;--shiki-dark:#ABB2BF">.</span><span style="color:#4078F2;--shiki-dark:#61AFEF">comparingDouble</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(NearbyUser</span><span style="color:#A626A4;--shiki-dark:#C678DD">::</span><span style="color:#383A42;--shiki-dark:#ABB2BF">finalScore).</span><span style="color:#4078F2;--shiki-dark:#61AFEF">reversed</span><span style="color:#383A42;--shiki-dark:#ABB2BF">())</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">                .</span><span style="color:#4078F2;--shiki-dark:#61AFEF">limit</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(pageSize)</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">                .</span><span style="color:#4078F2;--shiki-dark:#61AFEF">collect</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(</span><span style="color:#E45649;--shiki-dark:#E5C07B">Collectors</span><span style="color:#383A42;--shiki-dark:#ABB2BF">.</span><span style="color:#4078F2;--shiki-dark:#61AFEF">toList</span><span style="color:#383A42;--shiki-dark:#ABB2BF">());</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">    }</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">}</span></span></code></pre>
<h4 class="anchor anchorTargetStickyNavbar_Vzrq" id="33-controller-层">3.3 Controller 层<a href="https://blog.xiaoxiaoliang.top/en/blog/redis-multi-sort#33-controller-%E5%B1%82" class="hash-link" aria-label="Direct link to 3.3 Controller 层" title="Direct link to 3.3 Controller 层" translate="no">​</a></h4>
<pre tabindex="0" class="codeBlockStandalone_MEMb thin-scrollbar language-java codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#0f172a;--prism-background-color:#f1f5f9"><code class="codeBlockLines_e6Vv"><span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">import</span><span style="color:#C18401;--shiki-dark:#E5C07B"> lombok.RequiredArgsConstructor</span><span style="color:#383A42;--shiki-dark:#ABB2BF">;</span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">import</span><span style="color:#C18401;--shiki-dark:#E5C07B"> org.springframework.web.bind.annotation.</span><span style="color:#E45649;--shiki-dark:#E5C07B">*</span><span style="color:#383A42;--shiki-dark:#ABB2BF">;</span></span>
<span class="line"></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">import</span><span style="color:#C18401;--shiki-dark:#E5C07B"> java.util.List</span><span style="color:#383A42;--shiki-dark:#ABB2BF">;</span></span>
<span class="line"></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">@</span><span style="color:#A626A4;--shiki-dark:#E5C07B">RestController</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">@</span><span style="color:#A626A4;--shiki-dark:#E5C07B">RequestMapping</span><span style="color:#383A42;--shiki-dark:#E06C75">(</span><span style="color:#50A14F;--shiki-dark:#98C379">"/api/nearby"</span><span style="color:#383A42;--shiki-dark:#E06C75">)</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">@</span><span style="color:#A626A4;--shiki-dark:#E5C07B">RequiredArgsConstructor</span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">public</span><span style="color:#A626A4;--shiki-dark:#C678DD"> class</span><span style="color:#C18401;--shiki-dark:#E5C07B"> NearbyUserController</span><span style="color:#383A42;--shiki-dark:#ABB2BF"> {</span></span>
<span class="line"></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">    private</span><span style="color:#A626A4;--shiki-dark:#C678DD"> final</span><span style="color:#C18401;--shiki-dark:#E5C07B"> NearbyUserService</span><span style="color:#E45649;--shiki-dark:#E06C75"> nearbyUserService</span><span style="color:#383A42;--shiki-dark:#ABB2BF">;</span></span>
<span class="line"></span>
<span class="line"><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic">    /**</span></span>
<span class="line"><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic">     * 查询附近的人</span></span>
<span class="line"><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic">     *</span></span>
<span class="line"><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic">     * </span><span style="color:#A626A4;--shiki-light-font-style:italic;--shiki-dark:#C678DD;--shiki-dark-font-style:italic">@param</span><span style="color:#383A42;--shiki-light-font-style:italic;--shiki-dark:#E06C75;--shiki-dark-font-style:italic"> lon</span><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic">      我的经度</span></span>
<span class="line"><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic">     * </span><span style="color:#A626A4;--shiki-light-font-style:italic;--shiki-dark:#C678DD;--shiki-dark-font-style:italic">@param</span><span style="color:#383A42;--shiki-light-font-style:italic;--shiki-dark:#E06C75;--shiki-dark-font-style:italic"> lat</span><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic">      我的纬度</span></span>
<span class="line"><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic">     * </span><span style="color:#A626A4;--shiki-light-font-style:italic;--shiki-dark:#C678DD;--shiki-dark-font-style:italic">@param</span><span style="color:#383A42;--shiki-light-font-style:italic;--shiki-dark:#E06C75;--shiki-dark-font-style:italic"> radiusKm</span><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic"> 搜索半径，默认 10km</span></span>
<span class="line"><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic">     * </span><span style="color:#A626A4;--shiki-light-font-style:italic;--shiki-dark:#C678DD;--shiki-dark-font-style:italic">@param</span><span style="color:#383A42;--shiki-light-font-style:italic;--shiki-dark:#E06C75;--shiki-dark-font-style:italic"> size</span><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic">     返回条数，默认 5</span></span>
<span class="line"><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic">     */</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">    @</span><span style="color:#A626A4;--shiki-dark:#E5C07B">GetMapping</span><span style="color:#383A42;--shiki-dark:#E06C75">(</span><span style="color:#50A14F;--shiki-dark:#98C379">"/users"</span><span style="color:#383A42;--shiki-dark:#E06C75">)</span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">    public</span><span style="color:#C18401;--shiki-dark:#E5C07B"> List</span><span style="color:#383A42;--shiki-dark:#ABB2BF">&lt;</span><span style="color:#C18401;--shiki-dark:#E5C07B">NearbyUser</span><span style="color:#383A42;--shiki-dark:#ABB2BF">&gt;</span><span style="color:#4078F2;--shiki-dark:#61AFEF"> nearby</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">            @</span><span style="color:#A626A4;--shiki-dark:#E5C07B">RequestParam</span><span style="color:#A626A4;--shiki-dark:#C678DD"> double</span><span style="color:#383A42;--shiki-light-font-style:inherit;--shiki-dark:#E06C75;--shiki-dark-font-style:italic"> lon</span><span style="color:#383A42;--shiki-dark:#ABB2BF">,</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">            @</span><span style="color:#A626A4;--shiki-dark:#E5C07B">RequestParam</span><span style="color:#A626A4;--shiki-dark:#C678DD"> double</span><span style="color:#383A42;--shiki-light-font-style:inherit;--shiki-dark:#E06C75;--shiki-dark-font-style:italic"> lat</span><span style="color:#383A42;--shiki-dark:#ABB2BF">,</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">            @</span><span style="color:#A626A4;--shiki-dark:#E5C07B">RequestParam</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(</span><span style="color:#986801;--shiki-dark:#D19A66">defaultValue</span><span style="color:#383A42;--shiki-dark:#56B6C2"> =</span><span style="color:#50A14F;--shiki-dark:#98C379"> "10"</span><span style="color:#383A42;--shiki-dark:#ABB2BF">) </span><span style="color:#A626A4;--shiki-dark:#C678DD">double</span><span style="color:#383A42;--shiki-light-font-style:inherit;--shiki-dark:#E06C75;--shiki-dark-font-style:italic"> radiusKm</span><span style="color:#383A42;--shiki-dark:#ABB2BF">,</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">            @</span><span style="color:#A626A4;--shiki-dark:#E5C07B">RequestParam</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(</span><span style="color:#986801;--shiki-dark:#D19A66">defaultValue</span><span style="color:#383A42;--shiki-dark:#56B6C2"> =</span><span style="color:#50A14F;--shiki-dark:#98C379"> "5"</span><span style="color:#383A42;--shiki-dark:#ABB2BF">) </span><span style="color:#A626A4;--shiki-dark:#C678DD">int</span><span style="color:#383A42;--shiki-light-font-style:inherit;--shiki-dark:#E06C75;--shiki-dark-font-style:italic"> size</span><span style="color:#383A42;--shiki-dark:#ABB2BF">)</span><span style="color:#383A42;--shiki-dark:#ABB2BF"> {</span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">        return</span><span style="color:#E45649;--shiki-dark:#E5C07B"> nearbyUserService</span><span style="color:#383A42;--shiki-dark:#ABB2BF">.</span><span style="color:#4078F2;--shiki-dark:#61AFEF">findNearbyUsers</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(lon, lat, radiusKm, size);</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">    }</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">}</span></span></code></pre>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="4-mock-数据走一遍全流程">4. Mock 数据走一遍全流程<a href="https://blog.xiaoxiaoliang.top/en/blog/redis-multi-sort#4-mock-%E6%95%B0%E6%8D%AE%E8%B5%B0%E4%B8%80%E9%81%8D%E5%85%A8%E6%B5%81%E7%A8%8B" class="hash-link" aria-label="Direct link to 4. Mock 数据走一遍全流程" title="Direct link to 4. Mock 数据走一遍全流程" translate="no">​</a></h3>
<p>假设客户端请求：<code>GET /api/nearby/users?lon=116.404&amp;lat=39.915&amp;radiusKm=10&amp;size=3</code></p>
<h4 class="anchor anchorTargetStickyNavbar_Vzrq" id="step-1召回阶段">Step 1：召回阶段<a href="https://blog.xiaoxiaoliang.top/en/blog/redis-multi-sort#step-1%E5%8F%AC%E5%9B%9E%E9%98%B6%E6%AE%B5" class="hash-link" aria-label="Direct link to Step 1：召回阶段" title="Direct link to Step 1：召回阶段" translate="no">​</a></h4>
<p>Redis <code>GEOSEARCH</code> 在 10km 半径内按距离升序返回（<code>size=3</code>，<code>RECALL_RATIO=10</code>，所以召回 30 条，但我们只有 7 条在范围内）：</p>
<table><thead><tr><th style="text-align:left">排序</th><th style="text-align:left">用户</th><th style="text-align:left">距离(km)</th></tr></thead><tbody><tr><td style="text-align:left">1</td><td style="text-align:left">user<!-- -->:A</td><td style="text-align:left">0.50</td></tr><tr><td style="text-align:left">2</td><td style="text-align:left">user<!-- -->:F</td><td style="text-align:left">0.52</td></tr><tr><td style="text-align:left">3</td><td style="text-align:left">user<!-- -->:B</td><td style="text-align:left">0.72</td></tr><tr><td style="text-align:left">4</td><td style="text-align:left">user<!-- -->:H</td><td style="text-align:left">1.52</td></tr><tr><td style="text-align:left">5</td><td style="text-align:left">user<!-- -->:C</td><td style="text-align:left">2.10</td></tr><tr><td style="text-align:left">6</td><td style="text-align:left">user<!-- -->:E</td><td style="text-align:left">2.71</td></tr><tr><td style="text-align:left">7</td><td style="text-align:left">user<!-- -->:D</td><td style="text-align:left">5.30</td></tr></tbody></table>
<blockquote>
<p>user<!-- -->:G<!-- --> 距离 11km，超出 10km 半径，被过滤掉了。</p>
</blockquote>
<h4 class="anchor anchorTargetStickyNavbar_Vzrq" id="step-2批量查活跃度">Step 2：批量查活跃度<a href="https://blog.xiaoxiaoliang.top/en/blog/redis-multi-sort#step-2%E6%89%B9%E9%87%8F%E6%9F%A5%E6%B4%BB%E8%B7%83%E5%BA%A6" class="hash-link" aria-label="Direct link to Step 2：批量查活跃度" title="Direct link to Step 2：批量查活跃度" translate="no">​</a></h4>
<p>一次 <code>HMGET user:activity user:A user:F user:B user:H user:C user:E user:D</code>，得到：</p>
<table><thead><tr><th style="text-align:left">用户</th><th style="text-align:left">距离(km)</th><th style="text-align:left">活跃度</th></tr></thead><tbody><tr><td style="text-align:left">user<!-- -->:A</td><td style="text-align:left">0.50</td><td style="text-align:left">30</td></tr><tr><td style="text-align:left">user<!-- -->:F</td><td style="text-align:left">0.52</td><td style="text-align:left">50</td></tr><tr><td style="text-align:left">user<!-- -->:B</td><td style="text-align:left">0.72</td><td style="text-align:left">95</td></tr><tr><td style="text-align:left">user<!-- -->:H</td><td style="text-align:left">1.52</td><td style="text-align:left">75</td></tr><tr><td style="text-align:left">user<!-- -->:C</td><td style="text-align:left">2.10</td><td style="text-align:left">60</td></tr><tr><td style="text-align:left">user<!-- -->:E</td><td style="text-align:left">2.71</td><td style="text-align:left">10</td></tr><tr><td style="text-align:left">user<!-- -->:D</td><td style="text-align:left">5.30</td><td style="text-align:left">85</td></tr></tbody></table>
<h4 class="anchor anchorTargetStickyNavbar_Vzrq" id="step-3归一化计算">Step 3：归一化计算<a href="https://blog.xiaoxiaoliang.top/en/blog/redis-multi-sort#step-3%E5%BD%92%E4%B8%80%E5%8C%96%E8%AE%A1%E7%AE%97" class="hash-link" aria-label="Direct link to Step 3：归一化计算" title="Direct link to Step 3：归一化计算" translate="no">​</a></h4>
<p>找出最值：</p>
<ul>
<li class="">距离：min=0.50，max=5.30，range=4.80</li>
<li class="">活跃度：min=10，max=95，range=85</li>
</ul>
<p>归一化公式：</p>
<ul>
<li class=""><strong>距离得分</strong> = <code>1 - (dist - 0.50) / 4.80</code>（越近越高）</li>
<li class=""><strong>活跃度得分</strong> = <code>(activity - 10) / 85</code>（越活跃越高）</li>
</ul>
<table><thead><tr><th style="text-align:left">用户</th><th style="text-align:left">距离(km)</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">user<!-- -->:A</td><td style="text-align:left">0.50</td><td style="text-align:left"><strong>1.000</strong></td><td style="text-align:left">30</td><td style="text-align:left">0.235</td></tr><tr><td style="text-align:left">user<!-- -->:F</td><td style="text-align:left">0.52</td><td style="text-align:left"><strong>0.996</strong></td><td style="text-align:left">50</td><td style="text-align:left">0.471</td></tr><tr><td style="text-align:left">user<!-- -->:B</td><td style="text-align:left">0.72</td><td style="text-align:left"><strong>0.954</strong></td><td style="text-align:left">95</td><td style="text-align:left"><strong>1.000</strong></td></tr><tr><td style="text-align:left">user<!-- -->:H</td><td style="text-align:left">1.52</td><td style="text-align:left">0.788</td><td style="text-align:left">75</td><td style="text-align:left">0.765</td></tr><tr><td style="text-align:left">user<!-- -->:C</td><td style="text-align:left">2.10</td><td style="text-align:left">0.667</td><td style="text-align:left">60</td><td style="text-align:left">0.588</td></tr><tr><td style="text-align:left">user<!-- -->:E</td><td style="text-align:left">2.71</td><td style="text-align:left">0.540</td><td style="text-align:left">10</td><td style="text-align:left">0.000</td></tr><tr><td style="text-align:left">user<!-- -->:D</td><td style="text-align:left">5.30</td><td style="text-align:left">0.000</td><td style="text-align:left">85</td><td style="text-align:left">0.882</td></tr></tbody></table>
<h4 class="anchor anchorTargetStickyNavbar_Vzrq" id="step-4加权打分距离-60--活跃度-40">Step 4：加权打分（距离 60% + 活跃度 40%）<a href="https://blog.xiaoxiaoliang.top/en/blog/redis-multi-sort#step-4%E5%8A%A0%E6%9D%83%E6%89%93%E5%88%86%E8%B7%9D%E7%A6%BB-60--%E6%B4%BB%E8%B7%83%E5%BA%A6-40" class="hash-link" aria-label="Direct link to Step 4：加权打分（距离 60% + 活跃度 40%）" title="Direct link to Step 4：加权打分（距离 60% + 活跃度 40%）" translate="no">​</a></h4>
<div class="language-java codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#0f172a;--prism-background-color:#f1f5f9"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-java codeBlock_bY9V thin-scrollbar" style="color:#0f172a;background-color:#f1f5f9"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#0f172a"><span class="token class-name" style="color:#0ea5e9">FinalScore</span><span class="token plain"> </span><span class="token operator" style="color:#475569">=</span><span class="token plain"> </span><span class="token number" style="color:#B5CEA8">0.6</span><span class="token plain"> × 距离归一化 </span><span class="token operator" style="color:#475569">+</span><span class="token plain"> </span><span class="token number" style="color:#B5CEA8">0.4</span><span class="token plain"> × 活跃度归一化</span><br></div></code></pre></div></div>
<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"><strong>FinalScore</strong></th><th style="text-align:left"><strong>最终排名</strong></th></tr></thead><tbody><tr><td style="text-align:left">user<!-- -->:B</td><td style="text-align:left">0.954</td><td style="text-align:left">1.000</td><td style="text-align:left"><strong>0.972</strong></td><td style="text-align:left">🥇 1</td></tr><tr><td style="text-align:left">user<!-- -->:F</td><td style="text-align:left">0.996</td><td style="text-align:left">0.471</td><td style="text-align:left"><strong>0.786</strong></td><td style="text-align:left">🥈 2</td></tr><tr><td style="text-align:left">user<!-- -->:H</td><td style="text-align:left">0.788</td><td style="text-align:left">0.765</td><td style="text-align:left"><strong>0.779</strong></td><td style="text-align:left">🥉 3</td></tr><tr><td style="text-align:left">user<!-- -->:A</td><td style="text-align:left">1.000</td><td style="text-align:left">0.235</td><td style="text-align:left">0.694</td><td style="text-align:left">4</td></tr><tr><td style="text-align:left">user<!-- -->:C</td><td style="text-align:left">0.667</td><td style="text-align:left">0.588</td><td style="text-align:left">0.636</td><td style="text-align:left">5</td></tr><tr><td style="text-align:left">user<!-- -->:D</td><td style="text-align:left">0.000</td><td style="text-align:left">0.882</td><td style="text-align:left">0.353</td><td style="text-align:left">6</td></tr><tr><td style="text-align:left">user<!-- -->:E</td><td style="text-align:left">0.540</td><td style="text-align:left">0.000</td><td style="text-align:left">0.324</td><td style="text-align:left">7</td></tr></tbody></table>
<h4 class="anchor anchorTargetStickyNavbar_Vzrq" id="step-5截断-top-3-返回">Step 5：截断 Top 3 返回<a href="https://blog.xiaoxiaoliang.top/en/blog/redis-multi-sort#step-5%E6%88%AA%E6%96%AD-top-3-%E8%BF%94%E5%9B%9E" class="hash-link" aria-label="Direct link to Step 5：截断 Top 3 返回" title="Direct link to Step 5：截断 Top 3 返回" translate="no">​</a></h4>
<p>最终返回：<strong>user<!-- -->:B<!-- --> → user<!-- -->:F<!-- --> → user<!-- -->:H</strong></p>
<blockquote>
<p>🔍 <strong>分析结果</strong>：</p>
<ul>
<li class="">user<!-- -->:A<!-- --> 虽然距离最近（0.50km），但活跃度太低（30），被挤到了第 4 名。</li>
<li class="">user<!-- -->:B<!-- --> 虽然距离稍远（0.72km），但活跃度拉满（95），综合分最高，排名第一。</li>
<li class="">user<!-- -->:D<!-- --> 虽然活跃度很高（85），但距离太远（5.3km），综合分被距离权重压低。</li>
</ul>
<p>这就是<strong>混合排序的魅力</strong>——在"近"和"活跃"之间找到最佳平衡点。</p>
</blockquote>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="5-优缺点评估">5. 优缺点评估<a href="https://blog.xiaoxiaoliang.top/en/blog/redis-multi-sort#5-%E4%BC%98%E7%BC%BA%E7%82%B9%E8%AF%84%E4%BC%B0" class="hash-link" aria-label="Direct link to 5. 优缺点评估" title="Direct link to 5. 优缺点评估" translate="no">​</a></h3>
<p><strong>✅ 优点：</strong></p>
<ul>
<li class=""><strong>兼容性极强</strong>：适用于所有 3.2+ 版本的标准 Redis，不需要任何模块或升级。</li>
<li class=""><strong>业务极度灵活</strong>：排序逻辑在 Java 代码中，你可以随时接入更多维度（VIP 等级、同城加分、性别偏好），甚至对接推荐算法模型。权重调整只需改配置，无需改任何数据结构。</li>
</ul>
<p><strong>❌ 缺点：</strong></p>
<ul>
<li class=""><strong>网络 I/O 放大</strong>：为了拿 3 条数据，实际传输了 7 条 GEO 数据 + 7 次 HASH 查询。在百万级用户场景下，这个放大效应会很明显。</li>
<li class=""><strong>JVM 内存压力</strong>：高并发场景下，每个请求都要在内存中创建候选列表、做排序，容易引发 GC 抖动。</li>
<li class=""><strong>极小概率的「漏斗效应」</strong>：如果召回池的 200 人活跃度都很低，而第 201 个人虽然远一点但活跃度极高，他会被召回阶段直接淘汰，永远不会出现在结果中。</li>
</ul>
<hr>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="方案二降维打击-原生多维联合索引存储层计算">方案二：降维打击 ——「原生多维联合索引」（存储层计算）<a href="https://blog.xiaoxiaoliang.top/en/blog/redis-multi-sort#%E6%96%B9%E6%A1%88%E4%BA%8C%E9%99%8D%E7%BB%B4%E6%89%93%E5%87%BB-%E5%8E%9F%E7%94%9F%E5%A4%9A%E7%BB%B4%E8%81%94%E5%90%88%E7%B4%A2%E5%BC%95%E5%AD%98%E5%82%A8%E5%B1%82%E8%AE%A1%E7%AE%97" class="hash-link" aria-label="Direct link to 方案二：降维打击 ——「原生多维联合索引」（存储层计算）" title="Direct link to 方案二：降维打击 ——「原生多维联合索引」（存储层计算）" translate="no">​</a></h2>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="0-在讲方案之前什么是-redisearch">0. 在讲方案之前：什么是 RediSearch？<a href="https://blog.xiaoxiaoliang.top/en/blog/redis-multi-sort#0-%E5%9C%A8%E8%AE%B2%E6%96%B9%E6%A1%88%E4%B9%8B%E5%89%8D%E4%BB%80%E4%B9%88%E6%98%AF-redisearch" class="hash-link" aria-label="Direct link to 0. 在讲方案之前：什么是 RediSearch？" title="Direct link to 0. 在讲方案之前：什么是 RediSearch？" translate="no">​</a></h3>
<p>很多开发者对 Redis 的认知还停留在"缓存 + 简单数据结构"的阶段。但 Redis 在近几年发生了重大进化：</p>
<div class="language-java codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#0f172a;--prism-background-color:#f1f5f9"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-java codeBlock_bY9V thin-scrollbar" style="color:#0f172a;background-color:#f1f5f9"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#0f172a"><span class="token class-name" style="color:#0ea5e9">Redis</span><span class="token plain"> 版本进化路线（与搜索相关）：</span><br></div><div class="token-line" style="color:#0f172a"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#0f172a"><span class="token plain"></span><span class="token class-name" style="color:#0ea5e9">Redis</span><span class="token plain"> </span><span class="token number" style="color:#B5CEA8">3.2</span><span class="token plain"> </span><span class="token punctuation" style="color:#475569">(</span><span class="token number" style="color:#B5CEA8">2016</span><span class="token punctuation" style="color:#475569">)</span><span class="token plain">  ── 引入 </span><span class="token constant" style="color:#0f172a">GEO</span><span class="token plain"> 命令</span><br></div><div class="token-line" style="color:#0f172a"><span class="token plain">       │</span><br></div><div class="token-line" style="color:#0f172a"><span class="token plain"></span><span class="token class-name" style="color:#0ea5e9">Redis</span><span class="token plain"> </span><span class="token class-name" style="color:#0ea5e9">Modules</span><span class="token plain"> </span><span class="token punctuation" style="color:#475569">(</span><span class="token number" style="color:#B5CEA8">2017</span><span class="token punctuation" style="color:#475569">)</span><span class="token plain"> ── 支持加载第三方模块</span><br></div><div class="token-line" style="color:#0f172a"><span class="token plain">       │</span><br></div><div class="token-line" style="color:#0f172a"><span class="token plain"></span><span class="token class-name" style="color:#0ea5e9">RediSearch</span><span class="token plain"> </span><span class="token number" style="color:#B5CEA8">1.0</span><span class="token plain"> </span><span class="token punctuation" style="color:#475569">(</span><span class="token number" style="color:#B5CEA8">2018</span><span class="token punctuation" style="color:#475569">)</span><span class="token plain"> ── 独立模块，需手动安装</span><br></div><div class="token-line" style="color:#0f172a"><span class="token plain">       │</span><br></div><div class="token-line" style="color:#0f172a"><span class="token plain"></span><span class="token class-name" style="color:#0ea5e9">Redis</span><span class="token plain"> </span><span class="token class-name" style="color:#0ea5e9">Stack</span><span class="token plain"> </span><span class="token punctuation" style="color:#475569">(</span><span class="token number" style="color:#B5CEA8">2022</span><span class="token punctuation" style="color:#475569">)</span><span class="token plain"> ── 官方整合包，内置 </span><span class="token class-name" style="color:#0ea5e9">RediSearch</span><span class="token plain"> </span><span class="token operator" style="color:#475569">+</span><span class="token plain"> </span><span class="token class-name" style="color:#0ea5e9">RedisJSON</span><span class="token plain"> 等</span><br></div><div class="token-line" style="color:#0f172a"><span class="token plain">       │</span><br></div><div class="token-line" style="color:#0f172a"><span class="token plain"></span><span class="token class-name" style="color:#0ea5e9">Redis</span><span class="token plain"> </span><span class="token number" style="color:#B5CEA8">8.0</span><span class="token plain"> </span><span class="token punctuation" style="color:#475569">(</span><span class="token number" style="color:#B5CEA8">2025</span><span class="token punctuation" style="color:#475569">)</span><span class="token plain"> ── </span><span class="token class-name" style="color:#0ea5e9">RediSearch</span><span class="token plain"> 不再是模块，成为 </span><span class="token class-name" style="color:#0ea5e9">Redis</span><span class="token plain"> 核心的一部分！</span><br></div></code></pre></div></div>
<p><strong>RediSearch 做了一件什么事？</strong></p>
<p>简单来说，它让 Redis 从一个"只能按 Key 查、按 Score 排"的 KV 存储，进化成了一个<strong>支持全文搜索、多条件过滤、多字段排序</strong>的内存数据库。</p>
<p>你可以把它类比为：<strong>给 Redis 装上了一个 Elasticsearch 级别的查询引擎</strong>，但因为数据本身就在内存里，所以比 ES 快得多。</p>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="1-核心概念数据与索引分离">1. 核心概念：数据与索引分离<a href="https://blog.xiaoxiaoliang.top/en/blog/redis-multi-sort#1-%E6%A0%B8%E5%BF%83%E6%A6%82%E5%BF%B5%E6%95%B0%E6%8D%AE%E4%B8%8E%E7%B4%A2%E5%BC%95%E5%88%86%E7%A6%BB" class="hash-link" aria-label="Direct link to 1. 核心概念：数据与索引分离" title="Direct link to 1. 核心概念：数据与索引分离" translate="no">​</a></h3>
<p>在传统 Redis 中，数据结构本身就是"索引"。比如 ZSET 的 Score 就是排序依据，GEO 的 GeoHash 也是排序依据。这种设计简单高效，但<strong>一个数据结构只能支撑一种排序方式</strong>。</p>
<p>RediSearch 引入了<strong>数据与索引分离</strong>的思想：</p>
<div class="language-java codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#0f172a;--prism-background-color:#f1f5f9"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-java codeBlock_bY9V thin-scrollbar" style="color:#0f172a;background-color:#f1f5f9"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#0f172a"><span class="token plain">传统 </span><span class="token class-name" style="color:#0ea5e9">Redis</span><span class="token operator" style="color:#475569">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#0f172a"><span class="token plain">┌─────────────────────────────┐</span><br></div><div class="token-line" style="color:#0f172a"><span class="token plain">│  </span><span class="token constant" style="color:#0f172a">ZSET</span><span class="token plain">（数据 </span><span class="token operator" style="color:#475569">=</span><span class="token plain"> 索引）          │</span><br></div><div class="token-line" style="color:#0f172a"><span class="token plain">│  </span><span class="token class-name" style="color:#0ea5e9">Score</span><span class="token plain"> 只能存一个值           │</span><br></div><div class="token-line" style="color:#0f172a"><span class="token plain">│  只能按一个维度排序            │</span><br></div><div class="token-line" style="color:#0f172a"><span class="token plain">└─────────────────────────────┘</span><br></div><div class="token-line" style="color:#0f172a"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#0f172a"><span class="token plain"></span><span class="token class-name" style="color:#0ea5e9">RediSearch</span><span class="token operator" style="color:#475569">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#0f172a"><span class="token plain">┌─────────────────────────────┐      ┌───────────────────────────┐</span><br></div><div class="token-line" style="color:#0f172a"><span class="token plain">│  </span><span class="token constant" style="color:#0f172a">HASH</span><span class="token plain"> </span><span class="token operator" style="color:#475569">/</span><span class="token plain"> </span><span class="token constant" style="color:#0f172a">JSON</span><span class="token plain">（纯数据存储）    │      │  自动维护的多种索引         │</span><br></div><div class="token-line" style="color:#0f172a"><span class="token plain">│                               │ ───▶ │                             │</span><br></div><div class="token-line" style="color:#0f172a"><span class="token plain">│  user</span><span class="token operator" style="color:#475569">:</span><span class="token number" style="color:#B5CEA8">1</span><span class="token plain"> </span><span class="token punctuation" style="color:#475569">{</span><span class="token plain">                     │      │  ☑ location → </span><span class="token class-name" style="color:#0ea5e9">Quadtree</span><span class="token plain">     │</span><br></div><div class="token-line" style="color:#0f172a"><span class="token plain">│    location</span><span class="token operator" style="color:#475569">:</span><span class="token plain"> </span><span class="token string" style="color:#64748b">"116.4,39.9"</span><span class="token plain">     │      │  ☑ activity  → </span><span class="token class-name" style="color:#0ea5e9">SkipList</span><span class="token plain">    │</span><br></div><div class="token-line" style="color:#0f172a"><span class="token plain">│    activity</span><span class="token operator" style="color:#475569">:</span><span class="token plain"> </span><span class="token number" style="color:#B5CEA8">85</span><span class="token plain">               │      │  ☑ nickname  → </span><span class="token class-name" style="color:#0ea5e9">Trie</span><span class="token plain"> 前缀树  │</span><br></div><div class="token-line" style="color:#0f172a"><span class="token plain">│    vip_level</span><span class="token operator" style="color:#475569">:</span><span class="token plain"> </span><span class="token number" style="color:#B5CEA8">3</span><span class="token plain">               │      │  ☑ bio       → 倒排索引     │</span><br></div><div class="token-line" style="color:#0f172a"><span class="token plain">│  </span><span class="token punctuation" style="color:#475569">}</span><span class="token plain">                            │      │                             │</span><br></div><div class="token-line" style="color:#0f172a"><span class="token plain">└─────────────────────────────┘      └───────────────────────────┘</span><br></div><div class="token-line" style="color:#0f172a"><span class="token plain">         数据层                               索引层</span><br></div><div class="token-line" style="color:#0f172a"><span class="token plain">      （你来写入）                       （</span><span class="token class-name" style="color:#0ea5e9">RediSearch</span><span class="token plain"> 自动维护）</span><br></div></code></pre></div></div>
<p>你只需要把数据写入普通的 <code>HASH</code>，然后通过 <code>FT.CREATE</code> 命令告诉 Redis："请帮我对这些字段自动建立索引"。之后的写入、更新、删除操作，索引会<strong>自动同步更新</strong>，无需你手动维护。</p>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="2-底层索引结构详解">2. 底层索引结构详解<a href="https://blog.xiaoxiaoliang.top/en/blog/redis-multi-sort#2-%E5%BA%95%E5%B1%82%E7%B4%A2%E5%BC%95%E7%BB%93%E6%9E%84%E8%AF%A6%E8%A7%A3" class="hash-link" aria-label="Direct link to 2. 底层索引结构详解" title="Direct link to 2. 底层索引结构详解" translate="no">​</a></h3>
<p>RediSearch 为不同的字段类型选择了不同的最优索引结构：</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>GEO</code>（地理位置）</td><td style="text-align:left"><strong>Quadtree（四叉树）</strong></td><td style="text-align:left">高效地将二维空间递归分割为4个象限，范围查询时间复杂度 O(log n)</td></tr><tr><td style="text-align:left"><code>NUMERIC</code>（数值）</td><td style="text-align:left"><strong>SkipList（跳表）</strong> + 区间树</td><td style="text-align:left">支持范围查询和排序，与 Redis ZSET 底层一致，性能有保障</td></tr><tr><td style="text-align:left"><code>TEXT</code>（全文本）</td><td style="text-align:left"><strong>Inverted Index（倒排索引）</strong></td><td style="text-align:left">与 Elasticsearch 原理相同，分词后建立 term → docId 的映射</td></tr><tr><td style="text-align:left"><code>TAG</code>（标签）</td><td style="text-align:left"><strong>Trie（前缀树）</strong> + 倒排</td><td style="text-align:left">精确匹配或前缀匹配，适合枚举类字段（性别、城市等）</td></tr></tbody></table>
<p>当我们执行一条联合查询时，RediSearch 在底层的工作流程如下：</p>
<div class="language-java codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#0f172a;--prism-background-color:#f1f5f9"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-java codeBlock_bY9V thin-scrollbar" style="color:#0f172a;background-color:#f1f5f9"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#0f172a"><span class="token plain">查询命令</span><span class="token operator" style="color:#475569">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#0f172a"><span class="token plain"></span><span class="token constant" style="color:#0f172a">FT</span><span class="token punctuation" style="color:#475569">.</span><span class="token constant" style="color:#0f172a">SEARCH</span><span class="token plain"> users_idx </span><span class="token string" style="color:#64748b">"@location:[116.404 39.915 10 km]"</span><span class="token plain"> </span><span class="token constant" style="color:#0f172a">SORTBY</span><span class="token plain"> activity_score </span><span class="token constant" style="color:#0f172a">DESC</span><span class="token plain"></span><br></div><div class="token-line" style="color:#0f172a"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#0f172a"><span class="token plain">底层执行过程</span><span class="token operator" style="color:#475569">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#0f172a"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#0f172a"><span class="token plain"></span><span class="token class-name" style="color:#0ea5e9">Step</span><span class="token plain"> </span><span class="token number" style="color:#B5CEA8">1</span><span class="token operator" style="color:#475569">:</span><span class="token plain"> </span><span class="token class-name" style="color:#0ea5e9">Quadtree</span><span class="token plain"> 范围查询</span><br></div><div class="token-line" style="color:#0f172a"><span class="token plain">┌─────────────────────────────┐</span><br></div><div class="token-line" style="color:#0f172a"><span class="token plain">│      四叉树空间索引            │</span><br></div><div class="token-line" style="color:#0f172a"><span class="token plain">│                               │</span><br></div><div class="token-line" style="color:#0f172a"><span class="token plain">│   以</span><span class="token punctuation" style="color:#475569">(</span><span class="token number" style="color:#B5CEA8">116.404</span><span class="token punctuation" style="color:#475569">,</span><span class="token plain"> </span><span class="token number" style="color:#B5CEA8">39.915</span><span class="token punctuation" style="color:#475569">)</span><span class="token plain">为圆心   │</span><br></div><div class="token-line" style="color:#0f172a"><span class="token plain">│   </span><span class="token number" style="color:#B5CEA8">10</span><span class="token plain">km 为半径                 │</span><br></div><div class="token-line" style="color:#0f172a"><span class="token plain">│   快速剪枝，筛出候选集         │</span><br></div><div class="token-line" style="color:#0f172a"><span class="token plain">│                               │</span><br></div><div class="token-line" style="color:#0f172a"><span class="token plain">│   输出</span><span class="token operator" style="color:#475569">:</span><span class="token plain"> </span><span class="token punctuation" style="color:#475569">{</span><span class="token class-name" style="color:#0ea5e9">A</span><span class="token punctuation" style="color:#475569">,</span><span class="token plain"> </span><span class="token class-name" style="color:#0ea5e9">B</span><span class="token punctuation" style="color:#475569">,</span><span class="token plain"> </span><span class="token class-name" style="color:#0ea5e9">C</span><span class="token punctuation" style="color:#475569">,</span><span class="token plain"> </span><span class="token class-name" style="color:#0ea5e9">F</span><span class="token punctuation" style="color:#475569">,</span><span class="token plain"> </span><span class="token class-name" style="color:#0ea5e9">H</span><span class="token punctuation" style="color:#475569">,</span><span class="token plain"> </span><span class="token class-name" style="color:#0ea5e9">E</span><span class="token punctuation" style="color:#475569">,</span><span class="token plain"> </span><span class="token class-name" style="color:#0ea5e9">D</span><span class="token punctuation" style="color:#475569">}</span><span class="token plain"> │  ← </span><span class="token number" style="color:#B5CEA8">7</span><span class="token plain"> 个用户在范围内</span><br></div><div class="token-line" style="color:#0f172a"><span class="token plain">└───────────┬─────────────────┘</span><br></div><div class="token-line" style="color:#0f172a"><span class="token plain">            │</span><br></div><div class="token-line" style="color:#0f172a"><span class="token plain">            ▼</span><br></div><div class="token-line" style="color:#0f172a"><span class="token plain"></span><span class="token class-name" style="color:#0ea5e9">Step</span><span class="token plain"> </span><span class="token number" style="color:#B5CEA8">2</span><span class="token operator" style="color:#475569">:</span><span class="token plain"> </span><span class="token class-name" style="color:#0ea5e9">SkipList</span><span class="token plain"> 排序</span><br></div><div class="token-line" style="color:#0f172a"><span class="token plain">┌─────────────────────────────┐</span><br></div><div class="token-line" style="color:#0f172a"><span class="token plain">│      跳表排序索引              │</span><br></div><div class="token-line" style="color:#0f172a"><span class="token plain">│                               │</span><br></div><div class="token-line" style="color:#0f172a"><span class="token plain">│   对候选集 </span><span class="token punctuation" style="color:#475569">{</span><span class="token class-name" style="color:#0ea5e9">A</span><span class="token punctuation" style="color:#475569">,</span><span class="token class-name" style="color:#0ea5e9">B</span><span class="token punctuation" style="color:#475569">,</span><span class="token class-name" style="color:#0ea5e9">C</span><span class="token punctuation" style="color:#475569">,</span><span class="token class-name" style="color:#0ea5e9">F</span><span class="token punctuation" style="color:#475569">,</span><span class="token class-name" style="color:#0ea5e9">H</span><span class="token punctuation" style="color:#475569">,</span><span class="token class-name" style="color:#0ea5e9">E</span><span class="token punctuation" style="color:#475569">,</span><span class="token class-name" style="color:#0ea5e9">D</span><span class="token punctuation" style="color:#475569">}</span><span class="token plain">   │</span><br></div><div class="token-line" style="color:#0f172a"><span class="token plain">│   按 activity_score 降序排列   │</span><br></div><div class="token-line" style="color:#0f172a"><span class="token plain">│                               │</span><br></div><div class="token-line" style="color:#0f172a"><span class="token plain">│   输出</span><span class="token operator" style="color:#475569">:</span><span class="token plain"> </span><span class="token class-name" style="color:#0ea5e9">B</span><span class="token punctuation" style="color:#475569">(</span><span class="token number" style="color:#B5CEA8">95</span><span class="token punctuation" style="color:#475569">)</span><span class="token plain"> → </span><span class="token class-name" style="color:#0ea5e9">D</span><span class="token punctuation" style="color:#475569">(</span><span class="token number" style="color:#B5CEA8">85</span><span class="token punctuation" style="color:#475569">)</span><span class="token plain"> → </span><span class="token class-name" style="color:#0ea5e9">H</span><span class="token punctuation" style="color:#475569">(</span><span class="token number" style="color:#B5CEA8">75</span><span class="token punctuation" style="color:#475569">)</span><span class="token plain"> │  ← 直接输出 </span><span class="token class-name" style="color:#0ea5e9">Top</span><span class="token plain"> </span><span class="token number" style="color:#B5CEA8">3</span><span class="token plain"></span><br></div><div class="token-line" style="color:#0f172a"><span class="token plain">│         → </span><span class="token class-name" style="color:#0ea5e9">C</span><span class="token punctuation" style="color:#475569">(</span><span class="token number" style="color:#B5CEA8">60</span><span class="token punctuation" style="color:#475569">)</span><span class="token plain"> → </span><span class="token class-name" style="color:#0ea5e9">F</span><span class="token punctuation" style="color:#475569">(</span><span class="token number" style="color:#B5CEA8">50</span><span class="token punctuation" style="color:#475569">)</span><span class="token plain"> → </span><span class="token punctuation" style="color:#475569">.</span><span class="token punctuation" style="color:#475569">.</span><span class="token punctuation" style="color:#475569">.</span><span class="token plain">│</span><br></div><div class="token-line" style="color:#0f172a"><span class="token plain">└───────────┬─────────────────┘</span><br></div><div class="token-line" style="color:#0f172a"><span class="token plain">            │</span><br></div><div class="token-line" style="color:#0f172a"><span class="token plain">            ▼</span><br></div><div class="token-line" style="color:#0f172a"><span class="token plain"></span><span class="token class-name" style="color:#0ea5e9">Step</span><span class="token plain"> </span><span class="token number" style="color:#B5CEA8">3</span><span class="token operator" style="color:#475569">:</span><span class="token plain"> 回表取数据</span><br></div><div class="token-line" style="color:#0f172a"><span class="token plain">┌─────────────────────────────┐</span><br></div><div class="token-line" style="color:#0f172a"><span class="token plain">│   从 </span><span class="token constant" style="color:#0f172a">HASH</span><span class="token plain"> 中取 </span><span class="token class-name" style="color:#0ea5e9">B</span><span class="token punctuation" style="color:#475569">,</span><span class="token plain"> </span><span class="token class-name" style="color:#0ea5e9">D</span><span class="token punctuation" style="color:#475569">,</span><span class="token plain"> </span><span class="token class-name" style="color:#0ea5e9">H</span><span class="token plain"> 的     │</span><br></div><div class="token-line" style="color:#0f172a"><span class="token plain">│   完整数据返回给客户端         │</span><br></div><div class="token-line" style="color:#0f172a"><span class="token plain">└─────────────────────────────┘</span><br></div></code></pre></div></div>
<blockquote>
<p>🔑 <strong>关键理解</strong>：整个过程全部在 Redis 的 C 语言引擎内完成，<strong>没有网络往返、没有 Java 对象创建、没有 GC</strong>。这就是所谓的"降维打击"。</p>
</blockquote>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="3-手把手实战从建索引到查询">3. 手把手实战：从建索引到查询<a href="https://blog.xiaoxiaoliang.top/en/blog/redis-multi-sort#3-%E6%89%8B%E6%8A%8A%E6%89%8B%E5%AE%9E%E6%88%98%E4%BB%8E%E5%BB%BA%E7%B4%A2%E5%BC%95%E5%88%B0%E6%9F%A5%E8%AF%A2" class="hash-link" aria-label="Direct link to 3. 手把手实战：从建索引到查询" title="Direct link to 3. 手把手实战：从建索引到查询" translate="no">​</a></h3>
<h4 class="anchor anchorTargetStickyNavbar_Vzrq" id="31-准备数据用普通-hash-写入">3.1 准备数据（用普通 HASH 写入）<a href="https://blog.xiaoxiaoliang.top/en/blog/redis-multi-sort#31-%E5%87%86%E5%A4%87%E6%95%B0%E6%8D%AE%E7%94%A8%E6%99%AE%E9%80%9A-hash-%E5%86%99%E5%85%A5" class="hash-link" aria-label="Direct link to 3.1 准备数据（用普通 HASH 写入）" title="Direct link to 3.1 准备数据（用普通 HASH 写入）" translate="no">​</a></h4>
<pre tabindex="0" class="codeBlockStandalone_MEMb thin-scrollbar language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#0f172a;--prism-background-color:#f1f5f9"><code class="codeBlockLines_e6Vv"><span class="line"><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic"># 注意：这里使用普通的 HSET 写入数据</span></span>
<span class="line"><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic"># location 字段格式为 "经度,纬度"（逗号分隔的字符串）</span></span>
<span class="line"><span style="color:#4078F2;--shiki-dark:#61AFEF">HSET</span><span style="color:#50A14F;--shiki-dark:#98C379"> user:A</span><span style="color:#50A14F;--shiki-dark:#98C379"> location</span><span style="color:#50A14F;--shiki-dark:#98C379"> "116.408,39.918"</span><span style="color:#50A14F;--shiki-dark:#98C379"> activity_score</span><span style="color:#986801;--shiki-dark:#D19A66"> 30</span><span style="color:#50A14F;--shiki-dark:#98C379"> nickname</span><span style="color:#50A14F;--shiki-dark:#98C379"> "张三"</span><span style="color:#50A14F;--shiki-dark:#98C379"> vip_level</span><span style="color:#986801;--shiki-dark:#D19A66"> 0</span></span>
<span class="line"><span style="color:#4078F2;--shiki-dark:#61AFEF">HSET</span><span style="color:#50A14F;--shiki-dark:#98C379"> user:B</span><span style="color:#50A14F;--shiki-dark:#98C379"> location</span><span style="color:#50A14F;--shiki-dark:#98C379"> "116.398,39.910"</span><span style="color:#50A14F;--shiki-dark:#98C379"> activity_score</span><span style="color:#986801;--shiki-dark:#D19A66"> 95</span><span style="color:#50A14F;--shiki-dark:#98C379"> nickname</span><span style="color:#50A14F;--shiki-dark:#98C379"> "李四"</span><span style="color:#50A14F;--shiki-dark:#98C379"> vip_level</span><span style="color:#986801;--shiki-dark:#D19A66"> 2</span></span>
<span class="line"><span style="color:#4078F2;--shiki-dark:#61AFEF">HSET</span><span style="color:#50A14F;--shiki-dark:#98C379"> user:C</span><span style="color:#50A14F;--shiki-dark:#98C379"> location</span><span style="color:#50A14F;--shiki-dark:#98C379"> "116.420,39.930"</span><span style="color:#50A14F;--shiki-dark:#98C379"> activity_score</span><span style="color:#986801;--shiki-dark:#D19A66"> 60</span><span style="color:#50A14F;--shiki-dark:#98C379"> nickname</span><span style="color:#50A14F;--shiki-dark:#98C379"> "王五"</span><span style="color:#50A14F;--shiki-dark:#98C379"> vip_level</span><span style="color:#986801;--shiki-dark:#D19A66"> 1</span></span>
<span class="line"><span style="color:#4078F2;--shiki-dark:#61AFEF">HSET</span><span style="color:#50A14F;--shiki-dark:#98C379"> user:D</span><span style="color:#50A14F;--shiki-dark:#98C379"> location</span><span style="color:#50A14F;--shiki-dark:#98C379"> "116.450,39.950"</span><span style="color:#50A14F;--shiki-dark:#98C379"> activity_score</span><span style="color:#986801;--shiki-dark:#D19A66"> 85</span><span style="color:#50A14F;--shiki-dark:#98C379"> nickname</span><span style="color:#50A14F;--shiki-dark:#98C379"> "赵六"</span><span style="color:#50A14F;--shiki-dark:#98C379"> vip_level</span><span style="color:#986801;--shiki-dark:#D19A66"> 3</span></span>
<span class="line"><span style="color:#4078F2;--shiki-dark:#61AFEF">HSET</span><span style="color:#50A14F;--shiki-dark:#98C379"> user:E</span><span style="color:#50A14F;--shiki-dark:#98C379"> location</span><span style="color:#50A14F;--shiki-dark:#98C379"> "116.380,39.900"</span><span style="color:#50A14F;--shiki-dark:#98C379"> activity_score</span><span style="color:#986801;--shiki-dark:#D19A66"> 10</span><span style="color:#50A14F;--shiki-dark:#98C379"> nickname</span><span style="color:#50A14F;--shiki-dark:#98C379"> "孙七"</span><span style="color:#50A14F;--shiki-dark:#98C379"> vip_level</span><span style="color:#986801;--shiki-dark:#D19A66"> 0</span></span>
<span class="line"><span style="color:#4078F2;--shiki-dark:#61AFEF">HSET</span><span style="color:#50A14F;--shiki-dark:#98C379"> user:F</span><span style="color:#50A14F;--shiki-dark:#98C379"> location</span><span style="color:#50A14F;--shiki-dark:#98C379"> "116.410,39.916"</span><span style="color:#50A14F;--shiki-dark:#98C379"> activity_score</span><span style="color:#986801;--shiki-dark:#D19A66"> 50</span><span style="color:#50A14F;--shiki-dark:#98C379"> nickname</span><span style="color:#50A14F;--shiki-dark:#98C379"> "周八"</span><span style="color:#50A14F;--shiki-dark:#98C379"> vip_level</span><span style="color:#986801;--shiki-dark:#D19A66"> 1</span></span>
<span class="line"><span style="color:#4078F2;--shiki-dark:#61AFEF">HSET</span><span style="color:#50A14F;--shiki-dark:#98C379"> user:G</span><span style="color:#50A14F;--shiki-dark:#98C379"> location</span><span style="color:#50A14F;--shiki-dark:#98C379"> "116.500,39.980"</span><span style="color:#50A14F;--shiki-dark:#98C379"> activity_score</span><span style="color:#986801;--shiki-dark:#D19A66"> 90</span><span style="color:#50A14F;--shiki-dark:#98C379"> nickname</span><span style="color:#50A14F;--shiki-dark:#98C379"> "吴九"</span><span style="color:#50A14F;--shiki-dark:#98C379"> vip_level</span><span style="color:#986801;--shiki-dark:#D19A66"> 2</span></span>
<span class="line"><span style="color:#4078F2;--shiki-dark:#61AFEF">HSET</span><span style="color:#50A14F;--shiki-dark:#98C379"> user:H</span><span style="color:#50A14F;--shiki-dark:#98C379"> location</span><span style="color:#50A14F;--shiki-dark:#98C379"> "116.390,39.905"</span><span style="color:#50A14F;--shiki-dark:#98C379"> activity_score</span><span style="color:#986801;--shiki-dark:#D19A66"> 75</span><span style="color:#50A14F;--shiki-dark:#98C379"> nickname</span><span style="color:#50A14F;--shiki-dark:#98C379"> "郑十"</span><span style="color:#50A14F;--shiki-dark:#98C379"> vip_level</span><span style="color:#986801;--shiki-dark:#D19A66"> 1</span></span></code></pre>
<h4 class="anchor anchorTargetStickyNavbar_Vzrq" id="32-创建索引">3.2 创建索引<a href="https://blog.xiaoxiaoliang.top/en/blog/redis-multi-sort#32-%E5%88%9B%E5%BB%BA%E7%B4%A2%E5%BC%95" class="hash-link" aria-label="Direct link to 3.2 创建索引" title="Direct link to 3.2 创建索引" translate="no">​</a></h4>
<pre tabindex="0" class="codeBlockStandalone_MEMb thin-scrollbar language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#0f172a;--prism-background-color:#f1f5f9"><code class="codeBlockLines_e6Vv"><span class="line"><span style="color:#4078F2;--shiki-dark:#61AFEF">FT.CREATE</span><span style="color:#50A14F;--shiki-dark:#98C379"> users_idx</span><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic">                    # 索引名称</span></span>
<span class="line"><span style="color:#4078F2;--shiki-dark:#61AFEF">  ON</span><span style="color:#50A14F;--shiki-dark:#98C379"> HASH</span><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic">                              # 索引 HASH 类型的数据</span></span>
<span class="line"><span style="color:#4078F2;--shiki-dark:#61AFEF">  PREFIX</span><span style="color:#986801;--shiki-dark:#D19A66"> 1</span><span style="color:#50A14F;--shiki-dark:#98C379"> user:</span><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic">                       # 只索引 Key 前缀为 "user:" 的数据</span></span>
<span class="line"><span style="color:#4078F2;--shiki-dark:#61AFEF">  SCHEMA</span><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic">                               # 定义索引字段</span></span>
<span class="line"><span style="color:#4078F2;--shiki-dark:#61AFEF">    location</span><span style="color:#50A14F;--shiki-dark:#98C379"> GEO</span><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic">                       # 地理位置字段 → 自动建 Quadtree</span></span>
<span class="line"><span style="color:#4078F2;--shiki-dark:#61AFEF">    activity_score</span><span style="color:#50A14F;--shiki-dark:#98C379"> NUMERIC</span><span style="color:#50A14F;--shiki-dark:#98C379"> SORTABLE</span><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic">    # 数值字段 → 自动建 SkipList，支持排序</span></span>
<span class="line"><span style="color:#4078F2;--shiki-dark:#61AFEF">    nickname</span><span style="color:#50A14F;--shiki-dark:#98C379"> TEXT</span><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic">                      # 文本字段 → 自动建倒排索引</span></span>
<span class="line"><span style="color:#4078F2;--shiki-dark:#61AFEF">    vip_level</span><span style="color:#50A14F;--shiki-dark:#98C379"> NUMERIC</span><span style="color:#50A14F;--shiki-dark:#98C379"> SORTABLE</span><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic">         # 数值字段 → 自动建 SkipList</span></span></code></pre>
<blockquote>
<p>💡 <strong>注意</strong>：<code>FT.CREATE</code> 命令执行后，Redis 会<strong>异步扫描已有数据</strong>并自动建立索引。之后新写入的 <code>user:*</code> 数据也会被自动索引。</p>
</blockquote>
<h4 class="anchor anchorTargetStickyNavbar_Vzrq" id="33-执行联合查询">3.3 执行联合查询<a href="https://blog.xiaoxiaoliang.top/en/blog/redis-multi-sort#33-%E6%89%A7%E8%A1%8C%E8%81%94%E5%90%88%E6%9F%A5%E8%AF%A2" class="hash-link" aria-label="Direct link to 3.3 执行联合查询" title="Direct link to 3.3 执行联合查询" translate="no">​</a></h4>
<pre tabindex="0" class="codeBlockStandalone_MEMb thin-scrollbar language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#0f172a;--prism-background-color:#f1f5f9"><code class="codeBlockLines_e6Vv"><span class="line"><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic"># 查询1：10km 内的用户，按活跃度降序，取前 3 条</span></span>
<span class="line"><span style="color:#4078F2;--shiki-dark:#61AFEF">FT.SEARCH</span><span style="color:#50A14F;--shiki-dark:#98C379"> users_idx</span></span>
<span class="line"><span style="color:#4078F2;--shiki-dark:#61AFEF">  "@location:[116.404 39.915 10 km]"</span></span>
<span class="line"><span style="color:#4078F2;--shiki-dark:#61AFEF">  SORTBY</span><span style="color:#50A14F;--shiki-dark:#98C379"> activity_score</span><span style="color:#50A14F;--shiki-dark:#98C379"> DESC</span></span>
<span class="line"><span style="color:#4078F2;--shiki-dark:#61AFEF">  LIMIT</span><span style="color:#986801;--shiki-dark:#D19A66"> 0</span><span style="color:#986801;--shiki-dark:#D19A66"> 3</span></span>
<span class="line"></span>
<span class="line"><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic"># 返回结果：</span></span>
<span class="line"><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic"># 1) (integer) 7                        ← 总共命中 7 条</span></span>
<span class="line"><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic"># 2) "user:B"                           ← 第1条</span></span>
<span class="line"><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic"># 3) 1) "activity_score" 2) "95" 3) "nickname" 4) "李四" ...</span></span>
<span class="line"><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic"># 4) "user:D"                           ← 第2条</span></span>
<span class="line"><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic"># 5) 1) "activity_score" 2) "85" 3) "nickname" 4) "赵六" ...</span></span>
<span class="line"><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic"># 6) "user:H"                           ← 第3条</span></span>
<span class="line"><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic"># 7) 1) "activity_score" 2) "75" 3) "nickname" 4) "郑十" ...</span></span></code></pre>
<pre tabindex="0" class="codeBlockStandalone_MEMb thin-scrollbar language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#0f172a;--prism-background-color:#f1f5f9"><code class="codeBlockLines_e6Vv"><span class="line"><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic"># 查询2：5km 内 + VIP 等级 ≥ 1 的用户，按活跃度降序</span></span>
<span class="line"><span style="color:#4078F2;--shiki-dark:#61AFEF">FT.SEARCH</span><span style="color:#50A14F;--shiki-dark:#98C379"> users_idx</span></span>
<span class="line"><span style="color:#4078F2;--shiki-dark:#61AFEF">  "@location:[116.404 39.915 5 km] @vip_level:[1 +inf]"</span></span>
<span class="line"><span style="color:#4078F2;--shiki-dark:#61AFEF">  SORTBY</span><span style="color:#50A14F;--shiki-dark:#98C379"> activity_score</span><span style="color:#50A14F;--shiki-dark:#98C379"> DESC</span></span>
<span class="line"><span style="color:#4078F2;--shiki-dark:#61AFEF">  LIMIT</span><span style="color:#986801;--shiki-dark:#D19A66"> 0</span><span style="color:#986801;--shiki-dark:#D19A66"> 3</span></span>
<span class="line"></span>
<span class="line"><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic"># 返回：user:B(VIP2,活跃95) → user:H(VIP1,活跃75) → user:C(VIP1,活跃60)</span></span></code></pre>
<pre tabindex="0" class="codeBlockStandalone_MEMb thin-scrollbar language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#0f172a;--prism-background-color:#f1f5f9"><code class="codeBlockLines_e6Vv"><span class="line"><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic"># 查询3：10km 内 + 昵称包含"六"的用户</span></span>
<span class="line"><span style="color:#4078F2;--shiki-dark:#61AFEF">FT.SEARCH</span><span style="color:#50A14F;--shiki-dark:#98C379"> users_idx</span></span>
<span class="line"><span style="color:#4078F2;--shiki-dark:#61AFEF">  "@location:[116.404 39.915 10 km] @nickname:六"</span></span>
<span class="line"></span>
<span class="line"><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic"># 返回：user:D（赵六）</span></span></code></pre>
<blockquote>
<p>🎯 一条命令搞定<strong>地理过滤 + 数值过滤 + 文本搜索 + 排序 + 分页</strong>，这就是 RediSearch 的威力。</p>
</blockquote>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="4-java-端优雅集成redis-om-spring">4. Java 端优雅集成（Redis OM Spring）<a href="https://blog.xiaoxiaoliang.top/en/blog/redis-multi-sort#4-java-%E7%AB%AF%E4%BC%98%E9%9B%85%E9%9B%86%E6%88%90redis-om-spring" class="hash-link" aria-label="Direct link to 4. Java 端优雅集成（Redis OM Spring）" title="Direct link to 4. Java 端优雅集成（Redis OM Spring）" translate="no">​</a></h3>
<p>手动拼 <code>FT.SEARCH</code> 命令太原始了。官方提供了 <code>redis-om-spring</code> 库，让你像写 Spring Data JPA 一样操作 RediSearch。</p>
<h4 class="anchor anchorTargetStickyNavbar_Vzrq" id="41-引入依赖">4.1 引入依赖<a href="https://blog.xiaoxiaoliang.top/en/blog/redis-multi-sort#41-%E5%BC%95%E5%85%A5%E4%BE%9D%E8%B5%96" class="hash-link" aria-label="Direct link to 4.1 引入依赖" title="Direct link to 4.1 引入依赖" translate="no">​</a></h4>
<pre tabindex="0" class="codeBlockStandalone_MEMb thin-scrollbar language-xml codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#0f172a;--prism-background-color:#f1f5f9"><code class="codeBlockLines_e6Vv"><span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">&lt;</span><span style="color:#E45649;--shiki-dark:#E06C75">dependency</span><span style="color:#383A42;--shiki-dark:#ABB2BF">&gt;</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">    &lt;</span><span style="color:#E45649;--shiki-dark:#E06C75">groupId</span><span style="color:#383A42;--shiki-dark:#ABB2BF">&gt;com.redis.om&lt;/</span><span style="color:#E45649;--shiki-dark:#E06C75">groupId</span><span style="color:#383A42;--shiki-dark:#ABB2BF">&gt;</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">    &lt;</span><span style="color:#E45649;--shiki-dark:#E06C75">artifactId</span><span style="color:#383A42;--shiki-dark:#ABB2BF">&gt;redis-om-spring&lt;/</span><span style="color:#E45649;--shiki-dark:#E06C75">artifactId</span><span style="color:#383A42;--shiki-dark:#ABB2BF">&gt;</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">    &lt;</span><span style="color:#E45649;--shiki-dark:#E06C75">version</span><span style="color:#383A42;--shiki-dark:#ABB2BF">&gt;0.9.8&lt;/</span><span style="color:#E45649;--shiki-dark:#E06C75">version</span><span style="color:#383A42;--shiki-dark:#ABB2BF">&gt;</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">&lt;/</span><span style="color:#E45649;--shiki-dark:#E06C75">dependency</span><span style="color:#383A42;--shiki-dark:#ABB2BF">&gt;</span></span></code></pre>
<h4 class="anchor anchorTargetStickyNavbar_Vzrq" id="42-启用-redis-om">4.2 启用 Redis OM<a href="https://blog.xiaoxiaoliang.top/en/blog/redis-multi-sort#42-%E5%90%AF%E7%94%A8-redis-om" class="hash-link" aria-label="Direct link to 4.2 启用 Redis OM" title="Direct link to 4.2 启用 Redis OM" translate="no">​</a></h4>
<pre tabindex="0" class="codeBlockStandalone_MEMb thin-scrollbar language-java codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#0f172a;--prism-background-color:#f1f5f9"><code class="codeBlockLines_e6Vv"><span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">import</span><span style="color:#C18401;--shiki-dark:#E5C07B"> com.redis.om.spring.annotations.EnableRedisDocumentRepositories</span><span style="color:#383A42;--shiki-dark:#ABB2BF">;</span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">import</span><span style="color:#C18401;--shiki-dark:#E5C07B"> org.springframework.boot.SpringApplication</span><span style="color:#383A42;--shiki-dark:#ABB2BF">;</span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">import</span><span style="color:#C18401;--shiki-dark:#E5C07B"> org.springframework.boot.autoconfigure.SpringBootApplication</span><span style="color:#383A42;--shiki-dark:#ABB2BF">;</span></span>
<span class="line"></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">@</span><span style="color:#A626A4;--shiki-dark:#E5C07B">SpringBootApplication</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">@</span><span style="color:#A626A4;--shiki-dark:#E5C07B">EnableRedisDocumentRepositories</span><span style="color:#383A42;--shiki-dark:#E06C75">(</span><span style="color:#986801;--shiki-dark:#D19A66">basePackages</span><span style="color:#383A42;--shiki-dark:#56B6C2"> =</span><span style="color:#50A14F;--shiki-dark:#98C379"> "com.example.nearby"</span><span style="color:#383A42;--shiki-dark:#E06C75">)</span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">public</span><span style="color:#A626A4;--shiki-dark:#C678DD"> class</span><span style="color:#C18401;--shiki-dark:#E5C07B"> NearbyApplication</span><span style="color:#383A42;--shiki-dark:#ABB2BF"> {</span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">    public</span><span style="color:#A626A4;--shiki-dark:#C678DD"> static</span><span style="color:#A626A4;--shiki-dark:#C678DD"> void</span><span style="color:#4078F2;--shiki-dark:#61AFEF"> main</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(</span><span style="color:#C18401;--shiki-dark:#E5C07B">String</span><span style="color:#383A42;--shiki-dark:#ABB2BF">[] </span><span style="color:#383A42;--shiki-light-font-style:inherit;--shiki-dark:#E06C75;--shiki-dark-font-style:italic">args</span><span style="color:#383A42;--shiki-dark:#ABB2BF">)</span><span style="color:#383A42;--shiki-dark:#ABB2BF"> {</span></span>
<span class="line"><span style="color:#E45649;--shiki-dark:#E5C07B">        SpringApplication</span><span style="color:#383A42;--shiki-dark:#ABB2BF">.</span><span style="color:#4078F2;--shiki-dark:#61AFEF">run</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(</span><span style="color:#E45649;--shiki-dark:#E5C07B">NearbyApplication</span><span style="color:#383A42;--shiki-dark:#ABB2BF">.</span><span style="color:#E45649;--shiki-dark:#E5C07B">class</span><span style="color:#383A42;--shiki-dark:#ABB2BF">, args);</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">    }</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">}</span></span></code></pre>
<h4 class="anchor anchorTargetStickyNavbar_Vzrq" id="43-定义实体与索引">4.3 定义实体与索引<a href="https://blog.xiaoxiaoliang.top/en/blog/redis-multi-sort#43-%E5%AE%9A%E4%B9%89%E5%AE%9E%E4%BD%93%E4%B8%8E%E7%B4%A2%E5%BC%95" class="hash-link" aria-label="Direct link to 4.3 定义实体与索引" title="Direct link to 4.3 定义实体与索引" translate="no">​</a></h4>
<pre tabindex="0" class="codeBlockStandalone_MEMb thin-scrollbar language-java codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#0f172a;--prism-background-color:#f1f5f9"><code class="codeBlockLines_e6Vv"><span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">import</span><span style="color:#C18401;--shiki-dark:#E5C07B"> com.redis.om.spring.annotations.Document</span><span style="color:#383A42;--shiki-dark:#ABB2BF">;</span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">import</span><span style="color:#C18401;--shiki-dark:#E5C07B"> com.redis.om.spring.annotations.Indexed</span><span style="color:#383A42;--shiki-dark:#ABB2BF">;</span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">import</span><span style="color:#C18401;--shiki-dark:#E5C07B"> com.redis.om.spring.annotations.Searchable</span><span style="color:#383A42;--shiki-dark:#ABB2BF">;</span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">import</span><span style="color:#C18401;--shiki-dark:#E5C07B"> lombok.Data</span><span style="color:#383A42;--shiki-dark:#ABB2BF">;</span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">import</span><span style="color:#C18401;--shiki-dark:#E5C07B"> lombok.NoArgsConstructor</span><span style="color:#383A42;--shiki-dark:#ABB2BF">;</span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">import</span><span style="color:#C18401;--shiki-dark:#E5C07B"> lombok.NonNull</span><span style="color:#383A42;--shiki-dark:#ABB2BF">;</span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">import</span><span style="color:#C18401;--shiki-dark:#E5C07B"> lombok.RequiredArgsConstructor</span><span style="color:#383A42;--shiki-dark:#ABB2BF">;</span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">import</span><span style="color:#C18401;--shiki-dark:#E5C07B"> org.springframework.data.annotation.Id</span><span style="color:#383A42;--shiki-dark:#ABB2BF">;</span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">import</span><span style="color:#C18401;--shiki-dark:#E5C07B"> org.springframework.data.geo.Point</span><span style="color:#383A42;--shiki-dark:#ABB2BF">;</span></span>
<span class="line"></span>
<span class="line"><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic">/**</span></span>
<span class="line"><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic"> * 用户画像实体</span></span>
<span class="line"><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic"> *</span></span>
<span class="line"><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic"> * &lt;p&gt;注解说明：</span></span>
<span class="line"><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic"> * - @Document：标记为 Redis 文档，底层存储为 JSON 格式</span></span>
<span class="line"><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic"> * - @Indexed：为字段创建精确匹配/数值/地理索引</span></span>
<span class="line"><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic"> * - @Searchable：为字段创建全文搜索索引（倒排索引）</span></span>
<span class="line"><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic"> */</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">@</span><span style="color:#A626A4;--shiki-dark:#E5C07B">Data</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">@</span><span style="color:#A626A4;--shiki-dark:#E5C07B">NoArgsConstructor</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">@</span><span style="color:#A626A4;--shiki-dark:#E5C07B">RequiredArgsConstructor</span><span style="color:#383A42;--shiki-dark:#E06C75">(</span><span style="color:#986801;--shiki-dark:#D19A66">staticName</span><span style="color:#383A42;--shiki-dark:#56B6C2"> =</span><span style="color:#50A14F;--shiki-dark:#98C379"> "of"</span><span style="color:#383A42;--shiki-dark:#E06C75">)</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">@</span><span style="color:#A626A4;--shiki-dark:#E5C07B">Document</span><span style="color:#383A42;--shiki-dark:#E06C75">(</span><span style="color:#986801;--shiki-dark:#D19A66">value</span><span style="color:#383A42;--shiki-dark:#56B6C2"> =</span><span style="color:#50A14F;--shiki-dark:#98C379"> "user"</span><span style="color:#383A42;--shiki-dark:#E06C75">)  </span><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic">// Key 前缀为 "user:"，索引名自动生成</span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">public</span><span style="color:#A626A4;--shiki-dark:#C678DD"> class</span><span style="color:#C18401;--shiki-dark:#E5C07B"> UserProfile</span><span style="color:#383A42;--shiki-dark:#ABB2BF"> {</span></span>
<span class="line"></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">    @</span><span style="color:#A626A4;--shiki-dark:#E5C07B">Id</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">    @</span><span style="color:#A626A4;--shiki-dark:#E5C07B">NonNull</span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">    private</span><span style="color:#C18401;--shiki-dark:#E5C07B"> String</span><span style="color:#E45649;--shiki-dark:#E06C75"> userId</span><span style="color:#383A42;--shiki-dark:#ABB2BF">;</span></span>
<span class="line"></span>
<span class="line"><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic">    /** 地理位置 → 自动建 GEO 索引（Quadtree） */</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">    @</span><span style="color:#A626A4;--shiki-dark:#E5C07B">NonNull</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">    @</span><span style="color:#A626A4;--shiki-dark:#E5C07B">Indexed</span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">    private</span><span style="color:#C18401;--shiki-dark:#E5C07B"> Point</span><span style="color:#E45649;--shiki-dark:#E06C75"> location</span><span style="color:#383A42;--shiki-dark:#ABB2BF">;</span></span>
<span class="line"></span>
<span class="line"><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic">    /** 活跃度得分 → 自动建 NUMERIC 索引（SkipList） */</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">    @</span><span style="color:#A626A4;--shiki-dark:#E5C07B">NonNull</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">    @</span><span style="color:#A626A4;--shiki-dark:#E5C07B">Indexed</span><span style="color:#383A42;--shiki-dark:#E06C75">(</span><span style="color:#986801;--shiki-dark:#D19A66">sortable</span><span style="color:#383A42;--shiki-dark:#56B6C2"> =</span><span style="color:#986801;--shiki-dark:#D19A66"> true</span><span style="color:#383A42;--shiki-dark:#E06C75">)</span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">    private</span><span style="color:#C18401;--shiki-dark:#E5C07B"> Double</span><span style="color:#E45649;--shiki-dark:#E06C75"> activityScore</span><span style="color:#383A42;--shiki-dark:#ABB2BF">;</span></span>
<span class="line"></span>
<span class="line"><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic">    /** 昵称 → 自动建全文搜索索引（倒排索引） */</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">    @</span><span style="color:#A626A4;--shiki-dark:#E5C07B">Searchable</span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">    private</span><span style="color:#C18401;--shiki-dark:#E5C07B"> String</span><span style="color:#E45649;--shiki-dark:#E06C75"> nickname</span><span style="color:#383A42;--shiki-dark:#ABB2BF">;</span></span>
<span class="line"></span>
<span class="line"><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic">    /** VIP 等级 → 自动建 NUMERIC 索引 */</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">    @</span><span style="color:#A626A4;--shiki-dark:#E5C07B">Indexed</span><span style="color:#383A42;--shiki-dark:#E06C75">(</span><span style="color:#986801;--shiki-dark:#D19A66">sortable</span><span style="color:#383A42;--shiki-dark:#56B6C2"> =</span><span style="color:#986801;--shiki-dark:#D19A66"> true</span><span style="color:#383A42;--shiki-dark:#E06C75">)</span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">    private</span><span style="color:#C18401;--shiki-dark:#E5C07B"> Integer</span><span style="color:#E45649;--shiki-dark:#E06C75"> vipLevel</span><span style="color:#383A42;--shiki-dark:#ABB2BF">;</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">}</span></span></code></pre>
<h4 class="anchor anchorTargetStickyNavbar_Vzrq" id="44-定义-repository-接口">4.4 定义 Repository 接口<a href="https://blog.xiaoxiaoliang.top/en/blog/redis-multi-sort#44-%E5%AE%9A%E4%B9%89-repository-%E6%8E%A5%E5%8F%A3" class="hash-link" aria-label="Direct link to 4.4 定义 Repository 接口" title="Direct link to 4.4 定义 Repository 接口" translate="no">​</a></h4>
<pre tabindex="0" class="codeBlockStandalone_MEMb thin-scrollbar language-java codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#0f172a;--prism-background-color:#f1f5f9"><code class="codeBlockLines_e6Vv"><span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">import</span><span style="color:#C18401;--shiki-dark:#E5C07B"> com.redis.om.spring.repository.RedisDocumentRepository</span><span style="color:#383A42;--shiki-dark:#ABB2BF">;</span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">import</span><span style="color:#C18401;--shiki-dark:#E5C07B"> org.springframework.data.domain.Page</span><span style="color:#383A42;--shiki-dark:#ABB2BF">;</span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">import</span><span style="color:#C18401;--shiki-dark:#E5C07B"> org.springframework.data.domain.Pageable</span><span style="color:#383A42;--shiki-dark:#ABB2BF">;</span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">import</span><span style="color:#C18401;--shiki-dark:#E5C07B"> org.springframework.data.geo.Distance</span><span style="color:#383A42;--shiki-dark:#ABB2BF">;</span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">import</span><span style="color:#C18401;--shiki-dark:#E5C07B"> org.springframework.data.geo.Point</span><span style="color:#383A42;--shiki-dark:#ABB2BF">;</span></span>
<span class="line"></span>
<span class="line"><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic">/**</span></span>
<span class="line"><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic"> * 用户画像 Repository</span></span>
<span class="line"><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic"> *</span></span>
<span class="line"><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic"> * &lt;p&gt;方法名约定会被 Redis OM 自动翻译为 FT.SEARCH 命令：</span></span>
<span class="line"><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic"> * findByLocationNear → @location:[lon lat radius km]</span></span>
<span class="line"><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic"> * OrderByActivityScoreDesc → SORTBY activity_score DESC</span></span>
<span class="line"><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic"> */</span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">public</span><span style="color:#A626A4;--shiki-dark:#C678DD"> interface</span><span style="color:#C18401;--shiki-dark:#E5C07B"> UserProfileRepository</span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">        extends</span><span style="color:#C18401;--shiki-dark:#E5C07B"> RedisDocumentRepository</span><span style="color:#C18401;--shiki-dark:#ABB2BF">&lt;</span><span style="color:#C18401;--shiki-dark:#E5C07B">UserProfile</span><span style="color:#C18401;--shiki-dark:#ABB2BF">,</span><span style="color:#C18401;--shiki-dark:#E5C07B"> String</span><span style="color:#C18401;--shiki-dark:#ABB2BF">&gt;</span><span style="color:#383A42;--shiki-dark:#ABB2BF"> {</span></span>
<span class="line"></span>
<span class="line"><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic">    /**</span></span>
<span class="line"><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic">     * 查询指定范围内的用户，按活跃度降序分页</span></span>
<span class="line"><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic">     * 自动翻译为：FT.SEARCH @location:[...] SORTBY activity_score DESC LIMIT ...</span></span>
<span class="line"><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic">     */</span></span>
<span class="line"><span style="color:#C18401;--shiki-dark:#E5C07B">    Page</span><span style="color:#383A42;--shiki-dark:#ABB2BF">&lt;</span><span style="color:#C18401;--shiki-dark:#E5C07B">UserProfile</span><span style="color:#383A42;--shiki-dark:#ABB2BF">&gt;</span><span style="color:#4078F2;--shiki-dark:#61AFEF"> findByLocationNearOrderByActivityScoreDesc</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(</span></span>
<span class="line"><span style="color:#C18401;--shiki-dark:#E5C07B">            Point</span><span style="color:#383A42;--shiki-light-font-style:inherit;--shiki-dark:#E06C75;--shiki-dark-font-style:italic"> point</span><span style="color:#383A42;--shiki-dark:#ABB2BF">, </span><span style="color:#C18401;--shiki-dark:#E5C07B">Distance</span><span style="color:#383A42;--shiki-light-font-style:inherit;--shiki-dark:#E06C75;--shiki-dark-font-style:italic"> distance</span><span style="color:#383A42;--shiki-dark:#ABB2BF">, </span><span style="color:#C18401;--shiki-dark:#E5C07B">Pageable</span><span style="color:#383A42;--shiki-light-font-style:inherit;--shiki-dark:#E06C75;--shiki-dark-font-style:italic"> pageable</span><span style="color:#383A42;--shiki-dark:#ABB2BF">);</span></span>
<span class="line"></span>
<span class="line"><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic">    /**</span></span>
<span class="line"><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic">     * 查询指定范围内 + VIP 等级大于等于指定值的用户，按活跃度降序分页</span></span>
<span class="line"><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic">     */</span></span>
<span class="line"><span style="color:#C18401;--shiki-dark:#E5C07B">    Page</span><span style="color:#383A42;--shiki-dark:#ABB2BF">&lt;</span><span style="color:#C18401;--shiki-dark:#E5C07B">UserProfile</span><span style="color:#383A42;--shiki-dark:#ABB2BF">&gt;</span><span style="color:#4078F2;--shiki-dark:#61AFEF"> findByLocationNearAndVipLevelGreaterThanEqualOrderByActivityScoreDesc</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(</span></span>
<span class="line"><span style="color:#C18401;--shiki-dark:#E5C07B">            Point</span><span style="color:#383A42;--shiki-light-font-style:inherit;--shiki-dark:#E06C75;--shiki-dark-font-style:italic"> point</span><span style="color:#383A42;--shiki-dark:#ABB2BF">, </span><span style="color:#C18401;--shiki-dark:#E5C07B">Distance</span><span style="color:#383A42;--shiki-light-font-style:inherit;--shiki-dark:#E06C75;--shiki-dark-font-style:italic"> distance</span><span style="color:#383A42;--shiki-dark:#ABB2BF">, </span><span style="color:#C18401;--shiki-dark:#E5C07B">Integer</span><span style="color:#383A42;--shiki-light-font-style:inherit;--shiki-dark:#E06C75;--shiki-dark-font-style:italic"> vipLevel</span><span style="color:#383A42;--shiki-dark:#ABB2BF">, </span><span style="color:#C18401;--shiki-dark:#E5C07B">Pageable</span><span style="color:#383A42;--shiki-light-font-style:inherit;--shiki-dark:#E06C75;--shiki-dark-font-style:italic"> pageable</span><span style="color:#383A42;--shiki-dark:#ABB2BF">);</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">}</span></span></code></pre>
<h4 class="anchor anchorTargetStickyNavbar_Vzrq" id="45-service--controller">4.5 Service &amp; Controller<a href="https://blog.xiaoxiaoliang.top/en/blog/redis-multi-sort#45-service--controller" class="hash-link" aria-label="Direct link to 4.5 Service &amp; Controller" title="Direct link to 4.5 Service &amp; Controller" translate="no">​</a></h4>
<pre tabindex="0" class="codeBlockStandalone_MEMb thin-scrollbar language-java codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#0f172a;--prism-background-color:#f1f5f9"><code class="codeBlockLines_e6Vv"><span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">import</span><span style="color:#C18401;--shiki-dark:#E5C07B"> lombok.RequiredArgsConstructor</span><span style="color:#383A42;--shiki-dark:#ABB2BF">;</span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">import</span><span style="color:#C18401;--shiki-dark:#E5C07B"> lombok.extern.slf4j.Slf4j</span><span style="color:#383A42;--shiki-dark:#ABB2BF">;</span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">import</span><span style="color:#C18401;--shiki-dark:#E5C07B"> org.springframework.data.domain.Page</span><span style="color:#383A42;--shiki-dark:#ABB2BF">;</span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">import</span><span style="color:#C18401;--shiki-dark:#E5C07B"> org.springframework.data.domain.PageRequest</span><span style="color:#383A42;--shiki-dark:#ABB2BF">;</span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">import</span><span style="color:#C18401;--shiki-dark:#E5C07B"> org.springframework.data.geo.Distance</span><span style="color:#383A42;--shiki-dark:#ABB2BF">;</span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">import</span><span style="color:#C18401;--shiki-dark:#E5C07B"> org.springframework.data.geo.Metrics</span><span style="color:#383A42;--shiki-dark:#ABB2BF">;</span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">import</span><span style="color:#C18401;--shiki-dark:#E5C07B"> org.springframework.data.geo.Point</span><span style="color:#383A42;--shiki-dark:#ABB2BF">;</span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">import</span><span style="color:#C18401;--shiki-dark:#E5C07B"> org.springframework.stereotype.Service</span><span style="color:#383A42;--shiki-dark:#ABB2BF">;</span></span>
<span class="line"></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">@</span><span style="color:#A626A4;--shiki-dark:#E5C07B">Slf4j</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">@</span><span style="color:#A626A4;--shiki-dark:#E5C07B">Service</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">@</span><span style="color:#A626A4;--shiki-dark:#E5C07B">RequiredArgsConstructor</span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">public</span><span style="color:#A626A4;--shiki-dark:#C678DD"> class</span><span style="color:#C18401;--shiki-dark:#E5C07B"> NearbyUserServiceV2</span><span style="color:#383A42;--shiki-dark:#ABB2BF"> {</span></span>
<span class="line"></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">    private</span><span style="color:#A626A4;--shiki-dark:#C678DD"> final</span><span style="color:#C18401;--shiki-dark:#E5C07B"> UserProfileRepository</span><span style="color:#E45649;--shiki-dark:#E06C75"> userProfileRepository</span><span style="color:#383A42;--shiki-dark:#ABB2BF">;</span></span>
<span class="line"></span>
<span class="line"><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic">    /**</span></span>
<span class="line"><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic">     * 使用 RediSearch 原生联合索引查询附近的人</span></span>
<span class="line"><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic">     *</span></span>
<span class="line"><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic">     * &lt;p&gt;对比方案一：</span></span>
<span class="line"><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic">     * - 无需扩大召回</span></span>
<span class="line"><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic">     * - 无需批量查活跃度</span></span>
<span class="line"><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic">     * - 无需内存排序</span></span>
<span class="line"><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic">     * - 一次调用直接拿到全局最优解</span></span>
<span class="line"><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic">     */</span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">    public</span><span style="color:#C18401;--shiki-dark:#E5C07B"> Page</span><span style="color:#383A42;--shiki-dark:#ABB2BF">&lt;</span><span style="color:#C18401;--shiki-dark:#E5C07B">UserProfile</span><span style="color:#383A42;--shiki-dark:#ABB2BF">&gt;</span><span style="color:#4078F2;--shiki-dark:#61AFEF"> findNearbyUsers</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(</span><span style="color:#A626A4;--shiki-dark:#C678DD">double</span><span style="color:#383A42;--shiki-light-font-style:inherit;--shiki-dark:#E06C75;--shiki-dark-font-style:italic"> lon</span><span style="color:#383A42;--shiki-dark:#ABB2BF">, </span><span style="color:#A626A4;--shiki-dark:#C678DD">double</span><span style="color:#383A42;--shiki-light-font-style:inherit;--shiki-dark:#E06C75;--shiki-dark-font-style:italic"> lat</span><span style="color:#383A42;--shiki-dark:#ABB2BF">,</span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">                                              double</span><span style="color:#383A42;--shiki-light-font-style:inherit;--shiki-dark:#E06C75;--shiki-dark-font-style:italic"> radiusKm</span><span style="color:#383A42;--shiki-dark:#ABB2BF">, </span><span style="color:#A626A4;--shiki-dark:#C678DD">int</span><span style="color:#383A42;--shiki-light-font-style:inherit;--shiki-dark:#E06C75;--shiki-dark-font-style:italic"> page</span><span style="color:#383A42;--shiki-dark:#ABB2BF">, </span><span style="color:#A626A4;--shiki-dark:#C678DD">int</span><span style="color:#383A42;--shiki-light-font-style:inherit;--shiki-dark:#E06C75;--shiki-dark-font-style:italic"> size</span><span style="color:#383A42;--shiki-dark:#ABB2BF">)</span><span style="color:#383A42;--shiki-dark:#ABB2BF"> {</span></span>
<span class="line"><span style="color:#C18401;--shiki-dark:#E5C07B">        Point</span><span style="color:#E45649;--shiki-dark:#E06C75"> myLocation</span><span style="color:#383A42;--shiki-dark:#56B6C2"> =</span><span style="color:#A626A4;--shiki-dark:#C678DD"> new</span><span style="color:#4078F2;--shiki-dark:#61AFEF"> Point</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(lon, lat);</span></span>
<span class="line"><span style="color:#C18401;--shiki-dark:#E5C07B">        Distance</span><span style="color:#E45649;--shiki-dark:#E06C75"> searchRadius</span><span style="color:#383A42;--shiki-dark:#56B6C2"> =</span><span style="color:#A626A4;--shiki-dark:#C678DD"> new</span><span style="color:#4078F2;--shiki-dark:#61AFEF"> Distance</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(radiusKm, </span><span style="color:#E45649;--shiki-dark:#E5C07B">Metrics</span><span style="color:#383A42;--shiki-dark:#ABB2BF">.</span><span style="color:#E45649;--shiki-dark:#E5C07B">KILOMETERS</span><span style="color:#383A42;--shiki-dark:#ABB2BF">);</span></span>
<span class="line"></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">        return</span><span style="color:#383A42;--shiki-dark:#ABB2BF"> userProfileRepository</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">                .</span><span style="color:#4078F2;--shiki-dark:#61AFEF">findByLocationNearOrderByActivityScoreDesc</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">                        myLocation, searchRadius, </span><span style="color:#E45649;--shiki-dark:#E5C07B">PageRequest</span><span style="color:#383A42;--shiki-dark:#ABB2BF">.</span><span style="color:#4078F2;--shiki-dark:#61AFEF">of</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(page, size));</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">    }</span></span>
<span class="line"></span>
<span class="line"><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic">    /**</span></span>
<span class="line"><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic">     * 带 VIP 过滤的查询</span></span>
<span class="line"><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic">     */</span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">    public</span><span style="color:#C18401;--shiki-dark:#E5C07B"> Page</span><span style="color:#383A42;--shiki-dark:#ABB2BF">&lt;</span><span style="color:#C18401;--shiki-dark:#E5C07B">UserProfile</span><span style="color:#383A42;--shiki-dark:#ABB2BF">&gt;</span><span style="color:#4078F2;--shiki-dark:#61AFEF"> findNearbyVipUsers</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(</span><span style="color:#A626A4;--shiki-dark:#C678DD">double</span><span style="color:#383A42;--shiki-light-font-style:inherit;--shiki-dark:#E06C75;--shiki-dark-font-style:italic"> lon</span><span style="color:#383A42;--shiki-dark:#ABB2BF">, </span><span style="color:#A626A4;--shiki-dark:#C678DD">double</span><span style="color:#383A42;--shiki-light-font-style:inherit;--shiki-dark:#E06C75;--shiki-dark-font-style:italic"> lat</span><span style="color:#383A42;--shiki-dark:#ABB2BF">,</span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">                                                 double</span><span style="color:#383A42;--shiki-light-font-style:inherit;--shiki-dark:#E06C75;--shiki-dark-font-style:italic"> radiusKm</span><span style="color:#383A42;--shiki-dark:#ABB2BF">, </span><span style="color:#A626A4;--shiki-dark:#C678DD">int</span><span style="color:#383A42;--shiki-light-font-style:inherit;--shiki-dark:#E06C75;--shiki-dark-font-style:italic"> minVipLevel</span><span style="color:#383A42;--shiki-dark:#ABB2BF">,</span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">                                                 int</span><span style="color:#383A42;--shiki-light-font-style:inherit;--shiki-dark:#E06C75;--shiki-dark-font-style:italic"> page</span><span style="color:#383A42;--shiki-dark:#ABB2BF">, </span><span style="color:#A626A4;--shiki-dark:#C678DD">int</span><span style="color:#383A42;--shiki-light-font-style:inherit;--shiki-dark:#E06C75;--shiki-dark-font-style:italic"> size</span><span style="color:#383A42;--shiki-dark:#ABB2BF">)</span><span style="color:#383A42;--shiki-dark:#ABB2BF"> {</span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">        return</span><span style="color:#383A42;--shiki-dark:#ABB2BF"> userProfileRepository</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">                .</span><span style="color:#4078F2;--shiki-dark:#61AFEF">findByLocationNearAndVipLevelGreaterThanEqualOrderByActivityScoreDesc</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(</span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">                        new</span><span style="color:#4078F2;--shiki-dark:#61AFEF"> Point</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(lon, lat),</span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">                        new</span><span style="color:#4078F2;--shiki-dark:#61AFEF"> Distance</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(radiusKm, </span><span style="color:#E45649;--shiki-dark:#E5C07B">Metrics</span><span style="color:#383A42;--shiki-dark:#ABB2BF">.</span><span style="color:#E45649;--shiki-dark:#E5C07B">KILOMETERS</span><span style="color:#383A42;--shiki-dark:#ABB2BF">),</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">                        minVipLevel,</span></span>
<span class="line"><span style="color:#E45649;--shiki-dark:#E5C07B">                        PageRequest</span><span style="color:#383A42;--shiki-dark:#ABB2BF">.</span><span style="color:#4078F2;--shiki-dark:#61AFEF">of</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(page, size));</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">    }</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">}</span></span></code></pre>
<pre tabindex="0" class="codeBlockStandalone_MEMb thin-scrollbar language-java codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#0f172a;--prism-background-color:#f1f5f9"><code class="codeBlockLines_e6Vv"><span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">import</span><span style="color:#C18401;--shiki-dark:#E5C07B"> lombok.RequiredArgsConstructor</span><span style="color:#383A42;--shiki-dark:#ABB2BF">;</span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">import</span><span style="color:#C18401;--shiki-dark:#E5C07B"> org.springframework.data.domain.Page</span><span style="color:#383A42;--shiki-dark:#ABB2BF">;</span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">import</span><span style="color:#C18401;--shiki-dark:#E5C07B"> org.springframework.web.bind.annotation.</span><span style="color:#E45649;--shiki-dark:#E5C07B">*</span><span style="color:#383A42;--shiki-dark:#ABB2BF">;</span></span>
<span class="line"></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">@</span><span style="color:#A626A4;--shiki-dark:#E5C07B">RestController</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">@</span><span style="color:#A626A4;--shiki-dark:#E5C07B">RequestMapping</span><span style="color:#383A42;--shiki-dark:#E06C75">(</span><span style="color:#50A14F;--shiki-dark:#98C379">"/api/v2/nearby"</span><span style="color:#383A42;--shiki-dark:#E06C75">)</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">@</span><span style="color:#A626A4;--shiki-dark:#E5C07B">RequiredArgsConstructor</span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">public</span><span style="color:#A626A4;--shiki-dark:#C678DD"> class</span><span style="color:#C18401;--shiki-dark:#E5C07B"> NearbyUserControllerV2</span><span style="color:#383A42;--shiki-dark:#ABB2BF"> {</span></span>
<span class="line"></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">    private</span><span style="color:#A626A4;--shiki-dark:#C678DD"> final</span><span style="color:#C18401;--shiki-dark:#E5C07B"> NearbyUserServiceV2</span><span style="color:#E45649;--shiki-dark:#E06C75"> nearbyUserService</span><span style="color:#383A42;--shiki-dark:#ABB2BF">;</span></span>
<span class="line"></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">    @</span><span style="color:#A626A4;--shiki-dark:#E5C07B">GetMapping</span><span style="color:#383A42;--shiki-dark:#E06C75">(</span><span style="color:#50A14F;--shiki-dark:#98C379">"/users"</span><span style="color:#383A42;--shiki-dark:#E06C75">)</span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">    public</span><span style="color:#C18401;--shiki-dark:#E5C07B"> Page</span><span style="color:#383A42;--shiki-dark:#ABB2BF">&lt;</span><span style="color:#C18401;--shiki-dark:#E5C07B">UserProfile</span><span style="color:#383A42;--shiki-dark:#ABB2BF">&gt;</span><span style="color:#4078F2;--shiki-dark:#61AFEF"> nearby</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">            @</span><span style="color:#A626A4;--shiki-dark:#E5C07B">RequestParam</span><span style="color:#A626A4;--shiki-dark:#C678DD"> double</span><span style="color:#383A42;--shiki-light-font-style:inherit;--shiki-dark:#E06C75;--shiki-dark-font-style:italic"> lon</span><span style="color:#383A42;--shiki-dark:#ABB2BF">,</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">            @</span><span style="color:#A626A4;--shiki-dark:#E5C07B">RequestParam</span><span style="color:#A626A4;--shiki-dark:#C678DD"> double</span><span style="color:#383A42;--shiki-light-font-style:inherit;--shiki-dark:#E06C75;--shiki-dark-font-style:italic"> lat</span><span style="color:#383A42;--shiki-dark:#ABB2BF">,</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">            @</span><span style="color:#A626A4;--shiki-dark:#E5C07B">RequestParam</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(</span><span style="color:#986801;--shiki-dark:#D19A66">defaultValue</span><span style="color:#383A42;--shiki-dark:#56B6C2"> =</span><span style="color:#50A14F;--shiki-dark:#98C379"> "10"</span><span style="color:#383A42;--shiki-dark:#ABB2BF">) </span><span style="color:#A626A4;--shiki-dark:#C678DD">double</span><span style="color:#383A42;--shiki-light-font-style:inherit;--shiki-dark:#E06C75;--shiki-dark-font-style:italic"> radiusKm</span><span style="color:#383A42;--shiki-dark:#ABB2BF">,</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">            @</span><span style="color:#A626A4;--shiki-dark:#E5C07B">RequestParam</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(</span><span style="color:#986801;--shiki-dark:#D19A66">defaultValue</span><span style="color:#383A42;--shiki-dark:#56B6C2"> =</span><span style="color:#50A14F;--shiki-dark:#98C379"> "0"</span><span style="color:#383A42;--shiki-dark:#ABB2BF">) </span><span style="color:#A626A4;--shiki-dark:#C678DD">int</span><span style="color:#383A42;--shiki-light-font-style:inherit;--shiki-dark:#E06C75;--shiki-dark-font-style:italic"> page</span><span style="color:#383A42;--shiki-dark:#ABB2BF">,</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">            @</span><span style="color:#A626A4;--shiki-dark:#E5C07B">RequestParam</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(</span><span style="color:#986801;--shiki-dark:#D19A66">defaultValue</span><span style="color:#383A42;--shiki-dark:#56B6C2"> =</span><span style="color:#50A14F;--shiki-dark:#98C379"> "5"</span><span style="color:#383A42;--shiki-dark:#ABB2BF">) </span><span style="color:#A626A4;--shiki-dark:#C678DD">int</span><span style="color:#383A42;--shiki-light-font-style:inherit;--shiki-dark:#E06C75;--shiki-dark-font-style:italic"> size</span><span style="color:#383A42;--shiki-dark:#ABB2BF">)</span><span style="color:#383A42;--shiki-dark:#ABB2BF"> {</span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">        return</span><span style="color:#E45649;--shiki-dark:#E5C07B"> nearbyUserService</span><span style="color:#383A42;--shiki-dark:#ABB2BF">.</span><span style="color:#4078F2;--shiki-dark:#61AFEF">findNearbyUsers</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(lon, lat, radiusKm, page, size);</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">    }</span></span>
<span class="line"></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">    @</span><span style="color:#A626A4;--shiki-dark:#E5C07B">GetMapping</span><span style="color:#383A42;--shiki-dark:#E06C75">(</span><span style="color:#50A14F;--shiki-dark:#98C379">"/vip-users"</span><span style="color:#383A42;--shiki-dark:#E06C75">)</span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">    public</span><span style="color:#C18401;--shiki-dark:#E5C07B"> Page</span><span style="color:#383A42;--shiki-dark:#ABB2BF">&lt;</span><span style="color:#C18401;--shiki-dark:#E5C07B">UserProfile</span><span style="color:#383A42;--shiki-dark:#ABB2BF">&gt;</span><span style="color:#4078F2;--shiki-dark:#61AFEF"> nearbyVip</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">            @</span><span style="color:#A626A4;--shiki-dark:#E5C07B">RequestParam</span><span style="color:#A626A4;--shiki-dark:#C678DD"> double</span><span style="color:#383A42;--shiki-light-font-style:inherit;--shiki-dark:#E06C75;--shiki-dark-font-style:italic"> lon</span><span style="color:#383A42;--shiki-dark:#ABB2BF">,</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">            @</span><span style="color:#A626A4;--shiki-dark:#E5C07B">RequestParam</span><span style="color:#A626A4;--shiki-dark:#C678DD"> double</span><span style="color:#383A42;--shiki-light-font-style:inherit;--shiki-dark:#E06C75;--shiki-dark-font-style:italic"> lat</span><span style="color:#383A42;--shiki-dark:#ABB2BF">,</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">            @</span><span style="color:#A626A4;--shiki-dark:#E5C07B">RequestParam</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(</span><span style="color:#986801;--shiki-dark:#D19A66">defaultValue</span><span style="color:#383A42;--shiki-dark:#56B6C2"> =</span><span style="color:#50A14F;--shiki-dark:#98C379"> "10"</span><span style="color:#383A42;--shiki-dark:#ABB2BF">) </span><span style="color:#A626A4;--shiki-dark:#C678DD">double</span><span style="color:#383A42;--shiki-light-font-style:inherit;--shiki-dark:#E06C75;--shiki-dark-font-style:italic"> radiusKm</span><span style="color:#383A42;--shiki-dark:#ABB2BF">,</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">            @</span><span style="color:#A626A4;--shiki-dark:#E5C07B">RequestParam</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(</span><span style="color:#986801;--shiki-dark:#D19A66">defaultValue</span><span style="color:#383A42;--shiki-dark:#56B6C2"> =</span><span style="color:#50A14F;--shiki-dark:#98C379"> "1"</span><span style="color:#383A42;--shiki-dark:#ABB2BF">) </span><span style="color:#A626A4;--shiki-dark:#C678DD">int</span><span style="color:#383A42;--shiki-light-font-style:inherit;--shiki-dark:#E06C75;--shiki-dark-font-style:italic"> minVipLevel</span><span style="color:#383A42;--shiki-dark:#ABB2BF">,</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">            @</span><span style="color:#A626A4;--shiki-dark:#E5C07B">RequestParam</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(</span><span style="color:#986801;--shiki-dark:#D19A66">defaultValue</span><span style="color:#383A42;--shiki-dark:#56B6C2"> =</span><span style="color:#50A14F;--shiki-dark:#98C379"> "0"</span><span style="color:#383A42;--shiki-dark:#ABB2BF">) </span><span style="color:#A626A4;--shiki-dark:#C678DD">int</span><span style="color:#383A42;--shiki-light-font-style:inherit;--shiki-dark:#E06C75;--shiki-dark-font-style:italic"> page</span><span style="color:#383A42;--shiki-dark:#ABB2BF">,</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">            @</span><span style="color:#A626A4;--shiki-dark:#E5C07B">RequestParam</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(</span><span style="color:#986801;--shiki-dark:#D19A66">defaultValue</span><span style="color:#383A42;--shiki-dark:#56B6C2"> =</span><span style="color:#50A14F;--shiki-dark:#98C379"> "5"</span><span style="color:#383A42;--shiki-dark:#ABB2BF">) </span><span style="color:#A626A4;--shiki-dark:#C678DD">int</span><span style="color:#383A42;--shiki-light-font-style:inherit;--shiki-dark:#E06C75;--shiki-dark-font-style:italic"> size</span><span style="color:#383A42;--shiki-dark:#ABB2BF">)</span><span style="color:#383A42;--shiki-dark:#ABB2BF"> {</span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">        return</span><span style="color:#E45649;--shiki-dark:#E5C07B"> nearbyUserService</span><span style="color:#383A42;--shiki-dark:#ABB2BF">.</span><span style="color:#4078F2;--shiki-dark:#61AFEF">findNearbyVipUsers</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">                lon, lat, radiusKm, minVipLevel, page, size);</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">    }</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">}</span></span></code></pre>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="5-同样的数据方案二的查询结果">5. 同样的数据，方案二的查询结果<a href="https://blog.xiaoxiaoliang.top/en/blog/redis-multi-sort#5-%E5%90%8C%E6%A0%B7%E7%9A%84%E6%95%B0%E6%8D%AE%E6%96%B9%E6%A1%88%E4%BA%8C%E7%9A%84%E6%9F%A5%E8%AF%A2%E7%BB%93%E6%9E%9C" class="hash-link" aria-label="Direct link to 5. 同样的数据，方案二的查询结果" title="Direct link to 5. 同样的数据，方案二的查询结果" translate="no">​</a></h3>
<p>使用方案二查询 <code>10km 内按活跃度降序 Top 3</code>：</p>
<table><thead><tr><th style="text-align:left">排名</th><th style="text-align:left">用户</th><th style="text-align:left">距离(km)</th><th style="text-align:left">活跃度</th><th style="text-align:left">VIP</th></tr></thead><tbody><tr><td style="text-align:left">🥇 1</td><td style="text-align:left">user<!-- -->:B<!-- --> (李四)</td><td style="text-align:left">0.72</td><td style="text-align:left"><strong>95</strong></td><td style="text-align:left">2</td></tr><tr><td style="text-align:left">🥈 2</td><td style="text-align:left">user<!-- -->:D<!-- --> (赵六)</td><td style="text-align:left">5.30</td><td style="text-align:left"><strong>85</strong></td><td style="text-align:left">3</td></tr><tr><td style="text-align:left">🥉 3</td><td style="text-align:left">user<!-- -->:H<!-- --> (郑十)</td><td style="text-align:left">1.52</td><td style="text-align:left"><strong>75</strong></td><td style="text-align:left">1</td></tr></tbody></table>
<blockquote>
<p>🔍 <strong>对比方案一的结果</strong>：</p>
<p>方案一（距离60% + 活跃度40%）：user<!-- -->:B<!-- --> → user<!-- -->:F<!-- --> → user<!-- -->:H<!-- -->
方案二（纯活跃度排序）：      user<!-- -->:B<!-- --> → user<!-- -->:D<!-- --> → user<!-- -->:H</p>
<p><strong>区别在于</strong>：方案二这里是纯按活跃度排序（<code>SORTBY activity_score DESC</code>），user<!-- -->:D<!-- --> 虽然距离远(5.3km)但活跃度高(85)，所以排第二。而方案一的加权公式把距离因素考虑进去了，user<!-- -->:D<!-- --> 被压到了第 6 名。</p>
<p>如果你也想在方案二中实现加权混合排序，可以在 <code>HSET</code> 时预计算一个 <code>weighted_score</code> 字段（定时任务更新），或使用 <code>FT.AGGREGATE</code> 命令在查询时动态计算（语法稍复杂，但能实现等效的加权打分）。</p>
</blockquote>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="6-优缺点评估">6. 优缺点评估<a href="https://blog.xiaoxiaoliang.top/en/blog/redis-multi-sort#6-%E4%BC%98%E7%BC%BA%E7%82%B9%E8%AF%84%E4%BC%B0" class="hash-link" aria-label="Direct link to 6. 优缺点评估" title="Direct link to 6. 优缺点评估" translate="no">​</a></h3>
<p><strong>✅ 优点：</strong></p>
<ul>
<li class=""><strong>性能天花板</strong>：全部计算在 Redis C 语言引擎内完成，无网络放大、无 Java GC 压力。</li>
<li class=""><strong>代码极其简洁</strong>：从方案一的 100+ 行精排逻辑，缩减到 1 行方法名声明。</li>
<li class=""><strong>全局最优解</strong>：不存在"漏斗效应"，所有在范围内的用户都会被考虑。</li>
<li class=""><strong>天然支持分页</strong>：<code>LIMIT offset count</code> 直接分页，无需在内存中手动截断。</li>
</ul>
<p><strong>❌ 缺点：</strong></p>
<ul>
<li class=""><strong>基础设施门槛</strong>：必须使用 Redis 8.0 或安装 RediSearch 模块的 Redis Stack。传统的云 Redis 实例通常不支持。</li>
<li class=""><strong>内存开销更大</strong>：除了数据本身，还要额外存储 Quadtree、SkipList、倒排索引等索引结构，内存占用通常是纯数据的 <strong>2~4 倍</strong>。</li>
<li class=""><strong>复杂加权排序受限</strong>：如果需要像方案一那样的多维加权公式，需要借助 <code>FT.AGGREGATE</code> + <code>APPLY</code> 函数，学习曲线陡峭。</li>
</ul>
<hr>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="-总结与选型建议">📝 总结与选型建议<a href="https://blog.xiaoxiaoliang.top/en/blog/redis-multi-sort#-%E6%80%BB%E7%BB%93%E4%B8%8E%E9%80%89%E5%9E%8B%E5%BB%BA%E8%AE%AE" class="hash-link" aria-label="Direct link to 📝 总结与选型建议" title="Direct link to 📝 总结与选型建议" translate="no">​</a></h2>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="方案全维度对比">方案全维度对比<a href="https://blog.xiaoxiaoliang.top/en/blog/redis-multi-sort#%E6%96%B9%E6%A1%88%E5%85%A8%E7%BB%B4%E5%BA%A6%E5%AF%B9%E6%AF%94" class="hash-link" aria-label="Direct link to 方案全维度对比" title="Direct link to 方案全维度对比" translate="no">​</a></h3>
<table><thead><tr><th style="text-align:left">维度</th><th style="text-align:left">方案一：内存精排 (Redis GEO + Java)</th><th style="text-align:left">方案二：原生联合索引 (RediSearch)</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">标准 Redis ≥ 3.2 即可</td><td style="text-align:left">Redis 8.0 或 Redis Stack</td></tr><tr><td style="text-align:left"><strong>排序灵活度</strong></td><td style="text-align:left">⭐⭐⭐⭐⭐ 想怎么排就怎么排</td><td style="text-align:left">⭐⭐⭐ 简单排序很方便，复杂加权需 FT.AGGREGATE</td></tr><tr><td style="text-align:left"><strong>网络开销</strong></td><td style="text-align:left">❌ 有放大（召回 N×10 倍）</td><td style="text-align:left">✅ 精准返回，无放大</td></tr><tr><td style="text-align:left"><strong>JVM 压力</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">较高（~150 行核心逻辑）</td><td style="text-align:left">极低（~10 行接口声明）</td></tr><tr><td style="text-align:left"><strong>内存占用</strong></td><td style="text-align:left">✅ 只有 GEO + HASH</td><td style="text-align:left">❌ 多索引结构，2~4 倍</td></tr></tbody></table>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="一句话总结">一句话总结<a href="https://blog.xiaoxiaoliang.top/en/blog/redis-multi-sort#%E4%B8%80%E5%8F%A5%E8%AF%9D%E6%80%BB%E7%BB%93" class="hash-link" aria-label="Direct link to 一句话总结" title="Direct link to 一句话总结" translate="no">​</a></h3>
<blockquote>
<p>如果你的系统还是传统的 Redis，用**「扩大召回 + 内存重排」**足以应付 90% 的场景；如果你准备拥抱未来或正在主导架构升级，毫不犹豫地推行 <strong>Redis 8.0 的原生联合索引</strong>，享受底层 C 语言级别的降维打击吧！</p>
</blockquote>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="延伸阅读">延伸阅读<a href="https://blog.xiaoxiaoliang.top/en/blog/redis-multi-sort#%E5%BB%B6%E4%BC%B8%E9%98%85%E8%AF%BB" class="hash-link" aria-label="Direct link to 延伸阅读" title="Direct link to 延伸阅读" translate="no">​</a></h3>
<ul>
<li class=""><a href="https://redis.io/docs/latest/commands/?group=geo" target="_blank" rel="noopener noreferrer" class="">Redis 官方文档 - GEO 命令</a></li>
<li class=""><a href="https://redis.io/docs/latest/develop/interact/search-and-query/" target="_blank" rel="noopener noreferrer" class="">RediSearch 官方文档</a></li>
<li class=""><a href="https://github.com/redis/redis-om-spring" target="_blank" rel="noopener noreferrer" class="">Redis OM Spring GitHub</a></li>
<li class=""><a href="https://redis.io/blog/redis-8-ga/" target="_blank" rel="noopener noreferrer" class="">Redis 8.0 Release Notes</a></li>
</ul>]]></content>
        <author>
            <name>小小亮</name>
            <uri>https://github.com/xiaoxiaoliang</uri>
        </author>
        <category label="Redis" term="Redis"/>
        <category label="Java" term="Java"/>
    </entry>
    <entry>
        <title type="html"><![CDATA[聊聊分布式锁：从数据库到 Redis 再到 Redisson]]></title>
        <id>https://blog.xiaoxiaoliang.top/en/blog/distributed-lock</id>
        <link href="https://blog.xiaoxiaoliang.top/en/blog/distributed-lock"/>
        <updated>2026-02-17T00:00:00.000Z</updated>
        <summary type="html"><![CDATA[在单机应用时代，一个 synchronized 或 ReentrantLock 就能解决多线程竞争共享资源的问题。但随着微服务架构的普及，应用被部署在多台服务器上，JVM 级别的本地锁就失效了——它只能保证单进程内的互斥。这时候，就需要一把"分布式锁"来保证跨进程、跨主机的互斥访问。]]></summary>
        <content type="html"><![CDATA[<p>在单机应用时代，一个 <code>synchronized</code> 或 <code>ReentrantLock</code> 就能解决多线程竞争共享资源的问题。但随着微服务架构的普及，应用被部署在多台服务器上，JVM 级别的本地锁就失效了——它只能保证单进程内的互斥。这时候，就需要一把"分布式锁"来保证跨进程、跨主机的互斥访问。</p>
<p>本文将带你深入了解分布式锁的常见实现方案，特别是 Redis 方案的演进，以及工业级组件 Redisson 的底层原理。</p>
<!-- -->
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="1-分布式锁的核心要求">1. 分布式锁的核心要求<a href="https://blog.xiaoxiaoliang.top/en/blog/distributed-lock#1-%E5%88%86%E5%B8%83%E5%BC%8F%E9%94%81%E7%9A%84%E6%A0%B8%E5%BF%83%E8%A6%81%E6%B1%82" class="hash-link" aria-label="Direct link to 1. 分布式锁的核心要求" title="Direct link to 1. 分布式锁的核心要求" translate="no">​</a></h2>
<p>一个靠谱的分布式锁，通常需要满足以下条件：</p>
<ol>
<li class=""><strong>互斥性</strong>：在任意时刻，只能有一个客户端持有锁。</li>
<li class=""><strong>防死锁</strong>：即使持有锁的客户端崩溃或网络断开，锁也能自动释放（通常通过过期时间实现）。</li>
<li class=""><strong>可重入性</strong>：同一个客户端可以多次获取同一把锁。</li>
<li class=""><strong>高性能/高可用</strong>：加锁和解锁需要开销小，且服务要稳定。</li>
</ol>
<hr>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="2-常见的实现方案">2. 常见的实现方案<a href="https://blog.xiaoxiaoliang.top/en/blog/distributed-lock#2-%E5%B8%B8%E8%A7%81%E7%9A%84%E5%AE%9E%E7%8E%B0%E6%96%B9%E6%A1%88" class="hash-link" aria-label="Direct link to 2. 常见的实现方案" title="Direct link to 2. 常见的实现方案" translate="no">​</a></h2>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="21-基于数据库mysql实现">2.1 基于数据库（MySQL）实现<a href="https://blog.xiaoxiaoliang.top/en/blog/distributed-lock#21-%E5%9F%BA%E4%BA%8E%E6%95%B0%E6%8D%AE%E5%BA%93mysql%E5%AE%9E%E7%8E%B0" class="hash-link" aria-label="Direct link to 2.1 基于数据库（MySQL）实现" title="Direct link to 2.1 基于数据库（MySQL）实现" translate="no">​</a></h3>
<p>用 MySQL 来做分布式锁，是一种非常经典的思路。它适合系统规模不大、并发量不高，且不想为了分布式锁单独引入 Redis 或 Zookeeper 的场景。</p>
<p>在 MySQL 中，主流的方案有两种：<strong>基于唯一索引</strong>和<strong>基于悲观锁（<code>FOR UPDATE</code>）</strong>。</p>
<h4 class="anchor anchorTargetStickyNavbar_Vzrq" id="方案一基于唯一索引unique-key">方案一：基于唯一索引（Unique Key）<a href="https://blog.xiaoxiaoliang.top/en/blog/distributed-lock#%E6%96%B9%E6%A1%88%E4%B8%80%E5%9F%BA%E4%BA%8E%E5%94%AF%E4%B8%80%E7%B4%A2%E5%BC%95unique-key" class="hash-link" aria-label="Direct link to 方案一：基于唯一索引（Unique Key）" title="Direct link to 方案一：基于唯一索引（Unique Key）" translate="no">​</a></h4>
<p>这是最容易想到的一种方式。核心思路是：利用数据库唯一索引的约束，让多个节点竞争插入同一条记录，只有一个能成功。<strong>插入成功 = 拿到锁，删除记录 = 释放锁。</strong></p>
<p><strong>① 表结构设计</strong></p>
<pre tabindex="0" class="codeBlockStandalone_MEMb thin-scrollbar language-sql codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#0f172a;--prism-background-color:#f1f5f9"><code class="codeBlockLines_e6Vv"><span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">CREATE</span><span style="color:#A626A4;--shiki-dark:#C678DD"> TABLE</span><span style="color:#383A42;--shiki-dark:#ABB2BF"> `</span><span style="color:#4078F2;--shiki-dark:#61AFEF">distributed_lock</span><span style="color:#383A42;--shiki-dark:#ABB2BF">` (</span></span>
<span class="line"><span style="color:#50A14F;--shiki-dark:#98C379">  `id`</span><span style="color:#A626A4;--shiki-dark:#C678DD"> bigint</span><span style="color:#A626A4;--shiki-dark:#C678DD"> NOT NULL</span><span style="color:#383A42;--shiki-dark:#ABB2BF"> AUTO_INCREMENT COMMENT </span><span style="color:#50A14F;--shiki-dark:#98C379">'主键'</span><span style="color:#383A42;--shiki-dark:#ABB2BF">,</span></span>
<span class="line"><span style="color:#50A14F;--shiki-dark:#98C379">  `resource_name`</span><span style="color:#A626A4;--shiki-dark:#C678DD"> varchar</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(</span><span style="color:#986801;--shiki-dark:#D19A66">64</span><span style="color:#383A42;--shiki-dark:#ABB2BF">) </span><span style="color:#A626A4;--shiki-dark:#C678DD">NOT NULL</span><span style="color:#383A42;--shiki-dark:#ABB2BF"> COMMENT </span><span style="color:#50A14F;--shiki-dark:#98C379">'锁定的资源名称'</span><span style="color:#383A42;--shiki-dark:#ABB2BF">,</span></span>
<span class="line"><span style="color:#50A14F;--shiki-dark:#98C379">  `owner_info`</span><span style="color:#A626A4;--shiki-dark:#C678DD"> varchar</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(</span><span style="color:#986801;--shiki-dark:#D19A66">128</span><span style="color:#383A42;--shiki-dark:#ABB2BF">) </span><span style="color:#A626A4;--shiki-dark:#C678DD">NOT NULL</span><span style="color:#383A42;--shiki-dark:#ABB2BF"> COMMENT </span><span style="color:#50A14F;--shiki-dark:#98C379">'锁持有者信息(如 机器IP + 线程ID)'</span><span style="color:#383A42;--shiki-dark:#ABB2BF">,</span></span>
<span class="line"><span style="color:#50A14F;--shiki-dark:#98C379">  `create_time`</span><span style="color:#A626A4;--shiki-dark:#C678DD"> timestamp</span><span style="color:#A626A4;--shiki-dark:#C678DD"> NOT NULL</span><span style="color:#A626A4;--shiki-dark:#C678DD"> DEFAULT</span><span style="color:#383A42;--shiki-dark:#ABB2BF"> CURRENT_TIMESTAMP COMMENT </span><span style="color:#50A14F;--shiki-dark:#98C379">'创建时间'</span><span style="color:#383A42;--shiki-dark:#ABB2BF">,</span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">  PRIMARY KEY</span><span style="color:#383A42;--shiki-dark:#ABB2BF"> (</span><span style="color:#50A14F;--shiki-dark:#98C379">`id`</span><span style="color:#383A42;--shiki-dark:#ABB2BF">),</span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">  UNIQUE</span><span style="color:#A626A4;--shiki-dark:#C678DD"> KEY</span><span style="color:#50A14F;--shiki-dark:#98C379"> `uidx_resource`</span><span style="color:#383A42;--shiki-dark:#ABB2BF"> (</span><span style="color:#50A14F;--shiki-dark:#98C379">`resource_name`</span><span style="color:#383A42;--shiki-dark:#ABB2BF">) </span><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic">-- 核心：唯一索引保证互斥</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">) ENGINE</span><span style="color:#383A42;--shiki-dark:#56B6C2">=</span><span style="color:#383A42;--shiki-dark:#ABB2BF">InnoDB </span><span style="color:#A626A4;--shiki-dark:#C678DD">DEFAULT</span><span style="color:#383A42;--shiki-dark:#ABB2BF"> CHARSET</span><span style="color:#383A42;--shiki-dark:#56B6C2">=</span><span style="color:#383A42;--shiki-dark:#ABB2BF">utf8mb4 COMMENT</span><span style="color:#383A42;--shiki-dark:#56B6C2">=</span><span style="color:#50A14F;--shiki-dark:#98C379">'分布式锁表'</span><span style="color:#383A42;--shiki-dark:#ABB2BF">;</span></span></code></pre>
<p><strong>② 加锁与解锁</strong></p>
<ul>
<li class="">
<p><strong>加锁</strong>：尝试插入一条带有资源名称的记录。</p>
<pre tabindex="0" class="codeBlockStandalone_MEMb thin-scrollbar language-sql codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#0f172a;--prism-background-color:#f1f5f9"><code class="codeBlockLines_e6Vv"><span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">INSERT INTO</span><span style="color:#383A42;--shiki-dark:#ABB2BF"> distributed_lock (resource_name, owner_info) </span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">VALUES</span><span style="color:#383A42;--shiki-dark:#ABB2BF"> (</span><span style="color:#50A14F;--shiki-dark:#98C379">'order_12345'</span><span style="color:#383A42;--shiki-dark:#ABB2BF">, </span><span style="color:#50A14F;--shiki-dark:#98C379">'192.168.1.100:thread-1'</span><span style="color:#383A42;--shiki-dark:#ABB2BF">);</span></span></code></pre>
<p>插入成功说明拿到了锁；如果抛出 <code>DuplicateKeyException</code>（唯一键冲突），说明锁已被占，加锁失败。</p>
</li>
<li class="">
<p><strong>解锁</strong>：业务执行完毕后，删除记录即可。</p>
<pre tabindex="0" class="codeBlockStandalone_MEMb thin-scrollbar language-sql codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#0f172a;--prism-background-color:#f1f5f9"><code class="codeBlockLines_e6Vv"><span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">DELETE</span><span style="color:#A626A4;--shiki-dark:#C678DD"> FROM</span><span style="color:#383A42;--shiki-dark:#ABB2BF"> distributed_lock </span><span style="color:#A626A4;--shiki-dark:#C678DD">WHERE</span><span style="color:#383A42;--shiki-dark:#ABB2BF"> resource_name </span><span style="color:#383A42;--shiki-dark:#56B6C2">=</span><span style="color:#50A14F;--shiki-dark:#98C379"> 'order_12345'</span><span style="color:#383A42;--shiki-dark:#ABB2BF">;</span></span></code></pre>
</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>死锁（服务宕机）</strong></td><td style="text-align:left">持有锁的节点突然宕机，<code>DELETE</code> 没执行，记录永远存在</td><td style="text-align:left">引入定时清理任务，删除 <code>create_time</code> 超时的记录。但这又带来"业务还没跑完锁就被清掉"的风险</td></tr><tr><td style="text-align:left"><strong>非阻塞</strong></td><td style="text-align:left"><code>INSERT</code> 失败直接返回异常，不会像 <code>synchronized</code> 那样排队等待</td><td style="text-align:left">代码层面写 <code>while</code> 循环 + <code>Thread.sleep()</code> 自旋重试</td></tr><tr><td style="text-align:left"><strong>不可重入</strong></td><td style="text-align:left">同一线程在未释放锁的情况下再次申请，会因为唯一键冲突而失败</td><td style="text-align:left">增加 <code>reentrant_count</code> 字段，加锁前先查 <code>owner_info</code> 是否是自己，是则计数+1；释放时计数-1，减到 0 才真正 <code>DELETE</code></td></tr></tbody></table>
<hr>
<h4 class="anchor anchorTargetStickyNavbar_Vzrq" id="方案二基于悲观锁for-update">方案二：基于悲观锁（FOR UPDATE）<a href="https://blog.xiaoxiaoliang.top/en/blog/distributed-lock#%E6%96%B9%E6%A1%88%E4%BA%8C%E5%9F%BA%E4%BA%8E%E6%82%B2%E8%A7%82%E9%94%81for-update" class="hash-link" aria-label="Direct link to 方案二：基于悲观锁（FOR UPDATE）" title="Direct link to 方案二：基于悲观锁（FOR UPDATE）" translate="no">​</a></h4>
<p>这种方案利用了 InnoDB 引擎的<strong>行级排他锁</strong>。通过 <code>SELECT ... FOR UPDATE</code>，MySQL 会对命中的行加上 X 锁，其他事务访问同一行时会被<strong>阻塞</strong>，直到持锁事务提交或回滚。</p>
<p><strong>① 表结构设计</strong></p>
<pre tabindex="0" class="codeBlockStandalone_MEMb thin-scrollbar language-sql codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#0f172a;--prism-background-color:#f1f5f9"><code class="codeBlockLines_e6Vv"><span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">CREATE</span><span style="color:#A626A4;--shiki-dark:#C678DD"> TABLE</span><span style="color:#383A42;--shiki-dark:#ABB2BF"> `</span><span style="color:#4078F2;--shiki-dark:#61AFEF">distributed_lock</span><span style="color:#383A42;--shiki-dark:#ABB2BF">` (</span></span>
<span class="line"><span style="color:#50A14F;--shiki-dark:#98C379">  `id`</span><span style="color:#A626A4;--shiki-dark:#C678DD"> bigint</span><span style="color:#A626A4;--shiki-dark:#C678DD"> NOT NULL</span><span style="color:#383A42;--shiki-dark:#ABB2BF"> AUTO_INCREMENT,</span></span>
<span class="line"><span style="color:#50A14F;--shiki-dark:#98C379">  `resource_name`</span><span style="color:#A626A4;--shiki-dark:#C678DD"> varchar</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(</span><span style="color:#986801;--shiki-dark:#D19A66">64</span><span style="color:#383A42;--shiki-dark:#ABB2BF">) </span><span style="color:#A626A4;--shiki-dark:#C678DD">NOT NULL</span><span style="color:#383A42;--shiki-dark:#ABB2BF">,</span></span>
<span class="line"><span style="color:#50A14F;--shiki-dark:#98C379">  `description`</span><span style="color:#A626A4;--shiki-dark:#C678DD"> varchar</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(</span><span style="color:#986801;--shiki-dark:#D19A66">255</span><span style="color:#383A42;--shiki-dark:#ABB2BF">) </span><span style="color:#A626A4;--shiki-dark:#C678DD">DEFAULT</span><span style="color:#A626A4;--shiki-dark:#C678DD"> NULL</span><span style="color:#383A42;--shiki-dark:#ABB2BF">,</span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">  PRIMARY KEY</span><span style="color:#383A42;--shiki-dark:#ABB2BF"> (</span><span style="color:#50A14F;--shiki-dark:#98C379">`id`</span><span style="color:#383A42;--shiki-dark:#ABB2BF">),</span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">  UNIQUE</span><span style="color:#A626A4;--shiki-dark:#C678DD"> KEY</span><span style="color:#50A14F;--shiki-dark:#98C379"> `uidx_resource`</span><span style="color:#383A42;--shiki-dark:#ABB2BF"> (</span><span style="color:#50A14F;--shiki-dark:#98C379">`resource_name`</span><span style="color:#383A42;--shiki-dark:#ABB2BF">) </span><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic">-- 必须有索引！</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">) ENGINE</span><span style="color:#383A42;--shiki-dark:#56B6C2">=</span><span style="color:#383A42;--shiki-dark:#ABB2BF">InnoDB </span><span style="color:#A626A4;--shiki-dark:#C678DD">DEFAULT</span><span style="color:#383A42;--shiki-dark:#ABB2BF"> CHARSET</span><span style="color:#383A42;--shiki-dark:#56B6C2">=</span><span style="color:#383A42;--shiki-dark:#ABB2BF">utf8mb4;</span></span></code></pre>
<div class="theme-admonition theme-admonition-caution admonition_xJq3 alert alert--warning"><div class="admonitionHeading_Gvgb"><span class="admonitionIcon_Rf37"><svg viewBox="0 0 16 16"><path fill-rule="evenodd" d="M8.893 1.5c-.183-.31-.52-.5-.887-.5s-.703.19-.886.5L.138 13.499a.98.98 0 0 0 0 1.001c.193.31.53.501.886.501h13.964c.367 0 .704-.19.877-.5a1.03 1.03 0 0 0 .01-1.002L8.893 1.5zm.133 11.497H6.987v-2.003h2.039v2.003zm0-3.004H6.987V5.987h2.039v4.006z"></path></svg></span>重要前提</div><div class="admonitionContent_BuS1"><p>你需要提前在表里插入好对应的资源记录（或在代码中先做一次 <code>INSERT IGNORE</code>），因为 <code>FOR UPDATE</code> 锁的是已存在的行。</p></div></div>
<p><strong>② 加锁与解锁</strong></p>
<pre tabindex="0" class="codeBlockStandalone_MEMb thin-scrollbar language-sql codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#0f172a;--prism-background-color:#f1f5f9"><code class="codeBlockLines_e6Vv"><span class="line"><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic">-- 1. 开启事务</span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">BEGIN</span><span style="color:#383A42;--shiki-dark:#ABB2BF">;</span></span>
<span class="line"></span>
<span class="line"><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic">-- 2. 加锁：如果该行已被其他事务锁定，当前语句会阻塞等待</span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">SELECT</span><span style="color:#383A42;--shiki-dark:#ABB2BF"> * </span><span style="color:#A626A4;--shiki-dark:#C678DD">FROM</span><span style="color:#383A42;--shiki-dark:#ABB2BF"> distributed_lock </span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">WHERE</span><span style="color:#383A42;--shiki-dark:#ABB2BF"> resource_name </span><span style="color:#383A42;--shiki-dark:#56B6C2">=</span><span style="color:#50A14F;--shiki-dark:#98C379"> 'order_12345'</span><span style="color:#A626A4;--shiki-dark:#C678DD"> FOR</span><span style="color:#A626A4;--shiki-dark:#C678DD"> UPDATE</span><span style="color:#383A42;--shiki-dark:#ABB2BF">;</span></span>
<span class="line"></span>
<span class="line"><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic">-- 3. 执行业务逻辑...</span></span>
<span class="line"></span>
<span class="line"><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic">-- 4. 释放锁：提交事务，MySQL 自动释放行锁</span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">COMMIT</span><span style="color:#383A42;--shiki-dark:#ABB2BF">;</span></span></code></pre>
<p>在 Spring 中，可以通过 <code>@Transactional</code> 注解配合 MyBatis/JPA 来实现。</p>
<p><strong>③ 优势与踩坑</strong></p>
<p><strong>优势：</strong></p>
<ul>
<li class=""><strong>天然防死锁</strong>：如果服务宕机，数据库连接断开，MySQL 会自动回滚事务并释放行锁。不需要像方案一那样搞定时清理。</li>
<li class=""><strong>天然阻塞</strong>：<code>FOR UPDATE</code> 本身就会排队等待，不需要在代码里写重试循环。</li>
</ul>
<p><strong>踩坑：</strong></p>
<ul>
<li class=""><strong>索引失效 → 表锁</strong>：InnoDB 的行锁是基于<strong>索引</strong>实现的。如果 <code>WHERE</code> 条件的字段没有命中索引，行锁会<strong>退化为表锁</strong>，锁住整张表，导致所有并发请求串行化，吞吐量骤降。这是最危险的坑。</li>
<li class=""><strong>连接池耗尽</strong>：整个业务执行期间，数据库连接一直被占用。如果业务逻辑耗时较长，连接池会被迅速耗尽。因此必须保证加锁后的逻辑<strong>快进快出</strong>，严禁在事务中做 RPC 调用或大量 IO 操作。</li>
</ul>
<hr>
<h4 class="anchor anchorTargetStickyNavbar_Vzrq" id="mysql-方案对比">MySQL 方案对比<a href="https://blog.xiaoxiaoliang.top/en/blog/distributed-lock#mysql-%E6%96%B9%E6%A1%88%E5%AF%B9%E6%AF%94" class="hash-link" aria-label="Direct link to MySQL 方案对比" title="Direct link to MySQL 方案对比" translate="no">​</a></h4>
<table><thead><tr><th style="text-align:left">特性</th><th style="text-align:left">基于唯一索引 (INSERT)</th><th style="text-align:left">基于悲观锁 (FOR UPDATE)</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">代码自旋（消耗 CPU）</td><td style="text-align:left">数据库阻塞（需配置锁等待超时 <code>innodb_lock_wait_timeout</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></tbody></table>
<div class="theme-admonition theme-admonition-tip admonition_xJq3 alert alert--success"><div class="admonitionHeading_Gvgb"><span class="admonitionIcon_Rf37"><svg viewBox="0 0 12 16"><path fill-rule="evenodd" d="M6.5 0C3.48 0 1 2.19 1 5c0 .92.55 2.25 1 3 1.34 2.25 1.78 2.78 2 4v1h5v-1c.22-1.22.66-1.75 2-4 .45-.75 1-2.08 1-3 0-2.81-2.48-5-5.5-5zm3.64 7.48c-.25.44-.47.8-.67 1.11-.86 1.41-1.25 2.06-1.45 3.23-.02.05-.02.11-.02.17H5c0-.06 0-.13-.02-.17-.2-1.17-.59-1.83-1.45-3.23-.2-.31-.42-.67-.67-1.11C2.44 6.78 2 5.65 2 5c0-2.2 2.02-4 4.5-4 1.22 0 2.36.42 3.22 1.19C10.55 2.94 11 3.94 11 5c0 .66-.44 1.78-.86 2.48zM4 14h5c-.23 1.14-1.3 2-2.5 2s-2.27-.86-2.5-2z"></path></svg></span>选型建议</div><div class="admonitionContent_BuS1"><ul>
<li class="">如果只是防止定时任务重复执行这类低并发场景，<code>FOR UPDATE</code> 方案更省心。</li>
<li class="">如果分布式锁在核心业务链路上，且并发量较高（如秒杀、支付），<strong>请不要用 MySQL 做分布式锁</strong>，它会成为系统瓶颈。此时应该转向 Redis (Redisson) 或 Zookeeper。</li>
</ul></div></div>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="22-基于-redis-的基础实现">2.2 基于 Redis 的基础实现<a href="https://blog.xiaoxiaoliang.top/en/blog/distributed-lock#22-%E5%9F%BA%E4%BA%8E-redis-%E7%9A%84%E5%9F%BA%E7%A1%80%E5%AE%9E%E7%8E%B0" class="hash-link" aria-label="Direct link to 2.2 基于 Redis 的基础实现" title="Direct link to 2.2 基于 Redis 的基础实现" translate="no">​</a></h3>
<p>Redis 是内存数据库，性能极高，天然适合做分布式锁。我们先不谈 Redisson，来看看<strong>用原生 Redis 命令手动实现分布式锁</strong>的演进过程——理解这个过程，才能真正理解 Redisson 到底帮我们解决了什么。</p>
<h4 class="anchor anchorTargetStickyNavbar_Vzrq" id="版本-10setnx--expire有致命缺陷">版本 1.0：SETNX + EXPIRE（有致命缺陷）<a href="https://blog.xiaoxiaoliang.top/en/blog/distributed-lock#%E7%89%88%E6%9C%AC-10setnx--expire%E6%9C%89%E8%87%B4%E5%91%BD%E7%BC%BA%E9%99%B7" class="hash-link" aria-label="Direct link to 版本 1.0：SETNX + EXPIRE（有致命缺陷）" title="Direct link to 版本 1.0：SETNX + EXPIRE（有致命缺陷）" translate="no">​</a></h4>
<p>最朴素的想法：用 <code>SETNX</code>（SET if Not eXists）抢占一个 key，谁抢到谁就持有锁。</p>
<pre tabindex="0" class="codeBlockStandalone_MEMb thin-scrollbar language-java codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#0f172a;--prism-background-color:#f1f5f9"><code class="codeBlockLines_e6Vv"><span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">if</span><span style="color:#383A42;--shiki-dark:#E06C75"> (</span><span style="color:#E45649;--shiki-dark:#E5C07B">redis</span><span style="color:#383A42;--shiki-dark:#ABB2BF">.</span><span style="color:#4078F2;--shiki-dark:#61AFEF">setnx</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(</span><span style="color:#50A14F;--shiki-dark:#98C379">"lock_key"</span><span style="color:#383A42;--shiki-dark:#ABB2BF">, </span><span style="color:#50A14F;--shiki-dark:#98C379">"value"</span><span style="color:#383A42;--shiki-dark:#ABB2BF">)</span><span style="color:#383A42;--shiki-dark:#56B6C2"> ==</span><span style="color:#986801;--shiki-dark:#D19A66"> 1</span><span style="color:#383A42;--shiki-dark:#E06C75">) {</span></span>
<span class="line"><span style="color:#E45649;--shiki-dark:#E5C07B">    redis</span><span style="color:#383A42;--shiki-dark:#ABB2BF">.</span><span style="color:#4078F2;--shiki-dark:#61AFEF">expire</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(</span><span style="color:#50A14F;--shiki-dark:#98C379">"lock_key"</span><span style="color:#383A42;--shiki-dark:#ABB2BF">, </span><span style="color:#986801;--shiki-dark:#D19A66">30</span><span style="color:#383A42;--shiki-dark:#ABB2BF">);</span><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic"> // 设置30秒过期</span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">    try</span><span style="color:#383A42;--shiki-dark:#E06C75"> {</span></span>
<span class="line"><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic">        // 业务逻辑</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#E06C75">    } </span><span style="color:#A626A4;--shiki-dark:#C678DD">finally</span><span style="color:#383A42;--shiki-dark:#E06C75"> {</span></span>
<span class="line"><span style="color:#E45649;--shiki-dark:#E5C07B">        redis</span><span style="color:#383A42;--shiki-dark:#ABB2BF">.</span><span style="color:#4078F2;--shiki-dark:#61AFEF">del</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(</span><span style="color:#50A14F;--shiki-dark:#98C379">"lock_key"</span><span style="color:#383A42;--shiki-dark:#ABB2BF">);</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#E06C75">    }</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#E06C75">}</span></span></code></pre>
<div class="theme-admonition theme-admonition-danger admonition_xJq3 alert alert--danger"><div class="admonitionHeading_Gvgb"><span class="admonitionIcon_Rf37"><svg viewBox="0 0 12 16"><path fill-rule="evenodd" d="M5.05.31c.81 2.17.41 3.38-.52 4.31C3.55 5.67 1.98 6.45.9 7.98c-1.45 2.05-1.7 6.53 3.53 7.7-2.2-1.16-2.67-4.52-.3-6.61-.61 2.03.53 3.33 1.94 2.86 1.39-.47 2.3.53 2.27 1.67-.02.78-.31 1.44-1.13 1.81 3.42-.59 4.78-3.42 4.78-5.56 0-2.84-2.53-3.22-1.25-5.61-1.52.13-2.03 1.13-1.89 2.75.09 1.08-1.02 1.8-1.86 1.33-.67-.41-.66-1.19-.06-1.78C8.18 5.31 8.68 2.45 5.05.32L5.03.3l.02.01z"></path></svg></span>致命问题：非原子操作</div><div class="admonitionContent_BuS1"><p><code>SETNX</code> 和 <code>EXPIRE</code> 是<strong>两条独立命令</strong>。如果 <code>SETNX</code> 成功后、<code>EXPIRE</code> 执行前，服务器恰好宕机了，这把锁就<strong>永远不会过期</strong>——死锁。</p></div></div>
<h4 class="anchor anchorTargetStickyNavbar_Vzrq" id="版本-20原子加锁set-nx-px">版本 2.0：原子加锁（SET NX PX）<a href="https://blog.xiaoxiaoliang.top/en/blog/distributed-lock#%E7%89%88%E6%9C%AC-20%E5%8E%9F%E5%AD%90%E5%8A%A0%E9%94%81set-nx-px" class="hash-link" aria-label="Direct link to 版本 2.0：原子加锁（SET NX PX）" title="Direct link to 版本 2.0：原子加锁（SET NX PX）" translate="no">​</a></h4>
<p>从 Redis 2.6.12 开始，<code>SET</code> 命令支持参数组合，一条命令搞定加锁 + 过期：</p>
<pre tabindex="0" class="codeBlockStandalone_MEMb thin-scrollbar language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#0f172a;--prism-background-color:#f1f5f9"><code class="codeBlockLines_e6Vv"><span class="line"><span style="color:#4078F2;--shiki-dark:#61AFEF">SET</span><span style="color:#50A14F;--shiki-dark:#98C379"> lock_key</span><span style="color:#50A14F;--shiki-dark:#98C379"> unique_value</span><span style="color:#50A14F;--shiki-dark:#98C379"> NX</span><span style="color:#50A14F;--shiki-dark:#98C379"> PX</span><span style="color:#986801;--shiki-dark:#D19A66"> 30000</span></span></code></pre>
<table><thead><tr><th style="text-align:left">参数</th><th style="text-align:left">含义</th></tr></thead><tbody><tr><td style="text-align:left"><code>NX</code></td><td style="text-align:left"><strong>N</strong>ot e<strong>X</strong>ists，Key 不存在时才设置成功</td></tr><tr><td style="text-align:left"><code>PX 30000</code></td><td style="text-align:left">设置 30000 毫秒（30秒）后自动过期</td></tr><tr><td style="text-align:left"><code>unique_value</code></td><td style="text-align:left">设置为 UUID，用于标识谁持有这把锁</td></tr></tbody></table>
<p>对应的 Java 代码（使用 Jedis）：</p>
<pre tabindex="0" class="codeBlockStandalone_MEMb thin-scrollbar language-java codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#0f172a;--prism-background-color:#f1f5f9"><code class="codeBlockLines_e6Vv"><span class="line"><span style="color:#C18401;--shiki-dark:#E5C07B">String</span><span style="color:#E45649;--shiki-dark:#E06C75"> lockKey </span><span style="color:#383A42;--shiki-dark:#56B6C2">=</span><span style="color:#50A14F;--shiki-dark:#98C379"> "lock:order:12345"</span><span style="color:#383A42;--shiki-dark:#ABB2BF">;</span></span>
<span class="line"><span style="color:#C18401;--shiki-dark:#E5C07B">String</span><span style="color:#E45649;--shiki-dark:#E06C75"> uniqueValue </span><span style="color:#383A42;--shiki-dark:#56B6C2">=</span><span style="color:#E45649;--shiki-dark:#E5C07B"> UUID</span><span style="color:#383A42;--shiki-dark:#ABB2BF">.</span><span style="color:#4078F2;--shiki-dark:#61AFEF">randomUUID</span><span style="color:#383A42;--shiki-dark:#ABB2BF">().</span><span style="color:#4078F2;--shiki-dark:#61AFEF">toString</span><span style="color:#383A42;--shiki-dark:#ABB2BF">();</span></span>
<span class="line"></span>
<span class="line"><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic">// 原子加锁：SET key value NX PX 毫秒</span></span>
<span class="line"><span style="color:#C18401;--shiki-dark:#E5C07B">String</span><span style="color:#E45649;--shiki-dark:#E06C75"> result </span><span style="color:#383A42;--shiki-dark:#56B6C2">=</span><span style="color:#E45649;--shiki-dark:#E5C07B"> jedis</span><span style="color:#383A42;--shiki-dark:#ABB2BF">.</span><span style="color:#4078F2;--shiki-dark:#61AFEF">set</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(lockKey, uniqueValue, </span><span style="color:#E45649;--shiki-dark:#E5C07B">SetParams</span><span style="color:#383A42;--shiki-dark:#ABB2BF">.</span><span style="color:#4078F2;--shiki-dark:#61AFEF">setParams</span><span style="color:#383A42;--shiki-dark:#ABB2BF">().</span><span style="color:#4078F2;--shiki-dark:#61AFEF">nx</span><span style="color:#383A42;--shiki-dark:#ABB2BF">().</span><span style="color:#4078F2;--shiki-dark:#61AFEF">px</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(</span><span style="color:#986801;--shiki-dark:#D19A66">30000</span><span style="color:#383A42;--shiki-dark:#ABB2BF">));</span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">if</span><span style="color:#383A42;--shiki-dark:#E06C75"> (</span><span style="color:#50A14F;--shiki-dark:#98C379">"OK"</span><span style="color:#383A42;--shiki-dark:#ABB2BF">.</span><span style="color:#4078F2;--shiki-dark:#61AFEF">equals</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(result)</span><span style="color:#383A42;--shiki-dark:#E06C75">) {</span></span>
<span class="line"><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic">    // 加锁成功</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#E06C75">}</span></span></code></pre>
<p>到这一步，加锁的原子性问题解决了。但解锁呢？</p>
<h4 class="anchor anchorTargetStickyNavbar_Vzrq" id="版本-21安全解锁lua-脚本保证原子性">版本 2.1：安全解锁（Lua 脚本保证原子性）<a href="https://blog.xiaoxiaoliang.top/en/blog/distributed-lock#%E7%89%88%E6%9C%AC-21%E5%AE%89%E5%85%A8%E8%A7%A3%E9%94%81lua-%E8%84%9A%E6%9C%AC%E4%BF%9D%E8%AF%81%E5%8E%9F%E5%AD%90%E6%80%A7" class="hash-link" aria-label="Direct link to 版本 2.1：安全解锁（Lua 脚本保证原子性）" title="Direct link to 版本 2.1：安全解锁（Lua 脚本保证原子性）" translate="no">​</a></h4>
<p>很多人解锁时直接 <code>DEL lock_key</code>，这会造成一个严重问题——<strong>误删别人的锁</strong>。来看一个时间线：</p>
<div class="language-java codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#0f172a;--prism-background-color:#f1f5f9"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-java codeBlock_bY9V thin-scrollbar" style="color:#0f172a;background-color:#f1f5f9"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#0f172a"><span class="token plain">时间轴：</span><br></div><div class="token-line" style="color:#0f172a"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#0f172a"><span class="token plain">  </span><span class="token number" style="color:#B5CEA8">0</span><span class="token plain">s     </span><span class="token class-name" style="color:#0ea5e9">A</span><span class="token plain"> 加锁成功（过期时间 </span><span class="token number" style="color:#B5CEA8">30</span><span class="token plain">s）</span><br></div><div class="token-line" style="color:#0f172a"><span class="token plain">  </span><span class="token operator" style="color:#475569">|</span><span class="token plain">      </span><span class="token class-name" style="color:#0ea5e9">A</span><span class="token plain"> 开始执行业务</span><span class="token punctuation" style="color:#475569">.</span><span class="token punctuation" style="color:#475569">.</span><span class="token punctuation" style="color:#475569">.</span><span class="token plain"></span><br></div><div class="token-line" style="color:#0f172a"><span class="token plain">  </span><span class="token operator" style="color:#475569">|</span><span class="token plain"></span><br></div><div class="token-line" style="color:#0f172a"><span class="token plain"> </span><span class="token number" style="color:#B5CEA8">30</span><span class="token plain">s     锁自动过期！</span><span class="token class-name" style="color:#0ea5e9">A</span><span class="token plain"> 的锁没了</span><br></div><div class="token-line" style="color:#0f172a"><span class="token plain"> </span><span class="token number" style="color:#B5CEA8">31</span><span class="token plain">s     </span><span class="token class-name" style="color:#0ea5e9">B</span><span class="token plain"> 加锁成功</span><br></div><div class="token-line" style="color:#0f172a"><span class="token plain">  </span><span class="token operator" style="color:#475569">|</span><span class="token plain">      </span><span class="token class-name" style="color:#0ea5e9">B</span><span class="token plain"> 开始执行业务</span><span class="token punctuation" style="color:#475569">.</span><span class="token punctuation" style="color:#475569">.</span><span class="token punctuation" style="color:#475569">.</span><span class="token plain"></span><br></div><div class="token-line" style="color:#0f172a"><span class="token plain">  </span><span class="token operator" style="color:#475569">|</span><span class="token plain"></span><br></div><div class="token-line" style="color:#0f172a"><span class="token plain"> </span><span class="token number" style="color:#B5CEA8">40</span><span class="token plain">s     </span><span class="token class-name" style="color:#0ea5e9">A</span><span class="token plain"> 业务终于跑完了，执行 </span><span class="token constant" style="color:#0f172a">DEL</span><span class="token plain"> lock_key</span><br></div><div class="token-line" style="color:#0f172a"><span class="token plain">         </span><span class="token class-name" style="color:#0ea5e9">A</span><span class="token plain"> 把 </span><span class="token class-name" style="color:#0ea5e9">B</span><span class="token plain"> 的锁删了！</span><br></div><div class="token-line" style="color:#0f172a"><span class="token plain"> </span><span class="token number" style="color:#B5CEA8">41</span><span class="token plain">s     </span><span class="token class-name" style="color:#0ea5e9">C</span><span class="token plain"> 加锁成功</span><span class="token punctuation" style="color:#475569">.</span><span class="token punctuation" style="color:#475569">.</span><span class="token punctuation" style="color:#475569">.</span><span class="token punctuation" style="color:#475569">.</span><span class="token punctuation" style="color:#475569">.</span><span class="token punctuation" style="color:#475569">.</span><span class="token plain"></span><br></div><div class="token-line" style="color:#0f172a"><span class="token plain">         此时 </span><span class="token class-name" style="color:#0ea5e9">B</span><span class="token plain"> 和 </span><span class="token class-name" style="color:#0ea5e9">C</span><span class="token plain"> 同时在执行，互斥性被打破</span><br></div></code></pre></div></div>
<p><strong>解决方案</strong>：Value 设成 UUID，删除前先比对是不是自己的锁，是才删除。但关键是：<strong>判断 + 删除必须是原子操作</strong>，否则在判断之后、删除之前锁过期了仍然会出问题。</p>
<p>所以必须用 <strong>Lua 脚本</strong>来保证原子性：</p>
<pre tabindex="0" class="codeBlockStandalone_MEMb thin-scrollbar language-lua codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#0f172a;--prism-background-color:#f1f5f9"><code class="codeBlockLines_e6Vv"><span class="line"><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic">-- 解锁 Lua 脚本</span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">if</span><span style="color:#E45649;--shiki-dark:#E06C75"> redis</span><span style="color:#383A42;--shiki-dark:#ABB2BF">.</span><span style="color:#4078F2;--shiki-dark:#61AFEF">call</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(</span><span style="color:#50A14F;--shiki-dark:#98C379">'get'</span><span style="color:#383A42;--shiki-dark:#ABB2BF">, </span><span style="color:#E45649;--shiki-dark:#E06C75">KEYS</span><span style="color:#383A42;--shiki-dark:#ABB2BF">[</span><span style="color:#986801;--shiki-dark:#D19A66">1</span><span style="color:#383A42;--shiki-dark:#ABB2BF">]) == </span><span style="color:#E45649;--shiki-dark:#E06C75">ARGV</span><span style="color:#383A42;--shiki-dark:#ABB2BF">[</span><span style="color:#986801;--shiki-dark:#D19A66">1</span><span style="color:#383A42;--shiki-dark:#ABB2BF">] </span><span style="color:#A626A4;--shiki-dark:#C678DD">then</span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">    return</span><span style="color:#E45649;--shiki-dark:#E06C75"> redis</span><span style="color:#383A42;--shiki-dark:#ABB2BF">.</span><span style="color:#4078F2;--shiki-dark:#61AFEF">call</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(</span><span style="color:#50A14F;--shiki-dark:#98C379">'del'</span><span style="color:#383A42;--shiki-dark:#ABB2BF">, </span><span style="color:#E45649;--shiki-dark:#E06C75">KEYS</span><span style="color:#383A42;--shiki-dark:#ABB2BF">[</span><span style="color:#986801;--shiki-dark:#D19A66">1</span><span style="color:#383A42;--shiki-dark:#ABB2BF">])</span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">else</span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">    return</span><span style="color:#986801;--shiki-dark:#D19A66"> 0</span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">end</span></span></code></pre>
<p>Redis 执行 Lua 脚本是原子的（单线程模型），中间不会被其他命令打断。</p>
<pre tabindex="0" class="codeBlockStandalone_MEMb thin-scrollbar language-java codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#0f172a;--prism-background-color:#f1f5f9"><code class="codeBlockLines_e6Vv"><span class="line"><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic">// Java 调用 Lua 脚本解锁</span></span>
<span class="line"><span style="color:#C18401;--shiki-dark:#E5C07B">String</span><span style="color:#E45649;--shiki-dark:#E06C75"> luaScript </span><span style="color:#383A42;--shiki-dark:#56B6C2">=</span><span style="color:#383A42;--shiki-dark:#E06C75"> </span></span>
<span class="line"><span style="color:#50A14F;--shiki-dark:#98C379">    "if redis.call('get', KEYS[1]) == ARGV[1] then "</span><span style="color:#383A42;--shiki-dark:#56B6C2"> +</span></span>
<span class="line"><span style="color:#50A14F;--shiki-dark:#98C379">    "  return redis.call('del', KEYS[1]) "</span><span style="color:#383A42;--shiki-dark:#56B6C2"> +</span></span>
<span class="line"><span style="color:#50A14F;--shiki-dark:#98C379">    "else "</span><span style="color:#383A42;--shiki-dark:#56B6C2"> +</span></span>
<span class="line"><span style="color:#50A14F;--shiki-dark:#98C379">    "  return 0 "</span><span style="color:#383A42;--shiki-dark:#56B6C2"> +</span></span>
<span class="line"><span style="color:#50A14F;--shiki-dark:#98C379">    "end"</span><span style="color:#383A42;--shiki-dark:#ABB2BF">;</span></span>
<span class="line"></span>
<span class="line"><span style="color:#C18401;--shiki-dark:#E5C07B">Object</span><span style="color:#E45649;--shiki-dark:#E06C75"> result </span><span style="color:#383A42;--shiki-dark:#56B6C2">=</span><span style="color:#E45649;--shiki-dark:#E5C07B"> jedis</span><span style="color:#383A42;--shiki-dark:#ABB2BF">.</span><span style="color:#4078F2;--shiki-dark:#61AFEF">eval</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(luaScript, </span></span>
<span class="line"><span style="color:#E45649;--shiki-dark:#E5C07B">    List</span><span style="color:#383A42;--shiki-dark:#ABB2BF">.</span><span style="color:#4078F2;--shiki-dark:#61AFEF">of</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(lockKey),    </span><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic">// KEYS[1]</span></span>
<span class="line"><span style="color:#E45649;--shiki-dark:#E5C07B">    List</span><span style="color:#383A42;--shiki-dark:#ABB2BF">.</span><span style="color:#4078F2;--shiki-dark:#61AFEF">of</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(uniqueValue) </span><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic">// ARGV[1]</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">);</span></span></code></pre>
<h4 class="anchor anchorTargetStickyNavbar_Vzrq" id="还有什么问题">还有什么问题？<a href="https://blog.xiaoxiaoliang.top/en/blog/distributed-lock#%E8%BF%98%E6%9C%89%E4%BB%80%E4%B9%88%E9%97%AE%E9%A2%98" class="hash-link" aria-label="Direct link to 还有什么问题？" title="Direct link to 还有什么问题？" translate="no">​</a></h4>
<p>到版本 2.1，我们已经解决了<strong>原子加锁</strong>和<strong>安全解锁</strong>两个问题。但还有几个靠手动编码很难完美解决的痛点：</p>
<p><strong>1. 锁过期时间不好设</strong></p>
<p>这是一个两难困境：</p>
<ul>
<li class=""><strong>设短了</strong>：业务没执行完锁就过期了，互斥性被打破。</li>
<li class=""><strong>设长了</strong>：万一节点宕机，其他节点需要等待整个过期周期才能拿到锁，影响可用性。</li>
<li class=""><strong>本质问题</strong>：业务执行时间难以精确预估。网络抖动、Full GC 停顿、数据量突增，都可能导致实际执行时间远超预期。</li>
</ul>
<p><strong>2. 不支持可重入</strong></p>
<p>如果同一个线程在持有锁的情况下需要再次加锁（比如嵌套调用），用 <code>SET NX</code> 会失败，因为 Key 已经存在了。</p>
<p><strong>3. 加锁失败没有等待机制</strong></p>
<p><code>SET NX</code> 失败就直接返回了，想要阻塞等待只能自己写 <code>while</code> 循环轮询，既浪费 CPU 又不优雅。</p>
<div class="theme-admonition theme-admonition-info admonition_xJq3 alert alert--info"><div class="admonitionHeading_Gvgb"><span class="admonitionIcon_Rf37"><svg viewBox="0 0 14 16"><path fill-rule="evenodd" d="M7 2.3c3.14 0 5.7 2.56 5.7 5.7s-2.56 5.7-5.7 5.7A5.71 5.71 0 0 1 1.3 8c0-3.14 2.56-5.7 5.7-5.7zM7 1C3.14 1 0 4.14 0 8s3.14 7 7 7 7-3.14 7-7-3.14-7-7-7zm1 3H6v5h2V4zm0 6H6v2h2v-2z"></path></svg></span>小结</div><div class="admonitionContent_BuS1"><p>手动实现 Redis 分布式锁，写到最后你会发现：要处理原子性、要写 Lua 脚本、要考虑续期、要实现可重入、要处理等待队列...... 这些全都是 <strong>Redisson</strong> 帮你做好的事情。</p></div></div>
<hr>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="3-redisson工业级解决方案">3. Redisson：工业级解决方案<a href="https://blog.xiaoxiaoliang.top/en/blog/distributed-lock#3-redisson%E5%B7%A5%E4%B8%9A%E7%BA%A7%E8%A7%A3%E5%86%B3%E6%96%B9%E6%A1%88" class="hash-link" aria-label="Direct link to 3. Redisson：工业级解决方案" title="Direct link to 3. Redisson：工业级解决方案" translate="no">​</a></h2>
<p>Redisson 是一个在 Redis 基础上实现的 Java 驻内存数据网格（In-Memory Data Grid）。它提供的 <code>RLock</code> 几乎完美解决了我们手动实现时遇到的所有痛点：原子性、可重入、自动续期、阻塞等待。</p>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="31-基本用法">3.1 基本用法<a href="https://blog.xiaoxiaoliang.top/en/blog/distributed-lock#31-%E5%9F%BA%E6%9C%AC%E7%94%A8%E6%B3%95" class="hash-link" aria-label="Direct link to 3.1 基本用法" title="Direct link to 3.1 基本用法" translate="no">​</a></h3>
<pre tabindex="0" class="codeBlockStandalone_MEMb thin-scrollbar language-java codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#0f172a;--prism-background-color:#f1f5f9"><code class="codeBlockLines_e6Vv"><span class="line"><span style="color:#C18401;--shiki-dark:#E5C07B">RLock</span><span style="color:#E45649;--shiki-dark:#E06C75"> lock </span><span style="color:#383A42;--shiki-dark:#56B6C2">=</span><span style="color:#E45649;--shiki-dark:#E5C07B"> redisson</span><span style="color:#383A42;--shiki-dark:#ABB2BF">.</span><span style="color:#4078F2;--shiki-dark:#61AFEF">getLock</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(</span><span style="color:#50A14F;--shiki-dark:#98C379">"lock:order:12345"</span><span style="color:#383A42;--shiki-dark:#ABB2BF">);</span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">try</span><span style="color:#383A42;--shiki-dark:#E06C75"> {</span></span>
<span class="line"><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic">    // 加锁（不指定 leaseTime，自动启用看门狗续期）</span></span>
<span class="line"><span style="color:#E45649;--shiki-dark:#E5C07B">    lock</span><span style="color:#383A42;--shiki-dark:#ABB2BF">.</span><span style="color:#4078F2;--shiki-dark:#61AFEF">lock</span><span style="color:#383A42;--shiki-dark:#ABB2BF">();</span></span>
<span class="line"><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic">    // 执行业务逻辑...</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#E06C75">} </span><span style="color:#A626A4;--shiki-dark:#C678DD">finally</span><span style="color:#383A42;--shiki-dark:#E06C75"> {</span></span>
<span class="line"><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic">    // 必须在 finally 中解锁</span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">    if</span><span style="color:#383A42;--shiki-dark:#E06C75"> (</span><span style="color:#E45649;--shiki-dark:#E5C07B">lock</span><span style="color:#383A42;--shiki-dark:#ABB2BF">.</span><span style="color:#4078F2;--shiki-dark:#61AFEF">isHeldByCurrentThread</span><span style="color:#383A42;--shiki-dark:#ABB2BF">()</span><span style="color:#383A42;--shiki-dark:#E06C75">) {</span></span>
<span class="line"><span style="color:#E45649;--shiki-dark:#E5C07B">        lock</span><span style="color:#383A42;--shiki-dark:#ABB2BF">.</span><span style="color:#4078F2;--shiki-dark:#61AFEF">unlock</span><span style="color:#383A42;--shiki-dark:#ABB2BF">();</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#E06C75">    }</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#E06C75">}</span></span></code></pre>
<p>还有一个更常用的 <code>tryLock</code>，支持设置<strong>等待时间</strong>和<strong>锁持有时间</strong>：</p>
<pre tabindex="0" class="codeBlockStandalone_MEMb thin-scrollbar language-java codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#0f172a;--prism-background-color:#f1f5f9"><code class="codeBlockLines_e6Vv"><span class="line"><span style="color:#C18401;--shiki-dark:#E5C07B">RLock</span><span style="color:#E45649;--shiki-dark:#E06C75"> lock </span><span style="color:#383A42;--shiki-dark:#56B6C2">=</span><span style="color:#E45649;--shiki-dark:#E5C07B"> redisson</span><span style="color:#383A42;--shiki-dark:#ABB2BF">.</span><span style="color:#4078F2;--shiki-dark:#61AFEF">getLock</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(</span><span style="color:#50A14F;--shiki-dark:#98C379">"lock:order:12345"</span><span style="color:#383A42;--shiki-dark:#ABB2BF">);</span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">try</span><span style="color:#383A42;--shiki-dark:#E06C75"> {</span></span>
<span class="line"><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic">    // 最多等待 5 秒获取锁，获取成功后 30 秒自动释放</span></span>
<span class="line"><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic">    // 注意：指定了 leaseTime 后，看门狗不会启动！</span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">    boolean</span><span style="color:#E45649;--shiki-dark:#E06C75"> acquired </span><span style="color:#383A42;--shiki-dark:#56B6C2">=</span><span style="color:#E45649;--shiki-dark:#E5C07B"> lock</span><span style="color:#383A42;--shiki-dark:#ABB2BF">.</span><span style="color:#4078F2;--shiki-dark:#61AFEF">tryLock</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(</span><span style="color:#986801;--shiki-dark:#D19A66">5</span><span style="color:#383A42;--shiki-dark:#ABB2BF">, </span><span style="color:#986801;--shiki-dark:#D19A66">30</span><span style="color:#383A42;--shiki-dark:#ABB2BF">, </span><span style="color:#E45649;--shiki-dark:#E5C07B">TimeUnit</span><span style="color:#383A42;--shiki-dark:#ABB2BF">.</span><span style="color:#E45649;--shiki-dark:#E5C07B">SECONDS</span><span style="color:#383A42;--shiki-dark:#ABB2BF">);</span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">    if</span><span style="color:#383A42;--shiki-dark:#E06C75"> (acquired) {</span></span>
<span class="line"><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic">        // 执行业务逻辑...</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#E06C75">    } </span><span style="color:#A626A4;--shiki-dark:#C678DD">else</span><span style="color:#383A42;--shiki-dark:#E06C75"> {</span></span>
<span class="line"><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic">        // 5秒内没拿到锁，走降级或提示</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#E06C75">    }</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#E06C75">} </span><span style="color:#A626A4;--shiki-dark:#C678DD">finally</span><span style="color:#383A42;--shiki-dark:#E06C75"> {</span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">    if</span><span style="color:#383A42;--shiki-dark:#E06C75"> (</span><span style="color:#E45649;--shiki-dark:#E5C07B">lock</span><span style="color:#383A42;--shiki-dark:#ABB2BF">.</span><span style="color:#4078F2;--shiki-dark:#61AFEF">isHeldByCurrentThread</span><span style="color:#383A42;--shiki-dark:#ABB2BF">()</span><span style="color:#383A42;--shiki-dark:#E06C75">) {</span></span>
<span class="line"><span style="color:#E45649;--shiki-dark:#E5C07B">        lock</span><span style="color:#383A42;--shiki-dark:#ABB2BF">.</span><span style="color:#4078F2;--shiki-dark:#61AFEF">unlock</span><span style="color:#383A42;--shiki-dark:#ABB2BF">();</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#E06C75">    }</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#E06C75">}</span></span></code></pre>
<div class="theme-admonition theme-admonition-warning admonition_xJq3 alert alert--warning"><div class="admonitionHeading_Gvgb"><span class="admonitionIcon_Rf37"><svg viewBox="0 0 16 16"><path fill-rule="evenodd" d="M8.893 1.5c-.183-.31-.52-.5-.887-.5s-.703.19-.886.5L.138 13.499a.98.98 0 0 0 0 1.001c.193.31.53.501.886.501h13.964c.367 0 .704-.19.877-.5a1.03 1.03 0 0 0 .01-1.002L8.893 1.5zm.133 11.497H6.987v-2.003h2.039v2.003zm0-3.004H6.987V5.987h2.039v4.006z"></path></svg></span>lock() vs tryLock() 的重要区别</div><div class="admonitionContent_BuS1"><ul>
<li class=""><code>lock()</code>：阻塞等待，不指定 leaseTime，<strong>看门狗自动续期</strong>，推荐用于业务执行时间不可预估的场景。</li>
<li class=""><code>tryLock(waitTime, leaseTime, unit)</code>：指定了 leaseTime，<strong>看门狗不会启动</strong>，锁到期后直接释放。如果你的业务可能超过 leaseTime 但没有看门狗续期，互斥性就被打破了。</li>
<li class="">最佳实践：如果不确定业务耗时，用 <code>tryLock(waitTime, -1, unit)</code>（leaseTime 传 -1），这样既有超时等待，又有看门狗续期。</li>
</ul></div></div>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="32-核心原理揭秘">3.2 核心原理揭秘<a href="https://blog.xiaoxiaoliang.top/en/blog/distributed-lock#32-%E6%A0%B8%E5%BF%83%E5%8E%9F%E7%90%86%E6%8F%AD%E7%A7%98" class="hash-link" aria-label="Direct link to 3.2 核心原理揭秘" title="Direct link to 3.2 核心原理揭秘" translate="no">​</a></h3>
<p>Redisson 的强大在于两个核心设计：<strong>Lua 脚本保证原子性</strong> + <strong>Watchdog（看门狗）自动续期</strong>。</p>
<h4 class="anchor anchorTargetStickyNavbar_Vzrq" id="1-加锁机制">(1) 加锁机制<a href="https://blog.xiaoxiaoliang.top/en/blog/distributed-lock#1-%E5%8A%A0%E9%94%81%E6%9C%BA%E5%88%B6" class="hash-link" aria-label="Direct link to (1) 加锁机制" title="Direct link to (1) 加锁机制" translate="no">​</a></h4>
<p>Redisson 加锁时执行的 Lua 脚本（简化版）：</p>
<pre tabindex="0" class="codeBlockStandalone_MEMb thin-scrollbar language-lua codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#0f172a;--prism-background-color:#f1f5f9"><code class="codeBlockLines_e6Vv"><span class="line"><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic">-- KEYS[1] = 锁的名称</span></span>
<span class="line"><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic">-- ARGV[1] = 过期时间（默认 30000 ms）</span></span>
<span class="line"><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic">-- ARGV[2] = 客户端唯一标识（UUID:threadId）</span></span>
<span class="line"></span>
<span class="line"><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic">-- 1. 锁不存在 -&gt; 直接加锁</span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">if</span><span style="color:#383A42;--shiki-dark:#ABB2BF"> (</span><span style="color:#E45649;--shiki-dark:#E06C75">redis</span><span style="color:#383A42;--shiki-dark:#ABB2BF">.</span><span style="color:#4078F2;--shiki-dark:#61AFEF">call</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(</span><span style="color:#50A14F;--shiki-dark:#98C379">'exists'</span><span style="color:#383A42;--shiki-dark:#ABB2BF">, </span><span style="color:#E45649;--shiki-dark:#E06C75">KEYS</span><span style="color:#383A42;--shiki-dark:#ABB2BF">[</span><span style="color:#986801;--shiki-dark:#D19A66">1</span><span style="color:#383A42;--shiki-dark:#ABB2BF">]) == </span><span style="color:#986801;--shiki-dark:#D19A66">0</span><span style="color:#383A42;--shiki-dark:#ABB2BF">) </span><span style="color:#A626A4;--shiki-dark:#C678DD">then</span></span>
<span class="line"><span style="color:#E45649;--shiki-dark:#E06C75">    redis</span><span style="color:#383A42;--shiki-dark:#ABB2BF">.</span><span style="color:#4078F2;--shiki-dark:#61AFEF">call</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(</span><span style="color:#50A14F;--shiki-dark:#98C379">'hincrby'</span><span style="color:#383A42;--shiki-dark:#ABB2BF">, </span><span style="color:#E45649;--shiki-dark:#E06C75">KEYS</span><span style="color:#383A42;--shiki-dark:#ABB2BF">[</span><span style="color:#986801;--shiki-dark:#D19A66">1</span><span style="color:#383A42;--shiki-dark:#ABB2BF">], </span><span style="color:#E45649;--shiki-dark:#E06C75">ARGV</span><span style="color:#383A42;--shiki-dark:#ABB2BF">[</span><span style="color:#986801;--shiki-dark:#D19A66">2</span><span style="color:#383A42;--shiki-dark:#ABB2BF">], </span><span style="color:#986801;--shiki-dark:#D19A66">1</span><span style="color:#383A42;--shiki-dark:#ABB2BF">)    </span><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic">-- 设置持有者，重入次数=1</span></span>
<span class="line"><span style="color:#E45649;--shiki-dark:#E06C75">    redis</span><span style="color:#383A42;--shiki-dark:#ABB2BF">.</span><span style="color:#4078F2;--shiki-dark:#61AFEF">call</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(</span><span style="color:#50A14F;--shiki-dark:#98C379">'pexpire'</span><span style="color:#383A42;--shiki-dark:#ABB2BF">, </span><span style="color:#E45649;--shiki-dark:#E06C75">KEYS</span><span style="color:#383A42;--shiki-dark:#ABB2BF">[</span><span style="color:#986801;--shiki-dark:#D19A66">1</span><span style="color:#383A42;--shiki-dark:#ABB2BF">], </span><span style="color:#E45649;--shiki-dark:#E06C75">ARGV</span><span style="color:#383A42;--shiki-dark:#ABB2BF">[</span><span style="color:#986801;--shiki-dark:#D19A66">1</span><span style="color:#383A42;--shiki-dark:#ABB2BF">])        </span><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic">-- 设置过期时间</span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">    return</span><span style="color:#986801;--shiki-dark:#D19A66"> nil</span><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic">                                      -- 返回nil表示加锁成功</span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">end</span></span>
<span class="line"></span>
<span class="line"><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic">-- 2. 锁存在，且是自己持有 -&gt; 重入</span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">if</span><span style="color:#383A42;--shiki-dark:#ABB2BF"> (</span><span style="color:#E45649;--shiki-dark:#E06C75">redis</span><span style="color:#383A42;--shiki-dark:#ABB2BF">.</span><span style="color:#4078F2;--shiki-dark:#61AFEF">call</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(</span><span style="color:#50A14F;--shiki-dark:#98C379">'hexists'</span><span style="color:#383A42;--shiki-dark:#ABB2BF">, </span><span style="color:#E45649;--shiki-dark:#E06C75">KEYS</span><span style="color:#383A42;--shiki-dark:#ABB2BF">[</span><span style="color:#986801;--shiki-dark:#D19A66">1</span><span style="color:#383A42;--shiki-dark:#ABB2BF">], </span><span style="color:#E45649;--shiki-dark:#E06C75">ARGV</span><span style="color:#383A42;--shiki-dark:#ABB2BF">[</span><span style="color:#986801;--shiki-dark:#D19A66">2</span><span style="color:#383A42;--shiki-dark:#ABB2BF">]) == </span><span style="color:#986801;--shiki-dark:#D19A66">1</span><span style="color:#383A42;--shiki-dark:#ABB2BF">) </span><span style="color:#A626A4;--shiki-dark:#C678DD">then</span></span>
<span class="line"><span style="color:#E45649;--shiki-dark:#E06C75">    redis</span><span style="color:#383A42;--shiki-dark:#ABB2BF">.</span><span style="color:#4078F2;--shiki-dark:#61AFEF">call</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(</span><span style="color:#50A14F;--shiki-dark:#98C379">'hincrby'</span><span style="color:#383A42;--shiki-dark:#ABB2BF">, </span><span style="color:#E45649;--shiki-dark:#E06C75">KEYS</span><span style="color:#383A42;--shiki-dark:#ABB2BF">[</span><span style="color:#986801;--shiki-dark:#D19A66">1</span><span style="color:#383A42;--shiki-dark:#ABB2BF">], </span><span style="color:#E45649;--shiki-dark:#E06C75">ARGV</span><span style="color:#383A42;--shiki-dark:#ABB2BF">[</span><span style="color:#986801;--shiki-dark:#D19A66">2</span><span style="color:#383A42;--shiki-dark:#ABB2BF">], </span><span style="color:#986801;--shiki-dark:#D19A66">1</span><span style="color:#383A42;--shiki-dark:#ABB2BF">)    </span><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic">-- 重入次数 +1</span></span>
<span class="line"><span style="color:#E45649;--shiki-dark:#E06C75">    redis</span><span style="color:#383A42;--shiki-dark:#ABB2BF">.</span><span style="color:#4078F2;--shiki-dark:#61AFEF">call</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(</span><span style="color:#50A14F;--shiki-dark:#98C379">'pexpire'</span><span style="color:#383A42;--shiki-dark:#ABB2BF">, </span><span style="color:#E45649;--shiki-dark:#E06C75">KEYS</span><span style="color:#383A42;--shiki-dark:#ABB2BF">[</span><span style="color:#986801;--shiki-dark:#D19A66">1</span><span style="color:#383A42;--shiki-dark:#ABB2BF">], </span><span style="color:#E45649;--shiki-dark:#E06C75">ARGV</span><span style="color:#383A42;--shiki-dark:#ABB2BF">[</span><span style="color:#986801;--shiki-dark:#D19A66">1</span><span style="color:#383A42;--shiki-dark:#ABB2BF">])        </span><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic">-- 重置过期时间</span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">    return</span><span style="color:#986801;--shiki-dark:#D19A66"> nil</span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">end</span></span>
<span class="line"></span>
<span class="line"><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic">-- 3. 锁被别人持有 -&gt; 返回锁的剩余TTL（加锁失败）</span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">return</span><span style="color:#E45649;--shiki-dark:#E06C75"> redis</span><span style="color:#383A42;--shiki-dark:#ABB2BF">.</span><span style="color:#4078F2;--shiki-dark:#61AFEF">call</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(</span><span style="color:#50A14F;--shiki-dark:#98C379">'pttl'</span><span style="color:#383A42;--shiki-dark:#ABB2BF">, </span><span style="color:#E45649;--shiki-dark:#E06C75">KEYS</span><span style="color:#383A42;--shiki-dark:#ABB2BF">[</span><span style="color:#986801;--shiki-dark:#D19A66">1</span><span style="color:#383A42;--shiki-dark:#ABB2BF">])</span></span></code></pre>
<p>注意到没有？Redisson 用的是 <strong>Hash 结构</strong>（<code>HINCRBY</code>），而不是简单的 String。Key 是锁名，Field 是 <code>UUID:threadId</code>，Value 是<strong>重入次数</strong>。这就是可重入锁的实现原理——同一个线程再次加锁时，只需要把计数 +1 就行。</p>
<p>在 Redis 中存储的数据长这样：</p>
<div class="language-java codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#0f172a;--prism-background-color:#f1f5f9"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-java codeBlock_bY9V thin-scrollbar" style="color:#0f172a;background-color:#f1f5f9"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#0f172a"><span class="token class-name" style="color:#0ea5e9">Key</span><span class="token operator" style="color:#475569">:</span><span class="token plain"> </span><span class="token string" style="color:#64748b">"lock:order:12345"</span><span class="token plain"> </span><span class="token punctuation" style="color:#475569">(</span><span class="token class-name" style="color:#0ea5e9">Hash</span><span class="token plain">类型</span><span class="token punctuation" style="color:#475569">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#0f172a"><span class="token plain"></span><span class="token operator" style="color:#475569">+</span><span class="token operator" style="color:#475569">--</span><span class="token operator" style="color:#475569">--</span><span class="token operator" style="color:#475569">--</span><span class="token operator" style="color:#475569">--</span><span class="token operator" style="color:#475569">--</span><span class="token operator" style="color:#475569">--</span><span class="token operator" style="color:#475569">--</span><span class="token operator" style="color:#475569">--</span><span class="token operator" style="color:#475569">--</span><span class="token operator" style="color:#475569">--</span><span class="token operator" style="color:#475569">--</span><span class="token operator" style="color:#475569">--</span><span class="token operator" style="color:#475569">--</span><span class="token operator" style="color:#475569">--</span><span class="token operator" style="color:#475569">--</span><span class="token operator" style="color:#475569">--</span><span class="token operator" style="color:#475569">--</span><span class="token operator" style="color:#475569">-</span><span class="token operator" style="color:#475569">+</span><span class="token operator" style="color:#475569">--</span><span class="token operator" style="color:#475569">--</span><span class="token operator" style="color:#475569">--</span><span class="token operator" style="color:#475569">-</span><span class="token operator" style="color:#475569">+</span><span class="token plain"></span><br></div><div class="token-line" style="color:#0f172a"><span class="token plain"></span><span class="token operator" style="color:#475569">|</span><span class="token plain"> </span><span class="token class-name" style="color:#0ea5e9">Field</span><span class="token plain"> </span><span class="token punctuation" style="color:#475569">(</span><span class="token constant" style="color:#0f172a">UUID</span><span class="token operator" style="color:#475569">:</span><span class="token plain">threadId</span><span class="token punctuation" style="color:#475569">)</span><span class="token plain">             </span><span class="token operator" style="color:#475569">|</span><span class="token plain"> </span><span class="token class-name" style="color:#0ea5e9">Value</span><span class="token plain"> </span><span class="token operator" style="color:#475569">|</span><span class="token plain"></span><br></div><div class="token-line" style="color:#0f172a"><span class="token plain"></span><span class="token operator" style="color:#475569">+</span><span class="token operator" style="color:#475569">--</span><span class="token operator" style="color:#475569">--</span><span class="token operator" style="color:#475569">--</span><span class="token operator" style="color:#475569">--</span><span class="token operator" style="color:#475569">--</span><span class="token operator" style="color:#475569">--</span><span class="token operator" style="color:#475569">--</span><span class="token operator" style="color:#475569">--</span><span class="token operator" style="color:#475569">--</span><span class="token operator" style="color:#475569">--</span><span class="token operator" style="color:#475569">--</span><span class="token operator" style="color:#475569">--</span><span class="token operator" style="color:#475569">--</span><span class="token operator" style="color:#475569">--</span><span class="token operator" style="color:#475569">--</span><span class="token operator" style="color:#475569">--</span><span class="token operator" style="color:#475569">--</span><span class="token operator" style="color:#475569">-</span><span class="token operator" style="color:#475569">+</span><span class="token operator" style="color:#475569">--</span><span class="token operator" style="color:#475569">--</span><span class="token operator" style="color:#475569">--</span><span class="token operator" style="color:#475569">-</span><span class="token operator" style="color:#475569">+</span><span class="token plain"></span><br></div><div class="token-line" style="color:#0f172a"><span class="token plain"></span><span class="token operator" style="color:#475569">|</span><span class="token plain"> </span><span class="token number" style="color:#B5CEA8">6f</span><span class="token number" style="color:#B5CEA8">3e8</span><span class="token plain">a</span><span class="token punctuation" style="color:#475569">.</span><span class="token punctuation" style="color:#475569">.</span><span class="token punctuation" style="color:#475569">.</span><span class="token punctuation" style="color:#475569">:</span><span class="token plain">thread</span><span class="token operator" style="color:#475569">-</span><span class="token number" style="color:#B5CEA8">1</span><span class="token plain">                </span><span class="token operator" style="color:#475569">|</span><span class="token plain">   </span><span class="token number" style="color:#B5CEA8">2</span><span class="token plain">   </span><span class="token operator" style="color:#475569">|</span><span class="token plain">  </span><span class="token operator" style="color:#475569">&lt;</span><span class="token operator" style="color:#475569">--</span><span class="token plain"> 重入了</span><span class="token number" style="color:#B5CEA8">2</span><span class="token plain">次</span><br></div><div class="token-line" style="color:#0f172a"><span class="token plain"></span><span class="token operator" style="color:#475569">+</span><span class="token operator" style="color:#475569">--</span><span class="token operator" style="color:#475569">--</span><span class="token operator" style="color:#475569">--</span><span class="token operator" style="color:#475569">--</span><span class="token operator" style="color:#475569">--</span><span class="token operator" style="color:#475569">--</span><span class="token operator" style="color:#475569">--</span><span class="token operator" style="color:#475569">--</span><span class="token operator" style="color:#475569">--</span><span class="token operator" style="color:#475569">--</span><span class="token operator" style="color:#475569">--</span><span class="token operator" style="color:#475569">--</span><span class="token operator" style="color:#475569">--</span><span class="token operator" style="color:#475569">--</span><span class="token operator" style="color:#475569">--</span><span class="token operator" style="color:#475569">--</span><span class="token operator" style="color:#475569">--</span><span class="token operator" style="color:#475569">-</span><span class="token operator" style="color:#475569">+</span><span class="token operator" style="color:#475569">--</span><span class="token operator" style="color:#475569">--</span><span class="token operator" style="color:#475569">--</span><span class="token operator" style="color:#475569">-</span><span class="token operator" style="color:#475569">+</span><span class="token plain"></span><br></div><div class="token-line" style="color:#0f172a"><span class="token plain"></span><span class="token constant" style="color:#0f172a">TTL</span><span class="token operator" style="color:#475569">:</span><span class="token plain"> </span><span class="token number" style="color:#B5CEA8">30000</span><span class="token plain">ms</span><br></div></code></pre></div></div>
<h4 class="anchor anchorTargetStickyNavbar_Vzrq" id="2-watchdog-看门狗自动续期">(2) Watchdog 看门狗（自动续期）<a href="https://blog.xiaoxiaoliang.top/en/blog/distributed-lock#2-watchdog-%E7%9C%8B%E9%97%A8%E7%8B%97%E8%87%AA%E5%8A%A8%E7%BB%AD%E6%9C%9F" class="hash-link" aria-label="Direct link to (2) Watchdog 看门狗（自动续期）" title="Direct link to (2) Watchdog 看门狗（自动续期）" translate="no">​</a></h4>
<p>这是 Redisson 最精妙的设计，完美解决了「锁过期时间不好设」的难题。</p>
<p><strong>触发条件</strong>：只有在<strong>不指定 leaseTime</strong> 时（即调用 <code>lock()</code> 或 <code>tryLock(waitTime, -1, unit)</code>），看门狗才会启动。</p>
<p><strong>运作机制</strong>：</p>
<div class="language-java codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#0f172a;--prism-background-color:#f1f5f9"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-java codeBlock_bY9V thin-scrollbar" style="color:#0f172a;background-color:#f1f5f9"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#0f172a"><span class="token plain">加锁成功</span><br></div><div class="token-line" style="color:#0f172a"><span class="token plain">  </span><span class="token operator" style="color:#475569">|</span><span class="token plain"></span><br></div><div class="token-line" style="color:#0f172a"><span class="token plain">  </span><span class="token operator" style="color:#475569">+</span><span class="token operator" style="color:#475569">--</span><span class="token plain"> 默认过期时间 </span><span class="token operator" style="color:#475569">=</span><span class="token plain"> </span><span class="token number" style="color:#B5CEA8">30</span><span class="token plain">秒（lockWatchdogTimeout）</span><br></div><div class="token-line" style="color:#0f172a"><span class="token plain">  </span><span class="token operator" style="color:#475569">|</span><span class="token plain"></span><br></div><div class="token-line" style="color:#0f172a"><span class="token plain">  </span><span class="token operator" style="color:#475569">+</span><span class="token operator" style="color:#475569">--</span><span class="token plain"> 启动后台定时任务，每 </span><span class="token number" style="color:#B5CEA8">10</span><span class="token plain">秒（</span><span class="token number" style="color:#B5CEA8">30</span><span class="token plain">s </span><span class="token operator" style="color:#475569">/</span><span class="token plain"> </span><span class="token number" style="color:#B5CEA8">3</span><span class="token plain">）触发一次</span><br></div><div class="token-line" style="color:#0f172a"><span class="token plain">  </span><span class="token operator" style="color:#475569">|</span><span class="token plain">     </span><span class="token operator" style="color:#475569">|</span><span class="token plain"></span><br></div><div class="token-line" style="color:#0f172a"><span class="token plain">  </span><span class="token operator" style="color:#475569">|</span><span class="token plain">     </span><span class="token operator" style="color:#475569">+</span><span class="token operator" style="color:#475569">--</span><span class="token plain"> 检查当前线程是否还持有锁？</span><br></div><div class="token-line" style="color:#0f172a"><span class="token plain">  </span><span class="token operator" style="color:#475569">|</span><span class="token plain">     </span><span class="token operator" style="color:#475569">|</span><span class="token plain">     </span><span class="token operator" style="color:#475569">+</span><span class="token operator" style="color:#475569">--</span><span class="token plain"> 是 </span><span class="token operator" style="color:#475569">-&gt;</span><span class="token plain"> 续期至 </span><span class="token number" style="color:#B5CEA8">30</span><span class="token plain">秒，继续定时</span><br></div><div class="token-line" style="color:#0f172a"><span class="token plain">  </span><span class="token operator" style="color:#475569">|</span><span class="token plain">     </span><span class="token operator" style="color:#475569">|</span><span class="token plain">     </span><span class="token operator" style="color:#475569">+</span><span class="token operator" style="color:#475569">--</span><span class="token plain"> 否 </span><span class="token operator" style="color:#475569">-&gt;</span><span class="token plain"> 停止续期</span><br></div><div class="token-line" style="color:#0f172a"><span class="token plain">  </span><span class="token operator" style="color:#475569">|</span><span class="token plain">     </span><span class="token operator" style="color:#475569">|</span><span class="token plain"></span><br></div><div class="token-line" style="color:#0f172a"><span class="token plain">  </span><span class="token operator" style="color:#475569">|</span><span class="token plain">    </span><span class="token punctuation" style="color:#475569">.</span><span class="token punctuation" style="color:#475569">.</span><span class="token punctuation" style="color:#475569">.</span><span class="token plain"> </span><span class="token punctuation" style="color:#475569">(</span><span class="token plain">每</span><span class="token number" style="color:#B5CEA8">10</span><span class="token plain">秒重复</span><span class="token punctuation" style="color:#475569">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#0f172a"><span class="token plain">  </span><span class="token operator" style="color:#475569">|</span><span class="token plain"></span><br></div><div class="token-line" style="color:#0f172a"><span class="token plain">  </span><span class="token operator" style="color:#475569">+</span><span class="token operator" style="color:#475569">--</span><span class="token plain"> </span><span class="token punctuation" style="color:#475569">[</span><span class="token plain">正常情况</span><span class="token punctuation" style="color:#475569">]</span><span class="token plain"> 业务跑完 </span><span class="token operator" style="color:#475569">-&gt;</span><span class="token plain"> 手动 </span><span class="token function" style="color:#0e7490">unlock</span><span class="token punctuation" style="color:#475569">(</span><span class="token punctuation" style="color:#475569">)</span><span class="token plain"> </span><span class="token operator" style="color:#475569">-&gt;</span><span class="token plain"> 取消看门狗任务</span><br></div><div class="token-line" style="color:#0f172a"><span class="token plain">  </span><span class="token operator" style="color:#475569">|</span><span class="token plain"></span><br></div><div class="token-line" style="color:#0f172a"><span class="token plain">  </span><span class="token operator" style="color:#475569">+</span><span class="token operator" style="color:#475569">--</span><span class="token plain"> </span><span class="token punctuation" style="color:#475569">[</span><span class="token plain">异常情况</span><span class="token punctuation" style="color:#475569">]</span><span class="token plain"> 服务宕机 </span><span class="token operator" style="color:#475569">-&gt;</span><span class="token plain"> 看门狗随进程一起消失</span><br></div><div class="token-line" style="color:#0f172a"><span class="token plain">                          </span><span class="token operator" style="color:#475569">-&gt;</span><span class="token plain"> 没人续期 </span><span class="token operator" style="color:#475569">-&gt;</span><span class="token plain"> </span><span class="token number" style="color:#B5CEA8">30</span><span class="token plain">秒后锁自动释放</span><br></div></code></pre></div></div>
<p><strong>这个设计的巧妙之处</strong>：</p>
<ol>
<li class=""><strong>业务没跑完</strong>：看门狗持续续期，锁不会意外过期。</li>
<li class=""><strong>业务跑完了</strong>：手动 <code>unlock()</code>，看门狗任务被取消，锁被主动释放。</li>
<li class=""><strong>机器宕机了</strong>：看门狗线程也没了，没人续期，30 秒后 Redis 自动删除 Key，锁释放。<strong>完美兜底！</strong></li>
</ol>
<h4 class="anchor anchorTargetStickyNavbar_Vzrq" id="3-解锁机制">(3) 解锁机制<a href="https://blog.xiaoxiaoliang.top/en/blog/distributed-lock#3-%E8%A7%A3%E9%94%81%E6%9C%BA%E5%88%B6" class="hash-link" aria-label="Direct link to (3) 解锁机制" title="Direct link to (3) 解锁机制" translate="no">​</a></h4>
<p>解锁同样用 Lua 脚本保证原子性（简化版）：</p>
<pre tabindex="0" class="codeBlockStandalone_MEMb thin-scrollbar language-lua codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#0f172a;--prism-background-color:#f1f5f9"><code class="codeBlockLines_e6Vv"><span class="line"><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic">-- KEYS[1] = 锁名称</span></span>
<span class="line"><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic">-- KEYS[2] = 发布/订阅的 channel 名称</span></span>
<span class="line"><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic">-- ARGV[1] = 解锁消息（固定值 0）</span></span>
<span class="line"><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic">-- ARGV[2] = 过期时间</span></span>
<span class="line"><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic">-- ARGV[3] = 客户端标识（UUID:threadId）</span></span>
<span class="line"></span>
<span class="line"><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic">-- 1. 锁不是自己的 -&gt; 返回nil（防止误删）</span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">if</span><span style="color:#383A42;--shiki-dark:#ABB2BF"> (</span><span style="color:#E45649;--shiki-dark:#E06C75">redis</span><span style="color:#383A42;--shiki-dark:#ABB2BF">.</span><span style="color:#4078F2;--shiki-dark:#61AFEF">call</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(</span><span style="color:#50A14F;--shiki-dark:#98C379">'hexists'</span><span style="color:#383A42;--shiki-dark:#ABB2BF">, </span><span style="color:#E45649;--shiki-dark:#E06C75">KEYS</span><span style="color:#383A42;--shiki-dark:#ABB2BF">[</span><span style="color:#986801;--shiki-dark:#D19A66">1</span><span style="color:#383A42;--shiki-dark:#ABB2BF">], </span><span style="color:#E45649;--shiki-dark:#E06C75">ARGV</span><span style="color:#383A42;--shiki-dark:#ABB2BF">[</span><span style="color:#986801;--shiki-dark:#D19A66">3</span><span style="color:#383A42;--shiki-dark:#ABB2BF">]) == </span><span style="color:#986801;--shiki-dark:#D19A66">0</span><span style="color:#383A42;--shiki-dark:#ABB2BF">) </span><span style="color:#A626A4;--shiki-dark:#C678DD">then</span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">    return</span><span style="color:#986801;--shiki-dark:#D19A66"> nil</span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">end</span></span>
<span class="line"></span>
<span class="line"><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic">-- 2. 重入次数 -1</span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">local</span><span style="color:#E45649;--shiki-dark:#E06C75"> counter</span><span style="color:#383A42;--shiki-dark:#ABB2BF"> = </span><span style="color:#E45649;--shiki-dark:#E06C75">redis</span><span style="color:#383A42;--shiki-dark:#ABB2BF">.</span><span style="color:#4078F2;--shiki-dark:#61AFEF">call</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(</span><span style="color:#50A14F;--shiki-dark:#98C379">'hincrby'</span><span style="color:#383A42;--shiki-dark:#ABB2BF">, </span><span style="color:#E45649;--shiki-dark:#E06C75">KEYS</span><span style="color:#383A42;--shiki-dark:#ABB2BF">[</span><span style="color:#986801;--shiki-dark:#D19A66">1</span><span style="color:#383A42;--shiki-dark:#ABB2BF">], </span><span style="color:#E45649;--shiki-dark:#E06C75">ARGV</span><span style="color:#383A42;--shiki-dark:#ABB2BF">[</span><span style="color:#986801;--shiki-dark:#D19A66">3</span><span style="color:#383A42;--shiki-dark:#ABB2BF">], -</span><span style="color:#986801;--shiki-dark:#D19A66">1</span><span style="color:#383A42;--shiki-dark:#ABB2BF">)</span></span>
<span class="line"></span>
<span class="line"><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic">-- 3. 还有重入层数 -&gt; 不能真正释放，只重置过期时间</span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">if</span><span style="color:#383A42;--shiki-dark:#ABB2BF"> (</span><span style="color:#E45649;--shiki-dark:#E06C75">counter</span><span style="color:#383A42;--shiki-dark:#ABB2BF"> &gt; </span><span style="color:#986801;--shiki-dark:#D19A66">0</span><span style="color:#383A42;--shiki-dark:#ABB2BF">) </span><span style="color:#A626A4;--shiki-dark:#C678DD">then</span></span>
<span class="line"><span style="color:#E45649;--shiki-dark:#E06C75">    redis</span><span style="color:#383A42;--shiki-dark:#ABB2BF">.</span><span style="color:#4078F2;--shiki-dark:#61AFEF">call</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(</span><span style="color:#50A14F;--shiki-dark:#98C379">'pexpire'</span><span style="color:#383A42;--shiki-dark:#ABB2BF">, </span><span style="color:#E45649;--shiki-dark:#E06C75">KEYS</span><span style="color:#383A42;--shiki-dark:#ABB2BF">[</span><span style="color:#986801;--shiki-dark:#D19A66">1</span><span style="color:#383A42;--shiki-dark:#ABB2BF">], </span><span style="color:#E45649;--shiki-dark:#E06C75">ARGV</span><span style="color:#383A42;--shiki-dark:#ABB2BF">[</span><span style="color:#986801;--shiki-dark:#D19A66">2</span><span style="color:#383A42;--shiki-dark:#ABB2BF">])</span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">    return</span><span style="color:#986801;--shiki-dark:#D19A66"> 0</span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">end</span></span>
<span class="line"></span>
<span class="line"><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic">-- 4. 重入次数归零 -&gt; 真正释放锁，并发布通知</span></span>
<span class="line"><span style="color:#E45649;--shiki-dark:#E06C75">redis</span><span style="color:#383A42;--shiki-dark:#ABB2BF">.</span><span style="color:#4078F2;--shiki-dark:#61AFEF">call</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(</span><span style="color:#50A14F;--shiki-dark:#98C379">'del'</span><span style="color:#383A42;--shiki-dark:#ABB2BF">, </span><span style="color:#E45649;--shiki-dark:#E06C75">KEYS</span><span style="color:#383A42;--shiki-dark:#ABB2BF">[</span><span style="color:#986801;--shiki-dark:#D19A66">1</span><span style="color:#383A42;--shiki-dark:#ABB2BF">])</span></span>
<span class="line"><span style="color:#E45649;--shiki-dark:#E06C75">redis</span><span style="color:#383A42;--shiki-dark:#ABB2BF">.</span><span style="color:#4078F2;--shiki-dark:#61AFEF">call</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(</span><span style="color:#50A14F;--shiki-dark:#98C379">'publish'</span><span style="color:#383A42;--shiki-dark:#ABB2BF">, </span><span style="color:#E45649;--shiki-dark:#E06C75">KEYS</span><span style="color:#383A42;--shiki-dark:#ABB2BF">[</span><span style="color:#986801;--shiki-dark:#D19A66">2</span><span style="color:#383A42;--shiki-dark:#ABB2BF">], </span><span style="color:#E45649;--shiki-dark:#E06C75">ARGV</span><span style="color:#383A42;--shiki-dark:#ABB2BF">[</span><span style="color:#986801;--shiki-dark:#D19A66">1</span><span style="color:#383A42;--shiki-dark:#ABB2BF">])  </span><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic">-- 通知等待的客户端</span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">return</span><span style="color:#986801;--shiki-dark:#D19A66"> 1</span></span></code></pre>
<p>注意最后一步的 <code>PUBLISH</code>——Redisson 用 Redis 的**发布/订阅（Pub/Sub）**机制来通知其他等待锁的客户端。当锁被释放时，等待方会收到消息并尝试重新加锁，比傻轮询高效得多。</p>
<h4 class="anchor anchorTargetStickyNavbar_Vzrq" id="4-等待锁的过程">(4) 等待锁的过程<a href="https://blog.xiaoxiaoliang.top/en/blog/distributed-lock#4-%E7%AD%89%E5%BE%85%E9%94%81%E7%9A%84%E8%BF%87%E7%A8%8B" class="hash-link" aria-label="Direct link to (4) 等待锁的过程" title="Direct link to (4) 等待锁的过程" translate="no">​</a></h4>
<p>当 <code>tryLock</code> 加锁失败时，Redisson 并不是简单地 <code>while(true)</code> 轮询，而是：</p>
<ol>
<li class=""><strong>订阅</strong>锁释放的 Channel（<code>redisson_lock__channel:{锁名}</code>）。</li>
<li class="">进入<strong>阻塞等待</strong>（基于 Semaphore），不消耗 CPU。</li>
<li class="">收到锁释放的<strong>通知</strong>后，再次尝试加锁。</li>
<li class="">如果超过 <code>waitTime</code> 仍未获取到，返回 <code>false</code>。</li>
</ol>
<p>这种「订阅 + 信号量」的方式，比自旋轮询高效很多，大幅减少了无效的 Redis 请求。</p>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="33-redisson-的注意事项">3.3 Redisson 的注意事项<a href="https://blog.xiaoxiaoliang.top/en/blog/distributed-lock#33-redisson-%E7%9A%84%E6%B3%A8%E6%84%8F%E4%BA%8B%E9%A1%B9" class="hash-link" aria-label="Direct link to 3.3 Redisson 的注意事项" title="Direct link to 3.3 Redisson 的注意事项" translate="no">​</a></h3>
<p>虽然 Redisson 已经足够强大，但仍有几个需要注意的地方：</p>
<h4 class="anchor anchorTargetStickyNavbar_Vzrq" id="-主从切换导致锁丢失">① 主从切换导致锁丢失<a href="https://blog.xiaoxiaoliang.top/en/blog/distributed-lock#-%E4%B8%BB%E4%BB%8E%E5%88%87%E6%8D%A2%E5%AF%BC%E8%87%B4%E9%94%81%E4%B8%A2%E5%A4%B1" class="hash-link" aria-label="Direct link to ① 主从切换导致锁丢失" title="Direct link to ① 主从切换导致锁丢失" translate="no">​</a></h4>
<p>Redis 主从复制是<strong>异步</strong>的。考虑这个场景：</p>
<div class="language-java codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#0f172a;--prism-background-color:#f1f5f9"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-java codeBlock_bY9V thin-scrollbar" style="color:#0f172a;background-color:#f1f5f9"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#0f172a"><span class="token number" style="color:#B5CEA8">1.</span><span class="token plain"> </span><span class="token class-name" style="color:#0ea5e9">Client</span><span class="token plain"> </span><span class="token class-name" style="color:#0ea5e9">A</span><span class="token plain"> 在 </span><span class="token class-name" style="color:#0ea5e9">Master</span><span class="token plain"> 上加锁成功</span><br></div><div class="token-line" style="color:#0f172a"><span class="token plain"></span><span class="token number" style="color:#B5CEA8">2.</span><span class="token plain"> </span><span class="token class-name" style="color:#0ea5e9">Master</span><span class="token plain"> 还没把锁数据同步到 </span><span class="token class-name" style="color:#0ea5e9">Slave</span><span class="token plain"></span><br></div><div class="token-line" style="color:#0f172a"><span class="token plain"></span><span class="token number" style="color:#B5CEA8">3.</span><span class="token plain"> </span><span class="token class-name" style="color:#0ea5e9">Master</span><span class="token plain"> 宕机！</span><br></div><div class="token-line" style="color:#0f172a"><span class="token plain"></span><span class="token number" style="color:#B5CEA8">4.</span><span class="token plain"> </span><span class="token class-name" style="color:#0ea5e9">Slave</span><span class="token plain"> 升级为 </span><span class="token class-name" style="color:#0ea5e9">New</span><span class="token plain"> </span><span class="token class-name" style="color:#0ea5e9">Master</span><span class="token plain">（哨兵</span><span class="token operator" style="color:#475569">/</span><span class="token plain">集群自动故障转移）</span><br></div><div class="token-line" style="color:#0f172a"><span class="token plain"></span><span class="token number" style="color:#B5CEA8">5.</span><span class="token plain"> </span><span class="token class-name" style="color:#0ea5e9">Client</span><span class="token plain"> </span><span class="token class-name" style="color:#0ea5e9">B</span><span class="token plain"> 在 </span><span class="token class-name" style="color:#0ea5e9">New</span><span class="token plain"> </span><span class="token class-name" style="color:#0ea5e9">Master</span><span class="token plain"> 上加锁成功</span><br></div><div class="token-line" style="color:#0f172a"><span class="token plain">   </span><span class="token operator" style="color:#475569">-&gt;</span><span class="token plain"> 两个客户端同时持有锁！互斥性被打破</span><br></div></code></pre></div></div>
<p><strong>解决方案</strong>：Redisson 提供了 <strong>RedLock</strong> 算法（<code>getMultiLock()</code>），向多个独立的 Redis 实例同时请求锁，<strong>过半成功</strong>才算加锁成功。但实际生产中，RedLock 运维成本较高，且存在争议（Martin Kleppmann 与 Redis 作者 Antirez 的经典论战，感兴趣可搜索 <em>"How to do distributed locking"</em>）。大多数团队选择接受极小概率的锁丢失，或转向 Zookeeper 等 CP 模型的协调服务。</p>
<h4 class="anchor anchorTargetStickyNavbar_Vzrq" id="-unlock-必须加保护">② unlock() 必须加保护<a href="https://blog.xiaoxiaoliang.top/en/blog/distributed-lock#-unlock-%E5%BF%85%E9%A1%BB%E5%8A%A0%E4%BF%9D%E6%8A%A4" class="hash-link" aria-label="Direct link to ② unlock() 必须加保护" title="Direct link to ② unlock() 必须加保护" translate="no">​</a></h4>
<p>如果当前线程没有持有锁就调用 <code>unlock()</code>，Redisson 会抛出 <code>IllegalMonitorStateException</code>。所以务必在 <code>finally</code> 中加上判断：</p>
<pre tabindex="0" class="codeBlockStandalone_MEMb thin-scrollbar language-java codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#0f172a;--prism-background-color:#f1f5f9"><code class="codeBlockLines_e6Vv"><span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">finally</span><span style="color:#383A42;--shiki-dark:#E06C75"> {</span></span>
<span class="line"><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic">    // 只有当前线程持有锁时才解锁</span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">    if</span><span style="color:#383A42;--shiki-dark:#E06C75"> (</span><span style="color:#E45649;--shiki-dark:#E5C07B">lock</span><span style="color:#383A42;--shiki-dark:#ABB2BF">.</span><span style="color:#4078F2;--shiki-dark:#61AFEF">isHeldByCurrentThread</span><span style="color:#383A42;--shiki-dark:#ABB2BF">()</span><span style="color:#383A42;--shiki-dark:#E06C75">) {</span></span>
<span class="line"><span style="color:#E45649;--shiki-dark:#E5C07B">        lock</span><span style="color:#383A42;--shiki-dark:#ABB2BF">.</span><span style="color:#4078F2;--shiki-dark:#61AFEF">unlock</span><span style="color:#383A42;--shiki-dark:#ABB2BF">();</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#E06C75">    }</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#E06C75">}</span></span></code></pre>
<h4 class="anchor anchorTargetStickyNavbar_Vzrq" id="-不要在锁内做耗时-io">③ 不要在锁内做耗时 IO<a href="https://blog.xiaoxiaoliang.top/en/blog/distributed-lock#-%E4%B8%8D%E8%A6%81%E5%9C%A8%E9%94%81%E5%86%85%E5%81%9A%E8%80%97%E6%97%B6-io" class="hash-link" aria-label="Direct link to ③ 不要在锁内做耗时 IO" title="Direct link to ③ 不要在锁内做耗时 IO" translate="no">​</a></h4>
<p>即使有看门狗续期，也不要在持锁期间做 RPC 调用、大文件读写等耗时操作。持锁时间越长，系统的并发吞吐量越低。原则是<strong>快进快出</strong>。</p>
<hr>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="4-zookeeper-分布式锁">4. Zookeeper 分布式锁<a href="https://blog.xiaoxiaoliang.top/en/blog/distributed-lock#4-zookeeper-%E5%88%86%E5%B8%83%E5%BC%8F%E9%94%81" class="hash-link" aria-label="Direct link to 4. Zookeeper 分布式锁" title="Direct link to 4. Zookeeper 分布式锁" translate="no">​</a></h2>
<p>Zookeeper (ZK) 是 CP 模型（强一致性），天然适合做分布式协调。</p>
<ul>
<li class=""><strong>原理</strong>：利用 ZK 的<strong>临时顺序节点</strong>。<!-- -->
<ol>
<li class="">客户端在 <code>/locks</code> 下创建一个临时顺序节点。</li>
<li class="">判断自己是不是序号最小的节点？<!-- -->
<ul>
<li class="">是 -&gt; 获得锁。</li>
<li class="">否 -&gt; 监听（Watch）前一个节点。</li>
</ul>
</li>
<li class="">前一个节点删除（锁释放或对方挂了），触发 Watch 事件，自己再次判断是否是最小。</li>
</ol>
</li>
</ul>
<p><strong>对比 Redis vs Zookeeper</strong>：</p>
<table><thead><tr><th style="text-align:left">维度</th><th style="text-align:left">Redis (Redisson)</th><th style="text-align:left">Zookeeper</th></tr></thead><tbody><tr><td style="text-align:left"><strong>一致性</strong></td><td style="text-align:left">弱（AP模型，主从切换可能丢锁）</td><td style="text-align:left">强（CP模型，保证一致性）</td></tr><tr><td style="text-align:left"><strong>性能</strong></td><td style="text-align:left">极高（内存操作）</td><td style="text-align:left">中等（需要写磁盘日志，Leader同步）</td></tr><tr><td style="text-align:left"><strong>死锁风险</strong></td><td style="text-align:left">靠过期时间兜底</td><td style="text-align:left">靠临时节点（Session断开自动删除）</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>
<hr>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="5-总结">5. 总结<a href="https://blog.xiaoxiaoliang.top/en/blog/distributed-lock#5-%E6%80%BB%E7%BB%93" class="hash-link" aria-label="Direct link to 5. 总结" title="Direct link to 5. 总结" translate="no">​</a></h2>
<p>在大多数互联网业务场景下，<strong>Redis (Redisson)</strong> 是性价比最高的选择。</p>
<ul>
<li class="">如果是<strong>高并发的互联网业务</strong>（秒杀、库存扣减），追求极致性能，能接受极端情况下（主从切换）极小概率的并发冲突 → <strong>选 Redis (Redisson)</strong>。</li>
<li class="">如果是<strong>金融级场景</strong>，涉及资金操作，绝不允许任何并发差错 → <strong>选 Zookeeper</strong> 或 <strong>数据库悲观锁</strong>（适用于并发量不大的场景）。</li>
</ul>
<p><strong>最后建议</strong>：不要自己造轮子去写 Redis 分布式锁（SETNX），坑太多了。直接用 Redisson，省心省力！</p>]]></content>
        <author>
            <name>小小亮</name>
            <uri>https://github.com/xiaoxiaoliang</uri>
        </author>
        <category label="分布式锁" term="分布式锁"/>
        <category label="Redis" term="Redis"/>
        <category label="Redisson" term="Redisson"/>
        <category label="Java" term="Java"/>
        <category label="并发编程" term="并发编程"/>
    </entry>
    <entry>
        <title type="html"><![CDATA[Java接入第三方登录]]></title>
        <id>https://blog.xiaoxiaoliang.top/en/blog/java-third-party-login</id>
        <link href="https://blog.xiaoxiaoliang.top/en/blog/java-third-party-login"/>
        <updated>2025-12-01T00:00:00.000Z</updated>
        <summary type="html"><![CDATA[本文以 GitHub OAuth 为例，从原理到实践，全面讲解如何在 Spring Boot 项目中实现安全的第三方登录功能。]]></summary>
        <content type="html"><![CDATA[<blockquote>
<p>本文以 GitHub OAuth 为例，从原理到实践，全面讲解如何在 Spring Boot 项目中实现安全的第三方登录功能。</p>
</blockquote>
<!-- -->
<hr>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="目录">目录<a href="https://blog.xiaoxiaoliang.top/en/blog/java-third-party-login#%E7%9B%AE%E5%BD%95" class="hash-link" aria-label="Direct link to 目录" title="Direct link to 目录" translate="no">​</a></h2>
<ul>
<li class=""><a href="https://blog.xiaoxiaoliang.top/en/blog/java-third-party-login#%E4%B8%80oauth-20-%E5%8D%8F%E8%AE%AE%E6%B7%B1%E5%BA%A6%E8%A7%A3%E6%9E%90" class="">一、OAuth 2.0 协议深度解析</a></li>
<li class=""><a href="https://blog.xiaoxiaoliang.top/en/blog/java-third-party-login#%E4%BA%8C%E5%AE%89%E5%85%A8%E5%A8%81%E8%83%81%E4%B8%8E%E9%98%B2%E6%8A%A4%E7%AD%96%E7%95%A5" class="">二、安全威胁与防护策略</a></li>
<li class=""><a href="https://blog.xiaoxiaoliang.top/en/blog/java-third-party-login#%E4%B8%89%E6%B3%A8%E5%86%8C-github-oauth-%E5%BA%94%E7%94%A8" class="">三、注册 GitHub OAuth 应用</a></li>
<li class=""><a href="https://blog.xiaoxiaoliang.top/en/blog/java-third-party-login#%E5%9B%9B%E9%A1%B9%E7%9B%AE%E6%9E%B6%E6%9E%84%E8%AE%BE%E8%AE%A1" class="">四、项目架构设计</a></li>
<li class=""><a href="https://blog.xiaoxiaoliang.top/en/blog/java-third-party-login#%E4%BA%94%E6%A0%B8%E5%BF%83%E4%BB%A3%E7%A0%81%E5%AE%9E%E7%8E%B0" class="">五、核心代码实现</a></li>
<li class=""><a href="https://blog.xiaoxiaoliang.top/en/blog/java-third-party-login#%E5%85%AD%E5%AE%89%E5%85%A8%E5%A2%9E%E5%BC%BA%E5%AE%9E%E7%8E%B0" class="">六、安全增强实现</a></li>
<li class=""><a href="https://blog.xiaoxiaoliang.top/en/blog/java-third-party-login#%E4%B8%83%E6%95%B0%E6%8D%AE%E5%BA%93%E8%AE%BE%E8%AE%A1" class="">七、数据库设计</a></li>
<li class=""><a href="https://blog.xiaoxiaoliang.top/en/blog/java-third-party-login#%E5%85%AB%E5%89%8D%E7%AB%AF%E5%AF%B9%E6%8E%A5" class="">八、前端对接</a></li>
<li class=""><a href="https://blog.xiaoxiaoliang.top/en/blog/java-third-party-login#%E4%B9%9D%E7%94%9F%E4%BA%A7%E7%8E%AF%E5%A2%83%E9%83%A8%E7%BD%B2" class="">九、生产环境部署</a></li>
<li class=""><a href="https://blog.xiaoxiaoliang.top/en/blog/java-third-party-login#%E5%8D%81%E5%B8%B8%E8%A7%81%E9%97%AE%E9%A2%98%E4%B8%8E%E6%8E%92%E6%9F%A5" class="">十、常见问题与排查</a></li>
<li class=""><a href="https://blog.xiaoxiaoliang.top/en/blog/java-third-party-login#%E5%8D%81%E4%B8%80%E6%89%A9%E5%B1%95%E9%98%85%E8%AF%BB" class="">十一、扩展阅读</a></li>
</ul>
<hr>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="一oauth-20-协议深度解析">一、OAuth 2.0 协议深度解析<a href="https://blog.xiaoxiaoliang.top/en/blog/java-third-party-login#%E4%B8%80oauth-20-%E5%8D%8F%E8%AE%AE%E6%B7%B1%E5%BA%A6%E8%A7%A3%E6%9E%90" class="hash-link" aria-label="Direct link to 一、OAuth 2.0 协议深度解析" title="Direct link to 一、OAuth 2.0 协议深度解析" translate="no">​</a></h2>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="11-什么是-oauth-20">1.1 什么是 OAuth 2.0？<a href="https://blog.xiaoxiaoliang.top/en/blog/java-third-party-login#11-%E4%BB%80%E4%B9%88%E6%98%AF-oauth-20" class="hash-link" aria-label="Direct link to 1.1 什么是 OAuth 2.0？" title="Direct link to 1.1 什么是 OAuth 2.0？" translate="no">​</a></h3>
<p>OAuth 2.0 是一个<strong>授权框架</strong>（Authorization Framework），它允许第三方应用在用户授权的情况下，访问用户在某个服务上的特定资源，而<strong>无需获取用户的密码</strong>。</p>
<blockquote>
<p>🔑 <strong>核心思想</strong>：OAuth 解决的是"授权"问题，而非"认证"问题。它让用户可以授权第三方应用访问自己的数据，同时保持密码的私密性。</p>
</blockquote>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="12-oauth-20-四种授权模式">1.2 OAuth 2.0 四种授权模式<a href="https://blog.xiaoxiaoliang.top/en/blog/java-third-party-login#12-oauth-20-%E5%9B%9B%E7%A7%8D%E6%8E%88%E6%9D%83%E6%A8%A1%E5%BC%8F" class="hash-link" aria-label="Direct link to 1.2 OAuth 2.0 四种授权模式" title="Direct link to 1.2 OAuth 2.0 四种授权模式" translate="no">​</a></h3>
<p>OAuth 2.0 定义了四种授权模式，适用于不同场景：</p>
<table><thead><tr><th>模式</th><th>英文名</th><th>适用场景</th><th>安全性</th></tr></thead><tbody><tr><td><strong>授权码模式</strong></td><td>Authorization Code</td><td>Web 应用、有后端服务器</td><td>⭐⭐⭐⭐⭐ 最高</td></tr><tr><td><strong>隐式模式</strong></td><td>Implicit</td><td>纯前端应用（已不推荐）</td><td>⭐⭐ 较低</td></tr><tr><td><strong>密码模式</strong></td><td>Password</td><td>高度信任的应用（已废弃）</td><td>⭐ 最低</td></tr><tr><td><strong>客户端凭证</strong></td><td>Client Credentials</td><td>服务端对服务端通信</td><td>⭐⭐⭐⭐</td></tr></tbody></table>
<p>本文采用<strong>授权码模式（Authorization Code）</strong>，这是目前最安全、最常用的模式。</p>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="13-授权码模式完整流程">1.3 授权码模式完整流程<a href="https://blog.xiaoxiaoliang.top/en/blog/java-third-party-login#13-%E6%8E%88%E6%9D%83%E7%A0%81%E6%A8%A1%E5%BC%8F%E5%AE%8C%E6%95%B4%E6%B5%81%E7%A8%8B" class="hash-link" aria-label="Direct link to 1.3 授权码模式完整流程" title="Direct link to 1.3 授权码模式完整流程" translate="no">​</a></h3>
<p><img decoding="async" loading="lazy" alt="OAuth 2.0 授权码模式流程图" src="https://blog.xiaoxiaoliang.top/en/assets/images/oauth_authorization_flow-6321ef9c9fc41b082fab75dc1792d7bc.png" width="1024" height="1024" class="img_ev3q"></p>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="14-关键参数详解">1.4 关键参数详解<a href="https://blog.xiaoxiaoliang.top/en/blog/java-third-party-login#14-%E5%85%B3%E9%94%AE%E5%8F%82%E6%95%B0%E8%AF%A6%E8%A7%A3" class="hash-link" aria-label="Direct link to 1.4 关键参数详解" title="Direct link to 1.4 关键参数详解" translate="no">​</a></h3>
<h4 class="anchor anchorTargetStickyNavbar_Vzrq" id="授权请求参数">授权请求参数<a href="https://blog.xiaoxiaoliang.top/en/blog/java-third-party-login#%E6%8E%88%E6%9D%83%E8%AF%B7%E6%B1%82%E5%8F%82%E6%95%B0" class="hash-link" aria-label="Direct link to 授权请求参数" title="Direct link to 授权请求参数" translate="no">​</a></h4>
<table><thead><tr><th>参数</th><th>必填</th><th>说明</th></tr></thead><tbody><tr><td><code>client_id</code></td><td>✅</td><td>应用标识，在 GitHub 注册应用时获得</td></tr><tr><td><code>redirect_uri</code></td><td>✅</td><td>授权成功后的回调地址，必须与注册时配置一致</td></tr><tr><td><code>scope</code></td><td>❌</td><td>请求的权限范围，如 <code>user:email</code>、<code>repo</code></td></tr><tr><td><code>state</code></td><td>⚠️ <strong>强烈建议</strong></td><td>CSRF 防护参数，原样返回用于验证</td></tr><tr><td><code>response_type</code></td><td>✅</td><td>授权码模式固定为 <code>code</code></td></tr></tbody></table>
<h4 class="anchor anchorTargetStickyNavbar_Vzrq" id="令牌请求参数">令牌请求参数<a href="https://blog.xiaoxiaoliang.top/en/blog/java-third-party-login#%E4%BB%A4%E7%89%8C%E8%AF%B7%E6%B1%82%E5%8F%82%E6%95%B0" class="hash-link" aria-label="Direct link to 令牌请求参数" title="Direct link to 令牌请求参数" translate="no">​</a></h4>
<table><thead><tr><th>参数</th><th>必填</th><th>说明</th></tr></thead><tbody><tr><td><code>client_id</code></td><td>✅</td><td>应用标识</td></tr><tr><td><code>client_secret</code></td><td>✅</td><td>应用密钥，<strong>必须保密</strong></td></tr><tr><td><code>code</code></td><td>✅</td><td>授权码，一次性使用，有效期通常 10 分钟</td></tr><tr><td><code>redirect_uri</code></td><td>❌</td><td>必须与授权请求时一致</td></tr></tbody></table>
<h4 class="anchor anchorTargetStickyNavbar_Vzrq" id="令牌响应">令牌响应<a href="https://blog.xiaoxiaoliang.top/en/blog/java-third-party-login#%E4%BB%A4%E7%89%8C%E5%93%8D%E5%BA%94" class="hash-link" aria-label="Direct link to 令牌响应" title="Direct link to 令牌响应" translate="no">​</a></h4>
<table><thead><tr><th>字段</th><th>说明</th></tr></thead><tbody><tr><td><code>access_token</code></td><td>访问令牌，用于调用 API</td></tr><tr><td><code>token_type</code></td><td>令牌类型，通常为 <code>bearer</code></td></tr><tr><td><code>scope</code></td><td>实际授予的权限范围</td></tr><tr><td><code>expires_in</code></td><td>过期时间（秒），GitHub 不返回此字段表示永不过期</td></tr><tr><td><code>refresh_token</code></td><td>刷新令牌（部分平台支持）</td></tr></tbody></table>
<hr>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="二安全威胁与防护策略">二、安全威胁与防护策略<a href="https://blog.xiaoxiaoliang.top/en/blog/java-third-party-login#%E4%BA%8C%E5%AE%89%E5%85%A8%E5%A8%81%E8%83%81%E4%B8%8E%E9%98%B2%E6%8A%A4%E7%AD%96%E7%95%A5" class="hash-link" aria-label="Direct link to 二、安全威胁与防护策略" title="Direct link to 二、安全威胁与防护策略" translate="no">​</a></h2>
<p>OAuth 2.0 虽然设计精良，但实现不当仍会引入安全漏洞。以下是常见威胁及防护方案。</p>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="21-csrf-攻击跨站请求伪造">2.1 CSRF 攻击（跨站请求伪造）<a href="https://blog.xiaoxiaoliang.top/en/blog/java-third-party-login#21-csrf-%E6%94%BB%E5%87%BB%E8%B7%A8%E7%AB%99%E8%AF%B7%E6%B1%82%E4%BC%AA%E9%80%A0" class="hash-link" aria-label="Direct link to 2.1 CSRF 攻击（跨站请求伪造）" title="Direct link to 2.1 CSRF 攻击（跨站请求伪造）" translate="no">​</a></h3>
<h4 class="anchor anchorTargetStickyNavbar_Vzrq" id="什么是-csrf">什么是 CSRF？<a href="https://blog.xiaoxiaoliang.top/en/blog/java-third-party-login#%E4%BB%80%E4%B9%88%E6%98%AF-csrf" class="hash-link" aria-label="Direct link to 什么是 CSRF？" title="Direct link to 什么是 CSRF？" translate="no">​</a></h4>
<p>CSRF（Cross-Site Request Forgery）攻击是指攻击者诱导用户在已登录的状态下，点击恶意链接，从而在用户不知情的情况下完成某些操作。</p>
<h4 class="anchor anchorTargetStickyNavbar_Vzrq" id="oauth-中的-csrf-攻击场景">OAuth 中的 CSRF 攻击场景<a href="https://blog.xiaoxiaoliang.top/en/blog/java-third-party-login#oauth-%E4%B8%AD%E7%9A%84-csrf-%E6%94%BB%E5%87%BB%E5%9C%BA%E6%99%AF" class="hash-link" aria-label="Direct link to OAuth 中的 CSRF 攻击场景" title="Direct link to OAuth 中的 CSRF 攻击场景" translate="no">​</a></h4>
<div class="language-java codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#0f172a;--prism-background-color:#f1f5f9"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-java codeBlock_bY9V thin-scrollbar" style="color:#0f172a;background-color:#f1f5f9"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#0f172a"><span class="token number" style="color:#B5CEA8">1.</span><span class="token plain"> 攻击者用自己的 </span><span class="token class-name" style="color:#0ea5e9">GitHub</span><span class="token plain"> 账号发起授权，获得授权码 code</span><span class="token operator" style="color:#475569">=</span><span class="token plain">abc123</span><br></div><div class="token-line" style="color:#0f172a"><span class="token plain"></span><span class="token number" style="color:#B5CEA8">2.</span><span class="token plain"> 攻击者构造恶意链接：http</span><span class="token operator" style="color:#475569">:</span><span class="token operator" style="color:#475569">/</span><span class="token operator" style="color:#475569">/</span><span class="token plain">你的网站</span><span class="token operator" style="color:#475569">/</span><span class="token plain">callback</span><span class="token operator" style="color:#475569">?</span><span class="token plain">code</span><span class="token operator" style="color:#475569">=</span><span class="token plain">abc123</span><br></div><div class="token-line" style="color:#0f172a"><span class="token plain"></span><span class="token number" style="color:#B5CEA8">3.</span><span class="token plain"> 攻击者诱导受害者点击该链接</span><br></div><div class="token-line" style="color:#0f172a"><span class="token plain"></span><span class="token number" style="color:#B5CEA8">4.</span><span class="token plain"> 受害者的账号被绑定到攻击者的 </span><span class="token class-name" style="color:#0ea5e9">GitHub</span><span class="token plain"> 账号！</span><br></div><div class="token-line" style="color:#0f172a"><span class="token plain"></span><span class="token number" style="color:#B5CEA8">5.</span><span class="token plain"> 攻击者可以用自己的 </span><span class="token class-name" style="color:#0ea5e9">GitHub</span><span class="token plain"> 登录受害者的账号</span><br></div></code></pre></div></div>
<p>这就是著名的 <strong>"账号劫持"</strong> 攻击。</p>
<h4 class="anchor anchorTargetStickyNavbar_Vzrq" id="csrf-防护方案对比">CSRF 防护方案对比<a href="https://blog.xiaoxiaoliang.top/en/blog/java-third-party-login#csrf-%E9%98%B2%E6%8A%A4%E6%96%B9%E6%A1%88%E5%AF%B9%E6%AF%94" class="hash-link" aria-label="Direct link to CSRF 防护方案对比" title="Direct link to CSRF 防护方案对比" translate="no">​</a></h4>
<table><thead><tr><th>方案</th><th>原理</th><th>优点</th><th>缺点</th></tr></thead><tbody><tr><td><strong>State 参数（推荐）</strong></td><td>在授权请求中携带随机字符串，回调时验证</td><td>OAuth 标准方案，通用性强</td><td>需要服务端存储 state</td></tr><tr><td><strong>Referer 检查</strong></td><td>验证请求来源</td><td>实现简单</td><td>可被伪造或被浏览器隐私设置禁用</td></tr><tr><td><strong>Double Submit Cookie</strong></td><td>同时在 Cookie 和请求参数中携带 token</td><td>无需服务端存储</td><td>实现复杂</td></tr><tr><td><strong>SameSite Cookie</strong></td><td>使用 SameSite 属性限制 Cookie</td><td>浏览器原生支持</td><td>兼容性问题</td></tr></tbody></table>
<h4 class="anchor anchorTargetStickyNavbar_Vzrq" id="本项目的-csrf-防护方案">本项目的 CSRF 防护方案<a href="https://blog.xiaoxiaoliang.top/en/blog/java-third-party-login#%E6%9C%AC%E9%A1%B9%E7%9B%AE%E7%9A%84-csrf-%E9%98%B2%E6%8A%A4%E6%96%B9%E6%A1%88" class="hash-link" aria-label="Direct link to 本项目的 CSRF 防护方案" title="Direct link to 本项目的 CSRF 防护方案" translate="no">​</a></h4>
<p>我们采用 <strong>State 参数 + Redis 存储</strong> 的方案：</p>
<div class="language-java codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#0f172a;--prism-background-color:#f1f5f9"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-java codeBlock_bY9V thin-scrollbar" style="color:#0f172a;background-color:#f1f5f9"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#0f172a"><span class="token number" style="color:#B5CEA8">1.</span><span class="token plain"> 用户点击登录，后端生成随机 state 并存入 </span><span class="token class-name" style="color:#0ea5e9">Redis</span><span class="token plain">（</span><span class="token constant" style="color:#0f172a">TTL</span><span class="token plain"> </span><span class="token number" style="color:#B5CEA8">5</span><span class="token plain">分钟）</span><br></div><div class="token-line" style="color:#0f172a"><span class="token plain"></span><span class="token number" style="color:#B5CEA8">2.</span><span class="token plain"> 将 state 拼接到授权 </span><span class="token constant" style="color:#0f172a">URL</span><span class="token plain"> 中</span><br></div><div class="token-line" style="color:#0f172a"><span class="token plain"></span><span class="token number" style="color:#B5CEA8">3.</span><span class="token plain"> </span><span class="token class-name" style="color:#0ea5e9">GitHub</span><span class="token plain"> 回调时携带原样的 state</span><br></div><div class="token-line" style="color:#0f172a"><span class="token plain"></span><span class="token number" style="color:#B5CEA8">4.</span><span class="token plain"> 后端从 </span><span class="token class-name" style="color:#0ea5e9">Redis</span><span class="token plain"> 获取并验证 state，验证后立即删除（一次性使用）</span><br></div><div class="token-line" style="color:#0f172a"><span class="token plain"></span><span class="token number" style="color:#B5CEA8">5.</span><span class="token plain"> 验证失败则拒绝登录</span><br></div></code></pre></div></div>
<p><strong>优势</strong>：</p>
<ul>
<li class="">✅ 完全符合 OAuth 2.0 规范</li>
<li class="">✅ State 存储在 Redis，支持分布式部署</li>
<li class="">✅ 一次性使用，防止重放攻击</li>
<li class="">✅ 设置 TTL，自动过期清理</li>
</ul>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="22-授权码注入攻击">2.2 授权码注入攻击<a href="https://blog.xiaoxiaoliang.top/en/blog/java-third-party-login#22-%E6%8E%88%E6%9D%83%E7%A0%81%E6%B3%A8%E5%85%A5%E6%94%BB%E5%87%BB" class="hash-link" aria-label="Direct link to 2.2 授权码注入攻击" title="Direct link to 2.2 授权码注入攻击" translate="no">​</a></h3>
<h4 class="anchor anchorTargetStickyNavbar_Vzrq" id="攻击场景">攻击场景<a href="https://blog.xiaoxiaoliang.top/en/blog/java-third-party-login#%E6%94%BB%E5%87%BB%E5%9C%BA%E6%99%AF" class="hash-link" aria-label="Direct link to 攻击场景" title="Direct link to 攻击场景" translate="no">​</a></h4>
<p>攻击者获取到某个用户的授权码后，尝试在自己的会话中使用该授权码。</p>
<h4 class="anchor anchorTargetStickyNavbar_Vzrq" id="防护方案">防护方案<a href="https://blog.xiaoxiaoliang.top/en/blog/java-third-party-login#%E9%98%B2%E6%8A%A4%E6%96%B9%E6%A1%88" class="hash-link" aria-label="Direct link to 防护方案" title="Direct link to 防护方案" translate="no">​</a></h4>
<ol>
<li class=""><strong>验证 redirect_uri</strong>：换取 token 时校验 redirect_uri 与授权请求一致</li>
<li class=""><strong>授权码绑定会话</strong>：将授权码与发起请求的 session 绑定</li>
<li class=""><strong>授权码一次性使用</strong>：使用后立即失效</li>
</ol>
<p>本项目通过 State 参数同时实现了会话绑定和一次性使用。</p>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="23-token-泄露风险">2.3 Token 泄露风险<a href="https://blog.xiaoxiaoliang.top/en/blog/java-third-party-login#23-token-%E6%B3%84%E9%9C%B2%E9%A3%8E%E9%99%A9" class="hash-link" aria-label="Direct link to 2.3 Token 泄露风险" title="Direct link to 2.3 Token 泄露风险" translate="no">​</a></h3>
<h4 class="anchor anchorTargetStickyNavbar_Vzrq" id="威胁来源">威胁来源<a href="https://blog.xiaoxiaoliang.top/en/blog/java-third-party-login#%E5%A8%81%E8%83%81%E6%9D%A5%E6%BA%90" class="hash-link" aria-label="Direct link to 威胁来源" title="Direct link to 威胁来源" translate="no">​</a></h4>
<ul>
<li class="">数据库泄露</li>
<li class="">日志泄露</li>
<li class="">网络传输被截获</li>
</ul>
<h4 class="anchor anchorTargetStickyNavbar_Vzrq" id="本项目的防护措施">本项目的防护措施<a href="https://blog.xiaoxiaoliang.top/en/blog/java-third-party-login#%E6%9C%AC%E9%A1%B9%E7%9B%AE%E7%9A%84%E9%98%B2%E6%8A%A4%E6%8E%AA%E6%96%BD" class="hash-link" aria-label="Direct link to 本项目的防护措施" title="Direct link to 本项目的防护措施" translate="no">​</a></h4>
<table><thead><tr><th>风险点</th><th>防护措施</th></tr></thead><tbody><tr><td>数据库泄露</td><td>access_token 使用 AES-256-GCM 加密后存储</td></tr><tr><td>日志泄露</td><td>禁止在日志中打印 token、code 等敏感信息</td></tr><tr><td>网络传输</td><td>强制 HTTPS，token 仅在后端流转</td></tr></tbody></table>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="24-重定向-uri-劫持">2.4 重定向 URI 劫持<a href="https://blog.xiaoxiaoliang.top/en/blog/java-third-party-login#24-%E9%87%8D%E5%AE%9A%E5%90%91-uri-%E5%8A%AB%E6%8C%81" class="hash-link" aria-label="Direct link to 2.4 重定向 URI 劫持" title="Direct link to 2.4 重定向 URI 劫持" translate="no">​</a></h3>
<h4 class="anchor anchorTargetStickyNavbar_Vzrq" id="攻击场景-1">攻击场景<a href="https://blog.xiaoxiaoliang.top/en/blog/java-third-party-login#%E6%94%BB%E5%87%BB%E5%9C%BA%E6%99%AF-1" class="hash-link" aria-label="Direct link to 攻击场景" title="Direct link to 攻击场景" translate="no">​</a></h4>
<p>如果应用没有严格验证 redirect_uri，攻击者可以将用户重定向到恶意网站，窃取授权码。</p>
<h4 class="anchor anchorTargetStickyNavbar_Vzrq" id="防护建议">防护建议<a href="https://blog.xiaoxiaoliang.top/en/blog/java-third-party-login#%E9%98%B2%E6%8A%A4%E5%BB%BA%E8%AE%AE" class="hash-link" aria-label="Direct link to 防护建议" title="Direct link to 防护建议" translate="no">​</a></h4>
<ol>
<li class=""><strong>严格匹配</strong>：redirect_uri 必须完全匹配，不使用通配符</li>
<li class=""><strong>白名单验证</strong>：维护允许的 redirect_uri 列表</li>
<li class=""><strong>HTTPS 强制</strong>：生产环境 redirect_uri 必须使用 HTTPS</li>
</ol>
<hr>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="三注册-github-oauth-应用">三、注册 GitHub OAuth 应用<a href="https://blog.xiaoxiaoliang.top/en/blog/java-third-party-login#%E4%B8%89%E6%B3%A8%E5%86%8C-github-oauth-%E5%BA%94%E7%94%A8" class="hash-link" aria-label="Direct link to 三、注册 GitHub OAuth 应用" title="Direct link to 三、注册 GitHub OAuth 应用" translate="no">​</a></h2>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="31-创建应用">3.1 创建应用<a href="https://blog.xiaoxiaoliang.top/en/blog/java-third-party-login#31-%E5%88%9B%E5%BB%BA%E5%BA%94%E7%94%A8" class="hash-link" aria-label="Direct link to 3.1 创建应用" title="Direct link to 3.1 创建应用" translate="no">​</a></h3>
<ol>
<li class="">登录 GitHub，进入 <strong>Settings → Developer settings → OAuth Apps</strong></li>
<li class="">点击 <strong>New OAuth App</strong></li>
<li class="">填写应用信息：</li>
</ol>
<table><thead><tr><th>字段</th><th>开发环境示例</th><th>生产环境示例</th></tr></thead><tbody><tr><td>Application name</td><td>LeetCode Clone Dev</td><td>LeetCode Clone</td></tr><tr><td>Homepage URL</td><td><code>http://localhost:5173</code></td><td><code>https://leetcode.example.com</code></td></tr><tr><td>Authorization callback URL</td><td><code>http://localhost:8001/api/user/oauth/callback/github</code></td><td><code>https://api.leetcode.example.com/oauth/callback/github</code></td></tr></tbody></table>
<ol start="4">
<li class="">点击 <strong>Register application</strong></li>
</ol>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="32-获取凭证">3.2 获取凭证<a href="https://blog.xiaoxiaoliang.top/en/blog/java-third-party-login#32-%E8%8E%B7%E5%8F%96%E5%87%AD%E8%AF%81" class="hash-link" aria-label="Direct link to 3.2 获取凭证" title="Direct link to 3.2 获取凭证" translate="no">​</a></h3>
<p>创建成功后，你会看到：</p>
<ul>
<li class=""><strong>Client ID</strong>：公开的应用标识，可以放在前端代码中</li>
<li class=""><strong>Client Secret</strong>：<strong>必须保密</strong>，只能存放在后端</li>
</ul>
<blockquote>
<p>⚠️ <strong>安全提示</strong>：切勿将 Client Secret 提交到 Git 仓库，应使用环境变量管理</p>
</blockquote>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="33-多环境配置">3.3 多环境配置<a href="https://blog.xiaoxiaoliang.top/en/blog/java-third-party-login#33-%E5%A4%9A%E7%8E%AF%E5%A2%83%E9%85%8D%E7%BD%AE" class="hash-link" aria-label="Direct link to 3.3 多环境配置" title="Direct link to 3.3 多环境配置" translate="no">​</a></h3>
<p>建议为不同环境创建独立的 OAuth App：</p>
<pre tabindex="0" class="codeBlockStandalone_MEMb thin-scrollbar language-yaml codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#0f172a;--prism-background-color:#f1f5f9"><code class="codeBlockLines_e6Vv"><span class="line"><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic"># application-dev.yaml（开发环境）</span></span>
<span class="line"><span style="color:#E45649;--shiki-dark:#E06C75">oauth</span><span style="color:#383A42;--shiki-dark:#ABB2BF">:</span></span>
<span class="line"><span style="color:#E45649;--shiki-dark:#E06C75">  github</span><span style="color:#383A42;--shiki-dark:#ABB2BF">:</span></span>
<span class="line"><span style="color:#E45649;--shiki-dark:#E06C75">    client-id</span><span style="color:#383A42;--shiki-dark:#ABB2BF">: </span><span style="color:#50A14F;--shiki-dark:#98C379">${GITHUB_CLIENT_ID_DEV}</span></span>
<span class="line"><span style="color:#E45649;--shiki-dark:#E06C75">    client-secret</span><span style="color:#383A42;--shiki-dark:#ABB2BF">: </span><span style="color:#50A14F;--shiki-dark:#98C379">${GITHUB_CLIENT_SECRET_DEV}</span></span>
<span class="line"></span>
<span class="line"><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic"># application-prod.yaml（生产环境）</span></span>
<span class="line"><span style="color:#E45649;--shiki-dark:#E06C75">oauth</span><span style="color:#383A42;--shiki-dark:#ABB2BF">:</span></span>
<span class="line"><span style="color:#E45649;--shiki-dark:#E06C75">  github</span><span style="color:#383A42;--shiki-dark:#ABB2BF">:</span></span>
<span class="line"><span style="color:#E45649;--shiki-dark:#E06C75">    client-id</span><span style="color:#383A42;--shiki-dark:#ABB2BF">: </span><span style="color:#50A14F;--shiki-dark:#98C379">${GITHUB_CLIENT_ID_PROD}</span></span>
<span class="line"><span style="color:#E45649;--shiki-dark:#E06C75">    client-secret</span><span style="color:#383A42;--shiki-dark:#ABB2BF">: </span><span style="color:#50A14F;--shiki-dark:#98C379">${GITHUB_CLIENT_SECRET_PROD}</span></span></code></pre>
<hr>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="四项目架构设计">四、项目架构设计<a href="https://blog.xiaoxiaoliang.top/en/blog/java-third-party-login#%E5%9B%9B%E9%A1%B9%E7%9B%AE%E6%9E%B6%E6%9E%84%E8%AE%BE%E8%AE%A1" class="hash-link" aria-label="Direct link to 四、项目架构设计" title="Direct link to 四、项目架构设计" translate="no">​</a></h2>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="41-整体架构">4.1 整体架构<a href="https://blog.xiaoxiaoliang.top/en/blog/java-third-party-login#41-%E6%95%B4%E4%BD%93%E6%9E%B6%E6%9E%84" class="hash-link" aria-label="Direct link to 4.1 整体架构" title="Direct link to 4.1 整体架构" translate="no">​</a></h3>
<p><img decoding="async" loading="lazy" alt="OAuth 模块架构图" src="https://blog.xiaoxiaoliang.top/en/assets/images/oauth_module_architecture-451c022461cbc6f166e1c68f37b0ffbe.png" width="1024" height="1024" class="img_ev3q"></p>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="42-设计模式应用">4.2 设计模式应用<a href="https://blog.xiaoxiaoliang.top/en/blog/java-third-party-login#42-%E8%AE%BE%E8%AE%A1%E6%A8%A1%E5%BC%8F%E5%BA%94%E7%94%A8" class="hash-link" aria-label="Direct link to 4.2 设计模式应用" title="Direct link to 4.2 设计模式应用" translate="no">​</a></h3>
<h4 class="anchor anchorTargetStickyNavbar_Vzrq" id="策略模式strategy-pattern">策略模式（Strategy Pattern）<a href="https://blog.xiaoxiaoliang.top/en/blog/java-third-party-login#%E7%AD%96%E7%95%A5%E6%A8%A1%E5%BC%8Fstrategy-pattern" class="hash-link" aria-label="Direct link to 策略模式（Strategy Pattern）" title="Direct link to 策略模式（Strategy Pattern）" translate="no">​</a></h4>
<p><strong>问题</strong>：不同第三方平台（GitHub、Google、微信）的授权流程相似，但具体实现不同。</p>
<p><strong>解决方案</strong>：定义统一的策略接口，每个平台实现自己的策略类。</p>
<pre tabindex="0" class="codeBlockStandalone_MEMb thin-scrollbar language-java codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#0f172a;--prism-background-color:#f1f5f9"><code class="codeBlockLines_e6Vv"><span class="line"><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic">/**</span></span>
<span class="line"><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic"> * OAuth 策略接口</span></span>
<span class="line"><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic"> * 定义所有第三方平台必须实现的方法</span></span>
<span class="line"><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic"> */</span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">public</span><span style="color:#A626A4;--shiki-dark:#C678DD"> interface</span><span style="color:#C18401;--shiki-dark:#E5C07B"> OAuthStrategy</span><span style="color:#383A42;--shiki-dark:#ABB2BF"> {</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#E06C75">    </span></span>
<span class="line"><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic">    /**</span></span>
<span class="line"><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic">     * 获取平台标识（如 github、google）</span></span>
<span class="line"><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic">     */</span></span>
<span class="line"><span style="color:#C18401;--shiki-dark:#E5C07B">    String</span><span style="color:#4078F2;--shiki-dark:#61AFEF"> getProvider</span><span style="color:#383A42;--shiki-dark:#ABB2BF">();</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#E06C75">    </span></span>
<span class="line"><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic">    /**</span></span>
<span class="line"><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic">     * 构建授权 URL</span></span>
<span class="line"><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic">     * </span><span style="color:#A626A4;--shiki-light-font-style:italic;--shiki-dark:#C678DD;--shiki-dark-font-style:italic">@param</span><span style="color:#383A42;--shiki-light-font-style:italic;--shiki-dark:#E06C75;--shiki-dark-font-style:italic"> state</span><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic"> CSRF 防护参数</span></span>
<span class="line"><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic">     */</span></span>
<span class="line"><span style="color:#C18401;--shiki-dark:#E5C07B">    String</span><span style="color:#4078F2;--shiki-dark:#61AFEF"> getAuthorizeUrl</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(</span><span style="color:#C18401;--shiki-dark:#E5C07B">String</span><span style="color:#383A42;--shiki-light-font-style:inherit;--shiki-dark:#E06C75;--shiki-dark-font-style:italic"> state</span><span style="color:#383A42;--shiki-dark:#ABB2BF">);</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#E06C75">    </span></span>
<span class="line"><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic">    /**</span></span>
<span class="line"><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic">     * 用授权码换取用户信息</span></span>
<span class="line"><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic">     * </span><span style="color:#A626A4;--shiki-light-font-style:italic;--shiki-dark:#C678DD;--shiki-dark-font-style:italic">@param</span><span style="color:#383A42;--shiki-light-font-style:italic;--shiki-dark:#E06C75;--shiki-dark-font-style:italic"> code</span><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic"> 一次性授权码</span></span>
<span class="line"><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic">     */</span></span>
<span class="line"><span style="color:#C18401;--shiki-dark:#E5C07B">    OAuthUserInfo</span><span style="color:#4078F2;--shiki-dark:#61AFEF"> getUserInfo</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(</span><span style="color:#C18401;--shiki-dark:#E5C07B">String</span><span style="color:#383A42;--shiki-light-font-style:inherit;--shiki-dark:#E06C75;--shiki-dark-font-style:italic"> code</span><span style="color:#383A42;--shiki-dark:#ABB2BF">);</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#E06C75">    </span></span>
<span class="line"><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic">    /**</span></span>
<span class="line"><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic">     * 创建新用户并绑定第三方账号</span></span>
<span class="line"><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic">     */</span></span>
<span class="line"><span style="color:#C18401;--shiki-dark:#E5C07B">    Long</span><span style="color:#4078F2;--shiki-dark:#61AFEF"> createUserOAuth</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(</span><span style="color:#C18401;--shiki-dark:#E5C07B">OAuthUserInfo</span><span style="color:#383A42;--shiki-light-font-style:inherit;--shiki-dark:#E06C75;--shiki-dark-font-style:italic"> userInfo</span><span style="color:#383A42;--shiki-dark:#ABB2BF">);</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">}</span></span></code></pre>
<p><strong>优势</strong>：</p>
<ul>
<li class="">✅ 新增平台只需添加新的策略类，无需修改现有代码</li>
<li class="">✅ 符合开闭原则（对扩展开放，对修改关闭）</li>
<li class="">✅ 便于单元测试</li>
</ul>
<h4 class="anchor anchorTargetStickyNavbar_Vzrq" id="工厂模式factory-pattern">工厂模式（Factory Pattern）<a href="https://blog.xiaoxiaoliang.top/en/blog/java-third-party-login#%E5%B7%A5%E5%8E%82%E6%A8%A1%E5%BC%8Ffactory-pattern" class="hash-link" aria-label="Direct link to 工厂模式（Factory Pattern）" title="Direct link to 工厂模式（Factory Pattern）" translate="no">​</a></h4>
<p><strong>问题</strong>：需要根据平台名称动态获取对应的策略实现。</p>
<p><strong>解决方案</strong>：使用策略工厂统一管理所有策略实例。</p>
<pre tabindex="0" class="codeBlockStandalone_MEMb thin-scrollbar language-java codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#0f172a;--prism-background-color:#f1f5f9"><code class="codeBlockLines_e6Vv"><span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">@</span><span style="color:#A626A4;--shiki-dark:#E5C07B">Component</span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">public</span><span style="color:#A626A4;--shiki-dark:#C678DD"> class</span><span style="color:#C18401;--shiki-dark:#E5C07B"> OAuthStrategyFactory</span><span style="color:#383A42;--shiki-dark:#ABB2BF"> {</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#E06C75">    </span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">    private</span><span style="color:#A626A4;--shiki-dark:#C678DD"> final</span><span style="color:#C18401;--shiki-dark:#E5C07B"> Map</span><span style="color:#E45649;--shiki-dark:#ABB2BF">&lt;</span><span style="color:#C18401;--shiki-dark:#E5C07B">String</span><span style="color:#E45649;--shiki-dark:#ABB2BF">,</span><span style="color:#C18401;--shiki-dark:#E5C07B"> OAuthStrategy</span><span style="color:#E45649;--shiki-dark:#ABB2BF">&gt;</span><span style="color:#E45649;--shiki-dark:#E06C75"> strategyMap</span><span style="color:#383A42;--shiki-dark:#ABB2BF">;</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#E06C75">    </span></span>
<span class="line"><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic">    /**</span></span>
<span class="line"><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic">     * Spring 自动注入所有 OAuthStrategy 实现类</span></span>
<span class="line"><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic">     */</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">    @</span><span style="color:#A626A4;--shiki-dark:#E5C07B">Autowired</span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">    public</span><span style="color:#4078F2;--shiki-dark:#61AFEF"> OAuthStrategyFactory</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(</span><span style="color:#C18401;--shiki-dark:#E5C07B">List</span><span style="color:#383A42;--shiki-dark:#ABB2BF">&lt;</span><span style="color:#C18401;--shiki-dark:#E5C07B">OAuthStrategy</span><span style="color:#383A42;--shiki-dark:#ABB2BF">&gt; </span><span style="color:#383A42;--shiki-light-font-style:inherit;--shiki-dark:#E06C75;--shiki-dark-font-style:italic">strategies</span><span style="color:#383A42;--shiki-dark:#ABB2BF">)</span><span style="color:#383A42;--shiki-dark:#ABB2BF"> {</span></span>
<span class="line"><span style="color:#E45649;--shiki-dark:#E5C07B">        this</span><span style="color:#383A42;--shiki-dark:#ABB2BF">.</span><span style="color:#E45649;--shiki-dark:#E5C07B">strategyMap</span><span style="color:#383A42;--shiki-dark:#56B6C2"> =</span><span style="color:#E45649;--shiki-dark:#E5C07B"> strategies</span><span style="color:#383A42;--shiki-dark:#ABB2BF">.</span><span style="color:#4078F2;--shiki-dark:#61AFEF">stream</span><span style="color:#383A42;--shiki-dark:#ABB2BF">()</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">                .</span><span style="color:#4078F2;--shiki-dark:#61AFEF">collect</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(</span><span style="color:#E45649;--shiki-dark:#E5C07B">Collectors</span><span style="color:#383A42;--shiki-dark:#ABB2BF">.</span><span style="color:#4078F2;--shiki-dark:#61AFEF">toMap</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">                        s </span><span style="color:#C18401;--shiki-dark:#C678DD">-&gt;</span><span style="color:#E45649;--shiki-dark:#E5C07B"> s</span><span style="color:#383A42;--shiki-dark:#ABB2BF">.</span><span style="color:#4078F2;--shiki-dark:#61AFEF">getProvider</span><span style="color:#383A42;--shiki-dark:#ABB2BF">().</span><span style="color:#4078F2;--shiki-dark:#61AFEF">toLowerCase</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(),</span></span>
<span class="line"><span style="color:#E45649;--shiki-dark:#E5C07B">                        Function</span><span style="color:#383A42;--shiki-dark:#ABB2BF">.</span><span style="color:#4078F2;--shiki-dark:#61AFEF">identity</span><span style="color:#383A42;--shiki-dark:#ABB2BF">()</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">                ));</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">    }</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#E06C75">    </span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">    public</span><span style="color:#C18401;--shiki-dark:#E5C07B"> OAuthStrategy</span><span style="color:#4078F2;--shiki-dark:#61AFEF"> getStrategy</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(</span><span style="color:#C18401;--shiki-dark:#E5C07B">String</span><span style="color:#383A42;--shiki-light-font-style:inherit;--shiki-dark:#E06C75;--shiki-dark-font-style:italic"> provider</span><span style="color:#383A42;--shiki-dark:#ABB2BF">)</span><span style="color:#383A42;--shiki-dark:#ABB2BF"> {</span></span>
<span class="line"><span style="color:#C18401;--shiki-dark:#E5C07B">        OAuthStrategy</span><span style="color:#E45649;--shiki-dark:#E06C75"> strategy</span><span style="color:#383A42;--shiki-dark:#56B6C2"> =</span><span style="color:#E45649;--shiki-dark:#E5C07B"> strategyMap</span><span style="color:#383A42;--shiki-dark:#ABB2BF">.</span><span style="color:#4078F2;--shiki-dark:#61AFEF">get</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(</span><span style="color:#E45649;--shiki-dark:#E5C07B">provider</span><span style="color:#383A42;--shiki-dark:#ABB2BF">.</span><span style="color:#4078F2;--shiki-dark:#61AFEF">toLowerCase</span><span style="color:#383A42;--shiki-dark:#ABB2BF">());</span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">        if</span><span style="color:#383A42;--shiki-dark:#ABB2BF"> (strategy </span><span style="color:#383A42;--shiki-dark:#56B6C2">==</span><span style="color:#986801;--shiki-dark:#D19A66"> null</span><span style="color:#383A42;--shiki-dark:#ABB2BF">) {</span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">            throw</span><span style="color:#A626A4;--shiki-dark:#C678DD"> new</span><span style="color:#4078F2;--shiki-dark:#61AFEF"> IllegalArgumentException</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(</span><span style="color:#50A14F;--shiki-dark:#98C379">"不支持的OAuth平台: "</span><span style="color:#383A42;--shiki-dark:#56B6C2"> +</span><span style="color:#383A42;--shiki-dark:#ABB2BF"> provider);</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">        }</span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">        return</span><span style="color:#383A42;--shiki-dark:#ABB2BF"> strategy;</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">    }</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">}</span></span></code></pre>
<p><strong>优势</strong>：</p>
<ul>
<li class="">✅ 利用 Spring 依赖注入自动发现策略类</li>
<li class="">✅ 统一的获取入口，便于管理</li>
<li class="">✅ 支持运行时动态切换策略</li>
</ul>
<hr>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="五核心代码实现">五、核心代码实现<a href="https://blog.xiaoxiaoliang.top/en/blog/java-third-party-login#%E4%BA%94%E6%A0%B8%E5%BF%83%E4%BB%A3%E7%A0%81%E5%AE%9E%E7%8E%B0" class="hash-link" aria-label="Direct link to 五、核心代码实现" title="Direct link to 五、核心代码实现" translate="no">​</a></h2>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="51-配置文件">5.1 配置文件<a href="https://blog.xiaoxiaoliang.top/en/blog/java-third-party-login#51-%E9%85%8D%E7%BD%AE%E6%96%87%E4%BB%B6" class="hash-link" aria-label="Direct link to 5.1 配置文件" title="Direct link to 5.1 配置文件" translate="no">​</a></h3>
<pre tabindex="0" class="codeBlockStandalone_MEMb thin-scrollbar language-yaml codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#0f172a;--prism-background-color:#f1f5f9"><code class="codeBlockLines_e6Vv"><span class="line"><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic"># application.yaml</span></span>
<span class="line"><span style="color:#E45649;--shiki-dark:#E06C75">server</span><span style="color:#383A42;--shiki-dark:#ABB2BF">:</span></span>
<span class="line"><span style="color:#E45649;--shiki-dark:#E06C75">  port</span><span style="color:#383A42;--shiki-dark:#ABB2BF">: </span><span style="color:#986801;--shiki-dark:#D19A66">8001</span></span>
<span class="line"><span style="color:#E45649;--shiki-dark:#E06C75">  servlet</span><span style="color:#383A42;--shiki-dark:#ABB2BF">:</span></span>
<span class="line"><span style="color:#E45649;--shiki-dark:#E06C75">    context-path</span><span style="color:#383A42;--shiki-dark:#ABB2BF">: </span><span style="color:#50A14F;--shiki-dark:#98C379">/api/user</span></span>
<span class="line"></span>
<span class="line"><span style="color:#E45649;--shiki-dark:#E06C75">spring</span><span style="color:#383A42;--shiki-dark:#ABB2BF">:</span></span>
<span class="line"><span style="color:#E45649;--shiki-dark:#E06C75">  data</span><span style="color:#383A42;--shiki-dark:#ABB2BF">:</span></span>
<span class="line"><span style="color:#E45649;--shiki-dark:#E06C75">    redis</span><span style="color:#383A42;--shiki-dark:#ABB2BF">:</span></span>
<span class="line"><span style="color:#E45649;--shiki-dark:#E06C75">      host</span><span style="color:#383A42;--shiki-dark:#ABB2BF">: </span><span style="color:#50A14F;--shiki-dark:#98C379">localhost</span></span>
<span class="line"><span style="color:#E45649;--shiki-dark:#E06C75">      port</span><span style="color:#383A42;--shiki-dark:#ABB2BF">: </span><span style="color:#986801;--shiki-dark:#D19A66">6379</span></span>
<span class="line"></span>
<span class="line"><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic"># OAuth 配置</span></span>
<span class="line"><span style="color:#E45649;--shiki-dark:#E06C75">oauth</span><span style="color:#383A42;--shiki-dark:#ABB2BF">:</span></span>
<span class="line"><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic">  # 安全配置</span></span>
<span class="line"><span style="color:#E45649;--shiki-dark:#E06C75">  security</span><span style="color:#383A42;--shiki-dark:#ABB2BF">:</span></span>
<span class="line"><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic">    # AES 加密密钥（32字节），生产环境必须通过环境变量设置</span></span>
<span class="line"><span style="color:#E45649;--shiki-dark:#E06C75">    encryption-key</span><span style="color:#383A42;--shiki-dark:#ABB2BF">: </span><span style="color:#50A14F;--shiki-dark:#98C379">${OAUTH_ENCRYPTION_KEY:default32ByteSecretKeyForDev123}</span></span>
<span class="line"><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic">    # State 过期时间（秒）</span></span>
<span class="line"><span style="color:#E45649;--shiki-dark:#E06C75">    state-ttl</span><span style="color:#383A42;--shiki-dark:#ABB2BF">: </span><span style="color:#986801;--shiki-dark:#D19A66">300</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">  </span></span>
<span class="line"><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic">  # GitHub 配置</span></span>
<span class="line"><span style="color:#E45649;--shiki-dark:#E06C75">  github</span><span style="color:#383A42;--shiki-dark:#ABB2BF">:</span></span>
<span class="line"><span style="color:#E45649;--shiki-dark:#E06C75">    client-id</span><span style="color:#383A42;--shiki-dark:#ABB2BF">: </span><span style="color:#50A14F;--shiki-dark:#98C379">${GITHUB_CLIENT_ID:your-client-id}</span></span>
<span class="line"><span style="color:#E45649;--shiki-dark:#E06C75">    client-secret</span><span style="color:#383A42;--shiki-dark:#ABB2BF">: </span><span style="color:#50A14F;--shiki-dark:#98C379">${GITHUB_CLIENT_SECRET:your-client-secret}</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">  </span></span>
<span class="line"><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic">  # Google 配置（可选）</span></span>
<span class="line"><span style="color:#E45649;--shiki-dark:#E06C75">  google</span><span style="color:#383A42;--shiki-dark:#ABB2BF">:</span></span>
<span class="line"><span style="color:#E45649;--shiki-dark:#E06C75">    client-id</span><span style="color:#383A42;--shiki-dark:#ABB2BF">: </span><span style="color:#50A14F;--shiki-dark:#98C379">${GOOGLE_CLIENT_ID:}</span></span>
<span class="line"><span style="color:#E45649;--shiki-dark:#E06C75">    client-secret</span><span style="color:#383A42;--shiki-dark:#ABB2BF">: </span><span style="color:#50A14F;--shiki-dark:#98C379">${GOOGLE_CLIENT_SECRET:}</span></span></code></pre>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="52-数据模型">5.2 数据模型<a href="https://blog.xiaoxiaoliang.top/en/blog/java-third-party-login#52-%E6%95%B0%E6%8D%AE%E6%A8%A1%E5%9E%8B" class="hash-link" aria-label="Direct link to 5.2 数据模型" title="Direct link to 5.2 数据模型" translate="no">​</a></h3>
<h4 class="anchor anchorTargetStickyNavbar_Vzrq" id="oauth-用户信息中间对象">OAuth 用户信息（中间对象）<a href="https://blog.xiaoxiaoliang.top/en/blog/java-third-party-login#oauth-%E7%94%A8%E6%88%B7%E4%BF%A1%E6%81%AF%E4%B8%AD%E9%97%B4%E5%AF%B9%E8%B1%A1" class="hash-link" aria-label="Direct link to OAuth 用户信息（中间对象）" title="Direct link to OAuth 用户信息（中间对象）" translate="no">​</a></h4>
<pre tabindex="0" class="codeBlockStandalone_MEMb thin-scrollbar language-java codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#0f172a;--prism-background-color:#f1f5f9"><code class="codeBlockLines_e6Vv"><span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">@</span><span style="color:#A626A4;--shiki-dark:#E5C07B">Data</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">@</span><span style="color:#A626A4;--shiki-dark:#E5C07B">Builder</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">@</span><span style="color:#A626A4;--shiki-dark:#E5C07B">NoArgsConstructor</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">@</span><span style="color:#A626A4;--shiki-dark:#E5C07B">AllArgsConstructor</span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">public</span><span style="color:#A626A4;--shiki-dark:#C678DD"> class</span><span style="color:#C18401;--shiki-dark:#E5C07B"> OAuthUserInfo</span><span style="color:#383A42;--shiki-dark:#ABB2BF"> {</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#E06C75">    </span></span>
<span class="line"><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic">    /** 第三方平台用户ID */</span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">    private</span><span style="color:#C18401;--shiki-dark:#E5C07B"> String</span><span style="color:#E45649;--shiki-dark:#E06C75"> providerUserId</span><span style="color:#383A42;--shiki-dark:#ABB2BF">;</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#E06C75">    </span></span>
<span class="line"><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic">    /** 第三方平台用户名 */</span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">    private</span><span style="color:#C18401;--shiki-dark:#E5C07B"> String</span><span style="color:#E45649;--shiki-dark:#E06C75"> providerUsername</span><span style="color:#383A42;--shiki-dark:#ABB2BF">;</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#E06C75">    </span></span>
<span class="line"><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic">    /** 邮箱 */</span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">    private</span><span style="color:#C18401;--shiki-dark:#E5C07B"> String</span><span style="color:#E45649;--shiki-dark:#E06C75"> email</span><span style="color:#383A42;--shiki-dark:#ABB2BF">;</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#E06C75">    </span></span>
<span class="line"><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic">    /** 头像URL */</span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">    private</span><span style="color:#C18401;--shiki-dark:#E5C07B"> String</span><span style="color:#E45649;--shiki-dark:#E06C75"> avatarUrl</span><span style="color:#383A42;--shiki-dark:#ABB2BF">;</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#E06C75">    </span></span>
<span class="line"><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic">    /** 访问令牌（临时持有，存储前需加密） */</span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">    private</span><span style="color:#C18401;--shiki-dark:#E5C07B"> String</span><span style="color:#E45649;--shiki-dark:#E06C75"> accessToken</span><span style="color:#383A42;--shiki-dark:#ABB2BF">;</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">}</span></span></code></pre>
<h4 class="anchor anchorTargetStickyNavbar_Vzrq" id="state-载荷">State 载荷<a href="https://blog.xiaoxiaoliang.top/en/blog/java-third-party-login#state-%E8%BD%BD%E8%8D%B7" class="hash-link" aria-label="Direct link to State 载荷" title="Direct link to State 载荷" translate="no">​</a></h4>
<pre tabindex="0" class="codeBlockStandalone_MEMb thin-scrollbar language-java codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#0f172a;--prism-background-color:#f1f5f9"><code class="codeBlockLines_e6Vv"><span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">@</span><span style="color:#A626A4;--shiki-dark:#E5C07B">Data</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">@</span><span style="color:#A626A4;--shiki-dark:#E5C07B">Builder</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">@</span><span style="color:#A626A4;--shiki-dark:#E5C07B">NoArgsConstructor</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">@</span><span style="color:#A626A4;--shiki-dark:#E5C07B">AllArgsConstructor</span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">public</span><span style="color:#A626A4;--shiki-dark:#C678DD"> class</span><span style="color:#C18401;--shiki-dark:#E5C07B"> OAuthStatePayload</span><span style="color:#383A42;--shiki-dark:#ABB2BF"> {</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#E06C75">    </span></span>
<span class="line"><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic">    /** 操作类型：LOGIN（登录）或 BIND（绑定） */</span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">    private</span><span style="color:#C18401;--shiki-dark:#E5C07B"> OAuthStateType</span><span style="color:#E45649;--shiki-dark:#E06C75"> type</span><span style="color:#383A42;--shiki-dark:#ABB2BF">;</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#E06C75">    </span></span>
<span class="line"><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic">    /** 用户ID（绑定场景使用） */</span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">    private</span><span style="color:#C18401;--shiki-dark:#E5C07B"> Long</span><span style="color:#E45649;--shiki-dark:#E06C75"> userId</span><span style="color:#383A42;--shiki-dark:#ABB2BF">;</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#E06C75">    </span></span>
<span class="line"><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic">    /** OAuth 平台 */</span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">    private</span><span style="color:#C18401;--shiki-dark:#E5C07B"> String</span><span style="color:#E45649;--shiki-dark:#E06C75"> provider</span><span style="color:#383A42;--shiki-dark:#ABB2BF">;</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#E06C75">    </span></span>
<span class="line"><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic">    /** 创建时间戳 */</span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">    private</span><span style="color:#C18401;--shiki-dark:#E5C07B"> Long</span><span style="color:#E45649;--shiki-dark:#E06C75"> timestamp</span><span style="color:#383A42;--shiki-dark:#ABB2BF">;</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">}</span></span></code></pre>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="53-github-策略实现">5.3 GitHub 策略实现<a href="https://blog.xiaoxiaoliang.top/en/blog/java-third-party-login#53-github-%E7%AD%96%E7%95%A5%E5%AE%9E%E7%8E%B0" class="hash-link" aria-label="Direct link to 5.3 GitHub 策略实现" title="Direct link to 5.3 GitHub 策略实现" translate="no">​</a></h3>
<pre tabindex="0" class="codeBlockStandalone_MEMb thin-scrollbar language-java codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#0f172a;--prism-background-color:#f1f5f9"><code class="codeBlockLines_e6Vv"><span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">@</span><span style="color:#A626A4;--shiki-dark:#E5C07B">Slf4j</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">@</span><span style="color:#A626A4;--shiki-dark:#E5C07B">Component</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">@</span><span style="color:#A626A4;--shiki-dark:#E5C07B">RequiredArgsConstructor</span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">public</span><span style="color:#A626A4;--shiki-dark:#C678DD"> class</span><span style="color:#C18401;--shiki-dark:#E5C07B"> GitHubOAuthStrategy</span><span style="color:#A626A4;--shiki-dark:#C678DD"> implements</span><span style="color:#C18401;--shiki-dark:#E5C07B"> OAuthStrategy</span><span style="color:#383A42;--shiki-dark:#ABB2BF"> {</span></span>
<span class="line"></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">    private</span><span style="color:#A626A4;--shiki-dark:#C678DD"> final</span><span style="color:#C18401;--shiki-dark:#E5C07B"> UserMapper</span><span style="color:#E45649;--shiki-dark:#E06C75"> userMapper</span><span style="color:#383A42;--shiki-dark:#ABB2BF">;</span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">    private</span><span style="color:#A626A4;--shiki-dark:#C678DD"> final</span><span style="color:#C18401;--shiki-dark:#E5C07B"> UserOAuthMapper</span><span style="color:#E45649;--shiki-dark:#E06C75"> userOAuthMapper</span><span style="color:#383A42;--shiki-dark:#ABB2BF">;</span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">    private</span><span style="color:#A626A4;--shiki-dark:#C678DD"> final</span><span style="color:#C18401;--shiki-dark:#E5C07B"> TokenEncryptionUtil</span><span style="color:#E45649;--shiki-dark:#E06C75"> tokenEncryptionUtil</span><span style="color:#383A42;--shiki-dark:#ABB2BF">;</span></span>
<span class="line"></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">    @</span><span style="color:#A626A4;--shiki-dark:#E5C07B">Value</span><span style="color:#383A42;--shiki-dark:#E06C75">(</span><span style="color:#50A14F;--shiki-dark:#98C379">"${oauth.github.client-id}"</span><span style="color:#383A42;--shiki-dark:#E06C75">)</span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">    private</span><span style="color:#C18401;--shiki-dark:#E5C07B"> String</span><span style="color:#E45649;--shiki-dark:#E06C75"> clientId</span><span style="color:#383A42;--shiki-dark:#ABB2BF">;</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#E06C75">    </span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">    @</span><span style="color:#A626A4;--shiki-dark:#E5C07B">Value</span><span style="color:#383A42;--shiki-dark:#E06C75">(</span><span style="color:#50A14F;--shiki-dark:#98C379">"${oauth.github.client-secret}"</span><span style="color:#383A42;--shiki-dark:#E06C75">)</span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">    private</span><span style="color:#C18401;--shiki-dark:#E5C07B"> String</span><span style="color:#E45649;--shiki-dark:#E06C75"> clientSecret</span><span style="color:#383A42;--shiki-dark:#ABB2BF">;</span></span>
<span class="line"></span>
<span class="line"><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic">    // GitHub OAuth 端点</span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">    private</span><span style="color:#A626A4;--shiki-dark:#C678DD"> static</span><span style="color:#A626A4;--shiki-dark:#C678DD"> final</span><span style="color:#C18401;--shiki-dark:#E5C07B"> String</span><span style="color:#E45649;--shiki-dark:#E06C75"> AUTHORIZE_URL </span><span style="color:#383A42;--shiki-dark:#56B6C2">=</span><span style="color:#50A14F;--shiki-dark:#98C379"> "https://github.com/login/oauth/authorize"</span><span style="color:#383A42;--shiki-dark:#ABB2BF">;</span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">    private</span><span style="color:#A626A4;--shiki-dark:#C678DD"> static</span><span style="color:#A626A4;--shiki-dark:#C678DD"> final</span><span style="color:#C18401;--shiki-dark:#E5C07B"> String</span><span style="color:#E45649;--shiki-dark:#E06C75"> ACCESS_TOKEN_URL </span><span style="color:#383A42;--shiki-dark:#56B6C2">=</span><span style="color:#50A14F;--shiki-dark:#98C379"> "https://github.com/login/oauth/access_token"</span><span style="color:#383A42;--shiki-dark:#ABB2BF">;</span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">    private</span><span style="color:#A626A4;--shiki-dark:#C678DD"> static</span><span style="color:#A626A4;--shiki-dark:#C678DD"> final</span><span style="color:#C18401;--shiki-dark:#E5C07B"> String</span><span style="color:#E45649;--shiki-dark:#E06C75"> USER_INFO_URL </span><span style="color:#383A42;--shiki-dark:#56B6C2">=</span><span style="color:#50A14F;--shiki-dark:#98C379"> "https://api.github.com/user"</span><span style="color:#383A42;--shiki-dark:#ABB2BF">;</span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">    private</span><span style="color:#A626A4;--shiki-dark:#C678DD"> static</span><span style="color:#A626A4;--shiki-dark:#C678DD"> final</span><span style="color:#C18401;--shiki-dark:#E5C07B"> String</span><span style="color:#E45649;--shiki-dark:#E06C75"> REDIRECT_URI </span><span style="color:#383A42;--shiki-dark:#56B6C2">=</span><span style="color:#50A14F;--shiki-dark:#98C379"> "http://localhost:8001/api/user/oauth/callback/github"</span><span style="color:#383A42;--shiki-dark:#ABB2BF">;</span></span>
<span class="line"></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">    @</span><span style="color:#A626A4;--shiki-dark:#E5C07B">Override</span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">    public</span><span style="color:#C18401;--shiki-dark:#E5C07B"> String</span><span style="color:#4078F2;--shiki-dark:#61AFEF"> getProvider</span><span style="color:#383A42;--shiki-dark:#ABB2BF">()</span><span style="color:#383A42;--shiki-dark:#ABB2BF"> {</span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">        return</span><span style="color:#50A14F;--shiki-dark:#98C379"> "github"</span><span style="color:#383A42;--shiki-dark:#ABB2BF">;</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">    }</span></span>
<span class="line"></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">    @</span><span style="color:#A626A4;--shiki-dark:#E5C07B">Override</span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">    public</span><span style="color:#C18401;--shiki-dark:#E5C07B"> String</span><span style="color:#4078F2;--shiki-dark:#61AFEF"> getAuthorizeUrl</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(</span><span style="color:#C18401;--shiki-dark:#E5C07B">String</span><span style="color:#383A42;--shiki-light-font-style:inherit;--shiki-dark:#E06C75;--shiki-dark-font-style:italic"> state</span><span style="color:#383A42;--shiki-dark:#ABB2BF">)</span><span style="color:#383A42;--shiki-dark:#ABB2BF"> {</span></span>
<span class="line"><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic">        // 构建授权 URL，包含 state 参数防止 CSRF</span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">        return</span><span style="color:#E45649;--shiki-dark:#E5C07B"> String</span><span style="color:#383A42;--shiki-dark:#ABB2BF">.</span><span style="color:#4078F2;--shiki-dark:#61AFEF">format</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(</span></span>
<span class="line"><span style="color:#50A14F;--shiki-dark:#98C379">                "%s?client_id=%s&amp;redirect_uri=%s&amp;scope=user:email&amp;state=%s"</span><span style="color:#383A42;--shiki-dark:#ABB2BF">,</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">                AUTHORIZE_URL, clientId, REDIRECT_URI, state</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">        );</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">    }</span></span>
<span class="line"></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">    @</span><span style="color:#A626A4;--shiki-dark:#E5C07B">Override</span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">    public</span><span style="color:#C18401;--shiki-dark:#E5C07B"> OAuthUserInfo</span><span style="color:#4078F2;--shiki-dark:#61AFEF"> getUserInfo</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(</span><span style="color:#C18401;--shiki-dark:#E5C07B">String</span><span style="color:#383A42;--shiki-light-font-style:inherit;--shiki-dark:#E06C75;--shiki-dark-font-style:italic"> code</span><span style="color:#383A42;--shiki-dark:#ABB2BF">)</span><span style="color:#383A42;--shiki-dark:#ABB2BF"> {</span></span>
<span class="line"><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic">        // 第一步：用授权码换取 access_token</span></span>
<span class="line"><span style="color:#C18401;--shiki-dark:#E5C07B">        String</span><span style="color:#E45649;--shiki-dark:#E06C75"> accessToken</span><span style="color:#383A42;--shiki-dark:#56B6C2"> =</span><span style="color:#4078F2;--shiki-dark:#61AFEF"> exchangeCodeForToken</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(code);</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">        </span></span>
<span class="line"><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic">        // 第二步：用 access_token 获取用户信息</span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">        return</span><span style="color:#4078F2;--shiki-dark:#61AFEF"> fetchUserInfo</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(accessToken);</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">    }</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#E06C75">    </span></span>
<span class="line"><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic">    /**</span></span>
<span class="line"><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic">     * 用授权码换取 access_token</span></span>
<span class="line"><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic">     */</span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">    private</span><span style="color:#C18401;--shiki-dark:#E5C07B"> String</span><span style="color:#4078F2;--shiki-dark:#61AFEF"> exchangeCodeForToken</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(</span><span style="color:#C18401;--shiki-dark:#E5C07B">String</span><span style="color:#383A42;--shiki-light-font-style:inherit;--shiki-dark:#E06C75;--shiki-dark-font-style:italic"> code</span><span style="color:#383A42;--shiki-dark:#ABB2BF">)</span><span style="color:#383A42;--shiki-dark:#ABB2BF"> {</span></span>
<span class="line"><span style="color:#C18401;--shiki-dark:#E5C07B">        Map</span><span style="color:#E45649;--shiki-dark:#ABB2BF">&lt;</span><span style="color:#C18401;--shiki-dark:#E5C07B">String</span><span style="color:#E45649;--shiki-dark:#ABB2BF">, </span><span style="color:#C18401;--shiki-dark:#E5C07B">Object</span><span style="color:#E45649;--shiki-dark:#ABB2BF">&gt; </span><span style="color:#E45649;--shiki-dark:#E06C75">params</span><span style="color:#383A42;--shiki-dark:#56B6C2"> =</span><span style="color:#A626A4;--shiki-dark:#C678DD"> new</span><span style="color:#C18401;--shiki-dark:#E5C07B"> HashMap</span><span style="color:#383A42;--shiki-dark:#ABB2BF">&lt;&gt;();</span></span>
<span class="line"><span style="color:#E45649;--shiki-dark:#E5C07B">        params</span><span style="color:#383A42;--shiki-dark:#ABB2BF">.</span><span style="color:#4078F2;--shiki-dark:#61AFEF">put</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(</span><span style="color:#50A14F;--shiki-dark:#98C379">"client_id"</span><span style="color:#383A42;--shiki-dark:#ABB2BF">, clientId);</span></span>
<span class="line"><span style="color:#E45649;--shiki-dark:#E5C07B">        params</span><span style="color:#383A42;--shiki-dark:#ABB2BF">.</span><span style="color:#4078F2;--shiki-dark:#61AFEF">put</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(</span><span style="color:#50A14F;--shiki-dark:#98C379">"client_secret"</span><span style="color:#383A42;--shiki-dark:#ABB2BF">, clientSecret);</span></span>
<span class="line"><span style="color:#E45649;--shiki-dark:#E5C07B">        params</span><span style="color:#383A42;--shiki-dark:#ABB2BF">.</span><span style="color:#4078F2;--shiki-dark:#61AFEF">put</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(</span><span style="color:#50A14F;--shiki-dark:#98C379">"code"</span><span style="color:#383A42;--shiki-dark:#ABB2BF">, code);</span></span>
<span class="line"></span>
<span class="line"><span style="color:#C18401;--shiki-dark:#E5C07B">        String</span><span style="color:#E45649;--shiki-dark:#E06C75"> response</span><span style="color:#383A42;--shiki-dark:#56B6C2"> =</span><span style="color:#E45649;--shiki-dark:#E5C07B"> HttpUtil</span><span style="color:#383A42;--shiki-dark:#ABB2BF">.</span><span style="color:#4078F2;--shiki-dark:#61AFEF">post</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(ACCESS_TOKEN_URL, params);</span></span>
<span class="line"><span style="color:#E45649;--shiki-dark:#E5C07B">        log</span><span style="color:#383A42;--shiki-dark:#ABB2BF">.</span><span style="color:#4078F2;--shiki-dark:#61AFEF">debug</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(</span><span style="color:#50A14F;--shiki-dark:#98C379">"GitHub token 请求完成"</span><span style="color:#383A42;--shiki-dark:#ABB2BF">);  </span><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic">// 不要打印响应内容！</span></span>
<span class="line"></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">        return</span><span style="color:#4078F2;--shiki-dark:#61AFEF"> parseAccessToken</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(response);</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">    }</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#E06C75">    </span></span>
<span class="line"><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic">    /**</span></span>
<span class="line"><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic">     * 解析 access_token</span></span>
<span class="line"><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic">     * GitHub 可能返回两种格式：URL 编码 或 JSON</span></span>
<span class="line"><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic">     */</span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">    private</span><span style="color:#C18401;--shiki-dark:#E5C07B"> String</span><span style="color:#4078F2;--shiki-dark:#61AFEF"> parseAccessToken</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(</span><span style="color:#C18401;--shiki-dark:#E5C07B">String</span><span style="color:#383A42;--shiki-light-font-style:inherit;--shiki-dark:#E06C75;--shiki-dark-font-style:italic"> response</span><span style="color:#383A42;--shiki-dark:#ABB2BF">)</span><span style="color:#383A42;--shiki-dark:#ABB2BF"> {</span></span>
<span class="line"><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic">        // 尝试 JSON 格式</span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">        try</span><span style="color:#383A42;--shiki-dark:#ABB2BF"> {</span></span>
<span class="line"><span style="color:#C18401;--shiki-dark:#E5C07B">            JSONObject</span><span style="color:#E45649;--shiki-dark:#E06C75"> json</span><span style="color:#383A42;--shiki-dark:#56B6C2"> =</span><span style="color:#E45649;--shiki-dark:#E5C07B"> JSON</span><span style="color:#383A42;--shiki-dark:#ABB2BF">.</span><span style="color:#4078F2;--shiki-dark:#61AFEF">parseObject</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(response);</span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">            if</span><span style="color:#383A42;--shiki-dark:#ABB2BF"> (</span><span style="color:#E45649;--shiki-dark:#E5C07B">json</span><span style="color:#383A42;--shiki-dark:#ABB2BF">.</span><span style="color:#4078F2;--shiki-dark:#61AFEF">containsKey</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(</span><span style="color:#50A14F;--shiki-dark:#98C379">"access_token"</span><span style="color:#383A42;--shiki-dark:#ABB2BF">)) {</span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">                return</span><span style="color:#E45649;--shiki-dark:#E5C07B"> json</span><span style="color:#383A42;--shiki-dark:#ABB2BF">.</span><span style="color:#4078F2;--shiki-dark:#61AFEF">getString</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(</span><span style="color:#50A14F;--shiki-dark:#98C379">"access_token"</span><span style="color:#383A42;--shiki-dark:#ABB2BF">);</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">            }</span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">            if</span><span style="color:#383A42;--shiki-dark:#ABB2BF"> (</span><span style="color:#E45649;--shiki-dark:#E5C07B">json</span><span style="color:#383A42;--shiki-dark:#ABB2BF">.</span><span style="color:#4078F2;--shiki-dark:#61AFEF">containsKey</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(</span><span style="color:#50A14F;--shiki-dark:#98C379">"error"</span><span style="color:#383A42;--shiki-dark:#ABB2BF">)) {</span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">                throw</span><span style="color:#A626A4;--shiki-dark:#C678DD"> new</span><span style="color:#4078F2;--shiki-dark:#61AFEF"> RuntimeException</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(</span><span style="color:#50A14F;--shiki-dark:#98C379">"GitHub 授权失败: "</span><span style="color:#383A42;--shiki-dark:#56B6C2"> +</span><span style="color:#E45649;--shiki-dark:#E5C07B"> json</span><span style="color:#383A42;--shiki-dark:#ABB2BF">.</span><span style="color:#4078F2;--shiki-dark:#61AFEF">getString</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(</span><span style="color:#50A14F;--shiki-dark:#98C379">"error_description"</span><span style="color:#383A42;--shiki-dark:#ABB2BF">));</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">            }</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">        } </span><span style="color:#A626A4;--shiki-dark:#C678DD">catch</span><span style="color:#383A42;--shiki-dark:#ABB2BF"> (</span><span style="color:#C18401;--shiki-dark:#E5C07B">JSONException</span><span style="color:#383A42;--shiki-light-font-style:inherit;--shiki-dark:#E06C75;--shiki-dark-font-style:italic"> ignored</span><span style="color:#383A42;--shiki-dark:#ABB2BF">) {}</span></span>
<span class="line"></span>
<span class="line"><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic">        // URL 编码格式: access_token=xxx&amp;token_type=bearer&amp;scope=user:email</span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">        for</span><span style="color:#383A42;--shiki-dark:#ABB2BF"> (</span><span style="color:#C18401;--shiki-dark:#E5C07B">String</span><span style="color:#E45649;--shiki-dark:#E06C75"> param</span><span style="color:#A626A4;--shiki-dark:#C678DD"> :</span><span style="color:#E45649;--shiki-dark:#E5C07B"> response</span><span style="color:#383A42;--shiki-dark:#ABB2BF">.</span><span style="color:#4078F2;--shiki-dark:#61AFEF">split</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(</span><span style="color:#50A14F;--shiki-dark:#98C379">"&amp;"</span><span style="color:#383A42;--shiki-dark:#ABB2BF">)) {</span></span>
<span class="line"><span style="color:#C18401;--shiki-dark:#E5C07B">            String</span><span style="color:#E45649;--shiki-dark:#ABB2BF">[] </span><span style="color:#E45649;--shiki-dark:#E06C75">pair</span><span style="color:#383A42;--shiki-dark:#56B6C2"> =</span><span style="color:#E45649;--shiki-dark:#E5C07B"> param</span><span style="color:#383A42;--shiki-dark:#ABB2BF">.</span><span style="color:#4078F2;--shiki-dark:#61AFEF">split</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(</span><span style="color:#50A14F;--shiki-dark:#98C379">"="</span><span style="color:#383A42;--shiki-dark:#ABB2BF">, </span><span style="color:#986801;--shiki-dark:#D19A66">2</span><span style="color:#383A42;--shiki-dark:#ABB2BF">);</span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">            if</span><span style="color:#383A42;--shiki-dark:#ABB2BF"> (</span><span style="color:#E45649;--shiki-dark:#E5C07B">pair</span><span style="color:#383A42;--shiki-dark:#ABB2BF">.</span><span style="color:#E45649;--shiki-dark:#E5C07B">length</span><span style="color:#383A42;--shiki-dark:#56B6C2"> ==</span><span style="color:#986801;--shiki-dark:#D19A66"> 2</span><span style="color:#383A42;--shiki-dark:#56B6C2"> &amp;&amp;</span><span style="color:#50A14F;--shiki-dark:#98C379"> "access_token"</span><span style="color:#383A42;--shiki-dark:#ABB2BF">.</span><span style="color:#4078F2;--shiki-dark:#61AFEF">equals</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(pair[</span><span style="color:#986801;--shiki-dark:#D19A66">0</span><span style="color:#383A42;--shiki-dark:#ABB2BF">])) {</span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">                return</span><span style="color:#383A42;--shiki-dark:#ABB2BF"> pair[</span><span style="color:#986801;--shiki-dark:#D19A66">1</span><span style="color:#383A42;--shiki-dark:#ABB2BF">];</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">            }</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">        }</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">        </span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">        throw</span><span style="color:#A626A4;--shiki-dark:#C678DD"> new</span><span style="color:#4078F2;--shiki-dark:#61AFEF"> RuntimeException</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(</span><span style="color:#50A14F;--shiki-dark:#98C379">"无法解析 GitHub access_token"</span><span style="color:#383A42;--shiki-dark:#ABB2BF">);</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">    }</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#E06C75">    </span></span>
<span class="line"><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic">    /**</span></span>
<span class="line"><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic">     * 获取 GitHub 用户信息</span></span>
<span class="line"><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic">     */</span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">    private</span><span style="color:#C18401;--shiki-dark:#E5C07B"> OAuthUserInfo</span><span style="color:#4078F2;--shiki-dark:#61AFEF"> fetchUserInfo</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(</span><span style="color:#C18401;--shiki-dark:#E5C07B">String</span><span style="color:#383A42;--shiki-light-font-style:inherit;--shiki-dark:#E06C75;--shiki-dark-font-style:italic"> accessToken</span><span style="color:#383A42;--shiki-dark:#ABB2BF">)</span><span style="color:#383A42;--shiki-dark:#ABB2BF"> {</span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">        try</span><span style="color:#383A42;--shiki-dark:#ABB2BF"> (</span><span style="color:#C18401;--shiki-dark:#E5C07B">HttpResponse</span><span style="color:#E45649;--shiki-dark:#E06C75"> response</span><span style="color:#383A42;--shiki-dark:#56B6C2"> =</span><span style="color:#E45649;--shiki-dark:#E5C07B"> HttpRequest</span><span style="color:#383A42;--shiki-dark:#ABB2BF">.</span><span style="color:#4078F2;--shiki-dark:#61AFEF">get</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(USER_INFO_URL)</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">                .</span><span style="color:#4078F2;--shiki-dark:#61AFEF">header</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(</span><span style="color:#50A14F;--shiki-dark:#98C379">"Authorization"</span><span style="color:#383A42;--shiki-dark:#ABB2BF">, </span><span style="color:#50A14F;--shiki-dark:#98C379">"token "</span><span style="color:#383A42;--shiki-dark:#56B6C2"> +</span><span style="color:#383A42;--shiki-dark:#ABB2BF"> accessToken)</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">                .</span><span style="color:#4078F2;--shiki-dark:#61AFEF">header</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(</span><span style="color:#50A14F;--shiki-dark:#98C379">"Accept"</span><span style="color:#383A42;--shiki-dark:#ABB2BF">, </span><span style="color:#50A14F;--shiki-dark:#98C379">"application/json"</span><span style="color:#383A42;--shiki-dark:#ABB2BF">)</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">                .</span><span style="color:#4078F2;--shiki-dark:#61AFEF">execute</span><span style="color:#383A42;--shiki-dark:#ABB2BF">()) {</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">            </span></span>
<span class="line"><span style="color:#C18401;--shiki-dark:#E5C07B">            JSONObject</span><span style="color:#E45649;--shiki-dark:#E06C75"> user</span><span style="color:#383A42;--shiki-dark:#56B6C2"> =</span><span style="color:#E45649;--shiki-dark:#E5C07B"> JSON</span><span style="color:#383A42;--shiki-dark:#ABB2BF">.</span><span style="color:#4078F2;--shiki-dark:#61AFEF">parseObject</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(</span><span style="color:#E45649;--shiki-dark:#E5C07B">response</span><span style="color:#383A42;--shiki-dark:#ABB2BF">.</span><span style="color:#4078F2;--shiki-dark:#61AFEF">body</span><span style="color:#383A42;--shiki-dark:#ABB2BF">());</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">            </span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">            return</span><span style="color:#E45649;--shiki-dark:#E5C07B"> OAuthUserInfo</span><span style="color:#383A42;--shiki-dark:#ABB2BF">.</span><span style="color:#4078F2;--shiki-dark:#61AFEF">builder</span><span style="color:#383A42;--shiki-dark:#ABB2BF">()</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">                    .</span><span style="color:#4078F2;--shiki-dark:#61AFEF">providerUserId</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(</span><span style="color:#E45649;--shiki-dark:#E5C07B">user</span><span style="color:#383A42;--shiki-dark:#ABB2BF">.</span><span style="color:#4078F2;--shiki-dark:#61AFEF">getString</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(</span><span style="color:#50A14F;--shiki-dark:#98C379">"id"</span><span style="color:#383A42;--shiki-dark:#ABB2BF">))</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">                    .</span><span style="color:#4078F2;--shiki-dark:#61AFEF">providerUsername</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(</span><span style="color:#E45649;--shiki-dark:#E5C07B">user</span><span style="color:#383A42;--shiki-dark:#ABB2BF">.</span><span style="color:#4078F2;--shiki-dark:#61AFEF">getString</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(</span><span style="color:#50A14F;--shiki-dark:#98C379">"login"</span><span style="color:#383A42;--shiki-dark:#ABB2BF">))</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">                    .</span><span style="color:#4078F2;--shiki-dark:#61AFEF">email</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(</span><span style="color:#E45649;--shiki-dark:#E5C07B">user</span><span style="color:#383A42;--shiki-dark:#ABB2BF">.</span><span style="color:#4078F2;--shiki-dark:#61AFEF">getString</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(</span><span style="color:#50A14F;--shiki-dark:#98C379">"email"</span><span style="color:#383A42;--shiki-dark:#ABB2BF">))</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">                    .</span><span style="color:#4078F2;--shiki-dark:#61AFEF">avatarUrl</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(</span><span style="color:#E45649;--shiki-dark:#E5C07B">user</span><span style="color:#383A42;--shiki-dark:#ABB2BF">.</span><span style="color:#4078F2;--shiki-dark:#61AFEF">getString</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(</span><span style="color:#50A14F;--shiki-dark:#98C379">"avatar_url"</span><span style="color:#383A42;--shiki-dark:#ABB2BF">))</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">                    .</span><span style="color:#4078F2;--shiki-dark:#61AFEF">accessToken</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(accessToken)</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">                    .</span><span style="color:#4078F2;--shiki-dark:#61AFEF">build</span><span style="color:#383A42;--shiki-dark:#ABB2BF">();</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">        }</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">    }</span></span>
<span class="line"></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">    @</span><span style="color:#A626A4;--shiki-dark:#E5C07B">Override</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">    @</span><span style="color:#A626A4;--shiki-dark:#E5C07B">Transactional</span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">    public</span><span style="color:#C18401;--shiki-dark:#E5C07B"> Long</span><span style="color:#4078F2;--shiki-dark:#61AFEF"> createUserOAuth</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(</span><span style="color:#C18401;--shiki-dark:#E5C07B">OAuthUserInfo</span><span style="color:#383A42;--shiki-light-font-style:inherit;--shiki-dark:#E06C75;--shiki-dark-font-style:italic"> info</span><span style="color:#383A42;--shiki-dark:#ABB2BF">)</span><span style="color:#383A42;--shiki-dark:#ABB2BF"> {</span></span>
<span class="line"><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic">        // 1. 创建用户记录</span></span>
<span class="line"><span style="color:#C18401;--shiki-dark:#E5C07B">        User</span><span style="color:#E45649;--shiki-dark:#E06C75"> user</span><span style="color:#383A42;--shiki-dark:#56B6C2"> =</span><span style="color:#E45649;--shiki-dark:#E5C07B"> User</span><span style="color:#383A42;--shiki-dark:#ABB2BF">.</span><span style="color:#4078F2;--shiki-dark:#61AFEF">builder</span><span style="color:#383A42;--shiki-dark:#ABB2BF">()</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">                .</span><span style="color:#4078F2;--shiki-dark:#61AFEF">username</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(</span><span style="color:#E45649;--shiki-dark:#E5C07B">info</span><span style="color:#383A42;--shiki-dark:#ABB2BF">.</span><span style="color:#4078F2;--shiki-dark:#61AFEF">getProviderUsername</span><span style="color:#383A42;--shiki-dark:#ABB2BF">())</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">                .</span><span style="color:#4078F2;--shiki-dark:#61AFEF">email</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(</span><span style="color:#E45649;--shiki-dark:#E5C07B">info</span><span style="color:#383A42;--shiki-dark:#ABB2BF">.</span><span style="color:#4078F2;--shiki-dark:#61AFEF">getEmail</span><span style="color:#383A42;--shiki-dark:#ABB2BF">())</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">                .</span><span style="color:#4078F2;--shiki-dark:#61AFEF">nickname</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(</span><span style="color:#E45649;--shiki-dark:#E5C07B">info</span><span style="color:#383A42;--shiki-dark:#ABB2BF">.</span><span style="color:#4078F2;--shiki-dark:#61AFEF">getProviderUsername</span><span style="color:#383A42;--shiki-dark:#ABB2BF">())</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">                .</span><span style="color:#4078F2;--shiki-dark:#61AFEF">avatar</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(</span><span style="color:#E45649;--shiki-dark:#E5C07B">info</span><span style="color:#383A42;--shiki-dark:#ABB2BF">.</span><span style="color:#4078F2;--shiki-dark:#61AFEF">getAvatarUrl</span><span style="color:#383A42;--shiki-dark:#ABB2BF">())</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">                .</span><span style="color:#4078F2;--shiki-dark:#61AFEF">build</span><span style="color:#383A42;--shiki-dark:#ABB2BF">();</span></span>
<span class="line"><span style="color:#E45649;--shiki-dark:#E5C07B">        userMapper</span><span style="color:#383A42;--shiki-dark:#ABB2BF">.</span><span style="color:#4078F2;--shiki-dark:#61AFEF">insert</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(user);</span></span>
<span class="line"><span style="color:#E45649;--shiki-dark:#E5C07B">        log</span><span style="color:#383A42;--shiki-dark:#ABB2BF">.</span><span style="color:#4078F2;--shiki-dark:#61AFEF">info</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(</span><span style="color:#50A14F;--shiki-dark:#98C379">"创建新用户: {}"</span><span style="color:#383A42;--shiki-dark:#ABB2BF">, </span><span style="color:#E45649;--shiki-dark:#E5C07B">user</span><span style="color:#383A42;--shiki-dark:#ABB2BF">.</span><span style="color:#4078F2;--shiki-dark:#61AFEF">getUsername</span><span style="color:#383A42;--shiki-dark:#ABB2BF">());</span></span>
<span class="line"></span>
<span class="line"><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic">        // 2. 加密 token 并创建绑定关系</span></span>
<span class="line"><span style="color:#C18401;--shiki-dark:#E5C07B">        String</span><span style="color:#E45649;--shiki-dark:#E06C75"> encryptedToken</span><span style="color:#383A42;--shiki-dark:#56B6C2"> =</span><span style="color:#E45649;--shiki-dark:#E5C07B"> tokenEncryptionUtil</span><span style="color:#383A42;--shiki-dark:#ABB2BF">.</span><span style="color:#4078F2;--shiki-dark:#61AFEF">encrypt</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(</span><span style="color:#E45649;--shiki-dark:#E5C07B">info</span><span style="color:#383A42;--shiki-dark:#ABB2BF">.</span><span style="color:#4078F2;--shiki-dark:#61AFEF">getAccessToken</span><span style="color:#383A42;--shiki-dark:#ABB2BF">());</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">        </span></span>
<span class="line"><span style="color:#C18401;--shiki-dark:#E5C07B">        UserOAuth</span><span style="color:#E45649;--shiki-dark:#E06C75"> binding</span><span style="color:#383A42;--shiki-dark:#56B6C2"> =</span><span style="color:#E45649;--shiki-dark:#E5C07B"> UserOAuth</span><span style="color:#383A42;--shiki-dark:#ABB2BF">.</span><span style="color:#4078F2;--shiki-dark:#61AFEF">builder</span><span style="color:#383A42;--shiki-dark:#ABB2BF">()</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">                .</span><span style="color:#4078F2;--shiki-dark:#61AFEF">userId</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(</span><span style="color:#E45649;--shiki-dark:#E5C07B">user</span><span style="color:#383A42;--shiki-dark:#ABB2BF">.</span><span style="color:#4078F2;--shiki-dark:#61AFEF">getId</span><span style="color:#383A42;--shiki-dark:#ABB2BF">())</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">                .</span><span style="color:#4078F2;--shiki-dark:#61AFEF">provider</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(</span><span style="color:#50A14F;--shiki-dark:#98C379">"github"</span><span style="color:#383A42;--shiki-dark:#ABB2BF">)</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">                .</span><span style="color:#4078F2;--shiki-dark:#61AFEF">providerUserId</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(</span><span style="color:#E45649;--shiki-dark:#E5C07B">info</span><span style="color:#383A42;--shiki-dark:#ABB2BF">.</span><span style="color:#4078F2;--shiki-dark:#61AFEF">getProviderUserId</span><span style="color:#383A42;--shiki-dark:#ABB2BF">())</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">                .</span><span style="color:#4078F2;--shiki-dark:#61AFEF">providerUsername</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(</span><span style="color:#E45649;--shiki-dark:#E5C07B">info</span><span style="color:#383A42;--shiki-dark:#ABB2BF">.</span><span style="color:#4078F2;--shiki-dark:#61AFEF">getProviderUsername</span><span style="color:#383A42;--shiki-dark:#ABB2BF">())</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">                .</span><span style="color:#4078F2;--shiki-dark:#61AFEF">accessToken</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(encryptedToken)</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">                .</span><span style="color:#4078F2;--shiki-dark:#61AFEF">avatarUrl</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(</span><span style="color:#E45649;--shiki-dark:#E5C07B">info</span><span style="color:#383A42;--shiki-dark:#ABB2BF">.</span><span style="color:#4078F2;--shiki-dark:#61AFEF">getAvatarUrl</span><span style="color:#383A42;--shiki-dark:#ABB2BF">())</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">                .</span><span style="color:#4078F2;--shiki-dark:#61AFEF">email</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(</span><span style="color:#E45649;--shiki-dark:#E5C07B">info</span><span style="color:#383A42;--shiki-dark:#ABB2BF">.</span><span style="color:#4078F2;--shiki-dark:#61AFEF">getEmail</span><span style="color:#383A42;--shiki-dark:#ABB2BF">())</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">                .</span><span style="color:#4078F2;--shiki-dark:#61AFEF">build</span><span style="color:#383A42;--shiki-dark:#ABB2BF">();</span></span>
<span class="line"><span style="color:#E45649;--shiki-dark:#E5C07B">        userOAuthMapper</span><span style="color:#383A42;--shiki-dark:#ABB2BF">.</span><span style="color:#4078F2;--shiki-dark:#61AFEF">insert</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(binding);</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">        </span></span>
<span class="line"><span style="color:#E45649;--shiki-dark:#E5C07B">        log</span><span style="color:#383A42;--shiki-dark:#ABB2BF">.</span><span style="color:#4078F2;--shiki-dark:#61AFEF">info</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(</span><span style="color:#50A14F;--shiki-dark:#98C379">"用户 {} 绑定 GitHub 成功"</span><span style="color:#383A42;--shiki-dark:#ABB2BF">, </span><span style="color:#E45649;--shiki-dark:#E5C07B">user</span><span style="color:#383A42;--shiki-dark:#ABB2BF">.</span><span style="color:#4078F2;--shiki-dark:#61AFEF">getUsername</span><span style="color:#383A42;--shiki-dark:#ABB2BF">());</span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">        return</span><span style="color:#E45649;--shiki-dark:#E5C07B"> user</span><span style="color:#383A42;--shiki-dark:#ABB2BF">.</span><span style="color:#4078F2;--shiki-dark:#61AFEF">getId</span><span style="color:#383A42;--shiki-dark:#ABB2BF">();</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">    }</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">}</span></span></code></pre>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="54-service-层">5.4 Service 层<a href="https://blog.xiaoxiaoliang.top/en/blog/java-third-party-login#54-service-%E5%B1%82" class="hash-link" aria-label="Direct link to 5.4 Service 层" title="Direct link to 5.4 Service 层" translate="no">​</a></h3>
<pre tabindex="0" class="codeBlockStandalone_MEMb thin-scrollbar language-java codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#0f172a;--prism-background-color:#f1f5f9"><code class="codeBlockLines_e6Vv"><span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">@</span><span style="color:#A626A4;--shiki-dark:#E5C07B">Slf4j</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">@</span><span style="color:#A626A4;--shiki-dark:#E5C07B">Service</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">@</span><span style="color:#A626A4;--shiki-dark:#E5C07B">RequiredArgsConstructor</span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">public</span><span style="color:#A626A4;--shiki-dark:#C678DD"> class</span><span style="color:#C18401;--shiki-dark:#E5C07B"> OAuthServiceImpl</span><span style="color:#A626A4;--shiki-dark:#C678DD"> implements</span><span style="color:#C18401;--shiki-dark:#E5C07B"> OAuthService</span><span style="color:#383A42;--shiki-dark:#ABB2BF"> {</span></span>
<span class="line"></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">    private</span><span style="color:#A626A4;--shiki-dark:#C678DD"> final</span><span style="color:#C18401;--shiki-dark:#E5C07B"> OAuthStrategyFactory</span><span style="color:#E45649;--shiki-dark:#E06C75"> strategyFactory</span><span style="color:#383A42;--shiki-dark:#ABB2BF">;</span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">    private</span><span style="color:#A626A4;--shiki-dark:#C678DD"> final</span><span style="color:#C18401;--shiki-dark:#E5C07B"> OAuthStateService</span><span style="color:#E45649;--shiki-dark:#E06C75"> stateService</span><span style="color:#383A42;--shiki-dark:#ABB2BF">;</span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">    private</span><span style="color:#A626A4;--shiki-dark:#C678DD"> final</span><span style="color:#C18401;--shiki-dark:#E5C07B"> TokenEncryptionUtil</span><span style="color:#E45649;--shiki-dark:#E06C75"> tokenEncryptionUtil</span><span style="color:#383A42;--shiki-dark:#ABB2BF">;</span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">    private</span><span style="color:#A626A4;--shiki-dark:#C678DD"> final</span><span style="color:#C18401;--shiki-dark:#E5C07B"> UserOAuthMapper</span><span style="color:#E45649;--shiki-dark:#E06C75"> userOAuthMapper</span><span style="color:#383A42;--shiki-dark:#ABB2BF">;</span></span>
<span class="line"></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">    @</span><span style="color:#A626A4;--shiki-dark:#E5C07B">Override</span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">    public</span><span style="color:#C18401;--shiki-dark:#E5C07B"> String</span><span style="color:#4078F2;--shiki-dark:#61AFEF"> getAuthorizeUrl</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(</span><span style="color:#C18401;--shiki-dark:#E5C07B">String</span><span style="color:#383A42;--shiki-light-font-style:inherit;--shiki-dark:#E06C75;--shiki-dark-font-style:italic"> provider</span><span style="color:#383A42;--shiki-dark:#ABB2BF">)</span><span style="color:#383A42;--shiki-dark:#ABB2BF"> {</span></span>
<span class="line"><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic">        // 1. 生成 state 并存入 Redis</span></span>
<span class="line"><span style="color:#C18401;--shiki-dark:#E5C07B">        String</span><span style="color:#E45649;--shiki-dark:#E06C75"> state</span><span style="color:#383A42;--shiki-dark:#56B6C2"> =</span><span style="color:#E45649;--shiki-dark:#E5C07B"> stateService</span><span style="color:#383A42;--shiki-dark:#ABB2BF">.</span><span style="color:#4078F2;--shiki-dark:#61AFEF">generateLoginState</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(provider);</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">        </span></span>
<span class="line"><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic">        // 2. 获取策略并构建授权 URL</span></span>
<span class="line"><span style="color:#C18401;--shiki-dark:#E5C07B">        OAuthStrategy</span><span style="color:#E45649;--shiki-dark:#E06C75"> strategy</span><span style="color:#383A42;--shiki-dark:#56B6C2"> =</span><span style="color:#E45649;--shiki-dark:#E5C07B"> strategyFactory</span><span style="color:#383A42;--shiki-dark:#ABB2BF">.</span><span style="color:#4078F2;--shiki-dark:#61AFEF">getStrategy</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(provider);</span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">        return</span><span style="color:#E45649;--shiki-dark:#E5C07B"> strategy</span><span style="color:#383A42;--shiki-dark:#ABB2BF">.</span><span style="color:#4078F2;--shiki-dark:#61AFEF">getAuthorizeUrl</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(state);</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">    }</span></span>
<span class="line"></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">    @</span><span style="color:#A626A4;--shiki-dark:#E5C07B">Override</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">    @</span><span style="color:#A626A4;--shiki-dark:#E5C07B">Transactional</span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">    public</span><span style="color:#C18401;--shiki-dark:#E5C07B"> String</span><span style="color:#4078F2;--shiki-dark:#61AFEF"> handleCallback</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(</span><span style="color:#C18401;--shiki-dark:#E5C07B">String</span><span style="color:#383A42;--shiki-light-font-style:inherit;--shiki-dark:#E06C75;--shiki-dark-font-style:italic"> provider</span><span style="color:#383A42;--shiki-dark:#ABB2BF">, </span><span style="color:#C18401;--shiki-dark:#E5C07B">String</span><span style="color:#383A42;--shiki-light-font-style:inherit;--shiki-dark:#E06C75;--shiki-dark-font-style:italic"> code</span><span style="color:#383A42;--shiki-dark:#ABB2BF">, </span><span style="color:#C18401;--shiki-dark:#E5C07B">String</span><span style="color:#383A42;--shiki-light-font-style:inherit;--shiki-dark:#E06C75;--shiki-dark-font-style:italic"> state</span><span style="color:#383A42;--shiki-dark:#ABB2BF">)</span><span style="color:#383A42;--shiki-dark:#ABB2BF"> {</span></span>
<span class="line"><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic">        // 1. 验证 state（CSRF 防护）</span></span>
<span class="line"><span style="color:#C18401;--shiki-dark:#E5C07B">        OAuthStatePayload</span><span style="color:#E45649;--shiki-dark:#E06C75"> payload</span><span style="color:#383A42;--shiki-dark:#56B6C2"> =</span><span style="color:#E45649;--shiki-dark:#E5C07B"> stateService</span><span style="color:#383A42;--shiki-dark:#ABB2BF">.</span><span style="color:#4078F2;--shiki-dark:#61AFEF">validateAndConsumeState</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(state);</span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">        if</span><span style="color:#383A42;--shiki-dark:#ABB2BF"> (payload </span><span style="color:#383A42;--shiki-dark:#56B6C2">==</span><span style="color:#986801;--shiki-dark:#D19A66"> null</span><span style="color:#383A42;--shiki-dark:#ABB2BF">) {</span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">            throw</span><span style="color:#A626A4;--shiki-dark:#C678DD"> new</span><span style="color:#4078F2;--shiki-dark:#61AFEF"> ClientException</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(</span><span style="color:#50A14F;--shiki-dark:#98C379">"授权已过期，请重新登录"</span><span style="color:#383A42;--shiki-dark:#ABB2BF">);</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">        }</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">        </span></span>
<span class="line"><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic">        // 2. 验证 state 类型和平台</span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">        if</span><span style="color:#383A42;--shiki-dark:#ABB2BF"> (</span><span style="color:#E45649;--shiki-dark:#E5C07B">payload</span><span style="color:#383A42;--shiki-dark:#ABB2BF">.</span><span style="color:#4078F2;--shiki-dark:#61AFEF">getType</span><span style="color:#383A42;--shiki-dark:#ABB2BF">() </span><span style="color:#383A42;--shiki-dark:#56B6C2">!=</span><span style="color:#E45649;--shiki-dark:#E5C07B"> OAuthStateType</span><span style="color:#383A42;--shiki-dark:#ABB2BF">.</span><span style="color:#E45649;--shiki-dark:#E5C07B">LOGIN</span><span style="color:#383A42;--shiki-dark:#ABB2BF">) {</span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">            throw</span><span style="color:#A626A4;--shiki-dark:#C678DD"> new</span><span style="color:#4078F2;--shiki-dark:#61AFEF"> ClientException</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(</span><span style="color:#50A14F;--shiki-dark:#98C379">"无效的授权类型"</span><span style="color:#383A42;--shiki-dark:#ABB2BF">);</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">        }</span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">        if</span><span style="color:#383A42;--shiki-dark:#ABB2BF"> (</span><span style="color:#383A42;--shiki-dark:#56B6C2">!</span><span style="color:#E45649;--shiki-dark:#E5C07B">provider</span><span style="color:#383A42;--shiki-dark:#ABB2BF">.</span><span style="color:#4078F2;--shiki-dark:#61AFEF">equalsIgnoreCase</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(</span><span style="color:#E45649;--shiki-dark:#E5C07B">payload</span><span style="color:#383A42;--shiki-dark:#ABB2BF">.</span><span style="color:#4078F2;--shiki-dark:#61AFEF">getProvider</span><span style="color:#383A42;--shiki-dark:#ABB2BF">())) {</span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">            throw</span><span style="color:#A626A4;--shiki-dark:#C678DD"> new</span><span style="color:#4078F2;--shiki-dark:#61AFEF"> ClientException</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(</span><span style="color:#50A14F;--shiki-dark:#98C379">"授权来源不匹配"</span><span style="color:#383A42;--shiki-dark:#ABB2BF">);</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">        }</span></span>
<span class="line"></span>
<span class="line"><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic">        // 3. 获取第三方用户信息</span></span>
<span class="line"><span style="color:#C18401;--shiki-dark:#E5C07B">        OAuthStrategy</span><span style="color:#E45649;--shiki-dark:#E06C75"> strategy</span><span style="color:#383A42;--shiki-dark:#56B6C2"> =</span><span style="color:#E45649;--shiki-dark:#E5C07B"> strategyFactory</span><span style="color:#383A42;--shiki-dark:#ABB2BF">.</span><span style="color:#4078F2;--shiki-dark:#61AFEF">getStrategy</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(provider);</span></span>
<span class="line"><span style="color:#C18401;--shiki-dark:#E5C07B">        OAuthUserInfo</span><span style="color:#E45649;--shiki-dark:#E06C75"> userInfo</span><span style="color:#383A42;--shiki-dark:#56B6C2"> =</span><span style="color:#E45649;--shiki-dark:#E5C07B"> strategy</span><span style="color:#383A42;--shiki-dark:#ABB2BF">.</span><span style="color:#4078F2;--shiki-dark:#61AFEF">getUserInfo</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(code);</span></span>
<span class="line"></span>
<span class="line"><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic">        // 4. 查询是否已绑定</span></span>
<span class="line"><span style="color:#C18401;--shiki-dark:#E5C07B">        UserOAuth</span><span style="color:#E45649;--shiki-dark:#E06C75"> existing</span><span style="color:#383A42;--shiki-dark:#56B6C2"> =</span><span style="color:#E45649;--shiki-dark:#E5C07B"> userOAuthMapper</span><span style="color:#383A42;--shiki-dark:#ABB2BF">.</span><span style="color:#4078F2;--shiki-dark:#61AFEF">selectOne</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">                Wrappers.</span><span style="color:#383A42;--shiki-dark:#56B6C2">&lt;</span><span style="color:#383A42;--shiki-dark:#ABB2BF">UserOAuth</span><span style="color:#383A42;--shiki-dark:#56B6C2">&gt;</span><span style="color:#4078F2;--shiki-dark:#61AFEF">lambdaQuery</span><span style="color:#383A42;--shiki-dark:#ABB2BF">()</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">                        .</span><span style="color:#4078F2;--shiki-dark:#61AFEF">eq</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(UserOAuth</span><span style="color:#A626A4;--shiki-dark:#C678DD">::</span><span style="color:#383A42;--shiki-dark:#ABB2BF">getProvider, </span><span style="color:#E45649;--shiki-dark:#E5C07B">provider</span><span style="color:#383A42;--shiki-dark:#ABB2BF">.</span><span style="color:#4078F2;--shiki-dark:#61AFEF">toLowerCase</span><span style="color:#383A42;--shiki-dark:#ABB2BF">())</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">                        .</span><span style="color:#4078F2;--shiki-dark:#61AFEF">eq</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(UserOAuth</span><span style="color:#A626A4;--shiki-dark:#C678DD">::</span><span style="color:#383A42;--shiki-dark:#ABB2BF">getProviderUserId, </span><span style="color:#E45649;--shiki-dark:#E5C07B">userInfo</span><span style="color:#383A42;--shiki-dark:#ABB2BF">.</span><span style="color:#4078F2;--shiki-dark:#61AFEF">getProviderUserId</span><span style="color:#383A42;--shiki-dark:#ABB2BF">())</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">        );</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">        </span></span>
<span class="line"><span style="color:#C18401;--shiki-dark:#E5C07B">        Long</span><span style="color:#E45649;--shiki-dark:#E06C75"> userId</span><span style="color:#383A42;--shiki-dark:#ABB2BF">;</span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">        if</span><span style="color:#383A42;--shiki-dark:#ABB2BF"> (existing </span><span style="color:#383A42;--shiki-dark:#56B6C2">==</span><span style="color:#986801;--shiki-dark:#D19A66"> null</span><span style="color:#383A42;--shiki-dark:#ABB2BF">) {</span></span>
<span class="line"><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic">            // 新用户：创建账号并绑定</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">            userId </span><span style="color:#383A42;--shiki-dark:#56B6C2">=</span><span style="color:#E45649;--shiki-dark:#E5C07B"> strategy</span><span style="color:#383A42;--shiki-dark:#ABB2BF">.</span><span style="color:#4078F2;--shiki-dark:#61AFEF">createUserOAuth</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(userInfo);</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">        } </span><span style="color:#A626A4;--shiki-dark:#C678DD">else</span><span style="color:#383A42;--shiki-dark:#ABB2BF"> {</span></span>
<span class="line"><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic">            // 老用户：直接获取用户ID</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">            userId </span><span style="color:#383A42;--shiki-dark:#56B6C2">=</span><span style="color:#E45649;--shiki-dark:#E5C07B"> existing</span><span style="color:#383A42;--shiki-dark:#ABB2BF">.</span><span style="color:#4078F2;--shiki-dark:#61AFEF">getUserId</span><span style="color:#383A42;--shiki-dark:#ABB2BF">();</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">        }</span></span>
<span class="line"></span>
<span class="line"><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic">        // 5. 登录并返回 token</span></span>
<span class="line"><span style="color:#E45649;--shiki-dark:#E5C07B">        StpUtil</span><span style="color:#383A42;--shiki-dark:#ABB2BF">.</span><span style="color:#4078F2;--shiki-dark:#61AFEF">login</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(userId);</span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">        return</span><span style="color:#E45649;--shiki-dark:#E5C07B"> StpUtil</span><span style="color:#383A42;--shiki-dark:#ABB2BF">.</span><span style="color:#4078F2;--shiki-dark:#61AFEF">getTokenValue</span><span style="color:#383A42;--shiki-dark:#ABB2BF">();</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">    }</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#E06C75">    </span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">    @</span><span style="color:#A626A4;--shiki-dark:#E5C07B">Override</span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">    public</span><span style="color:#C18401;--shiki-dark:#E5C07B"> String</span><span style="color:#4078F2;--shiki-dark:#61AFEF"> getBindAuthorizeUrl</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(</span><span style="color:#C18401;--shiki-dark:#E5C07B">String</span><span style="color:#383A42;--shiki-light-font-style:inherit;--shiki-dark:#E06C75;--shiki-dark-font-style:italic"> provider</span><span style="color:#383A42;--shiki-dark:#ABB2BF">, </span><span style="color:#C18401;--shiki-dark:#E5C07B">Long</span><span style="color:#383A42;--shiki-light-font-style:inherit;--shiki-dark:#E06C75;--shiki-dark-font-style:italic"> userId</span><span style="color:#383A42;--shiki-dark:#ABB2BF">)</span><span style="color:#383A42;--shiki-dark:#ABB2BF"> {</span></span>
<span class="line"><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic">        // 绑定场景：state 中包含用户ID</span></span>
<span class="line"><span style="color:#C18401;--shiki-dark:#E5C07B">        String</span><span style="color:#E45649;--shiki-dark:#E06C75"> state</span><span style="color:#383A42;--shiki-dark:#56B6C2"> =</span><span style="color:#E45649;--shiki-dark:#E5C07B"> stateService</span><span style="color:#383A42;--shiki-dark:#ABB2BF">.</span><span style="color:#4078F2;--shiki-dark:#61AFEF">generateBindState</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(provider, userId);</span></span>
<span class="line"><span style="color:#C18401;--shiki-dark:#E5C07B">        OAuthStrategy</span><span style="color:#E45649;--shiki-dark:#E06C75"> strategy</span><span style="color:#383A42;--shiki-dark:#56B6C2"> =</span><span style="color:#E45649;--shiki-dark:#E5C07B"> strategyFactory</span><span style="color:#383A42;--shiki-dark:#ABB2BF">.</span><span style="color:#4078F2;--shiki-dark:#61AFEF">getStrategy</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(provider);</span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">        return</span><span style="color:#E45649;--shiki-dark:#E5C07B"> strategy</span><span style="color:#383A42;--shiki-dark:#ABB2BF">.</span><span style="color:#4078F2;--shiki-dark:#61AFEF">getAuthorizeUrl</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(state);</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">    }</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#E06C75">    </span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">    @</span><span style="color:#A626A4;--shiki-dark:#E5C07B">Override</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">    @</span><span style="color:#A626A4;--shiki-dark:#E5C07B">Transactional</span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">    public</span><span style="color:#A626A4;--shiki-dark:#C678DD"> void</span><span style="color:#4078F2;--shiki-dark:#61AFEF"> handleBindCallback</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(</span><span style="color:#C18401;--shiki-dark:#E5C07B">String</span><span style="color:#383A42;--shiki-light-font-style:inherit;--shiki-dark:#E06C75;--shiki-dark-font-style:italic"> provider</span><span style="color:#383A42;--shiki-dark:#ABB2BF">, </span><span style="color:#C18401;--shiki-dark:#E5C07B">String</span><span style="color:#383A42;--shiki-light-font-style:inherit;--shiki-dark:#E06C75;--shiki-dark-font-style:italic"> code</span><span style="color:#383A42;--shiki-dark:#ABB2BF">, </span><span style="color:#C18401;--shiki-dark:#E5C07B">String</span><span style="color:#383A42;--shiki-light-font-style:inherit;--shiki-dark:#E06C75;--shiki-dark-font-style:italic"> state</span><span style="color:#383A42;--shiki-dark:#ABB2BF">)</span><span style="color:#383A42;--shiki-dark:#ABB2BF"> {</span></span>
<span class="line"><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic">        // 1. 验证 state 并提取用户ID</span></span>
<span class="line"><span style="color:#C18401;--shiki-dark:#E5C07B">        OAuthStatePayload</span><span style="color:#E45649;--shiki-dark:#E06C75"> payload</span><span style="color:#383A42;--shiki-dark:#56B6C2"> =</span><span style="color:#E45649;--shiki-dark:#E5C07B"> stateService</span><span style="color:#383A42;--shiki-dark:#ABB2BF">.</span><span style="color:#4078F2;--shiki-dark:#61AFEF">validateAndConsumeState</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(state);</span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">        if</span><span style="color:#383A42;--shiki-dark:#ABB2BF"> (payload </span><span style="color:#383A42;--shiki-dark:#56B6C2">==</span><span style="color:#986801;--shiki-dark:#D19A66"> null</span><span style="color:#383A42;--shiki-dark:#56B6C2"> ||</span><span style="color:#E45649;--shiki-dark:#E5C07B"> payload</span><span style="color:#383A42;--shiki-dark:#ABB2BF">.</span><span style="color:#4078F2;--shiki-dark:#61AFEF">getType</span><span style="color:#383A42;--shiki-dark:#ABB2BF">() </span><span style="color:#383A42;--shiki-dark:#56B6C2">!=</span><span style="color:#E45649;--shiki-dark:#E5C07B"> OAuthStateType</span><span style="color:#383A42;--shiki-dark:#ABB2BF">.</span><span style="color:#E45649;--shiki-dark:#E5C07B">BIND</span><span style="color:#383A42;--shiki-dark:#ABB2BF">) {</span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">            throw</span><span style="color:#A626A4;--shiki-dark:#C678DD"> new</span><span style="color:#4078F2;--shiki-dark:#61AFEF"> ClientException</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(</span><span style="color:#50A14F;--shiki-dark:#98C379">"授权已过期，请重新绑定"</span><span style="color:#383A42;--shiki-dark:#ABB2BF">);</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">        }</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">        </span></span>
<span class="line"><span style="color:#C18401;--shiki-dark:#E5C07B">        Long</span><span style="color:#E45649;--shiki-dark:#E06C75"> userId</span><span style="color:#383A42;--shiki-dark:#56B6C2"> =</span><span style="color:#E45649;--shiki-dark:#E5C07B"> payload</span><span style="color:#383A42;--shiki-dark:#ABB2BF">.</span><span style="color:#4078F2;--shiki-dark:#61AFEF">getUserId</span><span style="color:#383A42;--shiki-dark:#ABB2BF">();</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">        </span></span>
<span class="line"><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic">        // 2. 获取第三方用户信息</span></span>
<span class="line"><span style="color:#C18401;--shiki-dark:#E5C07B">        OAuthStrategy</span><span style="color:#E45649;--shiki-dark:#E06C75"> strategy</span><span style="color:#383A42;--shiki-dark:#56B6C2"> =</span><span style="color:#E45649;--shiki-dark:#E5C07B"> strategyFactory</span><span style="color:#383A42;--shiki-dark:#ABB2BF">.</span><span style="color:#4078F2;--shiki-dark:#61AFEF">getStrategy</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(provider);</span></span>
<span class="line"><span style="color:#C18401;--shiki-dark:#E5C07B">        OAuthUserInfo</span><span style="color:#E45649;--shiki-dark:#E06C75"> userInfo</span><span style="color:#383A42;--shiki-dark:#56B6C2"> =</span><span style="color:#E45649;--shiki-dark:#E5C07B"> strategy</span><span style="color:#383A42;--shiki-dark:#ABB2BF">.</span><span style="color:#4078F2;--shiki-dark:#61AFEF">getUserInfo</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(code);</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">        </span></span>
<span class="line"><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic">        // 3. 检查是否已被其他用户绑定</span></span>
<span class="line"><span style="color:#C18401;--shiki-dark:#E5C07B">        UserOAuth</span><span style="color:#E45649;--shiki-dark:#E06C75"> existing</span><span style="color:#383A42;--shiki-dark:#56B6C2"> =</span><span style="color:#E45649;--shiki-dark:#E5C07B"> userOAuthMapper</span><span style="color:#383A42;--shiki-dark:#ABB2BF">.</span><span style="color:#4078F2;--shiki-dark:#61AFEF">selectOne</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">                Wrappers.</span><span style="color:#383A42;--shiki-dark:#56B6C2">&lt;</span><span style="color:#383A42;--shiki-dark:#ABB2BF">UserOAuth</span><span style="color:#383A42;--shiki-dark:#56B6C2">&gt;</span><span style="color:#4078F2;--shiki-dark:#61AFEF">lambdaQuery</span><span style="color:#383A42;--shiki-dark:#ABB2BF">()</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">                        .</span><span style="color:#4078F2;--shiki-dark:#61AFEF">eq</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(UserOAuth</span><span style="color:#A626A4;--shiki-dark:#C678DD">::</span><span style="color:#383A42;--shiki-dark:#ABB2BF">getProvider, </span><span style="color:#E45649;--shiki-dark:#E5C07B">provider</span><span style="color:#383A42;--shiki-dark:#ABB2BF">.</span><span style="color:#4078F2;--shiki-dark:#61AFEF">toLowerCase</span><span style="color:#383A42;--shiki-dark:#ABB2BF">())</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">                        .</span><span style="color:#4078F2;--shiki-dark:#61AFEF">eq</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(UserOAuth</span><span style="color:#A626A4;--shiki-dark:#C678DD">::</span><span style="color:#383A42;--shiki-dark:#ABB2BF">getProviderUserId, </span><span style="color:#E45649;--shiki-dark:#E5C07B">userInfo</span><span style="color:#383A42;--shiki-dark:#ABB2BF">.</span><span style="color:#4078F2;--shiki-dark:#61AFEF">getProviderUserId</span><span style="color:#383A42;--shiki-dark:#ABB2BF">())</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">        );</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">        </span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">        if</span><span style="color:#383A42;--shiki-dark:#ABB2BF"> (existing </span><span style="color:#383A42;--shiki-dark:#56B6C2">!=</span><span style="color:#986801;--shiki-dark:#D19A66"> null</span><span style="color:#383A42;--shiki-dark:#ABB2BF">) {</span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">            if</span><span style="color:#383A42;--shiki-dark:#ABB2BF"> (</span><span style="color:#E45649;--shiki-dark:#E5C07B">existing</span><span style="color:#383A42;--shiki-dark:#ABB2BF">.</span><span style="color:#4078F2;--shiki-dark:#61AFEF">getUserId</span><span style="color:#383A42;--shiki-dark:#ABB2BF">().</span><span style="color:#4078F2;--shiki-dark:#61AFEF">equals</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(userId)) {</span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">                throw</span><span style="color:#A626A4;--shiki-dark:#C678DD"> new</span><span style="color:#4078F2;--shiki-dark:#61AFEF"> ClientException</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(</span><span style="color:#50A14F;--shiki-dark:#98C379">"您已绑定该账号"</span><span style="color:#383A42;--shiki-dark:#ABB2BF">);</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">            } </span><span style="color:#A626A4;--shiki-dark:#C678DD">else</span><span style="color:#383A42;--shiki-dark:#ABB2BF"> {</span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">                throw</span><span style="color:#A626A4;--shiki-dark:#C678DD"> new</span><span style="color:#4078F2;--shiki-dark:#61AFEF"> ClientException</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(</span><span style="color:#50A14F;--shiki-dark:#98C379">"该账号已被其他用户绑定"</span><span style="color:#383A42;--shiki-dark:#ABB2BF">);</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">            }</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">        }</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">        </span></span>
<span class="line"><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic">        // 4. 创建绑定关系</span></span>
<span class="line"><span style="color:#C18401;--shiki-dark:#E5C07B">        String</span><span style="color:#E45649;--shiki-dark:#E06C75"> encryptedToken</span><span style="color:#383A42;--shiki-dark:#56B6C2"> =</span><span style="color:#E45649;--shiki-dark:#E5C07B"> tokenEncryptionUtil</span><span style="color:#383A42;--shiki-dark:#ABB2BF">.</span><span style="color:#4078F2;--shiki-dark:#61AFEF">encrypt</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(</span><span style="color:#E45649;--shiki-dark:#E5C07B">userInfo</span><span style="color:#383A42;--shiki-dark:#ABB2BF">.</span><span style="color:#4078F2;--shiki-dark:#61AFEF">getAccessToken</span><span style="color:#383A42;--shiki-dark:#ABB2BF">());</span></span>
<span class="line"><span style="color:#C18401;--shiki-dark:#E5C07B">        UserOAuth</span><span style="color:#E45649;--shiki-dark:#E06C75"> binding</span><span style="color:#383A42;--shiki-dark:#56B6C2"> =</span><span style="color:#E45649;--shiki-dark:#E5C07B"> UserOAuth</span><span style="color:#383A42;--shiki-dark:#ABB2BF">.</span><span style="color:#4078F2;--shiki-dark:#61AFEF">builder</span><span style="color:#383A42;--shiki-dark:#ABB2BF">()</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">                .</span><span style="color:#4078F2;--shiki-dark:#61AFEF">userId</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(userId)</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">                .</span><span style="color:#4078F2;--shiki-dark:#61AFEF">provider</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(</span><span style="color:#E45649;--shiki-dark:#E5C07B">provider</span><span style="color:#383A42;--shiki-dark:#ABB2BF">.</span><span style="color:#4078F2;--shiki-dark:#61AFEF">toLowerCase</span><span style="color:#383A42;--shiki-dark:#ABB2BF">())</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">                .</span><span style="color:#4078F2;--shiki-dark:#61AFEF">providerUserId</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(</span><span style="color:#E45649;--shiki-dark:#E5C07B">userInfo</span><span style="color:#383A42;--shiki-dark:#ABB2BF">.</span><span style="color:#4078F2;--shiki-dark:#61AFEF">getProviderUserId</span><span style="color:#383A42;--shiki-dark:#ABB2BF">())</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">                .</span><span style="color:#4078F2;--shiki-dark:#61AFEF">providerUsername</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(</span><span style="color:#E45649;--shiki-dark:#E5C07B">userInfo</span><span style="color:#383A42;--shiki-dark:#ABB2BF">.</span><span style="color:#4078F2;--shiki-dark:#61AFEF">getProviderUsername</span><span style="color:#383A42;--shiki-dark:#ABB2BF">())</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">                .</span><span style="color:#4078F2;--shiki-dark:#61AFEF">accessToken</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(encryptedToken)</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">                .</span><span style="color:#4078F2;--shiki-dark:#61AFEF">avatarUrl</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(</span><span style="color:#E45649;--shiki-dark:#E5C07B">userInfo</span><span style="color:#383A42;--shiki-dark:#ABB2BF">.</span><span style="color:#4078F2;--shiki-dark:#61AFEF">getAvatarUrl</span><span style="color:#383A42;--shiki-dark:#ABB2BF">())</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">                .</span><span style="color:#4078F2;--shiki-dark:#61AFEF">email</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(</span><span style="color:#E45649;--shiki-dark:#E5C07B">userInfo</span><span style="color:#383A42;--shiki-dark:#ABB2BF">.</span><span style="color:#4078F2;--shiki-dark:#61AFEF">getEmail</span><span style="color:#383A42;--shiki-dark:#ABB2BF">())</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">                .</span><span style="color:#4078F2;--shiki-dark:#61AFEF">build</span><span style="color:#383A42;--shiki-dark:#ABB2BF">();</span></span>
<span class="line"><span style="color:#E45649;--shiki-dark:#E5C07B">        userOAuthMapper</span><span style="color:#383A42;--shiki-dark:#ABB2BF">.</span><span style="color:#4078F2;--shiki-dark:#61AFEF">insert</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(binding);</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">        </span></span>
<span class="line"><span style="color:#E45649;--shiki-dark:#E5C07B">        log</span><span style="color:#383A42;--shiki-dark:#ABB2BF">.</span><span style="color:#4078F2;--shiki-dark:#61AFEF">info</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(</span><span style="color:#50A14F;--shiki-dark:#98C379">"用户 {} 绑定 {} 成功"</span><span style="color:#383A42;--shiki-dark:#ABB2BF">, userId, provider);</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">    }</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">}</span></span></code></pre>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="55-controller-层">5.5 Controller 层<a href="https://blog.xiaoxiaoliang.top/en/blog/java-third-party-login#55-controller-%E5%B1%82" class="hash-link" aria-label="Direct link to 5.5 Controller 层" title="Direct link to 5.5 Controller 层" translate="no">​</a></h3>
<pre tabindex="0" class="codeBlockStandalone_MEMb thin-scrollbar language-java codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#0f172a;--prism-background-color:#f1f5f9"><code class="codeBlockLines_e6Vv"><span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">@</span><span style="color:#A626A4;--shiki-dark:#E5C07B">Slf4j</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">@</span><span style="color:#A626A4;--shiki-dark:#E5C07B">RestController</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">@</span><span style="color:#A626A4;--shiki-dark:#E5C07B">RequestMapping</span><span style="color:#383A42;--shiki-dark:#E06C75">(</span><span style="color:#50A14F;--shiki-dark:#98C379">"/oauth"</span><span style="color:#383A42;--shiki-dark:#E06C75">)</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">@</span><span style="color:#A626A4;--shiki-dark:#E5C07B">RequiredArgsConstructor</span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">public</span><span style="color:#A626A4;--shiki-dark:#C678DD"> class</span><span style="color:#C18401;--shiki-dark:#E5C07B"> OAuthController</span><span style="color:#383A42;--shiki-dark:#ABB2BF"> {</span></span>
<span class="line"></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">    private</span><span style="color:#A626A4;--shiki-dark:#C678DD"> final</span><span style="color:#C18401;--shiki-dark:#E5C07B"> OAuthService</span><span style="color:#E45649;--shiki-dark:#E06C75"> oauthService</span><span style="color:#383A42;--shiki-dark:#ABB2BF">;</span></span>
<span class="line"></span>
<span class="line"><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic">    /**</span></span>
<span class="line"><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic">     * 发起 OAuth 授权</span></span>
<span class="line"><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic">     * GET /oauth/authorize/{provider}</span></span>
<span class="line"><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic">     */</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">    @</span><span style="color:#A626A4;--shiki-dark:#E5C07B">GetMapping</span><span style="color:#383A42;--shiki-dark:#E06C75">(</span><span style="color:#50A14F;--shiki-dark:#98C379">"/authorize/{provider}"</span><span style="color:#383A42;--shiki-dark:#E06C75">)</span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">    public</span><span style="color:#A626A4;--shiki-dark:#C678DD"> void</span><span style="color:#4078F2;--shiki-dark:#61AFEF"> authorize</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(@</span><span style="color:#A626A4;--shiki-dark:#E5C07B">PathVariable</span><span style="color:#C18401;--shiki-dark:#E5C07B"> String</span><span style="color:#383A42;--shiki-light-font-style:inherit;--shiki-dark:#E06C75;--shiki-dark-font-style:italic"> provider</span><span style="color:#383A42;--shiki-dark:#ABB2BF">, </span></span>
<span class="line"><span style="color:#C18401;--shiki-dark:#E5C07B">                          HttpServletResponse</span><span style="color:#383A42;--shiki-light-font-style:inherit;--shiki-dark:#E06C75;--shiki-dark-font-style:italic"> response</span><span style="color:#383A42;--shiki-dark:#ABB2BF">)</span><span style="color:#A626A4;--shiki-dark:#C678DD"> throws</span><span style="color:#C18401;--shiki-dark:#E5C07B"> IOException</span><span style="color:#383A42;--shiki-dark:#ABB2BF"> {</span></span>
<span class="line"><span style="color:#C18401;--shiki-dark:#E5C07B">        String</span><span style="color:#E45649;--shiki-dark:#E06C75"> authorizeUrl</span><span style="color:#383A42;--shiki-dark:#56B6C2"> =</span><span style="color:#E45649;--shiki-dark:#E5C07B"> oauthService</span><span style="color:#383A42;--shiki-dark:#ABB2BF">.</span><span style="color:#4078F2;--shiki-dark:#61AFEF">getAuthorizeUrl</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(provider);</span></span>
<span class="line"><span style="color:#E45649;--shiki-dark:#E5C07B">        response</span><span style="color:#383A42;--shiki-dark:#ABB2BF">.</span><span style="color:#4078F2;--shiki-dark:#61AFEF">sendRedirect</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(authorizeUrl);</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">    }</span></span>
<span class="line"></span>
<span class="line"><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic">    /**</span></span>
<span class="line"><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic">     * OAuth 回调处理</span></span>
<span class="line"><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic">     * GET /oauth/callback/{provider}?code=xxx&amp;state=xxx</span></span>
<span class="line"><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic">     */</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">    @</span><span style="color:#A626A4;--shiki-dark:#E5C07B">GetMapping</span><span style="color:#383A42;--shiki-dark:#E06C75">(</span><span style="color:#50A14F;--shiki-dark:#98C379">"/callback/{provider}"</span><span style="color:#383A42;--shiki-dark:#E06C75">)</span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">    public</span><span style="color:#A626A4;--shiki-dark:#C678DD"> void</span><span style="color:#4078F2;--shiki-dark:#61AFEF"> callback</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(@</span><span style="color:#A626A4;--shiki-dark:#E5C07B">PathVariable</span><span style="color:#C18401;--shiki-dark:#E5C07B"> String</span><span style="color:#383A42;--shiki-light-font-style:inherit;--shiki-dark:#E06C75;--shiki-dark-font-style:italic"> provider</span><span style="color:#383A42;--shiki-dark:#ABB2BF">,</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">                         @</span><span style="color:#A626A4;--shiki-dark:#E5C07B">RequestParam</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(</span><span style="color:#50A14F;--shiki-dark:#98C379">"code"</span><span style="color:#383A42;--shiki-dark:#ABB2BF">) </span><span style="color:#C18401;--shiki-dark:#E5C07B">String</span><span style="color:#383A42;--shiki-light-font-style:inherit;--shiki-dark:#E06C75;--shiki-dark-font-style:italic"> code</span><span style="color:#383A42;--shiki-dark:#ABB2BF">,</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">                         @</span><span style="color:#A626A4;--shiki-dark:#E5C07B">RequestParam</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(</span><span style="color:#986801;--shiki-dark:#D19A66">value</span><span style="color:#383A42;--shiki-dark:#56B6C2"> =</span><span style="color:#50A14F;--shiki-dark:#98C379"> "state"</span><span style="color:#383A42;--shiki-dark:#ABB2BF">, </span><span style="color:#986801;--shiki-dark:#D19A66">required</span><span style="color:#383A42;--shiki-dark:#56B6C2"> =</span><span style="color:#986801;--shiki-dark:#D19A66"> false</span><span style="color:#383A42;--shiki-dark:#ABB2BF">) </span><span style="color:#C18401;--shiki-dark:#E5C07B">String</span><span style="color:#383A42;--shiki-light-font-style:inherit;--shiki-dark:#E06C75;--shiki-dark-font-style:italic"> state</span><span style="color:#383A42;--shiki-dark:#ABB2BF">,</span></span>
<span class="line"><span style="color:#C18401;--shiki-dark:#E5C07B">                         HttpServletResponse</span><span style="color:#383A42;--shiki-light-font-style:inherit;--shiki-dark:#E06C75;--shiki-dark-font-style:italic"> response</span><span style="color:#383A42;--shiki-dark:#ABB2BF">)</span><span style="color:#A626A4;--shiki-dark:#C678DD"> throws</span><span style="color:#C18401;--shiki-dark:#E5C07B"> IOException</span><span style="color:#383A42;--shiki-dark:#ABB2BF"> {</span></span>
<span class="line"><span style="color:#E45649;--shiki-dark:#E5C07B">        log</span><span style="color:#383A42;--shiki-dark:#ABB2BF">.</span><span style="color:#4078F2;--shiki-dark:#61AFEF">info</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(</span><span style="color:#50A14F;--shiki-dark:#98C379">"OAuth 回调: provider={}"</span><span style="color:#383A42;--shiki-dark:#ABB2BF">, provider);</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">        </span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">        try</span><span style="color:#383A42;--shiki-dark:#ABB2BF"> {</span></span>
<span class="line"><span style="color:#C18401;--shiki-dark:#E5C07B">            String</span><span style="color:#E45649;--shiki-dark:#E06C75"> token</span><span style="color:#383A42;--shiki-dark:#56B6C2"> =</span><span style="color:#E45649;--shiki-dark:#E5C07B"> oauthService</span><span style="color:#383A42;--shiki-dark:#ABB2BF">.</span><span style="color:#4078F2;--shiki-dark:#61AFEF">handleCallback</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(provider, code, state);</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">            </span></span>
<span class="line"><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic">            // 重定向到前端，携带登录 token</span></span>
<span class="line"><span style="color:#E45649;--shiki-dark:#E5C07B">            response</span><span style="color:#383A42;--shiki-dark:#ABB2BF">.</span><span style="color:#4078F2;--shiki-dark:#61AFEF">sendRedirect</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(</span><span style="color:#50A14F;--shiki-dark:#98C379">"http://localhost:5173/oauth/callback?token="</span><span style="color:#383A42;--shiki-dark:#56B6C2"> +</span><span style="color:#383A42;--shiki-dark:#ABB2BF"> token);</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">            </span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">        } </span><span style="color:#A626A4;--shiki-dark:#C678DD">catch</span><span style="color:#383A42;--shiki-dark:#ABB2BF"> (</span><span style="color:#C18401;--shiki-dark:#E5C07B">Exception</span><span style="color:#383A42;--shiki-light-font-style:inherit;--shiki-dark:#E06C75;--shiki-dark-font-style:italic"> e</span><span style="color:#383A42;--shiki-dark:#ABB2BF">) {</span></span>
<span class="line"><span style="color:#E45649;--shiki-dark:#E5C07B">            log</span><span style="color:#383A42;--shiki-dark:#ABB2BF">.</span><span style="color:#4078F2;--shiki-dark:#61AFEF">error</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(</span><span style="color:#50A14F;--shiki-dark:#98C379">"OAuth 登录失败: {}"</span><span style="color:#383A42;--shiki-dark:#ABB2BF">, </span><span style="color:#E45649;--shiki-dark:#E5C07B">e</span><span style="color:#383A42;--shiki-dark:#ABB2BF">.</span><span style="color:#4078F2;--shiki-dark:#61AFEF">getMessage</span><span style="color:#383A42;--shiki-dark:#ABB2BF">());</span></span>
<span class="line"><span style="color:#E45649;--shiki-dark:#E5C07B">            response</span><span style="color:#383A42;--shiki-dark:#ABB2BF">.</span><span style="color:#4078F2;--shiki-dark:#61AFEF">sendRedirect</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(</span><span style="color:#50A14F;--shiki-dark:#98C379">"http://localhost:5173/login?error="</span><span style="color:#383A42;--shiki-dark:#56B6C2"> +</span><span style="color:#383A42;--shiki-dark:#ABB2BF"> </span></span>
<span class="line"><span style="color:#E45649;--shiki-dark:#E5C07B">                    URLEncoder</span><span style="color:#383A42;--shiki-dark:#ABB2BF">.</span><span style="color:#4078F2;--shiki-dark:#61AFEF">encode</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(</span><span style="color:#E45649;--shiki-dark:#E5C07B">e</span><span style="color:#383A42;--shiki-dark:#ABB2BF">.</span><span style="color:#4078F2;--shiki-dark:#61AFEF">getMessage</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(), </span><span style="color:#E45649;--shiki-dark:#E5C07B">StandardCharsets</span><span style="color:#383A42;--shiki-dark:#ABB2BF">.</span><span style="color:#E45649;--shiki-dark:#E5C07B">UTF_8</span><span style="color:#383A42;--shiki-dark:#ABB2BF">));</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">        }</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">    }</span></span>
<span class="line"></span>
<span class="line"><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic">    /**</span></span>
<span class="line"><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic">     * 绑定第三方账号（需登录）</span></span>
<span class="line"><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic">     */</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">    @</span><span style="color:#A626A4;--shiki-dark:#E5C07B">GetMapping</span><span style="color:#383A42;--shiki-dark:#E06C75">(</span><span style="color:#50A14F;--shiki-dark:#98C379">"/bind/{provider}"</span><span style="color:#383A42;--shiki-dark:#E06C75">)</span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">    public</span><span style="color:#A626A4;--shiki-dark:#C678DD"> void</span><span style="color:#4078F2;--shiki-dark:#61AFEF"> bind</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(@</span><span style="color:#A626A4;--shiki-dark:#E5C07B">PathVariable</span><span style="color:#C18401;--shiki-dark:#E5C07B"> String</span><span style="color:#383A42;--shiki-light-font-style:inherit;--shiki-dark:#E06C75;--shiki-dark-font-style:italic"> provider</span><span style="color:#383A42;--shiki-dark:#ABB2BF">, </span></span>
<span class="line"><span style="color:#C18401;--shiki-dark:#E5C07B">                     HttpServletResponse</span><span style="color:#383A42;--shiki-light-font-style:inherit;--shiki-dark:#E06C75;--shiki-dark-font-style:italic"> response</span><span style="color:#383A42;--shiki-dark:#ABB2BF">)</span><span style="color:#A626A4;--shiki-dark:#C678DD"> throws</span><span style="color:#C18401;--shiki-dark:#E5C07B"> IOException</span><span style="color:#383A42;--shiki-dark:#ABB2BF"> {</span></span>
<span class="line"><span style="color:#C18401;--shiki-dark:#E5C07B">        Long</span><span style="color:#E45649;--shiki-dark:#E06C75"> userId</span><span style="color:#383A42;--shiki-dark:#56B6C2"> =</span><span style="color:#E45649;--shiki-dark:#E5C07B"> StpUtil</span><span style="color:#383A42;--shiki-dark:#ABB2BF">.</span><span style="color:#4078F2;--shiki-dark:#61AFEF">getLoginIdAsLong</span><span style="color:#383A42;--shiki-dark:#ABB2BF">();</span></span>
<span class="line"><span style="color:#C18401;--shiki-dark:#E5C07B">        String</span><span style="color:#E45649;--shiki-dark:#E06C75"> authorizeUrl</span><span style="color:#383A42;--shiki-dark:#56B6C2"> =</span><span style="color:#E45649;--shiki-dark:#E5C07B"> oauthService</span><span style="color:#383A42;--shiki-dark:#ABB2BF">.</span><span style="color:#4078F2;--shiki-dark:#61AFEF">getBindAuthorizeUrl</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(provider, userId);</span></span>
<span class="line"><span style="color:#E45649;--shiki-dark:#E5C07B">        response</span><span style="color:#383A42;--shiki-dark:#ABB2BF">.</span><span style="color:#4078F2;--shiki-dark:#61AFEF">sendRedirect</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(authorizeUrl);</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">    }</span></span>
<span class="line"></span>
<span class="line"><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic">    /**</span></span>
<span class="line"><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic">     * 绑定回调处理</span></span>
<span class="line"><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic">     */</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">    @</span><span style="color:#A626A4;--shiki-dark:#E5C07B">GetMapping</span><span style="color:#383A42;--shiki-dark:#E06C75">(</span><span style="color:#50A14F;--shiki-dark:#98C379">"/bind/callback/{provider}"</span><span style="color:#383A42;--shiki-dark:#E06C75">)</span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">    public</span><span style="color:#A626A4;--shiki-dark:#C678DD"> void</span><span style="color:#4078F2;--shiki-dark:#61AFEF"> bindCallback</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(@</span><span style="color:#A626A4;--shiki-dark:#E5C07B">PathVariable</span><span style="color:#C18401;--shiki-dark:#E5C07B"> String</span><span style="color:#383A42;--shiki-light-font-style:inherit;--shiki-dark:#E06C75;--shiki-dark-font-style:italic"> provider</span><span style="color:#383A42;--shiki-dark:#ABB2BF">,</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">                             @</span><span style="color:#A626A4;--shiki-dark:#E5C07B">RequestParam</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(</span><span style="color:#50A14F;--shiki-dark:#98C379">"code"</span><span style="color:#383A42;--shiki-dark:#ABB2BF">) </span><span style="color:#C18401;--shiki-dark:#E5C07B">String</span><span style="color:#383A42;--shiki-light-font-style:inherit;--shiki-dark:#E06C75;--shiki-dark-font-style:italic"> code</span><span style="color:#383A42;--shiki-dark:#ABB2BF">,</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">                             @</span><span style="color:#A626A4;--shiki-dark:#E5C07B">RequestParam</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(</span><span style="color:#50A14F;--shiki-dark:#98C379">"state"</span><span style="color:#383A42;--shiki-dark:#ABB2BF">) </span><span style="color:#C18401;--shiki-dark:#E5C07B">String</span><span style="color:#383A42;--shiki-light-font-style:inherit;--shiki-dark:#E06C75;--shiki-dark-font-style:italic"> state</span><span style="color:#383A42;--shiki-dark:#ABB2BF">,</span></span>
<span class="line"><span style="color:#C18401;--shiki-dark:#E5C07B">                             HttpServletResponse</span><span style="color:#383A42;--shiki-light-font-style:inherit;--shiki-dark:#E06C75;--shiki-dark-font-style:italic"> response</span><span style="color:#383A42;--shiki-dark:#ABB2BF">)</span><span style="color:#A626A4;--shiki-dark:#C678DD"> throws</span><span style="color:#C18401;--shiki-dark:#E5C07B"> IOException</span><span style="color:#383A42;--shiki-dark:#ABB2BF"> {</span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">        try</span><span style="color:#383A42;--shiki-dark:#ABB2BF"> {</span></span>
<span class="line"><span style="color:#E45649;--shiki-dark:#E5C07B">            oauthService</span><span style="color:#383A42;--shiki-dark:#ABB2BF">.</span><span style="color:#4078F2;--shiki-dark:#61AFEF">handleBindCallback</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(provider, code, state);</span></span>
<span class="line"><span style="color:#E45649;--shiki-dark:#E5C07B">            response</span><span style="color:#383A42;--shiki-dark:#ABB2BF">.</span><span style="color:#4078F2;--shiki-dark:#61AFEF">sendRedirect</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(</span><span style="color:#50A14F;--shiki-dark:#98C379">"http://localhost:5173/settings?bind=success"</span><span style="color:#383A42;--shiki-dark:#ABB2BF">);</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">        } </span><span style="color:#A626A4;--shiki-dark:#C678DD">catch</span><span style="color:#383A42;--shiki-dark:#ABB2BF"> (</span><span style="color:#C18401;--shiki-dark:#E5C07B">Exception</span><span style="color:#383A42;--shiki-light-font-style:inherit;--shiki-dark:#E06C75;--shiki-dark-font-style:italic"> e</span><span style="color:#383A42;--shiki-dark:#ABB2BF">) {</span></span>
<span class="line"><span style="color:#E45649;--shiki-dark:#E5C07B">            log</span><span style="color:#383A42;--shiki-dark:#ABB2BF">.</span><span style="color:#4078F2;--shiki-dark:#61AFEF">error</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(</span><span style="color:#50A14F;--shiki-dark:#98C379">"绑定失败: {}"</span><span style="color:#383A42;--shiki-dark:#ABB2BF">, </span><span style="color:#E45649;--shiki-dark:#E5C07B">e</span><span style="color:#383A42;--shiki-dark:#ABB2BF">.</span><span style="color:#4078F2;--shiki-dark:#61AFEF">getMessage</span><span style="color:#383A42;--shiki-dark:#ABB2BF">());</span></span>
<span class="line"><span style="color:#E45649;--shiki-dark:#E5C07B">            response</span><span style="color:#383A42;--shiki-dark:#ABB2BF">.</span><span style="color:#4078F2;--shiki-dark:#61AFEF">sendRedirect</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(</span><span style="color:#50A14F;--shiki-dark:#98C379">"http://localhost:5173/settings?bind=error&amp;message="</span><span style="color:#383A42;--shiki-dark:#56B6C2"> +</span><span style="color:#383A42;--shiki-dark:#ABB2BF"> </span></span>
<span class="line"><span style="color:#E45649;--shiki-dark:#E5C07B">                    URLEncoder</span><span style="color:#383A42;--shiki-dark:#ABB2BF">.</span><span style="color:#4078F2;--shiki-dark:#61AFEF">encode</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(</span><span style="color:#E45649;--shiki-dark:#E5C07B">e</span><span style="color:#383A42;--shiki-dark:#ABB2BF">.</span><span style="color:#4078F2;--shiki-dark:#61AFEF">getMessage</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(), </span><span style="color:#E45649;--shiki-dark:#E5C07B">StandardCharsets</span><span style="color:#383A42;--shiki-dark:#ABB2BF">.</span><span style="color:#E45649;--shiki-dark:#E5C07B">UTF_8</span><span style="color:#383A42;--shiki-dark:#ABB2BF">));</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">        }</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">    }</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">}</span></span></code></pre>
<hr>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="六安全增强实现">六、安全增强实现<a href="https://blog.xiaoxiaoliang.top/en/blog/java-third-party-login#%E5%85%AD%E5%AE%89%E5%85%A8%E5%A2%9E%E5%BC%BA%E5%AE%9E%E7%8E%B0" class="hash-link" aria-label="Direct link to 六、安全增强实现" title="Direct link to 六、安全增强实现" translate="no">​</a></h2>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="61-state-管理服务">6.1 State 管理服务<a href="https://blog.xiaoxiaoliang.top/en/blog/java-third-party-login#61-state-%E7%AE%A1%E7%90%86%E6%9C%8D%E5%8A%A1" class="hash-link" aria-label="Direct link to 6.1 State 管理服务" title="Direct link to 6.1 State 管理服务" translate="no">​</a></h3>
<pre tabindex="0" class="codeBlockStandalone_MEMb thin-scrollbar language-java codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#0f172a;--prism-background-color:#f1f5f9"><code class="codeBlockLines_e6Vv"><span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">@</span><span style="color:#A626A4;--shiki-dark:#E5C07B">Slf4j</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">@</span><span style="color:#A626A4;--shiki-dark:#E5C07B">Service</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">@</span><span style="color:#A626A4;--shiki-dark:#E5C07B">RequiredArgsConstructor</span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">public</span><span style="color:#A626A4;--shiki-dark:#C678DD"> class</span><span style="color:#C18401;--shiki-dark:#E5C07B"> OAuthStateServiceImpl</span><span style="color:#A626A4;--shiki-dark:#C678DD"> implements</span><span style="color:#C18401;--shiki-dark:#E5C07B"> OAuthStateService</span><span style="color:#383A42;--shiki-dark:#ABB2BF"> {</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#E06C75">    </span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">    private</span><span style="color:#A626A4;--shiki-dark:#C678DD"> static</span><span style="color:#A626A4;--shiki-dark:#C678DD"> final</span><span style="color:#C18401;--shiki-dark:#E5C07B"> String</span><span style="color:#E45649;--shiki-dark:#E06C75"> STATE_KEY_PREFIX </span><span style="color:#383A42;--shiki-dark:#56B6C2">=</span><span style="color:#50A14F;--shiki-dark:#98C379"> "oauth:state:"</span><span style="color:#383A42;--shiki-dark:#ABB2BF">;</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#E06C75">    </span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">    private</span><span style="color:#A626A4;--shiki-dark:#C678DD"> final</span><span style="color:#C18401;--shiki-dark:#E5C07B"> StringRedisTemplate</span><span style="color:#E45649;--shiki-dark:#E06C75"> redisTemplate</span><span style="color:#383A42;--shiki-dark:#ABB2BF">;</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#E06C75">    </span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">    @</span><span style="color:#A626A4;--shiki-dark:#E5C07B">Value</span><span style="color:#383A42;--shiki-dark:#E06C75">(</span><span style="color:#50A14F;--shiki-dark:#98C379">"${oauth.security.state-ttl:300}"</span><span style="color:#383A42;--shiki-dark:#E06C75">)</span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">    private</span><span style="color:#A626A4;--shiki-dark:#C678DD"> long</span><span style="color:#E45649;--shiki-dark:#E06C75"> stateTtl</span><span style="color:#383A42;--shiki-dark:#ABB2BF">;</span></span>
<span class="line"></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">    @</span><span style="color:#A626A4;--shiki-dark:#E5C07B">Override</span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">    public</span><span style="color:#C18401;--shiki-dark:#E5C07B"> String</span><span style="color:#4078F2;--shiki-dark:#61AFEF"> generateLoginState</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(</span><span style="color:#C18401;--shiki-dark:#E5C07B">String</span><span style="color:#383A42;--shiki-light-font-style:inherit;--shiki-dark:#E06C75;--shiki-dark-font-style:italic"> provider</span><span style="color:#383A42;--shiki-dark:#ABB2BF">)</span><span style="color:#383A42;--shiki-dark:#ABB2BF"> {</span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">        return</span><span style="color:#4078F2;--shiki-dark:#61AFEF"> generateState</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(</span><span style="color:#E45649;--shiki-dark:#E5C07B">OAuthStateType</span><span style="color:#383A42;--shiki-dark:#ABB2BF">.</span><span style="color:#E45649;--shiki-dark:#E5C07B">LOGIN</span><span style="color:#383A42;--shiki-dark:#ABB2BF">, provider, </span><span style="color:#986801;--shiki-dark:#D19A66">null</span><span style="color:#383A42;--shiki-dark:#ABB2BF">);</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">    }</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#E06C75">    </span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">    @</span><span style="color:#A626A4;--shiki-dark:#E5C07B">Override</span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">    public</span><span style="color:#C18401;--shiki-dark:#E5C07B"> String</span><span style="color:#4078F2;--shiki-dark:#61AFEF"> generateBindState</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(</span><span style="color:#C18401;--shiki-dark:#E5C07B">String</span><span style="color:#383A42;--shiki-light-font-style:inherit;--shiki-dark:#E06C75;--shiki-dark-font-style:italic"> provider</span><span style="color:#383A42;--shiki-dark:#ABB2BF">, </span><span style="color:#C18401;--shiki-dark:#E5C07B">Long</span><span style="color:#383A42;--shiki-light-font-style:inherit;--shiki-dark:#E06C75;--shiki-dark-font-style:italic"> userId</span><span style="color:#383A42;--shiki-dark:#ABB2BF">)</span><span style="color:#383A42;--shiki-dark:#ABB2BF"> {</span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">        if</span><span style="color:#383A42;--shiki-dark:#ABB2BF"> (userId </span><span style="color:#383A42;--shiki-dark:#56B6C2">==</span><span style="color:#986801;--shiki-dark:#D19A66"> null</span><span style="color:#383A42;--shiki-dark:#ABB2BF">) {</span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">            throw</span><span style="color:#A626A4;--shiki-dark:#C678DD"> new</span><span style="color:#4078F2;--shiki-dark:#61AFEF"> IllegalArgumentException</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(</span><span style="color:#50A14F;--shiki-dark:#98C379">"绑定场景必须提供用户ID"</span><span style="color:#383A42;--shiki-dark:#ABB2BF">);</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">        }</span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">        return</span><span style="color:#4078F2;--shiki-dark:#61AFEF"> generateState</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(</span><span style="color:#E45649;--shiki-dark:#E5C07B">OAuthStateType</span><span style="color:#383A42;--shiki-dark:#ABB2BF">.</span><span style="color:#E45649;--shiki-dark:#E5C07B">BIND</span><span style="color:#383A42;--shiki-dark:#ABB2BF">, provider, userId);</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">    }</span></span>
<span class="line"></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">    @</span><span style="color:#A626A4;--shiki-dark:#E5C07B">Override</span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">    public</span><span style="color:#C18401;--shiki-dark:#E5C07B"> OAuthStatePayload</span><span style="color:#4078F2;--shiki-dark:#61AFEF"> validateAndConsumeState</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(</span><span style="color:#C18401;--shiki-dark:#E5C07B">String</span><span style="color:#383A42;--shiki-light-font-style:inherit;--shiki-dark:#E06C75;--shiki-dark-font-style:italic"> state</span><span style="color:#383A42;--shiki-dark:#ABB2BF">)</span><span style="color:#383A42;--shiki-dark:#ABB2BF"> {</span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">        if</span><span style="color:#383A42;--shiki-dark:#ABB2BF"> (state </span><span style="color:#383A42;--shiki-dark:#56B6C2">==</span><span style="color:#986801;--shiki-dark:#D19A66"> null</span><span style="color:#383A42;--shiki-dark:#56B6C2"> ||</span><span style="color:#E45649;--shiki-dark:#E5C07B"> state</span><span style="color:#383A42;--shiki-dark:#ABB2BF">.</span><span style="color:#4078F2;--shiki-dark:#61AFEF">isBlank</span><span style="color:#383A42;--shiki-dark:#ABB2BF">()) {</span></span>
<span class="line"><span style="color:#E45649;--shiki-dark:#E5C07B">            log</span><span style="color:#383A42;--shiki-dark:#ABB2BF">.</span><span style="color:#4078F2;--shiki-dark:#61AFEF">warn</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(</span><span style="color:#50A14F;--shiki-dark:#98C379">"State 参数为空"</span><span style="color:#383A42;--shiki-dark:#ABB2BF">);</span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">            return</span><span style="color:#986801;--shiki-dark:#D19A66"> null</span><span style="color:#383A42;--shiki-dark:#ABB2BF">;</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">        }</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">        </span></span>
<span class="line"><span style="color:#C18401;--shiki-dark:#E5C07B">        String</span><span style="color:#E45649;--shiki-dark:#E06C75"> key</span><span style="color:#383A42;--shiki-dark:#56B6C2"> =</span><span style="color:#383A42;--shiki-dark:#ABB2BF"> STATE_KEY_PREFIX </span><span style="color:#383A42;--shiki-dark:#56B6C2">+</span><span style="color:#383A42;--shiki-dark:#ABB2BF"> state;</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">        </span></span>
<span class="line"><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic">        // 获取并删除（原子操作，确保一次性使用）</span></span>
<span class="line"><span style="color:#C18401;--shiki-dark:#E5C07B">        String</span><span style="color:#E45649;--shiki-dark:#E06C75"> json</span><span style="color:#383A42;--shiki-dark:#56B6C2"> =</span><span style="color:#E45649;--shiki-dark:#E5C07B"> redisTemplate</span><span style="color:#383A42;--shiki-dark:#ABB2BF">.</span><span style="color:#4078F2;--shiki-dark:#61AFEF">opsForValue</span><span style="color:#383A42;--shiki-dark:#ABB2BF">().</span><span style="color:#4078F2;--shiki-dark:#61AFEF">getAndDelete</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(key);</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">        </span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">        if</span><span style="color:#383A42;--shiki-dark:#ABB2BF"> (json </span><span style="color:#383A42;--shiki-dark:#56B6C2">==</span><span style="color:#986801;--shiki-dark:#D19A66"> null</span><span style="color:#383A42;--shiki-dark:#ABB2BF">) {</span></span>
<span class="line"><span style="color:#E45649;--shiki-dark:#E5C07B">            log</span><span style="color:#383A42;--shiki-dark:#ABB2BF">.</span><span style="color:#4078F2;--shiki-dark:#61AFEF">warn</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(</span><span style="color:#50A14F;--shiki-dark:#98C379">"State 无效或已过期: {}"</span><span style="color:#383A42;--shiki-dark:#ABB2BF">, state);</span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">            return</span><span style="color:#986801;--shiki-dark:#D19A66"> null</span><span style="color:#383A42;--shiki-dark:#ABB2BF">;</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">        }</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">        </span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">        try</span><span style="color:#383A42;--shiki-dark:#ABB2BF"> {</span></span>
<span class="line"><span style="color:#C18401;--shiki-dark:#E5C07B">            OAuthStatePayload</span><span style="color:#E45649;--shiki-dark:#E06C75"> payload</span><span style="color:#383A42;--shiki-dark:#56B6C2"> =</span><span style="color:#E45649;--shiki-dark:#E5C07B"> JSON</span><span style="color:#383A42;--shiki-dark:#ABB2BF">.</span><span style="color:#4078F2;--shiki-dark:#61AFEF">parseObject</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(json, </span><span style="color:#E45649;--shiki-dark:#E5C07B">OAuthStatePayload</span><span style="color:#383A42;--shiki-dark:#ABB2BF">.</span><span style="color:#E45649;--shiki-dark:#E5C07B">class</span><span style="color:#383A42;--shiki-dark:#ABB2BF">);</span></span>
<span class="line"><span style="color:#E45649;--shiki-dark:#E5C07B">            log</span><span style="color:#383A42;--shiki-dark:#ABB2BF">.</span><span style="color:#4078F2;--shiki-dark:#61AFEF">info</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(</span><span style="color:#50A14F;--shiki-dark:#98C379">"State 验证成功: type={}, provider={}"</span><span style="color:#383A42;--shiki-dark:#ABB2BF">, </span></span>
<span class="line"><span style="color:#E45649;--shiki-dark:#E5C07B">                    payload</span><span style="color:#383A42;--shiki-dark:#ABB2BF">.</span><span style="color:#4078F2;--shiki-dark:#61AFEF">getType</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(), </span><span style="color:#E45649;--shiki-dark:#E5C07B">payload</span><span style="color:#383A42;--shiki-dark:#ABB2BF">.</span><span style="color:#4078F2;--shiki-dark:#61AFEF">getProvider</span><span style="color:#383A42;--shiki-dark:#ABB2BF">());</span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">            return</span><span style="color:#383A42;--shiki-dark:#ABB2BF"> payload;</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">        } </span><span style="color:#A626A4;--shiki-dark:#C678DD">catch</span><span style="color:#383A42;--shiki-dark:#ABB2BF"> (</span><span style="color:#C18401;--shiki-dark:#E5C07B">Exception</span><span style="color:#383A42;--shiki-light-font-style:inherit;--shiki-dark:#E06C75;--shiki-dark-font-style:italic"> e</span><span style="color:#383A42;--shiki-dark:#ABB2BF">) {</span></span>
<span class="line"><span style="color:#E45649;--shiki-dark:#E5C07B">            log</span><span style="color:#383A42;--shiki-dark:#ABB2BF">.</span><span style="color:#4078F2;--shiki-dark:#61AFEF">error</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(</span><span style="color:#50A14F;--shiki-dark:#98C379">"State 解析失败"</span><span style="color:#383A42;--shiki-dark:#ABB2BF">, e);</span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">            return</span><span style="color:#986801;--shiki-dark:#D19A66"> null</span><span style="color:#383A42;--shiki-dark:#ABB2BF">;</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">        }</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">    }</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#E06C75">    </span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">    private</span><span style="color:#C18401;--shiki-dark:#E5C07B"> String</span><span style="color:#4078F2;--shiki-dark:#61AFEF"> generateState</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(</span><span style="color:#C18401;--shiki-dark:#E5C07B">OAuthStateType</span><span style="color:#383A42;--shiki-light-font-style:inherit;--shiki-dark:#E06C75;--shiki-dark-font-style:italic"> type</span><span style="color:#383A42;--shiki-dark:#ABB2BF">, </span><span style="color:#C18401;--shiki-dark:#E5C07B">String</span><span style="color:#383A42;--shiki-light-font-style:inherit;--shiki-dark:#E06C75;--shiki-dark-font-style:italic"> provider</span><span style="color:#383A42;--shiki-dark:#ABB2BF">, </span><span style="color:#C18401;--shiki-dark:#E5C07B">Long</span><span style="color:#383A42;--shiki-light-font-style:inherit;--shiki-dark:#E06C75;--shiki-dark-font-style:italic"> userId</span><span style="color:#383A42;--shiki-dark:#ABB2BF">)</span><span style="color:#383A42;--shiki-dark:#ABB2BF"> {</span></span>
<span class="line"><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic">        // 生成 32 位随机字符串</span></span>
<span class="line"><span style="color:#C18401;--shiki-dark:#E5C07B">        String</span><span style="color:#E45649;--shiki-dark:#E06C75"> state</span><span style="color:#383A42;--shiki-dark:#56B6C2"> =</span><span style="color:#E45649;--shiki-dark:#E5C07B"> UUID</span><span style="color:#383A42;--shiki-dark:#ABB2BF">.</span><span style="color:#4078F2;--shiki-dark:#61AFEF">randomUUID</span><span style="color:#383A42;--shiki-dark:#ABB2BF">().</span><span style="color:#4078F2;--shiki-dark:#61AFEF">toString</span><span style="color:#383A42;--shiki-dark:#ABB2BF">().</span><span style="color:#4078F2;--shiki-dark:#61AFEF">replace</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(</span><span style="color:#50A14F;--shiki-dark:#98C379">"-"</span><span style="color:#383A42;--shiki-dark:#ABB2BF">, </span><span style="color:#50A14F;--shiki-dark:#98C379">""</span><span style="color:#383A42;--shiki-dark:#ABB2BF">);</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">        </span></span>
<span class="line"><span style="color:#C18401;--shiki-dark:#E5C07B">        OAuthStatePayload</span><span style="color:#E45649;--shiki-dark:#E06C75"> payload</span><span style="color:#383A42;--shiki-dark:#56B6C2"> =</span><span style="color:#E45649;--shiki-dark:#E5C07B"> OAuthStatePayload</span><span style="color:#383A42;--shiki-dark:#ABB2BF">.</span><span style="color:#4078F2;--shiki-dark:#61AFEF">builder</span><span style="color:#383A42;--shiki-dark:#ABB2BF">()</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">                .</span><span style="color:#4078F2;--shiki-dark:#61AFEF">type</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(type)</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">                .</span><span style="color:#4078F2;--shiki-dark:#61AFEF">provider</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(provider)</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">                .</span><span style="color:#4078F2;--shiki-dark:#61AFEF">userId</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(userId)</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">                .</span><span style="color:#4078F2;--shiki-dark:#61AFEF">timestamp</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(</span><span style="color:#E45649;--shiki-dark:#E5C07B">System</span><span style="color:#383A42;--shiki-dark:#ABB2BF">.</span><span style="color:#4078F2;--shiki-dark:#61AFEF">currentTimeMillis</span><span style="color:#383A42;--shiki-dark:#ABB2BF">())</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">                .</span><span style="color:#4078F2;--shiki-dark:#61AFEF">build</span><span style="color:#383A42;--shiki-dark:#ABB2BF">();</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">        </span></span>
<span class="line"><span style="color:#C18401;--shiki-dark:#E5C07B">        String</span><span style="color:#E45649;--shiki-dark:#E06C75"> key</span><span style="color:#383A42;--shiki-dark:#56B6C2"> =</span><span style="color:#383A42;--shiki-dark:#ABB2BF"> STATE_KEY_PREFIX </span><span style="color:#383A42;--shiki-dark:#56B6C2">+</span><span style="color:#383A42;--shiki-dark:#ABB2BF"> state;</span></span>
<span class="line"><span style="color:#C18401;--shiki-dark:#E5C07B">        String</span><span style="color:#E45649;--shiki-dark:#E06C75"> json</span><span style="color:#383A42;--shiki-dark:#56B6C2"> =</span><span style="color:#E45649;--shiki-dark:#E5C07B"> JSON</span><span style="color:#383A42;--shiki-dark:#ABB2BF">.</span><span style="color:#4078F2;--shiki-dark:#61AFEF">toJSONString</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(payload);</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">        </span></span>
<span class="line"><span style="color:#E45649;--shiki-dark:#E5C07B">        redisTemplate</span><span style="color:#383A42;--shiki-dark:#ABB2BF">.</span><span style="color:#4078F2;--shiki-dark:#61AFEF">opsForValue</span><span style="color:#383A42;--shiki-dark:#ABB2BF">().</span><span style="color:#4078F2;--shiki-dark:#61AFEF">set</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(key, json, stateTtl, </span><span style="color:#E45649;--shiki-dark:#E5C07B">TimeUnit</span><span style="color:#383A42;--shiki-dark:#ABB2BF">.</span><span style="color:#E45649;--shiki-dark:#E5C07B">SECONDS</span><span style="color:#383A42;--shiki-dark:#ABB2BF">);</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">        </span></span>
<span class="line"><span style="color:#E45649;--shiki-dark:#E5C07B">        log</span><span style="color:#383A42;--shiki-dark:#ABB2BF">.</span><span style="color:#4078F2;--shiki-dark:#61AFEF">info</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(</span><span style="color:#50A14F;--shiki-dark:#98C379">"生成 State: type={}, provider={}, ttl={}s"</span><span style="color:#383A42;--shiki-dark:#ABB2BF">, type, provider, stateTtl);</span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">        return</span><span style="color:#383A42;--shiki-dark:#ABB2BF"> state;</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">    }</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">}</span></span></code></pre>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="62-token-加密工具">6.2 Token 加密工具<a href="https://blog.xiaoxiaoliang.top/en/blog/java-third-party-login#62-token-%E5%8A%A0%E5%AF%86%E5%B7%A5%E5%85%B7" class="hash-link" aria-label="Direct link to 6.2 Token 加密工具" title="Direct link to 6.2 Token 加密工具" translate="no">​</a></h3>
<pre tabindex="0" class="codeBlockStandalone_MEMb thin-scrollbar language-java codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#0f172a;--prism-background-color:#f1f5f9"><code class="codeBlockLines_e6Vv"><span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">@</span><span style="color:#A626A4;--shiki-dark:#E5C07B">Slf4j</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">@</span><span style="color:#A626A4;--shiki-dark:#E5C07B">Component</span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">public</span><span style="color:#A626A4;--shiki-dark:#C678DD"> class</span><span style="color:#C18401;--shiki-dark:#E5C07B"> TokenEncryptionUtil</span><span style="color:#383A42;--shiki-dark:#ABB2BF"> {</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#E06C75">    </span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">    private</span><span style="color:#A626A4;--shiki-dark:#C678DD"> static</span><span style="color:#A626A4;--shiki-dark:#C678DD"> final</span><span style="color:#C18401;--shiki-dark:#E5C07B"> String</span><span style="color:#E45649;--shiki-dark:#E06C75"> ALGORITHM </span><span style="color:#383A42;--shiki-dark:#56B6C2">=</span><span style="color:#50A14F;--shiki-dark:#98C379"> "AES"</span><span style="color:#383A42;--shiki-dark:#ABB2BF">;</span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">    private</span><span style="color:#A626A4;--shiki-dark:#C678DD"> static</span><span style="color:#A626A4;--shiki-dark:#C678DD"> final</span><span style="color:#C18401;--shiki-dark:#E5C07B"> String</span><span style="color:#E45649;--shiki-dark:#E06C75"> TRANSFORMATION </span><span style="color:#383A42;--shiki-dark:#56B6C2">=</span><span style="color:#50A14F;--shiki-dark:#98C379"> "AES/GCM/NoPadding"</span><span style="color:#383A42;--shiki-dark:#ABB2BF">;</span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">    private</span><span style="color:#A626A4;--shiki-dark:#C678DD"> static</span><span style="color:#A626A4;--shiki-dark:#C678DD"> final</span><span style="color:#A626A4;--shiki-dark:#C678DD"> int</span><span style="color:#E45649;--shiki-dark:#E06C75"> GCM_IV_LENGTH </span><span style="color:#383A42;--shiki-dark:#56B6C2">=</span><span style="color:#986801;--shiki-dark:#D19A66"> 12</span><span style="color:#383A42;--shiki-dark:#ABB2BF">;</span><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic">   // GCM 推荐 IV 长度</span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">    private</span><span style="color:#A626A4;--shiki-dark:#C678DD"> static</span><span style="color:#A626A4;--shiki-dark:#C678DD"> final</span><span style="color:#A626A4;--shiki-dark:#C678DD"> int</span><span style="color:#E45649;--shiki-dark:#E06C75"> GCM_TAG_LENGTH </span><span style="color:#383A42;--shiki-dark:#56B6C2">=</span><span style="color:#986801;--shiki-dark:#D19A66"> 128</span><span style="color:#383A42;--shiki-dark:#ABB2BF">;</span><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic"> // 认证标签长度（bits）</span></span>
<span class="line"></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">    @</span><span style="color:#A626A4;--shiki-dark:#E5C07B">Value</span><span style="color:#383A42;--shiki-dark:#E06C75">(</span><span style="color:#50A14F;--shiki-dark:#98C379">"${oauth.security.encryption-key}"</span><span style="color:#383A42;--shiki-dark:#E06C75">)</span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">    private</span><span style="color:#C18401;--shiki-dark:#E5C07B"> String</span><span style="color:#E45649;--shiki-dark:#E06C75"> encryptionKey</span><span style="color:#383A42;--shiki-dark:#ABB2BF">;</span></span>
<span class="line"></span>
<span class="line"><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic">    /**</span></span>
<span class="line"><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic">     * AES-GCM 加密</span></span>
<span class="line"><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic">     * </span></span>
<span class="line"><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic">     * </span><span style="color:#A626A4;--shiki-light-font-style:italic;--shiki-dark:#C678DD;--shiki-dark-font-style:italic">@param</span><span style="color:#383A42;--shiki-light-font-style:italic;--shiki-dark:#E06C75;--shiki-dark-font-style:italic"> plainText</span><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic"> 明文</span></span>
<span class="line"><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic">     * </span><span style="color:#A626A4;--shiki-light-font-style:italic;--shiki-dark:#C678DD;--shiki-dark-font-style:italic">@return</span><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic"> Base64(IV + 密文)</span></span>
<span class="line"><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic">     */</span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">    public</span><span style="color:#C18401;--shiki-dark:#E5C07B"> String</span><span style="color:#4078F2;--shiki-dark:#61AFEF"> encrypt</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(</span><span style="color:#C18401;--shiki-dark:#E5C07B">String</span><span style="color:#383A42;--shiki-light-font-style:inherit;--shiki-dark:#E06C75;--shiki-dark-font-style:italic"> plainText</span><span style="color:#383A42;--shiki-dark:#ABB2BF">)</span><span style="color:#383A42;--shiki-dark:#ABB2BF"> {</span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">        if</span><span style="color:#383A42;--shiki-dark:#ABB2BF"> (plainText </span><span style="color:#383A42;--shiki-dark:#56B6C2">==</span><span style="color:#986801;--shiki-dark:#D19A66"> null</span><span style="color:#383A42;--shiki-dark:#56B6C2"> ||</span><span style="color:#E45649;--shiki-dark:#E5C07B"> plainText</span><span style="color:#383A42;--shiki-dark:#ABB2BF">.</span><span style="color:#4078F2;--shiki-dark:#61AFEF">isBlank</span><span style="color:#383A42;--shiki-dark:#ABB2BF">()) {</span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">            return</span><span style="color:#383A42;--shiki-dark:#ABB2BF"> plainText;</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">        }</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">        </span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">        try</span><span style="color:#383A42;--shiki-dark:#ABB2BF"> {</span></span>
<span class="line"><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic">            // 1. 生成随机 IV</span></span>
<span class="line"><span style="color:#C18401;--shiki-dark:#C678DD">            byte</span><span style="color:#E45649;--shiki-dark:#ABB2BF">[] </span><span style="color:#E45649;--shiki-dark:#E06C75">iv</span><span style="color:#383A42;--shiki-dark:#56B6C2"> =</span><span style="color:#A626A4;--shiki-dark:#C678DD"> new</span><span style="color:#C18401;--shiki-dark:#C678DD"> byte</span><span style="color:#383A42;--shiki-dark:#ABB2BF">[GCM_IV_LENGTH];</span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">            new</span><span style="color:#4078F2;--shiki-dark:#61AFEF"> SecureRandom</span><span style="color:#383A42;--shiki-dark:#ABB2BF">().</span><span style="color:#4078F2;--shiki-dark:#61AFEF">nextBytes</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(iv);</span></span>
<span class="line"></span>
<span class="line"><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic">            // 2. 初始化加密器</span></span>
<span class="line"><span style="color:#C18401;--shiki-dark:#E5C07B">            SecretKeySpec</span><span style="color:#E45649;--shiki-dark:#E06C75"> keySpec</span><span style="color:#383A42;--shiki-dark:#56B6C2"> =</span><span style="color:#A626A4;--shiki-dark:#C678DD"> new</span><span style="color:#4078F2;--shiki-dark:#61AFEF"> SecretKeySpec</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(</span><span style="color:#4078F2;--shiki-dark:#61AFEF">getKeyBytes</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(), ALGORITHM);</span></span>
<span class="line"><span style="color:#C18401;--shiki-dark:#E5C07B">            GCMParameterSpec</span><span style="color:#E45649;--shiki-dark:#E06C75"> gcmSpec</span><span style="color:#383A42;--shiki-dark:#56B6C2"> =</span><span style="color:#A626A4;--shiki-dark:#C678DD"> new</span><span style="color:#4078F2;--shiki-dark:#61AFEF"> GCMParameterSpec</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(GCM_TAG_LENGTH, iv);</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">            </span></span>
<span class="line"><span style="color:#C18401;--shiki-dark:#E5C07B">            Cipher</span><span style="color:#E45649;--shiki-dark:#E06C75"> cipher</span><span style="color:#383A42;--shiki-dark:#56B6C2"> =</span><span style="color:#E45649;--shiki-dark:#E5C07B"> Cipher</span><span style="color:#383A42;--shiki-dark:#ABB2BF">.</span><span style="color:#4078F2;--shiki-dark:#61AFEF">getInstance</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(TRANSFORMATION);</span></span>
<span class="line"><span style="color:#E45649;--shiki-dark:#E5C07B">            cipher</span><span style="color:#383A42;--shiki-dark:#ABB2BF">.</span><span style="color:#4078F2;--shiki-dark:#61AFEF">init</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(</span><span style="color:#E45649;--shiki-dark:#E5C07B">Cipher</span><span style="color:#383A42;--shiki-dark:#ABB2BF">.</span><span style="color:#E45649;--shiki-dark:#E5C07B">ENCRYPT_MODE</span><span style="color:#383A42;--shiki-dark:#ABB2BF">, keySpec, gcmSpec);</span></span>
<span class="line"></span>
<span class="line"><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic">            // 3. 加密</span></span>
<span class="line"><span style="color:#C18401;--shiki-dark:#C678DD">            byte</span><span style="color:#E45649;--shiki-dark:#ABB2BF">[] </span><span style="color:#E45649;--shiki-dark:#E06C75">encrypted</span><span style="color:#383A42;--shiki-dark:#56B6C2"> =</span><span style="color:#E45649;--shiki-dark:#E5C07B"> cipher</span><span style="color:#383A42;--shiki-dark:#ABB2BF">.</span><span style="color:#4078F2;--shiki-dark:#61AFEF">doFinal</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(</span><span style="color:#E45649;--shiki-dark:#E5C07B">plainText</span><span style="color:#383A42;--shiki-dark:#ABB2BF">.</span><span style="color:#4078F2;--shiki-dark:#61AFEF">getBytes</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(</span><span style="color:#E45649;--shiki-dark:#E5C07B">StandardCharsets</span><span style="color:#383A42;--shiki-dark:#ABB2BF">.</span><span style="color:#E45649;--shiki-dark:#E5C07B">UTF_8</span><span style="color:#383A42;--shiki-dark:#ABB2BF">));</span></span>
<span class="line"></span>
<span class="line"><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic">            // 4. IV + 密文 拼接，Base64 编码</span></span>
<span class="line"><span style="color:#C18401;--shiki-dark:#C678DD">            byte</span><span style="color:#E45649;--shiki-dark:#ABB2BF">[] </span><span style="color:#E45649;--shiki-dark:#E06C75">combined</span><span style="color:#383A42;--shiki-dark:#56B6C2"> =</span><span style="color:#A626A4;--shiki-dark:#C678DD"> new</span><span style="color:#C18401;--shiki-dark:#C678DD"> byte</span><span style="color:#383A42;--shiki-dark:#ABB2BF">[</span><span style="color:#E45649;--shiki-dark:#E5C07B">iv</span><span style="color:#383A42;--shiki-dark:#ABB2BF">.</span><span style="color:#E45649;--shiki-dark:#E5C07B">length</span><span style="color:#383A42;--shiki-dark:#56B6C2"> +</span><span style="color:#E45649;--shiki-dark:#E5C07B"> encrypted</span><span style="color:#383A42;--shiki-dark:#ABB2BF">.</span><span style="color:#E45649;--shiki-dark:#E5C07B">length</span><span style="color:#383A42;--shiki-dark:#ABB2BF">];</span></span>
<span class="line"><span style="color:#E45649;--shiki-dark:#E5C07B">            System</span><span style="color:#383A42;--shiki-dark:#ABB2BF">.</span><span style="color:#4078F2;--shiki-dark:#61AFEF">arraycopy</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(iv, </span><span style="color:#986801;--shiki-dark:#D19A66">0</span><span style="color:#383A42;--shiki-dark:#ABB2BF">, combined, </span><span style="color:#986801;--shiki-dark:#D19A66">0</span><span style="color:#383A42;--shiki-dark:#ABB2BF">, </span><span style="color:#E45649;--shiki-dark:#E5C07B">iv</span><span style="color:#383A42;--shiki-dark:#ABB2BF">.</span><span style="color:#E45649;--shiki-dark:#E5C07B">length</span><span style="color:#383A42;--shiki-dark:#ABB2BF">);</span></span>
<span class="line"><span style="color:#E45649;--shiki-dark:#E5C07B">            System</span><span style="color:#383A42;--shiki-dark:#ABB2BF">.</span><span style="color:#4078F2;--shiki-dark:#61AFEF">arraycopy</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(encrypted, </span><span style="color:#986801;--shiki-dark:#D19A66">0</span><span style="color:#383A42;--shiki-dark:#ABB2BF">, combined, </span><span style="color:#E45649;--shiki-dark:#E5C07B">iv</span><span style="color:#383A42;--shiki-dark:#ABB2BF">.</span><span style="color:#E45649;--shiki-dark:#E5C07B">length</span><span style="color:#383A42;--shiki-dark:#ABB2BF">, </span><span style="color:#E45649;--shiki-dark:#E5C07B">encrypted</span><span style="color:#383A42;--shiki-dark:#ABB2BF">.</span><span style="color:#E45649;--shiki-dark:#E5C07B">length</span><span style="color:#383A42;--shiki-dark:#ABB2BF">);</span></span>
<span class="line"></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">            return</span><span style="color:#E45649;--shiki-dark:#E5C07B"> Base64</span><span style="color:#383A42;--shiki-dark:#ABB2BF">.</span><span style="color:#4078F2;--shiki-dark:#61AFEF">getEncoder</span><span style="color:#383A42;--shiki-dark:#ABB2BF">().</span><span style="color:#4078F2;--shiki-dark:#61AFEF">encodeToString</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(combined);</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">            </span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">        } </span><span style="color:#A626A4;--shiki-dark:#C678DD">catch</span><span style="color:#383A42;--shiki-dark:#ABB2BF"> (</span><span style="color:#C18401;--shiki-dark:#E5C07B">Exception</span><span style="color:#383A42;--shiki-light-font-style:inherit;--shiki-dark:#E06C75;--shiki-dark-font-style:italic"> e</span><span style="color:#383A42;--shiki-dark:#ABB2BF">) {</span></span>
<span class="line"><span style="color:#E45649;--shiki-dark:#E5C07B">            log</span><span style="color:#383A42;--shiki-dark:#ABB2BF">.</span><span style="color:#4078F2;--shiki-dark:#61AFEF">error</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(</span><span style="color:#50A14F;--shiki-dark:#98C379">"加密失败"</span><span style="color:#383A42;--shiki-dark:#ABB2BF">, e);</span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">            throw</span><span style="color:#A626A4;--shiki-dark:#C678DD"> new</span><span style="color:#4078F2;--shiki-dark:#61AFEF"> RuntimeException</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(</span><span style="color:#50A14F;--shiki-dark:#98C379">"Token 加密失败"</span><span style="color:#383A42;--shiki-dark:#ABB2BF">, e);</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">        }</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">    }</span></span>
<span class="line"></span>
<span class="line"><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic">    /**</span></span>
<span class="line"><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic">     * AES-GCM 解密</span></span>
<span class="line"><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic">     * </span></span>
<span class="line"><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic">     * </span><span style="color:#A626A4;--shiki-light-font-style:italic;--shiki-dark:#C678DD;--shiki-dark-font-style:italic">@param</span><span style="color:#383A42;--shiki-light-font-style:italic;--shiki-dark:#E06C75;--shiki-dark-font-style:italic"> cipherText</span><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic"> Base64(IV + 密文)</span></span>
<span class="line"><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic">     * </span><span style="color:#A626A4;--shiki-light-font-style:italic;--shiki-dark:#C678DD;--shiki-dark-font-style:italic">@return</span><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic"> 明文</span></span>
<span class="line"><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic">     */</span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">    public</span><span style="color:#C18401;--shiki-dark:#E5C07B"> String</span><span style="color:#4078F2;--shiki-dark:#61AFEF"> decrypt</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(</span><span style="color:#C18401;--shiki-dark:#E5C07B">String</span><span style="color:#383A42;--shiki-light-font-style:inherit;--shiki-dark:#E06C75;--shiki-dark-font-style:italic"> cipherText</span><span style="color:#383A42;--shiki-dark:#ABB2BF">)</span><span style="color:#383A42;--shiki-dark:#ABB2BF"> {</span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">        if</span><span style="color:#383A42;--shiki-dark:#ABB2BF"> (cipherText </span><span style="color:#383A42;--shiki-dark:#56B6C2">==</span><span style="color:#986801;--shiki-dark:#D19A66"> null</span><span style="color:#383A42;--shiki-dark:#56B6C2"> ||</span><span style="color:#E45649;--shiki-dark:#E5C07B"> cipherText</span><span style="color:#383A42;--shiki-dark:#ABB2BF">.</span><span style="color:#4078F2;--shiki-dark:#61AFEF">isBlank</span><span style="color:#383A42;--shiki-dark:#ABB2BF">()) {</span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">            return</span><span style="color:#383A42;--shiki-dark:#ABB2BF"> cipherText;</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">        }</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">        </span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">        try</span><span style="color:#383A42;--shiki-dark:#ABB2BF"> {</span></span>
<span class="line"><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic">            // 1. Base64 解码</span></span>
<span class="line"><span style="color:#C18401;--shiki-dark:#C678DD">            byte</span><span style="color:#E45649;--shiki-dark:#ABB2BF">[] </span><span style="color:#E45649;--shiki-dark:#E06C75">combined</span><span style="color:#383A42;--shiki-dark:#56B6C2"> =</span><span style="color:#E45649;--shiki-dark:#E5C07B"> Base64</span><span style="color:#383A42;--shiki-dark:#ABB2BF">.</span><span style="color:#4078F2;--shiki-dark:#61AFEF">getDecoder</span><span style="color:#383A42;--shiki-dark:#ABB2BF">().</span><span style="color:#4078F2;--shiki-dark:#61AFEF">decode</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(cipherText);</span></span>
<span class="line"></span>
<span class="line"><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic">            // 2. 分离 IV 和密文</span></span>
<span class="line"><span style="color:#C18401;--shiki-dark:#C678DD">            byte</span><span style="color:#E45649;--shiki-dark:#ABB2BF">[] </span><span style="color:#E45649;--shiki-dark:#E06C75">iv</span><span style="color:#383A42;--shiki-dark:#56B6C2"> =</span><span style="color:#A626A4;--shiki-dark:#C678DD"> new</span><span style="color:#C18401;--shiki-dark:#C678DD"> byte</span><span style="color:#383A42;--shiki-dark:#ABB2BF">[GCM_IV_LENGTH];</span></span>
<span class="line"><span style="color:#C18401;--shiki-dark:#C678DD">            byte</span><span style="color:#E45649;--shiki-dark:#ABB2BF">[] </span><span style="color:#E45649;--shiki-dark:#E06C75">encrypted</span><span style="color:#383A42;--shiki-dark:#56B6C2"> =</span><span style="color:#A626A4;--shiki-dark:#C678DD"> new</span><span style="color:#C18401;--shiki-dark:#C678DD"> byte</span><span style="color:#383A42;--shiki-dark:#ABB2BF">[</span><span style="color:#E45649;--shiki-dark:#E5C07B">combined</span><span style="color:#383A42;--shiki-dark:#ABB2BF">.</span><span style="color:#E45649;--shiki-dark:#E5C07B">length</span><span style="color:#383A42;--shiki-dark:#56B6C2"> -</span><span style="color:#383A42;--shiki-dark:#ABB2BF"> GCM_IV_LENGTH];</span></span>
<span class="line"><span style="color:#E45649;--shiki-dark:#E5C07B">            System</span><span style="color:#383A42;--shiki-dark:#ABB2BF">.</span><span style="color:#4078F2;--shiki-dark:#61AFEF">arraycopy</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(combined, </span><span style="color:#986801;--shiki-dark:#D19A66">0</span><span style="color:#383A42;--shiki-dark:#ABB2BF">, iv, </span><span style="color:#986801;--shiki-dark:#D19A66">0</span><span style="color:#383A42;--shiki-dark:#ABB2BF">, </span><span style="color:#E45649;--shiki-dark:#E5C07B">iv</span><span style="color:#383A42;--shiki-dark:#ABB2BF">.</span><span style="color:#E45649;--shiki-dark:#E5C07B">length</span><span style="color:#383A42;--shiki-dark:#ABB2BF">);</span></span>
<span class="line"><span style="color:#E45649;--shiki-dark:#E5C07B">            System</span><span style="color:#383A42;--shiki-dark:#ABB2BF">.</span><span style="color:#4078F2;--shiki-dark:#61AFEF">arraycopy</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(combined, </span><span style="color:#E45649;--shiki-dark:#E5C07B">iv</span><span style="color:#383A42;--shiki-dark:#ABB2BF">.</span><span style="color:#E45649;--shiki-dark:#E5C07B">length</span><span style="color:#383A42;--shiki-dark:#ABB2BF">, encrypted, </span><span style="color:#986801;--shiki-dark:#D19A66">0</span><span style="color:#383A42;--shiki-dark:#ABB2BF">, </span><span style="color:#E45649;--shiki-dark:#E5C07B">encrypted</span><span style="color:#383A42;--shiki-dark:#ABB2BF">.</span><span style="color:#E45649;--shiki-dark:#E5C07B">length</span><span style="color:#383A42;--shiki-dark:#ABB2BF">);</span></span>
<span class="line"></span>
<span class="line"><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic">            // 3. 初始化解密器</span></span>
<span class="line"><span style="color:#C18401;--shiki-dark:#E5C07B">            SecretKeySpec</span><span style="color:#E45649;--shiki-dark:#E06C75"> keySpec</span><span style="color:#383A42;--shiki-dark:#56B6C2"> =</span><span style="color:#A626A4;--shiki-dark:#C678DD"> new</span><span style="color:#4078F2;--shiki-dark:#61AFEF"> SecretKeySpec</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(</span><span style="color:#4078F2;--shiki-dark:#61AFEF">getKeyBytes</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(), ALGORITHM);</span></span>
<span class="line"><span style="color:#C18401;--shiki-dark:#E5C07B">            GCMParameterSpec</span><span style="color:#E45649;--shiki-dark:#E06C75"> gcmSpec</span><span style="color:#383A42;--shiki-dark:#56B6C2"> =</span><span style="color:#A626A4;--shiki-dark:#C678DD"> new</span><span style="color:#4078F2;--shiki-dark:#61AFEF"> GCMParameterSpec</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(GCM_TAG_LENGTH, iv);</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">            </span></span>
<span class="line"><span style="color:#C18401;--shiki-dark:#E5C07B">            Cipher</span><span style="color:#E45649;--shiki-dark:#E06C75"> cipher</span><span style="color:#383A42;--shiki-dark:#56B6C2"> =</span><span style="color:#E45649;--shiki-dark:#E5C07B"> Cipher</span><span style="color:#383A42;--shiki-dark:#ABB2BF">.</span><span style="color:#4078F2;--shiki-dark:#61AFEF">getInstance</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(TRANSFORMATION);</span></span>
<span class="line"><span style="color:#E45649;--shiki-dark:#E5C07B">            cipher</span><span style="color:#383A42;--shiki-dark:#ABB2BF">.</span><span style="color:#4078F2;--shiki-dark:#61AFEF">init</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(</span><span style="color:#E45649;--shiki-dark:#E5C07B">Cipher</span><span style="color:#383A42;--shiki-dark:#ABB2BF">.</span><span style="color:#E45649;--shiki-dark:#E5C07B">DECRYPT_MODE</span><span style="color:#383A42;--shiki-dark:#ABB2BF">, keySpec, gcmSpec);</span></span>
<span class="line"></span>
<span class="line"><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic">            // 4. 解密</span></span>
<span class="line"><span style="color:#C18401;--shiki-dark:#C678DD">            byte</span><span style="color:#E45649;--shiki-dark:#ABB2BF">[] </span><span style="color:#E45649;--shiki-dark:#E06C75">decrypted</span><span style="color:#383A42;--shiki-dark:#56B6C2"> =</span><span style="color:#E45649;--shiki-dark:#E5C07B"> cipher</span><span style="color:#383A42;--shiki-dark:#ABB2BF">.</span><span style="color:#4078F2;--shiki-dark:#61AFEF">doFinal</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(encrypted);</span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">            return</span><span style="color:#A626A4;--shiki-dark:#C678DD"> new</span><span style="color:#4078F2;--shiki-dark:#61AFEF"> String</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(decrypted, </span><span style="color:#E45649;--shiki-dark:#E5C07B">StandardCharsets</span><span style="color:#383A42;--shiki-dark:#ABB2BF">.</span><span style="color:#E45649;--shiki-dark:#E5C07B">UTF_8</span><span style="color:#383A42;--shiki-dark:#ABB2BF">);</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">            </span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">        } </span><span style="color:#A626A4;--shiki-dark:#C678DD">catch</span><span style="color:#383A42;--shiki-dark:#ABB2BF"> (</span><span style="color:#C18401;--shiki-dark:#E5C07B">Exception</span><span style="color:#383A42;--shiki-light-font-style:inherit;--shiki-dark:#E06C75;--shiki-dark-font-style:italic"> e</span><span style="color:#383A42;--shiki-dark:#ABB2BF">) {</span></span>
<span class="line"><span style="color:#E45649;--shiki-dark:#E5C07B">            log</span><span style="color:#383A42;--shiki-dark:#ABB2BF">.</span><span style="color:#4078F2;--shiki-dark:#61AFEF">error</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(</span><span style="color:#50A14F;--shiki-dark:#98C379">"解密失败"</span><span style="color:#383A42;--shiki-dark:#ABB2BF">, e);</span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">            throw</span><span style="color:#A626A4;--shiki-dark:#C678DD"> new</span><span style="color:#4078F2;--shiki-dark:#61AFEF"> RuntimeException</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(</span><span style="color:#50A14F;--shiki-dark:#98C379">"Token 解密失败"</span><span style="color:#383A42;--shiki-dark:#ABB2BF">, e);</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">        }</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">    }</span></span>
<span class="line"></span>
<span class="line"><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic">    /**</span></span>
<span class="line"><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic">     * 获取 32 字节密钥（AES-256）</span></span>
<span class="line"><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic">     */</span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">    private</span><span style="color:#C18401;--shiki-dark:#C678DD"> byte</span><span style="color:#383A42;--shiki-dark:#61AFEF">[] </span><span style="color:#4078F2;--shiki-dark:#61AFEF">getKeyBytes</span><span style="color:#383A42;--shiki-dark:#ABB2BF">()</span><span style="color:#383A42;--shiki-dark:#ABB2BF"> {</span></span>
<span class="line"><span style="color:#C18401;--shiki-dark:#C678DD">        byte</span><span style="color:#E45649;--shiki-dark:#ABB2BF">[] </span><span style="color:#E45649;--shiki-dark:#E06C75">keyBytes</span><span style="color:#383A42;--shiki-dark:#56B6C2"> =</span><span style="color:#E45649;--shiki-dark:#E5C07B"> encryptionKey</span><span style="color:#383A42;--shiki-dark:#ABB2BF">.</span><span style="color:#4078F2;--shiki-dark:#61AFEF">getBytes</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(</span><span style="color:#E45649;--shiki-dark:#E5C07B">StandardCharsets</span><span style="color:#383A42;--shiki-dark:#ABB2BF">.</span><span style="color:#E45649;--shiki-dark:#E5C07B">UTF_8</span><span style="color:#383A42;--shiki-dark:#ABB2BF">);</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">        </span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">        if</span><span style="color:#383A42;--shiki-dark:#ABB2BF"> (</span><span style="color:#E45649;--shiki-dark:#E5C07B">keyBytes</span><span style="color:#383A42;--shiki-dark:#ABB2BF">.</span><span style="color:#E45649;--shiki-dark:#E5C07B">length</span><span style="color:#383A42;--shiki-dark:#56B6C2"> &lt;</span><span style="color:#986801;--shiki-dark:#D19A66"> 32</span><span style="color:#383A42;--shiki-dark:#ABB2BF">) {</span></span>
<span class="line"><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic">            // 不足 32 字节，填充 0</span></span>
<span class="line"><span style="color:#C18401;--shiki-dark:#C678DD">            byte</span><span style="color:#E45649;--shiki-dark:#ABB2BF">[] </span><span style="color:#E45649;--shiki-dark:#E06C75">padded</span><span style="color:#383A42;--shiki-dark:#56B6C2"> =</span><span style="color:#A626A4;--shiki-dark:#C678DD"> new</span><span style="color:#C18401;--shiki-dark:#C678DD"> byte</span><span style="color:#383A42;--shiki-dark:#ABB2BF">[</span><span style="color:#986801;--shiki-dark:#D19A66">32</span><span style="color:#383A42;--shiki-dark:#ABB2BF">];</span></span>
<span class="line"><span style="color:#E45649;--shiki-dark:#E5C07B">            System</span><span style="color:#383A42;--shiki-dark:#ABB2BF">.</span><span style="color:#4078F2;--shiki-dark:#61AFEF">arraycopy</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(keyBytes, </span><span style="color:#986801;--shiki-dark:#D19A66">0</span><span style="color:#383A42;--shiki-dark:#ABB2BF">, padded, </span><span style="color:#986801;--shiki-dark:#D19A66">0</span><span style="color:#383A42;--shiki-dark:#ABB2BF">, </span><span style="color:#E45649;--shiki-dark:#E5C07B">keyBytes</span><span style="color:#383A42;--shiki-dark:#ABB2BF">.</span><span style="color:#E45649;--shiki-dark:#E5C07B">length</span><span style="color:#383A42;--shiki-dark:#ABB2BF">);</span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">            return</span><span style="color:#383A42;--shiki-dark:#ABB2BF"> padded;</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">        } </span><span style="color:#A626A4;--shiki-dark:#C678DD">else</span><span style="color:#A626A4;--shiki-dark:#C678DD"> if</span><span style="color:#383A42;--shiki-dark:#ABB2BF"> (</span><span style="color:#E45649;--shiki-dark:#E5C07B">keyBytes</span><span style="color:#383A42;--shiki-dark:#ABB2BF">.</span><span style="color:#E45649;--shiki-dark:#E5C07B">length</span><span style="color:#383A42;--shiki-dark:#56B6C2"> &gt;</span><span style="color:#986801;--shiki-dark:#D19A66"> 32</span><span style="color:#383A42;--shiki-dark:#ABB2BF">) {</span></span>
<span class="line"><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic">            // 超过 32 字节，截断</span></span>
<span class="line"><span style="color:#C18401;--shiki-dark:#C678DD">            byte</span><span style="color:#E45649;--shiki-dark:#ABB2BF">[] </span><span style="color:#E45649;--shiki-dark:#E06C75">truncated</span><span style="color:#383A42;--shiki-dark:#56B6C2"> =</span><span style="color:#A626A4;--shiki-dark:#C678DD"> new</span><span style="color:#C18401;--shiki-dark:#C678DD"> byte</span><span style="color:#383A42;--shiki-dark:#ABB2BF">[</span><span style="color:#986801;--shiki-dark:#D19A66">32</span><span style="color:#383A42;--shiki-dark:#ABB2BF">];</span></span>
<span class="line"><span style="color:#E45649;--shiki-dark:#E5C07B">            System</span><span style="color:#383A42;--shiki-dark:#ABB2BF">.</span><span style="color:#4078F2;--shiki-dark:#61AFEF">arraycopy</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(keyBytes, </span><span style="color:#986801;--shiki-dark:#D19A66">0</span><span style="color:#383A42;--shiki-dark:#ABB2BF">, truncated, </span><span style="color:#986801;--shiki-dark:#D19A66">0</span><span style="color:#383A42;--shiki-dark:#ABB2BF">, </span><span style="color:#986801;--shiki-dark:#D19A66">32</span><span style="color:#383A42;--shiki-dark:#ABB2BF">);</span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">            return</span><span style="color:#383A42;--shiki-dark:#ABB2BF"> truncated;</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">        }</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">        </span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">        return</span><span style="color:#383A42;--shiki-dark:#ABB2BF"> keyBytes;</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">    }</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">}</span></span></code></pre>
<hr>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="七数据库设计">七、数据库设计<a href="https://blog.xiaoxiaoliang.top/en/blog/java-third-party-login#%E4%B8%83%E6%95%B0%E6%8D%AE%E5%BA%93%E8%AE%BE%E8%AE%A1" class="hash-link" aria-label="Direct link to 七、数据库设计" title="Direct link to 七、数据库设计" translate="no">​</a></h2>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="71-表结构">7.1 表结构<a href="https://blog.xiaoxiaoliang.top/en/blog/java-third-party-login#71-%E8%A1%A8%E7%BB%93%E6%9E%84" class="hash-link" aria-label="Direct link to 7.1 表结构" title="Direct link to 7.1 表结构" translate="no">​</a></h3>
<pre tabindex="0" class="codeBlockStandalone_MEMb thin-scrollbar language-sql codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#0f172a;--prism-background-color:#f1f5f9"><code class="codeBlockLines_e6Vv"><span class="line"><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic">-- 第三方账号绑定表</span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">CREATE</span><span style="color:#A626A4;--shiki-dark:#C678DD"> TABLE</span><span style="color:#383A42;--shiki-dark:#ABB2BF"> `</span><span style="color:#4078F2;--shiki-dark:#61AFEF">user_oauth</span><span style="color:#383A42;--shiki-dark:#ABB2BF">` (</span></span>
<span class="line"><span style="color:#50A14F;--shiki-dark:#98C379">    `id`</span><span style="color:#A626A4;--shiki-dark:#C678DD">                BIGINT</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(</span><span style="color:#986801;--shiki-dark:#D19A66">20</span><span style="color:#383A42;--shiki-dark:#ABB2BF">)   </span><span style="color:#A626A4;--shiki-dark:#C678DD">NOT NULL</span><span style="color:#383A42;--shiki-dark:#ABB2BF"> AUTO_INCREMENT COMMENT </span><span style="color:#50A14F;--shiki-dark:#98C379">'主键ID'</span><span style="color:#383A42;--shiki-dark:#ABB2BF">,</span></span>
<span class="line"><span style="color:#50A14F;--shiki-dark:#98C379">    `user_id`</span><span style="color:#A626A4;--shiki-dark:#C678DD">           BIGINT</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(</span><span style="color:#986801;--shiki-dark:#D19A66">20</span><span style="color:#383A42;--shiki-dark:#ABB2BF">)   </span><span style="color:#A626A4;--shiki-dark:#C678DD">NOT NULL</span><span style="color:#383A42;--shiki-dark:#ABB2BF"> COMMENT </span><span style="color:#50A14F;--shiki-dark:#98C379">'用户ID（关联 user 表）'</span><span style="color:#383A42;--shiki-dark:#ABB2BF">,</span></span>
<span class="line"><span style="color:#50A14F;--shiki-dark:#98C379">    `provider`</span><span style="color:#A626A4;--shiki-dark:#C678DD">          VARCHAR</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(</span><span style="color:#986801;--shiki-dark:#D19A66">50</span><span style="color:#383A42;--shiki-dark:#ABB2BF">)  </span><span style="color:#A626A4;--shiki-dark:#C678DD">NOT NULL</span><span style="color:#383A42;--shiki-dark:#ABB2BF"> COMMENT </span><span style="color:#50A14F;--shiki-dark:#98C379">'第三方平台：github, google, wechat, qq'</span><span style="color:#383A42;--shiki-dark:#ABB2BF">,</span></span>
<span class="line"><span style="color:#50A14F;--shiki-dark:#98C379">    `provider_user_id`</span><span style="color:#A626A4;--shiki-dark:#C678DD">  VARCHAR</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(</span><span style="color:#986801;--shiki-dark:#D19A66">255</span><span style="color:#383A42;--shiki-dark:#ABB2BF">) </span><span style="color:#A626A4;--shiki-dark:#C678DD">NOT NULL</span><span style="color:#383A42;--shiki-dark:#ABB2BF"> COMMENT </span><span style="color:#50A14F;--shiki-dark:#98C379">'第三方平台的用户ID'</span><span style="color:#383A42;--shiki-dark:#ABB2BF">,</span></span>
<span class="line"><span style="color:#50A14F;--shiki-dark:#98C379">    `provider_username`</span><span style="color:#A626A4;--shiki-dark:#C678DD"> VARCHAR</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(</span><span style="color:#986801;--shiki-dark:#D19A66">255</span><span style="color:#383A42;--shiki-dark:#ABB2BF">) </span><span style="color:#A626A4;--shiki-dark:#C678DD">DEFAULT</span><span style="color:#A626A4;--shiki-dark:#C678DD"> NULL</span><span style="color:#383A42;--shiki-dark:#ABB2BF"> COMMENT </span><span style="color:#50A14F;--shiki-dark:#98C379">'第三方平台的用户名'</span><span style="color:#383A42;--shiki-dark:#ABB2BF">,</span></span>
<span class="line"><span style="color:#50A14F;--shiki-dark:#98C379">    `access_token`</span><span style="color:#A626A4;--shiki-dark:#C678DD">      TEXT</span><span style="color:#A626A4;--shiki-dark:#C678DD">         DEFAULT</span><span style="color:#A626A4;--shiki-dark:#C678DD"> NULL</span><span style="color:#383A42;--shiki-dark:#ABB2BF"> COMMENT </span><span style="color:#50A14F;--shiki-dark:#98C379">'访问令牌（AES-GCM 加密存储）'</span><span style="color:#383A42;--shiki-dark:#ABB2BF">,</span></span>
<span class="line"><span style="color:#50A14F;--shiki-dark:#98C379">    `refresh_token`</span><span style="color:#A626A4;--shiki-dark:#C678DD">     TEXT</span><span style="color:#A626A4;--shiki-dark:#C678DD">         DEFAULT</span><span style="color:#A626A4;--shiki-dark:#C678DD"> NULL</span><span style="color:#383A42;--shiki-dark:#ABB2BF"> COMMENT </span><span style="color:#50A14F;--shiki-dark:#98C379">'刷新令牌（AES-GCM 加密存储）'</span><span style="color:#383A42;--shiki-dark:#ABB2BF">,</span></span>
<span class="line"><span style="color:#50A14F;--shiki-dark:#98C379">    `expires_at`</span><span style="color:#A626A4;--shiki-dark:#C678DD">        DATETIME</span><span style="color:#A626A4;--shiki-dark:#C678DD">     DEFAULT</span><span style="color:#A626A4;--shiki-dark:#C678DD"> NULL</span><span style="color:#383A42;--shiki-dark:#ABB2BF"> COMMENT </span><span style="color:#50A14F;--shiki-dark:#98C379">'令牌过期时间'</span><span style="color:#383A42;--shiki-dark:#ABB2BF">,</span></span>
<span class="line"><span style="color:#50A14F;--shiki-dark:#98C379">    `avatar_url`</span><span style="color:#A626A4;--shiki-dark:#C678DD">        VARCHAR</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(</span><span style="color:#986801;--shiki-dark:#D19A66">512</span><span style="color:#383A42;--shiki-dark:#ABB2BF">) </span><span style="color:#A626A4;--shiki-dark:#C678DD">DEFAULT</span><span style="color:#A626A4;--shiki-dark:#C678DD"> NULL</span><span style="color:#383A42;--shiki-dark:#ABB2BF"> COMMENT </span><span style="color:#50A14F;--shiki-dark:#98C379">'第三方平台头像URL'</span><span style="color:#383A42;--shiki-dark:#ABB2BF">,</span></span>
<span class="line"><span style="color:#50A14F;--shiki-dark:#98C379">    `email`</span><span style="color:#A626A4;--shiki-dark:#C678DD">             VARCHAR</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(</span><span style="color:#986801;--shiki-dark:#D19A66">255</span><span style="color:#383A42;--shiki-dark:#ABB2BF">) </span><span style="color:#A626A4;--shiki-dark:#C678DD">DEFAULT</span><span style="color:#A626A4;--shiki-dark:#C678DD"> NULL</span><span style="color:#383A42;--shiki-dark:#ABB2BF"> COMMENT </span><span style="color:#50A14F;--shiki-dark:#98C379">'第三方平台邮箱'</span><span style="color:#383A42;--shiki-dark:#ABB2BF">,</span></span>
<span class="line"><span style="color:#50A14F;--shiki-dark:#98C379">    `extra_info`</span><span style="color:#A626A4;--shiki-dark:#C678DD">        JSON</span><span style="color:#A626A4;--shiki-dark:#C678DD">         DEFAULT</span><span style="color:#A626A4;--shiki-dark:#C678DD"> NULL</span><span style="color:#383A42;--shiki-dark:#ABB2BF"> COMMENT </span><span style="color:#50A14F;--shiki-dark:#98C379">'扩展信息（JSON格式）'</span><span style="color:#383A42;--shiki-dark:#ABB2BF">,</span></span>
<span class="line"><span style="color:#50A14F;--shiki-dark:#98C379">    `create_time`</span><span style="color:#A626A4;--shiki-dark:#C678DD">       DATETIME</span><span style="color:#A626A4;--shiki-dark:#C678DD">     NOT NULL</span><span style="color:#A626A4;--shiki-dark:#C678DD"> DEFAULT</span><span style="color:#383A42;--shiki-dark:#ABB2BF"> CURRENT_TIMESTAMP COMMENT </span><span style="color:#50A14F;--shiki-dark:#98C379">'绑定时间'</span><span style="color:#383A42;--shiki-dark:#ABB2BF">,</span></span>
<span class="line"><span style="color:#50A14F;--shiki-dark:#98C379">    `update_time`</span><span style="color:#A626A4;--shiki-dark:#C678DD">       DATETIME</span><span style="color:#A626A4;--shiki-dark:#C678DD">     NOT NULL</span><span style="color:#A626A4;--shiki-dark:#C678DD"> DEFAULT</span><span style="color:#383A42;--shiki-dark:#ABB2BF"> CURRENT_TIMESTAMP </span><span style="color:#A626A4;--shiki-dark:#C678DD">ON UPDATE</span><span style="color:#383A42;--shiki-dark:#ABB2BF"> CURRENT_TIMESTAMP COMMENT </span><span style="color:#50A14F;--shiki-dark:#98C379">'更新时间'</span><span style="color:#383A42;--shiki-dark:#ABB2BF">,</span></span>
<span class="line"><span style="color:#50A14F;--shiki-dark:#98C379">    `deleted`</span><span style="color:#A626A4;--shiki-dark:#C678DD">           TINYINT</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(</span><span style="color:#986801;--shiki-dark:#D19A66">1</span><span style="color:#383A42;--shiki-dark:#ABB2BF">)   </span><span style="color:#A626A4;--shiki-dark:#C678DD">NOT NULL</span><span style="color:#A626A4;--shiki-dark:#C678DD"> DEFAULT</span><span style="color:#986801;--shiki-dark:#D19A66"> 0</span><span style="color:#383A42;--shiki-dark:#ABB2BF"> COMMENT </span><span style="color:#50A14F;--shiki-dark:#98C379">'逻辑删除：0-未删除，1-已删除'</span><span style="color:#383A42;--shiki-dark:#ABB2BF">,</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">    </span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">    PRIMARY KEY</span><span style="color:#383A42;--shiki-dark:#ABB2BF"> (</span><span style="color:#50A14F;--shiki-dark:#98C379">`id`</span><span style="color:#383A42;--shiki-dark:#ABB2BF">),</span></span>
<span class="line"><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic">    -- 确保同一平台的同一用户只能绑定一次</span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">    UNIQUE</span><span style="color:#A626A4;--shiki-dark:#C678DD"> KEY</span><span style="color:#50A14F;--shiki-dark:#98C379"> `uk_provider_user`</span><span style="color:#383A42;--shiki-dark:#ABB2BF"> (</span><span style="color:#50A14F;--shiki-dark:#98C379">`provider`</span><span style="color:#383A42;--shiki-dark:#ABB2BF">, </span><span style="color:#50A14F;--shiki-dark:#98C379">`provider_user_id`</span><span style="color:#383A42;--shiki-dark:#ABB2BF">, </span><span style="color:#50A14F;--shiki-dark:#98C379">`deleted`</span><span style="color:#383A42;--shiki-dark:#ABB2BF">),</span></span>
<span class="line"><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic">    -- 查询用户绑定列表的索引</span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">    INDEX</span><span style="color:#50A14F;--shiki-dark:#98C379"> `idx_user_id`</span><span style="color:#383A42;--shiki-dark:#ABB2BF"> (</span><span style="color:#50A14F;--shiki-dark:#98C379">`user_id`</span><span style="color:#383A42;--shiki-dark:#ABB2BF">)</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">) ENGINE</span><span style="color:#383A42;--shiki-dark:#56B6C2">=</span><span style="color:#383A42;--shiki-dark:#ABB2BF">InnoDB </span><span style="color:#A626A4;--shiki-dark:#C678DD">DEFAULT</span><span style="color:#383A42;--shiki-dark:#ABB2BF"> CHARSET</span><span style="color:#383A42;--shiki-dark:#56B6C2">=</span><span style="color:#383A42;--shiki-dark:#ABB2BF">utf8mb4 COMMENT</span><span style="color:#383A42;--shiki-dark:#56B6C2">=</span><span style="color:#50A14F;--shiki-dark:#98C379">'第三方账号绑定表'</span><span style="color:#383A42;--shiki-dark:#ABB2BF">;</span></span></code></pre>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="72-实体类">7.2 实体类<a href="https://blog.xiaoxiaoliang.top/en/blog/java-third-party-login#72-%E5%AE%9E%E4%BD%93%E7%B1%BB" class="hash-link" aria-label="Direct link to 7.2 实体类" title="Direct link to 7.2 实体类" translate="no">​</a></h3>
<pre tabindex="0" class="codeBlockStandalone_MEMb thin-scrollbar language-java codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#0f172a;--prism-background-color:#f1f5f9"><code class="codeBlockLines_e6Vv"><span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">@</span><span style="color:#A626A4;--shiki-dark:#E5C07B">Data</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">@</span><span style="color:#A626A4;--shiki-dark:#E5C07B">Builder</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">@</span><span style="color:#A626A4;--shiki-dark:#E5C07B">NoArgsConstructor</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">@</span><span style="color:#A626A4;--shiki-dark:#E5C07B">AllArgsConstructor</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">@</span><span style="color:#A626A4;--shiki-dark:#E5C07B">TableName</span><span style="color:#383A42;--shiki-dark:#E06C75">(</span><span style="color:#50A14F;--shiki-dark:#98C379">"user_oauth"</span><span style="color:#383A42;--shiki-dark:#E06C75">)</span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">public</span><span style="color:#A626A4;--shiki-dark:#C678DD"> class</span><span style="color:#C18401;--shiki-dark:#E5C07B"> UserOAuth</span><span style="color:#383A42;--shiki-dark:#ABB2BF"> {</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#E06C75">    </span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">    @</span><span style="color:#A626A4;--shiki-dark:#E5C07B">TableId</span><span style="color:#383A42;--shiki-dark:#E06C75">(</span><span style="color:#986801;--shiki-dark:#D19A66">type</span><span style="color:#383A42;--shiki-dark:#56B6C2"> =</span><span style="color:#E45649;--shiki-dark:#E5C07B"> IdType</span><span style="color:#383A42;--shiki-dark:#ABB2BF">.</span><span style="color:#E45649;--shiki-dark:#E5C07B">AUTO</span><span style="color:#383A42;--shiki-dark:#E06C75">)</span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">    private</span><span style="color:#C18401;--shiki-dark:#E5C07B"> Long</span><span style="color:#E45649;--shiki-dark:#E06C75"> id</span><span style="color:#383A42;--shiki-dark:#ABB2BF">;</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#E06C75">    </span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">    private</span><span style="color:#C18401;--shiki-dark:#E5C07B"> Long</span><span style="color:#E45649;--shiki-dark:#E06C75"> userId</span><span style="color:#383A42;--shiki-dark:#ABB2BF">;</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#E06C75">    </span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">    private</span><span style="color:#C18401;--shiki-dark:#E5C07B"> String</span><span style="color:#E45649;--shiki-dark:#E06C75"> provider</span><span style="color:#383A42;--shiki-dark:#ABB2BF">;</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#E06C75">    </span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">    private</span><span style="color:#C18401;--shiki-dark:#E5C07B"> String</span><span style="color:#E45649;--shiki-dark:#E06C75"> providerUserId</span><span style="color:#383A42;--shiki-dark:#ABB2BF">;</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#E06C75">    </span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">    private</span><span style="color:#C18401;--shiki-dark:#E5C07B"> String</span><span style="color:#E45649;--shiki-dark:#E06C75"> providerUsername</span><span style="color:#383A42;--shiki-dark:#ABB2BF">;</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#E06C75">    </span></span>
<span class="line"><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic">    /** 加密后的访问令牌 */</span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">    private</span><span style="color:#C18401;--shiki-dark:#E5C07B"> String</span><span style="color:#E45649;--shiki-dark:#E06C75"> accessToken</span><span style="color:#383A42;--shiki-dark:#ABB2BF">;</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#E06C75">    </span></span>
<span class="line"><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic">    /** 加密后的刷新令牌 */</span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">    private</span><span style="color:#C18401;--shiki-dark:#E5C07B"> String</span><span style="color:#E45649;--shiki-dark:#E06C75"> refreshToken</span><span style="color:#383A42;--shiki-dark:#ABB2BF">;</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#E06C75">    </span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">    private</span><span style="color:#C18401;--shiki-dark:#E5C07B"> LocalDateTime</span><span style="color:#E45649;--shiki-dark:#E06C75"> expiresAt</span><span style="color:#383A42;--shiki-dark:#ABB2BF">;</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#E06C75">    </span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">    private</span><span style="color:#C18401;--shiki-dark:#E5C07B"> String</span><span style="color:#E45649;--shiki-dark:#E06C75"> avatarUrl</span><span style="color:#383A42;--shiki-dark:#ABB2BF">;</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#E06C75">    </span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">    private</span><span style="color:#C18401;--shiki-dark:#E5C07B"> String</span><span style="color:#E45649;--shiki-dark:#E06C75"> email</span><span style="color:#383A42;--shiki-dark:#ABB2BF">;</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#E06C75">    </span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">    private</span><span style="color:#C18401;--shiki-dark:#E5C07B"> String</span><span style="color:#E45649;--shiki-dark:#E06C75"> extraInfo</span><span style="color:#383A42;--shiki-dark:#ABB2BF">;</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#E06C75">    </span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">    @</span><span style="color:#A626A4;--shiki-dark:#E5C07B">TableField</span><span style="color:#383A42;--shiki-dark:#E06C75">(</span><span style="color:#986801;--shiki-dark:#D19A66">fill</span><span style="color:#383A42;--shiki-dark:#56B6C2"> =</span><span style="color:#E45649;--shiki-dark:#E5C07B"> FieldFill</span><span style="color:#383A42;--shiki-dark:#ABB2BF">.</span><span style="color:#E45649;--shiki-dark:#E5C07B">INSERT</span><span style="color:#383A42;--shiki-dark:#E06C75">)</span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">    private</span><span style="color:#C18401;--shiki-dark:#E5C07B"> LocalDateTime</span><span style="color:#E45649;--shiki-dark:#E06C75"> createTime</span><span style="color:#383A42;--shiki-dark:#ABB2BF">;</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#E06C75">    </span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">    @</span><span style="color:#A626A4;--shiki-dark:#E5C07B">TableField</span><span style="color:#383A42;--shiki-dark:#E06C75">(</span><span style="color:#986801;--shiki-dark:#D19A66">fill</span><span style="color:#383A42;--shiki-dark:#56B6C2"> =</span><span style="color:#E45649;--shiki-dark:#E5C07B"> FieldFill</span><span style="color:#383A42;--shiki-dark:#ABB2BF">.</span><span style="color:#E45649;--shiki-dark:#E5C07B">INSERT_UPDATE</span><span style="color:#383A42;--shiki-dark:#E06C75">)</span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">    private</span><span style="color:#C18401;--shiki-dark:#E5C07B"> LocalDateTime</span><span style="color:#E45649;--shiki-dark:#E06C75"> updateTime</span><span style="color:#383A42;--shiki-dark:#ABB2BF">;</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#E06C75">    </span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">    @</span><span style="color:#A626A4;--shiki-dark:#E5C07B">TableLogic</span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">    private</span><span style="color:#C18401;--shiki-dark:#E5C07B"> Integer</span><span style="color:#E45649;--shiki-dark:#E06C75"> deleted</span><span style="color:#383A42;--shiki-dark:#ABB2BF">;</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">}</span></span></code></pre>
<hr>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="八前端对接">八、前端对接<a href="https://blog.xiaoxiaoliang.top/en/blog/java-third-party-login#%E5%85%AB%E5%89%8D%E7%AB%AF%E5%AF%B9%E6%8E%A5" class="hash-link" aria-label="Direct link to 八、前端对接" title="Direct link to 八、前端对接" translate="no">​</a></h2>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="81-发起登录">8.1 发起登录<a href="https://blog.xiaoxiaoliang.top/en/blog/java-third-party-login#81-%E5%8F%91%E8%B5%B7%E7%99%BB%E5%BD%95" class="hash-link" aria-label="Direct link to 8.1 发起登录" title="Direct link to 8.1 发起登录" translate="no">​</a></h3>
<pre tabindex="0" class="codeBlockStandalone_MEMb thin-scrollbar language-tsx codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#0f172a;--prism-background-color:#f1f5f9"><code class="codeBlockLines_e6Vv"><span class="line"><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic">// LoginPage.tsx</span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">const</span><span style="color:#4078F2;--shiki-dark:#61AFEF"> handleGitHubLogin</span><span style="color:#0184BC;--shiki-dark:#56B6C2"> =</span><span style="color:#383A42;--shiki-dark:#ABB2BF"> () </span><span style="color:#A626A4;--shiki-dark:#C678DD">=&gt;</span><span style="color:#383A42;--shiki-dark:#ABB2BF"> {</span></span>
<span class="line"><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic">    // 直接跳转到后端 OAuth 入口</span></span>
<span class="line"><span style="color:#E45649;--shiki-dark:#E5C07B">    window</span><span style="color:#383A42;--shiki-dark:#ABB2BF">.</span><span style="color:#E45649;--shiki-dark:#E5C07B">location</span><span style="color:#383A42;--shiki-dark:#ABB2BF">.</span><span style="color:#E45649;--shiki-dark:#E06C75">href</span><span style="color:#0184BC;--shiki-dark:#56B6C2"> =</span><span style="color:#50A14F;--shiki-dark:#98C379"> 'http://localhost:8001/api/user/oauth/authorize/github'</span><span style="color:#383A42;--shiki-dark:#ABB2BF">;</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">};</span></span>
<span class="line"></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">return</span><span style="color:#383A42;--shiki-dark:#ABB2BF"> (</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">    &lt;</span><span style="color:#E45649;--shiki-dark:#E06C75">button</span><span style="color:#986801;--shiki-light-font-style:inherit;--shiki-dark:#D19A66;--shiki-dark-font-style:italic"> onClick</span><span style="color:#383A42;--shiki-dark:#56B6C2">=</span><span style="color:#383A42;--shiki-dark:#C678DD">{</span><span style="color:#383A42;--shiki-dark:#E06C75">handleGitHubLogin</span><span style="color:#383A42;--shiki-dark:#C678DD">}</span><span style="color:#383A42;--shiki-dark:#ABB2BF">&gt;</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">        &lt;</span><span style="color:#C18401;--shiki-dark:#E5C07B">GitHubIcon</span><span style="color:#383A42;--shiki-dark:#ABB2BF"> /&gt; 使用 GitHub 登录</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">    &lt;/</span><span style="color:#E45649;--shiki-dark:#E06C75">button</span><span style="color:#383A42;--shiki-dark:#ABB2BF">&gt;</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">);</span></span></code></pre>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="82-处理回调">8.2 处理回调<a href="https://blog.xiaoxiaoliang.top/en/blog/java-third-party-login#82-%E5%A4%84%E7%90%86%E5%9B%9E%E8%B0%83" class="hash-link" aria-label="Direct link to 8.2 处理回调" title="Direct link to 8.2 处理回调" translate="no">​</a></h3>
<pre tabindex="0" class="codeBlockStandalone_MEMb thin-scrollbar language-tsx codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#0f172a;--prism-background-color:#f1f5f9"><code class="codeBlockLines_e6Vv"><span class="line"><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic">// OAuthCallback.tsx</span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">import</span><span style="color:#383A42;--shiki-dark:#ABB2BF"> { </span><span style="color:#E45649;--shiki-dark:#E06C75">useEffect</span><span style="color:#383A42;--shiki-dark:#ABB2BF"> } </span><span style="color:#A626A4;--shiki-dark:#C678DD">from</span><span style="color:#50A14F;--shiki-dark:#98C379"> 'react'</span><span style="color:#383A42;--shiki-dark:#ABB2BF">;</span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">import</span><span style="color:#383A42;--shiki-dark:#ABB2BF"> { </span><span style="color:#E45649;--shiki-dark:#E06C75">useSearchParams</span><span style="color:#383A42;--shiki-dark:#ABB2BF">, </span><span style="color:#E45649;--shiki-dark:#E06C75">useNavigate</span><span style="color:#383A42;--shiki-dark:#ABB2BF"> } </span><span style="color:#A626A4;--shiki-dark:#C678DD">from</span><span style="color:#50A14F;--shiki-dark:#98C379"> 'react-router-dom'</span><span style="color:#383A42;--shiki-dark:#ABB2BF">;</span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">import</span><span style="color:#383A42;--shiki-dark:#ABB2BF"> { </span><span style="color:#E45649;--shiki-dark:#E06C75">useUser</span><span style="color:#383A42;--shiki-dark:#ABB2BF"> } </span><span style="color:#A626A4;--shiki-dark:#C678DD">from</span><span style="color:#50A14F;--shiki-dark:#98C379"> '@/contexts/UserContext'</span><span style="color:#383A42;--shiki-dark:#ABB2BF">;</span></span>
<span class="line"></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">export</span><span style="color:#A626A4;--shiki-dark:#C678DD"> function</span><span style="color:#4078F2;--shiki-dark:#61AFEF"> OAuthCallback</span><span style="color:#383A42;--shiki-dark:#ABB2BF">() {</span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">    const</span><span style="color:#383A42;--shiki-dark:#ABB2BF"> [</span><span style="color:#986801;--shiki-dark:#E5C07B">searchParams</span><span style="color:#383A42;--shiki-dark:#ABB2BF">] </span><span style="color:#0184BC;--shiki-dark:#56B6C2">=</span><span style="color:#4078F2;--shiki-dark:#61AFEF"> useSearchParams</span><span style="color:#383A42;--shiki-dark:#ABB2BF">();</span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">    const</span><span style="color:#986801;--shiki-dark:#E5C07B"> navigate</span><span style="color:#0184BC;--shiki-dark:#56B6C2"> =</span><span style="color:#4078F2;--shiki-dark:#61AFEF"> useNavigate</span><span style="color:#383A42;--shiki-dark:#ABB2BF">();</span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">    const</span><span style="color:#383A42;--shiki-dark:#ABB2BF"> { </span><span style="color:#986801;--shiki-dark:#E5C07B">login</span><span style="color:#383A42;--shiki-dark:#ABB2BF"> } </span><span style="color:#0184BC;--shiki-dark:#56B6C2">=</span><span style="color:#4078F2;--shiki-dark:#61AFEF"> useUser</span><span style="color:#383A42;--shiki-dark:#ABB2BF">();</span></span>
<span class="line"></span>
<span class="line"><span style="color:#4078F2;--shiki-dark:#61AFEF">    useEffect</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(() </span><span style="color:#A626A4;--shiki-dark:#C678DD">=&gt;</span><span style="color:#383A42;--shiki-dark:#ABB2BF"> {</span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">        const</span><span style="color:#986801;--shiki-dark:#E5C07B"> token</span><span style="color:#0184BC;--shiki-dark:#56B6C2"> =</span><span style="color:#E45649;--shiki-dark:#E5C07B"> searchParams</span><span style="color:#383A42;--shiki-dark:#ABB2BF">.</span><span style="color:#4078F2;--shiki-dark:#61AFEF">get</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(</span><span style="color:#50A14F;--shiki-dark:#98C379">'token'</span><span style="color:#383A42;--shiki-dark:#ABB2BF">);</span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">        const</span><span style="color:#986801;--shiki-dark:#E5C07B"> error</span><span style="color:#0184BC;--shiki-dark:#56B6C2"> =</span><span style="color:#E45649;--shiki-dark:#E5C07B"> searchParams</span><span style="color:#383A42;--shiki-dark:#ABB2BF">.</span><span style="color:#4078F2;--shiki-dark:#61AFEF">get</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(</span><span style="color:#50A14F;--shiki-dark:#98C379">'error'</span><span style="color:#383A42;--shiki-dark:#ABB2BF">);</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">        </span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">        if</span><span style="color:#383A42;--shiki-dark:#ABB2BF"> (</span><span style="color:#383A42;--shiki-dark:#E06C75">error</span><span style="color:#383A42;--shiki-dark:#ABB2BF">) {</span></span>
<span class="line"><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic">            // 登录失败</span></span>
<span class="line"><span style="color:#4078F2;--shiki-dark:#61AFEF">            alert</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(</span><span style="color:#50A14F;--shiki-dark:#98C379">'登录失败: '</span><span style="color:#0184BC;--shiki-dark:#56B6C2"> +</span><span style="color:#4078F2;--shiki-dark:#61AFEF"> decodeURIComponent</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(</span><span style="color:#383A42;--shiki-dark:#E06C75">error</span><span style="color:#383A42;--shiki-dark:#ABB2BF">));</span></span>
<span class="line"><span style="color:#4078F2;--shiki-dark:#61AFEF">            navigate</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(</span><span style="color:#50A14F;--shiki-dark:#98C379">'/login'</span><span style="color:#383A42;--shiki-dark:#ABB2BF">);</span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">            return</span><span style="color:#383A42;--shiki-dark:#ABB2BF">;</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">        }</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">        </span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">        if</span><span style="color:#383A42;--shiki-dark:#ABB2BF"> (</span><span style="color:#383A42;--shiki-dark:#E06C75">token</span><span style="color:#383A42;--shiki-dark:#ABB2BF">) {</span></span>
<span class="line"><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic">            // 登录成功，保存 token 并获取用户信息</span></span>
<span class="line"><span style="color:#4078F2;--shiki-dark:#61AFEF">            login</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(</span><span style="color:#383A42;--shiki-dark:#E06C75">token</span><span style="color:#383A42;--shiki-dark:#ABB2BF">).</span><span style="color:#4078F2;--shiki-dark:#61AFEF">then</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(() </span><span style="color:#A626A4;--shiki-dark:#C678DD">=&gt;</span><span style="color:#383A42;--shiki-dark:#ABB2BF"> {</span></span>
<span class="line"><span style="color:#4078F2;--shiki-dark:#61AFEF">                navigate</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(</span><span style="color:#50A14F;--shiki-dark:#98C379">'/'</span><span style="color:#383A42;--shiki-dark:#ABB2BF">);</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">            });</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">        } </span><span style="color:#A626A4;--shiki-dark:#C678DD">else</span><span style="color:#383A42;--shiki-dark:#ABB2BF"> {</span></span>
<span class="line"><span style="color:#4078F2;--shiki-dark:#61AFEF">            navigate</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(</span><span style="color:#50A14F;--shiki-dark:#98C379">'/login'</span><span style="color:#383A42;--shiki-dark:#ABB2BF">);</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">        }</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">    }, [</span><span style="color:#383A42;--shiki-dark:#E06C75">searchParams</span><span style="color:#383A42;--shiki-dark:#ABB2BF">, </span><span style="color:#383A42;--shiki-dark:#E06C75">navigate</span><span style="color:#383A42;--shiki-dark:#ABB2BF">, </span><span style="color:#383A42;--shiki-dark:#E06C75">login</span><span style="color:#383A42;--shiki-dark:#ABB2BF">]);</span></span>
<span class="line"></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">    return</span><span style="color:#383A42;--shiki-dark:#ABB2BF"> (</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">        &lt;</span><span style="color:#E45649;--shiki-dark:#E06C75">div</span><span style="color:#986801;--shiki-light-font-style:inherit;--shiki-dark:#D19A66;--shiki-dark-font-style:italic"> className</span><span style="color:#383A42;--shiki-dark:#56B6C2">=</span><span style="color:#50A14F;--shiki-dark:#98C379">"loading-container"</span><span style="color:#383A42;--shiki-dark:#ABB2BF">&gt;</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">            &lt;</span><span style="color:#C18401;--shiki-dark:#E5C07B">Spinner</span><span style="color:#383A42;--shiki-dark:#ABB2BF"> /&gt;</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">            &lt;</span><span style="color:#E45649;--shiki-dark:#E06C75">p</span><span style="color:#383A42;--shiki-dark:#ABB2BF">&gt;正在登录...&lt;/</span><span style="color:#E45649;--shiki-dark:#E06C75">p</span><span style="color:#383A42;--shiki-dark:#ABB2BF">&gt;</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">        &lt;/</span><span style="color:#E45649;--shiki-dark:#E06C75">div</span><span style="color:#383A42;--shiki-dark:#ABB2BF">&gt;</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">    );</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">}</span></span></code></pre>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="83-路由配置">8.3 路由配置<a href="https://blog.xiaoxiaoliang.top/en/blog/java-third-party-login#83-%E8%B7%AF%E7%94%B1%E9%85%8D%E7%BD%AE" class="hash-link" aria-label="Direct link to 8.3 路由配置" title="Direct link to 8.3 路由配置" translate="no">​</a></h3>
<pre tabindex="0" class="codeBlockStandalone_MEMb thin-scrollbar language-tsx codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#0f172a;--prism-background-color:#f1f5f9"><code class="codeBlockLines_e6Vv"><span class="line"><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic">// App.tsx</span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">import</span><span style="color:#383A42;--shiki-dark:#ABB2BF"> { </span><span style="color:#E45649;--shiki-dark:#E06C75">OAuthCallback</span><span style="color:#383A42;--shiki-dark:#ABB2BF"> } </span><span style="color:#A626A4;--shiki-dark:#C678DD">from</span><span style="color:#50A14F;--shiki-dark:#98C379"> '@/pages/OAuthCallback'</span><span style="color:#383A42;--shiki-dark:#ABB2BF">;</span></span>
<span class="line"></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">const</span><span style="color:#986801;--shiki-dark:#E5C07B"> router</span><span style="color:#0184BC;--shiki-dark:#56B6C2"> =</span><span style="color:#4078F2;--shiki-dark:#61AFEF"> createBrowserRouter</span><span style="color:#383A42;--shiki-dark:#ABB2BF">([</span></span>
<span class="line"><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic">    // ...其他路由</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">    {</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#E06C75">        path</span><span style="color:#0184BC;--shiki-dark:#ABB2BF">:</span><span style="color:#50A14F;--shiki-dark:#98C379"> '/oauth/callback'</span><span style="color:#383A42;--shiki-dark:#ABB2BF">,</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#E06C75">        element</span><span style="color:#0184BC;--shiki-dark:#ABB2BF">:</span><span style="color:#383A42;--shiki-dark:#ABB2BF"> &lt;</span><span style="color:#C18401;--shiki-dark:#E5C07B">OAuthCallback</span><span style="color:#383A42;--shiki-dark:#ABB2BF"> /&gt;</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">    }</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">]);</span></span></code></pre>
<hr>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="九生产环境部署">九、生产环境部署<a href="https://blog.xiaoxiaoliang.top/en/blog/java-third-party-login#%E4%B9%9D%E7%94%9F%E4%BA%A7%E7%8E%AF%E5%A2%83%E9%83%A8%E7%BD%B2" class="hash-link" aria-label="Direct link to 九、生产环境部署" title="Direct link to 九、生产环境部署" translate="no">​</a></h2>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="91-环境变量配置">9.1 环境变量配置<a href="https://blog.xiaoxiaoliang.top/en/blog/java-third-party-login#91-%E7%8E%AF%E5%A2%83%E5%8F%98%E9%87%8F%E9%85%8D%E7%BD%AE" class="hash-link" aria-label="Direct link to 9.1 环境变量配置" title="Direct link to 9.1 环境变量配置" translate="no">​</a></h3>
<pre tabindex="0" class="codeBlockStandalone_MEMb thin-scrollbar language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#0f172a;--prism-background-color:#f1f5f9"><code class="codeBlockLines_e6Vv"><span class="line"><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic"># .env.production</span></span>
<span class="line"><span style="color:#E45649;--shiki-dark:#E06C75">GITHUB_CLIENT_ID</span><span style="color:#383A42;--shiki-dark:#56B6C2">=</span><span style="color:#50A14F;--shiki-dark:#98C379">your-production-client-id</span></span>
<span class="line"><span style="color:#E45649;--shiki-dark:#E06C75">GITHUB_CLIENT_SECRET</span><span style="color:#383A42;--shiki-dark:#56B6C2">=</span><span style="color:#50A14F;--shiki-dark:#98C379">your-production-client-secret</span></span>
<span class="line"></span>
<span class="line"><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic"># 必须是 32 字节的随机字符串</span></span>
<span class="line"><span style="color:#E45649;--shiki-dark:#E06C75">OAUTH_ENCRYPTION_KEY</span><span style="color:#383A42;--shiki-dark:#56B6C2">=</span><span style="color:#50A14F;--shiki-dark:#98C379">your-32-byte-random-production-key</span></span>
<span class="line"></span>
<span class="line"><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic"># 可选：其他平台</span></span>
<span class="line"><span style="color:#E45649;--shiki-dark:#E06C75">GOOGLE_CLIENT_ID</span><span style="color:#383A42;--shiki-dark:#56B6C2">=</span><span style="color:#50A14F;--shiki-dark:#98C379">...</span></span>
<span class="line"><span style="color:#E45649;--shiki-dark:#E06C75">GOOGLE_CLIENT_SECRET</span><span style="color:#383A42;--shiki-dark:#56B6C2">=</span><span style="color:#50A14F;--shiki-dark:#98C379">...</span></span></code></pre>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="92-https-配置">9.2 HTTPS 配置<a href="https://blog.xiaoxiaoliang.top/en/blog/java-third-party-login#92-https-%E9%85%8D%E7%BD%AE" class="hash-link" aria-label="Direct link to 9.2 HTTPS 配置" title="Direct link to 9.2 HTTPS 配置" translate="no">​</a></h3>
<p><strong>重要</strong>：生产环境必须使用 HTTPS！</p>
<pre tabindex="0" class="codeBlockStandalone_MEMb thin-scrollbar language-yaml codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#0f172a;--prism-background-color:#f1f5f9"><code class="codeBlockLines_e6Vv"><span class="line"><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic"># application-prod.yaml</span></span>
<span class="line"><span style="color:#E45649;--shiki-dark:#E06C75">server</span><span style="color:#383A42;--shiki-dark:#ABB2BF">:</span></span>
<span class="line"><span style="color:#E45649;--shiki-dark:#E06C75">  ssl</span><span style="color:#383A42;--shiki-dark:#ABB2BF">:</span></span>
<span class="line"><span style="color:#E45649;--shiki-dark:#E06C75">    enabled</span><span style="color:#383A42;--shiki-dark:#ABB2BF">: </span><span style="color:#986801;--shiki-dark:#D19A66">true</span></span>
<span class="line"><span style="color:#E45649;--shiki-dark:#E06C75">    key-store</span><span style="color:#383A42;--shiki-dark:#ABB2BF">: </span><span style="color:#50A14F;--shiki-dark:#98C379">classpath:keystore.p12</span></span>
<span class="line"><span style="color:#E45649;--shiki-dark:#E06C75">    key-store-password</span><span style="color:#383A42;--shiki-dark:#ABB2BF">: </span><span style="color:#50A14F;--shiki-dark:#98C379">${SSL_KEYSTORE_PASSWORD}</span></span>
<span class="line"><span style="color:#E45649;--shiki-dark:#E06C75">    key-store-type</span><span style="color:#383A42;--shiki-dark:#ABB2BF">: </span><span style="color:#50A14F;--shiki-dark:#98C379">PKCS12</span></span></code></pre>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="93-回调地址更新">9.3 回调地址更新<a href="https://blog.xiaoxiaoliang.top/en/blog/java-third-party-login#93-%E5%9B%9E%E8%B0%83%E5%9C%B0%E5%9D%80%E6%9B%B4%E6%96%B0" class="hash-link" aria-label="Direct link to 9.3 回调地址更新" title="Direct link to 9.3 回调地址更新" translate="no">​</a></h3>
<p>生产环境的回调地址需要更新为真实域名：</p>
<ol>
<li class="">修改 GitHub OAuth App 的 callback URL</li>
<li class="">更新代码中的 <code>REDIRECT_URI</code> 常量</li>
<li class="">更新前端重定向地址</li>
</ol>
<p>建议使用配置文件管理这些地址：</p>
<pre tabindex="0" class="codeBlockStandalone_MEMb thin-scrollbar language-yaml codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#0f172a;--prism-background-color:#f1f5f9"><code class="codeBlockLines_e6Vv"><span class="line"><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic"># application-prod.yaml</span></span>
<span class="line"><span style="color:#E45649;--shiki-dark:#E06C75">oauth</span><span style="color:#383A42;--shiki-dark:#ABB2BF">:</span></span>
<span class="line"><span style="color:#E45649;--shiki-dark:#E06C75">  github</span><span style="color:#383A42;--shiki-dark:#ABB2BF">:</span></span>
<span class="line"><span style="color:#E45649;--shiki-dark:#E06C75">    redirect-uri</span><span style="color:#383A42;--shiki-dark:#ABB2BF">: </span><span style="color:#50A14F;--shiki-dark:#98C379">https://api.example.com/oauth/callback/github</span></span>
<span class="line"><span style="color:#E45649;--shiki-dark:#E06C75">  frontend</span><span style="color:#383A42;--shiki-dark:#ABB2BF">:</span></span>
<span class="line"><span style="color:#E45649;--shiki-dark:#E06C75">    callback-url</span><span style="color:#383A42;--shiki-dark:#ABB2BF">: </span><span style="color:#50A14F;--shiki-dark:#98C379">https://example.com/oauth/callback</span></span>
<span class="line"><span style="color:#E45649;--shiki-dark:#E06C75">    settings-url</span><span style="color:#383A42;--shiki-dark:#ABB2BF">: </span><span style="color:#50A14F;--shiki-dark:#98C379">https://example.com/settings</span></span></code></pre>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="94-安全检查清单">9.4 安全检查清单<a href="https://blog.xiaoxiaoliang.top/en/blog/java-third-party-login#94-%E5%AE%89%E5%85%A8%E6%A3%80%E6%9F%A5%E6%B8%85%E5%8D%95" class="hash-link" aria-label="Direct link to 9.4 安全检查清单" title="Direct link to 9.4 安全检查清单" translate="no">​</a></h3>
<ul class="contains-task-list containsTaskList_mC6p">
<li class="task-list-item"><input type="checkbox" disabled=""> <!-- -->所有通信使用 HTTPS</li>
<li class="task-list-item"><input type="checkbox" disabled=""> <!-- -->Client Secret 通过环境变量配置，不在代码中硬编码</li>
<li class="task-list-item"><input type="checkbox" disabled=""> <!-- -->加密密钥足够随机且长度为 32 字节</li>
<li class="task-list-item"><input type="checkbox" disabled=""> <!-- -->日志中不包含敏感信息（token、code、secret）</li>
<li class="task-list-item"><input type="checkbox" disabled=""> <!-- -->redirect_uri 使用精确匹配，不允许通配符</li>
<li class="task-list-item"><input type="checkbox" disabled=""> <!-- -->State 参数已正确实现并验证</li>
</ul>
<hr>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="十常见问题与排查">十、常见问题与排查<a href="https://blog.xiaoxiaoliang.top/en/blog/java-third-party-login#%E5%8D%81%E5%B8%B8%E8%A7%81%E9%97%AE%E9%A2%98%E4%B8%8E%E6%8E%92%E6%9F%A5" class="hash-link" aria-label="Direct link to 十、常见问题与排查" title="Direct link to 十、常见问题与排查" translate="no">​</a></h2>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="q1-回调时报-redirect_uri-mismatch-错误">Q1: 回调时报 "redirect_uri mismatch" 错误<a href="https://blog.xiaoxiaoliang.top/en/blog/java-third-party-login#q1-%E5%9B%9E%E8%B0%83%E6%97%B6%E6%8A%A5-redirect_uri-mismatch-%E9%94%99%E8%AF%AF" class="hash-link" aria-label="Direct link to Q1: 回调时报 &quot;redirect_uri mismatch&quot; 错误" title="Direct link to Q1: 回调时报 &quot;redirect_uri mismatch&quot; 错误" translate="no">​</a></h3>
<p><strong>原因</strong>：代码中的回调地址与 GitHub OAuth App 配置不一致</p>
<p><strong>排查步骤</strong>：</p>
<ol>
<li class="">登录 GitHub，进入 OAuth App 设置页面</li>
<li class="">比对 "Authorization callback URL" 与代码中的地址</li>
<li class="">确保完全一致（包括协议、端口、路径、结尾斜杠）</li>
</ol>
<p><strong>常见差异</strong>：</p>
<ul>
<li class=""><code>http</code> vs <code>https</code></li>
<li class="">端口号不一致</li>
<li class="">路径大小写不一致</li>
<li class="">结尾是否有 <code>/</code></li>
</ul>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="q2-获取不到用户邮箱">Q2: 获取不到用户邮箱<a href="https://blog.xiaoxiaoliang.top/en/blog/java-third-party-login#q2-%E8%8E%B7%E5%8F%96%E4%B8%8D%E5%88%B0%E7%94%A8%E6%88%B7%E9%82%AE%E7%AE%B1" class="hash-link" aria-label="Direct link to Q2: 获取不到用户邮箱" title="Direct link to Q2: 获取不到用户邮箱" translate="no">​</a></h3>
<p><strong>原因</strong>：</p>
<ol>
<li class="">用户在 GitHub 设置中将邮箱设为私有</li>
<li class="">未请求 <code>user:email</code> scope</li>
</ol>
<p><strong>解决方案</strong>：</p>
<p>方案一：请求 <code>user:email</code> scope 后调用邮箱 API</p>
<pre tabindex="0" class="codeBlockStandalone_MEMb thin-scrollbar language-java codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#0f172a;--prism-background-color:#f1f5f9"><code class="codeBlockLines_e6Vv"><span class="line"><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic">// 获取用户邮箱列表</span></span>
<span class="line"><span style="color:#C18401;--shiki-dark:#E5C07B">String</span><span style="color:#E45649;--shiki-dark:#E06C75"> emailsJson </span><span style="color:#383A42;--shiki-dark:#56B6C2">=</span><span style="color:#E45649;--shiki-dark:#E5C07B"> HttpRequest</span><span style="color:#383A42;--shiki-dark:#ABB2BF">.</span><span style="color:#4078F2;--shiki-dark:#61AFEF">get</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(</span><span style="color:#50A14F;--shiki-dark:#98C379">"https://api.github.com/user/emails"</span><span style="color:#383A42;--shiki-dark:#ABB2BF">)</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">        .</span><span style="color:#4078F2;--shiki-dark:#61AFEF">header</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(</span><span style="color:#50A14F;--shiki-dark:#98C379">"Authorization"</span><span style="color:#383A42;--shiki-dark:#ABB2BF">, </span><span style="color:#50A14F;--shiki-dark:#98C379">"token "</span><span style="color:#383A42;--shiki-dark:#56B6C2"> +</span><span style="color:#383A42;--shiki-dark:#ABB2BF"> accessToken)</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">        .</span><span style="color:#4078F2;--shiki-dark:#61AFEF">execute</span><span style="color:#383A42;--shiki-dark:#ABB2BF">()</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">        .</span><span style="color:#4078F2;--shiki-dark:#61AFEF">body</span><span style="color:#383A42;--shiki-dark:#ABB2BF">();</span></span>
<span class="line"><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic">// 从返回的邮箱列表中选择 primary 或 verified 的邮箱</span></span></code></pre>
<p>方案二：允许邮箱为空，后续引导用户补充</p>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="q3-access_token-过期了怎么办">Q3: access_token 过期了怎么办？<a href="https://blog.xiaoxiaoliang.top/en/blog/java-third-party-login#q3-access_token-%E8%BF%87%E6%9C%9F%E4%BA%86%E6%80%8E%E4%B9%88%E5%8A%9E" class="hash-link" aria-label="Direct link to Q3: access_token 过期了怎么办？" title="Direct link to Q3: access_token 过期了怎么办？" translate="no">​</a></h3>
<p><strong>GitHub</strong>：access_token 默认不过期，无需刷新</p>
<p><strong>微信/其他平台</strong>：需要使用 refresh_token 刷新</p>
<pre tabindex="0" class="codeBlockStandalone_MEMb thin-scrollbar language-java codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#0f172a;--prism-background-color:#f1f5f9"><code class="codeBlockLines_e6Vv"><span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">public</span><span style="color:#C18401;--shiki-dark:#E5C07B"> String</span><span style="color:#4078F2;--shiki-dark:#61AFEF"> refreshAccessToken</span><span style="color:#383A42;--shiki-dark:#E06C75">(</span><span style="color:#C18401;--shiki-dark:#E5C07B">String</span><span style="color:#383A42;--shiki-dark:#E06C75"> provider</span><span style="color:#383A42;--shiki-dark:#ABB2BF">,</span><span style="color:#C18401;--shiki-dark:#E5C07B"> String</span><span style="color:#383A42;--shiki-dark:#E06C75"> refreshToken) {</span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">    if</span><span style="color:#383A42;--shiki-dark:#E06C75"> (</span><span style="color:#50A14F;--shiki-dark:#98C379">"wechat"</span><span style="color:#383A42;--shiki-dark:#ABB2BF">.</span><span style="color:#4078F2;--shiki-dark:#61AFEF">equals</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(provider)</span><span style="color:#383A42;--shiki-dark:#E06C75">) {</span></span>
<span class="line"><span style="color:#C18401;--shiki-dark:#E5C07B">        String</span><span style="color:#E45649;--shiki-dark:#E06C75"> url </span><span style="color:#383A42;--shiki-dark:#56B6C2">=</span><span style="color:#E45649;--shiki-dark:#E5C07B"> String</span><span style="color:#383A42;--shiki-dark:#ABB2BF">.</span><span style="color:#4078F2;--shiki-dark:#61AFEF">format</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(</span></span>
<span class="line"><span style="color:#50A14F;--shiki-dark:#98C379">            "https://api.weixin.qq.com/sns/oauth2/refresh_token?appid=%s&amp;grant_type=refresh_token&amp;refresh_token=%s"</span><span style="color:#383A42;--shiki-dark:#ABB2BF">,</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">            appId, refreshToken</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">        );</span></span>
<span class="line"><span style="color:#C18401;--shiki-dark:#E5C07B">        String</span><span style="color:#E45649;--shiki-dark:#E06C75"> response </span><span style="color:#383A42;--shiki-dark:#56B6C2">=</span><span style="color:#E45649;--shiki-dark:#E5C07B"> HttpUtil</span><span style="color:#383A42;--shiki-dark:#ABB2BF">.</span><span style="color:#4078F2;--shiki-dark:#61AFEF">get</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(url);</span></span>
<span class="line"><span style="color:#C18401;--shiki-dark:#E5C07B">        JSONObject</span><span style="color:#E45649;--shiki-dark:#E06C75"> json </span><span style="color:#383A42;--shiki-dark:#56B6C2">=</span><span style="color:#E45649;--shiki-dark:#E5C07B"> JSON</span><span style="color:#383A42;--shiki-dark:#ABB2BF">.</span><span style="color:#4078F2;--shiki-dark:#61AFEF">parseObject</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(response);</span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">        return</span><span style="color:#E45649;--shiki-dark:#E5C07B"> json</span><span style="color:#383A42;--shiki-dark:#ABB2BF">.</span><span style="color:#4078F2;--shiki-dark:#61AFEF">getString</span><span style="color:#383A42;--shiki-dark:#ABB2BF">(</span><span style="color:#50A14F;--shiki-dark:#98C379">"access_token"</span><span style="color:#383A42;--shiki-dark:#ABB2BF">);</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#E06C75">    }</span></span>
<span class="line"><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic">    // 其他平台...</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#E06C75">}</span></span></code></pre>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="q4-同一个第三方账号可以绑定多个用户吗">Q4: 同一个第三方账号可以绑定多个用户吗？<a href="https://blog.xiaoxiaoliang.top/en/blog/java-third-party-login#q4-%E5%90%8C%E4%B8%80%E4%B8%AA%E7%AC%AC%E4%B8%89%E6%96%B9%E8%B4%A6%E5%8F%B7%E5%8F%AF%E4%BB%A5%E7%BB%91%E5%AE%9A%E5%A4%9A%E4%B8%AA%E7%94%A8%E6%88%B7%E5%90%97" class="hash-link" aria-label="Direct link to Q4: 同一个第三方账号可以绑定多个用户吗？" title="Direct link to Q4: 同一个第三方账号可以绑定多个用户吗？" translate="no">​</a></h3>
<p><strong>不可以</strong>。这会导致安全问题（谁登录到谁的账号？）</p>
<p>本项目通过唯一索引 <code>uk_provider_user</code> 确保：</p>
<ul>
<li class="">同一个 GitHub 账号只能绑定一个用户</li>
<li class="">尝试重复绑定会触发异常</li>
</ul>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="q5-如何支持用户解绑后重新绑定">Q5: 如何支持用户解绑后重新绑定？<a href="https://blog.xiaoxiaoliang.top/en/blog/java-third-party-login#q5-%E5%A6%82%E4%BD%95%E6%94%AF%E6%8C%81%E7%94%A8%E6%88%B7%E8%A7%A3%E7%BB%91%E5%90%8E%E9%87%8D%E6%96%B0%E7%BB%91%E5%AE%9A" class="hash-link" aria-label="Direct link to Q5: 如何支持用户解绑后重新绑定？" title="Direct link to Q5: 如何支持用户解绑后重新绑定？" translate="no">​</a></h3>
<p>使用<strong>逻辑删除</strong>：</p>
<ul>
<li class="">解绑时设置 <code>deleted = 1</code></li>
<li class="">唯一索引包含 <code>deleted</code> 字段</li>
<li class="">用户可以重新绑定同一个第三方账号</li>
</ul>
<hr>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="十一扩展阅读">十一、扩展阅读<a href="https://blog.xiaoxiaoliang.top/en/blog/java-third-party-login#%E5%8D%81%E4%B8%80%E6%89%A9%E5%B1%95%E9%98%85%E8%AF%BB" class="hash-link" aria-label="Direct link to 十一、扩展阅读" title="Direct link to 十一、扩展阅读" translate="no">​</a></h2>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="111-支持更多平台">11.1 支持更多平台<a href="https://blog.xiaoxiaoliang.top/en/blog/java-third-party-login#111-%E6%94%AF%E6%8C%81%E6%9B%B4%E5%A4%9A%E5%B9%B3%E5%8F%B0" class="hash-link" aria-label="Direct link to 11.1 支持更多平台" title="Direct link to 11.1 支持更多平台" translate="no">​</a></h3>
<p>只需实现 <code>OAuthStrategy</code> 接口：</p>
<pre tabindex="0" class="codeBlockStandalone_MEMb thin-scrollbar language-java codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#0f172a;--prism-background-color:#f1f5f9"><code class="codeBlockLines_e6Vv"><span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">@</span><span style="color:#A626A4;--shiki-dark:#E5C07B">Component</span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">public</span><span style="color:#A626A4;--shiki-dark:#C678DD"> class</span><span style="color:#C18401;--shiki-dark:#E5C07B"> GoogleOAuthStrategy</span><span style="color:#A626A4;--shiki-dark:#C678DD"> implements</span><span style="color:#C18401;--shiki-dark:#E5C07B"> OAuthStrategy</span><span style="color:#383A42;--shiki-dark:#ABB2BF"> {</span></span>
<span class="line"><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic">    // Google OAuth 实现</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">}</span></span>
<span class="line"></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">@</span><span style="color:#A626A4;--shiki-dark:#E5C07B">Component</span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">public</span><span style="color:#A626A4;--shiki-dark:#C678DD"> class</span><span style="color:#C18401;--shiki-dark:#E5C07B"> WeChatOAuthStrategy</span><span style="color:#A626A4;--shiki-dark:#C678DD"> implements</span><span style="color:#C18401;--shiki-dark:#E5C07B"> OAuthStrategy</span><span style="color:#383A42;--shiki-dark:#ABB2BF"> {</span></span>
<span class="line"><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic">    // 微信 OAuth 实现</span></span>
<span class="line"><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic">    // 注意：微信使用 appid/appsecret 而非 client_id/client_secret</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">}</span></span>
<span class="line"></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">@</span><span style="color:#A626A4;--shiki-dark:#E5C07B">Component</span></span>
<span class="line"><span style="color:#A626A4;--shiki-dark:#C678DD">public</span><span style="color:#A626A4;--shiki-dark:#C678DD"> class</span><span style="color:#C18401;--shiki-dark:#E5C07B"> QQOAuthStrategy</span><span style="color:#A626A4;--shiki-dark:#C678DD"> implements</span><span style="color:#C18401;--shiki-dark:#E5C07B"> OAuthStrategy</span><span style="color:#383A42;--shiki-dark:#ABB2BF"> {</span></span>
<span class="line"><span style="color:#A0A1A7;--shiki-light-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic">    // QQ OAuth 实现</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#ABB2BF">}</span></span></code></pre>
<p>策略工厂会自动发现并注册。</p>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="112-oauth-21-新特性">11.2 OAuth 2.1 新特性<a href="https://blog.xiaoxiaoliang.top/en/blog/java-third-party-login#112-oauth-21-%E6%96%B0%E7%89%B9%E6%80%A7" class="hash-link" aria-label="Direct link to 11.2 OAuth 2.1 新特性" title="Direct link to 11.2 OAuth 2.1 新特性" translate="no">​</a></h3>
<p>OAuth 2.1 是 OAuth 2.0 的升级版，主要变化：</p>
<ol>
<li class=""><strong>废弃隐式模式</strong>：不再推荐在前端直接获取 token</li>
<li class=""><strong>强制 PKCE</strong>：所有客户端都应使用 PKCE 增强安全性</li>
<li class=""><strong>Refresh Token 轮换</strong>：每次刷新生成新的 refresh_token</li>
</ol>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="113-oidc-简介">11.3 OIDC 简介<a href="https://blog.xiaoxiaoliang.top/en/blog/java-third-party-login#113-oidc-%E7%AE%80%E4%BB%8B" class="hash-link" aria-label="Direct link to 11.3 OIDC 简介" title="Direct link to 11.3 OIDC 简介" translate="no">​</a></h3>
<p>OpenID Connect（OIDC）是建立在 OAuth 2.0 之上的身份认证协议：</p>
<ul>
<li class="">OAuth 2.0 解决<strong>授权</strong>问题</li>
<li class="">OIDC 解决<strong>认证</strong>问题</li>
</ul>
<p>OIDC 引入了 <code>id_token</code>（JWT 格式），包含用户身份信息，适合需要验证用户身份的场景。</p>
<hr>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="总结">总结<a href="https://blog.xiaoxiaoliang.top/en/blog/java-third-party-login#%E6%80%BB%E7%BB%93" class="hash-link" aria-label="Direct link to 总结" title="Direct link to 总结" translate="no">​</a></h2>
<p>实现安全的第三方登录需要关注以下要点：</p>
<table><thead><tr><th>方面</th><th>最佳实践</th></tr></thead><tbody><tr><td><strong>架构设计</strong></td><td>使用策略模式，支持多平台扩展</td></tr><tr><td><strong>CSRF 防护</strong></td><td>State 参数 + Redis 存储 + 一次性使用</td></tr><tr><td><strong>令牌安全</strong></td><td>AES-256-GCM 加密存储，禁止日志输出</td></tr><tr><td><strong>传输安全</strong></td><td>强制 HTTPS，token 仅后端流转</td></tr><tr><td><strong>配置管理</strong></td><td>敏感信息使用环境变量，多环境隔离</td></tr></tbody></table>
<hr>
<p><em>本文基于 Spring Boot 3.x + Sa-Token + Redis + MyBatis-Plus 实现</em>
<em>更新时间：2025年12月15日</em></p>]]></content>
        <author>
            <name>小小亮</name>
            <uri>https://github.com/xiaoxiaoliang</uri>
        </author>
        <category label="Java" term="Java"/>
        <category label="OAuth" term="OAuth"/>
        <category label="第三方登录" term="第三方登录"/>
    </entry>
</feed>