Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
136765dbb0 | ||
|
|
5430dc1f64 | ||
|
|
b5a781b714 | ||
|
|
87192f75d4 | ||
|
|
e450ac2898 | ||
|
|
0c24c3897a | ||
|
|
8c4940dc43 | ||
|
|
dac66b8384 | ||
|
|
4581ee5687 | ||
|
|
5f459329ed | ||
|
|
2cb8e1ad86 | ||
|
|
d2083e71fb | ||
|
|
5206a6fd7d | ||
|
|
d1996d28ee | ||
|
|
7fdfe04658 | ||
|
|
53143131be | ||
|
|
650f3a6ba1 |
@@ -44,6 +44,7 @@
|
||||
- 保留与当前工单无关的工作区改动,不重置、不覆盖、不顺手修改。
|
||||
- 测试结果必须真实;未执行或无法覆盖的真机、多设备、云环境和高风险行为必须明确记录。
|
||||
- 高风险修改必须停止并等待人工确认:创建订单、权限、安全、并发、数据库迁移、删除数据、发布和其他不可逆操作。
|
||||
- 回答语言:用户可以用中文或英文发言,Agent 一律用中文回答;技术英文词组、代码标识符、命令、文件路径、API 字段和错误码保持原文,不翻译。
|
||||
|
||||
## 2. 哪些改动需要工单
|
||||
|
||||
|
||||
+2
@@ -248,6 +248,8 @@ class GoAutoAccessibilityService : AccessibilityService(), UiDriver, PddCollecto
|
||||
checked = node.isChecked,
|
||||
enabled = node.isEnabled,
|
||||
visible = node.isVisibleToUser,
|
||||
hintText = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) node.hintText?.toString() else null,
|
||||
showingHintText = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) node.isShowingHintText else null,
|
||||
)
|
||||
for (index in 0 until node.childCount) {
|
||||
node.getChild(index)?.let { snapshot(it, "$path/$index", path) }
|
||||
|
||||
+3
@@ -27,6 +27,9 @@ data class SnapshotNode(
|
||||
val checked: Boolean,
|
||||
val enabled: Boolean,
|
||||
val visible: Boolean,
|
||||
// In-memory accessibility metadata; not part of result or diagnostic payloads.
|
||||
val hintText: String? = null,
|
||||
val showingHintText: Boolean? = null,
|
||||
) {
|
||||
val label: String get() = text?.trim().takeUnless { it.isNullOrEmpty() }
|
||||
?: contentDescription?.trim().orEmpty()
|
||||
|
||||
+44
-7
@@ -545,16 +545,12 @@ class PurchaseLiveAutomation(
|
||||
val modify = addressModifyTargets(panel)
|
||||
if (modify.size != 1) fail("PURCHASE_ADDRESS_EDIT_AMBIGUOUS", "没有找到唯一的地址修改按钮,未创建订单")
|
||||
click(modify.single(), "修改地址")
|
||||
val edit = waitFor("PURCHASE_ADDRESS_EDIT_TIMEOUT", "地址编辑页面打开超时,未创建订单") { snapshot ->
|
||||
snapshot.nodes.any { it.visible && it.label.replace(" ", "").contains("详细地址") }
|
||||
}
|
||||
val editors = shippingAddressEditors(edit)
|
||||
if (editors.size != 1) fail("PURCHASE_ADDRESS_UPDATE_FAILED", "没有找到唯一的详细地址输入框,未创建订单")
|
||||
val current = editors.single().label.trim()
|
||||
val editor = waitForInitialAddressEditor()
|
||||
val current = editor.text.orEmpty().trim()
|
||||
val body = current.split(Regex("[-_]"), limit = 2).firstOrNull()?.trim().orEmpty()
|
||||
if (body.isBlank()) fail("PURCHASE_ADDRESS_UPDATE_FAILED", "当前详细地址无效,未创建订单")
|
||||
val expected = body + suffix
|
||||
if (driver.inputFresh(editors.single(), expected) != FreshActionResult.SUCCESS) fail("PURCHASE_ADDRESS_UPDATE_FAILED", "详细地址修改失败,未创建订单")
|
||||
if (driver.inputFresh(editor, expected) != FreshActionResult.SUCCESS) fail("PURCHASE_ADDRESS_UPDATE_FAILED", "详细地址修改失败,未创建订单")
|
||||
val stable = waitFor("PURCHASE_ADDRESS_INPUT_MISMATCH", "详细地址修改后回读不一致,未创建订单") { snapshot ->
|
||||
snapshot.nodes.count { it.visible && it.enabled && it.className?.endsWith("EditText") == true && it.label == expected } == 1
|
||||
}
|
||||
@@ -575,6 +571,47 @@ class PurchaseLiveAutomation(
|
||||
return ShippingAddressProof(expected, suffix)
|
||||
}
|
||||
|
||||
/** Initial readiness only; return the same sampled node for the original value and fresh input anchor. */
|
||||
private fun waitForInitialAddressEditor(): SnapshotNode {
|
||||
fun isHint(editor: SnapshotNode): Boolean {
|
||||
val hint = editor.hintText?.trim().orEmpty()
|
||||
return editor.showingHintText == true || (hint.isNotEmpty() && editor.text?.trim() == hint)
|
||||
}
|
||||
var titleSeen = false
|
||||
var structural = emptyList<SnapshotNode>()
|
||||
var readyCount = 0
|
||||
repeat(50) {
|
||||
val snapshot = driver.capture()
|
||||
pageProblem(snapshot)
|
||||
val labels = snapshot.nodes.filter { it.visible && it.label.replace(" ", "").contains("详细地址") }
|
||||
titleSeen = titleSeen || labels.isNotEmpty()
|
||||
// Keep the existing same-row/rightward region, but count empty and disabled inputs too.
|
||||
// Their presence is structural ambiguity, even when just one input is ready.
|
||||
structural = snapshot.nodes.filter { editor ->
|
||||
editor.visible && editor.className?.endsWith("EditText") == true &&
|
||||
labels.any { label ->
|
||||
editor.bounds.top <= label.bounds.bottom &&
|
||||
editor.bounds.bottom >= label.bounds.top &&
|
||||
editor.bounds.left >= label.bounds.left
|
||||
}
|
||||
}.distinctBy { it.path }
|
||||
readyCount = structural.count { it.enabled && !it.text.isNullOrBlank() && !isHint(it) }
|
||||
if (structural.size == 1 && readyCount == 1) return structural.single()
|
||||
pause(200)
|
||||
}
|
||||
val reason = when {
|
||||
!titleSeen -> "title_missing"
|
||||
structural.isEmpty() -> "editor_missing"
|
||||
structural.size > 1 -> "editor_ambiguous"
|
||||
!structural.single().enabled -> "editor_disabled"
|
||||
structural.single().text.isNullOrBlank() -> "editor_empty"
|
||||
else -> "editor_hint"
|
||||
}
|
||||
val diagnostic = "stage=address_editor_ready;reason=$reason;titleSeen=$titleSeen;structural=${structural.size};ready=$readyCount"
|
||||
if (!titleSeen) fail("PURCHASE_ADDRESS_EDIT_TIMEOUT", "地址编辑页面打开超时,未创建订单 [$diagnostic]")
|
||||
fail("PURCHASE_ADDRESS_UPDATE_FAILED", "详细地址输入框未就绪,未创建订单 [$diagnostic]")
|
||||
}
|
||||
|
||||
private fun isPurchaseConfirmationPanel(snapshot: UiSnapshot): Boolean {
|
||||
if (snapshot.packageName != PDD_PACKAGE || shippingAddressEditors(snapshot).isNotEmpty()) return false
|
||||
val screen = PddScreenParser.parse(snapshot, PurchaseRehearsalExecutor.DEFAULT_COLLECTOR, "", null)
|
||||
|
||||
+102
@@ -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]"
|
||||
}
|
||||
}
|
||||
+20
@@ -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)
|
||||
}
|
||||
}
|
||||
+304
@@ -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("&", "&").replace("<", "<").replace(">", ">").replace("\"", """).replace("\n", " ").replace("\r", " ").replace("\t", "	") + "\""
|
||||
}
|
||||
}
|
||||
|
||||
class FailureSnapshotExporter(
|
||||
private val nodeLimit: Int = 5000,
|
||||
private val timeLimitMillis: Long = 1500,
|
||||
private val expandedLimit: Int = 8 * 1024 * 1024,
|
||||
private val archiveLimit: Int = 2 * 1024 * 1024,
|
||||
private val monotonicMillis: () -> Long = { System.nanoTime() / 1_000_000 },
|
||||
private val epochMillis: () -> Long = System::currentTimeMillis,
|
||||
) {
|
||||
fun notCaptured(context: SnapshotContext, reason: String): FailureSnapshot = capture(context, reason) { emptyList() }
|
||||
|
||||
fun capture(context: SnapshotContext, unavailableReason: String? = null, windows: () -> List<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))
|
||||
}
|
||||
@@ -100,6 +100,7 @@ data class PurchaseAgentTask(
|
||||
val ruleSnapshot: String,
|
||||
val ruleSnapshotHash: String,
|
||||
val leaseVersion: Long,
|
||||
val attemptNumber: Int = 0,
|
||||
)
|
||||
|
||||
data class CollectionHistoryItem(
|
||||
@@ -211,6 +212,47 @@ class AgentApiException(
|
||||
) : Exception(message)
|
||||
|
||||
class AgentApiClient(private val serverUrl: String) {
|
||||
@Volatile var lastResponseServerTimeMillis: Long? = null
|
||||
private set
|
||||
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))
|
||||
|
||||
@@ -627,6 +669,7 @@ class AgentApiClient(private val serverUrl: String) {
|
||||
private fun purchaseTask(data: JSONObject) = PurchaseAgentTask(
|
||||
taskId = data.getLong("taskId"),
|
||||
taskAttemptId = data.optString("taskAttemptId"),
|
||||
attemptNumber = data.optInt("attemptNumber"),
|
||||
phase = data.optString("phase"),
|
||||
executionMode = data.getString("executionMode"),
|
||||
status = data.getString("status"),
|
||||
@@ -670,6 +713,9 @@ class AgentApiClient(private val serverUrl: String) {
|
||||
connection.outputStream.use { it.write(payload.toString().toByteArray(Charsets.UTF_8)) }
|
||||
}
|
||||
val status = connection.responseCode
|
||||
// Metadata only: batch cutoff must use the API clock, never the phone's clock.
|
||||
// A missing Date invalidates the sample instead of retaining an older response.
|
||||
lastResponseServerTimeMillis = connection.getHeaderFieldDate("Date", 0L).takeIf { it > 0L }
|
||||
if (status == HttpURLConnection.HTTP_NO_CONTENT) return null
|
||||
val stream = if (status in 200..299) connection.inputStream else connection.errorStream
|
||||
val body = stream?.bufferedReader(Charsets.UTF_8)?.use { it.readText() }.orEmpty()
|
||||
|
||||
+23
-3
@@ -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())
|
||||
}
|
||||
|
||||
+5
-1
@@ -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 " +
|
||||
|
||||
+182
@@ -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")
|
||||
}
|
||||
}
|
||||
+40
@@ -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)
|
||||
}
|
||||
+265
-5
@@ -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,12 @@ 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 repurchaseExecutor: ScheduledExecutorService = Executors.newSingleThreadScheduledExecutor()
|
||||
private val repurchaseClosed = AtomicBoolean(false)
|
||||
private val repurchase = RepurchaseCoordinator(SystemClock::elapsedRealtime, { UUID.randomUUID().toString() }, ::publishRepurchase)
|
||||
private var repurchaseSession: RepurchaseSession? = null
|
||||
private val repurchaseEvidence = java.util.concurrent.ConcurrentHashMap<Long, java.util.concurrent.ConcurrentHashMap<Long, RepurchaseExecutionEvidence>>()
|
||||
private val taskMutex = TaskExecutionMutex()
|
||||
private val backfillGuard = OrderBackfillGuard(taskMutex)
|
||||
private val runningTaskId = AtomicReference<Long?>(null)
|
||||
@@ -99,6 +110,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() {
|
||||
@@ -112,18 +124,20 @@ class AgentForegroundService : Service() {
|
||||
identityStore = SecureDeviceStore(this)
|
||||
settingsStore = AgentSettingsStore(this)
|
||||
stateStore = AgentStateStore(this)
|
||||
repurchaseState = RepurchaseState()
|
||||
// Wake locks and cooldown tickets are process-local. Never restore a stale UI flag.
|
||||
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,9 +149,31 @@ 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)
|
||||
// Separate, serialized I/O ticks keep history reads and result observation off the
|
||||
// heartbeat scheduler and, especially, off the single purchase execution thread.
|
||||
repurchaseExecutor.scheduleWithFixedDelay(::tickRepurchase, 2, 2, TimeUnit.SECONDS)
|
||||
}
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
when (intent?.action) {
|
||||
ACTION_REPURCHASE_PREPARE -> {
|
||||
repurchaseExecutor.execute(::prepareRepurchase)
|
||||
return START_STICKY
|
||||
}
|
||||
ACTION_REPURCHASE_CONFIRM -> {
|
||||
val roundId = intent.getStringExtra("roundId").orEmpty()
|
||||
repurchaseExecutor.execute { repurchase.confirm(roundId); tickRepurchase() }
|
||||
return START_STICKY
|
||||
}
|
||||
ACTION_REPURCHASE_STOP -> {
|
||||
// Atomic: a network request must not delay the user's stop signal.
|
||||
repurchase.requestStop(intent.getStringExtra("roundId").orEmpty())
|
||||
repurchaseExecutor.execute(::tickRepurchase)
|
||||
return START_STICKY
|
||||
}
|
||||
}
|
||||
if (intent?.action == ACTION_BACKFILL_STOP) {
|
||||
backfillGuard.cancelled.set(true)
|
||||
return START_STICKY
|
||||
@@ -161,6 +197,10 @@ class AgentForegroundService : Service() {
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
repurchaseClosed.set(true)
|
||||
repurchase.requestStop(repurchase.state.roundId)
|
||||
repurchaseExecutor.shutdownNow()
|
||||
repurchaseState = RepurchaseState(message = "服务已停止;不会恢复整轮重购")
|
||||
backfillGuard.cancelled.set(true)
|
||||
runCatching { connectivityManager.unregisterNetworkCallback(networkCallback) }
|
||||
cancelIdleReturn("服务已停止")
|
||||
@@ -172,6 +212,7 @@ class AgentForegroundService : Service() {
|
||||
diagnosticExecutor.shutdown()
|
||||
runCatching { diagnosticExecutor.awaitTermination(1, TimeUnit.SECONDS) }
|
||||
diagnosticExecutor.shutdownNow()
|
||||
snapshotUploadExecutor.shutdownNow()
|
||||
purchaseStore.close()
|
||||
diagnosticStore.close()
|
||||
super.onDestroy()
|
||||
@@ -322,6 +363,110 @@ class AgentForegroundService : Service() {
|
||||
})
|
||||
}
|
||||
|
||||
private data class RepurchaseSession(val origin: String, val deviceId: Long, val token: String)
|
||||
|
||||
private fun publishRepurchase(state: RepurchaseState) {
|
||||
if (repurchaseClosed.get()) return
|
||||
repurchaseState = state
|
||||
sendBroadcast(Intent(ACTION_REPURCHASE_STATE).setPackage(packageName))
|
||||
updateNotification(state.message, force = true)
|
||||
}
|
||||
|
||||
private fun repurchaseLocalIdle(): Boolean = !working.get() && taskMutex.currentTaskId() == null &&
|
||||
purchaseStore.activeTaskId() == null && purchaseStore.pendingOutbox().isEmpty() &&
|
||||
stateStore.read().currentTaskId == null && !backfillState.running
|
||||
|
||||
private fun repurchaseEnvironment(session: RepurchaseSession): String? {
|
||||
if (repurchaseClosed.get()) return "服务已经停止"
|
||||
val credentials = identityStore.credentials()
|
||||
if (credentials?.deviceId != session.deviceId || credentials.token != session.token ||
|
||||
ServerUrlPolicy.normalize(settingsStore.serverUrl()) != session.origin) return "设备身份或服务器配置已变化,请重新连接"
|
||||
if (!registeredThisProcess.get()) return "设备尚未完成连接校验,请先测试连接"
|
||||
if (GoAutoAccessibilityService.instance == null) return "请先开启无障碍服务"
|
||||
val network = connectivityManager.activeNetwork ?: return "网络不可用"
|
||||
if (connectivityManager.getNetworkCapabilities(network)?.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) != true) return "网络不可用"
|
||||
return null
|
||||
}
|
||||
|
||||
private fun prepareRepurchase() {
|
||||
if (repurchaseClosed.get() || !repurchase.prepare()) return
|
||||
repurchaseEvidence.clear()
|
||||
try {
|
||||
val credentials = identityStore.credentials() ?: error("missing credentials")
|
||||
val session = RepurchaseSession(ServerUrlPolicy.normalize(settingsStore.serverUrl()), credentials.deviceId, credentials.token)
|
||||
repurchaseSession = session
|
||||
repurchaseEnvironment(session)?.let { repurchase.fail(it); return }
|
||||
val port = repurchasePort(session)
|
||||
if (!repurchaseLocalIdle() || port.hasOtherWork(null)) {
|
||||
repurchase.fail("设备已有未结束工作或结果上传尚未收尾,请完成后再重购")
|
||||
return
|
||||
}
|
||||
// Discovery owns its client. No other request can overwrite its page's Date sample.
|
||||
val api = AgentApiClient(session.origin)
|
||||
val batch = RepurchaseBatchDiscovery({ query ->
|
||||
check(!repurchase.isStopRequested() && !repurchaseClosed.get())
|
||||
check(repurchaseEnvironment(session) == null)
|
||||
api.purchaseHistory(session.token, query.page, query.status, query.taskNo, query.days, query.pageSize)
|
||||
}, { requireNotNull(api.lastResponseServerTimeMillis) }).discover()
|
||||
repurchaseEnvironment(session)?.let { repurchase.fail(it); return }
|
||||
if (!repurchaseLocalIdle() || port.hasOtherWork(null)) {
|
||||
repurchase.fail("读取期间出现新的采集或采购工作,请完成后重新读取")
|
||||
return
|
||||
}
|
||||
repurchase.prepared(batch)
|
||||
} catch (error: RepurchaseBatchException) {
|
||||
repurchase.fail(if (repurchase.isStopRequested()) "已停止读取,没有发起重购" else when (error.reason) {
|
||||
RepurchaseBatchFailure.LIST_CHANGED -> "采购列表已变化,请重新读取"
|
||||
RepurchaseBatchFailure.PAGE_FAILED -> "读取采购记录失败,请检查连接后重试"
|
||||
else -> "无法完整确定最后一批采购记录,请检查时间或记录后重试(${error.reason})"
|
||||
})
|
||||
} catch (_: Exception) {
|
||||
repurchase.fail("设备连接或记录读取失败,请恢复后重试")
|
||||
}
|
||||
}
|
||||
|
||||
private fun tickRepurchase() {
|
||||
if (repurchaseClosed.get() || !repurchase.state.busy) return
|
||||
val session = repurchaseSession ?: return
|
||||
val previousTaskId = repurchase.state.currentTaskId
|
||||
repurchase.tick(repurchasePort(session))
|
||||
// Reset only makes the original task pending. The original dispatcher owns all
|
||||
// claim/start/automation/upload steps; the round never executes a PDD action.
|
||||
if (repurchase.state.currentTaskId != null && repurchase.state.currentTaskId != previousTaskId) triggerSync()
|
||||
}
|
||||
|
||||
private fun repurchasePort(session: RepurchaseSession): RepurchasePort = object : RepurchasePort {
|
||||
private val api = AgentApiClient(session.origin)
|
||||
override fun environmentProblem() = repurchaseEnvironment(session)
|
||||
override fun localIdle() = repurchaseLocalIdle()
|
||||
override fun latestTaskId() = api.purchaseHistory(session.token, 1, null, null, 30, 1).items.firstOrNull()?.taskId
|
||||
override fun detail(taskId: Long) = api.purchaseHistoryDetail(taskId, session.token)
|
||||
override fun executionEvidence(taskId: Long) = repurchaseEvidence[taskId]?.values?.toList().orEmpty()
|
||||
override fun hasOtherWork(currentTaskId: Long?): Boolean {
|
||||
if (api.nextTask(session.token) != null) return true // collection IDs are a separate namespace
|
||||
val next = api.nextPurchaseTask(session.token)
|
||||
if (next != null && next.taskId != currentTaskId) return true
|
||||
// /next returns the current running/probing purchase first and can conceal new pending work.
|
||||
return listOf("pending", "running", "spec_probe_pending", "order_submit_started").any { status ->
|
||||
val page = api.purchaseHistory(session.token, 1, status, null, 30, 50)
|
||||
page.items.any { it.taskId != currentTaskId } || page.total > page.items.size
|
||||
}
|
||||
}
|
||||
override fun reset(taskId: Long, requestId: String): cn.ilapage.goauto.agent.network.PurchaseResetResult {
|
||||
val acquired = synchronized(taskMutex) {
|
||||
!working.get() && taskMutex.tryAcquire(REPURCHASE_RESERVATION_ID)
|
||||
}
|
||||
if (!acquired) throw RepurchaseLocalBusy()
|
||||
try {
|
||||
if (environmentProblem() != null || repurchase.isStopRequested() || repurchaseClosed.get() ||
|
||||
purchaseStore.activeTaskId() != null || purchaseStore.pendingOutbox().isNotEmpty()) {
|
||||
throw AgentApiException(409, "DEVICE_BUSY", "本地工作边界变化", false)
|
||||
}
|
||||
return api.resetPurchaseTask(taskId, requestId, session.token)
|
||||
} finally { taskMutex.release(REPURCHASE_RESERVATION_ID) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun publishBackfill(state: OrderBackfillState) {
|
||||
backfillState = state
|
||||
sendBroadcast(Intent(ACTION_BACKFILL_STATE).setPackage(packageName))
|
||||
@@ -577,7 +722,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 +741,21 @@ class AgentForegroundService : Service() {
|
||||
api.startPurchaseTask(claimed.taskId, UUID.randomUUID().toString(), token)
|
||||
} else claimed
|
||||
check(task.status == "running" && task.taskAttemptId.isNotBlank()) { "采购任务没有有效 attempt" }
|
||||
recordRepurchaseExecution(task, null)
|
||||
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 +789,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 +832,10 @@ 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)
|
||||
recordRepurchaseExecution(task, outcome.resultType)
|
||||
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,15 +854,24 @@ 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()
|
||||
}
|
||||
}
|
||||
|
||||
private fun recordRepurchaseExecution(task: PurchaseAgentTask, resultType: String?) {
|
||||
if (!repurchaseState.busy || repurchaseState.currentTaskId != task.taskId || task.attemptNumber <= 0) return
|
||||
val taskEvidence = repurchaseEvidence.getOrPut(task.taskId) { java.util.concurrent.ConcurrentHashMap() }
|
||||
taskEvidence[task.attemptNumber.toLong()] = RepurchaseExecutionEvidence(task.attemptNumber.toLong(), task.taskAttemptId, task.phase, resultType)
|
||||
}
|
||||
|
||||
private fun collectPurchaseProbe(
|
||||
accessibility: GoAutoAccessibilityService, task: PurchaseAgentTask, purchaseRule: PurchaseRule,
|
||||
diagnostic: (AgentDiagnosticEvent) -> Unit,
|
||||
@@ -740,7 +918,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 +934,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 +953,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 +973,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,
|
||||
@@ -1139,6 +1381,7 @@ class AgentForegroundService : Service() {
|
||||
}
|
||||
|
||||
private fun evaluateIdleReturn() {
|
||||
if (repurchaseState.phase == RepurchasePhase.RUNNING) return
|
||||
if (!idleReturn.isArmed()) return
|
||||
val accessibility = GoAutoAccessibilityService.instance
|
||||
if (accessibility == null) {
|
||||
@@ -1283,10 +1526,20 @@ class AgentForegroundService : Service() {
|
||||
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT)
|
||||
builder.addAction(Notification.Action.Builder(null, "停止回填", stop).build())
|
||||
}
|
||||
if (repurchaseState.busy) {
|
||||
val stop = PendingIntent.getService(this, 367,
|
||||
Intent(this, AgentForegroundService::class.java).setAction(ACTION_REPURCHASE_STOP).putExtra("roundId", repurchaseState.roundId),
|
||||
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT)
|
||||
builder.addAction(Notification.Action.Builder(null, "停止重购", stop).build())
|
||||
}
|
||||
return builder
|
||||
.setSmallIcon(android.R.drawable.stat_notify_sync)
|
||||
.setContentTitle(getString(R.string.app_name))
|
||||
.setContentText(if (backfillState.running) "订单回填 · 已检查 ${backfillState.checked}" else content)
|
||||
.setContentText(when {
|
||||
repurchaseState.busy -> "重购 ${repurchaseState.currentTaskId?.let { "CG-$it" }.orEmpty()} · 成功${repurchaseState.successCount} 失败${repurchaseState.failedCount} 跳过${repurchaseState.skippedCount}"
|
||||
backfillState.running -> "订单回填 · 已检查 ${backfillState.checked}"
|
||||
else -> content
|
||||
})
|
||||
.setContentIntent(pendingIntent)
|
||||
.setOngoing(true)
|
||||
.build()
|
||||
@@ -1333,6 +1586,12 @@ class AgentForegroundService : Service() {
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val ACTION_REPURCHASE_PREPARE = "cn.ilapage.goauto.agent.REPURCHASE_PREPARE"
|
||||
const val ACTION_REPURCHASE_CONFIRM = "cn.ilapage.goauto.agent.REPURCHASE_CONFIRM"
|
||||
const val ACTION_REPURCHASE_STOP = "cn.ilapage.goauto.agent.REPURCHASE_STOP"
|
||||
const val ACTION_REPURCHASE_STATE = "cn.ilapage.goauto.agent.REPURCHASE_STATE"
|
||||
@Volatile var repurchaseState = RepurchaseState()
|
||||
private set
|
||||
const val ACTION_BACKFILL_START = "cn.ilapage.goauto.agent.BACKFILL_START"
|
||||
const val ACTION_BACKFILL_STOP = "cn.ilapage.goauto.agent.BACKFILL_STOP"
|
||||
const val ACTION_BACKFILL_STATE = "cn.ilapage.goauto.agent.BACKFILL_STATE"
|
||||
@@ -1368,6 +1627,7 @@ class AgentForegroundService : Service() {
|
||||
private const val COOLDOWN_WAKE_GRACE_MILLIS = 5_000L
|
||||
private const val RETURN_CONFIRM_DELAY_MILLIS = 750L
|
||||
private const val CURRENT_PAGE_RESERVATION_ID = Long.MAX_VALUE
|
||||
private const val REPURCHASE_RESERVATION_ID = Long.MAX_VALUE - 1
|
||||
private const val PDD_PACKAGE = "com.xunmeng.pinduoduo"
|
||||
private const val CURRENT_PAGE_RESULT_NOTIFICATION_MILLIS = 30_000L
|
||||
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
package cn.ilapage.goauto.agent.service
|
||||
|
||||
import cn.ilapage.goauto.agent.network.HistoryPage
|
||||
import cn.ilapage.goauto.agent.network.PurchaseHistoryItem
|
||||
import java.text.ParsePosition
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Collections
|
||||
import java.util.Locale
|
||||
import java.util.TimeZone
|
||||
|
||||
data class RepurchaseHistoryQuery(val page: Int, val pageSize: Int = 50, val days: Int = 30, val status: String? = null, val taskNo: String? = null)
|
||||
|
||||
enum class RepurchaseBatchFailure { PAGE_FAILED, INVALID_PAGE, INVALID_TIMESTAMP, INVALID_ORDER, INCOMPLETE_BATCH, LIST_CHANGED, PAGE_LIMIT }
|
||||
|
||||
class RepurchaseBatchException(val reason: RepurchaseBatchFailure) : IllegalStateException(reason.name)
|
||||
|
||||
class RepurchaseBatch internal constructor(items: List<PurchaseHistoryItem>) {
|
||||
val items: List<PurchaseHistoryItem> = immutable(items)
|
||||
val candidates: List<PurchaseHistoryItem> = immutable(items.filter { it.status == "failed" && it.retryable })
|
||||
val failures: List<PurchaseHistoryItem> = immutable(items.filter { it.status == "failed" })
|
||||
val skips: List<PurchaseHistoryItem> = immutable(failures.filterNot { it.retryable })
|
||||
val headTaskId = items.firstOrNull()?.taskId
|
||||
val newestCreatedAt = items.firstOrNull()?.createdAt
|
||||
val oldestCreatedAt = items.lastOrNull()?.createdAt
|
||||
|
||||
private fun immutable(items: List<PurchaseHistoryItem>): List<PurchaseHistoryItem> =
|
||||
Collections.unmodifiableList(ArrayList(items))
|
||||
}
|
||||
|
||||
/** Read-only discovery. The callback must use the current device's authenticated history endpoint.
|
||||
*
|
||||
* [nowMillis] MUST return the trusted server Date time from the response to that fetch, and throw
|
||||
* when it is missing or invalid. Never fall back to the phone clock or a previous response's Date:
|
||||
* the server applies the rolling 30-day cutoff, and a slow phone could accept a truncated batch.
|
||||
* Tests may inject a synthetic server clock. It is read only after each successful page response.
|
||||
*
|
||||
* Offset overlap is tolerated; a changed head is rejected. This is not a snapshot guarantee for
|
||||
* arbitrary concurrent deletion, reassignment or timestamp edits on the server.
|
||||
*/
|
||||
class RepurchaseBatchDiscovery(
|
||||
private val fetchPage: (RepurchaseHistoryQuery) -> HistoryPage<PurchaseHistoryItem>,
|
||||
private val nowMillis: () -> Long,
|
||||
private val maxPages: Int = 200,
|
||||
) {
|
||||
init { require(maxPages > 0) }
|
||||
|
||||
fun discover(): RepurchaseBatch {
|
||||
var latestServerTime = 0L
|
||||
fun readWithServerTime(pageNumber: Int): HistoryPage<PurchaseHistoryItem> {
|
||||
val page = readPage(pageNumber)
|
||||
val serverTime = try { nowMillis() } catch (_: Exception) {
|
||||
refuse(RepurchaseBatchFailure.INCOMPLETE_BATCH)
|
||||
}
|
||||
if (serverTime <= 0) refuse(RepurchaseBatchFailure.INCOMPLETE_BATCH)
|
||||
latestServerTime = maxOf(latestServerTime, serverTime)
|
||||
return page
|
||||
}
|
||||
val seen = linkedMapOf<Long, Entry>()
|
||||
val batch = mutableListOf<PurchaseHistoryItem>()
|
||||
var head: Long? = null
|
||||
var previous: Entry? = null
|
||||
var originalTotal: Long? = null
|
||||
var foundBoundary = false
|
||||
var reachedEnd = false
|
||||
for (pageNumber in 1..maxPages) {
|
||||
val page = readWithServerTime(pageNumber)
|
||||
if (originalTotal == null) originalTotal = page.total
|
||||
else if (page.total != originalTotal) refuse(RepurchaseBatchFailure.LIST_CHANGED)
|
||||
val entries = parseOrdered(page.items)
|
||||
if (pageNumber == 1) head = entries.firstOrNull()?.item?.taskId
|
||||
for (entry in entries) {
|
||||
val duplicate = seen[entry.item.taskId]
|
||||
if (duplicate != null) {
|
||||
if (duplicate.time != entry.time) refuse(RepurchaseBatchFailure.INVALID_ORDER)
|
||||
continue
|
||||
}
|
||||
val prior = previous
|
||||
if (prior != null) {
|
||||
if (ENTRY_ORDER.compare(prior, entry) > 0) refuse(RepurchaseBatchFailure.INVALID_ORDER)
|
||||
if (prior.time.moreThanSixtySecondsAfter(entry.time)) {
|
||||
foundBoundary = true
|
||||
break
|
||||
}
|
||||
}
|
||||
seen[entry.item.taskId] = entry
|
||||
batch += entry.item
|
||||
previous = entry
|
||||
}
|
||||
if (foundBoundary) break
|
||||
if (pageNumber.toLong() * PAGE_SIZE >= page.total) {
|
||||
// Offset overlap without enough unique rows cannot establish a complete tail.
|
||||
if (seen.size.toLong() != page.total) refuse(RepurchaseBatchFailure.INCOMPLETE_BATCH)
|
||||
reachedEnd = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if (!foundBoundary && !reachedEnd) refuse(RepurchaseBatchFailure.PAGE_LIMIT)
|
||||
|
||||
val finalPage = readWithServerTime(1)
|
||||
val finalHead = parseOrdered(finalPage.items).firstOrNull()?.item?.taskId
|
||||
if (finalHead != head || finalPage.total != originalTotal) refuse(RepurchaseBatchFailure.LIST_CHANGED)
|
||||
if (reachedEnd && previous != null) {
|
||||
// The API applies a rolling 30-day range, not a midnight-based date filter.
|
||||
// HTTP Date has whole-second precision; include its possible fractional second.
|
||||
// Preserve the latest observed time if a later response's Date moves backwards.
|
||||
val floorMillis = latestServerTime + 1000 - DAYS * 24L * 60 * 60 * 1000
|
||||
val floorSeconds = floorMillis / 1000 - if (floorMillis < 0 && floorMillis % 1000 != 0L) 1 else 0
|
||||
val floor = Timestamp(floorSeconds, ((floorMillis - floorSeconds * 1000) * 1_000_000).toInt())
|
||||
if (!previous.time.moreThanSixtySecondsAfter(floor)) refuse(RepurchaseBatchFailure.INCOMPLETE_BATCH)
|
||||
}
|
||||
return RepurchaseBatch(batch)
|
||||
}
|
||||
|
||||
private fun readPage(pageNumber: Int): HistoryPage<PurchaseHistoryItem> {
|
||||
val page = try {
|
||||
fetchPage(RepurchaseHistoryQuery(page = pageNumber))
|
||||
} catch (_: Exception) {
|
||||
// Do not expose a network exception that might contain credentials or response data.
|
||||
refuse(RepurchaseBatchFailure.PAGE_FAILED)
|
||||
}
|
||||
val offset = (pageNumber - 1L) * PAGE_SIZE
|
||||
if (page.page != pageNumber || page.pageSize != PAGE_SIZE || page.total < 0 ||
|
||||
page.items.size.toLong() != (page.total - offset).coerceIn(0, PAGE_SIZE.toLong()) ||
|
||||
page.items.any { it.taskId <= 0 } || page.items.map { it.taskId }.distinct().size != page.items.size
|
||||
) refuse(RepurchaseBatchFailure.INVALID_PAGE)
|
||||
return page
|
||||
}
|
||||
|
||||
private fun parseOrdered(items: List<PurchaseHistoryItem>): List<Entry> {
|
||||
val entries = items.map { Entry(it, parseTime(it.createdAt)) }
|
||||
val ordered = entries.sortedWith(ENTRY_ORDER)
|
||||
if (entries != ordered) refuse(RepurchaseBatchFailure.INVALID_ORDER)
|
||||
return ordered
|
||||
}
|
||||
|
||||
private fun parseTime(raw: String): Timestamp {
|
||||
val match = TIMESTAMP.matchEntire(raw) ?: refuse(RepurchaseBatchFailure.INVALID_TIMESTAMP)
|
||||
val zone = match.groupValues[3].let { if (it == "Z") "+0000" else it.replace(":", "") }
|
||||
val normalized = match.groupValues[1] + zone
|
||||
val position = ParsePosition(0)
|
||||
val parsed = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssZ", Locale.ROOT).apply {
|
||||
isLenient = false
|
||||
timeZone = TimeZone.getTimeZone("UTC")
|
||||
}.parse(normalized, position)
|
||||
if (parsed == null || position.index != normalized.length) refuse(RepurchaseBatchFailure.INVALID_TIMESTAMP)
|
||||
return Timestamp(parsed.time / 1000, match.groupValues[2].padEnd(9, '0').toInt())
|
||||
}
|
||||
|
||||
private data class Entry(val item: PurchaseHistoryItem, val time: Timestamp)
|
||||
|
||||
private data class Timestamp(val seconds: Long, val nanos: Int) : Comparable<Timestamp> {
|
||||
override fun compareTo(other: Timestamp): Int =
|
||||
seconds.compareTo(other.seconds).takeIf { it != 0 } ?: nanos.compareTo(other.nanos)
|
||||
|
||||
fun moreThanSixtySecondsAfter(older: Timestamp): Boolean {
|
||||
val secondsApart = seconds - older.seconds
|
||||
return secondsApart > 60 || (secondsApart == 60L && nanos > older.nanos)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val PAGE_SIZE = 50
|
||||
private const val DAYS = 30
|
||||
private val TIMESTAMP = Regex("(\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2})(?:\\.(\\d{1,9}))?(Z|[+-]\\d{2}:\\d{2})")
|
||||
private val ENTRY_ORDER = compareByDescending<Entry> { it.time }.thenByDescending { it.item.taskId }
|
||||
private fun refuse(reason: RepurchaseBatchFailure): Nothing = throw RepurchaseBatchException(reason)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
package cn.ilapage.goauto.agent.service
|
||||
|
||||
import cn.ilapage.goauto.agent.network.PurchaseHistoryDetail
|
||||
import cn.ilapage.goauto.agent.network.PurchaseResetResult
|
||||
import cn.ilapage.goauto.agent.network.AgentApiException
|
||||
import java.util.concurrent.atomic.AtomicReference
|
||||
|
||||
interface RepurchasePort {
|
||||
fun environmentProblem(): String?
|
||||
fun localIdle(): Boolean
|
||||
fun hasOtherWork(currentTaskId: Long?): Boolean
|
||||
fun latestTaskId(): Long?
|
||||
fun detail(taskId: Long): PurchaseHistoryDetail
|
||||
fun reset(taskId: Long, requestId: String): PurchaseResetResult
|
||||
fun executionEvidence(taskId: Long): List<RepurchaseExecutionEvidence> = emptyList()
|
||||
}
|
||||
|
||||
/** Only emitted by the original executor after Start and after a known outcome. */
|
||||
data class RepurchaseExecutionEvidence(val attemptNumber: Long, val attemptId: String, val phase: String, val resultType: String?)
|
||||
class RepurchaseLocalBusy : IllegalStateException()
|
||||
|
||||
class RepurchaseCoordinator(
|
||||
private val now: () -> Long,
|
||||
private val requestId: () -> String,
|
||||
private val publish: (RepurchaseState) -> Unit,
|
||||
) {
|
||||
@Volatile var state = RepurchaseState()
|
||||
private set
|
||||
private val stopRequest = AtomicReference<String?>(null)
|
||||
private var position = 0
|
||||
private var expectedAttempt = 0L
|
||||
private var issuedAt = 0L
|
||||
private var busySince: Long? = null
|
||||
private var headChecked = false
|
||||
private val resetRequestIds = mutableMapOf<Long, String>()
|
||||
|
||||
// All mutation except requestStop is confined to the Service's orchestration worker.
|
||||
fun prepare(): Boolean {
|
||||
if (state.busy) return false
|
||||
stopRequest.set(null)
|
||||
position = 0
|
||||
expectedAttempt = 0
|
||||
busySince = null
|
||||
headChecked = false
|
||||
resetRequestIds.clear()
|
||||
emit(RepurchaseState(phase = RepurchasePhase.READING, roundId = requestId(), message = "正在读取本设备最后一批采购记录…"))
|
||||
return true
|
||||
}
|
||||
|
||||
fun prepared(batch: RepurchaseBatch) {
|
||||
if (state.phase != RepurchasePhase.READING) return
|
||||
if (isStopRequested()) { finish("已停止读取,没有发起重购"); return }
|
||||
val skips = batch.skips.map { RepurchaseItemResult(it.taskId, RepurchaseResult.SKIPPED, it.retryDisabledReason ?: "当前不可重试") }
|
||||
val message = when {
|
||||
batch.items.isEmpty() -> "近30天没有采购记录"
|
||||
batch.failures.isEmpty() -> "最后一批没有失败任务,不回退更早批次"
|
||||
batch.candidates.isEmpty() -> "最后一批失败任务均不可重试,请查看逐条结果"
|
||||
else -> "请确认本轮重购范围"
|
||||
}
|
||||
emit(state.copy(phase = if (batch.candidates.isEmpty()) RepurchasePhase.FINISHED else RepurchasePhase.READY,
|
||||
batch = batch, results = skips, message = message))
|
||||
}
|
||||
|
||||
fun confirm(roundId: String) {
|
||||
if (state.phase != RepurchasePhase.READY || roundId != state.roundId) return
|
||||
if (isStopRequested()) { finish("已取消重购"); return }
|
||||
emit(state.copy(phase = RepurchasePhase.RUNNING, message = "正在复核本轮任务"))
|
||||
}
|
||||
|
||||
fun requestStop(roundId: String) {
|
||||
if (roundId.isNotBlank() && roundId == state.roundId) stopRequest.set(roundId)
|
||||
}
|
||||
fun isStopRequested(): Boolean = stopRequest.get() == state.roundId && state.roundId.isNotBlank()
|
||||
|
||||
fun fail(message: String) {
|
||||
if (!state.busy) return
|
||||
if (state.currentTaskId == null) finish(message)
|
||||
else if (state.phase != RepurchasePhase.STOPPING) emit(state.copy(phase = RepurchasePhase.STOPPING, message = "$message;停止后续,当前任务沿用原流程收尾"))
|
||||
}
|
||||
|
||||
fun tick(port: RepurchasePort) {
|
||||
if (!state.busy) return
|
||||
if (isStopRequested()) fail("已请求停止重购")
|
||||
if (state.phase !in setOf(RepurchasePhase.RUNNING, RepurchasePhase.STOPPING)) return
|
||||
if (state.currentTaskId != null && now() - issuedAt >= TASK_TIMEOUT_MILLIS) {
|
||||
finish("单条等待已达15分钟,已停止编排;当前任务未被取消,请人工检查")
|
||||
return
|
||||
}
|
||||
try {
|
||||
port.environmentProblem()?.let { fail(it); return }
|
||||
val current = state.currentTaskId
|
||||
if (port.hasOtherWork(current)) fail("检测到新的采集或采购工作")
|
||||
if (!state.busy) return
|
||||
if (current != null) {
|
||||
observe(port, current)
|
||||
return
|
||||
}
|
||||
if (state.phase != RepurchasePhase.RUNNING) return
|
||||
if (busySince != null && now() - requireNotNull(busySince) >= DRAIN_TIMEOUT_MILLIS) {
|
||||
fail("设备忙碌或结果上传尚未收尾"); return
|
||||
}
|
||||
if (!port.localIdle()) {
|
||||
val since = busySince ?: now().also { busySince = it }
|
||||
if (now() - since >= DRAIN_TIMEOUT_MILLIS) fail("设备忙碌或结果上传尚未收尾")
|
||||
return
|
||||
}
|
||||
val batch = requireNotNull(state.batch)
|
||||
if (!headChecked) {
|
||||
if (port.latestTaskId() != batch.headTaskId) { fail("采购列表已变化,请重新读取后确认"); return }
|
||||
headChecked = true
|
||||
}
|
||||
if (position >= batch.candidates.size) { finish("本轮重购已结束"); return }
|
||||
val taskId = batch.candidates[position].taskId
|
||||
val detail = try { port.detail(taskId) } catch (error: AgentApiException) {
|
||||
if (error.code == "PURCHASE_TASK_NOT_FOUND") { skip(taskId, "任务已过期或不再属于本设备"); return }
|
||||
throw error
|
||||
}
|
||||
check(detail.task.taskId == taskId)
|
||||
if (detail.task.status != "failed" || !detail.task.retryable) {
|
||||
skip(taskId, detail.task.retryDisabledReason ?: "当前状态不可重试")
|
||||
return
|
||||
}
|
||||
// Recheck after network reads. The reset adapter also holds a short local reservation.
|
||||
if (isStopRequested()) { fail("已请求停止重购"); return }
|
||||
port.environmentProblem()?.let { fail(it); return }
|
||||
if (!port.localIdle()) return
|
||||
if (port.hasOtherWork(null)) { fail("检测到新的采集或采购工作"); return }
|
||||
if (isStopRequested()) { fail("已请求停止重购"); return }
|
||||
issueReset(port, taskId, detail.attemptCount)
|
||||
} catch (_: Exception) {
|
||||
fail("连接、身份或服务端校验失败,请恢复后再操作")
|
||||
}
|
||||
}
|
||||
|
||||
private fun issueReset(port: RepurchasePort, taskId: Long, priorAttempt: Long) {
|
||||
val fixedRequestId = resetRequestIds.getOrPut(taskId, requestId)
|
||||
issuedAt = now()
|
||||
expectedAttempt = 0
|
||||
emit(state.copy(currentTaskId = taskId, message = "正在重置 CG-$taskId(原任务号)"))
|
||||
var ambiguous = false
|
||||
repeat(2) { attempt ->
|
||||
// A stop while an ambiguous request was in flight must not start a fresh write.
|
||||
if (attempt > 0 && isStopRequested()) { fail("重置响应不明确,已停止,请人工核对当前任务"); return }
|
||||
try {
|
||||
val result = port.reset(taskId, fixedRequestId)
|
||||
check(result.taskId == taskId && result.attemptNumber > priorAttempt)
|
||||
expectedAttempt = result.attemptNumber.toLong()
|
||||
busySince = null
|
||||
emit(state.copy(message = "等待 CG-$taskId 本次执行与结果上传"))
|
||||
if (isStopRequested()) fail("已请求停止重购")
|
||||
return
|
||||
} catch (error: Exception) {
|
||||
if (!ambiguous && error is RepurchaseLocalBusy) {
|
||||
emit(state.copy(currentTaskId = null, message = "等待本地忙碌操作收尾"))
|
||||
if (busySince == null) busySince = now()
|
||||
return
|
||||
}
|
||||
if (!ambiguous && error is AgentApiException && error.status in 400..499 && error.code in BUSINESS_REFUSALS) {
|
||||
emit(state.copy(currentTaskId = null))
|
||||
skip(taskId, "当前业务资格拒绝:${error.code}")
|
||||
return
|
||||
}
|
||||
if (error is AgentApiException && error.status in 400..499) {
|
||||
if (!ambiguous) emit(state.copy(currentTaskId = null))
|
||||
fail(if (ambiguous) "重置响应不明确,请人工核对" else "设备、规则或身份校验失败")
|
||||
return
|
||||
}
|
||||
ambiguous = true
|
||||
}
|
||||
}
|
||||
fail("重置响应仍不明确,已停止,请人工核对当前任务")
|
||||
}
|
||||
|
||||
private fun observe(port: RepurchasePort, taskId: Long) {
|
||||
if (expectedAttempt == 0L) return // Never treat an old failed result as this reset's outcome.
|
||||
val detail = port.detail(taskId)
|
||||
check(detail.task.taskId == taskId)
|
||||
if (detail.attemptCount < expectedAttempt) return
|
||||
if (detail.attemptCount > expectedAttempt) {
|
||||
val evidence = port.executionEvidence(taskId)
|
||||
var linkedAttempt = expectedAttempt
|
||||
// Each extra attempt needs a completed probe/rematch and the original executor's
|
||||
// Start evidence for the immediately following purchase attempt. A separate reset
|
||||
// after failed/cancelled/success cannot satisfy this chain.
|
||||
while (linkedAttempt < detail.attemptCount) {
|
||||
val from = evidence.singleOrNull { it.attemptNumber == linkedAttempt }
|
||||
val to = evidence.singleOrNull { it.attemptNumber == linkedAttempt + 1 }
|
||||
if (from?.resultType !in setOf("spec_probe_completed", "spec_rematch_completed") ||
|
||||
to?.phase != "purchase" || from?.attemptId.isNullOrBlank() || to.attemptId.isBlank() || from?.attemptId == to.attemptId) {
|
||||
fail("同任务出现无法关联本轮的后续尝试,已停止,请人工核对")
|
||||
if (port.localIdle()) finish(state.message)
|
||||
return
|
||||
}
|
||||
linkedAttempt++
|
||||
}
|
||||
expectedAttempt = linkedAttempt
|
||||
}
|
||||
val status = detail.task.status
|
||||
if (status == "order_result_unknown") {
|
||||
fail("订单结果待核对,请人工检查,不能继续重购")
|
||||
if (port.localIdle()) finish(state.message)
|
||||
return
|
||||
}
|
||||
if (status !in TERMINAL) return
|
||||
if (!port.localIdle()) return
|
||||
val failed = status == "failed"
|
||||
val result = when (status) {
|
||||
"order_created" -> RepurchaseItemResult(taskId, RepurchaseResult.SUCCESS, "订单已创建")
|
||||
"cancelled" -> RepurchaseItemResult(taskId, RepurchaseResult.SKIPPED, "本次任务已取消")
|
||||
else -> RepurchaseItemResult(taskId, RepurchaseResult.FAILED, "再次失败:${detail.task.errorCode ?: "PURCHASE_FAILED"}")
|
||||
}
|
||||
position++
|
||||
val stopping = state.phase == RepurchasePhase.STOPPING
|
||||
emit(state.copy(currentTaskId = null, results = state.results + result))
|
||||
if (failed && detail.task.errorCode in ENVIRONMENT_FAILURES) finish("登录、验证码、风控或设备环境异常,已停止后续重购")
|
||||
else if (stopping || isStopRequested()) finish(state.message)
|
||||
else if (position >= requireNotNull(state.batch).candidates.size) finish("本轮重购已结束;再次失败的任务不会在本轮重复执行")
|
||||
}
|
||||
|
||||
private fun skip(taskId: Long, reason: String) {
|
||||
position++
|
||||
emit(state.copy(results = state.results + RepurchaseItemResult(taskId, RepurchaseResult.SKIPPED, reason)))
|
||||
if (position >= requireNotNull(state.batch).candidates.size) finish("本轮重购已结束")
|
||||
}
|
||||
private fun finish(message: String) = emit(state.copy(phase = RepurchasePhase.FINISHED, message = message))
|
||||
private fun emit(value: RepurchaseState) { state = value; publish(value) }
|
||||
|
||||
companion object {
|
||||
const val TASK_TIMEOUT_MILLIS = 15 * 60 * 1000L
|
||||
private const val DRAIN_TIMEOUT_MILLIS = 10_000L
|
||||
private val TERMINAL = setOf("order_created", "failed", "cancelled")
|
||||
private val BUSINESS_REFUSALS = setOf("PURCHASE_TASK_NOT_FOUND", "PURCHASE_RETRY_NOT_ALLOWED", "PURCHASE_RETRY_UNSAFE", "PURCHASE_RETRY_STALE", "PURCHASE_SPEC_MAPPING_REQUIRED")
|
||||
private val ENVIRONMENT_FAILURES = setOf("PDD_LOGIN_REQUIRED", "PDD_CAPTCHA_REQUIRED", "PDD_RISK_CONTROL", "ACCESSIBILITY_NOT_READY", "DEVICE_OFFLINE", "AGENT_RESTARTED_DURING_EXECUTION")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package cn.ilapage.goauto.agent.service
|
||||
|
||||
enum class RepurchasePhase { IDLE, READING, READY, RUNNING, STOPPING, FINISHED }
|
||||
enum class RepurchaseResult { SUCCESS, FAILED, SKIPPED }
|
||||
data class RepurchaseItemResult(val taskId: Long, val result: RepurchaseResult, val message: String)
|
||||
|
||||
/** Process-local presentation only; never persists or resumes a round after process death. */
|
||||
data class RepurchaseState(
|
||||
val phase: RepurchasePhase = RepurchasePhase.IDLE,
|
||||
val roundId: String = "",
|
||||
val batch: RepurchaseBatch? = null,
|
||||
val currentTaskId: Long? = null,
|
||||
val results: List<RepurchaseItemResult> = emptyList(),
|
||||
val message: String = "",
|
||||
) {
|
||||
val busy get() = phase in setOf(RepurchasePhase.READING, RepurchasePhase.READY, RepurchasePhase.RUNNING, RepurchasePhase.STOPPING)
|
||||
val successCount get() = results.count { it.result == RepurchaseResult.SUCCESS }
|
||||
val failedCount get() = results.count { it.result == RepurchaseResult.FAILED }
|
||||
val skippedCount get() = results.count { it.result == RepurchaseResult.SKIPPED }
|
||||
fun confirmationText(): String = buildString {
|
||||
append("本设备最后一批(近30天全部状态)\n")
|
||||
append("${batch?.oldestCreatedAt.orEmpty()} ~ ${batch?.newestCreatedAt.orEmpty()}\n")
|
||||
append("共 ${batch?.items?.size ?: 0} 条 · 失败 ${batch?.failures?.size ?: 0} 条\n")
|
||||
append("可重购 ${batch?.candidates?.size ?: 0} 条 · 跳过 ${batch?.skips?.size ?: 0} 条\n\n")
|
||||
append("相邻创建时间不超过60秒推算为同批,不受当前搜索和筛选影响。\n")
|
||||
append("逐条复用原任务号重试,本轮再次失败不重复执行。单条等待上限15分钟,超时只停止后续编排。\n\n")
|
||||
append("可能真实创建拼多多待付款订单,系统不会付款。停止重购不取消已经提交或正在执行的任务。")
|
||||
}
|
||||
|
||||
fun summaryText(): String = buildString {
|
||||
if (message.isNotBlank()) append(message).append('\n')
|
||||
currentTaskId?.let { append("当前 CG-$it\n") }
|
||||
append("成功 $successCount · 再次失败 $failedCount · 跳过 $skippedCount")
|
||||
batch?.let {
|
||||
val candidateIds = it.candidates.map { task -> task.taskId }.toSet()
|
||||
val handled = results.count { result -> result.taskId in candidateIds }
|
||||
append("\n本轮已结束 $handled / ${it.candidates.size} · 未结束 ${(it.candidates.size - handled).coerceAtLeast(0)}")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -142,8 +142,19 @@ class TaskHistoryFragment : Fragment() {
|
||||
private val imageRequests = mutableListOf<HistoryImageRequest>()
|
||||
private var currentPageReceiverRegistered = false
|
||||
private var backfillPanel: LinearLayout? = null
|
||||
private var repurchasePanel: LinearLayout? = null
|
||||
private var repurchaseButton: MaterialButton? = null
|
||||
private var repurchaseDialog: androidx.appcompat.app.AlertDialog? = null
|
||||
private var repurchaseDialogRound: String? = null
|
||||
private val currentPageReceiver = object : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context?, intent: Intent?) {
|
||||
if (intent?.action == AgentForegroundService.ACTION_REPURCHASE_STATE) {
|
||||
renderRepurchase()
|
||||
if (!collection && isResumed && AgentForegroundService.repurchaseState.phase == cn.ilapage.goauto.agent.service.RepurchasePhase.FINISHED) {
|
||||
detailState.taskId?.let(::loadPurchaseDetail) ?: load()
|
||||
}
|
||||
return
|
||||
}
|
||||
if (intent?.action == AgentForegroundService.ACTION_BACKFILL_STATE) {
|
||||
renderBackfill()
|
||||
if (!collection && isResumed && !AgentForegroundService.backfillState.running) {
|
||||
@@ -191,6 +202,9 @@ class TaskHistoryFragment : Fragment() {
|
||||
pageColumn.addView(context.screenTitle(if (collection) "采集记录" else "采购记录"))
|
||||
pageColumn.addView(buildSearch())
|
||||
if (!collection) {
|
||||
repurchasePanel = context.column(0)
|
||||
pageColumn.addView(repurchasePanel)
|
||||
renderRepurchase()
|
||||
backfillPanel = context.column(0)
|
||||
pageColumn.addView(backfillPanel)
|
||||
renderBackfill()
|
||||
@@ -214,12 +228,19 @@ class TaskHistoryFragment : Fragment() {
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
renderRepurchase()
|
||||
detailState.taskId?.let { taskId ->
|
||||
if (collection) loadCollectionDetail(taskId) else loadPurchaseDetail(taskId)
|
||||
} ?: load()
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
repurchaseDialog?.setOnDismissListener(null)
|
||||
repurchaseDialog?.dismiss()
|
||||
repurchaseDialog = null
|
||||
repurchaseDialogRound = null
|
||||
repurchasePanel = null
|
||||
repurchaseButton = null
|
||||
backfillPanel = null
|
||||
requestGeneration++
|
||||
cancelImageRequests()
|
||||
@@ -289,11 +310,16 @@ class TaskHistoryFragment : Fragment() {
|
||||
}, LinearLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, context.dp(48)).apply { marginStart = context.dp(8) })
|
||||
} else {
|
||||
row.addView(MaterialButton(context).apply {
|
||||
text = "回填"
|
||||
repurchaseButton = this
|
||||
text = "重购"
|
||||
textSize = 14f
|
||||
minimumHeight = context.dp(48)
|
||||
contentDescription = "回填拼多多订单号和下单时间"
|
||||
setOnClickListener { showBackfillInput() }
|
||||
contentDescription = "重试本设备最后一批失败采购"
|
||||
isEnabled = !AgentForegroundService.repurchaseState.busy
|
||||
setOnClickListener {
|
||||
isEnabled = false
|
||||
sendRepurchaseAction(AgentForegroundService.ACTION_REPURCHASE_PREPARE)
|
||||
}
|
||||
}, LinearLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, context.dp(48)).apply { marginStart = context.dp(8) })
|
||||
}
|
||||
addView(row, row.fullWidth())
|
||||
@@ -302,6 +328,61 @@ class TaskHistoryFragment : Fragment() {
|
||||
}
|
||||
}
|
||||
|
||||
private fun sendRepurchaseAction(action: String, roundId: String = AgentForegroundService.repurchaseState.roundId) {
|
||||
val context = context ?: return
|
||||
val intent = Intent(context, AgentForegroundService::class.java).setAction(action).putExtra("roundId", roundId)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) context.startForegroundService(intent) else context.startService(intent)
|
||||
}
|
||||
|
||||
private fun renderRepurchase() {
|
||||
val panel = repurchasePanel ?: return
|
||||
val context = context ?: return
|
||||
val state = AgentForegroundService.repurchaseState
|
||||
val phase = state.phase
|
||||
repurchaseButton?.isEnabled = !state.busy
|
||||
panel.removeAllViews()
|
||||
if (phase != cn.ilapage.goauto.agent.service.RepurchasePhase.READY) {
|
||||
repurchaseDialog?.setOnDismissListener(null)
|
||||
repurchaseDialog?.dismiss()
|
||||
repurchaseDialog = null
|
||||
repurchaseDialogRound = null
|
||||
}
|
||||
if (phase == cn.ilapage.goauto.agent.service.RepurchasePhase.IDLE) return
|
||||
panel.addView(context.card(context.cardColumn().apply {
|
||||
addView(context.label(if (state.busy) "本轮重购" else "重购结果", 16f))
|
||||
addView(context.label(state.summaryText(), 14f))
|
||||
if (state.busy) addView(MaterialButton(context).apply {
|
||||
text = if (phase == cn.ilapage.goauto.agent.service.RepurchasePhase.STOPPING) "正在收尾…" else "停止重购"
|
||||
minimumHeight = context.dp(48)
|
||||
isEnabled = phase != cn.ilapage.goauto.agent.service.RepurchasePhase.STOPPING
|
||||
setOnClickListener {
|
||||
isEnabled = false
|
||||
sendRepurchaseAction(AgentForegroundService.ACTION_REPURCHASE_STOP, state.roundId)
|
||||
}
|
||||
})
|
||||
if (state.results.isNotEmpty()) addView(MaterialButton(context).apply {
|
||||
text = "查看逐条结果"
|
||||
minimumHeight = context.dp(48)
|
||||
setOnClickListener {
|
||||
MaterialAlertDialogBuilder(context).setTitle("本轮重购明细")
|
||||
.setMessage(state.results.joinToString("\n") { "CG-${it.taskId}:${it.message}" })
|
||||
.setPositiveButton("关闭", null).show()
|
||||
}
|
||||
})
|
||||
}))
|
||||
if (phase == cn.ilapage.goauto.agent.service.RepurchasePhase.READY && isResumed && repurchaseDialogRound != state.roundId) {
|
||||
repurchaseDialogRound = state.roundId
|
||||
repurchaseDialog = MaterialAlertDialogBuilder(context).setTitle("重购最后一批")
|
||||
.setMessage(state.confirmationText())
|
||||
.setNegativeButton("取消") { _, _ -> sendRepurchaseAction(AgentForegroundService.ACTION_REPURCHASE_STOP, state.roundId) }
|
||||
.setPositiveButton("确认重购") { _, _ -> sendRepurchaseAction(AgentForegroundService.ACTION_REPURCHASE_CONFIRM, state.roundId) }
|
||||
.create().also { dialog ->
|
||||
dialog.setOnCancelListener { sendRepurchaseAction(AgentForegroundService.ACTION_REPURCHASE_STOP, state.roundId) }
|
||||
dialog.show()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun showBackfillInput() {
|
||||
if (AgentForegroundService.backfillState.running) {
|
||||
toast("设备忙碌,请稍后操作")
|
||||
@@ -729,6 +810,7 @@ class TaskHistoryFragment : Fragment() {
|
||||
if (currentPageReceiverRegistered) return
|
||||
val filter = IntentFilter(AgentForegroundService.ACTION_CURRENT_PAGE_RESULT)
|
||||
filter.addAction(AgentForegroundService.ACTION_BACKFILL_STATE)
|
||||
filter.addAction(AgentForegroundService.ACTION_REPURCHASE_STATE)
|
||||
if (Build.VERSION.SDK_INT >= 33) {
|
||||
requireContext().registerReceiver(currentPageReceiver, filter, Context.RECEIVER_NOT_EXPORTED)
|
||||
} else {
|
||||
|
||||
@@ -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() }
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,77 @@ import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class PurchaseLiveAutomationTest {
|
||||
@Test
|
||||
fun `hint delayed address update preserves fresh payment back budget for late order evidence`() {
|
||||
val observationDriver = OrderObservationDriver { sample ->
|
||||
observationPage(when {
|
||||
sample == 1 -> "payment"
|
||||
sample < 140 -> "loading"
|
||||
else -> "complete"
|
||||
})
|
||||
}.apply { backDuration = 4_000 }
|
||||
lateinit var addressDriver: LiveDriver
|
||||
addressDriver = LiveDriver(initialEditFrame = { snapshot, sample ->
|
||||
assertEquals(0, addressDriver.inputCount)
|
||||
assertFalse(addressDriver.clicked.contains("保存"))
|
||||
if (sample <= 4) observationDriver.elapsed += 10_000
|
||||
snapshot.changeAddressEditor {
|
||||
it.copy(
|
||||
text = if (sample <= 4) "合成提示占位标记" else "合成测试区域示例路段1号-old",
|
||||
hintText = "合成提示占位标记",
|
||||
showingHintText = sample <= 4,
|
||||
)
|
||||
}
|
||||
})
|
||||
var observingOrder = false
|
||||
val driver = object : PurchaseUiDriver by addressDriver {
|
||||
override fun capture(): UiSnapshot =
|
||||
if (observingOrder) observationDriver.capture() else addressDriver.capture()
|
||||
override fun backPurchase(): Boolean =
|
||||
if (observingOrder) observationDriver.backPurchase() else addressDriver.backPurchase()
|
||||
override fun clickFresh(target: SnapshotNode): FreshActionResult =
|
||||
if (observingOrder) observationDriver.clickFresh(target) else addressDriver.clickFresh(target)
|
||||
override fun swipePurchase(direction: SwipeDirection, durationMs: Long): Boolean =
|
||||
if (observingOrder) observationDriver.swipePurchase(direction, durationMs)
|
||||
else addressDriver.swipePurchase(direction, durationMs)
|
||||
override fun bringPddToForeground(): Boolean =
|
||||
if (observingOrder) observationDriver.bringPddToForeground() else addressDriver.bringPddToForeground()
|
||||
}
|
||||
val automation = PurchaseLiveAutomation(
|
||||
driver,
|
||||
pause = { observationDriver.elapsed += it },
|
||||
monotonicClockMs = { observationDriver.elapsed },
|
||||
)
|
||||
|
||||
val address = automation.updateShippingAddress("_cg366")
|
||||
assertEquals(5, addressDriver.initialEditCaptures)
|
||||
assertEquals("合成测试区域示例路段1号-old", addressDriver.lastInputTarget?.text)
|
||||
assertTrue(address.expectedAddress.endsWith("_cg366"))
|
||||
assertFalse(address.expectedAddress.contains("合成提示占位标记"))
|
||||
assertAddressEditorActions(addressDriver, saved = true)
|
||||
assertTrue(observationDriver.elapsed > 40_000)
|
||||
assertEquals("_cg366", automation.finalConfirmation(input().copy(addressSuffix = "_cg366"), address).addressSuffix)
|
||||
automation.submitOrderOnce()
|
||||
val clicksBeforeObservation = addressDriver.clicked.toList()
|
||||
observingOrder = true
|
||||
|
||||
val order = automation.readOrderResult()
|
||||
|
||||
assertEquals("PDD-DEMO-365", order?.orderNo)
|
||||
assertEquals("2026-10-08T03:21:43Z", order?.submittedAt)
|
||||
assertEquals(140, observationDriver.captures)
|
||||
val elapsedAfterBack = observationDriver.elapsed - observationDriver.backCompletedAt!!
|
||||
assertTrue(elapsedAfterBack in 28_000L until 30_000L)
|
||||
assertTrue(elapsedAfterBack + observationDriver.backDuration > 30_000)
|
||||
assertObservationActions(observationDriver, backs = 1)
|
||||
assertEquals(1, addressDriver.inputCount)
|
||||
assertEquals(1, addressDriver.clicked.count { it == "保存" })
|
||||
assertEquals(1, addressDriver.submitClicks)
|
||||
assertEquals(clicksBeforeObservation, addressDriver.clicked)
|
||||
assertEquals(0, addressDriver.genericSwipes + addressDriver.scopedSwipes)
|
||||
assertFalse(addressDriver.clicked.any { it.contains("支付") })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `payment back observes late complete order beyond original sample budget`() {
|
||||
val driver = OrderObservationDriver { sample ->
|
||||
@@ -496,6 +567,338 @@ class PurchaseLiveAutomationTest {
|
||||
assertFalse(driver.clicked.any { it.contains("支付") })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `address editor waits for an input arriving after its title`() {
|
||||
assertDelayedAddressEditor { snapshot, sample ->
|
||||
if (sample <= 4) snapshot.copy(nodes = snapshot.nodes.filterNot { it.path == "editor-address" }) else snapshot
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `address editor waits for a populated value`() {
|
||||
assertDelayedAddressEditor { snapshot, sample ->
|
||||
snapshot.changeAddressEditor { if (sample <= 4) it.copy(text = " ") else it }
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `address editor description cannot substitute for empty text`() {
|
||||
for (text in listOf(null, "", " ")) {
|
||||
assertAddressEditorTimeout("editor_empty", 1, 0) { snapshot, _ ->
|
||||
snapshot.changeAddressEditor { it.copy(text = text, contentDescription = "合成说明占位标记") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `address editor waits until hint is replaced by real text`() {
|
||||
assertDelayedAddressEditor { snapshot, sample ->
|
||||
snapshot.changeAddressEditor {
|
||||
it.copy(
|
||||
text = if (sample <= 4) "合成提示占位标记" else it.text,
|
||||
hintText = "合成提示占位标记",
|
||||
showingHintText = sample <= 4,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `address editor persistent hint text fails after the full budget`() {
|
||||
assertAddressEditorTimeout("editor_hint", 1, 0) { snapshot, _ ->
|
||||
snapshot.changeAddressEditor {
|
||||
it.copy(text = "合成提示占位标记", hintText = "合成提示占位标记", showingHintText = true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `address editor rejects text equal to trimmed hint despite false or absent showing flag`() {
|
||||
for (showing in listOf(false, null)) {
|
||||
assertAddressEditorTimeout("editor_hint", 1, 0) { snapshot, _ ->
|
||||
snapshot.changeAddressEditor {
|
||||
it.copy(text = " 合成提示占位标记 ", hintText = " 合成提示占位标记 ", showingHintText = showing)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `address editor showing hint flag blocks nonmatching or missing hint text`() {
|
||||
for (hint in listOf(null, "", "合成提示占位标记")) {
|
||||
assertAddressEditorTimeout("editor_hint", 1, 0) { snapshot, _ ->
|
||||
snapshot.changeAddressEditor { it.copy(hintText = hint, showingHintText = true) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `address editor empty text remains empty even when hint and description are present`() {
|
||||
assertAddressEditorTimeout("editor_empty", 1, 0) { snapshot, _ ->
|
||||
snapshot.changeAddressEditor {
|
||||
it.copy(text = " ", contentDescription = "合成说明占位标记", hintText = "合成提示占位标记", showingHintText = true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `address editor missing hint metadata remains compatible and uses actual text`() {
|
||||
val driver = LiveDriver(initialEditFrame = { snapshot, _ ->
|
||||
snapshot.changeAddressEditor {
|
||||
assertEquals(null, it.hintText)
|
||||
assertEquals(null, it.showingHintText)
|
||||
it.copy(text = " 合成真实地址-old ", contentDescription = "合成说明占位标记")
|
||||
}
|
||||
})
|
||||
|
||||
val proof = PurchaseLiveAutomation(driver, pause = {}).updateShippingAddress("_cg366")
|
||||
|
||||
assertEquals("合成真实地址_cg366", proof.expectedAddress)
|
||||
assertEquals(1, driver.initialEditCaptures)
|
||||
assertTrue(driver.lastInputTarget === driver.lastInitialEditSnapshot?.nodes?.single { it.path == "editor-address" })
|
||||
assertAddressEditorActions(driver, saved = true)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `address editor accepts actual text with absent false or blank hint metadata`() {
|
||||
for (showing in listOf(null, false)) {
|
||||
for (hint in listOf(null, "", " ", "合成提示占位标记")) {
|
||||
val driver = LiveDriver(initialEditFrame = { snapshot, _ ->
|
||||
snapshot.changeAddressEditor { it.copy(hintText = hint, showingHintText = showing) }
|
||||
})
|
||||
|
||||
val proof = PurchaseLiveAutomation(driver, pause = {}).updateShippingAddress("_cg366")
|
||||
|
||||
assertTrue(proof.expectedAddress.endsWith("_cg366"))
|
||||
assertEquals(1, driver.initialEditCaptures)
|
||||
assertAddressEditorActions(driver, saved = true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `address editor waits for an enabled input`() {
|
||||
assertDelayedAddressEditor { snapshot, sample ->
|
||||
snapshot.changeAddressEditor { if (sample <= 4) it.copy(enabled = false) else it }
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `address editor tolerates more than three ambiguous frames before unique readiness`() {
|
||||
assertDelayedAddressEditor { snapshot, sample ->
|
||||
if (sample <= 4) snapshot.withSecondAddressEditor() else snapshot
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `address editor uses the value and node from the successful readiness frame`() {
|
||||
val driver = LiveDriver(initialEditFrame = { snapshot, sample ->
|
||||
snapshot.changeAddressEditor { it.copy(text = if (sample == 1) "" else "合成地址第${sample}帧-old") }
|
||||
})
|
||||
|
||||
val proof = PurchaseLiveAutomation(driver, pause = {}).updateShippingAddress("_cg366")
|
||||
|
||||
assertEquals("合成地址第2帧_cg366", proof.expectedAddress)
|
||||
assertEquals(2, driver.initialEditCaptures)
|
||||
assertTrue(driver.lastInputTarget === driver.lastInitialEditSnapshot?.nodes?.single { it.path == "editor-address" })
|
||||
assertAddressEditorActions(driver, saved = true)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `address editor structural ambiguity waits full budget even with only one ready input`() {
|
||||
for (second in listOf<(SnapshotNode) -> SnapshotNode>(
|
||||
{ it.copy(text = "") }, { it.copy(enabled = false) },
|
||||
)) {
|
||||
assertAddressEditorTimeout("editor_ambiguous", structural = 2, ready = 1) { snapshot, _ ->
|
||||
snapshot.withSecondAddressEditor(second)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `address editor final zero inputs reports scalar missing reason`() {
|
||||
assertAddressEditorTimeout("editor_missing", 0, 0) { snapshot, _ ->
|
||||
snapshot.copy(nodes = snapshot.nodes.filterNot { it.path == "editor-address" })
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `address editor final empty input reports scalar empty reason`() {
|
||||
assertAddressEditorTimeout("editor_empty", 1, 0) { snapshot, _ ->
|
||||
snapshot.changeAddressEditor { it.copy(text = " ") }
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `address editor final disabled input reports scalar disabled reason`() {
|
||||
assertAddressEditorTimeout("editor_disabled", 1, 0) { snapshot, _ ->
|
||||
snapshot.changeAddressEditor { it.copy(enabled = false) }
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `address editor final multiple inputs reports scalar ambiguity reason`() {
|
||||
assertAddressEditorTimeout("editor_ambiguous", 2, 2) { snapshot, _ -> snapshot.withSecondAddressEditor() }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `address editor never seeing a title keeps edit timeout and one shared budget`() {
|
||||
assertAddressEditorTimeout("title_missing", 0, 0, titleSeen = false) { snapshot, _ ->
|
||||
snapshot.copy(nodes = snapshot.nodes.filterNot { it.path == "detail" })
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `address editor remembers a title even when the final snapshot is empty`() {
|
||||
assertAddressEditorTimeout("editor_missing", 0, 0) { snapshot, sample ->
|
||||
if (sample == 1) snapshot.copy(nodes = snapshot.nodes.filterNot { it.path == "editor-address" })
|
||||
else snapshot.copy(nodes = emptyList())
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `address editor late title does not start a second wait budget`() {
|
||||
assertAddressEditorTimeout("editor_empty", 1, 0) { snapshot, sample ->
|
||||
val emptyEditor = snapshot.changeAddressEditor { it.copy(text = "") }
|
||||
if (sample < 50) emptyEditor.copy(nodes = emptyEditor.nodes.filterNot { it.path == "detail" }) else emptyEditor
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `address editor can become ready on the final sample of its shared budget`() {
|
||||
val driver = LiveDriver(initialEditFrame = { snapshot, sample ->
|
||||
if (sample < 50) snapshot.copy(nodes = emptyList()) else snapshot
|
||||
})
|
||||
val editPauses = mutableListOf<Long>()
|
||||
|
||||
PurchaseLiveAutomation(driver, pause = {
|
||||
if (driver.currentPage == "edit" && driver.inputCount == 0) editPauses += it
|
||||
}).updateShippingAddress("_cg366")
|
||||
|
||||
assertEquals(50, driver.initialEditCaptures)
|
||||
assertEquals(listOf(500L) + List(49) { 200L }, editPauses)
|
||||
assertAddressEditorActions(driver, saved = true)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `address editor excludes recipient phone invisible and leftward inputs and deduplicates paths`() {
|
||||
val driver = LiveDriver(initialEditFrame = { snapshot, _ ->
|
||||
val editor = snapshot.nodes.single { it.path == "editor-address" }
|
||||
snapshot.copy(nodes = snapshot.nodes + listOf(
|
||||
editor,
|
||||
editor.copy(path = "hidden-input", visible = false),
|
||||
editor.copy(path = "left-input", bounds = NodeBounds(0, 390, 10, 480)),
|
||||
))
|
||||
})
|
||||
|
||||
PurchaseLiveAutomation(driver, pause = {}).updateShippingAddress("_cg366")
|
||||
|
||||
assertEquals(1, driver.initialEditCaptures)
|
||||
assertEquals("editor-address", driver.lastInputTargetPath)
|
||||
assertAddressEditorActions(driver, saved = true)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `address editor small screen row overlap remains ambiguous`() {
|
||||
assertAddressEditorTimeout("editor_ambiguous", 2, 2) { snapshot, _ ->
|
||||
snapshot.copy(nodes = snapshot.nodes.map {
|
||||
if (it.path == "editor-phone") it.copy(bounds = NodeBounds(180, 360, 900, 410)) else it
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `address editor fresh input failures never retry or save`() {
|
||||
for (result in FreshActionResult.values().filter { it != FreshActionResult.SUCCESS }) {
|
||||
val driver = LiveDriver(inputResult = result)
|
||||
|
||||
val error = runCatching { PurchaseLiveAutomation(driver, pause = {}).updateShippingAddress("_cg366") }
|
||||
.exceptionOrNull() as PurchaseLiveException
|
||||
|
||||
assertEquals("PURCHASE_ADDRESS_UPDATE_FAILED", error.code)
|
||||
assertEquals(1, driver.inputCount)
|
||||
assertEquals(1, driver.initialEditCaptures)
|
||||
assertEquals(listOf("138****5678", "修改"), driver.clicked)
|
||||
assertEquals(0, driver.backCount)
|
||||
assertEquals(0, driver.scopedSwipes + driver.genericSwipes + driver.submitClicks)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `address editor page safety problem interrupts readiness without input`() {
|
||||
val driver = LiveDriver(initialEditFrame = { snapshot, sample ->
|
||||
if (sample == 1) snapshot.changeAddressEditor { it.copy(text = "") }
|
||||
else snapshot.copy(nodes = snapshot.nodes.map { if (it.path == "title") it.copy(text = "立即支付") else it })
|
||||
})
|
||||
|
||||
val error = runCatching { PurchaseLiveAutomation(driver, pause = {}).updateShippingAddress("_cg366") }
|
||||
.exceptionOrNull() as PurchaseLiveException
|
||||
|
||||
assertEquals("PURCHASE_PAYMENT_FORBIDDEN", error.code)
|
||||
assertEquals(2, driver.initialEditCaptures)
|
||||
assertAddressEditorActions(driver, saved = false)
|
||||
}
|
||||
|
||||
private fun assertDelayedAddressEditor(frame: (UiSnapshot, Int) -> UiSnapshot) {
|
||||
val driver = LiveDriver(initialEditFrame = frame)
|
||||
val editPauses = mutableListOf<Long>()
|
||||
val automation = PurchaseLiveAutomation(driver, pause = {
|
||||
if (driver.currentPage == "edit" && driver.inputCount == 0) editPauses += it
|
||||
})
|
||||
|
||||
val proof = automation.updateShippingAddress("_cg366")
|
||||
|
||||
assertTrue(proof.expectedAddress.endsWith("_cg366"))
|
||||
assertEquals(5, driver.initialEditCaptures)
|
||||
assertEquals(listOf(500L, 200L, 200L, 200L, 200L), editPauses)
|
||||
assertEquals("editor-address", driver.lastInputTargetPath)
|
||||
assertAddressEditorActions(driver, saved = true)
|
||||
}
|
||||
|
||||
private fun assertAddressEditorTimeout(
|
||||
reason: String,
|
||||
structural: Int,
|
||||
ready: Int,
|
||||
titleSeen: Boolean = true,
|
||||
frame: (UiSnapshot, Int) -> UiSnapshot,
|
||||
) {
|
||||
val driver = LiveDriver(initialEditFrame = frame)
|
||||
val diagnostics = mutableListOf<String>()
|
||||
val editPauses = mutableListOf<Long>()
|
||||
val automation = PurchaseLiveAutomation(driver, pause = {
|
||||
if (driver.currentPage == "edit" && driver.inputCount == 0) editPauses += it
|
||||
}, panelDiagnostic = diagnostics::add)
|
||||
|
||||
val failure = runCatching { automation.updateShippingAddress("_cg366") }.exceptionOrNull()
|
||||
assertTrue("Readiness must fail without changing the address", failure is PurchaseLiveException)
|
||||
val error = failure as PurchaseLiveException
|
||||
|
||||
assertEquals(if (titleSeen) "PURCHASE_ADDRESS_UPDATE_FAILED" else "PURCHASE_ADDRESS_EDIT_TIMEOUT", error.code)
|
||||
val diagnostic = "stage=address_editor_ready;reason=$reason;titleSeen=$titleSeen;structural=$structural;ready=$ready"
|
||||
assertTrue(error.message.orEmpty(), error.message.orEmpty().endsWith("[$diagnostic]"))
|
||||
assertEquals(50, driver.initialEditCaptures)
|
||||
assertEquals(listOf(500L) + List(50) { 200L }, editPauses)
|
||||
val emitted = error.message.orEmpty() + diagnostics.joinToString()
|
||||
listOf("广东", "骏景", "测试收货人", "13800000000", "editor-address", "_cg366", "合成提示占位标记", "合成说明占位标记").forEach {
|
||||
assertFalse("Sensitive fixture value in readiness diagnostics", emitted.contains(it))
|
||||
}
|
||||
assertAddressEditorActions(driver, saved = false)
|
||||
}
|
||||
|
||||
private fun assertAddressEditorActions(driver: LiveDriver, saved: Boolean) {
|
||||
assertEquals(if (saved) 1 else 0, driver.inputCount)
|
||||
assertEquals(listOf("138****5678", "修改") + if (saved) listOf("保存") else emptyList(), driver.clicked)
|
||||
assertEquals(if (saved) 1 else 0, driver.backCount)
|
||||
assertEquals(1, driver.addressPathClicks)
|
||||
assertEquals(0, driver.addressTaps + driver.genericSwipes + driver.scopedSwipes + driver.submitClicks)
|
||||
}
|
||||
|
||||
private fun UiSnapshot.changeAddressEditor(change: (SnapshotNode) -> SnapshotNode): UiSnapshot =
|
||||
copy(nodes = nodes.map { if (it.path == "editor-address") change(it) else it })
|
||||
|
||||
private fun UiSnapshot.withSecondAddressEditor(change: (SnapshotNode) -> SnapshotNode = { it }): UiSnapshot =
|
||||
copy(nodes = nodes + change(nodes.single { it.path == "editor-address" }.copy(path = "editor-address-2")))
|
||||
|
||||
@Test
|
||||
fun `split confirmation address keeps the unique saved task suffix proof`() {
|
||||
val driver = LiveDriver(splitConfirmationAddress = true)
|
||||
@@ -1065,6 +1468,8 @@ class PurchaseLiveAutomationTest {
|
||||
private val orderDetailEntryAfterSubmit: Boolean = false,
|
||||
private val orderDetailEvidenceBelowFold: Boolean = false,
|
||||
private val duplicateOrderDetailEntry: Boolean = false,
|
||||
private val initialEditFrame: (UiSnapshot, Int) -> UiSnapshot = { snapshot, _ -> snapshot },
|
||||
private val inputResult: FreshActionResult = FreshActionResult.SUCCESS,
|
||||
postSubmitCaptureSequence: List<String> = emptyList(),
|
||||
) : PurchaseUiDriver {
|
||||
private var page = "confirmation"
|
||||
@@ -1079,6 +1484,10 @@ class PurchaseLiveAutomationTest {
|
||||
var addressTaps = 0
|
||||
var addressPathClicks = 0
|
||||
var lastInputTargetPath: String? = null
|
||||
var lastInputTarget: SnapshotNode? = null
|
||||
var lastInitialEditSnapshot: UiSnapshot? = null
|
||||
var initialEditCaptures = 0
|
||||
var inputCount = 0
|
||||
var backCount = 0
|
||||
var postSubmitBackCount = 0
|
||||
var pddRestoreCount = 0
|
||||
@@ -1091,7 +1500,12 @@ class PurchaseLiveAutomationTest {
|
||||
postSubmitCaptureCount++
|
||||
if (pendingPostSubmitPages.isNotEmpty()) page = pendingPostSubmitPages.removeAt(0)
|
||||
}
|
||||
return currentSnapshot()
|
||||
val snapshot = currentSnapshot()
|
||||
if (page == "edit" && inputCount == 0) {
|
||||
initialEditCaptures++
|
||||
return initialEditFrame(snapshot, initialEditCaptures).also { lastInitialEditSnapshot = it }
|
||||
}
|
||||
return snapshot
|
||||
}
|
||||
|
||||
private fun currentSnapshot(): UiSnapshot = when (page) {
|
||||
@@ -1263,7 +1677,10 @@ class PurchaseLiveAutomationTest {
|
||||
}
|
||||
|
||||
override fun inputFresh(target: SnapshotNode, value: String): FreshActionResult {
|
||||
inputCount++
|
||||
lastInputTarget = target
|
||||
lastInputTargetPath = target.path
|
||||
if (inputResult != FreshActionResult.SUCCESS) return inputResult
|
||||
address = value
|
||||
return FreshActionResult.SUCCESS
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
package cn.ilapage.goauto.agent
|
||||
|
||||
import cn.ilapage.goauto.agent.network.AgentApiClient
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Test
|
||||
import java.net.ServerSocket
|
||||
import java.util.concurrent.Executors
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
class RepurchaseServerClockTest {
|
||||
@Test fun historyCapturesServerDateAndMissingDateClearsPreviousSample() {
|
||||
ServerSocket(0).use { server ->
|
||||
val executor = Executors.newSingleThreadExecutor()
|
||||
val response = "{\"data\":{\"items\":[],\"total\":0,\"page\":1,\"pageSize\":50}}"
|
||||
val serving = executor.submit {
|
||||
repeat(2) { index ->
|
||||
server.accept().use { socket ->
|
||||
socket.soTimeout = 3000
|
||||
val reader = socket.getInputStream().bufferedReader()
|
||||
while (!reader.readLine().isNullOrEmpty()) { }
|
||||
val date = if (index == 0) "Date: Thu, 08 Oct 2026 09:00:00 GMT\r\n" else ""
|
||||
socket.getOutputStream().write(("HTTP/1.1 200 OK\r\n" + date +
|
||||
"Content-Type: application/json\r\nContent-Length: ${response.toByteArray().size}\r\nConnection: close\r\n\r\n$response").toByteArray())
|
||||
}
|
||||
}
|
||||
}
|
||||
try {
|
||||
val api = AgentApiClient("http://127.0.0.1:${server.localPort}")
|
||||
api.purchaseHistory("synthetic", 1, null, null, 30, 50)
|
||||
assertEquals(1791450000000L, api.lastResponseServerTimeMillis)
|
||||
api.purchaseHistory("synthetic", 1, null, null, 30, 50)
|
||||
assertNull(api.lastResponseServerTimeMillis)
|
||||
serving.get(5, TimeUnit.SECONDS)
|
||||
} finally { executor.shutdownNow() }
|
||||
}
|
||||
}
|
||||
}
|
||||
+19
@@ -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 ->
|
||||
|
||||
+131
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
package cn.ilapage.goauto.agent.service
|
||||
|
||||
import cn.ilapage.goauto.agent.network.HistoryPage
|
||||
import cn.ilapage.goauto.agent.network.PurchaseHistoryItem
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Test
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
import java.util.TimeZone
|
||||
|
||||
class RepurchaseBatchTest {
|
||||
private val now = 1_791_446_400_000L
|
||||
private fun at(secondsAgo: Long) = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.ROOT).apply {
|
||||
timeZone = TimeZone.getTimeZone("UTC")
|
||||
}.format(Date(now - secondsAgo * 1000))
|
||||
|
||||
private fun item(id: Long, secondsAgo: Long = 0, status: String = "failed", retryable: Boolean = true) = PurchaseHistoryItem(
|
||||
id, status, "synthetic", "synthetic", "synthetic", "", "", "", "", 1, null, "CNY", null, null,
|
||||
null, null, retryable, if (retryable) null else "不可重试", at(secondsAgo),
|
||||
)
|
||||
|
||||
private fun discover(items: List<PurchaseHistoryItem>): RepurchaseBatch = RepurchaseBatchDiscovery({ q ->
|
||||
HistoryPage(items.drop((q.page - 1) * 50).take(50), items.size.toLong(), q.page, 50)
|
||||
}, { now }).discover()
|
||||
|
||||
private fun refused(reason: RepurchaseBatchFailure, block: () -> Unit) {
|
||||
try { block(); fail("Expected $reason") } catch (e: RepurchaseBatchException) { assertEquals(reason, e.reason) }
|
||||
}
|
||||
|
||||
@Test fun sixtySecondsJoinsTransitivelyButSixtyOneStartsOlderBatch() {
|
||||
val batch = discover(listOf(item(5), item(4, 60), item(3, 120), item(2, 181), item(1, 182)))
|
||||
assertEquals(listOf(5L, 4L, 3L), batch.items.map { it.taskId })
|
||||
assertEquals(at(0), batch.newestCreatedAt)
|
||||
assertEquals(at(120), batch.oldestCreatedAt)
|
||||
assertEquals(5L, batch.headTaskId)
|
||||
}
|
||||
|
||||
@Test fun equalInstantsUseDescendingIdsAndRespectTimezones() {
|
||||
val batch = discover(listOf(item(3).copy(createdAt = "2026-10-08T08:00:00.123456789+08:00"),
|
||||
item(2).copy(createdAt = "2026-10-08T00:00:00.123456789Z"),
|
||||
item(1).copy(createdAt = "2026-10-07T20:00:00.123456789-04:00")))
|
||||
assertEquals(listOf(3L, 2L, 1L), batch.items.map { it.taskId })
|
||||
}
|
||||
|
||||
@Test fun subMillisecondGapAboveSixtySecondsIsBoundary() {
|
||||
val batch = discover(listOf(item(2).copy(createdAt = "2026-10-08T00:01:00.000000001Z"),
|
||||
item(1).copy(createdAt = "2026-10-08T00:00:00Z")))
|
||||
assertEquals(listOf(2L), batch.items.map { it.taskId })
|
||||
}
|
||||
|
||||
@Test fun readsFullStateHistoryAcrossFiftyItemBoundaryAndRechecksHead() {
|
||||
val requests = mutableListOf<RepurchaseHistoryQuery>()
|
||||
val data = (70L downTo 1).map { item(it, 70 - it, if (it % 2 == 0L) "failed" else "order_created") }
|
||||
val result = RepurchaseBatchDiscovery({ q ->
|
||||
requests += q
|
||||
HistoryPage(data.drop((q.page - 1) * 50).take(50), 70, q.page, 50)
|
||||
}, { now }).discover()
|
||||
assertEquals(70, result.items.size)
|
||||
assertEquals(35, result.candidates.size)
|
||||
assertEquals(listOf(1, 2, 1), requests.map { it.page })
|
||||
assertTrue(requests.all { it.days == 30 && it.pageSize == 50 && it.status == null && it.taskNo == null })
|
||||
}
|
||||
|
||||
@Test fun overlappingPagesDeduplicateTaskIdsBeforeBoundary() {
|
||||
val data = (60L downTo 1).map { item(it, if (it >= 10) 60 - it else 300) }
|
||||
val result = RepurchaseBatchDiscovery({ q ->
|
||||
val page = if (q.page == 1) data.take(50) else data.drop(49)
|
||||
HistoryPage(page, 61, q.page, 50)
|
||||
}, { now }).discover()
|
||||
assertEquals(51, result.items.size)
|
||||
assertEquals(51, result.items.map { it.taskId }.distinct().size)
|
||||
}
|
||||
|
||||
@Test fun headChangeRefusesEvenWhenBoundaryAlreadyFound() {
|
||||
var calls = 0
|
||||
refused(RepurchaseBatchFailure.LIST_CHANGED) {
|
||||
RepurchaseBatchDiscovery({ q ->
|
||||
calls++
|
||||
HistoryPage(if (calls == 1) listOf(item(2), item(1, 100)) else listOf(item(3), item(2), item(1, 100)),
|
||||
if (calls == 1) 2 else 3, q.page, 50)
|
||||
}, { now }).discover()
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun invalidDatesAndMissingZonesAreRefused() {
|
||||
listOf("", "2026-02-30T00:00:00Z", "2026-10-08T00:00:00", "2026-10-08T00:00:00+25:00", "2026-10-08T00:00:00Zjunk").forEach {
|
||||
refused(RepurchaseBatchFailure.INVALID_TIMESTAMP) { discover(listOf(item(1).copy(createdAt = it))) }
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun invertedChronologicalOrIdOrderingIsRefused() {
|
||||
refused(RepurchaseBatchFailure.INVALID_ORDER) { discover(listOf(item(2, 20), item(1))) }
|
||||
refused(RepurchaseBatchFailure.INVALID_ORDER) { discover(listOf(item(1), item(2))) }
|
||||
}
|
||||
|
||||
@Test fun nearThirtyDayCutoffCannotProveBatchComplete() {
|
||||
val days30 = 30L * 24 * 60 * 60
|
||||
listOf(days30, days30 - 60, days30 - 61, days30 + 1).forEach { age ->
|
||||
refused(RepurchaseBatchFailure.INCOMPLETE_BATCH) { discover(listOf(item(1, age))) }
|
||||
}
|
||||
assertEquals(1, discover(listOf(item(1, days30 - 62))).items.size)
|
||||
}
|
||||
|
||||
@Test fun usesServerResponseClockEvenWhenPhoneIsFiveMinutesSlow() {
|
||||
val phoneNow = now
|
||||
val serverNow = phoneNow + 5 * 60 * 1000
|
||||
val ageFromPhoneSeconds = 30L * 24 * 60 * 60 - 5 * 60 - 30
|
||||
var responseTime: Long? = null
|
||||
refused(RepurchaseBatchFailure.INCOMPLETE_BATCH) {
|
||||
RepurchaseBatchDiscovery({ q ->
|
||||
responseTime = serverNow
|
||||
HistoryPage(listOf(item(1, ageFromPhoneSeconds)), 1, q.page, 50)
|
||||
}, { checkNotNull(responseTime) }).discover()
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun responseTimeIsNotRequiredBeforeFirstFetch() {
|
||||
var responseTime: Long? = null
|
||||
val batch = RepurchaseBatchDiscovery({ q ->
|
||||
responseTime = now
|
||||
HistoryPage(listOf(item(1)), 1, q.page, 50)
|
||||
}, { checkNotNull(responseTime) }).discover()
|
||||
assertEquals(1, batch.items.size)
|
||||
}
|
||||
|
||||
@Test fun missingServerResponseClockRefusesWithoutPhoneClockFallback() {
|
||||
refused(RepurchaseBatchFailure.INCOMPLETE_BATCH) {
|
||||
RepurchaseBatchDiscovery({ q -> HistoryPage(listOf(item(1)), 1, q.page, 50) },
|
||||
{ error("No server Date") }).discover()
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun retainsLatestObservedServerClockWhenLaterResponseTimeRegresses() {
|
||||
var responseTime = now
|
||||
var calls = 0
|
||||
refused(RepurchaseBatchFailure.INCOMPLETE_BATCH) {
|
||||
RepurchaseBatchDiscovery({ q ->
|
||||
responseTime = if (++calls == 1) now + 300_000 else now
|
||||
HistoryPage(listOf(item(1, 30L * 24 * 60 * 60 - 330)), 1, q.page, 50)
|
||||
}, { responseTime }).discover()
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun exactlyFiftyRecordsCanEstablishTrustworthyEnd() {
|
||||
val requested = mutableListOf<Int>()
|
||||
val batch = RepurchaseBatchDiscovery({ q ->
|
||||
requested += q.page
|
||||
HistoryPage((50L downTo 1).map { item(it, 50 - it) }, 50, q.page, 50)
|
||||
}, { now }).discover()
|
||||
assertEquals(50, batch.items.size)
|
||||
assertEquals(listOf(1, 1), requested)
|
||||
}
|
||||
|
||||
@Test fun latestSuccessfulBatchDoesNotFallBackToOlderFailures() {
|
||||
val result = discover(listOf(item(2, status = "order_created"), item(1, 61)))
|
||||
assertEquals(1, result.items.size)
|
||||
assertTrue(result.failures.isEmpty())
|
||||
assertTrue(result.candidates.isEmpty())
|
||||
}
|
||||
|
||||
@Test fun onlyFailedRetryableAreCandidatesAndSecondRoundUsesRemainingFailures() {
|
||||
val items = (10L downTo 1).map { item(it, status = if (it <= 4) "failed" else "order_created") }
|
||||
val first = discover(items)
|
||||
assertEquals(listOf(4L, 3L, 2L, 1L), first.candidates.map { it.taskId })
|
||||
val second = discover(items.map { if (it.taskId in 3L..4L) it.copy(status = "order_created") else it })
|
||||
assertEquals(listOf(2L, 1L), second.candidates.map { it.taskId })
|
||||
val blocked = discover(listOf(item(2, retryable = false), item(1, status = "pending")))
|
||||
assertEquals(1, blocked.failures.size)
|
||||
assertEquals("不可重试", blocked.skips.single().retryDisabledReason)
|
||||
assertTrue(blocked.candidates.isEmpty())
|
||||
}
|
||||
|
||||
@Test fun pageFailureCannotReturnPartOfBatch() {
|
||||
refused(RepurchaseBatchFailure.PAGE_FAILED) {
|
||||
RepurchaseBatchDiscovery({ q ->
|
||||
if (q.page == 2) error("synthetic network failure")
|
||||
HistoryPage((60L downTo 11).map { item(it, 60 - it) }, 60, q.page, 50)
|
||||
}, { now }).discover()
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun shortPageWithUnseenTotalAndWrongMetadataAreRefused() {
|
||||
listOf(HistoryPage(listOf(item(1)), 2, 1, 50), HistoryPage(listOf(item(1)), 1, 2, 50),
|
||||
HistoryPage(listOf(item(1)), 1, 1, 20), HistoryPage(emptyList(), -1, 1, 50)).forEach { page ->
|
||||
refused(RepurchaseBatchFailure.INVALID_PAGE) { RepurchaseBatchDiscovery({ page }, { now }).discover() }
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun maximumPagesCannotReturnTruncatedBatch() {
|
||||
refused(RepurchaseBatchFailure.PAGE_LIMIT) {
|
||||
RepurchaseBatchDiscovery({ q -> HistoryPage((60L downTo 11).map { item(it, 60 - it) }, 60, q.page, 50) },
|
||||
{ now }, maxPages = 1).discover()
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun crossPageOrderInversionAndChangedDuplicateTimestampAreRefused() {
|
||||
listOf(item(1, 1), item(11, 99)).forEach { next ->
|
||||
refused(RepurchaseBatchFailure.INVALID_ORDER) {
|
||||
RepurchaseBatchDiscovery({ q ->
|
||||
HistoryPage(if (q.page == 1) (60L downTo 11).map { item(it, 60 - it) } else listOf(next),
|
||||
51, q.page, 50)
|
||||
}, { now }).discover()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun duplicatesCannotMakeAnIncompleteTailLookComplete() {
|
||||
refused(RepurchaseBatchFailure.INCOMPLETE_BATCH) {
|
||||
RepurchaseBatchDiscovery({ q ->
|
||||
HistoryPage(if (q.page == 1) (60L downTo 11).map { item(it, 60 - it) } else listOf(item(11, 49)),
|
||||
51, q.page, 50)
|
||||
}, { now }).discover()
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun failedHeadRecheckRefusesTheAlreadyFoundBatch() {
|
||||
var calls = 0
|
||||
refused(RepurchaseBatchFailure.PAGE_FAILED) {
|
||||
RepurchaseBatchDiscovery({ q ->
|
||||
if (++calls > 1) error("synthetic network failure")
|
||||
HistoryPage(listOf(item(1)), 1, q.page, 50)
|
||||
}, { now }).discover()
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun emptyHistoryIsRecheckedAndReturnedEmpty() {
|
||||
var calls = 0
|
||||
val batch = RepurchaseBatchDiscovery({ q -> calls++; HistoryPage(emptyList(), 0, q.page, 50) }, { now }).discover()
|
||||
assertTrue(batch.items.isEmpty())
|
||||
assertNull(batch.headTaskId)
|
||||
assertEquals(2, calls)
|
||||
}
|
||||
|
||||
@Test fun immutableSnapshotDoesNotExposeMutableLists() {
|
||||
val batch = discover(listOf(item(1)))
|
||||
listOf(batch.items, batch.candidates, batch.failures).forEach { items ->
|
||||
try { (items as MutableList).clear(); fail("Mutable snapshot") } catch (_: UnsupportedOperationException) { }
|
||||
}
|
||||
}
|
||||
}
|
||||
+182
@@ -0,0 +1,182 @@
|
||||
package cn.ilapage.goauto.agent.service
|
||||
|
||||
import cn.ilapage.goauto.agent.network.*
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Test
|
||||
import java.io.IOException
|
||||
|
||||
class RepurchaseCoordinatorTest {
|
||||
private fun item(id: Long, status: String = "failed", retryable: Boolean = true, error: String? = null) = PurchaseHistoryItem(
|
||||
id, status, "", "", "", "", "", "", "", 1, null, "CNY", null, null, error, null, retryable, "不可重试", "2026-10-08T09:00:00Z")
|
||||
private fun detail(id: Long, status: String = "failed", count: Long = 1, retryable: Boolean = true, error: String? = null) =
|
||||
PurchaseHistoryDetail(item(id, status, retryable, error), count, error, null, false, null, null, null, null, false, null)
|
||||
private inner class Fake : RepurchasePort {
|
||||
var clock = 0L
|
||||
var busy = false
|
||||
var otherWork = false
|
||||
var environment: String? = null
|
||||
var head: Long? = 2L
|
||||
var resetFailure: Exception? = null
|
||||
var failOnce = false
|
||||
var duringReset: (() -> Unit)? = null
|
||||
val details = mutableMapOf(2L to this@RepurchaseCoordinatorTest.detail(2), 1L to this@RepurchaseCoordinatorTest.detail(1))
|
||||
val resets = mutableListOf<Pair<Long, String>>()
|
||||
var evidence = emptyList<RepurchaseExecutionEvidence>()
|
||||
override fun executionEvidence(taskId: Long) = evidence
|
||||
override fun environmentProblem() = environment
|
||||
override fun localIdle() = !busy
|
||||
override fun hasOtherWork(currentTaskId: Long?) = otherWork
|
||||
override fun latestTaskId() = head
|
||||
override fun detail(taskId: Long) = details.getValue(taskId)
|
||||
override fun reset(taskId: Long, requestId: String): PurchaseResetResult {
|
||||
resets += taskId to requestId
|
||||
duringReset?.invoke()
|
||||
resetFailure?.let { if (failOnce) resetFailure = null; throw it }
|
||||
return PurchaseResetResult(taskId, "CG-$taskId", 2, "pending", resets.size > 1)
|
||||
}
|
||||
fun runner(): RepurchaseCoordinator {
|
||||
var sequence = 0
|
||||
return RepurchaseCoordinator({ clock }, { "request-${++sequence}" }, {}).also {
|
||||
assertTrue(it.prepare())
|
||||
it.prepared(RepurchaseBatch(listOf(item(2), item(1))))
|
||||
it.confirm(it.state.roundId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun serialResetsWaitForNewAttemptAndLocalDrain() {
|
||||
val p = Fake(); val r = p.runner()
|
||||
r.tick(p); assertEquals(listOf(2L), p.resets.map { it.first })
|
||||
r.tick(p); assertEquals(0, r.state.results.size) // old failed, attempt1
|
||||
p.details[2] = detail(2, "order_created", 2); p.busy = true
|
||||
r.tick(p); assertEquals(0, r.state.results.size)
|
||||
p.busy = false; r.tick(p); assertEquals(1, r.state.successCount)
|
||||
r.tick(p); assertEquals(listOf(2L, 1L), p.resets.map { it.first })
|
||||
p.details[1] = detail(1, count = 2); r.tick(p); r.tick(p)
|
||||
assertEquals(RepurchasePhase.FINISHED, r.state.phase)
|
||||
assertEquals(1, r.state.failedCount); assertEquals(2, p.resets.size)
|
||||
}
|
||||
@Test fun lostResetResponseUsesSameRequestIdAndReplayIsNotTerminal() {
|
||||
val p = Fake(); val r = p.runner(); p.resetFailure = IOException(); p.failOnce = true
|
||||
r.tick(p); r.tick(p)
|
||||
assertEquals(2, p.resets.size); assertEquals(p.resets[0], p.resets[1])
|
||||
assertEquals(0, r.state.results.size)
|
||||
}
|
||||
@Test fun unresolvedMappedValuesDoNotPreventReset() {
|
||||
val p = Fake(); val r = p.runner(); r.tick(p)
|
||||
assertEquals(2L, p.resets.single().first)
|
||||
}
|
||||
@Test fun definiteBusinessRefusalSkipsButNetworkAmbiguityStops() {
|
||||
val p = Fake(); val r = p.runner()
|
||||
p.resetFailure = AgentApiException(409, "PURCHASE_RETRY_STALE", "safe", false)
|
||||
r.tick(p); assertEquals(1, r.state.skippedCount)
|
||||
p.resetFailure = IOException(); r.tick(p); r.tick(p)
|
||||
assertTrue(r.state.phase in setOf(RepurchasePhase.STOPPING, RepurchasePhase.FINISHED))
|
||||
assertEquals(3, p.resets.size)
|
||||
}
|
||||
@Test fun newWorkStopsSubsequentButNotCurrent() {
|
||||
val p = Fake(); val r = p.runner(); r.tick(p); p.otherWork = true
|
||||
r.tick(p); assertEquals(RepurchasePhase.STOPPING, r.state.phase)
|
||||
p.details[2] = detail(2, "order_created", 2); r.tick(p); r.tick(p)
|
||||
assertEquals(RepurchasePhase.FINISHED, r.state.phase); assertEquals(1, p.resets.size)
|
||||
}
|
||||
@Test fun stopBeforeResetIssuesNothingAndStaleStopIgnored() {
|
||||
val p = Fake(); val r = p.runner(); r.requestStop("old"); r.tick(p)
|
||||
assertEquals(1, p.resets.size)
|
||||
val p2 = Fake(); val r2 = p2.runner(); r2.requestStop(r2.state.roundId); r2.tick(p2)
|
||||
assertEquals(0, p2.resets.size); assertEquals(RepurchasePhase.FINISHED, r2.state.phase)
|
||||
}
|
||||
@Test fun stopDuringIssuedResetAllowsOnlyCurrentToDrain() {
|
||||
val p = Fake(); val r = p.runner(); p.duringReset = { r.requestStop(r.state.roundId) }
|
||||
r.tick(p); p.details[2] = detail(2, "order_created", 2); r.tick(p); r.tick(p)
|
||||
assertEquals(1, p.resets.size); assertEquals(RepurchasePhase.FINISHED, r.state.phase)
|
||||
}
|
||||
@Test fun timeoutStopsOrchestrationWithoutAnotherReset() {
|
||||
val p = Fake(); val r = p.runner(); r.tick(p); p.clock = 15 * 60 * 1000L
|
||||
r.tick(p); assertEquals(RepurchasePhase.FINISHED, r.state.phase); assertEquals(1, p.resets.size)
|
||||
}
|
||||
@Test fun unknownAndCaptchaStopTheRound() {
|
||||
for ((status, error) in listOf("order_result_unknown" to null, "failed" to "PDD_CAPTCHA_REQUIRED")) {
|
||||
val p = Fake(); val r = p.runner(); r.tick(p); p.details[2] = detail(2, status, 2, error = error)
|
||||
r.tick(p); r.tick(p)
|
||||
assertEquals(RepurchasePhase.FINISHED, r.state.phase); assertEquals(1, p.resets.size)
|
||||
}
|
||||
}
|
||||
@Test fun headChangedOrEnvironmentUnavailableDoesNotReset() {
|
||||
val p = Fake(); val r = p.runner(); p.head = 3; r.tick(p)
|
||||
assertEquals(RepurchasePhase.FINISHED, r.state.phase); assertTrue(p.resets.isEmpty())
|
||||
val p2 = Fake(); val r2 = p2.runner(); p2.environment = "无障碍未开启"; r2.tick(p2)
|
||||
assertEquals(RepurchasePhase.FINISHED, r2.state.phase); assertTrue(p2.resets.isEmpty())
|
||||
}
|
||||
@Test fun busyHasBoundedDrainAndProcessRestartDoesNotRestoreRound() {
|
||||
val p = Fake(); val r = p.runner(); p.busy = true; r.tick(p)
|
||||
p.clock = 10000; r.tick(p); assertEquals(RepurchasePhase.FINISHED, r.state.phase)
|
||||
assertFalse(RepurchaseCoordinator({0}, {"x"}, {}).state.busy)
|
||||
}
|
||||
@Test fun noRetryableCandidatesDoesNotConfirmOrFallBack() {
|
||||
val r = RepurchaseCoordinator({0}, {"x"}, {})
|
||||
r.prepare(); r.prepared(RepurchaseBatch(listOf(item(3, "order_created"))))
|
||||
assertEquals(RepurchasePhase.FINISHED, r.state.phase)
|
||||
r.confirm(r.state.roundId); assertEquals(RepurchasePhase.FINISHED, r.state.phase)
|
||||
}
|
||||
|
||||
@Test fun definiteEnvironmentRefusalStopsWithoutPretendingTaskWasIssued() {
|
||||
val p = Fake(); val r = p.runner()
|
||||
p.resetFailure = AgentApiException(401, "DEVICE_TOKEN_INVALID", "unauthorized", false)
|
||||
r.tick(p)
|
||||
assertEquals(RepurchasePhase.FINISHED, r.state.phase)
|
||||
assertNull(r.state.currentTaskId)
|
||||
assertEquals(1, p.resets.size)
|
||||
}
|
||||
|
||||
@Test fun secondRoundOnlyUsesStillFailedCandidates() {
|
||||
val p = Fake(); val r = p.runner(); r.tick(p)
|
||||
p.details[2] = detail(2, "order_created", 2); r.tick(p); r.tick(p)
|
||||
p.details[1] = detail(1, count = 2); r.tick(p)
|
||||
assertTrue(r.prepare())
|
||||
r.prepared(RepurchaseBatch(listOf(item(2, "order_created"), item(1))))
|
||||
assertEquals(listOf(1L), r.state.batch!!.candidates.map { it.taskId })
|
||||
r.confirm(r.state.roundId)
|
||||
// New reset must return a strictly newer attempt than the refreshed detail.
|
||||
p.details[1] = detail(1)
|
||||
r.tick(p)
|
||||
assertEquals(listOf(2L, 1L, 1L), p.resets.map { it.first })
|
||||
}
|
||||
|
||||
@Test fun unexpectedExceptionDoesNotLeakRawErrorOrSkipRest() {
|
||||
val p = Fake(); val r = p.runner(); p.resetFailure = IOException("private remote body")
|
||||
r.tick(p)
|
||||
assertFalse(r.state.message.contains("private"))
|
||||
assertEquals(RepurchasePhase.STOPPING, r.state.phase)
|
||||
assertEquals(listOf(2L, 2L), p.resets.map { it.first })
|
||||
}
|
||||
|
||||
@Test fun externalLaterAttemptIsNotCountedAsThisRoundsResult() {
|
||||
val p = Fake(); val r = p.runner(); r.tick(p)
|
||||
p.details[2] = detail(2, "order_created", 3)
|
||||
p.evidence = listOf(RepurchaseExecutionEvidence(2, "attempt2", "purchase", "failed"),
|
||||
RepurchaseExecutionEvidence(3, "attempt3", "purchase", "order_created"))
|
||||
r.tick(p); r.tick(p)
|
||||
assertEquals(RepurchasePhase.FINISHED, r.state.phase)
|
||||
assertEquals(0, r.state.successCount); assertEquals(1, p.resets.size)
|
||||
}
|
||||
|
||||
@Test fun completedProbeCanLinkToOriginalExecutorsNextPurchaseAttempt() {
|
||||
val p = Fake(); val r = p.runner(); r.tick(p)
|
||||
p.details[2] = detail(2, "order_created", 3)
|
||||
p.evidence = listOf(RepurchaseExecutionEvidence(2, "probe2", "spec_probe", "spec_probe_completed"),
|
||||
RepurchaseExecutionEvidence(3, "purchase3", "purchase", "order_created"))
|
||||
r.tick(p)
|
||||
assertEquals(1, r.state.successCount)
|
||||
r.tick(p); assertEquals(listOf(2L, 1L), p.resets.map { it.first })
|
||||
}
|
||||
|
||||
@Test fun localReservationBusyNeverPretendsARequestWasSentAndUsesSameId() {
|
||||
val p = Fake(); val r = p.runner(); p.resetFailure = RepurchaseLocalBusy(); p.failOnce = true
|
||||
r.tick(p); assertNull(r.state.currentTaskId); assertEquals(RepurchasePhase.RUNNING, r.state.phase)
|
||||
r.tick(p); assertEquals(p.resets[0], p.resets[1])
|
||||
val p2 = Fake(); val r2 = p2.runner(); p2.resetFailure = RepurchaseLocalBusy()
|
||||
r2.tick(p2); p2.clock = 10000; r2.tick(p2)
|
||||
assertEquals(RepurchasePhase.FINISHED, r2.state.phase); assertNull(r2.state.currentTaskId)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package cn.ilapage.goauto.agent.service
|
||||
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Test
|
||||
|
||||
class RepurchaseStateTest {
|
||||
@Test fun confirmationExplainsOriginalTaskAndUnpaidOrderRisk() {
|
||||
val state = RepurchaseState(phase = RepurchasePhase.READY)
|
||||
assertTrue(state.confirmationText().contains("不会付款"))
|
||||
assertTrue(state.confirmationText().contains("原任务号"))
|
||||
assertTrue(state.confirmationText().contains("15分钟"))
|
||||
assertTrue(state.confirmationText().contains("60秒"))
|
||||
}
|
||||
|
||||
@Test fun readingReadyRunningAndStoppingPreventDuplicateRounds() {
|
||||
for (phase in listOf(RepurchasePhase.READING, RepurchasePhase.READY, RepurchasePhase.RUNNING, RepurchasePhase.STOPPING)) {
|
||||
assertTrue(RepurchaseState(phase = phase).busy)
|
||||
}
|
||||
assertFalse(RepurchaseState().busy)
|
||||
assertFalse(RepurchaseState(phase = RepurchasePhase.FINISHED).busy)
|
||||
}
|
||||
|
||||
@Test fun countsDoNotMarkUnexecutedItemsAsFailures() {
|
||||
val state = RepurchaseState(results = listOf(
|
||||
RepurchaseItemResult(1, RepurchaseResult.SUCCESS, "订单已创建"),
|
||||
RepurchaseItemResult(2, RepurchaseResult.FAILED, "再次失败"),
|
||||
RepurchaseItemResult(3, RepurchaseResult.SKIPPED, "不可重试"),
|
||||
))
|
||||
assertEquals(1, state.successCount)
|
||||
assertEquals(1, state.failedCount)
|
||||
assertEquals(1, state.skippedCount)
|
||||
assertTrue(state.summaryText().contains("成功 1"))
|
||||
}
|
||||
}
|
||||
@@ -2,8 +2,8 @@
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Architecture-and-Code-Map
|
||||
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Architecture-and-Code-Map.-
|
||||
wiki_revision: b3d1c9b7ba1dd5820a20e59b0c21a971aaf1b8ee
|
||||
synchronized_at: 2026-10-08T01:41:51Z
|
||||
wiki_revision: bf3ce6efd67fde1fdb6eacd3248f186aea880391
|
||||
synchronized_at: 2026-10-08T10:15:16Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
<!-- gitea-wiki-mirror:start -->
|
||||
@@ -63,7 +63,7 @@ Android Portal/Agent
|
||||
- 重置在事务中清空原结果,保留 URL、goods_id、规则和设备快照,状态恢复为 `pending`。
|
||||
- Android 本地互斥与服务端原子领取共同保证单设备串行。
|
||||
- 正式采购在 Android 本地 SQLite 事务中先保存不可逆状态、稳定服务端请求 ID 和脱敏最终确认快照,再通知服务端并只允许一次创建订单点击;重启后只重放服务端标记、只读核单或上报,不再次点击。
|
||||
- 原始控件树和截图不持久化;Android Agent 端第一期不使用 OCR/VLM。服务端 SYB 登录验证码识别是唯一例外,见 [#48](https://git.ilapage.cn/OPC/goauto/issues/48)。
|
||||
- 普通原始控件树和截图不持久化;采购失败私有现场诊断是 #364 的限定例外,见本页对应节;Android Agent 端第一期不使用 OCR/VLM。服务端 SYB 登录验证码识别是唯一例外,见 [#48](https://git.ilapage.cn/OPC/goauto/issues/48)。
|
||||
- 蝦皮规格映射独立保存在 `shopee_product.specs_json`:颜色只能选择关联 PDD 当前可选颜色,允许多个蝦皮颜色共用一个 PDD 颜色。Admin 商品详情的“一键匹配颜色和尺码”在服务端统一计算两个维度:保留当前仍有效的已确认映射,将唯一确定匹配及达到阈值、具备理由且候选仍有效的 AI 匹配,在重新校验规格上下文后于同一事务直接写为 `confirmed`,无需人工确认;低置信度或无结果保持未匹配,Provider 异常或上下文变化时不写入任何本次结果。PDD 目标规格消失后页面标记失效,映射保存和采购创建均拒绝继续使用;无需新增数据库表或 Android 能力。
|
||||
|
||||
## 最小业务数据
|
||||
@@ -639,3 +639,28 @@ Web 唯一展示位置为“采集采购 → SYB 同步记录”:列表状态
|
||||
- 因预算超时不能再使用已取消context写统计,收尾仅使用最多5秒的独立上下文执行受所有权保护的完成更新,不启动新商品领取或AI调用;失租不强制落库。
|
||||
- MySQL默认返回实际修改行数;续租更新返回0时,只在当前持有行锁的事务内再次核验owner/状态/槽位/实时有效租约,以区分同毫秒值未变化与真实失租;其他完成/工作项更新仍要求恰好一行。
|
||||
- AI配置读取先返回数据库错误,再判断停用,避免基础设施错误被误记为业务跳过。仅批次私有上下文把基础设施错误作为本轮错误终止;普通单商品Provider重试策略保持不变。
|
||||
|
||||
|
||||
## 采购失败现场诊断(#364,待真机验收)
|
||||
|
||||
实现绑定 `4581ee5`,后续合并记录见 #364;尚未发布。以下是普通原始控件树禁存规则的限定例外;普通采集、截图、OCR/VLM 和付款边界不变。
|
||||
|
||||
- Android `diagnostics/FailureSnapshotCapture.kt` 统一判断失败/待核对、成功排除及既有 ZIP;`FailureSnapshotExporter.kt` 独立导出有限公开属性,`AccessibilitySnapshotSource.kt` 只读默认显示屏交互窗口,不改变正常 `capture()` 或解析器。
|
||||
- `AgentForegroundService` 在有效实际 attempt 的失败结果或执行异常处同步有界截取,先于新提示/返回/下一任务;恢复场景单独标识。成功结果后的保存/提示/上传异常不创建失败快照,不改变原业务状态和不可逆标记。
|
||||
- `goauto_diagnostics.db` 升级 v4;独立 `purchase_failure_snapshot`、`purchase_failure_snapshot_queue`、`purchase_failure_snapshot_context` 表。旧 `agent_diagnostic` 保持 7 天/50 条,新快照30天/64MiB逻辑容量。SQL 核心 `FailureSnapshotRepository` 共用真实 SQLite 测试,Android适配器以256KiB块读取BLOB,避开CursorWindow单行限制。
|
||||
- 队列绑定 Server Origin;已保存 ZIP 不覆盖,旧上传响应凭 fingerprint 不得清除首次恢复 ZIP 的新队列项。结果 Outbox 优先,诊断独立后台线程只上传已存字节,不持有设备任务锁;启动及每60秒执行维护。
|
||||
- Server `purchase/failure_snapshot*.go` 提供专用上传/安全摘要/管理员ZIP下载;`models/purchase_failure_snapshot.go` 独立私有表。追加迁移 `1791400000000_purchase_failure_snapshot.go` 保存 LONGBLOB ZIP、LONGTEXT manifest、attempt唯一约束及到期索引。
|
||||
- `access.AdminAPIs` 注册两个仅管理员读取接口;普通任务DTO和Client API不增加原始诊断字段。通用操作日志精确排除三个诊断端点,私有SQL读写使用静默logger。服务启动立即清理并每小时清理过期诊断,不依赖新任务。
|
||||
- Web `purchase-tasks/FailureSnapshotCell.vue` 是现有执行记录末尾200px管理员专属列,独立摘要查询、固定原因中文和附件下载;无原始XML在线预览。
|
||||
|
||||
|
||||
## Android 重购编排(#367)
|
||||
|
||||
实现绑定 `5430dc1`,工单分支尚未合并/安装/发布;无Server/Web、数据库或接口迁移。
|
||||
|
||||
- `TaskHistoryFragment` 把采购搜索栏入口改为“重购”,发送 PREPARE/CONFIRM/STOP、显示 Service 内存状态;一次确认与逐条摘要由 `RepurchaseState` 提供,不由 Fragment 驱动采购生命周期。
|
||||
- `RepurchaseBatchDiscovery` 注入既有设备历史查询和服务端HTTP Date时钟,负责全状态/50条页/30天窗口、相邻60秒边界、去重、完整性及重读首页;最多200页,不使用本地历史缓存执行重购。
|
||||
- `RepurchaseCoordinator` 通过小型 `RepurchasePort` 执行非阻塞状态推进;只调用原reset和只读详情,固定每任务requestId、expected attempt、10秒本地忙碌复核及15分钟单条上限。依靠原执行器的attemptNumber/UUID/phase/已知结果识别合法探测续行,拒绝外部更晚attempt冒充本轮结果。
|
||||
- `AgentForegroundService` 独立单线程scheduled executor每2秒推进编排,历史网络不阻塞心跳或taskExecutor;发现使用独享API客户端,reset只短暂预留既有TaskExecutionMutex。原scheduler继续唯一负责claim/start/真实PDD操作及Outbox,编排不持整轮锁、不新建执行器路径。
|
||||
- 新工作查询同时检查采集next、采购next及本设备活动状态历史,防止采购next优先返回本轮current而遮蔽其他pending。停止信号为原子roundId,配置/身份冻结,仅内存保存摘要;进程重启不恢复整轮,既有单任务恢复及失败私有诊断不变。
|
||||
- `AgentApiClient` 仅补读已有采购payload的attemptNumber和标准HTTP Date响应元数据;不添加服务端字段或API。Date缺失清空样本,不沿用手机时间。
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Business-Rules-and-Glossary
|
||||
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Business-Rules-and-Glossary.-
|
||||
wiki_revision: 25fdd0c4837882dccb27c7b9e740d6d97065d490
|
||||
synchronized_at: 2026-10-08T03:25:26Z
|
||||
wiki_revision: c4870fd01a4569059fc0afc77179d14fbd286384
|
||||
synchronized_at: 2026-10-08T10:15:21Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
<!-- gitea-wiki-mirror:start -->
|
||||
@@ -213,7 +213,7 @@ Admin 的「创建时间」范围按 SYB 货运单列表的 `t_stock.created`(
|
||||
- 浏览器“打开拼多多APP”和系统确认框“打开”属于允许动作。
|
||||
- 登录失效、验证码、风控、人机验证和找不到唯一控件时失败并给出具体错误。
|
||||
- Android Agent 到 GoAuto 服务端默认使用 HTTPS。管理员可在明确接受 Device Token、任务内容、设备状态和执行结果被链路监听或篡改的风险后,通过服务端 `GOAUTO_ALLOW_INSECURE_AGENT_HTTP=true` 显式允许全部 `/api/agent/v1/**` 使用 HTTP;该例外不放宽 PDD、SYB、OCR 或其他第三方服务约束。
|
||||
- Android Agent 端不使用 OCR/VLM 兜底,不保存原始控件树或截图。采集阶段不猜测缺失数据。
|
||||
- Android Agent 端不使用 OCR/VLM 兜底;普通采集不保存原始控件树或截图,采集阶段不猜测缺失数据。采购失败私有现场诊断是 #364 的限定例外,见本页对应节;历史各节的结构化日志/普通接口禁存规则保持不变。
|
||||
- 服务端顺云宝(SYB)登录例外:允许调用配置的线上 OCR 服务识别登录验证码(见 [#48](https://git.ilapage.cn/OPC/goauto/issues/48))。验证码图片会离开本项目发送到该服务,更换服务地址前必须重新评估。此例外只适用于 SYB 登录,不扩大到 Agent 端或任何 PDD 相关流程。
|
||||
- 采购阶段的规格匹配由服务端决策(见 [#46](https://git.ilapage.cn/OPC/goauto/issues/46)、[#62](https://git.ilapage.cn/OPC/goauto/issues/62)):Agent 本地不得自行猜测规格或点击相近候选,只执行服务端下发的精确规格;AI Provider 只有一个 OpenAI-compatible 配置,由管理员维护。根据 #62 已确认的内部部署例外,API Key 明文保存在专用设置表,并只向管理员设置接口返回以便查看和替换;它仍不得写入代码、日志、工单、Wiki、任务快照、采购员接口或 Android 接口。Provider Base URL 不限制内网或公网,支持 HTTP/HTTPS;HTTP 不加密传输中的 API Key,生产环境建议 HTTPS。
|
||||
- 采购规则的 `openSpecPanel` 默认不写死页面文字。Agent 只在已由页面语义确认的规格入口或底部购买入口中选择;规则若提供 `textAliases`,只能进一步缩小这些安全候选,不能把任意同名页面文字变成可点击入口。
|
||||
@@ -846,3 +846,42 @@ Android 0.9.64 / versionCode 77,源码 `6550b9f`(分支实现,尚未安装
|
||||
- 正常完成或预算退出只保存最后已确定处理/跳过的位置;页中提前退出不跳到预取末尾。确实消费完末页才回绕0;下轮/进程重启从最近已终结且有有效游标的运行续扫,NULL不是有效游标,0是有效回绕点。
|
||||
- 单运行所有权在分页续期、领取、Provider调用及保存映射时检查。失租旧运行不能继续领取或覆盖新owner;基础设施错误或失租不提交新游标。
|
||||
- completed只表示本轮正常结束,处理0件可能合法;scanned为实际检查数,不是预取数或全表数,processed不是成功数,confirmed/unmatched是规格项数。商品变化后可能需要等扫描回绕,不保证固定小时内全部处理。
|
||||
|
||||
|
||||
## 采购失败现场私有诊断(#364,尚未发布)
|
||||
|
||||
本节是用户已确认的原始控件树禁存规则限定例外,仅适用于采购 `spec_probe/purchase` 本次失败、结果待核对及有真实上下文的执行阶段异常。普通采集及此前各业务模块的普通日志/结果接口不因此放宽。
|
||||
|
||||
- 不采集正常执行的连续帧,不抓成功结果;空探测在 demote 后失败才进入诊断。已知成功结果即使后续本地保存、提示或结果上传异常也不新增失败快照。
|
||||
- 使用实际已领取/开始的 task/device/attempt UUID/phase/规则快照哈希;无有效归属只留本机固定原因,无效hash如实标为无效,不为诊断伪造事实。前置失败只留未进入执行/无障碍不可用等状态,不把Agent页面当作PDD现场。
|
||||
- 原失败上传重试只复用原ZIP。进程中断不补拍当前页冒充原现场;已有恢复核单失败可生成首次 `source=recovery` ZIP,记录恢复实际时间。恢复前的API/网络异常不抓,恢复成功不抓,不新增任何恢复点击、下单或任务重试。
|
||||
- 原始订单/地址节点等可能含个人数据,只存手机私有诊断SQLite和服务端私有数据库,只允许管理员下载。不得写入普通日志、工单、Wiki、SynapBus、普通任务结果或采购员接口;不得保存整屏截图。密码标识为true、缺失或不可读时,内容文字及其编码备份、action label均不保存。
|
||||
- 每attempt最多保留一份ZIP,已有ZIP不可覆盖;只有无ZIP未截取记录可升级为首次恢复ZIP。以该现场 recordedAt 起保留30天,重复上传不续期;新恢复ZIP采用真实恢复时刻。两端定期清理,手机快照逻辑总量64MiB,淘汰时同时终止上传队列。清理不修改采购结果。
|
||||
- 管理员采购详情可见完整/部分/未截取/暂无现场数据及安全下载入口;普通采购员、售后、客户端密钥均无原始诊断下载权限。
|
||||
- 1500ms、5000节点、2MiB ZIP、8MiB展开、128窗口与深度保护是合成测试覆盖的开发边界,未经过代表性真机样本验收;单次系统Binder读取不能被这些前后检查强行中断。未知/部分必须如实显示,不宣称拿到了系统未暴露的节点。
|
||||
|
||||
## 图搜采集部分完成的自动关联资格(#368)
|
||||
|
||||
实现基线:工单分支 `fix/368-image-search-partial-link` 提交 `15ccab3`(2026-10-08);尚未合并 main 或发布,生产是否启用以发布记录为准。
|
||||
|
||||
- 此规则仅作用于 `source=image_search` 的结果后置自动关联。正常 `completed` 沿用原行为,包括 `missing_json=[]`。
|
||||
- `completed_partial` 仅在 `missing_json` 为可解析、非空字符串数组且每一项精确属于 `reviewCount`、`salesText`、`shopName` 时允许自动关联。三项均为可选描述信息。
|
||||
- partial 的空值、JSON null、空数组、非法 JSON、非字符串项、空项、未知项、颜色/尺码/价格/不支持维度缺失一律不放宽。不修剪字段名、不按前缀推测。
|
||||
- 保留原图搜快照、目标 PDD active、`OriginalPDDProductID` 乐观比较;人工改动后的关联不被覆盖。只有实际写入关联并返回 Linked,才进入既有规格同步和匹配流程。
|
||||
- 采集任务仍为 `completed_partial`,missing 保留,界面仍显示部分完成;关联成功不等于 AI 匹配成功。匹配无结果或不可用沿用原处理,不自动创建采购。
|
||||
- 不改变普通采集、手动当前页采集、手动关联、价格保护、映射算法、同步后置 hook 的耗时及重放机制。同图多结果沿用原提交顺序和乐观比较,不新增历史筛选、补关联或回退旧商品。
|
||||
- 验证边界:自动化测试使用隔离 SQLite 与本地模拟 ERP 服务验证实际 SubmitResult、关联及映射落库;未以此宣称 MySQL、真实 AI、真机或线上发布验证。请求在匹配开始后取消时,匹配使用原有脱钩 context 继续;最后 Detail 读取仍使用原请求 context,可能向断开的调用端返回错误,已提交数据不因此回滚。
|
||||
|
||||
|
||||
## Android 本设备最后一批重购(#367)
|
||||
|
||||
实现绑定 `5430dc1f64fc0a4d8ef124ada7356e21a975151f`(2026-10-08,工单分支 `feat/367-last-batch-repurchase`);未合并 main、未安装或发布,真机批量采购仍待独立授权验收。
|
||||
|
||||
- 采购页搜索栏的“回填”入口替换为“重购”。原回填弹窗、Service 动作和实现保留但不再由该按钮触发;原单条重试、继续采购不改。重购使用原任务的 `/reset`,保留 CG 编号,不使用创建替代任务的 `/retry`。
|
||||
- 读取本设备近30天全部状态、不继承页面编号/状态筛选;按创建时间倒序、同刻任务ID倒序,从最新记录逐项比较相邻时间,不超过60秒归入同一推算批次。跨50条分页去重,最多200页,结束复核首页;头部或总量变化、日期/页结构异常、查询截断无法证明完整时不启动,不拿部分列表执行。批次时间边界使用独享API客户端的 HTTP Date,不使用手机时钟,缺少有效 Date 拒绝;此轻量方案不保证任意并发删除/归属变动的数据库快照。
|
||||
- 最后一批没有失败或失败均不可重试时只提示,不回退更早批次。一次确认显示范围、总数、失败/可重购/跳过数量及真实待付款订单风险;系统永不付款。每轮冻结 `failed && retryable` 候选,每条只重置一次,本轮再次失败不重新入队;下次点击重新发现并确认剩余任务。
|
||||
- 每条前重新检查当前资格、设备身份/Origin、网络、无障碍、新工作与本地执行/结果上传边界;不额外要求已存在完整映射,沿用服务端 unresolved 规格探测资格。明确业务资格拒绝可跳过;环境、规则、鉴权或结果未知停止后续。
|
||||
- 每条固定 requestId;重置响应不明最多同ID发两次,仍不明确停止,不换UUID或把旧failed认成本次结果。本地短时忙碌且明确未发出请求时最多等待10秒复核;重放pending不是实时状态。后续 attempt 只有原执行器记录到连续探测/重匹配完成→正式采购启动的证据链才属于本轮,外部同任务另起attempt停止编排。
|
||||
- Service 独立串行后台调度观察,真正领取、执行、上传仍由原执行器和租约/任务锁负责。终态且执行锁、活跃记录和Outbox均收尾后才继续。检测新采集或采购工作停止剩余,本轮当前task本身不算新工作。
|
||||
- 页面和通知栏“停止重购”只停止后续,已提交/执行的任务沿用原流程;切Tab、切PDD、界面重建不中断。单条单调时钟等待上限15分钟,超时只停止编排,不取消任务、不重置或再次下单。进程死亡/服务重建不恢复整轮,原单任务恢复不改。
|
||||
- 本轮摘要与最小attempt关联仅在内存,不新增地址/树采集或数据库。接口、服务端、Web、权限及 #364 诊断契约不变。自动化回归和APK构建通过不代表真机页面/批量下单已验收。
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Local-Development-and-Verification
|
||||
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Local-Development-and-Verification.-
|
||||
wiki_revision: 03ea269058b50fea2be7842b9c284018987c82d9
|
||||
synchronized_at: 2026-09-21T08:14:42Z
|
||||
wiki_revision: 4349298c740c0cb94d8c6bf33423c9c7965ff1c7
|
||||
synchronized_at: 2026-10-08T07:10:50Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 本地开发与验证
|
||||
@@ -350,3 +350,15 @@ node --check popup.js
|
||||
```
|
||||
|
||||
自动化测试只使用合成 DOM 与 Chrome API mock,覆盖列表商品行负向选择、双展开与延迟字段、解析/冲突、启动互斥、停止、错误终态、扫描延迟增长、详情恢复、提交恢复、冻结配置、超时和可重试项。真实 PDD 浏览器点击、扩展安装和真实 Admin 上传不属于自动化验证,必须取得独立授权并在结果中明确区分。
|
||||
|
||||
|
||||
## 采购失败现场诊断验证(#364)
|
||||
|
||||
实现 `4581ee5` 的合成验证不连接PDD、不创建订单、不付款、不读取真实个人数据。安装、真实现场抓取和上线须另行授权。
|
||||
|
||||
- Android:`cd android; .\\gradlew.bat test assembleDebug`;重点类为 FailureSnapshotExporterTest、FailureSnapshotCaptureTest、FailureSnapshotPolicyTest、FailureSnapshotUploadTest、FailureSnapshotRepositoryTest、AgentDiagnosticStoreMigrationTest。旧结构化诊断实际无障碍回调到Android SQLite的真机闭环仍须另验,不能用JVM测试冒称真机问题已解决。
|
||||
- 跨端:仅测试时设置 `GOAUTO_DIAGNOSTIC_FIXTURE_DIR` 指向开发构建输出目录,运行 Android `FailureSnapshotExporterTest` 生成纯合成 `manifest.json/archive.zip`;保持同一环境目录后在 `server/` 执行 `go test ./app/goauto/purchase -run TestFailureSnapshotAndroidExporterContract -count=1`。这验证真实Android导出器生成的字节被Go校验器接受;未设置变量时该测试明确skip。禁止以真实下载ZIP替代该合成fixture,输出不提交Git。
|
||||
- Server:`go test ./app/goauto/purchase ./app/goauto/access ./app/goauto/migrations ./common/middleware ./cmd/migrate/migration/version-local ./cmd/api`;覆盖所有权、成功/待执行attempt拒绝、终态补传、幂等/恢复升级、密码内容排除、ZIP/XML/HTTP容量、日志排除及空闲清理。
|
||||
- Web:构建后本地preview,使用 `PLAYWRIGHT_TEST_BASE_URL=http://localhost:<端口>`(合成cookie域为localhost),执行 `pnpm exec playwright test tests/e2e/purchase-failure-snapshots.spec.ts tests/e2e/purchase-order-writeback.spec.ts --workers=1`;普通采购员角色为 `purchaser`。
|
||||
- 迁移:`1791400000000_purchase_failure_snapshot.go` 仅追加私有表与版本记录,提供 `MigratePurchaseFailureSnapshot` 幂等入口;专用API依赖既有启动权限对账。独立测试 `TestFailureSnapshotAuthorizedLocalMySQL` 须获得本地初始化/权限写入授权后显式注入 `GOAUTO_364_LOCAL_MYSQL_DSN`;硬限制本机 `127.0.0.1:3308/goauto` 且采购任务为空。它执行追加迁移、完整既有API/采购员权限对账,并以事务回滚的合成数据验证64KiB manifest/2MiB BLOB回读与attempt唯一。不是只读测试;不得对线上或已有业务库运行,不输出DSN。
|
||||
- 服务维护:Server清理启动立即运行并每小时运行;Android前台服务启动及每60秒维护,无新任务也清理。无需新增定时任务或开关。通过管理员专用下载路由取得ZIP,禁止把私有诊断表或下载目录暴露为静态资源。
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Android-Agent-API-Contract
|
||||
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Android-Agent-API-Contract.-
|
||||
wiki_revision: f97ac6b8784fc200ee68e9e2eb4a1201296049f0
|
||||
synchronized_at: 2026-10-08T03:25:37Z
|
||||
wiki_revision: 2102252a3f0ecb646b49941cc603423abb76ca2c
|
||||
synchronized_at: 2026-10-08T07:11:01Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
<!-- gitea-wiki-mirror:start -->
|
||||
@@ -1493,3 +1493,40 @@ Web 输入去重后一个值提交旧标量、多个值提交重复集合键,
|
||||
- `stopReason`:旧记录默认空字符串;完成原因是`batch_limit`、`scan_budget`、`time_budget`、`end_of_scan`、`lease_lost`或`error`。失租旧进程不能为填此字段越权更新;合法回收路径标记lease_lost。
|
||||
- `scannedCount`改为实际检查的候选数;SQL已过滤的空档案及预取未检查项不计入。eligibleCount为Go资格通过数,processedCount为实际领取处理数;confirmedCount/unmatchedCount仍为规格项数,不能据此直接混算商品成功率。
|
||||
- 单轮处理默认20、每页200、实际扫描上限2000、整轮预算10分钟。正常0处理仍可completed,预算退出有持久游标;错误/失租不提交新游标。
|
||||
|
||||
|
||||
## 采购失败现场诊断 v1(#364,实现 4581ee5,2026-10-08)
|
||||
|
||||
本节契约在 `4581ee5` 实现,后续合并记录见 #364;尚未发布或完成真机验收。只覆盖采购spec_probe/purchase的本次失败/结果待核对,不修改正常capture、采购结果/不可逆状态;成功结果后网络/提示异常不抓取。普通采集不纳入。每attempt UUID最多一份ZIP;重启不能补拍当前页面冒充原现场,恢复核单现场必须source=recovery。
|
||||
|
||||
### 专用接口
|
||||
|
||||
- POST `/api/agent/v1/purchase-tasks/{taskId}/attempts/{attemptId}/failure-snapshot`:Device Token鉴权及现有Agent传输策略。流式multipart,`metadata` UTF-8 JSON(≤64KiB),`archive` 可选ZIP(≤2MiB);不得写临时原始文件。验证attempt持久化的task/device/phase,不依赖task当前设备或running状态,终态允许异步补传。响应沿用Agent JSON envelope,data含taskAttemptId/status/replayed。
|
||||
- GET `/api/admin/v1/purchase-tasks/{taskId}/failure-snapshots`:仅管理员,返回`{code:200,data:{items:[...]}}`;items仅attemptId/phase/status/source/errorCode/agentVersion/capturedAt/recordedAt/expiresAt/partial/reasons/downloadAvailable/zipSize等安全标量,不包含窗口标题、节点或manifest原文。
|
||||
- GET `/api/admin/v1/purchase-tasks/{taskId}/attempts/{attemptId}/failure-snapshot/download`:仅管理员,附件ZIP,Content-Type application/zip、Cache-Control no-store、nosniff,服务端生成安全文件名。无快照/过期不可下载,普通采购员/API Key及普通Agent业务接口不得读取。不得在线渲染XML。
|
||||
|
||||
### metadata / ZIP
|
||||
|
||||
schemaVersion=1、attributeVersion=1;taskId/deviceId正整数、taskAttemptId UUID、phase为spec_probe或purchase;ruleSnapshotHash为有效SHA256或null,ruleSnapshotHashValid明确标记;有效时必须与attempt快照hash相同,无效时仅not_captured并记录rule_hash_invalid,不伪造hash。errorCode为有界代码,agentVersion为有界版本;recordedAt/capturedAt为UTC RFC3339,未截取capturedAt=null。source为execution/recovery;status为complete/partial/not_captured;reasons固定代码数组;windows数组含id/fileName/status及可获取的窗口属性。窗口标题等实际文本只存私有诊断内容,不出现在Admin摘要。
|
||||
|
||||
ZIP含manifest.json(与metadata语义一致)和每个可取得root窗口的window-<id>.xml,每个XML仅一个hierarchy根,禁止DTD/外部实体/路径穿越/重复条目;保留系统原顺序及不可见节点。窗口缺失/属性失败/节点数/字节数/耗时超限明确partial且仍可解析。password节点所有可能承载内容的文字字段均省略(text/contentDescription/hint/stateDescription/tooltip/action label等);不反射任意extras。标准UIAutomator属性名,扩展属性合法;XML非法字符采用显式可逆编码,不能丢弃。schema区分空值、不支持和读取失败。
|
||||
|
||||
未截取时只上传metadata,无ZIP,不制造空XML;无attempt只保存本机最小原因,不上传虚构归属。服务端解压后总量≤8MiB,不能只相信ZIP声明大小;按白名单条目流式有界验证,异常不回显正文。
|
||||
|
||||
### 存储、幂等及清理
|
||||
|
||||
Android复用诊断库升级至v4,新独立快照/待上传/实际执行上下文表;旧结构化诊断保持7天/50条,新快照开发容量64MiB和30天独立清理,队列随过期/淘汰终止;无新任务也定期清理。结果上报优先,上传单独线程/持久队列,失败不改变采购结果或阻塞设备释放;重传只复用已存字节。每attempt已有ZIP不得覆盖;仅无ZIP未截取状态允许后续该attempt首次恢复核单ZIP,不能阻止真实恢复现场。
|
||||
|
||||
Server追加迁移 `1791400000000_purchase_failure_snapshot.go` 建立purchase_failure_snapshot独立表(ZIP LONGBLOB、私有manifest LONGTEXT、归属与安全摘要,attempt UUID唯一)。同一有效载荷重放返回replayed=true;已有ZIP不同载荷409且保留原件;终态补传有效。按recordedAt起30天,上传不续期,过期补传410供客户端停止;清理启动立即执行并每小时,无任务也执行,下载查询亦拒绝已过期数据。使用既有管理员身份和专用API注册,不给采购员开放权限。
|
||||
|
||||
2MiB/8MiB/64MiB是经工单声明的开发测试起点;节点/耗时开发保护暂为5000节点、1500ms,采样前后检查,无法中断系统单次Binder读取的事实须记录;deadline后停止遍历,不异步继续抓下一任务。上述不是代表性真机实测结论,正式现场验收前仍需确认。上线/安装/真机抓取另行授权。
|
||||
|
||||
### v1 编码与队列细节
|
||||
|
||||
- windows 条目含 id、fileName(可空)、type、layer、title、active、focused、bounds、packageName、activityName、capturedAt、status、reasons、attributeStates;无 root 不制造空 XML。未暴露属性不得猜测。
|
||||
- XML 节点标准属性含 text/resource-id/class/package/content-desc/checkable/checked/clickable/enabled/focusable/focused/scrollable/long-clickable/password/selected/bounds;扩展 path、child-count、visible-to-user、editable、hint、state-description、tooltip、input-type、max-text-length、drawing-order,collection-* 与 range-* 为扁平字段;node 下的 action 子元素保留 id/label。版本1公开属性名单以 SnapshotXml.attributes 为准,不反射 extras。
|
||||
- 非值属性使用 `<属性名>-state=null|unsupported|read_error` 区分空值、不支持、读取失败;XML非法代码单元使用 `<属性名>-base64` 保存原始 UTF-16BE 字节的 Base64(包括未配对 surrogate)。换行/回车/Tab 使用数值实体。密码标识为 true、缺失或读取失败时,文字字段、状态/编码备份及 action label 均省略;只有显式 password=false 才允许保存内容,子 action 不得覆盖父节点保护。
|
||||
- 固定 reasons:rule_hash_invalid、execution_not_entered、accessibility_unavailable、process_interrupted、windows_unavailable、windows_unsupported、window_root_null、window_disappeared、window_read_error、node_read_error、attribute_unsupported、node_limit、time_limit、expanded_size_limit、archive_size_limit、capture_failed。
|
||||
- 有ZIP的新上传只接受相符的非成功执行 attempt;不能向 pending/成功 attempt 新增失败诊断,已有有效载荷重放仍幂等。manifest 与 multipart metadata 语义相同;ZIP拒绝未知条目、重复路径、加密/未知压缩、CRC错误,XML拒绝DTD和处理指令,除限定XML声明。
|
||||
- 本地队列绑定 Server Origin 和载荷 fingerprint,切换服务器不向新服务器转交旧ZIP;旧上传响应不删除后来首次恢复ZIP。每60秒维护,网络/408/429/5xx按60秒至1小时退避,410与其他永久4xx停止该队列项。结果Outbox优先,不因设备忙而永久饥饿;上传线程不持有任务互斥、不操作设备。
|
||||
- 诊断接口不进入通用请求正文日志;私有SQL记录器不输出manifest/BLOB。管理员摘要不包含窗口和节点内容。默认HTTP例外只沿用既有Agent设置,不新增开关;传输私有原始内容时同样存在明文风险,部署须确认现有传输策略。
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Deployment-and-Operations
|
||||
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Deployment-and-Operations.-
|
||||
wiki_revision: db99427fa2460989d34d28ef864230a907fc5e6d
|
||||
synchronized_at: 2026-10-08T01:42:22Z
|
||||
wiki_revision: 6ca0b35cb1e8058b2930a9a01029822af8b571d2
|
||||
synchronized_at: 2026-10-08T07:10:52Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
<!-- gitea-wiki-mirror:start -->
|
||||
@@ -308,3 +308,15 @@ Provider 故障日志只允许记录调用关联 ID、操作类型、耗时、
|
||||
- 新版启动日志结构检查未见panic/fatal/1146/1054。服务器systemd较旧,读取PID用 `systemctl show goauto -p MainPID` 并解析键值,不使用不支持的 `--value`;journalctl起始时间使用服务器本地格式 `YYYY-MM-DD HH:MM:SS`。
|
||||
- Nginx配置检查通过,本次无配置变化、不需重启或reload;仅恢复发布目录可读性即恢复前端。真实多轮续扫、末尾回绕和AI实际匹配仍待自然运行验收,健康检查不等于业务效果验收。
|
||||
- 回滚代码目标 `/home/goauto/releases/20261007-eb7cb6b-363` 保留;重启前核验任务空闲,保留追加列与已经保存的映射,不恢复旧备份覆盖此后业务。
|
||||
|
||||
|
||||
## 采购失败现场诊断部署与保留(#364)
|
||||
|
||||
实现绑定 `4581ee5`;本节为新版本部署要求,不表示线上已迁移/发布。#365/#366 为Android局部行为,#366仅②编辑框就绪/hint保护已交付,①地址列表识别仍待现场证据。
|
||||
|
||||
1. 先取得目标环境的追加迁移及发布授权,核对唯一待执行版本 `1791400000000_purchase_failure_snapshot.go`,建立独立私有诊断表,再启动配套Server/Web。专用摘要/ZIP下载注册到既有启动权限对账,只允许管理员;对账会维护完整API目录及采购员既有权限,不是只写两条接口。
|
||||
2. Server接收与权限验证就绪后再安装新Agent。旧Server不支持新诊断接口;不要把“本机保存成功”当作“已上传”。仅合并main不启动服务、不执行线上迁移、不自动安装APK。
|
||||
3. Server启动立即清理过期诊断,其后每小时维护;Android前台服务启动及每60秒维护。两端默认30天,手机私有诊断逻辑容量64MiB;无需Admin定时任务或新开关,清理不修改采购任务与订单事实。不得把诊断ZIP/数据库映射成静态目录。
|
||||
4. 管理员从采购详情执行记录下载ZIP;无现场、未截取、partial、上传中/失败应按实际结果区分。排错日志仅固定错误分类,禁止复制ZIP内容到日志、工单、Wiki或消息;分析只摘必要脱敏结构。沿用现有Agent HTTPS/显式HTTP例外,HTTP上传有个人数据明文风险。
|
||||
5. 代码回滚时保留追加表和已存数据,不降级或删除手机数据库来恢复;回滚到尚不识别schema v4的旧Agent须先单独评估,不承诺可直接覆盖安装。保留期限清理须有仍在运行的相应新版维护组件。
|
||||
6. 本机换机后的数据库经用户授权初始化于 `127.0.0.1:3308/goauto`,配置仍来自仓库根目录已忽略的config.yaml;已完成baseline迁移及#364幂等、容量、权限验证,未启动业务服务或连接线上。不是将历史3307运行说明套用于所有环境;实际Supervisor目录/发布按目标环境另核对。
|
||||
|
||||
@@ -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},
|
||||
|
||||
@@ -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&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 two	&😀" 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
+93
@@ -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")
|
||||
}
|
||||
}
|
||||
+65
@@ -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")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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([])
|
||||
})
|
||||
Reference in New Issue
Block a user