package server import ( "context" "database/sql" "errors" "fmt" "github.com/gin-gonic/gin" "github.com/jackc/pgx/v5/pgconn" "log" "net" "strings" "time" "vdi-cloud/platform/internal/model" "vdi-cloud/platform/internal/store" "vdi-cloud/platform/pkg/proxmox" ) func (s *Server) overview(c *gin.Context) { var counts struct { Terminals int `db:"terminals"` Online int `db:"online"` Desktops int `db:"desktops"` Running int `db:"running"` Sessions int `db:"sessions"` Failed int `db:"failed"` } err := s.DB.GetContext(c.Request.Context(), &counts, `SELECT (SELECT count(*) FROM terminals) terminals,(SELECT count(*) FROM terminals WHERE last_seen>now()-interval '90 seconds') online,(SELECT count(*) FROM desktops) desktops,(SELECT count(*) FROM desktops WHERE power_state='running') running,(SELECT count(*) FROM terminals WHERE session_state='connected' AND session_desktop_id IS NOT NULL AND last_seen>now()-interval '90 seconds') sessions,(SELECT count(*) FROM tasks WHERE status IN ('failed','timeout') AND created_at>now()-interval '24 hours') failed`) if err != nil { s.dbError(c, err) return } c.JSON(200, gin.H{"terminalCount": counts.Terminals, "onlineTerminals": counts.Online, "desktopCount": counts.Desktops, "runningDesktops": counts.Running, "activeSessions": counts.Sessions, "failedTasks": counts.Failed, "mode": s.Config.Mode}) } func (s *Server) settings(c *gin.Context) { provider := "unconfigured" if s.Config.Mode == "demo" { provider = "demo" } if s.PVE != nil { provider = "proxmox" } c.JSON(200, gin.H{"mode": s.Config.Mode, "computeProvider": provider, "pveConfigured": s.PVE != nil, "terminalTransport": "https-poll", "capabilities": []string{"auth", "resources", "desktops", "terminals", "bindings", "tasks", "audit"}}) } func (s *Server) resources(c *gin.Context) { items := []model.Resource{} if err := s.DB.SelectContext(c.Request.Context(), &items, "SELECT * FROM resources ORDER BY name"); err != nil { s.dbError(c, err) return } list(c, items) } func (s *Server) desktops(c *gin.Context) { items := []model.Desktop{} q := "%" + c.Query("keyword") + "%" err := s.DB.SelectContext(c.Request.Context(), &items, `SELECT * FROM desktops WHERE (name ILIKE $1 OR vmid::text ILIKE $1) AND ($2='' OR power_state=$2) ORDER BY name`, q, c.Query("status")) if err != nil { s.dbError(c, err) return } list(c, items) } func (s *Server) desktop(c *gin.Context) { var d model.Desktop if err := s.DB.GetContext(c.Request.Context(), &d, "SELECT * FROM desktops WHERE id=$1", c.Param("id")); err != nil { s.dbError(c, err) return } c.JSON(200, d) } func (s *Server) terminals(c *gin.Context) { items := []model.Terminal{} err := s.DB.SelectContext(c.Request.Context(), &items, `SELECT id,sn,name,group_name,CASE WHEN last_seen>now()-interval '90 seconds' THEN 'online' WHEN status='unregistered' THEN 'unregistered' ELSE 'offline' END status,agent_version,last_seen,CASE WHEN last_seen>now()-interval '90 seconds' THEN session_desktop_id ELSE NULL END session_desktop_id,session_state,config_version,fps,rtt FROM terminals WHERE (name ILIKE $1 OR sn ILIKE $1) ORDER BY name`, "%"+c.Query("keyword")+"%") if err != nil { s.dbError(c, err) return } list(c, items) } func (s *Server) users(c *gin.Context) { items, err := store.Users(c.Request.Context(), s.DB) if err != nil { s.dbError(c, err) return } list(c, items) } func (s *Server) createUser(c *gin.Context) { var in struct { Username string `json:"username" binding:"required,min=2,max=80"` DisplayName string `json:"displayName" binding:"required,max=80"` } if !s.decode(c, &in) { return } in.Username = strings.TrimSpace(in.Username) in.DisplayName = strings.TrimSpace(in.DisplayName) if in.Username == "" || in.DisplayName == "" { s.fail(c, 400, "invalid_request", "账号和姓名不能为空") return } id := store.ID("user_") _, err := s.DB.ExecContext(c.Request.Context(), "INSERT INTO users(id,username,display_name) VALUES($1,$2,$3)", id, in.Username, in.DisplayName) if err != nil { if conflict(err) { s.fail(c, 409, "conflict", "该账号已存在") } else { s.dbError(c, err) } return } s.log(c, "create_user", id, "success") c.JSON(201, model.User{ID: id, Username: in.Username, DisplayName: in.DisplayName, Enabled: true, DesktopIDs: []string{}}) } func conflict(err error) bool { var e *pgconn.PgError return errors.As(err, &e) && (e.Code == "23505" || e.Code == "23503") } func (s *Server) bindings(c *gin.Context) { var in struct { TerminalID *string `json:"terminalId"` DesktopIDs []string `json:"desktopIds" binding:"max=100"` Enabled *bool `json:"enabled"` } if !s.decode(c, &in) { return } if in.TerminalID != nil && *in.TerminalID == "" { in.TerminalID = nil } ctx := c.Request.Context() tx, err := s.DB.BeginTxx(ctx, nil) if err != nil { s.dbError(c, err) return } defer tx.Rollback() var user model.User if err = tx.GetContext(ctx, &user, "SELECT * FROM users WHERE id=$1 FOR UPDATE", c.Param("id")); err != nil { s.dbError(c, err) return } if in.Enabled != nil { user.Enabled = *in.Enabled } _, err = tx.ExecContext(ctx, "UPDATE users SET terminal_id=$1,enabled=$2 WHERE id=$3", in.TerminalID, user.Enabled, user.ID) if err != nil { if conflict(err) { s.fail(c, 409, "binding_conflict", "终端不存在或已关联其他用户") } else { s.dbError(c, err) } return } if _, err = tx.ExecContext(ctx, "DELETE FROM bindings WHERE user_id=$1", user.ID); err != nil { s.dbError(c, err) return } seen := map[string]bool{} for _, id := range in.DesktopIDs { if seen[id] { continue } seen[id] = true if _, err = tx.ExecContext(ctx, "INSERT INTO bindings VALUES($1,$2)", user.ID, id); err != nil { if conflict(err) { s.fail(c, 400, "invalid_desktop", "所选桌面不存在") } else { s.dbError(c, err) } return } } _, err = tx.ExecContext(ctx, "INSERT INTO audit_logs VALUES($1,$2,'update_bindings',$3,'success',$4,now())", store.ID("audit_"), s.admin(c).Username, user.ID, c.GetString("requestId")) if err == nil { err = tx.Commit() } if err != nil { s.dbError(c, err) return } user.TerminalID = in.TerminalID user.DesktopIDs = in.DesktopIDs if user.DesktopIDs == nil { user.DesktopIDs = []string{} } c.JSON(200, user) } func (s *Server) tasks(c *gin.Context) { items := []model.Task{} if err := s.DB.SelectContext(c.Request.Context(), &items, "SELECT * FROM tasks ORDER BY created_at DESC LIMIT 1000"); err != nil { s.dbError(c, err) return } list(c, items) } func (s *Server) task(c *gin.Context) { var t model.Task if err := s.DB.GetContext(c.Request.Context(), &t, "SELECT * FROM tasks WHERE id=$1", c.Param("id")); err != nil { s.dbError(c, err) return } c.JSON(200, t) } func (s *Server) audits(c *gin.Context) { items := []model.Audit{} if err := s.DB.SelectContext(c.Request.Context(), &items, "SELECT * FROM audit_logs ORDER BY created_at DESC LIMIT 1000"); err != nil { s.dbError(c, err) return } list(c, items) } func (s *Server) discoverVMs(c *gin.Context) { if s.PVE == nil { s.fail(c, 503, "pve_unconfigured", "尚未配置 Proxmox 接入,演示环境不发现真实 VM") return } items, err := s.PVE.Resources(c.Request.Context()) if err != nil { s.fail(c, 502, "pve_unavailable", "Proxmox 查询失败,请检查连接、证书和访问权限") return } vms := []gin.H{} for _, r := range items { if r.Type == "qemu" && r.Template == 0 { vms = append(vms, gin.H{"vmid": r.VMID, "node": r.Node, "name": r.Name, "status": r.Status}) } } list(c, vms) } func (s *Server) syncResources(c *gin.Context) { if s.PVE == nil { s.fail(c, 503, "pve_unconfigured", "请通过服务端环境变量配置 Proxmox 接入") return } s.submitTask(c, "resource", "sync", "cluster", "Proxmox 集群") } func (s *Server) importDesktop(c *gin.Context) { var in struct { Name string `json:"name" binding:"required,max=80"` OS string `json:"os" binding:"required,oneof=Windows Ubuntu 麒麟"` Node string `json:"node" binding:"required,max=100"` VMID int `json:"vmid" binding:"min=100"` Endpoint string `json:"endpoint" binding:"max=253"` Width int `json:"width" binding:"omitempty,min=640,max=7680"` Height int `json:"height" binding:"omitempty,min=480,max=4320"` FPS int `json:"fps" binding:"omitempty,min=10,max=120"` } if !s.decode(c, &in) { return } if s.PVE == nil { s.fail(c, 503, "pve_unconfigured", "真实 VM 纳管需要先配置 Proxmox") return } if in.Endpoint != "" && net.ParseIP(in.Endpoint) == nil { for _, part := range strings.Split(in.Endpoint, ".") { if part == "" || strings.ContainsAny(part, "/:@ \\") { s.fail(c, 400, "invalid_endpoint", "连接地址应为 IP 或主机名") return } } } power, err := s.PVE.Power(c.Request.Context(), in.Node, in.VMID) if err != nil { s.fail(c, 502, "pve_unavailable", "无法验证所选 VM,请检查节点和 VMID") return } id := store.ID("desk_") if in.Width == 0 { in.Width = 2560 } if in.Height == 0 { in.Height = 1440 } if in.FPS == 0 { in.FPS = 30 } _, err = s.DB.ExecContext(c.Request.Context(), `INSERT INTO desktops(id,name,os,node,vmid,power_state,provider,endpoint,width,height,fps) VALUES($1,$2,$3,$4,$5,$6,'proxmox',$7,$8,$9,$10)`, id, in.Name, in.OS, in.Node, in.VMID, power, in.Endpoint, in.Width, in.Height, in.FPS) if err != nil { if conflict(err) { s.fail(c, 409, "already_managed", "该 VM 已纳管") } else { s.dbError(c, err) } return } s.log(c, "import_desktop", id, "success") c.Params = append(c.Params, gin.Param{Key: "id", Value: id}) s.desktop(c) } func (s *Server) desktopAction(c *gin.Context) { var in struct { Action string `json:"action" binding:"required,oneof=start shutdown reboot"` } if !s.decode(c, &in) { return } var d model.Desktop if err := s.DB.GetContext(c.Request.Context(), &d, "SELECT * FROM desktops WHERE id=$1", c.Param("id")); err != nil { s.dbError(c, err) return } if d.Provider == "demo" && s.Config.Mode != "demo" { s.fail(c, 409, "demo_disabled", "正式模式不执行演示桌面任务") return } if d.Provider == "proxmox" && s.PVE == nil { s.fail(c, 503, "pve_unconfigured", "Proxmox 尚未配置") return } s.submitTask(c, "desktop", in.Action, d.ID, d.Name) } func (s *Server) terminalCommand(c *gin.Context) { var in struct { Action string `json:"action" binding:"required,oneof=reboot reset-session collect-logs"` } if !s.decode(c, &in) { return } var name string if err := s.DB.GetContext(c.Request.Context(), &name, "SELECT name FROM terminals WHERE id=$1", c.Param("id")); err != nil { s.dbError(c, err) return } s.submitTask(c, "terminal", in.Action, c.Param("id"), name) } func (s *Server) submitTask(c *gin.Context, kind, action, targetID, targetName string) { key := c.GetHeader("Idempotency-Key") if key == "" || len(key) > 100 { s.fail(c, 400, "idempotency_required", "请提供不超过 100 字符的 Idempotency-Key") return } actor := s.admin(c).Username ctx := c.Request.Context() var existing model.Task err := s.DB.GetContext(ctx, &existing, "SELECT * FROM tasks WHERE actor=$1 AND idempotency_key=$2", actor, key) if err == nil { if existing.Kind != kind || existing.Action != action || existing.TargetID != targetID { s.fail(c, 409, "idempotency_conflict", "相同请求标识不能用于不同操作") return } c.JSON(202, existing) return } if !errors.Is(err, sql.ErrNoRows) { s.dbError(c, err) return } tx, err := s.DB.BeginTxx(ctx, nil) if err != nil { s.dbError(c, err) return } defer tx.Rollback() var task model.Task err = tx.GetContext(ctx, &task, `INSERT INTO tasks(id,kind,action,target_id,target_name,actor,status,message,idempotency_key) VALUES($1,$2,$3,$4,$5,$6,'queued','已提交,等待执行回执',$7) RETURNING *`, store.ID("task_"), kind, action, targetID, targetName, actor, key) if err != nil { if conflict(err) { s.fail(c, 409, "task_conflict", "已有相同请求或该对象有任务执行中,请刷新任务列表") } else { s.dbError(c, err) } return } _, err = tx.ExecContext(ctx, "INSERT INTO audit_logs VALUES($1,$2,$3,$4,'submitted',$5,now())", store.ID("audit_"), actor, action, targetName, c.GetString("requestId")) if err == nil { err = tx.Commit() } if err != nil { s.dbError(c, err) return } c.JSON(202, task) } // Complete persists the final result and its audit entry together. func (s *Server) complete(ctx context.Context, t model.Task, status, message string) error { tx, err := s.DB.BeginTxx(ctx, nil) if err != nil { return err } defer tx.Rollback() result, err := tx.ExecContext(ctx, "UPDATE tasks SET status=$1,message=$2,updated_at=now() WHERE id=$3 AND status IN ('running','queued')", status, message, t.ID) if err != nil { return err } n, _ := result.RowsAffected() if n == 0 { return nil } // Session release is scoped to a session ID by the device event endpoint. // A delayed command acknowledgement must not clear a newer connection. _, err = tx.ExecContext(ctx, "INSERT INTO audit_logs VALUES($1,$2,$3,$4,$5,$6,now())", store.ID("audit_"), t.Actor, t.Action, t.TargetName, status, t.ID) if err != nil { return err } return tx.Commit() } func ratio(a, b float64) float64 { if b <= 0 { return 0 } return a / b } func (s *Server) sync(ctx context.Context) error { items, err := s.PVE.Resources(ctx) if err != nil { return err } tx, err := s.DB.BeginTxx(ctx, nil) if err != nil { return err } defer tx.Rollback() if _, err = tx.ExecContext(ctx, "DELETE FROM resources WHERE provider='proxmox'"); err != nil { return err } for _, r := range items { if r.Type == "node" { if _, err = tx.ExecContext(ctx, `INSERT INTO resources VALUES($1,$1,$2,$3,$4,$5,'proxmox',now()) ON CONFLICT(id) DO UPDATE SET status=$2,cpu=$3,memory=$4,storage=$5,provider='proxmox',updated_at=now()`, r.Node, r.Status, r.CPU, ratio(r.Memory, r.MaxMemory), ratio(r.Disk, r.MaxDisk)); err != nil { return err } } if r.Type == "qemu" { if _, err = tx.ExecContext(ctx, "UPDATE desktops SET power_state=$1,updated_at=now() WHERE provider='proxmox' AND node=$2 AND vmid=$3", r.Status, r.Node, r.VMID); err != nil { return err } } } return tx.Commit() } func (s *Server) RunWorker(ctx context.Context) { ticker := time.NewTicker(300 * time.Millisecond) defer ticker.Stop() for { select { case <-ctx.Done(): return case <-ticker.C: // Unknown outcomes are never automatically retried after a worker restart. var expired []model.Task if s.DB.SelectContext(ctx, &expired, "SELECT * FROM tasks WHERE status IN ('queued','running') AND created_at= 400 && httpError.Status < 500 { uncertain = false } if err == nil { _, err = s.DB.ExecContext(ctx, "UPDATE tasks SET provider_ref=$1 WHERE id=$2", upid, t.ID) } if err == nil { err = s.PVE.Wait(ctx, d.Node, upid, time.Second) } if err == nil { var power string power, err = s.PVE.Power(ctx, d.Node, d.VMID) if err == nil { expected := "running" if t.Action == "shutdown" { expected = "stopped" } if power != expected { err = fmt.Errorf("power result not confirmed") } else { _, err = s.DB.ExecContext(ctx, "UPDATE desktops SET power_state=$1,service_state='unknown',updated_at=now() WHERE id=$2", power, d.ID) } } } } } } status := "success" if err != nil { log.Printf("task execution error taskId=%s error=%v", t.ID, err) status = "failed" message = "执行失败,请检查服务日志及 PVE 任务;未自动重试" if (uncertain && !errors.Is(err, proxmox.ErrTaskFailed)) || errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) { status = "timeout" message = "结果未确认,请核对实际状态;未自动重试" } } finalCtx, finalCancel := context.WithTimeout(context.Background(), 5*time.Second) defer finalCancel() _ = s.complete(finalCtx, t, status, message) }