diff --git a/server/app/goauto/access/purchaser.go b/server/app/goauto/access/purchaser.go index dfb40f2..958e12a 100644 --- a/server/app/goauto/access/purchaser.go +++ b/server/app/goauto/access/purchaser.go @@ -47,6 +47,7 @@ var AdminAPIs = []APIPermission{ {"预览尺码匹配", "/api/admin/v1/shopee-products/:productId/specs/mapping/preview-auto-size", "POST", true}, {"AI 建议颜色映射", "/api/admin/v1/shopee-products/:productId/specs/mapping/suggest-colors", "POST", true}, {"AI 建议尺码映射", "/api/admin/v1/shopee-products/:productId/specs/mapping/suggest-sizes", "POST", true}, + {"一键匹配并确认颜色尺码", "/api/admin/v1/shopee-products/:productId/specs/mapping/auto-match", "POST", true}, {"查看 SYB 商品", "/api/admin/v1/syb-products", "GET", true}, {"查看 SYB 商品详情", "/api/admin/v1/syb-products/:productId", "GET", true}, diff --git a/server/app/goauto/access/purchaser_test.go b/server/app/goauto/access/purchaser_test.go index 30f8125..7cd208f 100644 --- a/server/app/goauto/access/purchaser_test.go +++ b/server/app/goauto/access/purchaser_test.go @@ -62,3 +62,12 @@ func TestPurchaserMayRunBatchSpecMatch(t *testing.T) { } t.Fatal("missing purchaser batch spec match permission") } + +func TestPurchaserMayAutoMatchShopeeProductSpecs(t *testing.T) { + for _, permission := range PurchaserAPIs() { + if permission.Method == "POST" && permission.Path == "/api/admin/v1/shopee-products/:productId/specs/mapping/auto-match" { + return + } + } + t.Fatal("missing purchaser shopee auto-match permission") +} diff --git a/server/app/goauto/shopeeproduct/auto_match.go b/server/app/goauto/shopeeproduct/auto_match.go new file mode 100644 index 0000000..91998fe --- /dev/null +++ b/server/app/goauto/shopeeproduct/auto_match.go @@ -0,0 +1,303 @@ +package shopeeproduct + +import ( + "context" + "errors" + "strings" + + "go-admin/app/goauto/aimatching" + "go-admin/app/goauto/models" + + "github.com/google/uuid" + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +type AutoMatchRequest struct { + RequestID string `json:"requestId"` + SpecContextVersion string `json:"specContextVersion"` +} + +type AutoMatchItem struct { + Dimension string `json:"dimension"` + Role string `json:"role"` + ValueName string `json:"valueName"` + PDDValue string `json:"pddValue,omitempty"` + Status string `json:"status"` // confirmed | preserved | unmatched + Source string `json:"source,omitempty"` + Confidence *float64 `json:"confidence,omitempty"` + Reason string `json:"reason,omitempty"` +} + +type AutoMatchResponse struct { + Product ProductView `json:"product"` + Items []AutoMatchItem `json:"items"` + ConfirmedCount int `json:"confirmedCount"` + PreservedCount int `json:"preservedCount"` + UnmatchedCount int `json:"unmatchedCount"` + Replayed bool `json:"replayed"` +} + +type autoMatchRef struct { + dimension string + valueName string +} + +// AutoMatchMappings resolves both color and size values, then persists every +// accepted result as confirmed in one transaction. Provider calls happen +// before the transaction; the transaction rechecks the complete spec context +// and PDD candidate set so a stale decision can never be written. +func (service *Service) AutoMatchMappings(ctx context.Context, id uint64, request AutoMatchRequest) (AutoMatchResponse, error) { + requestID := strings.TrimSpace(request.RequestID) + if _, err := uuid.Parse(requestID); err != nil { + return AutoMatchResponse{}, invalidRequest("requestId 必须是 UUID") + } + requestVersion := strings.TrimSpace(request.SpecContextVersion) + if requestVersion == "" { + return AutoMatchResponse{}, contextVersionStale() + } + + db := service.DB.WithContext(ctx) + var replay models.ShopeeProduct + if err := db.Where("id = ? AND last_update_request_id = ?", id, requestID).First(&replay).Error; err == nil { + return service.autoMatchReplay(ctx, id) + } else if !errors.Is(err, gorm.ErrRecordNotFound) { + return AutoMatchResponse{}, internalError(err) + } + + var shopee models.ShopeeProduct + if err := db.First(&shopee, id).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return AutoMatchResponse{}, productNotFound() + } + return AutoMatchResponse{}, internalError(err) + } + if shopee.PDDProductID == nil { + return AutoMatchResponse{}, &ServiceError{Code: CodePDDProductNotFound, Message: "请先关联 PDD 商品"} + } + var pdd models.PDDProduct + if err := db.First(&pdd, *shopee.PDDProductID).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return AutoMatchResponse{}, &ServiceError{Code: CodePDDProductNotFound, Message: "关联的 PDD 商品不存在"} + } + return AutoMatchResponse{}, internalError(err) + } + if computeSpecContextVersion(shopee.PDDProductID, shopee.SpecsJSON, pdd.SpecsJSON) != requestVersion { + return AutoMatchResponse{}, contextVersionStale() + } + initialSpecs, err := Unmarshal(shopee.SpecsJSON) + if err != nil { + return AutoMatchResponse{}, internalError(err) + } + + items := make([]AutoMatchItem, 0) + for _, role := range []string{RoleColor, RoleSize} { + refs := autoMatchRefs(initialSpecs, role) + if len(refs) == 0 { + continue + } + preview, err := service.suggestMappings(ctx, id, requestVersion, role) + if err != nil { + return AutoMatchResponse{}, err + } + if len(preview.Items) != len(refs) { + return AutoMatchResponse{}, internalError(errors.New("AI 匹配结果与规格输入数量不一致")) + } + for index, suggestion := range preview.Items { + item := AutoMatchItem{ + Dimension: refs[index].dimension, + Role: role, + ValueName: refs[index].valueName, + PDDValue: suggestion.PDDValue, + Confidence: suggestion.Confidence, + Reason: strings.TrimSpace(suggestion.Reason), + Status: "unmatched", + } + switch suggestion.Status { + case "preserved": + item.Status = "preserved" + case "matched": + item.Status = "confirmed" + item.Source = MappingSourceExactMatch + case "ai_matched": + if suggestion.Confidence != nil && item.Reason != "" { + item.Status = "confirmed" + item.Source = MappingSourceAIMatch + } + } + items = append(items, item) + } + } + + replayed := false + err = db.Transaction(func(tx *gorm.DB) error { + var current models.ShopeeProduct + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(¤t, id).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return productNotFound() + } + return internalError(err) + } + if current.LastUpdateRequestID != nil && *current.LastUpdateRequestID == requestID { + replayed = true + return nil + } + if current.PDDProductID == nil || *current.PDDProductID != pdd.ID { + return contextVersionStale() + } + var currentPDD models.PDDProduct + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(¤tPDD, pdd.ID).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return contextVersionStale() + } + return internalError(err) + } + if computeSpecContextVersion(current.PDDProductID, current.SpecsJSON, currentPDD.SpecsJSON) != requestVersion { + return contextVersionStale() + } + + currentSpecs, err := Unmarshal(current.SpecsJSON) + if err != nil { + return internalError(err) + } + candidates := map[string]map[string]bool{} + for _, role := range []string{RoleColor, RoleSize} { + values, err := selectablePDDValues(currentPDD.SpecsJSON, role) + if err != nil { + return internalError(err) + } + candidates[role] = values + } + + needsAISettings := false + for _, item := range items { + if item.Status == "confirmed" && item.Source == MappingSourceAIMatch { + needsAISettings = true + break + } + } + if needsAISettings { + settings, err := aimatching.NewService(tx).Settings(ctx) + if err != nil { + return internalError(err) + } + if !settings.Enabled { + return aiUnavailable("AI 匹配未启用,请先在设置中配置并启用") + } + for index := range items { + item := &items[index] + if item.Status == "confirmed" && item.Source == MappingSourceAIMatch && + (item.Confidence == nil || *item.Confidence < settings.AutoConfirmMinConfidence || strings.TrimSpace(item.Reason) == "") { + item.Status, item.Source = "unmatched", "" + } + } + } + + plans := make(map[string]AutoMatchItem, len(items)) + for _, item := range items { + plans[item.Dimension+"\x00"+item.ValueName] = item + } + for dimensionIndex, dimension := range currentSpecs { + if dimension.Role != RoleColor && dimension.Role != RoleSize { + continue + } + for valueIndex, value := range dimension.Values { + item, ok := plans[dimension.Name+"\x00"+value.Name] + if !ok || item.Status != "confirmed" { + continue + } + if !candidates[dimension.Role][item.PDDValue] { + return contextVersionStale() + } + currentSpecs[dimensionIndex].Values[valueIndex].Mapping = &Mapping{ + PDDValue: item.PDDValue, Source: item.Source, Status: MappingStatusConfirmed, + Confidence: item.Confidence, Reason: item.Reason, + } + } + } + if err := Validate(currentSpecs); err != nil { + return invalidRequest(err.Error()) + } + specsJSON, err := Marshal(currentSpecs) + if err != nil { + return internalError(err) + } + if err := tx.Model(&models.ShopeeProduct{}).Where("id = ?", id).Updates(map[string]any{ + "specs_json": specsJSON, "last_update_request_id": requestID, + }).Error; err != nil { + return internalError(err) + } + if err := syncReplacementItemAfterManualEdit(tx, id, currentSpecs); err != nil { + return internalError(err) + } + return nil + }) + if err != nil { + return AutoMatchResponse{}, err + } + if replayed { + return service.autoMatchReplay(ctx, id) + } + response, err := service.autoMatchResponse(ctx, id, items) + if err != nil { + return AutoMatchResponse{}, err + } + return response, nil +} + +func autoMatchRefs(specs []SpecDimension, role string) []autoMatchRef { + refs := make([]autoMatchRef, 0) + for _, dimension := range specs { + if dimension.Role != role { + continue + } + for _, value := range dimension.Values { + refs = append(refs, autoMatchRef{dimension: dimension.Name, valueName: value.Name}) + } + } + return refs +} + +func (service *Service) autoMatchResponse(ctx context.Context, id uint64, items []AutoMatchItem) (AutoMatchResponse, error) { + detail, err := service.Detail(ctx, id) + if err != nil { + return AutoMatchResponse{}, err + } + response := AutoMatchResponse{Product: detail.Product, Items: items} + for _, item := range items { + switch item.Status { + case "confirmed": + response.ConfirmedCount++ + case "preserved": + response.PreservedCount++ + default: + response.UnmatchedCount++ + } + } + return response, nil +} + +func (service *Service) autoMatchReplay(ctx context.Context, id uint64) (AutoMatchResponse, error) { + detail, err := service.Detail(ctx, id) + if err != nil { + return AutoMatchResponse{}, err + } + response := AutoMatchResponse{Product: detail.Product, Items: []AutoMatchItem{}, Replayed: true} + for _, dimension := range detail.Product.Specs { + if dimension.Role != RoleColor && dimension.Role != RoleSize { + continue + } + for _, value := range dimension.Values { + item := AutoMatchItem{Dimension: dimension.Name, Role: dimension.Role, ValueName: value.Name, Status: "unmatched"} + if value.Mapping != nil && value.Mapping.Status == MappingStatusConfirmed { + item.PDDValue, item.Status, item.Source = value.Mapping.PDDValue, "preserved", value.Mapping.Source + item.Confidence, item.Reason = value.Mapping.Confidence, value.Mapping.Reason + response.PreservedCount++ + } else { + response.UnmatchedCount++ + } + response.Items = append(response.Items, item) + } + } + return response, nil +} diff --git a/server/app/goauto/shopeeproduct/auto_match_test.go b/server/app/goauto/shopeeproduct/auto_match_test.go new file mode 100644 index 0000000..46bfaa1 --- /dev/null +++ b/server/app/goauto/shopeeproduct/auto_match_test.go @@ -0,0 +1,163 @@ +package shopeeproduct + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "sync/atomic" + "testing" + + "go-admin/app/goauto/models" + + "github.com/google/uuid" +) + +func TestAutoMatchMappingsConfirmsColorAndSizeInOneRequest(t *testing.T) { + provider := httptest.NewServer(chatCompletionResponder(`{"suggestions":[{"sourceId":"s1","candidateId":"c1","confidence":0.96,"reason":"颜色含义唯一一致"}]}`)) + defer provider.Close() + + db := openTestDB(t) + pdd := seedPDDProduct(t, db, "active") + setting := seedEnabledAISetting(t, provider.URL, 0.9) + if err := db.Create(&setting).Error; err != nil { + t.Fatal(err) + } + service := NewService(db) + created, err := service.Create(context.Background(), CreateRequest{ + RequestID: uuid.NewString(), ShopeeItemID: "SP-AUTO-BOTH", PDDProductID: &pdd.ID, + Specs: []SpecDimension{ + {Name: "颜色", Role: RoleColor, Values: []SpecValue{{Name: "深黑", Source: ValueSourceImport}}}, + {Name: "尺码", Role: RoleSize, Values: []SpecValue{{Name: " xl ", Source: ValueSourceImport}}}, + }, + }) + if err != nil { + t.Fatal(err) + } + requestID := uuid.NewString() + response, err := service.AutoMatchMappings(context.Background(), created.Product.ID, AutoMatchRequest{RequestID: requestID, SpecContextVersion: created.Product.SpecContextVersion}) + if err != nil { + t.Fatalf("auto match: %v", err) + } + if response.ConfirmedCount != 2 || response.PreservedCount != 0 || response.UnmatchedCount != 0 { + t.Fatalf("unexpected counts: %+v", response) + } + color := response.Product.Specs[0].Values[0].Mapping + size := response.Product.Specs[1].Values[0].Mapping + if color == nil || color.Status != MappingStatusConfirmed || color.Source != MappingSourceAIMatch || color.PDDValue != "黑色" { + t.Fatalf("color not auto-confirmed: %+v", color) + } + if size == nil || size.Status != MappingStatusConfirmed || size.Source != MappingSourceExactMatch || size.PDDValue != "XL" { + t.Fatalf("size not auto-confirmed: %+v", size) + } + + replayed, err := service.AutoMatchMappings(context.Background(), created.Product.ID, AutoMatchRequest{RequestID: requestID, SpecContextVersion: created.Product.SpecContextVersion}) + if err != nil || !replayed.Replayed || replayed.PreservedCount != 2 { + t.Fatalf("idempotent replay failed: response=%+v err=%v", replayed, err) + } +} + +func TestAutoMatchMappingsLeavesLowConfidenceUnmatched(t *testing.T) { + provider := httptest.NewServer(chatCompletionResponder(`{"suggestions":[{"sourceId":"s1","candidateId":"c1","confidence":0.4,"reason":"只能判断为相近颜色"}]}`)) + defer provider.Close() + + db := openTestDB(t) + pdd := seedPDDProduct(t, db, "active") + setting := seedEnabledAISetting(t, provider.URL, 0.9) + if err := db.Create(&setting).Error; err != nil { + t.Fatal(err) + } + service := NewService(db) + created, err := service.Create(context.Background(), CreateRequest{ + RequestID: uuid.NewString(), ShopeeItemID: "SP-AUTO-LOW", PDDProductID: &pdd.ID, + Specs: []SpecDimension{ + {Name: "颜色", Role: RoleColor, Values: []SpecValue{{Name: "浅黑", Source: ValueSourceImport}}}, + {Name: "尺码", Role: RoleSize, Values: []SpecValue{{Name: "XL", Source: ValueSourceImport}}}, + }, + }) + if err != nil { + t.Fatal(err) + } + response, err := service.AutoMatchMappings(context.Background(), created.Product.ID, AutoMatchRequest{RequestID: uuid.NewString(), SpecContextVersion: created.Product.SpecContextVersion}) + if err != nil { + t.Fatal(err) + } + if response.ConfirmedCount != 1 || response.UnmatchedCount != 1 || response.Product.Specs[0].Values[0].Mapping != nil { + t.Fatalf("low confidence color was persisted: %+v", response) + } + if response.Product.Specs[1].Values[0].Mapping == nil || response.Product.Specs[1].Values[0].Mapping.Status != MappingStatusConfirmed { + t.Fatalf("deterministic size was not confirmed: %+v", response.Product.Specs[1]) + } +} + +func TestAutoMatchMappingsProviderFailureDoesNotWrite(t *testing.T) { + provider := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + http.Error(w, "unavailable", http.StatusBadGateway) + })) + defer provider.Close() + + db := openTestDB(t) + pdd := seedPDDProduct(t, db, "active") + setting := seedEnabledAISetting(t, provider.URL, 0.9) + if err := db.Create(&setting).Error; err != nil { + t.Fatal(err) + } + service := NewService(db) + created, err := service.Create(context.Background(), CreateRequest{RequestID: uuid.NewString(), ShopeeItemID: "SP-AUTO-FAIL", PDDProductID: &pdd.ID, Specs: []SpecDimension{{Name: "颜色", Role: RoleColor, Values: []SpecValue{{Name: "深黑", Source: ValueSourceImport}}}}}) + if err != nil { + t.Fatal(err) + } + if _, err := service.AutoMatchMappings(context.Background(), created.Product.ID, AutoMatchRequest{RequestID: uuid.NewString(), SpecContextVersion: created.Product.SpecContextVersion}); err == nil || errCode(t, err) != CodeAIUnavailable { + t.Fatalf("expected AI unavailable, got %v", err) + } + detail, err := service.Detail(context.Background(), created.Product.ID) + if err != nil || detail.Product.Specs[0].Values[0].Mapping != nil { + t.Fatalf("provider failure changed mappings: detail=%+v err=%v", detail, err) + } +} + +func TestAutoMatchMappingsRejectsContextDriftBeforeAtomicWrite(t *testing.T) { + db := openTestDB(t) + pdd := seedPDDProduct(t, db, "active") + var calls atomic.Int32 + provider := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + call := calls.Add(1) + if call == 2 { + if err := db.Model(&models.PDDProduct{}).Where("id = ?", pdd.ID).Update("specs_json", `[{"name":"颜色","role":"color","values":[{"name":"黑色","selectable":true},{"name":"灰色","selectable":true}]},{"name":"尺码","role":"size","values":[{"name":"M","selectable":true}]}]`).Error; err != nil { + t.Fatal(err) + } + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"choices": []map[string]any{{"message": map[string]any{"content": `{"suggestions":[{"sourceId":"s1","candidateId":"c1","confidence":0.98,"reason":"唯一可靠候选"}]}`}}}}) + })) + defer provider.Close() + setting := seedEnabledAISetting(t, provider.URL, 0.9) + if err := db.Create(&setting).Error; err != nil { + t.Fatal(err) + } + service := NewService(db) + created, err := service.Create(context.Background(), CreateRequest{ + RequestID: uuid.NewString(), ShopeeItemID: "SP-AUTO-STALE", PDDProductID: &pdd.ID, + Specs: []SpecDimension{ + {Name: "颜色", Role: RoleColor, Values: []SpecValue{{Name: "深黑", Source: ValueSourceImport}}}, + {Name: "尺码", Role: RoleSize, Values: []SpecValue{{Name: "小号", Source: ValueSourceImport}}}, + }, + }) + if err != nil { + t.Fatal(err) + } + if _, err := service.AutoMatchMappings(context.Background(), created.Product.ID, AutoMatchRequest{RequestID: uuid.NewString(), SpecContextVersion: created.Product.SpecContextVersion}); err == nil || errCode(t, err) != CodeSpecContextStale { + t.Fatalf("expected stale context, got %v", err) + } + detail, err := service.Detail(context.Background(), created.Product.ID) + if err != nil { + t.Fatal(err) + } + for _, dimension := range detail.Product.Specs { + for _, value := range dimension.Values { + if value.Mapping != nil { + t.Fatalf("context drift caused partial write: %+v", detail.Product.Specs) + } + } + } +} diff --git a/server/app/goauto/shopeeproduct/handler.go b/server/app/goauto/shopeeproduct/handler.go index 562dbd1..82e1e19 100644 --- a/server/app/goauto/shopeeproduct/handler.go +++ b/server/app/goauto/shopeeproduct/handler.go @@ -278,6 +278,28 @@ func (handler Handler) SuggestSizeMappings(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"code": 200, "data": response}) } +func (handler Handler) AutoMatchMappings(c *gin.Context) { + id, ok := handler.pathID(c) + if !ok { + return + } + var request AutoMatchRequest + if err := decodeJSON(c, &request); err != nil { + writeError(c, invalidRequest("请求 JSON 无效")) + return + } + service, ok := handler.service(c) + if !ok { + return + } + response, err := service.AutoMatchMappings(c.Request.Context(), id, request) + if err != nil { + writeError(c, err) + return + } + c.JSON(http.StatusOK, gin.H{"code": 200, "data": response}) +} + func (handler Handler) BatchDelete(c *gin.Context) { var request BatchDeleteRequest if err := decodeJSON(c, &request); err != nil { diff --git a/server/app/goauto/shopeeproduct/router.go b/server/app/goauto/shopeeproduct/router.go index 5f09bd7..ec5982b 100644 --- a/server/app/goauto/shopeeproduct/router.go +++ b/server/app/goauto/shopeeproduct/router.go @@ -26,4 +26,5 @@ func InitRouter(engine *gin.Engine, auth *jwt.GinJWTMiddleware) { admin.POST("/:productId/specs/mapping/preview-auto-size", handler.PreviewAutoSizeMatches) admin.POST("/:productId/specs/mapping/suggest-colors", handler.SuggestColorMappings) admin.POST("/:productId/specs/mapping/suggest-sizes", handler.SuggestSizeMappings) + admin.POST("/:productId/specs/mapping/auto-match", handler.AutoMatchMappings) } diff --git a/web/src/api/goauto/shopee-products.js b/web/src/api/goauto/shopee-products.js index e15f207..77b0983 100644 --- a/web/src/api/goauto/shopee-products.js +++ b/web/src/api/goauto/shopee-products.js @@ -66,6 +66,10 @@ export function suggestShopeeSizeMappings(productId, data) { return request({ url: `/api/admin/v1/shopee-products/${productId}/specs/mapping/suggest-sizes`, method: 'post', data, timeout: aiSuggestTimeoutMs }) } +export function autoMatchShopeeSpecMappings(productId, data) { + return request({ url: `/api/admin/v1/shopee-products/${productId}/specs/mapping/auto-match`, method: 'post', data, timeout: aiSuggestTimeoutMs }) +} + export function batchDeleteShopeeProducts(data) { return request({ url: '/api/admin/v1/shopee-products/batch-delete', method: 'post', data }) } diff --git a/web/src/views/goauto/shopee-products/ShopeeProductDetailDrawer.vue b/web/src/views/goauto/shopee-products/ShopeeProductDetailDrawer.vue index e96ec4a..1d9670a 100644 --- a/web/src/views/goauto/shopee-products/ShopeeProductDetailDrawer.vue +++ b/web/src/views/goauto/shopee-products/ShopeeProductDetailDrawer.vue @@ -23,13 +23,12 @@

该 PDD 商品当前被 {{ detail.product.sharedByPddCount }} 个虾皮商品共用,允许共用。

-

规格匹配

{{ mappingProgressText }}
- +

规格匹配

{{ mappingProgressText }}一键匹配颜色和尺码
+ -
AI 只生成未保存的建议,置信度不足不会预填,均需人工确认后保存。AI 匹配
@@ -39,7 +38,6 @@
-
先按格式统一自动匹配;未匹配的项再用 AI 生成建议,置信度不足不会预填。AI 匹配
@@ -79,7 +77,7 @@ import { ElMessage } from 'element-plus' import { getShopeeProduct, updateShopeeProduct, linkShopeeProductPdd, addShopeeSpecValue, setShopeeSpecMapping, clearShopeeSpecMapping, confirmShopeeSpecMapping, - suggestShopeeColorMappings, suggestShopeeSizeMappings + autoMatchShopeeSpecMappings } from '@/api/goauto/shopee-products' import { getPddProduct, listPddProducts } from '@/api/goauto/pdd-products' import { createRequestId } from '@/utils/request-id' @@ -107,8 +105,7 @@ export default { allSpecRows() { return this.detail.product ? this.detail.product.specs.flatMap(d => d.values) : [] }, hasMappingChanges() { return this.allSpecRows.some(row => (row.pddValueDraft || '') !== (row.originalPddValue || '')) }, mappingProgressText() { const rows = this.allSpecRows.filter(row => row.role === 'color' || row.role === 'size'); const valid = rows.filter(row => row.pddValueDraft && this.pddValueExists(row.role, row.pddValueDraft)).length; return rows.length ? `${valid}/${rows.length} 已匹配` : '暂无可匹配规格' }, - canAIMatchColor() { return this.detail.pddLoaded && this.specRows('color').length > 0 && this.pddSpecValues('color').length > 0 }, - canAIMatchSize() { return this.detail.pddLoaded && this.specRows('size').length > 0 && this.pddSpecValues('size').length > 0 } + canAutoMatch() { return this.detail.pddLoaded && !this.hasMappingChanges && ['color', 'size'].some(role => this.specRows(role).length > 0 && this.pddSpecValues(role).length > 0) } }, watch: { modelValue: { immediate: true, handler(open) { if (open) this.requestSwitch(this.productId); else this.closeInternals() } }, @@ -117,7 +114,7 @@ export default { action(value) { if (value === 'link_pdd' && this.detail.product && !this.pddPicker.open) this.openPddPicker() } }, methods: { - emptyDetail() { return { loading: false, saving: false, aiSuggesting: { color: false, size: false }, product: null, pddProduct: null, pddLoaded: false, activeSpecTab: 'color', highlightColor: '' } }, + emptyDetail() { return { loading: false, saving: false, aiMatching: false, product: null, pddProduct: null, pddLoaded: false, activeSpecTab: 'color', highlightColor: '' } }, validProductId(id) { return Number.isInteger(Number(id)) && Number(id) > 0 }, isCurrent(generation, id) { return this.modelValue && generation === this.requestGeneration && Number(this.productId) === Number(id) }, async confirmDiscard() { if (!this.hasMappingChanges) return true; return this.$confirm('当前有未保存的规格匹配,确定放弃吗?', '放弃修改?', { type: 'warning', confirmButtonText: '放弃修改', cancelButtonText: '继续编辑' }).then(() => true).catch(() => false) }, @@ -164,7 +161,7 @@ export default { markManual(row) { row.draftSource = 'manual'; row.previewReason = ''; row.draftConfidence = null }, clearDraft(row) { row.pddValueDraft = ''; row.draftSource = 'manual'; row.previewReason = ''; row.draftConfidence = null }, mappingRowClass({ row }) { return this.detail.highlightColor && row.role === 'color' && row.name === this.detail.highlightColor ? 'mapping-row-highlight' : '' }, - async previewAISuggestions(role) { const id = this.loadedProductId; const generation = this.requestGeneration; this.detail.aiSuggesting[role] = true; try { const suggest = role === 'color' ? suggestShopeeColorMappings : suggestShopeeSizeMappings; const r = await suggest(id, { specContextVersion: this.detail.product.specContextVersion }); if (!this.isCurrent(generation, id)) return; const byName = new Map(r.data.items.map(item => [item.valueName, item])); this.specRows(role).forEach(row => { const item = byName.get(row.name); if (!item) return; row.previewReason = item.reason || ''; row.draftConfidence = item.confidence ?? null; if (item.apply && item.status !== 'preserved') { row.pddValueDraft = item.pddValue || ''; row.draftSource = item.status === 'matched' ? 'exact_match' : 'ai_match' } }); const suggestedCount = r.data.items.filter(item => item.status === 'ai_suggested').length; ElMessage.success(`匹配 ${r.data.matchedCount} 项,AI 建议但置信度不足 ${suggestedCount} 项,待人工选择 ${r.data.pendingCount} 项`) } finally { if (this.isCurrent(generation, id)) this.detail.aiSuggesting[role] = false } }, + async autoMatchMappings() { const id = this.loadedProductId; const generation = this.requestGeneration; this.detail.aiMatching = true; try { const r = await autoMatchShopeeSpecMappings(id, { requestId: createRequestId(), specContextVersion: this.detail.product.specContextVersion }); if (!this.isCurrent(generation, id)) return; const confirmed = Number(r.data.confirmedCount || 0); const preserved = Number(r.data.preservedCount || 0); const unmatched = Number(r.data.unmatchedCount || 0); await this.refreshDetail(); const message = `新匹配并确认 ${confirmed} 项,保留已确认 ${preserved} 项,未匹配 ${unmatched} 项`; if (unmatched > 0) ElMessage.warning(message); else ElMessage.success(message) } finally { if (this.isCurrent(generation, id)) this.detail.aiMatching = false } }, async confirmPendingMapping(row) { await confirmShopeeSpecMapping(this.loadedProductId, { requestId: createRequestId(), dimension: row.dimensionName, valueName: row.name }); await this.refreshDetail() }, async saveAllMappings() { const changes = this.allSpecRows.filter(row => (row.pddValueDraft || '') !== (row.originalPddValue || '')); if (!changes.length) return; const invalid = changes.find(row => row.pddValueDraft && !this.pddValueExists(row.role, row.pddValueDraft)); if (invalid) { ElMessage.warning(`PDD 规格“${invalid.pddValueDraft}”已不存在,请重新选择`); return } this.detail.saving = true; try { for (const row of changes) { if (!row.pddValueDraft) { if (row.originalPddValue) await clearShopeeSpecMapping(this.loadedProductId, { requestId: createRequestId(), dimension: row.dimensionName, valueName: row.name }); continue } const source = ['exact_match', 'ai_match'].includes(row.draftSource) ? row.draftSource : 'manual'; const payload = { requestId: createRequestId(), dimension: row.dimensionName, valueName: row.name, pddValue: row.pddValueDraft, source }; if (source === 'ai_match') { if (row.draftConfidence !== null && row.draftConfidence !== undefined) payload.confidence = row.draftConfidence; if (row.previewReason) payload.reason = row.previewReason } await setShopeeSpecMapping(this.loadedProductId, payload); if (source === 'exact_match') await confirmShopeeSpecMapping(this.loadedProductId, { requestId: createRequestId(), dimension: row.dimensionName, valueName: row.name }) } ElMessage.success('规格匹配已保存'); await this.refreshDetail() } finally { this.detail.saving = false } }, async submitAddValue() { const dimension = this.addValueForm.dimension.trim(); const name = this.addValueForm.name.trim(); if (!dimension || !name) { ElMessage.warning('请填写维度名称和规格值'); return } await addShopeeSpecValue(this.loadedProductId, { requestId: createRequestId(), dimension, role: this.addValueForm.role, name }); this.addValueForm = { dimension: '', role: 'other', name: '' }; await this.refreshDetail() }, @@ -175,5 +172,5 @@ export default { diff --git a/web/tests/e2e/shopee-pdd-picker.spec.ts b/web/tests/e2e/shopee-pdd-picker.spec.ts index bfdc4b3..36b1bf9 100644 --- a/web/tests/e2e/shopee-pdd-picker.spec.ts +++ b/web/tests/e2e/shopee-pdd-picker.spec.ts @@ -51,22 +51,26 @@ test('PDD 选择器默认最近五条并支持搜索与清空回退', async({ pa expect(listURLs.at(-1)?.searchParams.get('status')).toBe('active') }) -test('虾皮详情原地叠加 PDD 抽屉且低置信度 AI 建议不预填', async({ page, context }) => { +test('虾皮详情一键匹配颜色尺码后直接使用并可原地叠加 PDD 抽屉', async({ page, context }) => { await context.addCookies([{ name: 'Admin-Token', value: 'prototype-test-token', domain: 'localhost', path: '/' }]) + let autoMatched = false + let autoMatchBody: Record | null = null await page.route('**/api/**', async route => { const url = new URL(route.request().url()) if (url.pathname.startsWith('/src/api/')) return route.continue() if (url.pathname.endsWith('/api/v1/getinfo')) return route.fulfill({ json: { code: 200, data: { roles: ['purchaser'], name: '采购员', avatar: '', introduction: '', permissions: [] }}}) if (url.pathname.endsWith('/api/v1/menurole')) return route.fulfill({ json: { code: 200, data: routes }}) if (url.pathname.endsWith('/api/admin/v1/shopee-products')) return route.fulfill({ json: { code: 200, data: { items: [{ id: 9, shopeeItemId: 'S9', title: '叠加抽屉商品', shopName: '测试店', pddProductId: 17, specs: [{ name: '颜色', role: 'color', values: [{ name: '藏青色', source: 'syb', mapping: null }] }] }], total: 1, page: 1, pageSize: 20 }}}) - if (url.pathname.endsWith('/api/admin/v1/shopee-products/9/specs/mapping/suggest-colors')) { - return route.fulfill({ json: { code: 200, data: { items: [{ valueName: '藏青色', pddValue: '黑色', status: 'ai_suggested', apply: false, confidence: 0.45, reason: '置信度不足,请人工选择' }], matchedCount: 0, pendingCount: 1 }}}) + if (url.pathname.endsWith('/api/admin/v1/shopee-products/9/specs/mapping/auto-match')) { + autoMatchBody = route.request().postDataJSON() + autoMatched = true + return route.fulfill({ json: { code: 200, data: { confirmedCount: 2, preservedCount: 0, unmatchedCount: 0, items: [] }}}) } if (url.pathname.endsWith('/api/admin/v1/shopee-products/9')) { - return route.fulfill({ json: { code: 200, data: { product: { id: 9, shopeeItemId: 'S9', title: '叠加抽屉商品', shopName: '测试店', pddProductId: 17, specContextVersion: 3, specs: [{ name: '颜色', role: 'color', values: [{ name: '藏青色', source: 'syb', mapping: null }] }] }}}}) + return route.fulfill({ json: { code: 200, data: { product: { id: 9, shopeeItemId: 'S9', title: '叠加抽屉商品', shopName: '测试店', pddProductId: 17, specContextVersion: 'context-3', specs: [{ name: '颜色', role: 'color', values: [{ name: '藏青色', source: 'import', mapping: autoMatched ? { pddValue: '黑色', source: 'ai_match', status: 'confirmed', confidence: 0.96, reason: '颜色含义唯一一致' } : null }] }, { name: '尺码', role: 'size', values: [{ name: 'XL', source: 'import', mapping: autoMatched ? { pddValue: 'XL', source: 'exact_match', status: 'confirmed', reason: '格式统一后唯一匹配' } : null }] }] }}}}) } if (url.pathname.endsWith('/api/admin/v1/pdd-products/17/related-syb-products')) return route.fulfill({ json: { code: 200, data: { items: [], total: 0 }}}) - if (url.pathname.endsWith('/api/admin/v1/pdd-products/17')) return route.fulfill({ json: { code: 200, data: { product: { id: 17, goodsId: 'P17', title: 'PDD 叠加详情', shopName: 'PDD 店', status: 'active', url: 'https://mobile.yangkeduo.com/goods.html?goods_id=17', specs: [{ name: '颜色分类', role: 'color', values: [{ name: '黑色', selectable: true, priceCent: 1299 }] }], colorImages: [] }, relatedShopeeProducts: [] }}}) + if (url.pathname.endsWith('/api/admin/v1/pdd-products/17')) return route.fulfill({ json: { code: 200, data: { product: { id: 17, goodsId: 'P17', title: 'PDD 叠加详情', shopName: 'PDD 店', status: 'active', url: 'https://mobile.yangkeduo.com/goods.html?goods_id=17', specs: [{ name: '颜色分类', role: 'color', values: [{ name: '黑色', selectable: true, priceCent: 1299 }] }, { name: '尺码', role: 'size', values: [{ name: 'XL', selectable: true }] }], colorImages: [] }, relatedShopeeProducts: [] }}}) return route.fulfill({ json: { code: 200, data: [] }}) }) @@ -75,15 +79,19 @@ test('虾皮详情原地叠加 PDD 抽屉且低置信度 AI 建议不预填', as page.on('popup', () => { popupCount += 1 }) await page.getByRole('button', { name: '详情', exact: true }).click() await expect(page.getByText('虾皮商品详情', { exact: true })).toBeVisible() - await page.getByRole('button', { name: 'AI 匹配', exact: true }).click() - await expect(page.getByText('置信度不足,请人工选择', { exact: true })).toBeVisible() - await expect(page.locator('.el-drawer:visible .mapping-select input').first()).toHaveValue('') + await expect(page.getByRole('button', { name: '一键匹配颜色和尺码', exact: true })).toHaveCount(1) + await expect(page.getByRole('button', { name: 'AI 匹配', exact: true })).toHaveCount(0) + await page.getByRole('button', { name: '一键匹配颜色和尺码', exact: true }).click() + await expect(page.locator('.el-drawer:visible').getByText('黑色 · ¥12.99', { exact: true })).toBeVisible() + await expect(page.getByText('可采购', { exact: true })).toBeVisible() + expect(autoMatchBody?.specContextVersion).toBe('context-3') + expect(String(autoMatchBody?.requestId || '')).toMatch(/^[0-9a-f-]{36}$/) await page.getByLabel('虾皮商品详情').getByText('PDD-17 ↗', { exact: true }).click() await expect(page.locator('.el-drawer:visible').getByText('PDD 商品详情', { exact: true })).toBeVisible() await expect(page.getByText('PDD 叠加详情', { exact: true })).toBeVisible() await page.keyboard.press('Escape') await expect(page.getByText('PDD 叠加详情', { exact: true })).toBeHidden() await expect(page.getByText('虾皮商品详情', { exact: true })).toBeVisible() - await expect(page.getByText('置信度不足,请人工选择', { exact: true })).toBeVisible() + await expect(page.locator('.el-drawer:visible').getByText('黑色 · ¥12.99', { exact: true })).toBeVisible() expect(popupCount).toBe(0) })