Files
pangolin/docs/superpowers/plans/2026-07-27-configurable-proxy-phase1.md
T
wangjia 3a2c48541c docs(routing): 计划澄清 enabled 缺省语义(必填/客户端恒发/服务端不默认)
Task 2 复核发现 plan 内部不一致(接口注释 vs 代码块)。裁决:enabled 必填,Task 6 model
恒输出 enabled=true、fromJson 容错默认 true;服务端不修正缺省(缺失=禁用属 fail-safe)。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A79VtQA1BwTuQN1ThpvYpo
2026-07-28 07:39:51 +08:00

40 KiB
Raw Blame History

可配置分流(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 共用契约)

{
  "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/geositevalue 必须在自托管规则集清单内(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 —— ClientConfigOptsProfile *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 —— NavViewrouting(桌面内容区)
  • Modify client/lib/state/connection_provider.dart —— 存档案后触发重连(复用 onNodeChanged 序列)
  • (文案已在 strings.json;若需新增冲突提示文案,走单源 codegen)

Task 0: 同步分支到 main(前置,合并 + l10n 单源归一)

Files:

  • Merge: mainfeat/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: 验证 —— 全绿才算完成:
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.Rulerouting.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:

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:

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
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:13openDB/seedU)
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 方言)
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 类型名、DialectForDBserver/internal/db/dialect.gousage/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 为必填字段,客户端(Task 6 model)恒传 true;服务端不对缺省 enabled 做修正(缺失即零值 false=禁用,属 fail-safe 可接受)。

  • Consumes: routing.Profile(Task 1)。

  • Step 1: 写校验测试(先失败) server/internal/routing/profile_test.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(追加)
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 netstrings。)

  • 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.UserIDFromContextapierr.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.gochi.NewRouter() + httptest,注入伪 auth context 塞 user_id)

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) }
}

doAuthReqcontext.WithValue(r.Context(), codes.CtxKeyUserID, int64(uid)) 注入,参照 auth/middleware.go:45openTestDB/seedUser 复用本包既有 helper。)

  • Step 2: 跑确认失败 go test ./internal/httpapi/ -run TestRoutingGet -v → FAIL
  • Step 3: 实现 handler server/internal/httpapi/routing.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)加:
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/routingget(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(ClientConfigOptsProfile;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 断言)

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. ClientConfigOptsProfile *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),加:

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

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;含 RoutingRuleBuiltincopyWith)。字段与服务端契约一致(mode/builtin/rules/final;rule = type/value/action/note/enabled)。RoutingRule 构造默认 enabled: true,toJson() 恒输出 enabled 字段(服务端不修正缺省 enabled,契约靠客户端保证;缺失=禁用属 fail-safe)。fromJsonenabled: m['enabled'] as bool? ?? true(读取容错默认 true)。
  • 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):
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.dartProviderContainer(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* 文案)、SubScaffoldcontext.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:56ProviderScope + override appTextProvider/routingProfileProvider)
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: 实现 RoutingScreenConsumerWidget,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) 换成:

_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 模式复用/提取
),

_ModePillt.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/进程名)—— 暂不做。