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