Files
mengyastore/mengyastore-backend-go/internal/handlers/admin_status.go
2026-05-13 12:11:46 +08:00

318 lines
7.7 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}