Compare commits

...
7 changed files with 191 additions and 19 deletions
+2 -2
View File
@@ -11,8 +11,8 @@ android {
applicationId = "cn.ilapage.goauto.agent"
minSdk = 23
targetSdk = 34
versionCode = 60
versionName = "0.9.47"
versionCode = 62
versionName = "0.9.49"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
@@ -302,14 +302,62 @@ class PurchaseLiveAutomation(
click(save.single(), "保存地址")
val savedEvidence = waitForStableAddressEditorExit()
if (!hasFinalSavedAddressEvidence(savedEvidence, expected, suffix)) {
if (!driver.backPurchase()) fail("PURCHASE_ADDRESS_UPDATE_FAILED", "地址保存后无法返回订单页面,未创建订单")
waitFor("PURCHASE_ADDRESS_SAVE_TIMEOUT", "地址保存后无法返回订单页面,未创建订单") {
hasFinalSavedAddressEvidence(it, expected, suffix)
if (isPurchaseConfirmationPanel(savedEvidence)) {
restoreFinalEvidenceInCurrentPanel(savedEvidence, expected, suffix)
} else {
if (!driver.backPurchase()) fail("PURCHASE_ADDRESS_UPDATE_FAILED", "地址保存后无法返回订单页面,未创建订单")
waitFor("PURCHASE_ADDRESS_SAVE_TIMEOUT", "地址保存后无法返回订单页面,未创建订单") {
hasFinalSavedAddressEvidence(it, expected, suffix)
}
}
}
return ShippingAddressProof(expected, suffix)
}
private fun isPurchaseConfirmationPanel(snapshot: UiSnapshot): Boolean {
if (snapshot.packageName != PDD_PACKAGE || shippingAddressEditors(snapshot).isNotEmpty()) return false
val screen = PddScreenParser.parse(snapshot, PurchaseRehearsalExecutor.DEFAULT_COLLECTOR, "", null)
return screen.specPanelType in setOf(
SpecPanelType.NORMAL_SCROLLABLE,
SpecPanelType.NON_SCROLLABLE_CONFIRMATION,
SpecPanelType.ORDER_CONFIRMATION,
)
}
private fun restoreFinalEvidenceInCurrentPanel(
initial: UiSnapshot,
expected: String,
suffix: String,
) {
var snapshot = initial
var previousSignature: String? = null
repeat(ADDRESS_CONFIRMATION_SCROLL_LIMIT) { attempt ->
if (hasFinalSavedAddressEvidence(snapshot, expected, suffix)) return
if (!isPurchaseConfirmationPanel(snapshot)) {
fail("PURCHASE_ADDRESS_SAVE_TIMEOUT", "地址保存后采购面板发生变化,未创建订单")
}
val panels = purchasePanelScrollTargets(snapshot)
if (panels.size != 1) {
fail("PURCHASE_ADDRESS_ENTRY_AMBIGUOUS", "地址保存后没有找到唯一的规格面板滚动区域,未创建订单")
}
val panel = panels.single()
val signature = viewportSignature(snapshot, panel)
if (signature == previousSignature) {
fail("PURCHASE_ADDRESS_SAVE_TIMEOUT", "地址保存后规格面板未找到完整订单确认信息,未创建订单")
}
previousSignature = signature
if (!driver.swipePurchaseIn(panel, SwipeDirection.DOWN, 350)) {
fail("PURCHASE_ADDRESS_SAVE_TIMEOUT", "地址保存后规格面板无法定位订单确认信息,未创建订单")
}
if (attempt < ADDRESS_CONFIRMATION_SCROLL_LIMIT - 1) pause(ADDRESS_CONFIRMATION_SCROLL_INTERVAL_MS)
snapshot = driver.capture()
pageProblem(snapshot)
}
if (!hasFinalSavedAddressEvidence(snapshot, expected, suffix)) {
fail("PURCHASE_ADDRESS_SAVE_TIMEOUT", "地址保存后规格面板未找到完整订单确认信息,未创建订单")
}
}
private fun hasSavedAddressEvidence(snapshot: UiSnapshot, expected: String, suffix: String): Boolean {
if (snapshot.packageName != PDD_PACKAGE || shippingAddressEditors(snapshot).isNotEmpty()) return false
val visible = snapshot.nodes.filter { it.visible }
@@ -372,7 +420,7 @@ class PurchaseLiveAutomation(
private fun viewportSignature(snapshot: UiSnapshot, panel: SnapshotNode): String = snapshot.nodes
.filter { node -> node.visible && inside(node.bounds, panel.bounds) }
.joinToString("|") { node ->
listOf(node.className.orEmpty(), node.bounds.left, node.bounds.top, node.bounds.right, node.bounds.bottom, node.clickable, node.scrollable).joinToString(":")
listOf(node.path, node.className.orEmpty(), node.bounds.left, node.bounds.top, node.bounds.right, node.bounds.bottom, node.clickable, node.scrollable).joinToString(":")
}
private fun inside(child: NodeBounds, parent: NodeBounds): Boolean =
@@ -535,5 +583,7 @@ class PurchaseLiveAutomation(
const val ORDER_RESULT_SAMPLE_INTERVAL_MS = 200L
const val SPEC_CONFIRMATION_MAX_SAMPLES = 20
const val SPEC_CONFIRMATION_SAMPLE_INTERVAL_MS = 100L
const val ADDRESS_CONFIRMATION_SCROLL_LIMIT = 5
const val ADDRESS_CONFIRMATION_SCROLL_INTERVAL_MS = 500L
}
}
@@ -83,10 +83,15 @@ class PurchaseRehearsalExecutor(
val specSelectionProofs = mutableMapOf<String, ExactSpecSelectionProof>()
val live = PurchaseLiveAutomation(driver, pause)
for (action in rule.actions) {
// spec_probe already opened this task's URL and the server reserves
// the device while matching. Skip the whole navigation action,
// including its configured wait/swipe hooks, in phase two.
if (input.phase == "purchase" && action.type == PurchaseActionType.OPEN_PRODUCT) continue
// The immediate phase-two handoff can reuse the PDD page retained by
// spec_probe. A later manual retry may start from Agent (or another
// unrelated screen), so only skip navigation when a fresh snapshot
// still carries safe PDD product/spec evidence.
if (
input.phase == "purchase" &&
action.type == PurchaseActionType.OPEN_PRODUCT &&
canReuseCurrentProduct(input)
) continue
stepChanged(action.type.wireName)
val failure = when (action.type) {
PurchaseActionType.OPEN_PRODUCT -> openProduct(input, action)
@@ -157,6 +162,11 @@ class PurchaseRehearsalExecutor(
else failure("PURCHASE_RULE_INVALID", "正式采购规则缺少核单动作")
}
private fun canReuseCurrentProduct(input: PurchaseExecutionInput): Boolean =
currentScreen(input).let { screen ->
screen.problem == null && screen.hasPurchaseProductEvidence()
}
private fun validateBeforeDeviceAction(
input: PurchaseExecutionInput,
rule: PurchaseRule,
@@ -104,6 +104,31 @@ class PurchaseLiveAutomationTest {
assertEquals(0, driver.submitClicks)
}
@Test
fun `saved address returning to spec panel is recovered without pressing back`() {
val driver = LiveDriver(saveReturnsToSpecPanel = true)
val automation = PurchaseLiveAutomation(driver, pause = {})
val address = automation.updateShippingAddress("_cg91")
val final = automation.finalConfirmation(input().copy(addressSuffix = "_cg91"), address)
assertEquals("_cg91", final.addressSuffix)
assertEquals(0, driver.backCount)
assertEquals(1, driver.scopedSwipes)
assertEquals(0, driver.submitClicks)
}
@Test
fun `saved address stuck on spec panel fails without backing out or submitting`() {
val driver = LiveDriver(saveReturnsToSpecPanel = true, savedSpecPanelRecoveryStuck = true)
val error = runCatching { PurchaseLiveAutomation(driver, pause = {}).updateShippingAddress("_cg92") }
.exceptionOrNull() as PurchaseLiveException
assertEquals("PURCHASE_ADDRESS_SAVE_TIMEOUT", error.code)
assertEquals(0, driver.backCount)
assertEquals(0, driver.submitClicks)
}
@Test
fun `edit field containing suffix cannot impersonate post save evidence`() {
val driver = LiveDriver(saveStaysInEdit = true)
@@ -526,6 +551,8 @@ class PurchaseLiveAutomationTest {
private val saveStaysInEdit: Boolean = false,
private val savedTransitionWithoutLegacyContext: Boolean = false,
private val savedTransitionHidesSuffix: Boolean = false,
private val saveReturnsToSpecPanel: Boolean = false,
private val savedSpecPanelRecoveryStuck: Boolean = false,
private val wechatLoginAfterSubmit: Boolean = false,
private val wechatRestoreStuck: Boolean = false,
private val orderEvidenceBelowFold: Boolean = false,
@@ -581,6 +608,21 @@ class PurchaseLiveAutomationTest {
node("transition-title", "选择收货信息"),
node("address-summary", if (savedTransitionHidesSuffix) "已保存的收货信息" else address.substring(address.lastIndexOf("_cg"))),
))
"post-save-spec" -> snapshot(listOf(
node("root", "", bounds = NodeBounds(0, 0, 1080, 2200)),
node("panel", "", scrollable = true, bounds = NodeBounds(0, 400, 1080, 2100)),
node("panel-title", "确认款式", bounds = NodeBounds(20, 396, 300, 430)),
node("panel/price", "¥20.00", parentPath = "panel", bounds = NodeBounds(20, 460, 300, 520)),
node("panel/selected", "已选 黑色 XL", parentPath = "panel", bounds = NodeBounds(20, 540, 700, 600)),
node("panel/color-heading", "颜色分类", parentPath = "panel", bounds = NodeBounds(20, 650, 300, 700)),
node("panel/color", "黑色", clickable = true, parentPath = "panel", bounds = NodeBounds(20, 720, 220, 790)),
node("panel/size-heading", "尺码", parentPath = "panel", bounds = NodeBounds(20, 850, 300, 900)),
node("panel/size", "XL", clickable = true, parentPath = "panel", bounds = NodeBounds(20, 920, 220, 990)),
node("panel/quantity", "2", className = "android.widget.EditText", parentPath = "panel", bounds = NodeBounds(400, 1050, 600, 1120)),
node("panel/confirm", "确定", clickable = true, parentPath = "panel", bounds = NodeBounds(20, 1200, 500, 1280)),
node("submit-parent", "", clickable = true, bounds = NodeBounds(20, 1900, 1000, 2100)),
node("submit", "提交订单", parentPath = "submit-parent", bounds = NodeBounds(520, 1940, 980, 2040)),
))
"order" -> snapshot(listOf(node("status", "待付款"), node("order", "订单号:PDD-202608210001"), node("time", "下单时间:2026-08-21 10:30:00"), node("pay", "立即支付", clickable = true)))
"order-folded" -> snapshot(listOf(node("status", "待付款"), node("pay", "立即支付", clickable = true)))
"order-folded-payment-activity" -> UiSnapshot(PDD, "com.xunmeng.pinduoduo.app_pay.core.PayActivity", listOf(
@@ -639,7 +681,13 @@ class PurchaseLiveAutomationTest {
when (target.label) {
"138****5678" -> page = "panel"
"修改" -> page = "edit"
"保存" -> if (!saveStaysInEdit) page = if (savedTransitionWithoutLegacyContext) "saved-transition" else "panel"
"保存" -> if (!saveStaysInEdit) {
page = when {
saveReturnsToSpecPanel -> "post-save-spec"
savedTransitionWithoutLegacyContext -> "saved-transition"
else -> "panel"
}
}
"提交订单" -> {
submitClicks++
page = when {
@@ -677,6 +725,7 @@ class PurchaseLiveAutomationTest {
override fun swipePurchaseIn(target: SnapshotNode, direction: SwipeDirection, durationMs: Long): Boolean {
scopedSwipes++
if (target.path == "panel" && direction == SwipeDirection.DOWN) addressVisible = true
if (page == "post-save-spec" && direction == SwipeDirection.DOWN && !savedSpecPanelRecoveryStuck) page = "confirmation"
return true
}
override fun backPurchase(): Boolean {
@@ -89,6 +89,57 @@ class PurchaseRehearsalExecutorTest {
assertFalse(pauses.contains(700))
}
@Test
fun `manual purchase retry from agent reopens the task product url`() {
val driver = FakePurchaseDriver(initiallyInAgent = true)
var openCount = 0
val outcome = PurchaseRehearsalExecutor(
driver,
openLink = {
openCount++
driver.leaveAgentAndOpenBrowser()
true
},
probeSpecs = { null },
pause = {},
).execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals("rehearsal_completed", outcome.resultType)
assertEquals(1, openCount)
assertEquals(1, driver.openClickCount)
}
@Test
fun `purchase phase reopens when pdd foreground has no product evidence`() {
val driver = FakePurchaseDriver(loadingPddCaptures = 1)
var openCount = 0
val outcome = PurchaseRehearsalExecutor(
driver,
openLink = { openCount++; driver.browser = true; true },
probeSpecs = { null },
pause = {},
).execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals("rehearsal_completed", outcome.resultType)
assertEquals(1, openCount)
assertEquals(1, driver.openClickCount)
}
@Test
fun `purchase phase does not reuse a pdd login page`() {
val driver = FakePurchaseDriver(pddProblemLabels = listOf("手机号登录", "登录后继续"))
var openCount = 0
val outcome = PurchaseRehearsalExecutor(
driver,
openLink = { openCount++; driver.browser = true; true },
probeSpecs = { null },
pause = {},
).execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals("PDD_LOGIN_REQUIRED", outcome.errorCode)
assertEquals(1, openCount)
}
@Test
fun `price range failure reports the observed unit price`() {
val driver = FakePurchaseDriver()
@@ -622,14 +673,16 @@ class PurchaseRehearsalExecutorTest {
}
@Test
fun `verify product waits for product evidence instead of a visible loading frame`() {
fun `purchase reopens and waits for product evidence instead of a visible loading frame`() {
val pauses = mutableListOf<Long>()
val driver = FakePurchaseDriver(loadingPddCaptures = 1)
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = pauses::add)
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals("rehearsal_completed", outcome.resultType)
assertEquals(2, pauses.count { it == 100L })
// Two stable reads belong to reopening the product; verifyProduct then
// independently requires its second stable read before continuing.
assertEquals(3, pauses.count { it == 100L })
}
@Test
@@ -994,8 +1047,10 @@ class PurchaseRehearsalExecutorTest {
private val nonScrollablePanel: Boolean = false,
private val unrecognizedPanel: Boolean = false,
private val purchaseSwipeSucceeds: Boolean = true,
initiallyInAgent: Boolean = false,
) : PurchaseUiDriver {
var browser = false
private var inAgent = initiallyInAgent
var panel = false
var color: String? = null
var size: String? = initialSize
@@ -1019,6 +1074,9 @@ class PurchaseRehearsalExecutorTest {
val clickedPaths = mutableListOf<String>()
override fun capture(): UiSnapshot {
if (inAgent) {
return UiSnapshot("cn.ilapage.goauto.agent", "MainActivity", listOf(node("content", "", 0, 0, 1080, 2200)))
}
if (browser && !panel && color == null && size == null) {
browserCaptureCount++
if (browserCaptureCount <= browserOpenVisibleAfterCaptures) {
@@ -1145,6 +1203,11 @@ class PurchaseRehearsalExecutorTest {
return UiSnapshot(PDD, ACTIVITY, nodes)
}
fun leaveAgentAndOpenBrowser() {
inAgent = false
browser = true
}
override fun clickFresh(target: SnapshotNode): FreshActionResult {
clicked += target.label
clickedPaths += target.path
+3 -3
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Business-Rules-and-Glossary
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Business-Rules-and-Glossary.-
wiki_revision: 66dddbe756fd8b3cd89fc3d0a2fda69deac74af3
synchronized_at: 2026-09-05T08:45:24Z
wiki_revision: 240e8fb9e365d534bbf0c7b2cd8ff1438bc8857b
synchronized_at: 2026-09-05T09:03:48Z
<!-- gitea-wiki-mirror:end -->
# 业务规则与术语
@@ -341,7 +341,7 @@ synchronized_at: 2026-09-05T08:45:24Z
## SYB 采购强制当次规格探测(#215)
- 每个新 SYB 采购任务固定执行“首趟只读探测 → 服务端确定性优先/必要时 AI → 固化任务级精确规格 → 第二趟正式采购”。首趟只打开一次浏览器商品链接;匹配期间当前设备保留给同一任务,不领取其他采购或采集任务;第二趟复用 PDD 当前页,不再次打开链接,也不严格核验标题、goodsId 或页面指纹,但仍要求 PDD 包名与商品/规格/订单页面结构安全证据。已有长期映射只作商品档案事实,不直接进入任务执行规格。
- 每个新 SYB 采购任务固定执行“首趟只读探测 → 服务端确定性优先/必要时 AI → 固化任务级精确规格 → 第二趟正式采购”。首趟只打开一次浏览器商品链接;匹配期间当前设备保留给同一任务,不领取其他采购或采集任务。连续进入第二趟且当前仍有 PDD 商品页或规格面板强证据时复用当前页、不再次打开链接;手动同任务重试,或当前处于 Agent、其他应用及缺少上述强证据时,重新打开任务固化的商品 URL。复用或重开均不严格核验标题、goodsId 或页面指纹,但仍要求 PDD 包名与商品/规格/订单页面结构安全证据。已有长期映射只作商品档案事实,不直接进入任务执行规格。
- 首趟候选与 `taskId`、`taskAttemptId`、`deviceId`、规则快照哈希和幂等结果哈希关联;第二趟失败不得回到首趟循环探测。备货 `stock/direct_select` 没有 SYB 目标规格,继续使用用户逐字选择的档案规格,不进入本规则。
- 候选和 Provider 结果仅保存颜色、尺码原始标签及结构化决策,不保存控件树、整屏截图、账号、地址、订单或支付数据;付款仍永久禁止。
+3 -3
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Android-Agent-API-Contract
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Android-Agent-API-Contract.-
wiki_revision: 0c6d95d07adc7ec24982b195e53059151da4f451
synchronized_at: 2026-09-05T08:47:13Z
wiki_revision: f3242938de4ac55c47f5c6eebd69184024e6a0ea
synchronized_at: 2026-09-05T09:04:42Z
<!-- gitea-wiki-mirror:end -->
# MVP 共享 API 契约
@@ -535,7 +535,7 @@ Admin 列表与详情由 #35 实现;#67 增加 `shopeeOrderNoSnapshot` 的列
| `POST` | `/api/agent/v1/purchase-tasks/{taskId}/order-submit-started` | 创建订单前先落不可逆标记;演练任务和 `spec_probe` attempt 永远拒绝 |
| `POST` | `/api/agent/v1/purchase-tasks/{taskId}/result` | 请求体携带 `taskAttemptId` 和 `requestId`;幂等提交演练、规格探测、订单或失败结果 |
自 #215 起,新建 SYB 采购任务不再从 PDD 档案创建持久匹配工作项,也不在首次派发前调用外部 AI;部署前已存在的 `purchase_spec_match_work_item` 继续按原状态兼容处理。新任务首次 `start` 固定得到 `phase=spec_probe`,Android 通过浏览器打开任务链接一次并经既有结果字段回传当次候选;匹配成功后的第二次 `start` 才得到 `phase=purchase` 和服务端固化的精确 PDD 原始标签。第二阶段直接复用首趟保留的 PDD 页面,不再次打开浏览器链接,也不以标题、goodsId 或页面指纹做严格同页校验;仍必须通过 PDD 包名和商品/规格/订单页面结构安全证据。Android 不接收 AI 配置或自由决策权限。
自 #215 起,新建 SYB 采购任务不再从 PDD 档案创建持久匹配工作项,也不在首次派发前调用外部 AI;部署前已存在的 `purchase_spec_match_work_item` 继续按原状态兼容处理。新任务首次 `start` 固定得到 `phase=spec_probe`,Android 通过浏览器打开任务链接一次并经既有结果字段回传当次候选;匹配成功后的第二次 `start` 才得到 `phase=purchase` 和服务端固化的精确 PDD 原始标签。连续第二阶段仅在当前 PDD 页面仍有商品页或规格面板强证据时复用首趟页面、不再次打开浏览器链接;手动同任务重试,或当前为 Agent、其他应用及缺少上述强证据时,Android 重新打开任务 `urlSnapshot`。两种路径都不以标题、goodsId 或页面指纹做严格同页校验,仍必须通过 PDD 包名和商品/规格/订单页面结构安全证据。Android 不接收 AI 配置或自由决策权限。
结果提交至少关联 `taskId`、`taskAttemptId`、`deviceId`、规则快照哈希和结构化结果。相同 attempt 的相同结果重复提交返回同一事实;不同内容拒绝覆盖。每个新 SYB 采购任务的第一趟只读遍历当次 PDD 规格面板并提交颜色、尺码原始候选,随后释放数据库租约和已知账号运行守卫并进入 `spec_probe_pending`,但服务端调度与 Agent 必须把当前设备保留给同一采购流程:`next` 返回该等待任务,Agent 只轮询等待,不领取其他采购或采集任务。服务端只以任务冻结的 SYB 目标和当次候选先做繁简、空白/全半角/大小写及公斤/斤的唯一确定性匹配,仍无唯一结果才调用 AI。AI 的颜色和尺码必须逐字属于当次对应候选,否则按无匹配失败。第二趟只会收到服务端固化的精确 PDD 原始标签;Agent 复用首趟仍打开的页面,只在已打开的规格面板内做有限纵向滑动,每次重新读取节点并按完整规范化文字精确点击,连续没有新证据或达到上限即停止。尺码的任务目标与页面值在选择边界使用同一安全尾价规范化;不改写任务快照,规范化为空、仍含货币符号或多个原始候选折叠为同一值时安全失败。