diff --git a/server/app/goauto/purchase/archive_spec_match.go b/server/app/goauto/purchase/archive_spec_match.go index 77ef092..8129694 100644 --- a/server/app/goauto/purchase/archive_spec_match.go +++ b/server/app/goauto/purchase/archive_spec_match.go @@ -21,10 +21,12 @@ type ArchiveSpecMatchResult struct { Skipped int NoMatch int Unavailable int + // Aborted 是本端 context 取消或超时导致的中断,与 Provider 故障无关(#300)。 + Aborted int } // Failed is every value that ended without a mapping, whatever the reason. -func (r ArchiveSpecMatchResult) Failed() int { return r.NoMatch + r.Unavailable } +func (r ArchiveSpecMatchResult) Failed() int { return r.NoMatch + r.Unavailable + r.Aborted } // MatchArchiveSpecs matches every still-unmapped value in a Shopee product's // archive against its linked PDD product (#293). @@ -74,6 +76,10 @@ func (s *Service) MatchArchiveSpecs(ctx context.Context, shopeeProductID uint64) continue } switch s.matchOneArchiveValue(ctx, shopeeProductID, dimension, value.Name, candidates, combinations) { + case specMatchAborted: + // ctx 已死,后面的值一个也做不成,早停以免刷出一串同样的失败。 + result.Aborted++ + return result, nil case specMatchUnavailable: result.Unavailable++ case specMatchNoMatch: diff --git a/server/app/goauto/purchase/spec_match_retry.go b/server/app/goauto/purchase/spec_match_retry.go index 185ec30..054887f 100644 --- a/server/app/goauto/purchase/spec_match_retry.go +++ b/server/app/goauto/purchase/spec_match_retry.go @@ -14,8 +14,14 @@ const ( // specMatchNoMatch: AI 明确回答「没有可靠对应」。同样的输入下次多半还是这个答案, // 但实测并非一定——见 retryableSpecMatch 的说明。 specMatchNoMatch specMatchFailure = "no_match" - // specMatchUnavailable: 服务不可用、超时、配置缺失等,与规格内容无关。 + // specMatchUnavailable: Provider 不可用、配置缺失等外部故障。 specMatchUnavailable specMatchFailure = "unavailable" + // specMatchAborted: 本端 context 被取消或超时。 + // + // `[必须]` 必须与 unavailable 分开。它是本端调度问题而非 Provider 故障,重试 + // 毫无意义(同一个 ctx 已经死了),把它读成「AI 服务不可用」会一直误导排查—— + // 线上 2026-09-16 就出现过 unavailable=7 而 Provider 完全正常的情况(#300)。 + specMatchAborted specMatchFailure = "aborted" ) // specMatchAttempts is the total number of AI calls allowed for one value. @@ -42,13 +48,17 @@ func (s *Service) resolveSpecMatch(ctx context.Context, request aimatching.Match } lastErr, lastFailure = err, classifySpecMatchFailure(err) if ctx.Err() != nil { - break + // ctx 已死,重试用的是同一个 ctx,再试一次只是多一条误导性日志。 + return aimatching.MatchResult{}, specMatchAborted, lastErr } } return aimatching.MatchResult{}, lastFailure, lastErr } func classifySpecMatchFailure(err error) specMatchFailure { + if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { + return specMatchAborted + } var matchErr *aimatching.Error if errors.As(err, &matchErr) && matchErr.Code == aimatching.CodeNoMatch { return specMatchNoMatch diff --git a/server/app/goauto/purchase/spec_match_retry_test.go b/server/app/goauto/purchase/spec_match_retry_test.go index 13e6704..baa2619 100644 --- a/server/app/goauto/purchase/spec_match_retry_test.go +++ b/server/app/goauto/purchase/spec_match_retry_test.go @@ -72,3 +72,31 @@ func TestTransportFailureIsNotReportedAsNoMatch(t *testing.T) { t.Fatalf("failure=%q,应为 unavailable", failure) } } + +// `[必须]` ctx 被取消是本端调度问题,不是 Provider 故障。归成 unavailable 会让 +// 排查一直看错方向——线上 2026-09-16 就出现过 unavailable=7 而 AI 服务完全正常, +// 真正的原因是匹配挂在 Agent 提交请求的 ctx 上、请求断开即中断(#300)。 +func TestCancelledContextIsAbortedNotUnavailable(t *testing.T) { + matcher := &scriptedMatcher{answers: []error{context.Canceled, context.Canceled}} + service := &Service{Matcher: matcher} + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + _, failure, err := service.resolveSpecMatch(ctx, aimatching.MatchRequest{TargetColor: "桔紅色"}) + if err == nil { + t.Fatal("应当失败") + } + if failure != specMatchAborted { + t.Fatalf("failure=%q,应为 aborted", failure) + } + // ctx 已死,重试用的是同一个 ctx,不该再问一次。 + if matcher.calls != 1 { + t.Fatalf("调用了 %d 次,ctx 已取消时不应重试", matcher.calls) + } +} + +func TestDeadlineExceededIsAlsoAborted(t *testing.T) { + if got := classifySpecMatchFailure(context.DeadlineExceeded); got != specMatchAborted { + t.Fatalf("got %q, want aborted", got) + } +} diff --git a/server/app/goauto/task/image_search_link.go b/server/app/goauto/task/image_search_link.go index c779956..2faa45d 100644 --- a/server/app/goauto/task/image_search_link.go +++ b/server/app/goauto/task/image_search_link.go @@ -6,6 +6,7 @@ import ( "fmt" "log" "strings" + "time" "go-admin/app/goauto/purchase" @@ -111,6 +112,15 @@ func (service *Service) matchSpecsAfterImageSearch(ctx context.Context, linked a if !linked.Linked || len(linked.SYBProductIDs) == 0 { return } + // `[必须]` 不能直接用入站请求的 ctx。这个 ctx 来自 Agent 提交采集结果的那次 + // HTTP 请求,而 AI 匹配要几十秒到几分钟——Agent 先超时断开,ctx 被取消, + // 匹配当场中断,采购员还得手动点一次「一键匹配」。线上 2026-09-16 实测六个 + // 商品里四个是这样死的,日志全是 "archive spec match failed: context canceled"。 + // 采购员手动点能成功,正是因为那是另一个不会被取消的请求 ctx(#300)。 + // + // 这里保留请求的值(trace 等),只切断取消信号,再加自己的上限兜底,避免无限挂起。 + ctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), specMatchAfterImageSearchTimeout) + defer cancel() // 先把该虾皮商品的完整颜色尺码补齐,再匹配:只匹配本次明细会留下空洞, // 新订单带来新组合时又得重来(#290)。 service.syncShopeeSpecs(ctx, linked.ShopeeProductID, linked.PDDProductID) @@ -135,8 +145,8 @@ func (service *Service) matchSpecsAfterImageSearch(ctx context.Context, linked a log.Printf("archive spec match failed for shopee product %d: %v", linked.ShopeeProductID, err) return } - log.Printf("archive spec match for shopee product %d: confirmed=%d skipped=%d no_match=%d unavailable=%d", - linked.ShopeeProductID, archive.Confirmed, archive.Skipped, archive.NoMatch, archive.Unavailable) + log.Printf("archive spec match for shopee product %d: confirmed=%d skipped=%d no_match=%d unavailable=%d aborted=%d", + linked.ShopeeProductID, archive.Confirmed, archive.Skipped, archive.NoMatch, archive.Unavailable, archive.Aborted) } // batchSpecMatchDigest renders the first few non-successful reasons so a total @@ -159,3 +169,9 @@ func batchSpecMatchDigest(response purchase.BatchSpecMatchResponse) string { } const specMatchDigestLimit = 3 + +// specMatchAfterImageSearchTimeout bounds the detached matching pass. +// +// 一个虾皮商品最多十几个规格值,逐值匹配每次几秒;给到 10 分钟足够跑完最坏情况, +// 又不至于在 AI 端真的卡死时无限占着。 +const specMatchAfterImageSearchTimeout = 10 * time.Minute diff --git a/server/app/goauto/task/spec_sync_test.go b/server/app/goauto/task/spec_sync_test.go index a6b7037..73b05b1 100644 --- a/server/app/goauto/task/spec_sync_test.go +++ b/server/app/goauto/task/spec_sync_test.go @@ -1,7 +1,9 @@ package task import ( + "context" "testing" + "time" "go-admin/app/goauto/shopeeproduct" "go-admin/app/goauto/shopeespec" @@ -103,3 +105,33 @@ func TestSpecSyncedSkipFollowsTheCurrentLink(t *testing.T) { }) } } + +// `[必须]` 图搜后的匹配不能挂在 Agent 提交请求的生命周期上。AI 匹配要几十秒到 +// 几分钟,Agent 先超时断开,请求 ctx 被取消,匹配当场中断——线上 2026-09-16 +// 六个商品里四个是这样死的,日志全是 "archive spec match failed: context canceled", +// 采购员只能手动再点一次「一键匹配」(#300)。 +func TestMatchingContextSurvivesTheAgentRequest(t *testing.T) { + request, cancel := context.WithCancel(context.Background()) + type key struct{} + request = context.WithValue(request, key{}, "trace-1") + + detached, stop := context.WithTimeout(context.WithoutCancel(request), specMatchAfterImageSearchTimeout) + defer stop() + + // Agent 断开连接。 + cancel() + + if request.Err() == nil { + t.Fatal("请求 ctx 应当已被取消") + } + if detached.Err() != nil { + t.Fatalf("匹配用的 ctx 不该跟着死掉:%v", detached.Err()) + } + // 请求上的值要保留,否则 trace 断链。 + if detached.Value(key{}) != "trace-1" { + t.Fatal("脱钩后应保留请求携带的值,只切断取消信号") + } + if deadline, ok := detached.Deadline(); !ok || time.Until(deadline) > specMatchAfterImageSearchTimeout { + t.Fatal("必须有自己的超时上限,不能无限期挂起") + } +}