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

@@ -10,7 +10,7 @@ import (
"github.com/joho/godotenv"
)
// Config is populated entirely from environment variables (optionally set via .env — see Load).
// Config 全部由环境变量填充(可通过工作目录下 .env.development 或 ENV_FILE 设置,见 Load)。
type Config struct {
AppEnv string
AdminToken string
@@ -31,17 +31,26 @@ type Config struct {
RabbitMQEnabled bool
RabbitMQURL string
RabbitMQEnv string
WebhookMengyaSecret string
PaymentPendingTTLSecs int
// GinDebug为 true 时启用 Gin Debug打印路由表等默认 false日志更干净。
GinDebug bool
// EnableSwagger为 true 时注册 /swagger UI默认在 GIN_DEBUG 或 ENABLE_SWAGGER 为真时为 true。
EnableSwagger bool
}
// App environment: affects defaults when DATABASE_DSN / RABBITMQ_ENV are omitted.
// APP_ENV=production → prod DB default, RABBITMQ_ENV default prod
// Otherwise → development defaults (test DB, rabbit dev).
// 应用环境:在未设置 DATABASE_DSN / RABBITMQ_ENV 时决定默认值。
// APP_ENV=production → 生产库 DSN、RABBITMQ_ENV 默认 prod
// 否则 → 开发默认测试库、RabbitMQ 开发环境)。
const (
EnvDevelopment = "development"
EnvProduction = "production"
)
// Built-in DSN fallbacks when DATABASE_DSN is empty.
// 内建 DSN DATABASE_DSN 为空时使用。
const (
TestDSN = "mengyastore-test:mengyastore-test@tcp(10.1.1.100:3306)/mengyastore-test?charset=utf8mb4&parseTime=True&loc=Local"
ProdDSN = "mengyastore:mengyastore@tcp(192.168.1.100:3306)/mengyastore?charset=utf8mb4&parseTime=True&loc=Local"
@@ -60,26 +69,33 @@ const (
DefaultRedisPassword = "tyh@19900420"
)
// Load reads optional .env file(s) then builds Config from the process environment.
// Load 读取可选的 env 文件,再从进程环境组装 Config。
//
// Dotenv resolution order:
// 1. File named by ENV_FILE (if set)
// 2. .env in current working directory
// 解析 env 文件的顺序:
// 1. 环境变量 ENV_FILE 指定的文件(若设置了)
// 2. 当前工作目录下的 .env.development不存在则忽略
//
// Variables (all optional unless noted):
// 环境变量说明(除非注明否则均可选):
//
// APP_ENV — development | production (default: development)
// ADMIN_TOKEN — admin API token (default: changeme)
// AUTH_API_URL — SproutGate base URL
// DATABASE_DSN — MySQL DSN; if empty, uses TestDSN or ProdDSN from APP_ENV
// APP_ENV — development | production(默认 development
// ADMIN_TOKEN — 管理端 API 令牌(默认 changeme
// AUTH_API_URL — 萌芽认证中心 SproutGate URL
// DATABASE_DSN — MySQL DSN;为空则按 APP_ENV 选用 TestDSN / ProdDSN
// RABBITMQ_ENABLED — true/false
// RABBITMQ_URL — full amqp URL
// RABBITMQ_ENV — dev | prod (default from APP_ENV)
// RABBITMQ_PASSWORD — if RABBITMQ_URL empty but RABBITMQ_ENABLED, builds URL with host/vhost defaults
// RABBITMQ_URL — 完整 amqp URL
// RABBITMQ_ENV — dev | prod(默认随 APP_ENV
// RABBITMQ_PASSWORD — 若已启用 MQ 但未设 RABBITMQ_URL则用默认主机/vhost 拼 URL 时需要口令
//
// HTTP_LISTEN_ADDR — 进程监听,默认 :8080
// PUBLIC_API_BASE_URL — 对外 API 基地址(反向代理场景)
//
// WEBHOOK_MENGYA_SECRET — 若设置,到账 Webhook 须带相同请求头 X-Webhook-Secret
// PAYMENT_PENDING_SECONDS — 萌芽支付待支付超时(秒,默认 60
//
// GIN_DEBUG — 1/true/yes/onGin Debug默认关闭为真时同时开启 Swagger UI/swagger
// ENABLE_SWAGGER — 1/true/yes/on启用 Swagger UI与 GIN_DEBUG 任一为真即注册 /swagger
// WEBHOOK_LOG_VERBOSE — 1/true萌芽 Webhook 日志附带 notice/body 摘要(排查渠道用)
//
// REDIS_ENABLED — 默认 true显式 false/0/off 则关闭
// REDIS_ADDR — 默认 development→TestRedisAddrproduction→ProdRedisAddr
// REDIS_PASSWORD — 默认 DefaultRedisPassword建议生产用环境变量覆盖
@@ -168,10 +184,34 @@ func Load() (*Config, error) {
}
}
cfg.WebhookMengyaSecret = strings.TrimSpace(os.Getenv("WEBHOOK_MENGYA_SECRET"))
if sec := strings.TrimSpace(os.Getenv("PAYMENT_PENDING_SECONDS")); sec != "" {
if n, err := strconv.Atoi(sec); err == nil && n > 0 {
cfg.PaymentPendingTTLSecs = n
}
}
if cfg.PaymentPendingTTLSecs <= 0 {
cfg.PaymentPendingTTLSecs = 60
}
cfg.GinDebug = envTruthy(os.Getenv("GIN_DEBUG"))
cfg.EnableSwagger = cfg.GinDebug || envTruthy(os.Getenv("ENABLE_SWAGGER"))
return cfg, nil
}
// redisEnabledFromEnv defaults to true unless explicitly turned off.
// envTruthy将 1、true、yes、on 视为真(不区分大小写)。
func envTruthy(s string) bool {
switch strings.ToLower(strings.TrimSpace(s)) {
case "1", "true", "yes", "on":
return true
default:
return false
}
}
// redisEnabledFromEnv除非显式关闭否则默认启用 Redis。
func redisEnabledFromEnv(v string) bool {
s := strings.ToLower(strings.TrimSpace(v))
if s == "false" || s == "0" || s == "no" || s == "off" {
@@ -188,8 +228,8 @@ func loadDotenv() {
_ = godotenv.Load(f)
return
}
// Standard local file; ignore missing.
_ = godotenv.Load(filepath.Clean(".env"))
// 本地开发默认文件名;不存在则忽略。
_ = godotenv.Load(filepath.Clean(".env.development"))
}
func normalizeAppEnv(s string) string {

View File

@@ -2,6 +2,8 @@ package database
import (
"log"
"log/slog"
"os"
"time"
"gorm.io/driver/mysql"
@@ -11,8 +13,17 @@ import (
// Open 初始化 GORM 数据库连接并自动同步所有表结构。
func Open(dsn string) (*gorm.DB, error) {
gormLogger := logger.New(
log.New(os.Stdout, "", log.LstdFlags|log.Lmicroseconds),
logger.Config{
SlowThreshold: 500 * time.Millisecond,
LogLevel: logger.Warn,
IgnoreRecordNotFoundError: true,
Colorful: false,
},
)
db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{
Logger: logger.Default.LogMode(logger.Warn),
Logger: gormLogger,
})
if err != nil {
return nil, err
@@ -29,7 +40,7 @@ func Open(dsn string) (*gorm.DB, error) {
if err := autoMigrate(db); err != nil {
return nil, err
}
log.Println("[DB] 数据库连接成功,表结构已同步")
slog.Info("db", "event", "ready", "migrate", "ok")
return db, nil
}

View File

@@ -7,7 +7,7 @@ import (
"time"
)
// StringSlice is a JSON-serialized string slice stored as a MySQL TEXT/JSON column.
// StringSlice 表示以 JSON 序列化形式存入 MySQL TEXT/JSON 列的字符串切片。
type StringSlice []string
func (s StringSlice) Value() (driver.Value, error) {
@@ -31,9 +31,9 @@ func (s *StringSlice) Scan(src any) error {
return json.Unmarshal(raw, s)
}
// ─── Products ────────────────────────────────────────────────────────────────
// ─── 商品 ─────────────────────────────────────────────────────────────────────
// ProductRow is the GORM model for the `products` table.
// ProductRow `products` 表的 GORM 模型。
type ProductRow struct {
ID string `gorm:"primaryKey;size:36"`
Name string `gorm:"size:255;not null"`
@@ -55,12 +55,14 @@ type ProductRow struct {
FixedContent string `gorm:"type:text"`
ShowNote bool `gorm:"default:true"`
ShowContact bool `gorm:"default:true"`
// PaymentQrURLs JSON萌芽支付等多张收款码图片链接。
PaymentQrURLs StringSlice `gorm:"column:payment_qr_urls;type:json"`
CreatedAt time.Time `gorm:"index"`
}
func (ProductRow) TableName() string { return "products" }
// ProductCodeRow stores individual codes for a product (one row per code).
// ProductCodeRow 单条卡密一行,按商品维度存储。
type ProductCodeRow struct {
ID uint `gorm:"primaryKey;autoIncrement"`
ProductID string `gorm:"size:36;not null;index"`
@@ -69,7 +71,7 @@ type ProductCodeRow struct {
func (ProductCodeRow) TableName() string { return "product_codes" }
// ─── Orders ──────────────────────────────────────────────────────────────────
// ─── 订单 ─────────────────────────────────────────────────────────────────────
type OrderRow struct {
ID string `gorm:"primaryKey;size:36"`
@@ -85,14 +87,20 @@ type OrderRow struct {
ContactPhone string `gorm:"size:50"`
ContactEmail string `gorm:"size:255"`
NotifyEmail string `gorm:"size:255"`
// PaymentMethod 如 mengya历史订单或免费单可能为空。
PaymentMethod string `gorm:"size:32;default:'';index"`
// PaymentExpectedTotal 待支付时应付快照(元),用于 Webhook 金额核对。
PaymentExpectedTotal float64 `gorm:"default:0"`
// PaymentExpiresAt 待支付截止时间;非待支付订单为 NULL。
PaymentExpiresAt *time.Time `gorm:"index"`
CreatedAt time.Time
}
func (OrderRow) TableName() string { return "orders" }
// ─── Site settings ───────────────────────────────────────────────────────────
// ─── 站点设置 ─────────────────────────────────────────────────────────────────
// SiteSettingRow stores arbitrary key-value pairs for site-wide settings.
// SiteSettingRow 站点级键值配置。
type SiteSettingRow struct {
Key string `gorm:"primaryKey;size:64"`
Value string `gorm:"type:text"`
@@ -100,7 +108,7 @@ type SiteSettingRow struct {
func (SiteSettingRow) TableName() string { return "site_settings" }
// ─── Wishlists ───────────────────────────────────────────────────────────────
// ─── 收藏夹 ───────────────────────────────────────────────────────────────────
type WishlistRow struct {
ID uint `gorm:"primaryKey;autoIncrement"`
@@ -110,7 +118,7 @@ type WishlistRow struct {
func (WishlistRow) TableName() string { return "wishlists" }
// ─── Chat messages ───────────────────────────────────────────────────────────
// ─── 聊天消息 ─────────────────────────────────────────────────────────────────
type ChatMessageRow struct {
ID string `gorm:"primaryKey;size:36"`

View File

@@ -8,6 +8,14 @@ import (
)
// GetAllConversations 返回所有用户会话列表。
// @Summary 全部会话列表
// @Tags 管理端-聊天
// @Produce json
// @Security AdminToken
// @Success 200 {object} map[string]interface{}
// @Failure 401 {object} SwaggerErrorBody
// @Failure 500 {object} SwaggerErrorBody
// @Router /api/admin/chat [get]
func (h *AdminHandler) GetAllConversations(c *gin.Context) {
if !h.requireAdmin(c) {
return
@@ -21,6 +29,16 @@ func (h *AdminHandler) GetAllConversations(c *gin.Context) {
}
// GetConversation 返回指定账号的全部消息记录。
// @Summary 指定用户聊天记录
// @Tags 管理端-聊天
// @Produce json
// @Security AdminToken
// @Param account path string true "用户账号"
// @Success 200 {object} SwaggerMessagesWrap
// @Failure 400 {object} SwaggerErrorBody
// @Failure 401 {object} SwaggerErrorBody
// @Failure 500 {object} SwaggerErrorBody
// @Router /api/admin/chat/{account} [get]
func (h *AdminHandler) GetConversation(c *gin.Context) {
if !h.requireAdmin(c) {
return
@@ -38,11 +56,23 @@ func (h *AdminHandler) GetConversation(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"data": gin.H{"messages": msgs}})
}
type adminChatPayload struct {
type AdminChatPayload struct {
Content string `json:"content"`
}
// AdminReply 向指定用户发送管理员回复。
// @Summary 管理员回复
// @Tags 管理端-聊天
// @Accept json
// @Produce json
// @Security AdminToken
// @Param account path string true "用户账号"
// @Param body body AdminChatPayload true "回复内容"
// @Success 200 {object} SwaggerOneChatMsgWrap
// @Failure 400 {object} SwaggerErrorBody
// @Failure 401 {object} SwaggerErrorBody
// @Failure 500 {object} SwaggerErrorBody
// @Router /api/admin/chat/{account} [post]
func (h *AdminHandler) AdminReply(c *gin.Context) {
if !h.requireAdmin(c) {
return
@@ -52,7 +82,7 @@ func (h *AdminHandler) AdminReply(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": "缺少账号参数"})
return
}
var payload adminChatPayload
var payload AdminChatPayload
if err := c.ShouldBindJSON(&payload); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数有误"})
return
@@ -71,6 +101,16 @@ func (h *AdminHandler) AdminReply(c *gin.Context) {
}
// ClearConversation 清除与指定用户的全部消息记录。
// @Summary 清空会话
// @Tags 管理端-聊天
// @Produce json
// @Security AdminToken
// @Param account path string true "用户账号"
// @Success 200 {object} SwaggerBoolOKWrap
// @Failure 400 {object} SwaggerErrorBody
// @Failure 401 {object} SwaggerErrorBody
// @Failure 500 {object} SwaggerErrorBody
// @Router /api/admin/chat/{account} [delete]
func (h *AdminHandler) ClearConversation(c *gin.Context) {
if !h.requireAdmin(c) {
return

View File

@@ -6,6 +6,15 @@ import (
"github.com/gin-gonic/gin"
)
// ListAllOrders 管理端全部订单。
// @Summary 全部订单(管理)
// @Tags 管理端-订单
// @Produce json
// @Security AdminToken
// @Success 200 {object} SwaggerOrdersBody
// @Failure 401 {object} SwaggerErrorBody
// @Failure 500 {object} SwaggerErrorBody
// @Router /api/admin/orders [get]
func (h *AdminHandler) ListAllOrders(c *gin.Context) {
if !h.requireAdmin(c) {
return
@@ -18,6 +27,17 @@ func (h *AdminHandler) ListAllOrders(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"data": orders})
}
// DeleteOrder 管理端删除订单。
// @Summary 删除订单
// @Tags 管理端-订单
// @Produce json
// @Security AdminToken
// @Param id path string true "订单 ID"
// @Success 200 {object} SwaggerBoolOKWrap
// @Failure 400 {object} SwaggerErrorBody
// @Failure 401 {object} SwaggerErrorBody
// @Failure 500 {object} SwaggerErrorBody
// @Router /api/admin/orders/{id} [delete]
func (h *AdminHandler) DeleteOrder(c *gin.Context) {
if !h.requireAdmin(c) {
return

View File

@@ -9,7 +9,7 @@ import (
"mengyastore-backend/internal/models"
)
type productPayload struct {
type ProductPayload struct {
Name string `json:"name"`
Price float64 `json:"price"`
DiscountPrice float64 `json:"discountPrice"`
@@ -17,6 +17,7 @@ type productPayload struct {
CoverURL string `json:"coverUrl"`
Codes []string `json:"codes"`
ScreenshotURLs []string `json:"screenshotUrls"`
PaymentQrURLs []string `json:"paymentQrUrls"`
Description string `json:"description"`
Active *bool `json:"active"`
RequireLogin bool `json:"requireLogin"`
@@ -28,7 +29,7 @@ type productPayload struct {
ShowContact bool `json:"showContact"`
}
func normalizeFulfillmentPayload(payload *productPayload) string {
func normalizeFulfillmentPayload(payload *ProductPayload) string {
ft := strings.TrimSpace(strings.ToLower(payload.FulfillmentType))
if ft == "fixed" {
return "fixed"
@@ -36,16 +37,26 @@ func normalizeFulfillmentPayload(payload *productPayload) string {
return "card"
}
type togglePayload struct {
type TogglePayload struct {
Active bool `json:"active"`
}
// VerifyAdminToken checks whether the supplied token is correct.
// Returns {"valid": true/false} without leaking the real token value.
// AdminVerifyTokenRequest 管理端校验令牌Body非 Header
type AdminVerifyTokenRequest struct {
Token string `json:"token"`
}
// VerifyAdminToken 校验请求中的令牌是否正确。
// @Summary 校验管理员令牌
// @Description 响应为 {"valid": true/false},不泄露真实口令。
// @Tags 管理端-认证
// @Accept json
// @Produce json
// @Param body body AdminVerifyTokenRequest true "待校验 token"
// @Success 200 {object} SwaggerValidBody
// @Router /api/admin/verify [post]
func (h *AdminHandler) VerifyAdminToken(c *gin.Context) {
var payload struct {
Token string `json:"token"`
}
var payload AdminVerifyTokenRequest
if err := c.ShouldBindJSON(&payload); err != nil || payload.Token == "" {
c.JSON(http.StatusOK, gin.H{"valid": false})
return
@@ -53,6 +64,15 @@ func (h *AdminHandler) VerifyAdminToken(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"valid": payload.Token == h.cfg.AdminToken})
}
// ListAllProducts 管理端商品全量列表(含下架与卡密等敏感字段)。
// @Summary 全部商品(管理)
// @Tags 管理端-商品
// @Produce json
// @Security AdminToken
// @Success 200 {object} SwaggerProductListBody
// @Failure 401 {object} SwaggerErrorBody
// @Failure 500 {object} SwaggerErrorBody
// @Router /api/admin/products [get]
func (h *AdminHandler) ListAllProducts(c *gin.Context) {
if !h.requireAdmin(c) {
return
@@ -65,18 +85,35 @@ func (h *AdminHandler) ListAllProducts(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"data": items})
}
// CreateProduct 创建商品。
// @Summary 创建商品
// @Tags 管理端-商品
// @Accept json
// @Produce json
// @Security AdminToken
// @Param body body ProductPayload true "商品字段"
// @Success 200 {object} SwaggerProductOneBody
// @Failure 400 {object} SwaggerErrorBody
// @Failure 401 {object} SwaggerErrorBody
// @Failure 500 {object} SwaggerErrorBody
// @Router /api/admin/products [post]
func (h *AdminHandler) CreateProduct(c *gin.Context) {
if !h.requireAdmin(c) {
return
}
var payload productPayload
var payload ProductPayload
if err := c.ShouldBindJSON(&payload); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数有误"})
return
}
screenshotURLs, valid := normalizeScreenshotURLs(payload.ScreenshotURLs)
if !valid {
c.JSON(http.StatusBadRequest, gin.H{"error": "截图链接最多 5 条"})
c.JSON(http.StatusBadRequest, gin.H{"error": "截图链接最多 6 条"})
return
}
paymentQrURLs, valid := normalizePaymentQrURLs(payload.PaymentQrURLs)
if !valid {
c.JSON(http.StatusBadRequest, gin.H{"error": "萌芽收款渠道链接最多 6 条"})
return
}
active := true
@@ -100,6 +137,7 @@ func (h *AdminHandler) CreateProduct(c *gin.Context) {
CoverURL: strings.TrimSpace(payload.CoverURL),
Codes: payload.Codes,
ScreenshotURLs: screenshotURLs,
PaymentQrURLs: paymentQrURLs,
Description: payload.Description,
Active: active,
RequireLogin: payload.RequireLogin,
@@ -118,19 +156,38 @@ func (h *AdminHandler) CreateProduct(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"data": created})
}
// UpdateProduct 更新商品。
// @Summary 更新商品
// @Tags 管理端-商品
// @Accept json
// @Produce json
// @Security AdminToken
// @Param id path string true "商品 ID"
// @Param body body ProductPayload true "商品字段"
// @Success 200 {object} SwaggerProductOneBody
// @Failure 400 {object} SwaggerErrorBody
// @Failure 401 {object} SwaggerErrorBody
// @Failure 404 {object} SwaggerErrorBody
// @Failure 500 {object} SwaggerErrorBody
// @Router /api/admin/products/{id} [put]
func (h *AdminHandler) UpdateProduct(c *gin.Context) {
if !h.requireAdmin(c) {
return
}
id := c.Param("id")
var payload productPayload
var payload ProductPayload
if err := c.ShouldBindJSON(&payload); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数有误"})
return
}
screenshotURLs, valid := normalizeScreenshotURLs(payload.ScreenshotURLs)
if !valid {
c.JSON(http.StatusBadRequest, gin.H{"error": "截图链接最多 5 条"})
c.JSON(http.StatusBadRequest, gin.H{"error": "截图链接最多 6 条"})
return
}
paymentQrURLs, valid := normalizePaymentQrURLs(payload.PaymentQrURLs)
if !valid {
c.JSON(http.StatusBadRequest, gin.H{"error": "萌芽收款渠道链接最多 6 条"})
return
}
active := false
@@ -154,6 +211,7 @@ func (h *AdminHandler) UpdateProduct(c *gin.Context) {
CoverURL: strings.TrimSpace(payload.CoverURL),
Codes: payload.Codes,
ScreenshotURLs: screenshotURLs,
PaymentQrURLs: paymentQrURLs,
Description: payload.Description,
Active: active,
RequireLogin: payload.RequireLogin,
@@ -172,12 +230,25 @@ func (h *AdminHandler) UpdateProduct(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"data": updated})
}
// ToggleProduct 上架/下架切换。
// @Summary 切换上架状态
// @Tags 管理端-商品
// @Accept json
// @Produce json
// @Security AdminToken
// @Param id path string true "商品 ID"
// @Param body body TogglePayload true "{active}"
// @Success 200 {object} SwaggerProductOneBody
// @Failure 400 {object} SwaggerErrorBody
// @Failure 401 {object} SwaggerErrorBody
// @Failure 404 {object} SwaggerErrorBody
// @Router /api/admin/products/{id}/status [patch]
func (h *AdminHandler) ToggleProduct(c *gin.Context) {
if !h.requireAdmin(c) {
return
}
id := c.Param("id")
var payload togglePayload
var payload TogglePayload
if err := c.ShouldBindJSON(&payload); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数有误"})
return
@@ -190,6 +261,16 @@ func (h *AdminHandler) ToggleProduct(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"data": updated})
}
// DeleteProduct 删除商品。
// @Summary 删除商品
// @Tags 管理端-商品
// @Produce json
// @Security AdminToken
// @Param id path string true "商品 ID"
// @Success 200 {object} SwaggerBoolOKWrap
// @Failure 401 {object} SwaggerErrorBody
// @Failure 500 {object} SwaggerErrorBody
// @Router /api/admin/products/{id} [delete]
func (h *AdminHandler) DeleteProduct(c *gin.Context) {
if !h.requireAdmin(c) {
return
@@ -202,6 +283,26 @@ func (h *AdminHandler) DeleteProduct(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"data": gin.H{"ok": true}})
}
const maxScreenshotURLsAdmin = 6
const maxPaymentQrURLsAdmin = 6
func normalizePaymentQrURLs(urls []string) ([]string, bool) {
cleaned := make([]string, 0, len(urls))
seen := map[string]bool{}
for _, u := range urls {
trimmed := strings.TrimSpace(u)
if trimmed == "" || seen[trimmed] {
continue
}
seen[trimmed] = true
cleaned = append(cleaned, trimmed)
if len(cleaned) > maxPaymentQrURLsAdmin {
return nil, false
}
}
return cleaned, true
}
func normalizeScreenshotURLs(urls []string) ([]string, bool) {
cleaned := make([]string, 0, len(urls))
for _, url := range urls {
@@ -210,7 +311,7 @@ func normalizeScreenshotURLs(urls []string) ([]string, bool) {
continue
}
cleaned = append(cleaned, trimmed)
if len(cleaned) > 5 {
if len(cleaned) > maxScreenshotURLsAdmin {
return nil, false
}
}

View File

@@ -8,16 +8,28 @@ import (
"mengyastore-backend/internal/storage"
)
type maintenancePayload struct {
type MaintenancePayload struct {
Maintenance bool `json:"maintenance"`
Reason string `json:"reason"`
}
// SetMaintenance 设置站点维护模式。
// @Summary 设置维护模式
// @Tags 管理端-站点
// @Accept json
// @Produce json
// @Security AdminToken
// @Param body body MaintenancePayload true "维护开关与原因"
// @Success 200 {object} SwaggerMaintenanceWrap
// @Failure 400 {object} SwaggerErrorBody
// @Failure 401 {object} SwaggerErrorBody
// @Failure 500 {object} SwaggerErrorBody
// @Router /api/admin/site/maintenance [post]
func (h *AdminHandler) SetMaintenance(c *gin.Context) {
if !h.requireAdmin(c) {
return
}
var payload maintenancePayload
var payload MaintenancePayload
if err := c.ShouldBindJSON(&payload); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数有误"})
return
@@ -34,6 +46,15 @@ func (h *AdminHandler) SetMaintenance(c *gin.Context) {
})
}
// GetSMTPConfig 获取 SMTP 配置(密码脱敏)。
// @Summary 获取 SMTP 配置
// @Tags 管理端-站点
// @Produce json
// @Security AdminToken
// @Success 200 {object} SwaggerSMTPWrap
// @Failure 401 {object} SwaggerErrorBody
// @Failure 500 {object} SwaggerErrorBody
// @Router /api/admin/site/smtp [get]
func (h *AdminHandler) GetSMTPConfig(c *gin.Context) {
if !h.requireAdmin(c) {
return
@@ -51,6 +72,18 @@ func (h *AdminHandler) GetSMTPConfig(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"data": masked})
}
// SetSMTPConfig 保存 SMTP 配置。
// @Summary 保存 SMTP 配置
// @Tags 管理端-站点
// @Accept json
// @Produce json
// @Security AdminToken
// @Param body body storage.SMTPConfig true "SMTP 字段"
// @Success 200 {object} SwaggerStringOKBody
// @Failure 400 {object} SwaggerErrorBody
// @Failure 401 {object} SwaggerErrorBody
// @Failure 500 {object} SwaggerErrorBody
// @Router /api/admin/site/smtp [post]
func (h *AdminHandler) SetSMTPConfig(c *gin.Context) {
if !h.requireAdmin(c) {
return

View File

@@ -17,7 +17,7 @@ import (
"mengyastore-backend/internal/mq"
)
// SystemStatusHandler exposes admin-only operational status.
// SystemStatusHandler 向管理端暴露运维状态(仅供管理员)。
type SystemStatusHandler struct {
cfg *config.Config
db *gormDB.DB
@@ -29,7 +29,14 @@ func NewSystemStatusHandler(cfg *config.Config, db *gormDB.DB, mqClient *mq.Clie
return &SystemStatusHandler{cfg: cfg, db: db, mq: mqClient, start: startedAt}
}
// GetSystemStatus returns JSON for the admin dashboard. Requires admin token.
// GetSystemStatus 返回管理后台用 JSON需通过管理员令牌。
// @Summary 系统运行状态
// @Tags 管理端-系统
// @Produce json
// @Security AdminToken
// @Success 200 {object} map[string]interface{}
// @Failure 401 {object} SwaggerErrorBody
// @Router /api/admin/system-status [get]
func (h *SystemStatusHandler) GetSystemStatus(c *gin.Context) {
if !h.adminTokenOK(c) {
return
@@ -278,7 +285,7 @@ func (h *SystemStatusHandler) rabbitmqInfo() gin.H {
return out
}
// parseAMQPBroker returns host, port, vhost, user without password (for display only).
// parseAMQPBroker 从 amqp URL 解析主机、端口、vhost、用户名不含口令仅展示用
func parseAMQPBroker(raw string) (host, port, vhost, user string) {
if raw == "" {
return "", "", "", ""

View File

@@ -35,6 +35,14 @@ func (h *ChatHandler) requireChatUser(c *gin.Context) (account, name string, ok
}
// GetMyMessages 返回当前登录用户的全部聊天消息。
// @Summary 我的聊天消息
// @Tags 聊天(用户)
// @Produce json
// @Security BearerAuth
// @Success 200 {object} SwaggerMessagesWrap
// @Failure 401 {object} SwaggerErrorBody
// @Failure 500 {object} SwaggerErrorBody
// @Router /api/chat/messages [get]
func (h *ChatHandler) GetMyMessages(c *gin.Context) {
account, _, ok := h.requireChatUser(c)
if !ok {
@@ -48,17 +56,30 @@ func (h *ChatHandler) GetMyMessages(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"data": gin.H{"messages": msgs}})
}
type chatMessagePayload struct {
// ChatMessagePayload 用户发送消息请求体。
type ChatMessagePayload struct {
Content string `json:"content"`
}
// SendMyMessage 向管理员发送一条用户消息。
// @Summary 发送用户消息
// @Tags 聊天(用户)
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param body body ChatMessagePayload true "消息正文"
// @Success 200 {object} SwaggerOneChatMsgWrap
// @Failure 400 {object} SwaggerErrorBody
// @Failure 401 {object} SwaggerErrorBody
// @Failure 429 {object} SwaggerErrorBody
// @Failure 500 {object} SwaggerErrorBody
// @Router /api/chat/messages [post]
func (h *ChatHandler) SendMyMessage(c *gin.Context) {
account, name, ok := h.requireChatUser(c)
if !ok {
return
}
var payload chatMessagePayload
var payload ChatMessagePayload
if err := c.ShouldBindJSON(&payload); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数有误"})
return

File diff suppressed because it is too large Load Diff

View File

@@ -18,6 +18,13 @@ func NewPublicHandler(store *storage.ProductStore) *PublicHandler {
return &PublicHandler{store: store}
}
// ListProducts 上架商品列表(公开字段,不含卡密与管理信息)。
// @Summary 上架商品列表
// @Tags 公开
// @Produce json
// @Success 200 {object} SwaggerProductListBody
// @Failure 500 {object} SwaggerErrorBody
// @Router /api/products [get]
func (h *PublicHandler) ListProducts(c *gin.Context) {
items, err := h.store.ListActive()
if err != nil {
@@ -27,6 +34,14 @@ func (h *PublicHandler) ListProducts(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"data": sanitizeForPublic(items)})
}
// RecordProductView 记录商品浏览(去重策略由服务端指纹决定)。
// @Summary 记录商品浏览
// @Tags 公开
// @Produce json
// @Param id path string true "商品 ID"
// @Success 200 {object} SwaggerProductViewWrap
// @Failure 404 {object} SwaggerErrorBody
// @Router /api/products/{id}/view [post]
func (h *PublicHandler) RecordProductView(c *gin.Context) {
id := c.Param("id")
fingerprint := buildViewerFingerprint(c)

View File

@@ -17,6 +17,13 @@ func NewStatsHandler(orderStore *storage.OrderStore, siteStore *storage.SiteStor
return &StatsHandler{orderStore: orderStore, siteStore: siteStore}
}
// GetStats 订单总数与站点访问总数。
// @Summary 站点统计
// @Tags 公开
// @Produce json
// @Success 200 {object} SwaggerStatsWrap
// @Failure 500 {object} SwaggerErrorBody
// @Router /api/stats [get]
func (h *StatsHandler) GetStats(c *gin.Context) {
totalOrders, err := h.orderStore.Count()
if err != nil {
@@ -36,6 +43,13 @@ func (h *StatsHandler) GetStats(c *gin.Context) {
})
}
// RecordVisit 记录一次站点访问并返回累计访问量。
// @Summary 记录站点访问
// @Tags 公开
// @Produce json
// @Success 200 {object} SwaggerVisitWrap
// @Failure 500 {object} SwaggerErrorBody
// @Router /api/site/visit [post]
func (h *StatsHandler) RecordVisit(c *gin.Context) {
fingerprint := buildViewerFingerprint(c)
totalVisits, counted, err := h.siteStore.RecordVisit(fingerprint)
@@ -51,6 +65,13 @@ func (h *StatsHandler) RecordVisit(c *gin.Context) {
})
}
// GetMaintenance 当前维护模式与原因。
// @Summary 维护模式状态
// @Tags 公开
// @Produce json
// @Success 200 {object} SwaggerMaintenanceWrap
// @Failure 500 {object} SwaggerErrorBody
// @Router /api/site/maintenance [get]
func (h *StatsHandler) GetMaintenance(c *gin.Context) {
enabled, reason, err := h.siteStore.GetMaintenance()
if err != nil {

View File

@@ -0,0 +1,162 @@
package handlers
import (
"time"
"mengyastore-backend/internal/models"
"mengyastore-backend/internal/storage"
)
// 以下类型仅用于 swag/OpenAPI 描述,与实际 handler 返回值形状对齐。
type SwaggerErrorBody struct {
Error string `json:"error"`
}
type SwaggerValidBody struct {
Valid bool `json:"valid"`
}
type SwaggerProductListBody struct {
Data []models.Product `json:"data"`
}
type SwaggerProductOneBody struct {
Data models.Product `json:"data"`
}
type SwaggerOrdersBody struct {
Data []models.Order `json:"data"`
}
type SwaggerSMTPWrap struct {
Data storage.SMTPConfig `json:"data"`
}
type SwaggerStringOKBody struct {
Data string `json:"data"`
}
type SwaggerBoolOKWrap struct {
Data SwaggerBoolOK `json:"data"`
}
type SwaggerBoolOK struct {
OK bool `json:"ok"`
}
type SwaggerProductViewWrap struct {
Data SwaggerProductViewData `json:"data"`
}
type SwaggerProductViewData struct {
ID string `json:"id"`
ViewCount int `json:"viewCount"`
Counted bool `json:"counted"`
}
type SwaggerStatsWrap struct {
Data SwaggerStatsData `json:"data"`
}
type SwaggerStatsData struct {
TotalOrders int `json:"totalOrders"`
TotalVisits int `json:"totalVisits"`
}
type SwaggerVisitWrap struct {
Data SwaggerVisitData `json:"data"`
}
type SwaggerVisitData struct {
TotalVisits int `json:"totalVisits"`
Counted bool `json:"counted"`
}
type SwaggerMaintenanceWrap struct {
Data SwaggerMaintenanceData `json:"data"`
}
type SwaggerMaintenanceData struct {
Maintenance bool `json:"maintenance"`
Reason string `json:"reason"`
}
type SwaggerCheckoutWrap struct {
Data SwaggerCheckoutData `json:"data"`
}
type SwaggerCheckoutData struct {
OrderID string `json:"orderId"`
QrCodeURL string `json:"qrCodeUrl"`
ProductID string `json:"productId"`
ProductQty int `json:"productQty"`
ViewCount int `json:"viewCount"`
Status string `json:"status"`
PaymentMethod string `json:"paymentMethod"`
PaymentExpectedTotal float64 `json:"paymentExpectedTotal,omitempty"`
PaymentQrURLs []string `json:"paymentQrUrls,omitempty"`
PaymentExpiresAt *time.Time `json:"paymentExpiresAt,omitempty"`
}
type SwaggerConfirmWrap struct {
Data SwaggerConfirmData `json:"data"`
}
type SwaggerConfirmData struct {
OrderID string `json:"orderId"`
Status string `json:"status"`
DeliveryMode string `json:"deliveryMode"`
DeliveredCodes []string `json:"deliveredCodes"`
IsManual bool `json:"isManual"`
}
type SwaggerPaymentStatusWrap struct {
Data SwaggerPaymentStatusData `json:"data"`
}
type SwaggerPaymentStatusData struct {
Status string `json:"status"`
ExpectedTotal float64 `json:"expectedTotal"`
PaymentExpiresAt *time.Time `json:"paymentExpiresAt"`
PaymentMethod string `json:"paymentMethod"`
}
type SwaggerWebhookMengyaResp struct {
OK bool `json:"ok"`
Matched bool `json:"matched"`
MatchedOrderID string `json:"matched_order_id,omitempty"`
}
type SwaggerCancelWrap struct {
Data SwaggerCancelMsg `json:"data"`
}
type SwaggerCancelMsg struct {
OK bool `json:"ok"`
Message string `json:"message,omitempty"`
}
type SwaggerWishlistWrap struct {
Data SwaggerWishlistItems `json:"data"`
}
type SwaggerWishlistItems struct {
Items []string `json:"items"`
}
type SwaggerMessagesWrap struct {
Data SwaggerMessagesInner `json:"data"`
}
type SwaggerMessagesInner struct {
Messages []models.ChatMessage `json:"messages"`
}
type SwaggerOneChatMsgWrap struct {
Data SwaggerSingleChatWrap `json:"data"`
}
type SwaggerSingleChatWrap struct {
Message models.ChatMessage `json:"message"`
}

View File

@@ -34,6 +34,15 @@ func (h *WishlistHandler) requireUser(c *gin.Context) (string, bool) {
return result.User.Account, true
}
// GetWishlist 当前用户收藏的商品 ID 列表。
// @Summary 收藏列表
// @Tags 收藏
// @Produce json
// @Security BearerAuth
// @Success 200 {object} SwaggerWishlistWrap
// @Failure 401 {object} SwaggerErrorBody
// @Failure 500 {object} SwaggerErrorBody
// @Router /api/wishlist [get]
func (h *WishlistHandler) GetWishlist(c *gin.Context) {
account, ok := h.requireUser(c)
if !ok {
@@ -47,16 +56,29 @@ func (h *WishlistHandler) GetWishlist(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"data": gin.H{"items": ids}})
}
type wishlistItemPayload struct {
// WishlistItemPayload 添加收藏请求体。
type WishlistItemPayload struct {
ProductID string `json:"productId"`
}
// AddToWishlist 添加收藏。
// @Summary 添加收藏
// @Tags 收藏
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param body body WishlistItemPayload true "商品 ID"
// @Success 200 {object} SwaggerWishlistWrap
// @Failure 400 {object} SwaggerErrorBody
// @Failure 401 {object} SwaggerErrorBody
// @Failure 500 {object} SwaggerErrorBody
// @Router /api/wishlist [post]
func (h *WishlistHandler) AddToWishlist(c *gin.Context) {
account, ok := h.requireUser(c)
if !ok {
return
}
var payload wishlistItemPayload
var payload WishlistItemPayload
if err := c.ShouldBindJSON(&payload); err != nil || payload.ProductID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数有误"})
return
@@ -69,6 +91,17 @@ func (h *WishlistHandler) AddToWishlist(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"data": gin.H{"items": ids}})
}
// RemoveFromWishlist 按商品 ID 移除收藏。
// @Summary 移除收藏
// @Tags 收藏
// @Produce json
// @Security BearerAuth
// @Param id path string true "商品 ID"
// @Success 200 {object} SwaggerWishlistWrap
// @Failure 400 {object} SwaggerErrorBody
// @Failure 401 {object} SwaggerErrorBody
// @Failure 500 {object} SwaggerErrorBody
// @Router /api/wishlist/{id} [delete]
func (h *WishlistHandler) RemoveFromWishlist(c *gin.Context) {
account, ok := h.requireUser(c)
if !ok {

View File

@@ -16,5 +16,8 @@ type Order struct {
ContactPhone string `json:"contactPhone"`
ContactEmail string `json:"contactEmail"`
NotifyEmail string `json:"notifyEmail"`
PaymentMethod string `json:"paymentMethod,omitempty"`
PaymentExpectedTotal float64 `json:"paymentExpectedTotal,omitempty"`
PaymentExpiresAt *time.Time `json:"paymentExpiresAt,omitempty"`
CreatedAt time.Time `json:"createdAt"`
}

View File

@@ -11,6 +11,8 @@ type Product struct {
Quantity int `json:"quantity"`
CoverURL string `json:"coverUrl"`
ScreenshotURLs []string `json:"screenshotUrls"`
// PaymentQrURLs 萌芽支付收款码图片链接(可多条)。
PaymentQrURLs []string `json:"paymentQrUrls"`
VerificationURL string `json:"verificationUrl"`
Codes []string `json:"codes"`
ViewCount int `json:"viewCount"`

View File

@@ -15,7 +15,7 @@ import (
"mengyastore-backend/internal/storage"
)
// Client manages a single AMQP connection, a publish channel, and naming for one env.
// Client 封装单条 AMQP 连接、发布用 Channel以及单环境的命名。
type Client struct {
conn *amqp.Connection
pubCh *amqp.Channel
@@ -32,7 +32,7 @@ type Client struct {
consumerSite *storage.SiteStore
}
// New connects to RabbitMQ and declares exchange + queue + binding (idempotent).
// New 连接 RabbitMQ 并声明交换机 + 队列 + 绑定(幂等)。
func New(amqpURL, env string) (*Client, error) {
if amqpURL == "" {
return nil, fmt.Errorf("empty amqp url")
@@ -141,7 +141,7 @@ func (c *Client) passiveQueueLocked() error {
return err
}
// PublishOrderEmail publishes a persistent JSON message to the order-email routing key.
// PublishOrderEmail 向 order-email 路由键发布一条持久化 JSON 消息。
func (c *Client) PublishOrderEmail(ctx context.Context, p OrderEmailPayload) error {
body, err := json.Marshal(p)
if err != nil {
@@ -190,7 +190,7 @@ func (c *Client) PublishOrderEmail(ctx context.Context, p OrderEmailPayload) err
return nil
}
// QueueInspectInfo returns current queue depth and consumer count (passive declare).
// QueueInspectInfo 返回当前队列深度与消费者数(被动声明查询)。
func (c *Client) QueueInspectInfo() (messages int, consumers int, err error) {
c.mu.Lock()
defer c.mu.Unlock()
@@ -216,7 +216,7 @@ func (c *Client) QueueInspectInfo() (messages int, consumers int, err error) {
return msgs, cons, err
}
// Ping checks that the publish channel can query the declared queue (liveness for /api/health).
// Ping 检查发布 Channel 能否对声明的队列做被动查询(供 /api/health 探活)。
func (c *Client) Ping() error {
c.mu.Lock()
defer c.mu.Unlock()
@@ -231,10 +231,10 @@ func (c *Client) Ping() error {
return err
}
// Env returns the sanitized environment suffix used in exchange/queue names.
// Env 返回交换机/队列名使用的环境后缀(已规范化)。
func (c *Client) Env() string { return c.env }
// StartConsumer runs the order-email consumer until ctx is cancelled (run in a goroutine).
// StartConsumer 在 ctx 取消前运行订单邮件消费者(通常在 goroutine 中调用)。
func (c *Client) StartConsumer(ctx context.Context, site *storage.SiteStore) {
c.mu.Lock()
c.consumerCtx = ctx
@@ -248,7 +248,7 @@ func (c *Client) StartConsumer(ctx context.Context, site *storage.SiteStore) {
RunOrderEmailConsumer(ctx, conn, env, site)
}
// Close releases the publish channel and connection.
// Close 关闭发布 Channel 与连接。
func (c *Client) Close() {
c.closing.Do(func() {
c.mu.Lock()

View File

@@ -11,7 +11,7 @@ import (
"mengyastore-backend/internal/storage"
)
// RunOrderEmailConsumer runs until ctx is done. Must be started in its own goroutine.
// RunOrderEmailConsumer 运行至 ctx 结束,请在独立 goroutine 中启动。
func RunOrderEmailConsumer(ctx context.Context, conn *amqp.Connection, env string, site *storage.SiteStore) {
if conn == nil || site == nil {
return

View File

@@ -2,7 +2,7 @@ package mq
import "mengyastore-backend/internal/email"
// OrderEmailPayload is the JSON body published to RabbitMQ (no SMTP secrets).
// OrderEmailPayload 发布到 RabbitMQ 的订单邮件 JSON 负载(不含 SMTP 口令)。
type OrderEmailPayload struct {
ToEmail string `json:"toEmail"`
ToName string `json:"toName"`

View File

@@ -9,15 +9,15 @@ import (
const routingKeyOrderEmail = "order.email.notify"
// OrderEmailRoutingKey is the binding / publish key for order notification messages.
// OrderEmailRoutingKey 订单通知消息的绑定键 / 发布 routing key。
func OrderEmailRoutingKey() string { return routingKeyOrderEmail }
// ExchangeName returns the durable topic exchange for this app + env (isolation from other apps on same broker).
// ExchangeName 返回本应用 + 环境对应的持久化 topic 交换机名(与 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.
// QueueName 返回本环境下持久化的订单邮件队列名。
func QueueName(env string) string {
return fmt.Sprintf("q.mengyastore.%s.order_email", sanitizeEnv(env))
}

View File

@@ -0,0 +1,143 @@
package payment
import (
"bytes"
"encoding/json"
"fmt"
"math"
"regexp"
"strconv"
"strings"
)
// 依次尝试:微信等常带 ¥支付宝等到账文案多为「收款x.xx元」无货币符号。
var mengyaNoticeAmountPatterns = []*regexp.Regexp{
regexp.MustCompile(`到账[¥¥]\s*([\d]+(?:\.[\d]+)?)`),
regexp.MustCompile(`到账\s*([\d]+(?:\.[\d]+)?)\s*元`),
regexp.MustCompile(`收款[¥¥]\s*([\d]+(?:\.[\d]+)?)(?:\s*元)?`),
regexp.MustCompile(`收款\s*([\d]+(?:\.[\d]+)?)\s*元`),
}
// AmountEpsilonWebhook 到账金额与订单快照金额对比时的容差。
const AmountEpsilon = 0.005
func AmountAlmostEqual(expected, actual float64) bool {
return math.Abs(expected-actual) < AmountEpsilon
}
// ParseWebhookJSON 从转发通知体中解析支付金额:
// 优先 body.notice其次顶层 notice否则在序列化后的 JSON 字符串中扫描。
func ParseWebhookJSON(raw []byte) (float64, bool) {
if len(bytes.TrimSpace(raw)) == 0 {
return 0, false
}
var top map[string]json.RawMessage
if err := json.Unmarshal(raw, &top); err != nil {
return scanAmount(string(raw))
}
if v, ok := top["notice"]; ok {
if s := jsonString(v); s != "" {
return scanAmount(s)
}
}
if inner, ok := top["body"]; ok {
var nested map[string]json.RawMessage
if json.Unmarshal(inner, &nested) == nil {
if v, ok := nested["notice"]; ok {
if s := jsonString(v); s != "" {
return scanAmount(s)
}
}
if amt, ok := scanAmount(jsonString(inner)); ok {
return amt, ok
}
return scanAmount(string(inner))
}
if amt, ok := scanAmount(strings.Trim(strings.Trim(string(inner), "\""), `"`)); ok {
return amt, ok
}
return scanAmount(string(inner))
}
buf, _ := json.Marshal(top)
return scanAmount(string(buf))
}
func jsonString(raw json.RawMessage) string {
var s string
if json.Unmarshal(raw, &s) == nil {
return s
}
return ""
}
func scanAmount(text string) (float64, bool) {
if amt, ok := parseAmountRegex(text); ok {
return amt, true
}
return parseAmountRegex(strings.ReplaceAll(text, `\n`, "\n"))
}
func parseAmountRegex(text string) (float64, bool) {
for _, re := range mengyaNoticeAmountPatterns {
match := re.FindStringSubmatch(text)
if len(match) < 2 {
continue
}
s := strings.ReplaceAll(match[1], ",", "")
yuan, err := strconv.ParseFloat(s, 64)
if err != nil {
continue
}
return yuan, true
}
return 0, false
}
// NoticeFieldsForLog 从常见萌芽/转发 JSON 里抽出 notice 等字段,仅供后台日志排查(如微信/支付宝文案差异)。
func NoticeFieldsForLog(raw []byte) string {
if len(bytes.TrimSpace(raw)) == 0 {
return ""
}
var top map[string]json.RawMessage
if err := json.Unmarshal(raw, &top); err != nil {
return ""
}
var parts []string
add := func(k, v string) {
if v == "" {
return
}
if len(v) > 600 {
v = v[:600] + "…"
}
parts = append(parts, fmt.Sprintf("%s=%s", k, strconv.Quote(v)))
}
if v, ok := top["notice"]; ok {
add("notice", jsonString(v))
}
if inner, ok := top["body"]; ok {
var nested map[string]json.RawMessage
if json.Unmarshal(inner, &nested) == nil {
if v, ok := nested["notice"]; ok {
add("body.notice", jsonString(v))
}
if v, ok := nested["content"]; ok {
add("body.content", jsonString(v))
}
if v, ok := nested["text"]; ok {
add("body.text", jsonString(v))
}
} else if s := jsonString(inner); s != "" {
add("body(string)", s)
}
}
for _, k := range []string{"title", "msg", "message", "desc", "description"} {
if v, ok := top[k]; ok {
add(k, jsonString(v))
}
}
if len(parts) == 0 {
return ""
}
return strings.Join(parts, "; ")
}

View File

@@ -0,0 +1,3 @@
package payment
const MethodMengya = "mengya"

View File

@@ -1,15 +1,20 @@
package storage
import (
"errors"
"fmt"
"time"
"github.com/google/uuid"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"mengyastore-backend/internal/database"
"mengyastore-backend/internal/models"
)
const amountMatchEpsilon = 0.009
type OrderStore struct {
db *gorm.DB
}
@@ -20,20 +25,23 @@ func NewOrderStore(db *gorm.DB) (*OrderStore, error) {
func orderRowToModel(row database.OrderRow) models.Order {
return models.Order{
ID: row.ID,
ProductID: row.ProductID,
ProductName: row.ProductName,
UserAccount: row.UserAccount,
UserName: row.UserName,
Quantity: row.Quantity,
DeliveredCodes: row.DeliveredCodes,
Status: row.Status,
DeliveryMode: row.DeliveryMode,
Note: row.Note,
ContactPhone: row.ContactPhone,
ContactEmail: row.ContactEmail,
NotifyEmail: row.NotifyEmail,
CreatedAt: row.CreatedAt,
ID: row.ID,
ProductID: row.ProductID,
ProductName: row.ProductName,
UserAccount: row.UserAccount,
UserName: row.UserName,
Quantity: row.Quantity,
DeliveredCodes: row.DeliveredCodes,
Status: row.Status,
DeliveryMode: row.DeliveryMode,
Note: row.Note,
ContactPhone: row.ContactPhone,
ContactEmail: row.ContactEmail,
NotifyEmail: row.NotifyEmail,
PaymentMethod: row.PaymentMethod,
PaymentExpectedTotal: row.PaymentExpectedTotal,
PaymentExpiresAt: row.PaymentExpiresAt,
CreatedAt: row.CreatedAt,
}
}
@@ -45,24 +53,62 @@ func (s *OrderStore) Create(order models.Order) (models.Order, error) {
order.DeliveredCodes = []string{}
}
row := database.OrderRow{
ID: order.ID,
ProductID: order.ProductID,
ProductName: order.ProductName,
UserAccount: order.UserAccount,
UserName: order.UserName,
Quantity: order.Quantity,
DeliveredCodes: database.StringSlice(order.DeliveredCodes),
Status: order.Status,
DeliveryMode: order.DeliveryMode,
Note: order.Note,
ContactPhone: order.ContactPhone,
ContactEmail: order.ContactEmail,
NotifyEmail: order.NotifyEmail,
ID: order.ID,
ProductID: order.ProductID,
ProductName: order.ProductName,
UserAccount: order.UserAccount,
UserName: order.UserName,
Quantity: order.Quantity,
DeliveredCodes: database.StringSlice(order.DeliveredCodes),
Status: order.Status,
DeliveryMode: order.DeliveryMode,
Note: order.Note,
ContactPhone: order.ContactPhone,
ContactEmail: order.ContactEmail,
NotifyEmail: order.NotifyEmail,
PaymentMethod: order.PaymentMethod,
PaymentExpectedTotal: order.PaymentExpectedTotal,
PaymentExpiresAt: order.PaymentExpiresAt,
}
if err := s.db.Create(&row).Error; err != nil {
return models.Order{}, err
}
order.CreatedAt = row.CreatedAt
order.PaymentExpiresAt = row.PaymentExpiresAt
return order, nil
}
// CreateTx 在已有事务内创建订单。
func (s *OrderStore) CreateTx(tx *gorm.DB, order models.Order) (models.Order, error) {
if order.ID == "" {
order.ID = uuid.NewString()
}
if len(order.DeliveredCodes) == 0 {
order.DeliveredCodes = []string{}
}
row := database.OrderRow{
ID: order.ID,
ProductID: order.ProductID,
ProductName: order.ProductName,
UserAccount: order.UserAccount,
UserName: order.UserName,
Quantity: order.Quantity,
DeliveredCodes: database.StringSlice(order.DeliveredCodes),
Status: order.Status,
DeliveryMode: order.DeliveryMode,
Note: order.Note,
ContactPhone: order.ContactPhone,
ContactEmail: order.ContactEmail,
NotifyEmail: order.NotifyEmail,
PaymentMethod: order.PaymentMethod,
PaymentExpectedTotal: order.PaymentExpectedTotal,
PaymentExpiresAt: order.PaymentExpiresAt,
}
if err := tx.Create(&row).Error; err != nil {
return models.Order{}, err
}
order.CreatedAt = row.CreatedAt
order.PaymentExpiresAt = row.PaymentExpiresAt
return order, nil
}
@@ -112,14 +158,13 @@ func (s *OrderStore) ListAll() ([]models.Order, error) {
func (s *OrderStore) CountPurchasedByAccount(account, productID string) (int, error) {
var total int64
// 统计 pending手动待发货)和 completed 两种状态,防止用户快速下单绕过购买数量限制
// pending手动待发货pending_payment萌芽支付待到账会计入限购
err := s.db.Model(&database.OrderRow{}).
Where("user_account = ? AND product_id = ? AND status IN ?", account, productID, []string{"pending", "completed"}).
Where("user_account = ? AND product_id = ? AND status IN ?", account, productID, []string{"pending", "pending_payment", "completed"}).
Select("COALESCE(SUM(quantity), 0)").Scan(&total).Error
return int(total), err
}
// Count 返回所有订单的总数量。
func (s *OrderStore) Count() (int, error) {
var count int64
if err := s.db.Model(&database.OrderRow{}).Count(&count).Error; err != nil {
@@ -128,13 +173,69 @@ func (s *OrderStore) Count() (int, error) {
return int(count), nil
}
// Delete 根据 ID 删除单条订单。
func (s *OrderStore) Delete(id string) error {
return s.db.Delete(&database.OrderRow{}, "id = ?", id).Error
}
// UpdateCodes 更新订单的已发货卡密列表。
func (s *OrderStore) UpdateCodes(id string, codes []string) error {
return s.db.Model(&database.OrderRow{}).Where("id = ?", id).
Update("delivered_codes", database.StringSlice(codes)).Error
}
// TryFulfillMengyaPayment 在事务内按 FIFO 匹配一笔待支付订单并置为 completed、增加销量。无匹配时 ok=false。
func (s *OrderStore) TryFulfillMengyaPayment(amount float64, now time.Time) (order models.Order, ok bool, err error) {
err = s.db.Transaction(func(tx *gorm.DB) error {
var row database.OrderRow
res := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
Where("status = ? AND payment_expires_at IS NOT NULL AND payment_expires_at > ?", "pending_payment", now).
Where("ABS(payment_expected_total - ?) < ?", amount, amountMatchEpsilon).
Order("created_at ASC").
First(&row)
if res.Error != nil {
if errors.Is(res.Error, gorm.ErrRecordNotFound) {
return nil
}
return res.Error
}
if err := tx.Model(&database.OrderRow{}).Where("id = ?", row.ID).Update("status", "completed").Error; err != nil {
return err
}
if err := tx.Model(&database.ProductRow{}).Where("id = ?", row.ProductID).
UpdateColumn("total_sold", gorm.Expr("total_sold + ?", row.Quantity)).Error; err != nil {
return err
}
row.Status = "completed"
order = orderRowToModel(row)
ok = true
return nil
})
if err != nil {
return models.Order{}, false, err
}
return order, ok, nil
}
// ListExpiredPendingPayments 返回已过期仍未支付的订单(用于释放预留库存)。
func (s *OrderStore) ListExpiredPendingPayments(now time.Time) ([]models.Order, error) {
var rows []database.OrderRow
if err := s.db.Where("status = ? AND payment_expires_at IS NOT NULL AND payment_expires_at <= ?", "pending_payment", now).
Find(&rows).Error; err != nil {
return nil, err
}
out := make([]models.Order, len(rows))
for i, r := range rows {
out[i] = orderRowToModel(r)
}
return out, nil
}
// CancelPendingStaleIfEligible 仅在仍为 pending_payment 时将订单作废并清空预留内容;返回值 ok 表示成功取消(可安全退回库存)。
func (s *OrderStore) CancelPendingStaleIfEligible(id string) (ok bool, err error) {
res := s.db.Model(&database.OrderRow{}).
Where("id = ? AND status = ?", id, "pending_payment").
Updates(map[string]interface{}{
"status": "cancelled",
"delivered_codes": database.StringSlice([]string{}),
})
return res.RowsAffected > 0, res.Error
}

View File

@@ -9,6 +9,7 @@ import (
"github.com/google/uuid"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"mengyastore-backend/internal/database"
"mengyastore-backend/internal/models"
@@ -16,7 +17,8 @@ import (
const defaultCoverURL = "https://img.shumengya.top/i/2026/01/04/695a55058c37f.png"
const viewCooldown = 6 * time.Hour
const maxScreenshotURLs = 5
const maxScreenshotURLs = 6
const maxPaymentQrURLs = 6
type ProductStore struct {
db *gorm.DB
@@ -55,6 +57,7 @@ func rowToModel(row database.ProductRow, codes []string) models.Product {
Tags: row.Tags,
CoverURL: row.CoverURL,
ScreenshotURLs: row.ScreenshotURLs,
PaymentQrURLs: []string(row.PaymentQrURLs),
VerificationURL: row.VerificationURL,
Description: row.Description,
Active: row.Active,
@@ -74,8 +77,12 @@ func rowToModel(row database.ProductRow, codes []string) models.Product {
}
func (s *ProductStore) loadCodes(productID string) ([]string, error) {
return s.loadCodesTx(s.db, productID)
}
func (s *ProductStore) loadCodesTx(tx *gorm.DB, productID string) ([]string, error) {
var rows []database.ProductCodeRow
if err := s.db.Where("product_id = ?", productID).Find(&rows).Error; err != nil {
if err := tx.Where("product_id = ?", productID).Find(&rows).Error; err != nil {
return nil, err
}
codes := make([]string, len(rows))
@@ -85,8 +92,46 @@ func (s *ProductStore) loadCodes(productID string) ([]string, error) {
return codes, nil
}
// Transaction 在同一数据库连接上开启事务并执行 fn。
func (s *ProductStore) Transaction(fn func(tx *gorm.DB) error) error {
return s.db.Transaction(fn)
}
// GetByIDForUpdate 在事务内以 FOR UPDATE 锁定商品行(须在事务中调用)。
func (s *ProductStore) GetByIDForUpdate(tx *gorm.DB, id string) (models.Product, error) {
var row database.ProductRow
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&row, "id = ?", id).Error; err != nil {
return models.Product{}, fmt.Errorf("product not found")
}
codes, err := s.loadCodesTx(tx, id)
if err != nil {
return models.Product{}, err
}
return rowToModel(row, codes), nil
}
// PrependReservedCodes 把曾预留在订单里的卡密按原顺序放回该商品可用库存队列前端FIFO 出库顺序)。
func (s *ProductStore) PrependReservedCodes(productID string, codes []string) error {
codes = sanitizeCodes(codes)
if len(codes) == 0 {
return nil
}
existing, err := s.loadCodes(productID)
if err != nil {
return err
}
merged := make([]string, 0, len(codes)+len(existing))
merged = append(merged, codes...)
merged = append(merged, existing...)
return s.replaceCodes(productID, merged)
}
func (s *ProductStore) replaceCodes(productID string, codes []string) error {
if err := s.db.Where("product_id = ?", productID).Delete(&database.ProductCodeRow{}).Error; err != nil {
return s.replaceCodesTx(s.db, productID, codes)
}
func (s *ProductStore) replaceCodesTx(tx *gorm.DB, productID string, codes []string) error {
if err := tx.Where("product_id = ?", productID).Delete(&database.ProductCodeRow{}).Error; err != nil {
return err
}
if len(codes) == 0 {
@@ -96,7 +141,7 @@ func (s *ProductStore) replaceCodes(productID string, codes []string) error {
for _, code := range codes {
rows = append(rows, database.ProductCodeRow{ProductID: productID, Code: code})
}
return s.db.CreateInBatches(rows, 100).Error
return tx.CreateInBatches(rows, 100).Error
}
func (s *ProductStore) ListAll() ([]models.Product, error) {
@@ -158,6 +203,7 @@ func (s *ProductStore) Create(p models.Product) (models.Product, error) {
Tags: database.StringSlice(p.Tags),
CoverURL: p.CoverURL,
ScreenshotURLs: database.StringSlice(p.ScreenshotURLs),
PaymentQrURLs: database.StringSlice(p.PaymentQrURLs),
VerificationURL: p.VerificationURL,
Description: p.Description,
Active: p.Active,
@@ -198,6 +244,7 @@ func (s *ProductStore) Update(id string, patch models.Product) (models.Product,
"tags": database.StringSlice(normalized.Tags),
"cover_url": normalized.CoverURL,
"screenshot_urls": database.StringSlice(normalized.ScreenshotURLs),
"payment_qr_urls": database.StringSlice(normalized.PaymentQrURLs),
"verification_url": normalized.VerificationURL,
"description": normalized.Description,
"active": normalized.Active,
@@ -221,6 +268,45 @@ func (s *ProductStore) Update(id string, patch models.Product) (models.Product,
return rowToModel(updated, codes), nil
}
// UpdateTx 在事务内更新商品及卡密列表(与 Update 行为一致)。
func (s *ProductStore) UpdateTx(tx *gorm.DB, id string, patch models.Product) (models.Product, error) {
var row database.ProductRow
if err := tx.First(&row, "id = ?", id).Error; err != nil {
return models.Product{}, fmt.Errorf("product not found")
}
normalized := normalizeProduct(patch)
if err := tx.Model(&row).Updates(map[string]interface{}{
"name": normalized.Name,
"price": normalized.Price,
"discount_price": normalized.DiscountPrice,
"tags": database.StringSlice(normalized.Tags),
"cover_url": normalized.CoverURL,
"screenshot_urls": database.StringSlice(normalized.ScreenshotURLs),
"payment_qr_urls": database.StringSlice(normalized.PaymentQrURLs),
"verification_url": normalized.VerificationURL,
"description": normalized.Description,
"active": normalized.Active,
"require_login": normalized.RequireLogin,
"max_per_account": normalized.MaxPerAccount,
"delivery_mode": normalized.DeliveryMode,
"fulfillment_type": normalized.FulfillmentType,
"fixed_content": normalized.FixedContent,
"show_note": normalized.ShowNote,
"show_contact": normalized.ShowContact,
}).Error; err != nil {
return models.Product{}, err
}
if err := s.replaceCodesTx(tx, id, normalized.Codes); err != nil {
return models.Product{}, err
}
var updated database.ProductRow
tx.First(&updated, "id = ?", id)
codes, _ := s.loadCodesTx(tx, id)
return rowToModel(updated, codes), nil
}
func (s *ProductStore) Toggle(id string, active bool) (models.Product, error) {
if err := s.db.Model(&database.ProductRow{}).Where("id = ?", id).Update("active", active).Error; err != nil {
return models.Product{}, err
@@ -266,10 +352,8 @@ func (s *ProductStore) IncrementView(id, fingerprint string) (models.Product, bo
return rowToModel(row, nil), true, nil
}
// IncrementViewCount atomically increments view_count by 1 and returns the
// updated product (without codes). It is called by the handler when Redis-based
// deduplication has already confirmed this is a new view, so no in-memory lock
// is needed here.
// IncrementViewCount 原子地将 view_count +1并返回更新后的商品不含卡密
// 在 Handler 已用 Redis 去重确认本次为新浏览时调用,故此处无需进程内锁。
func (s *ProductStore) IncrementViewCount(id string) (models.Product, error) {
if err := s.db.Model(&database.ProductRow{}).Where("id = ?", id).
UpdateColumn("view_count", gorm.Expr("view_count + 1")).Error; err != nil {
@@ -282,8 +366,8 @@ func (s *ProductStore) IncrementViewCount(id string) (models.Product, error) {
return rowToModel(row, nil), nil
}
// GetViewState returns the current product state without modifying any counter.
// Used by the handler when a view is detected as duplicate via Redis.
// GetViewState 只读取当前商品状态,不修改任何计数。
// 在 Handler 已通过 Redis 判定为重复访问时使用。
func (s *ProductStore) GetViewState(id string) (models.Product, error) {
var row database.ProductRow
if err := s.db.First(&row, "id = ?", id).Error; err != nil {
@@ -309,12 +393,16 @@ func normalizeProduct(item models.Product) models.Product {
item.Tags = []string{}
}
item.Tags = sanitizeTags(item.Tags)
if item.PaymentQrURLs == nil {
item.PaymentQrURLs = []string{}
}
if item.ScreenshotURLs == nil {
item.ScreenshotURLs = []string{}
}
if len(item.ScreenshotURLs) > maxScreenshotURLs {
item.ScreenshotURLs = item.ScreenshotURLs[:maxScreenshotURLs]
}
item.PaymentQrURLs = sanitizePaymentQrURLs(item.PaymentQrURLs)
if item.Codes == nil {
item.Codes = []string{}
}
@@ -340,6 +428,23 @@ func normalizeProduct(item models.Product) models.Product {
return item
}
func sanitizePaymentQrURLs(urls []string) []string {
clean := make([]string, 0, len(urls))
seen := map[string]bool{}
for _, u := range urls {
t := strings.TrimSpace(u)
if t == "" || seen[t] {
continue
}
seen[t] = true
clean = append(clean, t)
if len(clean) >= maxPaymentQrURLs {
break
}
}
return clean
}
func sanitizeCodes(codes []string) []string {
clean := make([]string, 0, len(codes))
seen := map[string]bool{}

View File

@@ -20,7 +20,7 @@ func NewSiteStore(db *gorm.DB) (*SiteStore, error) {
func (s *SiteStore) get(key string) (string, error) {
var row database.SiteSettingRow
// `key` 是 MySQL 保留字,需用反引号转义以避免 SQL 语法错误。
// 使用 Find+Limit 而 First缺键时不产生 ErrRecordNotFound避免 GORM 默认 logger 刷「record not found」。
// 使用 Find+Limit 而不是 First缺键时不产生 ErrRecordNotFound避免 GORM 默认 logger 刷「record not found」。
if err := s.db.Where("`key` = ?", key).Limit(1).Find(&row).Error; err != nil {
return "", err
}