package server import ( "database/sql" "encoding/json" "sort" "strings" "time" "github.com/gin-gonic/gin" "vdi-cloud/platform/internal/store" ) // These settings affect management polling only, never the active stream. type PolicySettings struct { HeartbeatInterval int `json:"heartbeatInterval" db:"heartbeat_interval" binding:"min=15,max=60"` CommandPollInterval int `json:"commandPollInterval" db:"command_poll_interval" binding:"min=3,max=30"` DesktopRefreshInterval int `json:"desktopRefreshInterval" db:"desktop_refresh_interval" binding:"min=5,max=120"` } type terminalPolicy struct { ID string `json:"id" db:"id"` Name string `json:"name" db:"name" binding:"required,max=80"` Version string `json:"version" db:"version" binding:"required,max=40"` PolicySettings CreatedAt time.Time `json:"createdAt" db:"created_at"` } type policyDeployment struct { ID string `json:"id" db:"id"` TemplateID string `json:"templateId" db:"template_id"` TemplateName string `json:"templateName" db:"template_name"` TemplateVersion string `json:"templateVersion" db:"template_version"` Actor string `json:"actor" db:"actor"` TargetCount int `json:"targetCount" db:"target_count"` CreatedAt time.Time `json:"createdAt" db:"created_at"` } type policyResult struct { ID string `json:"id" db:"id"` TerminalID string `json:"terminalId" db:"terminal_id"` TerminalName string `json:"terminalName" db:"terminal_name"` Group string `json:"group" db:"group_name"` Protocol int `json:"protocol" db:"protocol"` DeploymentID string `json:"deploymentId" db:"deployment_id"` TemplateName string `json:"templateName" db:"template_name"` TemplateVersion string `json:"templateVersion" db:"template_version"` ConfigVersion int `json:"configVersion" db:"config_version"` Status string `json:"status" db:"status"` ErrorCode string `json:"errorCode" db:"error_code"` AcknowledgedAt *time.Time `json:"acknowledgedAt" db:"acknowledged_at"` Online bool `json:"online" db:"online"` Enabled bool `json:"enabled" db:"enabled"` Registered bool `json:"registered" db:"registered"` } const deploymentSQL = `SELECT d.id,d.template_id,p.name template_name,p.version template_version,d.actor, (SELECT count(*) FROM terminal_policy_deliveries r WHERE r.deployment_id=d.id) target_count,d.created_at FROM terminal_policy_deployments d JOIN terminal_policy_templates p ON p.id=d.template_id` const resultSQL = `SELECT COALESCE(r.id,'') id,t.id terminal_id,t.name terminal_name,t.group_name, COALESCE(c.protocol,0) protocol,COALESCE(r.deployment_id,'') deployment_id, COALESCE(p.name,'') template_name,COALESCE(p.version,'') template_version, t.config_version,COALESCE(r.status,'unconfigured') status,COALESCE(r.error_code,'') error_code,r.acknowledged_at, COALESCE(t.last_seen>now()-interval '90 seconds',false) online,t.enabled,(t.token_hash IS NOT NULL) registered FROM terminals t LEFT JOIN terminal_policy_capabilities c ON c.terminal_id=t.id LEFT JOIN terminal_policy_deliveries r ON r.terminal_id=t.id AND r.config_version=t.config_version LEFT JOIN terminal_policy_deployments d ON d.id=r.deployment_id LEFT JOIN terminal_policy_templates p ON p.id=d.template_id` func (s *Server) policyTemplates(c *gin.Context) { items := []terminalPolicy{} if err := s.DB.SelectContext(c.Request.Context(), &items, "SELECT * FROM terminal_policy_templates ORDER BY created_at DESC,id"); err != nil { s.dbError(c, err) return } list(c, items) } func (s *Server) createPolicyTemplate(c *gin.Context) { var in terminalPolicy if !s.decode(c, &in) { return } in.Name = strings.TrimSpace(in.Name) in.Version = strings.TrimSpace(in.Version) if in.Name == "" || in.Version == "" { s.fail(c, 400, "invalid_request", "策略名称和版本不能为空") return } tx, err := s.DB.BeginTxx(c.Request.Context(), nil) if err != nil { s.dbError(c, err) return } defer tx.Rollback() err = tx.GetContext(c.Request.Context(), &in, `INSERT INTO terminal_policy_templates(id,name,version,heartbeat_interval,command_poll_interval,desktop_refresh_interval) VALUES($1,$2,$3,$4,$5,$6) RETURNING *`, store.ID("policy_"), in.Name, in.Version, in.HeartbeatInterval, in.CommandPollInterval, in.DesktopRefreshInterval) if conflict(err) { s.fail(c, 409, "policy_exists", "该策略名称和版本已存在,请创建新版本") return } if err == nil { err = accountAudit(c.Request.Context(), tx, s.admin(c).Username, "create_terminal_policy", in.ID, c.GetString("requestId")) } if err == nil { err = tx.Commit() } if err != nil { s.dbError(c, err) return } c.JSON(201, in) } func (s *Server) policyDeployments(c *gin.Context) { items := []policyDeployment{} if err := s.DB.SelectContext(c.Request.Context(), &items, deploymentSQL+" ORDER BY d.created_at DESC,d.id"); err != nil { s.dbError(c, err) return } list(c, items) } func (s *Server) policyStates(c *gin.Context) { items := []policyResult{} if err := s.DB.SelectContext(c.Request.Context(), &items, resultSQL+" ORDER BY t.name,t.id"); err != nil { s.dbError(c, err) return } list(c, items) } func (s *Server) policyResults(c *gin.Context) { var exists bool if err := s.DB.GetContext(c.Request.Context(), &exists, "SELECT EXISTS(SELECT 1 FROM terminal_policy_deployments WHERE id=$1)", c.Param("id")); err != nil { s.dbError(c, err) return } if !exists { s.fail(c, 404, "not_found", "下发记录不存在") return } // Historical results retain their own version and status after a newer dispatch. items := []policyResult{} query := `SELECT r.id,t.id terminal_id,t.name terminal_name,t.group_name,COALESCE(c.protocol,0) protocol, r.deployment_id,p.name template_name,p.version template_version,r.config_version,r.status,r.error_code,r.acknowledged_at, COALESCE(t.last_seen>now()-interval '90 seconds',false) online,t.enabled,(t.token_hash IS NOT NULL) registered FROM terminal_policy_deliveries r JOIN terminals t ON t.id=r.terminal_id LEFT JOIN terminal_policy_capabilities c ON c.terminal_id=t.id JOIN terminal_policy_deployments d ON d.id=r.deployment_id JOIN terminal_policy_templates p ON p.id=d.template_id WHERE r.deployment_id=$1 ORDER BY t.name,t.id` if err := s.DB.SelectContext(c.Request.Context(), &items, query, c.Param("id")); err != nil { s.dbError(c, err) return } list(c, items) } func (s *Server) deployPolicy(c *gin.Context) { var in struct { TemplateID string `json:"templateId" binding:"required,max=100"` TargetMode string `json:"targetMode" binding:"required,oneof=devices group"` TerminalIDs []string `json:"terminalIds" binding:"max=200,dive,required,max=100"` Group string `json:"group" binding:"max=80"` } if !s.decode(c, &in) { return } in.Group = strings.TrimSpace(in.Group) in.TerminalIDs = uniqueIDs(in.TerminalIDs) sort.Strings(in.TerminalIDs) if (in.TargetMode == "devices" && (len(in.TerminalIDs) == 0 || in.Group != "")) || (in.TargetMode == "group" && (in.Group == "" || len(in.TerminalIDs) > 0)) { s.fail(c, 400, "invalid_request", "请选择设备或一个非空分组") return } key := strings.TrimSpace(c.GetHeader("Idempotency-Key")) if key == "" || len(key) > 100 { s.fail(c, 400, "invalid_request", "下发需要有效的请求标识") return } raw, _ := json.Marshal(in) fingerprint := store.Hash(string(raw)) ctx := c.Request.Context() actor := s.admin(c).Username tx, err := s.DB.BeginTxx(ctx, nil) if err != nil { s.dbError(c, err) return } defer tx.Rollback() if _, err = tx.ExecContext(ctx, "SELECT pg_advisory_xact_lock(hashtextextended($1,765110))", actor+":"+key); err != nil { s.dbError(c, err) return } var old struct { ID string `db:"id"` Hash string `db:"request_hash"` } err = tx.GetContext(ctx, &old, "SELECT id,request_hash FROM terminal_policy_deployments WHERE actor=$1 AND idempotency_key=$2", actor, key) if err == nil { if old.Hash != fingerprint { s.fail(c, 409, "idempotency_conflict", "请求标识已用于其他下发参数") return } var out policyDeployment if err = tx.GetContext(ctx, &out, deploymentSQL+" WHERE d.id=$1", old.ID); err != nil { s.dbError(c, err) return } c.JSON(202, out) return } if err != sql.ErrNoRows { s.dbError(c, err) return } var template terminalPolicy if err = tx.GetContext(ctx, &template, "SELECT * FROM terminal_policy_templates WHERE id=$1", in.TemplateID); err != nil { s.dbError(c, err) return } var targets []struct { ID string `db:"id"` Enabled bool `db:"enabled"` Registered bool `db:"registered"` Protocol int `db:"protocol"` } query := `SELECT t.id,t.enabled,(t.token_hash IS NOT NULL) registered,COALESCE(c.protocol,0) protocol FROM terminals t LEFT JOIN terminal_policy_capabilities c ON c.terminal_id=t.id WHERE ` var arg any = in.TerminalIDs if in.TargetMode == "group" { query += "t.group_name=$1" arg = in.Group } else { query += "t.id=ANY($1)" } if err = tx.SelectContext(ctx, &targets, query+" ORDER BY t.id FOR UPDATE OF t", arg); err != nil { s.dbError(c, err) return } if len(targets) == 0 || len(targets) > 200 || (in.TargetMode == "devices" && len(targets) != len(in.TerminalIDs)) { s.fail(c, 400, "invalid_targets", "目标为空、设备不存在或超过 200 台,请缩小范围") return } for _, t := range targets { if !t.Enabled || !t.Registered || t.Protocol != 1 { s.fail(c, 409, "policy_unsupported", "所选设备包含已停用、未激活或不支持策略的终端,请检查设备结果") return } } id := store.ID("dispatch_") if _, err = tx.ExecContext(ctx, "INSERT INTO terminal_policy_deployments(id,template_id,actor,idempotency_key,request_hash) VALUES($1,$2,$3,$4,$5)", id, in.TemplateID, actor, key, fingerprint); err != nil { s.dbError(c, err) return } for _, t := range targets { var version int if err = tx.GetContext(ctx, &version, "UPDATE terminals SET config_version=config_version+1 WHERE id=$1 RETURNING config_version", t.ID); err != nil { s.dbError(c, err) return } if _, err = tx.ExecContext(ctx, "UPDATE terminal_policy_deliveries SET status='superseded' WHERE terminal_id=$1 AND status IN ('pending','failed')", t.ID); err != nil { s.dbError(c, err) return } if _, err = tx.ExecContext(ctx, "INSERT INTO terminal_policy_deliveries(id,deployment_id,terminal_id,config_version) VALUES($1,$2,$3,$4)", store.ID("delivery_"), id, t.ID, version); err != nil { s.dbError(c, err) return } } var out policyDeployment if err = tx.GetContext(ctx, &out, deploymentSQL+" WHERE d.id=$1", id); err == nil { err = accountAudit(ctx, tx, actor, "deploy_terminal_policy", id, c.GetString("requestId")) } if err == nil { err = tx.Commit() } if err != nil { s.dbError(c, err) return } c.JSON(202, out) } func (s *Server) policyAck(c *gin.Context) { var in struct { DeploymentID string `json:"deploymentId" binding:"required,max=100"` ConfigVersion int `json:"configVersion" binding:"min=2"` Status string `json:"status" binding:"required,oneof=applied failed"` ErrorCode string `json:"errorCode" binding:"omitempty,oneof=invalid_policy persist_failed runtime_unavailable"` } if !s.decode(c, &in) { return } if (in.Status == "applied" && in.ErrorCode != "") || (in.Status == "failed" && in.ErrorCode == "") { s.fail(c, 400, "invalid_request", "请提供匹配应用结果的错误代码") return } tx := deviceTx(c) ctx := c.Request.Context() var row struct { ID string `db:"id"` Status string `db:"status"` ErrorCode string `db:"error_code"` } err := tx.GetContext(ctx, &row, `SELECT r.id,r.status,r.error_code FROM terminal_policy_deliveries r JOIN terminals t ON t.id=r.terminal_id WHERE r.terminal_id=$1 AND r.deployment_id=$2 AND r.config_version=$3 AND t.config_version=r.config_version`, c.GetString("terminalId"), in.DeploymentID, in.ConfigVersion) if err == sql.ErrNoRows { s.fail(c, 409, "stale_policy", "该策略已被更新,请重新拉取配置") return } if err != nil { s.dbError(c, err) return } // A successful application cannot be downgraded by a delayed failed response. if row.Status == "applied" || (row.Status == in.Status && row.ErrorCode == in.ErrorCode) { s.deviceJSON(c, 200, gin.H{"accepted": true}) return } if _, err = tx.ExecContext(ctx, "UPDATE terminal_policy_deliveries SET status=$1,error_code=$2,acknowledged_at=now() WHERE id=$3", in.Status, in.ErrorCode, row.ID); err != nil { s.dbError(c, err) return } if err = accountAudit(ctx, tx, "device", "terminal_policy_"+in.Status, row.ID, c.GetString("requestId")); err != nil { s.dbError(c, err) return } s.deviceJSON(c, 200, gin.H{"accepted": true}) }