feat: 导入上游商品时优先沿用上游 slug
This commit is contained in:
1 parent
291a2ae2b6
commit
5bd0ce93d6
4 files changed
+123
-2
No files matched your search
@@ -6,6 +6,8 @@ import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
mappingdomain "github.com/dujiao-next/internal/modules/catalog/mapping/domain"
|
||||
@@ -844,3 +846,98 @@ func TestImportUpstreamProductRejectsInactive(t *testing.T) {
|
||||
t.Fatalf("expected no local product created when import rejected, got %d", productCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestImportUpstreamProductPrefersUpstreamSlug(t *testing.T) {
|
||||
db, err := gorm.Open(sqlite.Open("file:import_upstream_slug?mode=memory&cache=shared"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite failed: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(
|
||||
&categorydomain.Category{},
|
||||
&productdomain.Product{},
|
||||
&productdomain.ProductSKU{},
|
||||
&siteconnectiondomain.Connection{},
|
||||
&mappingdomain.Mapping{},
|
||||
&mappingdomain.SKUMapping{},
|
||||
); err != nil {
|
||||
t.Fatalf("auto migrate failed: %v", err)
|
||||
}
|
||||
categoryRepo := categorygormstore.NewCategoryStore(db)
|
||||
if err := categoryRepo.Create(&categorydomain.Category{Slug: "c", NameJSON: jsonmap.JSON{"zh-CN": "C"}}); err != nil {
|
||||
t.Fatalf("create category failed: %v", err)
|
||||
}
|
||||
// 已软删除的商品仍占用唯一索引,导入必须避开
|
||||
if err := db.Exec("INSERT INTO products (category_id, slug, title_json, price_amount, deleted_at) VALUES (1, 'taken', '{}', 0, CURRENT_TIMESTAMP)").Error; err != nil {
|
||||
t.Fatalf("seed deleted product failed: %v", err)
|
||||
}
|
||||
|
||||
upstreamSlugs := map[string]string{
|
||||
"/api/v1/upstream/products/1": "telegram-asia-iq964",
|
||||
"/api/v1/upstream/products/2": "taken",
|
||||
"/api/v1/upstream/products/3": "Bad Slug!",
|
||||
"/api/v1/upstream/products/4": "",
|
||||
}
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
id, _ := strconv.Atoi(strings.TrimPrefix(r.URL.Path, "/api/v1/upstream/products/"))
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"ok": true,
|
||||
"product": upstream.UpstreamProduct{
|
||||
ID: uint(id),
|
||||
Slug: upstreamSlugs[r.URL.Path],
|
||||
Title: jsonmap.JSON{"zh-CN": "P"},
|
||||
PriceAmount: "10.00",
|
||||
IsActive: true,
|
||||
},
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
connService := siteconnectionapp.NewService(siteconnectiongormstore.New(db), "test-secret-key", t.TempDir())
|
||||
conn, err := connService.Create(siteconnectionapp.CreateInput{
|
||||
Name: "u", BaseURL: server.URL, ApiKey: "k", ApiSecret: "s",
|
||||
Protocol: constants.ConnectionProtocolDujiaoNext,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create connection failed: %v", err)
|
||||
}
|
||||
productRepo := productgormstore.NewProductStore(db)
|
||||
svc, err := newTestMappingService(
|
||||
mappinggormstore.NewMappingStore(db),
|
||||
mappinggormstore.NewSKUMappingStore(db),
|
||||
productRepo,
|
||||
productgormstore.NewSKUStore(db),
|
||||
categoryRepo,
|
||||
connService,
|
||||
noopLocalMediaRecorder{},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("create product mapping service: %v", err)
|
||||
}
|
||||
|
||||
cases := []struct {
|
||||
upstreamID uint
|
||||
want string
|
||||
}{
|
||||
{1, "telegram-asia-iq964"},
|
||||
{2, "taken-2"},
|
||||
{3, ""}, // 非法 slug → 回退自动生成
|
||||
{4, ""}, // 未提供 slug → 回退自动生成
|
||||
}
|
||||
for _, tc := range cases {
|
||||
mapping, err := svc.ImportUpstreamProduct(conn.ID, tc.upstreamID, 1, "")
|
||||
if err != nil {
|
||||
t.Fatalf("import upstream %d failed: %v", tc.upstreamID, err)
|
||||
}
|
||||
var product productdomain.Product
|
||||
if err := db.First(&product, mapping.LocalProductID).Error; err != nil {
|
||||
t.Fatalf("load product failed: %v", err)
|
||||
}
|
||||
if tc.want != "" && product.Slug != tc.want {
|
||||
t.Fatalf("upstream %d: expected slug %q, got %q", tc.upstreamID, tc.want, product.Slug)
|
||||
}
|
||||
if tc.want == "" && !strings.HasPrefix(product.Slug, "upstream-") {
|
||||
t.Fatalf("upstream %d: expected fallback slug, got %q", tc.upstreamID, product.Slug)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,25 @@ import (
|
||||
"github.com/shopspring/decimal"
|
||||
)
|
||||
|
||||
var importSlugPattern = regexp.MustCompile(`^[a-z0-9]+(?:-[a-z0-9]+)*$`)
|
||||
|
||||
// pickImportSlug 依次尝试 上游 slug → slug-上游ID → upstream-连接ID-上游ID-毫秒时间戳。
|
||||
func (s *Service) pickImportSlug(upstreamSlug string, connectionID, upstreamProductID uint) (string, error) {
|
||||
upstreamSlug = strings.ToLower(strings.TrimSpace(upstreamSlug))
|
||||
if len(upstreamSlug) <= 100 && importSlugPattern.MatchString(upstreamSlug) {
|
||||
for _, candidate := range []string{upstreamSlug, fmt.Sprintf("%s-%d", upstreamSlug, upstreamProductID)} {
|
||||
count, err := s.products.CountBySlug(candidate, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if count == 0 {
|
||||
return candidate, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
return fmt.Sprintf("upstream-%d-%d-%d", connectionID, upstreamProductID, time.Now().UnixMilli()), nil
|
||||
}
|
||||
|
||||
// ImportUpstreamProduct 从上游导入商品(克隆为本地商品 + 建立映射)
|
||||
func (s *Service) ImportUpstreamProduct(connectionID uint, upstreamProductID uint, categoryID uint, slug string) (*mappingdomain.Mapping, error) {
|
||||
return s.importUpstreamProduct(connectionID, upstreamProductID, categoryID, slug, false, nil)
|
||||
@@ -136,9 +155,12 @@ func (s *Service) importUpstreamProduct(connectionID uint, upstreamProductID uin
|
||||
}
|
||||
}
|
||||
|
||||
// 自动生成 slug(如果未提供)
|
||||
// 未指定 slug 时优先沿用上游 slug,不可用再回退自动生成
|
||||
if slug == "" {
|
||||
slug = fmt.Sprintf("upstream-%d-%d-%d", connectionID, upstreamProductID, time.Now().UnixMilli())
|
||||
slug, err = s.pickImportSlug(upProduct.Slug, connectionID, upstreamProductID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
// 创建本地商品
|
||||
|
||||
@@ -47,6 +47,7 @@ type ProductRepository interface {
|
||||
GetByID(id string) (*productdomain.Product, error)
|
||||
Update(item *productdomain.Product) error
|
||||
QuickUpdate(id string, fields map[string]interface{}) error
|
||||
CountBySlug(slug string, excludeID *string) (int64, error)
|
||||
}
|
||||
|
||||
// SKURepository 是映射上下文所需的最小本地 SKU 端口。
|
||||
|
||||
@@ -54,6 +54,7 @@ type ProductListResult struct {
|
||||
// UpstreamProduct 上游商品信息
|
||||
type UpstreamProduct struct {
|
||||
ID uint `json:"id"`
|
||||
Slug string `json:"slug"`
|
||||
SeoMeta jsonmap.JSON `json:"seo_meta"`
|
||||
Title jsonmap.JSON `json:"title"`
|
||||
Description jsonmap.JSON `json:"description"`
|
||||
|
||||
Reference in new issue
Block a user