Files
mengyastore/mengyastore-backend-go/main.go
2026-05-13 12:11:46 +08:00

277 lines
9.3 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// @title 萌芽小店 API
// @version 1.0.0-go
// @description 商品、下单、站点统计、收藏与客服聊天用户登录由萌芽账户认证中心SproutGate校验。监听地址以 HTTP_LISTEN_ADDR 为准。
// @host localhost:8080
// @BasePath /
// @schemes http https
// @securityDefinitions.apikey BearerAuth
// @in header
// @name Authorization
// @description 用户访问令牌,格式: Bearer 空格 + token
// @securityDefinitions.apikey AdminToken
// @in header
// @name X-Admin-Token
// @description 管理端令牌(也可使用 Authorization 头或 query token见各接口说明
// @securityDefinitions.apikey WebhookSecret
// @in header
// @name X-Webhook-Secret
// @description 与服务端 WEBHOOK_MENGYA_SECRET 一致时校验萌芽支付 Webhook
package main
import (
"context"
"log"
"log/slog"
"net/http"
"os"
"time"
"github.com/gin-contrib/cors"
"github.com/gin-gonic/gin"
swaggerFiles "github.com/swaggo/files"
ginSwagger "github.com/swaggo/gin-swagger"
_ "mengyastore-backend/docs"
"mengyastore-backend/internal/auth"
"mengyastore-backend/internal/config"
"mengyastore-backend/internal/database"
"mengyastore-backend/internal/handlers"
"mengyastore-backend/internal/mq"
"mengyastore-backend/internal/storage"
)
const apiVersion = "1.0.0-go"
func initLogging() {
h := slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
Level: slog.LevelInfo,
ReplaceAttr: func(groups []string, a slog.Attr) slog.Attr {
if a.Key != slog.TimeKey {
return a
}
t := a.Value.Time()
if t.IsZero() {
return a
}
return slog.String("time", t.Format("2006-01-02 15:04:05"))
},
})
slog.SetDefault(slog.New(h))
}
func accessLog() gin.HandlerFunc {
return func(c *gin.Context) {
t0 := time.Now()
path := c.Request.URL.Path
q := c.Request.URL.RawQuery
c.Next()
p := path
if q != "" {
p = path + "?" + q
}
slog.Info("http",
"method", c.Request.Method,
"path", p,
"status", c.Writer.Status(),
"ms", time.Since(t0).Milliseconds(),
"ip", c.ClientIP(),
)
}
}
// rootAPIInfo 浏览器或客户端访问服务根路径时返回 API 说明JSON
// @Summary API 根信息与端点索引
// @Description 返回服务描述、主要路径指引与版本(非 OpenAPI 详尽列表,完整契约见 /swagger
// @Tags 元信息
// @Produce json
// @Success 200 {object} map[string]interface{}
// @Router / [get]
func rootAPIInfo(c *gin.Context) {
loc, err := time.LoadLocation("Asia/Shanghai")
if err != nil {
loc = time.FixedZone("CST", 8*3600)
}
c.JSON(http.StatusOK, gin.H{
"description": "萌芽小店电商后端商品、下单、站点统计、收藏与客服聊天用户登录认证由萌芽账户认证中心SproutGate校验。",
"endpoints": gin.H{
"health": "/api/health",
"public": "/api/products, /api/checkout, /api/stats, /api/site/*, POST /api/products/:id/view",
"orders": "/api/orders (Bearer), GET /api/orders/:id/payment-status, POST /api/orders/:id/confirm",
"webhooks": "POST /api/webhooks/mengya-pay (萌芽支付到账,可选 X-Webhook-Secret)",
"wishlist": "/api/wishlist (Bearer)",
"chat_user": "/api/chat/messages (Bearer)",
"chat_admin": "/api/admin/chat/* (X-Admin-Token)",
"admin": "/api/admin/* 含 verify、products、site、smtp、orders、system-status (X-Admin-Token)",
"auth": "用户认证 via 萌芽认证中心 (Authorization: Bearer)",
},
"message": "萌芽小店 后端 API 服务运行中",
"timestamp": time.Now().In(loc).Format(time.RFC3339),
"version": apiVersion,
})
}
// HealthCheck 返回进程与可选 RabbitMQ 探活结果。
// @Summary 健康检查
// @Tags 健康检查
// @Produce json
// @Success 200 {object} map[string]interface{} "status okrabbitmq 为 disabled|ok|error:..."
// @Router /api/health [get]
func HealthCheck(mqClient *mq.Client) gin.HandlerFunc {
return func(c *gin.Context) {
resp := gin.H{"status": "ok", "rabbitmq": "disabled"}
if mqClient != nil {
if err := mqClient.Ping(); err != nil {
resp["rabbitmq"] = "error: " + err.Error()
} else {
resp["rabbitmq"] = "ok"
}
}
c.JSON(http.StatusOK, resp)
}
}
func main() {
initLogging()
cfg, err := config.Load()
if err != nil {
log.Fatalf("加载配置失败: %v", err)
}
startedAt := time.Now()
// 初始化数据库连接
db, err := database.Open(cfg.DatabaseDSN)
if err != nil {
log.Fatalf("初始化数据库失败: %v", err)
}
store, err := storage.NewProductStore(db)
if err != nil {
log.Fatalf("初始化商品存储失败: %v", err)
}
orderStore, err := storage.NewOrderStore(db)
if err != nil {
log.Fatalf("初始化订单存储失败: %v", err)
}
siteStore, err := storage.NewSiteStore(db)
if err != nil {
log.Fatalf("初始化站点存储失败: %v", err)
}
wishlistStore, err := storage.NewWishlistStore(db)
if err != nil {
log.Fatalf("初始化收藏夹存储失败: %v", err)
}
chatStore, err := storage.NewChatStore(db)
if err != nil {
log.Fatalf("初始化聊天存储失败: %v", err)
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
var mqClient *mq.Client
if cfg.RabbitMQEnabled {
if cfg.RabbitMQURL == "" {
slog.Warn("mq", "event", "enabled_no_url", "hint", "set RABBITMQ_URL or RABBITMQ_PASSWORD")
} else {
c, err := mq.New(cfg.RabbitMQURL, cfg.RabbitMQEnv)
if err != nil {
slog.Warn("mq", "event", "connect_failed", "err", err)
} else {
mqClient = c
defer mqClient.Close()
go mqClient.StartConsumer(ctx, siteStore)
slog.Info("mq", "event", "connected", "env", cfg.RabbitMQEnv, "exchange", mq.ExchangeName(cfg.RabbitMQEnv))
}
}
}
if cfg.GinDebug {
gin.SetMode(gin.DebugMode)
} else {
gin.SetMode(gin.ReleaseMode)
}
r := gin.New()
if err := r.SetTrustedProxies(nil); err != nil {
slog.Warn("server", "event", "trusted_proxies", "err", err)
}
r.Use(gin.Recovery())
r.Use(accessLog())
r.Use(cors.New(cors.Config{
AllowOrigins: []string{"*"},
AllowMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"},
AllowHeaders: []string{"Origin", "Content-Type", "Accept", "Authorization", "X-Admin-Token", "X-Webhook-Secret"},
ExposeHeaders: []string{"Content-Length"},
AllowCredentials: false,
MaxAge: 12 * time.Hour,
}))
r.GET("/", rootAPIInfo)
r.GET("/api/health", HealthCheck(mqClient))
if cfg.EnableSwagger {
r.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerFiles.Handler))
slog.Info("server", "event", "swagger", "path", "/swagger/index.html")
}
authClient := auth.NewSproutGateClient(cfg.AuthAPIURL)
publicHandler := handlers.NewPublicHandler(store)
adminHandler := handlers.NewAdminHandler(store, cfg, siteStore, orderStore, chatStore)
orderHandler := handlers.NewOrderHandler(store, orderStore, siteStore, authClient, mqClient, cfg.PaymentPendingTTLSecs, cfg.WebhookMengyaSecret)
go orderHandler.RunExpiredPaymentSweep(ctx, 25*time.Second)
statsHandler := handlers.NewStatsHandler(orderStore, siteStore)
wishlistHandler := handlers.NewWishlistHandler(wishlistStore, authClient)
chatHandler := handlers.NewChatHandler(chatStore, authClient)
statusHandler := handlers.NewSystemStatusHandler(cfg, db, mqClient, startedAt)
r.GET("/api/products", publicHandler.ListProducts)
r.POST("/api/checkout", orderHandler.CreateOrder)
r.GET("/api/orders/:id/payment-status", orderHandler.GetOrderPaymentStatus)
r.POST("/api/webhooks/mengya-pay", orderHandler.MengyaPaymentWebhook)
r.POST("/api/products/:id/view", publicHandler.RecordProductView)
r.GET("/api/stats", statsHandler.GetStats)
r.POST("/api/site/visit", statsHandler.RecordVisit)
r.GET("/api/site/maintenance", statsHandler.GetMaintenance)
r.GET("/api/orders", orderHandler.ListMyOrders)
r.POST("/api/orders/:id/confirm", orderHandler.ConfirmOrder)
r.POST("/api/orders/:id/cancel", orderHandler.CancelOrder)
r.POST("/api/admin/verify", adminHandler.VerifyAdminToken)
r.GET("/api/admin/products", adminHandler.ListAllProducts)
r.POST("/api/admin/products", adminHandler.CreateProduct)
r.PUT("/api/admin/products/:id", adminHandler.UpdateProduct)
r.PATCH("/api/admin/products/:id/status", adminHandler.ToggleProduct)
r.DELETE("/api/admin/products/:id", adminHandler.DeleteProduct)
r.POST("/api/admin/site/maintenance", adminHandler.SetMaintenance)
r.GET("/api/admin/site/smtp", adminHandler.GetSMTPConfig)
r.POST("/api/admin/site/smtp", adminHandler.SetSMTPConfig)
r.GET("/api/admin/orders", adminHandler.ListAllOrders)
r.DELETE("/api/admin/orders/:id", adminHandler.DeleteOrder)
r.GET("/api/admin/system-status", statusHandler.GetSystemStatus)
r.GET("/api/wishlist", wishlistHandler.GetWishlist)
r.POST("/api/wishlist", wishlistHandler.AddToWishlist)
r.DELETE("/api/wishlist/:id", wishlistHandler.RemoveFromWishlist)
// 用户聊天路由
r.GET("/api/chat/messages", chatHandler.GetMyMessages)
r.POST("/api/chat/messages", chatHandler.SendMyMessage)
// 管理员聊天路由
r.GET("/api/admin/chat", adminHandler.GetAllConversations)
r.GET("/api/admin/chat/:account", adminHandler.GetConversation)
r.POST("/api/admin/chat/:account", adminHandler.AdminReply)
r.DELETE("/api/admin/chat/:account", adminHandler.ClearConversation)
slog.Info("server", "event", "listen", "addr", cfg.HTTPListenAddr)
if err := r.Run(cfg.HTTPListenAddr); err != nil {
slog.Error("server", "event", "exit", "err", err)
os.Exit(1)
}
}