diff --git a/android-buyer/app/build.gradle.kts b/android-buyer/app/build.gradle.kts index 4e09a6d..d4f0f3a 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 = 19 - versionName = "1.4.14" + versionCode = 20 + versionName = "1.4.15" vectorDrawables { useSupportLibrary = true 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 48d0ed8..17ec77b 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 @@ -4,6 +4,9 @@ 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.PinduoduoOrderSubmissionExpectation +import com.roubao.autopilot.pinduoduo.PinduoduoOrderSubmissionReadiness +import com.roubao.autopilot.pinduoduo.PinduoduoPendingOrderObservation import com.roubao.autopilot.pinduoduo.PinduoduoScreenshotCapture import com.roubao.autopilot.pinduoduo.PinduoduoSpecificationEvidence import com.roubao.autopilot.pinduoduo.PinduoduoSpecificationGroupKind @@ -149,6 +152,44 @@ object BuyerAccessibilityBridge { } } + suspend fun orderSubmissionReadiness( + expectation: PinduoduoOrderSubmissionExpectation + ): PinduoduoOrderSubmissionReadiness? = + withContext(Dispatchers.Main.immediate) { + service?.readPinduoduoOrderSubmissionReadiness(expectation) + } + + suspend fun submitOrderOnce( + submissionFenceId: String, + expectation: PinduoduoOrderSubmissionExpectation + ): Boolean = withContext(Dispatchers.Main.immediate) { + service?.submitPinduoduoOrderOnce( + submissionFenceId, + expectation + ) == true + } + + suspend fun navigateToOrderListAfterSubmit(): Boolean = + withContext(Dispatchers.Main.immediate) { + service?.navigateToPinduoduoOrderListAfterSubmit() == true + } + + suspend fun pendingOrders( + limit: Int + ): List = + withContext(Dispatchers.Main.immediate) { + service?.readPinduoduoPendingOrders(limit).orEmpty() + } + + suspend fun captureOrderListScreenshot(): PinduoduoScreenshotCapture? = + withTimeoutOrNull(SCREENSHOT_TIMEOUT_MILLIS) { + withContext(Dispatchers.Main.immediate) { + service?.capturePinduoduoScreenshot( + PinduoduoPage.ORDER_LIST + ) + } + } + suspend fun captureSpecificationScreenshot(): PinduoduoScreenshotCapture? = withTimeoutOrNull(SCREENSHOT_TIMEOUT_MILLIS) { withContext(Dispatchers.Main.immediate) { 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 a9521e7..1fa5fdf 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 @@ -17,6 +17,11 @@ 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.pinduoduo.PinduoduoOrderSubmissionExpectation +import com.roubao.autopilot.pinduoduo.PinduoduoOrderSubmissionReadiness +import com.roubao.autopilot.pinduoduo.PinduoduoOrderSubmissionSafetyPolicy +import com.roubao.autopilot.pinduoduo.PinduoduoPendingOrderObservation +import com.roubao.autopilot.pinduoduo.PinduoduoPendingOrderParser import com.roubao.autopilot.readiness.DeviceObservationStore import com.roubao.autopilot.readiness.LoginBlockerDetector import com.roubao.autopilot.readiness.PINDUODUO_PACKAGE @@ -42,6 +47,7 @@ class BuyerAccessibilityService : AccessibilityService() { private var lastPinduoduoScanAt = 0L private var expectedSearchQuery = SEARCH_PROBE_KEYWORD private val screenshotExecutor = Executors.newSingleThreadExecutor() + private val consumedSubmissionFences = linkedSetOf() override fun onServiceConnected() { super.onServiceConnected() @@ -520,6 +526,131 @@ class BuyerAccessibilityService : AccessibilityService() { ) } + internal fun readPinduoduoOrderSubmissionReadiness( + expectation: PinduoduoOrderSubmissionExpectation + ): PinduoduoOrderSubmissionReadiness? = + withPinduoduoRoot { root -> + val snapshot = classifyPinduoduoRoot(root) + if ( + snapshot.page != PinduoduoPage.ORDER_CONFIRMATION || + snapshot.safetyStopReason !in setOf( + null, + SafetyStopReason.PAYMENT_BOUNDARY + ) + ) { + return@withPinduoduoRoot null + } + PinduoduoOrderSubmissionSafetyPolicy.inspect( + collectNodes(root).map(::uiElement), + expectation + ) + } + + internal fun submitPinduoduoOrderOnce( + submissionFenceId: String, + expectation: PinduoduoOrderSubmissionExpectation + ): Boolean = withPinduoduoRoot { root -> + if (!SUBMISSION_FENCE_PATTERN.matches(submissionFenceId)) { + return@withPinduoduoRoot false + } + val snapshot = classifyPinduoduoRoot(root) + if ( + snapshot.page != PinduoduoPage.ORDER_CONFIRMATION || + snapshot.safetyStopReason !in setOf( + null, + SafetyStopReason.PAYMENT_BOUNDARY + ) || + PinduoduoOrderSubmissionSafetyPolicy.inspect( + collectNodes(root).map(::uiElement), + expectation + ) == null + ) { + return@withPinduoduoRoot false + } + val targets = uniqueClickableTargets( + collectNodes(root).filter { node -> + node.isVisibleToUser && + node.isEnabled && + PinduoduoOrderSubmissionSafetyPolicy.isExactSubmitText( + semanticText(node) + ) + } + ) + val target = targets.singleOrNull() + ?: return@withPinduoduoRoot false + synchronized(consumedSubmissionFences) { + if ( + consumedSubmissionFences.size >= + MAX_CONSUMED_SUBMISSION_FENCES + ) { + return@withPinduoduoRoot false + } + if (!consumedSubmissionFences.add(submissionFenceId)) { + return@withPinduoduoRoot false + } + } + target.performAction(AccessibilityNodeInfo.ACTION_CLICK) + } ?: false + + internal fun navigateToPinduoduoOrderListAfterSubmit(): Boolean = + withPinduoduoRoot { root -> + val snapshot = classifyPinduoduoRoot(root) + when { + snapshot.page == PinduoduoPage.ORDER_LIST -> true + snapshot.safetyStopReason in setOf( + SafetyStopReason.LOGIN_REQUIRED, + SafetyStopReason.VERIFICATION_REQUIRED, + SafetyStopReason.RISK_CONTROL + ) -> false + snapshot.page in setOf( + PinduoduoPage.ORDER_CONFIRMATION, + PinduoduoPage.UNKNOWN + ) && + snapshot.safetyStopReason in setOf( + null, + SafetyStopReason.PAYMENT_BOUNDARY + ) -> + performGlobalAction(GLOBAL_ACTION_BACK) + else -> false + } + } ?: false + + internal fun readPinduoduoPendingOrders( + limit: Int + ): List = + withPinduoduoRoot { root -> + if ( + limit !in 1..MAX_PENDING_ORDERS || + classifyPinduoduoRoot(root).page != + PinduoduoPage.ORDER_LIST + ) { + return@withPinduoduoRoot emptyList() + } + val orderNumberNodes = collectNodes(root).filter { node -> + node.isVisibleToUser && + ORDER_NUMBER_LABEL_PATTERN.matches( + normalizeActionText(semanticText(node)) + ) + } + orderNumberNodes.mapNotNull { orderNumberNode -> + var container: AccessibilityNodeInfo? = + orderNumberNode.parent + repeat(MAX_ORDER_CARD_ANCESTORS) { + val current = container ?: return@repeat + val observation = PinduoduoPendingOrderParser.parse( + collectNodes(current).map(::uiElement) + ) + if (observation != null) { + return@mapNotNull observation + } + container = current.parent + } + null + }.distinctBy { + it.platformOrderNo + }.take(limit) + } ?: emptyList() + internal fun isPinduoduoCollapsedCheckout(): Boolean = withPinduoduoRoot { root -> PinduoduoCollapsedCheckoutPolicy.matches( @@ -608,7 +739,8 @@ class BuyerAccessibilityService : AccessibilityService() { expectedPage !in setOf( PinduoduoPage.PRODUCT_DETAIL, PinduoduoPage.SPECIFICATION_PANEL, - PinduoduoPage.ORDER_CONFIRMATION + PinduoduoPage.ORDER_CONFIRMATION, + PinduoduoPage.ORDER_LIST ) ) { return null @@ -1143,6 +1275,9 @@ 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 MAX_PENDING_ORDERS = 10 + private const val MAX_ORDER_CARD_ANCESTORS = 6 + private const val MAX_CONSUMED_SUBMISSION_FENCES = 1_024 private const val MIN_SCAN_INTERVAL_MS = 300L private const val IMAGE_SEARCH_DESCRIPTION = "拍照搜索" private const val IMAGE_SEARCH_RETRY_CANCEL_TEXT = "取消" @@ -1156,5 +1291,12 @@ class BuyerAccessibilityService : AccessibilityService() { setOf("减少数量", "减", "-") private val DECIMAL_PRICE_PATTERN = Regex("^\\s*\\d{1,6}\\.\\d{1,2}\\s*$") + private val SUBMISSION_FENCE_PATTERN = Regex( + "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-" + + "[1-5][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-" + + "[0-9a-fA-F]{12}$" + ) + private val ORDER_NUMBER_LABEL_PATTERN = + Regex("^订单(?:编号|号)[::]?[0-9]{8,40}$") } } diff --git a/android-buyer/app/src/main/java/com/roubao/autopilot/pinduoduo/AndroidPinduoduoOrderSubmissionDriver.kt b/android-buyer/app/src/main/java/com/roubao/autopilot/pinduoduo/AndroidPinduoduoOrderSubmissionDriver.kt new file mode 100644 index 0000000..8ac8e45 --- /dev/null +++ b/android-buyer/app/src/main/java/com/roubao/autopilot/pinduoduo/AndroidPinduoduoOrderSubmissionDriver.kt @@ -0,0 +1,70 @@ +package com.roubao.autopilot.pinduoduo + +import com.roubao.autopilot.accessibility.BuyerAccessibilityBridge +import com.roubao.autopilot.readiness.PINDUODUO_PACKAGE +import kotlinx.coroutines.delay + +class AndroidPinduoduoOrderSubmissionDriver( + private val expectation: PinduoduoOrderSubmissionExpectation, + private val pagePollLimit: Int = 10, + private val pollIntervalMillis: Long = 800L +) { + private var navigationActionConsumed = false + + suspend fun revalidate(): + PinduoduoOrderSubmissionReadiness? = + BuyerAccessibilityBridge.orderSubmissionReadiness(expectation) + + suspend fun submitOnce(submissionFenceId: String): Boolean = + BuyerAccessibilityBridge.submitOrderOnce( + submissionFenceId, + expectation + ) + + suspend fun awaitAndNavigateToOrderList( + allowBackNavigation: Boolean + ): Boolean { + repeat(pagePollLimit) { + val snapshot = BuyerAccessibilityBridge.snapshot() + if ( + snapshot.foregroundPackage == PINDUODUO_PACKAGE && + snapshot.page == PinduoduoPage.ORDER_LIST && + snapshot.safetyStopReason == null + ) { + return true + } + if ( + snapshot.foregroundPackage != PINDUODUO_PACKAGE || + snapshot.safetyStopReason in setOf( + com.roubao.autopilot.workflow.SafetyStopReason.LOGIN_REQUIRED, + com.roubao.autopilot.workflow.SafetyStopReason.VERIFICATION_REQUIRED, + com.roubao.autopilot.workflow.SafetyStopReason.RISK_CONTROL + ) + ) { + return false + } + if (!navigationActionConsumed && allowBackNavigation) { + navigationActionConsumed = true + if (!BuyerAccessibilityBridge.navigateToOrderListAfterSubmit()) { + return false + } + } else if (!allowBackNavigation) { + return false + } + delay(pollIntervalMillis) + } + return false + } + + suspend fun pendingOrders( + limit: Int = MAX_PENDING_ORDERS + ): List = + BuyerAccessibilityBridge.pendingOrders(limit) + + suspend fun captureOrderList(): PinduoduoScreenshotCapture? = + BuyerAccessibilityBridge.captureOrderListScreenshot() + + companion object { + const val MAX_PENDING_ORDERS = 10 + } +} diff --git a/android-buyer/app/src/main/java/com/roubao/autopilot/pinduoduo/PinduoduoOrderSubmissionModels.kt b/android-buyer/app/src/main/java/com/roubao/autopilot/pinduoduo/PinduoduoOrderSubmissionModels.kt new file mode 100644 index 0000000..4100656 --- /dev/null +++ b/android-buyer/app/src/main/java/com/roubao/autopilot/pinduoduo/PinduoduoOrderSubmissionModels.kt @@ -0,0 +1,348 @@ +package com.roubao.autopilot.pinduoduo + +import java.text.Normalizer +import java.time.Instant +import java.time.LocalDateTime +import java.time.ZoneId +import java.time.format.DateTimeFormatter + +data class PinduoduoOrderSubmissionExpectation( + val title: String, + val color: String, + val size: String, + val quantity: Int, + val totalPriceCents: Long +) + +data class PinduoduoOrderSubmissionReadiness( + val confirmation: PinduoduoOrderConfirmationEvidence, + val addressConfigured: Boolean, + val exactSubmitActionCount: Int +) + +data class PinduoduoPendingOrderObservation( + val platformOrderNo: String, + val platformOrderedAt: String, + val status: String, + val observedTitle: String, + val selectedSummary: String, + val quantity: Int, + val totalPriceCents: Long +) + +object PinduoduoOrderSubmissionSafetyPolicy { + fun inspect( + elements: Collection, + expectation: PinduoduoOrderSubmissionExpectation + ): PinduoduoOrderSubmissionReadiness? { + if (expectation.quantity !in 1..99 || + expectation.totalPriceCents <= 0 + ) { + return null + } + val visible = elements.filter { + it.visibleToUser && it.enabled + }.take(MAX_ELEMENTS + 1) + if (visible.isEmpty() || visible.size > MAX_ELEMENTS) { + return null + } + val texts = visible.mapNotNull(::semanticText).distinct() + val normalized = texts.map(::normalize) + if (normalized.any { text -> + ADD_ADDRESS_MARKERS.any(text::contains) || + FORBIDDEN_PAYMENT_MARKERS.any(text::contains) + } + ) { + return null + } + val hasAddressLabel = normalized.any { text -> + ADDRESS_LABELS.any(text::contains) + } + val hasRecipientPhone = texts.any { text -> + PHONE_PATTERN.containsMatchIn( + Normalizer.normalize(text, Normalizer.Form.NFKC) + .replace(" ", "") + ) + } + if (!hasAddressLabel || !hasRecipientPhone) { + return null + } + val confirmation = PinduoduoOrderConfirmationParser.parse(visible) + ?: return null + if ( + PinduoduoOrderConfirmationParser.normalize( + confirmation.observedTitle + ) != PinduoduoOrderConfirmationParser.normalize(expectation.title) || + !PinduoduoSpecificationTargetMatcher.matches( + PinduoduoSpecificationGroupKind.COLOR, + expectation.color, + confirmation.selectedSummary + ) || + !PinduoduoSpecificationTargetMatcher.matches( + PinduoduoSpecificationGroupKind.SIZE, + expectation.size, + confirmation.selectedSummary + ) || + confirmation.quantity != expectation.quantity || + confirmation.totalPriceCents != expectation.totalPriceCents + ) { + return null + } + val exactSubmitActions = visible.count { element -> + element.clickable && + normalize(semanticText(element).orEmpty()) == + EXACT_SUBMIT_TEXT + } + if (exactSubmitActions != 1) { + return null + } + return PinduoduoOrderSubmissionReadiness( + confirmation = confirmation, + addressConfigured = true, + exactSubmitActionCount = exactSubmitActions + ) + } + + internal fun isExactSubmitText(value: String): Boolean = + normalize(value) == EXACT_SUBMIT_TEXT + + private fun semanticText(element: PinduoduoUiElement): String? = + sequenceOf(element.text, element.contentDescription) + .filterNotNull() + .map(String::trim) + .firstOrNull(String::isNotEmpty) + + private fun normalize(value: String): String = + Normalizer.normalize(value.trim(), Normalizer.Form.NFKC) + .lowercase() + .replace(Regex("\\s+"), "") + + private const val MAX_ELEMENTS = 220 + private const val EXACT_SUBMIT_TEXT = "提交订单" + private val ADDRESS_LABELS = setOf("收货地址", "配送地址") + private val ADD_ADDRESS_MARKERS = setOf( + "添加收货地址", + "手动添加收货地址", + "请选择收货地址", + "请选择地址", + "新增地址" + ) + private val FORBIDDEN_PAYMENT_MARKERS = setOf( + "立即支付", + "确认支付", + "免密支付", + "先用后付", + "微信免密", + "支付宝免密", + "开通免密" + ) + private val PHONE_PATTERN = + Regex("""1[3-9]\d(?:\*{4}|\d{4})\d{4}""") +} + +object PinduoduoPendingOrderParser { + fun parse(elements: Collection): + PinduoduoPendingOrderObservation? { + val texts = elements.asSequence() + .filter { it.visibleToUser && it.enabled } + .sortedWith( + compareBy { it.boundsTop } + .thenBy { it.boundsLeft } + ) + .mapNotNull(::semanticText) + .distinct() + .take(MAX_ELEMENTS + 1) + .toList() + if (texts.isEmpty() || texts.size > MAX_ELEMENTS) { + return null + } + val orderNumbers = texts.mapNotNull(::parseOrderNumber).distinct() + val orderedTimes = texts.mapNotNull(::parseOrderedAt).distinct() + val quantities = texts.mapNotNull(::parseQuantity).distinct() + val totals = texts.mapNotNull(::parseTotal).distinct() + val title = PinduoduoObservedTitlePolicy.select( + texts.filterNot { value -> + val normalized = normalize(value) + parseOrderNumber(value) != null || + parseOrderedAt(value) != null || + parseQuantity(value) != null || + parseTotal(value) != null || + normalized == "待付款" || + normalized.startsWith("规格") || + normalized.startsWith("已选") || + normalized.startsWith("颜色") || + normalized.startsWith("尺码") + } + ) + val summary = texts.singleOrNull { value -> + val normalized = normalize(value) + normalized.startsWith("已选") || + normalized.startsWith("规格") + } ?: parseSplitSpecificationSummary(texts) + val pending = texts.any { normalize(it) == "待付款" } + return PinduoduoPendingOrderObservation( + platformOrderNo = orderNumbers.singleOrNull() ?: return null, + platformOrderedAt = orderedTimes.singleOrNull() ?: return null, + status = if (pending) "PENDING_PAYMENT" else return null, + observedTitle = title ?: return null, + selectedSummary = summary ?: return null, + quantity = quantities.singleOrNull() ?: return null, + totalPriceCents = totals.singleOrNull() ?: return null + ) + } + + private fun parseOrderNumber(value: String): String? = + ORDER_NUMBER_PATTERN.matchEntire( + Normalizer.normalize(value.trim(), Normalizer.Form.NFKC) + .replace(Regex("\\s+"), "") + )?.groupValues?.get(1) + + private fun parseOrderedAt(value: String): String? { + val match = ORDERED_AT_PATTERN.matchEntire( + Normalizer.normalize(value.trim(), Normalizer.Form.NFKC) + .replace(':', ':') + ) ?: return null + val raw = match.groupValues[1] + val formatter = if (raw.length == 16) { + ORDERED_MINUTE_FORMAT + } else { + ORDERED_SECOND_FORMAT + } + return runCatching { + LocalDateTime.parse(raw, formatter) + .atZone(PDD_TIME_ZONE) + .toInstant() + .toString() + }.getOrNull() + } + + private fun parseQuantity(value: String): Int? { + val normalized = normalize(value) + return QUANTITY_PATTERNS.firstNotNullOfOrNull { pattern -> + pattern.matchEntire(normalized) + ?.groupValues + ?.get(1) + ?.toIntOrNull() + ?.takeIf { it in 1..99 } + } + } + + private fun parseTotal(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(texts: List): String? { + val colors = texts.mapNotNull { + COLOR_PATTERN.matchEntire(it.trim())?.groupValues?.get(1) + }.distinct() + val sizes = texts.mapNotNull { + SIZE_PATTERN.matchEntire(it.trim())?.groupValues?.get(1) + }.distinct() + val color = colors.singleOrNull() ?: return null + val size = sizes.singleOrNull() ?: return null + return "$color $size" + } + + private fun semanticText(element: PinduoduoUiElement): String? = + sequenceOf(element.text, element.contentDescription) + .filterNotNull() + .map(String::trim) + .firstOrNull(String::isNotEmpty) + + private fun normalize(value: String): String = + Normalizer.normalize(value.trim(), Normalizer.Form.NFKC) + .lowercase() + .replace(Regex("\\s+"), "") + + private const val MAX_ELEMENTS = 180 + private const val MAX_TOTAL_CENTS = 100_000_000L + private val ORDER_NUMBER_PATTERN = + Regex("""^订单(?:编号|号)[::]?(\d{8,40})$""") + private val ORDERED_AT_PATTERN = + Regex("""^下单时间[::]\s*(\d{4}-\d{2}-\d{2} \d{2}:\d{2}(?::\d{2})?)$""") + private val ORDERED_MINUTE_FORMAT = + DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm") + private val ORDERED_SECOND_FORMAT = + DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss") + private val PDD_TIME_ZONE = ZoneId.of("Asia/Shanghai") + private val QUANTITY_PATTERNS = listOf( + Regex("""^(?:x|×)(\d{1,2})$"""), + Regex("""^共(\d{1,2})件$""") + ) + private val TOTAL_PATTERN = Regex( + """^(?:应付|实付款|合计|订单金额)[::]?[¥¥](\d{1,7})(?:\.(\d{1,2}))?$""" + ) + private val COLOR_PATTERN = + Regex("""^(?:颜色分类|颜色|颜色款式)[::]\s*(.+)$""") + private val SIZE_PATTERN = + Regex("""^(?:尺码|尺寸)[::]\s*(.+)$""") +} + +sealed interface PinduoduoOrderReconciliation { + data class Matched( + val order: PinduoduoPendingOrderObservation + ) : PinduoduoOrderReconciliation + + data class ManualReview( + val reasonCode: String + ) : PinduoduoOrderReconciliation +} + +object PinduoduoOrderReconciliationPolicy { + fun reconcile( + observations: Collection, + expectation: PinduoduoOrderSubmissionExpectation, + fencedAt: String, + now: Instant + ): PinduoduoOrderReconciliation { + val fence = runCatching { Instant.parse(fencedAt) }.getOrNull() + ?: return PinduoduoOrderReconciliation.ManualReview( + "ORDER_FIELDS_INCOMPLETE" + ) + val matches = observations.filter { observation -> + val orderedAt = runCatching { + Instant.parse(observation.platformOrderedAt) + }.getOrNull() ?: return@filter false + observation.status == "PENDING_PAYMENT" && + PinduoduoOrderConfirmationParser.normalize( + observation.observedTitle + ) == PinduoduoOrderConfirmationParser.normalize( + expectation.title + ) && + PinduoduoSpecificationTargetMatcher.matches( + PinduoduoSpecificationGroupKind.COLOR, + expectation.color, + observation.selectedSummary + ) && + PinduoduoSpecificationTargetMatcher.matches( + PinduoduoSpecificationGroupKind.SIZE, + expectation.size, + observation.selectedSummary + ) && + observation.quantity == expectation.quantity && + observation.totalPriceCents == expectation.totalPriceCents && + !orderedAt.isBefore(fence.minusSeconds(CLOCK_WINDOW_SECONDS)) && + !orderedAt.isAfter(now.plusSeconds(CLOCK_WINDOW_SECONDS)) + } + return when (matches.size) { + 1 -> PinduoduoOrderReconciliation.Matched(matches.single()) + 0 -> PinduoduoOrderReconciliation.ManualReview("ORDER_NOT_FOUND") + else -> PinduoduoOrderReconciliation.ManualReview( + "ORDER_AMBIGUOUS" + ) + } + } + + private const val CLOCK_WINDOW_SECONDS = 5L * 60L +} diff --git a/android-buyer/app/src/main/java/com/roubao/autopilot/procurement/OrderSubmissionCoordinator.kt b/android-buyer/app/src/main/java/com/roubao/autopilot/procurement/OrderSubmissionCoordinator.kt new file mode 100644 index 0000000..1883773 --- /dev/null +++ b/android-buyer/app/src/main/java/com/roubao/autopilot/procurement/OrderSubmissionCoordinator.kt @@ -0,0 +1,150 @@ +package com.roubao.autopilot.procurement + +import android.content.Context +import com.roubao.autopilot.pinduoduo.AndroidPinduoduoOrderSubmissionDriver +import com.roubao.autopilot.pinduoduo.PinduoduoEvidenceHash +import com.roubao.autopilot.pinduoduo.PinduoduoOrderReconciliation +import com.roubao.autopilot.pinduoduo.PinduoduoOrderReconciliationPolicy +import com.roubao.autopilot.pinduoduo.PinduoduoOrderSubmissionExpectation +import com.roubao.autopilot.vlm.SkuConstraintKind +import com.roubao.autopilot.vlm.SkuHardConstraintExtractor +import java.time.Instant + +class OrderSubmissionCoordinator( + context: Context, + private val repository: ProcurementRepository +) { + @Suppress("UNUSED_PARAMETER") + private val appContext = context.applicationContext + + suspend fun runOnce(): Boolean { + var context = + repository.currentAuthorizedOrderSubmissionContext() + ?: return false + val constraints = SkuHardConstraintExtractor.extract( + context.command.originalSku + ) + if (!constraints.readyForAutomaticMatching) { + repository.reportOrderSubmissionBlocked( + "SKU 的颜色或尺码无法唯一解析,未创建提交围栏" + ) + return false + } + val values = constraints.constraints.associate { + it.kind to it.expected + } + val expectation = PinduoduoOrderSubmissionExpectation( + title = requireNotNull(context.dryRun.observedTitle), + color = requireNotNull(values[SkuConstraintKind.COLOR]), + size = requireNotNull(values[SkuConstraintKind.SIZE]), + quantity = requireNotNull(context.dryRun.quantity), + totalPriceCents = requireNotNull( + context.dryRun.totalPriceCents + ) + ) + val driver = AndroidPinduoduoOrderSubmissionDriver(expectation) + val beforeFence = context.submission + if ( + beforeFence == null || + beforeFence.status == OrderSubmissionStatus.FENCE_INTENT_SAVED + ) { + if (driver.revalidate() == null) { + repository.reportOrderSubmissionBlocked( + "确认订单页缺少已配置地址、唯一提交按钮," + + "或存在免密/自动扣款语义;未创建提交围栏" + ) + return false + } + val armed = repository.armOrderSubmission() ?: return false + context = armed.context + if (armed.clickNow) { + val fenceID = requireNotNull(context.submission?.remoteId) + val clicked = driver.submitOnce(fenceID) + repository.markOrderSubmissionReconciling() + if (!clicked) { + repository.markOrderSubmissionManualReview( + "ORDER_PAGE_UNKNOWN" + ) + return false + } + } + } + context = repository.currentAuthorizedOrderSubmissionContext() + ?: return false + when (context.submission?.status) { + OrderSubmissionStatus.RECONCILED -> return true + OrderSubmissionStatus.CLICK_ASSUMED -> { + repository.markOrderSubmissionReconciling() + } + OrderSubmissionStatus.FENCE_INTENT_SAVED, + null -> return false + else -> Unit + } + val manualReview = + context.submission?.status == OrderSubmissionStatus.MANUAL_REVIEW + if (!driver.awaitAndNavigateToOrderList( + allowBackNavigation = !manualReview + ) + ) { + if ( + context.submission?.status != + OrderSubmissionStatus.MANUAL_REVIEW + ) { + repository.markOrderSubmissionManualReview( + "ORDER_PAGE_UNKNOWN" + ) + } + return false + } + val reconciliation = PinduoduoOrderReconciliationPolicy.reconcile( + observations = driver.pendingOrders(), + expectation = expectation, + fencedAt = requireNotNull( + repository.currentAuthorizedOrderSubmissionContext() + ?.submission + ?.fencedAt + ), + now = Instant.now() + ) + return when (reconciliation) { + is PinduoduoOrderReconciliation.Matched -> { + val screenshot = driver.captureOrderList() + if (screenshot == null) { + repository.markOrderSubmissionManualReview( + "EVIDENCE_UNAVAILABLE" + ) + false + } else { + val bytes = screenshot.pngBytes + val order = reconciliation.order + repository.reconcileOrderSubmission( + OrderReconciliationDraft( + platformOrderNo = order.platformOrderNo, + platformOrderedAt = + order.platformOrderedAt, + platformOrderStatus = order.status, + observedTitle = order.observedTitle, + selectedSku = context.command.originalSku, + quantity = order.quantity, + totalPriceCents = order.totalPriceCents, + orderListPng = bytes, + evidenceSha256 = + PinduoduoEvidenceHash.sha256(bytes) + ) + ) + } + } + is PinduoduoOrderReconciliation.ManualReview -> { + if ( + context.submission?.status != + OrderSubmissionStatus.MANUAL_REVIEW + ) { + repository.markOrderSubmissionManualReview( + reconciliation.reasonCode + ) + } + false + } + } + } +} 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 02b22c8..a42ed91 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 @@ -111,6 +111,39 @@ interface ProcurementRemoteApi { idempotencyKey: String ): RemoteOrderDryRun + suspend fun startOrderSubmission( + session: ProcurementSession, + task: RemotePurchaseTask, + execution: RunningExecution, + claimToken: String, + command: PendingOrderCommand, + dryRun: PendingOrderDryRun, + idempotencyKey: String + ): RemoteOrderSubmission + + suspend fun reconcileOrderSubmission( + session: ProcurementSession, + task: RemotePurchaseTask, + execution: RunningExecution, + claimToken: String, + command: PendingOrderCommand, + submission: PendingOrderSubmission, + draft: OrderReconciliationDraft, + evidenceAssetId: String, + idempotencyKey: String + ): RemoteOrderSubmission + + suspend fun markOrderSubmissionManualReview( + session: ProcurementSession, + task: RemotePurchaseTask, + execution: RunningExecution, + claimToken: String, + command: PendingOrderCommand, + submission: PendingOrderSubmission, + reasonCode: String, + idempotencyKey: String + ): RemoteOrderSubmission + suspend fun uploadExecutionOutboxItem( session: ProcurementSession, task: RemotePurchaseTask, @@ -532,6 +565,115 @@ class ProcurementApiClient( return parseOrderDryRun(json.getJSONObject("dry_run")) } + override suspend fun startOrderSubmission( + session: ProcurementSession, + task: RemotePurchaseTask, + execution: RunningExecution, + claimToken: String, + command: PendingOrderCommand, + dryRun: PendingOrderDryRun, + idempotencyKey: String + ): RemoteOrderSubmission { + 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) + .put("dry_run_id", requireNotNull(dryRun.remoteId)) + .put( + "dry_run_evidence_sha256", + requireNotNull(dryRun.evidenceSha256) + ) + .put("observed_title", requireNotNull(dryRun.observedTitle)) + .put("selected_sku", requireNotNull(dryRun.selectedSku)) + .put("quantity", requireNotNull(dryRun.quantity)) + .put("unit_price_cents", requireNotNull(dryRun.unitPriceCents)) + .put("total_price_cents", requireNotNull(dryRun.totalPriceCents)) + val json = executeJson( + authorizedRequest( + session, + "/api/v1/tasks/${task.id}/order-submissions/start" + ) + .header(CLAIM_TOKEN_HEADER, claimToken) + .header(IDEMPOTENCY_HEADER, idempotencyKey) + .post(payload.jsonBody()) + .build() + ) + return parseOrderSubmission(json.getJSONObject("submission")) + } + + override suspend fun reconcileOrderSubmission( + session: ProcurementSession, + task: RemotePurchaseTask, + execution: RunningExecution, + claimToken: String, + command: PendingOrderCommand, + submission: PendingOrderSubmission, + draft: OrderReconciliationDraft, + evidenceAssetId: String, + idempotencyKey: String + ): RemoteOrderSubmission { + 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) + .put("platform_order_no", draft.platformOrderNo) + .put("platform_ordered_at", draft.platformOrderedAt) + .put("platform_order_status", draft.platformOrderStatus) + .put("observed_title", draft.observedTitle) + .put("selected_sku", draft.selectedSku) + .put("quantity", draft.quantity) + .put("total_price_cents", draft.totalPriceCents) + .put("evidence_asset_id", evidenceAssetId) + .put("evidence_sha256", draft.evidenceSha256) + val json = executeJson( + authorizedRequest( + session, + "/api/v1/tasks/${task.id}/order-submissions/" + + "${requireNotNull(submission.remoteId)}/reconcile" + ) + .header(CLAIM_TOKEN_HEADER, claimToken) + .header(IDEMPOTENCY_HEADER, idempotencyKey) + .post(payload.jsonBody()) + .build() + ) + return parseOrderSubmission(json.getJSONObject("submission")) + } + + override suspend fun markOrderSubmissionManualReview( + session: ProcurementSession, + task: RemotePurchaseTask, + execution: RunningExecution, + claimToken: String, + command: PendingOrderCommand, + submission: PendingOrderSubmission, + reasonCode: String, + idempotencyKey: String + ): RemoteOrderSubmission { + 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) + .put("reason_code", reasonCode) + val json = executeJson( + authorizedRequest( + session, + "/api/v1/tasks/${task.id}/order-submissions/" + + "${requireNotNull(submission.remoteId)}/manual-review" + ) + .header(CLAIM_TOKEN_HEADER, claimToken) + .header(IDEMPOTENCY_HEADER, idempotencyKey) + .post(payload.jsonBody()) + .build() + ) + return parseOrderSubmission(json.getJSONObject("submission")) + } + private suspend fun executeTransition( session: ProcurementSession, task: RemotePurchaseTask, @@ -695,7 +837,58 @@ class ProcurementApiClient( id = json.getString("id"), commandId = json.getString("command_id"), commandSha256 = json.getString("command_sha256"), - status = json.getString("status") + status = json.getString("status"), + observedTitle = json.optionalString("observed_title"), + selectedSku = json.optionalString("selected_sku"), + quantity = if (json.has("quantity") && !json.isNull("quantity")) { + json.getInt("quantity") + } else { + null + }, + unitPriceCents = + if ( + json.has("unit_price_cents") && + !json.isNull("unit_price_cents") + ) { + json.getLong("unit_price_cents") + } else { + null + }, + totalPriceCents = + if ( + json.has("total_price_cents") && + !json.isNull("total_price_cents") + ) { + json.getLong("total_price_cents") + } else { + null + }, + evidenceSha256 = json.optionalString("evidence_sha256") + ) + + private fun parseOrderSubmission(json: JSONObject): RemoteOrderSubmission = + RemoteOrderSubmission( + id = json.getString("id"), + commandId = json.getString("command_id"), + dryRunId = json.getString("dry_run_id"), + commandSha256 = json.getString("command_sha256"), + dryRunEvidenceSha256 = + json.getString("dry_run_evidence_sha256"), + status = json.getString("status"), + expectedTitle = json.getString("expected_title"), + expectedSku = json.getString("expected_sku"), + expectedQuantity = json.getInt("expected_quantity"), + expectedUnitPriceCents = + json.getLong("expected_unit_price_cents"), + expectedTotalPriceCents = + json.getLong("expected_total_price_cents"), + platformOrderNo = json.optionalString("platform_order_no"), + platformOrderedAt = + json.optionalString("platform_ordered_at"), + platformOrderStatus = + json.optionalString("platform_order_status"), + manualReasonCode = json.optionalString("manual_reason_code"), + fencedAt = json.getString("fenced_at") ) private fun taskOrderCommandId(payload: String): String? = 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 969cd4b..ad2dd07 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 @@ -43,6 +43,10 @@ class ProcurementExecutionService : Service() { applicationContext, repository ) + val orderSubmission = OrderSubmissionCoordinator( + applicationContext, + repository + ) while (isActive) { val decision = repository.synchronizeRunning() if (decision == ExecutionSyncDecision.STOP) { @@ -62,6 +66,23 @@ class ProcurementExecutionService : Service() { ) } } + if ( + repository.shouldRunOrderSubmission && + readiness.snapshot().canStartProbe + ) { + runCatching { orderSubmission.runOnce() } + .onFailure { error -> + Log.w( + TAG, + "Single order submission stopped safely", + error + ) + } + } + if (repository.hasReconciledOrder) { + stopSelf() + break + } delay(HEARTBEAT_INTERVAL_MS) } } 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 382e5e9..d4dd554 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 @@ -13,6 +13,9 @@ enum class ProcurementPhase { ORDER_AUTHORIZED, ORDER_DRY_RUN_RUNNING, ORDER_DRY_RUN_READY, + ORDER_SUBMISSION_RECONCILING, + ORDER_SUBMISSION_MANUAL_REVIEW, + ORDER_RECONCILED, AUTHORIZATION_EXPIRED } @@ -90,7 +93,8 @@ data class PersistedProcurementState( val execution: RunningExecution? = null, val outbox: List = emptyList(), val orderCommand: PendingOrderCommand? = null, - val orderDryRun: PendingOrderDryRun? = null + val orderDryRun: PendingOrderDryRun? = null, + val orderSubmission: PendingOrderSubmission? = null ) data class OrderCommandCandidate( @@ -158,7 +162,13 @@ data class RemoteOrderDryRun( val id: String, val commandId: String, val commandSha256: String, - val status: String + val status: String, + 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 OrderDryRunReadyDraft( @@ -181,6 +191,118 @@ data class AuthorizedOrderDryRunContext( val maxBudgetCents: Long? ) +enum class OrderSubmissionStatus { + FENCE_INTENT_SAVED, + CLICK_ASSUMED, + RECONCILING, + MANUAL_REVIEW, + RECONCILED +} + +data class OrderSubmissionFenceTransition( + val status: OrderSubmissionStatus, + val mayClickInCurrentCall: Boolean +) + +object OrderSubmissionRecoveryPolicy { + fun afterFenceResponse( + persistedBeforeRequest: OrderSubmissionStatus, + remoteStatus: String, + intentCreatedInCurrentCall: Boolean + ): OrderSubmissionFenceTransition = + when (remoteStatus) { + "RECONCILED" -> OrderSubmissionFenceTransition( + OrderSubmissionStatus.RECONCILED, + false + ) + "MANUAL_REVIEW" -> OrderSubmissionFenceTransition( + OrderSubmissionStatus.MANUAL_REVIEW, + false + ) + "FENCED" -> OrderSubmissionFenceTransition( + OrderSubmissionStatus.CLICK_ASSUMED, + intentCreatedInCurrentCall && + persistedBeforeRequest == + OrderSubmissionStatus.FENCE_INTENT_SAVED + ) + else -> throw IllegalArgumentException( + "unsupported remote submission status" + ) + } + + fun mayResumeClick(status: OrderSubmissionStatus): Boolean = + when (status) { + OrderSubmissionStatus.FENCE_INTENT_SAVED, + OrderSubmissionStatus.CLICK_ASSUMED, + OrderSubmissionStatus.RECONCILING, + OrderSubmissionStatus.MANUAL_REVIEW, + OrderSubmissionStatus.RECONCILED -> false + } +} + +data class PendingOrderSubmission( + val commandId: String, + val commandSha256: String, + val dryRunId: String, + val dryRunEvidenceSha256: String, + val status: OrderSubmissionStatus, + val startIdempotencyKey: String, + val evidenceIdempotencyKey: String, + val reconcileIdempotencyKey: String, + val manualReviewIdempotencyKey: String, + val remoteId: String? = null, + val fencedAt: String? = null, + val platformOrderNo: String? = null, + val platformOrderedAt: String? = null, + val platformOrderStatus: String? = null, + val reconciliationEvidenceRelativePath: String? = null, + val reconciliationEvidenceSha256: String? = null, + val manualReasonCode: String? = null +) + +data class RemoteOrderSubmission( + val id: String, + val commandId: String, + val dryRunId: String, + val commandSha256: String, + val dryRunEvidenceSha256: String, + val status: String, + val expectedTitle: String, + val expectedSku: String, + val expectedQuantity: Int, + val expectedUnitPriceCents: Long, + val expectedTotalPriceCents: Long, + val platformOrderNo: String?, + val platformOrderedAt: String?, + val platformOrderStatus: String?, + val manualReasonCode: String?, + val fencedAt: String +) + +data class AuthorizedOrderSubmissionContext( + val task: RemotePurchaseTask, + val command: PendingOrderCommand, + val dryRun: PendingOrderDryRun, + val submission: PendingOrderSubmission? +) + +data class ArmedOrderSubmission( + val context: AuthorizedOrderSubmissionContext, + val clickNow: Boolean +) + +data class OrderReconciliationDraft( + val platformOrderNo: String, + val platformOrderedAt: String, + val platformOrderStatus: String, + val observedTitle: String, + val selectedSku: String, + val quantity: Int, + val totalPriceCents: Long, + val orderListPng: ByteArray, + val evidenceSha256: String +) + enum class ExecutionMode { MANUAL_FIRST, AI_ASSISTED @@ -223,6 +345,7 @@ data class ProcurementUiState( val execution: RunningExecution? = null, val orderCommand: PendingOrderCommand? = null, val orderDryRun: PendingOrderDryRun? = null, + val orderSubmission: PendingOrderSubmission? = 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 f0a3ca3..592e75d 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 @@ -42,8 +42,17 @@ class ProcurementRepository( val uiState: StateFlow = _uiState.asStateFlow() val hasRunningExecution: Boolean - get() = storageFailure == null && - persisted.execution?.safetyStopped == false + get() { + val submission = persisted.orderSubmission + val reconciliationPending = + submission?.remoteId != null && + submission.status != OrderSubmissionStatus.RECONCILED + return storageFailure == null && + ( + persisted.execution?.safetyStopped == false || + reconciliationPending + ) + } val canCollectCandidates: Boolean get() { @@ -72,6 +81,27 @@ class ProcurementRepository( (dryRun?.attemptCount ?: 0) < MAX_ORDER_DRY_RUN_ATTEMPTS } + val shouldRunOrderSubmission: Boolean + get() { + val execution = persisted.execution ?: return false + val dryRun = persisted.orderDryRun ?: return false + val submission = persisted.orderSubmission + val reconciliationPending = + submission?.remoteId != null && + submission.status != OrderSubmissionStatus.RECONCILED + return storageFailure == null && + dryRun.status == OrderDryRunStatus.READY && + submission?.status != OrderSubmissionStatus.RECONCILED && + ( + reconciliationPending || + (!execution.safetyStopped && !execution.isExpired()) + ) + } + + val hasReconciledOrder: Boolean + get() = persisted.orderSubmission?.status == + OrderSubmissionStatus.RECONCILED + suspend fun login(input: LoginInput): Boolean = operation { require(input.password.isNotEmpty()) { "请输入采购员密码" } val normalizedUrl = BackendEndpointPolicy.normalize( @@ -305,7 +335,21 @@ class ProcurementRepository( if (claim == null || task == null || execution == null) { return@withLock ExecutionSyncDecision.STOP } - if (execution.isExpired() && !execution.safetyStopped) { + if (persisted.orderSubmission?.status == + OrderSubmissionStatus.RECONCILED + ) { + return@withLock ExecutionSyncDecision.STOP + } + val reconciliationPending = + persisted.orderSubmission?.let { submission -> + submission.remoteId != null && + submission.status != OrderSubmissionStatus.RECONCILED + } == true + if ( + execution.isExpired() && + !execution.safetyStopped && + !reconciliationPending + ) { execution = execution.copy( currentStep = SAFE_STOPPED_STEP, safetyStopped = true @@ -317,7 +361,11 @@ class ProcurementRepository( error = "离线执行授权已到期,自动化已安全停止" ) } - if (execution.safetyStopped && !allowSafetyStoppedSync) { + if ( + execution.safetyStopped && + !allowSafetyStoppedSync && + !reconciliationPending + ) { return@withLock ExecutionSyncDecision.STOP } val session = persisted.session @@ -341,6 +389,16 @@ class ProcurementRepository( return@withLock ExecutionSyncDecision.STOP } execution = persisted.execution ?: return@withLock ExecutionSyncDecision.STOP + if ( + reconciliationPending && + (execution.safetyStopped || execution.isExpired()) + ) { + publish( + backendOnline = true, + message = "提交围栏已生效;授权到期后仅继续订单对账" + ) + return@withLock ExecutionSyncDecision.CONTINUE + } if (execution.safetyStopped) { publish( backendOnline = true, @@ -654,6 +712,384 @@ class ProcurementRepository( true } ?: false + suspend fun currentAuthorizedOrderSubmissionContext(): + AuthorizedOrderSubmissionContext? = operation { + val task = requireCurrentTask() + val execution = requireActiveExecution() + val command = requireNotNull(persisted.orderCommand) { + "尚未收到后台下单命令" + } + val dryRun = requireNotNull(persisted.orderDryRun) { + "订单核验尚未完成" + } + require(command.acknowledged) { "下单命令尚未安全确认" } + require(dryRun.status == OrderDryRunStatus.READY) { + "本地和后台订单核验尚未同时就绪" + } + val existingSubmission = persisted.orderSubmission + val reconciliationPending = existingSubmission?.remoteId != null + require( + reconciliationPending || + (!execution.safetyStopped && !execution.isExpired()) + ) { + "执行授权已到期,不能提交订单" + } + require( + command.taskId == task.id && + command.executionId == execution.id && + dryRun.commandId == command.id && + dryRun.commandSha256 == command.commandSha256 && + dryRun.remoteId != null && + dryRun.evidenceSha256 != null + ) { "订单核验与后台授权不一致" } + existingSubmission?.let { submission -> + require( + submission.commandId == command.id && + submission.commandSha256 == command.commandSha256 && + submission.dryRunId == dryRun.remoteId && + submission.dryRunEvidenceSha256 == + dryRun.evidenceSha256 + ) { "本地单次提交围栏与订单核验不一致" } + } + AuthorizedOrderSubmissionContext( + task = task, + command = command, + dryRun = dryRun, + submission = existingSubmission + ) + } + + suspend fun armOrderSubmission(): ArmedOrderSubmission? = operation { + val session = requireValidSession() + val claim = requireNotNull(persisted.claim) + val task = requireCurrentTask() + val execution = requireActiveExecution() + val command = requireNotNull(persisted.orderCommand) + var dryRun = requireNotNull(persisted.orderDryRun) + require(dryRun.status == OrderDryRunStatus.READY) { + "订单核验尚未在后台就绪" + } + require(!execution.safetyStopped && !execution.isExpired()) { + "执行授权已到期,不能创建提交围栏" + } + val refreshedDryRun = api.startOrderDryRun( + session, + task, + execution, + claim.token, + command, + dryRun.startIdempotencyKey + ) + require( + refreshedDryRun.id == dryRun.remoteId && + refreshedDryRun.commandId == command.id && + refreshedDryRun.commandSha256 == command.commandSha256 && + refreshedDryRun.status == "READY" && + refreshedDryRun.observedTitle != null && + normalizeTitle(refreshedDryRun.observedTitle) == + normalizeTitle(requireNotNull(dryRun.observedTitle)) && + refreshedDryRun.selectedSku == dryRun.selectedSku && + refreshedDryRun.quantity == dryRun.quantity && + refreshedDryRun.unitPriceCents == dryRun.unitPriceCents && + refreshedDryRun.totalPriceCents == + dryRun.totalPriceCents && + refreshedDryRun.evidenceSha256?.matches( + SHA256_PATTERN + ) == true + ) { "后台 READY 核验记录与本地状态不一致" } + dryRun = dryRun.copy( + evidenceSha256 = refreshedDryRun.evidenceSha256 + ) + persisted = persisted.copy(orderDryRun = dryRun) + store.save(persisted) + var submission = persisted.orderSubmission + var intentCreatedInCurrentCall = false + if (submission == null) { + intentCreatedInCurrentCall = true + submission = PendingOrderSubmission( + commandId = command.id, + commandSha256 = command.commandSha256, + dryRunId = requireNotNull(dryRun.remoteId), + dryRunEvidenceSha256 = + requireNotNull(dryRun.evidenceSha256), + status = OrderSubmissionStatus.FENCE_INTENT_SAVED, + startIdempotencyKey = newOpaqueSecret(), + evidenceIdempotencyKey = newOpaqueSecret(), + reconcileIdempotencyKey = newOpaqueSecret(), + manualReviewIdempotencyKey = newOpaqueSecret() + ) + persisted = persisted.copy(orderSubmission = submission) + store.save(persisted) + } + require( + submission.commandId == command.id && + submission.commandSha256 == command.commandSha256 && + submission.dryRunId == dryRun.remoteId && + submission.dryRunEvidenceSha256 == dryRun.evidenceSha256 + ) { "提交围栏与当前核验不一致" } + val clickNow = + submission.status == OrderSubmissionStatus.FENCE_INTENT_SAVED + if (!clickNow) { + return@operation ArmedOrderSubmission( + AuthorizedOrderSubmissionContext( + task, + command, + dryRun, + submission + ), + clickNow = false + ) + } + val remote = api.startOrderSubmission( + session, + task, + execution, + claim.token, + command, + dryRun, + submission.startIdempotencyKey + ) + require( + remote.commandId == command.id && + remote.dryRunId == dryRun.remoteId && + remote.commandSha256 == command.commandSha256 && + remote.dryRunEvidenceSha256 == dryRun.evidenceSha256 && + remote.status in setOf( + "FENCED", + "MANUAL_REVIEW", + "RECONCILED" + ) && + normalizeTitle(remote.expectedTitle) == + normalizeTitle(requireNotNull(dryRun.observedTitle)) && + remote.expectedSku == dryRun.selectedSku && + remote.expectedQuantity == dryRun.quantity && + remote.expectedUnitPriceCents == dryRun.unitPriceCents && + remote.expectedTotalPriceCents == dryRun.totalPriceCents + ) { "后台返回的单次提交围栏与本地核验不一致" } + val transition = OrderSubmissionRecoveryPolicy.afterFenceResponse( + submission.status, + remote.status, + intentCreatedInCurrentCall + ) + val localStatus = transition.status + submission = submission.copy( + status = localStatus, + remoteId = remote.id, + fencedAt = remote.fencedAt, + platformOrderNo = remote.platformOrderNo, + platformOrderedAt = remote.platformOrderedAt, + platformOrderStatus = remote.platformOrderStatus, + manualReasonCode = remote.manualReasonCode + ) + persisted = persisted.copy( + execution = execution.copy( + currentStep = ORDER_SUBMISSION_FENCED_STEP + ), + orderSubmission = submission + ) + saveAndPublish( + backendOnline = true, + message = "单次提交围栏已保存;此后不会重复点击" + ) + ArmedOrderSubmission( + AuthorizedOrderSubmissionContext( + task, + command, + dryRun, + submission + ), + clickNow = transition.mayClickInCurrentCall + ) + } + + suspend fun markOrderSubmissionReconciling(): Boolean = operation { + val execution = requireActiveExecution() + val submission = requireNotNull(persisted.orderSubmission) + require(submission.status == OrderSubmissionStatus.CLICK_ASSUMED) { + "提交点击资格已经消费" + } + persisted = persisted.copy( + execution = execution.copy( + currentStep = ORDER_SUBMISSION_RECONCILING_STEP + ), + orderSubmission = submission.copy( + status = OrderSubmissionStatus.RECONCILING + ) + ) + saveAndPublish( + backendOnline = _uiState.value.backendOnline, + message = "提交结果不作假设,正在订单列表对账" + ) + true + } ?: false + + suspend fun reconcileOrderSubmission( + draft: OrderReconciliationDraft + ): Boolean = operation { + val session = requireValidSession() + val claim = requireNotNull(persisted.claim) + val task = requireCurrentTask() + val execution = requireActiveExecution() + val command = requireNotNull(persisted.orderCommand) + var submission = requireNotNull(persisted.orderSubmission) + require( + submission.status in setOf( + OrderSubmissionStatus.RECONCILING, + OrderSubmissionStatus.MANUAL_REVIEW + ) + ) { "当前提交状态不能回传订单对账" } + require( + draft.platformOrderStatus == "PENDING_PAYMENT" && + draft.platformOrderNo.matches(PLATFORM_ORDER_NO_PATTERN) && + normalizeTitle(draft.observedTitle) == + normalizeTitle(requireNotNull(persisted.orderDryRun?.observedTitle)) && + draft.selectedSku == persisted.orderDryRun?.selectedSku && + draft.quantity == persisted.orderDryRun?.quantity && + draft.totalPriceCents == + persisted.orderDryRun?.totalPriceCents && + draft.orderListPng.isNotEmpty() && + sha256(draft.orderListPng) == draft.evidenceSha256 + ) { "订单列表对账证据与提交围栏不一致" } + val evidenceID = UUID.randomUUID().toString() + val relativePath = + submission.reconciliationEvidenceRelativePath + ?: "$OUTBOX_DIRECTORY/order-$evidenceID.png" + if (submission.reconciliationEvidenceRelativePath == null) { + persistEvidenceLocked( + File(relativePath).nameWithoutExtension, + draft.orderListPng + ) + submission = submission.copy( + status = OrderSubmissionStatus.RECONCILING, + reconciliationEvidenceRelativePath = relativePath, + reconciliationEvidenceSha256 = draft.evidenceSha256, + platformOrderNo = draft.platformOrderNo, + platformOrderedAt = draft.platformOrderedAt, + platformOrderStatus = draft.platformOrderStatus + ) + persisted = persisted.copy(orderSubmission = submission) + store.save(persisted) + } + val evidenceFile = File( + appContext.filesDir, + requireNotNull(submission.reconciliationEvidenceRelativePath) + ) + require(evidenceFile.isFile) { "订单对账截图文件缺失" } + val uploaded = api.uploadExecutionOutboxItem( + session, + task, + execution, + claim.token, + ExecutionOutboxItem( + id = File(relativePath).nameWithoutExtension, + type = ExecutionOutboxType.EVIDENCE, + idempotencyKey = submission.evidenceIdempotencyKey, + payload = "{}", + evidenceRelativePath = relativePath + ), + evidenceFile.readBytes() + ) + val remoteEvidenceID = requireNotNull(uploaded.evidenceAssetId) + val remoteEvidenceSHA = requireNotNull(uploaded.evidenceSha256) + val remote = api.reconcileOrderSubmission( + session, + task, + execution, + claim.token, + command, + submission, + draft.copy(evidenceSha256 = remoteEvidenceSHA), + remoteEvidenceID, + submission.reconcileIdempotencyKey + ) + require( + remote.id == submission.remoteId && + remote.status == "RECONCILED" && + remote.platformOrderNo == draft.platformOrderNo && + remote.platformOrderStatus == "PENDING_PAYMENT" + ) { "后台订单对账结果不一致" } + evidenceFile.delete() + submission = submission.copy( + status = OrderSubmissionStatus.RECONCILED, + platformOrderNo = remote.platformOrderNo, + platformOrderedAt = remote.platformOrderedAt, + platformOrderStatus = remote.platformOrderStatus, + reconciliationEvidenceRelativePath = null, + reconciliationEvidenceSha256 = remoteEvidenceSHA, + manualReasonCode = null + ) + persisted = persisted.copy( + execution = execution.copy(currentStep = ORDER_RECONCILED_STEP), + orderSubmission = submission + ) + saveAndPublish( + backendOnline = true, + message = "待付款订单已唯一对账并回传后台" + ) + true + } ?: false + + suspend fun markOrderSubmissionManualReview( + reasonCode: String + ): Boolean = operation { + require(reasonCode in ORDER_SUBMISSION_MANUAL_REASONS) { + "订单人工对账理由无效" + } + val session = requireValidSession() + val claim = requireNotNull(persisted.claim) + val task = requireCurrentTask() + val execution = requireActiveExecution() + val command = requireNotNull(persisted.orderCommand) + var submission = requireNotNull(persisted.orderSubmission) + if (submission.status == OrderSubmissionStatus.MANUAL_REVIEW) { + return@operation true + } + require( + submission.status in setOf( + OrderSubmissionStatus.CLICK_ASSUMED, + OrderSubmissionStatus.RECONCILING + ) + ) { "当前状态不能转人工对账" } + val remote = api.markOrderSubmissionManualReview( + session, + task, + execution, + claim.token, + command, + submission, + reasonCode, + submission.manualReviewIdempotencyKey + ) + require( + remote.id == submission.remoteId && + remote.status == "MANUAL_REVIEW" + ) { "后台人工对账状态无效" } + submission = submission.copy( + status = OrderSubmissionStatus.MANUAL_REVIEW, + manualReasonCode = remote.manualReasonCode ?: reasonCode + ) + persisted = persisted.copy( + execution = execution.copy( + currentStep = ORDER_SUBMISSION_MANUAL_REVIEW_STEP + ), + orderSubmission = submission + ) + saveAndPublish( + backendOnline = true, + message = "未唯一找到新订单,保持禁止重复提交并转人工对账" + ) + true + } ?: false + + suspend fun reportOrderSubmissionBlocked(message: String) { + mutex.withLock { + publish( + backendOnline = _uiState.value.backendOnline, + error = message + ) + } + } + suspend fun queueCandidateBatch( batch: ExecutionCandidateBatchDraft, evidence: List @@ -1003,7 +1439,8 @@ class ProcurementRepository( execution = null, outbox = emptyList(), orderCommand = null, - orderDryRun = null + orderDryRun = null, + orderSubmission = null ) } @@ -1204,7 +1641,15 @@ class ProcurementRepository( require( remote.commandId == command.id && remote.commandSha256 == command.commandSha256 && - remote.status == "READY" + remote.status == "READY" && + !remote.observedTitle.isNullOrBlank() && + !remote.selectedSku.isNullOrBlank() && + remote.quantity != null && + remote.unitPriceCents != null && + remote.totalPriceCents != null && + remote.evidenceSha256?.matches( + SHA256_PATTERN + ) == true ) { "后台订单核验状态无效" } persisted = persisted.copy( execution = execution.copy( @@ -1212,7 +1657,13 @@ class ProcurementRepository( ), orderDryRun = requireNotNull(persisted.orderDryRun).copy( status = OrderDryRunStatus.READY, - remoteId = remote.id + remoteId = remote.id, + observedTitle = remote.observedTitle, + selectedSku = remote.selectedSku, + quantity = remote.quantity, + unitPriceCents = remote.unitPriceCents, + totalPriceCents = remote.totalPriceCents, + evidenceSha256 = remote.evidenceSha256 ) ) } @@ -1401,6 +1852,14 @@ class ProcurementRepository( execution != null && (execution.safetyStopped || execution.isExpired()) -> ProcurementPhase.AUTHORIZATION_EXPIRED + persisted.orderSubmission?.status == + OrderSubmissionStatus.RECONCILED -> + ProcurementPhase.ORDER_RECONCILED + persisted.orderSubmission?.status == + OrderSubmissionStatus.MANUAL_REVIEW -> + ProcurementPhase.ORDER_SUBMISSION_MANUAL_REVIEW + persisted.orderSubmission != null -> + ProcurementPhase.ORDER_SUBMISSION_RECONCILING persisted.orderDryRun?.status == OrderDryRunStatus.READY -> ProcurementPhase.ORDER_DRY_RUN_READY persisted.orderDryRun != null -> @@ -1432,6 +1891,7 @@ class ProcurementRepository( execution = execution, orderCommand = persisted.orderCommand, orderDryRun = persisted.orderDryRun, + orderSubmission = persisted.orderSubmission, authenticationRequired = session?.isValid() != true ) } @@ -1487,6 +1947,13 @@ class ProcurementRepository( 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 ORDER_SUBMISSION_FENCED_STEP = + "ORDER_SUBMISSION_FENCED" + private const val ORDER_SUBMISSION_RECONCILING_STEP = + "ORDER_SUBMISSION_RECONCILING" + private const val ORDER_SUBMISSION_MANUAL_REVIEW_STEP = + "ORDER_SUBMISSION_MANUAL_REVIEW" + private const val ORDER_RECONCILED_STEP = "ORDER_RECONCILED" 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 @@ -1496,6 +1963,15 @@ class ProcurementRepository( 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 PLATFORM_ORDER_NO_PATTERN = Regex("^[0-9]{8,40}$") + private val ORDER_SUBMISSION_MANUAL_REASONS = setOf( + "ORDER_NOT_FOUND", + "ORDER_AMBIGUOUS", + "ORDER_FIELDS_INCOMPLETE", + "ORDER_PAGE_UNKNOWN", + "RISK_OR_PAYMENT_BOUNDARY", + "EVIDENCE_UNAVAILABLE" + ) 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 019e14f..3e996ff 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 @@ -141,6 +141,9 @@ class ProcurementSecureStore(context: Context) : ProcurementStateStore { state.orderDryRun?.let { dryRun -> put("order_dry_run", encodeOrderDryRun(dryRun)) } + state.orderSubmission?.let { submission -> + put("order_submission", encodeOrderSubmission(submission)) + } } private fun decodeState(json: JSONObject): PersistedProcurementState = @@ -230,6 +233,9 @@ class ProcurementSecureStore(context: Context) : ProcurementStateStore { }, orderDryRun = json.optionalObject("order_dry_run")?.let { decodeOrderDryRun(it) + }, + orderSubmission = json.optionalObject("order_submission")?.let { + decodeOrderSubmission(it) } ) @@ -288,6 +294,82 @@ class ProcurementSecureStore(context: Context) : ProcurementStateStore { evidenceSha256 = json.optionalString("evidence_sha256") ) + private fun encodeOrderSubmission( + submission: PendingOrderSubmission + ): JSONObject = + JSONObject().apply { + put("command_id", submission.commandId) + put("command_sha256", submission.commandSha256) + put("dry_run_id", submission.dryRunId) + put( + "dry_run_evidence_sha256", + submission.dryRunEvidenceSha256 + ) + put("status", submission.status.name) + put("start_idempotency_key", submission.startIdempotencyKey) + put( + "evidence_idempotency_key", + submission.evidenceIdempotencyKey + ) + put( + "reconcile_idempotency_key", + submission.reconcileIdempotencyKey + ) + put( + "manual_review_idempotency_key", + submission.manualReviewIdempotencyKey + ) + putNullable("remote_id", submission.remoteId) + putNullable("fenced_at", submission.fencedAt) + putNullable("platform_order_no", submission.platformOrderNo) + putNullable("platform_ordered_at", submission.platformOrderedAt) + putNullable( + "platform_order_status", + submission.platformOrderStatus + ) + putNullable( + "reconciliation_evidence_relative_path", + submission.reconciliationEvidenceRelativePath + ) + putNullable( + "reconciliation_evidence_sha256", + submission.reconciliationEvidenceSha256 + ) + putNullable("manual_reason_code", submission.manualReasonCode) + } + + private fun decodeOrderSubmission( + json: JSONObject + ): PendingOrderSubmission = + PendingOrderSubmission( + commandId = json.getString("command_id"), + commandSha256 = json.getString("command_sha256"), + dryRunId = json.getString("dry_run_id"), + dryRunEvidenceSha256 = + json.getString("dry_run_evidence_sha256"), + status = OrderSubmissionStatus.valueOf(json.getString("status")), + startIdempotencyKey = json.getString("start_idempotency_key"), + evidenceIdempotencyKey = + json.getString("evidence_idempotency_key"), + reconcileIdempotencyKey = + json.getString("reconcile_idempotency_key"), + manualReviewIdempotencyKey = + json.getString("manual_review_idempotency_key"), + remoteId = json.optionalString("remote_id"), + fencedAt = json.optionalString("fenced_at"), + platformOrderNo = json.optionalString("platform_order_no"), + platformOrderedAt = json.optionalString("platform_ordered_at"), + platformOrderStatus = + json.optionalString("platform_order_status"), + reconciliationEvidenceRelativePath = + json.optionalString( + "reconciliation_evidence_relative_path" + ), + reconciliationEvidenceSha256 = + json.optionalString("reconciliation_evidence_sha256"), + manualReasonCode = json.optionalString("manual_reason_code") + ) + 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 761a6de..a0fedc0 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 @@ -200,6 +200,9 @@ fun ProcurementScreen( ProcurementPhase.ORDER_AUTHORIZED, ProcurementPhase.ORDER_DRY_RUN_RUNNING, ProcurementPhase.ORDER_DRY_RUN_READY, + ProcurementPhase.ORDER_SUBMISSION_RECONCILING, + ProcurementPhase.ORDER_SUBMISSION_MANUAL_REVIEW, + ProcurementPhase.ORDER_RECONCILED, ProcurementPhase.AUTHORIZATION_EXPIRED -> { if (state.authenticationRequired) { item(key = "procurement-login") { @@ -497,12 +500,19 @@ private fun ExecutionDetails(state: ProcurementUiState) { "正在重新定位商品并核验规格、数量和金额" ProcurementPhase.ORDER_DRY_RUN_READY -> "订单已核验,等待单次提交" + ProcurementPhase.ORDER_SUBMISSION_RECONCILING -> + "提交围栏已消费,只允许查看订单列表对账" + ProcurementPhase.ORDER_SUBMISSION_MANUAL_REVIEW -> + "订单未能唯一对账,保持禁止重复提交" + ProcurementPhase.ORDER_RECONCILED -> + "待付款订单已回传后台,请人工确认付款" else -> "订单提交保持禁用" }, color = if ( state.phase in setOf( ProcurementPhase.ORDER_AUTHORIZED, - ProcurementPhase.ORDER_DRY_RUN_READY + ProcurementPhase.ORDER_DRY_RUN_READY, + ProcurementPhase.ORDER_RECONCILED ) ) { colors.success @@ -545,7 +555,10 @@ private fun phaseColor(state: ProcurementUiState) = ProcurementPhase.WAITING_ADMIN_CONFIRMATION, ProcurementPhase.ORDER_AUTHORIZED, ProcurementPhase.ORDER_DRY_RUN_RUNNING, - ProcurementPhase.ORDER_DRY_RUN_READY -> BaoziTheme.colors.success + ProcurementPhase.ORDER_DRY_RUN_READY, + ProcurementPhase.ORDER_SUBMISSION_RECONCILING, + ProcurementPhase.ORDER_SUBMISSION_MANUAL_REVIEW, + ProcurementPhase.ORDER_RECONCILED -> BaoziTheme.colors.success else -> BaoziTheme.colors.textSecondary } @@ -559,5 +572,8 @@ private fun phaseLabel(state: ProcurementUiState): String = ProcurementPhase.ORDER_AUTHORIZED -> "后台商品授权已同步" ProcurementPhase.ORDER_DRY_RUN_RUNNING -> "正在核验订单" ProcurementPhase.ORDER_DRY_RUN_READY -> "订单已核验,等待提交" + ProcurementPhase.ORDER_SUBMISSION_RECONCILING -> "订单提交后正在对账" + ProcurementPhase.ORDER_SUBMISSION_MANUAL_REVIEW -> "等待人工对账" + ProcurementPhase.ORDER_RECONCILED -> "待付款订单已回传" ProcurementPhase.AUTHORIZATION_EXPIRED -> "授权到期,已停止" } diff --git a/android-buyer/app/src/test/java/com/roubao/autopilot/pinduoduo/PinduoduoOrderSubmissionModelsTest.kt b/android-buyer/app/src/test/java/com/roubao/autopilot/pinduoduo/PinduoduoOrderSubmissionModelsTest.kt new file mode 100644 index 0000000..1bfba69 --- /dev/null +++ b/android-buyer/app/src/test/java/com/roubao/autopilot/pinduoduo/PinduoduoOrderSubmissionModelsTest.kt @@ -0,0 +1,186 @@ +package com.roubao.autopilot.pinduoduo + +import java.time.Instant +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class PinduoduoOrderSubmissionModelsTest { + private val expectation = PinduoduoOrderSubmissionExpectation( + title = "设计拼接T恤短袖2026轻奢小众夏季气质新款百搭漂亮上衣出片", + color = "GRAY", + size = "2XL", + quantity = 1, + totalPriceCents = 1_435L + ) + + @Test + fun `safe confirmation requires address and one exact submit action`() { + val readiness = PinduoduoOrderSubmissionSafetyPolicy.inspect( + confirmationElements(), + expectation + ) + + assertNotNull(readiness) + assertTrue(readiness!!.addressConfigured) + assertEquals(1, readiness.exactSubmitActionCount) + } + + @Test + fun `missing address and automatic payment semantics fail closed`() { + assertNull( + PinduoduoOrderSubmissionSafetyPolicy.inspect( + confirmationElements().map { + if (it.text == "张三 138****1234") { + it.copy(text = "手动添加收货地址") + } else { + it + } + }, + expectation + ) + ) + assertNull( + PinduoduoOrderSubmissionSafetyPolicy.inspect( + confirmationElements() + element("先用后付"), + expectation + ) + ) + } + + @Test + fun `duplicate or payment submit actions are never accepted`() { + assertNull( + PinduoduoOrderSubmissionSafetyPolicy.inspect( + confirmationElements() + element( + "提交订单", + clickable = true + ), + expectation + ) + ) + assertNull( + PinduoduoOrderSubmissionSafetyPolicy.inspect( + confirmationElements().map { + if (it.text == "提交订单") { + it.copy(text = "立即支付") + } else { + it + } + }, + expectation + ) + ) + } + + @Test + fun `pending order parser reads labeled identity and local order time`() { + val parsed = PinduoduoPendingOrderParser.parse(orderElements()) + + assertNotNull(parsed) + assertEquals("12345678901234567890", parsed!!.platformOrderNo) + assertEquals("PENDING_PAYMENT", parsed.status) + assertEquals("2026-07-28T01:31:00Z", parsed.platformOrderedAt) + assertEquals(1_435L, parsed.totalPriceCents) + } + + @Test + fun `reconciliation requires one exact recent pending order`() { + val parsed = requireNotNull( + PinduoduoPendingOrderParser.parse(orderElements()) + ) + val matched = PinduoduoOrderReconciliationPolicy.reconcile( + listOf(parsed), + expectation, + fencedAt = "2026-07-28T01:30:00Z", + now = Instant.parse("2026-07-28T01:32:00Z") + ) + assertTrue(matched is PinduoduoOrderReconciliation.Matched) + + val ambiguous = PinduoduoOrderReconciliationPolicy.reconcile( + listOf( + parsed, + parsed.copy( + platformOrderNo = "22345678901234567890" + ) + ), + expectation, + fencedAt = "2026-07-28T01:30:00Z", + now = Instant.parse("2026-07-28T01:32:00Z") + ) + assertEquals( + "ORDER_AMBIGUOUS", + (ambiguous as PinduoduoOrderReconciliation.ManualReview) + .reasonCode + ) + } + + @Test + fun `unlabeled order numbers and stale times fail closed`() { + assertNull( + PinduoduoPendingOrderParser.parse( + orderElements().map { + if (it.text?.startsWith("订单编号") == true) { + it.copy(text = "12345678901234567890") + } else { + it + } + } + ) + ) + val parsed = requireNotNull( + PinduoduoPendingOrderParser.parse(orderElements()) + ) + val result = PinduoduoOrderReconciliationPolicy.reconcile( + listOf( + parsed.copy( + platformOrderedAt = "2026-07-27T01:31:00Z" + ) + ), + expectation, + fencedAt = "2026-07-28T01:30:00Z", + now = Instant.parse("2026-07-28T01:32:00Z") + ) + assertEquals( + "ORDER_NOT_FOUND", + (result as PinduoduoOrderReconciliation.ManualReview) + .reasonCode + ) + } + + private fun confirmationElements() = listOf( + element("收货地址"), + element("张三 138****1234"), + element(expectation.title), + element("已选:灰色,2XL 建议125-135斤"), + element("×1"), + element("实付款¥14.35"), + element("提交订单", clickable = true) + ) + + private fun orderElements() = listOf( + element("待付款"), + element(expectation.title), + element("规格:灰色,2XL 建议125-135斤"), + element("×1"), + element("应付¥14.35"), + element("订单编号:12345678901234567890"), + element("下单时间:2026-07-28 09:31") + ) + + private fun element( + text: String, + clickable: Boolean = false + ) = PinduoduoUiElement( + text = text, + contentDescription = null, + className = "android.widget.TextView", + resourceId = null, + clickable = clickable, + editable = false, + enabled = true, + visibleToUser = true + ) +} diff --git a/android-buyer/app/src/test/java/com/roubao/autopilot/procurement/OrderSubmissionRecoveryPolicyTest.kt b/android-buyer/app/src/test/java/com/roubao/autopilot/procurement/OrderSubmissionRecoveryPolicyTest.kt new file mode 100644 index 0000000..9d535af --- /dev/null +++ b/android-buyer/app/src/test/java/com/roubao/autopilot/procurement/OrderSubmissionRecoveryPolicyTest.kt @@ -0,0 +1,62 @@ +package com.roubao.autopilot.procurement + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class OrderSubmissionRecoveryPolicyTest { + @Test + fun `fresh fence response permits only the current call to click`() { + val transition = OrderSubmissionRecoveryPolicy.afterFenceResponse( + OrderSubmissionStatus.FENCE_INTENT_SAVED, + "FENCED", + intentCreatedInCurrentCall = true + ) + + assertEquals(OrderSubmissionStatus.CLICK_ASSUMED, transition.status) + assertTrue(transition.mayClickInCurrentCall) + assertFalse( + OrderSubmissionRecoveryPolicy.mayResumeClick(transition.status) + ) + } + + @Test + fun `persisted fence intent can never regain click permission`() { + val transition = OrderSubmissionRecoveryPolicy.afterFenceResponse( + OrderSubmissionStatus.FENCE_INTENT_SAVED, + "FENCED", + intentCreatedInCurrentCall = false + ) + + assertEquals(OrderSubmissionStatus.CLICK_ASSUMED, transition.status) + assertFalse(transition.mayClickInCurrentCall) + } + + @Test + fun `persisted click assumed can never regain click permission`() { + val transition = OrderSubmissionRecoveryPolicy.afterFenceResponse( + OrderSubmissionStatus.CLICK_ASSUMED, + "FENCED", + intentCreatedInCurrentCall = false + ) + + assertEquals(OrderSubmissionStatus.CLICK_ASSUMED, transition.status) + assertFalse(transition.mayClickInCurrentCall) + OrderSubmissionStatus.entries.forEach { status -> + assertFalse(OrderSubmissionRecoveryPolicy.mayResumeClick(status)) + } + } + + @Test + fun `terminal backend states never permit a click`() { + listOf("MANUAL_REVIEW", "RECONCILED").forEach { remote -> + val transition = OrderSubmissionRecoveryPolicy.afterFenceResponse( + OrderSubmissionStatus.FENCE_INTENT_SAVED, + remote, + intentCreatedInCurrentCall = true + ) + assertFalse(transition.mayClickInCurrentCall) + } + } +} diff --git a/backend-api/cmd/api/main.go b/backend-api/cmd/api/main.go index d73b1b8..c03c018 100644 --- a/backend-api/cmd/api/main.go +++ b/backend-api/cmd/api/main.go @@ -209,17 +209,26 @@ func buildRouter( if err != nil { return nil, err } + submissions, err := usecase.NewOrderSubmissionService( + store, + clock, + ids, + ) + if err != nil { + return nil, err + } passwords, err := password.NewBcrypt(12) if err != nil { return nil, err } registerDeviceRoutes, err := httpapi.NewDeviceRouteRegistrar( httpapi.DeviceServices{ - Lifecycle: lifecycle, - Assets: assets, - Results: results, - Commands: commands, - DryRuns: dryRuns, + Lifecycle: lifecycle, + Assets: assets, + Results: results, + Commands: commands, + DryRuns: dryRuns, + Submissions: submissions, }, ) if err != nil { diff --git a/backend-api/internal/domain/task.go b/backend-api/internal/domain/task.go index 5f60973..32924ea 100644 --- a/backend-api/internal/domain/task.go +++ b/backend-api/internal/domain/task.go @@ -355,6 +355,39 @@ type OrderDryRun struct { ReadyAt *time.Time } +type OrderSubmissionStatus string + +const ( + OrderSubmissionFenced OrderSubmissionStatus = "FENCED" + OrderSubmissionReconciled OrderSubmissionStatus = "RECONCILED" + OrderSubmissionManualReview OrderSubmissionStatus = "MANUAL_REVIEW" +) + +type OrderSubmission struct { + ID string + AuthorizationID string + DryRunID string + TaskID string + ExecutionID string + CommandSHA256 string + DryRunEvidenceSHA256 string + Status OrderSubmissionStatus + ExpectedTitle string + ExpectedSKU string + ExpectedQuantity int + ExpectedUnitPriceCents int64 + ExpectedTotalPriceCents int64 + PlatformOrderNo *string + PlatformOrderedAt *time.Time + PlatformOrderStatus *string + ReconciliationEvidenceAssetID *string + ReconciliationEvidenceSHA256 *string + ManualReasonCode *string + FencedAt time.Time + ReconciledAt *time.Time + ManualReviewAt *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 26dfae9..fdae6b9 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 != 10 { - t.Fatalf("initial Up() applied = %d, want 10", applied) + } else if applied != 11 { + t.Fatalf("initial Up() applied = %d, want 11", applied) + } + if err := runner.Down(ctx); err != nil { + t.Fatalf("initial Down(v11) error = %v", err) } if err := runner.Down(ctx); err != nil { t.Fatalf("initial Down(v10) error = %v", err) @@ -59,9 +62,14 @@ func TestClaimsMigrationPreservesHistoryAcrossUpDownUp(t *testing.T) { seedClaimsHistoricalFixture(t, db) if applied, err := runner.Up(ctx); err != nil { - 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) + t.Fatalf("Up(v5-v11) over historical data error = %v", err) + } else if applied != 7 { + t.Fatalf("Up(v5-v11) applied = %d, want 7", applied) + } + assertClaimsHistory(t, db, true) + + if err := runner.Down(ctx); err != nil { + t.Fatalf("Down(v11) with compatible history error = %v", err) } assertClaimsHistory(t, db, true) @@ -101,9 +109,9 @@ func TestClaimsMigrationPreservesHistoryAcrossUpDownUp(t *testing.T) { assertClaimsHistory(t, db, false) if applied, err := runner.Up(ctx); err != nil { - t.Fatalf("final Up(v4-v10) error = %v", err) - } else if applied != 7 { - t.Fatalf("final Up(v4-v10) applied = %d, want 7", applied) + t.Fatalf("final Up(v4-v11) error = %v", err) + } else if applied != 8 { + t.Fatalf("final Up(v4-v11) applied = %d, want 8", applied) } assertClaimsHistory(t, db, true) } @@ -339,6 +347,9 @@ func TestClaimsMigrationDownFailsClosedForNewAuditData(t *testing.T) { t.Fatalf("insert v4 audit event: %v", err) } + if err := runner.Down(ctx); err != nil { + t.Fatalf("Down(v11) error = %v", err) + } if err := runner.Down(ctx); err != nil { t.Fatalf("Down(v10) error = %v", err) } diff --git a/backend-api/internal/platform/migration/runner_test.go b/backend-api/internal/platform/migration/runner_test.go index deddfde..ca0445a 100644 --- a/backend-api/internal/platform/migration/runner_test.go +++ b/backend-api/internal/platform/migration/runner_test.go @@ -27,8 +27,8 @@ func TestRunnerSupportsUpStatusDownAndIdempotentUp(t *testing.T) { if err != nil { t.Fatalf("Up() error = %v", err) } - if applied != 10 { - t.Fatalf("Up() applied = %d, want 10", applied) + if applied != 11 { + t.Fatalf("Up() applied = %d, want 11", applied) } assertStatuses(t, runner, map[int64]bool{ 1: true, @@ -41,6 +41,7 @@ func TestRunnerSupportsUpStatusDownAndIdempotentUp(t *testing.T) { 8: true, 9: true, 10: true, + 11: true, }) applied, err = runner.Up(context.Background()) @@ -64,7 +65,8 @@ func TestRunnerSupportsUpStatusDownAndIdempotentUp(t *testing.T) { 7: true, 8: true, 9: true, - 10: false, + 10: true, + 11: false, }) applied, err = runner.Up(context.Background()) @@ -85,6 +87,7 @@ func TestRunnerSupportsUpStatusDownAndIdempotentUp(t *testing.T) { 8: true, 9: true, 10: true, + 11: true, }) } diff --git a/backend-api/internal/repository/sqlite/auth_repository_test.go b/backend-api/internal/repository/sqlite/auth_repository_test.go index 5af96ff..66304ba 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(v11) error = %v", err) + } if err := runner.Down(context.Background()); err != nil { t.Fatalf("Down(v10) error = %v", err) } @@ -420,9 +423,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-v10) error = %v", err) - } else if applied != 8 { - t.Fatalf("Up(v3-v10) applied = %d, want 8", applied) + t.Fatalf("Up(v3-v11) error = %v", err) + } else if applied != 9 { + t.Fatalf("Up(v3-v11) applied = %d, want 9", applied) } } diff --git a/backend-api/internal/repository/sqlite/order_submission_repository.go b/backend-api/internal/repository/sqlite/order_submission_repository.go new file mode 100644 index 0000000..8fd56d5 --- /dev/null +++ b/backend-api/internal/repository/sqlite/order_submission_repository.go @@ -0,0 +1,880 @@ +package sqlite + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "strings" + "time" + "unicode" + + "cmroubao/backend-api/internal/domain" + "cmroubao/backend-api/internal/usecase" +) + +const orderReconciliationClockWindow = 5 * time.Minute + +func (s *Store) StartOrderSubmission( + ctx context.Context, + write usecase.StartOrderSubmissionWrite, +) (domain.OrderSubmission, bool, error) { + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return domain.OrderSubmission{}, false, repositoryFailure(err) + } + defer func() { _ = tx.Rollback() }() + if result, found, err := replayOrderSubmissionRequest( + ctx, + tx, + write.DeviceID, + "START", + write.IdempotencyKey, + write.RequestSHA256, + write.TaskID, + ); err != nil || found { + return commitOrderSubmissionReplay(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.OrderSubmission{}, false, err + } + authorization, err := getOrderAuthorization( + ctx, + tx, + write.AuthorizationID, + ) + if err != nil { + return domain.OrderSubmission{}, false, err + } + if err := validateSubmissionAuthorization( + authorization, + write.UserID, + write.DeviceID, + write.TaskID, + write.ExecutionID, + write.ClaimGeneration, + write.CommandSHA256, + ); err != nil { + return domain.OrderSubmission{}, false, err + } + if authorization.Status != domain.OrderAuthorizationExecuting { + return domain.OrderSubmission{}, false, usecase.ErrTaskStateConflict + } + dryRun, found, err := getOrderDryRunByAuthorization( + ctx, + tx, + authorization.ID, + ) + if err != nil { + return domain.OrderSubmission{}, false, err + } + if !found || + dryRun.ID != write.DryRunID || + dryRun.Status != domain.OrderDryRunReady || + dryRun.CommandSHA256 != write.CommandSHA256 || + dryRun.EvidenceSHA256 == nil || + *dryRun.EvidenceSHA256 != write.DryRunEvidenceSHA256 || + dryRun.ObservedTitle == nil || + normalizeSubmissionText(*dryRun.ObservedTitle) != + normalizeSubmissionText(write.ObservedTitle) || + dryRun.SelectedSKU == nil || + strings.TrimSpace(*dryRun.SelectedSKU) != write.SelectedSKU || + dryRun.Quantity == nil || + *dryRun.Quantity != write.Quantity || + dryRun.UnitPriceCents == nil || + *dryRun.UnitPriceCents != write.UnitPriceCents || + dryRun.TotalPriceCents == nil || + *dryRun.TotalPriceCents != write.TotalPriceCents { + return domain.OrderSubmission{}, false, usecase.ErrTaskStateConflict + } + if _, found, err := getOrderSubmissionByAuthorization( + ctx, + tx, + authorization.ID, + ); err != nil { + return domain.OrderSubmission{}, false, err + } else if found { + return domain.OrderSubmission{}, false, usecase.ErrTaskStateConflict + } + _, err = tx.ExecContext( + ctx, + `INSERT INTO order_submissions ( + id, authorization_id, dry_run_id, task_id, execution_id, + command_sha256, dry_run_evidence_sha256, status, + expected_title, expected_sku, expected_quantity, + expected_unit_price_cents, expected_total_price_cents, fenced_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, 'FENCED', ?, ?, ?, ?, ?, ?)`, + write.SubmissionID, + authorization.ID, + dryRun.ID, + write.TaskID, + write.ExecutionID, + write.CommandSHA256, + write.DryRunEvidenceSHA256, + write.ObservedTitle, + write.SelectedSKU, + write.Quantity, + write.UnitPriceCents, + write.TotalPriceCents, + formatTimestamp(write.Now), + ) + if err != nil { + return domain.OrderSubmission{}, false, repositoryFailure(err) + } + if err := insertTaskEvent(ctx, tx, write.Event); err != nil { + return domain.OrderSubmission{}, false, err + } + if err := insertOrderSubmissionRequest( + ctx, + tx, + write.DeviceID, + "START", + write.IdempotencyKey, + write.RequestSHA256, + write.TaskID, + write.SubmissionID, + write.Now, + ); err != nil { + return domain.OrderSubmission{}, false, err + } + submission, err := getOrderSubmissionByID( + ctx, + tx, + write.SubmissionID, + ) + if err != nil { + return domain.OrderSubmission{}, false, err + } + if err := tx.Commit(); err != nil { + return domain.OrderSubmission{}, false, repositoryFailure(err) + } + return submission, false, nil +} + +func (s *Store) ReconcileOrderSubmission( + ctx context.Context, + write usecase.ReconcileOrderSubmissionWrite, +) (domain.OrderSubmission, bool, error) { + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return domain.OrderSubmission{}, false, repositoryFailure(err) + } + defer func() { _ = tx.Rollback() }() + if result, found, err := replayOrderSubmissionRequest( + ctx, + tx, + write.DeviceID, + "RECONCILE", + write.IdempotencyKey, + write.RequestSHA256, + write.TaskID, + ); err != nil || found { + return commitOrderSubmissionReplay(tx, result, found, err) + } + if err := validateFencedSubmissionOwner( + ctx, + tx, + write.UserID, + write.DeviceID, + write.TaskID, + write.ExecutionID, + write.ClaimGeneration, + write.ClaimTokenHash, + ); err != nil { + return domain.OrderSubmission{}, false, err + } + authorization, err := getOrderAuthorization( + ctx, + tx, + write.AuthorizationID, + ) + if err != nil { + return domain.OrderSubmission{}, false, err + } + if err := validateSubmissionAuthorization( + authorization, + write.UserID, + write.DeviceID, + write.TaskID, + write.ExecutionID, + write.ClaimGeneration, + write.CommandSHA256, + ); err != nil { + return domain.OrderSubmission{}, false, err + } + if authorization.Status != domain.OrderAuthorizationExecuting { + return domain.OrderSubmission{}, false, usecase.ErrTaskStateConflict + } + submission, err := getOrderSubmissionByID( + ctx, + tx, + write.SubmissionID, + ) + if err != nil { + return domain.OrderSubmission{}, false, err + } + if err := validateSubmissionRecord( + submission, + authorization.ID, + write.TaskID, + write.ExecutionID, + write.CommandSHA256, + ); err != nil { + return domain.OrderSubmission{}, false, err + } + if submission.Status != domain.OrderSubmissionFenced && + submission.Status != domain.OrderSubmissionManualReview { + return domain.OrderSubmission{}, false, usecase.ErrTaskStateConflict + } + if normalizeSubmissionText(write.ObservedTitle) != + normalizeSubmissionText(submission.ExpectedTitle) || + write.SelectedSKU != submission.ExpectedSKU || + write.Quantity != submission.ExpectedQuantity || + write.TotalPriceCents != submission.ExpectedTotalPriceCents || + write.PlatformOrderStatus != "PENDING_PAYMENT" { + return domain.OrderSubmission{}, false, usecase.ErrTaskStateConflict + } + if write.ParsedPlatformOrderedAt.Before( + submission.FencedAt.Add(-orderReconciliationClockWindow), + ) || write.ParsedPlatformOrderedAt.After( + write.Now.Add(orderReconciliationClockWindow), + ) { + return domain.OrderSubmission{}, false, usecase.ErrTaskStateConflict + } + evidence, err := getExecutionEvidence( + ctx, + tx, + write.EvidenceAssetID, + ) + if err != nil { + return domain.OrderSubmission{}, false, err + } + if evidence.TaskID != write.TaskID || + evidence.ExecutionID != write.ExecutionID || + evidence.MediaType != "image/jpeg" || + evidence.SHA256 != write.EvidenceSHA256 { + return domain.OrderSubmission{}, false, usecase.ErrTaskStateConflict + } + result, err := tx.ExecContext( + ctx, + `UPDATE order_submissions + SET status = 'RECONCILED', + platform_order_no = ?, + platform_ordered_at = ?, + platform_order_status = 'PENDING_PAYMENT', + reconciliation_evidence_asset_id = ?, + reconciliation_evidence_sha256 = ?, + manual_reason_code = NULL, + reconciled_at = ?, + manual_review_at = NULL + WHERE id = ? AND status IN ('FENCED', 'MANUAL_REVIEW')`, + write.PlatformOrderNo, + formatTimestamp(write.ParsedPlatformOrderedAt), + write.EvidenceAssetID, + write.EvidenceSHA256, + formatTimestamp(write.Now), + submission.ID, + ) + if err != nil { + return domain.OrderSubmission{}, false, repositoryFailure(err) + } + if affected, err := result.RowsAffected(); err != nil || affected != 1 { + if err != nil { + return domain.OrderSubmission{}, false, repositoryFailure(err) + } + return domain.OrderSubmission{}, false, usecase.ErrTaskStateConflict + } + result, err = tx.ExecContext( + ctx, + `UPDATE order_authorizations + SET status = 'CONSUMED', consumed_at = ? + WHERE id = ? AND status = 'EXECUTING'`, + formatTimestamp(write.Now), + authorization.ID, + ) + if err != nil { + return domain.OrderSubmission{}, false, repositoryFailure(err) + } + if affected, err := result.RowsAffected(); err != nil || affected != 1 { + if err != nil { + return domain.OrderSubmission{}, false, repositoryFailure(err) + } + return domain.OrderSubmission{}, false, usecase.ErrTaskStateConflict + } + if err := finishReconciledOrderExecution( + ctx, + tx, + write, + authorization, + ); err != nil { + return domain.OrderSubmission{}, false, err + } + if err := insertTaskEvent(ctx, tx, write.Event); err != nil { + return domain.OrderSubmission{}, false, err + } + if err := insertOrderSubmissionRequest( + ctx, + tx, + write.DeviceID, + "RECONCILE", + write.IdempotencyKey, + write.RequestSHA256, + write.TaskID, + submission.ID, + write.Now, + ); err != nil { + return domain.OrderSubmission{}, false, err + } + submission, err = getOrderSubmissionByID(ctx, tx, submission.ID) + if err != nil { + return domain.OrderSubmission{}, false, err + } + if err := tx.Commit(); err != nil { + return domain.OrderSubmission{}, false, repositoryFailure(err) + } + return submission, false, nil +} + +func (s *Store) ManualReviewOrderSubmission( + ctx context.Context, + write usecase.ManualReviewOrderSubmissionWrite, +) (domain.OrderSubmission, bool, error) { + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return domain.OrderSubmission{}, false, repositoryFailure(err) + } + defer func() { _ = tx.Rollback() }() + if result, found, err := replayOrderSubmissionRequest( + ctx, + tx, + write.DeviceID, + "MANUAL_REVIEW", + write.IdempotencyKey, + write.RequestSHA256, + write.TaskID, + ); err != nil || found { + return commitOrderSubmissionReplay(tx, result, found, err) + } + if err := validateFencedSubmissionOwner( + ctx, + tx, + write.UserID, + write.DeviceID, + write.TaskID, + write.ExecutionID, + write.ClaimGeneration, + write.ClaimTokenHash, + ); err != nil { + return domain.OrderSubmission{}, false, err + } + authorization, err := getOrderAuthorization( + ctx, + tx, + write.AuthorizationID, + ) + if err != nil { + return domain.OrderSubmission{}, false, err + } + if err := validateSubmissionAuthorization( + authorization, + write.UserID, + write.DeviceID, + write.TaskID, + write.ExecutionID, + write.ClaimGeneration, + write.CommandSHA256, + ); err != nil { + return domain.OrderSubmission{}, false, err + } + if authorization.Status != domain.OrderAuthorizationExecuting { + return domain.OrderSubmission{}, false, usecase.ErrTaskStateConflict + } + submission, err := getOrderSubmissionByID( + ctx, + tx, + write.SubmissionID, + ) + if err != nil { + return domain.OrderSubmission{}, false, err + } + if err := validateSubmissionRecord( + submission, + authorization.ID, + write.TaskID, + write.ExecutionID, + write.CommandSHA256, + ); err != nil { + return domain.OrderSubmission{}, false, err + } + if submission.Status != domain.OrderSubmissionFenced { + return domain.OrderSubmission{}, false, usecase.ErrTaskStateConflict + } + var evidenceID, evidenceSHA any + if write.EvidenceAssetID != "" { + evidence, err := getExecutionEvidence( + ctx, + tx, + write.EvidenceAssetID, + ) + if err != nil { + return domain.OrderSubmission{}, false, err + } + if evidence.TaskID != write.TaskID || + evidence.ExecutionID != write.ExecutionID || + evidence.MediaType != "image/jpeg" || + evidence.SHA256 != write.EvidenceSHA256 { + return domain.OrderSubmission{}, false, usecase.ErrTaskStateConflict + } + evidenceID = write.EvidenceAssetID + evidenceSHA = write.EvidenceSHA256 + } + result, err := tx.ExecContext( + ctx, + `UPDATE order_submissions + SET status = 'MANUAL_REVIEW', + reconciliation_evidence_asset_id = ?, + reconciliation_evidence_sha256 = ?, + manual_reason_code = ?, + manual_review_at = ? + WHERE id = ? AND status = 'FENCED'`, + evidenceID, + evidenceSHA, + write.ReasonCode, + formatTimestamp(write.Now), + submission.ID, + ) + if err != nil { + return domain.OrderSubmission{}, false, repositoryFailure(err) + } + if affected, err := result.RowsAffected(); err != nil || affected != 1 { + if err != nil { + return domain.OrderSubmission{}, false, repositoryFailure(err) + } + return domain.OrderSubmission{}, false, usecase.ErrTaskStateConflict + } + if err := insertTaskEvent(ctx, tx, write.Event); err != nil { + return domain.OrderSubmission{}, false, err + } + if err := insertOrderSubmissionRequest( + ctx, + tx, + write.DeviceID, + "MANUAL_REVIEW", + write.IdempotencyKey, + write.RequestSHA256, + write.TaskID, + submission.ID, + write.Now, + ); err != nil { + return domain.OrderSubmission{}, false, err + } + submission, err = getOrderSubmissionByID(ctx, tx, submission.ID) + if err != nil { + return domain.OrderSubmission{}, false, err + } + if err := tx.Commit(); err != nil { + return domain.OrderSubmission{}, false, repositoryFailure(err) + } + return submission, false, nil +} + +func validateSubmissionAuthorization( + 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 validateFencedSubmissionOwner( + ctx context.Context, + tx *sql.Tx, + userID, deviceID, taskID, executionID string, + claimGeneration int64, + claimTokenHash string, +) error { + task, err := getClaimProtectedTask(ctx, tx, taskID) + if err != nil { + return err + } + if err := validateClaimOwner( + task, + userID, + deviceID, + claimGeneration, + claimTokenHash, + ); err != nil { + return err + } + if task.Status != domain.TaskStatusWaitingConfirmation || + task.CancelRequestedAt != nil { + return usecase.ErrTaskStateConflict + } + execution, err := getExecutionByID(ctx, tx, executionID) + if err != nil { + return err + } + if execution.TaskID != taskID || + execution.UserID != userID || + execution.DeviceID != deviceID || + execution.ClaimGeneration != claimGeneration || + execution.FinishedAt != nil { + return usecase.ErrExecutionMismatch + } + return nil +} + +func validateSubmissionRecord( + submission domain.OrderSubmission, + authorizationID, taskID, executionID, commandSHA256 string, +) error { + if submission.AuthorizationID != authorizationID || + submission.TaskID != taskID || + submission.ExecutionID != executionID || + submission.CommandSHA256 != commandSHA256 { + return usecase.ErrExecutionMismatch + } + return nil +} + +func finishReconciledOrderExecution( + ctx context.Context, + tx *sql.Tx, + write usecase.ReconcileOrderSubmissionWrite, + authorization domain.OrderAuthorization, +) error { + var executionMode string + err := tx.QueryRowContext( + ctx, + `SELECT COALESCE( + (SELECT execution_mode + FROM execution_candidate_batches + WHERE execution_id = ? AND task_id = ?), + (SELECT execution_mode + FROM candidate_search_runs + WHERE execution_id = ? AND task_id = ?) + )`, + write.ExecutionID, + write.TaskID, + write.ExecutionID, + write.TaskID, + ).Scan(&executionMode) + if errors.Is(err, sql.ErrNoRows) { + return usecase.ErrRepositoryInvariant + } + if err != nil { + return repositoryFailure(err) + } + evidenceIDs, err := json.Marshal([]string{write.EvidenceAssetID}) + if err != nil { + return repositoryFailure(err) + } + expired := false + var claimExpiresAt sql.NullString + if err := tx.QueryRowContext( + ctx, + `SELECT claim_expires_at FROM purchase_tasks WHERE id = ?`, + write.TaskID, + ).Scan(&claimExpiresAt); err != nil { + return repositoryFailure(err) + } + if !claimExpiresAt.Valid { + expired = true + } else { + expiresAt, err := parseTimestamp(claimExpiresAt.String) + if err != nil { + return repositoryFailure(err) + } + expired = !expiresAt.After(write.Now) + } + _, err = tx.ExecContext( + ctx, + `INSERT INTO execution_outcomes ( + execution_id, task_id, result_type, execution_mode, + task_content_sha256, outcome, operator_reason, + selected_candidate_json, evidence_asset_ids_json, + error_code, error_message, error_step, retryable, + order_submitted, received_at, + received_after_execution_expiry + ) VALUES (?, ?, 'COMPLETE', ?, ?, 'CANDIDATE_ACCEPTED', + ?, NULL, ?, NULL, NULL, NULL, NULL, 1, ?, ?)`, + write.ExecutionID, + write.TaskID, + executionMode, + authorization.TaskContentSHA256, + "pending-payment order uniquely reconciled", + string(evidenceIDs), + formatTimestamp(write.Now), + expired, + ) + if err != nil { + return repositoryFailure(err) + } + result, err := tx.ExecContext( + ctx, + `UPDATE task_executions + SET current_step = 'ORDER_RECONCILED', + last_heartbeat_at = ?, + finished_at = ? + WHERE id = ? AND finished_at IS NULL`, + formatTimestamp(write.Now), + formatTimestamp(write.Now), + write.ExecutionID, + ) + if err != nil { + return repositoryFailure(err) + } + if affected, err := result.RowsAffected(); err != nil || affected != 1 { + if err != nil { + return repositoryFailure(err) + } + return usecase.ErrTaskStateConflict + } + result, err = tx.ExecContext( + ctx, + `UPDATE purchase_tasks + SET status = 'SUCCEEDED', + version = version + 1, + claimed_by_user_id = NULL, + claimed_by_device_id = NULL, + claim_token_hash = NULL, + claim_issued_at = NULL, + claim_expires_at = NULL, + updated_at = ? + WHERE id = ? + AND status = 'WAITING_CONFIRMATION' + AND claim_generation = ?`, + formatTimestamp(write.Now), + write.TaskID, + write.ClaimGeneration, + ) + if err != nil { + return repositoryFailure(err) + } + if affected, err := result.RowsAffected(); err != nil || affected != 1 { + if err != nil { + return repositoryFailure(err) + } + return usecase.ErrTaskStateConflict + } + return nil +} + +func getOrderSubmissionByAuthorization( + ctx context.Context, + queryer queryRower, + authorizationID string, +) (domain.OrderSubmission, bool, error) { + submission, err := scanOrderSubmission(queryer.QueryRowContext( + ctx, + orderSubmissionSelect+` WHERE authorization_id = ?`, + authorizationID, + )) + if errors.Is(err, sql.ErrNoRows) { + return domain.OrderSubmission{}, false, nil + } + if err != nil { + return domain.OrderSubmission{}, false, repositoryFailure(err) + } + return submission, true, nil +} + +func getOrderSubmissionByID( + ctx context.Context, + queryer queryRower, + submissionID string, +) (domain.OrderSubmission, error) { + submission, err := scanOrderSubmission(queryer.QueryRowContext( + ctx, + orderSubmissionSelect+` WHERE id = ?`, + submissionID, + )) + if errors.Is(err, sql.ErrNoRows) { + return domain.OrderSubmission{}, usecase.ErrRepositoryNotFound + } + if err != nil { + return domain.OrderSubmission{}, repositoryFailure(err) + } + return submission, nil +} + +const orderSubmissionSelect = `SELECT + id, authorization_id, dry_run_id, task_id, execution_id, + command_sha256, dry_run_evidence_sha256, status, + expected_title, expected_sku, expected_quantity, + expected_unit_price_cents, expected_total_price_cents, + platform_order_no, platform_ordered_at, platform_order_status, + reconciliation_evidence_asset_id, reconciliation_evidence_sha256, + manual_reason_code, fenced_at, reconciled_at, manual_review_at +FROM order_submissions` + +func scanOrderSubmission( + scanner rowScanner, +) (domain.OrderSubmission, error) { + var submission domain.OrderSubmission + var orderNo, orderedAt, orderStatus sql.NullString + var evidenceID, evidenceSHA, manualReason sql.NullString + var fencedAt string + var reconciledAt, manualReviewAt sql.NullString + err := scanner.Scan( + &submission.ID, + &submission.AuthorizationID, + &submission.DryRunID, + &submission.TaskID, + &submission.ExecutionID, + &submission.CommandSHA256, + &submission.DryRunEvidenceSHA256, + &submission.Status, + &submission.ExpectedTitle, + &submission.ExpectedSKU, + &submission.ExpectedQuantity, + &submission.ExpectedUnitPriceCents, + &submission.ExpectedTotalPriceCents, + &orderNo, + &orderedAt, + &orderStatus, + &evidenceID, + &evidenceSHA, + &manualReason, + &fencedAt, + &reconciledAt, + &manualReviewAt, + ) + if err != nil { + return domain.OrderSubmission{}, err + } + submission.PlatformOrderNo = optionalSQLString(orderNo) + submission.PlatformOrderStatus = optionalSQLString(orderStatus) + submission.ReconciliationEvidenceAssetID = optionalSQLString(evidenceID) + submission.ReconciliationEvidenceSHA256 = optionalSQLString(evidenceSHA) + submission.ManualReasonCode = optionalSQLString(manualReason) + submission.FencedAt, err = parseTimestamp(fencedAt) + if err != nil { + return domain.OrderSubmission{}, err + } + if orderedAt.Valid { + parsed, parseErr := parseTimestamp(orderedAt.String) + if parseErr != nil { + return domain.OrderSubmission{}, parseErr + } + submission.PlatformOrderedAt = &parsed + } + if submission.ReconciledAt, err = parseNullableTimestamp(reconciledAt); err != nil { + return domain.OrderSubmission{}, err + } + if submission.ManualReviewAt, err = parseNullableTimestamp(manualReviewAt); err != nil { + return domain.OrderSubmission{}, err + } + return submission, nil +} + +func optionalSQLString(value sql.NullString) *string { + if !value.Valid { + return nil + } + return &value.String +} + +func replayOrderSubmissionRequest( + ctx context.Context, + tx *sql.Tx, + deviceID, operation, idempotencyKey, requestSHA256, taskID string, +) (domain.OrderSubmission, bool, error) { + var knownHash, knownTask, submissionID string + err := tx.QueryRowContext( + ctx, + `SELECT request_sha256, task_id, submission_id + FROM device_order_submission_requests + WHERE device_id = ? AND operation = ? AND idempotency_key = ?`, + deviceID, + operation, + idempotencyKey, + ).Scan(&knownHash, &knownTask, &submissionID) + if errors.Is(err, sql.ErrNoRows) { + return domain.OrderSubmission{}, false, nil + } + if err != nil { + return domain.OrderSubmission{}, false, repositoryFailure(err) + } + if knownHash != requestSHA256 || knownTask != taskID { + return domain.OrderSubmission{}, false, usecase.ErrIdempotencyConflict + } + result, err := getOrderSubmissionByID(ctx, tx, submissionID) + if err != nil { + return domain.OrderSubmission{}, false, err + } + return result, true, nil +} + +func commitOrderSubmissionReplay( + tx *sql.Tx, + result domain.OrderSubmission, + found bool, + err error, +) (domain.OrderSubmission, bool, error) { + if err != nil || !found { + return domain.OrderSubmission{}, false, err + } + if err := tx.Commit(); err != nil { + return domain.OrderSubmission{}, false, repositoryFailure(err) + } + return result, true, nil +} + +func insertOrderSubmissionRequest( + ctx context.Context, + tx *sql.Tx, + deviceID, operation, idempotencyKey, requestSHA256, taskID, + submissionID string, + now time.Time, +) error { + _, err := tx.ExecContext( + ctx, + `INSERT INTO device_order_submission_requests ( + device_id, operation, idempotency_key, request_sha256, + task_id, submission_id, created_at + ) VALUES (?, ?, ?, ?, ?, ?, ?)`, + deviceID, + operation, + idempotencyKey, + requestSHA256, + taskID, + submissionID, + formatTimestamp(now.UTC()), + ) + if err != nil { + return repositoryFailure(err) + } + return nil +} + +func normalizeSubmissionText(value string) string { + return strings.Map(func(character rune) rune { + if unicode.IsSpace(character) || unicode.IsPunct(character) { + return -1 + } + return unicode.ToLower(character) + }, value) +} + +var _ usecase.OrderSubmissionRepository = (*Store)(nil) diff --git a/backend-api/internal/transport/httpapi/admin_handlers_test.go b/backend-api/internal/transport/httpapi/admin_handlers_test.go index 33e8256..01ffa6d 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 submission migration down: %v", err) + } if err := runner.Down(context.Background()); err != nil { t.Fatalf("order dry-run 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 e75ee5b..9768646 100644 --- a/backend-api/internal/transport/httpapi/device_handlers.go +++ b/backend-api/internal/transport/httpapi/device_handlers.go @@ -17,17 +17,18 @@ import ( const claimTokenHeader = "X-Claim-Token" type DeviceServices struct { - Lifecycle *usecase.LifecycleService - Assets *usecase.AssetService - Results *usecase.ExecutionResultService - Commands *usecase.DeviceOrderCommandService - DryRuns *usecase.OrderDryRunService + Lifecycle *usecase.LifecycleService + Assets *usecase.AssetService + Results *usecase.ExecutionResultService + Commands *usecase.DeviceOrderCommandService + DryRuns *usecase.OrderDryRunService + Submissions *usecase.OrderSubmissionService } func (services DeviceServices) validate() error { if services.Lifecycle == nil || services.Assets == nil || services.Results == nil || services.Commands == nil || - services.DryRuns == nil { + services.DryRuns == nil || services.Submissions == nil { return errors.New("device services are required") } return nil @@ -93,12 +94,215 @@ func NewDeviceRouteRegistrar( "/api/v1/tasks/:id/order-dry-runs/:command_id/ready", handler.readyOrderDryRun, ) + routes.POST( + "/api/v1/tasks/:id/order-submissions/start", + handler.startOrderSubmission, + ) + routes.POST( + "/api/v1/tasks/:id/order-submissions/:submission_id/reconcile", + handler.reconcileOrderSubmission, + ) + routes.POST( + "/api/v1/tasks/:id/order-submissions/:submission_id/manual-review", + handler.manualReviewOrderSubmission, + ) routes.POST("/api/v1/tasks/:id/complete", handler.completeTask) routes.POST("/api/v1/tasks/:id/fail", handler.failTask) return nil }, nil } +func (handler *deviceHandlers) startOrderSubmission(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"` + DryRunID string `json:"dry_run_id"` + DryRunEvidenceSHA256 string `json:"dry_run_evidence_sha256"` + 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"` + } + if !decodeDeviceJSON(ctx, &request) || + !deviceIDMatches(ctx, request.DeviceID, principal.DeviceID) { + return + } + result, err := handler.services.Submissions.Start( + ctx.Request.Context(), + usecase.StartOrderSubmissionCommand{ + 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, + DryRunID: request.DryRunID, + DryRunEvidenceSHA256: request.DryRunEvidenceSHA256, + ObservedTitle: request.ObservedTitle, + SelectedSKU: request.SelectedSKU, + Quantity: request.Quantity, + UnitPriceCents: request.UnitPriceCents, + TotalPriceCents: request.TotalPriceCents, + IdempotencyKey: ctx.GetHeader("Idempotency-Key"), + }, + ) + if err != nil { + writeUsecaseError(ctx, err) + return + } + writeOrderSubmission(ctx, result) +} + +func (handler *deviceHandlers) reconcileOrderSubmission(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"` + PlatformOrderNo string `json:"platform_order_no"` + PlatformOrderedAt string `json:"platform_ordered_at"` + PlatformOrderStatus string `json:"platform_order_status"` + ObservedTitle string `json:"observed_title"` + SelectedSKU string `json:"selected_sku"` + Quantity int `json:"quantity"` + 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.Submissions.Reconcile( + ctx.Request.Context(), + usecase.ReconcileOrderSubmissionCommand{ + 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, + SubmissionID: ctx.Param("submission_id"), + PlatformOrderNo: request.PlatformOrderNo, + PlatformOrderedAt: request.PlatformOrderedAt, + PlatformOrderStatus: request.PlatformOrderStatus, + ObservedTitle: request.ObservedTitle, + SelectedSKU: request.SelectedSKU, + Quantity: request.Quantity, + TotalPriceCents: request.TotalPriceCents, + EvidenceAssetID: request.EvidenceAssetID, + EvidenceSHA256: request.EvidenceSHA256, + IdempotencyKey: ctx.GetHeader("Idempotency-Key"), + }, + ) + if err != nil { + writeUsecaseError(ctx, err) + return + } + writeOrderSubmission(ctx, result) +} + +func (handler *deviceHandlers) manualReviewOrderSubmission(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"` + ReasonCode string `json:"reason_code"` + 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.Submissions.ManualReview( + ctx.Request.Context(), + usecase.ManualReviewOrderSubmissionCommand{ + 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, + SubmissionID: ctx.Param("submission_id"), + ReasonCode: request.ReasonCode, + EvidenceAssetID: request.EvidenceAssetID, + EvidenceSHA256: request.EvidenceSHA256, + IdempotencyKey: ctx.GetHeader("Idempotency-Key"), + }, + ) + if err != nil { + writeUsecaseError(ctx, err) + return + } + writeOrderSubmission(ctx, result) +} + +func writeOrderSubmission( + ctx *gin.Context, + result usecase.OrderSubmissionResult, +) { + ctx.Header("Cache-Control", "no-store") + ctx.JSON(http.StatusOK, gin.H{ + "submission": orderSubmissionResponse(result.Submission), + "replayed": result.Replayed, + }) +} + +func orderSubmissionResponse(submission domain.OrderSubmission) gin.H { + return gin.H{ + "id": submission.ID, + "command_id": submission.AuthorizationID, + "dry_run_id": submission.DryRunID, + "task_id": submission.TaskID, + "execution_id": submission.ExecutionID, + "command_sha256": submission.CommandSHA256, + "dry_run_evidence_sha256": submission.DryRunEvidenceSHA256, + "status": submission.Status, + "expected_title": submission.ExpectedTitle, + "expected_sku": submission.ExpectedSKU, + "expected_quantity": submission.ExpectedQuantity, + "expected_unit_price_cents": submission.ExpectedUnitPriceCents, + "expected_total_price_cents": submission.ExpectedTotalPriceCents, + "platform_order_no": submission.PlatformOrderNo, + "platform_ordered_at": formatOptionalTime( + submission.PlatformOrderedAt, + ), + "platform_order_status": submission.PlatformOrderStatus, + "reconciliation_evidence_asset_id": submission.ReconciliationEvidenceAssetID, + "reconciliation_evidence_sha256": submission.ReconciliationEvidenceSHA256, + "manual_reason_code": submission.ManualReasonCode, + "fenced_at": formatTime(submission.FencedAt), + "reconciled_at": formatOptionalTime(submission.ReconciledAt), + "manual_review_at": formatOptionalTime(submission.ManualReviewAt), + } +} + func (handler *deviceHandlers) startOrderDryRun(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 8768da0..4507b45 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 submission migration down: %v", err) + } if err := runner.Down(context.Background()); err != nil { t.Fatalf("order dry-run migration down: %v", err) } @@ -724,8 +727,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 != 2 { - t.Fatalf("restored migrations = %d, want 2", applied) + } else if applied != 3 { + t.Fatalf("restored migrations = %d, want 3", applied) } completePayload := fmt.Sprintf( @@ -1125,6 +1128,12 @@ func TestDeviceOrderCommandDeliveryAndAcknowledgementAreRecoverable( if !strings.Contains(readyDryRun.Body.String(), `"status":"READY"`) { t.Fatalf("dry-run ready = %s", readyDryRun.Body.String()) } + var readyDryRunResponse struct { + DryRun struct { + ID string `json:"id"` + } `json:"dry_run"` + } + decodeResponse(t, readyDryRun, &readyDryRunResponse) readyDryRunReplay := performDeviceRequest(t, fixture.router, deviceRequest{ method: http.MethodPost, target: "/api/v1/tasks/" + taskID + @@ -1139,12 +1148,182 @@ func TestDeviceOrderCommandDeliveryAndAcknowledgementAreRecoverable( if !strings.Contains(readyDryRunReplay.Body.String(), `"replayed":true`) { t.Fatalf("dry-run ready replay = %s", readyDryRunReplay.Body.String()) } - var deliveredEvents, acknowledgedEvents, dryRunStartedEvents, dryRunReadyEvents int + startSubmissionPayload := fmt.Sprintf( + `{"device_id":%q,"execution_id":%q,"claim_generation":%d,"command_id":%q,"command_sha256":%q,"dry_run_id":%q,"dry_run_evidence_sha256":%q,"observed_title":%q,"selected_sku":%q,"quantity":2,"unit_price_cents":2150,"total_price_cents":4300}`, + deviceTestDeviceID, + started.Execution.ID, + started.Task.ClaimGeneration, + command.ID, + command.CommandSHA256, + readyDryRunResponse.DryRun.ID, + dryRunEvidenceSHA, + command.Candidate.Title, + command.OriginalSKU, + ) + startSubmission := performDeviceRequest(t, fixture.router, deviceRequest{ + method: http.MethodPost, + target: "/api/v1/tasks/" + taskID + "/order-submissions/start", + contentType: "application/json", + body: strings.NewReader(startSubmissionPayload), + bearerToken: testOpaqueToken, + claimToken: testOpaqueToken, + idempotencyKey: "order-submission-start", + }) + requireDeviceStatus(t, startSubmission, http.StatusOK) + var submissionResponse struct { + Submission struct { + ID string `json:"id"` + } `json:"submission"` + } + decodeResponse(t, startSubmission, &submissionResponse) + if submissionResponse.Submission.ID == "" || + !strings.Contains(startSubmission.Body.String(), `"status":"FENCED"`) { + t.Fatalf("submission start = %s", startSubmission.Body.String()) + } + startSubmissionReplay := performDeviceRequest(t, fixture.router, deviceRequest{ + method: http.MethodPost, + target: "/api/v1/tasks/" + taskID + "/order-submissions/start", + contentType: "application/json", + body: strings.NewReader(startSubmissionPayload), + bearerToken: testOpaqueToken, + claimToken: testOpaqueToken, + idempotencyKey: "order-submission-start", + }) + requireDeviceStatus(t, startSubmissionReplay, http.StatusOK) + if !strings.Contains(startSubmissionReplay.Body.String(), `"replayed":true`) { + t.Fatalf("submission start replay = %s", startSubmissionReplay.Body.String()) + } + secondSubmission := performDeviceRequest(t, fixture.router, deviceRequest{ + method: http.MethodPost, + target: "/api/v1/tasks/" + taskID + "/order-submissions/start", + contentType: "application/json", + body: strings.NewReader(startSubmissionPayload), + bearerToken: testOpaqueToken, + claimToken: testOpaqueToken, + idempotencyKey: "order-submission-start-second", + }) + requireDeviceStatus(t, secondSubmission, http.StatusConflict) + manualReviewPayload := fmt.Sprintf( + `{"device_id":%q,"execution_id":%q,"claim_generation":%d,"command_id":%q,"command_sha256":%q,"reason_code":"ORDER_PAGE_UNKNOWN"}`, + deviceTestDeviceID, + started.Execution.ID, + started.Task.ClaimGeneration, + command.ID, + command.CommandSHA256, + ) + manualReview := performDeviceRequest(t, fixture.router, deviceRequest{ + method: http.MethodPost, + target: "/api/v1/tasks/" + taskID + "/order-submissions/" + + submissionResponse.Submission.ID + "/manual-review", + contentType: "application/json", + body: strings.NewReader(manualReviewPayload), + bearerToken: testOpaqueToken, + claimToken: testOpaqueToken, + idempotencyKey: "order-submission-manual", + }) + requireDeviceStatus(t, manualReview, http.StatusOK) + if !strings.Contains(manualReview.Body.String(), `"status":"MANUAL_REVIEW"`) { + t.Fatalf("submission manual review = %s", manualReview.Body.String()) + } + const reconciliationEvidenceID = "00000000-0000-4000-8000-000000000078" + reconciliationEvidenceSHA := strings.Repeat("d", 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)`, + reconciliationEvidenceID, + taskID, + started.Execution.ID, + reconciliationEvidenceSHA, + "orders/reconciliation.jpg", + time.Now().UTC().Format(time.RFC3339Nano), + ); err != nil { + t.Fatalf("seed reconciliation evidence: %v", err) + } + reconcilePayload := fmt.Sprintf( + `{"device_id":%q,"execution_id":%q,"claim_generation":%d,"command_id":%q,"command_sha256":%q,"platform_order_no":"12345678901234567890","platform_ordered_at":%q,"platform_order_status":"PENDING_PAYMENT","observed_title":%q,"selected_sku":%q,"quantity":2,"total_price_cents":4300,"evidence_asset_id":%q,"evidence_sha256":%q}`, + deviceTestDeviceID, + started.Execution.ID, + started.Task.ClaimGeneration, + command.ID, + command.CommandSHA256, + time.Now().UTC().Format(time.RFC3339), + command.Candidate.Title, + command.OriginalSKU, + reconciliationEvidenceID, + reconciliationEvidenceSHA, + ) + reconciled := performDeviceRequest(t, fixture.router, deviceRequest{ + method: http.MethodPost, + target: "/api/v1/tasks/" + taskID + "/order-submissions/" + + submissionResponse.Submission.ID + "/reconcile", + contentType: "application/json", + body: strings.NewReader(reconcilePayload), + bearerToken: testOpaqueToken, + claimToken: testOpaqueToken, + idempotencyKey: "order-submission-reconcile", + }) + requireDeviceStatus(t, reconciled, http.StatusOK) + if !strings.Contains(reconciled.Body.String(), `"status":"RECONCILED"`) || + !strings.Contains(reconciled.Body.String(), `"platform_order_status":"PENDING_PAYMENT"`) { + t.Fatalf("submission reconciliation = %s", reconciled.Body.String()) + } + reconciledReplay := performDeviceRequest(t, fixture.router, deviceRequest{ + method: http.MethodPost, + target: "/api/v1/tasks/" + taskID + "/order-submissions/" + + submissionResponse.Submission.ID + "/reconcile", + contentType: "application/json", + body: strings.NewReader(reconcilePayload), + bearerToken: testOpaqueToken, + claimToken: testOpaqueToken, + idempotencyKey: "order-submission-reconcile", + }) + requireDeviceStatus(t, reconciledReplay, http.StatusOK) + if !strings.Contains(reconciledReplay.Body.String(), `"replayed":true`) { + t.Fatalf("submission reconcile replay = %s", reconciledReplay.Body.String()) + } + var outcomeSubmitted bool + if err := fixture.db.QueryRow( + `SELECT order_submitted FROM execution_outcomes + WHERE execution_id = ?`, + started.Execution.ID, + ).Scan(&outcomeSubmitted); err != nil { + t.Fatalf("query reconciled outcome: %v", err) + } + if !outcomeSubmitted { + t.Fatal("reconciled execution outcome did not record order_submitted") + } + var taskStatus, authorizationStatus string + if err := fixture.db.QueryRow( + `SELECT status FROM purchase_tasks WHERE id = ?`, + taskID, + ).Scan(&taskStatus); err != nil { + t.Fatalf("query reconciled task: %v", err) + } + if err := fixture.db.QueryRow( + `SELECT status FROM order_authorizations WHERE id = ?`, + command.ID, + ).Scan(&authorizationStatus); err != nil { + t.Fatalf("query consumed authorization: %v", err) + } + if taskStatus != "SUCCEEDED" || authorizationStatus != "CONSUMED" { + t.Fatalf( + "reconciled task/authorization = %s/%s", + taskStatus, + authorizationStatus, + ) + } + var deliveredEvents, acknowledgedEvents, dryRunStartedEvents, + dryRunReadyEvents, fencedEvents, manualEvents, reconciledEvents 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, + "ORDER_SUBMISSION_FENCED": &fencedEvents, + "ORDER_SUBMISSION_MANUAL_REVIEW": &manualEvents, + "ORDER_SUBMISSION_RECONCILED": &reconciledEvents, } { if err := fixture.db.QueryRow( `SELECT COUNT(*) FROM task_events @@ -1156,13 +1335,17 @@ func TestDeviceOrderCommandDeliveryAndAcknowledgementAreRecoverable( } } if deliveredEvents != 1 || acknowledgedEvents != 1 || - dryRunStartedEvents != 1 || dryRunReadyEvents != 1 { + dryRunStartedEvents != 1 || dryRunReadyEvents != 1 || + fencedEvents != 1 || manualEvents != 1 || reconciledEvents != 1 { t.Fatalf( - "delivery/ack/dry-run events = %d/%d/%d/%d", + "order workflow events = %d/%d/%d/%d/%d/%d/%d", deliveredEvents, acknowledgedEvents, dryRunStartedEvents, dryRunReadyEvents, + fencedEvents, + manualEvents, + reconciledEvents, ) } runner, err := migration.New(fixture.db) @@ -1170,7 +1353,7 @@ func TestDeviceOrderCommandDeliveryAndAcknowledgementAreRecoverable( t.Fatalf("migration.New() error = %v", err) } if err := runner.Down(context.Background()); err == nil { - t.Fatal("order dry-run migration down succeeded with dry-run data") + t.Fatal("order submission migration down succeeded with retained data") } } @@ -1503,13 +1686,18 @@ func newDeviceHTTPFixture(t *testing.T) *deviceHTTPFixture { if err != nil { t.Fatalf("usecase.NewOrderDryRunService() error = %v", err) } + submissions, err := usecase.NewOrderSubmissionService(store, clock, ids) + if err != nil { + t.Fatalf("usecase.NewOrderSubmissionService() error = %v", err) + } deviceRoutes, err := NewDeviceRouteRegistrar( DeviceServices{ - Lifecycle: lifecycle, - Assets: assets, - Results: results, - Commands: commands, - DryRuns: dryRuns, + Lifecycle: lifecycle, + Assets: assets, + Results: results, + Commands: commands, + DryRuns: dryRuns, + Submissions: submissions, }, ) if err != nil { diff --git a/backend-api/internal/usecase/order_submission_service.go b/backend-api/internal/usecase/order_submission_service.go new file mode 100644 index 0000000..af3ef82 --- /dev/null +++ b/backend-api/internal/usecase/order_submission_service.go @@ -0,0 +1,526 @@ +package usecase + +import ( + "context" + "errors" + "math" + "regexp" + "strings" + "time" + + "cmroubao/backend-api/internal/domain" +) + +const ( + orderSubmissionStartOperation = "START" + orderSubmissionReconcileOperation = "RECONCILE" + orderSubmissionManualReviewOperation = "MANUAL_REVIEW" +) + +type StartOrderSubmissionCommand struct { + UserID string + DeviceID string + TaskID string + ExecutionID string + AuthorizationID string + ClaimGeneration int64 + ClaimToken string + CommandSHA256 string + DryRunID string + DryRunEvidenceSHA256 string + ObservedTitle string + SelectedSKU string + Quantity int + UnitPriceCents int64 + TotalPriceCents int64 + IdempotencyKey string +} + +type StartOrderSubmissionWrite struct { + StartOrderSubmissionCommand + SubmissionID string + ClaimTokenHash string + RequestSHA256 string + Now time.Time + Event domain.TaskEvent +} + +type ReconcileOrderSubmissionCommand struct { + UserID string + DeviceID string + TaskID string + ExecutionID string + AuthorizationID string + ClaimGeneration int64 + ClaimToken string + CommandSHA256 string + SubmissionID string + PlatformOrderNo string + PlatformOrderedAt string + PlatformOrderStatus string + ObservedTitle string + SelectedSKU string + Quantity int + TotalPriceCents int64 + EvidenceAssetID string + EvidenceSHA256 string + IdempotencyKey string +} + +type ReconcileOrderSubmissionWrite struct { + ReconcileOrderSubmissionCommand + ParsedPlatformOrderedAt time.Time + ClaimTokenHash string + RequestSHA256 string + Now time.Time + Event domain.TaskEvent +} + +type ManualReviewOrderSubmissionCommand struct { + UserID string + DeviceID string + TaskID string + ExecutionID string + AuthorizationID string + ClaimGeneration int64 + ClaimToken string + CommandSHA256 string + SubmissionID string + ReasonCode string + EvidenceAssetID string + EvidenceSHA256 string + IdempotencyKey string +} + +type ManualReviewOrderSubmissionWrite struct { + ManualReviewOrderSubmissionCommand + ClaimTokenHash string + RequestSHA256 string + Now time.Time + Event domain.TaskEvent +} + +type OrderSubmissionResult struct { + Submission domain.OrderSubmission + Replayed bool +} + +type OrderSubmissionRepository interface { + StartOrderSubmission( + context.Context, + StartOrderSubmissionWrite, + ) (domain.OrderSubmission, bool, error) + ReconcileOrderSubmission( + context.Context, + ReconcileOrderSubmissionWrite, + ) (domain.OrderSubmission, bool, error) + ManualReviewOrderSubmission( + context.Context, + ManualReviewOrderSubmissionWrite, + ) (domain.OrderSubmission, bool, error) +} + +type OrderSubmissionService struct { + repository OrderSubmissionRepository + clock Clock + ids IDGenerator +} + +func NewOrderSubmissionService( + repository OrderSubmissionRepository, + clock Clock, + ids IDGenerator, +) (*OrderSubmissionService, error) { + if repository == nil || clock == nil || ids == nil { + return nil, errors.New("order submission service dependencies are required") + } + return &OrderSubmissionService{ + repository: repository, + clock: clock, + ids: ids, + }, nil +} + +func (service *OrderSubmissionService) Start( + ctx context.Context, + command StartOrderSubmissionCommand, +) (OrderSubmissionResult, error) { + command = normalizeStartOrderSubmission(command) + fields := orderSubmissionIdentityFields( + command.UserID, + command.DeviceID, + command.TaskID, + command.ExecutionID, + command.AuthorizationID, + command.ClaimGeneration, + command.ClaimToken, + command.CommandSHA256, + command.IdempotencyKey, + ) + if !isUUID(command.DryRunID) { + fields["dry_run_id"] = "must be a UUID" + } + if !sha256Pattern.MatchString(command.DryRunEvidenceSHA256) { + fields["dry_run_evidence_sha256"] = "must be lowercase SHA-256" + } + validateOrderSnapshot( + fields, + command.ObservedTitle, + command.SelectedSKU, + command.Quantity, + command.UnitPriceCents, + command.TotalPriceCents, + ) + if command.Quantity > 0 && + command.UnitPriceCents <= math.MaxInt64/int64(command.Quantity) && + command.TotalPriceCents != + command.UnitPriceCents*int64(command.Quantity) { + fields["total_price_cents"] = "must equal unit price times quantity" + } + if len(fields) > 0 { + return OrderSubmissionResult{}, invalidError( + "ORDER_SUBMISSION_START_INVALID", + "order submission start request is invalid", + fields, + ) + } + requestHash, err := lifecycleRequestHash(command) + if err != nil { + return OrderSubmissionResult{}, internalLifecycleFailure(err) + } + submissionID, err := service.ids.NewID() + if err != nil { + return OrderSubmissionResult{}, internalLifecycleFailure(err) + } + eventID, err := service.ids.NewID() + if err != nil { + return OrderSubmissionResult{}, internalLifecycleFailure(err) + } + now := service.clock.Now().UTC() + userID, deviceID := command.UserID, command.DeviceID + submission, replayed, err := service.repository.StartOrderSubmission( + ctx, + StartOrderSubmissionWrite{ + StartOrderSubmissionCommand: command, + SubmissionID: submissionID, + ClaimTokenHash: hashSecret(command.ClaimToken), + RequestSHA256: requestHash, + Now: now, + Event: domain.TaskEvent{ + ID: eventID, + TaskID: command.TaskID, + ActorUserID: &userID, + ActorDeviceID: &deviceID, + Type: "ORDER_SUBMISSION_FENCED", + Message: "single order submission fenced", + OccurredAt: now, + }, + }, + ) + if err != nil { + return OrderSubmissionResult{}, wrapLifecycleRepositoryError(err) + } + return OrderSubmissionResult{ + Submission: submission, + Replayed: replayed, + }, nil +} + +func (service *OrderSubmissionService) Reconcile( + ctx context.Context, + command ReconcileOrderSubmissionCommand, +) (OrderSubmissionResult, error) { + command = normalizeReconcileOrderSubmission(command) + fields := orderSubmissionIdentityFields( + command.UserID, + command.DeviceID, + command.TaskID, + command.ExecutionID, + command.AuthorizationID, + command.ClaimGeneration, + command.ClaimToken, + command.CommandSHA256, + command.IdempotencyKey, + ) + if !isUUID(command.SubmissionID) { + fields["submission_id"] = "must be a UUID" + } + if !platformOrderNumberPattern.MatchString(command.PlatformOrderNo) { + fields["platform_order_no"] = "must be 8 to 40 digits" + } + orderedAt, err := time.Parse(time.RFC3339, command.PlatformOrderedAt) + if err != nil { + fields["platform_ordered_at"] = "must be RFC3339" + } + if command.PlatformOrderStatus != "PENDING_PAYMENT" { + fields["platform_order_status"] = "must be PENDING_PAYMENT" + } + validateReconciledOrderSnapshot( + fields, + command.ObservedTitle, + command.SelectedSKU, + command.Quantity, + command.TotalPriceCents, + ) + if !isUUID(command.EvidenceAssetID) { + fields["evidence_asset_id"] = "must be a UUID" + } + if !sha256Pattern.MatchString(command.EvidenceSHA256) { + fields["evidence_sha256"] = "must be lowercase SHA-256" + } + if len(fields) > 0 { + return OrderSubmissionResult{}, invalidError( + "ORDER_SUBMISSION_RECONCILE_INVALID", + "order submission reconciliation request is invalid", + fields, + ) + } + requestHash, err := lifecycleRequestHash(command) + if err != nil { + return OrderSubmissionResult{}, internalLifecycleFailure(err) + } + eventID, err := service.ids.NewID() + if err != nil { + return OrderSubmissionResult{}, internalLifecycleFailure(err) + } + now := service.clock.Now().UTC() + userID, deviceID := command.UserID, command.DeviceID + submission, replayed, err := service.repository.ReconcileOrderSubmission( + ctx, + ReconcileOrderSubmissionWrite{ + ReconcileOrderSubmissionCommand: command, + ParsedPlatformOrderedAt: orderedAt.UTC(), + ClaimTokenHash: hashSecret(command.ClaimToken), + RequestSHA256: requestHash, + Now: now, + Event: domain.TaskEvent{ + ID: eventID, + TaskID: command.TaskID, + ActorUserID: &userID, + ActorDeviceID: &deviceID, + Type: "ORDER_SUBMISSION_RECONCILED", + Message: "pending-payment order uniquely reconciled", + OccurredAt: now, + }, + }, + ) + if err != nil { + return OrderSubmissionResult{}, wrapLifecycleRepositoryError(err) + } + return OrderSubmissionResult{ + Submission: submission, + Replayed: replayed, + }, nil +} + +func (service *OrderSubmissionService) ManualReview( + ctx context.Context, + command ManualReviewOrderSubmissionCommand, +) (OrderSubmissionResult, error) { + command = normalizeManualReviewOrderSubmission(command) + fields := orderSubmissionIdentityFields( + command.UserID, + command.DeviceID, + command.TaskID, + command.ExecutionID, + command.AuthorizationID, + command.ClaimGeneration, + command.ClaimToken, + command.CommandSHA256, + command.IdempotencyKey, + ) + if !isUUID(command.SubmissionID) { + fields["submission_id"] = "must be a UUID" + } + if _, ok := orderSubmissionManualReasons[command.ReasonCode]; !ok { + fields["reason_code"] = "must be an allowed reason" + } + if (command.EvidenceAssetID == "") != (command.EvidenceSHA256 == "") { + fields["evidence"] = "asset id and SHA-256 must be provided together" + } else if command.EvidenceAssetID != "" { + if !isUUID(command.EvidenceAssetID) { + fields["evidence_asset_id"] = "must be a UUID" + } + if !sha256Pattern.MatchString(command.EvidenceSHA256) { + fields["evidence_sha256"] = "must be lowercase SHA-256" + } + } + if len(fields) > 0 { + return OrderSubmissionResult{}, invalidError( + "ORDER_SUBMISSION_MANUAL_REVIEW_INVALID", + "order submission manual-review request is invalid", + fields, + ) + } + requestHash, err := lifecycleRequestHash(command) + if err != nil { + return OrderSubmissionResult{}, internalLifecycleFailure(err) + } + eventID, err := service.ids.NewID() + if err != nil { + return OrderSubmissionResult{}, internalLifecycleFailure(err) + } + now := service.clock.Now().UTC() + userID, deviceID := command.UserID, command.DeviceID + submission, replayed, err := service.repository.ManualReviewOrderSubmission( + ctx, + ManualReviewOrderSubmissionWrite{ + ManualReviewOrderSubmissionCommand: command, + ClaimTokenHash: hashSecret(command.ClaimToken), + RequestSHA256: requestHash, + Now: now, + Event: domain.TaskEvent{ + ID: eventID, + TaskID: command.TaskID, + ActorUserID: &userID, + ActorDeviceID: &deviceID, + Type: "ORDER_SUBMISSION_MANUAL_REVIEW", + Message: "order submission requires manual reconciliation", + OccurredAt: now, + }, + }, + ) + if err != nil { + return OrderSubmissionResult{}, wrapLifecycleRepositoryError(err) + } + return OrderSubmissionResult{ + Submission: submission, + Replayed: replayed, + }, nil +} + +func orderSubmissionIdentityFields( + userID, deviceID, taskID, executionID, authorizationID string, + claimGeneration int64, + claimToken, commandSHA256, idempotencyKey string, +) map[string]string { + return dryRunIdentityFields( + userID, + deviceID, + taskID, + executionID, + authorizationID, + claimGeneration, + claimToken, + commandSHA256, + idempotencyKey, + ) +} + +func validateOrderSnapshot( + fields map[string]string, + title, sku string, + quantity int, + unitPriceCents, totalPriceCents int64, +) { + if title == "" || len([]byte(title)) > 1024 { + fields["observed_title"] = "must be 1 to 1024 UTF-8 bytes" + } + if sku == "" || len([]byte(sku)) > 512 { + fields["selected_sku"] = "must be 1 to 512 UTF-8 bytes" + } + if quantity < 1 || quantity > 99 { + fields["quantity"] = "must be 1 to 99" + } + if unitPriceCents < 1 { + fields["unit_price_cents"] = "must be positive" + } + if totalPriceCents < 1 { + fields["total_price_cents"] = "must be positive" + } + if quantity > 0 && + unitPriceCents > math.MaxInt64/int64(quantity) { + fields["total_price_cents"] = "price multiplication overflows" + } +} + +func validateReconciledOrderSnapshot( + fields map[string]string, + title, sku string, + quantity int, + totalPriceCents int64, +) { + if title == "" || len([]byte(title)) > 1024 { + fields["observed_title"] = "must be 1 to 1024 UTF-8 bytes" + } + if sku == "" || len([]byte(sku)) > 512 { + fields["selected_sku"] = "must be 1 to 512 UTF-8 bytes" + } + if quantity < 1 || quantity > 99 { + fields["quantity"] = "must be 1 to 99" + } + if totalPriceCents < 1 { + fields["total_price_cents"] = "must be positive" + } +} + +func normalizeStartOrderSubmission( + command StartOrderSubmissionCommand, +) StartOrderSubmissionCommand { + 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.DryRunID = strings.TrimSpace(command.DryRunID) + command.DryRunEvidenceSHA256 = strings.TrimSpace( + command.DryRunEvidenceSHA256, + ) + command.ObservedTitle = strings.TrimSpace(command.ObservedTitle) + command.SelectedSKU = strings.TrimSpace(command.SelectedSKU) + command.IdempotencyKey = strings.TrimSpace(command.IdempotencyKey) + return command +} + +func normalizeReconcileOrderSubmission( + command ReconcileOrderSubmissionCommand, +) ReconcileOrderSubmissionCommand { + 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.SubmissionID = strings.TrimSpace(command.SubmissionID) + command.PlatformOrderNo = strings.TrimSpace(command.PlatformOrderNo) + command.PlatformOrderedAt = strings.TrimSpace(command.PlatformOrderedAt) + command.PlatformOrderStatus = strings.TrimSpace(command.PlatformOrderStatus) + 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 +} + +func normalizeManualReviewOrderSubmission( + command ManualReviewOrderSubmissionCommand, +) ManualReviewOrderSubmissionCommand { + 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.SubmissionID = strings.TrimSpace(command.SubmissionID) + command.ReasonCode = strings.TrimSpace(command.ReasonCode) + command.EvidenceAssetID = strings.TrimSpace(command.EvidenceAssetID) + command.EvidenceSHA256 = strings.TrimSpace(command.EvidenceSHA256) + command.IdempotencyKey = strings.TrimSpace(command.IdempotencyKey) + return command +} + +var platformOrderNumberPattern = regexp.MustCompile(`^[0-9]{8,40}$`) + +var orderSubmissionManualReasons = map[string]struct{}{ + "ORDER_NOT_FOUND": {}, + "ORDER_AMBIGUOUS": {}, + "ORDER_FIELDS_INCOMPLETE": {}, + "ORDER_PAGE_UNKNOWN": {}, + "RISK_OR_PAYMENT_BOUNDARY": {}, + "EVIDENCE_UNAVAILABLE": {}, +} diff --git a/backend-api/migrations/00011_order_submissions.sql b/backend-api/migrations/00011_order_submissions.sql new file mode 100644 index 0000000..fbbdb7b --- /dev/null +++ b/backend-api/migrations/00011_order_submissions.sql @@ -0,0 +1,435 @@ +-- +goose Up +CREATE TABLE order_submissions ( + 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, + dry_run_id TEXT NOT NULL UNIQUE + REFERENCES order_dry_runs(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]*' + ), + dry_run_evidence_sha256 TEXT NOT NULL + CHECK ( + length(dry_run_evidence_sha256) = 64 + AND dry_run_evidence_sha256 NOT GLOB '*[^0-9a-f]*' + ), + status TEXT NOT NULL + CHECK (status IN ('FENCED', 'RECONCILED', 'MANUAL_REVIEW')), + expected_title TEXT NOT NULL + CHECK ( + length(trim(expected_title)) > 0 + AND length(CAST(expected_title AS BLOB)) <= 1024 + ), + expected_sku TEXT NOT NULL + CHECK ( + length(trim(expected_sku)) > 0 + AND length(CAST(expected_sku AS BLOB)) <= 512 + ), + expected_quantity INTEGER NOT NULL + CHECK (expected_quantity BETWEEN 1 AND 99), + expected_unit_price_cents INTEGER NOT NULL + CHECK (expected_unit_price_cents > 0), + expected_total_price_cents INTEGER NOT NULL + CHECK (expected_total_price_cents > 0), + platform_order_no TEXT UNIQUE + CHECK ( + platform_order_no IS NULL + OR ( + length(platform_order_no) BETWEEN 8 AND 40 + AND platform_order_no NOT GLOB '*[^0-9]*' + ) + ), + platform_ordered_at TEXT, + platform_order_status TEXT + CHECK ( + platform_order_status IS NULL + OR platform_order_status = 'PENDING_PAYMENT' + ), + reconciliation_evidence_asset_id TEXT + REFERENCES execution_evidence_assets(id) + ON UPDATE RESTRICT ON DELETE RESTRICT, + reconciliation_evidence_sha256 TEXT + CHECK ( + reconciliation_evidence_sha256 IS NULL + OR ( + length(reconciliation_evidence_sha256) = 64 + AND reconciliation_evidence_sha256 NOT GLOB '*[^0-9a-f]*' + ) + ), + manual_reason_code TEXT + CHECK ( + manual_reason_code IS NULL + OR manual_reason_code IN ( + 'ORDER_NOT_FOUND', + 'ORDER_AMBIGUOUS', + 'ORDER_FIELDS_INCOMPLETE', + 'ORDER_PAGE_UNKNOWN', + 'RISK_OR_PAYMENT_BOUNDARY', + 'EVIDENCE_UNAVAILABLE' + ) + ), + fenced_at TEXT NOT NULL, + reconciled_at TEXT, + manual_review_at TEXT, + CHECK ( + expected_total_price_cents = + expected_unit_price_cents * expected_quantity + ), + CHECK ( + (status = 'FENCED' + AND platform_order_no IS NULL + AND platform_ordered_at IS NULL + AND platform_order_status IS NULL + AND reconciliation_evidence_asset_id IS NULL + AND reconciliation_evidence_sha256 IS NULL + AND manual_reason_code IS NULL + AND reconciled_at IS NULL + AND manual_review_at IS NULL) + OR + (status = 'RECONCILED' + AND platform_order_no IS NOT NULL + AND platform_ordered_at IS NOT NULL + AND platform_order_status = 'PENDING_PAYMENT' + AND reconciliation_evidence_asset_id IS NOT NULL + AND reconciliation_evidence_sha256 IS NOT NULL + AND manual_reason_code IS NULL + AND reconciled_at IS NOT NULL + AND manual_review_at IS NULL) + OR + (status = 'MANUAL_REVIEW' + AND platform_order_no IS NULL + AND platform_ordered_at IS NULL + AND platform_order_status IS NULL + AND manual_reason_code IS NOT NULL + AND reconciled_at IS NULL + AND manual_review_at IS NOT NULL) + ) +); + +CREATE INDEX order_submissions_task_fenced_idx + ON order_submissions (task_id, fenced_at DESC, id DESC); + +CREATE TABLE device_order_submission_requests ( + device_id TEXT NOT NULL + REFERENCES devices(id) ON UPDATE RESTRICT ON DELETE RESTRICT, + operation TEXT NOT NULL + CHECK (operation IN ('START', 'RECONCILE', 'MANUAL_REVIEW')), + 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, + submission_id TEXT NOT NULL + REFERENCES order_submissions(id) ON UPDATE RESTRICT ON DELETE RESTRICT, + created_at TEXT NOT NULL, + PRIMARY KEY (device_id, operation, idempotency_key) +); + +ALTER TABLE execution_outcomes RENAME TO execution_outcomes_v10; + +CREATE TABLE execution_outcomes ( + execution_id TEXT PRIMARY KEY NOT NULL + REFERENCES task_executions(id) ON UPDATE RESTRICT ON DELETE CASCADE, + task_id TEXT NOT NULL + REFERENCES purchase_tasks(id) ON UPDATE RESTRICT ON DELETE CASCADE, + result_type TEXT NOT NULL + CHECK (result_type IN ('COMPLETE', 'FAIL')), + execution_mode TEXT + CHECK (execution_mode IS NULL OR execution_mode IN ('MANUAL_FIRST', 'AI_ASSISTED')), + task_content_sha256 TEXT + CHECK ( + task_content_sha256 IS NULL + OR ( + length(task_content_sha256) = 64 + AND task_content_sha256 NOT GLOB '*[^0-9a-f]*' + ) + ), + outcome TEXT + CHECK ( + outcome IS NULL + OR outcome IN ( + 'CANDIDATE_ACCEPTED', + 'CANDIDATE_REJECTED', + 'NO_MATCH', + 'MANUAL_REQUIRED' + ) + ), + operator_reason TEXT + CHECK ( + operator_reason IS NULL + OR length(CAST(operator_reason AS BLOB)) <= 1000 + ), + selected_candidate_json TEXT, + evidence_asset_ids_json TEXT, + error_code TEXT + CHECK (error_code IS NULL OR length(CAST(error_code AS BLOB)) <= 64), + error_message TEXT + CHECK (error_message IS NULL OR length(CAST(error_message AS BLOB)) <= 1000), + error_step TEXT + CHECK (error_step IS NULL OR length(CAST(error_step AS BLOB)) <= 64), + retryable INTEGER + CHECK (retryable IS NULL OR retryable IN (0, 1)), + order_submitted INTEGER NOT NULL DEFAULT 0 + CHECK (order_submitted IN (0, 1)), + received_at TEXT NOT NULL, + received_after_execution_expiry INTEGER NOT NULL DEFAULT 0 + CHECK (received_after_execution_expiry IN (0, 1)), + CHECK ( + (result_type = 'COMPLETE' + AND execution_mode IS NOT NULL + AND task_content_sha256 IS NOT NULL + AND outcome IS NOT NULL + AND operator_reason IS NOT NULL + AND error_code IS NULL) + OR + (result_type = 'FAIL' + AND error_code IS NOT NULL + AND error_message IS NOT NULL + AND error_step IS NOT NULL + AND order_submitted = 0) + ) +); + +INSERT INTO execution_outcomes ( + execution_id, task_id, result_type, execution_mode, + task_content_sha256, outcome, operator_reason, + selected_candidate_json, evidence_asset_ids_json, + error_code, error_message, error_step, retryable, + order_submitted, received_at, received_after_execution_expiry +) +SELECT + execution_id, task_id, result_type, execution_mode, + task_content_sha256, outcome, operator_reason, + selected_candidate_json, evidence_asset_ids_json, + error_code, error_message, error_step, retryable, + order_submitted, received_at, received_after_execution_expiry +FROM execution_outcomes_v10; + +DROP TABLE execution_outcomes_v10; + +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', + 'ORDER_DRY_RUN_STARTED', + 'ORDER_DRY_RUN_READY', + 'ORDER_SUBMISSION_FENCED', + 'ORDER_SUBMISSION_RECONCILED', + 'ORDER_SUBMISSION_MANUAL_REVIEW' + ) + ), + 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); + +-- +goose Down +CREATE TEMP TABLE order_submissions_v11_down_guard ( + allowed INTEGER NOT NULL CHECK (allowed = 1) +); + +INSERT INTO order_submissions_v11_down_guard (allowed) +SELECT CASE + WHEN EXISTS (SELECT 1 FROM order_submissions) + OR EXISTS (SELECT 1 FROM device_order_submission_requests) + OR EXISTS ( + SELECT 1 FROM task_events + WHERE event_type IN ( + 'ORDER_SUBMISSION_FENCED', + 'ORDER_SUBMISSION_RECONCILED', + 'ORDER_SUBMISSION_MANUAL_REVIEW' + ) + ) + OR EXISTS ( + SELECT 1 FROM execution_outcomes WHERE order_submitted = 1 + ) + THEN 0 + ELSE 1 +END; + +DROP TABLE order_submissions_v11_down_guard; +DROP TABLE device_order_submission_requests; +DROP TABLE order_submissions; + +ALTER TABLE execution_outcomes RENAME TO execution_outcomes_v11; + +CREATE TABLE execution_outcomes ( + execution_id TEXT PRIMARY KEY NOT NULL + REFERENCES task_executions(id) ON UPDATE RESTRICT ON DELETE CASCADE, + task_id TEXT NOT NULL + REFERENCES purchase_tasks(id) ON UPDATE RESTRICT ON DELETE CASCADE, + result_type TEXT NOT NULL + CHECK (result_type IN ('COMPLETE', 'FAIL')), + execution_mode TEXT + CHECK (execution_mode IS NULL OR execution_mode IN ('MANUAL_FIRST', 'AI_ASSISTED')), + task_content_sha256 TEXT + CHECK ( + task_content_sha256 IS NULL + OR ( + length(task_content_sha256) = 64 + AND task_content_sha256 NOT GLOB '*[^0-9a-f]*' + ) + ), + outcome TEXT + CHECK ( + outcome IS NULL + OR outcome IN ( + 'CANDIDATE_ACCEPTED', + 'CANDIDATE_REJECTED', + 'NO_MATCH', + 'MANUAL_REQUIRED' + ) + ), + operator_reason TEXT + CHECK ( + operator_reason IS NULL + OR length(CAST(operator_reason AS BLOB)) <= 1000 + ), + selected_candidate_json TEXT, + evidence_asset_ids_json TEXT, + error_code TEXT + CHECK (error_code IS NULL OR length(CAST(error_code AS BLOB)) <= 64), + error_message TEXT + CHECK (error_message IS NULL OR length(CAST(error_message AS BLOB)) <= 1000), + error_step TEXT + CHECK (error_step IS NULL OR length(CAST(error_step AS BLOB)) <= 64), + retryable INTEGER + CHECK (retryable IS NULL OR retryable IN (0, 1)), + order_submitted INTEGER NOT NULL DEFAULT 0 + CHECK (order_submitted = 0), + received_at TEXT NOT NULL, + received_after_execution_expiry INTEGER NOT NULL DEFAULT 0 + CHECK (received_after_execution_expiry IN (0, 1)), + CHECK ( + (result_type = 'COMPLETE' + AND execution_mode IS NOT NULL + AND task_content_sha256 IS NOT NULL + AND outcome IS NOT NULL + AND operator_reason IS NOT NULL + AND error_code IS NULL) + OR + (result_type = 'FAIL' + AND error_code IS NOT NULL + AND error_message IS NOT NULL + AND error_step IS NOT NULL) + ) +); + +INSERT INTO execution_outcomes ( + execution_id, task_id, result_type, execution_mode, + task_content_sha256, outcome, operator_reason, + selected_candidate_json, evidence_asset_ids_json, + error_code, error_message, error_step, retryable, + order_submitted, received_at, received_after_execution_expiry +) +SELECT + execution_id, task_id, result_type, execution_mode, + task_content_sha256, outcome, operator_reason, + selected_candidate_json, evidence_asset_ids_json, + error_code, error_message, error_step, retryable, + order_submitted, received_at, received_after_execution_expiry +FROM execution_outcomes_v11; + +DROP TABLE execution_outcomes_v11; + +ALTER TABLE task_events RENAME TO task_events_v11; + +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_v11; + +DROP TABLE task_events_v11; + +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 1931533..23e47a2 100644 --- a/docs/00-ai-start-here.md +++ b/docs/00-ai-start-here.md @@ -61,7 +61,8 @@ T-205 原子领取/租约状态机、T-206 Android 登录/有限离线、T-207 规格组合/价格核验均已完成。T-208 的原始候选观测、模型评估、确定性推荐、逐候选 结构化人工理由和修订历史也已完成。T-214 商品持久身份和重新定位指纹、T-215 Admin 候选确认、不可变待投递授权、T-216 设备命令可靠投递及 T-217 已授权商品 -重新定位与订单 dry-run 均已完成;T-218 单次订单提交与订单回读正在开发。 +重新定位与订单 dry-run、T-218 单次订单提交围栏和订单回读均已完成;下一项是 +T-219 Admin 待付款提醒与端到端验收。 不得直接把候选链接或列表 ordinal 当成授权。 手机从管理后端领取任务并回传结果,VLM、拼多多自动化和人工确认在 App 本地完成。 T-206 增加有限离线执行;T-207 已复用 Roubao 端上 OpenAI 兼容适配器并加密本地 Key。 diff --git a/docs/02-requirements.md b/docs/02-requirements.md index 8d4f7d2..3403772 100644 --- a/docs/02-requirements.md +++ b/docs/02-requirements.md @@ -11,7 +11,7 @@ | 执行方式 | 采购人员使用 Android App 操作拼多多;需求提取、动态词搜索、最多 5 个候选证据采集、匹配建议和人工确认停止点已可运行。 | | 核心痛点 | 人工把图片和描述转成搜索词、逐条比较商品并记录结果,耗时且不一致。 | | 验证范围 | 一台设备、一个管理身份、一个采购执行人员、拼多多单平台。 | -| 资金边界 | MVP 不提交订单、不支付,只验证到人工确认位置。 | +| 资金边界 | 仅在 Admin 明确授权和一次性围栏后创建待付款订单;系统不自动付款。 | ## 二、用户角色 diff --git a/docs/04-architecture.md b/docs/04-architecture.md index 0978393..0056240 100644 --- a/docs/04-architecture.md +++ b/docs/04-architecture.md @@ -650,6 +650,9 @@ local READY + backend READY + valid lease submission fence 是 exactly-once 安全边界,不是“点击成功”回执。服务端创建围栏后, 任何进程恢复都假定订单可能已创建;宁可留下需要人工对账的未提交意图,也不能通过 重按产生重复订单。一个 authorization 和 dry-run 在数据库中只能对应一个 submission。 +只有同一次未中断调用中新建本地 intent 并收到后台 `FENCED` 响应时才短暂持有点击 +资格;磁盘恢复出的 intent 只能重放后台请求并进入对账。围栏已经存在后,即使 claim +租约到期也只继续对账,不恢复商品操作或提交资格。 不可逆动作前再次核对 T-217 全部商品字段,并要求明确可见的已配置地址和唯一 “提交订单”节点。地址缺失、金额变化、自动扣款方式或“立即支付/确认支付/免密支付/ diff --git a/docs/08-interaction-checklist.md b/docs/08-interaction-checklist.md index 7c1ff79..79ddb38 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-217 已完成;T-218/T-219 待实现 | +| IX-011 | US-010 | Admin 候选授权/App 订单执行 | 选择候选并授权、设备领取执行 | 创建一笔可对账的待付款订单并提醒人工付款 | P0 | T-215 至 T-218 已完成;T-219 待实现 | ## IX-001 管理 Web 登录 diff --git a/docs/api.md b/docs/api.md index f86606e..e71172c 100644 --- a/docs/api.md +++ b/docs/api.md @@ -334,6 +334,9 @@ App 唯一识别待付款订单并先上传受控 evidence 后,提交订单编 execution/generation、submission、期望快照、证据归属和幂等键;成功将 submission 置为 `RECONCILED`、authorization 置为 `CONSUMED` 并记录 `order_submitted=true`。订单编号不得进入 URL、日志、事件 message 或模型字段。 +围栏创建后允许原 user/device/generation/claim token 在租约到期后完成该 submission +的对账,但任务必须仍未取消且 execution 未结束;该例外不适用于 start,也不产生 +新的提交资格。 ### `POST /api/v1/tasks/{task_id}/order-submissions/{submission_id}/manual-review` diff --git a/docs/current-state.md b/docs/current-state.md index 8b00a5c..eba235d 100644 --- a/docs/current-state.md +++ b/docs/current-state.md @@ -5,7 +5,7 @@ ## 当前快照 - 日期:2026-07-28 -- 阶段:T-218 单次订单提交与订单回读已领取,合约冻结中 +- 阶段:T-218 单次订单提交与订单回读已完成;下一项 T-219 - Git:当前分支为 `main`;T-001 至 T-004、T-101 至 T-104、T-201 至 T-217 均按文档提交、实现提交的顺序纳入历史 - 生产代码:`android-buyer/` 已接入 Roubao Android 源码 @@ -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-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 集成测试通过 +- 测试:T-218 Android Debug/Release 单元测试与构建和根 `init.ps1` 通过; + Debug APK `1.4.15 (20)` 已覆盖安装到 PKG110 +- 后端测试:T-218 运行 `go test ./...`、`go test -race ./...`、`go vet ./...`; + v11 migration 降级保护及 fence/replay/manual-review/reconcile HTTP 集成测试通过 - 原型:4 个管理 Web 页面和 7 个 Android 页面均可离线独立打开;Playwright 以 1440×900、390×844、360×800 验证 36 个页面/视口组合,无页面横向溢出、 脚本错误或外部请求,Android 可见交互控件均不小于 44px @@ -76,9 +76,10 @@ 唯一颜色/尺码、数量和预算重新核验 Admin 选择;确认页 WebView 折叠时使用端上 中文 OCR 做只读 fail-closed 证据提取。后端保存 v10 READY、截图、SKU、数量、 单价/总额和事件;无障碍动作集中不含订单提交或支付。 -- T-218 目标:后端先创建一次性 submission fence,App 点击前持久化 - `CLICK_ASSUMED`,最多点击一次精确“提交订单”;进程/网络不确定后只回读待付款 - 订单或转人工,绝不重复提交或点击支付。 +- T-218 单次提交:后端先创建唯一 submission fence,App 点击前持久化 + `CLICK_ASSUMED`,最多点击一次精确“提交订单”;进程/网络不确定和租约到期后只 + 回读待付款订单或转人工,绝不重复提交或点击支付。v11 保存围栏、幂等操作、订单号/ + 时间、证据和审计事件,唯一对账后才写 `order_submitted=true`。 - Workflow:纯 Kotlin runner 已支持步骤 timeout、最多 3 次 retry、安全阻塞、 用户停止和单 runner 并发拒绝;T-102 已接入搜索加有界候选采集五步 - TaskSource:严格 CLI 已生成并验证真实私有 ProbeTask;默认 APK 不含私有 fixture @@ -92,11 +93,11 @@ - 离线执行:默认 30 分钟有限授权和 30 秒 best-effort heartbeat 已实现;PKG110 真机断开临时后端 95 秒后保持同一 execution,重连后滑动续期;到期持久安全停止, RUNNING 不自动重新分配 -- 测试设备:OnePlus PKG110,Android 16/API 36;已安装肉包 `1.4.14 (19)`;拼多多 +- 测试设备:OnePlus PKG110,Android 16/API 36;已安装肉包 `1.4.15 (20)`;拼多多 `8.17.0 (81700)` - 设备就绪:拼多多首页、文字/图片搜索、双列候选卡、详情截图和返回均已通过 8.17.0 - 真机验证;采购员已在 ColorOS 设置中手动启用肉包采购无障碍,APK 覆盖安装后授权 - 保持且服务已连接 + 真机验证;ColorOS 本次覆盖安装后移除了肉包采购无障碍,重新启用后服务已连接, + 因此每次覆盖安装后都必须复核该系统授权 - 拼多多 8.17 兼容:结果页可识别新版显示查询词的文本搜索栏和“推荐、手机、女装”等 分类栏,也保留旧版“综合、销量、价格、筛选”识别;长任务搜索词的拼多多省略显示须 至少两个按序片段对应原词才通过。每次候选探针会重写本次搜索词,不复用已有结果页。 @@ -107,8 +108,9 @@ 已用 CLI 真实导入并逐字段/图片哈希验证,生成物位于被忽略的 `.local/` - 标准启动路径:`$env:RUN_START_COMMAND="1"; .\init.ps1` - 标准验证路径:`.\init.ps1` -- 当前 blocker:T-218 实现无代码阻塞;当前真机确认页缺少已配置收货地址,因此 - 真实提交 smoke 必须按合约阻塞,不能为测试自动添加地址。真实 VLM 服务地址、模型、 +- 当前 blocker:T-219 无代码阻塞;当前真机确认页缺少已配置收货地址且显示 + “立即支付/先用后付”,T-218 已按合约证明不创建围栏、不点击。不能为测试自动添加 + 地址或跨过付款边界。真实 VLM 服务地址、模型、 设备级测试凭证、成本上限和数据留存尚未确认;当前只支持单 SKU/JPEG;候选探针 截图要求 Android 11/API 30+ @@ -143,6 +145,7 @@ | `docs/tasks/T-215.md` | DONE | Admin 按 candidate key 选择并创建不可变待投递授权 | | `docs/tasks/T-216.md` | DONE | App 主动拉取并先加密落盘再确认同一条下单命令 | | `docs/tasks/T-217.md` | DONE | 重新核对已授权商品并选择 SKU/数量,停在最终提交前 | +| `docs/tasks/T-218.md` | DONE | 单次提交围栏、最多一次提交动作和待付款订单唯一对账 | | `docs/design/` | 已确认 | T-202 原型索引、4 个管理页和 7 个 Android 页面 | | `deepseek总结.txt` | 已有 | 历史讨论摘要,不是正式需求权威 | | `android-buyer/` | 已有 | Roubao `main` 固定 commit 的 Android 基线 | @@ -153,8 +156,8 @@ ## 任务摘要 -- 已完成:T-001 至 T-004、T-101 至 T-104、T-201 至 T-217。 -- 进行中:T-218 单次订单提交与订单回读。 +- 已完成:T-001 至 T-004、T-101 至 T-104、T-201 至 T-218。 +- 进行中:无。 - 下一步:T-219 Admin 待付款提醒与端到端验收。 ## 当前可运行内容 diff --git a/docs/tasks/T-218.md b/docs/tasks/T-218.md index 919ad7d..8bc5ca8 100644 --- a/docs/tasks/T-218.md +++ b/docs/tasks/T-218.md @@ -4,7 +4,7 @@ title: 单次订单提交与订单回读 phase: 2 deps: - T-217 -status: DOING +status: DONE created: 2026-07-28 context_ref: 1e87b62 work_branch: null @@ -119,15 +119,15 @@ dry-run 身份及 `Idempotency-Key`。start 只接受 READY;reconcile/manual-r ## 验收要点 -- [ ] start 只有 READY、有效租约、未取消和完全一致的命令/金额可成功。 -- [ ] 同一授权最多一个 submission;响应丢失、重启和重复请求不能产生第二次点击。 -- [ ] 缺少地址、自动扣款语义、非精确“提交订单”或页面不确定时 fail closed。 -- [ ] 点击前持久化 `CLICK_ASSUMED`;恢复路径只有订单回读,没有重新提交。 -- [ ] 订单列表只读回读必须唯一匹配订单号、下单时间、SKU、数量、金额和待付款状态。 -- [ ] 零个/多个匹配转人工且仍禁止重新提交;证据和事件可审计。 -- [ ] 代码路径不包含支付点击,订单号不进日志/VLM。 -- [ ] v11 migration、Go test/race/vet、Android test/Debug/Release 和根验证通过。 -- [ ] 真机至少证明缺地址/危险付款语义会阻塞;只有具备受控地址和明确待付款语义时 +- [x] start 只有 READY、有效租约、未取消和完全一致的命令/金额可成功。 +- [x] 同一授权最多一个 submission;响应丢失、重启和重复请求不能产生第二次点击。 +- [x] 缺少地址、自动扣款语义、非精确“提交订单”或页面不确定时 fail closed。 +- [x] 点击前持久化 `CLICK_ASSUMED`;恢复路径只有订单回读,没有重新提交。 +- [x] 订单列表只读回读必须唯一匹配订单号、下单时间、SKU、数量、金额和待付款状态。 +- [x] 零个/多个匹配转人工且仍禁止重新提交;证据和事件可审计。 +- [x] 代码路径不包含支付点击,订单号不进日志/VLM。 +- [x] v11 migration、Go test/race/vet、Android test/Debug/Release 和根验证通过。 +- [x] 真机至少证明缺地址/危险付款语义会阻塞;只有具备受控地址和明确待付款语义时 才允许执行真实单次提交 smoke。 ## 边界 @@ -141,3 +141,15 @@ dry-run 身份及 `Idempotency-Key`。start 只接受 READY;reconcile/manual-r - 2026-07-28:在 T-217 实现提交 `1e87b62` 后领取。冻结服务端一次性提交围栏、 点击前 `CLICK_ASSUMED` 持久化、恢复只对账、订单列表唯一匹配和自动付款禁区。 +- 2026-07-28:合约提交 `682cc73`。实现 v11 submission/request 表、三条设备接口、 + 服务端唯一围栏和幂等对账;Android 加入确认页安全检查、加密恢复状态、单次点击 + 资格、待付款订单解析和有限唯一匹配。 +- 2026-07-28:补强崩溃窗口:只有本次调用新建的 intent 可在收到围栏后获得点击 + 资格;恢复出的旧 intent、`CLICK_ASSUMED` 及其后状态永远不能重新点击。围栏已在 + 后端建立时,租约到期只允许继续订单列表对账。 +- 2026-07-28:`go test ./...`、`go test -race ./...`、`go vet ./...`, + Android Debug/Release 单元测试和 APK 构建、根 `.\init.ps1` 全部通过。 +- 2026-07-28:Debug `1.4.15 (20)` 真机阻断 smoke 通过。拼多多 8.17 确认页显示 + “手动添加收货地址”“立即支付”和“先用后付”,肉包明确报告未创建提交围栏; + 触发前后生产库 `order_submissions=0`,未点击下单或付款。因没有受控收货地址, + 未执行真实订单提交。