Compare commits

...
11 changed files with 478 additions and 32 deletions
@@ -249,7 +249,16 @@ class PurchaseRehearsalExecutor(
pddForegroundObserved = true
val screen = PddScreenParser.parse(snapshot, DEFAULT_COLLECTOR, input.goodsId, null)
stableEvidenceReads = if (screen.hasPurchaseProductEvidence()) stableEvidenceReads + 1 else 0
if (stableEvidenceReads >= PRODUCT_PAGE_STABLE_READS) return null
if (stableEvidenceReads >= PRODUCT_PAGE_STABLE_READS) {
// A product-page accessibility tree can appear before PDD has
// finished wiring click listeners and bottom-sheet transitions.
// Give the page one short settle window, then require the
// evidence again before moving to the spec entry.
pause(PRODUCT_PAGE_SETTLE_MILLIS)
val settled = PddScreenParser.parse(driver.capture(), DEFAULT_COLLECTOR, input.goodsId, null)
if (settled.hasPurchaseProductEvidence()) return null
stableEvidenceReads = 0
}
pause(OPEN_PRODUCT_POLL_MILLIS)
return@repeat
}
@@ -295,7 +304,10 @@ class PurchaseRehearsalExecutor(
if (screen.hasPurchaseProductEvidence()) {
stableEvidenceReads++
if (stableEvidenceReads >= PRODUCT_PAGE_STABLE_READS) {
return recoverSoldOut(input, screen)
pause(PRODUCT_PAGE_SETTLE_MILLIS)
val settled = PddScreenParser.parse(driver.capture(), DEFAULT_COLLECTOR, input.goodsId, null)
if (settled.hasPurchaseProductEvidence()) return recoverSoldOut(input, settled)
stableEvidenceReads = 0
}
} else {
stableEvidenceReads = 0
@@ -341,6 +353,9 @@ class PurchaseRehearsalExecutor(
var screen = currentScreen(input)
var entryReadyWaitPolls = 0
var target: SnapshotNode? = null
var stableTargetPath: String? = null
var stableTargetBounds: NodeBounds? = null
var stableTargetReads = 0
while (target == null) {
if (screen.reviewPageOpen) return leaveUnexpectedReviewPage(input)
screen.problem?.let { return failure(it.code, it.message) }
@@ -359,8 +374,38 @@ class PurchaseRehearsalExecutor(
panelDiagnostic(specEntryEvidence(screen, candidates.size, entryReadyWaitPolls))
return failure(SPEC_ENTRY_TARGET_AMBIGUOUS, "规格入口候选不唯一 [${specEntryEvidence(screen, candidates.size, entryReadyWaitPolls)}]")
}
target = candidates.singleOrNull()?.second
if (target != null) continue
val candidate = candidates.singleOrNull()?.second
if (candidate != null) {
val unchanged = candidate.path == stableTargetPath && candidate.bounds == stableTargetBounds
stableTargetReads = if (unchanged) stableTargetReads + 1 else 1
stableTargetPath = candidate.path
stableTargetBounds = candidate.bounds
if (stableTargetReads >= SPEC_ENTRY_STABLE_READS) {
// Reacquire once more immediately before clicking so a node
// rebuilt during the settle window is never reused.
val latest = currentScreen(input)
val latestCandidates = listOfNotNull(
latest.specEntry?.let { anchor -> anchor to (latest.specEntryClickTarget ?: anchor) },
latest.quickConfirmationEntry?.let { it to it },
).distinctBy { it.second.path }
.let { candidatesToFilter ->
action.textAliases?.let { aliases ->
candidatesToFilter.filter { (anchor, _) -> specEntryMatchesAliases(latest, anchor, aliases) }
} ?: candidatesToFilter
}
if (latestCandidates.size > 1) {
return failure(SPEC_ENTRY_TARGET_AMBIGUOUS, "规格入口点击目标不唯一 [${specEntryEvidence(latest, latestCandidates.size, entryReadyWaitPolls)}]")
}
target = latestCandidates.singleOrNull()?.second
if (target != null) screen = latest
if (target != null) continue
stableTargetReads = 0
}
} else {
stableTargetReads = 0
stableTargetPath = null
stableTargetBounds = null
}
if (entryReadyWaitPolls >= SPEC_ENTRY_READY_WAIT_POLLS) {
panelDiagnostic(specEntryEvidence(screen, 0, entryReadyWaitPolls))
return failure(SPEC_ENTRY_NOT_FOUND, "没有找到安全的商品规格入口 [${specEntryEvidence(screen, 0, entryReadyWaitPolls)}]")
@@ -385,11 +430,58 @@ class PurchaseRehearsalExecutor(
var wait = waitForSpecPanel(input, beforeSignature)
wait.failure?.let { return it }
if (wait.opened) return null
var gestureBaseline = wait.screen
if (wait.changed) {
return failure(
SPEC_PANEL_EVIDENCE_NOT_MATCHED,
"规格入口点击后页面已变化,但规格面板强证据不足 [${panelEvidence(wait.screen)}]",
)
// A page transition can be caused by PDD rerendering the entry before
// the bottom sheet becomes observable. Re-locate the fresh entry and
// allow exactly one controlled retry while still on the same product.
if (!wait.screen.isPddPackage || !wait.screen.pageEvidenceMatched) {
return failure(SPEC_PANEL_EVIDENCE_NOT_MATCHED, "规格入口点击后页面已变化,但规格面板强证据不足 [${panelEvidence(wait.screen)}]")
}
val retryScreen = currentScreen(input)
if (!retryScreen.isPddPackage || !retryScreen.pageEvidenceMatched || retryScreen.specPanelOpen) {
return failure(SPEC_PANEL_EVIDENCE_NOT_MATCHED, "规格入口点击后页面已变化,但规格面板强证据不足 [${panelEvidence(retryScreen)}]")
}
val retryCandidates = listOfNotNull(
retryScreen.specEntry?.let { anchor -> anchor to (retryScreen.specEntryClickTarget ?: anchor) },
retryScreen.quickConfirmationEntry?.let { it to it },
).distinctBy { it.second.path }
.let { candidatesToFilter ->
action.textAliases?.let { aliases ->
candidatesToFilter.filter { (anchor, _) -> specEntryMatchesAliases(retryScreen, anchor, aliases) }
} ?: candidatesToFilter
}
if (retryCandidates.size != 1) {
return failure(SPEC_PANEL_EVIDENCE_NOT_MATCHED, "规格入口点击后页面已变化,但规格面板强证据不足 [${panelEvidence(retryScreen)}]")
}
target = retryCandidates.single().second
screen = retryScreen
gestureBaseline = retryScreen
}
// Give PDD's bottom-sheet re-render a short settle window after an
// accessibility click that produced no observable transition, then
// reacquire the target before the single controlled gesture retry.
if (!wait.changed) {
pause(SPEC_ENTRY_GESTURE_RETRY_SETTLE_MILLIS)
val refreshed = currentScreen(input)
if (!refreshed.isPddPackage || !refreshed.pageEvidenceMatched || refreshed.specPanelOpen) {
return failure(SPEC_PANEL_EVIDENCE_NOT_MATCHED, "规格入口点击后页面已变化,但规格面板强证据不足 [${panelEvidence(refreshed)}]")
}
val refreshedCandidates = listOfNotNull(
refreshed.specEntry?.let { anchor -> anchor to (refreshed.specEntryClickTarget ?: anchor) },
refreshed.quickConfirmationEntry?.let { it to it },
).distinctBy { it.second.path }
.let { candidatesToFilter ->
action.textAliases?.let { aliases ->
candidatesToFilter.filter { (anchor, _) -> specEntryMatchesAliases(refreshed, anchor, aliases) }
} ?: candidatesToFilter
}
if (refreshedCandidates.size != 1) {
return failure(SPEC_ENTRY_CLICK_FAILED, "gesture_failed_after_action_click_no_effect")
}
target = refreshedCandidates.single().second
gestureBaseline = refreshed
}
when (driver.tapSpecFresh(requireNotNull(target))) {
@@ -403,7 +495,7 @@ class PurchaseRehearsalExecutor(
click.reason.specEntrySubreasonAfterGestureFailure(),
)
}
wait = waitForSpecPanel(input, specActionSignature(wait.screen))
wait = waitForSpecPanel(input, specActionSignature(gestureBaseline))
wait.failure?.let { return it }
if (wait.opened) return null
if (wait.changed) {
@@ -477,6 +569,7 @@ class PurchaseRehearsalExecutor(
private fun specEntryEvidence(screen: ParsedPddScreen, candidateCount: Int, entryReadyWaitPolls: Int = 0): String =
"specEntryCandidates=$candidateCount;explicit=${screen.explicitSpecEntryCount};" +
"nested=${screen.nestedSpecEntryCount};bottomPurchase=${screen.bottomPurchaseEntryCount};" +
"source=${screen.specEntrySource ?: "none"};" +
"panelAlreadyOpen=${screen.specPanelOpen};reviewPage=${screen.reviewPageOpen};" +
"pageEvidence=${screen.pageEvidenceMatched};entryReadyWaitPolls=$entryReadyWaitPolls;" +
"entryReadyWaitMillis=${entryReadyWaitPolls * SPEC_ENTRY_READY_POLL_MILLIS}"
@@ -1161,11 +1254,14 @@ class PurchaseRehearsalExecutor(
private const val OPEN_PRODUCT_POLL_LIMIT = 150
private const val PRODUCT_PAGE_POLL_LIMIT = 150
private const val PRODUCT_PAGE_STABLE_READS = 2
private const val PRODUCT_PAGE_SETTLE_MILLIS = 1_000L
private const val OPEN_PRODUCT_RETRY_POLLS = 10
private const val OPEN_PRODUCT_POLL_MILLIS = 100L
private const val SPEC_ENTRY_READY_WAIT_POLLS = 20
private const val SPEC_ENTRY_STABLE_READS = 2
private const val SPEC_ENTRY_GESTURE_RETRY_SETTLE_MILLIS = 500L
private const val SPEC_ENTRY_READY_POLL_MILLIS = 100L
private const val SPEC_POST_CLICK_VERIFY_POLLS = 30
private const val SPEC_POST_CLICK_VERIFY_POLLS = 50
private const val SPEC_SELECTION_SUCCESS_VERIFY_POLLS = 20
private const val SPEC_SELECTION_FAILED_VERIFY_POLLS = 5
private const val SPEC_SELECTION_POLL_MILLIS = 100L
@@ -73,6 +73,7 @@ data class CurrentPageIdentity(
data class PurchaseAgentTask(
val taskId: Long,
val taskAttemptId: String,
val attemptNumber: Int,
val phase: String,
val executionMode: String,
val status: String,
@@ -589,6 +590,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", 1),
phase = data.optString("phase"),
executionMode = data.getString("executionMode"),
status = data.getString("status"),
@@ -1,7 +1,7 @@
package cn.ilapage.goauto.agent.persistence
internal object AgentDiagnosticSchema {
const val VERSION = 2
const val VERSION = 3
val colorDiagnosticColumns = linkedMapOf(
"color_row_count" to "INTEGER",
@@ -14,6 +14,15 @@ internal object AgentDiagnosticSchema {
"horizontal_swipe_count" to "INTEGER",
)
val purchaseFailureColumns = linkedMapOf(
"attempt_id" to "TEXT",
"device_id" to "INTEGER",
"error_code" to "TEXT",
"failure_message" to "TEXT",
"page_evidence" to "TEXT",
"last_step" to "TEXT",
)
val createTableSql =
"""CREATE TABLE agent_diagnostic (
id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -42,14 +51,33 @@ internal object AgentDiagnosticSchema {
initial_selected_size_count INTEGER,
selected_summary_present INTEGER,
horizontal_swipe_count INTEGER,
attempt_id TEXT,
device_id INTEGER,
error_code TEXT,
failure_message TEXT,
page_evidence TEXT,
last_step TEXT,
agent_version TEXT NOT NULL,
created_at INTEGER NOT NULL
)""".trimIndent()
fun v2MigrationStatements(oldVersion: Int, newVersion: Int, existingColumns: Set<String>): List<String> {
if (oldVersion >= 2 || newVersion < 2) return emptyList()
return colorDiagnosticColumns.mapNotNull { (name, definition) ->
if (name in existingColumns) null else "ALTER TABLE agent_diagnostic ADD COLUMN $name $definition"
fun migrationStatements(oldVersion: Int, newVersion: Int, existingColumns: Set<String>): List<String> {
if (oldVersion >= newVersion) return emptyList()
val statements = mutableListOf<String>()
if (oldVersion < 2 && newVersion >= 2) {
colorDiagnosticColumns.forEach { (name, definition) ->
if (name !in existingColumns) statements += "ALTER TABLE agent_diagnostic ADD COLUMN $name $definition"
}
}
if (oldVersion < 3 && newVersion >= 3) {
purchaseFailureColumns.forEach { (name, definition) ->
if (name !in existingColumns) statements += "ALTER TABLE agent_diagnostic ADD COLUMN $name $definition"
}
}
return statements
}
// Kept for existing migration tests and callers; new code should use migrationStatements.
fun v2MigrationStatements(oldVersion: Int, newVersion: Int, existingColumns: Set<String>): List<String> =
migrationStatements(oldVersion, newVersion, existingColumns)
}
@@ -4,6 +4,8 @@ import android.content.ContentValues
import android.content.Context
import android.database.sqlite.SQLiteDatabase
import android.database.sqlite.SQLiteOpenHelper
import org.json.JSONArray
import org.json.JSONObject
import cn.ilapage.goauto.agent.BuildConfig
enum class AgentDiagnosticStage {
@@ -71,6 +73,7 @@ enum class AgentDiagnosticReason {
LINK_AMBIGUOUS,
LINK_NOT_FOUND,
UNKNOWN,
PURCHASE_FAILURE,
}
data class AgentDiagnosticEvent(
@@ -99,6 +102,12 @@ data class AgentDiagnosticEvent(
val initialSelectedSizeCount: Int? = null,
val selectedSummaryPresent: Boolean? = null,
val horizontalSwipeCount: Int? = null,
val attemptId: String? = null,
val deviceId: Long? = null,
val errorCode: String? = null,
val failureMessage: String? = null,
val pageEvidence: String? = null,
val lastStep: String? = null,
val createdAt: Long = System.currentTimeMillis(),
)
@@ -118,7 +127,7 @@ internal object AgentDiagnosticRetentionPolicy {
fun cutoff(createdAt: Long): Long = createdAt - RETENTION_MILLIS
}
class AgentDiagnosticStore(context: Context) : SQLiteOpenHelper(context, DATABASE_NAME, null, DATABASE_VERSION) {
class AgentDiagnosticStore(private val context: Context) : SQLiteOpenHelper(context, DATABASE_NAME, null, DATABASE_VERSION) {
override fun onCreate(db: SQLiteDatabase) {
db.execSQL(AgentDiagnosticSchema.createTableSql)
db.execSQL("CREATE INDEX idx_agent_diagnostic_task ON agent_diagnostic(task_id, id)")
@@ -126,7 +135,7 @@ class AgentDiagnosticStore(context: Context) : SQLiteOpenHelper(context, DATABAS
override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) {
val existing = columnNames(db)
AgentDiagnosticSchema.v2MigrationStatements(oldVersion, newVersion, existing).forEach(db::execSQL)
AgentDiagnosticSchema.migrationStatements(oldVersion, newVersion, existing).forEach(db::execSQL)
}
private fun columnNames(db: SQLiteDatabase): Set<String> =
@@ -171,6 +180,12 @@ class AgentDiagnosticStore(context: Context) : SQLiteOpenHelper(context, DATABAS
putNullableInt("initial_selected_size_count", event.initialSelectedSizeCount)
putNullableBoolean("selected_summary_present", event.selectedSummaryPresent)
putNullableInt("horizontal_swipe_count", event.horizontalSwipeCount)
event.attemptId?.let { put("attempt_id", it.take(MAX_TEXT_CHARS)) }
putNullableLong("device_id", event.deviceId)
event.errorCode?.let { put("error_code", it.take(MAX_ERROR_CODE_CHARS)) }
event.failureMessage?.let { put("failure_message", sanitizeText(it)) }
event.pageEvidence?.let { put("page_evidence", sanitizeText(it)) }
event.lastStep?.let { put("last_step", it.take(MAX_TEXT_CHARS)) }
put("agent_version", BuildConfig.VERSION_NAME)
put("created_at", event.createdAt)
}
@@ -186,6 +201,53 @@ class AgentDiagnosticStore(context: Context) : SQLiteOpenHelper(context, DATABAS
}
}
/** Returns only the allow-listed, structured fields; raw accessibility data is never exported. */
@Synchronized
fun exportTaskJson(taskId: Long): String {
require(taskId > 0)
val events = JSONArray()
readableDatabase.query(
"agent_diagnostic",
arrayOf(
"task_id", "stage", "reason", "attempt", "elapsed_ms", "attempt_id", "device_id",
"error_code", "failure_message", "page_evidence", "last_step", "agent_version", "created_at",
),
"task_id = ?",
arrayOf(taskId.toString()),
null,
null,
"id ASC",
).use { cursor ->
while (cursor.moveToNext()) {
events.put(JSONObject().apply {
put("taskId", cursor.getLong(0))
put("stage", cursor.getString(1))
put("reason", cursor.getString(2))
put("attempt", cursor.getInt(3))
put("elapsedMs", cursor.getLong(4))
putNullable("attemptId", cursor, 5)
if (!cursor.isNull(6)) put("deviceId", cursor.getLong(6))
putNullable("errorCode", cursor, 7)
putNullable("message", cursor, 8)
putNullable("pageEvidence", cursor, 9)
putNullable("lastStep", cursor, 10)
put("agentVersion", cursor.getString(11))
put("createdAt", cursor.getLong(12))
})
}
}
return JSONObject().put("taskId", taskId).put("events", events).toString()
}
/** Writes a task export into app-specific external storage so it can be pulled over USB. */
@Synchronized
fun exportTaskJsonFile(taskId: Long, fileName: String = "task-$taskId.json"): java.io.File {
val safeName = fileName.replace(Regex("[^A-Za-z0-9._-]"), "_").take(96)
val directory = context.getExternalFilesDir("diagnostics") ?: error("诊断导出目录不可用")
if (!directory.exists() && !directory.mkdirs()) error("诊断导出目录创建失败")
return java.io.File(directory, safeName).apply { writeText(exportTaskJson(taskId), Charsets.UTF_8) }
}
private fun ContentValues.putNullableBoolean(key: String, value: Boolean?) {
value?.let { put(key, if (it) 1 else 0) }
}
@@ -194,11 +256,25 @@ class AgentDiagnosticStore(context: Context) : SQLiteOpenHelper(context, DATABAS
value?.let { put(key, it.coerceAtLeast(0)) }
}
private fun ContentValues.putNullableLong(key: String, value: Long?) {
value?.let { put(key, it) }
}
private fun JSONObject.putNullable(key: String, cursor: android.database.Cursor, index: Int) {
if (!cursor.isNull(index)) put(key, cursor.getString(index))
}
private fun sanitizeText(value: String): String = value
.replace(Regex("(?i)(addressSuffix|收货地址|详细地址)\\s*[:=:][^;,,\\]]+"), "[REDACTED]")
.take(MAX_TEXT_CHARS)
companion object {
private const val DATABASE_NAME = "goauto_diagnostics.db"
private const val DATABASE_VERSION = AgentDiagnosticSchema.VERSION
private const val MAX_CLASS_NAME_CHARS = 160
private const val MAX_ROW_VALUE_COUNTS_CHARS = 160
private const val MAX_TEXT_CHARS = 512
private const val MAX_ERROR_CODE_CHARS = 96
private val ROW_VALUE_COUNTS_PATTERN = Regex("[0-9]+(?:,[0-9]+)*")
private val ALLOWED_ZONES = setOf("top-left", "top-center", "top-right", "middle", "bottom")
}
@@ -445,7 +445,10 @@ class AgentForegroundService : Service() {
GoAutoAccessibilityService.instance?.dismissPurchaseResultBubble()
acquireTaskWakeLock()
var resultSafelyStored = false
var diagnosticRecorded = false
var activeTask = initial
val lastStep = AtomicReference("started")
val lastPanelEvidence = AtomicReference<String?>(null)
try {
val claimed = if (initial.status == "pending") {
api.claimPurchaseTask(initial.taskId, UUID.randomUUID().toString(), token)
@@ -453,6 +456,7 @@ class AgentForegroundService : Service() {
val task = if (claimed.status == "pending") {
api.startPurchaseTask(claimed.taskId, UUID.randomUUID().toString(), token)
} else claimed
activeTask = task
check(task.status == "running" && task.taskAttemptId.isNotBlank()) { "采购任务没有有效 attempt" }
val snapshotHashValid = task.ruleSnapshotHash.matches(Regex("^[0-9a-f]{64}$"))
val snapshotHash = task.ruleSnapshotHash.takeIf { snapshotHashValid } ?: "0".repeat(64)
@@ -486,7 +490,10 @@ class AgentForegroundService : Service() {
lastStep.set(step)
purchaseStore.updateStep(task.taskId, task.taskAttemptId, step)
},
panelDiagnostic = { evidence -> Log.i("GoAutoPurchasePanel", "task=${task.taskId};$evidence") },
panelDiagnostic = { evidence ->
lastPanelEvidence.set(evidence)
Log.i("GoAutoPurchasePanel", "task=${task.taskId};$evidence")
},
beforeOrderSubmit = { evidence ->
val boundaryRequestId = UUID.randomUUID().toString()
val finalEvidence = JSONObject()
@@ -526,6 +533,10 @@ class AgentForegroundService : Service() {
val payload = purchaseResultPayload(requestId, task.taskAttemptId, outcome)
purchaseStore.completeAndEnqueue(task.taskId, task.taskAttemptId, requestId, payload)
resultSafelyStored = true
if (outcome.resultType == "failed") {
recordPurchaseDiagnostic(task, outcome, lastStep.get(), lastPanelEvidence.get())
diagnosticRecorded = true
}
PurchaseResultBubblePolicy.create(
taskId = task.taskId,
resultType = outcome.resultType,
@@ -540,8 +551,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) {
if (!diagnosticRecorded) {
recordPurchaseDiagnostic(
activeTask,
PurchaseExecutionOutcome("failed", error.code, error.message ?: "采购接口调用失败"),
lastStep.get(),
lastPanelEvidence.get(),
)
}
stateStore.update("TASK_ERROR", "${error.code}:${error.message}", tokenStored = true)
} catch (error: Exception) {
if (!diagnosticRecorded) {
recordPurchaseDiagnostic(
activeTask,
PurchaseExecutionOutcome("failed", "AGENT_PURCHASE_EXCEPTION", error.message ?: "采购执行异常"),
lastStep.get(),
lastPanelEvidence.get(),
)
}
stateStore.update("TASK_ERROR", error.message ?: "采购演练执行异常", tokenStored = true)
} finally {
if (!resultSafelyStored) cancelIdleReturn("采购结果未安全保存")
@@ -549,6 +576,45 @@ class AgentForegroundService : Service() {
}
}
private fun recordPurchaseDiagnostic(
task: PurchaseAgentTask,
outcome: PurchaseExecutionOutcome,
lastStep: String,
panelEvidence: String?,
) {
if (task.taskId <= 0L) return
val deviceId = runCatching { identityStore.credentials()?.deviceId }.getOrNull()
val event = AgentDiagnosticEvent(
taskId = task.taskId,
stage = purchaseDiagnosticStage(lastStep),
reason = AgentDiagnosticReason.PURCHASE_FAILURE,
attempt = task.attemptNumber,
attemptId = task.taskAttemptId.takeIf { it.isNotBlank() },
deviceId = deviceId,
errorCode = outcome.errorCode,
failureMessage = outcome.message,
pageEvidence = panelEvidence,
lastStep = lastStep,
)
diagnosticExecutor.execute {
runCatching {
diagnosticStore.record(event)
val export = diagnosticStore.exportTaskJsonFile(task.taskId)
Log.i("GoAutoDiagnostic", "purchase diagnostic exported task=${task.taskId};file=${export.absolutePath}")
}.onFailure { error ->
Log.w("GoAutoDiagnostic", "purchase diagnostic export failed: ${error.javaClass.simpleName}")
}
}
}
private fun purchaseDiagnosticStage(step: String): AgentDiagnosticStage = when (step) {
"openProduct" -> AgentDiagnosticStage.DETAIL_ENTRY
"openSpecPanel" -> AgentDiagnosticStage.SPEC_PANEL_ENTRY
"selectSpec" -> AgentDiagnosticStage.SIZE_DISCOVERY
"verifyUnitPrice", "verifyOrderSummary" -> AgentDiagnosticStage.PAGE_STABILITY
else -> AgentDiagnosticStage.DETAIL_ENTRY
}
private fun collectPurchaseProbe(accessibility: GoAutoAccessibilityService, task: PurchaseAgentTask, purchaseRule: PurchaseRule): String? {
val snapshot = accessibility.capture()
val activity = snapshot.activityName ?: return null
@@ -788,6 +788,21 @@ class PurchaseRehearsalExecutorTest {
assertFalse(outcome.message.orEmpty().contains("确认款式"))
}
@Test
fun `changed product page retries a still visible spec entry once`() {
val driver = FakePurchaseDriver(
entryActionHasEffect = false,
entryActionChangesPageWithoutPanel = true,
specTapResult = FreshActionResult.SUCCESS,
)
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals(outcome.toString(), "rehearsal_completed", outcome.resultType)
assertEquals(1, driver.openEntryClickCount)
assertEquals(1, driver.specTapCount)
}
@Test
fun `unchanged spec entry action uses one verified center gesture`() {
val driver = FakePurchaseDriver(
@@ -846,7 +861,7 @@ class PurchaseRehearsalExecutorTest {
assertEquals("PURCHASE_SPEC_ENTRY_NOT_FOUND", outcome.errorCode)
assertEquals(
"specEntryCandidates=0;explicit=0;nested=0;bottomPurchase=0;panelAlreadyOpen=false;reviewPage=false;pageEvidence=true;entryReadyWaitPolls=20;entryReadyWaitMillis=2000",
"specEntryCandidates=0;explicit=0;nested=0;bottomPurchase=0;source=none;panelAlreadyOpen=false;reviewPage=false;pageEvidence=true;entryReadyWaitPolls=20;entryReadyWaitMillis=2000",
diagnostics.single(),
)
assertEquals(21, pauses.count { it == 100L })
@@ -864,7 +879,7 @@ class PurchaseRehearsalExecutorTest {
assertTrue(driver.clickedPaths.contains("buy"))
// One 100ms pause belongs to the existing open-product foreground poll;
// two belong to the entry-ready wait before the bottom bar appears.
assertEquals(3, pauses.count { it == 100L })
assertEquals(4, pauses.count { it == 100L })
}
@Test
@@ -877,7 +892,7 @@ class PurchaseRehearsalExecutorTest {
assertEquals("rehearsal_completed", outcome.resultType)
// Two stable reads belong to reopening the product; verifyProduct then
// independently requires its second stable read before continuing.
assertEquals(3, pauses.count { it == 100L })
assertEquals(4, pauses.count { it == 100L })
}
@Test
@@ -1042,7 +1057,7 @@ class PurchaseRehearsalExecutorTest {
assertEquals("PURCHASE_SPEC_ENTRY_NOT_FOUND", outcome.errorCode)
assertEquals(
"没有找到安全的商品规格入口 [specEntryCandidates=0;explicit=0;nested=0;bottomPurchase=0;panelAlreadyOpen=false;reviewPage=false;pageEvidence=true;entryReadyWaitPolls=20;entryReadyWaitMillis=2000]",
"没有找到安全的商品规格入口 [specEntryCandidates=0;explicit=0;nested=0;bottomPurchase=0;source=none;panelAlreadyOpen=false;reviewPage=false;pageEvidence=true;entryReadyWaitPolls=20;entryReadyWaitMillis=2000]",
outcome.message,
)
@@ -1052,7 +1067,7 @@ class PurchaseRehearsalExecutorTest {
assertEquals("PURCHASE_SPEC_ENTRY_TARGET_AMBIGUOUS", ambiguous.errorCode)
assertEquals(
"规格入口点击目标不唯一 [specEntryCandidates=1;explicit=1;nested=0;bottomPurchase=0;panelAlreadyOpen=false;reviewPage=false;pageEvidence=true;entryReadyWaitPolls=0;entryReadyWaitMillis=0]",
"规格入口点击目标不唯一 [specEntryCandidates=1;explicit=1;nested=0;bottomPurchase=0;source=explicit_selection;panelAlreadyOpen=false;reviewPage=false;pageEvidence=true;entryReadyWaitPolls=1;entryReadyWaitMillis=100]",
ambiguous.message,
)
}
@@ -1227,6 +1242,7 @@ class PurchaseRehearsalExecutorTest {
private val forcedEntryClickReason: FreshClickReason? = null,
private val initialSize: String? = null,
private val entryActionHasEffect: Boolean = true,
private val entryActionChangesPageWithoutPanel: Boolean = false,
private val specTapResult: FreshActionResult = FreshActionResult.FAILED,
private val specTapHasEffect: Boolean = true,
private val sizeSelectsOnFailedClick: Boolean = false,
@@ -1274,6 +1290,7 @@ class PurchaseRehearsalExecutorTest {
private var reviewPage = false
private var pddCaptureCount = 0
private var browserCaptureCount = 0
private var entryActionChanged = false
private var hiddenColorRestored = false
private var horizontalColorPage = 0
private var horizontalSizePage = 0
@@ -1347,6 +1364,7 @@ class PurchaseRehearsalExecutorTest {
} else if (!missingSpecEntry && specEntryReady) {
nodes += node("spec", "选择规格", 20, 1000, 900, 1100, clickable = true)
}
if (entryActionChanged) nodes += node("rerender", "页面已刷新", 20, 1100, 400, 1180)
if (includeReviewEntry) nodes += node("review", "商品评价", 20, 1200, 900, 1300, clickable = true)
return UiSnapshot(PDD, ACTIVITY, nodes)
}
@@ -1463,7 +1481,10 @@ class PurchaseRehearsalExecutorTest {
if (result == FreshActionResult.SUCCESS || openPddOnFailedClick) browser = false
return result
}
"选择规格", "免拼购买" -> if (entryActionHasEffect) panel = true
"选择规格", "免拼购买" -> {
if (entryActionChangesPageWithoutPanel) entryActionChanged = true
if (entryActionHasEffect) panel = true
}
in (horizontalSizePages.orEmpty().flatten() + sizes) -> {
sizeClickCount++
val result = sizeClickResults.removeFirstOrNull() ?: FreshActionResult.SUCCESS
@@ -1520,6 +1541,9 @@ class PurchaseRehearsalExecutorTest {
return FreshActionResult.SUCCESS
}
val openEntryClickCount: Int
get() = clicked.count { it == "选择规格" || it == "免拼购买" }
override fun inputFresh(target: SnapshotNode, value: String): FreshActionResult {
quantity = value.toLong()
return FreshActionResult.SUCCESS
@@ -83,6 +83,24 @@ class AgentDiagnosticStoreMigrationTest {
assertEquals(1, rowCount(db))
}
@Test
fun v2MigrationAddsPurchaseFailureColumnsWithoutDroppingRows() = withDatabase { db ->
db.createStatement().use { statement ->
statement.execute(CREATE_V1_TABLE_SQL)
statement.execute(
"INSERT INTO agent_diagnostic " +
"(task_id, stage, reason, attempt, elapsed_ms, agent_version, created_at) " +
"VALUES (153, 'SPEC_PANEL_ENTRY', 'PURCHASE_FAILURE', 2, 120, '0.9.99', 1000)",
)
}
AgentDiagnosticSchema.migrationStatements(1, 3, columnNames(db)).forEach { sql ->
db.createStatement().use { it.execute(sql) }
}
assertTrue(columnNames(db).containsAll(AgentDiagnosticSchema.purchaseFailureColumns.keys))
assertEquals(1, rowCount(db))
assertTrue(AgentDiagnosticSchema.migrationStatements(3, 3, columnNames(db)).isEmpty())
}
private fun migrateV1ToV2(db: Connection) {
AgentDiagnosticSchema.v2MigrationStatements(1, 2, columnNames(db)).forEach { sql ->
db.createStatement().use { it.execute(sql) }
+10 -2
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: SYB-ERP-Interface-Contract
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/SYB-ERP-Interface-Contract.-
wiki_revision: ad9adc22e69e48c9a8fd87d7121066eecca55fd6
synchronized_at: 2026-09-04T11:30:50Z
wiki_revision: df0fe874c7f3f3e9c031f2f793f8f6a0511ace25
synchronized_at: 2026-09-07T06:25:27Z
<!-- gitea-wiki-mirror:end -->
# 12 顺云宝(SYB)ERP 接口契约
@@ -228,6 +228,14 @@ Admin 默认 `max_matches = 10000`,可以在配置中调整;上限针对整
读取完整明细并按既有 upsert 保存,但本次同步仍记为失败、明确提示当天未形成
稳定快照且不推进游标,下一次继续覆盖今天。任何尝试都不得突破 `max_matches`;
网络/业务错误、非法 ID 或不完整明细不属于可放宽的快照漂移。
`[必须,#235]` 当天跨页重复 ID 纳入上述最多 3 次列表快照尝试(含首次),
不增加另一层重试次数。发现跨页重复后丢弃本次列表,从预检总数和第一页重新开始,
使用全新 ID 集合;最后一次仍重复时直接失败,不得去重后按成功或降级数据保存。
已经完成的历史日期保持其已有结果,不重复拉取;历史日期重复不适用此恢复。
每页先检查非法 ID 和页内重复,再检查跨页重叠;同页同时存在页内重复和跨页重叠时
仍作为硬错误停止。原有总数漂移/短页的合法唯一列表降级保存条件保持不变。
重复诊断只记录日期、当天尝试序号、首次/当前页码与行号、start、pageSize、
expectedTotal 和已获取唯一数量,不记录真实重复 ID、原始响应或个人数据。
### 4.4 统一日期范围同步与覆盖游标
@@ -0,0 +1,101 @@
package sybimport
import (
"context"
"strings"
"testing"
"time"
)
func freezePaginationToday(t *testing.T) {
t.Helper()
previous := syncNow
syncNow = func() time.Time { return time.Date(2026, 8, 29, 12, 0, 0, 0, time.FixedZone("Asia/Shanghai", 8*60*60)) }
t.Cleanup(func() { syncNow = previous })
}
func TestTodayCrossPageOverlapRestartsWithIndependentIDs(t *testing.T) {
freezePaginationToday(t)
f := &fakeSYB{perDay: map[string]int{"2026-08-29": 4}}
f.pageIDs = func(_ string, start, call int) []int64 {
if call == 2 {
return []int64{1001, 1002}
}
if call > 2 {
return []int64{int64(2000 + start), int64(2001 + start)}
}
return nil
}
rows, err := loadDailyListWithRecovery(context.Background(), newSyncClient(t, f), "2026-08-29", 2, 4, 100)
if err != nil || len(rows) != 4 || f.listTotalCalls != 2 {
t.Fatalf("rows=%d totals=%d err=%v", len(rows), f.listTotalCalls, err)
}
if got := strings.Join(f.listCalls, ","); got != "2026-08-29:0,2026-08-29:2,2026-08-29:0,2026-08-29:2" {
t.Fatalf("did not restart at first page: %s", got)
}
for index, row := range rows {
if row.ID != int64(2000+index) {
t.Fatal("rows leaked from abandoned attempt")
}
}
}
func TestTodayPersistentOverlapPreservesYesterdayButDoesNotImportToday(t *testing.T) {
freezePaginationToday(t)
f := &fakeSYB{perDay: map[string]int{"2026-08-28": 2, "2026-08-29": 4}}
f.pageIDs = func(date string, start, _ int) []int64 {
if date == "2026-08-29" && start == 2 {
return []int64{1001, 1002}
}
return nil
}
db := newSyncTestDB(t)
report, err := Sync(context.Background(), db, newSyncClient(t, f), SyncConfig{PageSize: 2, MaxMatches: 100}, "2026-08-28", "2026-08-29")
if err == nil {
t.Fatal("overlap was treated as successful sync")
}
for _, token := range []string{"连续 3 次", "跨页重复", "firstPage=1", "firstRow=2", "page=2", "row=1", "start=2", "pageSize=2", "expectedTotal=4", "unique=2"} {
if !strings.Contains(err.Error(), token) {
t.Fatalf("missing %s in %v", token, err)
}
}
if strings.Contains(err.Error(), "1001") || strings.Contains(err.Error(), "已保存") {
t.Fatalf("unsafe diagnosis/degraded save: %v", err)
}
if len(f.listCalls) != 7 || f.detailCalls != 1 || report.OrderCount != 2 {
t.Fatalf("unexpected retry/import boundary: pages=%d details=%d orders=%d", len(f.listCalls), f.detailCalls, report.OrderCount)
}
var count int64
if e := db.Table("syb_product").Count(&count).Error; e != nil || count != 2 {
t.Fatalf("yesterday not preserved: count=%d err=%v", count, e)
}
}
func TestPaginationHardErrorsDoNotUseOverlapRecovery(t *testing.T) {
freezePaginationToday(t)
for _, tc := range []struct {
name, date, message string
page int
ids []int64
calls int
}{
{"same page", "2026-08-29", "同页重复", 0, []int64{1000, 1000}, 1},
{"mixed overlap and same page", "2026-08-29", "同页重复", 2, []int64{1001, 1001}, 2},
{"overlap and invalid ID", "2026-08-29", "非法 id", 2, []int64{1001, 0}, 2},
{"historical overlap", "2026-08-28", "跨页重复", 2, []int64{1001, 1002}, 2},
} {
t.Run(tc.name, func(t *testing.T) {
f := &fakeSYB{perDay: map[string]int{tc.date: 4}}
f.pageIDs = func(_ string, start, _ int) []int64 {
if start == tc.page {
return tc.ids
}
return nil
}
rows, err := loadDailyListWithRecovery(context.Background(), newSyncClient(t, f), tc.date, 2, 4, 100)
if rows != nil || err == nil || !strings.Contains(err.Error(), tc.message) || len(f.listCalls) != tc.calls || f.listTotalCalls != 0 {
t.Fatalf("unexpected recovery: rows=%d pages=%d totals=%d err=%v", len(rows), len(f.listCalls), f.listTotalCalls, err)
}
})
}
}
+20 -6
View File
@@ -358,7 +358,7 @@ func loadDailyListWithRecovery(ctx context.Context, client *sybclient.Client, da
}
var drift *snapshotDriftError
if !errors.As(err, &drift) {
return nil, err
return nil, fmt.Errorf("今天第 %d/%d 次拉取失败: %w", attempt, maxTodaySnapshotAttempts, err)
}
last = drift
}
@@ -370,7 +370,8 @@ func loadDailyListWithRecovery(ctx context.Context, client *sybclient.Client, da
func loadDailyList(ctx context.Context, client *sybclient.Client, date string, pageSize, expectedTotal int) ([]sybclient.StockRow, error) {
rows := make([]sybclient.StockRow, 0, expectedTotal)
seen := make(map[int64]struct{}, expectedTotal)
type position struct{ page, row int }
seen := make(map[int64]position, expectedTotal)
for start := 0; start < expectedTotal; start += pageSize {
pageIndex := start/pageSize + 1
@@ -386,14 +387,27 @@ func loadDailyList(ctx context.Context, client *sybclient.Client, date string, p
if pageCount != len(page) {
return nil, fmt.Errorf("%s 货运单列表第 %d 页响应条数不自洽:total=%d,list=%d", date, pageIndex, pageCount, len(page))
}
for _, row := range page {
// Validate the whole page first: a same-page duplicate must not be
// hidden behind an earlier, recoverable cross-page overlap.
pageSeen := make(map[int64]int, len(page))
for index, row := range page {
if row.ID <= 0 {
return nil, fmt.Errorf("%s 货运单列表包含非法 id=%d", date, row.ID)
}
if _, duplicate := seen[row.ID]; duplicate {
return nil, fmt.Errorf("%s 货运单列表重复返回 id=%d", date, row.ID)
if firstRow, duplicate := pageSeen[row.ID]; duplicate {
return nil, fmt.Errorf("%s 货运单列表同页重复 [firstPage=%d,firstRow=%d,page=%d,row=%d,start=%d,pageSize=%d,expectedTotal=%d,unique=%d]",
date, pageIndex, firstRow, pageIndex, index+1, start, pageSize, expectedTotal, len(rows))
}
seen[row.ID] = struct{}{}
pageSeen[row.ID] = index + 1
}
for index, row := range page {
if first, duplicate := seen[row.ID]; duplicate {
// No rows/valid flag: overlapping pages are never eligible for
// the existing final-attempt degraded save, even after deduping.
return nil, &snapshotDriftError{message: fmt.Sprintf("%s 货运单列表跨页重复 [firstPage=%d,firstRow=%d,page=%d,row=%d,start=%d,pageSize=%d,expectedTotal=%d,unique=%d]",
date, first.page, first.row, pageIndex, index+1, start, pageSize, expectedTotal, len(rows))}
}
seen[row.ID] = position{page: pageIndex, row: index + 1}
rows = append(rows, row)
}
if len(page) != expectedPageCount {
+13
View File
@@ -60,6 +60,9 @@ type fakeSYB struct {
totalOverride map[int]int
shortPageAtIndex int
detailDropID int64
listCalls []string
pageIDs func(date string, start, call int) []int64
detailCalls int
// shopNames 按货运单序号轮换;留空表示全部用「测试店铺」。
shopNames []string
// detailShopName 非空时,明细响应里的 shopName 用它覆盖,
@@ -96,6 +99,7 @@ func (f *fakeSYB) server(t *testing.T) *httptest.Server {
start := int(body["start"].(float64))
pageSize := int(body["length"].(float64))
total := f.perDay[date]
f.listCalls = append(f.listCalls, fmt.Sprintf("%s:%d", date, start))
rows := []map[string]any{}
for i := start; i < total && len(rows) < pageSize; i++ {
@@ -108,9 +112,18 @@ func (f *fakeSYB) server(t *testing.T) *httptest.Server {
if f.shortPageAtIndex > 0 && start/pageSize+1 == f.shortPageAtIndex && len(rows) > 0 {
rows = rows[:len(rows)-1]
}
if f.pageIDs != nil {
if ids := f.pageIDs(date, start, len(f.listCalls)); ids != nil {
rows = nil
for _, id := range ids {
rows = append(rows, map[string]any{"id": id, "code": "TEST", "shopName": f.shopFor(0)})
}
}
}
writeEnvelope(w, map[string]any{"list": rows, "total": len(rows)})
})
mux.HandleFunc("/am/stock/detail/listByStock", func(w http.ResponseWriter, r *http.Request) {
f.detailCalls++
var body struct {
IDs []int64 `json:"ids"`
}