mirror of
https://github.com/Sagit-chu/flvx.git
synced 2026-11-13 00:14:22 +08:00
Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b37f4819f0 | |||
| 1f0bc0328b | |||
| 2ad30e4b2f | |||
| 1b326cb00e | |||
| 2ecdba9574 | |||
| 5cc91710c9 | |||
| e35bff8579 | |||
| 2e6cc2f60c | |||
| a403bf4155 | |||
| 2c8ca78923 | |||
| 02b7dd06df | |||
| 229af5dbca | |||
| bd2aba58e2 | |||
| af5d0c88c8 | |||
| 6d76fcac7e | |||
| b81ba70f5f | |||
| 5f15d3d63a | |||
| f8bf0572c4 |
@@ -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())
|
||||||
|
}
|
||||||
@@ -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-resume", h.forwardBatchResume)
|
||||||
mux.HandleFunc("/api/v1/forward/batch-redeploy", h.forwardBatchRedeploy)
|
mux.HandleFunc("/api/v1/forward/batch-redeploy", h.forwardBatchRedeploy)
|
||||||
mux.HandleFunc("/api/v1/forward/batch-change-tunnel", h.forwardBatchChangeTunnel)
|
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/list", h.speedLimitList)
|
||||||
mux.HandleFunc("/api/v1/speed-limit/create", h.speedLimitCreate)
|
mux.HandleFunc("/api/v1/speed-limit/create", h.speedLimitCreate)
|
||||||
mux.HandleFunc("/api/v1/speed-limit/update", h.speedLimitUpdate)
|
mux.HandleFunc("/api/v1/speed-limit/update", h.speedLimitUpdate)
|
||||||
|
|||||||
@@ -180,7 +180,7 @@ func requiresAdmin(path string) bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
switch path {
|
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
|
return true
|
||||||
case "/api/v1/config/update", "/api/v1/config/update-single":
|
case "/api/v1/config/update", "/api/v1/config/update-single":
|
||||||
return true
|
return true
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import "database/sql"
|
|||||||
// User maps to the "user" table. PostgreSQL treats "user" as a reserved
|
// User maps to the "user" table. PostgreSQL treats "user" as a reserved
|
||||||
// word, so TableName() is required for correct quoting.
|
// word, so TableName() is required for correct quoting.
|
||||||
type User struct {
|
type User struct {
|
||||||
|
DisplayOrder int64 `gorm:"not null;default:0"`
|
||||||
ID int64 `gorm:"primaryKey;autoIncrement"`
|
ID int64 `gorm:"primaryKey;autoIncrement"`
|
||||||
User string `gorm:"column:user;type:varchar(100);not null"`
|
User string `gorm:"column:user;type:varchar(100);not null"`
|
||||||
Pwd string `gorm:"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" }
|
func (NftCounterState) TableName() string { return "nft_counter_state" }
|
||||||
|
|
||||||
type SpeedLimit struct {
|
type SpeedLimit struct {
|
||||||
ID int64 `gorm:"primaryKey;autoIncrement"`
|
DisplayOrder int64 `gorm:"not null;default:0"`
|
||||||
Name string `gorm:"type:varchar(100);not null"`
|
ID int64 `gorm:"primaryKey;autoIncrement"`
|
||||||
Speed int `gorm:"not null"`
|
Name string `gorm:"type:varchar(100);not null"`
|
||||||
TunnelID sql.NullInt64 `gorm:"column:tunnel_id"`
|
Speed int `gorm:"not null"`
|
||||||
TunnelName sql.NullString `gorm:"column:tunnel_name;type:varchar(100)"`
|
TunnelID sql.NullInt64 `gorm:"column:tunnel_id"`
|
||||||
CreatedTime int64 `gorm:"column:created_time;not null"`
|
TunnelName sql.NullString `gorm:"column:tunnel_name;type:varchar(100)"`
|
||||||
UpdatedTime sql.NullInt64 `gorm:"column:updated_time"`
|
CreatedTime int64 `gorm:"column:created_time;not null"`
|
||||||
Status int `gorm:"not null"`
|
UpdatedTime sql.NullInt64 `gorm:"column:updated_time"`
|
||||||
|
Status int `gorm:"not null"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (SpeedLimit) TableName() string { return "speed_limit" }
|
func (SpeedLimit) TableName() string { return "speed_limit" }
|
||||||
@@ -417,6 +419,7 @@ type BackupData struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type UserBackup struct {
|
type UserBackup struct {
|
||||||
|
DisplayOrder *int64 `json:"displayOrder,omitempty"`
|
||||||
ID int64 `json:"id"`
|
ID int64 `json:"id"`
|
||||||
User string `json:"user"`
|
User string `json:"user"`
|
||||||
Pwd string `json:"pwd"`
|
Pwd string `json:"pwd"`
|
||||||
@@ -540,14 +543,15 @@ type UserTunnelBackup struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type SpeedLimitBackup struct {
|
type SpeedLimitBackup struct {
|
||||||
ID int64 `json:"id"`
|
DisplayOrder *int64 `json:"displayOrder,omitempty"`
|
||||||
Name string `json:"name"`
|
ID int64 `json:"id"`
|
||||||
Speed int64 `json:"speed"`
|
Name string `json:"name"`
|
||||||
TunnelID *int64 `json:"tunnelId,omitempty"`
|
Speed int64 `json:"speed"`
|
||||||
TunnelName string `json:"tunnelName,omitempty"`
|
TunnelID *int64 `json:"tunnelId,omitempty"`
|
||||||
CreatedTime int64 `json:"createdTime"`
|
TunnelName string `json:"tunnelName,omitempty"`
|
||||||
UpdatedTime int64 `json:"updatedTime,omitempty"`
|
CreatedTime int64 `json:"createdTime"`
|
||||||
Status int `json:"status"`
|
UpdatedTime int64 `json:"updatedTime,omitempty"`
|
||||||
|
Status int `json:"status"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type TunnelGroupBackup struct {
|
type TunnelGroupBackup struct {
|
||||||
|
|||||||
@@ -914,7 +914,7 @@ func (r *Repository) ListUsers() ([]map[string]interface{}, error) {
|
|||||||
return nil, errors.New("repository not initialized")
|
return nil, errors.New("repository not initialized")
|
||||||
}
|
}
|
||||||
var users []model.User
|
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
|
return nil, err
|
||||||
}
|
}
|
||||||
userIDs := make([]int64, 0, len(users))
|
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")
|
return nil, errors.New("repository not initialized")
|
||||||
}
|
}
|
||||||
var limits []model.SpeedLimit
|
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
|
return nil, err
|
||||||
}
|
}
|
||||||
items := make([]map[string]interface{}, 0, len(limits))
|
items := make([]map[string]interface{}, 0, len(limits))
|
||||||
@@ -1001,7 +1001,8 @@ func (r *Repository) ListForwards() ([]map[string]interface{}, error) {
|
|||||||
|
|
||||||
var rows []fwdRow
|
var rows []fwdRow
|
||||||
err := r.db.Model(&model.Forward{}).
|
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 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").
|
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").
|
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))
|
out := make([]model.UserBackup, 0, len(users))
|
||||||
for _, u := range users {
|
for _, u := range users {
|
||||||
b := model.UserBackup{
|
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,
|
ExpTime: u.ExpTime, Flow: u.Flow, FlowMiB: u.FlowMiB, InFlow: u.InFlow, OutFlow: u.OutFlow,
|
||||||
FlowResetTime: u.FlowResetTime, Num: u.Num,
|
FlowResetTime: u.FlowResetTime, Num: u.Num,
|
||||||
CreatedTime: u.CreatedTime, Status: u.Status,
|
CreatedTime: u.CreatedTime, Status: u.Status,
|
||||||
@@ -2289,7 +2291,8 @@ func (r *Repository) exportSpeedLimits() ([]model.SpeedLimitBackup, error) {
|
|||||||
out := make([]model.SpeedLimitBackup, 0, len(sls))
|
out := make([]model.SpeedLimitBackup, 0, len(sls))
|
||||||
for _, sl := range sls {
|
for _, sl := range sls {
|
||||||
b := model.SpeedLimitBackup{
|
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,
|
CreatedTime: sl.CreatedTime, Status: sl.Status,
|
||||||
}
|
}
|
||||||
if sl.UpdatedTime.Valid {
|
if sl.UpdatedTime.Valid {
|
||||||
@@ -2372,7 +2375,24 @@ func (r *Repository) Import(backup *model.BackupData, types []string) (*model.Im
|
|||||||
typeSet[t] = true
|
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()
|
now := unixMilliNow()
|
||||||
|
|
||||||
if typeSet["users"] && len(backup.Users) > 0 {
|
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
|
result.ConfigsImported = count
|
||||||
}
|
}
|
||||||
return nil
|
return advancePostgresBackupSequences(tx, tables)
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -2477,12 +2497,17 @@ func importUsers(tx *gorm.DB, users []model.UserBackup, now int64) (int, error)
|
|||||||
Status: status,
|
Status: status,
|
||||||
PasswordChangedAt: now,
|
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{
|
err = tx.Clauses(clause.OnConflict{
|
||||||
Columns: []clause.Column{{Name: "id"}},
|
Columns: []clause.Column{{Name: "id"}},
|
||||||
DoUpdates: clause.AssignmentColumns([]string{
|
DoUpdates: clause.AssignmentColumns(columns),
|
||||||
"user", "pwd", "role_id", "exp_time", "flow", "flow_mib", "in_flow", "out_flow",
|
|
||||||
"flow_reset_time", "num", "updated_time", "status", "password_changed_at",
|
|
||||||
}),
|
|
||||||
}).Create(&item).Error
|
}).Create(&item).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return count, err
|
return count, err
|
||||||
@@ -2765,11 +2790,16 @@ func importSpeedLimits(tx *gorm.DB, speedLimits []model.SpeedLimitBackup, now in
|
|||||||
UpdatedTime: sql.NullInt64{Int64: now, Valid: true},
|
UpdatedTime: sql.NullInt64{Int64: now, Valid: true},
|
||||||
Status: sl.Status,
|
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{
|
err := tx.Clauses(clause.OnConflict{
|
||||||
Columns: []clause.Column{{Name: "id"}},
|
Columns: []clause.Column{{Name: "id"}},
|
||||||
DoUpdates: clause.AssignmentColumns([]string{
|
DoUpdates: clause.AssignmentColumns(columns),
|
||||||
"name", "speed", "tunnel_id", "tunnel_name", "updated_time", "status",
|
|
||||||
}),
|
|
||||||
}).Create(&item).Error
|
}).Create(&item).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return count, err
|
return count, err
|
||||||
|
|||||||
@@ -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<<attempt, 100*time.Millisecond))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lock every table that import can write, including generated-ID child tables.
|
||||||
|
// No writes happen until the complete set is held. Existing CRUD transactions
|
||||||
|
// can therefore finish regardless of their own parent/child mutation order.
|
||||||
|
// The returned subset contains only tables receiving explicit backup IDs.
|
||||||
|
func lockPostgresBackupTables(tx *gorm.DB, backup *model.BackupData, types map[string]bool) ([]string, error) {
|
||||||
|
if tx.Dialector.Name() != "postgres" {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
chains, grants, ports := 0, 0, 0
|
||||||
|
for _, tunnel := range backup.Tunnels {
|
||||||
|
chains += len(tunnel.ChainTunnels)
|
||||||
|
}
|
||||||
|
for _, permission := range backup.Permissions {
|
||||||
|
grants += len(permission.Grants)
|
||||||
|
}
|
||||||
|
for _, forward := range backup.Forwards {
|
||||||
|
if forward.ForwardPorts != nil {
|
||||||
|
ports++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var tables []string
|
||||||
|
for _, item := range []struct {
|
||||||
|
kind string
|
||||||
|
count int
|
||||||
|
name string
|
||||||
|
explicitIDs bool
|
||||||
|
}{
|
||||||
|
{"users", len(backup.Users), "user", true},
|
||||||
|
{"speedLimits", len(backup.SpeedLimits), "speed_limit", true},
|
||||||
|
{"nodes", len(backup.Nodes), "node", true},
|
||||||
|
{"tunnels", len(backup.Tunnels), "tunnel", true},
|
||||||
|
{"tunnels", chains, "chain_tunnel", true},
|
||||||
|
{"forwards", len(backup.Forwards), "forward", true},
|
||||||
|
{"userTunnels", len(backup.UserTunnels), "user_tunnel", true},
|
||||||
|
{"tunnelGroups", len(backup.TunnelGroups), "tunnel_group", true},
|
||||||
|
{"userGroups", len(backup.UserGroups), "user_group", true},
|
||||||
|
{"permissions", len(backup.Permissions), "group_permission", true},
|
||||||
|
{"permissions", grants, "group_permission_grant", true},
|
||||||
|
{"users", len(backup.Users), "user_quota", false},
|
||||||
|
{"forwards", ports, "forward_port", false},
|
||||||
|
{"tunnelGroups", len(backup.TunnelGroups), "tunnel_group_tunnel", false},
|
||||||
|
{"userGroups", len(backup.UserGroups), "user_group_user", false},
|
||||||
|
{"configs", len(FilterBackupConfigs(backup.Configs)), "vite_config", false},
|
||||||
|
} {
|
||||||
|
if !types[item.kind] || item.count == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Resolve the same relation as normal unqualified repository queries,
|
||||||
|
// even when the first search_path schema has no copy of this table.
|
||||||
|
var relation struct {
|
||||||
|
Schema string
|
||||||
|
Name string
|
||||||
|
}
|
||||||
|
if err := tx.Raw(`SELECT n.nspname AS schema, c.relname AS name
|
||||||
|
FROM pg_catalog.pg_class c JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
|
||||||
|
WHERE c.oid = pg_catalog.to_regclass(?)`, quoteSQLIdentifier(item.name)).Scan(&relation).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if relation.Name == "" {
|
||||||
|
return nil, fmt.Errorf("missing import table %s", item.name)
|
||||||
|
}
|
||||||
|
table := quoteSQLIdentifier(relation.Schema) + "." + quoteSQLIdentifier(relation.Name)
|
||||||
|
if err := tx.Exec("LOCK TABLE " + table + " IN SHARE ROW EXCLUSIVE MODE NOWAIT").Error; err != nil {
|
||||||
|
var pgError interface{ SQLState() string }
|
||||||
|
if errors.As(err, &pgError) && pgError.SQLState() == "55P03" {
|
||||||
|
return nil, fmt.Errorf("%w: %w", errBackupImportBusy, err)
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if item.explicitIDs {
|
||||||
|
tables = append(tables, table)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return tables, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func advancePostgresBackupSequences(tx *gorm.DB, tables []string) error {
|
||||||
|
for _, table := range tables {
|
||||||
|
if err := advancePostgresBackupSequence(tx, table); err != nil {
|
||||||
|
return fmt.Errorf("advance imported %s id sequence: %w", table, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// The caller holds the table's SHARE ROW EXCLUSIVE lock until commit. Unlike
|
||||||
|
// startup's setval repair, RESTART is transactional and invalidates other
|
||||||
|
// connections' cached values. Never return to IDs already allocated, even if
|
||||||
|
// their rows were deleted or their transactions rolled back.
|
||||||
|
func advancePostgresBackupSequence(tx *gorm.DB, table string) error {
|
||||||
|
var maxID int64
|
||||||
|
if err := tx.Raw("SELECT COALESCE(MAX(id), 0) FROM " + table).Scan(&maxID).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if maxID <= 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
type sequenceInfo struct {
|
||||||
|
Schema string
|
||||||
|
Name string
|
||||||
|
Cache int64
|
||||||
|
Increment int64
|
||||||
|
Maximum int64
|
||||||
|
}
|
||||||
|
var sequences []sequenceInfo
|
||||||
|
// Prefer the actual default's catalog dependency: a legacy custom nextval
|
||||||
|
// default need not own its sequence, and the old owned serial may remain.
|
||||||
|
// Identity columns have no pg_attrdef, so use their owned sequence instead.
|
||||||
|
if err := tx.Raw(`WITH defaults AS (
|
||||||
|
SELECT dep.refobjid AS oid FROM pg_catalog.pg_attrdef def
|
||||||
|
JOIN pg_catalog.pg_attribute attr ON attr.attrelid = def.adrelid AND attr.attnum = def.adnum
|
||||||
|
JOIN pg_catalog.pg_depend dep ON dep.classid = 'pg_catalog.pg_attrdef'::regclass
|
||||||
|
AND dep.objid = def.oid AND dep.refclassid = 'pg_catalog.pg_class'::regclass
|
||||||
|
JOIN pg_catalog.pg_sequence seq ON seq.seqrelid = dep.refobjid
|
||||||
|
WHERE def.adrelid = ?::regclass AND attr.attname = 'id'
|
||||||
|
), candidates AS (
|
||||||
|
SELECT oid FROM defaults
|
||||||
|
UNION SELECT pg_catalog.pg_get_serial_sequence(?, 'id')::regclass WHERE NOT EXISTS (SELECT 1 FROM defaults)
|
||||||
|
)
|
||||||
|
SELECT n.nspname AS schema, c.relname AS name,
|
||||||
|
s.seqcache AS cache, s.seqincrement AS increment, s.seqmax AS maximum
|
||||||
|
FROM pg_catalog.pg_sequence s
|
||||||
|
JOIN pg_catalog.pg_class c ON c.oid = s.seqrelid
|
||||||
|
JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
|
||||||
|
WHERE s.seqrelid IN (SELECT oid FROM candidates)`, table, table).Scan(&sequences).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(sequences) != 1 || sequences[0].Increment <= 0 {
|
||||||
|
return fmt.Errorf("missing or ambiguous ascending ID sequence")
|
||||||
|
}
|
||||||
|
sequence := sequences[0]
|
||||||
|
name := quoteSQLIdentifier(sequence.Schema) + "." + quoteSQLIdentifier(sequence.Name)
|
||||||
|
// Reapplying CACHE without changing its setting takes a sequence DDL lock.
|
||||||
|
// Read last_value only after that lock: even direct concurrent nextval calls
|
||||||
|
// must finish first. The table locks already exclude normal INSERTs.
|
||||||
|
if err := tx.Exec(fmt.Sprintf("ALTER SEQUENCE %s CACHE %d", name, sequence.Cache)).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var state struct {
|
||||||
|
LastValue int64
|
||||||
|
IsCalled bool
|
||||||
|
}
|
||||||
|
if err := tx.Raw("SELECT last_value, is_called FROM " + name).Scan(&state).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if state.LastValue < 1 {
|
||||||
|
return fmt.Errorf("ID sequence must allocate positive IDs")
|
||||||
|
}
|
||||||
|
if maxID >= 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
|
||||||
|
}
|
||||||
@@ -37,8 +37,14 @@ func (r *Repository) ListForwardsByTunnelTx(tx *gorm.DB, tunnelID int64) ([]mode
|
|||||||
if tx == nil {
|
if tx == nil {
|
||||||
return nil, errors.New("database unavailable")
|
return nil, errors.New("database unavailable")
|
||||||
}
|
}
|
||||||
var forwards []model.Forward
|
var forwards []struct {
|
||||||
err := tx.Where("tunnel_id = ?", tunnelID).Order("id ASC").Find(&forwards).Error
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -48,7 +54,7 @@ func (r *Repository) ListForwardsByTunnelTx(tx *gorm.DB, tunnelID int64) ([]mode
|
|||||||
rows = append(rows, model.ForwardRecord{
|
rows = append(rows, model.ForwardRecord{
|
||||||
ID: f.ID,
|
ID: f.ID,
|
||||||
UserID: f.UserID,
|
UserID: f.UserID,
|
||||||
UserName: f.UserName,
|
UserName: f.CurrentUserName,
|
||||||
Name: f.Name,
|
Name: f.Name,
|
||||||
TunnelID: f.TunnelID,
|
TunnelID: f.TunnelID,
|
||||||
RemoteAddr: f.RemoteAddr,
|
RemoteAddr: f.RemoteAddr,
|
||||||
|
|||||||
@@ -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
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -65,7 +65,7 @@ func (r *Repository) CreateUser(username, pwdHash string, roleID int, expTime, f
|
|||||||
Status: status,
|
Status: status,
|
||||||
PasswordChangedAt: now,
|
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 0, err
|
||||||
}
|
}
|
||||||
return user.ID, nil
|
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},
|
UpdatedTime: sql.NullInt64{Int64: now, Valid: true},
|
||||||
Status: status,
|
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 0, err
|
||||||
}
|
}
|
||||||
return sl.ID, nil
|
return sl.ID, nil
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
@@ -619,3 +619,13 @@ export const assignMonitorPermission = (userId: number) =>
|
|||||||
|
|
||||||
export const removeMonitorPermission = (userId: number) =>
|
export const removeMonitorPermission = (userId: number) =>
|
||||||
Network.post("/monitor/permission/remove", { userId });
|
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);
|
||||||
|
|||||||
@@ -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<ReactNode>(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 (
|
||||||
|
<HandleContext.Provider
|
||||||
|
value={
|
||||||
|
<button
|
||||||
|
ref={setActivatorNodeRef}
|
||||||
|
{...attributes}
|
||||||
|
{...listeners}
|
||||||
|
aria-label={`拖动排序:${label}`}
|
||||||
|
className="shrink-0 rounded p-1 text-default-500 hover:bg-default-100 focus-visible:outline focus-visible:outline-2 focus-visible:outline-primary disabled:opacity-40 disabled:cursor-not-allowed cursor-grab active:cursor-grabbing"
|
||||||
|
disabled={disabled}
|
||||||
|
style={{ touchAction: "none" }}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<GripVertical aria-hidden="true" className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Component
|
||||||
|
ref={setNodeRef}
|
||||||
|
className={row ? undefined : "h-full min-w-0"}
|
||||||
|
style={{
|
||||||
|
transform: CSS.Transform.toString(transform),
|
||||||
|
transition,
|
||||||
|
opacity: isDragging ? 0.5 : 1,
|
||||||
|
position: "relative",
|
||||||
|
zIndex: isDragging ? 1 : undefined,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Component>
|
||||||
|
</HandleContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<DndContext
|
||||||
|
accessibility={{
|
||||||
|
screenReaderInstructions: {
|
||||||
|
draggable: "按空格开始排序,方向键移动,再按空格保存,Escape 取消。",
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
collisionDetection={closestCenter}
|
||||||
|
sensors={sensors}
|
||||||
|
onDragEnd={onDragEnd}
|
||||||
|
onDragStart={onDragStart}
|
||||||
|
>
|
||||||
|
<SortableContext items={ids} strategy={rectSortingStrategy}>
|
||||||
|
{children}
|
||||||
|
</SortableContext>
|
||||||
|
</DndContext>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function OrderStatus({
|
||||||
|
reason,
|
||||||
|
error,
|
||||||
|
retry,
|
||||||
|
}: {
|
||||||
|
reason: string;
|
||||||
|
error: string;
|
||||||
|
retry: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="mb-3 text-sm text-default-500">
|
||||||
|
<p role="status">
|
||||||
|
{reason || "拖动手柄调整全局顺序;键盘可按空格、方向键、空格完成排序。"}
|
||||||
|
</p>
|
||||||
|
{error && (
|
||||||
|
<p className="text-danger mt-2" role="alert">
|
||||||
|
{error}{" "}
|
||||||
|
<button className="underline" type="button" onClick={retry}>
|
||||||
|
重新加载
|
||||||
|
</button>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<T extends { id: number }>(
|
||||||
|
fetchItems: () => Promise<T[]>,
|
||||||
|
saveOrder: (payload: OrderUpdatePayload) => Promise<Result>,
|
||||||
|
) {
|
||||||
|
const [items, setItems] = useState<T[]>([]);
|
||||||
|
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<T[] | null>(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,
|
||||||
|
};
|
||||||
|
}
|
||||||
+215
-187
@@ -1,12 +1,17 @@
|
|||||||
import { useState, useEffect, useMemo } from "react";
|
import { useState, useMemo } from "react";
|
||||||
import toast from "react-hot-toast";
|
import toast from "react-hot-toast";
|
||||||
import { LayoutGrid, List } from "lucide-react";
|
import { LayoutGrid, List } from "lucide-react";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
AnimatedPage,
|
OrderHandle,
|
||||||
StaggerList,
|
OrderItem,
|
||||||
StaggerItem,
|
OrderList,
|
||||||
} from "@/components/animated-page";
|
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 { SearchBar } from "@/components/search-bar";
|
||||||
import { Card, CardBody, CardHeader } from "@/shadcn-bridge/heroui/card";
|
import { Card, CardBody, CardHeader } from "@/shadcn-bridge/heroui/card";
|
||||||
import { Button } from "@/shadcn-bridge/heroui/button";
|
import { Button } from "@/shadcn-bridge/heroui/button";
|
||||||
@@ -24,11 +29,11 @@ import {
|
|||||||
TableHeader,
|
TableHeader,
|
||||||
TableColumn,
|
TableColumn,
|
||||||
TableBody,
|
TableBody,
|
||||||
TableRow,
|
|
||||||
TableCell,
|
TableCell,
|
||||||
} from "@/shadcn-bridge/heroui/table";
|
} from "@/shadcn-bridge/heroui/table";
|
||||||
import {
|
import {
|
||||||
createSpeedLimit,
|
createSpeedLimit,
|
||||||
|
updateSpeedLimitOrder,
|
||||||
getSpeedLimitList,
|
getSpeedLimitList,
|
||||||
updateSpeedLimit,
|
updateSpeedLimit,
|
||||||
deleteSpeedLimit,
|
deleteSpeedLimit,
|
||||||
@@ -52,9 +57,17 @@ interface SpeedLimitForm {
|
|||||||
status: number;
|
status: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const fetchOrderedRules = async (): Promise<SpeedLimitRule[]> => {
|
||||||
|
const response = await getSpeedLimitList();
|
||||||
|
|
||||||
|
if (response.code !== 0) throw new Error(response.msg);
|
||||||
|
|
||||||
|
return (response.data || []) as SpeedLimitRule[];
|
||||||
|
};
|
||||||
|
|
||||||
export default function LimitPage() {
|
export default function LimitPage() {
|
||||||
const [loading, setLoading] = useState(true);
|
const order = useServerOrder(fetchOrderedRules, updateSpeedLimitOrder);
|
||||||
const [rules, setRules] = useState<SpeedLimitRule[]>([]);
|
const { items: rules, loading, reload: loadData } = order;
|
||||||
const [searchKeyword, setSearchKeyword] = useLocalStorageState(
|
const [searchKeyword, setSearchKeyword] = useLocalStorageState(
|
||||||
"limit-search-keyword",
|
"limit-search-keyword",
|
||||||
"",
|
"",
|
||||||
@@ -92,28 +105,6 @@ export default function LimitPage() {
|
|||||||
// 表单验证错误
|
// 表单验证错误
|
||||||
const [errors, setErrors] = useState<{ [key: string]: string }>({});
|
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 validateForm = (): boolean => {
|
||||||
const newErrors: { [key: string]: string } = {};
|
const newErrors: { [key: string]: string } = {};
|
||||||
@@ -166,7 +157,7 @@ export default function LimitPage() {
|
|||||||
|
|
||||||
// 确认删除规则
|
// 确认删除规则
|
||||||
const confirmDelete = async () => {
|
const confirmDelete = async () => {
|
||||||
if (!ruleToDelete) return;
|
if (!ruleToDelete || order.saving || deleteLoading) return;
|
||||||
|
|
||||||
setDeleteLoading(true);
|
setDeleteLoading(true);
|
||||||
try {
|
try {
|
||||||
@@ -175,7 +166,7 @@ export default function LimitPage() {
|
|||||||
if (res.code === 0) {
|
if (res.code === 0) {
|
||||||
toast.success("删除成功");
|
toast.success("删除成功");
|
||||||
setDeleteModalOpen(false);
|
setDeleteModalOpen(false);
|
||||||
loadData();
|
await loadData();
|
||||||
} else {
|
} else {
|
||||||
toast.error(res.msg || "删除失败");
|
toast.error(res.msg || "删除失败");
|
||||||
}
|
}
|
||||||
@@ -188,7 +179,7 @@ export default function LimitPage() {
|
|||||||
|
|
||||||
// 提交表单
|
// 提交表单
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
if (!validateForm()) return;
|
if (order.saving || !validateForm()) return;
|
||||||
|
|
||||||
setSubmitLoading(true);
|
setSubmitLoading(true);
|
||||||
try {
|
try {
|
||||||
@@ -215,7 +206,7 @@ export default function LimitPage() {
|
|||||||
if (res.code === 0) {
|
if (res.code === 0) {
|
||||||
toast.success(isEdit ? "修改成功" : "创建成功");
|
toast.success(isEdit ? "修改成功" : "创建成功");
|
||||||
setModalOpen(false);
|
setModalOpen(false);
|
||||||
loadData();
|
await loadData();
|
||||||
} else {
|
} else {
|
||||||
toast.error(res.msg || "操作失败");
|
toast.error(res.msg || "操作失败");
|
||||||
}
|
}
|
||||||
@@ -226,9 +217,13 @@ export default function LimitPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (loading) {
|
const orderReason = getOrderDisabledReason({
|
||||||
return <PageLoadingState message="正在加载..." />;
|
isAdmin: isAdmin(),
|
||||||
}
|
searchKeyword,
|
||||||
|
saving: order.saving,
|
||||||
|
busy: loading || submitLoading || deleteLoading,
|
||||||
|
});
|
||||||
|
const orderDisabled = Boolean(orderReason);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AnimatedPage className="px-3 lg:px-6 py-8">
|
<AnimatedPage className="px-3 lg:px-6 py-8">
|
||||||
@@ -263,172 +258,203 @@ export default function LimitPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 统一卡片网格 */}
|
<OrderStatus
|
||||||
{filteredRules.length > 0 ? (
|
error={order.error}
|
||||||
viewMode === "list" ? (
|
reason={orderReason}
|
||||||
<Card>
|
retry={() => {
|
||||||
<Table
|
if (!order.saving) void loadData();
|
||||||
aria-label="限速规则列表"
|
}}
|
||||||
className="overflow-x-auto min-w-full"
|
/>
|
||||||
classNames={{
|
<OrderList
|
||||||
wrapper:
|
key={`${viewMode}:${searchKeyword}:${orderDisabled}`}
|
||||||
"bg-transparent p-0 shadow-none border-none overflow-auto rounded-[24px]",
|
ids={filteredRules.map((rule) => rule.id)}
|
||||||
th: "bg-transparent text-default-600 font-semibold text-sm border-b border-white/20 dark:border-white/10 py-3 uppercase tracking-wider first:rounded-tl-[24px] last:rounded-tr-[24px]",
|
onDragEnd={(event) => {
|
||||||
td: "py-3 border-b border-divider/50 group-data-[last=true]:border-b-0",
|
void order.reorder(event, orderDisabled);
|
||||||
tr: "hover:bg-white/40 dark:hover:bg-white/10 transition-colors",
|
}}
|
||||||
}}
|
onDragStart={order.onDragStart}
|
||||||
>
|
>
|
||||||
<TableHeader>
|
{/* 统一卡片网格 */}
|
||||||
<TableColumn>规则名称</TableColumn>
|
{loading ? (
|
||||||
<TableColumn>速度限制</TableColumn>
|
<PageLoadingState message="正在加载..." />
|
||||||
<TableColumn>操作</TableColumn>
|
) : filteredRules.length > 0 ? (
|
||||||
</TableHeader>
|
viewMode === "list" ? (
|
||||||
<TableBody items={filteredRules}>
|
<Card>
|
||||||
{(rule) => (
|
<Table
|
||||||
<TableRow key={rule.id}>
|
aria-label="限速规则列表"
|
||||||
<TableCell>
|
className="overflow-x-auto min-w-full"
|
||||||
<div className="flex items-center gap-2">
|
classNames={{
|
||||||
<div
|
wrapper:
|
||||||
className={`shrink-0 w-2 h-2 rounded-full ${
|
"bg-transparent p-0 shadow-none border-none overflow-auto rounded-[24px]",
|
||||||
rule.status === 1 ? "bg-success" : "bg-danger"
|
th: "bg-transparent text-default-600 font-semibold text-sm border-b border-white/20 dark:border-white/10 py-3 uppercase tracking-wider first:rounded-tl-[24px] last:rounded-tr-[24px]",
|
||||||
}`}
|
td: "py-3 border-b border-divider/50 group-data-[last=true]:border-b-0",
|
||||||
/>
|
tr: "hover:bg-white/40 dark:hover:bg-white/10 transition-colors",
|
||||||
<span className="font-medium text-foreground text-sm">
|
}}
|
||||||
{rule.name}
|
>
|
||||||
|
<TableHeader>
|
||||||
|
<TableColumn>规则名称</TableColumn>
|
||||||
|
<TableColumn>速度限制</TableColumn>
|
||||||
|
<TableColumn>操作</TableColumn>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody items={filteredRules}>
|
||||||
|
{(rule) => (
|
||||||
|
<OrderItem
|
||||||
|
key={rule.id}
|
||||||
|
row
|
||||||
|
disabled={orderDisabled}
|
||||||
|
id={rule.id}
|
||||||
|
label={rule.name}
|
||||||
|
>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<OrderHandle />
|
||||||
|
<div
|
||||||
|
className={`shrink-0 w-2 h-2 rounded-full ${
|
||||||
|
rule.status === 1 ? "bg-success" : "bg-danger"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
<span className="font-medium text-foreground text-sm">
|
||||||
|
{rule.name}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<span className="text-sm font-mono text-default-600">
|
||||||
|
{rule.speed} Mbps
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</TableCell>
|
||||||
</TableCell>
|
<TableCell>
|
||||||
<TableCell>
|
<div className="flex flex-wrap items-center gap-1.5 min-w-max">
|
||||||
<span className="text-sm font-mono text-default-600">
|
<Button
|
||||||
{rule.speed} Mbps
|
className="h-6 px-2 min-w-0 text-xs bg-indigo-50 text-indigo-600 hover:bg-indigo-100 dark:bg-indigo-950/30 dark:text-indigo-400"
|
||||||
</span>
|
size="sm"
|
||||||
</TableCell>
|
variant="flat"
|
||||||
<TableCell>
|
onPress={() => handleEdit(rule)}
|
||||||
<div className="flex flex-wrap items-center gap-1.5 min-w-max">
|
>
|
||||||
<Button
|
编辑
|
||||||
className="h-6 px-2 min-w-0 text-xs bg-indigo-50 text-indigo-600 hover:bg-indigo-100 dark:bg-indigo-950/30 dark:text-indigo-400"
|
</Button>
|
||||||
|
<Button
|
||||||
|
className="h-6 px-2 min-w-0 text-xs bg-rose-50 text-rose-600 hover:bg-rose-100 dark:bg-rose-950/30 dark:text-rose-400"
|
||||||
|
size="sm"
|
||||||
|
variant="flat"
|
||||||
|
onPress={() => handleDelete(rule)}
|
||||||
|
>
|
||||||
|
删除
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</OrderItem>
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<StaggerList className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-4">
|
||||||
|
{filteredRules.map((rule) => (
|
||||||
|
<OrderItem
|
||||||
|
key={rule.id}
|
||||||
|
disabled={orderDisabled}
|
||||||
|
id={rule.id}
|
||||||
|
label={rule.name}
|
||||||
|
>
|
||||||
|
<Card className="shadow-sm overflow-hidden h-full">
|
||||||
|
<CardHeader className="pb-2 md:pb-2">
|
||||||
|
<OrderHandle />
|
||||||
|
<div className="flex justify-between items-start w-full">
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold text-foreground">
|
||||||
|
{rule.name}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<Chip
|
||||||
|
color={rule.status === 1 ? "success" : "danger"}
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="flat"
|
variant="flat"
|
||||||
|
>
|
||||||
|
{rule.status === 1 ? "运行" : "异常"}
|
||||||
|
</Chip>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardBody className="pt-0 pb-3 md:pt-0 md:pb-3">
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<span className="text-small text-default-600">
|
||||||
|
速度限制
|
||||||
|
</span>
|
||||||
|
<Chip color="secondary" size="sm" variant="flat">
|
||||||
|
{rule.speed} Mbps
|
||||||
|
</Chip>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-2 mt-4">
|
||||||
|
<Button
|
||||||
|
className="flex-1"
|
||||||
|
color="primary"
|
||||||
|
size="sm"
|
||||||
|
startContent={
|
||||||
|
<svg
|
||||||
|
aria-hidden="true"
|
||||||
|
className="w-4 h-4"
|
||||||
|
fill="currentColor"
|
||||||
|
viewBox="0 0 20 20"
|
||||||
|
>
|
||||||
|
<path d="M13.586 3.586a2 2 0 112.828 2.828l-.793.793-2.828-2.828.793-.793zM11.379 5.793L3 14.172V17h2.828l8.38-8.379-2.83-2.828z" />
|
||||||
|
</svg>
|
||||||
|
}
|
||||||
|
variant="flat"
|
||||||
onPress={() => handleEdit(rule)}
|
onPress={() => handleEdit(rule)}
|
||||||
>
|
>
|
||||||
编辑
|
编辑
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
className="h-6 px-2 min-w-0 text-xs bg-rose-50 text-rose-600 hover:bg-rose-100 dark:bg-rose-950/30 dark:text-rose-400"
|
className="flex-1"
|
||||||
|
color="danger"
|
||||||
size="sm"
|
size="sm"
|
||||||
|
startContent={
|
||||||
|
<svg
|
||||||
|
aria-hidden="true"
|
||||||
|
className="w-4 h-4"
|
||||||
|
fill="currentColor"
|
||||||
|
viewBox="0 0 20 20"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
clipRule="evenodd"
|
||||||
|
d="M9 2a1 1 0 000 2h2a1 1 0 100-2H9z"
|
||||||
|
fillRule="evenodd"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
clipRule="evenodd"
|
||||||
|
d="M10 18a8 8 0 100-16 8 8 0 000 16zM8 7a1 1 0 012 0v4a1 1 0 11-2 0V7zM12 7a1 1 0 012 0v4a1 1 0 11-2 0V7z"
|
||||||
|
fillRule="evenodd"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
}
|
||||||
variant="flat"
|
variant="flat"
|
||||||
onPress={() => handleDelete(rule)}
|
onPress={() => handleDelete(rule)}
|
||||||
>
|
>
|
||||||
删除
|
删除
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</CardBody>
|
||||||
</TableRow>
|
</Card>
|
||||||
)}
|
</OrderItem>
|
||||||
</TableBody>
|
))}
|
||||||
</Table>
|
</StaggerList>
|
||||||
</Card>
|
)
|
||||||
) : (
|
) : (
|
||||||
<StaggerList className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-4">
|
/* 空状态 */
|
||||||
{filteredRules.map((rule) => (
|
<Card className="shadow-sm bg-default-50/50">
|
||||||
<StaggerItem key={rule.id}>
|
<CardBody className="text-center py-20 flex flex-col items-center justify-center min-h-[240px]">
|
||||||
<Card className="shadow-sm overflow-hidden h-full">
|
<h3 className="text-xl font-medium text-foreground tracking-tight mb-2">
|
||||||
<CardHeader className="pb-2 md:pb-2">
|
暂无限速规则
|
||||||
<div className="flex justify-between items-start w-full">
|
</h3>
|
||||||
<div>
|
<p className="text-default-500 text-sm max-w-xs mx-auto leading-relaxed">
|
||||||
<h3 className="font-semibold text-foreground">
|
还没有创建任何限速规则,点击上方按钮开始创建
|
||||||
{rule.name}
|
</p>
|
||||||
</h3>
|
</CardBody>
|
||||||
</div>
|
</Card>
|
||||||
<Chip
|
)}
|
||||||
color={rule.status === 1 ? "success" : "danger"}
|
</OrderList>
|
||||||
size="sm"
|
|
||||||
variant="flat"
|
|
||||||
>
|
|
||||||
{rule.status === 1 ? "运行" : "异常"}
|
|
||||||
</Chip>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CardBody className="pt-0 pb-3 md:pt-0 md:pb-3">
|
|
||||||
<div className="space-y-3">
|
|
||||||
<div className="flex justify-between items-center">
|
|
||||||
<span className="text-small text-default-600">
|
|
||||||
速度限制
|
|
||||||
</span>
|
|
||||||
<Chip color="secondary" size="sm" variant="flat">
|
|
||||||
{rule.speed} Mbps
|
|
||||||
</Chip>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex gap-2 mt-4">
|
|
||||||
<Button
|
|
||||||
className="flex-1"
|
|
||||||
color="primary"
|
|
||||||
size="sm"
|
|
||||||
startContent={
|
|
||||||
<svg
|
|
||||||
aria-hidden="true"
|
|
||||||
className="w-4 h-4"
|
|
||||||
fill="currentColor"
|
|
||||||
viewBox="0 0 20 20"
|
|
||||||
>
|
|
||||||
<path d="M13.586 3.586a2 2 0 112.828 2.828l-.793.793-2.828-2.828.793-.793zM11.379 5.793L3 14.172V17h2.828l8.38-8.379-2.83-2.828z" />
|
|
||||||
</svg>
|
|
||||||
}
|
|
||||||
variant="flat"
|
|
||||||
onPress={() => handleEdit(rule)}
|
|
||||||
>
|
|
||||||
编辑
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
className="flex-1"
|
|
||||||
color="danger"
|
|
||||||
size="sm"
|
|
||||||
startContent={
|
|
||||||
<svg
|
|
||||||
aria-hidden="true"
|
|
||||||
className="w-4 h-4"
|
|
||||||
fill="currentColor"
|
|
||||||
viewBox="0 0 20 20"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
clipRule="evenodd"
|
|
||||||
d="M9 2a1 1 0 000 2h2a1 1 0 100-2H9z"
|
|
||||||
fillRule="evenodd"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
clipRule="evenodd"
|
|
||||||
d="M10 18a8 8 0 100-16 8 8 0 000 16zM8 7a1 1 0 012 0v4a1 1 0 11-2 0V7zM12 7a1 1 0 012 0v4a1 1 0 11-2 0V7z"
|
|
||||||
fillRule="evenodd"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
}
|
|
||||||
variant="flat"
|
|
||||||
onPress={() => handleDelete(rule)}
|
|
||||||
>
|
|
||||||
删除
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</CardBody>
|
|
||||||
</Card>
|
|
||||||
</StaggerItem>
|
|
||||||
))}
|
|
||||||
</StaggerList>
|
|
||||||
)
|
|
||||||
) : (
|
|
||||||
/* 空状态 */
|
|
||||||
<Card className="shadow-sm bg-default-50/50">
|
|
||||||
<CardBody className="text-center py-20 flex flex-col items-center justify-center min-h-[240px]">
|
|
||||||
<h3 className="text-xl font-medium text-foreground tracking-tight mb-2">
|
|
||||||
暂无限速规则
|
|
||||||
</h3>
|
|
||||||
<p className="text-default-500 text-sm max-w-xs mx-auto leading-relaxed">
|
|
||||||
还没有创建任何限速规则,点击上方按钮开始创建
|
|
||||||
</p>
|
|
||||||
</CardBody>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 新增/编辑模态框 */}
|
{/* 新增/编辑模态框 */}
|
||||||
<Modal
|
<Modal
|
||||||
@@ -497,6 +523,7 @@ export default function LimitPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
color="primary"
|
color="primary"
|
||||||
|
isDisabled={order.saving}
|
||||||
isLoading={submitLoading}
|
isLoading={submitLoading}
|
||||||
onPress={handleSubmit}
|
onPress={handleSubmit}
|
||||||
>
|
>
|
||||||
@@ -544,6 +571,7 @@ export default function LimitPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
color="danger"
|
color="danger"
|
||||||
|
isDisabled={order.saving}
|
||||||
isLoading={deleteLoading}
|
isLoading={deleteLoading}
|
||||||
onPress={confirmDelete}
|
onPress={confirmDelete}
|
||||||
>
|
>
|
||||||
|
|||||||
+491
-495
File diff suppressed because it is too large
Load Diff
@@ -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 "";
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user