Files
vdi-cloud/platform/internal/server/assistant.go
T

303 lines
13 KiB
Go

package server
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin"
"vdi-cloud/platform/internal/model"
"vdi-cloud/platform/internal/store"
)
type assistantCandidate struct {
ID string `json:"id"`
Label string `json:"label"`
Kind string `json:"kind"`
}
type assistantPlan struct {
Action string `json:"action"`
Kind string `json:"kind"`
Title string `json:"title"`
Summary string `json:"summary"`
UserID string `json:"userId,omitempty"`
UserName string `json:"userName,omitempty"`
DesktopID string `json:"desktopId,omitempty"`
DesktopName string `json:"desktopName,omitempty"`
DesktopVMID int `json:"desktopVmid,omitempty"`
Missing []string `json:"missing"`
Candidates []assistantCandidate `json:"candidates"`
Risk string `json:"risk"`
ConfirmationRequired bool `json:"confirmationRequired"`
Path string `json:"path,omitempty"`
Reason string `json:"reason,omitempty"`
}
type assistantRun struct {
ID string `json:"id" db:"id"`
Input string `json:"input" db:"input_text"`
Action string `json:"action" db:"action"`
Status string `json:"status" db:"status"`
Plan assistantPlan `json:"plan"`
CreatedAt time.Time `json:"createdAt" db:"created_at"`
ExpiresAt time.Time `json:"expiresAt" db:"expires_at"`
ExecutedAt *time.Time `json:"executedAt,omitempty" db:"executed_at"`
Result map[string]any `json:"result,omitempty" db:"-"`
}
func assistantTextMatch(text, value string) bool {
text, value = strings.ToLower(strings.TrimSpace(text)), strings.ToLower(strings.TrimSpace(value))
return value != "" && strings.Contains(text, value)
}
func (s *Server) assistantPlanHandler(c *gin.Context) {
var in struct {
Text string `json:"text" binding:"required,max=500"`
}
if !s.decode(c, &in) {
return
}
text := strings.TrimSpace(in.Text)
plan := assistantPlan{Kind: "blocked", Risk: "low", Missing: []string{}, Candidates: []assistantCandidate{}, ConfirmationRequired: false}
revoke := strings.Contains(text, "撤销") || strings.Contains(text, "收回") || strings.Contains(text, "取消授权")
grant := !revoke && (strings.Contains(text, "分配") || strings.Contains(text, "授权") || strings.Contains(text, "安排") || strings.Contains(text, "开通"))
if !grant && !revoke {
if strings.Contains(text, "打不开") || strings.Contains(text, "无法连接") || strings.Contains(text, "连不上") || strings.Contains(text, "故障") {
plan = assistantPlan{Kind: "route", Action: "diagnose_desktop", Title: "进入桌面问题处理", Summary: "先检查终端在线状态、员工关联、桌面授权、电源和连接服务。", Path: "/help", Risk: "low", Missing: []string{}, Candidates: []assistantCandidate{}}
} else if strings.Contains(text, "状态") || strings.Contains(text, "在线") || strings.Contains(text, "离线") {
plan = assistantPlan{Kind: "route", Action: "query_status", Title: "查看当前状态", Summary: "打开设备与桌面状态页面,查看在线设备、桌面电源和最近刷新时间。", Path: "/devices", Risk: "low", Missing: []string{}, Candidates: []assistantCandidate{}}
} else {
plan.Reason = "暂不支持这个固定场景"
plan.Summary = "请描述分配、撤销、桌面连接问题或设备状态;复杂运维操作请使用高级版。"
}
} else {
ctx := c.Request.Context()
var users []model.User
var desktops []model.Desktop
if err := s.DB.SelectContext(ctx, &users, "SELECT * FROM users WHERE enabled ORDER BY display_name,id"); err != nil {
s.dbError(c, err)
return
}
if err := s.DB.SelectContext(ctx, &desktops, "SELECT * FROM desktops WHERE provider IN ('demo','proxmox') ORDER BY name,id"); err != nil {
s.dbError(c, err)
return
}
userMatches := []model.User{}
for _, user := range users {
if assistantTextMatch(text, user.DisplayName) || assistantTextMatch(text, user.Username) {
userMatches = append(userMatches, user)
}
}
if len(userMatches) != 1 {
plan.Missing = append(plan.Missing, "user")
for _, user := range userMatches {
plan.Candidates = append(plan.Candidates, assistantCandidate{ID: user.ID, Label: user.DisplayName + " · " + user.Username, Kind: "user"})
}
if len(userMatches) == 0 {
for i, user := range users {
if i >= 20 {
break
}
plan.Candidates = append(plan.Candidates, assistantCandidate{ID: user.ID, Label: user.DisplayName + " · " + user.Username, Kind: "user"})
}
}
plan.Title, plan.Summary, plan.Reason = "需要确认员工", "请写出员工姓名或账号;如果有同名员工,请选择准确对象。", "没有唯一匹配到员工"
} else {
user := userMatches[0]
desktopMatches := []model.Desktop{}
for _, desktop := range desktops {
if assistantTextMatch(text, desktop.Name) || strings.Contains(text, strings.TrimSpace(stringID(desktop.VMID))) {
desktopMatches = append(desktopMatches, desktop)
}
}
if len(desktopMatches) != 1 {
plan.Missing = append(plan.Missing, "desktop")
for _, desktop := range desktopMatches {
plan.Candidates = append(plan.Candidates, assistantCandidate{ID: desktop.ID, Label: desktop.Name + " · VM " + stringID(desktop.VMID), Kind: "desktop"})
}
if len(desktopMatches) == 0 {
for i, desktop := range desktops {
if i >= 20 {
break
}
plan.Candidates = append(plan.Candidates, assistantCandidate{ID: desktop.ID, Label: desktop.Name + " · VM " + stringID(desktop.VMID), Kind: "desktop"})
}
}
plan.Title, plan.Summary, plan.Reason = "需要确认桌面", "请写出桌面名称或 VMID,再继续生成方案。", "没有唯一匹配到桌面"
} else {
desktop := desktopMatches[0]
plan.UserID, plan.UserName, plan.DesktopID, plan.DesktopName, plan.DesktopVMID = user.ID, user.DisplayName, desktop.ID, desktop.Name, desktop.VMID
plan.Action, plan.Kind, plan.Risk, plan.ConfirmationRequired = map[bool]string{true: "grant_desktop", false: "revoke_desktop"}[grant], "action", "medium", true
if grant {
plan.Title, plan.Summary = "分配已有桌面", "为 "+user.DisplayName+" 追加“"+desktop.Name+"”使用权限。不会创建 VM、不会自动开机。"
var exists bool
if err := s.DB.GetContext(ctx, &exists, "SELECT EXISTS(SELECT 1 FROM bindings WHERE user_id=$1 AND desktop_id=$2)", user.ID, desktop.ID); err != nil {
s.dbError(c, err)
return
}
if exists {
plan.Kind, plan.Reason, plan.Summary = "blocked", "授权已经存在", user.DisplayName+" 已经拥有“"+desktop.Name+"”的使用权限。"
}
} else {
plan.Title, plan.Summary = "撤销桌面授权", "撤销 "+user.DisplayName+" 对“"+desktop.Name+"”的使用权限,不会关机或中断已建立的连接。"
var exists bool
if err := s.DB.GetContext(ctx, &exists, "SELECT EXISTS(SELECT 1 FROM bindings WHERE user_id=$1 AND desktop_id=$2)", user.ID, desktop.ID); err != nil {
s.dbError(c, err)
return
}
if !exists {
plan.Kind, plan.Reason, plan.Summary = "blocked", "授权不存在", user.DisplayName+" 当前没有“"+desktop.Name+"”的使用权限。"
}
}
}
}
}
planJSON, _ := json.Marshal(plan)
id := store.ID("assist_")
status := "planned"
if plan.Kind == "blocked" {
status = "blocked"
}
expires := time.Now().UTC().Add(5 * time.Minute)
fingerprint := sha256.Sum256(append([]byte(text+"|"+c.MustGet("admin").(model.Administrator).Username+"|"), planJSON...))
if _, err := s.DB.ExecContext(c.Request.Context(), `INSERT INTO assistant_runs(id,actor,input_text,action,status,plan,fingerprint,expires_at) VALUES($1,$2,$3,$4,$5,$6,$7,$8)`, id, s.admin(c).Username, text, plan.Action, status, string(planJSON), hex.EncodeToString(fingerprint[:]), expires); err != nil {
s.dbError(c, err)
return
}
c.JSON(200, gin.H{"runId": id, "status": status, "input": text, "plan": plan, "createdAt": time.Now().UTC(), "expiresAt": expires})
}
func stringID(value int) string { return strconv.Itoa(value) }
func (s *Server) assistantExecute(c *gin.Context) {
var in struct {
RunID string `json:"runId" binding:"required,max=100"`
}
if !s.decode(c, &in) {
return
}
ctx := c.Request.Context()
tx, err := s.DB.BeginTxx(ctx, nil)
if err != nil {
s.dbError(c, err)
return
}
defer tx.Rollback()
var row struct {
ID string `db:"id"`
Input string `db:"input_text"`
Action string `db:"action"`
Status string `db:"status"`
Plan string `db:"plan"`
ExpiresAt time.Time `db:"expires_at"`
Result []byte `db:"result"`
}
if err = tx.GetContext(ctx, &row, "SELECT id,input_text,action,status,plan,expires_at,result FROM assistant_runs WHERE id=$1 AND actor=$2 FOR UPDATE", in.RunID, s.admin(c).Username); err != nil {
s.dbError(c, err)
return
}
if row.Status == "executed" {
result := map[string]any{}
_ = json.Unmarshal(row.Result, &result)
c.JSON(200, gin.H{"runId": in.RunID, "status": "executed", "result": result, "idempotent": true})
return
}
if row.Status != "planned" {
s.fail(c, 409, "assistant_not_executable", "方案已经执行、阻止或失效")
return
}
if time.Now().UTC().After(row.ExpiresAt) {
_, _ = tx.ExecContext(ctx, "UPDATE assistant_runs SET status='expired' WHERE id=$1", in.RunID)
s.fail(c, 409, "assistant_plan_expired", "方案已过期,请重新生成")
return
}
var plan assistantPlan
if json.Unmarshal([]byte(row.Plan), &plan) != nil || plan.Kind != "action" || (plan.Action != "grant_desktop" && plan.Action != "revoke_desktop") {
s.fail(c, 409, "assistant_invalid_plan", "方案内容无效,请重新生成")
return
}
if !s.lockAuthorization(c, tx) {
return
}
var user model.User
var desktop model.Desktop
if err = tx.GetContext(ctx, &user, "SELECT * FROM users WHERE id=$1 FOR UPDATE", plan.UserID); err != nil {
s.fail(c, 409, "assistant_state_changed", "员工已不存在或状态已变化,请重新生成方案")
return
}
if err = tx.GetContext(ctx, &desktop, "SELECT * FROM desktops WHERE id=$1 FOR UPDATE", plan.DesktopID); err != nil {
s.fail(c, 409, "assistant_state_changed", "桌面已不存在或状态已变化,请重新生成方案")
return
}
var exists bool
if err = tx.GetContext(ctx, &exists, "SELECT EXISTS(SELECT 1 FROM bindings WHERE user_id=$1 AND desktop_id=$2)", user.ID, desktop.ID); err != nil {
s.dbError(c, err)
return
}
if plan.Action == "grant" || plan.Action == "grant_desktop" {
if exists {
s.fail(c, 409, "assistant_state_changed", "授权已经被其他操作创建,请重新生成方案")
return
}
_, err = tx.ExecContext(ctx, "INSERT INTO bindings(user_id,desktop_id) VALUES($1,$2)", user.ID, desktop.ID)
} else {
if !exists {
s.fail(c, 409, "assistant_state_changed", "授权已经被其他操作撤销,请重新生成方案")
return
}
_, err = tx.ExecContext(ctx, "DELETE FROM bindings WHERE user_id=$1 AND desktop_id=$2", user.ID, desktop.ID)
}
if err == nil {
_, err = tx.ExecContext(ctx, "DELETE FROM connection_tickets WHERE consumed_at IS NULL AND terminal_id=(SELECT terminal_id FROM users WHERE id=$1)", user.ID)
}
if err == nil {
err = accountAudit(ctx, tx, s.admin(c).Username, "assistant_"+plan.Action, user.ID, c.GetString("requestId"))
}
if err != nil {
s.dbError(c, err)
return
}
result := map[string]any{"userId": user.ID, "desktopId": desktop.ID, "action": plan.Action, "message": "操作已保存"}
encoded, _ := json.Marshal(result)
if _, err = tx.ExecContext(ctx, "UPDATE assistant_runs SET status='executed',executed_at=now(),result=$1 WHERE id=$2", string(encoded), in.RunID); err != nil {
s.dbError(c, err)
return
}
if err = tx.Commit(); err != nil {
s.dbError(c, err)
return
}
c.JSON(200, gin.H{"runId": in.RunID, "status": "executed", "result": result})
}
func (s *Server) assistantRun(c *gin.Context) {
var row struct {
ID string `db:"id"`
Input string `db:"input_text"`
Action string `db:"action"`
Status string `db:"status"`
Plan string `db:"plan"`
CreatedAt time.Time `db:"created_at"`
ExpiresAt time.Time `db:"expires_at"`
ExecutedAt *time.Time `db:"executed_at"`
Result []byte `db:"result"`
}
query := "SELECT id,input_text,action,status,plan,created_at,expires_at,executed_at,result FROM assistant_runs WHERE id=$1"
args := []any{c.Param("id")}
if s.admin(c).Role != "super_admin" {
query += " AND actor=$2"
args = append(args, s.admin(c).Username)
}
if err := s.DB.GetContext(c.Request.Context(), &row, query, args...); err != nil {
s.dbError(c, err)
return
}
var plan assistantPlan
_ = json.Unmarshal([]byte(row.Plan), &plan)
result := map[string]any{}
_ = json.Unmarshal(row.Result, &result)
c.JSON(200, gin.H{"runId": row.ID, "input": row.Input, "action": row.Action, "status": row.Status, "plan": plan, "createdAt": row.CreatedAt, "expiresAt": row.ExpiresAt, "executedAt": row.ExecutedAt, "result": result})
}