Compare commits

..
Author SHA1 Message Date
QiuSW 0138da8560 merge: resume bounded Shopee auto-match scans (#359) 2026-10-07 16:11:47 +08:00
QiuSW a82a1469a6 docs(#359): sync bounded auto-match scan contracts and verification limits 2026-10-07 16:09:17 +08:00
QiuSW 9fcbc64117 fix: accept verified no-op auto-match lease renewals (#359) 2026-10-07 16:05:22 +08:00
QiuSW 151c426e54 test: isolate repeated auto-match scan regressions (#359) 2026-10-07 16:01:31 +08:00
QiuSW 95847a3f2d fix: resume bounded Shopee auto-match scans safely (#359) 2026-10-07 15:55:23 +08:00
QiuSW 6781b309d4 docs(#363): record production Web rollout and verification 2026-10-07 11:29:02 +08:00
QiuSW eb7cb6b5eb merge: default inner code list to 200 per page (#363) 2026-10-07 11:19:56 +08:00
QiuSW 845853c68a test(#363): verify page-local pagination and document default 2026-10-07 11:15:35 +08:00
QiuSW 955869b5f5 fix(#363): default inner code page size to 200 2026-10-07 11:09:26 +08:00
QiuSW 49de6f8045 merge: Android spec probe safety and truncated card handling (#361, #362) 2026-10-07 10:00:02 +08:00
QiuSW 279cb55847 docs: bind installed integrated Agent 0.9.69 (#362) 2026-10-06 18:03:31 +08:00
QiuSW 35119ea261 merge: retain #361 in #362 Android 0.9.69 installation build 2026-10-06 17:59:10 +08:00
QiuSW cb00194b0f docs: record independent spec card boundary (#362) 2026-10-06 17:57:41 +08:00
QiuSW 46b92dd6c5 docs: sync spec card dedup and prerequisite Wiki facts (#362) 2026-10-06 17:55:45 +08:00
QiuSW 1f5bab8192 fix(android): preserve independent cards under labelled groups (#362) 2026-10-06 17:54:50 +08:00
QiuSW 9a4495addd fix(android): deduplicate truncated same-card spec captions (#362) 2026-10-06 17:49:11 +08:00
QiuSW 75ab25658d docs: document spec probe diagnostics and safe verification (#361) 2026-10-06 17:19:19 +08:00
QiuSW a49dc696e9 fix(android): guard spec probe clicks and persist attempt diagnostics (#361) 2026-10-06 17:15:30 +08:00
QiuSWandClaude Opus 5.5 64f0e4941f merge: AGENTS worktree cleanup and SynapBus messaging rules
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 14:36:21 +08:00
35 changed files with 2275 additions and 151 deletions
+2 -2
View File
@@ -11,8 +11,8 @@ android {
applicationId = "cn.ilapage.goauto.agent" applicationId = "cn.ilapage.goauto.agent"
minSdk = 23 minSdk = 23
targetSdk = 34 targetSdk = 34
versionCode = 77 versionCode = 82
versionName = "0.9.64" versionName = "0.9.69"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
@@ -339,6 +339,7 @@ class CurrentPageIdentityRunner(
when (outcome.result) { when (outcome.result) {
FreshActionResult.SUCCESS -> return ShareClickResult(true, "") FreshActionResult.SUCCESS -> return ShareClickResult(true, "")
FreshActionResult.AMBIGUOUS -> return ShareClickResult(false, "商品分享入口不唯一") FreshActionResult.AMBIGUOUS -> return ShareClickResult(false, "商品分享入口不唯一")
FreshActionResult.BLOCKED -> return ShareClickResult(false, "已阻止点击尺码建议入口")
FreshActionResult.NOT_FOUND, FreshActionResult.FAILED -> Unit FreshActionResult.NOT_FOUND, FreshActionResult.FAILED -> Unit
} }
} }
@@ -366,6 +367,7 @@ class CurrentPageIdentityRunner(
when (outcome.result) { when (outcome.result) {
FreshActionResult.SUCCESS -> return ShareClickResult(true, "") FreshActionResult.SUCCESS -> return ShareClickResult(true, "")
FreshActionResult.AMBIGUOUS -> return ShareClickResult(false, "复制链接入口不唯一") FreshActionResult.AMBIGUOUS -> return ShareClickResult(false, "复制链接入口不唯一")
FreshActionResult.BLOCKED -> return ShareClickResult(false, "已阻止点击尺码建议入口")
FreshActionResult.NOT_FOUND, FreshActionResult.FAILED -> Unit FreshActionResult.NOT_FOUND, FreshActionResult.FAILED -> Unit
} }
} }
@@ -528,6 +530,7 @@ class CurrentPageIdentityRunner(
FreshClickReason.NO_CLICKABLE_ANCESTOR -> AgentDiagnosticReason.NO_CLICKABLE_ANCESTOR FreshClickReason.NO_CLICKABLE_ANCESTOR -> AgentDiagnosticReason.NO_CLICKABLE_ANCESTOR
FreshClickReason.ACTION_CLICK_FALSE -> AgentDiagnosticReason.ACTION_CLICK_FALSE FreshClickReason.ACTION_CLICK_FALSE -> AgentDiagnosticReason.ACTION_CLICK_FALSE
FreshClickReason.UNKNOWN -> AgentDiagnosticReason.UNKNOWN FreshClickReason.UNKNOWN -> AgentDiagnosticReason.UNKNOWN
FreshClickReason.SIZE_ADVICE_BLOCKED -> AgentDiagnosticReason.SIZE_ADVICE_CLICK_BLOCKED
} }
private data class SharePreparation( private data class SharePreparation(
@@ -162,7 +162,7 @@ class GoAutoAccessibilityService : AccessibilityService(), UiDriver, PddCollecto
while (!target.isClickable) { while (!target.isClickable) {
target = target.parent ?: return false target = target.parent ?: return false
} }
return target.performAction(AccessibilityNodeInfo.ACTION_CLICK) return !sizeAdviceBlocked(target) && target.performAction(AccessibilityNodeInfo.ACTION_CLICK)
} }
override fun input(node: UiNodeRef, value: String): Boolean { override fun input(node: UiNodeRef, value: String): Boolean {
@@ -212,7 +212,7 @@ class GoAutoAccessibilityService : AccessibilityService(), UiDriver, PddCollecto
if (!node.isClickable || !node.isEnabled || !node.isVisibleToUser || if (!node.isClickable || !node.isEnabled || !node.isVisibleToUser ||
NodeBounds(bounds.left, bounds.top, bounds.right, bounds.bottom) != fresh.bounds || NodeBounds(bounds.left, bounds.top, bounds.right, bounds.bottom) != fresh.bounds ||
(node.text?.toString()?.trim().takeUnless { it.isNullOrEmpty() } ?: node.contentDescription?.toString()?.trim().orEmpty()) != fresh.label) return false (node.text?.toString()?.trim().takeUnless { it.isNullOrEmpty() } ?: node.contentDescription?.toString()?.trim().orEmpty()) != fresh.label) return false
return node.performAction(AccessibilityNodeInfo.ACTION_CLICK) return !sizeAdviceBlocked(node) && node.performAction(AccessibilityNodeInfo.ACTION_CLICK)
} }
fun scrollBackfill(): Boolean { fun scrollBackfill(): Boolean {
@@ -329,6 +329,15 @@ class GoAutoAccessibilityService : AccessibilityService(), UiDriver, PddCollecto
override fun clickFresh(target: SnapshotNode): FreshActionResult = clickFreshDetailed(target).result override fun clickFresh(target: SnapshotNode): FreshActionResult = clickFreshDetailed(target).result
private fun sizeAdviceBlocked(node: AccessibilityNodeInfo): Boolean =
node.packageName?.toString() == "com.xunmeng.pinduoduo" && SizeAdviceClickPolicy.blocked(
node,
clickable = { it.isClickable },
parent = { it.parent },
children = { current -> (0 until current.childCount).mapNotNull(current::getChild) },
labels = { listOfNotNull(it.text?.toString(), it.contentDescription?.toString()) },
)
override fun clickFreshDetailed(target: SnapshotNode): FreshClickOutcome { override fun clickFreshDetailed(target: SnapshotNode): FreshClickOutcome {
val root = rootInActiveWindow ?: return FreshClickOutcome(FreshActionResult.NOT_FOUND, FreshClickReason.ROOT_UNAVAILABLE) val root = rootInActiveWindow ?: return FreshClickOutcome(FreshActionResult.NOT_FOUND, FreshClickReason.ROOT_UNAVAILABLE)
val candidates = mutableListOf<AccessibilityNodeInfo>() val candidates = mutableListOf<AccessibilityNodeInfo>()
@@ -356,6 +365,9 @@ class GoAutoAccessibilityService : AccessibilityService(), UiDriver, PddCollecto
) )
ancestorDepth++ ancestorDepth++
} }
if (sizeAdviceBlocked(node)) return FreshClickOutcome(
FreshActionResult.BLOCKED, FreshClickReason.SIZE_ADVICE_BLOCKED, candidates.size, ancestorDepth,
)
return if (node.performAction(AccessibilityNodeInfo.ACTION_CLICK)) { return if (node.performAction(AccessibilityNodeInfo.ACTION_CLICK)) {
FreshClickOutcome(FreshActionResult.SUCCESS, FreshClickReason.SUCCESS, candidates.size, ancestorDepth) FreshClickOutcome(FreshActionResult.SUCCESS, FreshClickReason.SUCCESS, candidates.size, ancestorDepth)
} else { } else {
@@ -392,6 +404,9 @@ class GoAutoAccessibilityService : AccessibilityService(), UiDriver, PddCollecto
) )
ancestorDepth++ ancestorDepth++
} }
if (sizeAdviceBlocked(node)) return FreshClickOutcome(
FreshActionResult.BLOCKED, FreshClickReason.SIZE_ADVICE_BLOCKED, 1, ancestorDepth,
)
return if (node.performAction(AccessibilityNodeInfo.ACTION_CLICK)) { return if (node.performAction(AccessibilityNodeInfo.ACTION_CLICK)) {
FreshClickOutcome(FreshActionResult.SUCCESS, FreshClickReason.SUCCESS, 1, ancestorDepth) FreshClickOutcome(FreshActionResult.SUCCESS, FreshClickReason.SUCCESS, 1, ancestorDepth)
} else { } else {
@@ -414,6 +429,7 @@ class GoAutoAccessibilityService : AccessibilityService(), UiDriver, PddCollecto
} }
if (candidates.isEmpty()) return FreshActionResult.NOT_FOUND if (candidates.isEmpty()) return FreshActionResult.NOT_FOUND
if (candidates.size != 1) return FreshActionResult.AMBIGUOUS if (candidates.size != 1) return FreshActionResult.AMBIGUOUS
if (sizeAdviceBlocked(candidates.single())) return FreshActionResult.BLOCKED
val bounds = Rect().also(candidates.single()::getBoundsInScreen) val bounds = Rect().also(candidates.single()::getBoundsInScreen)
if (bounds.width() < 2 || bounds.height() < 2 || Build.VERSION.SDK_INT < Build.VERSION_CODES.N) { if (bounds.width() < 2 || bounds.height() < 2 || Build.VERSION.SDK_INT < Build.VERSION_CODES.N) {
return FreshActionResult.FAILED return FreshActionResult.FAILED
@@ -438,6 +454,7 @@ class GoAutoAccessibilityService : AccessibilityService(), UiDriver, PddCollecto
} }
if (candidates.isEmpty()) return FreshActionResult.NOT_FOUND if (candidates.isEmpty()) return FreshActionResult.NOT_FOUND
if (candidates.size != 1) return FreshActionResult.AMBIGUOUS if (candidates.size != 1) return FreshActionResult.AMBIGUOUS
if (sizeAdviceBlocked(candidates.single())) return FreshActionResult.BLOCKED
val bounds = Rect().also(candidates.single()::getBoundsInScreen) val bounds = Rect().also(candidates.single()::getBoundsInScreen)
val freshTarget = target.copy(bounds = NodeBounds(bounds.left, bounds.top, bounds.right, bounds.bottom)) val freshTarget = target.copy(bounds = NodeBounds(bounds.left, bounds.top, bounds.right, bounds.bottom))
if (!PurchaseSpecGesturePolicy.isAllowed(freshTarget, width, height)) { if (!PurchaseSpecGesturePolicy.isAllowed(freshTarget, width, height)) {
@@ -38,7 +38,7 @@ data class UiSnapshot(
val nodes: List<SnapshotNode>, val nodes: List<SnapshotNode>,
) )
enum class FreshActionResult { SUCCESS, NOT_FOUND, AMBIGUOUS, FAILED } enum class FreshActionResult { SUCCESS, NOT_FOUND, AMBIGUOUS, FAILED, BLOCKED }
enum class FreshClickReason { enum class FreshClickReason {
SUCCESS, SUCCESS,
@@ -47,6 +47,7 @@ enum class FreshClickReason {
TARGET_AMBIGUOUS, TARGET_AMBIGUOUS,
NO_CLICKABLE_ANCESTOR, NO_CLICKABLE_ANCESTOR,
ACTION_CLICK_FALSE, ACTION_CLICK_FALSE,
SIZE_ADVICE_BLOCKED,
UNKNOWN, UNKNOWN,
} }
@@ -375,6 +376,7 @@ object PddScreenParser {
val candidates = panelVisible.asSequence() val candidates = panelVisible.asSequence()
.filter { it.clickable && inValueRegion(it) } .filter { it.clickable && inValueRegion(it) }
.filterNot { isExactHeadingLabel(it.label, config) } .filterNot { isExactHeadingLabel(it.label, config) }
.filterNot { containsDistinctOptionCard(it, sourceByPath) }
.filterNot { node -> .filterNot { node ->
isExcludedOptionLabel(node.label) || isExcludedOptionLabel(node.label) ||
descendants(node, visibleNodes).any { descendant -> isExcludedOptionLabel(descendant.label) } descendants(node, visibleNodes).any { descendant -> isExcludedOptionLabel(descendant.label) }
@@ -585,8 +587,8 @@ object PddScreenParser {
/** /**
* Outermost labelled clickable ancestor inside the value region whose label * Outermost labelled clickable ancestor inside the value region whose label
* prefixes every candidate nested in it. Rows that contain several distinct * prefixes every candidate nested in it, or a single image/caption card with
* options never qualify because their members do not share its label. * a truncated caption. A shared ancestor alone is not card identity.
*/ */
private fun optionBlock( private fun optionBlock(
candidate: SnapshotNode, candidate: SnapshotNode,
@@ -604,9 +606,11 @@ object PddScreenParser {
// Only a block with its own label names the option; a blank row // Only a block with its own label names the option; a blank row
// container resolved from its first child never merges options. // container resolved from its first child never merges options.
if (raw.clickable && raw.label.isNotBlank() && resolved != null && inValueRegion(resolved)) { if (raw.clickable && raw.label.isNotBlank() && resolved != null && inValueRegion(resolved)) {
if (containsDistinctOptionCard(resolved, sourceByPath)) break
val outer = compact(resolved.label) val outer = compact(resolved.label)
val members = candidates.filter { it.path == resolved.path || it.path.startsWith("${resolved.path}/") } val members = candidates.filter { it.path == resolved.path || it.path.startsWith("${resolved.path}/") }
if (outer.isEmpty() || members.any { !compact(it.label).startsWith(outer) }) break val existingBlock = outer.isNotEmpty() && members.all { compact(it.label).startsWith(outer) }
if (!existingBlock && !isTruncatedDisplayCard(resolved, members, sourceByPath)) break
block = resolved block = resolved
} }
parentPath = raw.parentPath parentPath = raw.parentPath
@@ -614,6 +618,57 @@ object PddScreenParser {
return block return block
} }
/** A labelled group must neither name its children nor become a synthetic selected option. */
private fun containsDistinctOptionCard(block: SnapshotNode, sourceByPath: Map<String, SnapshotNode>): Boolean {
val raw = sourceByPath[block.path] ?: return false
if (raw.label.isBlank()) return false
val name = SpecValueNormalizer.normalizeColor(raw.label).filterNot { it.isWhitespace() }
return sourceByPath.values.any { child ->
child.path.startsWith("${block.path}/") && child.clickable && child.label.isNotBlank() &&
SpecValueNormalizer.normalizeColor(child.label).filterNot { it.isWhitespace() } != name &&
sourceByPath.values.any { it.parentPath == child.path }
}
}
/**
* #362: narrowly recognize one full-labelled image above one truncated text
* leaf. Do not reverse the old prefix rule for arbitrary nested options.
* Non-clickable layout wrappers are fine; another clickable container is
* ambiguous, even if its label/price also happens to be a prefix match.
*/
private fun isTruncatedDisplayCard(
block: SnapshotNode,
members: List<SnapshotNode>,
sourceByPath: Map<String, SnapshotNode>,
): Boolean {
fun name(label: String) = SpecValueNormalizer.normalizeColor(label).filterNot { it.isWhitespace() }
val full = name(block.label)
if (full.isEmpty()) return false
val children = members.filter { it.path != block.path }
val image = children.singleOrNull { it.className == "android.widget.ImageView" } ?: return false
val caption = children.singleOrNull { it.className == "android.widget.TextView" } ?: return false
if (children.size != 2 || name(image.label) != full) return false
val short = name(caption.label)
if (short.isEmpty() || short == full || !full.startsWith(short)) return false
val descendants = sourceByPath.values.filter { it.path.startsWith("${block.path}/") }
// Image and caption must be leaves, not two independently labelled cards.
if (children.any { child -> descendants.any { it.parentPath == child.path } }) return false
if (descendants.any { node ->
node.clickable && node.label.isNotBlank() &&
descendants.any { it.parentPath == node.path }
}) return false
if (children.any { child ->
child.bounds.left < block.bounds.left || child.bounds.right > block.bounds.right ||
child.bounds.top < block.bounds.top || child.bounds.bottom > block.bounds.bottom
}) return false
if (image.bounds.top >= caption.bounds.top || image.bounds.bottom >= caption.bounds.bottom ||
minOf(image.bounds.right, caption.bounds.right) <= maxOf(image.bounds.left, caption.bounds.left)) return false
val prices = members.mapNotNull { SpecValueNormalizer.trailingPriceAmount(it.label) }
return prices.isEmpty() || prices.all { it.compareTo(prices.first()) == 0 }
}
/** The nearby clickable row holding [node], or null when there is none or it is not a plausible entry row. */ /** The nearby clickable row holding [node], or null when there is none or it is not a plausible entry row. */
private fun clickableEntryRow(node: SnapshotNode, sourceByPath: Map<String, SnapshotNode>, screenHeight: Int): SnapshotNode? { private fun clickableEntryRow(node: SnapshotNode, sourceByPath: Map<String, SnapshotNode>, screenHeight: Int): SnapshotNode? {
var current: SnapshotNode? = node var current: SnapshotNode? = node
@@ -1021,7 +1076,8 @@ class PddProductDetailCollector(
while (opened == null) { while (opened == null) {
val target = entry ?: return failure("RULE_NOT_MATCHED", "未找到 PDD 商品规格入口") val target = entry ?: return failure("RULE_NOT_MATCHED", "未找到 PDD 商品规格入口")
val beforeClick = screenSignature(current) val beforeClick = screenSignature(current)
val clickResult = driver.clickFresh(target) val clickResult = clickSpecTarget(target, AgentDiagnosticStage.SPEC_ENTRY_CLICK)
if (clickResult == FreshActionResult.BLOCKED) return sizeAdviceFailure()
trace( trace(
"spec-entry source=${entrySource ?: "unknown"} label=${target.label.take(40)} " + "spec-entry source=${entrySource ?: "unknown"} label=${target.label.take(40)} " +
"class=${target.className} bounds=${target.bounds} result=$clickResult", "class=${target.className} bounds=${target.bounds} result=$clickResult",
@@ -1131,7 +1187,8 @@ class PddProductDetailCollector(
opened = screen opened = screen
break break
} }
when (driver.clickFresh(recoveryEntry)) { when (clickSpecTarget(recoveryEntry, AgentDiagnosticStage.QUICK_CONFIRMATION_CLICK)) {
FreshActionResult.BLOCKED -> return sizeAdviceFailure()
FreshActionResult.SUCCESS -> { FreshActionResult.SUCCESS -> {
quickConfirmationRecovered = true quickConfirmationRecovered = true
pause(100) pause(100)
@@ -1431,9 +1488,10 @@ class PddProductDetailCollector(
} ?: return null } ?: return null
attempted += value.text attempted += value.text
val beforeClick = screen val beforeClick = screen
val clickResult = driver.clickFresh(value.node) val clickResult = clickSpecTarget(value.node, AgentDiagnosticStage.COLOR_CLICK)
trace("color-click color=${traceLabel(value.text)} result=$clickResult") trace("color-click color=${traceLabel(value.text)} result=$clickResult")
when (clickResult) { when (clickResult) {
FreshActionResult.BLOCKED -> return sizeAdviceFailure()
FreshActionResult.AMBIGUOUS -> return failure("RULE_AMBIGUOUS", "颜色“${value.text}”匹配到多个控件") FreshActionResult.AMBIGUOUS -> return failure("RULE_AMBIGUOUS", "颜色“${value.text}”匹配到多个控件")
FreshActionResult.NOT_FOUND, FreshActionResult.FAILED -> { FreshActionResult.NOT_FOUND, FreshActionResult.FAILED -> {
missing += "selection:${value.text}" missing += "selection:${value.text}"
@@ -1538,6 +1596,14 @@ class PddProductDetailCollector(
return null return null
} }
private fun sizeAdviceFailure() = failure("SIZE_ADVICE_CLICK_BLOCKED", "已阻止点击尺码建议入口")
private fun clickSpecTarget(target: SnapshotNode, stage: AgentDiagnosticStage): FreshActionResult {
val outcome = driver.clickFreshDetailed(target)
if (taskId > 0) runCatching { diagnostic(specClickDiagnostic(taskId, stage, target, outcome)) }
return outcome.result
}
private fun recordColorImage(reason: AgentDiagnosticReason) { private fun recordColorImage(reason: AgentDiagnosticReason) {
if (taskId <= 0) return if (taskId <= 0) return
diagnostic(AgentDiagnosticEvent(taskId = taskId, stage = AgentDiagnosticStage.COLOR_IMAGE, reason = reason, attempt = 1)) diagnostic(AgentDiagnosticEvent(taskId = taskId, stage = AgentDiagnosticStage.COLOR_IMAGE, reason = reason, attempt = 1))
@@ -1,5 +1,7 @@
package cn.ilapage.goauto.agent.automation package cn.ilapage.goauto.agent.automation
import cn.ilapage.goauto.agent.persistence.AgentDiagnosticStage
import java.net.URI import java.net.URI
import java.net.URLDecoder import java.net.URLDecoder
@@ -106,6 +108,7 @@ class PurchaseRehearsalExecutor(
private val stepChanged: (String) -> Unit = {}, private val stepChanged: (String) -> Unit = {},
private val panelDiagnostic: (String) -> Unit = {}, private val panelDiagnostic: (String) -> Unit = {},
private val beforeOrderSubmit: (FinalConfirmationEvidence) -> Unit = { throw PurchaseLiveException("PURCHASE_MODE_NOT_ALLOWED", "当前执行器没有正式采购授权") }, private val beforeOrderSubmit: (FinalConfirmationEvidence) -> Unit = { throw PurchaseLiveException("PURCHASE_MODE_NOT_ALLOWED", "当前执行器没有正式采购授权") },
private val probeClickDiagnostic: (AgentDiagnosticStage, SnapshotNode, FreshClickOutcome) -> Unit = { _, _, _ -> },
) { ) {
private var purchasePanelContext: PurchasePanelContext? = null private var purchasePanelContext: PurchasePanelContext? = null
@@ -393,6 +396,10 @@ class PurchaseRehearsalExecutor(
} }
val beforeSignature = specActionSignature(screen) val beforeSignature = specActionSignature(screen)
val click = driver.clickFreshDetailed(requireNotNull(target)) val click = driver.clickFreshDetailed(requireNotNull(target))
if (input.phase == "spec_probe") runCatching {
probeClickDiagnostic(AgentDiagnosticStage.SPEC_ENTRY_CLICK, requireNotNull(target), click)
}
if (click.result == FreshActionResult.BLOCKED) return sizeAdviceFailure()
when (click.result) { when (click.result) {
// The parser already narrowed to a single semantic candidate; the // The parser already narrowed to a single semantic candidate; the
// ambiguity here comes from the live tree matching that target more // ambiguity here comes from the live tree matching that target more
@@ -414,7 +421,14 @@ class PurchaseRehearsalExecutor(
) )
} }
when (driver.tapSpecFresh(requireNotNull(target))) { val gestureResult = driver.tapSpecFresh(requireNotNull(target))
if (input.phase == "spec_probe") runCatching {
probeClickDiagnostic(AgentDiagnosticStage.SPEC_ENTRY_GESTURE, requireNotNull(target), FreshClickOutcome(
gestureResult, if (gestureResult == FreshActionResult.BLOCKED) FreshClickReason.SIZE_ADVICE_BLOCKED else FreshClickReason.UNKNOWN,
))
}
when (gestureResult) {
FreshActionResult.BLOCKED -> return sizeAdviceFailure()
FreshActionResult.AMBIGUOUS -> return failure( FreshActionResult.AMBIGUOUS -> return failure(
SPEC_ENTRY_TARGET_AMBIGUOUS, SPEC_ENTRY_TARGET_AMBIGUOUS,
"规格入口手势目标不唯一 [${specEntryEvidence(screen, 1, entryReadyWaitPolls)}]", "规格入口手势目标不唯一 [${specEntryEvidence(screen, 1, entryReadyWaitPolls)}]",
@@ -605,6 +619,7 @@ class PurchaseRehearsalExecutor(
val lookup = locateExactSpec(input, dimension, target) val lookup = locateExactSpec(input, dimension, target)
lookup.failure?.let { return it } lookup.failure?.let { return it }
val outcome = driver.clickFreshDetailed(requireNotNull(lookup.node)) val outcome = driver.clickFreshDetailed(requireNotNull(lookup.node))
if (outcome.result == FreshActionResult.BLOCKED) return sizeAdviceFailure()
if (outcome.result == FreshActionResult.AMBIGUOUS) { if (outcome.result == FreshActionResult.AMBIGUOUS) {
return failure(SPEC_TARGET_AMBIGUOUS, "精确规格匹配到多个控件") return failure(SPEC_TARGET_AMBIGUOUS, "精确规格匹配到多个控件")
} }
@@ -618,6 +633,7 @@ class PurchaseRehearsalExecutor(
val refreshedLookup = locateExactSpec(input, dimension, target) val refreshedLookup = locateExactSpec(input, dimension, target)
refreshedLookup.failure?.let { return it } refreshedLookup.failure?.let { return it }
val gesture = driver.tapSpecFresh(requireNotNull(refreshedLookup.node)) val gesture = driver.tapSpecFresh(requireNotNull(refreshedLookup.node))
if (gesture == FreshActionResult.BLOCKED) return sizeAdviceFailure()
if (gesture == FreshActionResult.AMBIGUOUS) { if (gesture == FreshActionResult.AMBIGUOUS) {
return failure(SPEC_TARGET_AMBIGUOUS, "精确规格手势目标不唯一") return failure(SPEC_TARGET_AMBIGUOUS, "精确规格手势目标不唯一")
} }
@@ -1165,6 +1181,8 @@ class PurchaseRehearsalExecutor(
private fun applyPostAction(input: PurchaseExecutionInput, action: PurchaseAction): PurchaseExecutionOutcome? { private fun applyPostAction(input: PurchaseExecutionInput, action: PurchaseAction): PurchaseExecutionOutcome? {
if (action.waitAfterMs > 0) pause(action.waitAfterMs) if (action.waitAfterMs > 0) pause(action.waitAfterMs)
// The collector owns probe scrolling. Preserve settling time and every purchase-phase action.
if (input.phase == "spec_probe" && action.type == PurchaseActionType.OPEN_SPEC_PANEL) return null
action.swipeAfter?.let { swipe -> action.swipeAfter?.let { swipe ->
val reveal = action.type == PurchaseActionType.OPEN_SPEC_PANEL val reveal = action.type == PurchaseActionType.OPEN_SPEC_PANEL
val initialScreen = if (reveal) currentScreen(input) else null val initialScreen = if (reveal) currentScreen(input) else null
@@ -1207,6 +1225,8 @@ class PurchaseRehearsalExecutor(
return PurchaseExecutionOutcome("spec_probe_completed", message = "商品规格已回传,等待服务端匹配", probedSpecs = result) return PurchaseExecutionOutcome("spec_probe_completed", message = "商品规格已回传,等待服务端匹配", probedSpecs = result)
} }
private fun sizeAdviceFailure() = failure("SIZE_ADVICE_CLICK_BLOCKED", "已阻止点击尺码建议入口")
private fun normalizedTarget(dimension: String, raw: String): String? { private fun normalizedTarget(dimension: String, raw: String): String? {
val trimmed = raw.trim() val trimmed = raw.trim()
if (dimension != "size") return trimmed.takeIf(String::isNotBlank) if (dimension != "size") return trimmed.takeIf(String::isNotBlank)
@@ -0,0 +1,42 @@
package cn.ilapage.goauto.agent.automation
/** Only inspects the actual click receiver, never the whole page or unrelated siblings. */
internal object SizeAdviceClickPolicy {
private val phrases = listOf(
"查看尺码建议", "查看尺碼建議", "查看尺寸建议", "查看尺寸建議",
"尺码助手", "尺碼助手", "尺寸助手", "智能选码", "智能選碼", "选码建议", "選碼建議",
)
fun containsAdvice(label: String): Boolean {
val compact = label.filterNot(Char::isWhitespace)
return phrases.any(compact::contains)
}
fun <N : Any> blocked(
target: N,
clickable: (N) -> Boolean,
parent: (N) -> N?,
children: (N) -> List<N>,
labels: (N) -> List<String>,
): Boolean {
var receiver = target
while (!clickable(receiver)) {
val ancestor = parent(receiver)
if (ancestor == null) {
// A gesture can still reach a non-clickable leaf. Inspect the original
// target, not the unrelated root subtree, if no receiver is exposed.
receiver = target
break
}
receiver = ancestor
}
val pending = ArrayDeque<N>()
pending.add(receiver)
while (pending.isNotEmpty()) {
val node = pending.removeFirst()
if (labels(node).any(::containsAdvice)) return true
pending.addAll(children(node))
}
return false
}
}
@@ -0,0 +1,32 @@
package cn.ilapage.goauto.agent.automation
import cn.ilapage.goauto.agent.persistence.AgentDiagnosticEvent
import cn.ilapage.goauto.agent.persistence.AgentDiagnosticReason
import cn.ilapage.goauto.agent.persistence.AgentDiagnosticStage
/** No labels, bounds, selectors, or raw trace strings cross this boundary. */
internal fun specClickDiagnostic(
taskId: Long, stage: AgentDiagnosticStage, target: SnapshotNode, outcome: FreshClickOutcome,
) = AgentDiagnosticEvent(
taskId = taskId,
stage = stage,
reason = when (outcome.reason) {
FreshClickReason.SIZE_ADVICE_BLOCKED -> AgentDiagnosticReason.SIZE_ADVICE_CLICK_BLOCKED
FreshClickReason.ROOT_UNAVAILABLE -> AgentDiagnosticReason.ROOT_UNAVAILABLE
FreshClickReason.NO_CLICKABLE_ANCESTOR -> AgentDiagnosticReason.NO_CLICKABLE_ANCESTOR
else -> when (outcome.result) {
FreshActionResult.SUCCESS -> AgentDiagnosticReason.CLICK_SUCCESS
FreshActionResult.BLOCKED -> AgentDiagnosticReason.SIZE_ADVICE_CLICK_BLOCKED
FreshActionResult.AMBIGUOUS -> AgentDiagnosticReason.TARGET_AMBIGUOUS
FreshActionResult.NOT_FOUND -> AgentDiagnosticReason.TARGET_NOT_FOUND
FreshActionResult.FAILED -> AgentDiagnosticReason.ACTION_CLICK_FALSE
}
},
candidateCount = outcome.candidateCount,
targetClassName = target.className?.takeIf { it in setOf(
"android.widget.TextView", "android.widget.Button", "android.widget.LinearLayout",
"android.widget.FrameLayout", "android.view.View", "android.view.ViewGroup",
) },
targetClickable = target.clickable,
clickableAncestorDepth = outcome.clickableAncestorDepth,
)
@@ -13,6 +13,10 @@ object SpecValueNormalizer {
fun normalizeSize(value: String): String = normalizeTrailingPrice(value) fun normalizeSize(value: String): String = normalizeTrailingPrice(value)
/** The same terminal-price grammar as normalization; never reads numbers inside a spec. */
internal fun trailingPriceAmount(value: String): java.math.BigDecimal? =
trailingPrice.find(value)?.value?.replace(currency, "")?.trim()?.toBigDecimalOrNull()
fun isSafeColor(value: String): Boolean = isSafeValue(value) fun isSafeColor(value: String): Boolean = isSafeValue(value)
fun isSafeSize(value: String): Boolean = isSafeValue(value) fun isSafeSize(value: String): Boolean = isSafeValue(value)
@@ -1,7 +1,12 @@
package cn.ilapage.goauto.agent.persistence package cn.ilapage.goauto.agent.persistence
internal object AgentDiagnosticSchema { internal object AgentDiagnosticSchema {
const val VERSION = 2 const val VERSION = 3
val purchaseDiagnosticColumns = linkedMapOf(
"task_type" to "TEXT", "task_attempt_id" to "TEXT", "device_id" to "INTEGER",
"phase" to "TEXT", "rule_snapshot_hash" to "TEXT",
)
val colorDiagnosticColumns = linkedMapOf( val colorDiagnosticColumns = linkedMapOf(
"color_row_count" to "INTEGER", "color_row_count" to "INTEGER",
@@ -42,6 +47,11 @@ internal object AgentDiagnosticSchema {
initial_selected_size_count INTEGER, initial_selected_size_count INTEGER,
selected_summary_present INTEGER, selected_summary_present INTEGER,
horizontal_swipe_count INTEGER, horizontal_swipe_count INTEGER,
task_type TEXT,
task_attempt_id TEXT,
device_id INTEGER,
phase TEXT,
rule_snapshot_hash TEXT,
agent_version TEXT NOT NULL, agent_version TEXT NOT NULL,
created_at INTEGER NOT NULL created_at INTEGER NOT NULL
)""".trimIndent() )""".trimIndent()
@@ -52,4 +62,10 @@ internal object AgentDiagnosticSchema {
if (name in existingColumns) null else "ALTER TABLE agent_diagnostic ADD COLUMN $name $definition" if (name in existingColumns) null else "ALTER TABLE agent_diagnostic ADD COLUMN $name $definition"
} }
} }
fun migrationStatements(oldVersion: Int, newVersion: Int, existingColumns: Set<String>): List<String> =
v2MigrationStatements(oldVersion, newVersion, existingColumns) +
if (oldVersion < 3 && newVersion >= 3) purchaseDiagnosticColumns.mapNotNull { (name, definition) ->
if (name in existingColumns) null else "ALTER TABLE agent_diagnostic ADD COLUMN $name $definition"
} else emptyList()
} }
@@ -7,6 +7,10 @@ import android.database.sqlite.SQLiteOpenHelper
import cn.ilapage.goauto.agent.BuildConfig import cn.ilapage.goauto.agent.BuildConfig
enum class AgentDiagnosticStage { enum class AgentDiagnosticStage {
SPEC_ENTRY_CLICK,
SPEC_ENTRY_GESTURE,
QUICK_CONFIRMATION_CLICK,
COLOR_CLICK,
DETAIL_ENTRY, DETAIL_ENTRY,
SPEC_PANEL_ENTRY, SPEC_PANEL_ENTRY,
COLOR_DISCOVERY, COLOR_DISCOVERY,
@@ -20,6 +24,7 @@ enum class AgentDiagnosticStage {
} }
enum class AgentDiagnosticReason { enum class AgentDiagnosticReason {
SIZE_ADVICE_CLICK_BLOCKED,
DETAIL_ENTRY_MATCHED, DETAIL_ENTRY_MATCHED,
SPEC_PANEL_OPENED, SPEC_PANEL_OPENED,
SPEC_PANEL_RECOVERED, SPEC_PANEL_RECOVERED,
@@ -104,6 +109,11 @@ data class AgentDiagnosticEvent(
val selectedSummaryPresent: Boolean? = null, val selectedSummaryPresent: Boolean? = null,
val horizontalSwipeCount: Int? = null, val horizontalSwipeCount: Int? = null,
val createdAt: Long = System.currentTimeMillis(), val createdAt: Long = System.currentTimeMillis(),
val taskType: String = "collection",
val taskAttemptId: String? = null,
val deviceId: Long? = null,
val phase: String? = null,
val ruleSnapshotHash: String? = null,
) )
class SafeAgentDiagnosticRecorder( class SafeAgentDiagnosticRecorder(
@@ -130,7 +140,7 @@ class AgentDiagnosticStore(context: Context) : SQLiteOpenHelper(context, DATABAS
override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) { override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) {
val existing = columnNames(db) val existing = columnNames(db)
AgentDiagnosticSchema.v2MigrationStatements(oldVersion, newVersion, existing).forEach(db::execSQL) AgentDiagnosticSchema.migrationStatements(oldVersion, newVersion, existing).forEach(db::execSQL)
} }
private fun columnNames(db: SQLiteDatabase): Set<String> = private fun columnNames(db: SQLiteDatabase): Set<String> =
@@ -144,11 +154,22 @@ class AgentDiagnosticStore(context: Context) : SQLiteOpenHelper(context, DATABAS
@Synchronized @Synchronized
fun record(event: AgentDiagnosticEvent) { fun record(event: AgentDiagnosticEvent) {
require(event.taskId > 0) require(event.taskId > 0)
require(event.taskType in setOf("collection", "purchase"))
if (event.taskType == "purchase") {
require(event.taskAttemptId?.matches(Regex("[0-9a-fA-F]{8}(-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12}")) == true)
require((event.deviceId ?: 0) > 0 && event.phase in setOf("spec_probe", "purchase"))
require(event.ruleSnapshotHash?.matches(Regex("[0-9a-fA-F]{64}")) == true)
}
val db = writableDatabase val db = writableDatabase
db.beginTransaction() db.beginTransaction()
try { try {
val values = ContentValues().apply { val values = ContentValues().apply {
put("task_id", event.taskId) put("task_id", event.taskId)
put("task_type", event.taskType)
put("task_attempt_id", event.taskAttemptId)
event.deviceId?.let { put("device_id", it) }
put("phase", event.phase)
put("rule_snapshot_hash", event.ruleSnapshotHash)
put("stage", event.stage.name) put("stage", event.stage.name)
put("reason", event.reason.name) put("reason", event.reason.name)
put("attempt", event.attempt.coerceAtLeast(0)) put("attempt", event.attempt.coerceAtLeast(0))
@@ -22,6 +22,7 @@ import cn.ilapage.goauto.agent.ClipboardRelayActivity
import cn.ilapage.goauto.agent.MainActivity import cn.ilapage.goauto.agent.MainActivity
import cn.ilapage.goauto.agent.R import cn.ilapage.goauto.agent.R
import cn.ilapage.goauto.agent.automation.CollectionAssembler import cn.ilapage.goauto.agent.automation.CollectionAssembler
import cn.ilapage.goauto.agent.automation.specClickDiagnostic
import cn.ilapage.goauto.agent.automation.AgentCapabilities import cn.ilapage.goauto.agent.automation.AgentCapabilities
import cn.ilapage.goauto.agent.automation.CurrentPageIdentityRunner import cn.ilapage.goauto.agent.automation.CurrentPageIdentityRunner
import cn.ilapage.goauto.agent.automation.CurrentPageLaunchResult import cn.ilapage.goauto.agent.automation.CurrentPageLaunchResult
@@ -589,6 +590,13 @@ class AgentForegroundService : Service() {
val snapshotHash = task.ruleSnapshotHash.takeIf { snapshotHashValid } ?: "0".repeat(64) val snapshotHash = task.ruleSnapshotHash.takeIf { snapshotHashValid } ?: "0".repeat(64)
purchaseStore.recordRunning(task.taskId, task.taskAttemptId, snapshotHash) purchaseStore.recordRunning(task.taskId, task.taskAttemptId, snapshotHash)
val taskLabel = if (task.executionMode == "live") "正式采购" else "采购演练" val taskLabel = if (task.executionMode == "live") "正式采购" else "采购演练"
val diagnosticDeviceId = runCatching { identityStore.credentials()?.deviceId }.getOrNull()
val probeDiagnostic: (AgentDiagnosticEvent) -> Unit = { event ->
diagnosticRecorder.record(event.copy(
taskId = task.taskId, taskType = "purchase", taskAttemptId = task.taskAttemptId,
deviceId = diagnosticDeviceId, phase = task.phase, ruleSnapshotHash = task.ruleSnapshotHash,
))
}
stateStore.update("BUSY", "正在执行${taskLabel}任务 #${task.taskId}", tokenStored = true) stateStore.update("BUSY", "正在执行${taskLabel}任务 #${task.taskId}", tokenStored = true)
updateNotification("$taskLabel #${task.taskId}") updateNotification("$taskLabel #${task.taskId}")
@@ -612,7 +620,10 @@ class AgentForegroundService : Service() {
PurchaseRehearsalExecutor( PurchaseRehearsalExecutor(
driver = accessibility, driver = accessibility,
openLink = { PddLinkLauncher(this).open(it, preferDirect = true) }, openLink = { PddLinkLauncher(this).open(it, preferDirect = true) },
probeSpecs = { collectPurchaseProbe(accessibility, task, parsedRule) }, probeSpecs = { collectPurchaseProbe(accessibility, task, parsedRule, probeDiagnostic) },
probeClickDiagnostic = { stage, target, outcome ->
probeDiagnostic(specClickDiagnostic(task.taskId, stage, target, outcome))
},
stepChanged = { step -> stepChanged = { step ->
lastStep.set(step) lastStep.set(step)
purchaseStore.updateStep(task.taskId, task.taskAttemptId, step) purchaseStore.updateStep(task.taskId, task.taskAttemptId, step)
@@ -683,7 +694,10 @@ class AgentForegroundService : Service() {
} }
} }
private fun collectPurchaseProbe(accessibility: GoAutoAccessibilityService, task: PurchaseAgentTask, purchaseRule: PurchaseRule): String? { private fun collectPurchaseProbe(
accessibility: GoAutoAccessibilityService, task: PurchaseAgentTask, purchaseRule: PurchaseRule,
diagnostic: (AgentDiagnosticEvent) -> Unit,
): String? {
val snapshot = accessibility.capture() val snapshot = accessibility.capture()
val activity = snapshot.activityName ?: return null val activity = snapshot.activityName ?: return null
val collector = PurchaseRehearsalExecutor.collectorForProbe(purchaseRule) val collector = PurchaseRehearsalExecutor.collectorForProbe(purchaseRule)
@@ -694,7 +708,8 @@ class AgentForegroundService : Service() {
pageEvidence = PageEvidence("com.xunmeng.pinduoduo", activity, NodeSelector()), pageEvidence = PageEvidence("com.xunmeng.pinduoduo", activity, NodeSelector()),
collector = collector, collector = collector,
) )
val result = PddProductDetailCollector(accessibility).collect(task.pddGoodsId, rule) val result = PddProductDetailCollector(accessibility, taskId = task.taskId, diagnostic = diagnostic)
.collect(task.pddGoodsId, rule)
// #334: a collector failure (e.g. SPEC_PANEL_TOP_COLLAPSED) must not // #334: a collector failure (e.g. SPEC_PANEL_TOP_COLLAPSED) must not
// collapse into a generic "spec probe failed" outcome. Carry the // collapse into a generic "spec probe failed" outcome. Carry the
// real code/message through the opaque probeSpecs callback so // real code/message through the opaque probeSpecs callback so
@@ -27,6 +27,47 @@ import org.junit.Assert.assertTrue
import org.junit.Test import org.junit.Test
class PddProductDetailCollectorTest { class PddProductDetailCollectorTest {
@Test
fun `blocked color records ancestor evidence and never clicks a different color`() {
val base = FakeCollectorDriver(colors = listOf("红色", "蓝色"))
var attempts = 0
val driver = object : PddCollectorDriver by base {
override fun clickFreshDetailed(target: SnapshotNode): cn.ilapage.goauto.agent.automation.FreshClickOutcome {
attempts++
return cn.ilapage.goauto.agent.automation.FreshClickOutcome(
FreshActionResult.BLOCKED, cn.ilapage.goauto.agent.automation.FreshClickReason.SIZE_ADVICE_BLOCKED, 1, 2,
)
}
}
var clock = 0L
val events = mutableListOf<AgentDiagnosticEvent>()
val result = PddProductDetailCollector(driver, { clock }, { clock += it }, taskId = 1, diagnostic = events::add)
.collect(GOODS_ID, rule())
assertFalse(result.successful)
assertEquals("SIZE_ADVICE_CLICK_BLOCKED", result.code)
assertEquals(1, attempts)
assertEquals(0, base.clickCount)
val event = events.single { it.stage == AgentDiagnosticStage.COLOR_CLICK }
assertEquals(AgentDiagnosticReason.SIZE_ADVICE_CLICK_BLOCKED, event.reason)
assertEquals(2, event.clickableAncestorDepth)
}
@Test
fun `probe collector records clicks and discovers offscreen weight labelled sizes`() {
val driver = FakeCollectorDriver(
colors = listOf("黑色"),
sizePages = listOf(listOf("M 建议80-100斤"), listOf("4XL 建议160-180斤")),
)
var clock = 0L
val events = mutableListOf<AgentDiagnosticEvent>()
val result = PddProductDetailCollector(driver, { clock }, { clock += it }, taskId = 1, diagnostic = events::add)
.collect(GOODS_ID, rule())
assertTrue(result.successful)
assertEquals(listOf("M 建议80-100斤", "4XL 建议160-180斤"), result.payload!!.dimensions.first { it.key == "size" }.values)
assertTrue(driver.swipes.any { it.first == SwipeDirection.UP })
assertTrue(events.any { it.stage == AgentDiagnosticStage.COLOR_CLICK && it.reason == AgentDiagnosticReason.CLICK_SUCCESS })
}
private fun prefixlessPanel(): UiSnapshot = UiSnapshot(PDD_PACKAGE, ACTIVITY, listOf( private fun prefixlessPanel(): UiSnapshot = UiSnapshot(PDD_PACKAGE, ACTIVITY, listOf(
node("root", "", 0, 0, 1080, 2376), node("root", "", 0, 0, 1080, 2376),
node("close", "关闭", 970, 270, 1050, 350, clickable = true), node("close", "关闭", 970, 270, 1050, 350, clickable = true),
@@ -24,6 +24,62 @@ import org.junit.Assert.assertTrue
import org.junit.Test import org.junit.Test
class PurchaseRehearsalExecutorTest { class PurchaseRehearsalExecutorTest {
@Test
fun `probe skips post open swipes but preserves wait and records click`() {
val driver = FakePurchaseDriver()
val pauses = mutableListOf<Long>()
val clicks = mutableListOf<FreshClickOutcome>()
val configured = rule().replace("\"type\":\"openSpecPanel\"", "\"type\":\"openSpecPanel\",\"waitAfterMs\":1234")
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { "{}" },
pause = pauses::add, probeClickDiagnostic = { _, _, click -> clicks += click },
).execute(input().copy(phase = "spec_probe"), PurchaseRuleParser.parse(configured), PurchaseAgentCapabilities.supported)
assertEquals("spec_probe_completed", outcome.resultType)
assertEquals(0, driver.swipeCount)
assertEquals(1, pauses.count { it == 1234L })
assertTrue(clicks.any { it.result == FreshActionResult.SUCCESS })
}
@Test
fun `blocked entry stops without gesture or probe`() {
val driver = FakePurchaseDriver(forcedEntryClickReason = FreshClickReason.SIZE_ADVICE_BLOCKED)
var probes = 0
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { probes++; "{}" }, pause = {})
.execute(input().copy(phase = "spec_probe"), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals("SIZE_ADVICE_CLICK_BLOCKED", outcome.errorCode)
assertEquals(0, driver.specTapCount)
assertEquals(0, probes)
}
@Test
fun `blocked size stops without gesture retry`() {
val driver = FakePurchaseDriver(forcedSizeClickReason = FreshClickReason.SIZE_ADVICE_BLOCKED)
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals("SIZE_ADVICE_CLICK_BLOCKED", outcome.errorCode)
assertEquals(0, driver.specTapCount)
assertEquals(1, driver.sizeClickCount)
}
@Test
fun `blocked gesture stops without another click`() {
val driver = FakePurchaseDriver(forcedEntryClickReason = FreshClickReason.ACTION_CLICK_FALSE, specTapResult = FreshActionResult.BLOCKED)
var probes = 0
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { probes++; "{}" }, pause = {})
.execute(input().copy(phase = "spec_probe"), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals("SIZE_ADVICE_CLICK_BLOCKED", outcome.errorCode)
assertEquals(1, driver.specTapCount)
assertEquals(0, probes)
}
@Test
fun `purchase still fails required post open swipe`() {
val driver = FakePurchaseDriver(purchaseSwipeSucceeds = false)
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals("RULE_ACTION_FAILED", outcome.errorCode)
assertEquals(1, driver.swipeCount)
}
@Test @Test
fun `color selection then single size heading completes without selecting color again`() { fun `color selection then single size heading completes without selecting color again`() {
val driver = FakePurchaseDriver(prefixlessSingleHeadingAfterColor = true) val driver = FakePurchaseDriver(prefixlessSingleHeadingAfterColor = true)
@@ -778,7 +834,7 @@ class PurchaseRehearsalExecutorTest {
} }
@Test @Test
fun `open spec reveal skips missing scroll target regardless of panel classification`() { fun `probe skips reveal regardless of panel classification`() {
val driver = FakePurchaseDriver( val driver = FakePurchaseDriver(
panelOverride = SpecPanelFixtures.liveShapedSheet(), panelOverride = SpecPanelFixtures.liveShapedSheet(),
purchaseSwipeSucceeds = false, purchaseSwipeSucceeds = false,
@@ -793,11 +849,11 @@ class PurchaseRehearsalExecutorTest {
assertEquals(outcome.message, "spec_probe_completed", outcome.resultType) assertEquals(outcome.message, "spec_probe_completed", outcome.resultType)
assertEquals(1, probes) assertEquals(1, probes)
assertEquals(0, driver.swipeCount) assertEquals(0, driver.swipeCount)
assertTrue(diagnostics.any { it.contains("panel=REQUIRED_EVIDENCE;scrollables=0;reveal=true") }) assertFalse(diagnostics.any { it.startsWith("ruleSwipe;") })
} }
@Test @Test
fun `open spec reveal with a scroll target still fails on gesture failure`() { fun `probe does not invoke failing reveal even with a scroll target`() {
val driver = FakePurchaseDriver(purchaseSwipeSucceeds = false) val driver = FakePurchaseDriver(purchaseSwipeSucceeds = false)
var probes = 0 var probes = 0
val diagnostics = mutableListOf<String>() val diagnostics = mutableListOf<String>()
@@ -806,11 +862,10 @@ class PurchaseRehearsalExecutorTest {
pause = {}, panelDiagnostic = diagnostics::add, pause = {}, panelDiagnostic = diagnostics::add,
).execute(input().copy(phase = "spec_probe"), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported) ).execute(input().copy(phase = "spec_probe"), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals("RULE_ACTION_FAILED", outcome.errorCode) assertEquals("spec_probe_completed", outcome.resultType)
assertEquals(0, probes) assertEquals(1, probes)
assertEquals(1, driver.swipeCount) assertEquals(0, driver.swipeCount)
assertTrue(outcome.message.contains("panel=NORMAL_SCROLLABLE;scrollables=1;reveal=true;reason=failed")) assertFalse(diagnostics.any { it.startsWith("ruleSwipe;") })
assertTrue(diagnostics.any { it.startsWith("ruleSwipe;outcome=failed;") })
assertFalse(outcome.message.contains("黑色")) assertFalse(outcome.message.contains("黑色"))
assertFalse(outcome.message.contains(input().goodsId)) assertFalse(outcome.message.contains(input().goodsId))
} }
@@ -822,7 +877,7 @@ class PurchaseRehearsalExecutorTest {
)) ))
var probes = 0 var probes = 0
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { probes++; "{}" }, pause = {}) val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { probes++; "{}" }, pause = {})
.execute(input().copy(phase = "spec_probe"), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported) .execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals("RULE_ACTION_FAILED", outcome.errorCode) assertEquals("RULE_ACTION_FAILED", outcome.errorCode)
assertEquals(0, probes) assertEquals(0, probes)
@@ -837,9 +892,9 @@ class PurchaseRehearsalExecutorTest {
)) ))
val pauses = mutableListOf<Long>() val pauses = mutableListOf<Long>()
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { "{}" }, pause = pauses::add) val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { "{}" }, pause = pauses::add)
.execute(input().copy(phase = "spec_probe"), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported) .execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals("spec_probe_completed", outcome.resultType) assertEquals("rehearsal_completed", outcome.resultType)
assertEquals(1, driver.swipeCount) assertEquals(1, driver.swipeCount)
assertEquals(1, pauses.count { it == 1000L }) assertEquals(1, pauses.count { it == 1000L })
} }
@@ -849,9 +904,9 @@ class PurchaseRehearsalExecutorTest {
val driver = FakePurchaseDriver() val driver = FakePurchaseDriver()
val pauses = mutableListOf<Long>() val pauses = mutableListOf<Long>()
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { "{}" }, pause = pauses::add) val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { "{}" }, pause = pauses::add)
.execute(input().copy(phase = "spec_probe"), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported) .execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals("spec_probe_completed", outcome.resultType) assertEquals("rehearsal_completed", outcome.resultType)
assertEquals(2, driver.swipeCount) assertEquals(2, driver.swipeCount)
assertEquals(1, pauses.count { it == 1000L }) assertEquals(1, pauses.count { it == 1000L })
} }
@@ -1597,6 +1652,7 @@ class PurchaseRehearsalExecutorTest {
FreshClickReason.TARGET_AMBIGUOUS -> FreshActionResult.AMBIGUOUS FreshClickReason.TARGET_AMBIGUOUS -> FreshActionResult.AMBIGUOUS
FreshClickReason.NO_CLICKABLE_ANCESTOR, FreshClickReason.ACTION_CLICK_FALSE, FreshClickReason.UNKNOWN -> FreshActionResult.FAILED FreshClickReason.NO_CLICKABLE_ANCESTOR, FreshClickReason.ACTION_CLICK_FALSE, FreshClickReason.UNKNOWN -> FreshActionResult.FAILED
FreshClickReason.SUCCESS -> FreshActionResult.SUCCESS FreshClickReason.SUCCESS -> FreshActionResult.SUCCESS
FreshClickReason.SIZE_ADVICE_BLOCKED -> FreshActionResult.BLOCKED
} }
return FreshClickOutcome(result, forcedEntryClickReason) return FreshClickOutcome(result, forcedEntryClickReason)
} }
@@ -1607,6 +1663,7 @@ class PurchaseRehearsalExecutorTest {
FreshClickReason.NO_CLICKABLE_ANCESTOR, FreshClickReason.ACTION_CLICK_FALSE, FreshClickReason.UNKNOWN -> FreshActionResult.FAILED FreshClickReason.NO_CLICKABLE_ANCESTOR, FreshClickReason.ACTION_CLICK_FALSE, FreshClickReason.UNKNOWN -> FreshActionResult.FAILED
FreshClickReason.TARGET_AMBIGUOUS -> FreshActionResult.AMBIGUOUS FreshClickReason.TARGET_AMBIGUOUS -> FreshActionResult.AMBIGUOUS
FreshClickReason.SUCCESS -> FreshActionResult.SUCCESS FreshClickReason.SUCCESS -> FreshActionResult.SUCCESS
FreshClickReason.SIZE_ADVICE_BLOCKED -> FreshActionResult.BLOCKED
} }
return FreshClickOutcome(result, forcedSizeClickReason) return FreshClickOutcome(result, forcedSizeClickReason)
} }
@@ -1616,6 +1673,7 @@ class PurchaseRehearsalExecutorTest {
FreshActionResult.NOT_FOUND -> FreshClickReason.TARGET_NOT_FOUND FreshActionResult.NOT_FOUND -> FreshClickReason.TARGET_NOT_FOUND
FreshActionResult.AMBIGUOUS -> FreshClickReason.TARGET_AMBIGUOUS FreshActionResult.AMBIGUOUS -> FreshClickReason.TARGET_AMBIGUOUS
FreshActionResult.FAILED -> FreshClickReason.ACTION_CLICK_FALSE FreshActionResult.FAILED -> FreshClickReason.ACTION_CLICK_FALSE
FreshActionResult.BLOCKED -> FreshClickReason.SIZE_ADVICE_BLOCKED
} }
return FreshClickOutcome(result, reason) return FreshClickOutcome(result, reason)
} }
@@ -0,0 +1,255 @@
package cn.ilapage.goauto.agent
import cn.ilapage.goauto.agent.automation.*
import org.junit.Assert.*
import org.junit.Test
/** Synthetic cards only: no device tree, address or order data. */
class TruncatedSpecCardTest {
private val card = "r/sheet/body/list/color/o0"
private val other = "r/sheet/body/list/color/o1"
private val full = "黑色示例长裤【有抽绳】 有口袋不起球"
private val short = "黑色示例长裤【有抽绳】 有口袋"
private val size = "2XL建议130-150斤"
private fun sheet(
outer: String = "$full ¥18.8",
caption: String = "$short ¥18.8",
image: String = outer,
selected: Boolean = true,
otherSelected: Boolean = false,
): UiSnapshot = SpecPanelFixtures.taskOptionDedupSheet().let { snapshot ->
snapshot.copy(nodes = snapshot.nodes.map { node ->
when (node.path) {
card, "$card/img" -> node.copy(text = null, contentDescription = if (node.path == card) outer else image, selected = selected)
"$card/l/t" -> node.copy(text = caption, selected = selected)
"r/sheet/body/selected" -> node.copy(text = "请选择: 尺码")
else -> when {
node.path.startsWith("$card/") -> node.copy(selected = selected)
node.path == other || node.path.startsWith("$other/") -> node.copy(selected = otherSelected)
else -> node
}
}
})
}
private fun parse(snapshot: UiSnapshot) = PddScreenParser.parse(
snapshot, PurchaseRehearsalExecutor.DEFAULT_COLLECTOR, "123456789", null,
)
private fun colors(snapshot: UiSnapshot) = parse(snapshot).dimensions.single { it.key == "color" }.values
@Test fun `full image and truncated clickable caption are one selected color`() {
val result = colors(sheet())
assertEquals(listOf(full, "白条纹"), result.map { it.text })
assertTrue(result.first().node.selected)
assertFalse(result.last().node.selected)
assertEquals(5, parse(sheet()).dimensions.single { it.key == "size" }.values.size)
}
@Test fun `checked display state survives grouping`() {
val snapshot = sheet(selected = false).let { it.copy(nodes = it.nodes.map { n ->
if (n.path == "$card/l/t") n.copy(checked = true) else n
}) }
assertTrue(colors(snapshot).single { it.text == full }.node.checked)
assertFalse(colors(snapshot).any { it.text == short })
}
@Test fun `whitespace and equivalent terminal prices do not change full identity`() {
assertEquals(listOf(full, "白条纹"), colors(sheet(caption = "黑色示例长裤【有抽绳】\n有口袋 ¥18.80")).map { it.text })
}
@Test fun `one or both absent prices still require card evidence`() {
for ((outer, caption) in listOf(full to short, "$full ¥18.8" to short, full to "$short ¥18.8")) {
assertEquals(listOf(full, "白条纹"), colors(sheet(outer, caption)).map { it.text })
}
}
@Test fun `different prices refuse truncated merge`() {
assertTrue(colors(sheet(caption = "$short ¥28.8")).any { it.text == short })
}
@Test fun `non prefix caption remains independent`() {
assertTrue(colors(sheet(caption = "灰色示例 ¥18.8")).any { it.text == "灰色示例" })
}
@Test fun `empty price only and missing complete image are not prefix evidence`() {
for (caption in listOf("", "¥18.8")) {
val snapshot = sheet(caption = caption, selected = false).let { it.copy(nodes = it.nodes.map { n ->
if (n.path == "$card/l/t") n.copy(checked = true) else n
}) }
// An empty label may be omitted by the parser; a pure-price value
// must not be promoted into the full card via empty-prefix matching.
if (caption.isNotEmpty()) assertTrue(colors(snapshot).any { it.text.isEmpty() && it.node.checked })
}
assertTrue(colors(sheet(image = "$short ¥18.8")).any { it.text == short })
assertTrue(colors(sheet(outer = "¥18.8")).any { it.text == short })
}
@Test fun `truncated leaf outside card geometry is not merged`() {
val snapshot = sheet().let { it.copy(nodes = it.nodes.map { n ->
if (n.path == "$card/l/t") n.copy(bounds = NodeBounds(710, 1340, 1040, 1447)) else n
}) }
assertTrue(colors(snapshot).any { it.text == short })
}
@Test fun `clickable caption wrapper is not silently assumed to be a display leaf`() {
val snapshot = sheet().let { it.copy(nodes = it.nodes.map { n ->
if (n.path == "$card/l") n.copy(text = "$short ¥18.8", clickable = true) else n
}) }
assertTrue(colors(snapshot).any { it.text == short })
}
@Test fun `adjacent prefix named cards stay distinct even at same price`() {
val snapshot = sheet(outer = "黑色加绒 ¥18.8", caption = "黑色加 ¥18.8").let { it.copy(nodes = it.nodes.map { n ->
if (n.path == other || n.path == "$other/img" || n.path == "$other/l/t")
n.copy(text = "黑色 ¥18.8", contentDescription = null) else n
}) }
assertEquals(listOf("黑色加绒", "黑色"), colors(snapshot).map { it.text })
}
@Test fun `labelled clickable common parent cannot swallow independent cards`() {
val snapshot = sheet().let { it.copy(nodes = it.nodes.map { n ->
if (n.path == "r/sheet/body/list/color") n.copy(text = "$full ¥18.8", clickable = true,
bounds = NodeBounds(0, 1044, 1080, 1447)) else n
}) }
assertTrue(colors(snapshot).any { it.text == "白条纹" })
}
@Test fun `nested same price prefix card with own image and caption remains independent`() {
val base = sheet(outer = "黑色加绒 ¥18.8", caption = "黑色加 ¥18.8")
val parent = base.nodes.single { it.path == card }
val nested = listOf(
parent.copy(path = "$card/nested", parentPath = card, text = "黑色 ¥18.8", contentDescription = null),
parent.copy(path = "$card/nested/img", parentPath = "$card/nested", text = "黑色 ¥18.8", contentDescription = null, className = "android.widget.ImageView"),
parent.copy(path = "$card/nested/text", parentPath = "$card/nested", text = "黑色 ¥18.8", contentDescription = null, className = "android.widget.TextView"),
)
assertTrue(colors(base.copy(nodes = base.nodes + nested)).any { it.text == "黑色" })
}
@Test fun `short labelled common parent does not merge its longer independent cards`() {
val parent = "r/sheet/body/list/color/cards"
val base = sheet(outer = "黑色加绒", caption = "黑色加绒")
val nodes = base.nodes.map { n ->
val isOther = n.path == other || n.path == "$other/img" || n.path == "$other/l/t"
val child = if (isOther) n.copy(text = "黑色", contentDescription = null) else n
child.copy(
path = child.path.replace(card, "$parent/o0").replace(other, "$parent/o1"),
parentPath = child.parentPath?.replace(card, "$parent/o0")?.replace(other, "$parent/o1")
?.let { if ((n.path == card || n.path == other)) parent else it },
)
}
val container = base.nodes.single { it.path == card }.copy(
path = parent, parentPath = parent.substringBeforeLast('/'), text = "黑色", contentDescription = null,
bounds = NodeBounds(36, 1044, 698, 1447),
)
val values = colors(base.copy(nodes = nodes + container))
assertEquals(setOf("黑色加绒", "黑色"), values.map { it.text }.toSet())
assertTrue(values.single { it.text == "黑色加绒" }.node.selected)
assertFalse(values.single { it.text == "黑色" }.node.selected)
}
@Test fun `same named clickable caption wrapper keeps original badge merging`() {
val snapshot = SpecPanelFixtures.taskOptionDedupSheet().let { it.copy(nodes = it.nodes.map { n ->
if (n.path == "$card/l") n.copy(text = "兰条纹", clickable = true) else n
}) }
assertEquals(listOf("兰条纹", "白条纹"), colors(snapshot).map { it.text })
}
@Test fun `numeric and weight size values and badge regression remain intact`() {
val baseline = SpecPanelFixtures.taskOptionDedupSheet()
assertEquals(listOf("兰条纹", "白条纹"), colors(baseline).map { it.text })
val snapshot = sheet().let { it.copy(nodes = it.nodes.map { n ->
if (n.path.endsWith("size/o0/t")) n.copy(text = "42 ¥18.8") else n
}) }
val sizes = parse(snapshot).dimensions.single { it.key == "size" }.values.map { it.text }
assertTrue(sizes.containsAll(listOf("42", size, "3XL建议150-170斤")))
}
@Test fun `collector used by collection and spec probe emits only full color values`() {
var selectedColor = full
val driver = object : PddCollectorDriver {
override fun capture() = sheet(selected = selectedColor == full, otherSelected = selectedColor == "白条纹")
override fun clickFresh(target: SnapshotNode): FreshActionResult {
selectedColor = if (target.path.startsWith(card)) full else "白条纹"
return FreshActionResult.SUCCESS
}
override fun swipeSpec(direction: SwipeDirection, anchor: SnapshotNode?) = false
override fun pullDownGoodsPage() = false
override fun back() = false
}
var clock = 0L
val rule = CollectionRule(
schemaVersion = 2, steps = emptyList(), ruleType = "pddProductDetail",
pageEvidence = PageEvidence(SpecPanelFixtures.PDD, SpecPanelFixtures.ACTIVITY, NodeSelector(text = "颜色分类")),
collector = PurchaseRehearsalExecutor.DEFAULT_COLLECTOR,
)
val result = PddProductDetailCollector(driver, { clock }, { clock += it }).collect("123456789", rule)
assertTrue(result.message, result.successful)
assertEquals(listOf(full, "白条纹"), requireNotNull(result.payload).dimensions.single { it.key == "color" }.values)
}
private fun executor(driver: PurchaseUiDriver) = PurchaseRehearsalExecutor(driver, { true }, { null }, pause = {})
private class Driver(private val snapshot: UiSnapshot) : PurchaseUiDriver {
var opened = false
var sizeSelected = false
val clicks = mutableListOf<String>()
override fun capture() = if (opened) snapshot.copy(nodes = snapshot.nodes.map { n ->
if (n.path.contains("/size/o")) n.copy(selected = sizeSelected && n.path.startsWith("r/sheet/body/list/size/o2")) else n
}) else SpecPanelFixtures.productDetailPage()
override fun clickFresh(target: SnapshotNode): FreshActionResult {
clicks += target.label
if (target.label.startsWith("请选择")) opened = true
if (target.label == "2XL建议130-150斤") sizeSelected = true
return FreshActionResult.SUCCESS
}
override fun tapPurchaseFresh(target: SnapshotNode) = FreshActionResult.FAILED
override fun inputFresh(target: SnapshotNode, value: String) = FreshActionResult.FAILED
override fun swipePurchase(direction: SwipeDirection, durationMs: Long) = false
override fun swipePurchaseIn(target: SnapshotNode, direction: SwipeDirection, durationMs: Long) = false
override fun backPurchase() = false
}
private fun run(snapshot: UiSnapshot, target: String = full, driver: Driver = Driver(snapshot)) = executor(driver).execute(
PurchaseExecutionInput(362, "rehearsal", "purchase", "https://mobile.yangkeduo.com/goods.html?goods_id=123456789",
"123456789", target, size, quantity = 1, minUnitPriceCent = 1000, maxUnitPriceCent = 3000),
PurchaseRuleParser.parse("""{"schemaVersion":1,"ruleType":"pddPurchase","requiredCapabilities":["purchase.rehearsal.v1"],
"actions":[{"type":"openProduct"},{"type":"verifyProduct"},{"type":"openSpecPanel"},{"type":"selectSpec"},
{"type":"setQuantity"},{"type":"verifyUnitPrice"},{"type":"verifyOrderSummary"}]}"""),
PurchaseAgentCapabilities.supported,
)
@Test fun `dedup lets existing confirmation complete color size and rehearsal`() {
val snapshot = sheet()
val driver = Driver(snapshot)
val result = run(snapshot, driver = driver)
assertEquals(result.message, "rehearsal_completed", result.resultType)
assertEquals(1, driver.clicks.count { it == size })
assertFalse(driver.clicks.any { it.contains("订单") || it.contains("支付") })
}
@Test fun `other selected card still fails immediate confirmation`() {
for (selected in listOf(false, true)) {
val result = run(sheet(selected = selected, otherSelected = true))
assertEquals("PURCHASE_SPEC_SELECTION_UNCONFIRMED", result.errorCode)
}
}
@Test fun `old truncated mapped target is not silently upgraded`() {
assertEquals("PURCHASE_SPEC_TARGET_NOT_VISIBLE", run(sheet(), short).errorCode)
}
@Test fun `existing final confirmation target first ordering is documented not changed`() {
val screen = parse(sheet(otherSelected = true))
val executor = executor(Driver(sheet()))
val immediate = PurchaseRehearsalExecutor::class.java.declaredMethods.single { it.name == "isExactSpecSelected" }
immediate.isAccessible = true
assertEquals(false, immediate.invoke(executor, screen, "color", full))
val final = PurchaseRehearsalExecutor::class.java.declaredMethods.single { it.name == "verifyExactSpecSelection" }
final.isAccessible = true
val result = final.invoke(executor, screen, "color", full, null)
val confirmed = result.javaClass.getDeclaredField("confirmed").apply { isAccessible = true }
assertEquals(true, confirmed.get(result)) // Existing baseline; NOT a newly approved multi-selection behavior.
}
}
@@ -0,0 +1,46 @@
package cn.ilapage.goauto.agent.automation
import org.junit.Assert.*
import org.junit.Test
class SizeAdviceClickPolicyTest {
private class Node(val label: String, val clickable: Boolean, val description: String = "") {
var parent: Node? = null
val children = mutableListOf<Node>()
fun add(node: Node): Node { children += node; node.parent = this; return node }
}
private fun blocked(node: Node) = SizeAdviceClickPolicy.blocked(
node, { it.clickable }, { it.parent }, { it.children }, { listOf(it.label, it.description) },
)
@Test fun sameRowAdviceBlocksDirectReceiverAndLeafGesture() {
val row = Node("", true)
val title = row.add(Node("尺码", false))
row.add(Node("查看尺码建议", false))
assertTrue(blocked(row))
assertTrue(blocked(title))
}
@Test fun adviceElsewhereDoesNotBlockNormalOptionOrWeightDescription() {
val panel = Node("", true)
panel.add(Node("查看尺码建议", true))
val option = panel.add(Node("M 建议80-100斤", true))
val leaf = option.add(Node("M 建议80-100斤", false))
assertFalse(blocked(option))
assertFalse(blocked(leaf))
}
@Test fun traditionalAndContentDescriptionAreProtectedButPlainAdviceIsNot() {
assertTrue(blocked(Node("", true, "查看尺碼建議")))
assertTrue(blocked(Node("查 看 尺 寸 建 议", true)))
assertTrue(blocked(Node("智能選碼", true)))
assertFalse(blocked(Node("XL 建議55.5-60.0公斤穿", true)))
assertFalse(blocked(Node("尺码", true)))
}
@Test fun nonClickableAdviceLeafWithoutClickableAncestorCannotBypassGestureGuard() {
val root = Node("", false)
assertTrue(blocked(root.add(Node("查看尺码建议", false))))
assertFalse(blocked(root.add(Node("M 建议80-100斤", false))))
}
}
@@ -0,0 +1,21 @@
package cn.ilapage.goauto.agent.automation
import cn.ilapage.goauto.agent.persistence.AgentDiagnosticReason
import cn.ilapage.goauto.agent.persistence.AgentDiagnosticStage
import org.junit.Assert.*
import org.junit.Test
class SpecClickDiagnosticTest {
@Test fun recordsOnlyStructuralFieldsIncludingBlockedAncestor() {
val node = SnapshotNode("private-path", null, "private-label", "private-description", "private-id",
"private-class", NodeBounds(1,2,3,4), false, false, false, false, true, true)
val event = specClickDiagnostic(12, AgentDiagnosticStage.COLOR_CLICK, node,
FreshClickOutcome(FreshActionResult.BLOCKED, FreshClickReason.SIZE_ADVICE_BLOCKED, 1, 2))
assertEquals(AgentDiagnosticReason.SIZE_ADVICE_CLICK_BLOCKED, event.reason)
assertEquals(1, event.candidateCount)
assertEquals(2, event.clickableAncestorDepth)
assertNull(event.targetClassName)
assertFalse(event.toString().contains("private"))
assertEquals("collection", event.taskType)
}
}
@@ -7,6 +7,41 @@ import org.junit.Assert.assertTrue
import org.junit.Test import org.junit.Test
class AgentDiagnosticStoreMigrationTest { class AgentDiagnosticStoreMigrationTest {
@Test
fun upgradeFromV1OrV2PreservesOldRowsAndSeparatesPurchaseAttempts() {
for (oldVersion in listOf(1, 2)) withDatabase { db ->
db.createStatement().use { it.execute(CREATE_V1_TABLE_SQL) }
if (oldVersion == 2) migrateV1ToV2(db)
db.createStatement().use { it.execute("INSERT INTO agent_diagnostic " +
"(task_id,stage,reason,attempt,elapsed_ms,agent_version,created_at) " +
"VALUES (1,'DETAIL_ENTRY','DETAIL_ENTRY_MATCHED',2,10,'old',1000)") }
AgentDiagnosticSchema.migrationStatements(oldVersion, 3, columnNames(db)).forEach { sql ->
db.createStatement().use { it.execute(sql) }
}
assertTrue(columnNames(db).containsAll(AgentDiagnosticSchema.purchaseDiagnosticColumns.keys))
assertTrue(columnNames(db).containsAll(AgentDiagnosticSchema.colorDiagnosticColumns.keys))
assertTrue(AgentDiagnosticSchema.migrationStatements(oldVersion, 3, columnNames(db)).isEmpty())
db.createStatement().use { statement ->
statement.executeQuery("SELECT task_type,task_attempt_id,attempt FROM agent_diagnostic").use {
assertTrue(it.next()); assertEquals(null, it.getString(1)); assertEquals(null, it.getString(2)); assertEquals(2, it.getInt(3))
}
}
for (attemptId in listOf("11111111-1111-4111-8111-111111111111", "22222222-2222-4222-8222-222222222222")) {
db.prepareStatement("INSERT INTO agent_diagnostic " +
"(task_id,stage,reason,attempt,elapsed_ms,agent_version,created_at,task_type,task_attempt_id,device_id,phase,rule_snapshot_hash) " +
"VALUES (1,'COLOR_CLICK','CLICK_SUCCESS',3,10,'new',2000,'purchase',?,7,'spec_probe',?)").use {
it.setString(1, attemptId); it.setString(2, "a".repeat(64)); assertEquals(1, it.executeUpdate())
}
}
assertEquals(3, rowCount(db))
db.createStatement().use { statement ->
statement.executeQuery("SELECT COUNT(DISTINCT task_attempt_id) FROM agent_diagnostic WHERE task_type='purchase' AND task_id=1").use {
assertTrue(it.next()); assertEquals(2, it.getInt(1))
}
}
}
}
@Test @Test
fun freshDatabaseCreatesV2ColumnsAndAcceptsSingleColorDiagnosticRecord() = withDatabase { db -> fun freshDatabaseCreatesV2ColumnsAndAcceptsSingleColorDiagnosticRecord() = withDatabase { db ->
db.createStatement().use { it.execute(AgentDiagnosticSchema.createTableSql) } db.createStatement().use { it.execute(AgentDiagnosticSchema.createTableSql) }
+24 -2
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件) generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Architecture-and-Code-Map wiki_page: Architecture-and-Code-Map
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Architecture-and-Code-Map.- wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Architecture-and-Code-Map.-
wiki_revision: f73ab171684662d23f298d7e4927168b1a40b01a wiki_revision: b8b969b0c20aca9af6798a5a0b8784aa735600d4
synchronized_at: 2026-10-06T01:39:19Z synchronized_at: 2026-10-07T08:07:14Z
<!-- gitea-wiki-mirror:end --> <!-- gitea-wiki-mirror:end -->
<!-- gitea-wiki-mirror:start --> <!-- gitea-wiki-mirror:start -->
@@ -617,3 +617,25 @@ Web 唯一展示位置为“采集采购 → SYB 同步记录”:列表状态
- `sybimport.Handler.List/Detail` 复用产品模型返回 nullable 字段。列表不额外访问 SYB;不新增旧订单扫描、并发、定时任务或采购拦截。 - `sybimport.Handler.List/Detail` 复用产品模型返回 nullable 字段。列表不额外访问 SYB;不新增旧订单扫描、并发、定时任务或采购拦截。
- Web 只在 SYB 商品列表订单号后增加固定 72px 的“SYB状态”列;退货列改名“匹配退货”、min-width 200→160,保留图片/告警/操作并允许换行。使用现有标签与加载/失败态,其他列及 Android 不变。 - Web 只在 SYB 商品列表订单号后增加固定 72px 的“SYB状态”列;退货列改名“匹配退货”、min-width 200→160,保留图片/告警/操作并允许换行。使用现有标签与加载/失败态,其他列及 Android 不变。
- 迁移重复执行和三态接口/导入已在隔离 SQLite 测试,Server/Web 构建通过;尚未验证正式 MySQL 执行或真实同步后的页面。先授权迁移,再发布才能用于业务环境。 - 迁移重复执行和三态接口/导入已在隔离 SQLite 测试,Server/Web 构建通过;尚未验证正式 MySQL 执行或真实同步后的页面。先授权迁移,再发布才能用于业务环境。
## Android 规格探测与诊断增补(#361)
实现绑定 a49dc69,Agent 0.9.68 / 81,尚未合并 main。
- PurchaseRehearsalExecutor.applyPostAction 只在 spec_probe + OPEN_SPEC_PANEL 跳过 swipeAfter,保留等待和 purchase 行为。
- SizeAdviceClickPolicy 对最终点击接收者的子树检查明确建议入口短语;GoAutoAccessibilityService 的 ACTION_CLICK 和两种中心手势入口使用该策略。FreshActionResult.BLOCKED / FreshClickReason.SIZE_ADVICE_BLOCKED 阻止规格流程继续兜底。
- PddProductDetailCollector 的入口、快速确认恢复和颜色点击通过 clickFreshDetailed 获取结果;SpecClickDiagnostic 构造白名单结构事件。AgentForegroundService 给探测执行器与采集器接入既有 SafeAgentDiagnosticRecorder / 单线程队列,未接入原始 trace。
- AgentDiagnosticSchema v3:agent_diagnostic 追加可空 task_type TEXT、task_attempt_id TEXT、device_id INTEGER、phase TEXT、rule_snapshot_hash TEXT。onUpgrade 支持 V1/V2 追加并检查已有列;保留旧行且新字段为 NULL。AgentDiagnosticStore 写采购记录时验证类型、UUID、正设备 ID、阶段和 64 位十六进制哈希。动作 attempt 与采购 attempt UUID 分离,全库 50 条/7 天保留边界不变。
- 无 Server/Web/业务库或共享接口字段变化,无订单提交流程变化。新错误沿既有 errorCode 字符串回传;真实探测/采购验收仍待用户授权。
## 自动匹配扫描游标与租约守卫(#359)
实现绑定 `9fcbc64117bcee0cbed25c3957a14a25d826f637`,仅开发分支完成,迁移与部署尚未执行。
- `server/app/goauto/shopeeproduct/auto_match_batch.go` 按商品ID键集分页,200/页、2000/轮、10分钟预算,复用原单商品匹配。30分钟运行租约与唯一active_slot不变,运行及工作项变更增加所有权检查。
- `shopee_spec_auto_match_run.resume_after_id` 为可空、非负BIGINT:NULL不提交位置,0回绕,从最近已终结非NULL运行读取;`stop_reason` 为VARCHAR(24)、NOT NULL DEFAULT ''。完成更新在相同所有权守卫下原子提交统计和位置。
- `1791300000000_shopee_spec_auto_match_resume.go` 只追加两列,重复执行幂等;既有运行初始化NULL/空字符串,不改商品或工作项,不改变定时任务启停。旧代码忽略新列,回退代码保留列和既有映射。
- 批处理私有context将所有权检查传递到`ai_suggest.go`的Provider调用以及`auto_match.go`的映射事务;复用当前事务锁定运行,非批处理上下文不引入运行查询。AI决策算法和匹配规则不变。
- 因预算超时不能再使用已取消context写统计,收尾仅使用最多5秒的独立上下文执行受所有权保护的完成更新,不启动新商品领取或AI调用;失租不强制落库。
- MySQL默认返回实际修改行数;续租更新返回0时,只在当前持有行锁的事务内再次核验owner/状态/槽位/实时有效租约,以区分同毫秒值未变化与真实失租;其他完成/工作项更新仍要求恰好一行。
- AI配置读取先返回数据库错误,再判断停用,避免基础设施错误被误记为业务跳过。仅批次私有上下文把基础设施错误作为本轮错误终止;普通单商品Provider重试策略保持不变。
+36 -2
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件) generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Business-Rules-and-Glossary wiki_page: Business-Rules-and-Glossary
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Business-Rules-and-Glossary.- wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Business-Rules-and-Glossary.-
wiki_revision: f5077a19801882ebb4e94c5b0fe93f00fa6ebc7d wiki_revision: 2abd7a07ccf8a28308ce9c318250e2f8f957c137
synchronized_at: 2026-10-06T03:27:21Z synchronized_at: 2026-10-07T08:07:17Z
<!-- gitea-wiki-mirror:end --> <!-- gitea-wiki-mirror:end -->
<!-- gitea-wiki-mirror:start --> <!-- gitea-wiki-mirror:start -->
@@ -339,6 +339,8 @@ Admin 的「创建时间」范围按 SYB 货运单列表的 `t_stock.created`(
## SYB 档口入库码导入、匹配与回写 ## SYB 档口入库码导入、匹配与回写
- 页面分页(#363,实现绑定 `955869b`,2026-10-07 已合并 main `eb7cb6b` 并发布至 122.228.200.167):仅档口入库码页首次进入或刷新默认第 1 页、每页 200 条,保留 20/50/100/200 条选项;搜索和清空筛选保持当前选定条数。其他页面、公共分页组件及后端未传 pageSize 时默认 100 的行为不变;当前页全选仍仅限当前页,匹配、删除和回写的资格与确认规则不变。
- 只接受不超过 10 MB、最多 5000 条非空数据行且包含“标签入库码映射”工作表的 Excel。一次文件只能包含一个营业日期;同日单件入库码必须唯一,同一业务键按营业日期、订单号、档口和规范化规格合并并保留 Excel 顺序。 - 只接受不超过 10 MB、最多 5000 条非空数据行且包含“标签入库码映射”工作表的 Excel。一次文件只能包含一个营业日期;同日单件入库码必须唯一,同一业务键按营业日期、订单号、档口和规范化规格合并并保留 Excel 顺序。
- 导入事务提交后自动创建并启动只读匹配任务,手动批量重匹配入口见 #350 的版本绑定规则。再次导入同日数据只允许整体替换仍处于等待匹配的数据;存在匹配或回写证据时拒绝覆盖。 - 导入事务提交后自动创建并启动只读匹配任务,手动批量重匹配入口见 #350 的版本绑定规则。再次导入同日数据只允许整体替换仍处于等待匹配的数据;存在匹配或回写证据时拒绝覆盖。
- 匹配必须按精确订单号取得唯一货运单,再以规格、原始 SKU 和档口证据确定唯一商品;歧义、冲突或数量不一致均不猜测。匹配阶段不能调用任何 SYB 写接口。 - 匹配必须按精确订单号取得唯一货运单,再以规格、原始 SKU 和档口证据确定唯一商品;歧义、冲突或数量不一致均不猜测。匹配阶段不能调用任何 SYB 写接口。
@@ -801,3 +803,35 @@ Android 0.9.64 / versionCode 77,源码 `6550b9f`(分支实现,尚未安装
- RawSpecHalves 复用 Parse,ResolveKeys 复用两者,原始两侧角色、注释塌缩消歧与采购规格键继续共享同一判断,不另立角色推断规则。 - RawSpecHalves 复用 Parse,ResolveKeys 复用两者,原始两侧角色、注释塌缩消歧与采购规格键继续共享同一判断,不另立角色推断规则。
- 更新规则本身不会改历史数据库。正常重同步沿用现有保护规则;需要定向重新解析时复用 ReparseBatch,force=false 默认跳过人工及 AI 确认行。该流程可能重算同商品其他未确认明细,执行前必须核验完整影响集合。 - 更新规则本身不会改历史数据库。正常重同步沿用现有保护规则;需要定向重新解析时复用 ReparseBatch,force=false 默认跳过人工及 AI 确认行。该流程可能重算同商品其他未确认明细,执行前必须核验完整影响集合。
- ERPGo 规格同步及 Merge 仍只追加并保留映射,不自动清除旧反向档案值。清理须独立授权、指定对象并验证引用/映射,保留恢复依据;不自动扩大为全库数据清理,不修改采购任务快照。 - ERPGo 规格同步及 Merge 仍只追加并保留映射,不自动清除旧反向档案值。清理须独立授权、指定对象并验证引用/映射,保留恢复依据;不自动扩大为全库数据清理,不修改采购任务快照。
## Android 规格探测点击保护(#361)
实现绑定 a49dc69(工单分支,Agent 0.9.68 / 81,未合并 main);自动化验证通过,现场采购修复尚待验收。
- 仅 spec_probe 阶段的 OPEN_SPEC_PANEL 跳过规则 swipeAfter;waitAfterMs 和既有页面稳定等待保留,采集器继续自身的有限规格遍历。purchase 阶段原规则滑动不变。
- PDD 点击按最终接收动作的控件或最近可点击父容器检查尺码建议类明确短语,包含简繁体;无障碍直接点击、祖先点击与中心手势共享判断。不可因面板其他位置存在建议入口而拒绝所有规格;“M 建议80-100斤”等合法选项不按“建议”泛化拦截。命中返回 SIZE_ADVICE_CLICK_BLOCKED,规格入口/规格选择不再手势兜底,颜色采集不改点其他候选。
- 规格探测的结构化诊断复用本地诊断库,以 purchase 类型、task ID、服务端 attempt UUID、设备、phase 和规则快照哈希关联;不保存原始 trace/标签/树/截图。诊断失败不改变任务结果。
- 真机验证不能依赖探测失败作为停止边界:live 任务探测匹配成功后可继续正式下单。必须明确演练覆盖 spec_probe 的方式,或另获正式创建订单授权;永久禁止付款。
## Android 同一卡片截短规格显示去重(#362)
解析修复绑定 9a4495a + 1f5bab8(基于 main 64f0e49);用户随后授权构建安装,工单分支于 35119ea 集成 #361,Agent 0.9.69 / 82 已于 2026-10-06 覆盖安装到指定设备。尚未合并 main,现场采购效果未验证;集成不表示 #361 的诊断疑点已解决或整体验收通过。
- 共享 PddScreenParser.optionBlock 保留原“成员以外层完整标签开头”的分支(含徽标/附加说明)。只新增已知图片卡片形态的保守兼容:外层有自身完整标签,候选显示成员恰为一个完整标签 ImageView 叶节点和一个截短 TextView 叶节点;允许非点击布局包装,不跨另一个带标签且可点击的容器。
- 若带自身标签的候选容器包含另一个有不同规范化名称、可点击且有自身子节点的卡片,该容器不作为规格值,也不能成为这些卡片的合并块。这一结构边界同时保护原前缀分支,避免共同容器“黑色”吞并子卡片“黑色加绒”,以及容器聚合 selected 污染另一张卡片;同名包装和原徽标分支保留。
- 两个显示叶节点必须位于卡片边界内,图片在字幕上方且横向相交(允许图片/字幕边缘重叠)。缺少完整图片、多个字幕或图片、嵌套独立卡片、文字归属不明确时不采用新增合并分支。相邻同价且前缀相似的不同规格不能凭文字合并。
- 比较使用既有 SpecValueNormalizer 尾部价格语法和空白压缩,双方非空,截短名须为完整名的严格前缀;所有实际存在的尾价须数值一致,18.8 与 18.80 等价。缺失价格不构成同卡证据,必须仍满足全部结构条件。不去掉体重、尺码数字或款式文字。
- 合并后仍使用外层完整原文按既有规则去尾价作为规格值,沿用原点击节点排序、selected/checked 聚合;普通采集与采购探测共用这一路径。精确定位、即时确认、选中证明、最终复核及下单行为不改,不允许模糊点击。
- 历史任务若已映射到截短值,不自动升格为完整值,不修改任务快照、映射或历史数据;找不到精确目标仍明确失败。即时确认先查其他选中值,而最终确认先接受唯一目标已选中,是既有实现差异,本修复不调整或掩盖该差异。
- #361 的诊断未写入疑点继续独立核查。解析合成测试可先行,后续用于手机安装的集成版本须包含 #361,并经明确授权安装/真机验证;不以已有单次采购成功代替完整验收,不执行付款。
## 蝦皮规格自动匹配有界续扫(#359)
实现绑定 `9fcbc64117bcee0cbed25c3957a14a25d826f637`,分支 `fix/359-auto-match-resume`;已实现并经本地合成验证,尚未合并、执行业务库迁移或发布,不代表当前线上已启用。
- 定时及管理员批量匹配沿用原匹配算法、阈值、人工/有效确认映射保护和指纹重试规则;仅修复固定首段扫描无法到达后方候选。单商品手动匹配不受批次租约检查影响。
- 使用商品 ID 升序键集分页,每页最多200件,每轮实际检查最多2000件,默认实际领取处理最多20件(batchLimit原校验范围不变)。整轮数据库及AI操作共享10分钟预算,逐商品串行,30分钟租约不变。
- SQL排除明确空规格,Go先检查蝦皮端非空颜色/尺码再读PDD;只有颜色或只有尺码仍合法,只有other/空values不能成为匹配候选。
- 正常完成或预算退出只保存最后已确定处理/跳过的位置;页中提前退出不跳到预取末尾。确实消费完末页才回绕0;下轮/进程重启从最近已终结且有有效游标的运行续扫,NULL不是有效游标,0是有效回绕点。
- 单运行所有权在分页续期、领取、Provider调用及保存映射时检查。失租旧运行不能继续领取或覆盖新owner;基础设施错误或失租不提交新游标。
- completed只表示本轮正常结束,处理0件可能合法;scanned为实际检查数,不是预取数或全表数,processed不是成功数,confirmed/unmatched是规格项数。商品变化后可能需要等扫描回绕,不保证固定小时内全部处理。
+13 -11
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件) generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Troubleshooting wiki_page: Troubleshooting
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Troubleshooting wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Troubleshooting
wiki_revision: 40e77a656d1c21d72454ad722a4f71a0a4491241 wiki_revision: ac3a0b74704f398cec54149267089c87c8375a3e
synchronized_at: 2026-10-05T07:18:16Z synchronized_at: 2026-10-06T09:50:33Z
<!-- gitea-wiki-mirror:end --> <!-- gitea-wiki-mirror:end -->
# 故障排查 # 故障排查
@@ -86,24 +86,26 @@ sqlite3 -readonly agent-diagnostics.db "SELECT task_id,reason,color_row_count,co
读取时记录设备、Agent 版本、任务号和规则快照;工单只回写查询得到的脱敏聚合数值。读取完成后删除本地导出副本。正式 APK 若不允许 `run-as`,停止排查并确认安全的只读诊断出口,不通过放宽应用安全配置或上传完整数据库绕过。 读取时记录设备、Agent 版本、任务号和规则快照;工单只回写查询得到的脱敏聚合数值。读取完成后删除本地导出副本。正式 APK 若不允许 `run-as`,停止排查并确认安全的只读诊断出口,不通过放宽应用安全配置或上传完整数据库绕过。
## Android 采购规格入口本地诊断(#249) ## Android 采购规格入口本地诊断(#249 / #361)
实现绑定 `99faf5a`,Agent `0.9.67`(versionCode 80)。单元测试与 Debug 构建已通过,新增版本真机留存/读取尚待验证;本节不属于上文既有实测结论。 #249 的 JSONL 采购诊断实现位于独立分支(历史绑定 99faf5a),未合并基线 main 64f0e49;不能假定运行该 main 的设备存在 files/purchase_diagnostics。main 已有 goauto_diagnostics.db / agent_diagnostic,本单复用它,不整体合并 #249。
采购执行器启动后,在应用私有 `files/purchase_diagnostics/<taskId>_<attemptId>.jsonl` 保存白名单结构化入口诊断。元数据包含 taskId、attemptId、deviceId、agentVersion、phase、ruleHash(当前任务规则快照 SHA-256)、timestamp 和 elapsedMs。现有任务接口只有 attemptId,没有 attemptNumber;用 attemptId 对照服务端历史,不推算重试序号。 #361 实现绑定 a49dc69,Agent 0.9.68(versionCode 81),当前为工单分支,未合并 main 或发布 Admin。已完成 Debug 构建和单元测试,按用户授权覆盖安装一台设备;未执行真机探测或采购重试,不能视为现场修复验收。
证据仅包含入口来源枚举、候选数、点击结果/原因枚举、等待轮数、面板结构布尔值/计数,以及受控恢复是否执行及固定拒绝原因。没有商品标题、规格原文、地址、手机号、Cookie、Token、链接、原始控件树或截图。entryWaitMillis 是该等待循环累计的计划等待量;elapsedMs 才是自本次采购执行器诊断起点计量的实际耗时,timestamp 是后台写入时间。 规格探测的入口点击/手势、快速确认恢复、颜色点击,以及原有面板/颜色/尺码发现记录接入既有异步诊断队列。来源 stage 为 SPEC_ENTRY_CLICK / SPEC_ENTRY_GESTURE / QUICK_CONFIRMATION_CLICK / COLOR_CLICK;reason 为点击结果或 SIZE_ADVICE_CLICK_BLOCKED 等固定枚举。只存候选数、可点击布尔、白名单类名和可得的祖先层级,不保存原始 trace、标签、地址、手机号、控件树或截图。
复用现有单线程诊断队列异步写入,写入/排队失败不改变采购结果;不新增点击、滑动、等待或采购门禁。最多保留最近 5 个 attempt 文件、每文件最后 128 条事件,超过 7 天的文件在服务启动或下次写入时清理。异常断电或存储故障仍可能丢失诊断,不能将日志缺失当成动作未执行;不上传 Admin,不增加服务端接口。 本地 SQLite schema v3 仅追加可空 task_type、task_attempt_id、device_id、phase、rule_snapshot_hash。采购记录绑定 purchase_task.id、服务端 attempt UUID、设备、阶段与规则哈希;现有 attempt 仍是动作内次数,不能当作采购 attempt ID。新采集记录标识 collection;旧记录新增字段为 NULL,不猜测或回填归属。V1/V2 自动升级保留旧数据;旧版 SQLiteOpenHelper 不保证能降级打开 v3,回退前需单独评估,不卸载清数据。
仅对允许 run-as 的 Debug APK,在设备已连接且选定准确序列号后只读提取: 保留边界沿用全库最近 50 条及 7 天(写入时清理),日志可能因容量、断电或存储异常缺失;缺日志不能证明未点击。写入/排队失败不改变执行结果,不新增上传接口。
读取须确认准确设备、Debug APK、task ID 与 attempt UUID。设备具备 sqlite3 且允许 run-as 时可执行下面的只读查询;如缺 sqlite3/run-as,则停止并另行确认诊断读取路径,不放宽权限、不导出业务库。命令尚未在本单设备验证:
```powershell ```powershell
adb -s <device-serial> shell run-as cn.ilapage.goauto.agent ls files/purchase_diagnostics adb -s <serial> shell run-as cn.ilapage.goauto.agent sqlite3 -readonly databases/goauto_diagnostics.db "SELECT task_id,task_attempt_id,device_id,phase,rule_snapshot_hash,stage,reason,candidate_count,clickable_ancestor_depth,created_at FROM agent_diagnostic WHERE task_type='purchase' AND task_id=<taskId> AND task_attempt_id='<attemptUUID>' ORDER BY id;"
adb -s <device-serial> exec-out run-as cn.ilapage.goauto.agent cat files/purchase_diagnostics/<taskId>_<attemptId>.jsonl
``` ```
先列出文件,再读取本次任务/attempt 的准确文件名;不得扩大为导出所有应用数据或完整数据库。新版安装前的失败不会补生成日志;需要用户授权后重试才能产生新证据。Release 若禁止 run-as 则停止,不更改权限绕过;首版没有导出界面。该功能用于确定拒绝分支,并不代表已经修复对应采购故障。 如果日志出现 SIZE_ADVICE_CLICK_BLOCKED,只能证明保护已触发,不代表正常商品探测成功;正常商品仍必须读到预期颜色尺码。禁止直接重试 live 任务作“仅探测”验证:探测匹配后任务可回 pending 并继续下单。演练按 PDD 商品创建,且可能由档案匹配直接跳过 spec_probe;必须确认实际阶段和覆盖路径,装机与真机测试分别取得授权。
## SYB 商品列表查询等待(#355) ## SYB 商品列表查询等待(#355)
实现绑定 `a24c206`,仅 Web 客户端;2026-10-05 与 #353/#354 合并至 main `1b4f7cd` 并配套发布 Server/Web 至现有 167 服务器。已通过本地合成测试及线上只读页面验证:列表请求实际等待预算为 60000ms,原筛选组合超过 10 秒后正常返回。发布记录和回退目标见 #355 工单。 实现绑定 `a24c206`,仅 Web 客户端;2026-10-05 与 #353/#354 合并至 main `1b4f7cd` 并配套发布 Server/Web 至现有 167 服务器。已通过本地合成测试及线上只读页面验证:列表请求实际等待预算为 60000ms,原筛选组合超过 10 秒后正常返回。发布记录和回退目标见 #355 工单。
+12 -2
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件) generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Android-Agent-API-Contract wiki_page: Android-Agent-API-Contract
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Android-Agent-API-Contract.- wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Android-Agent-API-Contract.-
wiki_revision: 1ad78ac33cf5876c6b7db6a89107854978066373 wiki_revision: cf3788145daf3dfe9a4526fd231bc4b8d730f763
synchronized_at: 2026-10-06T01:39:51Z synchronized_at: 2026-10-07T08:07:41Z
<!-- gitea-wiki-mirror:end --> <!-- gitea-wiki-mirror:end -->
<!-- gitea-wiki-mirror:start --> <!-- gitea-wiki-mirror:start -->
@@ -1472,3 +1472,13 @@ Web 输入去重后一个值提交旧标量、多个值提交重复集合键,
| sybStatusSyncedAt | RFC3339 时间字符串或 null | 最近一次正常同步取得有效取消值的 UTC 时间,不代表上游取消发生时间 | | sybStatusSyncedAt | RFC3339 时间字符串或 null | 最近一次正常同步取得有效取消值的 UTC 时间,不代表上游取消发生时间 |
只使用上游 isCancel;不兼容性猜测字符串/布尔/其他数值,缺失或无效值保留原字段。成功同步的 false 可覆盖 true。无有效值的新记录两个字段均为 null;旧客户端可忽略新增字段,新 Web 对旧响应缺字段显示未获取。列表与详情不触发额外上游请求。取消状态仅展示,不改变采购准备阶段、创建/重试资格、任务执行或现有订单事实。 只使用上游 isCancel;不兼容性猜测字符串/布尔/其他数值,缺失或无效值保留原字段。成功同步的 false 可覆盖 true。无有效值的新记录两个字段均为 null;旧客户端可忽略新增字段,新 Web 对旧响应缺字段显示未获取。列表与详情不触发额外上游请求。取消状态仅展示,不改变采购准备阶段、创建/重试资格、任务执行或现有订单事实。
## Admin 蝦皮规格自动匹配运行摘要追加字段(#359)
实现绑定 `9fcbc64117bcee0cbed25c3957a14a25d826f637`,分支实现尚未部署。本节仅扩展既有Admin批次接口,不修改Android Agent接口、Web页面或权限。
- `POST /api/admin/v1/shopee-spec-auto-match/runs` 与 `GET /api/admin/v1/shopee-spec-auto-match/runs/latest` 的既有运行对象增加`resumeAfterId`、`stopReason`,请求参数、原字段和状态保持兼容。
- `resumeAfterId`:可空非负整数;null表示该运行没有提交有效续扫点,0表示下一轮从头扫描,正数表示最后已完成检查的位置,不是预取页末商品。运行中/旧记录可能为null。
- `stopReason`:旧记录默认空字符串;完成原因是`batch_limit`、`scan_budget`、`time_budget`、`end_of_scan`、`lease_lost`或`error`。失租旧进程不能为填此字段越权更新;合法回收路径标记lease_lost。
- `scannedCount`改为实际检查的候选数;SQL已过滤的空档案及预取未检查项不计入。eligibleCount为Go资格通过数,processedCount为实际领取处理数;confirmedCount/unmatchedCount仍为规格项数,不能据此直接混算商品成功率。
- 单轮处理默认20、每页200、实际扫描上限2000、整轮预算10分钟。正常0处理仍可completed,预算退出有持久游标;错误/失租不提交新游标。
+21 -2
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件) generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Deployment-and-Operations wiki_page: Deployment-and-Operations
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Deployment-and-Operations.- wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Deployment-and-Operations.-
wiki_revision: 8be38ac192b9e1930fdc3d998c23027fda17e8f3 wiki_revision: cf7b9d1e8ca8f97933936e4ddfd9edc3fac43c98
synchronized_at: 2026-10-06T03:27:28Z synchronized_at: 2026-10-07T08:07:26Z
<!-- gitea-wiki-mirror:end --> <!-- gitea-wiki-mirror:end -->
<!-- gitea-wiki-mirror:start --> <!-- gitea-wiki-mirror:start -->
@@ -276,3 +276,22 @@ Provider 故障日志只允许记录调用关联 ID、操作类型、耗时、
- 服务端 SHA256:`a4cf6c7028a2bbbfd86a1d657ad9c3daad7b4d08445405d8ca9a778adc584760`;远端校验一致。 - 服务端 SHA256:`a4cf6c7028a2bbbfd86a1d657ad9c3daad7b4d08445405d8ca9a778adc584760`;远端校验一致。
- 发布验收:公网 `/`、`/index.html`、`/syb-products/index` 与发布 dist/index.html 字节相同,10 项入口 JS/CSS 和 `/api/v1/health` 均正常;日志无 panic/fatal/缺表缺字段错误。合并后的 #358 合成解析/导入/重解析及 task/purchase 规格键定向回归通过。未将健康验证视为历史数据已纠正。 - 发布验收:公网 `/`、`/index.html`、`/syb-products/index` 与发布 dist/index.html 字节相同,10 项入口 JS/CSS 和 `/api/v1/health` 均正常;日志无 panic/fatal/缺表缺字段错误。合并后的 #358 合成解析/导入/重解析及 task/purchase 规格键定向回归通过。未将健康验证视为历史数据已纠正。
- 回滚目标 `/home/goauto/releases/20261006-0a79c83-356` 保留,需按执行中任务检查后切回并重启;不通过恢复数据库覆盖后续业务数据。 - 回滚目标 `/home/goauto/releases/20261006-0a79c83-356` 保留,需按执行中任务检查后切回并重启;不通过恢复数据库覆盖后续业务数据。
## #363 档口入库码默认 200 条的 Web 发布(2026-10-07)
- 用户授权合并 main 和线上发布;main `eb7cb6b5eb9a57041429c3dfb1d88dc8236d32f3` 已推送,发布目录 `/home/goauto/releases/20261007-eb7cb6b-363`。仅本页初始化 pageSize 改为 200,选项仍为20/50/100/200;后端缺省100不变,没有迁移、权限或业务数据修改。
- 对比上一线上源码 `e76de6f`,Server 无差异,复用原二进制并逐字节验证;Web 从合并提交重建。沿用原 config、环境、static/temp/var 的真实目录;新 dist 保留上版不同哈希 JS/CSS,避免已打开页面的延迟加载失效。
- 发布检查发现有采购任务执行中,未停止或重置任务。核验 Nginx root 及运行进程 GOAUTO_WEB_DIST 均指向 `/home/goauto/current/dist`,代码在请求时读取文件且没有提前解析软链接。因此本次只原子切换 current,不重启 GoAuto/Nginx;进程 PID 43738 前后一致且 active。此方式仅适用于后端二进制、配置与持久资源路径均不变的纯 Web 发布,不能推广为后端发布免重启。
- 公网首页、index.html、login、档口入库码路由均与新 dist/index.html 字节一致;10 项入口 JS/CSS 与健康接口正常,日志检查无 panic/fatal/1146/1054。真实 Chrome 已认证只读检查:首次默认 page=1/pageSize=200、当天空态与200条/页正常;清除日期后200行渲染通过,查询至渲染约987ms。未发起匹配、删除或回写,未保存原始生产数据或截图。
- Web包 SHA256 `a6b4429280fae37e46e81bd21a594941cfee3b5e8c48c105a09254314af32311`;Server SHA256 `a4cf6c7028a2bbbfd86a1d657ad9c3daad7b4d08445405d8ca9a778adc584760`(未变)。
- 回滚目录 `/home/goauto/releases/20261006-e76de6f-358` 保留。对本次纯 Web 变更可将 current 原子切回,不需要恢复数据库、删除文件或中断任务;若此后已升级后端,不能复用这一免重启结论。
## #359 续扫版本迁移与验证边界
源码 `9fcbc64117bcee0cbed25c3957a14a25d826f637`(`fix/359-auto-match-resume`)已实现;尚未执行本地/线上业务库迁移、合并、发布或真实匹配。以下为获得相应授权后的步骤,不是已上线结论。
1. 复核无冲突运行及现有迁移版本,按既有受限备份流程备份。追加迁移`1791300000000_shopee_spec_auto_match_resume.go`仅新增运行游标与停止原因,必须先迁移再运行新版本;不修改定时任务配置或历史商品。
2. 发布后观察运行的stopReason/resumeAfterId及真实计数,确认多轮向后推进、末尾回绕,而非反复固定首段。合法无候选仍允许processedCount=0,不能要求每轮强制匹配成功。
3. 批次结构化日志按run_id记录停止原因、实际扫描/领取/规格项计数及跳过类别,不记录商品规格原文或Provider响应。lost lease旧进程不能覆盖新运行,合法过期回收标记failed/lease_lost且不提交游标。
4. 回退旧二进制时保留追加列和已保存映射,不通过数据库回滚覆盖后续业务。恢复处理会带来原本预期的AI调用和映射写入,仍受默认20件、串行与总时间预算限制。
本地测试使用SQLite内存库和模拟Provider;MySQL8.4.3只执行合成JSON粗过滤SELECT验证,未执行MySQL迁移/锁竞争集成验证,也未调用线上AI。MySQL正式迁移及真实批次验证仍须授权。
+4 -1
View File
@@ -249,12 +249,15 @@ func (s *Service) ResolveSYBSpec(ctx context.Context, request SYBSpecParseReques
func (s *Service) activeSetting(ctx context.Context) (models.AIMatchingSetting, string, error) { func (s *Service) activeSetting(ctx context.Context) (models.AIMatchingSetting, string, error) {
setting, err := s.setting(ctx) setting, err := s.setting(ctx)
if errors.Is(err, gorm.ErrRecordNotFound) || !setting.Enabled { if errors.Is(err, gorm.ErrRecordNotFound) {
return models.AIMatchingSetting{}, "", fail(CodeNotConfigured, "AI 规格匹配未启用") return models.AIMatchingSetting{}, "", fail(CodeNotConfigured, "AI 规格匹配未启用")
} }
if err != nil { if err != nil {
return models.AIMatchingSetting{}, "", err return models.AIMatchingSetting{}, "", err
} }
if !setting.Enabled {
return models.AIMatchingSetting{}, "", fail(CodeNotConfigured, "AI 规格匹配未启用")
}
if strings.TrimSpace(setting.APIKey) == "" { if strings.TrimSpace(setting.APIKey) == "" {
return models.AIMatchingSetting{}, "", fail(CodeNotConfigured, "AI 规格匹配未配置 API Key") return models.AIMatchingSetting{}, "", fail(CodeNotConfigured, "AI 规格匹配未配置 API Key")
} }
@@ -26,6 +26,9 @@ type ShopeeSpecAutoMatchRun struct {
FinishedAt *time.Time `json:"finishedAt,omitempty"` FinishedAt *time.Time `json:"finishedAt,omitempty"`
CreatedAt time.Time `json:"createdAt"` CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"` UpdatedAt time.Time `json:"updatedAt"`
// NULL means no committed checkpoint; zero explicitly restarts at the head.
ResumeAfterID *uint64 `json:"resumeAfterId" gorm:"type:bigint;check:ck_shopee_spec_auto_match_resume,resume_after_id IS NULL OR resume_after_id >= 0"`
StopReason string `json:"stopReason" gorm:"size:24;not null;default:''"`
} }
func (ShopeeSpecAutoMatchRun) TableName() string { return "shopee_spec_auto_match_run" } func (ShopeeSpecAutoMatchRun) TableName() string { return "shopee_spec_auto_match_run" }
+29 -3
View File
@@ -194,10 +194,33 @@ func (service *Service) suggestMappings(ctx context.Context, id uint64, requestC
Dimension: role, ShopeeTitle: shopee.Title, PDDTitle: pdd.Title, Dimension: role, ShopeeTitle: shopee.Title, PDDTitle: pdd.Title,
Sources: sources, Candidates: candidates, Sources: sources, Candidates: candidates,
} }
result, err := aiService.SuggestBatch(ctx, suggestReq) // Batch ownership can change between role calls or provider retries.
var guardErr error
suggest := func(request aimatching.SuggestRequest) (aimatching.SuggestResult, error) {
if err := checkAutoMatchRunContext(ctx, service.DB); err != nil {
guardErr = err
return aimatching.SuggestResult{}, err
}
result, err := aiService.SuggestBatch(ctx, request)
if _, batch := ctx.Value(autoMatchRunContextKey{}).(autoMatchRunGuard); batch && err != nil {
var providerErr *aimatching.Error
if !errors.As(err, &providerErr) {
guardErr = internalError(err)
return aimatching.SuggestResult{}, guardErr
}
}
return result, err
}
result, err := suggest(suggestReq)
if guardErr != nil {
return AISuggestResponse{}, guardErr
}
suggestCalls := 1 suggestCalls := 1
if err != nil { if err != nil {
result, err = aiService.SuggestBatch(ctx, suggestReq) result, err = suggest(suggestReq)
if guardErr != nil {
return AISuggestResponse{}, guardErr
}
suggestCalls++ suggestCalls++
if err != nil { if err != nil {
return AISuggestResponse{}, aiUnavailable(aiSuggestErrorMessage(err)) return AISuggestResponse{}, aiUnavailable(aiSuggestErrorMessage(err))
@@ -216,10 +239,13 @@ func (service *Service) suggestMappings(ctx context.Context, id uint64, requestC
} }
} }
if len(retrySources) > 0 && suggestCalls < 2 { if len(retrySources) > 0 && suggestCalls < 2 {
retryResult, retryErr := aiService.SuggestBatch(ctx, aimatching.SuggestRequest{ retryResult, retryErr := suggest(aimatching.SuggestRequest{
Dimension: role, ShopeeTitle: shopee.Title, PDDTitle: pdd.Title, Dimension: role, ShopeeTitle: shopee.Title, PDDTitle: pdd.Title,
Sources: retrySources, Candidates: candidates, Sources: retrySources, Candidates: candidates,
}) })
if guardErr != nil {
return AISuggestResponse{}, guardErr
}
if retryErr == nil { if retryErr == nil {
for _, source := range retrySources { for _, source := range retrySources {
if decision, ok := retryResult.Decisions[source.ID]; ok { if decision, ok := retryResult.Decisions[source.ID]; ok {
@@ -148,6 +148,9 @@ func (service *Service) autoMatchMappings(ctx context.Context, id uint64, reques
replayed := false replayed := false
err = db.Transaction(func(tx *gorm.DB) error { err = db.Transaction(func(tx *gorm.DB) error {
if err := checkAutoMatchRunContext(ctx, tx.Clauses(clause.Locking{Strength: "UPDATE"})); err != nil {
return err
}
var current models.ShopeeProduct var current models.ShopeeProduct
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&current, id).Error; err != nil { if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&current, id).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
@@ -13,8 +13,10 @@ import (
"go-admin/app/goauto/aimatching" "go-admin/app/goauto/aimatching"
"go-admin/app/goauto/models" "go-admin/app/goauto/models"
log "github.com/go-admin-team/go-admin-core/logger"
"github.com/google/uuid" "github.com/google/uuid"
"gorm.io/gorm" "gorm.io/gorm"
"gorm.io/gorm/clause"
) )
const ( const (
@@ -23,6 +25,10 @@ const (
autoMatchLeaseDuration = 30 * time.Minute autoMatchLeaseDuration = 30 * time.Minute
autoMatchRetryDelay = time.Hour autoMatchRetryDelay = time.Hour
maxAutoMatchAttempts = 3 maxAutoMatchAttempts = 3
autoMatchPageSize = 200
autoMatchScanBudget = 2000
autoMatchTimeBudget = 10 * time.Minute
autoMatchNonEmptySpecsSQL = "TRIM(CAST(shopee_product.specs_json AS CHAR)) <> ? AND TRIM(CAST(shopee_product.specs_json AS CHAR)) <> ? AND TRIM(CAST(shopee_product.specs_json AS CHAR)) <> ? AND TRIM(CAST(shopee_product.specs_json AS CHAR)) <> ?"
) )
type AutoMatchRunView struct { type AutoMatchRunView struct {
@@ -41,11 +47,11 @@ func (service *Service) StartAutoMatchRun(ctx context.Context, trigger, requestI
if trigger != "manual" && trigger != "scheduled" { if trigger != "manual" && trigger != "scheduled" {
return AutoMatchRunView{}, false, invalidRequest("trigger 无效") return AutoMatchRunView{}, false, invalidRequest("trigger 无效")
} }
if batchLimit <= 0 { if batchLimit == 0 {
batchLimit = defaultAutoMatchBatchLimit batchLimit = defaultAutoMatchBatchLimit
} }
if batchLimit > 100 { if batchLimit < 1 || batchLimit > 100 {
return AutoMatchRunView{}, false, invalidRequest("batchLimit 不能超过 100") return AutoMatchRunView{}, false, invalidRequest("batchLimit 必须在 1 到 100 之间")
} }
now := time.Now().UTC() now := time.Now().UTC()
lease := now.Add(autoMatchLeaseDuration) lease := now.Add(autoMatchLeaseDuration)
@@ -55,8 +61,8 @@ func (service *Service) StartAutoMatchRun(ctx context.Context, trigger, requestI
created := false created := false
err := service.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error { err := service.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Model(&models.ShopeeSpecAutoMatchRun{}). if err := tx.Model(&models.ShopeeSpecAutoMatchRun{}).
Where("status = ? AND active_slot = ? AND lease_expires_at < ?", "running", 1, now). 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 { Updates(map[string]any{"status": "failed", "active_slot": nil, "lease_owner": "", "lease_expires_at": nil, "error_summary": "上次运行租约过期,已安全释放", "finished_at": now, "resume_after_id": nil, "stop_reason": "lease_lost"}).Error; err != nil {
return err return err
} }
if err := tx.Where("request_id = ?", requestID).First(&result).Error; err == nil { if err := tx.Where("request_id = ?", requestID).First(&result).Error; err == nil {
@@ -109,6 +115,8 @@ func (service *Service) LatestAutoMatchRun(ctx context.Context) (*AutoMatchRunVi
// HTTP-launched goroutine or the scheduler because only the run owning the // HTTP-launched goroutine or the scheduler because only the run owning the
// active slot may update and finish itself. // active slot may update and finish itself.
func (service *Service) ProcessAutoMatchRun(ctx context.Context, runID uint64) error { func (service *Service) ProcessAutoMatchRun(ctx context.Context, runID uint64) error {
ctx, cancel := context.WithTimeout(ctx, autoMatchTimeBudget)
defer cancel()
var run models.ShopeeSpecAutoMatchRun var run models.ShopeeSpecAutoMatchRun
if err := service.DB.WithContext(ctx).First(&run, runID).Error; err != nil { if err := service.DB.WithContext(ctx).First(&run, runID).Error; err != nil {
return err return err
@@ -120,98 +128,149 @@ func (service *Service) ProcessAutoMatchRun(ctx context.Context, runID uint64) e
if limit <= 0 || limit > 100 { if limit <= 0 || limit > 100 {
limit = defaultAutoMatchBatchLimit limit = defaultAutoMatchBatchLimit
} }
var candidates []models.ShopeeProduct ctx, cancelLease := context.WithCancelCause(ctx)
queryLimit := limit * 25 defer cancelLease(nil)
if queryLimit < 100 { ctx = context.WithValue(ctx, autoMatchRunContextKey{}, autoMatchRunGuard{run: run, cancel: func() { cancelLease(errAutoMatchLeaseLost) }})
queryLimit = 100 stats := autoMatchBatchStats{}
cursor := uint64(0)
var previous models.ShopeeSpecAutoMatchRun
err := service.DB.WithContext(ctx).Where("status <> ? AND resume_after_id IS NOT NULL", "running").Order("id DESC").First(&previous).Error
if err == nil {
cursor = *previous.ResumeAfterID
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
return service.endAutoMatchBatch(ctx, run, stats, cursor, "error", err)
} }
if queryLimit > 1000 { stats.checkpointLoaded = true
queryLimit = 1000 for {
} if err := ctx.Err(); err != nil {
if err := service.DB.WithContext(ctx). return service.endAutoMatchBatch(ctx, run, stats, cursor, "time_budget", err)
Joins("JOIN pdd_product ON pdd_product.id = shopee_product.pdd_product_id AND pdd_product.status = ?", "active").
Where("shopee_product.pdd_product_id IS NOT NULL").
Order("shopee_product.updated_at ASC, shopee_product.id ASC").Limit(queryLimit).Find(&candidates).Error; err != nil {
service.finishAutoMatchRun(run, "failed", 0, 0, 0, 0, 0, 1, "扫描符合条件的商品失败")
return err
}
eligible, processed, confirmed, unmatched, failed := 0, 0, 0, 0, 0
firstError := ""
for _, product := range candidates {
if processed >= limit {
break
} }
fingerprint, ok, err := service.autoMatchEligibility(ctx, product) if stats.processed >= limit {
if err != nil { return service.endAutoMatchBatch(ctx, run, stats, cursor, "batch_limit", nil)
failed++ }
if firstError == "" { if stats.scanned >= autoMatchScanBudget {
firstError = safeBatchError(err) return service.endAutoMatchBatch(ctx, run, stats, cursor, "scan_budget", nil)
}
if err := service.renewAutoMatchRun(ctx, run); err != nil {
return service.endAutoMatchBatch(ctx, run, stats, cursor, "error", err)
}
pageLimit := min(autoMatchPageSize, autoMatchScanBudget-stats.scanned)
var candidates []models.ShopeeProduct
if err := service.DB.WithContext(ctx).
Joins("JOIN pdd_product ON pdd_product.id = shopee_product.pdd_product_id AND pdd_product.status = ?", "active").
Where("shopee_product.pdd_product_id IS NOT NULL AND shopee_product.id > ?", cursor).
// Cast the JSON column to text before comparing: no JSON NOT IN/coercion.
Where(autoMatchNonEmptySpecsSQL, "", "[]", "null", `""`).
Order("shopee_product.id ASC").Limit(pageLimit).Find(&candidates).Error; err != nil {
return service.endAutoMatchBatch(ctx, run, stats, cursor, "error", err)
}
for _, product := range candidates {
if err := ctx.Err(); err != nil {
return service.endAutoMatchBatch(ctx, run, stats, cursor, "time_budget", err)
} }
continue if stats.processed >= limit {
} return service.endAutoMatchBatch(ctx, run, stats, cursor, "batch_limit", nil)
if !ok {
continue
}
eligible++
work, claimed, err := service.claimAutoMatchWork(ctx, run, product.ID, fingerprint)
if err != nil {
failed++
if firstError == "" {
firstError = safeBatchError(err)
} }
continue stats.scanned++
} fingerprint, skip, err := service.autoMatchEligibilityReason(ctx, product)
if !claimed { if err != nil {
continue return service.endAutoMatchBatch(ctx, run, stats, cursor, "error", err)
}
processed++
service.renewAutoMatchRun(run)
response, matchErr := service.autoMatchMappings(ctx, product.ID, AutoMatchRequest{RequestID: uuid.NewString(), SpecContextVersion: fingerprint[:64]}, aimatching.MaxProviderTimeout)
// fingerprint begins with the 64-character context version.
postFingerprint := fingerprint
if next, _, nextErr := service.autoMatchEligibility(ctx, product); nextErr == nil && next != "" {
postFingerprint = next
}
if matchErr != nil {
failed++
if firstError == "" {
firstError = safeBatchError(matchErr)
} }
service.completeAutoMatchWork(work, postFingerprint, 0, 0, matchErr) if skip != "" {
continue stats.skip(skip)
cursor = product.ID
continue
}
stats.eligible++
work, claimed, err := service.claimAutoMatchWork(ctx, run, product.ID, fingerprint)
if err != nil {
return service.endAutoMatchBatch(ctx, run, stats, cursor, "error", err)
}
if !claimed {
stats.skip(autoMatchWorkSkip(work, fingerprint, time.Now().UTC()))
cursor = product.ID
continue
}
stats.processed++
if err := checkAutoMatchRunContext(ctx, service.DB); err != nil {
return service.endAutoMatchBatch(ctx, run, stats, cursor, "error", err)
}
response, matchErr := service.autoMatchMappings(ctx, product.ID, AutoMatchRequest{RequestID: uuid.NewString(), SpecContextVersion: fingerprint[:64]}, aimatching.MaxProviderTimeout)
if err := checkAutoMatchRunContext(ctx, service.DB); err != nil {
return service.endAutoMatchBatch(ctx, run, stats, cursor, "error", err)
}
if matchErr != nil && batchErrorCode(matchErr) == CodeInternal {
return service.endAutoMatchBatch(ctx, run, stats, cursor, "error", matchErr)
}
postFingerprint := fingerprint
if next, _, err := service.autoMatchEligibility(ctx, product); err != nil {
return service.endAutoMatchBatch(ctx, run, stats, cursor, "error", err)
} else if next != "" {
postFingerprint = next
}
if err := service.completeAutoMatchWork(ctx, work, postFingerprint, response.ConfirmedCount, response.UnmatchedCount, matchErr); err != nil {
return service.endAutoMatchBatch(ctx, run, stats, cursor, "error", err)
}
if matchErr != nil {
stats.failed++
if stats.summary == "" {
stats.summary = safeBatchError(matchErr)
}
} else {
stats.confirmed += response.ConfirmedCount
stats.unmatched += response.UnmatchedCount
}
cursor = product.ID
}
// Only a fully consumed short page proves the actual end of the scan.
if len(candidates) < pageLimit {
return service.endAutoMatchBatch(ctx, run, stats, 0, "end_of_scan", nil)
} }
confirmed += response.ConfirmedCount
unmatched += response.UnmatchedCount
service.completeAutoMatchWork(work, postFingerprint, response.ConfirmedCount, response.UnmatchedCount, nil)
} }
status := "completed"
if failed > 0 {
status = "completed_partial"
}
return service.finishAutoMatchRun(run, status, len(candidates), eligible, processed, confirmed, unmatched, failed, firstError)
} }
func (service *Service) autoMatchEligibility(ctx context.Context, product models.ShopeeProduct) (string, bool, error) { func (service *Service) autoMatchEligibility(ctx context.Context, product models.ShopeeProduct) (string, bool, error) {
fingerprint, skip, err := service.autoMatchEligibilityReason(ctx, product)
return fingerprint, skip == "" && err == nil, err
}
func (service *Service) autoMatchEligibilityReason(ctx context.Context, product models.ShopeeProduct) (string, string, error) {
if product.PDDProductID == nil { if product.PDDProductID == nil {
return "", false, nil return "", "no_specs", nil
} }
var pdd models.PDDProduct if strings.TrimSpace(product.SpecsJSON) == `""` {
if err := service.DB.WithContext(ctx).First(&pdd, *product.PDDProductID).Error; err != nil { return "", "no_specs", nil
return "", false, err
}
if pdd.Status != "active" {
return "", false, nil
} }
shopeeSpecs, err := Unmarshal(product.SpecsJSON) shopeeSpecs, err := Unmarshal(product.SpecsJSON)
if err != nil { if err != nil {
return "", false, err return "", "", err
}
usable := false
for _, dimension := range shopeeSpecs {
if dimension.Role != RoleColor && dimension.Role != RoleSize {
continue
}
for _, value := range dimension.Values {
if strings.TrimSpace(value.Name) != "" {
usable = true
}
}
}
if !usable {
return "", "no_specs", nil
}
var pdd models.PDDProduct
if err := service.DB.WithContext(ctx).First(&pdd, *product.PDDProductID).Error; err != nil {
return "", "", err
}
if pdd.Status != "active" {
return "", "no_specs", nil
} }
shared, needsMatch := false, false shared, needsMatch := false, false
for _, role := range []string{RoleColor, RoleSize} { for _, role := range []string{RoleColor, RoleSize} {
pddValues, err := selectablePDDValues(pdd.SpecsJSON, role) pddValues, err := selectablePDDValues(pdd.SpecsJSON, role)
if err != nil { if err != nil {
return "", false, err return "", "", err
} }
if len(pddValues) == 0 { if len(pddValues) == 0 {
continue continue
@@ -220,24 +279,32 @@ func (service *Service) autoMatchEligibility(ctx context.Context, product models
if dimension.Role != role || len(dimension.Values) == 0 { if dimension.Role != role || len(dimension.Values) == 0 {
continue continue
} }
shared = true
for _, value := range dimension.Values { for _, value := range dimension.Values {
if strings.TrimSpace(value.Name) == "" {
continue
}
shared = true
if value.Mapping == nil || value.Mapping.Status != MappingStatusConfirmed || !pddValues[value.Mapping.PDDValue] { if value.Mapping == nil || value.Mapping.Status != MappingStatusConfirmed || !pddValues[value.Mapping.PDDValue] {
needsMatch = true needsMatch = true
} }
} }
} }
} }
if !shared || !needsMatch { if !shared {
return "", false, nil return "", "no_specs", nil
}
if !needsMatch {
return "", "confirmed", nil
} }
contextVersion := computeSpecContextVersion(product.PDDProductID, product.SpecsJSON, pdd.SpecsJSON) contextVersion := computeSpecContextVersion(product.PDDProductID, product.SpecsJSON, pdd.SpecsJSON)
var setting struct{ UpdatedAt time.Time } var setting struct{ UpdatedAt time.Time }
_ = service.DB.WithContext(ctx).Table((models.AIMatchingSetting{}).TableName()).Select("updated_at").Where("id = ?", 1).Scan(&setting).Error if err := service.DB.WithContext(ctx).Table((models.AIMatchingSetting{}).TableName()).Select("updated_at").Where("id = ?", 1).Scan(&setting).Error; err != nil {
return "", "", err
}
h := sha256.Sum256([]byte(contextVersion + "\x00" + setting.UpdatedAt.UTC().Format(time.RFC3339Nano))) h := sha256.Sum256([]byte(contextVersion + "\x00" + setting.UpdatedAt.UTC().Format(time.RFC3339Nano)))
// Keeping the context version as a prefix lets ProcessAutoMatchRun pass the // Keeping the context version as a prefix lets ProcessAutoMatchRun pass the
// exact version to #194 without re-reading a potentially drifting input. // exact version to #194 without re-reading a potentially drifting input.
return contextVersion + hex.EncodeToString(h[:]), true, nil return contextVersion + hex.EncodeToString(h[:]), "", nil
} }
func (service *Service) claimAutoMatchWork(ctx context.Context, run models.ShopeeSpecAutoMatchRun, productID uint64, fingerprint string) (models.ShopeeSpecAutoMatchWorkItem, bool, error) { func (service *Service) claimAutoMatchWork(ctx context.Context, run models.ShopeeSpecAutoMatchRun, productID uint64, fingerprint string) (models.ShopeeSpecAutoMatchWorkItem, bool, error) {
@@ -245,6 +312,9 @@ func (service *Service) claimAutoMatchWork(ctx context.Context, run models.Shope
lease := now.Add(autoMatchLeaseDuration) lease := now.Add(autoMatchLeaseDuration)
var work models.ShopeeSpecAutoMatchWorkItem var work models.ShopeeSpecAutoMatchWorkItem
err := service.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error { err := service.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := lockAutoMatchRun(ctx, tx, run); err != nil {
return err
}
err := tx.Where("shopee_product_id = ?", productID).First(&work).Error err := tx.Where("shopee_product_id = ?", productID).First(&work).Error
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
work = models.ShopeeSpecAutoMatchWorkItem{ShopeeProductID: productID, RunID: &run.ID, InputFingerprint: fingerprint, Status: "running", AttemptCount: 1, LeaseOwner: run.LeaseOwner, LeaseExpiresAt: &lease} work = models.ShopeeSpecAutoMatchWorkItem{ShopeeProductID: productID, RunID: &run.ID, InputFingerprint: fingerprint, Status: "running", AttemptCount: 1, LeaseOwner: run.LeaseOwner, LeaseExpiresAt: &lease}
@@ -253,11 +323,10 @@ func (service *Service) claimAutoMatchWork(ctx context.Context, run models.Shope
if err != nil { if err != nil {
return err return err
} }
if work.InputFingerprint == fingerprint { if autoMatchWorkSkip(work, fingerprint, now) != "" {
if work.Status == "completed" || work.Status == "unmatched" || work.AttemptCount >= maxAutoMatchAttempts || (work.NextAttemptAt != nil && work.NextAttemptAt.After(now)) || (work.Status == "running" && work.LeaseExpiresAt != nil && work.LeaseExpiresAt.After(now)) { return errWorkNotClaimed
return errWorkNotClaimed }
} if work.InputFingerprint != fingerprint {
} else {
work.AttemptCount = 0 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": ""} 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": ""}
@@ -274,7 +343,7 @@ func (service *Service) claimAutoMatchWork(ctx context.Context, run models.Shope
var errWorkNotClaimed = errors.New("auto match work not claimed") var errWorkNotClaimed = errors.New("auto match work not claimed")
func (service *Service) completeAutoMatchWork(work models.ShopeeSpecAutoMatchWorkItem, fingerprint string, confirmed, unmatched int, matchErr error) { func (service *Service) completeAutoMatchWork(ctx context.Context, work models.ShopeeSpecAutoMatchWorkItem, fingerprint string, confirmed, unmatched int, matchErr error) error {
now := time.Now().UTC() now := time.Now().UTC()
updates := map[string]any{"input_fingerprint": fingerprint, "lease_owner": "", "lease_expires_at": nil, "confirmed_count": confirmed, "unmatched_count": unmatched} updates := map[string]any{"input_fingerprint": fingerprint, "lease_owner": "", "lease_expires_at": nil, "confirmed_count": confirmed, "unmatched_count": unmatched}
if matchErr == nil { if matchErr == nil {
@@ -294,18 +363,189 @@ func (service *Service) completeAutoMatchWork(work models.ShopeeSpecAutoMatchWor
updates["next_attempt_at"] = nil updates["next_attempt_at"] = nil
} }
} }
_ = service.DB.Model(&models.ShopeeSpecAutoMatchWorkItem{}).Where("id = ?", work.ID).Updates(updates).Error return service.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := checkAutoMatchRunContext(ctx, tx.Clauses(clause.Locking{Strength: "UPDATE"})); err != nil {
return err
}
result := tx.Model(&models.ShopeeSpecAutoMatchWorkItem{}).Where("id = ? AND status = ? AND lease_owner = ? AND lease_expires_at > ?", work.ID, "running", work.LeaseOwner, now).Updates(updates)
return autoMatchOwnedUpdate(result)
})
} }
func (service *Service) renewAutoMatchRun(run models.ShopeeSpecAutoMatchRun) { func (service *Service) renewAutoMatchRun(ctx context.Context, run models.ShopeeSpecAutoMatchRun) error {
lease := time.Now().UTC().Add(autoMatchLeaseDuration) if err := ctx.Err(); err != nil {
_ = service.DB.Model(&models.ShopeeSpecAutoMatchRun{}).Where("id = ? AND status = ? AND lease_owner = ?", run.ID, "running", run.LeaseOwner).Update("lease_expires_at", lease).Error return err
}
return service.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := lockAutoMatchRun(ctx, tx, run); err != nil {
return err
}
result := autoMatchOwnedRun(tx, run).Update("lease_expires_at", time.Now().UTC().Add(autoMatchLeaseDuration))
if result.Error == nil && result.RowsAffected == 0 {
// MySQL's changed-row count can be zero when datetime precision
// rounds a rapid renewal to the stored value. Under the same row
// lock, distinguish that no-op from an expired or lost lease.
return lockAutoMatchRun(ctx, tx, run)
}
return autoMatchOwnedUpdate(result)
})
} }
func (service *Service) finishAutoMatchRun(run models.ShopeeSpecAutoMatchRun, status string, scanned, eligible, processed, confirmed, unmatched, failed int, summary string) error { func (service *Service) finishAutoMatchRun(ctx context.Context, run models.ShopeeSpecAutoMatchRun, stats autoMatchBatchStats, status, reason string, cursor *uint64) error {
now := time.Now().UTC() now := time.Now().UTC()
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": truncateBatchText(summary), "finished_at": now} updates := map[string]any{"status": status, "active_slot": nil, "lease_owner": "", "lease_expires_at": nil, "scanned_count": stats.scanned, "eligible_count": stats.eligible, "processed_count": stats.processed, "confirmed_count": stats.confirmed, "unmatched_count": stats.unmatched, "failed_count": stats.failed, "error_summary": truncateBatchText(stats.summary), "finished_at": now, "resume_after_id": cursor, "stop_reason": reason}
return service.DB.Model(&models.ShopeeSpecAutoMatchRun{}).Where("id = ? AND status = ? AND lease_owner = ?", run.ID, "running", run.LeaseOwner).Updates(updates).Error return service.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := lockAutoMatchRun(ctx, tx, run); err != nil {
return err
}
return autoMatchOwnedUpdate(autoMatchOwnedRun(tx, run).Updates(updates))
})
}
var errAutoMatchLeaseLost = errors.New("auto match run lease lost")
type autoMatchRunContextKey struct{}
type autoMatchRunGuard struct {
run models.ShopeeSpecAutoMatchRun
cancel context.CancelFunc
}
func autoMatchOwnedRun(db *gorm.DB, run models.ShopeeSpecAutoMatchRun) *gorm.DB {
return db.Model(&models.ShopeeSpecAutoMatchRun{}).Where("id = ? AND status = ? AND active_slot = ? AND lease_owner = ? AND lease_expires_at > ?", run.ID, "running", 1, run.LeaseOwner, time.Now().UTC())
}
func autoMatchOwnedUpdate(result *gorm.DB) error {
if result.Error != nil {
return result.Error
}
if result.RowsAffected != 1 {
return errAutoMatchLeaseLost
}
return nil
}
func lockAutoMatchRun(ctx context.Context, tx *gorm.DB, run models.ShopeeSpecAutoMatchRun) error {
if err := ctx.Err(); err != nil {
return err
}
var owned models.ShopeeSpecAutoMatchRun
err := autoMatchOwnedRun(tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}), run).Take(&owned).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return errAutoMatchLeaseLost
}
if err != nil {
return err
}
if err := ctx.Err(); err != nil {
return err
}
if owned.LeaseExpiresAt == nil || !owned.LeaseExpiresAt.After(time.Now().UTC()) {
return errAutoMatchLeaseLost
}
return nil
}
// Only scheduled/manual batch runs attach this context; individual matching
// keeps its existing behavior. Reuse the caller's transaction for row locks.
func checkAutoMatchRunContext(ctx context.Context, db *gorm.DB) error {
if err := ctx.Err(); err != nil {
return err
}
guard, ok := ctx.Value(autoMatchRunContextKey{}).(autoMatchRunGuard)
if !ok {
return nil
}
var owned models.ShopeeSpecAutoMatchRun
err := autoMatchOwnedRun(db.WithContext(ctx), guard.run).Take(&owned).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
err = errAutoMatchLeaseLost
}
if err == nil && (owned.LeaseExpiresAt == nil || !owned.LeaseExpiresAt.After(time.Now().UTC())) {
err = errAutoMatchLeaseLost
}
if errors.Is(err, errAutoMatchLeaseLost) {
guard.cancel()
}
if err == nil {
err = ctx.Err()
}
return err
}
func autoMatchWorkSkip(work models.ShopeeSpecAutoMatchWorkItem, fingerprint string, now time.Time) string {
if work.InputFingerprint != fingerprint {
return ""
}
if work.Status == "completed" || work.Status == "unmatched" {
return "unchanged"
}
if work.AttemptCount >= maxAutoMatchAttempts {
return "max_retry"
}
if work.NextAttemptAt != nil && work.NextAttemptAt.After(now) {
return "cooldown"
}
if work.Status == "running" && work.LeaseExpiresAt != nil && work.LeaseExpiresAt.After(now) {
return "occupied"
}
return ""
}
type autoMatchBatchStats struct {
scanned, eligible, processed, confirmed, unmatched, failed int
summary string
skips map[string]int
checkpointLoaded bool
}
func (stats *autoMatchBatchStats) skip(reason string) {
if stats.skips == nil {
stats.skips = map[string]int{}
}
stats.skips[reason]++
}
func (service *Service) endAutoMatchBatch(ctx context.Context, run models.ShopeeSpecAutoMatchRun, stats autoMatchBatchStats, cursor uint64, reason string, cause error) error {
checkpoint := &cursor
if !stats.checkpointLoaded {
checkpoint = nil
}
status := "completed"
if stats.failed > 0 {
status = "completed_partial"
}
if errors.Is(cause, errAutoMatchLeaseLost) || errors.Is(context.Cause(ctx), errAutoMatchLeaseLost) {
reason, cause = "lease_lost", errAutoMatchLeaseLost
} else if ctx.Err() != nil {
reason = "time_budget"
}
if reason == "lease_lost" || reason == "error" {
checkpoint = nil
status = "failed"
stats.failed++
stats.summary = safeBatchError(cause)
}
// Finalization is the sole exception to the scan deadline: a fresh bounded
// context records the last fully decided item after a time-budget stop.
finishCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
var finishErr error
if reason != "lease_lost" {
finishErr = service.finishAutoMatchRun(finishCtx, run, stats, status, reason, checkpoint)
}
if errors.Is(finishErr, errAutoMatchLeaseLost) {
reason = "lease_lost"
} else if finishErr != nil {
reason = "error"
}
log.Infof("shopee_spec_auto_match run_id=%d stop_reason=%s scanned=%d eligible=%d processed=%d confirmed=%d unmatched=%d failed=%d skip_no_specs=%d skip_confirmed=%d skip_unchanged=%d skip_max_retry=%d skip_cooldown=%d skip_occupied=%d", run.ID, reason, stats.scanned, stats.eligible, stats.processed, stats.confirmed, stats.unmatched, stats.failed, stats.skips["no_specs"], stats.skips["confirmed"], stats.skips["unchanged"], stats.skips["max_retry"], stats.skips["cooldown"], stats.skips["occupied"])
if finishErr != nil {
return finishErr
}
if reason == "time_budget" {
return nil
}
return cause
} }
func batchErrorCode(err error) string { func batchErrorCode(err error) string {
@@ -3,6 +3,7 @@ package shopeeproduct
import ( import (
"context" "context"
"testing" "testing"
"time"
"go-admin/app/goauto/models" "go-admin/app/goauto/models"
@@ -66,7 +67,8 @@ func TestUnchangedUnmatchedWorkIsNotClaimedAgain(t *testing.T) {
db := openTestDB(t) db := openTestDB(t)
service := NewService(db) service := NewService(db)
one := uint8(1) one := uint8(1)
run := models.ShopeeSpecAutoMatchRun{RequestID: uuid.NewString(), Trigger: "manual", Status: "running", ActiveSlot: &one, LeaseOwner: uuid.NewString(), BatchLimit: 20} lease := time.Now().UTC().Add(autoMatchLeaseDuration)
run := models.ShopeeSpecAutoMatchRun{RequestID: uuid.NewString(), Trigger: "manual", Status: "running", ActiveSlot: &one, LeaseOwner: uuid.NewString(), LeaseExpiresAt: &lease, BatchLimit: 20}
if err := db.Create(&run).Error; err != nil { if err := db.Create(&run).Error; err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -74,7 +76,9 @@ func TestUnchangedUnmatchedWorkIsNotClaimedAgain(t *testing.T) {
if err != nil || !claimed { if err != nil || !claimed {
t.Fatalf("work=%+v claimed=%v err=%v", work, claimed, err) t.Fatalf("work=%+v claimed=%v err=%v", work, claimed, err)
} }
service.completeAutoMatchWork(work, "fingerprint", 0, 1, nil) if err := service.completeAutoMatchWork(context.Background(), work, "fingerprint", 0, 1, nil); err != nil {
t.Fatal(err)
}
_, claimed, err = service.claimAutoMatchWork(context.Background(), run, 99, "fingerprint") _, claimed, err = service.claimAutoMatchWork(context.Background(), run, 99, "fingerprint")
if err != nil || claimed { if err != nil || claimed {
t.Fatalf("unchanged unmatched claimed=%v err=%v", claimed, err) t.Fatalf("unchanged unmatched claimed=%v err=%v", claimed, err)
@@ -0,0 +1,50 @@
package shopeeproduct
import (
"context"
"os"
"testing"
"time"
"gorm.io/driver/mysql"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
// Opt-in, synthetic SELECTs only: no schema selection is required, and no
// tables, production rows, migrations or credentials are written or logged.
func TestAutoMatchScanMySQLJSONCoarseFilter(t *testing.T) {
dsn := os.Getenv("GOAUTO_TEST_MYSQL_READONLY_DSN")
if dsn == "" {
t.Skip("set GOAUTO_TEST_MYSQL_READONLY_DSN to opt in to read-only MySQL compatibility checks")
}
db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
if err != nil {
t.Fatal("cannot connect to opted-in MySQL")
}
sqlDB, err := db.DB()
if err != nil {
t.Fatal("cannot access opted-in MySQL connection")
}
t.Cleanup(func() { sqlDB.Close() })
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
for _, tc := range []struct {
name, json string
want int
}{
{"empty_string", `""`, 0}, {"empty_array", `[]`, 0}, {"json_null", `null`, 0}, {"spaced_array", `[ ]`, 0},
{"empty_values", `[{"role":"size","values":[]}]`, 1}, {"size_only", sizeScanSpecs, 1},
{"color_only", `[{"role":"color","values":[{"name":"黑色"}]}]`, 1},
} {
t.Run(tc.name, func(t *testing.T) {
var count int
if err := db.WithContext(ctx).Raw("SELECT COUNT(*) FROM (SELECT CAST(? AS JSON) AS specs_json) shopee_product WHERE "+autoMatchNonEmptySpecsSQL, tc.json, "", "[]", "null", `""`).Scan(&count).Error; err != nil {
t.Fatal("MySQL JSON coarse filter query failed")
}
if count != tc.want {
t.Fatalf("count=%d want=%d", count, tc.want)
}
})
}
}
@@ -0,0 +1,784 @@
package shopeeproduct
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"sync/atomic"
"testing"
"time"
"github.com/google/uuid"
"go-admin/app/goauto/models"
"gorm.io/gorm"
)
const sizeScanSpecs = `[{"name":"尺码","role":"size","values":[{"name":"XL","source":"import"}]}]`
const otherScanSpecs = `[{"name":"材质","role":"other","values":[{"name":"棉","source":"import"}]}]`
func openScanTestDB(t *testing.T) *gorm.DB {
t.Helper()
db := openTestDB(t)
sqlDB, err := db.DB()
if err != nil {
t.Fatal(err)
}
// Closing the final connection releases the named in-memory database,
// including when go test repeats the same t.Name via -count.
t.Cleanup(func() {
if err := sqlDB.Close(); err != nil {
t.Errorf("close scan test database: %v", err)
}
})
return db
}
func seedScanProducts(t *testing.T, db *gorm.DB, pddID uint64, count int, specs string) []models.ShopeeProduct {
t.Helper()
products := make([]models.ShopeeProduct, count)
for i := range products {
products[i] = models.ShopeeProduct{ShopeeItemID: uuid.NewString(), PDDProductID: &pddID, SpecsJSON: specs}
}
if err := db.CreateInBatches(&products, 100).Error; err != nil {
t.Fatal(err)
}
return products
}
func runScan(t *testing.T, service *Service, limit int) *AutoMatchRunView {
t.Helper()
run, created, err := service.StartAutoMatchRun(context.Background(), "manual", uuid.NewString(), nil, limit)
if err != nil || !created {
t.Fatalf("start: created=%v err=%v", created, err)
}
if err := service.ProcessAutoMatchRun(context.Background(), run.ID); err != nil {
t.Fatal(err)
}
latest, err := service.LatestAutoMatchRun(context.Background())
if err != nil {
t.Fatal(err)
}
return latest
}
func scanCheckpoint(t *testing.T, run *AutoMatchRunView, cursor uint64, reason string) {
t.Helper()
raw, err := json.Marshal(run)
if err != nil {
t.Fatal(err)
}
var fields map[string]any
if err := json.Unmarshal(raw, &fields); err != nil {
t.Fatal(err)
}
if fields["resumeAfterId"] != float64(cursor) || fields["stopReason"] != reason {
t.Fatalf("checkpoint got cursor=%v reason=%v; want %d %s", fields["resumeAfterId"], fields["stopReason"], cursor, reason)
}
}
func TestAutoMatchScanPassesLongEmptyPrefix(t *testing.T) {
db := openScanTestDB(t)
pdd := seedPDDProduct(t, db, "active")
seedScanProducts(t, db, pdd.ID, 1812, `[]`)
seedScanProducts(t, db, pdd.ID, 1, sizeScanSpecs)
run := runScan(t, NewService(db), 20)
if run.ProcessedCount != 1 || run.ScannedCount != 1 || run.ConfirmedCount != 1 {
t.Fatalf("run=%+v", run)
}
scanCheckpoint(t, run, 0, "end_of_scan")
}
func TestAutoMatchScanRotatesAcrossServiceRestart(t *testing.T) {
db := openScanTestDB(t)
pdd := seedPDDProduct(t, db, "active")
prefix := seedScanProducts(t, db, pdd.ID, 2001, otherScanSpecs)
seedScanProducts(t, db, pdd.ID, 1, sizeScanSpecs)
first := runScan(t, NewService(db), 20)
if first.ScannedCount != 2000 || first.ProcessedCount != 0 {
t.Fatalf("first=%+v", first)
}
scanCheckpoint(t, first, prefix[1999].ID, "scan_budget")
second := runScan(t, NewService(db), 20)
if second.ScannedCount != 2 || second.ProcessedCount != 1 {
t.Fatalf("second=%+v", second)
}
scanCheckpoint(t, second, 0, "end_of_scan")
if err := db.Model(&prefix[0]).Update("specs_json", sizeScanSpecs).Error; err != nil {
t.Fatal(err)
}
third := runScan(t, NewService(db), 20)
if third.ProcessedCount != 1 {
t.Fatalf("changed low ID not visited: %+v", third)
}
}
func TestAutoMatchScanBatchLimitKeepsLastExaminedOnShortPage(t *testing.T) {
db := openScanTestDB(t)
pdd := seedPDDProduct(t, db, "active")
products := seedScanProducts(t, db, pdd.ID, 3, sizeScanSpecs)
for i := 0; i < 3; i++ {
run := runScan(t, NewService(db), 1)
if run.ProcessedCount != 1 || run.ScannedCount != 1 {
t.Fatalf("run=%+v", run)
}
if i < 2 {
scanCheckpoint(t, run, products[i].ID, "batch_limit")
} else {
scanCheckpoint(t, run, 0, "end_of_scan")
}
}
}
func TestAutoMatchScanExactBudgetDoesNotAssumeEnd(t *testing.T) {
db := openScanTestDB(t)
pdd := seedPDDProduct(t, db, "active")
products := seedScanProducts(t, db, pdd.ID, 2000, otherScanSpecs)
first := runScan(t, NewService(db), 20)
scanCheckpoint(t, first, products[1999].ID, "scan_budget")
second := runScan(t, NewService(db), 20)
scanCheckpoint(t, second, 0, "end_of_scan")
if second.ScannedCount != 0 {
t.Fatalf("second scanned %d", second.ScannedCount)
}
}
func TestAutoMatchEligibilityRejectsUnusableSpecsBeforePDDRead(t *testing.T) {
for i, specs := range []string{"", `[]`, `null`, `""`, otherScanSpecs, `[{"role":"size","values":[]}]`, `[{"role":"color","values":[{"name":" "}]}]`} {
t.Run(fmt.Sprint(i), func(t *testing.T) {
db := openScanTestDB(t)
missing := uint64(999)
_, eligible, err := NewService(db).autoMatchEligibility(context.Background(), models.ShopeeProduct{PDDProductID: &missing, SpecsJSON: specs})
if err != nil || eligible {
t.Fatalf("eligible=%v err=%v", eligible, err)
}
})
}
}
func TestAutoMatchScanEmptyRepresentationsAndSingleDimension(t *testing.T) {
db := openScanTestDB(t)
pdd := seedPDDProduct(t, db, "active")
for _, specs := range []string{`[]`, `null`, `""`} {
seedScanProducts(t, db, pdd.ID, 1, specs)
}
seedScanProducts(t, db, pdd.ID, 1, `[{"name":"颜色","role":"color","values":[{"name":"黑色","source":"import"}]}]`)
seedScanProducts(t, db, pdd.ID, 1, sizeScanSpecs)
run := runScan(t, NewService(db), 20)
if run.ScannedCount != 2 || run.ProcessedCount != 2 || run.ConfirmedCount != 2 {
t.Fatalf("run=%+v", run)
}
}
func TestAutoMatchScanLatestCommittedZeroWinsAndNullIsIgnored(t *testing.T) {
for _, latest := range []uint64{0, 2} {
t.Run(fmt.Sprint(latest), func(t *testing.T) {
db := openScanTestDB(t)
pdd := seedPDDProduct(t, db, "active")
products := seedScanProducts(t, db, pdd.ID, 3, otherScanSpecs)
old := uint64(1)
for _, checkpoint := range []*uint64{&old, &latest, nil} {
run := models.ShopeeSpecAutoMatchRun{RequestID: uuid.NewString(), Trigger: "manual", Status: "completed", ResumeAfterID: checkpoint}
if err := db.Create(&run).Error; err != nil {
t.Fatal(err)
}
}
run := runScan(t, NewService(db), 20)
if run.ScannedCount != len(products)-int(latest) {
t.Fatalf("wrong checkpoint: %+v", run)
}
scanCheckpoint(t, run, 0, "end_of_scan")
})
}
}
func TestAutoMatchRenewAndFinishRejectLostLease(t *testing.T) {
for _, change := range []string{"owner", "expired", "slot", "status"} {
t.Run(change, func(t *testing.T) {
db := openScanTestDB(t)
s := NewService(db)
run, _, err := s.StartAutoMatchRun(context.Background(), "manual", uuid.NewString(), nil, 20)
if err != nil {
t.Fatal(err)
}
updates := map[string]any{}
switch change {
case "owner":
updates["lease_owner"] = "new-owner"
case "expired":
updates["lease_expires_at"] = time.Now().UTC().Add(-time.Second)
case "slot":
updates["active_slot"] = nil
case "status":
updates["status"] = "failed"
}
if err := db.Model(&models.ShopeeSpecAutoMatchRun{}).Where("id = ?", run.ID).Updates(updates).Error; err != nil {
t.Fatal(err)
}
if err := s.renewAutoMatchRun(context.Background(), run.ShopeeSpecAutoMatchRun); err != errAutoMatchLeaseLost {
t.Fatalf("renew=%v", err)
}
cursor := uint64(999)
if err := s.finishAutoMatchRun(context.Background(), run.ShopeeSpecAutoMatchRun, autoMatchBatchStats{}, "completed", "end_of_scan", &cursor); err != errAutoMatchLeaseLost {
t.Fatalf("finish=%v", err)
}
var current models.ShopeeSpecAutoMatchRun
if err := db.First(&current, run.ID).Error; err != nil {
t.Fatal(err)
}
if current.ResumeAfterID != nil || current.StopReason != "" {
t.Fatalf("old owner committed: %+v", current)
}
})
}
}
func TestAutoMatchScanDatabaseErrorDoesNotCommitCheckpoint(t *testing.T) {
db := openScanTestDB(t)
pdd := seedPDDProduct(t, db, "active")
products := seedScanProducts(t, db, pdd.ID, 2, sizeScanSpecs)
s := NewService(db)
run, _, err := s.StartAutoMatchRun(context.Background(), "manual", uuid.NewString(), nil, 20)
if err != nil {
t.Fatal(err)
}
if err := db.Callback().Row().Before("gorm:row").Register("test_settings_error", func(tx *gorm.DB) {
if tx.Statement.Table == "ai_matching_setting" {
tx.AddError(fmt.Errorf("synthetic database error"))
}
}); err != nil {
t.Fatal(err)
}
if err := s.ProcessAutoMatchRun(context.Background(), run.ID); err == nil {
t.Fatal("database error ignored")
}
if err := db.Callback().Row().Remove("test_settings_error"); err != nil {
t.Fatal(err)
}
latest, err := s.LatestAutoMatchRun(context.Background())
if err != nil {
t.Fatal(err)
}
if latest.Status != "failed" || latest.StopReason != "error" || latest.ResumeAfterID != nil {
t.Fatalf("latest=%+v", latest)
}
next := runScan(t, NewService(db), 20)
if next.ProcessedCount != len(products) {
t.Fatalf("restart=%+v", next)
}
}
func TestAutoMatchClaimRejectsLostOrExpiredRun(t *testing.T) {
for _, change := range []string{"owner", "expired", "slot", "status"} {
t.Run(change, func(t *testing.T) {
db := openScanTestDB(t)
s := NewService(db)
run, _, err := s.StartAutoMatchRun(context.Background(), "manual", uuid.NewString(), nil, 20)
if err != nil {
t.Fatal(err)
}
updates := map[string]any{}
switch change {
case "owner":
updates["lease_owner"] = uuid.NewString()
case "expired":
updates["lease_expires_at"] = time.Now().UTC().Add(-time.Second)
case "slot":
updates["active_slot"] = nil
case "status":
updates["status"] = "failed"
}
if err := db.Model(&models.ShopeeSpecAutoMatchRun{}).Where("id = ?", run.ID).Updates(updates).Error; err != nil {
t.Fatal(err)
}
_, claimed, err := s.claimAutoMatchWork(context.Background(), run.ShopeeSpecAutoMatchRun, 999, "fingerprint")
if err == nil || claimed {
t.Fatalf("lost run claimed=%v err=%v", claimed, err)
}
var count int64
if err := db.Model(&models.ShopeeSpecAutoMatchWorkItem{}).Count(&count).Error; err != nil {
t.Fatal(err)
}
if count != 0 {
t.Fatalf("lost run wrote work: %d", count)
}
})
}
}
func TestAutoMatchScanOwnerLossDuringProviderStopsNextCallAndSave(t *testing.T) {
db := openScanTestDB(t)
s := NewService(db)
pdd := seedPDDProduct(t, db, "active")
products := seedScanProducts(t, db, pdd.ID, 2, `[{"name":"颜色","role":"color","values":[{"name":"深黑","source":"import"}]},{"name":"尺码","role":"size","values":[{"name":"大号","source":"import"}]}]`)
run, _, err := s.StartAutoMatchRun(context.Background(), "manual", uuid.NewString(), nil, 20)
if err != nil {
t.Fatal(err)
}
var calls atomic.Int32
provider := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls.Add(1)
if err := db.Model(&models.ShopeeSpecAutoMatchRun{}).Where("id = ?", run.ID).Update("lease_owner", "replacement-owner").Error; err != nil {
t.Error(err)
}
chatCompletionResponder(`{"suggestions":[{"sourceId":"s1","candidateId":"c1","confidence":0.96,"reason":"unique match"}]}`)(w, r)
}))
defer provider.Close()
setting := seedEnabledAISetting(t, provider.URL, 0.9)
if err := db.Create(&setting).Error; err != nil {
t.Fatal(err)
}
if err := s.ProcessAutoMatchRun(context.Background(), run.ID); err == nil {
t.Fatal("owner loss must be returned")
}
if calls.Load() != 1 {
t.Fatalf("provider calls after owner loss: %d", calls.Load())
}
var current models.ShopeeSpecAutoMatchRun
if err := db.First(&current, run.ID).Error; err != nil {
t.Fatal(err)
}
if current.Status != "running" || current.LeaseOwner != "replacement-owner" {
t.Fatalf("old owner overwrote run: %+v", current)
}
var product models.ShopeeProduct
if err := db.First(&product, products[0].ID).Error; err != nil {
t.Fatal(err)
}
if product.SpecsJSON != products[0].SpecsJSON {
t.Fatal("old owner saved mapping")
}
var count int64
if err := db.Model(&models.ShopeeSpecAutoMatchWorkItem{}).Count(&count).Error; err != nil {
t.Fatal(err)
}
if count != 1 {
t.Fatalf("unexpected work claims: %d", count)
}
}
func TestAutoMatchScanDeadlineStopsProviderAndKeepsLastDecision(t *testing.T) {
db := openScanTestDB(t)
pdd := seedPDDProduct(t, db, "active")
first := seedScanProducts(t, db, pdd.ID, 1, otherScanSpecs)[0]
products := seedScanProducts(t, db, pdd.ID, 2, `[{"name":"颜色","role":"color","values":[{"name":"深黑","source":"import"}]}]`)
var calls atomic.Int32
provider := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls.Add(1)
select {
case <-r.Context().Done():
case <-time.After(time.Second):
}
}))
defer provider.Close()
setting := seedEnabledAISetting(t, provider.URL, 0.9)
if err := db.Create(&setting).Error; err != nil {
t.Fatal(err)
}
s := NewService(db)
run, _, err := s.StartAutoMatchRun(context.Background(), "manual", uuid.NewString(), nil, 20)
if err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithTimeout(context.Background(), 150*time.Millisecond)
defer cancel()
started := time.Now()
if err := s.ProcessAutoMatchRun(ctx, run.ID); err != nil {
t.Fatal(err)
}
if time.Since(started) > time.Second {
t.Fatal("provider outlived batch deadline")
}
if calls.Load() != 1 {
t.Fatalf("provider calls=%d", calls.Load())
}
latest, err := s.LatestAutoMatchRun(context.Background())
if err != nil {
t.Fatal(err)
}
scanCheckpoint(t, latest, first.ID, "time_budget")
if latest.ScannedCount != 2 || latest.ProcessedCount != 1 || latest.FailedCount != 0 {
t.Fatalf("latest=%+v", latest)
}
var current models.ShopeeProduct
if err := db.First(&current, products[0].ID).Error; err != nil {
t.Fatal(err)
}
if current.SpecsJSON != products[0].SpecsJSON {
t.Fatal("timeout saved mapping")
}
}
func TestAutoMatchScanDeadlineBoundsDatabaseAndNoClaimAfterBudget(t *testing.T) {
db := openScanTestDB(t)
pdd := seedPDDProduct(t, db, "active")
seedScanProducts(t, db, pdd.ID, 2, sizeScanSpecs)
s := NewService(db)
run, _, err := s.StartAutoMatchRun(context.Background(), "manual", uuid.NewString(), nil, 20)
if err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
defer cancel()
queries := 0
if err := db.Callback().Query().Before("gorm:query").Register("test_database_deadline", func(tx *gorm.DB) {
deadline, ok := tx.Statement.Context.Deadline()
if !ok || time.Until(deadline) > autoMatchTimeBudget {
t.Error("database missed total deadline")
}
if tx.Statement.Table == "pdd_product" {
queries++
<-tx.Statement.Context.Done()
tx.AddError(tx.Statement.Context.Err())
}
}); err != nil {
t.Fatal(err)
}
if err := s.ProcessAutoMatchRun(ctx, run.ID); err != nil {
t.Fatal(err)
}
if err := db.Callback().Query().Remove("test_database_deadline"); err != nil {
t.Fatal(err)
}
latest, err := s.LatestAutoMatchRun(context.Background())
if err != nil {
t.Fatal(err)
}
scanCheckpoint(t, latest, 0, "time_budget")
if queries != 1 || latest.ProcessedCount != 0 || latest.ScannedCount != 1 {
t.Fatalf("queries=%d latest=%+v", queries, latest)
}
var count int64
if err := db.Model(&models.ShopeeSpecAutoMatchWorkItem{}).Count(&count).Error; err != nil || count != 0 {
t.Fatalf("work=%d err=%v", count, err)
}
}
func TestAutoMatchScanRecoveryIgnoresExpiredCheckpoint(t *testing.T) {
db := openScanTestDB(t)
s := NewService(db)
run, _, err := s.StartAutoMatchRun(context.Background(), "manual", uuid.NewString(), nil, 20)
if err != nil {
t.Fatal(err)
}
if err := db.Model(&models.ShopeeSpecAutoMatchRun{}).Where("id = ?", run.ID).Update("lease_expires_at", time.Now().UTC().Add(-time.Second)).Error; err != nil {
t.Fatal(err)
}
if _, created, err := s.StartAutoMatchRun(context.Background(), "scheduled", uuid.NewString(), nil, 20); err != nil || !created {
t.Fatalf("recovery created=%v err=%v", created, err)
}
var old models.ShopeeSpecAutoMatchRun
if err := db.First(&old, run.ID).Error; err != nil {
t.Fatal(err)
}
if old.Status != "failed" || old.StopReason != "lease_lost" || old.ResumeAfterID != nil {
t.Fatalf("old=%+v", old)
}
}
func TestAutoMatchScanExactPageBoundaryAndCandidateFilters(t *testing.T) {
db := openScanTestDB(t)
pdd := seedPDDProduct(t, db, "active")
seedScanProducts(t, db, pdd.ID, 200, otherScanSpecs)
seedScanProducts(t, db, pdd.ID, 1, sizeScanSpecs)
disabled := seedPDDProduct(t, db, "disabled")
seedScanProducts(t, db, disabled.ID, 1, sizeScanSpecs)
deleted := seedScanProducts(t, db, pdd.ID, 1, sizeScanSpecs)[0]
if err := db.Delete(&deleted).Error; err != nil {
t.Fatal(err)
}
unlinked := models.ShopeeProduct{ShopeeItemID: uuid.NewString(), SpecsJSON: sizeScanSpecs}
if err := db.Create(&unlinked).Error; err != nil {
t.Fatal(err)
}
run := runScan(t, NewService(db), 20)
if run.ScannedCount != 201 || run.ProcessedCount != 1 {
t.Fatalf("run=%+v", run)
}
scanCheckpoint(t, run, 0, "end_of_scan")
}
func TestAutoMatchWorkRetryAndCooldownPreserved(t *testing.T) {
for _, status := range []string{"completed", "unmatched", "max_retry", "cooldown", "occupied", "retryable"} {
t.Run(status, func(t *testing.T) {
db := openScanTestDB(t)
s := NewService(db)
run, _, err := s.StartAutoMatchRun(context.Background(), "manual", uuid.NewString(), nil, 20)
if err != nil {
t.Fatal(err)
}
future := time.Now().UTC().Add(time.Hour)
work := models.ShopeeSpecAutoMatchWorkItem{ShopeeProductID: 99, InputFingerprint: "same", Status: status, AttemptCount: 1}
switch status {
case "max_retry":
work.Status, work.AttemptCount = "failed", 3
case "cooldown":
work.Status, work.NextAttemptAt = "failed", &future
case "occupied":
work.Status, work.LeaseExpiresAt = "running", &future
case "retryable":
work.Status = "failed"
}
if err := db.Create(&work).Error; err != nil {
t.Fatal(err)
}
got, claimed, err := s.claimAutoMatchWork(context.Background(), run.ShopeeSpecAutoMatchRun, 99, "same")
if err != nil || claimed != (status == "retryable") {
t.Fatalf("claimed=%v err=%v", claimed, err)
}
if claimed {
if got.AttemptCount != 2 {
t.Fatalf("attempts=%d", got.AttemptCount)
}
if err := s.completeAutoMatchWork(context.Background(), got, "same", 0, 0, aiUnavailable("synthetic unavailable")); err != nil {
t.Fatal(err)
}
var saved models.ShopeeSpecAutoMatchWorkItem
if err := db.First(&saved, got.ID).Error; err != nil {
t.Fatal(err)
}
if saved.Status != "failed" || saved.NextAttemptAt == nil || saved.LastErrorCode != CodeAIUnavailable {
t.Fatalf("saved=%+v", saved)
}
}
changed, claimed, err := s.claimAutoMatchWork(context.Background(), run.ShopeeSpecAutoMatchRun, 99, "changed")
if err != nil || !claimed || changed.AttemptCount != 1 {
t.Fatalf("changed=%+v claimed=%v err=%v", changed, claimed, err)
}
})
}
}
func TestAutoMatchClaimDoesNotUseLeaseTimeBeforeLockWait(t *testing.T) {
db := openScanTestDB(t)
s := NewService(db)
run, _, err := s.StartAutoMatchRun(context.Background(), "manual", uuid.NewString(), nil, 20)
if err != nil {
t.Fatal(err)
}
expiry := time.Now().UTC().Add(50 * time.Millisecond)
if err := db.Model(&models.ShopeeSpecAutoMatchRun{}).Where("id = ?", run.ID).Update("lease_expires_at", expiry).Error; err != nil {
t.Fatal(err)
}
if err := db.Callback().Query().Before("gorm:query").Register("test_lock_wait", func(tx *gorm.DB) {
if tx.Statement.Table == "shopee_spec_auto_match_run" {
time.Sleep(time.Until(expiry) + 10*time.Millisecond)
}
}); err != nil {
t.Fatal(err)
}
_, claimed, err := s.claimAutoMatchWork(context.Background(), run.ShopeeSpecAutoMatchRun, 99, "same")
if err != errAutoMatchLeaseLost || claimed {
t.Fatalf("claimed=%v err=%v", claimed, err)
}
}
func TestAutoMatchProviderGuardDatabaseErrorMustNotRetryOrCheckpoint(t *testing.T) {
db := openScanTestDB(t)
pdd := seedPDDProduct(t, db, "active")
seedScanProducts(t, db, pdd.ID, 1, `[{"name":"颜色","role":"color","values":[{"name":"深黑","source":"import"}]}]`)
var calls atomic.Int32
provider := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls.Add(1)
chatCompletionResponder(`{"suggestions":[{"sourceId":"s1","candidateId":"c1","confidence":0.96,"reason":"unique match"}]}`)(w, r)
}))
defer provider.Close()
setting := seedEnabledAISetting(t, provider.URL, 0.9)
if err := db.Create(&setting).Error; err != nil {
t.Fatal(err)
}
s := NewService(db)
run, _, err := s.StartAutoMatchRun(context.Background(), "manual", uuid.NewString(), nil, 20)
if err != nil {
t.Fatal(err)
}
// The settings read in suggestMappings immediately precedes its provider guard.
armed, injected := false, false
if err := db.Callback().Query().Before("gorm:query").Register("test_provider_guard_error", func(tx *gorm.DB) {
if tx.Statement.Table == "ai_matching_setting" {
armed = true
}
if armed && !injected && tx.Statement.Table == "shopee_spec_auto_match_run" {
injected = true
tx.AddError(fmt.Errorf("synthetic provider guard database error"))
}
}); err != nil {
t.Fatal(err)
}
err = s.ProcessAutoMatchRun(context.Background(), run.ID)
if err == nil || !injected || calls.Load() != 0 {
t.Fatalf("err=%v injected=%v calls=%d", err, injected, calls.Load())
}
latest, err := s.LatestAutoMatchRun(context.Background())
if err != nil {
t.Fatal(err)
}
if latest.ResumeAfterID != nil || latest.StopReason != "error" {
t.Fatalf("latest=%+v", latest)
}
}
func TestAutoMatchProviderSettingsDatabaseErrorMustNotRetryOrCheckpoint(t *testing.T) {
db := openScanTestDB(t)
pdd := seedPDDProduct(t, db, "active")
seedScanProducts(t, db, pdd.ID, 1, `[{"name":"颜色","role":"color","values":[{"name":"深黑","source":"import"}]}]`)
var calls atomic.Int32
provider := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls.Add(1)
chatCompletionResponder(`{"suggestions":[{"sourceId":"s1","candidateId":"c1","confidence":0.96,"reason":"unique match"}]}`)(w, r)
}))
defer provider.Close()
setting := seedEnabledAISetting(t, provider.URL, 0.9)
if err := db.Create(&setting).Error; err != nil {
t.Fatal(err)
}
s := NewService(db)
run, _, err := s.StartAutoMatchRun(context.Background(), "manual", uuid.NewString(), nil, 20)
if err != nil {
t.Fatal(err)
}
settingReads := 0
if err := db.Callback().Query().Before("gorm:query").Register("test_provider_settings_error", func(tx *gorm.DB) {
if tx.Statement.Table == "ai_matching_setting" {
settingReads++
if settingReads == 2 {
tx.AddError(fmt.Errorf("synthetic nested settings error"))
}
}
}); err != nil {
t.Fatal(err)
}
err = s.ProcessAutoMatchRun(context.Background(), run.ID)
if err == nil || settingReads != 2 || calls.Load() != 0 {
t.Fatalf("err=%v settings_reads=%d calls=%d", err, settingReads, calls.Load())
}
latest, err := s.LatestAutoMatchRun(context.Background())
if err != nil {
t.Fatal(err)
}
if latest.ResumeAfterID != nil || latest.StopReason != "error" {
t.Fatalf("latest=%+v", latest)
}
}
func TestAutoMatchRunDatabaseWriteFailuresAreReturned(t *testing.T) {
for _, operation := range []string{"renew", "finish", "work"} {
t.Run(operation, func(t *testing.T) {
db := openScanTestDB(t)
s := NewService(db)
run, _, err := s.StartAutoMatchRun(context.Background(), "manual", uuid.NewString(), nil, 20)
if err != nil {
t.Fatal(err)
}
work, _, err := s.claimAutoMatchWork(context.Background(), run.ShopeeSpecAutoMatchRun, 99, "same")
if err != nil {
t.Fatal(err)
}
injected := fmt.Errorf("synthetic update failure")
if err := db.Callback().Update().Before("gorm:update").Register("test_update_error", func(tx *gorm.DB) { tx.AddError(injected) }); err != nil {
t.Fatal(err)
}
switch operation {
case "renew":
err = s.renewAutoMatchRun(context.Background(), run.ShopeeSpecAutoMatchRun)
case "finish":
cursor := uint64(99)
err = s.finishAutoMatchRun(context.Background(), run.ShopeeSpecAutoMatchRun, autoMatchBatchStats{}, "completed", "end_of_scan", &cursor)
case "work":
err = s.completeAutoMatchWork(context.Background(), work, "same", 1, 0, nil)
}
if err != injected {
t.Fatalf("err=%v", err)
}
var saved models.ShopeeSpecAutoMatchRun
if err := db.First(&saved, run.ID).Error; err != nil {
t.Fatal(err)
}
if saved.ResumeAfterID != nil || saved.Status != "running" {
t.Fatalf("saved=%+v", saved)
}
})
}
}
func TestAutoMatchDeadlineDuringCheckpointReadCannotCommitFalseHead(t *testing.T) {
db := openScanTestDB(t)
s := NewService(db)
checkpoint := uint64(1700)
old := models.ShopeeSpecAutoMatchRun{RequestID: uuid.NewString(), Trigger: "manual", Status: "completed", ResumeAfterID: &checkpoint}
if err := db.Create(&old).Error; err != nil {
t.Fatal(err)
}
run, _, err := s.StartAutoMatchRun(context.Background(), "manual", uuid.NewString(), nil, 20)
if err != nil {
t.Fatal(err)
}
queries := 0
if err := db.Callback().Query().Before("gorm:query").Register("test_checkpoint_timeout", func(tx *gorm.DB) {
if tx.Statement.Table == "shopee_spec_auto_match_run" {
queries++
if queries == 2 {
<-tx.Statement.Context.Done()
tx.AddError(tx.Statement.Context.Err())
}
}
}); err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
defer cancel()
if err := s.ProcessAutoMatchRun(ctx, run.ID); err != nil {
t.Fatal(err)
}
latest, err := s.LatestAutoMatchRun(context.Background())
if err != nil {
t.Fatal(err)
}
if latest.ResumeAfterID != nil || latest.StopReason != "time_budget" {
t.Fatalf("unknown cursor committed: %+v", latest)
}
}
func TestAutoMatchRenewZeroChangedRowsRequiresLiveOwnership(t *testing.T) {
for _, change := range []string{"unchanged", "expired", "owner", "multiple_rows"} {
t.Run(change, func(t *testing.T) {
db := openScanTestDB(t)
s := NewService(db)
run, _, err := s.StartAutoMatchRun(context.Background(), "manual", uuid.NewString(), nil, 20)
if err != nil {
t.Fatal(err)
}
if err := db.Callback().Update().After("gorm:update").Register("test_renew_zero_changed", func(tx *gorm.DB) {
if tx.Statement.Table != "shopee_spec_auto_match_run" || tx.Error != nil {
return
}
// MySQL reports changed rows by default: datetime(3) may round a
// same-millisecond renewal to the value already stored.
switch change {
case "expired":
err = tx.Session(&gorm.Session{NewDB: true}).Exec("UPDATE shopee_spec_auto_match_run SET lease_expires_at = ? WHERE id = ?", time.Now().UTC().Add(-time.Second), run.ID).Error
case "owner":
err = tx.Session(&gorm.Session{NewDB: true}).Exec("UPDATE shopee_spec_auto_match_run SET lease_owner = ? WHERE id = ?", "replacement-owner", run.ID).Error
}
if err != nil {
tx.AddError(err)
}
tx.RowsAffected = 0
if change == "multiple_rows" {
tx.RowsAffected = 2
}
}); err != nil {
t.Fatal(err)
}
err = s.renewAutoMatchRun(context.Background(), run.ShopeeSpecAutoMatchRun)
if change == "unchanged" {
if err != nil {
t.Fatalf("live no-op renewal rejected: %v", err)
}
} else if err != errAutoMatchLeaseLost {
t.Fatalf("lost lease accepted after zero changed rows: %v", err)
}
})
}
}
@@ -0,0 +1,32 @@
package version_local
import (
"go-admin/app/goauto/models"
"go-admin/cmd/migrate/migration"
common "go-admin/common/models"
"gorm.io/gorm"
"runtime"
)
func init() {
_, file, _, _ := runtime.Caller(0)
migration.Migrate.SetVersion(migration.GetFilename(file), migrateShopeeSpecAutoMatchResume)
}
func migrateShopeeSpecAutoMatchResume(db *gorm.DB, version string) error {
return db.Transaction(func(tx *gorm.DB) error {
if !tx.Migrator().HasColumn(&models.ShopeeSpecAutoMatchRun{}, "ResumeAfterID") {
// GORM AddColumn omits CHECK tags. Inline the portable constraint so
// this stays additive (SQLite otherwise rebuilds tables for checks).
if err := tx.Exec("ALTER TABLE shopee_spec_auto_match_run ADD COLUMN resume_after_id BIGINT NULL CONSTRAINT ck_shopee_spec_auto_match_resume CHECK (resume_after_id IS NULL OR resume_after_id >= 0)").Error; err != nil {
return err
}
}
if !tx.Migrator().HasColumn(&models.ShopeeSpecAutoMatchRun{}, "StopReason") {
if err := tx.Migrator().AddColumn(&models.ShopeeSpecAutoMatchRun{}, "StopReason"); err != nil {
return err
}
}
return tx.Where("version = ?", version).FirstOrCreate(&common.Migration{Version: version}).Error
})
}
@@ -0,0 +1,79 @@
package version_local
import (
"encoding/json"
"testing"
"go-admin/app/goauto/models"
common "go-admin/common/models"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
func TestMigrateShopeeSpecAutoMatchResumePreservesLegacyAndIsIdempotent(t *testing.T) {
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
sqlDB, _ := db.DB()
t.Cleanup(func() { sqlDB.Close() })
if err := db.Exec("CREATE TABLE shopee_spec_auto_match_run (id integer primary key, status varchar(24) NOT NULL)").Error; err != nil {
t.Fatal(err)
}
if err := db.Exec("INSERT INTO shopee_spec_auto_match_run(id,status) VALUES(1,'completed')").Error; err != nil {
t.Fatal(err)
}
if err := db.AutoMigrate(&common.Migration{}); err != nil {
t.Fatal(err)
}
for i := 0; i < 2; i++ {
if err := migrateShopeeSpecAutoMatchResume(db, "test_auto_match_resume"); err != nil {
t.Fatal(err)
}
}
var row models.ShopeeSpecAutoMatchRun
if err := db.First(&row, 1).Error; err != nil {
t.Fatal(err)
}
if row.Status != "completed" || row.ResumeAfterID != nil || row.StopReason != "" {
t.Fatalf("legacy changed: %+v", row)
}
columns, err := db.Migrator().ColumnTypes(&row)
if err != nil {
t.Fatal(err)
}
if len(columns) != 4 {
t.Fatalf("migration must append exactly two columns: %d", len(columns))
}
if err := db.Model(&row).UpdateColumns(map[string]any{"resume_after_id": 0, "stop_reason": "end_of_scan"}).Error; err != nil {
t.Fatal(err)
}
if err := migrateShopeeSpecAutoMatchResume(db, "test_auto_match_resume"); err != nil {
t.Fatal(err)
}
row = models.ShopeeSpecAutoMatchRun{}
if err := db.First(&row, 1).Error; err != nil {
t.Fatal(err)
}
if row.ResumeAfterID == nil || *row.ResumeAfterID != 0 || row.StopReason != "end_of_scan" {
t.Fatalf("zero overwritten: %+v", row)
}
var count int64
if err := db.Model(&common.Migration{}).Count(&count).Error; err != nil || count != 1 {
t.Fatalf("versions=%d err=%v", count, err)
}
raw, err := json.Marshal(row)
if err != nil {
t.Fatal(err)
}
var oldClient struct {
ID uint64 `json:"id"`
Status string `json:"status"`
}
if err := json.Unmarshal(raw, &oldClient); err != nil || oldClient.ID != 1 || oldClient.Status != "completed" {
t.Fatalf("old client=%+v err=%v", oldClient, err)
}
if err := db.Exec("UPDATE shopee_spec_auto_match_run SET resume_after_id = -1 WHERE id = 1").Error; err == nil {
t.Fatal("negative checkpoint accepted")
}
}
@@ -68,7 +68,7 @@ import { createRequestId } from '@/utils/request-id'
export default { export default {
name: 'GoAutoSybInnerCodes', name: 'GoAutoSybInnerCodes',
setup() { return { FolderOpened, RefreshLeft, Search, Upload } }, setup() { return { FolderOpened, RefreshLeft, Search, Upload } },
data() { const now = new Date(); const today = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`; return { loading: false, importing: false, busy: false, matchSubmitting: false, loadError: '', items: [], total: 0, selected: [], selectedFile: null, dateRange: [today, today], rowActionId: 0, pollTimer: null, activeJob: null, activeBatch: null, query: { page: 1, pageSize: 100, dateFrom: today, dateTo: today, keyword: '' }, applyDialog: { open: false, loading: false, saving: false, ready: false, error: '', ids: [], selectedCount: 0, preview: {}}, deleteDialog: { open: false, saving: false }, detail: { open: false, loading: false, item: null }} }, data() { const now = new Date(); const today = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`; return { loading: false, importing: false, busy: false, matchSubmitting: false, loadError: '', items: [], total: 0, selected: [], selectedFile: null, dateRange: [today, today], rowActionId: 0, pollTimer: null, activeJob: null, activeBatch: null, query: { page: 1, pageSize: 200, dateFrom: today, dateTo: today, keyword: '' }, applyDialog: { open: false, loading: false, saving: false, ready: false, error: '', ids: [], selectedCount: 0, preview: {}}, deleteDialog: { open: false, saving: false }, detail: { open: false, loading: false, item: null }} },
computed: { computed: {
matchingBusy() { return this.matchSubmitting || ['pending', 'running'].includes(this.activeJob?.status) }, matchingBusy() { return this.matchSubmitting || ['pending', 'running'].includes(this.activeJob?.status) },
matchEligibleRows() { const ids = new Set(this.selected.map(item => item.id)); return this.items.filter(item => ids.has(item.id) && ['pending', 'failed', 'skipped'].includes(item.status)) }, matchEligibleRows() { const ids = new Set(this.selected.map(item => item.id)); return this.items.filter(item => ids.has(item.id) && ['pending', 'failed', 'skipped'].includes(item.status)) },
@@ -0,0 +1,81 @@
import { expect, test } from '@playwright/test'
test('page-local 200 default, pagination, filters, selection and other-page isolation', async ({ page, context }) => {
await page.setViewportSize({ width: 1920, height: 1080 })
await context.addCookies([{ name: 'Admin-Token', value: 'isolated-test-token', domain: 'localhost', path: '/' }])
const lists: URL[] = []; const shopLists: URL[] = []; const previews: number[][] = []; const writes: string[] = []
const rows = Array.from({ length: 205 }, (_, i) => ({ id: i + 1, businessDate: '2026-10-07', orderNumber: `MOCK-${i + 1}`, status: 'ready', specRaw: '模拟规格', items: [{ ordinal: 1, code: `CODE-${i + 1}` }] }))
await page.route('**/api/**', async route => {
const request = route.request(); const url = new URL(request.url()); const path = url.pathname
if (path.startsWith('/src/api/')) return route.continue()
const reply = (data: unknown) => route.fulfill({ json: { code: 200, data } })
if (path.endsWith('/api/v1/getinfo')) return reply({ roles: ['purchaser'], name: '模拟采购员', avatar: '', permissions: [] })
if (path.endsWith('/api/v1/menurole')) return reply([{ path: '/workbench', component: 'Layout', menuName: 'Workbench', title: '工作台', visible: '0', children: [
{ path: '/syb-inner-codes', component: '/goauto/syb-inner-codes/index', menuName: 'GoAutoSybInnerCodes', title: '档口入库码', visible: '0' },
{ path: '/syb-shops', component: '/goauto/syb-shops/index', menuName: 'GoAutoSybShops', title: '店铺管理', visible: '0' }
] }])
if (path.endsWith('/syb-inner-codes') && request.method() === 'GET') {
lists.push(url)
const pageNo = Number(url.searchParams.get('page')); const size = Number(url.searchParams.get('pageSize'))
const filtered = url.searchParams.get('keyword') === 'empty' ? [] : rows
return reply({ items: filtered.slice((pageNo - 1) * size, pageNo * size), total: filtered.length, page: pageNo, pageSize: size })
}
if (path.endsWith('/apply-preview')) {
const ids = request.postDataJSON().ids; previews.push(ids)
return reply({ records: ids.length, inboundCodes: ids.length, placeholderDetails: 0, replaceOldCodes: 0, blocked: [] })
}
if (path.endsWith('/syb-shops') && request.method() === 'GET') { shopLists.push(url); return reply({ items: [], total: 0 }) }
if (request.method() !== 'GET') writes.push(path)
return reply({ items: [], total: 0 })
})
const tableRows = page.locator('.el-table__body-wrapper tbody tr')
const pagination = page.locator('.pagination-container')
const started = Date.now()
await page.goto('/#/syb-inner-codes')
await expect(tableRows).toHaveCount(200)
console.log(`mock 200-row initial navigation and render: ${Date.now() - started}ms`)
expect(lists[0].searchParams.get('page')).toBe('1')
expect(lists[0].searchParams.get('pageSize')).toBe('200')
await expect(pagination.locator('.el-select')).toContainText('200')
await page.locator('.el-table__header-wrapper .el-checkbox').first().click()
await page.getByRole('button', { name: '回写(200)', exact: true }).click()
await expect.poll(() => previews.length).toBe(1)
expect(previews[0]).toEqual(rows.slice(0, 200).map(row => row.id))
await page.getByRole('dialog').getByRole('button', { name: '取消', exact: true }).click()
expect(writes).toEqual([])
await pagination.locator('.btn-next').click()
await expect(tableRows).toHaveCount(5)
await expect(page.getByRole('cell', { name: 'MOCK-201', exact: true })).toBeVisible()
expect(lists.at(-1)!.searchParams.get('page')).toBe('2')
await expect(page.getByRole('button', { name: '回写(0)', exact: true })).toBeDisabled()
await page.getByRole('button', { name: '搜索', exact: true }).click()
await expect(tableRows).toHaveCount(200)
for (const size of [20, 50, 100, 200]) {
await pagination.locator('.el-select').click()
await page.getByRole('option', { name: new RegExp(`^${size}\\s*条/页$`) }).click()
await expect(tableRows).toHaveCount(size)
expect(lists.at(-1)!.searchParams.get('pageSize')).toBe(String(size))
for (const name of ['搜索', '清除']) {
const before = lists.length
await page.getByRole('button', { name, exact: true }).click()
await expect.poll(() => lists.length).toBeGreaterThan(before)
expect(lists.at(-1)!.searchParams.get('pageSize')).toBe(String(size))
}
}
await page.getByPlaceholder('订单号或入库码').fill('empty')
await page.getByRole('button', { name: '搜索', exact: true }).click()
await expect(tableRows).toHaveCount(0)
await expect(pagination).not.toBeVisible()
await page.reload()
await expect(tableRows).toHaveCount(200)
expect(lists.at(-1)!.searchParams.get('pageSize')).toBe('200')
await page.goto('/#/syb-shops')
await expect.poll(() => shopLists.length).toBeGreaterThan(0)
expect(shopLists[0].searchParams.get('pageSize')).toBe('20')
expect(writes).toEqual([])
})
@@ -0,0 +1,40 @@
const { test } = require('node:test')
const assert = require('node:assert/strict')
const fs = require('node:fs')
const path = require('node:path')
const page = fs.readFileSync(path.join(__dirname, '../../src/views/goauto/syb-inner-codes/index.vue'), 'utf8')
const source = page.split('<script>')[1].split('</script>')[0].replace(/^import .*$/gm, '').replace('export default', 'return')
const options = new Function(source)()
test('inner code page defaults to 200 and keeps a manual size through search and clear', async () => {
const vm = { ...options.data(), ...options.methods }
const requests = []
vm.load = async () => { requests.push({ ...vm.query }) }
const now = new Date()
const today = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`
assert.equal(vm.query.page, 1)
assert.equal(vm.query.pageSize, 200)
assert.deepEqual(vm.dateRange, [today, today])
assert.match(page, /:page-sizes="\[20, 50, 100, 200\]"/)
vm.query.pageSize = 50
vm.query.page = 4
vm.query.keyword = 'code'
vm.dateRange = ['2026-10-01', '2026-10-02']
await vm.search()
assert.equal(requests[0].page, 1)
assert.equal(requests[0].pageSize, 50)
vm.query.page = 3
await vm.clearFilters()
assert.equal(requests[1].page, 1)
assert.equal(requests[1].pageSize, 50)
assert.equal(requests[1].keyword, '')
assert.equal(requests[1].dateFrom, '')
assert.equal(requests[1].dateTo, '')
const freshVm = { ...options.data() }
assert.equal(freshVm.query.pageSize, 200)
})