feat: add safe agent purchase retry (#95)

This commit is contained in:
QiuSW
2026-08-26 14:37:01 +08:00
parent 9860b2aa63
commit 5f2dd05ebd
13 changed files with 352 additions and 19 deletions
+2 -2
View File
@@ -11,8 +11,8 @@ android {
applicationId = "cn.ilapage.goauto.agent"
minSdk = 23
targetSdk = 34
versionCode = 9
versionName = "0.5.2"
versionCode = 10
versionName = "0.6.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
@@ -94,6 +94,14 @@ data class CollectionHistoryDetail(
data class CollectionResetResult(val taskId: Long, val status: String, val replayed: Boolean)
data class PurchaseRetryResult(
val sourceTaskId: Long,
val sourceTaskNo: String,
val taskId: Long,
val taskNo: String,
val replayed: Boolean,
)
data class PurchaseHistoryItem(
val taskId: Long,
val status: String,
@@ -109,6 +117,8 @@ data class PurchaseHistoryItem(
val orderSubmittedAt: String?,
val errorCode: String?,
val errorMessage: String?,
val retryable: Boolean,
val retryDisabledReason: String?,
val createdAt: String,
)
@@ -263,6 +273,15 @@ class AgentApiClient(private val serverUrl: String) {
return PurchaseHistoryDetail(purchaseHistoryItem(data.getJSONObject("task")))
}
fun retryPurchaseTask(taskId: Long, requestId: String, token: String): PurchaseRetryResult {
val payload = JSONObject().put("requestId", requestId)
val data = requireNotNull(request("POST", "/api/agent/v1/purchase-tasks/$taskId/retry", payload, token)).getJSONObject("data")
return PurchaseRetryResult(
sourceTaskId = data.getLong("sourceTaskId"), sourceTaskNo = data.getString("sourceTaskNo"),
taskId = data.getLong("taskId"), taskNo = data.getString("taskNo"), replayed = data.optBoolean("replayed"),
)
}
private fun historyPath(base: String, page: Int, status: String?, taskNo: String?): String {
val values = mutableListOf("page=${page.coerceAtLeast(1)}", "pageSize=20")
status?.takeIf { it.isNotBlank() }?.let { values += "status=${encode(it)}" }
@@ -283,7 +302,9 @@ class AgentApiClient(private val serverUrl: String) {
mappedColor = data.optString("mappedColor"), mappedSize = data.optString("mappedSize"), quantity = data.getLong("quantity"),
actualUnitPriceCent = data.nullableLong("actualUnitPriceCent"), currency = data.getString("currency"),
pddOrderNo = data.nullableString("pddOrderNo"), orderSubmittedAt = data.nullableString("orderSubmittedAt"),
errorCode = data.nullableString("errorCode"), errorMessage = data.nullableString("errorMessage"), createdAt = data.getString("createdAt"),
errorCode = data.nullableString("errorCode"), errorMessage = data.nullableString("errorMessage"),
retryable = data.optBoolean("retryable"), retryDisabledReason = data.nullableString("retryDisabledReason"),
createdAt = data.getString("createdAt"),
)
private fun encode(value: String): String = URLEncoder.encode(value, Charsets.UTF_8.name())
@@ -65,6 +65,13 @@ internal object CollectionResetPolicy {
}
}
internal object PurchaseRetryPolicy {
fun showsAction(status: String, retryable: Boolean): Boolean = status == "failed" && retryable
fun confirmationMessage(): String =
"系统会保留原任务,并创建一个新的采购任务。重试可能创建新的拼多多待付款订单,但系统不会支付。"
}
class TaskHistoryFragment : Fragment() {
private val collection: Boolean get() = requireArguments().getBoolean(ARG_COLLECTION)
private lateinit var pageColumn: LinearLayout
@@ -254,7 +261,7 @@ class TaskHistoryFragment : Fragment() {
}
}
addPagination(result.page, result.pageSize)
if (!collection) resultColumn.addView(requireContext().centeredMessage("只读记录", "重新采购、取消订单和支付请在管理端处理。"))
if (!collection) resultColumn.addView(requireContext().centeredMessage("安全边界", "失败任务可由本机受控重试;取消订单和支付仍需在管理端处理。"))
}
}
}
@@ -300,6 +307,21 @@ class TaskHistoryFragment : Fragment() {
addView(context.label(purchaseStatus(item.status), 13f, statusColor(item.status)).apply { setPadding(0, context.dp(4), 0, 0) })
addView(context.label(summary, 14f, context.getColor(R.color.agent_text_muted)).apply { setPadding(0, context.dp(8), 0, 0) })
item.pddOrderNo?.let { addView(context.label("PDD 订单号:$it", 13f, context.getColor(R.color.agent_text_muted))) }
if (PurchaseRetryPolicy.showsAction(item.status, item.retryable)) {
addView(LinearLayout(context).apply {
gravity = Gravity.END
addView(MaterialButton(context, null, com.google.android.material.R.attr.materialButtonOutlinedStyle).apply {
text = "重试采购"
minimumHeight = context.dp(48)
contentDescription = "重试采购任务 CG-${item.taskId}"
setOnClickListener { confirmPurchaseRetry(item) }
}, LinearLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, context.dp(48)))
}, fullWidth(context.dp(8)))
} else if (item.status == "failed" && !item.retryDisabledReason.isNullOrBlank()) {
addView(context.label("暂不可重试:${item.retryDisabledReason}", 13f, context.getColor(R.color.agent_warning)).apply {
setPadding(0, context.dp(8), 0, 0)
})
}
}).apply {
isClickable = true; isFocusable = true
contentDescription = "查看采购任务 ${item.taskId} 详情"
@@ -426,7 +448,76 @@ class TaskHistoryFragment : Fragment() {
addView(context.label(info, 14f))
}), collectionCardParams())
if (task.errorMessage != null) resultColumn.addView(context.centeredMessage(task.errorMessage, "错误代码:${task.errorCode ?: "—"}"))
resultColumn.addView(context.centeredMessage("只读详情", "本页没有取消、修改、创建订单或支付入口。"))
if (PurchaseRetryPolicy.showsAction(task.status, task.retryable)) {
resultColumn.addView(MaterialButton(context).apply {
text = "重试采购"
minimumHeight = context.dp(48)
contentDescription = "重试采购任务 CG-${task.taskId}"
setOnClickListener { confirmPurchaseRetry(task) }
}, collectionCardParams())
resultColumn.addView(context.centeredMessage("重试边界", "系统保留旧任务并创建新任务;不会执行支付。"))
} else if (task.status == "failed") {
val reason = task.retryDisabledReason?.takeIf(String::isNotBlank) ?: "请在管理端核对任务状态。"
resultColumn.addView(context.centeredMessage("不可重试", reason))
} else {
resultColumn.addView(context.centeredMessage("只读详情", "本页没有取消、修改订单或支付入口。"))
}
}
private fun confirmPurchaseRetry(task: PurchaseHistoryItem) {
MaterialAlertDialogBuilder(requireContext())
.setTitle("重试采购 CG-${task.taskId}?")
.setMessage(PurchaseRetryPolicy.confirmationMessage())
.setNegativeButton("取消", null)
.setPositiveButton("确认重试") { _, _ -> retryPurchaseTask(task.taskId) }
.show()
}
private fun retryPurchaseTask(taskId: Long) {
val context = requireContext()
val credentials = runCatching { SecureDeviceStore(context).credentials() }.getOrNull()
val serverUrl = AgentSettingsStore(context).serverUrl()
if (credentials == null || serverUrl.isBlank()) {
showMessage("无法重试采购", "设备尚未连接服务端,请先检查设置。", "返回任务详情") { loadPurchaseDetail(taskId) }
return
}
val generation = ++requestGeneration
showLoading("正在提交重试请求…")
Thread {
runCatching { AgentApiClient(serverUrl).retryPurchaseTask(taskId, UUID.randomUUID().toString(), credentials.token) }
.onSuccess { result ->
resultColumn.post {
if (!isAdded || generation != requestGeneration) return@post
AgentForegroundService.start(requireContext())
showPurchaseRetrySuccess(result.sourceTaskNo, result.taskNo, result.taskId)
}
}
.onFailure { error ->
resultColumn.post {
if (isAdded && generation == requestGeneration) {
showMessage("无法重试采购", error.message ?: "请检查设备状态后重试。", "返回任务详情") { loadPurchaseDetail(taskId) }
}
}
}
}.start()
}
private fun showPurchaseRetrySuccess(sourceTaskNo: String, taskNo: String, taskId: Long) {
resultColumn.removeAllViews()
resultColumn.addView(requireContext().centeredMessage(
"已创建新任务 $taskNo",
"旧任务 $sourceTaskNo 已保留。新任务将由当前设备按正常队列执行;系统不会支付。",
))
resultColumn.addView(MaterialButton(requireContext()).apply {
text = "查看新任务"
minimumHeight = requireContext().dp(48)
setOnClickListener { loadPurchaseDetail(taskId) }
}, collectionCardParams())
resultColumn.addView(MaterialButton(requireContext(), null, com.google.android.material.R.attr.materialButtonOutlinedStyle).apply {
text = "返回采购记录"
minimumHeight = requireContext().dp(48)
setOnClickListener { page = 1; load() }
}, collectionCardParams())
}
private fun addPagination(currentPage: Int, pageSize: Int) {
@@ -0,0 +1,26 @@
package cn.ilapage.goauto.agent
import cn.ilapage.goauto.agent.ui.PurchaseRetryPolicy
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class PurchaseRetryPolicyTest {
@Test
fun `only server-approved failed tasks show retry action`() {
assertTrue(PurchaseRetryPolicy.showsAction("failed", true))
assertFalse(PurchaseRetryPolicy.showsAction("failed", false))
assertFalse(PurchaseRetryPolicy.showsAction("pending", true))
assertFalse(PurchaseRetryPolicy.showsAction("order_created", true))
assertFalse(PurchaseRetryPolicy.showsAction("order_result_unknown", true))
}
@Test
fun `confirmation explains new task order risk and no payment`() {
val message = PurchaseRetryPolicy.confirmationMessage()
assertTrue(message.contains("保留原任务"))
assertTrue(message.contains("新的采购任务"))
assertTrue(message.contains("待付款订单"))
assertTrue(message.contains("不会支付"))
}
}