Update mengyastore

This commit is contained in:
2026-04-03 21:18:28 +08:00
parent b4c5103fb3
commit 63897f9566
91 changed files with 4623 additions and 1364 deletions

View File

@@ -1,5 +1,5 @@
# Local secrets — use .env.example / .env.production.example as templates
.env
.env.local
.env.production
*.exe
# Local secrets — use .env.example / .env.production.example as templates
.env
.env.local
.env.production
*.exe

View File

@@ -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
}

View File

@@ -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
}
})
}

View File

@@ -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)
}

View File

@@ -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,
}
}

View File

@@ -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
}

View File

@@ -1,158 +1,158 @@
# 萌芽小店 · 后端mengyastore-backend-go
基于 **Go + Gin + GORM** 的 REST API商品与**发货方式**、订单、SproutGate 用户校验、站点设置、聊天、收藏夹;可选 **RabbitMQ** 发送订单邮件、可选 **Redis**;管理端提供 **系统状态** 聚合接口。
## 技术依赖(核心)
| 包 | / 用途 |
|----|--------|
| gin | HTTP 路由 |
| gorm.io/gorm + driver/mysql | MySQL |
| gin-contrib/cors | CORS |
| google/uuid | 订单 ID |
| github.com/rabbitmq/amqp091-go | RabbitMQ可选 |
| github.com/redis/go-redis/v9 | Redis 探测(可选) |
| github.com/joho/godotenv | `.env` 加载 |
## 目录结构(与仓库一致)
```
mengyastore-backend-go/
├── main.go # 路由注册、MQ 生命周期
├── docker-compose.yml
├── init.sql # 可选:建库参考
├── cmd/migrate/main.go # JSON → MySQL 历史数据迁移
├── internal/
│ ├── auth/sproutgate.go
│ ├── cache/ # Redis 等扩展
│ ├── config/config.go # 环境变量与默认值APP_ENV、DSN、MQ、Redis…
│ ├── database/db.go # Open + AutoMigrate
│ ├── database/models.go # GORM 表模型
│ ├── email/
│ ├── mq/ # 连接、拓扑、Publish、Consumer、断线重连
│ ├── models/ # JSON 业务模型
│ ├── storage/ # Product / Order / Site / Wishlist / Chat
│ └── handlers/
│ ├── public.go
│ ├── order.go
│ ├── stats.go
│ ├── wishlist.go
│ ├── chat.go
│ ├── admin.go
│ ├── admin_product.go
│ ├── admin_orders.go
│ ├── admin_site.go
│ ├── admin_chat.go
│ └── admin_status.go # GET /api/admin/system-status
```
## API 路由一览
### 公开
| 方法 | 路径 | 说明 |
|------|------|------|
| GET | `/api/health` | 健康检查(可含 rabbitmq 状态) |
| GET | `/api/products` | 上架商品列表(无卡密、无 `fixedContent` |
| POST | `/api/products/:id/view` | 记录浏览 |
| GET | `/api/stats` | 订单数、访问量等 |
| POST | `/api/site/visit` | 站点访问计数 |
| GET | `/api/site/maintenance` | 维护状态 |
| POST | `/api/checkout` | 创建订单(卡密扣库或固定内容填充 `delivered_codes` |
| GET | `/api/orders` | 当前用户订单Bearer |
| POST | `/api/orders/:id/confirm` | 确认订单(返回发货内容等) |
### 收藏夹Bearer
| GET | `/api/wishlist` |
| POST | `/api/wishlist` |
| DELETE | `/api/wishlist/:id` |
### 聊天Bearer
| GET | `/api/chat/messages` |
| POST | `/api/chat/messages` |
### 管理端(`X-Admin-Token` / `Authorization` / `?token=`
| POST | `/api/admin/verify` | 校验 token返回 `valid` |
| GET/POST | `/api/admin/products` … | 商品 CRUD见 payload 含 `fulfillmentType``fixedContent` |
| PUT | `/api/admin/products/:id` |
| PATCH | `/api/admin/products/:id/status` |
| DELETE | `/api/admin/products/:id` |
| POST | `/api/admin/site/maintenance` |
| GET/POST | `/api/admin/site/smtp` |
| GET | `/api/admin/orders` |
| DELETE | `/api/admin/orders/:id` |
| GET … POST … DELETE | `/api/admin/chat`… | 会话与回复 |
| **GET** | **`/api/admin/system-status`** | **backend / mysql / redis / rabbitmq 摘要** |
> 文档中若出现已废弃的 `GET /api/admin/token`,以仓库 `main.go` 为准。
## 数据库表(字段摘要)
### products
| 字段 | 说明 |
|------|------|
| fulfillment_type | `card` 卡密逐条;`fixed` 固定内容(不限库存) |
| fixed_content | 固定发货正文(网盘链接、说明等);公开接口不返回 |
| delivery_mode | 订单维度习惯字段:`auto` / `manual`(与 fulfillment 独立) |
| 其余 | name, price, discount_price, tags, cover_url, screenshot_urls, description, active, require_login, max_per_account, total_sold, view_count, show_note, show_contact … |
### product_codes
卡密一行一条;`fulfillment_type = fixed` 时可为空。
### orders
`delivered_codes`JSON`notify_email``delivery_mode``status` 等。
### site_settings
KV`totalVisits``maintenance``smtpHost``smtpPassword` 等;缺失键时 `sitestore.get` 不报错(不向日志刷 ErrRecordNotFound`Find` 替代 `First`)。
## 配置说明
配置由 **`config.Load()`** 从环境变量读取,可选 **`./.env`**(或 `ENV_FILE` 指定)。
要点:
- `DATABASE_DSN` 为空时按 `APP_ENV` 使用内建测试/生产默认 DSN仅开发便利
- RabbitMQ、Redis 开关与地址见 `config.go` 注释。
- `HTTP_LISTEN_ADDR``PUBLIC_API_BASE_URL` 供系统状态 JSON 展示。
## 发货与订单逻辑
### fulfillment_type = `card`(默认)
1. `POST /api/checkout` 校验库存 ≥ 购买数量。
2.`product_codes` 取出对应条数写入订单 `delivered_codes`,并 `Update` 商品去掉已发码。
3. 销量 `IncrementSold`;邮件/MQ 通知。
### fulfillment_type = `fixed`
1. 不校验卡密条数;不修改 `product_codes`
2.`fixed_content` 复制 `quantity` 次填入 `delivered_codes`(与多件购买展示一致)。
3.`IncrementSold`;邮件/MQ 同自动发货路径。
### delivery_mode订单
业务上仍可区分用户确认后展示「等待人工发货」等;与 `fulfillment_type` 正交。
## RabbitMQ 说明
- 启用时声明交换机/队列/绑定;订单邮件可 `Publish`,失败降级直发 SMTP。
- **504 channel closed** 等可回收错误:客户端会 **重连** 并在成功后 **重启消费协程**(见 `internal/mq/client.go`)。
## 本地开发
```bash
go run .
go build -o mengyastore-backend.exe .
go run ./cmd/migrate/main.go # 按需迁移旧 JSON
```
---
维护时请以 **`main.go``internal/database/models.go`** 为最终准据;本文随版本迭代更新。
# 萌芽小店 · 后端mengyastore-backend-go
基于 **Go + Gin + GORM** 的 REST API商品与**发货方式**、订单、SproutGate 用户校验、站点设置、聊天、收藏夹;可选 **RabbitMQ** 发送订单邮件、可选 **Redis**;管理端提供 **系统状态** 聚合接口。
## 技术依赖(核心)
| 包 | / 用途 |
|----|--------|
| gin | HTTP 路由 |
| gorm.io/gorm + driver/mysql | MySQL |
| gin-contrib/cors | CORS |
| google/uuid | 订单 ID |
| github.com/rabbitmq/amqp091-go | RabbitMQ可选 |
| github.com/redis/go-redis/v9 | Redis 探测(可选) |
| github.com/joho/godotenv | `.env` 加载 |
## 目录结构(与仓库一致)
```
mengyastore-backend-go/
├── main.go # 路由注册、MQ 生命周期
├── docker-compose.yml
├── init.sql # 可选:建库参考
├── cmd/migrate/main.go # JSON → MySQL 历史数据迁移
├── internal/
│ ├── auth/sproutgate.go
│ ├── cache/ # Redis 等扩展
│ ├── config/config.go # 环境变量与默认值APP_ENV、DSN、MQ、Redis…
│ ├── database/db.go # Open + AutoMigrate
│ ├── database/models.go # GORM 表模型
│ ├── email/
│ ├── mq/ # 连接、拓扑、Publish、Consumer、断线重连
│ ├── models/ # JSON 业务模型
│ ├── storage/ # Product / Order / Site / Wishlist / Chat
│ └── handlers/
│ ├── public.go
│ ├── order.go
│ ├── stats.go
│ ├── wishlist.go
│ ├── chat.go
│ ├── admin.go
│ ├── admin_product.go
│ ├── admin_orders.go
│ ├── admin_site.go
│ ├── admin_chat.go
│ └── admin_status.go # GET /api/admin/system-status
```
## API 路由一览
### 公开
| 方法 | 路径 | 说明 |
|------|------|------|
| GET | `/api/health` | 健康检查(可含 rabbitmq 状态) |
| GET | `/api/products` | 上架商品列表(无卡密、无 `fixedContent` |
| POST | `/api/products/:id/view` | 记录浏览 |
| GET | `/api/stats` | 订单数、访问量等 |
| POST | `/api/site/visit` | 站点访问计数 |
| GET | `/api/site/maintenance` | 维护状态 |
| POST | `/api/checkout` | 创建订单(卡密扣库或固定内容填充 `delivered_codes` |
| GET | `/api/orders` | 当前用户订单Bearer |
| POST | `/api/orders/:id/confirm` | 确认订单(返回发货内容等) |
### 收藏夹Bearer
| GET | `/api/wishlist` |
| POST | `/api/wishlist` |
| DELETE | `/api/wishlist/:id` |
### 聊天Bearer
| GET | `/api/chat/messages` |
| POST | `/api/chat/messages` |
### 管理端(`X-Admin-Token` / `Authorization` / `?token=`
| POST | `/api/admin/verify` | 校验 token返回 `valid` |
| GET/POST | `/api/admin/products` … | 商品 CRUD见 payload 含 `fulfillmentType``fixedContent` |
| PUT | `/api/admin/products/:id` |
| PATCH | `/api/admin/products/:id/status` |
| DELETE | `/api/admin/products/:id` |
| POST | `/api/admin/site/maintenance` |
| GET/POST | `/api/admin/site/smtp` |
| GET | `/api/admin/orders` |
| DELETE | `/api/admin/orders/:id` |
| GET … POST … DELETE | `/api/admin/chat`… | 会话与回复 |
| **GET** | **`/api/admin/system-status`** | **backend / mysql / redis / rabbitmq 摘要** |
> 文档中若出现已废弃的 `GET /api/admin/token`,以仓库 `main.go` 为准。
## 数据库表(字段摘要)
### products
| 字段 | 说明 |
|------|------|
| fulfillment_type | `card` 卡密逐条;`fixed` 固定内容(不限库存) |
| fixed_content | 固定发货正文(网盘链接、说明等);公开接口不返回 |
| delivery_mode | 订单维度习惯字段:`auto` / `manual`(与 fulfillment 独立) |
| 其余 | name, price, discount_price, tags, cover_url, screenshot_urls, description, active, require_login, max_per_account, total_sold, view_count, show_note, show_contact … |
### product_codes
卡密一行一条;`fulfillment_type = fixed` 时可为空。
### orders
`delivered_codes`JSON`notify_email``delivery_mode``status` 等。
### site_settings
KV`totalVisits``maintenance``smtpHost``smtpPassword` 等;缺失键时 `sitestore.get` 不报错(不向日志刷 ErrRecordNotFound`Find` 替代 `First`)。
## 配置说明
配置由 **`config.Load()`** 从环境变量读取,可选 **`./.env`**(或 `ENV_FILE` 指定)。
要点:
- `DATABASE_DSN` 为空时按 `APP_ENV` 使用内建测试/生产默认 DSN仅开发便利
- RabbitMQ、Redis 开关与地址见 `config.go` 注释。
- `HTTP_LISTEN_ADDR``PUBLIC_API_BASE_URL` 供系统状态 JSON 展示。
## 发货与订单逻辑
### fulfillment_type = `card`(默认)
1. `POST /api/checkout` 校验库存 ≥ 购买数量。
2.`product_codes` 取出对应条数写入订单 `delivered_codes`,并 `Update` 商品去掉已发码。
3. 销量 `IncrementSold`;邮件/MQ 通知。
### fulfillment_type = `fixed`
1. 不校验卡密条数;不修改 `product_codes`
2.`fixed_content` 复制 `quantity` 次填入 `delivered_codes`(与多件购买展示一致)。
3.`IncrementSold`;邮件/MQ 同自动发货路径。
### delivery_mode订单
业务上仍可区分用户确认后展示「等待人工发货」等;与 `fulfillment_type` 正交。
## RabbitMQ 说明
- 启用时声明交换机/队列/绑定;订单邮件可 `Publish`,失败降级直发 SMTP。
- **504 channel closed** 等可回收错误:客户端会 **重连** 并在成功后 **重启消费协程**(见 `internal/mq/client.go`)。
## 本地开发
```bash
go run .
go build -o mengyastore-backend.exe .
go run ./cmd/migrate/main.go # 按需迁移旧 JSON
```
---
维护时请以 **`main.go``internal/database/models.go`** 为最终准据;本文随版本迭代更新。