package server import ( "github.com/gin-gonic/gin" "github.com/jmoiron/sqlx" "strings" "vdi-cloud/platform/internal/model" "vdi-cloud/platform/internal/store" ) const terminalListSQL = `SELECT id,sn,name,group_name,enabled, CASE WHEN NOT enabled THEN 'disabled' WHEN status='unregistered' THEN 'unregistered' WHEN last_seen>now()-interval '90 seconds' THEN 'online' ELSE 'offline' END status, agent_version,last_seen,CASE WHEN enabled AND last_seen>now()-interval '90 seconds' THEN session_desktop_id ELSE NULL END session_desktop_id, session_state,config_version,fps,rtt FROM terminals` // Only authorization and identity changes share this lock. Device heartbeats and // command delivery use their own terminal row and remain independent. func (s *Server) lockAuthorization(c *gin.Context, tx *sqlx.Tx) bool { if _, err := tx.ExecContext(c.Request.Context(), "SELECT pg_advisory_xact_lock(7651092029)"); err != nil { s.dbError(c, err) return false } return true } func (s *Server) updateTerminal(c *gin.Context) { var in struct { Name string `json:"name" binding:"required,max=80"` Group string `json:"group" binding:"max=80"` } if !s.decode(c, &in) { return } in.Name = strings.TrimSpace(in.Name) in.Group = strings.TrimSpace(in.Group) if in.Name == "" { s.fail(c, 400, "invalid_request", "终端名称不能为空") return } tx, err := s.DB.BeginTxx(c.Request.Context(), nil) if err != nil { s.dbError(c, err) return } defer tx.Rollback() var id string if err = tx.GetContext(c.Request.Context(), &id, "UPDATE terminals SET name=$1,group_name=$2 WHERE id=$3 RETURNING id", in.Name, in.Group, c.Param("id")); err != nil { s.dbError(c, err) return } if err = accountAudit(c.Request.Context(), tx, s.admin(c).Username, "update_terminal", id, c.GetString("requestId")); err == nil { err = tx.Commit() } if err != nil { s.dbError(c, err) return } c.JSON(200, gin.H{"accepted": true}) } func (s *Server) disableTerminal(c *gin.Context) { s.resetTerminalIdentity(c, false) } func (s *Server) reactivateTerminal(c *gin.Context) { s.resetTerminalIdentity(c, true) } func (s *Server) resetTerminalIdentity(c *gin.Context, reactivate bool) { ctx := c.Request.Context() tx, err := s.DB.BeginTxx(ctx, nil) if err != nil { s.dbError(c, err) return } defer tx.Rollback() if !s.lockAuthorization(c, tx) { return } var state struct { ID string `db:"id"` Enabled bool `db:"enabled"` Active bool `db:"active"` } err = tx.GetContext(ctx, &state, "SELECT id,enabled,(session_desktop_id IS NOT NULL AND last_seen>now()-interval '90 seconds') active FROM terminals WHERE id=$1 FOR UPDATE", c.Param("id")) if err != nil { s.dbError(c, err) return } if !state.Enabled && !reactivate { c.JSON(200, gin.H{"accepted": true}) return } if state.Active { s.fail(c, 409, "terminal_in_use", "终端有活动会话或连接预约,请先结束或重置会话") return } var pending bool if err = tx.GetContext(ctx, &pending, "SELECT EXISTS(SELECT 1 FROM tasks WHERE kind='terminal' AND target_id=$1 AND status IN ('queued','running'))", state.ID); err != nil { s.dbError(c, err) return } if pending { s.fail(c, 409, "terminal_task_pending", "终端有未完成命令,请等待回执或核对超时结果") return } secret, bootstrap, status, action := "", "", "disabled", "disable_terminal" if reactivate { secret = store.ID("activate_") bootstrap = store.Hash(secret) status = "unregistered" action = "reactivate_terminal" } _, err = tx.ExecContext(ctx, `UPDATE terminals SET enabled=$1,status=$2,token_hash=NULL,bootstrap_hash=$3, last_seen=NULL,agent_version='',fps=NULL,rtt=NULL,session_id='',session_state='idle',session_desktop_id=NULL WHERE id=$4`, reactivate, status, bootstrap, state.ID) if err == nil { _, err = tx.ExecContext(ctx, "DELETE FROM connection_tickets WHERE terminal_id=$1", state.ID) } if err == nil { _, err = tx.ExecContext(ctx, "DELETE FROM terminal_policy_capabilities WHERE terminal_id=$1", state.ID) } if err == nil { _, err = tx.ExecContext(ctx, "DELETE FROM terminal_metrics_current WHERE terminal_id=$1", state.ID) } if err == nil { err = closeSessionRecords(ctx, tx, state.ID, "identity_reset") } if err == nil { _, err = tx.ExecContext(ctx, `UPDATE terminal_policy_deliveries SET status='pending',error_code='',acknowledged_at=NULL WHERE terminal_id=$1 AND config_version=(SELECT config_version FROM terminals WHERE id=$1)`, state.ID) } if err == nil { err = accountAudit(ctx, tx, s.admin(c).Username, action, state.ID, c.GetString("requestId")) } if err == nil { err = tx.Commit() } if err != nil { s.dbError(c, err) return } if reactivate { c.JSON(200, gin.H{"id": state.ID, "activationSecret": secret}) } else { c.JSON(200, gin.H{"accepted": true}) } } func (s *Server) updateUser(c *gin.Context) { var in struct { DisplayName string `json:"displayName" binding:"required,max=80"` Enabled *bool `json:"enabled" binding:"required"` } if !s.decode(c, &in) { return } in.DisplayName = strings.TrimSpace(in.DisplayName) if in.DisplayName == "" { s.fail(c, 400, "invalid_request", "用户姓名不能为空") return } ctx := c.Request.Context() tx, err := s.DB.BeginTxx(ctx, nil) if err != nil { s.dbError(c, err) return } defer tx.Rollback() if !s.lockAuthorization(c, tx) { return } var user model.User err = tx.GetContext(ctx, &user, "UPDATE users SET display_name=$1,enabled=$2 WHERE id=$3 RETURNING *", in.DisplayName, *in.Enabled, c.Param("id")) if err != nil { s.dbError(c, err) return } if _, err = tx.ExecContext(ctx, "DELETE FROM connection_tickets WHERE terminal_id=$1 AND consumed_at IS NULL", user.TerminalID); err != nil { s.dbError(c, err) return } user.DesktopIDs = []string{} if err = tx.SelectContext(ctx, &user.DesktopIDs, "SELECT desktop_id FROM bindings WHERE user_id=$1 ORDER BY desktop_id", user.ID); err != nil { s.dbError(c, err) return } if err = accountAudit(ctx, tx, s.admin(c).Username, "update_user", user.ID, c.GetString("requestId")); err == nil { err = tx.Commit() } if err != nil { s.dbError(c, err) return } c.JSON(200, user) } func uniqueIDs(items []string) []string { result := []string{} seen := map[string]bool{} for _, id := range items { if !seen[id] { seen[id] = true result = append(result, id) } } return result } func (s *Server) batchBindings(c *gin.Context) { var in struct { UserIDs []string `json:"userIds" binding:"required,min=1,max=100,dive,required,max=100"` DesktopIDs []string `json:"desktopIds" binding:"required,min=1,max=100,dive,required,max=100"` Action string `json:"action" binding:"required,oneof=grant revoke"` } if !s.decode(c, &in) { return } in.UserIDs = uniqueIDs(in.UserIDs) in.DesktopIDs = uniqueIDs(in.DesktopIDs) ctx := c.Request.Context() tx, err := s.DB.BeginTxx(ctx, nil) if err != nil { s.dbError(c, err) return } defer tx.Rollback() if !s.lockAuthorization(c, tx) { return } var ids []string if err = tx.SelectContext(ctx, &ids, "SELECT id FROM users WHERE id=ANY($1) ORDER BY id FOR UPDATE", in.UserIDs); err != nil { s.dbError(c, err) return } if len(ids) != len(in.UserIDs) { s.fail(c, 400, "invalid_user", "所选用户不存在,请刷新列表") return } var count int if err = tx.GetContext(ctx, &count, "SELECT count(*) FROM desktops WHERE id=ANY($1)", in.DesktopIDs); err != nil { s.dbError(c, err) return } if count != len(in.DesktopIDs) { s.fail(c, 400, "invalid_desktop", "所选桌面不存在,请刷新列表") return } for _, id := range ids { if in.Action == "grant" { _, err = tx.ExecContext(ctx, "INSERT INTO bindings(user_id,desktop_id) SELECT $1,unnest($2::text[]) ON CONFLICT DO NOTHING", id, in.DesktopIDs) } else { _, err = tx.ExecContext(ctx, "DELETE FROM bindings WHERE user_id=$1 AND desktop_id=ANY($2)", id, in.DesktopIDs) } if err == nil { _, err = tx.ExecContext(ctx, "DELETE FROM connection_tickets WHERE consumed_at IS NULL AND terminal_id=(SELECT terminal_id FROM users WHERE id=$1)", id) } if err == nil { err = accountAudit(ctx, tx, s.admin(c).Username, "batch_"+in.Action+"_bindings", id, c.GetString("requestId")) } if err != nil { s.dbError(c, err) return } } if err = tx.Commit(); err != nil { s.dbError(c, err) return } c.JSON(200, gin.H{"accepted": true, "userCount": len(ids)}) }