From 9cf9f4f1f7cfbf43cd25254dbf5c6c92e90e51b8 Mon Sep 17 00:00:00 2001 From: sagitchu Date: Tue, 3 Mar 2026 10:27:29 +0800 Subject: [PATCH] feat: add comprehensive migration test for legacy columns - Add ExtraIPs, TCPListenAddr, UDPListenAddr to Node migration - Add ip_preference to Tunnel migration - Add test for very legacy database migration (1.x schema) - Include issue #211 tracking document Entire-Checkpoint: 0d086883c34a --- 211-custom-ip-selection.md | 33 +++++++ go-backend/internal/store/repo/repository.go | 2 +- .../tests/contract/migration_contract_test.go | 98 ++++++++++++++++++- 3 files changed, 129 insertions(+), 4 deletions(-) create mode 100644 211-custom-ip-selection.md diff --git a/211-custom-ip-selection.md b/211-custom-ip-selection.md new file mode 100644 index 0000000..1ae1714 --- /dev/null +++ b/211-custom-ip-selection.md @@ -0,0 +1,33 @@ +# Issue #211: 转发自定义监听IP / 隧道指定连接IP + +## 需求总结 +1. **节点**: 高级配置增加"额外IP地址"字段(逗号分隔) +2. **转发**: 创建/编辑时可指定入口监听IP +3. **隧道**: 配置出口节点时可指定连接IP + +--- + +## 任务清单 + +### 后端 +- [x] 1. 数据模型扩展 - Node/ForwardPort/ChainTunnel 增加字段 +- [x] 2. Repository - CreateNode/UpdateNode 处理 extraIPs +- [x] 3. Repository - resolveForwardIngress 使用 forward_port.in_ip +- [x] 4. Repository - GetNodeAllIPs 辅助函数(返回节点所有可用IP) +- [x] 5. Handler - 转发创建/更新处理 inIp 参数 +- [x] 6. Handler - 隧道出口节点处理 connectIp 参数 +- [x] 7. Handler - 节点API返回 extraIPs 字段 + +### 前端 +- [x] 8. 节点编辑页 - 高级配置增加"额外IP"输入 +- [x] 9. 转发编辑弹窗 - 增加"监听IP"下拉选择 +- [x] 10. 隧道配置页 - 出口节点增加"连接IP"输入 + +--- + +## 完成进度 +- 开始时间: 2026-03-02 +- 完成时间: 2026-03-02 +- 完成任务: 10/10 +- 后端完成: ✅ +- 前端完成: ✅ diff --git a/go-backend/internal/store/repo/repository.go b/go-backend/internal/store/repo/repository.go index 7727721..5feb851 100644 --- a/go-backend/internal/store/repo/repository.go +++ b/go-backend/internal/store/repo/repository.go @@ -260,7 +260,7 @@ func prepareSQLiteLegacyColumns(db *gorm.DB) error { m := db.Migrator() if m.HasTable(&model.Node{}) { - for _, field := range []string{"ServerIPV4", "ServerIPV6", "Inx", "IsRemote", "RemoteURL", "RemoteToken", "RemoteConfig"} { + for _, field := range []string{"ServerIPV4", "ServerIPV6", "ExtraIPs", "TCPListenAddr", "UDPListenAddr", "Inx", "IsRemote", "RemoteURL", "RemoteToken", "RemoteConfig"} { if m.HasColumn(&model.Node{}, field) { continue } diff --git a/go-backend/tests/contract/migration_contract_test.go b/go-backend/tests/contract/migration_contract_test.go index 6292e7a..83a4e9a 100644 --- a/go-backend/tests/contract/migration_contract_test.go +++ b/go-backend/tests/contract/migration_contract_test.go @@ -684,15 +684,107 @@ func TestOpenMigratesLegacyNodeDualStackColumns(t *testing.T) { columns := readTableColumns(t, r.DB(), "node") - for _, required := range []string{"server_ip_v4", "server_ip_v6", "inx"} { + for _, required := range []string{"server_ip_v4", "server_ip_v6", "inx", "extra_ips"} { if !columns[required] { t.Fatalf("expected node column %q to exist after migration", required) } } tunnelColumns := readTableColumns(t, r.DB(), "tunnel") - if !tunnelColumns["inx"] { - t.Fatalf("expected tunnel column %q to exist after migration", "inx") + for _, required := range []string{"inx", "ip_preference"} { + if !tunnelColumns[required] { + t.Fatalf("expected tunnel column %q to exist after migration", required) + } + } +} + +func TestOpenMigratesVeryLegacyNodeAndTunnelColumns(t *testing.T) { + dbPath := filepath.Join(t.TempDir(), "legacy-1.x.db") + legacyDB, err := sql.Open("sqlite", dbPath) + if err != nil { + t.Fatalf("open legacy sqlite: %v", err) + } + + t.Cleanup(func() { + _ = legacyDB.Close() + }) + + if _, err := legacyDB.Exec(` + CREATE TABLE IF NOT EXISTS node ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name VARCHAR(100) NOT NULL, + secret VARCHAR(100) NOT NULL, + server_ip VARCHAR(100) NOT NULL, + port TEXT NOT NULL, + interface_name VARCHAR(200), + version VARCHAR(100), + http INTEGER NOT NULL DEFAULT 0, + tls INTEGER NOT NULL DEFAULT 0, + socks INTEGER NOT NULL DEFAULT 0, + created_time INTEGER NOT NULL, + updated_time INTEGER, + status INTEGER NOT NULL + ) + `); err != nil { + t.Fatalf("create very legacy node table: %v", err) + } + + if _, err := legacyDB.Exec(` + CREATE TABLE IF NOT EXISTS tunnel ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name VARCHAR(100) NOT NULL, + traffic_ratio REAL NOT NULL DEFAULT 1.0, + type INTEGER NOT NULL, + protocol VARCHAR(10) NOT NULL DEFAULT 'tls', + flow INTEGER NOT NULL, + created_time INTEGER NOT NULL, + updated_time INTEGER NOT NULL, + status INTEGER NOT NULL, + in_ip TEXT + ) + `); err != nil { + t.Fatalf("create very legacy tunnel table: %v", err) + } + + now := time.Now().UnixMilli() + if _, err := legacyDB.Exec(` + INSERT INTO node(name, secret, server_ip, port, interface_name, version, http, tls, socks, created_time, updated_time, status) + VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `, "legacy-node", "legacy-secret", "10.10.0.1", "10000-10010", "eth0", "v-old", 1, 1, 1, now, now, 1); err != nil { + t.Fatalf("seed legacy node row: %v", err) + } + + r, err := repo.Open(dbPath) + if err != nil { + t.Fatalf("open migrated sqlite: %v", err) + } + t.Cleanup(func() { + _ = r.Close() + }) + + columns := readTableColumns(t, r.DB(), "node") + for _, required := range []string{ + "server_ip_v4", + "server_ip_v6", + "extra_ips", + "tcp_listen_addr", + "udp_listen_addr", + "inx", + "is_remote", + "remote_url", + "remote_token", + "remote_config", + } { + if !columns[required] { + t.Fatalf("expected node column %q to exist after migration", required) + } + } + + tunnelColumns := readTableColumns(t, r.DB(), "tunnel") + for _, required := range []string{"inx", "ip_preference"} { + if !tunnelColumns[required] { + t.Fatalf("expected tunnel column %q to exist after migration", required) + } } }