Update mengyastore

This commit is contained in:
2026-05-13 12:11:46 +08:00
parent ad54b1eb7e
commit 37983f3b60
156 changed files with 10064 additions and 5681 deletions

View File

@@ -1,13 +1,40 @@
// @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"
@@ -17,7 +44,98 @@ import (
"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)
@@ -57,47 +175,55 @@ func main() {
var mqClient *mq.Client
if cfg.RabbitMQEnabled {
if cfg.RabbitMQURL == "" {
log.Println("[MQ] 已启用但未配置连接串:请设置 RABBITMQ_URL,或设置 RABBITMQ_PASSWORD(及可选 RABBITMQ_HOST/RABBITMQ_VHOST")
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 {
log.Printf("[MQ] 连接失败,将仅用直发邮件降级: %v", err)
slog.Warn("mq", "event", "connect_failed", "err", err)
} else {
mqClient = c
defer mqClient.Close()
go mqClient.StartConsumer(ctx, siteStore)
log.Printf("[MQ] 已连接 env=%s exchange=%s", cfg.RabbitMQEnv, mq.ExchangeName(cfg.RabbitMQEnv))
slog.Info("mq", "event", "connected", "env", cfg.RabbitMQEnv, "exchange", mq.ExchangeName(cfg.RabbitMQEnv))
}
}
}
r := gin.Default()
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"},
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("/api/health", 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)
})
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)
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)
@@ -105,12 +231,15 @@ func main() {
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)
@@ -139,8 +268,9 @@ func main() {
r.POST("/api/admin/chat/:account", adminHandler.AdminReply)
r.DELETE("/api/admin/chat/:account", adminHandler.ClearConversation)
log.Println("萌芽小店后端启动于 http://localhost:8080")
if err := r.Run(":8080"); err != nil {
log.Fatalf("服务器启动失败: %v", err)
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)
}
}