From 21b38c44441bb208e2a1c1efc436603aee41bd62 Mon Sep 17 00:00:00 2001 From: QiuSW <105186638@qq.com> Date: Mon, 31 Aug 2026 16:18:42 +0800 Subject: [PATCH] feat(goauto): suggest color/size specs via AI, human-confirmed save (#172) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add batch AI suggestion for Shopee color/size spec mapping: a specContextVersion snapshot guards both the new suggest endpoints and must be echoed back, short id binding keeps the model from ever returning a value outside the server-supplied PDD candidates, and suggestions only prefill the draft above AutoConfirmMinConfidence - low-confidence answers show the reason but stay unapplied. Every AI-sourced mapping is still written as pending by the existing SetMapping rule and needs an explicit operator confirm, same as before for manual edits. Server: server/app/goauto/aimatching/suggest.go (batch provider call), server/app/goauto/shopeeproduct/{context_version,ai_suggest}.go (spec context version + suggest-colors/suggest-sizes), wired into handler.go/router.go/service.go. Web: shopee-products AI 匹配 button on both tabs, low-confidence rows left for manual pick, pending AI mappings get an explicit 确认 action. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01VkjpksKeizaRpYXfgD74Lf --- server/app/goauto/aimatching/suggest.go | 203 ++++++++++++++ server/app/goauto/aimatching/suggest_test.go | 136 ++++++++++ server/app/goauto/shopeeproduct/ai_suggest.go | 255 ++++++++++++++++++ .../goauto/shopeeproduct/ai_suggest_test.go | 160 +++++++++++ .../goauto/shopeeproduct/context_version.go | 24 ++ server/app/goauto/shopeeproduct/handler.go | 52 +++- server/app/goauto/shopeeproduct/router.go | 2 + server/app/goauto/shopeeproduct/service.go | 13 + web/src/api/goauto/shopee-products.js | 8 + .../views/goauto/shopee-products/index.vue | 71 +++-- 10 files changed, 900 insertions(+), 24 deletions(-) create mode 100644 server/app/goauto/aimatching/suggest.go create mode 100644 server/app/goauto/aimatching/suggest_test.go create mode 100644 server/app/goauto/shopeeproduct/ai_suggest.go create mode 100644 server/app/goauto/shopeeproduct/ai_suggest_test.go create mode 100644 server/app/goauto/shopeeproduct/context_version.go diff --git a/server/app/goauto/aimatching/suggest.go b/server/app/goauto/aimatching/suggest.go new file mode 100644 index 0000000..d132b1c --- /dev/null +++ b/server/app/goauto/aimatching/suggest.go @@ -0,0 +1,203 @@ +package aimatching + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strings" + "time" +) + +// Suggestion limits are enforced defensively here too, even though callers +// (e.g. shopeeproduct) already reject oversized requests with a domain +// specific message; this keeps the package safe for any future caller. +const ( + maxSuggestSources = 100 + maxSuggestCandidates = 150 +) + +// SuggestSource is one value that needs a suggestion, addressed by a +// request-scoped short id so the model can never emit anything but a member +// of the candidate set. +type SuggestSource struct { + ID string `json:"id"` + Label string `json:"label"` +} + +// SuggestCandidate is one server-provided PDD spec value the model may +// choose, addressed the same way. +type SuggestCandidate struct { + ID string `json:"id"` + Label string `json:"label"` +} + +// SuggestRequest asks for suggestions across many source values in a single +// call. Dimension only steers prompt wording ("color" or "size"); it is never +// sent as free text to identify anything beyond that. +type SuggestRequest struct { + Dimension string + ShopeeTitle string + PDDTitle string + Sources []SuggestSource + Candidates []SuggestCandidate +} + +// SuggestDecision is the model's answer for one source. CandidateID is empty +// when the model had no reliable suggestion. +type SuggestDecision struct { + SourceID string + CandidateID string + Confidence float64 + Reason string +} + +// SuggestResult never contains a decision whose CandidateID is not one of the +// ids supplied in SuggestRequest.Candidates: SuggestBatch itself enforces that +// so callers cannot accidentally trust an out-of-band value. +type SuggestResult struct { + Decisions map[string]SuggestDecision + Provider string + Model string +} + +// SuggestBatch generates draft suggestions only; it never writes to the +// database and never selects anything outside the supplied candidate set. +// Callers must still treat the result as an unsaved draft (#40, #46: AI 匹配 +// 必须人工确认后生效). +func (s *Service) SuggestBatch(ctx context.Context, request SuggestRequest) (SuggestResult, error) { + if len(request.Sources) == 0 { + return SuggestResult{Decisions: map[string]SuggestDecision{}}, nil + } + if len(request.Sources) > maxSuggestSources || len(request.Candidates) > maxSuggestCandidates { + return SuggestResult{}, fail(CodeInvalidSetting, "AI 建议的候选或来源数量超过上限") + } + setting, apiKey, err := s.activeSetting(ctx) + if err != nil { + return SuggestResult{}, err + } + payload := openAIChatRequest{Model: setting.Model, Temperature: 0, Messages: []openAIMessage{ + {Role: "system", Content: suggestSystemPrompt(request.Dimension)}, + {Role: "user", Content: suggestPrompt(request)}, + }} + body, err := json.Marshal(payload) + if err != nil { + return SuggestResult{}, &Error{Code: CodeProviderUnavailable, Message: "AI 建议请求生成失败", Cause: err} + } + ctx, cancel := context.WithTimeout(ctx, time.Duration(setting.TimeoutSeconds)*time.Second) + defer cancel() + httpRequest, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint(setting.BaseURL, "chat/completions"), bytes.NewReader(body)) + if err != nil { + return SuggestResult{}, fail(CodeInvalidSetting, "AI 服务地址无效") + } + httpRequest.Header.Set("Authorization", "Bearer "+apiKey) + httpRequest.Header.Set("Content-Type", "application/json") + response, err := s.httpClient().Do(httpRequest) + if err != nil { + return SuggestResult{}, &Error{Code: CodeProviderUnavailable, Message: "AI 建议服务暂时不可用", Cause: err} + } + defer response.Body.Close() + limited := io.LimitReader(response.Body, 1<<20) + responseBody, readErr := io.ReadAll(limited) + if readErr != nil || response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices { + return SuggestResult{}, fail(CodeProviderUnavailable, "AI 建议服务暂时不可用") + } + raw, err := parseSuggestChoices(responseBody) + if err != nil { + return SuggestResult{}, fail(CodeProviderUnavailable, "AI 建议响应无效") + } + + sourceIDs := make(map[string]bool, len(request.Sources)) + for _, source := range request.Sources { + sourceIDs[source.ID] = true + } + candidateIDs := make(map[string]bool, len(request.Candidates)) + for _, candidate := range request.Candidates { + candidateIDs[candidate.ID] = true + } + + decisions := make(map[string]SuggestDecision, len(raw)) + for _, item := range raw { + sourceID := strings.TrimSpace(item.SourceID) + if sourceID == "" || !sourceIDs[sourceID] { + continue + } + if _, exists := decisions[sourceID]; exists { + // Duplicate source id in the model's answer: keep the first, the + // rest cannot be trusted to refer to the same intent. + continue + } + candidateID := strings.TrimSpace(item.CandidateID) + if candidateID != "" && !candidateIDs[candidateID] { + // Candidate id outside the supplied set: never guess, treat as no + // suggestion rather than translating it to anything. + candidateID = "" + } + confidence := 0.0 + if item.Confidence != nil && *item.Confidence >= 0 && *item.Confidence <= 1 { + confidence = *item.Confidence + } + decisions[sourceID] = SuggestDecision{SourceID: sourceID, CandidateID: candidateID, Confidence: confidence, Reason: safeReason(item.Reason)} + } + return SuggestResult{Decisions: decisions, Provider: ProviderOpenAICompatible, Model: setting.Model}, nil +} + +func suggestSystemPrompt(dimension string) string { + noun := "颜色或尺码" + switch dimension { + case "color": + noun = "颜色" + case "size": + noun = "尺码" + } + return fmt.Sprintf( + "你负责为每个来源%s在给定候选中选择完全一致的原始标签,不得猜测、不得改写候选文字、不得选择候选之外的内容。"+ + "每个来源最多对应一个候选,也可以没有可靠候选。只返回 JSON:"+ + "{\"suggestions\":[{\"sourceId\":\"来源编号\",\"candidateId\":\"候选编号或空字符串\",\"confidence\":0到1之间的数字,\"reason\":\"简短原因\"}]},"+ + "必须为每个来源都返回一条记录。", noun) +} + +func suggestPrompt(request SuggestRequest) string { + payload := struct { + ShopeeTitle string `json:"shopeeTitle,omitempty"` + PDDTitle string `json:"pddTitle,omitempty"` + Sources []SuggestSource `json:"sources"` + Candidates []SuggestCandidate `json:"candidates"` + }{request.ShopeeTitle, request.PDDTitle, request.Sources, request.Candidates} + raw, _ := json.Marshal(payload) + return string(raw) +} + +type suggestChoiceItem struct { + SourceID string `json:"sourceId"` + CandidateID string `json:"candidateId"` + Confidence *float64 `json:"confidence"` + Reason string `json:"reason"` +} + +func parseSuggestChoices(raw []byte) ([]suggestChoiceItem, error) { + var response struct { + Choices []struct { + Message struct { + Content string `json:"content"` + } `json:"message"` + } `json:"choices"` + } + if err := json.Unmarshal(raw, &response); err != nil || len(response.Choices) == 0 { + return nil, errors.New("invalid provider response") + } + content := strings.TrimSpace(response.Choices[0].Message.Content) + content = strings.TrimPrefix(content, "```json") + content = strings.TrimPrefix(content, "```") + content = strings.TrimSuffix(strings.TrimSpace(content), "```") + var body struct { + Suggestions []suggestChoiceItem `json:"suggestions"` + } + if err := json.Unmarshal([]byte(content), &body); err != nil { + return nil, err + } + return body.Suggestions, nil +} diff --git a/server/app/goauto/aimatching/suggest_test.go b/server/app/goauto/aimatching/suggest_test.go new file mode 100644 index 0000000..e2db94b --- /dev/null +++ b/server/app/goauto/aimatching/suggest_test.go @@ -0,0 +1,136 @@ +package aimatching + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "testing" + + "go-admin/app/goauto/models" + + "gorm.io/driver/sqlite" + "gorm.io/gorm" + "gorm.io/gorm/logger" +) + +func openSuggestTestDB(t *testing.T) *gorm.DB { + t.Helper() + db, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=shared&_foreign_keys=on", t.Name())), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)}) + if err != nil { + t.Fatalf("open database: %v", err) + } + if err := db.AutoMigrate(&models.AIMatchingSetting{}); err != nil { + t.Fatalf("migrate: %v", err) + } + return db +} + +func seedEnabledSetting(t *testing.T, db *gorm.DB, baseURL string) { + t.Helper() + setting := models.AIMatchingSetting{ID: 1, Enabled: true, Provider: ProviderOpenAICompatible, BaseURL: baseURL, Model: "test-model", APIKey: "test-key", TimeoutSeconds: 5, AutoConfirmMinConfidence: 0.9} + if err := db.Create(&setting).Error; err != nil { + t.Fatalf("seed setting: %v", err) + } +} + +func chatCompletionResponder(content string) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "choices": []map[string]any{{"message": map[string]any{"content": content}}}, + }) + } +} + +func TestSuggestBatchOnlyAcceptsSuppliedCandidateIDs(t *testing.T) { + server := httptest.NewServer(chatCompletionResponder(`{"suggestions":[ + {"sourceId":"s1","candidateId":"c2","confidence":0.95,"reason":"exact"}, + {"sourceId":"s2","candidateId":"c9","confidence":0.99,"reason":"out of set"}, + {"sourceId":"s3","candidateId":"","confidence":0.1,"reason":"no match"} + ]}`)) + defer server.Close() + + db := openSuggestTestDB(t) + seedEnabledSetting(t, db, server.URL) + service := NewService(db) + + result, err := service.SuggestBatch(context.Background(), SuggestRequest{ + Dimension: "color", + Sources: []SuggestSource{{ID: "s1", Label: "黑色"}, {ID: "s2", Label: "白色"}, {ID: "s3", Label: "红色"}}, + Candidates: []SuggestCandidate{ + {ID: "c1", Label: "藏青色"}, {ID: "c2", Label: "黑色"}, + }, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got := result.Decisions["s1"]; got.CandidateID != "c2" || got.Confidence != 0.95 { + t.Fatalf("s1 decision wrong: %+v", got) + } + if got := result.Decisions["s2"]; got.CandidateID != "" { + t.Fatalf("s2 must be treated as no-suggestion for out-of-set candidate id, got %+v", got) + } + if got := result.Decisions["s3"]; got.CandidateID != "" { + t.Fatalf("s3 decision wrong: %+v", got) + } +} + +func TestSuggestBatchIgnoresUnknownAndDuplicateSourceIDs(t *testing.T) { + server := httptest.NewServer(chatCompletionResponder(`{"suggestions":[ + {"sourceId":"s1","candidateId":"c1","confidence":0.5,"reason":"first"}, + {"sourceId":"s1","candidateId":"c1","confidence":0.99,"reason":"second, must be ignored"}, + {"sourceId":"unknown","candidateId":"c1","confidence":0.9,"reason":"must be dropped"} + ]}`)) + defer server.Close() + + db := openSuggestTestDB(t) + seedEnabledSetting(t, db, server.URL) + service := NewService(db) + + result, err := service.SuggestBatch(context.Background(), SuggestRequest{ + Sources: []SuggestSource{{ID: "s1", Label: "黑色"}}, + Candidates: []SuggestCandidate{{ID: "c1", Label: "黑色"}}, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(result.Decisions) != 1 { + t.Fatalf("expected exactly one decision, got %+v", result.Decisions) + } + if got := result.Decisions["s1"]; got.Confidence != 0.5 { + t.Fatalf("expected first duplicate to win, got %+v", got) + } +} + +func TestSuggestBatchRejectsOversizedRequest(t *testing.T) { + db := openSuggestTestDB(t) + seedEnabledSetting(t, db, "http://unused.invalid") + service := NewService(db) + + sources := make([]SuggestSource, maxSuggestSources+1) + for i := range sources { + sources[i] = SuggestSource{ID: fmt.Sprintf("s%d", i), Label: "x"} + } + _, err := service.SuggestBatch(context.Background(), SuggestRequest{Sources: sources, Candidates: []SuggestCandidate{{ID: "c1", Label: "y"}}}) + if err == nil { + t.Fatal("expected error for oversized request") + } +} + +func TestSuggestBatchRequiresConfiguredProvider(t *testing.T) { + db := openSuggestTestDB(t) + service := NewService(db) + _, err := service.SuggestBatch(context.Background(), SuggestRequest{ + Sources: []SuggestSource{{ID: "s1", Label: "黑色"}}, + Candidates: []SuggestCandidate{{ID: "c1", Label: "黑色"}}, + }) + target, ok := err.(*Error) + if !ok { + t.Fatalf("expected *Error, got %v (%T)", err, err) + } + if target.Code != CodeNotConfigured { + t.Fatalf("expected CodeNotConfigured, got %v", target.Code) + } +} diff --git a/server/app/goauto/shopeeproduct/ai_suggest.go b/server/app/goauto/shopeeproduct/ai_suggest.go new file mode 100644 index 0000000..f14ff21 --- /dev/null +++ b/server/app/goauto/shopeeproduct/ai_suggest.go @@ -0,0 +1,255 @@ +package shopeeproduct + +import ( + "context" + "errors" + "fmt" + "sort" + "strings" + + "go-admin/app/goauto/aimatching" + "go-admin/app/goauto/models" + + "gorm.io/gorm" +) + +const ( + CodeSpecContextStale = "SPEC_CONTEXT_VERSION_STALE" + CodeAIUnavailable = "AI_MATCHING_UNAVAILABLE" + + maxAISuggestSources = 100 + maxAISuggestCandidates = 150 +) + +// AISuggestItem is one row of an unsaved AI suggestion preview. It never +// represents a saved mapping; only SetMapping/ConfirmMapping write to the +// database, and only after an operator confirms. +type AISuggestItem struct { + ValueName string `json:"valueName"` + PDDValue string `json:"pddValue,omitempty"` + Status string `json:"status"` // preserved | matched | ai_matched | ai_suggested | pending + Confidence *float64 `json:"confidence,omitempty"` + Reason string `json:"reason,omitempty"` + // Apply tells the client whether to prefill the draft select. It is only + // true for a preserved/deterministic match or an AI decision whose + // confidence reached AutoConfirmMinConfidence; low-confidence AI answers + // are still shown (Reason explains why) but never prefilled. + Apply bool `json:"apply"` +} + +// AISuggestResponse is a read-only preview; SuggestColorMappings and +// SuggestSizeMappings never write to the database. +type AISuggestResponse struct { + Items []AISuggestItem `json:"items"` + MatchedCount int `json:"matchedCount"` + SuggestedCount int `json:"suggestedCount"` + PendingCount int `json:"pendingCount"` + ContextVersion string `json:"contextVersion"` +} + +func contextVersionStale() error { + return &ServiceError{Code: CodeSpecContextStale, Message: "商品关联或规格已变化,请刷新后重试"} +} + +func aiUnavailable(message string) error { + return &ServiceError{Code: CodeAIUnavailable, Message: message} +} + +// SuggestColorMappings previews AI color suggestions for every unmapped or +// stale color value. requestContextVersion must match the value the caller +// most recently read from the product detail; a mismatch means the linked +// PDD product or either spec set changed since then and the preview is +// refused rather than generated against stale candidates. +func (service *Service) SuggestColorMappings(ctx context.Context, id uint64, requestContextVersion string) (AISuggestResponse, error) { + return service.suggestMappings(ctx, id, requestContextVersion, RoleColor) +} + +// SuggestSizeMappings behaves like SuggestColorMappings but for sizes, and +// first runs the existing deterministic normalizer so an already-unique exact +// match never spends an AI call. +func (service *Service) SuggestSizeMappings(ctx context.Context, id uint64, requestContextVersion string) (AISuggestResponse, error) { + return service.suggestMappings(ctx, id, requestContextVersion, RoleSize) +} + +func (service *Service) suggestMappings(ctx context.Context, id uint64, requestContextVersion, role string) (AISuggestResponse, error) { + db := service.DB.WithContext(ctx) + var shopee models.ShopeeProduct + if err := db.First(&shopee, id).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return AISuggestResponse{}, productNotFound() + } + return AISuggestResponse{}, internalError(err) + } + if shopee.PDDProductID == nil { + return AISuggestResponse{}, &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 AISuggestResponse{}, &ServiceError{Code: CodePDDProductNotFound, Message: "关联的 PDD 商品不存在"} + } + return AISuggestResponse{}, internalError(err) + } + + currentVersion := computeSpecContextVersion(shopee.PDDProductID, shopee.SpecsJSON, pdd.SpecsJSON) + if strings.TrimSpace(requestContextVersion) == "" || requestContextVersion != currentVersion { + return AISuggestResponse{}, contextVersionStale() + } + + candidateValues, err := selectablePDDValues(pdd.SpecsJSON, role) + if err != nil { + return AISuggestResponse{}, internalError(err) + } + candidateNames := make([]string, 0, len(candidateValues)) + for name := range candidateValues { + candidateNames = append(candidateNames, name) + } + sort.Strings(candidateNames) + + shopeeSpecs, err := Unmarshal(shopee.SpecsJSON) + if err != nil { + return AISuggestResponse{}, internalError(err) + } + + response := AISuggestResponse{Items: []AISuggestItem{}, ContextVersion: currentVersion} + type placeholder struct { + itemIndex int + name string + } + var placeholders []placeholder + + for _, dimension := range shopeeSpecs { + if dimension.Role != role { + continue + } + for _, value := range dimension.Values { + item := AISuggestItem{ValueName: value.Name} + switch { + case value.Mapping != nil && value.Mapping.Status == MappingStatusConfirmed && candidateValues[value.Mapping.PDDValue]: + item.PDDValue = value.Mapping.PDDValue + item.Status = "preserved" + item.Reason = "保留已确认映射" + item.Apply = true + response.MatchedCount++ + case role == RoleSize && deterministicSizeMatch(value.Name, candidateNames) != "": + item.PDDValue = deterministicSizeMatch(value.Name, candidateNames) + item.Status = "matched" + item.Reason = "格式统一后唯一匹配" + item.Apply = true + response.MatchedCount++ + default: + item.Status = "pending" + placeholders = append(placeholders, placeholder{itemIndex: len(response.Items), name: value.Name}) + } + response.Items = append(response.Items, item) + } + } + + if len(placeholders) == 0 { + return response, nil + } + if len(placeholders) > maxAISuggestSources { + return AISuggestResponse{}, invalidRequest(fmt.Sprintf("当前待处理规格值超过 %d 个,请先人工缩小范围", maxAISuggestSources)) + } + if len(candidateNames) > maxAISuggestCandidates { + return AISuggestResponse{}, invalidRequest(fmt.Sprintf("关联 PDD 商品可选规格值超过 %d 个,请先人工缩小范围", maxAISuggestCandidates)) + } + if len(candidateNames) == 0 { + for _, p := range placeholders { + response.Items[p.itemIndex].Reason = "关联的 PDD 商品没有可用规格" + response.PendingCount++ + } + return response, nil + } + + sources := make([]aimatching.SuggestSource, len(placeholders)) + for i, p := range placeholders { + sources[i] = aimatching.SuggestSource{ID: fmt.Sprintf("s%d", i+1), Label: p.name} + } + candidates := make([]aimatching.SuggestCandidate, len(candidateNames)) + for i, name := range candidateNames { + candidates[i] = aimatching.SuggestCandidate{ID: fmt.Sprintf("c%d", i+1), Label: name} + } + candidateLabelByID := make(map[string]string, len(candidates)) + for _, candidate := range candidates { + candidateLabelByID[candidate.ID] = candidate.Label + } + + aiService := aimatching.NewService(service.DB) + settings, err := aiService.Settings(ctx) + if err != nil { + return AISuggestResponse{}, internalError(err) + } + if !settings.Enabled { + return AISuggestResponse{}, aiUnavailable("AI 匹配未启用,请先在设置中配置并启用") + } + + result, err := aiService.SuggestBatch(ctx, aimatching.SuggestRequest{ + Dimension: role, ShopeeTitle: shopee.Title, PDDTitle: pdd.Title, + Sources: sources, Candidates: candidates, + }) + if err != nil { + return AISuggestResponse{}, aiUnavailable(aiSuggestErrorMessage(err)) + } + + for i, p := range placeholders { + sourceID := fmt.Sprintf("s%d", i+1) + decision, ok := result.Decisions[sourceID] + item := &response.Items[p.itemIndex] + if !ok || decision.CandidateID == "" { + item.Reason = "AI 未给出可靠建议,请人工选择" + response.PendingCount++ + continue + } + pddValue, ok := candidateLabelByID[decision.CandidateID] + if !ok || !candidateValues[pddValue] { + // Defensive: SuggestBatch already rejects out-of-set ids, but + // never trust a translation that cannot be re-verified here. + item.Reason = "AI 未给出可靠建议,请人工选择" + response.PendingCount++ + continue + } + confidence := decision.Confidence + item.PDDValue = pddValue + item.Reason = decision.Reason + item.Confidence = &confidence + if confidence >= settings.AutoConfirmMinConfidence { + item.Status = "ai_matched" + item.Apply = true + response.MatchedCount++ + } else { + item.Status = "ai_suggested" + item.Apply = false + response.SuggestedCount++ + } + } + return response, nil +} + +// deterministicSizeMatch returns the unique normalized match for value among +// candidates, or "" when there is none. It reuses aimatching's normalizer so +// this preview and the purchase-time exact matcher never disagree. +func deterministicSizeMatch(value string, candidates []string) string { + match, ok := aimatching.DeterministicMatch(aimatching.MatchRequest{TargetSize: value, Sizes: candidates}) + if !ok { + return "" + } + return match.MappedSize +} + +// aiSuggestErrorMessage turns an aimatching error into a message safe to +// return to the admin UI (no credential, no raw provider body). +func aiSuggestErrorMessage(err error) string { + var matchErr *aimatching.Error + if errors.As(err, &matchErr) { + switch matchErr.Code { + case aimatching.CodeNotConfigured: + return "AI 匹配未启用或未配置 API Key" + case aimatching.CodeProviderUnavailable: + return "AI 匹配服务暂时不可用,请稍后重试" + case aimatching.CodeInvalidSetting: + return "AI 匹配设置无效,请检查服务地址和模型" + } + } + return "AI 匹配服务暂时不可用,请稍后重试" +} diff --git a/server/app/goauto/shopeeproduct/ai_suggest_test.go b/server/app/goauto/shopeeproduct/ai_suggest_test.go new file mode 100644 index 0000000..3e7ec29 --- /dev/null +++ b/server/app/goauto/shopeeproduct/ai_suggest_test.go @@ -0,0 +1,160 @@ +package shopeeproduct + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "go-admin/app/goauto/models" + + "github.com/google/uuid" +) + +func chatCompletionResponder(content string) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "choices": []map[string]any{{"message": map[string]any{"content": content}}}, + }) + } +} + +func seedEnabledAISetting(t *testing.T, baseURL string, minConfidence float64) models.AIMatchingSetting { + return models.AIMatchingSetting{ID: 1, Enabled: true, Provider: "openai_compatible", BaseURL: baseURL, Model: "test-model", APIKey: "test-key", TimeoutSeconds: 5, AutoConfirmMinConfidence: minConfidence} +} + +func TestSuggestColorMappingsRejectsStaleContextVersion(t *testing.T) { + db := openTestDB(t) + pdd := seedPDDProduct(t, db, "active") + service := NewService(db) + created, err := service.Create(context.Background(), CreateRequest{RequestID: uuid.NewString(), ShopeeItemID: "SP-CTX", PDDProductID: &pdd.ID, Specs: []SpecDimension{{Name: "颜色", Role: RoleColor, Values: []SpecValue{{Name: "深黑", Source: ValueSourceImport}}}}}) + if err != nil { + t.Fatal(err) + } + if _, err := service.SuggestColorMappings(context.Background(), created.Product.ID, "stale-version"); err == nil { + t.Fatal("expected stale context version to be rejected") + } else if errCode(t, err) != CodeSpecContextStale { + t.Fatalf("unexpected code: %v", err) + } + // The correct version must be accepted (fails later only because AI is not configured). + if _, err := service.SuggestColorMappings(context.Background(), created.Product.ID, created.Product.SpecContextVersion); err == nil { + t.Fatal("expected AI-not-configured error") + } else if errCode(t, err) != CodeAIUnavailable { + t.Fatalf("expected CodeAIUnavailable once version matches, got %v", err) + } +} + +func TestSuggestColorMappingsRequiresLinkedPDDProduct(t *testing.T) { + db := openTestDB(t) + service := NewService(db) + created, err := service.Create(context.Background(), CreateRequest{RequestID: uuid.NewString(), ShopeeItemID: "SP-NOLINK", Specs: []SpecDimension{{Name: "颜色", Role: RoleColor, Values: []SpecValue{{Name: "黑色", Source: ValueSourceImport}}}}}) + if err != nil { + t.Fatal(err) + } + if _, err := service.SuggestColorMappings(context.Background(), created.Product.ID, created.Product.SpecContextVersion); err == nil || errCode(t, err) != CodePDDProductNotFound { + t.Fatalf("expected CodePDDProductNotFound, got %v", err) + } +} + +func TestSuggestColorMappingsPrefillsOnlyHighConfidence(t *testing.T) { + server := httptest.NewServer(chatCompletionResponder(`{"suggestions":[ + {"sourceId":"s1","candidateId":"c1","confidence":0.95,"reason":"exact color name"}, + {"sourceId":"s2","candidateId":"c1","confidence":0.4,"reason":"uncertain, similar tone"} + ]}`)) + defer server.Close() + + db := openTestDB(t) + pdd := seedPDDProduct(t, db, "active") + setting := seedEnabledAISetting(t, server.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-COLOR-AI", PDDProductID: &pdd.ID, Specs: []SpecDimension{{Name: "颜色", Role: RoleColor, Values: []SpecValue{ + {Name: "深黑", Source: ValueSourceImport}, + {Name: "浅黑", Source: ValueSourceImport}, + }}}}) + if err != nil { + t.Fatal(err) + } + + preview, err := service.SuggestColorMappings(context.Background(), created.Product.ID, created.Product.SpecContextVersion) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(preview.Items) != 2 { + t.Fatalf("expected 2 items, got %+v", preview.Items) + } + high, low := preview.Items[0], preview.Items[1] + if high.Status != "ai_matched" || !high.Apply || high.PDDValue != "黑色" { + t.Fatalf("high confidence item wrong: %+v", high) + } + if low.Status != "ai_suggested" || low.Apply || low.PDDValue != "黑色" { + t.Fatalf("low confidence item must not be applied: %+v", low) + } + if preview.MatchedCount != 1 || preview.SuggestedCount != 1 { + t.Fatalf("unexpected counts: %+v", preview) + } + + // The preview itself must never persist anything. + var stored models.ShopeeProduct + if err := db.First(&stored, created.Product.ID).Error; err != nil { + t.Fatal(err) + } + specs, _ := Unmarshal(stored.SpecsJSON) + for _, value := range specs[0].Values { + if value.Mapping != nil { + t.Fatalf("suggest must not persist mapping: %+v", value) + } + } +} + +func TestSuggestSizeMappingsSkipsAIForDeterministicMatches(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Fatal("AI must not be called when every value already resolves deterministically") + })) + defer server.Close() + + db := openTestDB(t) + pdd := seedPDDProduct(t, db, "active") + setting := seedEnabledAISetting(t, server.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-SIZE-DET", PDDProductID: &pdd.ID, Specs: []SpecDimension{{Name: "尺码", Role: RoleSize, Values: []SpecValue{{Name: " xl ", Source: ValueSourceImport}}}}}) + if err != nil { + t.Fatal(err) + } + preview, err := service.SuggestSizeMappings(context.Background(), created.Product.ID, created.Product.SpecContextVersion) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(preview.Items) != 1 || preview.Items[0].Status != "matched" || preview.Items[0].PDDValue != "XL" { + t.Fatalf("unexpected preview: %+v", preview.Items) + } + if preview.MatchedCount != 1 || preview.SuggestedCount != 0 || preview.PendingCount != 0 { + t.Fatalf("unexpected counts: %+v", preview) + } +} + +func TestSuggestMappingsSkipConfirmedValuesStillValid(t *testing.T) { + db := openTestDB(t) + pdd := seedPDDProduct(t, db, "active") + service := NewService(db) + created, err := service.Create(context.Background(), CreateRequest{RequestID: uuid.NewString(), ShopeeItemID: "SP-PRESERVE-AI", PDDProductID: &pdd.ID, Specs: []SpecDimension{{Name: "颜色", Role: RoleColor, Values: []SpecValue{ + {Name: "深黑", Source: ValueSourceImport, Mapping: &Mapping{PDDValue: "黑色", Source: MappingSourceManual, Status: MappingStatusConfirmed}}, + }}}}) + if err != nil { + t.Fatal(err) + } + preview, err := service.SuggestColorMappings(context.Background(), created.Product.ID, created.Product.SpecContextVersion) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(preview.Items) != 1 || preview.Items[0].Status != "preserved" || preview.Items[0].PDDValue != "黑色" { + t.Fatalf("expected preserved confirmed mapping without calling AI: %+v", preview.Items) + } +} diff --git a/server/app/goauto/shopeeproduct/context_version.go b/server/app/goauto/shopeeproduct/context_version.go new file mode 100644 index 0000000..56fcf85 --- /dev/null +++ b/server/app/goauto/shopeeproduct/context_version.go @@ -0,0 +1,24 @@ +package shopeeproduct + +import ( + "crypto/sha256" + "encoding/hex" + "fmt" +) + +// computeSpecContextVersion derives a stable snapshot marker from everything +// an AI suggestion or a manual save depends on: which PDD product is linked +// and both sides' spec snapshots. Any change to the link or either spec set +// changes the version, so a save or a suggestion generated against a stale +// snapshot is rejected instead of silently applied. +func computeSpecContextVersion(pddProductID *uint64, shopeeSpecsJSON, pddSpecsJSON string) string { + hash := sha256.New() + if pddProductID != nil { + fmt.Fprintf(hash, "pdd-product:%d\n", *pddProductID) + } + hash.Write([]byte("shopee-specs:")) + hash.Write([]byte(shopeeSpecsJSON)) + hash.Write([]byte("\npdd-specs:")) + hash.Write([]byte(pddSpecsJSON)) + return hex.EncodeToString(hash.Sum(nil)) +} diff --git a/server/app/goauto/shopeeproduct/handler.go b/server/app/goauto/shopeeproduct/handler.go index 86462eb..562dbd1 100644 --- a/server/app/goauto/shopeeproduct/handler.go +++ b/server/app/goauto/shopeeproduct/handler.go @@ -230,6 +230,54 @@ func (handler Handler) PreviewAutoSizeMatches(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"code": 200, "data": response}) } +type suggestMappingRequest struct { + SpecContextVersion string `json:"specContextVersion"` +} + +func (handler Handler) SuggestColorMappings(c *gin.Context) { + id, ok := handler.pathID(c) + if !ok { + return + } + var request suggestMappingRequest + if err := decodeJSON(c, &request); err != nil { + writeError(c, invalidRequest("请求 JSON 无效")) + return + } + service, ok := handler.service(c) + if !ok { + return + } + response, err := service.SuggestColorMappings(c.Request.Context(), id, request.SpecContextVersion) + if err != nil { + writeError(c, err) + return + } + c.JSON(http.StatusOK, gin.H{"code": 200, "data": response}) +} + +func (handler Handler) SuggestSizeMappings(c *gin.Context) { + id, ok := handler.pathID(c) + if !ok { + return + } + var request suggestMappingRequest + if err := decodeJSON(c, &request); err != nil { + writeError(c, invalidRequest("请求 JSON 无效")) + return + } + service, ok := handler.service(c) + if !ok { + return + } + response, err := service.SuggestSizeMappings(c.Request.Context(), id, request.SpecContextVersion) + 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 { @@ -357,8 +405,10 @@ func writeError(c *gin.Context, err error) { status = http.StatusConflict case CodeProductNotFound, CodePDDProductNotFound, CodeMappingNotFound, CodeValueNotFound: status = http.StatusNotFound - case CodePDDProductDisabled: + case CodePDDProductDisabled, CodeSpecContextStale: status = http.StatusConflict + case CodeAIUnavailable: + status = http.StatusServiceUnavailable } response := gin.H{"code": target.Code, "message": target.Message, "retryable": target.Retryable} if target.ExistingItemID > 0 { diff --git a/server/app/goauto/shopeeproduct/router.go b/server/app/goauto/shopeeproduct/router.go index 2247d7e..5f09bd7 100644 --- a/server/app/goauto/shopeeproduct/router.go +++ b/server/app/goauto/shopeeproduct/router.go @@ -24,4 +24,6 @@ func InitRouter(engine *gin.Engine, auth *jwt.GinJWTMiddleware) { admin.POST("/:productId/specs/mapping/confirm", handler.ConfirmMapping) admin.POST("/:productId/specs/mapping/confirm-exact-matches", handler.ConfirmExactMatches) 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) } diff --git a/server/app/goauto/shopeeproduct/service.go b/server/app/goauto/shopeeproduct/service.go index 5beada7..fd75fa0 100644 --- a/server/app/goauto/shopeeproduct/service.go +++ b/server/app/goauto/shopeeproduct/service.go @@ -68,6 +68,11 @@ type ProductView struct { Specs []SpecDimension `json:"specs"` Deleted bool `json:"deleted"` SharedByPDD int64 `json:"sharedByPddCount,omitempty"` + // SpecContextVersion snapshots the linked PDD product and both spec sets. + // Any AI suggestion or manual save must echo this value back; the server + // rejects a mismatch instead of applying a decision made against stale + // candidates (#172). + SpecContextVersion string `json:"specContextVersion"` } type SaveResponse struct { @@ -743,13 +748,21 @@ func (service *Service) makeView(ctx context.Context, record models.ShopeeProduc return ProductView{}, internalError(fmt.Errorf("invalid specs_json for shopee product %d: %w", record.ID, err)) } view := ProductView{ShopeeProduct: record, Specs: specs, Deleted: record.DeletedAt.Valid} + pddSpecsJSON := "" if record.PDDProductID != nil { var shared int64 if err := service.DB.WithContext(ctx).Model(&models.ShopeeProduct{}).Where("pdd_product_id = ?", *record.PDDProductID).Count(&shared).Error; err != nil { return ProductView{}, internalError(err) } view.SharedByPDD = shared + var pdd models.PDDProduct + if err := service.DB.WithContext(ctx).Select("id", "specs_json").First(&pdd, *record.PDDProductID).Error; err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + return ProductView{}, internalError(err) + } else if err == nil { + pddSpecsJSON = pdd.SpecsJSON + } } + view.SpecContextVersion = computeSpecContextVersion(record.PDDProductID, record.SpecsJSON, pddSpecsJSON) return view, nil } diff --git a/web/src/api/goauto/shopee-products.js b/web/src/api/goauto/shopee-products.js index dbb8ca2..0b28cbf 100644 --- a/web/src/api/goauto/shopee-products.js +++ b/web/src/api/goauto/shopee-products.js @@ -52,6 +52,14 @@ export function previewShopeeAutoSizeMatches(productId) { return request({ url: `/api/admin/v1/shopee-products/${productId}/specs/mapping/preview-auto-size`, method: 'post' }) } +export function suggestShopeeColorMappings(productId, data) { + return request({ url: `/api/admin/v1/shopee-products/${productId}/specs/mapping/suggest-colors`, method: 'post', data }) +} + +export function suggestShopeeSizeMappings(productId, data) { + return request({ url: `/api/admin/v1/shopee-products/${productId}/specs/mapping/suggest-sizes`, method: 'post', data }) +} + 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/index.vue b/web/src/views/goauto/shopee-products/index.vue index c047280..ec44290 100644 --- a/web/src/views/goauto/shopee-products/index.vue +++ b/web/src/views/goauto/shopee-products/index.vue @@ -95,6 +95,7 @@ +
AI 只生成未保存的建议,置信度不足不会预填,均需人工确认后保存。AI 匹配
@@ -104,19 +105,20 @@ +
{{ row.previewReason }}
- +
-
只自动接受格式统一后的唯一结果,匹配不了的由人工选择。一键自动匹配
+
先按格式统一自动匹配;未匹配的项再用 AI 生成建议,置信度不足不会预填。AI 匹配
- +
@@ -150,7 +152,7 @@ import { listShopeeProducts, createShopeeProduct, getShopeeProduct, updateShopeeProduct, linkShopeeProductPdd, restoreShopeeProduct, addShopeeSpecValue, removeShopeeSpecValue, setShopeeSpecMapping, clearShopeeSpecMapping, confirmShopeeSpecMapping, - previewShopeeAutoSizeMatches, batchDeleteShopeeProducts + suggestShopeeColorMappings, suggestShopeeSizeMappings, batchDeleteShopeeProducts } from '@/api/goauto/shopee-products' import { getPddProduct, listPddProducts } from '@/api/goauto/pdd-products' @@ -181,7 +183,8 @@ export default { const valid = rows.filter(row => row.pddValueDraft && this.pddValueExists(row.role, row.pddValueDraft)).length return rows.length ? `${valid}/${rows.length} 已匹配` : '暂无可匹配规格' }, - canAutoMatchSize() { return this.detail.pddLoaded && this.specRows('size').length > 0 && this.pddSpecValues('size').length > 0 } + 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 } }, created() { this.load() @@ -191,7 +194,7 @@ export default { methods: { emptyCreate() { return { shopeeItemId: '', title: '', shopName: '', pddProductId: null } }, emptyBatchDelete() { return { open: false, saving: false, step: 'confirm', products: [], results: [], deletedCount: 0, skippedCount: 0 } }, - emptyDetail() { return { open: false, loading: false, saving: false, autoMatching: false, product: null, pddProduct: null, pddLoaded: false, activeSpecTab: 'color', highlightColor: '' } }, + emptyDetail() { return { open: false, loading: false, saving: false, autoMatching: false, aiSuggesting: { color: false, size: false }, product: null, pddProduct: null, pddLoaded: false, activeSpecTab: 'color', highlightColor: '' } }, async load() { this.loading = true this.selectedProducts = [] @@ -312,7 +315,7 @@ export default { ...v, dimensionName: d.name, role: d.role, pddValueDraft: v.mapping ? v.mapping.pddValue : '', originalPddValue: v.mapping ? v.mapping.pddValue : '', - draftSource: v.mapping ? v.mapping.source : 'manual', previewReason: '' + draftSource: v.mapping ? v.mapping.source : 'manual', previewReason: '', draftConfidence: null })) })) } @@ -342,7 +345,8 @@ export default { rowStatus(row) { if (!row.pddValueDraft) return { label: '待匹配', type: 'warning' } if (!this.pddValueExists(row.role, row.pddValueDraft)) return { label: '已失效', type: 'danger' } - if (row.pddValueDraft !== row.originalPddValue) return { label: '待保存', type: 'warning' } + if (row.pddValueDraft !== row.originalPddValue) return { label: row.draftSource === 'ai_match' ? '待保存(AI 建议)' : '待保存', type: 'warning' } + if (row.mapping && row.mapping.status === 'pending') return { label: 'AI 待确认', type: 'warning' } return { label: '已匹配', type: 'success' } }, colorOptionGroups(row) { @@ -365,26 +369,39 @@ export default { return groups }, pddOptionLabel(option) { return option.priceCent === null || option.priceCent === undefined ? option.name : `${option.name} · ¥${(option.priceCent / 100).toFixed(2)}` }, - markManual(row) { row.draftSource = 'manual'; row.previewReason = '' }, - clearDraft(row) { row.pddValueDraft = ''; row.draftSource = 'manual'; row.previewReason = '' }, + 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 previewAutoSize() { - this.detail.autoMatching = true + async previewAISuggestions(role) { + this.detail.aiSuggesting[role] = true try { - const r = await previewShopeeAutoSizeMatches(this.detail.product.id) + const suggest = role === 'color' ? suggestShopeeColorMappings : suggestShopeeSizeMappings + const r = await suggest(this.detail.product.id, { specContextVersion: this.detail.product.specContextVersion }) const byName = new Map(r.data.items.map(item => [item.valueName, item])) - this.specRows('size').forEach(row => { - const preview = byName.get(row.name) - if (!preview) return - row.pddValueDraft = preview.pddValue || '' - row.draftSource = preview.status === 'matched' ? 'exact_match' : (row.mapping?.source || 'manual') - row.previewReason = preview.reason + this.specRows(role).forEach(row => { + const item = byName.get(row.name) + if (!item) return + row.previewReason = item.reason || '' + row.draftConfidence = item.confidence ?? null + // "preserved" is an already-confirmed mapping the preview echoes + // back unchanged; only a fresh match/AI decision should touch the + // draft. A low-confidence AI decision (apply=false) is shown via + // previewReason but never prefilled — the operator must pick it. + if (item.apply && item.status !== 'preserved') { + row.pddValueDraft = item.pddValue || '' + row.draftSource = item.status === 'matched' ? 'exact_match' : 'ai_match' + } }) - ElMessage.success(`自动匹配 ${r.data.matchedCount} 项,${r.data.pendingCount} 项需人工选择`) + const suggestedCount = r.data.items.filter(item => item.status === 'ai_suggested').length + ElMessage.success(`匹配 ${r.data.matchedCount} 项,AI 建议但置信度不足 ${suggestedCount} 项,待人工选择 ${r.data.pendingCount} 项`) } finally { - this.detail.autoMatching = false + this.detail.aiSuggesting[role] = false } }, + async confirmPendingMapping(row) { + await confirmShopeeSpecMapping(this.detail.product.id, { requestId: crypto.randomUUID(), 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 @@ -397,8 +414,16 @@ export default { if (row.originalPddValue) await clearShopeeSpecMapping(this.detail.product.id, { requestId: crypto.randomUUID(), dimension: row.dimensionName, valueName: row.name }) continue } - const source = row.draftSource === 'exact_match' ? 'exact_match' : 'manual' - await setShopeeSpecMapping(this.detail.product.id, { requestId: crypto.randomUUID(), dimension: row.dimensionName, valueName: row.name, pddValue: row.pddValueDraft, source }) + const source = ['exact_match', 'ai_match'].includes(row.draftSource) ? row.draftSource : 'manual' + const payload = { requestId: crypto.randomUUID(), 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.detail.product.id, payload) + // AI-sourced mappings are always written as pending by the server + // (#40, #46) and must stay that way until an operator explicitly + // confirms them, separately from the act of saving the draft. if (source === 'exact_match') await confirmShopeeSpecMapping(this.detail.product.id, { requestId: crypto.randomUUID(), dimension: row.dimensionName, valueName: row.name }) } ElMessage.success('规格匹配已保存')