- 后端 internal/service 按职责拆分: fulfillment.go(1397→527)拆出 wallet/timeout/data/order/query/dashboard/shipnotify delivery.go(1124→801)拆出 upstream/link/state/helpers merchant.go(855→251)拆出 member/product/api_client/catalog/helpers - 前端 MerchantCenter.tsx(1327→606)拆出 merchantCenterTabs/merchantCenterUtils - docker-compose backend 透传 CORS_ALLOWED_ORIGINS - CORS 白名单实现(config/router/README/.env.example 配套) - 修复 gofmt 与文件尾部多余空行
263 lines
8.2 KiB
Go
263 lines
8.2 KiB
Go
package service
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
|
|
"affiliate_dash/internal/model"
|
|
|
|
"gorm.io/gorm"
|
|
"gorm.io/gorm/clause"
|
|
)
|
|
|
|
type ProductCatalogItem struct {
|
|
ID uint `json:"id"`
|
|
ProductID uint `json:"product_id"`
|
|
SKU string `json:"sku"`
|
|
DisplayName string `json:"display_name"`
|
|
Category string `json:"category"`
|
|
PriceAmount int64 `json:"price_amount"`
|
|
CostAmount int64 `json:"cost_amount"`
|
|
Currency string `json:"currency"`
|
|
Stock int64 `json:"stock"`
|
|
Status string `json:"status"`
|
|
}
|
|
|
|
// ListProductCatalog 返回自营商户的全部可售商品,作为平台默认商品目录供分配。
|
|
func (s *MerchantService) ListProductCatalog() ([]ProductCatalogItem, error) {
|
|
var selfMerchant model.Merchant
|
|
if err := s.db.Where("code = ?", model.MerchantCodeSelfOperated).First(&selfMerchant).Error; err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return nil, errors.New("平台商品目录尚未初始化")
|
|
}
|
|
return nil, err
|
|
}
|
|
var products []model.MerchantProduct
|
|
if err := s.db.Preload("Product").Where("merchant_id = ?", selfMerchant.ID).Order("id ASC").Find(&products).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
items := make([]ProductCatalogItem, 0, len(products))
|
|
for _, p := range products {
|
|
category := ""
|
|
if p.Product != nil {
|
|
category = p.Product.Category
|
|
}
|
|
items = append(items, ProductCatalogItem{
|
|
ID: p.ID,
|
|
ProductID: p.ProductID,
|
|
SKU: p.SKU,
|
|
DisplayName: p.DisplayName,
|
|
Category: category,
|
|
PriceAmount: p.PriceAmount,
|
|
CostAmount: p.CostAmount,
|
|
Currency: p.Currency,
|
|
Stock: p.Stock,
|
|
Status: p.Status,
|
|
})
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
// ListMerchantProductsByAdmin 供平台管理员查看指定商户的可售商品(不限功能开关)。
|
|
func (s *MerchantService) ListMerchantProductsByAdmin(merchantID uint) ([]ProductCatalogItem, error) {
|
|
var products []model.MerchantProduct
|
|
if err := s.db.Preload("Product").Where("merchant_id = ?", merchantID).Order("id ASC").Find(&products).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
items := make([]ProductCatalogItem, 0, len(products))
|
|
for _, p := range products {
|
|
category := ""
|
|
if p.Product != nil {
|
|
category = p.Product.Category
|
|
}
|
|
items = append(items, ProductCatalogItem{
|
|
ID: p.ID,
|
|
ProductID: p.ProductID,
|
|
SKU: p.SKU,
|
|
DisplayName: p.DisplayName,
|
|
Category: category,
|
|
PriceAmount: p.PriceAmount,
|
|
CostAmount: p.CostAmount,
|
|
Currency: p.Currency,
|
|
Stock: p.Stock,
|
|
Status: p.Status,
|
|
})
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
// AssignProductsInput 批量分配商品给商户的入参。
|
|
type AssignProductsInput struct {
|
|
// CatalogIDs 为自营商户商品目录 ID 列表;为空表示清空该商户全部商品。
|
|
CatalogIDs []uint
|
|
}
|
|
|
|
// AssignProducts 按自营商户商品目录 ID 批量同步商户的可售商品:
|
|
// 目录中勾选的商品会被复制(已存在则跳过),未勾选的已有商品会被移除。
|
|
func (s *MerchantService) AssignProducts(merchantID uint, in AssignProductsInput, actorUserID uint) (int, error) {
|
|
assigned := 0
|
|
err := s.db.Transaction(func(tx *gorm.DB) error {
|
|
var merchant model.Merchant
|
|
if err := tx.Where("id = ?", merchantID).First(&merchant).Error; err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return errors.New("商户不存在")
|
|
}
|
|
return err
|
|
}
|
|
if merchant.Code == model.MerchantCodeSelfOperated {
|
|
return errors.New("自营商户的商品目录由平台维护,不可分配")
|
|
}
|
|
var selfMerchant model.Merchant
|
|
if err := tx.Where("code = ?", model.MerchantCodeSelfOperated).First(&selfMerchant).Error; err != nil {
|
|
return errors.New("平台商品目录尚未初始化")
|
|
}
|
|
|
|
// 读取目录全量,构造 id -> 模板 的映射
|
|
var templates []model.MerchantProduct
|
|
if err := tx.Where("merchant_id = ?", selfMerchant.ID).Find(&templates).Error; err != nil {
|
|
return err
|
|
}
|
|
tmplByID := make(map[uint]model.MerchantProduct, len(templates))
|
|
for _, t := range templates {
|
|
tmplByID[t.ID] = t
|
|
}
|
|
|
|
// 读取商户已有商品,构造 sku -> 已有 的映射
|
|
var existing []model.MerchantProduct
|
|
if err := tx.Where("merchant_id = ?", merchantID).Find(&existing).Error; err != nil {
|
|
return err
|
|
}
|
|
existBySKU := make(map[string]model.MerchantProduct, len(existing))
|
|
for _, e := range existing {
|
|
existBySKU[e.SKU] = e
|
|
}
|
|
|
|
// 计算需要新增的 SKU 集合
|
|
wantSKUs := make(map[string]bool, len(in.CatalogIDs))
|
|
toCreate := make([]model.MerchantProduct, 0, len(in.CatalogIDs))
|
|
for _, id := range in.CatalogIDs {
|
|
t, ok := tmplByID[id]
|
|
if !ok {
|
|
continue
|
|
}
|
|
wantSKUs[t.SKU] = true
|
|
if _, has := existBySKU[t.SKU]; !has {
|
|
toCreate = append(toCreate, model.MerchantProduct{
|
|
MerchantID: merchantID,
|
|
ProductID: t.ProductID,
|
|
SKU: t.SKU,
|
|
DisplayName: t.DisplayName,
|
|
PriceAmount: t.PriceAmount,
|
|
CostAmount: t.CostAmount,
|
|
Currency: t.Currency,
|
|
Stock: t.Stock,
|
|
Status: t.Status,
|
|
FulfillmentConfig: t.FulfillmentConfig,
|
|
})
|
|
}
|
|
}
|
|
|
|
// 移除未勾选的已有商品
|
|
var removeIDs []uint
|
|
for _, e := range existing {
|
|
if !wantSKUs[e.SKU] {
|
|
removeIDs = append(removeIDs, e.ID)
|
|
}
|
|
}
|
|
if len(removeIDs) > 0 {
|
|
if err := tx.Where("merchant_id = ? AND id IN ?", merchantID, removeIDs).Delete(&model.MerchantProduct{}).Error; err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
// 新增勾选但尚未拥有的商品
|
|
if len(toCreate) > 0 {
|
|
if err := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&toCreate).Error; err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
assigned = len(wantSKUs)
|
|
return writeAudit(tx, &merchantID, &actorUserID, nil, "merchant.products.assign", "merchant", fmt.Sprint(merchantID), map[string]string{"assigned": fmt.Sprint(assigned)})
|
|
})
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
return assigned, nil
|
|
}
|
|
|
|
// copyDefaultProducts 将自营商户的全部可售商品复制给新建商户,作为默认商品目录。
|
|
// 自营商户(self-operated)充当平台默认商品模板,新商户开箱即用。
|
|
// 使用 OnConflict DoNothing 保证幂等:即使重复调用也不会报唯一索引冲突。
|
|
func copyDefaultProducts(tx *gorm.DB, merchantID uint) error {
|
|
var selfMerchant model.Merchant
|
|
if err := tx.Where("code = ?", model.MerchantCodeSelfOperated).First(&selfMerchant).Error; err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return nil // 自营商户不存在时跳过,不阻断建商户
|
|
}
|
|
return err
|
|
}
|
|
if selfMerchant.ID == merchantID {
|
|
return nil // 自营商户自身无需复制
|
|
}
|
|
var templates []model.MerchantProduct
|
|
if err := tx.Where("merchant_id = ?", selfMerchant.ID).Find(&templates).Error; err != nil {
|
|
return err
|
|
}
|
|
if len(templates) == 0 {
|
|
return nil
|
|
}
|
|
products := make([]model.MerchantProduct, 0, len(templates))
|
|
for _, t := range templates {
|
|
products = append(products, model.MerchantProduct{
|
|
MerchantID: merchantID,
|
|
ProductID: t.ProductID,
|
|
SKU: t.SKU,
|
|
DisplayName: t.DisplayName,
|
|
PriceAmount: t.PriceAmount,
|
|
CostAmount: t.CostAmount,
|
|
Currency: t.Currency,
|
|
Stock: t.Stock,
|
|
Status: t.Status,
|
|
FulfillmentConfig: t.FulfillmentConfig,
|
|
})
|
|
}
|
|
return tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&products).Error
|
|
}
|
|
|
|
func ensureProduct(tx *gorm.DB, in CreateMerchantProductInput) (*model.Product, error) {
|
|
if in.ProductCode != "" {
|
|
var product model.Product
|
|
err := tx.Where("code = ?", in.ProductCode).First(&product).Error
|
|
if err == nil {
|
|
return &product, nil
|
|
}
|
|
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return nil, err
|
|
}
|
|
}
|
|
if in.ProductName == "" {
|
|
return nil, errors.New("新建平台商品时商品名称不能为空")
|
|
}
|
|
code := in.ProductCode
|
|
if code == "" {
|
|
token, err := randomToken("prd_", 12)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
code = token
|
|
}
|
|
product := &model.Product{
|
|
Code: code,
|
|
Name: in.ProductName,
|
|
Category: in.Category,
|
|
Description: in.Description,
|
|
Attributes: in.Attributes,
|
|
Status: model.ProductStatusActive,
|
|
}
|
|
if err := tx.Create(product).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
return product, nil
|
|
}
|