mirror of
https://github.com/Sagit-chu/flvx.git
synced 2026-11-15 17:14:23 +08:00
feat: show current owner names and persist user and limit order (#576)
Resolve current forwarding-rule owner names by user ID. Add administrator-managed persistent user and speed-limit ordering, backup compatibility, and conflict recovery. Repair PostgreSQL explicit-ID import sequences transactionally. Closes #574 Closes #575
This commit is contained in:
@@ -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-redeploy", h.forwardBatchRedeploy)
|
||||
mux.HandleFunc("/api/v1/forward/batch-change-tunnel", h.forwardBatchChangeTunnel)
|
||||
mux.HandleFunc("/api/v1/user/update-order", h.userDisplayOrder)
|
||||
mux.HandleFunc("/api/v1/speed-limit/update-order", h.speedLimitDisplayOrder)
|
||||
mux.HandleFunc("/api/v1/speed-limit/list", h.speedLimitList)
|
||||
mux.HandleFunc("/api/v1/speed-limit/create", h.speedLimitCreate)
|
||||
mux.HandleFunc("/api/v1/speed-limit/update", h.speedLimitUpdate)
|
||||
|
||||
@@ -180,7 +180,7 @@ func requiresAdmin(path string) bool {
|
||||
}
|
||||
|
||||
switch path {
|
||||
case "/api/v1/user/create", "/api/v1/user/list", "/api/v1/user/update", "/api/v1/user/delete", "/api/v1/user/reset":
|
||||
case "/api/v1/user/update-order", "/api/v1/user/create", "/api/v1/user/list", "/api/v1/user/update", "/api/v1/user/delete", "/api/v1/user/reset":
|
||||
return true
|
||||
case "/api/v1/config/update", "/api/v1/config/update-single":
|
||||
return true
|
||||
|
||||
@@ -10,6 +10,7 @@ import "database/sql"
|
||||
// User maps to the "user" table. PostgreSQL treats "user" as a reserved
|
||||
// word, so TableName() is required for correct quoting.
|
||||
type User struct {
|
||||
DisplayOrder int64 `gorm:"not null;default:0"`
|
||||
ID int64 `gorm:"primaryKey;autoIncrement"`
|
||||
User string `gorm:"column:user;type:varchar(100);not null"`
|
||||
Pwd string `gorm:"type:varchar(100);not null"`
|
||||
@@ -151,14 +152,15 @@ type NftCounterState struct {
|
||||
func (NftCounterState) TableName() string { return "nft_counter_state" }
|
||||
|
||||
type SpeedLimit struct {
|
||||
ID int64 `gorm:"primaryKey;autoIncrement"`
|
||||
Name string `gorm:"type:varchar(100);not null"`
|
||||
Speed int `gorm:"not null"`
|
||||
TunnelID sql.NullInt64 `gorm:"column:tunnel_id"`
|
||||
TunnelName sql.NullString `gorm:"column:tunnel_name;type:varchar(100)"`
|
||||
CreatedTime int64 `gorm:"column:created_time;not null"`
|
||||
UpdatedTime sql.NullInt64 `gorm:"column:updated_time"`
|
||||
Status int `gorm:"not null"`
|
||||
DisplayOrder int64 `gorm:"not null;default:0"`
|
||||
ID int64 `gorm:"primaryKey;autoIncrement"`
|
||||
Name string `gorm:"type:varchar(100);not null"`
|
||||
Speed int `gorm:"not null"`
|
||||
TunnelID sql.NullInt64 `gorm:"column:tunnel_id"`
|
||||
TunnelName sql.NullString `gorm:"column:tunnel_name;type:varchar(100)"`
|
||||
CreatedTime int64 `gorm:"column:created_time;not null"`
|
||||
UpdatedTime sql.NullInt64 `gorm:"column:updated_time"`
|
||||
Status int `gorm:"not null"`
|
||||
}
|
||||
|
||||
func (SpeedLimit) TableName() string { return "speed_limit" }
|
||||
@@ -417,6 +419,7 @@ type BackupData struct {
|
||||
}
|
||||
|
||||
type UserBackup struct {
|
||||
DisplayOrder *int64 `json:"displayOrder,omitempty"`
|
||||
ID int64 `json:"id"`
|
||||
User string `json:"user"`
|
||||
Pwd string `json:"pwd"`
|
||||
@@ -540,14 +543,15 @@ type UserTunnelBackup struct {
|
||||
}
|
||||
|
||||
type SpeedLimitBackup struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Speed int64 `json:"speed"`
|
||||
TunnelID *int64 `json:"tunnelId,omitempty"`
|
||||
TunnelName string `json:"tunnelName,omitempty"`
|
||||
CreatedTime int64 `json:"createdTime"`
|
||||
UpdatedTime int64 `json:"updatedTime,omitempty"`
|
||||
Status int `json:"status"`
|
||||
DisplayOrder *int64 `json:"displayOrder,omitempty"`
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Speed int64 `json:"speed"`
|
||||
TunnelID *int64 `json:"tunnelId,omitempty"`
|
||||
TunnelName string `json:"tunnelName,omitempty"`
|
||||
CreatedTime int64 `json:"createdTime"`
|
||||
UpdatedTime int64 `json:"updatedTime,omitempty"`
|
||||
Status int `json:"status"`
|
||||
}
|
||||
|
||||
type TunnelGroupBackup struct {
|
||||
|
||||
@@ -914,7 +914,7 @@ func (r *Repository) ListUsers() ([]map[string]interface{}, error) {
|
||||
return nil, errors.New("repository not initialized")
|
||||
}
|
||||
var users []model.User
|
||||
if err := r.db.Where("role_id != ?", 0).Order("id DESC").Find(&users).Error; err != nil {
|
||||
if err := r.db.Where("role_id != ?", 0).Order("display_order ASC, id DESC").Find(&users).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
userIDs := make([]int64, 0, len(users))
|
||||
@@ -954,7 +954,7 @@ func (r *Repository) ListSpeedLimits() ([]map[string]interface{}, error) {
|
||||
return nil, errors.New("repository not initialized")
|
||||
}
|
||||
var limits []model.SpeedLimit
|
||||
if err := r.db.Order("id DESC").Find(&limits).Error; err != nil {
|
||||
if err := r.db.Order("display_order ASC, id DESC").Find(&limits).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items := make([]map[string]interface{}, 0, len(limits))
|
||||
@@ -1001,7 +1001,8 @@ func (r *Repository) ListForwards() ([]map[string]interface{}, error) {
|
||||
|
||||
var rows []fwdRow
|
||||
err := r.db.Model(&model.Forward{}).
|
||||
Select("forward.id, forward.user_id, forward.user_name, forward.name, forward.tunnel_id, COALESCE(tunnel.name, '') AS tunnel_name, COALESCE(tunnel.traffic_ratio, 1.0) AS traffic_ratio, forward.remote_addr, COALESCE(forward.strategy, 'fifo') AS strategy, forward.in_flow, forward.out_flow, forward.created_time, forward.status, forward.inx, forward.speed_id, forward.max_conn, forward.ip_max_conn, forward.ip_speed_id, COALESCE(ip_speed_limit.name, '') AS ip_speed_limit_name, forward.proxy_protocol, forward.proxy_protocol_receive, forward.proxy_protocol_send").
|
||||
Select("forward.id, forward.user_id, COALESCE(owner.\"user\", forward.user_name) AS user_name, forward.name, forward.tunnel_id, COALESCE(tunnel.name, '') AS tunnel_name, COALESCE(tunnel.traffic_ratio, 1.0) AS traffic_ratio, forward.remote_addr, COALESCE(forward.strategy, 'fifo') AS strategy, forward.in_flow, forward.out_flow, forward.created_time, forward.status, forward.inx, forward.speed_id, forward.max_conn, forward.ip_max_conn, forward.ip_speed_id, COALESCE(ip_speed_limit.name, '') AS ip_speed_limit_name, forward.proxy_protocol, forward.proxy_protocol_receive, forward.proxy_protocol_send").
|
||||
Joins(`LEFT JOIN "user" AS owner ON owner.id = forward.user_id`).
|
||||
Joins("LEFT JOIN tunnel ON tunnel.id = forward.tunnel_id").
|
||||
Joins("LEFT JOIN speed_limit AS ip_speed_limit ON ip_speed_limit.id = forward.ip_speed_id").
|
||||
Order("forward.inx ASC, forward.id ASC").
|
||||
@@ -2092,7 +2093,8 @@ func (r *Repository) exportUsers() ([]model.UserBackup, error) {
|
||||
out := make([]model.UserBackup, 0, len(users))
|
||||
for _, u := range users {
|
||||
b := model.UserBackup{
|
||||
ID: u.ID, User: u.User, Pwd: u.Pwd, RoleID: u.RoleID,
|
||||
DisplayOrder: &u.DisplayOrder,
|
||||
ID: u.ID, User: u.User, Pwd: u.Pwd, RoleID: u.RoleID,
|
||||
ExpTime: u.ExpTime, Flow: u.Flow, FlowMiB: u.FlowMiB, InFlow: u.InFlow, OutFlow: u.OutFlow,
|
||||
FlowResetTime: u.FlowResetTime, Num: u.Num,
|
||||
CreatedTime: u.CreatedTime, Status: u.Status,
|
||||
@@ -2289,7 +2291,8 @@ func (r *Repository) exportSpeedLimits() ([]model.SpeedLimitBackup, error) {
|
||||
out := make([]model.SpeedLimitBackup, 0, len(sls))
|
||||
for _, sl := range sls {
|
||||
b := model.SpeedLimitBackup{
|
||||
ID: sl.ID, Name: sl.Name, Speed: int64(sl.Speed),
|
||||
DisplayOrder: &sl.DisplayOrder,
|
||||
ID: sl.ID, Name: sl.Name, Speed: int64(sl.Speed),
|
||||
CreatedTime: sl.CreatedTime, Status: sl.Status,
|
||||
}
|
||||
if sl.UpdatedTime.Valid {
|
||||
@@ -2372,7 +2375,24 @@ func (r *Repository) Import(backup *model.BackupData, types []string) (*model.Im
|
||||
typeSet[t] = true
|
||||
}
|
||||
|
||||
err := r.db.Transaction(func(tx *gorm.DB) error {
|
||||
err := backupImportTransaction(r.db, func(tx *gorm.DB) error {
|
||||
result = &model.ImportResult{}
|
||||
tables, err := lockPostgresBackupTables(tx, backup, typeSet)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// SQLite still needs its writer reservation for display order.
|
||||
// PostgreSQL membership locks were taken with all imported tables above.
|
||||
if tx.Dialector.Name() != "postgres" && typeSet["users"] {
|
||||
if err := lockDisplayOrder(tx, "user"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if tx.Dialector.Name() != "postgres" && typeSet["speedLimits"] {
|
||||
if err := lockDisplayOrder(tx, "speed_limit"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
now := unixMilliNow()
|
||||
|
||||
if typeSet["users"] && len(backup.Users) > 0 {
|
||||
@@ -2445,7 +2465,7 @@ func (r *Repository) Import(backup *model.BackupData, types []string) (*model.Im
|
||||
}
|
||||
result.ConfigsImported = count
|
||||
}
|
||||
return nil
|
||||
return advancePostgresBackupSequences(tx, tables)
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -2477,12 +2497,17 @@ func importUsers(tx *gorm.DB, users []model.UserBackup, now int64) (int, error)
|
||||
Status: status,
|
||||
PasswordChangedAt: now,
|
||||
}
|
||||
// An omitted old-backup rank preserves existing order on conflict.
|
||||
// New legacy records use rank zero and ID descending, matching upgrades.
|
||||
columns := []string{"user", "pwd", "role_id", "exp_time", "flow", "flow_mib", "in_flow", "out_flow",
|
||||
"flow_reset_time", "num", "updated_time", "status", "password_changed_at"}
|
||||
if u.DisplayOrder != nil {
|
||||
item.DisplayOrder = *u.DisplayOrder
|
||||
columns = append(columns, "display_order")
|
||||
}
|
||||
err = tx.Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "id"}},
|
||||
DoUpdates: clause.AssignmentColumns([]string{
|
||||
"user", "pwd", "role_id", "exp_time", "flow", "flow_mib", "in_flow", "out_flow",
|
||||
"flow_reset_time", "num", "updated_time", "status", "password_changed_at",
|
||||
}),
|
||||
Columns: []clause.Column{{Name: "id"}},
|
||||
DoUpdates: clause.AssignmentColumns(columns),
|
||||
}).Create(&item).Error
|
||||
if err != nil {
|
||||
return count, err
|
||||
@@ -2765,11 +2790,16 @@ func importSpeedLimits(tx *gorm.DB, speedLimits []model.SpeedLimitBackup, now in
|
||||
UpdatedTime: sql.NullInt64{Int64: now, Valid: true},
|
||||
Status: sl.Status,
|
||||
}
|
||||
// An omitted old-backup rank preserves existing order on conflict.
|
||||
// New legacy records use rank zero and ID descending, matching upgrades.
|
||||
columns := []string{"name", "speed", "tunnel_id", "tunnel_name", "updated_time", "status"}
|
||||
if sl.DisplayOrder != nil {
|
||||
item.DisplayOrder = *sl.DisplayOrder
|
||||
columns = append(columns, "display_order")
|
||||
}
|
||||
err := tx.Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "id"}},
|
||||
DoUpdates: clause.AssignmentColumns([]string{
|
||||
"name", "speed", "tunnel_id", "tunnel_name", "updated_time", "status",
|
||||
}),
|
||||
Columns: []clause.Column{{Name: "id"}},
|
||||
DoUpdates: clause.AssignmentColumns(columns),
|
||||
}).Create(&item).Error
|
||||
if err != nil {
|
||||
return count, err
|
||||
|
||||
@@ -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 {
|
||||
return nil, errors.New("database unavailable")
|
||||
}
|
||||
var forwards []model.Forward
|
||||
err := tx.Where("tunnel_id = ?", tunnelID).Order("id ASC").Find(&forwards).Error
|
||||
var forwards []struct {
|
||||
model.Forward
|
||||
CurrentUserName string
|
||||
}
|
||||
err := tx.Model(&model.Forward{}).
|
||||
Select(`forward.*, COALESCE(owner."user", forward.user_name) AS current_user_name`).
|
||||
Joins(`LEFT JOIN "user" AS owner ON owner.id = forward.user_id`).
|
||||
Where("forward.tunnel_id = ?", tunnelID).Order("forward.id ASC").Find(&forwards).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -48,7 +54,7 @@ func (r *Repository) ListForwardsByTunnelTx(tx *gorm.DB, tunnelID int64) ([]mode
|
||||
rows = append(rows, model.ForwardRecord{
|
||||
ID: f.ID,
|
||||
UserID: f.UserID,
|
||||
UserName: f.UserName,
|
||||
UserName: f.CurrentUserName,
|
||||
Name: f.Name,
|
||||
TunnelID: f.TunnelID,
|
||||
RemoteAddr: f.RemoteAddr,
|
||||
|
||||
@@ -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,
|
||||
PasswordChangedAt: now,
|
||||
}
|
||||
if err := r.db.Create(&user).Error; err != nil {
|
||||
if err := createFirst(r.db, "user", &user, &user.DisplayOrder); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return user.ID, nil
|
||||
@@ -940,7 +940,7 @@ func (r *Repository) CreateSpeedLimit(name string, speed int, now int64, status
|
||||
UpdatedTime: sql.NullInt64{Int64: now, Valid: true},
|
||||
Status: status,
|
||||
}
|
||||
if err := r.db.Create(&sl).Error; err != nil {
|
||||
if err := createFirst(r.db, "speed_limit", &sl, &sl.DisplayOrder); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return sl.ID, nil
|
||||
|
||||
Reference in New Issue
Block a user