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:
co-authored by
Claude Sonnet 5.5
parent
23dafcf73f
commit
a0d2416c49
+67
-3
@@ -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) })
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user