package server import ( "context" "errors" "github.com/gin-gonic/gin" "github.com/jmoiron/sqlx" "time" "vdi-cloud/platform/internal/model" "vdi-cloud/platform/pkg/proxmox" ) type powerBatchJob struct { Stage string `db:"stage"` Fingerprint string `db:"fingerprint"` } func (s *Server) claimBatchPower(ctx context.Context, t model.Task) (bool, error) { tx, err := s.DB.BeginTxx(ctx, nil) if err != nil { return false, err } defer tx.Rollback() var id string if err = tx.GetContext(ctx, &id, "SELECT id FROM desktops WHERE id=$1 FOR UPDATE", t.TargetID); err != nil { return false, err } r, err := tx.ExecContext(ctx, `UPDATE power_batch_items SET stage='submitting' WHERE task_id=$1 AND stage='prepared' AND EXISTS(SELECT 1 FROM tasks WHERE id=$1 AND kind='batch-desktop' AND status IN ('queued','running'))`, t.ID) if err != nil { return false, err } n, _ := r.RowsAffected() if n != 1 { return false, nil } err = tx.Commit() return err == nil, err } func (s *Server) confirmBatchPower(ctx context.Context, db sqlx.ExtContext, t model.Task) error { var d model.Desktop if err := sqlx.GetContext(ctx, db, &d, "SELECT * FROM desktops WHERE id=$1", t.TargetID); err != nil { return err } power := d.PowerState if d.Provider == "proxmox" { pve := s.currentPVE() if pve == nil || t.ProviderRef == "" { return errors.New("missing provider task") } task, err := pve.Task(ctx, d.Node, t.ProviderRef) if err != nil { return err } if task.Status != "stopped" || task.ExitStatus != "OK" { return errors.New("original power task not successful") } power, err = pve.Power(ctx, d.Node, d.VMID) if err != nil { return err } } else if d.Provider != "demo" || s.Config.Mode != "demo" { return errors.New("unsupported provider") } expected := "running" if t.Action == "shutdown" { expected = "stopped" } if power != expected { return errors.New("actual power differs") } return nil } func (s *Server) executeBatchPower(parent context.Context, t model.Task) { ctx, cancel := context.WithTimeout(parent, 90*time.Second) defer cancel() var job powerBatchJob err := s.DB.GetContext(ctx, &job, "SELECT stage,fingerprint FROM power_batch_items WHERE task_id=$1", t.ID) submitted := job.Stage == "submitting" || job.Stage == "uncertain" if err == nil { err = s.RefreshCompute(ctx) } var d model.Desktop if err == nil && !submitted { var item powerBatchCheckItem d, item, err = s.powerBatchPreflight(ctx, s.DB, t.TargetID, t.Action, t.ID) if err == nil && (!item.Available || powerFingerprint(d, item.PowerState) != job.Fingerprint) { err = errors.New("power preflight changed") } if err == nil { var claimed bool claimed, err = s.claimBatchPower(ctx, t) if err == nil && !claimed { return } if err == nil { submitted = true if d.Provider == "demo" { power := "running" if t.Action == "shutdown" { power = "stopped" } _, err = s.DB.ExecContext(ctx, "UPDATE desktops SET power_state=$1,service_state='unknown',updated_at=now() WHERE id=$2", power, d.ID) } else { t.ProviderRef, err = s.currentPVE().Action(ctx, d.Node, d.VMID, t.Action) var rejected *proxmox.HTTPError if errors.As(err, &rejected) && rejected.Status >= 400 && rejected.Status < 500 { submitted = false } if err == nil { _, err = s.DB.ExecContext(ctx, "UPDATE tasks SET provider_ref=$1,updated_at=now() WHERE id=$2", t.ProviderRef, t.ID) } } } } } if err == nil { err = s.DB.GetContext(ctx, &d, "SELECT * FROM desktops WHERE id=$1", t.TargetID) if err == nil && d.Provider == "proxmox" { if t.ProviderRef == "" { err = errors.New("missing original UPID") } else { err = s.currentPVE().Wait(ctx, d.Node, t.ProviderRef, time.Second) } } } if err == nil { err = s.confirmBatchPower(ctx, s.DB, t) } status, stage, message := "success", "completed", "电源任务和实际状态已确认;连接服务需另行确认" if err != nil { status, stage, message = "failed", "failed", "执行前状态发生变化或 PVE 拒绝请求,未发送有效修改" if submitted { status, stage, message = "timeout", "uncertain", "批量电源结果未确认,已阻止后续修改和新连接;只核对原任务,不会自动重发" } } s.finishBatchPower(t, status, stage, message) } func (s *Server) finishBatchPower(t model.Task, status, stage, message string) { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() tx, err := s.DB.BeginTxx(ctx, nil) if err != nil { return } defer tx.Rollback() var id, current, jobStage string if err = tx.GetContext(ctx, &id, "SELECT id FROM desktops WHERE id=$1 FOR UPDATE", t.TargetID); err != nil { return } if err = tx.GetContext(ctx, ¤t, "SELECT status FROM tasks WHERE id=$1 FOR UPDATE", t.ID); err != nil || current != "queued" && current != "running" { return } if err = tx.GetContext(ctx, &jobStage, "SELECT stage FROM power_batch_items WHERE task_id=$1 FOR UPDATE", t.ID); err != nil { return } if stage == "expired" { status, stage, message = "failed", "failed", "批量电源任务提交前过期,未发送修改" if jobStage != "prepared" { status, stage, message = "timeout", "uncertain", "原批量电源任务结果未确认,不会自动重发" } } _, err = tx.ExecContext(ctx, "UPDATE power_batch_items SET stage=$1 WHERE task_id=$2", stage, t.ID) if err == nil && status == "success" { power := "running" if t.Action == "shutdown" { power = "stopped" } _, err = tx.ExecContext(ctx, "UPDATE desktops SET power_state=$1,service_state='unknown',updated_at=now() WHERE id=$2", power, t.TargetID) } if err == nil { err = s.completeTx(ctx, tx, t, status, message) } if err == nil { _ = tx.Commit() } } func (s *Server) reconcileBatchPower(c *gin.Context) { 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 := c.Request.Context() var t model.Task if err := tx.GetContext(ctx, &t, "SELECT * FROM tasks WHERE id=$1 AND kind='batch-desktop'", c.Param("id")); err != nil { s.dbError(c, err) return } var id string if err := tx.GetContext(ctx, &id, "SELECT id FROM desktops WHERE id=$1 FOR UPDATE", t.TargetID); err != nil { s.dbError(c, err) return } if err := tx.GetContext(ctx, &t, "SELECT * FROM tasks WHERE id=$1 FOR UPDATE", t.ID); err != nil { s.dbError(c, err) return } var stage string if err := tx.GetContext(ctx, &stage, "SELECT stage FROM power_batch_items WHERE task_id=$1 FOR UPDATE", t.ID); err != nil { s.dbError(c, err) return } if t.Status != "timeout" || stage != "uncertain" { s.fail(c, 409, "reconcile_unavailable", "仅可核对结果未确认的批量电源任务") return } if err := s.confirmBatchPower(ctx, tx, t); err != nil { s.fail(c, 409, "power_unconfirmed", "原 PVE 任务和实际电源状态尚未确认;未重新发送修改") return } _, err := tx.ExecContext(ctx, "UPDATE power_batch_items SET stage='completed' WHERE task_id=$1", t.ID) if err == nil { _, err = tx.ExecContext(ctx, "UPDATE tasks SET status='success',message='只读核对原任务和实际电源状态通过,未重新发送修改',updated_at=now() WHERE id=$1", t.ID) } if err == nil { power := "running" if t.Action == "shutdown" { power = "stopped" } _, err = tx.ExecContext(ctx, "UPDATE desktops SET power_state=$1,service_state='unknown',updated_at=now() WHERE id=$2", power, t.TargetID) } if err == nil { err = accountAudit(ctx, tx, actor.Username, "reconcile_batch_power", t.ID, c.GetString("requestId")) } if err == nil { err = tx.Commit() } if err != nil { s.dbError(c, err) return } c.JSON(200, gin.H{"accepted": true}) }