Compare commits

...
5 changed files with 144 additions and 18 deletions
@@ -194,16 +194,18 @@ class GoAutoAccessibilityService : AccessibilityService(), UiDriver, PddCollecto
return swipeNode(node, direction)
}
/** Backfill never uses ancestor clicks or coordinate/gesture fallbacks. */
fun clickBackfill(target: SnapshotNode): Boolean {
/** Only the unique All navigation tab may use a gesture; order actions never do. */
fun clickBackfill(target: SnapshotNode, allTabConfirmed: Boolean = false): Boolean {
val page = capture()
BackfillPagePolicy.validate(page)
val fresh = page.nodes.singleOrNull { it.path == target.path && it.label == target.label &&
it.bounds == target.bounds && it.className == target.className } ?: return false
val allowed = BackfillPagePolicy.cards(page).any { it.path == fresh.path } ||
val allTab = BackfillPagePolicy.allTab(page)?.path == fresh.path
val allowed = BackfillPagePolicy.cards(page, allTabConfirmed).any { it.path == fresh.path } ||
BackfillPagePolicy.expansion(page)?.path == fresh.path ||
(fresh.label == "全部" && page.nodes.any { it.label in setOf("我的订单", "全部订单") })
if (!allowed || !BackfillPagePolicy.safe(page, fresh)) return false
if (allTab && !fresh.clickable) return dispatchCenterTap(Rect(fresh.bounds.left, fresh.bounds.top, fresh.bounds.right, fresh.bounds.bottom))
if (!allowed || (!allTab && !BackfillPagePolicy.safe(page, fresh))) return false
val root = rootInActiveWindow ?: return false
if (root.packageName?.toString() != BackfillPagePolicy.PDD) return false
var node = root
@@ -21,6 +21,26 @@ object BackfillPagePolicy {
fun list(page: UiSnapshot): Boolean = page.nodes.any { it.visible && it.label == "全部" && it.selected } &&
page.nodes.any { it.visible && it.label in setOf("我的订单", "全部订单") }
fun detail(page: UiSnapshot): Boolean = page.nodes.any { it.visible && (it.label == "订单详情" || it.label.contains("订单编号")) }
// Only the navigation tab may use a bounded gesture, never an order action.
fun allTab(page: UiSnapshot): SnapshotNode? {
if (detail(page) || page.nodes.none { it.visible && it.label in setOf("我的订单", "全部订单") }) return null
val tab = page.nodes.filter { it.visible && it.enabled && it.label == "全部" }.singleOrNull() ?: return null
val peers = page.nodes.filter { it.visible && it.label in setOf("待付款", "待分享", "待发货", "待收货", "待评价") &&
it.bounds.top < tab.bounds.bottom && it.bounds.bottom > tab.bounds.top }
val centerX = tab.bounds.left.toLong() + tab.bounds.width / 2
if (peers.map { it.label }.distinct().size < 2 || peers.any {
it.bounds.width <= 0 || it.bounds.left.toLong() + it.bounds.width / 2 <= centerX ||
centerX >= it.bounds.left && centerX < it.bounds.right
}) return null
// WebView navigation labels can overlap by a few pixels. Exclude only
// these verified peers (e.g. 待付款), not actual payment/action nodes.
val navigationPage = page.copy(nodes = page.nodes.filter { it !in peers })
return tab.takeIf { safe(navigationPage, it.copy(clickable = true)) }
}
fun confirmedList(page: UiSnapshot, tabConfirmed: Boolean): Boolean = list(page) ||
(tabConfirmed && allTab(page) != null && page.nodes.none { it.visible && it.selected &&
it.label in setOf("待付款", "待分享", "待发货", "待收货", "待评价") })
fun expansion(page: UiSnapshot): SnapshotNode? {
if (!detail(page) || page.nodes.none { it.visible && it.label.contains("订单编号") }) return null
val order = page.nodes.first { it.visible && it.label.contains("订单编号") }
@@ -28,8 +48,8 @@ object BackfillPagePolicy {
return page.nodes.filter { it.label == "展开" && safe(page, it) && it.bounds.top >= order.bounds.top &&
it.bounds.top < snapshot.bounds.bottom && it.bounds.bottom > snapshot.bounds.top }.singleOrNull()
}
fun cards(page: UiSnapshot): List<SnapshotNode> {
if (!list(page)) return emptyList()
fun cards(page: UiSnapshot, tabConfirmed: Boolean = false): List<SnapshotNode> {
if (!confirmedList(page, tabConfirmed)) return emptyList()
return page.nodes.filter { node ->
if (!safe(page, node)) return@filter false
if (node.label in setOf("订单详情", "查看详情")) return@filter true
@@ -64,17 +84,38 @@ class OrderBackfillScanner(
private val submit: (BackfillItem) -> Unit,
private val progress: (Int) -> Unit,
) {
private var tabConfirmed = false
private fun listReady(page: UiSnapshot) = BackfillPagePolicy.confirmedList(page, tabConfirmed)
private fun awaitOrders(): UiSnapshot {
var previous: String? = null
repeat(8) {
checkActive()
val page = driver.capture()
if (page.packageName == BackfillPagePolicy.PDD && page.activityName?.startsWith(BackfillPagePolicy.PDD) == true) {
BackfillPagePolicy.validate(page)
if (BackfillPagePolicy.list(page) || BackfillPagePolicy.allTab(page) != null) {
val signature = page.nodes.filter { it.visible && it.label in setOf("我的订单", "全部订单", "全部", "待付款", "待发货", "待收货", "待评价") }
.joinToString { "${it.label}:${it.bounds}:${it.selected}" }
if (previous == signature) return page
previous = signature
} else previous = null
} else previous = null
driver.pause()
}
error("等待订单列表加载超时,未完整扫描")
}
fun scan(): String {
checkActive()
tabConfirmed = false
driver.openOrders()
driver.pause()
var page = read()
var page = awaitOrders()
if (!BackfillPagePolicy.list(page)) {
val tab = page.nodes.filter { it.label == "全部" && BackfillPagePolicy.safe(page, it) }.singleOrNull()
check(page.nodes.any { it.label in setOf("我的订单", "全部订单") } && tab != null) { "未识别我的订单-全部" }
val tab = BackfillPagePolicy.allTab(page)
check(tab != null) { "未识别我的订单-全部" }
act { driver.click(tab) }
page = read()
check(BackfillPagePolicy.list(page)) { "无法确认全部订单标签" }
tabConfirmed = true
page = awaitOrders()
check(listReady(page)) { "无法确认全部订单标签" }
}
if (page.nodes.any { it.visible && it.label == "暂无订单" }) return "扫描完成,未发现订单"
val seenCards = mutableSetOf<String>()
@@ -82,8 +123,8 @@ class OrderBackfillScanner(
var noProgress = 0
while (window.checked < OrderBackfillWindow.MAX_ORDERS) {
checkActive()
check(BackfillPagePolicy.list(page)) { "返回后未识别全部订单列表" }
val card = BackfillPagePolicy.cards(page).firstOrNull { BackfillPagePolicy.fingerprint(page, it) !in seenCards }
check(listReady(page)) { "返回后未识别全部订单列表" }
val card = BackfillPagePolicy.cards(page, tabConfirmed).firstOrNull { BackfillPagePolicy.fingerprint(page, it) !in seenCards }
if (card == null) {
if (++noProgress >= 3) return finish("列表无进展或卡片无法安全识别,未完整扫描")
checkActive()
@@ -323,7 +323,10 @@ class AgentForegroundService : Service() {
private fun publishBackfill(state: OrderBackfillState) {
backfillState = state
sendBroadcast(Intent(ACTION_BACKFILL_STATE).setPackage(packageName))
updateNotification(if (state.running) "订单回填 · 已检查 ${state.checked}" else "订单回填已停止,请查看采购页结果")
updateNotification(if (state.running) "订单回填 · 已检查 ${state.checked}" else "订单回填:${state.message}")
if (!state.running) android.os.Handler(android.os.Looper.getMainLooper()).post {
android.widget.Toast.makeText(this, "订单回填:${state.message}", android.widget.Toast.LENGTH_LONG).show()
}
}
private fun requestOrderBackfill(days: String, confirmedAt: Long) {
@@ -358,13 +361,19 @@ class AgentForegroundService : Service() {
checkActive = ::checkActive,
)
val driver = object : cn.ilapage.goauto.agent.automation.BackfillDriver {
private var allTabConfirmed = false
override fun openOrders() {
checkActive()
startActivity(Intent(Intent.ACTION_VIEW, android.net.Uri.parse("https://mobile.yangkeduo.com/orders.html"))
.setPackage(cn.ilapage.goauto.agent.automation.BackfillPagePolicy.PDD).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
}
override fun capture() = accessibility.capture()
override fun click(node: cn.ilapage.goauto.agent.automation.SnapshotNode): Boolean { checkActive(); return accessibility.clickBackfill(node) }
override fun click(node: cn.ilapage.goauto.agent.automation.SnapshotNode): Boolean {
checkActive()
val clicked = accessibility.clickBackfill(node, allTabConfirmed)
if (clicked && node.label == "全部") allTabConfirmed = true
return clicked
}
override fun scroll(): Boolean { checkActive(); return accessibility.scrollBackfill() }
override fun back(): Boolean {
checkActive()
@@ -259,6 +259,74 @@ class OrderBackfillTest {
}
private val item = BackfillItem("_cg7", "TEST-000007", null)
private fun missingSelectedPage() = page(
node("0/0", "我的订单").copy(bounds = NodeBounds(0, 0, 500, 50)),
node("0/1", "全部").copy(clickable = false, bounds = NodeBounds(0, 60, 100, 100)),
node("0/2", "待发货").copy(bounds = NodeBounds(100, 60, 200, 100)),
node("0/3", "待收货").copy(bounds = NodeBounds(200, 60, 300, 100)))
@Test fun `missing selected requires unique safe tab and explicit confirmation`() {
val p = missingSelectedPage()
assertNotNull(BackfillPagePolicy.allTab(p))
assertFalse(BackfillPagePolicy.confirmedList(p, false))
assertTrue(BackfillPagePolicy.confirmedList(p, true))
assertNull(BackfillPagePolicy.allTab(p.copy(nodes = p.nodes + p.nodes[1].copy(path = "0/9"))))
assertFalse(BackfillPagePolicy.confirmedList(p.copy(nodes = p.nodes.map { if (it.label == "待收货") it.copy(selected = true) else it }), true))
assertNull(BackfillPagePolicy.allTab(p.copy(nodes = p.nodes + p.nodes[1].copy(path = "0/9", text = "去支付"))))
}
@Test fun `real navigation bounds tolerate three pixel overlap but never payment actions`() {
val p = page(
node("0/0", "我的订单").copy(bounds = NodeBounds(438, 120, 642, 258)),
node("0/1", "全部").copy(clickable = false, bounds = NodeBounds(0, 258, 201, 372)),
node("0/2", "待付款").copy(clickable = false, bounds = NodeBounds(198, 258, 399, 372)),
node("0/3", "待分享").copy(clickable = false, bounds = NodeBounds(396, 258, 597, 372)),
node("0/4", "待发货").copy(clickable = false, bounds = NodeBounds(594, 258, 795, 372)))
assertEquals(p.nodes[1], BackfillPagePolicy.allTab(p))
assertTrue(BackfillPagePolicy.confirmedList(p, true))
assertFalse(BackfillPagePolicy.confirmedList(p, false))
assertNull(BackfillPagePolicy.allTab(p.copy(nodes = p.nodes +
node("0/9", "去支付").copy(bounds = NodeBounds(198, 258, 399, 372)))))
assertNull(BackfillPagePolicy.allTab(p.copy(nodes = p.nodes.map {
if (it.label == "待付款") it.copy(bounds = NodeBounds(90, 258, 399, 372)) else it
})))
assertNull(BackfillPagePolicy.allTab(p.copy(nodes = p.nodes.map {
if (it.label == "待付款") it.copy(bounds = NodeBounds(-100, 258, 90, 372)) else it
})))
}
@Test fun `scanner waits for stable list and taps only all when selected is missing`() {
var reads = 0
var clicks = 0
val driver = object : BackfillDriver {
override fun openOrders() = Unit
override fun pause() = Unit
override fun capture(): UiSnapshot = if (++reads < 3) UiSnapshot(null, null, emptyList()) else missingSelectedPage()
override fun click(node: SnapshotNode): Boolean { assertEquals("全部", node.label); clicks++; return true }
override fun scroll() = false
override fun back() = false
}
val reason = OrderBackfillScanner(driver, OrderBackfillWindow("2", now), {}, { fail("no upload") }, {}).scan()
assertEquals(1, clicks)
assertTrue(reads >= 6)
assertTrue(reason.contains("未完整扫描"))
}
@Test fun `missing order page times out without any click`() {
var pauses = 0
val driver = object : BackfillDriver {
override fun openOrders() = Unit
override fun pause() { pauses++ }
override fun capture() = UiSnapshot(null, null, emptyList())
override fun click(node: SnapshotNode): Boolean { fail("unexpected click"); return false }
override fun scroll() = false
override fun back() = false
}
try { OrderBackfillScanner(driver, OrderBackfillWindow("2", now), {}, {}, {}).scan(); fail() }
catch (e: IllegalStateException) { assertTrue(e.message!!.contains("加载超时")) }
assertEquals(8, pauses)
}
private fun failure(code: String) = BackfillResult(0, 7, "failed", code, "", 0, null, null, "")
private fun success() = BackfillResult(0, 7, "backfilled", "BACKFILLED", "order_created", 3, item.pddOrderNo, null, "page")
private fun scanner(driver: FakeDriver, items: MutableList<BackfillItem>) = OrderBackfillScanner(driver, OrderBackfillWindow("2", now), {}, items::add, {})
+8 -2
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: f353b476add5081d4d7a1493e5454b6400518923
synchronized_at: 2026-09-18T02:18:13Z
wiki_revision: 4b61d5dcc75c459831e6b1f0d556b09d6acd0310
synchronized_at: 2026-09-18T02:58:16Z
<!-- gitea-wiki-mirror:end -->
# 业务规则与术语
@@ -630,3 +630,9 @@ Web 唯一展示位置为“采集采购 → SYB 同步记录”:列表状态
订单回填状态独立于采购成功、支付复核及物流 writeback_status。实付金额只存 Admin,SYB cost=0 为接口固定参数,不以金额推断付款。SYB 接口会同时更新采购状态/平台/时间,不能视作纯展示修改。
采购管理增加独立状态列、批量回填和详情补偿;复用既有访问权限,不增支付确认或审批。批量受理与最终成功分开展示;重试采购和回填分别筛选勾选项。远端无原子CAS,对系统外人工并发修改/超长延迟请求不能承诺绝对互斥;有冲突应人工核对,禁止强制覆盖。
## Agent 回填订单入口兼容(#307)
- 打开我的订单后最多采样8次,每次间隔1秒,连续两次订单导航结构一致才继续;登录/风控仍立即停止,超时给出明确原因。
- 保留全部标签selected=true的原路径。PDD不提供selected属性时,要求订单标题、唯一全部标签以及同排至少两个订单状态标签;仅该安全导航标签允许一次中心手势,不扩大到订单卡片、地址、支付或其他操作。
- 本轮主动点击全部后复核订单导航结构;本轮上下文不能跨扫描复用,出现其他已选中订单状态时停止。卡片识别与支付禁令保持不变。
- 回填结束/失败通过现有通知与Toast显示脱敏原因;详细结果仍在Agent采购页。本变更不调整服务端回填接口、采集或采购流程。