feat: fulfillment modes, MQ, admin status, docs; scrub compose secrets

Made-with: Cursor
This commit is contained in:
2026-04-03 16:26:38 +08:00
parent 1f16966174
commit b4c5103fb3
56 changed files with 4088 additions and 3742 deletions

View File

@@ -1,45 +1,246 @@
package config
import (
"encoding/json"
"fmt"
"os"
)
type Config struct {
AdminToken string `json:"adminToken"`
AuthAPIURL string `json:"authApiUrl"`
// 数据库 DSN为空时回退到测试数据库。
// 格式user:pass@tcp(host:port)/dbname?charset=utf8mb4&parseTime=True&loc=Local
DatabaseDSN string `json:"databaseDsn"`
}
// 各环境默认 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"
)
func Load(path string) (*Config, error) {
var cfg Config
data, err := os.ReadFile(path)
if err == nil {
if jsonErr := json.Unmarshal(data, &cfg); jsonErr != nil {
return nil, fmt.Errorf("解析配置文件 %s 失败: %w", path, jsonErr)
}
}
// 文件不存在时使用默认值,环境变量在下方仍优先生效。
if cfg.AdminToken == "" {
cfg.AdminToken = "changeme"
}
// DATABASE_DSN 环境变量优先于配置文件。
if dsn := os.Getenv("DATABASE_DSN"); dsn != "" {
cfg.DatabaseDSN = dsn
}
if cfg.DatabaseDSN == "" {
cfg.DatabaseDSN = TestDSN
}
return &cfg, nil
}
package config
import (
"net/url"
"os"
"path/filepath"
"strconv"
"strings"
"github.com/joho/godotenv"
)
// Config is populated entirely from environment variables (optionally set via .env — see Load).
type Config struct {
AppEnv string
AdminToken string
AuthAPIURL string
DatabaseDSN string
// 进程与对外访问(管理后台「系统状态」展示)
HTTPListenAddr string
PublicAPIBaseURL string
// Redis可选用于缓存时配置未启用则不连接
RedisEnabled bool
RedisAddr string
RedisPassword string
RedisDB int
RedisEnv string
RabbitMQEnabled bool
RabbitMQURL string
RabbitMQEnv string
}
// 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).
const (
EnvDevelopment = "development"
EnvProduction = "production"
)
// Built-in DSN fallbacks when DATABASE_DSN is empty.
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"
DefaultRabbitMQHost = "10.1.1.233"
DefaultRabbitMQPort = "5672"
DefaultRabbitVHost = "mengyastore-dev"
DefaultRabbitMQUser = "admin"
ProdRabbitMQHost = "192.168.1.100"
DefaultRabbitVHostProd = "mengyastore-prod"
// Redis与 MySQL 同网段;未设置 REDIS_ADDR / REDIS_PASSWORD 时按 APP_ENV 填入
TestRedisAddr = "10.1.1.100:6379"
ProdRedisAddr = "192.168.1.100:6379"
// 内网默认口令(可用环境变量 REDIS_PASSWORD 覆盖)
DefaultRedisPassword = "tyh@19900420"
)
// Load reads optional .env file(s) then builds Config from the process environment.
//
// Dotenv resolution order:
// 1. File named by ENV_FILE (if set)
// 2. .env in current working directory
//
// 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
// 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
//
// HTTP_LISTEN_ADDR — 进程监听,默认 :8080
// PUBLIC_API_BASE_URL — 对外 API 基地址(反向代理场景)
//
// REDIS_ENABLED — 默认 true显式 false/0/off 则关闭
// REDIS_ADDR — 默认 development→TestRedisAddrproduction→ProdRedisAddr
// REDIS_PASSWORD — 默认 DefaultRedisPassword建议生产用环境变量覆盖
// REDIS_DB — 逻辑库编号,默认 1
// REDIS_ENV — dev|prod展示用 Key 前缀环境)
func Load() (*Config, error) {
loadDotenv()
appEnv := normalizeAppEnv(os.Getenv("APP_ENV"))
cfg := &Config{
AppEnv: appEnv,
AdminToken: strings.TrimSpace(os.Getenv("ADMIN_TOKEN")),
AuthAPIURL: strings.TrimSpace(os.Getenv("AUTH_API_URL")),
DatabaseDSN: strings.TrimSpace(os.Getenv("DATABASE_DSN")),
RabbitMQURL: strings.TrimSpace(os.Getenv("RABBITMQ_URL")),
}
if cfg.AdminToken == "" {
cfg.AdminToken = "changeme"
}
if cfg.DatabaseDSN == "" {
if appEnv == EnvProduction {
cfg.DatabaseDSN = ProdDSN
} else {
cfg.DatabaseDSN = TestDSN
}
}
if v := os.Getenv("RABBITMQ_ENABLED"); v != "" {
cfg.RabbitMQEnabled = v == "true" || v == "1"
}
cfg.RabbitMQEnv = strings.TrimSpace(os.Getenv("RABBITMQ_ENV"))
if cfg.RabbitMQEnv == "" {
if appEnv == EnvProduction {
cfg.RabbitMQEnv = "prod"
} else {
cfg.RabbitMQEnv = "dev"
}
}
if cfg.RabbitMQURL == "" && cfg.RabbitMQEnabled {
cfg.RabbitMQURL = composeRabbitMQURL(cfg)
}
if v := os.Getenv("HTTP_LISTEN_ADDR"); v != "" {
cfg.HTTPListenAddr = strings.TrimSpace(v)
}
if cfg.HTTPListenAddr == "" {
cfg.HTTPListenAddr = ":8080"
}
cfg.PublicAPIBaseURL = strings.TrimSpace(os.Getenv("PUBLIC_API_BASE_URL"))
cfg.RedisEnabled = redisEnabledFromEnv(os.Getenv("REDIS_ENABLED"))
cfg.RedisAddr = strings.TrimSpace(os.Getenv("REDIS_ADDR"))
cfg.RedisPassword = os.Getenv("REDIS_PASSWORD")
if v := os.Getenv("REDIS_DB"); v != "" {
if n, err := strconv.Atoi(v); err == nil {
cfg.RedisDB = n
}
}
cfg.RedisEnv = strings.TrimSpace(os.Getenv("REDIS_ENV"))
if cfg.RedisEnv == "" {
if appEnv == EnvProduction {
cfg.RedisEnv = "prod"
} else {
cfg.RedisEnv = "dev"
}
}
if cfg.RedisEnabled {
if cfg.RedisAddr == "" {
if appEnv == EnvProduction {
cfg.RedisAddr = ProdRedisAddr
} else {
cfg.RedisAddr = TestRedisAddr
}
}
if cfg.RedisPassword == "" {
cfg.RedisPassword = DefaultRedisPassword
}
if cfg.RedisDB == 0 {
cfg.RedisDB = 1
}
}
return cfg, nil
}
// redisEnabledFromEnv defaults to true unless explicitly turned off.
func redisEnabledFromEnv(v string) bool {
s := strings.ToLower(strings.TrimSpace(v))
if s == "false" || s == "0" || s == "no" || s == "off" {
return false
}
if s == "true" || s == "1" || s == "yes" || s == "on" {
return true
}
return true
}
func loadDotenv() {
if f := strings.TrimSpace(os.Getenv("ENV_FILE")); f != "" {
_ = godotenv.Load(f)
return
}
// Standard local file; ignore missing.
_ = godotenv.Load(filepath.Clean(".env"))
}
func normalizeAppEnv(s string) string {
switch strings.ToLower(strings.TrimSpace(s)) {
case "production", "prod":
return EnvProduction
default:
return EnvDevelopment
}
}
func composeRabbitMQURL(cfg *Config) string {
pass := os.Getenv("RABBITMQ_PASSWORD")
if pass == "" {
return ""
}
host := os.Getenv("RABBITMQ_HOST")
port := os.Getenv("RABBITMQ_PORT")
user := os.Getenv("RABBITMQ_USER")
vhost := os.Getenv("RABBITMQ_VHOST")
if host == "" {
if cfg.RabbitMQEnv == "prod" {
host = ProdRabbitMQHost
} else {
host = DefaultRabbitMQHost
}
}
if port == "" {
port = DefaultRabbitMQPort
}
if user == "" {
user = DefaultRabbitMQUser
}
if vhost == "" {
if cfg.RabbitMQEnv == "prod" {
vhost = DefaultRabbitVHostProd
} else {
vhost = DefaultRabbitVHost
}
}
var path string
if vhost == "/" {
path = "/%2F"
} else {
path = "/" + url.PathEscape(vhost)
}
u := url.URL{
Scheme: "amqp",
User: url.UserPassword(user, pass),
Host: host + ":" + port,
Path: path,
}
return u.String()
}

View File

@@ -50,6 +50,9 @@ type ProductRow struct {
TotalSold int `gorm:"default:0"`
ViewCount int `gorm:"default:0"`
DeliveryMode string `gorm:"size:20;default:'auto'"`
// FulfillmentType: card=卡密库存 / fixed=固定内容(不限库存,内容见 FixedContent
FulfillmentType string `gorm:"size:16;default:card;index"`
FixedContent string `gorm:"type:text"`
ShowNote bool `gorm:"default:true"`
ShowContact bool `gorm:"default:true"`
CreatedAt time.Time `gorm:"index"`

View File

@@ -1,228 +1,243 @@
package handlers
import (
"log"
"net/http"
"strings"
"github.com/gin-gonic/gin"
"mengyastore-backend/internal/cache"
"mengyastore-backend/internal/models"
)
type productPayload struct {
Name string `json:"name"`
Price float64 `json:"price"`
DiscountPrice float64 `json:"discountPrice"`
Tags string `json:"tags"`
CoverURL string `json:"coverUrl"`
Codes []string `json:"codes"`
ScreenshotURLs []string `json:"screenshotUrls"`
Description string `json:"description"`
Active *bool `json:"active"`
RequireLogin bool `json:"requireLogin"`
MaxPerAccount int `json:"maxPerAccount"`
DeliveryMode string `json:"deliveryMode"`
ShowNote bool `json:"showNote"`
ShowContact bool `json:"showContact"`
}
type togglePayload struct {
Active bool `json:"active"`
}
// invalidateProductCache removes the cached product list so that the next
// public request rebuilds it from the freshest DB state (Write-Invalidate).
func (h *AdminHandler) invalidateProductCache(c *gin.Context) {
if h.cache == nil {
return
}
if err := h.cache.Del(c.Request.Context(), cache.KeyProductList); err != nil {
log.Printf("[cache] DEL %s error: %v", cache.KeyProductList, err)
}
}
// VerifyAdminToken checks whether the supplied token is correct.
// Returns {"valid": true/false} without leaking the real token value.
func (h *AdminHandler) VerifyAdminToken(c *gin.Context) {
var payload struct {
Token string `json:"token"`
}
if err := c.ShouldBindJSON(&payload); err != nil || payload.Token == "" {
c.JSON(http.StatusOK, gin.H{"valid": false})
return
}
c.JSON(http.StatusOK, gin.H{"valid": payload.Token == h.cfg.AdminToken})
}
func (h *AdminHandler) ListAllProducts(c *gin.Context) {
if !h.requireAdmin(c) {
return
}
items, err := h.store.ListAll()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"data": items})
}
func (h *AdminHandler) CreateProduct(c *gin.Context) {
if !h.requireAdmin(c) {
return
}
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 条"})
return
}
active := true
if payload.Active != nil {
active = *payload.Active
}
product := models.Product{
Name: payload.Name,
Price: payload.Price,
DiscountPrice: payload.DiscountPrice,
Tags: normalizeTags(payload.Tags),
CoverURL: strings.TrimSpace(payload.CoverURL),
Codes: payload.Codes,
ScreenshotURLs: screenshotURLs,
Description: payload.Description,
Active: active,
RequireLogin: payload.RequireLogin,
MaxPerAccount: payload.MaxPerAccount,
DeliveryMode: "auto",
ShowNote: payload.ShowNote,
ShowContact: payload.ShowContact,
}
created, err := h.store.Create(product)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
h.invalidateProductCache(c) // Write-Invalidate: clear stale cache
c.JSON(http.StatusOK, gin.H{"data": created})
}
func (h *AdminHandler) UpdateProduct(c *gin.Context) {
if !h.requireAdmin(c) {
return
}
id := c.Param("id")
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 条"})
return
}
active := false
if payload.Active != nil {
active = *payload.Active
}
patch := models.Product{
Name: payload.Name,
Price: payload.Price,
DiscountPrice: payload.DiscountPrice,
Tags: normalizeTags(payload.Tags),
CoverURL: strings.TrimSpace(payload.CoverURL),
Codes: payload.Codes,
ScreenshotURLs: screenshotURLs,
Description: payload.Description,
Active: active,
RequireLogin: payload.RequireLogin,
MaxPerAccount: payload.MaxPerAccount,
DeliveryMode: "auto",
ShowNote: payload.ShowNote,
ShowContact: payload.ShowContact,
}
updated, err := h.store.Update(id, patch)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
return
}
h.invalidateProductCache(c)
c.JSON(http.StatusOK, gin.H{"data": updated})
}
func (h *AdminHandler) ToggleProduct(c *gin.Context) {
if !h.requireAdmin(c) {
return
}
id := c.Param("id")
var payload togglePayload
if err := c.ShouldBindJSON(&payload); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数有误"})
return
}
updated, err := h.store.Toggle(id, payload.Active)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
return
}
h.invalidateProductCache(c)
c.JSON(http.StatusOK, gin.H{"data": updated})
}
func (h *AdminHandler) DeleteProduct(c *gin.Context) {
if !h.requireAdmin(c) {
return
}
id := c.Param("id")
if err := h.store.Delete(id); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
h.invalidateProductCache(c)
c.JSON(http.StatusOK, gin.H{"data": gin.H{"ok": true}})
}
func normalizeScreenshotURLs(urls []string) ([]string, bool) {
cleaned := make([]string, 0, len(urls))
for _, url := range urls {
trimmed := strings.TrimSpace(url)
if trimmed == "" {
continue
}
cleaned = append(cleaned, trimmed)
if len(cleaned) > 5 {
return nil, false
}
}
return cleaned, true
}
func normalizeTags(tagsCSV string) []string {
if tagsCSV == "" {
return []string{}
}
parts := strings.Split(tagsCSV, ",")
clean := make([]string, 0, len(parts))
seen := map[string]bool{}
for _, p := range parts {
t := strings.TrimSpace(p)
if t == "" {
continue
}
key := strings.ToLower(t)
if seen[key] {
continue
}
seen[key] = true
clean = append(clean, t)
if len(clean) >= 20 {
break
}
}
return clean
}
package handlers
import (
"net/http"
"strings"
"github.com/gin-gonic/gin"
"mengyastore-backend/internal/models"
)
type productPayload struct {
Name string `json:"name"`
Price float64 `json:"price"`
DiscountPrice float64 `json:"discountPrice"`
Tags string `json:"tags"`
CoverURL string `json:"coverUrl"`
Codes []string `json:"codes"`
ScreenshotURLs []string `json:"screenshotUrls"`
Description string `json:"description"`
Active *bool `json:"active"`
RequireLogin bool `json:"requireLogin"`
MaxPerAccount int `json:"maxPerAccount"`
DeliveryMode string `json:"deliveryMode"`
FulfillmentType string `json:"fulfillmentType"`
FixedContent string `json:"fixedContent"`
ShowNote bool `json:"showNote"`
ShowContact bool `json:"showContact"`
}
func normalizeFulfillmentPayload(payload *productPayload) string {
ft := strings.TrimSpace(strings.ToLower(payload.FulfillmentType))
if ft == "fixed" {
return "fixed"
}
return "card"
}
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.
func (h *AdminHandler) VerifyAdminToken(c *gin.Context) {
var payload struct {
Token string `json:"token"`
}
if err := c.ShouldBindJSON(&payload); err != nil || payload.Token == "" {
c.JSON(http.StatusOK, gin.H{"valid": false})
return
}
c.JSON(http.StatusOK, gin.H{"valid": payload.Token == h.cfg.AdminToken})
}
func (h *AdminHandler) ListAllProducts(c *gin.Context) {
if !h.requireAdmin(c) {
return
}
items, err := h.store.ListAll()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"data": items})
}
func (h *AdminHandler) CreateProduct(c *gin.Context) {
if !h.requireAdmin(c) {
return
}
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 条"})
return
}
active := true
if payload.Active != nil {
active = *payload.Active
}
ft := normalizeFulfillmentPayload(&payload)
if ft == "fixed" && strings.TrimSpace(payload.FixedContent) == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "固定内容发货须填写发货内容(如网盘链接、说明文字)"})
return
}
dm := strings.TrimSpace(payload.DeliveryMode)
if dm == "" {
dm = "auto"
}
product := models.Product{
Name: payload.Name,
Price: payload.Price,
DiscountPrice: payload.DiscountPrice,
Tags: normalizeTags(payload.Tags),
CoverURL: strings.TrimSpace(payload.CoverURL),
Codes: payload.Codes,
ScreenshotURLs: screenshotURLs,
Description: payload.Description,
Active: active,
RequireLogin: payload.RequireLogin,
MaxPerAccount: payload.MaxPerAccount,
DeliveryMode: dm,
FulfillmentType: ft,
FixedContent: payload.FixedContent,
ShowNote: payload.ShowNote,
ShowContact: payload.ShowContact,
}
created, err := h.store.Create(product)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"data": created})
}
func (h *AdminHandler) UpdateProduct(c *gin.Context) {
if !h.requireAdmin(c) {
return
}
id := c.Param("id")
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 条"})
return
}
active := false
if payload.Active != nil {
active = *payload.Active
}
ft := normalizeFulfillmentPayload(&payload)
if ft == "fixed" && strings.TrimSpace(payload.FixedContent) == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "固定内容发货须填写发货内容(如网盘链接、说明文字)"})
return
}
dm := strings.TrimSpace(payload.DeliveryMode)
if dm == "" {
dm = "auto"
}
patch := models.Product{
Name: payload.Name,
Price: payload.Price,
DiscountPrice: payload.DiscountPrice,
Tags: normalizeTags(payload.Tags),
CoverURL: strings.TrimSpace(payload.CoverURL),
Codes: payload.Codes,
ScreenshotURLs: screenshotURLs,
Description: payload.Description,
Active: active,
RequireLogin: payload.RequireLogin,
MaxPerAccount: payload.MaxPerAccount,
DeliveryMode: dm,
FulfillmentType: ft,
FixedContent: payload.FixedContent,
ShowNote: payload.ShowNote,
ShowContact: payload.ShowContact,
}
updated, err := h.store.Update(id, patch)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"data": updated})
}
func (h *AdminHandler) ToggleProduct(c *gin.Context) {
if !h.requireAdmin(c) {
return
}
id := c.Param("id")
var payload togglePayload
if err := c.ShouldBindJSON(&payload); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数有误"})
return
}
updated, err := h.store.Toggle(id, payload.Active)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"data": updated})
}
func (h *AdminHandler) DeleteProduct(c *gin.Context) {
if !h.requireAdmin(c) {
return
}
id := c.Param("id")
if err := h.store.Delete(id); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"data": gin.H{"ok": true}})
}
func normalizeScreenshotURLs(urls []string) ([]string, bool) {
cleaned := make([]string, 0, len(urls))
for _, url := range urls {
trimmed := strings.TrimSpace(url)
if trimmed == "" {
continue
}
cleaned = append(cleaned, trimmed)
if len(cleaned) > 5 {
return nil, false
}
}
return cleaned, true
}
func normalizeTags(tagsCSV string) []string {
if tagsCSV == "" {
return []string{}
}
parts := strings.Split(tagsCSV, ",")
clean := make([]string, 0, len(parts))
seen := map[string]bool{}
for _, p := range parts {
t := strings.TrimSpace(p)
if t == "" {
continue
}
key := strings.ToLower(t)
if seen[key] {
continue
}
seen[key] = true
clean = append(clean, t)
if len(clean) >= 20 {
break
}
}
return clean
}

View File

@@ -0,0 +1,310 @@
package handlers
import (
"context"
"net"
"net/http"
"net/url"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/go-sql-driver/mysql"
"github.com/redis/go-redis/v9"
gormDB "gorm.io/gorm"
"mengyastore-backend/internal/config"
"mengyastore-backend/internal/mq"
)
// SystemStatusHandler exposes admin-only operational status.
type SystemStatusHandler struct {
cfg *config.Config
db *gormDB.DB
mq *mq.Client
start time.Time
}
func NewSystemStatusHandler(cfg *config.Config, db *gormDB.DB, mqClient *mq.Client, startedAt time.Time) *SystemStatusHandler {
return &SystemStatusHandler{cfg: cfg, db: db, mq: mqClient, start: startedAt}
}
// GetSystemStatus returns JSON for the admin dashboard. Requires admin token.
func (h *SystemStatusHandler) GetSystemStatus(c *gin.Context) {
if !h.adminTokenOK(c) {
return
}
ctx, cancel := context.WithTimeout(c.Request.Context(), 8*time.Second)
defer cancel()
c.JSON(http.StatusOK, gin.H{
"data": gin.H{
"backend": h.backendInfo(c),
"mysql": h.mysqlInfo(ctx),
"redis": h.redisInfo(ctx),
"rabbitmq": h.rabbitmqInfo(),
},
})
}
func (h *SystemStatusHandler) adminTokenOK(c *gin.Context) bool {
token := c.GetHeader("X-Admin-Token")
if token == "" {
token = c.GetHeader("Authorization")
}
if token == "" {
token = c.Query("token")
}
if token != "" && token == h.cfg.AdminToken {
return true
}
c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
return false
}
func (h *SystemStatusHandler) backendInfo(c *gin.Context) gin.H {
proto := c.GetHeader("X-Forwarded-Proto")
if proto == "" {
if c.Request.TLS != nil {
proto = "https"
} else {
proto = "http"
}
}
host := c.Request.Host
publicBase := strings.TrimSpace(h.cfg.PublicAPIBaseURL)
if publicBase == "" && host != "" {
publicBase = proto + "://" + host
}
out := gin.H{
"status": "ok",
"appEnv": h.cfg.AppEnv,
"ginMode": gin.Mode(),
"requestHost": host,
"listenAddr": h.cfg.HTTPListenAddr,
"publicBaseUrl": publicBase,
"uptimeSeconds": int(time.Since(h.start).Seconds()),
"authApiConfigured": strings.TrimSpace(h.cfg.AuthAPIURL) != "",
"rabbitmqEnabled": h.cfg.RabbitMQEnabled,
"redisEnabled": h.cfg.RedisEnabled,
}
return out
}
func (h *SystemStatusHandler) mysqlInfo(ctx context.Context) gin.H {
out := gin.H{}
parseMysqlDSNForDisplay(h.cfg.DatabaseDSN, out)
if h.db == nil {
out["status"] = "error"
out["message"] = "数据库未初始化"
return out
}
sqlDB, err := h.db.DB()
if err != nil {
out["status"] = "error"
out["message"] = err.Error()
return out
}
if err := sqlDB.PingContext(ctx); err != nil {
out["status"] = "error"
out["message"] = err.Error()
return out
}
var ver string
if err := h.db.WithContext(ctx).Raw("SELECT VERSION()").Scan(&ver).Error; err != nil {
out["status"] = "degraded"
out["message"] = "连接正常但读取版本失败: " + err.Error()
stats := sqlDB.Stats()
out["openConnections"] = stats.OpenConnections
out["inUse"] = stats.InUse
out["idle"] = stats.Idle
return out
}
stats := sqlDB.Stats()
out["status"] = "ok"
out["version"] = strings.TrimSpace(ver)
out["openConnections"] = stats.OpenConnections
out["inUse"] = stats.InUse
out["idle"] = stats.Idle
out["waitCount"] = stats.WaitCount
return out
}
func parseMysqlDSNForDisplay(dsn string, out gin.H) {
if dsn == "" {
out["configured"] = false
return
}
mc, err := mysql.ParseDSN(dsn)
if err != nil {
out["configured"] = false
out["dsnParseError"] = err.Error()
return
}
out["configured"] = true
out["user"] = mc.User
out["database"] = mc.DBName
host, port, err := net.SplitHostPort(mc.Addr)
if err != nil {
out["host"] = mc.Addr
out["port"] = ""
if mc.Net == "unix" {
out["socket"] = mc.Addr
}
} else {
out["host"] = host
out["port"] = port
}
}
func (h *SystemStatusHandler) redisInfo(ctx context.Context) gin.H {
out := gin.H{
"enabled": h.cfg.RedisEnabled,
"env": h.cfg.RedisEnv,
}
if h.cfg.RedisAddr != "" {
host, port, err := net.SplitHostPort(h.cfg.RedisAddr)
if err != nil {
out["host"] = h.cfg.RedisAddr
out["port"] = "6379"
} else {
out["host"] = host
if port == "" {
port = "6379"
}
out["port"] = port
}
}
out["dbIndex"] = h.cfg.RedisDB
if !h.cfg.RedisEnabled {
out["status"] = "disabled"
return out
}
if h.cfg.RedisAddr == "" {
out["status"] = "misconfigured"
out["message"] = "已启用但未配置 REDIS_ADDR"
return out
}
rdb := redis.NewClient(&redis.Options{
Addr: h.cfg.RedisAddr,
Password: h.cfg.RedisPassword,
DB: h.cfg.RedisDB,
})
defer rdb.Close()
if err := rdb.Ping(ctx).Err(); err != nil {
out["status"] = "error"
out["message"] = err.Error()
return out
}
info, err := rdb.Info(ctx, "server", "memory").Result()
if err != nil {
out["status"] = "degraded"
out["message"] = "PING 成功但无法读取 INFO: " + err.Error()
return out
}
dbsize, _ := rdb.DBSize(ctx).Result()
out["status"] = "ok"
out["redisVersion"] = parseRedisInfoField(info, "redis_version:")
out["usedMemoryHuman"] = parseRedisInfoField(info, "used_memory_human:")
out["keysApprox"] = dbsize
return out
}
func parseRedisInfoField(block, key string) string {
for _, line := range strings.Split(block, "\r\n") {
if strings.HasPrefix(line, key) {
return strings.TrimSpace(strings.TrimPrefix(line, key))
}
}
return ""
}
func (h *SystemStatusHandler) rabbitmqInfo() gin.H {
out := gin.H{
"enabled": h.cfg.RabbitMQEnabled,
"env": h.cfg.RabbitMQEnv,
"exchange": mq.ExchangeName(h.cfg.RabbitMQEnv),
"queue": mq.QueueName(h.cfg.RabbitMQEnv),
"routingKey": mq.OrderEmailRoutingKey(),
}
host, port, vhost, user := parseAMQPBroker(h.cfg.RabbitMQURL)
out["brokerHost"] = host
out["brokerPort"] = port
out["vhost"] = vhost
out["brokerUser"] = user
if !h.cfg.RabbitMQEnabled {
out["status"] = "disabled"
return out
}
if h.cfg.RabbitMQURL == "" {
out["status"] = "misconfigured"
out["message"] = "已启用但未配置 RABBITMQ_URL 或 RABBITMQ_PASSWORD"
return out
}
if h.mq == nil {
out["status"] = "error"
out["message"] = "连接未建立,请检查 Broker 与 vhost 权限"
return out
}
if err := h.mq.Ping(); err != nil {
out["status"] = "error"
out["message"] = err.Error()
return out
}
msgs, cons, err := h.mq.QueueInspectInfo()
if err != nil {
out["status"] = "degraded"
out["message"] = "通道可用但无法读取队列统计: " + err.Error()
return out
}
out["status"] = "ok"
out["messagesReady"] = msgs
out["consumers"] = cons
return out
}
// parseAMQPBroker returns host, port, vhost, user without password (for display only).
func parseAMQPBroker(raw string) (host, port, vhost, user string) {
if raw == "" {
return "", "", "", ""
}
u, err := url.Parse(raw)
if err != nil {
return "", "", "", ""
}
host = u.Hostname()
port = u.Port()
if port == "" {
port = "5672"
}
if u.User != nil {
user = u.User.Username()
}
vpath := strings.TrimPrefix(u.Path, "/")
if vpath != "" {
if dec, err := url.PathUnescape(vpath); err == nil {
vhost = dec
} else {
vhost = vpath
}
}
if vhost == "" {
vhost = "/"
}
return host, port, vhost, user
}

File diff suppressed because it is too large Load Diff

View File

@@ -55,6 +55,7 @@ func sanitizeForPublic(items []models.Product) []models.Product {
out := make([]models.Product, len(items))
for i, item := range items {
item.Codes = nil
item.FixedContent = ""
out[i] = item
}
return out

View File

@@ -20,6 +20,8 @@ type Product struct {
MaxPerAccount int `json:"maxPerAccount"`
TotalSold int `json:"totalSold"`
DeliveryMode string `json:"deliveryMode"`
FulfillmentType string `json:"fulfillmentType"`
FixedContent string `json:"fixedContent"`
ShowNote bool `json:"showNote"`
ShowContact bool `json:"showContact"`
CreatedAt time.Time `json:"createdAt"`

View File

@@ -0,0 +1,268 @@
package mq
import (
"context"
"encoding/json"
"errors"
"fmt"
"log"
"strings"
"sync"
"time"
amqp "github.com/rabbitmq/amqp091-go"
"mengyastore-backend/internal/storage"
)
// Client manages a single AMQP connection, a publish channel, and naming for one env.
type Client struct {
conn *amqp.Connection
pubCh *amqp.Channel
exchange string
queue string
env string
amqpURL string
mu sync.Mutex
closing sync.Once
connected bool
// 用于重连后重启消费协程(与 StartConsumer 注入的一致)
consumerCtx context.Context
consumerSite *storage.SiteStore
}
// New connects to RabbitMQ and declares exchange + queue + binding (idempotent).
func New(amqpURL, env string) (*Client, error) {
if amqpURL == "" {
return nil, fmt.Errorf("empty amqp url")
}
conn, err := amqp.DialConfig(amqpURL, amqp.Config{
Heartbeat: 30 * time.Second,
Locale: "en_US",
})
if err != nil {
return nil, fmt.Errorf("amqp dial: %w", err)
}
pubCh, err := conn.Channel()
if err != nil {
conn.Close()
return nil, fmt.Errorf("amqp channel: %w", err)
}
exchange, queue, err := declareTopology(pubCh, env)
if err != nil {
pubCh.Close()
conn.Close()
return nil, err
}
return &Client{
conn: conn,
pubCh: pubCh,
exchange: exchange,
queue: queue,
env: sanitizeEnv(env),
amqpURL: amqpURL,
connected: true,
}, nil
}
func isRecoverableAMQP(err error) bool {
if err == nil {
return false
}
var e *amqp.Error
if errors.As(err, &e) {
// 504 channel/connection not open、320 连接被服务端关闭等,通过重连恢复
if e.Code == amqp.ChannelError || e.Code == amqp.ConnectionForced {
return true
}
return strings.Contains(strings.ToLower(e.Reason), "not open")
}
s := strings.ToLower(err.Error())
return strings.Contains(s, "channel/connection is not open") ||
strings.Contains(s, "connection closed") ||
strings.Contains(s, "use of closed network connection") ||
strings.Contains(s, "eof")
}
// reconnectLocked 在持有 mu 时调用:关闭旧连接并重新拨号、声明拓扑。
func (c *Client) reconnectLocked() error {
if !c.connected || c.amqpURL == "" {
return fmt.Errorf("mq client closed")
}
if c.pubCh != nil {
_ = c.pubCh.Close()
c.pubCh = nil
}
if c.conn != nil && !c.conn.IsClosed() {
_ = c.conn.Close()
}
c.conn = nil
conn, err := amqp.DialConfig(c.amqpURL, amqp.Config{
Heartbeat: 30 * time.Second,
Locale: "en_US",
})
if err != nil {
return fmt.Errorf("amqp reconnect dial: %w", err)
}
pubCh, err := conn.Channel()
if err != nil {
conn.Close()
return fmt.Errorf("amqp reconnect channel: %w", err)
}
exchange, queue, err := declareTopology(pubCh, c.env)
if err != nil {
pubCh.Close()
conn.Close()
return err
}
c.conn = conn
c.pubCh = pubCh
c.exchange = exchange
c.queue = queue
if c.consumerCtx != nil && c.consumerSite != nil && c.consumerCtx.Err() == nil {
go RunOrderEmailConsumer(c.consumerCtx, c.conn, c.env, c.consumerSite)
log.Printf("[MQ] consumer restarted after reconnect (queue=%s)", c.queue)
}
return nil
}
func (c *Client) passiveQueueLocked() error {
if c.pubCh == nil || !c.connected {
return fmt.Errorf("mq client closed")
}
_, err := c.pubCh.QueueDeclarePassive(c.queue, true, false, false, false, nil)
return err
}
// PublishOrderEmail publishes a persistent JSON message to the order-email routing key.
func (c *Client) PublishOrderEmail(ctx context.Context, p OrderEmailPayload) error {
body, err := json.Marshal(p)
if err != nil {
return fmt.Errorf("marshal payload: %w", err)
}
c.mu.Lock()
defer c.mu.Unlock()
if c.pubCh == nil || !c.connected {
return fmt.Errorf("mq client closed")
}
select {
case <-ctx.Done():
return ctx.Err()
default:
}
tryPublish := func() error {
return c.pubCh.PublishWithContext(ctx,
c.exchange,
routingKeyOrderEmail,
false,
false,
amqp.Publishing{
ContentType: "application/json",
DeliveryMode: amqp.Persistent,
Timestamp: time.Now(),
Body: body,
},
)
}
err = tryPublish()
if err != nil && isRecoverableAMQP(err) {
if rerr := c.reconnectLocked(); rerr == nil {
err = tryPublish()
} else {
err = fmt.Errorf("%w (reconnect: %v)", err, rerr)
}
}
if err != nil {
return fmt.Errorf("publish: %w", err)
}
return nil
}
// QueueInspectInfo returns current queue depth and consumer count (passive declare).
func (c *Client) QueueInspectInfo() (messages int, consumers int, err error) {
c.mu.Lock()
defer c.mu.Unlock()
try := func() (int, int, error) {
if c.pubCh == nil || !c.connected {
return 0, 0, fmt.Errorf("mq client closed")
}
q, e := c.pubCh.QueueDeclarePassive(c.queue, true, false, false, false, nil)
if e != nil {
return 0, 0, e
}
return q.Messages, q.Consumers, nil
}
msgs, cons, err := try()
if err != nil && isRecoverableAMQP(err) {
if rerr := c.reconnectLocked(); rerr != nil {
return 0, 0, fmt.Errorf("%w (reconnect: %v)", err, rerr)
}
return try()
}
return msgs, cons, err
}
// Ping checks that the publish channel can query the declared queue (liveness for /api/health).
func (c *Client) Ping() error {
c.mu.Lock()
defer c.mu.Unlock()
err := c.passiveQueueLocked()
if err != nil && isRecoverableAMQP(err) {
if rerr := c.reconnectLocked(); rerr != nil {
return fmt.Errorf("%w (reconnect: %v)", err, rerr)
}
return c.passiveQueueLocked()
}
return err
}
// Env returns the sanitized environment suffix used in exchange/queue names.
func (c *Client) Env() string { return c.env }
// StartConsumer runs the order-email consumer until ctx is cancelled (run in a goroutine).
func (c *Client) StartConsumer(ctx context.Context, site *storage.SiteStore) {
c.mu.Lock()
c.consumerCtx = ctx
c.consumerSite = site
conn := c.conn
env := c.env
c.mu.Unlock()
if conn == nil || site == nil {
return
}
RunOrderEmailConsumer(ctx, conn, env, site)
}
// Close releases the publish channel and connection.
func (c *Client) Close() {
c.closing.Do(func() {
c.mu.Lock()
defer c.mu.Unlock()
c.connected = false
c.consumerCtx = nil
c.consumerSite = nil
if c.pubCh != nil {
_ = c.pubCh.Close()
c.pubCh = nil
}
if c.conn != nil {
_ = c.conn.Close()
c.conn = nil
}
})
}

View File

@@ -0,0 +1,101 @@
package mq
import (
"context"
"encoding/json"
"log"
amqp "github.com/rabbitmq/amqp091-go"
"mengyastore-backend/internal/email"
"mengyastore-backend/internal/storage"
)
// RunOrderEmailConsumer runs until ctx is done. Must be started in its own goroutine.
func RunOrderEmailConsumer(ctx context.Context, conn *amqp.Connection, env string, site *storage.SiteStore) {
if conn == nil || site == nil {
return
}
ch, err := conn.Channel()
if err != nil {
log.Printf("[MQ] consumer channel: %v", err)
return
}
defer ch.Close()
if _, _, err := declareTopology(ch, env); err != nil {
log.Printf("[MQ] consumer declare topology: %v", err)
return
}
if err := ch.Qos(1, 0, false); err != nil {
log.Printf("[MQ] consumer qos: %v", err)
return
}
queue := QueueName(env)
const tag = "mengyastore-order-email"
msgs, err := ch.Consume(queue, tag, false, false, false, false, nil)
if err != nil {
log.Printf("[MQ] consume %s: %v", queue, err)
return
}
log.Printf("[MQ] consumer started queue=%s", queue)
for {
select {
case <-ctx.Done():
_ = ch.Cancel(tag, false)
log.Printf("[MQ] consumer stopped queue=%s", queue)
return
case d, ok := <-msgs:
if !ok {
return
}
handleDelivery(site, d)
}
}
}
func handleDelivery(site *storage.SiteStore, d amqp.Delivery) {
var payload OrderEmailPayload
if err := json.Unmarshal(d.Body, &payload); err != nil {
log.Printf("[MQ] bad message: %v", err)
_ = d.Ack(false)
return
}
if payload.ToEmail == "" {
_ = d.Ack(false)
return
}
cfg, err := site.GetSMTPConfig()
if err != nil || !cfg.IsConfiguredEmail() {
log.Printf("[MQ] skip email order=%s: smtp not configured", payload.OrderID)
_ = d.Ack(false)
return
}
emailCfg := email.Config{
SMTPHost: cfg.Host,
SMTPPort: cfg.Port,
From: cfg.Email,
Password: cfg.Password,
FromName: cfg.FromName,
}
data := payload.ToNotifyData()
if err := email.SendOrderNotify(emailCfg, data); err != nil {
log.Printf("[MQ] send email fail order=%s: %v", payload.OrderID, err)
if d.Redelivered {
log.Printf("[MQ] drop order=%s after failed redelivery", payload.OrderID)
_ = d.Ack(false)
return
}
_ = d.Nack(false, true)
return
}
log.Printf("[MQ] email ok order=%s to=%s", payload.OrderID, payload.ToEmail)
_ = d.Ack(false)
}

View File

@@ -0,0 +1,26 @@
package mq
import "mengyastore-backend/internal/email"
// OrderEmailPayload is the JSON body published to RabbitMQ (no SMTP secrets).
type OrderEmailPayload struct {
ToEmail string `json:"toEmail"`
ToName string `json:"toName"`
ProductName string `json:"productName"`
OrderID string `json:"orderId"`
Quantity int `json:"quantity"`
Codes []string `json:"codes"`
IsManual bool `json:"isManual"`
}
func (p OrderEmailPayload) ToNotifyData() email.OrderNotifyData {
return email.OrderNotifyData{
ToEmail: p.ToEmail,
ToName: p.ToName,
ProductName: p.ProductName,
OrderID: p.OrderID,
Quantity: p.Quantity,
Codes: p.Codes,
IsManual: p.IsManual,
}
}

View File

@@ -0,0 +1,71 @@
package mq
import (
"fmt"
"strings"
amqp "github.com/rabbitmq/amqp091-go"
)
const routingKeyOrderEmail = "order.email.notify"
// OrderEmailRoutingKey is the binding / publish key for order notification messages.
func OrderEmailRoutingKey() string { return routingKeyOrderEmail }
// ExchangeName returns the durable topic exchange for this app + env (isolation from other apps on same 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.
func QueueName(env string) string {
return fmt.Sprintf("q.mengyastore.%s.order_email", sanitizeEnv(env))
}
func sanitizeEnv(env string) string {
e := strings.TrimSpace(strings.ToLower(env))
if e == "prod" || e == "production" {
return "prod"
}
return "dev"
}
func declareTopology(ch *amqp.Channel, env string) (exchange, queue string, err error) {
exchange = ExchangeName(env)
queue = QueueName(env)
if err = ch.ExchangeDeclare(
exchange,
"topic",
true,
false,
false,
false,
nil,
); err != nil {
return "", "", fmt.Errorf("exchange declare: %w", err)
}
if _, err = ch.QueueDeclare(
queue,
true,
false,
false,
false,
nil,
); err != nil {
return "", "", fmt.Errorf("queue declare: %w", err)
}
if err = ch.QueueBind(
queue,
routingKeyOrderEmail,
exchange,
false,
nil,
); err != nil {
return "", "", fmt.Errorf("queue bind: %w", err)
}
return exchange, queue, nil
}

View File

@@ -31,8 +31,22 @@ func NewProductStore(db *gorm.DB) (*ProductStore, error) {
}, nil
}
// rowToModel 将数据库行(含卡密)转换为业务模型。
func effectiveFulfillment(t string) string {
switch strings.ToLower(strings.TrimSpace(t)) {
case "fixed":
return "fixed"
default:
return "card"
}
}
// rowToModel 将数据库行(含卡密)转换为业务模型。固定内容类商品 Quantity 为 -1 表示不限库存。
func rowToModel(row database.ProductRow, codes []string) models.Product {
ft := effectiveFulfillment(row.FulfillmentType)
qty := len(codes)
if ft == "fixed" {
qty = -1
}
return models.Product{
ID: row.ID,
Name: row.Name,
@@ -49,10 +63,12 @@ func rowToModel(row database.ProductRow, codes []string) models.Product {
TotalSold: row.TotalSold,
ViewCount: row.ViewCount,
DeliveryMode: row.DeliveryMode,
FulfillmentType: ft,
FixedContent: row.FixedContent,
ShowNote: row.ShowNote,
ShowContact: row.ShowContact,
Codes: codes,
Quantity: len(codes),
Quantity: qty,
CreatedAt: row.CreatedAt,
}
}
@@ -103,13 +119,17 @@ func (s *ProductStore) ListActive() ([]models.Product, error) {
}
products := make([]models.Product, 0, len(rows))
for _, row := range rows {
// 公开接口不暴露卡密,但需要统计剩余库存数量
var count int64
s.db.Model(&database.ProductCodeRow{}).Where("product_id = ?", row.ID).Count(&count)
row.Active = true
ft := effectiveFulfillment(row.FulfillmentType)
p := rowToModel(row, nil)
p.Quantity = int(count)
p.Codes = nil
if ft == "fixed" {
p.Quantity = -1
p.FixedContent = ""
} else {
var count int64
s.db.Model(&database.ProductCodeRow{}).Where("product_id = ?", row.ID).Count(&count)
p.Quantity = int(count)
}
products = append(products, p)
}
return products, nil
@@ -146,6 +166,8 @@ func (s *ProductStore) Create(p models.Product) (models.Product, error) {
TotalSold: p.TotalSold,
ViewCount: p.ViewCount,
DeliveryMode: p.DeliveryMode,
FulfillmentType: p.FulfillmentType,
FixedContent: p.FixedContent,
ShowNote: p.ShowNote,
ShowContact: p.ShowContact,
CreatedAt: now,
@@ -156,8 +178,10 @@ func (s *ProductStore) Create(p models.Product) (models.Product, error) {
if err := s.replaceCodes(p.ID, p.Codes); err != nil {
return models.Product{}, err
}
p.Quantity = len(p.Codes)
return p, nil
var createdRow database.ProductRow
s.db.First(&createdRow, "id = ?", p.ID)
codes, _ := s.loadCodes(p.ID)
return rowToModel(createdRow, codes), nil
}
func (s *ProductStore) Update(id string, patch models.Product) (models.Product, error) {
@@ -168,20 +192,22 @@ func (s *ProductStore) Update(id string, patch models.Product) (models.Product,
normalized := normalizeProduct(patch)
if err := s.db.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),
"verification_url": normalized.VerificationURL,
"description": normalized.Description,
"active": normalized.Active,
"require_login": normalized.RequireLogin,
"max_per_account": normalized.MaxPerAccount,
"delivery_mode": normalized.DeliveryMode,
"show_note": normalized.ShowNote,
"show_contact": normalized.ShowContact,
"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),
"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
}
@@ -296,8 +322,18 @@ func normalizeProduct(item models.Product) models.Product {
item.DiscountPrice = 0
}
item.VerificationURL = strings.TrimSpace(item.VerificationURL)
item.Codes = sanitizeCodes(item.Codes)
item.FulfillmentType = effectiveFulfillment(item.FulfillmentType)
if item.FulfillmentType == "fixed" {
item.FixedContent = strings.TrimSpace(item.FixedContent)
item.Codes = []string{}
} else {
item.FixedContent = ""
item.Codes = sanitizeCodes(item.Codes)
}
item.Quantity = len(item.Codes)
if item.FulfillmentType == "fixed" {
item.Quantity = -1
}
if item.DeliveryMode == "" || item.DeliveryMode == "manual" {
item.DeliveryMode = "auto"
}

View File

@@ -1,146 +1,150 @@
package storage
import (
"strconv"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"mengyastore-backend/internal/database"
)
type SiteStore struct {
db *gorm.DB
}
func NewSiteStore(db *gorm.DB) (*SiteStore, error) {
return &SiteStore{db: db}, nil
}
func (s *SiteStore) get(key string) (string, error) {
var row database.SiteSettingRow
// `key` 是 MySQL 保留字,需用反引号转义以避免 SQL 语法错误。
if err := s.db.Where("`key` = ?", key).First(&row).Error; err != nil {
return "", nil // 键不存在时返回零值
}
return row.Value, nil
}
func (s *SiteStore) set(key, value string) error {
return s.db.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "key"}},
DoUpdates: clause.AssignmentColumns([]string{"value"}),
}).Create(&database.SiteSettingRow{Key: key, Value: value}).Error
}
func (s *SiteStore) GetTotalVisits() (int, error) {
v, err := s.get("totalVisits")
if err != nil || v == "" {
return 0, err
}
n, _ := strconv.Atoi(v)
return n, nil
}
func (s *SiteStore) IncrementVisits() (int, error) {
current, err := s.GetTotalVisits()
if err != nil {
return 0, err
}
current++
if err := s.set("totalVisits", strconv.Itoa(current)); err != nil {
return 0, err
}
return current, nil
}
func (s *SiteStore) GetMaintenance() (enabled bool, reason string, err error) {
v, err := s.get("maintenance")
if err != nil {
return false, "", err
}
enabled = v == "true"
reason, err = s.get("maintenanceReason")
return enabled, reason, err
}
func (s *SiteStore) SetMaintenance(enabled bool, reason string) error {
v := "false"
if enabled {
v = "true"
}
if err := s.set("maintenance", v); err != nil {
return err
}
return s.set("maintenanceReason", reason)
}
// RecordVisit 递增访问计数,返回 (总访问量, 是否计入, 错误)。
// 去重逻辑由上层 handler 的内存指纹完成,此处无条件累加。
func (s *SiteStore) RecordVisit(_ string) (int, bool, error) {
total, err := s.IncrementVisits()
return total, true, err
}
// SMTPConfig 存储数据库中的发件 SMTP 配置。
type SMTPConfig struct {
Enabled bool `json:"enabled"`
Email string `json:"email"`
Password string `json:"password"`
FromName string `json:"fromName"`
Host string `json:"host"`
Port string `json:"port"`
}
// IsConfiguredEmail 判断邮件通知是否已启用且 SMTP 配置完整。
func (c SMTPConfig) IsConfiguredEmail() bool {
return c.Enabled && c.Email != "" && c.Password != "" && c.Host != ""
}
func (s *SiteStore) GetSMTPConfig() (SMTPConfig, error) {
cfg := SMTPConfig{
Enabled: true, // 默认启用
Host: "smtp.qq.com",
Port: "465",
}
if v, _ := s.get("smtpEnabled"); v == "false" {
cfg.Enabled = false
}
if v, _ := s.get("smtpEmail"); v != "" {
cfg.Email = v
}
if v, _ := s.get("smtpPassword"); v != "" {
cfg.Password = v
}
if v, _ := s.get("smtpFromName"); v != "" {
cfg.FromName = v
}
if v, _ := s.get("smtpHost"); v != "" {
cfg.Host = v
}
if v, _ := s.get("smtpPort"); v != "" {
cfg.Port = v
}
return cfg, nil
}
func (s *SiteStore) SetSMTPConfig(cfg SMTPConfig) error {
enabledVal := "true"
if !cfg.Enabled {
enabledVal = "false"
}
pairs := [][2]string{
{"smtpEnabled", enabledVal},
{"smtpEmail", cfg.Email},
{"smtpPassword", cfg.Password},
{"smtpFromName", cfg.FromName},
{"smtpHost", cfg.Host},
{"smtpPort", cfg.Port},
}
for _, p := range pairs {
if err := s.set(p[0], p[1]); err != nil {
return err
}
}
return nil
}
package storage
import (
"strconv"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"mengyastore-backend/internal/database"
)
type SiteStore struct {
db *gorm.DB
}
func NewSiteStore(db *gorm.DB) (*SiteStore, error) {
return &SiteStore{db: db}, nil
}
func (s *SiteStore) get(key string) (string, error) {
var row database.SiteSettingRow
// `key` 是 MySQL 保留字,需用反引号转义以避免 SQL 语法错误。
// 使用 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
}
if row.Key == "" {
return "", nil
}
return row.Value, nil
}
func (s *SiteStore) set(key, value string) error {
return s.db.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "key"}},
DoUpdates: clause.AssignmentColumns([]string{"value"}),
}).Create(&database.SiteSettingRow{Key: key, Value: value}).Error
}
func (s *SiteStore) GetTotalVisits() (int, error) {
v, err := s.get("totalVisits")
if err != nil || v == "" {
return 0, err
}
n, _ := strconv.Atoi(v)
return n, nil
}
func (s *SiteStore) IncrementVisits() (int, error) {
current, err := s.GetTotalVisits()
if err != nil {
return 0, err
}
current++
if err := s.set("totalVisits", strconv.Itoa(current)); err != nil {
return 0, err
}
return current, nil
}
func (s *SiteStore) GetMaintenance() (enabled bool, reason string, err error) {
v, err := s.get("maintenance")
if err != nil {
return false, "", err
}
enabled = v == "true"
reason, err = s.get("maintenanceReason")
return enabled, reason, err
}
func (s *SiteStore) SetMaintenance(enabled bool, reason string) error {
v := "false"
if enabled {
v = "true"
}
if err := s.set("maintenance", v); err != nil {
return err
}
return s.set("maintenanceReason", reason)
}
// RecordVisit 递增访问计数,返回 (总访问量, 是否计入, 错误)。
// 去重逻辑由上层 handler 的内存指纹完成,此处无条件累加。
func (s *SiteStore) RecordVisit(_ string) (int, bool, error) {
total, err := s.IncrementVisits()
return total, true, err
}
// SMTPConfig 存储数据库中的发件 SMTP 配置。
type SMTPConfig struct {
Enabled bool `json:"enabled"`
Email string `json:"email"`
Password string `json:"password"`
FromName string `json:"fromName"`
Host string `json:"host"`
Port string `json:"port"`
}
// IsConfiguredEmail 判断邮件通知是否已启用且 SMTP 配置完整。
func (c SMTPConfig) IsConfiguredEmail() bool {
return c.Enabled && c.Email != "" && c.Password != "" && c.Host != ""
}
func (s *SiteStore) GetSMTPConfig() (SMTPConfig, error) {
cfg := SMTPConfig{
Enabled: true, // 默认启用
Host: "smtp.qq.com",
Port: "465",
}
if v, _ := s.get("smtpEnabled"); v == "false" {
cfg.Enabled = false
}
if v, _ := s.get("smtpEmail"); v != "" {
cfg.Email = v
}
if v, _ := s.get("smtpPassword"); v != "" {
cfg.Password = v
}
if v, _ := s.get("smtpFromName"); v != "" {
cfg.FromName = v
}
if v, _ := s.get("smtpHost"); v != "" {
cfg.Host = v
}
if v, _ := s.get("smtpPort"); v != "" {
cfg.Port = v
}
return cfg, nil
}
func (s *SiteStore) SetSMTPConfig(cfg SMTPConfig) error {
enabledVal := "true"
if !cfg.Enabled {
enabledVal = "false"
}
pairs := [][2]string{
{"smtpEnabled", enabledVal},
{"smtpEmail", cfg.Email},
{"smtpPassword", cfg.Password},
{"smtpFromName", cfg.FromName},
{"smtpHost", cfg.Host},
{"smtpPort", cfg.Port},
}
for _, p := range pairs {
if err := s.set(p[0], p[1]); err != nil {
return err
}
}
return nil
}