From 577c1897375b41b94deace5d69b64b1a3b0343f1 Mon Sep 17 00:00:00 2001 From: sagit <36596628+Sagit-chu@users.noreply.github.com> Date: Tue, 10 Nov 2026 09:13:05 +0800 Subject: [PATCH] feat: show current owner names and persist user and limit order (#576) Resolve current forwarding-rule owner names by user ID. Add administrator-managed persistent user and speed-limit ordering, backup compatibility, and conflict recovery. Repair PostgreSQL explicit-ID import sequences transactionally. Closes #574 Closes #575 --- .../internal/http/handler/display_order.go | 45 + go-backend/internal/http/handler/handler.go | 2 + go-backend/internal/http/middleware/auth.go | 2 +- go-backend/internal/store/model/model.go | 36 +- go-backend/internal/store/repo/repository.go | 62 +- .../store/repo/repository_backup_postgres.go | 195 ++++ .../internal/store/repo/repository_control.go | 12 +- .../store/repo/repository_display_order.go | 109 ++ .../store/repo/repository_mutations.go | 4 +- .../contract/display_order_contract_test.go | 144 +++ .../contract/display_order_lifecycle_test.go | 367 +++++++ .../contract/forward_owner_names_test.go | 300 ++++++ .../postgres_backup_sequences_test.go | 554 ++++++++++ plans/074-user-names-and-custom-order.md | 98 ++ vite-frontend/src/api/index.ts | 10 + vite-frontend/src/components/server-order.tsx | 151 +++ vite-frontend/src/hooks/use-server-order.ts | 122 +++ vite-frontend/src/pages/limit.tsx | 402 +++---- vite-frontend/src/pages/user.tsx | 986 +++++++++--------- vite-frontend/src/utils/server-order.ts | 20 + 20 files changed, 2901 insertions(+), 720 deletions(-) create mode 100644 go-backend/internal/http/handler/display_order.go create mode 100644 go-backend/internal/store/repo/repository_backup_postgres.go create mode 100644 go-backend/internal/store/repo/repository_display_order.go create mode 100644 go-backend/tests/contract/display_order_contract_test.go create mode 100644 go-backend/tests/contract/display_order_lifecycle_test.go create mode 100644 go-backend/tests/contract/forward_owner_names_test.go create mode 100644 go-backend/tests/contract/postgres_backup_sequences_test.go create mode 100644 plans/074-user-names-and-custom-order.md create mode 100644 vite-frontend/src/components/server-order.tsx create mode 100644 vite-frontend/src/hooks/use-server-order.ts create mode 100644 vite-frontend/src/utils/server-order.ts diff --git a/go-backend/internal/http/handler/display_order.go b/go-backend/internal/http/handler/display_order.go new file mode 100644 index 0000000..2f76f85 --- /dev/null +++ b/go-backend/internal/http/handler/display_order.go @@ -0,0 +1,45 @@ +package handler + +import ( + "errors" + "go-backend/internal/http/response" + "go-backend/internal/store/repo" + "net/http" +) + +func (h *Handler) userDisplayOrder(w http.ResponseWriter, r *http.Request) { + updateDisplayOrder(w, r, h.repo.UpdateUserDisplayOrder) +} +func (h *Handler) speedLimitDisplayOrder(w http.ResponseWriter, r *http.Request) { + updateDisplayOrder(w, r, h.repo.UpdateSpeedLimitDisplayOrder) +} +func updateDisplayOrder(w http.ResponseWriter, r *http.Request, save func([]int64, []int64) error) { + if r.Method != http.MethodPost { + response.WriteJSON(w, response.Err(400, "请求方法错误")) + return + } + var req struct { + IDs []int64 `json:"ids"` + ExpectedIDs []int64 `json:"expectedIds"` + } + if err := decodeJSON(r.Body, &req); err != nil { + response.WriteJSON(w, response.Err(400, "请求参数错误")) + return + } + if err := save(req.IDs, req.ExpectedIDs); err != nil { + code := 500 + if errors.Is(err, repo.ErrInvalidDisplayOrder) { + code = 400 + } + if errors.Is(err, repo.ErrDisplayOrderConflict) { + code = 409 + } + msg := "排序保存失败,请刷新后重试" + if code != 500 { + msg = err.Error() + } + response.WriteJSON(w, response.Err(code, msg)) + return + } + response.WriteJSON(w, response.OKEmpty()) +} diff --git a/go-backend/internal/http/handler/handler.go b/go-backend/internal/http/handler/handler.go index 58b52df..a5a814a 100644 --- a/go-backend/internal/http/handler/handler.go +++ b/go-backend/internal/http/handler/handler.go @@ -248,6 +248,8 @@ func (h *Handler) Register(mux *http.ServeMux) { mux.HandleFunc("/api/v1/forward/batch-resume", h.forwardBatchResume) mux.HandleFunc("/api/v1/forward/batch-redeploy", h.forwardBatchRedeploy) mux.HandleFunc("/api/v1/forward/batch-change-tunnel", h.forwardBatchChangeTunnel) + mux.HandleFunc("/api/v1/user/update-order", h.userDisplayOrder) + mux.HandleFunc("/api/v1/speed-limit/update-order", h.speedLimitDisplayOrder) mux.HandleFunc("/api/v1/speed-limit/list", h.speedLimitList) mux.HandleFunc("/api/v1/speed-limit/create", h.speedLimitCreate) mux.HandleFunc("/api/v1/speed-limit/update", h.speedLimitUpdate) diff --git a/go-backend/internal/http/middleware/auth.go b/go-backend/internal/http/middleware/auth.go index 36e411c..f4e8606 100644 --- a/go-backend/internal/http/middleware/auth.go +++ b/go-backend/internal/http/middleware/auth.go @@ -180,7 +180,7 @@ func requiresAdmin(path string) bool { } switch path { - case "/api/v1/user/create", "/api/v1/user/list", "/api/v1/user/update", "/api/v1/user/delete", "/api/v1/user/reset": + case "/api/v1/user/update-order", "/api/v1/user/create", "/api/v1/user/list", "/api/v1/user/update", "/api/v1/user/delete", "/api/v1/user/reset": return true case "/api/v1/config/update", "/api/v1/config/update-single": return true diff --git a/go-backend/internal/store/model/model.go b/go-backend/internal/store/model/model.go index f77dec3..56f7149 100644 --- a/go-backend/internal/store/model/model.go +++ b/go-backend/internal/store/model/model.go @@ -10,6 +10,7 @@ import "database/sql" // User maps to the "user" table. PostgreSQL treats "user" as a reserved // word, so TableName() is required for correct quoting. type User struct { + DisplayOrder int64 `gorm:"not null;default:0"` ID int64 `gorm:"primaryKey;autoIncrement"` User string `gorm:"column:user;type:varchar(100);not null"` Pwd string `gorm:"type:varchar(100);not null"` @@ -151,14 +152,15 @@ type NftCounterState struct { func (NftCounterState) TableName() string { return "nft_counter_state" } type SpeedLimit struct { - ID int64 `gorm:"primaryKey;autoIncrement"` - Name string `gorm:"type:varchar(100);not null"` - Speed int `gorm:"not null"` - TunnelID sql.NullInt64 `gorm:"column:tunnel_id"` - TunnelName sql.NullString `gorm:"column:tunnel_name;type:varchar(100)"` - CreatedTime int64 `gorm:"column:created_time;not null"` - UpdatedTime sql.NullInt64 `gorm:"column:updated_time"` - Status int `gorm:"not null"` + DisplayOrder int64 `gorm:"not null;default:0"` + ID int64 `gorm:"primaryKey;autoIncrement"` + Name string `gorm:"type:varchar(100);not null"` + Speed int `gorm:"not null"` + TunnelID sql.NullInt64 `gorm:"column:tunnel_id"` + TunnelName sql.NullString `gorm:"column:tunnel_name;type:varchar(100)"` + CreatedTime int64 `gorm:"column:created_time;not null"` + UpdatedTime sql.NullInt64 `gorm:"column:updated_time"` + Status int `gorm:"not null"` } func (SpeedLimit) TableName() string { return "speed_limit" } @@ -417,6 +419,7 @@ type BackupData struct { } type UserBackup struct { + DisplayOrder *int64 `json:"displayOrder,omitempty"` ID int64 `json:"id"` User string `json:"user"` Pwd string `json:"pwd"` @@ -540,14 +543,15 @@ type UserTunnelBackup struct { } type SpeedLimitBackup struct { - ID int64 `json:"id"` - Name string `json:"name"` - Speed int64 `json:"speed"` - TunnelID *int64 `json:"tunnelId,omitempty"` - TunnelName string `json:"tunnelName,omitempty"` - CreatedTime int64 `json:"createdTime"` - UpdatedTime int64 `json:"updatedTime,omitempty"` - Status int `json:"status"` + DisplayOrder *int64 `json:"displayOrder,omitempty"` + ID int64 `json:"id"` + Name string `json:"name"` + Speed int64 `json:"speed"` + TunnelID *int64 `json:"tunnelId,omitempty"` + TunnelName string `json:"tunnelName,omitempty"` + CreatedTime int64 `json:"createdTime"` + UpdatedTime int64 `json:"updatedTime,omitempty"` + Status int `json:"status"` } type TunnelGroupBackup struct { diff --git a/go-backend/internal/store/repo/repository.go b/go-backend/internal/store/repo/repository.go index fd37b57..00381a0 100644 --- a/go-backend/internal/store/repo/repository.go +++ b/go-backend/internal/store/repo/repository.go @@ -914,7 +914,7 @@ func (r *Repository) ListUsers() ([]map[string]interface{}, error) { return nil, errors.New("repository not initialized") } var users []model.User - if err := r.db.Where("role_id != ?", 0).Order("id DESC").Find(&users).Error; err != nil { + if err := r.db.Where("role_id != ?", 0).Order("display_order ASC, id DESC").Find(&users).Error; err != nil { return nil, err } userIDs := make([]int64, 0, len(users)) @@ -954,7 +954,7 @@ func (r *Repository) ListSpeedLimits() ([]map[string]interface{}, error) { return nil, errors.New("repository not initialized") } var limits []model.SpeedLimit - if err := r.db.Order("id DESC").Find(&limits).Error; err != nil { + if err := r.db.Order("display_order ASC, id DESC").Find(&limits).Error; err != nil { return nil, err } items := make([]map[string]interface{}, 0, len(limits)) @@ -1001,7 +1001,8 @@ func (r *Repository) ListForwards() ([]map[string]interface{}, error) { var rows []fwdRow err := r.db.Model(&model.Forward{}). - Select("forward.id, forward.user_id, forward.user_name, forward.name, forward.tunnel_id, COALESCE(tunnel.name, '') AS tunnel_name, COALESCE(tunnel.traffic_ratio, 1.0) AS traffic_ratio, forward.remote_addr, COALESCE(forward.strategy, 'fifo') AS strategy, forward.in_flow, forward.out_flow, forward.created_time, forward.status, forward.inx, forward.speed_id, forward.max_conn, forward.ip_max_conn, forward.ip_speed_id, COALESCE(ip_speed_limit.name, '') AS ip_speed_limit_name, forward.proxy_protocol, forward.proxy_protocol_receive, forward.proxy_protocol_send"). + Select("forward.id, forward.user_id, COALESCE(owner.\"user\", forward.user_name) AS user_name, forward.name, forward.tunnel_id, COALESCE(tunnel.name, '') AS tunnel_name, COALESCE(tunnel.traffic_ratio, 1.0) AS traffic_ratio, forward.remote_addr, COALESCE(forward.strategy, 'fifo') AS strategy, forward.in_flow, forward.out_flow, forward.created_time, forward.status, forward.inx, forward.speed_id, forward.max_conn, forward.ip_max_conn, forward.ip_speed_id, COALESCE(ip_speed_limit.name, '') AS ip_speed_limit_name, forward.proxy_protocol, forward.proxy_protocol_receive, forward.proxy_protocol_send"). + Joins(`LEFT JOIN "user" AS owner ON owner.id = forward.user_id`). Joins("LEFT JOIN tunnel ON tunnel.id = forward.tunnel_id"). Joins("LEFT JOIN speed_limit AS ip_speed_limit ON ip_speed_limit.id = forward.ip_speed_id"). Order("forward.inx ASC, forward.id ASC"). @@ -2092,7 +2093,8 @@ func (r *Repository) exportUsers() ([]model.UserBackup, error) { out := make([]model.UserBackup, 0, len(users)) for _, u := range users { b := model.UserBackup{ - ID: u.ID, User: u.User, Pwd: u.Pwd, RoleID: u.RoleID, + DisplayOrder: &u.DisplayOrder, + ID: u.ID, User: u.User, Pwd: u.Pwd, RoleID: u.RoleID, ExpTime: u.ExpTime, Flow: u.Flow, FlowMiB: u.FlowMiB, InFlow: u.InFlow, OutFlow: u.OutFlow, FlowResetTime: u.FlowResetTime, Num: u.Num, CreatedTime: u.CreatedTime, Status: u.Status, @@ -2289,7 +2291,8 @@ func (r *Repository) exportSpeedLimits() ([]model.SpeedLimitBackup, error) { out := make([]model.SpeedLimitBackup, 0, len(sls)) for _, sl := range sls { b := model.SpeedLimitBackup{ - ID: sl.ID, Name: sl.Name, Speed: int64(sl.Speed), + DisplayOrder: &sl.DisplayOrder, + ID: sl.ID, Name: sl.Name, Speed: int64(sl.Speed), CreatedTime: sl.CreatedTime, Status: sl.Status, } if sl.UpdatedTime.Valid { @@ -2372,7 +2375,24 @@ func (r *Repository) Import(backup *model.BackupData, types []string) (*model.Im typeSet[t] = true } - err := r.db.Transaction(func(tx *gorm.DB) error { + err := backupImportTransaction(r.db, func(tx *gorm.DB) error { + result = &model.ImportResult{} + tables, err := lockPostgresBackupTables(tx, backup, typeSet) + if err != nil { + return err + } + // SQLite still needs its writer reservation for display order. + // PostgreSQL membership locks were taken with all imported tables above. + if tx.Dialector.Name() != "postgres" && typeSet["users"] { + if err := lockDisplayOrder(tx, "user"); err != nil { + return err + } + } + if tx.Dialector.Name() != "postgres" && typeSet["speedLimits"] { + if err := lockDisplayOrder(tx, "speed_limit"); err != nil { + return err + } + } now := unixMilliNow() if typeSet["users"] && len(backup.Users) > 0 { @@ -2445,7 +2465,7 @@ func (r *Repository) Import(backup *model.BackupData, types []string) (*model.Im } result.ConfigsImported = count } - return nil + return advancePostgresBackupSequences(tx, tables) }) if err != nil { return nil, err @@ -2477,12 +2497,17 @@ func importUsers(tx *gorm.DB, users []model.UserBackup, now int64) (int, error) Status: status, PasswordChangedAt: now, } + // An omitted old-backup rank preserves existing order on conflict. + // New legacy records use rank zero and ID descending, matching upgrades. + columns := []string{"user", "pwd", "role_id", "exp_time", "flow", "flow_mib", "in_flow", "out_flow", + "flow_reset_time", "num", "updated_time", "status", "password_changed_at"} + if u.DisplayOrder != nil { + item.DisplayOrder = *u.DisplayOrder + columns = append(columns, "display_order") + } err = tx.Clauses(clause.OnConflict{ - Columns: []clause.Column{{Name: "id"}}, - DoUpdates: clause.AssignmentColumns([]string{ - "user", "pwd", "role_id", "exp_time", "flow", "flow_mib", "in_flow", "out_flow", - "flow_reset_time", "num", "updated_time", "status", "password_changed_at", - }), + Columns: []clause.Column{{Name: "id"}}, + DoUpdates: clause.AssignmentColumns(columns), }).Create(&item).Error if err != nil { return count, err @@ -2765,11 +2790,16 @@ func importSpeedLimits(tx *gorm.DB, speedLimits []model.SpeedLimitBackup, now in UpdatedTime: sql.NullInt64{Int64: now, Valid: true}, Status: sl.Status, } + // An omitted old-backup rank preserves existing order on conflict. + // New legacy records use rank zero and ID descending, matching upgrades. + columns := []string{"name", "speed", "tunnel_id", "tunnel_name", "updated_time", "status"} + if sl.DisplayOrder != nil { + item.DisplayOrder = *sl.DisplayOrder + columns = append(columns, "display_order") + } err := tx.Clauses(clause.OnConflict{ - Columns: []clause.Column{{Name: "id"}}, - DoUpdates: clause.AssignmentColumns([]string{ - "name", "speed", "tunnel_id", "tunnel_name", "updated_time", "status", - }), + Columns: []clause.Column{{Name: "id"}}, + DoUpdates: clause.AssignmentColumns(columns), }).Create(&item).Error if err != nil { return count, err diff --git a/go-backend/internal/store/repo/repository_backup_postgres.go b/go-backend/internal/store/repo/repository_backup_postgres.go new file mode 100644 index 0000000..238437a --- /dev/null +++ b/go-backend/internal/store/repo/repository_backup_postgres.go @@ -0,0 +1,195 @@ +package repo + +import ( + "errors" + "fmt" + "time" + + "go-backend/internal/store/model" + + "gorm.io/gorm" +) + +var errBackupImportBusy = errors.New("database is busy; retry the backup import") + +// Only retry lock acquisition failures, which occur before any backup writes. +// NOWAIT releases partial lock sets instead of deadlocking with existing +// child-before-parent deletes or forward-before-user traffic updates. +func backupImportTransaction(db *gorm.DB, importRows func(*gorm.DB) error) error { + var err error + for attempt := 0; attempt < 10; attempt++ { + err = db.Transaction(importRows) + if !errors.Is(err, errBackupImportBusy) { + return err + } + if attempt < 9 { + time.Sleep(min(10*time.Millisecond<= sequence.Maximum || (state.IsCalled && state.LastValue > sequence.Maximum-sequence.Increment) { + return fmt.Errorf("ID sequence exhausted") + } + next := state.LastValue + if state.IsCalled { + next += sequence.Increment + } + if next <= maxID { + steps := (maxID-next)/sequence.Increment + 1 + if steps > (sequence.Maximum-next)/sequence.Increment { + return fmt.Errorf("ID sequence exhausted") + } + next += steps * sequence.Increment + } + return tx.Exec(fmt.Sprintf("ALTER SEQUENCE %s RESTART WITH %d", name, next)).Error +} diff --git a/go-backend/internal/store/repo/repository_control.go b/go-backend/internal/store/repo/repository_control.go index 307b298..4d7d6de 100644 --- a/go-backend/internal/store/repo/repository_control.go +++ b/go-backend/internal/store/repo/repository_control.go @@ -37,8 +37,14 @@ func (r *Repository) ListForwardsByTunnelTx(tx *gorm.DB, tunnelID int64) ([]mode if tx == nil { return nil, errors.New("database unavailable") } - var forwards []model.Forward - err := tx.Where("tunnel_id = ?", tunnelID).Order("id ASC").Find(&forwards).Error + var forwards []struct { + model.Forward + CurrentUserName string + } + err := tx.Model(&model.Forward{}). + Select(`forward.*, COALESCE(owner."user", forward.user_name) AS current_user_name`). + Joins(`LEFT JOIN "user" AS owner ON owner.id = forward.user_id`). + Where("forward.tunnel_id = ?", tunnelID).Order("forward.id ASC").Find(&forwards).Error if err != nil { return nil, err } @@ -48,7 +54,7 @@ func (r *Repository) ListForwardsByTunnelTx(tx *gorm.DB, tunnelID int64) ([]mode rows = append(rows, model.ForwardRecord{ ID: f.ID, UserID: f.UserID, - UserName: f.UserName, + UserName: f.CurrentUserName, Name: f.Name, TunnelID: f.TunnelID, RemoteAddr: f.RemoteAddr, diff --git a/go-backend/internal/store/repo/repository_display_order.go b/go-backend/internal/store/repo/repository_display_order.go new file mode 100644 index 0000000..29eeb4f --- /dev/null +++ b/go-backend/internal/store/repo/repository_display_order.go @@ -0,0 +1,109 @@ +package repo + +import ( + "errors" + "gorm.io/gorm" + "slices" +) + +var ErrDisplayOrderConflict = errors.New("列表已变更,请刷新后重试") +var ErrInvalidDisplayOrder = errors.New("排序必须包含全部条目且不能重复") + +// Lock the entire membership, including an empty list and concurrent INSERTs. +// PostgreSQL row locks alone cannot protect the missing-row/phantom case. +// SQLite's first write acquires its writer reservation before reading the list. +func lockDisplayOrder(tx *gorm.DB, table string) error { + if tx.Dialector.Name() == "postgres" { + return tx.Exec("LOCK TABLE \"" + table + "\" IN SHARE ROW EXCLUSIVE MODE").Error + } + return tx.Table(table).Where("1 = 0").UpdateColumn("display_order", gorm.Expr("display_order")).Error +} + +func (r *Repository) UpdateUserDisplayOrder(ids, expected []int64) error { + return r.updateDisplayOrder("user", ids, expected) +} +func (r *Repository) UpdateSpeedLimitDisplayOrder(ids, expected []int64) error { + return r.updateDisplayOrder("speed_limit", ids, expected) +} +func (r *Repository) updateDisplayOrder(table string, ids, expected []int64) error { + if ids == nil || expected == nil { + return ErrInvalidDisplayOrder + } + seen := map[int64]bool{} + for _, id := range ids { + if id <= 0 || seen[id] { + return ErrInvalidDisplayOrder + } + seen[id] = true + } + seenExpected := map[int64]bool{} + for _, id := range expected { + if id <= 0 || seenExpected[id] { + return ErrInvalidDisplayOrder + } + seenExpected[id] = true + } + if len(ids) != len(expected) { + return ErrInvalidDisplayOrder + } + for id := range seen { + if !seenExpected[id] { + return ErrInvalidDisplayOrder + } + } + return r.db.Transaction(func(tx *gorm.DB) error { + if err := lockDisplayOrder(tx, table); err != nil { + return err + } + q := tx.Table(table) + if table == "user" { + var admins int64 + if err := tx.Table(table).Where("role_id = ? AND id IN ?", 0, ids).Count(&admins).Error; err != nil { + return err + } + if admins != 0 { + return ErrInvalidDisplayOrder + } + q = q.Where("role_id != ?", 0) + } + var current []int64 + if err := q.Order("display_order ASC, id DESC").Pluck("id", ¤t).Error; err != nil { + return err + } + if !slices.Equal(current, expected) { + return ErrDisplayOrderConflict + } + for i, id := range ids { + result := tx.Table(table).Where("id = ?", id).UpdateColumn("display_order", int64(i)) + if result.Error != nil { + return result.Error + } + if result.RowsAffected != 1 { + return ErrDisplayOrderConflict + } + } + return nil + }) +} + +// New items precede all saved ranks. Zero is only the legacy migration default. +func createFirst(tx *gorm.DB, table string, item interface{}, rank *int64) error { + return tx.Transaction(func(tx *gorm.DB) error { + if err := lockDisplayOrder(tx, table); err != nil { + return err + } + q := tx.Table(table) + if table == "user" { + q = q.Where("role_id != ?", 0) + } + var first int64 + if err := q.Select("COALESCE(MIN(display_order), 0)").Scan(&first).Error; err != nil { + return err + } + if first == -1<<63 { + return errors.New("display order exhausted") + } + *rank = first - 1 + return tx.Create(item).Error + }) +} diff --git a/go-backend/internal/store/repo/repository_mutations.go b/go-backend/internal/store/repo/repository_mutations.go index 63e0506..5618a6f 100644 --- a/go-backend/internal/store/repo/repository_mutations.go +++ b/go-backend/internal/store/repo/repository_mutations.go @@ -65,7 +65,7 @@ func (r *Repository) CreateUser(username, pwdHash string, roleID int, expTime, f Status: status, PasswordChangedAt: now, } - if err := r.db.Create(&user).Error; err != nil { + if err := createFirst(r.db, "user", &user, &user.DisplayOrder); err != nil { return 0, err } return user.ID, nil @@ -940,7 +940,7 @@ func (r *Repository) CreateSpeedLimit(name string, speed int, now int64, status UpdatedTime: sql.NullInt64{Int64: now, Valid: true}, Status: status, } - if err := r.db.Create(&sl).Error; err != nil { + if err := createFirst(r.db, "speed_limit", &sl, &sl.DisplayOrder); err != nil { return 0, err } return sl.ID, nil diff --git a/go-backend/tests/contract/display_order_contract_test.go b/go-backend/tests/contract/display_order_contract_test.go new file mode 100644 index 0000000..aaa2d50 --- /dev/null +++ b/go-backend/tests/contract/display_order_contract_test.go @@ -0,0 +1,144 @@ +package contract_test + +import ( + "bytes" + "encoding/json" + "fmt" + "go-backend/internal/auth" + "go-backend/internal/http/response" + "net/http" + "net/http/httptest" + "reflect" + "testing" +) + +func orderRequest(t *testing.T, router http.Handler, token, resource, body string) response.R { + t.Helper() + req := httptest.NewRequest(http.MethodPost, "/api/v1/"+resource+"/update-order", bytes.NewBufferString(body)) + req.Header.Set("Authorization", token) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + var out response.R + if err := json.Unmarshal(w.Body.Bytes(), &out); err != nil { + t.Fatalf("decode: %v (%s)", err, w.Body.String()) + } + return out +} +func TestDisplayOrderUserContract(t *testing.T) { + router, r := setupContractRouter(t, "order-secret") + token := mustAdminToken(t, "order-secret") + a, err := r.CreateUser("order-a", "hash", 1, 0, 10, 1, 2, 1, 3, 0) + if err != nil { + t.Fatal(err) + } + b, err := r.CreateUser("order-b", "hash", 1, 0, 20, 1, 2, 1, 3, 0) + if err != nil { + t.Fatal(err) + } + out := orderRequest(t, router, token, "user", fmt.Sprintf(`{"ids":[%d,%d],"expectedIds":[%d,%d]}`, a, b, b, a)) + if out.Code != 0 { + t.Fatalf("reorder: %+v", out) + } + users, err := r.ListUsers() + if err != nil { + t.Fatal(err) + } + if got := []int64{users[0]["id"].(int64), users[1]["id"].(int64)}; !reflect.DeepEqual(got, []int64{a, b}) { + t.Fatalf("order %v", got) + } +} + +func TestDisplayOrderValidationAndLifecycle(t *testing.T) { + for _, resource := range []string{"user", "speed-limit"} { + t.Run(resource, func(t *testing.T) { + router, r := setupContractRouter(t, "order-secret") + token := mustAdminToken(t, "order-secret") + create := func(name string) int64 { + t.Helper() + var id int64 + var err error + if resource == "user" { + id, err = r.CreateUser(name, "hash", 1, 0, 10, 1, 2, 1, 3, 0) + } else { + id, err = r.CreateSpeedLimit(name, 100, 0, 1) + } + if err != nil { + t.Fatal(err) + } + return id + } + list := func() []int64 { + t.Helper() + out := postBatchRequest(t, router, token, "/api/v1/"+resource+"/list", "{}") + rows, ok := out.Data.([]interface{}) + if !ok { + t.Fatalf("list data: %T", out.Data) + } + ids := []int64{} + for _, row := range rows { + ids = append(ids, int64(row.(map[string]interface{})["id"].(float64))) + } + return ids + } + a, b := create("a"), create("b") + body := func(ids, expected []int64) string { + v, _ := json.Marshal(map[string]interface{}{"ids": ids, "expectedIds": expected}) + return string(v) + } + check := func(payload string, code int) { + t.Helper() + out := orderRequest(t, router, token, resource, payload) + if out.Code != code { + t.Fatalf("want %d got %+v", code, out) + } + } + check(body([]int64{a, b}, []int64{b, a}), 0) + for _, payload := range []string{`{}`, `{"ids":null,"expectedIds":[]}`, `{"ids":[1.1],"expectedIds":[1]}`, body([]int64{a, a}, []int64{a, b}), body([]int64{a}, []int64{a, b}), body([]int64{a, b, 99999}, []int64{a, b}), body([]int64{a, 99999}, []int64{a, b}), body([]int64{a, b}, []int64{a, a})} { + check(payload, 400) + } + if resource == "user" { + check(body([]int64{1, a, b}, []int64{a, b}), 400) + check(body([]int64{1, a, b}, []int64{1, a, b}), 400) + } + check(body([]int64{b, a}, []int64{b, a}), 409) + c := create("c") + if got := list(); !reflect.DeepEqual(got, []int64{c, a, b}) { + t.Fatalf("new item first: %v", got) + } + check(body([]int64{b, a}, []int64{a, b}), 409) + if resource == "user" { + if err := r.UpdateUserWithoutPassword(a, "edited", 10, 2, 0, 1, 1, 3, 0); err != nil { + t.Fatal(err) + } + } else { + if err := r.UpdateSpeedLimit(a, "edited", 200, 1, 0); err != nil { + t.Fatal(err) + } + } + if got := list(); !reflect.DeepEqual(got, []int64{c, a, b}) { + t.Fatalf("edit changed order: %v", got) + } + if got := orderRequest(t, router, "", resource, body([]int64{b, a, c}, []int64{c, a, b})); got.Code != 401 { + t.Fatalf("missing auth: %+v", got) + } + normal, err := auth.GenerateToken(a, "edited", 1, "order-secret") + if err != nil { + t.Fatal(err) + } + // Speed-limit fixture needs a real ordinary account for auth lookup. + if resource == "speed-limit" { + uid, err := r.CreateUser("normal", "hash", 1, 0, 10, 1, 2, 1, 3, 0) + if err != nil { + t.Fatal(err) + } + normal, err = auth.GenerateToken(uid, "normal", 1, "order-secret") + if err != nil { + t.Fatal(err) + } + } + if got := orderRequest(t, router, normal, resource, body([]int64{b, a, c}, []int64{c, a, b})); got.Code != 403 { + t.Fatalf("non-admin auth: %+v", got) + } + }) + } +} diff --git a/go-backend/tests/contract/display_order_lifecycle_test.go b/go-backend/tests/contract/display_order_lifecycle_test.go new file mode 100644 index 0000000..b83b9c1 --- /dev/null +++ b/go-backend/tests/contract/display_order_lifecycle_test.go @@ -0,0 +1,367 @@ +package contract_test + +import ( + "database/sql" + "errors" + "fmt" + "go-backend/internal/security" + "go-backend/internal/store/model" + "go-backend/internal/store/repo" + "os" + "path/filepath" + "reflect" + "strconv" + "testing" + "time" +) + +func TestDisplayOrderBackupAndRestart(t *testing.T) { + path := filepath.Join(t.TempDir(), "order.db") + r, err := repo.Open(path) + if err != nil { + t.Fatal(err) + } + a, err := r.CreateSpeedLimit("a", 100, 0, 1) + if err != nil { + t.Fatal(err) + } + b, err := r.CreateSpeedLimit("b", 200, 0, 1) + if err != nil { + t.Fatal(err) + } + if err := r.UpdateSpeedLimitDisplayOrder([]int64{a, b}, []int64{b, a}); err != nil { + t.Fatal(err) + } + check := func(r *repo.Repository) { + t.Helper() + rows, err := r.ListSpeedLimits() + if err != nil { + t.Fatal(err) + } + ids := []int64{} + for _, row := range rows { + ids = append(ids, row["id"].(int64)) + } + if !reflect.DeepEqual(ids, []int64{a, b}) { + t.Fatalf("order: %v", ids) + } + } + if err := r.Close(); err != nil { + t.Fatal(err) + } + r, err = repo.Open(path) + if err != nil { + t.Fatal(err) + } + defer r.Close() + check(r) + backup, err := r.ExportPartial([]string{"speedLimits"}) + if err != nil { + t.Fatal(err) + } + dest, err := repo.Open(filepath.Join(t.TempDir(), "restore.db")) + if err != nil { + t.Fatal(err) + } + defer dest.Close() + if _, err := dest.Import(backup, []string{"speedLimits"}); err != nil { + t.Fatal(err) + } + check(dest) +} + +// Lifecycle and migration fixtures use DDL only to reproduce a pre-upgrade +// database and inject a write failure. All observations use public interfaces. +func TestDisplayOrderLifecycleDialects(t *testing.T) { + for _, dialect := range []string{"sqlite", "postgres"} { + t.Run(dialect, func(t *testing.T) { + open := orderTestDatabase(t, dialect) + r := open() + for _, resource := range []string{"user", "speed_limit"} { + t.Run(resource, func(t *testing.T) { + create := func(name string) int64 { + t.Helper() + var id int64 + var err error + if resource == "user" { + id, err = r.CreateUser(name, security.MD5("test-password"), 1, 0, 123, 1, 4, 1, 5, 0) + } else { + id, err = r.CreateSpeedLimit(name, 123, 0, 1) + } + if err != nil { + t.Fatal(err) + } + return id + } + list := func(db *repo.Repository) []int64 { + t.Helper() + var rows []map[string]interface{} + var err error + if resource == "user" { + rows, err = db.ListUsers() + } else { + rows, err = db.ListSpeedLimits() + } + if err != nil { + t.Fatal(err) + } + ids := []int64{} + for _, row := range rows { + ids = append(ids, row["id"].(int64)) + } + return ids + } + save := func(db *repo.Repository, ids, expected []int64) error { + if resource == "user" { + return db.UpdateUserDisplayOrder(ids, expected) + } + return db.UpdateSpeedLimitDisplayOrder(ids, expected) + } + a, b, c := create("a"), create("b"), create("c") + // Drop the new column to reproduce a database last opened by the old release. + if err := r.DB().Exec("ALTER TABLE \"" + resource + "\" DROP COLUMN display_order").Error; err != nil { + t.Fatal(err) + } + if err := r.Close(); err != nil { + t.Fatal(err) + } + r = open() + if got := list(r); !reflect.DeepEqual(got, []int64{c, b, a}) { + t.Fatalf("legacy order %v", got) + } + if err := save(r, []int64{a, c, b}, []int64{c, b, a}); err != nil { + t.Fatal(err) + } + backup, err := r.ExportPartial([]string{"users", "speedLimits"}) + if err != nil { + t.Fatal(err) + } + restore := orderTestDatabase(t, dialect)() + defer restore.Close() + types := []string{"speedLimits"} + if resource == "user" { + types = []string{"users"} + } + if _, err := restore.Import(backup, types); err != nil { + t.Fatal(err) + } + if got := list(restore); !reflect.DeepEqual(got, []int64{a, c, b}) { + t.Fatalf("new backup order: %v", got) + } + before, err := r.ExportPartial([]string{"users", "speedLimits"}) + if err != nil { + t.Fatal(err) + } + if err := r.Close(); err != nil { + t.Fatal(err) + } + r = open() + second := open() + defer second.Close() + if got := list(second); !reflect.DeepEqual(got, []int64{a, c, b}) { + t.Fatalf("cross-client/restart order %v", got) + } + // The next save fails on its second row; the first row must roll back. + if dialect == "sqlite" { + if err := r.DB().Exec(fmt.Sprintf("CREATE TRIGGER reject_order BEFORE UPDATE OF display_order ON \"%s\" WHEN NEW.id = %d BEGIN SELECT RAISE(ABORT, 'injected failure'); END", resource, a)).Error; err != nil { + t.Fatal(err) + } + } else { + if err := r.DB().Exec(fmt.Sprintf("ALTER TABLE \"%s\" ADD CONSTRAINT reject_order CHECK (id != %d OR display_order = 0)", resource, a)).Error; err != nil { + t.Fatal(err) + } + } + if err := save(r, []int64{b, a, c}, []int64{a, c, b}); err == nil { + t.Fatal("expected injected failure") + } + if got := list(second); !reflect.DeepEqual(got, []int64{a, c, b}) { + t.Fatalf("partial write: %v", got) + } + if dialect == "sqlite" { + if err := r.DB().Exec("DROP TRIGGER reject_order").Error; err != nil { + t.Fatal(err) + } + } else { + if err := r.DB().Exec("ALTER TABLE \"" + resource + "\" DROP CONSTRAINT reject_order").Error; err != nil { + t.Fatal(err) + } + } + // Exactly one cross-client save against the same observed order may win. + start := make(chan struct{}) + results := make(chan error, 2) + for _, db := range []*repo.Repository{r, second} { + go func(db *repo.Repository) { <-start; results <- save(db, []int64{b, a, c}, []int64{a, c, b}) }(db) + } + close(start) + e1, e2 := <-results, <-results + if !((e1 == nil && errors.Is(e2, repo.ErrDisplayOrderConflict)) || (e2 == nil && errors.Is(e1, repo.ErrDisplayOrderConflict))) { + t.Fatalf("concurrent saves: %v / %v", e1, e2) + } + // Reordering must not mutate any exported fields besides rank. + after, err := r.ExportPartial([]string{"users", "speedLimits"}) + if err != nil { + t.Fatal(err) + } + strip := func(b *model.BackupData) { + b.ExportedAt = 0 + for i := range b.Users { + b.Users[i].DisplayOrder = nil + } + for i := range b.SpeedLimits { + b.SpeedLimits[i].DisplayOrder = nil + } + } + strip(before) + strip(after) + if !reflect.DeepEqual(before, after) { + t.Fatal("reorder modified non-order backup fields") + } + // Old backups omit ranks and must not overwrite an existing saved order. + legacy := orderTestDatabase(t, dialect)() + defer legacy.Close() + if _, err := legacy.Import(after, types); err != nil { + t.Fatal(err) + } + if got := list(legacy); !reflect.DeepEqual(got, []int64{c, b, a}) { + t.Fatalf("new legacy records: %v", got) + } + if _, err := r.Import(after, types); err != nil { + t.Fatal(err) + } + if got := list(r); !reflect.DeepEqual(got, []int64{b, a, c}) { + t.Fatalf("old backup reset existing order: %v", got) + } + d := create("d") + if got := list(r); !reflect.DeepEqual(got, []int64{d, b, a, c}) { + t.Fatalf("create after reorder: %v", got) + } + if err := save(second, []int64{a, b, c}, []int64{b, a, c}); !errors.Is(err, repo.ErrDisplayOrderConflict) { + t.Fatalf("stale membership: %v", err) + } + }) + } + r.Close() + }) + } +} + +func orderTestDatabase(t *testing.T, dialect string) func() *repo.Repository { + t.Helper() + if dialect == "sqlite" { + path := filepath.Join(t.TempDir(), "lifecycle.db") + return func() *repo.Repository { + r, err := repo.Open(path) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { r.Close() }) + return r + } + } + dsn := os.Getenv("FLVX_POSTGRES_TEST_DSN") + if dsn == "" { + t.Skip("set FLVX_POSTGRES_TEST_DSN for isolated PostgreSQL contracts") + } + admin, err := sql.Open("pgx", dsn) + if err != nil { + t.Fatal(err) + } + schema := "contract_display_order_" + strconv.FormatInt(time.Now().UnixNano(), 36) + if _, err := admin.Exec("CREATE SCHEMA \"" + schema + "\""); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { admin.Exec("DROP SCHEMA \"" + schema + "\" CASCADE"); admin.Close() }) + scoped, err := withSearchPath(dsn, schema) + if err != nil { + t.Fatal(err) + } + return func() *repo.Repository { + r, err := repo.OpenPostgres(scoped) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { r.Close() }) + return r + } +} + +func TestDisplayOrderConcurrentCreate(t *testing.T) { + for _, dialect := range []string{"sqlite", "postgres"} { + t.Run(dialect, func(t *testing.T) { + for _, resource := range []string{"user", "speed-limit"} { + t.Run(resource, func(t *testing.T) { + open := orderTestDatabase(t, dialect) + r, other := open(), open() + create := func(db *repo.Repository, name string) (int64, error) { + if resource == "user" { + return db.CreateUser(name, "hash", 1, 0, 10, 1, 2, 1, 3, 0) + } + return db.CreateSpeedLimit(name, 100, 0, 1) + } + save := func(ids, expected []int64) error { + if resource == "user" { + return r.UpdateUserDisplayOrder(ids, expected) + } + return r.UpdateSpeedLimitDisplayOrder(ids, expected) + } + a, err := create(r, "a") + if err != nil { + t.Fatal(err) + } + b, err := create(r, "b") + if err != nil { + t.Fatal(err) + } + start := make(chan struct{}) + saved := make(chan error, 1) + type creation struct { + id int64 + err error + } + created := make(chan creation, 1) + go func() { <-start; saved <- save([]int64{a, b}, []int64{b, a}) }() + go func() { <-start; id, err := create(other, "c"); created <- creation{id, err} }() + close(start) + saveErr, c := <-saved, <-created + if c.err != nil { + t.Fatal(c.err) + } + if saveErr != nil && !errors.Is(saveErr, repo.ErrDisplayOrderConflict) { + t.Fatalf("concurrent save: %v", saveErr) + } + var rows []map[string]interface{} + if resource == "user" { + rows, err = r.ListUsers() + } else { + rows, err = r.ListSpeedLimits() + } + if err != nil { + t.Fatal(err) + } + got := []int64{} + for _, row := range rows { + got = append(got, row["id"].(int64)) + } + want := []int64{c.id, a, b} + if saveErr != nil { + want = []int64{c.id, b, a} + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("concurrent create order %v want %v", got, want) + } + if resource == "user" { + err = r.DeleteUserCascade(c.id) + } else { + err = r.DeleteSpeedLimit(c.id) + } + if err != nil { + t.Fatal(err) + } + if err := save(got, got); !errors.Is(err, repo.ErrDisplayOrderConflict) { + t.Fatalf("deleted membership not rejected: %v", err) + } + }) + } + }) + } +} diff --git a/go-backend/tests/contract/forward_owner_names_test.go b/go-backend/tests/contract/forward_owner_names_test.go new file mode 100644 index 0000000..c8d5a88 --- /dev/null +++ b/go-backend/tests/contract/forward_owner_names_test.go @@ -0,0 +1,300 @@ +package contract_test + +import ( + "bytes" + "database/sql" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strconv" + "strings" + "testing" + "time" + + "go-backend/internal/auth" + httpserver "go-backend/internal/http" + "go-backend/internal/http/handler" + "go-backend/internal/security" + "go-backend/internal/store/model" + "go-backend/internal/store/repo" +) + +func TestForwardOwnerNamesFollowAccountRenames(t *testing.T) { + for _, database := range []string{"sqlite", "postgres"} { + t.Run(database, func(t *testing.T) { + for _, mode := range []string{"admin-name-only", "admin-name-and-password", "self-name-and-password"} { + t.Run(mode, func(t *testing.T) { + const secret = "issue574-isolated-test" + open := ownerNamesRepository(t, database) + r := open() + router := httpserver.NewRouter(handler.New(r, secret), secret) + for _, u := range []model.User{ + {ID: 9103, User: "before-name", Pwd: security.MD5("old-password"), RoleID: 1, Status: 1, PasswordChangedAt: time.Now().Add(-10 * time.Second).UnixMilli()}, + {ID: 9104, User: "other-user", Pwd: security.MD5("other-password"), RoleID: 1, Status: 1, PasswordChangedAt: time.Now().Add(-10 * time.Second).UnixMilli()}, + } { + if err := r.DB().Create(&u).Error; err != nil { + t.Fatal(err) + } + } + exp := time.Now().Add(time.Hour).UnixMilli() + f := model.Forward{ID: 9301, UserID: 9103, UserName: "before-name", Name: "existing-rule", TunnelID: 9201, RemoteAddr: "127.0.0.1:8080", Status: 1, InFlow: 123, OutFlow: 456} + for _, v := range []interface{}{&model.Tunnel{ID: 9201, Name: "isolated", Type: 1, Protocol: "tls", Status: 1, Flow: 1}, &model.UserTunnel{ID: 9401, UserID: 9103, TunnelID: 9201, Status: 1, Flow: 100, Num: 10, ExpTime: exp}, &f} { + if err := r.DB().Create(v).Error; err != nil { + t.Fatal(err) + } + } + token := func(id int64, name string, role int) string { + v, e := auth.GenerateTokenAt(id, name, role, secret, time.Now().Add(time.Second)) + if e != nil { + t.Fatal(e) + } + return v + } + admin := token(1, "admin_user", 0) + oldToken, e := auth.GenerateTokenAt(9103, "before-name", 1, secret, time.Now().Add(-time.Second)) + if e != nil { + t.Fatal(e) + } + call := func(path, tok string, body interface{}) (int, json.RawMessage) { + b, err := json.Marshal(body) + if err != nil { + t.Fatal(err) + } + q := httptest.NewRequest(http.MethodPost, path, bytes.NewReader(b)) + q.Header.Set("Authorization", tok) + q.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + router.ServeHTTP(w, q) + var out struct { + Code int `json:"code"` + Data json.RawMessage `json:"data"` + Msg string `json:"msg"` + } + if e := json.Unmarshal(w.Body.Bytes(), &out); e != nil { + t.Fatal(e) + } + return out.Code, out.Data + } + path, tok := "/api/v1/user/update", admin + body := map[string]interface{}{"id": 9103, "user": "after-name", "flow": 100, "num": 10, "expTime": exp, "flowResetTime": 1, "status": 1, "maxConn": 0} + if mode == "admin-name-and-password" { + body["pwd"] = "new-password" + } + if mode == "self-name-and-password" { + path = "/api/v1/user/updatePassword" + tok = oldToken + body = map[string]interface{}{"newUsername": "after-name", "currentPassword": "old-password", "newPassword": "new-password", "confirmPassword": "new-password"} + } + if code, _ := call(path, tok, body); code != 0 { + t.Fatalf("rename code=%d", code) + } + + fresh := token(9103, "after-name", 1) + code, data := call("/api/v1/forward/list", admin, map[string]interface{}{}) + if code != 0 { + t.Fatal(code) + } + var rows []map[string]interface{} + if err := json.Unmarshal(data, &rows); err != nil { + t.Fatal(err) + } + if len(rows) != 1 || rows[0]["userName"] != "after-name" || rows[0]["userId"] != float64(9103) { + t.Fatalf("expected current owner name: %s", data) + } + if rows[0]["id"] != float64(9301) || rows[0]["inFlow"] != float64(123) || rows[0]["outFlow"] != float64(456) || rows[0]["status"] != float64(1) { + t.Fatalf("rule identity/traffic/status changed: %s", data) + } + if code, data = call("/api/v1/forward/list", fresh, map[string]interface{}{}); code != 0 { + t.Fatalf("owner denied: %d", code) + } + rows = nil + if err := json.Unmarshal(data, &rows); err != nil { + t.Fatal(err) + } + if len(rows) != 1 || rows[0]["userName"] != "after-name" { + t.Fatalf("owner cannot see renamed rule: %s", data) + } + code, data = call("/api/v1/tunnel/delete-preview", admin, map[string]interface{}{"id": 9201}) + if code != 0 { + t.Fatalf("preview code=%d", code) + } + var preview struct { + SampleForwards []struct { + ID int64 `json:"id"` + UserID int64 `json:"userId"` + UserName string `json:"userName"` + } `json:"sampleForwards"` + } + if err := json.Unmarshal(data, &preview); err != nil { + t.Fatal(err) + } + if len(preview.SampleForwards) != 1 || preview.SampleForwards[0].ID != 9301 || preview.SampleForwards[0].UserID != 9103 || preview.SampleForwards[0].UserName != "after-name" { + t.Fatalf("preview must show current owner: %s", data) + } + code, data = call("/api/v1/tunnel/batch-delete-preview", admin, map[string]interface{}{"ids": []int64{9201}}) + if code != 0 { + t.Fatalf("batch preview code=%d", code) + } + var batch struct { + Items []struct { + SampleForwards []struct { + UserName string `json:"userName"` + } `json:"sampleForwards"` + } `json:"items"` + } + if err := json.Unmarshal(data, &batch); err != nil { + t.Fatal(err) + } + if len(batch.Items) != 1 || len(batch.Items[0].SampleForwards) != 1 || batch.Items[0].SampleForwards[0].UserName != "after-name" { + t.Fatalf("batch preview must show current owner: %s", data) + } + exported, err := r.ExportAll() + if err != nil { + t.Fatal(err) + } + if len(exported.UserTunnels) != 1 || exported.UserTunnels[0].UserID != 9103 || exported.UserTunnels[0].TunnelID != 9201 || exported.UserTunnels[0].Flow != 100 || exported.UserTunnels[0].Status != 1 { + t.Fatalf("rename changed tunnel grant: %+v", exported.UserTunnels) + } + oldCode, _ := call("/api/v1/forward/list", oldToken, map[string]interface{}{}) + wantOld := 0 + if mode != "admin-name-only" { + wantOld = 401 + } + if oldCode != wantOld { + t.Fatalf("old token=%d want %d", oldCode, wantOld) + } + // Re-use the abandoned username for another identity; it must not acquire rules. + if code, _ := call("/api/v1/user/update", admin, map[string]interface{}{"id": 9104, "user": "before-name", "flow": 100, "num": 10, "expTime": exp, "flowResetTime": 1, "status": 1, "maxConn": 0}); code != 0 { + t.Fatalf("reuse old name: %d", code) + } + other := token(9104, "before-name", 1) + code, data = call("/api/v1/forward/list", other, map[string]interface{}{}) + if code != 0 { + t.Fatal(code) + } + rows = nil + if err := json.Unmarshal(data, &rows); err != nil { + t.Fatal(err) + } + if len(rows) != 0 { + t.Fatalf("old name gained rules: %s", data) + } + if code, _ := call("/api/v1/forward/reset-flow", other, map[string]interface{}{"id": 9301}); code == 0 { + t.Fatal("other user reset original rule") + } + if code, _ := call("/api/v1/forward/reset-flow", fresh, map[string]interface{}{"id": 9301}); code != 0 { + t.Fatalf("owner reset denied: %d", code) + } + }) + } + }) + } +} + +// Each PostgreSQL test owns only its unique schema, never the shared test database. +func ownerNamesRepository(t *testing.T, database string) func() *repo.Repository { + t.Helper() + dsn := filepath.Join(t.TempDir(), "owners.db") + if database == "postgres" { + baseDSN := strings.TrimSpace(os.Getenv("FLVX_POSTGRES_TEST_DSN")) + if baseDSN == "" { + t.Skip("set FLVX_POSTGRES_TEST_DSN to run postgres owner-name contracts") + } + schema := "contract_owner_names_" + strconv.FormatInt(time.Now().UnixNano(), 36) + db, err := sql.Open("pgx", baseDSN) + if err != nil { + t.Fatal(err) + } + if _, err = db.Exec(`CREATE SCHEMA "` + schema + `"`); err != nil { + db.Close() + t.Fatal(err) + } + t.Cleanup(func() { _, _ = db.Exec(`DROP SCHEMA IF EXISTS "` + schema + `" CASCADE`); _ = db.Close() }) + dsn, err = withSearchPath(baseDSN, schema) + if err != nil { + t.Fatal(err) + } + } + return func() *repo.Repository { + t.Helper() + var r *repo.Repository + var err error + if database == "postgres" { + r, err = repo.OpenPostgres(dsn) + } else { + r, err = repo.Open(dsn) + } + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = r.Close() }) + return r + } +} + +func TestForwardOwnerNamesSurviveLegacyBackupAndRestart(t *testing.T) { + for _, database := range []string{"sqlite", "postgres"} { + t.Run(database, func(t *testing.T) { + open := ownerNamesRepository(t, database) + r := open() + // A legacy backup stores the rule's original name, not the user's current one. + const legacy = `{"users":[{"id":9103,"user":"current-owner","pwd":"test-only-password","roleId":1,"status":1}], + "forwards":[ + {"id":9301,"userId":9103,"userName":"former-owner","name":"historical-rule","tunnelId":9201,"inFlow":123,"outFlow":456,"status":1}, + {"id":9302,"userId":9199,"userName":"missing-owner","name":"orphan-rule","tunnelId":9201,"status":1}, + {"id":9303,"userId":9198,"userName":"","name":"unknown-owner-rule","tunnelId":9201,"status":1}]}` + var backup model.BackupData + if err := json.Unmarshal([]byte(legacy), &backup); err != nil { + t.Fatal(err) + } + if _, err := r.Import(&backup, []string{"users", "forwards"}); err != nil { + t.Fatal(err) + } + check := func(stage string) { + t.Helper() + rows, err := r.ListForwards() + if err != nil { + t.Fatal(err) + } + if len(rows) != 3 { + t.Fatalf("%s: rows=%+v", stage, rows) + } + for i, want := range []string{"current-owner", "missing-owner", ""} { + if rows[i]["userName"] != want { + t.Fatalf("%s: row %d owner=%v want %q", stage, i, rows[i]["userName"], want) + } + } + if rows[0]["id"] != int64(9301) || rows[0]["userId"] != int64(9103) || rows[0]["inFlow"] != int64(123) || rows[0]["outFlow"] != int64(456) { + t.Fatalf("%s: rule changed: %+v", stage, rows[0]) + } + previews, err := r.ListForwardsByTunnel(9201) + if err != nil { + t.Fatal(err) + } + if len(previews) != 3 || previews[0].UserName != "current-owner" || previews[1].UserName != "missing-owner" || previews[2].UserName != "" { + t.Fatalf("%s: preview owners=%+v", stage, previews) + } + exported, err := r.ExportAll() + if err != nil { + t.Fatal(err) + } + if len(exported.Forwards) != 3 || exported.Forwards[0].UserName != "former-owner" { + t.Fatalf("%s: stored legacy snapshot was rewritten: %+v", stage, exported.Forwards) + } + } + check("import") + if err := r.Close(); err != nil { + t.Fatal(err) + } + r = open() + check("restart") + // A forwards-only restore must resolve against the existing user by ID. + if _, err := r.Import(&backup, []string{"forwards"}); err != nil { + t.Fatal(err) + } + check("partial restore") + }) + } +} diff --git a/go-backend/tests/contract/postgres_backup_sequences_test.go b/go-backend/tests/contract/postgres_backup_sequences_test.go new file mode 100644 index 0000000..35c6389 --- /dev/null +++ b/go-backend/tests/contract/postgres_backup_sequences_test.go @@ -0,0 +1,554 @@ +package contract_test + +import ( + "context" + "database/sql" + "fmt" + "net/http" + "net/http/httptest" + "slices" + "strconv" + "strings" + "testing" + "time" + + "go-backend/internal/auth" + httpserver "go-backend/internal/http" + "go-backend/internal/http/handler" + "go-backend/internal/security" + "go-backend/internal/store/model" + "go-backend/internal/store/repo" +) + +func TestPostgresBackupImportAllowsImmediateCreate(t *testing.T) { + source := ownerNamesRepository(t, "postgres")() + for _, name := range []string{"backup-one", "backup-two"} { + if _, err := source.CreateUser(name, security.MD5("test-password"), 1, 0, 10, 1, 2, 1, 3, 0); err != nil { + t.Fatal(err) + } + if _, err := source.CreateSpeedLimit(name, 100, 0, 1); err != nil { + t.Fatal(err) + } + } + backup, err := source.ExportAll() + if err != nil { + t.Fatal(err) + } + target := ownerNamesRepository(t, "postgres")() + if _, err := target.Import(backup, []string{"users", "speedLimits"}); err != nil { + t.Fatal(err) + } + if id, err := target.CreateUser("after-import", security.MD5("test-password"), 1, 0, 10, 1, 2, 1, 3, 0); err != nil || id <= 2 { + t.Errorf("create user immediately after backup import: id=%d err=%v", id, err) + } + if id, err := target.CreateSpeedLimit("after-import", 100, 0, 1); err != nil || id <= 2 { + t.Errorf("create speed limit immediately after backup import: id=%d err=%v", id, err) + } +} + +func TestPostgresBackupImportPreservesAllocatedIDs(t *testing.T) { + for _, called := range []bool{false, true} { + t.Run(fmt.Sprintf("called=%t", called), func(t *testing.T) { + r := ownerNamesRepository(t, "postgres")() + // A deleted or rolled-back allocation may be higher than every row. + for _, table := range []string{"user", "speed_limit"} { + if err := r.DB().Exec(`SELECT setval(pg_get_serial_sequence(?, 'id'), 500, ?)`, `"`+table+`"`, called).Error; err != nil { + t.Fatal(err) + } + } + b := &model.BackupData{Users: []model.UserBackup{{ID: 1, User: "restored", Pwd: "$2-test-fixture", RoleID: 1}}, SpeedLimits: []model.SpeedLimitBackup{{ID: 1, Name: "restored"}}} + if _, err := r.Import(b, []string{"users", "speedLimits"}); err != nil { + t.Fatal(err) + } + want := int64(500) + if called { + want++ + } + if id, err := r.CreateUser("new", "hash", 1, 0, 0, 0, 0, 1, 0, 0); err != nil || id != want { + t.Fatalf("user id=%d, want %d; err=%v", id, want, err) + } + if id, err := r.CreateSpeedLimit("new", 100, 0, 1); err != nil || id != want { + t.Fatalf("speed limit id=%d, want %d; err=%v", id, want, err) + } + }) + } +} + +func TestPostgresBackupImportInvalidatesCachedIDs(t *testing.T) { + r := ownerNamesRepository(t, "postgres")() + if err := r.DB().Exec(`ALTER SEQUENCE speed_limit_id_seq CACHE 20`).Error; err != nil { + t.Fatal(err) + } + pool, err := r.DB().DB() + if err != nil { + t.Fatal(err) + } + cached, err := pool.Conn(context.Background()) + if err != nil { + t.Fatal(err) + } + defer cached.Close() + // Hold this backend out of the pool with unused cached IDs 2..20. + var allocated int64 + if err := cached.QueryRowContext(context.Background(), `SELECT nextval('speed_limit_id_seq')`).Scan(&allocated); err != nil { + t.Fatal(err) + } + if allocated != 1 { + t.Fatal(allocated) + } + b := &model.BackupData{SpeedLimits: []model.SpeedLimitBackup{{ID: 2, Name: "restored"}}} + if _, err := r.Import(b, []string{"speedLimits"}); err != nil { + t.Fatal(err) + } + // Return only the cached backend to the pool, so the public create must use it. + pool.SetMaxIdleConns(0) + pool.SetMaxIdleConns(1) + if err := cached.Close(); err != nil { + t.Fatal(err) + } + if id, err := r.CreateSpeedLimit("new", 100, 0, 1); err != nil || id <= 20 { + t.Fatalf("cached ID reused: id=%d err=%v", id, err) + } +} + +func TestPostgresBackupImportRollsBackSequenceOnCommitFailure(t *testing.T) { + r := ownerNamesRepository(t, "postgres")() + // Deferred validation fails after import has already advanced the sequence. + for _, query := range []string{ + `CREATE FUNCTION reject_import_commit() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN RAISE EXCEPTION 'reject imported row at commit'; END $$`, + `CREATE CONSTRAINT TRIGGER reject_import_commit AFTER INSERT ON speed_limit DEFERRABLE INITIALLY DEFERRED FOR EACH ROW WHEN (NEW.name = 'fail-at-commit') EXECUTE FUNCTION reject_import_commit()`, + } { + if err := r.DB().Exec(query).Error; err != nil { + t.Fatal(err) + } + } + b := &model.BackupData{SpeedLimits: []model.SpeedLimitBackup{{ID: 100, Name: "fail-at-commit"}}} + if _, err := r.Import(b, []string{"speedLimits"}); err == nil || !strings.Contains(err.Error(), "reject imported row at commit") { + t.Fatalf("expected commit failure, got %v", err) + } + exported, err := r.ExportAll() + if err != nil { + t.Fatal(err) + } + if len(exported.SpeedLimits) != 0 { + t.Fatal("failed import persisted rows") + } + if id, err := r.CreateSpeedLimit("after-failure", 100, 0, 1); err != nil || id != 1 { + t.Fatalf("failed import changed next allocation: id=%d err=%v", id, err) + } +} + +func TestPostgresBackupImportPreservesSequenceIncrement(t *testing.T) { + r := ownerNamesRepository(t, "postgres")() + if err := r.DB().Exec(`ALTER SEQUENCE speed_limit_id_seq INCREMENT BY 10`).Error; err != nil { + t.Fatal(err) + } + b := &model.BackupData{SpeedLimits: []model.SpeedLimitBackup{{ID: 25, Name: "restored"}}} + if _, err := r.Import(b, []string{"speedLimits"}); err != nil { + t.Fatal(err) + } + if id, err := r.CreateSpeedLimit("new", 100, 0, 1); err != nil || id != 31 { + t.Fatalf("changed ID progression: id=%d, want 31; err=%v", id, err) + } +} + +func TestPostgresBackupImportAdvancesEveryExplicitIDTable(t *testing.T) { + source := ownerNamesRepository(t, "postgres")() + types := []string{"users", "speedLimits", "nodes", "tunnels", "forwards", "userTunnels", "tunnelGroups", "userGroups", "permissions", "configs"} + fixture := &model.BackupData{ + Users: []model.UserBackup{{ID: 1, User: "old", Pwd: "$2-test-fixture", RoleID: 1}}, + SpeedLimits: []model.SpeedLimitBackup{{ID: 1, Name: "old"}}, + Nodes: []model.NodeBackup{{ID: 1, Name: "old", Secret: "old-node"}}, + Tunnels: []model.TunnelBackup{{ID: 1, Name: "old", ChainTunnels: []model.ChainTunnelBackup{{ID: 1, TunnelID: 1, NodeID: 1, ChainType: "1"}}}}, + Forwards: []model.ForwardBackup{{ID: 1, Name: "old", UserID: 1, TunnelID: 1}}, + UserTunnels: []model.UserTunnelBackup{{ID: 1, UserID: 1, TunnelID: 1}}, + TunnelGroups: []model.TunnelGroupBackup{{ID: 1, Name: "old"}}, + UserGroups: []model.UserGroupBackup{{ID: 1, Name: "old"}}, + Permissions: []model.PermissionBackup{{ID: 1, UserGroupID: 1, TunnelGroupID: 1, Grants: []model.PermissionGrantBackup{{ID: 1, UserGroupID: 1, TunnelGroupID: 1, UserTunnelID: 1}}}}, + } + if _, err := source.Import(fixture, types); err != nil { + t.Fatal(err) + } + backup, err := source.ExportAll() + if err != nil { + t.Fatal(err) + } + r := ownerNamesRepository(t, "postgres")() + if _, err := r.Import(backup, types); err != nil { + t.Fatal(err) + } + // Use the normal public creation methods, including the nested child tables. + for _, create := range []struct { + name string + run func() error + }{ + {"user", func() error { _, err := r.CreateUser("new", "hash", 1, 0, 0, 0, 0, 1, 0, 0); return err }}, + {"speed limit", func() error { _, err := r.CreateSpeedLimit("new", 100, 0, 1); return err }}, + {"node", func() error { + return r.CreateRemoteNode("new", "new-node", "127.0.0.1", "10000-20000", 0, 1, 0, "", "", "") + }}, + {"tunnel", func() error { _, err := r.CreateTunnelTx(r.DB(), "new", 1, 1, 0, 0, 1, nil, 0, "", "", 0); return err }}, + {"chain tunnel", func() error { return r.CreateChainTunnelTx(r.DB(), 1, "1", 2, sql.NullInt64{}, "", 1, "tls", "") }}, + {"forward", func() error { + _, err := r.CreateForwardTx(1, "old", "new", 1, "127.0.0.1:80", "", 0, 0, nil, 0, "", nil, 0, 0, nil, 0, 0, 0) + return err + }}, + {"user tunnel", func() error { return r.InsertUserTunnel(2, 1, nil, 0, 0, 0, 0, 1) }}, + {"tunnel group", func() error { return r.GroupCreate("tunnel_group", "new", 1, 0) }}, + {"user group", func() error { return r.GroupCreate("user_group", "new", 1, 0) }}, + {"permission", func() error { return r.InsertGroupPermission(2, 2, 0) }}, + {"permission grant", func() error { r.InsertGroupPermissionGrant(1, 1, 2, 1, 0); return nil }}, + } { + t.Run(create.name, func(t *testing.T) { + if err := create.run(); err != nil { + t.Fatal(err) + } + }) + } + got, err := r.ExportAll() + if err != nil { + t.Fatal(err) + } + ids := map[string][]int64{} + for _, v := range got.Users { + ids["user"] = append(ids["user"], v.ID) + } + for _, v := range got.SpeedLimits { + ids["speed limit"] = append(ids["speed limit"], v.ID) + } + for _, v := range got.Nodes { + ids["node"] = append(ids["node"], v.ID) + } + for _, v := range got.Tunnels { + ids["tunnel"] = append(ids["tunnel"], v.ID) + for _, child := range v.ChainTunnels { + ids["chain tunnel"] = append(ids["chain tunnel"], child.ID) + } + } + for _, v := range got.Forwards { + ids["forward"] = append(ids["forward"], v.ID) + } + for _, v := range got.UserTunnels { + ids["user tunnel"] = append(ids["user tunnel"], v.ID) + } + for _, v := range got.TunnelGroups { + ids["tunnel group"] = append(ids["tunnel group"], v.ID) + } + for _, v := range got.UserGroups { + ids["user group"] = append(ids["user group"], v.ID) + } + for _, v := range got.Permissions { + ids["permission"] = append(ids["permission"], v.ID) + for _, child := range v.Grants { + ids["permission grant"] = append(ids["permission grant"], child.ID) + } + } + if len(ids) != 11 { + t.Fatalf("missing tables in export: %v", ids) + } + for name, values := range ids { + slices.Sort(values) + if !slices.Equal(values, []int64{1, 2}) { + t.Errorf("%s: expected old and new IDs [1 2], got %v", name, values) + } + } +} + +func TestPostgresBackupImportKeepsExistingRowsAndEmptySelections(t *testing.T) { + r := ownerNamesRepository(t, "postgres")() + // Fixture simulates an existing explicit-ID row with an unsynchronized sequence. + if err := r.DB().Create(&model.SpeedLimit{ID: 500, Name: "existing"}).Error; err != nil { + t.Fatal(err) + } + old := &model.BackupData{SpeedLimits: []model.SpeedLimitBackup{{ID: 1, Name: "legacy"}}} + if _, err := r.Import(old, []string{"speedLimits"}); err != nil { + t.Fatal(err) + } + if id, err := r.CreateSpeedLimit("new", 100, 0, 1); err != nil || id != 501 { + t.Fatalf("existing maximum ignored: id=%d err=%v", id, err) + } + for _, b := range []*model.BackupData{{}, {Users: []model.UserBackup{{ID: 900, User: "not-selected", Pwd: "$2-test-fixture"}}}} { + if _, err := r.Import(b, []string{"speedLimits"}); err != nil { + t.Fatal(err) + } + } + if id, err := r.CreateUser("first-user", "hash", 1, 0, 0, 0, 0, 1, 0, 0); err != nil || id != 2 { + t.Fatalf("unselected sequence changed: id=%d err=%v", id, err) + } + if id, err := r.CreateSpeedLimit("after-empty", 100, 0, 1); err != nil || id != 502 { + t.Fatalf("empty import changed allocation: id=%d err=%v", id, err) + } + got, err := r.ExportAll() + if err != nil { + t.Fatal(err) + } + if len(got.Users) != 2 || len(got.SpeedLimits) != 4 || got.SpeedLimits[1].Name != "existing" { + t.Fatalf("existing or unselected data changed: %+v", got) + } +} + +func TestPostgresBackupImportResolvesLegacySequenceAndSearchPath(t *testing.T) { + r := ownerNamesRepository(t, "postgres")() + pool, err := r.DB().DB() + if err != nil { + t.Fatal(err) + } + pool.SetMaxOpenConns(1) + var schema string + if err := r.DB().Raw(`SELECT current_schema()`).Scan(&schema).Error; err != nil { + t.Fatal(err) + } + prefix := "Empty-Prefix-" + strconv.FormatInt(time.Now().UnixNano(), 36) + if err := r.DB().Exec(`CREATE SCHEMA "` + prefix + `"`).Error; err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = r.DB().Exec(`DROP SCHEMA "` + prefix + `" CASCADE`).Error }) + // The historical default uses a custom, unowned sequence; the original + // owned serial sequence still exists but is no longer used by INSERT. + for _, query := range []string{ + `CREATE SEQUENCE "Custom Limit Sequence"`, + `ALTER TABLE speed_limit ALTER COLUMN id SET DEFAULT nextval('"Custom Limit Sequence"'::regclass)`, + `SET search_path TO "` + prefix + `", "` + schema + `"`, + } { + if err := r.DB().Exec(query).Error; err != nil { + t.Fatal(err) + } + } + b := &model.BackupData{SpeedLimits: []model.SpeedLimitBackup{{ID: 1, Name: "legacy"}}} + if _, err := r.Import(b, []string{"speedLimits"}); err != nil { + t.Fatal(err) + } + if id, err := r.CreateSpeedLimit("new", 100, 0, 1); err != nil || id != 2 { + t.Fatalf("wrong sequence or schema: id=%d err=%v", id, err) + } +} + +func waitForBackupDBCondition(t *testing.T, r *repo.Repository, query string, args ...interface{}) { + t.Helper() + deadline := time.Now().Add(3 * time.Second) + for time.Now().Before(deadline) { + var ready bool + if err := r.DB().Raw(query, args...).Scan(&ready).Error; err != nil { + t.Fatal(err) + } + if ready { + return + } + time.Sleep(5 * time.Millisecond) + } + t.Fatal("database barrier was not reached") +} + +func backupImportBarrier(t *testing.T, r *repo.Repository, table, timing, event, condition string) (*sql.Tx, int64) { + t.Helper() + pool, err := r.DB().DB() + if err != nil { + t.Fatal(err) + } + barrier, err := pool.Begin() + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = barrier.Rollback() }) + key := time.Now().UnixNano() & 0x3fffffff + if _, err := barrier.Exec(`SELECT pg_advisory_xact_lock($1)`, key); err != nil { + t.Fatal(err) + } + guardEnd := "" + if condition != "" { + guardEnd = "END IF;" + } + function := fmt.Sprintf(`CREATE FUNCTION pause_backup_test() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN %s PERFORM pg_advisory_xact_lock(%d); %s RETURN NEW; END $$`, condition, key, guardEnd) + if err := r.DB().Exec(function).Error; err != nil { + t.Fatal(err) + } + trigger := fmt.Sprintf(`CREATE TRIGGER pause_backup_test %s %s ON %s FOR EACH ROW EXECUTE FUNCTION pause_backup_test()`, timing, event, table) + if err := r.DB().Exec(trigger).Error; err != nil { + t.Fatal(err) + } + return barrier, key +} + +func TestPostgresBackupImportSerializesConcurrentCreatesAndImports(t *testing.T) { + r := ownerNamesRepository(t, "postgres")() + barrier, key := backupImportBarrier(t, r, "speed_limit", "BEFORE", "INSERT", "IF NEW.name = 'hold-import' THEN") + first := make(chan error, 1) + go func() { + _, err := r.Import(&model.BackupData{SpeedLimits: []model.SpeedLimitBackup{{ID: 100, Name: "hold-import"}}}, []string{"speedLimits"}) + first <- err + }() + waitForBackupDBCondition(t, r, `SELECT EXISTS (SELECT 1 FROM pg_locks WHERE locktype='advisory' AND objid=? AND NOT granted)`, key) + created := make(chan error, 1) + go func() { + id, err := r.CreateSpeedLimit("concurrent-create", 100, 0, 1) + if err == nil && id <= 100 { + err = fmt.Errorf("reused imported ID: %d", id) + } + created <- err + }() + waitForBackupDBCondition(t, r, `SELECT EXISTS (SELECT 1 FROM pg_locks WHERE relation='speed_limit'::regclass AND NOT granted)`) + second := make(chan error, 1) + go func() { + result, err := r.Import(&model.BackupData{SpeedLimits: []model.SpeedLimitBackup{{ID: 200, Name: "second-import"}}}, []string{"speedLimits"}) + if err == nil && result.SpeedLimitsImported != 1 { + err = fmt.Errorf("wrong retry count: %+v", result) + } + second <- err + }() + if err := barrier.Commit(); err != nil { + t.Fatal(err) + } + for name, done := range map[string]<-chan error{"first import": first, "create": created, "second import": second} { + select { + case err := <-done: + if err != nil { + t.Errorf("%s: %v", name, err) + } + case <-time.After(3 * time.Second): + t.Fatalf("%s blocked", name) + } + } + if id, err := r.CreateSpeedLimit("after-both", 100, 0, 1); err != nil || id <= 200 { + t.Fatalf("second import sequence not advanced: id=%d err=%v", id, err) + } + got, err := r.ExportAll() + if err != nil { + t.Fatal(err) + } + if len(got.SpeedLimits) != 4 { + t.Fatalf("lost concurrent rows: %+v", got.SpeedLimits) + } +} + +func TestPostgresBackupImportAvoidsCascadeDeleteDeadlock(t *testing.T) { + for _, mode := range []string{"retry-success", "busy-bounded"} { + t.Run(mode, func(t *testing.T) { + r := ownerNamesRepository(t, "postgres")() + user, err := r.CreateUser("delete-me", "hash", 1, 0, 0, 0, 0, 1, 0, 0) + if err != nil { + t.Fatal(err) + } + forward, err := r.CreateForwardTx(user, "delete-me", "old", 1, "127.0.0.1:80", "", 0, 0, []int64{1}, 12345, "", nil, 0, 0, nil, 0, 0, 0) + if err != nil { + t.Fatal(err) + } + // Pause a real public cascade after deleting a child row, before it + // reaches forward/user. Import must not retain partial parent locks. + barrier, key := backupImportBarrier(t, r, "forward_port", "AFTER", "DELETE", "") + deleted := make(chan error, 1) + go func() { deleted <- r.DeleteUserCascade(user) }() + waitForBackupDBCondition(t, r, `SELECT EXISTS (SELECT 1 FROM pg_locks WHERE locktype='advisory' AND objid=? AND NOT granted)`, key) + emptyPorts := []model.ForwardPortBackup{} + backup := &model.BackupData{ + Users: []model.UserBackup{{ID: 100, User: "restored", Pwd: "$2-test-fixture", RoleID: 1}}, + Forwards: []model.ForwardBackup{{ID: forward, UserID: 100, Name: "restored", ForwardPorts: &emptyPorts}}, + } + imported := make(chan error, 1) + runImport := func() { + result, err := r.Import(backup, []string{"users", "forwards"}) + if err == nil && (result.UsersImported != 1 || result.ForwardsImported != 1) { + err = fmt.Errorf("wrong retry count: %+v", result) + } + imported <- err + } + go runImport() + if mode == "busy-bounded" { + select { + case err := <-imported: + if err == nil || !strings.Contains(err.Error(), "database is busy") { + t.Fatalf("expected bounded busy error, got %v", err) + } + case <-time.After(3 * time.Second): + _ = barrier.Rollback() + t.Fatal("import waited while holding partial table locks") + } + } else { + // The held child lock makes every attempt fail before writes. + // Let several bounded attempts encounter it before release. + time.Sleep(75 * time.Millisecond) + } + if err := barrier.Commit(); err != nil { + t.Fatal(err) + } + select { + case err := <-deleted: + if err != nil { + t.Fatalf("cascade: %v", err) + } + case <-time.After(3 * time.Second): + t.Fatal("cascade deadlocked") + } + if mode == "busy-bounded" { + go runImport() + } + select { + case err := <-imported: + if err != nil { + t.Fatalf("import: %v", err) + } + case <-time.After(3 * time.Second): + t.Fatal("import deadlocked") + } + if id, err := r.CreateUser("after", "hash", 1, 0, 0, 0, 0, 1, 0, 0); err != nil || id <= 100 { + t.Fatalf("create after concurrent cascade: id=%d err=%v", id, err) + } + }) + } +} + +func TestPostgresBackupImportRollsBackEarlierSequenceWhenLaterSequenceFails(t *testing.T) { + r := ownerNamesRepository(t, "postgres")() + if err := r.DB().Exec(`ALTER SEQUENCE speed_limit_id_seq MAXVALUE 10`).Error; err != nil { + t.Fatal(err) + } + backup := &model.BackupData{ + Users: []model.UserBackup{{ID: 100, User: "rollback-user", Pwd: "$2-test-fixture", RoleID: 1}}, + SpeedLimits: []model.SpeedLimitBackup{{ID: 10, Name: "exhausted"}}, + } + // User sequence advances first. The exhausted limit sequence then fails. + if _, err := r.Import(backup, []string{"users", "speedLimits"}); err == nil || !strings.Contains(err.Error(), "sequence exhausted") { + t.Fatalf("expected sequence exhaustion: %v", err) + } + if id, err := r.CreateUser("after-failure", "hash", 1, 0, 0, 0, 0, 1, 0, 0); err != nil || id != 2 { + t.Fatalf("earlier sequence failed to roll back: id=%d err=%v", id, err) + } + if id, err := r.CreateSpeedLimit("after-failure", 100, 0, 1); err != nil || id != 1 { + t.Fatalf("later sequence changed: id=%d err=%v", id, err) + } + got, err := r.ExportAll() + if err != nil { + t.Fatal(err) + } + if len(got.Users) != 2 || len(got.SpeedLimits) != 1 { + t.Fatalf("partial import committed: %+v", got) + } +} + +func TestPostgresBackupImportAPIAllowsImmediateCreate(t *testing.T) { + r := ownerNamesRepository(t, "postgres")() + const secret = "backup-sequence-contract" + router := httpserver.NewRouter(handler.New(r, secret), secret) + token, err := auth.GenerateToken(1, "admin_user", 0, secret) + if err != nil { + t.Fatal(err) + } + post := func(path, body string) { + t.Helper() + request := httptest.NewRequest(http.MethodPost, path, strings.NewReader(body)) + request.Header.Set("Authorization", token) + request.Header.Set("Content-Type", "application/json") + response := httptest.NewRecorder() + router.ServeHTTP(response, request) + assertCode(t, response, 0) + } + post("/api/v1/backup/import", `{"version":"1.0","types":["users","speedLimits"],"users":[{"id":2,"user":"restored","pwd":"$2-test-fixture","roleId":1,"status":1}],"speedLimits":[{"id":1,"name":"restored","speed":100,"status":1}]}`) + post("/api/v1/user/create", `{"user":"after-import","pwd":"test-only-password","status":1}`) + post("/api/v1/speed-limit/create", `{"name":"after-import","speed":200}`) + got, err := r.ExportAll() + if err != nil { + t.Fatal(err) + } + if len(got.Users) != 3 || got.Users[2].ID != 3 || len(got.SpeedLimits) != 2 || got.SpeedLimits[1].ID != 2 { + t.Fatalf("API create did not retain restored records: %+v", got) + } +} diff --git a/plans/074-user-names-and-custom-order.md b/plans/074-user-names-and-custom-order.md new file mode 100644 index 0000000..b3a35f7 --- /dev/null +++ b/plans/074-user-names-and-custom-order.md @@ -0,0 +1,98 @@ +# FLVX #574 / #575: current owner names and custom order + +Status: implemented and verified locally on 2026-11-10. The user confirmed the three test boundaries before implementation and later authorized the PostgreSQL import repair discovered during validation. +Base: `3209ff8a59bf02287e6934d0b80b941006db239e` (main, verified 2026-11-10). +Sources: https://github.com/Sagit-chu/flvx/issues/574 and https://github.com/Sagit-chu/flvx/issues/575. + +## Problem statement + +Existing forwarding rules retain the username copied when they were created. Renaming an account leaves the rules page's owner filter and related displays showing the old name, including after restart. Users and speed limits have only ID-descending order, so an administrator cannot arrange them intentionally. + +## Solution + +Resolve a forwarding rule's current owner name by immutable user ID without recreating rules. Add administrator-managed global drag order for users and speed limits, persisted on the server across clients. Newly created entries appear first. Searching disables dragging so a partial result cannot overwrite the complete order. + +## User stories + +1. As an administrator, I see the current owner name on existing rules after renaming a user. +2. As a user changing my account name and password, my existing rules display the new name. +3. As an administrator, I see consistent names in rule groups, owner filters and tunnel deletion previews. +4. As an administrator restoring an old backup, I do not have to delete or recreate rules to correct owner names. +5. As a user, a different account reusing my former name cannot acquire my rules or permissions. +6. As an administrator, I can drag ordinary users into a global order in both card and table views. +7. As an administrator, I can drag speed limits into a global order in both card and table views. +8. As an administrator switching browsers or signing in again, I see the server's saved order. +9. As an administrator creating an item, I find it at the top; editing it keeps its position. +10. As an administrator searching a list, I see why dragging is unavailable and hidden items retain their positions. +11. As an administrator with more than ten users, I do not lose entries from the reorder set after creating a user. +12. As an administrator whose save fails or conflicts, I see the failure and recover the authoritative order rather than a false success. +13. As an operator upgrading an existing SQLite or PostgreSQL database, I retain the prior default order until I rearrange it. +14. As an operator restoring a new backup, I retain its order; older backups remain importable. +15. As a user, an administrative display-order change does not change my password, ownership, permissions, traffic totals, limits, or rule bindings. +16. As an operator restoring PostgreSQL data, I can immediately create new records without restarting, with existing sequence allocations preserved and failed imports rolled back. + +## Implementation decisions + +- Rule ownership remains numeric user ID. Display uses the current associated username, with the stored name (and existing unknown-owner UI) as a compatibility fallback when no associated user exists. +- Keep the legacy stored name field and backup format compatible. Display correction must work for stale data without a destructive rewrite or rule recreation. +- Cover all relevant presentation projections, not just the owner selector. Do not change data-plane service identity or authentication behavior. +- Add integer ordering to ordinary users and speed limits. Preserve old ID-descending order for tied/default ranks, set new items before the current first rank, and preserve rank on ordinary edits. +- Add administrator-only reorder endpoints for both resources. A request supplies the complete ordered ID set and its previously observed order so stale sets/orders can be rejected safely; return an actionable conflict/error rather than partial success. +- Validate input as a complete permutation of eligible current resources. Reject malformed IDs, duplicates, missing/extra IDs and administrator user IDs. All writes are transactional. +- Serialize client saves or disable dragging while a save is outstanding. On failure/conflict, reload the server's order. Cancel or ignore stale list requests when search/navigation changes. +- Reuse existing drag sensors and affordances. Support mouse, touch and keyboard. Keep delete/edit controls usable and prevent accidental drag activation. +- Search disables drag. User creation reloads the authoritative complete list, avoiding the existing ten-item truncation. +- Preserve existing API envelopes and raw JWT convention. Explicitly protect the new user reorder path in authorization middleware. +- User and speed-limit backup DTOs, export mappings and import updates carry order. Missing old-backup fields must not accidentally zero an existing custom order. New records imported without rank follow a documented deterministic rule. +- No new frontend test framework; use the repository's build/lint and isolated browser checks. + +## Testing decisions (confirmed boundaries) + +1. **Public HTTP API**: current-name responses, all rename entry points, ownership isolation, admin-only reorder, complete-set validation, failures/conflicts, and list/create/edit/delete behavior. This catches application behavior but not browser interactions or every database upgrade path. +2. **Repository lifecycle and backup public interfaces**: fresh/legacy databases, restart persistence, old/new backup round trips and partial import behavior for SQLite and PostgreSQL. This catches persisted compatibility but not UI behavior. Observe through public reads/exports rather than implementation-coupled assertions where possible; legacy schema fixtures may require direct fixture setup. +3. **Rendered browser UI**: table/card drag, touch/keyboard affordances, search-disabled state, recovery from failed saves, repeat actions, and navigation. This catches actual UI wiring, not production connectivity or real forwarding. + +Use Matt Pocock's vertical red-to-green cycles: one failing behavior test, minimum implementation, then the next behavior. Prefer existing contract fixtures and public interfaces. Review standards and requirements independently after implementation. These boundaries were confirmed by the user before implementation tests were added. + +## Work graph + +- **T1 Current owner names**: implement #574 with API regression coverage and historical/backup cases. Independent. +- **T2 Persistent order backend**: implement ordering models, migrations, endpoints, permissions, transaction/conflict handling and backup compatibility. Runs in an isolated worktree; integrate after T1 and resolve any shared repository-layer edits before reporting complete. +- **T3 Drag order frontend**: implement both list views and failure/search behavior against the agreed API contract. Can proceed after T2 contract is pinned; no backend edits. +- **T4 Verify and review**: integrate all slices, full backend checks, frontend checks, isolated SQLite/PG/browser verification, independent standards/spec reviews and fixes. Depends on T1–T3. Complete. +- **T5 PostgreSQL import sequences**: user-authorized follow-on repair for the pre-existing explicit-ID import bug. Import takes the complete set of write-table locks with NOWAIT before any data writes, retries only pre-write lock contention within a bounded budget, and advances actual ID sequences transactionally without moving allocated IDs backward. Complete. + +## Out of scope + +Production access or changes, external comments/issues/spec publication, personal per-admin preferences, drag while searching, server pagination/cross-page drag, field-based sorting, changing actual speed-limit enforcement, agent changes, and unrelated refactors. Push/PR/merge/release were excluded from implementation and authorized separately afterward as recorded below. + +## Further notes + +Work is isolated on `fix/user-names-custom-order`; original checkout remains untouched. Local commits were permitted by the implementation workflow; publication required the separate authorization recorded below. Existing assessment reproductions are available in a temporary isolated checkout for reference, not as final tests. PostgreSQL testing used an isolated, disposable local PostgreSQL 16.15 container with per-test schemas. No production database or user data was used. + + +## Verification results + +Final implementation merge: `2ad30e4b2f587b0118cb53049194c915fb32565e`. + +- Full backend suite passed with PostgreSQL enabled and the cache disabled: `FLVX_POSTGRES_TEST_DSN=... go test -count=1 ./...`. +- Race-enabled owner-name, display-order and PostgreSQL backup-import contracts passed. The new PostgreSQL import contracts also passed three repeated race-enabled runs in their implementation worktree. +- `go vet ./...`, native backend build and Linux amd64/arm64 cross-builds passed using Go 1.26.10. +- Frontend `tsc --noEmit`, full `eslint .`, and production build passed using Node 24.22.1. The build still reports the existing outdated Browserslist data notice. +- SQLite and PostgreSQL contracts cover three rename paths, old-name reuse/ownership isolation, historical fallback, previews, restart and old/new backups. Order contracts cover administrator checks, complete permutations, stale order/membership, concurrent creates/reorders, new-first and edit-preserved rank. +- PostgreSQL import tests reproduce the prior 23505 failure and cover immediate public API creates without reopening the repository, all 11 explicit-ID tables, allocated/cached IDs, non-unit positive increments, unowned legacy defaults, search-path resolution, concurrent imports/creates, cascade-delete contention, bounded busy recovery, intermediate-sequence failure and commit-time rollback. Removing NOWAIT makes the contention regression fail. +- Chrome tests against synthetic localhost data passed: user and limit card keyboard reorder; user and limit table pointer reorder; 390px viewport touch reorder; refresh persistence; 13-user creation without truncation; new-first and edit-preserved order; search-disabled handles; drag cancellation; a second client's conflict followed by authoritative recovery; transport failure and successful load-error retry. +- The rendered rules page and owner filter show the current username for a historical rule whose stored snapshot still has the former name. +- Restarting the final backend binary preserved the 13-user order, four-limit order and current-name projection. +- Independent standards and requirements reviews passed after fixes. The recovered-load error is now cleared without hiding save warnings. The PostgreSQL lock-order and legacy-default findings were resolved and re-reviewed. + +## Validation limits + +- Linux amd64/arm64 binaries were cross-built, not run on those target machines. No live forwarding agents, production deployment, real traffic, Safari or physical mobile device were exercised. Mobile touch was tested through Chrome's emulated viewport and touch input. +- PostgreSQL identity-column fallback was reviewed but does not have a dedicated fixture. Normal repository writes and imports are covered; external SQL that reserves sequence values inside an open transaction and later acquires tables in reverse order, or concurrent manual sequence-definition changes, are outside the tested contract. Negative/non-positive ID sequences are rejected with a rolled-back import. +- Browser logs included existing React Router/dialog accessibility warnings and a password-manager-extension error. A development HMR hook-order error during the hook edit cleared on full reload; final page flows did not reproduce it. +- At completion of local verification, all changes remained on a local branch. No push, pull request, issue comment, release or production change had been performed. + +## Publication stage + +After reviewing the PostgreSQL identity-column testing boundary, the user separately authorized pushing the branch, creating and merging a pull request, and publishing a new tag/release on 2026-11-10. Publication must respect required checks and branch protection. The tag workflow publishes versioned/latest images and release attachments; it does not deploy production services. Production access or changes remain out of scope. diff --git a/vite-frontend/src/api/index.ts b/vite-frontend/src/api/index.ts index 8b5f3f1..6e423f9 100644 --- a/vite-frontend/src/api/index.ts +++ b/vite-frontend/src/api/index.ts @@ -619,3 +619,13 @@ export const assignMonitorPermission = (userId: number) => export const removeMonitorPermission = (userId: number) => Network.post("/monitor/permission/remove", { userId }); + +// Complete ordered IDs plus the last observed server order for conflict detection. +export interface OrderUpdatePayload { + ids: number[]; + expectedIds: number[]; +} +export const updateUserOrder = (data: OrderUpdatePayload) => + Network.post("/user/update-order", data); +export const updateSpeedLimitOrder = (data: OrderUpdatePayload) => + Network.post("/speed-limit/update-order", data); diff --git a/vite-frontend/src/components/server-order.tsx b/vite-frontend/src/components/server-order.tsx new file mode 100644 index 0000000..cbe96aa --- /dev/null +++ b/vite-frontend/src/components/server-order.tsx @@ -0,0 +1,151 @@ +import { createContext, useContext, type ReactNode } from "react"; +import { + DndContext, + closestCenter, + KeyboardSensor, + MouseSensor, + TouchSensor, + useSensor, + useSensors, + type DragEndEvent, +} from "@dnd-kit/core"; +import { + SortableContext, + rectSortingStrategy, + sortableKeyboardCoordinates, + useSortable, +} from "@dnd-kit/sortable"; +import { CSS } from "@dnd-kit/utilities"; +import { GripVertical } from "lucide-react"; + +import { TableRow } from "@/shadcn-bridge/heroui/table"; + +const HandleContext = createContext(null); + +export function OrderHandle() { + return <>{useContext(HandleContext)}; +} + +export function OrderItem({ + id, + label, + disabled, + row = false, + children, +}: { + id: number; + label: string; + disabled: boolean; + row?: boolean; + children: ReactNode; +}) { + const { + attributes, + listeners, + setNodeRef, + setActivatorNodeRef, + transform, + transition, + isDragging, + } = useSortable({ id, disabled }); + const Component = row ? TableRow : "div"; + + return ( + + + ); +} + +export function OrderList({ + ids, + children, + onDragStart, + onDragEnd, +}: { + ids: number[]; + children: ReactNode; + onDragStart: () => void; + onDragEnd: (event: DragEndEvent) => void; +}) { + const sensors = useSensors( + useSensor(MouseSensor, { activationConstraint: { distance: 8 } }), + useSensor(TouchSensor, { + activationConstraint: { delay: 250, tolerance: 8 }, + }), + useSensor(KeyboardSensor, { + coordinateGetter: sortableKeyboardCoordinates, + }), + ); + + return ( + + + {children} + + + ); +} + +export function OrderStatus({ + reason, + error, + retry, +}: { + reason: string; + error: string; + retry: () => void; +}) { + return ( +
+

+ {reason || "拖动手柄调整全局顺序;键盘可按空格、方向键、空格完成排序。"} +

+ {error && ( +

+ {error}{" "} + +

+ )} +
+ ); +} diff --git a/vite-frontend/src/hooks/use-server-order.ts b/vite-frontend/src/hooks/use-server-order.ts new file mode 100644 index 0000000..b1be00a --- /dev/null +++ b/vite-frontend/src/hooks/use-server-order.ts @@ -0,0 +1,122 @@ +import type { DragEndEvent } from "@dnd-kit/core"; +import type { OrderUpdatePayload } from "@/api"; + +import { useCallback, useEffect, useRef, useState } from "react"; +import { arrayMove } from "@dnd-kit/sortable"; + +interface Result { + code: number; + msg?: string; + transportError?: boolean; +} + +// Keep one complete, authoritative snapshot. Filtering is presentation-only. +export function useServerOrder( + fetchItems: () => Promise, + saveOrder: (payload: OrderUpdatePayload) => Promise, +) { + const [items, setItems] = useState([]); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [loadError, setLoadError] = useState(""); + const [saveWarning, setSaveWarning] = useState(""); + const mounted = useRef(false); + const request = useRef(0); + const savingRef = useRef(false); + const snapshot = useRef(items); + + snapshot.current = items; + const dragSnapshot = useRef(null); + + const reload = useCallback(async () => { + const version = ++request.current; + + setLoading(true); + try { + const rows = await fetchItems(); + + if (!mounted.current || version !== request.current) return; + setItems(rows); + setLoadError(""); + } catch { + if (!mounted.current || version !== request.current) return; + // Never expose a partial or unverified set as reorderable after failure. + setItems([]); + setLoadError("加载列表失败,请重试后再排序。"); + } finally { + if (mounted.current && version === request.current) setLoading(false); + } + }, [fetchItems]); + + useEffect(() => { + mounted.current = true; + void reload(); + + return () => { + mounted.current = false; + ++request.current; + }; + }, [reload]); + + const onDragStart = () => { + dragSnapshot.current = snapshot.current; + }; + const reorder = async ({ active, over }: DragEndEvent, disabled: boolean) => { + const before = dragSnapshot.current; + + dragSnapshot.current = null; + if ( + disabled || + loading || + savingRef.current || + !over || + active.id === over.id || + before !== snapshot.current + ) + return; + const from = items.findIndex((item) => item.id === active.id); + const to = items.findIndex((item) => item.id === over.id); + + if (from < 0 || to < 0) return; + savingRef.current = true; + setSaving(true); + setSaveWarning(""); + try { + const result = await saveOrder({ + ids: arrayMove(items, from, to).map((item) => item.id), + expectedIds: items.map((item) => item.id), + }); + + if (!mounted.current) return; + if (result.code !== 0) { + setSaveWarning( + result.transportError + ? "排序保存未确认,请检查重新加载后的服务器顺序再重试。" + : result.code === 409 + ? "列表已被其他操作更改,排序未保存,请按重新加载后的顺序重试。" + : `排序保存失败:${result.msg || "请重试"}。请检查重新加载后的服务器顺序。`, + ); + } + } catch { + if (mounted.current) + setSaveWarning("排序保存未确认,请检查重新加载后的服务器顺序再重试。"); + } finally { + if (mounted.current) { + await reload(); + if (mounted.current) setSaving(false); + } + savingRef.current = false; + } + }; + + return { + items, + setItems, + loading, + saving, + error: [saveWarning, loadError].filter(Boolean).join(" "), + reload, + onDragStart, + reorder, + }; +} diff --git a/vite-frontend/src/pages/limit.tsx b/vite-frontend/src/pages/limit.tsx index b4be918..5e45779 100644 --- a/vite-frontend/src/pages/limit.tsx +++ b/vite-frontend/src/pages/limit.tsx @@ -1,12 +1,17 @@ -import { useState, useEffect, useMemo } from "react"; +import { useState, useMemo } from "react"; import toast from "react-hot-toast"; import { LayoutGrid, List } from "lucide-react"; import { - AnimatedPage, - StaggerList, - StaggerItem, -} from "@/components/animated-page"; + OrderHandle, + OrderItem, + OrderList, + OrderStatus, +} from "@/components/server-order"; +import { useServerOrder } from "@/hooks/use-server-order"; +import { getOrderDisabledReason } from "@/utils/server-order"; +import { isAdmin } from "@/utils/auth"; +import { AnimatedPage, StaggerList } from "@/components/animated-page"; import { SearchBar } from "@/components/search-bar"; import { Card, CardBody, CardHeader } from "@/shadcn-bridge/heroui/card"; import { Button } from "@/shadcn-bridge/heroui/button"; @@ -24,11 +29,11 @@ import { TableHeader, TableColumn, TableBody, - TableRow, TableCell, } from "@/shadcn-bridge/heroui/table"; import { createSpeedLimit, + updateSpeedLimitOrder, getSpeedLimitList, updateSpeedLimit, deleteSpeedLimit, @@ -52,9 +57,17 @@ interface SpeedLimitForm { status: number; } +const fetchOrderedRules = async (): Promise => { + const response = await getSpeedLimitList(); + + if (response.code !== 0) throw new Error(response.msg); + + return (response.data || []) as SpeedLimitRule[]; +}; + export default function LimitPage() { - const [loading, setLoading] = useState(true); - const [rules, setRules] = useState([]); + const order = useServerOrder(fetchOrderedRules, updateSpeedLimitOrder); + const { items: rules, loading, reload: loadData } = order; const [searchKeyword, setSearchKeyword] = useLocalStorageState( "limit-search-keyword", "", @@ -92,28 +105,6 @@ export default function LimitPage() { // 表单验证错误 const [errors, setErrors] = useState<{ [key: string]: string }>({}); - useEffect(() => { - loadData(); - }, []); - - // 加载所有数据 - const loadData = async () => { - setLoading(true); - try { - const rulesRes = await getSpeedLimitList(); - - if (rulesRes.code === 0) { - setRules(rulesRes.data || []); - } else { - toast.error(rulesRes.msg || "获取限速规则失败"); - } - } catch { - toast.error("加载数据失败"); - } finally { - setLoading(false); - } - }; - // 表单验证 const validateForm = (): boolean => { const newErrors: { [key: string]: string } = {}; @@ -166,7 +157,7 @@ export default function LimitPage() { // 确认删除规则 const confirmDelete = async () => { - if (!ruleToDelete) return; + if (!ruleToDelete || order.saving || deleteLoading) return; setDeleteLoading(true); try { @@ -175,7 +166,7 @@ export default function LimitPage() { if (res.code === 0) { toast.success("删除成功"); setDeleteModalOpen(false); - loadData(); + await loadData(); } else { toast.error(res.msg || "删除失败"); } @@ -188,7 +179,7 @@ export default function LimitPage() { // 提交表单 const handleSubmit = async () => { - if (!validateForm()) return; + if (order.saving || !validateForm()) return; setSubmitLoading(true); try { @@ -215,7 +206,7 @@ export default function LimitPage() { if (res.code === 0) { toast.success(isEdit ? "修改成功" : "创建成功"); setModalOpen(false); - loadData(); + await loadData(); } else { toast.error(res.msg || "操作失败"); } @@ -226,9 +217,13 @@ export default function LimitPage() { } }; - if (loading) { - return ; - } + const orderReason = getOrderDisabledReason({ + isAdmin: isAdmin(), + searchKeyword, + saving: order.saving, + busy: loading || submitLoading || deleteLoading, + }); + const orderDisabled = Boolean(orderReason); return ( @@ -263,172 +258,203 @@ export default function LimitPage() { - {/* 统一卡片网格 */} - {filteredRules.length > 0 ? ( - viewMode === "list" ? ( - - - - 规则名称 - 速度限制 - 操作 - - - {(rule) => ( - - -
-
- - {rule.name} + { + if (!order.saving) void loadData(); + }} + /> + rule.id)} + onDragEnd={(event) => { + void order.reorder(event, orderDisabled); + }} + onDragStart={order.onDragStart} + > + {/* 统一卡片网格 */} + {loading ? ( + + ) : filteredRules.length > 0 ? ( + viewMode === "list" ? ( + +
+ + 规则名称 + 速度限制 + 操作 + + + {(rule) => ( + + +
+ +
+ + {rule.name} + +
+ + + + {rule.speed} Mbps -
-
- - - {rule.speed} Mbps - - - -
- + +
+
+
+ )} +
+
+
+ ) : ( + + {filteredRules.map((rule) => ( + + + + +
+
+

+ {rule.name} +

+
+ + {rule.status === 1 ? "运行" : "异常"} + +
+
+ +
+
+ + 速度限制 + + + {rule.speed} Mbps + +
+
+ +
+
- - - )} - - -
+ + +
+ ))} +
+ ) ) : ( - - {filteredRules.map((rule) => ( - - - -
-
-

- {rule.name} -

-
- - {rule.status === 1 ? "运行" : "异常"} - -
-
- -
-
- - 速度限制 - - - {rule.speed} Mbps - -
-
- -
- - -
-
-
-
- ))} -
- ) - ) : ( - /* 空状态 */ - - -

- 暂无限速规则 -

-

- 还没有创建任何限速规则,点击上方按钮开始创建 -

-
-
- )} + /* 空状态 */ + + +

+ 暂无限速规则 +

+

+ 还没有创建任何限速规则,点击上方按钮开始创建 +

+
+
+ )} + {/* 新增/编辑模态框 */} + + + + + + + ); + }} + + + + ) : ( + + {users.map((user) => { + const userStatus = getUserStatus(user); + const expStatus = user.expTime + ? getExpireStatus(user.expTime) + : null; + const usedFlow = calculateUserTotalUsedFlow(user); + const flowPercent = + user.flow > 0 && !(user.flow === 99999 && !user.flowMiB) + ? Math.min( + (usedFlow / flowLimitBytes(user.flow, user.flowMiB)) * + 100, + 100, + ) + : 0; + + return ( + + + + +
+
+
+ {(user.name || user.user).slice(0, 2).toUpperCase()} +
- )} -
-
- -
- {/* 第一行:编辑和重置 */} -
- -
+
+
+ + {userStatus.text} + + {user.disabledByQuota ? ( + - - - } - variant="flat" - onPress={() => handleResetFlow(user)} - > - 重置 - + 配额超额 + + ) : null} +
+ +
+ + +
+ {/* 流量信息 */} +
+
+ 流量限制 + + {formatFlowLimit(user.flow, user.flowMiB)} + +
+
+ 已使用 + + {formatFlow(usedFlow)} + +
+ 90 + ? "danger" + : flowPercent > 70 + ? "warning" + : "success" + } + size="sm" + value={flowPercent} + /> +
+ + {/* 其他信息 */} +
+ {(user.dailyQuotaGB ?? 0) > 0 || + (user.monthlyQuotaGB ?? 0) > 0 || + (user.disabledByQuota ?? 0) > 0 ? ( + <> +
+ + 每日配额 + + + {formatFlow(Number(user.dailyUsedBytes ?? 0))}{" "} + / {formatQuotaLimit(user.dailyQuotaGB)} + +
+
+ + 每月配额 + + + {formatFlow( + Number(user.monthlyUsedBytes ?? 0), + )}{" "} + / {formatQuotaLimit(user.monthlyQuotaGB)} + +
+ + ) : null} +
+ 规则数量 + + {user.num} + +
+
+ 重置日期 + + {user.flowResetTime === 0 + ? "不重置" + : `每月${user.flowResetTime}号`} + +
+ {user.expTime && ( +
+ 过期时间 +
+ {expStatus && expStatus.color === "success" ? ( +
+ {formatDate(user.expTime)} +
+ ) : ( + + {expStatus?.text || "未知状态"} + + )} +
+
+ )} +
- {/* 第二行:权限和删除 */} -
- - +
+ {/* 第一行:编辑和重置 */} +
+ + +
+ + {/* 第二行:权限和删除 */} +
+ + +
-
-
-
- - ); - })} -
- )} + + + + ); + })} + + )} + {/* 用户表单模态框 */} 取消 - diff --git a/vite-frontend/src/utils/server-order.ts b/vite-frontend/src/utils/server-order.ts new file mode 100644 index 0000000..3ccb7ca --- /dev/null +++ b/vite-frontend/src/utils/server-order.ts @@ -0,0 +1,20 @@ +interface OrderAvailability { + isAdmin: boolean; + searchKeyword: string; + saving: boolean; + busy: boolean; +} + +export function getOrderDisabledReason({ + isAdmin, + searchKeyword, + saving, + busy, +}: OrderAvailability): string { + if (!isAdmin) return "仅管理员可调整全局顺序。"; + if (searchKeyword.length > 0) return "搜索期间无法排序,请清空搜索后重试。"; + if (saving) return "正在保存顺序,请稍候。"; + if (busy) return "正在加载或更新列表,请稍候。"; + + return ""; +}