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:
QiuSW
2026-08-31 16:18:42 +08:00
co-authored by Claude Sonnet 5
parent 1d0b0ce086
commit 21b38c4444
10 changed files with 900 additions and 24 deletions
+203
View File
@@ -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))
}
+51 -1
View File
@@ -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
}
+8
View File
@@ -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 })
}
+48 -23
View File
@@ -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('规格匹配已保存')