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

679 lines
40 KiB
Markdown
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.
# 可配置分流(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 共用契约)
```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` ∈ {`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/`
- [ ] **Step 4: 提交合并** `git commit`(合并提交)
- [ ] **Step 5: 验证** —— 全绿才算完成:
```bash
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`:
```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`:
```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`
```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`)
```go
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` 方言)
```go
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`
```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`(追加)
```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)
```go
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`
```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`)加:
```go
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`/`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`(`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`(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` 断言)
```go
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. `ClientConfigOpts``Profile *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`),加:
```go
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`
```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`(仿 `:43` me()/`:68` renameDevice):
```dart
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.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` + override `appTextProvider`/`routingProfileProvider`)
```dart
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)` 换成:
```dart
_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()` 成功后应触发一次重连(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/进程名)—— 暂不做。