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 }} 个虾皮商品共用,允许共用。
-