Files
yml2213 44ec142395 style: 清理 gofmt 偏差
纯对齐与行尾空行调整,无逻辑变更。gofmt -l 现输出为空,
配合 make fmt 可将格式检查直接纳入 CI。
2026-10-06 19:43:43 +08:00

510 lines
17 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}