package service import ( "context" "database/sql" "encoding/json" "fmt" "sort" "strings" "time" "unicode/utf8" "cmautobuy/admin/model" "cmautobuy/admin/repository" "cmautobuy/admin/spec" ) const ( AISpecMatchPromptVersion = "spec_prompt_v1" // 单次模型调用最多携带 150 个已过滤的可购买候选。超过这个数量时, // 候选本身仍会写入审计记录,但不会让模型在过于宽泛的范围里猜测。 maxAIModelCandidates = 150 ) type AIMatchSnapshot struct { Provider model.AIProviderConfig ConfigFingerprint string Secret string Client AIModelClient } type AISpecMatchResult struct { Outcome string Message string Source string OptionKey string ConfidenceBPS int ConfidenceSet bool ContextVersion string ModelCalled bool } type aiCandidateBinding struct { ID string Choice PddOptionChoice } // LoadActiveAIMatchSnapshot 固定新匹配动作使用的服务商配置;调用方可在整个批次复用它。 func LoadActiveAIMatchSnapshot(ctx context.Context, db *sql.DB, secrets AISecretStore, policy AIEndpointPolicy) (AIMatchSnapshot, error) { provider, err := repository.GetEnabledAIProvider(db) if err != nil { return AIMatchSnapshot{}, err } if provider == nil { return AIMatchSnapshot{}, fmt.Errorf("尚未启用 AI 服务商,请管理员先完成连接测试并启用") } fingerprint := aiProviderFingerprint(*provider) if provider.LastTestStatus != "succeeded" || provider.LastTestFingerprint != fingerprint { return AIMatchSnapshot{}, fmt.Errorf("当前 AI 服务商配置未通过有效连接测试") } secret, err := secrets.Get(provider.ProviderID) if err != nil { return AIMatchSnapshot{}, err } if secret == "" { return AIMatchSnapshot{}, fmt.Errorf("当前 AI 服务商未配置 API Key") } if err := policy.ValidateResolved(ctx, provider.BaseURL); err != nil { return AIMatchSnapshot{}, err } client := NewOpenAICompatibleModelClient(NewSafeAIHTTPClient(policy, time.Duration(provider.TimeoutSeconds)*time.Second)) return AIMatchSnapshot{Provider: *provider, ConfigFingerprint: fingerprint, Secret: secret, Client: client}, nil } // MatchSybSpecWithAI 对一条 SYB 商品执行规则优先、AI 补充的候选匹配。 func MatchSybSpecWithAI(ctx context.Context, db *sql.DB, actor *model.User, snapshot AIMatchSnapshot, sybID, expectedVersion string) (AISpecMatchResult, error) { if actor == nil || !actor.IsActive() { return AISpecMatchResult{}, ErrUnauthenticated } orderContext, err := repository.GetSybOrderContext(db, strings.TrimSpace(sybID)) if err != nil { return AISpecMatchResult{}, err } if orderContext == nil { return AISpecMatchResult{}, fmt.Errorf("顺运宝明细不存在") } version := mappingContextVersion(*orderContext) if expectedVersion != "" && expectedVersion != version { return AISpecMatchResult{Outcome: "stale", Message: "数据已变化,请刷新后重试", ContextVersion: version}, nil } key, err := spec.SpecKey(orderContext.Order.ProductSpec) if err != nil || orderContext.Order.SpecKey == "" || key != orderContext.Order.SpecKey { return AISpecMatchResult{Outcome: "rejected", Message: "顺运宝未提供有效规格", ContextVersion: version}, nil } if orderContext.PddGoodsID == "" || orderContext.PddCollectStatus != string(model.CollectCollected) || strings.TrimSpace(orderContext.PddSkusJSON) == "" { return AISpecMatchResult{Outcome: "rejected", Message: "当前 PDD 商品尚未完成采集", ContextVersion: version}, nil } if mappingIsValid(*orderContext) { source := orderContext.MappingSource if source == "" { source = "manual" } return AISpecMatchResult{Outcome: "reused", Message: "已复用当前有效规格映射", Source: source, OptionKey: orderContext.MappingOptionKey, ConfidenceBPS: orderContext.MappingConfidenceBPS, ConfidenceSet: orderContext.MappingConfidenceSet, ContextVersion: version}, nil } choices, keys, names, err := pddOptionChoices(orderContext.PddSkusJSON) if err != nil { return AISpecMatchResult{}, fmt.Errorf("读取 PDD 规格失败: %w", err) } baseDecision := newAISpecDecision(*orderContext, *actor, snapshot, version) if len(choices) == 0 { return recordAIMatchWithoutSave(db, baseDecision, "rejected", "PDD 没有当前可购买规格") } match := rankSpecChoices(orderContext.Order.ProductSpec, choices, keys, names) if match.PreselectOptionKey != "" { choice := choiceByKey(match.Choices, match.PreselectOptionKey) baseDecision.CandidatesJSON = candidateAuditJSON(bindAICandidates(match.Choices)) if utf8.RuneCountInString(choice.Key) > 191 { return recordAIMatchWithoutSave(db, baseDecision, "rejected", "规则候选规格键超过可保存长度") } baseDecision.ChosenOptionKey, baseDecision.ConfidenceBPS, baseDecision.ConfidenceSet = choice.Key, 10000, true baseDecision.Reason = choice.RecommendationReason return saveAutomaticMapping(db, *actor, *orderContext, choice, "rule", baseDecision) } resolved, resolveReason := resolveExtraDimensionSignals(orderContext.Order.ProductSpec, match.Choices, keys, names) if resolveReason != "" { baseDecision.CandidatesJSON = candidateAuditJSON(bindAICandidates(match.Choices)) return recordAIMatchWithoutSave(db, baseDecision, "rejected", resolveReason) } match = rankSpecChoices(orderContext.Order.ProductSpec, resolved, keys, names) if match.PreselectOptionKey != "" { choice := choiceByKey(match.Choices, match.PreselectOptionKey) baseDecision.CandidatesJSON = candidateAuditJSON(bindAICandidates(match.Choices)) if utf8.RuneCountInString(choice.Key) > 191 { return recordAIMatchWithoutSave(db, baseDecision, "rejected", "规则候选规格键超过可保存长度") } baseDecision.ChosenOptionKey, baseDecision.ConfidenceBPS, baseDecision.ConfidenceSet = choice.Key, 10000, true baseDecision.Reason = choice.RecommendationReason return saveAutomaticMapping(db, *actor, *orderContext, choice, "rule", baseDecision) } eligible := make([]PddOptionChoice, 0, len(match.Choices)) for _, choice := range match.Choices { if choice.MatchLevel != "冲突" && utf8.RuneCountInString(choice.Key) <= 191 { eligible = append(eligible, choice) } } if len(eligible) == 0 { baseDecision.CandidatesJSON = `[]` return recordAIMatchWithoutSave(db, baseDecision, "rejected", "全部候选都与顺运宝规格明确冲突") } if len(eligible) > maxAIModelCandidates { baseDecision.CandidatesJSON = candidateAuditJSON(bindAICandidates(eligible)) return recordAIMatchWithoutSave(db, baseDecision, "rejected", "可购买候选超过 150 条,请先人工核对") } bindings := bindAICandidates(eligible) baseDecision.CandidatesJSON = candidateAuditJSON(bindings) request := AIModelMatchRequest{ProductTitle: truncateRunes(orderContext.Order.Title, 300), SourceSpec: truncateRunes(orderContext.Order.ProductSpec, 300)} for _, binding := range bindings { request.Candidates = append(request.Candidates, AIModelCandidate{ID: binding.ID, Label: truncateRunes(binding.Choice.Label, 300), Options: boundedAIOptions(binding.Choice.Options)}) } selection := selectAIWhitelistedCandidate(ctx, snapshot, request) response := selection.Response baseDecision.ChosenCandidateID = response.CandidateID baseDecision.ConfidenceBPS = response.ConfidenceBPS baseDecision.ConfidenceSet = selection.ConfidenceSet baseDecision.Reason = selection.Reason baseDecision.ConflictDimensionsJSON = stringArrayJSON(response.ConflictDimensions) baseDecision.MissingDimensionsJSON = stringArrayJSON(response.MissingDimensions) if selection.Status != aiSelectionMatched { outcome := "rejected" if selection.Status == aiSelectionFailed { outcome = "failed" } result, auditErr := recordAIMatchWithoutSave(db, baseDecision, outcome, selection.Reason) result.ModelCalled = selection.ModelCalled return result, auditErr } selected, found := bindingByID(bindings, response.CandidateID) if !found { // selectAIWhitelistedCandidate 已核对过同一 request;此分支只防以后 // 绑定构造被改坏时把错误候选写进真实映射。 result, auditErr := recordAIMatchWithoutSave(db, baseDecision, "rejected", "候选绑定已变化,未保存映射") result.ModelCalled = selection.ModelCalled return result, auditErr } baseDecision.ChosenOptionKey = selected.Choice.Key result, err := saveAutomaticMapping(db, *actor, *orderContext, selected.Choice, "ai", baseDecision) result.ModelCalled = selection.ModelCalled return result, err } func saveAutomaticMapping(db *sql.DB, actor model.User, original repository.SybOrderContext, selected PddOptionChoice, source string, decision model.AISpecMatchDecision) (AISpecMatchResult, error) { tx, err := db.Begin() if err != nil { return AISpecMatchResult{}, err } defer tx.Rollback() current, err := repository.GetSybOrderContext(tx, original.Order.SybID) if err != nil { return AISpecMatchResult{}, err } if current == nil || mappingContextVersion(*current) != decision.ContextVersion { decision.Outcome, decision.Reason = "stale", "保存前数据或规则已变化" if err := repository.InsertAISpecMatchDecision(tx, decision); err != nil { return AISpecMatchResult{}, err } if err := tx.Commit(); err != nil { return AISpecMatchResult{}, err } return AISpecMatchResult{Outcome: "stale", Message: decision.Reason, ContextVersion: decision.ContextVersion}, nil } latestChoice, err := findPddChoice(current.PddSkusJSON, selected.Key) if err != nil { return AISpecMatchResult{}, err } if latestChoice == nil { decision.Outcome, decision.Reason = "stale", "所选 PDD 规格已不存在或不可购买" if err := repository.InsertAISpecMatchDecision(tx, decision); err != nil { return AISpecMatchResult{}, err } if err := tx.Commit(); err != nil { return AISpecMatchResult{}, err } return AISpecMatchResult{Outcome: "stale", Message: decision.Reason, ContextVersion: decision.ContextVersion}, nil } if source == "ai" { choices, keys, names, parseErr := pddOptionChoices(current.PddSkusJSON) if parseErr != nil { return AISpecMatchResult{}, parseErr } match := rankSpecChoices(current.Order.ProductSpec, choices, keys, names) resolved, reason := resolveExtraDimensionSignals(current.Order.ProductSpec, match.Choices, keys, names) if reason != "" || !eligibleChoiceContains(current.Order.ProductSpec, resolved, keys, names, selected.Key) { decision.Outcome, decision.Reason = "stale", "保存前候选硬校验不再通过" if err := repository.InsertAISpecMatchDecision(tx, decision); err != nil { return AISpecMatchResult{}, err } if err := tx.Commit(); err != nil { return AISpecMatchResult{}, err } return AISpecMatchResult{Outcome: "stale", Message: decision.Reason, ContextVersion: decision.ContextVersion}, nil } } mappedAt := model.NowISO() mapping := model.SpecMapping{ShopeeGoodsID: current.Order.ShopeeGoodsID, SpecKey: current.Order.SpecKey, PddGoodsID: current.PddGoodsID, PddOptionKey: latestChoice.Key, PddOptions: latestChoice.OptionsJSON, SpecRaw: current.Order.ProductSpec, MappedAt: mappedAt, MappedBy: actor.UserID, Source: source, ConfidenceBPS: decision.ConfidenceBPS, ConfidenceSet: true, SourceReason: truncateRunes(decision.Reason, 500), SourceVersion: SpecMatchRulesVersion, ContextVersion: decision.ContextVersion} if source == "ai" { mapping.SourceProviderID, mapping.SourceModel = decision.ProviderID, decision.Model mapping.SourceVersion = decision.PromptVersion + "+" + decision.RulesVersion } if err := repository.UpsertAutomaticSpecMapping(tx, mapping); err != nil { return AISpecMatchResult{}, err } actual, err := repository.GetSpecMapping(tx, mapping.ShopeeGoodsID, mapping.SpecKey, mapping.PddGoodsID) if err != nil { return AISpecMatchResult{}, err } if actual == nil { return AISpecMatchResult{}, fmt.Errorf("自动规格映射保存后无法读取") } if actual.Source != source || actual.ContextVersion != mapping.ContextVersion || actual.PddOptionKey != mapping.PddOptionKey { outcome := "reused" actualSource := actual.Source if actualSource == "manual" || actualSource == "" { outcome, actualSource = "manual_exists", "manual" } decision.Outcome, decision.Reason, decision.ChosenOptionKey = outcome, "保存时发现已有映射,自动结果未覆盖", actual.PddOptionKey if err := repository.InsertAISpecMatchDecision(tx, decision); err != nil { return AISpecMatchResult{}, err } if err := tx.Commit(); err != nil { return AISpecMatchResult{}, err } return AISpecMatchResult{Outcome: outcome, Message: decision.Reason, Source: actualSource, OptionKey: actual.PddOptionKey, ConfidenceBPS: actual.ConfidenceBPS, ConfidenceSet: actual.ConfidenceSet, ContextVersion: decision.ContextVersion}, nil } decision.Outcome = source + "_saved" decision.ChosenOptionKey = latestChoice.Key decision.DecidedAt = mappedAt if err := repository.InsertAISpecMatchDecision(tx, decision); err != nil { return AISpecMatchResult{}, err } if err := tx.Commit(); err != nil { return AISpecMatchResult{}, err } return AISpecMatchResult{Outcome: decision.Outcome, Message: "规格映射已保存", Source: source, OptionKey: latestChoice.Key, ConfidenceBPS: decision.ConfidenceBPS, ConfidenceSet: decision.ConfidenceSet, ContextVersion: decision.ContextVersion}, nil } func recordAIMatchWithoutSave(db *sql.DB, decision model.AISpecMatchDecision, outcome, reason string) (AISpecMatchResult, error) { decision.Outcome, decision.Reason = outcome, truncateRunes(reason, 500) if decision.CandidatesJSON == "" { decision.CandidatesJSON = `[]` } if decision.ConflictDimensionsJSON == "" { decision.ConflictDimensionsJSON = `[]` } if decision.MissingDimensionsJSON == "" { decision.MissingDimensionsJSON = `[]` } if err := repository.InsertAISpecMatchDecision(db, decision); err != nil { return AISpecMatchResult{}, err } return AISpecMatchResult{Outcome: outcome, Message: decision.Reason, ConfidenceBPS: decision.ConfidenceBPS, ConfidenceSet: decision.ConfidenceSet, ContextVersion: decision.ContextVersion}, nil } func newAISpecDecision(c repository.SybOrderContext, actor model.User, snapshot AIMatchSnapshot, version string) model.AISpecMatchDecision { return model.AISpecMatchDecision{ShopeeGoodsID: c.Order.ShopeeGoodsID, SpecKey: c.Order.SpecKey, PddGoodsID: c.PddGoodsID, ContextVersion: version, RulesVersion: SpecMatchRulesVersion, PromptVersion: AISpecMatchPromptVersion, ProviderID: snapshot.Provider.ProviderID, ProviderName: snapshot.Provider.Name, Model: snapshot.Provider.Model, ConfigFingerprint: snapshot.ConfigFingerprint, CandidatesJSON: `[]`, ConflictDimensionsJSON: `[]`, MissingDimensionsJSON: `[]`, DecidedBy: actor.UserID, DecidedAt: model.NowISO()} } func bindAICandidates(choices []PddOptionChoice) []aiCandidateBinding { result := make([]aiCandidateBinding, 0, len(choices)) for i, choice := range choices { result = append(result, aiCandidateBinding{ID: fmt.Sprintf("C%02d", i+1), Choice: choice}) } return result } func bindingByID(bindings []aiCandidateBinding, id string) (aiCandidateBinding, bool) { for _, binding := range bindings { if binding.ID == id { return binding, true } } return aiCandidateBinding{}, false } func candidateAuditJSON(bindings []aiCandidateBinding) string { type item struct { ID string `json:"id"` OptionKey string `json:"option_key"` } values := make([]item, 0, len(bindings)) for _, binding := range bindings { values = append(values, item{ID: binding.ID, OptionKey: binding.Choice.Key}) } raw, _ := json.Marshal(values) return string(raw) } func resolveExtraDimensionSignals(raw string, choices []PddOptionChoice, keys, names []string) ([]PddOptionChoice, string) { colorKey, sizeKey, ambiguous, _ := identifyDimensions(choices, keys, names) if ambiguous { return nil, "颜色或尺码维度定义不明确,请人工核对" } filtered := append([]PddOptionChoice(nil), choices...) normalizedRaw := normalizeDimensionSignal(raw) for _, key := range keys { if key == colorKey || key == sizeKey { continue } values := map[string]bool{} for _, choice := range filtered { values[choice.Options[key]] = true } if len(values) <= 1 { continue } var matched []string for value := range values { normalizedValue := normalizeDimensionSignal(value) if normalizedValue != "" && strings.Contains(normalizedRaw, normalizedValue) { matched = append(matched, value) } } sort.Strings(matched) if len(matched) != 1 { return nil, "存在无法从顺运宝规格确定的额外规格维度,请人工核对" } selectedValue := matched[0] next := filtered[:0] for _, choice := range filtered { if choice.Options[key] == selectedValue { next = append(next, choice) } } filtered = append([]PddOptionChoice(nil), next...) } return filtered, "" } func normalizeDimensionSignal(raw string) string { return strings.ToLower(strings.Join(strings.Fields(simplifyExplicit(raw)), "")) } func boundedAIOptions(options map[string]string) map[string]string { result := make(map[string]string, len(options)) for key, value := range options { result[truncateRunes(key, 64)] = truncateRunes(value, 128) } return result } func eligibleChoiceContains(raw string, choices []PddOptionChoice, keys, names []string, optionKey string) bool { match := rankSpecChoices(raw, choices, keys, names) for _, choice := range match.Choices { if choice.Key == optionKey && choice.MatchLevel != "冲突" { return true } } return false } func choiceByKey(choices []PddOptionChoice, key string) PddOptionChoice { for _, choice := range choices { if choice.Key == key { return choice } } return PddOptionChoice{} } func validateAIModelMatchResponse(response AIModelMatchResponse) error { if response.Conclusion != "match" && response.Conclusion != "uncertain" && response.Conclusion != "conflict" { return fmt.Errorf("AI 模型结论字段无效") } if response.ConfidenceBPS < 0 || response.ConfidenceBPS > 10000 { return fmt.Errorf("AI 模型置信度超出范围") } if response.Conclusion == "match" && strings.TrimSpace(response.CandidateID) == "" { return fmt.Errorf("AI 模型没有返回候选编号") } if strings.TrimSpace(response.Reason) == "" || utf8.RuneCountInString(response.Reason) > 500 { return fmt.Errorf("AI 模型理由为空或过长") } for _, values := range [][]string{response.ConflictDimensions, response.MissingDimensions} { if len(values) > 8 { return fmt.Errorf("AI 模型返回的维度列表过长") } for _, value := range values { if strings.TrimSpace(value) == "" || utf8.RuneCountInString(value) > 64 { return fmt.Errorf("AI 模型返回的维度名称无效") } } } return nil } func stringArrayJSON(values []string) string { if values == nil { values = []string{} } raw, _ := json.Marshal(values) return string(raw) } func truncateRunes(value string, max int) string { runes := []rune(strings.TrimSpace(value)) if len(runes) > max { runes = runes[:max] } return string(runes) }