功能:新增云管API与Web控制台并完成现场接入

这个提交包含在:
songyiming 于 2026-11-09 17:54:52 +08:00 提交
1 父节点 18a9ce995d
当前提交 3fb7b4da8c
79 个文件已更改
+17367 -3

没有与搜索条件相匹配的文件

+553
查看文件
@@ -0,0 +1,553 @@
package server
import (
"context"
"database/sql"
"errors"
"fmt"
"github.com/gin-gonic/gin"
"github.com/jackc/pgx/v5/pgconn"
"log"
"net"
"strings"
"time"
"vdi-cloud/platform/internal/model"
"vdi-cloud/platform/internal/store"
"vdi-cloud/platform/pkg/proxmox"
)
func (s *Server) overview(c *gin.Context) {
var counts struct {
Terminals int `db:"terminals"`
Online int `db:"online"`
Desktops int `db:"desktops"`
Running int `db:"running"`
Sessions int `db:"sessions"`
Failed int `db:"failed"`
}
err := s.DB.GetContext(c.Request.Context(), &counts, `SELECT (SELECT count(*) FROM terminals) terminals,(SELECT count(*) FROM terminals WHERE last_seen>now()-interval '90 seconds') online,(SELECT count(*) FROM desktops) desktops,(SELECT count(*) FROM desktops WHERE power_state='running') running,(SELECT count(*) FROM terminals WHERE session_state='connected' AND session_desktop_id IS NOT NULL AND last_seen>now()-interval '90 seconds') sessions,(SELECT count(*) FROM tasks WHERE status IN ('failed','timeout') AND created_at>now()-interval '24 hours') failed`)
if err != nil {
s.dbError(c, err)
return
}
c.JSON(200, gin.H{"terminalCount": counts.Terminals, "onlineTerminals": counts.Online, "desktopCount": counts.Desktops, "runningDesktops": counts.Running, "activeSessions": counts.Sessions, "failedTasks": counts.Failed, "mode": s.Config.Mode})
}
func (s *Server) settings(c *gin.Context) {
provider := "unconfigured"
if s.Config.Mode == "demo" {
provider = "demo"
}
if s.PVE != nil {
provider = "proxmox"
}
c.JSON(200, gin.H{"mode": s.Config.Mode, "computeProvider": provider, "pveConfigured": s.PVE != nil, "terminalTransport": "https-poll", "capabilities": []string{"auth", "resources", "desktops", "terminals", "bindings", "tasks", "audit"}})
}
func (s *Server) resources(c *gin.Context) {
items := []model.Resource{}
if err := s.DB.SelectContext(c.Request.Context(), &items, "SELECT * FROM resources ORDER BY name"); err != nil {
s.dbError(c, err)
return
}
list(c, items)
}
func (s *Server) desktops(c *gin.Context) {
items := []model.Desktop{}
q := "%" + c.Query("keyword") + "%"
err := s.DB.SelectContext(c.Request.Context(), &items, `SELECT * FROM desktops WHERE (name ILIKE $1 OR vmid::text ILIKE $1) AND ($2='' OR power_state=$2) ORDER BY name`, q, c.Query("status"))
if err != nil {
s.dbError(c, err)
return
}
list(c, items)
}
func (s *Server) desktop(c *gin.Context) {
var d model.Desktop
if err := s.DB.GetContext(c.Request.Context(), &d, "SELECT * FROM desktops WHERE id=$1", c.Param("id")); err != nil {
s.dbError(c, err)
return
}
c.JSON(200, d)
}
func (s *Server) terminals(c *gin.Context) {
items := []model.Terminal{}
err := s.DB.SelectContext(c.Request.Context(), &items, `SELECT id,sn,name,group_name,CASE WHEN last_seen>now()-interval '90 seconds' THEN 'online' WHEN status='unregistered' THEN 'unregistered' ELSE 'offline' END status,agent_version,last_seen,CASE WHEN last_seen>now()-interval '90 seconds' THEN session_desktop_id ELSE NULL END session_desktop_id,session_state,config_version,fps,rtt FROM terminals WHERE (name ILIKE $1 OR sn ILIKE $1) ORDER BY name`, "%"+c.Query("keyword")+"%")
if err != nil {
s.dbError(c, err)
return
}
list(c, items)
}
func (s *Server) users(c *gin.Context) {
items, err := store.Users(c.Request.Context(), s.DB)
if err != nil {
s.dbError(c, err)
return
}
list(c, items)
}
func (s *Server) createUser(c *gin.Context) {
var in struct {
Username string `json:"username" binding:"required,min=2,max=80"`
DisplayName string `json:"displayName" binding:"required,max=80"`
}
if !s.decode(c, &in) {
return
}
in.Username = strings.TrimSpace(in.Username)
in.DisplayName = strings.TrimSpace(in.DisplayName)
if in.Username == "" || in.DisplayName == "" {
s.fail(c, 400, "invalid_request", "账号和姓名不能为空")
return
}
id := store.ID("user_")
_, err := s.DB.ExecContext(c.Request.Context(), "INSERT INTO users(id,username,display_name) VALUES($1,$2,$3)", id, in.Username, in.DisplayName)
if err != nil {
if conflict(err) {
s.fail(c, 409, "conflict", "该账号已存在")
} else {
s.dbError(c, err)
}
return
}
s.log(c, "create_user", id, "success")
c.JSON(201, model.User{ID: id, Username: in.Username, DisplayName: in.DisplayName, Enabled: true, DesktopIDs: []string{}})
}
func conflict(err error) bool {
var e *pgconn.PgError
return errors.As(err, &e) && (e.Code == "23505" || e.Code == "23503")
}
func (s *Server) bindings(c *gin.Context) {
var in struct {
TerminalID *string `json:"terminalId"`
DesktopIDs []string `json:"desktopIds" binding:"max=100"`
Enabled *bool `json:"enabled"`
}
if !s.decode(c, &in) {
return
}
if in.TerminalID != nil && *in.TerminalID == "" {
in.TerminalID = nil
}
ctx := c.Request.Context()
tx, err := s.DB.BeginTxx(ctx, nil)
if err != nil {
s.dbError(c, err)
return
}
defer tx.Rollback()
var user model.User
if err = tx.GetContext(ctx, &user, "SELECT * FROM users WHERE id=$1 FOR UPDATE", c.Param("id")); err != nil {
s.dbError(c, err)
return
}
if in.Enabled != nil {
user.Enabled = *in.Enabled
}
_, err = tx.ExecContext(ctx, "UPDATE users SET terminal_id=$1,enabled=$2 WHERE id=$3", in.TerminalID, user.Enabled, user.ID)
if err != nil {
if conflict(err) {
s.fail(c, 409, "binding_conflict", "终端不存在或已关联其他用户")
} else {
s.dbError(c, err)
}
return
}
if _, err = tx.ExecContext(ctx, "DELETE FROM bindings WHERE user_id=$1", user.ID); err != nil {
s.dbError(c, err)
return
}
seen := map[string]bool{}
for _, id := range in.DesktopIDs {
if seen[id] {
continue
}
seen[id] = true
if _, err = tx.ExecContext(ctx, "INSERT INTO bindings VALUES($1,$2)", user.ID, id); err != nil {
if conflict(err) {
s.fail(c, 400, "invalid_desktop", "所选桌面不存在")
} else {
s.dbError(c, err)
}
return
}
}
_, err = tx.ExecContext(ctx, "INSERT INTO audit_logs VALUES($1,$2,'update_bindings',$3,'success',$4,now())", store.ID("audit_"), s.admin(c).Username, user.ID, c.GetString("requestId"))
if err == nil {
err = tx.Commit()
}
if err != nil {
s.dbError(c, err)
return
}
user.TerminalID = in.TerminalID
user.DesktopIDs = in.DesktopIDs
if user.DesktopIDs == nil {
user.DesktopIDs = []string{}
}
c.JSON(200, user)
}
func (s *Server) tasks(c *gin.Context) {
items := []model.Task{}
if err := s.DB.SelectContext(c.Request.Context(), &items, "SELECT * FROM tasks ORDER BY created_at DESC LIMIT 1000"); err != nil {
s.dbError(c, err)
return
}
list(c, items)
}
func (s *Server) task(c *gin.Context) {
var t model.Task
if err := s.DB.GetContext(c.Request.Context(), &t, "SELECT * FROM tasks WHERE id=$1", c.Param("id")); err != nil {
s.dbError(c, err)
return
}
c.JSON(200, t)
}
func (s *Server) audits(c *gin.Context) {
items := []model.Audit{}
if err := s.DB.SelectContext(c.Request.Context(), &items, "SELECT * FROM audit_logs ORDER BY created_at DESC LIMIT 1000"); err != nil {
s.dbError(c, err)
return
}
list(c, items)
}
func (s *Server) discoverVMs(c *gin.Context) {
if s.PVE == nil {
s.fail(c, 503, "pve_unconfigured", "尚未配置 Proxmox 接入,演示环境不发现真实 VM")
return
}
items, err := s.PVE.Resources(c.Request.Context())
if err != nil {
s.fail(c, 502, "pve_unavailable", "Proxmox 查询失败,请检查连接、证书和访问权限")
return
}
vms := []gin.H{}
for _, r := range items {
if r.Type == "qemu" && r.Template == 0 {
vms = append(vms, gin.H{"vmid": r.VMID, "node": r.Node, "name": r.Name, "status": r.Status})
}
}
list(c, vms)
}
func (s *Server) syncResources(c *gin.Context) {
if s.PVE == nil {
s.fail(c, 503, "pve_unconfigured", "请通过服务端环境变量配置 Proxmox 接入")
return
}
s.submitTask(c, "resource", "sync", "cluster", "Proxmox 集群")
}
func (s *Server) importDesktop(c *gin.Context) {
var in struct {
Name string `json:"name" binding:"required,max=80"`
OS string `json:"os" binding:"required,oneof=Windows Ubuntu 麒麟"`
Node string `json:"node" binding:"required,max=100"`
VMID int `json:"vmid" binding:"min=100"`
Endpoint string `json:"endpoint" binding:"max=253"`
Width int `json:"width" binding:"omitempty,min=640,max=7680"`
Height int `json:"height" binding:"omitempty,min=480,max=4320"`
FPS int `json:"fps" binding:"omitempty,min=10,max=120"`
}
if !s.decode(c, &in) {
return
}
if s.PVE == nil {
s.fail(c, 503, "pve_unconfigured", "真实 VM 纳管需要先配置 Proxmox")
return
}
if in.Endpoint != "" && net.ParseIP(in.Endpoint) == nil {
for _, part := range strings.Split(in.Endpoint, ".") {
if part == "" || strings.ContainsAny(part, "/:@ \\") {
s.fail(c, 400, "invalid_endpoint", "连接地址应为 IP 或主机名")
return
}
}
}
power, err := s.PVE.Power(c.Request.Context(), in.Node, in.VMID)
if err != nil {
s.fail(c, 502, "pve_unavailable", "无法验证所选 VM,请检查节点和 VMID")
return
}
id := store.ID("desk_")
if in.Width == 0 {
in.Width = 2560
}
if in.Height == 0 {
in.Height = 1440
}
if in.FPS == 0 {
in.FPS = 30
}
_, err = s.DB.ExecContext(c.Request.Context(), `INSERT INTO desktops(id,name,os,node,vmid,power_state,provider,endpoint,width,height,fps) VALUES($1,$2,$3,$4,$5,$6,'proxmox',$7,$8,$9,$10)`, id, in.Name, in.OS, in.Node, in.VMID, power, in.Endpoint, in.Width, in.Height, in.FPS)
if err != nil {
if conflict(err) {
s.fail(c, 409, "already_managed", "该 VM 已纳管")
} else {
s.dbError(c, err)
}
return
}
s.log(c, "import_desktop", id, "success")
c.Params = append(c.Params, gin.Param{Key: "id", Value: id})
s.desktop(c)
}
func (s *Server) desktopAction(c *gin.Context) {
var in struct {
Action string `json:"action" binding:"required,oneof=start shutdown reboot"`
}
if !s.decode(c, &in) {
return
}
var d model.Desktop
if err := s.DB.GetContext(c.Request.Context(), &d, "SELECT * FROM desktops WHERE id=$1", c.Param("id")); err != nil {
s.dbError(c, err)
return
}
if d.Provider == "demo" && s.Config.Mode != "demo" {
s.fail(c, 409, "demo_disabled", "正式模式不执行演示桌面任务")
return
}
if d.Provider == "proxmox" && s.PVE == nil {
s.fail(c, 503, "pve_unconfigured", "Proxmox 尚未配置")
return
}
s.submitTask(c, "desktop", in.Action, d.ID, d.Name)
}
func (s *Server) terminalCommand(c *gin.Context) {
var in struct {
Action string `json:"action" binding:"required,oneof=reboot reset-session collect-logs"`
}
if !s.decode(c, &in) {
return
}
var name string
if err := s.DB.GetContext(c.Request.Context(), &name, "SELECT name FROM terminals WHERE id=$1", c.Param("id")); err != nil {
s.dbError(c, err)
return
}
s.submitTask(c, "terminal", in.Action, c.Param("id"), name)
}
func (s *Server) submitTask(c *gin.Context, kind, action, targetID, targetName string) {
key := c.GetHeader("Idempotency-Key")
if key == "" || len(key) > 100 {
s.fail(c, 400, "idempotency_required", "请提供不超过 100 字符的 Idempotency-Key")
return
}
actor := s.admin(c).Username
ctx := c.Request.Context()
var existing model.Task
err := s.DB.GetContext(ctx, &existing, "SELECT * FROM tasks WHERE actor=$1 AND idempotency_key=$2", actor, key)
if err == nil {
if existing.Kind != kind || existing.Action != action || existing.TargetID != targetID {
s.fail(c, 409, "idempotency_conflict", "相同请求标识不能用于不同操作")
return
}
c.JSON(202, existing)
return
}
if !errors.Is(err, sql.ErrNoRows) {
s.dbError(c, err)
return
}
tx, err := s.DB.BeginTxx(ctx, nil)
if err != nil {
s.dbError(c, err)
return
}
defer tx.Rollback()
var task model.Task
err = tx.GetContext(ctx, &task, `INSERT INTO tasks(id,kind,action,target_id,target_name,actor,status,message,idempotency_key) VALUES($1,$2,$3,$4,$5,$6,'queued','已提交,等待执行回执',$7) RETURNING *`, store.ID("task_"), kind, action, targetID, targetName, actor, key)
if err != nil {
if conflict(err) {
s.fail(c, 409, "task_conflict", "已有相同请求或该对象有任务执行中,请刷新任务列表")
} else {
s.dbError(c, err)
}
return
}
_, err = tx.ExecContext(ctx, "INSERT INTO audit_logs VALUES($1,$2,$3,$4,'submitted',$5,now())", store.ID("audit_"), actor, action, targetName, c.GetString("requestId"))
if err == nil {
err = tx.Commit()
}
if err != nil {
s.dbError(c, err)
return
}
c.JSON(202, task)
}
// Complete persists the final result and its audit entry together.
func (s *Server) complete(ctx context.Context, t model.Task, status, message string) error {
tx, err := s.DB.BeginTxx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
result, err := tx.ExecContext(ctx, "UPDATE tasks SET status=$1,message=$2,updated_at=now() WHERE id=$3 AND status IN ('running','queued')", status, message, t.ID)
if err != nil {
return err
}
n, _ := result.RowsAffected()
if n == 0 {
return nil
}
// Session release is scoped to a session ID by the device event endpoint.
// A delayed command acknowledgement must not clear a newer connection.
_, err = tx.ExecContext(ctx, "INSERT INTO audit_logs VALUES($1,$2,$3,$4,$5,$6,now())", store.ID("audit_"), t.Actor, t.Action, t.TargetName, status, t.ID)
if err != nil {
return err
}
return tx.Commit()
}
func ratio(a, b float64) float64 {
if b <= 0 {
return 0
}
return a / b
}
func (s *Server) sync(ctx context.Context) error {
items, err := s.PVE.Resources(ctx)
if err != nil {
return err
}
tx, err := s.DB.BeginTxx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
if _, err = tx.ExecContext(ctx, "DELETE FROM resources WHERE provider='proxmox'"); err != nil {
return err
}
for _, r := range items {
if r.Type == "node" {
if _, err = tx.ExecContext(ctx, `INSERT INTO resources VALUES($1,$1,$2,$3,$4,$5,'proxmox',now()) ON CONFLICT(id) DO UPDATE SET status=$2,cpu=$3,memory=$4,storage=$5,provider='proxmox',updated_at=now()`, r.Node, r.Status, r.CPU, ratio(r.Memory, r.MaxMemory), ratio(r.Disk, r.MaxDisk)); err != nil {
return err
}
}
if r.Type == "qemu" {
if _, err = tx.ExecContext(ctx, "UPDATE desktops SET power_state=$1,updated_at=now() WHERE provider='proxmox' AND node=$2 AND vmid=$3", r.Status, r.Node, r.VMID); err != nil {
return err
}
}
}
return tx.Commit()
}
func (s *Server) RunWorker(ctx context.Context) {
ticker := time.NewTicker(300 * time.Millisecond)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
// Unknown outcomes are never automatically retried after a worker restart.
var expired []model.Task
if s.DB.SelectContext(ctx, &expired, "SELECT * FROM tasks WHERE status IN ('queued','running') AND created_at<now()-interval '2 minutes'") == nil {
for _, t := range expired {
_ = s.complete(ctx, t, "timeout", "结果未确认,请核对设备或 PVE 实际状态;不会自动重放")
}
}
var t model.Task
err := s.DB.GetContext(ctx, &t, `UPDATE tasks SET status='running',updated_at=now(),message='正在执行,等待最终回执' WHERE id=(SELECT id FROM tasks WHERE status='queued' AND kind!='terminal' ORDER BY created_at FOR UPDATE SKIP LOCKED LIMIT 1) RETURNING *`)
if err != nil {
continue
}
s.execute(ctx, t)
}
}
}
// Resource reads are independent of the command queue and never change VM power.
func (s *Server) RunSync(ctx context.Context) {
ticker := time.NewTicker(30 * time.Second)
defer ticker.Stop()
for {
readCtx, cancel := context.WithTimeout(ctx, 15*time.Second)
if err := s.sync(readCtx); err != nil {
log.Printf("PVE resource sync failed: %v", err)
}
cancel()
select {
case <-ctx.Done():
return
case <-ticker.C:
}
}
}
func (s *Server) execute(parent context.Context, t model.Task) {
ctx, cancel := context.WithTimeout(parent, 90*time.Second)
defer cancel()
var err error
uncertain := false
message := "已收到执行结果"
if t.Kind == "resource" {
err = s.sync(ctx)
} else {
var d model.Desktop
err = s.DB.GetContext(ctx, &d, "SELECT * FROM desktops WHERE id=$1", t.TargetID)
if err == nil && d.Provider == "demo" {
if s.Config.Mode != "demo" {
err = errors.New("demo disabled")
} else {
select {
case <-ctx.Done():
err = ctx.Err()
case <-time.After(500 * time.Millisecond):
}
if err == nil {
power := "running"
if t.Action == "shutdown" {
power = "stopped"
}
_, err = s.DB.ExecContext(ctx, "UPDATE desktops SET power_state=$1,service_state='unknown',updated_at=now() WHERE id=$2", power, d.ID)
message = "演示计算任务已完成;未检测真实连接服务"
}
}
} else if err == nil {
if s.PVE == nil {
err = errors.New("PVE unconfigured")
} else {
var upid string
upid, err = s.PVE.Action(ctx, d.Node, d.VMID, t.Action)
uncertain = true
var httpError *proxmox.HTTPError
if errors.As(err, &httpError) && httpError.Status >= 400 && httpError.Status < 500 {
uncertain = false
}
if err == nil {
_, err = s.DB.ExecContext(ctx, "UPDATE tasks SET provider_ref=$1 WHERE id=$2", upid, t.ID)
}
if err == nil {
err = s.PVE.Wait(ctx, d.Node, upid, time.Second)
}
if err == nil {
var power string
power, err = s.PVE.Power(ctx, d.Node, d.VMID)
if err == nil {
expected := "running"
if t.Action == "shutdown" {
expected = "stopped"
}
if power != expected {
err = fmt.Errorf("power result not confirmed")
} else {
_, err = s.DB.ExecContext(ctx, "UPDATE desktops SET power_state=$1,service_state='unknown',updated_at=now() WHERE id=$2", power, d.ID)
}
}
}
}
}
}
status := "success"
if err != nil {
log.Printf("task execution error taskId=%s error=%v", t.ID, err)
status = "failed"
message = "执行失败,请检查服务日志及 PVE 任务;未自动重试"
if (uncertain && !errors.Is(err, proxmox.ErrTaskFailed)) || errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) {
status = "timeout"
message = "结果未确认,请核对实际状态;未自动重试"
}
}
finalCtx, finalCancel := context.WithTimeout(context.Background(), 5*time.Second)
defer finalCancel()
_ = s.complete(finalCtx, t, status, message)
}
+214
查看文件
@@ -0,0 +1,214 @@
package server
import (
"context"
"database/sql"
"errors"
"github.com/gin-gonic/gin"
"github.com/golang-jwt/jwt/v5"
"github.com/jmoiron/sqlx"
"golang.org/x/crypto/bcrypt"
"log"
"net/http"
"strconv"
"strings"
"time"
"vdi-cloud/platform/internal/config"
"vdi-cloud/platform/internal/model"
"vdi-cloud/platform/internal/store"
"vdi-cloud/platform/pkg/proxmox"
)
type Server struct {
Config config.Config
DB *sqlx.DB
PVE *proxmox.Client
Router *gin.Engine
}
type claims struct {
AdminID string `json:"adminId"`
jwt.RegisteredClaims
}
func New(cfg config.Config, db *sqlx.DB, pve *proxmox.Client) *Server {
gin.SetMode(gin.ReleaseMode)
s := &Server{Config: cfg, DB: db, PVE: pve}
r := gin.New()
r.Use(gin.Recovery(), func(c *gin.Context) {
c.Set("requestId", store.ID("req_"))
c.Header("X-Request-ID", c.GetString("requestId"))
c.Header("Cache-Control", "no-store")
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 1<<20)
c.Next()
})
s.Router = r
r.GET("/healthz", func(c *gin.Context) {
ctx, cancel := context.WithTimeout(c.Request.Context(), 2*time.Second)
defer cancel()
if db.PingContext(ctx) != nil {
s.fail(c, 503, "dependency_unavailable", "数据库暂不可用")
return
}
c.JSON(200, gin.H{"status": "ok", "mode": cfg.Mode})
})
r.POST("/api/v1/auth/login", s.login)
r.POST("/api/v1/terminal/register", s.register)
t := r.Group("/api/v1/terminal", s.deviceAuth)
t.POST("/heartbeat", s.heartbeat)
t.GET("/config", s.deviceConfig)
t.GET("/desktops", s.deviceDesktops)
t.GET("/commands", s.deviceCommands)
t.POST("/commands/:id/ack", s.deviceAck)
t.POST("/connect-ticket", s.createTicket)
t.POST("/connect-ticket/consume", s.consumeTicket)
t.POST("/session", s.deviceSession)
a := r.Group("/api/v1", s.adminAuth)
a.GET("/auth/me", func(c *gin.Context) { c.JSON(200, c.MustGet("admin")) })
a.POST("/auth/logout", s.logout)
a.GET("/overview", s.overview)
a.GET("/settings", s.settings)
a.GET("/resources", s.resources)
a.GET("/resources/vms", s.discoverVMs)
a.POST("/resources/sync", s.allow("super_admin"), s.syncResources)
a.GET("/desktops", s.desktops)
a.POST("/desktops/import", s.allow("super_admin"), s.importDesktop)
a.GET("/desktops/:id", s.desktop)
a.POST("/desktops/:id/actions", s.allow("super_admin", "operator"), s.desktopAction)
a.GET("/terminals", s.terminals)
a.POST("/terminals", s.allow("super_admin"), s.enroll)
a.POST("/terminals/:id/commands", s.allow("super_admin", "operator"), s.terminalCommand)
a.GET("/users", s.users)
a.POST("/users", s.allow("super_admin"), s.createUser)
a.PUT("/users/:id/bindings", s.allow("super_admin"), s.bindings)
a.GET("/tasks", s.tasks)
a.GET("/tasks/:id", s.task)
a.GET("/tasks/:id/diagnostics", s.allow("super_admin", "operator"), s.taskDiagnostics)
a.GET("/audit-logs", s.audits)
r.NoRoute(func(c *gin.Context) { s.fail(c, 404, "not_found", "接口不存在") })
return s
}
func (s *Server) fail(c *gin.Context, status int, code, message string) {
c.AbortWithStatusJSON(status, gin.H{"code": code, "message": message, "requestId": c.GetString("requestId")})
}
func (s *Server) dbError(c *gin.Context, err error) {
log.Printf("database request failed requestId=%s error=%v", c.GetString("requestId"), err)
if errors.Is(err, sql.ErrNoRows) {
s.fail(c, 404, "not_found", "记录不存在")
} else {
s.fail(c, 500, "database_error", "操作未完成,请根据请求 ID 检查服务日志")
}
}
func (s *Server) decode(c *gin.Context, v any) bool {
if err := c.ShouldBindJSON(v); err != nil {
s.fail(c, 400, "invalid_request", "请检查必填字段和数据格式")
return false
}
return true
}
func (s *Server) admin(c *gin.Context) model.Administrator {
return c.MustGet("admin").(model.Administrator)
}
func (s *Server) log(c *gin.Context, action, target, result string) {
actor := "device"
if a, ok := c.Get("admin"); ok {
actor = a.(model.Administrator).Username
}
_, err := s.DB.ExecContext(c.Request.Context(), "INSERT INTO audit_logs(id,actor,action,target,result,request_id) VALUES($1,$2,$3,$4,$5,$6)", store.ID("audit_"), actor, action, target, result, c.GetString("requestId"))
if err != nil {
c.Error(err)
}
}
func (s *Server) allow(roles ...string) gin.HandlerFunc {
return func(c *gin.Context) {
for _, role := range roles {
if role == s.admin(c).Role {
c.Next()
return
}
}
s.log(c, c.Request.Method+" "+c.FullPath(), c.Param("id"), "denied")
s.fail(c, 403, "forbidden", "当前账号没有该操作权限")
}
}
func (s *Server) adminAuth(c *gin.Context) {
raw := strings.TrimPrefix(c.GetHeader("Authorization"), "Bearer ")
cl := &claims{}
token, err := jwt.ParseWithClaims(raw, cl, func(t *jwt.Token) (any, error) { return []byte(s.Config.JWTSecret), nil }, jwt.WithValidMethods([]string{"HS256"}), jwt.WithIssuer("vdi-platform"), jwt.WithAudience("vdi-console"), jwt.WithExpirationRequired())
if err != nil || !token.Valid {
s.fail(c, 401, "unauthorized", "登录已失效,请重新登录")
return
}
var a model.Administrator
err = s.DB.GetContext(c.Request.Context(), &a, `SELECT a.* FROM administrators a JOIN admin_sessions x ON x.admin_id=a.id WHERE x.id=$1 AND a.id=$2 AND x.expires_at>now()`, cl.ID, cl.AdminID)
if err != nil {
s.fail(c, 401, "unauthorized", "登录已失效,请重新登录")
return
}
c.Set("admin", a)
c.Set("sessionId", cl.ID)
c.Next()
}
func (s *Server) login(c *gin.Context) {
var input struct {
Username string `json:"username" binding:"required,max=80"`
Password string `json:"password" binding:"required,max=200"`
}
if !s.decode(c, &input) {
return
}
var a model.Administrator
err := s.DB.GetContext(c.Request.Context(), &a, "SELECT * FROM administrators WHERE username=$1", input.Username)
if err != nil || bcrypt.CompareHashAndPassword([]byte(a.PasswordHash), []byte(input.Password)) != nil {
s.fail(c, 401, "invalid_credentials", "用户名或密码错误")
return
}
now := time.Now()
id := store.ID("sess_")
expires := now.Add(time.Hour)
token, err := jwt.NewWithClaims(jwt.SigningMethodHS256, claims{AdminID: a.ID, RegisteredClaims: jwt.RegisteredClaims{Issuer: "vdi-platform", Audience: jwt.ClaimStrings{"vdi-console"}, ID: id, Subject: a.ID, IssuedAt: jwt.NewNumericDate(now), ExpiresAt: jwt.NewNumericDate(expires)}}).SignedString([]byte(s.Config.JWTSecret))
if err != nil {
s.dbError(c, err)
return
}
if _, err = s.DB.ExecContext(c.Request.Context(), "INSERT INTO admin_sessions VALUES($1,$2,$3)", id, a.ID, expires); err != nil {
s.dbError(c, err)
return
}
c.Set("admin", a)
s.log(c, "login", a.Username, "success")
c.JSON(200, gin.H{"accessToken": token, "expiresIn": 3600, "user": a})
}
func (s *Server) logout(c *gin.Context) {
if _, err := s.DB.ExecContext(c.Request.Context(), "DELETE FROM admin_sessions WHERE id=$1", c.GetString("sessionId")); err != nil {
s.dbError(c, err)
return
}
s.log(c, "logout", s.admin(c).Username, "success")
c.Status(204)
}
func list[T any](c *gin.Context, items []T) {
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
size, _ := strconv.Atoi(c.DefaultQuery("pageSize", "20"))
if page < 1 {
page = 1
}
if size < 1 {
size = 20
}
if size > 100 {
size = 100
}
total := len(items)
start := (page - 1) * size
if start < 0 || start > total {
start = total
}
end := start + size
if end > total {
end = total
}
if items == nil {
items = []T{}
}
c.JSON(200, gin.H{"items": items[start:end], "page": page, "pageSize": size, "total": total, "requestId": c.GetString("requestId")})
}
+375
查看文件
@@ -0,0 +1,375 @@
package server
import (
"bytes"
"context"
"encoding/json"
"fmt"
"github.com/jmoiron/sqlx"
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"
"time"
"vdi-cloud/platform/internal/config"
"vdi-cloud/platform/internal/model"
"vdi-cloud/platform/internal/store"
"vdi-cloud/platform/pkg/proxmox"
)
type fixture struct {
server *Server
db *sqlx.DB
t *testing.T
}
func newFixture(t *testing.T) *fixture {
t.Helper()
url := os.Getenv("VDI_TEST_DATABASE_URL")
if url == "" {
t.Skip("set VDI_TEST_DATABASE_URL to a dedicated empty test database")
}
ctx := context.Background()
db, err := store.Open(ctx, url)
if err != nil {
t.Fatal(err)
}
if _, err = db.Exec(`TRUNCATE connection_tickets,audit_logs,tasks,bindings,users,terminals,desktops,resources,admin_sessions,administrators CASCADE`); err != nil {
t.Fatal(err)
}
if err = store.Bootstrap(ctx, db, "demo", "", ""); err != nil {
t.Fatal(err)
}
s := New(config.Config{Mode: "demo", JWTSecret: strings.Repeat("test-", 10)}, db, nil)
ctx, cancel := context.WithCancel(ctx)
go s.RunWorker(ctx)
t.Cleanup(func() { cancel(); time.Sleep(30 * time.Millisecond); db.Close() })
return &fixture{s, db, t}
}
func (f *fixture) request(method, path, token string, body any, key string) (int, map[string]any) {
f.t.Helper()
var encoded []byte
if body != nil {
encoded, _ = json.Marshal(body)
}
r := httptest.NewRequest(method, "/api/v1"+path, bytes.NewReader(encoded))
r.Header.Set("Content-Type", "application/json")
if token != "" {
r.Header.Set("Authorization", "Bearer "+token)
}
if key != "" {
r.Header.Set("Idempotency-Key", key)
}
w := httptest.NewRecorder()
f.server.Router.ServeHTTP(w, r)
out := map[string]any{}
if w.Body.Len() > 0 {
if err := json.Unmarshal(w.Body.Bytes(), &out); err != nil {
f.t.Fatal(err)
}
}
return w.Code, out
}
func (f *fixture) login(user string) string {
f.t.Helper()
status, out := f.request("POST", "/auth/login", "", map[string]any{"username": user, "password": "Demo@2026"}, "")
if status != 200 {
f.t.Fatalf("login failed %d %v", status, out)
}
return out["accessToken"].(string)
}
func expect(t *testing.T, want, got int, out any) {
t.Helper()
if want != got {
t.Fatalf("want status %d got %d: %v", want, got, out)
}
}
func TestRBACLogoutAndDurableTask(t *testing.T) {
f := newFixture(t)
viewer := f.login("viewer")
operator := f.login("operator")
admin := f.login("admin")
status, out := f.request("GET", "/desktops", viewer, nil, "")
expect(t, 200, status, out)
status, out = f.request("POST", "/desktops/desk_demo_kylin/actions", viewer, map[string]any{"action": "start"}, "viewer-1")
expect(t, 403, status, out)
status, out = f.request("PUT", "/users/user_demo_wang/bindings", operator, map[string]any{"desktopIds": []string{}}, "")
expect(t, 403, status, out)
status, out = f.request("POST", "/desktops/desk_demo_kylin/actions", operator, map[string]any{"action": "start"}, "start-1")
expect(t, 202, status, out)
id := out["id"].(string)
status, again := f.request("POST", "/desktops/desk_demo_kylin/actions", operator, map[string]any{"action": "start"}, "start-1")
expect(t, 202, status, again)
if again["id"] != id {
t.Fatal("duplicate task created")
}
status, out = f.request("POST", "/desktops/desk_demo_kylin/actions", operator, map[string]any{"action": "shutdown"}, "start-1")
expect(t, 409, status, out)
deadline := time.Now().Add(5 * time.Second)
for {
status, out = f.request("GET", "/tasks/"+id, admin, nil, "")
expect(t, 200, status, out)
if out["status"] == "success" {
break
}
if time.Now().After(deadline) {
t.Fatal("task never completed", out)
}
time.Sleep(50 * time.Millisecond)
}
var power, service string
if err := f.db.QueryRow("SELECT power_state,service_state FROM desktops WHERE id='desk_demo_kylin'").Scan(&power, &service); err != nil {
t.Fatal(err)
}
if power != "running" || service != "unknown" {
t.Fatal("power and service state confused")
}
status, out = f.request("POST", "/auth/logout", admin, nil, "")
expect(t, 204, status, out)
status, out = f.request("GET", "/auth/me", admin, nil, "")
expect(t, 401, status, out)
var count int
f.db.Get(&count, "SELECT count(*) FROM tasks WHERE id=$1", id)
if count != 1 {
t.Fatal("task not persisted")
}
}
func TestDeviceIdentityBindingTicketAndAcknowledgement(t *testing.T) {
f := newFixture(t)
admin := f.login("admin")
enroll := func(sn string) (string, string) {
status, out := f.request("POST", "/terminals", admin, map[string]any{"sn": sn, "name": sn, "group": "测试"}, "")
expect(t, 201, status, out)
secret := out["activationSecret"].(string)
id := out["id"].(string)
status, out = f.request("POST", "/terminal/register", "", map[string]any{"sn": sn, "activationSecret": secret, "agentVersion": "test"}, "")
expect(t, 200, status, out)
token := out["accessToken"].(string)
status, out = f.request("POST", "/terminal/register", "", map[string]any{"sn": sn, "activationSecret": secret, "agentVersion": "test"}, "")
expect(t, 401, status, out)
return id, token
}
id, device := enroll("TEST-DEVICE-001")
otherID, other := enroll("TEST-DEVICE-002")
status, out := f.request("GET", "/users", device, nil, "")
expect(t, 401, status, out)
status, out = f.request("POST", "/terminal/connect-ticket", device, map[string]any{"desktopId": "desk_demo_windows"}, "")
expect(t, 403, status, out)
status, out = f.request("PUT", "/users/user_demo_wang/bindings", admin, map[string]any{"terminalId": id, "desktopIds": []string{"desk_demo_windows", "desk_demo_ubuntu"}, "enabled": true}, "")
expect(t, 200, status, out)
status, out = f.request("GET", "/terminal/desktops", device, nil, "")
expect(t, 200, status, out)
if len(out["items"].([]any)) != 2 {
t.Fatal("missing allowed desktops")
}
f.db.Exec("UPDATE desktops SET endpoint='192.0.2.100' WHERE id='desk_demo_windows'")
status, out = f.request("POST", "/terminal/connect-ticket", device, map[string]any{"desktopId": "desk_demo_windows"}, "")
expect(t, 201, status, out)
ticket := out["ticket"].(string)
status, out = f.request("POST", "/terminal/connect-ticket/consume", other, map[string]any{"ticket": ticket}, "")
expect(t, 401, status, out)
status, out = f.request("POST", "/terminal/connect-ticket/consume", device, map[string]any{"ticket": ticket}, "")
expect(t, 200, status, out)
if out["credentialMode"] != "existing-pairing" {
t.Fatal("must declare existing Sunshine pairing")
}
sessionID := out["sessionId"].(string)
status, out = f.request("GET", "/overview", admin, nil, "")
expect(t, 200, status, out)
if out["activeSessions"] != float64(0) {
t.Fatal("reservation counted as active stream")
}
status, out = f.request("POST", "/terminal/session", device, map[string]any{"sessionId": sessionID, "state": "connected"}, "")
expect(t, 200, status, out)
status, out = f.request("GET", "/overview", admin, nil, "")
if out["activeSessions"] != float64(1) {
t.Fatal("stream-ready event missing")
}
status, out = f.request("POST", "/terminal/session", other, map[string]any{"sessionId": sessionID, "state": "ended"}, "")
expect(t, 409, status, out)
status, out = f.request("POST", "/terminal/session", device, map[string]any{"sessionId": sessionID, "state": "ended"}, "")
expect(t, 200, status, out)
status, out = f.request("POST", "/terminal/session", device, map[string]any{"sessionId": sessionID, "state": "connected"}, "")
expect(t, 409, status, out)
status, out = f.request("POST", "/terminal/connect-ticket/consume", device, map[string]any{"ticket": ticket}, "")
expect(t, 401, status, out)
status, out = f.request("PUT", "/users/user_demo_wang/bindings", admin, map[string]any{"terminalId": id, "desktopIds": []string{}, "enabled": true}, "")
expect(t, 200, status, out)
status, out = f.request("POST", "/terminal/connect-ticket", device, map[string]any{"desktopId": "desk_demo_windows"}, "")
expect(t, 403, status, out)
status, out = f.request("POST", fmt.Sprintf("/terminals/%s/commands", id), admin, map[string]any{"action": "collect-logs"}, "cmd-1")
expect(t, 202, status, out)
taskID := out["id"].(string)
status, out = f.request("GET", "/terminal/commands", other, nil, "")
expect(t, 200, status, out)
if len(out["items"].([]any)) != 0 {
t.Fatal("command leaked to other terminal")
}
status, out = f.request("POST", "/terminal/commands/"+taskID+"/ack", other, map[string]any{"status": "success"}, "")
expect(t, 404, status, out)
status, out = f.request("GET", "/terminal/commands", device, nil, "")
expect(t, 200, status, out)
if len(out["items"].([]any)) != 1 {
t.Fatal("device did not receive command")
}
status, out = f.request("POST", "/terminal/commands/"+taskID+"/ack", device, map[string]any{"status": "failed", "message": "private device log"}, "")
expect(t, 200, status, out)
status, out = f.request("GET", "/tasks/"+taskID, admin, nil, "")
expect(t, 200, status, out)
if out["status"] != "failed" {
t.Fatal("must wait for device ack")
}
if strings.Contains(out["message"].(string), "private") {
t.Fatal("raw device detail leaked")
}
status, out = f.request("POST", "/terminals/"+id+"/commands", admin, map[string]any{"action": "collect-logs"}, "diagnostics-2")
expect(t, 202, status, out)
diagnosticID := out["id"].(string)
status, out = f.request("GET", "/terminal/commands", device, nil, "")
expect(t, 200, status, out)
status, out = f.request("POST", "/terminal/commands/"+diagnosticID+"/ack", device, map[string]any{"status": "success"}, "")
expect(t, 400, status, out)
status, out = f.request("POST", "/terminal/commands/"+diagnosticID+"/ack", device, map[string]any{"status": "success", "diagnostics": map[string]any{"agentVersion": "qt-test", "bootId": "test-boot", "kioskState": "DESKTOP_LIST", "uptimeSeconds": 100, "logTail": "private diagnostic"}}, "")
expect(t, 200, status, out)
viewer := f.login("viewer")
status, out = f.request("GET", "/tasks/"+diagnosticID+"/diagnostics", viewer, nil, "")
expect(t, 403, status, out)
status, out = f.request("GET", "/tasks/"+diagnosticID+"/diagnostics", admin, nil, "")
expect(t, 200, status, out)
if out["logTail"] != "private diagnostic" {
t.Fatal("diagnostic not retained")
}
_ = otherID
}
func TestUnknownTerminalOutcomeAndBindingRollback(t *testing.T) {
f := newFixture(t)
admin := f.login("admin")
status, out := f.request("PUT", "/users/user_demo_wang/bindings", admin, map[string]any{"terminalId": "term_demo_001", "desktopIds": []string{"missing"}}, "")
expect(t, 400, status, out)
var n int
f.db.Get(&n, "SELECT count(*) FROM bindings WHERE user_id='user_demo_wang'")
if n != 2 {
t.Fatal("failed binding update lost original authorization")
}
status, out = f.request("POST", "/terminals/term_demo_001/commands", admin, map[string]any{"action": "reboot"}, "offline-1")
expect(t, 202, status, out)
id := out["id"].(string)
f.db.Exec("UPDATE tasks SET created_at=now()-interval '3 minutes' WHERE id=$1", id)
deadline := time.Now().Add(3 * time.Second)
for {
status, out = f.request("GET", "/tasks/"+id, admin, nil, "")
expect(t, 200, status, out)
if out["status"] == "timeout" {
break
}
if time.Now().After(deadline) {
t.Fatal("offline task was not timed out", out)
}
time.Sleep(50 * time.Millisecond)
}
status, out = f.request(http.MethodPost, "/resources/sync", admin, nil, "sync-1")
expect(t, 503, status, out)
}
func TestRestartPreservesRevokedBindingsAndRejectsModeChange(t *testing.T) {
f := newFixture(t)
if _, err := f.db.Exec("DELETE FROM bindings WHERE user_id='user_demo_wang'"); err != nil {
t.Fatal(err)
}
if err := store.Bootstrap(context.Background(), f.db, "demo", "", ""); err != nil {
t.Fatal(err)
}
var count int
if err := f.db.Get(&count, "SELECT count(*) FROM bindings WHERE user_id='user_demo_wang'"); err != nil || count != 0 {
t.Fatalf("restart restored revoked bindings: count=%d err=%v", count, err)
}
if err := store.Bootstrap(context.Background(), f.db, "production", "prod-admin", "long-password-2026"); err == nil {
t.Fatal("demo database accepted production bootstrap")
}
}
func TestInitialPasswordPolicyDoesNotRejectExistingAccountOnRestart(t *testing.T) {
f := newFixture(t)
if _, err := f.db.Exec("UPDATE platform_metadata SET mode='production'"); err != nil {
t.Fatal(err)
}
defer f.db.Exec("UPDATE platform_metadata SET mode='demo'")
ctx := context.Background()
if err := store.Bootstrap(ctx, f.db, "production", "new-admin", "short"); err == nil {
t.Fatal("weak initial password accepted")
}
if err := store.Bootstrap(ctx, f.db, "production", "new-admin", "strong-initial-password"); err != nil {
t.Fatal(err)
}
var before, after string
if err := f.db.Get(&before, "SELECT password_hash FROM administrators WHERE username='new-admin'"); err != nil {
t.Fatal(err)
}
if err := store.Bootstrap(ctx, f.db, "production", "new-admin", "short"); err != nil {
t.Fatal("existing account blocked startup", err)
}
if err := f.db.Get(&after, "SELECT password_hash FROM administrators WHERE username='new-admin'"); err != nil {
t.Fatal(err)
}
if before != after {
t.Fatal("restart changed the account password")
}
}
func TestLateCommandAcknowledgementPreservesNewSession(t *testing.T) {
f := newFixture(t)
if _, err := f.db.Exec("UPDATE terminals SET session_id='new-session',session_state='connected',session_desktop_id='desk_demo_windows' WHERE id='term_demo_001'"); err != nil {
t.Fatal(err)
}
var task model.Task
if err := f.db.Get(&task, `INSERT INTO tasks(id,kind,action,target_id,target_name,actor,status,message,idempotency_key) VALUES('task-late','terminal','reset-session','term_demo_001','test','admin','running','','late') RETURNING *`); err != nil {
t.Fatal(err)
}
if err := f.server.complete(context.Background(), task, "success", "ack"); err != nil {
t.Fatal(err)
}
var session string
if err := f.db.Get(&session, "SELECT session_id FROM terminals WHERE id='term_demo_001'"); err != nil || session != "new-session" {
t.Fatalf("new session lost: %q %v", session, err)
}
}
func TestPVEConfirmedFailureAndUncertainSubmission(t *testing.T) {
f := newFixture(t)
if _, err := f.db.Exec("UPDATE desktops SET provider='proxmox' WHERE id='desk_demo_kylin'"); err != nil {
t.Fatal(err)
}
for _, tc := range []struct {
name string
submission int
exit string
want string
}{{"submission unavailable", 503, "", "timeout"}, {"permission denied", 403, "", "failed"}, {"explicit PVE task failure", 200, "ERROR", "failed"}} {
t.Run(tc.name, func(t *testing.T) {
pve := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
if r.Method == http.MethodPost {
w.WriteHeader(tc.submission)
fmt.Fprint(w, `{"data":"UPID:test"}`)
return
}
fmt.Fprintf(w, `{"data":{"status":"stopped","exitstatus":%q}}`, tc.exit)
}))
defer pve.Close()
f.server.PVE = &proxmox.Client{BaseURL: pve.URL, HTTP: pve.Client()}
var task model.Task
if err := f.db.Get(&task, `INSERT INTO tasks(id,kind,action,target_id,target_name,actor,status,message,idempotency_key) VALUES($1,'desktop','start','desk_demo_kylin','test VM','admin','running','test',$1) RETURNING *`, store.ID("task_")); err != nil {
t.Fatal(err)
}
f.server.execute(context.Background(), task)
var status string
if err := f.db.Get(&status, "SELECT status FROM tasks WHERE id=$1", task.ID); err != nil || status != tc.want {
t.Fatalf("want %s, got %s err=%v", tc.want, status, err)
}
})
}
}
+291
查看文件
@@ -0,0 +1,291 @@
package server
import (
"crypto/subtle"
"encoding/json"
"github.com/gin-gonic/gin"
"strings"
"time"
"vdi-cloud/platform/internal/model"
"vdi-cloud/platform/internal/store"
)
func (s *Server) enroll(c *gin.Context) {
var in struct {
SN string `json:"sn" binding:"required,min=3,max=100"`
Name string `json:"name" binding:"required,max=80"`
Group string `json:"group" binding:"max=80"`
}
if !s.decode(c, &in) {
return
}
in.SN = strings.TrimSpace(in.SN)
in.Name = strings.TrimSpace(in.Name)
if in.SN == "" || in.Name == "" {
s.fail(c, 400, "invalid_request", "SN 和终端名称不能为空")
return
}
id, secret := store.ID("term_"), store.ID("activate_")
_, err := s.DB.ExecContext(c.Request.Context(), "INSERT INTO terminals(id,sn,name,group_name,bootstrap_hash) VALUES($1,$2,$3,$4,$5)", id, in.SN, in.Name, in.Group, store.Hash(secret))
if err != nil {
if conflict(err) {
s.fail(c, 409, "sn_exists", "该 SN 已登记")
} else {
s.dbError(c, err)
}
return
}
s.log(c, "enroll_terminal", id, "success")
c.JSON(201, gin.H{"id": id, "activationSecret": secret})
}
func (s *Server) register(c *gin.Context) {
var in struct {
SN string `json:"sn" binding:"required,max=100"`
ActivationSecret string `json:"activationSecret" binding:"required,max=200"`
AgentVersion string `json:"agentVersion" binding:"required,max=80"`
}
if !s.decode(c, &in) {
return
}
token := store.ID("device_")
var id string
err := s.DB.GetContext(c.Request.Context(), &id, `UPDATE terminals SET token_hash=$1,bootstrap_hash='',status='online',agent_version=$2,last_seen=now() WHERE sn=$3 AND bootstrap_hash=$4 AND bootstrap_hash!='' RETURNING id`, store.Hash(token), in.AgentVersion, in.SN, store.Hash(in.ActivationSecret))
if err != nil {
s.fail(c, 401, "invalid_activation", "激活信息无效或已使用,请联系管理员")
return
}
c.JSON(200, gin.H{"terminalId": id, "accessToken": token, "heartbeatInterval": 30})
}
func (s *Server) deviceAuth(c *gin.Context) {
token := strings.TrimPrefix(c.GetHeader("Authorization"), "Bearer ")
var id string
if token == "" || s.DB.GetContext(c.Request.Context(), &id, "SELECT id FROM terminals WHERE token_hash=$1", store.Hash(token)) != nil {
s.fail(c, 401, "device_unauthorized", "终端身份无效")
return
}
c.Set("terminalId", id)
c.Next()
}
func (s *Server) heartbeat(c *gin.Context) {
var in struct {
AgentVersion string `json:"agentVersion" binding:"required,max=80"`
FPS *float64 `json:"fps" binding:"omitempty,min=0,max=1000"`
RTT *float64 `json:"rtt" binding:"omitempty,min=0,max=60000"`
}
if !s.decode(c, &in) {
return
}
_, err := s.DB.ExecContext(c.Request.Context(), "UPDATE terminals SET last_seen=now(),status='online',agent_version=$1,fps=$2,rtt=$3 WHERE id=$4", in.AgentVersion, in.FPS, in.RTT, c.GetString("terminalId"))
if err != nil {
s.dbError(c, err)
return
}
c.JSON(200, gin.H{"accepted": true, "serverTime": time.Now().UTC()})
}
func (s *Server) deviceConfig(c *gin.Context) {
var version int
if err := s.DB.GetContext(c.Request.Context(), &version, "SELECT config_version FROM terminals WHERE id=$1", c.GetString("terminalId")); err != nil {
s.dbError(c, err)
return
}
c.JSON(200, gin.H{"configVersion": version, "identityMode": "device", "commandTransport": "https-poll", "heartbeatInterval": 30})
}
func (s *Server) deviceDesktops(c *gin.Context) {
items := []model.Desktop{}
err := s.DB.SelectContext(c.Request.Context(), &items, `SELECT d.* FROM desktops d JOIN bindings b ON b.desktop_id=d.id JOIN users u ON u.id=b.user_id WHERE u.terminal_id=$1 AND u.enabled=true ORDER BY d.name`, c.GetString("terminalId"))
if err != nil {
s.dbError(c, err)
return
}
list(c, items)
}
func (s *Server) deviceCommands(c *gin.Context) {
items := []model.Task{}
err := s.DB.SelectContext(c.Request.Context(), &items, `UPDATE tasks SET status='running',updated_at=now(),message='终端已领取,等待执行回执' WHERE id IN (SELECT id FROM tasks WHERE kind='terminal' AND target_id=$1 AND status='queued' AND created_at>now()-interval '2 minutes' ORDER BY created_at FOR UPDATE SKIP LOCKED LIMIT 10) RETURNING *`, c.GetString("terminalId"))
if err != nil {
s.dbError(c, err)
return
}
list(c, items)
}
func (s *Server) deviceAck(c *gin.Context) {
var in struct {
Status string `json:"status" binding:"required,oneof=success failed"`
Message string `json:"message" binding:"max=300"`
Diagnostics *model.DeviceDiagnostics `json:"diagnostics"`
}
if !s.decode(c, &in) {
return
}
var task model.Task
err := s.DB.GetContext(c.Request.Context(), &task, "SELECT * FROM tasks WHERE id=$1 AND kind='terminal' AND target_id=$2", c.Param("id"), c.GetString("terminalId"))
if err != nil {
s.dbError(c, err)
return
}
if task.Status == in.Status {
c.JSON(200, gin.H{"accepted": true})
return
}
if task.Status != "running" {
s.fail(c, 409, "invalid_task_state", "该命令尚未领取或已经结束")
return
}
if task.Action == "collect-logs" && in.Status == "success" {
if in.Diagnostics == nil {
s.fail(c, 400, "diagnostics_required", "收集日志成功需要上传诊断结果")
return
}
data, marshalErr := json.Marshal(in.Diagnostics)
if marshalErr != nil {
s.dbError(c, marshalErr)
return
}
if _, err = s.DB.ExecContext(c.Request.Context(), "INSERT INTO task_diagnostics(task_id,content) VALUES($1,$2) ON CONFLICT(task_id) DO NOTHING", task.ID, string(data)); err != nil {
s.dbError(c, err)
return
}
}
// Agent text can contain sensitive device details. Keep the public audit message fixed.
if err = s.complete(c.Request.Context(), task, in.Status, "已收到终端执行回执"); err != nil {
s.dbError(c, err)
return
}
c.JSON(200, gin.H{"accepted": true})
}
func (s *Server) createTicket(c *gin.Context) {
var in struct {
DesktopID string `json:"desktopId" binding:"required,max=100"`
}
if !s.decode(c, &in) {
return
}
var allowed bool
err := s.DB.GetContext(c.Request.Context(), &allowed, `SELECT EXISTS(SELECT 1 FROM bindings b JOIN users u ON u.id=b.user_id WHERE b.desktop_id=$1 AND u.terminal_id=$2 AND u.enabled=true)`, in.DesktopID, c.GetString("terminalId"))
if err != nil {
s.dbError(c, err)
return
}
if !allowed {
s.fail(c, 403, "desktop_forbidden", "该终端没有桌面使用权限")
return
}
var d model.Desktop
if err = s.DB.GetContext(c.Request.Context(), &d, "SELECT * FROM desktops WHERE id=$1", in.DesktopID); err != nil {
s.dbError(c, err)
return
}
if d.PowerState != "running" || d.Endpoint == "" {
s.fail(c, 409, "desktop_not_ready", "桌面未开机或尚未配置连接地址")
return
}
token := store.ID("ticket_")
expires := time.Now().Add(45 * time.Second)
_, err = s.DB.ExecContext(c.Request.Context(), "INSERT INTO connection_tickets(token_hash,terminal_id,desktop_id,expires_at) VALUES($1,$2,$3,$4)", store.Hash(token), c.GetString("terminalId"), in.DesktopID, expires)
if err != nil {
s.dbError(c, err)
return
}
c.JSON(201, gin.H{"ticket": token, "desktopId": in.DesktopID, "expiresAt": expires, "credentialMode": "existing-pairing"})
}
func (s *Server) consumeTicket(c *gin.Context) {
var in struct {
Ticket string `json:"ticket" binding:"required,max=200"`
}
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 {
TokenHash string `db:"token_hash"`
TerminalID string `db:"terminal_id"`
DesktopID string `db:"desktop_id"`
}
err = tx.GetContext(ctx, &row, `SELECT token_hash,terminal_id,desktop_id FROM connection_tickets WHERE token_hash=$1 AND consumed_at IS NULL AND expires_at>now() FOR UPDATE`, store.Hash(in.Ticket))
if err != nil || subtle.ConstantTimeCompare([]byte(row.TerminalID), []byte(c.GetString("terminalId"))) != 1 {
s.fail(c, 401, "invalid_ticket", "票据已失效或不属于当前终端")
return
}
var d model.Desktop
if err = tx.GetContext(ctx, &d, "SELECT * FROM desktops WHERE id=$1 FOR UPDATE", row.DesktopID); err != nil {
s.dbError(c, err)
return
}
var allowed bool
err = tx.GetContext(ctx, &allowed, `SELECT EXISTS(SELECT 1 FROM users u JOIN bindings b ON b.user_id=u.id WHERE u.terminal_id=$1 AND u.enabled AND b.desktop_id=$2)`, row.TerminalID, d.ID)
if err != nil {
s.dbError(c, err)
return
}
if !allowed {
s.fail(c, 403, "desktop_forbidden", "桌面授权已撤销")
return
}
if d.PowerState != "running" || d.Endpoint == "" {
s.fail(c, 409, "desktop_not_ready", "桌面当前不可连接")
return
}
var busy bool
if err = tx.GetContext(ctx, &busy, `SELECT EXISTS(SELECT 1 FROM terminals WHERE session_desktop_id=$1 AND id!=$2 AND last_seen>now()-interval '90 seconds')`, d.ID, row.TerminalID); err != nil {
s.dbError(c, err)
return
}
if busy {
s.fail(c, 409, "desktop_busy", "桌面已被其他终端占用")
return
}
sessionID := store.ID("session_")
_, err = tx.ExecContext(ctx, "UPDATE connection_tickets SET consumed_at=now() WHERE token_hash=$1", row.TokenHash)
if err == nil {
_, err = tx.ExecContext(ctx, "UPDATE terminals SET session_desktop_id=$1,last_seen=now(),session_id=$3,session_state='connecting' WHERE id=$2", d.ID, row.TerminalID, sessionID)
}
if err == nil {
err = tx.Commit()
}
if err != nil {
s.dbError(c, err)
return
}
c.JSON(200, gin.H{"desktopId": d.ID, "sessionId": sessionID, "protocol": "moonlight", "endpoint": d.Endpoint, "app": d.App, "width": d.Width, "height": d.Height, "fps": d.FPS, "credentialMode": "existing-pairing"})
}
func (s *Server) deviceSession(c *gin.Context) {
var in struct {
SessionID string `json:"sessionId" binding:"required,max=100"`
State string `json:"state" binding:"required,oneof=connected ended"`
}
if !s.decode(c, &in) {
return
}
query := "UPDATE terminals SET session_state='connected',last_seen=now() WHERE id=$1 AND session_id=$2 AND session_desktop_id IS NOT NULL"
if in.State == "ended" {
query = "UPDATE terminals SET session_state='idle',session_id='',session_desktop_id=NULL WHERE id=$1 AND session_id=$2"
}
result, err := s.DB.ExecContext(c.Request.Context(), query, c.GetString("terminalId"), in.SessionID)
if err != nil {
s.dbError(c, err)
return
}
n, _ := result.RowsAffected()
if n == 0 {
s.fail(c, 409, "stale_session", "连接预约已结束或已被新会话替代")
return
}
c.JSON(200, gin.H{"accepted": true})
}
func (s *Server) taskDiagnostics(c *gin.Context) {
var data []byte
if err := s.DB.GetContext(c.Request.Context(), &data, "SELECT content FROM task_diagnostics WHERE task_id=$1", c.Param("id")); err != nil {
s.dbError(c, err)
return
}
s.log(c, "read_diagnostics", c.Param("id"), "success")
c.Data(200, "application/json", data)
}