feat: capture private purchase failure diagnostics end to end (#364)

This commit is contained in:
QiuSW
2026-10-08 15:05:32 +08:00
parent 7622795d97
commit 4581ee5687
35 changed files with 3198 additions and 11 deletions
@@ -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<SnapshotWindow> {
// 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]"
}
}
@@ -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<SnapshotWindow>, 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)
}
}
@@ -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<Pair<Int, SnapshotAttribute>> = 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("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;").replace("\"", "&quot;").replace("\n", "&#10;").replace("\r", "&#13;").replace("\t", "&#9;") + "\""
}
}
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<SnapshotWindow>): FailureSnapshot {
val now = timestamp(epochMillis())
val reasons = linkedSetOf<String>()
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<String, ByteArray>()
data class Cut(val offset: Int, val openNodes: Int)
val fileCuts = mutableMapOf<String, List<Cut>>()
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<String>()
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("<?xml version=\"1.0\" encoding=\"UTF-8\"?><hierarchy>")
var bytes = xml.length
data class Frame(val node: SnapshotNode, val path: String, var next: Int = -1, var children: Int = 0)
val stack = ArrayDeque<Frame>()
stack.push(Frame(root, "0"))
var emitted = 0
val cuts = mutableListOf<Cut>()
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("<node index=\"${frame.path.substringAfterLast('/')}\" path=\"${frame.path}\"")
fun read(name: String): SnapshotAttribute {
if (!timeAvailable()) { wr.add("time_limit"); return SnapshotAttribute(state="read_error") }
val a = try { frame.node.attribute(name) } catch (_: Exception) { SnapshotAttribute(state="read_error") }
if (!timeAvailable()) wr.add("time_limit")
if (a.state == "read_error") wr.add("node_read_error")
return a
}
val password = read("password")
// Fail closed if password status is unavailable: do not retain content-bearing fields.
val omitContent = password.state != "value" || password.value == "true"
var tooLarge = false
for (name in SnapshotXml.attributes) {
if (omitContent && name in SnapshotXml.contentAttributes) continue
val value = if (name == "password") password else read(name)
if ((value.value?.length ?: 0) > 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("<action id=\"$actionId\"")
if (!omitContent) {
if ((label.value?.length ?: 0) > 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("</node>"); 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("</node>")
runCatching { frame.node.close() }
}
}
xml.append("</hierarchy>")
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 + "</node>".repeat(cut.openNodes) + "</hierarchy>").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))
}
@@ -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<cn.ilapage.goauto.agent.automation.BackfillItem>, token: String): List<BackfillResult> =
parseBackfillResults(post("/api/agent/v1/purchase-tasks/order-backfill", backfillPayload(requestId, items), token))
@@ -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<String>): List<String> =
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())
}
@@ -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 " +
@@ -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<Any?> = emptyList())
fun query(sql: String, args: List<Any?> = emptyList()): List<Map<String, Any?>>
fun <T> 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<FailureSnapshot> = 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")
}
}
@@ -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<Any?>) { helper.writableDatabase.execSQL(sql, args.toTypedArray()) }
override fun query(sql: String, args: List<Any?>): List<Map<String, Any?>> =
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 <T> 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<FailureSnapshot> =
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)
}
@@ -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<Long?>(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,
@@ -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<SnapshotWindow>() }
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<FailureSnapshot>()
val capture=FailureSnapshotCapture(hasArchive={false},persist={saved.add(it)})
val source={ fail("must not capture current screen"); emptyList<SnapshotWindow>() }
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()})
}
}
@@ -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<String, String> = emptyMap(), children: List<SnapshotNode> = 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<String, ByteArray> = 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("<action id=\"16\"/>")); 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))
}
}
}
@@ -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))
}
}
@@ -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(offset<length) { val n=input.read(body,offset,length-offset); check(n>0); 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() }
}
}
@@ -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 ->
@@ -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<Any?>): List<Map<String, Any?>> = db.query(sql, args).also { rows ->
rows.forEach { row -> row.values.filterIsInstance<ByteArray>().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<Any?>) {
connection.prepareStatement(sql).use { s ->
args.forEachIndexed { index, value -> s.setObject(index + 1, value) }; s.executeUpdate()
}
}
override fun query(sql: String, args: List<Any?>): List<Map<String, Any?>> = 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 <T> 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)
}
}
}
+2
View File
@@ -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},
+1
View File
@@ -62,6 +62,7 @@ func MigratedModels() []any {
&models.PDDAccount{},
&models.PurchaseTask{},
&models.PurchaseTaskAttempt{},
&models.PurchaseFailureSnapshot{},
&models.PurchaseOrderWriteback{},
&models.PurchaseOrderWritebackLease{},
&models.PurchaseOrderWritebackCommand{},
@@ -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" }
@@ -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()
}
}
}
@@ -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)
}
}
@@ -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
}
@@ -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)
}
@@ -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{
`<hierarchy><node text="secret"/></hierarchy>`,
`<hierarchy><node text-base64="AHMAZQBjAHIAZQB0"/></hierarchy>`,
`<hierarchy><node><action id="1" label="secret"/></node></hierarchy>`,
`<hierarchy><node><action id="1" password="false" label="secret"/></node></hierarchy>`,
} {
if validateFailureXML([]byte(raw)) == nil {
t.Errorf("missing password flag retained content: %s", raw)
}
}
for _, raw := range []string{
`<hierarchy><node password="false" text="public"><action id="1" label="public"/></node></hierarchy>`,
`<hierarchy><node password="false" text-base64="AGE="/></hierarchy>`,
`<hierarchy><node path="0" child-count-state="read_error" actions-state="read_error"><action id="1"/></node></hierarchy>`,
} {
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(`<hierarchy><node password="false" text="` + strings.Repeat("x", padding) + `"/></hierarchy>`)}} {
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())
}
})
}
}
@@ -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, `<hierarchy><node password="false" text="SYNTHETIC_PRIVATE"/></hierarchy>`, 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, `<hierarchy><node password="false" text="OTHER"/></hierarchy>`, 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, `<hierarchy/>`, 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, `<hierarchy><node password="false" text="PRIVATE_FIXTURE"/></hierarchy>`, 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", `<!DOCTYPE hierarchy [<!ENTITY x "private">]><hierarchy/>`, nil},
{"multiple_roots", `<hierarchy/><hierarchy/>`, nil},
{"password_text", `<hierarchy><node password="true" text="secret"/></hierarchy>`, nil},
{"password_description", `<hierarchy><node password="true" content-desc="secret"/></hierarchy>`, nil},
{"password_action", `<hierarchy><node password="true"><action id="1" label="secret"/></node></hierarchy>`, nil},
{"traversal", `<hierarchy/>`, map[string]string{"../outside.xml": "x"}},
{"unknown_file", `<hierarchy/>`, map[string]string{"extra.xml": "x"}},
{"expanded_limit", "<hierarchy>" + strings.Repeat(" ", FailureSnapshotExpandedLimit) + "</hierarchy>", 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, `<hierarchy><node text="a&amp;b😀" password="false"/></hierarchy>`, nil)
if _, err := validateFailureArchive(m, raw, valid); err != nil {
t.Fatal(err)
}
wrong := m
wrong.TaskID++
bad := snapshotZIP(t, wrong, `<hierarchy/>`, 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, `<hierarchy><node password="false" text="PRIVATE_NODE_SENTINEL"/></hierarchy>`, 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("<hierarchy/>"))
}
}
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, `<hierarchy/>`, 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(`<hierarchy><node password="true" ` + name + suffix + `="` + value + `"/></hierarchy>`)
if validateFailureXML(raw) == nil {
t.Fatalf("password %s leaked", name+suffix)
}
}
}
for _, raw := range []string{`<hierarchy><node text-base64="2AA=" password="false" hint-state="unsupported"/></hierarchy>`, `<hierarchy><node password="true" path="0" child-count="0"><action id="1"/></node></hierarchy>`, `<hierarchy><node password="false" text="line&#10;two&#9;&amp;😀" collection-row-count="2" range-current="1.5"/></hierarchy>`} {
if err := validateFailureXML([]byte(raw)); err != nil {
t.Fatalf("valid XML rejected: %v", err)
}
}
for _, raw := range []string{`<hierarchy><node text="x" text-base64="AGE="/></hierarchy>`, `<hierarchy><node hint-state="invalid"/></hierarchy>`, `<hierarchy><node text-base64="YQ=="/></hierarchy>`} {
if validateFailureXML([]byte(raw)) == nil {
t.Fatal("ambiguous/invalid attribute accepted")
}
}
}
func TestFailureSnapshotUnreadablePasswordFlagFailsClosed(t *testing.T) {
for _, raw := range []string{`<hierarchy><node password-state="read_error" text="secret"/></hierarchy>`, `<hierarchy><node password-base64="AHQAcgB1AGU=" text="secret"/></hierarchy>`, `<hierarchy><node password="maybe" text="secret"/></hierarchy>`} {
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)
}
}
}
+3
View File
@@ -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)
+4
View File
@@ -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) },
@@ -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
})
}
@@ -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")
}
}
@@ -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")
}
}
@@ -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")
}
})
}
}
+17 -1
View File
@@ -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) {
@@ -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
}
@@ -0,0 +1,55 @@
<template>
<div class="snapshot-cell">
<span v-if="loading" class="muted">加载中…</span>
<el-button v-else-if="error" type="primary" link @click="$emit('retry')">读取失败,重试</el-button>
<template v-else-if="snapshot?.downloadAvailable">
<div><el-tag size="small" :type="snapshot.partial ? 'warning' : 'success'">{{ snapshot.partial ? '部分' : '完整' }}</el-tag></div>
<div v-if="snapshot.partial" class="muted">{{ reasonText }}</div>
<el-button type="primary" link :loading="downloading" :disabled="downloading" @click="download">下载控件树</el-button>
<div v-if="downloadError" role="alert" class="download-error">下载失败,请重试</div>
</template>
<span v-else-if="snapshot?.status === 'not_captured'" class="muted">未保存:{{ reasonText }}</span>
<span v-else class="muted">暂无现场数据</span>
</div>
</template>
<script>
import { downloadFailureSnapshot } from '@/api/goauto/purchase-failure-snapshots'
const 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: '截取失败'
}
export default {
name: 'FailureSnapshotCell',
props: { taskId: { type: [Number, String], required: true }, attemptId: { type: String, required: true }, snapshot: { type: Object, default: null }, loading: Boolean, error: Boolean },
emits: ['retry'],
data() { return { downloading: false, downloadError: false, active: true } },
computed: { reasonText() { return [...new Set((this.snapshot?.reasons || []).map(reason => Object.hasOwn(reasons, reason) ? reasons[reason] : '未能截取现场'))].join(';') || '未能截取现场' } },
beforeUnmount() { this.active = false },
methods: {
async download() {
if (this.downloading || !this.snapshot?.downloadAvailable) return
this.downloading = true; this.downloadError = false
try {
const blob = await downloadFailureSnapshot(this.taskId, this.attemptId)
if (!this.active) return
const url = URL.createObjectURL(blob)
const anchor = document.createElement('a')
anchor.href = url
const safe = value => String(value).replace(/[^a-zA-Z0-9_-]/g, '_').slice(0, 80)
anchor.download = `purchase-${safe(this.taskId)}-attempt-${safe(this.attemptId)}.zip`
try { document.body.appendChild(anchor); anchor.click() } finally { anchor.remove(); setTimeout(() => URL.revokeObjectURL(url), 0) }
} catch { if (this.active) this.downloadError = true } finally { if (this.active) this.downloading = false }
}
}
}
</script>
<style scoped>
.snapshot-cell{display:flex;flex-direction:column;align-items:flex-start;gap:4px}.muted{color:#909399;font-size:12px;line-height:1.5}.download-error{color:#f56c6c;font-size:12px}
</style>
+26 -2
View File
@@ -73,6 +73,7 @@
<section><h3>执行记录</h3><el-table :data="detail.attempts" border size="small" empty-text="暂无执行记录">
<el-table-column label="次数" width="70"><template #default="{ row }">#{{ row.attemptNumber }}</template></el-table-column><el-table-column label="阶段" prop="phase" min-width="120" /><el-table-column label="状态" prop="status" min-width="110" /><el-table-column label="结果" min-width="150"><template #default="{ row }">{{ row.resultType || row.errorCode || '—' }}</template></el-table-column><el-table-column label="说明" min-width="220"><template #default="{ row }">{{ row.errorMessage || '—' }}</template></el-table-column><el-table-column label="开始时间" width="170"><template #default="{ row }">{{ formatTime(row.startedAt || row.createdAt) }}</template></el-table-column>
<el-table-column v-if="isSnapshotAdmin" label="现场诊断" width="200"><template #default="{ row }"><FailureSnapshotCell v-if="detail.open" :key="`${detailSession}-${row.attemptId}`" :task-id="detail.task.id" :attempt-id="row.attemptId" :snapshot="snapshots.items[row.attemptId]" :loading="snapshots.loading" :error="snapshots.error" @retry="loadSnapshots" /></template></el-table-column>
</el-table></section>
<el-alert v-if="detail.task.errorCode" :title="`${detail.task.errorCode}:${detail.task.errorMessage || '任务执行失败'}`" type="error" :closable="false" show-icon class="notice" />
@@ -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] || '未记录' },
@@ -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([])
})