<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>架构 on 姚玉亮的网络日志</title><link>https://yusanwen-code.github.io/tags/%E6%9E%B6%E6%9E%84/</link><description>Recent content in 架构 on 姚玉亮的网络日志</description><generator>Hugo -- gohugo.io</generator><language>zh-CN</language><lastBuildDate>Fri, 04 Sep 2026 10:00:00 +0800</lastBuildDate><atom:link href="https://yusanwen-code.github.io/tags/%E6%9E%B6%E6%9E%84/index.xml" rel="self" type="application/rss+xml"/><item><title>最强的模型选了主键关联：不是它不够强，是业务没人替它想清楚</title><link>https://yusanwen-code.github.io/posts/post-77/</link><pubDate>Fri, 04 Sep 2026 10:00:00 +0800</pubDate><guid>https://yusanwen-code.github.io/posts/post-77/</guid><description>&lt;img src="https://yusanwen-code.github.io/images/post-77-cover.jpg" alt="Featured image of post 最强的模型选了主键关联：不是它不够强，是业务没人替它想清楚" /&gt;&lt;h2 id="先承认模型真的很强"&gt;先承认：模型真的很强&#10;&lt;/h2&gt;&lt;p&gt;我是 yusanwen，一名深度使用 AI 的工程师。我承认大模型是真的强，强到逻辑推演接近无敌：让它在代码里找边界情况、做多步推理，它做得比大多数同事都快。这篇文章不是唱衰模型，恰恰相反：正因为承认它强，有个问题才值得写下来。&lt;/p&gt;&#10;&lt;h2 id="一个当下完全正确的-demo"&gt;一个&amp;quot;当下完全正确&amp;quot;的 demo&#10;&lt;/h2&gt;&lt;p&gt;前几天我用目前能调到的最强档 Codex 模型（5.6 sol）做一个内部小 demo。需求很小：描述几个实体、说清楚它们之间的关系，让它建一套带接口的最小系统。我没有给表结构、没有给任何存储选型约束，想看看它&amp;quot;自己会怎么设计&amp;quot;。&lt;/p&gt;&#10;&lt;p&gt;它交出来的东西非常干净：每个实体一张表，自增主键，实体之间全部用主键 id 互相外键关联。代码规整，测试全绿，接口能跑。一切看起来完美。&lt;/p&gt;&#10;&lt;p&gt;但我盯着那张 ER 图，后背有点发凉。&lt;/p&gt;&#10;&lt;p&gt;单看当下，主键关联没有任何问题。问题藏在一个没说出口的假设里：这套关联成立的前提，是&amp;quot;永远只有这一张主键表、永远只有一套存储、永远不迁移&amp;quot;。&lt;/p&gt;&#10;&lt;p&gt;只要未来有一天换库类型（比如从单库换成分库/异构存储，或者业务合并、要和另一套系统的数据对上），以主键 id 为语义的关联就全线失效：两边都有 id=1，却不是同一个东西。那时怎么办？洗数据：把几十万行关联按业务规则重映射。能做，但那是用未来的一场大手术，偿还现在省下的十分钟。&lt;/p&gt;&#10;&lt;p&gt;说&amp;quot;后背发凉&amp;quot;，不是这套设计错了，主键外键在大多数单库场景就是对的。凉的是：这个&amp;quot;对当下完全正确、对未来埋雷&amp;quot;的决策，全程没有任何人做过。模型默默地替你选了，而你默认了。&lt;/p&gt;&#10;&lt;h2 id="为什么最强的模型选了最平均的方案"&gt;为什么最强的模型，选了最&amp;quot;平均&amp;quot;的方案&#10;&lt;/h2&gt;&lt;p&gt;第一反应当然是怪模型：不是号称最强吗，连外键策略都选不好？想了两分钟，我收回这句话。&lt;/p&gt;&#10;&lt;p&gt;因为大模型本质上是概率模型。它产出的不是&amp;quot;最优解&amp;quot;，而是训练数据分布里的最可能解：在它见过的几千万张表里，&amp;ldquo;自增主键 + id 外键&amp;quot;是最常见的写法，没有之一。我描述实体和关系时没给任何反向约束，它按概率落点，自然落在最常见、最不需要解释的答案上。&lt;/p&gt;&#10;&lt;p&gt;它优化的是&amp;quot;这段设计看起来像什么&amp;rdquo;，不是&amp;quot;这段设计未来扛不扛得住&amp;quot;。更要命的是，它没有 skin in the game：换库的迁移成本是几个月后的我的，不是现在的它的。一个不用承担后果的决策者，天然倾向于选择让自己&amp;quot;看起来最正常&amp;quot;的选项，哪怕那个选项把风险往后推。&lt;/p&gt;&#10;&lt;p&gt;所以，凡是你没说出口的约束，概率模型都会替你平均掉。&lt;/p&gt;&#10;&lt;h2 id="为什么这套设计能一路绿灯"&gt;为什么这套设计能一路绿灯&#10;&lt;/h2&gt;&lt;p&gt;因为链条上每个环节都在奖励&amp;quot;快&amp;quot;。demo 要得急，模型写得快，自测全绿，人扫一眼觉得&amp;quot;挺规范的&amp;quot;。管理层的关注点不在主键策略上，利益也不在那里：那里要的是交付速度、团队吞吐、demo 赶紧变成能演示的东西。没有人有动力为一个&amp;quot;当下完全正确&amp;quot;的设计踩刹车。&lt;/p&gt;&#10;&lt;p&gt;于是模型参与越深、任务越重，这种&amp;quot;概率平局&amp;quot;的设计被无审查放行的比例就越高。不是哪个人不负责，是速度把&amp;quot;想清楚&amp;quot;这个环节压缩没了，而模型用一个看似专业的默认值，恰好补上了这个真空。它越强，输出越像资深工程师的手笔，就越没人质疑。我在这个 demo 里看到的最危险的东西，不是主键，是流畅的平庸。&lt;/p&gt;&#10;&lt;h2 id="解法把约束说出口而不是靠模型自觉"&gt;解法：把约束说出口，而不是靠模型自觉&#10;&lt;/h2&gt;&lt;p&gt;结论不是&amp;quot;关键设计别让模型碰&amp;quot;，那是因噎废食。要做的，是把被速度压缩掉的环节，重新变成显式的东西。&lt;/p&gt;&#10;&lt;p&gt;解法一：关系是业务语义，先写出来，再让模型建表。&lt;/p&gt;&#10;&lt;p&gt;&amp;ldquo;订单属于用户&amp;quot;是业务，&amp;ldquo;订单表加一列 user_id 外键&amp;quot;是实现。只给模型后者，它就会顺着 id 一路关联下去；给它前者，它才可能停下来想&amp;quot;该用哪个键表达这个关系&amp;rdquo;。所以我现在的做法是，做这类 demo 前先花十分钟写一段领域骨架：每个实体的业务唯一键是什么、实体之间靠什么语义关联（单号？编码？还是本就同库同生命周期？）。这段骨架不属于任何技术选型，换什么存储都成立。写清楚之后模型再怎么实现都安全，因为最危险的那个决策，已经被写下来的业务做完了。&lt;/p&gt;&#10;&lt;p&gt;解法二：把&amp;quot;换库演练&amp;quot;写进验收条件。&lt;/p&gt;&#10;&lt;p&gt;给模型的验收清单里加一条：回答&amp;quot;如果存储换成另一种形态，这套关联要改多少、怎么改&amp;rdquo;。它答不上来，说明关联设计偷懒了。这一步几乎零成本，却把&amp;quot;未来的成本&amp;quot;拉进了当前这一回合：模型不能只优化当下，因为验收里写了未来。有意思的是，概率模型其实答得上来这种问题，只要你问。&lt;/p&gt;&#10;&lt;p&gt;解法三：踩过的坑沉淀成约束，注入而不是叮嘱。&lt;/p&gt;&#10;&lt;p&gt;主键关联这种事，踩一次就够。但&amp;quot;记住&amp;quot;不该靠人脑，该靠系统：把反模式写进团队的编码规范，再通过 AGENTS.md / Skill / 提示词模板注入每个后续任务，比如&amp;quot;跨实体关联必须引用业务唯一键，或用一句话说明为什么不能&amp;quot;。AI 时代，编码经验的载体正在从&amp;quot;老员工脑子里&amp;quot;变成&amp;quot;可注入的约束文件里&amp;quot;。谁的约束写得全，谁的模型输出就靠谱。说白了，就是把概率模型的落点，用显式经验强行掰回来。&lt;/p&gt;&#10;&lt;p&gt;解法四：架构决策不外包，但让模型自证最坏情况。&lt;/p&gt;&#10;&lt;p&gt;实现可以外包，设计决策留一道复核：交付前让模型自己列&amp;quot;这个设计在哪些条件下会失效&amp;quot;。它列得出来，列不出来才是问题。这一条的本质是把隐性决策变成显性决策：主键外键可以选，但必须是&amp;quot;讨论过、知道代价&amp;quot;之后的选，而不是概率的默认值。&lt;/p&gt;&#10;&lt;h2 id="结尾两种能力"&gt;结尾：两种能力&#10;&lt;/h2&gt;&lt;p&gt;回到开头。我承认大模型很强，逻辑能力甚至接近无敌。但这次 demo 让我更确定另一句话：&lt;/p&gt;&#10;&#10; &lt;blockquote&gt;&#10; &lt;p&gt;大模型的强，是&amp;quot;把说出口的经验变成执行&amp;quot;的强；而&amp;quot;把没说出口的经验变成决策&amp;quot;，暂时还得靠人。&lt;/p&gt;&#10;&#10; &lt;/blockquote&gt;&#10;&lt;p&gt;未来能编好程序的人，一定是两种能力的组合：深谙业务，知道哪些关联是语义、哪些只是巧合；善用模型，能把业务约束、验收标准、历史教训，全部显式地喂给它。会写代码正在贬值，&amp;ldquo;能把业务说清楚&amp;quot;正在升值。模型写出的每一行都在替你打工，但往 prompt 里放什么约束，决定了它是替你赚钱，还是替你埋雷。编码的姿势，暂时还是值钱的。&lt;/p&gt;&#10;&#10; &lt;blockquote&gt;&#10; &lt;p&gt;封面图：&lt;a class="link" href="https://www.flickr.com/photos/26344495@N05/32735744587" target="_blank" rel="noopener"&#10; &gt;Ivan Radic / Flickr&lt;/a&gt; · CC BY 2.0&lt;/p&gt;&#10;&#10; &lt;/blockquote&gt;&#10;</description></item><item><title>对话编排层重构：从 Go 业务编排迁移到 Python LangGraph</title><link>https://yusanwen-code.github.io/posts/post-76/</link><pubDate>Wed, 02 Sep 2026 20:00:00 +0800</pubDate><guid>https://yusanwen-code.github.io/posts/post-76/</guid><description>&lt;img src="https://yusanwen-code.github.io/images/post-76-cover.jpg" alt="Featured image of post 对话编排层重构：从 Go 业务编排迁移到 Python LangGraph" /&gt;&lt;h2 id="一条报数指令暴露的问题"&gt;一条报数指令暴露的问题&#10;&lt;/h2&gt;&lt;p&gt;炼丹炉（&lt;a class="link" href="https://github.com/yusanwen-code/alchemy-furnace" target="_blank" rel="noopener"&#10; &gt;Alchemy Furnace&lt;/a&gt;，我的开源项目）的群聊里，我发过这样一条指令：&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;div style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&#10;&lt;table style="border-spacing:0;padding:0;margin:0;border:0;"&gt;&lt;tr&gt;&lt;td style="vertical-align:top;padding:0;margin:0;border:0;"&gt;&#10;&lt;pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code&gt;&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#737679"&gt;1&#10;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&#10;&lt;td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%"&gt;&#10;&lt;pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;@全体成员 全体都有！报数！&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;&#10;&lt;/div&gt;&#10;&lt;/div&gt;&lt;p&gt;预期很明确：A、B、C、D 四个道人按顺序报 1、2、3、4。实际跑起来，它被当成了闲聊：有时只让一个人接话，有时人设和记忆指令还能把硬指令覆盖掉，报数报成了自由发挥。&lt;/p&gt;&#10;&lt;p&gt;这不是一个偶发 bug，是一类问题的缩影：当编排逻辑长在业务代码里，&amp;ldquo;确定性指令&amp;quot;和&amp;quot;开放对话&amp;quot;就没有边界。群聊调度全部写在 Go 网关里，意图分类、发言人选择、轮次策略和业务逻辑缠在一起，每加一个编排能力都要改 Go、编译、发布，而编排恰恰是整个产品里变化最快的部分。&lt;/p&gt;&#10;&lt;p&gt;于是我做了这次架构迁移：把提示词构建、群聊调度和模型调用编排，整体从 Go 迁到 Python LangGraph，形成「Go 负责数据与 CRUD，Python 负责智能编排」的分层。&lt;/p&gt;&#10;&lt;h2 id="go-在变成-agent-框架"&gt;Go 在变成 Agent 框架&#10;&lt;/h2&gt;&lt;p&gt;迁移前炼丹炉是双后端：Go 网关（Gin）+ Python 引擎（FastAPI）。这个结构本身没问题，问题在于群聊上线后，Go 里长出了第三种性质的职责。&lt;/p&gt;&#10;&lt;p&gt;一轮群聊对话，Go 实际在做三件事：&lt;/p&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;数据：会话、消息、道人、金丹、记忆、模型的存储与 CRUD，凭据加密，这是 Go 的本行&lt;/li&gt;&#10;&lt;li&gt;协议：对前端的 SSE 流、内部服务调用，是稳定的契约面&lt;/li&gt;&#10;&lt;li&gt;智能编排：意图分类、发言人选择、提示词拼装、模型调用、轮次与收敛策略，这部分高频变化、需要快速实验&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;p&gt;第三类和前两类完全不是一种东西。编排层需要的是图结构、状态机、检查点、可恢复执行、结构化输出校验，这些 LangGraph 生态里全是现成的，Go 里全要手写。让一个后端语言慢慢长成一个 Agent 框架，才是最贵的路线。&lt;/p&gt;&#10;&lt;h2 id="分层go-管什么python-管什么"&gt;分层：Go 管什么，Python 管什么&#10;&lt;/h2&gt;&lt;p&gt;迁移后的职责边界：&lt;/p&gt;&#10;&lt;table&gt;&#10;&#9;&lt;thead&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th&gt;职责&lt;/th&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th&gt;归属&lt;/th&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&lt;/thead&gt;&#10;&#9;&lt;tbody&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;认证、请求校验、凭据加密存储&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Go&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;会话/消息/道人/金丹/记忆/模型的存储与 CRUD&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Go&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;正式消息落库（只在 &lt;code&gt;assistant_final&lt;/code&gt; 之后，按 run_id 幂等）&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Go&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;内部事件 → 前端 SSE 的翻译&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Go&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;单聊/群聊路由&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Python LangGraph&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;指令分类、确定性路由&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Python&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Supervisor 计划、发言人调度、收敛&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Python&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;提示词构建、模型调用、重试&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Python&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;记忆检索策略与记忆提案&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Python 提案 → Go 校验落库&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&lt;/tbody&gt;&#10;&lt;/table&gt;&#10;&lt;p&gt;说白了：Go 不再选择发言人、不再分类对话意图、不再拼 prompt，也不再管轮次策略。前端仍然只调 Go、只消费 Go 的 SSE。对前端来说契约没变，变的是 SSE 背后的调度从 Go 挪进了图里。&lt;/p&gt;&#10;&lt;p&gt;边界里我最在意的一条：凭据不进图状态。API key、解密后的凭据走 LangGraph 的 &lt;code&gt;context_schema&lt;/code&gt;（RuntimeContext：模型网关、凭据、调试开关、取消旗标），与图状态彻底分离：它们绝不进入图状态、SQLite checkpoint、对外事件和日志。图状态里只有快照：历史、道人、金丹、记忆、模型引用，都是 Go 在发起一轮 run 时传进来的不可变快照。&lt;/p&gt;&#10;&lt;h2 id="图架构一张会话图一张可复用的道人图"&gt;图架构：一张会话图，一张可复用的道人图&#10;&lt;/h2&gt;&lt;p&gt;整个编排是四层图，顶层一张会话图：&lt;/p&gt;&#10;&#10;&lt;pre class="mermaid"&gt;&#10;flowchart TD&#10; A[&amp;#34;ConversationGraph（一次用户轮入口）&amp;#34;] --&amp;gt; B[&amp;#34;hydrate_context&amp;#34;] --&amp;gt; C[&amp;#34;route_session&amp;#34;]&#10; C --&amp;gt; D[&amp;#34;SingleChatGraph&amp;#34;] --&amp;gt; D1[&amp;#34;DaoistGraph（复用子图）&amp;#34;]&#10; C --&amp;gt; E[&amp;#34;GroupChatGraph&amp;#34;] --&amp;gt; F[&amp;#34;classify_directive&amp;#34;]&#10; F --&amp;gt; G[&amp;#34;deterministic_router（确定性指令）&amp;#34;]&#10; F --&amp;gt; H[&amp;#34;supervisor（开放讨论出计划）&amp;#34;]&#10; G --&amp;gt; I[&amp;#34;dispatch_daoists&amp;#34;]&#10; H --&amp;gt; I&#10; I --&amp;gt; J[&amp;#34;DaoistGraph × N&amp;#34;]&#10; I --&amp;gt; K[&amp;#34;convergence 收敛&amp;#34;]&#10; K --&amp;gt; L[&amp;#34;propose_memory 记忆提案 → Go 校验落库&amp;#34;]&#10;&lt;/pre&gt;&#10;&#10;&lt;p&gt;关键设计是 DaoistGraph 作为可复用子图：单聊直接调它，群聊每个发言人各自子运行一张。单聊和每个群成员走的是同一条 prompt 构建 + 模型调用管线：选定记忆、编译人设与金丹行为、调模型、校验响应、流出事件。这一条保证了&amp;quot;行为可解释&amp;rdquo;：单聊什么效果，群聊里同一个道人就是什么效果，不存在两套 prompt 逻辑。&lt;/p&gt;&#10;&lt;p&gt;群聊图里的路由是有讲究的：&lt;code&gt;classify_directive&lt;/code&gt; 先分类这一轮用户输入，确定性指令走 &lt;code&gt;deterministic_router&lt;/code&gt; 直接成计划，开放讨论才走 &lt;code&gt;supervisor&lt;/code&gt; 让模型出计划，这就是下一节的内容。&lt;/p&gt;&#10;&lt;h2 id="混合导演该调模型的调模型不该调的绝不调"&gt;混合导演：该调模型的调模型，不该调的绝不调&#10;&lt;/h2&gt;&lt;p&gt;群聊调度我用的是混合导演策略，核心就一句：确定性的事情用代码，创造性的事情用模型。&lt;/p&gt;&#10;&lt;p&gt;确定性指令永不委托模型。直接 @某个成员、@全体成员、报数、停止/继续，这些必须在代码里走死路径。报数的实现是：路由器为每个成员构造一个不可变任务，里面写死序号；人设、记忆、闲聊风格规则只能影响语气，不能改数字、不能漏人、不能加戏、不能改顺序。报数这种事交给模型去&amp;quot;理解&amp;quot;，就是开头那个 bug 的根源。&lt;/p&gt;&#10;&lt;p&gt;开放讨论交给 Supervisor。这一轮该谁说话、按什么顺序、每人拿到什么任务、发言预算多少、要不要收束，由会话默认模型（非人设，不扮演任何道人）产出一份结构化计划。Supervisor 不可见、不写用户可见的文本，只出计划。它失败或产出无效计划时，确定性回退到被明确 @ 的成员或主成员，导演的错误不传染整轮。&lt;/p&gt;&#10;&lt;p&gt;这样分工还有个实际的好处：明确的群指令不花 Supervisor 的模型调用，单聊用被选中道人自己配置的模型。钱花在真正需要智能的地方。&lt;/p&gt;&#10;&lt;h2 id="可恢复执行中断在发言人边界续跑按差集"&gt;可恢复执行：中断在发言人边界，续跑按差集&#10;&lt;/h2&gt;&lt;p&gt;这是整个迁移里技术含量最高的部分，也是最值得抄的部分。&lt;/p&gt;&#10;&lt;p&gt;每个用户轮有全局唯一的 &lt;code&gt;run_id&lt;/code&gt;，每条终稿回复有唯一 &lt;code&gt;reply_id&lt;/code&gt;，Go 按这两个 ID 做落库幂等。LangGraph 侧用本地异步 SQLite checkpointer 做检查点。用户点停止、前端断连、进程重启，都要能恢复，而且恢复时不能重复已完成发言人的发言。&lt;/p&gt;&#10;&lt;p&gt;做法是把恢复粒度设计在发言人边界：dispatch 节点按计划逐个发言人在子图里跑，每个发言人之前先 &lt;code&gt;await asyncio.sleep(0)&lt;/code&gt; 让出事件循环再查取消旗标：取消请求只能落在 await 点，这样中断恰好落在发言人之间的缝隙里，不会截断一条发到一半的回复。&lt;/p&gt;&#10;&lt;p&gt;续跑靠差集裁决。顶层检查点只携带「计划 + 已完成回复」，恢复重放时：&lt;/p&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;计划已存在 → 不重新规划、不重发 &lt;code&gt;plan_created&lt;/code&gt; 事件&lt;/li&gt;&#10;&lt;li&gt;已完成的发言人 → 跳过，只在剩余差集上继续&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;p&gt;其中藏着一个容易踩的坑，代码里的路由顺序：&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;div style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&#10;&lt;table style="border-spacing:0;padding:0;margin:0;border:0;"&gt;&lt;tr&gt;&lt;td style="vertical-align:top;padding:0;margin:0;border:0;"&gt;&#10;&lt;pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code&gt;&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#737679"&gt;1&#10;&lt;/span&gt;&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#737679"&gt;2&#10;&lt;/span&gt;&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#737679"&gt;3&#10;&lt;/span&gt;&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#737679"&gt;4&#10;&lt;/span&gt;&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#737679"&gt;5&#10;&lt;/span&gt;&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#737679"&gt;6&#10;&lt;/span&gt;&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#737679"&gt;7&#10;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&#10;&lt;td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%"&gt;&#10;&lt;pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-python" data-lang="python"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#ff7b72"&gt;def&lt;/span&gt; &lt;span style="color:#d2a8ff;font-weight:bold"&gt;_route_after_router&lt;/span&gt;(state) &lt;span style="color:#ff7b72;font-weight:bold"&gt;-&amp;gt;&lt;/span&gt; Literal[&lt;span style="color:#a5d6ff"&gt;&amp;#34;dispatch&amp;#34;&lt;/span&gt;, &lt;span style="color:#a5d6ff"&gt;&amp;#34;supervisor&amp;#34;&lt;/span&gt;, &lt;span style="color:#a5d6ff"&gt;&amp;#34;end&amp;#34;&lt;/span&gt;]:&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#ff7b72"&gt;if&lt;/span&gt; state&lt;span style="color:#ff7b72;font-weight:bold"&gt;.&lt;/span&gt;get(&lt;span style="color:#a5d6ff"&gt;&amp;#34;speaking_plan&amp;#34;&lt;/span&gt;):&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#ff7b72"&gt;return&lt;/span&gt; &lt;span style="color:#a5d6ff"&gt;&amp;#34;dispatch&amp;#34;&lt;/span&gt; &lt;span style="color:#8b949e;font-style:italic"&gt;# 续跑重放：计划已在，按差集续，不重规划&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; kind &lt;span style="color:#ff7b72;font-weight:bold"&gt;=&lt;/span&gt; state[&lt;span style="color:#a5d6ff"&gt;&amp;#34;directive&amp;#34;&lt;/span&gt;][&lt;span style="color:#a5d6ff"&gt;&amp;#34;kind&amp;#34;&lt;/span&gt;]&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#ff7b72"&gt;if&lt;/span&gt; kind &lt;span style="color:#ff7b72;font-weight:bold"&gt;==&lt;/span&gt; &lt;span style="color:#a5d6ff"&gt;&amp;#34;open&amp;#34;&lt;/span&gt;:&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#ff7b72"&gt;return&lt;/span&gt; &lt;span style="color:#a5d6ff"&gt;&amp;#34;supervisor&amp;#34;&lt;/span&gt; &lt;span style="color:#8b949e;font-style:italic"&gt;# 首轮开放讨论：默认模型出计划&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#ff7b72"&gt;return&lt;/span&gt; &lt;span style="color:#a5d6ff"&gt;&amp;#34;end&amp;#34;&lt;/span&gt; &lt;span style="color:#8b949e;font-style:italic"&gt;# 停止/继续：控制指令，不产计划&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;&#10;&lt;/div&gt;&#10;&lt;/div&gt;&lt;p&gt;这个判断顺序不能反。如果先看 &lt;code&gt;kind&lt;/code&gt;，开放讨论在续跑重放时会误入 supervisor 再调一次模型；先看计划有无，重放才直接走 dispatch 按差集续跑。可恢复执行的全部难度就在这种毫厘之间：状态里每多带一个通道、路由里每换一个判断顺序，恢复语义就完全不同。&lt;/p&gt;&#10;&lt;p&gt;失败语义同样是发言人粒度：单个道人失败只跳过该发言人，不阻塞剩余计划成员；道人图内部对模型错误做最多 2 次机械重试；Supervisor 失败走确定性回退。三层兜底，任何一层的失败都不会把整轮炸掉。&lt;/p&gt;&#10;&lt;h2 id="迁移不是重写feature-flag--一条真实的提交序列"&gt;迁移不是重写：feature flag + 一条真实的提交序列&#10;&lt;/h2&gt;&lt;p&gt;迁移策略是增量推进，但结局只有一种实现：feature flag &lt;code&gt;orchestration_engine=legacy|langgraph&lt;/code&gt; 是临时迁移控制，不是长期双引擎产品设置。验证通过后默认 LangGraph，短回滚窗口过后删掉 legacy。&lt;/p&gt;&#10;&lt;p&gt;真实提交序列（节选自仓库主干）：&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;div style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&#10;&lt;table style="border-spacing:0;padding:0;margin:0;border:0;"&gt;&lt;tr&gt;&lt;td style="vertical-align:top;padding:0;margin:0;border:0;"&gt;&#10;&lt;pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code&gt;&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#737679"&gt; 1&#10;&lt;/span&gt;&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#737679"&gt; 2&#10;&lt;/span&gt;&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#737679"&gt; 3&#10;&lt;/span&gt;&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#737679"&gt; 4&#10;&lt;/span&gt;&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#737679"&gt; 5&#10;&lt;/span&gt;&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#737679"&gt; 6&#10;&lt;/span&gt;&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#737679"&gt; 7&#10;&lt;/span&gt;&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#737679"&gt; 8&#10;&lt;/span&gt;&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#737679"&gt; 9&#10;&lt;/span&gt;&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#737679"&gt;10&#10;&lt;/span&gt;&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#737679"&gt;11&#10;&lt;/span&gt;&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#737679"&gt;12&#10;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&#10;&lt;td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%"&gt;&#10;&lt;pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;docs: design LangGraph conversation orchestration ← 321 行设计文档&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;docs: plan LangGraph orchestration migration ← 873 行实施计划&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;build(python): add LangGraph runtime dependencies&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;feat(orchestration): define state and event contracts ← 状态/事件契约先行&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;feat(orchestration): add provider-aware model gateway&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;feat(orchestration): evolve speaking plan to ordered task items&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;feat(orchestration): route explicit group directives&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;feat(orchestration): add reusable Daoist graph&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;feat(orchestration): add hybrid group director&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;feat(orchestration): add resumable conversation graph&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;feat(orchestration): expose internal streaming API&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;feat(chat): persist orchestration runs idempotently&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;&#10;&lt;/div&gt;&#10;&lt;/div&gt;&lt;p&gt;节奏是契约先行：状态、事件、模型网关先于任何图节点落地；图是一层一层长出来的（道人图 → 确定性路由 → 混合导演 → 可恢复）。整个编排包最终约 1900 行 Python，14 个文件。&lt;/p&gt;&#10;&lt;p&gt;验收有一条硬场景，就是开头那条报数：四个成员各回自己的序号，人设只能加语气，不能改数、不能漏人、不能加戏。这条过不了，迁移就不算完。&lt;/p&gt;&#10;&lt;h2 id="复盘"&gt;复盘&#10;&lt;/h2&gt;&lt;ul&gt;&#10;&lt;li&gt;分层不是按语言划的，是按职责性质划的。数据是稳定的，编排是高频变化的。把变化最快的职责放进迭代最快的生态：LangGraph 的图结构、checkpointer、子图、结构化输出校验都是现成的，Go 里全要手写。&lt;/li&gt;&#10;&lt;li&gt;图的节点边界就是恢复边界。LangGraph 的检查点落在节点边界，所以节点怎么切，决定能从哪里恢复。想要发言人粒度的恢复，就得把调度切成发言人粒度的节点。&lt;/li&gt;&#10;&lt;li&gt;确定性与概率性分离，是 Agent 系统的地基。能写成代码的绝不调模型；需要模型的环节（Supervisor）只出可校验的结构化计划，失败可回退。开头那个报数 bug，本质就是没有这层分离。&lt;/li&gt;&#10;&lt;li&gt;契约先行让两端各自演进。11 个类型化事件 + run_id 幂等 + 快照式输入，Go 和 Python 之间只有契约没有纠缠；Go 仍然是数据的事实源，Python 在快照上做编排，记忆用提案-校验的方式回流。&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;p&gt;这次重构真正的收益在后面：工具调用、人工审批中断（&lt;code&gt;permission_required&lt;/code&gt; 事件已在契约里预留）、长任务规划、更复杂的多 Agent 协作，这些以后都是&amp;quot;在图里加节点&amp;quot;，不再需要动 Go。架构分层的意义不是当下的优雅，是把未来的变化留在了便宜的地方。&lt;/p&gt;&#10;&#10; &lt;blockquote&gt;&#10; &lt;p&gt;封面图：&lt;a class="link" href="https://www.flickr.com/photos/Creativity103/5227842611" target="_blank" rel="noopener"&#10; &gt;Creativity103 / Flickr&lt;/a&gt; · CC BY 2.0&lt;/p&gt;&#10;&#10; &lt;/blockquote&gt;&#10;</description></item><item><title>数据集管理服务架构设计</title><link>https://yusanwen-code.github.io/posts/post-42/</link><pubDate>Sun, 08 Jun 2025 10:30:00 +0800</pubDate><guid>https://yusanwen-code.github.io/posts/post-42/</guid><description>&lt;img src="https://yusanwen-code.github.io/images/post-42-cover.jpg" alt="Featured image of post 数据集管理服务架构设计" /&gt;&lt;h2 id="为什么不做成单体"&gt;为什么不做成单体&#10;&lt;/h2&gt;&lt;p&gt;某科技公司的 AI 数据平台需要一个统一的数据集管理服务：往上支撑学术文档上传、解析、向量化、知识图谱构建，往下给知识库问答服务供检索能力。数据形态从 PDF、Word 到图片、结构化表格都有，存储量大，处理链路又长又耗资源。&lt;/p&gt;&#10;&lt;p&gt;之所以不做成单体，是因为上传、解析、检索混在一个进程里会互相影响，任何一边都没法独立扩缩容。数据集管理服务就是照着这个约束设计的，后端架构由我主导。&lt;/p&gt;&#10;&lt;h2 id="整体怎么拆"&gt;整体怎么拆&#10;&lt;/h2&gt;&lt;p&gt;服务基于 Hertz（字节开源的 HTTP 框架）搭，按职责拆成几块。API 层管数据集 CRUD、文档上传、检索接口，用 Hertz 的路由分组和中间件做鉴权、日志、限流。存储层分三份：原始文件放 S3 兼容对象存储（MinIO），元数据存 GaussDB，向量和图谱实体存 MongoDB，向量一个集合，图遍历也靠它。处理层用 eino 编排解析流水线，并发交给 pond，前两篇已经讲过。检索层做向量加关键词的混合召回，支持按数据集、文档、元数据过滤。&lt;/p&gt;&#10;&lt;p&gt;&lt;img alt="数据集管理服务整体架构：上传直传、Temporal 异步处理、混合检索三条链路" loading="lazy" sizes="(max-width: 767px) calc(100vw - 30px), (max-width: 1023px) 700px, (max-width: 1279px) 950px, 1232px" src="https://yusanwen-code.github.io/images/post-42-dataset-architecture.svg"&gt;&lt;/p&gt;&#10;&lt;p&gt;模块间的依赖注入用 Wire，Service/DAO/Client 各归各层：&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;div style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&#10;&lt;table style="border-spacing:0;padding:0;margin:0;border:0;"&gt;&lt;tr&gt;&lt;td style="vertical-align:top;padding:0;margin:0;border:0;"&gt;&#10;&lt;pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code&gt;&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#737679"&gt; 1&#10;&lt;/span&gt;&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#737679"&gt; 2&#10;&lt;/span&gt;&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#737679"&gt; 3&#10;&lt;/span&gt;&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#737679"&gt; 4&#10;&lt;/span&gt;&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#737679"&gt; 5&#10;&lt;/span&gt;&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#737679"&gt; 6&#10;&lt;/span&gt;&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#737679"&gt; 7&#10;&lt;/span&gt;&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#737679"&gt; 8&#10;&lt;/span&gt;&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#737679"&gt; 9&#10;&lt;/span&gt;&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#737679"&gt;10&#10;&lt;/span&gt;&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#737679"&gt;11&#10;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&#10;&lt;td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%"&gt;&#10;&lt;pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-go" data-lang="go"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#8b949e;font-style:italic"&gt;// wire.go&lt;/span&gt;&lt;span style="color:#6e7681"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#ff7b72"&gt;func&lt;/span&gt;&lt;span style="color:#6e7681"&gt; &lt;/span&gt;&lt;span style="color:#d2a8ff;font-weight:bold"&gt;NewDatasetService&lt;/span&gt;(&lt;span style="color:#6e7681"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#6e7681"&gt; &lt;/span&gt;cfg&lt;span style="color:#6e7681"&gt; &lt;/span&gt;&lt;span style="color:#ff7b72;font-weight:bold"&gt;*&lt;/span&gt;Config,&lt;span style="color:#6e7681"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#6e7681"&gt; &lt;/span&gt;db&lt;span style="color:#6e7681"&gt; &lt;/span&gt;&lt;span style="color:#ff7b72;font-weight:bold"&gt;*&lt;/span&gt;gorm.DB,&lt;span style="color:#6e7681"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#6e7681"&gt; &lt;/span&gt;mongo&lt;span style="color:#6e7681"&gt; &lt;/span&gt;&lt;span style="color:#ff7b72;font-weight:bold"&gt;*&lt;/span&gt;mongo.Client,&lt;span style="color:#6e7681"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#6e7681"&gt; &lt;/span&gt;s3&lt;span style="color:#6e7681"&gt; &lt;/span&gt;&lt;span style="color:#ff7b72;font-weight:bold"&gt;*&lt;/span&gt;s3.Client,&lt;span style="color:#6e7681"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#6e7681"&gt; &lt;/span&gt;temporalClient&lt;span style="color:#6e7681"&gt; &lt;/span&gt;client.Client,&lt;span style="color:#6e7681"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#6e7681"&gt; &lt;/span&gt;chain&lt;span style="color:#6e7681"&gt; &lt;/span&gt;&lt;span style="color:#ff7b72;font-weight:bold"&gt;*&lt;/span&gt;eino.Chain,&lt;span style="color:#6e7681"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;)&lt;span style="color:#6e7681"&gt; &lt;/span&gt;&lt;span style="color:#ff7b72;font-weight:bold"&gt;*&lt;/span&gt;DatasetService&lt;span style="color:#6e7681"&gt; &lt;/span&gt;{&lt;span style="color:#6e7681"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#6e7681"&gt; &lt;/span&gt;&lt;span style="color:#8b949e;font-style:italic"&gt;// ...&lt;/span&gt;&lt;span style="color:#6e7681"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;}&lt;span style="color:#6e7681"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;&#10;&lt;/div&gt;&#10;&lt;/div&gt;&lt;h2 id="上传直传解析异步"&gt;上传直传，解析异步&#10;&lt;/h2&gt;&lt;p&gt;上传走 S3 预签名，前端直传对象存储，不占用服务带宽：&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;div style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&#10;&lt;table style="border-spacing:0;padding:0;margin:0;border:0;"&gt;&lt;tr&gt;&lt;td style="vertical-align:top;padding:0;margin:0;border:0;"&gt;&#10;&lt;pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code&gt;&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#737679"&gt; 1&#10;&lt;/span&gt;&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#737679"&gt; 2&#10;&lt;/span&gt;&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#737679"&gt; 3&#10;&lt;/span&gt;&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#737679"&gt; 4&#10;&lt;/span&gt;&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#737679"&gt; 5&#10;&lt;/span&gt;&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#737679"&gt; 6&#10;&lt;/span&gt;&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#737679"&gt; 7&#10;&lt;/span&gt;&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#737679"&gt; 8&#10;&lt;/span&gt;&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#737679"&gt; 9&#10;&lt;/span&gt;&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#737679"&gt;10&#10;&lt;/span&gt;&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#737679"&gt;11&#10;&lt;/span&gt;&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#737679"&gt;12&#10;&lt;/span&gt;&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#737679"&gt;13&#10;&lt;/span&gt;&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#737679"&gt;14&#10;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&#10;&lt;td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%"&gt;&#10;&lt;pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-go" data-lang="go"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#ff7b72"&gt;func&lt;/span&gt;&lt;span style="color:#6e7681"&gt; &lt;/span&gt;(h&lt;span style="color:#6e7681"&gt; &lt;/span&gt;&lt;span style="color:#ff7b72;font-weight:bold"&gt;*&lt;/span&gt;DatasetHandler)&lt;span style="color:#6e7681"&gt; &lt;/span&gt;&lt;span style="color:#d2a8ff;font-weight:bold"&gt;PresignUpload&lt;/span&gt;(c&lt;span style="color:#6e7681"&gt; &lt;/span&gt;context.Context,&lt;span style="color:#6e7681"&gt; &lt;/span&gt;ctx&lt;span style="color:#6e7681"&gt; &lt;/span&gt;&lt;span style="color:#ff7b72;font-weight:bold"&gt;*&lt;/span&gt;app.RequestContext)&lt;span style="color:#6e7681"&gt; &lt;/span&gt;{&lt;span style="color:#6e7681"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#6e7681"&gt; &lt;/span&gt;&lt;span style="color:#ff7b72"&gt;var&lt;/span&gt;&lt;span style="color:#6e7681"&gt; &lt;/span&gt;req&lt;span style="color:#6e7681"&gt; &lt;/span&gt;UploadReq&lt;span style="color:#6e7681"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#6e7681"&gt; &lt;/span&gt;&lt;span style="color:#ff7b72"&gt;if&lt;/span&gt;&lt;span style="color:#6e7681"&gt; &lt;/span&gt;err&lt;span style="color:#6e7681"&gt; &lt;/span&gt;&lt;span style="color:#ff7b72;font-weight:bold"&gt;:=&lt;/span&gt;&lt;span style="color:#6e7681"&gt; &lt;/span&gt;ctx.&lt;span style="color:#d2a8ff;font-weight:bold"&gt;BindAndValidate&lt;/span&gt;(&lt;span style="color:#ff7b72;font-weight:bold"&gt;&amp;amp;&lt;/span&gt;req);&lt;span style="color:#6e7681"&gt; &lt;/span&gt;err&lt;span style="color:#6e7681"&gt; &lt;/span&gt;&lt;span style="color:#ff7b72;font-weight:bold"&gt;!=&lt;/span&gt;&lt;span style="color:#6e7681"&gt; &lt;/span&gt;&lt;span style="color:#79c0ff"&gt;nil&lt;/span&gt;&lt;span style="color:#6e7681"&gt; &lt;/span&gt;{&lt;span style="color:#6e7681"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#6e7681"&gt; &lt;/span&gt;ctx.&lt;span style="color:#d2a8ff;font-weight:bold"&gt;JSON&lt;/span&gt;(&lt;span style="color:#a5d6ff"&gt;400&lt;/span&gt;,&lt;span style="color:#6e7681"&gt; &lt;/span&gt;&lt;span style="color:#d2a8ff;font-weight:bold"&gt;errResp&lt;/span&gt;(err))&lt;span style="color:#6e7681"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#6e7681"&gt; &lt;/span&gt;&lt;span style="color:#ff7b72"&gt;return&lt;/span&gt;&lt;span style="color:#6e7681"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#6e7681"&gt; &lt;/span&gt;}&lt;span style="color:#6e7681"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#6e7681"&gt; &lt;/span&gt;key&lt;span style="color:#6e7681"&gt; &lt;/span&gt;&lt;span style="color:#ff7b72;font-weight:bold"&gt;:=&lt;/span&gt;&lt;span style="color:#6e7681"&gt; &lt;/span&gt;fmt.&lt;span style="color:#d2a8ff;font-weight:bold"&gt;Sprintf&lt;/span&gt;(&lt;span style="color:#a5d6ff"&gt;&amp;#34;datasets/%s/%s%s&amp;#34;&lt;/span&gt;,&lt;span style="color:#6e7681"&gt; &lt;/span&gt;req.DatasetID,&lt;span style="color:#6e7681"&gt; &lt;/span&gt;snowflake.&lt;span style="color:#d2a8ff;font-weight:bold"&gt;NextID&lt;/span&gt;(),&lt;span style="color:#6e7681"&gt; &lt;/span&gt;&lt;span style="color:#d2a8ff;font-weight:bold"&gt;extOf&lt;/span&gt;(req.Filename))&lt;span style="color:#6e7681"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#6e7681"&gt; &lt;/span&gt;url,&lt;span style="color:#6e7681"&gt; &lt;/span&gt;err&lt;span style="color:#6e7681"&gt; &lt;/span&gt;&lt;span style="color:#ff7b72;font-weight:bold"&gt;:=&lt;/span&gt;&lt;span style="color:#6e7681"&gt; &lt;/span&gt;h.s3.&lt;span style="color:#d2a8ff;font-weight:bold"&gt;PresignPutObject&lt;/span&gt;(c,&lt;span style="color:#6e7681"&gt; &lt;/span&gt;key,&lt;span style="color:#6e7681"&gt; &lt;/span&gt;&lt;span style="color:#a5d6ff"&gt;15&lt;/span&gt;&lt;span style="color:#ff7b72;font-weight:bold"&gt;*&lt;/span&gt;time.Minute)&lt;span style="color:#6e7681"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#6e7681"&gt; &lt;/span&gt;&lt;span style="color:#ff7b72"&gt;if&lt;/span&gt;&lt;span style="color:#6e7681"&gt; &lt;/span&gt;err&lt;span style="color:#6e7681"&gt; &lt;/span&gt;&lt;span style="color:#ff7b72;font-weight:bold"&gt;!=&lt;/span&gt;&lt;span style="color:#6e7681"&gt; &lt;/span&gt;&lt;span style="color:#79c0ff"&gt;nil&lt;/span&gt;&lt;span style="color:#6e7681"&gt; &lt;/span&gt;{&lt;span style="color:#6e7681"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#6e7681"&gt; &lt;/span&gt;ctx.&lt;span style="color:#d2a8ff;font-weight:bold"&gt;JSON&lt;/span&gt;(&lt;span style="color:#a5d6ff"&gt;500&lt;/span&gt;,&lt;span style="color:#6e7681"&gt; &lt;/span&gt;&lt;span style="color:#d2a8ff;font-weight:bold"&gt;errResp&lt;/span&gt;(err))&lt;span style="color:#6e7681"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#6e7681"&gt; &lt;/span&gt;&lt;span style="color:#ff7b72"&gt;return&lt;/span&gt;&lt;span style="color:#6e7681"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#6e7681"&gt; &lt;/span&gt;}&lt;span style="color:#6e7681"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#6e7681"&gt; &lt;/span&gt;ctx.&lt;span style="color:#d2a8ff;font-weight:bold"&gt;JSON&lt;/span&gt;(&lt;span style="color:#a5d6ff"&gt;200&lt;/span&gt;,&lt;span style="color:#6e7681"&gt; &lt;/span&gt;&lt;span style="color:#ff7b72"&gt;map&lt;/span&gt;[&lt;span style="color:#ff7b72"&gt;string&lt;/span&gt;]&lt;span style="color:#ff7b72"&gt;any&lt;/span&gt;{&lt;span style="color:#a5d6ff"&gt;&amp;#34;url&amp;#34;&lt;/span&gt;:&lt;span style="color:#6e7681"&gt; &lt;/span&gt;url,&lt;span style="color:#6e7681"&gt; &lt;/span&gt;&lt;span style="color:#a5d6ff"&gt;&amp;#34;object_key&amp;#34;&lt;/span&gt;:&lt;span style="color:#6e7681"&gt; &lt;/span&gt;key})&lt;span style="color:#6e7681"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;}&lt;span style="color:#6e7681"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;&#10;&lt;/div&gt;&#10;&lt;/div&gt;&lt;p&gt;前端拿到预签名 URL 直传 S3，传完回调服务，服务创建文档记录、投递解析任务。&lt;/p&gt;&#10;&lt;p&gt;处理链路交给 Temporal 编排（和数据治理服务共用一个 Temporal 集群），每个文档一个 workflow，内部调 eino Chain 走完解析、分块、embedding、写图谱。重试、超时、状态持久化都是 Temporal 的活，服务重启任务也不丢。&lt;/p&gt;&#10;&lt;h2 id="检索是多路召回"&gt;检索是多路召回&#10;&lt;/h2&gt;&lt;p&gt;检索接口对知识库问答服务只暴露一个统一的 &lt;code&gt;Search&lt;/code&gt;：&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;div style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&#10;&lt;table style="border-spacing:0;padding:0;margin:0;border:0;"&gt;&lt;tr&gt;&lt;td style="vertical-align:top;padding:0;margin:0;border:0;"&gt;&#10;&lt;pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code&gt;&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#737679"&gt; 1&#10;&lt;/span&gt;&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#737679"&gt; 2&#10;&lt;/span&gt;&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#737679"&gt; 3&#10;&lt;/span&gt;&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#737679"&gt; 4&#10;&lt;/span&gt;&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#737679"&gt; 5&#10;&lt;/span&gt;&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#737679"&gt; 6&#10;&lt;/span&gt;&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#737679"&gt; 7&#10;&lt;/span&gt;&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#737679"&gt; 8&#10;&lt;/span&gt;&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#737679"&gt; 9&#10;&lt;/span&gt;&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#737679"&gt;10&#10;&lt;/span&gt;&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#737679"&gt;11&#10;&lt;/span&gt;&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#737679"&gt;12&#10;&lt;/span&gt;&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#737679"&gt;13&#10;&lt;/span&gt;&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#737679"&gt;14&#10;&lt;/span&gt;&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#737679"&gt;15&#10;&lt;/span&gt;&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#737679"&gt;16&#10;&lt;/span&gt;&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#737679"&gt;17&#10;&lt;/span&gt;&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#737679"&gt;18&#10;&lt;/span&gt;&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#737679"&gt;19&#10;&lt;/span&gt;&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#737679"&gt;20&#10;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&#10;&lt;td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%"&gt;&#10;&lt;pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-go" data-lang="go"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#ff7b72"&gt;type&lt;/span&gt;&lt;span style="color:#6e7681"&gt; &lt;/span&gt;SearchService&lt;span style="color:#6e7681"&gt; &lt;/span&gt;&lt;span style="color:#ff7b72"&gt;struct&lt;/span&gt;&lt;span style="color:#6e7681"&gt; &lt;/span&gt;{&lt;span style="color:#6e7681"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#6e7681"&gt; &lt;/span&gt;vecStore&lt;span style="color:#6e7681"&gt; &lt;/span&gt;VectorStore&lt;span style="color:#6e7681"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#6e7681"&gt; &lt;/span&gt;kwStore&lt;span style="color:#6e7681"&gt; &lt;/span&gt;KeywordStore&lt;span style="color:#6e7681"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#6e7681"&gt; &lt;/span&gt;graph&lt;span style="color:#6e7681"&gt; &lt;/span&gt;GraphStore&lt;span style="color:#6e7681"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;}&lt;span style="color:#6e7681"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#6e7681"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#ff7b72"&gt;func&lt;/span&gt;&lt;span style="color:#6e7681"&gt; &lt;/span&gt;(s&lt;span style="color:#6e7681"&gt; &lt;/span&gt;&lt;span style="color:#ff7b72;font-weight:bold"&gt;*&lt;/span&gt;SearchService)&lt;span style="color:#6e7681"&gt; &lt;/span&gt;&lt;span style="color:#d2a8ff;font-weight:bold"&gt;Search&lt;/span&gt;(ctx&lt;span style="color:#6e7681"&gt; &lt;/span&gt;context.Context,&lt;span style="color:#6e7681"&gt; &lt;/span&gt;req&lt;span style="color:#6e7681"&gt; &lt;/span&gt;&lt;span style="color:#ff7b72;font-weight:bold"&gt;*&lt;/span&gt;SearchReq)&lt;span style="color:#6e7681"&gt; &lt;/span&gt;(&lt;span style="color:#ff7b72;font-weight:bold"&gt;*&lt;/span&gt;SearchResp,&lt;span style="color:#6e7681"&gt; &lt;/span&gt;&lt;span style="color:#ff7b72"&gt;error&lt;/span&gt;)&lt;span style="color:#6e7681"&gt; &lt;/span&gt;{&lt;span style="color:#6e7681"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#6e7681"&gt; &lt;/span&gt;&lt;span style="color:#8b949e;font-style:italic"&gt;// 1. 向量召回&lt;/span&gt;&lt;span style="color:#6e7681"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#6e7681"&gt; &lt;/span&gt;vecHits,&lt;span style="color:#6e7681"&gt; &lt;/span&gt;err&lt;span style="color:#6e7681"&gt; &lt;/span&gt;&lt;span style="color:#ff7b72;font-weight:bold"&gt;:=&lt;/span&gt;&lt;span style="color:#6e7681"&gt; &lt;/span&gt;s.vecStore.&lt;span style="color:#d2a8ff;font-weight:bold"&gt;Search&lt;/span&gt;(ctx,&lt;span style="color:#6e7681"&gt; &lt;/span&gt;req.DatasetID,&lt;span style="color:#6e7681"&gt; &lt;/span&gt;req.QueryEmbedding,&lt;span style="color:#6e7681"&gt; &lt;/span&gt;req.TopK)&lt;span style="color:#6e7681"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#6e7681"&gt; &lt;/span&gt;&lt;span style="color:#ff7b72"&gt;if&lt;/span&gt;&lt;span style="color:#6e7681"&gt; &lt;/span&gt;err&lt;span style="color:#6e7681"&gt; &lt;/span&gt;&lt;span style="color:#ff7b72;font-weight:bold"&gt;!=&lt;/span&gt;&lt;span style="color:#6e7681"&gt; &lt;/span&gt;&lt;span style="color:#79c0ff"&gt;nil&lt;/span&gt;&lt;span style="color:#6e7681"&gt; &lt;/span&gt;{&lt;span style="color:#6e7681"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#6e7681"&gt; &lt;/span&gt;&lt;span style="color:#ff7b72"&gt;return&lt;/span&gt;&lt;span style="color:#6e7681"&gt; &lt;/span&gt;&lt;span style="color:#79c0ff"&gt;nil&lt;/span&gt;,&lt;span style="color:#6e7681"&gt; &lt;/span&gt;err&lt;span style="color:#6e7681"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#6e7681"&gt; &lt;/span&gt;}&lt;span style="color:#6e7681"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#6e7681"&gt; &lt;/span&gt;&lt;span style="color:#8b949e;font-style:italic"&gt;// 2. 关键词召回（BM25）&lt;/span&gt;&lt;span style="color:#6e7681"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#6e7681"&gt; &lt;/span&gt;kwHits,&lt;span style="color:#6e7681"&gt; &lt;/span&gt;_&lt;span style="color:#6e7681"&gt; &lt;/span&gt;&lt;span style="color:#ff7b72;font-weight:bold"&gt;:=&lt;/span&gt;&lt;span style="color:#6e7681"&gt; &lt;/span&gt;s.kwStore.&lt;span style="color:#d2a8ff;font-weight:bold"&gt;Search&lt;/span&gt;(ctx,&lt;span style="color:#6e7681"&gt; &lt;/span&gt;req.DatasetID,&lt;span style="color:#6e7681"&gt; &lt;/span&gt;req.Query,&lt;span style="color:#6e7681"&gt; &lt;/span&gt;req.TopK)&lt;span style="color:#6e7681"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#6e7681"&gt; &lt;/span&gt;&lt;span style="color:#8b949e;font-style:italic"&gt;// 3. 融合排序（RRF）&lt;/span&gt;&lt;span style="color:#6e7681"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#6e7681"&gt; &lt;/span&gt;fused&lt;span style="color:#6e7681"&gt; &lt;/span&gt;&lt;span style="color:#ff7b72;font-weight:bold"&gt;:=&lt;/span&gt;&lt;span style="color:#6e7681"&gt; &lt;/span&gt;&lt;span style="color:#d2a8ff;font-weight:bold"&gt;rrfFuse&lt;/span&gt;(vecHits,&lt;span style="color:#6e7681"&gt; &lt;/span&gt;kwHits)&lt;span style="color:#6e7681"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#6e7681"&gt; &lt;/span&gt;&lt;span style="color:#8b949e;font-style:italic"&gt;// 4. 图谱扩展：对 top 结果关联实体补充邻接信息&lt;/span&gt;&lt;span style="color:#6e7681"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#6e7681"&gt; &lt;/span&gt;enriched&lt;span style="color:#6e7681"&gt; &lt;/span&gt;&lt;span style="color:#ff7b72;font-weight:bold"&gt;:=&lt;/span&gt;&lt;span style="color:#6e7681"&gt; &lt;/span&gt;s.graph.&lt;span style="color:#d2a8ff;font-weight:bold"&gt;Expand&lt;/span&gt;(ctx,&lt;span style="color:#6e7681"&gt; &lt;/span&gt;fused,&lt;span style="color:#6e7681"&gt; &lt;/span&gt;req.DatasetID)&lt;span style="color:#6e7681"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#6e7681"&gt; &lt;/span&gt;&lt;span style="color:#ff7b72"&gt;return&lt;/span&gt;&lt;span style="color:#6e7681"&gt; &lt;/span&gt;&lt;span style="color:#ff7b72;font-weight:bold"&gt;&amp;amp;&lt;/span&gt;SearchResp{Hits:&lt;span style="color:#6e7681"&gt; &lt;/span&gt;enriched},&lt;span style="color:#6e7681"&gt; &lt;/span&gt;&lt;span style="color:#79c0ff"&gt;nil&lt;/span&gt;&lt;span style="color:#6e7681"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;}&lt;span style="color:#6e7681"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;&#10;&lt;/div&gt;&#10;&lt;/div&gt;&lt;h2 id="五个关键取舍"&gt;五个关键取舍&#10;&lt;/h2&gt;&lt;p&gt;上传和处理必须拆开。最初想在上传接口里同步解析，大文件一进来接口就超时。改成预签名直传加 Temporal 异步任务之后，接口只管元数据和任务投递，处理能力想扩就单独扩。文档状态用状态机管理（uploaded → parsing → parsed / failed），前端轮询或订阅进度。&lt;/p&gt;&#10;&lt;p&gt;存储选型上，GaussDB 存结构化元数据（数据集、文档、权限），MongoDB 存向量和非结构化 chunk，主要看中它的向量索引和文档模型，跟 chunk 加 metadata 的形态正合适。图谱没有引入独立图数据库，实体和关系就用 MongoDB 集合存，当前规模够用，还省掉一个中间件的运维成本；等图遍历深度真上去了，再换专门的图库不迟。&lt;/p&gt;&#10;&lt;p&gt;大文件和小文件分开对待。几百页的 PDF 解析起来很吃内存，我们在 Temporal workflow 里按页拆 activity，每页独立处理、独立落盘，避免整个文档一口气读进内存。小文件反过来，批量合并处理，省 workflow 调度开销。&lt;/p&gt;&#10;&lt;p&gt;幂等和重试要提前想好。解析任务可能因为 OOM 或节点重启被 Temporal 重试，所有写入都以文档 ID 加 chunk 序号做幂等键，重试不会产生重复向量。S3 上的中间结果（解析出的文本、表格）也缓存着，重试时已完成的阶段直接跳过。&lt;/p&gt;&#10;&lt;p&gt;多租户隔离贯穿全链路。数据集属于某个 AppRole 团队，所有查询强制带租户过滤条件，S3 的 key 前缀也按租户隔离，预签名 URL 带时效，防越权下载。&lt;/p&gt;&#10;&lt;h2 id="三条链路各走各的"&gt;三条链路各走各的&#10;&lt;/h2&gt;&lt;p&gt;整个服务拆开看就三件事：上传走对象存储直传，处理走异步工作流，检索做多路召回。Hertz 顶在 API 层，S3 承载原始文件，eino 加 pond 解决解析并发，Temporal 保证任务可靠，GaussDB 和 MongoDB 分别承接元数据和向量图谱。这套架构支撑着知识库问答服务，也给后续的知识图谱和多模态检索留了扩展空间。&lt;/p&gt;&#10;&#10; &lt;blockquote&gt;&#10; &lt;p&gt;封面图：&lt;a class="link" href="https://www.flickr.com/photos/16894864@N05/6239564720" target="_blank" rel="noopener"&#10; &gt;motleypixel / Flickr&lt;/a&gt; · CC BY 2.0&lt;/p&gt;&#10;&#10; &lt;/blockquote&gt;&#10;</description></item><item><title>数据治理服务架构：原始文件、PDF 解析与论文元数据管理</title><link>https://yusanwen-code.github.io/posts/post-25/</link><pubDate>Sun, 15 Sep 2024 10:30:00 +0800</pubDate><guid>https://yusanwen-code.github.io/posts/post-25/</guid><description>&lt;img src="https://yusanwen-code.github.io/images/post-25-cover.jpg" alt="Featured image of post 数据治理服务架构：原始文件、PDF 解析与论文元数据管理" /&gt;&lt;h2 id="文件杂体积大质量参差"&gt;文件杂、体积大、质量参差&#10;&lt;/h2&gt;&lt;p&gt;某科技公司的数据平台要处理的东西很杂：客户上传的论文 PDF、期刊数据包，还有从 OpenAlex 同步来的学术数据。目标只有一个，把它们统一治理成可检索、可分析的结构化资产。&lt;/p&gt;&#10;&lt;p&gt;麻烦在于文件来源杂、体积大（单文件几十到几百 MB），质量还参差不齐。有的 PDF 是扫描件，得走 OCR；有的元数据干脆缺失；作者机构的写法五花八门。&lt;/p&gt;&#10;&lt;p&gt;我在数据治理服务里设计了一条从原始文件入库到结构化元数据落地的链路。思路不复杂：把「文件存储」「解析」「元数据管理」三件事拆开，每一步都能独立重试和替换。&lt;/p&gt;&#10;&lt;h2 id="四层流水线"&gt;四层流水线&#10;&lt;/h2&gt;&lt;p&gt;整体分四层：&lt;/p&gt;&#10;&lt;ol&gt;&#10;&lt;li&gt;原始文件层：文件通过 S3 预签名直传到 MinIO，路径按 &lt;code&gt;raw/{tenant}/{yyyy}/{mm}/{id}.pdf&lt;/code&gt; 组织，元信息写 file_objects 表。&lt;/li&gt;&#10;&lt;li&gt;解析层：异步 Worker 消费解析任务，按文件类型路由到不同 parser。文本型 PDF 用 pdfplumber 提取文本和章节结构，扫描件走 OCR；论文元数据通过 GROBID 或正则从首页抽取标题、作者、摘要、DOI、参考文献。&lt;/li&gt;&#10;&lt;li&gt;清洗层：作者名标准化、机构归一化、DOI 校验去重，用 Temporal 工作流编排（下一篇会展开规则引擎）。&lt;/li&gt;&#10;&lt;li&gt;元数据层：结构化结果落 MySQL 作为权威库，同时同步到 StarRocks 做分析查询，全文索引进 Elasticsearch。&lt;/li&gt;&#10;&lt;/ol&gt;&#10;&lt;p&gt;各层之间通过任务表和消息队列解耦。原始文件永远不修改，所有解析结果挂在 file_id 下，出了问题随时能追溯。&lt;/p&gt;&#10;&lt;p&gt;&lt;img alt="数据治理四层链路：原始文件、解析、清洗、元数据" loading="lazy" sizes="(max-width: 767px) calc(100vw - 30px), (max-width: 1023px) 700px, (max-width: 1279px) 950px, 1232px" src="https://yusanwen-code.github.io/images/post-25-governance-pipeline.svg"&gt;&lt;/p&gt;&#10;&lt;h2 id="文件对象与解析路由"&gt;文件对象与解析路由&#10;&lt;/h2&gt;&lt;p&gt;文件在系统里的身份，是这么记录的：&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;div style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&#10;&lt;table style="border-spacing:0;padding:0;margin:0;border:0;"&gt;&lt;tr&gt;&lt;td style="vertical-align:top;padding:0;margin:0;border:0;"&gt;&#10;&lt;pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code&gt;&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#737679"&gt; 1&#10;&lt;/span&gt;&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#737679"&gt; 2&#10;&lt;/span&gt;&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#737679"&gt; 3&#10;&lt;/span&gt;&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#737679"&gt; 4&#10;&lt;/span&gt;&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#737679"&gt; 5&#10;&lt;/span&gt;&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#737679"&gt; 6&#10;&lt;/span&gt;&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#737679"&gt; 7&#10;&lt;/span&gt;&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#737679"&gt; 8&#10;&lt;/span&gt;&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#737679"&gt; 9&#10;&lt;/span&gt;&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#737679"&gt;10&#10;&lt;/span&gt;&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#737679"&gt;11&#10;&lt;/span&gt;&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#737679"&gt;12&#10;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&#10;&lt;td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%"&gt;&#10;&lt;pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-go" data-lang="go"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#ff7b72"&gt;type&lt;/span&gt;&lt;span style="color:#6e7681"&gt; &lt;/span&gt;FileObject&lt;span style="color:#6e7681"&gt; &lt;/span&gt;&lt;span style="color:#ff7b72"&gt;struct&lt;/span&gt;&lt;span style="color:#6e7681"&gt; &lt;/span&gt;{&lt;span style="color:#6e7681"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#6e7681"&gt; &lt;/span&gt;ID&lt;span style="color:#6e7681"&gt; &lt;/span&gt;&lt;span style="color:#ff7b72"&gt;int64&lt;/span&gt;&lt;span style="color:#6e7681"&gt; &lt;/span&gt;&lt;span style="color:#a5d6ff"&gt;`gorm:&amp;#34;primaryKey&amp;#34;`&lt;/span&gt;&lt;span style="color:#6e7681"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#6e7681"&gt; &lt;/span&gt;TenantID&lt;span style="color:#6e7681"&gt; &lt;/span&gt;&lt;span style="color:#ff7b72"&gt;int64&lt;/span&gt;&lt;span style="color:#6e7681"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#6e7681"&gt; &lt;/span&gt;FileKey&lt;span style="color:#6e7681"&gt; &lt;/span&gt;&lt;span style="color:#ff7b72"&gt;string&lt;/span&gt;&lt;span style="color:#6e7681"&gt; &lt;/span&gt;&lt;span style="color:#8b949e;font-style:italic"&gt;// S3 object key&lt;/span&gt;&lt;span style="color:#6e7681"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#6e7681"&gt; &lt;/span&gt;FileName&lt;span style="color:#6e7681"&gt; &lt;/span&gt;&lt;span style="color:#ff7b72"&gt;string&lt;/span&gt;&lt;span style="color:#6e7681"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#6e7681"&gt; &lt;/span&gt;Size&lt;span style="color:#6e7681"&gt; &lt;/span&gt;&lt;span style="color:#ff7b72"&gt;int64&lt;/span&gt;&lt;span style="color:#6e7681"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#6e7681"&gt; &lt;/span&gt;MIMEType&lt;span style="color:#6e7681"&gt; &lt;/span&gt;&lt;span style="color:#ff7b72"&gt;string&lt;/span&gt;&lt;span style="color:#6e7681"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#6e7681"&gt; &lt;/span&gt;Hash&lt;span style="color:#6e7681"&gt; &lt;/span&gt;&lt;span style="color:#ff7b72"&gt;string&lt;/span&gt;&lt;span style="color:#6e7681"&gt; &lt;/span&gt;&lt;span style="color:#8b949e;font-style:italic"&gt;// sha256，用于秒传和去重&lt;/span&gt;&lt;span style="color:#6e7681"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#6e7681"&gt; &lt;/span&gt;Status&lt;span style="color:#6e7681"&gt; &lt;/span&gt;&lt;span style="color:#ff7b72"&gt;string&lt;/span&gt;&lt;span style="color:#6e7681"&gt; &lt;/span&gt;&lt;span style="color:#8b949e;font-style:italic"&gt;// uploaded/parsing/done/failed&lt;/span&gt;&lt;span style="color:#6e7681"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#6e7681"&gt; &lt;/span&gt;ParsedMeta&lt;span style="color:#6e7681"&gt; &lt;/span&gt;&lt;span style="color:#ff7b72;font-weight:bold"&gt;*&lt;/span&gt;&lt;span style="color:#ff7b72"&gt;string&lt;/span&gt;&lt;span style="color:#6e7681"&gt; &lt;/span&gt;&lt;span style="color:#8b949e;font-style:italic"&gt;// JSON，解析出的标题作者等&lt;/span&gt;&lt;span style="color:#6e7681"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#6e7681"&gt; &lt;/span&gt;CreatedAt&lt;span style="color:#6e7681"&gt; &lt;/span&gt;time.Time&lt;span style="color:#6e7681"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;}&lt;span style="color:#6e7681"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;&#10;&lt;/div&gt;&#10;&lt;/div&gt;&lt;p&gt;解析任务的分发在 Python Worker 端，按 MIME 类型路由：&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;div style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&#10;&lt;table style="border-spacing:0;padding:0;margin:0;border:0;"&gt;&lt;tr&gt;&lt;td style="vertical-align:top;padding:0;margin:0;border:0;"&gt;&#10;&lt;pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code&gt;&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#737679"&gt; 1&#10;&lt;/span&gt;&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#737679"&gt; 2&#10;&lt;/span&gt;&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#737679"&gt; 3&#10;&lt;/span&gt;&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#737679"&gt; 4&#10;&lt;/span&gt;&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#737679"&gt; 5&#10;&lt;/span&gt;&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#737679"&gt; 6&#10;&lt;/span&gt;&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#737679"&gt; 7&#10;&lt;/span&gt;&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#737679"&gt; 8&#10;&lt;/span&gt;&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#737679"&gt; 9&#10;&lt;/span&gt;&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#737679"&gt;10&#10;&lt;/span&gt;&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#737679"&gt;11&#10;&lt;/span&gt;&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#737679"&gt;12&#10;&lt;/span&gt;&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#737679"&gt;13&#10;&lt;/span&gt;&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#737679"&gt;14&#10;&lt;/span&gt;&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#737679"&gt;15&#10;&lt;/span&gt;&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#737679"&gt;16&#10;&lt;/span&gt;&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#737679"&gt;17&#10;&lt;/span&gt;&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#737679"&gt;18&#10;&lt;/span&gt;&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#737679"&gt;19&#10;&lt;/span&gt;&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#737679"&gt;20&#10;&lt;/span&gt;&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#737679"&gt;21&#10;&lt;/span&gt;&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#737679"&gt;22&#10;&lt;/span&gt;&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#737679"&gt;23&#10;&lt;/span&gt;&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#737679"&gt;24&#10;&lt;/span&gt;&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#737679"&gt;25&#10;&lt;/span&gt;&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#737679"&gt;26&#10;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&#10;&lt;td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%"&gt;&#10;&lt;pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-python" data-lang="python"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;PARSERS &lt;span style="color:#ff7b72;font-weight:bold"&gt;=&lt;/span&gt; {&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a5d6ff"&gt;&amp;#34;application/pdf&amp;#34;&lt;/span&gt;: &lt;span style="color:#a5d6ff"&gt;&amp;#34;pdf&amp;#34;&lt;/span&gt;,&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a5d6ff"&gt;&amp;#34;application/epub+xml&amp;#34;&lt;/span&gt;: &lt;span style="color:#a5d6ff"&gt;&amp;#34;jats&amp;#34;&lt;/span&gt;,&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;}&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#ff7b72"&gt;async&lt;/span&gt; &lt;span style="color:#ff7b72"&gt;def&lt;/span&gt; &lt;span style="color:#d2a8ff;font-weight:bold"&gt;dispatch_parse&lt;/span&gt;(file_obj: dict):&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; parser_type &lt;span style="color:#ff7b72;font-weight:bold"&gt;=&lt;/span&gt; PARSERS&lt;span style="color:#ff7b72;font-weight:bold"&gt;.&lt;/span&gt;get(file_obj[&lt;span style="color:#a5d6ff"&gt;&amp;#34;mime_type&amp;#34;&lt;/span&gt;])&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#ff7b72"&gt;if&lt;/span&gt; parser_type &lt;span style="color:#ff7b72;font-weight:bold"&gt;==&lt;/span&gt; &lt;span style="color:#a5d6ff"&gt;&amp;#34;pdf&amp;#34;&lt;/span&gt;:&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; meta &lt;span style="color:#ff7b72;font-weight:bold"&gt;=&lt;/span&gt; &lt;span style="color:#ff7b72"&gt;await&lt;/span&gt; parse_pdf(file_obj[&lt;span style="color:#a5d6ff"&gt;&amp;#34;file_key&amp;#34;&lt;/span&gt;])&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#ff7b72"&gt;elif&lt;/span&gt; parser_type &lt;span style="color:#ff7b72;font-weight:bold"&gt;==&lt;/span&gt; &lt;span style="color:#a5d6ff"&gt;&amp;#34;jats&amp;#34;&lt;/span&gt;:&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; meta &lt;span style="color:#ff7b72;font-weight:bold"&gt;=&lt;/span&gt; parse_jats(file_obj[&lt;span style="color:#a5d6ff"&gt;&amp;#34;file_key&amp;#34;&lt;/span&gt;])&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#ff7b72"&gt;else&lt;/span&gt;:&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; meta &lt;span style="color:#ff7b72;font-weight:bold"&gt;=&lt;/span&gt; {}&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#ff7b72"&gt;await&lt;/span&gt; update_metadata(file_obj[&lt;span style="color:#a5d6ff"&gt;&amp;#34;id&amp;#34;&lt;/span&gt;], meta)&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#ff7b72"&gt;async&lt;/span&gt; &lt;span style="color:#ff7b72"&gt;def&lt;/span&gt; &lt;span style="color:#d2a8ff;font-weight:bold"&gt;parse_pdf&lt;/span&gt;(key: str) &lt;span style="color:#ff7b72;font-weight:bold"&gt;-&amp;gt;&lt;/span&gt; dict:&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; local &lt;span style="color:#ff7b72;font-weight:bold"&gt;=&lt;/span&gt; &lt;span style="color:#ff7b72"&gt;await&lt;/span&gt; s3_download_to_tmp(key)&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#ff7b72"&gt;with&lt;/span&gt; pdfplumber&lt;span style="color:#ff7b72;font-weight:bold"&gt;.&lt;/span&gt;open(local) &lt;span style="color:#ff7b72"&gt;as&lt;/span&gt; pdf:&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; first_page &lt;span style="color:#ff7b72;font-weight:bold"&gt;=&lt;/span&gt; pdf&lt;span style="color:#ff7b72;font-weight:bold"&gt;.&lt;/span&gt;pages[&lt;span style="color:#a5d6ff"&gt;0&lt;/span&gt;]&lt;span style="color:#ff7b72;font-weight:bold"&gt;.&lt;/span&gt;extract_text() &lt;span style="color:#ff7b72;font-weight:bold"&gt;or&lt;/span&gt; &lt;span style="color:#a5d6ff"&gt;&amp;#34;&amp;#34;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#ff7b72"&gt;return&lt;/span&gt; {&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a5d6ff"&gt;&amp;#34;title&amp;#34;&lt;/span&gt;: extract_title(first_page),&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a5d6ff"&gt;&amp;#34;authors&amp;#34;&lt;/span&gt;: extract_authors(first_page),&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a5d6ff"&gt;&amp;#34;doi&amp;#34;&lt;/span&gt;: extract_doi(first_page),&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a5d6ff"&gt;&amp;#34;abstract&amp;#34;&lt;/span&gt;: extract_abstract(pdf),&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a5d6ff"&gt;&amp;#34;page_count&amp;#34;&lt;/span&gt;: len(pdf&lt;span style="color:#ff7b72;font-weight:bold"&gt;.&lt;/span&gt;pages),&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; }&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;&#10;&lt;/div&gt;&#10;&lt;/div&gt;&lt;h2 id="解析是最脆的一环"&gt;解析是最脆的一环&#10;&lt;/h2&gt;&lt;p&gt;pdfplumber 对双栏排版、数学公式、上下标经常串行；GROBID 基于 CRF 模型，效果好但部署重，单篇解析要 5 到 10 秒。我们的做法是默认走 pdfplumber 粗解析，关键客户的高价值文件再回灌 GROBID 精修，两边各取所长。&lt;/p&gt;&#10;&lt;p&gt;扫描件必须 OCR，但 OCR 错误率高、成本也大。好在可以省着用：看首页有没有可选文本层，有就直接解析，没有才进 OCR 队列，不把算力浪费在本来就有文本层的 PDF 上。&lt;/p&gt;&#10;&lt;p&gt;去重也有一层妥协。元数据去重以 DOI 为主键，可 DOI 会缺失、会写错。没有 DOI 的论文，就用标题加首作者加年份做 SimHash 近似去重，阈值得跟着数据调。&lt;/p&gt;&#10;&lt;p&gt;还有两条底线。一是原始文件不可变：所有清洗都基于副本生成新版本，解析出问题随时重跑，不会污染原始数据。二是 OpenAlex 数据量太大，全量同步不现实，我们用 SeaTunnel 做增量同步，按 update_date 分批拉，避免一次性打满源库带宽。&lt;/p&gt;&#10;&lt;h2 id="后来"&gt;后来&#10;&lt;/h2&gt;&lt;p&gt;数据治理没有银弹。这套架构管用的地方，是把脏活拆成了能单独观测、重试、替换的阶段：原始文件不变，解析和清洗各管一段，元数据按用途分开存。面对来源各异的学术数据，加规则是渐进的事，不用推倒重来。&lt;/p&gt;&#10;&#10; &lt;blockquote&gt;&#10; &lt;p&gt;封面图：&lt;a class="link" href="https://www.flickr.com/photos/98640399@N08/10030588973" target="_blank" rel="noopener"&#10; &gt;Barta IV / Flickr&lt;/a&gt; · CC BY 2.0&lt;/p&gt;&#10;&#10; &lt;/blockquote&gt;&#10;</description></item><item><title>从单体到账号中台：统一认证中心的架构思考</title><link>https://yusanwen-code.github.io/posts/post-13/</link><pubDate>Mon, 11 Mar 2024 10:30:00 +0800</pubDate><guid>https://yusanwen-code.github.io/posts/post-13/</guid><description>&lt;img src="https://yusanwen-code.github.io/images/post-13-cover.jpg" alt="Featured image of post 从单体到账号中台：统一认证中心的架构思考" /&gt;&lt;h2 id="四个系统四套登录"&gt;四个系统，四套登录&#10;&lt;/h2&gt;&lt;p&gt;2023 年底我刚到某科技公司时，内部几个业务系统各有各的登录：统一支付平台一套账号，数据治理服务一套，数据集管理服务和知识库问答服务又各搞一套。员工要记四五个密码，离职了账号还关不干净；外部客户在一个系统注册完，跳到另一个系统还得再注册一次。运维和安全团队都在催：能不能统一一下。&lt;/p&gt;&#10;&lt;p&gt;这就是统一认证中心的起点。但很快想明白一件事：光把用户表抽出来解决不了问题，身份怎么统一、认证方式怎么扩展、应用之间怎么建立信任，这三件事都绕不开。&lt;/p&gt;&#10;&lt;h2 id="核心域怎么划"&gt;核心域怎么划&#10;&lt;/h2&gt;&lt;p&gt;我把统一认证中心划成五个域：&lt;/p&gt;&#10;&lt;ol&gt;&#10;&lt;li&gt;身份域：全局唯一的 &lt;code&gt;UserID&lt;/code&gt;（Snowflake 生成），一个用户可以绑定多种凭证，密码、手机、邮箱、微信/企微/飞书 OAuth 都行。&lt;/li&gt;&#10;&lt;li&gt;应用域：每个接入方有自己的 &lt;code&gt;AppID/AppSecret&lt;/code&gt;，配置回调地址、授权方式、可用的登录 Provider。&lt;/li&gt;&#10;&lt;li&gt;组织域：用 &lt;code&gt;AppRole&lt;/code&gt; 做团队/租户隔离，用户在不同应用里可以有不同角色。RBAC 权限模型落在认证中心，资源权限还是业务系统自己持有。&lt;/li&gt;&#10;&lt;li&gt;会话域：认证中心统一颁发 JWT（RSA 私钥签），业务系统拿 JWKS 公钥在本地验签，不回源。&lt;/li&gt;&#10;&lt;li&gt;Provider 域：短信、邮件、验证码、社交登录全做成可插拔接口，默认实现腾讯云 SMS/SES/Captcha，以后要换阿里或自建，加个实现就行。&lt;/li&gt;&#10;&lt;/ol&gt;&#10;&lt;p&gt;技术栈上用 go-zero 拆 RPC 服务，Wire 做依赖注入，Service/DAO 分层。&lt;/p&gt;&#10;&lt;p&gt;&lt;img alt="统一认证中心的五个核心域与一条信任链" loading="lazy" sizes="(max-width: 767px) calc(100vw - 30px), (max-width: 1023px) 700px, (max-width: 1279px) 950px, 1232px" src="https://yusanwen-code.github.io/images/post-13-auth-center-domains.svg"&gt;&lt;/p&gt;&#10;&lt;h2 id="分层与关键代码"&gt;分层与关键代码&#10;&lt;/h2&gt;&lt;p&gt;整体分层这样组织：&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;div style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&#10;&lt;table style="border-spacing:0;padding:0;margin:0;border:0;"&gt;&lt;tr&gt;&lt;td style="vertical-align:top;padding:0;margin:0;border:0;"&gt;&#10;&lt;pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code&gt;&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#737679"&gt; 1&#10;&lt;/span&gt;&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#737679"&gt; 2&#10;&lt;/span&gt;&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#737679"&gt; 3&#10;&lt;/span&gt;&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#737679"&gt; 4&#10;&lt;/span&gt;&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#737679"&gt; 5&#10;&lt;/span&gt;&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#737679"&gt; 6&#10;&lt;/span&gt;&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#737679"&gt; 7&#10;&lt;/span&gt;&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#737679"&gt; 8&#10;&lt;/span&gt;&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#737679"&gt; 9&#10;&lt;/span&gt;&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#737679"&gt;10&#10;&lt;/span&gt;&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#737679"&gt;11&#10;&lt;/span&gt;&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#737679"&gt;12&#10;&lt;/span&gt;&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#737679"&gt;13&#10;&lt;/span&gt;&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#737679"&gt;14&#10;&lt;/span&gt;&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#737679"&gt;15&#10;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&#10;&lt;td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%"&gt;&#10;&lt;pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-fallback" data-lang="fallback"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;passport/&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;├── api/ # HTTP 网关 (go-zero rest)&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;│ ├── handler/&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;│ └── middleware/&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;├── rpc/ # gRPC 服务&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;│ ├── user/&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;│ ├── app/&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;│ ├── auth/&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;│ └── org/&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;├── internal/&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;│ ├── service/ # 业务编排&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;│ ├── dao/ # 数据访问&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;│ ├── provider/ # 可插拔 Provider&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;│ └── token/ # JWT 签发/JWKS&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;└── wire/&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;&#10;&lt;/div&gt;&#10;&lt;/div&gt;&lt;p&gt;Provider 接口是可插拔的关键：&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;div style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&#10;&lt;table style="border-spacing:0;padding:0;margin:0;border:0;"&gt;&lt;tr&gt;&lt;td style="vertical-align:top;padding:0;margin:0;border:0;"&gt;&#10;&lt;pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code&gt;&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#737679"&gt; 1&#10;&lt;/span&gt;&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#737679"&gt; 2&#10;&lt;/span&gt;&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#737679"&gt; 3&#10;&lt;/span&gt;&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#737679"&gt; 4&#10;&lt;/span&gt;&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#737679"&gt; 5&#10;&lt;/span&gt;&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#737679"&gt; 6&#10;&lt;/span&gt;&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#737679"&gt; 7&#10;&lt;/span&gt;&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#737679"&gt; 8&#10;&lt;/span&gt;&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#737679"&gt; 9&#10;&lt;/span&gt;&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#737679"&gt;10&#10;&lt;/span&gt;&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#737679"&gt;11&#10;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&#10;&lt;td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%"&gt;&#10;&lt;pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-go" data-lang="go"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#ff7b72"&gt;type&lt;/span&gt;&lt;span style="color:#6e7681"&gt; &lt;/span&gt;SMSProvider&lt;span style="color:#6e7681"&gt; &lt;/span&gt;&lt;span style="color:#ff7b72"&gt;interface&lt;/span&gt;&lt;span style="color:#6e7681"&gt; &lt;/span&gt;{&lt;span style="color:#6e7681"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#6e7681"&gt; &lt;/span&gt;&lt;span style="color:#d2a8ff;font-weight:bold"&gt;Send&lt;/span&gt;(ctx&lt;span style="color:#6e7681"&gt; &lt;/span&gt;context.Context,&lt;span style="color:#6e7681"&gt; &lt;/span&gt;phone,&lt;span style="color:#6e7681"&gt; &lt;/span&gt;tmplID&lt;span style="color:#6e7681"&gt; &lt;/span&gt;&lt;span style="color:#ff7b72"&gt;string&lt;/span&gt;,&lt;span style="color:#6e7681"&gt; &lt;/span&gt;params&lt;span style="color:#6e7681"&gt; &lt;/span&gt;&lt;span style="color:#ff7b72"&gt;map&lt;/span&gt;[&lt;span style="color:#ff7b72"&gt;string&lt;/span&gt;]&lt;span style="color:#ff7b72"&gt;string&lt;/span&gt;)&lt;span style="color:#6e7681"&gt; &lt;/span&gt;&lt;span style="color:#ff7b72"&gt;error&lt;/span&gt;&lt;span style="color:#6e7681"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;}&lt;span style="color:#6e7681"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#6e7681"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#ff7b72"&gt;type&lt;/span&gt;&lt;span style="color:#6e7681"&gt; &lt;/span&gt;EmailProvider&lt;span style="color:#6e7681"&gt; &lt;/span&gt;&lt;span style="color:#ff7b72"&gt;interface&lt;/span&gt;&lt;span style="color:#6e7681"&gt; &lt;/span&gt;{&lt;span style="color:#6e7681"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#6e7681"&gt; &lt;/span&gt;&lt;span style="color:#d2a8ff;font-weight:bold"&gt;Send&lt;/span&gt;(ctx&lt;span style="color:#6e7681"&gt; &lt;/span&gt;context.Context,&lt;span style="color:#6e7681"&gt; &lt;/span&gt;to,&lt;span style="color:#6e7681"&gt; &lt;/span&gt;subject,&lt;span style="color:#6e7681"&gt; &lt;/span&gt;body&lt;span style="color:#6e7681"&gt; &lt;/span&gt;&lt;span style="color:#ff7b72"&gt;string&lt;/span&gt;)&lt;span style="color:#6e7681"&gt; &lt;/span&gt;&lt;span style="color:#ff7b72"&gt;error&lt;/span&gt;&lt;span style="color:#6e7681"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;}&lt;span style="color:#6e7681"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#6e7681"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#ff7b72"&gt;type&lt;/span&gt;&lt;span style="color:#6e7681"&gt; &lt;/span&gt;CaptchaProvider&lt;span style="color:#6e7681"&gt; &lt;/span&gt;&lt;span style="color:#ff7b72"&gt;interface&lt;/span&gt;&lt;span style="color:#6e7681"&gt; &lt;/span&gt;{&lt;span style="color:#6e7681"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#6e7681"&gt; &lt;/span&gt;&lt;span style="color:#d2a8ff;font-weight:bold"&gt;Verify&lt;/span&gt;(ctx&lt;span style="color:#6e7681"&gt; &lt;/span&gt;context.Context,&lt;span style="color:#6e7681"&gt; &lt;/span&gt;ticket,&lt;span style="color:#6e7681"&gt; &lt;/span&gt;randstr&lt;span style="color:#6e7681"&gt; &lt;/span&gt;&lt;span style="color:#ff7b72"&gt;string&lt;/span&gt;)&lt;span style="color:#6e7681"&gt; &lt;/span&gt;&lt;span style="color:#ff7b72"&gt;error&lt;/span&gt;&lt;span style="color:#6e7681"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;}&lt;span style="color:#6e7681"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;&#10;&lt;/div&gt;&#10;&lt;/div&gt;&lt;p&gt;Wire 注入时按配置选择实现：&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;div style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&#10;&lt;table style="border-spacing:0;padding:0;margin:0;border:0;"&gt;&lt;tr&gt;&lt;td style="vertical-align:top;padding:0;margin:0;border:0;"&gt;&#10;&lt;pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code&gt;&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#737679"&gt; 1&#10;&lt;/span&gt;&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#737679"&gt; 2&#10;&lt;/span&gt;&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#737679"&gt; 3&#10;&lt;/span&gt;&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#737679"&gt; 4&#10;&lt;/span&gt;&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#737679"&gt; 5&#10;&lt;/span&gt;&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#737679"&gt; 6&#10;&lt;/span&gt;&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#737679"&gt; 7&#10;&lt;/span&gt;&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#737679"&gt; 8&#10;&lt;/span&gt;&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#737679"&gt; 9&#10;&lt;/span&gt;&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#737679"&gt;10&#10;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&#10;&lt;td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%"&gt;&#10;&lt;pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-go" data-lang="go"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#ff7b72"&gt;func&lt;/span&gt;&lt;span style="color:#6e7681"&gt; &lt;/span&gt;&lt;span style="color:#d2a8ff;font-weight:bold"&gt;NewSMSProvider&lt;/span&gt;(cfg&lt;span style="color:#6e7681"&gt; &lt;/span&gt;config.SMS)&lt;span style="color:#6e7681"&gt; &lt;/span&gt;provider.SMSProvider&lt;span style="color:#6e7681"&gt; &lt;/span&gt;{&lt;span style="color:#6e7681"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#6e7681"&gt; &lt;/span&gt;&lt;span style="color:#ff7b72"&gt;switch&lt;/span&gt;&lt;span style="color:#6e7681"&gt; &lt;/span&gt;cfg.Provider&lt;span style="color:#6e7681"&gt; &lt;/span&gt;{&lt;span style="color:#6e7681"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#6e7681"&gt; &lt;/span&gt;&lt;span style="color:#ff7b72"&gt;case&lt;/span&gt;&lt;span style="color:#6e7681"&gt; &lt;/span&gt;&lt;span style="color:#a5d6ff"&gt;&amp;#34;tencent&amp;#34;&lt;/span&gt;:&lt;span style="color:#6e7681"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#6e7681"&gt; &lt;/span&gt;&lt;span style="color:#ff7b72"&gt;return&lt;/span&gt;&lt;span style="color:#6e7681"&gt; &lt;/span&gt;tencent.&lt;span style="color:#d2a8ff;font-weight:bold"&gt;NewSMS&lt;/span&gt;(cfg.Tencent)&lt;span style="color:#6e7681"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#6e7681"&gt; &lt;/span&gt;&lt;span style="color:#ff7b72"&gt;case&lt;/span&gt;&lt;span style="color:#6e7681"&gt; &lt;/span&gt;&lt;span style="color:#a5d6ff"&gt;&amp;#34;aliyun&amp;#34;&lt;/span&gt;:&lt;span style="color:#6e7681"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#6e7681"&gt; &lt;/span&gt;&lt;span style="color:#ff7b72"&gt;return&lt;/span&gt;&lt;span style="color:#6e7681"&gt; &lt;/span&gt;aliyun.&lt;span style="color:#d2a8ff;font-weight:bold"&gt;NewSMS&lt;/span&gt;(cfg.Aliyun)&lt;span style="color:#6e7681"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#6e7681"&gt; &lt;/span&gt;&lt;span style="color:#ff7b72"&gt;default&lt;/span&gt;:&lt;span style="color:#6e7681"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#6e7681"&gt; &lt;/span&gt;&lt;span style="color:#ff7b72"&gt;return&lt;/span&gt;&lt;span style="color:#6e7681"&gt; &lt;/span&gt;noop.&lt;span style="color:#d2a8ff;font-weight:bold"&gt;NewSMS&lt;/span&gt;()&lt;span style="color:#6e7681"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#6e7681"&gt; &lt;/span&gt;}&lt;span style="color:#6e7681"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;}&lt;span style="color:#6e7681"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;&#10;&lt;/div&gt;&#10;&lt;/div&gt;&lt;p&gt;登录入口用 &lt;code&gt;AuthService&lt;/code&gt; 统一编排。不管密码、短信还是社交登录，最后都收拢到同一套&amp;quot;凭证换 UserID → 发 Token&amp;quot;的流程里：&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;div style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&#10;&lt;table style="border-spacing:0;padding:0;margin:0;border:0;"&gt;&lt;tr&gt;&lt;td style="vertical-align:top;padding:0;margin:0;border:0;"&gt;&#10;&lt;pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code&gt;&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#737679"&gt; 1&#10;&lt;/span&gt;&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#737679"&gt; 2&#10;&lt;/span&gt;&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#737679"&gt; 3&#10;&lt;/span&gt;&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#737679"&gt; 4&#10;&lt;/span&gt;&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#737679"&gt; 5&#10;&lt;/span&gt;&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#737679"&gt; 6&#10;&lt;/span&gt;&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#737679"&gt; 7&#10;&lt;/span&gt;&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#737679"&gt; 8&#10;&lt;/span&gt;&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#737679"&gt; 9&#10;&lt;/span&gt;&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#737679"&gt;10&#10;&lt;/span&gt;&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#737679"&gt;11&#10;&lt;/span&gt;&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#737679"&gt;12&#10;&lt;/span&gt;&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#737679"&gt;13&#10;&lt;/span&gt;&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#737679"&gt;14&#10;&lt;/span&gt;&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#737679"&gt;15&#10;&lt;/span&gt;&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#737679"&gt;16&#10;&lt;/span&gt;&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#737679"&gt;17&#10;&lt;/span&gt;&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#737679"&gt;18&#10;&lt;/span&gt;&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#737679"&gt;19&#10;&lt;/span&gt;&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#737679"&gt;20&#10;&lt;/span&gt;&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#737679"&gt;21&#10;&lt;/span&gt;&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#737679"&gt;22&#10;&lt;/span&gt;&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#737679"&gt;23&#10;&lt;/span&gt;&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#737679"&gt;24&#10;&lt;/span&gt;&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#737679"&gt;25&#10;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&#10;&lt;td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%"&gt;&#10;&lt;pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-go" data-lang="go"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#ff7b72"&gt;type&lt;/span&gt;&lt;span style="color:#6e7681"&gt; &lt;/span&gt;AuthService&lt;span style="color:#6e7681"&gt; &lt;/span&gt;&lt;span style="color:#ff7b72"&gt;struct&lt;/span&gt;&lt;span style="color:#6e7681"&gt; &lt;/span&gt;{&lt;span style="color:#6e7681"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#6e7681"&gt; &lt;/span&gt;userDao&lt;span style="color:#6e7681"&gt; &lt;/span&gt;dao.UserDAO&lt;span style="color:#6e7681"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#6e7681"&gt; &lt;/span&gt;credDao&lt;span style="color:#6e7681"&gt; &lt;/span&gt;dao.CredentialDAO&lt;span style="color:#6e7681"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#6e7681"&gt; &lt;/span&gt;tokenSvc&lt;span style="color:#6e7681"&gt; &lt;/span&gt;&lt;span style="color:#ff7b72;font-weight:bold"&gt;*&lt;/span&gt;token.Service&lt;span style="color:#6e7681"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#6e7681"&gt; &lt;/span&gt;providers&lt;span style="color:#6e7681"&gt; &lt;/span&gt;provider.Container&lt;span style="color:#6e7681"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;}&lt;span style="color:#6e7681"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#6e7681"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#ff7b72"&gt;func&lt;/span&gt;&lt;span style="color:#6e7681"&gt; &lt;/span&gt;(s&lt;span style="color:#6e7681"&gt; &lt;/span&gt;&lt;span style="color:#ff7b72;font-weight:bold"&gt;*&lt;/span&gt;AuthService)&lt;span style="color:#6e7681"&gt; &lt;/span&gt;&lt;span style="color:#d2a8ff;font-weight:bold"&gt;Login&lt;/span&gt;(ctx&lt;span style="color:#6e7681"&gt; &lt;/span&gt;context.Context,&lt;span style="color:#6e7681"&gt; &lt;/span&gt;req&lt;span style="color:#6e7681"&gt; &lt;/span&gt;&lt;span style="color:#ff7b72;font-weight:bold"&gt;*&lt;/span&gt;LoginRequest)&lt;span style="color:#6e7681"&gt; &lt;/span&gt;(&lt;span style="color:#ff7b72;font-weight:bold"&gt;*&lt;/span&gt;TokenPair,&lt;span style="color:#6e7681"&gt; &lt;/span&gt;&lt;span style="color:#ff7b72"&gt;error&lt;/span&gt;)&lt;span style="color:#6e7681"&gt; &lt;/span&gt;{&lt;span style="color:#6e7681"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#6e7681"&gt; &lt;/span&gt;&lt;span style="color:#ff7b72"&gt;var&lt;/span&gt;&lt;span style="color:#6e7681"&gt; &lt;/span&gt;userID&lt;span style="color:#6e7681"&gt; &lt;/span&gt;&lt;span style="color:#ff7b72"&gt;int64&lt;/span&gt;&lt;span style="color:#6e7681"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#6e7681"&gt; &lt;/span&gt;&lt;span style="color:#ff7b72"&gt;var&lt;/span&gt;&lt;span style="color:#6e7681"&gt; &lt;/span&gt;err&lt;span style="color:#6e7681"&gt; &lt;/span&gt;&lt;span style="color:#ff7b72"&gt;error&lt;/span&gt;&lt;span style="color:#6e7681"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#6e7681"&gt; &lt;/span&gt;&lt;span style="color:#ff7b72"&gt;switch&lt;/span&gt;&lt;span style="color:#6e7681"&gt; &lt;/span&gt;req.GrantType&lt;span style="color:#6e7681"&gt; &lt;/span&gt;{&lt;span style="color:#6e7681"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#6e7681"&gt; &lt;/span&gt;&lt;span style="color:#ff7b72"&gt;case&lt;/span&gt;&lt;span style="color:#6e7681"&gt; &lt;/span&gt;&lt;span style="color:#a5d6ff"&gt;&amp;#34;password&amp;#34;&lt;/span&gt;:&lt;span style="color:#6e7681"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#6e7681"&gt; &lt;/span&gt;userID,&lt;span style="color:#6e7681"&gt; &lt;/span&gt;err&lt;span style="color:#6e7681"&gt; &lt;/span&gt;=&lt;span style="color:#6e7681"&gt; &lt;/span&gt;s.&lt;span style="color:#d2a8ff;font-weight:bold"&gt;loginByPassword&lt;/span&gt;(ctx,&lt;span style="color:#6e7681"&gt; &lt;/span&gt;req.AppID,&lt;span style="color:#6e7681"&gt; &lt;/span&gt;req.Account,&lt;span style="color:#6e7681"&gt; &lt;/span&gt;req.Password)&lt;span style="color:#6e7681"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#6e7681"&gt; &lt;/span&gt;&lt;span style="color:#ff7b72"&gt;case&lt;/span&gt;&lt;span style="color:#6e7681"&gt; &lt;/span&gt;&lt;span style="color:#a5d6ff"&gt;&amp;#34;sms&amp;#34;&lt;/span&gt;:&lt;span style="color:#6e7681"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#6e7681"&gt; &lt;/span&gt;userID,&lt;span style="color:#6e7681"&gt; &lt;/span&gt;err&lt;span style="color:#6e7681"&gt; &lt;/span&gt;=&lt;span style="color:#6e7681"&gt; &lt;/span&gt;s.&lt;span style="color:#d2a8ff;font-weight:bold"&gt;loginBySMS&lt;/span&gt;(ctx,&lt;span style="color:#6e7681"&gt; &lt;/span&gt;req.AppID,&lt;span style="color:#6e7681"&gt; &lt;/span&gt;req.Phone,&lt;span style="color:#6e7681"&gt; &lt;/span&gt;req.Code)&lt;span style="color:#6e7681"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#6e7681"&gt; &lt;/span&gt;&lt;span style="color:#ff7b72"&gt;case&lt;/span&gt;&lt;span style="color:#6e7681"&gt; &lt;/span&gt;&lt;span style="color:#a5d6ff"&gt;&amp;#34;social&amp;#34;&lt;/span&gt;:&lt;span style="color:#6e7681"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#6e7681"&gt; &lt;/span&gt;userID,&lt;span style="color:#6e7681"&gt; &lt;/span&gt;err&lt;span style="color:#6e7681"&gt; &lt;/span&gt;=&lt;span style="color:#6e7681"&gt; &lt;/span&gt;s.&lt;span style="color:#d2a8ff;font-weight:bold"&gt;loginBySocial&lt;/span&gt;(ctx,&lt;span style="color:#6e7681"&gt; &lt;/span&gt;req.AppID,&lt;span style="color:#6e7681"&gt; &lt;/span&gt;req.Provider,&lt;span style="color:#6e7681"&gt; &lt;/span&gt;req.Code)&lt;span style="color:#6e7681"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#6e7681"&gt; &lt;/span&gt;&lt;span style="color:#ff7b72"&gt;default&lt;/span&gt;:&lt;span style="color:#6e7681"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#6e7681"&gt; &lt;/span&gt;&lt;span style="color:#ff7b72"&gt;return&lt;/span&gt;&lt;span style="color:#6e7681"&gt; &lt;/span&gt;&lt;span style="color:#79c0ff"&gt;nil&lt;/span&gt;,&lt;span style="color:#6e7681"&gt; &lt;/span&gt;ErrUnsupportedGrantType&lt;span style="color:#6e7681"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#6e7681"&gt; &lt;/span&gt;}&lt;span style="color:#6e7681"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#6e7681"&gt; &lt;/span&gt;&lt;span style="color:#ff7b72"&gt;if&lt;/span&gt;&lt;span style="color:#6e7681"&gt; &lt;/span&gt;err&lt;span style="color:#6e7681"&gt; &lt;/span&gt;&lt;span style="color:#ff7b72;font-weight:bold"&gt;!=&lt;/span&gt;&lt;span style="color:#6e7681"&gt; &lt;/span&gt;&lt;span style="color:#79c0ff"&gt;nil&lt;/span&gt;&lt;span style="color:#6e7681"&gt; &lt;/span&gt;{&lt;span style="color:#6e7681"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#6e7681"&gt; &lt;/span&gt;&lt;span style="color:#ff7b72"&gt;return&lt;/span&gt;&lt;span style="color:#6e7681"&gt; &lt;/span&gt;&lt;span style="color:#79c0ff"&gt;nil&lt;/span&gt;,&lt;span style="color:#6e7681"&gt; &lt;/span&gt;err&lt;span style="color:#6e7681"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#6e7681"&gt; &lt;/span&gt;}&lt;span style="color:#6e7681"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#6e7681"&gt; &lt;/span&gt;&lt;span style="color:#ff7b72"&gt;return&lt;/span&gt;&lt;span style="color:#6e7681"&gt; &lt;/span&gt;s.tokenSvc.&lt;span style="color:#d2a8ff;font-weight:bold"&gt;Issue&lt;/span&gt;(ctx,&lt;span style="color:#6e7681"&gt; &lt;/span&gt;userID,&lt;span style="color:#6e7681"&gt; &lt;/span&gt;req.AppID)&lt;span style="color:#6e7681"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;}&lt;span style="color:#6e7681"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;&#10;&lt;/div&gt;&#10;&lt;/div&gt;&lt;p&gt;JWT 签发用 RSA 私钥，公钥通过 JWKS 端点暴露：&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;div style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&#10;&lt;table style="border-spacing:0;padding:0;margin:0;border:0;"&gt;&lt;tr&gt;&lt;td style="vertical-align:top;padding:0;margin:0;border:0;"&gt;&#10;&lt;pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code&gt;&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#737679"&gt; 1&#10;&lt;/span&gt;&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#737679"&gt; 2&#10;&lt;/span&gt;&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#737679"&gt; 3&#10;&lt;/span&gt;&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#737679"&gt; 4&#10;&lt;/span&gt;&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#737679"&gt; 5&#10;&lt;/span&gt;&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#737679"&gt; 6&#10;&lt;/span&gt;&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#737679"&gt; 7&#10;&lt;/span&gt;&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#737679"&gt; 8&#10;&lt;/span&gt;&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#737679"&gt; 9&#10;&lt;/span&gt;&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#737679"&gt;10&#10;&lt;/span&gt;&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#737679"&gt;11&#10;&lt;/span&gt;&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#737679"&gt;12&#10;&lt;/span&gt;&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#737679"&gt;13&#10;&lt;/span&gt;&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#737679"&gt;14&#10;&lt;/span&gt;&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#737679"&gt;15&#10;&lt;/span&gt;&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#737679"&gt;16&#10;&lt;/span&gt;&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#737679"&gt;17&#10;&lt;/span&gt;&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#737679"&gt;18&#10;&lt;/span&gt;&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#737679"&gt;19&#10;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&#10;&lt;td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%"&gt;&#10;&lt;pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-go" data-lang="go"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#ff7b72"&gt;func&lt;/span&gt;&lt;span style="color:#6e7681"&gt; &lt;/span&gt;(s&lt;span style="color:#6e7681"&gt; &lt;/span&gt;&lt;span style="color:#ff7b72;font-weight:bold"&gt;*&lt;/span&gt;Service)&lt;span style="color:#6e7681"&gt; &lt;/span&gt;&lt;span style="color:#d2a8ff;font-weight:bold"&gt;Issue&lt;/span&gt;(ctx&lt;span style="color:#6e7681"&gt; &lt;/span&gt;context.Context,&lt;span style="color:#6e7681"&gt; &lt;/span&gt;userID&lt;span style="color:#6e7681"&gt; &lt;/span&gt;&lt;span style="color:#ff7b72"&gt;int64&lt;/span&gt;,&lt;span style="color:#6e7681"&gt; &lt;/span&gt;appID&lt;span style="color:#6e7681"&gt; &lt;/span&gt;&lt;span style="color:#ff7b72"&gt;string&lt;/span&gt;)&lt;span style="color:#6e7681"&gt; &lt;/span&gt;(&lt;span style="color:#ff7b72;font-weight:bold"&gt;*&lt;/span&gt;TokenPair,&lt;span style="color:#6e7681"&gt; &lt;/span&gt;&lt;span style="color:#ff7b72"&gt;error&lt;/span&gt;)&lt;span style="color:#6e7681"&gt; &lt;/span&gt;{&lt;span style="color:#6e7681"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#6e7681"&gt; &lt;/span&gt;now&lt;span style="color:#6e7681"&gt; &lt;/span&gt;&lt;span style="color:#ff7b72;font-weight:bold"&gt;:=&lt;/span&gt;&lt;span style="color:#6e7681"&gt; &lt;/span&gt;time.&lt;span style="color:#d2a8ff;font-weight:bold"&gt;Now&lt;/span&gt;()&lt;span style="color:#6e7681"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#6e7681"&gt; &lt;/span&gt;claims&lt;span style="color:#6e7681"&gt; &lt;/span&gt;&lt;span style="color:#ff7b72;font-weight:bold"&gt;:=&lt;/span&gt;&lt;span style="color:#6e7681"&gt; &lt;/span&gt;Claims{&lt;span style="color:#6e7681"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#6e7681"&gt; &lt;/span&gt;UserID:&lt;span style="color:#6e7681"&gt; &lt;/span&gt;userID,&lt;span style="color:#6e7681"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#6e7681"&gt; &lt;/span&gt;AppID:&lt;span style="color:#6e7681"&gt; &lt;/span&gt;appID,&lt;span style="color:#6e7681"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#6e7681"&gt; &lt;/span&gt;RegisteredClaims:&lt;span style="color:#6e7681"&gt; &lt;/span&gt;jwt.RegisteredClaims{&lt;span style="color:#6e7681"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#6e7681"&gt; &lt;/span&gt;Issuer:&lt;span style="color:#6e7681"&gt; &lt;/span&gt;&lt;span style="color:#a5d6ff"&gt;&amp;#34;passport&amp;#34;&lt;/span&gt;,&lt;span style="color:#6e7681"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#6e7681"&gt; &lt;/span&gt;Subject:&lt;span style="color:#6e7681"&gt; &lt;/span&gt;strconv.&lt;span style="color:#d2a8ff;font-weight:bold"&gt;FormatInt&lt;/span&gt;(userID,&lt;span style="color:#6e7681"&gt; &lt;/span&gt;&lt;span style="color:#a5d6ff"&gt;10&lt;/span&gt;),&lt;span style="color:#6e7681"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#6e7681"&gt; &lt;/span&gt;Audience:&lt;span style="color:#6e7681"&gt; &lt;/span&gt;jwt.ClaimStrings{appID},&lt;span style="color:#6e7681"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#6e7681"&gt; &lt;/span&gt;ExpiresAt:&lt;span style="color:#6e7681"&gt; &lt;/span&gt;jwt.&lt;span style="color:#d2a8ff;font-weight:bold"&gt;NewNumericDate&lt;/span&gt;(now.&lt;span style="color:#d2a8ff;font-weight:bold"&gt;Add&lt;/span&gt;(&lt;span style="color:#a5d6ff"&gt;2&lt;/span&gt;&lt;span style="color:#6e7681"&gt; &lt;/span&gt;&lt;span style="color:#ff7b72;font-weight:bold"&gt;*&lt;/span&gt;&lt;span style="color:#6e7681"&gt; &lt;/span&gt;time.Hour)),&lt;span style="color:#6e7681"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#6e7681"&gt; &lt;/span&gt;IssuedAt:&lt;span style="color:#6e7681"&gt; &lt;/span&gt;jwt.&lt;span style="color:#d2a8ff;font-weight:bold"&gt;NewNumericDate&lt;/span&gt;(now),&lt;span style="color:#6e7681"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#6e7681"&gt; &lt;/span&gt;ID:&lt;span style="color:#6e7681"&gt; &lt;/span&gt;snowflake.&lt;span style="color:#d2a8ff;font-weight:bold"&gt;NextID&lt;/span&gt;(),&lt;span style="color:#6e7681"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#6e7681"&gt; &lt;/span&gt;},&lt;span style="color:#6e7681"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#6e7681"&gt; &lt;/span&gt;}&lt;span style="color:#6e7681"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#6e7681"&gt; &lt;/span&gt;accessToken,&lt;span style="color:#6e7681"&gt; &lt;/span&gt;err&lt;span style="color:#6e7681"&gt; &lt;/span&gt;&lt;span style="color:#ff7b72;font-weight:bold"&gt;:=&lt;/span&gt;&lt;span style="color:#6e7681"&gt; &lt;/span&gt;jwt.&lt;span style="color:#d2a8ff;font-weight:bold"&gt;NewWithClaims&lt;/span&gt;(jwt.SigningMethodRS256,&lt;span style="color:#6e7681"&gt; &lt;/span&gt;claims).&lt;span style="color:#6e7681"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#6e7681"&gt; &lt;/span&gt;&lt;span style="color:#d2a8ff;font-weight:bold"&gt;SignedString&lt;/span&gt;(s.privKey)&lt;span style="color:#6e7681"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#6e7681"&gt; &lt;/span&gt;&lt;span style="color:#8b949e;font-style:italic"&gt;// refresh token 省略&lt;/span&gt;&lt;span style="color:#6e7681"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#6e7681"&gt; &lt;/span&gt;&lt;span style="color:#ff7b72"&gt;return&lt;/span&gt;&lt;span style="color:#6e7681"&gt; &lt;/span&gt;&lt;span style="color:#ff7b72;font-weight:bold"&gt;&amp;amp;&lt;/span&gt;TokenPair{AccessToken:&lt;span style="color:#6e7681"&gt; &lt;/span&gt;accessToken,&lt;span style="color:#6e7681"&gt; &lt;/span&gt;&lt;span style="color:#ff7b72;font-weight:bold"&gt;...&lt;/span&gt;},&lt;span style="color:#6e7681"&gt; &lt;/span&gt;&lt;span style="color:#79c0ff"&gt;nil&lt;/span&gt;&lt;span style="color:#6e7681"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;}&lt;span style="color:#6e7681"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;&#10;&lt;/div&gt;&#10;&lt;/div&gt;&lt;h2 id="最难的是用户合并"&gt;最难的是用户合并&#10;&lt;/h2&gt;&lt;p&gt;五个域里真正难缠的是身份。一个用户先用微信登录、后来又用手机号注册，识别为同一个人之后要合并。我的做法是建一张 &lt;code&gt;user_bindings&lt;/code&gt; 表，凭证和用户是多对一关系，合并时把旧凭证挂到新 UserID 下，再写一条审计日志。&lt;/p&gt;&#10;&lt;p&gt;麻烦在后头：业务系统的外键引用的还是旧 UserID，得发事件通知各系统做 ID 映射。这件事比当初拍板时想的工作量大得多。&lt;/p&gt;&#10;&lt;h2 id="吊销组织树和供应商灰度"&gt;吊销、组织树和供应商灰度&#10;&lt;/h2&gt;&lt;p&gt;JWT 吊销是个老话题。我们用短 AccessToken（2 小时）加长 RefreshToken（7 天），RefreshToken 存 Redis，随时可吊销；AccessToken 不做黑名单，靠短过期自然失效。登出只吊销 RefreshToken。支付这类安全要求极高的场景，再加一个&amp;quot;令牌版本号&amp;quot;claim，改密码时版本号递增，旧 token 立刻作废。&lt;/p&gt;&#10;&lt;p&gt;组织这块一开始想把组织树建在认证中心，后来发现各业务系统的组织模型差异太大：统一支付平台里是商户，数据治理服务里是团队，知识库问答服务里是企业。强行统一就是削足适履。最后认证中心只存 &lt;code&gt;(app_id, user_id, role_external_id)&lt;/code&gt;，组织名和层级由业务系统自己维护。&lt;/p&gt;&#10;&lt;p&gt;Provider 也有灰度需求。腾讯云短信偶尔抖动，我在 Provider 层加了个 &lt;code&gt;FanoutProvider&lt;/code&gt;，按权重在多家供应商之间分流，失败自动降级，配置走配置中心热更新，不用重启。&lt;/p&gt;&#10;&lt;h2 id="后来"&gt;后来&#10;&lt;/h2&gt;&lt;p&gt;统一认证中心上线之后，新业务接入 SSO 只要半天，离职员工的账号在一处关掉就全端下线，安全审计也有了统一入口。回头看，做账号中台靠的是克制：只做身份、认证、应用信任这三件事，组织和资源权限坚决留给业务系统。&amp;ldquo;登录&amp;quot;这件每个系统都要重复做的事，总算变成了一个可复用的平台能力。&lt;/p&gt;&#10;&#10; &lt;blockquote&gt;&#10; &lt;p&gt;封面图：&lt;a class="link" href="https://www.flickr.com/photos/34831177@N06/3232156751" target="_blank" rel="noopener"&#10; &gt;anthony arrigo / Flickr&lt;/a&gt; · CC BY 2.0&lt;/p&gt;&#10;&#10; &lt;/blockquote&gt;&#10;</description></item></channel></rss>