feat: major update - MySQL, chat, wishlist, PWA, admin overhaul
This commit is contained in:
99
mengyastore-backend/internal/storage/chatstore.go
Normal file
99
mengyastore-backend/internal/storage/chatstore.go
Normal file
@@ -0,0 +1,99 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"mengyastore-backend/internal/database"
|
||||
"mengyastore-backend/internal/models"
|
||||
)
|
||||
|
||||
type ChatStore struct {
|
||||
db *gorm.DB
|
||||
mu sync.Mutex
|
||||
lastSent map[string]time.Time
|
||||
}
|
||||
|
||||
func NewChatStore(db *gorm.DB) (*ChatStore, error) {
|
||||
return &ChatStore{db: db, lastSent: make(map[string]time.Time)}, nil
|
||||
}
|
||||
|
||||
func chatRowToModel(row database.ChatMessageRow) models.ChatMessage {
|
||||
return models.ChatMessage{
|
||||
ID: row.ID,
|
||||
AccountID: row.AccountID,
|
||||
AccountName: row.AccountName,
|
||||
Content: row.Content,
|
||||
SentAt: row.SentAt,
|
||||
FromAdmin: row.FromAdmin,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *ChatStore) GetMessages(accountID string) ([]models.ChatMessage, error) {
|
||||
var rows []database.ChatMessageRow
|
||||
if err := s.db.Where("account_id = ?", accountID).Order("sent_at ASC").Find(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
msgs := make([]models.ChatMessage, len(rows))
|
||||
for i, r := range rows {
|
||||
msgs[i] = chatRowToModel(r)
|
||||
}
|
||||
return msgs, nil
|
||||
}
|
||||
|
||||
func (s *ChatStore) ListConversations() (map[string][]models.ChatMessage, error) {
|
||||
var rows []database.ChatMessageRow
|
||||
if err := s.db.Order("account_id, sent_at ASC").Find(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := make(map[string][]models.ChatMessage)
|
||||
for _, r := range rows {
|
||||
result[r.AccountID] = append(result[r.AccountID], chatRowToModel(r))
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *ChatStore) SendUserMessage(accountID, accountName, content string) (models.ChatMessage, bool, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if last, ok := s.lastSent[accountID]; ok && time.Since(last) < time.Second {
|
||||
return models.ChatMessage{}, true, nil
|
||||
}
|
||||
s.lastSent[accountID] = time.Now()
|
||||
|
||||
row := database.ChatMessageRow{
|
||||
ID: uuid.New().String(),
|
||||
AccountID: accountID,
|
||||
AccountName: accountName,
|
||||
Content: content,
|
||||
SentAt: time.Now(),
|
||||
FromAdmin: false,
|
||||
}
|
||||
if err := s.db.Create(&row).Error; err != nil {
|
||||
return models.ChatMessage{}, false, err
|
||||
}
|
||||
return chatRowToModel(row), false, nil
|
||||
}
|
||||
|
||||
func (s *ChatStore) SendAdminMessage(accountID, content string) (models.ChatMessage, error) {
|
||||
row := database.ChatMessageRow{
|
||||
ID: uuid.New().String(),
|
||||
AccountID: accountID,
|
||||
AccountName: "管理员",
|
||||
Content: content,
|
||||
SentAt: time.Now(),
|
||||
FromAdmin: true,
|
||||
}
|
||||
if err := s.db.Create(&row).Error; err != nil {
|
||||
return models.ChatMessage{}, err
|
||||
}
|
||||
return chatRowToModel(row), nil
|
||||
}
|
||||
|
||||
func (s *ChatStore) ClearConversation(accountID string) error {
|
||||
return s.db.Where("account_id = ?", accountID).Delete(&database.ChatMessageRow{}).Error
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -1,140 +1,139 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"mengyastore-backend/internal/database"
|
||||
"mengyastore-backend/internal/models"
|
||||
)
|
||||
|
||||
type OrderStore struct {
|
||||
path string
|
||||
mu sync.Mutex
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewOrderStore(path string) (*OrderStore, error) {
|
||||
if err := ensureOrdersFile(path); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &OrderStore{path: path}, nil
|
||||
func NewOrderStore(db *gorm.DB) (*OrderStore, error) {
|
||||
return &OrderStore{db: db}, nil
|
||||
}
|
||||
|
||||
func (s *OrderStore) Count() (int, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
items, err := s.readAll()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
func orderRowToModel(row database.OrderRow) models.Order {
|
||||
return models.Order{
|
||||
ID: row.ID,
|
||||
ProductID: row.ProductID,
|
||||
ProductName: row.ProductName,
|
||||
UserAccount: row.UserAccount,
|
||||
UserName: row.UserName,
|
||||
Quantity: row.Quantity,
|
||||
DeliveredCodes: row.DeliveredCodes,
|
||||
Status: row.Status,
|
||||
DeliveryMode: row.DeliveryMode,
|
||||
Note: row.Note,
|
||||
ContactPhone: row.ContactPhone,
|
||||
ContactEmail: row.ContactEmail,
|
||||
NotifyEmail: row.NotifyEmail,
|
||||
CreatedAt: row.CreatedAt,
|
||||
}
|
||||
return len(items), nil
|
||||
}
|
||||
|
||||
func (s *OrderStore) ListByAccount(account string) ([]models.Order, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
items, err := s.readAll()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
matched := make([]models.Order, 0)
|
||||
for i := len(items) - 1; i >= 0; i-- {
|
||||
if items[i].UserAccount == account {
|
||||
matched = append(matched, items[i])
|
||||
}
|
||||
}
|
||||
return matched, nil
|
||||
}
|
||||
|
||||
func (s *OrderStore) Confirm(id string) (models.Order, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
items, err := s.readAll()
|
||||
if err != nil {
|
||||
return models.Order{}, err
|
||||
}
|
||||
for i, item := range items {
|
||||
if item.ID == id {
|
||||
if item.Status == "completed" {
|
||||
return item, nil
|
||||
}
|
||||
items[i].Status = "completed"
|
||||
if err := s.writeAll(items); err != nil {
|
||||
return models.Order{}, err
|
||||
}
|
||||
return items[i], nil
|
||||
}
|
||||
}
|
||||
return models.Order{}, fmt.Errorf("order not found")
|
||||
}
|
||||
|
||||
func (s *OrderStore) Create(order models.Order) (models.Order, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
items, err := s.readAll()
|
||||
if err != nil {
|
||||
return models.Order{}, err
|
||||
}
|
||||
|
||||
order.ID = uuid.NewString()
|
||||
order.CreatedAt = time.Now()
|
||||
items = append(items, order)
|
||||
if err := s.writeAll(items); err != nil {
|
||||
if order.ID == "" {
|
||||
order.ID = uuid.NewString()
|
||||
}
|
||||
if len(order.DeliveredCodes) == 0 {
|
||||
order.DeliveredCodes = []string{}
|
||||
}
|
||||
row := database.OrderRow{
|
||||
ID: order.ID,
|
||||
ProductID: order.ProductID,
|
||||
ProductName: order.ProductName,
|
||||
UserAccount: order.UserAccount,
|
||||
UserName: order.UserName,
|
||||
Quantity: order.Quantity,
|
||||
DeliveredCodes: database.StringSlice(order.DeliveredCodes),
|
||||
Status: order.Status,
|
||||
DeliveryMode: order.DeliveryMode,
|
||||
Note: order.Note,
|
||||
ContactPhone: order.ContactPhone,
|
||||
ContactEmail: order.ContactEmail,
|
||||
NotifyEmail: order.NotifyEmail,
|
||||
}
|
||||
if err := s.db.Create(&row).Error; err != nil {
|
||||
return models.Order{}, err
|
||||
}
|
||||
order.CreatedAt = row.CreatedAt
|
||||
return order, nil
|
||||
}
|
||||
|
||||
func (s *OrderStore) readAll() ([]models.Order, error) {
|
||||
bytes, err := os.ReadFile(s.path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read orders: %w", err)
|
||||
func (s *OrderStore) GetByID(id string) (models.Order, error) {
|
||||
var row database.OrderRow
|
||||
if err := s.db.First(&row, "id = ?", id).Error; err != nil {
|
||||
return models.Order{}, fmt.Errorf("order not found")
|
||||
}
|
||||
var items []models.Order
|
||||
if err := json.Unmarshal(bytes, &items); err != nil {
|
||||
return nil, fmt.Errorf("parse orders: %w", err)
|
||||
}
|
||||
return items, nil
|
||||
return orderRowToModel(row), nil
|
||||
}
|
||||
|
||||
func (s *OrderStore) writeAll(items []models.Order) error {
|
||||
bytes, err := json.MarshalIndent(items, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("encode orders: %w", err)
|
||||
func (s *OrderStore) Confirm(id string) (models.Order, error) {
|
||||
var row database.OrderRow
|
||||
if err := s.db.First(&row, "id = ?", id).Error; err != nil {
|
||||
return models.Order{}, fmt.Errorf("order not found")
|
||||
}
|
||||
if err := os.WriteFile(s.path, bytes, 0o644); err != nil {
|
||||
return fmt.Errorf("write orders: %w", err)
|
||||
if err := s.db.Model(&row).Update("status", "completed").Error; err != nil {
|
||||
return models.Order{}, err
|
||||
}
|
||||
return nil
|
||||
row.Status = "completed"
|
||||
return orderRowToModel(row), nil
|
||||
}
|
||||
|
||||
func ensureOrdersFile(path string) error {
|
||||
dir := filepath.Dir(path)
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return fmt.Errorf("mkdir data dir: %w", err)
|
||||
func (s *OrderStore) ListByAccount(account string) ([]models.Order, error) {
|
||||
var rows []database.OrderRow
|
||||
if err := s.db.Where("user_account = ?", account).Order("created_at DESC").Find(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := os.Stat(path); err == nil {
|
||||
return nil
|
||||
} else if !os.IsNotExist(err) {
|
||||
return fmt.Errorf("stat data file: %w", err)
|
||||
orders := make([]models.Order, len(rows))
|
||||
for i, r := range rows {
|
||||
orders[i] = orderRowToModel(r)
|
||||
}
|
||||
|
||||
initial := []models.Order{}
|
||||
bytes, err := json.MarshalIndent(initial, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("init json: %w", err)
|
||||
}
|
||||
if err := os.WriteFile(path, bytes, 0o644); err != nil {
|
||||
return fmt.Errorf("write init json: %w", err)
|
||||
}
|
||||
return nil
|
||||
return orders, nil
|
||||
}
|
||||
|
||||
func (s *OrderStore) ListAll() ([]models.Order, error) {
|
||||
var rows []database.OrderRow
|
||||
if err := s.db.Order("created_at DESC").Find(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
orders := make([]models.Order, len(rows))
|
||||
for i, r := range rows {
|
||||
orders[i] = orderRowToModel(r)
|
||||
}
|
||||
return orders, nil
|
||||
}
|
||||
|
||||
func (s *OrderStore) CountPurchasedByAccount(account, productID string) (int, error) {
|
||||
var total int64
|
||||
err := s.db.Model(&database.OrderRow{}).
|
||||
Where("user_account = ? AND product_id = ? AND status = ?", account, productID, "completed").
|
||||
Select("COALESCE(SUM(quantity), 0)").Scan(&total).Error
|
||||
return int(total), err
|
||||
}
|
||||
|
||||
// Count returns the total number of orders.
|
||||
func (s *OrderStore) Count() (int, error) {
|
||||
var count int64
|
||||
if err := s.db.Model(&database.OrderRow{}).Count(&count).Error; err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return int(count), nil
|
||||
}
|
||||
|
||||
// Delete removes a single order by ID.
|
||||
func (s *OrderStore) Delete(id string) error {
|
||||
return s.db.Delete(&database.OrderRow{}, "id = ?", id).Error
|
||||
}
|
||||
|
||||
// UpdateCodes replaces the delivered codes for an order (used by auto-delivery to set codes after extracting).
|
||||
func (s *OrderStore) UpdateCodes(id string, codes []string) error {
|
||||
return s.db.Model(&database.OrderRow{}).Where("id = ?", id).
|
||||
Update("delivered_codes", database.StringSlice(codes)).Error
|
||||
}
|
||||
|
||||
@@ -1,128 +1,135 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"time"
|
||||
"strconv"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
|
||||
"mengyastore-backend/internal/database"
|
||||
)
|
||||
|
||||
const visitCooldown = 6 * time.Hour
|
||||
|
||||
type siteData struct {
|
||||
TotalVisits int `json:"totalVisits"`
|
||||
}
|
||||
|
||||
type SiteStore struct {
|
||||
path string
|
||||
mu sync.Mutex
|
||||
recentVisits map[string]time.Time
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewSiteStore(path string) (*SiteStore, error) {
|
||||
if err := ensureSiteFile(path); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &SiteStore{
|
||||
path: path,
|
||||
recentVisits: make(map[string]time.Time),
|
||||
}, nil
|
||||
func NewSiteStore(db *gorm.DB) (*SiteStore, error) {
|
||||
return &SiteStore{db: db}, nil
|
||||
}
|
||||
|
||||
func (s *SiteStore) RecordVisit(fingerprint string) (int, bool, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
now := time.Now()
|
||||
s.cleanupRecentVisits(now)
|
||||
|
||||
key := buildSiteVisitKey(fingerprint)
|
||||
if last, ok := s.recentVisits[key]; ok && now.Sub(last) < visitCooldown {
|
||||
data, err := s.read()
|
||||
if err != nil {
|
||||
return 0, false, err
|
||||
}
|
||||
return data.TotalVisits, false, nil
|
||||
func (s *SiteStore) get(key string) (string, error) {
|
||||
var row database.SiteSettingRow
|
||||
if err := s.db.First(&row, "key = ?", key).Error; err != nil {
|
||||
return "", nil // key not found → return zero value
|
||||
}
|
||||
return row.Value, nil
|
||||
}
|
||||
|
||||
data, err := s.read()
|
||||
if err != nil {
|
||||
return 0, false, err
|
||||
}
|
||||
data.TotalVisits++
|
||||
s.recentVisits[key] = now
|
||||
if err := s.write(data); err != nil {
|
||||
return 0, false, err
|
||||
}
|
||||
return data.TotalVisits, true, 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) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
data, err := s.read()
|
||||
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
|
||||
}
|
||||
return data.TotalVisits, nil
|
||||
current++
|
||||
if err := s.set("totalVisits", strconv.Itoa(current)); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return current, nil
|
||||
}
|
||||
|
||||
func (s *SiteStore) read() (siteData, error) {
|
||||
bytes, err := os.ReadFile(s.path)
|
||||
func (s *SiteStore) GetMaintenance() (enabled bool, reason string, err error) {
|
||||
v, err := s.get("maintenance")
|
||||
if err != nil {
|
||||
return siteData{}, fmt.Errorf("read site data: %w", err)
|
||||
return false, "", err
|
||||
}
|
||||
var data siteData
|
||||
if err := json.Unmarshal(bytes, &data); err != nil {
|
||||
return siteData{}, fmt.Errorf("parse site data: %w", err)
|
||||
}
|
||||
return data, nil
|
||||
enabled = v == "true"
|
||||
reason, err = s.get("maintenanceReason")
|
||||
return enabled, reason, err
|
||||
}
|
||||
|
||||
func (s *SiteStore) write(data siteData) error {
|
||||
bytes, err := json.MarshalIndent(data, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("encode site data: %w", err)
|
||||
func (s *SiteStore) SetMaintenance(enabled bool, reason string) error {
|
||||
v := "false"
|
||||
if enabled {
|
||||
v = "true"
|
||||
}
|
||||
if err := os.WriteFile(s.path, bytes, 0o644); err != nil {
|
||||
return fmt.Errorf("write site data: %w", err)
|
||||
if err := s.set("maintenance", v); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
return s.set("maintenanceReason", reason)
|
||||
}
|
||||
|
||||
func (s *SiteStore) cleanupRecentVisits(now time.Time) {
|
||||
for key, last := range s.recentVisits {
|
||||
if now.Sub(last) >= visitCooldown {
|
||||
delete(s.recentVisits, key)
|
||||
// RecordVisit increments the visit counter. Returns (totalVisits, counted, error).
|
||||
// For simplicity, every call increments (fingerprint dedup is handled in-memory by the handler layer).
|
||||
func (s *SiteStore) RecordVisit(_ string) (int, bool, error) {
|
||||
total, err := s.IncrementVisits()
|
||||
return total, true, err
|
||||
}
|
||||
|
||||
// SMTPConfig holds the mail sender configuration stored in the DB.
|
||||
type SMTPConfig struct {
|
||||
Email string `json:"email"`
|
||||
Password string `json:"password"`
|
||||
FromName string `json:"fromName"`
|
||||
Host string `json:"host"`
|
||||
Port string `json:"port"`
|
||||
}
|
||||
|
||||
// IsConfiguredEmail returns true if the SMTP config is ready to send mail.
|
||||
func (c SMTPConfig) IsConfiguredEmail() bool {
|
||||
return c.Email != "" && c.Password != "" && c.Host != ""
|
||||
}
|
||||
|
||||
func (s *SiteStore) GetSMTPConfig() (SMTPConfig, error) {
|
||||
cfg := SMTPConfig{
|
||||
Host: "smtp.qq.com",
|
||||
Port: "465",
|
||||
}
|
||||
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 {
|
||||
pairs := [][2]string{
|
||||
{"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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func buildSiteVisitKey(fingerprint string) string {
|
||||
sum := sha256.Sum256([]byte("site|" + fingerprint))
|
||||
return fmt.Sprintf("%x", sum)
|
||||
}
|
||||
|
||||
func ensureSiteFile(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 site file: %w", err)
|
||||
}
|
||||
initial := siteData{TotalVisits: 0}
|
||||
bytes, err := json.MarshalIndent(initial, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("init site json: %w", err)
|
||||
}
|
||||
if err := os.WriteFile(path, bytes, 0o644); err != nil {
|
||||
return fmt.Errorf("write site json: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
38
mengyastore-backend/internal/storage/wishliststore.go
Normal file
38
mengyastore-backend/internal/storage/wishliststore.go
Normal file
@@ -0,0 +1,38 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
|
||||
"mengyastore-backend/internal/database"
|
||||
)
|
||||
|
||||
type WishlistStore struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewWishlistStore(db *gorm.DB) (*WishlistStore, error) {
|
||||
return &WishlistStore{db: db}, nil
|
||||
}
|
||||
|
||||
func (s *WishlistStore) Get(accountID string) ([]string, error) {
|
||||
var rows []database.WishlistRow
|
||||
if err := s.db.Where("account_id = ?", accountID).Find(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ids := make([]string, len(rows))
|
||||
for i, r := range rows {
|
||||
ids[i] = r.ProductID
|
||||
}
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
func (s *WishlistStore) Add(accountID, productID string) error {
|
||||
return s.db.Clauses(clause.OnConflict{DoNothing: true}).
|
||||
Create(&database.WishlistRow{AccountID: accountID, ProductID: productID}).Error
|
||||
}
|
||||
|
||||
func (s *WishlistStore) Remove(accountID, productID string) error {
|
||||
return s.db.Where("account_id = ? AND product_id = ?", accountID, productID).
|
||||
Delete(&database.WishlistRow{}).Error
|
||||
}
|
||||
Reference in New Issue
Block a user