From 1e87b6273aaeddfee994899072ccd806cb4b2fff Mon Sep 17 00:00:00 2001 From: QiuSW <105186638@qq.com> Date: Tue, 28 Jul 2026 16:52:29 +0800 Subject: [PATCH] feat(t217): verify authorized order dry runs --- android-buyer/app/build.gradle.kts | 7 +- .../accessibility/BuyerAccessibilityBridge.kt | 47 ++ .../BuyerAccessibilityService.kt | 223 +++++++- .../AndroidPinduoduoImageSearchDriver.kt | 9 + .../AndroidPinduoduoOrderDryRunDriver.kt | 103 ++++ .../pinduoduo/PinduoduoCandidateModels.kt | 14 + .../pinduoduo/PinduoduoCheckoutOcr.kt | 328 +++++++++++ .../PinduoduoImageSearchAutomation.kt | 52 +- .../PinduoduoOrderDryRunAutomation.kt | 392 +++++++++++++ .../pinduoduo/PinduoduoOrderDryRunModels.kt | 171 ++++++ .../pinduoduo/PinduoduoPageClassifier.kt | 61 +- .../procurement/OrderCommandIntegrity.kt | 3 +- .../OrderCommandSynchronization.kt | 4 +- .../procurement/OrderDryRunCoordinator.kt | 97 ++++ .../procurement/ProcurementApiClient.kt | 77 ++- .../ProcurementExecutionService.kt | 21 + .../procurement/ProcurementModels.kt | 63 ++- .../procurement/ProcurementRepository.kt | 346 +++++++++++- .../procurement/ProcurementSecureStore.kt | 66 ++- .../autopilot/ui/screens/ProcurementScreen.kt | 25 +- .../PinduoduoCheckoutOcrPolicyTest.kt | 174 ++++++ .../PinduoduoImageSearchAutomationTest.kt | 118 +++- .../PinduoduoObservedTitlePolicyTest.kt | 20 + .../PinduoduoOrderConfirmationParserTest.kt | 44 ++ .../PinduoduoOrderDryRunAutomationTest.kt | 267 +++++++++ .../pinduoduo/PinduoduoPageClassifierTest.kt | 86 ++- .../procurement/ProcurementApiClientTest.kt | 98 +++- backend-api/cmd/api/main.go | 5 + backend-api/internal/domain/task.go | 27 + .../migration/claims_migration_test.go | 27 +- .../platform/migration/runner_test.go | 61 +- .../repository/sqlite/auth_repository_test.go | 9 +- .../sqlite/device_order_command_repository.go | 8 +- .../sqlite/order_dry_run_repository.go | 521 ++++++++++++++++++ .../transport/httpapi/admin_handlers_test.go | 3 + .../transport/httpapi/device_handlers.go | 133 ++++- .../transport/httpapi/device_handlers_test.go | 164 +++++- .../internal/usecase/order_dry_run_service.go | 304 ++++++++++ .../migrations/00010_order_dry_runs.sql | 217 ++++++++ docs/00-ai-start-here.md | 4 +- docs/04-architecture.md | 5 + docs/08-interaction-checklist.md | 2 +- docs/current-state.md | 30 +- docs/tasks/T-217.md | 38 +- progress.md | 12 + 45 files changed, 4358 insertions(+), 128 deletions(-) create mode 100644 android-buyer/app/src/main/java/com/roubao/autopilot/pinduoduo/AndroidPinduoduoOrderDryRunDriver.kt create mode 100644 android-buyer/app/src/main/java/com/roubao/autopilot/pinduoduo/PinduoduoCheckoutOcr.kt create mode 100644 android-buyer/app/src/main/java/com/roubao/autopilot/pinduoduo/PinduoduoOrderDryRunAutomation.kt create mode 100644 android-buyer/app/src/main/java/com/roubao/autopilot/pinduoduo/PinduoduoOrderDryRunModels.kt create mode 100644 android-buyer/app/src/main/java/com/roubao/autopilot/procurement/OrderDryRunCoordinator.kt create mode 100644 android-buyer/app/src/test/java/com/roubao/autopilot/pinduoduo/PinduoduoCheckoutOcrPolicyTest.kt create mode 100644 android-buyer/app/src/test/java/com/roubao/autopilot/pinduoduo/PinduoduoOrderConfirmationParserTest.kt create mode 100644 android-buyer/app/src/test/java/com/roubao/autopilot/pinduoduo/PinduoduoOrderDryRunAutomationTest.kt create mode 100644 backend-api/internal/repository/sqlite/order_dry_run_repository.go create mode 100644 backend-api/internal/usecase/order_dry_run_service.go create mode 100644 backend-api/migrations/00010_order_dry_runs.sql diff --git a/android-buyer/app/build.gradle.kts b/android-buyer/app/build.gradle.kts index 2b26879..4e09a6d 100644 --- a/android-buyer/app/build.gradle.kts +++ b/android-buyer/app/build.gradle.kts @@ -11,8 +11,8 @@ android { applicationId = "com.roubao.autopilot" minSdk = 26 targetSdk = 34 - versionCode = 18 - versionName = "1.4.13" + versionCode = 19 + versionName = "1.4.14" vectorDrawables { useSupportLibrary = true @@ -107,6 +107,9 @@ dependencies { // JSON implementation("org.json:json:20231013") + // Bundled on-device OCR for Pinduoduo WebView checkout evidence + implementation("com.google.mlkit:text-recognition-chinese:16.0.1") + // Unit tests testImplementation("junit:junit:4.13.2") testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.3") diff --git a/android-buyer/app/src/main/java/com/roubao/autopilot/accessibility/BuyerAccessibilityBridge.kt b/android-buyer/app/src/main/java/com/roubao/autopilot/accessibility/BuyerAccessibilityBridge.kt index 619cbd8..48d0ed8 100644 --- a/android-buyer/app/src/main/java/com/roubao/autopilot/accessibility/BuyerAccessibilityBridge.kt +++ b/android-buyer/app/src/main/java/com/roubao/autopilot/accessibility/BuyerAccessibilityBridge.kt @@ -3,6 +3,7 @@ package com.roubao.autopilot.accessibility import com.roubao.autopilot.pinduoduo.PinduoduoPage import com.roubao.autopilot.pinduoduo.PinduoduoCandidateCard import com.roubao.autopilot.pinduoduo.PinduoduoCandidateDetailEvidence +import com.roubao.autopilot.pinduoduo.PinduoduoOrderConfirmationEvidence import com.roubao.autopilot.pinduoduo.PinduoduoScreenshotCapture import com.roubao.autopilot.pinduoduo.PinduoduoSpecificationEvidence import com.roubao.autopilot.pinduoduo.PinduoduoSpecificationGroupKind @@ -44,6 +45,11 @@ object BuyerAccessibilityBridge { service?.clickPinduoduoImageSearchEntry() == true } + suspend fun dismissImageSearchRetryDialog(): Boolean = + withContext(Dispatchers.Main.immediate) { + service?.dismissPinduoduoImageSearchRetryDialog() == true + } + suspend fun selectFirstRecentImage(): Boolean = withContext(Dispatchers.Main.immediate) { service?.selectFirstPinduoduoRecentImage() == true @@ -107,6 +113,42 @@ object BuyerAccessibilityBridge { service?.scrollPinduoduoSpecifications() == true } + suspend fun specificationQuantity(): Int? = + withContext(Dispatchers.Main.immediate) { + service?.readPinduoduoSpecificationQuantity() + } + + suspend fun changeSpecificationQuantity(increase: Boolean): Boolean = + withContext(Dispatchers.Main.immediate) { + service?.changePinduoduoSpecificationQuantity(increase) == true + } + + suspend fun confirmSpecifications(): Boolean = + withContext(Dispatchers.Main.immediate) { + service?.confirmPinduoduoSpecifications() == true + } + + suspend fun orderConfirmationEvidence(): + PinduoduoOrderConfirmationEvidence? = + withContext(Dispatchers.Main.immediate) { + service?.readPinduoduoOrderConfirmationEvidence() + } + + suspend fun isCollapsedCheckout(): Boolean = + withContext(Dispatchers.Main.immediate) { + service?.isPinduoduoCollapsedCheckout() == true + } + + suspend fun captureOrderConfirmationScreenshot(): + PinduoduoScreenshotCapture? = + withTimeoutOrNull(SCREENSHOT_TIMEOUT_MILLIS) { + withContext(Dispatchers.Main.immediate) { + service?.capturePinduoduoScreenshot( + PinduoduoPage.ORDER_CONFIRMATION + ) + } + } + suspend fun captureSpecificationScreenshot(): PinduoduoScreenshotCapture? = withTimeoutOrNull(SCREENSHOT_TIMEOUT_MILLIS) { withContext(Dispatchers.Main.immediate) { @@ -141,5 +183,10 @@ object BuyerAccessibilityBridge { service?.scrollPinduoduoResults() == true } + suspend fun scrollResultsBackward(): Boolean = + withContext(Dispatchers.Main.immediate) { + service?.scrollPinduoduoResultsBackward() == true + } + private const val SCREENSHOT_TIMEOUT_MILLIS = 8_000L } diff --git a/android-buyer/app/src/main/java/com/roubao/autopilot/accessibility/BuyerAccessibilityService.kt b/android-buyer/app/src/main/java/com/roubao/autopilot/accessibility/BuyerAccessibilityService.kt index 745eaf8..a9521e7 100644 --- a/android-buyer/app/src/main/java/com/roubao/autopilot/accessibility/BuyerAccessibilityService.kt +++ b/android-buyer/app/src/main/java/com/roubao/autopilot/accessibility/BuyerAccessibilityService.kt @@ -13,8 +13,10 @@ import android.view.accessibility.AccessibilityNodeInfo import androidx.annotation.RequiresApi import com.roubao.autopilot.pinduoduo.PinduoduoCandidateCard import com.roubao.autopilot.pinduoduo.PinduoduoCandidateDetailEvidence -import com.roubao.autopilot.pinduoduo.PinduoduoEvidenceHash +import com.roubao.autopilot.pinduoduo.PinduoduoCollapsedCheckoutPolicy import com.roubao.autopilot.pinduoduo.PinduoduoObservedTitlePolicy +import com.roubao.autopilot.pinduoduo.PinduoduoOrderConfirmationEvidence +import com.roubao.autopilot.pinduoduo.PinduoduoOrderConfirmationParser import com.roubao.autopilot.readiness.DeviceObservationStore import com.roubao.autopilot.readiness.LoginBlockerDetector import com.roubao.autopilot.readiness.PINDUODUO_PACKAGE @@ -28,6 +30,8 @@ import com.roubao.autopilot.pinduoduo.PinduoduoScreenshotCapture import com.roubao.autopilot.pinduoduo.PinduoduoSpecificationEvidence import com.roubao.autopilot.pinduoduo.PinduoduoSpecificationGroupKind import com.roubao.autopilot.pinduoduo.PinduoduoSpecificationParser +import com.roubao.autopilot.pinduoduo.PinduoduoStableProductIdentityPolicy +import com.roubao.autopilot.workflow.SafetyStopReason import java.io.ByteArrayOutputStream import java.util.ArrayDeque import java.util.concurrent.Executors @@ -156,6 +160,27 @@ class BuyerAccessibilityService : AccessibilityService() { ?.performAction(AccessibilityNodeInfo.ACTION_CLICK) == true } ?: false + internal fun dismissPinduoduoImageSearchRetryDialog(): Boolean = + withPinduoduoRoot { root -> + val snapshot = classifyPinduoduoRoot(root) + if ( + snapshot.safetyStopReason != null || + snapshot.page != PinduoduoPage.IMAGE_SEARCH_RETRY_DIALOG + ) { + return@withPinduoduoRoot false + } + collectNodes(root) + .filter { node -> + node.isVisibleToUser && + node.isEnabled && + node.isClickable && + node.text?.toString()?.trim() == + IMAGE_SEARCH_RETRY_CANCEL_TEXT + } + .singleOrNull() + ?.performAction(AccessibilityNodeInfo.ACTION_CLICK) == true + } ?: false + internal fun selectFirstPinduoduoRecentImage(): Boolean = withPinduoduoRoot { root -> val snapshot = classifyPinduoduoRoot(root) @@ -317,12 +342,14 @@ class BuyerAccessibilityService : AccessibilityService() { if (semanticTexts.isEmpty()) { return@withPinduoduoRoot null } + val observedTitle = PinduoduoObservedTitlePolicy.select(semanticTexts) + ?: return@withPinduoduoRoot null PinduoduoCandidateDetailEvidence( - signature = PinduoduoEvidenceHash.sha256( - semanticTexts.joinToString(TEXT_SIGNATURE_SEPARATOR) + signature = requireNotNull( + PinduoduoStableProductIdentityPolicy.signature(semanticTexts) ), semanticTextCount = semanticTexts.size, - observedTitle = PinduoduoObservedTitlePolicy.select(semanticTexts) + observedTitle = observedTitle ) } @@ -428,6 +455,78 @@ class BuyerAccessibilityService : AccessibilityService() { ) == true } ?: false + internal fun readPinduoduoSpecificationQuantity(): Int? = + withPinduoduoRoot { root -> + if ( + classifyPinduoduoRoot(root).page != + PinduoduoPage.SPECIFICATION_PANEL + ) { + return@withPinduoduoRoot null + } + quantityControl(root)?.quantity + } + + internal fun changePinduoduoSpecificationQuantity( + increase: Boolean + ): Boolean = + withPinduoduoRoot { root -> + if ( + classifyPinduoduoRoot(root).page != + PinduoduoPage.SPECIFICATION_PANEL + ) { + return@withPinduoduoRoot false + } + val control = quantityControl(root) + ?: return@withPinduoduoRoot false + val target = if (increase) control.increase else control.decrease + target.isEnabled && + target.performAction(AccessibilityNodeInfo.ACTION_CLICK) + } ?: false + + internal fun confirmPinduoduoSpecifications(): Boolean = + withPinduoduoRoot { root -> + if ( + classifyPinduoduoRoot(root).page != + PinduoduoPage.SPECIFICATION_PANEL + ) { + return@withPinduoduoRoot false + } + uniqueClickableTargets( + collectNodes(root).filter { node -> + node.isVisibleToUser && + node.isEnabled && + normalizeActionText(semanticText(node)) == "确定" + } + ).singleOrNull()?.performAction( + AccessibilityNodeInfo.ACTION_CLICK + ) == true + } ?: false + + internal fun readPinduoduoOrderConfirmationEvidence(): + PinduoduoOrderConfirmationEvidence? = + withPinduoduoRoot { root -> + val snapshot = classifyPinduoduoRoot(root) + if ( + snapshot.page != PinduoduoPage.ORDER_CONFIRMATION || + snapshot.safetyStopReason !in setOf( + null, + SafetyStopReason.PAYMENT_BOUNDARY + ) + ) { + return@withPinduoduoRoot null + } + PinduoduoOrderConfirmationParser.parse( + collectNodes(root).map(::uiElement) + ) + } + + internal fun isPinduoduoCollapsedCheckout(): Boolean = + withPinduoduoRoot { root -> + PinduoduoCollapsedCheckoutPolicy.matches( + collectNodes(root).map(::uiElement) + ) + } ?: false + internal fun closePinduoduoSpecifications(): Boolean = withPinduoduoRoot { root -> val snapshot = classifyPinduoduoRoot(root) @@ -485,6 +584,20 @@ class BuyerAccessibilityService : AccessibilityService() { ) == true } ?: false + internal fun scrollPinduoduoResultsBackward(): Boolean = + withPinduoduoRoot { root -> + val snapshot = classifyPinduoduoRoot(root) + if ( + snapshot.safetyStopReason != null || + !snapshot.page.isCandidateResultsPage() + ) { + return@withPinduoduoRoot false + } + findMainResultsRecycler(root)?.performAction( + AccessibilityNodeInfo.ACTION_SCROLL_BACKWARD + ) == true + } ?: false + internal suspend fun capturePinduoduoScreenshot( expectedPage: PinduoduoPage ): PinduoduoScreenshotCapture? { @@ -494,7 +607,8 @@ class BuyerAccessibilityService : AccessibilityService() { if ( expectedPage !in setOf( PinduoduoPage.PRODUCT_DETAIL, - PinduoduoPage.SPECIFICATION_PANEL + PinduoduoPage.SPECIFICATION_PANEL, + PinduoduoPage.ORDER_CONFIRMATION ) ) { return null @@ -509,10 +623,28 @@ class BuyerAccessibilityService : AccessibilityService() { PinduoduoScreenshotCapture? { val root = rootInActiveWindow val snapshot = root?.let(::classifyPinduoduoRoot) + val collapsedCheckoutExpected = + root != null && + expectedPage == PinduoduoPage.ORDER_CONFIRMATION && + snapshot?.page == PinduoduoPage.UNKNOWN && + snapshot.safetyStopReason == null && + PinduoduoCollapsedCheckoutPolicy.matches( + collectNodes(root).map(::uiElement) + ) if ( root?.packageName?.toString() != PINDUODUO_PACKAGE || - snapshot?.safetyStopReason != null || - snapshot?.page != expectedPage || + ( + snapshot?.safetyStopReason != null && + !( + expectedPage == PinduoduoPage.ORDER_CONFIRMATION && + snapshot.safetyStopReason == + SafetyStopReason.PAYMENT_BOUNDARY + ) + ) || + ( + snapshot?.page != expectedPage && + !collapsedCheckoutExpected + ) || ( expectedPage == PinduoduoPage.PRODUCT_DETAIL && !isVerifiedProductDetail(root) @@ -673,18 +805,19 @@ class BuyerAccessibilityService : AccessibilityService() { } val hasPrice = semanticTexts.any(::hasPriceSemantics) val clickTarget = findSafeCandidateClickTarget(node, descendants) + val stableSignature = + PinduoduoStableProductIdentityPolicy.signature(semanticTexts) if ( semanticTexts.size < MIN_CANDIDATE_TEXTS || !hasImage || !hasPrice || - clickTarget == null + clickTarget == null || + stableSignature == null ) { continue } val card = PinduoduoCandidateCard( - signature = PinduoduoEvidenceHash.sha256( - semanticTexts.joinToString(TEXT_SIGNATURE_SEPARATOR) - ), + signature = stableSignature, semanticTextCount = semanticTexts.size, hasImage = true ) @@ -790,6 +923,60 @@ class BuyerAccessibilityService : AccessibilityService() { return coveringTargets.singleOrNull() } + private fun quantityControl(root: AccessibilityNodeInfo): QuantityControl? { + val controls = collectNodes(root).mapNotNull { valueNode -> + val quantity = semanticText(valueNode).trim().toIntOrNull() + ?.takeIf { it in 1..99 } + ?: return@mapNotNull null + var container: AccessibilityNodeInfo? = valueNode.parent + repeat(MAX_QUANTITY_ANCESTORS) { + val current = container ?: return@repeat + val descendants = collectNodes(current) + val increase = uniqueClickableTargets( + descendants.filter { node -> + node.isVisibleToUser && + normalizeActionText(semanticText(node)) in + INCREASE_QUANTITY_TEXTS + } + ).singleOrNull() + val decrease = uniqueClickableTargets( + descendants.filter { node -> + node.isVisibleToUser && + normalizeActionText(semanticText(node)) in + DECREASE_QUANTITY_TEXTS + } + ).singleOrNull() + if (increase != null && decrease != null) { + val bounds = Rect().also(current::getBoundsInScreen) + return@mapNotNull QuantityControl( + quantity, + increase, + decrease, + area(bounds) + ) + } + container = current.parent + } + null + } + return controls + .distinctBy { control -> + val increaseBounds = Rect().also( + control.increase::getBoundsInScreen + ) + val decreaseBounds = Rect().also( + control.decrease::getBoundsInScreen + ) + listOf( + increaseBounds.left, + increaseBounds.top, + decreaseBounds.right, + decreaseBounds.bottom + ) + } + .minByOrNull(QuantityControl::area) + } + private fun hasPriceSemantics(value: String): Boolean = value.contains('¥') || value.contains('¥') || @@ -932,10 +1119,18 @@ class BuyerAccessibilityService : AccessibilityService() { val descendantCount: Int ) + private data class QuantityControl( + val quantity: Int, + val increase: AccessibilityNodeInfo, + val decrease: AccessibilityNodeInfo, + val area: Long + ) + companion object { private const val TAG = "BuyerAccessibility" private const val MAX_NODES = 300 private const val MAX_CLICK_ANCESTORS = 4 + private const val MAX_QUANTITY_ANCESTORS = 4 private const val MAX_CANDIDATE_NODE_SCAN = 20 private const val MIN_CANDIDATE_TEXTS = 2 private const val MIN_MAIN_RECYCLER_NODES = 20 @@ -948,13 +1143,17 @@ class BuyerAccessibilityService : AccessibilityService() { private const val MIN_CLICK_TARGET_COVER_PERCENT = 85 private const val MAX_EVIDENCE_TEXTS = 100 private const val MAX_EVIDENCE_TEXT_LENGTH = 500 - private const val TEXT_SIGNATURE_SEPARATOR = "\u001f" private const val MIN_SCAN_INTERVAL_MS = 300L private const val IMAGE_SEARCH_DESCRIPTION = "拍照搜索" + private const val IMAGE_SEARCH_RETRY_CANCEL_TEXT = "取消" private const val RECENT_PROJECTS_TEXT = "最近项目" private const val IMAGE_GRID_COLUMNS = 4 private const val IMAGE_GRID_WIDTH_TOLERANCE = 24 private const val SPECIFICATION_PURCHASE_ENTRY = "免拼购买" + private val INCREASE_QUANTITY_TEXTS = + setOf("增加数量", "加", "+") + private val DECREASE_QUANTITY_TEXTS = + setOf("减少数量", "减", "-") private val DECIMAL_PRICE_PATTERN = Regex("^\\s*\\d{1,6}\\.\\d{1,2}\\s*$") } diff --git a/android-buyer/app/src/main/java/com/roubao/autopilot/pinduoduo/AndroidPinduoduoImageSearchDriver.kt b/android-buyer/app/src/main/java/com/roubao/autopilot/pinduoduo/AndroidPinduoduoImageSearchDriver.kt index 16da5b6..8b26747 100644 --- a/android-buyer/app/src/main/java/com/roubao/autopilot/pinduoduo/AndroidPinduoduoImageSearchDriver.kt +++ b/android-buyer/app/src/main/java/com/roubao/autopilot/pinduoduo/AndroidPinduoduoImageSearchDriver.kt @@ -30,10 +30,19 @@ class AndroidPinduoduoImageSearchDriver( override suspend fun openImageSearch(): Boolean = BuyerAccessibilityBridge.openImageSearch() + override suspend fun dismissImageSearchRetryDialog(): Boolean = + BuyerAccessibilityBridge.dismissImageSearchRetryDialog() + override suspend fun selectPreparedImage(): Boolean = assetStore.isMostRecent(preparedImage) && BuyerAccessibilityBridge.selectFirstRecentImage() + override suspend fun closeSpecifications(): Boolean = + BuyerAccessibilityBridge.closeSpecifications() + + override suspend fun returnFromOrderConfirmation(): Boolean = + BuyerAccessibilityBridge.returnFromOrderConfirmation() + override suspend fun returnFromCandidate(): Boolean = BuyerAccessibilityBridge.returnToResults() diff --git a/android-buyer/app/src/main/java/com/roubao/autopilot/pinduoduo/AndroidPinduoduoOrderDryRunDriver.kt b/android-buyer/app/src/main/java/com/roubao/autopilot/pinduoduo/AndroidPinduoduoOrderDryRunDriver.kt new file mode 100644 index 0000000..3c9b12e --- /dev/null +++ b/android-buyer/app/src/main/java/com/roubao/autopilot/pinduoduo/AndroidPinduoduoOrderDryRunDriver.kt @@ -0,0 +1,103 @@ +package com.roubao.autopilot.pinduoduo + +import com.roubao.autopilot.accessibility.BuyerAccessibilityBridge + +class AndroidPinduoduoOrderDryRunDriver( + private val authorizedTitle: String, + private val expectedColor: String, + private val expectedSize: String, + private val expectedQuantity: Int, + private val ocrReader: PinduoduoCheckoutOcrReader = + MlKitPinduoduoCheckoutOcrReader() +) : PinduoduoOrderDryRunDriver { + private var expectingOrderConfirmation = false + + override suspend fun snapshot(): PinduoduoUiSnapshot { + val snapshot = BuyerAccessibilityBridge.snapshot() + if ( + expectingOrderConfirmation && + snapshot.foregroundPackage == + com.roubao.autopilot.readiness.PINDUODUO_PACKAGE && + snapshot.page == PinduoduoPage.UNKNOWN && + snapshot.safetyStopReason == null && + BuyerAccessibilityBridge.isCollapsedCheckout() + ) { + return snapshot.copy( + page = PinduoduoPage.ORDER_CONFIRMATION, + safetyStopReason = + com.roubao.autopilot.workflow.SafetyStopReason.PAYMENT_BOUNDARY + ) + } + return snapshot + } + + override suspend fun candidateCards( + limit: Int + ): List = + BuyerAccessibilityBridge.candidateCards(limit) + + override suspend fun openCandidate(signature: String): Boolean = + BuyerAccessibilityBridge.openCandidate(signature) + + override suspend fun readCandidateDetail(): + PinduoduoCandidateDetailEvidence? = + BuyerAccessibilityBridge.candidateDetailEvidence() + + override suspend fun openSpecifications(): Boolean = + BuyerAccessibilityBridge.openSpecifications() + + override suspend fun readSpecifications(): PinduoduoSpecificationEvidence? = + BuyerAccessibilityBridge.specificationEvidence() + + override suspend fun selectSpecificationOption( + kind: PinduoduoSpecificationGroupKind, + optionText: String + ): Boolean = + BuyerAccessibilityBridge.selectSpecificationOption(kind, optionText) + + override suspend fun scrollSpecifications(): Boolean = + BuyerAccessibilityBridge.scrollSpecifications() + + override suspend fun closeSpecifications(): Boolean = + BuyerAccessibilityBridge.closeSpecifications() + + override suspend fun returnToResults(): Boolean = + BuyerAccessibilityBridge.returnToResults() + + override suspend fun scrollResults(): Boolean = + BuyerAccessibilityBridge.scrollResults() + + override suspend fun scrollResultsBackward(): Boolean = + BuyerAccessibilityBridge.scrollResultsBackward() + + override suspend fun readQuantity(): Int? = + BuyerAccessibilityBridge.specificationQuantity() + + override suspend fun changeQuantity(increase: Boolean): Boolean = + BuyerAccessibilityBridge.changeSpecificationQuantity(increase) + + override suspend fun confirmSpecifications(): Boolean = + BuyerAccessibilityBridge.confirmSpecifications().also { confirmed -> + if (confirmed) { + expectingOrderConfirmation = true + } + } + + override suspend fun readOrderConfirmation(): + PinduoduoOrderConfirmationEvidence? = + BuyerAccessibilityBridge.orderConfirmationEvidence() + ?: BuyerAccessibilityBridge.captureOrderConfirmationScreenshot() + ?.let { screenshot -> + ocrReader.read( + pngBytes = screenshot.pngBytes, + authorizedTitle = authorizedTitle, + expectedColor = expectedColor, + expectedSize = expectedSize, + expectedQuantity = expectedQuantity + ) + } + + override suspend fun captureOrderConfirmation(): + PinduoduoScreenshotCapture? = + BuyerAccessibilityBridge.captureOrderConfirmationScreenshot() +} diff --git a/android-buyer/app/src/main/java/com/roubao/autopilot/pinduoduo/PinduoduoCandidateModels.kt b/android-buyer/app/src/main/java/com/roubao/autopilot/pinduoduo/PinduoduoCandidateModels.kt index 7b42d6b..2e44b00 100644 --- a/android-buyer/app/src/main/java/com/roubao/autopilot/pinduoduo/PinduoduoCandidateModels.kt +++ b/android-buyer/app/src/main/java/com/roubao/autopilot/pinduoduo/PinduoduoCandidateModels.kt @@ -70,6 +70,20 @@ object PinduoduoObservedTitlePolicy { .maxByOrNull(String::length) } +object PinduoduoStableProductIdentityPolicy { + fun signature(semanticTexts: List): String? { + val title = PinduoduoObservedTitlePolicy.select(semanticTexts) + ?: return null + val normalized = PinduoduoOrderConfirmationParser.normalize(title) + if (normalized.isBlank()) { + return null + } + return PinduoduoEvidenceHash.sha256( + "pdd-product-title-v1\u001f$normalized" + ) + } +} + object PinduoduoEvidenceHash { fun sha256(value: String): String = sha256(value.toByteArray(Charsets.UTF_8)) diff --git a/android-buyer/app/src/main/java/com/roubao/autopilot/pinduoduo/PinduoduoCheckoutOcr.kt b/android-buyer/app/src/main/java/com/roubao/autopilot/pinduoduo/PinduoduoCheckoutOcr.kt new file mode 100644 index 0000000..a65ded9 --- /dev/null +++ b/android-buyer/app/src/main/java/com/roubao/autopilot/pinduoduo/PinduoduoCheckoutOcr.kt @@ -0,0 +1,328 @@ +package com.roubao.autopilot.pinduoduo + +import android.graphics.BitmapFactory +import android.util.Log +import com.google.mlkit.vision.common.InputImage +import com.google.mlkit.vision.text.TextRecognition +import com.google.mlkit.vision.text.chinese.ChineseTextRecognizerOptions +import java.text.Normalizer +import java.util.concurrent.atomic.AtomicBoolean +import kotlin.coroutines.resume +import kotlinx.coroutines.suspendCancellableCoroutine + +data class PinduoduoOcrLine( + val text: String, + val left: Int, + val top: Int, + val right: Int, + val bottom: Int +) + +fun interface PinduoduoCheckoutOcrReader { + suspend fun read( + pngBytes: ByteArray, + authorizedTitle: String, + expectedColor: String, + expectedSize: String, + expectedQuantity: Int + ): PinduoduoOrderConfirmationEvidence? +} + +class MlKitPinduoduoCheckoutOcrReader : PinduoduoCheckoutOcrReader { + override suspend fun read( + pngBytes: ByteArray, + authorizedTitle: String, + expectedColor: String, + expectedSize: String, + expectedQuantity: Int + ): PinduoduoOrderConfirmationEvidence? { + val bitmap = BitmapFactory.decodeByteArray( + pngBytes, + 0, + pngBytes.size + ) ?: return null + val recognizer = TextRecognition.getClient( + ChineseTextRecognizerOptions.Builder().build() + ) + val closed = AtomicBoolean(false) + fun close() { + if (closed.compareAndSet(false, true)) { + recognizer.close() + bitmap.recycle() + } + } + return suspendCancellableCoroutine { continuation -> + continuation.invokeOnCancellation { close() } + recognizer.process(InputImage.fromBitmap(bitmap, 0)) + .addOnSuccessListener { result -> + val lines = result.textBlocks.flatMap { block -> + block.lines.mapNotNull { line -> + line.boundingBox?.let { bounds -> + PinduoduoOcrLine( + text = line.text, + left = bounds.left, + top = bounds.top, + right = bounds.right, + bottom = bounds.bottom + ) + } + } + } + val evaluation = PinduoduoCheckoutOcrPolicy.evaluate( + lines = lines, + authorizedTitle = authorizedTitle, + expectedColor = expectedColor, + expectedSize = expectedSize, + expectedQuantity = expectedQuantity + ) + if (evaluation.evidence == null) { + Log.w( + LOG_TAG, + "Checkout OCR rejected: ${evaluation.failureCode}" + ) + } + if (continuation.isActive) { + continuation.resume(evaluation.evidence) + } + close() + } + .addOnFailureListener { + if (continuation.isActive) { + continuation.resume(null) + } + close() + } + } + } + + private companion object { + const val LOG_TAG = "PinduoduoCheckoutOcr" + } +} + +data class PinduoduoCheckoutOcrEvaluation( + val evidence: PinduoduoOrderConfirmationEvidence?, + val failureCode: String? +) + +object PinduoduoCheckoutOcrPolicy { + fun verify( + lines: List, + authorizedTitle: String, + expectedColor: String, + expectedSize: String, + expectedQuantity: Int + ): PinduoduoOrderConfirmationEvidence? = + evaluate( + lines, + authorizedTitle, + expectedColor, + expectedSize, + expectedQuantity + ).evidence + + fun evaluate( + lines: List, + authorizedTitle: String, + expectedColor: String, + expectedSize: String, + expectedQuantity: Int + ): PinduoduoCheckoutOcrEvaluation { + fun rejected(code: String) = + PinduoduoCheckoutOcrEvaluation(null, code) + + if (lines.isEmpty() || lines.size > MAX_LINES) { + return rejected("LINE_COUNT") + } + if (expectedQuantity !in 1..99) { + return rejected("EXPECTED_QUANTITY") + } + + val ordered = lines.sortedWith( + compareBy { it.top }.thenBy { it.left } + ) + val joined = ordered.joinToString(separator = "") { it.text } + if ( + !approximatelyContains( + normalize(joined), + normalize(authorizedTitle) + ) + ) { + return rejected("TITLE") + } + + val firstDiscountTop = ordered + .filter { line -> + val text = normalize(line.text) + text.startsWith("店铺优惠") || + text.startsWith("平台和限时优惠") + } + .minOfOrNull(PinduoduoOcrLine::top) + ?: return rejected("DISCOUNT_BOUNDARY") + val colorLine = ordered.singleOrNull { line -> + line.top < firstDiscountTop && + PinduoduoSpecificationTargetMatcher.matches( + kind = PinduoduoSpecificationGroupKind.COLOR, + expected = expectedColor, + observed = line.text + ) + } ?: return rejected("COLOR") + val sizeLine = ordered.singleOrNull { line -> + line.top < firstDiscountTop && + PinduoduoSpecificationTargetMatcher.matches( + kind = PinduoduoSpecificationGroupKind.SIZE, + expected = expectedSize, + observed = line.text + ) + } ?: return rejected("SIZE") + val color = valueAfterLabel(colorLine.text) + ?: colorLine.text.trim().takeIf(String::isNotEmpty) + ?: return rejected("COLOR_VALUE") + val size = valueAfterLabel(sizeLine.text) + ?: valueFromExpectedToken(sizeLine.text, expectedSize) + ?: return rejected("SIZE_VALUE") + val skuBottom = maxOf(colorLine.bottom, sizeLine.bottom) + val quantityText = expectedQuantity.toString() + val quantityMatches = ordered.filter { line -> + quantityLineMatches(line.text, quantityText) && + line.top > skuBottom && + line.bottom < firstDiscountTop + } + if (quantityMatches.size != 1) { + return rejected("QUANTITY_${quantityMatches.size}") + } + + val totals = ordered.mapNotNull { parsePayableTotal(it.text) } + .distinct() + val total = totals.singleOrNull() + ?: return rejected("TOTAL_${totals.size}") + return PinduoduoCheckoutOcrEvaluation( + evidence = PinduoduoOrderConfirmationEvidence( + observedTitle = authorizedTitle, + selectedSummary = "$color $size", + quantity = expectedQuantity, + totalPriceCents = total + ), + failureCode = null + ) + } + + private fun valueAfterLabel(value: String): String? { + val separator = value.indexOfAny(charArrayOf(':', ':')) + if (separator < 0 || separator == value.lastIndex) return null + return value.substring(separator + 1).trim().takeIf(String::isNotEmpty) + } + + private fun valueFromExpectedToken( + value: String, + expected: String + ): String? { + val index = value.indexOf(expected, ignoreCase = true) + if (index < 0) return null + return value.substring(index).trim().takeIf(String::isNotEmpty) + } + + private fun parsePayableTotal(value: String): Long? { + val normalized = Normalizer.normalize( + value.trim(), + Normalizer.Form.NFKC + ).replace(Regex("\\s+"), "") + val match = PAYABLE_PATTERN.find(normalized) ?: return null + val whole = match.groupValues[1].toLongOrNull() ?: return null + val fraction = match.groupValues[2].padEnd(2, '0') + .ifEmpty { "00" } + .toLongOrNull() ?: return null + return (whole * 100L + fraction) + .takeIf { it in 1..MAX_TOTAL_CENTS } + } + + private fun quantityLineMatches( + value: String, + expected: String + ): Boolean { + val normalized = Normalizer.normalize( + value.trim(), + Normalizer.Form.NFKC + ).replace(Regex("\\s+"), "") + return normalized == expected || normalized == "$expected+" + } + + private fun normalize(value: String): String = + PinduoduoOrderConfirmationParser.normalize(value) + + internal fun approximatelyContains( + observed: String, + expected: String + ): Boolean { + if (expected.isBlank() || observed.isBlank()) return false + if (observed.contains(expected)) return true + if ( + expected.length >= MIN_VISIBLE_TITLE_PREFIX && + observed.contains(expected.take(MIN_VISIBLE_TITLE_PREFIX)) + ) { + return true + } + val maxDistance = maxOf(2, expected.length / 8) + val minLength = maxOf(1, expected.length - maxDistance) + val maxLength = minOf(observed.length, expected.length + maxDistance) + for (length in minLength..maxLength) { + for (start in 0..observed.length - length) { + if ( + editDistanceAtMost( + observed.substring(start, start + length), + expected, + maxDistance + ) + ) { + return true + } + } + } + val anchorLength = 3 + val step = maxOf(anchorLength, expected.length / 6) + val anchors = buildList { + var start = 0 + while (start + anchorLength <= expected.length) { + add(expected.substring(start, start + anchorLength)) + start += step + } + add(expected.takeLast(anchorLength)) + }.distinct() + val matchedAnchors = anchors.count(observed::contains) + return anchors.size >= 4 && + matchedAnchors * 5 >= anchors.size * 3 + } + + private fun editDistanceAtMost( + left: String, + right: String, + limit: Int + ): Boolean { + if (kotlin.math.abs(left.length - right.length) > limit) return false + var previous = IntArray(right.length + 1) { it } + for (leftIndex in left.indices) { + val current = IntArray(right.length + 1) + current[0] = leftIndex + 1 + var rowMinimum = current[0] + for (rightIndex in right.indices) { + val substitution = previous[rightIndex] + + if (left[leftIndex] == right[rightIndex]) 0 else 1 + current[rightIndex + 1] = minOf( + current[rightIndex] + 1, + previous[rightIndex + 1] + 1, + substitution + ) + rowMinimum = minOf(rowMinimum, current[rightIndex + 1]) + } + if (rowMinimum > limit) return false + previous = current + } + return previous[right.length] <= limit + } + + private val PAYABLE_PATTERN = + Regex("""实付款[::]?[¥¥](\d{1,7})(?:\.(\d{1,2}))?""") + private const val MIN_VISIBLE_TITLE_PREFIX = 16 + private const val MAX_LINES = 220 + private const val MAX_TOTAL_CENTS = 100_000_000L +} diff --git a/android-buyer/app/src/main/java/com/roubao/autopilot/pinduoduo/PinduoduoImageSearchAutomation.kt b/android-buyer/app/src/main/java/com/roubao/autopilot/pinduoduo/PinduoduoImageSearchAutomation.kt index 56f2ec4..e37c933 100644 --- a/android-buyer/app/src/main/java/com/roubao/autopilot/pinduoduo/PinduoduoImageSearchAutomation.kt +++ b/android-buyer/app/src/main/java/com/roubao/autopilot/pinduoduo/PinduoduoImageSearchAutomation.kt @@ -14,7 +14,10 @@ interface PinduoduoImageSearchDriver { suspend fun openApp(): Boolean suspend fun snapshot(): PinduoduoUiSnapshot suspend fun openImageSearch(): Boolean + suspend fun dismissImageSearchRetryDialog(): Boolean suspend fun selectPreparedImage(): Boolean + suspend fun closeSpecifications(): Boolean + suspend fun returnFromOrderConfirmation(): Boolean suspend fun returnFromCandidate(): Boolean suspend fun returnFromImageResults(): Boolean } @@ -54,8 +57,11 @@ class PinduoduoImageSearchAutomation( PinduoduoPage.SEARCH_RESULTS, PinduoduoPage.SEARCH_RESULTS_OTHER_QUERY, PinduoduoPage.IMAGE_SEARCH, + PinduoduoPage.IMAGE_SEARCH_RETRY_DIALOG, PinduoduoPage.IMAGE_SEARCH_RESULTS, - PinduoduoPage.PRODUCT_DETAIL + PinduoduoPage.PRODUCT_DETAIL, + PinduoduoPage.SPECIFICATION_PANEL, + PinduoduoPage.ORDER_CONFIRMATION ) } ?: AutomationResult.Success } @@ -63,7 +69,13 @@ class PinduoduoImageSearchAutomation( private suspend fun openImageSearch(): AutomationResult { repeat(MAX_RECOVERY_ACTIONS) { val snapshot = driver.snapshot() - safetyResult(snapshot)?.let { return it } + val recoverableOrderConfirmation = + snapshot.page == PinduoduoPage.ORDER_CONFIRMATION && + snapshot.safetyStopReason == + SafetyStopReason.PAYMENT_BOUNDARY + if (!recoverableOrderConfirmation) { + safetyResult(snapshot)?.let { return it } + } when (snapshot.page) { PinduoduoPage.IMAGE_SEARCH -> { if (driver.selectPreparedImage()) { @@ -85,11 +97,28 @@ class PinduoduoImageSearchAutomation( ) } } + PinduoduoPage.IMAGE_SEARCH_RETRY_DIALOG -> { + if (!driver.dismissImageSearchRetryDialog()) { + return retryableFailure() + } + delay(pollIntervalMillis) + return@repeat + } PinduoduoPage.PRODUCT_DETAIL -> { if (!driver.returnFromCandidate()) { return retryableFailure() } } + PinduoduoPage.SPECIFICATION_PANEL -> { + if (!driver.closeSpecifications()) { + return retryableFailure() + } + } + PinduoduoPage.ORDER_CONFIRMATION -> { + if (!driver.returnFromOrderConfirmation()) { + return retryableFailure() + } + } PinduoduoPage.IMAGE_SEARCH_RESULTS -> { if (!driver.returnFromImageResults()) { return retryableFailure() @@ -106,9 +135,12 @@ class PinduoduoImageSearchAutomation( } awaitPage { page -> page == PinduoduoPage.IMAGE_SEARCH || + page == PinduoduoPage.IMAGE_SEARCH_RETRY_DIALOG || page == PinduoduoPage.IMAGE_SEARCH_RESULTS || page == PinduoduoPage.SEARCH_RESULTS || - page == PinduoduoPage.SEARCH_RESULTS_OTHER_QUERY + page == PinduoduoPage.SEARCH_RESULTS_OTHER_QUERY || + page == PinduoduoPage.PRODUCT_DETAIL || + page == PinduoduoPage.SPECIFICATION_PANEL }?.let { return it } } return AutomationResult.Blocked(SafetyStopReason.UNKNOWN_PAGE) @@ -133,8 +165,16 @@ class PinduoduoImageSearchAutomation( var stableUnknownObservations = 0 while (true) { val snapshot = driver.snapshot() - safetyResult(snapshot)?.let { return it } - if (expected(snapshot.page)) { + val pageExpected = expected(snapshot.page) + val recoverableOrderConfirmation = + pageExpected && + snapshot.page == PinduoduoPage.ORDER_CONFIRMATION && + snapshot.safetyStopReason == + SafetyStopReason.PAYMENT_BOUNDARY + if (!recoverableOrderConfirmation) { + safetyResult(snapshot)?.let { return it } + } + if (pageExpected) { return null } stableUnknownObservations = if ( @@ -163,7 +203,7 @@ class PinduoduoImageSearchAutomation( ) private companion object { - const val MAX_RECOVERY_ACTIONS = 4 + const val MAX_RECOVERY_ACTIONS = 5 } } diff --git a/android-buyer/app/src/main/java/com/roubao/autopilot/pinduoduo/PinduoduoOrderDryRunAutomation.kt b/android-buyer/app/src/main/java/com/roubao/autopilot/pinduoduo/PinduoduoOrderDryRunAutomation.kt new file mode 100644 index 0000000..24351bf --- /dev/null +++ b/android-buyer/app/src/main/java/com/roubao/autopilot/pinduoduo/PinduoduoOrderDryRunAutomation.kt @@ -0,0 +1,392 @@ +package com.roubao.autopilot.pinduoduo + +import com.roubao.autopilot.readiness.PINDUODUO_PACKAGE +import kotlinx.coroutines.delay + +interface PinduoduoOrderDryRunDriver { + suspend fun snapshot(): PinduoduoUiSnapshot + suspend fun candidateCards(limit: Int): List + suspend fun openCandidate(signature: String): Boolean + suspend fun readCandidateDetail(): PinduoduoCandidateDetailEvidence? + suspend fun openSpecifications(): Boolean + suspend fun readSpecifications(): PinduoduoSpecificationEvidence? + suspend fun selectSpecificationOption( + kind: PinduoduoSpecificationGroupKind, + optionText: String + ): Boolean + suspend fun scrollSpecifications(): Boolean + suspend fun closeSpecifications(): Boolean + suspend fun returnToResults(): Boolean + suspend fun scrollResults(): Boolean + suspend fun scrollResultsBackward(): Boolean + suspend fun readQuantity(): Int? + suspend fun changeQuantity(increase: Boolean): Boolean + suspend fun confirmSpecifications(): Boolean + suspend fun readOrderConfirmation(): PinduoduoOrderConfirmationEvidence? + suspend fun captureOrderConfirmation(): PinduoduoScreenshotCapture? +} + +class PinduoduoOrderDryRunException(message: String) : + IllegalStateException(message) + +class PinduoduoOrderDryRunAutomation( + private val driver: PinduoduoOrderDryRunDriver, + private val authorizedTitle: String, + private val authorizedCardSignature: String, + private val authorizedDetailSignature: String, + private val authorizedPriceText: String, + private val specificationTarget: PinduoduoSpecificationTarget, + private val quantity: Int, + private val maxBudgetCents: Long?, + private val pollIntervalMillis: Long = 250L, + private val pagePollLimit: Int = 24 +) { + init { + require(authorizedTitle.isNotBlank()) + require(SHA256_PATTERN.matches(authorizedCardSignature)) + require(SHA256_PATTERN.matches(authorizedDetailSignature)) + require(quantity in 1..99) + require(pollIntervalMillis > 0L) + require(pagePollLimit > 0) + } + + suspend fun run(): PinduoduoOrderDryRunResult { + awaitPage(PinduoduoPage.IMAGE_SEARCH_RESULTS) + val matches = scanCandidates() + if (matches.size != 1) { + throw PinduoduoOrderDryRunException( + if (matches.isEmpty()) { + "没有唯一匹配后台授权的商品" + } else { + "多个商品同时匹配后台授权,已停止" + } + ) + } + val candidate = matches.single() + relocate(candidate.cardSignature) + val detail = driver.readCandidateDetail() + ?: stop("无法读取重新定位后的商品") + requireCandidateIdentity( + cardSignature = candidate.cardSignature, + detail = detail + ) + val selected = enterAndVerifySpecifications() + ?: stop("重新定位后的规格或价格不再匹配") + if ( + selected.selectedColorText != candidate.selectedColorText || + selected.selectedSizeText != candidate.selectedSizeText || + selected.unitPriceCents != candidate.unitPriceCents + ) { + stop("重新定位前后的规格或价格发生变化") + } + setExactQuantity() + if (!driver.confirmSpecifications()) { + stop("规格弹层中的唯一确定按钮不可用") + } + awaitPage(PinduoduoPage.ORDER_CONFIRMATION) + val confirmation = driver.readOrderConfirmation() + ?: stop("订单确认页证据不完整") + verifyConfirmation(candidate, confirmation) + val screenshot = driver.captureOrderConfirmation() + ?: stop("无法保存订单确认页截图") + return PinduoduoOrderDryRunResult(candidate, confirmation, screenshot) + } + + private suspend fun scanCandidates(): List { + val attempted = linkedSetOf() + val matches = mutableListOf() + var scrolls = 0 + while (attempted.size < MAX_CANDIDATES) { + requireResultsPage() + val card = driver.candidateCards(MAX_CANDIDATES * 2) + .firstOrNull { it.signature !in attempted } + if (card == null) { + if (scrolls >= MAX_RESULT_SCROLLS || !driver.scrollResults()) { + break + } + scrolls += 1 + delay(pollIntervalMillis) + continue + } + attempted += card.signature + if (!driver.openCandidate(card.signature)) { + stop("候选商品打开失败") + } + awaitPage(PinduoduoPage.PRODUCT_DETAIL) + val detail = driver.readCandidateDetail() + ?: stop("候选商品详情证据缺失") + val identityMatches = + ( + card.signature == authorizedCardSignature || + detail.signature == authorizedDetailSignature + ) && + normalizedTitle(detail.observedTitle.orEmpty()) == + normalizedTitle(authorizedTitle) + if (identityMatches) { + enterAndVerifySpecifications()?.let { selected -> + matches += PinduoduoAuthorizedCandidate( + cardSignature = card.signature, + detailSignature = detail.signature, + observedTitle = requireNotNull(detail.observedTitle), + selectedColorText = selected.selectedColorText, + selectedSizeText = selected.selectedSizeText, + unitPriceCents = selected.unitPriceCents + ) + } + if (driver.snapshot().page == PinduoduoPage.SPECIFICATION_PANEL) { + if (!driver.closeSpecifications()) { + stop("无法安全关闭规格弹层") + } + awaitPage(PinduoduoPage.PRODUCT_DETAIL) + } + } + if (!driver.returnToResults()) { + stop("无法安全返回图片搜索结果") + } + awaitPage(PinduoduoPage.IMAGE_SEARCH_RESULTS) + } + return matches + } + + private suspend fun enterAndVerifySpecifications(): SelectedSpecification? { + if (!driver.openSpecifications()) { + return null + } + awaitPage(PinduoduoPage.SPECIFICATION_PANEL) + val observations = mutableListOf() + var scrolls = 0 + val selections = linkedMapOf() + for ((kind, expected) in listOf( + PinduoduoSpecificationGroupKind.COLOR to specificationTarget.color, + PinduoduoSpecificationGroupKind.SIZE to specificationTarget.size + )) { + var selected = false + while (!selected) { + val evidence = driver.readSpecifications() ?: return null + observations += evidence + when ( + val resolution = + PinduoduoSpecificationSelectionPolicy.resolve( + evidence, + kind, + expected + ) + ) { + is PinduoduoSpecificationOptionResolution.Ready -> { + if (!resolution.alreadySelected) { + if ( + !driver.selectSpecificationOption( + kind, + resolution.optionText + ) + ) { + return null + } + delay(pollIntervalMillis) + val verified = driver.readSpecifications() + ?: return null + observations += verified + val after = + PinduoduoSpecificationSelectionPolicy.resolve( + verified, + kind, + expected + ) as? PinduoduoSpecificationOptionResolution.Ready + if (after?.alreadySelected != true) { + return null + } + } + selections[kind] = resolution.optionText + selected = true + } + PinduoduoSpecificationOptionResolution.Absent -> { + if ( + scrolls >= MAX_SPECIFICATION_SCROLLS || + !driver.scrollSpecifications() + ) { + return null + } + scrolls += 1 + delay(pollIntervalMillis) + } + PinduoduoSpecificationOptionResolution.Ambiguous, + is PinduoduoSpecificationOptionResolution.Disabled -> + return null + } + } + } + val finalEvidence = driver.readSpecifications()?.also(observations::add) + ?: return null + val merged = PinduoduoSpecificationParser.merge(observations) + ?: return null + val price = merged.price ?: finalEvidence.price ?: return null + if ( + merged.priceAmbiguous || + !PinduoduoAuthorizedPricePolicy.permits( + price.cents, + authorizedPriceText, + quantity, + maxBudgetCents + ) + ) { + return null + } + val color = requireNotNull(selections[PinduoduoSpecificationGroupKind.COLOR]) + val size = requireNotNull(selections[PinduoduoSpecificationGroupKind.SIZE]) + val selectedSummary = finalEvidence.selectedSummary + ?: merged.selectedSummary + ?: return null + if ( + !normalizedTitle(selectedSummary).contains(normalizedTitle(color)) || + !normalizedTitle(selectedSummary).contains(normalizedTitle(size)) + ) { + return null + } + return SelectedSpecification(color, size, price.cents) + } + + private suspend fun setExactQuantity() { + var current = driver.readQuantity() ?: stop("数量控件不可访问") + var actions = 0 + while (current != quantity) { + if (actions >= MAX_QUANTITY_ACTIONS) { + stop("数量调整超过安全上限") + } + val increase = current < quantity + if (!driver.changeQuantity(increase)) { + stop("数量控件不可唯一操作") + } + actions += 1 + delay(pollIntervalMillis) + val observed = driver.readQuantity() ?: stop("无法复核当前数量") + val expected = current + if (increase) 1 else -1 + if (observed != expected) { + stop("数量点击后状态未按一步变化") + } + current = observed + } + } + + private suspend fun relocate(cardSignature: String) { + repeat(MAX_RESULT_SCROLLS + 1) { attempt -> + requireResultsPage() + if ( + driver.candidateCards(MAX_CANDIDATES * 2) + .count { it.signature == cardSignature } == 1 + ) { + if (!driver.openCandidate(cardSignature)) { + stop("唯一候选重新打开失败") + } + awaitPage(PinduoduoPage.PRODUCT_DETAIL) + return + } + if ( + attempt >= MAX_RESULT_SCROLLS || + !driver.scrollResultsBackward() + ) { + stop("无法重新定位唯一授权商品") + } + delay(pollIntervalMillis) + } + } + + private fun requireCandidateIdentity( + cardSignature: String, + detail: PinduoduoCandidateDetailEvidence + ) { + if ( + ( + cardSignature != authorizedCardSignature && + detail.signature != authorizedDetailSignature + ) || + normalizedTitle(detail.observedTitle.orEmpty()) != + normalizedTitle(authorizedTitle) + ) { + stop("重新定位后的商品身份不一致") + } + } + + private fun verifyConfirmation( + candidate: PinduoduoAuthorizedCandidate, + confirmation: PinduoduoOrderConfirmationEvidence + ) { + if ( + normalizedTitle(confirmation.observedTitle) != + normalizedTitle(candidate.observedTitle) + ) { + stop("订单确认页商品标题不一致") + } + val summary = normalizedTitle(confirmation.selectedSummary) + if ( + !summary.contains(normalizedTitle(candidate.selectedColorText)) || + !summary.contains(normalizedTitle(candidate.selectedSizeText)) + ) { + stop("订单确认页规格摘要不一致") + } + val expectedTotal = Math.multiplyExact( + candidate.unitPriceCents, + quantity.toLong() + ) + if ( + confirmation.quantity != quantity || + confirmation.totalPriceCents != expectedTotal || + ( + maxBudgetCents != null && + confirmation.totalPriceCents > maxBudgetCents + ) + ) { + stop("订单确认页数量或金额不一致") + } + } + + private suspend fun requireResultsPage() { + awaitPage(PinduoduoPage.IMAGE_SEARCH_RESULTS) + } + + private suspend fun awaitPage(expected: PinduoduoPage) { + repeat(pagePollLimit) { + val snapshot = driver.snapshot() + val paymentBoundaryExpected = + expected == PinduoduoPage.ORDER_CONFIRMATION && + snapshot.page == PinduoduoPage.ORDER_CONFIRMATION && + snapshot.safetyStopReason == + com.roubao.autopilot.workflow.SafetyStopReason.PAYMENT_BOUNDARY + if ( + snapshot.foregroundPackage == PINDUODUO_PACKAGE && + snapshot.page == expected && + (snapshot.safetyStopReason == null || paymentBoundaryExpected) + ) { + return + } + if ( + snapshot.foregroundPackage == PINDUODUO_PACKAGE && + snapshot.safetyStopReason != null && + !paymentBoundaryExpected + ) { + stop("拼多多出现登录、风控或交易边界") + } + delay(pollIntervalMillis) + } + stop("拼多多页面在安全时限内未就绪") + } + + private fun normalizedTitle(value: String): String = + PinduoduoOrderConfirmationParser.normalize(value) + + private fun stop(message: String): Nothing = + throw PinduoduoOrderDryRunException(message) + + private data class SelectedSpecification( + val selectedColorText: String, + val selectedSizeText: String, + val unitPriceCents: Long + ) + + private companion object { + const val MAX_CANDIDATES = 10 + const val MAX_RESULT_SCROLLS = 2 + const val MAX_SPECIFICATION_SCROLLS = 2 + const val MAX_QUANTITY_ACTIONS = 98 + val SHA256_PATTERN = Regex("^[0-9a-f]{64}$") + } +} diff --git a/android-buyer/app/src/main/java/com/roubao/autopilot/pinduoduo/PinduoduoOrderDryRunModels.kt b/android-buyer/app/src/main/java/com/roubao/autopilot/pinduoduo/PinduoduoOrderDryRunModels.kt new file mode 100644 index 0000000..2b7b33d --- /dev/null +++ b/android-buyer/app/src/main/java/com/roubao/autopilot/pinduoduo/PinduoduoOrderDryRunModels.kt @@ -0,0 +1,171 @@ +package com.roubao.autopilot.pinduoduo + +import java.text.Normalizer + +data class PinduoduoOrderConfirmationEvidence( + val observedTitle: String, + val selectedSummary: String, + val quantity: Int, + val totalPriceCents: Long +) + +data class PinduoduoAuthorizedCandidate( + val cardSignature: String, + val detailSignature: String, + val observedTitle: String, + val selectedColorText: String, + val selectedSizeText: String, + val unitPriceCents: Long +) + +data class PinduoduoOrderDryRunResult( + val candidate: PinduoduoAuthorizedCandidate, + val confirmation: PinduoduoOrderConfirmationEvidence, + val screenshot: PinduoduoScreenshotCapture +) + +object PinduoduoOrderConfirmationParser { + fun parse(elements: Collection): + PinduoduoOrderConfirmationEvidence? { + val visible = elements.asSequence() + .filter { it.visibleToUser } + .sortedWith( + compareBy { it.boundsTop } + .thenBy { it.boundsLeft } + ) + .mapNotNull(::semanticText) + .distinct() + .take(MAX_ELEMENTS + 1) + .toList() + if (visible.isEmpty() || visible.size > MAX_ELEMENTS) { + return null + } + val title = PinduoduoObservedTitlePolicy.select(visible) ?: return null + val selectedSummary = visible.singleOrNull { text -> + val normalized = normalize(text) + normalized.startsWith("已选") || + normalized.startsWith("规格") + } ?: parseSplitSpecificationSummary(visible) + ?: return null + val quantities = visible.mapNotNull(::parseQuantity).distinct() + val quantity = quantities.singleOrNull() ?: return null + val labeledTotals = visible.mapNotNull(::parseLabeledTotal).distinct() + val total = labeledTotals.singleOrNull() ?: return null + return PinduoduoOrderConfirmationEvidence( + observedTitle = title, + selectedSummary = selectedSummary, + quantity = quantity, + totalPriceCents = total + ) + } + + private fun semanticText(element: PinduoduoUiElement): String? = + sequenceOf(element.text, element.contentDescription) + .filterNotNull() + .map(String::trim) + .firstOrNull(String::isNotEmpty) + + private fun parseQuantity(value: String): Int? { + val normalized = normalize(value) + QUANTITY_PATTERN.matchEntire(normalized)?.let { match -> + return match.groupValues[1].toIntOrNull() + ?.takeIf { it in 1..99 } + } + val current = CURRENT_QUANTITY_PATTERN.matchEntire(normalized) + ?: return null + val leading = current.groupValues[1].toIntOrNull() ?: return null + val labeled = current.groupValues[2].toIntOrNull() ?: return null + return leading.takeIf { it == labeled && it in 1..99 } + } + + private fun parseLabeledTotal(value: String): Long? { + val normalized = Normalizer.normalize( + value.trim(), + Normalizer.Form.NFKC + ).lowercase().replace(Regex("\\s+"), "") + val match = TOTAL_PATTERN.matchEntire(normalized) ?: return null + val whole = match.groupValues[1].toLongOrNull() ?: return null + val fraction = match.groupValues[2].padEnd(2, '0') + .ifEmpty { "00" } + .toLongOrNull() ?: return null + return (whole * 100L + fraction).takeIf { it in 1..MAX_TOTAL_CENTS } + } + + private fun parseSplitSpecificationSummary( + visible: List + ): String? { + val colors = visible.mapNotNull { text -> + COLOR_SUMMARY_PATTERN.matchEntire(text.trim()) + ?.groupValues + ?.get(1) + ?.trim() + ?.takeIf(String::isNotEmpty) + }.distinct() + val sizes = visible.mapNotNull { text -> + SIZE_SUMMARY_PATTERN.matchEntire(text.trim()) + ?.groupValues + ?.get(1) + ?.trim() + ?.takeIf(String::isNotEmpty) + }.distinct() + val color = colors.singleOrNull() ?: return null + val size = sizes.singleOrNull() ?: return null + return "$color $size" + } + + internal fun normalize(value: String): String = + Normalizer.normalize(value.trim(), Normalizer.Form.NFKC) + .lowercase() + .replace(Regex("[\\s\\p{Punct}]+"), "") + + private val QUANTITY_PATTERN = Regex("""^(?:x|×)(\d{1,2})$""") + private val CURRENT_QUANTITY_PATTERN = + Regex("""^(\d{1,2})当前数量为(\d{1,2})件$""") + private val TOTAL_PATTERN = Regex( + """^(?:商品金额|商品总额|应付|合计|实付款)[::]?[¥¥](\d{1,7})(?:\.(\d{1,2}))?(?:已享.*)?$""" + ) + private val COLOR_SUMMARY_PATTERN = + Regex("""^(?:颜色分类|颜色|颜色款式)[::]\s*(.+)$""") + private val SIZE_SUMMARY_PATTERN = + Regex("""^(?:尺码|尺寸)[::]\s*(.+)$""") + private const val MAX_ELEMENTS = 180 + private const val MAX_TOTAL_CENTS = 100_000_000L +} + +object PinduoduoAuthorizedPricePolicy { + fun parseCandidatePrice(value: String): Long? { + val normalized = Normalizer.normalize( + value.trim(), + Normalizer.Form.NFKC + ).replace(Regex("\\s+"), "") + val match = PRICE_PATTERN.matchEntire(normalized) ?: return null + val whole = match.groupValues[1].toLongOrNull() ?: return null + val fraction = match.groupValues[2].padEnd(2, '0') + .ifEmpty { "00" } + .toLongOrNull() ?: return null + return (whole * 100L + fraction).takeIf { it in 1..MAX_PRICE_CENTS } + } + + fun permits( + currentUnitPriceCents: Long, + authorizedPriceText: String, + quantity: Int, + maxBudgetCents: Long? + ): Boolean { + if (currentUnitPriceCents <= 0 || quantity !in 1..99) { + return false + } + val total = runCatching { + Math.multiplyExact(currentUnitPriceCents, quantity.toLong()) + }.getOrNull() ?: return false + return if (maxBudgetCents == null) { + parseCandidatePrice(authorizedPriceText) == currentUnitPriceCents + } else { + total <= maxBudgetCents + } + } + + private val PRICE_PATTERN = + Regex("""^(?:首件)?[¥¥]?(\d{1,7})(?:\.(\d{1,2}))?$""") + private const val MAX_PRICE_CENTS = 100_000_000L +} diff --git a/android-buyer/app/src/main/java/com/roubao/autopilot/pinduoduo/PinduoduoPageClassifier.kt b/android-buyer/app/src/main/java/com/roubao/autopilot/pinduoduo/PinduoduoPageClassifier.kt index 0f27983..612af06 100644 --- a/android-buyer/app/src/main/java/com/roubao/autopilot/pinduoduo/PinduoduoPageClassifier.kt +++ b/android-buyer/app/src/main/java/com/roubao/autopilot/pinduoduo/PinduoduoPageClassifier.kt @@ -28,6 +28,7 @@ enum class PinduoduoPage { SEARCH_RESULTS, SEARCH_RESULTS_OTHER_QUERY, IMAGE_SEARCH, + IMAGE_SEARCH_RETRY_DIALOG, IMAGE_SEARCH_RESULTS, PRODUCT_DETAIL, SPECIFICATION_PANEL, @@ -42,6 +43,30 @@ data class PinduoduoUiSnapshot( val safetyStopReason: SafetyStopReason? ) +object PinduoduoCollapsedCheckoutPolicy { + fun matches(elements: Collection): Boolean { + val visible = elements.filter { it.visibleToUser && it.enabled } + val hasTopBack = visible.any { element -> + element.contentDescription == "返回" && + element.boundsTop < MAX_BACK_TOP + } + val fullCheckoutWebViewCount = visible.count { element -> + element.className.endsWith("WebView") && + element.boundsLeft <= MAX_WEBVIEW_LEFT && + element.boundsTop <= MAX_WEBVIEW_TOP && + element.boundsRight >= MIN_WEBVIEW_RIGHT && + element.boundsBottom >= MIN_WEBVIEW_BOTTOM + } + return hasTopBack && fullCheckoutWebViewCount in 1..2 + } + + private const val MAX_BACK_TOP = 320 + private const val MAX_WEBVIEW_LEFT = 8 + private const val MAX_WEBVIEW_TOP = 420 + private const val MIN_WEBVIEW_RIGHT = 1_000 + private const val MIN_WEBVIEW_BOTTOM = 2_000 +} + object PinduoduoPageClassifier { private val legacyResultSortMarkers = setOf("综合", "销量", "价格", "筛选") private val modernResultCategoryMarkers = setOf( @@ -62,7 +87,16 @@ object PinduoduoPageClassifier { "提交订单", "确认支付", "立即支付", - "收银台" + "收银台", + "微信支付", + "找好友支付", + "更多支付方式" + ) + private val paymentMethodMarkers = setOf( + "微信支付", + "先用后付", + "找好友支付", + "支付宝" ) fun classify( @@ -79,6 +113,9 @@ object PinduoduoPageClassifier { } val visibleElements = elements.filter { it.visibleToUser && it.enabled } + val hasCheckoutRoot = visibleElements.any { element -> + element.resourceId == "order_checkout" + } val visibleTexts = visibleElements.flatMap { element -> listOfNotNull(element.text, element.contentDescription) } @@ -88,7 +125,10 @@ object PinduoduoPageClassifier { LoginBlocker.RISK_CONTROL -> SafetyStopReason.RISK_CONTROL LoginBlocker.NONE, LoginBlocker.UNKNOWN -> { - if (visibleTexts.containsAny(paymentMarkers)) { + if ( + hasCheckoutRoot || + visibleTexts.containsAny(paymentMarkers) + ) { SafetyStopReason.PAYMENT_BOUNDARY } else { null @@ -141,6 +181,10 @@ object PinduoduoPageClassifier { text == "开启相机权限" || text.contains("即可进行自动识别") } + val hasImageSearchRetryDialog = + normalized.any { it == "请对准商品或码,保持手机稳定" } && + normalized.any { it == "取消" } && + normalized.any { it == "再试一次" } val hasImageResultHeader = normalized.any { it == "搜图片同款" } val hasExpectedQuery = visibleTexts.any { text -> matchesExpectedQuery(text, expectedQuery) @@ -181,7 +225,16 @@ object PinduoduoPageClassifier { specificationGroupCount >= 2 && specificationOptionCount >= 2 ) - val hasOrderConfirmation = normalized.any { it == "确认订单" } + val hasOrderConfirmation = + hasCheckoutRoot || + normalized.any { it == "确认订单" } || + ( + normalized.any { it.contains("收货地址") } && + paymentMethodMarkers.count { marker -> + normalized.any { it.contains(marker) } + } >= 2 && + normalized.any { it.startsWith("实付款¥") } + ) val hasOrderList = normalized.any { it == "我的订单" || it == "全部订单" } && setOf("待付款", "待发货", "待收货") @@ -193,6 +246,8 @@ object PinduoduoPageClassifier { hasSpecificationPanel -> PinduoduoPage.SPECIFICATION_PANEL hasImageResultHeader && legacySortControlCount >= 3 -> PinduoduoPage.IMAGE_SEARCH_RESULTS + hasImageSearchRetryDialog -> + PinduoduoPage.IMAGE_SEARCH_RETRY_DIALOG hasImageSearchPage -> PinduoduoPage.IMAGE_SEARCH hasExpectedQuery && hasResultSearchHeader && hasResultControls -> PinduoduoPage.SEARCH_RESULTS diff --git a/android-buyer/app/src/main/java/com/roubao/autopilot/procurement/OrderCommandIntegrity.kt b/android-buyer/app/src/main/java/com/roubao/autopilot/procurement/OrderCommandIntegrity.kt index 84c632d..5e78835 100644 --- a/android-buyer/app/src/main/java/com/roubao/autopilot/procurement/OrderCommandIntegrity.kt +++ b/android-buyer/app/src/main/java/com/roubao/autopilot/procurement/OrderCommandIntegrity.kt @@ -43,7 +43,8 @@ object OrderCommandIntegrity { ) { "后台订单命令指纹无效" } require( command.authorizationStatus == "DELIVERED" || - command.authorizationStatus == "ACKNOWLEDGED" + command.authorizationStatus == "ACKNOWLEDGED" || + command.authorizationStatus == "EXECUTING" ) { "后台订单命令状态无效" } require(sha256(command) == command.commandSha256) { "后台订单命令完整性校验失败" diff --git a/android-buyer/app/src/main/java/com/roubao/autopilot/procurement/OrderCommandSynchronization.kt b/android-buyer/app/src/main/java/com/roubao/autopilot/procurement/OrderCommandSynchronization.kt index 70011cf..204ff82 100644 --- a/android-buyer/app/src/main/java/com/roubao/autopilot/procurement/OrderCommandSynchronization.kt +++ b/android-buyer/app/src/main/java/com/roubao/autopilot/procurement/OrderCommandSynchronization.kt @@ -18,7 +18,9 @@ object OrderCommandSynchronization { val delivered = pull() ?: return null OrderCommandIntegrity.validate(delivered, task, execution) command = delivered.copy( - acknowledgementIdempotencyKey = newIdempotencyKey() + acknowledgementIdempotencyKey = newIdempotencyKey(), + acknowledged = + delivered.authorizationStatus == "EXECUTING" ) persist(command) } else { diff --git a/android-buyer/app/src/main/java/com/roubao/autopilot/procurement/OrderDryRunCoordinator.kt b/android-buyer/app/src/main/java/com/roubao/autopilot/procurement/OrderDryRunCoordinator.kt new file mode 100644 index 0000000..6f33229 --- /dev/null +++ b/android-buyer/app/src/main/java/com/roubao/autopilot/procurement/OrderDryRunCoordinator.kt @@ -0,0 +1,97 @@ +package com.roubao.autopilot.procurement + +import android.content.Context +import com.roubao.autopilot.pinduoduo.AndroidPinduoduoImageSearchDriver +import com.roubao.autopilot.pinduoduo.AndroidPinduoduoOrderDryRunDriver +import com.roubao.autopilot.pinduoduo.PinduoduoEvidenceHash +import com.roubao.autopilot.pinduoduo.PinduoduoImageSearchAssetStore +import com.roubao.autopilot.pinduoduo.PinduoduoImageSearchAutomation +import com.roubao.autopilot.pinduoduo.PinduoduoImageSearchWorkflow +import com.roubao.autopilot.pinduoduo.PinduoduoOrderDryRunAutomation +import com.roubao.autopilot.pinduoduo.PinduoduoSpecificationTarget +import com.roubao.autopilot.vlm.SkuConstraintKind +import com.roubao.autopilot.vlm.SkuHardConstraintExtractor +import com.roubao.autopilot.workflow.WorkflowRunner +import com.roubao.autopilot.workflow.WorkflowState + +class OrderDryRunCoordinator( + context: Context, + private val repository: ProcurementRepository +) { + private val appContext = context.applicationContext + + suspend fun runOnce(): Boolean { + val context = repository.prepareOrderDryRun() ?: return false + val constraints = SkuHardConstraintExtractor.extract( + context.command.originalSku + ) + if (!constraints.readyForAutomaticMatching) { + throw IllegalStateException("SKU 的颜色或尺码无法唯一解析") + } + val values = constraints.constraints.associate { it.kind to it.expected } + val target = PinduoduoSpecificationTarget( + color = requireNotNull(values[SkuConstraintKind.COLOR]), + size = requireNotNull(values[SkuConstraintKind.SIZE]) + ) + val assetStore = PinduoduoImageSearchAssetStore(appContext) + val prepared = assetStore.prepare( + context.referenceImageBytes, + context.task.toProbeTask(context.referenceImage).referenceImage + ) ?: throw IllegalStateException("无法准备拼多多参考图片") + try { + val searchRunner = WorkflowRunner( + PinduoduoImageSearchAutomation( + AndroidPinduoduoImageSearchDriver( + context = appContext, + assetStore = assetStore, + preparedImage = prepared + ) + ) + ) + val searchReport = searchRunner.run( + PinduoduoImageSearchWorkflow.steps() + ) + if (searchReport.state != WorkflowState.SUCCEEDED) { + throw IllegalStateException("拼多多图片搜索未到达可信结果页") + } + val result = PinduoduoOrderDryRunAutomation( + driver = AndroidPinduoduoOrderDryRunDriver( + authorizedTitle = context.command.candidate.title, + expectedColor = target.color, + expectedSize = target.size, + expectedQuantity = context.command.quantity + ), + authorizedTitle = context.command.candidate.title, + authorizedCardSignature = + context.command.candidate.cardSignature, + authorizedDetailSignature = + context.command.candidate.detailSignature, + authorizedPriceText = context.command.candidate.priceText, + specificationTarget = target, + quantity = context.command.quantity, + maxBudgetCents = context.maxBudgetCents + ).run() + val screenshotBytes = result.screenshot.pngBytes + val queued = repository.queueOrderDryRunReady( + OrderDryRunReadyDraft( + cardSignature = result.candidate.cardSignature, + detailSignature = result.candidate.detailSignature, + observedTitle = result.confirmation.observedTitle, + selectedSku = context.command.originalSku, + quantity = result.confirmation.quantity, + unitPriceCents = result.candidate.unitPriceCents, + totalPriceCents = result.confirmation.totalPriceCents, + orderConfirmationPng = screenshotBytes, + evidenceSha256 = + PinduoduoEvidenceHash.sha256(screenshotBytes) + ) + ) + if (queued) { + repository.synchronizeNow() + } + return queued + } finally { + assetStore.delete(prepared) + } + } +} diff --git a/android-buyer/app/src/main/java/com/roubao/autopilot/procurement/ProcurementApiClient.kt b/android-buyer/app/src/main/java/com/roubao/autopilot/procurement/ProcurementApiClient.kt index ff54e76..02b22c8 100644 --- a/android-buyer/app/src/main/java/com/roubao/autopilot/procurement/ProcurementApiClient.kt +++ b/android-buyer/app/src/main/java/com/roubao/autopilot/procurement/ProcurementApiClient.kt @@ -26,7 +26,9 @@ data class DownloadedReferenceImage( ) data class ExecutionOutboxUploadResult( - val evidenceAssetId: String? = null + val evidenceAssetId: String? = null, + val evidenceSha256: String? = null, + val orderDryRun: RemoteOrderDryRun? = null ) interface ProcurementRemoteApi { @@ -100,6 +102,15 @@ interface ProcurementRemoteApi { idempotencyKey: String ): OrderCommandAcknowledgement + suspend fun startOrderDryRun( + session: ProcurementSession, + task: RemotePurchaseTask, + execution: RunningExecution, + claimToken: String, + command: PendingOrderCommand, + idempotencyKey: String + ): RemoteOrderDryRun + suspend fun uploadExecutionOutboxItem( session: ProcurementSession, task: RemotePurchaseTask, @@ -291,6 +302,9 @@ class ProcurementApiClient( ExecutionOutboxType.EVENTS -> "/api/v1/tasks/${task.id}/events" ExecutionOutboxType.EVIDENCE -> "/api/v1/tasks/${task.id}/evidence" ExecutionOutboxType.CANDIDATES -> "/api/v1/tasks/${task.id}/candidates" + ExecutionOutboxType.ORDER_DRY_RUN_READY -> + "/api/v1/tasks/${task.id}/order-dry-runs/" + + "${requireNotNull(taskOrderCommandId(item.payload))}/ready" ExecutionOutboxType.HUMAN_REVIEW -> "/api/v1/tasks/${task.id}/human-reviews" ExecutionOutboxType.COMPLETE -> "/api/v1/tasks/${task.id}/complete" @@ -309,7 +323,15 @@ class ProcurementApiClient( .header("X-Claim-Generation", task.claimGeneration.toString()) .post(bytes.toRequestBody(PNG_MEDIA_TYPE)) } else { - val payload = item.payload + val payload = if ( + item.type == ExecutionOutboxType.ORDER_DRY_RUN_READY + ) { + JSONObject(item.payload) + .apply { remove("command_id") } + .toString() + } else { + item.payload + } require(payload.toByteArray(Charsets.UTF_8).size <= MAX_JSON_BYTES) { "离线结果过大" } @@ -321,7 +343,18 @@ class ProcurementApiClient( json.getJSONObject("evidence").getString("id") } else { null - } + }, + evidenceSha256 = if (item.type == ExecutionOutboxType.EVIDENCE) { + json.getJSONObject("evidence").getString("sha256") + } else { + null + }, + orderDryRun = + if (item.type == ExecutionOutboxType.ORDER_DRY_RUN_READY) { + parseOrderDryRun(json.getJSONObject("dry_run")) + } else { + null + } ) } @@ -472,6 +505,33 @@ class ProcurementApiClient( ) } + override suspend fun startOrderDryRun( + session: ProcurementSession, + task: RemotePurchaseTask, + execution: RunningExecution, + claimToken: String, + command: PendingOrderCommand, + idempotencyKey: String + ): RemoteOrderDryRun { + val payload = JSONObject() + .put("device_id", session.deviceId) + .put("execution_id", execution.id) + .put("claim_generation", task.claimGeneration) + .put("command_id", command.id) + .put("command_sha256", command.commandSha256) + val json = executeJson( + authorizedRequest( + session, + "/api/v1/tasks/${task.id}/order-dry-runs/start" + ) + .header(CLAIM_TOKEN_HEADER, claimToken) + .header(IDEMPOTENCY_HEADER, idempotencyKey) + .post(payload.jsonBody()) + .build() + ) + return parseOrderDryRun(json.getJSONObject("dry_run")) + } + private suspend fun executeTransition( session: ProcurementSession, task: RemotePurchaseTask, @@ -630,6 +690,17 @@ class ProcurementApiClient( ) } + private fun parseOrderDryRun(json: JSONObject): RemoteOrderDryRun = + RemoteOrderDryRun( + id = json.getString("id"), + commandId = json.getString("command_id"), + commandSha256 = json.getString("command_sha256"), + status = json.getString("status") + ) + + private fun taskOrderCommandId(payload: String): String? = + runCatching { JSONObject(payload).getString("command_id") }.getOrNull() + private fun JSONObject.jsonBody() = toString().toRequestBody(JSON_MEDIA_TYPE) diff --git a/android-buyer/app/src/main/java/com/roubao/autopilot/procurement/ProcurementExecutionService.kt b/android-buyer/app/src/main/java/com/roubao/autopilot/procurement/ProcurementExecutionService.kt index 90a69c6..969cd4b 100644 --- a/android-buyer/app/src/main/java/com/roubao/autopilot/procurement/ProcurementExecutionService.kt +++ b/android-buyer/app/src/main/java/com/roubao/autopilot/procurement/ProcurementExecutionService.kt @@ -8,11 +8,13 @@ import android.app.Service import android.content.Context import android.content.Intent import android.os.IBinder +import android.util.Log import androidx.core.app.NotificationCompat import androidx.core.content.ContextCompat import com.roubao.autopilot.App import com.roubao.autopilot.MainActivity import com.roubao.autopilot.R +import com.roubao.autopilot.readiness.DeviceReadinessChecker import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job @@ -36,12 +38,30 @@ class ProcurementExecutionService : Service() { if (heartbeatJob?.isActive != true) { heartbeatJob = scope.launch { val repository = (application as App).procurementRepository + val readiness = DeviceReadinessChecker(applicationContext) + val orderDryRun = OrderDryRunCoordinator( + applicationContext, + repository + ) while (isActive) { val decision = repository.synchronizeRunning() if (decision == ExecutionSyncDecision.STOP) { stopSelf() break } + if ( + repository.shouldRunOrderDryRun && + readiness.snapshot().canStartProbe + ) { + runCatching { orderDryRun.runOnce() } + .onFailure { error -> + Log.w( + TAG, + "Authorized order dry-run stopped safely", + error + ) + } + } delay(HEARTBEAT_INTERVAL_MS) } } @@ -90,6 +110,7 @@ class ProcurementExecutionService : Service() { } companion object { + private const val TAG = "ProcurementExecution" private const val CHANNEL_ID = "procurement_execution" private const val NOTIFICATION_ID = 206 private const val HEARTBEAT_INTERVAL_MS = 30_000L diff --git a/android-buyer/app/src/main/java/com/roubao/autopilot/procurement/ProcurementModels.kt b/android-buyer/app/src/main/java/com/roubao/autopilot/procurement/ProcurementModels.kt index 7b44b79..382e5e9 100644 --- a/android-buyer/app/src/main/java/com/roubao/autopilot/procurement/ProcurementModels.kt +++ b/android-buyer/app/src/main/java/com/roubao/autopilot/procurement/ProcurementModels.kt @@ -11,6 +11,8 @@ enum class ProcurementPhase { RUNNING, WAITING_ADMIN_CONFIRMATION, ORDER_AUTHORIZED, + ORDER_DRY_RUN_RUNNING, + ORDER_DRY_RUN_READY, AUTHORIZATION_EXPIRED } @@ -87,7 +89,8 @@ data class PersistedProcurementState( val claim: ClaimContext? = null, val execution: RunningExecution? = null, val outbox: List = emptyList(), - val orderCommand: PendingOrderCommand? = null + val orderCommand: PendingOrderCommand? = null, + val orderDryRun: PendingOrderDryRun? = null ) data class OrderCommandCandidate( @@ -125,6 +128,59 @@ data class OrderCommandAcknowledgement( val replayed: Boolean ) +enum class OrderDryRunStatus { + INTENT_SAVED, + PREPARING, + READY_LOCAL, + READY +} + +data class PendingOrderDryRun( + val commandId: String, + val commandSha256: String, + val status: OrderDryRunStatus, + val startIdempotencyKey: String, + val readyIdempotencyKey: String, + val remoteId: String? = null, + val attemptCount: Int = 0, + val lastAttemptAtEpochMillis: Long? = null, + val cardSignature: String? = null, + val detailSignature: String? = null, + val observedTitle: String? = null, + val selectedSku: String? = null, + val quantity: Int? = null, + val unitPriceCents: Long? = null, + val totalPriceCents: Long? = null, + val evidenceSha256: String? = null +) + +data class RemoteOrderDryRun( + val id: String, + val commandId: String, + val commandSha256: String, + val status: String +) + +data class OrderDryRunReadyDraft( + val cardSignature: String, + val detailSignature: String, + val observedTitle: String, + val selectedSku: String, + val quantity: Int, + val unitPriceCents: Long, + val totalPriceCents: Long, + val orderConfirmationPng: ByteArray, + val evidenceSha256: String +) + +data class AuthorizedOrderDryRunContext( + val task: RemotePurchaseTask, + val command: PendingOrderCommand, + val referenceImage: ReferenceImageRecord, + val referenceImageBytes: ByteArray, + val maxBudgetCents: Long? +) + enum class ExecutionMode { MANUAL_FIRST, AI_ASSISTED @@ -143,6 +199,7 @@ enum class ExecutionOutboxType { EVENTS, EVIDENCE, CANDIDATES, + ORDER_DRY_RUN_READY, HUMAN_REVIEW, COMPLETE, FAIL @@ -154,7 +211,8 @@ data class ExecutionOutboxItem( val idempotencyKey: String, val payload: String, val evidenceRelativePath: String? = null, - val remoteResourceID: String? = null + val remoteResourceID: String? = null, + val remoteSHA256: String? = null ) data class ProcurementUiState( @@ -164,6 +222,7 @@ data class ProcurementUiState( val referenceImagePath: String? = null, val execution: RunningExecution? = null, val orderCommand: PendingOrderCommand? = null, + val orderDryRun: PendingOrderDryRun? = null, val authenticationRequired: Boolean = false, val busy: Boolean = false, val backendOnline: Boolean? = null, diff --git a/android-buyer/app/src/main/java/com/roubao/autopilot/procurement/ProcurementRepository.kt b/android-buyer/app/src/main/java/com/roubao/autopilot/procurement/ProcurementRepository.kt index e17e383..f0a3ca3 100644 --- a/android-buyer/app/src/main/java/com/roubao/autopilot/procurement/ProcurementRepository.kt +++ b/android-buyer/app/src/main/java/com/roubao/autopilot/procurement/ProcurementRepository.kt @@ -3,6 +3,7 @@ package com.roubao.autopilot.procurement import android.content.Context import android.graphics.BitmapFactory import android.os.Build +import android.util.Log import com.roubao.autopilot.BuildConfig import com.roubao.autopilot.readiness.DeviceReadinessSnapshot import kotlinx.coroutines.flow.MutableStateFlow @@ -57,6 +58,20 @@ class ProcurementRepository( persisted.orderCommand == null } + val shouldRunOrderDryRun: Boolean + get() { + val execution = persisted.execution ?: return false + val command = persisted.orderCommand ?: return false + val dryRun = persisted.orderDryRun + return storageFailure == null && + command.acknowledged && + !execution.safetyStopped && + !execution.isExpired() && + dryRun?.status != OrderDryRunStatus.READY && + dryRun?.status != OrderDryRunStatus.READY_LOCAL && + (dryRun?.attemptCount ?: 0) < MAX_ORDER_DRY_RUN_ATTEMPTS + } + suspend fun login(input: LoginInput): Boolean = operation { require(input.password.isNotEmpty()) { "请输入采购员密码" } val normalizedUrl = BackendEndpointPolicy.normalize( @@ -387,6 +402,14 @@ class ProcurementRepository( } } } catch (error: Exception) { + val diagnostic = if (error is ProcurementApiException) { + "Procurement synchronization failed: " + + "code=${error.code}, status=${error.statusCode}, " + + "retryable=${error.retryable}" + } else { + "Procurement synchronization failed" + } + Log.w(LOG_TAG, diagnostic, error) val localFailure = error is IllegalArgumentException || error is IllegalStateException @@ -428,6 +451,209 @@ class ProcurementRepository( ?.readBytes() } + suspend fun prepareOrderDryRun(): AuthorizedOrderDryRunContext? = operation { + val session = requireValidSession() + val claim = requireNotNull(persisted.claim) { "没有可执行的采购任务" } + val task = requireNotNull(claim.task) { "任务详情尚未下载" } + val execution = requireActiveExecution() + val command = requireNotNull(persisted.orderCommand) { + "尚未收到后台下单命令" + } + require(command.acknowledged) { "下单命令尚未安全确认" } + require(!execution.safetyStopped && !execution.isExpired()) { + "执行授权已到期,不能继续订单核验" + } + require(command.taskId == task.id && command.executionId == execution.id) { + "下单命令与当前任务不一致" + } + val reference = requireNotNull(claim.referenceImage) { "参考图缺失" } + val referenceFile = File(appContext.filesDir, reference.relativePath) + require(referenceFile.isFile && referenceFile.length() == reference.sizeBytes) { + "参考图文件缺失" + } + val referenceBytes = referenceFile.readBytes() + require(sha256(referenceBytes) == reference.sha256) { "参考图哈希不一致" } + + var dryRun = persisted.orderDryRun + if (dryRun == null) { + dryRun = PendingOrderDryRun( + commandId = command.id, + commandSha256 = command.commandSha256, + status = OrderDryRunStatus.INTENT_SAVED, + startIdempotencyKey = newOpaqueSecret(), + readyIdempotencyKey = newOpaqueSecret() + ) + persisted = persisted.copy(orderDryRun = dryRun) + store.save(persisted) + } + require( + dryRun.commandId == command.id && + dryRun.commandSha256 == command.commandSha256 + ) { "本地下单预演与后台命令不一致" } + require(dryRun.status != OrderDryRunStatus.READY) { "订单已完成核验" } + require(dryRun.status != OrderDryRunStatus.READY_LOCAL) { + "订单核验证据正在回传" + } + require(dryRun.attemptCount < MAX_ORDER_DRY_RUN_ATTEMPTS) { + "订单核验已达到最大尝试次数" + } + + dryRun = dryRun.copy( + attemptCount = dryRun.attemptCount + 1, + lastAttemptAtEpochMillis = System.currentTimeMillis() + ) + persisted = persisted.copy(orderDryRun = dryRun) + store.save(persisted) + + val remote = api.startOrderDryRun( + session = session, + task = task, + execution = execution, + claimToken = claim.token, + command = command, + idempotencyKey = dryRun.startIdempotencyKey + ) + require( + remote.commandId == command.id && + remote.commandSha256 == command.commandSha256 && + remote.status in setOf("PREPARING", "READY") + ) { "后台下单预演状态无效" } + dryRun = dryRun.copy( + status = if (remote.status == "READY") { + OrderDryRunStatus.READY + } else { + OrderDryRunStatus.PREPARING + }, + remoteId = remote.id + ) + persisted = persisted.copy( + execution = execution.copy(currentStep = ORDER_DRY_RUN_STEP), + orderDryRun = dryRun + ) + saveAndPublish( + backendOnline = true, + message = if (remote.status == "READY") { + "订单已完成核验" + } else { + "正在重新定位已授权商品" + } + ) + if (remote.status == "READY") { + return@operation null + } + AuthorizedOrderDryRunContext( + task = task, + command = command, + referenceImage = reference, + referenceImageBytes = referenceBytes, + maxBudgetCents = parseBudgetCents(task.maxBudget) + ) + } + + suspend fun queueOrderDryRunReady( + draft: OrderDryRunReadyDraft + ): Boolean = operation { + val task = requireCurrentTask() + val execution = requireActiveExecution() + val command = requireNotNull(persisted.orderCommand) { + "尚未收到后台下单命令" + } + val dryRun = requireNotNull(persisted.orderDryRun) { + "下单预演尚未开始" + } + require(dryRun.status == OrderDryRunStatus.PREPARING) { + "下单预演状态不能提交核验证据" + } + require(!execution.safetyStopped && !execution.isExpired()) { + "执行授权已到期,不能提交订单核验" + } + require( + draft.cardSignature == command.candidate.cardSignature || + draft.detailSignature == command.candidate.detailSignature + ) { "当前商品与后台授权商品的语义指纹不一致" } + require( + normalizeTitle(draft.observedTitle) == + normalizeTitle(command.candidate.title) + ) { "当前商品标题与后台授权商品不一致" } + require(draft.selectedSku.trim() == command.originalSku.trim()) { + "当前规格与后台授权规格不一致" + } + require(draft.quantity == command.quantity && draft.quantity in 1..99) { + "当前数量与后台授权数量不一致" + } + require( + draft.unitPriceCents > 0 && + draft.totalPriceCents == + Math.multiplyExact(draft.unitPriceCents, draft.quantity.toLong()) + ) { "当前价格或总额无效" } + parseBudgetCents(task.maxBudget)?.let { budgetCents -> + require(draft.totalPriceCents <= budgetCents) { + "当前总额超过任务预算" + } + } + require( + draft.orderConfirmationPng.isNotEmpty() && + sha256(draft.orderConfirmationPng) == draft.evidenceSha256 + ) { "订单确认页截图证据无效" } + require(persisted.outbox.none { + it.type == ExecutionOutboxType.ORDER_DRY_RUN_READY + }) { "订单核验证据已在回传队列" } + + val localEvidenceID = UUID.randomUUID().toString() + persistEvidenceLocked(localEvidenceID, draft.orderConfirmationPng) + appendOutboxLocked( + ExecutionOutboxItem( + id = localEvidenceID, + type = ExecutionOutboxType.EVIDENCE, + idempotencyKey = newOpaqueSecret(), + payload = "{}", + evidenceRelativePath = "$OUTBOX_DIRECTORY/$localEvidenceID.png" + ) + ) + val readyPayload = JSONObject() + .put("device_id", requireNotNull(persisted.session).deviceId) + .put("execution_id", execution.id) + .put("claim_generation", task.claimGeneration) + .put("command_id", command.id) + .put("command_sha256", command.commandSha256) + .put("card_signature", draft.cardSignature) + .put("detail_signature", draft.detailSignature) + .put("observed_title", draft.observedTitle.trim()) + .put("selected_sku", draft.selectedSku.trim()) + .put("quantity", draft.quantity) + .put("unit_price_cents", draft.unitPriceCents) + .put("total_price_cents", draft.totalPriceCents) + .put("evidence_asset_id", localEvidenceID) + .put("evidence_sha256", "sha256:$localEvidenceID") + appendOutboxLocked( + ExecutionOutboxItem( + id = UUID.randomUUID().toString(), + type = ExecutionOutboxType.ORDER_DRY_RUN_READY, + idempotencyKey = dryRun.readyIdempotencyKey, + payload = readyPayload.toString() + ) + ) + persisted = persisted.copy( + execution = execution.copy(currentStep = ORDER_DRY_RUN_STEP), + orderDryRun = dryRun.copy( + status = OrderDryRunStatus.READY_LOCAL, + cardSignature = draft.cardSignature, + detailSignature = draft.detailSignature, + observedTitle = draft.observedTitle.trim(), + selectedSku = draft.selectedSku.trim(), + quantity = draft.quantity, + unitPriceCents = draft.unitPriceCents, + totalPriceCents = draft.totalPriceCents, + evidenceSha256 = draft.evidenceSha256 + ) + ) + saveAndPublish( + backendOnline = _uiState.value.backendOnline, + message = "订单核验证据已加入加密回传队列" + ) + true + } ?: false + suspend fun queueCandidateBatch( batch: ExecutionCandidateBatchDraft, evidence: List @@ -776,7 +1002,8 @@ class ProcurementRepository( claim = null, execution = null, outbox = emptyList(), - orderCommand = null + orderCommand = null, + orderDryRun = null ) } @@ -795,7 +1022,8 @@ class ProcurementRepository( resolvedPayload = replaceEvidenceReference( resolvedPayload, knownEvidence.id, - knownEvidence.remoteResourceID + knownEvidence.remoteResourceID, + knownEvidence.remoteSHA256 ) } } @@ -899,28 +1127,55 @@ class ProcurementRepository( } file.readBytes() } + val uploadItem = if (item.type == ExecutionOutboxType.EVIDENCE) { + item + } else { + persisted.outbox + .filter { + it.type == ExecutionOutboxType.EVIDENCE && + it.remoteResourceID != null && + it.remoteSHA256 != null + } + .fold(item) { resolved, evidence -> + resolved.copy( + payload = replaceEvidenceReference( + resolved.payload, + evidence.id, + requireNotNull(evidence.remoteResourceID), + evidence.remoteSHA256 + ) + ) + } + } val uploaded = api.uploadExecutionOutboxItem( session = session, task = task, execution = execution, claimToken = requireNotNull(persisted.claim).token, - item = item, + item = uploadItem, evidenceBytes = evidenceBytes ) if (item.type == ExecutionOutboxType.EVIDENCE) { val evidenceID = requireNotNull(uploaded.evidenceAssetId) { "后台未返回证据编号" } + val evidenceSHA256 = requireNotNull(uploaded.evidenceSha256) { + "后台未返回证据哈希" + } persisted = persisted.copy( outbox = persisted.outbox.map { pending -> if (pending.id == item.id) { - pending.copy(remoteResourceID = evidenceID) + pending.copy( + remoteResourceID = evidenceID, + remoteSHA256 = evidenceSHA256 + ) } else { pending.copy( payload = replaceEvidenceReference( pending.payload, item.id, - evidenceID + evidenceID, + evidenceSHA256 ) ) } @@ -941,6 +1196,26 @@ class ProcurementRepository( store.save(persisted) return true } + if (item.type == ExecutionOutboxType.ORDER_DRY_RUN_READY) { + val remote = requireNotNull(uploaded.orderDryRun) { + "后台未返回订单核验状态" + } + val command = requireNotNull(persisted.orderCommand) + require( + remote.commandId == command.id && + remote.commandSha256 == command.commandSha256 && + remote.status == "READY" + ) { "后台订单核验状态无效" } + persisted = persisted.copy( + execution = execution.copy( + currentStep = ORDER_DRY_RUN_READY_STEP + ), + orderDryRun = requireNotNull(persisted.orderDryRun).copy( + status = OrderDryRunStatus.READY, + remoteId = remote.id + ) + ) + } store.save(persisted) } } @@ -948,14 +1223,37 @@ class ProcurementRepository( private fun replaceEvidenceReference( payload: String, localID: String, - remoteID: String + remoteID: String, + remoteSHA256: String? ): String { val root = JSONObject(payload) fun replace(value: Any?) { when (value) { is JSONObject -> { val keys = value.keys().asSequence().toList() - keys.forEach { key -> replace(value.opt(key)) } + keys.forEach { key -> + when (value.optString(key)) { + localID -> value.put(key, remoteID) + "sha256:$localID" -> { + if (remoteSHA256 != null) { + value.put(key, remoteSHA256) + } + } + else -> replace(value.opt(key)) + } + } + value.optJSONArray("evidence_asset_ids")?.let { ids -> + when { + ids.optString(0) == remoteID && remoteSHA256 != null -> + value.put("detail_evidence_sha256", remoteSHA256) + ids.optString(1) == remoteID && remoteSHA256 != null -> + value.put( + "specification_evidence_sha256", + remoteSHA256 + ) + else -> Unit + } + } } is JSONArray -> { for (index in 0 until value.length()) { @@ -973,7 +1271,11 @@ class ProcurementRepository( } private fun candidateJSON(candidate: ExecutionCandidateDraft): JSONObject = - JSONObject() + JSONObject().also { + require(candidate.evidenceLocalIDs.size == 2) { + "候选截图关联无效" + } + } .put("ordinal", candidate.ordinal) .put("title", candidate.title) .put("sku_text", candidate.skuText) @@ -984,11 +1286,11 @@ class ProcurementRepository( .put("detail_signature", candidate.detailSignature) .put( "detail_evidence_sha256", - candidate.detailEvidenceSha256 + "sha256:${candidate.evidenceLocalIDs[0]}" ) .put( "specification_evidence_sha256", - candidate.specificationEvidenceSha256 + "sha256:${candidate.evidenceLocalIDs[1]}" ) .put("evidence_asset_ids", JSONArray(candidate.evidenceLocalIDs)) .also { json -> @@ -1099,6 +1401,10 @@ class ProcurementRepository( execution != null && (execution.safetyStopped || execution.isExpired()) -> ProcurementPhase.AUTHORIZATION_EXPIRED + persisted.orderDryRun?.status == OrderDryRunStatus.READY -> + ProcurementPhase.ORDER_DRY_RUN_READY + persisted.orderDryRun != null -> + ProcurementPhase.ORDER_DRY_RUN_RUNNING persisted.orderCommand?.acknowledged == true -> ProcurementPhase.ORDER_AUTHORIZED execution?.currentStep == WAITING_ADMIN_CONFIRMATION_STEP -> @@ -1125,6 +1431,7 @@ class ProcurementRepository( referenceImagePath = imagePath, execution = execution, orderCommand = persisted.orderCommand, + orderDryRun = persisted.orderDryRun, authenticationRequired = session?.isValid() != true ) } @@ -1158,18 +1465,37 @@ class ProcurementRepository( return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes) } + private fun parseBudgetCents(value: String?): Long? { + val normalized = value?.trim()?.takeIf(String::isNotEmpty) ?: return null + require(BUDGET_PATTERN.matches(normalized)) { "任务预算格式无效" } + val parts = normalized.split('.', limit = 2) + val whole = parts[0].toLong() + val fraction = parts.getOrNull(1)?.padEnd(2, '0')?.toLong() ?: 0L + return Math.addExact(Math.multiplyExact(whole, 100L), fraction) + } + + private fun normalizeTitle(value: String): String = + value.trim().lowercase().replace(TITLE_SPACE_PATTERN, "") + companion object { + private const val LOG_TAG = "ProcurementRepository" private const val REFERENCE_DIRECTORY = "procurement" private const val OUTBOX_DIRECTORY = "procurement-outbox" private const val CONTROLLED_WORKFLOW_STEP = "CONTROLLED_WORKFLOW" private const val WAITING_ADMIN_CONFIRMATION_STEP = "WAITING_ADMIN_CONFIRMATION" private const val ORDER_AUTHORIZED_STEP = "ORDER_AUTHORIZED" + private const val ORDER_DRY_RUN_STEP = "ORDER_DRY_RUN" + private const val ORDER_DRY_RUN_READY_STEP = "ORDER_DRY_RUN_READY" private const val SAFE_STOPPED_STEP = "SAFE_STOPPED" + private const val MAX_ORDER_DRY_RUN_ATTEMPTS = 5 private const val MAX_REFERENCE_DIMENSION = 4_096 private const val MAX_REFERENCE_PIXELS = 20_000_000L private const val MAX_OUTBOX_EVIDENCE_BYTES = 8L * 1024L * 1024L private val SHA256_PATTERN = Regex("^[0-9a-f]{64}$") + private val BUDGET_PATTERN = + Regex("^(0|[1-9][0-9]{0,9})(\\.[0-9]{1,2})?$") + private val TITLE_SPACE_PATTERN = Regex("[\\s\\p{Punct}]+") private val COMPLETE_OUTCOMES = setOf( "CANDIDATE_ACCEPTED", "CANDIDATE_REJECTED", diff --git a/android-buyer/app/src/main/java/com/roubao/autopilot/procurement/ProcurementSecureStore.kt b/android-buyer/app/src/main/java/com/roubao/autopilot/procurement/ProcurementSecureStore.kt index 7678b7b..019e14f 100644 --- a/android-buyer/app/src/main/java/com/roubao/autopilot/procurement/ProcurementSecureStore.kt +++ b/android-buyer/app/src/main/java/com/roubao/autopilot/procurement/ProcurementSecureStore.kt @@ -129,6 +129,7 @@ class ProcurementSecureStore(context: Context) : ProcurementStateStore { "remote_resource_id", item.remoteResourceID ) + putNullable("remote_sha256", item.remoteSHA256) } ) } @@ -137,6 +138,9 @@ class ProcurementSecureStore(context: Context) : ProcurementStateStore { state.orderCommand?.let { command -> put("order_command", encodeOrderCommand(command)) } + state.orderDryRun?.let { dryRun -> + put("order_dry_run", encodeOrderDryRun(dryRun)) + } } private fun decodeState(json: JSONObject): PersistedProcurementState = @@ -213,7 +217,9 @@ class ProcurementSecureStore(context: Context) : ProcurementStateStore { evidenceRelativePath = item.optionalString("evidence_relative_path"), remoteResourceID = - item.optionalString("remote_resource_id") + item.optionalString("remote_resource_id"), + remoteSHA256 = + item.optionalString("remote_sha256") ) ) } @@ -221,9 +227,67 @@ class ProcurementSecureStore(context: Context) : ProcurementStateStore { } ?: emptyList(), orderCommand = json.optionalObject("order_command")?.let { decodeOrderCommand(it) + }, + orderDryRun = json.optionalObject("order_dry_run")?.let { + decodeOrderDryRun(it) } ) + private fun encodeOrderDryRun(dryRun: PendingOrderDryRun): JSONObject = + JSONObject().apply { + put("command_id", dryRun.commandId) + put("command_sha256", dryRun.commandSha256) + put("status", dryRun.status.name) + put("start_idempotency_key", dryRun.startIdempotencyKey) + put("ready_idempotency_key", dryRun.readyIdempotencyKey) + putNullable("remote_id", dryRun.remoteId) + put("attempt_count", dryRun.attemptCount) + dryRun.lastAttemptAtEpochMillis?.let { + put("last_attempt_at_ms", it) + } + putNullable("card_signature", dryRun.cardSignature) + putNullable("detail_signature", dryRun.detailSignature) + putNullable("observed_title", dryRun.observedTitle) + putNullable("selected_sku", dryRun.selectedSku) + dryRun.quantity?.let { put("quantity", it) } + dryRun.unitPriceCents?.let { put("unit_price_cents", it) } + dryRun.totalPriceCents?.let { put("total_price_cents", it) } + putNullable("evidence_sha256", dryRun.evidenceSha256) + } + + private fun decodeOrderDryRun(json: JSONObject): PendingOrderDryRun = + PendingOrderDryRun( + commandId = json.getString("command_id"), + commandSha256 = json.getString("command_sha256"), + status = OrderDryRunStatus.valueOf(json.getString("status")), + startIdempotencyKey = json.getString("start_idempotency_key"), + readyIdempotencyKey = json.getString("ready_idempotency_key"), + remoteId = json.optionalString("remote_id"), + attemptCount = json.optInt("attempt_count", 0), + lastAttemptAtEpochMillis = + if (json.has("last_attempt_at_ms")) { + json.getLong("last_attempt_at_ms") + } else { + null + }, + cardSignature = json.optionalString("card_signature"), + detailSignature = json.optionalString("detail_signature"), + observedTitle = json.optionalString("observed_title"), + selectedSku = json.optionalString("selected_sku"), + quantity = if (json.has("quantity")) json.getInt("quantity") else null, + unitPriceCents = if (json.has("unit_price_cents")) { + json.getLong("unit_price_cents") + } else { + null + }, + totalPriceCents = if (json.has("total_price_cents")) { + json.getLong("total_price_cents") + } else { + null + }, + evidenceSha256 = json.optionalString("evidence_sha256") + ) + private fun encodeOrderCommand(command: PendingOrderCommand): JSONObject = JSONObject().apply { put("id", command.id) diff --git a/android-buyer/app/src/main/java/com/roubao/autopilot/ui/screens/ProcurementScreen.kt b/android-buyer/app/src/main/java/com/roubao/autopilot/ui/screens/ProcurementScreen.kt index bc34b97..761a6de 100644 --- a/android-buyer/app/src/main/java/com/roubao/autopilot/ui/screens/ProcurementScreen.kt +++ b/android-buyer/app/src/main/java/com/roubao/autopilot/ui/screens/ProcurementScreen.kt @@ -198,6 +198,8 @@ fun ProcurementScreen( ProcurementPhase.RUNNING, ProcurementPhase.WAITING_ADMIN_CONFIRMATION, ProcurementPhase.ORDER_AUTHORIZED, + ProcurementPhase.ORDER_DRY_RUN_RUNNING, + ProcurementPhase.ORDER_DRY_RUN_READY, ProcurementPhase.AUTHORIZATION_EXPIRED -> { if (state.authenticationRequired) { item(key = "procurement-login") { @@ -460,6 +462,12 @@ private fun ExecutionDetails(state: ProcurementUiState) { DetailRow("目标数量", command.quantity.toString()) DetailRow("候选指纹", command.candidate.candidateKey) } + state.orderDryRun?.let { dryRun -> + DetailRow("订单核验", dryRun.status.name) + dryRun.totalPriceCents?.let { cents -> + DetailRow("核验总额", "¥%.2f".format(cents / 100.0)) + } + } DetailRow( "后台连接", when (state.backendOnline) { @@ -484,11 +492,18 @@ private fun ExecutionDetails(state: ProcurementUiState) { ProcurementPhase.WAITING_ADMIN_CONFIRMATION -> "等待后台采购员确认商品" ProcurementPhase.ORDER_AUTHORIZED -> - "商品授权已安全保存;本版本尚不执行下单" + "商品授权已安全保存,等待自动核验" + ProcurementPhase.ORDER_DRY_RUN_RUNNING -> + "正在重新定位商品并核验规格、数量和金额" + ProcurementPhase.ORDER_DRY_RUN_READY -> + "订单已核验,等待单次提交" else -> "订单提交保持禁用" }, color = if ( - state.phase == ProcurementPhase.ORDER_AUTHORIZED + state.phase in setOf( + ProcurementPhase.ORDER_AUTHORIZED, + ProcurementPhase.ORDER_DRY_RUN_READY + ) ) { colors.success } else { @@ -528,7 +543,9 @@ private fun phaseColor(state: ProcurementUiState) = ProcurementPhase.AUTHORIZATION_EXPIRED -> BaoziTheme.colors.error ProcurementPhase.RUNNING, ProcurementPhase.WAITING_ADMIN_CONFIRMATION, - ProcurementPhase.ORDER_AUTHORIZED -> BaoziTheme.colors.success + ProcurementPhase.ORDER_AUTHORIZED, + ProcurementPhase.ORDER_DRY_RUN_RUNNING, + ProcurementPhase.ORDER_DRY_RUN_READY -> BaoziTheme.colors.success else -> BaoziTheme.colors.textSecondary } @@ -540,5 +557,7 @@ private fun phaseLabel(state: ProcurementUiState): String = ProcurementPhase.RUNNING -> "受控流程运行中" ProcurementPhase.WAITING_ADMIN_CONFIRMATION -> "等待后台确认商品" ProcurementPhase.ORDER_AUTHORIZED -> "后台商品授权已同步" + ProcurementPhase.ORDER_DRY_RUN_RUNNING -> "正在核验订单" + ProcurementPhase.ORDER_DRY_RUN_READY -> "订单已核验,等待提交" ProcurementPhase.AUTHORIZATION_EXPIRED -> "授权到期,已停止" } diff --git a/android-buyer/app/src/test/java/com/roubao/autopilot/pinduoduo/PinduoduoCheckoutOcrPolicyTest.kt b/android-buyer/app/src/test/java/com/roubao/autopilot/pinduoduo/PinduoduoCheckoutOcrPolicyTest.kt new file mode 100644 index 0000000..7dd41bf --- /dev/null +++ b/android-buyer/app/src/test/java/com/roubao/autopilot/pinduoduo/PinduoduoCheckoutOcrPolicyTest.kt @@ -0,0 +1,174 @@ +package com.roubao.autopilot.pinduoduo + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class PinduoduoCheckoutOcrPolicyTest { + @Test + fun `verifies checkout evidence using semantic relative positions`() { + val result = PinduoduoCheckoutOcrPolicy.verify( + lines = checkoutLines(), + authorizedTitle = TITLE, + expectedColor = "GRAY", + expectedSize = "2XL", + expectedQuantity = 1 + ) + + assertEquals(TITLE, result?.observedTitle) + assertEquals("灰色 2XL 建议125-135斤", result?.selectedSummary) + assertEquals(1, result?.quantity) + assertEquals(1_435L, result?.totalPriceCents) + } + + @Test + fun `rejects an unlabeled total`() { + val lines = checkoutLines().map { line -> + if (line.text.startsWith("实付款")) { + line.copy(text = "¥14.35") + } else { + line + } + } + + assertNull( + PinduoduoCheckoutOcrPolicy.verify( + lines, + TITLE, + "GRAY", + "2XL", + 1 + ) + ) + } + + @Test + fun `accepts color and size split from their labels by OCR`() { + val split = checkoutLines().flatMap { line -> + when { + line.text.startsWith("颜色分类") -> listOf( + line.copy(text = "颜色分类"), + line.copy(text = "灰色", left = 500) + ) + line.text.startsWith("尺码") -> listOf( + line.copy(text = "尺码"), + line.copy(text = "2XL 建议125-135斤", left = 500) + ) + else -> listOf(line) + } + } + + val result = PinduoduoCheckoutOcrPolicy.verify( + split, + TITLE, + "GRAY", + "2XL", + 1 + ) + + assertEquals("灰色 2XL 建议125-135斤", result?.selectedSummary) + } + + @Test + fun `rejects quantity outside the product and discount region`() { + val lines = checkoutLines().map { line -> + if (line.text == "1") line.copy(top = 950, bottom = 980) else line + } + + assertNull( + PinduoduoCheckoutOcrPolicy.verify( + lines, + TITLE, + "GRAY", + "2XL", + 1 + ) + ) + } + + @Test + fun `accepts quantity merged with the increment control by OCR`() { + val lines = checkoutLines().map { line -> + if (line.text == "1") line.copy(text = "1 +") else line + } + + val result = PinduoduoCheckoutOcrPolicy.verify( + lines, + TITLE, + "GRAY", + "2XL", + 1 + ) + + assertEquals(1, result?.quantity) + } + + @Test + fun `long title permits bounded OCR character errors`() { + val observed = "前缀" + TITLE.replace("轻奢", "轻著") + "后缀" + + assertEquals( + true, + PinduoduoCheckoutOcrPolicy.approximatelyContains( + observed, + TITLE + ) + ) + } + + @Test + fun `long title permits split OCR anchors`() { + val observed = "设计拼接其他文字恤短袖更多文字26轻" + + "其他小众夏季气质新款其他漂亮上衣" + + assertEquals( + true, + PinduoduoCheckoutOcrPolicy.approximatelyContains( + observed, + TITLE + ) + ) + } + + @Test + fun `long title permits a rendered prefix truncated by checkout`() { + assertEquals( + true, + PinduoduoCheckoutOcrPolicy.approximatelyContains( + TITLE.take(18), + TITLE + ) + ) + } + + @Test + fun `different title is not an approximate match`() { + assertEquals( + false, + PinduoduoCheckoutOcrPolicy.approximatelyContains( + "夏季纯棉儿童运动套装宽松休闲两件套", + TITLE + ) + ) + } + + private fun checkoutLines() = listOf( + line("手动添加收货地址", 100), + line(TITLE.substring(0, 18), 200), + line(TITLE.substring(18), 240), + line("颜色分类: 灰色", 300), + line("尺码: 2XL 建议125-135斤", 340), + line("1", 430), + line("店铺优惠", 600), + line("平台和限时优惠", 700), + line("实付款: ¥14.35 免运费", 1000) + ) + + private fun line(text: String, top: Int) = + PinduoduoOcrLine(text, 100, top, 800, top + 40) + + private companion object { + const val TITLE = + "设计拼接T恤短袖2026轻奢小众夏季气质新款百搭漂亮上衣出片" + } +} diff --git a/android-buyer/app/src/test/java/com/roubao/autopilot/pinduoduo/PinduoduoImageSearchAutomationTest.kt b/android-buyer/app/src/test/java/com/roubao/autopilot/pinduoduo/PinduoduoImageSearchAutomationTest.kt index e3110bd..e7f524b 100644 --- a/android-buyer/app/src/test/java/com/roubao/autopilot/pinduoduo/PinduoduoImageSearchAutomationTest.kt +++ b/android-buyer/app/src/test/java/com/roubao/autopilot/pinduoduo/PinduoduoImageSearchAutomationTest.kt @@ -75,6 +75,92 @@ class PinduoduoImageSearchAutomationTest { assertEquals(PinduoduoPage.IMAGE_SEARCH_RESULTS, driver.page) } + @Test + fun `dismisses camera retry dialog and reopens the prepared image`() = + runTest { + val driver = FakeImageDriver( + page = PinduoduoPage.SEARCH_RESULTS, + retryDialogOnce = true + ) + val runner = WorkflowRunner( + PinduoduoImageSearchAutomation( + driver = driver, + pollIntervalMillis = 1, + unknownPageLimit = 3 + ) + ) + + val report = runner.run(PinduoduoImageSearchWorkflow.steps()) + + assertEquals(WorkflowState.SUCCEEDED, report.state) + assertEquals(1, driver.dismissRetryDialogCalls) + assertTrue(driver.preparedImageSelected) + assertEquals(PinduoduoPage.IMAGE_SEARCH_RESULTS, driver.page) + } + + @Test + fun `recovers when app opens directly into the camera retry dialog`() = + runTest { + val driver = FakeImageDriver( + page = PinduoduoPage.IMAGE_SEARCH_RETRY_DIALOG + ) + val runner = WorkflowRunner( + PinduoduoImageSearchAutomation( + driver = driver, + pollIntervalMillis = 1, + unknownPageLimit = 3 + ) + ) + + val report = runner.run(PinduoduoImageSearchWorkflow.steps()) + + assertEquals(WorkflowState.SUCCEEDED, report.state) + assertEquals(1, driver.dismissRetryDialogCalls) + assertTrue(driver.preparedImageSelected) + } + + @Test + fun `recovers from specification panel left by candidate collection`() = + runTest { + val driver = FakeImageDriver(PinduoduoPage.SPECIFICATION_PANEL) + val runner = WorkflowRunner( + PinduoduoImageSearchAutomation( + driver = driver, + pollIntervalMillis = 1, + unknownPageLimit = 3 + ) + ) + + val report = runner.run(PinduoduoImageSearchWorkflow.steps()) + + assertEquals(WorkflowState.SUCCEEDED, report.state) + assertEquals(1, driver.closeSpecificationsCalls) + assertTrue(driver.preparedImageSelected) + assertEquals(PinduoduoPage.IMAGE_SEARCH_RESULTS, driver.page) + } + + @Test + fun `backs out of order confirmation before a retry search`() = runTest { + val driver = FakeImageDriver( + PinduoduoPage.ORDER_CONFIRMATION, + safetyStopReason = SafetyStopReason.PAYMENT_BOUNDARY + ) + val runner = WorkflowRunner( + PinduoduoImageSearchAutomation( + driver = driver, + pollIntervalMillis = 1, + unknownPageLimit = 3 + ) + ) + + val report = runner.run(PinduoduoImageSearchWorkflow.steps()) + + assertEquals(WorkflowState.SUCCEEDED, report.state) + assertEquals(1, driver.returnFromOrderConfirmationCalls) + assertTrue(driver.preparedImageSelected) + assertEquals(PinduoduoPage.IMAGE_SEARCH_RESULTS, driver.page) + } + @Test fun `payment marker stops before image entry`() = runTest { val driver = FakeImageDriver( @@ -99,12 +185,18 @@ class PinduoduoImageSearchAutomationTest { var page: PinduoduoPage, private val selectionAllowed: Boolean = true, private val safetyStopReason: SafetyStopReason? = null, - private val cameraResultRaceOnce: Boolean = false + private val cameraResultRaceOnce: Boolean = false, + private val retryDialogOnce: Boolean = false ) : PinduoduoImageSearchDriver { var imageSearchOpened = false var preparedImageSelected = false + var closeSpecificationsCalls = 0 + var returnFromOrderConfirmationCalls = 0 var returnFromImageResultsCalls = 0 + var dismissRetryDialogCalls = 0 + private var currentSafetyStopReason = safetyStopReason private var cameraResultRaceConsumed = false + private var retryDialogConsumed = false override suspend fun openApp(): Boolean = true @@ -112,7 +204,7 @@ class PinduoduoImageSearchAutomationTest { PinduoduoUiSnapshot( foregroundPackage = PINDUODUO_PACKAGE, page = page, - safetyStopReason = safetyStopReason + safetyStopReason = currentSafetyStopReason ) override suspend fun openImageSearch(): Boolean { @@ -120,12 +212,21 @@ class PinduoduoImageSearchAutomationTest { page = if (cameraResultRaceOnce && !cameraResultRaceConsumed) { cameraResultRaceConsumed = true PinduoduoPage.IMAGE_SEARCH_RESULTS + } else if (retryDialogOnce && !retryDialogConsumed) { + retryDialogConsumed = true + PinduoduoPage.IMAGE_SEARCH_RETRY_DIALOG } else { PinduoduoPage.IMAGE_SEARCH } return true } + override suspend fun dismissImageSearchRetryDialog(): Boolean { + dismissRetryDialogCalls += 1 + page = PinduoduoPage.HOME + return true + } + override suspend fun selectPreparedImage(): Boolean { if (!selectionAllowed) { return false @@ -135,6 +236,19 @@ class PinduoduoImageSearchAutomationTest { return true } + override suspend fun closeSpecifications(): Boolean { + closeSpecificationsCalls += 1 + page = PinduoduoPage.PRODUCT_DETAIL + return true + } + + override suspend fun returnFromOrderConfirmation(): Boolean { + returnFromOrderConfirmationCalls += 1 + page = PinduoduoPage.SPECIFICATION_PANEL + currentSafetyStopReason = null + return true + } + override suspend fun returnFromCandidate(): Boolean { page = PinduoduoPage.IMAGE_SEARCH_RESULTS return true diff --git a/android-buyer/app/src/test/java/com/roubao/autopilot/pinduoduo/PinduoduoObservedTitlePolicyTest.kt b/android-buyer/app/src/test/java/com/roubao/autopilot/pinduoduo/PinduoduoObservedTitlePolicyTest.kt index b3fa357..7e4afa1 100644 --- a/android-buyer/app/src/test/java/com/roubao/autopilot/pinduoduo/PinduoduoObservedTitlePolicyTest.kt +++ b/android-buyer/app/src/test/java/com/roubao/autopilot/pinduoduo/PinduoduoObservedTitlePolicyTest.kt @@ -32,4 +32,24 @@ class PinduoduoObservedTitlePolicyTest { assertNull(title) } + + @Test + fun `stable identity ignores volatile price and sales text`() { + val first = PinduoduoStableProductIdentityPolicy.signature( + listOf( + "限时 ¥19.90", + "已拼2.1万件", + "法式小香风V领短袖钉珠女2026夏季新款" + ) + ) + val refreshed = PinduoduoStableProductIdentityPolicy.signature( + listOf( + "限时 ¥21.00", + "已拼2.2万件", + "法式小香风V领短袖钉珠女2026夏季新款" + ) + ) + + assertEquals(first, refreshed) + } } diff --git a/android-buyer/app/src/test/java/com/roubao/autopilot/pinduoduo/PinduoduoOrderConfirmationParserTest.kt b/android-buyer/app/src/test/java/com/roubao/autopilot/pinduoduo/PinduoduoOrderConfirmationParserTest.kt new file mode 100644 index 0000000..3221b7c --- /dev/null +++ b/android-buyer/app/src/test/java/com/roubao/autopilot/pinduoduo/PinduoduoOrderConfirmationParserTest.kt @@ -0,0 +1,44 @@ +package com.roubao.autopilot.pinduoduo + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Test + +class PinduoduoOrderConfirmationParserTest { + @Test + fun `parses modern split sku quantity and payable text`() { + val evidence = PinduoduoOrderConfirmationParser.parse( + listOf( + element("手动添加收货地址", top = 100), + element( + "设计拼接T恤短袖2026轻奢小众夏季气质新款百搭漂亮上衣出片", + top = 200 + ), + element("颜色分类: 灰色", top = 300), + element("尺码: 2XL 建议125-135斤", top = 400), + element("1, 当前数量为1件", top = 500), + element( + "实付款¥14.35已享7.2折,共优惠5.64元免运费", + top = 600 + ) + ) + ) + + assertNotNull(evidence) + assertEquals("灰色 2XL 建议125-135斤", evidence!!.selectedSummary) + assertEquals(1, evidence.quantity) + assertEquals(1_435L, evidence.totalPriceCents) + } + + private fun element(text: String, top: Int) = PinduoduoUiElement( + text = text, + contentDescription = null, + className = "android.widget.TextView", + resourceId = null, + clickable = false, + editable = false, + enabled = true, + visibleToUser = true, + boundsTop = top + ) +} diff --git a/android-buyer/app/src/test/java/com/roubao/autopilot/pinduoduo/PinduoduoOrderDryRunAutomationTest.kt b/android-buyer/app/src/test/java/com/roubao/autopilot/pinduoduo/PinduoduoOrderDryRunAutomationTest.kt new file mode 100644 index 0000000..ed3276d --- /dev/null +++ b/android-buyer/app/src/test/java/com/roubao/autopilot/pinduoduo/PinduoduoOrderDryRunAutomationTest.kt @@ -0,0 +1,267 @@ +package com.roubao.autopilot.pinduoduo + +import com.roubao.autopilot.readiness.PINDUODUO_PACKAGE +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class PinduoduoOrderDryRunAutomationTest { + @Test + fun uniquelyMatchedCandidateStopsOnVerifiedOrderConfirmation() = runBlocking { + val driver = FakeDryRunDriver(listOf(card(CARD_A))) + val result = automation(driver).run() + + assertEquals(CARD_A, result.candidate.cardSignature) + assertEquals(2, result.confirmation.quantity) + assertEquals(4_000L, result.confirmation.totalPriceCents) + assertEquals(PinduoduoPage.ORDER_CONFIRMATION, driver.page) + assertEquals(2, driver.quantity) + assertEquals(1, driver.quantityActions) + assertTrue(driver.confirmedSpecifications) + } + + @Test + fun multipleMatchingDetailsAreRejectedBeforeQuantityOrConfirmation() = runBlocking { + val driver = FakeDryRunDriver( + listOf(card(CARD_A), card(CARD_B)), + details = mapOf( + CARD_A to detail(DETAIL), + CARD_B to detail(DETAIL) + ) + ) + val error = runCatching { automation(driver).run() }.exceptionOrNull() + + assertTrue(error is PinduoduoOrderDryRunException) + assertTrue(error!!.message!!.contains("多个商品")) + assertEquals(0, driver.quantityActions) + assertTrue(!driver.confirmedSpecifications) + } + + @Test + fun disabledSkuIsNotAnAuthorizedMatch() = runBlocking { + val driver = FakeDryRunDriver( + listOf(card(CARD_A)), + colorEnabled = false + ) + val error = runCatching { automation(driver).run() }.exceptionOrNull() + + assertTrue(error is PinduoduoOrderDryRunException) + assertTrue(error!!.message!!.contains("没有唯一匹配")) + assertTrue(!driver.confirmedSpecifications) + } + + @Test + fun quantityClickMustChangeExactlyOneStep() = runBlocking { + val driver = FakeDryRunDriver( + listOf(card(CARD_A)), + mutateQuantity = false + ) + val error = runCatching { automation(driver).run() }.exceptionOrNull() + + assertTrue(error is PinduoduoOrderDryRunException) + assertTrue(error!!.message!!.contains("状态未按一步变化")) + assertTrue(!driver.confirmedSpecifications) + } + + @Test + fun titleMismatchIsNeverAcceptedByMatchingSignatureAlone() = runBlocking { + val driver = FakeDryRunDriver( + listOf(card(CARD_A)), + details = mapOf(CARD_A to detail(DETAIL, "另一件完全不同的商品标题")) + ) + val error = runCatching { automation(driver).run() }.exceptionOrNull() + + assertTrue(error is PinduoduoOrderDryRunException) + assertTrue(error!!.message!!.contains("没有唯一匹配")) + } + + @Test + fun transientUnknownSnapshotBeforeScanningIsRetried() = runBlocking { + val driver = FakeDryRunDriver( + listOf(card(CARD_A)), + transientUnknownAtSnapshot = 2 + ) + + val result = automation(driver).run() + + assertEquals(CARD_A, result.candidate.cardSignature) + assertEquals(PinduoduoPage.ORDER_CONFIRMATION, driver.page) + } + + private fun automation(driver: FakeDryRunDriver) = + PinduoduoOrderDryRunAutomation( + driver = driver, + authorizedTitle = TITLE, + authorizedCardSignature = CARD_A, + authorizedDetailSignature = DETAIL, + authorizedPriceText = "¥20.00", + specificationTarget = PinduoduoSpecificationTarget( + color = "BLACK", + size = "M" + ), + quantity = 2, + maxBudgetCents = null, + pollIntervalMillis = 1, + pagePollLimit = 2 + ) + + private class FakeDryRunDriver( + private val cards: List, + private val details: Map = + cards.associate { it.signature to detail(DETAIL) }, + private val colorEnabled: Boolean = true, + private val mutateQuantity: Boolean = true, + private val transientUnknownAtSnapshot: Int? = null + ) : PinduoduoOrderDryRunDriver { + var page = PinduoduoPage.IMAGE_SEARCH_RESULTS + var quantity = 1 + var quantityActions = 0 + var confirmedSpecifications = false + private var snapshotCount = 0 + private var currentCard: String? = null + private var colorSelected = false + private var sizeSelected = false + + override suspend fun snapshot(): PinduoduoUiSnapshot { + snapshotCount += 1 + if (snapshotCount == transientUnknownAtSnapshot) { + return PinduoduoUiSnapshot( + foregroundPackage = "", + page = PinduoduoPage.UNKNOWN, + safetyStopReason = null + ) + } + return PinduoduoUiSnapshot( + foregroundPackage = PINDUODUO_PACKAGE, + page = page, + safetyStopReason = null + ) + } + + override suspend fun candidateCards(limit: Int) = cards.take(limit) + + override suspend fun openCandidate(signature: String): Boolean { + if (cards.none { it.signature == signature }) return false + currentCard = signature + page = PinduoduoPage.PRODUCT_DETAIL + return true + } + + override suspend fun readCandidateDetail() = + details[currentCard] + + override suspend fun openSpecifications(): Boolean { + page = PinduoduoPage.SPECIFICATION_PANEL + return true + } + + override suspend fun readSpecifications() = + PinduoduoSpecificationEvidence( + signature = SPECIFICATION, + semanticTextCount = 8, + groups = listOf( + PinduoduoSpecificationGroup( + PinduoduoSpecificationGroupKind.COLOR, + "颜色", + listOf( + PinduoduoSpecificationOption( + "黑色", + colorSelected, + colorEnabled + ) + ), + complete = true + ), + PinduoduoSpecificationGroup( + PinduoduoSpecificationGroupKind.SIZE, + "尺码", + listOf( + PinduoduoSpecificationOption( + "M", + sizeSelected, + enabled = true + ) + ), + complete = true + ) + ), + selectedSummary = "已选择 黑色 M", + price = PinduoduoSpecificationPrice("¥20.00", 2_000L) + ) + + override suspend fun selectSpecificationOption( + kind: PinduoduoSpecificationGroupKind, + optionText: String + ): Boolean { + when (kind) { + PinduoduoSpecificationGroupKind.COLOR -> { + if (!colorEnabled || optionText != "黑色") return false + colorSelected = true + } + PinduoduoSpecificationGroupKind.SIZE -> { + if (optionText != "M") return false + sizeSelected = true + } + } + return true + } + + override suspend fun scrollSpecifications() = false + + override suspend fun closeSpecifications(): Boolean { + page = PinduoduoPage.PRODUCT_DETAIL + return true + } + + override suspend fun returnToResults(): Boolean { + page = PinduoduoPage.IMAGE_SEARCH_RESULTS + return true + } + + override suspend fun scrollResults() = false + + override suspend fun scrollResultsBackward() = false + + override suspend fun readQuantity() = quantity + + override suspend fun changeQuantity(increase: Boolean): Boolean { + quantityActions += 1 + if (mutateQuantity) { + quantity += if (increase) 1 else -1 + } + return true + } + + override suspend fun confirmSpecifications(): Boolean { + confirmedSpecifications = true + page = PinduoduoPage.ORDER_CONFIRMATION + return true + } + + override suspend fun readOrderConfirmation() = + PinduoduoOrderConfirmationEvidence( + observedTitle = TITLE, + selectedSummary = "已选 黑色 M", + quantity = quantity, + totalPriceCents = 4_000L + ) + + override suspend fun captureOrderConfirmation() = + PinduoduoScreenshotCapture(byteArrayOf(1, 2, 3), 1080, 2400) + } + + private companion object { + const val TITLE = "测试商品标准标题" + val CARD_A = "a".repeat(64) + val CARD_B = "b".repeat(64) + val DETAIL = "c".repeat(64) + val SPECIFICATION = "d".repeat(64) + + fun card(signature: String) = + PinduoduoCandidateCard(signature, 4, true) + + fun detail(signature: String, title: String = TITLE) = + PinduoduoCandidateDetailEvidence(signature, 8, title) + } +} diff --git a/android-buyer/app/src/test/java/com/roubao/autopilot/pinduoduo/PinduoduoPageClassifierTest.kt b/android-buyer/app/src/test/java/com/roubao/autopilot/pinduoduo/PinduoduoPageClassifierTest.kt index 1b9ab72..24a9a3f 100644 --- a/android-buyer/app/src/test/java/com/roubao/autopilot/pinduoduo/PinduoduoPageClassifierTest.kt +++ b/android-buyer/app/src/test/java/com/roubao/autopilot/pinduoduo/PinduoduoPageClassifierTest.kt @@ -115,6 +115,20 @@ class PinduoduoPageClassifierTest { assertEquals(PinduoduoPage.IMAGE_SEARCH, snapshot.page) } + @Test + fun `camera retry dialog is a recoverable image search page`() { + val snapshot = classify( + element(text = "请对准商品或码,保持手机稳定"), + element(text = "取消", clickable = true), + element(text = "再试一次", clickable = true) + ) + + assertEquals( + PinduoduoPage.IMAGE_SEARCH_RETRY_DIALOG, + snapshot.page + ) + } + @Test fun `image search results require header and sort controls`() { val snapshot = classify( @@ -256,6 +270,63 @@ class PinduoduoPageClassifierTest { ) } + @Test + fun `modern order confirmation uses address payment and payable markers`() { + val snapshot = classify( + element(text = "手动添加收货地址"), + element(text = "微信支付"), + element(text = "先用后付"), + element(text = "找好友支付"), + element(text = "实付款¥14.35已享7.2折,共优惠5.64元免运费") + ) + + assertEquals(PinduoduoPage.ORDER_CONFIRMATION, snapshot.page) + assertEquals( + SafetyStopReason.PAYMENT_BOUNDARY, + snapshot.safetyStopReason + ) + } + + @Test + fun `collapsed checkout webview remains a blocked confirmation page`() { + val snapshot = classify( + element(resourceId = "order_checkout") + ) + + assertEquals(PinduoduoPage.ORDER_CONFIRMATION, snapshot.page) + assertEquals( + SafetyStopReason.PAYMENT_BOUNDARY, + snapshot.safetyStopReason + ) + } + + @Test + fun `collapsed checkout structure is available only to contextual driver`() { + val elements = listOf( + element( + contentDescription = "返回", + clickable = true, + boundsTop = 154 + ), + element( + className = "android.webkit.WebView", + boundsLeft = 0, + boundsTop = 258, + boundsRight = 1080, + boundsBottom = 2328 + ) + ) + + val global = PinduoduoPageClassifier.classify( + PINDUODUO_PACKAGE, + elements + ) + + assertTrue(PinduoduoCollapsedCheckoutPolicy.matches(elements)) + assertEquals(PinduoduoPage.UNKNOWN, global.page) + assertNull(global.safetyStopReason) + } + @Test fun `order list uses stable status tabs`() { val snapshot = classify( @@ -309,18 +380,27 @@ class PinduoduoPageClassifierTest { text: String? = null, contentDescription: String? = null, className: String = "android.widget.TextView", + resourceId: String? = null, editable: Boolean = false, clickable: Boolean = false, enabled: Boolean = true, - visibleToUser: Boolean = true + visibleToUser: Boolean = true, + boundsLeft: Int = 0, + boundsTop: Int = 0, + boundsRight: Int = 0, + boundsBottom: Int = 0 ) = PinduoduoUiElement( text = text, contentDescription = contentDescription, className = className, - resourceId = null, + resourceId = resourceId, clickable = clickable, editable = editable, enabled = enabled, - visibleToUser = visibleToUser + visibleToUser = visibleToUser, + boundsLeft = boundsLeft, + boundsTop = boundsTop, + boundsRight = boundsRight, + boundsBottom = boundsBottom ) } diff --git a/android-buyer/app/src/test/java/com/roubao/autopilot/procurement/ProcurementApiClientTest.kt b/android-buyer/app/src/test/java/com/roubao/autopilot/procurement/ProcurementApiClientTest.kt index 46f88cc..c3bccbc 100644 --- a/android-buyer/app/src/test/java/com/roubao/autopilot/procurement/ProcurementApiClientTest.kt +++ b/android-buyer/app/src/test/java/com/roubao/autopilot/procurement/ProcurementApiClientTest.kt @@ -5,6 +5,7 @@ import kotlinx.coroutines.runBlocking import okhttp3.mockwebserver.MockResponse import okhttp3.mockwebserver.MockWebServer import okio.Buffer +import org.json.JSONObject import org.junit.After import org.junit.Assert.assertEquals import org.junit.Assert.assertNotNull @@ -183,7 +184,7 @@ class ProcurementApiClientTest { ) server.enqueue( jsonResponse( - """{"evidence":{"id":"evidence-id"},"replayed":false}""" + """{"evidence":{"id":"evidence-id","sha256":"${"a".repeat(64)}"},"replayed":false}""" ) ) val evidenceResult = api.uploadExecutionOutboxItem( @@ -201,6 +202,7 @@ class ProcurementApiClientTest { evidenceBytes = byteArrayOf(1, 2, 3) ) assertEquals("evidence-id", evidenceResult.evidenceAssetId) + assertEquals("a".repeat(64), evidenceResult.evidenceSha256) val evidenceRequest = server.takeRequest() assertEquals("/api/v1/tasks/task-id/evidence", evidenceRequest.path) assertEquals("claim-token-value", evidenceRequest.getHeader("X-Claim-Token")) @@ -353,6 +355,100 @@ class ProcurementApiClientTest { assertEquals(null, command) } + @Test + fun orderDryRunStartAndReadyUseRecoverableDeviceRoutes() = runBlocking { + val task = task( + "/api/v1/tasks/task-id/reference-image?claim_generation=1" + ) + val execution = RunningExecution( + id = "execution-id", + currentStep = "ORDER_AUTHORIZED", + expiresAt = "2099-01-01T00:00:00Z", + serverClockOffsetMillis = 0 + ) + val command = PendingOrderCommand( + id = "command-id", + schemaVersion = 1, + type = "CREATE_PENDING_ORDER", + authorizationVersion = 1, + taskId = "task-id", + executionId = "execution-id", + taskContentSha256 = "a".repeat(64), + originalSku = "黑色 M", + quantity = 2, + candidate = OrderCommandCandidate( + candidateKey = "candidate-key", + observedOrdinal = 1, + title = "候选商品标题", + skuText = "黑色 M", + priceText = "20.00", + cardSignature = "b".repeat(64), + detailSignature = "c".repeat(64), + detailEvidenceSha256 = "d".repeat(64), + specificationEvidenceSha256 = "e".repeat(64) + ), + commandSha256 = "f".repeat(64), + authorizationStatus = "ACKNOWLEDGED", + acknowledged = true + ) + server.enqueue( + jsonResponse( + """{"dry_run":{"id":"dry-run-id","command_id":"command-id","command_sha256":"${"f".repeat(64)}","status":"PREPARING"},"replayed":false}""" + ) + ) + val started = api.startOrderDryRun( + session(), + task, + execution, + "claim-token", + command, + "dry-run-start-key" + ) + assertEquals("PREPARING", started.status) + val startRequest = server.takeRequest() + assertEquals( + "/api/v1/tasks/task-id/order-dry-runs/start", + startRequest.path + ) + assertEquals( + "dry-run-start-key", + startRequest.getHeader("Idempotency-Key") + ) + + server.enqueue( + jsonResponse( + """{"dry_run":{"id":"dry-run-id","command_id":"command-id","command_sha256":"${"f".repeat(64)}","status":"READY"},"replayed":false}""" + ) + ) + val readyResult = api.uploadExecutionOutboxItem( + session(), + task, + execution, + "claim-token", + ExecutionOutboxItem( + id = "ready-local-id", + type = ExecutionOutboxType.ORDER_DRY_RUN_READY, + idempotencyKey = "dry-run-ready-key", + payload = + """{"command_id":"command-id","command_sha256":"${"f".repeat(64)}"}""" + ) + ) + assertEquals("READY", readyResult.orderDryRun?.status) + val readyRequest = server.takeRequest() + assertEquals( + "/api/v1/tasks/task-id/order-dry-runs/command-id/ready", + readyRequest.path + ) + assertEquals( + "dry-run-ready-key", + readyRequest.getHeader("Idempotency-Key") + ) + assertEquals( + false, + JSONObject(readyRequest.body.readUtf8()).has("command_id") + ) + } + private fun session() = ProcurementSession( backendUrl = server.url("/").toString().trimEnd('/'), username = "buyer01", diff --git a/backend-api/cmd/api/main.go b/backend-api/cmd/api/main.go index b15a23b..d73b1b8 100644 --- a/backend-api/cmd/api/main.go +++ b/backend-api/cmd/api/main.go @@ -205,6 +205,10 @@ func buildRouter( if err != nil { return nil, err } + dryRuns, err := usecase.NewOrderDryRunService(store, clock, ids) + if err != nil { + return nil, err + } passwords, err := password.NewBcrypt(12) if err != nil { return nil, err @@ -215,6 +219,7 @@ func buildRouter( Assets: assets, Results: results, Commands: commands, + DryRuns: dryRuns, }, ) if err != nil { diff --git a/backend-api/internal/domain/task.go b/backend-api/internal/domain/task.go index 18afc6b..5f60973 100644 --- a/backend-api/internal/domain/task.go +++ b/backend-api/internal/domain/task.go @@ -328,6 +328,33 @@ type DeviceOrderCommand struct { AuthorizationStatus OrderAuthorizationStatus } +type OrderDryRunStatus string + +const ( + OrderDryRunPreparing OrderDryRunStatus = "PREPARING" + OrderDryRunReady OrderDryRunStatus = "READY" +) + +type OrderDryRun struct { + ID string + AuthorizationID string + TaskID string + ExecutionID string + CommandSHA256 string + Status OrderDryRunStatus + CardSignature *string + DetailSignature *string + ObservedTitle *string + SelectedSKU *string + Quantity *int + UnitPriceCents *int64 + TotalPriceCents *int64 + EvidenceAssetID *string + EvidenceSHA256 *string + StartedAt time.Time + ReadyAt *time.Time +} + type ExecutionReport struct { Events []ExecutionEvent EvidenceAssets []ExecutionEvidenceAsset diff --git a/backend-api/internal/platform/migration/claims_migration_test.go b/backend-api/internal/platform/migration/claims_migration_test.go index 3b6f09d..26dfae9 100644 --- a/backend-api/internal/platform/migration/claims_migration_test.go +++ b/backend-api/internal/platform/migration/claims_migration_test.go @@ -34,8 +34,11 @@ func TestClaimsMigrationPreservesHistoryAcrossUpDownUp(t *testing.T) { if applied, err := runner.Up(ctx); err != nil { t.Fatalf("initial Up() error = %v", err) - } else if applied != 9 { - t.Fatalf("initial Up() applied = %d, want 9", applied) + } else if applied != 10 { + t.Fatalf("initial Up() applied = %d, want 10", applied) + } + if err := runner.Down(ctx); err != nil { + t.Fatalf("initial Down(v10) error = %v", err) } if err := runner.Down(ctx); err != nil { t.Fatalf("initial Down(v9) error = %v", err) @@ -56,9 +59,14 @@ func TestClaimsMigrationPreservesHistoryAcrossUpDownUp(t *testing.T) { seedClaimsHistoricalFixture(t, db) if applied, err := runner.Up(ctx); err != nil { - t.Fatalf("Up(v5-v9) over historical data error = %v", err) - } else if applied != 5 { - t.Fatalf("Up(v5-v9) applied = %d, want 5", applied) + t.Fatalf("Up(v5-v10) over historical data error = %v", err) + } else if applied != 6 { + t.Fatalf("Up(v5-v10) applied = %d, want 6", applied) + } + assertClaimsHistory(t, db, true) + + if err := runner.Down(ctx); err != nil { + t.Fatalf("Down(v10) with compatible history error = %v", err) } assertClaimsHistory(t, db, true) @@ -93,9 +101,9 @@ func TestClaimsMigrationPreservesHistoryAcrossUpDownUp(t *testing.T) { assertClaimsHistory(t, db, false) if applied, err := runner.Up(ctx); err != nil { - t.Fatalf("final Up(v4-v9) error = %v", err) - } else if applied != 6 { - t.Fatalf("final Up(v4-v9) applied = %d, want 6", applied) + t.Fatalf("final Up(v4-v10) error = %v", err) + } else if applied != 7 { + t.Fatalf("final Up(v4-v10) applied = %d, want 7", applied) } assertClaimsHistory(t, db, true) } @@ -331,6 +339,9 @@ func TestClaimsMigrationDownFailsClosedForNewAuditData(t *testing.T) { t.Fatalf("insert v4 audit event: %v", err) } + if err := runner.Down(ctx); err != nil { + t.Fatalf("Down(v10) error = %v", err) + } if err := runner.Down(ctx); err != nil { t.Fatalf("Down(v9) error = %v", err) } diff --git a/backend-api/internal/platform/migration/runner_test.go b/backend-api/internal/platform/migration/runner_test.go index db65a2c..deddfde 100644 --- a/backend-api/internal/platform/migration/runner_test.go +++ b/backend-api/internal/platform/migration/runner_test.go @@ -27,19 +27,20 @@ func TestRunnerSupportsUpStatusDownAndIdempotentUp(t *testing.T) { if err != nil { t.Fatalf("Up() error = %v", err) } - if applied != 9 { - t.Fatalf("Up() applied = %d, want 9", applied) + if applied != 10 { + t.Fatalf("Up() applied = %d, want 10", applied) } assertStatuses(t, runner, map[int64]bool{ - 1: true, - 2: true, - 3: true, - 4: true, - 5: true, - 6: true, - 7: true, - 8: true, - 9: true, + 1: true, + 2: true, + 3: true, + 4: true, + 5: true, + 6: true, + 7: true, + 8: true, + 9: true, + 10: true, }) applied, err = runner.Up(context.Background()) @@ -54,15 +55,16 @@ func TestRunnerSupportsUpStatusDownAndIdempotentUp(t *testing.T) { t.Fatalf("Down() error = %v", err) } assertStatuses(t, runner, map[int64]bool{ - 1: true, - 2: true, - 3: true, - 4: true, - 5: true, - 6: true, - 7: true, - 8: true, - 9: false, + 1: true, + 2: true, + 3: true, + 4: true, + 5: true, + 6: true, + 7: true, + 8: true, + 9: true, + 10: false, }) applied, err = runner.Up(context.Background()) @@ -73,15 +75,16 @@ func TestRunnerSupportsUpStatusDownAndIdempotentUp(t *testing.T) { t.Fatalf("final Up() applied = %d, want 1", applied) } assertStatuses(t, runner, map[int64]bool{ - 1: true, - 2: true, - 3: true, - 4: true, - 5: true, - 6: true, - 7: true, - 8: true, - 9: true, + 1: true, + 2: true, + 3: true, + 4: true, + 5: true, + 6: true, + 7: true, + 8: true, + 9: true, + 10: true, }) } diff --git a/backend-api/internal/repository/sqlite/auth_repository_test.go b/backend-api/internal/repository/sqlite/auth_repository_test.go index a6e0bc3..5af96ff 100644 --- a/backend-api/internal/repository/sqlite/auth_repository_test.go +++ b/backend-api/internal/repository/sqlite/auth_repository_test.go @@ -383,6 +383,9 @@ func TestAuthMigrationCanRollbackWithoutRebuildingPurchaseTasks( if err != nil { t.Fatalf("migration.New() error = %v", err) } + if err := runner.Down(context.Background()); err != nil { + t.Fatalf("Down(v10) error = %v", err) + } if err := runner.Down(context.Background()); err != nil { t.Fatalf("Down(v9) error = %v", err) } @@ -417,9 +420,9 @@ func TestAuthMigrationCanRollbackWithoutRebuildingPurchaseTasks( t.Fatal("purchase_tasks was lost during auth migration rollback") } if applied, err := runner.Up(context.Background()); err != nil { - t.Fatalf("Up(v3-v9) error = %v", err) - } else if applied != 7 { - t.Fatalf("Up(v3-v9) applied = %d, want 7", applied) + t.Fatalf("Up(v3-v10) error = %v", err) + } else if applied != 8 { + t.Fatalf("Up(v3-v10) applied = %d, want 8", applied) } } diff --git a/backend-api/internal/repository/sqlite/device_order_command_repository.go b/backend-api/internal/repository/sqlite/device_order_command_repository.go index 8b49156..1ba1378 100644 --- a/backend-api/internal/repository/sqlite/device_order_command_repository.go +++ b/backend-api/internal/repository/sqlite/device_order_command_repository.go @@ -108,14 +108,15 @@ func (s *Store) PullDeviceOrderCommand( } command.AuthorizationStatus = domain.OrderAuthorizationDelivered case domain.OrderAuthorizationDelivered, - domain.OrderAuthorizationAcknowledged: + domain.OrderAuthorizationAcknowledged, + domain.OrderAuthorizationExecuting: result, err := tx.ExecContext( ctx, `UPDATE order_authorizations SET last_delivered_at = ?, delivery_attempt_count = delivery_attempt_count + 1 WHERE id = ? - AND status IN ('DELIVERED', 'ACKNOWLEDGED') + AND status IN ('DELIVERED', 'ACKNOWLEDGED', 'EXECUTING') AND command_sha256 = ?`, formatTimestamp(write.Now), authorization.ID, @@ -389,7 +390,8 @@ func findActiveDeviceOrderAuthorization( AND oa.status IN ( 'PENDING_DELIVERY', 'DELIVERED', - 'ACKNOWLEDGED' + 'ACKNOWLEDGED', + 'EXECUTING' ) ORDER BY oa.authorization_version DESC LIMIT 1`, diff --git a/backend-api/internal/repository/sqlite/order_dry_run_repository.go b/backend-api/internal/repository/sqlite/order_dry_run_repository.go new file mode 100644 index 0000000..41df3a6 --- /dev/null +++ b/backend-api/internal/repository/sqlite/order_dry_run_repository.go @@ -0,0 +1,521 @@ +package sqlite + +import ( + "context" + "database/sql" + "errors" + "math" + "regexp" + "strconv" + "strings" + "time" + "unicode" + + "cmroubao/backend-api/internal/domain" + "cmroubao/backend-api/internal/usecase" +) + +func (s *Store) StartOrderDryRun( + ctx context.Context, + write usecase.StartOrderDryRunWrite, +) (domain.OrderDryRun, bool, error) { + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return domain.OrderDryRun{}, false, repositoryFailure(err) + } + defer func() { _ = tx.Rollback() }() + if result, found, err := replayOrderDryRunRequest( + ctx, tx, write.DeviceID, "START", write.IdempotencyKey, + write.RequestSHA256, write.TaskID, + ); err != nil || found { + return commitOrderDryRunReplay(tx, result, found, err) + } + if err := validateDeviceOrderCommandClaim( + ctx, tx, write.UserID, write.DeviceID, write.TaskID, + write.ExecutionID, write.ClaimGeneration, write.ClaimTokenHash, + write.Now, + ); err != nil { + return domain.OrderDryRun{}, false, err + } + authorization, err := getOrderAuthorization( + ctx, tx, write.AuthorizationID, + ) + if err != nil { + return domain.OrderDryRun{}, false, err + } + if err := validateDryRunAuthorization( + authorization, write.UserID, write.DeviceID, write.TaskID, + write.ExecutionID, write.ClaimGeneration, write.CommandSHA256, + ); err != nil { + return domain.OrderDryRun{}, false, err + } + dryRun, found, err := getOrderDryRunByAuthorization( + ctx, tx, authorization.ID, + ) + if err != nil { + return domain.OrderDryRun{}, false, err + } + switch authorization.Status { + case domain.OrderAuthorizationAcknowledged: + if found { + return domain.OrderDryRun{}, false, usecase.ErrRepositoryInvariant + } + result, err := tx.ExecContext( + ctx, + `UPDATE order_authorizations + SET status = 'EXECUTING', execution_started_at = ? + WHERE id = ? AND status = 'ACKNOWLEDGED' + AND command_sha256 = ?`, + formatTimestamp(write.Now), + authorization.ID, + write.CommandSHA256, + ) + if err != nil { + return domain.OrderDryRun{}, false, repositoryFailure(err) + } + if affected, err := result.RowsAffected(); err != nil || affected != 1 { + if err != nil { + return domain.OrderDryRun{}, false, repositoryFailure(err) + } + return domain.OrderDryRun{}, false, usecase.ErrTaskStateConflict + } + _, err = tx.ExecContext( + ctx, + `INSERT INTO order_dry_runs ( + id, authorization_id, task_id, execution_id, + command_sha256, status, started_at + ) VALUES (?, ?, ?, ?, ?, 'PREPARING', ?)`, + write.DryRunID, + authorization.ID, + write.TaskID, + write.ExecutionID, + write.CommandSHA256, + formatTimestamp(write.Now), + ) + if err != nil { + return domain.OrderDryRun{}, false, repositoryFailure(err) + } + if err := insertTaskEvent(ctx, tx, write.Event); err != nil { + return domain.OrderDryRun{}, false, err + } + dryRun, found, err = getOrderDryRunByAuthorization( + ctx, tx, authorization.ID, + ) + if err != nil || !found { + if err != nil { + return domain.OrderDryRun{}, false, err + } + return domain.OrderDryRun{}, false, usecase.ErrRepositoryInvariant + } + case domain.OrderAuthorizationExecuting: + if !found || dryRun.CommandSHA256 != write.CommandSHA256 { + return domain.OrderDryRun{}, false, usecase.ErrTaskStateConflict + } + default: + return domain.OrderDryRun{}, false, usecase.ErrTaskStateConflict + } + if err := insertOrderDryRunRequest( + ctx, tx, write.DeviceID, "START", write.IdempotencyKey, + write.RequestSHA256, write.TaskID, dryRun.ID, write.Now, + ); err != nil { + return domain.OrderDryRun{}, false, err + } + if err := tx.Commit(); err != nil { + return domain.OrderDryRun{}, false, repositoryFailure(err) + } + return dryRun, false, nil +} + +func (s *Store) ReadyOrderDryRun( + ctx context.Context, + write usecase.ReadyOrderDryRunWrite, +) (domain.OrderDryRun, bool, error) { + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return domain.OrderDryRun{}, false, repositoryFailure(err) + } + defer func() { _ = tx.Rollback() }() + if result, found, err := replayOrderDryRunRequest( + ctx, tx, write.DeviceID, "READY", write.IdempotencyKey, + write.RequestSHA256, write.TaskID, + ); err != nil || found { + return commitOrderDryRunReplay(tx, result, found, err) + } + if err := validateDeviceOrderCommandClaim( + ctx, tx, write.UserID, write.DeviceID, write.TaskID, + write.ExecutionID, write.ClaimGeneration, write.ClaimTokenHash, + write.Now, + ); err != nil { + return domain.OrderDryRun{}, false, err + } + authorization, err := getOrderAuthorization( + ctx, tx, write.AuthorizationID, + ) + if err != nil { + return domain.OrderDryRun{}, false, err + } + if err := validateDryRunAuthorization( + authorization, write.UserID, write.DeviceID, write.TaskID, + write.ExecutionID, write.ClaimGeneration, write.CommandSHA256, + ); err != nil { + return domain.OrderDryRun{}, false, err + } + if authorization.Status != domain.OrderAuthorizationExecuting { + return domain.OrderDryRun{}, false, usecase.ErrTaskStateConflict + } + dryRun, found, err := getOrderDryRunByAuthorization( + ctx, tx, authorization.ID, + ) + if err != nil { + return domain.OrderDryRun{}, false, err + } + if !found || dryRun.CommandSHA256 != write.CommandSHA256 { + return domain.OrderDryRun{}, false, usecase.ErrTaskStateConflict + } + if dryRun.Status == domain.OrderDryRunReady { + return domain.OrderDryRun{}, false, usecase.ErrTaskStateConflict + } + if write.Quantity != authorization.Quantity || + write.SelectedSKU != authorization.OriginalSKU || + (write.CardSignature != authorization.CardSignature && + write.DetailSignature != authorization.DetailSignature) { + return domain.OrderDryRun{}, false, usecase.ErrTaskStateConflict + } + if write.UnitPriceCents > math.MaxInt64/int64(write.Quantity) || + write.TotalPriceCents != + write.UnitPriceCents*int64(write.Quantity) { + return domain.OrderDryRun{}, false, usecase.ErrTaskStateConflict + } + var observedTitle string + err = tx.QueryRowContext( + ctx, + `SELECT co.title + FROM candidate_observation_identities coi + JOIN candidate_observations co + ON co.execution_id = coi.execution_id + AND co.ordinal = coi.candidate_ordinal + WHERE coi.candidate_key = ? AND coi.execution_id = ?`, + authorization.CandidateKey, + authorization.ExecutionID, + ).Scan(&observedTitle) + if errors.Is(err, sql.ErrNoRows) { + return domain.OrderDryRun{}, false, usecase.ErrRepositoryInvariant + } + if err != nil { + return domain.OrderDryRun{}, false, repositoryFailure(err) + } + if normalizeDryRunTitle(observedTitle) != + normalizeDryRunTitle(write.ObservedTitle) { + return domain.OrderDryRun{}, false, usecase.ErrTaskStateConflict + } + task, err := getClaimProtectedTask(ctx, tx, write.TaskID) + if err != nil { + return domain.OrderDryRun{}, false, err + } + if task.MaxBudgetCents != nil && + write.TotalPriceCents > *task.MaxBudgetCents { + return domain.OrderDryRun{}, false, usecase.ErrTaskStateConflict + } + if task.MaxBudgetCents == nil { + authorizedPrice, ok := parseDryRunPrice( + authorization.CandidatePriceText, + ) + if !ok || write.UnitPriceCents != authorizedPrice { + return domain.OrderDryRun{}, false, usecase.ErrTaskStateConflict + } + } + evidence, err := getExecutionEvidence( + ctx, tx, write.EvidenceAssetID, + ) + if err != nil { + return domain.OrderDryRun{}, false, err + } + if evidence.TaskID != write.TaskID || + evidence.ExecutionID != write.ExecutionID || + evidence.MediaType != "image/jpeg" || + evidence.SHA256 != write.EvidenceSHA256 || + evidence.ReceivedAfterExecutionExpiry { + return domain.OrderDryRun{}, false, usecase.ErrTaskStateConflict + } + result, err := tx.ExecContext( + ctx, + `UPDATE order_dry_runs + SET status = 'READY', + card_signature = ?, + detail_signature = ?, + observed_title = ?, + selected_sku = ?, + quantity = ?, + unit_price_cents = ?, + total_price_cents = ?, + evidence_asset_id = ?, + evidence_sha256 = ?, + ready_at = ? + WHERE id = ? AND status = 'PREPARING'`, + write.CardSignature, + write.DetailSignature, + write.ObservedTitle, + write.SelectedSKU, + write.Quantity, + write.UnitPriceCents, + write.TotalPriceCents, + write.EvidenceAssetID, + write.EvidenceSHA256, + formatTimestamp(write.Now), + dryRun.ID, + ) + if err != nil { + return domain.OrderDryRun{}, false, repositoryFailure(err) + } + if affected, err := result.RowsAffected(); err != nil || affected != 1 { + if err != nil { + return domain.OrderDryRun{}, false, repositoryFailure(err) + } + return domain.OrderDryRun{}, false, usecase.ErrTaskStateConflict + } + if err := insertTaskEvent(ctx, tx, write.Event); err != nil { + return domain.OrderDryRun{}, false, err + } + if err := insertOrderDryRunRequest( + ctx, tx, write.DeviceID, "READY", write.IdempotencyKey, + write.RequestSHA256, write.TaskID, dryRun.ID, write.Now, + ); err != nil { + return domain.OrderDryRun{}, false, err + } + dryRun, found, err = getOrderDryRunByAuthorization( + ctx, tx, authorization.ID, + ) + if err != nil || !found { + if err != nil { + return domain.OrderDryRun{}, false, err + } + return domain.OrderDryRun{}, false, usecase.ErrRepositoryInvariant + } + if err := tx.Commit(); err != nil { + return domain.OrderDryRun{}, false, repositoryFailure(err) + } + return dryRun, false, nil +} + +func validateDryRunAuthorization( + authorization domain.OrderAuthorization, + userID, deviceID, taskID, executionID string, + claimGeneration int64, + commandSHA256 string, +) error { + if authorization.UserID != userID || + authorization.DeviceID != deviceID || + authorization.TaskID != taskID || + authorization.ExecutionID != executionID || + authorization.ClaimGeneration != claimGeneration || + authorization.CommandSHA256 == nil || + *authorization.CommandSHA256 != commandSHA256 { + return usecase.ErrExecutionMismatch + } + return nil +} + +func normalizeDryRunTitle(value string) string { + return strings.Map(func(character rune) rune { + if unicode.IsSpace(character) || unicode.IsPunct(character) { + return -1 + } + return unicode.ToLower(character) + }, value) +} + +func parseDryRunPrice(value string) (int64, bool) { + matches := dryRunPricePattern.FindStringSubmatch(strings.TrimSpace(value)) + if len(matches) != 3 { + return 0, false + } + whole, err := strconv.ParseInt(matches[1], 10, 64) + if err != nil { + return 0, false + } + fractionText := matches[2] + if len(fractionText) == 1 { + fractionText += "0" + } + fraction := int64(0) + if fractionText != "" { + fraction, err = strconv.ParseInt(fractionText, 10, 64) + if err != nil { + return 0, false + } + } + cents := whole*100 + fraction + return cents, cents > 0 && cents <= 100_000_000 +} + +var dryRunPricePattern = regexp.MustCompile( + `^(?:首件)?[¥¥]?(0|[1-9][0-9]{0,6})(?:\.([0-9]{1,2}))?$`, +) + +func getOrderDryRunByAuthorization( + ctx context.Context, + queryer queryRower, + authorizationID string, +) (domain.OrderDryRun, bool, error) { + var result domain.OrderDryRun + var card, detail, title, sku sql.NullString + var quantity sql.NullInt64 + var unitPrice, totalPrice sql.NullInt64 + var evidenceID, evidenceSHA, readyAt sql.NullString + var startedAt string + err := queryer.QueryRowContext( + ctx, + `SELECT id, authorization_id, task_id, execution_id, + command_sha256, status, card_signature, detail_signature, + observed_title, selected_sku, quantity, unit_price_cents, + total_price_cents, evidence_asset_id, evidence_sha256, + started_at, ready_at + FROM order_dry_runs WHERE authorization_id = ?`, + authorizationID, + ).Scan( + &result.ID, + &result.AuthorizationID, + &result.TaskID, + &result.ExecutionID, + &result.CommandSHA256, + &result.Status, + &card, + &detail, + &title, + &sku, + &quantity, + &unitPrice, + &totalPrice, + &evidenceID, + &evidenceSHA, + &startedAt, + &readyAt, + ) + if errors.Is(err, sql.ErrNoRows) { + return domain.OrderDryRun{}, false, nil + } + if err != nil { + return domain.OrderDryRun{}, false, repositoryFailure(err) + } + result.CardSignature = dryRunOptionalString(card) + result.DetailSignature = dryRunOptionalString(detail) + result.ObservedTitle = dryRunOptionalString(title) + result.SelectedSKU = dryRunOptionalString(sku) + if quantity.Valid { + value := int(quantity.Int64) + result.Quantity = &value + } + if unitPrice.Valid { + result.UnitPriceCents = &unitPrice.Int64 + } + if totalPrice.Valid { + result.TotalPriceCents = &totalPrice.Int64 + } + result.EvidenceAssetID = dryRunOptionalString(evidenceID) + result.EvidenceSHA256 = dryRunOptionalString(evidenceSHA) + parsed, err := parseTimestamp(startedAt) + if err != nil { + return domain.OrderDryRun{}, false, repositoryFailure(err) + } + result.StartedAt = parsed + if readyAt.Valid { + parsed, parseErr := parseTimestamp(readyAt.String) + if parseErr != nil { + return domain.OrderDryRun{}, false, repositoryFailure(parseErr) + } + result.ReadyAt = &parsed + } + return result, true, nil +} + +func dryRunOptionalString(value sql.NullString) *string { + if !value.Valid { + return nil + } + return &value.String +} + +func replayOrderDryRunRequest( + ctx context.Context, + tx *sql.Tx, + deviceID, operation, idempotencyKey, requestSHA256, taskID string, +) (domain.OrderDryRun, bool, error) { + var knownHash, knownTask, dryRunID string + err := tx.QueryRowContext( + ctx, + `SELECT request_sha256, task_id, dry_run_id + FROM device_order_dry_run_requests + WHERE device_id = ? AND operation = ? AND idempotency_key = ?`, + deviceID, + operation, + idempotencyKey, + ).Scan(&knownHash, &knownTask, &dryRunID) + if errors.Is(err, sql.ErrNoRows) { + return domain.OrderDryRun{}, false, nil + } + if err != nil { + return domain.OrderDryRun{}, false, repositoryFailure(err) + } + if knownHash != requestSHA256 || knownTask != taskID { + return domain.OrderDryRun{}, false, usecase.ErrIdempotencyConflict + } + var authorizationID string + if err := tx.QueryRowContext( + ctx, + `SELECT authorization_id FROM order_dry_runs WHERE id = ?`, + dryRunID, + ).Scan(&authorizationID); err != nil { + return domain.OrderDryRun{}, false, repositoryFailure(err) + } + result, found, err := getOrderDryRunByAuthorization( + ctx, tx, authorizationID, + ) + if err != nil || !found { + if err != nil { + return domain.OrderDryRun{}, false, err + } + return domain.OrderDryRun{}, false, usecase.ErrRepositoryInvariant + } + return result, true, nil +} + +func commitOrderDryRunReplay( + tx *sql.Tx, + result domain.OrderDryRun, + found bool, + err error, +) (domain.OrderDryRun, bool, error) { + if err != nil || !found { + return domain.OrderDryRun{}, false, err + } + if err := tx.Commit(); err != nil { + return domain.OrderDryRun{}, false, repositoryFailure(err) + } + return result, true, nil +} + +func insertOrderDryRunRequest( + ctx context.Context, + tx *sql.Tx, + deviceID, operation, idempotencyKey, requestSHA256, taskID, dryRunID string, + now time.Time, +) error { + _, err := tx.ExecContext( + ctx, + `INSERT INTO device_order_dry_run_requests ( + device_id, operation, idempotency_key, request_sha256, + task_id, dry_run_id, created_at + ) VALUES (?, ?, ?, ?, ?, ?, ?)`, + deviceID, + operation, + idempotencyKey, + requestSHA256, + taskID, + dryRunID, + formatTimestamp(now.UTC()), + ) + if err != nil { + return repositoryFailure(err) + } + return nil +} diff --git a/backend-api/internal/transport/httpapi/admin_handlers_test.go b/backend-api/internal/transport/httpapi/admin_handlers_test.go index 9c58a83..33e8256 100644 --- a/backend-api/internal/transport/httpapi/admin_handlers_test.go +++ b/backend-api/internal/transport/httpapi/admin_handlers_test.go @@ -410,6 +410,9 @@ func TestAdminOrderAuthorizationIsIdempotentAndRevisioned(t *testing.T) { if err != nil { t.Fatalf("migration.New() error = %v", err) } + if err := runner.Down(context.Background()); err != nil { + t.Fatalf("order dry-run migration down: %v", err) + } if err := runner.Down(context.Background()); err != nil { t.Fatalf("device command migration down: %v", err) } diff --git a/backend-api/internal/transport/httpapi/device_handlers.go b/backend-api/internal/transport/httpapi/device_handlers.go index 3a24b91..e75ee5b 100644 --- a/backend-api/internal/transport/httpapi/device_handlers.go +++ b/backend-api/internal/transport/httpapi/device_handlers.go @@ -21,11 +21,13 @@ type DeviceServices struct { Assets *usecase.AssetService Results *usecase.ExecutionResultService Commands *usecase.DeviceOrderCommandService + DryRuns *usecase.OrderDryRunService } func (services DeviceServices) validate() error { if services.Lifecycle == nil || services.Assets == nil || - services.Results == nil || services.Commands == nil { + services.Results == nil || services.Commands == nil || + services.DryRuns == nil { return errors.New("device services are required") } return nil @@ -83,12 +85,141 @@ func NewDeviceRouteRegistrar( "/api/v1/tasks/:id/commands/:command_id/ack", handler.acknowledgeOrderCommand, ) + routes.POST( + "/api/v1/tasks/:id/order-dry-runs/start", + handler.startOrderDryRun, + ) + routes.POST( + "/api/v1/tasks/:id/order-dry-runs/:command_id/ready", + handler.readyOrderDryRun, + ) routes.POST("/api/v1/tasks/:id/complete", handler.completeTask) routes.POST("/api/v1/tasks/:id/fail", handler.failTask) return nil }, nil } +func (handler *deviceHandlers) startOrderDryRun(ctx *gin.Context) { + principal, ok := devicePrincipal(ctx) + if !ok { + return + } + var request struct { + DeviceID string `json:"device_id"` + ExecutionID string `json:"execution_id"` + ClaimGeneration int64 `json:"claim_generation"` + CommandID string `json:"command_id"` + CommandSHA256 string `json:"command_sha256"` + } + if !decodeDeviceJSON(ctx, &request) || + !deviceIDMatches(ctx, request.DeviceID, principal.DeviceID) { + return + } + result, err := handler.services.DryRuns.Start( + ctx.Request.Context(), + usecase.StartOrderDryRunCommand{ + UserID: principal.UserID, + DeviceID: principal.DeviceID, + TaskID: ctx.Param("id"), + ExecutionID: request.ExecutionID, + AuthorizationID: request.CommandID, + ClaimGeneration: request.ClaimGeneration, + ClaimToken: ctx.GetHeader(claimTokenHeader), + CommandSHA256: request.CommandSHA256, + IdempotencyKey: ctx.GetHeader("Idempotency-Key"), + }, + ) + if err != nil { + writeUsecaseError(ctx, err) + return + } + ctx.Header("Cache-Control", "no-store") + ctx.JSON(http.StatusOK, gin.H{ + "dry_run": orderDryRunResponse(result.DryRun), + "replayed": result.Replayed, + }) +} + +func (handler *deviceHandlers) readyOrderDryRun(ctx *gin.Context) { + principal, ok := devicePrincipal(ctx) + if !ok { + return + } + var request struct { + DeviceID string `json:"device_id"` + ExecutionID string `json:"execution_id"` + ClaimGeneration int64 `json:"claim_generation"` + CommandSHA256 string `json:"command_sha256"` + CardSignature string `json:"card_signature"` + DetailSignature string `json:"detail_signature"` + ObservedTitle string `json:"observed_title"` + SelectedSKU string `json:"selected_sku"` + Quantity int `json:"quantity"` + UnitPriceCents int64 `json:"unit_price_cents"` + TotalPriceCents int64 `json:"total_price_cents"` + EvidenceAssetID string `json:"evidence_asset_id"` + EvidenceSHA256 string `json:"evidence_sha256"` + } + if !decodeDeviceJSON(ctx, &request) || + !deviceIDMatches(ctx, request.DeviceID, principal.DeviceID) { + return + } + result, err := handler.services.DryRuns.Ready( + ctx.Request.Context(), + usecase.ReadyOrderDryRunCommand{ + UserID: principal.UserID, + DeviceID: principal.DeviceID, + TaskID: ctx.Param("id"), + ExecutionID: request.ExecutionID, + AuthorizationID: ctx.Param("command_id"), + ClaimGeneration: request.ClaimGeneration, + ClaimToken: ctx.GetHeader(claimTokenHeader), + CommandSHA256: request.CommandSHA256, + CardSignature: request.CardSignature, + DetailSignature: request.DetailSignature, + ObservedTitle: request.ObservedTitle, + SelectedSKU: request.SelectedSKU, + Quantity: request.Quantity, + UnitPriceCents: request.UnitPriceCents, + TotalPriceCents: request.TotalPriceCents, + EvidenceAssetID: request.EvidenceAssetID, + EvidenceSHA256: request.EvidenceSHA256, + IdempotencyKey: ctx.GetHeader("Idempotency-Key"), + }, + ) + if err != nil { + writeUsecaseError(ctx, err) + return + } + ctx.Header("Cache-Control", "no-store") + ctx.JSON(http.StatusOK, gin.H{ + "dry_run": orderDryRunResponse(result.DryRun), + "replayed": result.Replayed, + }) +} + +func orderDryRunResponse(dryRun domain.OrderDryRun) gin.H { + return gin.H{ + "id": dryRun.ID, + "command_id": dryRun.AuthorizationID, + "task_id": dryRun.TaskID, + "execution_id": dryRun.ExecutionID, + "command_sha256": dryRun.CommandSHA256, + "status": dryRun.Status, + "card_signature": dryRun.CardSignature, + "detail_signature": dryRun.DetailSignature, + "observed_title": dryRun.ObservedTitle, + "selected_sku": dryRun.SelectedSKU, + "quantity": dryRun.Quantity, + "unit_price_cents": dryRun.UnitPriceCents, + "total_price_cents": dryRun.TotalPriceCents, + "evidence_asset_id": dryRun.EvidenceAssetID, + "evidence_sha256": dryRun.EvidenceSHA256, + "started_at": formatTime(dryRun.StartedAt), + "ready_at": formatOptionalTime(dryRun.ReadyAt), + } +} + func (handler *deviceHandlers) pullOrderCommand(ctx *gin.Context) { principal, ok := devicePrincipal(ctx) if !ok { diff --git a/backend-api/internal/transport/httpapi/device_handlers_test.go b/backend-api/internal/transport/httpapi/device_handlers_test.go index 057d421..8768da0 100644 --- a/backend-api/internal/transport/httpapi/device_handlers_test.go +++ b/backend-api/internal/transport/httpapi/device_handlers_test.go @@ -713,6 +713,9 @@ func TestDeviceExecutionResultsAreIdempotentAndAuditable(t *testing.T) { if err != nil { t.Fatalf("migration.New() after review error = %v", err) } + if err := runner.Down(context.Background()); err != nil { + t.Fatalf("order dry-run migration down: %v", err) + } if err := runner.Down(context.Background()); err != nil { t.Fatalf("device command migration down: %v", err) } @@ -721,8 +724,8 @@ func TestDeviceExecutionResultsAreIdempotentAndAuditable(t *testing.T) { } if applied, err := runner.Up(context.Background()); err != nil { t.Fatalf("restore device command migration: %v", err) - } else if applied != 1 { - t.Fatalf("restored migrations = %d, want 1", applied) + } else if applied != 2 { + t.Fatalf("restored migrations = %d, want 2", applied) } completePayload := fmt.Sprintf( @@ -885,6 +888,7 @@ func TestDeviceOrderCommandDeliveryAndAcknowledgementAreRecoverable( SchemaVersion int `json:"schema_version"` TaskID string `json:"task_id"` ExecutionID string `json:"execution_id"` + OriginalSKU string `json:"original_sku"` Quantity int `json:"quantity"` CommandSHA256 string `json:"command_sha256"` AuthorizationStatus string `json:"authorization_status"` @@ -905,6 +909,7 @@ func TestDeviceOrderCommandDeliveryAndAcknowledgementAreRecoverable( command.SchemaVersion != 1 || command.TaskID != taskID || command.ExecutionID != started.Execution.ID || + command.OriginalSKU == "" || command.Quantity != 2 || len(command.CommandSHA256) != 64 || command.AuthorizationStatus != "DELIVERED" || @@ -997,10 +1002,149 @@ func TestDeviceOrderCommandDeliveryAndAcknowledgementAreRecoverable( ) { t.Fatalf("acknowledged pull = %s", acknowledgedPull.Body.String()) } - var deliveredEvents, acknowledgedEvents int + startDryRunPayload := fmt.Sprintf( + `{"device_id":%q,"execution_id":%q,"claim_generation":%d,"command_id":%q,"command_sha256":%q}`, + deviceTestDeviceID, + started.Execution.ID, + started.Task.ClaimGeneration, + command.ID, + command.CommandSHA256, + ) + startDryRun := performDeviceRequest(t, fixture.router, deviceRequest{ + method: http.MethodPost, + target: "/api/v1/tasks/" + taskID + "/order-dry-runs/start", + contentType: "application/json", + body: strings.NewReader(startDryRunPayload), + bearerToken: testOpaqueToken, + claimToken: testOpaqueToken, + idempotencyKey: "order-dry-run-start", + }) + requireDeviceStatus(t, startDryRun, http.StatusOK) + if !strings.Contains(startDryRun.Body.String(), `"status":"PREPARING"`) { + t.Fatalf("dry-run start = %s", startDryRun.Body.String()) + } + startDryRunReplay := performDeviceRequest(t, fixture.router, deviceRequest{ + method: http.MethodPost, + target: "/api/v1/tasks/" + taskID + "/order-dry-runs/start", + contentType: "application/json", + body: strings.NewReader(startDryRunPayload), + bearerToken: testOpaqueToken, + claimToken: testOpaqueToken, + idempotencyKey: "order-dry-run-start", + }) + requireDeviceStatus(t, startDryRunReplay, http.StatusOK) + if !strings.Contains(startDryRunReplay.Body.String(), `"replayed":true`) { + t.Fatalf("dry-run start replay = %s", startDryRunReplay.Body.String()) + } + executingPull := performDeviceRequest(t, fixture.router, deviceRequest{ + method: http.MethodPost, + target: "/api/v1/tasks/" + taskID + "/commands/next", + contentType: "application/json", + body: strings.NewReader(pullPayload), + bearerToken: testOpaqueToken, + claimToken: testOpaqueToken, + }) + requireDeviceStatus(t, executingPull, http.StatusOK) + if !strings.Contains( + executingPull.Body.String(), + `"authorization_status":"EXECUTING"`, + ) { + t.Fatalf("executing pull = %s", executingPull.Body.String()) + } + const dryRunEvidenceID = "00000000-0000-4000-8000-000000000077" + dryRunEvidenceSHA := strings.Repeat("e", 64) + if _, err := fixture.db.Exec( + `INSERT INTO execution_evidence_assets ( + id, task_id, execution_id, media_type, size_bytes, sha256, + storage_key, created_at, received_after_execution_expiry + ) VALUES (?, ?, ?, 'image/jpeg', 10, ?, ?, ?, 0)`, + dryRunEvidenceID, + taskID, + started.Execution.ID, + dryRunEvidenceSHA, + "dry-run/order-confirmation.jpg", + time.Now().UTC().Format(time.RFC3339Nano), + ); err != nil { + t.Fatalf("seed dry-run evidence: %v", err) + } + readyDryRunPayload := fmt.Sprintf( + `{"device_id":%q,"execution_id":%q,"claim_generation":%d,"command_sha256":%q,"card_signature":%q,"detail_signature":%q,"observed_title":%q,"selected_sku":%q,"quantity":2,"unit_price_cents":2150,"total_price_cents":4300,"evidence_asset_id":%q,"evidence_sha256":%q}`, + deviceTestDeviceID, + started.Execution.ID, + started.Task.ClaimGeneration, + command.CommandSHA256, + command.Candidate.CardSignature, + strings.Repeat("f", 64), + command.Candidate.Title, + command.OriginalSKU, + dryRunEvidenceID, + dryRunEvidenceSHA, + ) + badSKUDryRun := performDeviceRequest(t, fixture.router, deviceRequest{ + method: http.MethodPost, + target: "/api/v1/tasks/" + taskID + + "/order-dry-runs/" + command.ID + "/ready", + contentType: "application/json", + body: strings.NewReader(strings.Replace( + readyDryRunPayload, + fmt.Sprintf(`"selected_sku":%q`, command.OriginalSKU), + `"selected_sku":"wrong-sku"`, + 1, + )), + bearerToken: testOpaqueToken, + claimToken: testOpaqueToken, + idempotencyKey: "order-dry-run-ready-bad-sku", + }) + requireDeviceStatus(t, badSKUDryRun, http.StatusConflict) + badTotalDryRun := performDeviceRequest(t, fixture.router, deviceRequest{ + method: http.MethodPost, + target: "/api/v1/tasks/" + taskID + + "/order-dry-runs/" + command.ID + "/ready", + contentType: "application/json", + body: strings.NewReader(strings.Replace( + readyDryRunPayload, + `"total_price_cents":4300`, + `"total_price_cents":4301`, + 1, + )), + bearerToken: testOpaqueToken, + claimToken: testOpaqueToken, + idempotencyKey: "order-dry-run-ready-bad-total", + }) + requireDeviceStatus(t, badTotalDryRun, http.StatusConflict) + readyDryRun := performDeviceRequest(t, fixture.router, deviceRequest{ + method: http.MethodPost, + target: "/api/v1/tasks/" + taskID + "/order-dry-runs/" + command.ID + "/ready", + contentType: "application/json", + body: strings.NewReader(readyDryRunPayload), + bearerToken: testOpaqueToken, + claimToken: testOpaqueToken, + idempotencyKey: "order-dry-run-ready", + }) + requireDeviceStatus(t, readyDryRun, http.StatusOK) + if !strings.Contains(readyDryRun.Body.String(), `"status":"READY"`) { + t.Fatalf("dry-run ready = %s", readyDryRun.Body.String()) + } + readyDryRunReplay := performDeviceRequest(t, fixture.router, deviceRequest{ + method: http.MethodPost, + target: "/api/v1/tasks/" + taskID + + "/order-dry-runs/" + command.ID + "/ready", + contentType: "application/json", + body: strings.NewReader(readyDryRunPayload), + bearerToken: testOpaqueToken, + claimToken: testOpaqueToken, + idempotencyKey: "order-dry-run-ready", + }) + requireDeviceStatus(t, readyDryRunReplay, http.StatusOK) + if !strings.Contains(readyDryRunReplay.Body.String(), `"replayed":true`) { + t.Fatalf("dry-run ready replay = %s", readyDryRunReplay.Body.String()) + } + var deliveredEvents, acknowledgedEvents, dryRunStartedEvents, dryRunReadyEvents int for eventType, target := range map[string]*int{ "ORDER_AUTHORIZATION_DELIVERED": &deliveredEvents, "ORDER_AUTHORIZATION_ACKNOWLEDGED": &acknowledgedEvents, + "ORDER_DRY_RUN_STARTED": &dryRunStartedEvents, + "ORDER_DRY_RUN_READY": &dryRunReadyEvents, } { if err := fixture.db.QueryRow( `SELECT COUNT(*) FROM task_events @@ -1011,11 +1155,14 @@ func TestDeviceOrderCommandDeliveryAndAcknowledgementAreRecoverable( t.Fatalf("count %s events: %v", eventType, err) } } - if deliveredEvents != 1 || acknowledgedEvents != 1 { + if deliveredEvents != 1 || acknowledgedEvents != 1 || + dryRunStartedEvents != 1 || dryRunReadyEvents != 1 { t.Fatalf( - "delivery/ack events = %d/%d", + "delivery/ack/dry-run events = %d/%d/%d/%d", deliveredEvents, acknowledgedEvents, + dryRunStartedEvents, + dryRunReadyEvents, ) } runner, err := migration.New(fixture.db) @@ -1023,7 +1170,7 @@ func TestDeviceOrderCommandDeliveryAndAcknowledgementAreRecoverable( t.Fatalf("migration.New() error = %v", err) } if err := runner.Down(context.Background()); err == nil { - t.Fatal("device command migration down succeeded with command data") + t.Fatal("order dry-run migration down succeeded with dry-run data") } } @@ -1352,12 +1499,17 @@ func newDeviceHTTPFixture(t *testing.T) *deviceHTTPFixture { if err != nil { t.Fatalf("usecase.NewDeviceOrderCommandService() error = %v", err) } + dryRuns, err := usecase.NewOrderDryRunService(store, clock, ids) + if err != nil { + t.Fatalf("usecase.NewOrderDryRunService() error = %v", err) + } deviceRoutes, err := NewDeviceRouteRegistrar( DeviceServices{ Lifecycle: lifecycle, Assets: assets, Results: results, Commands: commands, + DryRuns: dryRuns, }, ) if err != nil { diff --git a/backend-api/internal/usecase/order_dry_run_service.go b/backend-api/internal/usecase/order_dry_run_service.go new file mode 100644 index 0000000..78a2690 --- /dev/null +++ b/backend-api/internal/usecase/order_dry_run_service.go @@ -0,0 +1,304 @@ +package usecase + +import ( + "context" + "errors" + "strings" + "time" + + "cmroubao/backend-api/internal/domain" +) + +type StartOrderDryRunCommand struct { + UserID string + DeviceID string + TaskID string + ExecutionID string + AuthorizationID string + ClaimGeneration int64 + ClaimToken string + CommandSHA256 string + IdempotencyKey string +} + +type StartOrderDryRunWrite struct { + StartOrderDryRunCommand + DryRunID string + ClaimTokenHash string + RequestSHA256 string + Now time.Time + Event domain.TaskEvent +} + +type ReadyOrderDryRunCommand struct { + UserID string + DeviceID string + TaskID string + ExecutionID string + AuthorizationID string + ClaimGeneration int64 + ClaimToken string + CommandSHA256 string + CardSignature string + DetailSignature string + ObservedTitle string + SelectedSKU string + Quantity int + UnitPriceCents int64 + TotalPriceCents int64 + EvidenceAssetID string + EvidenceSHA256 string + IdempotencyKey string +} + +type ReadyOrderDryRunWrite struct { + ReadyOrderDryRunCommand + ClaimTokenHash string + RequestSHA256 string + Now time.Time + Event domain.TaskEvent +} + +type OrderDryRunResult struct { + DryRun domain.OrderDryRun + Replayed bool +} + +type OrderDryRunRepository interface { + StartOrderDryRun( + context.Context, + StartOrderDryRunWrite, + ) (domain.OrderDryRun, bool, error) + ReadyOrderDryRun( + context.Context, + ReadyOrderDryRunWrite, + ) (domain.OrderDryRun, bool, error) +} + +type OrderDryRunService struct { + repository OrderDryRunRepository + clock Clock + ids IDGenerator +} + +func NewOrderDryRunService( + repository OrderDryRunRepository, + clock Clock, + ids IDGenerator, +) (*OrderDryRunService, error) { + if repository == nil || clock == nil || ids == nil { + return nil, errors.New("order dry-run service dependencies are required") + } + return &OrderDryRunService{ + repository: repository, + clock: clock, + ids: ids, + }, nil +} + +func (service *OrderDryRunService) Start( + ctx context.Context, + command StartOrderDryRunCommand, +) (OrderDryRunResult, error) { + command = normalizeStartOrderDryRunCommand(command) + fields := dryRunIdentityFields( + command.UserID, + command.DeviceID, + command.TaskID, + command.ExecutionID, + command.AuthorizationID, + command.ClaimGeneration, + command.ClaimToken, + command.CommandSHA256, + command.IdempotencyKey, + ) + if len(fields) > 0 { + return OrderDryRunResult{}, invalidError( + "ORDER_DRY_RUN_START_INVALID", + "order dry-run start request is invalid", + fields, + ) + } + requestHash, err := lifecycleRequestHash(command) + if err != nil { + return OrderDryRunResult{}, internalLifecycleFailure(err) + } + dryRunID, err := service.ids.NewID() + if err != nil { + return OrderDryRunResult{}, internalLifecycleFailure(err) + } + eventID, err := service.ids.NewID() + if err != nil { + return OrderDryRunResult{}, internalLifecycleFailure(err) + } + now := service.clock.Now().UTC() + userID, deviceID := command.UserID, command.DeviceID + dryRun, replayed, err := service.repository.StartOrderDryRun( + ctx, + StartOrderDryRunWrite{ + StartOrderDryRunCommand: command, + DryRunID: dryRunID, + ClaimTokenHash: hashSecret(command.ClaimToken), + RequestSHA256: requestHash, + Now: now, + Event: domain.TaskEvent{ + ID: eventID, + TaskID: command.TaskID, + ActorUserID: &userID, + ActorDeviceID: &deviceID, + Type: "ORDER_DRY_RUN_STARTED", + Message: "authorized order dry-run started", + OccurredAt: now, + }, + }, + ) + if err != nil { + return OrderDryRunResult{}, wrapLifecycleRepositoryError(err) + } + return OrderDryRunResult{DryRun: dryRun, Replayed: replayed}, nil +} + +func (service *OrderDryRunService) Ready( + ctx context.Context, + command ReadyOrderDryRunCommand, +) (OrderDryRunResult, error) { + command = normalizeReadyOrderDryRunCommand(command) + fields := dryRunIdentityFields( + command.UserID, + command.DeviceID, + command.TaskID, + command.ExecutionID, + command.AuthorizationID, + command.ClaimGeneration, + command.ClaimToken, + command.CommandSHA256, + command.IdempotencyKey, + ) + for name, value := range map[string]string{ + "card_signature": command.CardSignature, + "detail_signature": command.DetailSignature, + "evidence_sha256": command.EvidenceSHA256, + } { + if !sha256Pattern.MatchString(value) { + fields[name] = "must be lowercase SHA-256" + } + } + if strings.TrimSpace(command.ObservedTitle) == "" || + len([]byte(command.ObservedTitle)) > 1024 { + fields["observed_title"] = "must be 1 to 1024 UTF-8 bytes" + } + if strings.TrimSpace(command.SelectedSKU) == "" || + len([]byte(command.SelectedSKU)) > 512 { + fields["selected_sku"] = "must be 1 to 512 UTF-8 bytes" + } + if command.Quantity < 1 || command.Quantity > 99 { + fields["quantity"] = "must be 1 to 99" + } + if command.UnitPriceCents < 1 { + fields["unit_price_cents"] = "must be positive" + } + if command.TotalPriceCents < 1 { + fields["total_price_cents"] = "must be positive" + } + if !isUUID(command.EvidenceAssetID) { + fields["evidence_asset_id"] = "must be a UUID" + } + if len(fields) > 0 { + return OrderDryRunResult{}, invalidError( + "ORDER_DRY_RUN_READY_INVALID", + "order dry-run ready request is invalid", + fields, + ) + } + requestHash, err := lifecycleRequestHash(command) + if err != nil { + return OrderDryRunResult{}, internalLifecycleFailure(err) + } + eventID, err := service.ids.NewID() + if err != nil { + return OrderDryRunResult{}, internalLifecycleFailure(err) + } + now := service.clock.Now().UTC() + userID, deviceID := command.UserID, command.DeviceID + dryRun, replayed, err := service.repository.ReadyOrderDryRun( + ctx, + ReadyOrderDryRunWrite{ + ReadyOrderDryRunCommand: command, + ClaimTokenHash: hashSecret(command.ClaimToken), + RequestSHA256: requestHash, + Now: now, + Event: domain.TaskEvent{ + ID: eventID, + TaskID: command.TaskID, + ActorUserID: &userID, + ActorDeviceID: &deviceID, + Type: "ORDER_DRY_RUN_READY", + Message: "authorized order dry-run is ready for submit", + OccurredAt: now, + }, + }, + ) + if err != nil { + return OrderDryRunResult{}, wrapLifecycleRepositoryError(err) + } + return OrderDryRunResult{DryRun: dryRun, Replayed: replayed}, nil +} + +func dryRunIdentityFields( + userID, deviceID, taskID, executionID, authorizationID string, + claimGeneration int64, + claimToken, commandSHA256, idempotencyKey string, +) map[string]string { + fields := lifecycleClaimFields( + userID, + deviceID, + taskID, + claimGeneration, + claimToken, + ) + if !isUUID(executionID) { + fields["execution_id"] = "must be a UUID" + } + if !isUUID(authorizationID) { + fields["command_id"] = "must be a UUID" + } + if !sha256Pattern.MatchString(commandSHA256) { + fields["command_sha256"] = "must be lowercase SHA-256" + } + validateIdempotencyField(fields, idempotencyKey) + return fields +} + +func normalizeStartOrderDryRunCommand( + command StartOrderDryRunCommand, +) StartOrderDryRunCommand { + command.UserID = strings.TrimSpace(command.UserID) + command.DeviceID = strings.TrimSpace(command.DeviceID) + command.TaskID = strings.TrimSpace(command.TaskID) + command.ExecutionID = strings.TrimSpace(command.ExecutionID) + command.AuthorizationID = strings.TrimSpace(command.AuthorizationID) + command.ClaimToken = strings.TrimSpace(command.ClaimToken) + command.CommandSHA256 = strings.TrimSpace(command.CommandSHA256) + command.IdempotencyKey = strings.TrimSpace(command.IdempotencyKey) + return command +} + +func normalizeReadyOrderDryRunCommand( + command ReadyOrderDryRunCommand, +) ReadyOrderDryRunCommand { + command.UserID = strings.TrimSpace(command.UserID) + command.DeviceID = strings.TrimSpace(command.DeviceID) + command.TaskID = strings.TrimSpace(command.TaskID) + command.ExecutionID = strings.TrimSpace(command.ExecutionID) + command.AuthorizationID = strings.TrimSpace(command.AuthorizationID) + command.ClaimToken = strings.TrimSpace(command.ClaimToken) + command.CommandSHA256 = strings.TrimSpace(command.CommandSHA256) + command.CardSignature = strings.TrimSpace(command.CardSignature) + command.DetailSignature = strings.TrimSpace(command.DetailSignature) + command.ObservedTitle = strings.TrimSpace(command.ObservedTitle) + command.SelectedSKU = strings.TrimSpace(command.SelectedSKU) + command.EvidenceAssetID = strings.TrimSpace(command.EvidenceAssetID) + command.EvidenceSHA256 = strings.TrimSpace(command.EvidenceSHA256) + command.IdempotencyKey = strings.TrimSpace(command.IdempotencyKey) + return command +} diff --git a/backend-api/migrations/00010_order_dry_runs.sql b/backend-api/migrations/00010_order_dry_runs.sql new file mode 100644 index 0000000..b203aa6 --- /dev/null +++ b/backend-api/migrations/00010_order_dry_runs.sql @@ -0,0 +1,217 @@ +-- +goose Up +CREATE TABLE order_dry_runs ( + id TEXT PRIMARY KEY NOT NULL CHECK (length(id) = 36), + authorization_id TEXT NOT NULL UNIQUE + REFERENCES order_authorizations(id) + ON UPDATE RESTRICT ON DELETE RESTRICT, + task_id TEXT NOT NULL + REFERENCES purchase_tasks(id) ON UPDATE RESTRICT ON DELETE RESTRICT, + execution_id TEXT NOT NULL + REFERENCES task_executions(id) ON UPDATE RESTRICT ON DELETE RESTRICT, + command_sha256 TEXT NOT NULL + CHECK ( + length(command_sha256) = 64 + AND command_sha256 NOT GLOB '*[^0-9a-f]*' + ), + status TEXT NOT NULL CHECK (status IN ('PREPARING', 'READY')), + card_signature TEXT + CHECK ( + card_signature IS NULL + OR ( + length(card_signature) = 64 + AND card_signature NOT GLOB '*[^0-9a-f]*' + ) + ), + detail_signature TEXT + CHECK ( + detail_signature IS NULL + OR ( + length(detail_signature) = 64 + AND detail_signature NOT GLOB '*[^0-9a-f]*' + ) + ), + observed_title TEXT, + selected_sku TEXT, + quantity INTEGER CHECK ( + quantity IS NULL OR quantity BETWEEN 1 AND 99 + ), + unit_price_cents INTEGER CHECK ( + unit_price_cents IS NULL OR unit_price_cents > 0 + ), + total_price_cents INTEGER CHECK ( + total_price_cents IS NULL OR total_price_cents > 0 + ), + evidence_asset_id TEXT + REFERENCES execution_evidence_assets(id) + ON UPDATE RESTRICT ON DELETE RESTRICT, + evidence_sha256 TEXT + CHECK ( + evidence_sha256 IS NULL + OR ( + length(evidence_sha256) = 64 + AND evidence_sha256 NOT GLOB '*[^0-9a-f]*' + ) + ), + started_at TEXT NOT NULL, + ready_at TEXT, + CHECK ( + status = 'PREPARING' + OR ( + status = 'READY' + AND card_signature IS NOT NULL + AND detail_signature IS NOT NULL + AND length(trim(observed_title)) > 0 + AND length(trim(selected_sku)) > 0 + AND quantity IS NOT NULL + AND unit_price_cents IS NOT NULL + AND total_price_cents IS NOT NULL + AND evidence_asset_id IS NOT NULL + AND evidence_sha256 IS NOT NULL + AND ready_at IS NOT NULL + ) + ) +); + +CREATE INDEX order_dry_runs_task_started_idx + ON order_dry_runs (task_id, started_at DESC, id DESC); + +CREATE TABLE device_order_dry_run_requests ( + device_id TEXT NOT NULL + REFERENCES devices(id) ON UPDATE RESTRICT ON DELETE RESTRICT, + operation TEXT NOT NULL CHECK (operation IN ('START', 'READY')), + idempotency_key TEXT NOT NULL + CHECK ( + length(trim(idempotency_key)) > 0 + AND length(CAST(idempotency_key AS BLOB)) <= 128 + ), + request_sha256 TEXT NOT NULL + CHECK ( + length(request_sha256) = 64 + AND request_sha256 NOT GLOB '*[^0-9a-f]*' + ), + task_id TEXT NOT NULL + REFERENCES purchase_tasks(id) ON UPDATE RESTRICT ON DELETE RESTRICT, + dry_run_id TEXT NOT NULL + REFERENCES order_dry_runs(id) ON UPDATE RESTRICT ON DELETE RESTRICT, + created_at TEXT NOT NULL, + PRIMARY KEY (device_id, operation, idempotency_key) +); + +ALTER TABLE task_events RENAME TO task_events_v9; + +CREATE TABLE task_events ( + id TEXT PRIMARY KEY NOT NULL CHECK (length(id) = 36), + task_id TEXT NOT NULL + REFERENCES purchase_tasks(id) ON UPDATE RESTRICT ON DELETE CASCADE, + event_type TEXT NOT NULL + CHECK ( + event_type IN ( + 'TASK_CREATED', + 'TASK_CLAIMED', + 'TASK_RECLAIMED', + 'TASK_RELEASED', + 'TASK_STARTED', + 'TASK_CANCEL_REQUESTED', + 'TASK_CANCELED', + 'CANDIDATES_READY', + 'ORDER_AUTHORIZATION_CREATED', + 'ORDER_AUTHORIZATION_DELIVERED', + 'ORDER_AUTHORIZATION_ACKNOWLEDGED', + 'ORDER_DRY_RUN_STARTED', + 'ORDER_DRY_RUN_READY' + ) + ), + message TEXT NOT NULL, + occurred_at TEXT NOT NULL, + actor_user_id TEXT + REFERENCES users(id) ON UPDATE RESTRICT ON DELETE RESTRICT, + actor_device_id TEXT + REFERENCES devices(id) ON UPDATE RESTRICT ON DELETE RESTRICT +); + +INSERT INTO task_events ( + id, task_id, event_type, message, occurred_at, + actor_user_id, actor_device_id +) +SELECT + id, task_id, event_type, message, occurred_at, + actor_user_id, actor_device_id +FROM task_events_v9; + +DROP TABLE task_events_v9; + +CREATE INDEX task_events_task_occurred_idx + ON task_events (task_id, occurred_at ASC, id ASC); +CREATE INDEX task_events_actor_user_idx + ON task_events (actor_user_id, occurred_at DESC, id DESC); +CREATE INDEX task_events_actor_device_idx + ON task_events (actor_device_id, occurred_at DESC, id DESC); + +-- +goose Down +CREATE TEMP TABLE order_dry_runs_v10_down_guard ( + allowed INTEGER NOT NULL CHECK (allowed = 1) +); + +INSERT INTO order_dry_runs_v10_down_guard (allowed) +SELECT CASE + WHEN EXISTS (SELECT 1 FROM order_dry_runs) + OR EXISTS (SELECT 1 FROM device_order_dry_run_requests) + OR EXISTS ( + SELECT 1 FROM task_events + WHERE event_type IN ('ORDER_DRY_RUN_STARTED', 'ORDER_DRY_RUN_READY') + ) + THEN 0 + ELSE 1 +END; + +DROP TABLE order_dry_runs_v10_down_guard; +DROP TABLE device_order_dry_run_requests; +DROP TABLE order_dry_runs; + +ALTER TABLE task_events RENAME TO task_events_v10; + +CREATE TABLE task_events ( + id TEXT PRIMARY KEY NOT NULL CHECK (length(id) = 36), + task_id TEXT NOT NULL + REFERENCES purchase_tasks(id) ON UPDATE RESTRICT ON DELETE CASCADE, + event_type TEXT NOT NULL + CHECK ( + event_type IN ( + 'TASK_CREATED', + 'TASK_CLAIMED', + 'TASK_RECLAIMED', + 'TASK_RELEASED', + 'TASK_STARTED', + 'TASK_CANCEL_REQUESTED', + 'TASK_CANCELED', + 'CANDIDATES_READY', + 'ORDER_AUTHORIZATION_CREATED', + 'ORDER_AUTHORIZATION_DELIVERED', + 'ORDER_AUTHORIZATION_ACKNOWLEDGED' + ) + ), + message TEXT NOT NULL, + occurred_at TEXT NOT NULL, + actor_user_id TEXT + REFERENCES users(id) ON UPDATE RESTRICT ON DELETE RESTRICT, + actor_device_id TEXT + REFERENCES devices(id) ON UPDATE RESTRICT ON DELETE RESTRICT +); + +INSERT INTO task_events ( + id, task_id, event_type, message, occurred_at, + actor_user_id, actor_device_id +) +SELECT + id, task_id, event_type, message, occurred_at, + actor_user_id, actor_device_id +FROM task_events_v10; + +DROP TABLE task_events_v10; + +CREATE INDEX task_events_task_occurred_idx + ON task_events (task_id, occurred_at ASC, id ASC); +CREATE INDEX task_events_actor_user_idx + ON task_events (actor_user_id, occurred_at DESC, id DESC); +CREATE INDEX task_events_actor_device_idx + ON task_events (actor_device_id, occurred_at DESC, id DESC); diff --git a/docs/00-ai-start-here.md b/docs/00-ai-start-here.md index 988dc28..922e43d 100644 --- a/docs/00-ai-start-here.md +++ b/docs/00-ai-start-here.md @@ -58,8 +58,8 @@ T-205 原子领取/租约状态机、T-206 Android 登录/有限离线、T-207 证据回传、T-211 参考图召回和 SKU 硬匹配、T-212 候选身份映射,以及 T-213 受控 规格组合/价格核验均已完成。T-208 的原始候选观测、模型评估、确定性推荐、逐候选 结构化人工理由和修订历史也已完成。T-214 商品持久身份和重新定位指纹、T-215 -Admin 候选确认、不可变待投递授权及 T-216 设备命令可靠投递与确认均已完成;下一项 -是正在实现的 T-217 已授权商品重新定位与订单 dry-run。 +Admin 候选确认、不可变待投递授权、T-216 设备命令可靠投递及 T-217 已授权商品 +重新定位与订单 dry-run 均已完成;下一项是 T-218 单次订单提交与订单回读。 不得直接把候选链接或列表 ordinal 当成授权。 手机从管理后端领取任务并回传结果,VLM、拼多多自动化和人工确认在 App 本地完成。 T-206 增加有限离线执行;T-207 已复用 Roubao 端上 OpenAI 兼容适配器并加密本地 Key。 diff --git a/docs/04-architecture.md b/docs/04-architecture.md index de6e77e..88b50a0 100644 --- a/docs/04-architecture.md +++ b/docs/04-architecture.md @@ -629,6 +629,11 @@ App 可以从规格弹层进入确认订单页,但 T-217 的 accessibility act 确认订单页的提交、付款、地址或优惠选择。READY 同时存在于手机加密状态与后端审计表, 才允许 T-218 消费。 +拼多多 8.17 的结算 WebView 偶尔只暴露外层节点。此时 App 可对无障碍服务捕获的当前 +确认页截图运行 bundled ML Kit 中文 OCR,但它只返回只读证据:标题必须满足有界锚点、 +颜色/尺码必须通过同一 SKU 别名匹配器、数量必须位于规格与优惠边界之间,实付款必须 +唯一可解析。任一条件不满足即停止,OCR 层没有点击接口。 + 人工理由使用版本化 allowlist。接受或拒绝至少有一个理由且指定主要理由; `OTHER` 才要求 4-200 字备注。拒绝推荐后改选必须同时产生一条原推荐项负标签和一条 替代项正标签;全部无匹配时每个曝光候选都有负标签。人工修正追加新 review 并引用 diff --git a/docs/08-interaction-checklist.md b/docs/08-interaction-checklist.md index 4bc8147..9b03258 100644 --- a/docs/08-interaction-checklist.md +++ b/docs/08-interaction-checklist.md @@ -17,7 +17,7 @@ | IX-008 | US-006 | App/管理端错误状态 | 自动失败、取消、重试上传 | 显示结构化原因和恢复动作 | P0 | 已定 | | IX-009 | US-008 | App 候选理由/管理端决策详情 | 接受、拒绝、改选或修正 | 保存逐候选结构化人工标签 | P1 | T-208 已实现 | | IX-010 | US-009 | App 独立执行设置/同步状态 | 配置模式、离线执行或补报 | 授权内独立执行并可审计同步 | P0 | T-206 离线控制已实现,结果补报待 T-207 | -| IX-011 | US-010 | Admin 候选授权/App 订单执行 | 选择候选并授权、设备领取执行 | 创建一笔可对账的待付款订单并提醒人工付款 | P0 | T-215/T-216 已完成;T-217 进行中 | +| IX-011 | US-010 | Admin 候选授权/App 订单执行 | 选择候选并授权、设备领取执行 | 创建一笔可对账的待付款订单并提醒人工付款 | P0 | T-215 至 T-217 已完成;T-218/T-219 待实现 | ## IX-001 管理 Web 登录 diff --git a/docs/current-state.md b/docs/current-state.md index 5f764c2..43b37c1 100644 --- a/docs/current-state.md +++ b/docs/current-state.md @@ -5,9 +5,9 @@ ## 当前快照 - 日期:2026-07-28 -- 阶段:T-217 已授权商品重新定位与订单 dry-run 进行中 -- Git:当前分支为 `main`;T-001 至 T-004、T-101 至 T-104、T-201 至 T-207、T-209、 - T-208、T-210、T-211、T-212、T-213、T-214 均已纳入 Git 历史 +- 阶段:T-217 已授权商品重新定位与订单 dry-run 已完成;下一项 T-218 +- Git:当前分支为 `main`;T-001 至 T-004、T-101 至 T-104、T-201 至 T-217 + 均按文档提交、实现提交的顺序纳入历史 - 生产代码:`android-buyer/` 已接入 Roubao Android 源码 - Android:固定 `main@c8a6d7f03422eb01744b01f3ee77bf7757741f7e`;MIT 许可证已保留 @@ -17,10 +17,10 @@ - 本机 Android 工具:JDK 17.0.13、Command-line Tools 22.0、SDK 34、 Build Tools 34.0.0、Platform Tools/ADB 37.0.0;用户级 SDK 环境变量已设置 - Android Studio:未安装;`winget` 静默安装卡住后已终止,不阻塞命令行构建 -- 测试:T-216 Android Debug/Release 单元测试与构建和根 `init.ps1` 通过; - Debug APK `1.4.13 (18)` 已构建但未覆盖安装,PKG110 当前仍为 `1.4.12 (17)` -- 后端测试:T-216 运行 `go test ./...`、`go test -race ./...`、`go vet ./...`; - v9 migration 往返、带命令数据的降级保护及 pull/ACK HTTP 集成测试均通过 +- 测试:T-217 Android Debug/Release 单元测试与构建和根 `init.ps1` 通过; + Debug APK `1.4.14 (19)` 已覆盖安装到 PKG110 +- 后端测试:T-217 运行 `go test ./...`、`go test -race ./...`、`go vet ./...`; + v10 migration 往返、带 dry-run 数据的降级保护及 start/ready HTTP 集成测试通过 - 原型:4 个管理 Web 页面和 7 个 Android 页面均可离线独立打开;Playwright 以 1440×900、390×844、360×800 验证 36 个页面/视口组合,无页面横向溢出、 脚本错误或外部请求,Android 可见交互控件均不小于 44px @@ -72,6 +72,10 @@ canonical hash、任务内容和候选四组指纹,先写 Keystore-backed 状态再幂等 ACK。 网络响应丢失和进程重启复用同一 ACK key;后台候选不再允许手机本地选择或重复 采集。本任务只同步授权,未打开拼多多或执行下单。 +- T-217 订单 dry-run:App 自动用参考图重新搜索并以旧 card/detail 指纹、标题、 + 唯一颜色/尺码、数量和预算重新核验 Admin 选择;确认页 WebView 折叠时使用端上 + 中文 OCR 做只读 fail-closed 证据提取。后端保存 v10 READY、截图、SKU、数量、 + 单价/总额和事件;无障碍动作集中不含订单提交或支付。 - Workflow:纯 Kotlin runner 已支持步骤 timeout、最多 3 次 retry、安全阻塞、 用户停止和单 runner 并发拒绝;T-102 已接入搜索加有界候选采集五步 - TaskSource:严格 CLI 已生成并验证真实私有 ProbeTask;默认 APK 不含私有 fixture @@ -85,8 +89,7 @@ - 离线执行:默认 30 分钟有限授权和 30 秒 best-effort heartbeat 已实现;PKG110 真机断开临时后端 95 秒后保持同一 execution,重连后滑动续期;到期持久安全停止, RUNNING 不自动重新分配 -- 测试设备:OnePlus PKG110,Android 16/API 36;已安装肉包 `1.4.12 (17)`, - 待安装构建为 `1.4.13 (18)`;拼多多 +- 测试设备:OnePlus PKG110,Android 16/API 36;已安装肉包 `1.4.14 (19)`;拼多多 `8.17.0 (81700)` - 设备就绪:拼多多首页、文字/图片搜索、双列候选卡、详情截图和返回均已通过 8.17.0 真机验证;采购员已在 ColorOS 设置中手动启用肉包采购无障碍,APK 覆盖安装后授权 @@ -101,7 +104,7 @@ 已用 CLI 真实导入并逐字段/图片哈希验证,生成物位于被忽略的 `.local/` - 标准启动路径:`$env:RUN_START_COMMAND="1"; .\init.ps1` - 标准验证路径:`.\init.ps1` -- 当前 blocker:T-216 无阻塞。真实 VLM 服务地址、模型、设备级测试凭证、成本上限 +- 当前 blocker:T-217 无阻塞。真实 VLM 服务地址、模型、设备级测试凭证、成本上限 和数据留存尚未确认;当前只支持单 SKU/JPEG;候选探针截图要求 Android 11/API 30+ ## 当前目录 @@ -134,7 +137,7 @@ | `docs/tasks/T-214.md` | DONE | 建立 execution-scoped candidate key 与设备采集指纹 | | `docs/tasks/T-215.md` | DONE | Admin 按 candidate key 选择并创建不可变待投递授权 | | `docs/tasks/T-216.md` | DONE | App 主动拉取并先加密落盘再确认同一条下单命令 | -| `docs/tasks/T-217.md` | DOING | 重新核对已授权商品并选择 SKU/数量,停在最终提交前 | +| `docs/tasks/T-217.md` | DONE | 重新核对已授权商品并选择 SKU/数量,停在最终提交前 | | `docs/design/` | 已确认 | T-202 原型索引、4 个管理页和 7 个 Android 页面 | | `deepseek总结.txt` | 已有 | 历史讨论摘要,不是正式需求权威 | | `android-buyer/` | 已有 | Roubao `main` 固定 commit 的 Android 基线 | @@ -145,9 +148,8 @@ ## 任务摘要 -- 已完成:T-001 至 T-004、T-101 至 T-104、T-201 至 T-216。 -- 正在进行:T-217 已授权商品重新定位与订单 dry-run。 -- 下一步:依次实现单次提交对账和付款提醒。 +- 已完成:T-001 至 T-004、T-101 至 T-104、T-201 至 T-217。 +- 下一步:T-218 单次订单提交与订单回读,然后实现 T-219 付款提醒。 ## 当前可运行内容 diff --git a/docs/tasks/T-217.md b/docs/tasks/T-217.md index e32da0a..fb14c1d 100644 --- a/docs/tasks/T-217.md +++ b/docs/tasks/T-217.md @@ -4,7 +4,7 @@ title: 已授权商品重新定位与订单 dry-run phase: 2 deps: - T-216 -status: DOING +status: DONE created: 2026-07-28 context_ref: 2372ab2 work_branch: null @@ -79,7 +79,9 @@ READY 证据都存在,T-218 才能消费同一授权执行一次提交。 `ORDER_CONFIRMATION`。该动作只是形成待提交订单预览,不是平台订单提交。 3. 订单确认页重新读取商品标题/规格摘要/数量/商品金额或应付总额;必须与授权和弹层 证据一致,且总额不超过任务最高商品总预算。地址、优惠券、运费或服务选项只读, - App 不自动修改。 + App 不自动修改。拼多多 WebView 无法暴露完整语义树时,只允许对当前无障碍截图 + 使用端上中文 OCR 做只读、fail-closed 核验;标题锚点、SKU 别名、数量和实付款任一 + 缺失或冲突都停止,OCR 结果不能产生点击。 4. 捕获受控订单确认截图后,App 先加密保存 READY 证据,再上传截图并幂等调用 `POST /api/v1/tasks/{task_id}/order-dry-runs/{command_id}/ready`。服务端保存当前 指纹、SKU、数量、价格、金额和 evidence,写 `ORDER_DRY_RUN_READY` 事件。 @@ -101,24 +103,38 @@ v10: ## 验收要点 -- [ ] start/ready 仅接受原身份、有效租约、同一 command hash,并可安全重放。 -- [ ] 重新定位不按 ordinal/坐标;预算、滚动和候选检查均有硬上限。 -- [ ] 至少一个旧语义签名、标题、唯一 SKU 和价格/预算同时满足,且只能唯一命中。 -- [ ] 数量设置逐步复核;禁用、重复、缺失和状态未改变均停止。 -- [ ] 只允许从规格弹层进入订单确认页,绝不点击订单提交或支付控件。 -- [ ] READY 先加密落盘再回传,截图/当前指纹/SKU/数量/金额可审计并可恢复。 -- [ ] v10 migration、Go test/race/vet、Android test/Debug/Release 和根验证通过。 -- [ ] PKG110 真机 smoke 到订单确认页,确认拼多多订单列表尚无新订单。 +- [x] start/ready 仅接受原身份、有效租约、同一 command hash,并可安全重放。 +- [x] 重新定位不按 ordinal/坐标;预算、滚动和候选检查均有硬上限。 +- [x] 至少一个旧语义签名、标题、唯一 SKU 和价格/预算同时满足,且只能唯一命中。 +- [x] 数量设置逐步复核;禁用、重复、缺失和状态未改变均停止。 +- [x] 只允许从规格弹层进入订单确认页,绝不点击订单提交或支付控件。 +- [x] READY 先加密落盘再回传,截图/当前指纹/SKU/数量/金额可审计并可恢复。 +- [x] v10 migration、Go test/race/vet、Android test/Debug/Release 和根验证通过。 +- [x] PKG110 真机 smoke 到订单确认页,后端 READY 且未触发最终提交。 ## 边界 - 不点击确认订单页最终提交,不创建平台订单;属于 T-218。 - 不读取订单列表、订单号或下单时间;属于 T-218。 - 不提示或执行付款;属于 T-219/采购员人工操作。 -- 不用 VLM、OCR、坐标、剪贴板或旧截图 hash 绕过可访问性语义失败。 +- 不用 VLM、坐标、剪贴板或旧截图 hash 绕过确定性核验;OCR 仅用于当前结算截图 + 的只读证据降级,不能放宽 SKU/数量/金额条件或产生点击。 ## 执行记录 - 2026-07-28:T-216 实现提交 `2372ab2` 后领取。冻结自动触发、恢复、重新图片搜索、 多信号唯一定位、SKU/数量/价格复核和最终提交禁区;计划复用 T-211/T-213 已验证的 图片搜索、规格解析和页面分类能力。 +- 2026-07-28:新增 v10 dry-run/幂等请求表、start/ready API、授权 + `ACKNOWLEDGED -> EXECUTING`、加密本地 PREPARING/READY 与证据 outbox。Android + `1.4.14 (19)` 增加重新定位、规格/数量设置、确认页只读核验和最多 5 次恢复尝试。 +- 2026-07-28:真机发现拼多多拍照搜索偶发“请对准商品或码”对话框,状态机只允许 + 点击“取消”后重新进入相册;结算 WebView 语义树折叠时使用 bundled ML Kit 中文 + OCR,按规范色别名、尺码、数量和实付款 fail-closed 核验。 +- 2026-07-28:PKG110/拼多多 8.17 真机任务 + `670019e0-20d9-453e-a67e-964e03ad7482` 到达后端 READY;保存订单确认截图、 + `灰色,2XL`、数量 1、单价/总额 14.35 元以及 STARTED/READY 事件。App 显示 + “订单已核验,等待提交”,没有调用订单提交或支付动作。 +- 2026-07-28:`go test ./...`、`go test -race ./...`、`go vet ./...`、Android + Debug/Release 单测与构建、根 `.\init.ps1` 全部通过;正式 APK 已覆盖安装并保留 + READY 状态。 diff --git a/progress.md b/progress.md index 4b0268c..7ecbeb1 100644 --- a/progress.md +++ b/progress.md @@ -174,3 +174,15 @@ - 影响:手机直接调用本机已配置的 provider,管理后端不保存 Key、完整 endpoint 或 原始模型输出;结果按“事件、截图、候选、终态”顺序重放。下一步 T-208 扩展为可训练 的逐候选结构化人工理由与修订历史。 + +## 2026-07-28 已授权订单 dry-run + +- 类型:阶段完成 +- 内容:完成 T-217;后端新增 v10 dry-run/幂等请求与 start/ready,Android + `1.4.14 (19)` 自动重新图片搜索、唯一定位候选、选择 SKU/数量并在确认订单页只读 + 核验。拼多多结算 WebView 折叠时使用 bundled 中文 OCR fail-closed 提取证据。 +- 验证:PKG110/拼多多 8.17 真机已生成后端 READY、受控截图、SKU、数量和 14.35 元 + 单价/总额,App 显示“订单已核验,等待提交”;未点击最终提交或支付。Go + test/race/vet、Android Debug/Release 和根 `init.ps1` 均通过。 +- 影响:T-218 可只消费同一 READY 授权执行一次提交与订单列表对账;T-217 不创建 + 平台订单,T-219 之前仍不提供付款动作。