319 lines
7.9 KiB
Go
319 lines
7.9 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 向管理端暴露运维状态(仅供管理员)。
|
||
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 管理端系统运行状态与依赖探活
|
||
// @Description 聚合展示配置摘要、数据库/MySQL、RabbitMQ、Redis 等探测结果与进程启动时间,便于运维排查;需管理令牌。
|
||
// @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
|
||
}
|