feat(android): 服务端手动打开拼多多商品与统一忙状态 (#375)

- 新增 DeviceBusyPolicy:服务与界面共用的设备忙判断与原因文案,repurchaseLocalIdle 复用(行为不变,冷却期不算忙)
- 新增 PddManualOpenCoordinator:先判忙,再作废交接凭证,再打开;复用 PddLaunchFallback 事件生成提示
- AgentForegroundService:新增打开动作、结果广播,每 2 秒发布只读忙状态;只打开商品链接,不点击 PDD 内控件

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
This commit is contained in:
QiuSW
2026-10-12 15:53:47 +08:00
co-authored by Claude Sonnet 5.5
parent 23dafcf73f
commit a0d2416c49
3 changed files with 299 additions and 3 deletions
@@ -176,6 +176,7 @@ class AgentForegroundService : Service() {
// Separate, serialized I/O ticks keep history reads and result observation off the
// heartbeat scheduler and, especially, off the single purchase execution thread.
repurchaseExecutor.scheduleWithFixedDelay(::tickRepurchase, 2, 2, TimeUnit.SECONDS)
repurchaseExecutor.scheduleWithFixedDelay(::refreshDeviceBusy, 1, 2, TimeUnit.SECONDS)
diagnosticInstance = this
}
@@ -197,6 +198,12 @@ class AgentForegroundService : Service() {
return START_STICKY
}
}
if (intent?.action == ACTION_OPEN_PDD_PRODUCT) {
val goodsId = intent.getStringExtra(EXTRA_OPEN_PDD_GOODS_ID)
val purchase = intent.getBooleanExtra(EXTRA_OPEN_PDD_PURCHASE, false)
runCatching { repurchaseExecutor.execute { openPddProduct(goodsId, purchase) } }
return START_STICKY
}
if (intent?.action == ACTION_BACKFILL_STOP) {
backfillGuard.cancelled.set(true)
return START_STICKY
@@ -228,6 +235,7 @@ class AgentForegroundService : Service() {
repurchase.requestStop(repurchase.state.roundId)
repurchaseExecutor.shutdownNow()
repurchaseState = RepurchaseState(message = "服务已停止;不会恢复整轮重购")
deviceBusyReason = null
backfillGuard.cancelled.set(true)
runCatching { connectivityManager.unregisterNetworkCallback(networkCallback) }
cancelIdleReturn("服务已停止")
@@ -497,9 +505,47 @@ class AgentForegroundService : Service() {
updateNotification(state.message, force = true)
}
private fun repurchaseLocalIdle(): Boolean = !working.get() && taskMutex.currentTaskId() == null &&
purchaseStore.activeTaskId() == null && purchaseStore.pendingOutbox().isEmpty() &&
stateStore.read().currentTaskId == null && !backfillState.running
private fun deviceBusyInputs(includeRepurchase: Boolean): DeviceBusyPolicy.Inputs {
val mutexTaskId = taskMutex.currentTaskId()
return DeviceBusyPolicy.Inputs(
repurchaseBusy = includeRepurchase && repurchaseState.busy,
backfillRunning = backfillState.running,
currentPageCollecting = mutexTaskId == CURRENT_PAGE_RESERVATION_ID,
taskActive = working.get() || mutexTaskId != null || purchaseStore.activeTaskId() != null ||
stateStore.read().currentTaskId != null,
unuploadedPurchase = purchaseStore.pendingOutbox().isNotEmpty(),
)
}
// The repurchase round itself asks this question between its tasks, so it must not count its own busy flag.
private fun repurchaseLocalIdle(): Boolean = DeviceBusyPolicy.reason(deviceBusyInputs(includeRepurchase = false)) == null
/** Publishes the read-only busy reason for the history detail buttons; click time re-checks. */
private fun refreshDeviceBusy() {
runCatching {
val reason = if (repurchaseClosed.get()) null else DeviceBusyPolicy.reason(deviceBusyInputs(includeRepurchase = true))
if (reason != deviceBusyReason) {
deviceBusyReason = reason
sendBroadcast(Intent(ACTION_DEVICE_BUSY_STATE).setPackage(packageName))
}
}
}
private fun openPddProduct(goodsId: String?, purchase: Boolean) {
val result = runCatching {
PddManualOpenCoordinator(
busyReason = { synchronized(taskMutex) { DeviceBusyPolicy.reason(deviceBusyInputs(includeRepurchase = true)) } },
invalidateHandoff = probeHandoff::invalidate,
launch = { url, preferDirect, diagnostic ->
cn.ilapage.goauto.agent.automation.PddLinkLauncher(this).open(url, preferDirect, diagnostic)
},
).run(goodsId, purchase)
}.getOrDefault(PddManualOpenResult.Failed)
sendBroadcast(Intent(ACTION_OPEN_PDD_RESULT).apply {
setPackage(packageName)
putExtra(EXTRA_OPEN_PDD_MESSAGE, PddManualOpenCoordinator.message(result))
})
}
private fun repurchaseEnvironment(session: RepurchaseSession): String? {
if (repurchaseClosed.get()) return "服务已经停止"
@@ -1811,6 +1857,15 @@ class AgentForegroundService : Service() {
const val ACTION_BACKFILL_STATE = "cn.ilapage.goauto.agent.BACKFILL_STATE"
@Volatile var backfillState = OrderBackfillState()
private set
const val ACTION_OPEN_PDD_PRODUCT = "cn.ilapage.goauto.agent.OPEN_PDD_PRODUCT"
const val ACTION_OPEN_PDD_RESULT = "cn.ilapage.goauto.agent.OPEN_PDD_RESULT"
const val ACTION_DEVICE_BUSY_STATE = "cn.ilapage.goauto.agent.DEVICE_BUSY_STATE"
const val EXTRA_OPEN_PDD_GOODS_ID = "open_pdd_goods_id"
const val EXTRA_OPEN_PDD_PURCHASE = "open_pdd_purchase"
const val EXTRA_OPEN_PDD_MESSAGE = "open_pdd_message"
/** Null when idle; otherwise the user-facing reason. Updated about every two seconds. */
@Volatile var deviceBusyReason: String? = null
private set
const val ACTION_RECONNECT = "cn.ilapage.goauto.agent.RECONNECT"
const val ACTION_CHECK_NOW = "cn.ilapage.goauto.agent.CHECK_NOW"
const val ACTION_CHECK_RESULT = "cn.ilapage.goauto.agent.CHECK_RESULT"
@@ -1867,6 +1922,15 @@ class AgentForegroundService : Service() {
}
}
fun openPddProduct(context: Context, goodsId: String, purchase: Boolean) {
val intent = Intent(context, AgentForegroundService::class.java).apply {
action = ACTION_OPEN_PDD_PRODUCT
putExtra(EXTRA_OPEN_PDD_GOODS_ID, goodsId)
putExtra(EXTRA_OPEN_PDD_PURCHASE, purchase)
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) context.startForegroundService(intent) else context.startService(intent)
}
fun collectCurrentPage(
context: Context,
requestId: String,
@@ -0,0 +1,85 @@
package cn.ilapage.goauto.agent.service
import cn.ilapage.goauto.agent.automation.PddLaunchEvent
import cn.ilapage.goauto.agent.automation.PddLaunchPath
import cn.ilapage.goauto.agent.automation.PddLaunchStatus
/** Single source of truth for "the device is busy"; the service and the UI both consume its result. */
internal object DeviceBusyPolicy {
data class Inputs(
val repurchaseBusy: Boolean = false,
val backfillRunning: Boolean = false,
/** The manual current-page collection holds the task mutex with its own reservation. */
val currentPageCollecting: Boolean = false,
/** A collection/purchase/image-search task is claimed, running or restored. */
val taskActive: Boolean = false,
/** Unuploaded purchase results or a purchase waiting for recovery. */
val unuploadedPurchase: Boolean = false,
)
const val REPURCHASE = "重购进行中"
const val BACKFILL = "订单回填中"
const val CURRENT_PAGE = "当前页采集进行中"
const val TASK = "设备正在执行任务"
const val UNUPLOADED = "有未上传的采购结果"
/** Null means idle. A collection cooldown is intentionally not an input. */
fun reason(inputs: Inputs): String? = when {
inputs.repurchaseBusy -> REPURCHASE
inputs.backfillRunning -> BACKFILL
inputs.currentPageCollecting -> CURRENT_PAGE
inputs.taskActive -> TASK
inputs.unuploadedPurchase -> UNUPLOADED
else -> null
}
}
internal sealed class PddManualOpenResult {
object InvalidGoodsId : PddManualOpenResult()
data class Busy(val reason: String) : PddManualOpenResult()
data class Opened(val fallbackNotice: String?) : PddManualOpenResult()
object Failed : PddManualOpenResult()
}
/** Manual "open in PDD" entry. Only opens the product link; it never clicks anything inside PDD. */
internal class PddManualOpenCoordinator(
private val busyReason: () -> String?,
private val invalidateHandoff: () -> Unit,
private val launch: (url: String, preferDirect: Boolean, diagnostic: (PddLaunchEvent) -> Unit) -> Boolean,
) {
fun run(goodsId: String?, purchase: Boolean): PddManualOpenResult {
val url = link(goodsId) ?: return PddManualOpenResult.InvalidGoodsId
busyReason()?.let { return PddManualOpenResult.Busy(it) }
// The opened page is no longer the product a previous probe prepared.
invalidateHandoff()
val events = mutableListOf<PddLaunchEvent>()
val opened = launch(url, purchase) { events += it }
return if (opened) PddManualOpenResult.Opened(fallbackNotice(events)) else PddManualOpenResult.Failed
}
companion object {
const val OPENED_HINT = "打开期间如有新任务,Agent 会接管屏幕"
const val FAILED = "无法打开拼多多链接"
const val INVALID = "商品编号无效,无法打开"
fun link(goodsId: String?): String? =
goodsId?.takeIf { it.isNotEmpty() && it.all { c -> c in '0'..'9' } }
?.let { "https://mobile.yangkeduo.com/goods.html?goods_id=$it" }
/** Never contains the URL or an exception message; only the simple exception class name. */
fun fallbackNotice(events: List<PddLaunchEvent>): String? {
val direct = events.firstOrNull { it.path == PddLaunchPath.DIRECT && it.status != PddLaunchStatus.ACCEPTED }
?: return null
if (events.none { it.path == PddLaunchPath.BROWSER && it.status == PddLaunchStatus.ACCEPTED }) return null
val name = direct.exceptionClass?.substringAfterLast('.')?.takeIf(String::isNotBlank)
return "拼多多直接打开失败,已改用浏览器" + (name?.let { "($it)" } ?: "")
}
fun message(result: PddManualOpenResult): String = when (result) {
PddManualOpenResult.InvalidGoodsId -> INVALID
is PddManualOpenResult.Busy -> "${result.reason},暂不能打开拼多多"
is PddManualOpenResult.Opened -> listOfNotNull(result.fallbackNotice, OPENED_HINT).joinToString("\n")
PddManualOpenResult.Failed -> FAILED
}
}
}
@@ -0,0 +1,147 @@
package cn.ilapage.goauto.agent.service
import cn.ilapage.goauto.agent.automation.PddLaunchEvent
import cn.ilapage.goauto.agent.automation.PddLaunchFallback
import cn.ilapage.goauto.agent.automation.PddLaunchPath
import cn.ilapage.goauto.agent.automation.PddLaunchStatus
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class PddManualOpenTest {
private class Harness(
val busy: String? = null,
val direct: () -> Boolean = { true },
val browser: () -> Boolean = { true },
) {
val calls = mutableListOf<String>()
var preferDirect: Boolean? = null
val coordinator = PddManualOpenCoordinator(
busyReason = { calls += "busy"; busy },
invalidateHandoff = { calls += "invalidate" },
launch = { _, prefer, diagnostic ->
calls += "launch"
preferDirect = prefer
PddLaunchFallback.open(prefer, direct = { calls += "direct"; direct() },
browser = { calls += "browser"; browser() }, diagnostic = diagnostic)
},
)
}
@Test fun linkUsesGoodsIdOnly() {
assertEquals("https://mobile.yangkeduo.com/goods.html?goods_id=825093359197", PddManualOpenCoordinator.link("825093359197"))
}
@Test fun linkRejectsBlankOrNonDigitGoodsId() {
listOf(null, "", " ", "12a", "1 2", "12&x=1", "-1", "123").forEach { assertNull(it, PddManualOpenCoordinator.link(it)) }
}
@Test fun invalidGoodsIdNeitherChecksBusyNorInvalidatesNorOpens() {
val h = Harness()
assertEquals(PddManualOpenResult.InvalidGoodsId, h.coordinator.run("abc", purchase = false))
assertTrue(h.calls.isEmpty())
}
@Test fun collectionButtonOnlyUsesBrowser() {
val h = Harness(direct = { error("unexpected") })
assertEquals(PddManualOpenResult.Opened(null), h.coordinator.run("1", purchase = false))
assertEquals(false, h.preferDirect)
assertFalse(h.calls.contains("direct"))
assertTrue(h.calls.contains("browser"))
}
@Test fun purchaseButtonPrefersDirectAndStopsWhenAccepted() {
val h = Harness(browser = { error("unexpected") })
assertEquals(PddManualOpenResult.Opened(null), h.coordinator.run("1", purchase = true))
assertEquals(true, h.preferDirect)
assertFalse(h.calls.contains("browser"))
}
@Test fun purchaseDirectExceptionFallsBackToBrowserWithSimpleClassName() {
val h = Harness(direct = { throw SecurityException("secret url and address") })
val result = h.coordinator.run("1", purchase = true)
assertEquals(PddManualOpenResult.Opened("拼多多直接打开失败,已改用浏览器(SecurityException)"), result)
val text = PddManualOpenCoordinator.message(result)
assertFalse(text.contains("secret"))
assertFalse(text.contains("http"))
assertTrue(text.endsWith(PddManualOpenCoordinator.OPENED_HINT))
}
@Test fun purchaseDirectRejectedFallbackNoticeOmitsParenthesesWithoutClass() {
val h = Harness(direct = { false })
assertEquals(PddManualOpenResult.Opened("拼多多直接打开失败,已改用浏览器"), h.coordinator.run("1", purchase = true))
}
@Test fun allPathsFailingReportsOpenFailure() {
val h = Harness(direct = { throw IllegalStateException("x") }, browser = { false })
val result = h.coordinator.run("1", purchase = true)
assertEquals(PddManualOpenResult.Failed, result)
assertEquals("无法打开拼多多链接", PddManualOpenCoordinator.message(result))
}
@Test fun fallbackNoticeNeedsAcceptedBrowser() {
val events = listOf(
PddLaunchEvent(PddLaunchPath.DIRECT, PddLaunchStatus.EXCEPTION, "java.lang.SecurityException"),
PddLaunchEvent(PddLaunchPath.BROWSER, PddLaunchStatus.REJECTED),
)
assertNull(PddManualOpenCoordinator.fallbackNotice(events))
}
@Test fun busyAtClickTimeDoesNotInvalidateOrOpen() {
val h = Harness(busy = DeviceBusyPolicy.TASK)
val result = h.coordinator.run("1", purchase = true)
assertEquals(PddManualOpenResult.Busy("设备正在执行任务"), result)
assertEquals(listOf("busy"), h.calls)
assertEquals("设备正在执行任务,暂不能打开拼多多", PddManualOpenCoordinator.message(result))
}
@Test fun handoffIsInvalidatedBeforeAnyLaunch() {
val h = Harness()
h.coordinator.run("1", purchase = true)
assertEquals(listOf("busy", "invalidate", "launch", "direct"), h.calls)
}
@Test fun handoffIsInvalidatedEvenWhenOpeningFails() {
val h = Harness(direct = { false }, browser = { false })
h.coordinator.run("1", purchase = true)
assertTrue(h.calls.indexOf("invalidate") in 0 until h.calls.indexOf("launch"))
}
@Test fun idleInputsAreNotBusy() {
assertNull(DeviceBusyPolicy.reason(DeviceBusyPolicy.Inputs()))
}
@Test fun runningTaskIsBusy() {
assertEquals("设备正在执行任务", DeviceBusyPolicy.reason(DeviceBusyPolicy.Inputs(taskActive = true)))
}
@Test fun repurchaseRoundIsBusyIncludingBetweenTasks() {
// Between two tasks every task input is false but the round is still busy.
assertEquals("重购进行中", DeviceBusyPolicy.reason(DeviceBusyPolicy.Inputs(repurchaseBusy = true)))
}
@Test fun backfillIsBusy() {
assertEquals("订单回填中", DeviceBusyPolicy.reason(DeviceBusyPolicy.Inputs(backfillRunning = true)))
}
@Test fun currentPageCollectionIsBusy() {
assertEquals("当前页采集进行中", DeviceBusyPolicy.reason(DeviceBusyPolicy.Inputs(currentPageCollecting = true, taskActive = true)))
}
@Test fun unuploadedOrRecoverablePurchaseIsBusy() {
assertEquals("有未上传的采购结果", DeviceBusyPolicy.reason(DeviceBusyPolicy.Inputs(unuploadedPurchase = true)))
}
@Test fun repurchaseLocalIdleViewIgnoresOwnRoundButKeepsOtherWork() {
// The service builds the repurchase-idle inputs with repurchaseBusy=false: the round never blocks itself.
assertNull(DeviceBusyPolicy.reason(DeviceBusyPolicy.Inputs(repurchaseBusy = false)))
assertEquals("订单回填中", DeviceBusyPolicy.reason(DeviceBusyPolicy.Inputs(repurchaseBusy = false, backfillRunning = true)))
}
@Test fun collectionCooldownIsNotAnInput() {
// A cooldown only blocks claiming new collection work; it leaves every busy input false.
assertTrue(DeviceBusyPolicy.Inputs::class.java.declaredFields.none { it.name.contains("cooldown", ignoreCase = true) })
}
}