Compare commits

...
15 changed files with 759 additions and 28 deletions
+2 -2
View File
@@ -11,8 +11,8 @@ android {
applicationId = "cn.ilapage.goauto.agent"
minSdk = 23
targetSdk = 34
versionCode = 79
versionName = "0.9.66"
versionCode = 85
versionName = "0.9.72"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
@@ -0,0 +1,58 @@
package cn.ilapage.goauto.agent.automation
/** Complete temporary collections only; caller retains the existing task lock. */
internal class PddCollectionReturnNavigator(
private val driver: PddCollectorDriver,
private val openAgent: () -> Boolean,
private val currentPackage: () -> String?,
private val pause: (Long) -> Unit = Thread::sleep,
) {
var reason = "not_started"
private set
var actions = 0
private set
fun returnToAgent(count: Int): Boolean {
actions = 0
if (count !in 1..5) { reason = "invalid_count"; return false }
try {
reason = "count_completed"
for (step in 1..count) {
if (currentPackage() != PDD) { reason = "left_pdd"; break }
val screen = driver.capture()
if (screen.packageName != PDD) { reason = "left_pdd"; break }
if (screen.activityName.isNullOrBlank() || unsafe(screen)) {
reason = "unsafe_page"
return false
}
// Recheck after capturing: never send Back to another foreground app.
if (currentPackage() != PDD) { reason = "left_pdd"; break }
actions++
if (!driver.back()) { reason = "back_failed"; break }
pause(500)
}
// Retain risk scenes even if the last Back lands on one.
if (currentPackage() == PDD && unsafe(driver.capture())) {
reason = "unsafe_page"
return false
}
if (!openAgent()) { reason = "agent_launch_failed"; return false }
pause(500)
if (currentPackage() != AGENT) { reason = "agent_not_confirmed"; return false }
return true
} catch (_: Exception) {
reason = "navigation_exception"
return false
}
}
private fun unsafe(screen: UiSnapshot): Boolean =
PddPageClassifier.classify(screen.packageName, screen.activityName,
screen.nodes.filter { it.visible }.map { it.label }) != null ||
screen.nodes.any { it.visible && listOf("订单详情", "订单待支付", "确认付款", "立即支付", "编辑收货地址", "新增收货地址", "地址管理").any(it.label::contains) }
companion object {
private const val PDD = "com.xunmeng.pinduoduo"
private const val AGENT = "cn.ilapage.goauto.agent"
}
}
@@ -425,6 +425,7 @@ class PurchaseRehearsalExecutor(
wait.failure?.let { return it }
if (wait.opened) return null
if (wait.changed && recoverableSpecEntry(screen, wait.screen, action, beforeIdentity) == null) {
panelDiagnostic("entryRecovery=rejected;reason=$entryRecoveryReason;pageChanged=${wait.changed};${panelEvidence(wait.screen)}")
return failure(
SPEC_PANEL_EVIDENCE_NOT_MATCHED,
"规格入口手势后页面已变化,但规格面板强证据不足 [${panelEvidence(wait.screen)}]",
@@ -500,9 +501,9 @@ class PurchaseRehearsalExecutor(
private fun waitForSpecPanel(input: PurchaseExecutionInput, beforeSignature: List<SpecActionNodeSignature>): SpecPanelWait {
var last = currentScreen(input)
var changed = false
repeat(SPEC_POST_CLICK_VERIFY_POLLS) {
repeat(SPEC_POST_CLICK_VERIFY_POLLS) { poll ->
last = currentScreen(input)
panelDiagnostic(panelEvidence(last))
panelDiagnostic("${panelEvidence(last)};entryWaitPolls=${poll + 1};entryWaitMillis=${poll * SPEC_SELECTION_POLL_MILLIS};closeControl=${last.hasCloseControl};paymentArea=${last.hasPaymentArea}")
if (last.reviewPageOpen) {
return SpecPanelWait(last, false, true, leaveUnexpectedReviewPage(input))
}
@@ -0,0 +1,92 @@
package cn.ilapage.goauto.agent.persistence
import cn.ilapage.goauto.agent.automation.FreshActionResult
import cn.ilapage.goauto.agent.automation.FreshClickReason
import java.io.File
import org.json.JSONObject
data class PurchaseDiagnosticContext(
val taskId: Long,
val attemptId: String,
val deviceId: Long,
val ruleHash: String,
val agentVersion: String,
val phase: String,
)
/** Private, bounded diagnostics only. Neither raw evidence nor write errors escape this store. */
class PurchaseDiagnosticStore(private val directory: File, private val now: () -> Long = System::currentTimeMillis) {
@Synchronized
fun record(context: PurchaseDiagnosticContext, elapsedMs: Long, evidence: String): Boolean = runCatching {
require(context.taskId > 0 && context.deviceId > 0 && ID.matches(context.attemptId))
require(HASH.matches(context.ruleHash) && VERSION.matches(context.agentVersion))
require(context.phase in setOf("purchase", "spec_probe"))
val fields = sanitize(evidence)
if (fields.length() == 0) return false
val timestamp = now()
val line = JSONObject().put("taskId", context.taskId).put("attemptId", context.attemptId)
.put("deviceId", context.deviceId).put("ruleHash", context.ruleHash)
.put("agentVersion", context.agentVersion).put("phase", context.phase)
.put("timestamp", timestamp).put("elapsedMs", elapsedMs.coerceAtLeast(0))
.put("evidence", fields).toString()
check(directory.isDirectory || directory.mkdirs())
val file = File(directory, "${context.taskId}_${context.attemptId}.jsonl")
file.appendText(line + "\n", Charsets.UTF_8)
val lines = file.readLines(Charsets.UTF_8)
if (lines.size > MAX_EVENTS) file.writeText(lines.takeLast(MAX_EVENTS).joinToString("\n", postfix = "\n"), Charsets.UTF_8)
file.setLastModified(timestamp)
prune(timestamp)
true
}.getOrDefault(false)
@Synchronized
fun prune(timestamp: Long = now()) {
runCatching {
val files = directory.listFiles()?.filter { it.isFile && FILE_NAME.matches(it.name) }.orEmpty()
files.filter { it.lastModified() < timestamp - RETENTION_MS }.forEach { it.delete() }
files.filter(File::exists).sortedWith(compareByDescending<File> { it.lastModified() }.thenBy { it.name })
.drop(MAX_ATTEMPTS).forEach { it.delete() }
}
}
companion object {
const val MAX_ATTEMPTS = 5
const val MAX_EVENTS = 128
const val RETENTION_MS = 7L * 24 * 60 * 60 * 1000
private val ID = Regex("[A-Za-z0-9-]{1,80}")
private val HASH = Regex("[0-9a-f]{64}")
private val VERSION = Regex("[0-9]+\\.[0-9]+\\.[0-9]+")
private val FILE_NAME = Regex("[1-9][0-9]*_[A-Za-z0-9-]{1,80}\\.jsonl")
private val numbers = setOf("specEntryCandidates", "explicit", "nested", "bottomPurchase", "entryReadyWaitPolls",
"entryReadyWaitMillis", "scrollables", "headings", "options", "entryWaitPolls", "entryWaitMillis")
private val booleans = setOf("panelAlreadyOpen", "reviewPage", "pageEvidence", "pageChanged", "summary", "quantity",
"orderAction", "closeControl", "paymentArea")
private val enums = mapOf(
"event" to setOf("started"),
"entrySource" to setOf("unknown", "explicit_selection", "nested_selection", "bottom_purchase", "bottom_purchase_rightmost"),
"entryClick" to FreshActionResult.values().map { it.name }.toSet(),
"entryReason" to FreshClickReason.values().map { it.name }.toSet(),
"gesture" to FreshActionResult.values().map { it.name }.toSet(),
"entryRecovery" to setOf("rejected", "attempted"),
"type" to setOf("UNKNOWN", "NORMAL_SCROLLABLE", "NON_SCROLLABLE_CONFIRMATION", "ORDER_CONFIRMATION", "QUICK_CONFIRMATION"),
"reason" to setOf("identity_missing", "identity_changed", "product_context_missing", "page_problem", "panel_open",
"close_control", "payment_area", "order_action", "quantity_controls", "selection_summary", "title_missing",
"title_changed", "entry_missing", "entry_source_missing", "entry_source_changed", "entry_label_changed",
"entry_ambiguous", "rule_alias_mismatch", "entry_unavailable", "eligible"),
)
internal fun sanitize(evidence: String): JSONObject {
val result = JSONObject()
evidence.take(4096).split(';').take(40).forEach { part ->
val key = part.substringBefore('=')
val value = part.substringAfter('=', "")
when {
key in numbers && value.matches(Regex("[0-9]{1,8}")) -> result.put(key, value.toLong())
key in booleans && value in setOf("true", "false") -> result.put(key, value == "true")
value in enums[key].orEmpty() -> result.put(key, value)
}
}
return result
}
}
}
@@ -18,6 +18,10 @@ import android.os.PowerManager
import android.os.SystemClock
import android.util.Log
import cn.ilapage.goauto.agent.BuildConfig
import cn.ilapage.goauto.agent.automation.PddCollectionReturnNavigator
import cn.ilapage.goauto.agent.persistence.PurchaseDiagnosticContext
import cn.ilapage.goauto.agent.persistence.PurchaseDiagnosticStore
import java.io.File
import cn.ilapage.goauto.agent.ClipboardRelayActivity
import cn.ilapage.goauto.agent.MainActivity
import cn.ilapage.goauto.agent.R
@@ -91,6 +95,7 @@ class AgentForegroundService : Service() {
private lateinit var settingsStore: AgentSettingsStore
private lateinit var stateStore: AgentStateStore
private lateinit var purchaseStore: PurchaseTaskStore
private lateinit var purchaseDiagnostics: PurchaseDiagnosticStore
private lateinit var diagnosticStore: AgentDiagnosticStore
private lateinit var diagnosticRecorder: SafeAgentDiagnosticRecorder
private lateinit var connectivityManager: ConnectivityManager
@@ -109,6 +114,8 @@ class AgentForegroundService : Service() {
// Wake locks and cooldown tickets are process-local. Never restore a stale UI flag.
stateStore.setKeepScreenOn(false)
purchaseStore = PurchaseTaskStore(this)
purchaseDiagnostics = PurchaseDiagnosticStore(File(filesDir, "purchase_diagnostics"))
runCatching { diagnosticExecutor.execute { purchaseDiagnostics.prune() } }
diagnosticStore = AgentDiagnosticStore(this)
diagnosticRecorder = SafeAgentDiagnosticRecorder(
persist = { event ->
@@ -350,11 +357,16 @@ class AgentForegroundService : Service() {
}
runningTaskId.set(task.taskId)
stateStore.setActiveTask(task.taskId, "collection")
val outcome = executeTask(api, task, credentials.token)
val continuous = ContinuousCollectionPolicy.applies(
settingsStore.continuousCollection(), task.source, task.attemptNumber, task.replacementOriginType,
)
val outcome = executeTask(api, task, credentials.token, continuous)
publishCurrentPageResult(
task.taskId,
if (outcome.successful) {
if (task.replacementOriginType.isNotBlank()) {
if (continuous) {
outcome.message ?: "采集已结束,请查看采集记录。"
} else if (task.replacementOriginType.isNotBlank()) {
"已替换,正在匹配规格"
} else {
"临时采集任务 #${task.taskId} 已结束,请查看采集记录。"
@@ -480,6 +492,15 @@ class AgentForegroundService : Service() {
} else {
val diagnosticDeviceId = runCatching { identityStore.credentials()?.deviceId ?: 0L }.getOrDefault(0L)
val diagnosticAttempt = task.taskAttemptId.takeIf { it.matches(Regex("^[a-zA-Z0-9-]{1,80}$")) } ?: "invalid"
val diagnosticContext = PurchaseDiagnosticContext(task.taskId, task.taskAttemptId, diagnosticDeviceId,
snapshotHash, BuildConfig.VERSION_NAME, task.phase)
val diagnosticStarted = SystemClock.elapsedRealtime()
val recordDiagnostic: (String) -> Unit = { evidence ->
val elapsed = SystemClock.elapsedRealtime() - diagnosticStarted
runCatching { diagnosticExecutor.execute { purchaseDiagnostics.record(diagnosticContext, elapsed, evidence) } }
Unit
}
recordDiagnostic("event=started")
PurchaseRehearsalExecutor(
driver = accessibility,
openLink = { PddLinkLauncher(this).open(it, preferDirect = true) },
@@ -489,6 +510,7 @@ class AgentForegroundService : Service() {
purchaseStore.updateStep(task.taskId, task.taskAttemptId, step)
},
panelDiagnostic = { evidence ->
recordDiagnostic(evidence)
Log.i("GoAutoPurchasePanel", "task=${task.taskId};attempt=$diagnosticAttempt;device=$diagnosticDeviceId;rule=$snapshotHash;$evidence")
},
beforeOrderSubmit = { evidence ->
@@ -635,11 +657,29 @@ class AgentForegroundService : Service() {
api: AgentApiClient,
initialTask: cn.ilapage.goauto.agent.network.AgentTask,
token: String,
continuous: Boolean = false,
): TaskExecutionSummary {
GoAutoAccessibilityService.instance?.dismissPurchaseResultBubble()
acquireTaskWakeLock()
return try {
executeTaskWhileAwake(api, initialTask, token)
val returnCount = settingsStore.continuousReturnCount()
val outcome = executeTaskWhileAwake(api, initialTask, token, continuous)
if (!continuous) outcome else {
cancelIdleReturn("连续采集保留 PDD 页面")
ContinuousCollectionPolicy.finish(continuous, outcome) {
val driver = GoAutoAccessibilityService.instance ?: return@finish false
val navigator = PddCollectionReturnNavigator(driver, driver::openAgentPreservingTab, driver::currentPackage)
val returned = navigator.returnToAgent(returnCount)
runCatching {
val hash = java.security.MessageDigest.getInstance("SHA-256")
.digest(initialTask.ruleSnapshot.toByteArray(Charsets.UTF_8)).joinToString("") { "%02x".format(it) }
Log.i("GoAutoCollector", "task=${initialTask.taskId};attempt=${initialTask.attemptNumber};device=${stateStore.read().deviceId};rule=$hash;continuousReturn=$returned;configuredBacks=$returnCount;reason=${navigator.reason};actions=${navigator.actions}")
}
returned
}.also { result ->
if (result.successful) runCatching { stateStore.update("ONLINE", result.message.orEmpty(), tokenStored = true) }
}
}
} finally {
releaseTaskWakeLock()
}
@@ -649,6 +689,7 @@ class AgentForegroundService : Service() {
api: AgentApiClient,
initialTask: cn.ilapage.goauto.agent.network.AgentTask,
token: String,
continuous: Boolean = false,
): TaskExecutionSummary {
var started = initialTask.status == "running"
var resultSafelySubmitted = false
@@ -755,25 +796,25 @@ class AgentForegroundService : Service() {
}
}
stateStore.update("ONLINE", "任务 #${task.taskId} 已提交:${result.status}", tokenStored = true)
beginPostCollectionCooldowns()
TaskExecutionSummary(successful = true)
beginPostCollectionCooldowns(returnToAgent = !continuous)
TaskExecutionSummary(successful = true, resultStatus = result.status)
} catch (error: TaskFailure) {
if (started) {
resultSafelySubmitted = failSafely(api, initialTask.taskId, token, error.code, error.message ?: "采集失败")
if (resultSafelySubmitted) beginPostCollectionCooldowns()
if (resultSafelySubmitted) beginPostCollectionCooldowns(returnToAgent = !continuous)
}
TaskExecutionSummary(false, error.code, error.message)
} catch (error: AgentApiException) {
if (error.code == "REPLACEMENT_ACTIVATION_FAILED") {
resultSafelySubmitted = true
beginPostCollectionCooldowns()
beginPostCollectionCooldowns(returnToAgent = !continuous)
}
stateStore.update("TASK_ERROR", "${error.code}:${error.message}", tokenStored = true)
TaskExecutionSummary(false, error.code, error.message)
} catch (error: Exception) {
if (started) {
resultSafelySubmitted = failSafely(api, initialTask.taskId, token, "AGENT_EXECUTION_ERROR", error.message ?: "Android 执行异常")
if (resultSafelySubmitted) beginPostCollectionCooldowns()
if (resultSafelySubmitted) beginPostCollectionCooldowns(returnToAgent = !continuous)
}
TaskExecutionSummary(false, "AGENT_EXECUTION_ERROR", error.message)
} finally {
@@ -832,8 +873,8 @@ class AgentForegroundService : Service() {
executor.schedule(::triggerSync, IdleReturnCoordinator.DEFAULT_COOLDOWN_MILLIS, TimeUnit.MILLISECONDS)
}
private fun beginPostCollectionCooldowns() {
beginIdleReturnCooldown()
private fun beginPostCollectionCooldowns(returnToAgent: Boolean = true) {
if (returnToAgent) beginIdleReturnCooldown() else cancelIdleReturn("连续采集不自动返回 Agent")
val ticket = CollectionCooldownPolicy.arm(
System.currentTimeMillis(),
settingsStore.collectionIntervalRange(),
@@ -1152,9 +1193,3 @@ class AgentForegroundService : Service() {
}
private class TaskFailure(val code: String, message: String) : Exception(message)
private data class TaskExecutionSummary(
val successful: Boolean,
val code: String? = null,
val message: String? = null,
)
@@ -1,6 +1,7 @@
package cn.ilapage.goauto.agent.service
import android.content.Context
import android.content.SharedPreferences
import cn.ilapage.goauto.agent.BuildConfig
import cn.ilapage.goauto.agent.network.ServerUrlPolicy
import kotlin.random.Random
@@ -113,14 +114,38 @@ internal object TaskDispatchPolicy {
}
}
class AgentSettingsStore(context: Context) {
private val preferences = context.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE)
class AgentSettingsStore internal constructor(private val preferences: SharedPreferences) {
constructor(context: Context) : this(context.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE))
fun serverUrl(): String = preferences.getString(SERVER_URL, null)
?: BuildConfig.DEFAULT_SERVER_URL.takeIf { it.isNotBlank() }.orEmpty()
fun deviceName(): String = preferences.getString(DEVICE_NAME, "").orEmpty()
fun continuousCollection(): Boolean = runCatching { preferences.getBoolean(CONTINUOUS_COLLECTION, false) }.getOrDefault(false)
fun continuousReturnCount(): Int = runCatching {
preferences.getInt(CONTINUOUS_RETURN_COUNT, 3).takeIf { it in 1..5 } ?: 3
}.getOrDefault(3)
fun saveContinuousReturnCount(count: Int) {
require(count in 1..5) { "返回次数须为 1~5 次" }
val previous = continuousReturnCount()
if (!preferences.edit().putInt(CONTINUOUS_RETURN_COUNT, count).commit()) {
preferences.edit().putInt(CONTINUOUS_RETURN_COUNT, previous).commit()
error("返回次数未保存,请重试")
}
}
fun saveContinuousCollection(enabled: Boolean) {
val previous = continuousCollection()
if (!preferences.edit().putBoolean(CONTINUOUS_COLLECTION, enabled).commit()) {
// SharedPreferences also updates memory on failed disk commits; restore the displayed value.
preferences.edit().putBoolean(CONTINUOUS_COLLECTION, previous).commit()
error("连续采集设置未保存,请重试")
}
}
fun historyDays(): Int = HistoryRangePolicy.stored(preferences.getInt(HISTORY_DAYS, 7))
fun collectionIntervalRange(): CollectionIntervalRange {
@@ -177,6 +202,8 @@ class AgentSettingsStore(context: Context) {
const val PREFERENCES = "goauto_agent_settings"
const val SERVER_URL = "server_url"
const val DEVICE_NAME = "device_name"
const val CONTINUOUS_COLLECTION = "continuous_collection"
const val CONTINUOUS_RETURN_COUNT = "continuous_collection_return_count"
const val HISTORY_DAYS = "history_days"
const val COLLECTION_INTERVAL_SECONDS = "collection_interval_seconds"
const val COLLECTION_INTERVAL_START_SECONDS = "collection_interval_start_seconds"
@@ -0,0 +1,27 @@
package cn.ilapage.goauto.agent.service
/** Only the foreground, newly requested temporary collection opts in. */
internal object ContinuousCollectionPolicy {
fun applies(enabled: Boolean, source: String, attemptNumber: Int, replacementOrigin: String): Boolean =
enabled && source == "agent_current_page" && attemptNumber == 1 && replacementOrigin.isBlank()
fun shouldReturn(enabledForTask: Boolean, submitted: Boolean, status: String?): Boolean =
enabledForTask && submitted && status == "completed"
fun finish(enabledForTask: Boolean, outcome: TaskExecutionSummary, navigate: () -> Boolean): TaskExecutionSummary {
if (!enabledForTask || !outcome.successful) return outcome
if (!shouldReturn(enabledForTask, outcome.successful, outcome.resultStatus)) {
return outcome.copy(message = "采集部分成功,已保留现场,请查看采集记录。")
}
val returned = runCatching(navigate).getOrDefault(false)
return outcome.copy(message = if (returned) "采集已完成,返回操作已结束,已切回 Agent。"
else "采集已完成,自动返回未完成,请手动处理当前页面并打开 Agent。")
}
}
internal data class TaskExecutionSummary(
val successful: Boolean,
val code: String? = null,
val message: String? = null,
val resultStatus: String? = null,
)
@@ -34,6 +34,7 @@ import cn.ilapage.goauto.agent.update.AgentAppUpdateManager
import cn.ilapage.goauto.agent.update.UpdateCheckResult
import com.google.android.material.button.MaterialButton
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.switchmaterial.SwitchMaterial
import com.google.android.material.textfield.TextInputEditText
import com.google.android.material.textfield.TextInputLayout
import java.util.concurrent.Executors
@@ -64,6 +65,10 @@ class AgentSettingsFragment : Fragment() {
private lateinit var collectionIntervalEndLayout: TextInputLayout
private lateinit var collectionIntervalEndInput: TextInputEditText
private lateinit var collectionIntervalFeedback: TextView
private lateinit var continuousCollectionSwitch: SwitchMaterial
private lateinit var continuousReturnCountButton: MaterialButton
private lateinit var continuousCollectionFeedback: TextView
private var refreshingContinuousCollection = false
private lateinit var historyDaysLayout: TextInputLayout
private lateinit var historyDaysInput: TextInputEditText
private lateinit var historySyncButton: MaterialButton
@@ -257,6 +262,69 @@ class AgentSettingsFragment : Fragment() {
collectionIntervalFeedback.setPadding(0, context.dp(8), 0, 0)
addView(collectionIntervalFeedback)
}))
addView(context.card(context.cardColumn().apply {
continuousCollectionSwitch = SwitchMaterial(context).apply {
// Use the switch style supplied by our MaterialComponents (M2) theme.
showText = false
textOn = "开启"
textOff = "关闭"
isClickable = true
isFocusable = true
text = "连续采集"
textSize = 18f
setTextColor(context.getColor(R.color.agent_text))
minHeight = context.dp(48)
isChecked = settingsStore.continuousCollection()
setOnCheckedChangeListener { _, enabled ->
if (!refreshingContinuousCollection) {
val current = stateStore.read()
val result = runCatching {
check(current.code != "BUSY" && current.currentTaskId == null) { "任务结束后可修改" }
settingsStore.saveContinuousCollection(enabled)
}
refreshingContinuousCollection = true
isChecked = settingsStore.continuousCollection()
refreshingContinuousCollection = false
continuousCollectionFeedback.text = result.fold(
onSuccess = { "" },
onFailure = { "未保存:${it.message ?: "请重试"}" },
)
continuousCollectionFeedback.visibility = if (continuousCollectionFeedback.text.isEmpty()) View.GONE else View.VISIBLE
refreshContinuousReturnCount()
}
}
}
addView(continuousCollectionSwitch, fullWidth())
continuousReturnCountButton = MaterialButton(context, null, com.google.android.material.R.attr.materialButtonOutlinedStyle).apply {
minHeight = context.dp(48)
setTextColor(context.getColor(R.color.agent_text))
setOnClickListener {
MaterialAlertDialogBuilder(context)
.setTitle("返回次数")
.setSingleChoiceItems(arrayOf("1 次", "2 次", "3 次", "4 次", "5 次"), settingsStore.continuousReturnCount() - 1) { dialog, which ->
val result = runCatching {
val current = stateStore.read()
check(current.code != "BUSY" && current.currentTaskId == null) { "任务结束后可修改" }
check(settingsStore.continuousCollection()) { "请先开启连续采集" }
settingsStore.saveContinuousReturnCount(which + 1)
}
continuousCollectionFeedback.text = result.fold(onSuccess = { "" }, onFailure = { "未保存:${it.message ?: "请重试"}" })
continuousCollectionFeedback.visibility = if (result.isSuccess) View.GONE else View.VISIBLE
refreshContinuousReturnCount()
dialog.dismiss()
}
.setNegativeButton("取消", null)
.show()
}
}
addView(continuousReturnCountButton, fullWidth(8))
refreshContinuousReturnCount()
continuousCollectionFeedback = context.label("", 14f, context.getColor(R.color.agent_text_muted)).apply {
visibility = View.GONE
accessibilityLiveRegion = View.ACCESSIBILITY_LIVE_REGION_POLITE
}
addView(continuousCollectionFeedback, fullWidth(8))
}))
addView(context.card(context.cardColumn().apply {
addView(context.label("任务记录", 18f, context.getColor(R.color.agent_text), true))
val syncRow = LinearLayout(context).apply {
@@ -654,11 +722,23 @@ class AgentSettingsFragment : Fragment() {
return normalizedUrl
}
private fun refreshContinuousReturnCount() {
val current = stateStore.read()
continuousReturnCountButton.text = "返回次数:${settingsStore.continuousReturnCount()} 次"
continuousReturnCountButton.visibility = if (settingsStore.continuousCollection()) View.VISIBLE else View.GONE
continuousReturnCountButton.isEnabled = current.code != "BUSY" && current.currentTaskId == null
}
private fun refreshDiagnostics() {
if (!isAdded || view == null) return
val context = requireContext()
val state = stateStore.read()
val busy = state.code == "BUSY" || state.currentTaskId != null
continuousCollectionSwitch.isEnabled = !busy
refreshContinuousReturnCount()
if (busy) continuousCollectionFeedback.text = "任务结束后可修改"
else if (continuousCollectionFeedback.text == "任务结束后可修改") continuousCollectionFeedback.text = ""
continuousCollectionFeedback.visibility = if (continuousCollectionFeedback.text.isEmpty()) View.GONE else View.VISIBLE
if (busy && updateDownloading) updateCancelled.set(true)
val editable = SettingsAvailabilityResolver.editable(state.code, state.currentTaskId, testing)
serverInput.isEnabled = editable
@@ -0,0 +1,82 @@
package cn.ilapage.goauto.agent
import android.content.SharedPreferences
import cn.ilapage.goauto.agent.service.AgentSettingsStore
import java.lang.reflect.Proxy
import org.junit.Assert.*
import org.junit.Test
class ContinuousCollectionSettingsTest {
@Test fun `default off persists and survives store recreation`() {
val disk = MemoryPreferences()
val store = AgentSettingsStore(disk.preferences)
assertFalse(store.continuousCollection())
store.saveContinuousCollection(true)
assertTrue(AgentSettingsStore(disk.preferences).continuousCollection())
store.saveContinuousCollection(false)
assertFalse(AgentSettingsStore(disk.preferences).continuousCollection())
}
@Test fun `failed disk commit restores previous in memory preference`() {
val disk = MemoryPreferences()
val store = AgentSettingsStore(disk.preferences)
store.saveContinuousCollection(true)
disk.failNextCommit = true
assertTrue(runCatching { store.saveContinuousCollection(false) }.isFailure)
assertTrue(store.continuousCollection())
assertTrue(AgentSettingsStore(disk.preferences).continuousCollection())
}
@Test fun `return count defaults three and persists valid bounds`() {
val disk = MemoryPreferences()
val store = AgentSettingsStore(disk.preferences)
assertEquals(3, store.continuousReturnCount())
for (count in 1..5) {
store.saveContinuousReturnCount(count)
assertEquals(count, AgentSettingsStore(disk.preferences).continuousReturnCount())
}
for (count in listOf(0, 6)) {
assertTrue(runCatching { store.saveContinuousReturnCount(count) }.isFailure)
assertEquals(5, store.continuousReturnCount())
}
}
@Test fun `return count failed write restores previous and invalid stored value defaults`() {
val disk = MemoryPreferences()
val store = AgentSettingsStore(disk.preferences)
store.saveContinuousReturnCount(2)
disk.failNextCommit = true
assertTrue(runCatching { store.saveContinuousReturnCount(4) }.isFailure)
assertEquals(2, AgentSettingsStore(disk.preferences).continuousReturnCount())
disk.preferences.edit().putInt("continuous_collection_return_count", 99).commit()
assertEquals(3, store.continuousReturnCount())
}
private class MemoryPreferences {
private val values = mutableMapOf<String, Any>()
var failNextCommit = false
val preferences = Proxy.newProxyInstance(SharedPreferences::class.java.classLoader, arrayOf(SharedPreferences::class.java)) { _, method, args ->
when (method.name) {
"getBoolean", "getInt" -> values[args!![0] as String] ?: args[1]
"edit" -> editor()
else -> error("Unexpected preference access ${method.name}")
}
} as SharedPreferences
private fun editor(): SharedPreferences.Editor {
val pending = mutableMapOf<String, Any>()
return Proxy.newProxyInstance(SharedPreferences.Editor::class.java.classLoader, arrayOf(SharedPreferences.Editor::class.java)) { proxy, method, args ->
when (method.name) {
"putBoolean", "putInt" -> { pending[args!![0] as String] = args[1]; proxy }
"commit" -> {
values.putAll(pending)
val success = !failNextCommit
failNextCommit = false
success
}
else -> error("Unexpected preference edit ${method.name}")
}
} as SharedPreferences.Editor
}
}
}
@@ -0,0 +1,163 @@
package cn.ilapage.goauto.agent
import cn.ilapage.goauto.agent.automation.*
import cn.ilapage.goauto.agent.service.ContinuousCollectionPolicy
import cn.ilapage.goauto.agent.service.TaskExecutionSummary
import org.junit.Assert.*
import org.junit.Test
class ContinuousCollectionTest {
private val complete = TaskExecutionSummary(true, resultStatus = "completed")
@Test fun `only new ordinary temporary collection participates`() {
assertTrue(ContinuousCollectionPolicy.applies(true, "agent_current_page", 1, ""))
assertFalse(ContinuousCollectionPolicy.applies(false, "agent_current_page", 1, ""))
for (source in listOf("admin", "purchase")) assertFalse(ContinuousCollectionPolicy.applies(true, source, 1, ""))
assertFalse(ContinuousCollectionPolicy.applies(true, "agent_current_page", 2, ""))
for (origin in listOf("collection", "purchase")) assertFalse(ContinuousCollectionPolicy.applies(true, "agent_current_page", 1, origin))
}
@Test fun `only acknowledged complete result can navigate`() {
for (status in listOf("completed_partial", "failed", "running", null)) assertFalse(ContinuousCollectionPolicy.shouldReturn(true, true, status))
assertFalse(ContinuousCollectionPolicy.shouldReturn(true, false, "completed"))
assertFalse(ContinuousCollectionPolicy.shouldReturn(false, true, "completed"))
assertTrue(ContinuousCollectionPolicy.shouldReturn(true, true, "completed"))
}
@Test fun `failure partial and disabled never invoke navigation`() {
val failed = TaskExecutionSummary(false, "failed", "failure")
assertEquals(failed, ContinuousCollectionPolicy.finish(true, failed) { error("unexpected") })
assertEquals(complete, ContinuousCollectionPolicy.finish(false, complete) { error("unexpected") })
val partial = ContinuousCollectionPolicy.finish(true, complete.copy(resultStatus = "completed_partial")) { error("unexpected") }
assertTrue(partial.message!!.contains("保留现场"))
}
@Test fun `navigation failure never rewrites collection success`() {
for (navigate in listOf<() -> Boolean>({ false }, { error("failure") })) {
val result = ContinuousCollectionPolicy.finish(true, complete, navigate)
assertTrue(result.successful)
assertEquals(complete.code, result.code)
assertEquals("completed", result.resultStatus)
assertTrue(result.message!!.contains("手动"))
}
val success = ContinuousCollectionPolicy.finish(true, complete) { true }
assertTrue(success.message!!.contains("已切回 Agent"))
assertFalse(success.message!!.contains("首页"))
}
@Test fun `configured counts send exact backs even when page is unchanged`() {
for (count in 1..5) {
val d = Driver()
val waits = mutableListOf<Long>()
val navigator = d.navigator { waits += it }
assertTrue(navigator.returnToAgent(count))
assertEquals(count, d.backs)
assertEquals(count, navigator.actions)
assertEquals(1, d.opens)
assertEquals(List(count + 1) { 500L }, waits)
assertEquals("count_completed", navigator.reason)
}
}
@Test fun `invalid counts do not navigate`() {
for (count in listOf(-1, 0, 6, Int.MAX_VALUE)) {
val d = Driver()
assertFalse(d.navigator().returnToAgent(count))
assertEquals(0, d.backs + d.opens)
}
}
@Test fun `leaving pdd stops remaining backs and returns to agent`() {
val d = Driver().apply { leaveAfter = 1 }
val navigator = d.navigator()
assertTrue(navigator.returnToAgent(3))
assertEquals(1, d.backs)
assertEquals(1, d.opens)
assertEquals("left_pdd", navigator.reason)
}
@Test fun `already outside pdd sends no back`() {
val d = Driver().apply { pkg = "launcher" }
assertTrue(d.navigator().returnToAgent(3))
assertEquals(0, d.backs)
assertEquals(1, d.opens)
}
@Test fun `back failure stops without retry but can return to agent`() {
val d = Driver().apply { backSuccess = false }
val navigator = d.navigator()
assertTrue(navigator.returnToAgent(3))
assertEquals(1, d.backs)
assertEquals("back_failed", navigator.reason)
}
@Test fun `risk pages preserve scene including after last back`() {
for (after in listOf(0, 1)) {
val d = Driver().apply { riskAfter = after }
val navigator = d.navigator()
assertFalse(navigator.returnToAgent(1))
assertEquals(after, d.backs)
assertEquals(0, d.opens)
assertEquals("unsafe_page", navigator.reason)
}
}
@Test fun `switching foreground during capture never sends back to other app`() {
val d = Driver().apply { changeDuringCapture = true }
assertTrue(d.navigator().returnToAgent(3))
assertEquals(0, d.backs)
}
@Test fun `agent launch and foreground confirmation failures are explicit`() {
for (launch in listOf(false, true)) {
val d = Driver().apply { launchSuccess = launch; confirmAgent = false }
val navigator = d.navigator()
assertFalse(navigator.returnToAgent(1))
assertEquals(if (launch) "agent_not_confirmed" else "agent_launch_failed", navigator.reason)
}
}
@Test fun `missing activity or capture exception does not send back`() {
for (throws in listOf(false, true)) {
val d = Driver().apply { missingActivity = true; throwCapture = throws }
assertFalse(d.navigator().returnToAgent(3))
assertEquals(0, d.backs + d.opens)
}
}
private class Driver : PddCollectorDriver {
var pkg = "com.xunmeng.pinduoduo"
var backs = 0
var opens = 0
var leaveAfter = Int.MAX_VALUE
var riskAfter = Int.MAX_VALUE
var backSuccess = true
var launchSuccess = true
var confirmAgent = true
var missingActivity = false
var throwCapture = false
var changeDuringCapture = false
fun navigator(pause: (Long) -> Unit = {}) = PddCollectionReturnNavigator(this, {
opens++
if (launchSuccess && confirmAgent) pkg = "cn.ilapage.goauto.agent"
launchSuccess
}, { pkg }, pause)
override fun capture(): UiSnapshot {
if (throwCapture) error("capture failed")
val snapshot = UiSnapshot(pkg, if (missingActivity) null else "PddActivity", if (backs >= riskAfter) listOf(
SnapshotNode("risk", null, "订单详情", null, null, "android.widget.TextView", NodeBounds(0, 0, 100, 100),
false, false, false, false, true, true),
) else emptyList())
if (changeDuringCapture) pkg = "launcher"
return snapshot
}
override fun back(): Boolean {
backs++
if (backs >= leaveAfter) pkg = "launcher"
return backSuccess
}
override fun clickFresh(target: SnapshotNode) = error("must not click page controls")
override fun swipeSpec(direction: SwipeDirection, anchor: SnapshotNode?) = error("must not swipe")
override fun pullDownGoodsPage() = error("must not pull down")
}
}
@@ -16,13 +16,42 @@ import cn.ilapage.goauto.agent.automation.SnapshotNode
import cn.ilapage.goauto.agent.automation.SwipeDirection
import cn.ilapage.goauto.agent.automation.UiSnapshot
import cn.ilapage.goauto.agent.persistence.PendingPurchaseOutbox
import cn.ilapage.goauto.agent.persistence.PurchaseDiagnosticContext
import cn.ilapage.goauto.agent.persistence.PurchaseDiagnosticStore
import cn.ilapage.goauto.agent.persistence.PurchaseOutboxUploader
import org.json.JSONObject
import org.junit.Rule
import org.junit.rules.TemporaryFolder
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class PurchaseRehearsalExecutorTest {
@get:Rule val diagnosticTemporary = TemporaryFolder()
@Test
fun `entry rejection persists without changing outcome when diagnostic storage fails`() {
for (writable in listOf(true, false)) {
val location = if (writable) diagnosticTemporary.newFolder() else diagnosticTemporary.newFile()
val store = PurchaseDiagnosticStore(location)
val context = PurchaseDiagnosticContext(81, "attempt-14", 7, "a".repeat(64), "0.9.67", "purchase")
val driver = FakePurchaseDriver(entryActionHasEffect = false, dynamicProduct = true, productOverlay = true,
specTapResult = FreshActionResult.SUCCESS)
val outcome = PurchaseRehearsalExecutor(driver, { true }, { null }, pause = {},
panelDiagnostic = { store.record(context, 0, it); Unit })
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals("PURCHASE_SPEC_PANEL_EVIDENCE_NOT_MATCHED", outcome.errorCode)
assertEquals(0, driver.specTapCount)
if (writable) {
val events = location.listFiles()!!.single().readLines().map { JSONObject(it).getJSONObject("evidence") }
assertEquals("close_control", events.last().getString("reason"))
assertTrue(events.any { it.optInt("entryWaitPolls") == 30 })
assertTrue(events.any { it.optString("entryClick") == "SUCCESS" })
}
}
}
@Test
fun `color selection then single size heading completes without selecting color again`() {
val driver = FakePurchaseDriver(prefixlessSingleHeadingAfterColor = true)
@@ -0,0 +1,81 @@
package cn.ilapage.goauto.agent.persistence
import java.io.File
import org.json.JSONObject
import org.junit.Assert.*
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TemporaryFolder
class PurchaseDiagnosticStoreTest {
@get:Rule val temporary = TemporaryFolder()
private val context = PurchaseDiagnosticContext(81, "attempt-14", 7, "a".repeat(64), "0.9.67", "purchase")
@Test fun `only known structural fields and fixed values survive`() {
val output = PurchaseDiagnosticStore.sanitize(
"entryClick=SUCCESS;entryRecovery=rejected;reason=title_changed;summary=false;options=20;" +
"title=敏感标题;address=测试地址;token=secret;entrySource=private-value;gesture=private-value;" +
"quantity=private-value;scrollables=-1;unknown=private-value",
)
assertEquals(5, output.length())
assertEquals("title_changed", output.getString("reason"))
assertFalse(output.getBoolean("summary"))
assertFalse(output.toString().contains("private"))
assertFalse(output.toString().contains("secret"))
assertEquals(0, PurchaseDiagnosticStore.sanitize("reason=测试地址;entryReason=secret").length())
}
@Test fun `events persist across store recreation with identity and elapsed time`() {
val dir = temporary.newFolder()
assertTrue(PurchaseDiagnosticStore(dir) { 1000 }.record(context, 2300,
"entryRecovery=rejected;reason=close_control;entryWaitPolls=30;entryWaitMillis=2900;closeControl=true"))
assertTrue(PurchaseDiagnosticStore(dir) { 2000 }.record(context, 2500, "entryClick=SUCCESS"))
val lines = dir.listFiles()!!.single().readLines()
assertEquals(2, lines.size)
val entry = JSONObject(lines.first())
assertEquals(81, entry.getLong("taskId"))
assertEquals("attempt-14", entry.getString("attemptId"))
assertEquals(7, entry.getLong("deviceId"))
assertEquals(context.ruleHash, entry.getString("ruleHash"))
assertEquals(2300, entry.getLong("elapsedMs"))
assertEquals("0.9.67", entry.getString("agentVersion"))
assertEquals("close_control", entry.getJSONObject("evidence").getString("reason"))
}
@Test fun `retains last five attempts and last 128 events including failure`() {
val dir = temporary.newFolder()
var time = 1000L
val store = PurchaseDiagnosticStore(dir) { time++ }
repeat(7) { store.record(context.copy(attemptId = "attempt-$it"), 0, "event=started") }
assertEquals(5, dir.listFiles()!!.size)
assertFalse(File(dir, "81_attempt-0.jsonl").exists())
repeat(150) { store.record(context, it.toLong(), "entryWaitPolls=$it") }
store.record(context, 999, "entryRecovery=rejected;reason=entry_label_changed")
val lines = File(dir, "81_attempt-14.jsonl").readLines()
assertEquals(128, lines.size)
assertEquals("entry_label_changed", JSONObject(lines.last()).getJSONObject("evidence").getString("reason"))
assertEquals(5, dir.listFiles()!!.size)
}
@Test fun `expiry deletes only owned files and can run at startup`() {
val dir = temporary.newFolder()
PurchaseDiagnosticStore(dir) { 1000 }.record(context, 0, "event=started")
File(dir, "unrelated.txt").writeText("preserve")
File(dir, "unrelated.txt").setLastModified(1000)
PurchaseDiagnosticStore(dir) { PurchaseDiagnosticStore.RETENTION_MS + 1001 }.prune()
assertEquals(listOf("unrelated.txt"), dir.listFiles()!!.map { it.name })
}
@Test fun `invalid context and filesystem failures never throw`() {
val store = PurchaseDiagnosticStore(temporary.newFile())
assertFalse(store.record(context, 1, "event=started"))
store.prune()
val dir = temporary.newFolder()
val validStore = PurchaseDiagnosticStore(dir)
assertFalse(validStore.record(context.copy(attemptId = "../outside"), 1, "event=started"))
assertFalse(validStore.record(context.copy(ruleHash = "secret"), 1, "event=started"))
assertFalse(validStore.record(context.copy(phase = "secret"), 1, "event=started"))
assertFalse(validStore.record(context, 1, "unknown=secret"))
assertTrue(dir.listFiles()!!.isEmpty())
}
}
+27 -2
View File
@@ -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: 20a1ca65dccde9ca1ecd93419cc1b164a5717fdf
synchronized_at: 2026-09-07T13:34:45Z
wiki_revision: 223cb9035a6b6e63072aa6b075889fcd0d6c2f23
synchronized_at: 2026-09-09T08:18:24Z
<!-- gitea-wiki-mirror:end -->
# 架构与代码地图
@@ -68,6 +68,8 @@ Android Portal/Agent
| `pdd_account` | 可选的账号调度引用,只保存名称和状态,不保存凭据 |
| `purchase_task` | 商品外键和不可变快照、执行模式、状态/租约 guard、价格边界、订单、人工支付复核、物流与回填事实 |
| `purchase_task_attempt` | `task_id + attempt_id` 幂等执行记录、阶段、规则哈希、固化规格决策和结构化错误 |
> #241 新增:`server/app/goauto/purchase/order_backfill.go` 与 `order_backfill_handler.go` 提供 `POST /api/agent/v1/purchase-tasks/order-backfill`(Device Token 鉴权,逐条事务、逐条结果);`server/app/goauto/models/purchase_order_guard.go` 在 `PurchaseTask.BeforeSave` 上全局强制订单号唯一,以 `purchase_rule_setting` 单例行串行化订单号分配,避免新增唯一索引迁移;`purchasecontract.ParseAddressSuffix` 为 `AddressSuffix` 的反解,通过回比而非负向前瞻实现(Go RE2 不支持前瞻)。
| `ai_matching_setting` | 唯一单例的启用状态、OpenAI-compatible Base URL、模型、超时、内部部署明文 API Key 和更新人;仅管理员设置接口可以读取该字段 |
`collection_task` 的状态仅为 `pending`、`running`、`completed`、`completed_partial`、`failed`。设备身份和心跳表属于 Agent 领取任务的必要基础,不承载 PDD 业务数据。
@@ -352,3 +354,26 @@ PddProductDetailCollector
`GoAutoAccessibilityService.swipePurchase` 的失败分类由 `PurchaseSwipeFailureReason` 枚举提供;共享 `swipeNode` 仅增加可选分类回调,不改变手势目标、轨迹、1500ms 回调等待或其他调用者行为。`GoAutoPurchasePanel` 日志经 `AgentForegroundService` 关联 task、attempt、device 与规则快照哈希,新增预滑动跳过/必需滑动失败标量;不记录节点文字、坐标、原始控件树、截图或凭据。
Server/Web、数据库和任务快照不变;旧 APK 仍有预滑动行为,必须更新 Agent 才生效。相关验证在 `PurchaseRehearsalExecutorTest`,Android 全量测试与 APK 构建入口不变。
## SYB 逐页保存与部分成功(#239)
实现绑定 c6a962d;代码已实现不代表当前线上已部署。每页完整明细在外部请求结束后按页事务保存;页回滚不累计明细/新增/覆盖数,已提交页保留。日期局部读取失败继续下一日期,全局数据库/进度/会话/取消故障停止。当天漂移不在一次运行内重扫;后续运行重新扫描并幂等补齐。
同步状态增加 `partial_success`(部分成功,15 字符,复用现有 varchar(16),无需迁移)。有错误且 created+updated>0 为部分成功;有错误无已提交明细为 failed;完整且无错误为 succeeded(包括无符合店铺的数据)。中断仍为 interrupted,不把中断追认为成功。所有终态沿用活动槽释放规则。
`orderCount` 是已验证页的原始列表读取数量;`detailCount`、`created`、`updated` 为已提交明细及其新增/覆盖数量;`daysProcessed` 是完整通过的日期数,不是已尝试日期数。失败日期/页码/阶段写入现有脱敏限长 errorMessage。部分成功不刷新店铺的完整同步统计。
Web 唯一展示位置为“采集采购 → SYB 同步记录”:列表状态、状态筛选及详情支持部分成功,详情保留已保存数量、错误原因与重新同步补齐提示。定时任务日志只表示异步任务受理,不等于最终业务同步成功。
入口为 `SyncWithShopSnapshot → loadDailyList → importSyncPage`;`server/app/goauto/sybimport/sync_page.go` 封装页事务和提交后计数;`import_handler.go` 判定终态,`sync_run.go` 保存及筛选,Web 复用 `web/src/views/goauto/syb-sync-runs/index.vue`。
## Android 连续临时采集收尾(#250)
当前实现绑定 `37e8b56`(Agent 0.9.72 / 85),用户确认 v5 按次数返回后切回 Agent。仅 Android,无 Admin/API/权限/数据库迁移;取代 v1~v4 首页导航。
- `AgentSettingsFragment` 在采集间隔后复用卡片和 SwitchMaterial;仅开启显示“返回次数”按钮,Material 单选框选择 1~5 次(默认 3),立即保存或取消。成功无常驻反馈,错误/忙碌显示原因。任务忙碌禁用,弹窗选定时再检查。
- `AgentSettingsStore` 在私有 `goauto_agent_settings` 保存 `continuous_collection`(false)与 `continuous_collection_return_count`(3);次数越界或读取错误回落 3,写入失败恢复旧值,无网络加载。
- `requestCurrentPageCollection` 保留原范围判断;`executeTask` 在采集开始固定次数。`executeTaskWhileAwake` 提交结果、结束图片上传后返回 resultStatus;`ContinuousCollectionPolicy.finish` 仅对完整确认成功调用新 `PddCollectionReturnNavigator`。失败/部分成功不导航。冷却间隔不变,连续模式不启动旧 idle-return。
- 删除 `PddCollectionHomeNavigator` 和专属首页/搜索层判据。新导航器仅使用 capture/back、当前包名、openAgentPreservingTab;1~5 次 Back,每次后 500ms,前台离开 PDD 终止剩余 Back;保留风险场景停止。最后打开 Agent 并等待 500ms 验证包名,不验证 PDD 首页。仍在原任务互斥与唤醒锁范围内完成,结束后原 finally 释放。
- `GoAutoCollector` 日志关联 taskId、attemptNumber、deviceId、规则快照 SHA-256;记录 continuousReturn、configuredBacks、固定 reason 和 actions。原因含 count_completed、left_pdd、back_failed、unsafe_page、agent_launch_failed、agent_not_confirmed、navigation_exception、invalid_count;actions 为 Back 尝试数,不代表最终页面。无节点文字、树、截图或个人数据落盘;不扩大采购诊断存储。
- `ContinuousCollectionTest` 覆盖范围/终态、1~5 次、无变化仍按次数、前台变化、风险、动作/启动失败、包名确认和异常;`ContinuousCollectionSettingsTest` 覆盖开关与次数持久化、默认/越界/写入失败。`:app:testDebugUnitTest :app:assembleDebug` 376 项通过。旧首页专属测试随旧行为替换,新版未安装,UI 大字体/横屏、多设备和完整采集真机链路尚未验证。
+14 -2
View File
@@ -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: a445dfe3b76336f5c7387a2da9760c5d6e383ff6
synchronized_at: 2026-09-09T02:47:12Z
wiki_revision: 5a49f94d2c8bebe4e05bf3343834567e294e3894
synchronized_at: 2026-09-09T08:18:31Z
<!-- gitea-wiki-mirror:end -->
# 业务规则与术语
@@ -511,3 +511,15 @@ Web 唯一展示位置为“采集采购 → SYB 同步记录”:列表状态
- 旧文案无候选时,只接受弹层底部固定区域内一个独立、可见启用、非滚动的可点击容器;不在规格滚动区内选择按钮,不取控件树末项,不使用固定屏幕点击坐标。文字与人民币金额子节点合并为一个按钮;支持如“复购价,”及非支付展示文字变化,但单独价格、多个操作按钮或支付/付款/地址/返回等非提交动作均不放行。
- 地址返回检查、最终复核和提交共用目标定位。结构路径保留原文字节点为重新定位锚点,由已有无障碍点击重新查找其可点击父级;不新增下单手势兜底。最终复核成功后才允许该结构路径提交;提交前再次检查路径、类别、文字与边界,发生变化即停止;复核失败清除本地结构提交锚点。
- 既有规格、数量、价格和地址复核,任务租约、服务端/本地不可逆边界及单次提交规则不变。结构识别不支持支付,不增加采购员操作、配置或审批;不改变 Admin、接口、数据库、规则 JSON、通用解析器及滑动执行器。原始控件树、整屏截图、地址全文和凭据不得落盘或上传。
## 临时采集连续模式(#250)
当前实现绑定 `37e8b56`,Agent 0.9.72 / versionCode 85。2026-09-09 用户批准 v5:按配置次数返回后切回 Agent,替代此前“识别并停留 PDD 首页”的方案;不保证 PDD 最终停在首页。代码、376 项 Android 单测及 Debug 构建通过,新版设置与完整真机闭环待验收。
- 连续采集默认关闭,本机即时保存,失败恢复原值;开启后显示返回次数选择,1~5 次、默认 3,取消不改,写入失败恢复旧值。任务忙碌时开关和次数均禁用,选择提交时再次检查;无新增权限。只用于本次人工新建普通临时采集(source=agent_current_page、attemptNumber=1、无替换来源),Admin 下发、重试、替代与采购不参与。
- 单任务执行开始固定返回次数。完整 completed、服务端提交确认且既有图片上传流程结束后,执行有限 Back,每次后等待 500ms;不要求识别商品/搜索/相机/首页,也不要求画面发生变化。图片上传仍沿用既有非致命行为,不因收尾更改结果。
- 每次 Back 前检查前台仍为 PDD,读取页面后再次检查;离开 PDD 则停止余下 Back。次数完成、离开 PDD 或 Back 动作失败后,尝试打开 Agent 保持原 Tab,等待 500ms 并验证前台包名。动作失败不重试,不把请求启动成功直接当作切回成功。
- 沿用风险页安全边界:登录/验证码/风控、地址编辑、订单详情或支付等证据时保留现场,不发送 Back 或强行切回;Activity 缺失、读取异常明确停止。以上例外不引入首页门禁。
- 导航在原任务互斥和唤醒锁内完成,不变更租约/调度。返回失败只显示人工处理提示,不把成功采集改为失败或自动重采。
- 失败、部分成功、提交未确认保留现场,不运行返回步骤,也不触发旧空闲返回倒计时。关闭开关保持原流程,采集间隔不变。
- 不自动拍照、搜索或创建下一任务,不涉及采购/订单/付款;不使用深链、浏览器、OCR/VLM 或坐标兜底。
+21 -2
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Troubleshooting
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Troubleshooting
wiki_revision: b1b1b343917e66288f4282bc6b3b90ea4ff3cca0
synchronized_at: 2026-09-04T11:30:16Z
wiki_revision: 18744477bfd17f396e8c76ec7a2fcc6720acdf6f
synchronized_at: 2026-09-09T03:16:51Z
<!-- gitea-wiki-mirror:end -->
# 故障排查
@@ -85,3 +85,22 @@ sqlite3 -readonly agent-diagnostics.db "SELECT task_id,reason,color_row_count,co
```
读取时记录设备、Agent 版本、任务号和规则快照;工单只回写查询得到的脱敏聚合数值。读取完成后删除本地导出副本。正式 APK 若不允许 `run-as`,停止排查并确认安全的只读诊断出口,不通过放宽应用安全配置或上传完整数据库绕过。
## Android 采购规格入口本地诊断(#249)
实现绑定 `99faf5a`,Agent `0.9.67`(versionCode 80)。单元测试与 Debug 构建已通过,新增版本真机留存/读取尚待验证;本节不属于上文既有实测结论。
采购执行器启动后,在应用私有 `files/purchase_diagnostics/<taskId>_<attemptId>.jsonl` 保存白名单结构化入口诊断。元数据包含 taskId、attemptId、deviceId、agentVersion、phase、ruleHash(当前任务规则快照 SHA-256)、timestamp 和 elapsedMs。现有任务接口只有 attemptId,没有 attemptNumber;用 attemptId 对照服务端历史,不推算重试序号。
证据仅包含入口来源枚举、候选数、点击结果/原因枚举、等待轮数、面板结构布尔值/计数,以及受控恢复是否执行及固定拒绝原因。没有商品标题、规格原文、地址、手机号、Cookie、Token、链接、原始控件树或截图。entryWaitMillis 是该等待循环累计的计划等待量;elapsedMs 才是自本次采购执行器诊断起点计量的实际耗时,timestamp 是后台写入时间。
复用现有单线程诊断队列异步写入,写入/排队失败不改变采购结果;不新增点击、滑动、等待或采购门禁。最多保留最近 5 个 attempt 文件、每文件最后 128 条事件,超过 7 天的文件在服务启动或下次写入时清理。异常断电或存储故障仍可能丢失诊断,不能将日志缺失当成动作未执行;不上传 Admin,不增加服务端接口。
仅对允许 run-as 的 Debug APK,在设备已连接且选定准确序列号后只读提取:
```powershell
adb -s <device-serial> shell run-as cn.ilapage.goauto.agent ls files/purchase_diagnostics
adb -s <device-serial> exec-out run-as cn.ilapage.goauto.agent cat files/purchase_diagnostics/<taskId>_<attemptId>.jsonl
```
先列出文件,再读取本次任务/attempt 的准确文件名;不得扩大为导出所有应用数据或完整数据库。新版安装前的失败不会补生成日志;需要用户授权后重试才能产生新证据。Release 若禁止 run-as 则停止,不更改权限绕过;首版没有导出界面。该功能用于确定拒绝分支,并不代表已经修复对应采购故障。