diff --git a/docs/configurable-proxy-plan.html b/docs/configurable-proxy-plan.html new file mode 100644 index 0000000..005d82b --- /dev/null +++ b/docs/configurable-proxy-plan.html @@ -0,0 +1,185 @@ + + + + + +Pangolin 可配置分流 · Phase 1 实现计划 + + + + +
+

可配置分流 · Phase 1 实现计划

+

让用户像 Shadowrocket 那样自定义路由规则(域名 / IP / GeoIP / GeoSite × 直连 / 走隧道 / 拒绝),存服务端 per-user 档案,连接时服务端翻译进渲染的 sing-box 配置。

+

执行真相源(含 - [ ] checkbox):docs/superpowers/plans/2026-07-27-configurable-proxy-phase1.md · 配套设计:configurable-proxy-spec.html · 视觉原型 design/prototype/screens/ui-mobile.html

+ +

Goal / Architecture

+
+

目标:一套用户可见、可配的有序规则表(首命中生效),动作三选一。

+

架构铁律:客户端做规则编辑 UI + 存/取档案,绝不拼 sing-box 配置。用户编规则 → POST /v1/me/routing 存 per-user 档案(DB)→ connect 时 BuildClientConfig 读档案翻译成 route.rules,插在系统强制层之后、国内分流之前。IP 直连并入 route_exclude_address、域名直连靠 reverse_mapping+local DNS 真生效(复用 main 已验证的手法)。

+

:服务端 Go(chi + 裸 SQL + internal/db 方言层 + golang-migrate 双方言);客户端 Flutter + Riverpod(StateNotifier/AsyncNotifier)+ SharedPreferences。

+
+ +

规则模型(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示例→ sing-box 字段
domainexample.comdomain(精确)
domain_suffixaliyun.comdomain_suffix(含子域)
domain_keywordgoogledomain_keyword
ip_cidr35.190.0.0/16ip_cidr(v4/v6)
geoip / geositecnrule_set(自托管 .srs;Phase 1 仅 cn)
+ + + + + +
action→ outbound说明
directdirect物理网卡直连
proxyauto经节点(REALITY/Hy2 urltest 择优)
rejectblock阻断
+ +

渲染层级(route.rules 自上而下,首命中)

+ +
三模式语义:rule(智能分流,默认)完整链;global(全局代理)跳过④⑤、final=auto、系统层仍在;direct(全部直连)跳过④⑤、final=direct、系统层仍在。用户规则排在国内分流之前——「我要 github 走隧道」能压过 geoip-cn 直连默认。系统层任何模式强制生效,用户不可覆盖。
+ +

Global Constraints(每个任务隐含)

+ + +

任务总览(Task 0 前置 + 10 任务 TDD)

+ + + + + + + + + + + + + +
#任务关键产物 / 验证
0合并 main(前置)拿到私有分流/reverse_mapping/route_exclude;解 7 冲突(3 非 l10n 并集 + 4 l10n 归一 strings.json 重生成);go/flutter/漂移闸全绿
1routing_profiles 表 + Profile 类型 + Store双方言 000028;Store.Get/Upsert;sqlite 实库测试
2档案校验 / 规范化Validate()[]FieldError(type/CIDR/geo白名单/上限200)+ Normalize()(去重/trim)
3GET/POST /v1/me/routingRoutingAPI;无档案返回 Default;非法 400+逐条;openapi 两份;httptest
4核心:BuildClientConfig 翻译用户规则层;IP直连→route_exclude;域名直连→reverse_mapping;三模式;nil-profile 逐字节不变
5connect 读档案 → 传入渲染uid 已在手;fail-safe 回退默认;保留 ?split_cn 兜底
6客户端 model + API + providerRoutingProfile(fromJson/toJson);AccountApi.routingProfile/saveRoutingProfile;AsyncNotifier
7分流规则子屏 UI模式段选 / 内置 / 我的规则增删排序 / 添加弹层 / 冲突提示;对照原型
8设置入口下钻行 + 导航smartRoute 开关 → 「分流规则」行(当前模式 pill + chevron);双形态
9存档案后自动重连连接态 on 时 save→onNodeChanged 重连使新规则生效
10端到端联调 + 收尾go/flutter 全量 + 真机冒烟 + 文档登记 + draft PR
+ +

File Structure

+
+

服务端(Go)

+ +

客户端(Flutter)

+ +
+ +

关键任务细节(核心代码)

+ +

Task 0 · 合并 main(前置)

+
当前分支落后 main 37 commit,spec 复用的 PrivateSplitDomains(系统层3)/reverse_mapping/route_exclude_address 都在 main 的 74d8c85、不在此分支。必须先合并。冲突 7 个:.gitignore/docs/index.html/scripts/local_test.sh(取并集)+ strings_{es,ja,ko,ru}.dart(main 手写 pay-v2 getter vs 我的 codegen 重排 → 把 main 新增并进 strings.json 单源后 gen_l10n_dart.mjs 重生成)。验证:go build/test + flutter analyze/test + 三段漂移闸全绿。
+ +

Task 1 · Profile 类型 + Store

+
type Profile struct {
+  Mode    string  // rule | global | direct
+  Builtin Builtin // ChinaDirect / LanDirect / PrivateViaTunnel
+  Rules   []Rule  // Type/Value/Action/Note/Enabled
+  Final   string  // proxy | direct
+}
+// Default() 是 fail-safe 兜底,等价当前行为(智能分流+国内直连+无用户规则)
+func (s *Store) Get(ctx, userID) (*Profile, error)   // 无档案 → nil,nil
+func (s *Store) Upsert(ctx, userID, p) error          // dialect.Upsert(["user_id"], ...)
+ +

Task 4 · BuildClientConfig 翻译(核心)

+

在 main 版 route.rules 顺序基础上,私有域名(层3)之后、geoip-cn(层5)之前插入用户规则;提取纯函数便于测试:

+
func translateUserRules(p *routing.Profile) (
+    rules []any, extraExclude []string, hasDomainDirect bool, geoSets []string)
+ + +

Task 7 · 分流规则子屏 UI

+

对照视觉真源 design/prototype/screens/ui-mobile.html(data-sub="routing"):代理模式段选 → setMode;内置规则卡(国内直连开关 + LAN 锁定行);我的规则 ReorderableListView(拖动排序 + 动作 pill + 删除 + enabled 开关);「添加规则」弹层(类型 chip + 目标输入 + 动作段选,仿 account_screens.dart:209 _renameDialog);FINAL 行。冲突提示:被前面规则遮蔽的行灰化 +「已被上面规则覆盖」;命中系统锁定目标标「系统强制走隧道,此规则不生效」。桌面/移动双形态复用 SubScaffold + account_page.dart:47 open()

+ +

不在本轮(Phase 2/3)

+ + +
执行方式(确认计划后二选一):A. Subagent 驱动(推荐)——每 Task 派新 subagent 实现 + 两段复核,任务间可叫停;B. 本会话内批量执行 + 检查点。
+
+ + diff --git a/docs/index.html b/docs/index.html index e766307..372cedf 100644 --- a/docs/index.html +++ b/docs/index.html @@ -101,6 +101,11 @@

实现计划 / Plans

+ +
可配置分流 Phase 1 实现计划(Task 3)HTML
+
阅读版;执行真相源 docs/superpowers/plans/2026-07-27-configurable-proxy-phase1.md(Task 0 前置 + 10 任务 TDD,含 checkbox)。Task 0 先合并 main(拿私有分流/reverse_mapping/route_exclude,落后 37 commit)。服务端(1-5):迁移 000028(routing_profiles per-user) → Profile 类型+Store(双方言) → 校验/规范化(type/CIDR/geo白名单/上限200/去重) → GET/POST /v1/me/routing+openapi → BuildClientConfig 翻译用户规则(层级插私有分流后·geoip前;IP直连并入 route_exclude、域名直连开 reverse_mapping;三模式;nil逐字节不变) → connect 读档案 fail-safe 回退。客户端(6-9):RoutingProfile model+AccountApi+AsyncNotifier → 分流规则子屏(模式段选/内置/我的规则增删排序/添加弹层/冲突提示) → 设置入口下钻行+双形态导航 → 存档案后自动重连。设计见 configurable-proxy-spec.html。
+
docs/configurable-proxy-plan.html · 真相源 docs/superpowers/plans/2026-07-27-configurable-proxy-phase1.md
+
系统通知机制 实现计划(Spec ③)HTML
阅读版;执行真相源 docs/superpowers/plans/2026-07-13-notifications.md(11 任务 TDD,含 checkbox)。后端(1-7):迁移 000026(notices 表+users.notices_read_at) → notices.Store(广播∪定向合并/已读水位/事务内插) → GET /v1/notices+POST /read(替换空壳)+openapi 扩展 → 四事件钩子同事务(邀请注册/首充/TG任务/购买开通,零孤儿) → notices.Service(发布/撤回/important 邮件兜底幂等) → nodectl notice add/list/revoke → main.go 装配+发版脚本联动插 version 公告。原型与客户端(8-10):原型先行统一两端通知视图(桌面式pill+未读点)+reward/version/promo 示例 → notices api/provider(未读计数/延迟首拉) → 通知页真实化+铃铛红点接 provider+三新类型六语标签。Subagent 驱动执行。设计见 notifications-design.html。
diff --git a/docs/superpowers/plans/2026-07-27-configurable-proxy-phase1.md b/docs/superpowers/plans/2026-07-27-configurable-proxy-phase1.md new file mode 100644 index 0000000..ec5ce60 --- /dev/null +++ b/docs/superpowers/plans/2026-07-27-configurable-proxy-phase1.md @@ -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` + 本地草稿 +- 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-"` / `geosite→rule_set "geosite-"`。 +- **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`、`AccountApi.saveRoutingProfile(RoutingProfile) → Future`;`routingProfileProvider`(`AsyncNotifierProvider`)含 `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() async => + RoutingProfile.fromJson(await _c.getJson('/v1/me/routing')); +Future 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/进程名)—— 暂不做。