feat: major update - MySQL, chat, wishlist, PWA, admin overhaul
This commit is contained in:
@@ -2,16 +2,15 @@ package storage
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"mengyastore-backend/internal/database"
|
||||
"mengyastore-backend/internal/models"
|
||||
)
|
||||
|
||||
@@ -20,238 +19,235 @@ const viewCooldown = 6 * time.Hour
|
||||
const maxScreenshotURLs = 5
|
||||
|
||||
type JSONStore struct {
|
||||
path string
|
||||
db *gorm.DB
|
||||
mu sync.Mutex
|
||||
recentViews map[string]time.Time
|
||||
}
|
||||
|
||||
func NewJSONStore(path string) (*JSONStore, error) {
|
||||
if err := ensureProductsFile(path); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
func NewJSONStore(db *gorm.DB) (*JSONStore, error) {
|
||||
return &JSONStore{
|
||||
path: path,
|
||||
db: db,
|
||||
recentViews: make(map[string]time.Time),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func ensureProductsFile(path string) error {
|
||||
dir := filepath.Dir(path)
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return fmt.Errorf("mkdir data dir: %w", err)
|
||||
}
|
||||
if _, err := os.Stat(path); err == nil {
|
||||
return nil
|
||||
} else if !os.IsNotExist(err) {
|
||||
return fmt.Errorf("stat data file: %w", err)
|
||||
// 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,
|
||||
}
|
||||
}
|
||||
|
||||
initial := []models.Product{}
|
||||
bytes, err := json.MarshalIndent(initial, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("init json: %w", err)
|
||||
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
|
||||
}
|
||||
if err := os.WriteFile(path, bytes, 0o644); err != nil {
|
||||
return fmt.Errorf("write init json: %w", err)
|
||||
codes := make([]string, len(rows))
|
||||
for i, r := range rows {
|
||||
codes[i] = r.Code
|
||||
}
|
||||
return nil
|
||||
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) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.readAll()
|
||||
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) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
items, err := s.readAll()
|
||||
if err != nil {
|
||||
var rows []database.ProductRow
|
||||
if err := s.db.Where("active = ?", true).Order("created_at DESC").Find(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
active := make([]models.Product, 0, len(items))
|
||||
for _, item := range items {
|
||||
if item.Active {
|
||||
active = append(active, item)
|
||||
}
|
||||
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 active, nil
|
||||
return products, nil
|
||||
}
|
||||
|
||||
func (s *JSONStore) GetByID(id string) (models.Product, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
items, err := s.readAll()
|
||||
if 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")
|
||||
}
|
||||
for _, item := range items {
|
||||
if item.ID == id {
|
||||
return item, 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) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
items, err := s.readAll()
|
||||
if err != nil {
|
||||
return models.Product{}, err
|
||||
}
|
||||
p = normalizeProduct(p)
|
||||
p.ID = uuid.NewString()
|
||||
now := time.Now()
|
||||
p.CreatedAt = now
|
||||
p.UpdatedAt = now
|
||||
items = append(items, p)
|
||||
if err := s.writeAll(items); err != nil {
|
||||
|
||||
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) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
items, err := s.readAll()
|
||||
if err != nil {
|
||||
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
|
||||
}
|
||||
for i, item := range items {
|
||||
if item.ID == id {
|
||||
normalized := normalizeProduct(patch)
|
||||
item.Name = normalized.Name
|
||||
item.Price = normalized.Price
|
||||
item.DiscountPrice = normalized.DiscountPrice
|
||||
item.Tags = normalized.Tags
|
||||
item.CoverURL = normalized.CoverURL
|
||||
item.ScreenshotURLs = normalized.ScreenshotURLs
|
||||
item.VerificationURL = normalized.VerificationURL
|
||||
item.Codes = normalized.Codes
|
||||
item.Quantity = normalized.Quantity
|
||||
item.Description = normalized.Description
|
||||
item.Active = normalized.Active
|
||||
item.UpdatedAt = time.Now()
|
||||
items[i] = item
|
||||
if err := s.writeAll(items); err != nil {
|
||||
return models.Product{}, err
|
||||
}
|
||||
return item, nil
|
||||
}
|
||||
if err := s.replaceCodes(id, normalized.Codes); err != nil {
|
||||
return models.Product{}, err
|
||||
}
|
||||
return models.Product{}, fmt.Errorf("product not found")
|
||||
|
||||
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) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
items, err := s.readAll()
|
||||
if err != nil {
|
||||
if err := s.db.Model(&database.ProductRow{}).Where("id = ?", id).Update("active", active).Error; err != nil {
|
||||
return models.Product{}, err
|
||||
}
|
||||
for i, item := range items {
|
||||
if item.ID == id {
|
||||
item.Active = active
|
||||
item.UpdatedAt = time.Now()
|
||||
items[i] = item
|
||||
if err := s.writeAll(items); err != nil {
|
||||
return models.Product{}, err
|
||||
}
|
||||
return item, nil
|
||||
}
|
||||
var row database.ProductRow
|
||||
if err := s.db.First(&row, "id = ?", id).Error; err != nil {
|
||||
return models.Product{}, fmt.Errorf("product not found")
|
||||
}
|
||||
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()
|
||||
|
||||
items, err := s.readAll()
|
||||
if err != nil {
|
||||
return models.Product{}, false, err
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
s.cleanupRecentViews(now)
|
||||
key := buildViewKey(id, fingerprint)
|
||||
if lastViewedAt, ok := s.recentViews[key]; ok && now.Sub(lastViewedAt) < viewCooldown {
|
||||
for _, item := range items {
|
||||
if item.ID == id {
|
||||
return item, false, nil
|
||||
}
|
||||
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")
|
||||
}
|
||||
|
||||
for i, item := range items {
|
||||
if item.ID == id {
|
||||
item.ViewCount++
|
||||
item.UpdatedAt = now
|
||||
items[i] = item
|
||||
s.recentViews[key] = now
|
||||
if err := s.writeAll(items); err != nil {
|
||||
return models.Product{}, false, err
|
||||
}
|
||||
return item, true, nil
|
||||
}
|
||||
}
|
||||
|
||||
return models.Product{}, false, fmt.Errorf("product not found")
|
||||
return rowToModel(row, nil), true, nil
|
||||
}
|
||||
|
||||
func (s *JSONStore) Delete(id string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
items, err := s.readAll()
|
||||
if err != nil {
|
||||
if err := s.db.Where("product_id = ?", id).Delete(&database.ProductCodeRow{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
filtered := make([]models.Product, 0, len(items))
|
||||
for _, item := range items {
|
||||
if item.ID != id {
|
||||
filtered = append(filtered, item)
|
||||
}
|
||||
}
|
||||
if err := s.writeAll(filtered); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *JSONStore) readAll() ([]models.Product, error) {
|
||||
bytes, err := os.ReadFile(s.path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read products: %w", err)
|
||||
}
|
||||
var items []models.Product
|
||||
if err := json.Unmarshal(bytes, &items); err != nil {
|
||||
return nil, fmt.Errorf("parse products: %w", err)
|
||||
}
|
||||
for i, item := range items {
|
||||
items[i] = normalizeProduct(item)
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func (s *JSONStore) writeAll(items []models.Product) error {
|
||||
for i, item := range items {
|
||||
items[i] = normalizeProduct(item)
|
||||
}
|
||||
bytes, err := json.MarshalIndent(items, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("encode products: %w", err)
|
||||
}
|
||||
if err := os.WriteFile(s.path, bytes, 0o644); err != nil {
|
||||
return fmt.Errorf("write products: %w", err)
|
||||
}
|
||||
return nil
|
||||
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 == "" {
|
||||
@@ -276,6 +272,9 @@ func normalizeProduct(item models.Product) models.Product {
|
||||
item.VerificationURL = strings.TrimSpace(item.VerificationURL)
|
||||
item.Codes = sanitizeCodes(item.Codes)
|
||||
item.Quantity = len(item.Codes)
|
||||
if item.DeliveryMode == "" {
|
||||
item.DeliveryMode = "auto"
|
||||
}
|
||||
return item
|
||||
}
|
||||
|
||||
@@ -284,10 +283,7 @@ func sanitizeCodes(codes []string) []string {
|
||||
seen := map[string]bool{}
|
||||
for _, code := range codes {
|
||||
trimmed := strings.TrimSpace(code)
|
||||
if trimmed == "" {
|
||||
continue
|
||||
}
|
||||
if seen[trimmed] {
|
||||
if trimmed == "" || seen[trimmed] {
|
||||
continue
|
||||
}
|
||||
seen[trimmed] = true
|
||||
|
||||
Reference in New Issue
Block a user