510 lines
17 KiB
Go
510 lines
17 KiB
Go
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
|
||
}
|