337 lines
13 KiB
Go
337 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
|
|
}
|
|
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)
|
|
}
|