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