// @title 萌芽小店 API // @version 1.0.0-go // @description 萌芽小店电商后端 HTTP API:商品、下单结账、订单与支付、站点统计与访问、维护模式、收藏、用户/管理员聊天、萌芽支付 Webhook,以及管理端商品/订单/站点与运维状态等。用户身份由萌芽账户认证中心(SproutGate)签发令牌并完成校验。 // @description // @description **运行环境与基地址** // @description - **本地开发**:监听地址由环境变量 `HTTP_LISTEN_ADDR` 决定,未设置时默认为 `:8080`,即常见入口 `http://localhost:8080`。可在浏览器打开 `http://localhost:8080/swagger/index.html` 查看本页同款文档并调试。 // @description - **生产部署**:线上前台/API 域名为 `https://store.shumengya.top`(HTTPS) // @description // @description **说明**:本 OpenAPI 由代码注释生成;与进程真实监听、反向代理头无关,仅作契约参考。 // @host localhost:8080 // @BasePath / // @schemes http https // @securityDefinitions.apikey BearerAuth // @in header // @name Authorization // @description 用户访问令牌:请求头 `Authorization`,值为 `Bearer ` 后接 SproutGate 返回的 JWT/access token(部分公开接口可不携带)。 // @securityDefinitions.apikey AdminToken // @in header // @name X-Admin-Token // @description 管理端访问令牌:与进程环境变量 `ADMIN_TOKEN` 一致。部分管理路由也可使用 `Authorization` 头或 Query `token`(以具体路由实现为准)。 // @securityDefinitions.apikey WebhookSecret // @in header // @name X-Webhook-Secret // @description 当服务端配置了 `WEBHOOK_MENGYA_SECRET` 时,萌芽支付到账回调 `POST /api/webhooks/mengya-pay` 需携带本头且值与密钥完全一致,否则返回 403。 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 访问服务根路径 `/` 返回 JSON:服务简介、本地与生产环境说明、主要业务路由分组、运行版本与时间戳。完整请求/响应模型请使用 Swagger UI:`GET /swagger/index.html`。 // @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)校验。", "environments": gin.H{ "local": "本地开发:默认 http://localhost:8080(以 HTTP_LISTEN_ADDR 为准)", "production": "生产部署:https://store.shumengya.top(若经反代请用实际对外地址)", }, "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 健康检查 // @Description 用于负载均衡或编排探活:`status` 恒为 ok 表示 HTTP 服务存活;`rabbitmq` 为 `disabled`(未启用消息队列客户端)、`ok`(连接可用)或以 `error:` 开头的错误片段。 // @Tags 健康检查 // @Produce json // @Success 200 {object} map[string]interface{} "status 为 ok;rabbitmq 为 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) } }