Files
mengyastore/mengyastore-backend-go/internal/handlers/admin_status.go

311 lines
7.2 KiB
Go

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 exposes admin-only operational status.
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 returns JSON for the admin dashboard. Requires admin token.
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 returns host, port, vhost, user without password (for display only).
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
}