diff --git a/android/app/src/main/java/cn/ilapage/goauto/agent/automation/GoAutoAccessibilityService.kt b/android/app/src/main/java/cn/ilapage/goauto/agent/automation/GoAutoAccessibilityService.kt index 3f8bedb..59c1168 100644 --- a/android/app/src/main/java/cn/ilapage/goauto/agent/automation/GoAutoAccessibilityService.kt +++ b/android/app/src/main/java/cn/ilapage/goauto/agent/automation/GoAutoAccessibilityService.kt @@ -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() + 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() diff --git a/android/app/src/main/java/cn/ilapage/goauto/agent/automation/OrderBackfillPolicy.kt b/android/app/src/main/java/cn/ilapage/goauto/agent/automation/OrderBackfillPolicy.kt new file mode 100644 index 0000000..61824e8 --- /dev/null +++ b/android/app/src/main/java/cn/ilapage/goauto/agent/automation/OrderBackfillPolicy.kt @@ -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() + private val orders = mutableSetOf() + private val times = mutableSetOf() + + 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])") + } +} diff --git a/android/app/src/main/java/cn/ilapage/goauto/agent/automation/OrderBackfillScanner.kt b/android/app/src/main/java/cn/ilapage/goauto/agent/automation/OrderBackfillScanner.kt new file mode 100644 index 0000000..7d6c42f --- /dev/null +++ b/android/app/src/main/java/cn/ilapage/goauto/agent/automation/OrderBackfillScanner.kt @@ -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 { + 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() + val seenOrders = mutableSetOf() + 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() + } +} diff --git a/android/app/src/main/java/cn/ilapage/goauto/agent/network/AgentApiClient.kt b/android/app/src/main/java/cn/ilapage/goauto/agent/network/AgentApiClient.kt index 9f017c0..d41dcff 100644 --- a/android/app/src/main/java/cn/ilapage/goauto/agent/network/AgentApiClient.kt +++ b/android/app/src/main/java/cn/ilapage/goauto/agent/network/AgentApiClient.kt @@ -203,6 +203,9 @@ class AgentApiException( ) : Exception(message) class AgentApiClient(private val serverUrl: String) { + fun backfillOrders(requestId: String, items: List, token: String): List = + parseBackfillResults(post("/api/agent/v1/purchase-tasks/order-backfill", backfillPayload(requestId, items), token)) + fun testConnection() { requireNotNull(request("GET", "/api/v1/health", null, null)) } diff --git a/android/app/src/main/java/cn/ilapage/goauto/agent/network/OrderBackfillUpload.kt b/android/app/src/main/java/cn/ilapage/goauto/agent/network/OrderBackfillUpload.kt new file mode 100644 index 0000000..c755c35 --- /dev/null +++ b/android/app/src/main/java/cn/ilapage/goauto/agent/network/OrderBackfillUpload.kt @@ -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): 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 { + 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) -> List, + private val checkActive: () -> Unit, + private val pause: (Long) -> Unit = Thread::sleep, +) { + fun upload(items: List, 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>() + 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) + } + } +} diff --git a/android/app/src/main/java/cn/ilapage/goauto/agent/persistence/TaskHistoryCache.kt b/android/app/src/main/java/cn/ilapage/goauto/agent/persistence/TaskHistoryCache.kt index 227a935..f2b9604 100644 --- a/android/app/src/main/java/cn/ilapage/goauto/agent/persistence/TaskHistoryCache.kt +++ b/android/app/src/main/java/cn/ilapage/goauto/agent/persistence/TaskHistoryCache.kt @@ -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) = 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) +} diff --git a/android/app/src/main/java/cn/ilapage/goauto/agent/service/AgentForegroundService.kt b/android/app/src/main/java/cn/ilapage/goauto/agent/service/AgentForegroundService.kt index d9d6068..17e6e2b 100644 --- a/android/app/src/main/java/cn/ilapage/goauto/agent/service/AgentForegroundService.kt +++ b/android/app/src/main/java/cn/ilapage/goauto/agent/service/AgentForegroundService.kt @@ -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(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" diff --git a/android/app/src/main/java/cn/ilapage/goauto/agent/service/OrderBackfillGuard.kt b/android/app/src/main/java/cn/ilapage/goauto/agent/service/OrderBackfillGuard.kt new file mode 100644 index 0000000..3888d98 --- /dev/null +++ b/android/app/src/main/java/cn/ilapage/goauto/agent/service/OrderBackfillGuard.kt @@ -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 } +} diff --git a/android/app/src/main/java/cn/ilapage/goauto/agent/service/OrderBackfillState.kt b/android/app/src/main/java/cn/ilapage/goauto/agent/service/OrderBackfillState.kt new file mode 100644 index 0000000..7726b72 --- /dev/null +++ b/android/app/src/main/java/cn/ilapage/goauto/agent/service/OrderBackfillState.kt @@ -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 = emptyList(), +) { + fun text(): String = "已检查 $checked · 成功 $success · 已回填 $already · 冲突/失败 $failed\n$message" + + if (evidence.isEmpty()) "" else "\n" + evidence.joinToString("\n") +} diff --git a/android/app/src/main/java/cn/ilapage/goauto/agent/ui/TaskHistoryFragment.kt b/android/app/src/main/java/cn/ilapage/goauto/agent/ui/TaskHistoryFragment.kt index d8b3f4e..9b10ae7 100644 --- a/android/app/src/main/java/cn/ilapage/goauto/agent/ui/TaskHistoryFragment.kt +++ b/android/app/src/main/java/cn/ilapage/goauto/agent/ui/TaskHistoryFragment.kt @@ -141,8 +141,16 @@ class TaskHistoryFragment : Fragment() { private val imageLoader = HistoryImageLoader() private val imageRequests = mutableListOf() 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)) diff --git a/android/app/src/test/java/cn/ilapage/goauto/agent/OrderBackfillTest.kt b/android/app/src/test/java/cn/ilapage/goauto/agent/OrderBackfillTest.kt new file mode 100644 index 0000000..c138662 --- /dev/null +++ b/android/app/src/test/java/cn/ilapage/goauto/agent/OrderBackfillTest.kt @@ -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() + 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() + 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() + 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() + val results = mutableListOf() + 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() + val results = mutableListOf() + 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) = 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 } + } +}