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 be6c6f4..4ff2c6f 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 @@ -248,6 +248,8 @@ class GoAutoAccessibilityService : AccessibilityService(), UiDriver, PddCollecto checked = node.isChecked, enabled = node.isEnabled, visible = node.isVisibleToUser, + hintText = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) node.hintText?.toString() else null, + showingHintText = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) node.isShowingHintText else null, ) for (index in 0 until node.childCount) { node.getChild(index)?.let { snapshot(it, "$path/$index", path) } diff --git a/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PddProductDetailCollector.kt b/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PddProductDetailCollector.kt index 4f49eef..d298d72 100644 --- a/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PddProductDetailCollector.kt +++ b/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PddProductDetailCollector.kt @@ -27,6 +27,9 @@ data class SnapshotNode( val checked: Boolean, val enabled: Boolean, val visible: Boolean, + // In-memory accessibility metadata; not part of result or diagnostic payloads. + val hintText: String? = null, + val showingHintText: Boolean? = null, ) { val label: String get() = text?.trim().takeUnless { it.isNullOrEmpty() } ?: contentDescription?.trim().orEmpty() diff --git a/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PurchaseLiveAutomation.kt b/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PurchaseLiveAutomation.kt index 72e9495..110c499 100644 --- a/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PurchaseLiveAutomation.kt +++ b/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PurchaseLiveAutomation.kt @@ -42,6 +42,7 @@ class PurchaseLiveAutomation( private val pause: (Long) -> Unit = Thread::sleep, /** Boolean/count-only diagnostics; never receives address text. */ private val panelDiagnostic: (String) -> Unit = {}, + private val monotonicClockMs: () -> Long = { System.nanoTime() / 1_000_000L }, ) { private var submitAttempted = false var lastOrderReadFailure: PurchaseOrderReadFailure? = null @@ -366,48 +367,83 @@ class PurchaseLiveAutomation( var consecutiveEmptySnapshots = 0 var unpaidStartSample: Int? = null var unpaidSwipes = 0 - repeat(ORDER_RESULT_MAX_SAMPLES + ORDER_RESULT_UNPAID_MAX_SAMPLES) { index -> + var paymentBackSucceededAt: Long? = null + var orderContextObserved = false + // Extra observations cannot reopen navigation/scroll budgets. Late unpaid evidence + // still receives its existing independent budget below. + var passiveExtension = false + fun observationRemainingMs(): Long? { + val startedAt = paymentBackSucceededAt ?: return null + if (unpaidStartSample != null || (orderContextObserved && !passiveExtension)) return null + return ORDER_RESULT_POST_PAYMENT_OBSERVATION_MS - (monotonicClockMs() - startedAt) + } + fun observePause(durationMs: Long) { + val remaining = observationRemainingMs() + val duration = if (remaining == null) durationMs else minOf(durationMs, remaining.coerceAtLeast(0)) + if (duration > 0) pause(duration) + } + var nextSample = 0 + while (true) { + val index = nextSample++ // The first unpaid page receives its own bounded budget, even after a long handoff. // Never reset it on repeated labels or navigation back to payment. val unpaidStart = unpaidStartSample - if ((unpaidStart == null && index >= ORDER_RESULT_MAX_SAMPLES) || + if (unpaidStart == null && index >= ORDER_RESULT_MAX_SAMPLES && + paymentBackSucceededAt != null && !orderContextObserved) { + passiveExtension = true + } + if ((observationRemainingMs()?.let { it <= 0 } == true) || + (unpaidStart == null && index >= ORDER_RESULT_MAX_SAMPLES && !passiveExtension) || (unpaidStart != null && index - unpaidStart >= ORDER_RESULT_UNPAID_MAX_SAMPLES)) { val failure = orderEvidenceFailure(labels) return unknown(failure.code, failure.message) } val snapshot = driver.capture() + // A blocking capture can finish after the deadline. Do not act on or accept that frame. + if (observationRemainingMs()?.let { it <= 0 } == true) { + val failure = orderEvidenceFailure(labels) + return unknown(failure.code, failure.message) + } if (snapshot.packageName.isNullOrBlank()) { consecutiveEmptySnapshots++ if (consecutiveEmptySnapshots >= ORDER_RESULT_MAX_EMPTY_SAMPLES) { return unknown("PURCHASE_ORDER_EMPTY_TIMEOUT", "等待订单页面时无障碍窗口持续为空") } - pause(ORDER_RESULT_SAMPLE_INTERVAL_MS) - return@repeat + observePause(ORDER_RESULT_SAMPLE_INTERVAL_MS) + continue } consecutiveEmptySnapshots = 0 val currentLabels = snapshot.nodes.filter { it.visible }.map { it.label } if (isKnownAndroidWechatChooser(snapshot, currentLabels)) { + if (passiveExtension && !backedOutOfChooser) { + val failure = orderEvidenceFailure(labels) + return unknown(failure.code, failure.message) + } if (backedOutOfChooser || !driver.backPurchase()) { return unknown("PURCHASE_ORDER_CHOOSER_BACK_FAILED", "系统应用选择页无法安全返回") } backedOutOfChooser = true - pause(500) - return@repeat + observePause(500) + continue } if (snapshot.packageName == WECHAT_PACKAGE) { if (!restoredFromWechat) { + if (passiveExtension) { + val failure = orderEvidenceFailure(labels) + return unknown(failure.code, failure.message) + } if (!driver.bringPddToForeground()) { return unknown("PURCHASE_ORDER_WECHAT_RESTORE_FAILED", "从微信恢复拼多多的请求失败") } restoredFromWechat = true - pause(500) - return@repeat + observePause(500) + continue } if (pddObservedAfterWechatRestore || ++wechatRestorePendingSamples >= ORDER_RESULT_WECHAT_RESTORE_MAX_SAMPLES) { return unknown("PURCHASE_ORDER_WECHAT_RESTORE_TIMEOUT", "从微信恢复拼多多后未在限定时间到达订单页面") } - pause(ORDER_RESULT_SAMPLE_INTERVAL_MS) - return@repeat + observePause(ORDER_RESULT_SAMPLE_INTERVAL_MS) + continue } if (snapshot.packageName != PDD_PACKAGE) { return unknown("PURCHASE_ORDER_UNEXPECTED_APP", "核单期间出现未授权应用") @@ -426,7 +462,8 @@ class PurchaseLiveAutomation( if (!driver.backPurchase()) { return unknown("PURCHASE_ORDER_PAYMENT_BACK_FAILED", "支付页无法安全返回订单详情") } - pause(500) + paymentBackSucceededAt = monotonicClockMs() + observePause(500) } else { consecutivePaymentSamplesAfterBack++ if (consecutivePaymentSamplesAfterBack >= ORDER_RESULT_PAYMENT_POST_BACK_MAX_SAMPLES) { @@ -437,20 +474,21 @@ class PurchaseLiveAutomation( "consecutivePaymentSamplesAfterBack=$consecutivePaymentSamplesAfterBack]", ) } - pause(ORDER_RESULT_SAMPLE_INTERVAL_MS) + observePause(ORDER_RESULT_SAMPLE_INTERVAL_MS) } - return@repeat + continue } consecutivePaymentSamplesAfterBack = 0 + if (orderContextVisible) orderContextObserved = true if (unpaidContextVisible) { paymentPageObserved = true if (unpaidStartSample == null) unpaidStartSample = index } if (!orderContextVisible && !unpaidContextVisible) { - if (unpaidStartSample != null) { - // After reaching the unpaid page only observe; never click a newly exposed control. - pause(ORDER_RESULT_SAMPLE_INTERVAL_MS) - return@repeat + if (unpaidStartSample != null || passiveExtension) { + // Neither unpaid reading nor the added observation budget permits a new click. + observePause(ORDER_RESULT_SAMPLE_INTERVAL_MS) + continue } val entries = orderDetailEntryTargets(snapshot) if (entries.size > 1) { @@ -466,27 +504,25 @@ class PurchaseLiveAutomation( else -> return unknown("PURCHASE_ORDER_DETAIL_ENTRY_FAILED", "订单详情入口点击失败,已停止只读核单") } orderDetailEntryOpened = true - pause(500) - return@repeat + observePause(500) + continue } - pause(ORDER_RESULT_SAMPLE_INTERVAL_MS) - return@repeat + observePause(ORDER_RESULT_SAMPLE_INTERVAL_MS) + continue } currentLabels.forEach(labels::add) parseOrderEvidence(labels)?.let { return it } if (unpaidStartSample != null && unpaidSwipes < ORDER_RESULT_UNPAID_MAX_SWIPES) { unpaidSwipes++ driver.swipePurchase(SwipeDirection.UP, 400) - pause(ORDER_RESULT_UNPAID_SETTLE_MS) - return@repeat + observePause(ORDER_RESULT_UNPAID_SETTLE_MS) + continue } - if (unpaidStartSample == null && index > 0 && index % ORDER_RESULT_SCROLL_SAMPLE_INTERVAL == 0) { + if (!passiveExtension && unpaidStartSample == null && index > 0 && index % ORDER_RESULT_SCROLL_SAMPLE_INTERVAL == 0) { driver.swipePurchase(SwipeDirection.UP, 400) } - pause(ORDER_RESULT_SAMPLE_INTERVAL_MS) + observePause(ORDER_RESULT_SAMPLE_INTERVAL_MS) } - val failure = orderEvidenceFailure(labels) - return unknown(failure.code, failure.message) } // orderEvidenceFailure 之外的路径(选择器卡住、微信恢复超时等)同样要带上 @@ -509,16 +545,12 @@ class PurchaseLiveAutomation( val modify = addressModifyTargets(panel) if (modify.size != 1) fail("PURCHASE_ADDRESS_EDIT_AMBIGUOUS", "没有找到唯一的地址修改按钮,未创建订单") click(modify.single(), "修改地址") - val edit = waitFor("PURCHASE_ADDRESS_EDIT_TIMEOUT", "地址编辑页面打开超时,未创建订单") { snapshot -> - snapshot.nodes.any { it.visible && it.label.replace(" ", "").contains("详细地址") } - } - val editors = shippingAddressEditors(edit) - if (editors.size != 1) fail("PURCHASE_ADDRESS_UPDATE_FAILED", "没有找到唯一的详细地址输入框,未创建订单") - val current = editors.single().label.trim() + val editor = waitForInitialAddressEditor() + val current = editor.text.orEmpty().trim() val body = current.split(Regex("[-_]"), limit = 2).firstOrNull()?.trim().orEmpty() if (body.isBlank()) fail("PURCHASE_ADDRESS_UPDATE_FAILED", "当前详细地址无效,未创建订单") val expected = body + suffix - if (driver.inputFresh(editors.single(), expected) != FreshActionResult.SUCCESS) fail("PURCHASE_ADDRESS_UPDATE_FAILED", "详细地址修改失败,未创建订单") + if (driver.inputFresh(editor, expected) != FreshActionResult.SUCCESS) fail("PURCHASE_ADDRESS_UPDATE_FAILED", "详细地址修改失败,未创建订单") val stable = waitFor("PURCHASE_ADDRESS_INPUT_MISMATCH", "详细地址修改后回读不一致,未创建订单") { snapshot -> snapshot.nodes.count { it.visible && it.enabled && it.className?.endsWith("EditText") == true && it.label == expected } == 1 } @@ -539,6 +571,47 @@ class PurchaseLiveAutomation( return ShippingAddressProof(expected, suffix) } + /** Initial readiness only; return the same sampled node for the original value and fresh input anchor. */ + private fun waitForInitialAddressEditor(): SnapshotNode { + fun isHint(editor: SnapshotNode): Boolean { + val hint = editor.hintText?.trim().orEmpty() + return editor.showingHintText == true || (hint.isNotEmpty() && editor.text?.trim() == hint) + } + var titleSeen = false + var structural = emptyList() + var readyCount = 0 + repeat(50) { + val snapshot = driver.capture() + pageProblem(snapshot) + val labels = snapshot.nodes.filter { it.visible && it.label.replace(" ", "").contains("详细地址") } + titleSeen = titleSeen || labels.isNotEmpty() + // Keep the existing same-row/rightward region, but count empty and disabled inputs too. + // Their presence is structural ambiguity, even when just one input is ready. + structural = snapshot.nodes.filter { editor -> + editor.visible && editor.className?.endsWith("EditText") == true && + labels.any { label -> + editor.bounds.top <= label.bounds.bottom && + editor.bounds.bottom >= label.bounds.top && + editor.bounds.left >= label.bounds.left + } + }.distinctBy { it.path } + readyCount = structural.count { it.enabled && !it.text.isNullOrBlank() && !isHint(it) } + if (structural.size == 1 && readyCount == 1) return structural.single() + pause(200) + } + val reason = when { + !titleSeen -> "title_missing" + structural.isEmpty() -> "editor_missing" + structural.size > 1 -> "editor_ambiguous" + !structural.single().enabled -> "editor_disabled" + structural.single().text.isNullOrBlank() -> "editor_empty" + else -> "editor_hint" + } + val diagnostic = "stage=address_editor_ready;reason=$reason;titleSeen=$titleSeen;structural=${structural.size};ready=$readyCount" + if (!titleSeen) fail("PURCHASE_ADDRESS_EDIT_TIMEOUT", "地址编辑页面打开超时,未创建订单 [$diagnostic]") + fail("PURCHASE_ADDRESS_UPDATE_FAILED", "详细地址输入框未就绪,未创建订单 [$diagnostic]") + } + private fun isPurchaseConfirmationPanel(snapshot: UiSnapshot): Boolean { if (snapshot.packageName != PDD_PACKAGE || shippingAddressEditors(snapshot).isNotEmpty()) return false val screen = PddScreenParser.parse(snapshot, PurchaseRehearsalExecutor.DEFAULT_COLLECTOR, "", null) @@ -1038,6 +1111,7 @@ class PurchaseLiveAutomation( val ANDROID_CHOOSER_TITLES = setOf("选择要使用的应用", "使用以下应用打开", "完成操作时使用") val PDD_PAYMENT_ACTIVITIES = setOf("com.xunmeng.pinduoduo.app_pay.core.PayActivity") const val ORDER_RESULT_MAX_SAMPLES = 60 + const val ORDER_RESULT_POST_PAYMENT_OBSERVATION_MS = 30_000L const val ORDER_RESULT_MAX_EMPTY_SAMPLES = 15 const val ORDER_RESULT_WECHAT_RESTORE_MAX_SAMPLES = 15 const val ORDER_RESULT_PAYMENT_POST_BACK_MAX_SAMPLES = 25 diff --git a/android/app/src/main/java/cn/ilapage/goauto/agent/diagnostics/AccessibilitySnapshotSource.kt b/android/app/src/main/java/cn/ilapage/goauto/agent/diagnostics/AccessibilitySnapshotSource.kt new file mode 100644 index 0000000..8e16cff --- /dev/null +++ b/android/app/src/main/java/cn/ilapage/goauto/agent/diagnostics/AccessibilitySnapshotSource.kt @@ -0,0 +1,102 @@ +package cn.ilapage.goauto.agent.diagnostics + +import android.graphics.Rect +import android.os.Build +import android.view.accessibility.AccessibilityNodeInfo +import android.view.accessibility.AccessibilityWindowInfo +import cn.ilapage.goauto.agent.automation.GoAutoAccessibilityService +import cn.ilapage.goauto.agent.BuildConfig + +/** Independent getWindows adapter. The normal capture()/UiSnapshot parser stays untouched. */ +class AccessibilitySnapshotSource(private val service: GoAutoAccessibilityService) { + fun windows(): List { + // This guard is part of the timed synchronous sampling callback, not a preceding unbounded read. + val started = System.nanoTime() + val foreground = try { service.currentPackage() } catch (_: Exception) { throw SnapshotUnavailableException("accessibility_unavailable") } + if (foreground == BuildConfig.APPLICATION_ID) throw SnapshotUnavailableException("execution_not_entered") + if ((System.nanoTime() - started) / 1_000_000 >= 1500) throw SnapshotUnavailableException("time_limit") + return service.windows.map { Window(it) } + } + + private inner class Window(private val window: AccessibilityWindowInfo) : SnapshotWindow { + private var cachedRoot: AccessibilityNodeInfo? = null + private var rootRead = false + private fun rawRoot(): AccessibilityNodeInfo? { + if (!rootRead) { cachedRoot = window.root; rootRead = true } + return cachedRoot + } + override fun attribute(name: String): SnapshotAttribute = read { + when (name) { + "id" -> window.id + "type" -> window.type + "layer" -> window.layer + "title" -> if (Build.VERSION.SDK_INT >= 24) window.title else return SnapshotAttribute(state="unsupported") + "active" -> window.isActive + "focused" -> window.isFocused + "bounds" -> Rect().also(window::getBoundsInScreen).flatten() + "packageName" -> rawRoot()?.packageName + "activityName" -> if (window.isActive) service.currentActivity() else null + else -> return SnapshotAttribute(state="unsupported") + } + } + override fun root(): SnapshotNode? = rawRoot()?.let { Node(AccessibilityNodeInfo.obtain(it)) } + @Suppress("DEPRECATION") + override fun close() { cachedRoot?.recycle(); window.recycle() } + } + + private class Node(private val node: AccessibilityNodeInfo) : SnapshotNode { + override fun attribute(name: String): SnapshotAttribute = read { + when (name) { + "text" -> node.text + "resource-id" -> node.viewIdResourceName + "class" -> node.className + "package" -> node.packageName + "content-desc" -> node.contentDescription + "checkable" -> node.isCheckable + "checked" -> node.isChecked + "clickable" -> node.isClickable + "enabled" -> node.isEnabled + "focusable" -> node.isFocusable + "focused" -> node.isFocused + "scrollable" -> node.isScrollable + "long-clickable" -> node.isLongClickable + "password" -> node.isPassword + "selected" -> node.isSelected + "bounds" -> Rect().also(node::getBoundsInScreen).flatten() + "visible-to-user" -> node.isVisibleToUser + "editable" -> node.isEditable + "hint" -> if (Build.VERSION.SDK_INT >= 26) node.hintText else return SnapshotAttribute(state="unsupported") + "state-description" -> if (Build.VERSION.SDK_INT >= 30) node.stateDescription else return SnapshotAttribute(state="unsupported") + "tooltip" -> if (Build.VERSION.SDK_INT >= 28) node.tooltipText else return SnapshotAttribute(state="unsupported") + "input-type" -> node.inputType + "max-text-length" -> node.maxTextLength + "drawing-order" -> if (Build.VERSION.SDK_INT >= 24) node.drawingOrder else return SnapshotAttribute(state="unsupported") + "collection-row-count" -> node.collectionInfo?.rowCount + "collection-column-count" -> node.collectionInfo?.columnCount + "collection-hierarchical" -> node.collectionInfo?.isHierarchical + "collection-selection-mode" -> node.collectionInfo?.selectionMode + "collection-item-row-index" -> node.collectionItemInfo?.rowIndex + "collection-item-row-span" -> node.collectionItemInfo?.rowSpan + "collection-item-column-index" -> node.collectionItemInfo?.columnIndex + "collection-item-column-span" -> node.collectionItemInfo?.columnSpan + "collection-item-heading" -> node.collectionItemInfo?.isHeading + "collection-item-selected" -> node.collectionItemInfo?.isSelected + "range-type" -> node.rangeInfo?.type + "range-min" -> node.rangeInfo?.min + "range-max" -> node.rangeInfo?.max + "range-current" -> node.rangeInfo?.current + else -> return SnapshotAttribute(state="unsupported") + } + } + override fun childCount() = node.childCount + override fun child(index: Int) = node.getChild(index)?.let(::Node) + override fun actions() = node.actionList.map { it.id to read { it.label } } + @Suppress("DEPRECATION") + override fun close() = node.recycle() + } + + companion object { + private inline fun read(block: () -> Any?): SnapshotAttribute = try { SnapshotAttribute(block()?.toString()) } catch (_: Exception) { SnapshotAttribute(state="read_error") } + private fun Rect.flatten() = "[$left,$top][$right,$bottom]" + } +} diff --git a/android/app/src/main/java/cn/ilapage/goauto/agent/diagnostics/FailureSnapshotCapture.kt b/android/app/src/main/java/cn/ilapage/goauto/agent/diagnostics/FailureSnapshotCapture.kt new file mode 100644 index 0000000..95b31d7 --- /dev/null +++ b/android/app/src/main/java/cn/ilapage/goauto/agent/diagnostics/FailureSnapshotCapture.kt @@ -0,0 +1,20 @@ +package cn.ilapage.goauto.agent.diagnostics + +/** Synchronous capture boundary; persistence/upload must never move the live read onto another task. */ +class FailureSnapshotCapture( + private val hasArchive: (String) -> Boolean, + private val persist: (FailureSnapshot) -> Unit, + private val exporter: FailureSnapshotExporter = FailureSnapshotExporter(), +) { + fun record(context: SnapshotContext?, resultType: String, safelyStored: Boolean, executionEntered: Boolean, + accessibilityAvailable: Boolean, windows: () -> List, unavailableReason: String? = null) { + if (context == null || !FailureSnapshotPolicy.eligible(context.phase,resultType,safelyStored) || hasArchive(context.taskAttemptId)) return + val reason = unavailableReason ?: when { + !executionEntered -> "execution_not_entered" + !accessibilityAvailable -> "accessibility_unavailable" + else -> null + } + val snapshot = try { exporter.capture(context, reason, windows) } catch (_: Exception) { exporter.notCaptured(context,"capture_failed") } + persist(snapshot) + } +} diff --git a/android/app/src/main/java/cn/ilapage/goauto/agent/diagnostics/FailureSnapshotExporter.kt b/android/app/src/main/java/cn/ilapage/goauto/agent/diagnostics/FailureSnapshotExporter.kt new file mode 100644 index 0000000..3d4d9cd --- /dev/null +++ b/android/app/src/main/java/cn/ilapage/goauto/agent/diagnostics/FailureSnapshotExporter.kt @@ -0,0 +1,304 @@ +package cn.ilapage.goauto.agent.diagnostics + +import org.json.JSONArray +import org.json.JSONObject +import java.io.ByteArrayOutputStream +import java.text.SimpleDateFormat +import java.util.ArrayDeque +import java.util.Date +import java.util.Locale +import java.util.TimeZone +import java.util.zip.ZipEntry +import java.util.zip.ZipOutputStream + +data class FailureSnapshot(val metadataJson: String, val archive: ByteArray?, val serverOrigin: String = "") +class SnapshotUnavailableException(val reason: String) : Exception(reason) +data class SnapshotAttribute(val value: String? = null, val state: String = if (value == null) "null" else "value") +interface SnapshotNode { + fun attribute(name: String): SnapshotAttribute + fun childCount(): Int + fun child(index: Int): SnapshotNode? + fun actions(): List> = emptyList() + fun close() {} +} +interface SnapshotWindow { + fun attribute(name: String): SnapshotAttribute + fun root(): SnapshotNode? + fun close() {} +} +data class SnapshotContext( + val taskId: Long, val deviceId: Long, val taskAttemptId: String, val phase: String, + val ruleSnapshotHash: String?, val errorCode: String, val agentVersion: String, + val source: String, val serverOrigin: String = "", +) + +object FailureSnapshotPolicy { + fun eligible(phase: String, result: String, safelyStored: Boolean) = + !safelyStored && phase in setOf("purchase", "spec_probe") && result in setOf("failed", "order_result_unknown") + fun retryUpload(httpStatus: Int) = httpStatus == 0 || httpStatus in setOf(408, 429) || httpStatus >= 500 + fun exceptionEligible(knownResult: String?, safelyStored: Boolean) = !safelyStored && + knownResult !in setOf("spec_probe_completed", "order_created", "rehearsal_completed") +} + +/** No extras, reflection, screenshots, parsing or text normalization. */ +object SnapshotXml { + private const val alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/" + val contentAttributes = setOf("text", "content-desc", "hint", "state-description", "tooltip") + val attributes = listOf("text", "resource-id", "class", "package", "content-desc", "checkable", "checked", "clickable", "enabled", "focusable", "focused", "scrollable", "long-clickable", "password", "selected", "bounds", "visible-to-user", "editable", "hint", "state-description", "tooltip", "input-type", "max-text-length", "drawing-order", "collection-row-count", "collection-column-count", "collection-hierarchical", "collection-selection-mode", "collection-item-row-index", "collection-item-row-span", "collection-item-column-index", "collection-item-column-span", "collection-item-heading", "collection-item-selected", "range-type", "range-min", "range-max", "range-current") + fun decode(value: String): String { + val output = ByteArrayOutputStream() + var accumulator = 0; var bits = 0 + for (c in value) { + if (c == '=') break + val digit = alphabet.indexOf(c); require(digit >= 0) + accumulator = (accumulator shl 6) or digit; bits += 6 + if (bits >= 8) { bits -= 8; output.write((accumulator shr bits) and 255) } + } + val bytes = output.toByteArray() + return CharArray(bytes.size / 2) { ((bytes[it * 2].toInt() and 255) * 256 + (bytes[it * 2 + 1].toInt() and 255)).toChar() }.concatToString() + } + private fun encode(value: String): String { + val bytes = ByteArray(value.length * 2) + value.forEachIndexed { index, c -> bytes[index * 2] = (c.code shr 8).toByte(); bytes[index * 2 + 1] = c.code.toByte() } + val encoded = StringBuilder((bytes.size + 2) / 3 * 4) + var index = 0 + while (index < bytes.size) { + val remaining = bytes.size - index + val value24 = ((bytes[index].toInt() and 255) shl 16) or + (if (remaining > 1) (bytes[index + 1].toInt() and 255) shl 8 else 0) or + (if (remaining > 2) bytes[index + 2].toInt() and 255 else 0) + encoded.append(alphabet[(value24 shr 18) and 63]).append(alphabet[(value24 shr 12) and 63]) + .append(if (remaining > 1) alphabet[(value24 shr 6) and 63] else '=') + .append(if (remaining > 2) alphabet[value24 and 63] else '=') + index += 3 + } + return encoded.toString() + } + private fun legal(value: String): Boolean { + var i = 0 + while (i < value.length) { + val c = value[i].code + if (c in 0xD800..0xDBFF) { + if (i + 1 >= value.length || value[i + 1].code !in 0xDC00..0xDFFF) return false + i += 2; continue + } + if (!(c == 9 || c == 10 || c == 13 || c in 0x20..0xD7FF || c in 0xE000..0xFFFD)) return false + i++ + } + return true + } + fun attribute(name: String, attr: SnapshotAttribute): String { + if (attr.state != "value") return " $name-state=\"${attr.state}\"" + val value = attr.value ?: return " $name-state=\"null\"" + if (!legal(value)) return " $name-base64=\"${encode(value)}\"" + return " $name=\"" + value.replace("&", "&").replace("<", "<").replace(">", ">").replace("\"", """).replace("\n", " ").replace("\r", " ").replace("\t", " ") + "\"" + } +} + +class FailureSnapshotExporter( + private val nodeLimit: Int = 5000, + private val timeLimitMillis: Long = 1500, + private val expandedLimit: Int = 8 * 1024 * 1024, + private val archiveLimit: Int = 2 * 1024 * 1024, + private val monotonicMillis: () -> Long = { System.nanoTime() / 1_000_000 }, + private val epochMillis: () -> Long = System::currentTimeMillis, +) { + fun notCaptured(context: SnapshotContext, reason: String): FailureSnapshot = capture(context, reason) { emptyList() } + + fun capture(context: SnapshotContext, unavailableReason: String? = null, windows: () -> List): FailureSnapshot { + val now = timestamp(epochMillis()) + val reasons = linkedSetOf() + val metadata = JSONObject().put("schemaVersion", 1).put("attributeVersion", 1) + .put("taskId", context.taskId).put("deviceId", context.deviceId).put("taskAttemptId", context.taskAttemptId) + .put("phase", context.phase).put("errorCode", context.errorCode.take(64)).put("agentVersion", context.agentVersion.take(64)) + .put("recordedAt", now).put("source", context.source) + val validHash = context.ruleSnapshotHash?.matches(Regex("[a-f0-9]{64}")) == true + metadata.put("ruleSnapshotHash", if (validHash) context.ruleSnapshotHash else JSONObject.NULL).put("ruleSnapshotHashValid", validHash) + val windowMetadata = JSONArray() + val files = linkedMapOf() + data class Cut(val offset: Int, val openNodes: Int) + val fileCuts = mutableMapOf>() + metadata.put("windows", windowMetadata) + val start = monotonicMillis() + var count = 0 + var expanded = 0 + fun timeAvailable(): Boolean { + if (monotonicMillis() - start >= timeLimitMillis) { reasons.add("time_limit"); return false } + return true + } + if (!validHash) reasons.add("rule_hash_invalid") + else if (unavailableReason != null) reasons.add(unavailableReason) + else { + val sources = try { if (timeAvailable()) windows() else emptyList() } catch (error: SnapshotUnavailableException) { + reasons.add(error.reason); emptyList() + } catch (_: UnsupportedOperationException) { + reasons.add("windows_unsupported"); emptyList() + } catch (_: Exception) { reasons.add("windows_unavailable"); emptyList() } + try { + for (window in sources) { + if (!timeAvailable()) break + // Bound the metadata separately from the expanded archive; retain explicit partial evidence. + if (windowMetadata.length() >= 128 || windowMetadata.toString().toByteArray(Charsets.UTF_8).size > 32 * 1024) { reasons.add("expanded_size_limit"); break } + val wm = JSONObject().put("capturedAt", now).put("fileName", JSONObject.NULL) + val states = JSONObject() + val wr = linkedSetOf() + val fields = listOf("id", "type", "layer", "title", "active", "focused", "bounds", "packageName", "activityName") + for (name in fields) { + if (!timeAvailable()) { wm.put(name, JSONObject.NULL); states.put(name, "read_error"); wr.add("time_limit"); continue } + val value = try { window.attribute(name) } catch (_: Exception) { SnapshotAttribute(state="read_error") } + if (!timeAvailable()) wr.add("time_limit") + if (value.state == "read_error") wr.add("window_read_error") + if (value.value != null && value.value.length > 4096) { + wm.put(name, JSONObject.NULL); states.put(name, "read_error"); wr.add("expanded_size_limit") + } else if (value.state != "value" || value.value == null) { + wm.put(name, JSONObject.NULL); states.put(name, value.state) + } else when (name) { + "id", "type", "layer" -> { + val number = value.value.toIntOrNull() + wm.put(name, number ?: JSONObject.NULL) + if (number == null) { states.put(name,"read_error"); wr.add("window_read_error") } + } + "active", "focused" -> wm.put(name, value.value == "true") + else -> wm.put(name, value.value) + } + } + wm.put("attributeStates", states) + windowMetadata.put(wm) + val id = if (wm.isNull("id")) null else wm.getInt("id") + val fileName = id?.let { "window-$it.xml" } + var root: SnapshotNode? = null + if (fileName != null && !files.containsKey(fileName) && timeAvailable()) { + root = try { window.root() } catch (_: Exception) { wr.add("window_read_error"); null } + if (!timeAvailable()) { root?.close(); root = null; wr.add("time_limit") } + else if (root == null) wr.add("window_root_null") + } else if (fileName == null || files.containsKey(fileName)) wr.add("window_read_error") + if (root != null) { + val xml = StringBuilder("") + var bytes = xml.length + data class Frame(val node: SnapshotNode, val path: String, var next: Int = -1, var children: Int = 0) + val stack = ArrayDeque() + stack.push(Frame(root, "0")) + var emitted = 0 + val cuts = mutableListOf() + try { + while (stack.isNotEmpty()) { + val frame = requireNotNull(stack.peek()) + if (!timeAvailable()) { wr.add("time_limit"); break } + if (frame.next == -1) { + if (count >= nodeLimit || stack.size > 250) { wr.add("node_limit"); break } + val attrs = StringBuilder(" expandedLimit / 4) { tooLarge = true; break } + attrs.append(SnapshotXml.attribute(name, value)) + if (attrs.length > expandedLimit / 4) { tooLarge = true; break } + } + var childCountState: String? = null + frame.children = try { if (timeAvailable()) frame.node.childCount().coerceAtLeast(0) else { childCountState = "read_error"; 0 } } catch (_: Exception) { wr.add("node_read_error"); childCountState = "read_error"; 0 } + if (!timeAvailable()) wr.add("time_limit") + attrs.append(if (childCountState == null) " child-count=\"${frame.children}\"" else " child-count-state=\"$childCountState\"") + var actionsReadError = false + val actions = try { if (timeAvailable()) frame.node.actions() else { actionsReadError = true; emptyList() } } catch (_: Exception) { wr.add("node_read_error"); actionsReadError = true; emptyList() } + if (actionsReadError) attrs.append(" actions-state=\"read_error\"") + attrs.append('>') + if (!timeAvailable()) wr.add("time_limit") + for ((actionId, label) in actions) { + attrs.append(" expandedLimit / 4) { tooLarge = true; break } + attrs.append(SnapshotXml.attribute("label", label)) + if (label.state == "read_error") wr.add("node_read_error") + } + attrs.append("/>") + if (attrs.length > expandedLimit / 4 || !timeAvailable()) { tooLarge = true; break } + } + val addition = attrs.toString().toByteArray(Charsets.UTF_8).size + if (tooLarge || expanded + bytes + addition + stack.size * 7 + 65536 >= expandedLimit) { wr.add("expanded_size_limit"); break } + if (!timeAvailable()) { wr.add("time_limit"); break } + cuts.add(Cut(xml.length, stack.size - 1)) + xml.append(attrs); bytes += addition; count++; emitted++; frame.next = 0 + } else if (frame.next >= frame.children) { + xml.append(""); bytes += 7; stack.pop().node.close() + } else { + if (count >= nodeLimit) { wr.add("node_limit"); break } + val index = frame.next++ + val child = try { frame.node.child(index) } catch (_: Exception) { wr.add("node_read_error"); null } + if (child == null) wr.add("window_disappeared") + else stack.push(Frame(child, "${frame.path}/$index")) + } + } + } finally { + while (stack.isNotEmpty()) { + val frame = stack.pop() + if (frame.next >= 0) xml.append("") + runCatching { frame.node.close() } + } + } + xml.append("") + if (emitted > 0) { + val content = xml.toString().toByteArray(Charsets.UTF_8) + files[requireNotNull(fileName)] = content; expanded += content.size + fileCuts[fileName] = cuts + wm.put("fileName", fileName) + } + } + wm.put("status", if (wr.isEmpty() && !wm.isNull("fileName")) "complete" else "partial").put("reasons", JSONArray(wr.toList())) + reasons.addAll(wr) + } + } finally { sources.forEach { runCatching { it.close() } } } + if (sources.isEmpty() && reasons.isEmpty()) reasons.add("windows_unavailable") + } + fun finishMetadata() { + metadata.put("status", if (files.isEmpty()) "not_captured" else if (reasons.isEmpty()) "complete" else "partial") + .put("capturedAt", if (files.isEmpty()) JSONObject.NULL else now).put("reasons", JSONArray(reasons.toList())) + } + finishMetadata() + var archive: ByteArray? = null + while (files.isNotEmpty()) { + val output = ByteArrayOutputStream() + ZipOutputStream(output).use { zip -> + zip.setLevel(1) + fun entry(name: String, data: ByteArray) { zip.putNextEntry(ZipEntry(name).apply { time = 0 }); zip.write(data); zip.closeEntry() } + entry("manifest.json", metadata.toString().toByteArray(Charsets.UTF_8)) + files.forEach { (name, data) -> entry(name, data) } + } + val alreadyTimedOut = "time_limit" in reasons + if (!timeAvailable() && !alreadyTimedOut) { finishMetadata(); continue } + if (output.size() <= archiveLimit) { archive = output.toByteArray(); break } + reasons.add("archive_size_limit") + val removed = files.keys.last() + val cuts = fileCuts.getValue(removed) + if (cuts.size > 1) { + val index = (cuts.size / 2).coerceAtLeast(1) + val cut = cuts[index] + val prefix = String(files.getValue(removed), Charsets.UTF_8).substring(0, cut.offset) + files[removed] = (prefix + "".repeat(cut.openNodes) + "").toByteArray(Charsets.UTF_8) + fileCuts[removed] = cuts.take(index) + } else files.remove(removed) + for (i in 0 until windowMetadata.length()) { + val w = windowMetadata.getJSONObject(i) + if (w.optString("fileName") == removed) { + if (removed !in files) w.put("fileName", JSONObject.NULL) + w.put("status", "partial") + if (!w.getJSONArray("reasons").toString().contains("archive_size_limit")) w.getJSONArray("reasons").put("archive_size_limit") + } + } + finishMetadata() + } + return FailureSnapshot(metadata.toString(), archive, context.serverOrigin) + } + + private fun timestamp(millis: Long) = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.US).apply { timeZone = TimeZone.getTimeZone("UTC") }.format(Date(millis)) +} 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 5c008a0..c0daa78 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 @@ -211,6 +211,45 @@ class AgentApiException( ) : Exception(message) class AgentApiClient(private val serverUrl: String) { + val failureSnapshotOrigin: String get() = ServerUrlPolicy.normalize(serverUrl) + /** Stream the two bounded parts; never build another combined copy of the archive. */ + fun uploadFailureSnapshot(snapshot: cn.ilapage.goauto.agent.diagnostics.FailureSnapshot, token: String) { + val metadata = snapshot.metadataJson.toByteArray(Charsets.UTF_8) + require(metadata.size <= 64 * 1024 && (snapshot.archive?.size ?: 0) <= 2 * 1024 * 1024) + val json = JSONObject(snapshot.metadataJson) + val taskId = json.getLong("taskId") + val attemptId = json.getString("taskAttemptId") + require(taskId > 0 && attemptId.matches(Regex("[0-9a-fA-F]{8}(-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12}"))) + val boundary = "GoAuto-${UUID.randomUUID()}" + val prefix = "--$boundary\r\nContent-Disposition: form-data; name=\"metadata\"\r\nContent-Type: application/json; charset=UTF-8\r\n\r\n".toByteArray(Charsets.UTF_8) + val archiveHeader = if (snapshot.archive != null) "\r\n--$boundary\r\nContent-Disposition: form-data; name=\"archive\"; filename=\"failure-snapshot.zip\"\r\nContent-Type: application/zip\r\n\r\n".toByteArray(Charsets.UTF_8) else ByteArray(0) + val suffix = "\r\n--$boundary--\r\n".toByteArray(Charsets.UTF_8) + val origin = ServerUrlPolicy.normalize(serverUrl) + require(snapshot.serverOrigin.isEmpty() || origin == snapshot.serverOrigin) + val connection = (URL("$origin/api/agent/v1/purchase-tasks/$taskId/attempts/$attemptId/failure-snapshot").openConnection() as HttpURLConnection).apply { + requestMethod = "POST"; connectTimeout = 5000; readTimeout = 5000 + doOutput = true; useCaches = false; instanceFollowRedirects = false + setFixedLengthStreamingMode(prefix.size + metadata.size + archiveHeader.size + (snapshot.archive?.size ?: 0) + suffix.size) + setRequestProperty("Content-Type", "multipart/form-data; boundary=$boundary") + setRequestProperty("Authorization", "Bearer $token") + setRequestProperty("Accept", "application/json") + setRequestProperty("Cache-Control", "no-store") + } + try { + connection.outputStream.use { output -> + output.write(prefix); output.write(metadata); output.write(archiveHeader) + snapshot.archive?.inputStream()?.use { it.copyTo(output, 16 * 1024) } + output.write(suffix) + } + val status = connection.responseCode + if (status !in 200..299) throw AgentApiException(status, "SNAPSHOT_UPLOAD_HTTP_$status", "诊断快照上传失败", cn.ilapage.goauto.agent.diagnostics.FailureSnapshotPolicy.retryUpload(status)) + val response = connection.inputStream.bufferedReader(Charsets.UTF_8).use { it.readText() } + val data = JSONObject(response).getJSONObject("data") + check(data.getString("taskAttemptId") == attemptId && data.getString("status") == json.getString("status")) + data.getBoolean("replayed") + } finally { connection.disconnect() } + } + fun backfillOrders(requestId: String, items: List, token: String): List = parseBackfillResults(post("/api/agent/v1/purchase-tasks/order-backfill", backfillPayload(requestId, items), token)) diff --git a/android/app/src/main/java/cn/ilapage/goauto/agent/persistence/AgentDiagnosticSchema.kt b/android/app/src/main/java/cn/ilapage/goauto/agent/persistence/AgentDiagnosticSchema.kt index 3105726..edaab41 100644 --- a/android/app/src/main/java/cn/ilapage/goauto/agent/persistence/AgentDiagnosticSchema.kt +++ b/android/app/src/main/java/cn/ilapage/goauto/agent/persistence/AgentDiagnosticSchema.kt @@ -1,7 +1,26 @@ package cn.ilapage.goauto.agent.persistence internal object AgentDiagnosticSchema { - const val VERSION = 3 + const val VERSION = 4 + + val failureSnapshotStatements = listOf( + """CREATE TABLE IF NOT EXISTS purchase_failure_snapshot ( + attempt_id TEXT PRIMARY KEY, server_origin TEXT NOT NULL, + task_id INTEGER NOT NULL, device_id INTEGER NOT NULL, phase TEXT NOT NULL, + recorded_at INTEGER NOT NULL, expires_at INTEGER NOT NULL, + source TEXT NOT NULL, status TEXT NOT NULL, metadata TEXT NOT NULL, + archive BLOB, fingerprint TEXT NOT NULL, size_bytes INTEGER NOT NULL + )""".trimIndent(), + """CREATE TABLE IF NOT EXISTS purchase_failure_snapshot_queue ( + attempt_id TEXT PRIMARY KEY, fingerprint TEXT NOT NULL, + next_attempt_at INTEGER NOT NULL, attempts INTEGER NOT NULL DEFAULT 0 + )""".trimIndent(), + """CREATE TABLE IF NOT EXISTS purchase_failure_snapshot_context ( + attempt_id TEXT PRIMARY KEY, server_origin TEXT NOT NULL, + context TEXT NOT NULL, created_at INTEGER NOT NULL + )""".trimIndent(), + "CREATE INDEX IF NOT EXISTS idx_failure_snapshot_expiry ON purchase_failure_snapshot(expires_at)", + ) val purchaseDiagnosticColumns = linkedMapOf( "task_type" to "TEXT", "task_attempt_id" to "TEXT", "device_id" to "INTEGER", @@ -65,7 +84,8 @@ internal object AgentDiagnosticSchema { fun migrationStatements(oldVersion: Int, newVersion: Int, existingColumns: Set): List = v2MigrationStatements(oldVersion, newVersion, existingColumns) + - if (oldVersion < 3 && newVersion >= 3) purchaseDiagnosticColumns.mapNotNull { (name, definition) -> + (if (oldVersion < 3 && newVersion >= 3) purchaseDiagnosticColumns.mapNotNull { (name, definition) -> if (name in existingColumns) null else "ALTER TABLE agent_diagnostic ADD COLUMN $name $definition" - } else emptyList() + } else emptyList()) + + (if (oldVersion < 4 && newVersion >= 4) failureSnapshotStatements else emptyList()) } diff --git a/android/app/src/main/java/cn/ilapage/goauto/agent/persistence/AgentDiagnosticStore.kt b/android/app/src/main/java/cn/ilapage/goauto/agent/persistence/AgentDiagnosticStore.kt index f19d23d..752f1f1 100644 --- a/android/app/src/main/java/cn/ilapage/goauto/agent/persistence/AgentDiagnosticStore.kt +++ b/android/app/src/main/java/cn/ilapage/goauto/agent/persistence/AgentDiagnosticStore.kt @@ -136,6 +136,7 @@ class AgentDiagnosticStore(context: Context) : SQLiteOpenHelper(context, DATABAS override fun onCreate(db: SQLiteDatabase) { db.execSQL(AgentDiagnosticSchema.createTableSql) db.execSQL("CREATE INDEX idx_agent_diagnostic_task ON agent_diagnostic(task_id, id)") + AgentDiagnosticSchema.failureSnapshotStatements.forEach(db::execSQL) } override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) { @@ -199,7 +200,10 @@ class AgentDiagnosticStore(context: Context) : SQLiteOpenHelper(context, DATABAS put("agent_version", BuildConfig.VERSION_NAME) put("created_at", event.createdAt) } - check(db.insert("agent_diagnostic", null, values) != -1L) + val insertedId = db.insertOrThrow("agent_diagnostic", null, values) + db.rawQuery("SELECT id FROM agent_diagnostic WHERE id=?", arrayOf(insertedId.toString())).use { + check(it.moveToFirst()) { "diagnostic_readback_failed" } + } db.delete("agent_diagnostic", "created_at < ?", arrayOf(AgentDiagnosticRetentionPolicy.cutoff(event.createdAt).toString())) db.execSQL( "DELETE FROM agent_diagnostic WHERE id NOT IN " + diff --git a/android/app/src/main/java/cn/ilapage/goauto/agent/persistence/FailureSnapshotRepository.kt b/android/app/src/main/java/cn/ilapage/goauto/agent/persistence/FailureSnapshotRepository.kt new file mode 100644 index 0000000..829b4a5 --- /dev/null +++ b/android/app/src/main/java/cn/ilapage/goauto/agent/persistence/FailureSnapshotRepository.kt @@ -0,0 +1,182 @@ +package cn.ilapage.goauto.agent.persistence + +import cn.ilapage.goauto.agent.diagnostics.FailureSnapshot +import java.net.URI +import java.security.MessageDigest +import java.text.SimpleDateFormat +import java.text.ParsePosition +import java.util.Date +import java.util.Locale +import java.util.TimeZone +import org.json.JSONObject + +/** Small SQL boundary shared by the Android adapter and real SQLite JVM tests. */ +internal interface FailureSnapshotDatabase { + fun execute(sql: String, args: List = emptyList()) + fun query(sql: String, args: List = emptyList()): List> + fun transaction(block: () -> T): T +} + +internal class FailureSnapshotRepository( + private val db: FailureSnapshotDatabase, + private val clock: () -> Long = System::currentTimeMillis, + private val maxBytes: Long = 64L * 1024 * 1024, +) { + @Synchronized + fun recordFailureSnapshotContext(taskId: Long, attemptId: String, deviceId: Long, phase: String, ruleSnapshotHash: String?, serverOrigin: String) { + validateIdentity(taskId, attemptId, deviceId, phase) + val origin = origin(serverOrigin) + val hash = ruleSnapshotHash?.takeIf(HASH::matches) + val now = clock() + val context = JSONObject().put("taskId", taskId).put("taskAttemptId", attemptId).put("deviceId", deviceId) + .put("phase", phase).put("ruleSnapshotHash", hash ?: JSONObject.NULL).put("ruleSnapshotHashValid", hash != null) + .put("recordedAt", utcFormat().format(Date(now))).put("serverOrigin", origin).toString() + db.execute("INSERT OR IGNORE INTO purchase_failure_snapshot_context (attempt_id,server_origin,context,created_at) VALUES (?,?,?,?)", listOf(attemptId, origin, context, now)) + } + + @Synchronized + fun failureSnapshotContext(attemptId: String): String? = db.query( + "SELECT context FROM purchase_failure_snapshot_context WHERE attempt_id=? AND created_at>?", + listOf(attemptId, clock() - RETENTION_MILLIS), + ).firstOrNull()?.get("context") as? String + + @Synchronized + fun hasFailureSnapshotArchive(attemptId: String): Boolean = db.query( + "SELECT attempt_id FROM purchase_failure_snapshot WHERE attempt_id=? AND archive IS NOT NULL AND expires_at>?", + listOf(attemptId, clock()), + ).isNotEmpty() + + @Synchronized + fun saveFailureSnapshot(snapshot: FailureSnapshot): Boolean { + require(snapshot.metadataJson.toByteArray(Charsets.UTF_8).size <= 64 * 1024) + require(snapshot.archive == null || snapshot.archive.size in 1..2 * 1024 * 1024) + val metadata = JSONObject(snapshot.metadataJson) + val taskId = metadata.getLong("taskId") + val attemptId = metadata.getString("taskAttemptId") + val deviceId = metadata.getLong("deviceId") + val phase = metadata.getString("phase") + validateIdentity(taskId, attemptId, deviceId, phase) + val serverOrigin = origin(snapshot.serverOrigin) + val recordedAt = parseTimestamp(metadata.getString("recordedAt")) + val status = metadata.getString("status") + val source = metadata.getString("source") + require(source in setOf("execution", "recovery")) + require(status in setOf("complete", "partial", "not_captured")) + require((status == "not_captured") == (snapshot.archive == null)) + val validHash = metadata.getBoolean("ruleSnapshotHashValid") + require(if (validHash) HASH.matches(metadata.getString("ruleSnapshotHash")) else metadata.isNull("ruleSnapshotHash") && snapshot.archive == null) + val now = clock() + if (recordedAt <= now - RETENTION_MILLIS) return false + require(recordedAt <= now + 5 * 60_000) + val fingerprint = fingerprint(snapshot) + return db.transaction { + cleanup(now) + val old = db.query("SELECT archive IS NOT NULL AS has_archive,server_origin FROM purchase_failure_snapshot WHERE attempt_id=?", listOf(attemptId)).firstOrNull() + if (old != null && ((old["has_archive"] as Number).toInt() != 0 || old["server_origin"] != serverOrigin || snapshot.archive == null || source != "recovery")) return@transaction false + db.execute( + "INSERT OR REPLACE INTO purchase_failure_snapshot (attempt_id,server_origin,task_id,device_id,phase,recorded_at,expires_at,source,status,metadata,archive,fingerprint,size_bytes) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)", + listOf(attemptId, serverOrigin, taskId, deviceId, phase, recordedAt, recordedAt + RETENTION_MILLIS, source, status, + snapshot.metadataJson, snapshot.archive, fingerprint, snapshot.metadataJson.toByteArray(Charsets.UTF_8).size.toLong() + (snapshot.archive?.size ?: 0)), + ) + db.execute("INSERT OR REPLACE INTO purchase_failure_snapshot_queue (attempt_id,fingerprint,next_attempt_at,attempts) VALUES (?,?,?,0)", listOf(attemptId, fingerprint, now)) + check(db.query("SELECT fingerprint FROM purchase_failure_snapshot WHERE attempt_id=?", listOf(attemptId)).single()["fingerprint"] == fingerprint) + cleanup(now) + db.query("SELECT attempt_id FROM purchase_failure_snapshot WHERE attempt_id=?", listOf(attemptId)).isNotEmpty() + } + } + + @Synchronized + fun pendingFailureSnapshots(serverOrigin: String, nowMillis: Long = clock(), limit: Int = 1): List = db.transaction { + cleanup(nowMillis) + db.query("SELECT s.attempt_id,s.metadata,length(s.archive) AS archive_length,s.server_origin FROM purchase_failure_snapshot s JOIN purchase_failure_snapshot_queue q ON s.attempt_id=q.attempt_id AND s.fingerprint=q.fingerprint WHERE s.server_origin=? AND q.next_attempt_at<=? ORDER BY s.recorded_at,s.attempt_id LIMIT ?", + listOf(origin(serverOrigin), nowMillis, limit.coerceIn(1, 10))).map { row -> + val size = (row["archive_length"] as? Number)?.toInt() + require(size == null || size in 1..(2 * 1024 * 1024)) + val archive = size?.let { length -> + // CursorWindow is only ~2MiB on many devices. Never SELECT the whole ZIP row. + ByteArray(length).also { bytes -> + var offset = 0 + while (offset < length) { + val amount = minOf(256 * 1024, length - offset) + val chunk = db.query("SELECT substr(archive,?,?) AS chunk FROM purchase_failure_snapshot WHERE attempt_id=?", + listOf(offset + 1, amount, row["attempt_id"])).single()["chunk"] as ByteArray + check(chunk.size == amount) { "snapshot_readback_failed" } + chunk.copyInto(bytes, offset); offset += amount + } + } + } + FailureSnapshot(row["metadata"] as String, archive, row["server_origin"] as String) + } + } + + @Synchronized + fun markFailureSnapshotUploaded(snapshot: FailureSnapshot, terminalStatus: String = "uploaded") { + require(terminalStatus in setOf("uploaded", "expired", "rejected")) + db.execute("DELETE FROM purchase_failure_snapshot_queue WHERE attempt_id=? AND fingerprint=? AND EXISTS (SELECT 1 FROM purchase_failure_snapshot s WHERE s.attempt_id=purchase_failure_snapshot_queue.attempt_id AND s.server_origin=?)", + listOf(JSONObject(snapshot.metadataJson).getString("taskAttemptId"), fingerprint(snapshot), origin(snapshot.serverOrigin))) + } + + @Synchronized + fun retryFailureSnapshotLater(snapshot: FailureSnapshot, nowMillis: Long = clock()) = db.transaction { + val attemptId = JSONObject(snapshot.metadataJson).getString("taskAttemptId") + val digest = fingerprint(snapshot) + val queued = db.query("SELECT q.attempts FROM purchase_failure_snapshot_queue q JOIN purchase_failure_snapshot s ON s.attempt_id=q.attempt_id WHERE q.attempt_id=? AND q.fingerprint=? AND s.server_origin=?", + listOf(attemptId, digest, origin(snapshot.serverOrigin))).firstOrNull() ?: return@transaction + val attempts = (queued["attempts"] as Number).toInt().coerceIn(0, 10) + val delay = (60_000L * (1L shl attempts)).coerceAtMost(3_600_000L) + db.execute("UPDATE purchase_failure_snapshot_queue SET next_attempt_at=?,attempts=? WHERE attempt_id=? AND fingerprint=?", listOf(nowMillis + delay, attempts + 1, attemptId, digest)) + } + + @Synchronized + fun cleanupFailureSnapshots(nowMillis: Long = clock()) = db.transaction { cleanup(nowMillis) } + + private fun cleanup(now: Long) { + db.execute("DELETE FROM purchase_failure_snapshot WHERE expires_at<=?", listOf(now)) + db.execute("DELETE FROM purchase_failure_snapshot_context WHERE created_at<=?", listOf(now - RETENTION_MILLIS)) + val rows = db.query("SELECT attempt_id,size_bytes FROM purchase_failure_snapshot ORDER BY recorded_at,attempt_id") + var total = rows.sumOf { (it["size_bytes"] as Number).toLong() } + for (row in rows) { + if (total <= maxBytes) break + db.execute("DELETE FROM purchase_failure_snapshot WHERE attempt_id=?", listOf(row["attempt_id"])) + total -= (row["size_bytes"] as Number).toLong() + } + db.execute("DELETE FROM purchase_failure_snapshot_queue WHERE attempt_id NOT IN (SELECT attempt_id FROM purchase_failure_snapshot)") + } + + private fun validateIdentity(taskId: Long, attemptId: String, deviceId: Long, phase: String) { + require(taskId > 0 && deviceId > 0 && UUID.matches(attemptId) && phase in setOf("spec_probe", "purchase")) + } + + // java.time is unavailable on API 23-25; the exporter writes UTC milliseconds. + private fun utcFormat() = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.US).apply { + timeZone = TimeZone.getTimeZone("UTC"); isLenient = false + } + + private fun parseTimestamp(value: String): Long { + val match = TIMESTAMP.matchEntire(value) ?: throw IllegalArgumentException("snapshot_time_invalid") + val normalized = match.groupValues[1] + "." + match.groupValues[2].padEnd(3, '0') + "Z" + val position = ParsePosition(0) + val parsed = utcFormat().parse(normalized, position) + require(parsed != null && position.index == normalized.length) { "snapshot_time_invalid" } + return parsed.time + } + + private fun origin(value: String): String { + val normalized = value.trimEnd('/') + val uri = URI(normalized) + require(uri.scheme in setOf("http", "https") && !uri.host.isNullOrBlank() && uri.userInfo == null && uri.rawQuery == null && uri.fragment == null) + return normalized + } + + private fun fingerprint(snapshot: FailureSnapshot): String = MessageDigest.getInstance("SHA-256").run { + update(snapshot.metadataJson.toByteArray(Charsets.UTF_8)); update(0.toByte()); snapshot.archive?.let(::update) + digest().joinToString("") { "%02x".format(it) } + } + + companion object { + const val RETENTION_MILLIS = 30L * 24 * 60 * 60 * 1000 + private val UUID = Regex("[0-9a-fA-F]{8}(-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12}") + private val HASH = Regex("[0-9a-f]{64}") + private val TIMESTAMP = Regex("(\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2})(?:\\.(\\d{1,3}))?Z") + } +} diff --git a/android/app/src/main/java/cn/ilapage/goauto/agent/persistence/PurchaseFailureSnapshotStore.kt b/android/app/src/main/java/cn/ilapage/goauto/agent/persistence/PurchaseFailureSnapshotStore.kt new file mode 100644 index 0000000..faccbd9 --- /dev/null +++ b/android/app/src/main/java/cn/ilapage/goauto/agent/persistence/PurchaseFailureSnapshotStore.kt @@ -0,0 +1,40 @@ +package cn.ilapage.goauto.agent.persistence + +import android.database.Cursor +import cn.ilapage.goauto.agent.diagnostics.FailureSnapshot + +/** Uses the same private SQLite database, without sharing the old 7-day/50-row retention. */ +class PurchaseFailureSnapshotStore(helper: AgentDiagnosticStore) { + private val repository = FailureSnapshotRepository(object : FailureSnapshotDatabase { + override fun execute(sql: String, args: List) { helper.writableDatabase.execSQL(sql, args.toTypedArray()) } + override fun query(sql: String, args: List): List> = + helper.readableDatabase.rawQuery(sql, args.map { it?.toString() }.toTypedArray()).use { cursor -> buildList { + while (cursor.moveToNext()) add((0 until cursor.columnCount).associate { index -> + cursor.getColumnName(index) to when (cursor.getType(index)) { + Cursor.FIELD_TYPE_NULL -> null + Cursor.FIELD_TYPE_INTEGER -> cursor.getLong(index) + Cursor.FIELD_TYPE_FLOAT -> cursor.getDouble(index) + Cursor.FIELD_TYPE_BLOB -> cursor.getBlob(index) + else -> cursor.getString(index) + } + }) + } } + override fun transaction(block: () -> T): T { + val database = helper.writableDatabase + database.beginTransaction() + try { val result = block(); database.setTransactionSuccessful(); return result } + finally { database.endTransaction() } + } + }) + + fun saveFailureSnapshot(snapshot: FailureSnapshot): Boolean = repository.saveFailureSnapshot(snapshot) + fun hasFailureSnapshotArchive(attemptId: String): Boolean = repository.hasFailureSnapshotArchive(attemptId) + fun recordFailureSnapshotContext(taskId: Long, attemptId: String, deviceId: Long, phase: String, ruleSnapshotHash: String?, serverOrigin: String) = + repository.recordFailureSnapshotContext(taskId, attemptId, deviceId, phase, ruleSnapshotHash, serverOrigin) + fun failureSnapshotContext(attemptId: String): String? = repository.failureSnapshotContext(attemptId) + fun pendingFailureSnapshots(serverOrigin: String, nowMillis: Long = System.currentTimeMillis(), limit: Int = 1): List = + repository.pendingFailureSnapshots(serverOrigin, nowMillis, limit) + fun markFailureSnapshotUploaded(snapshot: FailureSnapshot, terminalStatus: String = "uploaded") = repository.markFailureSnapshotUploaded(snapshot, terminalStatus) + fun retryFailureSnapshotLater(snapshot: FailureSnapshot, nowMillis: Long = System.currentTimeMillis()) = repository.retryFailureSnapshotLater(snapshot, nowMillis) + fun cleanupFailureSnapshots(nowMillis: Long = System.currentTimeMillis()) = repository.cleanupFailureSnapshots(nowMillis) +} 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 5febd8d..06830ca 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 @@ -62,6 +62,11 @@ import cn.ilapage.goauto.agent.persistence.AgentDiagnosticEvent import cn.ilapage.goauto.agent.persistence.AgentDiagnosticReason import cn.ilapage.goauto.agent.persistence.AgentDiagnosticStage import cn.ilapage.goauto.agent.persistence.SafeAgentDiagnosticRecorder +import cn.ilapage.goauto.agent.persistence.PurchaseFailureSnapshotStore +import cn.ilapage.goauto.agent.diagnostics.AccessibilitySnapshotSource +import cn.ilapage.goauto.agent.diagnostics.FailureSnapshotCapture +import cn.ilapage.goauto.agent.diagnostics.FailureSnapshotPolicy +import cn.ilapage.goauto.agent.diagnostics.SnapshotContext import cn.ilapage.goauto.agent.ui.PurchaseResultBubblePolicy import org.json.JSONArray import org.json.JSONObject @@ -79,6 +84,7 @@ class AgentForegroundService : Service() { private val executor: ScheduledExecutorService = Executors.newSingleThreadScheduledExecutor() private val taskExecutor: ExecutorService = Executors.newSingleThreadExecutor() private val diagnosticExecutor: ExecutorService = Executors.newSingleThreadExecutor() + private val snapshotUploadExecutor: ScheduledExecutorService = Executors.newSingleThreadScheduledExecutor() private val taskMutex = TaskExecutionMutex() private val backfillGuard = OrderBackfillGuard(taskMutex) private val runningTaskId = AtomicReference(null) @@ -99,6 +105,7 @@ class AgentForegroundService : Service() { private lateinit var purchaseStore: PurchaseTaskStore private lateinit var diagnosticStore: AgentDiagnosticStore private lateinit var diagnosticRecorder: SafeAgentDiagnosticRecorder + private lateinit var failureSnapshotStore: PurchaseFailureSnapshotStore private lateinit var connectivityManager: ConnectivityManager private val networkCallback = object : ConnectivityManager.NetworkCallback() { @@ -116,14 +123,15 @@ class AgentForegroundService : Service() { stateStore.setKeepScreenOn(false) purchaseStore = PurchaseTaskStore(this) diagnosticStore = AgentDiagnosticStore(this) + failureSnapshotStore = PurchaseFailureSnapshotStore(diagnosticStore) diagnosticRecorder = SafeAgentDiagnosticRecorder( persist = { event -> diagnosticExecutor.execute { runCatching { diagnosticStore.record(event) } - .onFailure { error -> Log.w("GoAutoDiagnostic", "agent diagnostic write failed: ${error.javaClass.simpleName}") } + .onFailure(::logDiagnosticPersistenceFailure) } }, - onFailure = { error -> Log.w("GoAutoDiagnostic", "agent diagnostic write failed: ${error.javaClass.simpleName}") }, + onFailure = ::logDiagnosticPersistenceFailure, ) val restoredPurchaseTaskId = purchaseStore.activeTaskId() runningTaskId.set(restoredPurchaseTaskId) @@ -135,6 +143,8 @@ class AgentForegroundService : Service() { registerNetworkCallback() resumeCollectionCooldown() executor.scheduleWithFixedDelay(::triggerSync, 0, HEARTBEAT_SECONDS, TimeUnit.SECONDS) + // Independent retention/upload ticks run even when no further task is dispatched. + snapshotUploadExecutor.scheduleWithFixedDelay(::maintainFailureSnapshots, 0, 60, TimeUnit.SECONDS) } override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { @@ -172,6 +182,7 @@ class AgentForegroundService : Service() { diagnosticExecutor.shutdown() runCatching { diagnosticExecutor.awaitTermination(1, TimeUnit.SECONDS) } diagnosticExecutor.shutdownNow() + snapshotUploadExecutor.shutdownNow() purchaseStore.close() diagnosticStore.close() super.onDestroy() @@ -577,7 +588,17 @@ class AgentForegroundService : Service() { GoAutoAccessibilityService.instance?.dismissPurchaseResultBubble() acquireTaskWakeLock() var resultSafelyStored = false + var knownResultType: String? = null + var snapshotContext: SnapshotContext? = null + var executionEntered = false + var snapshotAttempted = false + var probeDiagnosticEvents = 0 val lastStep = AtomicReference("started") + fun failureSnapshot(resultType: String, errorCode: String) { + if (snapshotAttempted || !FailureSnapshotPolicy.exceptionEligible(knownResultType, resultSafelyStored)) return + snapshotAttempted = true + savePurchaseFailureSnapshot(snapshotContext?.copy(errorCode = errorCode), resultType, executionEntered) + } try { val claimed = if (initial.status == "pending") { api.claimPurchaseTask(initial.taskId, UUID.randomUUID().toString(), token) @@ -586,12 +607,20 @@ class AgentForegroundService : Service() { api.startPurchaseTask(claimed.taskId, UUID.randomUUID().toString(), token) } else claimed check(task.status == "running" && task.taskAttemptId.isNotBlank()) { "采购任务没有有效 attempt" } + val diagnosticDeviceId = runCatching { identityStore.credentials()?.takeIf { it.token == token }?.deviceId }.getOrNull() + if (task.taskAttemptId.matches(Regex("[0-9a-fA-F]{8}(-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12}")) && + (diagnosticDeviceId ?: 0) > 0 && task.phase in setOf("spec_probe", "purchase")) { + snapshotContext = SnapshotContext(task.taskId, requireNotNull(diagnosticDeviceId), task.taskAttemptId, task.phase, + task.ruleSnapshotHash, "PURCHASE_EXECUTION_FAILED", BuildConfig.VERSION_NAME, "execution", api.failureSnapshotOrigin) + runCatching { failureSnapshotStore.recordFailureSnapshotContext(task.taskId, task.taskAttemptId, diagnosticDeviceId, + task.phase, task.ruleSnapshotHash, api.failureSnapshotOrigin) }.onFailure(::logDiagnosticPersistenceFailure) + } val snapshotHashValid = task.ruleSnapshotHash.matches(Regex("^[0-9a-f]{64}$")) val snapshotHash = task.ruleSnapshotHash.takeIf { snapshotHashValid } ?: "0".repeat(64) purchaseStore.recordRunning(task.taskId, task.taskAttemptId, snapshotHash) val taskLabel = if (task.executionMode == "live") "正式采购" else "采购演练" - val diagnosticDeviceId = runCatching { identityStore.credentials()?.deviceId }.getOrNull() val probeDiagnostic: (AgentDiagnosticEvent) -> Unit = { event -> + probeDiagnosticEvents++ diagnosticRecorder.record(event.copy( taskId = task.taskId, taskType = "purchase", taskAttemptId = task.taskAttemptId, deviceId = diagnosticDeviceId, phase = task.phase, ruleSnapshotHash = task.ruleSnapshotHash, @@ -625,6 +654,7 @@ class AgentForegroundService : Service() { probeDiagnostic(specClickDiagnostic(task.taskId, stage, target, outcome)) }, stepChanged = { step -> + executionEntered = true lastStep.set(step) purchaseStore.updateStep(task.taskId, task.taskAttemptId, step) }, @@ -667,6 +697,9 @@ class AgentForegroundService : Service() { // #334: a spec probe that read zero colors/sizes must fail explicitly // instead of being reported as a normal, empty spec_probe_completed. val outcome = PurchaseSpecProbePolicy.demote(rawOutcome) + knownResultType = outcome.resultType + // The live read finishes before persistence, the new failure bubble, return to Agent, or lease release. + if (FailureSnapshotPolicy.eligible(task.phase, outcome.resultType, false)) failureSnapshot(outcome.resultType, outcome.errorCode ?: "PURCHASE_EXECUTION_FAILED") val requestId = UUID.randomUUID().toString() val payload = purchaseResultPayload(requestId, task.taskAttemptId, outcome) purchaseStore.completeAndEnqueue(task.taskId, task.taskAttemptId, requestId, payload) @@ -685,10 +718,13 @@ class AgentForegroundService : Service() { stateStore.update(if (outcome.resultType == "failed") "TASK_ERROR" else "ONLINE", message, tokenStored = true) updateNotification(if (outcome.resultType == "failed") "$taskLabel #${task.taskId} 失败" else "$taskLabel #${task.taskId} 已提交") } catch (error: AgentApiException) { + failureSnapshot("failed", error.code.takeIf { it.matches(Regex("[A-Z][A-Z0-9_]{0,63}")) } ?: "PURCHASE_API_FAILED") stateStore.update("TASK_ERROR", "${error.code}:${error.message}", tokenStored = true) } catch (error: Exception) { + failureSnapshot("failed", "PURCHASE_EXECUTION_EXCEPTION") stateStore.update("TASK_ERROR", error.message ?: "采购演练执行异常", tokenStored = true) } finally { + if (snapshotContext?.phase == "spec_probe" && probeDiagnosticEvents == 0) Log.i("GoAutoDiagnostic", "purchase_diagnostic_no_event") if (!resultSafelyStored) cancelIdleReturn("采购结果未安全保存") releaseTaskWakeLock() } @@ -740,7 +776,11 @@ class AgentForegroundService : Service() { try { api.markPurchaseOrderSubmitStarted(interrupted.taskId, boundaryRequestId, token) val automation = GoAutoAccessibilityService.instance?.let(::PurchaseLiveAutomation) - val evidence = automation?.readOrderResult() + val evidence = try { automation?.readOrderResult() } catch (error: Exception) { + savePurchaseFailureSnapshot(restoredFailureSnapshotContext(interrupted.attemptId, api.failureSnapshotOrigin, + "PURCHASE_ORDER_RESULT_UNKNOWN"), "order_result_unknown", true) + throw error + } val outcome = if (evidence == null) { val readFailure = automation?.lastOrderReadFailure PurchaseExecutionOutcome( @@ -752,6 +792,11 @@ class AgentForegroundService : Service() { } else { PurchaseExecutionOutcome("order_created", message = "订单已创建,等待人工检查和支付", pddOrderNo = evidence.orderNo, orderSubmittedAt = evidence.submittedAt, pddOrderAmountCent = evidence.pddOrderAmountCent) } + if (outcome.resultType == "order_result_unknown") { + val context = restoredFailureSnapshotContext(interrupted.attemptId, api.failureSnapshotOrigin, + outcome.errorCode ?: "PURCHASE_ORDER_RESULT_UNKNOWN") + savePurchaseFailureSnapshot(context, outcome.resultType, true) + } val requestId = UUID.randomUUID().toString() purchaseStore.completeAndEnqueue(interrupted.taskId, interrupted.attemptId, requestId, purchaseResultPayload(requestId, interrupted.attemptId, outcome)) } catch (_: AgentApiException) { @@ -766,6 +811,8 @@ class AgentForegroundService : Service() { "AGENT_RESTARTED_DURING_EXECUTION", "手机服务在创建订单前重启,任务已停止且不会重复操作拼多多", ) + savePurchaseFailureSnapshot(restoredFailureSnapshotContext(interrupted.attemptId, api.failureSnapshotOrigin, + "AGENT_RESTARTED_DURING_EXECUTION"), "failed", false, "process_interrupted") purchaseStore.completeAndEnqueue( interrupted.taskId, interrupted.attemptId, @@ -784,6 +831,59 @@ class AgentForegroundService : Service() { runningTaskId.set(purchaseStore.activeTaskId()) } + private fun logDiagnosticPersistenceFailure(error: Throwable) { + val category = when (error) { + is IllegalArgumentException -> "diagnostic_validation_failed" + is android.database.SQLException -> "diagnostic_sql_failed" + else -> "diagnostic_persistence_failed" + } + // Never include exception messages or a stack trace: SQL/binder errors may contain page data. + Log.w("GoAutoDiagnostic", category) + } + + private fun savePurchaseFailureSnapshot(context: SnapshotContext?, resultType: String, executionEntered: Boolean, reason: String? = null) { + if (context == null) { Log.i("GoAutoDiagnostic", "failure_snapshot_attempt_context_missing"); return } + runCatching { + val accessibility = GoAutoAccessibilityService.instance + FailureSnapshotCapture(failureSnapshotStore::hasFailureSnapshotArchive, { failureSnapshotStore.saveFailureSnapshot(it) }) + .record(context, resultType, false, executionEntered, accessibility != null, + { AccessibilitySnapshotSource(requireNotNull(accessibility)).windows() }, reason) + }.onFailure(::logDiagnosticPersistenceFailure) + } + + private fun restoredFailureSnapshotContext(attemptId: String, serverOrigin: String, errorCode: String): SnapshotContext? = runCatching { + val raw = failureSnapshotStore.failureSnapshotContext(attemptId) ?: return@runCatching null + val context = JSONObject(raw) + if (context.getString("serverOrigin") != serverOrigin) return@runCatching null + SnapshotContext(context.getLong("taskId"), context.getLong("deviceId"), context.getString("taskAttemptId"), + context.getString("phase"), if (context.isNull("ruleSnapshotHash")) null else context.getString("ruleSnapshotHash"), + errorCode, BuildConfig.VERSION_NAME, "recovery", serverOrigin) + }.onFailure(::logDiagnosticPersistenceFailure).getOrNull() + + private fun maintainFailureSnapshots() { + runCatching { + failureSnapshotStore.cleanupFailureSnapshots() + // Result submission has priority. Snapshot transfer never owns the device/task mutex. + if (purchaseStore.pendingOutbox().isNotEmpty()) return@runCatching + val configured = settingsStore.serverUrl() + if (configured.isBlank()) return@runCatching + val origin = ServerUrlPolicy.normalize(configured) + val credentials = identityStore.credentials() ?: return@runCatching + val snapshot = failureSnapshotStore.pendingFailureSnapshots(origin).firstOrNull() ?: return@runCatching + if (purchaseStore.pendingOutbox().isNotEmpty() || + ServerUrlPolicy.normalize(settingsStore.serverUrl()) != origin || identityStore.credentials() != credentials) return@runCatching + try { + AgentApiClient(origin).uploadFailureSnapshot(snapshot, credentials.token) + failureSnapshotStore.markFailureSnapshotUploaded(snapshot) + } catch (error: AgentApiException) { + if (FailureSnapshotPolicy.retryUpload(error.status)) failureSnapshotStore.retryFailureSnapshotLater(snapshot) + else failureSnapshotStore.markFailureSnapshotUploaded(snapshot, if (error.status == 410) "expired" else "rejected") + } catch (_: Exception) { + failureSnapshotStore.retryFailureSnapshotLater(snapshot) + } + }.onFailure(::logDiagnosticPersistenceFailure) + } + private fun executeTask( api: AgentApiClient, initialTask: cn.ilapage.goauto.agent.network.AgentTask, diff --git a/android/app/src/test/java/cn/ilapage/goauto/agent/FailureSnapshotCaptureTest.kt b/android/app/src/test/java/cn/ilapage/goauto/agent/FailureSnapshotCaptureTest.kt new file mode 100644 index 0000000..f0d0d6d --- /dev/null +++ b/android/app/src/test/java/cn/ilapage/goauto/agent/FailureSnapshotCaptureTest.kt @@ -0,0 +1,32 @@ +package cn.ilapage.goauto.agent + +import cn.ilapage.goauto.agent.diagnostics.* +import org.json.JSONObject +import org.junit.Assert.* +import org.junit.Test + +class FailureSnapshotCaptureTest { + private val context = SnapshotContext(9,4,"11111111-1111-4111-8111-111111111111","purchase","a".repeat(64),"FAILED","test","execution") + @Test fun noAttemptOrSuccessfulStoredResultDoesNotReadWindows() { + var calls=0 + val capture=FailureSnapshotCapture(hasArchive={false}, persist={calls++}) + val source={ calls++; emptyList() } + capture.record(null,"failed",false,true,true,source) + capture.record(context,"order_created",false,true,true,source) + capture.record(context,"failed",true,true,true,source) + assertEquals(0,calls) + } + @Test fun notEnteredUnavailableAndInterruptedNeverCaptureCurrentScreen() { + val saved=mutableListOf() + val capture=FailureSnapshotCapture(hasArchive={false},persist={saved.add(it)}) + val source={ fail("must not capture current screen"); emptyList() } + capture.record(context,"failed",false,false,true,source) + capture.record(context,"failed",false,true,false,source) + capture.record(context.copy(source="recovery"),"failed",false,true,true,source,"process_interrupted") + assertEquals(listOf("execution_not_entered","accessibility_unavailable","process_interrupted"),saved.map { JSONObject(it.metadataJson).getJSONArray("reasons").getString(0) }) + } + @Test fun existingArchivePreventsAnyRecaptureDuringRecovery() { + val capture=FailureSnapshotCapture(hasArchive={true},persist={fail("must preserve original ZIP")}) + capture.record(context.copy(source="recovery"),"order_result_unknown",false,true,true,{fail("must preserve original scene"); emptyList()}) + } +} diff --git a/android/app/src/test/java/cn/ilapage/goauto/agent/FailureSnapshotExporterTest.kt b/android/app/src/test/java/cn/ilapage/goauto/agent/FailureSnapshotExporterTest.kt new file mode 100644 index 0000000..e1bcc14 --- /dev/null +++ b/android/app/src/test/java/cn/ilapage/goauto/agent/FailureSnapshotExporterTest.kt @@ -0,0 +1,191 @@ +package cn.ilapage.goauto.agent + +import cn.ilapage.goauto.agent.diagnostics.* +import org.junit.Assert.* +import org.junit.Test +import java.io.ByteArrayInputStream +import java.util.zip.ZipInputStream +import javax.xml.parsers.DocumentBuilderFactory +import org.json.JSONObject + +class FailureSnapshotExporterTest { + private val context = SnapshotContext(9, 4, "11111111-1111-4111-8111-111111111111", "spec_probe", "a".repeat(64), "TEST_FAILURE", "test", "execution") + private fun node(values: Map = emptyMap(), children: List = emptyList()) = object : SnapshotNode { + override fun attribute(name: String) = SnapshotAttribute(values[name] ?: if (name == "password") "false" else null) + override fun childCount() = children.size + override fun child(index: Int) = children[index] + } + private fun window(root: SnapshotNode?) = object : SnapshotWindow { + override fun attribute(name: String) = SnapshotAttribute(if (name == "id") "1" else null) + override fun root() = root + } + private fun files(bytes: ByteArray): Map = buildMap { + ZipInputStream(ByteArrayInputStream(bytes)).use { zip -> + while (true) { val entry = zip.nextEntry ?: break; put(entry.name, zip.readBytes()) } + } + } + @Test fun validXmlPreservesRawOrderInvisibleTextAndIllegalCodeUnits() { + val text = "中文😀\n\t\r<&\"\u0001\uD800" + val result = FailureSnapshotExporter().capture(context) { listOf(window(node(children=listOf(node(mapOf("text" to text,"visible-to-user" to "false")),node(mapOf("text" to "same")),node(mapOf("text" to "same")))))) } + val entries = files(requireNotNull(result.archive)) + val doc = DocumentBuilderFactory.newInstance().newDocumentBuilder().parse(ByteArrayInputStream(entries.getValue("window-1.xml"))) + val nodes = doc.getElementsByTagName("node") + assertEquals(4, nodes.length) + val first = nodes.item(1) as org.w3c.dom.Element + assertEquals("0/0", first.getAttribute("path")) + assertEquals(text, SnapshotXml.decode(first.getAttribute("text-base64"))) + assertEquals("false", first.getAttribute("visible-to-user")) + assertEquals(JSONObject(result.metadataJson).toString(), JSONObject(String(entries.getValue("manifest.json"), Charsets.UTF_8)).toString()) + } + @Test fun passwordOmitsEveryContentFieldAndBackup() { + val values = SnapshotXml.contentAttributes.associateWith { "SECRET\u0001" } + ("password" to "true") + val result = FailureSnapshotExporter().capture(context) { listOf(window(node(values))) } + val xml = String(files(requireNotNull(result.archive)).getValue("window-1.xml"), Charsets.UTF_8) + assertFalse(xml.contains("SECRET")) + SnapshotXml.contentAttributes.forEach { assertFalse(xml.contains(" $it=")); assertFalse(xml.contains(" $it-state=")); assertFalse(xml.contains(" $it-base64=")) } + } + @Test fun rootNullIsNotCapturedAndInvalidHashNeverReadsWindows() { + val result = FailureSnapshotExporter().capture(context) { listOf(window(null)) } + assertNull(result.archive) + assertEquals("not_captured", JSONObject(result.metadataJson).getString("status")) + assertTrue(JSONObject(result.metadataJson).isNull("capturedAt")) + var called = false + val invalid = FailureSnapshotExporter().capture(context.copy(ruleSnapshotHash="bad")) { called=true; emptyList() } + assertFalse(called) + assertEquals("rule_hash_invalid", JSONObject(invalid.metadataJson).getJSONArray("reasons").getString(0)) + } + @Test fun budgetStopsTraversalWithLegalXmlAndPartialStatus() { + val result = FailureSnapshotExporter(nodeLimit=2).capture(context) { listOf(window(node(children=List(10) { node(mapOf("text" to "$it")) }))) } + val xml = files(requireNotNull(result.archive)).getValue("window-1.xml") + val doc = DocumentBuilderFactory.newInstance().newDocumentBuilder().parse(ByteArrayInputStream(xml)) + assertEquals(2, doc.getElementsByTagName("node").length) + assertEquals("partial", JSONObject(result.metadataJson).getString("status")) + assertTrue(JSONObject(result.metadataJson).getJSONArray("reasons").toString().contains("node_limit")) + } + @Test fun archiveBudgetRetainsLegalPartialTreeInsteadOfDiscardingEntireWindow() { + val random=java.util.Random(7) + val children=List(30) { node(mapOf("text" to CharArray(1000) { (33+random.nextInt(90)).toChar() }.concatToString())) } + val result=FailureSnapshotExporter(archiveLimit=4096).capture(context) { listOf(window(node(children=children))) } + assertNotNull("small prefix of a large window must survive ZIP cap",result.archive) + assertTrue(result.archive!!.size<=4096) + val xml=files(requireNotNull(result.archive)).getValue("window-1.xml") + val doc=DocumentBuilderFactory.newInstance().newDocumentBuilder().parse(ByteArrayInputStream(xml)) + assertTrue(doc.getElementsByTagName("node").length in 1..29) + assertTrue(JSONObject(result.metadataJson).getJSONArray("reasons").toString().contains("archive_size_limit")) + } + @Test fun ordinaryXmlPreservesWhitespaceEmojiAndNullVsUnsupportedVsReadError() { + val root=object:SnapshotNode { + override fun attribute(name:String)=when(name) { + "password" -> SnapshotAttribute("false") + "text" -> SnapshotAttribute("中文😀\n\r\t<&\"") + "hint" -> SnapshotAttribute(state="unsupported") + "state-description" -> SnapshotAttribute(state="read_error") + else -> SnapshotAttribute() + } + override fun childCount()=0 + override fun child(index:Int):SnapshotNode?=null + } + val result=FailureSnapshotExporter().capture(context) { listOf(window(root)) } + val doc=DocumentBuilderFactory.newInstance().newDocumentBuilder().parse(ByteArrayInputStream(files(result.archive!!).getValue("window-1.xml"))) + val element=doc.getElementsByTagName("node").item(0) as org.w3c.dom.Element + assertEquals("中文😀\n\r\t<&\"",element.getAttribute("text")) + assertEquals("unsupported",element.getAttribute("hint-state")) + assertEquals("read_error",element.getAttribute("state-description-state")) + assertEquals("null",element.getAttribute("tooltip-state")) + assertEquals("partial",JSONObject(result.metadataJson).getString("status")) + } + @Test fun passwordActionLabelsAndUnknownPasswordContentAreNeverReadOrSaved() { + for (password in listOf(SnapshotAttribute("true"),SnapshotAttribute(state="read_error"))) { + val root=object:SnapshotNode { + override fun attribute(name:String):SnapshotAttribute { + if(name in SnapshotXml.contentAttributes) fail("password content must not even be queried") + return if(name=="password") password else SnapshotAttribute() + } + override fun actions()=listOf(16 to SnapshotAttribute("secret\u0001")) + override fun childCount()=0 + override fun child(index:Int):SnapshotNode?=null + } + val result=FailureSnapshotExporter().capture(context) { listOf(window(root)) } + val xml=String(files(result.archive!!).getValue("window-1.xml"),Charsets.UTF_8) + assertTrue(xml.contains("")); assertFalse(xml.contains("label")); assertFalse(xml.contains("secret")) + } + } + @Test fun failedChildCountMustNotPretendTheNodeHasZeroChildren() { + val root=object:SnapshotNode { + override fun attribute(name:String)=SnapshotAttribute(if(name=="password") "false" else null) + override fun childCount():Int=throw IllegalStateException("synthetic") + override fun child(index:Int):SnapshotNode?=null + } + val result=FailureSnapshotExporter().capture(context) { listOf(window(root)) } + val xml=String(files(result.archive!!).getValue("window-1.xml"),Charsets.UTF_8) + assertTrue(xml.contains("child-count-state=\"read_error\"")); assertFalse(xml.contains("child-count=\"0\"")) + } + @Test fun timeBudgetChecksImmediatelyAfterBinderReadAndNeverVisitsLaterWindow() { + var elapsed=0L + var laterReads=0 + val first=object:SnapshotWindow { + override fun attribute(name:String):SnapshotAttribute { elapsed=2000; return SnapshotAttribute("1") } + override fun root():SnapshotNode? { fail("budget was exceeded"); return null } + } + val later=object:SnapshotWindow { + override fun attribute(name:String):SnapshotAttribute { laterReads++; return SnapshotAttribute() } + override fun root():SnapshotNode? { laterReads++; return null } + } + val result=FailureSnapshotExporter(monotonicMillis={elapsed}).capture(context) { listOf(first,later) } + assertEquals(0,laterReads); assertNull(result.archive) + assertTrue(JSONObject(result.metadataJson).getJSONArray("reasons").toString().contains("time_limit")) + } + @Test fun foregroundAgentGuardRunsInsideCaptureBudgetAndReportsNoCapture() { + val result=FailureSnapshotExporter().capture(context) { throw SnapshotUnavailableException("execution_not_entered") } + assertNull(result.archive) + assertEquals("execution_not_entered",JSONObject(result.metadataJson).getJSONArray("reasons").getString(0)) + } + @Test fun expandedAndDepthLimitsRetainParseablePartialTrees() { + var deep:SnapshotNode=node() + repeat(300) { deep=node(children=listOf(deep)) } + val cases=listOf( + FailureSnapshotExporter() to deep, + FailureSnapshotExporter(expandedLimit=96*1024) to node(children=List(100) { node(mapOf("text" to "中".repeat(1000))) }), + ) + for ((exporter,root) in cases) { + val result=exporter.capture(context) { listOf(window(root)) } + val entries=files(result.archive!!) + val doc=DocumentBuilderFactory.newInstance().newDocumentBuilder().parse(ByteArrayInputStream(entries.getValue("window-1.xml"))) + assertTrue(doc.getElementsByTagName("node").length in 1..250) + assertEquals("partial",JSONObject(result.metadataJson).getString("status")) + assertTrue(entries.values.sumOf { it.size }<=8*1024*1024) + } + } + @Test fun utf16EncodingMatchesExternalBase64Standard() { + assertEquals(" text-base64=\"AAHYAA==\"",SnapshotXml.attribute("text",SnapshotAttribute("\u0001\uD800"))) + assertArrayEquals(byteArrayOf(0,1,0xD8.toByte(),0),java.util.Base64.getDecoder().decode("AAHYAA==")) + } + @Test fun generatesCrossPlatformFixtureFromSyntheticNodesOnly() { + fun syntheticWindow(id:Int,root:SnapshotNode?)=object:SnapshotWindow { + override fun attribute(name:String)=SnapshotAttribute(when(name) { + "id" -> id.toString(); "type" -> "1"; "layer" -> id.toString(); "title" -> "合成测试😀" + "active","focused" -> "true"; "bounds" -> "[0,0][100,100]"; "packageName" -> "synthetic.test"; else -> null + }) + override fun root()=root + } + val password=object:SnapshotNode { + override fun attribute(name:String)=SnapshotAttribute(if(name=="password") "true" else if(name in SnapshotXml.contentAttributes) "SYNTHETIC_SECRET\u0001" else null) + override fun actions()=listOf(16 to SnapshotAttribute("SYNTHETIC_SECRET\u0001")) + override fun childCount()=0 + override fun child(index:Int):SnapshotNode?=null + } + val root=node(children=listOf(node(mapOf("text" to "中文😀\n\t\r\u0001\uD800","visible-to-user" to "false")),node(mapOf("text" to "duplicate")),node(mapOf("text" to "duplicate")),password)) + val result=FailureSnapshotExporter().capture(context) { listOf(syntheticWindow(7,root),syntheticWindow(-8,node(mapOf("text" to "second"))),syntheticWindow(9,null)) } + val entries=files(result.archive!!) + assertEquals(setOf("manifest.json","window-7.xml","window--8.xml"),entries.keys) + for ((name,content) in entries) if(name.endsWith(".xml")) { + DocumentBuilderFactory.newInstance().newDocumentBuilder().parse(ByteArrayInputStream(content)) + assertFalse(String(content,Charsets.UTF_8).contains("SYNTHETIC_SECRET")) + } + System.getenv("GOAUTO_DIAGNOSTIC_FIXTURE_DIR")?.takeIf { it.isNotBlank() }?.let { path -> + val directory=java.io.File(path); check(directory.isDirectory || directory.mkdirs()) + java.io.File(directory,"manifest.json").writeText(result.metadataJson,Charsets.UTF_8) + java.io.File(directory,"archive.zip").writeBytes(requireNotNull(result.archive)) + } + } +} diff --git a/android/app/src/test/java/cn/ilapage/goauto/agent/FailureSnapshotPolicyTest.kt b/android/app/src/test/java/cn/ilapage/goauto/agent/FailureSnapshotPolicyTest.kt new file mode 100644 index 0000000..e9e0027 --- /dev/null +++ b/android/app/src/test/java/cn/ilapage/goauto/agent/FailureSnapshotPolicyTest.kt @@ -0,0 +1,30 @@ +package cn.ilapage.goauto.agent + +import cn.ilapage.goauto.agent.diagnostics.FailureSnapshotPolicy +import org.junit.Assert.* +import org.junit.Test + +class FailureSnapshotPolicyTest { + @Test fun onlyFailedOrUnknownPurchaseOutcomesAreEligible() { + for (phase in listOf("spec_probe", "purchase")) { + assertTrue(FailureSnapshotPolicy.eligible(phase,"failed",false)) + assertTrue(FailureSnapshotPolicy.eligible(phase,"order_result_unknown",false)) + for (result in listOf("spec_probe_completed","order_created","rehearsal_completed")) assertFalse(FailureSnapshotPolicy.eligible(phase,result,false)) + } + assertFalse(FailureSnapshotPolicy.eligible("collection","failed",false)) + assertFalse(FailureSnapshotPolicy.eligible("purchase","failed",true)) + } + @Test fun retryClassificationConvergesPermanentErrors() { + for (status in listOf(400,401,403,404,409,410,413,422)) assertFalse(FailureSnapshotPolicy.retryUpload(status)) + for (status in listOf(0,408,429,500,502,503)) assertTrue(FailureSnapshotPolicy.retryUpload(status)) + } + @Test fun successfulOutcomeDoesNotBecomeSceneFailureWhenResultStorageThrows() { + for (result in listOf("spec_probe_completed","order_created","rehearsal_completed")) { + assertFalse(FailureSnapshotPolicy.exceptionEligible(result,false)) + assertFalse(FailureSnapshotPolicy.exceptionEligible(result,true)) + } + assertTrue(FailureSnapshotPolicy.exceptionEligible(null,false)) + assertTrue(FailureSnapshotPolicy.exceptionEligible("failed",false)) + assertFalse(FailureSnapshotPolicy.exceptionEligible("failed",true)) + } +} diff --git a/android/app/src/test/java/cn/ilapage/goauto/agent/FailureSnapshotUploadTest.kt b/android/app/src/test/java/cn/ilapage/goauto/agent/FailureSnapshotUploadTest.kt new file mode 100644 index 0000000..c32d5fe --- /dev/null +++ b/android/app/src/test/java/cn/ilapage/goauto/agent/FailureSnapshotUploadTest.kt @@ -0,0 +1,40 @@ +package cn.ilapage.goauto.agent + +import cn.ilapage.goauto.agent.diagnostics.FailureSnapshot +import cn.ilapage.goauto.agent.network.AgentApiClient +import java.net.ServerSocket +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit +import org.json.JSONObject +import org.junit.Assert.* +import org.junit.Test + +class FailureSnapshotUploadTest { + @Test fun streamsUtf8MetadataAndArchiveAsSeparateMultipartParts() { + val attempt = "11111111-1111-4111-8111-111111111111" + val server = ServerSocket(0) + val executor = Executors.newSingleThreadExecutor() + var body = ByteArray(0) + var auth = "" + val received = executor.submit { + server.accept().use { socket -> + val input = socket.getInputStream() + fun line(): String { val s=StringBuilder(); while (true) { val c=input.read(); if (c < 0 || c==10) break; if(c!=13) s.append(c.toChar()) }; return s.toString() } + assertTrue(line().contains("/api/agent/v1/purchase-tasks/9/attempts/$attempt/failure-snapshot")) + var length = 0 + while(true) { val header=line(); if(header.isEmpty()) break; if(header.startsWith("Content-Length:",true)) length=header.substringAfter(':').trim().toInt(); if(header.startsWith("Authorization:",true)) auth=header.substringAfter(':').trim() } + body=ByteArray(length); var offset=0; while(offset0); offset+=n } + val response = "{\"data\":{\"taskAttemptId\":\"$attempt\",\"status\":\"partial\",\"replayed\":false}}".toByteArray() + socket.getOutputStream().apply { write("HTTP/1.1 200 OK\r\nContent-Length: ${response.size}\r\nConnection: close\r\n\r\n".toByteArray()); write(response); flush() } + } + } + try { + val json = JSONObject().put("taskId",9).put("taskAttemptId",attempt).put("status","partial").put("test","中文😀").toString() + AgentApiClient("http://127.0.0.1:${server.localPort}").uploadFailureSnapshot(FailureSnapshot(json,byteArrayOf(0,1,2)), "synthetic-token") + received.get(5,TimeUnit.SECONDS) + val text = String(body,Charsets.UTF_8) + assertTrue(text.contains("name=\"metadata\"")); assertTrue(text.contains("application/json; charset=UTF-8")) + assertTrue(text.contains(json)); assertTrue(text.contains("name=\"archive\"")); assertEquals("Bearer synthetic-token",auth) + } finally { server.close(); executor.shutdownNow() } + } +} diff --git a/android/app/src/test/java/cn/ilapage/goauto/agent/PurchaseLiveAutomationTest.kt b/android/app/src/test/java/cn/ilapage/goauto/agent/PurchaseLiveAutomationTest.kt index a1521d9..d3b55e5 100644 --- a/android/app/src/test/java/cn/ilapage/goauto/agent/PurchaseLiveAutomationTest.kt +++ b/android/app/src/test/java/cn/ilapage/goauto/agent/PurchaseLiveAutomationTest.kt @@ -18,6 +18,354 @@ import org.junit.Assert.assertTrue import org.junit.Test class PurchaseLiveAutomationTest { + @Test + fun `hint delayed address update preserves fresh payment back budget for late order evidence`() { + val observationDriver = OrderObservationDriver { sample -> + observationPage(when { + sample == 1 -> "payment" + sample < 140 -> "loading" + else -> "complete" + }) + }.apply { backDuration = 4_000 } + lateinit var addressDriver: LiveDriver + addressDriver = LiveDriver(initialEditFrame = { snapshot, sample -> + assertEquals(0, addressDriver.inputCount) + assertFalse(addressDriver.clicked.contains("保存")) + if (sample <= 4) observationDriver.elapsed += 10_000 + snapshot.changeAddressEditor { + it.copy( + text = if (sample <= 4) "合成提示占位标记" else "合成测试区域示例路段1号-old", + hintText = "合成提示占位标记", + showingHintText = sample <= 4, + ) + } + }) + var observingOrder = false + val driver = object : PurchaseUiDriver by addressDriver { + override fun capture(): UiSnapshot = + if (observingOrder) observationDriver.capture() else addressDriver.capture() + override fun backPurchase(): Boolean = + if (observingOrder) observationDriver.backPurchase() else addressDriver.backPurchase() + override fun clickFresh(target: SnapshotNode): FreshActionResult = + if (observingOrder) observationDriver.clickFresh(target) else addressDriver.clickFresh(target) + override fun swipePurchase(direction: SwipeDirection, durationMs: Long): Boolean = + if (observingOrder) observationDriver.swipePurchase(direction, durationMs) + else addressDriver.swipePurchase(direction, durationMs) + override fun bringPddToForeground(): Boolean = + if (observingOrder) observationDriver.bringPddToForeground() else addressDriver.bringPddToForeground() + } + val automation = PurchaseLiveAutomation( + driver, + pause = { observationDriver.elapsed += it }, + monotonicClockMs = { observationDriver.elapsed }, + ) + + val address = automation.updateShippingAddress("_cg366") + assertEquals(5, addressDriver.initialEditCaptures) + assertEquals("合成测试区域示例路段1号-old", addressDriver.lastInputTarget?.text) + assertTrue(address.expectedAddress.endsWith("_cg366")) + assertFalse(address.expectedAddress.contains("合成提示占位标记")) + assertAddressEditorActions(addressDriver, saved = true) + assertTrue(observationDriver.elapsed > 40_000) + assertEquals("_cg366", automation.finalConfirmation(input().copy(addressSuffix = "_cg366"), address).addressSuffix) + automation.submitOrderOnce() + val clicksBeforeObservation = addressDriver.clicked.toList() + observingOrder = true + + val order = automation.readOrderResult() + + assertEquals("PDD-DEMO-365", order?.orderNo) + assertEquals("2026-10-08T03:21:43Z", order?.submittedAt) + assertEquals(140, observationDriver.captures) + val elapsedAfterBack = observationDriver.elapsed - observationDriver.backCompletedAt!! + assertTrue(elapsedAfterBack in 28_000L until 30_000L) + assertTrue(elapsedAfterBack + observationDriver.backDuration > 30_000) + assertObservationActions(observationDriver, backs = 1) + assertEquals(1, addressDriver.inputCount) + assertEquals(1, addressDriver.clicked.count { it == "保存" }) + assertEquals(1, addressDriver.submitClicks) + assertEquals(clicksBeforeObservation, addressDriver.clicked) + assertEquals(0, addressDriver.genericSwipes + addressDriver.scopedSwipes) + assertFalse(addressDriver.clicked.any { it.contains("支付") }) + } + + @Test + fun `payment back observes late complete order beyond original sample budget`() { + val driver = OrderObservationDriver { sample -> + when { + sample == 1 -> observationPage("payment") + sample < 100 -> observationPage("loading") + else -> observationPage("complete") + } + } + val order = observationAutomation(driver).readOrderResult() + + assertEquals("PDD-DEMO-365", order?.orderNo) + assertEquals(100, driver.captures) + assertEquals(1, driver.backs) + assertEquals(0, driver.swipes) + assertEquals(0, driver.clicks) + assertEquals(0, driver.foregroundRequests) + } + + @Test + fun `deadline starts at late successful back and never resets on incomplete evidence or jitter`() { + val driver = OrderObservationDriver { sample -> + observationPage(when { + sample < 58 -> "loading" + sample == 58 -> "payment" + sample > 60 && sample % 3 == 0 -> "incomplete" + else -> "loading" + }) + }.apply { backDuration = 4_000; captureDuration = 25 } + val automation = observationAutomation(driver, pauseOverhead = 17) + + assertEquals(null, automation.readOrderResult()) + assertTrue(driver.captures > 100) + assertEquals(driver.backCompletedAt!! + 30_000 + 17, driver.elapsed) + assertTrue(automation.lastOrderReadFailure!!.paymentPageObserved) + assertObservationActions(driver, backs = 1) + } + + @Test + fun `capture that crosses deadline cannot accept late complete evidence or trigger unpaid scroll`() { + for (latePage in listOf("complete", "unpaid", "entry", "chooser", "wechat")) { + lateinit var driver: OrderObservationDriver + driver = OrderObservationDriver { sample -> + if (sample == 1) { + driver.captureDuration = 30_000 + observationPage("payment") + } else observationPage(latePage) + } + val automation = observationAutomation(driver) + + assertEquals(null, automation.readOrderResult()) + assertEquals(2, driver.captures) + assertEquals(30_500L, driver.elapsed) + assertObservationActions(driver, backs = 1) + } + } + + @Test + fun `slow captures exhaust fixed observation deadline before original sixty samples`() { + val driver = OrderObservationDriver { sample -> observationPage(if (sample == 1) "payment" else "loading") } + .apply { captureDuration = 1_000 } + val automation = observationAutomation(driver) + + assertEquals(null, automation.readOrderResult()) + assertTrue(driver.captures < 60) + assertTrue(driver.elapsed >= driver.backCompletedAt!! + 30_000) + assertTrue(driver.elapsed < driver.backCompletedAt!! + 31_000) + assertObservationActions(driver, backs = 1) + } + + @Test + fun `incomplete context first appearing during extension stays passive until late time arrives`() { + val driver = OrderObservationDriver { sample -> observationPage(when { + sample == 1 -> "payment" + sample < 80 -> "loading" + sample < 100 -> "incomplete" + else -> "complete" + }) } + + assertEquals("PDD-DEMO-365", observationAutomation(driver).readOrderResult()?.orderNo) + assertEquals(100, driver.captures) + assertObservationActions(driver, backs = 1) + } + + @Test + fun `late detail entry chooser and WeChat cannot add navigation during extension`() { + for (latePage in listOf("entry", "chooser", "wechat")) { + val driver = OrderObservationDriver { sample -> observationPage(when { + sample == 1 -> "payment" + sample <= 60 -> "loading" + else -> latePage + }) } + val automation = observationAutomation(driver) + assertEquals(null, automation.readOrderResult()) + assertEquals("PURCHASE_ORDER_CONTEXT_NOT_FOUND", automation.lastOrderReadFailure?.code) + assertTrue(driver.captures > 60) + assertObservationActions(driver, backs = 1) + } + } + + @Test + fun `previous chooser and WeChat recovery retain repeat failures during extension`() { + for (kind in listOf("chooser", "wechat")) { + val driver = OrderObservationDriver { sample -> observationPage(when { + sample == 1 || sample > 60 -> kind + sample == 2 -> "payment" + else -> "loading" + }) } + val automation = observationAutomation(driver) + assertEquals(null, automation.readOrderResult()) + assertEquals( + if (kind == "chooser") "PURCHASE_ORDER_CHOOSER_BACK_FAILED" else "PURCHASE_ORDER_WECHAT_RESTORE_TIMEOUT", + automation.lastOrderReadFailure?.code, + ) + assertEquals(61, driver.captures) + assertEquals(if (kind == "chooser") 2 else 1, driver.backs) + assertEquals(if (kind == "wechat") 1 else 0, driver.foregroundRequests) + assertEquals(0, driver.swipes) + assertEquals(0, driver.clicks) + } + } + + @Test + fun `late unpaid context retains its independent thirty samples and four swipes`() { + val driver = OrderObservationDriver { sample -> observationPage(when { + sample == 1 -> "payment" + sample < 140 -> "loading" + else -> "unpaid" + }) } + val automation = observationAutomation(driver) + + assertEquals(null, automation.readOrderResult()) + assertEquals(169, driver.captures) + assertTrue(driver.elapsed > 30_000) + assertObservationActions(driver, backs = 1, swipes = 4) + } + + @Test + fun `unpaid markers alone never activate payment back extension`() { + val driver = OrderObservationDriver { observationPage("unpaid") } + val automation = observationAutomation(driver) + + assertEquals(null, automation.readOrderResult()) + assertEquals(30, driver.captures) + assertTrue(automation.lastOrderReadFailure!!.paymentPageObserved) + assertObservationActions(driver, swipes = 4) + } + + @Test + fun `missing payment back retains original sixty samples and existing context scroll budget`() { + for (kind in listOf("loading", "incomplete")) { + val driver = OrderObservationDriver { observationPage(kind) } + assertEquals(null, observationAutomation(driver).readOrderResult()) + assertEquals(60, driver.captures) + assertObservationActions(driver, swipes = if (kind == "incomplete") 3 else 0) + } + } + + @Test + fun `order context within original budget retains original sampling and gestures after back`() { + val driver = OrderObservationDriver { sample -> observationPage(if (sample == 1) "payment" else "incomplete") } + assertEquals(null, observationAutomation(driver).readOrderResult()) + assertEquals(60, driver.captures) + assertObservationActions(driver, backs = 1, swipes = 3) + } + + @Test + fun `failed payment back stops immediately without activating observation`() { + val driver = OrderObservationDriver { observationPage("payment") }.apply { backSucceeds = false } + val automation = observationAutomation(driver) + assertEquals(null, automation.readOrderResult()) + assertEquals("PURCHASE_ORDER_PAYMENT_BACK_FAILED", automation.lastOrderReadFailure?.code) + assertEquals(1, driver.captures) + assertEquals(0L, driver.elapsed) + assertObservationActions(driver, backs = 1) + } + + @Test + fun `extension preserves repeated payment empty and unexpected app early failures`() { + for ((kind, code, expectedCaptures) in listOf( + Triple("payment", "PURCHASE_ORDER_PAYMENT_REPEATED", 85), + Triple("empty", "PURCHASE_ORDER_EMPTY_TIMEOUT", 75), + Triple("unexpected", "PURCHASE_ORDER_UNEXPECTED_APP", 61), + )) { + val driver = OrderObservationDriver { sample -> observationPage(when { + sample == 1 -> "payment" + sample <= 60 -> "loading" + else -> kind + }) } + val automation = observationAutomation(driver) + assertEquals(null, automation.readOrderResult()) + assertEquals(code, automation.lastOrderReadFailure?.code) + assertEquals(expectedCaptures, driver.captures) + assertObservationActions(driver, backs = 1) + } + } + + private fun observationAutomation(driver: OrderObservationDriver, pauseOverhead: Long = 0) = PurchaseLiveAutomation( + driver, + pause = { driver.elapsed += it + pauseOverhead }, + monotonicClockMs = { driver.elapsed }, + ) + + private fun assertObservationActions(driver: OrderObservationDriver, backs: Int = 0, swipes: Int = 0) { + assertEquals(backs, driver.backs) + assertEquals(swipes, driver.swipes) + assertEquals(0, driver.clicks) + assertEquals(0, driver.foregroundRequests) + } + + private class OrderObservationDriver( + val frame: (Int) -> UiSnapshot, + ) : PurchaseUiDriver by LiveDriver() { + var elapsed = 0L + var captures = 0 + var backs = 0 + var swipes = 0 + var clicks = 0 + var foregroundRequests = 0 + var captureDuration = 0L + var backDuration = 0L + var backCompletedAt: Long? = null + var backSucceeds = true + override fun capture(): UiSnapshot { + elapsed += captureDuration + return frame(++captures) + } + override fun backPurchase(): Boolean { + backs++ + elapsed += backDuration + if (backSucceeds) backCompletedAt = elapsed + return backSucceeds + } + override fun swipePurchase(direction: SwipeDirection, durationMs: Long): Boolean { + swipes++ + return true + } + override fun clickFresh(target: SnapshotNode): FreshActionResult { + clicks++ + return FreshActionResult.SUCCESS + } + override fun bringPddToForeground(): Boolean { + foregroundRequests++ + return true + } + } + + private fun observationPage(kind: String): UiSnapshot { + val labels = when (kind) { + "payment" -> listOf("立即支付") + "complete" -> listOf("订单号:PDD-DEMO-365", "下单时间:2026-10-08 11:21:43", "立即支付") + "incomplete" -> listOf("订单号:PDD-DEMO-365", "立即支付") + "unpaid" -> listOf("待付款") + "entry" -> listOf("查看订单") + "chooser" -> listOf("选择要使用的应用", "微信") + else -> emptyList() + } + return UiSnapshot( + when (kind) { + "empty" -> null + "wechat" -> "com.tencent.mm" + "unexpected" -> "com.example.unexpected" + "chooser" -> "android" + else -> PDD + }, + when (kind) { + "payment" -> "com.xunmeng.pinduoduo.app_pay.core.PayActivity" + "chooser" -> "com.android.internal.app.ChooserActivity" + else -> ACTIVITY + }, + labels.map { text -> + SnapshotNode(text, null, text, null, null, "android.widget.TextView", + NodeBounds(0, 100, 500, 180), true, false, false, false, true, true) + }, + ) + } + @Test fun `two second payment transition and two needed scrolls yield order and payable amount without clicks`() { val driver = ReadOnlyOrderDriver(paymentMs = 2500) @@ -219,6 +567,338 @@ class PurchaseLiveAutomationTest { assertFalse(driver.clicked.any { it.contains("支付") }) } + @Test + fun `address editor waits for an input arriving after its title`() { + assertDelayedAddressEditor { snapshot, sample -> + if (sample <= 4) snapshot.copy(nodes = snapshot.nodes.filterNot { it.path == "editor-address" }) else snapshot + } + } + + @Test + fun `address editor waits for a populated value`() { + assertDelayedAddressEditor { snapshot, sample -> + snapshot.changeAddressEditor { if (sample <= 4) it.copy(text = " ") else it } + } + } + + @Test + fun `address editor description cannot substitute for empty text`() { + for (text in listOf(null, "", " ")) { + assertAddressEditorTimeout("editor_empty", 1, 0) { snapshot, _ -> + snapshot.changeAddressEditor { it.copy(text = text, contentDescription = "合成说明占位标记") } + } + } + } + + @Test + fun `address editor waits until hint is replaced by real text`() { + assertDelayedAddressEditor { snapshot, sample -> + snapshot.changeAddressEditor { + it.copy( + text = if (sample <= 4) "合成提示占位标记" else it.text, + hintText = "合成提示占位标记", + showingHintText = sample <= 4, + ) + } + } + } + + @Test + fun `address editor persistent hint text fails after the full budget`() { + assertAddressEditorTimeout("editor_hint", 1, 0) { snapshot, _ -> + snapshot.changeAddressEditor { + it.copy(text = "合成提示占位标记", hintText = "合成提示占位标记", showingHintText = true) + } + } + } + + @Test + fun `address editor rejects text equal to trimmed hint despite false or absent showing flag`() { + for (showing in listOf(false, null)) { + assertAddressEditorTimeout("editor_hint", 1, 0) { snapshot, _ -> + snapshot.changeAddressEditor { + it.copy(text = " 合成提示占位标记 ", hintText = " 合成提示占位标记 ", showingHintText = showing) + } + } + } + } + + @Test + fun `address editor showing hint flag blocks nonmatching or missing hint text`() { + for (hint in listOf(null, "", "合成提示占位标记")) { + assertAddressEditorTimeout("editor_hint", 1, 0) { snapshot, _ -> + snapshot.changeAddressEditor { it.copy(hintText = hint, showingHintText = true) } + } + } + } + + @Test + fun `address editor empty text remains empty even when hint and description are present`() { + assertAddressEditorTimeout("editor_empty", 1, 0) { snapshot, _ -> + snapshot.changeAddressEditor { + it.copy(text = " ", contentDescription = "合成说明占位标记", hintText = "合成提示占位标记", showingHintText = true) + } + } + } + + @Test + fun `address editor missing hint metadata remains compatible and uses actual text`() { + val driver = LiveDriver(initialEditFrame = { snapshot, _ -> + snapshot.changeAddressEditor { + assertEquals(null, it.hintText) + assertEquals(null, it.showingHintText) + it.copy(text = " 合成真实地址-old ", contentDescription = "合成说明占位标记") + } + }) + + val proof = PurchaseLiveAutomation(driver, pause = {}).updateShippingAddress("_cg366") + + assertEquals("合成真实地址_cg366", proof.expectedAddress) + assertEquals(1, driver.initialEditCaptures) + assertTrue(driver.lastInputTarget === driver.lastInitialEditSnapshot?.nodes?.single { it.path == "editor-address" }) + assertAddressEditorActions(driver, saved = true) + } + + @Test + fun `address editor accepts actual text with absent false or blank hint metadata`() { + for (showing in listOf(null, false)) { + for (hint in listOf(null, "", " ", "合成提示占位标记")) { + val driver = LiveDriver(initialEditFrame = { snapshot, _ -> + snapshot.changeAddressEditor { it.copy(hintText = hint, showingHintText = showing) } + }) + + val proof = PurchaseLiveAutomation(driver, pause = {}).updateShippingAddress("_cg366") + + assertTrue(proof.expectedAddress.endsWith("_cg366")) + assertEquals(1, driver.initialEditCaptures) + assertAddressEditorActions(driver, saved = true) + } + } + } + + @Test + fun `address editor waits for an enabled input`() { + assertDelayedAddressEditor { snapshot, sample -> + snapshot.changeAddressEditor { if (sample <= 4) it.copy(enabled = false) else it } + } + } + + @Test + fun `address editor tolerates more than three ambiguous frames before unique readiness`() { + assertDelayedAddressEditor { snapshot, sample -> + if (sample <= 4) snapshot.withSecondAddressEditor() else snapshot + } + } + + @Test + fun `address editor uses the value and node from the successful readiness frame`() { + val driver = LiveDriver(initialEditFrame = { snapshot, sample -> + snapshot.changeAddressEditor { it.copy(text = if (sample == 1) "" else "合成地址第${sample}帧-old") } + }) + + val proof = PurchaseLiveAutomation(driver, pause = {}).updateShippingAddress("_cg366") + + assertEquals("合成地址第2帧_cg366", proof.expectedAddress) + assertEquals(2, driver.initialEditCaptures) + assertTrue(driver.lastInputTarget === driver.lastInitialEditSnapshot?.nodes?.single { it.path == "editor-address" }) + assertAddressEditorActions(driver, saved = true) + } + + @Test + fun `address editor structural ambiguity waits full budget even with only one ready input`() { + for (second in listOf<(SnapshotNode) -> SnapshotNode>( + { it.copy(text = "") }, { it.copy(enabled = false) }, + )) { + assertAddressEditorTimeout("editor_ambiguous", structural = 2, ready = 1) { snapshot, _ -> + snapshot.withSecondAddressEditor(second) + } + } + } + + @Test + fun `address editor final zero inputs reports scalar missing reason`() { + assertAddressEditorTimeout("editor_missing", 0, 0) { snapshot, _ -> + snapshot.copy(nodes = snapshot.nodes.filterNot { it.path == "editor-address" }) + } + } + + @Test + fun `address editor final empty input reports scalar empty reason`() { + assertAddressEditorTimeout("editor_empty", 1, 0) { snapshot, _ -> + snapshot.changeAddressEditor { it.copy(text = " ") } + } + } + + @Test + fun `address editor final disabled input reports scalar disabled reason`() { + assertAddressEditorTimeout("editor_disabled", 1, 0) { snapshot, _ -> + snapshot.changeAddressEditor { it.copy(enabled = false) } + } + } + + @Test + fun `address editor final multiple inputs reports scalar ambiguity reason`() { + assertAddressEditorTimeout("editor_ambiguous", 2, 2) { snapshot, _ -> snapshot.withSecondAddressEditor() } + } + + @Test + fun `address editor never seeing a title keeps edit timeout and one shared budget`() { + assertAddressEditorTimeout("title_missing", 0, 0, titleSeen = false) { snapshot, _ -> + snapshot.copy(nodes = snapshot.nodes.filterNot { it.path == "detail" }) + } + } + + @Test + fun `address editor remembers a title even when the final snapshot is empty`() { + assertAddressEditorTimeout("editor_missing", 0, 0) { snapshot, sample -> + if (sample == 1) snapshot.copy(nodes = snapshot.nodes.filterNot { it.path == "editor-address" }) + else snapshot.copy(nodes = emptyList()) + } + } + + @Test + fun `address editor late title does not start a second wait budget`() { + assertAddressEditorTimeout("editor_empty", 1, 0) { snapshot, sample -> + val emptyEditor = snapshot.changeAddressEditor { it.copy(text = "") } + if (sample < 50) emptyEditor.copy(nodes = emptyEditor.nodes.filterNot { it.path == "detail" }) else emptyEditor + } + } + + @Test + fun `address editor can become ready on the final sample of its shared budget`() { + val driver = LiveDriver(initialEditFrame = { snapshot, sample -> + if (sample < 50) snapshot.copy(nodes = emptyList()) else snapshot + }) + val editPauses = mutableListOf() + + PurchaseLiveAutomation(driver, pause = { + if (driver.currentPage == "edit" && driver.inputCount == 0) editPauses += it + }).updateShippingAddress("_cg366") + + assertEquals(50, driver.initialEditCaptures) + assertEquals(listOf(500L) + List(49) { 200L }, editPauses) + assertAddressEditorActions(driver, saved = true) + } + + @Test + fun `address editor excludes recipient phone invisible and leftward inputs and deduplicates paths`() { + val driver = LiveDriver(initialEditFrame = { snapshot, _ -> + val editor = snapshot.nodes.single { it.path == "editor-address" } + snapshot.copy(nodes = snapshot.nodes + listOf( + editor, + editor.copy(path = "hidden-input", visible = false), + editor.copy(path = "left-input", bounds = NodeBounds(0, 390, 10, 480)), + )) + }) + + PurchaseLiveAutomation(driver, pause = {}).updateShippingAddress("_cg366") + + assertEquals(1, driver.initialEditCaptures) + assertEquals("editor-address", driver.lastInputTargetPath) + assertAddressEditorActions(driver, saved = true) + } + + @Test + fun `address editor small screen row overlap remains ambiguous`() { + assertAddressEditorTimeout("editor_ambiguous", 2, 2) { snapshot, _ -> + snapshot.copy(nodes = snapshot.nodes.map { + if (it.path == "editor-phone") it.copy(bounds = NodeBounds(180, 360, 900, 410)) else it + }) + } + } + + @Test + fun `address editor fresh input failures never retry or save`() { + for (result in FreshActionResult.values().filter { it != FreshActionResult.SUCCESS }) { + val driver = LiveDriver(inputResult = result) + + val error = runCatching { PurchaseLiveAutomation(driver, pause = {}).updateShippingAddress("_cg366") } + .exceptionOrNull() as PurchaseLiveException + + assertEquals("PURCHASE_ADDRESS_UPDATE_FAILED", error.code) + assertEquals(1, driver.inputCount) + assertEquals(1, driver.initialEditCaptures) + assertEquals(listOf("138****5678", "修改"), driver.clicked) + assertEquals(0, driver.backCount) + assertEquals(0, driver.scopedSwipes + driver.genericSwipes + driver.submitClicks) + } + } + + @Test + fun `address editor page safety problem interrupts readiness without input`() { + val driver = LiveDriver(initialEditFrame = { snapshot, sample -> + if (sample == 1) snapshot.changeAddressEditor { it.copy(text = "") } + else snapshot.copy(nodes = snapshot.nodes.map { if (it.path == "title") it.copy(text = "立即支付") else it }) + }) + + val error = runCatching { PurchaseLiveAutomation(driver, pause = {}).updateShippingAddress("_cg366") } + .exceptionOrNull() as PurchaseLiveException + + assertEquals("PURCHASE_PAYMENT_FORBIDDEN", error.code) + assertEquals(2, driver.initialEditCaptures) + assertAddressEditorActions(driver, saved = false) + } + + private fun assertDelayedAddressEditor(frame: (UiSnapshot, Int) -> UiSnapshot) { + val driver = LiveDriver(initialEditFrame = frame) + val editPauses = mutableListOf() + val automation = PurchaseLiveAutomation(driver, pause = { + if (driver.currentPage == "edit" && driver.inputCount == 0) editPauses += it + }) + + val proof = automation.updateShippingAddress("_cg366") + + assertTrue(proof.expectedAddress.endsWith("_cg366")) + assertEquals(5, driver.initialEditCaptures) + assertEquals(listOf(500L, 200L, 200L, 200L, 200L), editPauses) + assertEquals("editor-address", driver.lastInputTargetPath) + assertAddressEditorActions(driver, saved = true) + } + + private fun assertAddressEditorTimeout( + reason: String, + structural: Int, + ready: Int, + titleSeen: Boolean = true, + frame: (UiSnapshot, Int) -> UiSnapshot, + ) { + val driver = LiveDriver(initialEditFrame = frame) + val diagnostics = mutableListOf() + val editPauses = mutableListOf() + val automation = PurchaseLiveAutomation(driver, pause = { + if (driver.currentPage == "edit" && driver.inputCount == 0) editPauses += it + }, panelDiagnostic = diagnostics::add) + + val failure = runCatching { automation.updateShippingAddress("_cg366") }.exceptionOrNull() + assertTrue("Readiness must fail without changing the address", failure is PurchaseLiveException) + val error = failure as PurchaseLiveException + + assertEquals(if (titleSeen) "PURCHASE_ADDRESS_UPDATE_FAILED" else "PURCHASE_ADDRESS_EDIT_TIMEOUT", error.code) + val diagnostic = "stage=address_editor_ready;reason=$reason;titleSeen=$titleSeen;structural=$structural;ready=$ready" + assertTrue(error.message.orEmpty(), error.message.orEmpty().endsWith("[$diagnostic]")) + assertEquals(50, driver.initialEditCaptures) + assertEquals(listOf(500L) + List(50) { 200L }, editPauses) + val emitted = error.message.orEmpty() + diagnostics.joinToString() + listOf("广东", "骏景", "测试收货人", "13800000000", "editor-address", "_cg366", "合成提示占位标记", "合成说明占位标记").forEach { + assertFalse("Sensitive fixture value in readiness diagnostics", emitted.contains(it)) + } + assertAddressEditorActions(driver, saved = false) + } + + private fun assertAddressEditorActions(driver: LiveDriver, saved: Boolean) { + assertEquals(if (saved) 1 else 0, driver.inputCount) + assertEquals(listOf("138****5678", "修改") + if (saved) listOf("保存") else emptyList(), driver.clicked) + assertEquals(if (saved) 1 else 0, driver.backCount) + assertEquals(1, driver.addressPathClicks) + assertEquals(0, driver.addressTaps + driver.genericSwipes + driver.scopedSwipes + driver.submitClicks) + } + + private fun UiSnapshot.changeAddressEditor(change: (SnapshotNode) -> SnapshotNode): UiSnapshot = + copy(nodes = nodes.map { if (it.path == "editor-address") change(it) else it }) + + private fun UiSnapshot.withSecondAddressEditor(change: (SnapshotNode) -> SnapshotNode = { it }): UiSnapshot = + copy(nodes = nodes + change(nodes.single { it.path == "editor-address" }.copy(path = "editor-address-2"))) + @Test fun `split confirmation address keeps the unique saved task suffix proof`() { val driver = LiveDriver(splitConfirmationAddress = true) @@ -788,6 +1468,8 @@ class PurchaseLiveAutomationTest { private val orderDetailEntryAfterSubmit: Boolean = false, private val orderDetailEvidenceBelowFold: Boolean = false, private val duplicateOrderDetailEntry: Boolean = false, + private val initialEditFrame: (UiSnapshot, Int) -> UiSnapshot = { snapshot, _ -> snapshot }, + private val inputResult: FreshActionResult = FreshActionResult.SUCCESS, postSubmitCaptureSequence: List = emptyList(), ) : PurchaseUiDriver { private var page = "confirmation" @@ -802,6 +1484,10 @@ class PurchaseLiveAutomationTest { var addressTaps = 0 var addressPathClicks = 0 var lastInputTargetPath: String? = null + var lastInputTarget: SnapshotNode? = null + var lastInitialEditSnapshot: UiSnapshot? = null + var initialEditCaptures = 0 + var inputCount = 0 var backCount = 0 var postSubmitBackCount = 0 var pddRestoreCount = 0 @@ -814,7 +1500,12 @@ class PurchaseLiveAutomationTest { postSubmitCaptureCount++ if (pendingPostSubmitPages.isNotEmpty()) page = pendingPostSubmitPages.removeAt(0) } - return currentSnapshot() + val snapshot = currentSnapshot() + if (page == "edit" && inputCount == 0) { + initialEditCaptures++ + return initialEditFrame(snapshot, initialEditCaptures).also { lastInitialEditSnapshot = it } + } + return snapshot } private fun currentSnapshot(): UiSnapshot = when (page) { @@ -986,7 +1677,10 @@ class PurchaseLiveAutomationTest { } override fun inputFresh(target: SnapshotNode, value: String): FreshActionResult { + inputCount++ + lastInputTarget = target lastInputTargetPath = target.path + if (inputResult != FreshActionResult.SUCCESS) return inputResult address = value return FreshActionResult.SUCCESS } diff --git a/android/app/src/test/java/cn/ilapage/goauto/agent/persistence/AgentDiagnosticStoreMigrationTest.kt b/android/app/src/test/java/cn/ilapage/goauto/agent/persistence/AgentDiagnosticStoreMigrationTest.kt index 1115c3e..da33a87 100644 --- a/android/app/src/test/java/cn/ilapage/goauto/agent/persistence/AgentDiagnosticStoreMigrationTest.kt +++ b/android/app/src/test/java/cn/ilapage/goauto/agent/persistence/AgentDiagnosticStoreMigrationTest.kt @@ -7,6 +7,25 @@ import org.junit.Assert.assertTrue import org.junit.Test class AgentDiagnosticStoreMigrationTest { + @Test + fun v4AddsIndependentFailureSnapshotQueueWithoutChangingStructuredRows() = withDatabase { db -> + db.createStatement().use { + it.execute(AgentDiagnosticSchema.createTableSql) + it.execute("INSERT INTO agent_diagnostic (task_id,stage,reason,attempt,elapsed_ms,agent_version,created_at) VALUES (1,'COLOR_CLICK','CLICK_SUCCESS',1,0,'old',1000)") + } + AgentDiagnosticSchema.migrationStatements(3, 4, columnNames(db)).forEach { sql -> + db.createStatement().use { it.execute(sql) } + } + assertEquals(4, AgentDiagnosticSchema.VERSION) + for (table in listOf("purchase_failure_snapshot", "purchase_failure_snapshot_queue", "purchase_failure_snapshot_context")) { + db.prepareStatement("SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=?").use { + it.setString(1, table) + it.executeQuery().use { rows -> assertTrue(rows.next()); assertEquals(table, 1, rows.getInt(1)) } + } + } + assertEquals(1, rowCount(db)) + } + @Test fun upgradeFromV1OrV2PreservesOldRowsAndSeparatesPurchaseAttempts() { for (oldVersion in listOf(1, 2)) withDatabase { db -> diff --git a/android/app/src/test/java/cn/ilapage/goauto/agent/persistence/FailureSnapshotRepositoryTest.kt b/android/app/src/test/java/cn/ilapage/goauto/agent/persistence/FailureSnapshotRepositoryTest.kt new file mode 100644 index 0000000..826ff80 --- /dev/null +++ b/android/app/src/test/java/cn/ilapage/goauto/agent/persistence/FailureSnapshotRepositoryTest.kt @@ -0,0 +1,131 @@ +package cn.ilapage.goauto.agent.persistence + +import cn.ilapage.goauto.agent.diagnostics.FailureSnapshot +import java.sql.DriverManager +import java.time.Instant +import org.json.JSONObject +import org.junit.Assert.* +import org.junit.Test + +class FailureSnapshotRepositoryTest { + private val now = 1_791_400_000_000L + private val origin = "https://test.invalid" + private val attempt = "11111111-1111-4111-8111-111111111111" + + private fun snapshot(zip: Boolean = false, source: String = "execution", time: Long = now) = FailureSnapshot( + JSONObject().put("taskId", 9).put("deviceId", 4).put("taskAttemptId", attempt) + .put("phase", "purchase").put("ruleSnapshotHash", "a".repeat(64)).put("ruleSnapshotHashValid", true) + .put("recordedAt", Instant.ofEpochMilli(time).toString()).put("source", source) + .put("status", if (zip) "partial" else "not_captured").toString(), + if (zip) byteArrayOf(1, 2, 3) else null, origin, + ) + + @Test fun durableQueueIsOriginBoundAndKeepsFirstArchive() = database { db -> + val repo = FailureSnapshotRepository(db, clock = { now }) + val first = snapshot(true) + assertTrue(repo.saveFailureSnapshot(first)) + assertFalse(repo.saveFailureSnapshot(snapshot(true, "recovery"))) + assertEquals(0, repo.pendingFailureSnapshots("https://other.invalid").size) + val restarted = FailureSnapshotRepository(db, clock = { now }) + assertArrayEquals(first.archive, restarted.pendingFailureSnapshots(origin).single().archive) + restarted.markFailureSnapshotUploaded(first) + assertTrue(restarted.pendingFailureSnapshots(origin).isEmpty()) + assertTrue(restarted.hasFailureSnapshotArchive(attempt)) + } + + @Test fun recoveryPromotionCannotBeAcknowledgedByOldUpload() = database { db -> + val repo = FailureSnapshotRepository(db, clock = { now }) + val old = snapshot() + assertTrue(repo.saveFailureSnapshot(old)) + val recovery = snapshot(true, "recovery") + assertTrue(repo.saveFailureSnapshot(recovery)) + repo.markFailureSnapshotUploaded(old) + repo.retryFailureSnapshotLater(old) + assertArrayEquals(recovery.archive, repo.pendingFailureSnapshots(origin).single().archive) + } + + @Test fun cleanupRemovesExpiredSnapshotsAndQueueButNotStructuredRows() = database { db -> + db.execute(AgentDiagnosticSchema.createTableSql) + db.execute("INSERT INTO agent_diagnostic (task_id,stage,reason,attempt,elapsed_ms,agent_version,created_at) VALUES (1,'COLOR_CLICK','CLICK_SUCCESS',1,0,'old',1000)") + val repo = FailureSnapshotRepository(db, clock = { now }) + assertTrue(repo.saveFailureSnapshot(snapshot())) + repo.cleanupFailureSnapshots(now + FailureSnapshotRepository.RETENTION_MILLIS) + assertTrue(repo.pendingFailureSnapshots(origin).isEmpty()) + assertEquals(0L, db.query("SELECT COUNT(*) AS n FROM purchase_failure_snapshot").single()["n"]) + assertEquals(0L, db.query("SELECT COUNT(*) AS n FROM purchase_failure_snapshot_queue").single()["n"]) + assertEquals(1L, db.query("SELECT COUNT(*) AS n FROM agent_diagnostic").single()["n"]) + } + + @Test fun emptyArchiveIsRejectedBeforeItCanPoisonTheUploadQueue() = database { db -> + val repo = FailureSnapshotRepository(db, clock = { now }) + assertThrows(IllegalArgumentException::class.java) { + repo.saveFailureSnapshot(snapshot(true).copy(archive = byteArrayOf())) + } + assertTrue(repo.pendingFailureSnapshots(origin).isEmpty()) + } + + @Test fun contextPreservesInvalidHashValidityWithoutInventingHash() = database { db -> + val repo = FailureSnapshotRepository(db, clock = { now }) + repo.recordFailureSnapshotContext(9, attempt, 4, "purchase", "invalid", origin) + val context = JSONObject(repo.failureSnapshotContext(attempt)!!) + assertFalse(context.getBoolean("ruleSnapshotHashValid")) + assertTrue(context.isNull("ruleSnapshotHash")) + assertEquals(origin, context.getString("serverOrigin")) + assertTrue(repo.pendingFailureSnapshots(origin).isEmpty()) + } + + @Test fun retryDelayIsPersistentAndCapEvictionRemovesQueue() = database { db -> + val repo = FailureSnapshotRepository(db, { now }, maxBytes = 4096) + val first = snapshot(true) + assertTrue(repo.saveFailureSnapshot(first)) + repo.retryFailureSnapshotLater(first) + assertTrue(repo.pendingFailureSnapshots(origin).isEmpty()) + assertEquals(1, repo.pendingFailureSnapshots(origin, now + 60_000).size) + val tiny = FailureSnapshotRepository(db, { now }, maxBytes = 1) + tiny.cleanupFailureSnapshots(now) + assertTrue(tiny.pendingFailureSnapshots(origin).isEmpty()) + assertFalse(tiny.hasFailureSnapshotArchive(attempt)) + } + + @Test fun nearLimitArchiveUsesSmallCursorRowsForUploadAndDuplicateDetection() = database { db -> + val bounded = object : FailureSnapshotDatabase by db { + override fun query(sql: String, args: List): List> = db.query(sql, args).also { rows -> + rows.forEach { row -> row.values.filterIsInstance().forEach { assertTrue("blob cursor row exceeds 256KiB", it.size <= 256 * 1024) } } + } + } + val repo = FailureSnapshotRepository(bounded, clock = { now }) + val large = snapshot(true).copy(archive = ByteArray(2 * 1024 * 1024) { (it % 127).toByte() }) + assertTrue(repo.saveFailureSnapshot(large)) + assertFalse(repo.saveFailureSnapshot(large)) + assertArrayEquals(large.archive, repo.pendingFailureSnapshots(origin).single().archive) + } + + private fun database(block: (FailureSnapshotDatabase) -> Unit) { + DriverManager.getConnection("jdbc:sqlite::memory:").use { connection -> + AgentDiagnosticSchema.failureSnapshotStatements.forEach { connection.createStatement().use { s -> s.execute(it) } } + val db = object : FailureSnapshotDatabase { + override fun execute(sql: String, args: List) { + connection.prepareStatement(sql).use { s -> + args.forEachIndexed { index, value -> s.setObject(index + 1, value) }; s.executeUpdate() + } + } + override fun query(sql: String, args: List): List> = connection.prepareStatement(sql).use { s -> + args.forEachIndexed { index, value -> s.setObject(index + 1, value) } + s.executeQuery().use { rows -> buildList { + while (rows.next()) add((1..rows.metaData.columnCount).associate { index -> + val value = rows.getObject(index) + rows.metaData.getColumnLabel(index) to if (value is Number) value.toLong() else value + }) + } } + } + override fun transaction(block: () -> T): T { + connection.autoCommit = false + try { val result = block(); connection.commit(); return result } + catch (error: Throwable) { connection.rollback(); throw error } + finally { connection.autoCommit = true } + } + } + block(db) + } + } +} diff --git a/docs/02-architecture-and-code-map.md b/docs/02-architecture-and-code-map.md index a021ccd..426e93d 100644 --- a/docs/02-architecture-and-code-map.md +++ b/docs/02-architecture-and-code-map.md @@ -2,8 +2,8 @@ generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件) wiki_page: Architecture-and-Code-Map wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Architecture-and-Code-Map.- -wiki_revision: b3d1c9b7ba1dd5820a20e59b0c21a971aaf1b8ee -synchronized_at: 2026-10-08T01:41:51Z +wiki_revision: 6a6a1a9a13aaf5284e5a81a79f849ad28525ceba +synchronized_at: 2026-10-08T07:10:44Z @@ -63,7 +63,7 @@ Android Portal/Agent - 重置在事务中清空原结果,保留 URL、goods_id、规则和设备快照,状态恢复为 `pending`。 - Android 本地互斥与服务端原子领取共同保证单设备串行。 - 正式采购在 Android 本地 SQLite 事务中先保存不可逆状态、稳定服务端请求 ID 和脱敏最终确认快照,再通知服务端并只允许一次创建订单点击;重启后只重放服务端标记、只读核单或上报,不再次点击。 -- 原始控件树和截图不持久化;Android Agent 端第一期不使用 OCR/VLM。服务端 SYB 登录验证码识别是唯一例外,见 [#48](https://git.ilapage.cn/OPC/goauto/issues/48)。 +- 普通原始控件树和截图不持久化;采购失败私有现场诊断是 #364 的限定例外,见本页对应节;Android Agent 端第一期不使用 OCR/VLM。服务端 SYB 登录验证码识别是唯一例外,见 [#48](https://git.ilapage.cn/OPC/goauto/issues/48)。 - 蝦皮规格映射独立保存在 `shopee_product.specs_json`:颜色只能选择关联 PDD 当前可选颜色,允许多个蝦皮颜色共用一个 PDD 颜色。Admin 商品详情的“一键匹配颜色和尺码”在服务端统一计算两个维度:保留当前仍有效的已确认映射,将唯一确定匹配及达到阈值、具备理由且候选仍有效的 AI 匹配,在重新校验规格上下文后于同一事务直接写为 `confirmed`,无需人工确认;低置信度或无结果保持未匹配,Provider 异常或上下文变化时不写入任何本次结果。PDD 目标规格消失后页面标记失效,映射保存和采购创建均拒绝继续使用;无需新增数据库表或 Android 能力。 ## 最小业务数据 @@ -639,3 +639,16 @@ Web 唯一展示位置为“采集采购 → SYB 同步记录”:列表状态 - 因预算超时不能再使用已取消context写统计,收尾仅使用最多5秒的独立上下文执行受所有权保护的完成更新,不启动新商品领取或AI调用;失租不强制落库。 - MySQL默认返回实际修改行数;续租更新返回0时,只在当前持有行锁的事务内再次核验owner/状态/槽位/实时有效租约,以区分同毫秒值未变化与真实失租;其他完成/工作项更新仍要求恰好一行。 - AI配置读取先返回数据库错误,再判断停用,避免基础设施错误被误记为业务跳过。仅批次私有上下文把基础设施错误作为本轮错误终止;普通单商品Provider重试策略保持不变。 + + +## 采购失败现场诊断(#364,待真机验收) + +实现绑定 `4581ee5`,后续合并记录见 #364;尚未发布。以下是普通原始控件树禁存规则的限定例外;普通采集、截图、OCR/VLM 和付款边界不变。 + +- Android `diagnostics/FailureSnapshotCapture.kt` 统一判断失败/待核对、成功排除及既有 ZIP;`FailureSnapshotExporter.kt` 独立导出有限公开属性,`AccessibilitySnapshotSource.kt` 只读默认显示屏交互窗口,不改变正常 `capture()` 或解析器。 +- `AgentForegroundService` 在有效实际 attempt 的失败结果或执行异常处同步有界截取,先于新提示/返回/下一任务;恢复场景单独标识。成功结果后的保存/提示/上传异常不创建失败快照,不改变原业务状态和不可逆标记。 +- `goauto_diagnostics.db` 升级 v4;独立 `purchase_failure_snapshot`、`purchase_failure_snapshot_queue`、`purchase_failure_snapshot_context` 表。旧 `agent_diagnostic` 保持 7 天/50 条,新快照30天/64MiB逻辑容量。SQL 核心 `FailureSnapshotRepository` 共用真实 SQLite 测试,Android适配器以256KiB块读取BLOB,避开CursorWindow单行限制。 +- 队列绑定 Server Origin;已保存 ZIP 不覆盖,旧上传响应凭 fingerprint 不得清除首次恢复 ZIP 的新队列项。结果 Outbox 优先,诊断独立后台线程只上传已存字节,不持有设备任务锁;启动及每60秒执行维护。 +- Server `purchase/failure_snapshot*.go` 提供专用上传/安全摘要/管理员ZIP下载;`models/purchase_failure_snapshot.go` 独立私有表。追加迁移 `1791400000000_purchase_failure_snapshot.go` 保存 LONGBLOB ZIP、LONGTEXT manifest、attempt唯一约束及到期索引。 +- `access.AdminAPIs` 注册两个仅管理员读取接口;普通任务DTO和Client API不增加原始诊断字段。通用操作日志精确排除三个诊断端点,私有SQL读写使用静默logger。服务启动立即清理并每小时清理过期诊断,不依赖新任务。 +- Web `purchase-tasks/FailureSnapshotCell.vue` 是现有执行记录末尾200px管理员专属列,独立摘要查询、固定原因中文和附件下载;无原始XML在线预览。 diff --git a/docs/03-business-rules-and-glossary.md b/docs/03-business-rules-and-glossary.md index e48c979..4efad1c 100644 --- a/docs/03-business-rules-and-glossary.md +++ b/docs/03-business-rules-and-glossary.md @@ -2,8 +2,8 @@ generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件) wiki_page: Business-Rules-and-Glossary wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Business-Rules-and-Glossary.- -wiki_revision: 233b72e9e2415a2456bb8dd27c770df881960db4 -synchronized_at: 2026-10-08T01:42:04Z +wiki_revision: b9efffa2045d01399bfe02ce626cfd1bb379ec41 +synchronized_at: 2026-10-08T07:10:48Z @@ -213,7 +213,7 @@ Admin 的「创建时间」范围按 SYB 货运单列表的 `t_stock.created`( - 浏览器“打开拼多多APP”和系统确认框“打开”属于允许动作。 - 登录失效、验证码、风控、人机验证和找不到唯一控件时失败并给出具体错误。 - Android Agent 到 GoAuto 服务端默认使用 HTTPS。管理员可在明确接受 Device Token、任务内容、设备状态和执行结果被链路监听或篡改的风险后,通过服务端 `GOAUTO_ALLOW_INSECURE_AGENT_HTTP=true` 显式允许全部 `/api/agent/v1/**` 使用 HTTP;该例外不放宽 PDD、SYB、OCR 或其他第三方服务约束。 -- Android Agent 端不使用 OCR/VLM 兜底,不保存原始控件树或截图。采集阶段不猜测缺失数据。 +- Android Agent 端不使用 OCR/VLM 兜底;普通采集不保存原始控件树或截图,采集阶段不猜测缺失数据。采购失败私有现场诊断是 #364 的限定例外,见本页对应节;历史各节的结构化日志/普通接口禁存规则保持不变。 - 服务端顺云宝(SYB)登录例外:允许调用配置的线上 OCR 服务识别登录验证码(见 [#48](https://git.ilapage.cn/OPC/goauto/issues/48))。验证码图片会离开本项目发送到该服务,更换服务地址前必须重新评估。此例外只适用于 SYB 登录,不扩大到 Agent 端或任何 PDD 相关流程。 - 采购阶段的规格匹配由服务端决策(见 [#46](https://git.ilapage.cn/OPC/goauto/issues/46)、[#62](https://git.ilapage.cn/OPC/goauto/issues/62)):Agent 本地不得自行猜测规格或点击相近候选,只执行服务端下发的精确规格;AI Provider 只有一个 OpenAI-compatible 配置,由管理员维护。根据 #62 已确认的内部部署例外,API Key 明文保存在专用设置表,并只向管理员设置接口返回以便查看和替换;它仍不得写入代码、日志、工单、Wiki、任务快照、采购员接口或 Android 接口。Provider Base URL 不限制内网或公网,支持 HTTP/HTTPS;HTTP 不加密传输中的 API Key,生产环境建议 HTTPS。 - 采购规则的 `openSpecPanel` 默认不写死页面文字。Agent 只在已由页面语义确认的规格入口或底部购买入口中选择;规则若提供 `textAliases`,只能进一步缩小这些安全候选,不能把任意同名页面文字变成可点击入口。 @@ -670,10 +670,21 @@ Web 唯一展示位置为“采集采购 → SYB 同步记录”:列表状态 实现分支 fix/325-order-result,2026-09-19;未部署、未真机验收。下单点击及不可逆边界不改。已知 PayActivity 允许一次原有 Back,随后从3次放宽为25次200ms采样(约5秒等待,不含页面捕获耗时),持续无证据仍为 PURCHASE_ORDER_PAYMENT_REPEATED。微信恢复、系统选择器、空窗口和陌生应用的既有边界保留。 -过渡阶段最多60次采样。首次识别到待付款证据后单独分配30次采样预算,不被之前绕路消耗,不因文字反复出现重置;核单总采样上限90次。缺少唯一订单号或时间时立即向上滑动读取下方内容,最多4次、手势400ms、每次后等500ms;读全立即停止,已有完整字段不滑动。该待付款读取分支不点击任何控件,也不点击付款。纯支付页不使用该滑动分支;出现可读待付款证据优先于复用的 Activity 名称。按采样间隔约18秒加有限手势/额外等待,实际耗时还含capture等调用;未改变2分钟任务租约或5分钟唤醒锁,也不宣称新增了执行器总体墙钟超时机制。 +原 #325 过渡阶段最多60次采样。首次识别到待付款证据后单独分配30次采样预算,不被之前绕路消耗,不因文字反复出现重置;原核单总采样上限90次。#365 工单分支增加下节所述成功支付页返回后的定时观察例外,不能再把90次视为该例外路径的总上限。缺少唯一订单号或时间时立即向上滑动读取下方内容,最多4次、手势400ms、每次后等500ms;读全立即停止,已有完整字段不滑动。该待付款读取分支不点击任何控件,也不点击付款。纯支付页不使用该滑动分支;出现可读待付款证据优先于复用的 Activity 名称。按采样间隔约18秒加有限手势/额外等待,实际耗时还含capture等调用;未改变2分钟任务租约或5分钟唤醒锁,也不宣称新增了执行器总体墙钟超时机制。 应付金额从同一订单的可见标签集合中读取,兼容“应付:,13元”等标点;不跨无关联节点拼接,不读拼单价、优惠或实付。父子重复同金额去重,多值/缺失/无效/溢出省略,不因金额失败丢弃订单号与时间。仅存Admin,不推断支付、不改SYB金额、不修改地址行为。历史回填及界面语义未在本单统一,详见API契约#325补充。 +## 支付页安全返回后的核单观察(#365) + +实现绑定 `37714d39b5ac8ff720cfa1d0d2609f9a8af3fbd9`,工单分支已实现,未合并 main、未安装或发布;自动化验证不代表真机核单成功率已改善。仅 Android 变化,Admin、Server、数据库和上传状态契约不改。 + +- 只有识别到 PDD 支付页且既有一次 Back 成功后,才记录单调时钟起点。在尚未取得订单/待付款上下文时,允许观察至该起点后约30秒,不因反复采样或上下文缺失重置;不能用 paymentPageObserved 代替成功返回事实。 +- 无成功返回,或在原60次内已取得普通订单上下文的路径,保持原预算。返回失败、持续支付页25次、空窗口15次、微信恢复失败和未授权应用等独立失败保持,不保证每种失败等满30秒。 +- 原60次之后的新增观察只采样,不因迟到的详情入口、选择器、微信页面新增点击、返回、拉前台或滑动。延长期出现非待付款不完整订单上下文,可只读等待迟到字段至同一期限;完整证据立即结束。 +- 首次出现待付款证据后,仍使用原独立30次采样及最多4次有界读取滑动;不新增该分支额度。该分支有自己的预算,30秒不是整个核单方法的全局时限。 +- 固定期限检查包含采样耗时与既有暂停,单次 capture 本身不能被此机制中断;不承诺严格墙钟总耗时。不增加采样硬上限来提前替代30秒期限。 +- 仍无完整证据时沿用已有失败原因与 order_result_unknown 处理,不自动补录、重试采购或再次创建订单;永久禁止付款。这只是对加载慢的有界缓解,不是对历史未知原因的根因修复。 + ## 采购订单资料完整性及简化单号回填(#326) 用户2026-09-19确认内部系统简化流程,原型v1通过。实现3a2472d,尚未发布或真实SYB验收。 @@ -835,3 +846,16 @@ Android 0.9.64 / versionCode 77,源码 `6550b9f`(分支实现,尚未安装 - 正常完成或预算退出只保存最后已确定处理/跳过的位置;页中提前退出不跳到预取末尾。确实消费完末页才回绕0;下轮/进程重启从最近已终结且有有效游标的运行续扫,NULL不是有效游标,0是有效回绕点。 - 单运行所有权在分页续期、领取、Provider调用及保存映射时检查。失租旧运行不能继续领取或覆盖新owner;基础设施错误或失租不提交新游标。 - completed只表示本轮正常结束,处理0件可能合法;scanned为实际检查数,不是预取数或全表数,processed不是成功数,confirmed/unmatched是规格项数。商品变化后可能需要等扫描回绕,不保证固定小时内全部处理。 + + +## 采购失败现场私有诊断(#364,尚未发布) + +本节是用户已确认的原始控件树禁存规则限定例外,仅适用于采购 `spec_probe/purchase` 本次失败、结果待核对及有真实上下文的执行阶段异常。普通采集及此前各业务模块的普通日志/结果接口不因此放宽。 + +- 不采集正常执行的连续帧,不抓成功结果;空探测在 demote 后失败才进入诊断。已知成功结果即使后续本地保存、提示或结果上传异常也不新增失败快照。 +- 使用实际已领取/开始的 task/device/attempt UUID/phase/规则快照哈希;无有效归属只留本机固定原因,无效hash如实标为无效,不为诊断伪造事实。前置失败只留未进入执行/无障碍不可用等状态,不把Agent页面当作PDD现场。 +- 原失败上传重试只复用原ZIP。进程中断不补拍当前页冒充原现场;已有恢复核单失败可生成首次 `source=recovery` ZIP,记录恢复实际时间。恢复前的API/网络异常不抓,恢复成功不抓,不新增任何恢复点击、下单或任务重试。 +- 原始订单/地址节点等可能含个人数据,只存手机私有诊断SQLite和服务端私有数据库,只允许管理员下载。不得写入普通日志、工单、Wiki、SynapBus、普通任务结果或采购员接口;不得保存整屏截图。密码标识为true、缺失或不可读时,内容文字及其编码备份、action label均不保存。 +- 每attempt最多保留一份ZIP,已有ZIP不可覆盖;只有无ZIP未截取记录可升级为首次恢复ZIP。以该现场 recordedAt 起保留30天,重复上传不续期;新恢复ZIP采用真实恢复时刻。两端定期清理,手机快照逻辑总量64MiB,淘汰时同时终止上传队列。清理不修改采购结果。 +- 管理员采购详情可见完整/部分/未截取/暂无现场数据及安全下载入口;普通采购员、售后、客户端密钥均无原始诊断下载权限。 +- 1500ms、5000节点、2MiB ZIP、8MiB展开、128窗口与深度保护是合成测试覆盖的开发边界,未经过代表性真机样本验收;单次系统Binder读取不能被这些前后检查强行中断。未知/部分必须如实显示,不宣称拿到了系统未暴露的节点。 diff --git a/docs/04-local-development-and-verification.md b/docs/04-local-development-and-verification.md index 05488ad..925db0d 100644 --- a/docs/04-local-development-and-verification.md +++ b/docs/04-local-development-and-verification.md @@ -2,8 +2,8 @@ generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件) wiki_page: Local-Development-and-Verification wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Local-Development-and-Verification.- -wiki_revision: 03ea269058b50fea2be7842b9c284018987c82d9 -synchronized_at: 2026-09-21T08:14:42Z +wiki_revision: 4349298c740c0cb94d8c6bf33423c9c7965ff1c7 +synchronized_at: 2026-10-08T07:10:50Z # 本地开发与验证 @@ -350,3 +350,15 @@ node --check popup.js ``` 自动化测试只使用合成 DOM 与 Chrome API mock,覆盖列表商品行负向选择、双展开与延迟字段、解析/冲突、启动互斥、停止、错误终态、扫描延迟增长、详情恢复、提交恢复、冻结配置、超时和可重试项。真实 PDD 浏览器点击、扩展安装和真实 Admin 上传不属于自动化验证,必须取得独立授权并在结果中明确区分。 + + +## 采购失败现场诊断验证(#364) + +实现 `4581ee5` 的合成验证不连接PDD、不创建订单、不付款、不读取真实个人数据。安装、真实现场抓取和上线须另行授权。 + +- Android:`cd android; .\\gradlew.bat test assembleDebug`;重点类为 FailureSnapshotExporterTest、FailureSnapshotCaptureTest、FailureSnapshotPolicyTest、FailureSnapshotUploadTest、FailureSnapshotRepositoryTest、AgentDiagnosticStoreMigrationTest。旧结构化诊断实际无障碍回调到Android SQLite的真机闭环仍须另验,不能用JVM测试冒称真机问题已解决。 +- 跨端:仅测试时设置 `GOAUTO_DIAGNOSTIC_FIXTURE_DIR` 指向开发构建输出目录,运行 Android `FailureSnapshotExporterTest` 生成纯合成 `manifest.json/archive.zip`;保持同一环境目录后在 `server/` 执行 `go test ./app/goauto/purchase -run TestFailureSnapshotAndroidExporterContract -count=1`。这验证真实Android导出器生成的字节被Go校验器接受;未设置变量时该测试明确skip。禁止以真实下载ZIP替代该合成fixture,输出不提交Git。 +- Server:`go test ./app/goauto/purchase ./app/goauto/access ./app/goauto/migrations ./common/middleware ./cmd/migrate/migration/version-local ./cmd/api`;覆盖所有权、成功/待执行attempt拒绝、终态补传、幂等/恢复升级、密码内容排除、ZIP/XML/HTTP容量、日志排除及空闲清理。 +- Web:构建后本地preview,使用 `PLAYWRIGHT_TEST_BASE_URL=http://localhost:<端口>`(合成cookie域为localhost),执行 `pnpm exec playwright test tests/e2e/purchase-failure-snapshots.spec.ts tests/e2e/purchase-order-writeback.spec.ts --workers=1`;普通采购员角色为 `purchaser`。 +- 迁移:`1791400000000_purchase_failure_snapshot.go` 仅追加私有表与版本记录,提供 `MigratePurchaseFailureSnapshot` 幂等入口;专用API依赖既有启动权限对账。独立测试 `TestFailureSnapshotAuthorizedLocalMySQL` 须获得本地初始化/权限写入授权后显式注入 `GOAUTO_364_LOCAL_MYSQL_DSN`;硬限制本机 `127.0.0.1:3308/goauto` 且采购任务为空。它执行追加迁移、完整既有API/采购员权限对账,并以事务回滚的合成数据验证64KiB manifest/2MiB BLOB回读与attempt唯一。不是只读测试;不得对线上或已有业务库运行,不输出DSN。 +- 服务维护:Server清理启动立即运行并每小时运行;Android前台服务启动及每60秒维护,无新任务也清理。无需新增定时任务或开关。通过管理员专用下载路由取得ZIP,禁止把私有诊断表或下载目录暴露为静态资源。 diff --git a/docs/06-troubleshooting.md b/docs/06-troubleshooting.md index 5490939..caf14c0 100644 --- a/docs/06-troubleshooting.md +++ b/docs/06-troubleshooting.md @@ -2,8 +2,8 @@ generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件) wiki_page: Troubleshooting wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Troubleshooting -wiki_revision: ac3a0b74704f398cec54149267089c87c8375a3e -synchronized_at: 2026-10-06T09:50:33Z +wiki_revision: fee37efc79b644ecfa3237283dd45f0a09796f44 +synchronized_at: 2026-10-08T03:25:33Z # 故障排查 @@ -114,3 +114,25 @@ adb -s shell run-as cn.ilapage.goauto.agent sqlite3 -readonly databases - 保留现有加载、错误提示、取消和旧请求隔离逻辑,不增加自动重试。慢请求在 10~60 秒间完成时不再被原 10 秒客户端预算提前中止;超过 60 秒仍会超时,上游更短的超时也可能先终止请求。 - 此调整不优化后端执行速度。处理阶段筛选当前先加载候选并计算阶段、后分页;不选店铺且采购类型为全部时,候选可能很大。遇到持续慢查询,应另行分析候选预筛选与数据库执行计划,不能据此认为延长前端预算已解决后端性能问题。 - 只需发布包含该提交的 Web 资源即可生效,不要求数据库迁移、Android 安装或后端参数变更;发布仍需人工授权。 + +## Android 地址编辑页控件就绪等待(#366,环节②) + +实现绑定 `7fdfe04658d16a05ebcb016ee964a7263eb74802`,位于独立工单分支,未合并 main、未装机或发布;合成测试与 APK 构建结果见 #366 工单,不代表真机采购验收。只修改点击“修改”后等待详细地址输入框就绪的环节;地址列表识别(①)仍缺真实单/多地址列表证据,保存退出(③)、返回与最终复核(④)不改。 + +- 标题出现不再立即触发输入框缺失失败。在“详细地址”标签的既有同排区域,统计可见 EditText 的 structural 数量(包括空值/禁用)及其 enabled、实际 text 非空且未判为 hint 的子集 ready 数量。只有 structural=1 且 ready=1 才继续,读取的地址与就绪判定来自同一帧;后续 inputFresh 仍重新定位且只尝试一次。 +- 原地址只读取同帧实际 text.trim(),不使用 contentDescription 补空。API 26+ 捕获内存 hintText / showingHintText;showingHintText=true 或 text.trim() 等于非空 hintText.trim() 时不就绪,继续等待。全局 label 和其他调用方的语义保持不变,这两个新增元数据不落盘、不上报、不记日志。 +- API <26 或应用未正确提供 hint 元数据时,不能完全区分非空 text 是提示还是实际值;仅凭此修复不能保证所有设备/页面的提示文字均被识别,不使用提示词猜测。 +- 沿用单轮最多 50 次采样、原 200ms 暂停及点击后的既有暂停。不再叠加标题等待与控件等待,不设置“三次多候选即失败”;采样与解析另有耗时,不能将其表述为严格 10 秒墙钟超时。既有安全异常仍可提前终止。 +- 等待超时使用现有错误码:从未观察到详细地址标题为 PURCHASE_ADDRESS_EDIT_TIMEOUT;曾见标题但候选未就绪/不唯一为 PURCHASE_ADDRESS_UPDATE_FAILED。titleSeen 跨帧保留,因此最后一帧空树不会抹掉此前的标题证据。 +- 诊断仅含固定 stage=address_editor_ready、reason 分类以及 titleSeen、structural、ready 标量,不含地址、收货人、电话或节点文本。具体原因以实现和测试中的固定分类为准;这不是新增 SQLite 字段或上传接口。 +- 收货人/电话不能作为备用目标;区域重叠造成歧义时不输入、不保存。无额外点击、滑动、返回、保存或下单重试,正常单次动作路径保持。 +- 此修复不能证明历史失败均因慢加载,也不解决保存地址后返回超时。识别①不能仅凭“不是规格面板+无提交按钮”推断已进入地址列表:两项都依赖同一个面板解析结果。 + +## 支付页返回后核单仍缺少证据(#365) + +实现绑定 `37714d39b5ac8ff720cfa1d0d2609f9a8af3fbd9`(工单分支,未合并、安装或发布)。成功执行既有一次支付页安全返回后,在无订单上下文阶段以固定单调时钟起点观察约30秒;原预算后的新增观察不增加页面动作,待付款分支仍沿用独立读取预算。 + +- 先确认运行版本是否包含此提交,再区分失败码:持续支付页、空窗口、微信恢复和未授权应用等独立条件可提前结束,并非所有失败都等待30秒。 +- 完整订单证据出现就结束;超时保留原订单结果未知语义,不能因为等待更久就把任务改为可重试失败或自动重下单。 +- 固定期限从 Back 成功时起算,不是从提交订单或进入核单函数起算,也不会因新样本重置;capture 阻塞不能被该检查中断,待付款分支拥有独立预算。 +- 不因本次延长断言历史问题就是加载慢。延长期出现必须点击才能展开的入口仍不会新增点击;页面识别/挽留弹窗等原因需要真实诊断证据另行分析。#364 的失败现场诊断不属于本单实现。 diff --git a/docs/08-agent-api-contract.md b/docs/08-agent-api-contract.md index 10382cd..e2c09ff 100644 --- a/docs/08-agent-api-contract.md +++ b/docs/08-agent-api-contract.md @@ -2,8 +2,8 @@ generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件) wiki_page: Android-Agent-API-Contract wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Android-Agent-API-Contract.- -wiki_revision: 640da0a86bf1057d418dbeb062c07a84cb5b1d8e -synchronized_at: 2026-10-08T01:42:56Z +wiki_revision: 2102252a3f0ecb646b49941cc603423abb76ca2c +synchronized_at: 2026-10-08T07:11:01Z @@ -641,6 +641,17 @@ Agent 主动提交 `order_result_unknown` 时保持订单号和下单时间为 | `PURCHASE_ORDER_RESULT_UNKNOWN` | 无法确认订单是否创建,请人工检查 | | `PURCHASE_PAYMENT_FORBIDDEN` | 系统禁止自动付款 | +### 地址编辑页初始等待诊断(#366,环节②) + +实现绑定 `7fdfe04658d16a05ebcb016ee964a7263eb74802`(工单分支,尚未合并 main 或发布)。本修订只细化 Android 点击“修改”后的输入框等待及错误说明,不增加 HTTP 字段、数据库字段或错误码,不改变任务结果提交、设备/任务/规则关联和保存后的最终复核。 + +- 一个现有 50 次采样预算内,只有详细地址区域可见 EditText 的结构候选数为 1,且其中 enabled、实际 text 非空并排除明确 hint 的就绪候选数也为 1,才允许后续既有单次输入;结构计数包括空值和禁用候选。 +- ready 和原地址不使用 contentDescription 补空;showingHintText=true 或实际 text.trim() 等于非空 hintText.trim() 时排除。hintText / showingHintText 仅为 API 26+ 的内存节点元数据,低版本为 null,不新增持久化或上传字段,不改变全局 label。应用缺失/错误的 hint 元数据或 API <26 时无法完全区分提示与实际值。 +- 超时从未观察到“详细地址”标题时沿用 `PURCHASE_ADDRESS_EDIT_TIMEOUT`;曾见标题但输入框缺失、为空、仅显示 hint、禁用或存在歧义时沿用 `PURCHASE_ADDRESS_UPDATE_FAILED`。 +- 此等待失败的 `errorMessage` 可附固定诊断 `stage=address_editor_ready;reason=<固定分类>;titleSeen=<布尔>;structural=<数量>;ready=<数量>`。reason 为 `title_missing`、`editor_missing`、`editor_ambiguous`、`editor_disabled`、`editor_empty` 或 `editor_hint`;titleSeen 累计保留,数量取最后采样帧,不包含地址值、收货人、电话及节点文本。 +- 既有页面安全异常优先失败,不必耗尽预算;`inputFresh` 失败仍不重试。其它使用 `PURCHASE_ADDRESS_UPDATE_FAILED` 的路径保持原义,不要求它们附本段诊断。 +- ①地址列表识别仍待真实场景证据,③保存退出、④返回与最终复核不改;本段不代表整单完成或真机验收。 + ## Agent 当前设备任务历史(#90) 四个只读接口统一使用设备注册所得的 Device Token,只返回该 Token 对应设备最近 30 天内的任务;设备 A 查询设备 B 的任务时按不存在处理,不泄露任务是否存在。 @@ -1482,3 +1493,40 @@ Web 输入去重后一个值提交旧标量、多个值提交重复集合键, - `stopReason`:旧记录默认空字符串;完成原因是`batch_limit`、`scan_budget`、`time_budget`、`end_of_scan`、`lease_lost`或`error`。失租旧进程不能为填此字段越权更新;合法回收路径标记lease_lost。 - `scannedCount`改为实际检查的候选数;SQL已过滤的空档案及预取未检查项不计入。eligibleCount为Go资格通过数,processedCount为实际领取处理数;confirmedCount/unmatchedCount仍为规格项数,不能据此直接混算商品成功率。 - 单轮处理默认20、每页200、实际扫描上限2000、整轮预算10分钟。正常0处理仍可completed,预算退出有持久游标;错误/失租不提交新游标。 + + +## 采购失败现场诊断 v1(#364,实现 4581ee5,2026-10-08) + +本节契约在 `4581ee5` 实现,后续合并记录见 #364;尚未发布或完成真机验收。只覆盖采购spec_probe/purchase的本次失败/结果待核对,不修改正常capture、采购结果/不可逆状态;成功结果后网络/提示异常不抓取。普通采集不纳入。每attempt UUID最多一份ZIP;重启不能补拍当前页面冒充原现场,恢复核单现场必须source=recovery。 + +### 专用接口 + +- POST `/api/agent/v1/purchase-tasks/{taskId}/attempts/{attemptId}/failure-snapshot`:Device Token鉴权及现有Agent传输策略。流式multipart,`metadata` UTF-8 JSON(≤64KiB),`archive` 可选ZIP(≤2MiB);不得写临时原始文件。验证attempt持久化的task/device/phase,不依赖task当前设备或running状态,终态允许异步补传。响应沿用Agent JSON envelope,data含taskAttemptId/status/replayed。 +- GET `/api/admin/v1/purchase-tasks/{taskId}/failure-snapshots`:仅管理员,返回`{code:200,data:{items:[...]}}`;items仅attemptId/phase/status/source/errorCode/agentVersion/capturedAt/recordedAt/expiresAt/partial/reasons/downloadAvailable/zipSize等安全标量,不包含窗口标题、节点或manifest原文。 +- GET `/api/admin/v1/purchase-tasks/{taskId}/attempts/{attemptId}/failure-snapshot/download`:仅管理员,附件ZIP,Content-Type application/zip、Cache-Control no-store、nosniff,服务端生成安全文件名。无快照/过期不可下载,普通采购员/API Key及普通Agent业务接口不得读取。不得在线渲染XML。 + +### metadata / ZIP + +schemaVersion=1、attributeVersion=1;taskId/deviceId正整数、taskAttemptId UUID、phase为spec_probe或purchase;ruleSnapshotHash为有效SHA256或null,ruleSnapshotHashValid明确标记;有效时必须与attempt快照hash相同,无效时仅not_captured并记录rule_hash_invalid,不伪造hash。errorCode为有界代码,agentVersion为有界版本;recordedAt/capturedAt为UTC RFC3339,未截取capturedAt=null。source为execution/recovery;status为complete/partial/not_captured;reasons固定代码数组;windows数组含id/fileName/status及可获取的窗口属性。窗口标题等实际文本只存私有诊断内容,不出现在Admin摘要。 + +ZIP含manifest.json(与metadata语义一致)和每个可取得root窗口的window-.xml,每个XML仅一个hierarchy根,禁止DTD/外部实体/路径穿越/重复条目;保留系统原顺序及不可见节点。窗口缺失/属性失败/节点数/字节数/耗时超限明确partial且仍可解析。password节点所有可能承载内容的文字字段均省略(text/contentDescription/hint/stateDescription/tooltip/action label等);不反射任意extras。标准UIAutomator属性名,扩展属性合法;XML非法字符采用显式可逆编码,不能丢弃。schema区分空值、不支持和读取失败。 + +未截取时只上传metadata,无ZIP,不制造空XML;无attempt只保存本机最小原因,不上传虚构归属。服务端解压后总量≤8MiB,不能只相信ZIP声明大小;按白名单条目流式有界验证,异常不回显正文。 + +### 存储、幂等及清理 + +Android复用诊断库升级至v4,新独立快照/待上传/实际执行上下文表;旧结构化诊断保持7天/50条,新快照开发容量64MiB和30天独立清理,队列随过期/淘汰终止;无新任务也定期清理。结果上报优先,上传单独线程/持久队列,失败不改变采购结果或阻塞设备释放;重传只复用已存字节。每attempt已有ZIP不得覆盖;仅无ZIP未截取状态允许后续该attempt首次恢复核单ZIP,不能阻止真实恢复现场。 + +Server追加迁移 `1791400000000_purchase_failure_snapshot.go` 建立purchase_failure_snapshot独立表(ZIP LONGBLOB、私有manifest LONGTEXT、归属与安全摘要,attempt UUID唯一)。同一有效载荷重放返回replayed=true;已有ZIP不同载荷409且保留原件;终态补传有效。按recordedAt起30天,上传不续期,过期补传410供客户端停止;清理启动立即执行并每小时,无任务也执行,下载查询亦拒绝已过期数据。使用既有管理员身份和专用API注册,不给采购员开放权限。 + +2MiB/8MiB/64MiB是经工单声明的开发测试起点;节点/耗时开发保护暂为5000节点、1500ms,采样前后检查,无法中断系统单次Binder读取的事实须记录;deadline后停止遍历,不异步继续抓下一任务。上述不是代表性真机实测结论,正式现场验收前仍需确认。上线/安装/真机抓取另行授权。 + +### v1 编码与队列细节 + +- windows 条目含 id、fileName(可空)、type、layer、title、active、focused、bounds、packageName、activityName、capturedAt、status、reasons、attributeStates;无 root 不制造空 XML。未暴露属性不得猜测。 +- XML 节点标准属性含 text/resource-id/class/package/content-desc/checkable/checked/clickable/enabled/focusable/focused/scrollable/long-clickable/password/selected/bounds;扩展 path、child-count、visible-to-user、editable、hint、state-description、tooltip、input-type、max-text-length、drawing-order,collection-* 与 range-* 为扁平字段;node 下的 action 子元素保留 id/label。版本1公开属性名单以 SnapshotXml.attributes 为准,不反射 extras。 +- 非值属性使用 `<属性名>-state=null|unsupported|read_error` 区分空值、不支持、读取失败;XML非法代码单元使用 `<属性名>-base64` 保存原始 UTF-16BE 字节的 Base64(包括未配对 surrogate)。换行/回车/Tab 使用数值实体。密码标识为 true、缺失或读取失败时,文字字段、状态/编码备份及 action label 均省略;只有显式 password=false 才允许保存内容,子 action 不得覆盖父节点保护。 +- 固定 reasons:rule_hash_invalid、execution_not_entered、accessibility_unavailable、process_interrupted、windows_unavailable、windows_unsupported、window_root_null、window_disappeared、window_read_error、node_read_error、attribute_unsupported、node_limit、time_limit、expanded_size_limit、archive_size_limit、capture_failed。 +- 有ZIP的新上传只接受相符的非成功执行 attempt;不能向 pending/成功 attempt 新增失败诊断,已有有效载荷重放仍幂等。manifest 与 multipart metadata 语义相同;ZIP拒绝未知条目、重复路径、加密/未知压缩、CRC错误,XML拒绝DTD和处理指令,除限定XML声明。 +- 本地队列绑定 Server Origin 和载荷 fingerprint,切换服务器不向新服务器转交旧ZIP;旧上传响应不删除后来首次恢复ZIP。每60秒维护,网络/408/429/5xx按60秒至1小时退避,410与其他永久4xx停止该队列项。结果Outbox优先,不因设备忙而永久饥饿;上传线程不持有任务互斥、不操作设备。 +- 诊断接口不进入通用请求正文日志;私有SQL记录器不输出manifest/BLOB。管理员摘要不包含窗口和节点内容。默认HTTP例外只沿用既有Agent设置,不新增开关;传输私有原始内容时同样存在明文风险,部署须确认现有传输策略。 diff --git a/docs/13-deployment-and-operations.md b/docs/13-deployment-and-operations.md index 9df8004..4e4a54f 100644 --- a/docs/13-deployment-and-operations.md +++ b/docs/13-deployment-and-operations.md @@ -2,8 +2,8 @@ generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件) wiki_page: Deployment-and-Operations wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Deployment-and-Operations.- -wiki_revision: db99427fa2460989d34d28ef864230a907fc5e6d -synchronized_at: 2026-10-08T01:42:22Z +wiki_revision: 6ca0b35cb1e8058b2930a9a01029822af8b571d2 +synchronized_at: 2026-10-08T07:10:52Z @@ -308,3 +308,15 @@ Provider 故障日志只允许记录调用关联 ID、操作类型、耗时、 - 新版启动日志结构检查未见panic/fatal/1146/1054。服务器systemd较旧,读取PID用 `systemctl show goauto -p MainPID` 并解析键值,不使用不支持的 `--value`;journalctl起始时间使用服务器本地格式 `YYYY-MM-DD HH:MM:SS`。 - Nginx配置检查通过,本次无配置变化、不需重启或reload;仅恢复发布目录可读性即恢复前端。真实多轮续扫、末尾回绕和AI实际匹配仍待自然运行验收,健康检查不等于业务效果验收。 - 回滚代码目标 `/home/goauto/releases/20261007-eb7cb6b-363` 保留;重启前核验任务空闲,保留追加列与已经保存的映射,不恢复旧备份覆盖此后业务。 + + +## 采购失败现场诊断部署与保留(#364) + +实现绑定 `4581ee5`;本节为新版本部署要求,不表示线上已迁移/发布。#365/#366 为Android局部行为,#366仅②编辑框就绪/hint保护已交付,①地址列表识别仍待现场证据。 + +1. 先取得目标环境的追加迁移及发布授权,核对唯一待执行版本 `1791400000000_purchase_failure_snapshot.go`,建立独立私有诊断表,再启动配套Server/Web。专用摘要/ZIP下载注册到既有启动权限对账,只允许管理员;对账会维护完整API目录及采购员既有权限,不是只写两条接口。 +2. Server接收与权限验证就绪后再安装新Agent。旧Server不支持新诊断接口;不要把“本机保存成功”当作“已上传”。仅合并main不启动服务、不执行线上迁移、不自动安装APK。 +3. Server启动立即清理过期诊断,其后每小时维护;Android前台服务启动及每60秒维护。两端默认30天,手机私有诊断逻辑容量64MiB;无需Admin定时任务或新开关,清理不修改采购任务与订单事实。不得把诊断ZIP/数据库映射成静态目录。 +4. 管理员从采购详情执行记录下载ZIP;无现场、未截取、partial、上传中/失败应按实际结果区分。排错日志仅固定错误分类,禁止复制ZIP内容到日志、工单、Wiki或消息;分析只摘必要脱敏结构。沿用现有Agent HTTPS/显式HTTP例外,HTTP上传有个人数据明文风险。 +5. 代码回滚时保留追加表和已存数据,不降级或删除手机数据库来恢复;回滚到尚不识别schema v4的旧Agent须先单独评估,不承诺可直接覆盖安装。保留期限清理须有仍在运行的相应新版维护组件。 +6. 本机换机后的数据库经用户授权初始化于 `127.0.0.1:3308/goauto`,配置仍来自仓库根目录已忽略的config.yaml;已完成baseline迁移及#364幂等、容量、权限验证,未启动业务服务或连接线上。不是将历史3307运行说明套用于所有环境;实际Supervisor目录/发布按目标环境另核对。 diff --git a/server/app/goauto/access/purchaser.go b/server/app/goauto/access/purchaser.go index bd76654..0d417b7 100644 --- a/server/app/goauto/access/purchaser.go +++ b/server/app/goauto/access/purchaser.go @@ -116,6 +116,8 @@ var AdminAPIs = []APIPermission{ {"回填SYB采购单号", "/api/admin/v1/purchase-tasks/syb-order-writeback", "POST", true}, {"创建备货采购任务", "/api/admin/v1/purchase-tasks/stock", "POST", true}, {"查看采购任务详情", "/api/admin/v1/purchase-tasks/:taskId", "GET", true}, + {"查看采购失败现场摘要", "/api/admin/v1/purchase-tasks/:taskId/failure-snapshots", "GET", false}, + {"下载采购失败现场", "/api/admin/v1/purchase-tasks/:taskId/attempts/:attemptId/failure-snapshot/download", "GET", false}, {"创建采购任务", "/api/admin/v1/purchase-tasks", "POST", true}, {"处理采购规格", "/api/admin/v1/purchase-tasks/:taskId/spec-decision", "POST", true}, {"查看采购规格匹配", "/api/admin/v1/purchase-tasks/:taskId/matching", "GET", true}, diff --git a/server/app/goauto/migrations/migrate.go b/server/app/goauto/migrations/migrate.go index b60fb83..1a09f02 100644 --- a/server/app/goauto/migrations/migrate.go +++ b/server/app/goauto/migrations/migrate.go @@ -62,6 +62,7 @@ func MigratedModels() []any { &models.PDDAccount{}, &models.PurchaseTask{}, &models.PurchaseTaskAttempt{}, + &models.PurchaseFailureSnapshot{}, &models.PurchaseOrderWriteback{}, &models.PurchaseOrderWritebackLease{}, &models.PurchaseOrderWritebackCommand{}, diff --git a/server/app/goauto/models/purchase_failure_snapshot.go b/server/app/goauto/models/purchase_failure_snapshot.go new file mode 100644 index 0000000..522efc2 --- /dev/null +++ b/server/app/goauto/models/purchase_failure_snapshot.go @@ -0,0 +1,31 @@ +package models + +import "time" + +// PurchaseFailureSnapshot is deliberately never embedded in a business API DTO. +// Private manifest and archive may contain personal information. +type PurchaseFailureSnapshot struct { + ID uint64 `json:"-" gorm:"primaryKey;autoIncrement"` + TaskID uint64 `json:"-" gorm:"not null;index"` + AttemptID string `json:"-" gorm:"size:36;not null;uniqueIndex:ux_purchase_failure_snapshot_attempt"` + DeviceID uint64 `json:"-" gorm:"not null"` + Phase string `json:"-" gorm:"size:16;not null"` + RuleSnapshotHash string `json:"-" gorm:"size:64;not null"` + Status string `json:"-" gorm:"size:16;not null"` + Source string `json:"-" gorm:"size:16;not null"` + ErrorCode string `json:"-" gorm:"size:64;not null"` + AgentVersion string `json:"-" gorm:"size:64;not null"` + CapturedAt *time.Time `json:"-"` + RecordedAt time.Time `json:"-" gorm:"not null"` + ExpiresAt time.Time `json:"-" gorm:"not null;index"` + ReasonsJSON string `json:"-" gorm:"type:text;not null"` + ManifestJSON string `json:"-" gorm:"type:longtext;not null"` + ZIPData []byte `json:"-" gorm:"column:zip_data;type:longblob"` + PayloadSHA256 string `json:"-" gorm:"size:64;not null"` + ZIPSize int64 `json:"-" gorm:"column:zip_size;not null"` + ExpandedSize int64 `json:"-" gorm:"not null"` + CreatedAt time.Time `json:"-"` + UpdatedAt time.Time `json:"-"` +} + +func (PurchaseFailureSnapshot) TableName() string { return "purchase_failure_snapshot" } diff --git a/server/app/goauto/purchase/failure_snapshot.go b/server/app/goauto/purchase/failure_snapshot.go new file mode 100644 index 0000000..6a072fb --- /dev/null +++ b/server/app/goauto/purchase/failure_snapshot.go @@ -0,0 +1,237 @@ +package purchase + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "io" + "regexp" + "time" + + "github.com/google/uuid" + "go-admin/app/goauto/device" + "go-admin/app/goauto/models" + "gorm.io/gorm" + "gorm.io/gorm/clause" + "gorm.io/gorm/logger" +) + +const ( + FailureSnapshotMetadataLimit = 64 << 10 + FailureSnapshotZIPLimit = 2 << 20 + FailureSnapshotExpandedLimit = 8 << 20 + FailureSnapshotRetention = 30 * 24 * time.Hour +) + +type FailureSnapshotMetadata struct { + SchemaVersion int `json:"schemaVersion"` + AttributeVersion int `json:"attributeVersion"` + TaskID uint64 `json:"taskId"` + DeviceID uint64 `json:"deviceId"` + TaskAttemptID string `json:"taskAttemptId"` + Phase string `json:"phase"` + RuleSnapshotHash *string `json:"ruleSnapshotHash"` + RuleSnapshotHashValid bool `json:"ruleSnapshotHashValid"` + ErrorCode string `json:"errorCode"` + AgentVersion string `json:"agentVersion"` + RecordedAt time.Time `json:"recordedAt"` + CapturedAt *time.Time `json:"capturedAt"` + Source string `json:"source"` + Status string `json:"status"` + Reasons []string `json:"reasons"` + Windows []json.RawMessage `json:"windows"` +} + +type FailureSnapshotSummary struct { + AttemptID string `json:"attemptId"` + Phase string `json:"phase"` + Status string `json:"status"` + Source string `json:"source"` + ErrorCode string `json:"errorCode"` + AgentVersion string `json:"agentVersion"` + CapturedAt *time.Time `json:"capturedAt"` + RecordedAt time.Time `json:"recordedAt"` + ExpiresAt time.Time `json:"expiresAt"` + Partial bool `json:"partial"` + Reasons []string `json:"reasons"` + DownloadAvailable bool `json:"downloadAvailable"` + ZIPSize int64 `json:"zipSize"` +} + +type snapshotError struct { + status int + code string +} + +func (e *snapshotError) Error() string { return e.code } +func snapshotFailure(status int, code string) error { return &snapshotError{status, code} } +func invalidSnapshot() error { return snapshotFailure(422, "FAILURE_SNAPSHOT_INVALID") } + +// Keep private content out of SQL logging, including failures in tests/debug mode. +func (s *Service) snapshotDB(ctx context.Context) *gorm.DB { + return s.DB.WithContext(ctx).Session(&gorm.Session{Logger: logger.Default.LogMode(logger.Silent)}) +} + +var snapshotCodePattern = regexp.MustCompile(`^[A-Z][A-Z0-9_]{0,63}$`) +var snapshotVersionPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9.+_-]{0,63}$`) +var snapshotHashPattern = regexp.MustCompile(`^[a-f0-9]{64}$`) + +func decodeSnapshotMetadata(raw []byte) (FailureSnapshotMetadata, error) { + var m FailureSnapshotMetadata + if len(raw) == 0 || len(raw) > FailureSnapshotMetadataLimit { + return m, invalidSnapshot() + } + if err := uniqueSnapshotJSON(raw); err != nil { + return m, err + } + var fields map[string]json.RawMessage + if json.Unmarshal(raw, &fields) != nil { + return m, invalidSnapshot() + } + for _, name := range []string{"schemaVersion", "attributeVersion", "taskId", "deviceId", "taskAttemptId", "phase", "ruleSnapshotHash", "ruleSnapshotHashValid", "errorCode", "agentVersion", "recordedAt", "capturedAt", "source", "status", "reasons", "windows"} { + if _, present := fields[name]; !present { + return m, invalidSnapshot() + } + } + d := json.NewDecoder(bytes.NewReader(raw)) + d.DisallowUnknownFields() + if err := d.Decode(&m); err != nil { + return m, invalidSnapshot() + } + if err := d.Decode(&struct{}{}); !errors.Is(err, io.EOF) { + return m, invalidSnapshot() + } + return m, nil +} + +func (s *Service) UploadFailureSnapshot(ctx context.Context, taskID uint64, attemptID, token string, raw, archive []byte) (string, bool, error) { + d, err := device.NewService(s.snapshotDB(ctx)).Authenticate(ctx, token) + if err != nil { + return "", false, err + } + m, err := decodeSnapshotMetadata(raw) + if err != nil { + return "", false, err + } + parsed, err := uuid.Parse(attemptID) + if err != nil || parsed.String() != attemptID || m.TaskID != taskID || m.TaskAttemptID != attemptID || m.DeviceID != d.ID { + return "", false, invalidSnapshot() + } + if err = validateSnapshotMetadata(m, s.Now()); err != nil { + return "", false, err + } + if !m.RecordedAt.Add(FailureSnapshotRetention).After(s.Now()) { + return "", false, snapshotFailure(410, "FAILURE_SNAPSHOT_EXPIRED") + } + expanded, err := validateFailureArchive(m, raw, archive) + if err != nil { + return "", false, err + } + canonical, err := canonicalSnapshotJSON(raw) + if err != nil { + return "", false, invalidSnapshot() + } + digest := sha256.New() + digest.Write(canonical) + digest.Write([]byte{0}) + digest.Write(archive) + hash := hex.EncodeToString(digest.Sum(nil)) + replayed := false + err = s.snapshotDB(ctx).Transaction(func(tx *gorm.DB) error { + var a models.PurchaseTaskAttempt + if e := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("task_id = ? AND attempt_id = ? AND device_id = ?", taskID, attemptID, d.ID).First(&a).Error; e != nil { + if errors.Is(e, gorm.ErrRecordNotFound) { + return snapshotFailure(404, "FAILURE_SNAPSHOT_NOT_FOUND") + } + return e + } + if a.Phase != m.Phase || (m.RuleSnapshotHashValid && (m.RuleSnapshotHash == nil || *m.RuleSnapshotHash != a.RuleSnapshotHash)) { + return invalidSnapshot() + } + if a.Status == models.PurchaseAttemptStatusPending { + return snapshotFailure(409, "FAILURE_SNAPSHOT_ATTEMPT_NOT_STARTED") + } + if a.StartedAt != nil && m.RecordedAt.Before(a.StartedAt.Add(-5*time.Minute)) { + return invalidSnapshot() + } + var old models.PurchaseFailureSnapshot + e := tx.Where("attempt_id = ?", attemptID).First(&old).Error + if e == nil { + if old.PayloadSHA256 == hash { + replayed = true + return nil + } + if old.ZIPSize > 0 || len(archive) == 0 || m.Source != "recovery" { + return snapshotFailure(409, "FAILURE_SNAPSHOT_CONFLICT") + } + if !old.ExpiresAt.After(s.Now()) { + return snapshotFailure(410, "FAILURE_SNAPSHOT_EXPIRED") + } + } else if !errors.Is(e, gorm.ErrRecordNotFound) { + return e + } + if a.Status == models.PurchaseAttemptStatusCompleted && a.ResultType != nil && (*a.ResultType == "order_created" || *a.ResultType == "spec_probe_completed" || *a.ResultType == "spec_rematch_completed" || *a.ResultType == "rehearsal_completed") { + return snapshotFailure(409, "FAILURE_SNAPSHOT_SUCCESSFUL_ATTEMPT") + } + reasons, _ := json.Marshal(m.Reasons) + row := models.PurchaseFailureSnapshot{TaskID: taskID, AttemptID: attemptID, DeviceID: d.ID, Phase: m.Phase, RuleSnapshotHash: a.RuleSnapshotHash, Status: m.Status, Source: m.Source, ErrorCode: m.ErrorCode, AgentVersion: m.AgentVersion, CapturedAt: m.CapturedAt, RecordedAt: m.RecordedAt, ExpiresAt: m.RecordedAt.Add(FailureSnapshotRetention), ReasonsJSON: string(reasons), ManifestJSON: string(canonical), ZIPData: archive, PayloadSHA256: hash, ZIPSize: int64(len(archive)), ExpandedSize: expanded} + if old.ID != 0 { + row.ID = old.ID + row.CreatedAt = old.CreatedAt + return tx.Save(&row).Error + } + return tx.Create(&row).Error + }) + return m.Status, replayed, err +} + +func (s *Service) FailureSnapshotSummaries(ctx context.Context, taskID uint64) ([]FailureSnapshotSummary, error) { + var rows []models.PurchaseFailureSnapshot + err := s.snapshotDB(ctx).Omit("zip_data", "manifest_json").Where("task_id = ? AND expires_at > ?", taskID, s.Now()).Order("id ASC").Find(&rows).Error + out := make([]FailureSnapshotSummary, 0, len(rows)) + for _, r := range rows { + var reasons []string + _ = json.Unmarshal([]byte(r.ReasonsJSON), &reasons) + out = append(out, FailureSnapshotSummary{AttemptID: r.AttemptID, Phase: r.Phase, Status: r.Status, Source: r.Source, ErrorCode: r.ErrorCode, AgentVersion: r.AgentVersion, CapturedAt: r.CapturedAt, RecordedAt: r.RecordedAt, ExpiresAt: r.ExpiresAt, Partial: r.Status == "partial", Reasons: reasons, DownloadAvailable: r.ZIPSize > 0, ZIPSize: r.ZIPSize}) + } + return out, err +} + +func (s *Service) DownloadFailureSnapshot(ctx context.Context, taskID uint64, attemptID string) ([]byte, error) { + var row models.PurchaseFailureSnapshot + err := s.snapshotDB(ctx).Select("zip_data").Where("task_id = ? AND attempt_id = ? AND expires_at > ? AND zip_size > 0", taskID, attemptID, s.Now()).First(&row).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, snapshotFailure(404, "FAILURE_SNAPSHOT_NOT_FOUND") + } + return row.ZIPData, err +} + +func (s *Service) CleanupFailureSnapshots(ctx context.Context) error { + return s.snapshotDB(ctx).Where("expires_at <= ?", s.Now()).Delete(&models.PurchaseFailureSnapshot{}).Error +} + +// Starts independently of task execution, including on servers with no new work. +func RunFailureSnapshotCleanup(ctx context.Context, s *Service, interval time.Duration, onError func(error)) { + if interval <= 0 { + interval = time.Hour + } + clean := func() { + if err := s.CleanupFailureSnapshots(ctx); err != nil && ctx.Err() == nil && onError != nil { + onError(errors.New("failure snapshot cleanup failed")) + } + } + clean() + ticker := time.NewTicker(interval) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + clean() + } + } +} diff --git a/server/app/goauto/purchase/failure_snapshot_android_contract_test.go b/server/app/goauto/purchase/failure_snapshot_android_contract_test.go new file mode 100644 index 0000000..b4f258f --- /dev/null +++ b/server/app/goauto/purchase/failure_snapshot_android_contract_test.go @@ -0,0 +1,35 @@ +package purchase + +import ( + "os" + "path/filepath" + "testing" + "time" +) + +// The Android JVM exporter writes only synthetic data when this opt-in path is +// set. Consume its actual bytes rather than reconstructing a second Go fixture. +func TestFailureSnapshotAndroidExporterContract(t *testing.T) { + dir := os.Getenv("GOAUTO_DIAGNOSTIC_FIXTURE_DIR") + if dir == "" { + t.Skip("set GOAUTO_DIAGNOSTIC_FIXTURE_DIR to the Android synthetic fixture output") + } + raw, err := os.ReadFile(filepath.Join(dir, "manifest.json")) + if err != nil { + t.Fatal("Android manifest fixture unavailable") + } + archive, err := os.ReadFile(filepath.Join(dir, "archive.zip")) + if err != nil { + t.Fatal("Android archive fixture unavailable") + } + metadata, err := decodeSnapshotMetadata(raw) + if err != nil { + t.Fatalf("Android metadata rejected: %v", err) + } + if err := validateSnapshotMetadata(metadata, metadata.RecordedAt.Add(time.Second)); err != nil { + t.Fatalf("Android metadata contract rejected: %v", err) + } + if _, err := validateFailureArchive(metadata, raw, archive); err != nil { + t.Fatalf("Android generated archive rejected: %v", err) + } +} diff --git a/server/app/goauto/purchase/failure_snapshot_archive.go b/server/app/goauto/purchase/failure_snapshot_archive.go new file mode 100644 index 0000000..96da9fe --- /dev/null +++ b/server/app/goauto/purchase/failure_snapshot_archive.go @@ -0,0 +1,371 @@ +package purchase + +import ( + "archive/zip" + "bytes" + "encoding/base64" + "encoding/json" + "encoding/xml" + "errors" + "io" + "regexp" + "strconv" + "strings" + "time" +) + +var snapshotReasons = map[string]bool{ + "rule_hash_invalid": true, "execution_not_entered": true, "accessibility_unavailable": true, "process_interrupted": true, + "windows_unavailable": true, "windows_unsupported": true, "window_root_null": true, "window_disappeared": true, + "window_read_error": true, "node_read_error": true, "attribute_unsupported": true, "node_limit": true, "time_limit": true, + "expanded_size_limit": true, "archive_size_limit": true, "capture_failed": true, +} + +func validateSnapshotMetadata(m FailureSnapshotMetadata, now time.Time) error { + if m.SchemaVersion != 1 || m.AttributeVersion != 1 || m.TaskID == 0 || m.DeviceID == 0 || (m.Phase != "purchase" && m.Phase != "spec_probe") || (m.Source != "execution" && m.Source != "recovery") || !snapshotCodePattern.MatchString(m.ErrorCode) || !snapshotVersionPattern.MatchString(m.AgentVersion) { + return invalidSnapshot() + } + if m.RecordedAt.IsZero() || m.RecordedAt.After(now.Add(5*time.Minute)) { + return invalidSnapshot() + } + _, offset := m.RecordedAt.Zone() + if offset != 0 { + return invalidSnapshot() + } + if m.CapturedAt != nil { + _, offset = m.CapturedAt.Zone() + if offset != 0 || m.CapturedAt.IsZero() || m.CapturedAt.After(m.RecordedAt) || m.CapturedAt.Before(m.RecordedAt.Add(-5*time.Minute)) { + return invalidSnapshot() + } + } + seen := map[string]bool{} + for _, r := range m.Reasons { + if !snapshotReasons[r] || seen[r] { + return invalidSnapshot() + } + seen[r] = true + } + if m.Reasons == nil || m.Windows == nil || len(m.Windows) > 128 { + return invalidSnapshot() + } + if m.RuleSnapshotHashValid { + if m.RuleSnapshotHash == nil || !snapshotHashPattern.MatchString(*m.RuleSnapshotHash) { + return invalidSnapshot() + } + } else if m.RuleSnapshotHash != nil || m.Status != "not_captured" || !seen["rule_hash_invalid"] { + return invalidSnapshot() + } + switch m.Status { + case "complete": + if m.CapturedAt == nil || len(m.Reasons) > 0 { + return invalidSnapshot() + } + case "partial": + if m.CapturedAt == nil || len(m.Reasons) == 0 { + return invalidSnapshot() + } + case "not_captured": + if m.CapturedAt != nil || len(m.Reasons) == 0 { + return invalidSnapshot() + } + default: + return invalidSnapshot() + } + for _, raw := range m.Windows { + var w snapshotWindow + if json.Unmarshal(raw, &w) != nil || (m.Status == "complete" && (w.Status != "complete" || w.FileName == nil || len(w.Reasons) > 0)) { + return invalidSnapshot() + } + } + return nil +} + +type snapshotWindow struct { + ID *int `json:"id"` + FileName *string `json:"fileName"` + Status string `json:"status"` + Reasons []string `json:"reasons"` +} + +func snapshotWindowFiles(m FailureSnapshotMetadata) (map[string]bool, error) { + files := map[string]bool{} + ids := map[int]bool{} + for _, raw := range m.Windows { + var w snapshotWindow + if json.Unmarshal(raw, &w) != nil || (w.Status != "complete" && w.Status != "partial") { + return nil, invalidSnapshot() + } + if w.ID != nil { + if ids[*w.ID] { + return nil, invalidSnapshot() + } + ids[*w.ID] = true + } + for _, reason := range w.Reasons { + if !snapshotReasons[reason] { + return nil, invalidSnapshot() + } + } + if w.FileName == nil { + continue + } + if w.ID == nil || *w.FileName != "window-"+strconv.Itoa(*w.ID)+".xml" || files[*w.FileName] { + return nil, invalidSnapshot() + } + files[*w.FileName] = true + } + return files, nil +} + +func validateFailureArchive(m FailureSnapshotMetadata, raw, archive []byte) (int64, error) { + files, err := snapshotWindowFiles(m) + if err != nil { + return 0, err + } + if len(archive) == 0 { + if m.Status != "not_captured" || len(files) > 0 { + return 0, invalidSnapshot() + } + return 0, nil + } + if m.Status == "not_captured" || len(files) == 0 || len(archive) > FailureSnapshotZIPLimit { + return 0, invalidSnapshot() + } + zr, err := zip.NewReader(bytes.NewReader(archive), int64(len(archive))) + if err != nil { + return 0, invalidSnapshot() + } + if len(zr.File) != len(files)+1 { + return 0, invalidSnapshot() + } + seen := map[string]bool{} + total := int64(0) + for _, f := range zr.File { + if seen[f.Name] || (f.Name != "manifest.json" && !files[f.Name]) || !f.Mode().IsRegular() || f.Flags&1 != 0 || (f.Method != zip.Store && f.Method != zip.Deflate) { + return 0, invalidSnapshot() + } + seen[f.Name] = true + if f.UncompressedSize64 > FailureSnapshotExpandedLimit || f.UncompressedSize64 > uint64(FailureSnapshotExpandedLimit-total) { + return 0, invalidSnapshot() + } + stream, e := f.Open() + if e != nil { + return 0, invalidSnapshot() + } + limit := int64(FailureSnapshotExpandedLimit) - total + if f.Name == "manifest.json" && limit > FailureSnapshotMetadataLimit { + limit = FailureSnapshotMetadataLimit + } + content, e := io.ReadAll(io.LimitReader(stream, limit+1)) + closeErr := stream.Close() + if e != nil || closeErr != nil || int64(len(content)) > limit || uint64(len(content)) != f.UncompressedSize64 { + return 0, invalidSnapshot() + } + total += int64(len(content)) + if f.Name == "manifest.json" { + if _, e = decodeSnapshotMetadata(content); e != nil { + return 0, invalidSnapshot() + } + left, le := canonicalSnapshotJSON(raw) + right, re := canonicalSnapshotJSON(content) + if le != nil || re != nil || !bytes.Equal(left, right) { + return 0, invalidSnapshot() + } + } else if e = validateFailureXML(content); e != nil { + return 0, e + } + } + if !seen["manifest.json"] { + return 0, invalidSnapshot() + } + return total, nil +} + +func canonicalSnapshotJSON(raw []byte) ([]byte, error) { + d := json.NewDecoder(bytes.NewReader(raw)) + d.UseNumber() + var v any + if err := d.Decode(&v); err != nil { + return nil, err + } + return json.Marshal(v) +} + +// encoding/json otherwise accepts duplicate keys, allowing conflicting identity +// or password-related metadata to be interpreted differently by other readers. +func uniqueSnapshotJSON(raw []byte) error { + d := json.NewDecoder(bytes.NewReader(raw)) + d.UseNumber() + var value func(int) error + value = func(depth int) error { + if depth > 64 { + return invalidSnapshot() + } + tok, err := d.Token() + if err != nil { + return invalidSnapshot() + } + delim, ok := tok.(json.Delim) + if !ok { + return nil + } + switch delim { + case '{': + seen := map[string]bool{} + for d.More() { + key, e := d.Token() + if e != nil { + return invalidSnapshot() + } + name, ok := key.(string) + if !ok || seen[name] { + return invalidSnapshot() + } + seen[name] = true + if e = value(depth + 1); e != nil { + return e + } + } + case '[': + for d.More() { + if e := value(depth + 1); e != nil { + return e + } + } + default: + return invalidSnapshot() + } + _, err = d.Token() + return err + } + if err := value(0); err != nil { + return invalidSnapshot() + } + if _, err := d.Token(); !errors.Is(err, io.EOF) { + return invalidSnapshot() + } + return nil +} + +var snapshotXMLAttribute = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9_-]*$`) +var snapshotSecretAttributes = map[string]bool{"text": true, "content-desc": true, "contentDescription": true, "hint": true, "state-description": true, "stateDescription": true, "tooltip": true, "label": true} + +func validateFailureXML(raw []byte) error { + d := xml.NewDecoder(bytes.NewReader(raw)) + d.Strict = true + type frame struct { + name string + password bool + } + stack := []frame{} + roots := 0 + nodes := 0 + for { + token, err := d.Token() + if errors.Is(err, io.EOF) { + break + } + if err != nil { + return invalidSnapshot() + } + switch v := token.(type) { + case xml.Directive: + return invalidSnapshot() + case xml.ProcInst: + if v.Target != "xml" || roots != 0 { + return invalidSnapshot() + } + case xml.Comment: + return invalidSnapshot() + case xml.CharData: + if strings.TrimSpace(string(v)) != "" { + return invalidSnapshot() + } + case xml.StartElement: + if v.Name.Space != "" || len(stack) > 256 { + return invalidSnapshot() + } + if len(stack) == 0 { + roots++ + if roots != 1 || v.Name.Local != "hierarchy" { + return invalidSnapshot() + } + } else { + parent := stack[len(stack)-1].name + if (v.Name.Local != "node" && v.Name.Local != "action") || parent == "action" || (v.Name.Local == "action" && parent != "node") { + return invalidSnapshot() + } + } + if v.Name.Local == "node" { + nodes++ + if nodes > 5000 { + return invalidSnapshot() + } + } + // A missing/unreadable password flag cannot establish that content is + // safe to retain. Only an explicit false on the node opens that gate. + secret := v.Name.Local == "node" + if len(stack) > 0 && v.Name.Local == "action" { + secret = stack[len(stack)-1].password + } + passwordState := false + for _, a := range v.Attr { + if v.Name.Local == "node" && a.Name.Local == "password" && a.Value == "false" { + secret = false + } + if a.Name.Local == "password" && strings.EqualFold(a.Value, "true") { + secret = true + } + if a.Name.Local == "password-state" { + passwordState = true + } + if a.Name.Local == "password-base64" || (a.Name.Local == "password" && a.Value != "true" && a.Value != "false") { + return invalidSnapshot() + } + } + secret = secret || passwordState + attrs := map[string]string{} + for _, a := range v.Attr { + if a.Name.Space != "" || !snapshotXMLAttribute.MatchString(a.Name.Local) { + return invalidSnapshot() + } + name := a.Name.Local + if _, ok := attrs[name]; ok { + return invalidSnapshot() + } + attrs[name] = a.Value + base := strings.TrimSuffix(strings.TrimSuffix(name, "-state"), "-base64") + if secret && snapshotSecretAttributes[base] { + return invalidSnapshot() + } + if strings.HasSuffix(name, "-state") && a.Value != "null" && a.Value != "unsupported" && a.Value != "read_error" { + return invalidSnapshot() + } + if strings.HasSuffix(name, "-base64") { + decoded, e := base64.StdEncoding.Strict().DecodeString(a.Value) + if e != nil || len(decoded)%2 != 0 { + return invalidSnapshot() + } + } + } + for name := range attrs { + if strings.HasSuffix(name, "-base64") || strings.HasSuffix(name, "-state") { + base := strings.TrimSuffix(strings.TrimSuffix(name, "-state"), "-base64") + if _, ok := attrs[base]; ok { + return invalidSnapshot() + } + } + } + stack = append(stack, frame{v.Name.Local, secret}) + case xml.EndElement: + if len(stack) == 0 { + return invalidSnapshot() + } + stack = stack[:len(stack)-1] + } + } + if roots != 1 || len(stack) != 0 { + return invalidSnapshot() + } + return nil +} diff --git a/server/app/goauto/purchase/failure_snapshot_handler.go b/server/app/goauto/purchase/failure_snapshot_handler.go new file mode 100644 index 0000000..26626a7 --- /dev/null +++ b/server/app/goauto/purchase/failure_snapshot_handler.go @@ -0,0 +1,158 @@ +package purchase + +import ( + "errors" + "io" + "net/http" + "strconv" + + "github.com/gin-gonic/gin" + jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth" + "github.com/google/uuid" + "go-admin/app/goauto/device" + "go-admin/common/clientprincipal" +) + +func (h Handler) UploadFailureSnapshot(c *gin.Context) { + s, ok := h.service(c) + if !ok { + return + } + // Authenticate before reading an untrusted archive. + if _, err := device.NewService(s.snapshotDB(c.Request.Context())).Authenticate(c.Request.Context(), bearer(c.GetHeader("Authorization"))); err != nil { + writeError(c, err) + return + } + taskID, ok := pathID(c) + if !ok { + return + } + c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, FailureSnapshotZIPLimit+FailureSnapshotMetadataLimit+(16<<10)) + reader, err := c.Request.MultipartReader() + if err != nil { + writeSnapshotError(c, invalidSnapshot()) + return + } + var metadata, archive []byte + seen := map[string]bool{} + for { + part, e := reader.NextPart() + if errors.Is(e, io.EOF) { + break + } + if e != nil { + var tooLarge *http.MaxBytesError + if errors.As(e, &tooLarge) { + writeSnapshotError(c, snapshotFailure(413, "FAILURE_SNAPSHOT_TOO_LARGE")) + } else { + writeSnapshotError(c, invalidSnapshot()) + } + return + } + name := part.FormName() + limit := int64(FailureSnapshotMetadataLimit) + if name == "archive" { + limit = FailureSnapshotZIPLimit + } else if name != "metadata" { + part.Close() + writeSnapshotError(c, invalidSnapshot()) + return + } + if seen[name] { + part.Close() + writeSnapshotError(c, invalidSnapshot()) + return + } + seen[name] = true + raw, e := io.ReadAll(io.LimitReader(part, limit+1)) + part.Close() + if e != nil || int64(len(raw)) > limit { + writeSnapshotError(c, snapshotFailure(413, "FAILURE_SNAPSHOT_TOO_LARGE")) + return + } + if name == "metadata" { + metadata = raw + } else { + archive = raw + } + } + if seen["archive"] && len(archive) == 0 { + writeSnapshotError(c, invalidSnapshot()) + return + } + status, replayed, err := s.UploadFailureSnapshot(c.Request.Context(), taskID, c.Param("attemptId"), bearer(c.GetHeader("Authorization")), metadata, archive) + if err != nil { + writeSnapshotError(c, err) + return + } + c.Header("Cache-Control", "no-store") + c.JSON(200, gin.H{"data": gin.H{"taskAttemptId": c.Param("attemptId"), "status": status, "replayed": replayed}}) +} + +func snapshotAdminOnly(c *gin.Context) bool { + _, client := clientprincipal.Get(c) + role, _ := jwt.ExtractClaims(c)["rolekey"].(string) + if !client && role == "admin" { + return true + } + c.AbortWithStatusJSON(403, gin.H{"code": "FORBIDDEN", "message": "仅管理员可访问现场诊断"}) + return false +} + +func (h Handler) FailureSnapshotSummaries(c *gin.Context) { + if !snapshotAdminOnly(c) { + return + } + id, ok := pathID(c) + if !ok { + return + } + s, ok := h.service(c) + if !ok { + return + } + items, err := s.FailureSnapshotSummaries(c.Request.Context(), id) + if err != nil { + writeSnapshotError(c, err) + return + } + c.Header("Cache-Control", "no-store") + writeAdminData(c, gin.H{"items": items}) +} + +func (h Handler) DownloadFailureSnapshot(c *gin.Context) { + if !snapshotAdminOnly(c) { + return + } + id, ok := pathID(c) + if !ok { + return + } + attempt, err := uuid.Parse(c.Param("attemptId")) + if err != nil || attempt.String() != c.Param("attemptId") { + writeSnapshotError(c, invalidSnapshot()) + return + } + s, ok := h.service(c) + if !ok { + return + } + raw, err := s.DownloadFailureSnapshot(c.Request.Context(), id, attempt.String()) + if err != nil { + writeSnapshotError(c, err) + return + } + c.Header("Cache-Control", "no-store") + c.Header("X-Content-Type-Options", "nosniff") + c.Header("Content-Disposition", `attachment; filename="purchase-`+strconv.FormatUint(id, 10)+`-`+attempt.String()+`.zip"`) + c.Data(200, "application/zip", raw) +} + +func writeSnapshotError(c *gin.Context, err error) { + var e *snapshotError + if errors.As(err, &e) { + c.JSON(e.status, gin.H{"code": e.code, "message": "现场诊断请求未被接受", "retryable": false}) + return + } + writeError(c, err) +} diff --git a/server/app/goauto/purchase/failure_snapshot_quality_test.go b/server/app/goauto/purchase/failure_snapshot_quality_test.go new file mode 100644 index 0000000..e6ff988 --- /dev/null +++ b/server/app/goauto/purchase/failure_snapshot_quality_test.go @@ -0,0 +1,133 @@ +package purchase + +import ( + "archive/zip" + "bytes" + "encoding/json" + "fmt" + "io" + "mime/multipart" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/gin-gonic/gin" +) + +func TestFailureSnapshotMissingPasswordFlagRejectsContents(t *testing.T) { + for _, raw := range []string{ + ``, + ``, + ``, + ``, + } { + if validateFailureXML([]byte(raw)) == nil { + t.Errorf("missing password flag retained content: %s", raw) + } + } + for _, raw := range []string{ + ``, + ``, + ``, + } { + if err := validateFailureXML([]byte(raw)); err != nil { + t.Fatalf("safe structure or explicit non-password rejected: %v", err) + } + } +} + +func exactSnapshotZIP(t *testing.T, m FailureSnapshotMetadata, size int) []byte { + t.Helper() + makeZIP := func(padding int) []byte { + var b bytes.Buffer + w := zip.NewWriter(&b) + raw, _ := json.Marshal(m) + for _, entry := range []struct { + name string + data []byte + }{{"manifest.json", raw}, {"window-1.xml", []byte(``)}} { + part, err := w.CreateHeader(&zip.FileHeader{Name: entry.name, Method: zip.Store}) + if err != nil { + t.Fatal(err) + } + if _, err = part.Write(entry.data); err != nil { + t.Fatal(err) + } + } + if err := w.Close(); err != nil { + t.Fatal(err) + } + return b.Bytes() + } + base := makeZIP(0) + if size < len(base) { + t.Fatal("requested archive size too small") + } + result := makeZIP(size - len(base)) + if len(result) != size { + t.Fatalf("ZIP fixture size=%d want=%d", len(result), size) + } + return result +} + +func TestFailureSnapshotHTTPMultipartLimits(t *testing.T) { + gin.SetMode(gin.TestMode) + for _, tc := range []struct { + name string + metadataSize, archiveSize int + oversizedPreamble bool + want int + }{ + {name: "metadata_exact_64KiB", metadataSize: FailureSnapshotMetadataLimit, want: 200}, + {name: "metadata_64KiB_plus_one", metadataSize: FailureSnapshotMetadataLimit + 1, want: 413}, + {name: "archive_exact_2MiB", archiveSize: FailureSnapshotZIPLimit, want: 200}, + {name: "archive_2MiB_plus_one", archiveSize: FailureSnapshotZIPLimit + 1, want: 413}, + {name: "whole_body_exceeded_in_next_part", oversizedPreamble: true, want: 413}, + } { + t.Run(tc.name, func(t *testing.T) { + s, f, a, m := snapshotFixture(t) + m.RecordedAt = time.Now().UTC() + var archive []byte + if tc.archiveSize > 0 { + m = capturedMetadata(m) + archive = exactSnapshotZIP(t, m, tc.archiveSize) + } + raw, _ := json.Marshal(m) + if tc.metadataSize > 0 { + raw = append(raw, bytes.Repeat([]byte(" "), tc.metadataSize-len(raw))...) + } + var body bytes.Buffer + mw := multipart.NewWriter(&body) + if tc.oversizedPreamble { + limit := FailureSnapshotMetadataLimit + FailureSnapshotZIPLimit + (16 << 10) + body.WriteString(strings.Repeat("ignored-preamble\r\n", limit/18+2)) + } + if err := mw.WriteField("metadata", string(raw)); err != nil { + t.Fatal(err) + } + if archive != nil { + part, err := mw.CreateFormFile("archive", "synthetic.zip") + if err != nil { + t.Fatal(err) + } + if _, err = io.Copy(part, bytes.NewReader(archive)); err != nil { + t.Fatal(err) + } + } + if err := mw.Close(); err != nil { + t.Fatal(err) + } + r := gin.New() + r.POST("/:taskId/attempts/:attemptId/failure-snapshot", (Handler{DB: s.DB}).UploadFailureSnapshot) + req := httptest.NewRequest("POST", fmt.Sprintf("/%d/attempts/%s/failure-snapshot", a.TaskID, a.AttemptID), &body) + req.Header.Set("Content-Type", mw.FormDataContentType()) + req.Header.Set("Authorization", "Bearer "+f.token) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + if w.Code != tc.want { + t.Fatalf("status=%d want=%d body=%s", w.Code, tc.want, w.Body.String()) + } + }) + } +} diff --git a/server/app/goauto/purchase/failure_snapshot_test.go b/server/app/goauto/purchase/failure_snapshot_test.go new file mode 100644 index 0000000..9d1f998 --- /dev/null +++ b/server/app/goauto/purchase/failure_snapshot_test.go @@ -0,0 +1,507 @@ +package purchase + +import ( + "archive/zip" + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "github.com/gin-gonic/gin" + jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth" + "github.com/google/uuid" + "go-admin/app/goauto/access" + "go-admin/app/goauto/models" + "go-admin/common/clientprincipal" + "gorm.io/gorm" + "gorm.io/gorm/logger" + "log" + "mime/multipart" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" +) + +func TestFailureSnapshotDedicatedRoutesAndPrivateSchema(t *testing.T) { + gin.SetMode(gin.TestMode) + r := gin.New() + InitRouter(r, &jwt.GinJWTMiddleware{}) + expected := map[string]bool{ + "POST /api/agent/v1/purchase-tasks/:taskId/attempts/:attemptId/failure-snapshot": false, + "GET /api/admin/v1/purchase-tasks/:taskId/failure-snapshots": false, + "GET /api/admin/v1/purchase-tasks/:taskId/attempts/:attemptId/failure-snapshot/download": false, + } + for _, route := range r.Routes() { + key := route.Method + " " + route.Path + if _, ok := expected[key]; ok { + expected[key] = true + } + } + for route, found := range expected { + if !found { + t.Errorf("dedicated snapshot route missing: %s", route) + } + } + for _, path := range []string{"/api/admin/v1/purchase-tasks/:taskId/failure-snapshots", "/api/admin/v1/purchase-tasks/:taskId/attempts/:attemptId/failure-snapshot/download"} { + found := false + for _, p := range access.AdminAPIs { + if p.Path == path && p.Method == "GET" { + found = true + if p.Purchaser { + t.Error("snapshot permission leaked to purchaser") + } + } + } + if !found { + t.Errorf("admin-only permission missing: %s", path) + } + } + db := testDB(t) + if !db.Migrator().HasTable("purchase_failure_snapshot") { + t.Error("private snapshot table missing from fresh schema") + } +} + +func snapshotFixture(t *testing.T) (*Service, fixture, models.PurchaseTaskAttempt, FailureSnapshotMetadata) { + t.Helper() + db := testDB(t) + f := seed(t, db, liveCaps(), true) + s := testService(db) + task := backfillTask(t, db, f, models.PurchaseTaskStatusFailed) + hash := strings.Repeat("a", 64) + a := models.PurchaseTaskAttempt{TaskID: task.ID, AttemptID: uuid.NewString(), AttemptNumber: 1, Phase: "purchase", Status: "failed", DeviceID: &f.device.ID, RuleSnapshotHash: hash, SpecDecisionSnapshot: "{}"} + if err := db.Create(&a).Error; err != nil { + t.Fatal(err) + } + m := FailureSnapshotMetadata{SchemaVersion: 1, AttributeVersion: 1, TaskID: task.ID, DeviceID: f.device.ID, TaskAttemptID: a.AttemptID, Phase: a.Phase, RuleSnapshotHash: &hash, RuleSnapshotHashValid: true, ErrorCode: "PURCHASE_ORDER_CONTEXT_NOT_FOUND", AgentVersion: "0.9.65", RecordedAt: s.Now(), Source: "execution", Status: "not_captured", Reasons: []string{"process_interrupted"}, Windows: []json.RawMessage{}} + return s, f, a, m +} + +func uploadSnapshot(t *testing.T, s *Service, f fixture, m FailureSnapshotMetadata, archive []byte) (bool, error) { + t.Helper() + raw, _ := json.Marshal(m) + _, replay, err := s.UploadFailureSnapshot(context.Background(), m.TaskID, m.TaskAttemptID, f.token, raw, archive) + return replay, err +} + +func snapshotZIP(t *testing.T, m FailureSnapshotMetadata, xml string, extra map[string]string) []byte { + t.Helper() + var b bytes.Buffer + w := zip.NewWriter(&b) + raw, _ := json.Marshal(m) + p, _ := w.Create("manifest.json") + p.Write(raw) + if xml != "" { + p, _ = w.Create("window-1.xml") + p.Write([]byte(xml)) + } + for n, v := range extra { + p, _ = w.Create(n) + p.Write([]byte(v)) + } + if err := w.Close(); err != nil { + t.Fatal(err) + } + return b.Bytes() +} + +func capturedMetadata(m FailureSnapshotMetadata) FailureSnapshotMetadata { + m.Status = "complete" + m.Source = "recovery" + m.CapturedAt = &m.RecordedAt + m.Reasons = []string{} + m.Windows = []json.RawMessage{json.RawMessage(`{"id":1,"fileName":"window-1.xml","status":"complete","reasons":[]}`)} + return m +} + +func TestFailureSnapshotNoCaptureReplayUpgradeAndImmutableZIP(t *testing.T) { + s, f, a, m := snapshotFixture(t) + if replay, err := uploadSnapshot(t, s, f, m, nil); err != nil || replay { + t.Fatalf("initial: replay=%v err=%v", replay, err) + } + if replay, err := uploadSnapshot(t, s, f, m, nil); err != nil || !replay { + t.Fatalf("replay: replay=%v err=%v", replay, err) + } + // Current task ownership/hash may change after reset; attempt remains authority. + if err := s.DB.Table("purchase_task").Where("id = ?", a.TaskID).Updates(map[string]any{"device_id": nil, "rule_snapshot": "{}"}).Error; err != nil { + t.Fatal(err) + } + m = capturedMetadata(m) + archive := snapshotZIP(t, m, ``, nil) + if _, err := uploadSnapshot(t, s, f, m, archive); err != nil { + t.Fatal(err) + } + if replay, err := uploadSnapshot(t, s, f, m, archive); err != nil || !replay { + t.Fatalf("ZIP replay: %v %v", replay, err) + } + other := snapshotZIP(t, m, ``, nil) + if _, err := uploadSnapshot(t, s, f, m, other); err == nil { + t.Fatal("ZIP overwritten") + } + raw, err := s.DownloadFailureSnapshot(context.Background(), a.TaskID, a.AttemptID) + if err != nil || !bytes.Equal(raw, archive) { + t.Fatalf("original lost: err=%v bytes=%d expected=%d", err, len(raw), len(archive)) + } + views, err := s.FailureSnapshotSummaries(context.Background(), a.TaskID) + if err != nil || len(views) != 1 || !views[0].DownloadAvailable { + t.Fatal("summary missing") + } + out, _ := json.Marshal(views) + if bytes.Contains(out, []byte("SYNTHETIC_PRIVATE")) || bytes.Contains(out, []byte("windows")) { + t.Fatal("summary leak") + } + var row models.PurchaseFailureSnapshot + s.DB.First(&row) + out, _ = json.Marshal(row) + if string(out) != "{}" { + t.Fatal("model has public fields") + } +} + +func TestFailureSnapshotOwnershipHashPhaseAndSuccessfulAttempt(t *testing.T) { + for _, kind := range []string{"device", "task", "attempt", "phase", "hash", "success", "expired", "future", "invalid_hash_zip"} { + t.Run(kind, func(t *testing.T) { + s, f, a, m := snapshotFixture(t) + switch kind { + case "device": + m.DeviceID++ + case "task": + m.TaskID++ + case "attempt": + m.TaskAttemptID = uuid.NewString() + case "phase": + m.Phase = "spec_probe" + case "hash": + h := strings.Repeat("b", 64) + m.RuleSnapshotHash = &h + case "success": + s.DB.Model(&a).Updates(map[string]any{"result_type": "order_created", "status": "completed"}) + case "expired": + m.RecordedAt = m.RecordedAt.Add(-FailureSnapshotRetention) + case "future": + m.RecordedAt = m.RecordedAt.Add(time.Hour) + case "invalid_hash_zip": + m = capturedMetadata(m) + m.RuleSnapshotHashValid = false + m.RuleSnapshotHash = nil + } + if _, err := uploadSnapshot(t, s, f, m, nil); err == nil { + t.Fatalf("accepted %s", kind) + } + var count int64 + s.DB.Model(&models.PurchaseFailureSnapshot{}).Count(&count) + if count != 0 { + t.Fatal("rejected request persisted") + } + }) + } +} + +func TestFailureSnapshotDedicatedAdminGuard(t *testing.T) { + for _, role := range []string{"admin", "purchaser", "after_sales", ""} { + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Set("JWT_PAYLOAD", jwt.MapClaims{"rolekey": role}) + if got := snapshotAdminOnly(c); got != (role == "admin") { + t.Fatalf("role %s", role) + } + } + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Set("JWT_PAYLOAD", jwt.MapClaims{"rolekey": "admin"}) + clientprincipal.Set(c, clientprincipal.Identity{KeyID: 1}) + if snapshotAdminOnly(c) { + t.Fatal("client key accepted") + } +} + +func TestFailureSnapshotServerDemotionStillAcceptsDiagnostic(t *testing.T) { + s, f, a, m := snapshotFixture(t) + s.DB.Model(&a).Update("result_type", "order_created") + if _, err := uploadSnapshot(t, s, f, m, nil); err != nil { + t.Fatalf("demoted failed attempt rejected: %v", err) + } +} + +func TestFailureSnapshotSemanticReplayAndDuplicateJSON(t *testing.T) { + s, f, _, m := snapshotFixture(t) + m = capturedMetadata(m) + archive := snapshotZIP(t, m, ``, nil) + if _, err := uploadSnapshot(t, s, f, m, archive); err != nil { + t.Fatal(err) + } + m.Windows = []json.RawMessage{json.RawMessage(`{"status":"complete","fileName":"window-1.xml","id":1,"reasons":[]}`)} + if replay, err := uploadSnapshot(t, s, f, m, archive); err != nil || !replay { + t.Fatalf("semantic replay: %v %v", replay, err) + } + raw, _ := json.Marshal(m) + raw = append([]byte(`{"status":"not_captured",`), raw[1:]...) + if _, err := decodeSnapshotMetadata(raw); err == nil { + t.Fatal("duplicate JSON key accepted") + } +} + +func TestFailureSnapshotHTTPIsolationMultipartAndDownload(t *testing.T) { + s, f, a, m := snapshotFixture(t) + m.RecordedAt = time.Now().UTC() + m = capturedMetadata(m) + archive := snapshotZIP(t, m, ``, nil) + h := Handler{DB: s.DB} + r := gin.New() + r.POST("/:taskId/attempts/:attemptId/failure-snapshot", h.UploadFailureSnapshot) + for _, tc := range []struct { + name, token string + duplicate bool + want int + }{{"missing_token", "", false, 401}, {"ok", f.token, false, 200}, {"duplicate", f.token, true, 422}} { + t.Run(tc.name, func(t *testing.T) { + var b bytes.Buffer + mw := multipart.NewWriter(&b) + raw, _ := json.Marshal(m) + mw.WriteField("metadata", string(raw)) + if tc.duplicate { + mw.WriteField("metadata", string(raw)) + } + p, _ := mw.CreateFormFile("archive", "snapshot.zip") + p.Write(archive) + mw.Close() + req := httptest.NewRequest("POST", fmt.Sprintf("/%d/attempts/%s/failure-snapshot", a.TaskID, a.AttemptID), &b) + req.Header.Set("Content-Type", mw.FormDataContentType()) + req.Header.Set("Authorization", "Bearer "+tc.token) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + if w.Code != tc.want { + t.Fatalf("status %d body %s", w.Code, w.Body.String()) + } + if strings.Contains(w.Body.String(), "PRIVATE_FIXTURE") { + t.Fatal("HTTP leak") + } + }) + } + for _, role := range []string{"admin", "purchaser", "after_sales"} { + router := gin.New() + router.Use(func(c *gin.Context) { c.Set("JWT_PAYLOAD", jwt.MapClaims{"rolekey": role}) }) + router.GET("/:taskId/failure-snapshots", h.FailureSnapshotSummaries) + router.GET("/:taskId/attempts/:attemptId/download", h.DownloadFailureSnapshot) + for _, suffix := range []string{"failure-snapshots", "attempts/" + a.AttemptID + "/download"} { + w := httptest.NewRecorder() + router.ServeHTTP(w, httptest.NewRequest(http.MethodGet, fmt.Sprintf("/%d/%s", a.TaskID, suffix), nil)) + if role != "admin" { + if w.Code != 403 { + t.Fatalf("%s: %d", role, w.Code) + } + } else { + if w.Code != 200 { + t.Fatalf("admin %s: %d %s", suffix, w.Code, w.Body.String()) + } + if strings.HasSuffix(suffix, "download") { + if w.Header().Get("Content-Type") != "application/zip" || !bytes.Equal(w.Body.Bytes(), archive) || w.Header().Get("Cache-Control") != "no-store" { + t.Fatal("download contract") + } + } else if strings.Contains(w.Body.String(), "PRIVATE_FIXTURE") || strings.Contains(w.Body.String(), "manifest") { + t.Fatal("metadata leak") + } + } + } + } +} + +func TestFailureSnapshotArchiveValidation(t *testing.T) { + _, _, _, base := snapshotFixture(t) + m := capturedMetadata(base) + raw, _ := json.Marshal(m) + for _, tc := range []struct { + name, xml string + extra map[string]string + }{ + {"doctype", `]>`, nil}, + {"multiple_roots", ``, nil}, + {"password_text", ``, nil}, + {"password_description", ``, nil}, + {"password_action", ``, nil}, + {"traversal", ``, map[string]string{"../outside.xml": "x"}}, + {"unknown_file", ``, map[string]string{"extra.xml": "x"}}, + {"expanded_limit", "" + strings.Repeat(" ", FailureSnapshotExpandedLimit) + "", nil}, + } { + t.Run(tc.name, func(t *testing.T) { + archive := snapshotZIP(t, m, tc.xml, tc.extra) + if _, err := validateFailureArchive(m, raw, archive); err == nil { + t.Fatal("unsafe archive accepted") + } + }) + } + valid := snapshotZIP(t, m, ``, nil) + if _, err := validateFailureArchive(m, raw, valid); err != nil { + t.Fatal(err) + } + wrong := m + wrong.TaskID++ + bad := snapshotZIP(t, wrong, ``, nil) + if _, err := validateFailureArchive(m, raw, bad); err == nil { + t.Fatal("wrong manifest accepted") + } +} + +func TestFailureSnapshotExpiryAndIdleCleanup(t *testing.T) { + s, f, a, m := snapshotFixture(t) + if _, err := uploadSnapshot(t, s, f, m, nil); err != nil { + t.Fatal(err) + } + now := s.Now().Add(FailureSnapshotRetention) + s.Now = func() time.Time { return now } + if _, err := uploadSnapshot(t, s, f, m, nil); err == nil { + t.Fatal("expired replay accepted") + } else { + var e *snapshotError + if !errors.As(err, &e) || e.status != 410 { + t.Fatal(err) + } + } + views, err := s.FailureSnapshotSummaries(context.Background(), a.TaskID) + if err != nil || len(views) != 0 { + t.Fatal("expired summary visible") + } + ctx, cancel := context.WithCancel(context.Background()) + cancel() + RunFailureSnapshotCleanup(ctx, s, time.Millisecond, nil) + // Exercise actual idle ticker independently of upload traffic. + ctx, cancel = context.WithCancel(context.Background()) + done := make(chan struct{}) + go func() { RunFailureSnapshotCleanup(ctx, s, time.Millisecond, nil); close(done) }() + deadline := time.Now().Add(time.Second) + for time.Now().Before(deadline) { + var count int64 + s.DB.Model(&models.PurchaseFailureSnapshot{}).Count(&count) + if count == 0 { + cancel() + <-done + return + } + time.Sleep(time.Millisecond) + } + cancel() + <-done + t.Fatal("idle cleanup did not run") +} + +func TestFailureSnapshotSQLAndOrdinaryViewsDoNotLeak(t *testing.T) { + s, f, a, m := snapshotFixture(t) + var logs bytes.Buffer + s.DB = s.DB.Session(&gorm.Session{Logger: logger.New(log.New(&logs, "", 0), logger.Config{LogLevel: logger.Info})}) + if _, err := uploadSnapshot(t, s, f, m, nil); err != nil { + t.Fatal(err) + } + m = capturedMetadata(m) + m.Windows = []json.RawMessage{json.RawMessage(`{"id":1,"fileName":"window-1.xml","status":"complete","reasons":[],"title":"PRIVATE_WINDOW_SENTINEL"}`)} + archive := snapshotZIP(t, m, ``, nil) + // Force a private-table persistence error after validation. The SQL must stay silent. + s.DB.Exec("CREATE TRIGGER reject_snapshot_update BEFORE UPDATE ON purchase_failure_snapshot BEGIN SELECT RAISE(FAIL, 'synthetic'); END") + if _, err := uploadSnapshot(t, s, f, m, archive); err == nil { + t.Fatal("synthetic write failure did not happen") + } + s.DB.Exec("DROP TRIGGER reject_snapshot_update") + if _, err := uploadSnapshot(t, s, f, m, archive); err != nil { + t.Fatal(err) + } + detail, err := s.AdminDetail(context.Background(), a.TaskID) + if err != nil { + t.Fatal(err) + } + raw, _ := json.Marshal(detail) + if bytes.Contains(raw, []byte("PRIVATE_")) || bytes.Contains(raw, []byte("manifest")) || strings.Contains(logs.String(), "PRIVATE_") { + t.Fatal("private data leaked") + } +} + +func TestFailureSnapshotZIPRejectsDuplicateCorruptionAndEmptyArchive(t *testing.T) { + _, _, _, base := snapshotFixture(t) + m := capturedMetadata(base) + raw, _ := json.Marshal(m) + var b bytes.Buffer + w := zip.NewWriter(&b) + for _, name := range []string{"manifest.json", "window-1.xml", "window-1.xml"} { + p, _ := w.Create(name) + if name == "manifest.json" { + p.Write(raw) + } else { + p.Write([]byte("")) + } + } + w.Close() + if _, err := validateFailureArchive(m, raw, b.Bytes()); err == nil { + t.Fatal("duplicate entry accepted") + } + if _, err := validateFailureArchive(m, raw, nil); err == nil { + t.Fatal("empty captured archive accepted") + } + bad := snapshotZIP(t, m, ``, nil) + bad[len(bad)/2] ^= 0xff + if _, err := validateFailureArchive(m, raw, bad); err == nil { + t.Fatal("corruption accepted") + } +} + +func TestFailureSnapshotPasswordVariantsAndReversibleAttributes(t *testing.T) { + for _, name := range []string{"text", "content-desc", "hint", "state-description", "tooltip"} { + for _, suffix := range []string{"", "-state", "-base64"} { + value := "private" + if suffix == "-state" { + value = "null" + } + if suffix == "-base64" { + value = "AGE=" + } + raw := []byte(``) + if validateFailureXML(raw) == nil { + t.Fatalf("password %s leaked", name+suffix) + } + } + } + for _, raw := range []string{``, ``, ``} { + if err := validateFailureXML([]byte(raw)); err != nil { + t.Fatalf("valid XML rejected: %v", err) + } + } + for _, raw := range []string{``, ``, ``} { + if validateFailureXML([]byte(raw)) == nil { + t.Fatal("ambiguous/invalid attribute accepted") + } + } +} + +func TestFailureSnapshotUnreadablePasswordFlagFailsClosed(t *testing.T) { + for _, raw := range []string{``, ``, ``} { + if validateFailureXML([]byte(raw)) == nil { + t.Error("unreliable password flag accepted with content") + } + } +} + +func TestFailureSnapshotRejectsPendingAttemptAndContradictoryWindow(t *testing.T) { + s, f, a, m := snapshotFixture(t) + s.DB.Model(&a).Update("status", "pending") + if _, err := uploadSnapshot(t, s, f, m, nil); err == nil { + t.Error("pending attempt accepted") + } + m = capturedMetadata(m) + m.Windows = append(m.Windows, json.RawMessage(`{"id":2,"fileName":null,"status":"partial","reasons":["window_root_null"]}`)) + if err := validateSnapshotMetadata(m, s.Now()); err == nil { + t.Error("incomplete windows declared complete") + } +} + +func TestFailureSnapshotMetadataRequiresExplicitValidityAndNullCapture(t *testing.T) { + _, _, _, m := snapshotFixture(t) + m.RuleSnapshotHash = nil + m.RuleSnapshotHashValid = false + m.Reasons = []string{"rule_hash_invalid"} + raw, _ := json.Marshal(m) + for _, field := range []string{`"ruleSnapshotHashValid":false,`, `"capturedAt":null,`, `"ruleSnapshotHash":null,`} { + missing := bytes.Replace(raw, []byte(field), nil, 1) + if _, err := decodeSnapshotMetadata(missing); err == nil { + t.Errorf("missing explicit field accepted: %s", field) + } + } +} diff --git a/server/app/goauto/purchase/router.go b/server/app/goauto/purchase/router.go index c99878e..e29d968 100644 --- a/server/app/goauto/purchase/router.go +++ b/server/app/goauto/purchase/router.go @@ -26,6 +26,7 @@ func InitRouter(engine *gin.Engine, auth *jwt.GinJWTMiddleware) { agent.POST("/:taskId/start", h.Start) agent.POST("/:taskId/order-submit-started", h.OrderSubmitStarted) agent.POST("/:taskId/result", h.Result) + agent.POST("/:taskId/attempts/:attemptId/failure-snapshot", h.UploadFailureSnapshot) admin := engine.Group("/api/admin/v1/purchase-tasks").Use(auth.MiddlewareFunc()).Use(middleware.AuthCheckRole()) admin.GET("", h.AdminList) admin.POST("/batch-preview", h.AdminBatchPreview) @@ -35,6 +36,8 @@ func InitRouter(engine *gin.Engine, auth *jwt.GinJWTMiddleware) { admin.POST("/syb-order-writeback", h.AdminOrderWriteback) admin.POST("/stock", h.AdminCreateStock) admin.GET("/:taskId", h.AdminDetail) + admin.GET("/:taskId/failure-snapshots", h.FailureSnapshotSummaries) + admin.GET("/:taskId/attempts/:attemptId/failure-snapshot/download", h.DownloadFailureSnapshot) admin.POST("", h.AdminCreate) admin.POST("/:taskId/spec-decision", h.SpecDecision) admin.GET("/:taskId/matching", h.MatchingDetail) diff --git a/server/cmd/api/server.go b/server/cmd/api/server.go index e435dae..d3ec9b8 100644 --- a/server/cmd/api/server.go +++ b/server/cmd/api/server.go @@ -129,6 +129,10 @@ func run() error { defer stopOfflineMonitors() for _, db := range sdk.Runtime.GetDb() { service := goautodevice.NewService(db) + go goautopurchase.RunFailureSnapshotCleanup( + offlineMonitorContext, goautopurchase.NewService(db), time.Hour, + func(err error) { log.Error("purchase failure snapshot cleanup failed") }, + ) go goautodevice.RunOfflineMonitor( offlineMonitorContext, service, goautodevice.DefaultOfflineScan, goautodevice.DefaultOfflineThreshold, func(err error) { log.Errorf("device offline monitor failed: %v", err) }, diff --git a/server/cmd/migrate/migration/version-local/1791400000000_purchase_failure_snapshot.go b/server/cmd/migrate/migration/version-local/1791400000000_purchase_failure_snapshot.go new file mode 100644 index 0000000..5536d60 --- /dev/null +++ b/server/cmd/migrate/migration/version-local/1791400000000_purchase_failure_snapshot.go @@ -0,0 +1,26 @@ +package version_local + +import ( + "go-admin/app/goauto/models" + "go-admin/cmd/migrate/migration" + common "go-admin/common/models" + "gorm.io/gorm" + "runtime" +) + +func init() { + _, file, _, _ := runtime.Caller(0) + migration.Migrate.SetVersion(migration.GetFilename(file), MigratePurchaseFailureSnapshot) +} + +// MigratePurchaseFailureSnapshot applies only the reviewed #364 table migration. +func MigratePurchaseFailureSnapshot(db *gorm.DB, version string) error { + return db.Transaction(func(tx *gorm.DB) error { + if !tx.Migrator().HasTable(&models.PurchaseFailureSnapshot{}) { + if err := tx.Migrator().CreateTable(&models.PurchaseFailureSnapshot{}); err != nil { + return err + } + } + return tx.Where("version = ?", version).FirstOrCreate(&common.Migration{Version: version}).Error + }) +} diff --git a/server/cmd/migrate/migration/version-local/1791400000000_purchase_failure_snapshot_mysql_test.go b/server/cmd/migrate/migration/version-local/1791400000000_purchase_failure_snapshot_mysql_test.go new file mode 100644 index 0000000..371b2d8 --- /dev/null +++ b/server/cmd/migrate/migration/version-local/1791400000000_purchase_failure_snapshot_mysql_test.go @@ -0,0 +1,93 @@ +package version_local + +import ( + "bytes" + "errors" + "os" + "strings" + "testing" + "time" + + drivermysql "github.com/go-sql-driver/mysql" + "go-admin/app/goauto/access" + "go-admin/app/goauto/models" + "gorm.io/driver/mysql" + "gorm.io/gorm" + "gorm.io/gorm/logger" +) + +// Explicit opt-in for the user-authorized newly initialized local database. +// Never accepts a remote host and never starts a worker or creates an order. +func TestFailureSnapshotAuthorizedLocalMySQL(t *testing.T) { + dsn := os.Getenv("GOAUTO_364_LOCAL_MYSQL_DSN") + if dsn == "" { + t.Skip("local MySQL verification requires explicit opt-in") + } + cfg, err := drivermysql.ParseDSN(dsn) + if err != nil || cfg.Net != "tcp" || cfg.Addr != "127.0.0.1:3308" || cfg.DBName != "goauto" { + t.Fatal("unexpected local database target") + } + db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)}) + if err != nil { + t.Fatal("local database unavailable") + } + sqlDB, _ := db.DB() + defer sqlDB.Close() + var orders int64 + if err := db.Model(&models.PurchaseTask{}).Count(&orders).Error; err != nil || orders != 0 { + t.Fatal("requires newly initialized local database with no purchase tasks") + } + for i := 0; i < 2; i++ { + if err := MigratePurchaseFailureSnapshot(db, "1791400000000"); err != nil { + t.Fatal("local additive migration failed") + } + } + if err := access.ReconcilePurchaserPermissions(db); err != nil { + t.Fatal("local permission reconciliation failed") + } + var catalog, ordinary int64 + if err := db.Table("sys_api").Where("path LIKE ?", "%failure-snapshot%").Count(&catalog).Error; err != nil || catalog != 2 { + t.Fatal("dedicated API catalogue is incomplete") + } + if err := db.Table("casbin_rule").Where("v1 LIKE ? AND v0 <> ?", "%failure-snapshot%", "admin").Count(&ordinary).Error; err != nil || ordinary != 0 { + t.Fatal("ordinary roles received private diagnostic grants") + } + var columns []struct { + Name string `gorm:"column:column_name"` + Kind string `gorm:"column:data_type"` + } + if err := db.Raw("SELECT column_name,data_type FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='purchase_failure_snapshot'").Scan(&columns).Error; err != nil { + t.Fatal("column inspection failed") + } + kinds := map[string]string{} + for _, col := range columns { + kinds[col.Name] = col.Kind + } + if kinds["manifest_json"] != "longtext" || kinds["zip_data"] != "longblob" { + t.Fatal("private payload columns cannot hold the contract limit") + } + rollbackFixture := errors.New("rollback synthetic fixture") + err = db.Transaction(func(tx *gorm.DB) error { + row := models.PurchaseFailureSnapshot{TaskID: 1, AttemptID: "36400000-0000-4000-8000-000000000001", DeviceID: 1, Phase: "purchase", RuleSnapshotHash: strings.Repeat("a", 64), Status: "partial", Source: "execution", ErrorCode: "SYNTHETIC_TEST", AgentVersion: "test", RecordedAt: time.Now(), ExpiresAt: time.Now().Add(time.Hour), ReasonsJSON: "[]", ManifestJSON: strings.Repeat("x", 64*1024), ZIPData: bytes.Repeat([]byte{0x41}, 2*1024*1024), PayloadSHA256: strings.Repeat("b", 64), ZIPSize: 2 * 1024 * 1024} + if e := tx.Create(&row).Error; e != nil { + return errors.New("MySQL contract-limit insert failed") + } + var readback models.PurchaseFailureSnapshot + if e := tx.First(&readback, row.ID).Error; e != nil || !bytes.Equal(readback.ZIPData, row.ZIPData) || readback.ManifestJSON != row.ManifestJSON { + return errors.New("MySQL private payload readback failed") + } + duplicate := row + duplicate.ID = 0 + if e := tx.Create(&duplicate).Error; e == nil { + return errors.New("attempt uniqueness is missing") + } + return rollbackFixture + }) + if !errors.Is(err, rollbackFixture) { + t.Fatal(err) + } + var fixtures int64 + if err := db.Model(&models.PurchaseFailureSnapshot{}).Where("attempt_id=?", "36400000-0000-4000-8000-000000000001").Count(&fixtures).Error; err != nil || fixtures != 0 { + t.Fatal("synthetic fixture was not rolled back") + } +} diff --git a/server/cmd/migrate/migration/version-local/1791400000000_purchase_failure_snapshot_test.go b/server/cmd/migrate/migration/version-local/1791400000000_purchase_failure_snapshot_test.go new file mode 100644 index 0000000..679f45f --- /dev/null +++ b/server/cmd/migrate/migration/version-local/1791400000000_purchase_failure_snapshot_test.go @@ -0,0 +1,65 @@ +package version_local + +import ( + "encoding/json" + "go-admin/app/goauto/models" + common "go-admin/common/models" + "gorm.io/driver/sqlite" + "gorm.io/gorm" + "gorm.io/gorm/logger" + "testing" + "time" +) + +func TestMigratePurchaseFailureSnapshotPreservesBusinessAndIsIdempotent(t *testing.T) { + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)}) + if err != nil { + t.Fatal(err) + } + conn, _ := db.DB() + defer conn.Close() + if err = db.AutoMigrate(&common.Migration{}); err != nil { + t.Fatal(err) + } + if err = db.Exec("CREATE TABLE purchase_task (id integer primary key, status text NOT NULL)").Error; err != nil { + t.Fatal(err) + } + if err = db.Exec("INSERT INTO purchase_task(id,status) VALUES(1,'failed')").Error; err != nil { + t.Fatal(err) + } + for i := 0; i < 2; i++ { + if err = MigratePurchaseFailureSnapshot(db, "test_364"); err != nil { + t.Fatal(err) + } + } + row := models.PurchaseFailureSnapshot{TaskID: 1, AttemptID: "00000000-0000-4000-8000-000000000001", DeviceID: 2, Phase: "purchase", RuleSnapshotHash: "hash", Status: "not_captured", Source: "execution", ErrorCode: "TEST", AgentVersion: "1", RecordedAt: time.Now(), ExpiresAt: time.Now().Add(time.Hour), ReasonsJSON: "[]", ManifestJSON: "{}", PayloadSHA256: "digest"} + if err = db.Create(&row).Error; err != nil { + t.Fatal(err) + } + duplicate := row + duplicate.ID = 0 + if db.Create(&duplicate).Error == nil { + t.Fatal("attempt unique index missing") + } + if err = MigratePurchaseFailureSnapshot(db, "test_364"); err != nil { + t.Fatal(err) + } + var status string + db.Table("purchase_task").Select("status").Where("id=1").Scan(&status) + if status != "failed" { + t.Fatal("business row changed") + } + cols, err := db.Migrator().ColumnTypes("purchase_task") + if err != nil || len(cols) != 2 { + t.Fatal("business schema changed") + } + var count int64 + db.Model(&common.Migration{}).Where("version = ?", "test_364").Count(&count) + if count != 1 { + t.Fatal("version not idempotent") + } + raw, _ := json.Marshal(row) + if string(raw) != "{}" { + t.Fatal("private row can be serialized") + } +} diff --git a/server/common/middleware/failure_snapshot_logger_test.go b/server/common/middleware/failure_snapshot_logger_test.go new file mode 100644 index 0000000..e232ca8 --- /dev/null +++ b/server/common/middleware/failure_snapshot_logger_test.go @@ -0,0 +1,45 @@ +package middleware + +import ( + "github.com/gin-gonic/gin" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +type snapshotBodySpy struct { + reads int + body io.Reader +} + +func (s *snapshotBodySpy) Read(p []byte) (int, error) { s.reads++; return s.body.Read(p) } +func (s *snapshotBodySpy) Close() error { return nil } + +func TestFailureSnapshotLoggerNeverReadsOrRecordsBody(t *testing.T) { + gin.SetMode(gin.TestMode) + for _, path := range []string{ + "/api/agent/v1/purchase-tasks/1/attempts/00000000-0000-4000-8000-000000000001/failure-snapshot", + "/api/admin/v1/purchase-tasks/1/failure-snapshots", + "/api/admin/v1/purchase-tasks/1/attempts/00000000-0000-4000-8000-000000000001/failure-snapshot/download", + } { + t.Run(path, func(t *testing.T) { + spy := &snapshotBodySpy{body: strings.NewReader("SYNTHETIC_PRIVATE_CONTENT")} + r := gin.New() + r.Use(LoggerToFile()) + r.POST(path, func(c *gin.Context) { + if spy.reads != 0 { + t.Error("logger consumed private body before handler") + } + c.Status(http.StatusNoContent) + }) + req := httptest.NewRequest(http.MethodPost, path, nil) + req.Body = spy + r.ServeHTTP(httptest.NewRecorder(), req) + if spy.reads != 0 { + t.Error("logger consumed private body") + } + }) + } +} diff --git a/server/common/middleware/logger.go b/server/common/middleware/logger.go index afb071e..0da280b 100644 --- a/server/common/middleware/logger.go +++ b/server/common/middleware/logger.go @@ -26,7 +26,7 @@ func LoggerToFile() gin.HandlerFunc { return func(c *gin.Context) { // #237: client request/response bodies and one-time credentials must never // enter the legacy operation logger. The client gateway keeps metadata-only audit. - if strings.HasPrefix(c.Request.URL.Path, "/api/client/") || strings.HasPrefix(c.Request.URL.Path, "/api/admin/v1/client-keys") { + if strings.HasPrefix(c.Request.URL.Path, "/api/client/") || strings.HasPrefix(c.Request.URL.Path, "/api/admin/v1/client-keys") || isPrivateFailureSnapshotPath(c.Request.URL.Path) { c.Next() return } @@ -111,6 +111,22 @@ func LoggerToFile() gin.HandlerFunc { } } +// #364: these dedicated endpoints carry private accessibility diagnostics. +// Skip before reading the body; their handlers enforce authentication and limits. +func isPrivateFailureSnapshotPath(path string) bool { + parts := strings.Split(strings.Trim(path, "/"), "/") + if len(parts) < 6 || parts[0] != "api" || parts[2] != "v1" || parts[3] != "purchase-tasks" { + return false + } + if parts[1] == "admin" && len(parts) == 6 && parts[5] == "failure-snapshots" { + return true + } + if len(parts) >= 8 && parts[5] == "attempts" && parts[7] == "failure-snapshot" { + return (parts[1] == "agent" && len(parts) == 8) || (parts[1] == "admin" && len(parts) == 9 && parts[8] == "download") + } + return false +} + // SetDBOperLog 写入操作日志表 fixme 该方法后续即将弃用 func SetDBOperLog(c *gin.Context, clientIP string, statusCode int, reqUri string, reqMethod string, latencyTime time.Duration, body string, result string, status int) { diff --git a/web/src/api/goauto/purchase-failure-snapshots.js b/web/src/api/goauto/purchase-failure-snapshots.js new file mode 100644 index 0000000..481d074 --- /dev/null +++ b/web/src/api/goauto/purchase-failure-snapshots.js @@ -0,0 +1,19 @@ +import request from '@/utils/request' + +export function listFailureSnapshots(taskId) { + return request({ url: `/api/admin/v1/purchase-tasks/${encodeURIComponent(taskId)}/failure-snapshots`, method: 'get', suppressErrorMessage: true }) +} + +export async function downloadFailureSnapshot(taskId, attemptId) { + const response = await request({ + url: `/api/admin/v1/purchase-tasks/${encodeURIComponent(taskId)}/attempts/${encodeURIComponent(attemptId)}/failure-snapshot/download`, + method: 'get', responseType: 'blob', suppressErrorMessage: true, + // Adapt only this endpoint to the existing JSON-envelope interceptor. + transformResponse: [data => ({ code: 200, data })] + }) + const blob = response.data + if (!(blob instanceof Blob) || blob.type.split(';')[0] !== 'application/zip') throw new Error('下载失败,请重试') + const signature = new Uint8Array(await blob.slice(0, 4).arrayBuffer()) + if (signature.length !== 4 || signature[0] !== 80 || signature[1] !== 75 || signature[2] !== 3 || signature[3] !== 4) throw new Error('下载失败,请重试') + return blob +} diff --git a/web/src/views/goauto/purchase-tasks/FailureSnapshotCell.vue b/web/src/views/goauto/purchase-tasks/FailureSnapshotCell.vue new file mode 100644 index 0000000..15ed26f --- /dev/null +++ b/web/src/views/goauto/purchase-tasks/FailureSnapshotCell.vue @@ -0,0 +1,55 @@ + + + + + diff --git a/web/src/views/goauto/purchase-tasks/index.vue b/web/src/views/goauto/purchase-tasks/index.vue index f8a9a4c..08ba033 100644 --- a/web/src/views/goauto/purchase-tasks/index.vue +++ b/web/src/views/goauto/purchase-tasks/index.vue @@ -73,6 +73,7 @@

执行记录

+
@@ -153,6 +154,8 @@ import { ElMessage, ElMessageBox } from 'element-plus' import { RefreshLeft, Search } from '@element-plus/icons-vue' import { authorizeRepurchase, cancelPurchaseTask, choosePurchaseMatching, getPurchaseTask, listPurchaseTasks, requeuePurchaseMatching, resolveUnknownPurchaseTask, retryPurchaseTasksBatch, reviewPurchasePayment, selectPurchaseWriteback, writebackPurchaseOrderNumbers } from '@/api/goauto/purchase-tasks' import { createRequestId } from '@/utils/request-id' +import { listFailureSnapshots } from '@/api/goauto/purchase-failure-snapshots' +import FailureSnapshotCell from './FailureSnapshotCell.vue' const statusOptions = [ ['pending', '待执行'], ['spec_probe_pending', '待探测规格'], ['running', '执行中'], ['rehearsal_completed', '演练完成'], @@ -161,10 +164,12 @@ const statusOptions = [ export default { name: 'GoAutoPurchaseTasks', + components: { FailureSnapshotCell }, setup() { return { RefreshLeft, Search } }, data() { return { loading: false, loadError: '', tasks: [], total: 0, statuses: [...statusOptions, { value: 'syb_writeback_succeeded', label: '已回填 SYB' }], + detailSession: 0, snapshots: { items: {}, loading: false, error: false }, orderWriteback: { open: false, saving: false, items: [] }, query: { page: 1, pageSize: 20, taskId: '', taskType: '', shopeeOrderNo: '', status: '', executionMode: 'live', sybProductId: '', pddOrderNo: '' }, detail: { open: false, loading: false, task: null, attempts: [], matching: null }, @@ -178,6 +183,7 @@ export default { } }, computed: { + isSnapshotAdmin() { return (this.$store.getters.roles || []).includes('admin') }, orderFilter() { return parsePurchaseOrderFilter(this.query.shopeeOrderNo) }, retryCandidates() { return this.retrySelection.filter(this.isRetrySelectable) }, orderWritebackCandidates() { return this.retrySelection.filter(this.canOrderWriteback) }, @@ -197,6 +203,12 @@ export default { canSubmitMatching() { const m = this.detail.matching || {}; return (!m.targetColor || this.matchingDialog.color) && (!m.targetSize || this.matchingDialog.size) }, firstRetriedTask() { return this.retryResult.items.find(item => item.created && item.taskId) || null } }, + watch: { + 'detail.open'(open) { if (!open) this.clearSnapshots() }, + isSnapshotAdmin(admin) { if (!admin) this.clearSnapshots() } + }, + beforeUnmount() { this.clearSnapshots() }, + deactivated() { this.detail.open = false; this.clearSnapshots() }, created() { const taskId = Number(this.$route.query.taskId) if (Number.isInteger(taskId) && taskId > 0) { @@ -223,8 +235,20 @@ export default { }, search() { this.query.page = 1; this.load() }, reset() { this.query = { page: 1, pageSize: 20, taskId: '', taskType: '', shopeeOrderNo: '', status: '', executionMode: 'live', sybProductId: '', pddOrderNo: '' }; this.load() }, - async openDetail(row) { this.detail = { open: true, loading: true, task: null, attempts: [], matching: null }; try { await this.refreshDetail(row.id) } finally { this.detail.loading = false } }, - async refreshDetail(id = this.detail.task?.id) { const r = await getPurchaseTask(id); this.detail.task = r.data.task; this.detail.attempts = r.data.attempts || []; this.detail.matching = r.data.matching || r.data.task?.matching || null }, + clearSnapshots() { this.detailSession++; this.snapshots = { items: {}, loading: false, error: false } }, + async loadSnapshots() { + if (!this.isSnapshotAdmin || !this.detail.open || !this.detail.task || this.snapshots.loading) return + const session = this.detailSession + this.snapshots = { items: {}, loading: true, error: false } + const state = this.snapshots + try { + const r = await listFailureSnapshots(this.detail.task.id) + if (session !== this.detailSession || !this.detail.open || !this.isSnapshotAdmin) return + state.items = Object.fromEntries((r.data.items || []).map(item => [item.attemptId, item])) + } catch { if (session === this.detailSession) state.error = true } finally { if (session === this.detailSession) state.loading = false } + }, + async openDetail(row) { this.clearSnapshots(); const session = this.detailSession; this.detail = { open: true, loading: true, task: null, attempts: [], matching: null }; try { await this.refreshDetail(row.id) } finally { if (session === this.detailSession) this.detail.loading = false } }, + async refreshDetail(id = this.detail.task?.id) { const session = this.detailSession; const r = await getPurchaseTask(id); if (session !== this.detailSession || !this.detail.open) return; this.detail.task = r.data.task; this.detail.attempts = r.data.attempts || []; this.detail.matching = r.data.matching || r.data.task?.matching || null; this.loadSnapshots() }, statusLabel(value) { return statusOptions.find(item => item.value === value)?.label || value || '—' }, statusType(value) { return { pending: 'info', spec_probe_pending: 'warning', running: 'warning', rehearsal_completed: 'success', order_submit_started: 'warning', order_created: 'success', order_result_unknown: 'danger', failed: 'danger', cancelled: 'info' }[value] || 'info' }, matchingLabel(value) { return { pending: '等待匹配', running: '匹配中', retry_wait: '等待重试', matched: '已匹配', manual_required: '需要人工处理', cancelled: '输入已失效', unresolved: '执行时探测' }[value] || '未记录' }, diff --git a/web/tests/e2e/purchase-failure-snapshots.spec.ts b/web/tests/e2e/purchase-failure-snapshots.spec.ts new file mode 100644 index 0000000..2539048 --- /dev/null +++ b/web/tests/e2e/purchase-failure-snapshots.spec.ts @@ -0,0 +1,108 @@ +import { expect, test } from '@playwright/test' + +const menu = [{ path: '/collection-purchase', component: 'Layout', menuName: 'GoAutoCollectionPurchase', title: '采集采购', visible: '0', children: [{ path: '/purchase-tasks/index', component: '/goauto/purchase-tasks/index', menuName: 'GoAutoPurchaseTasks', title: '采购管理', visible: '0' }] }] +async function setup(page: any, roles = ['admin']) { + const state = { reads: 0, downloads: 0, failRead: false, failDownload: false, delay: 0, downloadDelay: 0 } + await page.context().addCookies([{ name: 'Admin-Token', value: 'synthetic-test-token', domain: 'localhost', path: '/' }]) + await page.route('**/api/**', async route => { + const path = new URL(route.request().url()).pathname + if (path.startsWith('/src/api/')) return route.continue() + const ok = data => route.fulfill({ json: { code: 200, data } }) + if (path.endsWith('/getinfo')) return ok({ roles, name: '测试用户', avatar: '', permissions: [] }) + if (path.endsWith('/menurole')) return ok(menu) + if (path.endsWith('/failure-snapshot/download')) { + state.downloads++ + if (state.downloadDelay) await new Promise(resolve => setTimeout(resolve, state.downloadDelay)) + if (state.failDownload) return route.fulfill({ json: { code: 403, msg: 'synthetic-private-error' } }) + return route.fulfill({ contentType: 'application/zip', body: Buffer.from([80, 75, 3, 4, 0, 0]) }) + } + if (path.endsWith('/failure-snapshots')) { + state.reads++ + const delayed = state.delay + if (delayed) await new Promise(resolve => setTimeout(resolve, delayed)) + if (state.failRead) return route.fulfill({ status: 500, json: { code: 500 } }) + if (path.includes('/12/')) return ok({ items: [] }) + return ok({ items: [ + { attemptId: 'a-1', phase: 'purchase', status: 'captured', partial: false, reasons: [], downloadAvailable: true }, + { attemptId: 'a-2', phase: 'purchase', status: 'captured', partial: true, reasons: ['node_limit'], downloadAvailable: true }, + { attemptId: 'a-3', phase: 'purchase', status: 'not_captured', partial: false, reasons: ['synthetic-private-reason'], downloadAvailable: false } + ] }) + } + const tasks = [11, 12].map(id => ({ id, status: 'failed', executionMode: 'live', taskType: 'stock', errorCode: 'TEST_FAILURE' })) + if (/purchase-tasks\/\d+$/.test(path)) return ok({ task: tasks.find(t => path.endsWith('/' + t.id)), attempts: [1, 2, 3, 4].map(n => ({ attemptId: 'a-' + n, attemptNumber: n, phase: 'purchase', status: 'failed' })) }) + if (path.endsWith('/purchase-tasks')) return ok({ items: tasks, total: 2 }) + return ok([]) + }) + await page.goto('/#/purchase-tasks/index') + await page.getByRole('button', { name: '详情', exact: true }).first().click() + return state +} + +test('管理员现场列显示完整、部分、未截取和空状态,并安全下载与重试', async ({ page }) => { + const state = await setup(page) + await expect(page.getByText('现场诊断', { exact: true })).toBeVisible() + const buttons = page.getByRole('button', { name: '下载控件树', exact: true }) + await expect(buttons).toHaveCount(2) + await expect(page.getByText('完整', { exact: true })).toBeVisible() + await expect(page.getByText('部分', { exact: true })).toBeVisible() + await expect(page.getByText('已达到节点上限')).toBeVisible() + await expect(page.getByText('未保存:未能截取现场')).toBeVisible() + await expect(page.getByText('暂无现场数据')).toBeVisible() + state.failDownload = true + const downloads: string[] = [] + page.on('download', d => downloads.push(d.suggestedFilename())) + await buttons.first().click() + await expect(page.getByText('下载失败,请重试')).toBeVisible() + expect(downloads).toEqual([]) + await expect(page.getByText('synthetic-private-error')).toHaveCount(0) + state.failDownload = false + const download = page.waitForEvent('download') + await buttons.first().click() + expect((await download).suggestedFilename()).toBe('purchase-11-attempt-a-1.zip') +}) + +for (const roles of [['purchaser'], ['after_sales'], ['api_key']]) { + test(`非管理员 ${roles[0]} 不显示诊断且不请求专用接口`, async ({ page }) => { + const state = await setup(page, roles) + await expect(page.getByText('执行记录', { exact: true })).toBeVisible() + await expect(page.getByText('现场诊断', { exact: true })).toHaveCount(0) + expect(state.reads).toBe(0) + expect(state.downloads).toBe(0) + }) +} + +test('元信息失败可重试;关闭再切换任务丢弃旧响应', async ({ page }) => { + const state = await setup(page) + await expect(page.getByRole('button', { name: '下载控件树', exact: true })).toHaveCount(2) + await page.locator('.el-drawer__close-btn').click() + state.failRead = true + await page.getByRole('button', { name: '详情', exact: true }).first().click() + await expect(page.getByRole('button', { name: '读取失败,重试' }).first()).toBeVisible() + state.failRead = false + await page.getByRole('button', { name: '读取失败,重试' }).first().click() + await expect(page.getByRole('button', { name: '下载控件树', exact: true })).toHaveCount(2) + await page.locator('.el-drawer__close-btn').click() + state.delay = 1200 + await page.getByRole('button', { name: '详情', exact: true }).first().click() + await expect(page.getByText('加载中…').first()).toBeVisible() + await page.locator('.el-drawer__close-btn').click() + state.delay = 0 + await page.getByRole('button', { name: '详情', exact: true }).nth(1).click() + await expect(page.getByText('暂无现场数据')).toHaveCount(4) + await page.waitForTimeout(1400) + await expect(page.getByRole('button', { name: '下载控件树', exact: true })).toHaveCount(0) +}) + +test('下载期间禁用按钮,离开详情后不触发旧下载', async ({ page }) => { + const state = await setup(page) + state.downloadDelay = 1500 + const downloads: string[] = [] + page.on('download', d => downloads.push(d.suggestedFilename())) + const button = page.getByRole('button', { name: '下载控件树', exact: true }).first() + await button.click() + await expect(button).toBeDisabled() + expect(state.downloads).toBe(1) + await page.locator('.el-drawer__close-btn').click() + await page.waitForTimeout(1800) + expect(downloads).toEqual([]) +})