Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
04c42dca43 | ||
|
|
37e8b567e8 | ||
|
|
ade48a7a38 | ||
|
|
fe321b668c | ||
|
|
c292e58f75 | ||
|
|
a11ad65577 | ||
|
|
7ff2596646 | ||
|
|
074e5aab29 | ||
|
|
659bbc6e1a | ||
|
|
99faf5ad95 |
@@ -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"
|
||||
|
||||
|
||||
+58
@@ -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"
|
||||
}
|
||||
}
|
||||
+3
-2
@@ -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))
|
||||
}
|
||||
|
||||
+92
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
+51
-16
@@ -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"
|
||||
|
||||
+27
@@ -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)
|
||||
|
||||
+81
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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 大字体/横屏、多设备和完整采集真机链路尚未验证。
|
||||
|
||||
@@ -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 或坐标兜底。
|
||||
|
||||
@@ -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 则停止,不更改权限绕过;首版没有导出界面。该功能用于确定拒绝分支,并不代表已经修复对应采购故障。
|
||||
|
||||
Reference in New Issue
Block a user