feat: 实现 AI 规格候选匹配与审计 (#201)

This commit is contained in:
chengma
2026-08-14 10:08:32 +08:00
parent 2f51126e1d
commit aac289a4d3
15 changed files with 1200 additions and 25 deletions
+476
View File
@@ -0,0 +1,476 @@
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"
maxAIModelCandidates = 24
)
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
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, 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", "可购买候选过多,请先人工缩小范围")
}
bindings := bindAICandidates(eligible)
baseDecision.CandidatesJSON = candidateAuditJSON(bindings)
if snapshot.Client == nil || snapshot.Secret == "" || snapshot.Provider.ProviderID == "" {
return recordAIMatchWithoutSave(db, baseDecision, "failed", "AI 服务商运行快照不可用")
}
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)})
}
response, modelErr := snapshot.Client.Match(ctx, snapshot.Provider, snapshot.Secret, request)
if modelErr != nil {
result, auditErr := recordAIMatchWithoutSave(db, baseDecision, "failed", safeAIError(modelErr))
result.ModelCalled = true
return result, auditErr
}
if err := validateAIModelMatchResponse(response); err != nil {
result, auditErr := recordAIMatchWithoutSave(db, baseDecision, "failed", err.Error())
result.ModelCalled = true
return result, auditErr
}
baseDecision.ChosenCandidateID = response.CandidateID
baseDecision.ConfidenceBPS = response.ConfidenceBPS
baseDecision.ConfidenceSet = true
baseDecision.Reason = response.Reason
baseDecision.ConflictDimensionsJSON = stringArrayJSON(response.ConflictDimensions)
baseDecision.MissingDimensionsJSON = stringArrayJSON(response.MissingDimensions)
if response.Conclusion != "match" {
result, auditErr := recordAIMatchWithoutSave(db, baseDecision, "rejected", response.Reason)
result.ModelCalled = true
return result, auditErr
}
selected, found := bindingByID(bindings, response.CandidateID)
if !found {
result, auditErr := recordAIMatchWithoutSave(db, baseDecision, "rejected", "模型返回了不在候选白名单中的编号")
result.ModelCalled = true
return result, auditErr
}
baseDecision.ChosenOptionKey = selected.Choice.Key
if len(response.ConflictDimensions) > 0 || len(response.MissingDimensions) > 0 {
result, auditErr := recordAIMatchWithoutSave(db, baseDecision, "rejected", "模型报告仍有冲突或缺失维度")
result.ModelCalled = true
return result, auditErr
}
if response.ConfidenceBPS < snapshot.Provider.ConfidenceThresholdBPS {
result, auditErr := recordAIMatchWithoutSave(db, baseDecision, "rejected", "模型置信度低于管理员设置的自动写入阈值")
result.ModelCalled = true
return result, auditErr
}
result, err := saveAutomaticMapping(db, *actor, *orderContext, selected.Choice, "ai", baseDecision)
result.ModelCalled = true
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, 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, 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,
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)
}
+109
View File
@@ -0,0 +1,109 @@
package service
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"cmautobuy/admin/model"
)
const maxAIModelResponseBytes = 64 << 10
type AIModelCandidate struct {
ID string `json:"id"`
Label string `json:"label"`
Options map[string]string `json:"options"`
}
type AIModelMatchRequest struct {
ProductTitle string `json:"product_title"`
SourceSpec string `json:"source_spec"`
Candidates []AIModelCandidate `json:"candidates"`
}
type AIModelMatchResponse struct {
Conclusion string `json:"conclusion"`
CandidateID string `json:"candidate_id"`
ConfidenceBPS int `json:"confidence_bps"`
Reason string `json:"reason"`
ConflictDimensions []string `json:"conflict_dimensions"`
MissingDimensions []string `json:"missing_dimensions"`
}
type AIModelClient interface {
Match(context.Context, model.AIProviderConfig, string, AIModelMatchRequest) (AIModelMatchResponse, error)
}
type OpenAICompatibleModelClient struct{ doer AIHTTPDoer }
func NewOpenAICompatibleModelClient(doer AIHTTPDoer) *OpenAICompatibleModelClient {
return &OpenAICompatibleModelClient{doer: doer}
}
func (c *OpenAICompatibleModelClient) Match(ctx context.Context, provider model.AIProviderConfig, secret string, input AIModelMatchRequest) (AIModelMatchResponse, error) {
if c == nil || c.doer == nil {
return AIModelMatchResponse{}, fmt.Errorf("AI 模型客户端未初始化")
}
inputJSON, err := json.Marshal(input)
if err != nil {
return AIModelMatchResponse{}, fmt.Errorf("准备 AI 规格候选失败")
}
system := `你是商品规格候选选择器。只能从 candidates 的 id 中选择,不能生成新候选。` +
`只返回 JSON:conclusion(match/uncertain/conflict)、candidate_id、confidence_bps(0-10000)、` +
`reason、conflict_dimensions、missing_dimensions。信息不足时 conclusion 必须是 uncertain。`
payload, err := json.Marshal(map[string]any{
"model": provider.Model,
"messages": []map[string]string{{"role": "system", "content": system}, {"role": "user", "content": string(inputJSON)}},
"temperature": 0, "max_tokens": 300,
"response_format": map[string]string{"type": "json_object"},
})
if err != nil {
return AIModelMatchResponse{}, fmt.Errorf("准备 AI 模型请求失败")
}
request, err := http.NewRequestWithContext(ctx, http.MethodPost,
strings.TrimRight(provider.BaseURL, "/")+"/chat/completions", bytes.NewReader(payload))
if err != nil {
return AIModelMatchResponse{}, fmt.Errorf("准备 AI 模型请求失败")
}
request.Header.Set("Authorization", "Bearer "+secret)
request.Header.Set("Content-Type", "application/json")
response, err := c.doer.Do(request)
if err != nil {
return AIModelMatchResponse{}, fmt.Errorf("AI 模型请求失败")
}
defer response.Body.Close()
if response.StatusCode < 200 || response.StatusCode >= 300 {
_, _ = io.Copy(io.Discard, io.LimitReader(response.Body, maxAIModelResponseBytes))
return AIModelMatchResponse{}, fmt.Errorf("AI 模型返回 HTTP %d", response.StatusCode)
}
raw, err := io.ReadAll(io.LimitReader(response.Body, maxAIModelResponseBytes+1))
if err != nil || len(raw) > maxAIModelResponseBytes {
return AIModelMatchResponse{}, fmt.Errorf("AI 模型响应无法读取或过大")
}
var outer struct {
Choices []struct {
Message struct {
Content string `json:"content"`
} `json:"message"`
} `json:"choices"`
}
if err := json.Unmarshal(raw, &outer); err != nil || len(outer.Choices) == 0 {
return AIModelMatchResponse{}, fmt.Errorf("AI 模型响应格式不正确")
}
decoder := json.NewDecoder(strings.NewReader(outer.Choices[0].Message.Content))
decoder.DisallowUnknownFields()
var result AIModelMatchResponse
if err := decoder.Decode(&result); err != nil {
return AIModelMatchResponse{}, fmt.Errorf("AI 模型结论不是有效 JSON")
}
var extra any
if decoder.Decode(&extra) != io.EOF {
return AIModelMatchResponse{}, fmt.Errorf("AI 模型结论包含多余内容")
}
return result, nil
}
+60
View File
@@ -0,0 +1,60 @@
package service
import (
"context"
"io"
"net/http"
"strings"
"testing"
"cmautobuy/admin/model"
)
type staticAIResponseDoer struct {
status int
body string
seen *http.Request
}
func (d *staticAIResponseDoer) Do(request *http.Request) (*http.Response, error) {
d.seen = request
return &http.Response{StatusCode: d.status, Body: io.NopCloser(strings.NewReader(d.body))}, nil
}
func TestOpenAICompatibleModelClient_解析严格JSON且不把密钥放进正文(t *testing.T) {
doer := &staticAIResponseDoer{status: 200, body: `{"choices":[{"message":{"content":"{\"conclusion\":\"match\",\"candidate_id\":\"C01\",\"confidence_bps\":9300,\"reason\":\"规格一致\",\"conflict_dimensions\":[],\"missing_dimensions\":[]}"}}]}`}
client := NewOpenAICompatibleModelClient(doer)
const fakeSecret = "fake-secret-not-production"
result, err := client.Match(context.Background(), model.AIProviderConfig{BaseURL: "https://api.example.com/v1", Model: "fake"}, fakeSecret,
AIModelMatchRequest{ProductTitle: "测试商品", SourceSpec: "黑色,M", Candidates: []AIModelCandidate{{ID: "C01", Label: "黑色/M"}}})
if err != nil || result.CandidateID != "C01" || result.ConfidenceBPS != 9300 {
t.Fatalf("解析结果=%+v err=%v", result, err)
}
body, _ := io.ReadAll(doer.seen.Body)
if strings.Contains(string(body), fakeSecret) {
t.Fatal("API Key 只能放 Authorization,不能进入请求正文")
}
if got := doer.seen.Header.Get("Authorization"); got != "Bearer "+fakeSecret {
t.Fatalf("Authorization=%q", got)
}
}
func TestOpenAICompatibleModelClient_拒绝无效或多余模型字段(t *testing.T) {
for _, content := range []string{
`not-json`,
`{"conclusion":"match","candidate_id":"C01","confidence_bps":9000,"reason":"x","conflict_dimensions":[],"missing_dimensions":[],"option_key":"forged"}`,
} {
doer := &staticAIResponseDoer{status: 200, body: `{"choices":[{"message":{"content":` + quoteJSONString(content) + `}}]}`}
client := NewOpenAICompatibleModelClient(doer)
if _, err := client.Match(context.Background(), model.AIProviderConfig{BaseURL: "https://api.example.com/v1", Model: "fake"}, "fake-secret",
AIModelMatchRequest{Candidates: []AIModelCandidate{{ID: "C01"}}}); err == nil {
t.Fatalf("无效结论应被拒绝: %s", content)
}
}
}
func quoteJSONString(value string) string {
value = strings.ReplaceAll(value, `\`, `\\`)
value = strings.ReplaceAll(value, `"`, `\"`)
return `"` + value + `"`
}
+200
View File
@@ -0,0 +1,200 @@
package service
import (
"context"
"database/sql"
"errors"
"testing"
"cmautobuy/admin/model"
"cmautobuy/admin/repository"
)
type fakeAIModelClient struct {
response AIModelMatchResponse
err error
calls int
last AIModelMatchRequest
beforeReturn func()
}
func (f *fakeAIModelClient) Match(_ context.Context, _ model.AIProviderConfig, _ string, request AIModelMatchRequest) (AIModelMatchResponse, error) {
f.calls++
f.last = request
if f.beforeReturn != nil {
f.beforeReturn()
}
return f.response, f.err
}
func TestValidateAIModelMatchResponse_严格结构(t *testing.T) {
valid := AIModelMatchResponse{Conclusion: "match", CandidateID: "C01", ConfidenceBPS: 9000, Reason: "颜色和尺码一致"}
if err := validateAIModelMatchResponse(valid); err != nil {
t.Fatal(err)
}
for _, invalid := range []AIModelMatchResponse{
{Conclusion: "yes", Reason: "x"},
{Conclusion: "match", ConfidenceBPS: 9000, Reason: "x"},
{Conclusion: "uncertain", ConfidenceBPS: 10001, Reason: "x"},
{Conclusion: "uncertain", ConfidenceBPS: 1},
} {
if err := validateAIModelMatchResponse(invalid); err == nil {
t.Fatalf("无效模型结论应被拒绝: %+v", invalid)
}
}
}
func TestBindingByID_伪造候选不能映射到真实选项(t *testing.T) {
bindings := bindAICandidates([]PddOptionChoice{{Key: `{"color":"黑色"}`}})
if _, ok := bindingByID(bindings, "C99"); ok {
t.Fatal("伪造候选编号不能命中服务端白名单")
}
if got, ok := bindingByID(bindings, "C01"); !ok || got.Choice.Key == "" {
t.Fatal("服务端生成的候选编号应能还原真实选项")
}
}
func TestResolveExtraDimensionSignals_额外维度必须有确定信号(t *testing.T) {
first := map[string]string{"color": "黑色", "size": "M", "style": "常规"}
second := map[string]string{"color": "黑色", "size": "M", "style": "加绒"}
firstKey, _ := OptionKey(first)
secondKey, _ := OptionKey(second)
choices := []PddOptionChoice{{Key: firstKey, Label: "黑色 M 常规", Options: first}, {Key: secondKey, Label: "黑色 M 加绒", Options: second}}
keys, names := []string{"color", "size", "style"}, []string{"颜色", "尺码", "款式"}
if _, reason := resolveExtraDimensionSignals("黑色,M", choices, keys, names); reason == "" {
t.Fatal("没有款式信号时不能交给 AI 猜额外维度")
}
got, reason := resolveExtraDimensionSignals("黑色,M,加绒", choices, keys, names)
if reason != "" || len(got) != 1 || got[0].Options["style"] != "加绒" {
t.Fatalf("明确额外维度应缩小候选: got=%+v reason=%q", got, reason)
}
}
func TestMatchSybSpecWithAI_候选白名单低置信度和人工优先(t *testing.T) {
t.Run("合格AI结果直接保存", func(t *testing.T) {
db := newTestDB(t)
actor := seedAIMatchContext(t, db, "SYB-AI-SAVE")
fake := &fakeAIModelClient{response: AIModelMatchResponse{Conclusion: "match", CandidateID: "C01", ConfidenceBPS: 9200, Reason: "主色和尺码一致"}}
result, err := MatchSybSpecWithAI(context.Background(), db, &actor, testAIMatchSnapshot(fake), "SYB-AI-SAVE", "")
if err != nil || result.Outcome != "ai_saved" || result.Source != "ai" || fake.calls != 1 {
t.Fatalf("AI 保存结果=%+v calls=%d err=%v", result, fake.calls, err)
}
var source string
if err := db.QueryRow(`SELECT source FROM spec_mappings WHERE shopee_goods_id='SP-AI'`).Scan(&source); err != nil || source != "ai" {
t.Fatalf("当前映射来源=%q err=%v", source, err)
}
if err := SaveSybMapping(db, "SYB-AI-SAVE", result.OptionKey, actor.UserID); err != nil {
t.Fatalf("人工覆盖 AI 映射失败: %v", err)
}
var auditCount int
if err := db.QueryRow(`SELECT source FROM spec_mappings WHERE shopee_goods_id='SP-AI'`).Scan(&source); err != nil || source != "manual" {
t.Fatalf("人工覆盖后来源=%q err=%v", source, err)
}
if err := db.QueryRow(`SELECT COUNT(*) FROM ai_spec_match_decisions WHERE shopee_goods_id='SP-AI' AND outcome='ai_saved'`).Scan(&auditCount); err != nil || auditCount != 1 {
t.Fatalf("人工覆盖不得删除 AI 审计: count=%d err=%v", auditCount, err)
}
})
t.Run("伪造候选不写映射", func(t *testing.T) {
db := newTestDB(t)
actor := seedAIMatchContext(t, db, "SYB-AI-FORGE")
fake := &fakeAIModelClient{response: AIModelMatchResponse{Conclusion: "match", CandidateID: "C99", ConfidenceBPS: 9900, Reason: "尝试越界"}}
result, err := MatchSybSpecWithAI(context.Background(), db, &actor, testAIMatchSnapshot(fake), "SYB-AI-FORGE", "")
if err != nil || result.Outcome != "rejected" {
t.Fatalf("伪造候选结果=%+v err=%v", result, err)
}
assertNoAIMapping(t, db)
})
t.Run("低置信度不写映射", func(t *testing.T) {
db := newTestDB(t)
actor := seedAIMatchContext(t, db, "SYB-AI-LOW")
fake := &fakeAIModelClient{response: AIModelMatchResponse{Conclusion: "match", CandidateID: "C01", ConfidenceBPS: 7999, Reason: "信号偏弱"}}
result, err := MatchSybSpecWithAI(context.Background(), db, &actor, testAIMatchSnapshot(fake), "SYB-AI-LOW", "")
if err != nil || result.Outcome != "rejected" {
t.Fatalf("低置信度结果=%+v err=%v", result, err)
}
assertNoAIMapping(t, db)
})
t.Run("已有人工映射不调用模型", func(t *testing.T) {
db := newTestDB(t)
actor := seedAIMatchContext(t, db, "SYB-AI-MANUAL")
key, _ := OptionKey(map[string]string{"color": "黑色", "size": "M"})
if err := SaveSybMapping(db, "SYB-AI-MANUAL", key, actor.UserID); err != nil {
t.Fatal(err)
}
fake := &fakeAIModelClient{err: errors.New("不应调用")}
result, err := MatchSybSpecWithAI(context.Background(), db, &actor, testAIMatchSnapshot(fake), "SYB-AI-MANUAL", "")
if err != nil || result.Outcome != "reused" || result.Source != "manual" || fake.calls != 0 {
t.Fatalf("人工复用结果=%+v calls=%d err=%v", result, fake.calls, err)
}
})
t.Run("唯一确定规则结果不调用模型", func(t *testing.T) {
db := newTestDB(t)
actor := seedAIMatchContextWithData(t, db, "SYB-AI-RULE", "灰色-小個子,L建議53-57公斤", collectedRuleChoices)
fake := &fakeAIModelClient{err: errors.New("不应调用")}
result, err := MatchSybSpecWithAI(context.Background(), db, &actor, testAIMatchSnapshot(fake), "SYB-AI-RULE", "")
if err != nil || result.Outcome != "rule_saved" || result.Source != "rule" || fake.calls != 0 {
t.Fatalf("规则保存结果=%+v calls=%d err=%v", result, fake.calls, err)
}
})
t.Run("模型调用期间人工保存仍然优先", func(t *testing.T) {
db := newTestDB(t)
actor := seedAIMatchContext(t, db, "SYB-AI-RACE")
key, _ := OptionKey(map[string]string{"color": "黑色", "size": "M"})
fake := &fakeAIModelClient{response: AIModelMatchResponse{Conclusion: "match", CandidateID: "C01", ConfidenceBPS: 9500, Reason: "规格一致"}}
fake.beforeReturn = func() {
if err := SaveSybMapping(db, "SYB-AI-RACE", key, actor.UserID); err != nil {
t.Fatalf("并发人工保存失败: %v", err)
}
}
result, err := MatchSybSpecWithAI(context.Background(), db, &actor, testAIMatchSnapshot(fake), "SYB-AI-RACE", "")
if err != nil || result.Outcome != "manual_exists" || result.Source != "manual" {
t.Fatalf("人工并发优先结果=%+v err=%v", result, err)
}
var source string
if err := db.QueryRow(`SELECT source FROM spec_mappings WHERE shopee_goods_id='SP-AI'`).Scan(&source); err != nil || source != "manual" {
t.Fatalf("并发后来源=%q err=%v", source, err)
}
})
}
const collectedAIChoices = `{"goods_id":"737116531267","price_granularity":"sku","dimensions":[{"key":"color","name":"颜色"},{"key":"size","name":"尺码"}],"skus":[{"options":{"color":"黑色","size":"M"},"price_cent":1180,"available":true},{"options":{"color":"白色","size":"L"},"price_cent":1280,"available":true}]}`
const collectedRuleChoices = `{"goods_id":"737116531267","price_granularity":"sku","dimensions":[{"key":"color","name":"颜色"},{"key":"size","name":"尺码"}],"skus":[{"options":{"color":"灰色中长款","size":"L(106-114斤)"},"price_cent":1180,"available":true},{"options":{"color":"黑色","size":"L(106-114斤)"},"price_cent":1280,"available":true}]}`
func seedAIMatchContext(t *testing.T, db *sql.DB, sybID string) model.User {
return seedAIMatchContextWithData(t, db, sybID, "黑色,M", collectedAIChoices)
}
func seedAIMatchContextWithData(t *testing.T, db *sql.DB, sybID, rawSpec, collected string) model.User {
t.Helper()
actor := model.User{UserID: "USR-AI", Username: "ai-buyer", PasswordHash: "test-hash",
Role: model.RolePurchaser, Status: model.UserActive, PasswordChangedAt: model.NowISO(), CreatedAt: model.NowISO(), UpdatedAt: model.NowISO()}
if err := repository.CreateUser(db, actor); err != nil {
t.Fatal(err)
}
seedWorkflowOrder(t, db, sybID, "SP-AI", rawSpec)
if _, err := AssociateShopeePdd(db, "SP-AI", pddURLA, false); err != nil {
t.Fatal(err)
}
if err := repository.SetCollectResult(db, "737116531267", "PDD 测试商品", "测试店铺", collected); err != nil {
t.Fatal(err)
}
return actor
}
func testAIMatchSnapshot(client AIModelClient) AIMatchSnapshot {
provider := model.AIProviderConfig{ProviderID: "AIP-TEST", Name: "假模型", Model: "fake-model", ConfidenceThresholdBPS: 8000}
return AIMatchSnapshot{Provider: provider, ConfigFingerprint: "test-fingerprint", Secret: "fake-secret-only-test", Client: client}
}
func assertNoAIMapping(t *testing.T, db *sql.DB) {
t.Helper()
var count int
if err := db.QueryRow(`SELECT COUNT(*) FROM spec_mappings WHERE shopee_goods_id='SP-AI'`).Scan(&count); err != nil || count != 0 {
t.Fatalf("不应写映射: count=%d err=%v", count, err)
}
}
+4 -2
View File
@@ -128,11 +128,13 @@ func SaveSybMapping(db *sql.DB, sybID, optionKey, operator string, expectedVersi
if utf8.RuneCountInString(choice.Key) > 191 || utf8.RuneCountInString(match.SuggestedOptionKey) > 191 || utf8.RuneCountInString(SpecMatchRulesVersion) > 32 {
return fmt.Errorf("规格选项键或规则版本超过数据库列宽,未保存")
}
mappedAt := model.NowISO()
if err := repository.UpsertSpecMapping(tx, model.SpecMapping{
ShopeeGoodsID: context.Order.ShopeeGoodsID, SpecKey: key,
SpecRaw: context.Order.ProductSpec, PddGoodsID: context.PddGoodsID,
PddOptionKey: choice.Key, PddOptions: choice.OptionsJSON,
MappedAt: model.NowISO(), MappedBy: strings.TrimSpace(operator),
MappedAt: mappedAt, MappedBy: strings.TrimSpace(operator), Source: "manual",
ContextVersion: expectedContextVersion,
}); err != nil {
return err
}
@@ -140,7 +142,7 @@ func SaveSybMapping(db *sql.DB, sybID, optionKey, operator string, expectedVersi
ShopeeGoodsID: context.Order.ShopeeGoodsID, SpecKey: key, PddGoodsID: context.PddGoodsID,
RulesVersion: SpecMatchRulesVersion, SuggestedOptionKey: match.SuggestedOptionKey,
ChosenOptionKey: choice.Key, Accepted: match.SuggestedOptionKey != "" && match.SuggestedOptionKey == choice.Key,
DecidedBy: strings.TrimSpace(operator), DecidedAt: model.NowISO(),
DecidedBy: strings.TrimSpace(operator), DecidedAt: mappedAt,
}); err != nil {
return err
}