feat: add safe agent purchase retry (#95)
This commit is contained in:
@@ -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("不会支付"))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user