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