fix(agent): restore PDD before current-page collection (#107)

This commit is contained in:
QiuSW
2026-08-27 14:33:03 +08:00
parent 1d1618ce02
commit ea970c755e
9 changed files with 270 additions and 83 deletions
+2 -2
View File
@@ -11,8 +11,8 @@ android {
applicationId = "cn.ilapage.goauto.agent"
minSdk = 23
targetSdk = 34
versionCode = 16
versionName = "0.9.3"
versionCode = 17
versionName = "0.9.4"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
@@ -23,26 +23,93 @@ data class ClipboardReadOutcome(
enum class CurrentPageRestoreDecision {
READY,
BACK_FROM_AGENT,
REJECT,
BACK_THEN_LAUNCH,
LAUNCH,
}
enum class CurrentPageLaunchResult {
STARTED,
START_FAILED,
BACKGROUND_FAILED,
}
enum class PddLaunchResult {
STARTED,
INTENT_MISSING,
FAILED,
}
enum class PddForegroundRestoreResult {
READY,
INTENT_MISSING,
LAUNCH_FAILED,
TIMEOUT,
}
object CurrentPageNavigationPolicy {
fun restoreDecision(currentPackage: String?, agentPackage: String, recentPdd: Boolean): CurrentPageRestoreDecision = when {
currentPackage == PDD_PACKAGE -> CurrentPageRestoreDecision.READY
currentPackage == agentPackage && recentPdd -> CurrentPageRestoreDecision.BACK_FROM_AGENT
else -> CurrentPageRestoreDecision.REJECT
currentPackage == agentPackage && recentPdd -> CurrentPageRestoreDecision.BACK_THEN_LAUNCH
else -> CurrentPageRestoreDecision.LAUNCH
}
fun backgroundAndLaunch(moveTaskToBack: () -> Boolean, launch: () -> Unit): Boolean {
if (!moveTaskToBack()) return false
launch()
return true
fun launchBeforeOptionalBackground(
launch: () -> Unit,
moveTaskToBack: (() -> Boolean)? = null,
): CurrentPageLaunchResult {
runCatching(launch).getOrElse { return CurrentPageLaunchResult.START_FAILED }
if (moveTaskToBack != null && !moveTaskToBack()) return CurrentPageLaunchResult.BACKGROUND_FAILED
return CurrentPageLaunchResult.STARTED
}
private const val PDD_PACKAGE = "com.xunmeng.pinduoduo"
}
class PddForegroundRestorer(
private val currentPackage: () -> String?,
private val recentPdd: () -> Boolean,
private val backFromAgent: () -> Boolean,
private val launchPdd: () -> PddLaunchResult,
private val now: () -> Long,
private val pause: (Long) -> Unit,
) {
fun restore(agentPackage: String, timeoutMillis: Long): PddForegroundRestoreResult {
val deadline = now() + timeoutMillis
when (CurrentPageNavigationPolicy.restoreDecision(currentPackage(), agentPackage, recentPdd())) {
CurrentPageRestoreDecision.READY -> return PddForegroundRestoreResult.READY
CurrentPageRestoreDecision.BACK_THEN_LAUNCH -> {
if (backFromAgent() && waitForPdd(minOf(deadline, now() + BACK_FALLBACK_MILLIS))) {
return PddForegroundRestoreResult.READY
}
}
CurrentPageRestoreDecision.LAUNCH -> Unit
}
return when (launchPdd()) {
PddLaunchResult.INTENT_MISSING -> PddForegroundRestoreResult.INTENT_MISSING
PddLaunchResult.FAILED -> PddForegroundRestoreResult.LAUNCH_FAILED
PddLaunchResult.STARTED -> if (waitForPdd(deadline)) {
PddForegroundRestoreResult.READY
} else {
PddForegroundRestoreResult.TIMEOUT
}
}
}
private fun waitForPdd(deadline: Long): Boolean {
do {
if (currentPackage() == PDD_PACKAGE) return true
if (now() >= deadline) return false
pause(POLL_MILLIS)
} while (true)
}
private companion object {
const val PDD_PACKAGE = "com.xunmeng.pinduoduo"
const val BACK_FALLBACK_MILLIS = 750L
const val POLL_MILLIS = 100L
}
}
object PddShareLinkExtractor {
private val urlPattern = Regex("https://[^\\s]+", RegexOption.IGNORE_CASE)
private val trailingPunctuation = charArrayOf(')', ']', '}', ')', '】', '」', '』', '》', ',', ',', '.', '。', ';', ';')
@@ -99,19 +99,17 @@ class GoAutoAccessibilityService : AccessibilityService(), UiDriver, PddCollecto
return seenAt > 0L && SystemClock.elapsedRealtime() - seenAt in 0..maxAgeMillis
}
fun restoreRecentPddPage(timeoutMillis: Long = 5_000L): Boolean {
when (CurrentPageNavigationPolicy.restoreDecision(currentPackage(), packageName, hasRecentPddForeground())) {
CurrentPageRestoreDecision.READY -> return true
CurrentPageRestoreDecision.REJECT -> return false
CurrentPageRestoreDecision.BACK_FROM_AGENT -> if (!performGlobalAction(GLOBAL_ACTION_BACK)) return false
}
val deadline = SystemClock.elapsedRealtime() + timeoutMillis
do {
if (currentPackage() == PDD_PACKAGE) return true
Thread.sleep(100)
} while (SystemClock.elapsedRealtime() <= deadline)
return false
}
fun restoreRecentPddPage(
launchPdd: () -> PddLaunchResult,
timeoutMillis: Long = 5_000L,
): PddForegroundRestoreResult = PddForegroundRestorer(
currentPackage = ::currentPackage,
recentPdd = ::hasRecentPddForeground,
backFromAgent = { performGlobalAction(GLOBAL_ACTION_BACK) },
launchPdd = launchPdd,
now = SystemClock::elapsedRealtime,
pause = Thread::sleep,
).restore(packageName, timeoutMillis)
fun openAgentStatus(): Boolean = runCatching {
startActivity(
@@ -24,7 +24,10 @@ import cn.ilapage.goauto.agent.R
import cn.ilapage.goauto.agent.automation.CollectionAssembler
import cn.ilapage.goauto.agent.automation.AgentCapabilities
import cn.ilapage.goauto.agent.automation.CurrentPageIdentityRunner
import cn.ilapage.goauto.agent.automation.CurrentPageLaunchResult
import cn.ilapage.goauto.agent.automation.GoAutoAccessibilityService
import cn.ilapage.goauto.agent.automation.PddForegroundRestoreResult
import cn.ilapage.goauto.agent.automation.PddLaunchResult
import cn.ilapage.goauto.agent.automation.PddLinkLauncher
import cn.ilapage.goauto.agent.automation.PddDetailEntryRunner
import cn.ilapage.goauto.agent.automation.PddProductDetailCollector
@@ -59,6 +62,7 @@ import java.util.concurrent.ScheduledExecutorService
import java.util.concurrent.ScheduledFuture
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicLong
import java.util.concurrent.atomic.AtomicReference
import java.util.UUID
@@ -73,6 +77,7 @@ class AgentForegroundService : Service() {
private val registeredThisProcess = AtomicBoolean(false)
private val taskWakeActive = AtomicBoolean(false)
private val collectionCooldownWakeActive = AtomicBoolean(false)
private val currentPageResultNotificationUntil = AtomicLong(0L)
private val idleReturn = IdleReturnCoordinator()
private var taskWakeLock: PowerManager.WakeLock? = null
private var cooldownWakeLock: PowerManager.WakeLock? = null
@@ -320,8 +325,15 @@ class AgentForegroundService : Service() {
}
runningTaskId.set(task.taskId)
stateStore.setActiveTask(task.taskId, "collection")
executeTask(api, task, credentials.token)
publishCurrentPageResult(task.taskId, "临时采集任务 #${task.taskId} 已结束,请查看采集记录。")
val outcome = executeTask(api, task, credentials.token)
publishCurrentPageResult(
task.taskId,
if (outcome.successful) {
"临时采集任务 #${task.taskId} 已结束,请查看采集记录。"
} else {
currentPageUserMessage(outcome.code.orEmpty(), outcome.message.orEmpty())
},
)
} catch (error: TaskFailure) {
publishCurrentPageResult(taskId, currentPageUserMessage(error.code, error.message ?: "采集失败"))
} catch (error: AgentApiException) {
@@ -355,6 +367,8 @@ class AgentForegroundService : Service() {
}
private fun publishCurrentPageResult(taskId: Long, message: String) {
currentPageResultNotificationUntil.set(SystemClock.elapsedRealtime() + CURRENT_PAGE_RESULT_NOTIFICATION_MILLIS)
updateNotification(message, force = true)
sendBroadcast(Intent(ACTION_CURRENT_PAGE_RESULT).apply {
setPackage(packageName)
putExtra(EXTRA_CURRENT_PAGE_TASK_ID, taskId)
@@ -570,19 +584,27 @@ class AgentForegroundService : Service() {
.toString()
private fun executeTask(api: AgentApiClient, initialTask: cn.ilapage.goauto.agent.network.AgentTask, token: String) {
private fun executeTask(
api: AgentApiClient,
initialTask: cn.ilapage.goauto.agent.network.AgentTask,
token: String,
): TaskExecutionSummary {
acquireTaskWakeLock()
try {
return try {
executeTaskWhileAwake(api, initialTask, token)
} finally {
releaseTaskWakeLock()
}
}
private fun executeTaskWhileAwake(api: AgentApiClient, initialTask: cn.ilapage.goauto.agent.network.AgentTask, token: String) {
private fun executeTaskWhileAwake(
api: AgentApiClient,
initialTask: cn.ilapage.goauto.agent.network.AgentTask,
token: String,
): TaskExecutionSummary {
var started = initialTask.status == "running"
var resultSafelySubmitted = false
try {
return try {
val claimed = if (initialTask.status == "pending") {
api.claimTask(initialTask.taskId, UUID.randomUUID().toString(), token)
} else initialTask
@@ -603,8 +625,14 @@ class AgentForegroundService : Service() {
if (rule.schemaVersion != 2) {
throw TaskFailure("RULE_INVALID", "当前页面采集只支持新版采集规则")
}
if (!accessibility.restoreRecentPddPage()) {
throw TaskFailure("CURRENT_PDD_PAGE_NOT_FOUND", "没有找到商品详情页")
when (accessibility.restoreRecentPddPage(::launchPddToForeground)) {
PddForegroundRestoreResult.READY -> Unit
PddForegroundRestoreResult.INTENT_MISSING ->
throw TaskFailure("PDD_APP_UNAVAILABLE", "没有找到拼多多应用,请确认已安装")
PddForegroundRestoreResult.LAUNCH_FAILED ->
throw TaskFailure("PDD_FOREGROUND_LAUNCH_FAILED", "无法打开拼多多,请手动打开商品页后重试")
PddForegroundRestoreResult.TIMEOUT ->
throw TaskFailure("CURRENT_PDD_PAGE_NOT_FOUND", "拼多多未能切换到前台")
}
val identityResult = CurrentPageIdentityRunner(
driver = accessibility,
@@ -653,18 +681,22 @@ class AgentForegroundService : Service() {
resultSafelySubmitted = true
stateStore.update("ONLINE", "任务 #${task.taskId} 已提交:${result.status}", tokenStored = true)
beginPostCollectionCooldowns()
TaskExecutionSummary(successful = true)
} catch (error: TaskFailure) {
if (started) {
resultSafelySubmitted = failSafely(api, initialTask.taskId, token, error.code, error.message ?: "采集失败")
if (resultSafelySubmitted) beginPostCollectionCooldowns()
}
TaskExecutionSummary(false, error.code, error.message)
} catch (error: AgentApiException) {
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()
}
TaskExecutionSummary(false, "AGENT_EXECUTION_ERROR", error.message)
} finally {
if (!resultSafelySubmitted) cancelIdleReturn("采集结果未安全提交")
}
@@ -934,10 +966,25 @@ class AgentForegroundService : Service() {
.build()
}
private fun updateNotification(content: String) {
private fun updateNotification(content: String, force: Boolean = false) {
if (!force && SystemClock.elapsedRealtime() < currentPageResultNotificationUntil.get()) return
getSystemService(NotificationManager::class.java).notify(NOTIFICATION_ID, notification(content))
}
private fun launchPddToForeground(): PddLaunchResult {
val launchIntent = packageManager.getLaunchIntentForPackage(PDD_PACKAGE)
?: return PddLaunchResult.INTENT_MISSING
launchIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
return runCatching { startActivity(launchIntent) }
.fold(
onSuccess = { PddLaunchResult.STARTED },
onFailure = {
Log.w("GoAutoAgent", "failed to bring PDD to foreground", it)
PddLaunchResult.FAILED
},
)
}
companion object {
const val ACTION_RECONNECT = "cn.ilapage.goauto.agent.RECONNECT"
const val ACTION_CHECK_NOW = "cn.ilapage.goauto.agent.CHECK_NOW"
@@ -966,6 +1013,8 @@ class AgentForegroundService : Service() {
private const val COOLDOWN_WAKE_GRACE_MILLIS = 5_000L
private const val RETURN_CONFIRM_DELAY_MILLIS = 750L
private const val CURRENT_PAGE_RESERVATION_ID = Long.MAX_VALUE
private const val PDD_PACKAGE = "com.xunmeng.pinduoduo"
private const val CURRENT_PAGE_RESULT_NOTIFICATION_MILLIS = 30_000L
fun start(context: Context, reconnect: Boolean = false) {
val intent = Intent(context, AgentForegroundService::class.java).apply {
@@ -989,18 +1038,28 @@ class AgentForegroundService : Service() {
}
}
fun collectCurrentPage(context: Context, requestId: String) {
fun collectCurrentPage(context: Context, requestId: String): CurrentPageLaunchResult {
val intent = Intent(context, AgentForegroundService::class.java).apply {
action = ACTION_CURRENT_PAGE_COLLECTION
putExtra(EXTRA_CURRENT_PAGE_REQUEST_ID, requestId)
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
context.startForegroundService(intent)
} else {
context.startService(intent)
}
return cn.ilapage.goauto.agent.automation.CurrentPageNavigationPolicy.launchBeforeOptionalBackground(
launch = {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
context.startForegroundService(intent)
} else {
context.startService(intent)
}
},
)
}
}
}
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,
)
@@ -24,7 +24,7 @@ import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
import cn.ilapage.goauto.agent.R
import cn.ilapage.goauto.agent.identity.SecureDeviceStore
import cn.ilapage.goauto.agent.automation.GoAutoAccessibilityService
import cn.ilapage.goauto.agent.automation.CurrentPageNavigationPolicy
import cn.ilapage.goauto.agent.automation.CurrentPageLaunchResult
import cn.ilapage.goauto.agent.network.AgentApiClient
import cn.ilapage.goauto.agent.network.CollectionHistoryDetail
import cn.ilapage.goauto.agent.network.CollectionHistoryItem
@@ -494,11 +494,13 @@ class TaskHistoryFragment : Fragment() {
.setNegativeButton("取消", null)
.setPositiveButton("开始采集") { _, _ ->
toast("正在创建临时采集任务…")
val launched = CurrentPageNavigationPolicy.backgroundAndLaunch(
moveTaskToBack = { requireActivity().moveTaskToBack(true) },
launch = { AgentForegroundService.collectCurrentPage(context, UUID.randomUUID().toString()) },
)
if (!launched) showCurrentPageBlocked("无法返回拼多多,请重新打开商品后再试。")
when (AgentForegroundService.collectCurrentPage(context, UUID.randomUUID().toString())) {
CurrentPageLaunchResult.STARTED -> Unit
CurrentPageLaunchResult.START_FAILED ->
showCurrentPageBlocked("系统未允许启动采集服务,请保持 Agent 在前台后重试。")
CurrentPageLaunchResult.BACKGROUND_FAILED ->
showCurrentPageBlocked("无法将 Agent 切到后台,请手动返回拼多多后重试。")
}
}
.show()
}
@@ -4,14 +4,17 @@ import cn.ilapage.goauto.agent.automation.CollectionRule
import cn.ilapage.goauto.agent.automation.ClipboardReadOutcome
import cn.ilapage.goauto.agent.automation.CurrentPageIdentityConfig
import cn.ilapage.goauto.agent.automation.CurrentPageIdentityRunner
import cn.ilapage.goauto.agent.automation.CurrentPageLaunchResult
import cn.ilapage.goauto.agent.automation.CurrentPageNavigationPolicy
import cn.ilapage.goauto.agent.automation.CurrentPageRestoreDecision
import cn.ilapage.goauto.agent.automation.FreshActionResult
import cn.ilapage.goauto.agent.automation.FreshClickOutcome
import cn.ilapage.goauto.agent.automation.FreshClickReason
import cn.ilapage.goauto.agent.automation.NodeBounds
import cn.ilapage.goauto.agent.automation.NodeSelector
import cn.ilapage.goauto.agent.automation.PageEvidence
import cn.ilapage.goauto.agent.automation.PddForegroundRestoreResult
import cn.ilapage.goauto.agent.automation.PddForegroundRestorer
import cn.ilapage.goauto.agent.automation.PddLaunchResult
import cn.ilapage.goauto.agent.automation.PddCollectorDriver
import cn.ilapage.goauto.agent.automation.PddShareLinkExtractor
import cn.ilapage.goauto.agent.automation.SnapshotNode
@@ -27,47 +30,95 @@ import org.junit.Test
class CurrentPageCollectionTest {
@Test
fun backgroundsAgentBeforeLaunchingCurrentPageCollection() {
fun launchesServiceBeforeOptionalBackgroundFallback() {
val calls = mutableListOf<String>()
val launched = CurrentPageNavigationPolicy.backgroundAndLaunch(
moveTaskToBack = { calls += "background"; true },
val result = CurrentPageNavigationPolicy.launchBeforeOptionalBackground(
launch = { calls += "launch" },
moveTaskToBack = { calls += "background"; true },
)
assertTrue(launched)
assertEquals(listOf("background", "launch"), calls)
assertEquals(CurrentPageLaunchResult.STARTED, result)
assertEquals(listOf("launch", "background"), calls)
}
@Test
fun doesNotLaunchWhenAgentTaskCannotMoveToBackground() {
var launchCalled = false
val launched = CurrentPageNavigationPolicy.backgroundAndLaunch(
fun distinguishesServiceLaunchFailureFromBackgroundFailure() {
var backgroundCalled = false
val launchFailure = CurrentPageNavigationPolicy.launchBeforeOptionalBackground(
launch = { error("blocked") },
moveTaskToBack = { backgroundCalled = true; true },
)
val backgroundFailure = CurrentPageNavigationPolicy.launchBeforeOptionalBackground(
launch = {},
moveTaskToBack = { false },
launch = { launchCalled = true },
)
assertFalse(launched)
assertEquals(CurrentPageLaunchResult.START_FAILED, launchFailure)
assertFalse(backgroundCalled)
assertEquals(CurrentPageLaunchResult.BACKGROUND_FAILED, backgroundFailure)
}
@Test
fun keepsPddForegroundWithoutBackOrRelaunch() {
var backCalled = false
var launchCalled = false
val result = restorer(
currentPackage = { PDD_PACKAGE },
back = { backCalled = true; true },
launch = { launchCalled = true; PddLaunchResult.STARTED },
).restore(AGENT_PACKAGE, 5_000)
assertEquals(PddForegroundRestoreResult.READY, result)
assertFalse(backCalled)
assertFalse(launchCalled)
}
@Test
fun restoresOnlyPddOrOneRecentAgentFallback() {
assertEquals(
CurrentPageRestoreDecision.READY,
CurrentPageNavigationPolicy.restoreDecision(PDD_PACKAGE, "cn.ilapage.goauto.agent", recentPdd = true),
)
assertEquals(
CurrentPageRestoreDecision.BACK_FROM_AGENT,
CurrentPageNavigationPolicy.restoreDecision("cn.ilapage.goauto.agent", "cn.ilapage.goauto.agent", recentPdd = true),
)
assertEquals(
CurrentPageRestoreDecision.REJECT,
CurrentPageNavigationPolicy.restoreDecision("com.android.settings", "cn.ilapage.goauto.agent", recentPdd = true),
)
assertEquals(
CurrentPageRestoreDecision.REJECT,
CurrentPageNavigationPolicy.restoreDecision("cn.ilapage.goauto.agent", "cn.ilapage.goauto.agent", recentPdd = false),
)
fun restoresRecentPddFromAgentWithOneBackOnly() {
var foreground = AGENT_PACKAGE
var launchCalled = false
val result = restorer(
currentPackage = { foreground },
back = { foreground = PDD_PACKAGE; true },
launch = { launchCalled = true; PddLaunchResult.STARTED },
).restore(AGENT_PACKAGE, 5_000)
assertEquals(PddForegroundRestoreResult.READY, result)
assertFalse(launchCalled)
}
@Test
fun explicitlyLaunchesPddFromThirdPartyForeground() {
var foreground = "com.android.launcher"
val result = restorer(
currentPackage = { foreground },
launch = { foreground = PDD_PACKAGE; PddLaunchResult.STARTED },
).restore(AGENT_PACKAGE, 5_000)
assertEquals(PddForegroundRestoreResult.READY, result)
}
@Test
fun reportsMissingPddLaunchIntent() {
val result = restorer(
currentPackage = { "com.android.launcher" },
launch = { PddLaunchResult.INTENT_MISSING },
).restore(AGENT_PACKAGE, 5_000)
assertEquals(PddForegroundRestoreResult.INTENT_MISSING, result)
}
@Test
fun reportsPddForegroundTimeoutAfterLaunch() {
var elapsed = 0L
val result = restorer(
currentPackage = { "com.android.launcher" },
launch = { PddLaunchResult.STARTED },
now = { elapsed },
pause = { elapsed += it },
).restore(AGENT_PACKAGE, 500)
assertEquals(PddForegroundRestoreResult.TIMEOUT, result)
}
@Test
@@ -170,8 +221,18 @@ class CurrentPageCollectionTest {
private companion object {
const val PDD_PACKAGE = "com.xunmeng.pinduoduo"
const val AGENT_PACKAGE = "cn.ilapage.goauto.agent"
const val ACTIVITY = "com.xunmeng.pinduoduo.activity.NewPageActivity"
fun restorer(
currentPackage: () -> String?,
recentPdd: () -> Boolean = { true },
back: () -> Boolean = { false },
launch: () -> PddLaunchResult,
now: () -> Long = { 0L },
pause: (Long) -> Unit = {},
) = PddForegroundRestorer(currentPackage, recentPdd, back, launch, now, pause)
fun rule() = CollectionRule(
schemaVersion = 2,
steps = emptyList(),
+3 -3
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: ecfe6f881cb937688547b0fe819734813d93dd1b
synchronized_at: 2026-08-27T02:38:43Z
wiki_revision: 7a1281d8873cc6afaf121c13c0562a3caf1d3eb7
synchronized_at: 2026-08-27T06:25:11Z
<!-- gitea-wiki-mirror:end -->
# 架构与代码地图
@@ -213,7 +213,7 @@ Android Portal/Agent
- 服务端入口位于 `server/app/goauto/task/current_page.go`:`CreateCurrentPage` 原子完成设备校验、任务创建、规则快照和租约;`IdentifyCurrentPage` 负责白名单短链解析、PDD 商品复用/创建和任务身份绑定。路由为 `/api/agent/v1/current-page-collection-tasks` 及其 `/{taskId}/identify`。
- 默认规则设置位于 `server/app/goauto/rule/agent_manual_setting.go`,使用单行表 `agent_manual_collection_setting`。迁移 `1787790000000_agent_current_page_collection.go` 增加任务来源、识别幂等字段、可空 PDD 外键和默认规则表,并对旧任务回填 `admin`。
- `collection_task.source` 目前只允许 `admin` / `agent_current_page`。当前页面任务创建时由设备运行槽防并发,识别商品后再参与 PDD 商品活动槽;结构化结果仍写入既有任务及规格子表,不新增临时任务表。
- Android 入口仍由 `TaskHistoryFragment` 承载;`AgentForegroundService` 先用 `TaskExecutionMutex` 预占本地串行槽,服务端创建成功后把预占转为真实 taskId,再恢复 PDD、识别身份并调用既有 `PddProductDetailCollector`。
- Android 入口仍由 `TaskHistoryFragment` 承载;点击确认时先启动 `AgentForegroundService`,不先把 Agent 退到后台。服务先用 `TaskExecutionMutex` 预占本地串行槽,服务端创建成功后把预占转为真实 taskId;前台已是 PDD 时不执行返回,前台仍是 Agent 且近期见过 PDD 时只尝试一次返回,否则使用不清理任务栈、不带深链参数的 PDD 启动 Intent 恢复现有任务,并在有界等待确认 PDD 前台后才识别身份和调用既有 `PddProductDetailCollector`。
- `CurrentPageIdentityRunner` 负责页面证据、唯一分享/复制入口和面板清理;`ClipboardRelayActivity` 使用独立、不可导出的短生命周期任务在前台读取新鲜剪贴板,完成后移除中转任务并露出原 PDD 页面。原始剪贴板不进入日志、缓存或网络请求。
- v2 规则新增可选 `currentPageIdentity`(分享/复制别名和三个有界超时),旧 v2 规则由 Android 使用安全默认值;新设备能力为 `collector.pdd.current-page-share.v1`。
- 该路径复用既有结果/失败接口、PDD 最新档案写回、任务结束返回和采集间隔,不调用浏览器导航,不进入任何采购、地址、创建订单或支付代码。
+3 -3
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: 579d9b25297d7b404c68aca4a29ef3179555baa9
synchronized_at: 2026-08-27T02:38:52Z
wiki_revision: fa2a6f6a41290095a2625771715ad28c1c6c6736
synchronized_at: 2026-08-27T06:25:24Z
<!-- gitea-wiki-mirror:end -->
# 业务规则与术语
@@ -277,7 +277,7 @@ synchronized_at: 2026-08-27T02:38:52Z
- 操作员先人工在 PDD App 打开并停留在目标商品详情页,再从 Agent 采集 Tab 点击“采集”;该入口采集当前页面,不是重置历史任务。
- Agent 在请求服务端前取得采集/采购共用的本地互斥锁。服务端原子校验设备在线、空闲、跨域活动任务、默认规则和能力,创建固定当前设备且已进入 `running` 的标准采集任务。
- Agent 只通过系统返回恢复刚才的 PDD 任务栈,并重新验证包名、精确 Activity 与页面强证据;不得启动浏览器、从桌面重开 PDD、自动搜索或自动选择相似商品。
- Agent 启动当前页面采集前先保持自身在前台并启动既有前台服务。恢复时若 PDD 已在前台则不返回;若 Agent 仍在前台且近期见过 PDD,只尝试一次系统返回;仍未恢复时只使用 PDD 自身的不带参数启动 Intent 拉回现有任务,不附加清栈/重置标志。恢复后必须重新验证包名、精确 Activity 与页面强证据;不得启动浏览器、构造深链、自动搜索或自动选择相似商品。
- 恢复详情页后先按规则唯一点击“分享”与“复制链接”,只读取本次复制后的单条、有限长度剪贴板文本;Android 只保留唯一白名单 URL,服务端再校验短链并确认 goods_id。原始剪贴板、分享文案、控件树和截图都不保存。
- goods_id 已存在时复用唯一 PDD 商品,成功或部分成功结果按既有最新档案覆盖规则更新;不存在时创建 PDD 商品。该流程不自动停用或替换旧商品,也不修改虾皮到 PDD 的关联。
- 管理员必须配置一条存活的 PDD 商品详情 v2 规则作为 Agent 手动采集默认规则;迁移只做一次安全初选,创建任务时仍实时验证规则与设备 `collector.pdd.current-page-share.v1` 能力。
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Local-Development-and-Verification
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Local-Development-and-Verification.-
wiki_revision: 9253f4f7557eeb014032edd7b0645de19a1516f5
synchronized_at: 2026-08-27T02:39:03Z
wiki_revision: 3dc6c3774b31e28f6ad60f564d673a5cabdbb678
synchronized_at: 2026-08-27T06:25:36Z
<!-- gitea-wiki-mirror:end -->
# 本地开发与验证
@@ -250,12 +250,12 @@ adb shell am start -n cn.ilapage.goauto.agent/.MainActivity
- 将范围设为非固定值并完成一次采集,记录状态页显示的实际剩余秒数;间隔期间重启 Agent 前台服务,确认按同一抽取结果恢复且没有重新随机。使用 `adb shell dumpsys power` 确认 `:collection-cooldown` WakeLock 有界持有并在到期或服务停止后释放。
- 此项验证不要求创建正式采购订单;没有单独授权时不得点击创建订单,永久禁止支付。
### Android Agent 0.9.0 当前页面临时采集检查(#101)
### Android Agent 0.9.4 当前页面临时采集检查(#101/#107)
- 自动化验证运行 `cd android && .\gradlew.bat testDebugUnitTest assembleDebug`,服务端运行 `cd server && go test ./...`;覆盖本地串行预占转移、分享 URL 白名单、唯一分享/复制入口、默认规则、创建/识别幂等、身份冲突和结果写回。
- 本地 MySQL 8.4 必须先在明确授权后运行 `cd server && go run . migrate -c config/settings.yml`,确认迁移版本 `1787790000000` 已应用;重复执行应报告 0 个新增迁移。迁移保留旧任务并回填来源 `admin`。
- 真机覆盖安装前确认设备空闲、Agent 0.9.0 已上报 `collector.pdd.current-page-share.v1`、无障碍已人工开启,服务端已有 Agent 手动采集默认规则。
- 在 PDD 人工打开已授权的测试商品详情页,切回 Agent 采集 Tab 点击“采集”并确认;验证 Agent 不启动浏览器,只返回原 PDD 页面,依次完成分享、复制链接、goods_id 识别和常规采集。
- 真机覆盖安装前确认设备空闲、Agent 0.9.4 已上报 `collector.pdd.current-page-share.v1`、无障碍已人工开启,服务端已有 Agent 手动采集默认规则。
- 在 PDD 人工打开已授权的测试商品详情页,分别经 PDD 直接切换、桌面图标和最近任务进入 Agent,点击“采集”并确认;验证前台服务先启动,PDD 现有任务被拉回前台且仍停在原商品详情页,没有清栈、重置首页或启动浏览器,再依次完成分享、复制链接、goods_id 识别和常规采集。另验证 PDD 未安装、启动 Intent 缺失、前台切换超时和前台服务启动被拒绝时均有可见反馈。
- 分别验证直链和 PDD 短链;成功/部分成功应显示来源“Agent 当前页面”,相同 goods_id 不产生重复商品,任务详情与 PDD 最新档案一致。
- 断开网络、离开详情页、制造重复分享入口或剪贴板不可用时,应得到普通人可理解的失败原因并释放设备槽;原始分享文案、剪贴板、控件树和截图不得出现在数据库或日志。
- 验证期间不得自动搜索或选择相似商品,不得修改虾皮关联,不创建采购任务、不修改地址、不创建订单,永久禁止支付。