refactor(cart): complete vertical migration

This commit is contained in:
dujiao-next committed 2026-07-22 18:39:41 +08:00
1 parent 10852e3b39
commit 80804dbb1f
30 files changed
+512 -379

No files matched your search

@@ -9,12 +9,22 @@ import (
func TestCartHTTPLivesInTransport(t *testing.T) {
repositoryRoot := findRepositoryRoot(t)
moduleRoot := filepath.Join(repositoryRoot, "internal", "modules", "cart")
transportRoot := filepath.Join(repositoryRoot, "internal", "transport", "http", "cart")
applicationRoot := filepath.Join(moduleRoot, "application")
contractRoot := filepath.Join(moduleRoot, "contract")
domainRoot := filepath.Join(moduleRoot, "domain")
storeRoot := filepath.Join(moduleRoot, "infrastructure", "gormstore")
transportRoot := filepath.Join(moduleRoot, "transport", "http")
presenterRoot := filepath.Join(moduleRoot, "transport", "presenter")
assertFileDeclaresTypes(t, filepath.Join(moduleRoot, "service.go"), []string{
"Service", "Repository", "ProductReader", "SKUReader", "CurrencyReader", "ItemDetail", "UpsertItemInput",
assertFileDeclaresTypes(t, filepath.Join(applicationRoot, "service.go"), []string{"Service"})
assertFileDeclaresTypes(t, filepath.Join(applicationRoot, "types.go"), []string{"ItemDetail", "UpsertItemInput"})
assertFileDeclaresFunctions(t, filepath.Join(applicationRoot, "service.go"), []string{"NewService"})
assertFileDeclaresTypes(t, filepath.Join(contractRoot, "ports.go"), []string{
"StoredItem", "Repository", "ProductReader", "SKUReader", "CurrencyReader",
})
assertFileDeclaresFunctions(t, filepath.Join(moduleRoot, "service.go"), []string{"NewService"})
assertFileDeclaresTypes(t, filepath.Join(domainRoot, "item.go"), []string{"Item"})
assertFileDeclaresTypes(t, filepath.Join(storeRoot, "store.go"), []string{"Store"})
assertFileDeclaresFunctions(t, filepath.Join(storeRoot, "store.go"), []string{"New", "WithTx"})
assertFileDeclaresFunctions(t, filepath.Join(transportRoot, "routes.go"), []string{"RegisterUserRoutes"})
assertFileDeclaresTypes(t, filepath.Join(transportRoot, "user_handler.go"), []string{
"UserHandler", "Service",
@@ -22,8 +32,20 @@ func TestCartHTTPLivesInTransport(t *testing.T) {
assertFileDeclaresFunctions(t, filepath.Join(transportRoot, "user_handler.go"), []string{
"NewUserHandler", "GetCart", "UpsertCartItem", "DeleteCartItem",
})
assertDirectoryGoFileBudget(t, moduleRoot, 1)
production, total := countDirectGoFiles(t, moduleRoot)
if production != 0 || total != 0 {
t.Fatalf("cart module root must remain structural only, got production=%d total=%d", production, total)
}
assertDirectoryGoFileBudget(t, applicationRoot, 3)
assertDirectoryGoFileBudget(t, contractRoot, 3)
assertDirectoryGoFileBudget(t, domainRoot, 2)
assertDirectoryGoFileBudget(t, storeRoot, 3)
assertDirectoryGoFileBudget(t, transportRoot, 4)
assertDirectoryGoFileBudget(t, presenterRoot, 2)
assertProductionImportsAbsent(t, applicationRoot, moduleImportPath+"/internal/models")
assertProductionImportsAbsent(t, contractRoot, moduleImportPath+"/internal/models")
assertProductionImportsAbsent(t, domainRoot, moduleImportPath+"/internal/models")
assertProductionImportsAbsent(t, transportRoot, moduleImportPath+"/internal/models")
legacy := filepath.Join(repositoryRoot, "internal", "http", "handlers", "public", "cart.go")
if _, err := os.Stat(legacy); err == nil {
@@ -34,6 +56,7 @@ func TestCartHTTPLivesInTransport(t *testing.T) {
for _, relativePath := range []string{
"internal/service/cart_service.go",
"internal/wiring/cart/wiring.go",
"internal/wiring/cart/factory.go",
} {
path := filepath.Join(repositoryRoot, filepath.FromSlash(relativePath))
if _, err := os.Stat(path); err == nil {
@@ -305,6 +305,13 @@ var completedMigrationPaths = []string{
"internal/transport/http/dashboard",
"internal/http/handlers/shared/reporting_query.go",
"internal/http/handlers/shared/reporting_query_test.go",
"internal/models/cart_item.go",
"internal/repository/cart_repository.go",
"internal/repository/cart_repository_test.go",
"internal/modules/cart/service.go",
"internal/dto/cart.go",
"internal/transport/http/cart",
"internal/wiring/cart",
}
func TestCompletedMigrationPathsStayDeleted(t *testing.T) {
@@ -7,6 +7,7 @@ import (
categorycontract "github.com/dujiao-next/internal/modules/catalog/category/contract"
cartgormstore "github.com/dujiao-next/internal/modules/cart/infrastructure/gormstore"
mappingcontract "github.com/dujiao-next/internal/modules/catalog/mapping/contract"
productapplication "github.com/dujiao-next/internal/modules/catalog/product/application"
productadmin "github.com/dujiao-next/internal/modules/catalog/product/application/admin"
@@ -47,7 +48,7 @@ type Dependencies struct {
CardSecretBatches repository.CardSecretBatchRepository
Categories categorycontract.Repository
MemberLevelPrices memberLevelPriceCleaner
Carts repository.CartRepository
Carts *cartgormstore.Store
ProductMappings MappingStore
Orders repository.OrderRepository
PaymentChannels repository.PaymentChannelRepository
@@ -110,7 +111,7 @@ type productAdminUnitOfWork struct {
cardSecrets repository.CardSecretRepository
cardSecretBatches repository.CardSecretBatchRepository
memberLevelPrices memberLevelPriceCleaner
carts repository.CartRepository
carts *cartgormstore.Store
productMappings MappingStore
}
@@ -120,7 +121,7 @@ func newProductAdminUnitOfWork(
cardSecrets repository.CardSecretRepository,
cardSecretBatches repository.CardSecretBatchRepository,
memberLevelPrices memberLevelPriceCleaner,
carts repository.CartRepository,
carts *cartgormstore.Store,
productMappings MappingStore,
) productadmin.UnitOfWork {
return &productAdminUnitOfWork{
-29
View File
@@ -1,29 +0,0 @@
package models
import (
"time"
productdomain "github.com/dujiao-next/internal/modules/catalog/product/domain"
"gorm.io/gorm"
)
// CartItem 购物车项
type CartItem struct {
ID uint `gorm:"primarykey" json:"id"` // 主键
UserID uint `gorm:"not null;uniqueIndex:idx_cart_user_product_sku" json:"user_id"` // 用户ID
ProductID uint `gorm:"not null;uniqueIndex:idx_cart_user_product_sku" json:"product_id"` // 商品ID
SKUID uint `gorm:"column:sku_id;not null;default:0;uniqueIndex:idx_cart_user_product_sku" json:"sku_id"` // SKU ID
Quantity int `gorm:"not null" json:"quantity"` // 数量
FulfillmentType string `gorm:"type:varchar(20);not null;default:'manual'" json:"fulfillment_type"` // 交付类型
CreatedAt time.Time `gorm:"index" json:"created_at"` // 创建时间
UpdatedAt time.Time `gorm:"index" json:"updated_at"` // 更新时间
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` // 软删除时间
Product *productdomain.Product `gorm:"foreignKey:ProductID" json:"product,omitempty"` // 关联商品
SKU *productdomain.ProductSKU `gorm:"foreignKey:SKUID" json:"sku,omitempty"` // 关联SKU
}
// TableName 指定表名
func (CartItem) TableName() string {
return "cart_items"
}
+2 -1
View File
@@ -11,6 +11,7 @@ import (
affiliatedomain "github.com/dujiao-next/internal/modules/affiliate/domain"
apicredentialdomain "github.com/dujiao-next/internal/modules/apicredential/domain"
auditlogdomain "github.com/dujiao-next/internal/modules/auditlog/domain"
cartdomain "github.com/dujiao-next/internal/modules/cart/domain"
categorydomain "github.com/dujiao-next/internal/modules/catalog/category/domain"
mappingdomain "github.com/dujiao-next/internal/modules/catalog/mapping/domain"
productdomain "github.com/dujiao-next/internal/modules/catalog/product/domain"
@@ -170,7 +171,7 @@ func AutoMigrate() error {
&Order{},
&OrderItem{},
&OrderRefundRecord{},
&CartItem{},
&cartdomain.Item{},
&PaymentChannel{},
&Payment{},
&CardSecret{},
+8 -6
View File
@@ -5,6 +5,8 @@ import (
"testing"
"time"
cartdomain "github.com/dujiao-next/internal/modules/cart/domain"
productdomain "github.com/dujiao-next/internal/modules/catalog/product/domain"
settingsstore "github.com/dujiao-next/internal/modules/settings/infrastructure/gormstore"
"github.com/dujiao-next/internal/shared/jsonmap"
@@ -34,7 +36,7 @@ func TestEnsureProductSKUMigrationBackfillLegacyData(t *testing.T) {
&productdomain.Product{},
&productdomain.ProductSKU{},
&OrderItem{},
&CartItem{},
&cartdomain.Item{},
&CardSecret{},
&CardSecretBatch{},
&settingsstore.SettingRecord{},
@@ -75,7 +77,7 @@ func TestEnsureProductSKUMigrationBackfillLegacyData(t *testing.T) {
t.Fatalf("create order item failed: %v", err)
}
cartItem := &CartItem{
cartItem := &cartdomain.Item{
UserID: 1001,
ProductID: product.ID,
SKUID: 0,
@@ -137,7 +139,7 @@ func TestEnsureProductSKUMigrationBackfillLegacyData(t *testing.T) {
t.Fatalf("order item sku_id want %d got %d", sku.ID, gotOrderItem.SKUID)
}
var gotCartItem CartItem
var gotCartItem cartdomain.Item
if err := db.First(&gotCartItem, cartItem.ID).Error; err != nil {
t.Fatalf("reload cart item failed: %v", err)
}
@@ -176,7 +178,7 @@ func TestEnsureProductSKUMigrationBackfillLegacyData(t *testing.T) {
func TestMigrateCartSKUUniqueIndex(t *testing.T) {
db := setupSKUMigrationTestDB(t)
if err := db.AutoMigrate(&CartItem{}); err != nil {
if err := db.AutoMigrate(&cartdomain.Item{}); err != nil {
t.Fatalf("auto migrate cart item failed: %v", err)
}
@@ -189,10 +191,10 @@ func TestMigrateCartSKUUniqueIndex(t *testing.T) {
t.Fatalf("migrate cart unique index failed: %v", err)
}
if db.Migrator().HasIndex(&CartItem{}, "idx_cart_user_product") {
if db.Migrator().HasIndex(&cartdomain.Item{}, "idx_cart_user_product") {
t.Fatalf("legacy unique index idx_cart_user_product should be dropped")
}
if !db.Migrator().HasIndex(&CartItem{}, "idx_cart_user_product_sku") {
if !db.Migrator().HasIndex(&cartdomain.Item{}, "idx_cart_user_product_sku") {
t.Fatalf("new unique index idx_cart_user_product_sku should exist")
}
}
+8 -6
View File
@@ -6,6 +6,8 @@ import (
"strings"
"time"
cartdomain "github.com/dujiao-next/internal/modules/cart/domain"
categorydomain "github.com/dujiao-next/internal/modules/catalog/category/domain"
productdomain "github.com/dujiao-next/internal/modules/catalog/product/domain"
settingsstore "github.com/dujiao-next/internal/modules/settings/infrastructure/gormstore"
@@ -114,14 +116,14 @@ func migrateCartSKUUniqueIndex() error {
migrator := DB.Migrator()
// 历史唯一索引会阻止同一商品不同 SKU 共存,迁移时必须移除。
if migrator.HasIndex(&CartItem{}, "idx_cart_user_product") {
if err := migrator.DropIndex(&CartItem{}, "idx_cart_user_product"); err != nil {
if migrator.HasIndex(&cartdomain.Item{}, "idx_cart_user_product") {
if err := migrator.DropIndex(&cartdomain.Item{}, "idx_cart_user_product"); err != nil {
return err
}
}
if !migrator.HasIndex(&CartItem{}, "idx_cart_user_product_sku") {
if err := migrator.CreateIndex(&CartItem{}, "idx_cart_user_product_sku"); err != nil {
if !migrator.HasIndex(&cartdomain.Item{}, "idx_cart_user_product_sku") {
if err := migrator.CreateIndex(&cartdomain.Item{}, "idx_cart_user_product_sku"); err != nil {
return err
}
}
@@ -276,7 +278,7 @@ func backfillLegacySKUID(productToSKU map[uint]uint) error {
Update("sku_id", skuID).Error; err != nil {
return err
}
if err := tx.Unscoped().Model(&CartItem{}).
if err := tx.Model(&cartdomain.Item{}).
Where("product_id = ? AND sku_id = 0", productID).
Update("sku_id", skuID).Error; err != nil {
return err
@@ -316,7 +318,7 @@ func validateSKUMigrationIntegrity() error {
name: "cart_items",
query: func() (int64, error) {
var count int64
err := DB.Model(&CartItem{}).Where("sku_id = 0").Count(&count).Error
err := DB.Model(&cartdomain.Item{}).Where("sku_id = 0").Count(&count).Error
return count, err
},
},
@@ -1,84 +1,28 @@
package cart
package application
import (
"errors"
"strconv"
"strings"
"time"
"github.com/dujiao-next/internal/constants"
"github.com/dujiao-next/internal/models"
"github.com/dujiao-next/internal/modules/cart/contract"
"github.com/dujiao-next/internal/modules/cart/domain"
productdomain "github.com/dujiao-next/internal/modules/catalog/product/domain"
promotionapp "github.com/dujiao-next/internal/modules/promotion/application"
promotioncontract "github.com/dujiao-next/internal/modules/promotion/contract"
"github.com/dujiao-next/internal/shared/money"
)
var (
ErrInvalidItem = errors.New("invalid cart item")
ErrProductUnavailable = errors.New("product not available")
ErrFulfillmentInvalid = errors.New("fulfillment invalid")
ErrSKURequired = errors.New("product sku required")
ErrSKUInvalid = errors.New("product sku invalid")
ErrManualStockInsufficient = errors.New("manual stock insufficient")
)
// ItemDetail 购物车项详情(用于响应)。
type ItemDetail struct {
ProductID uint `json:"product_id"`
SKUID uint `json:"sku_id"`
Quantity int `json:"quantity"`
FulfillmentType string `json:"fulfillment_type"`
UnitPrice money.Amount `json:"unit_price"`
OriginalPrice money.Amount `json:"original_price"`
Currency string `json:"currency"`
Product *productdomain.Product `json:"product"`
SKU *productdomain.ProductSKU `json:"sku"`
}
// UpsertItemInput 购物车更新输入。
type UpsertItemInput struct {
UserID uint
ProductID uint
SKUID uint
Quantity int
FulfillmentType string
}
// Repository 是购物车服务所需的最小持久化端口。
type Repository interface {
ListByUser(userID uint) ([]models.CartItem, error)
Upsert(item *models.CartItem) error
DeleteByUserProductSKU(userID, productID, skuID uint) error
}
// ProductReader 是购物车服务所需的商品读取端口。
type ProductReader interface {
GetByID(id string) (*productdomain.Product, error)
}
// SKUReader 是购物车服务所需的 SKU 读取端口。
type SKUReader interface {
GetByID(id uint) (*productdomain.ProductSKU, error)
ListByProduct(productID uint, onlyActive bool) ([]productdomain.ProductSKU, error)
}
// CurrencyReader 是购物车服务所需的站点币种端口。
type CurrencyReader interface {
GetSiteCurrency(defaultCurrency string) (string, error)
}
// Service 购物车服务。
type Service struct {
cartRepo Repository
productRepo ProductReader
productSKURepo SKUReader
promotionRepo promotioncontract.Repository
currencyReader CurrencyReader
cartRepo contract.Repository
productRepo contract.ProductReader
productSKURepo contract.SKUReader
promotionRepo contract.PromotionRepository
currencyReader contract.CurrencyReader
}
// NewService 创建购物车服务。
func NewService(cartRepo Repository, productRepo ProductReader, productSKURepo SKUReader, promotionRepo promotioncontract.Repository, currencyReader CurrencyReader) *Service {
func NewService(cartRepo contract.Repository, productRepo contract.ProductReader, productSKURepo contract.SKUReader, promotionRepo contract.PromotionRepository, currencyReader contract.CurrencyReader) *Service {
return &Service{
cartRepo: cartRepo,
productRepo: productRepo,
@@ -91,7 +35,7 @@ func NewService(cartRepo Repository, productRepo ProductReader, productSKURepo S
// ListByUser 获取用户购物车
func (s *Service) ListByUser(userID uint) ([]ItemDetail, error) {
if userID == 0 {
return nil, ErrInvalidItem
return nil, contract.ErrInvalidItem
}
items, err := s.cartRepo.ListByUser(userID)
if err != nil {
@@ -169,14 +113,14 @@ func (s *Service) ListByUser(userID uint) ([]ItemDetail, error) {
// UpsertItem 添加或更新购物车项
func (s *Service) UpsertItem(input UpsertItemInput) error {
if input.UserID == 0 || input.ProductID == 0 || input.Quantity <= 0 {
return ErrInvalidItem
return contract.ErrInvalidItem
}
product, err := s.productRepo.GetByID(strconv.FormatUint(uint64(input.ProductID), 10))
if err != nil {
return err
}
if product == nil || !product.IsActive {
return ErrProductUnavailable
return contract.ErrProductUnavailable
}
if err := productdomain.ValidatePurchaseQuantity(product, input.Quantity); err != nil {
return err
@@ -191,16 +135,16 @@ func (s *Service) UpsertItem(input UpsertItemInput) error {
fulfillmentType = constants.FulfillmentTypeManual
}
if fulfillmentType != constants.FulfillmentTypeManual && fulfillmentType != constants.FulfillmentTypeAuto {
return ErrFulfillmentInvalid
return contract.ErrFulfillmentInvalid
}
if fulfillmentType == constants.FulfillmentTypeManual &&
productdomain.ShouldEnforceManualSKUStock(product, sku) &&
productdomain.ManualSKUAvailable(sku) < input.Quantity {
return ErrManualStockInsufficient
return contract.ErrManualStockInsufficient
}
now := time.Now()
item := &models.CartItem{
item := &domain.Item{
UserID: input.UserID,
ProductID: input.ProductID,
SKUID: sku.ID,
@@ -215,7 +159,7 @@ func (s *Service) UpsertItem(input UpsertItemInput) error {
// RemoveItem 删除购物车项
func (s *Service) RemoveItem(userID, productID, skuID uint) error {
if userID == 0 || productID == 0 {
return ErrInvalidItem
return contract.ErrInvalidItem
}
return s.cartRepo.DeleteByUserProductSKU(userID, productID, skuID)
}
@@ -231,12 +175,12 @@ func (s *Service) siteCurrency() string {
return currency
}
func resolveProductSKU(repo SKUReader, product *productdomain.Product, rawSKUID uint) (*productdomain.ProductSKU, error) {
func resolveProductSKU(repo contract.SKUReader, product *productdomain.Product, rawSKUID uint) (*productdomain.ProductSKU, error) {
if product == nil || product.ID == 0 {
return nil, ErrProductUnavailable
return nil, contract.ErrProductUnavailable
}
if repo == nil {
return nil, ErrSKUInvalid
return nil, contract.ErrSKUInvalid
}
if rawSKUID > 0 {
sku, err := repo.GetByID(rawSKUID)
@@ -244,7 +188,7 @@ func resolveProductSKU(repo SKUReader, product *productdomain.Product, rawSKUID
return nil, err
}
if sku == nil || sku.ProductID != product.ID || !sku.IsActive {
return nil, ErrSKUInvalid
return nil, contract.ErrSKUInvalid
}
return sku, nil
}
@@ -256,7 +200,7 @@ func resolveProductSKU(repo SKUReader, product *productdomain.Product, rawSKUID
return &activeSKUs[0], nil
}
if len(activeSKUs) == 0 {
return nil, ErrSKUInvalid
return nil, contract.ErrSKUInvalid
}
return nil, ErrSKURequired
return nil, contract.ErrSKURequired
}
@@ -0,0 +1,28 @@
package application
import (
productdomain "github.com/dujiao-next/internal/modules/catalog/product/domain"
"github.com/dujiao-next/internal/shared/money"
)
// ItemDetail 购物车项详情。
type ItemDetail struct {
ProductID uint
SKUID uint
Quantity int
FulfillmentType string
UnitPrice money.Amount
OriginalPrice money.Amount
Currency string
Product *productdomain.Product
SKU *productdomain.ProductSKU
}
// UpsertItemInput 购物车更新输入。
type UpsertItemInput struct {
UserID uint
ProductID uint
SKUID uint
Quantity int
FulfillmentType string
}
+12
View File
@@ -0,0 +1,12 @@
package contract
import "errors"
var (
ErrInvalidItem = errors.New("invalid cart item")
ErrProductUnavailable = errors.New("product not available")
ErrFulfillmentInvalid = errors.New("fulfillment invalid")
ErrSKURequired = errors.New("product sku required")
ErrSKUInvalid = errors.New("product sku invalid")
ErrManualStockInsufficient = errors.New("manual stock insufficient")
)
+40
View File
@@ -0,0 +1,40 @@
package contract
import (
"github.com/dujiao-next/internal/modules/cart/domain"
productdomain "github.com/dujiao-next/internal/modules/catalog/product/domain"
promotioncontract "github.com/dujiao-next/internal/modules/promotion/contract"
)
// StoredItem 是购物车持久化端口返回的条目及其商品快照。
type StoredItem struct {
domain.Item
Product *productdomain.Product
SKU *productdomain.ProductSKU
}
// Repository 是购物车应用层所需的最小持久化端口。
type Repository interface {
ListByUser(userID uint) ([]StoredItem, error)
Upsert(item *domain.Item) error
DeleteByUserProductSKU(userID, productID, skuID uint) error
}
// ProductReader 是购物车服务所需的商品读取端口。
type ProductReader interface {
GetByID(id string) (*productdomain.Product, error)
}
// SKUReader 是购物车服务所需的 SKU 读取端口。
type SKUReader interface {
GetByID(id uint) (*productdomain.ProductSKU, error)
ListByProduct(productID uint, onlyActive bool) ([]productdomain.ProductSKU, error)
}
// CurrencyReader 是购物车服务所需的站点币种端口。
type CurrencyReader interface {
GetSiteCurrency(defaultCurrency string) (string, error)
}
// PromotionRepository 是购物车价格计算所需的活动价端口。
type PromotionRepository = promotioncontract.Repository
+23
View File
@@ -0,0 +1,23 @@
package domain
import (
"time"
)
// Item 购物车项。
type Item struct {
ID uint `gorm:"primarykey" json:"id"` // 主键
UserID uint `gorm:"not null;uniqueIndex:idx_cart_user_product_sku" json:"user_id"` // 用户ID
ProductID uint `gorm:"not null;uniqueIndex:idx_cart_user_product_sku" json:"product_id"` // 商品ID
SKUID uint `gorm:"column:sku_id;not null;default:0;uniqueIndex:idx_cart_user_product_sku" json:"sku_id"` // SKU ID
Quantity int `gorm:"not null" json:"quantity"` // 数量
FulfillmentType string `gorm:"type:varchar(20);not null;default:'manual'" json:"fulfillment_type"` // 交付类型
CreatedAt time.Time `gorm:"index" json:"created_at"` // 创建时间
UpdatedAt time.Time `gorm:"index" json:"updated_at"` // 更新时间
DeletedAt *time.Time `gorm:"index" json:"-"` // 软删除时间
}
// TableName 指定表名
func (Item) TableName() string {
return "cart_items"
}
@@ -0,0 +1,111 @@
package gormstore
import (
"errors"
"github.com/dujiao-next/internal/modules/cart/contract"
"github.com/dujiao-next/internal/modules/cart/domain"
productdomain "github.com/dujiao-next/internal/modules/catalog/product/domain"
"gorm.io/gorm"
)
// Store 是 Cart 的 GORM 持久化适配器。
type Store struct {
db *gorm.DB
}
var _ contract.Repository = (*Store)(nil)
type itemRecord struct {
domain.Item `gorm:"embedded"`
Product *productdomain.Product `gorm:"foreignKey:ProductID"`
SKU *productdomain.ProductSKU `gorm:"foreignKey:SKUID"`
}
func (itemRecord) TableName() string { return "cart_items" }
// New 创建购物车持久化适配器。
func New(db *gorm.DB) *Store {
return &Store{db: db}
}
// WithTx 绑定事务
func (r *Store) WithTx(tx *gorm.DB) *Store {
if tx == nil {
return r
}
return &Store{db: tx}
}
// ListByUser 获取用户购物车项
func (r *Store) ListByUser(userID uint) ([]contract.StoredItem, error) {
var records []itemRecord
if err := r.db.
Preload("Product", "deleted_at IS NULL").
Preload("SKU", "deleted_at IS NULL").
Where("cart_items.user_id = ? AND cart_items.deleted_at IS NULL", userID).
Order("updated_at desc").
Find(&records).Error; err != nil {
return nil, err
}
items := make([]contract.StoredItem, 0, len(records))
for index := range records {
record := &records[index]
items = append(items, contract.StoredItem{
Item: record.Item,
Product: record.Product,
SKU: record.SKU,
})
}
return items, nil
}
// Upsert 添加或更新购物车项
func (r *Store) Upsert(item *domain.Item) error {
if item == nil {
return nil
}
var existing domain.Item
err := r.db.Where("user_id = ? AND product_id = ? AND sku_id = ? AND deleted_at IS NULL", item.UserID, item.ProductID, item.SKUID).First(&existing).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return r.db.Create(item).Error
}
if err != nil {
return err
}
updates := map[string]interface{}{
"sku_id": item.SKUID,
"quantity": item.Quantity,
"fulfillment_type": item.FulfillmentType,
"updated_at": item.UpdatedAt,
}
return r.db.Model(&existing).Updates(updates).Error
}
// DeleteByProduct 删除指定商品的所有购物车项
func (r *Store) DeleteByProduct(productID uint) error {
return r.softDelete(r.db.Where("product_id = ? AND deleted_at IS NULL", productID))
}
// DeleteByUserAndProduct 删除购物车项
func (r *Store) DeleteByUserAndProduct(userID, productID uint) error {
return r.softDelete(r.db.Where("user_id = ? AND product_id = ? AND deleted_at IS NULL", userID, productID))
}
// DeleteByUserProductSKU 按用户+商品+SKU删除购物车项
func (r *Store) DeleteByUserProductSKU(userID, productID, skuID uint) error {
if skuID == 0 {
return r.DeleteByUserAndProduct(userID, productID)
}
return r.softDelete(r.db.Where("user_id = ? AND product_id = ? AND sku_id = ? AND deleted_at IS NULL", userID, productID, skuID))
}
// ClearByUser 清空购物车
func (r *Store) ClearByUser(userID uint) error {
return r.softDelete(r.db.Where("user_id = ? AND deleted_at IS NULL", userID))
}
func (r *Store) softDelete(query *gorm.DB) error {
return query.Model(&domain.Item{}).Update("deleted_at", r.db.NowFunc()).Error
}
@@ -0,0 +1,165 @@
package gormstore
import (
"fmt"
"testing"
"time"
"github.com/dujiao-next/internal/modules/cart/domain"
productdomain "github.com/dujiao-next/internal/modules/catalog/product/domain"
"github.com/dujiao-next/internal/shared/jsonmap"
"github.com/glebarez/sqlite"
"gorm.io/gorm"
)
func setupCartRepositoryTest(t *testing.T) (*Store, *gorm.DB) {
t.Helper()
dsn := fmt.Sprintf("file:cart_repository_test_%d?mode=memory&cache=shared", time.Now().UnixNano())
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{
DisableForeignKeyConstraintWhenMigrating: true,
})
if err != nil {
t.Fatalf("open sqlite failed: %v", err)
}
if err := db.AutoMigrate(&productdomain.Product{}, &productdomain.ProductSKU{}, &domain.Item{}); err != nil {
t.Fatalf("migrate cart item failed: %v", err)
}
return New(db), db
}
func TestStoreListByUserLoadsProductAndSKUAndHidesDeletedItems(t *testing.T) {
repo, db := setupCartRepositoryTest(t)
now := time.Now()
product := productdomain.Product{
ID: 888,
CategoryID: 1,
Slug: "cart-product",
TitleJSON: jsonmap.JSON{"zh-CN": "购物车商品"},
FulfillmentType: "manual",
IsActive: true,
CreatedAt: now,
UpdatedAt: now,
}
if err := db.Create(&product).Error; err != nil {
t.Fatalf("create product failed: %v", err)
}
sku := productdomain.ProductSKU{
ID: 101,
ProductID: product.ID,
SKUCode: "CART-SKU",
IsActive: true,
CreatedAt: now,
UpdatedAt: now,
}
if err := db.Create(&sku).Error; err != nil {
t.Fatalf("create sku failed: %v", err)
}
item := &domain.Item{
UserID: 10001,
ProductID: product.ID,
SKUID: sku.ID,
Quantity: 2,
FulfillmentType: "manual",
CreatedAt: now,
UpdatedAt: now,
}
if err := repo.Upsert(item); err != nil {
t.Fatalf("upsert item failed: %v", err)
}
items, err := repo.ListByUser(item.UserID)
if err != nil {
t.Fatalf("list cart items failed: %v", err)
}
if len(items) != 1 {
t.Fatalf("cart items want 1 got %d", len(items))
}
if items[0].Product == nil || items[0].Product.ID != product.ID {
t.Fatalf("product association was not loaded: %#v", items[0].Product)
}
if items[0].SKU == nil || items[0].SKU.ID != sku.ID {
t.Fatalf("sku association was not loaded: %#v", items[0].SKU)
}
if err := repo.DeleteByUserProductSKU(item.UserID, item.ProductID, item.SKUID); err != nil {
t.Fatalf("delete item failed: %v", err)
}
items, err = repo.ListByUser(item.UserID)
if err != nil {
t.Fatalf("list after delete failed: %v", err)
}
if len(items) != 0 {
t.Fatalf("deleted cart item must be hidden, got %d rows", len(items))
}
var deleted domain.Item
if err := db.Where("id = ?", item.ID).First(&deleted).Error; err != nil {
t.Fatalf("load deleted row failed: %v", err)
}
if deleted.DeletedAt == nil {
t.Fatal("deleted_at was not persisted")
}
}
func TestCartRepositoryUpsertUsesProductAndSKUDimension(t *testing.T) {
repo, db := setupCartRepositoryTest(t)
now := time.Now()
first := &domain.Item{
UserID: 10001,
ProductID: 888,
SKUID: 101,
Quantity: 1,
FulfillmentType: "manual",
CreatedAt: now,
UpdatedAt: now,
}
second := &domain.Item{
UserID: 10001,
ProductID: 888,
SKUID: 102,
Quantity: 2,
FulfillmentType: "manual",
CreatedAt: now,
UpdatedAt: now,
}
if err := repo.Upsert(first); err != nil {
t.Fatalf("upsert first sku failed: %v", err)
}
if err := repo.Upsert(second); err != nil {
t.Fatalf("upsert second sku failed: %v", err)
}
var count int64
if err := db.Model(&domain.Item{}).
Where("user_id = ? AND product_id = ? AND deleted_at IS NULL", first.UserID, first.ProductID).
Count(&count).Error; err != nil {
t.Fatalf("count cart items failed: %v", err)
}
if count != 2 {
t.Fatalf("cart rows want 2 got %d", count)
}
first.Quantity = 5
first.UpdatedAt = now.Add(time.Minute)
if err := repo.Upsert(first); err != nil {
t.Fatalf("update first sku failed: %v", err)
}
var gotFirst domain.Item
if err := db.Where("user_id = ? AND product_id = ? AND sku_id = ?", first.UserID, first.ProductID, first.SKUID).First(&gotFirst).Error; err != nil {
t.Fatalf("query first sku row failed: %v", err)
}
if gotFirst.Quantity != 5 {
t.Fatalf("first sku quantity want 5 got %d", gotFirst.Quantity)
}
var gotSecond domain.Item
if err := db.Where("user_id = ? AND product_id = ? AND sku_id = ?", second.UserID, second.ProductID, second.SKUID).First(&gotSecond).Error; err != nil {
t.Fatalf("query second sku row failed: %v", err)
}
if gotSecond.Quantity != 2 {
t.Fatalf("second sku quantity should keep 2 got %d", gotSecond.Quantity)
}
}
@@ -4,8 +4,9 @@ import (
"errors"
"github.com/dujiao-next/internal/constants"
"github.com/dujiao-next/internal/dto"
"github.com/dujiao-next/internal/modules/cart"
cartapp "github.com/dujiao-next/internal/modules/cart/application"
cartcontract "github.com/dujiao-next/internal/modules/cart/contract"
cartpresenter "github.com/dujiao-next/internal/modules/cart/transport/presenter"
productdomain "github.com/dujiao-next/internal/modules/catalog/product/domain"
promotioncontract "github.com/dujiao-next/internal/modules/promotion/contract"
"github.com/dujiao-next/internal/platform/http/ginutil"
@@ -16,8 +17,8 @@ import (
// Service 购物车应用端口。
type Service interface {
ListByUser(userID uint) ([]cart.ItemDetail, error)
UpsertItem(input cart.UpsertItemInput) error
ListByUser(userID uint) ([]cartapp.ItemDetail, error)
UpsertItem(input cartapp.UpsertItemInput) error
RemoveItem(userID, productID, skuID uint) error
}
@@ -51,11 +52,11 @@ func (h *UserHandler) GetCart(c *gin.Context) {
items, err := h.carts.ListByUser(uid)
if err != nil {
switch {
case errors.Is(err, cart.ErrInvalidItem):
case errors.Is(err, cartcontract.ErrInvalidItem):
ginutil.RespondError(c, response.CodeBadRequest, "error.order_item_invalid", nil)
case errors.Is(err, cart.ErrProductUnavailable):
case errors.Is(err, cartcontract.ErrProductUnavailable):
ginutil.RespondError(c, response.CodeBadRequest, "error.product_not_available", nil)
case errors.Is(err, cart.ErrFulfillmentInvalid):
case errors.Is(err, cartcontract.ErrFulfillmentInvalid):
ginutil.RespondError(c, response.CodeBadRequest, "error.fulfillment_invalid", nil)
case errors.Is(err, promotioncontract.ErrInvalid):
ginutil.RespondError(c, response.CodeBadRequest, "error.promotion_invalid", nil)
@@ -65,7 +66,7 @@ func (h *UserHandler) GetCart(c *gin.Context) {
return
}
respItems := make([]dto.CartItemResp, 0, len(items))
respItems := make([]cartpresenter.CartItemResp, 0, len(items))
for _, item := range items {
if item.Product == nil {
continue
@@ -78,7 +79,7 @@ func (h *UserHandler) GetCart(c *gin.Context) {
if cartFT == constants.FulfillmentTypeUpstream {
cartFT = constants.FulfillmentTypeManual
}
product := dto.CartProductResp{
product := cartpresenter.CartProductResp{
Slug: item.Product.Slug,
Title: item.Product.TitleJSON,
PriceAmount: item.Product.PriceAmount,
@@ -90,7 +91,7 @@ func (h *UserHandler) GetCart(c *gin.Context) {
FulfillmentType: productFT,
IsActive: item.Product.IsActive,
}
respItems = append(respItems, dto.CartItemResp{
respItems = append(respItems, cartpresenter.CartItemResp{
ProductID: item.ProductID,
SKUID: item.SKUID,
Quantity: item.Quantity,
@@ -124,7 +125,7 @@ func (h *UserHandler) UpsertCartItem(c *gin.Context) {
response.Success(c, gin.H{"updated": true})
return
}
if err := h.carts.UpsertItem(cart.UpsertItemInput{
if err := h.carts.UpsertItem(cartapp.UpsertItemInput{
UserID: uid,
ProductID: req.ProductID,
SKUID: req.SKUID,
@@ -155,7 +156,7 @@ func (h *UserHandler) DeleteCartItem(c *gin.Context) {
}
if err := h.carts.RemoveItem(uid, productID, skuID); err != nil {
switch {
case errors.Is(err, cart.ErrInvalidItem):
case errors.Is(err, cartcontract.ErrInvalidItem):
ginutil.RespondError(c, response.CodeBadRequest, "error.order_item_invalid", nil)
default:
ginutil.RespondError(c, response.CodeInternal, "error.order_update_failed", err)
@@ -167,19 +168,19 @@ func (h *UserHandler) DeleteCartItem(c *gin.Context) {
func respondCartItemUpdateError(c *gin.Context, err error) {
switch {
case errors.Is(err, cart.ErrSKURequired),
errors.Is(err, cart.ErrSKUInvalid),
errors.Is(err, cart.ErrInvalidItem):
case errors.Is(err, cartcontract.ErrSKURequired),
errors.Is(err, cartcontract.ErrSKUInvalid),
errors.Is(err, cartcontract.ErrInvalidItem):
ginutil.RespondError(c, response.CodeBadRequest, "error.order_item_invalid", nil)
case errors.Is(err, productdomain.ErrMaxPurchaseExceeded):
ginutil.RespondError(c, response.CodeBadRequest, "error.product_max_purchase_exceeded", nil)
case errors.Is(err, productdomain.ErrMinPurchaseNotMet):
ginutil.RespondError(c, response.CodeBadRequest, "error.product_min_purchase_not_met", nil)
case errors.Is(err, cart.ErrProductUnavailable):
case errors.Is(err, cartcontract.ErrProductUnavailable):
ginutil.RespondError(c, response.CodeBadRequest, "error.product_not_available", nil)
case errors.Is(err, cart.ErrManualStockInsufficient):
case errors.Is(err, cartcontract.ErrManualStockInsufficient):
ginutil.RespondError(c, response.CodeBadRequest, "error.manual_stock_insufficient", nil)
case errors.Is(err, cart.ErrFulfillmentInvalid):
case errors.Is(err, cartcontract.ErrFulfillmentInvalid):
ginutil.RespondError(c, response.CodeBadRequest, "error.fulfillment_invalid", nil)
default:
ginutil.RespondError(c, response.CodeInternal, "error.order_update_failed", err)
@@ -8,7 +8,7 @@ import (
"testing"
"github.com/dujiao-next/internal/platform/http/response"
"github.com/dujiao-next/internal/modules/cart"
cartcontract "github.com/dujiao-next/internal/modules/cart/contract"
"github.com/gin-gonic/gin"
)
@@ -24,13 +24,13 @@ func TestRespondCartItemUpdateError(t *testing.T) {
}{
{
name: "invalid fulfillment",
err: cart.ErrFulfillmentInvalid,
err: cartcontract.ErrFulfillmentInvalid,
code: response.CodeBadRequest,
msg: "交付信息不合法",
},
{
name: "manual stock insufficient",
err: cart.ErrManualStockInsufficient,
err: cartcontract.ErrManualStockInsufficient,
code: response.CodeBadRequest,
msg: "人工库存不足",
},
@@ -1,4 +1,4 @@
package dto
package presenter
import (
"github.com/dujiao-next/internal/shared/jsonmap"
@@ -18,7 +18,8 @@ import (
productgormstore "github.com/dujiao-next/internal/modules/catalog/product/store/gormstore"
"github.com/dujiao-next/internal/constants"
"github.com/dujiao-next/internal/models"
cartdomain "github.com/dujiao-next/internal/modules/cart/domain"
cartgormstore "github.com/dujiao-next/internal/modules/cart/infrastructure/gormstore"
categorygormstore "github.com/dujiao-next/internal/modules/catalog/category/infrastructure/gormstore"
mappinggormstore "github.com/dujiao-next/internal/modules/catalog/mapping/infrastructure/gormstore"
memberlevelgormstore "github.com/dujiao-next/internal/modules/memberlevel/infrastructure/gormstore"
@@ -105,7 +106,7 @@ func TestProductServiceDeleteCascade(t *testing.T) {
}
// 创建购物车项
cart := models.CartItem{
cart := cartdomain.Item{
UserID: 1,
ProductID: product.ID,
SKUID: sku.ID,
@@ -157,7 +158,7 @@ func TestProductServiceDeleteCascade(t *testing.T) {
}
var cartCount int64
db.Model(&models.CartItem{}).Where("product_id = ?", product.ID).Count(&cartCount)
db.Model(&cartdomain.Item{}).Where("product_id = ? AND deleted_at IS NULL", product.ID).Count(&cartCount)
if cartCount != 0 {
t.Errorf("expected 0 cart items after delete, got %d", cartCount)
}
@@ -203,7 +204,7 @@ func TestProductServiceDeleteRollsBackCascadeWhenProductDeleteFails(t *testing.T
CardSecretBatches: repository.NewCardSecretBatchRepository(db),
Categories: categorygormstore.NewCategoryStore(db),
MemberLevelPrices: memberlevelgormstore.NewPriceStore(db),
Carts: repository.NewCartRepository(db),
Carts: cartgormstore.New(db),
ProductMappings: mappinggormstore.NewMappingStore(db),
Orders: repository.NewOrderRepository(db),
PaymentChannels: repository.NewPaymentChannelRepository(db),
@@ -229,7 +230,7 @@ func TestProductServiceDeleteRollsBackCascadeWhenProductDeleteFails(t *testing.T
if err := db.Create(&sku).Error; err != nil {
t.Fatalf("create sku failed: %v", err)
}
cart := models.CartItem{
cart := cartdomain.Item{
UserID: 1,
ProductID: product.ID,
SKUID: sku.ID,
@@ -255,7 +256,7 @@ func TestProductServiceDeleteRollsBackCascadeWhenProductDeleteFails(t *testing.T
assertProductRelationCount(t, db, &productdomain.Product{}, "id = ?", product.ID, 1)
assertProductRelationCount(t, db, &productdomain.ProductSKU{}, "product_id = ?", product.ID, 1)
assertProductRelationCount(t, db, &models.CartItem{}, "product_id = ?", product.ID, 1)
assertProductRelationCount(t, db, &cartdomain.Item{}, "product_id = ? AND deleted_at IS NULL", product.ID, 1)
assertProductRelationCount(t, db, &mappingdomain.Mapping{}, "local_product_id = ?", product.ID, 1)
}
@@ -21,6 +21,8 @@ import (
"github.com/dujiao-next/internal/constants"
"github.com/dujiao-next/internal/models"
cartdomain "github.com/dujiao-next/internal/modules/cart/domain"
cartgormstore "github.com/dujiao-next/internal/modules/cart/infrastructure/gormstore"
categorygormstore "github.com/dujiao-next/internal/modules/catalog/category/infrastructure/gormstore"
mappinggormstore "github.com/dujiao-next/internal/modules/catalog/mapping/infrastructure/gormstore"
memberlevelgormstore "github.com/dujiao-next/internal/modules/memberlevel/infrastructure/gormstore"
@@ -49,7 +51,7 @@ func newProductServiceForResellerPublicTest(t *testing.T) (catalogproductbootstr
&models.CardSecret{},
&models.CardSecretBatch{},
&memberleveldomain.MemberLevelPrice{},
&models.CartItem{},
&cartdomain.Item{},
&mappingdomain.Mapping{},
&mappingdomain.SKUMapping{},
&models.Order{},
@@ -68,7 +70,7 @@ func newProductServiceForResellerPublicTest(t *testing.T) (catalogproductbootstr
CardSecretBatches: repository.NewCardSecretBatchRepository(db),
Categories: categorygormstore.NewCategoryStore(db),
MemberLevelPrices: memberlevelgormstore.NewPriceStore(db),
Carts: repository.NewCartRepository(db),
Carts: cartgormstore.New(db),
ProductMappings: mappinggormstore.NewMappingStore(db),
Orders: repository.NewOrderRepository(db),
PaymentChannels: repository.NewPaymentChannelRepository(db),
@@ -17,6 +17,8 @@ import (
catalogproductbootstrap "github.com/dujiao-next/internal/bootstrap/catalogproduct"
"github.com/dujiao-next/internal/models"
cartdomain "github.com/dujiao-next/internal/modules/cart/domain"
cartgormstore "github.com/dujiao-next/internal/modules/cart/infrastructure/gormstore"
categorygormstore "github.com/dujiao-next/internal/modules/catalog/category/infrastructure/gormstore"
mappinggormstore "github.com/dujiao-next/internal/modules/catalog/mapping/infrastructure/gormstore"
memberlevelgormstore "github.com/dujiao-next/internal/modules/memberlevel/infrastructure/gormstore"
@@ -63,7 +65,7 @@ func newProductServiceForTest(t *testing.T) (catalogproductbootstrap.Services, *
if err != nil {
t.Fatalf("open sqlite failed: %v", err)
}
if err := db.AutoMigrate(&categorydomain.Category{}, &productdomain.Product{}, &productdomain.ProductSKU{}, &models.CardSecret{}, &models.CardSecretBatch{}, &memberleveldomain.MemberLevelPrice{}, &models.CartItem{}, &mappingdomain.Mapping{}, &mappingdomain.SKUMapping{}, &models.Order{}, &models.OrderItem{}, &models.PaymentChannel{}); err != nil {
if err := db.AutoMigrate(&categorydomain.Category{}, &productdomain.Product{}, &productdomain.ProductSKU{}, &models.CardSecret{}, &models.CardSecretBatch{}, &memberleveldomain.MemberLevelPrice{}, &cartdomain.Item{}, &mappingdomain.Mapping{}, &mappingdomain.SKUMapping{}, &models.Order{}, &models.OrderItem{}, &models.PaymentChannel{}); err != nil {
t.Fatalf("auto migrate product service tables failed: %v", err)
}
@@ -74,7 +76,7 @@ func newProductServiceForTest(t *testing.T) (catalogproductbootstrap.Services, *
CardSecretBatches: repository.NewCardSecretBatchRepository(db),
Categories: categorygormstore.NewCategoryStore(db),
MemberLevelPrices: memberlevelgormstore.NewPriceStore(db),
Carts: repository.NewCartRepository(db),
Carts: cartgormstore.New(db),
ProductMappings: mappinggormstore.NewMappingStore(db),
Orders: repository.NewOrderRepository(db),
PaymentChannels: repository.NewPaymentChannelRepository(db),
@@ -21,6 +21,8 @@ import (
"github.com/dujiao-next/internal/constants"
"github.com/dujiao-next/internal/models"
cardsecretgormstore "github.com/dujiao-next/internal/modules/cardsecret/store/gormstore"
cartdomain "github.com/dujiao-next/internal/modules/cart/domain"
cartgormstore "github.com/dujiao-next/internal/modules/cart/infrastructure/gormstore"
categorygormstore "github.com/dujiao-next/internal/modules/catalog/category/infrastructure/gormstore"
mappinggormstore "github.com/dujiao-next/internal/modules/catalog/mapping/infrastructure/gormstore"
productapplication "github.com/dujiao-next/internal/modules/catalog/product/application"
@@ -68,20 +70,10 @@ type productAdminUoW struct {
cardSecrets *cardsecretgormstore.Store
cardSecretBatches *cardsecretgormstore.BatchStore
memberLevelPrices *memberlevelgormstore.PriceStore
carts *cartDeleteStore
carts *cartgormstore.Store
productMappings *mappinggormstore.MappingStore
}
type cartDeleteStore struct{ db *gorm.DB }
func (s *cartDeleteStore) DeleteByProduct(productID uint) error {
return s.db.Where("product_id = ?", productID).Delete(&models.CartItem{}).Error
}
func (s *cartDeleteStore) WithTx(tx *gorm.DB) *cartDeleteStore {
return &cartDeleteStore{db: tx}
}
func (unit *productAdminUoW) WithinTransaction(fn func(productadmin.DeleteRepositories) error) error {
if fn == nil {
return nil
@@ -134,7 +126,7 @@ func setupAdminProductHandlerTest(t *testing.T) (*producthttp.AdminProductHandle
&models.CardSecret{},
&models.CardSecretBatch{},
&memberleveldomain.MemberLevelPrice{},
&models.CartItem{},
&cartdomain.Item{},
&mappingdomain.Mapping{},
&mappingdomain.SKUMapping{},
&models.Order{},
@@ -152,7 +144,7 @@ func setupAdminProductHandlerTest(t *testing.T) (*producthttp.AdminProductHandle
memberLevelPriceStore := memberlevelgormstore.NewPriceStore(db)
mappingStore := mappinggormstore.NewMappingStore(db)
skuMappingStore := mappinggormstore.NewSKUMappingStore(db)
cartStore := &cartDeleteStore{db: db}
cartStore := cartgormstore.New(db)
facade := &productHandlerFacade{
Service: productapplication.NewService(productapplication.Options{
+4 -3
View File
@@ -13,7 +13,8 @@ import (
auditlogcontract "github.com/dujiao-next/internal/modules/auditlog/contract"
"github.com/dujiao-next/internal/modules/captcha"
"github.com/dujiao-next/internal/modules/cardsecret"
"github.com/dujiao-next/internal/modules/cart"
cartapp "github.com/dujiao-next/internal/modules/cart/application"
cartgormstore "github.com/dujiao-next/internal/modules/cart/infrastructure/gormstore"
categoryapp "github.com/dujiao-next/internal/modules/catalog/category/application"
categorycontract "github.com/dujiao-next/internal/modules/catalog/category/contract"
mappingapp "github.com/dujiao-next/internal/modules/catalog/mapping/application"
@@ -86,7 +87,7 @@ type Container struct {
FulfillmentRepo repository.FulfillmentRepository
ProductRepo *productgormstore.ProductStore
ProductSKURepo *productgormstore.SKUStore
CartRepo repository.CartRepository
CartRepo *cartgormstore.Store
CouponRepo *coupongormstore.Store
CouponUsageRepo *coupongormstore.UsageStore
PromotionRepo *promotiongormstore.Store
@@ -137,7 +138,7 @@ type Container struct {
CategoryService *categoryapp.Service
SettingService *settingsapp.Service
SitemapService *sitemap.Service
CartService *cart.Service
CartService *cartapp.Service
WalletService *service.WalletService
OrderRefundService *service.OrderRefundService
OrderService *service.OrderService
+2 -1
View File
@@ -5,6 +5,7 @@ import (
affiliategormstore "github.com/dujiao-next/internal/modules/affiliate/infrastructure/gormstore"
apicredentialgormstore "github.com/dujiao-next/internal/modules/apicredential/infrastructure/gormstore"
auditloggormstore "github.com/dujiao-next/internal/modules/auditlog/infrastructure/gormstore"
cartgormstore "github.com/dujiao-next/internal/modules/cart/infrastructure/gormstore"
categorygormstore "github.com/dujiao-next/internal/modules/catalog/category/infrastructure/gormstore"
mappinggormstore "github.com/dujiao-next/internal/modules/catalog/mapping/infrastructure/gormstore"
productgormstore "github.com/dujiao-next/internal/modules/catalog/product/store/gormstore"
@@ -42,7 +43,7 @@ func (c *Container) initRepositories() {
c.FulfillmentRepo = repository.NewFulfillmentRepository(db)
c.ProductRepo = productgormstore.NewProductStore(db)
c.ProductSKURepo = productgormstore.NewSKUStore(db)
c.CartRepo = repository.NewCartRepository(db)
c.CartRepo = cartgormstore.New(db)
c.CouponRepo = coupongormstore.New(db)
c.CouponUsageRepo = coupongormstore.NewUsageStore(db)
c.PromotionRepo = promotiongormstore.New(db)
+2 -2
View File
@@ -9,7 +9,7 @@ import (
"github.com/dujiao-next/internal/logger"
"github.com/dujiao-next/internal/models"
"github.com/dujiao-next/internal/modules/cardsecret"
"github.com/dujiao-next/internal/modules/cart"
cartapp "github.com/dujiao-next/internal/modules/cart/application"
contentapp "github.com/dujiao-next/internal/modules/content/application"
"github.com/dujiao-next/internal/modules/content/infrastructure/gormstore"
couponapp "github.com/dujiao-next/internal/modules/coupon/application"
@@ -61,7 +61,7 @@ func (c *Container) initApplicationServices() {
panic(err)
}
c.SitemapService = sitemapService
c.CartService = cart.NewService(c.CartRepo, c.ProductRepo, c.ProductSKURepo, c.PromotionRepo, c.SettingService)
c.CartService = cartapp.NewService(c.CartRepo, c.ProductRepo, c.ProductSKURepo, c.PromotionRepo, c.SettingService)
c.WalletService = service.NewWalletService(c.WalletRepo, c.OrderRepo, c.OrderRefundRecordRepo, c.UserStore, c.AffiliateRefundHandler, c.SettingService)
c.OrderRefundService = service.NewOrderRefundService(c.OrderRepo, c.UserStore, c.OrderRefundRecordRepo, c.AffiliateRefundHandler, c.SettingService)
c.MemberLevelService = memberlevelapp.NewService(c.MemberLevelRepo, c.MemberLevelPriceRepo, c.MemberLevelUserRepo)
-97
View File
@@ -1,97 +0,0 @@
package repository
import (
"errors"
"github.com/dujiao-next/internal/models"
"gorm.io/gorm"
)
// CartRepository 购物车数据访问接口
type CartRepository interface {
ListByUser(userID uint) ([]models.CartItem, error)
Upsert(item *models.CartItem) error
DeleteByProduct(productID uint) error
DeleteByUserAndProduct(userID, productID uint) error
DeleteByUserProductSKU(userID, productID, skuID uint) error
ClearByUser(userID uint) error
WithTx(tx *gorm.DB) *GormCartRepository
}
// GormCartRepository GORM 实现
type GormCartRepository struct {
db *gorm.DB
}
// NewCartRepository 创建购物车仓库
func NewCartRepository(db *gorm.DB) *GormCartRepository {
return &GormCartRepository{db: db}
}
// WithTx 绑定事务
func (r *GormCartRepository) WithTx(tx *gorm.DB) *GormCartRepository {
if tx == nil {
return r
}
return &GormCartRepository{db: tx}
}
// ListByUser 获取用户购物车项
func (r *GormCartRepository) ListByUser(userID uint) ([]models.CartItem, error) {
var items []models.CartItem
if err := r.db.
Preload("Product", "deleted_at IS NULL").
Preload("SKU", "deleted_at IS NULL").
Where("user_id = ?", userID).
Order("updated_at desc").
Find(&items).Error; err != nil {
return nil, err
}
return items, nil
}
// Upsert 添加或更新购物车项
func (r *GormCartRepository) Upsert(item *models.CartItem) error {
if item == nil {
return nil
}
var existing models.CartItem
err := r.db.Where("user_id = ? AND product_id = ? AND sku_id = ?", item.UserID, item.ProductID, item.SKUID).First(&existing).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return r.db.Create(item).Error
}
if err != nil {
return err
}
updates := map[string]interface{}{
"sku_id": item.SKUID,
"quantity": item.Quantity,
"fulfillment_type": item.FulfillmentType,
"updated_at": item.UpdatedAt,
}
return r.db.Model(&existing).Updates(updates).Error
}
// DeleteByProduct 删除指定商品的所有购物车项
func (r *GormCartRepository) DeleteByProduct(productID uint) error {
return r.db.Where("product_id = ?", productID).Delete(&models.CartItem{}).Error
}
// DeleteByUserAndProduct 删除购物车项
func (r *GormCartRepository) DeleteByUserAndProduct(userID, productID uint) error {
return r.db.Where("user_id = ? AND product_id = ?", userID, productID).Delete(&models.CartItem{}).Error
}
// DeleteByUserProductSKU 按用户+商品+SKU删除购物车项
func (r *GormCartRepository) DeleteByUserProductSKU(userID, productID, skuID uint) error {
if skuID == 0 {
return r.DeleteByUserAndProduct(userID, productID)
}
return r.db.Where("user_id = ? AND product_id = ? AND sku_id = ?", userID, productID, skuID).Delete(&models.CartItem{}).Error
}
// ClearByUser 清空购物车
func (r *GormCartRepository) ClearByUser(userID uint) error {
return r.db.Where("user_id = ?", userID).Delete(&models.CartItem{}).Error
}
@@ -1,90 +0,0 @@
package repository
import (
"fmt"
"testing"
"time"
"github.com/dujiao-next/internal/models"
"github.com/glebarez/sqlite"
"gorm.io/gorm"
)
func setupCartRepositoryTest(t *testing.T) (*GormCartRepository, *gorm.DB) {
t.Helper()
dsn := fmt.Sprintf("file:cart_repository_test_%d?mode=memory&cache=shared", time.Now().UnixNano())
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{
DisableForeignKeyConstraintWhenMigrating: true,
})
if err != nil {
t.Fatalf("open sqlite failed: %v", err)
}
if err := db.AutoMigrate(&models.CartItem{}); err != nil {
t.Fatalf("migrate cart item failed: %v", err)
}
return NewCartRepository(db), db
}
func TestCartRepositoryUpsertUsesProductAndSKUDimension(t *testing.T) {
repo, db := setupCartRepositoryTest(t)
now := time.Now()
first := &models.CartItem{
UserID: 10001,
ProductID: 888,
SKUID: 101,
Quantity: 1,
FulfillmentType: "manual",
CreatedAt: now,
UpdatedAt: now,
}
second := &models.CartItem{
UserID: 10001,
ProductID: 888,
SKUID: 102,
Quantity: 2,
FulfillmentType: "manual",
CreatedAt: now,
UpdatedAt: now,
}
if err := repo.Upsert(first); err != nil {
t.Fatalf("upsert first sku failed: %v", err)
}
if err := repo.Upsert(second); err != nil {
t.Fatalf("upsert second sku failed: %v", err)
}
var count int64
if err := db.Model(&models.CartItem{}).
Where("user_id = ? AND product_id = ?", first.UserID, first.ProductID).
Count(&count).Error; err != nil {
t.Fatalf("count cart items failed: %v", err)
}
if count != 2 {
t.Fatalf("cart rows want 2 got %d", count)
}
first.Quantity = 5
first.UpdatedAt = now.Add(time.Minute)
if err := repo.Upsert(first); err != nil {
t.Fatalf("update first sku failed: %v", err)
}
var gotFirst models.CartItem
if err := db.Where("user_id = ? AND product_id = ? AND sku_id = ?", first.UserID, first.ProductID, first.SKUID).First(&gotFirst).Error; err != nil {
t.Fatalf("query first sku row failed: %v", err)
}
if gotFirst.Quantity != 5 {
t.Fatalf("first sku quantity want 5 got %d", gotFirst.Quantity)
}
var gotSecond models.CartItem
if err := db.Where("user_id = ? AND product_id = ? AND sku_id = ?", second.UserID, second.ProductID, second.SKUID).First(&gotSecond).Error; err != nil {
t.Fatalf("query second sku row failed: %v", err)
}
if gotSecond.Quantity != 2 {
t.Fatalf("second sku quantity should keep 2 got %d", gotSecond.Quantity)
}
}
+2 -2
View File
@@ -16,6 +16,7 @@ import (
apicredentialtransport "github.com/dujiao-next/internal/modules/apicredential/transport/http"
auditlogtransport "github.com/dujiao-next/internal/modules/auditlog/transport/http"
captchahttp "github.com/dujiao-next/internal/modules/captcha/transport/http"
carttransport "github.com/dujiao-next/internal/modules/cart/transport/http"
categoryhttp "github.com/dujiao-next/internal/modules/catalog/category/transport/http"
mappinghttp "github.com/dujiao-next/internal/modules/catalog/mapping/transport/http"
producthttp "github.com/dujiao-next/internal/modules/catalog/product/transport/http"
@@ -35,7 +36,6 @@ import (
adminauthwiring "github.com/dujiao-next/internal/wiring/adminauth"
adminauthzwiring "github.com/dujiao-next/internal/wiring/adminauthz"
adminuserwiring "github.com/dujiao-next/internal/wiring/adminuser"
cartwiring "github.com/dujiao-next/internal/wiring/cart"
channelwiring "github.com/dujiao-next/internal/wiring/channel"
channeluserwiring "github.com/dujiao-next/internal/wiring/channeluser"
fulfillmentwiring "github.com/dujiao-next/internal/wiring/fulfillment"
@@ -84,7 +84,7 @@ func SetupRouter(cfg *config.Config, c *provider.Container) *gin.Engine {
paymentWebhookHandler := paymentHandlers.Webhook
paymentCallbackHandler := paymentHandlers.Callback
publicConfigHandler := publicconfigwiring.NewHandler(c)
userCartHandler := cartwiring.NewUserHandler(c)
userCartHandler := carttransport.NewUserHandler(c.CartService)
channelHandler := channelwiring.NewHandler(c)
upstreamHandler := upstreamwiring.NewHandler(c)
publicContentHandler := contenttransport.NewPublicHandler(
+1 -1
View File
@@ -8,13 +8,13 @@ import (
apicredentialtransport "github.com/dujiao-next/internal/modules/apicredential/transport/http"
auditlogtransport "github.com/dujiao-next/internal/modules/auditlog/transport/http"
captchatransport "github.com/dujiao-next/internal/modules/captcha/transport/http"
carttransport "github.com/dujiao-next/internal/modules/cart/transport/http"
categoryhttp "github.com/dujiao-next/internal/modules/catalog/category/transport/http"
producthttp "github.com/dujiao-next/internal/modules/catalog/product/transport/http"
contenttransport "github.com/dujiao-next/internal/modules/content/transport/http"
giftcardtransport "github.com/dujiao-next/internal/modules/giftcard/transport/http"
memberleveltransport "github.com/dujiao-next/internal/modules/memberlevel/transport/http"
"github.com/dujiao-next/internal/provider"
carttransport "github.com/dujiao-next/internal/transport/http/cart"
ordertransport "github.com/dujiao-next/internal/transport/http/order"
paymenttransport "github.com/dujiao-next/internal/transport/http/payment"
paymentcallbacktransport "github.com/dujiao-next/internal/transport/http/payment/callback"
-10
View File
@@ -1,10 +0,0 @@
package cartwiring
import (
"github.com/dujiao-next/internal/provider"
carttransport "github.com/dujiao-next/internal/transport/http/cart"
)
func NewUserHandler(c *provider.Container) *carttransport.UserHandler {
return carttransport.NewUserHandler(c.CartService)
}