Update mengyastore
This commit is contained in:
@@ -1,15 +1,20 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
|
||||
"mengyastore-backend/internal/database"
|
||||
"mengyastore-backend/internal/models"
|
||||
)
|
||||
|
||||
const amountMatchEpsilon = 0.009
|
||||
|
||||
type OrderStore struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
@@ -20,20 +25,23 @@ func NewOrderStore(db *gorm.DB) (*OrderStore, error) {
|
||||
|
||||
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,
|
||||
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,
|
||||
PaymentMethod: row.PaymentMethod,
|
||||
PaymentExpectedTotal: row.PaymentExpectedTotal,
|
||||
PaymentExpiresAt: row.PaymentExpiresAt,
|
||||
CreatedAt: row.CreatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,24 +53,62 @@ func (s *OrderStore) Create(order models.Order) (models.Order, error) {
|
||||
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,
|
||||
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,
|
||||
PaymentMethod: order.PaymentMethod,
|
||||
PaymentExpectedTotal: order.PaymentExpectedTotal,
|
||||
PaymentExpiresAt: order.PaymentExpiresAt,
|
||||
}
|
||||
if err := s.db.Create(&row).Error; err != nil {
|
||||
return models.Order{}, err
|
||||
}
|
||||
order.CreatedAt = row.CreatedAt
|
||||
order.PaymentExpiresAt = row.PaymentExpiresAt
|
||||
return order, nil
|
||||
}
|
||||
|
||||
// CreateTx 在已有事务内创建订单。
|
||||
func (s *OrderStore) CreateTx(tx *gorm.DB, order models.Order) (models.Order, error) {
|
||||
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,
|
||||
PaymentMethod: order.PaymentMethod,
|
||||
PaymentExpectedTotal: order.PaymentExpectedTotal,
|
||||
PaymentExpiresAt: order.PaymentExpiresAt,
|
||||
}
|
||||
if err := tx.Create(&row).Error; err != nil {
|
||||
return models.Order{}, err
|
||||
}
|
||||
order.CreatedAt = row.CreatedAt
|
||||
order.PaymentExpiresAt = row.PaymentExpiresAt
|
||||
return order, nil
|
||||
}
|
||||
|
||||
@@ -112,14 +158,13 @@ func (s *OrderStore) ListAll() ([]models.Order, error) {
|
||||
|
||||
func (s *OrderStore) CountPurchasedByAccount(account, productID string) (int, error) {
|
||||
var total int64
|
||||
// 统计 pending(手动待发货)和 completed 两种状态,防止用户快速下单绕过购买数量限制。
|
||||
// pending:手动待发货;pending_payment:萌芽支付待到账(会计入限购)。
|
||||
err := s.db.Model(&database.OrderRow{}).
|
||||
Where("user_account = ? AND product_id = ? AND status IN ?", account, productID, []string{"pending", "completed"}).
|
||||
Where("user_account = ? AND product_id = ? AND status IN ?", account, productID, []string{"pending", "pending_payment", "completed"}).
|
||||
Select("COALESCE(SUM(quantity), 0)").Scan(&total).Error
|
||||
return int(total), err
|
||||
}
|
||||
|
||||
// Count 返回所有订单的总数量。
|
||||
func (s *OrderStore) Count() (int, error) {
|
||||
var count int64
|
||||
if err := s.db.Model(&database.OrderRow{}).Count(&count).Error; err != nil {
|
||||
@@ -128,13 +173,69 @@ func (s *OrderStore) Count() (int, error) {
|
||||
return int(count), nil
|
||||
}
|
||||
|
||||
// Delete 根据 ID 删除单条订单。
|
||||
func (s *OrderStore) Delete(id string) error {
|
||||
return s.db.Delete(&database.OrderRow{}, "id = ?", id).Error
|
||||
}
|
||||
|
||||
// UpdateCodes 更新订单的已发货卡密列表。
|
||||
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
|
||||
}
|
||||
|
||||
// TryFulfillMengyaPayment 在事务内按 FIFO 匹配一笔待支付订单并置为 completed、增加销量。无匹配时 ok=false。
|
||||
func (s *OrderStore) TryFulfillMengyaPayment(amount float64, now time.Time) (order models.Order, ok bool, err error) {
|
||||
err = s.db.Transaction(func(tx *gorm.DB) error {
|
||||
var row database.OrderRow
|
||||
res := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||
Where("status = ? AND payment_expires_at IS NOT NULL AND payment_expires_at > ?", "pending_payment", now).
|
||||
Where("ABS(payment_expected_total - ?) < ?", amount, amountMatchEpsilon).
|
||||
Order("created_at ASC").
|
||||
First(&row)
|
||||
if res.Error != nil {
|
||||
if errors.Is(res.Error, gorm.ErrRecordNotFound) {
|
||||
return nil
|
||||
}
|
||||
return res.Error
|
||||
}
|
||||
if err := tx.Model(&database.OrderRow{}).Where("id = ?", row.ID).Update("status", "completed").Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Model(&database.ProductRow{}).Where("id = ?", row.ProductID).
|
||||
UpdateColumn("total_sold", gorm.Expr("total_sold + ?", row.Quantity)).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
row.Status = "completed"
|
||||
order = orderRowToModel(row)
|
||||
ok = true
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return models.Order{}, false, err
|
||||
}
|
||||
return order, ok, nil
|
||||
}
|
||||
|
||||
// ListExpiredPendingPayments 返回已过期仍未支付的订单(用于释放预留库存)。
|
||||
func (s *OrderStore) ListExpiredPendingPayments(now time.Time) ([]models.Order, error) {
|
||||
var rows []database.OrderRow
|
||||
if err := s.db.Where("status = ? AND payment_expires_at IS NOT NULL AND payment_expires_at <= ?", "pending_payment", now).
|
||||
Find(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]models.Order, len(rows))
|
||||
for i, r := range rows {
|
||||
out[i] = orderRowToModel(r)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// CancelPendingStaleIfEligible 仅在仍为 pending_payment 时将订单作废并清空预留内容;返回值 ok 表示成功取消(可安全退回库存)。
|
||||
func (s *OrderStore) CancelPendingStaleIfEligible(id string) (ok bool, err error) {
|
||||
res := s.db.Model(&database.OrderRow{}).
|
||||
Where("id = ? AND status = ?", id, "pending_payment").
|
||||
Updates(map[string]interface{}{
|
||||
"status": "cancelled",
|
||||
"delivered_codes": database.StringSlice([]string{}),
|
||||
})
|
||||
return res.RowsAffected > 0, res.Error
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
|
||||
"mengyastore-backend/internal/database"
|
||||
"mengyastore-backend/internal/models"
|
||||
@@ -16,7 +17,8 @@ import (
|
||||
|
||||
const defaultCoverURL = "https://img.shumengya.top/i/2026/01/04/695a55058c37f.png"
|
||||
const viewCooldown = 6 * time.Hour
|
||||
const maxScreenshotURLs = 5
|
||||
const maxScreenshotURLs = 6
|
||||
const maxPaymentQrURLs = 6
|
||||
|
||||
type ProductStore struct {
|
||||
db *gorm.DB
|
||||
@@ -55,6 +57,7 @@ func rowToModel(row database.ProductRow, codes []string) models.Product {
|
||||
Tags: row.Tags,
|
||||
CoverURL: row.CoverURL,
|
||||
ScreenshotURLs: row.ScreenshotURLs,
|
||||
PaymentQrURLs: []string(row.PaymentQrURLs),
|
||||
VerificationURL: row.VerificationURL,
|
||||
Description: row.Description,
|
||||
Active: row.Active,
|
||||
@@ -74,8 +77,12 @@ func rowToModel(row database.ProductRow, codes []string) models.Product {
|
||||
}
|
||||
|
||||
func (s *ProductStore) loadCodes(productID string) ([]string, error) {
|
||||
return s.loadCodesTx(s.db, productID)
|
||||
}
|
||||
|
||||
func (s *ProductStore) loadCodesTx(tx *gorm.DB, productID string) ([]string, error) {
|
||||
var rows []database.ProductCodeRow
|
||||
if err := s.db.Where("product_id = ?", productID).Find(&rows).Error; err != nil {
|
||||
if err := tx.Where("product_id = ?", productID).Find(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
codes := make([]string, len(rows))
|
||||
@@ -85,8 +92,46 @@ func (s *ProductStore) loadCodes(productID string) ([]string, error) {
|
||||
return codes, nil
|
||||
}
|
||||
|
||||
// Transaction 在同一数据库连接上开启事务并执行 fn。
|
||||
func (s *ProductStore) Transaction(fn func(tx *gorm.DB) error) error {
|
||||
return s.db.Transaction(fn)
|
||||
}
|
||||
|
||||
// GetByIDForUpdate 在事务内以 FOR UPDATE 锁定商品行(须在事务中调用)。
|
||||
func (s *ProductStore) GetByIDForUpdate(tx *gorm.DB, id string) (models.Product, error) {
|
||||
var row database.ProductRow
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&row, "id = ?", id).Error; err != nil {
|
||||
return models.Product{}, fmt.Errorf("product not found")
|
||||
}
|
||||
codes, err := s.loadCodesTx(tx, id)
|
||||
if err != nil {
|
||||
return models.Product{}, err
|
||||
}
|
||||
return rowToModel(row, codes), nil
|
||||
}
|
||||
|
||||
// PrependReservedCodes 把曾预留在订单里的卡密按原顺序放回该商品可用库存队列前端(FIFO 出库顺序)。
|
||||
func (s *ProductStore) PrependReservedCodes(productID string, codes []string) error {
|
||||
codes = sanitizeCodes(codes)
|
||||
if len(codes) == 0 {
|
||||
return nil
|
||||
}
|
||||
existing, err := s.loadCodes(productID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
merged := make([]string, 0, len(codes)+len(existing))
|
||||
merged = append(merged, codes...)
|
||||
merged = append(merged, existing...)
|
||||
return s.replaceCodes(productID, merged)
|
||||
}
|
||||
|
||||
func (s *ProductStore) replaceCodes(productID string, codes []string) error {
|
||||
if err := s.db.Where("product_id = ?", productID).Delete(&database.ProductCodeRow{}).Error; err != nil {
|
||||
return s.replaceCodesTx(s.db, productID, codes)
|
||||
}
|
||||
|
||||
func (s *ProductStore) replaceCodesTx(tx *gorm.DB, productID string, codes []string) error {
|
||||
if err := tx.Where("product_id = ?", productID).Delete(&database.ProductCodeRow{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if len(codes) == 0 {
|
||||
@@ -96,7 +141,7 @@ func (s *ProductStore) replaceCodes(productID string, codes []string) error {
|
||||
for _, code := range codes {
|
||||
rows = append(rows, database.ProductCodeRow{ProductID: productID, Code: code})
|
||||
}
|
||||
return s.db.CreateInBatches(rows, 100).Error
|
||||
return tx.CreateInBatches(rows, 100).Error
|
||||
}
|
||||
|
||||
func (s *ProductStore) ListAll() ([]models.Product, error) {
|
||||
@@ -158,6 +203,7 @@ func (s *ProductStore) Create(p models.Product) (models.Product, error) {
|
||||
Tags: database.StringSlice(p.Tags),
|
||||
CoverURL: p.CoverURL,
|
||||
ScreenshotURLs: database.StringSlice(p.ScreenshotURLs),
|
||||
PaymentQrURLs: database.StringSlice(p.PaymentQrURLs),
|
||||
VerificationURL: p.VerificationURL,
|
||||
Description: p.Description,
|
||||
Active: p.Active,
|
||||
@@ -198,6 +244,7 @@ func (s *ProductStore) Update(id string, patch models.Product) (models.Product,
|
||||
"tags": database.StringSlice(normalized.Tags),
|
||||
"cover_url": normalized.CoverURL,
|
||||
"screenshot_urls": database.StringSlice(normalized.ScreenshotURLs),
|
||||
"payment_qr_urls": database.StringSlice(normalized.PaymentQrURLs),
|
||||
"verification_url": normalized.VerificationURL,
|
||||
"description": normalized.Description,
|
||||
"active": normalized.Active,
|
||||
@@ -221,6 +268,45 @@ func (s *ProductStore) Update(id string, patch models.Product) (models.Product,
|
||||
return rowToModel(updated, codes), nil
|
||||
}
|
||||
|
||||
// UpdateTx 在事务内更新商品及卡密列表(与 Update 行为一致)。
|
||||
func (s *ProductStore) UpdateTx(tx *gorm.DB, id string, patch models.Product) (models.Product, error) {
|
||||
var row database.ProductRow
|
||||
if err := tx.First(&row, "id = ?", id).Error; err != nil {
|
||||
return models.Product{}, fmt.Errorf("product not found")
|
||||
}
|
||||
normalized := normalizeProduct(patch)
|
||||
|
||||
if err := tx.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),
|
||||
"payment_qr_urls": database.StringSlice(normalized.PaymentQrURLs),
|
||||
"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
|
||||
}
|
||||
if err := s.replaceCodesTx(tx, id, normalized.Codes); err != nil {
|
||||
return models.Product{}, err
|
||||
}
|
||||
|
||||
var updated database.ProductRow
|
||||
tx.First(&updated, "id = ?", id)
|
||||
codes, _ := s.loadCodesTx(tx, id)
|
||||
return rowToModel(updated, codes), nil
|
||||
}
|
||||
|
||||
func (s *ProductStore) 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
|
||||
@@ -266,10 +352,8 @@ func (s *ProductStore) IncrementView(id, fingerprint string) (models.Product, bo
|
||||
return rowToModel(row, nil), true, nil
|
||||
}
|
||||
|
||||
// IncrementViewCount atomically increments view_count by 1 and returns the
|
||||
// updated product (without codes). It is called by the handler when Redis-based
|
||||
// deduplication has already confirmed this is a new view, so no in-memory lock
|
||||
// is needed here.
|
||||
// IncrementViewCount 原子地将 view_count +1,并返回更新后的商品(不含卡密)。
|
||||
// 在 Handler 已用 Redis 去重确认本次为新浏览时调用,故此处无需进程内锁。
|
||||
func (s *ProductStore) IncrementViewCount(id string) (models.Product, error) {
|
||||
if err := s.db.Model(&database.ProductRow{}).Where("id = ?", id).
|
||||
UpdateColumn("view_count", gorm.Expr("view_count + 1")).Error; err != nil {
|
||||
@@ -282,8 +366,8 @@ func (s *ProductStore) IncrementViewCount(id string) (models.Product, error) {
|
||||
return rowToModel(row, nil), nil
|
||||
}
|
||||
|
||||
// GetViewState returns the current product state without modifying any counter.
|
||||
// Used by the handler when a view is detected as duplicate via Redis.
|
||||
// GetViewState 只读取当前商品状态,不修改任何计数。
|
||||
// 在 Handler 已通过 Redis 判定为重复访问时使用。
|
||||
func (s *ProductStore) GetViewState(id string) (models.Product, error) {
|
||||
var row database.ProductRow
|
||||
if err := s.db.First(&row, "id = ?", id).Error; err != nil {
|
||||
@@ -309,12 +393,16 @@ func normalizeProduct(item models.Product) models.Product {
|
||||
item.Tags = []string{}
|
||||
}
|
||||
item.Tags = sanitizeTags(item.Tags)
|
||||
if item.PaymentQrURLs == nil {
|
||||
item.PaymentQrURLs = []string{}
|
||||
}
|
||||
if item.ScreenshotURLs == nil {
|
||||
item.ScreenshotURLs = []string{}
|
||||
}
|
||||
if len(item.ScreenshotURLs) > maxScreenshotURLs {
|
||||
item.ScreenshotURLs = item.ScreenshotURLs[:maxScreenshotURLs]
|
||||
}
|
||||
item.PaymentQrURLs = sanitizePaymentQrURLs(item.PaymentQrURLs)
|
||||
if item.Codes == nil {
|
||||
item.Codes = []string{}
|
||||
}
|
||||
@@ -340,6 +428,23 @@ func normalizeProduct(item models.Product) models.Product {
|
||||
return item
|
||||
}
|
||||
|
||||
func sanitizePaymentQrURLs(urls []string) []string {
|
||||
clean := make([]string, 0, len(urls))
|
||||
seen := map[string]bool{}
|
||||
for _, u := range urls {
|
||||
t := strings.TrimSpace(u)
|
||||
if t == "" || seen[t] {
|
||||
continue
|
||||
}
|
||||
seen[t] = true
|
||||
clean = append(clean, t)
|
||||
if len(clean) >= maxPaymentQrURLs {
|
||||
break
|
||||
}
|
||||
}
|
||||
return clean
|
||||
}
|
||||
|
||||
func sanitizeCodes(codes []string) []string {
|
||||
clean := make([]string, 0, len(codes))
|
||||
seen := map[string]bool{}
|
||||
|
||||
@@ -20,7 +20,7 @@ func NewSiteStore(db *gorm.DB) (*SiteStore, error) {
|
||||
func (s *SiteStore) get(key string) (string, error) {
|
||||
var row database.SiteSettingRow
|
||||
// `key` 是 MySQL 保留字,需用反引号转义以避免 SQL 语法错误。
|
||||
// 使用 Find+Limit 而非 First:缺键时不产生 ErrRecordNotFound,避免 GORM 默认 logger 刷「record not found」。
|
||||
// 使用 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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user