vdi-cloud/platform/internal/server/power_batches.go

331 lines
13 KiB
Go

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
}
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)
}