328 lines
9.3 KiB
Go
328 lines
9.3 KiB
Go
package storage
|
|
|
|
import (
|
|
"crypto/sha256"
|
|
"fmt"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"gorm.io/gorm"
|
|
|
|
"mengyastore-backend/internal/database"
|
|
"mengyastore-backend/internal/models"
|
|
)
|
|
|
|
const defaultCoverURL = "https://img.shumengya.top/i/2026/01/04/695a55058c37f.png"
|
|
const viewCooldown = 6 * time.Hour
|
|
const maxScreenshotURLs = 5
|
|
|
|
type JSONStore struct {
|
|
db *gorm.DB
|
|
mu sync.Mutex
|
|
recentViews map[string]time.Time
|
|
}
|
|
|
|
func NewJSONStore(db *gorm.DB) (*JSONStore, error) {
|
|
return &JSONStore{
|
|
db: db,
|
|
recentViews: make(map[string]time.Time),
|
|
}, nil
|
|
}
|
|
|
|
// rowToModel converts a ProductRow (+ codes) to a models.Product.
|
|
func rowToModel(row database.ProductRow, codes []string) models.Product {
|
|
return models.Product{
|
|
ID: row.ID,
|
|
Name: row.Name,
|
|
Price: row.Price,
|
|
DiscountPrice: row.DiscountPrice,
|
|
Tags: row.Tags,
|
|
CoverURL: row.CoverURL,
|
|
ScreenshotURLs: row.ScreenshotURLs,
|
|
VerificationURL: row.VerificationURL,
|
|
Description: row.Description,
|
|
Active: row.Active,
|
|
RequireLogin: row.RequireLogin,
|
|
MaxPerAccount: row.MaxPerAccount,
|
|
TotalSold: row.TotalSold,
|
|
ViewCount: row.ViewCount,
|
|
DeliveryMode: row.DeliveryMode,
|
|
ShowNote: row.ShowNote,
|
|
ShowContact: row.ShowContact,
|
|
Codes: codes,
|
|
Quantity: len(codes),
|
|
CreatedAt: row.CreatedAt,
|
|
}
|
|
}
|
|
|
|
func (s *JSONStore) loadCodes(productID string) ([]string, error) {
|
|
var rows []database.ProductCodeRow
|
|
if err := s.db.Where("product_id = ?", productID).Find(&rows).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
codes := make([]string, len(rows))
|
|
for i, r := range rows {
|
|
codes[i] = r.Code
|
|
}
|
|
return codes, nil
|
|
}
|
|
|
|
func (s *JSONStore) replaceCodes(productID string, codes []string) error {
|
|
if err := s.db.Where("product_id = ?", productID).Delete(&database.ProductCodeRow{}).Error; err != nil {
|
|
return err
|
|
}
|
|
if len(codes) == 0 {
|
|
return nil
|
|
}
|
|
rows := make([]database.ProductCodeRow, 0, len(codes))
|
|
for _, code := range codes {
|
|
rows = append(rows, database.ProductCodeRow{ProductID: productID, Code: code})
|
|
}
|
|
return s.db.CreateInBatches(rows, 100).Error
|
|
}
|
|
|
|
func (s *JSONStore) ListAll() ([]models.Product, error) {
|
|
var rows []database.ProductRow
|
|
if err := s.db.Order("created_at DESC").Find(&rows).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
products := make([]models.Product, 0, len(rows))
|
|
for _, row := range rows {
|
|
codes, _ := s.loadCodes(row.ID)
|
|
products = append(products, rowToModel(row, codes))
|
|
}
|
|
return products, nil
|
|
}
|
|
|
|
func (s *JSONStore) ListActive() ([]models.Product, error) {
|
|
var rows []database.ProductRow
|
|
if err := s.db.Where("active = ?", true).Order("created_at DESC").Find(&rows).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
products := make([]models.Product, 0, len(rows))
|
|
for _, row := range rows {
|
|
// For public listing we don't expose codes, but we still need Quantity
|
|
var count int64
|
|
s.db.Model(&database.ProductCodeRow{}).Where("product_id = ?", row.ID).Count(&count)
|
|
row.Active = true
|
|
p := rowToModel(row, nil)
|
|
p.Quantity = int(count)
|
|
p.Codes = nil
|
|
products = append(products, p)
|
|
}
|
|
return products, nil
|
|
}
|
|
|
|
func (s *JSONStore) GetByID(id string) (models.Product, error) {
|
|
var row database.ProductRow
|
|
if err := s.db.First(&row, "id = ?", id).Error; err != nil {
|
|
return models.Product{}, fmt.Errorf("product not found")
|
|
}
|
|
codes, _ := s.loadCodes(id)
|
|
return rowToModel(row, codes), nil
|
|
}
|
|
|
|
func (s *JSONStore) Create(p models.Product) (models.Product, error) {
|
|
p = normalizeProduct(p)
|
|
p.ID = uuid.NewString()
|
|
now := time.Now()
|
|
p.CreatedAt = now
|
|
|
|
row := database.ProductRow{
|
|
ID: p.ID,
|
|
Name: p.Name,
|
|
Price: p.Price,
|
|
DiscountPrice: p.DiscountPrice,
|
|
Tags: database.StringSlice(p.Tags),
|
|
CoverURL: p.CoverURL,
|
|
ScreenshotURLs: database.StringSlice(p.ScreenshotURLs),
|
|
VerificationURL: p.VerificationURL,
|
|
Description: p.Description,
|
|
Active: p.Active,
|
|
RequireLogin: p.RequireLogin,
|
|
MaxPerAccount: p.MaxPerAccount,
|
|
TotalSold: p.TotalSold,
|
|
ViewCount: p.ViewCount,
|
|
DeliveryMode: p.DeliveryMode,
|
|
ShowNote: p.ShowNote,
|
|
ShowContact: p.ShowContact,
|
|
CreatedAt: now,
|
|
}
|
|
if err := s.db.Create(&row).Error; err != nil {
|
|
return models.Product{}, err
|
|
}
|
|
if err := s.replaceCodes(p.ID, p.Codes); err != nil {
|
|
return models.Product{}, err
|
|
}
|
|
p.Quantity = len(p.Codes)
|
|
return p, nil
|
|
}
|
|
|
|
func (s *JSONStore) Update(id string, patch models.Product) (models.Product, error) {
|
|
var row database.ProductRow
|
|
if err := s.db.First(&row, "id = ?", id).Error; err != nil {
|
|
return models.Product{}, fmt.Errorf("product not found")
|
|
}
|
|
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,
|
|
}).Error; err != nil {
|
|
return models.Product{}, err
|
|
}
|
|
if err := s.replaceCodes(id, normalized.Codes); err != nil {
|
|
return models.Product{}, err
|
|
}
|
|
|
|
var updated database.ProductRow
|
|
s.db.First(&updated, "id = ?", id)
|
|
codes, _ := s.loadCodes(id)
|
|
return rowToModel(updated, codes), nil
|
|
}
|
|
|
|
func (s *JSONStore) 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
|
|
}
|
|
var row database.ProductRow
|
|
if err := s.db.First(&row, "id = ?", id).Error; err != nil {
|
|
return models.Product{}, fmt.Errorf("product not found")
|
|
}
|
|
codes, _ := s.loadCodes(id)
|
|
return rowToModel(row, codes), nil
|
|
}
|
|
|
|
func (s *JSONStore) IncrementSold(id string, count int) error {
|
|
return s.db.Model(&database.ProductRow{}).Where("id = ?", id).
|
|
UpdateColumn("total_sold", gorm.Expr("total_sold + ?", count)).Error
|
|
}
|
|
|
|
func (s *JSONStore) IncrementView(id, fingerprint string) (models.Product, bool, error) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
now := time.Now()
|
|
s.cleanupRecentViews(now)
|
|
key := buildViewKey(id, fingerprint)
|
|
if lastViewedAt, ok := s.recentViews[key]; ok && now.Sub(lastViewedAt) < viewCooldown {
|
|
var row database.ProductRow
|
|
if err := s.db.First(&row, "id = ?", id).Error; err != nil {
|
|
return models.Product{}, false, fmt.Errorf("product not found")
|
|
}
|
|
return rowToModel(row, nil), false, nil
|
|
}
|
|
|
|
if err := s.db.Model(&database.ProductRow{}).Where("id = ?", id).
|
|
UpdateColumn("view_count", gorm.Expr("view_count + 1")).Error; err != nil {
|
|
return models.Product{}, false, err
|
|
}
|
|
s.recentViews[key] = now
|
|
|
|
var row database.ProductRow
|
|
if err := s.db.First(&row, "id = ?", id).Error; err != nil {
|
|
return models.Product{}, false, fmt.Errorf("product not found")
|
|
}
|
|
return rowToModel(row, nil), true, nil
|
|
}
|
|
|
|
func (s *JSONStore) Delete(id string) error {
|
|
if err := s.db.Where("product_id = ?", id).Delete(&database.ProductCodeRow{}).Error; err != nil {
|
|
return err
|
|
}
|
|
return s.db.Delete(&database.ProductRow{}, "id = ?", id).Error
|
|
}
|
|
|
|
// normalizeProduct cleans up product fields (same logic as before, no file I/O).
|
|
func normalizeProduct(item models.Product) models.Product {
|
|
item.CoverURL = strings.TrimSpace(item.CoverURL)
|
|
if item.CoverURL == "" {
|
|
item.CoverURL = defaultCoverURL
|
|
}
|
|
if item.Tags == nil {
|
|
item.Tags = []string{}
|
|
}
|
|
item.Tags = sanitizeTags(item.Tags)
|
|
if item.ScreenshotURLs == nil {
|
|
item.ScreenshotURLs = []string{}
|
|
}
|
|
if len(item.ScreenshotURLs) > maxScreenshotURLs {
|
|
item.ScreenshotURLs = item.ScreenshotURLs[:maxScreenshotURLs]
|
|
}
|
|
if item.Codes == nil {
|
|
item.Codes = []string{}
|
|
}
|
|
if item.DiscountPrice <= 0 || item.DiscountPrice >= item.Price {
|
|
item.DiscountPrice = 0
|
|
}
|
|
item.VerificationURL = strings.TrimSpace(item.VerificationURL)
|
|
item.Codes = sanitizeCodes(item.Codes)
|
|
item.Quantity = len(item.Codes)
|
|
if item.DeliveryMode == "" {
|
|
item.DeliveryMode = "auto"
|
|
}
|
|
return item
|
|
}
|
|
|
|
func sanitizeCodes(codes []string) []string {
|
|
clean := make([]string, 0, len(codes))
|
|
seen := map[string]bool{}
|
|
for _, code := range codes {
|
|
trimmed := strings.TrimSpace(code)
|
|
if trimmed == "" || seen[trimmed] {
|
|
continue
|
|
}
|
|
seen[trimmed] = true
|
|
clean = append(clean, trimmed)
|
|
}
|
|
return clean
|
|
}
|
|
|
|
func sanitizeTags(tags []string) []string {
|
|
clean := make([]string, 0, len(tags))
|
|
seen := map[string]bool{}
|
|
for _, tag := range tags {
|
|
t := strings.TrimSpace(tag)
|
|
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
|
|
}
|
|
|
|
func buildViewKey(id, fingerprint string) string {
|
|
sum := sha256.Sum256([]byte(id + "|" + fingerprint))
|
|
return fmt.Sprintf("%x", sum)
|
|
}
|
|
|
|
func (s *JSONStore) cleanupRecentViews(now time.Time) {
|
|
for key, lastViewedAt := range s.recentViews {
|
|
if now.Sub(lastViewedAt) >= viewCooldown {
|
|
delete(s.recentViews, key)
|
|
}
|
|
}
|
|
}
|