feat(#130): collect replacement products from Agent

This commit is contained in:
QiuSW
2026-08-28 18:20:24 +08:00
parent 4862d48687
commit 33d4bb17ad
19 changed files with 843 additions and 105 deletions
@@ -43,6 +43,7 @@ data class AgentTask(
val leaseVersion: Long,
val status: String,
val source: String,
val replacementOriginType: String,
)
data class CurrentPageIdentity(
@@ -100,6 +101,11 @@ data class CollectionHistoryDetail(
val colorPrices: List<HistoryColorPrice>,
val skus: List<HistorySku>,
val missing: List<String>,
val replacementEligible: Boolean,
val replacementDisabledReason: String?,
val replacementMappingStatus: String?,
val replacementActivationStatus: String?,
val replacementActivationErrorMessage: String?,
)
data class CollectionResetResult(val taskId: Long, val status: String, val replayed: Boolean)
@@ -132,7 +138,14 @@ data class PurchaseHistoryItem(
val createdAt: String,
)
data class PurchaseHistoryDetail(val task: PurchaseHistoryItem)
data class PurchaseHistoryDetail(
val task: PurchaseHistoryItem,
val replacementEligible: Boolean,
val replacementDisabledReason: String?,
val replacementMappingStatus: String?,
val replacementActivationStatus: String?,
val replacementActivationErrorMessage: String?,
)
class AgentApiException(
val status: Int,
@@ -184,8 +197,19 @@ class AgentApiClient(private val serverUrl: String) {
return task(response.getJSONObject("data"))
}
fun createCurrentPageCollectionTask(requestId: String, token: String): AgentTask {
fun createCurrentPageCollectionTask(
requestId: String,
token: String,
replacementOriginType: String? = null,
replacementOriginTaskId: Long? = null,
): AgentTask {
val payload = JSONObject().put("requestId", requestId)
if (!replacementOriginType.isNullOrBlank() && replacementOriginTaskId != null) {
payload.put(
"replacementOrigin",
JSONObject().put("type", replacementOriginType).put("taskId", replacementOriginTaskId),
)
}
return task(requireNotNull(request("POST", "/api/agent/v1/current-page-collection-tasks", payload, token)).getJSONObject("data"))
}
@@ -275,6 +299,11 @@ class AgentApiClient(private val serverUrl: String) {
HistorySku(specs, item.getLong("priceCent"), item.getBoolean("available"), item.getBoolean("complete"))
},
missing = data.getJSONArray("missing").strings(),
replacementEligible = data.optBoolean("replacementEligible"),
replacementDisabledReason = data.nullableString("replacementDisabledReason"),
replacementMappingStatus = data.nullableString("replacementMappingStatus"),
replacementActivationStatus = data.nullableString("replacementActivationStatus"),
replacementActivationErrorMessage = data.nullableString("replacementActivationErrorMessage"),
)
}
@@ -294,7 +323,14 @@ class AgentApiClient(private val serverUrl: String) {
fun purchaseHistoryDetail(taskId: Long, token: String): PurchaseHistoryDetail {
val data = requireNotNull(request("GET", "/api/agent/v1/purchase-tasks/$taskId", null, token)).getJSONObject("data")
return PurchaseHistoryDetail(purchaseHistoryItem(data.getJSONObject("task")))
return PurchaseHistoryDetail(
task = purchaseHistoryItem(data.getJSONObject("task")),
replacementEligible = data.optBoolean("replacementEligible"),
replacementDisabledReason = data.nullableString("replacementDisabledReason"),
replacementMappingStatus = data.nullableString("replacementMappingStatus"),
replacementActivationStatus = data.nullableString("replacementActivationStatus"),
replacementActivationErrorMessage = data.nullableString("replacementActivationErrorMessage"),
)
}
fun retryPurchaseTask(taskId: Long, requestId: String, token: String): PurchaseRetryResult {
@@ -345,6 +381,7 @@ class AgentApiClient(private val serverUrl: String) {
leaseVersion = data.getLong("leaseVersion"),
status = data.getString("status"),
source = data.optString("source", "admin"),
replacementOriginType = data.optString("replacementOriginType"),
)
private fun purchaseTask(data: JSONObject) = PurchaseAgentTask(
@@ -131,7 +131,11 @@ class AgentForegroundService : Service() {
if (intent?.action == ACTION_RECONNECT) registeredThisProcess.set(false)
if (intent?.action == ACTION_CHECK_NOW) manualCheckRequested.set(true)
if (intent?.action == ACTION_CURRENT_PAGE_COLLECTION) {
requestCurrentPageCollection(intent.getStringExtra(EXTRA_CURRENT_PAGE_REQUEST_ID).orEmpty())
requestCurrentPageCollection(
intent.getStringExtra(EXTRA_CURRENT_PAGE_REQUEST_ID).orEmpty(),
intent.getStringExtra(EXTRA_REPLACEMENT_ORIGIN_TYPE),
intent.getLongExtra(EXTRA_REPLACEMENT_ORIGIN_TASK_ID, 0L).takeIf { it > 0L },
)
return START_STICKY
}
triggerSync()
@@ -291,18 +295,22 @@ class AgentForegroundService : Service() {
})
}
private fun requestCurrentPageCollection(requestId: String) {
private fun requestCurrentPageCollection(
requestId: String,
replacementOriginType: String?,
replacementOriginTaskId: Long?,
) {
if (requestId.isBlank()) {
publishCurrentPageResult(0L, "采集请求无效,请重试。")
publishCurrentPageResult(0L, "采集请求无效,请重试。", replacementOriginType, replacementOriginTaskId)
return
}
activeCollectionCooldown()?.let { ticket ->
val remaining = CollectionCooldownPolicy.remainingSeconds(System.currentTimeMillis(), ticket)
publishCurrentPageResult(0L, "采集间隔中,还需 $remaining 秒。")
publishCurrentPageResult(0L, "采集间隔中,还需 $remaining 秒。", replacementOriginType, replacementOriginTaskId)
return
}
if (!taskMutex.tryAcquire(CURRENT_PAGE_RESERVATION_ID)) {
publishCurrentPageResult(0L, "设备正在执行任务,请稍后再试。")
publishCurrentPageResult(0L, "设备正在执行任务,请稍后再试。", replacementOriginType, replacementOriginTaskId)
return
}
cancelIdleReturn("正在创建当前页面采集任务")
@@ -318,7 +326,12 @@ class AgentForegroundService : Service() {
throw TaskFailure("CURRENT_PDD_PAGE_NOT_FOUND", "请先在拼多多打开目标商品详情页")
}
val api = AgentApiClient(serverUrl)
val task = api.createCurrentPageCollectionTask(requestId, credentials.token)
val task = api.createCurrentPageCollectionTask(
requestId,
credentials.token,
replacementOriginType,
replacementOriginTaskId,
)
taskId = task.taskId
check(taskMutex.transfer(CURRENT_PAGE_RESERVATION_ID, task.taskId)) {
"当前页面采集本地占用状态不一致"
@@ -329,15 +342,21 @@ class AgentForegroundService : Service() {
publishCurrentPageResult(
task.taskId,
if (outcome.successful) {
"临时采集任务 #${task.taskId} 已结束,请查看采集记录。"
if (task.replacementOriginType.isNotBlank()) {
"已替换,正在匹配规格"
} else {
"临时采集任务 #${task.taskId} 已结束,请查看采集记录。"
}
} else {
currentPageUserMessage(outcome.code.orEmpty(), outcome.message.orEmpty())
},
replacementOriginType,
replacementOriginTaskId,
)
} catch (error: TaskFailure) {
publishCurrentPageResult(taskId, currentPageUserMessage(error.code, error.message ?: "采集失败"))
publishCurrentPageResult(taskId, currentPageUserMessage(error.code, error.message ?: "采集失败"), replacementOriginType, replacementOriginTaskId)
} catch (error: AgentApiException) {
publishCurrentPageResult(taskId, currentPageUserMessage(error.code, error.message))
publishCurrentPageResult(taskId, currentPageUserMessage(error.code, error.message), replacementOriginType, replacementOriginTaskId)
} catch (error: Exception) {
if (taskId > 0L) {
val token = runCatching { identityStore.credentials()?.token }.getOrNull()
@@ -353,7 +372,7 @@ class AgentForegroundService : Service() {
}
}
}
publishCurrentPageResult(taskId, "采集失败,请查看任务详情。")
publishCurrentPageResult(taskId, "采集失败,请查看任务详情。", replacementOriginType, replacementOriginTaskId)
} finally {
if (taskId > 0L) {
runningTaskId.compareAndSet(taskId, null)
@@ -366,13 +385,20 @@ class AgentForegroundService : Service() {
}
}
private fun publishCurrentPageResult(taskId: Long, message: String) {
private fun publishCurrentPageResult(
taskId: Long,
message: String,
replacementOriginType: String? = null,
replacementOriginTaskId: Long? = null,
) {
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)
putExtra(EXTRA_CURRENT_PAGE_MESSAGE, message)
replacementOriginType?.let { putExtra(EXTRA_REPLACEMENT_ORIGIN_TYPE, it) }
replacementOriginTaskId?.let { putExtra(EXTRA_REPLACEMENT_ORIGIN_TASK_ID, it) }
})
}
@@ -382,6 +408,7 @@ class AgentForegroundService : Service() {
"CURRENT_PDD_PAGE_NOT_FOUND" -> "没有找到商品详情页,请重新打开商品后再试。"
"PDD_SHARE_UNAVAILABLE", "PDD_COPY_LINK_UNAVAILABLE", "PDD_CLIPBOARD_UNAVAILABLE", "PDD_SHARE_LINK_INVALID" ->
"无法识别商品链接,请确认商品页可以分享后再试。"
"REPLACEMENT_ACTIVATION_FAILED" -> "采集成功,但替换生效失败,请稍后重试。"
else -> fallback.ifBlank { "采集失败,请稍后重试。" }
}
@@ -694,6 +721,10 @@ class AgentForegroundService : Service() {
}
TaskExecutionSummary(false, error.code, error.message)
} catch (error: AgentApiException) {
if (error.code == "REPLACEMENT_ACTIVATION_FAILED") {
resultSafelySubmitted = true
beginPostCollectionCooldowns()
}
stateStore.update("TASK_ERROR", "${error.code}:${error.message}", tokenStored = true)
TaskExecutionSummary(false, error.code, error.message)
} catch (error: Exception) {
@@ -1001,6 +1032,8 @@ class AgentForegroundService : Service() {
const val EXTRA_CURRENT_PAGE_REQUEST_ID = "current_page_request_id"
const val EXTRA_CURRENT_PAGE_TASK_ID = "current_page_task_id"
const val EXTRA_CURRENT_PAGE_MESSAGE = "current_page_message"
const val EXTRA_REPLACEMENT_ORIGIN_TYPE = "replacement_origin_type"
const val EXTRA_REPLACEMENT_ORIGIN_TASK_ID = "replacement_origin_task_id"
const val MANUAL_EMPTY = "empty"
const val MANUAL_COLLECTION_TASK = "collection_task"
const val MANUAL_PURCHASE_TASK = "purchase_task"
@@ -1044,10 +1077,17 @@ class AgentForegroundService : Service() {
}
}
fun collectCurrentPage(context: Context, requestId: String): CurrentPageLaunchResult {
fun collectCurrentPage(
context: Context,
requestId: String,
replacementOriginType: String? = null,
replacementOriginTaskId: Long? = null,
): CurrentPageLaunchResult {
val intent = Intent(context, AgentForegroundService::class.java).apply {
action = ACTION_CURRENT_PAGE_COLLECTION
putExtra(EXTRA_CURRENT_PAGE_REQUEST_ID, requestId)
replacementOriginType?.let { putExtra(EXTRA_REPLACEMENT_ORIGIN_TYPE, it) }
replacementOriginTaskId?.let { putExtra(EXTRA_REPLACEMENT_ORIGIN_TASK_ID, it) }
}
return cn.ilapage.goauto.agent.automation.CurrentPageNavigationPolicy.launchBeforeOptionalBackground(
launch = {
@@ -84,6 +84,19 @@ internal object PurchaseRetryPolicy {
"系统会保留原任务,并创建一个新的采购任务。重试可能创建新的拼多多待付款订单,但系统不会支付。"
}
internal enum class ReplacementPresentation { ACTION, ACTIVATION_FAILED, MATCHING, MATCHED, MANUAL_REQUIRED, HIDDEN }
internal object ReplacementActionPolicy {
fun presentation(eligible: Boolean, mappingStatus: String?, activationStatus: String?): ReplacementPresentation = when {
activationStatus == "failed" -> ReplacementPresentation.ACTIVATION_FAILED
mappingStatus == "matching" -> ReplacementPresentation.MATCHING
mappingStatus == "matched" -> ReplacementPresentation.MATCHED
mappingStatus == "manual_required" -> ReplacementPresentation.MANUAL_REQUIRED
eligible -> ReplacementPresentation.ACTION
else -> ReplacementPresentation.HIDDEN
}
}
internal class TaskDetailState(restoredTaskId: Long? = null) {
var taskId: Long? = restoredTaskId?.takeIf { it > 0L }
private set
@@ -113,18 +126,33 @@ class TaskHistoryFragment : Fragment() {
private var currentPageReceiverRegistered = false
private val currentPageReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
if (intent?.action != AgentForegroundService.ACTION_CURRENT_PAGE_RESULT || !collection) return
if (intent?.action != AgentForegroundService.ACTION_CURRENT_PAGE_RESULT) return
val message = intent.getStringExtra(AgentForegroundService.EXTRA_CURRENT_PAGE_MESSAGE).orEmpty()
val taskId = intent.getLongExtra(AgentForegroundService.EXTRA_CURRENT_PAGE_TASK_ID, 0L)
val originType = intent.getStringExtra(AgentForegroundService.EXTRA_REPLACEMENT_ORIGIN_TYPE)
val originTaskId = intent.getLongExtra(AgentForegroundService.EXTRA_REPLACEMENT_ORIGIN_TASK_ID, 0L)
val handlesResult = when (originType) {
"collection" -> collection
"purchase" -> !collection
null -> collection
else -> false
}
if (!handlesResult) return
if (message.isNotBlank()) toast(message)
if (taskId > 0L && isAdded) loadCollectionDetail(taskId) else if (isAdded) load()
if (!isAdded) return
when {
originType == "collection" && collection && originTaskId > 0L -> loadCollectionDetail(originTaskId)
originType == "purchase" && !collection && originTaskId > 0L -> loadPurchaseDetail(originTaskId)
originType == null && collection && taskId > 0L -> loadCollectionDetail(taskId)
originType == null && collection -> load()
}
}
}
override fun onCreate(state: Bundle?) {
super.onCreate(state)
detailState = TaskDetailState(state?.getLong(STATE_DETAIL_TASK_ID)?.takeIf { it > 0L })
if (collection) registerCurrentPageReceiver()
registerCurrentPageReceiver()
}
override fun onSaveInstanceState(outState: Bundle) {
@@ -506,24 +534,20 @@ class TaskHistoryFragment : Fragment() {
setOnClickListener { confirmReset(task) }
}, collectionCardParams())
}
renderReplacementAction(
eligible = detail.replacementEligible,
mappingStatus = detail.replacementMappingStatus,
activationStatus = detail.replacementActivationStatus,
activationErrorMessage = detail.replacementActivationErrorMessage,
originType = "collection",
taskId = task.taskId,
taskNo = "#${task.taskId}",
)
}
private fun confirmCurrentPageCollection() {
collectionCooldownMessage()?.let { message ->
showCurrentPageBlocked(message)
return
}
val context = requireContext()
val settings = AgentSettingsStore(context)
val credentials = runCatching { SecureDeviceStore(context).credentials() }.getOrNull()
val accessibility = GoAutoAccessibilityService.instance
val problem = when {
settings.serverUrl().isBlank() || credentials == null -> "设备尚未连接服务端,请先检查设置。"
AccessibilityReadinessDetector.current(context) != AccessibilityReadiness.READY || accessibility == null -> "请先到“设置”开启采集采购助手。"
AgentStateStore(context).read().currentTaskId != null -> "设备正在执行任务,请稍后再试。"
!accessibility.hasRecentPddForeground() -> "请先在拼多多打开目标商品详情页,再切回 Agent。"
else -> null
}
val problem = currentPageCollectionProblem()
if (problem != null) {
showCurrentPageBlocked(problem)
return
@@ -533,18 +557,38 @@ class TaskHistoryFragment : Fragment() {
.setMessage("请确认拼多多已停留在目标商品详情页。Agent 将读取当前商品资料和分享链接;不会采购、创建订单或支付。")
.setNegativeButton("取消", null)
.setPositiveButton("开始采集") { _, _ ->
toast("正在创建临时采集任务…")
when (AgentForegroundService.collectCurrentPage(context, UUID.randomUUID().toString())) {
CurrentPageLaunchResult.STARTED -> Unit
CurrentPageLaunchResult.START_FAILED ->
showCurrentPageBlocked("系统未允许启动采集服务,请保持 Agent 在前台后重试。")
CurrentPageLaunchResult.BACKGROUND_FAILED ->
showCurrentPageBlocked("无法将 Agent 切到后台,请手动返回拼多多后重试。")
}
launchCurrentPageCollection()
}
.show()
}
private fun currentPageCollectionProblem(): String? {
collectionCooldownMessage()?.let { return it }
val context = requireContext()
val settings = AgentSettingsStore(context)
val credentials = runCatching { SecureDeviceStore(context).credentials() }.getOrNull()
val accessibility = GoAutoAccessibilityService.instance
return when {
settings.serverUrl().isBlank() || credentials == null -> "设备尚未连接服务端,请先检查设置。"
AccessibilityReadinessDetector.current(context) != AccessibilityReadiness.READY || accessibility == null -> "请先到“设置”开启采集采购助手。"
AgentStateStore(context).read().currentTaskId != null -> "设备正在执行任务,请稍后再试。"
!accessibility.hasRecentPddForeground() -> "请先在拼多多打开目标商品详情页,再切回 Agent。"
else -> null
}
}
private fun launchCurrentPageCollection(originType: String? = null, originTaskId: Long? = null) {
val context = requireContext()
toast(if (originType == null) "正在创建临时采集任务…" else "正在创建替代商品采集任务…")
when (AgentForegroundService.collectCurrentPage(context, UUID.randomUUID().toString(), originType, originTaskId)) {
CurrentPageLaunchResult.STARTED -> Unit
CurrentPageLaunchResult.START_FAILED ->
showCurrentPageBlocked("系统未允许启动采集服务,请保持 Agent 在前台后重试。")
CurrentPageLaunchResult.BACKGROUND_FAILED ->
showCurrentPageBlocked("无法将 Agent 切到后台,请手动返回拼多多后重试。")
}
}
private fun showCurrentPageBlocked(message: String) {
MaterialAlertDialogBuilder(requireContext())
.setTitle("暂时不能采集")
@@ -665,6 +709,58 @@ class TaskHistoryFragment : Fragment() {
} else {
resultColumn.addView(context.centeredMessage("只读详情", "本页没有取消、修改订单或支付入口。"))
}
renderReplacementAction(
eligible = detail.replacementEligible,
mappingStatus = detail.replacementMappingStatus,
activationStatus = detail.replacementActivationStatus,
activationErrorMessage = detail.replacementActivationErrorMessage,
originType = "purchase",
taskId = task.taskId,
taskNo = "CG-${task.taskId}",
)
}
private fun renderReplacementAction(
eligible: Boolean,
mappingStatus: String?,
activationStatus: String?,
activationErrorMessage: String?,
originType: String,
taskId: Long,
taskNo: String,
) {
val context = requireContext()
when (ReplacementActionPolicy.presentation(eligible, mappingStatus, activationStatus)) {
ReplacementPresentation.ACTIVATION_FAILED -> resultColumn.addView(
context.centeredMessage(
"采集成功,但替换生效失败",
activationErrorMessage?.takeIf(String::isNotBlank) ?: "请稍后重试。",
),
)
ReplacementPresentation.MATCHING -> resultColumn.addView(context.centeredMessage("规格匹配中", "替代商品已提交,正在匹配规格。"))
ReplacementPresentation.MATCHED -> resultColumn.addView(context.centeredMessage("规格已匹配", "可继续处理原任务。"))
ReplacementPresentation.MANUAL_REQUIRED -> resultColumn.addView(context.centeredMessage("需要人工处理", "需在 Admin 人工匹配规格。"))
ReplacementPresentation.ACTION -> resultColumn.addView(MaterialButton(context, null, com.google.android.material.R.attr.materialButtonOutlinedStyle).apply {
text = "采集替代商品"
minimumHeight = context.dp(48)
contentDescription = "为任务 $taskNo 采集替代商品"
setOnClickListener { confirmReplacement(originType, taskId, taskNo) }
}, collectionCardParams())
ReplacementPresentation.HIDDEN -> Unit
}
}
private fun confirmReplacement(originType: String, taskId: Long, taskNo: String) {
currentPageCollectionProblem()?.let { problem ->
showCurrentPageBlocked(problem)
return
}
MaterialAlertDialogBuilder(requireContext())
.setTitle("采集替代商品")
.setMessage("用当前商品替换 $taskNo 的失效商品?")
.setNegativeButton("取消", null)
.setPositiveButton("替换") { _, _ -> launchCurrentPageCollection(originType, taskId) }
.show()
}
private fun confirmPurchaseRetry(task: PurchaseHistoryItem) {
@@ -0,0 +1,22 @@
package cn.ilapage.goauto.agent
import cn.ilapage.goauto.agent.ui.ReplacementActionPolicy
import cn.ilapage.goauto.agent.ui.ReplacementPresentation
import org.junit.Assert.assertEquals
import org.junit.Test
class ReplacementActionPolicyTest {
@Test
fun serverEligibilityIsTheOnlyWayToShowTheAction() {
assertEquals(ReplacementPresentation.ACTION, ReplacementActionPolicy.presentation(true, null, null))
assertEquals(ReplacementPresentation.HIDDEN, ReplacementActionPolicy.presentation(false, null, null))
}
@Test
fun workflowStatesTakePriorityOverEligibility() {
assertEquals(ReplacementPresentation.ACTIVATION_FAILED, ReplacementActionPolicy.presentation(true, "matching", "failed"))
assertEquals(ReplacementPresentation.MATCHING, ReplacementActionPolicy.presentation(true, "matching", "activated"))
assertEquals(ReplacementPresentation.MATCHED, ReplacementActionPolicy.presentation(false, "matched", "activated"))
assertEquals(ReplacementPresentation.MANUAL_REQUIRED, ReplacementActionPolicy.presentation(false, "manual_required", "activated"))
}
}