package server import ( "context" "database/sql" "encoding/json" "errors" "fmt" "github.com/gin-gonic/gin" "github.com/golang-jwt/jwt/v5" "github.com/jmoiron/sqlx" "sort" "strings" "time" "vdi-cloud/platform/internal/model" "vdi-cloud/platform/internal/store" "vdi-cloud/platform/pkg/proxmox" ) type powerBatchInput struct { Action string `json:"action" binding:"required,oneof=start shutdown reboot"` DesktopIDs []string `json:"desktopIds" binding:"required,min=1,max=50,dive,required,max=100"` PreviewToken string `json:"previewToken" binding:"max=20000"` } type powerBatchCheckItem struct { DesktopID string `json:"desktopId"` Name string `json:"name"` VMID int `json:"vmid"` PowerState string `json:"powerState"` Available bool `json:"available"` Code string `json:"code"` Message string `json:"message"` } type powerBatchClaims struct { Hash string `json:"requestHash"` Session string `json:"session"` Ready map[string]string `json:"ready"` jwt.RegisteredClaims } func normalizePowerBatch(in *powerBatchInput) (string, error) { sort.Strings(in.DesktopIDs) for i, id := range in.DesktopIDs { if strings.TrimSpace(id) != id || id == "" || i > 0 && id == in.DesktopIDs[i-1] { return "", errors.New("桌面列表不能包含空值、重复值或多余空格") } } raw, _ := json.Marshal(struct { Action string IDs []string }{in.Action, in.DesktopIDs}) return store.Hash(string(raw)), nil } func powerFingerprint(d model.Desktop, power string) string { raw, _ := json.Marshal([]any{d.ID, d.Name, d.Provider, d.Node, d.VMID, power}) return store.Hash(string(raw)) } func (s *Server) powerBatchPreflight(ctx context.Context, db sqlx.ExtContext, id, action, own string) (model.Desktop, powerBatchCheckItem, error) { item := powerBatchCheckItem{DesktopID: id, PowerState: "unknown"} var d model.Desktop err := sqlx.GetContext(ctx, db, &d, "SELECT * FROM desktops WHERE id=$1", id) if errors.Is(err, sql.ErrNoRows) { item.Code = "not_found" item.Message = "桌面不存在或已移除" return d, item, nil } if err != nil { return d, item, err } item.Name = d.Name item.VMID = d.VMID reject := func(code, message string) (model.Desktop, powerBatchCheckItem, error) { item.Code = code item.Message = message return d, item, nil } if s.exclusiveGPU(d) && action != "shutdown" { return reject("exclusive_gpu_power", "此桌面共用显卡,请从终端选择页启动") } if err = s.exclusiveGPUMaintenance(ctx, db, d); err != nil { return reject("gpu_switch_busy", err.Error()) } blocked, err := maintenanceBlocked(ctx, db, id, own, true) if err != nil { return d, item, err } if blocked { return reject("maintenance", "有维护任务或未确认结果,请先处理") } var latest string err = sqlx.GetContext(ctx, db, &latest, "SELECT status FROM tasks WHERE target_id=$1 AND id!=$2 AND kind IN ('desktop','batch-desktop') ORDER BY created_at DESC,id DESC LIMIT 1", id, own) if err != nil && !errors.Is(err, sql.ErrNoRows) { return d, item, err } if latest == "timeout" { return reject("power_unconfirmed", "最近电源任务结果未确认,请先核对") } var active bool if err = sqlx.GetContext(ctx, db, &active, "SELECT EXISTS(SELECT 1 FROM terminals WHERE session_desktop_id=$1 AND last_seen>now()-interval '90 seconds')", id); err != nil { return d, item, err } if active { return reject("active_session", "有活动会话,批量操作跳过;请先结束会话") } power := d.PowerState if d.Provider == "proxmox" { pve := s.currentPVE() if pve == nil { return reject("pve_unconfigured", "PVE 未配置") } config, e := pve.VMConfig(ctx, d.Node, d.VMID) if e != nil { return reject("provider_unavailable", "无法读取 PVE 配置") } if proxmox.ConfigInt(config, "template", 0) != 0 || config["lock"] != nil && fmt.Sprint(config["lock"]) != "" { return reject("pve_locked", "PVE 模板或锁定状态不支持批量电源操作") } power, e = pve.Power(ctx, d.Node, d.VMID) if e != nil { return reject("provider_unavailable", "无法读取真实电源状态") } item.PowerState = power ok, e := pve.HasPermission(ctx, fmt.Sprintf("/vms/%d", d.VMID), "VM.PowerMgmt") if e != nil || !ok { return reject("permission_missing", "无法确认 VM.PowerMgmt 权限") } } else if d.Provider != "demo" || s.Config.Mode != "demo" { return reject("provider_disabled", "当前环境不允许该桌面操作") } item.PowerState = power if action == "start" && power != "stopped" || action != "start" && power != "running" { return reject("power_mismatch", "当前电源状态不适用此操作") } item.Available = true item.Code = "ready" item.Message = "可提交,执行前将再次核对" return d, item, nil } func (s *Server) checkPowerBatch(c *gin.Context) { var in powerBatchInput if !s.decodeConfiguration(c, &in) { return } hash, err := normalizePowerBatch(&in) if err != nil || in.PreviewToken != "" { s.fail(c, 400, "invalid_request", "请检查桌面列表,仅接受预检参数") return } ctx, cancel := context.WithTimeout(c.Request.Context(), 30*time.Second) defer cancel() items := []powerBatchCheckItem{} ready := map[string]string{} for _, id := range in.DesktopIDs { d, item, e := s.powerBatchPreflight(ctx, s.DB, id, in.Action, "") if e != nil { s.fail(c, 503, "batch_check_unavailable", "批量预检未完成,请稍后重试") return } items = append(items, item) if item.Available { ready[id] = powerFingerprint(d, item.PowerState) } } expires := time.Now().Add(5 * time.Minute) claims := powerBatchClaims{Hash: hash, Session: c.GetString("sessionId"), Ready: ready, RegisteredClaims: jwt.RegisteredClaims{Issuer: "vdi-power-batch-preview", Subject: s.admin(c).ID, ExpiresAt: jwt.NewNumericDate(expires)}} token, err := jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString([]byte(s.Config.JWTSecret)) if err != nil { s.dbError(c, err) return } c.JSON(200, gin.H{"items": items, "eligibleCount": len(ready), "skippedCount": len(items) - len(ready), "previewToken": token, "expiresAt": expires}) } type powerBatch struct { ID string `json:"id" db:"id"` Action string `json:"action" db:"action"` Actor string `json:"actor" db:"actor"` CreatedAt time.Time `json:"createdAt" db:"created_at"` TotalCount int `json:"totalCount" db:"total_count"` SubmittedCount int `json:"submittedCount" db:"submitted_count"` SkippedCount int `json:"skippedCount" db:"skipped_count"` ActiveCount int `json:"activeCount" db:"active_count"` SuccessCount int `json:"successCount" db:"success_count"` FailedCount int `json:"failedCount" db:"failed_count"` UncertainCount int `json:"uncertainCount" db:"uncertain_count"` } type powerBatchItem struct { DesktopID string `json:"desktopId" db:"desktop_id"` Name string `json:"name" db:"desktop_name"` VMID int `json:"vmid" db:"vmid"` TaskID string `json:"taskId" db:"task_id"` Status string `json:"status" db:"status"` Code string `json:"code" db:"code"` Message string `json:"message" db:"message"` } const powerBatchQuery = `SELECT b.id,b.action,b.actor,b.created_at,count(*) total_count,count(i.task_id) submitted_count,count(*) FILTER(WHERE i.task_id IS NULL) skipped_count,count(*) FILTER(WHERE t.status IN ('queued','running')) active_count,count(*) FILTER(WHERE t.status='success') success_count,count(*) FILTER(WHERE t.status='failed') failed_count,count(*) FILTER(WHERE t.status='timeout') uncertain_count FROM power_batches b JOIN power_batch_items i ON i.batch_id=b.id LEFT JOIN tasks t ON t.id=i.task_id` func (s *Server) powerBatches(c *gin.Context) { items := []powerBatch{} if err := s.DB.SelectContext(c.Request.Context(), &items, powerBatchQuery+" GROUP BY b.id ORDER BY b.created_at DESC,b.id DESC LIMIT 100"); err != nil { s.dbError(c, err) return } list(c, items) } func powerBatchDetail(ctx context.Context, db sqlx.ExtContext, id string) (gin.H, error) { var batch powerBatch if err := sqlx.GetContext(ctx, db, &batch, powerBatchQuery+" WHERE b.id=$1 GROUP BY b.id", id); err != nil { return nil, err } items := []powerBatchItem{} err := sqlx.SelectContext(ctx, db, &items, `SELECT i.desktop_id,i.desktop_name,i.vmid,coalesce(i.task_id,'') task_id,coalesce(t.status,'skipped') status,i.code,coalesce(t.message,i.message) message FROM power_batch_items i LEFT JOIN tasks t ON t.id=i.task_id WHERE i.batch_id=$1 ORDER BY i.desktop_id`, id) return gin.H{"batch": batch, "items": items}, err } func (s *Server) getPowerBatch(c *gin.Context) { out, err := powerBatchDetail(c.Request.Context(), s.DB, c.Param("id")) if err != nil { s.dbError(c, err) return } c.JSON(200, out) } func (s *Server) queuePowerBatch(c *gin.Context) { var in powerBatchInput if !s.decodeConfiguration(c, &in) { return } hash, err := normalizePowerBatch(&in) if err != nil { s.fail(c, 400, "invalid_request", err.Error()) return } key := strings.TrimSpace(c.GetHeader("Idempotency-Key")) if key == "" || len(key) > 100 { s.fail(c, 400, "idempotency_required", "请携带有效请求标识") return } tx, actor := s.accountTransaction(c, false) if tx == nil { return } defer tx.Rollback() if actor.Role != "super_admin" && actor.Role != "operator" { s.fail(c, 403, "forbidden", "当前账号不能执行批量电源操作") return } ctx, cancel := context.WithTimeout(c.Request.Context(), 30*time.Second) defer cancel() var old struct { ID string `db:"id"` Hash string `db:"request_hash"` } err = tx.GetContext(ctx, &old, "SELECT id,request_hash FROM power_batches WHERE actor=$1 AND idempotency_key=$2", actor.Username, key) if err == nil { if old.Hash != hash { s.fail(c, 409, "idempotency_conflict", "相同请求标识不能用于其他桌面或操作") return } out, e := powerBatchDetail(ctx, tx, old.ID) if e != nil { s.dbError(c, e) return } c.JSON(202, out) return } if !errors.Is(err, sql.ErrNoRows) { s.dbError(c, err) return } claims := &powerBatchClaims{} token, e := jwt.ParseWithClaims(in.PreviewToken, claims, func(t *jwt.Token) (any, error) { return []byte(s.Config.JWTSecret), nil }, jwt.WithValidMethods([]string{"HS256"}), jwt.WithIssuer("vdi-power-batch-preview"), jwt.WithExpirationRequired()) if e != nil || !token.Valid || claims.Subject != actor.ID || claims.Session != c.GetString("sessionId") || claims.Hash != hash || len(claims.Ready) == 0 { s.fail(c, 409, "batch_preview_expired", "请重新预检,并确认至少一台桌面可执行") return } var locked []string if err = tx.SelectContext(ctx, &locked, "SELECT id FROM desktops WHERE id=ANY($1) ORDER BY id FOR UPDATE", in.DesktopIDs); err != nil { s.dbError(c, err) return } id := store.ID("batch_") _, err = tx.ExecContext(ctx, "INSERT INTO power_batches(id,actor,action,idempotency_key,request_hash) VALUES($1,$2,$3,$4,$5)", id, actor.Username, in.Action, key, hash) if err != nil { s.dbError(c, err) return } for _, desktopID := range in.DesktopIDs { d, item, e := s.powerBatchPreflight(ctx, tx, desktopID, in.Action, "") if e != nil { s.dbError(c, e) return } fingerprint, approved := claims.Ready[desktopID] if item.Available && (!approved || fingerprint != powerFingerprint(d, item.PowerState)) { item.Available = false item.Code = "preview_changed" item.Message = "与已确认的预检不一致,已跳过,请重新预检" } taskID, stage := "", "skipped" if item.Available { taskID = store.ID("task_") stage = "prepared" _, err = tx.ExecContext(ctx, `INSERT INTO tasks(id,kind,action,target_id,target_name,actor,status,message,idempotency_key) VALUES($1,'batch-desktop',$2,$3,$4,$5,'queued','已提交批量电源任务,执行前再次预检',$6)`, taskID, in.Action, d.ID, d.Name, actor.Username, id+":"+d.ID) if err != nil { s.dbError(c, err) return } _, err = tx.ExecContext(ctx, "INSERT INTO audit_logs(id,actor,action,target,result,request_id) VALUES($1,$2,$3,$4,'submitted',$5)", store.ID("audit_"), actor.Username, in.Action, d.ID, c.GetString("requestId")) if err != nil { s.dbError(c, err) return } } _, err = tx.ExecContext(ctx, `INSERT INTO power_batch_items(batch_id,desktop_id,desktop_name,vmid,task_id,code,message,fingerprint,stage) VALUES($1,$2,$3,$4,NULLIF($5,''),$6,$7,$8,$9)`, id, desktopID, item.Name, item.VMID, taskID, item.Code, item.Message, fingerprint, stage) if err != nil { s.dbError(c, err) return } } _, err = tx.ExecContext(ctx, "INSERT INTO audit_logs(id,actor,action,target,result,request_id) VALUES($1,$2,$3,$4,'submitted',$5)", store.ID("audit_"), actor.Username, "batch-"+in.Action, id, c.GetString("requestId")) if err != nil { s.dbError(c, err) return } out, err := powerBatchDetail(ctx, tx, id) if err == nil { err = tx.Commit() } if err != nil { s.dbError(c, err) return } c.JSON(202, out) }