docs(routing): 可配置分流 Phase 1 实现计划(.md 执行版 + HTML 阅读版 + 登记索引)
Phase 1 计划:Task 0 合并 main(前置)+ 10 任务 TDD(服务端 routing_profiles 表/校验/ API/BuildClientConfig 翻译/connect 读档案;客户端 model+API+provider/规则子屏 UI/设置入口/ 自动重连)。执行真相源 docs/superpowers/plans/2026-07-27-configurable-proxy-phase1.md, 阅读版 docs/configurable-proxy-plan.html 已登记 docs/index.html「实现计划」。待用户确认后执行。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01A79VtQA1BwTuQN1ThpvYpo
This commit is contained in:
@@ -0,0 +1,678 @@
|
||||
# 可配置分流(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 缺省)。
|
||||
- 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)。
|
||||
- [ ] **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/进程名)—— 暂不做。
|
||||
Reference in New Issue
Block a user