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