feat(goauto): suggest color/size specs via AI, human-confirmed save (#172)
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VkjpksKeizaRpYXfgD74Lf
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 匹配服务暂时不可用,请稍后重试"
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
@@ -95,6 +95,7 @@
|
||||
<el-alert v-else-if="detail.pddLoaded && !pddSpecValues('color').length && !pddSpecValues('size').length" title="关联的 PDD 商品暂无可用规格,请先完成商品采集。" type="warning" :closable="false" show-icon class="notice" />
|
||||
<el-tabs v-model="detail.activeSpecTab" class="mapping-tabs">
|
||||
<el-tab-pane label="颜色匹配" name="color">
|
||||
<div class="tab-actions"><span class="muted">AI 只生成未保存的建议,置信度不足不会预填,均需人工确认后保存。</span><el-button type="primary" plain :loading="detail.aiSuggesting.color" :disabled="!canAIMatchColor" @click="previewAISuggestions('color')">AI 匹配</el-button></div>
|
||||
<el-empty v-if="!specRows('color').length" description="虾皮商品暂无颜色规格" />
|
||||
<el-table v-else :data="specRows('color')" :row-class-name="mappingRowClass" border size="small">
|
||||
<el-table-column label="虾皮颜色" min-width="180"><template #default="{ row }"><div class="primary">{{ row.name }}</div><div class="muted">{{ row.source === 'manual' ? '人工添加' : 'SYB 导入' }}</div></template></el-table-column>
|
||||
@@ -104,19 +105,20 @@
|
||||
<el-option v-for="option in group.options" :key="`${group.label}-${option.value}`" :label="option.label" :value="option.value" />
|
||||
</el-option-group>
|
||||
</el-select>
|
||||
<div v-if="row.previewReason" class="muted preview-reason">{{ row.previewReason }}</div>
|
||||
</template></el-table-column>
|
||||
<el-table-column label="状态" width="150"><template #default="{ row }"><el-tag :type="rowStatus(row).type" size="small">{{ rowStatus(row).label }}</el-tag></template></el-table-column>
|
||||
<el-table-column label="操作" width="90"><template #default="{ row }"><el-button link type="primary" :disabled="!row.pddValueDraft" @click="clearDraft(row)">清除</el-button></template></el-table-column>
|
||||
<el-table-column label="操作" width="150"><template #default="{ row }"><el-button link type="primary" :disabled="!row.pddValueDraft" @click="clearDraft(row)">清除</el-button><el-button v-if="row.pddValueDraft === row.originalPddValue && row.mapping && row.mapping.status === 'pending'" link type="primary" @click="confirmPendingMapping(row)">确认</el-button></template></el-table-column>
|
||||
</el-table>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="尺码匹配" name="size">
|
||||
<div class="tab-actions"><span class="muted">只自动接受格式统一后的唯一结果,匹配不了的由人工选择。</span><el-button type="primary" plain :loading="detail.autoMatching" :disabled="!canAutoMatchSize" @click="previewAutoSize">一键自动匹配</el-button></div>
|
||||
<div class="tab-actions"><span class="muted">先按格式统一自动匹配;未匹配的项再用 AI 生成建议,置信度不足不会预填。</span><el-button type="primary" plain :loading="detail.aiSuggesting.size" :disabled="!canAIMatchSize" @click="previewAISuggestions('size')">AI 匹配</el-button></div>
|
||||
<el-empty v-if="!specRows('size').length" description="虾皮商品暂无尺码规格" />
|
||||
<el-table v-else :data="specRows('size')" border size="small">
|
||||
<el-table-column label="虾皮尺码" min-width="180"><template #default="{ row }"><div class="primary">{{ row.name }}</div><div class="muted">{{ row.source === 'manual' ? '人工添加' : 'SYB 导入' }}</div></template></el-table-column>
|
||||
<el-table-column label="PDD 尺码" min-width="360"><template #default="{ row }"><el-select v-model="row.pddValueDraft" filterable clearable placeholder="搜索并选择 PDD 尺码" class="mapping-select" :disabled="!detail.pddLoaded" @change="markManual(row)"><el-option v-for="option in pddSpecValues('size')" :key="option.name" :label="option.name" :value="option.name" /></el-select><div v-if="row.previewReason" class="muted preview-reason">{{ row.previewReason }}</div></template></el-table-column>
|
||||
<el-table-column label="状态" width="150"><template #default="{ row }"><el-tag :type="rowStatus(row).type" size="small">{{ rowStatus(row).label }}</el-tag></template></el-table-column>
|
||||
<el-table-column label="操作" width="90"><template #default="{ row }"><el-button link type="primary" :disabled="!row.pddValueDraft" @click="clearDraft(row)">清除</el-button></template></el-table-column>
|
||||
<el-table-column label="操作" width="150"><template #default="{ row }"><el-button link type="primary" :disabled="!row.pddValueDraft" @click="clearDraft(row)">清除</el-button><el-button v-if="row.pddValueDraft === row.originalPddValue && row.mapping && row.mapping.status === 'pending'" link type="primary" @click="confirmPendingMapping(row)">确认</el-button></template></el-table-column>
|
||||
</el-table>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
@@ -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('规格匹配已保存')
|
||||
|
||||
Reference in New Issue
Block a user