Update mengyastore
This commit is contained in:
@@ -1,310 +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
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
@@ -1,268 +1,268 @@
|
||||
package mq
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
amqp "github.com/rabbitmq/amqp091-go"
|
||||
|
||||
"mengyastore-backend/internal/storage"
|
||||
)
|
||||
|
||||
// Client manages a single AMQP connection, a publish channel, and naming for one env.
|
||||
type Client struct {
|
||||
conn *amqp.Connection
|
||||
pubCh *amqp.Channel
|
||||
exchange string
|
||||
queue string
|
||||
env string
|
||||
amqpURL string
|
||||
mu sync.Mutex
|
||||
closing sync.Once
|
||||
connected bool
|
||||
|
||||
// 用于重连后重启消费协程(与 StartConsumer 注入的一致)
|
||||
consumerCtx context.Context
|
||||
consumerSite *storage.SiteStore
|
||||
}
|
||||
|
||||
// New connects to RabbitMQ and declares exchange + queue + binding (idempotent).
|
||||
func New(amqpURL, env string) (*Client, error) {
|
||||
if amqpURL == "" {
|
||||
return nil, fmt.Errorf("empty amqp url")
|
||||
}
|
||||
conn, err := amqp.DialConfig(amqpURL, amqp.Config{
|
||||
Heartbeat: 30 * time.Second,
|
||||
Locale: "en_US",
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("amqp dial: %w", err)
|
||||
}
|
||||
|
||||
pubCh, err := conn.Channel()
|
||||
if err != nil {
|
||||
conn.Close()
|
||||
return nil, fmt.Errorf("amqp channel: %w", err)
|
||||
}
|
||||
|
||||
exchange, queue, err := declareTopology(pubCh, env)
|
||||
if err != nil {
|
||||
pubCh.Close()
|
||||
conn.Close()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &Client{
|
||||
conn: conn,
|
||||
pubCh: pubCh,
|
||||
exchange: exchange,
|
||||
queue: queue,
|
||||
env: sanitizeEnv(env),
|
||||
amqpURL: amqpURL,
|
||||
connected: true,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func isRecoverableAMQP(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
var e *amqp.Error
|
||||
if errors.As(err, &e) {
|
||||
// 504 channel/connection not open、320 连接被服务端关闭等,通过重连恢复
|
||||
if e.Code == amqp.ChannelError || e.Code == amqp.ConnectionForced {
|
||||
return true
|
||||
}
|
||||
return strings.Contains(strings.ToLower(e.Reason), "not open")
|
||||
}
|
||||
s := strings.ToLower(err.Error())
|
||||
return strings.Contains(s, "channel/connection is not open") ||
|
||||
strings.Contains(s, "connection closed") ||
|
||||
strings.Contains(s, "use of closed network connection") ||
|
||||
strings.Contains(s, "eof")
|
||||
}
|
||||
|
||||
// reconnectLocked 在持有 mu 时调用:关闭旧连接并重新拨号、声明拓扑。
|
||||
func (c *Client) reconnectLocked() error {
|
||||
if !c.connected || c.amqpURL == "" {
|
||||
return fmt.Errorf("mq client closed")
|
||||
}
|
||||
|
||||
if c.pubCh != nil {
|
||||
_ = c.pubCh.Close()
|
||||
c.pubCh = nil
|
||||
}
|
||||
if c.conn != nil && !c.conn.IsClosed() {
|
||||
_ = c.conn.Close()
|
||||
}
|
||||
c.conn = nil
|
||||
|
||||
conn, err := amqp.DialConfig(c.amqpURL, amqp.Config{
|
||||
Heartbeat: 30 * time.Second,
|
||||
Locale: "en_US",
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("amqp reconnect dial: %w", err)
|
||||
}
|
||||
pubCh, err := conn.Channel()
|
||||
if err != nil {
|
||||
conn.Close()
|
||||
return fmt.Errorf("amqp reconnect channel: %w", err)
|
||||
}
|
||||
exchange, queue, err := declareTopology(pubCh, c.env)
|
||||
if err != nil {
|
||||
pubCh.Close()
|
||||
conn.Close()
|
||||
return err
|
||||
}
|
||||
c.conn = conn
|
||||
c.pubCh = pubCh
|
||||
c.exchange = exchange
|
||||
c.queue = queue
|
||||
|
||||
if c.consumerCtx != nil && c.consumerSite != nil && c.consumerCtx.Err() == nil {
|
||||
go RunOrderEmailConsumer(c.consumerCtx, c.conn, c.env, c.consumerSite)
|
||||
log.Printf("[MQ] consumer restarted after reconnect (queue=%s)", c.queue)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) passiveQueueLocked() error {
|
||||
if c.pubCh == nil || !c.connected {
|
||||
return fmt.Errorf("mq client closed")
|
||||
}
|
||||
_, err := c.pubCh.QueueDeclarePassive(c.queue, true, false, false, false, nil)
|
||||
return err
|
||||
}
|
||||
|
||||
// PublishOrderEmail publishes a persistent JSON message to the order-email routing key.
|
||||
func (c *Client) PublishOrderEmail(ctx context.Context, p OrderEmailPayload) error {
|
||||
body, err := json.Marshal(p)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal payload: %w", err)
|
||||
}
|
||||
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
if c.pubCh == nil || !c.connected {
|
||||
return fmt.Errorf("mq client closed")
|
||||
}
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
default:
|
||||
}
|
||||
|
||||
tryPublish := func() error {
|
||||
return c.pubCh.PublishWithContext(ctx,
|
||||
c.exchange,
|
||||
routingKeyOrderEmail,
|
||||
false,
|
||||
false,
|
||||
amqp.Publishing{
|
||||
ContentType: "application/json",
|
||||
DeliveryMode: amqp.Persistent,
|
||||
Timestamp: time.Now(),
|
||||
Body: body,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
err = tryPublish()
|
||||
if err != nil && isRecoverableAMQP(err) {
|
||||
if rerr := c.reconnectLocked(); rerr == nil {
|
||||
err = tryPublish()
|
||||
} else {
|
||||
err = fmt.Errorf("%w (reconnect: %v)", err, rerr)
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("publish: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// QueueInspectInfo returns current queue depth and consumer count (passive declare).
|
||||
func (c *Client) QueueInspectInfo() (messages int, consumers int, err error) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
try := func() (int, int, error) {
|
||||
if c.pubCh == nil || !c.connected {
|
||||
return 0, 0, fmt.Errorf("mq client closed")
|
||||
}
|
||||
q, e := c.pubCh.QueueDeclarePassive(c.queue, true, false, false, false, nil)
|
||||
if e != nil {
|
||||
return 0, 0, e
|
||||
}
|
||||
return q.Messages, q.Consumers, nil
|
||||
}
|
||||
|
||||
msgs, cons, err := try()
|
||||
if err != nil && isRecoverableAMQP(err) {
|
||||
if rerr := c.reconnectLocked(); rerr != nil {
|
||||
return 0, 0, fmt.Errorf("%w (reconnect: %v)", err, rerr)
|
||||
}
|
||||
return try()
|
||||
}
|
||||
return msgs, cons, err
|
||||
}
|
||||
|
||||
// Ping checks that the publish channel can query the declared queue (liveness for /api/health).
|
||||
func (c *Client) Ping() error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
err := c.passiveQueueLocked()
|
||||
if err != nil && isRecoverableAMQP(err) {
|
||||
if rerr := c.reconnectLocked(); rerr != nil {
|
||||
return fmt.Errorf("%w (reconnect: %v)", err, rerr)
|
||||
}
|
||||
return c.passiveQueueLocked()
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// Env returns the sanitized environment suffix used in exchange/queue names.
|
||||
func (c *Client) Env() string { return c.env }
|
||||
|
||||
// StartConsumer runs the order-email consumer until ctx is cancelled (run in a goroutine).
|
||||
func (c *Client) StartConsumer(ctx context.Context, site *storage.SiteStore) {
|
||||
c.mu.Lock()
|
||||
c.consumerCtx = ctx
|
||||
c.consumerSite = site
|
||||
conn := c.conn
|
||||
env := c.env
|
||||
c.mu.Unlock()
|
||||
if conn == nil || site == nil {
|
||||
return
|
||||
}
|
||||
RunOrderEmailConsumer(ctx, conn, env, site)
|
||||
}
|
||||
|
||||
// Close releases the publish channel and connection.
|
||||
func (c *Client) Close() {
|
||||
c.closing.Do(func() {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.connected = false
|
||||
c.consumerCtx = nil
|
||||
c.consumerSite = nil
|
||||
if c.pubCh != nil {
|
||||
_ = c.pubCh.Close()
|
||||
c.pubCh = nil
|
||||
}
|
||||
if c.conn != nil {
|
||||
_ = c.conn.Close()
|
||||
c.conn = nil
|
||||
}
|
||||
})
|
||||
}
|
||||
package mq
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
amqp "github.com/rabbitmq/amqp091-go"
|
||||
|
||||
"mengyastore-backend/internal/storage"
|
||||
)
|
||||
|
||||
// Client manages a single AMQP connection, a publish channel, and naming for one env.
|
||||
type Client struct {
|
||||
conn *amqp.Connection
|
||||
pubCh *amqp.Channel
|
||||
exchange string
|
||||
queue string
|
||||
env string
|
||||
amqpURL string
|
||||
mu sync.Mutex
|
||||
closing sync.Once
|
||||
connected bool
|
||||
|
||||
// 用于重连后重启消费协程(与 StartConsumer 注入的一致)
|
||||
consumerCtx context.Context
|
||||
consumerSite *storage.SiteStore
|
||||
}
|
||||
|
||||
// New connects to RabbitMQ and declares exchange + queue + binding (idempotent).
|
||||
func New(amqpURL, env string) (*Client, error) {
|
||||
if amqpURL == "" {
|
||||
return nil, fmt.Errorf("empty amqp url")
|
||||
}
|
||||
conn, err := amqp.DialConfig(amqpURL, amqp.Config{
|
||||
Heartbeat: 30 * time.Second,
|
||||
Locale: "en_US",
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("amqp dial: %w", err)
|
||||
}
|
||||
|
||||
pubCh, err := conn.Channel()
|
||||
if err != nil {
|
||||
conn.Close()
|
||||
return nil, fmt.Errorf("amqp channel: %w", err)
|
||||
}
|
||||
|
||||
exchange, queue, err := declareTopology(pubCh, env)
|
||||
if err != nil {
|
||||
pubCh.Close()
|
||||
conn.Close()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &Client{
|
||||
conn: conn,
|
||||
pubCh: pubCh,
|
||||
exchange: exchange,
|
||||
queue: queue,
|
||||
env: sanitizeEnv(env),
|
||||
amqpURL: amqpURL,
|
||||
connected: true,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func isRecoverableAMQP(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
var e *amqp.Error
|
||||
if errors.As(err, &e) {
|
||||
// 504 channel/connection not open、320 连接被服务端关闭等,通过重连恢复
|
||||
if e.Code == amqp.ChannelError || e.Code == amqp.ConnectionForced {
|
||||
return true
|
||||
}
|
||||
return strings.Contains(strings.ToLower(e.Reason), "not open")
|
||||
}
|
||||
s := strings.ToLower(err.Error())
|
||||
return strings.Contains(s, "channel/connection is not open") ||
|
||||
strings.Contains(s, "connection closed") ||
|
||||
strings.Contains(s, "use of closed network connection") ||
|
||||
strings.Contains(s, "eof")
|
||||
}
|
||||
|
||||
// reconnectLocked 在持有 mu 时调用:关闭旧连接并重新拨号、声明拓扑。
|
||||
func (c *Client) reconnectLocked() error {
|
||||
if !c.connected || c.amqpURL == "" {
|
||||
return fmt.Errorf("mq client closed")
|
||||
}
|
||||
|
||||
if c.pubCh != nil {
|
||||
_ = c.pubCh.Close()
|
||||
c.pubCh = nil
|
||||
}
|
||||
if c.conn != nil && !c.conn.IsClosed() {
|
||||
_ = c.conn.Close()
|
||||
}
|
||||
c.conn = nil
|
||||
|
||||
conn, err := amqp.DialConfig(c.amqpURL, amqp.Config{
|
||||
Heartbeat: 30 * time.Second,
|
||||
Locale: "en_US",
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("amqp reconnect dial: %w", err)
|
||||
}
|
||||
pubCh, err := conn.Channel()
|
||||
if err != nil {
|
||||
conn.Close()
|
||||
return fmt.Errorf("amqp reconnect channel: %w", err)
|
||||
}
|
||||
exchange, queue, err := declareTopology(pubCh, c.env)
|
||||
if err != nil {
|
||||
pubCh.Close()
|
||||
conn.Close()
|
||||
return err
|
||||
}
|
||||
c.conn = conn
|
||||
c.pubCh = pubCh
|
||||
c.exchange = exchange
|
||||
c.queue = queue
|
||||
|
||||
if c.consumerCtx != nil && c.consumerSite != nil && c.consumerCtx.Err() == nil {
|
||||
go RunOrderEmailConsumer(c.consumerCtx, c.conn, c.env, c.consumerSite)
|
||||
log.Printf("[MQ] consumer restarted after reconnect (queue=%s)", c.queue)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) passiveQueueLocked() error {
|
||||
if c.pubCh == nil || !c.connected {
|
||||
return fmt.Errorf("mq client closed")
|
||||
}
|
||||
_, err := c.pubCh.QueueDeclarePassive(c.queue, true, false, false, false, nil)
|
||||
return err
|
||||
}
|
||||
|
||||
// PublishOrderEmail publishes a persistent JSON message to the order-email routing key.
|
||||
func (c *Client) PublishOrderEmail(ctx context.Context, p OrderEmailPayload) error {
|
||||
body, err := json.Marshal(p)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal payload: %w", err)
|
||||
}
|
||||
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
if c.pubCh == nil || !c.connected {
|
||||
return fmt.Errorf("mq client closed")
|
||||
}
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
default:
|
||||
}
|
||||
|
||||
tryPublish := func() error {
|
||||
return c.pubCh.PublishWithContext(ctx,
|
||||
c.exchange,
|
||||
routingKeyOrderEmail,
|
||||
false,
|
||||
false,
|
||||
amqp.Publishing{
|
||||
ContentType: "application/json",
|
||||
DeliveryMode: amqp.Persistent,
|
||||
Timestamp: time.Now(),
|
||||
Body: body,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
err = tryPublish()
|
||||
if err != nil && isRecoverableAMQP(err) {
|
||||
if rerr := c.reconnectLocked(); rerr == nil {
|
||||
err = tryPublish()
|
||||
} else {
|
||||
err = fmt.Errorf("%w (reconnect: %v)", err, rerr)
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("publish: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// QueueInspectInfo returns current queue depth and consumer count (passive declare).
|
||||
func (c *Client) QueueInspectInfo() (messages int, consumers int, err error) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
try := func() (int, int, error) {
|
||||
if c.pubCh == nil || !c.connected {
|
||||
return 0, 0, fmt.Errorf("mq client closed")
|
||||
}
|
||||
q, e := c.pubCh.QueueDeclarePassive(c.queue, true, false, false, false, nil)
|
||||
if e != nil {
|
||||
return 0, 0, e
|
||||
}
|
||||
return q.Messages, q.Consumers, nil
|
||||
}
|
||||
|
||||
msgs, cons, err := try()
|
||||
if err != nil && isRecoverableAMQP(err) {
|
||||
if rerr := c.reconnectLocked(); rerr != nil {
|
||||
return 0, 0, fmt.Errorf("%w (reconnect: %v)", err, rerr)
|
||||
}
|
||||
return try()
|
||||
}
|
||||
return msgs, cons, err
|
||||
}
|
||||
|
||||
// Ping checks that the publish channel can query the declared queue (liveness for /api/health).
|
||||
func (c *Client) Ping() error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
err := c.passiveQueueLocked()
|
||||
if err != nil && isRecoverableAMQP(err) {
|
||||
if rerr := c.reconnectLocked(); rerr != nil {
|
||||
return fmt.Errorf("%w (reconnect: %v)", err, rerr)
|
||||
}
|
||||
return c.passiveQueueLocked()
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// Env returns the sanitized environment suffix used in exchange/queue names.
|
||||
func (c *Client) Env() string { return c.env }
|
||||
|
||||
// StartConsumer runs the order-email consumer until ctx is cancelled (run in a goroutine).
|
||||
func (c *Client) StartConsumer(ctx context.Context, site *storage.SiteStore) {
|
||||
c.mu.Lock()
|
||||
c.consumerCtx = ctx
|
||||
c.consumerSite = site
|
||||
conn := c.conn
|
||||
env := c.env
|
||||
c.mu.Unlock()
|
||||
if conn == nil || site == nil {
|
||||
return
|
||||
}
|
||||
RunOrderEmailConsumer(ctx, conn, env, site)
|
||||
}
|
||||
|
||||
// Close releases the publish channel and connection.
|
||||
func (c *Client) Close() {
|
||||
c.closing.Do(func() {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.connected = false
|
||||
c.consumerCtx = nil
|
||||
c.consumerSite = nil
|
||||
if c.pubCh != nil {
|
||||
_ = c.pubCh.Close()
|
||||
c.pubCh = nil
|
||||
}
|
||||
if c.conn != nil {
|
||||
_ = c.conn.Close()
|
||||
c.conn = nil
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,101 +1,101 @@
|
||||
package mq
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log"
|
||||
|
||||
amqp "github.com/rabbitmq/amqp091-go"
|
||||
|
||||
"mengyastore-backend/internal/email"
|
||||
"mengyastore-backend/internal/storage"
|
||||
)
|
||||
|
||||
// RunOrderEmailConsumer runs until ctx is done. Must be started in its own goroutine.
|
||||
func RunOrderEmailConsumer(ctx context.Context, conn *amqp.Connection, env string, site *storage.SiteStore) {
|
||||
if conn == nil || site == nil {
|
||||
return
|
||||
}
|
||||
|
||||
ch, err := conn.Channel()
|
||||
if err != nil {
|
||||
log.Printf("[MQ] consumer channel: %v", err)
|
||||
return
|
||||
}
|
||||
defer ch.Close()
|
||||
|
||||
if _, _, err := declareTopology(ch, env); err != nil {
|
||||
log.Printf("[MQ] consumer declare topology: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := ch.Qos(1, 0, false); err != nil {
|
||||
log.Printf("[MQ] consumer qos: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
queue := QueueName(env)
|
||||
const tag = "mengyastore-order-email"
|
||||
msgs, err := ch.Consume(queue, tag, false, false, false, false, nil)
|
||||
if err != nil {
|
||||
log.Printf("[MQ] consume %s: %v", queue, err)
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("[MQ] consumer started queue=%s", queue)
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
_ = ch.Cancel(tag, false)
|
||||
log.Printf("[MQ] consumer stopped queue=%s", queue)
|
||||
return
|
||||
case d, ok := <-msgs:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
handleDelivery(site, d)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func handleDelivery(site *storage.SiteStore, d amqp.Delivery) {
|
||||
var payload OrderEmailPayload
|
||||
if err := json.Unmarshal(d.Body, &payload); err != nil {
|
||||
log.Printf("[MQ] bad message: %v", err)
|
||||
_ = d.Ack(false)
|
||||
return
|
||||
}
|
||||
if payload.ToEmail == "" {
|
||||
_ = d.Ack(false)
|
||||
return
|
||||
}
|
||||
|
||||
cfg, err := site.GetSMTPConfig()
|
||||
if err != nil || !cfg.IsConfiguredEmail() {
|
||||
log.Printf("[MQ] skip email order=%s: smtp not configured", payload.OrderID)
|
||||
_ = d.Ack(false)
|
||||
return
|
||||
}
|
||||
|
||||
emailCfg := email.Config{
|
||||
SMTPHost: cfg.Host,
|
||||
SMTPPort: cfg.Port,
|
||||
From: cfg.Email,
|
||||
Password: cfg.Password,
|
||||
FromName: cfg.FromName,
|
||||
}
|
||||
data := payload.ToNotifyData()
|
||||
if err := email.SendOrderNotify(emailCfg, data); err != nil {
|
||||
log.Printf("[MQ] send email fail order=%s: %v", payload.OrderID, err)
|
||||
if d.Redelivered {
|
||||
log.Printf("[MQ] drop order=%s after failed redelivery", payload.OrderID)
|
||||
_ = d.Ack(false)
|
||||
return
|
||||
}
|
||||
_ = d.Nack(false, true)
|
||||
return
|
||||
}
|
||||
log.Printf("[MQ] email ok order=%s to=%s", payload.OrderID, payload.ToEmail)
|
||||
_ = d.Ack(false)
|
||||
}
|
||||
package mq
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log"
|
||||
|
||||
amqp "github.com/rabbitmq/amqp091-go"
|
||||
|
||||
"mengyastore-backend/internal/email"
|
||||
"mengyastore-backend/internal/storage"
|
||||
)
|
||||
|
||||
// RunOrderEmailConsumer runs until ctx is done. Must be started in its own goroutine.
|
||||
func RunOrderEmailConsumer(ctx context.Context, conn *amqp.Connection, env string, site *storage.SiteStore) {
|
||||
if conn == nil || site == nil {
|
||||
return
|
||||
}
|
||||
|
||||
ch, err := conn.Channel()
|
||||
if err != nil {
|
||||
log.Printf("[MQ] consumer channel: %v", err)
|
||||
return
|
||||
}
|
||||
defer ch.Close()
|
||||
|
||||
if _, _, err := declareTopology(ch, env); err != nil {
|
||||
log.Printf("[MQ] consumer declare topology: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := ch.Qos(1, 0, false); err != nil {
|
||||
log.Printf("[MQ] consumer qos: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
queue := QueueName(env)
|
||||
const tag = "mengyastore-order-email"
|
||||
msgs, err := ch.Consume(queue, tag, false, false, false, false, nil)
|
||||
if err != nil {
|
||||
log.Printf("[MQ] consume %s: %v", queue, err)
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("[MQ] consumer started queue=%s", queue)
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
_ = ch.Cancel(tag, false)
|
||||
log.Printf("[MQ] consumer stopped queue=%s", queue)
|
||||
return
|
||||
case d, ok := <-msgs:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
handleDelivery(site, d)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func handleDelivery(site *storage.SiteStore, d amqp.Delivery) {
|
||||
var payload OrderEmailPayload
|
||||
if err := json.Unmarshal(d.Body, &payload); err != nil {
|
||||
log.Printf("[MQ] bad message: %v", err)
|
||||
_ = d.Ack(false)
|
||||
return
|
||||
}
|
||||
if payload.ToEmail == "" {
|
||||
_ = d.Ack(false)
|
||||
return
|
||||
}
|
||||
|
||||
cfg, err := site.GetSMTPConfig()
|
||||
if err != nil || !cfg.IsConfiguredEmail() {
|
||||
log.Printf("[MQ] skip email order=%s: smtp not configured", payload.OrderID)
|
||||
_ = d.Ack(false)
|
||||
return
|
||||
}
|
||||
|
||||
emailCfg := email.Config{
|
||||
SMTPHost: cfg.Host,
|
||||
SMTPPort: cfg.Port,
|
||||
From: cfg.Email,
|
||||
Password: cfg.Password,
|
||||
FromName: cfg.FromName,
|
||||
}
|
||||
data := payload.ToNotifyData()
|
||||
if err := email.SendOrderNotify(emailCfg, data); err != nil {
|
||||
log.Printf("[MQ] send email fail order=%s: %v", payload.OrderID, err)
|
||||
if d.Redelivered {
|
||||
log.Printf("[MQ] drop order=%s after failed redelivery", payload.OrderID)
|
||||
_ = d.Ack(false)
|
||||
return
|
||||
}
|
||||
_ = d.Nack(false, true)
|
||||
return
|
||||
}
|
||||
log.Printf("[MQ] email ok order=%s to=%s", payload.OrderID, payload.ToEmail)
|
||||
_ = d.Ack(false)
|
||||
}
|
||||
|
||||
@@ -1,26 +1,26 @@
|
||||
package mq
|
||||
|
||||
import "mengyastore-backend/internal/email"
|
||||
|
||||
// OrderEmailPayload is the JSON body published to RabbitMQ (no SMTP secrets).
|
||||
type OrderEmailPayload struct {
|
||||
ToEmail string `json:"toEmail"`
|
||||
ToName string `json:"toName"`
|
||||
ProductName string `json:"productName"`
|
||||
OrderID string `json:"orderId"`
|
||||
Quantity int `json:"quantity"`
|
||||
Codes []string `json:"codes"`
|
||||
IsManual bool `json:"isManual"`
|
||||
}
|
||||
|
||||
func (p OrderEmailPayload) ToNotifyData() email.OrderNotifyData {
|
||||
return email.OrderNotifyData{
|
||||
ToEmail: p.ToEmail,
|
||||
ToName: p.ToName,
|
||||
ProductName: p.ProductName,
|
||||
OrderID: p.OrderID,
|
||||
Quantity: p.Quantity,
|
||||
Codes: p.Codes,
|
||||
IsManual: p.IsManual,
|
||||
}
|
||||
}
|
||||
package mq
|
||||
|
||||
import "mengyastore-backend/internal/email"
|
||||
|
||||
// OrderEmailPayload is the JSON body published to RabbitMQ (no SMTP secrets).
|
||||
type OrderEmailPayload struct {
|
||||
ToEmail string `json:"toEmail"`
|
||||
ToName string `json:"toName"`
|
||||
ProductName string `json:"productName"`
|
||||
OrderID string `json:"orderId"`
|
||||
Quantity int `json:"quantity"`
|
||||
Codes []string `json:"codes"`
|
||||
IsManual bool `json:"isManual"`
|
||||
}
|
||||
|
||||
func (p OrderEmailPayload) ToNotifyData() email.OrderNotifyData {
|
||||
return email.OrderNotifyData{
|
||||
ToEmail: p.ToEmail,
|
||||
ToName: p.ToName,
|
||||
ProductName: p.ProductName,
|
||||
OrderID: p.OrderID,
|
||||
Quantity: p.Quantity,
|
||||
Codes: p.Codes,
|
||||
IsManual: p.IsManual,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,71 +1,71 @@
|
||||
package mq
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
amqp "github.com/rabbitmq/amqp091-go"
|
||||
)
|
||||
|
||||
const routingKeyOrderEmail = "order.email.notify"
|
||||
|
||||
// OrderEmailRoutingKey is the binding / publish key for order notification messages.
|
||||
func OrderEmailRoutingKey() string { return routingKeyOrderEmail }
|
||||
|
||||
// ExchangeName returns the durable topic exchange for this app + env (isolation from other apps on same broker).
|
||||
func ExchangeName(env string) string {
|
||||
return fmt.Sprintf("ex.mengyastore.%s.events", sanitizeEnv(env))
|
||||
}
|
||||
|
||||
// QueueName returns the durable order-email queue name for this env.
|
||||
func QueueName(env string) string {
|
||||
return fmt.Sprintf("q.mengyastore.%s.order_email", sanitizeEnv(env))
|
||||
}
|
||||
|
||||
func sanitizeEnv(env string) string {
|
||||
e := strings.TrimSpace(strings.ToLower(env))
|
||||
if e == "prod" || e == "production" {
|
||||
return "prod"
|
||||
}
|
||||
return "dev"
|
||||
}
|
||||
|
||||
func declareTopology(ch *amqp.Channel, env string) (exchange, queue string, err error) {
|
||||
exchange = ExchangeName(env)
|
||||
queue = QueueName(env)
|
||||
|
||||
if err = ch.ExchangeDeclare(
|
||||
exchange,
|
||||
"topic",
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
nil,
|
||||
); err != nil {
|
||||
return "", "", fmt.Errorf("exchange declare: %w", err)
|
||||
}
|
||||
|
||||
if _, err = ch.QueueDeclare(
|
||||
queue,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
nil,
|
||||
); err != nil {
|
||||
return "", "", fmt.Errorf("queue declare: %w", err)
|
||||
}
|
||||
|
||||
if err = ch.QueueBind(
|
||||
queue,
|
||||
routingKeyOrderEmail,
|
||||
exchange,
|
||||
false,
|
||||
nil,
|
||||
); err != nil {
|
||||
return "", "", fmt.Errorf("queue bind: %w", err)
|
||||
}
|
||||
|
||||
return exchange, queue, nil
|
||||
}
|
||||
package mq
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
amqp "github.com/rabbitmq/amqp091-go"
|
||||
)
|
||||
|
||||
const routingKeyOrderEmail = "order.email.notify"
|
||||
|
||||
// OrderEmailRoutingKey is the binding / publish key for order notification messages.
|
||||
func OrderEmailRoutingKey() string { return routingKeyOrderEmail }
|
||||
|
||||
// ExchangeName returns the durable topic exchange for this app + env (isolation from other apps on same broker).
|
||||
func ExchangeName(env string) string {
|
||||
return fmt.Sprintf("ex.mengyastore.%s.events", sanitizeEnv(env))
|
||||
}
|
||||
|
||||
// QueueName returns the durable order-email queue name for this env.
|
||||
func QueueName(env string) string {
|
||||
return fmt.Sprintf("q.mengyastore.%s.order_email", sanitizeEnv(env))
|
||||
}
|
||||
|
||||
func sanitizeEnv(env string) string {
|
||||
e := strings.TrimSpace(strings.ToLower(env))
|
||||
if e == "prod" || e == "production" {
|
||||
return "prod"
|
||||
}
|
||||
return "dev"
|
||||
}
|
||||
|
||||
func declareTopology(ch *amqp.Channel, env string) (exchange, queue string, err error) {
|
||||
exchange = ExchangeName(env)
|
||||
queue = QueueName(env)
|
||||
|
||||
if err = ch.ExchangeDeclare(
|
||||
exchange,
|
||||
"topic",
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
nil,
|
||||
); err != nil {
|
||||
return "", "", fmt.Errorf("exchange declare: %w", err)
|
||||
}
|
||||
|
||||
if _, err = ch.QueueDeclare(
|
||||
queue,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
nil,
|
||||
); err != nil {
|
||||
return "", "", fmt.Errorf("queue declare: %w", err)
|
||||
}
|
||||
|
||||
if err = ch.QueueBind(
|
||||
queue,
|
||||
routingKeyOrderEmail,
|
||||
exchange,
|
||||
false,
|
||||
nil,
|
||||
); err != nil {
|
||||
return "", "", fmt.Errorf("queue bind: %w", err)
|
||||
}
|
||||
|
||||
return exchange, queue, nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user