refactor(core): remove structural compatibility aliases

This commit is contained in:
dujiao-next committed 2026-07-26 13:18:43 +08:00
1 parent 0472220a68
commit 3da93ba7ed
35 files changed
+186 -307

No files matched your search

@@ -170,7 +170,7 @@ func TestCatalogProductImplementationLivesInNestedBoundedContext(t *testing.T) {
}
assertFileDeclaresTypes(t, filepath.Join(applicationRoot, "query.go"), []string{
"ProductRepository", "CategoryRepository", "HiddenProductRepository", "StockCounter",
"TenantContext", "Options", "Service",
"Options", "Service",
})
assertFileDeclaresFunctions(t, filepath.Join(applicationRoot, "query.go"), []string{
"NewService", "ListPublic", "ListPublicForTenant", "ListForUpstreamSync", "ListPublicExact",
@@ -183,7 +183,7 @@ func TestCatalogProductImplementationLivesInNestedBoundedContext(t *testing.T) {
"ProductRepository", "CategoryRepository", "CardSecretStockRepository", "OrderHistoryRepository",
"ProductDeleteRepository", "CardSecretDeleteRepository", "CardSecretBatchDeleteRepository",
"SKUDeleteRepository", "MemberLevelPriceDeleteRepository", "CartDeleteRepository",
"ProductMappingDeleteRepository", "DeleteRepositories", "UnitOfWork", "ErrorSet", "Options", "AdminService",
"ProductMappingDeleteRepository", "DeleteRepositories", "UnitOfWork", "Options", "AdminService",
})
assertFileDeclaresFunctions(t, filepath.Join(adminRoot, "service.go"), []string{"NewAdminService"})
assertFileDeclaresFunctions(t, filepath.Join(adminRoot, "operations.go"), []string{
@@ -198,8 +198,8 @@ func TestCatalogProductImplementationLivesInNestedBoundedContext(t *testing.T) {
})
assertFileDeclaresTypes(t, filepath.Join(writeRoot, "service.go"), []string{
"ProductRepository", "SKURepository", "CategoryRepository", "PaymentChannelStoresitory",
"CardSecretStockRepository", "TransactionRepositories", "UnitOfWork", "ErrorSet", "Options",
"WriteService", "CreateProductInput", "WholesalePriceInput", "ProductSKUInput",
"CardSecretStockRepository", "TransactionRepositories", "UnitOfWork", "Options",
"WriteService", "CreateProductInput", "ProductSKUInput",
})
assertFileDeclaresFunctions(t, filepath.Join(writeRoot, "service.go"), []string{"NewWriteService"})
assertFileDeclaresFunctions(t, filepath.Join(writeRoot, "create.go"), []string{"Create"})
@@ -1,6 +1,7 @@
package architecture
import (
"go/ast"
"io/fs"
"os"
"path/filepath"
@@ -16,7 +17,7 @@ type packageFileBudget struct {
// Test-only architecture assertions intentionally share one package so they can
// reuse AST helpers. Production packages have no file-budget exceptions.
var packageFileBudgetOverrides = map[string]packageFileBudget{
"internal/architecture": {production: 0, total: 55},
"internal/architecture": {production: 0, total: 51},
}
// completedMigrationPaths are deleted compatibility-free entry points. Once a
@@ -630,6 +631,41 @@ func TestNoNewCompatibilityOrLegacyProductionFiles(t *testing.T) {
}
}
func TestProductionCodeContainsNoTypeAliases(t *testing.T) {
repositoryRoot := findRepositoryRoot(t)
internalRoot := filepath.Join(repositoryRoot, "internal")
err := filepath.WalkDir(internalRoot, func(path string, entry fs.DirEntry, walkErr error) error {
if walkErr != nil {
return walkErr
}
if entry.IsDir() || filepath.Ext(path) != ".go" || strings.HasSuffix(path, "_test.go") {
return nil
}
parsed := parseProductionGoFile(t, path)
ast.Inspect(parsed, func(node ast.Node) bool {
typeSpec, ok := node.(*ast.TypeSpec)
if ok && typeSpec.Assign.IsValid() {
relativePath, err := filepath.Rel(repositoryRoot, path)
if err != nil {
t.Fatalf("resolve type alias path: %v", err)
}
t.Errorf(
"production type alias %s is forbidden; depend on its owning package or declare an owned type: %s",
typeSpec.Name.Name,
filepath.ToSlash(relativePath),
)
}
return true
})
return nil
})
if err != nil {
t.Fatalf("inspect production Go aliases: %v", err)
}
}
func TestGoPackagesStayWithinFileBudgets(t *testing.T) {
repositoryRoot := findRepositoryRoot(t)
internalRoot := filepath.Join(repositoryRoot, "internal")
+3 -24
View File
@@ -192,11 +192,9 @@ func (adapter memberLevelPriceDeleteAdapter) DeleteByProduct(productID uint) err
// New 显式装配 Product 查询、管理和写入用例。
func New(dependencies Dependencies) Services {
read := productapplication.NewService(productapplication.Options{
Products: dependencies.Products,
Categories: dependencies.Categories,
Stock: dependencies.CardSecrets,
NotFoundError: productcontract.ErrNotFound,
ResellerProductNotListedError: productcontract.ErrResellerProductNotListed,
Products: dependencies.Products,
Categories: dependencies.Categories,
Stock: dependencies.CardSecrets,
})
admin := productadmin.NewAdminService(productadmin.Options{
Products: dependencies.Products,
@@ -204,12 +202,6 @@ func New(dependencies Dependencies) Services {
CardSecrets: dependencies.CardSecrets,
Orders: dependencies.Orders,
Transactions: newProductAdminUnitOfWork(dependencies.Products, dependencies.SKUs, dependencies.CardSecrets, dependencies.CardSecretBatches, dependencies.MemberLevelPrices, dependencies.Carts, dependencies.ProductMappings),
Errors: productadmin.ErrorSet{
NotFound: productcontract.ErrNotFound,
ProductCategoryInvalid: productcontract.ErrProductCategoryInvalid,
ProductHasStock: productcontract.ErrProductHasStock,
ProductHasOrderRecord: productcontract.ErrProductHasOrderRecord,
},
})
write := productwrite.NewWriteService(productwrite.Options{
Products: dependencies.Products,
@@ -217,19 +209,6 @@ func New(dependencies Dependencies) Services {
Categories: dependencies.Categories,
PaymentChannels: dependencies.PaymentChannels,
Transactions: newProductWriteUnitOfWork(dependencies.Products, dependencies.SKUs, dependencies.CardSecrets),
Errors: productwrite.ErrorSet{
NotFound: productcontract.ErrNotFound,
SlugExists: productcontract.ErrSlugExists,
ProductCategoryInvalid: productcontract.ErrProductCategoryInvalid,
ProductPurchaseInvalid: productcontract.ErrProductPurchaseInvalid,
FulfillmentInvalid: productcontract.ErrFulfillmentInvalid,
ProductPriceInvalid: productcontract.ErrProductPriceInvalid,
ManualStockInvalid: productcontract.ErrManualStockInvalid,
ProductPurchaseLimitInvalid: productcontract.ErrProductPurchaseLimitInvalid,
ProductStockDisplayInvalid: productcontract.ErrProductStockDisplayInvalid,
ProductSKUInvalid: productcontract.ErrProductSKUInvalid,
ProductSKUHasCardSecretStock: productcontract.ErrProductSKUHasCardSecretStock,
},
})
return Services{Read: read, Admin: admin, Write: write}
}
+1 -1
View File
@@ -17,7 +17,7 @@ type Handlers struct {
Callback *paymentcallbacktransport.Handler
}
// New assembles payment transports without exposing legacy adapters to router.
// New assembles payment transports from application services and HTTP handlers.
func New(c *container.Container) Handlers {
guestOrders := guestOrderLookupAdapter{orders: c.OrderService}
userOrders := userOrderLookupAdapter{orders: c.OrderService}
+3 -2
View File
@@ -10,6 +10,7 @@ import (
"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"
)
// Service 购物车服务。
@@ -17,12 +18,12 @@ type Service struct {
cartRepo contract.Repository
productRepo contract.ProductReader
productSKURepo contract.SKUReader
promotionRepo contract.PromotionRepository
promotionRepo promotioncontract.Repository
currencyReader contract.CurrencyReader
}
// NewService 创建购物车服务。
func NewService(cartRepo contract.Repository, productRepo contract.ProductReader, productSKURepo contract.SKUReader, promotionRepo contract.PromotionRepository, currencyReader contract.CurrencyReader) *Service {
func NewService(cartRepo contract.Repository, productRepo contract.ProductReader, productSKURepo contract.SKUReader, promotionRepo promotioncontract.Repository, currencyReader contract.CurrencyReader) *Service {
return &Service{
cartRepo: cartRepo,
productRepo: productRepo,
-4
View File
@@ -3,7 +3,6 @@ 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 是购物车持久化端口返回的条目及其商品快照。
@@ -35,6 +34,3 @@ type SKUReader interface {
type CurrencyReader interface {
GetSiteCurrency(defaultCurrency string) (string, error)
}
// PromotionRepository 是购物车价格计算所需的活动价端口。
type PromotionRepository = promotioncontract.Repository
@@ -3,6 +3,7 @@ package productadmin
import (
"strconv"
productcontract "github.com/dujiao-next/internal/modules/catalog/product/contract"
productdomain "github.com/dujiao-next/internal/modules/catalog/product/domain"
)
@@ -13,7 +14,7 @@ func (s *AdminService) Delete(id string) error {
return err
}
if product == nil {
return s.errors.NotFound
return productcontract.ErrNotFound
}
// 事务前校验,避免 SQLite 在读后写场景中发生自锁。
@@ -22,14 +23,14 @@ func (s *AdminService) Delete(id string) error {
return err
}
if available > 0 {
return s.errors.ProductHasStock
return productcontract.ErrProductHasStock
}
reserved, err := s.cardSecrets.CountReserved(product.ID, 0)
if err != nil {
return err
}
if reserved > 0 {
return s.errors.ProductHasStock
return productcontract.ErrProductHasStock
}
orderCount, err := s.orders.CountOrderItemsByProduct(product.ID)
@@ -37,7 +38,7 @@ func (s *AdminService) Delete(id string) error {
return err
}
if orderCount > 0 {
return s.errors.ProductHasOrderRecord
return productcontract.ErrProductHasOrderRecord
}
return s.transactions.WithinTransaction(func(repositories DeleteRepositories) error {
@@ -70,18 +71,18 @@ func (s *AdminService) QuickUpdate(id string, fields map[string]interface{}) (*p
return nil, err
}
if product == nil {
return nil, s.errors.NotFound
return nil, productcontract.ErrNotFound
}
if isActivatingProduct(fields) {
categoryID := product.CategoryID
if rawCategoryID, ok := fields["category_id"]; ok {
parsedCategoryID, parseErr := categoryIDFromValue(rawCategoryID, s.errors.ProductCategoryInvalid)
parsedCategoryID, parseErr := categoryIDFromValue(rawCategoryID, productcontract.ErrProductCategoryInvalid)
if parseErr != nil {
return nil, parseErr
}
categoryID = parsedCategoryID
}
if err := validateActivationCategory(s.categories, categoryID, s.errors.ProductCategoryInvalid); err != nil {
if err := validateActivationCategory(s.categories, categoryID, productcontract.ErrProductCategoryInvalid); err != nil {
return nil, err
}
}
@@ -98,7 +99,7 @@ func (s *AdminService) UpdateWholesalePrices(id string, inputs []productdomain.W
return nil, err
}
if product == nil {
return nil, s.errors.NotFound
return nil, productcontract.ErrNotFound
}
wholesalePrices, err := productdomain.NormalizeWholesalePricesForSKUs(inputs, product.SKUs)
@@ -6,24 +6,21 @@ import (
categorydomain "github.com/dujiao-next/internal/modules/catalog/category/domain"
productcontract "github.com/dujiao-next/internal/modules/catalog/product/contract"
productdomain "github.com/dujiao-next/internal/modules/catalog/product/domain"
"github.com/shopspring/decimal"
)
func TestAdminServiceDeleteStopsBeforeTransactionWhenStockExists(t *testing.T) {
hasStock := errors.New("product has stock")
transactions := &unitOfWorkStub{}
service := NewAdminService(Options{
Products: &productRepositoryStub{product: &productdomain.Product{ID: 7}},
CardSecrets: stockRepositoryStub{available: 1},
Orders: orderRepositoryStub{},
Transactions: transactions,
Errors: ErrorSet{
ProductHasStock: hasStock,
},
})
if err := service.Delete("7"); !errors.Is(err, hasStock) {
if err := service.Delete("7"); !errors.Is(err, productcontract.ErrProductHasStock) {
t.Fatalf("expected product stock error, got %v", err)
}
if transactions.called {
@@ -64,14 +61,10 @@ func TestAdminServiceDeleteUsesAllCascadePorts(t *testing.T) {
}
func TestAdminServiceQuickUpdateValidatesActivationCategory(t *testing.T) {
invalidCategory := errors.New("invalid product category")
products := &productRepositoryStub{product: &productdomain.Product{ID: 11, CategoryID: 3}}
service := NewAdminService(Options{
Products: products,
Categories: categoryRepositoryStub{categories: map[string]*categorydomain.Category{"5": {ID: 5, IsActive: true}}},
Errors: ErrorSet{
ProductCategoryInvalid: invalidCategory,
},
})
updated, err := service.QuickUpdate("11", map[string]interface{}{
@@ -89,7 +82,7 @@ func TestAdminServiceQuickUpdateValidatesActivationCategory(t *testing.T) {
"category_id": 5.5,
"is_active": true,
})
if !errors.Is(err, invalidCategory) {
if !errors.Is(err, productcontract.ErrProductCategoryInvalid) {
t.Fatalf("expected invalid category error, got %v", err)
}
if products.quickUpdates != 1 {
@@ -1,10 +1,6 @@
package productadmin
import (
"errors"
productdomain "github.com/dujiao-next/internal/modules/catalog/product/domain"
)
import productdomain "github.com/dujiao-next/internal/modules/catalog/product/domain"
// ProductRepository 是商品管理用例所需的最小商品端口。
type ProductRepository interface {
@@ -73,21 +69,12 @@ type UnitOfWork interface {
WithinTransaction(fn func(DeleteRepositories) error) error
}
// ErrorSet 保留旧服务层公开错误的 errors.Is 身份。
type ErrorSet struct {
NotFound error
ProductCategoryInvalid error
ProductHasStock error
ProductHasOrderRecord error
}
type Options struct {
Products ProductRepository
Categories CategoryRepository
CardSecrets CardSecretStockRepository
Orders OrderHistoryRepository
Transactions UnitOfWork
Errors ErrorSet
}
// AdminService 承载商品删除、快速更新和批发价管理用例。
@@ -97,7 +84,6 @@ type AdminService struct {
cardSecrets CardSecretStockRepository
orders OrderHistoryRepository
transactions UnitOfWork
errors ErrorSet
}
func NewAdminService(options Options) *AdminService {
@@ -107,22 +93,5 @@ func NewAdminService(options Options) *AdminService {
cardSecrets: options.CardSecrets,
orders: options.Orders,
transactions: options.Transactions,
errors: resolveErrorSet(options.Errors),
}
}
func resolveErrorSet(values ErrorSet) ErrorSet {
return ErrorSet{
NotFound: errorOrDefault(values.NotFound, "not found"),
ProductCategoryInvalid: errorOrDefault(values.ProductCategoryInvalid, "product category invalid"),
ProductHasStock: errorOrDefault(values.ProductHasStock, "product has stock"),
ProductHasOrderRecord: errorOrDefault(values.ProductHasOrderRecord, "product has order record"),
}
}
func errorOrDefault(value error, message string) error {
if value != nil {
return value
}
return errors.New(message)
}
@@ -13,6 +13,7 @@ import (
cardsecretcontract "github.com/dujiao-next/internal/modules/cardsecret/contract"
cardsecretdomain "github.com/dujiao-next/internal/modules/cardsecret/domain"
productcontract "github.com/dujiao-next/internal/modules/catalog/product/contract"
resellercontract "github.com/dujiao-next/internal/modules/reseller/contract"
)
type productRepositoryStub struct {
@@ -81,7 +82,7 @@ func TestListPublicForTenantBuildsVisibilityFilterBeforePagination(t *testing.T)
resellerID := uint(9)
_, total, err := service.ListPublicForTenant(
TenantContext{ResellerID: &resellerID},
resellercontract.TenantContext{ResellerID: &resellerID},
hiddenProductRepositoryStub{ids: []uint{7, 8}},
"10",
"keyword",
@@ -105,21 +106,17 @@ func TestListPublicForTenantBuildsVisibilityFilterBeforePagination(t *testing.T)
}
}
func TestQueryServicePreservesInjectedCompatibilityErrors(t *testing.T) {
notFound := errors.New("legacy not found")
notListed := errors.New("legacy reseller product not listed")
func TestQueryServiceUsesProductContractErrors(t *testing.T) {
service := NewService(Options{
Products: &productRepositoryStub{},
NotFoundError: notFound,
ResellerProductNotListedError: notListed,
Products: &productRepositoryStub{},
})
if _, err := service.GetPublicBySlug("missing"); !errors.Is(err, notFound) {
t.Fatalf("GetPublicBySlug want injected not-found error, got %v", err)
if _, err := service.GetPublicBySlug("missing"); !errors.Is(err, productcontract.ErrNotFound) {
t.Fatalf("GetPublicBySlug want contract not-found error, got %v", err)
}
resellerID := uint(9)
if _, _, err := service.ListPublicForTenant(TenantContext{ResellerID: &resellerID}, nil, "", "", 1, 20); !errors.Is(err, notListed) {
t.Fatalf("ListPublicForTenant want injected not-listed error, got %v", err)
if _, _, err := service.ListPublicForTenant(resellercontract.TenantContext{ResellerID: &resellerID}, nil, "", "", 1, 20); !errors.Is(err, productcontract.ErrResellerProductNotListed) {
t.Fatalf("ListPublicForTenant want contract not-listed error, got %v", err)
}
}
@@ -14,11 +14,6 @@ import (
reseller "github.com/dujiao-next/internal/modules/reseller/contract"
)
var (
ErrNotFound = productcontract.ErrNotFound
ErrResellerProductNotListed = productcontract.ErrResellerProductNotListed
)
// ProductRepository 是读取应用服务所需的最小商品持久化端口。
type ProductRepository interface {
List(filter productcontract.ListFilter) ([]productdomain.Product, int64, error)
@@ -42,43 +37,26 @@ type StockCounter interface {
CountStockByProductIDs(productIDs []uint) ([]cardsecretcontract.SKUStockCount, error)
}
// TenantContext 商品读取用例关心的租户上下文;值对象归属 modules/reseller。
type TenantContext = reseller.TenantContext
// Options 描述 Product 读取应用服务的端口和兼容错误。
// Options 描述 Product 读取应用服务的端口和领域错误。
type Options struct {
Products ProductRepository
Categories CategoryRepository
Stock StockCounter
NotFoundError error
ResellerProductNotListedError error
Products ProductRepository
Categories CategoryRepository
Stock StockCounter
}
// Service 编排商品查询、租户可见性和库存聚合。
type Service struct {
products ProductRepository
categories CategoryRepository
stock StockCounter
notFoundError error
resellerProductNotListedError error
products ProductRepository
categories CategoryRepository
stock StockCounter
}
// NewService 创建 Product 读取应用服务。
func NewService(options Options) *Service {
notFoundError := options.NotFoundError
if notFoundError == nil {
notFoundError = ErrNotFound
}
resellerProductNotListedError := options.ResellerProductNotListedError
if resellerProductNotListedError == nil {
resellerProductNotListedError = ErrResellerProductNotListed
}
return &Service{
products: options.Products,
categories: options.Categories,
stock: options.Stock,
notFoundError: notFoundError,
resellerProductNotListedError: resellerProductNotListedError,
products: options.Products,
categories: options.Categories,
stock: options.Stock,
}
}
@@ -102,12 +80,12 @@ func (s *Service) ListPublic(categoryID, search string, page, pageSize int) ([]p
}
// ListPublicForTenant 获取当前租户上下文的公开商品列表。
func (s *Service) ListPublicForTenant(tenant TenantContext, resellerRepo HiddenProductRepository, categoryID, search string, page, pageSize int) ([]productdomain.Product, int64, error) {
func (s *Service) ListPublicForTenant(tenant reseller.TenantContext, resellerRepo HiddenProductRepository, categoryID, search string, page, pageSize int) ([]productdomain.Product, int64, error) {
if !tenant.IsReseller() {
return s.ListPublic(categoryID, search, page, pageSize)
}
if tenant.ResellerID == nil || resellerRepo == nil {
return nil, 0, s.resellerProductNotListedError
return nil, 0, productcontract.ErrResellerProductNotListed
}
categoryIDs, err := expandPublicCategoryIDs(s.categories, categoryID)
if err != nil {
@@ -162,13 +140,13 @@ func (s *Service) GetPublicBySlug(slug string) (*productdomain.Product, error) {
return nil, err
}
if item == nil {
return nil, s.notFoundError
return nil, productcontract.ErrNotFound
}
return item, nil
}
// GetPublicBySlugForTenant 获取当前租户上下文的公开商品详情。
func (s *Service) GetPublicBySlugForTenant(tenant TenantContext, resellerRepo HiddenProductRepository, slug string) (*productdomain.Product, error) {
func (s *Service) GetPublicBySlugForTenant(tenant reseller.TenantContext, resellerRepo HiddenProductRepository, slug string) (*productdomain.Product, error) {
item, err := s.GetPublicBySlug(slug)
if err != nil {
return nil, err
@@ -177,7 +155,7 @@ func (s *Service) GetPublicBySlugForTenant(tenant TenantContext, resellerRepo Hi
return item, nil
}
if tenant.ResellerID == nil || resellerRepo == nil {
return nil, s.notFoundError
return nil, productcontract.ErrNotFound
}
hiddenIDs, err := resellerRepo.ListHiddenProductIDs(*tenant.ResellerID)
if err != nil {
@@ -185,7 +163,7 @@ func (s *Service) GetPublicBySlugForTenant(tenant TenantContext, resellerRepo Hi
}
for _, id := range hiddenIDs {
if id == item.ID {
return nil, s.notFoundError
return nil, productcontract.ErrNotFound
}
}
return item, nil
@@ -215,7 +193,7 @@ func (s *Service) GetAdminByID(id string) (*productdomain.Product, error) {
return nil, err
}
if item == nil {
return nil, s.notFoundError
return nil, productcontract.ErrNotFound
}
return item, nil
}
@@ -4,6 +4,7 @@ import (
"strconv"
"github.com/dujiao-next/internal/constants"
productcontract "github.com/dujiao-next/internal/modules/catalog/product/contract"
productdomain "github.com/dujiao-next/internal/modules/catalog/product/domain"
"github.com/dujiao-next/internal/modules/catalog/product/manualform"
"github.com/dujiao-next/internal/shared/jsonmap"
@@ -15,7 +16,7 @@ import (
// Create 创建商品
func (s *WriteService) Create(input CreateProductInput) (*productdomain.Product, error) {
if err := productdomain.ValidateCategoryAssignment(s.categories, input.CategoryID, 0, s.errors.ProductCategoryInvalid); err != nil {
if err := productdomain.ValidateCategoryAssignment(s.categories, input.CategoryID, 0, productcontract.ErrProductCategoryInvalid); err != nil {
return nil, err
}
@@ -24,7 +25,7 @@ func (s *WriteService) Create(input CreateProductInput) (*productdomain.Product,
return nil, err
}
if count > 0 {
return nil, s.errors.SlugExists
return nil, productcontract.ErrSlugExists
}
isActive := true
@@ -37,16 +38,16 @@ func (s *WriteService) Create(input CreateProductInput) (*productdomain.Product,
}
purchaseType := productdomain.NormalizePurchaseType(input.PurchaseType)
if purchaseType == "" {
return nil, s.errors.ProductPurchaseInvalid
return nil, productcontract.ErrProductPurchaseInvalid
}
fulfillmentType := productdomain.NormalizeFulfillmentType(input.FulfillmentType)
if fulfillmentType == "" {
return nil, s.errors.FulfillmentInvalid
return nil, productcontract.ErrFulfillmentInvalid
}
priceAmount := input.PriceAmount.Round(2)
if len(input.SKUs) == 0 && priceAmount.LessThanOrEqual(decimal.Zero) {
return nil, s.errors.ProductPriceInvalid
return nil, productcontract.ErrProductPriceInvalid
}
manualStockTotal := 0
@@ -54,7 +55,7 @@ func (s *WriteService) Create(input CreateProductInput) (*productdomain.Product,
manualStockTotal = *input.ManualStockTotal
}
if manualStockTotal < constants.ManualStockUnlimited {
return nil, s.errors.ManualStockInvalid
return nil, productcontract.ErrManualStockInvalid
}
maxPurchaseQuantity := 0
if input.MaxPurchaseQuantity != nil {
@@ -65,15 +66,15 @@ func (s *WriteService) Create(input CreateProductInput) (*productdomain.Product,
minPurchaseQuantity = productdomain.NormalizePurchaseQuantityLimit(*input.MinPurchaseQuantity)
}
if minPurchaseQuantity > 0 && maxPurchaseQuantity > 0 && minPurchaseQuantity > maxPurchaseQuantity {
return nil, s.errors.ProductPurchaseLimitInvalid
return nil, productcontract.ErrProductPurchaseLimitInvalid
}
stockDisplayMode := productdomain.NormalizeStockDisplayMode(input.StockDisplayMode)
if stockDisplayMode == "" {
return nil, s.errors.ProductStockDisplayInvalid
return nil, productcontract.ErrProductStockDisplayInvalid
}
costPriceAmount := input.CostPriceAmount.Round(2)
var wholesaleInputs []WholesalePriceInput
var wholesaleInputs []productdomain.WholesalePriceInput
if input.WholesalePrices != nil {
wholesaleInputs = *input.WholesalePrices
}
@@ -81,7 +82,7 @@ func (s *WriteService) Create(input CreateProductInput) (*productdomain.Product,
var normalizedSKUs []normalizedProductSKU
if len(input.SKUs) > 0 {
if s.skus == nil {
return nil, s.errors.ProductSKUInvalid
return nil, productcontract.ErrProductSKUInvalid
}
var normalizeErr error
normalizedSKUs, priceAmount, manualStockTotal, normalizeErr = s.normalizeProductSKUInputs(input.SKUs, fulfillmentType, nil)
@@ -1,8 +1,6 @@
package productwrite
import (
"errors"
paymentdomain "github.com/dujiao-next/internal/modules/payment/domain"
productdomain "github.com/dujiao-next/internal/modules/catalog/product/domain"
@@ -55,21 +53,6 @@ type UnitOfWork interface {
WithinTransaction(fn func(repositories TransactionRepositories) error) error
}
// ErrorSet 保留旧 Service 错误哨兵身份,保证 errors.Is 与 HTTP 映射兼容。
type ErrorSet struct {
NotFound error
SlugExists error
ProductCategoryInvalid error
ProductPurchaseInvalid error
FulfillmentInvalid error
ProductPriceInvalid error
ManualStockInvalid error
ProductPurchaseLimitInvalid error
ProductStockDisplayInvalid error
ProductSKUInvalid error
ProductSKUHasCardSecretStock error
}
// Options 描述商品写入应用服务依赖。
type Options struct {
Products ProductRepository
@@ -77,7 +60,6 @@ type Options struct {
Categories CategoryRepository
PaymentChannels PaymentChannelStoresitory
Transactions UnitOfWork
Errors ErrorSet
}
// WriteService 编排商品创建、更新和 SKU 同步。
@@ -87,7 +69,6 @@ type WriteService struct {
categories CategoryRepository
paymentChannels PaymentChannelStoresitory
transactions UnitOfWork
errors ErrorSet
}
// NewWriteService 创建商品写入应用服务。
@@ -98,7 +79,6 @@ func NewWriteService(options Options) *WriteService {
categories: options.Categories,
paymentChannels: options.PaymentChannels,
transactions: options.Transactions,
errors: resolveErrorSet(options.Errors),
}
}
@@ -115,7 +95,7 @@ type CreateProductInput struct {
PriceAmount decimal.Decimal
CostPriceAmount decimal.Decimal
// WholesalePrices 为可选字段:nil 表示更新时保留,非 nil 表示整体覆盖。
WholesalePrices *[]WholesalePriceInput
WholesalePrices *[]productdomain.WholesalePriceInput
Images []string
Tags []string
PurchaseType string
@@ -131,8 +111,6 @@ type CreateProductInput struct {
SortOrder int
}
type WholesalePriceInput = productdomain.WholesalePriceInput
// ProductSKUInput 描述商品 SKU 的完整写入值。
type ProductSKUInput struct {
ID uint
@@ -145,32 +123,6 @@ type ProductSKUInput struct {
SortOrder int
}
func resolveErrorSet(values ErrorSet) ErrorSet {
return ErrorSet{
NotFound: errorOrDefault(values.NotFound, "not found"),
SlugExists: errorOrDefault(values.SlugExists, "slug exists"),
ProductCategoryInvalid: errorOrDefault(values.ProductCategoryInvalid, "product category invalid"),
ProductPurchaseInvalid: errorOrDefault(values.ProductPurchaseInvalid, "product purchase invalid"),
FulfillmentInvalid: errorOrDefault(values.FulfillmentInvalid, "fulfillment invalid"),
ProductPriceInvalid: errorOrDefault(values.ProductPriceInvalid, "product price invalid"),
ManualStockInvalid: errorOrDefault(values.ManualStockInvalid, "manual stock invalid"),
ProductPurchaseLimitInvalid: errorOrDefault(values.ProductPurchaseLimitInvalid, "product purchase limit invalid"),
ProductStockDisplayInvalid: errorOrDefault(values.ProductStockDisplayInvalid, "product stock display invalid"),
ProductSKUInvalid: errorOrDefault(values.ProductSKUInvalid, "product sku invalid"),
ProductSKUHasCardSecretStock: errorOrDefault(
values.ProductSKUHasCardSecretStock,
"product sku has card secret stock",
),
}
}
func errorOrDefault(value error, message string) error {
if value != nil {
return value
}
return errors.New(message)
}
func (s *WriteService) filterAvailablePaymentChannelIDs(ids []uint) ([]uint, error) {
if len(ids) == 0 {
return nil, nil
@@ -6,6 +6,7 @@ import (
productdomain "github.com/dujiao-next/internal/modules/catalog/product/domain"
"github.com/dujiao-next/internal/constants"
productcontract "github.com/dujiao-next/internal/modules/catalog/product/contract"
"github.com/dujiao-next/internal/shared/jsonmap"
"github.com/dujiao-next/internal/shared/money"
@@ -39,7 +40,7 @@ func (s *WriteService) syncSingleProductSKU(skuRepo SKURepository, productID uin
}
targetIndex := pickSingleModeTargetSKUIndex(skus)
if targetIndex < 0 || targetIndex >= len(skus) {
return s.errors.ProductSKUInvalid
return productcontract.ErrProductSKUInvalid
}
target := skus[targetIndex]
@@ -105,7 +106,7 @@ type normalizedProductSKU struct {
func (s *WriteService) normalizeProductSKUInputs(inputs []ProductSKUInput, fulfillmentType string, existingSKUMap map[uint]productdomain.ProductSKU) ([]normalizedProductSKU, decimal.Decimal, int, error) {
if len(inputs) == 0 {
return nil, decimal.Zero, 0, s.errors.ProductSKUInvalid
return nil, decimal.Zero, 0, productcontract.ErrProductSKUInvalid
}
seenCode := make(map[string]struct{}, len(inputs))
normalized := make([]normalizedProductSKU, 0, len(inputs))
@@ -117,26 +118,26 @@ func (s *WriteService) normalizeProductSKUInputs(inputs []ProductSKUInput, fulfi
for _, input := range inputs {
skuCode := strings.TrimSpace(input.SKUCode)
if skuCode == "" {
return nil, decimal.Zero, 0, s.errors.ProductSKUInvalid
return nil, decimal.Zero, 0, productcontract.ErrProductSKUInvalid
}
codeKey := strings.ToLower(skuCode)
if _, exists := seenCode[codeKey]; exists {
return nil, decimal.Zero, 0, s.errors.ProductSKUInvalid
return nil, decimal.Zero, 0, productcontract.ErrProductSKUInvalid
}
seenCode[codeKey] = struct{}{}
priceAmount := input.PriceAmount.Round(2)
if priceAmount.LessThanOrEqual(decimal.Zero) {
return nil, decimal.Zero, 0, s.errors.ProductPriceInvalid
return nil, decimal.Zero, 0, productcontract.ErrProductPriceInvalid
}
costPriceAmount := input.CostPriceAmount.Round(2)
if costPriceAmount.LessThan(decimal.Zero) {
return nil, decimal.Zero, 0, s.errors.ProductPriceInvalid
return nil, decimal.Zero, 0, productcontract.ErrProductPriceInvalid
}
manualTotal := input.ManualStockTotal
if manualTotal < constants.ManualStockUnlimited {
return nil, decimal.Zero, 0, s.errors.ManualStockInvalid
return nil, decimal.Zero, 0, productcontract.ErrManualStockInvalid
}
if fulfillmentType != constants.FulfillmentTypeManual {
manualTotal = 0
@@ -144,7 +145,7 @@ func (s *WriteService) normalizeProductSKUInputs(inputs []ProductSKUInput, fulfi
if existingSKUMap != nil && input.ID > 0 {
_, ok := existingSKUMap[input.ID]
if !ok {
return nil, decimal.Zero, 0, s.errors.ProductSKUInvalid
return nil, decimal.Zero, 0, productcontract.ErrProductSKUInvalid
}
}
@@ -184,7 +185,7 @@ func (s *WriteService) normalizeProductSKUInputs(inputs []ProductSKUInput, fulfi
}
if !hasActive {
return nil, decimal.Zero, 0, s.errors.ProductSKUInvalid
return nil, decimal.Zero, 0, productcontract.ErrProductSKUInvalid
}
if fulfillmentType != constants.FulfillmentTypeManual {
manualStockTotal = 0
@@ -219,7 +220,7 @@ func (s *WriteService) applyProductSKUsWithStockGuard(
rows []normalizedProductSKU,
) error {
if skuRepo == nil || productID == 0 || len(rows) == 0 {
return s.errors.ProductSKUInvalid
return productcontract.ErrProductSKUInvalid
}
existingRows, err := skuRepo.ListByProduct(productID, false)
if err != nil {
@@ -240,7 +241,7 @@ func (s *WriteService) applyProductSKUsWithStockGuard(
if row.ID > 0 {
existing, ok := existingByID[row.ID]
if !ok {
return s.errors.ProductSKUInvalid
return productcontract.ErrProductSKUInvalid
}
existing.SKUCode = row.SKUCode
existing.SpecValuesJSON = row.SpecValuesJSON
@@ -324,7 +325,7 @@ func (s *WriteService) ensureAutoSKUCardSecretStockSafe(
if row.ID > 0 {
existing, ok := existingByID[row.ID]
if !ok {
return s.errors.ProductSKUInvalid
return productcontract.ErrProductSKUInvalid
}
nextActive[existing.ID] = row.IsActive
kept[existing.ID] = struct{}{}
@@ -354,7 +355,7 @@ func (s *WriteService) ensureAutoSKUCardSecretStockSafe(
}
outstanding := total - used
if available > 0 || outstanding > 0 {
return s.errors.ProductSKUHasCardSecretStock
return productcontract.ErrProductSKUHasCardSecretStock
}
}
return nil
@@ -6,6 +6,7 @@ import (
categorydomain "github.com/dujiao-next/internal/modules/catalog/category/domain"
"github.com/dujiao-next/internal/constants"
productcontract "github.com/dujiao-next/internal/modules/catalog/product/contract"
productdomain "github.com/dujiao-next/internal/modules/catalog/product/domain"
"github.com/dujiao-next/internal/modules/catalog/product/manualform"
"github.com/dujiao-next/internal/shared/jsonmap"
@@ -19,16 +20,16 @@ import (
func (s *WriteService) Update(id string, input CreateProductInput) (*productdomain.Product, error) {
priceAmount := input.PriceAmount.Round(2)
if len(input.SKUs) == 0 && priceAmount.LessThanOrEqual(decimal.Zero) {
return nil, s.errors.ProductPriceInvalid
return nil, productcontract.ErrProductPriceInvalid
}
product, err := s.products.GetByID(id)
if err != nil {
return nil, err
}
if product == nil {
return nil, s.errors.NotFound
return nil, productcontract.ErrNotFound
}
if err := productdomain.ValidateCategoryAssignment(s.categories, input.CategoryID, product.CategoryID, s.errors.ProductCategoryInvalid); err != nil {
if err := productdomain.ValidateCategoryAssignment(s.categories, input.CategoryID, product.CategoryID, productcontract.ErrProductCategoryInvalid); err != nil {
return nil, err
}
@@ -37,7 +38,7 @@ func (s *WriteService) Update(id string, input CreateProductInput) (*productdoma
return nil, err
}
if count > 0 {
return nil, s.errors.SlugExists
return nil, productcontract.ErrSlugExists
}
product.CategoryID = input.CategoryID
@@ -70,7 +71,7 @@ func (s *WriteService) Update(id string, input CreateProductInput) (*productdoma
}
purchaseType := productdomain.NormalizePurchaseType(rawPurchaseType)
if purchaseType == "" {
return nil, s.errors.ProductPurchaseInvalid
return nil, productcontract.ErrProductPurchaseInvalid
}
product.PurchaseType = purchaseType
if input.MaxPurchaseQuantity != nil {
@@ -80,11 +81,11 @@ func (s *WriteService) Update(id string, input CreateProductInput) (*productdoma
product.MinPurchaseQuantity = productdomain.NormalizePurchaseQuantityLimit(*input.MinPurchaseQuantity)
}
if product.MinPurchaseQuantity > 0 && product.MaxPurchaseQuantity > 0 && product.MinPurchaseQuantity > product.MaxPurchaseQuantity {
return nil, s.errors.ProductPurchaseLimitInvalid
return nil, productcontract.ErrProductPurchaseLimitInvalid
}
stockDisplayMode := productdomain.NormalizeStockDisplayMode(input.StockDisplayMode)
if stockDisplayMode == "" {
return nil, s.errors.ProductStockDisplayInvalid
return nil, productcontract.ErrProductStockDisplayInvalid
}
product.StockDisplayMode = stockDisplayMode
rawFulfillmentType := strings.TrimSpace(input.FulfillmentType)
@@ -93,7 +94,7 @@ func (s *WriteService) Update(id string, input CreateProductInput) (*productdoma
}
fulfillmentType := productdomain.NormalizeFulfillmentType(rawFulfillmentType)
if fulfillmentType == "" {
return nil, s.errors.FulfillmentInvalid
return nil, productcontract.ErrFulfillmentInvalid
}
// 对接商品的真实交付类型必须保持 upstream,后台返回的 auto/manual 仅用于展示。
if product.IsMapped {
@@ -113,13 +114,13 @@ func (s *WriteService) Update(id string, input CreateProductInput) (*productdoma
manualStockTotal = *input.ManualStockTotal
}
if manualStockTotal < constants.ManualStockUnlimited {
return nil, s.errors.ManualStockInvalid
return nil, productcontract.ErrManualStockInvalid
}
var normalizedSKUs []normalizedProductSKU
if len(input.SKUs) > 0 {
if s.skus == nil {
return nil, s.errors.ProductSKUInvalid
return nil, productcontract.ErrProductSKUInvalid
}
existingSKUs, listErr := s.skus.ListByProduct(product.ID, false)
if listErr != nil {
@@ -132,7 +132,7 @@ func TestProductServiceCreateRollsBackProductAndSKUWhenWholesaleValidationFails(
PriceAmount: decimal.NewFromInt(10),
PurchaseType: constants.ProductPurchaseMember,
FulfillmentType: constants.FulfillmentTypeAuto,
WholesalePrices: &[]productwrite.WholesalePriceInput{{
WholesalePrices: &[]productdomain.WholesalePriceInput{{
MinQuantity: 0,
UnitPrice: decimal.NewFromInt(8),
}},
@@ -18,7 +18,7 @@ import (
)
func TestNormalizeWholesalePriceInputsSortsTiers(t *testing.T) {
tiers, err := productdomain.NormalizeWholesalePrices([]productwrite.WholesalePriceInput{
tiers, err := productdomain.NormalizeWholesalePrices([]productdomain.WholesalePriceInput{
{MinQuantity: 10, UnitPrice: decimal.NewFromInt(70)},
{MinQuantity: 5, UnitPrice: decimal.NewFromInt(80)},
})
@@ -39,33 +39,33 @@ func TestNormalizeWholesalePriceInputsSortsTiers(t *testing.T) {
func TestNormalizeWholesalePriceInputsRejectsInvalidTiers(t *testing.T) {
tests := []struct {
name string
inputs []productwrite.WholesalePriceInput
inputs []productdomain.WholesalePriceInput
}{
{
name: "zero quantity",
inputs: []productwrite.WholesalePriceInput{{MinQuantity: 0, UnitPrice: decimal.NewFromInt(80)}},
inputs: []productdomain.WholesalePriceInput{{MinQuantity: 0, UnitPrice: decimal.NewFromInt(80)}},
},
{
name: "zero price",
inputs: []productwrite.WholesalePriceInput{{MinQuantity: 5, UnitPrice: decimal.Zero}},
inputs: []productdomain.WholesalePriceInput{{MinQuantity: 5, UnitPrice: decimal.Zero}},
},
{
name: "duplicate quantity",
inputs: []productwrite.WholesalePriceInput{
inputs: []productdomain.WholesalePriceInput{
{MinQuantity: 5, UnitPrice: decimal.NewFromInt(80)},
{MinQuantity: 5, UnitPrice: decimal.NewFromInt(70)},
},
},
{
name: "higher tier more expensive",
inputs: []productwrite.WholesalePriceInput{
inputs: []productdomain.WholesalePriceInput{
{MinQuantity: 5, UnitPrice: decimal.NewFromInt(80)},
{MinQuantity: 10, UnitPrice: decimal.NewFromInt(90)},
},
},
{
name: "higher tier equal price",
inputs: []productwrite.WholesalePriceInput{
inputs: []productdomain.WholesalePriceInput{
{MinQuantity: 5, UnitPrice: decimal.NewFromInt(80)},
{MinQuantity: 10, UnitPrice: decimal.NewFromInt(80)},
},
@@ -83,7 +83,7 @@ func TestNormalizeWholesalePriceInputsRejectsInvalidTiers(t *testing.T) {
}
func TestNormalizeWholesalePriceInputsRejectsDuplicateCanonicalSKUScope(t *testing.T) {
_, err := productdomain.NormalizeWholesalePrices([]productwrite.WholesalePriceInput{
_, err := productdomain.NormalizeWholesalePrices([]productdomain.WholesalePriceInput{
{SKUID: 5, SKUCode: "SKU-A", MinQuantity: 10, UnitPrice: decimal.NewFromInt(80)},
{SKUCode: "SKU-A", MinQuantity: 10, UnitPrice: decimal.NewFromInt(70)},
})
@@ -93,7 +93,7 @@ func TestNormalizeWholesalePriceInputsRejectsDuplicateCanonicalSKUScope(t *testi
}
func TestNormalizeWholesalePriceInputsRejectsNonDecreasingCanonicalSKUScope(t *testing.T) {
_, err := productdomain.NormalizeWholesalePrices([]productwrite.WholesalePriceInput{
_, err := productdomain.NormalizeWholesalePrices([]productdomain.WholesalePriceInput{
{SKUID: 5, SKUCode: "SKU-A", MinQuantity: 5, UnitPrice: decimal.NewFromInt(80)},
{SKUCode: "SKU-A", MinQuantity: 10, UnitPrice: decimal.NewFromInt(90)},
})
@@ -103,7 +103,7 @@ func TestNormalizeWholesalePriceInputsRejectsNonDecreasingCanonicalSKUScope(t *t
}
func TestNormalizeWholesalePriceInputsAllowsSameQuantityForDifferentSKUs(t *testing.T) {
tiers, err := productdomain.NormalizeWholesalePrices([]productwrite.WholesalePriceInput{
tiers, err := productdomain.NormalizeWholesalePrices([]productdomain.WholesalePriceInput{
{SKUID: 2, SKUCode: "SKU-B", MinQuantity: 5, UnitPrice: decimal.NewFromInt(70)},
{SKUID: 1, SKUCode: "SKU-A", MinQuantity: 5, UnitPrice: decimal.NewFromInt(80)},
{MinQuantity: 5, UnitPrice: decimal.NewFromInt(90)},
@@ -292,7 +292,7 @@ func TestProductServiceUpdateWholesalePricesOptionalSemantics(t *testing.T) {
PriceAmount: decimal.NewFromInt(100),
PurchaseType: constants.ProductPurchaseMember,
FulfillmentType: constants.FulfillmentTypeAuto,
WholesalePrices: &[]productwrite.WholesalePriceInput{
WholesalePrices: &[]productdomain.WholesalePriceInput{
{MinQuantity: 5, UnitPrice: decimal.NewFromInt(80)},
},
IsActive: boolPtr(true),
@@ -330,7 +330,7 @@ func TestProductServiceUpdateWholesalePricesOptionalSemantics(t *testing.T) {
// 传入空切片:显式清空。
clear := baseUpdate()
clear.WholesalePrices = &[]productwrite.WholesalePriceInput{}
clear.WholesalePrices = &[]productdomain.WholesalePriceInput{}
cleared, err := svc.Write.Update(idStr, clear)
if err != nil {
t.Fatalf("update with empty wholesale prices failed: %v", err)
@@ -368,7 +368,7 @@ func TestProductServiceUpdateWholesalePricesOnlyTouchesWholesaleField(t *testing
t.Fatalf("create product failed: %v", err)
}
updated, err := svc.Admin.UpdateWholesalePrices(strconv.FormatUint(uint64(product.ID), 10), []productwrite.WholesalePriceInput{
updated, err := svc.Admin.UpdateWholesalePrices(strconv.FormatUint(uint64(product.ID), 10), []productdomain.WholesalePriceInput{
{MinQuantity: 10, UnitPrice: decimal.RequireFromString("70.00")},
{MinQuantity: 5, UnitPrice: decimal.RequireFromString("80.00")},
})
@@ -414,7 +414,7 @@ func TestProductServiceUpdateWholesalePricesClearsTiers(t *testing.T) {
t.Fatalf("create product failed: %v", err)
}
updated, err := svc.Admin.UpdateWholesalePrices(strconv.FormatUint(uint64(product.ID), 10), []productwrite.WholesalePriceInput{})
updated, err := svc.Admin.UpdateWholesalePrices(strconv.FormatUint(uint64(product.ID), 10), []productdomain.WholesalePriceInput{})
if err != nil {
t.Fatalf("clear wholesale prices failed: %v", err)
}
@@ -442,11 +442,11 @@ func TestProductServiceUpdateWholesalePricesRejectsInvalidInputs(t *testing.T) {
cases := []struct {
name string
inputs []productwrite.WholesalePriceInput
inputs []productdomain.WholesalePriceInput
}{
{name: "zero quantity", inputs: []productwrite.WholesalePriceInput{{MinQuantity: 0, UnitPrice: decimal.NewFromInt(80)}}},
{name: "zero price", inputs: []productwrite.WholesalePriceInput{{MinQuantity: 5, UnitPrice: decimal.Zero}}},
{name: "duplicate quantity", inputs: []productwrite.WholesalePriceInput{
{name: "zero quantity", inputs: []productdomain.WholesalePriceInput{{MinQuantity: 0, UnitPrice: decimal.NewFromInt(80)}}},
{name: "zero price", inputs: []productdomain.WholesalePriceInput{{MinQuantity: 5, UnitPrice: decimal.Zero}}},
{name: "duplicate quantity", inputs: []productdomain.WholesalePriceInput{
{MinQuantity: 5, UnitPrice: decimal.NewFromInt(80)},
{MinQuantity: 5, UnitPrice: decimal.NewFromInt(70)},
}},
@@ -516,18 +516,18 @@ func TestProductServiceUpdateWholesalePricesValidatesSKUBelonging(t *testing.T)
}
idStr := strconv.FormatUint(uint64(product.ID), 10)
if _, err := svc.Admin.UpdateWholesalePrices(idStr, []productwrite.WholesalePriceInput{
if _, err := svc.Admin.UpdateWholesalePrices(idStr, []productdomain.WholesalePriceInput{
{SKUID: foreignSKU.ID, MinQuantity: 5, UnitPrice: decimal.NewFromInt(80)},
}); !errors.Is(err, productdomain.ErrWholesalePriceInvalid) {
t.Fatalf("expected foreign sku_id to be rejected, got %v", err)
}
if _, err := svc.Admin.UpdateWholesalePrices(idStr, []productwrite.WholesalePriceInput{
if _, err := svc.Admin.UpdateWholesalePrices(idStr, []productdomain.WholesalePriceInput{
{SKUID: skuA.ID, SKUCode: "SKU-X", MinQuantity: 5, UnitPrice: decimal.NewFromInt(80)},
}); !errors.Is(err, productdomain.ErrWholesalePriceInvalid) {
t.Fatalf("expected sku_id/sku_code mismatch to be rejected, got %v", err)
}
updated, err := svc.Admin.UpdateWholesalePrices(idStr, []productwrite.WholesalePriceInput{
updated, err := svc.Admin.UpdateWholesalePrices(idStr, []productdomain.WholesalePriceInput{
{SKUCode: "SKU-A", MinQuantity: 5, UnitPrice: decimal.NewFromInt(80)},
})
if err != nil {
@@ -544,7 +544,7 @@ func TestProductServiceUpdateWholesalePricesValidatesSKUBelonging(t *testing.T)
func TestProductServiceUpdateWholesalePricesReturnsNotFound(t *testing.T) {
svc, _ := newProductServiceForTest(t)
_, err := svc.Admin.UpdateWholesalePrices("999999", []productwrite.WholesalePriceInput{
_, err := svc.Admin.UpdateWholesalePrices("999999", []productdomain.WholesalePriceInput{
{MinQuantity: 5, UnitPrice: decimal.NewFromInt(80)},
})
if !errors.Is(err, productcontract.ErrNotFound) {
@@ -18,7 +18,6 @@ import (
mappingdomain "github.com/dujiao-next/internal/modules/catalog/mapping/domain"
productcontract "github.com/dujiao-next/internal/modules/catalog/product/contract"
productdomain "github.com/dujiao-next/internal/modules/catalog/product/domain"
categorydomain "github.com/dujiao-next/internal/modules/catalog/category/domain"
@@ -152,11 +151,9 @@ func setupAdminProductHandlerTest(t *testing.T) (*producthttp.AdminProductHandle
facade := &productHandlerFacade{
Service: productapplication.NewService(productapplication.Options{
Products: productStore,
Categories: categoryStore,
Stock: cardSecretStore,
NotFoundError: productcontract.ErrNotFound,
ResellerProductNotListedError: productcontract.ErrResellerProductNotListed,
Products: productStore,
Categories: categoryStore,
Stock: cardSecretStore,
}),
AdminService: productadmin.NewAdminService(productadmin.Options{
Products: productStore,
@@ -172,12 +169,6 @@ func setupAdminProductHandlerTest(t *testing.T) (*producthttp.AdminProductHandle
carts: cartStore,
productMappings: mappingStore,
},
Errors: productadmin.ErrorSet{
NotFound: productcontract.ErrNotFound,
ProductCategoryInvalid: productcontract.ErrProductCategoryInvalid,
ProductHasStock: productcontract.ErrProductHasStock,
ProductHasOrderRecord: productcontract.ErrProductHasOrderRecord,
},
}),
WriteService: productwrite.NewWriteService(productwrite.Options{
Products: productStore,
@@ -189,19 +180,6 @@ func setupAdminProductHandlerTest(t *testing.T) (*producthttp.AdminProductHandle
skus: skuStore,
cardSecrets: cardSecretStore,
},
Errors: productwrite.ErrorSet{
NotFound: productcontract.ErrNotFound,
SlugExists: productcontract.ErrSlugExists,
ProductCategoryInvalid: productcontract.ErrProductCategoryInvalid,
ProductPurchaseInvalid: productcontract.ErrProductPurchaseInvalid,
FulfillmentInvalid: productcontract.ErrFulfillmentInvalid,
ProductPriceInvalid: productcontract.ErrProductPriceInvalid,
ManualStockInvalid: productcontract.ErrManualStockInvalid,
ProductPurchaseLimitInvalid: productcontract.ErrProductPurchaseLimitInvalid,
ProductStockDisplayInvalid: productcontract.ErrProductStockDisplayInvalid,
ProductSKUInvalid: productcontract.ErrProductSKUInvalid,
ProductSKUHasCardSecretStock: productcontract.ErrProductSKUHasCardSecretStock,
},
}),
}
@@ -36,7 +36,7 @@ type ProductWriter interface {
// ProductAdminCommands 是商品管理与删除用例。
type ProductAdminCommands interface {
UpdateWholesalePrices(id string, prices []productwrite.WholesalePriceInput) (*productdomain.Product, error)
UpdateWholesalePrices(id string, prices []productdomain.WholesalePriceInput) (*productdomain.Product, error)
QuickUpdate(id string, fields map[string]interface{}) (*productdomain.Product, error)
Delete(id string) error
}
@@ -233,13 +233,13 @@ type CreateProductRequest struct {
// toWholesalePriceInputs 透传「是否提供」语义:请求未携带 wholesale_prices 时返回 nil
// (Update 保留原配置),携带(含空数组)时返回非 nil 指针以整体覆盖。
func toWholesalePriceInputs(items *[]WholesalePriceRequest) *[]productwrite.WholesalePriceInput {
func toWholesalePriceInputs(items *[]WholesalePriceRequest) *[]productdomain.WholesalePriceInput {
if items == nil {
return nil
}
result := make([]productwrite.WholesalePriceInput, 0, len(*items))
result := make([]productdomain.WholesalePriceInput, 0, len(*items))
for _, item := range *items {
result = append(result, productwrite.WholesalePriceInput{
result = append(result, productdomain.WholesalePriceInput{
SKUID: item.SKUID,
SKUCode: strings.TrimSpace(item.SKUCode),
MinQuantity: item.MinQuantity,
+1 -1
View File
@@ -22,7 +22,7 @@ type Repository interface {
GetTotalUserBalance() (float64, error)
}
// SettingReader keeps the dashboard core independent from the legacy setting service.
// SettingReader keeps the dashboard core independent from settings persistence.
type SettingReader interface {
GetDashboardSetting() (settingsstorefront.DashboardSetting, error)
}
@@ -6,11 +6,11 @@ import (
"github.com/dujiao-next/internal/logger"
"github.com/dujiao-next/internal/modules/notification/application/format"
"github.com/dujiao-next/internal/modules/notification/contract"
settingsmessaging "github.com/dujiao-next/internal/modules/settings/schema/messaging"
"github.com/dujiao-next/internal/queue"
)
func (s *Service) dispatchExceptionAlertCheck(ctx context.Context, setting contract.NotificationCenterSetting, payload queue.NotificationDispatchPayload) error {
func (s *Service) dispatchExceptionAlertCheck(ctx context.Context, setting settingsmessaging.NotificationCenterSetting, payload queue.NotificationDispatchPayload) error {
if s.dashboardSvc == nil || s.settingService == nil {
return nil
}
@@ -5,10 +5,10 @@ import (
"strings"
"github.com/dujiao-next/internal/constants"
"github.com/dujiao-next/internal/modules/notification/contract"
settingsstorefront "github.com/dujiao-next/internal/modules/settings/schema/storefront"
)
func thresholdValueByAlertType(setting contract.DashboardAlertSetting, alertType string) int64 {
func thresholdValueByAlertType(setting settingsstorefront.DashboardAlertSetting, alertType string) int64 {
switch alertType {
case constants.NotificationAlertTypeOutOfStockProducts:
return setting.OutOfStockProductsThreshold
@@ -9,13 +9,14 @@ import (
"github.com/dujiao-next/internal/constants"
dashboard "github.com/dujiao-next/internal/modules/dashboard/contract"
"github.com/dujiao-next/internal/modules/notification/contract"
settingsmessaging "github.com/dujiao-next/internal/modules/settings/schema/messaging"
settingsstorefront "github.com/dujiao-next/internal/modules/settings/schema/storefront"
"github.com/dujiao-next/internal/queue"
)
func BuildInventoryAlertDispatchPayloads(
setting contract.NotificationCenterSetting,
dashboardSetting contract.DashboardSetting,
setting settingsmessaging.NotificationCenterSetting,
dashboardSetting settingsstorefront.DashboardSetting,
payload queue.NotificationDispatchPayload,
rows []dashboard.InventoryAlertRow,
) []queue.NotificationDispatchPayload {
@@ -6,14 +6,15 @@ import (
"github.com/dujiao-next/internal/constants"
dashboard "github.com/dujiao-next/internal/modules/dashboard/contract"
"github.com/dujiao-next/internal/modules/notification/contract"
settingsmessaging "github.com/dujiao-next/internal/modules/settings/schema/messaging"
settingsstorefront "github.com/dujiao-next/internal/modules/settings/schema/storefront"
"github.com/dujiao-next/internal/queue"
)
// BuildPaymentOrderAlertDispatchPayloads 构建支付订单告警通知载荷。
func BuildPaymentOrderAlertDispatchPayloads(
setting contract.NotificationCenterSetting,
dashboardSetting contract.DashboardSetting,
setting settingsmessaging.NotificationCenterSetting,
dashboardSetting settingsstorefront.DashboardSetting,
payload queue.NotificationDispatchPayload,
counts dashboard.PaymentOrderAlertCountsRow,
) []queue.NotificationDispatchPayload {
@@ -41,7 +42,7 @@ func BuildPaymentOrderAlertDispatchPayloads(
// buildPaymentOrderAlertDispatchPayload 构建单个支付订单告警通知载荷
func buildPaymentOrderAlertDispatchPayload(
setting contract.NotificationCenterSetting,
setting settingsmessaging.NotificationCenterSetting,
payload queue.NotificationDispatchPayload,
alertType string,
value int64,
@@ -9,6 +9,7 @@ import (
"github.com/dujiao-next/internal/logger"
"github.com/dujiao-next/internal/modules/notification/application/format"
"github.com/dujiao-next/internal/modules/notification/contract"
settingsmessaging "github.com/dujiao-next/internal/modules/settings/schema/messaging"
"github.com/dujiao-next/internal/queue"
"github.com/dujiao-next/internal/shared/jsonmap"
"github.com/dujiao-next/internal/shared/outboundctx"
@@ -97,7 +98,7 @@ func (s *Service) SendTest(ctx context.Context, input contract.TestSendInput) er
}
}
func (s *Service) dispatchSingleEvent(ctx context.Context, setting contract.NotificationCenterSetting, payload queue.NotificationDispatchPayload) error {
func (s *Service) dispatchSingleEvent(ctx context.Context, setting settingsmessaging.NotificationCenterSetting, payload queue.NotificationDispatchPayload) error {
if !payload.Force {
ok, err := acquireNotificationDedupe(ctx, setting.DedupeTTLSeconds, payload)
if err != nil {
@@ -13,10 +13,6 @@ import (
"github.com/dujiao-next/internal/shared/money"
)
type NotificationCenterSetting = settingsmessaging.NotificationCenterSetting
type DashboardSetting = settingsstorefront.DashboardSetting
type DashboardAlertSetting = settingsstorefront.DashboardAlertSetting
type SettingsReader interface {
GetNotificationCenterSetting() (settingsmessaging.NotificationCenterSetting, error)
GetDashboardSetting() (settingsstorefront.DashboardSetting, error)
@@ -264,7 +264,7 @@ func (s *PaymentService) handleWebhookByCandidateIteration(
func (s *PaymentService) tryParseWebhookWithChannel(
channel *paymentdomain.PaymentChannel,
input WebhookCallbackInput,
) (*paymentcontract.GatewayWebhookResult, error) {
) (*paymentcontract.GatewayCallbackResult, error) {
if s.paymentProviderRegistry == nil {
return nil, ErrPaymentProviderNotSupported
}
@@ -291,7 +291,7 @@ func (s *PaymentService) tryParseWebhookWithChannel(
// 反查 payment 并落库。任何错误都是真实的业务/DB 错误,不再 retry 其他 channel。
func (s *PaymentService) commitVerifiedWebhook(
channel *paymentdomain.PaymentChannel,
result *paymentcontract.GatewayWebhookResult,
result *paymentcontract.GatewayCallbackResult,
log *zap.SugaredLogger,
) (*paymentdomain.Payment, string, error) {
log.Infow("payment_webhook_parsed",
@@ -387,7 +387,7 @@ func supportsBlindWebhookCandidateMatching(channelType string) bool {
// findWebhookPayment 通过 webhook result 反查 payment。
// 优先用 OrderNo(= GatewayOrderNo,商户单号),次选 ProviderRef(网关流水号)。
func (s *PaymentService) findWebhookPayment(channelID uint, result *paymentcontract.GatewayWebhookResult) (*paymentdomain.Payment, error) {
func (s *PaymentService) findWebhookPayment(channelID uint, result *paymentcontract.GatewayCallbackResult) (*paymentdomain.Payment, error) {
if result == nil {
return nil, ErrPaymentNotFound
}
+1 -3
View File
@@ -56,8 +56,6 @@ type GatewayCallbackResult struct {
Payload jsonmap.JSON
}
type GatewayWebhookResult = GatewayCallbackResult
// GatewayProvider 是所有支付网关适配器必须实现的最小能力。
type GatewayProvider interface {
Type() string
@@ -72,7 +70,7 @@ type GatewayCapturer interface {
type GatewayWebhooker interface {
GatewayProvider
ParseWebhook(ctx context.Context, cfg jsonmap.JSON, headers map[string]string, body []byte, now time.Time) (*GatewayWebhookResult, error)
ParseWebhook(ctx context.Context, cfg jsonmap.JSON, headers map[string]string, body []byte, now time.Time) (*GatewayCallbackResult, error)
}
type GatewayCallbackVerifier interface {
@@ -166,7 +166,7 @@ func (a *dujiaoPayAdapter) CreatePayment(ctx context.Context, raw jsonmap.JSON,
}
// ParseWebhook 验签并解析 DujiaoPay webhook。
func (a *dujiaoPayAdapter) ParseWebhook(_ context.Context, raw jsonmap.JSON, headers map[string]string, body []byte, now time.Time) (*paymentcontract.GatewayWebhookResult, error) {
func (a *dujiaoPayAdapter) ParseWebhook(_ context.Context, raw jsonmap.JSON, headers map[string]string, body []byte, now time.Time) (*paymentcontract.GatewayCallbackResult, error) {
cfg, err := dujiaopay.ParseConfig(raw)
if err != nil {
return nil, mapDujiaoPayError(err)
@@ -184,7 +184,7 @@ func (a *dujiaoPayAdapter) ParseWebhook(_ context.Context, raw jsonmap.JSON, hea
payload["tx_hash"] = event.TxHash
}
return &paymentcontract.GatewayWebhookResult{
return &paymentcontract.GatewayCallbackResult{
OrderNo: event.MerchantOrderID,
ProviderRef: event.OrderID,
Status: event.Status,
@@ -158,7 +158,7 @@ func (a *paypalAdapter) QueryPayment(ctx context.Context, raw jsonmap.JSON, prov
}
// ParseWebhook 合并 paypal 的 VerifyWebhookSignature + ParseWebhookEvent 两步(stripe 是一步)。
func (a *paypalAdapter) ParseWebhook(ctx context.Context, raw jsonmap.JSON, headers map[string]string, body []byte, _ time.Time) (*paymentcontract.GatewayWebhookResult, error) {
func (a *paypalAdapter) ParseWebhook(ctx context.Context, raw jsonmap.JSON, headers map[string]string, body []byte, _ time.Time) (*paymentcontract.GatewayCallbackResult, error) {
cfg, err := a.parseConfig(raw)
if err != nil {
return nil, err
@@ -195,7 +195,7 @@ func (a *paypalAdapter) ParseWebhook(ctx context.Context, raw jsonmap.JSON, head
}
}
return &paymentcontract.GatewayWebhookResult{
return &paymentcontract.GatewayCallbackResult{
OrderNo: parsed.RelatedInvoiceID(),
ProviderRef: parsed.RelatedOrderID(),
Status: status,
@@ -162,7 +162,7 @@ func (a *stripeAdapter) QueryPayment(ctx context.Context, raw jsonmap.JSON, prov
}
// ParseWebhook 验签并解析 webhook(实现 paymentcontract.GatewayWebhooker)。
func (a *stripeAdapter) ParseWebhook(_ context.Context, raw jsonmap.JSON, headers map[string]string, body []byte, now time.Time) (*paymentcontract.GatewayWebhookResult, error) {
func (a *stripeAdapter) ParseWebhook(_ context.Context, raw jsonmap.JSON, headers map[string]string, body []byte, now time.Time) (*paymentcontract.GatewayCallbackResult, error) {
cfg, err := a.parseConfig(raw)
if err != nil {
return nil, err
@@ -182,7 +182,7 @@ func (a *stripeAdapter) ParseWebhook(_ context.Context, raw jsonmap.JSON, header
}
}
return &paymentcontract.GatewayWebhookResult{
return &paymentcontract.GatewayCallbackResult{
OrderNo: result.OrderNo,
ProviderRef: gatewaycommon.PickFirstNonEmpty(result.ProviderRef, result.SessionID, result.PaymentIntentID),
Status: result.Status,
@@ -177,7 +177,7 @@ func (a *wechatpayAdapter) QueryPayment(ctx context.Context, raw jsonmap.JSON, p
}
// ParseWebhook 验签并解析 webhook(实现 paymentcontract.GatewayWebhooker)。
func (a *wechatpayAdapter) ParseWebhook(ctx context.Context, raw jsonmap.JSON, headers map[string]string, body []byte, _ time.Time) (*paymentcontract.GatewayWebhookResult, error) {
func (a *wechatpayAdapter) ParseWebhook(ctx context.Context, raw jsonmap.JSON, headers map[string]string, body []byte, _ time.Time) (*paymentcontract.GatewayCallbackResult, error) {
cfg, err := a.parseConfig(raw, "")
if err != nil {
return nil, err
@@ -197,7 +197,7 @@ func (a *wechatpayAdapter) ParseWebhook(ctx context.Context, raw jsonmap.JSON, h
}
}
return &paymentcontract.GatewayWebhookResult{
return &paymentcontract.GatewayCallbackResult{
OrderNo: result.OrderNo,
ProviderRef: result.TransactionID,
Status: result.Status,
@@ -20,7 +20,7 @@ import (
// Lifecycle owns the procurement-side persistence operations that span
// fulfillments and parent/child order status. It deliberately uses private
// records instead of the legacy repository package.
// records through the procurement-owned store.
type Lifecycle struct {
db *gorm.DB
queue StatusEmailQueue
+4 -3
View File
@@ -4,6 +4,7 @@ import (
"time"
walletdomain "github.com/dujiao-next/internal/modules/wallet/domain"
"github.com/dujiao-next/internal/shared/money"
)
// Repository owns all wallet aggregate persistence. Transactional callers
@@ -49,14 +50,14 @@ type UseCase interface {
StatsUserRechargeOrders(userID uint, rechargeNo string) (map[string]int64, error)
GetRechargeOrderByRechargeNo(userID uint, rechargeNo string) (*walletdomain.RechargeOrder, error)
GetRechargeOrderByPaymentIDAndUser(paymentID, userID uint) (*walletdomain.RechargeOrder, error)
GetBalancesByUserIDs(userIDs []uint) (map[uint]Balance, error)
GetBalancesByUserIDs(userIDs []uint) (map[uint]money.Amount, error)
Recharge(input RechargeInput) (*walletdomain.Account, *walletdomain.Transaction, error)
AdminAdjustBalance(input AdjustBalanceInput) (*walletdomain.Account, *walletdomain.Transaction, error)
CreditInTransaction(tx Transaction, input CreditInput) (*walletdomain.Account, *walletdomain.Transaction, error)
ApplyRechargePayment(tx Transaction, recharge *walletdomain.RechargeOrder) (*walletdomain.Transaction, error)
ApplyOrderBalance(tx Transaction, input OrderBalanceInput) (Balance, error)
ReleaseOrderBalance(tx Transaction, input OrderReleaseInput, claim ReleaseClaim) (Balance, error)
ApplyOrderBalance(tx Transaction, input OrderBalanceInput) (money.Amount, error)
ReleaseOrderBalance(tx Transaction, input OrderReleaseInput, claim ReleaseClaim) (money.Amount, error)
}
// ReleaseClaim atomically clears the order-side wallet allocation before the
@@ -6,8 +6,6 @@ import (
"github.com/dujiao-next/internal/shared/money"
)
type Balance = money.Amount
type AccountListFilter struct {
Page int
PageSize int