fix(purchase): 重建 Agent 重试任务 (#217)

This commit is contained in:
QiuSW
2026-09-05 09:06:41 +08:00
parent 96cc2ab700
commit 8d2e61bb72
8 changed files with 68 additions and 44 deletions
+2 -2
View File
@@ -11,8 +11,8 @@ android {
applicationId = "cn.ilapage.goauto.agent"
minSdk = 23
targetSdk = 34
versionCode = 52
versionName = "0.9.39"
versionCode = 53
versionName = "0.9.40"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
@@ -36,8 +36,6 @@ import cn.ilapage.goauto.agent.network.HistoryPage
import cn.ilapage.goauto.agent.network.HistoryColorImage
import cn.ilapage.goauto.agent.network.PurchaseHistoryDetail
import cn.ilapage.goauto.agent.network.PurchaseHistoryItem
import cn.ilapage.goauto.agent.network.PurchaseResetResult
import cn.ilapage.goauto.agent.network.PurchaseRetryResult
import cn.ilapage.goauto.agent.persistence.TaskHistoryCache
import cn.ilapage.goauto.agent.service.AgentForegroundService
import cn.ilapage.goauto.agent.service.AgentSettingsStore
@@ -88,7 +86,7 @@ internal object PurchaseRetryPolicy {
fun confirmationMessage(continuing: Boolean = false): String = if (continuing) {
"替代商品已完成匹配。系统会保留原任务并创建一笔新采购任务;可能创建拼多多待付款订单,但不会支付。"
} else {
"将使用服务端最新采购规则重跑当前任务,任务号和商品、规格、价格快照不变;可能创建拼多多待付款订单,但不会支付。"
"系统会保留原任务,并根据当前商品档案和最新采购规则创建一笔新采购任务;可能创建拼多多待付款订单,但不会支付。"
}
}
@@ -748,7 +746,7 @@ class TaskHistoryFragment : Fragment() {
contentDescription = "重试采购任务 CG-${task.taskId}"
setOnClickListener { confirmPurchaseRetry(task) }
}, collectionCardParams())
resultColumn.addView(context.centeredMessage("重试边界", "复用当前任务并刷新采购规则;不会执行支付。"))
resultColumn.addView(context.centeredMessage("重试边界", "保留当前失败任务并创建新任务;新任务读取当前商品档案和采购规则,不会执行支付。"))
} else if (!replacementInProgress && task.status == "failed") {
val reason = task.retryDisabledReason?.takeIf(String::isNotBlank) ?: "请在管理端核对任务状态。"
resultColumn.addView(context.centeredMessage("不可重试", reason))
@@ -869,17 +867,13 @@ class TaskHistoryFragment : Fragment() {
runCatching {
val client = AgentApiClient(serverUrl)
val requestId = UUID.randomUUID().toString()
if (continuing) client.retryPurchaseTask(taskId, requestId, credentials.token)
else client.resetPurchaseTask(taskId, requestId, credentials.token)
client.retryPurchaseTask(taskId, requestId, credentials.token)
}
.onSuccess { result ->
resultColumn.post {
if (!isAdded || generation != requestGeneration) return@post
AgentForegroundService.start(requireContext())
when (result) {
is PurchaseRetryResult -> showPurchaseRetrySuccess(result.sourceTaskNo, result.taskNo, result.taskId)
is PurchaseResetResult -> showPurchaseResetSuccess(result.taskNo, result.taskId, result.attemptNumber)
}
showPurchaseRetrySuccess(result.sourceTaskNo, result.taskNo, result.taskId)
}
}
.onFailure { error ->
@@ -910,24 +904,6 @@ class TaskHistoryFragment : Fragment() {
}, collectionCardParams())
}
private fun showPurchaseResetSuccess(taskNo: String, taskId: Long, attemptNumber: Int) {
resultColumn.removeAllViews()
resultColumn.addView(requireContext().centeredMessage(
"已加入重试队列 $taskNo",
"任务号保持不变,将使用最新采购规则进行第 $attemptNumber 次尝试;系统不会支付。",
))
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) {
if (total <= pageSize && currentPage == 1) return
val context = requireContext()
@@ -16,11 +16,12 @@ class PurchaseRetryPolicyTest {
}
@Test
fun `retry confirmation explains same task latest rule and no payment`() {
fun `retry confirmation explains new task current archive and no payment`() {
val message = PurchaseRetryPolicy.confirmationMessage()
assertTrue(message.contains("当前任务"))
assertTrue(message.contains("保留原任务"))
assertTrue(message.contains("当前商品档案"))
assertTrue(message.contains("最新采购规则"))
assertFalse(message.contains("新采购任务"))
assertTrue(message.contains("新采购任务"))
assertTrue(message.contains("待付款订单"))
assertTrue(message.contains("不会支付"))
}
+1 -1
View File
@@ -18,7 +18,7 @@ func TestParseBuiltAgentAPKWhenAvailable(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if metadata.VersionCode != 49 || metadata.VersionName != "0.9.36" {
if metadata.VersionCode != 53 || metadata.VersionName != "0.9.40" {
t.Fatalf("metadata=%+v", metadata)
}
}
@@ -71,6 +71,11 @@ func TestAgentRetryCreatesOneFixedDeviceTaskAndReplays(t *testing.T) {
setCollectedPDDPrice(t, db, f.pdd.ID)
service := testService(db)
failed := failedLiveTask(t, db, service, f)
if err := db.Model(&models.SYBProduct{}).Where("id = ?", f.syb.ID).Updates(map[string]any{
"target_color": "当前黑色", "target_size": "均碼",
}).Error; err != nil {
t.Fatal(err)
}
history, err := service.AgentHistoryDetail(context.Background(), failed.ID, f.token)
if err != nil || !history.Task.Retryable || history.Task.RetryDisabledReason != "" {
@@ -92,6 +97,9 @@ func TestAgentRetryCreatesOneFixedDeviceTaskAndReplays(t *testing.T) {
if oldTask.Status != models.PurchaseTaskStatusFailed || newTask.Status != models.PurchaseTaskStatusPending || newTask.DeviceID == nil || *newTask.DeviceID != f.device.ID || newTask.AddressSuffix == oldTask.AddressSuffix {
t.Fatalf("retry did not preserve old task or fix new task to device: old=%+v new=%+v", oldTask, newTask)
}
if newTask.TargetColorSnapshot != "当前黑色" || newTask.TargetSizeSnapshot != "均碼" || newTask.SpecSource != "unresolved" || purchaseAttemptPhase(newTask) != models.PurchaseAttemptPhaseSpecProbe {
t.Fatalf("retry did not use current SYB targets and a fresh probe: %+v", newTask)
}
replay, err := service.AgentRetry(context.Background(), failed.ID, request, f.token)
if err != nil || !replay.Replayed || replay.TaskID != first.TaskID {
+3 -6
View File
@@ -186,17 +186,14 @@ func (s *Service) Start(ctx context.Context, taskID uint64, req ActionRequest, t
if e = ensureAccountFree(tx, t.PDDAccountID, t.ID, s.Now()); e != nil {
return e
}
phase := models.PurchaseAttemptPhasePurchase
if t.SpecSource == "unresolved" {
phase = models.PurchaseAttemptPhaseSpecProbe
}
phase := purchaseAttemptPhase(t)
ruleSnapshotHash := purchaseRuleSnapshotHash(t.RuleSnapshot)
now := s.Now()
var a models.PurchaseTaskAttempt
if e = tx.Where("task_id = ? AND status = ?", t.ID, models.PurchaseAttemptStatusPending).Order("attempt_number DESC, id DESC").First(&a).Error; e == nil {
if a.DeviceID == nil || *a.DeviceID != d.ID || a.RuleSnapshotHash != ruleSnapshotHash {
if a.DeviceID == nil || *a.DeviceID != d.ID || a.RuleSnapshotHash != ruleSnapshotHash || a.Phase != phase {
failureCode := CodeStateConflict
message := "采购规则快照校验失败,请重新重试任务"
message := "采购任务执行快照校验失败,请重新创建任务"
a.Status = models.PurchaseAttemptStatusFailed
a.ErrorCode = &failureCode
a.ErrorMessage = &message
+16 -4
View File
@@ -87,6 +87,9 @@ func (s *Service) reset(ctx context.Context, taskID uint64, req PurchaseResetReq
if err := validatePurchaseResetState(tx, task); err != nil {
return err
}
if task.SpecSource != "unresolved" && !hasCompletePurchaseSpec(task) {
return fail(CodeMappingRequired, "任务没有完整的精确商品规格,请创建新采购任务")
}
deviceRecord, err := lockPurchaseResetDevice(tx, task, authenticated)
if err != nil {
return err
@@ -130,10 +133,7 @@ func (s *Service) reset(ctx context.Context, taskID uint64, req PurchaseResetReq
return conflictOrInternal(err)
}
phase := models.PurchaseAttemptPhasePurchase
if task.SpecSource == "unresolved" {
phase = models.PurchaseAttemptPhaseSpecProbe
}
phase := purchaseAttemptPhase(task)
// MySQL normalizes values written to a JSON column. Reload the task before
// hashing so the pending attempt uses the exact representation Start will
// read later, rather than the pre-persistence DefaultLiveRule bytes.
@@ -155,6 +155,18 @@ func (s *Service) reset(ctx context.Context, taskID uint64, req PurchaseResetReq
return response, err
}
func purchaseAttemptPhase(task models.PurchaseTask) string {
if task.SpecSource == "unresolved" || !hasCompletePurchaseSpec(task) {
return models.PurchaseAttemptPhaseSpecProbe
}
return models.PurchaseAttemptPhasePurchase
}
func hasCompletePurchaseSpec(task models.PurchaseTask) bool {
return (strings.TrimSpace(task.TargetColorSnapshot) == "" || strings.TrimSpace(task.MappedColorSnapshot) != "") &&
(strings.TrimSpace(task.TargetSizeSnapshot) == "" || strings.TrimSpace(task.MappedSizeSnapshot) != "")
}
func validatePurchaseResetState(tx *gorm.DB, task models.PurchaseTask) error {
switch task.Status {
case models.PurchaseTaskStatusOrderSubmitStarted, models.PurchaseTaskStatusOrderCreated, models.PurchaseTaskStatusOrderResultUnknown:
+30
View File
@@ -108,6 +108,36 @@ func TestPurchaseResetReusesTaskRefreshesRuleAndIsIdempotent(t *testing.T) {
}
}
func TestPurchaseResetRejectsResolvedDecisionWithoutExactSpec(t *testing.T) {
db := testDB(t)
f := seed(t, db, liveCaps(), true)
service := testService(db)
failed := failedLiveTask(t, db, service, f)
decisionID := uuid.NewString()
if err := db.Session(&gorm.Session{SkipHooks: true}).Model(&models.PurchaseTask{}).Where("id = ?", failed.ID).Updates(map[string]any{
"spec_source": "ai_match", "spec_decision_request_id": decisionID,
"mapped_color_snapshot": "", "mapped_size_snapshot": "",
}).Error; err != nil {
t.Fatal(err)
}
_, err := service.ResetForDevice(context.Background(), failed.ID, PurchaseResetRequest{RequestID: uuid.NewString()}, f.token)
if code(err) != CodeMappingRequired {
t.Fatalf("resolved decision without exact spec was reset: %v", err)
}
var task models.PurchaseTask
if queryErr := db.First(&task, failed.ID).Error; queryErr != nil {
t.Fatal(queryErr)
}
if task.Status != models.PurchaseTaskStatusFailed {
t.Fatalf("rejected reset changed task status: %+v", task)
}
var attemptCount int64
if queryErr := db.Model(&models.PurchaseTaskAttempt{}).Where("task_id = ?", failed.ID).Count(&attemptCount).Error; queryErr != nil || attemptCount != 0 {
t.Fatalf("rejected reset created attempt: count=%d err=%v", attemptCount, queryErr)
}
}
func TestPurchaseStartPersistsPendingAttemptSnapshotMismatchAsFailure(t *testing.T) {
db := testDB(t)
f := seed(t, db, liveCaps(), true)