package service import ( "encoding/json" "errors" "fmt" "strings" "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"` FulfillmentConfig string `json:"fulfillment_config,omitempty"` Routing ProductRoutingConfig `json:"routing"` } // 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 } routing := ParseProductRoutingConfig(p.FulfillmentConfig, p.SKU) 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, FulfillmentConfig: p.FulfillmentConfig, Routing: routing, }) } 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 } // UpdateProductRouting 更新平台目录商品的履约渠道与优先级配置 func (s *MerchantService) UpdateProductRouting(catalogID uint, routing ProductRoutingConfig, actorUserID uint) error { switch routing.RoutingMode { case RoutingModePriority, RoutingModeLewan, RoutingModeDLC, RoutingModeRock: default: return errors.New("routing_mode 仅支持 priority / lewan / dlc / rock") } if routing.RoutingMode == RoutingModeRock { routing.Priority = []string{ProviderRock} } if routing.RoutingMode == RoutingModePriority { if len(routing.Priority) == 0 { routing.Priority = []string{ProviderLewan, ProviderDLC} } for _, name := range routing.Priority { if name != ProviderLewan && name != ProviderDLC { return errors.New("priority 仅支持 lewan / dlc") } } } if routing.RoutingMode == RoutingModeLewan || routing.RoutingMode == RoutingModePriority { if strings.TrimSpace(routing.LewanProductID) == "" { return errors.New("乐玩渠道需配置 lewan_product_id") } } if routing.DeliveryType != "" && routing.DeliveryType != "LINK_BINDING" && routing.DeliveryType != "EMAIL_BINDING" { return errors.New("delivery_type 仅支持 LINK_BINDING / EMAIL_BINDING") } configBytes, err := json.Marshal(routing) if err != nil { return err } configStr := string(configBytes) return s.db.Transaction(func(tx *gorm.DB) error { var item model.MerchantProduct if err := tx.Preload("Product").Where("id = ?", catalogID).First(&item).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return errors.New("目录商品不存在") } return err } // 更新自营模板 if err := tx.Model(&model.MerchantProduct{}).Where("id = ?", item.ID).Update("fulfillment_config", configStr).Error; err != nil { return err } // 同步更新所有商户同 SKU 商品的履约配置,保证全平台生效 if item.SKU != "" { _ = tx.Model(&model.MerchantProduct{}).Where("sku = ?", item.SKU).Update("fulfillment_config", configStr).Error } return writeAudit(tx, &item.MerchantID, &actorUserID, nil, "catalog.routing.update", "merchant_product", fmt.Sprint(catalogID), map[string]string{ "sku": item.SKU, "routing_mode": routing.RoutingMode, "priority": strings.Join(routing.Priority, ","), }) }) } type SyncLewanProductInput struct { LewanProductID string `json:"lewan_product_id"` Name string `json:"name"` Category string `json:"category"` SKU string `json:"sku"` Price int64 `json:"price"` DeliveryType string `json:"delivery_type"` } // SyncLewanProductToCatalog 将乐玩商品关联到英文主 sku 目录。 // - 禁止新建 LEWAN_* 镜像 sku // - sku 为空时按 display_name 匹配已有主商品并挂接乐玩履约 // - 新商品必须显式传入英文 sku func (s *MerchantService) SyncLewanProductToCatalog(in SyncLewanProductInput, actorUserID uint) (*ProductCatalogItem, error) { if in.LewanProductID == "" { return nil, errors.New("乐玩商品ID不能为空") } if in.Name == "" { return nil, errors.New("商品名称不能为空") } var res *ProductCatalogItem err := s.db.Transaction(func(tx *gorm.DB) error { var selfMerchant model.Merchant if err := tx.Where("code = ?", model.MerchantCodeSelfOperated).First(&selfMerchant).Error; err != nil { return errors.New("自营商户未初始化") } sku := strings.TrimSpace(in.SKU) if IsLegacyLewanMirrorSKU(sku) { return errors.New("禁止使用 LEWAN_ 前缀 sku,请填写英文主商品 sku,或留空以按中文名关联已有商品") } var mp model.MerchantProduct if sku == "" { // 按中文名关联已有主商品(排除 LEWAN_*) if err := tx.Where( "merchant_id = ? AND display_name = ? AND sku NOT ILIKE ? AND status = ?", selfMerchant.ID, in.Name, "lewan_%", model.ProductStatusActive, ).Order("id ASC").First(&mp).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return errors.New("未找到同名英文主商品,请填写英文 sku 新建(如 suit_xxx / pack_xxx)") } return err } sku = mp.SKU } else { if err := tx.Where("merchant_id = ? AND sku = ?", selfMerchant.ID, sku).First(&mp).Error; err != nil { if !errors.Is(err, gorm.ErrRecordNotFound) { return err } mp = model.MerchantProduct{} } } var product model.Product if mp.ID > 0 { if err := tx.Where("id = ?", mp.ProductID).First(&product).Error; err != nil { return err } } else if err := tx.Where("code = ?", sku).First(&product).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { product = model.Product{ Code: sku, Name: in.Name, Category: in.Category, Status: model.ProductStatusActive, } if err := tx.Create(&product).Error; err != nil { return err } } else { return err } } routing := ProductRoutingConfig{ RoutingMode: RoutingModeLewan, Priority: []string{ProviderLewan, ProviderDLC}, LewanProductID: in.LewanProductID, LewanName: in.Name, DeliveryType: in.DeliveryType, } if routing.DeliveryType == "" { routing.DeliveryType = "LINK_BINDING" } if routing.DeliveryType != "LINK_BINDING" && routing.DeliveryType != "EMAIL_BINDING" { return errors.New("delivery_type 仅支持 LINK_BINDING / EMAIL_BINDING") } if builtinDLC := deliveryGoodID("dlc", sku); builtinDLC != "" { routing.DLCGoodID = builtinDLC routing.RoutingMode = RoutingModePriority } // 保留已有 dlc_good_id(若原配置有) if mp.ID > 0 { if existing := ParseProductRoutingConfig(mp.FulfillmentConfig, sku); existing.DLCGoodID != "" && routing.DLCGoodID == "" { routing.DLCGoodID = existing.DLCGoodID routing.RoutingMode = RoutingModePriority } } configBytes, _ := json.Marshal(routing) configStr := string(configBytes) if mp.ID == 0 { price := in.Price if price <= 0 { price = 1000 } mp = model.MerchantProduct{ MerchantID: selfMerchant.ID, ProductID: product.ID, SKU: sku, DisplayName: in.Name, PriceAmount: price, CostAmount: price, Currency: "POINT", Stock: -1, Status: model.ProductStatusActive, FulfillmentConfig: configStr, } if err := tx.Create(&mp).Error; err != nil { return err } } else { updates := map[string]interface{}{ "fulfillment_config": configStr, } if in.Price > 0 { updates["price_amount"] = in.Price updates["cost_amount"] = in.Price } if err := tx.Model(&mp).Updates(updates).Error; err != nil { return err } // 同步所有商户同 SKU 的履约配置 _ = tx.Model(&model.MerchantProduct{}).Where("sku = ?", sku).Update("fulfillment_config", configStr).Error } res = &ProductCatalogItem{ ID: mp.ID, ProductID: product.ID, SKU: sku, DisplayName: fallbackName(mp.DisplayName, in.Name), Category: product.Category, PriceAmount: mp.PriceAmount, CostAmount: mp.CostAmount, Currency: mp.Currency, Stock: mp.Stock, Status: mp.Status, FulfillmentConfig: configStr, Routing: routing, } return writeAudit(tx, &selfMerchant.ID, &actorUserID, nil, "catalog.lewan.sync", "merchant_product", fmt.Sprint(mp.ID), map[string]string{ "lewan_product_id": in.LewanProductID, "sku": sku, }) }) if err != nil { return nil, err } return res, nil } // BatchSyncLewanProductsToCatalog 批量导入或关联乐玩商品至平台自营商品目录 func (s *MerchantService) BatchSyncLewanProductsToCatalog(items []SyncLewanProductInput, actorUserID uint) (int, error) { if len(items) == 0 { return 0, nil } count := 0 for _, item := range items { if _, err := s.SyncLewanProductToCatalog(item, actorUserID); err == nil { count++ } } return count, nil }