让用户像 Shadowrocket 那样自定义路由规则(域名 / IP / GeoIP / GeoSite × 直连 / 走隧道 / 拒绝),存服务端 per-user 档案,连接时服务端翻译进渲染的 sing-box 配置。
执行真相源(含 - [ ] checkbox):docs/superpowers/plans/2026-07-27-configurable-proxy-phase1.md · 配套设计:configurable-proxy-spec.html · 视觉原型 design/prototype/screens/ui-mobile.html
目标:一套用户可见、可配的有序规则表(首命中生效),动作三选一。
架构铁律:客户端只做规则编辑 UI + 存/取档案,绝不拼 sing-box 配置。用户编规则 → POST /v1/me/routing 存 per-user 档案(DB)→ connect 时 BuildClientConfig 读档案翻译成 route.rules,插在系统强制层之后、国内分流之前。IP 直连并入 route_exclude_address、域名直连靠 reverse_mapping+local DNS 真生效(复用 main 已验证的手法)。
栈:服务端 Go(chi + 裸 SQL + internal/db 方言层 + golang-migrate 双方言);客户端 Flutter + Riverpod(StateNotifier/AsyncNotifier)+ SharedPreferences。
// 档案 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 | 示例 | → sing-box 字段 |
|---|---|---|
domain | example.com | domain(精确) |
domain_suffix | aliyun.com | domain_suffix(含子域) |
domain_keyword | domain_keyword | |
ip_cidr | 35.190.0.0/16 | ip_cidr(v4/v6) |
geoip / geosite | cn | rule_set(自托管 .srs;Phase 1 仅 cn) |
| action | → outbound | 说明 |
|---|---|---|
| direct | direct | 物理网卡直连 |
| proxy | auto | 经节点(REALITY/Hy2 urltest 择优) |
| reject | block | 阻断 |
PANGOLIN_PRIVATE_SPLIT_DOMAINS)· 系统强制mode==rule 且 enabled)← 新增builtin.china_direct 开时)internal/db 方言层(dialect.Upsert / LockForUpdate);时间 Go 端算传 ?,禁 NOW()/UTC_TIMESTAMP()。迁移 mysql/sqlite 两套,编号 000028。design/i18n/strings.json + app_text.dart 抽象声明,跑 gen_l10n_dart.mjs 生成 6 份(勿手改);过 ci/check-codegen-drift.sh。routing* 文案键已就位。{code, message_zh, message_en} 三字段 required;写操作走 POST(项目无 PUT 先例)。go build/test ./...、client flutter analyze+test 全绿才提交。| # | 任务 | 关键产物 / 验证 |
|---|---|---|
| 0 | 合并 main(前置) | 拿到私有分流/reverse_mapping/route_exclude;解 7 冲突(3 非 l10n 并集 + 4 l10n 归一 strings.json 重生成);go/flutter/漂移闸全绿 |
| 1 | routing_profiles 表 + Profile 类型 + Store | 双方言 000028;Store.Get/Upsert;sqlite 实库测试 |
| 2 | 档案校验 / 规范化 | Validate()[]FieldError(type/CIDR/geo白名单/上限200)+ Normalize()(去重/trim) |
| 3 | GET/POST /v1/me/routing | RoutingAPI;无档案返回 Default;非法 400+逐条;openapi 两份;httptest |
| 4 | 核心:BuildClientConfig 翻译 | 用户规则层;IP直连→route_exclude;域名直连→reverse_mapping;三模式;nil-profile 逐字节不变 |
| 5 | connect 读档案 → 传入渲染 | uid 已在手;fail-safe 回退默认;保留 ?split_cn 兜底 |
| 6 | 客户端 model + API + provider | RoutingProfile(fromJson/toJson);AccountApi.routingProfile/saveRoutingProfile;AsyncNotifier |
| 7 | 分流规则子屏 UI | 模式段选 / 内置 / 我的规则增删排序 / 添加弹层 / 冲突提示;对照原型 |
| 8 | 设置入口下钻行 + 导航 | smartRoute 开关 → 「分流规则」行(当前模式 pill + chevron);双形态 |
| 9 | 存档案后自动重连 | 连接态 on 时 save→onNodeChanged 重连使新规则生效 |
| 10 | 端到端联调 + 收尾 | go/flutter 全量 + 真机冒烟 + 文档登记 + draft PR |
服务端(Go)
server/migrations/{mysql,sqlite}/000028_routing_profiles.{up,down}.sqlserver/internal/routing/{profile.go, profile_test.go, store.go, store_sqlite_test.go}server/internal/httpapi/{routing.go, routing_test.go}server/internal/httpapi/clientconfig.go(opts 加 Profile;route.rules 插用户规则层;route_exclude / reverse_mapping;三模式)server/internal/httpapi/nodes.go(connect 读档案)· server/cmd/server/main.go(构造 + 注册路由)design/server/openapi.yaml(CI 校验)· server/api/openapi.yaml客户端(Flutter)
client/lib/models/routing_profile.dart· client/lib/state/routing_provider.dart· client/lib/widgets/routing_screen.dart(+ 3 个测试)client/lib/services/account_api.dart· client/lib/screens/settings_page.dart:66· client/lib/state/navigation_provider.dart· client/lib/state/connection_provider.dartPrivateSplitDomains(系统层3)/reverse_mapping/route_exclude_address 都在 main 的 74d8c85、不在此分支。必须先合并。冲突 7 个:.gitignore/docs/index.html/scripts/local_test.sh(取并集)+ strings_{es,ja,ko,ru}.dart(main 手写 pay-v2 getter vs 我的 codegen 重排 → 把 main 新增并进 strings.json 单源后 gen_l10n_dart.mjs 重生成)。验证:go build/test + flutter analyze/test + 三段漂移闸全绿。type Profile struct { Mode string // rule | global | direct Builtin Builtin // ChinaDirect / LanDirect / PrivateViaTunnel Rules []Rule // Type/Value/Action/Note/Enabled Final string // proxy | direct } // Default() 是 fail-safe 兜底,等价当前行为(智能分流+国内直连+无用户规则) func (s *Store) Get(ctx, userID) (*Profile, error) // 无档案 → nil,nil func (s *Store) Upsert(ctx, userID, p) error // dialect.Upsert(["user_id"], ...)
在 main 版 route.rules 顺序基础上,私有域名(层3)之后、geoip-cn(层5)之前插入用户规则;提取纯函数便于测试:
func translateUserRules(p *routing.Profile) ( rules []any, extraExclude []string, hasDomainDirect bool, geoSets []string)
direct→"direct" / proxy→"auto" / reject→"block"ip_cidr && direct 的 value 追加进 route_exclude_address(现为 ["192.168/16","10/8"])direct 的域名类规则 → dns["reverse_mapping"]=trueroute["final"]:direct→"direct" / global→"auto" / rule→(Final=="direct"?"direct":"auto")对照视觉真源 design/prototype/screens/ui-mobile.html(data-sub="routing"):代理模式段选 → setMode;内置规则卡(国内直连开关 + LAN 锁定行);我的规则 ReorderableListView(拖动排序 + 动作 pill + 删除 + enabled 开关);「添加规则」弹层(类型 chip + 目标输入 + 动作段选,仿 account_screens.dart:209 _renameDialog);FINAL 行。冲突提示:被前面规则遮蔽的行灰化 +「已被上面规则覆盖」;命中系统锁定目标标「系统强制走隧道,此规则不生效」。桌面/移动双形态复用 SubScaffold + account_page.dart:47 open()。