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
40 KiB
可配置分流(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 ./...,clientflutter 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/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/。
- 确认 main 的 pay-v2 getter 是否已在本分支
- 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.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:
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:13的openDB/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 类型名、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为必填字段,客户端(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 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)
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
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));在受保护分组(:~471protected)加:
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/RoutingRulecomponents 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(mainclientconfig.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):ClientConfigOpts加Profile *routing.Profile。- 在 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。 - geoip-cn/geosite-cn 直连规则(⑤)条件改为
(opts.SplitCN || 无)… 保持:mode=="rule" && Builtin.ChinaDirect(取代旧opts.SplitCN;connect 端后续传SplitCN = profile.Builtin.ChinaDirect,Task 5)。 route["final"]:mode=="direct"→"direct";mode=="global"→"auto";mode=="rule"→ if Final=="direct" "direct" else "auto"。route_exclude_addressappendextraExclude(去重)。dns["reverse_mapping"]:privateSplit || hasDomainDirect时为 true。- 若用到 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.goConnectNode: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;含RoutingRule、Builtin、copyWith)。字段与服务端契约一致(mode/builtin/rules/final;rule = type/value/action/note/enabled)。RoutingRule构造默认enabled: true,toJson()恒输出enabled字段(服务端不修正缺省 enabled,契约靠客户端保证;缺失=禁用属 fail-safe)。fromJson中enabled: 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(仿:43me()/:68renameDevice):
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:86DevicesNotifier的 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+ overrideappTextProvider/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: 实现
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)换成:
_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()成功后应触发一次重连(spyConnectionController的 disconnect→connect,或断言onNodeChanged被调)。断连态off时不触发。 -
Step 2: 跑确认失败
-
Step 3: 实现:
save()成功后,若ref.read(connectionProvider).phase == VpnPhase.on,调ref.read(connectionProvider.notifier).onNodeChanged()(或新增reapplyConfig()复刻其 disconnect→connect,不改选中节点)。给用户瞬态提示「规则已更新,正在重连…」(复用 l10nnodeReconnecting若存在,否则走单源加键)。 -
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/进程名)—— 暂不做。