fix(goauto): preserve unknown order evidence (#167)
This commit is contained in:
@@ -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"
|
||||
|
||||
|
||||
+50
-8
@@ -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<String>()
|
||||
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<String>): Boolean {
|
||||
@@ -369,6 +390,27 @@ class PurchaseLiveAutomation(
|
||||
return PurchaseOrderEvidence(orderNumbers.single(), output)
|
||||
}
|
||||
|
||||
private fun orderEvidenceFailure(labels: Collection<String>): 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 {
|
||||
|
||||
+14
-2
@@ -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) {
|
||||
|
||||
+8
-20
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
@@ -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", "微信分身"),
|
||||
))
|
||||
|
||||
@@ -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"))
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
<!-- gitea-wiki-mirror:start -->
|
||||
@@ -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`。
|
||||
|
||||
| 错误码 | 普通提示 |
|
||||
|---|---|
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user