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