docs(routing): 可配置分流 Phase 1 实现计划(.md 执行版 + HTML 阅读版 + 登记索引)

Phase 1 计划:Task 0 合并 main(前置)+ 10 任务 TDD(服务端 routing_profiles 表/校验/
API/BuildClientConfig 翻译/connect 读档案;客户端 model+API+provider/规则子屏 UI/设置入口/
自动重连)。执行真相源 docs/superpowers/plans/2026-07-27-configurable-proxy-phase1.md,
阅读版 docs/configurable-proxy-plan.html 已登记 docs/index.html「实现计划」。待用户确认后执行。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A79VtQA1BwTuQN1ThpvYpo
This commit is contained in:
wangjia
2026-07-27 12:06:21 +08:00
parent fae73ff656
commit 7776fc4d6c
3 changed files with 868 additions and 0 deletions
+185
View File
@@ -0,0 +1,185 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Pangolin 可配置分流 · Phase 1 实现计划</title>
<style>
:root{
--bg:#0f1117; --panel:#171a22; --panel2:#1d2129; --fg:#e6e8ee; --fg2:#a8afbd;
--accent:#e0884f; --accent2:#5fb0c9; --ok:#5ec27a; --bad:#e06a6a; --warn:#e0b84f;
--border:#262b36; --sans:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"PingFang SC","Microsoft YaHei",sans-serif;
--mono:"SF Mono",ui-monospace,Menlo,Consolas,monospace;
}
@media (prefers-color-scheme: light){:root{--bg:#f4f5f8;--panel:#fff;--panel2:#eef0f4;--fg:#1a1d24;--fg2:#5a6172;--border:#dde0e7}}
:root[data-theme="dark"]{--bg:#0f1117;--panel:#171a22;--panel2:#1d2129;--fg:#e6e8ee;--fg2:#a8afbd;--border:#262b36}
:root[data-theme="light"]{--bg:#f4f5f8;--panel:#fff;--panel2:#eef0f4;--fg:#1a1d24;--fg2:#5a6172;--border:#dde0e7}
*{box-sizing:border-box}
body{margin:0;background:var(--bg);color:var(--fg);font-family:var(--sans);line-height:1.7;font-size:15px}
.wrap{max-width:880px;margin:0 auto;padding:34px 22px 90px}
h1{font-size:25px;margin:0 0 4px}
.lead{color:var(--fg2);margin:0 0 8px;font-size:14.5px}
h2{font-size:18px;color:var(--accent);border-bottom:1px solid var(--border);padding-bottom:6px;margin:40px 0 14px}
h3{font-size:15.5px;color:var(--accent2);margin:26px 0 8px}
p{margin:9px 0}
code{font-family:var(--mono);font-size:.87em;background:var(--panel2);padding:1px 6px;border-radius:5px;color:#f0d9c4}
pre{background:#0a0c11;border:1px solid var(--border);border-radius:10px;padding:14px 16px;overflow-x:auto;font-family:var(--mono);font-size:12.5px;line-height:1.55;color:#cdd3df}
pre .k{color:#e0884f} pre .s{color:#5ec27a} pre .c{color:#5a6172;font-style:italic} pre .t{color:#5fb0c9}
table{border-collapse:collapse;width:100%;font-size:13.5px;margin:12px 0;display:block;overflow-x:auto}
th,td{border:1px solid var(--border);padding:8px 11px;text-align:left;vertical-align:top}
th{background:var(--panel2);color:var(--accent2);font-weight:600;white-space:nowrap}
.card{background:var(--panel);border:1px solid var(--border);border-radius:12px;padding:6px 18px;margin:14px 0}
.tag{display:inline-block;font-size:11px;padding:1px 8px;border-radius:20px;font-family:var(--mono)}
.t-direct{background:rgba(94,194,122,.16);color:var(--ok)} .t-proxy{background:rgba(224,136,79,.18);color:var(--accent)} .t-reject{background:rgba(224,106,106,.16);color:var(--bad)}
.box{border-left:3px solid var(--accent);background:var(--panel);border-radius:0 10px 10px 0;padding:12px 16px;margin:14px 0;font-size:14px}
.box.warn{border-color:var(--warn)} .box.q{border-color:var(--accent2)} .box.ok{border-color:var(--ok)}
ul{padding-left:22px} li{margin:5px 0}
a{color:var(--accent2)}
.layers{list-style:none;padding:0;counter-reset:l}
.layers li{counter-increment:l;background:var(--panel);border:1px solid var(--border);border-radius:9px;padding:9px 14px 9px 44px;margin:6px 0;position:relative;font-size:14px}
.layers li::before{content:counter(l);position:absolute;left:12px;top:50%;transform:translateY(-50%);width:22px;height:22px;border-radius:50%;background:var(--panel2);color:var(--accent2);display:flex;align-items:center;justify-content:center;font-family:var(--mono);font-size:12px}
.layers li.sys{border-left:3px solid var(--warn)} .layers li.user{border-left:3px solid var(--accent)}
.files{font-size:13px} .files .new{color:var(--ok)} .files .mod{color:var(--warn)}
.themebtn{position:fixed;top:14px;right:14px;background:var(--panel);border:1px solid var(--border);color:var(--fg2);border-radius:20px;padding:5px 12px;font-size:12px;cursor:pointer;font-family:var(--mono)}
</style>
</head>
<body>
<button class="themebtn" onclick="var r=document.documentElement,d=r.getAttribute('data-theme')==='light'?'dark':'light';r.setAttribute('data-theme',d)">◐ 主题</button>
<div class="wrap">
<h1>可配置分流 · Phase 1 实现计划</h1>
<p class="lead">让用户像 Shadowrocket 那样自定义路由规则(域名 / IP / GeoIP / GeoSite × 直连 / 走隧道 / 拒绝),存服务端 per-user 档案,连接时服务端翻译进渲染的 sing-box 配置。</p>
<p class="lead">执行真相源(含 <code>- [ ]</code> checkbox):<code>docs/superpowers/plans/2026-07-27-configurable-proxy-phase1.md</code> · 配套设计:<a href="configurable-proxy-spec.html">configurable-proxy-spec.html</a> · 视觉原型 <code>design/prototype/screens/ui-mobile.html</code></p>
<h2>Goal / Architecture</h2>
<div class="card">
<p><b>目标</b>:一套用户可见、可配的有序规则表(首命中生效),动作三选一。</p>
<p><b>架构铁律</b>:客户端<b></b>做规则编辑 UI + 存/取档案,<b>绝不</b>拼 sing-box 配置。用户编规则 → <code>POST /v1/me/routing</code> 存 per-user 档案(DB)→ connect 时 <code>BuildClientConfig</code> 读档案翻译成 <code>route.rules</code>,插在系统强制层之后、国内分流之前。IP 直连并入 <code>route_exclude_address</code>、域名直连靠 <code>reverse_mapping</code>+local DNS 真生效(复用 main 已验证的手法)。</p>
<p><b></b>:服务端 Go(chi + 裸 SQL + <code>internal/db</code> 方言层 + golang-migrate 双方言);客户端 Flutter + Riverpod(StateNotifier/AsyncNotifier)+ SharedPreferences。</p>
</div>
<h2>规则模型(server ↔ client 共用契约)</h2>
<pre><span class="c">// 档案 JSON</span>
{
<span class="t">"mode"</span>: <span class="s">"rule"</span>, <span class="c">// rule | global | direct</span>
<span class="t">"builtin"</span>: {
<span class="t">"china_direct"</span>: <span class="k">true</span>, <span class="c">// 国内分流(geoip-cn/geosite-cn → 直连)开关</span>
<span class="t">"lan_direct"</span>: <span class="k">true</span>, <span class="c">// LAN/私网直连(强制,恒 true)</span>
<span class="t">"private_via_tunnel"</span>: <span class="k">true</span> <span class="c">// 私有服务域名走隧道(服务端 env,恒 true)</span>
},
<span class="t">"rules"</span>: [ <span class="c">// 有序,首命中生效,上限 200</span>
{ <span class="t">"type"</span>:<span class="s">"domain_suffix"</span>, <span class="t">"value"</span>:<span class="s">"git.51yanmei.com"</span>, <span class="t">"action"</span>:<span class="s">"direct"</span>, <span class="t">"note"</span>:<span class="s">"CI 源"</span>, <span class="t">"enabled"</span>:<span class="k">true</span> }
],
<span class="t">"final"</span>: <span class="s">"proxy"</span> <span class="c">// proxy | direct(未命中兜底)</span>
}</pre>
<table>
<tr><th>type</th><th>示例</th><th>→ sing-box 字段</th></tr>
<tr><td><code>domain</code></td><td>example.com</td><td>domain(精确)</td></tr>
<tr><td><code>domain_suffix</code></td><td>aliyun.com</td><td>domain_suffix(含子域)</td></tr>
<tr><td><code>domain_keyword</code></td><td>google</td><td>domain_keyword</td></tr>
<tr><td><code>ip_cidr</code></td><td>35.190.0.0/16</td><td>ip_cidr(v4/v6)</td></tr>
<tr><td><code>geoip</code> / <code>geosite</code></td><td>cn</td><td>rule_set(自托管 .srs;Phase 1 仅 cn)</td></tr>
</table>
<table>
<tr><th>action</th><th>→ outbound</th><th>说明</th></tr>
<tr><td><span class="tag t-direct">direct</span></td><td>direct</td><td>物理网卡直连</td></tr>
<tr><td><span class="tag t-proxy">proxy</span></td><td>auto</td><td>经节点(REALITY/Hy2 urltest 择优)</td></tr>
<tr><td><span class="tag t-reject">reject</span></td><td>block</td><td>阻断</td></tr>
</table>
<h2>渲染层级(route.rules 自上而下,首命中)</h2>
<ul class="layers">
<li class="sys">DNS 劫持(port 53 → hijack-dns)· <b>系统强制</b></li>
<li class="sys">LAN / 私网直连(10/8 · 192.168/16 · 127/8)· <b>系统强制</b></li>
<li class="sys">私有服务域名 → 走隧道(<code>PANGOLIN_PRIVATE_SPLIT_DOMAINS</code><b>系统强制</b></li>
<li class="user"><b>用户规则</b>(档案 rules[] 顺序展开,仅 <code>mode==rule</code> 且 enabled)← 新增</li>
<li>国内分流 geoip-cn/geosite-cn → 直连(<code>builtin.china_direct</code> 开时)</li>
<li>FINAL 兜底(按 mode/final)</li>
</ul>
<div class="box q"><b>三模式语义</b>:<b>rule</b>(智能分流,默认)完整链;<b>global</b>(全局代理)跳过④⑤、final=auto、系统层仍在;<b>direct</b>(全部直连)跳过④⑤、final=direct、系统层仍在。用户规则排在国内分流<b>之前</b>——「我要 github 走隧道」能压过 geoip-cn 直连默认。系统层任何模式强制生效,用户不可覆盖。</div>
<h2>Global Constraints(每个任务隐含)</h2>
<ul>
<li><b>客户端不拼配置</b>:Dart 永不生成/改 sing-box JSON;只 PUT/GET 档案 + 编辑 UI。</li>
<li><b>多数据库</b>:走 <code>internal/db</code> 方言层(<code>dialect.Upsert</code> / <code>LockForUpdate</code>);时间 Go 端算传 <code>?</code>,禁 <code>NOW()</code>/<code>UTC_TIMESTAMP()</code>。迁移 mysql/sqlite 两套,编号 <code>000028</code></li>
<li><b>fail-safe</b>:档案损坏/为空 → 回退内置默认(等于现在行为),绝不产坏配置。PUT 校验非法整体拒绝(4xx + 逐条错误),不半保存。</li>
<li><b>文案单源</b>:新增 UI 文案改 <code>design/i18n/strings.json</code> + <code>app_text.dart</code> 抽象声明,跑 <code>gen_l10n_dart.mjs</code> 生成 6 份(勿手改);过 <code>ci/check-codegen-drift.sh</code>。routing* 文案键<b>已就位</b></li>
<li><b>错误响应</b>:<code>{code, message_zh, message_en}</code> 三字段 required;写操作走 <code>POST</code>(项目无 PUT 先例)。</li>
<li><b>每刀一 commit</b>:server <code>go build/test ./...</code>、client <code>flutter analyze</code>+<code>test</code> 全绿才提交。</li>
</ul>
<h2>任务总览(Task 0 前置 + 10 任务 TDD)</h2>
<table>
<tr><th>#</th><th>任务</th><th>关键产物 / 验证</th></tr>
<tr><td><b>0</b></td><td><b>合并 main</b>(前置)</td><td>拿到私有分流/reverse_mapping/route_exclude;解 7 冲突(3 非 l10n 并集 + 4 l10n 归一 strings.json 重生成);go/flutter/漂移闸全绿</td></tr>
<tr><td>1</td><td>routing_profiles 表 + Profile 类型 + Store</td><td>双方言 000028;<code>Store.Get/Upsert</code>;sqlite 实库测试</td></tr>
<tr><td>2</td><td>档案校验 / 规范化</td><td><code>Validate()[]FieldError</code>(type/CIDR/geo白名单/上限200)+ <code>Normalize()</code>(去重/trim)</td></tr>
<tr><td>3</td><td><code>GET/POST /v1/me/routing</code></td><td><code>RoutingAPI</code>;无档案返回 Default;非法 400+逐条;openapi 两份;httptest</td></tr>
<tr><td>4</td><td><b>核心</b>:BuildClientConfig 翻译</td><td>用户规则层;IP直连→route_exclude;域名直连→reverse_mapping;三模式;nil-profile 逐字节不变</td></tr>
<tr><td>5</td><td>connect 读档案 → 传入渲染</td><td>uid 已在手;fail-safe 回退默认;保留 <code>?split_cn</code> 兜底</td></tr>
<tr><td>6</td><td>客户端 model + API + provider</td><td><code>RoutingProfile</code>(fromJson/toJson);<code>AccountApi.routingProfile/saveRoutingProfile</code>;<code>AsyncNotifier</code></td></tr>
<tr><td>7</td><td>分流规则子屏 UI</td><td>模式段选 / 内置 / 我的规则增删排序 / 添加弹层 / 冲突提示;对照原型</td></tr>
<tr><td>8</td><td>设置入口下钻行 + 导航</td><td>smartRoute 开关 → 「分流规则」行(当前模式 pill + chevron);双形态</td></tr>
<tr><td>9</td><td>存档案后自动重连</td><td>连接态 on 时 save→<code>onNodeChanged</code> 重连使新规则生效</td></tr>
<tr><td>10</td><td>端到端联调 + 收尾</td><td>go/flutter 全量 + 真机冒烟 + 文档登记 + draft PR</td></tr>
</table>
<h2>File Structure</h2>
<div class="card files">
<p><b>服务端(Go)</b></p>
<ul>
<li><span class="new">新建</span> <code>server/migrations/{mysql,sqlite}/000028_routing_profiles.{up,down}.sql</code></li>
<li><span class="new">新建</span> <code>server/internal/routing/{profile.go, profile_test.go, store.go, store_sqlite_test.go}</code></li>
<li><span class="new">新建</span> <code>server/internal/httpapi/{routing.go, routing_test.go}</code></li>
<li><span class="mod"></span> <code>server/internal/httpapi/clientconfig.go</code>(opts 加 Profile;route.rules 插用户规则层;route_exclude / reverse_mapping;三模式)</li>
<li><span class="mod"></span> <code>server/internal/httpapi/nodes.go</code>(connect 读档案)· <code>server/cmd/server/main.go</code>(构造 + 注册路由)</li>
<li><span class="mod"></span> <code>design/server/openapi.yaml</code>(CI 校验)· <code>server/api/openapi.yaml</code></li>
</ul>
<p><b>客户端(Flutter)</b></p>
<ul>
<li><span class="new">新建</span> <code>client/lib/models/routing_profile.dart</code>· <code>client/lib/state/routing_provider.dart</code>· <code>client/lib/widgets/routing_screen.dart</code>(+ 3 个测试)</li>
<li><span class="mod"></span> <code>client/lib/services/account_api.dart</code>· <code>client/lib/screens/settings_page.dart:66</code>· <code>client/lib/state/navigation_provider.dart</code>· <code>client/lib/state/connection_provider.dart</code></li>
</ul>
</div>
<h2>关键任务细节(核心代码)</h2>
<h3>Task 0 · 合并 main(前置)</h3>
<div class="box warn">当前分支落后 main <b>37 commit</b>,spec 复用的 <code>PrivateSplitDomains</code>(系统层3)/<code>reverse_mapping</code>/<code>route_exclude_address</code> 都在 main 的 <code>74d8c85</code>、不在此分支。必须先合并。冲突 7 个:<code>.gitignore</code>/<code>docs/index.html</code>/<code>scripts/local_test.sh</code>(取并集)+ <code>strings_{es,ja,ko,ru}.dart</code>(main 手写 pay-v2 getter vs 我的 codegen 重排 → 把 main 新增并进 <code>strings.json</code> 单源后 <code>gen_l10n_dart.mjs</code> 重生成)。验证:<code>go build/test</code> + <code>flutter analyze/test</code> + 三段漂移闸全绿。</div>
<h3>Task 1 · Profile 类型 + Store</h3>
<pre><span class="k">type</span> <span class="t">Profile</span> <span class="k">struct</span> {
Mode <span class="k">string</span> <span class="c">// rule | global | direct</span>
Builtin <span class="t">Builtin</span> <span class="c">// ChinaDirect / LanDirect / PrivateViaTunnel</span>
Rules []<span class="t">Rule</span> <span class="c">// Type/Value/Action/Note/Enabled</span>
Final <span class="k">string</span> <span class="c">// proxy | direct</span>
}
<span class="c">// Default() 是 fail-safe 兜底,等价当前行为(智能分流+国内直连+无用户规则)</span>
<span class="k">func</span> (s *<span class="t">Store</span>) <span class="t">Get</span>(ctx, userID) (*<span class="t">Profile</span>, <span class="k">error</span>) <span class="c">// 无档案 → nil,nil</span>
<span class="k">func</span> (s *<span class="t">Store</span>) <span class="t">Upsert</span>(ctx, userID, p) <span class="k">error</span> <span class="c">// dialect.Upsert(["user_id"], ...)</span></pre>
<h3>Task 4 · BuildClientConfig 翻译(核心)</h3>
<p>在 main 版 route.rules 顺序基础上,<b>私有域名(层3)之后、geoip-cn(层5)之前</b>插入用户规则;提取纯函数便于测试:</p>
<pre><span class="k">func</span> <span class="t">translateUserRules</span>(p *routing.<span class="t">Profile</span>) (
rules []<span class="k">any</span>, extraExclude []<span class="k">string</span>, hasDomainDirect <span class="k">bool</span>, geoSets []<span class="k">string</span>)</pre>
<ul>
<li>action → outbound:<code>direct→"direct"</code> / <code>proxy→"auto"</code> / <code>reject→"block"</code></li>
<li><b>IP 直连真生效</b>:<code>ip_cidr && direct</code> 的 value 追加进 <code>route_exclude_address</code>(现为 <code>["192.168/16","10/8"]</code>)</li>
<li><b>域名直连真生效</b>:存在 <code>direct</code> 的域名类规则 → <code>dns["reverse_mapping"]=true</code></li>
<li><code>route["final"]</code>:direct→"direct" / global→"auto" / rule→(Final=="direct"?"direct":"auto")</li>
<li><b>nil profile</b> → 与现有行为逐字节一致(回退默认,有测试钉)</li>
</ul>
<h3>Task 7 · 分流规则子屏 UI</h3>
<p>对照视觉真源 <code>design/prototype/screens/ui-mobile.html</code>(data-sub="routing"):代理模式段选 → <code>setMode</code>;内置规则卡(国内直连开关 + LAN 锁定行);我的规则 <code>ReorderableListView</code>(拖动排序 + 动作 pill + 删除 + enabled 开关);「添加规则」弹层(类型 chip + 目标输入 + 动作段选,仿 <code>account_screens.dart:209 _renameDialog</code>);FINAL 行。<b>冲突提示</b>:被前面规则遮蔽的行灰化 +「已被上面规则覆盖」;命中系统锁定目标标「系统强制走隧道,此规则不生效」。桌面/移动双形态复用 <code>SubScaffold</code> + <code>account_page.dart:47 open()</code></p>
<h2>不在本轮(Phase 2/3)</h2>
<ul>
<li><b>Phase 2</b>:从文本导入(Shadowrocket/Clash 批量,原型第三屏已画)· 内置规则模板一键加 · 桌面端 UI 精修。</li>
<li><b>Phase 3</b>:分应用代理(per-app)· 规则订阅 URL。</li>
<li><b>暂不做</b>:更细规则粒度(URL 正则 / UA / 进程名)。</li>
</ul>
<div class="box ok">执行方式(确认计划后二选一):<b>A. Subagent 驱动(推荐)</b>——每 Task 派新 subagent 实现 + 两段复核,任务间可叫停;<b>B. 本会话内批量执行</b> + 检查点。</div>
</div>
</body>
</html>
+5
View File
@@ -101,6 +101,11 @@
</a>
<h2>实现计划 / Plans</h2>
<a class="doc" href="configurable-proxy-plan.html">
<div class="t">可配置分流 Phase 1 实现计划(Task 3<span class="tag html">HTML</span></div>
<div class="d">阅读版;执行真相源 <code>docs/superpowers/plans/2026-07-27-configurable-proxy-phase1.md</code>Task 0 前置 + 10 任务 TDD,含 checkbox)。<b>Task 0 先合并 main</b>(拿私有分流/reverse_mapping/route_exclude,落后 37 commit)。服务端(1-5):迁移 000028(routing_profiles per-user) → Profile 类型+Store(双方言) → 校验/规范化(type/CIDR/geo白名单/上限200/去重) → GET/POST /v1/me/routing+openapi → BuildClientConfig 翻译用户规则(层级插私有分流后·geoip前;IP直连并入 route_exclude、域名直连开 reverse_mapping;三模式;nil逐字节不变) → connect 读档案 fail-safe 回退。客户端(6-9)RoutingProfile model+AccountApi+AsyncNotifier → 分流规则子屏(模式段选/内置/我的规则增删排序/添加弹层/冲突提示) → 设置入口下钻行+双形态导航 → 存档案后自动重连。设计见 configurable-proxy-spec.html。</div>
<div class="path">docs/configurable-proxy-plan.html · 真相源 docs/superpowers/plans/2026-07-27-configurable-proxy-phase1.md</div>
</a>
<a class="doc" href="notifications-plan.html">
<div class="t">系统通知机制 实现计划(Spec ③)<span class="tag html">HTML</span></div>
<div class="d">阅读版;执行真相源 <code>docs/superpowers/plans/2026-07-13-notifications.md</code>11 任务 TDD,含 checkbox)。后端(1-7):迁移 000026(notices 表+users.notices_read_at) → notices.Store(广播∪定向合并/已读水位/事务内插) → GET /v1/notices+POST /read(替换空壳)+openapi 扩展 → 四事件钩子同事务(邀请注册/首充/TG任务/购买开通,零孤儿) → notices.Service(发布/撤回/important 邮件兜底幂等) → nodectl notice add/list/revoke → main.go 装配+发版脚本联动插 version 公告。原型与客户端(8-10):原型先行统一两端通知视图(桌面式pill+未读点)+reward/version/promo 示例 → notices api/provider(未读计数/延迟首拉) → 通知页真实化+铃铛红点接 provider+三新类型六语标签。Subagent 驱动执行。设计见 notifications-design.html。</div>
@@ -0,0 +1,678 @@
# 可配置分流(Configurable Proxy)Phase 1 实现计划
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 让 pangolin 客户端用户像 Shadowrocket 那样自定义路由规则(域名/IP/GeoIP/GeoSite × 直连/走隧道/拒绝),存为服务端 per-user 路由档案,连接时服务端翻译进渲染的 sing-box 配置。
**Architecture:** 客户端**只**做规则编辑 UI + 存/取服务端档案(铁律:客户端不拼 sing-box 配置)。用户在 App 编规则 → `POST /v1/me/routing` 存 per-user 档案(DB)→ connect 时 `BuildClientConfig` 读档案翻译成 `route.rules`,插在系统强制层之后、国内分流之前。IP 直连规则并入 `route_exclude_address`、域名直连靠 `reverse_mapping`+local DNS 真生效(复用 main 已验证的 sing-box 手法)。
**Tech Stack:** 服务端 Go(chi router + 裸 SQL + `internal/db` 方言层 + golang-migrate 双方言 mysql/sqlite);客户端 Flutter + Riverpod(StateNotifier/AsyncNotifier)+ SharedPreferences;sing-box 数据面。
## Global Constraints
- **客户端不拼配置**:Dart 侧永不生成/修改 sing-box JSON;配置一律服务端渲染原样下发(ARCHITECTURE.md §3.1)。客户端只 PUT 档案、GET 档案、编辑 UI。
- **多数据库**:schema/查询走 `internal/db` 方言层;`dialect.Upsert(...)`(中性 `EXCLUDED.col`)、`dialect.LockForUpdate()`;时间 Go 端算好传 `?`,禁 `NOW()`/`UTC_TIMESTAMP()`/`FIELD()`。迁移 mysql/sqlite 两套,编号 `000028`(当前最新 `000027`)。
- **系统强制层永远赢**:route.rules 层级 ①hijack-dns ②LAN 直连 ③私有服务域名走隧道(`PANGOLIN_PRIVATE_SPLIT_DOMAINS`)—— 恒在用户规则之上,用户不可覆盖/删除。
- **用户规则排在国内分流之前**:层 ④用户规则 → 层 ⑤geoip-cn/geosite-cn 直连 → 层 ⑥FINAL。用户显式规则压过 geoip-cn 宽泛默认。
- **三模式语义**:`rule`(智能分流,默认,完整链)/ `global`(全局代理,除系统层全走隧道,忽略用户规则)/ `direct`(全部直连,除系统层全直连,忽略用户规则)。系统层任何模式强制生效。
- **fail-safe**:档案损坏/为空 → 回退内置默认(等于现在的行为),绝不产坏配置。PUT 校验非法整体拒绝(4xx + 逐条错误),不半保存。
- **文案单源**:新增 UI 文案改 `design/i18n/strings.json` + `client/lib/l10n/app_text.dart` 抽象声明,跑 `node design/codegen/gen_l10n_dart.mjs` 生成 6 份 `strings_*.dart`(勿手改);过 `bash ci/check-codegen-drift.sh`。routing* 文案键**已就位**(Phase 3 i18n 阶段已加)。
- **错误响应格式**:`apierr.WriteJSON(w, status, *apierr.Error)`,body `{code, message_zh, message_en}` 三字段 required。
- **红线词脱敏**:任何 UI/文案禁 VPN/翻墙/科学上网 等(design/CLAUDE.md §1 铁律 13);「隧道/走隧道」属技术词允许。
- **每刀一 commit**:每个 Task 完成即 commit;server `go build ./...` / `go test ./...`,client `flutter analyze` + `flutter test` 全绿才提交。
## 规则模型(档案 JSON,server 与 client 共用契约)
```json
{
"mode": "rule", // rule | global | direct
"builtin": {
"china_direct": true, // 国内分流(geoip-cn/geosite-cn → 直连)开关
"lan_direct": true, // LAN/私网直连(强制,恒 true,不可关)
"private_via_tunnel": true // 私有服务域名走隧道(服务端 env,恒 true,不可关)
},
"rules": [ // 用户自定义,有序,首命中生效,上限 200 条
{ "type": "domain_suffix", "value": "git.51yanmei.com", "action": "direct", "note": "CI 源", "enabled": true }
],
"final": "proxy" // proxy | direct(未命中兜底)
}
```
- `type` ∈ {`domain`, `domain_suffix`, `domain_keyword`, `ip_cidr`, `geoip`, `geosite`}
- `action` ∈ {`direct`, `proxy`, `reject`} → sing-box outbound {`direct`, `auto`, `block`}
- `geoip`/`geosite``value` 必须在自托管规则集清单内(Phase 1 仅 `geoip-cn` / `geosite-cn`,即 `cn`)。
---
## File Structure(新建/修改)
**服务端(Go)**
- Create `server/migrations/mysql/000028_routing_profiles.up.sql` / `.down.sql`
- Create `server/migrations/sqlite/000028_routing_profiles.up.sql` / `.down.sql`
- Create `server/internal/routing/profile.go` —— 档案类型 + 默认档案 + 校验(纯逻辑,无 DB/HTTP)
- Create `server/internal/routing/profile_test.go`
- Create `server/internal/routing/store.go` —— `Store{Get, Upsert}`(裸 SQL + dialect)
- Create `server/internal/routing/store_sqlite_test.go`
- Modify `server/internal/httpapi/clientconfig.go` —— `ClientConfigOpts``Profile *routing.Profile`;`route.rules` 插用户规则层 + IP 直连并入 `route_exclude_address` + 域名直连开 `reverse_mapping`;三模式语义
- Modify `server/internal/httpapi/clientconfig_test.go` —— 渲染断言
- Create `server/internal/httpapi/routing.go` —— `RoutingAPI{GetProfile, SaveProfile}` handlers
- Create `server/internal/httpapi/routing_test.go`
- Modify `server/internal/httpapi/nodes.go:~316` —— connect 时读档案传入 opts
- Modify `server/cmd/server/main.go:~407/~471` —— 构造 `RoutingAPI` + 注册 `GET/POST /v1/me/routing`
- Modify `design/server/openapi.yaml` + `server/api/openapi.yaml` —— 两端点规格
**客户端(Flutter)**
- Create `client/lib/models/routing_profile.dart` —— `RoutingProfile` / `RoutingRule`(fromJson/toJson)
- Create `client/test/unit/routing_profile_model_test.dart`
- Modify `client/lib/services/account_api.dart` —— `routingProfile()` GET + `saveRoutingProfile(p)` POST
- Create `client/lib/state/routing_provider.dart` —— `RoutingProfileNotifier extends AsyncNotifier<RoutingProfile>` + 本地草稿
- Create `client/test/unit/routing_provider_test.dart`
- Create `client/lib/widgets/routing_screen.dart` —— 分流规则子屏(模式段选 + 内置 + 我的规则增删排序 + 添加弹层 + FINAL + 冲突提示)
- Create `client/test/widget/routing_screen_test.dart`
- Modify `client/lib/screens/settings_page.dart:66` —— smartRoute 开关行 → 「分流规则」下钻行(右侧当前模式 pill + chevron)
- Modify `client/lib/state/navigation_provider.dart` —— `NavView``routing`(桌面内容区)
- Modify `client/lib/state/connection_provider.dart` —— 存档案后触发重连(复用 `onNodeChanged` 序列)
- (文案已在 strings.json;若需新增冲突提示文案,走单源 codegen)
---
## Task 0: 同步分支到 main(前置,合并 + l10n 单源归一)
**Files:**
- Merge: `main``feat/configurable-proxy`
- Resolve: `.gitignore`, `docs/index.html`, `scripts/local_test.sh`, `client/lib/l10n/strings_{es,ja,ko,ru}.dart`
**为什么:** 当前分支落后 main 37 commit,spec 复用的 `PrivateSplitDomains`(系统层3)/`reverse_mapping`(域名直连)/`route_exclude_address`(IP 直连)三套机制都在 main 的 `74d8c85`、不在此分支。必须先合并才能在 main 版 `clientconfig.go` 上扩展。
- [ ] **Step 1: 起合并** `git merge main --no-commit --no-ff`(预期 7 冲突:3 非 l10n + 4 l10n)
- [ ] **Step 2: 解非 l10n 冲突**(取并集,人工确认语义):`.gitignore`(两侧条目合并)、`docs/index.html`(两侧文档索引条目合并,含本计划)、`scripts/local_test.sh`(两侧改动合并)。逐个 `git add`
- [ ] **Step 3: 解 l10n 冲突(单源归一)**:main 给 `strings_{es,ja,ko,ru}.dart` 手写新增了 pay-v2 购买 getter(purchaseTitle/paymentTitle/… 各 ~29 个)。做法:
- 确认 main 的 pay-v2 getter 是否已在本分支 `design/i18n/strings.json`(本分支 zh/en/app_text 已自动合并含它们)。缺哪些语言值 → 从 main 的对应 `strings_*.dart` 抽出补进 strings.json 的 `getters`(补齐 6 语)。
- `git checkout --ours client/lib/l10n/strings_{es,ja,ko,ru}.dart`(先取本分支生成物占位),再 `node design/codegen/gen_l10n_dart.mjs` 从归一后的 strings.json 重生成全部 6 份,`git add client/lib/l10n/`
- [ ] **Step 4: 提交合并** `git commit`(合并提交)
- [ ] **Step 5: 验证** —— 全绿才算完成:
```bash
cd server && go build ./... && go test ./... 2>&1 | tail -5
cd ../client && flutter analyze 2>&1 | tail -3 && flutter test 2>&1 | tail -3
cd .. && bash ci/check-codegen-drift.sh 2>&1 | grep -E "✅|❌"
node design/prototype/tools/check-proto-i18n.mjs 2>&1 | tail -1
```
Expected: go build/test 通过、flutter analyze 0 issue、flutter test 全绿、三段漂移闸 ✅、原型 i18n resolve。若 pay-v2 getter 在 strings.json 缺失导致 analyze 报「missing override」→ 回 Step 3 补齐。
---
## Task 1: routing_profiles 表 + Profile 类型 + Store
**Files:**
- Create: `server/migrations/{mysql,sqlite}/000028_routing_profiles.{up,down}.sql`
- Create: `server/internal/routing/profile.go`(仅类型;校验在 Task 2)
- Create: `server/internal/routing/store.go`, `server/internal/routing/store_sqlite_test.go`
**Interfaces:**
- Produces: `routing.Profile`(struct,见规则模型)、`routing.Rule``routing.NewStore(db *sql.DB) *Store``(*Store).Get(ctx, userID int64) (*Profile, error)`(无则返回 `nil, nil`)、`(*Store).Upsert(ctx, userID int64, p *Profile) error`
- [ ] **Step 1: 写迁移(两方言)**
`server/migrations/mysql/000028_routing_profiles.up.sql`:
```sql
CREATE TABLE routing_profiles (
user_id BIGINT UNSIGNED NOT NULL PRIMARY KEY,
profile_json TEXT NOT NULL,
updated_at DATETIME(6) NOT NULL,
CONSTRAINT fk_routing_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
```
`server/migrations/sqlite/000028_routing_profiles.up.sql`:
```sql
CREATE TABLE routing_profiles (
user_id INTEGER NOT NULL PRIMARY KEY,
profile_json TEXT NOT NULL,
updated_at DATETIME NOT NULL
);
```
两个 `.down.sql`:`DROP TABLE IF EXISTS routing_profiles;`
- [ ] **Step 2: 写 Profile 类型** `server/internal/routing/profile.go`
```go
package routing
type Rule struct {
Type string `json:"type"`
Value string `json:"value"`
Action string `json:"action"`
Note string `json:"note,omitempty"`
Enabled bool `json:"enabled"`
}
type Builtin struct {
ChinaDirect bool `json:"china_direct"`
LanDirect bool `json:"lan_direct"`
PrivateViaTunnel bool `json:"private_via_tunnel"`
}
type Profile struct {
Mode string `json:"mode"` // rule | global | direct
Builtin Builtin `json:"builtin"`
Rules []Rule `json:"rules"`
Final string `json:"final"` // proxy | direct
}
// Default 是 fail-safe 兜底,等价当前行为(智能分流 + 国内直连 + 无用户规则)。
func Default() *Profile {
return &Profile{
Mode: "rule",
Builtin: Builtin{ChinaDirect: true, LanDirect: true, PrivateViaTunnel: true},
Rules: []Rule{},
Final: "proxy",
}
}
```
- [ ] **Step 3: 写 Store 测试(先失败)** `server/internal/routing/store_sqlite_test.go`(仿 `server/internal/notices/store_sqlite_test.go:13``openDB`/`seedU`)
```go
func TestSQLiteRoutingStoreUpsertGet(t *testing.T) {
db := openDB(t) // 内存库 + MigrateUp(sqlite)
seedU(t, db, 1, "u1")
st := routing.NewStore(db)
ctx := context.Background()
// 无档案 → nil,nil
got, err := st.Get(ctx, 1)
if err != nil || got != nil { t.Fatalf("empty want nil,nil got %v,%v", got, err) }
// Upsert 后可取回
p := routing.Default(); p.Final = "direct"
if err := st.Upsert(ctx, 1, p); err != nil { t.Fatal(err) }
got, err = st.Get(ctx, 1)
if err != nil || got == nil || got.Final != "direct" { t.Fatalf("got %v,%v", got, err) }
// 二次 Upsert 覆盖
p.Final = "proxy"; _ = st.Upsert(ctx, 1, p)
got, _ = st.Get(ctx, 1)
if got.Final != "proxy" { t.Fatalf("upsert overwrite failed: %s", got.Final) }
}
```
`openDB`/`seedU` 复制 notices 测试里的 helper 到本包测试文件。)
- [ ] **Step 4: 跑测试确认失败** `cd server && go test ./internal/routing/ -run TestSQLiteRoutingStore -v` → FAIL(NewStore undefined)
- [ ] **Step 5: 写 Store** `server/internal/routing/store.go`(仿 `server/internal/usage/store.go:48` 构造 + `internal/db` 方言)
```go
package routing
import (
"context"
"database/sql"
"encoding/json"
"errors"
"time"
dbx "github.com/.../server/internal/db" // 按仓库实际 module path
)
type Store struct {
db *sql.DB
dialect dbx.Dialect
}
func NewStore(db *sql.DB) *Store { return &Store{db: db, dialect: dbx.DialectForDB(db)} }
func (s *Store) Get(ctx context.Context, userID int64) (*Profile, error) {
var raw string
err := s.db.QueryRowContext(ctx,
`SELECT profile_json FROM routing_profiles WHERE user_id = ?`, userID).Scan(&raw)
if errors.Is(err, sql.ErrNoRows) { return nil, nil }
if err != nil { return nil, err }
var p Profile
if err := json.Unmarshal([]byte(raw), &p); err != nil { return nil, err }
return &p, nil
}
func (s *Store) Upsert(ctx context.Context, userID int64, p *Profile) error {
raw, err := json.Marshal(p)
if err != nil { return err }
now := time.Now().UTC()
q := `INSERT INTO routing_profiles (user_id, profile_json, updated_at) VALUES (?,?,?) ` +
s.dialect.Upsert([]string{"user_id"},
"profile_json = EXCLUDED.profile_json", "updated_at = EXCLUDED.updated_at")
_, err = s.db.ExecContext(ctx, q, userID, string(raw), now)
return err
}
```
`db` import path、`Dialect` 类型名、`DialectForDB``server/internal/db/dialect.go``usage/store.go` 实际为准。)
- [ ] **Step 6: 跑测试确认通过** `go test ./internal/routing/ -run TestSQLiteRoutingStore -v` → PASS。另跑 `./server/run_sqlite_test.sh` 若它含迁移 up/down 全量校验,确保 000028 up/down 干净。
- [ ] **Step 7: Commit** `git add server/migrations server/internal/routing && git commit -m "feat(routing): routing_profiles 表 + Profile 类型 + store(双方言)"`
---
## Task 2: 档案校验 + 规范化(validate + 白名单 + 上限 + 去重)
**Files:**
- Modify: `server/internal/routing/profile.go`(加 `Validate`/`Normalize`)
- Create: `server/internal/routing/profile_test.go`
**Interfaces:**
- Produces: `func (p *Profile) Validate() []FieldError`(空 slice = 合法);`FieldError{Index int, Field, Reason string}`;`func (p *Profile) Normalize()`(去重、trim、修正 enabled 缺省)。
- Consumes: `routing.Profile`(Task 1)。
- [ ] **Step 1: 写校验测试(先失败)** `server/internal/routing/profile_test.go`
```go
func TestValidate(t *testing.T) {
ok := routing.Default()
ok.Rules = []routing.Rule{{Type: "domain_suffix", Value: "example.com", Action: "direct", Enabled: true}}
if e := ok.Validate(); len(e) != 0 { t.Fatalf("valid profile got errors %v", e) }
bad := routing.Default()
bad.Mode = "weird" // 非法 mode
bad.Final = "reject" // final 只能 proxy|direct
bad.Rules = []routing.Rule{
{Type: "ip_cidr", Value: "not-a-cidr", Action: "proxy", Enabled: true}, // CIDR 非法
{Type: "geosite", Value: "netflix", Action: "direct", Enabled: true}, // 不在白名单(仅 cn)
{Type: "bogus", Value: "x", Action: "direct", Enabled: true}, // type 非法
}
errs := bad.Validate()
if len(errs) < 5 { t.Fatalf("want >=5 field errors, got %d: %v", len(errs), errs) }
}
func TestValidateCountLimit(t *testing.T) {
p := routing.Default()
for i := 0; i < 201; i++ { p.Rules = append(p.Rules, routing.Rule{Type:"domain",Value:"a.com",Action:"proxy",Enabled:true}) }
if e := p.Validate(); len(e) == 0 { t.Fatal("want count-limit error") }
}
```
- [ ] **Step 2: 跑确认失败** `go test ./internal/routing/ -run TestValidate -v` → FAIL
- [ ] **Step 3: 实现 Validate/Normalize** `server/internal/routing/profile.go`(追加)
```go
type FieldError struct {
Index int `json:"index"` // -1 表示档案级(mode/final)
Field string `json:"field"`
Reason string `json:"reason"`
}
const MaxRules = 200
var validType = map[string]bool{"domain": true, "domain_suffix": true, "domain_keyword": true, "ip_cidr": true, "geoip": true, "geosite": true}
var validAction = map[string]bool{"direct": true, "proxy": true, "reject": true}
var geoWhitelist = map[string]bool{"cn": true} // geoip/geosite 仅自托管 cn
func (p *Profile) Validate() []FieldError {
var errs []FieldError
if p.Mode != "rule" && p.Mode != "global" && p.Mode != "direct" {
errs = append(errs, FieldError{-1, "mode", "must be rule|global|direct"})
}
if p.Final != "proxy" && p.Final != "direct" {
errs = append(errs, FieldError{-1, "final", "must be proxy|direct"})
}
if len(p.Rules) > MaxRules {
errs = append(errs, FieldError{-1, "rules", "exceeds max 200"})
}
for i, r := range p.Rules {
if !validType[r.Type] { errs = append(errs, FieldError{i, "type", "invalid type"}) }
if !validAction[r.Action] { errs = append(errs, FieldError{i, "action", "invalid action"}) }
if r.Value == "" { errs = append(errs, FieldError{i, "value", "empty"}) }
switch r.Type {
case "ip_cidr":
if _, _, err := net.ParseCIDR(r.Value); err != nil {
errs = append(errs, FieldError{i, "value", "invalid CIDR"})
}
case "geoip", "geosite":
if !geoWhitelist[strings.ToLower(r.Value)] {
errs = append(errs, FieldError{i, "value", "geo set not in whitelist (cn only)"})
}
}
}
return errs
}
func (p *Profile) Normalize() {
// trim value、按 (type,value,action) 去重、保持首现顺序
seen := map[string]bool{}
out := p.Rules[:0]
for _, r := range p.Rules {
r.Value = strings.TrimSpace(r.Value)
k := r.Type + "|" + r.Value + "|" + r.Action
if seen[k] { continue }
seen[k] = true
out = append(out, r)
}
p.Rules = out
}
```
import `net``strings`。)
- [ ] **Step 4: 跑确认通过** `go test ./internal/routing/ -v` → PASS
- [ ] **Step 5: Commit** `git commit -am "feat(routing): 档案校验 + 规范化(白名单/上限/去重)"`
---
## Task 3: `GET/POST /v1/me/routing` 端点 + openapi
**Files:**
- Create: `server/internal/httpapi/routing.go`, `server/internal/httpapi/routing_test.go`
- Modify: `server/cmd/server/main.go`(构造 + 注册)
- Modify: `design/server/openapi.yaml`, `server/api/openapi.yaml`
**Interfaces:**
- Consumes: `routing.Store`(Task 1)、`routing.Profile.Validate/Normalize`(Task 2)、`auth.UserIDFromContext``apierr.WriteJSON`
- Produces: `httpapi.NewRoutingAPI(store *routing.Store) *RoutingAPI`;`(*RoutingAPI).GetProfile(w,r)`(GET;无档案返回 `routing.Default()`);`(*RoutingAPI).SaveProfile(w,r)`(POST;校验失败 → 400 + 逐条错误)。
- [ ] **Step 1: 写 handler 测试(先失败)** `server/internal/httpapi/routing_test.go`(仿 `clientconfig_test.go``chi.NewRouter()` + `httptest`,注入伪 auth context 塞 user_id)
```go
func TestRoutingGetDefaultThenSave(t *testing.T) {
db := openTestDB(t) // 复用本包既有 test DB helper(见 clientconfig_test/account 测试)
seedUser(t, db, 7)
api := httpapi.NewRoutingAPI(routing.NewStore(db))
// GET 无档案 → 200 + Default
rr := doAuthReq(t, http.MethodGet, "/v1/me/routing", nil, 7, api.GetProfile)
if rr.Code != 200 { t.Fatalf("GET code %d", rr.Code) }
var p routing.Profile; json.Unmarshal(rr.Body.Bytes(), &p)
if p.Mode != "rule" { t.Fatalf("default mode %s", p.Mode) }
// POST 合法 → 200
body := `{"mode":"rule","builtin":{"china_direct":true,"lan_direct":true,"private_via_tunnel":true},"rules":[{"type":"domain_suffix","value":"x.com","action":"direct","enabled":true}],"final":"proxy"}`
rr = doAuthReq(t, http.MethodPost, "/v1/me/routing", strings.NewReader(body), 7, api.SaveProfile)
if rr.Code != 200 { t.Fatalf("POST code %d body %s", rr.Code, rr.Body) }
// POST 非法 → 400 + errors
rr = doAuthReq(t, http.MethodPost, "/v1/me/routing", strings.NewReader(`{"mode":"x","final":"y","rules":[]}`), 7, api.SaveProfile)
if rr.Code != 400 { t.Fatalf("bad POST code %d", rr.Code) }
}
```
`doAuthReq``context.WithValue(r.Context(), codes.CtxKeyUserID, int64(uid))` 注入,参照 `auth/middleware.go:45``openTestDB`/`seedUser` 复用本包既有 helper。)
- [ ] **Step 2: 跑确认失败** `go test ./internal/httpapi/ -run TestRoutingGet -v` → FAIL
- [ ] **Step 3: 实现 handler** `server/internal/httpapi/routing.go`
```go
package httpapi
type RoutingAPI struct{ store *routing.Store }
func NewRoutingAPI(store *routing.Store) *RoutingAPI { return &RoutingAPI{store: store} }
func (a *RoutingAPI) GetProfile(w http.ResponseWriter, r *http.Request) {
uid, ok := auth.UserIDFromContext(r.Context())
if !ok { apierr.WriteJSON(w, http.StatusUnauthorized, apierr.ErrUnauthorized); return }
p, err := a.store.Get(r.Context(), uid)
if err != nil { apierr.WriteJSON(w, http.StatusInternalServerError, apierr.ErrInternal); return }
if p == nil { p = routing.Default() }
writeJSON(w, http.StatusOK, p) // 复用本包既有 writeJSON 或 json.NewEncoder
}
func (a *RoutingAPI) SaveProfile(w http.ResponseWriter, r *http.Request) {
uid, ok := auth.UserIDFromContext(r.Context())
if !ok { apierr.WriteJSON(w, http.StatusUnauthorized, apierr.ErrUnauthorized); return }
var p routing.Profile
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 64*1024)).Decode(&p); err != nil {
apierr.WriteJSON(w, http.StatusBadRequest, apierr.ErrBadRequest); return
}
p.Normalize()
if errs := p.Validate(); len(errs) > 0 {
// 400 + 逐条错误(在 apierr.Error 里带 details,或直接自定义 body)
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(http.StatusBadRequest)
json.NewEncoder(w).Encode(map[string]any{
"code": "routing_invalid", "message_zh": "规则校验未通过", "message_en": "Rule validation failed",
"errors": errs,
})
return
}
if err := a.store.Upsert(r.Context(), uid, &p); err != nil {
apierr.WriteJSON(w, http.StatusInternalServerError, apierr.ErrInternal); return
}
writeJSON(w, http.StatusOK, &p)
}
```
- [ ] **Step 4: 注册路由** `server/cmd/server/main.go`:在依赖构造区(`:~407`)加 `routingAPI := httpapi.NewRoutingAPI(routing.NewStore(db))`;在受保护分组(`:~471` `protected`)加:
```go
protected.Get("/me/routing", routingAPI.GetProfile)
protected.Post("/me/routing", routingAPI.SaveProfile)
```
- [ ] **Step 5: 跑确认通过** `go test ./internal/httpapi/ -run TestRoutingGet -v` → PASS;`go build ./...`
- [ ] **Step 6: 更新 openapi**(两份;CI 只校验 `design/server/openapi.yaml`):`paths` 下加 `/me/routing``get`(200 → Profile schema)+ `post`(requestBody Profile,200 / 400)。加 `RoutingProfile`/`RoutingRule` components schema。本地校验:`docker run --rm -v "$PWD/design/server:/spec" python:3.12-alpine sh -c "pip install -q openapi-spec-validator && openapi_spec_validator /spec/openapi.yaml"`
- [ ] **Step 7: Commit** `git commit -am "feat(routing): GET/POST /v1/me/routing 端点 + openapi"`
---
## Task 4: `BuildClientConfig` 翻译档案进 route.rules(核心)
**Files:**
- Modify: `server/internal/httpapi/clientconfig.go`(`ClientConfigOpts``Profile`;route.rules 插用户规则层;IP 直连并入 `route_exclude_address`;域名直连开 `reverse_mapping`;三模式)
- Modify: `server/internal/httpapi/clientconfig_test.go`
**Interfaces:**
- Consumes: `routing.Profile`(Task 1)。
- Produces: `ClientConfigOpts` 新增字段 `Profile *routing.Profile`(nil = 回退当前默认行为)。
**渲染语义(在 main 版 route.rules 顺序基础上插入):**
```
① hijack-dns(port 53) — 系统强制,不变
② LAN 直连(ip_cidr → direct) — 系统强制,不变
③ 私有服务域名 → 走隧道(privateSplit)— 系统强制,不变
④ 用户规则(profile.Rules,mode==rule 且 enabled,按序) ← 新增
⑤ 国内分流 geoip-cn/geosite-cn → 直连(builtin.china_direct 且 mode==rule)
⑥ route.final = 按 mode/final:rule→(final==direct?direct:auto)/ global→auto / direct→direct
```
- `mode==global`:跳过 ④⑤,final=auto(除系统层全走隧道)。
- `mode==direct`:跳过 ④⑤,final=direct(除系统层全直连)。
- 用户规则 action → outbound:`direct→"direct"` / `proxy→"auto"` / `reject→"block"`
- 用户规则 type → sing-box 字段:`domain→"domain"`(数组)/ `domain_suffix→"domain_suffix"` / `domain_keyword→"domain_keyword"` / `ip_cidr→"ip_cidr"` / `geoip→rule_set "geoip-<v>"` / `geosite→rule_set "geosite-<v>"`
- **IP 直连真生效**:凡 `type==ip_cidr && action==direct` 的 value,追加进 `route_exclude_address`(main `clientconfig.go:121` 现为 `["192.168.0.0/16","10.0.0.0/8"]`)。
- **域名直连真生效**:凡存在 `action==direct` 的域名类规则(domain/domain_suffix/domain_keyword)→ 确保 `dns["reverse_mapping"]=true`(main 仅在 privateSplit 时开;此处扩展条件)。
- **geo 规则引用**:若用户规则用到 `geoip-cn`/`geosite-cn` 而 SplitCN 未开,仍需把对应 rule_set 定义加进 `route["rule_set"]`(去重,复用 main 的 rule_set 定义块)。
- [ ] **Step 1: 写渲染测试(先失败)** `server/internal/httpapi/clientconfig_test.go`(追加,仿现有 `json.Unmarshal` + 逐字段/`strings.Contains` 断言)
```go
func TestBuildConfigUserRules(t *testing.T) {
node := testNode() // 复用本文件既有构造
p := routing.Default()
p.Rules = []routing.Rule{
{Type: "domain_suffix", Value: "github.com", Action: "proxy", Enabled: true},
{Type: "ip_cidr", Value: "35.190.0.0/16", Action: "direct", Enabled: true},
{Type: "domain_suffix", Value: "git.51yanmei.com", Action: "direct", Enabled: true},
}
raw, err := BuildClientConfig(node, "dp", "k", ClientConfigOpts{Profile: p, SplitCN: true, RulesBaseURL: "http://x"})
if err != nil { t.Fatal(err) }
var cfg map[string]any; json.Unmarshal(raw, &cfg)
rules := cfg["route"].(map[string]any)["rules"].([]any)
// 用户 github→auto 规则应在 geoip-cn 规则之前
iUser, iCN := ruleIndexByDomain(rules, "github.com"), ruleIndexByRuleSet(rules, "geoip-cn")
if iUser < 0 || iCN < 0 || iUser > iCN { t.Fatalf("user rule must precede geoip-cn: %d vs %d", iUser, iCN) }
// IP 直连并入 route_exclude_address
excl := toStrings(cfg["route"].(map[string]any)["route_exclude_address"])
if !contains(excl, "35.190.0.0/16") { t.Fatalf("ip direct not in route_exclude_address: %v", excl) }
// 有域名直连 → reverse_mapping 开
if cfg["dns"].(map[string]any)["reverse_mapping"] != true { t.Fatal("reverse_mapping must be on") }
}
func TestBuildConfigGlobalMode(t *testing.T) {
p := routing.Default(); p.Mode = "global"
p.Rules = []routing.Rule{{Type:"domain_suffix",Value:"github.com",Action:"direct",Enabled:true}}
raw, _ := BuildClientConfig(testNode(), "dp", "k", ClientConfigOpts{Profile: p, SplitCN: true, RulesBaseURL: "http://x"})
var cfg map[string]any; json.Unmarshal(raw, &cfg)
// global:忽略用户规则 + 无 geoip-cn 直连,final=auto,但系统层(hijack-dns/LAN)仍在
rules := cfg["route"].(map[string]any)["rules"].([]any)
if ruleIndexByDomain(rules, "github.com") >= 0 { t.Fatal("global must ignore user rules") }
if cfg["route"].(map[string]any)["final"] != "auto" { t.Fatal("global final=auto") }
if !hasHijackDNS(rules) { t.Fatal("system layer must survive in global") }
}
func TestBuildConfigNilProfileUnchanged(t *testing.T) {
// Profile==nil → 与现有行为逐字节一致(回退默认)
a, _ := BuildClientConfig(testNode(), "dp", "k", ClientConfigOpts{SplitCN: true, RulesBaseURL: "http://x"})
b, _ := BuildClientConfig(testNode(), "dp", "k", ClientConfigOpts{Profile: nil, SplitCN: true, RulesBaseURL: "http://x"})
if !bytes.Equal(a, b) { t.Fatal("nil profile must equal no-profile") }
}
```
`ruleIndexByDomain`/`ruleIndexByRuleSet`/`hasHijackDNS`/`toStrings`/`contains` 为测试内小 helper,写在测试文件。)
- [ ] **Step 2: 跑确认失败** `go test ./internal/httpapi/ -run TestBuildConfig -v` → FAIL
- [ ] **Step 3: 实现**(改 `clientconfig.go`):
1. `ClientConfigOpts``Profile *routing.Profile`
2. 在 route.rules 拼装块(main `:143-187`),privateSplit 之后、geoip-cn(SplitCN)之前,插入:若 `opts.Profile != nil && opts.Profile.Mode == "rule"`,遍历 `Profile.Rules`(`Enabled` 为 true),按 type→字段、action→outbound 生成规则 append;收集 ip_cidr+direct 的 value 到 `extraExclude`、标记 `hasDomainDirect`、收集用到的 geo rule_set。
3. geoip-cn/geosite-cn 直连规则(⑤)条件改为 `(opts.SplitCN || 无)`… 保持:`mode=="rule" && Builtin.ChinaDirect`(取代旧 `opts.SplitCN`;connect 端后续传 `SplitCN = profile.Builtin.ChinaDirect`,Task 5)。
4. `route["final"]`:`mode=="direct"→"direct"`;`mode=="global"→"auto"`;`mode=="rule"→ if Final=="direct" "direct" else "auto"`
5. `route_exclude_address` append `extraExclude`(去重)。
6. `dns["reverse_mapping"]`:`privateSplit || hasDomainDirect` 时为 true。
7. 若用到 geo rule_set 而 `route["rule_set"]` 未含 → 补定义(去重)。
提取一个纯函数便于测试:`func translateUserRules(p *routing.Profile) (rules []any, extraExclude []string, hasDomainDirect bool, geoSets []string)`
- [ ] **Step 4: 跑确认通过** `go test ./internal/httpapi/ -run TestBuildConfig -v` → PASS;`go test ./...`(确保未破坏既有 clientconfig 测试)
- [ ] **Step 5: Commit** `git commit -am "feat(routing): BuildClientConfig 翻译用户规则(层级/IP直连/域名直连/三模式)"`
---
## Task 5: connect 端点读档案 → 传入渲染
**Files:**
- Modify: `server/internal/httpapi/nodes.go`(`ConnectNode`,`:~316`)
- Modify: `server/cmd/server/main.go`(给 `NodeAPI` 注入 `routing.Store`)
**Interfaces:**
- Consumes: `routing.Store.Get`(Task 1)、`ClientConfigOpts.Profile`(Task 4)。
- [ ] **Step 1: 写测试(先失败)** `server/internal/httpapi/nodes_test.go`(或既有):存一份含用户规则的档案 → 打 connect → 断言返回配置的 route.rules 含该用户规则。若无现成 connect handler 测试脚手架,则在 clientconfig 层已覆盖(Task 4),此步可改为集成断言 `ConnectNode` 读到了 store(用伪 store 注入,验证 `Get` 被调用且结果进 opts)。
- [ ] **Step 2: 实现** `nodes.go` `ConnectNode`:`uid` 已在手(`:134`),加:
```go
var prof *routing.Profile
if a.routingStore != nil {
if p, err := a.routingStore.Get(r.Context(), uid); err == nil { prof = p } // err/nil → prof=nil 回退默认
}
splitCN := true
if prof != nil { splitCN = prof.Mode == "rule" && prof.Builtin.ChinaDirect } // 兼容旧 query?保留 query 兜底
cfgJSON, err := BuildClientConfig(node, dpUUID, a.deriveKey,
ClientConfigOpts{Profile: prof, SplitCN: splitCN, RulesBaseURL: a.rulesBaseURL, PrivateSplitDomains: a.privateSplitDomains})
```
`NodeAPI` 加字段 `routingStore *routing.Store`;`main.go` 构造 `NodeAPI` 处注入。保留 `?split_cn` query 作无档案时兜底。)
- [ ] **Step 3: 跑确认通过** `go test ./internal/httpapi/... -v`;`go build ./...`
- [ ] **Step 4: Commit** `git commit -am "feat(routing): connect 读 per-user 档案传入渲染(fail-safe 回退默认)"`
---
## Task 6: 客户端 model + API + provider
**Files:**
- Create: `client/lib/models/routing_profile.dart`, `client/test/unit/routing_profile_model_test.dart`
- Modify: `client/lib/services/account_api.dart`
- Create: `client/lib/state/routing_provider.dart`, `client/test/unit/routing_provider_test.dart`
**Interfaces:**
- Produces: `RoutingProfile`/`RoutingRule`(`fromJson`/`toJson`/`copyWith`);`AccountApi.routingProfile() → Future<RoutingProfile>``AccountApi.saveRoutingProfile(RoutingProfile) → Future<void>`;`routingProfileProvider`(`AsyncNotifierProvider<RoutingProfileNotifier, RoutingProfile>`)含 `save()`/`addRule`/`removeRule`/`reorder`/`setMode`
- [ ] **Step 1: model 测试(先失败)** `client/test/unit/routing_profile_model_test.dart`
```dart
test('RoutingProfile round-trips json', () {
const j = {
'mode': 'rule',
'builtin': {'china_direct': true, 'lan_direct': true, 'private_via_tunnel': true},
'rules': [{'type': 'domain_suffix', 'value': 'x.com', 'action': 'direct', 'note': 'ci', 'enabled': true}],
'final': 'proxy',
};
final p = RoutingProfile.fromJson(j);
expect(p.mode, 'rule');
expect(p.rules.single.value, 'x.com');
expect(p.toJson(), j); // 无损往返
});
```
- [ ] **Step 2: 跑确认失败** `cd client && flutter test test/unit/routing_profile_model_test.dart` → FAIL
- [ ] **Step 3: 写 model** `client/lib/models/routing_profile.dart`(手写 fromJson/toJson,仿 `lib/models/me.dart:50`;含 `RoutingRule``Builtin``copyWith`)。字段与服务端契约一致(mode/builtin/rules/final;rule = type/value/action/note/enabled)。
- [ ] **Step 4: 跑确认通过** `flutter test test/unit/routing_profile_model_test.dart` → PASS
- [ ] **Step 5: 加 API** `client/lib/services/account_api.dart`(仿 `:43` me()/`:68` renameDevice):
```dart
Future<RoutingProfile> routingProfile() async =>
RoutingProfile.fromJson(await _c.getJson('/v1/me/routing'));
Future<void> saveRoutingProfile(RoutingProfile p) async =>
_c.postJson('/v1/me/routing', p.toJson());
```
- [ ] **Step 6: 写 provider + 测试** `routing_provider.dart`(仿 `lib/state/account_providers.dart:86` `DevicesNotifier` 的 AsyncNotifier + invalidateSelf 范式);`routing_provider_test.dart``ProviderContainer(overrides: [accountApiProvider.overrideWithValue(_FakeApi())])` 断言 addRule→save 调用了 `saveRoutingProfile`,且乐观更新 state。
- [ ] **Step 7: 跑确认通过** `flutter test test/unit/routing_provider_test.dart` → PASS
- [ ] **Step 8: Commit** `git commit -m "feat(routing): 客户端 model + AccountApi + AsyncNotifier provider"`
---
## Task 7: 分流规则子屏 UI(模式段选 + 内置 + 我的规则增删排序 + 添加弹层 + 冲突提示)
**Files:**
- Create: `client/lib/widgets/routing_screen.dart`, `client/test/widget/routing_screen_test.dart`
- Modify: `client/lib/state/navigation_provider.dart`(`NavView.routing`)
**Interfaces:**
- Consumes: `routingProfileProvider`(Task 6)、`appTextProvider`(routing* 文案)、`SubScaffold``context.pangolin` 主题原子。
**UI 对照视觉真源** `design/prototype/screens/ui-mobile.html`(data-sub="routing"):代理模式段选(全局/智能/全部直连)→ `setMode`;内置规则卡(国内直连开关 + LAN 锁定行);我的规则列表(拖动排序 `ReorderableListView` + 每行动作 pill + 删除 + `enabled` 开关);「添加规则」弹层(类型 chip + 目标输入 + 动作段选,仿 `account_screens.dart:209 _renameDialog`);FINAL 兜底行。**冲突提示**:被前面规则遮蔽的行灰化 + 文案「已被上面规则覆盖」;命中系统锁定目标(私有服务域名)标「系统强制走隧道,此规则不生效」。
- [ ] **Step 1: widget 测试(先失败)** `client/test/widget/routing_screen_test.dart`(仿 `test/widget/settings_ia_test.dart:56``ProviderScope` + override `appTextProvider`/`routingProfileProvider`)
```dart
testWidgets('routing screen shows mode seg + rules + add', (t) async {
final profile = RoutingProfile.fromJson({... 1 domain_suffix direct ...});
await t.pumpWidget(ProviderScope(overrides: [
appTextProvider.overrideWithValue(StringsZh()),
routingProfileProvider.overrideWith(() => _FakeRoutingNotifier(profile)),
], child: const MaterialApp(home: RoutingScreen())));
await t.pumpAndSettle();
expect(find.text(StringsZh().routingModeSmart), findsWidgets); // 段选
expect(find.text('x.com'), findsOneWidget); // 规则行
expect(find.text(StringsZh().routingAddRule), findsOneWidget); // 添加按钮
});
```
- [ ] **Step 2: 跑确认失败** → FAIL(RoutingScreen 未定义)
- [ ] **Step 3: 实现 `RoutingScreen`**`ConsumerWidget`,`routingProfileProvider``.when(loading/error/data)`;各区块用 `_Card`/`_SectionLabel` 风格;`ReorderableListView` 排序回写 `provider.reorder`;添加弹层 `showDialog`;文案全走 `t.routing*`)。桌面/移动双形态:`SubScaffold(embedded: isDesktop, ...)`
- [ ] **Step 4: 跑确认通过** `flutter test test/widget/routing_screen_test.dart`;`flutter analyze`
- [ ] **Step 5: Commit** `git commit -m "feat(routing): 分流规则子屏 UI(模式/内置/我的规则/添加/冲突提示)"`
---
## Task 8: 设置入口行(smartRoute 开关 → 分流规则下钻行)+ 导航
**Files:**
- Modify: `client/lib/screens/settings_page.dart:66`
- Modify: `client/lib/state/navigation_provider.dart`(登记 routing 到桌面内容区/`kAccountSubViews` 或 shell)
**Interfaces:**
- Consumes: `RoutingScreen`(Task 7)、`routingProfileProvider`(取当前 mode 显 pill)、`account_page.dart:47 open()` 双形态导航。
- [ ] **Step 1: 测试(先失败)** `client/test/widget/settings_ia_test.dart`(追加):设置页「连接」组出现「分流规则」下钻行(标题 `t.routingRulesTitle` + 右侧当前模式 pill + chevron),点击导航到 `RoutingScreen`
- [ ] **Step 2: 跑确认失败**
- [ ] **Step 3: 实现**:`settings_page.dart:66``_Row(smartRoute switch)` 换成:
```dart
_Row(
title: t.routingRulesTitle,
right: Row(mainAxisSize: MainAxisSize.min, children: [
_ModePill(mode: ref.watch(routingProfileProvider).valueOrNull?.mode ?? 'rule'),
const Icon(Icons.chevron_right),
]),
onTap: () => open(NavView.routing, const RoutingScreen()), // open() 从 account_page 模式复用/提取
),
```
`_ModePill``t.routingModeGlobal|Smart|Direct`;`open()` 若仅在 account_page 私有,提取为共享 helper 或在 settings 局部实现同款双形态。)
- [ ] **Step 4: 跑确认通过** `flutter test test/widget/settings_ia_test.dart`;`flutter analyze`
- [ ] **Step 5: Commit** `git commit -m "feat(routing): 设置入口改分流规则下钻行 + 双形态导航"`
---
## Task 9: 存档案后触发重连(配置生效)
**Files:**
- Modify: `client/lib/state/routing_provider.dart`(save 成功后回调)或 `client/lib/state/connection_provider.dart`
**Interfaces:**
- Consumes: `connection_provider.dart:179 onNodeChanged`(disconnect→connect 序列)或等价重连入口。
- [ ] **Step 1: 测试(先失败)** `client/test/unit/routing_reconnect_test.dart`:当连接态为 `on` 时,`routingProfileProvider.save()` 成功后应触发一次重连(spy `ConnectionController` 的 disconnect→connect,或断言 `onNodeChanged` 被调)。断连态 `off` 时不触发。
- [ ] **Step 2: 跑确认失败**
- [ ] **Step 3: 实现**:`save()` 成功后,若 `ref.read(connectionProvider).phase == VpnPhase.on`,调 `ref.read(connectionProvider.notifier).onNodeChanged()`(或新增 `reapplyConfig()` 复刻其 disconnect→connect,不改选中节点)。给用户瞬态提示「规则已更新,正在重连…」(复用 l10n `nodeReconnecting` 若存在,否则走单源加键)。
- [ ] **Step 4: 跑确认通过** `flutter test test/unit/routing_reconnect_test.dart`
- [ ] **Step 5: Commit** `git commit -m "feat(routing): 存档案后连接态自动重连使新规则生效"`
---
## Task 10: 端到端联调 + 收尾
**Files:** 无新增;跑通全链路 + 文档登记。
- [ ] **Step 1: 服务端全量** `cd server && go test ./... && ./run_sqlite_test.sh`(含迁移 up/down)
- [ ] **Step 2: 客户端全量** `cd client && flutter analyze && flutter test`(含 golden 若受影响则 `--update-goldens` 重录随 commit)
- [ ] **Step 3: 漂移闸 + openapi** `bash ci/check-codegen-drift.sh`;`design/server/openapi.yaml` 本地 validator 过
- [ ] **Step 4: 真机冒烟(手动,记录结果)**:登录 → 设置进分流规则 → 加一条 `domain_suffix github.com → 走隧道`、一条 `ip_cidr <某IP>/32 → 直连` → 保存 → 连接 → 抓下发配置确认 route.rules 含用户规则且顺序正确;切「全部直连」验 final=direct、用户规则被忽略;私有服务域名加规则验提示「不生效」。
- [ ] **Step 5: 文档登记** 更新本计划 HTML 阅读版 + `docs/index.html`「实现计划」分类;`docs/configurable-proxy-spec.html` 状态改「Phase 1 已实现」。
- [ ] **Step 6: 收尾 commit** + 推送分支 + 开/更新 draft PR。
---
## 不在本轮(Phase 2/3)
- 从文本导入(粘贴 Shadowrocket/Clash 批量建)—— Phase 2(原型第三屏已画)。
- 内置规则模板一键加(国内直连包/广告拒绝包)—— Phase 2。
- 桌面端 UI 精修(Phase 1 双形态已可用,精修留 Phase 2)。
- 分应用代理(per-app)、规则订阅 URL —— Phase 3。
- 更细规则粒度(URL 正则/UA/进程名)—— 暂不做。