diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..4647a46 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,10 @@ +# 默认忽略的文件 +/shelf/ +/workspace.xml +# 已忽略包含查询文件的默认文件夹 +/queries/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml +# 基于编辑器的 HTTP 客户端请求 +/httpRequests/ diff --git a/.idea/compiler.xml b/.idea/compiler.xml new file mode 100644 index 0000000..627e4cd --- /dev/null +++ b/.idea/compiler.xml @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/.idea/encodings.xml b/.idea/encodings.xml new file mode 100644 index 0000000..f82ef42 --- /dev/null +++ b/.idea/encodings.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/.idea/jarRepositories.xml b/.idea/jarRepositories.xml new file mode 100644 index 0000000..a468a99 --- /dev/null +++ b/.idea/jarRepositories.xml @@ -0,0 +1,20 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/.idea/mengyastore.iml b/.idea/mengyastore.iml new file mode 100644 index 0000000..18ec59d --- /dev/null +++ b/.idea/mengyastore.iml @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 0000000..18e444c --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,14 @@ + + + + + + + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..746d6ba --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..c8397c9 --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/mengyastore-backend-go/.gitignore b/mengyastore-backend-go/.gitignore index eddf75a..91fcb8f 100644 --- a/mengyastore-backend-go/.gitignore +++ b/mengyastore-backend-go/.gitignore @@ -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 diff --git a/mengyastore-backend-go/internal/handlers/admin_status.go b/mengyastore-backend-go/internal/handlers/admin_status.go index e530b30..da7b3cd 100644 --- a/mengyastore-backend-go/internal/handlers/admin_status.go +++ b/mengyastore-backend-go/internal/handlers/admin_status.go @@ -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 +} diff --git a/mengyastore-backend-go/internal/mq/client.go b/mengyastore-backend-go/internal/mq/client.go index 9d7640c..7755a22 100644 --- a/mengyastore-backend-go/internal/mq/client.go +++ b/mengyastore-backend-go/internal/mq/client.go @@ -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 + } + }) +} diff --git a/mengyastore-backend-go/internal/mq/consumer.go b/mengyastore-backend-go/internal/mq/consumer.go index 5492fd3..64a058d 100644 --- a/mengyastore-backend-go/internal/mq/consumer.go +++ b/mengyastore-backend-go/internal/mq/consumer.go @@ -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) +} diff --git a/mengyastore-backend-go/internal/mq/payload.go b/mengyastore-backend-go/internal/mq/payload.go index b47591f..b1499ba 100644 --- a/mengyastore-backend-go/internal/mq/payload.go +++ b/mengyastore-backend-go/internal/mq/payload.go @@ -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, + } +} diff --git a/mengyastore-backend-go/internal/mq/topology.go b/mengyastore-backend-go/internal/mq/topology.go index fc6b1eb..44534a8 100644 --- a/mengyastore-backend-go/internal/mq/topology.go +++ b/mengyastore-backend-go/internal/mq/topology.go @@ -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 +} diff --git a/mengyastore-backend-go/后端文档.md b/mengyastore-backend-go/后端文档.md index 4dfbaa8..a625c30 100644 --- a/mengyastore-backend-go/后端文档.md +++ b/mengyastore-backend-go/后端文档.md @@ -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`** 为最终准据;本文随版本迭代更新。 diff --git a/mengyastore-backend-java/pom.xml b/mengyastore-backend-java/pom.xml index 2c8e499..503ea79 100644 --- a/mengyastore-backend-java/pom.xml +++ b/mengyastore-backend-java/pom.xml @@ -6,52 +6,82 @@ org.springframework.boot spring-boot-starter-parent 3.5.12 - + com.smyhub.store mengyastore-backend-java 0.0.1-SNAPSHOT mengyastore-backend-java - mengyastore-backend-java - - - - - - - - - - - - - + 萌芽小店 Java Spring Boot 后端 17 + org.springframework.boot - spring-boot-starter + spring-boot-starter-web + + + org.springframework.boot + spring-boot-starter-data-jpa + + + + + com.mysql + mysql-connector-j + runtime + + + + + org.springframework.boot + spring-boot-starter-amqp + + + + + org.springframework.boot + spring-boot-starter-data-redis + + + + + org.springframework.boot + spring-boot-starter-validation + + + + + org.springframework.boot + spring-boot-starter-mail + + + + + org.springframework.boot + spring-boot-starter-actuator + + + + + org.projectlombok + lombok + true + + + org.springframework.boot spring-boot-devtools runtime true - - org.springframework.boot - spring-boot-docker-compose - runtime - true - - - org.projectlombok - lombok - true - + + org.springframework.boot spring-boot-starter-test diff --git a/mengyastore-backend-java/src/main/java/com/smyhub/store/mengyastorebackendjava/MengyastoreBackendJavaApplication.java b/mengyastore-backend-java/src/main/java/com/smyhub/store/mengyastorebackendjava/MengyastoreBackendJavaApplication.java index 538a248..c247a17 100644 --- a/mengyastore-backend-java/src/main/java/com/smyhub/store/mengyastorebackendjava/MengyastoreBackendJavaApplication.java +++ b/mengyastore-backend-java/src/main/java/com/smyhub/store/mengyastorebackendjava/MengyastoreBackendJavaApplication.java @@ -2,12 +2,16 @@ package com.smyhub.store.mengyastorebackendjava; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.scheduling.annotation.EnableScheduling; +import org.springframework.transaction.annotation.EnableTransactionManagement; @SpringBootApplication +@EnableTransactionManagement +@EnableScheduling public class MengyastoreBackendJavaApplication { public static void main(String[] args) { SpringApplication.run(MengyastoreBackendJavaApplication.class, args); } - } diff --git a/mengyastore-backend-java/src/main/java/com/smyhub/store/mengyastorebackendjava/config/AppProperties.java b/mengyastore-backend-java/src/main/java/com/smyhub/store/mengyastorebackendjava/config/AppProperties.java new file mode 100644 index 0000000..733c7f7 --- /dev/null +++ b/mengyastore-backend-java/src/main/java/com/smyhub/store/mengyastorebackendjava/config/AppProperties.java @@ -0,0 +1,33 @@ +package com.smyhub.store.mengyastorebackendjava.config; + +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +@Data +@Component +@ConfigurationProperties(prefix = "app") +public class AppProperties { + + private String adminToken = "changeme"; + + private String authApiUrl = "https://auth.api.shumengya.top"; + + /** Mirrors Go APP_ENV: {@code development} | {@code production}; used by system-status panel. */ + private String appEnv = "development"; + + private boolean rabbitmqEnabled = false; + + /** + * Mirrors Go REDIS_ENABLED (default on unless explicitly disabled). + */ + private boolean redisEnabled = true; + + private String rabbitmqEnv = "dev"; + + private String redisEnv = "dev"; + + private String publicApiBaseUrl = ""; + + private String corsAllowedOrigins = "http://localhost:5173,http://localhost:3000"; +} diff --git a/mengyastore-backend-java/src/main/java/com/smyhub/store/mengyastorebackendjava/config/CorsConfig.java b/mengyastore-backend-java/src/main/java/com/smyhub/store/mengyastorebackendjava/config/CorsConfig.java new file mode 100644 index 0000000..5932584 --- /dev/null +++ b/mengyastore-backend-java/src/main/java/com/smyhub/store/mengyastorebackendjava/config/CorsConfig.java @@ -0,0 +1,44 @@ +package com.smyhub.store.mengyastorebackendjava.config; + +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.util.StringUtils; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; +import org.springframework.web.filter.CorsFilter; + +import java.util.Arrays; +import java.util.List; + +@Configuration +@RequiredArgsConstructor +public class CorsConfig { + + private final AppProperties appProperties; + + @Bean + public CorsFilter corsFilter() { + CorsConfiguration config = new CorsConfiguration(); + + String originsRaw = appProperties.getCorsAllowedOrigins(); + if (StringUtils.hasText(originsRaw)) { + List origins = Arrays.asList(originsRaw.split(",")); + config.setAllowedOrigins(origins); + } else { + config.addAllowedOriginPattern("*"); + } + + config.setAllowedMethods(List.of("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS")); + config.setAllowedHeaders(List.of( + "Content-Type", "Authorization", "X-Admin-Token", + "X-Requested-With", "Accept", "Accept-Language" + )); + config.setAllowCredentials(true); + config.setMaxAge(86400L); + + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/api/**", config); + return new CorsFilter(source); + } +} diff --git a/mengyastore-backend-java/src/main/java/com/smyhub/store/mengyastorebackendjava/config/RabbitMQConfig.java b/mengyastore-backend-java/src/main/java/com/smyhub/store/mengyastorebackendjava/config/RabbitMQConfig.java new file mode 100644 index 0000000..03b46bd --- /dev/null +++ b/mengyastore-backend-java/src/main/java/com/smyhub/store/mengyastorebackendjava/config/RabbitMQConfig.java @@ -0,0 +1,63 @@ +package com.smyhub.store.mengyastorebackendjava.config; + +import com.smyhub.store.mengyastorebackendjava.mq.RabbitMQTopology; +import lombok.RequiredArgsConstructor; +import org.springframework.amqp.core.*; +import org.springframework.amqp.rabbit.config.SimpleRabbitListenerContainerFactory; +import org.springframework.amqp.rabbit.connection.ConnectionFactory; +import org.springframework.amqp.rabbit.core.RabbitTemplate; +import org.springframework.amqp.core.AcknowledgeMode; +import org.springframework.amqp.support.converter.Jackson2JsonMessageConverter; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +@RequiredArgsConstructor +@ConditionalOnProperty(name = "app.rabbitmq-enabled", havingValue = "true") +public class RabbitMQConfig { + + private final AppProperties appProperties; + + @Bean + public Jackson2JsonMessageConverter jsonMessageConverter() { + return new Jackson2JsonMessageConverter(); + } + + @Bean + public RabbitTemplate rabbitTemplate(ConnectionFactory connectionFactory) { + RabbitTemplate template = new RabbitTemplate(connectionFactory); + template.setMessageConverter(jsonMessageConverter()); + return template; + } + + @Bean + public SimpleRabbitListenerContainerFactory rabbitListenerContainerFactory(ConnectionFactory connectionFactory) { + SimpleRabbitListenerContainerFactory factory = new SimpleRabbitListenerContainerFactory(); + factory.setConnectionFactory(connectionFactory); + factory.setMessageConverter(jsonMessageConverter()); + factory.setPrefetchCount(1); + factory.setAcknowledgeMode(AcknowledgeMode.MANUAL); + return factory; + } + + @Bean + public TopicExchange orderEmailExchange() { + String name = RabbitMQTopology.exchangeName(appProperties.getRabbitmqEnv()); + return ExchangeBuilder.topicExchange(name).durable(true).build(); + } + + @Bean + public Queue orderEmailQueue() { + String name = RabbitMQTopology.queueName(appProperties.getRabbitmqEnv()); + return QueueBuilder.durable(name).build(); + } + + @Bean + public Binding orderEmailBinding() { + return BindingBuilder + .bind(orderEmailQueue()) + .to(orderEmailExchange()) + .with(RabbitMQTopology.ORDER_EMAIL_ROUTING_KEY); + } +} diff --git a/mengyastore-backend-java/src/main/java/com/smyhub/store/mengyastorebackendjava/config/RedisConfig.java b/mengyastore-backend-java/src/main/java/com/smyhub/store/mengyastorebackendjava/config/RedisConfig.java new file mode 100644 index 0000000..2f65a2e --- /dev/null +++ b/mengyastore-backend-java/src/main/java/com/smyhub/store/mengyastorebackendjava/config/RedisConfig.java @@ -0,0 +1,15 @@ +package com.smyhub.store.mengyastorebackendjava.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.core.StringRedisTemplate; + +@Configuration +public class RedisConfig { + + @Bean + public StringRedisTemplate stringRedisTemplate(RedisConnectionFactory connectionFactory) { + return new StringRedisTemplate(connectionFactory); + } +} diff --git a/mengyastore-backend-java/src/main/java/com/smyhub/store/mengyastorebackendjava/config/RestTemplateConfig.java b/mengyastore-backend-java/src/main/java/com/smyhub/store/mengyastorebackendjava/config/RestTemplateConfig.java new file mode 100644 index 0000000..c2470bb --- /dev/null +++ b/mengyastore-backend-java/src/main/java/com/smyhub/store/mengyastorebackendjava/config/RestTemplateConfig.java @@ -0,0 +1,20 @@ +package com.smyhub.store.mengyastorebackendjava.config; + +import org.springframework.boot.web.client.RestTemplateBuilder; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.client.RestTemplate; + +import java.time.Duration; + +@Configuration +public class RestTemplateConfig { + + @Bean + public RestTemplate restTemplate(RestTemplateBuilder builder) { + return builder + .connectTimeout(Duration.ofSeconds(5)) + .readTimeout(Duration.ofSeconds(8)) + .build(); + } +} diff --git a/mengyastore-backend-java/src/main/java/com/smyhub/store/mengyastorebackendjava/config/WebMvcConfig.java b/mengyastore-backend-java/src/main/java/com/smyhub/store/mengyastorebackendjava/config/WebMvcConfig.java new file mode 100644 index 0000000..def68e4 --- /dev/null +++ b/mengyastore-backend-java/src/main/java/com/smyhub/store/mengyastorebackendjava/config/WebMvcConfig.java @@ -0,0 +1,31 @@ +package com.smyhub.store.mengyastorebackendjava.config; + +import com.smyhub.store.mengyastorebackendjava.security.AdminTokenInterceptor; +import com.smyhub.store.mengyastorebackendjava.security.BearerTokenInterceptor; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.servlet.config.annotation.InterceptorRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +@Configuration +@RequiredArgsConstructor +public class WebMvcConfig implements WebMvcConfigurer { + + private final AdminTokenInterceptor adminTokenInterceptor; + private final BearerTokenInterceptor bearerTokenInterceptor; + + @Override + public void addInterceptors(InterceptorRegistry registry) { + registry.addInterceptor(adminTokenInterceptor) + .addPathPatterns("/api/admin/**") + .excludePathPatterns("/api/admin/verify"); + + registry.addInterceptor(bearerTokenInterceptor) + .addPathPatterns( + "/api/orders", + "/api/wishlist", + "/api/wishlist/**", + "/api/chat/messages" + ); + } +} diff --git a/mengyastore-backend-java/src/main/java/com/smyhub/store/mengyastorebackendjava/controller/ChatController.java b/mengyastore-backend-java/src/main/java/com/smyhub/store/mengyastorebackendjava/controller/ChatController.java new file mode 100644 index 0000000..c8d7935 --- /dev/null +++ b/mengyastore-backend-java/src/main/java/com/smyhub/store/mengyastorebackendjava/controller/ChatController.java @@ -0,0 +1,38 @@ +package com.smyhub.store.mengyastorebackendjava.controller; + +import com.smyhub.store.mengyastorebackendjava.dto.request.ChatMessageRequest; +import com.smyhub.store.mengyastorebackendjava.dto.response.ApiResponse; +import com.smyhub.store.mengyastorebackendjava.dto.response.ChatMessageResponse; +import com.smyhub.store.mengyastorebackendjava.security.CurrentUserHolder; +import com.smyhub.store.mengyastorebackendjava.security.SproutGateUser; +import com.smyhub.store.mengyastorebackendjava.service.ChatService; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RestController +@RequestMapping("/api/chat") +@RequiredArgsConstructor +public class ChatController { + + private final ChatService chatService; + + @GetMapping("/messages") + public ResponseEntity>> getMessages() { + String account = CurrentUserHolder.get().getAccount(); + return ResponseEntity.ok(ApiResponse.ok(chatService.getUserMessages(account))); + } + + @PostMapping("/messages") + public ResponseEntity> sendMessage( + @Valid @RequestBody ChatMessageRequest req) { + SproutGateUser user = CurrentUserHolder.get(); + ChatMessageResponse msg = chatService.sendUserMessage( + user.getAccount(), user.getUsername(), req.getContent() + ); + return ResponseEntity.ok(ApiResponse.ok(msg)); + } +} diff --git a/mengyastore-backend-java/src/main/java/com/smyhub/store/mengyastorebackendjava/controller/HealthController.java b/mengyastore-backend-java/src/main/java/com/smyhub/store/mengyastorebackendjava/controller/HealthController.java new file mode 100644 index 0000000..920e33d --- /dev/null +++ b/mengyastore-backend-java/src/main/java/com/smyhub/store/mengyastorebackendjava/controller/HealthController.java @@ -0,0 +1,55 @@ +package com.smyhub.store.mengyastorebackendjava.controller; + +import com.smyhub.store.mengyastorebackendjava.config.AppProperties; +import com.smyhub.store.mengyastorebackendjava.dto.response.ApiResponse; +import com.smyhub.store.mengyastorebackendjava.dto.response.HealthResponse; +import lombok.extern.slf4j.Slf4j; +import org.springframework.amqp.rabbit.core.RabbitTemplate; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@Slf4j +@RestController +@RequestMapping("/api") +public class HealthController { + + private final AppProperties appProperties; + + @Autowired(required = false) + private RabbitTemplate rabbitTemplate; + + public HealthController(AppProperties appProperties) { + this.appProperties = appProperties; + } + + @GetMapping("/health") + public ResponseEntity> health() { + HealthResponse body = HealthResponse.builder() + .status("ok") + .rabbitmq(checkRabbitMQ()) + .build(); + return ResponseEntity.ok(ApiResponse.ok(body)); + } + + private String checkRabbitMQ() { + if (!appProperties.isRabbitmqEnabled()) { + return "disabled"; + } + if (rabbitTemplate == null) { + return "disabled"; + } + try { + rabbitTemplate.execute(channel -> { + channel.basicQos(1); + return null; + }); + return "ok"; + } catch (Exception e) { + log.debug("[Health] RabbitMQ ping failed: {}", e.getMessage()); + return "error"; + } + } +} diff --git a/mengyastore-backend-java/src/main/java/com/smyhub/store/mengyastorebackendjava/controller/PublicOrderController.java b/mengyastore-backend-java/src/main/java/com/smyhub/store/mengyastorebackendjava/controller/PublicOrderController.java new file mode 100644 index 0000000..18bee7f --- /dev/null +++ b/mengyastore-backend-java/src/main/java/com/smyhub/store/mengyastorebackendjava/controller/PublicOrderController.java @@ -0,0 +1,67 @@ +package com.smyhub.store.mengyastorebackendjava.controller; + +import com.smyhub.store.mengyastorebackendjava.dto.request.CheckoutRequest; +import com.smyhub.store.mengyastorebackendjava.dto.response.ApiResponse; +import com.smyhub.store.mengyastorebackendjava.security.SproutGateUser; +import com.smyhub.store.mengyastorebackendjava.security.SproutGateVerifyResult; +import com.smyhub.store.mengyastorebackendjava.service.OrderService; +import com.smyhub.store.mengyastorebackendjava.service.SproutGateAuthService; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.util.StringUtils; +import org.springframework.web.bind.annotation.*; + +import java.util.Map; + +@RestController +@RequestMapping("/api") +@RequiredArgsConstructor +public class PublicOrderController { + + private final OrderService orderService; + private final SproutGateAuthService authService; + + @PostMapping("/checkout") + public ResponseEntity>> checkout( + @Valid @RequestBody CheckoutRequest req, + HttpServletRequest request) { + + SproutGateUser user = tryResolveUser(request); + OrderService.CheckoutResult result = orderService.checkout(req, user); + + String qrPayload = "order:" + result.orderId() + ":" + result.productId(); + String qrUrl = "https://api.qrserver.com/v1/create-qr-code/?size=320x320&data=" + + java.net.URLEncoder.encode(qrPayload, java.nio.charset.StandardCharsets.UTF_8); + + return ResponseEntity.ok(ApiResponse.ok(Map.of( + "orderId", result.orderId(), + "qrCodeUrl", qrUrl, + "productId", result.productId(), + "productQty", result.qty(), + "viewCount", result.viewCount(), + "status", result.status() + ))); + } + + @PostMapping("/orders/{id}/confirm") + public ResponseEntity>> confirmOrder(@PathVariable String id) { + var order = orderService.confirmOrder(id); + return ResponseEntity.ok(ApiResponse.ok(Map.of( + "orderId", order.getId(), + "status", order.getStatus(), + "deliveryMode", order.getDeliveryMode(), + "deliveredCodes", order.getDeliveredCodes(), + "isManual", "manual".equals(order.getDeliveryMode()) + ))); + } + + private SproutGateUser tryResolveUser(HttpServletRequest request) { + String header = request.getHeader("Authorization"); + if (!StringUtils.hasText(header) || !header.startsWith("Bearer ")) return null; + String token = header.substring(7); + SproutGateVerifyResult result = authService.verifyToken(token); + return (result.isValid() && result.getUser() != null) ? result.getUser() : null; + } +} diff --git a/mengyastore-backend-java/src/main/java/com/smyhub/store/mengyastorebackendjava/controller/PublicProductController.java b/mengyastore-backend-java/src/main/java/com/smyhub/store/mengyastorebackendjava/controller/PublicProductController.java new file mode 100644 index 0000000..b0b53b2 --- /dev/null +++ b/mengyastore-backend-java/src/main/java/com/smyhub/store/mengyastorebackendjava/controller/PublicProductController.java @@ -0,0 +1,57 @@ +package com.smyhub.store.mengyastorebackendjava.controller; + +import com.smyhub.store.mengyastorebackendjava.dto.response.ApiResponse; +import com.smyhub.store.mengyastorebackendjava.dto.response.ProductResponse; +import com.smyhub.store.mengyastorebackendjava.service.ProductService; +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.Map; + +@RestController +@RequestMapping("/api") +@RequiredArgsConstructor +public class PublicProductController { + + private final ProductService productService; + + @GetMapping("/products") + public ResponseEntity>> listProducts() { + return ResponseEntity.ok(ApiResponse.ok(productService.listPublicProducts())); + } + + @PostMapping("/products/{id}/view") + public ResponseEntity>> recordView( + @PathVariable String id, + HttpServletRequest request) { + String fingerprint = buildFingerprint(request); + ProductResponse p = productService.recordView(id, fingerprint); + return ResponseEntity.ok(ApiResponse.ok(Map.of( + "id", p.getId(), + "viewCount", p.getViewCount(), + "counted", true + ))); + } + + private String buildFingerprint(HttpServletRequest request) { + String ip = getClientIp(request); + String ua = nvl(request.getHeader("User-Agent")); + String lang = nvl(request.getHeader("Accept-Language")); + return ip + "|" + ua + "|" + lang; + } + + private String getClientIp(HttpServletRequest request) { + String forwarded = request.getHeader("X-Forwarded-For"); + if (forwarded != null && !forwarded.isBlank()) { + return forwarded.split(",")[0].strip(); + } + return request.getRemoteAddr(); + } + + private String nvl(String s) { + return s != null ? s.strip() : ""; + } +} diff --git a/mengyastore-backend-java/src/main/java/com/smyhub/store/mengyastorebackendjava/controller/StatsController.java b/mengyastore-backend-java/src/main/java/com/smyhub/store/mengyastorebackendjava/controller/StatsController.java new file mode 100644 index 0000000..9b5ad4b --- /dev/null +++ b/mengyastore-backend-java/src/main/java/com/smyhub/store/mengyastorebackendjava/controller/StatsController.java @@ -0,0 +1,42 @@ +package com.smyhub.store.mengyastorebackendjava.controller; + +import com.smyhub.store.mengyastorebackendjava.dto.response.ApiResponse; +import com.smyhub.store.mengyastorebackendjava.dto.response.MaintenanceResponse; +import com.smyhub.store.mengyastorebackendjava.dto.response.StatsResponse; +import com.smyhub.store.mengyastorebackendjava.repository.OrderRepository; +import com.smyhub.store.mengyastorebackendjava.service.SiteService; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.Map; + +@RestController +@RequestMapping("/api") +@RequiredArgsConstructor +public class StatsController { + + private final OrderRepository orderRepository; + private final SiteService siteService; + + @GetMapping("/stats") + public ResponseEntity> getStats() { + long total = orderRepository.count(); + int visits = siteService.getTotalVisits(); + return ResponseEntity.ok(ApiResponse.ok(StatsResponse.builder() + .totalOrders(total) + .totalVisits(visits) + .build())); + } + + @PostMapping("/site/visit") + public ResponseEntity>> recordVisit() { + int total = siteService.incrementVisits(); + return ResponseEntity.ok(ApiResponse.ok(Map.of("total", total, "counted", true))); + } + + @GetMapping("/site/maintenance") + public ResponseEntity> getMaintenance() { + return ResponseEntity.ok(ApiResponse.ok(siteService.getMaintenance())); + } +} diff --git a/mengyastore-backend-java/src/main/java/com/smyhub/store/mengyastorebackendjava/controller/UserOrderController.java b/mengyastore-backend-java/src/main/java/com/smyhub/store/mengyastorebackendjava/controller/UserOrderController.java new file mode 100644 index 0000000..e94a208 --- /dev/null +++ b/mengyastore-backend-java/src/main/java/com/smyhub/store/mengyastorebackendjava/controller/UserOrderController.java @@ -0,0 +1,27 @@ +package com.smyhub.store.mengyastorebackendjava.controller; + +import com.smyhub.store.mengyastorebackendjava.dto.response.ApiResponse; +import com.smyhub.store.mengyastorebackendjava.dto.response.OrderResponse; +import com.smyhub.store.mengyastorebackendjava.security.CurrentUserHolder; +import com.smyhub.store.mengyastorebackendjava.service.OrderService; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +@RestController +@RequestMapping("/api") +@RequiredArgsConstructor +public class UserOrderController { + + private final OrderService orderService; + + @GetMapping("/orders") + public ResponseEntity>> myOrders() { + String account = CurrentUserHolder.get().getAccount(); + return ResponseEntity.ok(ApiResponse.ok(orderService.getMyOrders(account))); + } +} diff --git a/mengyastore-backend-java/src/main/java/com/smyhub/store/mengyastorebackendjava/controller/WishlistController.java b/mengyastore-backend-java/src/main/java/com/smyhub/store/mengyastorebackendjava/controller/WishlistController.java new file mode 100644 index 0000000..c28cc1f --- /dev/null +++ b/mengyastore-backend-java/src/main/java/com/smyhub/store/mengyastorebackendjava/controller/WishlistController.java @@ -0,0 +1,45 @@ +package com.smyhub.store.mengyastorebackendjava.controller; + +import com.smyhub.store.mengyastorebackendjava.dto.response.ApiResponse; +import com.smyhub.store.mengyastorebackendjava.security.CurrentUserHolder; +import com.smyhub.store.mengyastorebackendjava.service.WishlistService; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.Map; + +@RestController +@RequestMapping("/api/wishlist") +@RequiredArgsConstructor +public class WishlistController { + + private final WishlistService wishlistService; + + @GetMapping + public ResponseEntity>>> getWishlist() { + String account = CurrentUserHolder.get().getAccount(); + List items = wishlistService.getWishlistIds(account); + return ResponseEntity.ok(ApiResponse.ok(Map.of("items", items))); + } + + @PostMapping + public ResponseEntity>>> addToWishlist( + @RequestBody Map body) { + String productId = body.get("productId"); + String account = CurrentUserHolder.get().getAccount(); + wishlistService.addToWishlist(account, productId); + List items = wishlistService.getWishlistIds(account); + return ResponseEntity.ok(ApiResponse.ok(Map.of("items", items))); + } + + @DeleteMapping("/{productId}") + public ResponseEntity>>> removeFromWishlist( + @PathVariable String productId) { + String account = CurrentUserHolder.get().getAccount(); + wishlistService.removeFromWishlist(account, productId); + List items = wishlistService.getWishlistIds(account); + return ResponseEntity.ok(ApiResponse.ok(Map.of("items", items))); + } +} diff --git a/mengyastore-backend-java/src/main/java/com/smyhub/store/mengyastorebackendjava/controller/admin/AdminChatController.java b/mengyastore-backend-java/src/main/java/com/smyhub/store/mengyastorebackendjava/controller/admin/AdminChatController.java new file mode 100644 index 0000000..5f29d53 --- /dev/null +++ b/mengyastore-backend-java/src/main/java/com/smyhub/store/mengyastorebackendjava/controller/admin/AdminChatController.java @@ -0,0 +1,46 @@ +package com.smyhub.store.mengyastorebackendjava.controller.admin; + +import com.smyhub.store.mengyastorebackendjava.dto.request.AdminChatMessageRequest; +import com.smyhub.store.mengyastorebackendjava.dto.response.ApiResponse; +import com.smyhub.store.mengyastorebackendjava.dto.response.ChatMessageResponse; +import com.smyhub.store.mengyastorebackendjava.service.ChatService; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.Map; + +@RestController +@RequestMapping("/api/admin/chat") +@RequiredArgsConstructor +public class AdminChatController { + + private final ChatService chatService; + + @GetMapping + public ResponseEntity>>> listConversations() { + return ResponseEntity.ok(ApiResponse.ok(chatService.listAllConversations())); + } + + @GetMapping("/{account}") + public ResponseEntity>> getConversation( + @PathVariable String account) { + return ResponseEntity.ok(ApiResponse.ok(chatService.getConversation(account))); + } + + @PostMapping("/{account}") + public ResponseEntity> replyToUser( + @PathVariable String account, + @Valid @RequestBody AdminChatMessageRequest req) { + ChatMessageResponse msg = chatService.sendAdminMessage(account, req.getContent()); + return ResponseEntity.ok(ApiResponse.ok(msg)); + } + + @DeleteMapping("/{account}") + public ResponseEntity> clearConversation(@PathVariable String account) { + chatService.clearConversation(account); + return ResponseEntity.ok(ApiResponse.ok(null)); + } +} diff --git a/mengyastore-backend-java/src/main/java/com/smyhub/store/mengyastorebackendjava/controller/admin/AdminOrderController.java b/mengyastore-backend-java/src/main/java/com/smyhub/store/mengyastorebackendjava/controller/admin/AdminOrderController.java new file mode 100644 index 0000000..b77ce5e --- /dev/null +++ b/mengyastore-backend-java/src/main/java/com/smyhub/store/mengyastorebackendjava/controller/admin/AdminOrderController.java @@ -0,0 +1,29 @@ +package com.smyhub.store.mengyastorebackendjava.controller.admin; + +import com.smyhub.store.mengyastorebackendjava.dto.response.ApiResponse; +import com.smyhub.store.mengyastorebackendjava.dto.response.OrderResponse; +import com.smyhub.store.mengyastorebackendjava.service.OrderService; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RestController +@RequestMapping("/api/admin/orders") +@RequiredArgsConstructor +public class AdminOrderController { + + private final OrderService orderService; + + @GetMapping + public ResponseEntity>> listOrders() { + return ResponseEntity.ok(ApiResponse.ok(orderService.listAllOrders())); + } + + @DeleteMapping("/{id}") + public ResponseEntity> deleteOrder(@PathVariable String id) { + orderService.deleteOrder(id); + return ResponseEntity.ok(ApiResponse.ok(null)); + } +} diff --git a/mengyastore-backend-java/src/main/java/com/smyhub/store/mengyastorebackendjava/controller/admin/AdminProductController.java b/mengyastore-backend-java/src/main/java/com/smyhub/store/mengyastorebackendjava/controller/admin/AdminProductController.java new file mode 100644 index 0000000..f8c9ceb --- /dev/null +++ b/mengyastore-backend-java/src/main/java/com/smyhub/store/mengyastorebackendjava/controller/admin/AdminProductController.java @@ -0,0 +1,52 @@ +package com.smyhub.store.mengyastorebackendjava.controller.admin; + +import com.smyhub.store.mengyastorebackendjava.dto.request.AdminProductRequest; +import com.smyhub.store.mengyastorebackendjava.dto.request.ProductStatusRequest; +import com.smyhub.store.mengyastorebackendjava.dto.response.ApiResponse; +import com.smyhub.store.mengyastorebackendjava.dto.response.ProductResponse; +import com.smyhub.store.mengyastorebackendjava.service.ProductService; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RestController +@RequestMapping("/api/admin/products") +@RequiredArgsConstructor +public class AdminProductController { + + private final ProductService productService; + + @GetMapping + public ResponseEntity>> listProducts() { + return ResponseEntity.ok(ApiResponse.ok(productService.listAllProducts())); + } + + @PostMapping + public ResponseEntity> createProduct( + @Valid @RequestBody AdminProductRequest req) { + return ResponseEntity.ok(ApiResponse.ok(productService.createProduct(req))); + } + + @PutMapping("/{id}") + public ResponseEntity> updateProduct( + @PathVariable String id, + @Valid @RequestBody AdminProductRequest req) { + return ResponseEntity.ok(ApiResponse.ok(productService.updateProduct(id, req))); + } + + @PatchMapping("/{id}/status") + public ResponseEntity> toggleStatus( + @PathVariable String id, + @RequestBody ProductStatusRequest req) { + return ResponseEntity.ok(ApiResponse.ok(productService.toggleStatus(id, req.isActive()))); + } + + @DeleteMapping("/{id}") + public ResponseEntity> deleteProduct(@PathVariable String id) { + productService.deleteProduct(id); + return ResponseEntity.ok(ApiResponse.ok(null)); + } +} diff --git a/mengyastore-backend-java/src/main/java/com/smyhub/store/mengyastorebackendjava/controller/admin/AdminSiteController.java b/mengyastore-backend-java/src/main/java/com/smyhub/store/mengyastorebackendjava/controller/admin/AdminSiteController.java new file mode 100644 index 0000000..aa194b9 --- /dev/null +++ b/mengyastore-backend-java/src/main/java/com/smyhub/store/mengyastorebackendjava/controller/admin/AdminSiteController.java @@ -0,0 +1,38 @@ +package com.smyhub.store.mengyastorebackendjava.controller.admin; + +import com.smyhub.store.mengyastorebackendjava.dto.request.MaintenanceRequest; +import com.smyhub.store.mengyastorebackendjava.dto.request.SmtpConfigRequest; +import com.smyhub.store.mengyastorebackendjava.dto.response.ApiResponse; +import com.smyhub.store.mengyastorebackendjava.dto.response.MaintenanceResponse; +import com.smyhub.store.mengyastorebackendjava.dto.response.SmtpConfigResponse; +import com.smyhub.store.mengyastorebackendjava.service.SiteService; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/api/admin/site") +@RequiredArgsConstructor +public class AdminSiteController { + + private final SiteService siteService; + + @PostMapping("/maintenance") + public ResponseEntity> setMaintenance( + @RequestBody MaintenanceRequest req) { + siteService.setMaintenance(req.isEnabled(), req.getReason()); + return ResponseEntity.ok(ApiResponse.ok(siteService.getMaintenance())); + } + + @GetMapping("/smtp") + public ResponseEntity> getSmtp() { + return ResponseEntity.ok(ApiResponse.ok(siteService.getSmtpConfigResponse(true))); + } + + @PostMapping("/smtp") + public ResponseEntity> setSmtp( + @RequestBody SmtpConfigRequest req) { + siteService.setSmtpConfig(req); + return ResponseEntity.ok(ApiResponse.ok(siteService.getSmtpConfigResponse(true))); + } +} diff --git a/mengyastore-backend-java/src/main/java/com/smyhub/store/mengyastorebackendjava/controller/admin/AdminStatusController.java b/mengyastore-backend-java/src/main/java/com/smyhub/store/mengyastorebackendjava/controller/admin/AdminStatusController.java new file mode 100644 index 0000000..8c12b34 --- /dev/null +++ b/mengyastore-backend-java/src/main/java/com/smyhub/store/mengyastorebackendjava/controller/admin/AdminStatusController.java @@ -0,0 +1,25 @@ +package com.smyhub.store.mengyastorebackendjava.controller.admin; + +import com.smyhub.store.mengyastorebackendjava.dto.response.ApiResponse; +import com.smyhub.store.mengyastorebackendjava.service.SystemStatusService; +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.Map; + +@RestController +@RequestMapping("/api/admin") +@RequiredArgsConstructor +public class AdminStatusController { + + private final SystemStatusService systemStatusService; + + @GetMapping("/system-status") + public ResponseEntity>> getSystemStatus(HttpServletRequest request) { + return ResponseEntity.ok(ApiResponse.ok(systemStatusService.buildStatus(request))); + } +} diff --git a/mengyastore-backend-java/src/main/java/com/smyhub/store/mengyastorebackendjava/controller/admin/AdminVerifyController.java b/mengyastore-backend-java/src/main/java/com/smyhub/store/mengyastorebackendjava/controller/admin/AdminVerifyController.java new file mode 100644 index 0000000..0f3eb69 --- /dev/null +++ b/mengyastore-backend-java/src/main/java/com/smyhub/store/mengyastorebackendjava/controller/admin/AdminVerifyController.java @@ -0,0 +1,30 @@ +package com.smyhub.store.mengyastorebackendjava.controller.admin; + +import com.smyhub.store.mengyastorebackendjava.config.AppProperties; +import com.smyhub.store.mengyastorebackendjava.dto.request.AdminVerifyRequest; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.util.StringUtils; +import org.springframework.web.bind.annotation.*; + +import java.util.Map; + +@RestController +@RequestMapping("/api/admin") +@RequiredArgsConstructor +public class AdminVerifyController { + + private final AppProperties appProperties; + + /** + * Matches Go backend: top-level {@code {"valid": true/false}} (frontend reads {@code response.data.valid} from axios). + */ + @PostMapping("/verify") + public ResponseEntity> verify(@RequestBody(required = false) AdminVerifyRequest req) { + if (req == null || !StringUtils.hasText(req.getToken())) { + return ResponseEntity.ok(Map.of("valid", false)); + } + boolean valid = req.getToken().equals(appProperties.getAdminToken()); + return ResponseEntity.ok(Map.of("valid", valid)); + } +} diff --git a/mengyastore-backend-java/src/main/java/com/smyhub/store/mengyastorebackendjava/dto/request/AdminChatMessageRequest.java b/mengyastore-backend-java/src/main/java/com/smyhub/store/mengyastorebackendjava/dto/request/AdminChatMessageRequest.java new file mode 100644 index 0000000..61fbc0c --- /dev/null +++ b/mengyastore-backend-java/src/main/java/com/smyhub/store/mengyastorebackendjava/dto/request/AdminChatMessageRequest.java @@ -0,0 +1,11 @@ +package com.smyhub.store.mengyastorebackendjava.dto.request; + +import jakarta.validation.constraints.NotBlank; +import lombok.Data; + +@Data +public class AdminChatMessageRequest { + + @NotBlank(message = "消息内容不能为空") + private String content; +} diff --git a/mengyastore-backend-java/src/main/java/com/smyhub/store/mengyastorebackendjava/dto/request/AdminProductRequest.java b/mengyastore-backend-java/src/main/java/com/smyhub/store/mengyastorebackendjava/dto/request/AdminProductRequest.java new file mode 100644 index 0000000..a802d04 --- /dev/null +++ b/mengyastore-backend-java/src/main/java/com/smyhub/store/mengyastorebackendjava/dto/request/AdminProductRequest.java @@ -0,0 +1,46 @@ +package com.smyhub.store.mengyastorebackendjava.dto.request; + +import jakarta.validation.constraints.NotBlank; +import lombok.Data; + +import java.util.ArrayList; +import java.util.List; + +@Data +public class AdminProductRequest { + + @NotBlank(message = "商品名称不能为空") + private String name; + + private double price; + + private double discountPrice; + + private List tags = new ArrayList<>(); + + private String coverUrl; + + private List screenshotUrls = new ArrayList<>(); + + private String verificationUrl; + + private String description; + + private boolean active = true; + + private boolean requireLogin; + + private int maxPerAccount; + + private String deliveryMode = "auto"; + + private String fulfillmentType = "card"; + + private String fixedContent; + + private boolean showNote = true; + + private boolean showContact = true; + + private List codes = new ArrayList<>(); +} diff --git a/mengyastore-backend-java/src/main/java/com/smyhub/store/mengyastorebackendjava/dto/request/AdminVerifyRequest.java b/mengyastore-backend-java/src/main/java/com/smyhub/store/mengyastorebackendjava/dto/request/AdminVerifyRequest.java new file mode 100644 index 0000000..93bfaf2 --- /dev/null +++ b/mengyastore-backend-java/src/main/java/com/smyhub/store/mengyastorebackendjava/dto/request/AdminVerifyRequest.java @@ -0,0 +1,8 @@ +package com.smyhub.store.mengyastorebackendjava.dto.request; + +import lombok.Data; + +@Data +public class AdminVerifyRequest { + private String token; +} diff --git a/mengyastore-backend-java/src/main/java/com/smyhub/store/mengyastorebackendjava/dto/request/ChatMessageRequest.java b/mengyastore-backend-java/src/main/java/com/smyhub/store/mengyastorebackendjava/dto/request/ChatMessageRequest.java new file mode 100644 index 0000000..f175ab0 --- /dev/null +++ b/mengyastore-backend-java/src/main/java/com/smyhub/store/mengyastorebackendjava/dto/request/ChatMessageRequest.java @@ -0,0 +1,11 @@ +package com.smyhub.store.mengyastorebackendjava.dto.request; + +import jakarta.validation.constraints.NotBlank; +import lombok.Data; + +@Data +public class ChatMessageRequest { + + @NotBlank(message = "消息内容不能为空") + private String content; +} diff --git a/mengyastore-backend-java/src/main/java/com/smyhub/store/mengyastorebackendjava/dto/request/CheckoutRequest.java b/mengyastore-backend-java/src/main/java/com/smyhub/store/mengyastorebackendjava/dto/request/CheckoutRequest.java new file mode 100644 index 0000000..b2dc47d --- /dev/null +++ b/mengyastore-backend-java/src/main/java/com/smyhub/store/mengyastorebackendjava/dto/request/CheckoutRequest.java @@ -0,0 +1,21 @@ +package com.smyhub.store.mengyastorebackendjava.dto.request; + +import jakarta.validation.constraints.NotBlank; +import lombok.Data; + +@Data +public class CheckoutRequest { + + @NotBlank(message = "productId 不能为空") + private String productId; + + private int quantity = 1; + + private String note; + + private String contactPhone; + + private String contactEmail; + + private String notifyEmail; +} diff --git a/mengyastore-backend-java/src/main/java/com/smyhub/store/mengyastorebackendjava/dto/request/MaintenanceRequest.java b/mengyastore-backend-java/src/main/java/com/smyhub/store/mengyastorebackendjava/dto/request/MaintenanceRequest.java new file mode 100644 index 0000000..7a79769 --- /dev/null +++ b/mengyastore-backend-java/src/main/java/com/smyhub/store/mengyastorebackendjava/dto/request/MaintenanceRequest.java @@ -0,0 +1,9 @@ +package com.smyhub.store.mengyastorebackendjava.dto.request; + +import lombok.Data; + +@Data +public class MaintenanceRequest { + private boolean enabled; + private String reason = ""; +} diff --git a/mengyastore-backend-java/src/main/java/com/smyhub/store/mengyastorebackendjava/dto/request/ProductStatusRequest.java b/mengyastore-backend-java/src/main/java/com/smyhub/store/mengyastorebackendjava/dto/request/ProductStatusRequest.java new file mode 100644 index 0000000..d2867cf --- /dev/null +++ b/mengyastore-backend-java/src/main/java/com/smyhub/store/mengyastorebackendjava/dto/request/ProductStatusRequest.java @@ -0,0 +1,8 @@ +package com.smyhub.store.mengyastorebackendjava.dto.request; + +import lombok.Data; + +@Data +public class ProductStatusRequest { + private boolean active; +} diff --git a/mengyastore-backend-java/src/main/java/com/smyhub/store/mengyastorebackendjava/dto/request/SmtpConfigRequest.java b/mengyastore-backend-java/src/main/java/com/smyhub/store/mengyastorebackendjava/dto/request/SmtpConfigRequest.java new file mode 100644 index 0000000..74fc31f --- /dev/null +++ b/mengyastore-backend-java/src/main/java/com/smyhub/store/mengyastorebackendjava/dto/request/SmtpConfigRequest.java @@ -0,0 +1,13 @@ +package com.smyhub.store.mengyastorebackendjava.dto.request; + +import lombok.Data; + +@Data +public class SmtpConfigRequest { + private boolean enabled = true; + private String email; + private String password; + private String fromName; + private String host = "smtp.qq.com"; + private String port = "465"; +} diff --git a/mengyastore-backend-java/src/main/java/com/smyhub/store/mengyastorebackendjava/dto/response/ApiResponse.java b/mengyastore-backend-java/src/main/java/com/smyhub/store/mengyastorebackendjava/dto/response/ApiResponse.java new file mode 100644 index 0000000..8dc3ff2 --- /dev/null +++ b/mengyastore-backend-java/src/main/java/com/smyhub/store/mengyastorebackendjava/dto/response/ApiResponse.java @@ -0,0 +1,25 @@ +package com.smyhub.store.mengyastorebackendjava.dto.response; + +import com.fasterxml.jackson.annotation.JsonInclude; +import lombok.Getter; + +@Getter +@JsonInclude(JsonInclude.Include.NON_NULL) +public class ApiResponse { + + private final T data; + private final String error; + + private ApiResponse(T data, String error) { + this.data = data; + this.error = error; + } + + public static ApiResponse ok(T data) { + return new ApiResponse<>(data, null); + } + + public static ApiResponse error(String message) { + return new ApiResponse<>(null, message); + } +} diff --git a/mengyastore-backend-java/src/main/java/com/smyhub/store/mengyastorebackendjava/dto/response/ChatMessageResponse.java b/mengyastore-backend-java/src/main/java/com/smyhub/store/mengyastorebackendjava/dto/response/ChatMessageResponse.java new file mode 100644 index 0000000..4357e51 --- /dev/null +++ b/mengyastore-backend-java/src/main/java/com/smyhub/store/mengyastorebackendjava/dto/response/ChatMessageResponse.java @@ -0,0 +1,18 @@ +package com.smyhub.store.mengyastorebackendjava.dto.response; + +import lombok.Builder; +import lombok.Data; + +import java.time.LocalDateTime; + +@Data +@Builder +public class ChatMessageResponse { + + private String id; + private String accountId; + private String accountName; + private String content; + private LocalDateTime sentAt; + private boolean fromAdmin; +} diff --git a/mengyastore-backend-java/src/main/java/com/smyhub/store/mengyastorebackendjava/dto/response/HealthResponse.java b/mengyastore-backend-java/src/main/java/com/smyhub/store/mengyastorebackendjava/dto/response/HealthResponse.java new file mode 100644 index 0000000..968bb44 --- /dev/null +++ b/mengyastore-backend-java/src/main/java/com/smyhub/store/mengyastorebackendjava/dto/response/HealthResponse.java @@ -0,0 +1,11 @@ +package com.smyhub.store.mengyastorebackendjava.dto.response; + +import lombok.Builder; +import lombok.Data; + +@Data +@Builder +public class HealthResponse { + private String status; + private String rabbitmq; +} diff --git a/mengyastore-backend-java/src/main/java/com/smyhub/store/mengyastorebackendjava/dto/response/MaintenanceResponse.java b/mengyastore-backend-java/src/main/java/com/smyhub/store/mengyastorebackendjava/dto/response/MaintenanceResponse.java new file mode 100644 index 0000000..81f7d79 --- /dev/null +++ b/mengyastore-backend-java/src/main/java/com/smyhub/store/mengyastorebackendjava/dto/response/MaintenanceResponse.java @@ -0,0 +1,11 @@ +package com.smyhub.store.mengyastorebackendjava.dto.response; + +import lombok.Builder; +import lombok.Data; + +@Data +@Builder +public class MaintenanceResponse { + private boolean maintenance; + private String reason; +} diff --git a/mengyastore-backend-java/src/main/java/com/smyhub/store/mengyastorebackendjava/dto/response/OrderResponse.java b/mengyastore-backend-java/src/main/java/com/smyhub/store/mengyastorebackendjava/dto/response/OrderResponse.java new file mode 100644 index 0000000..8c45825 --- /dev/null +++ b/mengyastore-backend-java/src/main/java/com/smyhub/store/mengyastorebackendjava/dto/response/OrderResponse.java @@ -0,0 +1,27 @@ +package com.smyhub.store.mengyastorebackendjava.dto.response; + +import lombok.Builder; +import lombok.Data; + +import java.time.LocalDateTime; +import java.util.List; + +@Data +@Builder +public class OrderResponse { + + private String id; + private String productId; + private String productName; + private String userAccount; + private String userName; + private int quantity; + private List deliveredCodes; + private String status; + private String deliveryMode; + private String note; + private String contactPhone; + private String contactEmail; + private String notifyEmail; + private LocalDateTime createdAt; +} diff --git a/mengyastore-backend-java/src/main/java/com/smyhub/store/mengyastorebackendjava/dto/response/ProductResponse.java b/mengyastore-backend-java/src/main/java/com/smyhub/store/mengyastorebackendjava/dto/response/ProductResponse.java new file mode 100644 index 0000000..0081ee0 --- /dev/null +++ b/mengyastore-backend-java/src/main/java/com/smyhub/store/mengyastorebackendjava/dto/response/ProductResponse.java @@ -0,0 +1,37 @@ +package com.smyhub.store.mengyastorebackendjava.dto.response; + +import com.fasterxml.jackson.annotation.JsonInclude; +import lombok.Builder; +import lombok.Data; + +import java.time.LocalDateTime; +import java.util.List; + +@Data +@Builder +@JsonInclude(JsonInclude.Include.NON_NULL) +public class ProductResponse { + + private String id; + private String name; + private double price; + private double discountPrice; + private List tags; + private int quantity; + private String coverUrl; + private List screenshotUrls; + private String verificationUrl; + private List codes; + private int viewCount; + private String description; + private boolean active; + private boolean requireLogin; + private int maxPerAccount; + private int totalSold; + private String deliveryMode; + private String fulfillmentType; + private String fixedContent; + private boolean showNote; + private boolean showContact; + private LocalDateTime createdAt; +} diff --git a/mengyastore-backend-java/src/main/java/com/smyhub/store/mengyastorebackendjava/dto/response/SmtpConfigResponse.java b/mengyastore-backend-java/src/main/java/com/smyhub/store/mengyastorebackendjava/dto/response/SmtpConfigResponse.java new file mode 100644 index 0000000..a3e2ed7 --- /dev/null +++ b/mengyastore-backend-java/src/main/java/com/smyhub/store/mengyastorebackendjava/dto/response/SmtpConfigResponse.java @@ -0,0 +1,15 @@ +package com.smyhub.store.mengyastorebackendjava.dto.response; + +import lombok.Builder; +import lombok.Data; + +@Data +@Builder +public class SmtpConfigResponse { + private boolean enabled; + private String email; + private String password; + private String fromName; + private String host; + private String port; +} diff --git a/mengyastore-backend-java/src/main/java/com/smyhub/store/mengyastorebackendjava/dto/response/StatsResponse.java b/mengyastore-backend-java/src/main/java/com/smyhub/store/mengyastorebackendjava/dto/response/StatsResponse.java new file mode 100644 index 0000000..61c4d4d --- /dev/null +++ b/mengyastore-backend-java/src/main/java/com/smyhub/store/mengyastorebackendjava/dto/response/StatsResponse.java @@ -0,0 +1,11 @@ +package com.smyhub.store.mengyastorebackendjava.dto.response; + +import lombok.Builder; +import lombok.Data; + +@Data +@Builder +public class StatsResponse { + private long totalOrders; + private int totalVisits; +} diff --git a/mengyastore-backend-java/src/main/java/com/smyhub/store/mengyastorebackendjava/dto/response/WishlistResponse.java b/mengyastore-backend-java/src/main/java/com/smyhub/store/mengyastorebackendjava/dto/response/WishlistResponse.java new file mode 100644 index 0000000..6f7dd60 --- /dev/null +++ b/mengyastore-backend-java/src/main/java/com/smyhub/store/mengyastorebackendjava/dto/response/WishlistResponse.java @@ -0,0 +1,12 @@ +package com.smyhub.store.mengyastorebackendjava.dto.response; + +import lombok.Builder; +import lombok.Data; + +@Data +@Builder +public class WishlistResponse { + private Long id; + private String accountId; + private String productId; +} diff --git a/mengyastore-backend-java/src/main/java/com/smyhub/store/mengyastorebackendjava/entity/ChatMessage.java b/mengyastore-backend-java/src/main/java/com/smyhub/store/mengyastorebackendjava/entity/ChatMessage.java new file mode 100644 index 0000000..9c838a9 --- /dev/null +++ b/mengyastore-backend-java/src/main/java/com/smyhub/store/mengyastorebackendjava/entity/ChatMessage.java @@ -0,0 +1,33 @@ +package com.smyhub.store.mengyastorebackendjava.entity; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.Setter; + +import java.time.LocalDateTime; + +@Entity +@Table(name = "chat_messages") +@Getter +@Setter +public class ChatMessage { + + @Id + @Column(length = 36) + private String id; + + @Column(name = "account_id", length = 255, nullable = false) + private String accountId; + + @Column(name = "account_name", length = 255) + private String accountName; + + @Column(columnDefinition = "text", nullable = false) + private String content; + + @Column(name = "sent_at", nullable = false) + private LocalDateTime sentAt; + + @Column(name = "from_admin", columnDefinition = "tinyint(1) default 0") + private boolean fromAdmin; +} diff --git a/mengyastore-backend-java/src/main/java/com/smyhub/store/mengyastorebackendjava/entity/Order.java b/mengyastore-backend-java/src/main/java/com/smyhub/store/mengyastorebackendjava/entity/Order.java new file mode 100644 index 0000000..37f5aae --- /dev/null +++ b/mengyastore-backend-java/src/main/java/com/smyhub/store/mengyastorebackendjava/entity/Order.java @@ -0,0 +1,61 @@ +package com.smyhub.store.mengyastorebackendjava.entity; + +import com.smyhub.store.mengyastorebackendjava.entity.converter.StringListConverter; +import jakarta.persistence.*; +import lombok.Getter; +import lombok.Setter; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; + +@Entity +@Table(name = "orders") +@Getter +@Setter +public class Order { + + @Id + @Column(length = 36) + private String id; + + @Column(name = "product_id", length = 36, nullable = false) + private String productId; + + @Column(name = "product_name", length = 255, nullable = false) + private String productName; + + @Column(name = "user_account", length = 255) + private String userAccount; + + @Column(name = "user_name", length = 255) + private String userName; + + @Column(nullable = false, columnDefinition = "int default 1") + private int quantity; + + @Convert(converter = StringListConverter.class) + @Column(name = "delivered_codes", columnDefinition = "json") + private List deliveredCodes = new ArrayList<>(); + + @Column(length = 20, nullable = false, columnDefinition = "varchar(20) default 'pending'") + private String status = "pending"; + + @Column(name = "delivery_mode", length = 20, columnDefinition = "varchar(20) default 'auto'") + private String deliveryMode = "auto"; + + @Column(columnDefinition = "text") + private String note; + + @Column(name = "contact_phone", length = 50) + private String contactPhone; + + @Column(name = "contact_email", length = 255) + private String contactEmail; + + @Column(name = "notify_email", length = 255) + private String notifyEmail; + + @Column(name = "created_at") + private LocalDateTime createdAt; +} diff --git a/mengyastore-backend-java/src/main/java/com/smyhub/store/mengyastorebackendjava/entity/Product.java b/mengyastore-backend-java/src/main/java/com/smyhub/store/mengyastorebackendjava/entity/Product.java new file mode 100644 index 0000000..b53834b --- /dev/null +++ b/mengyastore-backend-java/src/main/java/com/smyhub/store/mengyastorebackendjava/entity/Product.java @@ -0,0 +1,80 @@ +package com.smyhub.store.mengyastorebackendjava.entity; + +import com.smyhub.store.mengyastorebackendjava.entity.converter.StringListConverter; +import jakarta.persistence.*; +import lombok.Getter; +import lombok.Setter; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; + +@Entity +@Table(name = "products") +@Getter +@Setter +public class Product { + + @Id + @Column(length = 36) + private String id; + + @Column(length = 255, nullable = false) + private String name; + + @Column(nullable = false, columnDefinition = "double default 0") + private double price; + + @Column(columnDefinition = "double default 0") + private double discountPrice; + + @Convert(converter = StringListConverter.class) + @Column(columnDefinition = "json") + private List tags = new ArrayList<>(); + + @Column(length = 500) + private String coverUrl; + + @Convert(converter = StringListConverter.class) + @Column(columnDefinition = "json") + private List screenshotUrls = new ArrayList<>(); + + @Column(length = 500, columnDefinition = "varchar(500) default ''") + private String verificationUrl; + + @Column(columnDefinition = "text") + private String description; + + @Column(columnDefinition = "tinyint(1) default 1") + private boolean active = true; + + @Column(columnDefinition = "tinyint(1) default 0") + private boolean requireLogin; + + @Column(columnDefinition = "int default 0") + private int maxPerAccount; + + @Column(columnDefinition = "int default 0") + private int totalSold; + + @Column(columnDefinition = "int default 0") + private int viewCount; + + @Column(length = 20, columnDefinition = "varchar(20) default 'auto'") + private String deliveryMode = "auto"; + + @Column(length = 16, columnDefinition = "varchar(16) default 'card'") + private String fulfillmentType = "card"; + + @Column(columnDefinition = "text") + private String fixedContent; + + @Column(columnDefinition = "tinyint(1) default 1") + private boolean showNote = true; + + @Column(columnDefinition = "tinyint(1) default 1") + private boolean showContact = true; + + @Column + private LocalDateTime createdAt; +} diff --git a/mengyastore-backend-java/src/main/java/com/smyhub/store/mengyastorebackendjava/entity/ProductCode.java b/mengyastore-backend-java/src/main/java/com/smyhub/store/mengyastorebackendjava/entity/ProductCode.java new file mode 100644 index 0000000..52373df --- /dev/null +++ b/mengyastore-backend-java/src/main/java/com/smyhub/store/mengyastorebackendjava/entity/ProductCode.java @@ -0,0 +1,29 @@ +package com.smyhub.store.mengyastorebackendjava.entity; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Entity +@Table(name = "product_codes") +@Getter +@Setter +@NoArgsConstructor +public class ProductCode { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "product_id", length = 36, nullable = false) + private String productId; + + @Column(columnDefinition = "text", nullable = false) + private String code; + + public ProductCode(String productId, String code) { + this.productId = productId; + this.code = code; + } +} diff --git a/mengyastore-backend-java/src/main/java/com/smyhub/store/mengyastorebackendjava/entity/SiteSetting.java b/mengyastore-backend-java/src/main/java/com/smyhub/store/mengyastorebackendjava/entity/SiteSetting.java new file mode 100644 index 0000000..9862ec3 --- /dev/null +++ b/mengyastore-backend-java/src/main/java/com/smyhub/store/mengyastorebackendjava/entity/SiteSetting.java @@ -0,0 +1,26 @@ +package com.smyhub.store.mengyastorebackendjava.entity; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Entity +@Table(name = "site_settings") +@Getter +@Setter +@NoArgsConstructor +public class SiteSetting { + + @Id + @Column(name = "`key`", length = 64) + private String key; + + @Column(columnDefinition = "text") + private String value; + + public SiteSetting(String key, String value) { + this.key = key; + this.value = value; + } +} diff --git a/mengyastore-backend-java/src/main/java/com/smyhub/store/mengyastorebackendjava/entity/Wishlist.java b/mengyastore-backend-java/src/main/java/com/smyhub/store/mengyastorebackendjava/entity/Wishlist.java new file mode 100644 index 0000000..241730f --- /dev/null +++ b/mengyastore-backend-java/src/main/java/com/smyhub/store/mengyastorebackendjava/entity/Wishlist.java @@ -0,0 +1,30 @@ +package com.smyhub.store.mengyastorebackendjava.entity; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Entity +@Table(name = "wishlists", + uniqueConstraints = @UniqueConstraint(name = "idx_wishlist", columnNames = {"account_id", "product_id"})) +@Getter +@Setter +@NoArgsConstructor +public class Wishlist { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "account_id", length = 255, nullable = false) + private String accountId; + + @Column(name = "product_id", length = 36, nullable = false) + private String productId; + + public Wishlist(String accountId, String productId) { + this.accountId = accountId; + this.productId = productId; + } +} diff --git a/mengyastore-backend-java/src/main/java/com/smyhub/store/mengyastorebackendjava/entity/converter/StringListConverter.java b/mengyastore-backend-java/src/main/java/com/smyhub/store/mengyastorebackendjava/entity/converter/StringListConverter.java new file mode 100644 index 0000000..4e41cb5 --- /dev/null +++ b/mengyastore-backend-java/src/main/java/com/smyhub/store/mengyastorebackendjava/entity/converter/StringListConverter.java @@ -0,0 +1,39 @@ +package com.smyhub.store.mengyastorebackendjava.entity.converter; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.persistence.AttributeConverter; +import jakarta.persistence.Converter; + +import java.util.ArrayList; +import java.util.List; + +@Converter +public class StringListConverter implements AttributeConverter, String> { + + private static final ObjectMapper MAPPER = new ObjectMapper(); + + @Override + public String convertToDatabaseColumn(List attribute) { + if (attribute == null) { + return "[]"; + } + try { + return MAPPER.writeValueAsString(attribute); + } catch (Exception e) { + return "[]"; + } + } + + @Override + public List convertToEntityAttribute(String dbData) { + if (dbData == null || dbData.isBlank()) { + return new ArrayList<>(); + } + try { + return MAPPER.readValue(dbData, new TypeReference<>() {}); + } catch (Exception e) { + return new ArrayList<>(); + } + } +} diff --git a/mengyastore-backend-java/src/main/java/com/smyhub/store/mengyastorebackendjava/exception/BusinessException.java b/mengyastore-backend-java/src/main/java/com/smyhub/store/mengyastorebackendjava/exception/BusinessException.java new file mode 100644 index 0000000..0820ba6 --- /dev/null +++ b/mengyastore-backend-java/src/main/java/com/smyhub/store/mengyastorebackendjava/exception/BusinessException.java @@ -0,0 +1,21 @@ +package com.smyhub.store.mengyastorebackendjava.exception; + +import org.springframework.http.HttpStatus; + +public class BusinessException extends RuntimeException { + + private final HttpStatus status; + + public BusinessException(String message) { + this(message, HttpStatus.BAD_REQUEST); + } + + public BusinessException(String message, HttpStatus status) { + super(message); + this.status = status; + } + + public HttpStatus getStatus() { + return status; + } +} diff --git a/mengyastore-backend-java/src/main/java/com/smyhub/store/mengyastorebackendjava/exception/GlobalExceptionHandler.java b/mengyastore-backend-java/src/main/java/com/smyhub/store/mengyastorebackendjava/exception/GlobalExceptionHandler.java new file mode 100644 index 0000000..8909453 --- /dev/null +++ b/mengyastore-backend-java/src/main/java/com/smyhub/store/mengyastorebackendjava/exception/GlobalExceptionHandler.java @@ -0,0 +1,35 @@ +package com.smyhub.store.mengyastorebackendjava.exception; + +import com.smyhub.store.mengyastorebackendjava.dto.response.ApiResponse; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +@Slf4j +@RestControllerAdvice +public class GlobalExceptionHandler { + + @ExceptionHandler(BusinessException.class) + public ResponseEntity> handleBusiness(BusinessException ex) { + return ResponseEntity.status(ex.getStatus()).body(ApiResponse.error(ex.getMessage())); + } + + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity> handleValidation(MethodArgumentNotValidException ex) { + String message = ex.getBindingResult().getAllErrors().stream() + .findFirst() + .map(e -> e.getDefaultMessage()) + .orElse("请求参数有误"); + return ResponseEntity.badRequest().body(ApiResponse.error(message)); + } + + @ExceptionHandler(Exception.class) + public ResponseEntity> handleGeneric(Exception ex) { + log.error("Unhandled exception", ex); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(ApiResponse.error("服务器内部错误")); + } +} diff --git a/mengyastore-backend-java/src/main/java/com/smyhub/store/mengyastorebackendjava/mq/OrderEmailConsumer.java b/mengyastore-backend-java/src/main/java/com/smyhub/store/mengyastorebackendjava/mq/OrderEmailConsumer.java new file mode 100644 index 0000000..5ac3096 --- /dev/null +++ b/mengyastore-backend-java/src/main/java/com/smyhub/store/mengyastorebackendjava/mq/OrderEmailConsumer.java @@ -0,0 +1,64 @@ +package com.smyhub.store.mengyastorebackendjava.mq; + +import com.rabbitmq.client.Channel; +import com.smyhub.store.mengyastorebackendjava.service.EmailService; +import com.smyhub.store.mengyastorebackendjava.service.SiteService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.amqp.core.Message; +import org.springframework.amqp.rabbit.annotation.RabbitListener; +import org.springframework.amqp.rabbit.listener.api.ChannelAwareMessageListener; +import org.springframework.amqp.support.AmqpHeaders; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.messaging.handler.annotation.Header; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +@RequiredArgsConstructor +@ConditionalOnProperty(name = "app.rabbitmq-enabled", havingValue = "true") +public class OrderEmailConsumer { + + private final SiteService siteService; + private final EmailService emailService; + + @RabbitListener(queues = "#{T(com.smyhub.store.mengyastorebackendjava.mq.RabbitMQTopology).queueName(@appProperties.rabbitmqEnv)}", ackMode = "MANUAL") + public void onMessage(OrderEmailPayload payload, Message message, Channel channel, + @Header(AmqpHeaders.DELIVERY_TAG) long deliveryTag) throws Exception { + if (payload == null || payload.getToEmail() == null || payload.getToEmail().isBlank()) { + channel.basicAck(deliveryTag, false); + return; + } + + SiteService.SmtpConfig cfg = siteService.getSmtpConfig(); + if (!cfg.isConfigured()) { + log.info("[MQ] skip email order={}: smtp not configured", payload.getOrderId()); + channel.basicAck(deliveryTag, false); + return; + } + + EmailService.OrderNotifyData data = new EmailService.OrderNotifyData(); + data.toEmail = payload.getToEmail(); + data.toName = payload.getToName(); + data.productName = payload.getProductName(); + data.orderId = payload.getOrderId(); + data.quantity = payload.getQuantity(); + data.codes = payload.getCodes(); + data.isManual = payload.isManual(); + + try { + emailService.sendOrderNotify(cfg, data); + channel.basicAck(deliveryTag, false); + log.info("[MQ] email ok order={} to={}", payload.getOrderId(), payload.getToEmail()); + } catch (Exception e) { + log.warn("[MQ] send email fail order={}: {}", payload.getOrderId(), e.getMessage()); + boolean redelivered = message.getMessageProperties().isRedelivered(); + if (redelivered) { + log.warn("[MQ] drop order={} after failed redelivery", payload.getOrderId()); + channel.basicAck(deliveryTag, false); + } else { + channel.basicNack(deliveryTag, false, true); + } + } + } +} diff --git a/mengyastore-backend-java/src/main/java/com/smyhub/store/mengyastorebackendjava/mq/OrderEmailPayload.java b/mengyastore-backend-java/src/main/java/com/smyhub/store/mengyastorebackendjava/mq/OrderEmailPayload.java new file mode 100644 index 0000000..1fc88fc --- /dev/null +++ b/mengyastore-backend-java/src/main/java/com/smyhub/store/mengyastorebackendjava/mq/OrderEmailPayload.java @@ -0,0 +1,21 @@ +package com.smyhub.store.mengyastorebackendjava.mq; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class OrderEmailPayload { + + private String toEmail; + private String toName; + private String productName; + private String orderId; + private int quantity; + private List codes; + private boolean isManual; +} diff --git a/mengyastore-backend-java/src/main/java/com/smyhub/store/mengyastorebackendjava/mq/OrderEmailProducer.java b/mengyastore-backend-java/src/main/java/com/smyhub/store/mengyastorebackendjava/mq/OrderEmailProducer.java new file mode 100644 index 0000000..dd0689c --- /dev/null +++ b/mengyastore-backend-java/src/main/java/com/smyhub/store/mengyastorebackendjava/mq/OrderEmailProducer.java @@ -0,0 +1,36 @@ +package com.smyhub.store.mengyastorebackendjava.mq; + +import com.smyhub.store.mengyastorebackendjava.config.AppProperties; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.amqp.AmqpException; +import org.springframework.amqp.rabbit.core.RabbitTemplate; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +@RequiredArgsConstructor +public class OrderEmailProducer { + + private final RabbitTemplate rabbitTemplate; + private final AppProperties appProperties; + + /** + * Publishes an order email payload to RabbitMQ. + * Returns true on success, false on failure (caller should fall back to direct email). + */ + public boolean publish(OrderEmailPayload payload) { + if (!appProperties.isRabbitmqEnabled()) { + return false; + } + String exchange = RabbitMQTopology.exchangeName(appProperties.getRabbitmqEnv()); + try { + rabbitTemplate.convertAndSend(exchange, RabbitMQTopology.ORDER_EMAIL_ROUTING_KEY, payload); + log.info("[MQ] queued order email order={} to={}", payload.getOrderId(), payload.getToEmail()); + return true; + } catch (AmqpException e) { + log.warn("[MQ] publish failed, will fallback to direct email: {}", e.getMessage()); + return false; + } + } +} diff --git a/mengyastore-backend-java/src/main/java/com/smyhub/store/mengyastorebackendjava/mq/RabbitMQTopology.java b/mengyastore-backend-java/src/main/java/com/smyhub/store/mengyastorebackendjava/mq/RabbitMQTopology.java new file mode 100644 index 0000000..7db7820 --- /dev/null +++ b/mengyastore-backend-java/src/main/java/com/smyhub/store/mengyastorebackendjava/mq/RabbitMQTopology.java @@ -0,0 +1,16 @@ +package com.smyhub.store.mengyastorebackendjava.mq; + +public final class RabbitMQTopology { + + private RabbitMQTopology() {} + + public static final String ORDER_EMAIL_ROUTING_KEY = "order.email.notify"; + + public static String exchangeName(String env) { + return "ex.mengyastore." + env + ".events"; + } + + public static String queueName(String env) { + return "q.mengyastore." + env + ".order_email"; + } +} diff --git a/mengyastore-backend-java/src/main/java/com/smyhub/store/mengyastorebackendjava/repository/ChatMessageRepository.java b/mengyastore-backend-java/src/main/java/com/smyhub/store/mengyastorebackendjava/repository/ChatMessageRepository.java new file mode 100644 index 0000000..76c2bcf --- /dev/null +++ b/mengyastore-backend-java/src/main/java/com/smyhub/store/mengyastorebackendjava/repository/ChatMessageRepository.java @@ -0,0 +1,19 @@ +package com.smyhub.store.mengyastorebackendjava.repository; + +import com.smyhub.store.mengyastorebackendjava.entity.ChatMessage; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Repository +public interface ChatMessageRepository extends JpaRepository { + + List findByAccountIdOrderBySentAtAsc(String accountId); + + List findAllByOrderByAccountIdAscSentAtAsc(); + + @Transactional + void deleteByAccountId(String accountId); +} diff --git a/mengyastore-backend-java/src/main/java/com/smyhub/store/mengyastorebackendjava/repository/OrderRepository.java b/mengyastore-backend-java/src/main/java/com/smyhub/store/mengyastorebackendjava/repository/OrderRepository.java new file mode 100644 index 0000000..871c421 --- /dev/null +++ b/mengyastore-backend-java/src/main/java/com/smyhub/store/mengyastorebackendjava/repository/OrderRepository.java @@ -0,0 +1,20 @@ +package com.smyhub.store.mengyastorebackendjava.repository; + +import com.smyhub.store.mengyastorebackendjava.entity.Order; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +public interface OrderRepository extends JpaRepository { + + List findByUserAccountOrderByCreatedAtDesc(String userAccount); + + List findAllByOrderByCreatedAtDesc(); + + @Query("SELECT COALESCE(SUM(o.quantity), 0) FROM Order o WHERE o.userAccount = :account AND o.productId = :productId AND o.status <> 'cancelled'") + int sumQuantityByUserAccountAndProductId(@Param("account") String account, @Param("productId") String productId); +} diff --git a/mengyastore-backend-java/src/main/java/com/smyhub/store/mengyastorebackendjava/repository/ProductCodeRepository.java b/mengyastore-backend-java/src/main/java/com/smyhub/store/mengyastorebackendjava/repository/ProductCodeRepository.java new file mode 100644 index 0000000..44181b7 --- /dev/null +++ b/mengyastore-backend-java/src/main/java/com/smyhub/store/mengyastorebackendjava/repository/ProductCodeRepository.java @@ -0,0 +1,19 @@ +package com.smyhub.store.mengyastorebackendjava.repository; + +import com.smyhub.store.mengyastorebackendjava.entity.ProductCode; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Repository +public interface ProductCodeRepository extends JpaRepository { + + List findByProductId(String productId); + + long countByProductId(String productId); + + @Transactional + void deleteByProductId(String productId); +} diff --git a/mengyastore-backend-java/src/main/java/com/smyhub/store/mengyastorebackendjava/repository/ProductRepository.java b/mengyastore-backend-java/src/main/java/com/smyhub/store/mengyastorebackendjava/repository/ProductRepository.java new file mode 100644 index 0000000..79a3065 --- /dev/null +++ b/mengyastore-backend-java/src/main/java/com/smyhub/store/mengyastorebackendjava/repository/ProductRepository.java @@ -0,0 +1,29 @@ +package com.smyhub.store.mengyastorebackendjava.repository; + +import com.smyhub.store.mengyastorebackendjava.entity.Product; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Repository +public interface ProductRepository extends JpaRepository { + + List findByActiveTrueOrderByCreatedAtDesc(); + + List findAllByOrderByCreatedAtDesc(); + + @Transactional + @Modifying + @Query("UPDATE Product p SET p.totalSold = p.totalSold + :count WHERE p.id = :id") + void incrementTotalSold(@Param("id") String id, @Param("count") int count); + + @Transactional + @Modifying + @Query("UPDATE Product p SET p.viewCount = p.viewCount + 1 WHERE p.id = :id") + void incrementViewCount(@Param("id") String id); +} diff --git a/mengyastore-backend-java/src/main/java/com/smyhub/store/mengyastorebackendjava/repository/SiteSettingRepository.java b/mengyastore-backend-java/src/main/java/com/smyhub/store/mengyastorebackendjava/repository/SiteSettingRepository.java new file mode 100644 index 0000000..4412918 --- /dev/null +++ b/mengyastore-backend-java/src/main/java/com/smyhub/store/mengyastorebackendjava/repository/SiteSettingRepository.java @@ -0,0 +1,9 @@ +package com.smyhub.store.mengyastorebackendjava.repository; + +import com.smyhub.store.mengyastorebackendjava.entity.SiteSetting; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface SiteSettingRepository extends JpaRepository { +} diff --git a/mengyastore-backend-java/src/main/java/com/smyhub/store/mengyastorebackendjava/repository/WishlistRepository.java b/mengyastore-backend-java/src/main/java/com/smyhub/store/mengyastorebackendjava/repository/WishlistRepository.java new file mode 100644 index 0000000..0dce62b --- /dev/null +++ b/mengyastore-backend-java/src/main/java/com/smyhub/store/mengyastorebackendjava/repository/WishlistRepository.java @@ -0,0 +1,22 @@ +package com.smyhub.store.mengyastorebackendjava.repository; + +import com.smyhub.store.mengyastorebackendjava.entity.Wishlist; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.Optional; + +@Repository +public interface WishlistRepository extends JpaRepository { + + List findByAccountId(String accountId); + + Optional findByAccountIdAndProductId(String accountId, String productId); + + boolean existsByAccountIdAndProductId(String accountId, String productId); + + @Transactional + void deleteByAccountIdAndProductId(String accountId, String productId); +} diff --git a/mengyastore-backend-java/src/main/java/com/smyhub/store/mengyastorebackendjava/security/AdminTokenInterceptor.java b/mengyastore-backend-java/src/main/java/com/smyhub/store/mengyastorebackendjava/security/AdminTokenInterceptor.java new file mode 100644 index 0000000..4d3de58 --- /dev/null +++ b/mengyastore-backend-java/src/main/java/com/smyhub/store/mengyastorebackendjava/security/AdminTokenInterceptor.java @@ -0,0 +1,46 @@ +package com.smyhub.store.mengyastorebackendjava.security; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.smyhub.store.mengyastorebackendjava.config.AppProperties; +import com.smyhub.store.mengyastorebackendjava.dto.response.ApiResponse; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; +import org.springframework.web.servlet.HandlerInterceptor; + +@Component +@RequiredArgsConstructor +public class AdminTokenInterceptor implements HandlerInterceptor { + + private final AppProperties appProperties; + private final ObjectMapper objectMapper; + + @Override + public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { + String token = resolveToken(request); + if (StringUtils.hasText(token) && token.equals(appProperties.getAdminToken())) { + return true; + } + response.setStatus(HttpStatus.UNAUTHORIZED.value()); + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + response.setCharacterEncoding("UTF-8"); + objectMapper.writeValue(response.getWriter(), ApiResponse.error("unauthorized")); + return false; + } + + private String resolveToken(HttpServletRequest request) { + String token = request.getHeader("X-Admin-Token"); + if (StringUtils.hasText(token)) { + return token; + } + token = request.getHeader("Authorization"); + if (StringUtils.hasText(token)) { + return token; + } + return request.getParameter("token"); + } +} diff --git a/mengyastore-backend-java/src/main/java/com/smyhub/store/mengyastorebackendjava/security/BearerTokenInterceptor.java b/mengyastore-backend-java/src/main/java/com/smyhub/store/mengyastorebackendjava/security/BearerTokenInterceptor.java new file mode 100644 index 0000000..bd26c46 --- /dev/null +++ b/mengyastore-backend-java/src/main/java/com/smyhub/store/mengyastorebackendjava/security/BearerTokenInterceptor.java @@ -0,0 +1,49 @@ +package com.smyhub.store.mengyastorebackendjava.security; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.smyhub.store.mengyastorebackendjava.dto.response.ApiResponse; +import com.smyhub.store.mengyastorebackendjava.service.SproutGateAuthService; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; +import org.springframework.web.servlet.HandlerInterceptor; + +@Component +@RequiredArgsConstructor +public class BearerTokenInterceptor implements HandlerInterceptor { + + private final SproutGateAuthService authService; + private final ObjectMapper objectMapper; + + @Override + public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { + String header = request.getHeader("Authorization"); + if (!StringUtils.hasText(header) || !header.startsWith("Bearer ")) { + return sendUnauthorized(response, "请先登录"); + } + String token = header.substring(7); + SproutGateVerifyResult result = authService.verifyToken(token); + if (!result.isValid() || result.getUser() == null) { + return sendUnauthorized(response, "登录已过期,请重新登录"); + } + CurrentUserHolder.set(result.getUser()); + return true; + } + + @Override + public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) { + CurrentUserHolder.clear(); + } + + private boolean sendUnauthorized(HttpServletResponse response, String message) throws Exception { + response.setStatus(HttpStatus.UNAUTHORIZED.value()); + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + response.setCharacterEncoding("UTF-8"); + objectMapper.writeValue(response.getWriter(), ApiResponse.error(message)); + return false; + } +} diff --git a/mengyastore-backend-java/src/main/java/com/smyhub/store/mengyastorebackendjava/security/CurrentUserHolder.java b/mengyastore-backend-java/src/main/java/com/smyhub/store/mengyastorebackendjava/security/CurrentUserHolder.java new file mode 100644 index 0000000..84171cb --- /dev/null +++ b/mengyastore-backend-java/src/main/java/com/smyhub/store/mengyastorebackendjava/security/CurrentUserHolder.java @@ -0,0 +1,20 @@ +package com.smyhub.store.mengyastorebackendjava.security; + +public final class CurrentUserHolder { + + private static final ThreadLocal HOLDER = new ThreadLocal<>(); + + private CurrentUserHolder() {} + + public static void set(SproutGateUser user) { + HOLDER.set(user); + } + + public static SproutGateUser get() { + return HOLDER.get(); + } + + public static void clear() { + HOLDER.remove(); + } +} diff --git a/mengyastore-backend-java/src/main/java/com/smyhub/store/mengyastorebackendjava/security/SproutGateUser.java b/mengyastore-backend-java/src/main/java/com/smyhub/store/mengyastorebackendjava/security/SproutGateUser.java new file mode 100644 index 0000000..982b541 --- /dev/null +++ b/mengyastore-backend-java/src/main/java/com/smyhub/store/mengyastorebackendjava/security/SproutGateUser.java @@ -0,0 +1,13 @@ +package com.smyhub.store.mengyastorebackendjava.security; + +import lombok.Data; + +@Data +public class SproutGateUser { + + private String account; + + private String username; + + private String email; +} diff --git a/mengyastore-backend-java/src/main/java/com/smyhub/store/mengyastorebackendjava/security/SproutGateVerifyResult.java b/mengyastore-backend-java/src/main/java/com/smyhub/store/mengyastorebackendjava/security/SproutGateVerifyResult.java new file mode 100644 index 0000000..47efdc5 --- /dev/null +++ b/mengyastore-backend-java/src/main/java/com/smyhub/store/mengyastorebackendjava/security/SproutGateVerifyResult.java @@ -0,0 +1,11 @@ +package com.smyhub.store.mengyastorebackendjava.security; + +import lombok.Data; + +@Data +public class SproutGateVerifyResult { + + private boolean valid; + + private SproutGateUser user; +} diff --git a/mengyastore-backend-java/src/main/java/com/smyhub/store/mengyastorebackendjava/service/ChatService.java b/mengyastore-backend-java/src/main/java/com/smyhub/store/mengyastorebackendjava/service/ChatService.java new file mode 100644 index 0000000..a2022bd --- /dev/null +++ b/mengyastore-backend-java/src/main/java/com/smyhub/store/mengyastorebackendjava/service/ChatService.java @@ -0,0 +1,86 @@ +package com.smyhub.store.mengyastorebackendjava.service; + +import com.smyhub.store.mengyastorebackendjava.dto.response.ChatMessageResponse; +import com.smyhub.store.mengyastorebackendjava.entity.ChatMessage; +import com.smyhub.store.mengyastorebackendjava.exception.BusinessException; +import com.smyhub.store.mengyastorebackendjava.repository.ChatMessageRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; + +@Service +@RequiredArgsConstructor +public class ChatService { + + private final ChatMessageRepository chatMessageRepository; + private final Map lastSentMap = new ConcurrentHashMap<>(); + + public List getUserMessages(String accountId) { + return chatMessageRepository.findByAccountIdOrderBySentAtAsc(accountId) + .stream().map(this::toResponse).toList(); + } + + @Transactional + public ChatMessageResponse sendUserMessage(String accountId, String accountName, String content) { + LocalDateTime last = lastSentMap.get(accountId); + if (last != null && last.plusSeconds(1).isAfter(LocalDateTime.now())) { + throw new BusinessException("发送太频繁,请稍后再试", HttpStatus.TOO_MANY_REQUESTS); + } + lastSentMap.put(accountId, LocalDateTime.now()); + + ChatMessage msg = new ChatMessage(); + msg.setId(UUID.randomUUID().toString()); + msg.setAccountId(accountId); + msg.setAccountName(accountName); + msg.setContent(content); + msg.setSentAt(LocalDateTime.now()); + msg.setFromAdmin(false); + return toResponse(chatMessageRepository.save(msg)); + } + + public Map> listAllConversations() { + Map> result = new LinkedHashMap<>(); + chatMessageRepository.findAllByOrderByAccountIdAscSentAtAsc().forEach(row -> { + result.computeIfAbsent(row.getAccountId(), k -> new ArrayList<>()).add(toResponse(row)); + }); + return result; + } + + public List getConversation(String accountId) { + return chatMessageRepository.findByAccountIdOrderBySentAtAsc(accountId) + .stream().map(this::toResponse).toList(); + } + + @Transactional + public ChatMessageResponse sendAdminMessage(String accountId, String content) { + ChatMessage msg = new ChatMessage(); + msg.setId(UUID.randomUUID().toString()); + msg.setAccountId(accountId); + msg.setAccountName("管理员"); + msg.setContent(content); + msg.setSentAt(LocalDateTime.now()); + msg.setFromAdmin(true); + return toResponse(chatMessageRepository.save(msg)); + } + + @Transactional + public void clearConversation(String accountId) { + chatMessageRepository.deleteByAccountId(accountId); + } + + private ChatMessageResponse toResponse(ChatMessage m) { + return ChatMessageResponse.builder() + .id(m.getId()) + .accountId(m.getAccountId()) + .accountName(m.getAccountName()) + .content(m.getContent()) + .sentAt(m.getSentAt()) + .fromAdmin(m.isFromAdmin()) + .build(); + } +} diff --git a/mengyastore-backend-java/src/main/java/com/smyhub/store/mengyastorebackendjava/service/EmailService.java b/mengyastore-backend-java/src/main/java/com/smyhub/store/mengyastorebackendjava/service/EmailService.java new file mode 100644 index 0000000..882172d --- /dev/null +++ b/mengyastore-backend-java/src/main/java/com/smyhub/store/mengyastorebackendjava/service/EmailService.java @@ -0,0 +1,105 @@ +package com.smyhub.store.mengyastorebackendjava.service; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import javax.net.ssl.SSLSocketFactory; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.List; +import java.util.Properties; +import jakarta.mail.*; +import jakarta.mail.internet.*; + +@Slf4j +@Service +public class EmailService { + + private static final DateTimeFormatter FORMATTER = + DateTimeFormatter.ofPattern("yyyy年MM月dd日 HH:mm:ss"); + + public void sendOrderNotify(SiteService.SmtpConfig cfg, OrderNotifyData data) { + if (!cfg.isConfigured() || data.toEmail == null || data.toEmail.isBlank()) { + return; + } + try { + Properties props = buildMailProperties(cfg); + Session session = Session.getInstance(props, new Authenticator() { + @Override + protected PasswordAuthentication getPasswordAuthentication() { + return new PasswordAuthentication(cfg.email, cfg.password); + } + }); + Message message = new MimeMessage(session); + message.setFrom(new InternetAddress(cfg.email, cfg.fromName, "UTF-8")); + message.setRecipients(Message.RecipientType.TO, InternetAddress.parse(data.toEmail)); + message.setSubject(data.isManual ? "【萌芽小店】您的订单正在处理中" : "【萌芽小店】您的订单已发货"); + message.setContent(buildBody(data), "text/plain; charset=UTF-8"); + Transport.send(message); + log.info("[Email] 发送成功 order={} to={}", data.orderId, data.toEmail); + } catch (Exception e) { + log.error("[Email] 发送失败 order={} to={}: {}", data.orderId, data.toEmail, e.getMessage()); + } + } + + private Properties buildMailProperties(SiteService.SmtpConfig cfg) { + Properties props = new Properties(); + props.put("mail.smtp.host", cfg.host); + props.put("mail.smtp.port", cfg.port); + props.put("mail.smtp.auth", "true"); + if ("465".equals(cfg.port)) { + props.put("mail.smtp.ssl.enable", "true"); + props.put("mail.smtp.socketFactory.port", "465"); + props.put("mail.smtp.socketFactory.class", SSLSocketFactory.class.getName()); + props.put("mail.smtp.socketFactory.fallback", "false"); + } else { + props.put("mail.smtp.starttls.enable", "true"); + } + return props; + } + + private String buildBody(OrderNotifyData data) { + String now = LocalDateTime.now().format(FORMATTER); + String recipient = (data.toName != null && !data.toName.isBlank()) ? data.toName : "用户"; + StringBuilder sb = new StringBuilder(); + sb.append("尊敬的 ").append(recipient).append(",\n\n"); + sb.append(" 您好!感谢您在萌芽小店的支持与购买。\n\n"); + sb.append("────────────────────────────────\n"); + sb.append(" 订单信息\n"); + sb.append("────────────────────────────────\n"); + sb.append(" 商品名称:").append(data.productName).append("\n"); + sb.append(" 订单编号:").append(data.orderId).append("\n"); + sb.append(" 购买数量:").append(data.quantity).append(" 件\n"); + sb.append(" 通知时间:").append(now).append("\n"); + sb.append("────────────────────────────────\n\n"); + if (data.isManual) { + sb.append(" 您的订单已成功提交,目前正在等待人工审核与处理。\n"); + sb.append(" 工作人员将尽快为您安排发货,请耐心等候。\n"); + sb.append(" 发货完成后,我们将另行发送邮件通知。\n\n"); + } else { + sb.append(" 您的订单已完成自动发货,发货内容如下:\n\n"); + if (data.codes != null) { + for (int i = 0; i < data.codes.size(); i++) { + sb.append(" [").append(i + 1).append("] ").append(data.codes.get(i)).append("\n"); + } + sb.append("\n"); + } + sb.append(" 请妥善保管以上发货内容,切勿泄露给他人。\n\n"); + } + sb.append(" 如有任何疑问,请联系在线客服,我们将竭诚为您服务。\n\n"); + sb.append("────────────────────────────────\n"); + sb.append(" 此邮件由系统自动发送,请勿直接回复。\n"); + sb.append("────────────────────────────────\n"); + return sb.toString(); + } + + public static class OrderNotifyData { + public String toEmail; + public String toName; + public String productName; + public String orderId; + public int quantity; + public List codes; + public boolean isManual; + } +} diff --git a/mengyastore-backend-java/src/main/java/com/smyhub/store/mengyastorebackendjava/service/OrderService.java b/mengyastore-backend-java/src/main/java/com/smyhub/store/mengyastorebackendjava/service/OrderService.java new file mode 100644 index 0000000..15d913b --- /dev/null +++ b/mengyastore-backend-java/src/main/java/com/smyhub/store/mengyastorebackendjava/service/OrderService.java @@ -0,0 +1,183 @@ +package com.smyhub.store.mengyastorebackendjava.service; + +import com.smyhub.store.mengyastorebackendjava.dto.request.CheckoutRequest; +import com.smyhub.store.mengyastorebackendjava.dto.response.OrderResponse; +import com.smyhub.store.mengyastorebackendjava.entity.Order; +import com.smyhub.store.mengyastorebackendjava.entity.Product; +import com.smyhub.store.mengyastorebackendjava.exception.BusinessException; +import com.smyhub.store.mengyastorebackendjava.mq.OrderEmailPayload; +import com.smyhub.store.mengyastorebackendjava.mq.OrderEmailProducer; +import com.smyhub.store.mengyastorebackendjava.repository.OrderRepository; +import com.smyhub.store.mengyastorebackendjava.security.SproutGateUser; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.UUID; + +@Slf4j +@Service +@RequiredArgsConstructor +public class OrderService { + + private final OrderRepository orderRepository; + private final ProductService productService; + private final SiteService siteService; + private final EmailService emailService; + private final OrderEmailProducer orderEmailProducer; + + @Transactional + public CheckoutResult checkout(CheckoutRequest req, SproutGateUser optionalUser) { + String userAccount = optionalUser != null ? optionalUser.getAccount() : ""; + String userName = optionalUser != null ? optionalUser.getUsername() : ""; + String userEmail = optionalUser != null ? optionalUser.getEmail() : ""; + + int qty = req.getQuantity() <= 0 ? 1 : req.getQuantity(); + Product product = productService.findById(req.getProductId().strip()); + + if (!product.isActive()) { + throw new BusinessException("商品暂时无法购买"); + } + if (product.isRequireLogin() && !StringUtils.hasText(userAccount)) { + throw new BusinessException("该商品需要登录后才能购买", HttpStatus.UNAUTHORIZED); + } + if (product.getMaxPerAccount() > 0 && StringUtils.hasText(userAccount)) { + int purchased = orderRepository.sumQuantityByUserAccountAndProductId(userAccount, product.getId()); + if (purchased + qty > product.getMaxPerAccount()) { + int remain = product.getMaxPerAccount() - purchased; + if (remain <= 0) { + throw new BusinessException(String.format("每个账户最多购买 %d 个,您已达上限", product.getMaxPerAccount())); + } + throw new BusinessException(String.format("每个账户最多购买 %d 个,您还可购买 %d 个", product.getMaxPerAccount(), remain)); + } + } + + List deliveredCodes; + boolean isFixed = "fixed".equalsIgnoreCase(product.getFulfillmentType()); + if (isFixed) { + String content = product.getFixedContent() != null ? product.getFixedContent().strip() : ""; + if (content.isEmpty()) { + throw new BusinessException("该商品为固定内容发货,但未配置发货内容,请联系管理员"); + } + deliveredCodes = Collections.nCopies(qty, content); + } else { + deliveredCodes = productService.popCodes(product.getId(), qty); + } + + String notifyEmail = StringUtils.hasText(userEmail) ? userEmail + : StringUtils.hasText(req.getNotifyEmail()) ? req.getNotifyEmail().strip() + : StringUtils.hasText(req.getContactEmail()) ? req.getContactEmail().strip() + : ""; + + Order order = new Order(); + order.setId(UUID.randomUUID().toString()); + order.setProductId(product.getId()); + order.setProductName(product.getName()); + order.setUserAccount(userAccount); + order.setUserName(userName); + order.setQuantity(qty); + order.setDeliveredCodes(new ArrayList<>(deliveredCodes)); + order.setStatus("completed"); + order.setDeliveryMode("auto"); + order.setNote(req.getNote() != null ? req.getNote().strip() : ""); + order.setContactPhone(req.getContactPhone() != null ? req.getContactPhone().strip() : ""); + order.setContactEmail(req.getContactEmail() != null ? req.getContactEmail().strip() : ""); + order.setNotifyEmail(notifyEmail); + order.setCreatedAt(LocalDateTime.now()); + orderRepository.save(order); + + productService.incrementSold(product.getId(), qty); + + if (StringUtils.hasText(notifyEmail)) { + sendOrderEmail(notifyEmail, userName, product.getName(), order.getId(), qty, new ArrayList<>(deliveredCodes), false); + } + + return new CheckoutResult(order.getId(), product.getId(), qty, product.getViewCount(), order.getStatus()); + } + + public OrderResponse confirmOrder(String orderId) { + Order order = orderRepository.findById(orderId) + .orElseThrow(() -> new BusinessException("订单不存在", HttpStatus.NOT_FOUND)); + order.setStatus("confirmed"); + orderRepository.save(order); + + boolean isManual = "manual".equals(order.getDeliveryMode()); + if (isManual) { + String email = StringUtils.hasText(order.getNotifyEmail()) + ? order.getNotifyEmail() : order.getContactEmail(); + if (StringUtils.hasText(email)) { + sendOrderEmail(email, order.getUserName(), order.getProductName(), + order.getId(), order.getQuantity(), order.getDeliveredCodes(), false); + } + } + return toResponse(order); + } + + public List getMyOrders(String userAccount) { + return orderRepository.findByUserAccountOrderByCreatedAtDesc(userAccount) + .stream().map(this::toResponse).toList(); + } + + public List listAllOrders() { + return orderRepository.findAllByOrderByCreatedAtDesc() + .stream().map(this::toResponse).toList(); + } + + @Transactional + public void deleteOrder(String orderId) { + if (!orderRepository.existsById(orderId)) { + throw new BusinessException("订单不存在", HttpStatus.NOT_FOUND); + } + orderRepository.deleteById(orderId); + } + + private void sendOrderEmail(String toEmail, String toName, String productName, + String orderId, int qty, List codes, boolean isManual) { + SiteService.SmtpConfig cfg = siteService.getSmtpConfig(); + if (!cfg.isConfigured()) return; + + OrderEmailPayload payload = new OrderEmailPayload(toEmail, toName, productName, orderId, qty, codes, isManual); + boolean queued = orderEmailProducer.publish(payload); + if (!queued) { + EmailService.OrderNotifyData data = new EmailService.OrderNotifyData(); + data.toEmail = toEmail; + data.toName = toName; + data.productName = productName; + data.orderId = orderId; + data.quantity = qty; + data.codes = codes; + data.isManual = isManual; + Thread emailThread = new Thread(() -> emailService.sendOrderNotify(cfg, data)); + emailThread.setDaemon(true); + emailThread.start(); + } + } + + private OrderResponse toResponse(Order o) { + return OrderResponse.builder() + .id(o.getId()) + .productId(o.getProductId()) + .productName(o.getProductName()) + .userAccount(o.getUserAccount()) + .userName(o.getUserName()) + .quantity(o.getQuantity()) + .deliveredCodes(o.getDeliveredCodes()) + .status(o.getStatus()) + .deliveryMode(o.getDeliveryMode()) + .note(o.getNote()) + .contactPhone(o.getContactPhone()) + .contactEmail(o.getContactEmail()) + .notifyEmail(o.getNotifyEmail()) + .createdAt(o.getCreatedAt()) + .build(); + } + + public record CheckoutResult(String orderId, String productId, int qty, int viewCount, String status) {} +} diff --git a/mengyastore-backend-java/src/main/java/com/smyhub/store/mengyastorebackendjava/service/ProductService.java b/mengyastore-backend-java/src/main/java/com/smyhub/store/mengyastorebackendjava/service/ProductService.java new file mode 100644 index 0000000..f131593 --- /dev/null +++ b/mengyastore-backend-java/src/main/java/com/smyhub/store/mengyastorebackendjava/service/ProductService.java @@ -0,0 +1,246 @@ +package com.smyhub.store.mengyastorebackendjava.service; + +import com.smyhub.store.mengyastorebackendjava.dto.request.AdminProductRequest; +import com.smyhub.store.mengyastorebackendjava.dto.response.ProductResponse; +import com.smyhub.store.mengyastorebackendjava.entity.Product; +import com.smyhub.store.mengyastorebackendjava.entity.ProductCode; +import com.smyhub.store.mengyastorebackendjava.exception.BusinessException; +import com.smyhub.store.mengyastorebackendjava.repository.ProductCodeRepository; +import com.smyhub.store.mengyastorebackendjava.repository.ProductRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; + +import java.security.MessageDigest; +import java.time.LocalDateTime; +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; + +@Service +@RequiredArgsConstructor +public class ProductService { + + private static final String DEFAULT_COVER_URL = "https://img.shumengya.top/i/2026/01/04/695a55058c37f.png"; + private static final int VIEW_COOLDOWN_HOURS = 6; + private static final int MAX_SCREENSHOT_URLS = 5; + + private final ProductRepository productRepository; + private final ProductCodeRepository productCodeRepository; + private final Map recentViews = new ConcurrentHashMap<>(); + + public List listPublicProducts() { + return productRepository.findByActiveTrueOrderByCreatedAtDesc().stream() + .map(p -> toResponse(p, loadCodeCount(p), false, false)) + .toList(); + } + + public List listAllProducts() { + return productRepository.findAllByOrderByCreatedAtDesc().stream() + .map(p -> toResponse(p, loadCodeCount(p), true, true)) + .toList(); + } + + public ProductResponse getProductForAdmin(String id) { + Product p = findById(id); + List codes = productCodeRepository.findByProductId(id).stream() + .map(ProductCode::getCode).toList(); + return toResponse(p, (long) codes.size(), true, true, codes); + } + + @Transactional + public ProductResponse createProduct(AdminProductRequest req) { + Product p = new Product(); + p.setId(UUID.randomUUID().toString()); + p.setCreatedAt(LocalDateTime.now()); + applyRequest(p, req); + productRepository.save(p); + replaceCodes(p.getId(), sanitizeCodes(req.getCodes())); + return getProductForAdmin(p.getId()); + } + + @Transactional + public ProductResponse updateProduct(String id, AdminProductRequest req) { + Product p = findById(id); + applyRequest(p, req); + productRepository.save(p); + replaceCodes(id, sanitizeCodes(req.getCodes())); + return getProductForAdmin(id); + } + + @Transactional + public ProductResponse toggleStatus(String id, boolean active) { + Product p = findById(id); + p.setActive(active); + productRepository.save(p); + long count = productCodeRepository.countByProductId(id); + return toResponse(p, count, true, false); + } + + @Transactional + public void deleteProduct(String id) { + findById(id); + productCodeRepository.deleteByProductId(id); + productRepository.deleteById(id); + } + + public ProductResponse recordView(String id, String fingerprint) { + cleanupRecentViews(); + String key = buildViewKey(id, fingerprint); + LocalDateTime last = recentViews.get(key); + LocalDateTime now = LocalDateTime.now(); + boolean counted = false; + if (last == null || last.plusHours(VIEW_COOLDOWN_HOURS).isBefore(now)) { + productRepository.incrementViewCount(id); + recentViews.put(key, now); + counted = true; + } + Product p = findById(id); + return ProductResponse.builder() + .id(p.getId()) + .viewCount(p.getViewCount()) + .build(); + } + + @Transactional + public void incrementSold(String id, int count) { + productRepository.incrementTotalSold(id, count); + } + + public Product findById(String id) { + return productRepository.findById(id) + .orElseThrow(() -> new BusinessException("商品不存在", HttpStatus.NOT_FOUND)); + } + + @Transactional + public List popCodes(String productId, int count) { + List codes = productCodeRepository.findByProductId(productId); + if (codes.size() < count) { + throw new BusinessException("卡密不足"); + } + List popped = codes.subList(0, count).stream().map(ProductCode::getCode).toList(); + codes.subList(0, count).forEach(c -> productCodeRepository.deleteById(c.getId())); + return popped; + } + + private long loadCodeCount(Product p) { + if ("fixed".equalsIgnoreCase(p.getFulfillmentType())) { + return -1; + } + return productCodeRepository.countByProductId(p.getId()); + } + + private void replaceCodes(String productId, List codes) { + productCodeRepository.deleteByProductId(productId); + if (!codes.isEmpty()) { + List rows = codes.stream() + .map(c -> new ProductCode(productId, c)).toList(); + productCodeRepository.saveAll(rows); + } + } + + private void applyRequest(Product p, AdminProductRequest req) { + p.setName(req.getName()); + p.setPrice(req.getPrice()); + double discount = req.getDiscountPrice(); + p.setDiscountPrice((discount > 0 && discount < req.getPrice()) ? discount : 0); + p.setTags(sanitizeTags(req.getTags())); + String cover = req.getCoverUrl(); + p.setCoverUrl(StringUtils.hasText(cover) ? cover : DEFAULT_COVER_URL); + List screenshots = req.getScreenshotUrls(); + if (screenshots != null && screenshots.size() > MAX_SCREENSHOT_URLS) { + screenshots = screenshots.subList(0, MAX_SCREENSHOT_URLS); + } + p.setScreenshotUrls(screenshots != null ? screenshots : new ArrayList<>()); + p.setVerificationUrl(req.getVerificationUrl() != null ? req.getVerificationUrl().strip() : ""); + p.setDescription(req.getDescription()); + p.setActive(req.isActive()); + p.setRequireLogin(req.isRequireLogin()); + p.setMaxPerAccount(req.getMaxPerAccount()); + p.setDeliveryMode(StringUtils.hasText(req.getDeliveryMode()) ? req.getDeliveryMode() : "auto"); + String ft = normalizeFullfillmentType(req.getFulfillmentType()); + p.setFulfillmentType(ft); + if ("fixed".equals(ft)) { + p.setFixedContent(req.getFixedContent() != null ? req.getFixedContent().strip() : ""); + } else { + p.setFixedContent(""); + } + p.setShowNote(req.isShowNote()); + p.setShowContact(req.isShowContact()); + } + + private ProductResponse toResponse(Product p, long codeCount, boolean includeCodes, boolean includeFixedContent) { + return toResponse(p, codeCount, includeCodes, includeFixedContent, null); + } + + private ProductResponse toResponse(Product p, long codeCount, boolean includeCodes, boolean includeFixedContent, List codes) { + int qty = "fixed".equalsIgnoreCase(p.getFulfillmentType()) ? -1 : (int) codeCount; + return ProductResponse.builder() + .id(p.getId()) + .name(p.getName()) + .price(p.getPrice()) + .discountPrice(p.getDiscountPrice()) + .tags(p.getTags()) + .quantity(qty) + .coverUrl(p.getCoverUrl()) + .screenshotUrls(p.getScreenshotUrls()) + .verificationUrl(p.getVerificationUrl()) + .codes(includeCodes ? codes : null) + .viewCount(p.getViewCount()) + .description(p.getDescription()) + .active(p.isActive()) + .requireLogin(p.isRequireLogin()) + .maxPerAccount(p.getMaxPerAccount()) + .totalSold(p.getTotalSold()) + .deliveryMode(p.getDeliveryMode()) + .fulfillmentType(p.getFulfillmentType()) + .fixedContent(includeFixedContent ? p.getFixedContent() : null) + .showNote(p.isShowNote()) + .showContact(p.isShowContact()) + .createdAt(p.getCreatedAt()) + .build(); + } + + private static String normalizeFullfillmentType(String t) { + if (t != null && "fixed".equalsIgnoreCase(t.strip())) return "fixed"; + return "card"; + } + + private static List sanitizeCodes(List codes) { + if (codes == null) return new ArrayList<>(); + Set seen = new LinkedHashSet<>(); + for (String code : codes) { + String trimmed = code != null ? code.strip() : ""; + if (!trimmed.isEmpty()) seen.add(trimmed); + } + return new ArrayList<>(seen); + } + + private static List sanitizeTags(List tags) { + if (tags == null) return new ArrayList<>(); + Set seen = new LinkedHashSet<>(); + for (String tag : tags) { + String t = tag != null ? tag.strip() : ""; + if (!t.isEmpty() && seen.size() < 20) seen.add(t); + } + return new ArrayList<>(seen); + } + + private static String buildViewKey(String id, String fingerprint) { + try { + MessageDigest md = MessageDigest.getInstance("SHA-256"); + byte[] hash = md.digest((id + "|" + fingerprint).getBytes()); + StringBuilder hex = new StringBuilder(); + for (byte b : hash) hex.append(String.format("%02x", b)); + return hex.toString(); + } catch (Exception e) { + return id + "|" + fingerprint; + } + } + + private void cleanupRecentViews() { + LocalDateTime cutoff = LocalDateTime.now().minusHours(VIEW_COOLDOWN_HOURS); + recentViews.entrySet().removeIf(e -> e.getValue().isBefore(cutoff)); + } +} diff --git a/mengyastore-backend-java/src/main/java/com/smyhub/store/mengyastorebackendjava/service/SiteService.java b/mengyastore-backend-java/src/main/java/com/smyhub/store/mengyastorebackendjava/service/SiteService.java new file mode 100644 index 0000000..56080d6 --- /dev/null +++ b/mengyastore-backend-java/src/main/java/com/smyhub/store/mengyastorebackendjava/service/SiteService.java @@ -0,0 +1,119 @@ +package com.smyhub.store.mengyastorebackendjava.service; + +import com.smyhub.store.mengyastorebackendjava.dto.request.SmtpConfigRequest; +import com.smyhub.store.mengyastorebackendjava.dto.response.MaintenanceResponse; +import com.smyhub.store.mengyastorebackendjava.dto.response.SmtpConfigResponse; +import com.smyhub.store.mengyastorebackendjava.entity.SiteSetting; +import com.smyhub.store.mengyastorebackendjava.repository.SiteSettingRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class SiteService { + + private static final String KEY_TOTAL_VISITS = "totalVisits"; + private static final String KEY_MAINTENANCE = "maintenance"; + private static final String KEY_MAINTENANCE_REASON = "maintenanceReason"; + private static final String KEY_SMTP_ENABLED = "smtpEnabled"; + private static final String KEY_SMTP_EMAIL = "smtpEmail"; + private static final String KEY_SMTP_PASSWORD = "smtpPassword"; + private static final String KEY_SMTP_FROM_NAME = "smtpFromName"; + private static final String KEY_SMTP_HOST = "smtpHost"; + private static final String KEY_SMTP_PORT = "smtpPort"; + + private final SiteSettingRepository repo; + + private String get(String key) { + return repo.findById(key).map(SiteSetting::getValue).orElse(null); + } + + private void set(String key, String value) { + repo.save(new SiteSetting(key, value)); + } + + public int getTotalVisits() { + String v = get(KEY_TOTAL_VISITS); + if (v == null || v.isBlank()) return 0; + try { return Integer.parseInt(v); } catch (NumberFormatException e) { return 0; } + } + + @Transactional + public int incrementVisits() { + int current = getTotalVisits() + 1; + set(KEY_TOTAL_VISITS, String.valueOf(current)); + return current; + } + + public MaintenanceResponse getMaintenance() { + String enabled = get(KEY_MAINTENANCE); + String reason = get(KEY_MAINTENANCE_REASON); + return MaintenanceResponse.builder() + .maintenance("true".equals(enabled)) + .reason(reason != null ? reason : "") + .build(); + } + + @Transactional + public void setMaintenance(boolean enabled, String reason) { + set(KEY_MAINTENANCE, enabled ? "true" : "false"); + set(KEY_MAINTENANCE_REASON, reason != null ? reason : ""); + } + + public SmtpConfig getSmtpConfig() { + SmtpConfig cfg = new SmtpConfig(); + String enabledStr = get(KEY_SMTP_ENABLED); + cfg.enabled = !"false".equals(enabledStr); + cfg.email = nvl(get(KEY_SMTP_EMAIL), ""); + cfg.password = nvl(get(KEY_SMTP_PASSWORD), ""); + cfg.fromName = nvl(get(KEY_SMTP_FROM_NAME), "萌芽小店"); + cfg.host = nvl(get(KEY_SMTP_HOST), "smtp.qq.com"); + cfg.port = nvl(get(KEY_SMTP_PORT), "465"); + return cfg; + } + + @Transactional + public void setSmtpConfig(SmtpConfigRequest req) { + set(KEY_SMTP_ENABLED, req.isEnabled() ? "true" : "false"); + set(KEY_SMTP_EMAIL, nvl(req.getEmail(), "")); + set(KEY_SMTP_PASSWORD, nvl(req.getPassword(), "")); + set(KEY_SMTP_FROM_NAME, nvl(req.getFromName(), "")); + set(KEY_SMTP_HOST, nvl(req.getHost(), "smtp.qq.com")); + set(KEY_SMTP_PORT, nvl(req.getPort(), "465")); + } + + public SmtpConfigResponse getSmtpConfigResponse(boolean maskPassword) { + SmtpConfig cfg = getSmtpConfig(); + return SmtpConfigResponse.builder() + .enabled(cfg.enabled) + .email(cfg.email) + .password(maskPassword ? maskStr(cfg.password) : cfg.password) + .fromName(cfg.fromName) + .host(cfg.host) + .port(cfg.port) + .build(); + } + + private static String nvl(String s, String def) { + return (s != null && !s.isBlank()) ? s : def; + } + + private static String maskStr(String s) { + if (s == null || s.length() <= 2) return "***"; + return s.substring(0, 1) + "***" + s.substring(s.length() - 1); + } + + public static class SmtpConfig { + public boolean enabled = true; + public String email = ""; + public String password = ""; + public String fromName = "萌芽小店"; + public String host = "smtp.qq.com"; + public String port = "465"; + + public boolean isConfigured() { + return enabled && !email.isBlank() && !password.isBlank() && !host.isBlank(); + } + } +} diff --git a/mengyastore-backend-java/src/main/java/com/smyhub/store/mengyastorebackendjava/service/SproutGateAuthService.java b/mengyastore-backend-java/src/main/java/com/smyhub/store/mengyastorebackendjava/service/SproutGateAuthService.java new file mode 100644 index 0000000..3ff175f --- /dev/null +++ b/mengyastore-backend-java/src/main/java/com/smyhub/store/mengyastorebackendjava/service/SproutGateAuthService.java @@ -0,0 +1,37 @@ +package com.smyhub.store.mengyastorebackendjava.service; + +import com.smyhub.store.mengyastorebackendjava.config.AppProperties; +import com.smyhub.store.mengyastorebackendjava.security.SproutGateVerifyResult; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Service; +import org.springframework.web.client.RestTemplate; + +import java.util.Map; + +@Slf4j +@Service +@RequiredArgsConstructor +public class SproutGateAuthService { + + private final RestTemplate restTemplate; + private final AppProperties appProperties; + + public SproutGateVerifyResult verifyToken(String token) { + try { + String url = appProperties.getAuthApiUrl() + "/api/auth/verify"; + ResponseEntity resp = restTemplate.postForEntity( + url, Map.of("token", token), SproutGateVerifyResult.class + ); + if (resp.getStatusCode().is2xxSuccessful() && resp.getBody() != null) { + return resp.getBody(); + } + } catch (Exception e) { + log.warn("[SproutGate] token verify failed: {}", e.getMessage()); + } + SproutGateVerifyResult invalid = new SproutGateVerifyResult(); + invalid.setValid(false); + return invalid; + } +} diff --git a/mengyastore-backend-java/src/main/java/com/smyhub/store/mengyastorebackendjava/service/SystemStatusService.java b/mengyastore-backend-java/src/main/java/com/smyhub/store/mengyastorebackendjava/service/SystemStatusService.java new file mode 100644 index 0000000..d0aae74 --- /dev/null +++ b/mengyastore-backend-java/src/main/java/com/smyhub/store/mengyastorebackendjava/service/SystemStatusService.java @@ -0,0 +1,296 @@ +package com.smyhub.store.mengyastorebackendjava.service; + +import com.smyhub.store.mengyastorebackendjava.config.AppProperties; +import com.smyhub.store.mengyastorebackendjava.mq.RabbitMQTopology; +import com.zaxxer.hikari.HikariDataSource; +import com.zaxxer.hikari.HikariPoolMXBean; +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; +import org.springframework.amqp.rabbit.core.RabbitTemplate; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.data.redis.connection.RedisConnection; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.stereotype.Service; +import org.springframework.util.StringUtils; + +import javax.sql.DataSource; +import java.sql.Connection; +import java.sql.ResultSet; +import java.sql.Statement; +import java.time.Instant; +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * Builds the same JSON shape as Go {@code admin_status.go} for the admin system-status panel. + */ +@Service +@RequiredArgsConstructor +public class SystemStatusService { + + private static final Instant START_TIME = Instant.now(); + + private final AppProperties appProperties; + private final DataSource dataSource; + private final RedisConnectionFactory redisConnectionFactory; + private final RabbitTemplate rabbitTemplate; + + @Value("${spring.datasource.url:}") + private String datasourceUrl; + + @Value("${spring.datasource.username:}") + private String datasourceUsername; + + @Value("${server.port:8080}") + private int serverPort; + + @Value("${server.address:}") + private String serverAddress; + + @Value("${spring.rabbitmq.host:}") + private String rabbitHost; + + @Value("${spring.rabbitmq.port:5672}") + private int rabbitPort; + + @Value("${spring.rabbitmq.virtual-host:/}") + private String rabbitVhost; + + @Value("${spring.rabbitmq.username:}") + private String rabbitUser; + + @Value("${spring.data.redis.host:}") + private String redisHost; + + @Value("${spring.data.redis.port:6379}") + private int redisPort; + + @Value("${spring.data.redis.database:0}") + private int redisDb; + + public Map buildStatus(HttpServletRequest request) { + Map root = new LinkedHashMap<>(); + root.put("backend", backendInfo(request)); + root.put("mysql", mysqlInfo()); + root.put("redis", redisInfo()); + root.put("rabbitmq", rabbitmqInfo()); + return root; + } + + private Map backendInfo(HttpServletRequest request) { + Map m = new LinkedHashMap<>(); + m.put("status", "ok"); + m.put("appEnv", normalizeAppEnv(appProperties.getAppEnv())); + m.put("ginMode", "—"); + String host = request.getServerName(); + int port = request.getServerPort(); + String hostHeader = request.getHeader("Host"); + m.put("requestHost", StringUtils.hasText(hostHeader) ? hostHeader : host + ":" + port); + + String listen = formatListenAddr(); + m.put("listenAddr", listen); + + String publicBase = appProperties.getPublicApiBaseUrl() != null + ? appProperties.getPublicApiBaseUrl().strip() : ""; + if (publicBase.isEmpty()) { + String proto = request.getHeader("X-Forwarded-Proto"); + if (!StringUtils.hasText(proto)) { + proto = request.isSecure() ? "https" : "http"; + } + if (StringUtils.hasText(hostHeader)) { + publicBase = proto + "://" + hostHeader; + } else { + publicBase = proto + "://" + host + ":" + port; + } + } + m.put("publicBaseUrl", publicBase); + + m.put("uptimeSeconds", (int) (Instant.now().getEpochSecond() - START_TIME.getEpochSecond())); + m.put("authApiConfigured", StringUtils.hasText(appProperties.getAuthApiUrl())); + m.put("rabbitmqEnabled", appProperties.isRabbitmqEnabled()); + m.put("redisEnabled", appProperties.isRedisEnabled()); + return m; + } + + private String normalizeAppEnv(String raw) { + if (raw == null) return "development"; + return switch (raw.strip().toLowerCase()) { + case "production", "prod" -> "production"; + default -> "development"; + }; + } + + private String formatListenAddr() { + String addr = serverAddress != null ? serverAddress.strip() : ""; + if (addr.isEmpty() || "0.0.0.0".equals(addr)) { + return ":" + serverPort; + } + return addr + ":" + serverPort; + } + + private Map mysqlInfo() { + Map m = new LinkedHashMap<>(); + parseJdbcMysqlUrl(datasourceUrl, datasourceUsername, m); + + try (Connection conn = dataSource.getConnection(); + Statement stmt = conn.createStatement(); + ResultSet rs = stmt.executeQuery("SELECT VERSION()")) { + if (rs.next()) { + m.put("version", rs.getString(1).trim()); + } + m.put("status", "ok"); + appendPoolStats(m); + } catch (Exception e) { + m.put("status", "error"); + m.put("message", e.getMessage()); + } + return m; + } + + private void appendPoolStats(Map m) { + try { + if (dataSource instanceof HikariDataSource hikari) { + HikariPoolMXBean pool = hikari.getHikariPoolMXBean(); + if (pool != null) { + int active = pool.getActiveConnections(); + int idle = pool.getIdleConnections(); + m.put("openConnections", active + idle); + m.put("inUse", active); + m.put("idle", idle); + m.put("waitCount", pool.getThreadsAwaitingConnection()); + } + } + } catch (Exception ignored) { + // optional metrics + } + } + + private static void parseJdbcMysqlUrl(String jdbcUrl, String username, Map out) { + if (jdbcUrl == null || jdbcUrl.isBlank()) { + out.put("configured", false); + return; + } + try { + String rest = jdbcUrl.substring("jdbc:mysql://".length()); + int q = rest.indexOf('?'); + String core = q >= 0 ? rest.substring(0, q) : rest; + int slash = core.indexOf('/'); + String hostPort = slash >= 0 ? core.substring(0, slash) : core; + String database = ""; + if (slash >= 0 && slash < core.length() - 1) { + database = core.substring(slash + 1); + } + String host; + String port; + if (hostPort.contains(":")) { + int c = hostPort.lastIndexOf(':'); + host = hostPort.substring(0, c); + port = hostPort.substring(c + 1); + } else { + host = hostPort; + port = "3306"; + } + out.put("configured", true); + out.put("host", host); + out.put("port", port); + out.put("database", database); + out.put("user", username != null ? username : ""); + } catch (Exception e) { + out.put("configured", false); + out.put("dsnParseError", e.getMessage()); + } + } + + private Map redisInfo() { + Map m = new LinkedHashMap<>(); + m.put("enabled", appProperties.isRedisEnabled()); + m.put("env", appProperties.getRedisEnv()); + m.put("host", redisHost); + m.put("port", redisPort); + m.put("dbIndex", redisDb); + + if (!appProperties.isRedisEnabled()) { + m.put("status", "disabled"); + return m; + } + if (!StringUtils.hasText(redisHost)) { + m.put("status", "misconfigured"); + m.put("message", "已启用但未配置 spring.data.redis.host"); + return m; + } + + try (RedisConnection conn = redisConnectionFactory.getConnection()) { + String pong = new String(conn.ping()); + if (!"PONG".equalsIgnoreCase(pong)) { + m.put("status", "degraded"); + m.put("message", "PING 返回异常: " + pong); + return m; + } + java.util.Properties infoProps = conn.info(); + if (infoProps == null || infoProps.isEmpty()) { + m.put("status", "degraded"); + m.put("message", "PING 成功但无法读取 INFO"); + return m; + } + Long dbSize = conn.dbSize(); + m.put("status", "ok"); + m.put("redisVersion", nullToEmpty(infoProps.getProperty("redis_version"))); + m.put("usedMemoryHuman", nullToEmpty(infoProps.getProperty("used_memory_human"))); + m.put("keysApprox", dbSize); + } catch (Exception e) { + m.put("status", "error"); + m.put("message", e.getMessage()); + } + return m; + } + + private static String nullToEmpty(String s) { + return s != null ? s : ""; + } + + private Map rabbitmqInfo() { + String env = appProperties.getRabbitmqEnv(); + Map m = new LinkedHashMap<>(); + m.put("enabled", appProperties.isRabbitmqEnabled()); + m.put("env", env); + m.put("exchange", RabbitMQTopology.exchangeName(env)); + m.put("queue", RabbitMQTopology.queueName(env)); + m.put("routingKey", RabbitMQTopology.ORDER_EMAIL_ROUTING_KEY); + m.put("brokerHost", rabbitHost); + m.put("brokerPort", String.valueOf(rabbitPort)); + m.put("vhost", rabbitVhost); + m.put("brokerUser", rabbitUser); + + if (!appProperties.isRabbitmqEnabled()) { + m.put("status", "disabled"); + return m; + } + if (!StringUtils.hasText(rabbitHost)) { + m.put("status", "misconfigured"); + m.put("message", "已启用但未配置 spring.rabbitmq.host"); + return m; + } + + try { + int[] mqStats = rabbitTemplate.execute(channel -> { + var ok = channel.queueDeclarePassive(RabbitMQTopology.queueName(env)); + return new int[]{ok.getMessageCount(), ok.getConsumerCount()}; + }); + if (mqStats != null && mqStats.length >= 2) { + m.put("messagesReady", mqStats[0]); + m.put("consumers", mqStats[1]); + } + m.put("status", "ok"); + } catch (Exception e) { + String msg = e.getMessage() != null ? e.getMessage() : e.getClass().getSimpleName(); + if (msg.contains("NOT_FOUND") || msg.contains("404")) { + m.put("status", "degraded"); + m.put("message", "已连接但队列不存在或未声明: " + msg); + } else { + m.put("status", "error"); + m.put("message", msg); + } + } + return m; + } +} diff --git a/mengyastore-backend-java/src/main/java/com/smyhub/store/mengyastorebackendjava/service/WishlistService.java b/mengyastore-backend-java/src/main/java/com/smyhub/store/mengyastorebackendjava/service/WishlistService.java new file mode 100644 index 0000000..026bac1 --- /dev/null +++ b/mengyastore-backend-java/src/main/java/com/smyhub/store/mengyastorebackendjava/service/WishlistService.java @@ -0,0 +1,61 @@ +package com.smyhub.store.mengyastorebackendjava.service; + +import com.smyhub.store.mengyastorebackendjava.dto.response.WishlistResponse; +import com.smyhub.store.mengyastorebackendjava.entity.Wishlist; +import com.smyhub.store.mengyastorebackendjava.exception.BusinessException; +import com.smyhub.store.mengyastorebackendjava.repository.WishlistRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Service +@RequiredArgsConstructor +public class WishlistService { + + private final WishlistRepository wishlistRepository; + + public List getWishlistIds(String accountId) { + return wishlistRepository.findByAccountId(accountId).stream() + .map(Wishlist::getProductId) + .toList(); + } + + public List getWishlist(String accountId) { + return wishlistRepository.findByAccountId(accountId).stream() + .map(this::toResponse) + .toList(); + } + + @Transactional + public WishlistResponse addToWishlist(String accountId, String productId) { + if (wishlistRepository.existsByAccountIdAndProductId(accountId, productId)) { + throw new BusinessException("该商品已在心愿单中"); + } + try { + Wishlist saved = wishlistRepository.save(new Wishlist(accountId, productId)); + return toResponse(saved); + } catch (DataIntegrityViolationException e) { + throw new BusinessException("该商品已在心愿单中"); + } + } + + @Transactional + public void removeFromWishlist(String accountId, String productId) { + if (!wishlistRepository.existsByAccountIdAndProductId(accountId, productId)) { + throw new BusinessException("心愿单中不存在该商品", HttpStatus.NOT_FOUND); + } + wishlistRepository.deleteByAccountIdAndProductId(accountId, productId); + } + + private WishlistResponse toResponse(Wishlist w) { + return WishlistResponse.builder() + .id(w.getId()) + .accountId(w.getAccountId()) + .productId(w.getProductId()) + .build(); + } +} diff --git a/mengyastore-backend-java/src/main/resources/application.yaml b/mengyastore-backend-java/src/main/resources/application.yaml index ce7f855..6bcf749 100644 --- a/mengyastore-backend-java/src/main/resources/application.yaml +++ b/mengyastore-backend-java/src/main/resources/application.yaml @@ -1,3 +1,85 @@ spring: application: name: mengyastore-backend-java + profiles: + active: dev + + datasource: + url: jdbc:mysql://10.1.1.100:3306/mengyastore-test?charset=utf8mb4&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true + username: mengyastore-test + password: mengyastore-test + driver-class-name: com.mysql.cj.jdbc.Driver + + jpa: + hibernate: + ddl-auto: update + show-sql: false + properties: + hibernate: + dialect: org.hibernate.dialect.MySQLDialect + format_sql: false + + rabbitmq: + host: 10.1.1.233 + port: 5672 + username: admin + # 请将此密码替换为真实的 RabbitMQ 密码,然后将 app.rabbitmq-enabled 设为 true + password: shumengya520 + virtual-host: mengyastore-dev + listener: + simple: + acknowledge-mode: manual + prefetch: 1 + + data: + redis: + host: 10.1.1.100 + port: 6379 + password: tyh@19900420 + database: 1 + timeout: 3000ms + + mail: + host: smtp.qq.com + port: 465 + username: "" + password: "" + properties: + mail: + smtp: + auth: true + ssl: + enable: true + socketFactory: + port: 465 + class: javax.net.ssl.SSLSocketFactory + fallback: false + +server: + port: 8080 + +app: + admin-token: shumengya520 + auth-api-url: https://auth.api.shumengya.top + # 对应 Go 的 APP_ENV:development | production(系统状态页「应用环境」) + app-env: development + # 对应 Go 的 REDIS_ENABLED;false 时系统状态里 Redis 为「未启用」且不再探测 + redis-enabled: true + # 对应 Go 后端的 RABBITMQ_ENABLED;默认关闭,密码配置正确后再改为 true + rabbitmq-enabled: true + rabbitmq-env: dev + redis-env: dev + public-api-base-url: "" + cors-allowed-origins: "http://localhost:5173,http://localhost:3000,http://localhost:5174" + +# SMTP 在库里配置前无用户名密码;关闭邮件健康检查避免启动后打 ERROR 日志 +management: + health: + mail: + enabled: false + +logging: + level: + com.smyhub.store: INFO + org.springframework.amqp: WARN + org.springframework.data.redis: WARN diff --git a/mengyastore-backend-java/后端文档.md b/mengyastore-backend-java/后端文档.md new file mode 100644 index 0000000..3c59996 --- /dev/null +++ b/mengyastore-backend-java/后端文档.md @@ -0,0 +1,112 @@ +# 萌芽小店 · 后端(mengyastore-backend-java) + +基于 **Java 17、Spring Boot 3、Spring MVC、Spring Data JPA(Hibernate)** 的 REST API,配套 **Vue 3** 前端联调;可在 **IntelliJ IDEA** 中运行 `MengyastoreBackendJavaApplication` 进行开发与测试。 + +## 技术栈与 Maven 依赖 + +| 依赖 | 用途 | +|------|------| +| spring-boot-starter-web | REST、Jackson | +| spring-boot-starter-data-jpa | 实体、Repository、事务 | +| mysql-connector-j | MySQL JDBC | +| spring-boot-starter-amqp | RabbitMQ | +| spring-boot-starter-data-redis | Redis | +| spring-boot-starter-validation | Bean 校验 | +| spring-boot-starter-mail | Jakarta Mail(业务 SMTP 配置来自数据库) | +| spring-boot-starter-actuator | 健康检查(默认关闭 Mail 指标以免未配 SMTP 报错) | +| lombok | Getter 等样板代码 | + +## 包结构 + +``` +src/main/java/com/smyhub/store/mengyastorebackendjava/ +├── MengyastoreBackendJavaApplication.java +├── config/ # AppProperties、CORS、RabbitMQ、Redis、RestTemplate、WebMvc +├── controller/ # 按域拆分;admin/ 为管理端 +├── dto/request|response/ +├── entity/ # JPA;entity/converter 处理 JSON 列 +├── exception/ # BusinessException、GlobalExceptionHandler +├── mq/ # 拓扑、Payload、Producer、Consumer(随 app.rabbitmq-enabled) +├── repository/ +├── security/ # 管理令牌与用户 Bearer 拦截器、ThreadLocal 用户 +└── service/ # 业务与 SystemStatusService 等 +``` + +## API 路由一览 + +### 公开 + +| 方法 | 路径 | 说明 | +|------|------|------| +| GET | `/api/health` | 健康检查;含 RabbitMQ 状态(`ok` / `error` / `disabled`) | +| GET | `/api/products` | 上架商品列表(不含卡密与 `fixedContent`) | +| POST | `/api/products/{id}/view` | 记录浏览(进程内去重) | +| GET | `/api/stats` | 订单数、访问量 | +| POST | `/api/site/visit` | 站点访问计数 | +| GET | `/api/site/maintenance` | 维护状态 | +| POST | `/api/checkout` | 创建订单(可选 `Authorization: Bearer`) | +| POST | `/api/orders/{id}/confirm` | 确认订单 | + +### 用户(Bearer,远程认证服务校验) + +| 方法 | 路径 | 说明 | +|------|------|------| +| GET | `/api/orders` | 当前用户订单 | +| GET / POST | `/api/wishlist` | 列表;POST body `{"productId":"…"}` | +| DELETE | `/api/wishlist/{id}` | 删除,路径参数为商品 ID | +| GET / POST | `/api/chat/messages` | 消息列表;发送(带发送频控) | + +### 管理端(`X-Admin-Token`;兼容 `Authorization`、`?token=`) + +| 方法 | 路径 | 说明 | +|------|------|------| +| POST | `/api/admin/verify` | 请求体 `{"token":"…"}`,**响应顶层** `{"valid":true/false}` | +| GET / POST / PUT / PATCH / DELETE | `/api/admin/products` | 商品 CRUD 与上下架 | +| POST | `/api/admin/site/maintenance` | 维护模式 | +| GET / POST | `/api/admin/site/smtp` | SMTP 配置读/写 | +| GET | `/api/admin/orders` | 订单列表 | +| DELETE | `/api/admin/orders/{id}` | 删除订单 | +| GET / POST / DELETE | `/api/admin/chat`、`/api/admin/chat/{account}` | 会话、回复、清空 | +| GET | `/api/admin/system-status` | 聚合 backend / mysql / redis / rabbitmq;JSON 外层为 `{"data":{…},"error":null}` | + +其它管理接口的成功响应多为 `{"data":…,"error":null}` 形式。 + +## 数据库表(摘要) + +开发环境常用 `spring.jpa.hibernate.ddl-auto: update` 同步表结构。 + +**products:** 商品信息;`fulfillment_type`:`card`(卡密库存)/ `fixed`(固定内容);`fixed_content` 不在公开列表返回;含价格、标签 JSON、封面、截图 JSON、`active`、`require_login`、`max_per_account`、`total_sold`、`view_count`、`delivery_mode` 等。 + +**product_codes:** 每行一条卡密,关联 `product_id`;固定内容商品可不设。 + +**orders:** `delivered_codes` JSON、用户信息、`status`、`delivery_mode`、`notify_email`、联系方式等。 + +**site_settings:** 键值对,如 `totalVisits`、`maintenance`、`smtpHost`、`smtpPassword` 等。 + +**wishlists:** 用户账号与商品 ID 唯一约束。 + +**chat_messages:** 会话消息、`from_admin`、发送时间等。 + +## 配置说明(application.yaml) + +| 项 | 说明 | +|----|------| +| `spring.datasource.*` | MySQL | +| `spring.jpa.hibernate.ddl-auto` | 如 `update` | +| `server.port` | HTTP 端口,需与前端 `VITE_API_BASE_URL` 一致 | +| `spring.rabbitmq.*` | Broker;`app.rabbitmq-enabled=false` 时不注册监听器与拓扑,避免无法连接时启动失败 | +| `spring.data.redis.*` | Redis;`app.redis-enabled=false` 时系统状态中 Redis 标记为未启用 | +| `app.admin-token` | 管理后台令牌 | +| `app.auth-api-url` | 用户 token 远程校验服务根地址(`POST …/api/auth/verify`) | +| `app.app-env` | `development` / `production` | +| `app.rabbitmq-enabled` | 是否启用 RabbitMQ 发信与消费者 | +| `app.redis-enabled` | 是否在系统状态中探测 Redis | +| `app.rabbitmq-env` / `app.redis-env` | `dev` / `prod`,影响交换机与队列名 | +| `app.cors-allowed-origins` | 逗号分隔的允许源 | +| `management.health.mail.enabled` | 建议 `false` 直至配置 SMTP | + +**CORS:** 见 `CorsConfig`,包含 `Authorization`、`X-Admin-Token` 等请求头。 + +## 前端联调 + +前端 axios `baseURL` 来自环境变量或当前页 `origin`,请指向本服务监听的 **协议 + 主机 + 端口**。 diff --git a/mengyastore-frontend/src/modules/admin/components/AdminSystemStatusPanel.vue b/mengyastore-frontend/src/modules/admin/components/AdminSystemStatusPanel.vue index 6eba3d6..19aa732 100644 --- a/mengyastore-frontend/src/modules/admin/components/AdminSystemStatusPanel.vue +++ b/mengyastore-frontend/src/modules/admin/components/AdminSystemStatusPanel.vue @@ -1,254 +1,254 @@ - - - + + + diff --git a/mengyastore-frontend/前端文档.md b/mengyastore-frontend/前端文档.md index 3546df7..84cf0a5 100644 --- a/mengyastore-frontend/前端文档.md +++ b/mengyastore-frontend/前端文档.md @@ -1,143 +1,143 @@ -# 萌芽小店 · 前端(mengyastore-frontend) - -基于 **Vue 3 + Vite** + **Tailwind CSS(Vite 插件)** 的数字商品前端:商店、结算(含扫码收款演示)、管理后台、收藏、订单、聊天、PWA。 - -## 技术依赖 - -| 包 | 用途 | -|----|------| -| vue ^3.x | 框架 | -| vite ^5.x | 构建 | -| @tailwindcss/vite | 工具类样式 | -| vue-router ^4.x | 路由 | -| pinia ^2.x | 状态(与 reactive 并存) | -| axios ^1.6 | HTTP | -| markdown-it ^14 | 商品描述 | -| vite-plugin-pwa | PWA | - -## 目录结构 - -``` -src/ -├── assets/ -│ ├── styles.css # 全局样式、.ghost / .page-card / . form-field 等 -│ └── payment/ # 支付宝/微信收款码 PNG(import 进 CheckoutPage) -├── router/index.js # 路由;维护模式 beforeEach -├── modules/ -│ ├── shared/ -│ │ ├── api.js # API 封装(含 fetchSystemStatus) -│ │ ├── auth.js # 登录态、getLoginUrl -│ │ ├── fulfillment.js # isFixedFulfillment、stockLabel、isSoldOutProduct -│ │ └── useWishlist.js -│ ├── store/ -│ │ ├── StorePage.vue -│ │ ├── ProductDetail.vue -│ │ ├── CheckoutPage.vue # 响应式布局、支付渠道 Tab、订单号复制、收款图 -│ │ └── components/ProductCard.vue -│ ├── admin/ -│ │ ├── AdminPage.vue # 侧边栏:商品/订单/聊天/设置/系统状态 -│ │ └── components/ -│ │ ├── AdminProductModal.vue # 发货方式:卡密 | 固定内容 -│ │ ├── AdminProductTable.vue -│ │ ├── AdminOrderTable.vue -│ │ ├── AdminChatPanel.vue -│ │ ├── AdminMaintenanceRow.vue -│ │ ├── AdminSMTPRow.vue -│ │ └── AdminSystemStatusPanel.vue -│ ├── user/MyOrdersPage.vue -│ ├── wishlist/WishlistPage.vue -│ ├── maintenance/MaintenancePage.vue -│ ├── auth/AuthCallback.vue -│ └── chat/ChatWidget.vue -└── App.vue # 顶栏:桌面横排导航;移动端汉堡菜单 + 下拉面板 -``` - -## 路由 - -| 路径 | 组件 | 说明 | -|------|------|------| -| `/` | StorePage | 商品列表 | -| `/product/:id` | ProductDetail | 详情 | -| `/checkout/:id` | CheckoutPage | 结算(路径参数为商品 id) | -| `/my/orders` | MyOrdersPage | 我的订单(建议登录) | -| `/wishlist` | WishlistPage | 收藏 | -| `/admin` | AdminPage | 管理后台(token) | -| `/auth/callback` | AuthCallback | OAuth 回调 | -| `/maintenance` | MaintenancePage | 维护中 | - -### 路由守卫 - -- 请求 `GET /api/site/maintenance`;维护中时跳转 `/maintenance`。 -- 豁免:`/maintenance`、`/wishlist`、`/admin`、`/auth/callback`(与 `router/index.js` 保持一致)。 - -## 认证 - -1. 「萌芽账号登录」→ SproutGate 登录页,`callback` 回指本站。 -2. Token 存 `localStorage`,`authState` 共享用户信息。 -3. 需登录接口带 `Authorization: Bearer `。 - -## 功能要点 - -### 商品与库存展示 - -- API 字段 **`fulfillmentType`**:`card` | `fixed`。 -- **`fixed`**:列表/详情/卡片显示库存为 **「不限」**,不按 `quantity===0` 售罄。 -- 工具函数:`modules/shared/fulfillment.js`。 - -### 管理后台 · 商品 - -- **卡密**:多行输入,逐条对应 `codes`。 -- **固定内容**:大文本 `fixedContent`;保存后无需维护条数。 - -### 结算页(CheckoutPage) - -- 栅格与表单单列适配窄屏。 -- 下单成功后:应付金额、支付宝/微信/赞赏 **本地图片** 切换、`订单号` **一键复制**、可选展开服务端 `qrCodeUrl`。 -- 数量上限:卡密=min(库存, 每账号限购);固定内容=仅每账号限购(若设置)。 - -### 顶栏(App.vue) - -- `md` 及以上:横向按钮。 -- 以下:汉堡按钮 + 全宽大标题项 + 遮罩;Escape 关闭;打开时锁 body 滚动。 - -### 系统状态 - -- `AdminSystemStatusPanel` 调用 `GET /api/admin/system-status`,展示后端/API、MySQL、Redis、RabbitMQ(需管理 Token)。 - -### PWA - -- `vite-plugin-pwa`:`registerType: 'autoUpdate'`,构建后生成 SW;根组件有更新 Toast 与 `SplashScreen`。 - -## 开发命令 - -```bash -npm install -npm run dev # :5173 -npm run build # dist/ -npm run preview -``` - -### 环境变量 - -开发示例(`.env.development`): - -```env -VITE_API_BASE_URL=http://localhost:8080 -``` - -生产部署设置实际 API 基地址(或同源反代可不设)。 - -## 部署(静态资源) - -```nginx -location / { - try_files $uri $uri/ /index.html; -} -location /api/ { - proxy_pass http://127.0.0.1:8080; -} -``` - ---- - -字体与色板以 `src/assets/styles.css` 及 Tailwind 主题为准;文档若与代码不一致,以仓库当前实现为准。 +# 萌芽小店 · 前端(mengyastore-frontend) + +基于 **Vue 3 + Vite** + **Tailwind CSS(Vite 插件)** 的数字商品前端:商店、结算(含扫码收款演示)、管理后台、收藏、订单、聊天、PWA。 + +## 技术依赖 + +| 包 | 用途 | +|----|------| +| vue ^3.x | 框架 | +| vite ^5.x | 构建 | +| @tailwindcss/vite | 工具类样式 | +| vue-router ^4.x | 路由 | +| pinia ^2.x | 状态(与 reactive 并存) | +| axios ^1.6 | HTTP | +| markdown-it ^14 | 商品描述 | +| vite-plugin-pwa | PWA | + +## 目录结构 + +``` +src/ +├── assets/ +│ ├── styles.css # 全局样式、.ghost / .page-card / . form-field 等 +│ └── payment/ # 支付宝/微信收款码 PNG(import 进 CheckoutPage) +├── router/index.js # 路由;维护模式 beforeEach +├── modules/ +│ ├── shared/ +│ │ ├── api.js # API 封装(含 fetchSystemStatus) +│ │ ├── auth.js # 登录态、getLoginUrl +│ │ ├── fulfillment.js # isFixedFulfillment、stockLabel、isSoldOutProduct +│ │ └── useWishlist.js +│ ├── store/ +│ │ ├── StorePage.vue +│ │ ├── ProductDetail.vue +│ │ ├── CheckoutPage.vue # 响应式布局、支付渠道 Tab、订单号复制、收款图 +│ │ └── components/ProductCard.vue +│ ├── admin/ +│ │ ├── AdminPage.vue # 侧边栏:商品/订单/聊天/设置/系统状态 +│ │ └── components/ +│ │ ├── AdminProductModal.vue # 发货方式:卡密 | 固定内容 +│ │ ├── AdminProductTable.vue +│ │ ├── AdminOrderTable.vue +│ │ ├── AdminChatPanel.vue +│ │ ├── AdminMaintenanceRow.vue +│ │ ├── AdminSMTPRow.vue +│ │ └── AdminSystemStatusPanel.vue +│ ├── user/MyOrdersPage.vue +│ ├── wishlist/WishlistPage.vue +│ ├── maintenance/MaintenancePage.vue +│ ├── auth/AuthCallback.vue +│ └── chat/ChatWidget.vue +└── App.vue # 顶栏:桌面横排导航;移动端汉堡菜单 + 下拉面板 +``` + +## 路由 + +| 路径 | 组件 | 说明 | +|------|------|------| +| `/` | StorePage | 商品列表 | +| `/product/:id` | ProductDetail | 详情 | +| `/checkout/:id` | CheckoutPage | 结算(路径参数为商品 id) | +| `/my/orders` | MyOrdersPage | 我的订单(建议登录) | +| `/wishlist` | WishlistPage | 收藏 | +| `/admin` | AdminPage | 管理后台(token) | +| `/auth/callback` | AuthCallback | OAuth 回调 | +| `/maintenance` | MaintenancePage | 维护中 | + +### 路由守卫 + +- 请求 `GET /api/site/maintenance`;维护中时跳转 `/maintenance`。 +- 豁免:`/maintenance`、`/wishlist`、`/admin`、`/auth/callback`(与 `router/index.js` 保持一致)。 + +## 认证 + +1. 「萌芽账号登录」→ SproutGate 登录页,`callback` 回指本站。 +2. Token 存 `localStorage`,`authState` 共享用户信息。 +3. 需登录接口带 `Authorization: Bearer `。 + +## 功能要点 + +### 商品与库存展示 + +- API 字段 **`fulfillmentType`**:`card` | `fixed`。 +- **`fixed`**:列表/详情/卡片显示库存为 **「不限」**,不按 `quantity===0` 售罄。 +- 工具函数:`modules/shared/fulfillment.js`。 + +### 管理后台 · 商品 + +- **卡密**:多行输入,逐条对应 `codes`。 +- **固定内容**:大文本 `fixedContent`;保存后无需维护条数。 + +### 结算页(CheckoutPage) + +- 栅格与表单单列适配窄屏。 +- 下单成功后:应付金额、支付宝/微信/赞赏 **本地图片** 切换、`订单号` **一键复制**、可选展开服务端 `qrCodeUrl`。 +- 数量上限:卡密=min(库存, 每账号限购);固定内容=仅每账号限购(若设置)。 + +### 顶栏(App.vue) + +- `md` 及以上:横向按钮。 +- 以下:汉堡按钮 + 全宽大标题项 + 遮罩;Escape 关闭;打开时锁 body 滚动。 + +### 系统状态 + +- `AdminSystemStatusPanel` 调用 `GET /api/admin/system-status`,展示后端/API、MySQL、Redis、RabbitMQ(需管理 Token)。 + +### PWA + +- `vite-plugin-pwa`:`registerType: 'autoUpdate'`,构建后生成 SW;根组件有更新 Toast 与 `SplashScreen`。 + +## 开发命令 + +```bash +npm install +npm run dev # :5173 +npm run build # dist/ +npm run preview +``` + +### 环境变量 + +开发示例(`.env.development`): + +```env +VITE_API_BASE_URL=http://localhost:8080 +``` + +生产部署设置实际 API 基地址(或同源反代可不设)。 + +## 部署(静态资源) + +```nginx +location / { + try_files $uri $uri/ /index.html; +} +location /api/ { + proxy_pass http://127.0.0.1:8080; +} +``` + +--- + +字体与色板以 `src/assets/styles.css` 及 Tailwind 主题为准;文档若与代码不一致,以仓库当前实现为准。 diff --git a/萌芽小店项目经历.md b/萌芽小店项目经历-golang.md similarity index 100% rename from 萌芽小店项目经历.md rename to 萌芽小店项目经历-golang.md diff --git a/萌芽小店项目经历-java.md b/萌芽小店项目经历-java.md new file mode 100644 index 0000000..8ecdeca --- /dev/null +++ b/萌芽小店项目经历-java.md @@ -0,0 +1,5 @@ +**项目名称:** 萌芽小店-网关登录的轻量级商城系统(Java 后端) +**时间:** 2026.01 – 2026.04 +**技术栈:** Java 17、Spring Boot 3(Spring Web、Spring Data JPA、Spring AMQP、Spring Data Redis、Validation、Mail、Actuator)、MySQL、Hibernate、RabbitMQ / Redis、Maven、Lombok +**描述:** 基于 Spring Boot 的后端服务,与前端仓库配套 REST 接口:商品展示与下单、发货、订单邮件通知(SMTP 存库配置,可选 RabbitMQ 异步投递与失败回退)、购物车、在线客服、站点维护模式与访问量统计;管理端覆盖商品与订单、对话、SMTP 与系统状态(MySQL / Redis / RabbitMQ 探活与连接池等指标)。分层为 Controller、Service、Repository 与 JPA 实体,拦截器区分公开接口、Bearer 用户鉴权(第三方认证网关 HTTP 校验)与管理令牌;全局异常与统一响应体。浏览量在单机场景下进程内去重 +**项目地址:** https://store.smyhub.com | **开源地址:** https://github.com/shumengya/mengyastore