diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 3db25cf..588b68d 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -11,8 +11,8 @@ android { applicationId = "cn.ilapage.goauto.agent" minSdk = 23 targetSdk = 34 - versionCode = 45 - versionName = "0.9.32" + versionCode = 46 + versionName = "0.9.33" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" diff --git a/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PurchaseLiveAutomation.kt b/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PurchaseLiveAutomation.kt index b09ab3e..f38f1db 100644 --- a/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PurchaseLiveAutomation.kt +++ b/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PurchaseLiveAutomation.kt @@ -20,6 +20,7 @@ data class FinalConfirmationEvidence( ) data class PurchaseOrderEvidence(val orderNo: String, val submittedAt: String) +data class PurchaseOrderReadFailure(val code: String, val message: String) class PurchaseLiveException(val code: String, message: String) : IllegalStateException(message) @@ -33,6 +34,8 @@ class PurchaseLiveAutomation( private val pause: (Long) -> Unit = Thread::sleep, ) { private var submitAttempted = false + var lastOrderReadFailure: PurchaseOrderReadFailure? = null + private set fun updateShippingAddress(addressSuffix: String): ShippingAddressProof { if (!addressSuffix.matches(Regex("^_cg[1-9][0-9]*$"))) fail("PURCHASE_ADDRESS_UPDATE_FAILED", "采购任务的地址标记无效") var snapshot = driver.capture() @@ -108,6 +111,11 @@ class PurchaseLiveAutomation( /** Reads the order flow with bounded chooser/payment Back actions and one verified PDD foreground request. */ fun readOrderResult(): PurchaseOrderEvidence? { + lastOrderReadFailure = null + fun unknown(code: String, message: String): PurchaseOrderEvidence? { + lastOrderReadFailure = PurchaseOrderReadFailure(code, message) + return null + } val labels = linkedSetOf() var backedOutOfPayment = false var backedOutOfChooser = false @@ -119,30 +127,40 @@ class PurchaseLiveAutomation( val snapshot = driver.capture() if (snapshot.packageName.isNullOrBlank()) { consecutiveEmptySnapshots++ - if (consecutiveEmptySnapshots >= ORDER_RESULT_MAX_EMPTY_SAMPLES) return null + if (consecutiveEmptySnapshots >= ORDER_RESULT_MAX_EMPTY_SAMPLES) { + return unknown("PURCHASE_ORDER_EMPTY_TIMEOUT", "等待订单页面时无障碍窗口持续为空") + } pause(ORDER_RESULT_SAMPLE_INTERVAL_MS) return@repeat } consecutiveEmptySnapshots = 0 val currentLabels = snapshot.nodes.filter { it.visible }.map { it.label } if (isKnownAndroidWechatChooser(snapshot, currentLabels)) { - if (backedOutOfChooser || !driver.backPurchase()) return null + if (backedOutOfChooser || !driver.backPurchase()) { + return unknown("PURCHASE_ORDER_CHOOSER_BACK_FAILED", "系统应用选择页无法安全返回") + } backedOutOfChooser = true pause(500) return@repeat } if (snapshot.packageName == WECHAT_PACKAGE) { if (!restoredFromWechat) { - if (!driver.bringPddToForeground()) return null + if (!driver.bringPddToForeground()) { + return unknown("PURCHASE_ORDER_WECHAT_RESTORE_FAILED", "从微信恢复拼多多的请求失败") + } restoredFromWechat = true pause(500) return@repeat } - if (pddObservedAfterWechatRestore || ++wechatRestorePendingSamples >= ORDER_RESULT_WECHAT_RESTORE_MAX_SAMPLES) return null + if (pddObservedAfterWechatRestore || ++wechatRestorePendingSamples >= ORDER_RESULT_WECHAT_RESTORE_MAX_SAMPLES) { + return unknown("PURCHASE_ORDER_WECHAT_RESTORE_TIMEOUT", "从微信恢复拼多多后未在限定时间到达订单页面") + } pause(ORDER_RESULT_SAMPLE_INTERVAL_MS) return@repeat } - if (snapshot.packageName != PDD_PACKAGE) return null + if (snapshot.packageName != PDD_PACKAGE) { + return unknown("PURCHASE_ORDER_UNEXPECTED_APP", "核单期间出现未授权应用") + } if (restoredFromWechat) pddObservedAfterWechatRestore = true val paymentVisible = isKnownPddPaymentActivity(snapshot) || currentLabels.any { label -> PAYMENT_MARKERS.any(label::contains) } @@ -155,16 +173,19 @@ class PurchaseLiveAutomation( parseOrderEvidence(labels)?.let { return it } if (paymentVisible && !backedOutOfPayment) { backedOutOfPayment = true - if (!driver.backPurchase()) return null + if (!driver.backPurchase()) { + return unknown("PURCHASE_ORDER_PAYMENT_BACK_FAILED", "支付页无法安全返回订单详情") + } pause(500) } else if (paymentVisible) { - return null + return unknown("PURCHASE_ORDER_PAYMENT_REPEATED", "支付页重复出现,已停止自动核单") } else if (index > 0 && index % 15 == 0) { driver.swipePurchase(SwipeDirection.UP, 400) } pause(ORDER_RESULT_SAMPLE_INTERVAL_MS) } - return null + val failure = orderEvidenceFailure(labels) + return unknown(failure.code, failure.message) } private fun isKnownAndroidWechatChooser(snapshot: UiSnapshot, labels: Collection): Boolean { @@ -369,6 +390,27 @@ class PurchaseLiveAutomation( return PurchaseOrderEvidence(orderNumbers.single(), output) } + private fun orderEvidenceFailure(labels: Collection): PurchaseOrderReadFailure { + val text = labels.joinToString(" ") + if (labels.none { label -> ORDER_CONTEXT_MARKERS.any(label::contains) }) { + return PurchaseOrderReadFailure("PURCHASE_ORDER_CONTEXT_NOT_FOUND", "限定时间内没有出现订单详情证据") + } + val orderNumbers = ORDER_NO.findAll(text).map { it.groupValues[1] }.distinct().toList() + if (orderNumbers.isEmpty()) return PurchaseOrderReadFailure("PURCHASE_ORDER_NO_MISSING", "订单详情缺少可读取的订单号") + if (orderNumbers.size > 1) return PurchaseOrderReadFailure("PURCHASE_ORDER_NO_AMBIGUOUS", "订单详情出现多个订单号,无法唯一确认") + val rawTime = ORDER_TIME.find(text)?.groupValues?.get(1) + ?: return PurchaseOrderReadFailure("PURCHASE_ORDER_TIME_MISSING", "订单详情缺少可读取的下单时间") + val normalized = rawTime.replace('年', '-').replace('月', '-').replace("日", "").replace('/', '-').replace('.', '-') + val validTime = listOf("yyyy-MM-dd HH:mm:ss", "yyyy-MM-dd HH:mm").any { pattern -> + runCatching { SimpleDateFormat(pattern, Locale.ROOT).apply { isLenient = false }.parse(normalized) }.getOrNull() != null + } + if (!validTime) return PurchaseOrderReadFailure("PURCHASE_ORDER_TIME_INVALID", "订单详情的下单时间格式无法确认") + if (labels.none { label -> UNPAID_MARKERS.any(label::contains) || PAYMENT_MARKERS.any(label::contains) }) { + return PurchaseOrderReadFailure("PURCHASE_ORDER_UNPAID_EVIDENCE_MISSING", "订单详情缺少待付款状态证据") + } + return PurchaseOrderReadFailure("PURCHASE_ORDER_RESULT_UNKNOWN", "订单详情证据不完整,无法唯一确认") + } + private fun fail(code: String, message: String): Nothing = throw PurchaseLiveException(code, message) private companion object { diff --git a/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PurchaseRehearsalExecutor.kt b/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PurchaseRehearsalExecutor.kt index a48df8f..07a2b3e 100644 --- a/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PurchaseRehearsalExecutor.kt +++ b/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PurchaseRehearsalExecutor.kt @@ -91,13 +91,25 @@ class PurchaseRehearsalExecutor( live.submitOrderOnce() null } catch (error: PurchaseLiveException) { - if (irreversibleStarted) PurchaseExecutionOutcome("order_result_unknown", "PURCHASE_ORDER_RESULT_UNKNOWN", "无法确认订单是否创建,请人工检查") + if (irreversibleStarted) PurchaseExecutionOutcome( + "order_result_unknown", + "PURCHASE_ORDER_RESULT_UNKNOWN", + "无法确认订单是否创建,请人工检查", + actualUnitPriceCent = observedPrice, + ) else failure(error.code, error.message ?: "创建订单前检查失败") } PurchaseActionType.READ_ORDER_RESULT -> { if (!irreversibleStarted) failure("PURCHASE_RULE_INVALID", "尚未进入创建订单边界") else live.readOrderResult()?.let { PurchaseExecutionOutcome("order_created", message = "订单已创建,等待人工检查和支付", pddOrderNo = it.orderNo, orderSubmittedAt = it.submittedAt, actualUnitPriceCent = observedPrice) } - ?: PurchaseExecutionOutcome("order_result_unknown", "PURCHASE_ORDER_RESULT_UNKNOWN", "无法确认订单是否创建,请人工检查") + ?: live.lastOrderReadFailure.let { readFailure -> + PurchaseExecutionOutcome( + "order_result_unknown", + readFailure?.code ?: "PURCHASE_ORDER_RESULT_UNKNOWN", + readFailure?.message ?: "无法确认订单是否创建,请人工检查", + actualUnitPriceCent = observedPrice, + ) + } } } if (failure != null) { diff --git a/android/app/src/main/java/cn/ilapage/goauto/agent/service/AgentForegroundService.kt b/android/app/src/main/java/cn/ilapage/goauto/agent/service/AgentForegroundService.kt index ef357c8..9609132 100644 --- a/android/app/src/main/java/cn/ilapage/goauto/agent/service/AgentForegroundService.kt +++ b/android/app/src/main/java/cn/ilapage/goauto/agent/service/AgentForegroundService.kt @@ -559,9 +559,15 @@ class AgentForegroundService : Service() { val boundaryRequestId = interrupted.orderSubmitRequestId ?: return@forEach try { api.markPurchaseOrderSubmitStarted(interrupted.taskId, boundaryRequestId, token) - val evidence = GoAutoAccessibilityService.instance?.let { PurchaseLiveAutomation(it).readOrderResult() } + val automation = GoAutoAccessibilityService.instance?.let(::PurchaseLiveAutomation) + val evidence = automation?.readOrderResult() val outcome = if (evidence == null) { - PurchaseExecutionOutcome("order_result_unknown", "PURCHASE_ORDER_RESULT_UNKNOWN", "无法确认订单是否创建,请人工检查") + val readFailure = automation?.lastOrderReadFailure + PurchaseExecutionOutcome( + "order_result_unknown", + readFailure?.code ?: "PURCHASE_ORDER_RESULT_UNKNOWN", + readFailure?.message ?: "无法确认订单是否创建,请人工检查", + ) } else { PurchaseExecutionOutcome("order_created", message = "订单已创建,等待人工检查和支付", pddOrderNo = evidence.orderNo, orderSubmittedAt = evidence.submittedAt) } @@ -597,24 +603,6 @@ class AgentForegroundService : Service() { runningTaskId.set(purchaseStore.activeTaskId()) } - private fun purchaseResultPayload(requestId: String, attemptId: String, outcome: PurchaseExecutionOutcome): String = - JSONObject() - .put("requestId", requestId) - .put("taskAttemptId", attemptId) - .put("resultType", outcome.resultType) - .apply { - if (outcome.resultType == "failed") { - put("errorCode", outcome.errorCode ?: "AGENT_EXECUTION_ERROR") - put("errorMessage", outcome.message.take(1000)) - } - outcome.probedSpecs?.let { put("probedSpecs", JSONObject(it)) } - outcome.pddOrderNo?.let { put("pddOrderNo", it) } - outcome.orderSubmittedAt?.let { put("orderSubmittedAt", it) } - outcome.actualUnitPriceCent?.let { put("actualUnitPriceCent", it) } - } - .toString() - - private fun executeTask( api: AgentApiClient, initialTask: cn.ilapage.goauto.agent.network.AgentTask, diff --git a/android/app/src/main/java/cn/ilapage/goauto/agent/service/PurchaseResultPayload.kt b/android/app/src/main/java/cn/ilapage/goauto/agent/service/PurchaseResultPayload.kt new file mode 100644 index 0000000..fe23a86 --- /dev/null +++ b/android/app/src/main/java/cn/ilapage/goauto/agent/service/PurchaseResultPayload.kt @@ -0,0 +1,24 @@ +package cn.ilapage.goauto.agent.service + +import cn.ilapage.goauto.agent.automation.PurchaseExecutionOutcome +import org.json.JSONObject + +internal fun purchaseResultPayload( + requestId: String, + attemptId: String, + outcome: PurchaseExecutionOutcome, +): String = JSONObject() + .put("requestId", requestId) + .put("taskAttemptId", attemptId) + .put("resultType", outcome.resultType) + .apply { + if (outcome.resultType == "failed" || outcome.resultType == "order_result_unknown") { + put("errorCode", outcome.errorCode ?: "AGENT_EXECUTION_ERROR") + put("errorMessage", outcome.message.take(1000)) + } + outcome.probedSpecs?.let { put("probedSpecs", JSONObject(it)) } + outcome.pddOrderNo?.let { put("pddOrderNo", it) } + outcome.orderSubmittedAt?.let { put("orderSubmittedAt", it) } + outcome.actualUnitPriceCent?.let { put("actualUnitPriceCent", it) } + } + .toString() diff --git a/android/app/src/test/java/cn/ilapage/goauto/agent/PurchaseLiveAutomationTest.kt b/android/app/src/test/java/cn/ilapage/goauto/agent/PurchaseLiveAutomationTest.kt index 22fc3b3..fd002e8 100644 --- a/android/app/src/test/java/cn/ilapage/goauto/agent/PurchaseLiveAutomationTest.kt +++ b/android/app/src/test/java/cn/ilapage/goauto/agent/PurchaseLiveAutomationTest.kt @@ -185,6 +185,7 @@ class PurchaseLiveAutomationTest { automation.submitOrderOnce() assertEquals(null, automation.readOrderResult()) + assertEquals("PURCHASE_ORDER_EMPTY_TIMEOUT", automation.lastOrderReadFailure?.code) assertEquals(15, driver.postSubmitCaptureCount) assertEquals(0, driver.pddRestoreCount) assertEquals(0, driver.postSubmitBackCount) @@ -200,6 +201,7 @@ class PurchaseLiveAutomationTest { automation.submitOrderOnce() assertEquals(null, automation.readOrderResult()) + assertEquals("PURCHASE_ORDER_UNEXPECTED_APP", automation.lastOrderReadFailure?.code) assertEquals(1, driver.postSubmitCaptureCount) assertEquals(0, driver.pddRestoreCount) assertEquals(0, driver.postSubmitBackCount) @@ -215,6 +217,7 @@ class PurchaseLiveAutomationTest { automation.submitOrderOnce() assertEquals(null, automation.readOrderResult()) + assertEquals("PURCHASE_ORDER_CONTEXT_NOT_FOUND", automation.lastOrderReadFailure?.code) assertEquals(1, driver.pddRestoreCount) assertEquals(0, driver.postSubmitBackCount) assertEquals(0, driver.genericSwipes) @@ -229,6 +232,7 @@ class PurchaseLiveAutomationTest { automation.submitOrderOnce() assertEquals(null, automation.readOrderResult()) + assertEquals("PURCHASE_ORDER_WECHAT_RESTORE_TIMEOUT", automation.lastOrderReadFailure?.code) assertEquals(1, driver.pddRestoreCount) assertEquals(0, driver.genericSwipes) assertEquals(0, driver.postSubmitBackCount) @@ -246,6 +250,18 @@ class PurchaseLiveAutomationTest { assertEquals(0, driver.postSubmitBackCount) } + @Test + fun `order context without submitted time reports stable missing time stage`() { + val driver = LiveDriver(postSubmitCaptureSequence = listOf("order-no-time")) + val automation = PurchaseLiveAutomation(driver, pause = {}) + val address = automation.updateShippingAddress("_cg37") + automation.finalConfirmation(input().copy(addressSuffix = "_cg37"), address) + automation.submitOrderOnce() + + assertEquals(null, automation.readOrderResult()) + assertEquals("PURCHASE_ORDER_TIME_MISSING", automation.lastOrderReadFailure?.code) + } + @Test fun `clipped address is revealed by swiping the unique purchase panel`() { val driver = LiveDriver(addressClipped = true) @@ -343,6 +359,7 @@ class PurchaseLiveAutomationTest { node("address-summary", if (savedTransitionHidesSuffix) "已保存的收货信息" else address.substring(address.lastIndexOf("_cg"))), )) "order" -> snapshot(listOf(node("status", "待付款"), node("order", "订单号:PDD-202608210001"), node("time", "下单时间:2026-08-21 10:30:00"), node("pay", "立即支付", clickable = true))) + "order-no-time" -> snapshot(listOf(node("status", "待付款"), node("order", "订单号:PDD-202608210001"), node("pay", "去支付", clickable = true))) "chooser" -> UiSnapshot(if (trustedChooser) "android" else "example.untrusted", "com.android.internal.app.ChooserActivity", listOf( node("chooser-title", "选择要使用的应用"), node("wechat-1", "微信"), node("wechat-2", "微信分身"), )) diff --git a/android/app/src/test/java/cn/ilapage/goauto/agent/PurchaseResultPayloadTest.kt b/android/app/src/test/java/cn/ilapage/goauto/agent/PurchaseResultPayloadTest.kt new file mode 100644 index 0000000..8a3bd3c --- /dev/null +++ b/android/app/src/test/java/cn/ilapage/goauto/agent/PurchaseResultPayloadTest.kt @@ -0,0 +1,30 @@ +package cn.ilapage.goauto.agent + +import cn.ilapage.goauto.agent.automation.PurchaseExecutionOutcome +import cn.ilapage.goauto.agent.service.purchaseResultPayload +import org.json.JSONObject +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Test + +class PurchaseResultPayloadTest { + @Test + fun `unknown order result keeps stable failure and verified price without order fields`() { + val payload = JSONObject(purchaseResultPayload( + requestId = "request-1", + attemptId = "attempt-1", + outcome = PurchaseExecutionOutcome( + resultType = "order_result_unknown", + errorCode = "PURCHASE_ORDER_TIME_MISSING", + message = "订单详情缺少可读取的下单时间", + actualUnitPriceCent = 1999, + ), + )) + + assertEquals("order_result_unknown", payload.getString("resultType")) + assertEquals("PURCHASE_ORDER_TIME_MISSING", payload.getString("errorCode")) + assertEquals(1999L, payload.getLong("actualUnitPriceCent")) + assertFalse(payload.has("pddOrderNo")) + assertFalse(payload.has("orderSubmittedAt")) + } +} diff --git a/docs/08-agent-api-contract.md b/docs/08-agent-api-contract.md index 09cc30b..673c5e5 100644 --- a/docs/08-agent-api-contract.md +++ b/docs/08-agent-api-contract.md @@ -2,8 +2,8 @@ generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件) wiki_page: Android-Agent-API-Contract wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Android-Agent-API-Contract.- -wiki_revision: 484a645058de780abe2ff2951afd25d38a03b74e -synchronized_at: 2026-08-31T03:41:42Z +wiki_revision: 6990b1a92006572069d13bdffba1ffc5bfc0f13d +synchronized_at: 2026-08-31T06:22:58Z @@ -544,7 +544,8 @@ Android #42/#36 使用本地 SQLite 保存恢复与重传所需的任务、attem 创建订单前先在本地事务保存 `order_submit_started`、不可逆时间、稳定的 `orderSubmitRequestId` 和脱敏最终确认快照,再调用服务端同名接口;只有两侧标记完成并重新校验商品、规格、数量、单价、地址后缀、PDD 包/Activity 与唯一创建订单按钮后,才点击一次。重启时重放同一标记请求并只读核单;无法取得唯一未付款订单号和 PDD 下单时间时提交 `order_result_unknown`。支付文字仅用于识别未付款/离开支付页,永不点击。服务端数据库仍是最终事实来源;双方均不保存原始控件树、截图、PDD 凭据或完整收货地址。 -创建订单后若出现 Android 多微信应用选择器,只读核单器必须同时确认前台包为 `android` / `com.android.intentresolver`、Activity 为白名单 `ChooserActivity` / `ResolverActivity`、页面出现已知系统选择器标题且至少一个候选以“微信”开头,才允许执行一次系统返回;不得点击任何微信候选。若前台已经是精确微信包 `com.tencent.mm`,只读核单器不得点击、输入、登录、支付或强制停止微信,只允许执行一次无参数 PDD 启动 Intent 并等待既有 PDD 任务栈回到前台;拉起失败或第二次仍见微信立即返回未知结果。随后若前台为 PDD `com.xunmeng.pinduoduo.app_pay.core.PayActivity` 或当前页面出现支付动作文字,最多再返回一次。返回后只读解析唯一订单号和下单时间;选择器或支付页重复出现、白名单不成立、恢复动作重复、无法到达订单详情、结果不唯一或超时均返回未知结果,禁止再次点击创建订单、取消订单或支付。 +创建订单后若出现 Android 多微信应用选择器,只读核单器必须同时确认前台包为 `android` / `com.android.intentresolver`、Activity 为白名单 `ChooserActivity` / `ResolverActivity`、页面出现已知系统选择器标题且至少一个候选以“微信”开头,才允许执行一次系统返回;不得点击任何微信候选。若前台已经是精确微信包 `com.tencent.mm`,只读核单器不得点击、输入、登录、支付或强制停止微信,只允许执行一次无参数 PDD 启动 Intent 并等待既有 PDD 任务栈回到前台;`startActivity()` 成功只表示恢复请求已发起。请求后在固定最多 15 次、每次 200ms 的宽限期内允许微信或空窗口短暂残留,不执行点击、返回、输入或滑动;观察到 PDD 后结束宽限,宽限超时、已经观察到 PDD 后再次进入微信或出现稳定未知应用时返回未知结果。随后若前台为 PDD `com.xunmeng.pinduoduo.app_pay.core.PayActivity` 或当前页面出现支付动作文字,最多再返回一次。返回后只读解析唯一订单号和下单时间;选择器或支付页重复出现、白名单不成立、恢复动作重复、无法到达订单详情、结果不唯一或超时均返回未知结果,禁止再次点击创建订单、取消订单或支付。 +`order_result_unknown` 保持订单号和下单时间为空,但允许携带创建订单前已经严格验证的 `actualUnitPriceCent`。Agent 同时提交脱敏稳定失败阶段,服务端只接受白名单并按错误码写入固定提示,不信任或保存页面原文;阶段覆盖空窗口超时、选择器返回失败、微信恢复失败/超时、未知应用、支付页返回失败/重复、订单上下文缺失、订单号缺失/歧义、下单时间缺失/无效和待付款证据缺失。任务与 attempt 保存同一错误阶段,Admin 和 Agent 历史读取数据库最终事实;旧 Agent 未提交阶段时归一为 `PURCHASE_ORDER_RESULT_UNKNOWN`。 | 错误码 | 普通提示 | |---|---| diff --git a/server/app/goauto/purchase/lifecycle.go b/server/app/goauto/purchase/lifecycle.go index 6c6a9d9..82d2e82 100644 --- a/server/app/goauto/purchase/lifecycle.go +++ b/server/app/goauto/purchase/lifecycle.go @@ -369,8 +369,18 @@ func (s *Service) SubmitResult(ctx context.Context, taskID uint64, req ResultReq if t.ExecutionMode != models.PurchaseExecutionModeLive || t.Status != models.PurchaseTaskStatusOrderSubmitStarted { return TaskPayload{}, fail(CodeStateConflict, "当前任务不能标记订单结果未知") } + if strings.TrimSpace(req.PDDOrderNo) != "" || req.OrderSubmittedAt != nil { + return TaskPayload{}, fail(CodeInvalidRequest, "订单结果未知时不能提交订单号或下单时间") + } + failureCode, failureMessage, failureErr := normalizeOrderUnknownFailure(req.ErrorCode, req.ErrorMessage) + if failureErr != nil { + return TaskPayload{}, failureErr + } next = models.PurchaseTaskStatusOrderResultUnknown a.Status = models.PurchaseAttemptStatusFailed + a.ErrorCode, a.ErrorMessage = &failureCode, &failureMessage + t.ErrorCode, t.ErrorMessage = &failureCode, &failureMessage + t.ActualUnitPriceCent = req.ActualUnitPriceCent case "failed": next = models.PurchaseTaskStatusFailed a.Status = models.PurchaseAttemptStatusFailed @@ -406,6 +416,35 @@ func (s *Service) SubmitResult(ctx context.Context, taskID uint64, req ResultReq return s.resolveProbedSpecs(ctx, taskID, req.TaskAttemptID, req.ProbedSpecs) } +var allowedOrderUnknownFailures = map[string]string{ + CodeOrderResultUnknown: "无法确认订单是否创建,请人工检查", + CodeOrderEmptyTimeout: "等待订单页面时无障碍窗口持续为空", + CodeOrderChooserBack: "系统应用选择页无法安全返回", + CodeOrderWechatRestore: "从微信恢复拼多多的请求失败", + CodeOrderWechatTimeout: "从微信恢复拼多多后未在限定时间到达订单页面", + CodeOrderUnexpectedApp: "核单期间出现未授权应用", + CodeOrderPaymentBack: "支付页无法安全返回订单详情", + CodeOrderPaymentRepeat: "支付页重复出现,已停止自动核单", + CodeOrderContextMissing: "限定时间内没有出现订单详情证据", + CodeOrderNoMissing: "订单详情缺少可读取的订单号", + CodeOrderNoAmbiguous: "订单详情出现多个订单号,无法唯一确认", + CodeOrderTimeMissing: "订单详情缺少可读取的下单时间", + CodeOrderTimeInvalid: "订单详情的下单时间格式无法确认", + CodeOrderUnpaidMissing: "订单详情缺少待付款状态证据", +} + +func normalizeOrderUnknownFailure(code, message string) (string, string, error) { + code, message = strings.TrimSpace(code), strings.TrimSpace(message) + if code == "" && message == "" { + return CodeOrderResultUnknown, "无法确认订单是否创建,请人工检查", nil + } + canonicalMessage, ok := allowedOrderUnknownFailures[code] + if !ok || message == "" { + return "", "", fail(CodeInvalidRequest, "订单核单失败阶段无效") + } + return code, canonicalMessage, nil +} + func (s *Service) ApplySpecDecision(ctx context.Context, taskID uint64, req SpecDecisionRequest) (models.PurchaseTask, bool, error) { operatorID := req.OperatorID return s.applySpecDecision(ctx, taskID, req, &operatorID) diff --git a/server/app/goauto/purchase/service_test.go b/server/app/goauto/purchase/service_test.go index 2eace9c..15cfe01 100644 --- a/server/app/goauto/purchase/service_test.go +++ b/server/app/goauto/purchase/service_test.go @@ -478,15 +478,64 @@ func TestOrderUnknownIsNotAutomaticallyRedispatched(t *testing.T) { s.Claim(context.Background(), task.ID, ActionRequest{RequestID: uuid.NewString()}, f.token) started, _ := s.Start(context.Background(), task.ID, ActionRequest{RequestID: uuid.NewString()}, f.token) s.MarkOrderSubmitStarted(context.Background(), task.ID, ActionRequest{RequestID: uuid.NewString()}, f.token) - if _, err := s.SubmitResult(context.Background(), task.ID, ResultRequest{RequestID: uuid.NewString(), TaskAttemptID: started.TaskAttemptID, ResultType: "order_result_unknown"}, f.token); err != nil { + actual := int64(1999) + if _, err := s.SubmitResult(context.Background(), task.ID, ResultRequest{ + RequestID: uuid.NewString(), TaskAttemptID: started.TaskAttemptID, ResultType: "order_result_unknown", + ActualUnitPriceCent: &actual, ErrorCode: CodeOrderTimeMissing, ErrorMessage: "订单详情缺少可读取的下单时间", + }, f.token); err != nil { t.Fatal(err) } + var saved models.PurchaseTask + if err := db.First(&saved, task.ID).Error; err != nil { + t.Fatal(err) + } + if saved.ActualUnitPriceCent == nil || *saved.ActualUnitPriceCent != actual || saved.PDDOrderNo != nil || saved.OrderSubmittedAt != nil || saved.ErrorCode == nil || *saved.ErrorCode != CodeOrderTimeMissing { + t.Fatalf("unknown task evidence not preserved: %+v", saved) + } + var attempt models.PurchaseTaskAttempt + if err := db.Where("task_id = ?", task.ID).First(&attempt).Error; err != nil { + t.Fatal(err) + } + if attempt.ErrorCode == nil || *attempt.ErrorCode != CodeOrderTimeMissing || attempt.ErrorMessage == nil { + t.Fatalf("unknown attempt failure not preserved: %+v", attempt) + } next, err := s.Next(context.Background(), f.token) if err != nil || next != nil { t.Fatalf("unknown task redispatched: %+v %v", next, err) } } +func TestOrderUnknownRejectsOrderFieldsAndUnapprovedFailureCode(t *testing.T) { + db := testDB(t) + f := seed(t, db, liveCaps(), true) + s := testService(db) + task, _ := createLive(t, s, f) + s.Claim(context.Background(), task.ID, ActionRequest{RequestID: uuid.NewString()}, f.token) + started, _ := s.Start(context.Background(), task.ID, ActionRequest{RequestID: uuid.NewString()}, f.token) + s.MarkOrderSubmitStarted(context.Background(), task.ID, ActionRequest{RequestID: uuid.NewString()}, f.token) + + if _, err := s.SubmitResult(context.Background(), task.ID, ResultRequest{ + RequestID: uuid.NewString(), TaskAttemptID: started.TaskAttemptID, ResultType: "order_result_unknown", + ErrorCode: "UNAPPROVED", ErrorMessage: "invalid", + }, f.token); code(err) != CodeInvalidRequest { + t.Fatalf("unapproved code error=%v", err) + } + submitted := time.Now() + if _, err := s.SubmitResult(context.Background(), task.ID, ResultRequest{ + RequestID: uuid.NewString(), TaskAttemptID: started.TaskAttemptID, ResultType: "order_result_unknown", + PDDOrderNo: "PDD-should-not-save", OrderSubmittedAt: &submitted, + }, f.token); code(err) != CodeInvalidRequest { + t.Fatalf("unknown order fields error=%v", err) + } +} + +func TestNormalizeOrderUnknownFailureKeepsLegacyAgentCompatibility(t *testing.T) { + gotCode, gotMessage, err := normalizeOrderUnknownFailure("", "") + if err != nil || gotCode != CodeOrderResultUnknown || gotMessage == "" { + t.Fatalf("legacy unknown normalization code=%q message=%q err=%v", gotCode, gotMessage, err) + } +} + func TestClaimLocksKnownPDDAccountAcrossDevices(t *testing.T) { db := testDB(t) first := seed(t, db, liveCaps(), true) diff --git a/server/app/goauto/purchase/types.go b/server/app/goauto/purchase/types.go index 41094fe..b3431b6 100644 --- a/server/app/goauto/purchase/types.go +++ b/server/app/goauto/purchase/types.go @@ -21,6 +21,20 @@ const ( CodeRetryUnsafe = "PURCHASE_RETRY_UNSAFE" CodeRetryStale = "PURCHASE_RETRY_STALE" CodeSpecReprobeRejected = "PURCHASE_SPEC_REPROBE_REJECTED" + CodeOrderResultUnknown = "PURCHASE_ORDER_RESULT_UNKNOWN" + CodeOrderEmptyTimeout = "PURCHASE_ORDER_EMPTY_TIMEOUT" + CodeOrderChooserBack = "PURCHASE_ORDER_CHOOSER_BACK_FAILED" + CodeOrderWechatRestore = "PURCHASE_ORDER_WECHAT_RESTORE_FAILED" + CodeOrderWechatTimeout = "PURCHASE_ORDER_WECHAT_RESTORE_TIMEOUT" + CodeOrderUnexpectedApp = "PURCHASE_ORDER_UNEXPECTED_APP" + CodeOrderPaymentBack = "PURCHASE_ORDER_PAYMENT_BACK_FAILED" + CodeOrderPaymentRepeat = "PURCHASE_ORDER_PAYMENT_REPEATED" + CodeOrderContextMissing = "PURCHASE_ORDER_CONTEXT_NOT_FOUND" + CodeOrderNoMissing = "PURCHASE_ORDER_NO_MISSING" + CodeOrderNoAmbiguous = "PURCHASE_ORDER_NO_AMBIGUOUS" + CodeOrderTimeMissing = "PURCHASE_ORDER_TIME_MISSING" + CodeOrderTimeInvalid = "PURCHASE_ORDER_TIME_INVALID" + CodeOrderUnpaidMissing = "PURCHASE_ORDER_UNPAID_EVIDENCE_MISSING" CodeInternal = "INTERNAL_ERROR" )