fix(#277): 图搜候选点击改瞄有文案子节点,关联不再被跨币种判据挡死

真机连续排查出三处缺陷,均已实测确认:

1) 候选卡片点不动。快照里 SnapshotNode.label 只取节点自身的 text/
   contentDescription,而 clickFreshDetailed 重新定位时用
   preferredOrDescendantLabel()(会往子孙里挖)。候选卡片是自身无文案的
   FrameLayout,两者算出的 label 一个是空串一个是标题文字,永远对不上,
   重找直接 TARGET_NOT_FOUND。改为瞄准卡片内最靠上的有文案子节点,由
   clickFreshDetailed 既有的向上走到可点祖先逻辑落到卡片容器上。
   卡片内文案的归属同时从 path 前缀改为几何包含——path 父子语义从未核对,
   几何包含已用真机 dump 直接验证。

2) 错误码拆分。IMAGE_SEARCH_NO_CANDIDATES 此前同时表示“结果页没出现”和
   “卡片点不动”,定位问题时完全分不清。拆为 RESULTS_NOT_READY /
   NO_CANDIDATES / CANDIDATE_CLICK_FAILED,并把 clickFreshDetailed 的
   result 与 reason 带进失败消息——这两个值本来就有,此前被丢弃。

3) 自动关联从上线起一次都没执行过。snapshot.PriceGuardSkipped 与
   shopee_product.currency != "CNY" 两处各自 return nil,而 priceSkipped
   的唯一来源就是 referenceCurrency != "CNY",两者是同一个条件;线上 26902
   个虾皮商品全部是 TWD。表现为图搜任务 completed、PDD 商品采集完整,但
   关联停在旧值,采购因此无法创建(订单 26091536MJHXG8 即如此)。
   币种不同只说明价格没法比,不说明关联不该建立,两件事已解耦。

   补偿控制是 image_search_linked 标记,已显示在商品页与采购视图。
   ImageSearchPriceAllowed 零调用方,加 [未接线] 标注,避免再被当成生效的
   防线;待引入可配汇率后再接。

测试夹具默认币种由 CNY 改为 TWD(线上真实币种),并新增跨币种必须关联、
同币种行为不变两个用例。此前夹具用 CNY,所以整套用例全绿而线上从未成功。

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-15 16:27:15 +08:00
co-authored by Claude Opus 5
parent 750b8763dc
commit 95b5f05ae8
7 changed files with 353 additions and 49 deletions
@@ -29,7 +29,16 @@ enum class ImageSearchRecoveryAction {
NONE,
/** 在首页,点击"拍照搜索"入口(唯一可点节点)。 */
CLICK_CAMERA,
/** 在结果页/重试弹窗/订单确认页/其它拼多多页面,按返回逐步归位。订单确认页只允许返回,
/**
* 关闭"再试一次"弹窗。
*
* `[必须]` 这个弹窗按返回键**无效**——2026-09-15 真机实测,按 BACK 前后两次
* uiautomator dump 逐字节相同。唯一出口是点"取消",而点"取消"会退出图搜、
* 回到拼多多首页,所以关闭之后仍要重新走一遍入口。
*/
DISMISS_RETRY_DIALOG,
/** 在结果页/订单确认页/其它拼多多页面,按返回逐步归位。订单确认页只允许返回,
* 绝不点击任何支付/下单控件。 */
BACK,
/** 完全不在拼多多前台,需要先拉起应用。 */
@@ -44,6 +53,15 @@ object ImageSearchRecoveryPolicy {
/** 归位动作上限,参考 cmroubao_old 的经验值,防止死循环。 */
const val MAX_RECOVERY_ACTIONS = 50
/**
* 允许关闭"再试一次"弹窗并重新进入的次数上限。
*
* `[必须]` 进入图搜页后拼多多的摄像头会实时识别,识别不到东西约 4 秒后自己弹出
* 该弹窗(2026-09-15 真机实测:+3s 无弹窗、+5s 有)。关闭后会回到首页,重新进入
* 又会撞上同一个窗口,因此必须单独设上限,否则会在通用的 50 次预算里空转数分钟。
*/
const val MAX_RETRY_DIALOG_RECOVERIES = 3
/**
* 首页搜索栏的 content-desc,真机实测 `[381,166][765,223]` TextView 携带。
* 只用于证明"当前确实在带搜索栏的首页",不作为点击目标。
@@ -76,8 +94,8 @@ object ImageSearchRecoveryPolicy {
ImageSearchPageKind.IMAGE_SEARCH_ENTRY -> ImageSearchRecoveryAction.NONE
ImageSearchPageKind.HOME -> ImageSearchRecoveryAction.CLICK_CAMERA
ImageSearchPageKind.NON_PDD -> ImageSearchRecoveryAction.LAUNCH_PDD
ImageSearchPageKind.RETRY_DIALOG -> ImageSearchRecoveryAction.DISMISS_RETRY_DIALOG
ImageSearchPageKind.IMAGE_SEARCH_RESULTS,
ImageSearchPageKind.RETRY_DIALOG,
ImageSearchPageKind.ORDER_CONFIRM,
ImageSearchPageKind.OTHER_PDD,
-> ImageSearchRecoveryAction.BACK
@@ -114,6 +132,23 @@ data class ImageSearchResultCard(
object ImageSearchCandidatePolicy {
private const val AD_MARKER = "广告"
/** 节点是否完全落在卡片矩形内。 */
fun inside(card: ImageSearchResultCard, bounds: NodeBounds): Boolean =
bounds.left >= card.left && bounds.top >= card.top &&
bounds.right <= card.left + card.width && bounds.bottom <= card.top + card.height
/**
* 卡片内最靠上的有文案节点,作为点击目标。
*
* `[必须]` 点击目标不能是卡片容器本身。快照里 SnapshotNode.label 只取节点**自身**的
* text/contentDescription,而 clickFreshDetailed 重新定位时用 preferredOrDescendantLabel()
* ——会往子孙里挖。候选卡片是自身无文案的 FrameLayout,两者算出的 label 一个是空串、
* 一个是标题文字,永远对不上,重找直接返回 TARGET_NOT_FOUND。改为瞄准有文案的子节点,
* 由 clickFreshDetailed 既有的"向上走到可点祖先"逻辑落到卡片容器上。
*/
fun clickTargetInside(card: ImageSearchResultCard, nodes: List<SnapshotNode>): SnapshotNode? =
nodes.filter { it.label.isNotEmpty() && inside(card, it.bounds) }.minByOrNull { it.bounds.top }
fun firstOpenableCandidate(cards: List<ImageSearchResultCard>): ImageSearchResultCard? =
cards
.filter { card -> card.texts.none { it.contains(AD_MARKER) } }
@@ -153,9 +188,26 @@ class PinduoduoImageSearchAutomation(
private val currentPackage: () -> String?,
private val pause: (Long) -> Unit = Thread::sleep,
) {
/** 归位到图搜入口页。launchPdd 由调用方传入(真机由 AgentForegroundService 提供)。 */
fun restoreToEntry(launchPdd: () -> PddLaunchResult): ImageSearchAutomationResult {
/**
* 归位到图搜入口页并在同一次迭代内选中相册第一张。
*
* `[必须]` 归位与选图必须合成一个循环,不能拆成 restoreToEntry() + selectFirstRecentImage()
* 两次调用。2026-09-15 真机实测:进入图搜页后拼多多的摄像头开始实时识别,识别不到东西
* 约 4 秒后自己弹出"再试一次"弹窗,把相册网格整个遮住;而该弹窗是独立 window,
* rootInActiveWindow 只能看到弹窗的 13 个节点,底下的图搜页完全不可见。
*
* 拆成两次调用时,中间要多一次完整的无障碍树抓取(图搜页节点很多,开销不小),
* 等第二次抓取回来时弹窗往往已经弹出,于是找不到"最近项目"锚点并报
* IMAGE_SEARCH_ENTRY_NOT_FOUND——线上任务 262 连续四次都是这样,每次约 3 秒。
* 手动实测在 1.8 秒内点中第一格可以干净进入结果页,所以余量存在,只是不能浪费。
*
* 结构参考同作者的 cmroubao_old(android-buyer PinduoduoImageSearchAutomation):
* 检测到图搜页的那一刻就在当前快照上选图,并且每个动作之后等待预期页面出现,
* 而不是盲目重判——页面加载中时判据一个都不命中,会被误判成其它页面而按下返回键。
*/
fun enterAndSelectImage(launchPdd: () -> PddLaunchResult): ImageSearchAutomationResult {
var actions = 0
var dialogRecoveries = 0
while (true) {
val snapshot = driver.capture()
val visible = snapshot.nodes.filter { it.visible }
@@ -169,11 +221,40 @@ class PinduoduoImageSearchAutomation(
currentPackage(), texts, sortControlCount, hasClickableCameraEntry = cameraEntry != null,
)
when (ImageSearchRecoveryPolicy.nextAction(kind)) {
ImageSearchRecoveryAction.NONE -> return ImageSearchAutomationResult(true, "OK", "已在图搜入口")
ImageSearchRecoveryAction.NONE -> {
// 已在图搜入口页:就用刚抓到的这份快照选图,不再返回调用方、不再重新抓树。
if (selectFirstRecentImageFrom(visible)) {
return ImageSearchAutomationResult(true, "OK", "已选中相册第一张")
}
// 选不中通常是网格还没渲染出来,等一轮重来;弹窗弹出时会在下一轮被识别并关闭。
}
ImageSearchRecoveryAction.CLICK_CAMERA -> {
val target = cameraEntry
?: return ImageSearchAutomationResult(false, "IMAGE_SEARCH_ENTRY_NOT_FOUND", "找不到唯一可点的拍照搜索入口")
driver.clickFreshDetailed(target)
// `[必须]` 点完入口后等页面稳定再决策。图搜页加载中时判据一个都不命中,
// 会被判成 OTHER_PDD 并按下返回键,等于自己把刚打开的图搜页退掉。
val settled = awaitPage(ENTRY_SETTLE_TIMEOUT_MILLIS) {
it == ImageSearchPageKind.IMAGE_SEARCH_ENTRY || it == ImageSearchPageKind.RETRY_DIALOG
}
if (settled != null && selectFirstRecentImageFrom(settled)) {
return ImageSearchAutomationResult(true, "OK", "已选中相册第一张")
}
}
ImageSearchRecoveryAction.DISMISS_RETRY_DIALOG -> {
dialogRecoveries++
if (dialogRecoveries > ImageSearchRecoveryPolicy.MAX_RETRY_DIALOG_RECOVERIES) {
return ImageSearchAutomationResult(
false, "IMAGE_SEARCH_CAMERA_BLOCKED",
"拼多多摄像头识别反复失败,图搜页始终被重试弹窗占据",
)
}
if (!dismissRetryDialog(visible)) {
return ImageSearchAutomationResult(
false, "IMAGE_SEARCH_CAMERA_BLOCKED",
"无法关闭重试弹窗,请人工处理后重试",
)
}
}
ImageSearchRecoveryAction.BACK -> driver.back()
ImageSearchRecoveryAction.LAUNCH_PDD -> {
@@ -186,12 +267,49 @@ class PinduoduoImageSearchAutomation(
}
actions++
if (ImageSearchRecoveryPolicy.exceedsRecoveryBudget(actions)) {
return ImageSearchAutomationResult(false, "IMAGE_SEARCH_ENTRY_NOT_FOUND", "归位到拍照搜索入口失败,请人工检查")
return ImageSearchAutomationResult(false, "IMAGE_SEARCH_ENTRY_NOT_FOUND", "归位到图搜入口并选中参考图失败,请人工检查")
}
pause(RECOVERY_STEP_MILLIS)
}
}
/**
* 轮询直到页面稳定为 [accept] 接受的形态,命中时返回**那一次抓取**的可见节点,
* 供调用方直接使用,避免为了拿到同一个页面再抓一次树。超时返回 null。
*/
private fun awaitPage(
timeoutMillis: Long,
accept: (ImageSearchPageKind) -> Boolean,
): List<SnapshotNode>? {
val deadline = System.currentTimeMillis() + timeoutMillis
while (System.currentTimeMillis() <= deadline) {
val snapshot = driver.capture()
val visible = snapshot.nodes.filter { it.visible }
val texts = visible.map { it.label }
val sortControlCount = visible.count { it.label in SORT_CONTROL_LABELS }
val kind = ImageSearchRecoveryPolicy.classify(
currentPackage(), texts, sortControlCount,
hasClickableCameraEntry = uniqueCameraEntry(visible) != null,
)
if (accept(kind)) return visible
pause(POLL_INTERVAL_MILLIS)
}
return null
}
/**
* 点"取消"关闭"再试一次"弹窗。
*
* `[必须]` 不用 BACK:真机实测 BACK 对该弹窗完全无效。也不点"再试一次"——那会
* 重新启动摄像头识别,几秒后再弹一次,形成死循环。
*/
private fun dismissRetryDialog(visible: List<SnapshotNode>): Boolean {
val cancel = visible.firstOrNull {
it.enabled && it.clickable && it.label == PinduoduoImageSearchCriteria.RETRY_DIALOG_CANCEL_TEXT
} ?: return false
return driver.clickFreshDetailed(cancel).result == FreshActionResult.SUCCESS
}
/** 只保留唯一同时满足 content-desc 和 clickable 的节点;否则按几何兜底(顶部区域最靠右的可点节点)。 */
private fun uniqueCameraEntry(visible: List<SnapshotNode>): SnapshotNode? {
val byText = visible.filter {
@@ -213,10 +331,13 @@ class PinduoduoImageSearchAutomation(
return fallback.maxByOrNull { it.bounds.right }
}
/** 定位"最近项目"锚点下方的网格,点击第一格。 */
fun selectFirstRecentImage(): Boolean {
val snapshot = driver.capture()
val visible = snapshot.nodes.filter { it.visible }
/**
* 在**已经抓好**的快照上定位"最近项目"锚点下方的网格并点击第一格。
*
* `[必须]` 接收 visible 而不是自己 capture:见 [enterAndSelectImage] 的说明,
* 多一次抓取就可能错过 4 秒窗口。
*/
fun selectFirstRecentImageFrom(visible: List<SnapshotNode>): Boolean {
val anchor = visible.firstOrNull { it.label.contains(PinduoduoImageSearchCriteria.RECENT_ITEMS_LABEL) } ?: return false
val screenWidth = visible.maxOfOrNull { it.bounds.right } ?: return false
val cells = visible
@@ -231,10 +352,14 @@ class PinduoduoImageSearchAutomation(
}
/**
* 轮询直到进入结果页;若出现重试弹窗则点击"再试一次"继续等待。`[未验证]`:重试弹窗分支未在
* 真机触发过,见 [PinduoduoImageSearchCriteria] 顶部注释。
* 轮询直到进入结果页。
*
* `[必须]` 出现"再试一次"弹窗时**不点"再试一次"**:那会重启摄像头识别、几秒后再弹一次。
* 这里直接判失败交回调用方,由 restoreToEntry 走关闭并重新进入的路径(有独立次数上限)。
*/
fun waitForResultsPage(timeoutMillis: Long = DEFAULT_RESULTS_TIMEOUT_MILLIS): Boolean {
// 失败由调用方映射为 IMAGE_SEARCH_RESULTS_NOT_READY,与"结果页出来了但候选点不动"
// 区分开——两者此前共用 IMAGE_SEARCH_NO_CANDIDATES,定位问题时完全分不清。
val deadline = System.currentTimeMillis() + timeoutMillis
while (System.currentTimeMillis() <= deadline) {
val snapshot = driver.capture()
@@ -242,46 +367,91 @@ class PinduoduoImageSearchAutomation(
val texts = visible.map { it.label }
val sortControlCount = visible.count { it.label in SORT_CONTROL_LABELS }
if (PinduoduoImageSearchCriteria.isImageSearchResultsPage(texts, sortControlCount)) return true
if (PinduoduoImageSearchCriteria.isRetryDialog(texts)) {
val retry = visible.firstOrNull { it.label == PinduoduoImageSearchCriteria.RETRY_DIALOG_CONFIRM_TEXT && it.clickable }
if (retry != null) driver.clickFreshDetailed(retry)
}
if (PinduoduoImageSearchCriteria.isRetryDialog(texts)) return false
pause(RECOVERY_STEP_MILLIS)
}
return false
}
/** 打开结果页第一个非广告候选卡片;容忍卡片内图片尚未加载完成。 */
fun openFirstCandidate(): Boolean {
fun openFirstCandidate(): ImageSearchAutomationResult {
val snapshot = driver.capture()
val visible = snapshot.nodes.filter { it.visible }
val cardContainers = visible.filter { node ->
node.clickable &&
node.width6xHeight() &&
node.bounds.width in CARD_WIDTH_RANGE &&
node.bounds.height in CARD_HEIGHT_RANGE
}
if (cardContainers.isEmpty()) {
return ImageSearchAutomationResult(
false, "IMAGE_SEARCH_NO_CANDIDATES", "结果页没有可识别的商品卡片",
)
}
// `[必须]` 卡片内文案按**几何包含**判定,不用 path 前缀。path 的父子语义从未核对过,
// 而 bounds 包含在 2026-09-15 的真机 dump 上直接验证过:四张 535x764 卡片各自的
// 标题、价格、店铺文案都能正确归位。
val cards = cardContainers.map { container ->
val descendantTexts = visible.filter { it.path.startsWith("${container.path}/") }.map { it.label }
ImageSearchResultCard(
top = container.bounds.top,
left = container.bounds.left,
width = container.bounds.width,
height = container.bounds.height,
texts = descendantTexts,
texts = visible.filter {
it.label.isNotEmpty() && ImageSearchCandidatePolicy.inside(
ImageSearchResultCard(container.bounds.top, container.bounds.left, container.bounds.width, container.bounds.height, emptyList()),
it.bounds,
)
}.map { it.label },
)
}
val chosen = ImageSearchCandidatePolicy.firstOpenableCandidate(cards) ?: return false
val node = cardContainers.firstOrNull {
it.bounds.top == chosen.top && it.bounds.left == chosen.left && it.bounds.width == chosen.width && it.bounds.height == chosen.height
} ?: return false
return driver.clickFreshDetailed(node).result == FreshActionResult.SUCCESS
val chosen = ImageSearchCandidatePolicy.firstOpenableCandidate(cards)
?: return ImageSearchAutomationResult(
false, "IMAGE_SEARCH_NO_CANDIDATES",
"结果页 ${cards.size} 张卡片全部是广告,没有可采集的候选",
)
val container = cardContainers.firstOrNull {
it.bounds.top == chosen.top && it.bounds.left == chosen.left &&
it.bounds.width == chosen.width && it.bounds.height == chosen.height
} ?: return ImageSearchAutomationResult(
false, "IMAGE_SEARCH_CANDIDATE_CLICK_FAILED", "选中的候选卡片在点击前消失",
)
// `[必须]` 不能直接点卡片容器。快照里 SnapshotNode.label 只取节点**自身**的
// text/contentDescription,而 clickFreshDetailed 重新定位时用的是
// preferredOrDescendantLabel()——会往子孙里挖。候选卡片是自身无文案的
// FrameLayout,两者算出的 label 一个是空串、一个是标题文字,永远对不上,
// 重找直接返回 TARGET_NOT_FOUND(2026-09-15 真机:任务 262 卡在这里)。
// 改为瞄准卡片内有文案的子节点,由 clickFreshDetailed 既有的"向上走到可点祖先"
// 逻辑落到卡片容器上。
val labelled = ImageSearchCandidatePolicy.clickTargetInside(chosen, visible)
?: return ImageSearchAutomationResult(
false, "IMAGE_SEARCH_CANDIDATE_CLICK_FAILED", "候选卡片内没有可定位的文案节点",
)
val outcome = driver.clickFreshDetailed(labelled)
if (outcome.result != FreshActionResult.SUCCESS) {
return ImageSearchAutomationResult(
false, "IMAGE_SEARCH_CANDIDATE_CLICK_FAILED",
"候选卡片点击失败[result=${outcome.result};reason=${outcome.reason}]",
)
}
return ImageSearchAutomationResult(true, "OK", "已打开第一个候选")
}
private fun SnapshotNode.width6xHeight(): Boolean = bounds.width > 0 && bounds.height > 0
private companion object {
const val RECOVERY_STEP_MILLIS = 300L
/** 动作之间的轮询间隔,取自 cmroubao_old 的实测值。 */
const val POLL_INTERVAL_MILLIS = 200L
/**
* 点击图搜入口后等待页面稳定的上限。
*
* `[必须]` 必须明显短于摄像头自动弹出重试弹窗的约 4 秒,否则等到的就是弹窗而不是图搜页。
*/
const val ENTRY_SETTLE_TIMEOUT_MILLIS = 2_500L
const val DEFAULT_RESULTS_TIMEOUT_MILLIS = 15_000L
val SORT_CONTROL_LABELS = setOf("综合", "销量", "价格", "品牌")
val CARD_WIDTH_RANGE = 400..700
@@ -34,8 +34,13 @@ object PinduoduoImageSearchCriteria {
/**
* "再试一次"弹窗文案。
*
* `[未验证]` 2026-09-15 的真机核对中该弹窗没有出现(识别成功,直接进结果页),
* 这三个常量仍是移植值。接线时若发现识别失败走了别的形态,需要重新 dump。
* `[已验证]` 2026-09-15 真机复现,三个常量全部命中。进入图搜页后摄像头会
* 实时识别,识别不到东西约 4 秒后拼多多**自己**弹出该弹窗(+3s 无、+5s 有),
* 不需要我们点任何东西。它会遮住相册网格。
*
* `[必须]` 按 BACK 对该弹窗**完全无效**(前后两次 dump 逐字节相同);点「取消」
* 可以关闭,但会退出图搜、回到拼多多首页。实测在 1.8 秒内点中相册第一格
* 可以赶在弹窗之前干净进入结果页。
*/
const val RETRY_DIALOG_CANCEL_TEXT = "取消"
const val RETRY_DIALOG_CONFIRM_TEXT = "再试一次"
@@ -421,7 +421,10 @@ class AgentForegroundService : Service() {
private fun imageSearchUserMessage(code: String): String = when (code) {
"IMAGE_SEARCH_PERMISSION_REQUIRED" -> "未授予相册权限,无法准备图搜参考图,请到“状态”页开启相册权限后重试。"
"IMAGE_SEARCH_ENTRY_NOT_FOUND" -> "打不开拍照搜索入口,请检查 PDD App 版本或手动确认后重试。"
"IMAGE_SEARCH_RESULTS_NOT_READY" -> "图搜结果页没有如期出现,请稍后重试。"
"IMAGE_SEARCH_CANDIDATE_CLICK_FAILED" -> "搜到了商品但打不开,请稍后重试或人工关联。"
"IMAGE_SEARCH_NO_CANDIDATES" -> "没有搜到相似商品,请确认参考图清晰或换一张图后重试。"
"IMAGE_SEARCH_CAMERA_BLOCKED" -> "拼多多摄像头一直识别不到东西,图搜页反复弹出重试提示。请把手机对着有内容的画面后重试。"
"IMAGE_SEARCH_ASSET_INVALID" -> "图片准备失败,请重新发起图搜任务。"
"IMAGE_SEARCH_ASSET_NOT_LATEST" -> "相册里有更新的照片或视频,无法确认图搜会用到我们准备的图片,请清理相册后重试。"
"IMAGE_SEARCH_AUTOMATION_UNAVAILABLE" -> "当前 Agent 版本尚未接入 PDD 图搜自动化,请使用普通采集任务。"
@@ -757,16 +760,17 @@ class AgentForegroundService : Service() {
throw TaskFailure("IMAGE_SEARCH_ASSET_NOT_LATEST", imageSearchUserMessage("IMAGE_SEARCH_ASSET_NOT_LATEST"))
}
val imageSearch = PinduoduoImageSearchAutomation(accessibility, accessibility::currentPackage)
val entry = imageSearch.restoreToEntry(::launchPddToForeground)
// `[必须]` 归位与选图是同一个调用:中间多一次无障碍树抓取就可能错过
// 拼多多摄像头自动弹出重试弹窗前的约 4 秒窗口,详见 enterAndSelectImage。
val entry = imageSearch.enterAndSelectImage(::launchPddToForeground)
if (!entry.successful) throw TaskFailure(entry.code, imageSearchUserMessage(entry.code).takeIf { entry.code.startsWith("IMAGE_SEARCH") } ?: entry.message)
if (!imageSearch.selectFirstRecentImage()) {
throw TaskFailure("IMAGE_SEARCH_ENTRY_NOT_FOUND", imageSearchUserMessage("IMAGE_SEARCH_ENTRY_NOT_FOUND"))
}
if (!imageSearch.waitForResultsPage()) {
throw TaskFailure("IMAGE_SEARCH_NO_CANDIDATES", imageSearchUserMessage("IMAGE_SEARCH_NO_CANDIDATES"))
throw TaskFailure("IMAGE_SEARCH_RESULTS_NOT_READY", imageSearchUserMessage("IMAGE_SEARCH_RESULTS_NOT_READY"))
}
if (!imageSearch.openFirstCandidate()) {
throw TaskFailure("IMAGE_SEARCH_NO_CANDIDATES", imageSearchUserMessage("IMAGE_SEARCH_NO_CANDIDATES"))
val candidate = imageSearch.openFirstCandidate()
if (!candidate.successful) {
// 保留执行器给出的具体原因(含 result/reason),排查时不必再猜卡在哪一步。
throw TaskFailure(candidate.code, "${imageSearchUserMessage(candidate.code)}[${candidate.message}]")
}
// 接回现有的分享链接识别 + 商品详情采集链路(与 agent_current_page 完全一致)。
val identityResult = CurrentPageIdentityRunner(
@@ -76,7 +76,8 @@ class ImageSearchRecoveryPolicyTest {
assertEquals(ImageSearchRecoveryAction.CLICK_CAMERA, ImageSearchRecoveryPolicy.nextAction(ImageSearchPageKind.HOME))
assertEquals(ImageSearchRecoveryAction.LAUNCH_PDD, ImageSearchRecoveryPolicy.nextAction(ImageSearchPageKind.NON_PDD))
assertEquals(ImageSearchRecoveryAction.BACK, ImageSearchRecoveryPolicy.nextAction(ImageSearchPageKind.IMAGE_SEARCH_RESULTS))
assertEquals(ImageSearchRecoveryAction.BACK, ImageSearchRecoveryPolicy.nextAction(ImageSearchPageKind.RETRY_DIALOG))
// RETRY_DIALOG 不再是 BACK,理由见下方 retry dialog must be dismissed by cancel。
assertEquals(ImageSearchRecoveryAction.DISMISS_RETRY_DIALOG, ImageSearchRecoveryPolicy.nextAction(ImageSearchPageKind.RETRY_DIALOG))
assertEquals(ImageSearchRecoveryAction.BACK, ImageSearchRecoveryPolicy.nextAction(ImageSearchPageKind.OTHER_PDD))
}
@@ -85,6 +86,48 @@ class ImageSearchRecoveryPolicyTest {
assertFalse(ImageSearchRecoveryPolicy.exceedsRecoveryBudget(ImageSearchRecoveryPolicy.MAX_RECOVERY_ACTIONS - 1))
assertTrue(ImageSearchRecoveryPolicy.exceedsRecoveryBudget(ImageSearchRecoveryPolicy.MAX_RECOVERY_ACTIONS))
}
// `[必须]` 2026-09-15 真机实测:按 BACK 对这个弹窗完全无效,前后两次 uiautomator
// dump 逐字节相同;唯一出口是点「取消」。此前把它映射成 BACK,导致任务在弹窗上
// 空转直到预算耗尽、报 IMAGE_SEARCH_ENTRY_NOT_FOUND,而弹窗一直留在屏幕上。
@Test
fun `retry dialog must be dismissed by cancel, never by back`() {
val kind = ImageSearchRecoveryPolicy.classify(
"com.xunmeng.pinduoduo", retryTexts, 0, hasClickableCameraEntry = false,
)
assertEquals(ImageSearchPageKind.RETRY_DIALOG, kind)
assertEquals(
ImageSearchRecoveryAction.DISMISS_RETRY_DIALOG,
ImageSearchRecoveryPolicy.nextAction(kind),
)
}
// 其余需要归位的页面仍然用返回,不受上面那条影响。
@Test
fun `other pages still recover by back`() {
for (kind in listOf(
ImageSearchPageKind.IMAGE_SEARCH_RESULTS,
ImageSearchPageKind.ORDER_CONFIRM,
ImageSearchPageKind.OTHER_PDD,
)) {
assertEquals(
"$kind 的归位动作被改动了",
ImageSearchRecoveryAction.BACK,
ImageSearchRecoveryPolicy.nextAction(kind),
)
}
}
// 关闭弹窗会退出图搜回到首页,重新进入又会撞上同一个约 4 秒的摄像头识别窗口,
// 因此必须有独立且很小的上限,不能落在通用的 50 次预算里空转数分钟。
@Test
fun `retry dialog recovery budget is separate and small`() {
assertTrue(
"弹窗恢复上限必须显著小于通用恢复预算",
ImageSearchRecoveryPolicy.MAX_RETRY_DIALOG_RECOVERIES in
1 until ImageSearchRecoveryPolicy.MAX_RECOVERY_ACTIONS / 5,
)
}
}
class ImageSearchCandidatePolicyTest {
@@ -132,6 +175,46 @@ class ImageSearchCandidatePolicyTest {
}
}
class ImageSearchClickTargetTest {
private fun node(top: Int, left: Int, right: Int, bottom: Int, text: String?) = SnapshotNode(
path = "0/$top/$left", parentPath = "0", text = text, contentDescription = null,
resourceId = null, className = "android.widget.TextView",
bounds = NodeBounds(left, top, right, bottom),
clickable = false, scrollable = false, selected = false, checked = false,
enabled = true, visible = true,
)
// 真机 dump(2026-09-15)的第一张卡片:[0,600] 535x764。
private val card = ImageSearchResultCard(top = 600, left = 0, width = 535, height = 764, texts = emptyList())
// `[必须]` 卡片容器自身无文案,label 为空串;clickFreshDetailed 重找时用的是
// preferredOrDescendantLabel()(会挖子孙),两者永远对不上,直接 TARGET_NOT_FOUND。
// 所以点击目标必须是卡片内有文案的子节点。
@Test
fun `click target is the topmost labelled node inside the card`() {
val nodes = listOf(
node(600, 0, 535, 1364, null), // 卡片容器本身,无文案
node(1100, 20, 300, 1140, "¥21.8"),
node(1000, 20, 500, 1060, "港风时尚"), // 最靠上的有文案节点
)
val target = ImageSearchCandidatePolicy.clickTargetInside(card, nodes)
assertEquals("港风时尚", target?.label)
}
// 相邻卡片的文案不得被当成本卡片的内容。真机上右侧卡片从 x=545 开始。
@Test
fun `nodes outside the card bounds are ignored`() {
val nodes = listOf(node(1000, 565, 1040, 1060, "森系假两件"))
assertNull(ImageSearchCandidatePolicy.clickTargetInside(card, nodes))
}
@Test
fun `card without any labelled child yields no target`() {
assertNull(ImageSearchCandidatePolicy.clickTargetInside(card, listOf(node(700, 10, 500, 900, null))))
}
}
class ImageSearchMediaRecencyPolicyTest {
@Test
+5 -2
View File
@@ -218,8 +218,11 @@ func loadImageSearchSnapshot(record models.CollectionTask) (ImageSearchSnapshot,
}
// ImageSearchPriceAllowed deliberately refuses to compare unlike currencies.
// A skipped check is returned as comparable=false so callers must require
// manual confirmation before automatic association.
// A skipped check is returned as comparable=false.
//
// `[未接线]` 这个函数目前**没有任何调用方**。它不是一道正在生效的防线——线上
// 虾皮商品全部是 TWD 而 PDD 是 CNY,没有汇率就无法比较。保留它是为了将来引入
// 可配汇率后接上;在那之前不要把它当成已有的价格保护。
func ImageSearchPriceAllowed(snapshot ImageSearchSnapshot, pddCurrency string, priceCent int64) (allowed bool, comparable bool) {
if priceCent < 0 || snapshot.ReferencePriceCent < 0 || snapshot.ReferencePriceCent == 0 || snapshot.MaxPriceRatio < 1 {
return false, false
+12 -9
View File
@@ -3,7 +3,6 @@ package task
import (
"context"
"errors"
"strings"
"go-admin/app/goauto/models"
"gorm.io/gorm"
@@ -25,9 +24,18 @@ func (service *Service) autoLinkImageSearch(ctx context.Context, taskID uint64)
if err != nil {
return err
}
if snapshot.PriceGuardSkipped {
return nil
}
// `[必须]` 币种不同只说明**价格没法比**,不说明这条关联不该建立。这两件事
// 此前被混在一起:snapshot.PriceGuardSkipped 与 shopee_product.currency != "CNY"
// 各自 return nil,而 priceSkipped 的唯一来源就是 referenceCurrency != "CNY",
// 两个判据其实是同一个条件。
//
// 线上 26902 个虾皮商品**全部是 TWD**,一个 CNY 都没有(2026-09-15 核对),
// 所以自动关联从上线起一次都没有执行过:图搜任务显示 completed、PDD 商品也
// 采集完整,但关联始终停在旧值,采购因此无法创建。
//
// 补偿控制是 image_search_linked 标记(本函数下方写入),它会显示在虾皮商品页、
// 批量采购预览和采购任务列表上,含义是"机器找的,没人核过"。价格比对留待
// 引入汇率后再启用,见 ImageSearchPriceAllowed 的说明。
var product models.ShopeeProduct
if err := service.DB.WithContext(ctx).First(&product, snapshot.ShopeeProductID).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
@@ -35,11 +43,6 @@ func (service *Service) autoLinkImageSearch(ctx context.Context, taskID uint64)
}
return internalError(err)
}
// PDD products are RMB in the current contract; unlike-currency snapshots
// must remain available for manual review and can never auto-link.
if strings.ToUpper(strings.TrimSpace(product.Currency)) != "CNY" {
return nil
}
var pdd models.PDDProduct
if err := service.DB.WithContext(ctx).Select("id", "status").First(&pdd, *task.PDDProductID).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
@@ -49,8 +49,14 @@ func containsAny(value string, needles ...string) bool {
// seedAutoLinkCase 建一个「蝦皮商品当前关联 current,图搜任务找到 found,
// 任务创建时快照记录的关联是 original」的场景。
func seedAutoLinkCase(t *testing.T, db *gorm.DB, itemID string, current *uint64, original *uint64, found uint64) models.ShopeeProduct {
return seedAutoLinkCaseWithCurrency(t, db, itemID, "TWD", current, original, found)
}
// `[必须]` 默认用 TWD,因为线上 26902 个蕃皮商品**全部是 TWD**(2026-09-15 核对)。
// 之前夹具用 CNY,于是整套用例全绿,而线上因为跨币种判据一次都没关联成功过。
func seedAutoLinkCaseWithCurrency(t *testing.T, db *gorm.DB, itemID string, currency string, current *uint64, original *uint64, found uint64) models.ShopeeProduct {
t.Helper()
shopee := models.ShopeeProduct{ShopeeItemID: itemID, Title: "test", Currency: "CNY", SpecsJSON: "[]", PDDProductID: current}
shopee := models.ShopeeProduct{ShopeeItemID: itemID, Title: "test", Currency: currency, SpecsJSON: "[]", PDDProductID: current}
if err := db.Create(&shopee).Error; err != nil {
t.Fatal(err)
}
@@ -60,8 +66,9 @@ func seedAutoLinkCase(t *testing.T, db *gorm.DB, itemID string, current *uint64,
}
snapshot := ImageSearchSnapshot{
ShopeeProductID: shopee.ID, RepresentativeSYBProductID: 1, OriginalPDDProductID: original,
ReferenceCurrency: "CNY", ReferencePriceCent: 100, MaxPriceRatio: 3,
ImageSearchImage: ImageSearchImage{ImageURL: "https://example.invalid/a.jpg", MediaType: "image/jpeg", SizeBytes: 1, SHA256: "0123456789012345678901234567890101234567890123456789012345678901"},
ReferenceCurrency: currency, ReferencePriceCent: 100, MaxPriceRatio: 3,
PriceGuardSkipped: currency != "CNY",
ImageSearchImage: ImageSearchImage{ImageURL: "https://example.invalid/a.jpg", MediaType: "image/jpeg", SizeBytes: 1, SHA256: "0123456789012345678901234567890101234567890123456789012345678901"},
}
raw, _ := json.Marshal(snapshot)
task := models.CollectionTask{
@@ -150,3 +157,32 @@ func TestAutoLinkImageSearchLinksWhenStillUnlinked(t *testing.T) {
t.Fatal("auto link must set the image-search marker")
}
}
// 回归:PDD 是 CNY、蕃皮是 TWD 时仍然建立关联。
//
// 币种不同只说明价格没法比,不说明关联不该建立。旧实现在这里直接 return nil,
// 而线上商品全是 TWD,于是自动关联从上线起一次都没执行过:图搜任务
// completed、PDD 商品也采集完整,关联却停在旧值,采购因此无法创建。
func TestAutoLinkImageSearchLinksCrossCurrencyShopeeProducts(t *testing.T) {
db := openTaskDatabase(t)
found := seedAutoLinkPDD(t, db, "found-pdd")
saved := seedAutoLinkCaseWithCurrency(t, db, "twd-case", "TWD", nil, nil, found.ID)
if saved.PDDProductID == nil || *saved.PDDProductID != found.ID {
t.Fatalf("TWD 商品未建立关联: %+v", saved.PDDProductID)
}
if !saved.ImageSearchLinked {
t.Fatal("自动关联必须置上图搜标记,它是价格比对缺失时的唯一补偿控制")
}
}
// CNY 商品的行为不变。
func TestAutoLinkImageSearchStillLinksSameCurrencyProducts(t *testing.T) {
db := openTaskDatabase(t)
found := seedAutoLinkPDD(t, db, "found-pdd")
saved := seedAutoLinkCaseWithCurrency(t, db, "cny-case", "CNY", nil, nil, found.ID)
if saved.PDDProductID == nil || *saved.PDDProductID != found.ID {
t.Fatalf("CNY 商品未建立关联: %+v", saved.PDDProductID)
}
}