feat: add scheduled SYB spec AI parsing (#198)
This commit is contained in:
@@ -195,6 +195,58 @@ func (s *Service) Resolve(ctx context.Context, request MatchRequest) (MatchResul
|
||||
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 {
|
||||
@@ -310,6 +362,22 @@ func validChoice(target, selected string, candidates []string) bool {
|
||||
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 == "" {
|
||||
@@ -332,6 +400,16 @@ func matchPrompt(request MatchRequest) string {
|
||||
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"`
|
||||
|
||||
@@ -2,6 +2,7 @@ package aimatching
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
@@ -135,3 +136,61 @@ func TestAutoConfirmThresholdDefaultsPersistsAndValidates(t *testing.T) {
|
||||
t.Fatalf("invalid threshold err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveSYBSpecUsesOnlyProductSpecAndClosedShopeeCandidates(t *testing.T) {
|
||||
service := matcherTestService(t)
|
||||
if _, err := service.SaveSettings(context.Background(), SaveSettingsRequest{Enabled: true, BaseURL: "https://provider.example/v1", Model: "test-model", APIKey: "test-secret", TimeoutSeconds: 8}, 7); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var sent map[string]any
|
||||
service.HTTPClient = &http.Client{Transport: roundTripper(func(request *http.Request) (*http.Response, error) {
|
||||
raw, err := io.ReadAll(request.Body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := json.Unmarshal(raw, &sent); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body := `{"choices":[{"message":{"content":"{\"color\":\"黑色\",\"size\":\"XL\",\"reason\":\"原文对应唯一候选\",\"confidence\":0.95}"}}]}`
|
||||
return &http.Response{StatusCode: http.StatusOK, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(body)), Request: request}, nil
|
||||
})}
|
||||
result, err := service.ResolveSYBSpec(context.Background(), SYBSpecParseRequest{ProductSpec: "黑色 XL【备注】", Colors: []string{"黑色", "白色"}, Sizes: []string{"L", "XL"}})
|
||||
if err != nil || result.Color != "黑色" || result.Size != "XL" || result.Confidence == nil || *result.Confidence != 0.95 {
|
||||
t.Fatalf("result=%+v err=%v", result, err)
|
||||
}
|
||||
encoded, _ := json.Marshal(sent)
|
||||
for _, forbidden := range []string{"orderCode", "address", "rawJson", "price", "test-secret"} {
|
||||
if strings.Contains(string(encoded), forbidden) {
|
||||
t.Fatalf("provider payload leaked forbidden field %q: %s", forbidden, encoded)
|
||||
}
|
||||
}
|
||||
for _, required := range []string{"productSpec", "shopeeColorCandidates", "shopeeSizeCandidates"} {
|
||||
if !strings.Contains(string(encoded), required) {
|
||||
t.Fatalf("provider payload missing %q: %s", required, encoded)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveSYBSpecRejectsCandidateOutsideClosedSetAndMissingConfidence(t *testing.T) {
|
||||
service := matcherTestService(t)
|
||||
if _, err := service.SaveSettings(context.Background(), SaveSettingsRequest{Enabled: true, BaseURL: "https://provider.example/v1", Model: "test-model", APIKey: "test-secret", TimeoutSeconds: 8}, 7); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
responses := []string{
|
||||
`{"choices":[{"message":{"content":"{\"color\":\"灰色\",\"size\":\"XL\",\"reason\":\"猜测\",\"confidence\":0.99}"}}]}`,
|
||||
`{"choices":[{"message":{"content":"{\"color\":\"黑色\",\"size\":\"XL\",\"reason\":\"候选\"}"}}]}`,
|
||||
}
|
||||
service.HTTPClient = &http.Client{Transport: roundTripper(func(request *http.Request) (*http.Response, error) {
|
||||
body := responses[0]
|
||||
responses = responses[1:]
|
||||
return &http.Response{StatusCode: http.StatusOK, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(body)), Request: request}, nil
|
||||
})}
|
||||
request := SYBSpecParseRequest{ProductSpec: "黑 XL", Colors: []string{"黑色"}, Sizes: []string{"XL"}}
|
||||
for i := 0; i < 2; i++ {
|
||||
_, err := service.ResolveSYBSpec(context.Background(), request)
|
||||
var target *Error
|
||||
if !errors.As(err, &target) || target.Code != CodeNoMatch {
|
||||
t.Fatalf("attempt %d err=%v", i, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,6 +29,24 @@ type MatchRequest struct {
|
||||
Sizes []string
|
||||
}
|
||||
|
||||
// SYBSpecParseRequest contains the only source text and closed Shopee
|
||||
// candidate sets that may leave GoAuto for an AI-assisted SYB parse. It must
|
||||
// never contain the shipment/order, account, address, price or full raw JSON.
|
||||
type SYBSpecParseRequest struct {
|
||||
ProductSpec string
|
||||
Colors []string
|
||||
Sizes []string
|
||||
}
|
||||
|
||||
type SYBSpecParseResult struct {
|
||||
Color string
|
||||
Size string
|
||||
Provider string
|
||||
Model string
|
||||
Reason string
|
||||
Confidence *float64
|
||||
}
|
||||
|
||||
type CandidateSnapshot struct {
|
||||
Colors []string `json:"colors,omitempty"`
|
||||
Sizes []string `json:"sizes,omitempty"`
|
||||
|
||||
@@ -37,6 +37,8 @@ func MigratedModels() []any {
|
||||
&models.ShopeeSpecAutoMatchRun{},
|
||||
&models.ShopeeSpecAutoMatchWorkItem{},
|
||||
&models.SYBProduct{},
|
||||
&models.SYBSpecAIParseRun{},
|
||||
&models.SYBSpecAIParseWorkItem{},
|
||||
&models.SYBSession{},
|
||||
&models.SYBShop{},
|
||||
&models.SYBSyncRun{},
|
||||
|
||||
@@ -533,6 +533,15 @@ type SYBProduct struct {
|
||||
// recording the parser's own last output for audit even after a manual
|
||||
// correction; it is not overwritten by the correction itself.
|
||||
ManuallyConfirmed bool `json:"manuallyConfirmed" gorm:"not null;default:false"`
|
||||
// AIConfirmed is independent of ParseStatus: ParseStatus remains the
|
||||
// deterministic parser's audit result, while these fields record a closed-
|
||||
// candidate, high-confidence AI decision. Human correction always clears
|
||||
// and supersedes this decision.
|
||||
AIConfirmed bool `json:"aiConfirmed" gorm:"not null;default:false;index"`
|
||||
AIConfidence *float64 `json:"aiConfidence,omitempty"`
|
||||
AIReason string `json:"aiReason,omitempty" gorm:"size:500;not null;default:''"`
|
||||
AIConfirmedAt *time.Time `json:"aiConfirmedAt,omitempty"`
|
||||
AIInputFingerprint string `json:"-" gorm:"size:64;not null;default:'';index"`
|
||||
|
||||
// RawJSON is the untouched `details[]` element as SYB returned it. It is
|
||||
// what reparse (#41: "适用于解析规则更新后批量重跑,只读取已保存的原始
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
// SYBSpecAIParseRun is one globally serialized scheduled batch.
|
||||
type SYBSpecAIParseRun struct {
|
||||
ID uint64 `json:"id" gorm:"primaryKey;autoIncrement"`
|
||||
RequestID string `json:"requestId" gorm:"size:36;not null;uniqueIndex:ux_syb_spec_ai_parse_run_request"`
|
||||
Trigger string `json:"trigger" gorm:"size:16;not null;index"`
|
||||
Status string `json:"status" gorm:"size:24;not null;index"`
|
||||
ActiveSlot *uint8 `json:"-" gorm:"uniqueIndex:ux_syb_spec_ai_parse_run_active"`
|
||||
LeaseOwner string `json:"-" gorm:"size:64;not null;default:''"`
|
||||
LeaseExpiresAt *time.Time `json:"-" gorm:"index"`
|
||||
BatchLimit int `json:"batchLimit" gorm:"not null;default:20"`
|
||||
ScannedCount int `json:"scannedCount" gorm:"not null;default:0"`
|
||||
EligibleCount int `json:"eligibleCount" gorm:"not null;default:0"`
|
||||
ProcessedCount int `json:"processedCount" gorm:"not null;default:0"`
|
||||
ConfirmedCount int `json:"confirmedCount" gorm:"not null;default:0"`
|
||||
UnmatchedCount int `json:"unmatchedCount" gorm:"not null;default:0"`
|
||||
FailedCount int `json:"failedCount" gorm:"not null;default:0"`
|
||||
ErrorSummary string `json:"errorSummary,omitempty" gorm:"size:500;not null;default:''"`
|
||||
StartedAt time.Time `json:"startedAt" gorm:"not null"`
|
||||
FinishedAt *time.Time `json:"finishedAt,omitempty"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
func (SYBSpecAIParseRun) TableName() string { return "syb_spec_ai_parse_run" }
|
||||
|
||||
// SYBSpecAIParseWorkItem prevents unchanged ambiguous input from repeatedly
|
||||
// spending provider calls and owns the per-row recovery lease.
|
||||
type SYBSpecAIParseWorkItem struct {
|
||||
ID uint64 `json:"id" gorm:"primaryKey;autoIncrement"`
|
||||
SYBProductID uint64 `json:"sybProductId" gorm:"not null;uniqueIndex:ux_syb_spec_ai_parse_work_product"`
|
||||
RunID *uint64 `json:"runId,omitempty" gorm:"index"`
|
||||
InputFingerprint string `json:"inputFingerprint" gorm:"size:64;not null;default:'';index"`
|
||||
Status string `json:"status" gorm:"size:24;not null;index"`
|
||||
AttemptCount int `json:"attemptCount" gorm:"not null;default:0"`
|
||||
NextAttemptAt *time.Time `json:"nextAttemptAt,omitempty" gorm:"index"`
|
||||
LeaseOwner string `json:"-" gorm:"size:64;not null;default:''"`
|
||||
LeaseExpiresAt *time.Time `json:"-" gorm:"index"`
|
||||
LastErrorCode string `json:"lastErrorCode,omitempty" gorm:"size:64;not null;default:''"`
|
||||
LastError string `json:"lastError,omitempty" gorm:"size:500;not null;default:''"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
func (SYBSpecAIParseWorkItem) TableName() string { return "syb_spec_ai_parse_work_item" }
|
||||
@@ -32,7 +32,41 @@ type skuCombinationRow struct {
|
||||
}
|
||||
|
||||
func sybSpecsTrusted(syb models.SYBProduct) bool {
|
||||
return syb.ParseStatus == models.SYBParseStatusSuccess || syb.ManuallyConfirmed
|
||||
return syb.ParseStatus == models.SYBParseStatusSuccess || syb.ManuallyConfirmed || syb.AIConfirmed
|
||||
}
|
||||
|
||||
func aiConfirmedSpecsCurrent(syb models.SYBProduct, shopee models.ShopeeProduct) bool {
|
||||
if !syb.AIConfirmed {
|
||||
return true
|
||||
}
|
||||
specs, err := shopeeproduct.Unmarshal(shopee.SpecsJSON)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
wanted := map[string]string{shopeeproduct.RoleColor: strings.TrimSpace(syb.TargetColor), shopeeproduct.RoleSize: strings.TrimSpace(syb.TargetSize)}
|
||||
foundAny := false
|
||||
for role, target := range wanted {
|
||||
if target == "" {
|
||||
continue
|
||||
}
|
||||
foundAny = true
|
||||
found := false
|
||||
for _, dimension := range specs {
|
||||
if dimension.Role != role {
|
||||
continue
|
||||
}
|
||||
for _, value := range dimension.Values {
|
||||
if value.Name == target {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return foundAny
|
||||
}
|
||||
|
||||
func (s *Service) loadLatestSKUCombinations(ctx context.Context, pddIDs []uint64, dataset *batchPreviewDataset) error {
|
||||
|
||||
@@ -406,6 +406,10 @@ func (s *Service) previewFromDataset(ctx context.Context, id uint64, dataset bat
|
||||
item.ReasonCode, item.Reason, item.NextAction = "SHOPEE_NOT_FOUND", "关联的蝦皮商品不存在,请先处理商品档案", "open_shopee"
|
||||
return item
|
||||
}
|
||||
if !aiConfirmedSpecsCurrent(syb, shopee) {
|
||||
item.ReasonCode, item.Reason, item.NextAction = "SYB_PARSE_FAILED", "AI 解析依据已变化,请等待重新解析或人工修正", "reparse"
|
||||
return item
|
||||
}
|
||||
if shopee.PDDProductID == nil {
|
||||
item.ReasonCode, item.Reason, item.NextAction = "PDD_NOT_LINKED", "尚未关联 PDD 商品,请先关联", processActionOpenPDDLink
|
||||
return item
|
||||
|
||||
@@ -44,6 +44,28 @@ func TestBatchPreviewUsesPDDPriceAndExplainsIneligibleRows(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSYBSpecsTrustedAcceptsAIWithoutCallingItManual(t *testing.T) {
|
||||
row := models.SYBProduct{ParseStatus: models.SYBParseStatusUncertain, AIConfirmed: true}
|
||||
if !sybSpecsTrusted(row) {
|
||||
t.Fatal("a valid AI-confirmed parse must pass the purchase parse gate")
|
||||
}
|
||||
if row.ManuallyConfirmed {
|
||||
t.Fatal("AI confirmation must not be represented as human confirmation")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAIConfirmedSpecsBecomeUntrustedWhenShopeeCandidateDisappears(t *testing.T) {
|
||||
row := models.SYBProduct{TargetColor: "黑色", TargetSize: "XL", ParseStatus: models.SYBParseStatusUncertain, AIConfirmed: true}
|
||||
product := models.ShopeeProduct{SpecsJSON: `[{"name":"颜色","role":"color","values":[{"name":"白色","source":"import"}]},{"name":"尺码","role":"size","values":[{"name":"XL","source":"import"}]}]`}
|
||||
if aiConfirmedSpecsCurrent(row, product) {
|
||||
t.Fatal("removed Shopee candidate must invalidate the AI parse gate")
|
||||
}
|
||||
product.SpecsJSON = `[{"name":"颜色","role":"color","values":[{"name":"黑色","source":"import"}]},{"name":"尺码","role":"size","values":[{"name":"XL","source":"import"}]}]`
|
||||
if !aiConfirmedSpecsCurrent(row, product) {
|
||||
t.Fatal("unchanged exact Shopee candidates should keep AI parse valid")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchPreviewExposesIndependentCollectionEligibility(t *testing.T) {
|
||||
db := testDB(t)
|
||||
fixture := seed(t, db, liveCaps(), true)
|
||||
|
||||
@@ -137,7 +137,7 @@ func processStageFromDataset(id uint64, dataset batchPreviewDataset, preview Bat
|
||||
if !ok {
|
||||
return stage(ProcessStageManualAction, "SYB 商品不存在或已删除", "refresh")
|
||||
}
|
||||
if syb.ParseStatus == models.SYBParseStatusFailed {
|
||||
if syb.ParseStatus == models.SYBParseStatusFailed && !syb.ManuallyConfirmed && !syb.AIConfirmed {
|
||||
return stage(ProcessStageManualAction, "解析失败,请先处理", "reparse")
|
||||
}
|
||||
if syb.ShopeeProductID == nil {
|
||||
|
||||
@@ -0,0 +1,489 @@
|
||||
package sybimport
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go-admin/app/goauto/aimatching"
|
||||
"go-admin/app/goauto/models"
|
||||
"go-admin/app/goauto/shopeeproduct"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const (
|
||||
SpecAIParseInvokeTarget = "GoAutoSYBSpecAIParse"
|
||||
defaultAIParseBatchLimit = 20
|
||||
aiParseLeaseDuration = 30 * time.Minute
|
||||
aiParseRetryDelay = time.Hour
|
||||
maxAIParseAttempts = 3
|
||||
)
|
||||
|
||||
var (
|
||||
errAIParseWorkNotClaimed = errors.New("syb spec ai parse work not claimed")
|
||||
errAIParseInputChanged = errors.New("syb spec ai parse input changed")
|
||||
)
|
||||
|
||||
type aiParseInput struct {
|
||||
ProductSpec string
|
||||
Colors []string
|
||||
Sizes []string
|
||||
Fingerprint string
|
||||
}
|
||||
|
||||
func StartSpecAIParseRun(ctx context.Context, db *gorm.DB, requestID string, batchLimit int) (models.SYBSpecAIParseRun, bool, error) {
|
||||
if _, err := uuid.Parse(strings.TrimSpace(requestID)); err != nil {
|
||||
return models.SYBSpecAIParseRun{}, false, fmt.Errorf("requestId 必须是 UUID")
|
||||
}
|
||||
if batchLimit <= 0 {
|
||||
batchLimit = defaultAIParseBatchLimit
|
||||
}
|
||||
if batchLimit > 100 {
|
||||
return models.SYBSpecAIParseRun{}, false, fmt.Errorf("batchLimit 不能超过 100")
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
lease := now.Add(aiParseLeaseDuration)
|
||||
one := uint8(1)
|
||||
owner := uuid.NewString()
|
||||
var run models.SYBSpecAIParseRun
|
||||
created := false
|
||||
err := db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Model(&models.SYBSpecAIParseRun{}).
|
||||
Where("status = ? AND active_slot = ? AND lease_expires_at < ?", "running", 1, now).
|
||||
Updates(map[string]any{"status": "failed", "active_slot": nil, "lease_owner": "", "lease_expires_at": nil, "error_summary": "上次运行租约过期,已安全释放", "finished_at": now}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Where("request_id = ?", requestID).First(&run).Error; err == nil {
|
||||
return nil
|
||||
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return err
|
||||
}
|
||||
if err := tx.Where("status = ? AND active_slot = ?", "running", 1).First(&run).Error; err == nil {
|
||||
return nil
|
||||
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return err
|
||||
}
|
||||
run = models.SYBSpecAIParseRun{
|
||||
RequestID: requestID, Trigger: "scheduled", Status: "running", ActiveSlot: &one,
|
||||
LeaseOwner: owner, LeaseExpiresAt: &lease, BatchLimit: batchLimit, StartedAt: now,
|
||||
}
|
||||
if err := tx.Create(&run).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
created = true
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
if findErr := db.WithContext(ctx).Where("status = ? AND active_slot = ?", "running", 1).First(&run).Error; findErr == nil {
|
||||
return run, false, nil
|
||||
}
|
||||
return models.SYBSpecAIParseRun{}, false, err
|
||||
}
|
||||
return run, created, nil
|
||||
}
|
||||
|
||||
func ProcessSpecAIParseRun(ctx context.Context, db *gorm.DB, runID uint64) error {
|
||||
var run models.SYBSpecAIParseRun
|
||||
if err := db.WithContext(ctx).First(&run, runID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if run.Status != "running" || run.ActiveSlot == nil || *run.ActiveSlot != 1 {
|
||||
return nil
|
||||
}
|
||||
limit := run.BatchLimit
|
||||
if limit <= 0 || limit > 100 {
|
||||
limit = defaultAIParseBatchLimit
|
||||
}
|
||||
queryLimit := limit * 25
|
||||
if queryLimit < 100 {
|
||||
queryLimit = 100
|
||||
}
|
||||
if queryLimit > 1000 {
|
||||
queryLimit = 1000
|
||||
}
|
||||
var candidates []models.SYBProduct
|
||||
if err := db.WithContext(ctx).
|
||||
Where("manually_confirmed = ?", false).
|
||||
Where("parse_status IN ?", []string{models.SYBParseStatusUncertain, models.SYBParseStatusFailed}).
|
||||
Order("updated_at ASC, id ASC").Limit(queryLimit).Find(&candidates).Error; err != nil {
|
||||
finishSpecAIParseRun(db, run, "failed", 0, 0, 0, 0, 0, 1, "扫描异常规格失败")
|
||||
return err
|
||||
}
|
||||
|
||||
eligible, processed, confirmed, unmatched, failed := 0, 0, 0, 0, 0
|
||||
firstError := ""
|
||||
for _, candidate := range candidates {
|
||||
if processed >= limit {
|
||||
break
|
||||
}
|
||||
if candidate.AIConfirmed {
|
||||
current, currentErr := aiConfirmationTargetsCurrent(ctx, db, candidate)
|
||||
if currentErr != nil {
|
||||
failed++
|
||||
continue
|
||||
}
|
||||
if current {
|
||||
continue
|
||||
}
|
||||
if err := db.WithContext(ctx).Model(&models.SYBProduct{}).
|
||||
Where("id = ? AND manually_confirmed = ?", candidate.ID, false).
|
||||
Updates(map[string]any{"ai_confirmed": false, "ai_confidence": nil, "ai_reason": "", "ai_confirmed_at": nil, "ai_input_fingerprint": ""}).Error; err != nil {
|
||||
failed++
|
||||
continue
|
||||
}
|
||||
candidate.AIConfirmed = false
|
||||
}
|
||||
outcome, err := Reparse(ctx, db, candidate.ID, false)
|
||||
if err != nil {
|
||||
failed++
|
||||
if firstError == "" {
|
||||
firstError = "确定性重新解析失败"
|
||||
}
|
||||
continue
|
||||
}
|
||||
if outcome.NewStatus == models.SYBParseStatusSuccess {
|
||||
processed++
|
||||
confirmed++
|
||||
continue
|
||||
}
|
||||
if err := db.WithContext(ctx).First(&candidate, candidate.ID).Error; err != nil {
|
||||
failed++
|
||||
continue
|
||||
}
|
||||
input, ok, err := buildAIParseInput(ctx, db, candidate)
|
||||
if err != nil {
|
||||
failed++
|
||||
if firstError == "" {
|
||||
firstError = "读取 AI 解析上下文失败"
|
||||
}
|
||||
continue
|
||||
}
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
eligible++
|
||||
work, claimed, err := claimSpecAIParseWork(ctx, db, run, candidate.ID, input.Fingerprint)
|
||||
if err != nil {
|
||||
failed++
|
||||
continue
|
||||
}
|
||||
if !claimed {
|
||||
continue
|
||||
}
|
||||
processed++
|
||||
renewSpecAIParseRun(db, run)
|
||||
matcher := aimatching.NewService(db)
|
||||
result, matchErr := matcher.ResolveSYBSpec(ctx, aimatching.SYBSpecParseRequest{
|
||||
ProductSpec: input.ProductSpec, Colors: input.Colors, Sizes: input.Sizes,
|
||||
})
|
||||
if matchErr == nil {
|
||||
settings, settingsErr := matcher.Settings(ctx)
|
||||
if settingsErr != nil {
|
||||
matchErr = settingsErr
|
||||
} else if result.Confidence == nil || *result.Confidence < settings.AutoConfirmMinConfidence || strings.TrimSpace(result.Reason) == "" {
|
||||
unmatched++
|
||||
completeSpecAIParseWork(db, work, input.Fingerprint, false, nil)
|
||||
continue
|
||||
} else {
|
||||
matchErr = applyAIParseResult(ctx, db, candidate.ID, input.Fingerprint, result)
|
||||
}
|
||||
}
|
||||
if matchErr != nil {
|
||||
if isNoAIParseMatch(matchErr) || errors.Is(matchErr, errAIParseInputChanged) {
|
||||
unmatched++
|
||||
completeSpecAIParseWork(db, work, input.Fingerprint, false, nil)
|
||||
continue
|
||||
}
|
||||
failed++
|
||||
if firstError == "" {
|
||||
firstError = safeAIParseError(matchErr)
|
||||
}
|
||||
completeSpecAIParseWork(db, work, input.Fingerprint, false, matchErr)
|
||||
continue
|
||||
}
|
||||
confirmed++
|
||||
completeSpecAIParseWork(db, work, input.Fingerprint, true, nil)
|
||||
}
|
||||
status := "completed"
|
||||
if failed > 0 {
|
||||
status = "completed_partial"
|
||||
}
|
||||
return finishSpecAIParseRun(db, run, status, len(candidates), eligible, processed, confirmed, unmatched, failed, firstError)
|
||||
}
|
||||
|
||||
func buildAIParseInput(ctx context.Context, db *gorm.DB, record models.SYBProduct) (aiParseInput, bool, error) {
|
||||
if record.ManuallyConfirmed || (record.ParseStatus != models.SYBParseStatusUncertain && record.ParseStatus != models.SYBParseStatusFailed) || record.ShopeeProductID == nil {
|
||||
return aiParseInput{}, false, nil
|
||||
}
|
||||
var raw rawDetailSpec
|
||||
if err := json.Unmarshal([]byte(record.RawJSON), &raw); err != nil {
|
||||
return aiParseInput{}, false, err
|
||||
}
|
||||
raw.ProductSpec = strings.TrimSpace(raw.ProductSpec)
|
||||
if raw.ProductSpec == "" {
|
||||
return aiParseInput{}, false, nil
|
||||
}
|
||||
var product models.ShopeeProduct
|
||||
if err := db.WithContext(ctx).First(&product, *record.ShopeeProductID).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return aiParseInput{}, false, nil
|
||||
}
|
||||
return aiParseInput{}, false, err
|
||||
}
|
||||
specs, err := shopeeproduct.Unmarshal(product.SpecsJSON)
|
||||
if err != nil {
|
||||
return aiParseInput{}, false, err
|
||||
}
|
||||
colors, sizes, ambiguous := closedShopeeCandidates(specs)
|
||||
if ambiguous || (len(colors) == 0 && len(sizes) == 0) {
|
||||
return aiParseInput{}, false, nil
|
||||
}
|
||||
var setting models.AIMatchingSetting
|
||||
if err := db.WithContext(ctx).First(&setting, 1).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return aiParseInput{}, false, nil
|
||||
}
|
||||
return aiParseInput{}, false, err
|
||||
}
|
||||
if !setting.Enabled || strings.TrimSpace(setting.APIKey) == "" {
|
||||
return aiParseInput{}, false, nil
|
||||
}
|
||||
fingerprintPayload := struct {
|
||||
ProductSpec string
|
||||
ShopeeProductID uint64
|
||||
ShopeeSpecsJSON string
|
||||
SettingUpdatedAt string
|
||||
}{raw.ProductSpec, product.ID, product.SpecsJSON, setting.UpdatedAt.UTC().Format(time.RFC3339Nano)}
|
||||
encoded, _ := json.Marshal(fingerprintPayload)
|
||||
hash := sha256.Sum256(encoded)
|
||||
return aiParseInput{ProductSpec: raw.ProductSpec, Colors: colors, Sizes: sizes, Fingerprint: hex.EncodeToString(hash[:])}, true, nil
|
||||
}
|
||||
|
||||
func aiConfirmationTargetsCurrent(ctx context.Context, db *gorm.DB, record models.SYBProduct) (bool, error) {
|
||||
if !record.AIConfirmed || record.ShopeeProductID == nil {
|
||||
return false, nil
|
||||
}
|
||||
var product models.ShopeeProduct
|
||||
if err := db.WithContext(ctx).First(&product, *record.ShopeeProductID).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return false, nil
|
||||
}
|
||||
return false, err
|
||||
}
|
||||
specs, err := shopeeproduct.Unmarshal(product.SpecsJSON)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
colors, sizes, ambiguous := closedShopeeCandidates(specs)
|
||||
if ambiguous {
|
||||
return false, nil
|
||||
}
|
||||
return closedCandidateContains(record.TargetColor, colors) && closedCandidateContains(record.TargetSize, sizes) &&
|
||||
(strings.TrimSpace(record.TargetColor) != "" || strings.TrimSpace(record.TargetSize) != ""), nil
|
||||
}
|
||||
|
||||
func closedCandidateContains(value string, candidates []string) bool {
|
||||
value = strings.TrimSpace(value)
|
||||
if len(candidates) == 0 {
|
||||
return value == ""
|
||||
}
|
||||
for _, candidate := range candidates {
|
||||
if candidate == value {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func closedShopeeCandidates(specs []shopeeproduct.SpecDimension) (colors, sizes []string, ambiguous bool) {
|
||||
roleDimensions := map[string]int{}
|
||||
for _, dimension := range specs {
|
||||
if dimension.Role != shopeeproduct.RoleColor && dimension.Role != shopeeproduct.RoleSize {
|
||||
continue
|
||||
}
|
||||
values := make([]string, 0, len(dimension.Values))
|
||||
seen := map[string]bool{}
|
||||
for _, value := range dimension.Values {
|
||||
name := strings.TrimSpace(value.Name)
|
||||
if name != "" && !seen[name] {
|
||||
seen[name] = true
|
||||
values = append(values, name)
|
||||
}
|
||||
}
|
||||
if len(values) == 0 {
|
||||
continue
|
||||
}
|
||||
roleDimensions[dimension.Role]++
|
||||
if roleDimensions[dimension.Role] > 1 {
|
||||
return nil, nil, true
|
||||
}
|
||||
if dimension.Role == shopeeproduct.RoleColor {
|
||||
colors = values
|
||||
} else {
|
||||
sizes = values
|
||||
}
|
||||
}
|
||||
return colors, sizes, false
|
||||
}
|
||||
|
||||
func applyAIParseResult(ctx context.Context, db *gorm.DB, id uint64, fingerprint string, result aimatching.SYBSpecParseResult) error {
|
||||
now := time.Now().UTC()
|
||||
return db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
var record models.SYBProduct
|
||||
if err := tx.First(&record, id).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
input, ok, err := buildAIParseInput(ctx, tx, record)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !ok || input.Fingerprint != fingerprint {
|
||||
return errAIParseInputChanged
|
||||
}
|
||||
if result.Confidence == nil || strings.TrimSpace(result.Reason) == "" {
|
||||
return errAIParseInputChanged
|
||||
}
|
||||
write := tx.Model(&models.SYBProduct{}).Where("id = ? AND manually_confirmed = ? AND ai_confirmed = ?", id, false, false).Updates(map[string]any{
|
||||
"target_color": result.Color, "target_size": result.Size,
|
||||
"ai_confirmed": true, "ai_confidence": *result.Confidence, "ai_reason": truncateAIParseText(result.Reason),
|
||||
"ai_confirmed_at": now, "ai_input_fingerprint": fingerprint,
|
||||
})
|
||||
if write.Error != nil {
|
||||
return write.Error
|
||||
}
|
||||
if write.RowsAffected != 1 {
|
||||
return errAIParseInputChanged
|
||||
}
|
||||
return mergeParsedSpec(tx, *record.ShopeeProductID, ParseResult{Color: result.Color, Size: result.Size, Status: models.SYBParseStatusSuccess})
|
||||
})
|
||||
}
|
||||
|
||||
func claimSpecAIParseWork(ctx context.Context, db *gorm.DB, run models.SYBSpecAIParseRun, productID uint64, fingerprint string) (models.SYBSpecAIParseWorkItem, bool, error) {
|
||||
now := time.Now().UTC()
|
||||
lease := now.Add(aiParseLeaseDuration)
|
||||
var work models.SYBSpecAIParseWorkItem
|
||||
err := db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
err := tx.Where("syb_product_id = ?", productID).First(&work).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
work = models.SYBSpecAIParseWorkItem{SYBProductID: productID, RunID: &run.ID, InputFingerprint: fingerprint, Status: "running", AttemptCount: 1, LeaseOwner: run.LeaseOwner, LeaseExpiresAt: &lease}
|
||||
return tx.Create(&work).Error
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if work.InputFingerprint == fingerprint {
|
||||
if work.Status == "completed" || work.Status == "unmatched" || work.AttemptCount >= maxAIParseAttempts || (work.NextAttemptAt != nil && work.NextAttemptAt.After(now)) || (work.Status == "running" && work.LeaseExpiresAt != nil && work.LeaseExpiresAt.After(now)) {
|
||||
return errAIParseWorkNotClaimed
|
||||
}
|
||||
} else {
|
||||
work.AttemptCount = 0
|
||||
}
|
||||
updates := map[string]any{"run_id": run.ID, "input_fingerprint": fingerprint, "status": "running", "attempt_count": work.AttemptCount + 1, "next_attempt_at": nil, "lease_owner": run.LeaseOwner, "lease_expires_at": lease, "last_error_code": "", "last_error": ""}
|
||||
if err := tx.Model(&models.SYBSpecAIParseWorkItem{}).Where("id = ?", work.ID).Updates(updates).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.First(&work, work.ID).Error
|
||||
})
|
||||
if errors.Is(err, errAIParseWorkNotClaimed) {
|
||||
return work, false, nil
|
||||
}
|
||||
return work, err == nil, err
|
||||
}
|
||||
|
||||
func completeSpecAIParseWork(db *gorm.DB, work models.SYBSpecAIParseWorkItem, fingerprint string, confirmed bool, parseErr error) {
|
||||
now := time.Now().UTC()
|
||||
updates := map[string]any{"input_fingerprint": fingerprint, "lease_owner": "", "lease_expires_at": nil}
|
||||
if parseErr == nil {
|
||||
if confirmed {
|
||||
updates["status"] = "completed"
|
||||
} else {
|
||||
updates["status"] = "unmatched"
|
||||
}
|
||||
updates["next_attempt_at"], updates["last_error_code"], updates["last_error"] = nil, "", ""
|
||||
} else {
|
||||
code := aiParseErrorCode(parseErr)
|
||||
updates["status"], updates["last_error_code"], updates["last_error"] = "failed", code, safeAIParseError(parseErr)
|
||||
if code == aimatching.CodeProviderUnavailable && work.AttemptCount < maxAIParseAttempts {
|
||||
next := now.Add(aiParseRetryDelay)
|
||||
updates["next_attempt_at"] = next
|
||||
} else {
|
||||
updates["next_attempt_at"] = nil
|
||||
}
|
||||
}
|
||||
_ = db.Model(&models.SYBSpecAIParseWorkItem{}).Where("id = ?", work.ID).Updates(updates).Error
|
||||
}
|
||||
|
||||
func renewSpecAIParseRun(db *gorm.DB, run models.SYBSpecAIParseRun) {
|
||||
lease := time.Now().UTC().Add(aiParseLeaseDuration)
|
||||
_ = db.Model(&models.SYBSpecAIParseRun{}).Where("id = ? AND status = ? AND lease_owner = ?", run.ID, "running", run.LeaseOwner).Update("lease_expires_at", lease).Error
|
||||
}
|
||||
|
||||
func finishSpecAIParseRun(db *gorm.DB, run models.SYBSpecAIParseRun, status string, scanned, eligible, processed, confirmed, unmatched, failed int, summary string) error {
|
||||
now := time.Now().UTC()
|
||||
return db.Model(&models.SYBSpecAIParseRun{}).Where("id = ? AND status = ? AND lease_owner = ?", run.ID, "running", run.LeaseOwner).Updates(map[string]any{
|
||||
"status": status, "active_slot": nil, "lease_owner": "", "lease_expires_at": nil,
|
||||
"scanned_count": scanned, "eligible_count": eligible, "processed_count": processed,
|
||||
"confirmed_count": confirmed, "unmatched_count": unmatched, "failed_count": failed,
|
||||
"error_summary": truncateAIParseText(summary), "finished_at": now,
|
||||
}).Error
|
||||
}
|
||||
|
||||
func isNoAIParseMatch(err error) bool { return aiParseErrorCode(err) == aimatching.CodeNoMatch }
|
||||
|
||||
func aiParseErrorCode(err error) string {
|
||||
var target *aimatching.Error
|
||||
if errors.As(err, &target) {
|
||||
return target.Code
|
||||
}
|
||||
return CodeInternal
|
||||
}
|
||||
|
||||
func safeAIParseError(err error) string {
|
||||
var target *aimatching.Error
|
||||
if errors.As(err, &target) {
|
||||
return truncateAIParseText(target.Message)
|
||||
}
|
||||
return "服务端处理失败"
|
||||
}
|
||||
|
||||
func truncateAIParseText(value string) string {
|
||||
runes := []rune(strings.TrimSpace(value))
|
||||
if len(runes) > 500 {
|
||||
runes = runes[:500]
|
||||
}
|
||||
return string(runes)
|
||||
}
|
||||
|
||||
type scheduledSpecAIParseArgs struct {
|
||||
BatchLimit int `json:"batchLimit"`
|
||||
}
|
||||
|
||||
type ScheduledSpecAIParseJob struct{}
|
||||
|
||||
func (ScheduledSpecAIParseJob) Exec(_ interface{}) error {
|
||||
return errors.New("SYB 规格 AI 解析定时任务缺少数据库连接")
|
||||
}
|
||||
|
||||
func (ScheduledSpecAIParseJob) ExecWithDB(db *gorm.DB, arg interface{}) error {
|
||||
args := scheduledSpecAIParseArgs{BatchLimit: defaultAIParseBatchLimit}
|
||||
if raw, ok := arg.(string); ok && strings.TrimSpace(raw) != "" {
|
||||
if err := json.Unmarshal([]byte(raw), &args); err != nil {
|
||||
return fmt.Errorf("SYB 规格 AI 解析参数不是合法 JSON: %w", err)
|
||||
}
|
||||
}
|
||||
if args.BatchLimit < 1 || args.BatchLimit > 100 {
|
||||
return errors.New("SYB 规格 AI 解析 batchLimit 必须在 1 到 100 之间")
|
||||
}
|
||||
run, created, err := StartSpecAIParseRun(context.Background(), db, uuid.NewString(), args.BatchLimit)
|
||||
if err != nil || !created {
|
||||
return err
|
||||
}
|
||||
return ProcessSpecAIParseRun(context.Background(), db, run.ID)
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
package sybimport_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go-admin/app/goauto/models"
|
||||
"go-admin/app/goauto/shopeeproduct"
|
||||
"go-admin/app/goauto/sybimport"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func seedAIParseCandidate(t *testing.T, db *gorm.DB, serverURL string, detailID uint64, productSpec string) models.SYBProduct {
|
||||
t.Helper()
|
||||
detail := realDetailA()
|
||||
detail.ID = detailID
|
||||
detail.ProductSpec = productSpec
|
||||
detail.Raw = []byte(fmt.Sprintf(`{"id":%d,"productSpec":%q}`, detail.ID, productSpec))
|
||||
applied, err := sybimport.ApplyDetail(context.Background(), db, realOrder(), detail)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
specs, err := shopeeproduct.Marshal([]shopeeproduct.SpecDimension{
|
||||
{Name: "颜色", Role: shopeeproduct.RoleColor, Values: []shopeeproduct.SpecValue{{Name: "黑色", Source: shopeeproduct.ValueSourceImport}, {Name: "白色", Source: shopeeproduct.ValueSourceImport}}},
|
||||
{Name: "尺码", Role: shopeeproduct.RoleSize, Values: []shopeeproduct.SpecValue{{Name: "L", Source: shopeeproduct.ValueSourceImport}, {Name: "XL", Source: shopeeproduct.ValueSourceImport}}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Model(&models.ShopeeProduct{}).Where("id = ?", *applied.SYBProduct.ShopeeProductID).Update("specs_json", specs).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
setting := models.AIMatchingSetting{ID: 1, Enabled: true, Provider: "openai_compatible", BaseURL: serverURL, Model: "test-model", APIKey: "test-key", TimeoutSeconds: 5, AutoConfirmMinConfidence: 0.9}
|
||||
if err := db.Save(&setting).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return applied.SYBProduct
|
||||
}
|
||||
|
||||
func TestScheduledAIParseConfirmsClosedCandidatesAndDoesNotRepeat(t *testing.T) {
|
||||
var calls atomic.Int32
|
||||
provider := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
|
||||
calls.Add(1)
|
||||
response.Header().Set("Content-Type", "application/json")
|
||||
_, _ = response.Write([]byte(`{"choices":[{"message":{"content":"{\"color\":\"黑色\",\"size\":\"XL\",\"reason\":\"原文对应唯一候选\",\"confidence\":0.95}"}}]}`))
|
||||
}))
|
||||
defer provider.Close()
|
||||
db := openTestDB(t)
|
||||
record := seedAIParseCandidate(t, db, provider.URL, 19801, "黑色 XL")
|
||||
rawBefore := record.RawJSON
|
||||
run, created, err := sybimport.StartSpecAIParseRun(context.Background(), db, uuid.NewString(), 20)
|
||||
if err != nil || !created {
|
||||
t.Fatalf("start run: created=%v err=%v", created, err)
|
||||
}
|
||||
if err := sybimport.ProcessSpecAIParseRun(context.Background(), db, run.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.First(&record, record.ID).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !record.AIConfirmed || record.ManuallyConfirmed || record.ParseStatus != models.SYBParseStatusUncertain || record.TargetColor != "黑色" || record.TargetSize != "XL" || record.AIConfidence == nil || *record.AIConfidence != 0.95 || record.AIReason == "" || record.AIInputFingerprint == "" {
|
||||
t.Fatalf("unexpected confirmed record: %+v", record)
|
||||
}
|
||||
if record.RawJSON != rawBefore {
|
||||
t.Fatal("AI confirmation must not rewrite RawJSON")
|
||||
}
|
||||
var finished models.SYBSpecAIParseRun
|
||||
if err := db.First(&finished, run.ID).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if finished.Status != "completed" || finished.ConfirmedCount != 1 || finished.ProcessedCount != 1 {
|
||||
t.Fatalf("unexpected run: %+v", finished)
|
||||
}
|
||||
second, created, err := sybimport.StartSpecAIParseRun(context.Background(), db, uuid.NewString(), 20)
|
||||
if err != nil || !created {
|
||||
t.Fatalf("second start: created=%v err=%v", created, err)
|
||||
}
|
||||
if err := sybimport.ProcessSpecAIParseRun(context.Background(), db, second.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if calls.Load() != 1 {
|
||||
t.Fatalf("unchanged confirmed input called provider %d times", calls.Load())
|
||||
}
|
||||
}
|
||||
|
||||
func TestScheduledAIParseLeavesLowConfidenceUnmatchedForSameFingerprint(t *testing.T) {
|
||||
var calls atomic.Int32
|
||||
provider := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
|
||||
calls.Add(1)
|
||||
_, _ = response.Write([]byte(`{"choices":[{"message":{"content":"{\"color\":\"黑色\",\"size\":\"XL\",\"reason\":\"仍有歧义\",\"confidence\":0.4}"}}]}`))
|
||||
}))
|
||||
defer provider.Close()
|
||||
db := openTestDB(t)
|
||||
record := seedAIParseCandidate(t, db, provider.URL, 19802, "黑色 XL")
|
||||
for i := 0; i < 2; i++ {
|
||||
run, created, err := sybimport.StartSpecAIParseRun(context.Background(), db, uuid.NewString(), 20)
|
||||
if err != nil || !created {
|
||||
t.Fatalf("start %d: created=%v err=%v", i, created, err)
|
||||
}
|
||||
if err := sybimport.ProcessSpecAIParseRun(context.Background(), db, run.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := db.First(&record, record.ID).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if record.AIConfirmed || calls.Load() != 1 {
|
||||
t.Fatalf("low confidence must remain unconfirmed and not repeat: confirmed=%v calls=%d", record.AIConfirmed, calls.Load())
|
||||
}
|
||||
var work models.SYBSpecAIParseWorkItem
|
||||
if err := db.Where("syb_product_id = ?", record.ID).First(&work).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if work.Status != "unmatched" {
|
||||
t.Fatalf("work status=%s", work.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScheduledAIParseSkipsEmptySourceAndManualConfirmation(t *testing.T) {
|
||||
var calls atomic.Int32
|
||||
provider := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
|
||||
calls.Add(1)
|
||||
response.WriteHeader(http.StatusInternalServerError)
|
||||
}))
|
||||
defer provider.Close()
|
||||
db := openTestDB(t)
|
||||
empty := seedAIParseCandidate(t, db, provider.URL, 19803, "")
|
||||
manual := seedAIParseCandidate(t, db, provider.URL, 19804, "黑色 XL")
|
||||
if _, err := sybimport.ManualCorrect(context.Background(), db, manual.ID, "黑色", "XL"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
run, created, err := sybimport.StartSpecAIParseRun(context.Background(), db, uuid.NewString(), 20)
|
||||
if err != nil || !created {
|
||||
t.Fatalf("start: created=%v err=%v", created, err)
|
||||
}
|
||||
if err := sybimport.ProcessSpecAIParseRun(context.Background(), db, run.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if calls.Load() != 0 {
|
||||
t.Fatalf("ineligible rows called provider %d times", calls.Load())
|
||||
}
|
||||
var workCount int64
|
||||
if err := db.Model(&models.SYBSpecAIParseWorkItem{}).Where("syb_product_id IN ?", []uint64{empty.ID, manual.ID}).Count(&workCount).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if workCount != 0 {
|
||||
t.Fatalf("ineligible rows created %d work items", workCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSpecAIParseRunHasSingleGlobalActiveSlot(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
first, created, err := sybimport.StartSpecAIParseRun(context.Background(), db, uuid.NewString(), 20)
|
||||
if err != nil || !created {
|
||||
t.Fatalf("first: created=%v err=%v", created, err)
|
||||
}
|
||||
second, created, err := sybimport.StartSpecAIParseRun(context.Background(), db, uuid.NewString(), 20)
|
||||
if err != nil || created || second.ID != first.ID {
|
||||
t.Fatalf("second must reuse active run: first=%d second=%d created=%v err=%v", first.ID, second.ID, created, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScheduledAIParseRetriesProviderFailureAtMostThreeTimes(t *testing.T) {
|
||||
var calls atomic.Int32
|
||||
provider := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
|
||||
calls.Add(1)
|
||||
response.WriteHeader(http.StatusBadGateway)
|
||||
}))
|
||||
defer provider.Close()
|
||||
db := openTestDB(t)
|
||||
record := seedAIParseCandidate(t, db, provider.URL, 19805, "黑色 XL")
|
||||
for attempt := 1; attempt <= 4; attempt++ {
|
||||
run, created, err := sybimport.StartSpecAIParseRun(context.Background(), db, uuid.NewString(), 20)
|
||||
if err != nil || !created {
|
||||
t.Fatalf("start %d: created=%v err=%v", attempt, created, err)
|
||||
}
|
||||
if err := sybimport.ProcessSpecAIParseRun(context.Background(), db, run.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if attempt < 3 {
|
||||
past := time.Now().UTC().Add(-time.Minute)
|
||||
if err := db.Model(&models.SYBSpecAIParseWorkItem{}).Where("syb_product_id = ?", record.ID).Update("next_attempt_at", past).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
if calls.Load() != 3 {
|
||||
t.Fatalf("provider calls=%d, want 3", calls.Load())
|
||||
}
|
||||
var work models.SYBSpecAIParseWorkItem
|
||||
if err := db.Where("syb_product_id = ?", record.ID).First(&work).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if work.AttemptCount != 3 || work.NextAttemptAt != nil {
|
||||
t.Fatalf("retry state=%+v", work)
|
||||
}
|
||||
}
|
||||
@@ -130,13 +130,37 @@ func ApplyDetail(ctx context.Context, db *gorm.DB, order OrderInput, detail Deta
|
||||
result.Outcome = OutcomeCreated
|
||||
case err == nil:
|
||||
record.ID = existing.ID
|
||||
if err := tx.Model(&models.SYBProduct{}).Where("id = ?", existing.ID).Updates(map[string]any{
|
||||
// Human-confirmed target values are authoritative and survive every
|
||||
// source re-import. ParseStatus/ParseNote below still record what the
|
||||
// current deterministic parser observed for audit.
|
||||
if existing.ManuallyConfirmed {
|
||||
record.TargetColor, record.TargetSize = existing.TargetColor, existing.TargetSize
|
||||
record.ManuallyConfirmed = true
|
||||
}
|
||||
updates := map[string]any{
|
||||
"stock_id": record.StockID, "shop_name": record.ShopName, "shopee_item_id": record.ShopeeItemID,
|
||||
"shopee_product_id": record.ShopeeProductID, "product_title": record.ProductTitle,
|
||||
"target_color": record.TargetColor, "target_size": record.TargetSize,
|
||||
"quantity": record.Quantity, "unit_price_cent": record.UnitPriceCent, "image_url": record.ImageURL,
|
||||
"parse_status": record.ParseStatus, "parse_note": record.ParseNote, "raw_json": record.RawJSON,
|
||||
}).Error; err != nil {
|
||||
}
|
||||
// An identical re-import keeps a valid AI decision. Changed source,
|
||||
// link, or a newly deterministic parse invalidates it atomically.
|
||||
preserveAI := existing.AIConfirmed && !existing.ManuallyConfirmed && parsed.Status != models.SYBParseStatusSuccess &&
|
||||
existing.RawJSON == record.RawJSON && sameOptionalID(existing.ShopeeProductID, record.ShopeeProductID)
|
||||
if preserveAI {
|
||||
record.TargetColor, record.TargetSize = existing.TargetColor, existing.TargetSize
|
||||
updates["target_color"], updates["target_size"] = existing.TargetColor, existing.TargetSize
|
||||
record.AIConfirmed, record.AIConfidence, record.AIReason = true, existing.AIConfidence, existing.AIReason
|
||||
record.AIConfirmedAt, record.AIInputFingerprint = existing.AIConfirmedAt, existing.AIInputFingerprint
|
||||
} else {
|
||||
updates["ai_confirmed"], updates["ai_confidence"], updates["ai_reason"] = false, nil, ""
|
||||
updates["ai_confirmed_at"], updates["ai_input_fingerprint"] = nil, ""
|
||||
}
|
||||
if existing.ManuallyConfirmed {
|
||||
updates["target_color"], updates["target_size"] = existing.TargetColor, existing.TargetSize
|
||||
}
|
||||
if err := tx.Model(&models.SYBProduct{}).Where("id = ?", existing.ID).Updates(updates).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
result.Outcome = OutcomeUpdated
|
||||
@@ -161,6 +185,13 @@ func ApplyDetail(ctx context.Context, db *gorm.DB, order OrderInput, detail Deta
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func sameOptionalID(left, right *uint64) bool {
|
||||
if left == nil || right == nil {
|
||||
return left == nil && right == nil
|
||||
}
|
||||
return *left == *right
|
||||
}
|
||||
|
||||
// findOrCreateShopeeProduct implements #40's revival rule: a live match wins,
|
||||
// a soft-deleted match is revived (keeping its prior mapping), and only when
|
||||
// neither exists does the import create a minimal archive. On an existing
|
||||
|
||||
@@ -28,7 +28,7 @@ type rawDetailSpec struct {
|
||||
// result page's per-line feedback.
|
||||
type ReparseOutcome struct {
|
||||
SYBProductID uint64 `json:"sybProductId"`
|
||||
Outcome string `json:"outcome"` // reparsed | skipped_manual | unchanged
|
||||
Outcome string `json:"outcome"` // reparsed | skipped_manual | skipped_ai | unchanged
|
||||
OldStatus string `json:"oldStatus"`
|
||||
NewStatus string `json:"newStatus"`
|
||||
}
|
||||
@@ -36,6 +36,7 @@ type ReparseOutcome struct {
|
||||
const (
|
||||
ReparseOutcomeReparsed = "reparsed"
|
||||
ReparseOutcomeSkippedManual = "skipped_manual"
|
||||
ReparseOutcomeSkippedAI = "skipped_ai"
|
||||
ReparseOutcomeUnchanged = "unchanged"
|
||||
)
|
||||
|
||||
@@ -65,6 +66,11 @@ func Reparse(ctx context.Context, db *gorm.DB, sybProductID uint64, force bool)
|
||||
outcome.NewStatus = record.ParseStatus
|
||||
return nil
|
||||
}
|
||||
if record.AIConfirmed && !force {
|
||||
outcome.Outcome = ReparseOutcomeSkippedAI
|
||||
outcome.NewStatus = record.ParseStatus
|
||||
return nil
|
||||
}
|
||||
|
||||
var raw rawDetailSpec
|
||||
if err := json.Unmarshal([]byte(record.RawJSON), &raw); err != nil {
|
||||
@@ -76,8 +82,11 @@ func Reparse(ctx context.Context, db *gorm.DB, sybProductID uint64, force bool)
|
||||
if parsed.Color == record.TargetColor && parsed.Size == record.TargetSize && parsed.Status == record.ParseStatus {
|
||||
outcome.Outcome = ReparseOutcomeUnchanged
|
||||
if force {
|
||||
record.ManuallyConfirmed = false
|
||||
if err := tx.Model(&models.SYBProduct{}).Where("id = ?", record.ID).Update("manually_confirmed", false).Error; err != nil {
|
||||
record.ManuallyConfirmed, record.AIConfirmed = false, false
|
||||
if err := tx.Model(&models.SYBProduct{}).Where("id = ?", record.ID).Updates(map[string]any{
|
||||
"manually_confirmed": false, "ai_confirmed": false, "ai_confidence": nil,
|
||||
"ai_reason": "", "ai_confirmed_at": nil, "ai_input_fingerprint": "",
|
||||
}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
@@ -87,6 +96,8 @@ func Reparse(ctx context.Context, db *gorm.DB, sybProductID uint64, force bool)
|
||||
updates := map[string]any{
|
||||
"target_color": parsed.Color, "target_size": parsed.Size,
|
||||
"parse_status": parsed.Status, "parse_note": parsed.Note, "manually_confirmed": false,
|
||||
"ai_confirmed": false, "ai_confidence": nil, "ai_reason": "",
|
||||
"ai_confirmed_at": nil, "ai_input_fingerprint": "",
|
||||
}
|
||||
if err := tx.Model(&models.SYBProduct{}).Where("id = ?", record.ID).Updates(updates).Error; err != nil {
|
||||
return err
|
||||
@@ -140,8 +151,12 @@ func ManualCorrect(ctx context.Context, db *gorm.DB, sybProductID uint64, color,
|
||||
return err
|
||||
}
|
||||
record.TargetColor, record.TargetSize, record.ManuallyConfirmed = color, size, true
|
||||
record.AIConfirmed, record.AIConfidence, record.AIReason = false, nil, ""
|
||||
record.AIConfirmedAt, record.AIInputFingerprint = nil, ""
|
||||
if err := tx.Model(&models.SYBProduct{}).Where("id = ?", sybProductID).Updates(map[string]any{
|
||||
"target_color": color, "target_size": size, "manually_confirmed": true,
|
||||
"ai_confirmed": false, "ai_confidence": nil, "ai_reason": "",
|
||||
"ai_confirmed_at": nil, "ai_input_fingerprint": "",
|
||||
}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -60,6 +60,35 @@ func TestReparseSkipsManuallyConfirmedRowByDefault(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestReparseSkipsAIConfirmationUnlessForced(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
applied, err := sybimport.ApplyDetail(context.Background(), db, realOrder(), realDetailB())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
confidence := 0.95
|
||||
if err := db.Model(&models.SYBProduct{}).Where("id = ?", applied.SYBProduct.ID).Updates(map[string]any{
|
||||
"target_color": "AI颜色", "target_size": "AI尺码", "ai_confirmed": true,
|
||||
"ai_confidence": confidence, "ai_reason": "AI 结果", "ai_input_fingerprint": strings.Repeat("c", 64),
|
||||
}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
outcome, err := sybimport.Reparse(context.Background(), db, applied.SYBProduct.ID, false)
|
||||
if err != nil || outcome.Outcome != sybimport.ReparseOutcomeSkippedAI {
|
||||
t.Fatalf("unforced outcome=%+v err=%v", outcome, err)
|
||||
}
|
||||
if _, err := sybimport.Reparse(context.Background(), db, applied.SYBProduct.ID, true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var record models.SYBProduct
|
||||
if err := db.First(&record, applied.SYBProduct.ID).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if record.AIConfirmed || record.AIConfidence != nil || record.AIReason != "" || record.AIInputFingerprint != "" {
|
||||
t.Fatalf("forced reparse retained AI state: %+v", record)
|
||||
}
|
||||
}
|
||||
|
||||
// 可勾选强制覆盖.
|
||||
func TestReparseWithForceOverridesManualCorrection(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
@@ -151,6 +180,83 @@ func TestManualCorrectMergesIntoArchiveLikeASuccessfulParse(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestManualCorrectSupersedesAIConfirmation(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
applied, err := sybimport.ApplyDetail(context.Background(), db, realOrder(), realDetailB())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
confidence := 0.96
|
||||
if err := db.Model(&models.SYBProduct{}).Where("id = ?", applied.SYBProduct.ID).Updates(map[string]any{
|
||||
"ai_confirmed": true, "ai_confidence": confidence, "ai_reason": "旧 AI 结果", "ai_input_fingerprint": strings.Repeat("a", 64),
|
||||
}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
corrected, err := sybimport.ManualCorrect(context.Background(), db, applied.SYBProduct.ID, "人工颜色", "人工尺码")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !corrected.ManuallyConfirmed || corrected.AIConfirmed || corrected.AIConfidence != nil || corrected.AIReason != "" || corrected.AIInputFingerprint != "" {
|
||||
t.Fatalf("human correction did not supersede AI state: %+v", corrected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReimportPreservesIdenticalAIInputAndInvalidatesChangedSource(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
order, detail := realOrder(), realDetailB()
|
||||
first, err := sybimport.ApplyDetail(context.Background(), db, order, detail)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
confidence := 0.95
|
||||
if err := db.Model(&models.SYBProduct{}).Where("id = ?", first.SYBProduct.ID).Updates(map[string]any{
|
||||
"target_color": "AI颜色", "target_size": "AI尺码", "ai_confirmed": true,
|
||||
"ai_confidence": confidence, "ai_reason": "已确认", "ai_input_fingerprint": strings.Repeat("b", 64),
|
||||
}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
same, err := sybimport.ApplyDetail(context.Background(), db, order, detail)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !same.SYBProduct.AIConfirmed {
|
||||
t.Fatal("identical re-import must preserve AI confirmation")
|
||||
}
|
||||
if same.SYBProduct.TargetColor != "AI颜色" || same.SYBProduct.TargetSize != "AI尺码" {
|
||||
t.Fatal("identical re-import must preserve AI-confirmed target values")
|
||||
}
|
||||
detail.ProductSpec += " 新备注"
|
||||
detail.Raw = []byte(`{"productSpec":"changed"}`)
|
||||
changed, err := sybimport.ApplyDetail(context.Background(), db, order, detail)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if changed.SYBProduct.AIConfirmed || changed.SYBProduct.AIConfidence != nil || changed.SYBProduct.AIReason != "" {
|
||||
t.Fatalf("changed source retained stale AI state: %+v", changed.SYBProduct)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReimportNeverOverwritesManualTargetValues(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
order, detail := realOrder(), realDetailB()
|
||||
first, err := sybimport.ApplyDetail(context.Background(), db, order, detail)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := sybimport.ManualCorrect(context.Background(), db, first.SYBProduct.ID, "人工颜色", "人工尺码"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
detail.ProductSpec = "来源新颜色,来源新尺码"
|
||||
detail.Raw = []byte(`{"productSpec":"来源新颜色,来源新尺码"}`)
|
||||
updated, err := sybimport.ApplyDetail(context.Background(), db, order, detail)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !updated.SYBProduct.ManuallyConfirmed || updated.SYBProduct.TargetColor != "人工颜色" || updated.SYBProduct.TargetSize != "人工尺码" {
|
||||
t.Fatalf("re-import overwrote human decision: %+v", updated.SYBProduct)
|
||||
}
|
||||
}
|
||||
|
||||
// Regression test: ReparseOutcome originally had no json tags at all, so Go's
|
||||
// default marshaling produced PascalCase keys ("SYBProductID", "OldStatus")
|
||||
// instead of the camelCase the rest of this API and the admin frontend use.
|
||||
|
||||
@@ -15,6 +15,7 @@ func InitJob() {
|
||||
jobList = map[string]JobExec{
|
||||
"ExamplesOne": ExamplesOne{},
|
||||
sybimport.HourlySyncInvokeTarget: sybimport.HourlySyncJob{},
|
||||
sybimport.SpecAIParseInvokeTarget: sybimport.ScheduledSpecAIParseJob{},
|
||||
shopeeproduct.SpecAutoMatchInvokeTarget: shopeeproduct.ScheduledAutoMatchJob{},
|
||||
// ...
|
||||
}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
package version_local
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"runtime"
|
||||
|
||||
goautomigrations "go-admin/app/goauto/migrations"
|
||||
"go-admin/app/goauto/sybimport"
|
||||
jobsmodels "go-admin/app/jobs/models"
|
||||
"go-admin/cmd/migrate/migration"
|
||||
common "go-admin/common/models"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func init() {
|
||||
_, fileName, _, _ := runtime.Caller(0)
|
||||
migration.Migrate.SetVersion(migration.GetFilename(fileName), migrateSYBSpecAIParse)
|
||||
}
|
||||
|
||||
func migrateSYBSpecAIParse(db *gorm.DB, version string) error {
|
||||
return db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := goautomigrations.Migrate(tx); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := ensureSYBSpecAIParseJob(tx); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Create(&common.Migration{Version: version}).Error
|
||||
})
|
||||
}
|
||||
|
||||
func ensureSYBSpecAIParseJob(db *gorm.DB) error {
|
||||
var existing jobsmodels.SysJob
|
||||
err := db.Where("invoke_target = ?", sybimport.SpecAIParseInvokeTarget).First(&existing).Error
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return err
|
||||
}
|
||||
return db.Create(&jobsmodels.SysJob{
|
||||
JobName: "SYB 异常规格 AI 解析", JobGroup: "GoAuto", JobType: 2,
|
||||
CronExpression: "0 5 * * * *", InvokeTarget: sybimport.SpecAIParseInvokeTarget,
|
||||
Args: `{"batchLimit":20}`, MisfirePolicy: 1, Concurrent: 1, Status: 1,
|
||||
}).Error
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package version_local
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"go-admin/app/goauto/sybimport"
|
||||
jobsmodels "go-admin/app/jobs/models"
|
||||
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func TestEnsureSYBSpecAIParseJobIsDisabledIdempotentAndPreservesChanges(t *testing.T) {
|
||||
db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.AutoMigrate(&jobsmodels.SysJob{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := ensureSYBSpecAIParseJob(db); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var job jobsmodels.SysJob
|
||||
if err := db.Where("invoke_target = ?", sybimport.SpecAIParseInvokeTarget).First(&job).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if job.Status != 1 || job.CronExpression != "0 5 * * * *" || job.Args != `{"batchLimit":20}` {
|
||||
t.Fatalf("unexpected initial job: %+v", job)
|
||||
}
|
||||
if err := db.Model(&job).Updates(map[string]any{"status": 2, "cron_expression": "0 7 * * * *", "args": `{"batchLimit":7}`}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := ensureSYBSpecAIParseJob(db); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var count int64
|
||||
if err := db.Model(&jobsmodels.SysJob{}).Where("invoke_target = ?", sybimport.SpecAIParseInvokeTarget).Count(&count).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if count != 1 {
|
||||
t.Fatalf("job count=%d, want 1", count)
|
||||
}
|
||||
if err := db.First(&job, job.JobId).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if job.Status != 2 || job.CronExpression != "0 7 * * * *" || job.Args != `{"batchLimit":7}` {
|
||||
t.Fatalf("existing administrator changes were overwritten: %+v", job)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user