package handlers import ( "context" "net" "net/http" "net/url" "strings" "time" "github.com/gin-gonic/gin" "github.com/go-sql-driver/mysql" "github.com/redis/go-redis/v9" gormDB "gorm.io/gorm" "mengyastore-backend/internal/config" "mengyastore-backend/internal/mq" ) // SystemStatusHandler 向管理端暴露运维状态(仅供管理员)。 type SystemStatusHandler struct { cfg *config.Config db *gormDB.DB mq *mq.Client start time.Time } func NewSystemStatusHandler(cfg *config.Config, db *gormDB.DB, mqClient *mq.Client, startedAt time.Time) *SystemStatusHandler { return &SystemStatusHandler{cfg: cfg, db: db, mq: mqClient, start: startedAt} } // GetSystemStatus 返回管理后台用 JSON,需通过管理员令牌。 // @Summary 系统运行状态 // @Tags 管理端-系统 // @Produce json // @Security AdminToken // @Success 200 {object} map[string]interface{} // @Failure 401 {object} SwaggerErrorBody // @Router /api/admin/system-status [get] func (h *SystemStatusHandler) GetSystemStatus(c *gin.Context) { if !h.adminTokenOK(c) { return } ctx, cancel := context.WithTimeout(c.Request.Context(), 8*time.Second) defer cancel() c.JSON(http.StatusOK, gin.H{ "data": gin.H{ "backend": h.backendInfo(c), "mysql": h.mysqlInfo(ctx), "redis": h.redisInfo(ctx), "rabbitmq": h.rabbitmqInfo(), }, }) } func (h *SystemStatusHandler) adminTokenOK(c *gin.Context) bool { token := c.GetHeader("X-Admin-Token") if token == "" { token = c.GetHeader("Authorization") } if token == "" { token = c.Query("token") } if token != "" && token == h.cfg.AdminToken { return true } c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"}) return false } func (h *SystemStatusHandler) backendInfo(c *gin.Context) gin.H { proto := c.GetHeader("X-Forwarded-Proto") if proto == "" { if c.Request.TLS != nil { proto = "https" } else { proto = "http" } } host := c.Request.Host publicBase := strings.TrimSpace(h.cfg.PublicAPIBaseURL) if publicBase == "" && host != "" { publicBase = proto + "://" + host } out := gin.H{ "status": "ok", "appEnv": h.cfg.AppEnv, "ginMode": gin.Mode(), "requestHost": host, "listenAddr": h.cfg.HTTPListenAddr, "publicBaseUrl": publicBase, "uptimeSeconds": int(time.Since(h.start).Seconds()), "authApiConfigured": strings.TrimSpace(h.cfg.AuthAPIURL) != "", "rabbitmqEnabled": h.cfg.RabbitMQEnabled, "redisEnabled": h.cfg.RedisEnabled, } return out } func (h *SystemStatusHandler) mysqlInfo(ctx context.Context) gin.H { out := gin.H{} parseMysqlDSNForDisplay(h.cfg.DatabaseDSN, out) if h.db == nil { out["status"] = "error" out["message"] = "数据库未初始化" return out } sqlDB, err := h.db.DB() if err != nil { out["status"] = "error" out["message"] = err.Error() return out } if err := sqlDB.PingContext(ctx); err != nil { out["status"] = "error" out["message"] = err.Error() return out } var ver string if err := h.db.WithContext(ctx).Raw("SELECT VERSION()").Scan(&ver).Error; err != nil { out["status"] = "degraded" out["message"] = "连接正常但读取版本失败: " + err.Error() stats := sqlDB.Stats() out["openConnections"] = stats.OpenConnections out["inUse"] = stats.InUse out["idle"] = stats.Idle return out } stats := sqlDB.Stats() out["status"] = "ok" out["version"] = strings.TrimSpace(ver) out["openConnections"] = stats.OpenConnections out["inUse"] = stats.InUse out["idle"] = stats.Idle out["waitCount"] = stats.WaitCount return out } func parseMysqlDSNForDisplay(dsn string, out gin.H) { if dsn == "" { out["configured"] = false return } mc, err := mysql.ParseDSN(dsn) if err != nil { out["configured"] = false out["dsnParseError"] = err.Error() return } out["configured"] = true out["user"] = mc.User out["database"] = mc.DBName host, port, err := net.SplitHostPort(mc.Addr) if err != nil { out["host"] = mc.Addr out["port"] = "" if mc.Net == "unix" { out["socket"] = mc.Addr } } else { out["host"] = host out["port"] = port } } func (h *SystemStatusHandler) redisInfo(ctx context.Context) gin.H { out := gin.H{ "enabled": h.cfg.RedisEnabled, "env": h.cfg.RedisEnv, } if h.cfg.RedisAddr != "" { host, port, err := net.SplitHostPort(h.cfg.RedisAddr) if err != nil { out["host"] = h.cfg.RedisAddr out["port"] = "6379" } else { out["host"] = host if port == "" { port = "6379" } out["port"] = port } } out["dbIndex"] = h.cfg.RedisDB if !h.cfg.RedisEnabled { out["status"] = "disabled" return out } if h.cfg.RedisAddr == "" { out["status"] = "misconfigured" out["message"] = "已启用但未配置 REDIS_ADDR" return out } rdb := redis.NewClient(&redis.Options{ Addr: h.cfg.RedisAddr, Password: h.cfg.RedisPassword, DB: h.cfg.RedisDB, }) defer rdb.Close() if err := rdb.Ping(ctx).Err(); err != nil { out["status"] = "error" out["message"] = err.Error() return out } info, err := rdb.Info(ctx, "server", "memory").Result() if err != nil { out["status"] = "degraded" out["message"] = "PING 成功但无法读取 INFO: " + err.Error() return out } dbsize, _ := rdb.DBSize(ctx).Result() out["status"] = "ok" out["redisVersion"] = parseRedisInfoField(info, "redis_version:") out["usedMemoryHuman"] = parseRedisInfoField(info, "used_memory_human:") out["keysApprox"] = dbsize return out } func parseRedisInfoField(block, key string) string { for _, line := range strings.Split(block, "\r\n") { if strings.HasPrefix(line, key) { return strings.TrimSpace(strings.TrimPrefix(line, key)) } } return "" } func (h *SystemStatusHandler) rabbitmqInfo() gin.H { out := gin.H{ "enabled": h.cfg.RabbitMQEnabled, "env": h.cfg.RabbitMQEnv, "exchange": mq.ExchangeName(h.cfg.RabbitMQEnv), "queue": mq.QueueName(h.cfg.RabbitMQEnv), "routingKey": mq.OrderEmailRoutingKey(), } host, port, vhost, user := parseAMQPBroker(h.cfg.RabbitMQURL) out["brokerHost"] = host out["brokerPort"] = port out["vhost"] = vhost out["brokerUser"] = user if !h.cfg.RabbitMQEnabled { out["status"] = "disabled" return out } if h.cfg.RabbitMQURL == "" { out["status"] = "misconfigured" out["message"] = "已启用但未配置 RABBITMQ_URL 或 RABBITMQ_PASSWORD" return out } if h.mq == nil { out["status"] = "error" out["message"] = "连接未建立,请检查 Broker 与 vhost 权限" return out } if err := h.mq.Ping(); err != nil { out["status"] = "error" out["message"] = err.Error() return out } msgs, cons, err := h.mq.QueueInspectInfo() if err != nil { out["status"] = "degraded" out["message"] = "通道可用但无法读取队列统计: " + err.Error() return out } out["status"] = "ok" out["messagesReady"] = msgs out["consumers"] = cons return out } // parseAMQPBroker 从 amqp URL 解析主机、端口、vhost、用户名(不含口令,仅展示用)。 func parseAMQPBroker(raw string) (host, port, vhost, user string) { if raw == "" { return "", "", "", "" } u, err := url.Parse(raw) if err != nil { return "", "", "", "" } host = u.Hostname() port = u.Port() if port == "" { port = "5672" } if u.User != nil { user = u.User.Username() } vpath := strings.TrimPrefix(u.Path, "/") if vpath != "" { if dec, err := url.PathUnescape(vpath); err == nil { vhost = dec } else { vhost = vpath } } if vhost == "" { vhost = "/" } return host, port, vhost, user }