453 lines
17 KiB
Go
453 lines
17 KiB
Go
package aimatching
|
||
|
||
import (
|
||
"bytes"
|
||
"context"
|
||
"encoding/json"
|
||
"errors"
|
||
"io"
|
||
"net/http"
|
||
"net/url"
|
||
"strings"
|
||
"time"
|
||
|
||
"go-admin/app/goauto/models"
|
||
|
||
"gorm.io/gorm"
|
||
)
|
||
|
||
const (
|
||
settingID = uint64(1)
|
||
defaultTimeoutSecs = 15
|
||
maxTimeoutSecs = 600
|
||
minTimeoutSecs = 3
|
||
maxSettingText = 2048
|
||
defaultAutoConfirmMinConfidence = 0.9
|
||
)
|
||
|
||
// MaxProviderTimeout is also the total budget used by composite synchronous
|
||
// AI operations. This keeps their HTTP response inside the Admin and API
|
||
// transport windows even when an operation needs more than one provider call.
|
||
const MaxProviderTimeout = 600 * time.Second
|
||
|
||
// Service owns the internal AI Provider configuration. The API key exception
|
||
// is deliberately narrow: it is plain text only in the dedicated settings
|
||
// table and is returned only by the administrator settings handler.
|
||
type Service struct {
|
||
DB *gorm.DB
|
||
HTTPClient *http.Client
|
||
ProviderFailureLogger func(ProviderFailureDiagnostic)
|
||
}
|
||
|
||
// ProviderFailureDiagnostic deliberately contains no URL, model, prompt,
|
||
// candidates, response body or credential. It is safe for operational logs.
|
||
type ProviderFailureDiagnostic struct {
|
||
CallID string
|
||
Operation string
|
||
Kind string
|
||
StatusCode int
|
||
Duration time.Duration
|
||
}
|
||
|
||
func NewService(db *gorm.DB) *Service {
|
||
return &Service{DB: db, HTTPClient: secureHTTPClient()}
|
||
}
|
||
|
||
func secureHTTPClient() *http.Client {
|
||
return &http.Client{
|
||
CheckRedirect: func(_ *http.Request, _ []*http.Request) error { return http.ErrUseLastResponse },
|
||
}
|
||
}
|
||
|
||
func (s *Service) Settings(ctx context.Context) (SettingsView, error) {
|
||
setting, err := s.setting(ctx)
|
||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||
return SettingsView{Enabled: false, Provider: ProviderOpenAICompatible, TimeoutSeconds: defaultTimeoutSecs, AutoConfirmMinConfidence: defaultAutoConfirmMinConfidence}, nil
|
||
}
|
||
if err != nil {
|
||
return SettingsView{}, err
|
||
}
|
||
return settingView(setting), nil
|
||
}
|
||
|
||
func (s *Service) SaveSettings(ctx context.Context, request SaveSettingsRequest, operatorID uint64) (SettingsView, error) {
|
||
if operatorID == 0 {
|
||
return SettingsView{}, fail(CodeInvalidSetting, "无法识别当前管理员")
|
||
}
|
||
request, err := validateSettings(request)
|
||
if err != nil {
|
||
return SettingsView{}, err
|
||
}
|
||
var result models.AIMatchingSetting
|
||
err = s.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||
var current models.AIMatchingSetting
|
||
err := tx.First(¤t, settingID).Error
|
||
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
||
return err
|
||
}
|
||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||
current = models.AIMatchingSetting{ID: settingID, Provider: ProviderOpenAICompatible, BaseURL: "", Model: "", APIKey: "", TimeoutSeconds: defaultTimeoutSecs, AutoConfirmMinConfidence: defaultAutoConfirmMinConfidence}
|
||
}
|
||
if key := strings.TrimSpace(request.APIKey); key != "" {
|
||
current.APIKey = key
|
||
}
|
||
if request.Enabled && strings.TrimSpace(current.APIKey) == "" {
|
||
return fail(CodeInvalidSetting, "启用 AI 匹配前请填写 API Key")
|
||
}
|
||
current.Enabled = request.Enabled
|
||
current.Provider = ProviderOpenAICompatible
|
||
current.BaseURL = request.BaseURL
|
||
current.Model = request.Model
|
||
current.TimeoutSeconds = request.TimeoutSeconds
|
||
if request.AutoConfirmMinConfidence != nil {
|
||
current.AutoConfirmMinConfidence = *request.AutoConfirmMinConfidence
|
||
}
|
||
current.UpdatedBy = &operatorID
|
||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||
if createErr := tx.Create(¤t).Error; createErr != nil {
|
||
return createErr
|
||
}
|
||
} else if saveErr := tx.Save(¤t).Error; saveErr != nil {
|
||
return saveErr
|
||
}
|
||
result = current
|
||
return nil
|
||
})
|
||
if err != nil {
|
||
return SettingsView{}, err
|
||
}
|
||
return settingView(result), nil
|
||
}
|
||
|
||
// TestConnection checks the currently saved provider endpoint. It does not log
|
||
// or return the credential, response body, or provider URL.
|
||
func (s *Service) TestConnection(ctx context.Context) error {
|
||
setting, apiKey, err := s.activeSetting(ctx)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
ctx, cancel := context.WithTimeout(ctx, time.Duration(setting.TimeoutSeconds)*time.Second)
|
||
defer cancel()
|
||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint(setting.BaseURL, "models"), nil)
|
||
if err != nil {
|
||
return fail(CodeInvalidSetting, "AI 服务地址无效")
|
||
}
|
||
req.Header.Set("Authorization", "Bearer "+apiKey)
|
||
response, err := s.httpClient().Do(req)
|
||
if err != nil {
|
||
return &Error{Code: CodeProviderUnavailable, Message: "无法连接 AI 服务,请检查地址、模型和网络", Cause: err}
|
||
}
|
||
defer response.Body.Close()
|
||
if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices {
|
||
return fail(CodeProviderUnavailable, "AI 服务连接失败,请检查地址和 API Key")
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// Resolve runs exact deterministic matching first and uses the configured
|
||
// OpenAI-compatible provider only when no unique normalized choice exists.
|
||
func (s *Service) Resolve(ctx context.Context, request MatchRequest) (MatchResult, error) {
|
||
request = sanitizeRequest(request)
|
||
if result, ok := DeterministicMatch(request); ok {
|
||
return result, nil
|
||
}
|
||
setting, apiKey, err := s.activeSetting(ctx)
|
||
if err != nil {
|
||
return MatchResult{}, err
|
||
}
|
||
payload := openAIChatRequest{Model: setting.Model, Temperature: 0, Messages: []openAIMessage{
|
||
{Role: "system", Content: "你只负责在给定的 PDD 可选规格中选择完全一致的原始标签。不得猜测、不得改写候选值。只返回 JSON:{\"color\":\"候选原文或空\",\"size\":\"候选原文或空\",\"reason\":\"简短原因\",\"confidence\":0到1}。没有唯一可靠结果时对应字段为空。"},
|
||
{Role: "user", Content: matchPrompt(request)},
|
||
}}
|
||
body, err := json.Marshal(payload)
|
||
if err != nil {
|
||
return MatchResult{}, &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 MatchResult{}, 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 MatchResult{}, &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 MatchResult{}, fail(CodeProviderUnavailable, "AI 匹配服务暂时不可用")
|
||
}
|
||
choice, err := parseProviderChoice(responseBody)
|
||
if err != nil {
|
||
return MatchResult{}, fail(CodeNoMatch, "没有找到可采购的 PDD 颜色或尺码")
|
||
}
|
||
if !validChoice(request.TargetColor, choice.Color, request.Colors) || !validChoice(request.TargetSize, choice.Size, request.Sizes) {
|
||
return MatchResult{}, fail(CodeNoMatch, "没有找到可采购的 PDD 颜色或尺码")
|
||
}
|
||
result := matchResult(request, SourceAI, ProviderOpenAICompatible, setting.Model, choice.Color, choice.Size, safeReason(choice.Reason))
|
||
if choice.Confidence != nil && *choice.Confidence >= 0 && *choice.Confidence <= 1 {
|
||
result.Decision.Confidence = choice.Confidence
|
||
}
|
||
return result, nil
|
||
}
|
||
|
||
// ResolveSYBSpec parses one SYB productSpec into the linked Shopee product's
|
||
// exact color/size labels. Unlike Resolve, this operation does not map to PDD:
|
||
// every non-empty answer must be an exact member of the supplied Shopee set.
|
||
func (s *Service) ResolveSYBSpec(ctx context.Context, request SYBSpecParseRequest) (SYBSpecParseResult, error) {
|
||
request.ProductSpec = strings.TrimSpace(request.ProductSpec)
|
||
request.Colors = usableCandidates(request.Colors)
|
||
request.Sizes = usableCandidates(request.Sizes)
|
||
if request.ProductSpec == "" || (len(request.Colors) == 0 && len(request.Sizes) == 0) {
|
||
return SYBSpecParseResult{}, fail(CodeNoMatch, "SYB 采购规格缺少可判断的原文或蝦皮候选")
|
||
}
|
||
setting, apiKey, err := s.activeSetting(ctx)
|
||
if err != nil {
|
||
return SYBSpecParseResult{}, err
|
||
}
|
||
payload := openAIChatRequest{Model: setting.Model, Temperature: 0, Messages: []openAIMessage{
|
||
{Role: "system", Content: "你只负责把一条 SYB 商品规格原文解析成给定蝦皮候选中的原始颜色和尺码。不得猜测、不得改写候选、不得返回候选外文本。只返回 JSON:{\"color\":\"颜色候选原文或空\",\"size\":\"尺码候选原文或空\",\"reason\":\"简短原因\",\"confidence\":0到1}。提供了某角色候选时必须唯一可靠地选择一个,否则对应字段留空。"},
|
||
{Role: "user", Content: sybSpecParsePrompt(request)},
|
||
}}
|
||
body, err := json.Marshal(payload)
|
||
if err != nil {
|
||
return SYBSpecParseResult{}, &Error{Code: CodeProviderUnavailable, Message: "SYB 规格 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 SYBSpecParseResult{}, 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 SYBSpecParseResult{}, &Error{Code: CodeProviderUnavailable, Message: "SYB 规格 AI 解析服务暂时不可用", Cause: err}
|
||
}
|
||
defer response.Body.Close()
|
||
responseBody, readErr := io.ReadAll(io.LimitReader(response.Body, 1<<20))
|
||
if readErr != nil || response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices {
|
||
return SYBSpecParseResult{}, fail(CodeProviderUnavailable, "SYB 规格 AI 解析服务暂时不可用")
|
||
}
|
||
choice, err := parseProviderChoice(responseBody)
|
||
if err != nil || !validClosedChoice(choice.Color, request.Colors) || !validClosedChoice(choice.Size, request.Sizes) {
|
||
return SYBSpecParseResult{}, fail(CodeNoMatch, "AI 未能在蝦皮候选中唯一解析采购规格")
|
||
}
|
||
if choice.Confidence == nil || *choice.Confidence < 0 || *choice.Confidence > 1 || strings.TrimSpace(choice.Reason) == "" {
|
||
return SYBSpecParseResult{}, fail(CodeNoMatch, "AI 解析结果缺少有效置信度或理由")
|
||
}
|
||
return SYBSpecParseResult{
|
||
Color: choice.Color, Size: choice.Size, Provider: ProviderOpenAICompatible,
|
||
Model: setting.Model, Reason: safeReason(choice.Reason), Confidence: choice.Confidence,
|
||
}, nil
|
||
}
|
||
|
||
func (s *Service) activeSetting(ctx context.Context) (models.AIMatchingSetting, string, error) {
|
||
setting, err := s.setting(ctx)
|
||
if errors.Is(err, gorm.ErrRecordNotFound) || !setting.Enabled {
|
||
return models.AIMatchingSetting{}, "", fail(CodeNotConfigured, "AI 规格匹配未启用")
|
||
}
|
||
if err != nil {
|
||
return models.AIMatchingSetting{}, "", err
|
||
}
|
||
if strings.TrimSpace(setting.APIKey) == "" {
|
||
return models.AIMatchingSetting{}, "", fail(CodeNotConfigured, "AI 规格匹配未配置 API Key")
|
||
}
|
||
return setting, setting.APIKey, nil
|
||
}
|
||
|
||
func (s *Service) setting(ctx context.Context) (models.AIMatchingSetting, error) {
|
||
var setting models.AIMatchingSetting
|
||
err := s.DB.WithContext(ctx).First(&setting, settingID).Error
|
||
return setting, err
|
||
}
|
||
|
||
func validateSettings(request SaveSettingsRequest) (SaveSettingsRequest, error) {
|
||
request.BaseURL = strings.TrimRight(strings.TrimSpace(request.BaseURL), "/")
|
||
request.Model = strings.TrimSpace(request.Model)
|
||
request.APIKey = strings.TrimSpace(request.APIKey)
|
||
if request.TimeoutSeconds == 0 {
|
||
request.TimeoutSeconds = defaultTimeoutSecs
|
||
}
|
||
if request.TimeoutSeconds < minTimeoutSecs || request.TimeoutSeconds > maxTimeoutSecs {
|
||
return SaveSettingsRequest{}, fail(CodeInvalidSetting, "超时时间必须在 3 到 600 秒之间")
|
||
}
|
||
if len(request.BaseURL) > maxSettingText || len(request.Model) > 255 || len(request.APIKey) > maxSettingText {
|
||
return SaveSettingsRequest{}, fail(CodeInvalidSetting, "AI 设置内容过长")
|
||
}
|
||
if request.AutoConfirmMinConfidence != nil && (*request.AutoConfirmMinConfidence < 0 || *request.AutoConfirmMinConfidence > 1) {
|
||
return SaveSettingsRequest{}, fail(CodeInvalidSetting, "自动确认阈值必须在 0 与 1 之间")
|
||
}
|
||
if request.Enabled {
|
||
if request.BaseURL == "" || request.Model == "" {
|
||
return SaveSettingsRequest{}, fail(CodeInvalidSetting, "启用 AI 匹配前请填写服务地址和模型")
|
||
}
|
||
if _, err := validatedBaseURL(request.BaseURL); err != nil {
|
||
return SaveSettingsRequest{}, err
|
||
}
|
||
} else if request.BaseURL != "" {
|
||
if _, err := validatedBaseURL(request.BaseURL); err != nil {
|
||
return SaveSettingsRequest{}, err
|
||
}
|
||
}
|
||
return request, nil
|
||
}
|
||
|
||
func validatedBaseURL(raw string) (*url.URL, error) {
|
||
parsed, err := url.Parse(raw)
|
||
if err != nil || (parsed.Scheme != "https" && parsed.Scheme != "http") || parsed.Hostname() == "" || parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" {
|
||
return nil, fail(CodeInvalidSetting, "AI 服务地址必须是有效的 HTTP 或 HTTPS 地址")
|
||
}
|
||
return parsed, nil
|
||
}
|
||
|
||
func settingView(setting models.AIMatchingSetting) SettingsView {
|
||
timeout := setting.TimeoutSeconds
|
||
if timeout == 0 {
|
||
timeout = defaultTimeoutSecs
|
||
}
|
||
return SettingsView{Enabled: setting.Enabled, Provider: ProviderOpenAICompatible, BaseURL: setting.BaseURL, Model: setting.Model, APIKey: setting.APIKey, TimeoutSeconds: timeout, AutoConfirmMinConfidence: setting.AutoConfirmMinConfidence}
|
||
}
|
||
|
||
func (s *Service) httpClient() *http.Client {
|
||
if s.HTTPClient == nil {
|
||
return secureHTTPClient()
|
||
}
|
||
return s.HTTPClient
|
||
}
|
||
|
||
func endpoint(base, suffix string) string {
|
||
return strings.TrimRight(base, "/") + "/" + suffix
|
||
}
|
||
|
||
func sanitizeRequest(request MatchRequest) MatchRequest {
|
||
request.TargetColor, request.TargetSize = strings.TrimSpace(request.TargetColor), strings.TrimSpace(request.TargetSize)
|
||
request.Colors = usableCandidates(request.Colors)
|
||
request.Sizes = usableCandidates(request.Sizes)
|
||
return request
|
||
}
|
||
|
||
func usableCandidates(values []string) []string {
|
||
seen := make(map[string]bool, len(values))
|
||
result := make([]string, 0, len(values))
|
||
for _, value := range values {
|
||
value = strings.TrimSpace(value)
|
||
if value == "" || seen[value] {
|
||
continue
|
||
}
|
||
seen[value] = true
|
||
result = append(result, value)
|
||
}
|
||
return result
|
||
}
|
||
|
||
func validChoice(target, selected string, candidates []string) bool {
|
||
if strings.TrimSpace(target) == "" {
|
||
return strings.TrimSpace(selected) == ""
|
||
}
|
||
selected = strings.TrimSpace(selected)
|
||
if selected == "" {
|
||
return false
|
||
}
|
||
for _, candidate := range candidates {
|
||
if candidate == selected {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
func validClosedChoice(selected string, candidates []string) bool {
|
||
selected = strings.TrimSpace(selected)
|
||
if len(candidates) == 0 {
|
||
return selected == ""
|
||
}
|
||
if selected == "" {
|
||
return false
|
||
}
|
||
for _, candidate := range candidates {
|
||
if candidate == selected {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
func safeReason(reason string) string {
|
||
reason = strings.TrimSpace(reason)
|
||
if reason == "" {
|
||
return "AI 在可选规格中返回唯一结果"
|
||
}
|
||
if len([]rune(reason)) > 200 {
|
||
return string([]rune(reason)[:200])
|
||
}
|
||
return reason
|
||
}
|
||
|
||
func matchPrompt(request MatchRequest) string {
|
||
payload := struct {
|
||
TargetColor string `json:"targetColor,omitempty"`
|
||
TargetSize string `json:"targetSize,omitempty"`
|
||
Colors []string `json:"pddSelectableColors,omitempty"`
|
||
Sizes []string `json:"pddSelectableSizes,omitempty"`
|
||
}{request.TargetColor, request.TargetSize, request.Colors, request.Sizes}
|
||
raw, _ := json.Marshal(payload)
|
||
return string(raw)
|
||
}
|
||
|
||
func sybSpecParsePrompt(request SYBSpecParseRequest) string {
|
||
payload := struct {
|
||
ProductSpec string `json:"productSpec"`
|
||
Colors []string `json:"shopeeColorCandidates,omitempty"`
|
||
Sizes []string `json:"shopeeSizeCandidates,omitempty"`
|
||
}{request.ProductSpec, request.Colors, request.Sizes}
|
||
raw, _ := json.Marshal(payload)
|
||
return string(raw)
|
||
}
|
||
|
||
type openAIMessage struct {
|
||
Role string `json:"role"`
|
||
Content string `json:"content"`
|
||
}
|
||
|
||
type openAIChatRequest struct {
|
||
Model string `json:"model"`
|
||
Temperature float64 `json:"temperature"`
|
||
Messages []openAIMessage `json:"messages"`
|
||
}
|
||
|
||
type providerChoice struct {
|
||
Color string `json:"color"`
|
||
Size string `json:"size"`
|
||
Reason string `json:"reason"`
|
||
Confidence *float64 `json:"confidence"`
|
||
}
|
||
|
||
func parseProviderChoice(raw []byte) (providerChoice, 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 providerChoice{}, 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 choice providerChoice
|
||
if err := json.Unmarshal([]byte(content), &choice); err != nil {
|
||
return providerChoice{}, err
|
||
}
|
||
choice.Color, choice.Size = strings.TrimSpace(choice.Color), strings.TrimSpace(choice.Size)
|
||
return choice, nil
|
||
}
|