feat: 平台管理员可在前端分配商品给商户

后端:
- service: 新增 ListProductCatalog(自营商户商品目录)、ListMerchantProductsByAdmin、AssignProducts(按目录ID批量同步商户商品,未勾选的已有商品会被移除)
- handler + router: 新增 GET /platform/product-catalog、GET /platform/merchants/:id/products、POST /platform/merchants/:id/products/assign 三个管理员路由

前端:
- types: 新增 ProductCatalogItem 类型
- api: platformApi 新增 productCatalog / merchantProducts / assignProducts
- PlatformMerchants: 商户列表操作列新增「分配商品」按钮,弹窗内以表格+复选框展示平台商品目录,支持全选/清空/重置,打开时按已有商品预选,保存后同步分配结果

实测:创建商户自动获得27个默认商品,管理员可随时增减调整
This commit is contained in:
yml2213
2026-07-30 15:20:02 +08:00
parent 7cff2dffed
commit 502b719f48
6 changed files with 376 additions and 9 deletions
+43
View File
@@ -329,6 +329,49 @@ func (h *MerchantHandler) ListPlatformMerchants(c *gin.Context) {
response.Page(c, list, total, page, size)
}
// ListProductCatalog 返回自营商户的全部可售商品,作为平台默认商品目录供分配。
func (h *MerchantHandler) ListProductCatalog(c *gin.Context) {
list, err := h.merchantSvc.ListProductCatalog()
if err != nil {
response.ServerError(c, err.Error())
return
}
response.OK(c, list)
}
// ListMerchantProductsByAdmin 供平台管理员查看指定商户的可售商品。
func (h *MerchantHandler) ListMerchantProductsByAdmin(c *gin.Context) {
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
list, err := h.merchantSvc.ListMerchantProductsByAdmin(uint(id))
if err != nil {
response.ServerError(c, err.Error())
return
}
response.OK(c, list)
}
type assignProductsReq struct {
CatalogIDs []uint `json:"catalog_ids"`
}
// AssignProducts 按商品目录 ID 批量同步商户的可售商品。
func (h *MerchantHandler) AssignProducts(c *gin.Context) {
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
var req assignProductsReq
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "参数错误")
return
}
count, err := h.merchantSvc.AssignProducts(uint(id), service.AssignProductsInput{
CatalogIDs: req.CatalogIDs,
}, middleware.GetUserID(c))
if err != nil {
response.BadRequest(c, err.Error())
return
}
response.OK(c, gin.H{"assigned": count})
}
type createMerchantReq struct {
Code string `json:"code" binding:"required"`
Name string `json:"name" binding:"required"`
+8 -5
View File
@@ -111,13 +111,16 @@ func Setup(h *Handlers) *gin.Engine {
admin := auth.Group("")
admin.Use(middleware.RequireRole(model.RoleAdmin))
{
admin.GET("/users", h.User.List)
admin.POST("/users", h.User.Create)
admin.GET("/users", h.User.List)
admin.POST("/users", h.User.Create)
admin.PATCH("/users/:id/status", h.User.UpdateStatus)
admin.GET("/platform/merchants", h.Merchant.ListPlatformMerchants)
admin.POST("/platform/merchants", h.Merchant.CreateMerchant)
admin.PATCH("/platform/merchants/:id", h.Merchant.UpdateMerchantSettings)
admin.POST("/platform/merchants/:id/members", h.Merchant.AddPlatformMerchantMember)
admin.POST("/platform/merchants", h.Merchant.CreateMerchant)
admin.PATCH("/platform/merchants/:id", h.Merchant.UpdateMerchantSettings)
admin.POST("/platform/merchants/:id/members", h.Merchant.AddPlatformMerchantMember)
admin.GET("/platform/product-catalog", h.Merchant.ListProductCatalog)
admin.GET("/platform/merchants/:id/products", h.Merchant.ListMerchantProductsByAdmin)
admin.POST("/platform/merchants/:id/products/assign", h.Merchant.AssignProducts)
}
}
}
+177
View File
@@ -535,6 +535,183 @@ func (s *MerchantService) UpdateAPIClientStatus(merchantID, id uint, status stri
})
}
// ProductCatalogItem 是商品目录(自营商户可售商品)的展示项,供平台管理员分配商品时勾选。
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 保证幂等:即使重复调用也不会报唯一索引冲突。