161 lines
6.1 KiB
Go
161 lines
6.1 KiB
Go
// 充值商品:前台可购买的积分档位与管理端增改。
|
|
|
|
package app
|
|
|
|
import (
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
"infinite-canvas/backend/internal/model"
|
|
)
|
|
|
|
func (s *Service) TopupProducts(actor *model.User) ([]model.TopupProduct, error) {
|
|
if actor == nil {
|
|
return nil, Unauthorized("请先登录")
|
|
}
|
|
if err := s.RequireFeature(FeatureCredits); err != nil {
|
|
return nil, err
|
|
}
|
|
products, err := s.repo.TopupProducts(false)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return decorateTopupProducts(products, time.Now()), nil
|
|
}
|
|
|
|
func (s *Service) AdminTopupProducts(actor *model.User) ([]model.TopupProduct, error) {
|
|
if err := s.RequireAdmin(actor); err != nil {
|
|
return nil, err
|
|
}
|
|
products, err := s.repo.TopupProducts(true)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return decorateTopupProducts(products, time.Now()), nil
|
|
}
|
|
|
|
func decorateTopupProducts(products []model.TopupProduct, now time.Time) []model.TopupProduct {
|
|
for index := range products {
|
|
product := &products[index]
|
|
if product.SaleStrategy == "" {
|
|
product.SaleStrategy = model.TopupSaleStrategyUnlimited
|
|
}
|
|
product.CanPurchase = product.Enabled
|
|
product.SaleStatus = "on_sale"
|
|
if !product.Enabled {
|
|
product.CanPurchase = false
|
|
product.SaleStatus = "disabled"
|
|
} else if product.SaleStrategy == model.TopupSaleStrategyInventory && product.StockRemaining <= 0 {
|
|
product.CanPurchase = false
|
|
product.SaleStatus = "sold_out"
|
|
} else if product.SaleStrategy == model.TopupSaleStrategyTimed {
|
|
if product.SaleStartAt != nil && now.Before(*product.SaleStartAt) {
|
|
product.CanPurchase = false
|
|
product.SaleStatus = "upcoming"
|
|
} else if product.SaleEndAt != nil && !now.Before(*product.SaleEndAt) {
|
|
product.CanPurchase = false
|
|
product.SaleStatus = "ended"
|
|
}
|
|
}
|
|
}
|
|
return products
|
|
}
|
|
|
|
func (s *Service) CreateTopupProduct(actor *model.User, request TopupProductRequest) (*model.TopupProduct, error) {
|
|
if err := s.RequireAdmin(actor); err != nil {
|
|
return nil, err
|
|
}
|
|
product, err := topupProductFromRequest(newID(), actor.ID, request, 0, 0)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if err := s.repo.CreateTopupProduct(product); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := s.appendAdminAudit(actor, "topup_product.create", "topup_product", product.ID, "创建积分充值商品", map[string]any{"amountFen": product.AmountFen, "creditsMicrocredits": product.CreditsMicrocredits, "saleStrategy": product.SaleStrategy}); err != nil {
|
|
return nil, err
|
|
}
|
|
return product, nil
|
|
}
|
|
|
|
func (s *Service) UpdateTopupProduct(actor *model.User, id string, request TopupProductRequest) (*model.TopupProduct, error) {
|
|
if err := s.RequireAdmin(actor); err != nil {
|
|
return nil, err
|
|
}
|
|
id = strings.TrimSpace(id)
|
|
var product *model.TopupProduct
|
|
// 已售数量必须在锁住商品行之后计算,否则会覆盖并发下单扣减或关单退回的库存。
|
|
err := s.repo.UpdateTopupProduct(id, func(existing *model.TopupProduct) (*model.TopupProduct, error) {
|
|
consumed := existing.StockTotal - existing.StockRemaining
|
|
if existing.SaleStrategy != model.TopupSaleStrategyInventory {
|
|
consumed = 0
|
|
}
|
|
built, buildErr := topupProductFromRequest(id, actor.ID, request, existing.StockTotal, consumed)
|
|
if buildErr != nil {
|
|
return nil, buildErr
|
|
}
|
|
product = built
|
|
return built, nil
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if err := s.appendAdminAudit(actor, "topup_product.update", "topup_product", product.ID, "更新积分充值商品", map[string]any{"enabled": product.Enabled, "saleStrategy": product.SaleStrategy}); err != nil {
|
|
return nil, err
|
|
}
|
|
return s.repo.TopupProduct(product.ID)
|
|
}
|
|
|
|
func topupProductFromRequest(id, actorID string, request TopupProductRequest, stockArgs ...int64) (*model.TopupProduct, error) {
|
|
var consumed int64
|
|
if len(stockArgs) > 1 {
|
|
consumed = stockArgs[1]
|
|
}
|
|
name := strings.TrimSpace(request.Name)
|
|
if name == "" || len([]rune(name)) > 120 {
|
|
return nil, BadAuthRequest("充值商品名称不能为空且不能超过 120 个字符")
|
|
}
|
|
if request.AmountFen <= 0 || request.AmountFen > 100_000_000 {
|
|
return nil, BadAuthRequest("充值金额必须为 1 分至 100 万元")
|
|
}
|
|
if request.CreditsMicrocredits <= 0 || request.CreditsMicrocredits > maxTopupCreditsMicrocredits {
|
|
return nil, BadAuthRequest("充值积分必须为 0.000001 至 10 亿积分")
|
|
}
|
|
strategy := request.SaleStrategy
|
|
if strategy == "" {
|
|
strategy = model.TopupSaleStrategyUnlimited
|
|
}
|
|
if strategy != model.TopupSaleStrategyUnlimited && strategy != model.TopupSaleStrategyPeriodic && strategy != model.TopupSaleStrategyInventory && strategy != model.TopupSaleStrategyTimed {
|
|
return nil, BadAuthRequest("上架策略无效")
|
|
}
|
|
if strategy == model.TopupSaleStrategyPeriodic && (request.PeriodDays <= 0 || request.PeriodDays > 3650 || request.PeriodPurchaseLimit <= 0 || request.PeriodPurchaseLimit > 100000) {
|
|
return nil, BadAuthRequest("周期性商品需设置有效的周期天数和购买次数")
|
|
}
|
|
if strategy == model.TopupSaleStrategyInventory && request.StockTotal <= 0 {
|
|
return nil, BadAuthRequest("库存性商品库存必须大于 0")
|
|
}
|
|
if strategy == model.TopupSaleStrategyTimed && (request.SaleStartAt == nil || request.SaleEndAt == nil || !request.SaleEndAt.After(*request.SaleStartAt)) {
|
|
return nil, BadAuthRequest("限时商品必须设置有效的发售和结束时间")
|
|
}
|
|
stockRemaining := int64(0)
|
|
if strategy == model.TopupSaleStrategyInventory {
|
|
if consumed < 0 {
|
|
consumed = 0
|
|
}
|
|
if request.StockTotal < consumed {
|
|
return nil, BadAuthRequest(fmt.Sprintf("库存不能低于已售数量(至少 %d)", consumed))
|
|
}
|
|
stockRemaining = request.StockTotal - consumed
|
|
}
|
|
return &model.TopupProduct{
|
|
ID: id, Name: name, Description: truncateRunes(strings.TrimSpace(request.Description), 500),
|
|
AmountFen: request.AmountFen, CreditsMicrocredits: request.CreditsMicrocredits,
|
|
Enabled: request.Enabled, SortOrder: request.SortOrder, SaleStrategy: strategy,
|
|
PeriodDays: request.PeriodDays, PeriodPurchaseLimit: request.PeriodPurchaseLimit,
|
|
StockTotal: request.StockTotal, StockRemaining: stockRemaining,
|
|
SaleStartAt: request.SaleStartAt, SaleEndAt: request.SaleEndAt,
|
|
CreatedBy: actorID, UpdatedBy: actorID,
|
|
}, nil
|
|
}
|