7776fc4d6c
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
186 lines
17 KiB
HTML
186 lines
17 KiB
HTML
<!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>
|