Files
pangolin/docs/configurable-proxy-plan.html
T
wangjia 7776fc4d6c 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
2026-07-27 12:06:21 +08:00

186 lines
17 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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>