fix(ops): 记录图搜规格匹配的逐条结果,保留 Agent 原始失败原因 (#294)

排查时两次卡在缺记录上,只能靠手工重放接口和按失败耗时反推。

服务端:BatchSpecMatch 的返回值此前用 `_` 丢弃,23 条明细因 AI 服务 503 全部
失败时日志一个字都没有。改为记录四个计数并附前几条阻塞原因。

Agent:IMAGE_SEARCH_ENTRY_NOT_FOUND 有「找不到入口」和「归位失败」两个来源,
统一文案把两者抹平。改为在用户文案后括注内部原因,写法与候选点击失败一致。

两处都只记录错误码、计数与规格层面的原因,不含凭据、账号、订单和个人数据。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
This commit is contained in:
QiuSW
2026-09-16 11:53:43 +08:00
co-authored by Claude Opus 5
parent 1274161a1d
commit b2b8c0d1e0
2 changed files with 59 additions and 3 deletions
@@ -417,6 +417,17 @@ class AgentForegroundService : Service() {
})
}
/**
* 把采购员看得懂的文案和内部原因拼在一起:文案在前,原始说法括注在后。
*
* 内部原因只描述页面形态与归位结果,不含凭据、账号、订单和个人数据(#294)。
*/
private fun imageSearchFailureMessage(code: String, internal: String): String {
if (!code.startsWith("IMAGE_SEARCH")) return internal
val user = imageSearchUserMessage(code)
return if (internal.isBlank() || internal == user) user else "$user[$internal]"
}
/** #277:图搜任务失败码到人话文案的映射,采购员看得懂即可,不暴露内部状态机细节。 */
private fun imageSearchUserMessage(code: String): String = when (code) {
"IMAGE_SEARCH_PERMISSION_REQUIRED" -> "未授予相册权限,无法准备图搜参考图,请到“状态”页开启相册权限后重试。"
@@ -763,7 +774,11 @@ class AgentForegroundService : Service() {
// `[必须]` 归位与选图是同一个调用:中间多一次无障碍树抓取就可能错过
// 拼多多摄像头自动弹出重试弹窗前的约 4 秒窗口,详见 enterAndSelectImage。
val entry = imageSearch.enterAndSelectImage(::launchPddToForeground, ::resetPddToHome)
if (!entry.successful) throw TaskFailure(entry.code, imageSearchUserMessage(entry.code).takeIf { entry.code.startsWith("IMAGE_SEARCH") } ?: entry.message)
// `[必须]` 保留归位状态机的原始说法。IMAGE_SEARCH_ENTRY_NOT_FOUND 有两个
// 来源——「找不到唯一可点的拍照搜索入口」和「归位失败(预算耗尽)」——
// 统一文案会把两者抹平,排查任务 156 时只能靠失败耗时反推(#294)。
// 括注写法与下面候选点击失败一致。
if (!entry.successful) throw TaskFailure(entry.code, imageSearchFailureMessage(entry.code, entry.message))
if (!imageSearch.waitForResultsPage()) {
throw TaskFailure("IMAGE_SEARCH_RESULTS_NOT_READY", imageSearchUserMessage("IMAGE_SEARCH_RESULTS_NOT_READY"))
}
+43 -2
View File
@@ -3,7 +3,9 @@ package task
import (
"context"
"errors"
"fmt"
"log"
"strings"
"go-admin/app/goauto/purchase"
@@ -112,9 +114,48 @@ func (service *Service) matchSpecsAfterImageSearch(ctx context.Context, linked a
// 先把该虾皮商品的完整颜色尺码补齐,再匹配:只匹配本次明细会留下空洞,
// 新订单带来新组合时又得重来(#290)。
service.syncShopeeSpecs(ctx, linked.ShopeeProductID, linked.PDDProductID)
if _, err := purchase.NewService(service.DB).BatchSpecMatch(ctx, purchase.BatchSpecMatchRequest{
purchaseService := purchase.NewService(service.DB)
response, err := purchaseService.BatchSpecMatch(ctx, purchase.BatchSpecMatchRequest{
SYBProductIDs: linked.SYBProductIDs,
}); err != nil {
})
if err != nil {
log.Printf("image search spec match failed for syb products %v: %v", linked.SYBProductIDs, err)
} else {
// `[必须]` 逐条结果必须落日志。此前这里是 `if _, err := ...`,23 条明细因 AI
// 服务 503 全部失败时日志一个字都没有,真实原因只能靠手工重放接口拿到(#294)。
// 只记录计数与规格层面的原因,不记录凭据、账号、订单和个人数据。
log.Printf("image search spec match for shopee product %d: confirmed=%d pending=%d failed=%d skipped=%d; %s",
linked.ShopeeProductID, response.AutoConfirmedCount, response.PendingCount,
response.FailedCount, response.SkippedCount, batchSpecMatchDigest(response))
}
// 同步补进档案的规格值没有对应明细,上面那轮碰不到它们;等订单真来了还得人工
// 点一次匹配,正是 #290 想消除的动作。这里对档案里所有未映射的值再跑一轮(#293)。
archive, err := purchaseService.MatchArchiveSpecs(ctx, linked.ShopeeProductID)
if err != nil {
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 failed=%d",
linked.ShopeeProductID, archive.Confirmed, archive.Skipped, archive.Failed)
}
// batchSpecMatchDigest renders the first few non-successful reasons so a total
// failure says why, without dumping every row (#294).
func batchSpecMatchDigest(response purchase.BatchSpecMatchResponse) string {
reasons := make([]string, 0, specMatchDigestLimit)
for _, item := range response.Items {
if item.Status == purchase.BatchSpecMatchAutoConfirmed || strings.TrimSpace(item.Reason) == "" {
continue
}
reasons = append(reasons, fmt.Sprintf("syb %d %s: %s", item.SYBProductID, item.Status, item.Reason))
if len(reasons) == specMatchDigestLimit {
break
}
}
if len(reasons) == 0 {
return "no blocking reasons"
}
return strings.Join(reasons, " | ")
}
const specMatchDigestLimit = 3