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

@@ -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
}