feat(android): add read-only order backfill with bounded scanning (#242)

This commit is contained in:
QiuSW
2026-09-08 15:45:06 +08:00
parent 482ba3408a
commit edd1cb15df
11 changed files with 883 additions and 2 deletions
@@ -194,6 +194,39 @@ class GoAutoAccessibilityService : AccessibilityService(), UiDriver, PddCollecto
return swipeNode(node, direction)
}
/** Backfill never uses ancestor clicks or coordinate/gesture fallbacks. */
fun clickBackfill(target: SnapshotNode): 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 } ||
BackfillPagePolicy.expansion(page)?.path == fresh.path ||
(fresh.label == "全部" && page.nodes.any { it.label in setOf("我的订单", "全部订单") })
if (!allowed || !BackfillPagePolicy.safe(page, fresh)) return false
val root = rootInActiveWindow ?: return false
if (root.packageName?.toString() != BackfillPagePolicy.PDD) return false
var node = root
for (index in fresh.path.split('/').drop(1)) node = node.getChild(index.toInt()) ?: return false
val bounds = Rect().also(node::getBoundsInScreen)
if (!node.isClickable || !node.isEnabled || !node.isVisibleToUser ||
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
return node.performAction(AccessibilityNodeInfo.ACTION_CLICK)
}
fun scrollBackfill(): Boolean {
BackfillPagePolicy.validate(capture())
val root = rootInActiveWindow ?: return false
val candidates = mutableListOf<AccessibilityNodeInfo>()
walk(root) { if (it.isScrollable && it.isVisibleToUser && it.isEnabled) candidates += it }
// Prefer the unique largest vertical viewport. Ambiguous panes fail closed.
val areas = candidates.map { it to Rect().also(it::getBoundsInScreen) }
val maxArea = areas.maxOfOrNull { it.second.width().toLong() * it.second.height() } ?: return false
val target = areas.filter { it.second.width().toLong() * it.second.height() == maxArea }.singleOrNull() ?: return false
return target.first.performAction(AccessibilityNodeInfo.ACTION_SCROLL_FORWARD)
}
override fun capture(): UiSnapshot {
val root = rootInActiveWindow ?: return UiSnapshot(null, null, emptyList())
val rootPackage = root.packageName?.toString()
@@ -0,0 +1,87 @@
package cn.ilapage.goauto.agent.automation
import java.math.BigInteger
import java.text.SimpleDateFormat
import java.util.Locale
import java.util.TimeZone
/** Runtime evidence only: a sorted prefix cannot prove the unvisited tail is sorted. */
class OrderBackfillWindow(days: String, val confirmedAt: Long) {
val cutoff: Long
var checked = 0
private set
var nonDescending = false
private set
private var previous: Long? = null
private var missingTime = false
private var timed = 0
init {
require(days.matches(Regex("[0-9]+")) && BigInteger(days) > BigInteger.ZERO)
cutoff = BigInteger.valueOf(confirmedAt).subtract(BigInteger(days).multiply(BigInteger.valueOf(86_400_000)))
.max(BigInteger.valueOf(Long.MIN_VALUE)).toLong()
}
fun observe(time: Long?): Boolean {
checked++
if (time == null) missingTime = true else {
if (previous != null && time > previous!!) nonDescending = true
previous = time
timed++
}
// Check at least five details before using a sampled ordering assumption.
return !nonDescending && !missingTime && timed >= ORDERING_SAMPLE && time != null && time < cutoff
}
fun includes(time: Long?): Boolean = time == null || time in cutoff..confirmedAt
companion object {
const val ORDERING_SAMPLE = 5
const val MAX_ORDERS = 200
const val MAX_DURATION_MS = 10 * 60_000L
const val UNORDERED = "列表非严格倒序,已改为有界扫描,可能未覆盖全部"
}
}
data class BackfillItem(val addressSuffix: String, val pddOrderNo: String, val orderSubmittedAt: String?)
data class BackfillDetail(val item: BackfillItem?, val timeMillis: Long?)
/** Per-detail accumulator. Never retains raw text, addresses, names or phone numbers. */
class BackfillDetailReader(private val zone: TimeZone = TimeZone.getDefault()) {
private val suffixes = mutableSetOf<String>()
private val orders = mutableSetOf<String>()
private val times = mutableSetOf<Long>()
fun accept(text: String) {
SUFFIX.findAll(text).forEach { suffixes += it.value }
ORDER_NO.findAll(text).forEach { orders += it.groupValues[1] }
ORDER_TIME.findAll(text).forEach { match -> parseTime(match.groupValues[1])?.let(times::add) }
}
fun finish(): BackfillDetail {
val time = times.singleOrNull()
val item = if (suffixes.size == 1 && orders.size == 1 && times.size <= 1) {
BackfillItem(suffixes.single(), orders.single(), time?.let {
SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssXXX", Locale.ROOT).apply { timeZone = zone }.format(it)
})
} else null
suffixes.clear()
orders.clear()
times.clear()
return BackfillDetail(item, time)
}
private fun parseTime(raw: String): Long? {
val normalized = raw.replace('年', '-').replace('月', '-').replace("日", "").replace('/', '-').replace('.', '-')
val pattern = if (normalized.count { it == ':' } == 2) "yyyy-M-d H:mm:ss" else "yyyy-M-d H:mm"
val position = java.text.ParsePosition(0)
val date = SimpleDateFormat(pattern, Locale.ROOT).apply { isLenient = false; timeZone = zone }.parse(normalized, position)
return date?.time?.takeIf { position.index == normalized.length }
}
companion object {
val ORDER_NO = Regex("(?:订单编号|订单号)\\s*[::]?\\s*([A-Za-z0-9-]{6,64})")
val ORDER_TIME = Regex("(?:下单时间|创建时间)\\s*[::]?\\s*(20\\d{2}[-/.年]\\d{1,2}[-/.月]\\d{1,2}日?\\s+\\d{1,2}:\\d{2}(?::\\d{2})?)")
private val SUFFIX = Regex("_cg[1-9][0-9]*(?![0-9A-Za-z_0-9])")
}
}
@@ -0,0 +1,140 @@
package cn.ilapage.goauto.agent.automation
import java.security.MessageDigest
object BackfillPagePolicy {
const val PDD = "com.xunmeng.pinduoduo"
val forbidden = listOf("确认收货", "申请退款", "催发货", "去支付", "立即支付", "提交订单", "付款", "退款", "取消订单", "再次购买", "删除订单")
private val risk = listOf("验证码", "安全验证", "人机验证", "登录", "账号异常", "风险验证", "拖动滑块")
fun validate(page: UiSnapshot) {
require(page.packageName == PDD && page.activityName?.startsWith(PDD) == true) { "PDD 页面身份不符" }
require(page.nodes.none { it.visible && risk.any { word -> it.label.contains(word) } }) { "遇到登录或安全验证,已停止" }
}
private fun subtree(page: UiSnapshot, node: SnapshotNode) = page.nodes.filter { it.path == node.path || it.path.startsWith(node.path + "/") }
fun safe(page: UiSnapshot, node: SnapshotNode): Boolean = node.visible && node.enabled && node.clickable &&
node.bounds.width > 0 && node.bounds.height > 0 &&
subtree(page, node).none { child -> forbidden.any { child.label.contains(it) } } &&
page.nodes.none { other -> other.visible && forbidden.any { other.label.contains(it) } &&
other.bounds.left < node.bounds.right && other.bounds.right > node.bounds.left &&
other.bounds.top < node.bounds.bottom && other.bounds.bottom > node.bounds.top }
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("订单编号")) }
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("订单编号") }
val snapshot = page.nodes.filter { it.visible && it.label == "商品快照" }.singleOrNull() ?: return null
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()
return page.nodes.filter { node ->
if (!safe(page, node)) return@filter false
if (node.label in setOf("订单详情", "查看详情")) return@filter true
val children = subtree(page, node)
val product = children.any { it.className?.endsWith("ImageView") == true } && children.any { it.label.length >= 4 }
val parent = page.nodes.firstOrNull { it.path == node.parentPath } ?: return@filter false
val context = subtree(page, parent)
product && context.any { it.label in setOf("查看物流", "确认收货", "去支付", "待发货", "待收货", "交易成功", "再次购买") }
}.sortedBy { it.bounds.top }.let { candidates ->
candidates.filter { node -> candidates.none { it !== node && it.path.startsWith(node.path + "/") } }
}
}
fun fingerprint(page: UiSnapshot, card: SnapshotNode): String {
val labels = subtree(page, card).joinToString("|") { it.label }
return MessageDigest.getInstance("SHA-256").digest(labels.toByteArray()).joinToString("") { "%02x".format(it) }
}
}
interface BackfillDriver {
fun openOrders()
fun capture(): UiSnapshot
fun click(node: SnapshotNode): Boolean
fun scroll(): Boolean
fun back(): Boolean
fun pause()
}
class OrderBackfillScanner(
private val driver: BackfillDriver,
private val window: OrderBackfillWindow,
private val checkActive: () -> Unit,
private val submit: (BackfillItem) -> Unit,
private val progress: (Int) -> Unit,
) {
fun scan(): String {
checkActive()
driver.openOrders()
driver.pause()
var page = read()
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) { "未识别我的订单-全部" }
act { driver.click(tab) }
page = read()
check(BackfillPagePolicy.list(page)) { "无法确认全部订单标签" }
}
if (page.nodes.any { it.visible && it.label == "暂无订单" }) return "扫描完成,未发现订单"
val seenCards = mutableSetOf<String>()
val seenOrders = mutableSetOf<String>()
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 }
if (card == null) {
if (++noProgress >= 3) return finish("列表无进展或卡片无法安全识别,未完整扫描")
checkActive()
if (!driver.scroll()) return finish("列表滚动结束或不可滚动,未完整扫描")
driver.pause()
page = read()
continue
}
noProgress = 0
seenCards += BackfillPagePolicy.fingerprint(page, card)
act { driver.click(card) }
var detail = read()
check(BackfillPagePolicy.detail(detail)) { "点击后未识别订单详情" }
val reader = BackfillDetailReader()
var expanded = false
// Limited detail scrolling; only this accumulator associates fields across these frames.
for (step in 0 until 6) {
reader.accept(detail.nodes.filter { it.visible }.joinToString("\n") { it.label })
val expand = if (expanded) null else BackfillPagePolicy.expansion(detail)
if (expand != null) {
act { driver.click(expand) }
expanded = true
} else {
checkActive()
if (!driver.scroll()) break
driver.pause()
}
detail = read()
check(BackfillPagePolicy.detail(detail)) { "滚动后无法确认订单详情,未完整扫描" }
}
reader.accept(detail.nodes.filter { it.visible }.joinToString("\n") { it.label })
val found = reader.finish()
val stopForTime = window.observe(found.timeMillis)
progress(window.checked)
found.item?.takeIf { window.includes(found.timeMillis) && seenOrders.add(it.pddOrderNo) }?.let(submit)
if (stopForTime) return "已达指定天数(已读序列倒序);未完整扫描,后续列表时序未经验证"
act { driver.back() }
page = read()
}
return finish("达到 200 单内部上限,未完整扫描")
}
private fun finish(reason: String): String = reason + if (window.nonDescending) "\n${OrderBackfillWindow.UNORDERED}" else ""
private fun read(): UiSnapshot {
checkActive()
return driver.capture().also(BackfillPagePolicy::validate)
}
private fun act(action: () -> Boolean) {
checkActive()
check(action()) { "安全页面操作失败,未完整扫描" }
driver.pause()
}
}
@@ -203,6 +203,9 @@ class AgentApiException(
) : Exception(message)
class AgentApiClient(private val serverUrl: String) {
fun backfillOrders(requestId: String, items: List<cn.ilapage.goauto.agent.automation.BackfillItem>, token: String): List<BackfillResult> =
parseBackfillResults(post("/api/agent/v1/purchase-tasks/order-backfill", backfillPayload(requestId, items), token))
fun testConnection() {
requireNotNull(request("GET", "/api/v1/health", null, null))
}
@@ -0,0 +1,92 @@
package cn.ilapage.goauto.agent.network
import cn.ilapage.goauto.agent.automation.BackfillItem
import org.json.JSONArray
import org.json.JSONObject
import java.io.IOException
import java.util.UUID
data class BackfillResult(
val index: Int, val taskId: Long, val result: String, val code: String,
val status: String, val statusVersion: Long, val pddOrderNo: String?,
val orderSubmittedAt: String?, val timeSource: String,
) {
val success get() = result in setOf("backfilled", "already_backfilled")
val needsReview get() = !success && code != "INTERNAL_ERROR" && code != "NETWORK_ERROR"
fun display(): String = if (success) {
val source = when (timeSource) {
"page" -> "页面下单时间"
"irreversible_at" -> "估算时间(提交订单时刻)"
else -> "已有时间(来源未知)"
}
"CG-$taskId:${if (result == "already_backfilled") "已回填" else "成功"};$source ${orderSubmittedAt.orEmpty()}"
} else "${if (taskId > 0) "CG-$taskId" else "条目 ${index + 1}"}:${if (needsReview) "需人工检查" else "重试耗尽,未确认"}($code)"
}
internal fun backfillPayload(requestId: String, items: List<BackfillItem>): JSONObject {
require(items.size in 1..50)
return JSONObject().put("requestId", requestId).put("items", JSONArray().apply {
items.forEach { item -> put(JSONObject().put("addressSuffix", item.addressSuffix).put("pddOrderNo", item.pddOrderNo).apply {
item.orderSubmittedAt?.let { put("orderSubmittedAt", it) }
}) }
})
}
internal fun parseBackfillResults(data: JSONObject): List<BackfillResult> {
val items = data.getJSONArray("items")
return (0 until items.length()).map { index -> items.getJSONObject(index).let {
BackfillResult(it.getInt("index"), it.optLong("taskId"), it.getString("result"), it.getString("code"),
it.optString("status"), it.optLong("statusVersion"),
if (it.isNull("pddOrderNo")) null else it.optString("pddOrderNo"),
if (it.isNull("orderSubmittedAt")) null else it.optString("orderSubmittedAt"), it.optString("timeSource"))
} }
}
/** Separate from the purchase outbox: only transport failures / INTERNAL_ERROR retry, three attempts total. */
class OrderBackfillUpload(
private val submit: (String, List<BackfillItem>) -> List<BackfillResult>,
private val checkActive: () -> Unit,
private val pause: (Long) -> Unit = Thread::sleep,
) {
fun upload(items: List<BackfillItem>, confirmed: (BackfillResult) -> Unit) {
var pending = items.mapIndexed { index, item -> index to item }
var requestId = UUID.randomUUID().toString()
repeat(3) { attempt ->
checkActive()
val results = try {
submit(requestId, pending.map { it.second }).also { values ->
check(values.size == pending.size && values.map { it.index }.toSet() == pending.indices.toSet())
values.filter { it.success }.forEach { value ->
val input = pending[value.index].second
check(value.taskId.toString() == input.addressSuffix.removePrefix("_cg") && value.pddOrderNo == input.pddOrderNo)
check(value.status.isNotBlank())
}
}
} catch (error: Exception) {
val code = when (error) {
is AgentApiException -> error.code
is IOException -> "NETWORK_ERROR"
else -> throw error
}
if (code in setOf("INTERNAL_ERROR", "NETWORK_ERROR") && attempt < 2) {
pause((attempt + 1) * 1_000L)
return@repeat // same request ID for an unknown transport result
}
pending.forEach { (index, _) -> confirmed(BackfillResult(index, 0, "failed", code, "", 0, null, null, "")) }
return
}
// A completed HTTP response is evidence, even when cancellation arrived while waiting.
val retry = mutableListOf<Pair<Int, BackfillItem>>()
results.forEach { result ->
val original = pending[result.index]
if (!result.success && result.code == "INTERNAL_ERROR" && attempt < 2) retry += original
else confirmed(result.copy(index = original.first))
}
if (retry.isEmpty()) return
pending = retry
requestId = UUID.randomUUID().toString() // payload changed after partial acknowledgement
checkActive()
pause((attempt + 1) * 1_000L)
}
}
}
@@ -11,6 +11,32 @@ import org.json.JSONObject
class TaskHistoryCache(context: Context) {
private val preferences = context.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE)
/** Only acknowledged server facts may overwrite cached task facts. Never store scanned candidates. */
fun applyBackfill(result: cn.ilapage.goauto.agent.network.BackfillResult, environment: String) = synchronized(BACKFILL_LOCK) {
if (!result.success) {
if (result.needsReview) check(preferences.edit().putString("backfill_review_${result.taskId}_${result.code}",
JSONObject().put("taskId", result.taskId).put("code", result.code).put("environment", environment).toString()).commit())
return@synchronized
}
val values = JSONArray(preferences.getString(PURCHASE, "[]"))
for (index in 0 until values.length()) {
applyBackfillFacts(values.getJSONObject(index), result)
}
// Also retain the small acknowledged summary when this task is absent from downloaded history.
val confirmed = JSONObject().put("taskId", result.taskId).put("status", result.status)
.put("statusVersion", result.statusVersion).putNullable("pddOrderNo", result.pddOrderNo)
.putNullable("orderSubmittedAt", result.orderSubmittedAt).put("timeSource", result.timeSource)
.put("environment", environment)
check(preferences.edit().putString(PURCHASE, values.toString())
.putString("backfill_confirmed_${result.taskId}", confirmed.toString()).commit())
}
fun backfillTimeSource(taskId: Long, environment: String): String? {
val raw = preferences.getString("backfill_confirmed_$taskId", null) ?: return null
val confirmed = runCatching { JSONObject(raw) }.getOrNull() ?: return null
return confirmed.optString("timeSource").takeIf { confirmed.optString("environment") == environment }
}
fun saveCollection(days: Int, items: List<CollectionHistoryItem>) = save(COLLECTION, days, JSONArray().apply {
items.forEach { item -> put(JSONObject()
.put("taskId", item.taskId).put("attemptNumber", item.attemptNumber).put("status", item.status).put("source", item.source).put("goodsId", item.goodsId)
@@ -90,9 +116,18 @@ class TaskHistoryCache(context: Context) {
private fun JSONObject.nullableLong(key: String): Long? = if (isNull(key)) null else optLong(key)
private companion object {
val BACKFILL_LOCK = Any()
const val PREFERENCES = "goauto_task_history_cache"
const val COLLECTION = "collection"
const val PURCHASE = "purchase"
const val PAGE_SIZE = 20
}
}
internal fun applyBackfillFacts(task: JSONObject, result: cn.ilapage.goauto.agent.network.BackfillResult) {
if (!result.success || task.optLong("taskId") != result.taskId) return
task.put("status", result.status).put("pddOrderNo", result.pddOrderNo ?: JSONObject.NULL)
.put("orderSubmittedAt", result.orderSubmittedAt ?: JSONObject.NULL)
.put("errorCode", JSONObject.NULL).put("errorMessage", JSONObject.NULL)
.put("retryable", false).put("retryDisabledReason", JSONObject.NULL)
}
@@ -75,6 +75,7 @@ class AgentForegroundService : Service() {
private val taskExecutor: ExecutorService = Executors.newSingleThreadExecutor()
private val diagnosticExecutor: ExecutorService = Executors.newSingleThreadExecutor()
private val taskMutex = TaskExecutionMutex()
private val backfillGuard = OrderBackfillGuard(taskMutex)
private val runningTaskId = AtomicReference<Long?>(null)
private val working = AtomicBoolean(false)
private val manualCheckRequested = AtomicBoolean(false)
@@ -132,6 +133,14 @@ class AgentForegroundService : Service() {
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
if (intent?.action == ACTION_BACKFILL_STOP) {
backfillGuard.cancelled.set(true)
return START_STICKY
}
if (intent?.action == ACTION_BACKFILL_START) {
requestOrderBackfill(intent.getStringExtra("days").orEmpty(), intent.getLongExtra("confirmedAt", 0))
return START_STICKY
}
if (intent?.action == ACTION_RECONNECT) registeredThisProcess.set(false)
if (intent?.action == ACTION_CHECK_NOW) manualCheckRequested.set(true)
if (intent?.action == ACTION_CURRENT_PAGE_COLLECTION) {
@@ -147,6 +156,7 @@ class AgentForegroundService : Service() {
}
override fun onDestroy() {
backfillGuard.cancelled.set(true)
runCatching { connectivityManager.unregisterNetworkCallback(networkCallback) }
cancelIdleReturn("服务已停止")
collectionCooldownFuture.getAndSet(null)?.cancel(false)
@@ -165,7 +175,7 @@ class AgentForegroundService : Service() {
override fun onBind(intent: Intent?): IBinder? = null
private fun triggerSync() {
if (!working.compareAndSet(false, true)) return
if (!synchronized(taskMutex) { working.compareAndSet(false, true) }) return
executor.execute {
val manualCheck = manualCheckRequested.getAndSet(false)
try {
@@ -307,6 +317,98 @@ class AgentForegroundService : Service() {
})
}
private fun publishBackfill(state: OrderBackfillState) {
backfillState = state
sendBroadcast(Intent(ACTION_BACKFILL_STATE).setPackage(packageName))
updateNotification(if (state.running) "订单回填 · 已检查 ${state.checked}" else "订单回填已停止,请查看采购页结果")
}
private fun requestOrderBackfill(days: String, confirmedAt: Long) {
// Refuse while polling/dispatch is in flight too: never queue behind another PDD operation.
val acquired = synchronized(taskMutex) {
!working.get() && purchaseStore.activeTaskId() == null && backfillGuard.tryAcquire()
}
if (!acquired) {
android.widget.Toast.makeText(this, "设备忙碌,请稍后操作", android.widget.Toast.LENGTH_SHORT).show()
return
}
try {
val window = cn.ilapage.goauto.agent.automation.OrderBackfillWindow(days, confirmedAt)
val server = settingsStore.serverUrl()
val credentials = identityStore.credentials() ?: error("设备尚未注册")
val accessibility = GoAutoAccessibilityService.instance ?: error("请先启用无障碍服务")
val started = SystemClock.elapsedRealtime()
cancelIdleReturn("人工订单回填")
publishBackfill(OrderBackfillState(running = true, message = "正在检查订单列表时序…"))
taskExecutor.execute {
var state = backfillState
fun checkActive() {
check(!backfillGuard.cancelled.get() && !Thread.currentThread().isInterrupted) { "用户停止,未完整扫描" }
check(SystemClock.elapsedRealtime() - started < cn.ilapage.goauto.agent.automation.OrderBackfillWindow.MAX_DURATION_MS) { "达到 10 分钟内部上限,未完整扫描" }
check(settingsStore.serverUrl() == server && identityStore.credentials() == credentials) { "服务器或设备身份已变化,未完整扫描" }
}
try {
acquireTaskWakeLock()
val api = AgentApiClient(server)
val uploader = cn.ilapage.goauto.agent.network.OrderBackfillUpload(
submit = { id, items -> api.backfillOrders(id, items, credentials.token) },
checkActive = ::checkActive,
)
val driver = object : cn.ilapage.goauto.agent.automation.BackfillDriver {
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 scroll(): Boolean { checkActive(); return accessibility.scrollBackfill() }
override fun back(): Boolean {
checkActive()
cn.ilapage.goauto.agent.automation.BackfillPagePolicy.validate(capture())
return accessibility.back()
}
override fun pause() { repeat(10) { checkActive(); Thread.sleep(100) } }
}
val reason = cn.ilapage.goauto.agent.automation.OrderBackfillScanner(driver, window, ::checkActive,
submit = { item -> uploader.upload(listOf(item)) { result ->
state = state.copy(success = state.success + if (result.result == "backfilled") 1 else 0,
already = state.already + if (result.result == "already_backfilled") 1 else 0,
failed = state.failed + if (!result.success) 1 else 0,
evidence = state.evidence + result.display())
// The frozen API receives all submissions. Never write an old environment's response into a new cache.
if (settingsStore.serverUrl() == server && identityStore.credentials() == credentials) {
cn.ilapage.goauto.agent.persistence.TaskHistoryCache(this).applyBackfill(result, "$server|${credentials.deviceId}")
}
publishBackfill(state)
check(result.success || result.needsReview) { "网络或服务端瞬时错误重试耗尽,未完整扫描" }
} },
progress = { checked ->
state = state.copy(checked = checked, message = if (window.nonDescending) cn.ilapage.goauto.agent.automation.OrderBackfillWindow.UNORDERED else "正在扫描(已读 $checked 单)…")
publishBackfill(state)
}).scan()
state = state.copy(message = reason)
} catch (error: Exception) {
// Never echo raw page text, HTTP bodies or credentials in UI/logs.
val reason = if (error is IllegalStateException || error is IllegalArgumentException) error.message else null
val message = reason?.takeIf { it.length < 100 } ?: "网络或页面异常"
state = state.copy(message = message + if (message.contains("未完整扫描")) "" else ",未完整扫描")
} finally {
val warning = if (window.nonDescending && !state.message.contains(cn.ilapage.goauto.agent.automation.OrderBackfillWindow.UNORDERED))
"\n${cn.ilapage.goauto.agent.automation.OrderBackfillWindow.UNORDERED}" else ""
try {
publishBackfill(state.copy(running = false, message = state.message + warning))
} finally {
try { releaseTaskWakeLock() } finally { backfillGuard.release() }
}
}
}
} catch (error: Exception) {
backfillGuard.release()
publishBackfill(OrderBackfillState(message = "回填未启动,请检查天数、设备连接与无障碍服务"))
}
}
private fun requestCurrentPageCollection(
requestId: String,
replacementOriginType: String?,
@@ -1038,10 +1140,15 @@ class AgentForegroundService : Service() {
@Suppress("DEPRECATION")
Notification.Builder(this)
}
if (backfillState.running) {
val stop = PendingIntent.getService(this, 242, Intent(this, AgentForegroundService::class.java).setAction(ACTION_BACKFILL_STOP),
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT)
builder.addAction(Notification.Action.Builder(null, "停止回填", stop).build())
}
return builder
.setSmallIcon(android.R.drawable.stat_notify_sync)
.setContentTitle(getString(R.string.app_name))
.setContentText(content)
.setContentText(if (backfillState.running) "订单回填 · 已检查 ${backfillState.checked}" else content)
.setContentIntent(pendingIntent)
.setOngoing(true)
.build()
@@ -1067,6 +1174,11 @@ class AgentForegroundService : Service() {
}
companion object {
const val ACTION_BACKFILL_START = "cn.ilapage.goauto.agent.BACKFILL_START"
const val ACTION_BACKFILL_STOP = "cn.ilapage.goauto.agent.BACKFILL_STOP"
const val ACTION_BACKFILL_STATE = "cn.ilapage.goauto.agent.BACKFILL_STATE"
@Volatile var backfillState = OrderBackfillState()
private set
const val ACTION_RECONNECT = "cn.ilapage.goauto.agent.RECONNECT"
const val ACTION_CHECK_NOW = "cn.ilapage.goauto.agent.CHECK_NOW"
const val ACTION_CHECK_RESULT = "cn.ilapage.goauto.agent.CHECK_RESULT"
@@ -0,0 +1,22 @@
package cn.ilapage.goauto.agent.service
import java.util.concurrent.atomic.AtomicBoolean
class OrderBackfillGuard(private val mutex: TaskExecutionMutex) {
private val active = AtomicBoolean(false)
val cancelled = AtomicBoolean(false)
fun tryAcquire(): Boolean {
if (!active.compareAndSet(false, true)) return false
if (!mutex.tryAcquire(RESERVATION)) {
active.set(false)
return false
}
cancelled.set(false)
return true
}
fun release() {
mutex.release(RESERVATION)
active.set(false)
}
companion object { const val RESERVATION = Long.MAX_VALUE - 1 }
}
@@ -0,0 +1,14 @@
package cn.ilapage.goauto.agent.service
data class OrderBackfillState(
val running: Boolean = false,
val checked: Int = 0,
val success: Int = 0,
val already: Int = 0,
val failed: Int = 0,
val message: String = "",
val evidence: List<String> = emptyList(),
) {
fun text(): String = "已检查 $checked · 成功 $success · 已回填 $already · 冲突/失败 $failed\n$message" +
if (evidence.isEmpty()) "" else "\n" + evidence.joinToString("\n")
}
@@ -141,8 +141,16 @@ class TaskHistoryFragment : Fragment() {
private val imageLoader = HistoryImageLoader()
private val imageRequests = mutableListOf<HistoryImageRequest>()
private var currentPageReceiverRegistered = false
private var backfillPanel: LinearLayout? = null
private val currentPageReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
if (intent?.action == AgentForegroundService.ACTION_BACKFILL_STATE) {
renderBackfill()
if (!collection && isResumed && !AgentForegroundService.backfillState.running) {
detailState.taskId?.let(::loadPurchaseDetail) ?: load()
}
return
}
if (intent?.action != AgentForegroundService.ACTION_CURRENT_PAGE_RESULT) return
val message = intent.getStringExtra(AgentForegroundService.EXTRA_CURRENT_PAGE_MESSAGE).orEmpty()
val taskId = intent.getLongExtra(AgentForegroundService.EXTRA_CURRENT_PAGE_TASK_ID, 0L)
@@ -182,6 +190,11 @@ class TaskHistoryFragment : Fragment() {
pageColumn = context.column()
pageColumn.addView(context.screenTitle(if (collection) "采集记录" else "采购记录"))
pageColumn.addView(buildSearch())
if (!collection) {
backfillPanel = context.column(0)
pageColumn.addView(backfillPanel)
renderBackfill()
}
pageColumn.addView(buildFilters())
resultColumn = context.column(0).apply { setPadding(0, context.dp(12), 0, 0) }
pageColumn.addView(resultColumn, resultColumn.fullWidth())
@@ -207,6 +220,7 @@ class TaskHistoryFragment : Fragment() {
}
override fun onDestroyView() {
backfillPanel = null
requestGeneration++
cancelImageRequests()
super.onDestroyView()
@@ -273,6 +287,14 @@ class TaskHistoryFragment : Fragment() {
contentDescription = "采集当前拼多多商品"
setOnClickListener { confirmCurrentPageCollection() }
}, LinearLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, context.dp(48)).apply { marginStart = context.dp(8) })
} else {
row.addView(MaterialButton(context).apply {
text = "回填"
textSize = 14f
minimumHeight = context.dp(48)
contentDescription = "回填拼多多订单号和下单时间"
setOnClickListener { showBackfillInput() }
}, LinearLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, context.dp(48)).apply { marginStart = context.dp(8) })
}
addView(row, row.fullWidth())
}).apply {
@@ -280,6 +302,75 @@ class TaskHistoryFragment : Fragment() {
}
}
private fun showBackfillInput() {
if (AgentForegroundService.backfillState.running) {
toast("设备忙碌,请稍后操作")
return
}
val context = requireContext()
val input = TextInputEditText(context).apply {
setText("2")
inputType = android.text.InputType.TYPE_CLASS_NUMBER
minimumHeight = context.dp(48)
contentDescription = "回填天数"
selectAll()
}
val field = TextInputLayout(context).apply {
hint = "天数"
helperText = "从确认时刻往前 N×24 小时"
addView(input)
}
val dialog = MaterialAlertDialogBuilder(context).setTitle("回填订单")
.setView(context.cardColumn().apply { addView(field) })
.setNegativeButton("取消", null).setPositiveButton("确认", null).create()
dialog.setOnShowListener {
dialog.getButton(androidx.appcompat.app.AlertDialog.BUTTON_POSITIVE).setOnClickListener {
val days = input.text.toString().trim()
val now = System.currentTimeMillis()
if (runCatching { cn.ilapage.goauto.agent.automation.OrderBackfillWindow(days, now) }.isFailure) {
field.error = "请输入正整数天数"
return@setOnClickListener
}
val intent = Intent(context, AgentForegroundService::class.java)
.setAction(AgentForegroundService.ACTION_BACKFILL_START).putExtra("days", days).putExtra("confirmedAt", now)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) context.startForegroundService(intent) else context.startService(intent)
dialog.dismiss()
}
}
dialog.show()
}
private fun renderBackfill() {
val panel = backfillPanel ?: return
val context = context ?: return
val state = AgentForegroundService.backfillState
panel.removeAllViews()
if (state.message.isBlank()) return
panel.addView(context.card(context.cardColumn().apply {
addView(context.label(if (state.running) "正在回填订单" else "回填结果", 16f))
addView(context.label(state.copy(evidence = emptyList()).text(), 14f))
if (state.running) {
addView(MaterialButton(context).apply {
text = "停止"
minimumHeight = context.dp(48)
setOnClickListener {
context.startService(Intent(context, AgentForegroundService::class.java).setAction(AgentForegroundService.ACTION_BACKFILL_STOP))
isEnabled = false
text = "正在停止…"
}
})
}
if (state.evidence.isNotEmpty()) addView(MaterialButton(context).apply {
text = "查看逐条结果"
minimumHeight = context.dp(48)
setOnClickListener {
MaterialAlertDialogBuilder(context).setTitle("回填明细")
.setMessage(state.evidence.joinToString("\n")).setPositiveButton("关闭", null).show()
}
})
}))
}
private fun buildFilters(): View {
val context = requireContext()
val statuses = if (collection) {
@@ -632,6 +723,7 @@ class TaskHistoryFragment : Fragment() {
private fun registerCurrentPageReceiver() {
if (currentPageReceiverRegistered) return
val filter = IntentFilter(AgentForegroundService.ACTION_CURRENT_PAGE_RESULT)
filter.addAction(AgentForegroundService.ACTION_BACKFILL_STATE)
if (Build.VERSION.SDK_INT >= 33) {
requireContext().registerReceiver(currentPageReceiver, filter, Context.RECEIVER_NOT_EXPORTED)
} else {
@@ -724,6 +816,12 @@ class TaskHistoryFragment : Fragment() {
append("实际单价:${money(task.actualUnitPriceCent, task.currency)}\n")
append("PDD 订单号:${task.pddOrderNo ?: "—"}\n")
append("下单时间:${task.orderSubmittedAt?.let(::formatTime) ?: "—"}")
val environment = "${AgentSettingsStore(context).serverUrl()}|${runCatching { SecureDeviceStore(context).credentials()?.deviceId }.getOrNull()}"
when (TaskHistoryCache(context).backfillTimeSource(task.taskId, environment)) {
"page" -> append("(页面读取)")
"irreversible_at" -> append("(估算:提交订单时刻)")
"existing_unknown" -> append("(已有值,来源未知)")
}
}
resultColumn.addView(context.card(context.cardColumn().apply {
addView(context.label("CG-${task.taskId}", 20f, context.getColor(R.color.agent_text), true))
@@ -0,0 +1,245 @@
package cn.ilapage.goauto.agent
import cn.ilapage.goauto.agent.automation.*
import cn.ilapage.goauto.agent.network.*
import cn.ilapage.goauto.agent.service.*
import org.json.JSONObject
import org.junit.Assert.*
import org.junit.Test
import java.io.IOException
import java.util.TimeZone
import java.util.concurrent.CountDownLatch
import java.util.concurrent.Executors
import java.util.concurrent.atomic.AtomicInteger
class OrderBackfillTest {
private val zone = TimeZone.getTimeZone("Asia/Shanghai")
private fun time(raw: String): Long = BackfillDetailReader(zone).apply { accept("下单时间:$raw") }.finish().timeMillis!!
private val now = time("2026-09-08 12:00:00")
@Test fun `expanded detail reads order time not group time across frames`() {
val reader = BackfillDetailReader(zone)
reader.accept("测试地址_cg7\n订单编号:TEST-000007\n展开")
reader.accept("下单时间:2026-09-08 10:50:29\n拼单时间:2026-09-08 11:20:35")
val detail = reader.finish()
assertEquals("2026-09-08T10:50:29+08:00", detail.item!!.orderSubmittedAt)
assertEquals("_cg7", detail.item!!.addressSuffix)
assertNull(reader.finish().item)
}
@Test fun `untagged order never becomes retained or uploaded candidate`() {
val reader = BackfillDetailReader(zone)
reader.accept("个人订单\n订单编号:PERSONAL-1\n下单时间:2026-09-08 10:00:00")
assertNull(reader.finish().item)
reader.accept("测试地址_cg7")
assertNull(reader.finish().item) // previous order number has been cleared
val uploaded = mutableListOf<BackfillItem>()
scanner(FakeDriver(tagged = false), uploaded).scan()
assertTrue(uploaded.isEmpty())
}
@Test fun `ambiguous and noncanonical suffixes are skipped and missing time remains optional`() {
for (suffix in listOf("_cg07", "_cg+7", "_cg7", "_cg7abc", "_cg7 _cg8")) {
assertNull(BackfillDetailReader(zone).apply { accept("$suffix\n订单号:TEST-000007") }.finish().item)
}
assertNull(BackfillDetailReader(zone).apply { accept("_cg7\n订单号:TEST-000007\n订单号:TEST-000008") }.finish().item)
val missing = BackfillDetailReader(zone).apply { accept("_cg7\n订单号:TEST-000007\n拼单时间:2026-09-08 10:00:00") }.finish()
assertNotNull(missing.item)
assertNull(missing.item!!.orderSubmittedAt)
}
@Test fun `rolling hours boundary and sampled descending cutoff`() {
val window = OrderBackfillWindow("2", now)
assertEquals(now - 48 * 3_600_000L, window.cutoff)
assertTrue(window.includes(window.cutoff))
assertFalse(window.includes(window.cutoff - 1))
repeat(4) { assertFalse(window.observe(window.cutoff + 10 - it)) }
assertTrue(window.observe(window.cutoff - 1))
assertEquals(Long.MIN_VALUE, OrderBackfillWindow("99999999999999999999999999", now).cutoff)
}
@Test fun `time reversal and missing times disable early stop`() {
val window = OrderBackfillWindow("2", now)
assertFalse(window.observe(now - 100))
assertFalse(window.observe(now))
repeat(10) { assertFalse(window.observe(window.cutoff - it - 1)) }
assertTrue(window.nonDescending)
val unknown = OrderBackfillWindow("2", now)
unknown.observe(null)
repeat(10) { assertFalse(unknown.observe(unknown.cutoff - it - 1)) }
}
@Test fun `scanner expands and stops after validated prefix passes rolling cutoff`() {
val driver = FakeDriver()
val items = mutableListOf<BackfillItem>()
val result = scanner(driver, items).scan()
assertEquals(5, driver.opened)
assertEquals(5, driver.expansions)
assertTrue(result.contains("已达指定天数"))
assertTrue(result.contains("未完整扫描"))
assertTrue(items.all { !it.orderSubmittedAt.orEmpty().contains("11:20") })
}
@Test fun `unordered list scans to internal cap and reports incomplete`() {
val window = OrderBackfillWindow("2", now)
val driver = FakeDriver(unordered = true)
val result = OrderBackfillScanner(driver, window, {}, {}, {}).scan()
assertTrue(window.nonDescending)
assertEquals(200, driver.opened)
assertTrue(result.contains("未完整扫描"))
assertTrue(result.contains(OrderBackfillWindow.UNORDERED))
}
@Test fun `mutex occupied rejects and simultaneous double tap has one winner and finally releases`() {
val mutex = TaskExecutionMutex()
val guard = OrderBackfillGuard(mutex)
mutex.tryAcquire(7)
assertFalse(guard.tryAcquire())
mutex.release(7)
val start = CountDownLatch(1)
val complete = CountDownLatch(2)
val won = AtomicInteger()
val pool = Executors.newFixedThreadPool(2)
repeat(2) { pool.execute { start.await(); if (guard.tryAcquire()) won.incrementAndGet(); complete.countDown() } }
start.countDown()
complete.await()
pool.shutdownNow()
assertEquals(1, won.get())
assertFalse(mutex.tryAcquire(8))
try { guard.cancelled.set(true); throw IllegalStateException("cancelled") } catch (_: IllegalStateException) { } finally { guard.release() }
assertNull(mutex.currentTaskId())
assertTrue(guard.tryAcquire())
assertFalse(guard.cancelled.get())
guard.release()
}
@Test fun `permanent business errors never retry regardless of server retryable flag`() {
val codes = listOf("PURCHASE_BACKFILL_SUFFIX_INVALID", "PURCHASE_TASK_NOT_FOUND", "PURCHASE_BACKFILL_DEVICE_MISMATCH",
"PURCHASE_STATE_CONFLICT", "PURCHASE_INVALID_REQUEST", "PURCHASE_ORDER_TIME_INVALID", "PURCHASE_ORDER_TIME_MISSING",
"PURCHASE_BACKFILL_ORDER_CONFLICT", "PURCHASE_BACKFILL_BATCH_CONFLICT", "PURCHASE_BACKFILL_ORDER_ALREADY_USED")
codes.forEach { code ->
var calls = 0
val results = mutableListOf<BackfillResult>()
OrderBackfillUpload({ _, _ -> calls++; listOf(failure(code)) }, {}, {}).upload(listOf(item), results::add)
assertEquals(1, calls)
assertTrue(results.single().display().contains("需人工检查"))
}
var calls = 0
OrderBackfillUpload({ _, _ -> calls++; throw AgentApiException(409, codes.first(), "hidden", true) }, {}, {})
.upload(listOf(item)) { assertTrue(it.needsReview) }
assertEquals(1, calls)
}
@Test fun `network and internal errors retry bounded with stable transport request id`() {
val ids = mutableListOf<String>()
val results = mutableListOf<BackfillResult>()
OrderBackfillUpload({ id, _ -> ids += id; throw IOException("private body") }, {}, {})
.upload(listOf(item), results::add)
assertEquals(3, ids.size)
assertEquals(1, ids.toSet().size)
assertFalse(results.single().needsReview)
assertFalse(results.single().display().contains("private"))
var calls = 0
OrderBackfillUpload({ _, _ -> calls++; listOf(failure("INTERNAL_ERROR")) }, {}, {})
.upload(listOf(item)) { assertTrue(it.display().contains("重试耗尽")) }
assertEquals(3, calls)
}
@Test fun `partial response only retries transient items and preserves acknowledged success`() {
val sizes = mutableListOf<Int>()
val results = mutableListOf<BackfillResult>()
val second = item.copy(addressSuffix = "_cg8", pddOrderNo = "TEST-000008")
OrderBackfillUpload({ _, items ->
sizes += items.size
if (items.size == 2) listOf(success(), failure("INTERNAL_ERROR").copy(index = 1))
else listOf(failure("PURCHASE_STATE_CONFLICT"))
}, {}, {}).upload(listOf(item, second), results::add)
assertEquals(listOf(2, 1), sizes)
assertTrue(results.first().success)
assertEquals(1, results.last().index)
}
@Test fun `cancellation prevents upload and invalid response cannot confirm cache`() {
var calls = 0
try {
OrderBackfillUpload({ _, _ -> calls++; listOf(success()) }, { error("stopped") }, {}).upload(listOf(item)) { fail() }
fail()
} catch (_: IllegalStateException) { }
assertEquals(0, calls)
try {
OrderBackfillUpload({ _, _ -> listOf(success().copy(taskId = 99)) }, {}, {}).upload(listOf(item)) { fail() }
fail()
} catch (_: IllegalStateException) { }
}
@Test fun `payload contains only suffix order and optional RFC3339 time`() {
val payload = backfillPayload("test", listOf(item))
assertEquals(setOf("requestId", "items"), payload.keySet())
assertEquals(setOf("addressSuffix", "pddOrderNo"), payload.getJSONArray("items").getJSONObject(0).keySet())
val parsed = parseBackfillResults(JSONObject("""{"items":[{"index":0,"taskId":7,"result":"backfilled","code":"BACKFILLED","status":"order_created","statusVersion":3,"pddOrderNo":"TEST-000007","orderSubmittedAt":"2026-09-08T10:00:00+08:00","timeSource":"irreversible_at","retryable":false}]}"""))
assertTrue(parsed.single().display().contains("估算"))
assertTrue(parsed.single().copy(timeSource = "page").display().contains("页面下单时间"))
}
@Test fun `cache updates confirmed server facts only and clears old errors`() {
val task = JSONObject().put("taskId", 7).put("status", "order_result_unknown").put("errorCode", "OLD").put("errorMessage", "old")
cn.ilapage.goauto.agent.persistence.applyBackfillFacts(task, failure("PURCHASE_BACKFILL_ORDER_CONFLICT"))
assertEquals("order_result_unknown", task.getString("status"))
assertEquals("OLD", task.getString("errorCode"))
cn.ilapage.goauto.agent.persistence.applyBackfillFacts(task, success())
assertEquals("order_created", task.getString("status"))
assertEquals(item.pddOrderNo, task.getString("pddOrderNo"))
assertTrue(task.isNull("errorCode"))
assertTrue(task.isNull("errorMessage"))
assertFalse(task.getBoolean("retryable"))
}
@Test fun `expansion requires order information and product snapshot row`() {
val expand = node("0/1", "展开")
assertNull(BackfillPagePolicy.expansion(page(node("0/0", "订单编号:TEST-000007"), expand)))
assertEquals(expand, BackfillPagePolicy.expansion(page(node("0/0", "订单编号:TEST-000007"), expand, node("0/2", "商品快照"))))
}
@Test fun `dangerous nodes ancestors and overlapping actions never become click targets`() {
BackfillPagePolicy.forbidden.forEach { label ->
val dangerous = node("0/0", label)
assertFalse(BackfillPagePolicy.safe(page(dangerous), dangerous))
val parent = node("0", "查看详情")
assertFalse(BackfillPagePolicy.safe(page(parent, dangerous), parent))
val adjacent = node("0/1", "查看详情")
assertFalse(BackfillPagePolicy.safe(page(adjacent, dangerous), adjacent))
}
}
private val item = BackfillItem("_cg7", "TEST-000007", null)
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, {})
private fun node(path: String, label: String, selected: Boolean = false) = SnapshotNode(path, path.substringBeforeLast('/'), label, null, null, "View",
NodeBounds(0, 0, 200, 60), true, false, selected, false, true, true)
private fun page(vararg nodes: SnapshotNode) = UiSnapshot(BackfillPagePolicy.PDD, "com.xunmeng.pinduoduo.activity.NewPageActivity", nodes.toList())
private inner class FakeDriver(val tagged: Boolean = true, val unordered: Boolean = false) : BackfillDriver {
var opened = 0
var expansions = 0
var index = 0
var inDetail = false
var expanded = false
override fun openOrders() = Unit
override fun pause() = Unit
override fun capture(): UiSnapshot {
if (!inDetail) return page(node("0/0", "我的订单"), node("0/1", "全部", true), node("0/2", "查看详情"), node("0/2/0", "合成卡片 $index"))
val date = if (unordered && index == 1) "2026-09-08 11:00:00" else if (index < 4) "2026-09-08 10:00:00" else "2026-09-05 10:00:00"
return page(node("0/0", "订单详情"), node("0/1", "订单编号:TEST-${100000 + index}"),
node("0/2", if (tagged) "合成地址_cg${index + 1}" else "无后缀合成地址"),
node("0/4", "商品快照"),
node("0/3", if (expanded) "下单时间:$date\n拼单时间:2026-09-08 11:20:00" else "展开"))
}
override fun click(node: SnapshotNode): Boolean {
if (node.label == "展开") { expanded = true; expansions++ } else { inDetail = true; opened++ }
return true
}
override fun scroll() = false
override fun back(): Boolean { inDetail = false; expanded = false; index++; return true }
}
}