feat: fulfillment modes, MQ, admin status, docs; scrub compose secrets
Made-with: Cursor
This commit is contained in:
@@ -1,228 +1,243 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"mengyastore-backend/internal/cache"
|
||||
"mengyastore-backend/internal/models"
|
||||
)
|
||||
|
||||
type productPayload struct {
|
||||
Name string `json:"name"`
|
||||
Price float64 `json:"price"`
|
||||
DiscountPrice float64 `json:"discountPrice"`
|
||||
Tags string `json:"tags"`
|
||||
CoverURL string `json:"coverUrl"`
|
||||
Codes []string `json:"codes"`
|
||||
ScreenshotURLs []string `json:"screenshotUrls"`
|
||||
Description string `json:"description"`
|
||||
Active *bool `json:"active"`
|
||||
RequireLogin bool `json:"requireLogin"`
|
||||
MaxPerAccount int `json:"maxPerAccount"`
|
||||
DeliveryMode string `json:"deliveryMode"`
|
||||
ShowNote bool `json:"showNote"`
|
||||
ShowContact bool `json:"showContact"`
|
||||
}
|
||||
|
||||
type togglePayload struct {
|
||||
Active bool `json:"active"`
|
||||
}
|
||||
|
||||
// invalidateProductCache removes the cached product list so that the next
|
||||
// public request rebuilds it from the freshest DB state (Write-Invalidate).
|
||||
func (h *AdminHandler) invalidateProductCache(c *gin.Context) {
|
||||
if h.cache == nil {
|
||||
return
|
||||
}
|
||||
if err := h.cache.Del(c.Request.Context(), cache.KeyProductList); err != nil {
|
||||
log.Printf("[cache] DEL %s error: %v", cache.KeyProductList, err)
|
||||
}
|
||||
}
|
||||
|
||||
// VerifyAdminToken checks whether the supplied token is correct.
|
||||
// Returns {"valid": true/false} without leaking the real token value.
|
||||
func (h *AdminHandler) VerifyAdminToken(c *gin.Context) {
|
||||
var payload struct {
|
||||
Token string `json:"token"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&payload); err != nil || payload.Token == "" {
|
||||
c.JSON(http.StatusOK, gin.H{"valid": false})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"valid": payload.Token == h.cfg.AdminToken})
|
||||
}
|
||||
|
||||
func (h *AdminHandler) ListAllProducts(c *gin.Context) {
|
||||
if !h.requireAdmin(c) {
|
||||
return
|
||||
}
|
||||
items, err := h.store.ListAll()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": items})
|
||||
}
|
||||
|
||||
func (h *AdminHandler) CreateProduct(c *gin.Context) {
|
||||
if !h.requireAdmin(c) {
|
||||
return
|
||||
}
|
||||
var payload productPayload
|
||||
if err := c.ShouldBindJSON(&payload); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数有误"})
|
||||
return
|
||||
}
|
||||
screenshotURLs, valid := normalizeScreenshotURLs(payload.ScreenshotURLs)
|
||||
if !valid {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "截图链接最多 5 条"})
|
||||
return
|
||||
}
|
||||
active := true
|
||||
if payload.Active != nil {
|
||||
active = *payload.Active
|
||||
}
|
||||
product := models.Product{
|
||||
Name: payload.Name,
|
||||
Price: payload.Price,
|
||||
DiscountPrice: payload.DiscountPrice,
|
||||
Tags: normalizeTags(payload.Tags),
|
||||
CoverURL: strings.TrimSpace(payload.CoverURL),
|
||||
Codes: payload.Codes,
|
||||
ScreenshotURLs: screenshotURLs,
|
||||
Description: payload.Description,
|
||||
Active: active,
|
||||
RequireLogin: payload.RequireLogin,
|
||||
MaxPerAccount: payload.MaxPerAccount,
|
||||
DeliveryMode: "auto",
|
||||
ShowNote: payload.ShowNote,
|
||||
ShowContact: payload.ShowContact,
|
||||
}
|
||||
created, err := h.store.Create(product)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
h.invalidateProductCache(c) // Write-Invalidate: clear stale cache
|
||||
c.JSON(http.StatusOK, gin.H{"data": created})
|
||||
}
|
||||
|
||||
func (h *AdminHandler) UpdateProduct(c *gin.Context) {
|
||||
if !h.requireAdmin(c) {
|
||||
return
|
||||
}
|
||||
id := c.Param("id")
|
||||
var payload productPayload
|
||||
if err := c.ShouldBindJSON(&payload); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数有误"})
|
||||
return
|
||||
}
|
||||
screenshotURLs, valid := normalizeScreenshotURLs(payload.ScreenshotURLs)
|
||||
if !valid {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "截图链接最多 5 条"})
|
||||
return
|
||||
}
|
||||
active := false
|
||||
if payload.Active != nil {
|
||||
active = *payload.Active
|
||||
}
|
||||
patch := models.Product{
|
||||
Name: payload.Name,
|
||||
Price: payload.Price,
|
||||
DiscountPrice: payload.DiscountPrice,
|
||||
Tags: normalizeTags(payload.Tags),
|
||||
CoverURL: strings.TrimSpace(payload.CoverURL),
|
||||
Codes: payload.Codes,
|
||||
ScreenshotURLs: screenshotURLs,
|
||||
Description: payload.Description,
|
||||
Active: active,
|
||||
RequireLogin: payload.RequireLogin,
|
||||
MaxPerAccount: payload.MaxPerAccount,
|
||||
DeliveryMode: "auto",
|
||||
ShowNote: payload.ShowNote,
|
||||
ShowContact: payload.ShowContact,
|
||||
}
|
||||
updated, err := h.store.Update(id, patch)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
h.invalidateProductCache(c)
|
||||
c.JSON(http.StatusOK, gin.H{"data": updated})
|
||||
}
|
||||
|
||||
func (h *AdminHandler) ToggleProduct(c *gin.Context) {
|
||||
if !h.requireAdmin(c) {
|
||||
return
|
||||
}
|
||||
id := c.Param("id")
|
||||
var payload togglePayload
|
||||
if err := c.ShouldBindJSON(&payload); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数有误"})
|
||||
return
|
||||
}
|
||||
updated, err := h.store.Toggle(id, payload.Active)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
h.invalidateProductCache(c)
|
||||
c.JSON(http.StatusOK, gin.H{"data": updated})
|
||||
}
|
||||
|
||||
func (h *AdminHandler) DeleteProduct(c *gin.Context) {
|
||||
if !h.requireAdmin(c) {
|
||||
return
|
||||
}
|
||||
id := c.Param("id")
|
||||
if err := h.store.Delete(id); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
h.invalidateProductCache(c)
|
||||
c.JSON(http.StatusOK, gin.H{"data": gin.H{"ok": true}})
|
||||
}
|
||||
|
||||
func normalizeScreenshotURLs(urls []string) ([]string, bool) {
|
||||
cleaned := make([]string, 0, len(urls))
|
||||
for _, url := range urls {
|
||||
trimmed := strings.TrimSpace(url)
|
||||
if trimmed == "" {
|
||||
continue
|
||||
}
|
||||
cleaned = append(cleaned, trimmed)
|
||||
if len(cleaned) > 5 {
|
||||
return nil, false
|
||||
}
|
||||
}
|
||||
return cleaned, true
|
||||
}
|
||||
|
||||
func normalizeTags(tagsCSV string) []string {
|
||||
if tagsCSV == "" {
|
||||
return []string{}
|
||||
}
|
||||
parts := strings.Split(tagsCSV, ",")
|
||||
clean := make([]string, 0, len(parts))
|
||||
seen := map[string]bool{}
|
||||
for _, p := range parts {
|
||||
t := strings.TrimSpace(p)
|
||||
if t == "" {
|
||||
continue
|
||||
}
|
||||
key := strings.ToLower(t)
|
||||
if seen[key] {
|
||||
continue
|
||||
}
|
||||
seen[key] = true
|
||||
clean = append(clean, t)
|
||||
if len(clean) >= 20 {
|
||||
break
|
||||
}
|
||||
}
|
||||
return clean
|
||||
}
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"mengyastore-backend/internal/models"
|
||||
)
|
||||
|
||||
type productPayload struct {
|
||||
Name string `json:"name"`
|
||||
Price float64 `json:"price"`
|
||||
DiscountPrice float64 `json:"discountPrice"`
|
||||
Tags string `json:"tags"`
|
||||
CoverURL string `json:"coverUrl"`
|
||||
Codes []string `json:"codes"`
|
||||
ScreenshotURLs []string `json:"screenshotUrls"`
|
||||
Description string `json:"description"`
|
||||
Active *bool `json:"active"`
|
||||
RequireLogin bool `json:"requireLogin"`
|
||||
MaxPerAccount int `json:"maxPerAccount"`
|
||||
DeliveryMode string `json:"deliveryMode"`
|
||||
FulfillmentType string `json:"fulfillmentType"`
|
||||
FixedContent string `json:"fixedContent"`
|
||||
ShowNote bool `json:"showNote"`
|
||||
ShowContact bool `json:"showContact"`
|
||||
}
|
||||
|
||||
func normalizeFulfillmentPayload(payload *productPayload) string {
|
||||
ft := strings.TrimSpace(strings.ToLower(payload.FulfillmentType))
|
||||
if ft == "fixed" {
|
||||
return "fixed"
|
||||
}
|
||||
return "card"
|
||||
}
|
||||
|
||||
type togglePayload struct {
|
||||
Active bool `json:"active"`
|
||||
}
|
||||
|
||||
// VerifyAdminToken checks whether the supplied token is correct.
|
||||
// Returns {"valid": true/false} without leaking the real token value.
|
||||
func (h *AdminHandler) VerifyAdminToken(c *gin.Context) {
|
||||
var payload struct {
|
||||
Token string `json:"token"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&payload); err != nil || payload.Token == "" {
|
||||
c.JSON(http.StatusOK, gin.H{"valid": false})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"valid": payload.Token == h.cfg.AdminToken})
|
||||
}
|
||||
|
||||
func (h *AdminHandler) ListAllProducts(c *gin.Context) {
|
||||
if !h.requireAdmin(c) {
|
||||
return
|
||||
}
|
||||
items, err := h.store.ListAll()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": items})
|
||||
}
|
||||
|
||||
func (h *AdminHandler) CreateProduct(c *gin.Context) {
|
||||
if !h.requireAdmin(c) {
|
||||
return
|
||||
}
|
||||
var payload productPayload
|
||||
if err := c.ShouldBindJSON(&payload); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数有误"})
|
||||
return
|
||||
}
|
||||
screenshotURLs, valid := normalizeScreenshotURLs(payload.ScreenshotURLs)
|
||||
if !valid {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "截图链接最多 5 条"})
|
||||
return
|
||||
}
|
||||
active := true
|
||||
if payload.Active != nil {
|
||||
active = *payload.Active
|
||||
}
|
||||
ft := normalizeFulfillmentPayload(&payload)
|
||||
if ft == "fixed" && strings.TrimSpace(payload.FixedContent) == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "固定内容发货须填写发货内容(如网盘链接、说明文字)"})
|
||||
return
|
||||
}
|
||||
dm := strings.TrimSpace(payload.DeliveryMode)
|
||||
if dm == "" {
|
||||
dm = "auto"
|
||||
}
|
||||
product := models.Product{
|
||||
Name: payload.Name,
|
||||
Price: payload.Price,
|
||||
DiscountPrice: payload.DiscountPrice,
|
||||
Tags: normalizeTags(payload.Tags),
|
||||
CoverURL: strings.TrimSpace(payload.CoverURL),
|
||||
Codes: payload.Codes,
|
||||
ScreenshotURLs: screenshotURLs,
|
||||
Description: payload.Description,
|
||||
Active: active,
|
||||
RequireLogin: payload.RequireLogin,
|
||||
MaxPerAccount: payload.MaxPerAccount,
|
||||
DeliveryMode: dm,
|
||||
FulfillmentType: ft,
|
||||
FixedContent: payload.FixedContent,
|
||||
ShowNote: payload.ShowNote,
|
||||
ShowContact: payload.ShowContact,
|
||||
}
|
||||
created, err := h.store.Create(product)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": created})
|
||||
}
|
||||
|
||||
func (h *AdminHandler) UpdateProduct(c *gin.Context) {
|
||||
if !h.requireAdmin(c) {
|
||||
return
|
||||
}
|
||||
id := c.Param("id")
|
||||
var payload productPayload
|
||||
if err := c.ShouldBindJSON(&payload); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数有误"})
|
||||
return
|
||||
}
|
||||
screenshotURLs, valid := normalizeScreenshotURLs(payload.ScreenshotURLs)
|
||||
if !valid {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "截图链接最多 5 条"})
|
||||
return
|
||||
}
|
||||
active := false
|
||||
if payload.Active != nil {
|
||||
active = *payload.Active
|
||||
}
|
||||
ft := normalizeFulfillmentPayload(&payload)
|
||||
if ft == "fixed" && strings.TrimSpace(payload.FixedContent) == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "固定内容发货须填写发货内容(如网盘链接、说明文字)"})
|
||||
return
|
||||
}
|
||||
dm := strings.TrimSpace(payload.DeliveryMode)
|
||||
if dm == "" {
|
||||
dm = "auto"
|
||||
}
|
||||
patch := models.Product{
|
||||
Name: payload.Name,
|
||||
Price: payload.Price,
|
||||
DiscountPrice: payload.DiscountPrice,
|
||||
Tags: normalizeTags(payload.Tags),
|
||||
CoverURL: strings.TrimSpace(payload.CoverURL),
|
||||
Codes: payload.Codes,
|
||||
ScreenshotURLs: screenshotURLs,
|
||||
Description: payload.Description,
|
||||
Active: active,
|
||||
RequireLogin: payload.RequireLogin,
|
||||
MaxPerAccount: payload.MaxPerAccount,
|
||||
DeliveryMode: dm,
|
||||
FulfillmentType: ft,
|
||||
FixedContent: payload.FixedContent,
|
||||
ShowNote: payload.ShowNote,
|
||||
ShowContact: payload.ShowContact,
|
||||
}
|
||||
updated, err := h.store.Update(id, patch)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": updated})
|
||||
}
|
||||
|
||||
func (h *AdminHandler) ToggleProduct(c *gin.Context) {
|
||||
if !h.requireAdmin(c) {
|
||||
return
|
||||
}
|
||||
id := c.Param("id")
|
||||
var payload togglePayload
|
||||
if err := c.ShouldBindJSON(&payload); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数有误"})
|
||||
return
|
||||
}
|
||||
updated, err := h.store.Toggle(id, payload.Active)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": updated})
|
||||
}
|
||||
|
||||
func (h *AdminHandler) DeleteProduct(c *gin.Context) {
|
||||
if !h.requireAdmin(c) {
|
||||
return
|
||||
}
|
||||
id := c.Param("id")
|
||||
if err := h.store.Delete(id); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": gin.H{"ok": true}})
|
||||
}
|
||||
|
||||
func normalizeScreenshotURLs(urls []string) ([]string, bool) {
|
||||
cleaned := make([]string, 0, len(urls))
|
||||
for _, url := range urls {
|
||||
trimmed := strings.TrimSpace(url)
|
||||
if trimmed == "" {
|
||||
continue
|
||||
}
|
||||
cleaned = append(cleaned, trimmed)
|
||||
if len(cleaned) > 5 {
|
||||
return nil, false
|
||||
}
|
||||
}
|
||||
return cleaned, true
|
||||
}
|
||||
|
||||
func normalizeTags(tagsCSV string) []string {
|
||||
if tagsCSV == "" {
|
||||
return []string{}
|
||||
}
|
||||
parts := strings.Split(tagsCSV, ",")
|
||||
clean := make([]string, 0, len(parts))
|
||||
seen := map[string]bool{}
|
||||
for _, p := range parts {
|
||||
t := strings.TrimSpace(p)
|
||||
if t == "" {
|
||||
continue
|
||||
}
|
||||
key := strings.ToLower(t)
|
||||
if seen[key] {
|
||||
continue
|
||||
}
|
||||
seen[key] = true
|
||||
clean = append(clean, t)
|
||||
if len(clean) >= 20 {
|
||||
break
|
||||
}
|
||||
}
|
||||
return clean
|
||||
}
|
||||
|
||||
310
mengyastore-backend-go/internal/handlers/admin_status.go
Normal file
310
mengyastore-backend-go/internal/handlers/admin_status.go
Normal file
@@ -0,0 +1,310 @@
|
||||
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
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -55,6 +55,7 @@ func sanitizeForPublic(items []models.Product) []models.Product {
|
||||
out := make([]models.Product, len(items))
|
||||
for i, item := range items {
|
||||
item.Codes = nil
|
||||
item.FixedContent = ""
|
||||
out[i] = item
|
||||
}
|
||||
return out
|
||||
|
||||
Reference in New Issue
Block a user