Files

293 lines
11 KiB
Go
Raw Permalink 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 萌芽小店电商后端 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 为 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/announcement", adminHandler.GetAnnouncement)
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.PUT("/api/admin/announcement", adminHandler.SetAnnouncement)
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)
}
}