311 lines
7.2 KiB
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
|
|
}
|