feat(t217): verify authorized order dry runs
This commit is contained in:
@@ -11,8 +11,8 @@ android {
|
||||
applicationId = "com.roubao.autopilot"
|
||||
minSdk = 26
|
||||
targetSdk = 34
|
||||
versionCode = 18
|
||||
versionName = "1.4.13"
|
||||
versionCode = 19
|
||||
versionName = "1.4.14"
|
||||
|
||||
vectorDrawables {
|
||||
useSupportLibrary = true
|
||||
@@ -107,6 +107,9 @@ dependencies {
|
||||
// JSON
|
||||
implementation("org.json:json:20231013")
|
||||
|
||||
// Bundled on-device OCR for Pinduoduo WebView checkout evidence
|
||||
implementation("com.google.mlkit:text-recognition-chinese:16.0.1")
|
||||
|
||||
// Unit tests
|
||||
testImplementation("junit:junit:4.13.2")
|
||||
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.3")
|
||||
|
||||
+47
@@ -3,6 +3,7 @@ package com.roubao.autopilot.accessibility
|
||||
import com.roubao.autopilot.pinduoduo.PinduoduoPage
|
||||
import com.roubao.autopilot.pinduoduo.PinduoduoCandidateCard
|
||||
import com.roubao.autopilot.pinduoduo.PinduoduoCandidateDetailEvidence
|
||||
import com.roubao.autopilot.pinduoduo.PinduoduoOrderConfirmationEvidence
|
||||
import com.roubao.autopilot.pinduoduo.PinduoduoScreenshotCapture
|
||||
import com.roubao.autopilot.pinduoduo.PinduoduoSpecificationEvidence
|
||||
import com.roubao.autopilot.pinduoduo.PinduoduoSpecificationGroupKind
|
||||
@@ -44,6 +45,11 @@ object BuyerAccessibilityBridge {
|
||||
service?.clickPinduoduoImageSearchEntry() == true
|
||||
}
|
||||
|
||||
suspend fun dismissImageSearchRetryDialog(): Boolean =
|
||||
withContext(Dispatchers.Main.immediate) {
|
||||
service?.dismissPinduoduoImageSearchRetryDialog() == true
|
||||
}
|
||||
|
||||
suspend fun selectFirstRecentImage(): Boolean =
|
||||
withContext(Dispatchers.Main.immediate) {
|
||||
service?.selectFirstPinduoduoRecentImage() == true
|
||||
@@ -107,6 +113,42 @@ object BuyerAccessibilityBridge {
|
||||
service?.scrollPinduoduoSpecifications() == true
|
||||
}
|
||||
|
||||
suspend fun specificationQuantity(): Int? =
|
||||
withContext(Dispatchers.Main.immediate) {
|
||||
service?.readPinduoduoSpecificationQuantity()
|
||||
}
|
||||
|
||||
suspend fun changeSpecificationQuantity(increase: Boolean): Boolean =
|
||||
withContext(Dispatchers.Main.immediate) {
|
||||
service?.changePinduoduoSpecificationQuantity(increase) == true
|
||||
}
|
||||
|
||||
suspend fun confirmSpecifications(): Boolean =
|
||||
withContext(Dispatchers.Main.immediate) {
|
||||
service?.confirmPinduoduoSpecifications() == true
|
||||
}
|
||||
|
||||
suspend fun orderConfirmationEvidence():
|
||||
PinduoduoOrderConfirmationEvidence? =
|
||||
withContext(Dispatchers.Main.immediate) {
|
||||
service?.readPinduoduoOrderConfirmationEvidence()
|
||||
}
|
||||
|
||||
suspend fun isCollapsedCheckout(): Boolean =
|
||||
withContext(Dispatchers.Main.immediate) {
|
||||
service?.isPinduoduoCollapsedCheckout() == true
|
||||
}
|
||||
|
||||
suspend fun captureOrderConfirmationScreenshot():
|
||||
PinduoduoScreenshotCapture? =
|
||||
withTimeoutOrNull(SCREENSHOT_TIMEOUT_MILLIS) {
|
||||
withContext(Dispatchers.Main.immediate) {
|
||||
service?.capturePinduoduoScreenshot(
|
||||
PinduoduoPage.ORDER_CONFIRMATION
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun captureSpecificationScreenshot(): PinduoduoScreenshotCapture? =
|
||||
withTimeoutOrNull(SCREENSHOT_TIMEOUT_MILLIS) {
|
||||
withContext(Dispatchers.Main.immediate) {
|
||||
@@ -141,5 +183,10 @@ object BuyerAccessibilityBridge {
|
||||
service?.scrollPinduoduoResults() == true
|
||||
}
|
||||
|
||||
suspend fun scrollResultsBackward(): Boolean =
|
||||
withContext(Dispatchers.Main.immediate) {
|
||||
service?.scrollPinduoduoResultsBackward() == true
|
||||
}
|
||||
|
||||
private const val SCREENSHOT_TIMEOUT_MILLIS = 8_000L
|
||||
}
|
||||
|
||||
+211
-12
@@ -13,8 +13,10 @@ import android.view.accessibility.AccessibilityNodeInfo
|
||||
import androidx.annotation.RequiresApi
|
||||
import com.roubao.autopilot.pinduoduo.PinduoduoCandidateCard
|
||||
import com.roubao.autopilot.pinduoduo.PinduoduoCandidateDetailEvidence
|
||||
import com.roubao.autopilot.pinduoduo.PinduoduoEvidenceHash
|
||||
import com.roubao.autopilot.pinduoduo.PinduoduoCollapsedCheckoutPolicy
|
||||
import com.roubao.autopilot.pinduoduo.PinduoduoObservedTitlePolicy
|
||||
import com.roubao.autopilot.pinduoduo.PinduoduoOrderConfirmationEvidence
|
||||
import com.roubao.autopilot.pinduoduo.PinduoduoOrderConfirmationParser
|
||||
import com.roubao.autopilot.readiness.DeviceObservationStore
|
||||
import com.roubao.autopilot.readiness.LoginBlockerDetector
|
||||
import com.roubao.autopilot.readiness.PINDUODUO_PACKAGE
|
||||
@@ -28,6 +30,8 @@ import com.roubao.autopilot.pinduoduo.PinduoduoScreenshotCapture
|
||||
import com.roubao.autopilot.pinduoduo.PinduoduoSpecificationEvidence
|
||||
import com.roubao.autopilot.pinduoduo.PinduoduoSpecificationGroupKind
|
||||
import com.roubao.autopilot.pinduoduo.PinduoduoSpecificationParser
|
||||
import com.roubao.autopilot.pinduoduo.PinduoduoStableProductIdentityPolicy
|
||||
import com.roubao.autopilot.workflow.SafetyStopReason
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.util.ArrayDeque
|
||||
import java.util.concurrent.Executors
|
||||
@@ -156,6 +160,27 @@ class BuyerAccessibilityService : AccessibilityService() {
|
||||
?.performAction(AccessibilityNodeInfo.ACTION_CLICK) == true
|
||||
} ?: false
|
||||
|
||||
internal fun dismissPinduoduoImageSearchRetryDialog(): Boolean =
|
||||
withPinduoduoRoot { root ->
|
||||
val snapshot = classifyPinduoduoRoot(root)
|
||||
if (
|
||||
snapshot.safetyStopReason != null ||
|
||||
snapshot.page != PinduoduoPage.IMAGE_SEARCH_RETRY_DIALOG
|
||||
) {
|
||||
return@withPinduoduoRoot false
|
||||
}
|
||||
collectNodes(root)
|
||||
.filter { node ->
|
||||
node.isVisibleToUser &&
|
||||
node.isEnabled &&
|
||||
node.isClickable &&
|
||||
node.text?.toString()?.trim() ==
|
||||
IMAGE_SEARCH_RETRY_CANCEL_TEXT
|
||||
}
|
||||
.singleOrNull()
|
||||
?.performAction(AccessibilityNodeInfo.ACTION_CLICK) == true
|
||||
} ?: false
|
||||
|
||||
internal fun selectFirstPinduoduoRecentImage(): Boolean =
|
||||
withPinduoduoRoot { root ->
|
||||
val snapshot = classifyPinduoduoRoot(root)
|
||||
@@ -317,12 +342,14 @@ class BuyerAccessibilityService : AccessibilityService() {
|
||||
if (semanticTexts.isEmpty()) {
|
||||
return@withPinduoduoRoot null
|
||||
}
|
||||
val observedTitle = PinduoduoObservedTitlePolicy.select(semanticTexts)
|
||||
?: return@withPinduoduoRoot null
|
||||
PinduoduoCandidateDetailEvidence(
|
||||
signature = PinduoduoEvidenceHash.sha256(
|
||||
semanticTexts.joinToString(TEXT_SIGNATURE_SEPARATOR)
|
||||
signature = requireNotNull(
|
||||
PinduoduoStableProductIdentityPolicy.signature(semanticTexts)
|
||||
),
|
||||
semanticTextCount = semanticTexts.size,
|
||||
observedTitle = PinduoduoObservedTitlePolicy.select(semanticTexts)
|
||||
observedTitle = observedTitle
|
||||
)
|
||||
}
|
||||
|
||||
@@ -428,6 +455,78 @@ class BuyerAccessibilityService : AccessibilityService() {
|
||||
) == true
|
||||
} ?: false
|
||||
|
||||
internal fun readPinduoduoSpecificationQuantity(): Int? =
|
||||
withPinduoduoRoot { root ->
|
||||
if (
|
||||
classifyPinduoduoRoot(root).page !=
|
||||
PinduoduoPage.SPECIFICATION_PANEL
|
||||
) {
|
||||
return@withPinduoduoRoot null
|
||||
}
|
||||
quantityControl(root)?.quantity
|
||||
}
|
||||
|
||||
internal fun changePinduoduoSpecificationQuantity(
|
||||
increase: Boolean
|
||||
): Boolean =
|
||||
withPinduoduoRoot { root ->
|
||||
if (
|
||||
classifyPinduoduoRoot(root).page !=
|
||||
PinduoduoPage.SPECIFICATION_PANEL
|
||||
) {
|
||||
return@withPinduoduoRoot false
|
||||
}
|
||||
val control = quantityControl(root)
|
||||
?: return@withPinduoduoRoot false
|
||||
val target = if (increase) control.increase else control.decrease
|
||||
target.isEnabled &&
|
||||
target.performAction(AccessibilityNodeInfo.ACTION_CLICK)
|
||||
} ?: false
|
||||
|
||||
internal fun confirmPinduoduoSpecifications(): Boolean =
|
||||
withPinduoduoRoot { root ->
|
||||
if (
|
||||
classifyPinduoduoRoot(root).page !=
|
||||
PinduoduoPage.SPECIFICATION_PANEL
|
||||
) {
|
||||
return@withPinduoduoRoot false
|
||||
}
|
||||
uniqueClickableTargets(
|
||||
collectNodes(root).filter { node ->
|
||||
node.isVisibleToUser &&
|
||||
node.isEnabled &&
|
||||
normalizeActionText(semanticText(node)) == "确定"
|
||||
}
|
||||
).singleOrNull()?.performAction(
|
||||
AccessibilityNodeInfo.ACTION_CLICK
|
||||
) == true
|
||||
} ?: false
|
||||
|
||||
internal fun readPinduoduoOrderConfirmationEvidence():
|
||||
PinduoduoOrderConfirmationEvidence? =
|
||||
withPinduoduoRoot { root ->
|
||||
val snapshot = classifyPinduoduoRoot(root)
|
||||
if (
|
||||
snapshot.page != PinduoduoPage.ORDER_CONFIRMATION ||
|
||||
snapshot.safetyStopReason !in setOf(
|
||||
null,
|
||||
SafetyStopReason.PAYMENT_BOUNDARY
|
||||
)
|
||||
) {
|
||||
return@withPinduoduoRoot null
|
||||
}
|
||||
PinduoduoOrderConfirmationParser.parse(
|
||||
collectNodes(root).map(::uiElement)
|
||||
)
|
||||
}
|
||||
|
||||
internal fun isPinduoduoCollapsedCheckout(): Boolean =
|
||||
withPinduoduoRoot { root ->
|
||||
PinduoduoCollapsedCheckoutPolicy.matches(
|
||||
collectNodes(root).map(::uiElement)
|
||||
)
|
||||
} ?: false
|
||||
|
||||
internal fun closePinduoduoSpecifications(): Boolean =
|
||||
withPinduoduoRoot { root ->
|
||||
val snapshot = classifyPinduoduoRoot(root)
|
||||
@@ -485,6 +584,20 @@ class BuyerAccessibilityService : AccessibilityService() {
|
||||
) == true
|
||||
} ?: false
|
||||
|
||||
internal fun scrollPinduoduoResultsBackward(): Boolean =
|
||||
withPinduoduoRoot { root ->
|
||||
val snapshot = classifyPinduoduoRoot(root)
|
||||
if (
|
||||
snapshot.safetyStopReason != null ||
|
||||
!snapshot.page.isCandidateResultsPage()
|
||||
) {
|
||||
return@withPinduoduoRoot false
|
||||
}
|
||||
findMainResultsRecycler(root)?.performAction(
|
||||
AccessibilityNodeInfo.ACTION_SCROLL_BACKWARD
|
||||
) == true
|
||||
} ?: false
|
||||
|
||||
internal suspend fun capturePinduoduoScreenshot(
|
||||
expectedPage: PinduoduoPage
|
||||
): PinduoduoScreenshotCapture? {
|
||||
@@ -494,7 +607,8 @@ class BuyerAccessibilityService : AccessibilityService() {
|
||||
if (
|
||||
expectedPage !in setOf(
|
||||
PinduoduoPage.PRODUCT_DETAIL,
|
||||
PinduoduoPage.SPECIFICATION_PANEL
|
||||
PinduoduoPage.SPECIFICATION_PANEL,
|
||||
PinduoduoPage.ORDER_CONFIRMATION
|
||||
)
|
||||
) {
|
||||
return null
|
||||
@@ -509,10 +623,28 @@ class BuyerAccessibilityService : AccessibilityService() {
|
||||
PinduoduoScreenshotCapture? {
|
||||
val root = rootInActiveWindow
|
||||
val snapshot = root?.let(::classifyPinduoduoRoot)
|
||||
val collapsedCheckoutExpected =
|
||||
root != null &&
|
||||
expectedPage == PinduoduoPage.ORDER_CONFIRMATION &&
|
||||
snapshot?.page == PinduoduoPage.UNKNOWN &&
|
||||
snapshot.safetyStopReason == null &&
|
||||
PinduoduoCollapsedCheckoutPolicy.matches(
|
||||
collectNodes(root).map(::uiElement)
|
||||
)
|
||||
if (
|
||||
root?.packageName?.toString() != PINDUODUO_PACKAGE ||
|
||||
snapshot?.safetyStopReason != null ||
|
||||
snapshot?.page != expectedPage ||
|
||||
(
|
||||
snapshot?.safetyStopReason != null &&
|
||||
!(
|
||||
expectedPage == PinduoduoPage.ORDER_CONFIRMATION &&
|
||||
snapshot.safetyStopReason ==
|
||||
SafetyStopReason.PAYMENT_BOUNDARY
|
||||
)
|
||||
) ||
|
||||
(
|
||||
snapshot?.page != expectedPage &&
|
||||
!collapsedCheckoutExpected
|
||||
) ||
|
||||
(
|
||||
expectedPage == PinduoduoPage.PRODUCT_DETAIL &&
|
||||
!isVerifiedProductDetail(root)
|
||||
@@ -673,18 +805,19 @@ class BuyerAccessibilityService : AccessibilityService() {
|
||||
}
|
||||
val hasPrice = semanticTexts.any(::hasPriceSemantics)
|
||||
val clickTarget = findSafeCandidateClickTarget(node, descendants)
|
||||
val stableSignature =
|
||||
PinduoduoStableProductIdentityPolicy.signature(semanticTexts)
|
||||
if (
|
||||
semanticTexts.size < MIN_CANDIDATE_TEXTS ||
|
||||
!hasImage ||
|
||||
!hasPrice ||
|
||||
clickTarget == null
|
||||
clickTarget == null ||
|
||||
stableSignature == null
|
||||
) {
|
||||
continue
|
||||
}
|
||||
val card = PinduoduoCandidateCard(
|
||||
signature = PinduoduoEvidenceHash.sha256(
|
||||
semanticTexts.joinToString(TEXT_SIGNATURE_SEPARATOR)
|
||||
),
|
||||
signature = stableSignature,
|
||||
semanticTextCount = semanticTexts.size,
|
||||
hasImage = true
|
||||
)
|
||||
@@ -790,6 +923,60 @@ class BuyerAccessibilityService : AccessibilityService() {
|
||||
return coveringTargets.singleOrNull()
|
||||
}
|
||||
|
||||
private fun quantityControl(root: AccessibilityNodeInfo): QuantityControl? {
|
||||
val controls = collectNodes(root).mapNotNull { valueNode ->
|
||||
val quantity = semanticText(valueNode).trim().toIntOrNull()
|
||||
?.takeIf { it in 1..99 }
|
||||
?: return@mapNotNull null
|
||||
var container: AccessibilityNodeInfo? = valueNode.parent
|
||||
repeat(MAX_QUANTITY_ANCESTORS) {
|
||||
val current = container ?: return@repeat
|
||||
val descendants = collectNodes(current)
|
||||
val increase = uniqueClickableTargets(
|
||||
descendants.filter { node ->
|
||||
node.isVisibleToUser &&
|
||||
normalizeActionText(semanticText(node)) in
|
||||
INCREASE_QUANTITY_TEXTS
|
||||
}
|
||||
).singleOrNull()
|
||||
val decrease = uniqueClickableTargets(
|
||||
descendants.filter { node ->
|
||||
node.isVisibleToUser &&
|
||||
normalizeActionText(semanticText(node)) in
|
||||
DECREASE_QUANTITY_TEXTS
|
||||
}
|
||||
).singleOrNull()
|
||||
if (increase != null && decrease != null) {
|
||||
val bounds = Rect().also(current::getBoundsInScreen)
|
||||
return@mapNotNull QuantityControl(
|
||||
quantity,
|
||||
increase,
|
||||
decrease,
|
||||
area(bounds)
|
||||
)
|
||||
}
|
||||
container = current.parent
|
||||
}
|
||||
null
|
||||
}
|
||||
return controls
|
||||
.distinctBy { control ->
|
||||
val increaseBounds = Rect().also(
|
||||
control.increase::getBoundsInScreen
|
||||
)
|
||||
val decreaseBounds = Rect().also(
|
||||
control.decrease::getBoundsInScreen
|
||||
)
|
||||
listOf(
|
||||
increaseBounds.left,
|
||||
increaseBounds.top,
|
||||
decreaseBounds.right,
|
||||
decreaseBounds.bottom
|
||||
)
|
||||
}
|
||||
.minByOrNull(QuantityControl::area)
|
||||
}
|
||||
|
||||
private fun hasPriceSemantics(value: String): Boolean =
|
||||
value.contains('¥') ||
|
||||
value.contains('¥') ||
|
||||
@@ -932,10 +1119,18 @@ class BuyerAccessibilityService : AccessibilityService() {
|
||||
val descendantCount: Int
|
||||
)
|
||||
|
||||
private data class QuantityControl(
|
||||
val quantity: Int,
|
||||
val increase: AccessibilityNodeInfo,
|
||||
val decrease: AccessibilityNodeInfo,
|
||||
val area: Long
|
||||
)
|
||||
|
||||
companion object {
|
||||
private const val TAG = "BuyerAccessibility"
|
||||
private const val MAX_NODES = 300
|
||||
private const val MAX_CLICK_ANCESTORS = 4
|
||||
private const val MAX_QUANTITY_ANCESTORS = 4
|
||||
private const val MAX_CANDIDATE_NODE_SCAN = 20
|
||||
private const val MIN_CANDIDATE_TEXTS = 2
|
||||
private const val MIN_MAIN_RECYCLER_NODES = 20
|
||||
@@ -948,13 +1143,17 @@ class BuyerAccessibilityService : AccessibilityService() {
|
||||
private const val MIN_CLICK_TARGET_COVER_PERCENT = 85
|
||||
private const val MAX_EVIDENCE_TEXTS = 100
|
||||
private const val MAX_EVIDENCE_TEXT_LENGTH = 500
|
||||
private const val TEXT_SIGNATURE_SEPARATOR = "\u001f"
|
||||
private const val MIN_SCAN_INTERVAL_MS = 300L
|
||||
private const val IMAGE_SEARCH_DESCRIPTION = "拍照搜索"
|
||||
private const val IMAGE_SEARCH_RETRY_CANCEL_TEXT = "取消"
|
||||
private const val RECENT_PROJECTS_TEXT = "最近项目"
|
||||
private const val IMAGE_GRID_COLUMNS = 4
|
||||
private const val IMAGE_GRID_WIDTH_TOLERANCE = 24
|
||||
private const val SPECIFICATION_PURCHASE_ENTRY = "免拼购买"
|
||||
private val INCREASE_QUANTITY_TEXTS =
|
||||
setOf("增加数量", "加", "+")
|
||||
private val DECREASE_QUANTITY_TEXTS =
|
||||
setOf("减少数量", "减", "-")
|
||||
private val DECIMAL_PRICE_PATTERN =
|
||||
Regex("^\\s*\\d{1,6}\\.\\d{1,2}\\s*$")
|
||||
}
|
||||
|
||||
+9
@@ -30,10 +30,19 @@ class AndroidPinduoduoImageSearchDriver(
|
||||
override suspend fun openImageSearch(): Boolean =
|
||||
BuyerAccessibilityBridge.openImageSearch()
|
||||
|
||||
override suspend fun dismissImageSearchRetryDialog(): Boolean =
|
||||
BuyerAccessibilityBridge.dismissImageSearchRetryDialog()
|
||||
|
||||
override suspend fun selectPreparedImage(): Boolean =
|
||||
assetStore.isMostRecent(preparedImage) &&
|
||||
BuyerAccessibilityBridge.selectFirstRecentImage()
|
||||
|
||||
override suspend fun closeSpecifications(): Boolean =
|
||||
BuyerAccessibilityBridge.closeSpecifications()
|
||||
|
||||
override suspend fun returnFromOrderConfirmation(): Boolean =
|
||||
BuyerAccessibilityBridge.returnFromOrderConfirmation()
|
||||
|
||||
override suspend fun returnFromCandidate(): Boolean =
|
||||
BuyerAccessibilityBridge.returnToResults()
|
||||
|
||||
|
||||
+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)
|
||||
}
|
||||
|
||||
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 {
|
||||
fun sha256(value: String): String =
|
||||
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 snapshot(): PinduoduoUiSnapshot
|
||||
suspend fun openImageSearch(): Boolean
|
||||
suspend fun dismissImageSearchRetryDialog(): Boolean
|
||||
suspend fun selectPreparedImage(): Boolean
|
||||
suspend fun closeSpecifications(): Boolean
|
||||
suspend fun returnFromOrderConfirmation(): Boolean
|
||||
suspend fun returnFromCandidate(): Boolean
|
||||
suspend fun returnFromImageResults(): Boolean
|
||||
}
|
||||
@@ -54,8 +57,11 @@ class PinduoduoImageSearchAutomation(
|
||||
PinduoduoPage.SEARCH_RESULTS,
|
||||
PinduoduoPage.SEARCH_RESULTS_OTHER_QUERY,
|
||||
PinduoduoPage.IMAGE_SEARCH,
|
||||
PinduoduoPage.IMAGE_SEARCH_RETRY_DIALOG,
|
||||
PinduoduoPage.IMAGE_SEARCH_RESULTS,
|
||||
PinduoduoPage.PRODUCT_DETAIL
|
||||
PinduoduoPage.PRODUCT_DETAIL,
|
||||
PinduoduoPage.SPECIFICATION_PANEL,
|
||||
PinduoduoPage.ORDER_CONFIRMATION
|
||||
)
|
||||
} ?: AutomationResult.Success
|
||||
}
|
||||
@@ -63,7 +69,13 @@ class PinduoduoImageSearchAutomation(
|
||||
private suspend fun openImageSearch(): AutomationResult {
|
||||
repeat(MAX_RECOVERY_ACTIONS) {
|
||||
val snapshot = driver.snapshot()
|
||||
safetyResult(snapshot)?.let { return it }
|
||||
val recoverableOrderConfirmation =
|
||||
snapshot.page == PinduoduoPage.ORDER_CONFIRMATION &&
|
||||
snapshot.safetyStopReason ==
|
||||
SafetyStopReason.PAYMENT_BOUNDARY
|
||||
if (!recoverableOrderConfirmation) {
|
||||
safetyResult(snapshot)?.let { return it }
|
||||
}
|
||||
when (snapshot.page) {
|
||||
PinduoduoPage.IMAGE_SEARCH -> {
|
||||
if (driver.selectPreparedImage()) {
|
||||
@@ -85,11 +97,28 @@ class PinduoduoImageSearchAutomation(
|
||||
)
|
||||
}
|
||||
}
|
||||
PinduoduoPage.IMAGE_SEARCH_RETRY_DIALOG -> {
|
||||
if (!driver.dismissImageSearchRetryDialog()) {
|
||||
return retryableFailure()
|
||||
}
|
||||
delay(pollIntervalMillis)
|
||||
return@repeat
|
||||
}
|
||||
PinduoduoPage.PRODUCT_DETAIL -> {
|
||||
if (!driver.returnFromCandidate()) {
|
||||
return retryableFailure()
|
||||
}
|
||||
}
|
||||
PinduoduoPage.SPECIFICATION_PANEL -> {
|
||||
if (!driver.closeSpecifications()) {
|
||||
return retryableFailure()
|
||||
}
|
||||
}
|
||||
PinduoduoPage.ORDER_CONFIRMATION -> {
|
||||
if (!driver.returnFromOrderConfirmation()) {
|
||||
return retryableFailure()
|
||||
}
|
||||
}
|
||||
PinduoduoPage.IMAGE_SEARCH_RESULTS -> {
|
||||
if (!driver.returnFromImageResults()) {
|
||||
return retryableFailure()
|
||||
@@ -106,9 +135,12 @@ class PinduoduoImageSearchAutomation(
|
||||
}
|
||||
awaitPage { page ->
|
||||
page == PinduoduoPage.IMAGE_SEARCH ||
|
||||
page == PinduoduoPage.IMAGE_SEARCH_RETRY_DIALOG ||
|
||||
page == PinduoduoPage.IMAGE_SEARCH_RESULTS ||
|
||||
page == PinduoduoPage.SEARCH_RESULTS ||
|
||||
page == PinduoduoPage.SEARCH_RESULTS_OTHER_QUERY
|
||||
page == PinduoduoPage.SEARCH_RESULTS_OTHER_QUERY ||
|
||||
page == PinduoduoPage.PRODUCT_DETAIL ||
|
||||
page == PinduoduoPage.SPECIFICATION_PANEL
|
||||
}?.let { return it }
|
||||
}
|
||||
return AutomationResult.Blocked(SafetyStopReason.UNKNOWN_PAGE)
|
||||
@@ -133,8 +165,16 @@ class PinduoduoImageSearchAutomation(
|
||||
var stableUnknownObservations = 0
|
||||
while (true) {
|
||||
val snapshot = driver.snapshot()
|
||||
safetyResult(snapshot)?.let { return it }
|
||||
if (expected(snapshot.page)) {
|
||||
val pageExpected = expected(snapshot.page)
|
||||
val recoverableOrderConfirmation =
|
||||
pageExpected &&
|
||||
snapshot.page == PinduoduoPage.ORDER_CONFIRMATION &&
|
||||
snapshot.safetyStopReason ==
|
||||
SafetyStopReason.PAYMENT_BOUNDARY
|
||||
if (!recoverableOrderConfirmation) {
|
||||
safetyResult(snapshot)?.let { return it }
|
||||
}
|
||||
if (pageExpected) {
|
||||
return null
|
||||
}
|
||||
stableUnknownObservations = if (
|
||||
@@ -163,7 +203,7 @@ class PinduoduoImageSearchAutomation(
|
||||
)
|
||||
|
||||
private companion object {
|
||||
const val MAX_RECOVERY_ACTIONS = 4
|
||||
const val MAX_RECOVERY_ACTIONS = 5
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+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_OTHER_QUERY,
|
||||
IMAGE_SEARCH,
|
||||
IMAGE_SEARCH_RETRY_DIALOG,
|
||||
IMAGE_SEARCH_RESULTS,
|
||||
PRODUCT_DETAIL,
|
||||
SPECIFICATION_PANEL,
|
||||
@@ -42,6 +43,30 @@ data class PinduoduoUiSnapshot(
|
||||
val safetyStopReason: SafetyStopReason?
|
||||
)
|
||||
|
||||
object PinduoduoCollapsedCheckoutPolicy {
|
||||
fun matches(elements: Collection<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 {
|
||||
private val legacyResultSortMarkers = setOf("综合", "销量", "价格", "筛选")
|
||||
private val modernResultCategoryMarkers = setOf(
|
||||
@@ -62,7 +87,16 @@ object PinduoduoPageClassifier {
|
||||
"提交订单",
|
||||
"确认支付",
|
||||
"立即支付",
|
||||
"收银台"
|
||||
"收银台",
|
||||
"微信支付",
|
||||
"找好友支付",
|
||||
"更多支付方式"
|
||||
)
|
||||
private val paymentMethodMarkers = setOf(
|
||||
"微信支付",
|
||||
"先用后付",
|
||||
"找好友支付",
|
||||
"支付宝"
|
||||
)
|
||||
|
||||
fun classify(
|
||||
@@ -79,6 +113,9 @@ object PinduoduoPageClassifier {
|
||||
}
|
||||
|
||||
val visibleElements = elements.filter { it.visibleToUser && it.enabled }
|
||||
val hasCheckoutRoot = visibleElements.any { element ->
|
||||
element.resourceId == "order_checkout"
|
||||
}
|
||||
val visibleTexts = visibleElements.flatMap { element ->
|
||||
listOfNotNull(element.text, element.contentDescription)
|
||||
}
|
||||
@@ -88,7 +125,10 @@ object PinduoduoPageClassifier {
|
||||
LoginBlocker.RISK_CONTROL -> SafetyStopReason.RISK_CONTROL
|
||||
LoginBlocker.NONE,
|
||||
LoginBlocker.UNKNOWN -> {
|
||||
if (visibleTexts.containsAny(paymentMarkers)) {
|
||||
if (
|
||||
hasCheckoutRoot ||
|
||||
visibleTexts.containsAny(paymentMarkers)
|
||||
) {
|
||||
SafetyStopReason.PAYMENT_BOUNDARY
|
||||
} else {
|
||||
null
|
||||
@@ -141,6 +181,10 @@ object PinduoduoPageClassifier {
|
||||
text == "开启相机权限" ||
|
||||
text.contains("即可进行自动识别")
|
||||
}
|
||||
val hasImageSearchRetryDialog =
|
||||
normalized.any { it == "请对准商品或码,保持手机稳定" } &&
|
||||
normalized.any { it == "取消" } &&
|
||||
normalized.any { it == "再试一次" }
|
||||
val hasImageResultHeader = normalized.any { it == "搜图片同款" }
|
||||
val hasExpectedQuery = visibleTexts.any { text ->
|
||||
matchesExpectedQuery(text, expectedQuery)
|
||||
@@ -181,7 +225,16 @@ object PinduoduoPageClassifier {
|
||||
specificationGroupCount >= 2 &&
|
||||
specificationOptionCount >= 2
|
||||
)
|
||||
val hasOrderConfirmation = normalized.any { it == "确认订单" }
|
||||
val hasOrderConfirmation =
|
||||
hasCheckoutRoot ||
|
||||
normalized.any { it == "确认订单" } ||
|
||||
(
|
||||
normalized.any { it.contains("收货地址") } &&
|
||||
paymentMethodMarkers.count { marker ->
|
||||
normalized.any { it.contains(marker) }
|
||||
} >= 2 &&
|
||||
normalized.any { it.startsWith("实付款¥") }
|
||||
)
|
||||
val hasOrderList =
|
||||
normalized.any { it == "我的订单" || it == "全部订单" } &&
|
||||
setOf("待付款", "待发货", "待收货")
|
||||
@@ -193,6 +246,8 @@ object PinduoduoPageClassifier {
|
||||
hasSpecificationPanel -> PinduoduoPage.SPECIFICATION_PANEL
|
||||
hasImageResultHeader && legacySortControlCount >= 3 ->
|
||||
PinduoduoPage.IMAGE_SEARCH_RESULTS
|
||||
hasImageSearchRetryDialog ->
|
||||
PinduoduoPage.IMAGE_SEARCH_RETRY_DIALOG
|
||||
hasImageSearchPage -> PinduoduoPage.IMAGE_SEARCH
|
||||
hasExpectedQuery && hasResultSearchHeader && hasResultControls ->
|
||||
PinduoduoPage.SEARCH_RESULTS
|
||||
|
||||
+2
-1
@@ -43,7 +43,8 @@ object OrderCommandIntegrity {
|
||||
) { "后台订单命令指纹无效" }
|
||||
require(
|
||||
command.authorizationStatus == "DELIVERED" ||
|
||||
command.authorizationStatus == "ACKNOWLEDGED"
|
||||
command.authorizationStatus == "ACKNOWLEDGED" ||
|
||||
command.authorizationStatus == "EXECUTING"
|
||||
) { "后台订单命令状态无效" }
|
||||
require(sha256(command) == command.commandSha256) {
|
||||
"后台订单命令完整性校验失败"
|
||||
|
||||
+3
-1
@@ -18,7 +18,9 @@ object OrderCommandSynchronization {
|
||||
val delivered = pull() ?: return null
|
||||
OrderCommandIntegrity.validate(delivered, task, execution)
|
||||
command = delivered.copy(
|
||||
acknowledgementIdempotencyKey = newIdempotencyKey()
|
||||
acknowledgementIdempotencyKey = newIdempotencyKey(),
|
||||
acknowledged =
|
||||
delivered.authorizationStatus == "EXECUTING"
|
||||
)
|
||||
persist(command)
|
||||
} else {
|
||||
|
||||
+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(
|
||||
val evidenceAssetId: String? = null
|
||||
val evidenceAssetId: String? = null,
|
||||
val evidenceSha256: String? = null,
|
||||
val orderDryRun: RemoteOrderDryRun? = null
|
||||
)
|
||||
|
||||
interface ProcurementRemoteApi {
|
||||
@@ -100,6 +102,15 @@ interface ProcurementRemoteApi {
|
||||
idempotencyKey: String
|
||||
): OrderCommandAcknowledgement
|
||||
|
||||
suspend fun startOrderDryRun(
|
||||
session: ProcurementSession,
|
||||
task: RemotePurchaseTask,
|
||||
execution: RunningExecution,
|
||||
claimToken: String,
|
||||
command: PendingOrderCommand,
|
||||
idempotencyKey: String
|
||||
): RemoteOrderDryRun
|
||||
|
||||
suspend fun uploadExecutionOutboxItem(
|
||||
session: ProcurementSession,
|
||||
task: RemotePurchaseTask,
|
||||
@@ -291,6 +302,9 @@ class ProcurementApiClient(
|
||||
ExecutionOutboxType.EVENTS -> "/api/v1/tasks/${task.id}/events"
|
||||
ExecutionOutboxType.EVIDENCE -> "/api/v1/tasks/${task.id}/evidence"
|
||||
ExecutionOutboxType.CANDIDATES -> "/api/v1/tasks/${task.id}/candidates"
|
||||
ExecutionOutboxType.ORDER_DRY_RUN_READY ->
|
||||
"/api/v1/tasks/${task.id}/order-dry-runs/" +
|
||||
"${requireNotNull(taskOrderCommandId(item.payload))}/ready"
|
||||
ExecutionOutboxType.HUMAN_REVIEW ->
|
||||
"/api/v1/tasks/${task.id}/human-reviews"
|
||||
ExecutionOutboxType.COMPLETE -> "/api/v1/tasks/${task.id}/complete"
|
||||
@@ -309,7 +323,15 @@ class ProcurementApiClient(
|
||||
.header("X-Claim-Generation", task.claimGeneration.toString())
|
||||
.post(bytes.toRequestBody(PNG_MEDIA_TYPE))
|
||||
} else {
|
||||
val payload = item.payload
|
||||
val payload = if (
|
||||
item.type == ExecutionOutboxType.ORDER_DRY_RUN_READY
|
||||
) {
|
||||
JSONObject(item.payload)
|
||||
.apply { remove("command_id") }
|
||||
.toString()
|
||||
} else {
|
||||
item.payload
|
||||
}
|
||||
require(payload.toByteArray(Charsets.UTF_8).size <= MAX_JSON_BYTES) {
|
||||
"离线结果过大"
|
||||
}
|
||||
@@ -321,7 +343,18 @@ class ProcurementApiClient(
|
||||
json.getJSONObject("evidence").getString("id")
|
||||
} else {
|
||||
null
|
||||
}
|
||||
},
|
||||
evidenceSha256 = if (item.type == ExecutionOutboxType.EVIDENCE) {
|
||||
json.getJSONObject("evidence").getString("sha256")
|
||||
} else {
|
||||
null
|
||||
},
|
||||
orderDryRun =
|
||||
if (item.type == ExecutionOutboxType.ORDER_DRY_RUN_READY) {
|
||||
parseOrderDryRun(json.getJSONObject("dry_run"))
|
||||
} else {
|
||||
null
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -472,6 +505,33 @@ class ProcurementApiClient(
|
||||
)
|
||||
}
|
||||
|
||||
override suspend fun startOrderDryRun(
|
||||
session: ProcurementSession,
|
||||
task: RemotePurchaseTask,
|
||||
execution: RunningExecution,
|
||||
claimToken: String,
|
||||
command: PendingOrderCommand,
|
||||
idempotencyKey: String
|
||||
): RemoteOrderDryRun {
|
||||
val payload = JSONObject()
|
||||
.put("device_id", session.deviceId)
|
||||
.put("execution_id", execution.id)
|
||||
.put("claim_generation", task.claimGeneration)
|
||||
.put("command_id", command.id)
|
||||
.put("command_sha256", command.commandSha256)
|
||||
val json = executeJson(
|
||||
authorizedRequest(
|
||||
session,
|
||||
"/api/v1/tasks/${task.id}/order-dry-runs/start"
|
||||
)
|
||||
.header(CLAIM_TOKEN_HEADER, claimToken)
|
||||
.header(IDEMPOTENCY_HEADER, idempotencyKey)
|
||||
.post(payload.jsonBody())
|
||||
.build()
|
||||
)
|
||||
return parseOrderDryRun(json.getJSONObject("dry_run"))
|
||||
}
|
||||
|
||||
private suspend fun executeTransition(
|
||||
session: ProcurementSession,
|
||||
task: RemotePurchaseTask,
|
||||
@@ -630,6 +690,17 @@ class ProcurementApiClient(
|
||||
)
|
||||
}
|
||||
|
||||
private fun parseOrderDryRun(json: JSONObject): RemoteOrderDryRun =
|
||||
RemoteOrderDryRun(
|
||||
id = json.getString("id"),
|
||||
commandId = json.getString("command_id"),
|
||||
commandSha256 = json.getString("command_sha256"),
|
||||
status = json.getString("status")
|
||||
)
|
||||
|
||||
private fun taskOrderCommandId(payload: String): String? =
|
||||
runCatching { JSONObject(payload).getString("command_id") }.getOrNull()
|
||||
|
||||
private fun JSONObject.jsonBody() =
|
||||
toString().toRequestBody(JSON_MEDIA_TYPE)
|
||||
|
||||
|
||||
+21
@@ -8,11 +8,13 @@ import android.app.Service
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.IBinder
|
||||
import android.util.Log
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.roubao.autopilot.App
|
||||
import com.roubao.autopilot.MainActivity
|
||||
import com.roubao.autopilot.R
|
||||
import com.roubao.autopilot.readiness.DeviceReadinessChecker
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
@@ -36,12 +38,30 @@ class ProcurementExecutionService : Service() {
|
||||
if (heartbeatJob?.isActive != true) {
|
||||
heartbeatJob = scope.launch {
|
||||
val repository = (application as App).procurementRepository
|
||||
val readiness = DeviceReadinessChecker(applicationContext)
|
||||
val orderDryRun = OrderDryRunCoordinator(
|
||||
applicationContext,
|
||||
repository
|
||||
)
|
||||
while (isActive) {
|
||||
val decision = repository.synchronizeRunning()
|
||||
if (decision == ExecutionSyncDecision.STOP) {
|
||||
stopSelf()
|
||||
break
|
||||
}
|
||||
if (
|
||||
repository.shouldRunOrderDryRun &&
|
||||
readiness.snapshot().canStartProbe
|
||||
) {
|
||||
runCatching { orderDryRun.runOnce() }
|
||||
.onFailure { error ->
|
||||
Log.w(
|
||||
TAG,
|
||||
"Authorized order dry-run stopped safely",
|
||||
error
|
||||
)
|
||||
}
|
||||
}
|
||||
delay(HEARTBEAT_INTERVAL_MS)
|
||||
}
|
||||
}
|
||||
@@ -90,6 +110,7 @@ class ProcurementExecutionService : Service() {
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "ProcurementExecution"
|
||||
private const val CHANNEL_ID = "procurement_execution"
|
||||
private const val NOTIFICATION_ID = 206
|
||||
private const val HEARTBEAT_INTERVAL_MS = 30_000L
|
||||
|
||||
+61
-2
@@ -11,6 +11,8 @@ enum class ProcurementPhase {
|
||||
RUNNING,
|
||||
WAITING_ADMIN_CONFIRMATION,
|
||||
ORDER_AUTHORIZED,
|
||||
ORDER_DRY_RUN_RUNNING,
|
||||
ORDER_DRY_RUN_READY,
|
||||
AUTHORIZATION_EXPIRED
|
||||
}
|
||||
|
||||
@@ -87,7 +89,8 @@ data class PersistedProcurementState(
|
||||
val claim: ClaimContext? = null,
|
||||
val execution: RunningExecution? = null,
|
||||
val outbox: List<ExecutionOutboxItem> = emptyList(),
|
||||
val orderCommand: PendingOrderCommand? = null
|
||||
val orderCommand: PendingOrderCommand? = null,
|
||||
val orderDryRun: PendingOrderDryRun? = null
|
||||
)
|
||||
|
||||
data class OrderCommandCandidate(
|
||||
@@ -125,6 +128,59 @@ data class OrderCommandAcknowledgement(
|
||||
val replayed: Boolean
|
||||
)
|
||||
|
||||
enum class OrderDryRunStatus {
|
||||
INTENT_SAVED,
|
||||
PREPARING,
|
||||
READY_LOCAL,
|
||||
READY
|
||||
}
|
||||
|
||||
data class PendingOrderDryRun(
|
||||
val commandId: String,
|
||||
val commandSha256: String,
|
||||
val status: OrderDryRunStatus,
|
||||
val startIdempotencyKey: String,
|
||||
val readyIdempotencyKey: String,
|
||||
val remoteId: String? = null,
|
||||
val attemptCount: Int = 0,
|
||||
val lastAttemptAtEpochMillis: Long? = null,
|
||||
val cardSignature: String? = null,
|
||||
val detailSignature: String? = null,
|
||||
val observedTitle: String? = null,
|
||||
val selectedSku: String? = null,
|
||||
val quantity: Int? = null,
|
||||
val unitPriceCents: Long? = null,
|
||||
val totalPriceCents: Long? = null,
|
||||
val evidenceSha256: String? = null
|
||||
)
|
||||
|
||||
data class RemoteOrderDryRun(
|
||||
val id: String,
|
||||
val commandId: String,
|
||||
val commandSha256: String,
|
||||
val status: String
|
||||
)
|
||||
|
||||
data class OrderDryRunReadyDraft(
|
||||
val cardSignature: String,
|
||||
val detailSignature: String,
|
||||
val observedTitle: String,
|
||||
val selectedSku: String,
|
||||
val quantity: Int,
|
||||
val unitPriceCents: Long,
|
||||
val totalPriceCents: Long,
|
||||
val orderConfirmationPng: ByteArray,
|
||||
val evidenceSha256: String
|
||||
)
|
||||
|
||||
data class AuthorizedOrderDryRunContext(
|
||||
val task: RemotePurchaseTask,
|
||||
val command: PendingOrderCommand,
|
||||
val referenceImage: ReferenceImageRecord,
|
||||
val referenceImageBytes: ByteArray,
|
||||
val maxBudgetCents: Long?
|
||||
)
|
||||
|
||||
enum class ExecutionMode {
|
||||
MANUAL_FIRST,
|
||||
AI_ASSISTED
|
||||
@@ -143,6 +199,7 @@ enum class ExecutionOutboxType {
|
||||
EVENTS,
|
||||
EVIDENCE,
|
||||
CANDIDATES,
|
||||
ORDER_DRY_RUN_READY,
|
||||
HUMAN_REVIEW,
|
||||
COMPLETE,
|
||||
FAIL
|
||||
@@ -154,7 +211,8 @@ data class ExecutionOutboxItem(
|
||||
val idempotencyKey: String,
|
||||
val payload: String,
|
||||
val evidenceRelativePath: String? = null,
|
||||
val remoteResourceID: String? = null
|
||||
val remoteResourceID: String? = null,
|
||||
val remoteSHA256: String? = null
|
||||
)
|
||||
|
||||
data class ProcurementUiState(
|
||||
@@ -164,6 +222,7 @@ data class ProcurementUiState(
|
||||
val referenceImagePath: String? = null,
|
||||
val execution: RunningExecution? = null,
|
||||
val orderCommand: PendingOrderCommand? = null,
|
||||
val orderDryRun: PendingOrderDryRun? = null,
|
||||
val authenticationRequired: Boolean = false,
|
||||
val busy: Boolean = false,
|
||||
val backendOnline: Boolean? = null,
|
||||
|
||||
+336
-10
@@ -3,6 +3,7 @@ package com.roubao.autopilot.procurement
|
||||
import android.content.Context
|
||||
import android.graphics.BitmapFactory
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import com.roubao.autopilot.BuildConfig
|
||||
import com.roubao.autopilot.readiness.DeviceReadinessSnapshot
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
@@ -57,6 +58,20 @@ class ProcurementRepository(
|
||||
persisted.orderCommand == null
|
||||
}
|
||||
|
||||
val shouldRunOrderDryRun: Boolean
|
||||
get() {
|
||||
val execution = persisted.execution ?: return false
|
||||
val command = persisted.orderCommand ?: return false
|
||||
val dryRun = persisted.orderDryRun
|
||||
return storageFailure == null &&
|
||||
command.acknowledged &&
|
||||
!execution.safetyStopped &&
|
||||
!execution.isExpired() &&
|
||||
dryRun?.status != OrderDryRunStatus.READY &&
|
||||
dryRun?.status != OrderDryRunStatus.READY_LOCAL &&
|
||||
(dryRun?.attemptCount ?: 0) < MAX_ORDER_DRY_RUN_ATTEMPTS
|
||||
}
|
||||
|
||||
suspend fun login(input: LoginInput): Boolean = operation {
|
||||
require(input.password.isNotEmpty()) { "请输入采购员密码" }
|
||||
val normalizedUrl = BackendEndpointPolicy.normalize(
|
||||
@@ -387,6 +402,14 @@ class ProcurementRepository(
|
||||
}
|
||||
}
|
||||
} catch (error: Exception) {
|
||||
val diagnostic = if (error is ProcurementApiException) {
|
||||
"Procurement synchronization failed: " +
|
||||
"code=${error.code}, status=${error.statusCode}, " +
|
||||
"retryable=${error.retryable}"
|
||||
} else {
|
||||
"Procurement synchronization failed"
|
||||
}
|
||||
Log.w(LOG_TAG, diagnostic, error)
|
||||
val localFailure =
|
||||
error is IllegalArgumentException ||
|
||||
error is IllegalStateException
|
||||
@@ -428,6 +451,209 @@ class ProcurementRepository(
|
||||
?.readBytes()
|
||||
}
|
||||
|
||||
suspend fun prepareOrderDryRun(): AuthorizedOrderDryRunContext? = operation {
|
||||
val session = requireValidSession()
|
||||
val claim = requireNotNull(persisted.claim) { "没有可执行的采购任务" }
|
||||
val task = requireNotNull(claim.task) { "任务详情尚未下载" }
|
||||
val execution = requireActiveExecution()
|
||||
val command = requireNotNull(persisted.orderCommand) {
|
||||
"尚未收到后台下单命令"
|
||||
}
|
||||
require(command.acknowledged) { "下单命令尚未安全确认" }
|
||||
require(!execution.safetyStopped && !execution.isExpired()) {
|
||||
"执行授权已到期,不能继续订单核验"
|
||||
}
|
||||
require(command.taskId == task.id && command.executionId == execution.id) {
|
||||
"下单命令与当前任务不一致"
|
||||
}
|
||||
val reference = requireNotNull(claim.referenceImage) { "参考图缺失" }
|
||||
val referenceFile = File(appContext.filesDir, reference.relativePath)
|
||||
require(referenceFile.isFile && referenceFile.length() == reference.sizeBytes) {
|
||||
"参考图文件缺失"
|
||||
}
|
||||
val referenceBytes = referenceFile.readBytes()
|
||||
require(sha256(referenceBytes) == reference.sha256) { "参考图哈希不一致" }
|
||||
|
||||
var dryRun = persisted.orderDryRun
|
||||
if (dryRun == null) {
|
||||
dryRun = PendingOrderDryRun(
|
||||
commandId = command.id,
|
||||
commandSha256 = command.commandSha256,
|
||||
status = OrderDryRunStatus.INTENT_SAVED,
|
||||
startIdempotencyKey = newOpaqueSecret(),
|
||||
readyIdempotencyKey = newOpaqueSecret()
|
||||
)
|
||||
persisted = persisted.copy(orderDryRun = dryRun)
|
||||
store.save(persisted)
|
||||
}
|
||||
require(
|
||||
dryRun.commandId == command.id &&
|
||||
dryRun.commandSha256 == command.commandSha256
|
||||
) { "本地下单预演与后台命令不一致" }
|
||||
require(dryRun.status != OrderDryRunStatus.READY) { "订单已完成核验" }
|
||||
require(dryRun.status != OrderDryRunStatus.READY_LOCAL) {
|
||||
"订单核验证据正在回传"
|
||||
}
|
||||
require(dryRun.attemptCount < MAX_ORDER_DRY_RUN_ATTEMPTS) {
|
||||
"订单核验已达到最大尝试次数"
|
||||
}
|
||||
|
||||
dryRun = dryRun.copy(
|
||||
attemptCount = dryRun.attemptCount + 1,
|
||||
lastAttemptAtEpochMillis = System.currentTimeMillis()
|
||||
)
|
||||
persisted = persisted.copy(orderDryRun = dryRun)
|
||||
store.save(persisted)
|
||||
|
||||
val remote = api.startOrderDryRun(
|
||||
session = session,
|
||||
task = task,
|
||||
execution = execution,
|
||||
claimToken = claim.token,
|
||||
command = command,
|
||||
idempotencyKey = dryRun.startIdempotencyKey
|
||||
)
|
||||
require(
|
||||
remote.commandId == command.id &&
|
||||
remote.commandSha256 == command.commandSha256 &&
|
||||
remote.status in setOf("PREPARING", "READY")
|
||||
) { "后台下单预演状态无效" }
|
||||
dryRun = dryRun.copy(
|
||||
status = if (remote.status == "READY") {
|
||||
OrderDryRunStatus.READY
|
||||
} else {
|
||||
OrderDryRunStatus.PREPARING
|
||||
},
|
||||
remoteId = remote.id
|
||||
)
|
||||
persisted = persisted.copy(
|
||||
execution = execution.copy(currentStep = ORDER_DRY_RUN_STEP),
|
||||
orderDryRun = dryRun
|
||||
)
|
||||
saveAndPublish(
|
||||
backendOnline = true,
|
||||
message = if (remote.status == "READY") {
|
||||
"订单已完成核验"
|
||||
} else {
|
||||
"正在重新定位已授权商品"
|
||||
}
|
||||
)
|
||||
if (remote.status == "READY") {
|
||||
return@operation null
|
||||
}
|
||||
AuthorizedOrderDryRunContext(
|
||||
task = task,
|
||||
command = command,
|
||||
referenceImage = reference,
|
||||
referenceImageBytes = referenceBytes,
|
||||
maxBudgetCents = parseBudgetCents(task.maxBudget)
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun queueOrderDryRunReady(
|
||||
draft: OrderDryRunReadyDraft
|
||||
): Boolean = operation {
|
||||
val task = requireCurrentTask()
|
||||
val execution = requireActiveExecution()
|
||||
val command = requireNotNull(persisted.orderCommand) {
|
||||
"尚未收到后台下单命令"
|
||||
}
|
||||
val dryRun = requireNotNull(persisted.orderDryRun) {
|
||||
"下单预演尚未开始"
|
||||
}
|
||||
require(dryRun.status == OrderDryRunStatus.PREPARING) {
|
||||
"下单预演状态不能提交核验证据"
|
||||
}
|
||||
require(!execution.safetyStopped && !execution.isExpired()) {
|
||||
"执行授权已到期,不能提交订单核验"
|
||||
}
|
||||
require(
|
||||
draft.cardSignature == command.candidate.cardSignature ||
|
||||
draft.detailSignature == command.candidate.detailSignature
|
||||
) { "当前商品与后台授权商品的语义指纹不一致" }
|
||||
require(
|
||||
normalizeTitle(draft.observedTitle) ==
|
||||
normalizeTitle(command.candidate.title)
|
||||
) { "当前商品标题与后台授权商品不一致" }
|
||||
require(draft.selectedSku.trim() == command.originalSku.trim()) {
|
||||
"当前规格与后台授权规格不一致"
|
||||
}
|
||||
require(draft.quantity == command.quantity && draft.quantity in 1..99) {
|
||||
"当前数量与后台授权数量不一致"
|
||||
}
|
||||
require(
|
||||
draft.unitPriceCents > 0 &&
|
||||
draft.totalPriceCents ==
|
||||
Math.multiplyExact(draft.unitPriceCents, draft.quantity.toLong())
|
||||
) { "当前价格或总额无效" }
|
||||
parseBudgetCents(task.maxBudget)?.let { budgetCents ->
|
||||
require(draft.totalPriceCents <= budgetCents) {
|
||||
"当前总额超过任务预算"
|
||||
}
|
||||
}
|
||||
require(
|
||||
draft.orderConfirmationPng.isNotEmpty() &&
|
||||
sha256(draft.orderConfirmationPng) == draft.evidenceSha256
|
||||
) { "订单确认页截图证据无效" }
|
||||
require(persisted.outbox.none {
|
||||
it.type == ExecutionOutboxType.ORDER_DRY_RUN_READY
|
||||
}) { "订单核验证据已在回传队列" }
|
||||
|
||||
val localEvidenceID = UUID.randomUUID().toString()
|
||||
persistEvidenceLocked(localEvidenceID, draft.orderConfirmationPng)
|
||||
appendOutboxLocked(
|
||||
ExecutionOutboxItem(
|
||||
id = localEvidenceID,
|
||||
type = ExecutionOutboxType.EVIDENCE,
|
||||
idempotencyKey = newOpaqueSecret(),
|
||||
payload = "{}",
|
||||
evidenceRelativePath = "$OUTBOX_DIRECTORY/$localEvidenceID.png"
|
||||
)
|
||||
)
|
||||
val readyPayload = JSONObject()
|
||||
.put("device_id", requireNotNull(persisted.session).deviceId)
|
||||
.put("execution_id", execution.id)
|
||||
.put("claim_generation", task.claimGeneration)
|
||||
.put("command_id", command.id)
|
||||
.put("command_sha256", command.commandSha256)
|
||||
.put("card_signature", draft.cardSignature)
|
||||
.put("detail_signature", draft.detailSignature)
|
||||
.put("observed_title", draft.observedTitle.trim())
|
||||
.put("selected_sku", draft.selectedSku.trim())
|
||||
.put("quantity", draft.quantity)
|
||||
.put("unit_price_cents", draft.unitPriceCents)
|
||||
.put("total_price_cents", draft.totalPriceCents)
|
||||
.put("evidence_asset_id", localEvidenceID)
|
||||
.put("evidence_sha256", "sha256:$localEvidenceID")
|
||||
appendOutboxLocked(
|
||||
ExecutionOutboxItem(
|
||||
id = UUID.randomUUID().toString(),
|
||||
type = ExecutionOutboxType.ORDER_DRY_RUN_READY,
|
||||
idempotencyKey = dryRun.readyIdempotencyKey,
|
||||
payload = readyPayload.toString()
|
||||
)
|
||||
)
|
||||
persisted = persisted.copy(
|
||||
execution = execution.copy(currentStep = ORDER_DRY_RUN_STEP),
|
||||
orderDryRun = dryRun.copy(
|
||||
status = OrderDryRunStatus.READY_LOCAL,
|
||||
cardSignature = draft.cardSignature,
|
||||
detailSignature = draft.detailSignature,
|
||||
observedTitle = draft.observedTitle.trim(),
|
||||
selectedSku = draft.selectedSku.trim(),
|
||||
quantity = draft.quantity,
|
||||
unitPriceCents = draft.unitPriceCents,
|
||||
totalPriceCents = draft.totalPriceCents,
|
||||
evidenceSha256 = draft.evidenceSha256
|
||||
)
|
||||
)
|
||||
saveAndPublish(
|
||||
backendOnline = _uiState.value.backendOnline,
|
||||
message = "订单核验证据已加入加密回传队列"
|
||||
)
|
||||
true
|
||||
} ?: false
|
||||
|
||||
suspend fun queueCandidateBatch(
|
||||
batch: ExecutionCandidateBatchDraft,
|
||||
evidence: List<ExecutionEvidenceDraft>
|
||||
@@ -776,7 +1002,8 @@ class ProcurementRepository(
|
||||
claim = null,
|
||||
execution = null,
|
||||
outbox = emptyList(),
|
||||
orderCommand = null
|
||||
orderCommand = null,
|
||||
orderDryRun = null
|
||||
)
|
||||
}
|
||||
|
||||
@@ -795,7 +1022,8 @@ class ProcurementRepository(
|
||||
resolvedPayload = replaceEvidenceReference(
|
||||
resolvedPayload,
|
||||
knownEvidence.id,
|
||||
knownEvidence.remoteResourceID
|
||||
knownEvidence.remoteResourceID,
|
||||
knownEvidence.remoteSHA256
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -899,28 +1127,55 @@ class ProcurementRepository(
|
||||
}
|
||||
file.readBytes()
|
||||
}
|
||||
val uploadItem = if (item.type == ExecutionOutboxType.EVIDENCE) {
|
||||
item
|
||||
} else {
|
||||
persisted.outbox
|
||||
.filter {
|
||||
it.type == ExecutionOutboxType.EVIDENCE &&
|
||||
it.remoteResourceID != null &&
|
||||
it.remoteSHA256 != null
|
||||
}
|
||||
.fold(item) { resolved, evidence ->
|
||||
resolved.copy(
|
||||
payload = replaceEvidenceReference(
|
||||
resolved.payload,
|
||||
evidence.id,
|
||||
requireNotNull(evidence.remoteResourceID),
|
||||
evidence.remoteSHA256
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
val uploaded = api.uploadExecutionOutboxItem(
|
||||
session = session,
|
||||
task = task,
|
||||
execution = execution,
|
||||
claimToken = requireNotNull(persisted.claim).token,
|
||||
item = item,
|
||||
item = uploadItem,
|
||||
evidenceBytes = evidenceBytes
|
||||
)
|
||||
if (item.type == ExecutionOutboxType.EVIDENCE) {
|
||||
val evidenceID = requireNotNull(uploaded.evidenceAssetId) {
|
||||
"后台未返回证据编号"
|
||||
}
|
||||
val evidenceSHA256 = requireNotNull(uploaded.evidenceSha256) {
|
||||
"后台未返回证据哈希"
|
||||
}
|
||||
persisted = persisted.copy(
|
||||
outbox = persisted.outbox.map { pending ->
|
||||
if (pending.id == item.id) {
|
||||
pending.copy(remoteResourceID = evidenceID)
|
||||
pending.copy(
|
||||
remoteResourceID = evidenceID,
|
||||
remoteSHA256 = evidenceSHA256
|
||||
)
|
||||
} else {
|
||||
pending.copy(
|
||||
payload = replaceEvidenceReference(
|
||||
pending.payload,
|
||||
item.id,
|
||||
evidenceID
|
||||
evidenceID,
|
||||
evidenceSHA256
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -941,6 +1196,26 @@ class ProcurementRepository(
|
||||
store.save(persisted)
|
||||
return true
|
||||
}
|
||||
if (item.type == ExecutionOutboxType.ORDER_DRY_RUN_READY) {
|
||||
val remote = requireNotNull(uploaded.orderDryRun) {
|
||||
"后台未返回订单核验状态"
|
||||
}
|
||||
val command = requireNotNull(persisted.orderCommand)
|
||||
require(
|
||||
remote.commandId == command.id &&
|
||||
remote.commandSha256 == command.commandSha256 &&
|
||||
remote.status == "READY"
|
||||
) { "后台订单核验状态无效" }
|
||||
persisted = persisted.copy(
|
||||
execution = execution.copy(
|
||||
currentStep = ORDER_DRY_RUN_READY_STEP
|
||||
),
|
||||
orderDryRun = requireNotNull(persisted.orderDryRun).copy(
|
||||
status = OrderDryRunStatus.READY,
|
||||
remoteId = remote.id
|
||||
)
|
||||
)
|
||||
}
|
||||
store.save(persisted)
|
||||
}
|
||||
}
|
||||
@@ -948,14 +1223,37 @@ class ProcurementRepository(
|
||||
private fun replaceEvidenceReference(
|
||||
payload: String,
|
||||
localID: String,
|
||||
remoteID: String
|
||||
remoteID: String,
|
||||
remoteSHA256: String?
|
||||
): String {
|
||||
val root = JSONObject(payload)
|
||||
fun replace(value: Any?) {
|
||||
when (value) {
|
||||
is JSONObject -> {
|
||||
val keys = value.keys().asSequence().toList()
|
||||
keys.forEach { key -> replace(value.opt(key)) }
|
||||
keys.forEach { key ->
|
||||
when (value.optString(key)) {
|
||||
localID -> value.put(key, remoteID)
|
||||
"sha256:$localID" -> {
|
||||
if (remoteSHA256 != null) {
|
||||
value.put(key, remoteSHA256)
|
||||
}
|
||||
}
|
||||
else -> replace(value.opt(key))
|
||||
}
|
||||
}
|
||||
value.optJSONArray("evidence_asset_ids")?.let { ids ->
|
||||
when {
|
||||
ids.optString(0) == remoteID && remoteSHA256 != null ->
|
||||
value.put("detail_evidence_sha256", remoteSHA256)
|
||||
ids.optString(1) == remoteID && remoteSHA256 != null ->
|
||||
value.put(
|
||||
"specification_evidence_sha256",
|
||||
remoteSHA256
|
||||
)
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
}
|
||||
is JSONArray -> {
|
||||
for (index in 0 until value.length()) {
|
||||
@@ -973,7 +1271,11 @@ class ProcurementRepository(
|
||||
}
|
||||
|
||||
private fun candidateJSON(candidate: ExecutionCandidateDraft): JSONObject =
|
||||
JSONObject()
|
||||
JSONObject().also {
|
||||
require(candidate.evidenceLocalIDs.size == 2) {
|
||||
"候选截图关联无效"
|
||||
}
|
||||
}
|
||||
.put("ordinal", candidate.ordinal)
|
||||
.put("title", candidate.title)
|
||||
.put("sku_text", candidate.skuText)
|
||||
@@ -984,11 +1286,11 @@ class ProcurementRepository(
|
||||
.put("detail_signature", candidate.detailSignature)
|
||||
.put(
|
||||
"detail_evidence_sha256",
|
||||
candidate.detailEvidenceSha256
|
||||
"sha256:${candidate.evidenceLocalIDs[0]}"
|
||||
)
|
||||
.put(
|
||||
"specification_evidence_sha256",
|
||||
candidate.specificationEvidenceSha256
|
||||
"sha256:${candidate.evidenceLocalIDs[1]}"
|
||||
)
|
||||
.put("evidence_asset_ids", JSONArray(candidate.evidenceLocalIDs))
|
||||
.also { json ->
|
||||
@@ -1099,6 +1401,10 @@ class ProcurementRepository(
|
||||
execution != null &&
|
||||
(execution.safetyStopped || execution.isExpired()) ->
|
||||
ProcurementPhase.AUTHORIZATION_EXPIRED
|
||||
persisted.orderDryRun?.status == OrderDryRunStatus.READY ->
|
||||
ProcurementPhase.ORDER_DRY_RUN_READY
|
||||
persisted.orderDryRun != null ->
|
||||
ProcurementPhase.ORDER_DRY_RUN_RUNNING
|
||||
persisted.orderCommand?.acknowledged == true ->
|
||||
ProcurementPhase.ORDER_AUTHORIZED
|
||||
execution?.currentStep == WAITING_ADMIN_CONFIRMATION_STEP ->
|
||||
@@ -1125,6 +1431,7 @@ class ProcurementRepository(
|
||||
referenceImagePath = imagePath,
|
||||
execution = execution,
|
||||
orderCommand = persisted.orderCommand,
|
||||
orderDryRun = persisted.orderDryRun,
|
||||
authenticationRequired = session?.isValid() != true
|
||||
)
|
||||
}
|
||||
@@ -1158,18 +1465,37 @@ class ProcurementRepository(
|
||||
return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes)
|
||||
}
|
||||
|
||||
private fun parseBudgetCents(value: String?): Long? {
|
||||
val normalized = value?.trim()?.takeIf(String::isNotEmpty) ?: return null
|
||||
require(BUDGET_PATTERN.matches(normalized)) { "任务预算格式无效" }
|
||||
val parts = normalized.split('.', limit = 2)
|
||||
val whole = parts[0].toLong()
|
||||
val fraction = parts.getOrNull(1)?.padEnd(2, '0')?.toLong() ?: 0L
|
||||
return Math.addExact(Math.multiplyExact(whole, 100L), fraction)
|
||||
}
|
||||
|
||||
private fun normalizeTitle(value: String): String =
|
||||
value.trim().lowercase().replace(TITLE_SPACE_PATTERN, "")
|
||||
|
||||
companion object {
|
||||
private const val LOG_TAG = "ProcurementRepository"
|
||||
private const val REFERENCE_DIRECTORY = "procurement"
|
||||
private const val OUTBOX_DIRECTORY = "procurement-outbox"
|
||||
private const val CONTROLLED_WORKFLOW_STEP = "CONTROLLED_WORKFLOW"
|
||||
private const val WAITING_ADMIN_CONFIRMATION_STEP =
|
||||
"WAITING_ADMIN_CONFIRMATION"
|
||||
private const val ORDER_AUTHORIZED_STEP = "ORDER_AUTHORIZED"
|
||||
private const val ORDER_DRY_RUN_STEP = "ORDER_DRY_RUN"
|
||||
private const val ORDER_DRY_RUN_READY_STEP = "ORDER_DRY_RUN_READY"
|
||||
private const val SAFE_STOPPED_STEP = "SAFE_STOPPED"
|
||||
private const val MAX_ORDER_DRY_RUN_ATTEMPTS = 5
|
||||
private const val MAX_REFERENCE_DIMENSION = 4_096
|
||||
private const val MAX_REFERENCE_PIXELS = 20_000_000L
|
||||
private const val MAX_OUTBOX_EVIDENCE_BYTES = 8L * 1024L * 1024L
|
||||
private val SHA256_PATTERN = Regex("^[0-9a-f]{64}$")
|
||||
private val BUDGET_PATTERN =
|
||||
Regex("^(0|[1-9][0-9]{0,9})(\\.[0-9]{1,2})?$")
|
||||
private val TITLE_SPACE_PATTERN = Regex("[\\s\\p{Punct}]+")
|
||||
private val COMPLETE_OUTCOMES = setOf(
|
||||
"CANDIDATE_ACCEPTED",
|
||||
"CANDIDATE_REJECTED",
|
||||
|
||||
+65
-1
@@ -129,6 +129,7 @@ class ProcurementSecureStore(context: Context) : ProcurementStateStore {
|
||||
"remote_resource_id",
|
||||
item.remoteResourceID
|
||||
)
|
||||
putNullable("remote_sha256", item.remoteSHA256)
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -137,6 +138,9 @@ class ProcurementSecureStore(context: Context) : ProcurementStateStore {
|
||||
state.orderCommand?.let { command ->
|
||||
put("order_command", encodeOrderCommand(command))
|
||||
}
|
||||
state.orderDryRun?.let { dryRun ->
|
||||
put("order_dry_run", encodeOrderDryRun(dryRun))
|
||||
}
|
||||
}
|
||||
|
||||
private fun decodeState(json: JSONObject): PersistedProcurementState =
|
||||
@@ -213,7 +217,9 @@ class ProcurementSecureStore(context: Context) : ProcurementStateStore {
|
||||
evidenceRelativePath =
|
||||
item.optionalString("evidence_relative_path"),
|
||||
remoteResourceID =
|
||||
item.optionalString("remote_resource_id")
|
||||
item.optionalString("remote_resource_id"),
|
||||
remoteSHA256 =
|
||||
item.optionalString("remote_sha256")
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -221,9 +227,67 @@ class ProcurementSecureStore(context: Context) : ProcurementStateStore {
|
||||
} ?: emptyList(),
|
||||
orderCommand = json.optionalObject("order_command")?.let {
|
||||
decodeOrderCommand(it)
|
||||
},
|
||||
orderDryRun = json.optionalObject("order_dry_run")?.let {
|
||||
decodeOrderDryRun(it)
|
||||
}
|
||||
)
|
||||
|
||||
private fun encodeOrderDryRun(dryRun: PendingOrderDryRun): JSONObject =
|
||||
JSONObject().apply {
|
||||
put("command_id", dryRun.commandId)
|
||||
put("command_sha256", dryRun.commandSha256)
|
||||
put("status", dryRun.status.name)
|
||||
put("start_idempotency_key", dryRun.startIdempotencyKey)
|
||||
put("ready_idempotency_key", dryRun.readyIdempotencyKey)
|
||||
putNullable("remote_id", dryRun.remoteId)
|
||||
put("attempt_count", dryRun.attemptCount)
|
||||
dryRun.lastAttemptAtEpochMillis?.let {
|
||||
put("last_attempt_at_ms", it)
|
||||
}
|
||||
putNullable("card_signature", dryRun.cardSignature)
|
||||
putNullable("detail_signature", dryRun.detailSignature)
|
||||
putNullable("observed_title", dryRun.observedTitle)
|
||||
putNullable("selected_sku", dryRun.selectedSku)
|
||||
dryRun.quantity?.let { put("quantity", it) }
|
||||
dryRun.unitPriceCents?.let { put("unit_price_cents", it) }
|
||||
dryRun.totalPriceCents?.let { put("total_price_cents", it) }
|
||||
putNullable("evidence_sha256", dryRun.evidenceSha256)
|
||||
}
|
||||
|
||||
private fun decodeOrderDryRun(json: JSONObject): PendingOrderDryRun =
|
||||
PendingOrderDryRun(
|
||||
commandId = json.getString("command_id"),
|
||||
commandSha256 = json.getString("command_sha256"),
|
||||
status = OrderDryRunStatus.valueOf(json.getString("status")),
|
||||
startIdempotencyKey = json.getString("start_idempotency_key"),
|
||||
readyIdempotencyKey = json.getString("ready_idempotency_key"),
|
||||
remoteId = json.optionalString("remote_id"),
|
||||
attemptCount = json.optInt("attempt_count", 0),
|
||||
lastAttemptAtEpochMillis =
|
||||
if (json.has("last_attempt_at_ms")) {
|
||||
json.getLong("last_attempt_at_ms")
|
||||
} else {
|
||||
null
|
||||
},
|
||||
cardSignature = json.optionalString("card_signature"),
|
||||
detailSignature = json.optionalString("detail_signature"),
|
||||
observedTitle = json.optionalString("observed_title"),
|
||||
selectedSku = json.optionalString("selected_sku"),
|
||||
quantity = if (json.has("quantity")) json.getInt("quantity") else null,
|
||||
unitPriceCents = if (json.has("unit_price_cents")) {
|
||||
json.getLong("unit_price_cents")
|
||||
} else {
|
||||
null
|
||||
},
|
||||
totalPriceCents = if (json.has("total_price_cents")) {
|
||||
json.getLong("total_price_cents")
|
||||
} else {
|
||||
null
|
||||
},
|
||||
evidenceSha256 = json.optionalString("evidence_sha256")
|
||||
)
|
||||
|
||||
private fun encodeOrderCommand(command: PendingOrderCommand): JSONObject =
|
||||
JSONObject().apply {
|
||||
put("id", command.id)
|
||||
|
||||
+22
-3
@@ -198,6 +198,8 @@ fun ProcurementScreen(
|
||||
ProcurementPhase.RUNNING,
|
||||
ProcurementPhase.WAITING_ADMIN_CONFIRMATION,
|
||||
ProcurementPhase.ORDER_AUTHORIZED,
|
||||
ProcurementPhase.ORDER_DRY_RUN_RUNNING,
|
||||
ProcurementPhase.ORDER_DRY_RUN_READY,
|
||||
ProcurementPhase.AUTHORIZATION_EXPIRED -> {
|
||||
if (state.authenticationRequired) {
|
||||
item(key = "procurement-login") {
|
||||
@@ -460,6 +462,12 @@ private fun ExecutionDetails(state: ProcurementUiState) {
|
||||
DetailRow("目标数量", command.quantity.toString())
|
||||
DetailRow("候选指纹", command.candidate.candidateKey)
|
||||
}
|
||||
state.orderDryRun?.let { dryRun ->
|
||||
DetailRow("订单核验", dryRun.status.name)
|
||||
dryRun.totalPriceCents?.let { cents ->
|
||||
DetailRow("核验总额", "¥%.2f".format(cents / 100.0))
|
||||
}
|
||||
}
|
||||
DetailRow(
|
||||
"后台连接",
|
||||
when (state.backendOnline) {
|
||||
@@ -484,11 +492,18 @@ private fun ExecutionDetails(state: ProcurementUiState) {
|
||||
ProcurementPhase.WAITING_ADMIN_CONFIRMATION ->
|
||||
"等待后台采购员确认商品"
|
||||
ProcurementPhase.ORDER_AUTHORIZED ->
|
||||
"商品授权已安全保存;本版本尚不执行下单"
|
||||
"商品授权已安全保存,等待自动核验"
|
||||
ProcurementPhase.ORDER_DRY_RUN_RUNNING ->
|
||||
"正在重新定位商品并核验规格、数量和金额"
|
||||
ProcurementPhase.ORDER_DRY_RUN_READY ->
|
||||
"订单已核验,等待单次提交"
|
||||
else -> "订单提交保持禁用"
|
||||
},
|
||||
color = if (
|
||||
state.phase == ProcurementPhase.ORDER_AUTHORIZED
|
||||
state.phase in setOf(
|
||||
ProcurementPhase.ORDER_AUTHORIZED,
|
||||
ProcurementPhase.ORDER_DRY_RUN_READY
|
||||
)
|
||||
) {
|
||||
colors.success
|
||||
} else {
|
||||
@@ -528,7 +543,9 @@ private fun phaseColor(state: ProcurementUiState) =
|
||||
ProcurementPhase.AUTHORIZATION_EXPIRED -> BaoziTheme.colors.error
|
||||
ProcurementPhase.RUNNING,
|
||||
ProcurementPhase.WAITING_ADMIN_CONFIRMATION,
|
||||
ProcurementPhase.ORDER_AUTHORIZED -> BaoziTheme.colors.success
|
||||
ProcurementPhase.ORDER_AUTHORIZED,
|
||||
ProcurementPhase.ORDER_DRY_RUN_RUNNING,
|
||||
ProcurementPhase.ORDER_DRY_RUN_READY -> BaoziTheme.colors.success
|
||||
else -> BaoziTheme.colors.textSecondary
|
||||
}
|
||||
|
||||
@@ -540,5 +557,7 @@ private fun phaseLabel(state: ProcurementUiState): String =
|
||||
ProcurementPhase.RUNNING -> "受控流程运行中"
|
||||
ProcurementPhase.WAITING_ADMIN_CONFIRMATION -> "等待后台确认商品"
|
||||
ProcurementPhase.ORDER_AUTHORIZED -> "后台商品授权已同步"
|
||||
ProcurementPhase.ORDER_DRY_RUN_RUNNING -> "正在核验订单"
|
||||
ProcurementPhase.ORDER_DRY_RUN_READY -> "订单已核验,等待提交"
|
||||
ProcurementPhase.AUTHORIZATION_EXPIRED -> "授权到期,已停止"
|
||||
}
|
||||
|
||||
+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)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `dismisses camera retry dialog and reopens the prepared image`() =
|
||||
runTest {
|
||||
val driver = FakeImageDriver(
|
||||
page = PinduoduoPage.SEARCH_RESULTS,
|
||||
retryDialogOnce = true
|
||||
)
|
||||
val runner = WorkflowRunner(
|
||||
PinduoduoImageSearchAutomation(
|
||||
driver = driver,
|
||||
pollIntervalMillis = 1,
|
||||
unknownPageLimit = 3
|
||||
)
|
||||
)
|
||||
|
||||
val report = runner.run(PinduoduoImageSearchWorkflow.steps())
|
||||
|
||||
assertEquals(WorkflowState.SUCCEEDED, report.state)
|
||||
assertEquals(1, driver.dismissRetryDialogCalls)
|
||||
assertTrue(driver.preparedImageSelected)
|
||||
assertEquals(PinduoduoPage.IMAGE_SEARCH_RESULTS, driver.page)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `recovers when app opens directly into the camera retry dialog`() =
|
||||
runTest {
|
||||
val driver = FakeImageDriver(
|
||||
page = PinduoduoPage.IMAGE_SEARCH_RETRY_DIALOG
|
||||
)
|
||||
val runner = WorkflowRunner(
|
||||
PinduoduoImageSearchAutomation(
|
||||
driver = driver,
|
||||
pollIntervalMillis = 1,
|
||||
unknownPageLimit = 3
|
||||
)
|
||||
)
|
||||
|
||||
val report = runner.run(PinduoduoImageSearchWorkflow.steps())
|
||||
|
||||
assertEquals(WorkflowState.SUCCEEDED, report.state)
|
||||
assertEquals(1, driver.dismissRetryDialogCalls)
|
||||
assertTrue(driver.preparedImageSelected)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `recovers from specification panel left by candidate collection`() =
|
||||
runTest {
|
||||
val driver = FakeImageDriver(PinduoduoPage.SPECIFICATION_PANEL)
|
||||
val runner = WorkflowRunner(
|
||||
PinduoduoImageSearchAutomation(
|
||||
driver = driver,
|
||||
pollIntervalMillis = 1,
|
||||
unknownPageLimit = 3
|
||||
)
|
||||
)
|
||||
|
||||
val report = runner.run(PinduoduoImageSearchWorkflow.steps())
|
||||
|
||||
assertEquals(WorkflowState.SUCCEEDED, report.state)
|
||||
assertEquals(1, driver.closeSpecificationsCalls)
|
||||
assertTrue(driver.preparedImageSelected)
|
||||
assertEquals(PinduoduoPage.IMAGE_SEARCH_RESULTS, driver.page)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `backs out of order confirmation before a retry search`() = runTest {
|
||||
val driver = FakeImageDriver(
|
||||
PinduoduoPage.ORDER_CONFIRMATION,
|
||||
safetyStopReason = SafetyStopReason.PAYMENT_BOUNDARY
|
||||
)
|
||||
val runner = WorkflowRunner(
|
||||
PinduoduoImageSearchAutomation(
|
||||
driver = driver,
|
||||
pollIntervalMillis = 1,
|
||||
unknownPageLimit = 3
|
||||
)
|
||||
)
|
||||
|
||||
val report = runner.run(PinduoduoImageSearchWorkflow.steps())
|
||||
|
||||
assertEquals(WorkflowState.SUCCEEDED, report.state)
|
||||
assertEquals(1, driver.returnFromOrderConfirmationCalls)
|
||||
assertTrue(driver.preparedImageSelected)
|
||||
assertEquals(PinduoduoPage.IMAGE_SEARCH_RESULTS, driver.page)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `payment marker stops before image entry`() = runTest {
|
||||
val driver = FakeImageDriver(
|
||||
@@ -99,12 +185,18 @@ class PinduoduoImageSearchAutomationTest {
|
||||
var page: PinduoduoPage,
|
||||
private val selectionAllowed: Boolean = true,
|
||||
private val safetyStopReason: SafetyStopReason? = null,
|
||||
private val cameraResultRaceOnce: Boolean = false
|
||||
private val cameraResultRaceOnce: Boolean = false,
|
||||
private val retryDialogOnce: Boolean = false
|
||||
) : PinduoduoImageSearchDriver {
|
||||
var imageSearchOpened = false
|
||||
var preparedImageSelected = false
|
||||
var closeSpecificationsCalls = 0
|
||||
var returnFromOrderConfirmationCalls = 0
|
||||
var returnFromImageResultsCalls = 0
|
||||
var dismissRetryDialogCalls = 0
|
||||
private var currentSafetyStopReason = safetyStopReason
|
||||
private var cameraResultRaceConsumed = false
|
||||
private var retryDialogConsumed = false
|
||||
|
||||
override suspend fun openApp(): Boolean = true
|
||||
|
||||
@@ -112,7 +204,7 @@ class PinduoduoImageSearchAutomationTest {
|
||||
PinduoduoUiSnapshot(
|
||||
foregroundPackage = PINDUODUO_PACKAGE,
|
||||
page = page,
|
||||
safetyStopReason = safetyStopReason
|
||||
safetyStopReason = currentSafetyStopReason
|
||||
)
|
||||
|
||||
override suspend fun openImageSearch(): Boolean {
|
||||
@@ -120,12 +212,21 @@ class PinduoduoImageSearchAutomationTest {
|
||||
page = if (cameraResultRaceOnce && !cameraResultRaceConsumed) {
|
||||
cameraResultRaceConsumed = true
|
||||
PinduoduoPage.IMAGE_SEARCH_RESULTS
|
||||
} else if (retryDialogOnce && !retryDialogConsumed) {
|
||||
retryDialogConsumed = true
|
||||
PinduoduoPage.IMAGE_SEARCH_RETRY_DIALOG
|
||||
} else {
|
||||
PinduoduoPage.IMAGE_SEARCH
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
override suspend fun dismissImageSearchRetryDialog(): Boolean {
|
||||
dismissRetryDialogCalls += 1
|
||||
page = PinduoduoPage.HOME
|
||||
return true
|
||||
}
|
||||
|
||||
override suspend fun selectPreparedImage(): Boolean {
|
||||
if (!selectionAllowed) {
|
||||
return false
|
||||
@@ -135,6 +236,19 @@ class PinduoduoImageSearchAutomationTest {
|
||||
return true
|
||||
}
|
||||
|
||||
override suspend fun closeSpecifications(): Boolean {
|
||||
closeSpecificationsCalls += 1
|
||||
page = PinduoduoPage.PRODUCT_DETAIL
|
||||
return true
|
||||
}
|
||||
|
||||
override suspend fun returnFromOrderConfirmation(): Boolean {
|
||||
returnFromOrderConfirmationCalls += 1
|
||||
page = PinduoduoPage.SPECIFICATION_PANEL
|
||||
currentSafetyStopReason = null
|
||||
return true
|
||||
}
|
||||
|
||||
override suspend fun returnFromCandidate(): Boolean {
|
||||
page = PinduoduoPage.IMAGE_SEARCH_RESULTS
|
||||
return true
|
||||
|
||||
+20
@@ -32,4 +32,24 @@ class PinduoduoObservedTitlePolicyTest {
|
||||
|
||||
assertNull(title)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `stable identity ignores volatile price and sales text`() {
|
||||
val first = PinduoduoStableProductIdentityPolicy.signature(
|
||||
listOf(
|
||||
"限时 ¥19.90",
|
||||
"已拼2.1万件",
|
||||
"法式小香风V领短袖钉珠女2026夏季新款"
|
||||
)
|
||||
)
|
||||
val refreshed = PinduoduoStableProductIdentityPolicy.signature(
|
||||
listOf(
|
||||
"限时 ¥21.00",
|
||||
"已拼2.2万件",
|
||||
"法式小香风V领短袖钉珠女2026夏季新款"
|
||||
)
|
||||
)
|
||||
|
||||
assertEquals(first, refreshed)
|
||||
}
|
||||
}
|
||||
|
||||
+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)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `camera retry dialog is a recoverable image search page`() {
|
||||
val snapshot = classify(
|
||||
element(text = "请对准商品或码,保持手机稳定"),
|
||||
element(text = "取消", clickable = true),
|
||||
element(text = "再试一次", clickable = true)
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
PinduoduoPage.IMAGE_SEARCH_RETRY_DIALOG,
|
||||
snapshot.page
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `image search results require header and sort controls`() {
|
||||
val snapshot = classify(
|
||||
@@ -256,6 +270,63 @@ class PinduoduoPageClassifierTest {
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `modern order confirmation uses address payment and payable markers`() {
|
||||
val snapshot = classify(
|
||||
element(text = "手动添加收货地址"),
|
||||
element(text = "微信支付"),
|
||||
element(text = "先用后付"),
|
||||
element(text = "找好友支付"),
|
||||
element(text = "实付款¥14.35已享7.2折,共优惠5.64元免运费")
|
||||
)
|
||||
|
||||
assertEquals(PinduoduoPage.ORDER_CONFIRMATION, snapshot.page)
|
||||
assertEquals(
|
||||
SafetyStopReason.PAYMENT_BOUNDARY,
|
||||
snapshot.safetyStopReason
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `collapsed checkout webview remains a blocked confirmation page`() {
|
||||
val snapshot = classify(
|
||||
element(resourceId = "order_checkout")
|
||||
)
|
||||
|
||||
assertEquals(PinduoduoPage.ORDER_CONFIRMATION, snapshot.page)
|
||||
assertEquals(
|
||||
SafetyStopReason.PAYMENT_BOUNDARY,
|
||||
snapshot.safetyStopReason
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `collapsed checkout structure is available only to contextual driver`() {
|
||||
val elements = listOf(
|
||||
element(
|
||||
contentDescription = "返回",
|
||||
clickable = true,
|
||||
boundsTop = 154
|
||||
),
|
||||
element(
|
||||
className = "android.webkit.WebView",
|
||||
boundsLeft = 0,
|
||||
boundsTop = 258,
|
||||
boundsRight = 1080,
|
||||
boundsBottom = 2328
|
||||
)
|
||||
)
|
||||
|
||||
val global = PinduoduoPageClassifier.classify(
|
||||
PINDUODUO_PACKAGE,
|
||||
elements
|
||||
)
|
||||
|
||||
assertTrue(PinduoduoCollapsedCheckoutPolicy.matches(elements))
|
||||
assertEquals(PinduoduoPage.UNKNOWN, global.page)
|
||||
assertNull(global.safetyStopReason)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `order list uses stable status tabs`() {
|
||||
val snapshot = classify(
|
||||
@@ -309,18 +380,27 @@ class PinduoduoPageClassifierTest {
|
||||
text: String? = null,
|
||||
contentDescription: String? = null,
|
||||
className: String = "android.widget.TextView",
|
||||
resourceId: String? = null,
|
||||
editable: Boolean = false,
|
||||
clickable: Boolean = false,
|
||||
enabled: Boolean = true,
|
||||
visibleToUser: Boolean = true
|
||||
visibleToUser: Boolean = true,
|
||||
boundsLeft: Int = 0,
|
||||
boundsTop: Int = 0,
|
||||
boundsRight: Int = 0,
|
||||
boundsBottom: Int = 0
|
||||
) = PinduoduoUiElement(
|
||||
text = text,
|
||||
contentDescription = contentDescription,
|
||||
className = className,
|
||||
resourceId = null,
|
||||
resourceId = resourceId,
|
||||
clickable = clickable,
|
||||
editable = editable,
|
||||
enabled = enabled,
|
||||
visibleToUser = visibleToUser
|
||||
visibleToUser = visibleToUser,
|
||||
boundsLeft = boundsLeft,
|
||||
boundsTop = boundsTop,
|
||||
boundsRight = boundsRight,
|
||||
boundsBottom = boundsBottom
|
||||
)
|
||||
}
|
||||
|
||||
+97
-1
@@ -5,6 +5,7 @@ import kotlinx.coroutines.runBlocking
|
||||
import okhttp3.mockwebserver.MockResponse
|
||||
import okhttp3.mockwebserver.MockWebServer
|
||||
import okio.Buffer
|
||||
import org.json.JSONObject
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotNull
|
||||
@@ -183,7 +184,7 @@ class ProcurementApiClientTest {
|
||||
)
|
||||
server.enqueue(
|
||||
jsonResponse(
|
||||
"""{"evidence":{"id":"evidence-id"},"replayed":false}"""
|
||||
"""{"evidence":{"id":"evidence-id","sha256":"${"a".repeat(64)}"},"replayed":false}"""
|
||||
)
|
||||
)
|
||||
val evidenceResult = api.uploadExecutionOutboxItem(
|
||||
@@ -201,6 +202,7 @@ class ProcurementApiClientTest {
|
||||
evidenceBytes = byteArrayOf(1, 2, 3)
|
||||
)
|
||||
assertEquals("evidence-id", evidenceResult.evidenceAssetId)
|
||||
assertEquals("a".repeat(64), evidenceResult.evidenceSha256)
|
||||
val evidenceRequest = server.takeRequest()
|
||||
assertEquals("/api/v1/tasks/task-id/evidence", evidenceRequest.path)
|
||||
assertEquals("claim-token-value", evidenceRequest.getHeader("X-Claim-Token"))
|
||||
@@ -353,6 +355,100 @@ class ProcurementApiClientTest {
|
||||
assertEquals(null, command)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun orderDryRunStartAndReadyUseRecoverableDeviceRoutes() = runBlocking {
|
||||
val task = task(
|
||||
"/api/v1/tasks/task-id/reference-image?claim_generation=1"
|
||||
)
|
||||
val execution = RunningExecution(
|
||||
id = "execution-id",
|
||||
currentStep = "ORDER_AUTHORIZED",
|
||||
expiresAt = "2099-01-01T00:00:00Z",
|
||||
serverClockOffsetMillis = 0
|
||||
)
|
||||
val command = PendingOrderCommand(
|
||||
id = "command-id",
|
||||
schemaVersion = 1,
|
||||
type = "CREATE_PENDING_ORDER",
|
||||
authorizationVersion = 1,
|
||||
taskId = "task-id",
|
||||
executionId = "execution-id",
|
||||
taskContentSha256 = "a".repeat(64),
|
||||
originalSku = "黑色 M",
|
||||
quantity = 2,
|
||||
candidate = OrderCommandCandidate(
|
||||
candidateKey = "candidate-key",
|
||||
observedOrdinal = 1,
|
||||
title = "候选商品标题",
|
||||
skuText = "黑色 M",
|
||||
priceText = "20.00",
|
||||
cardSignature = "b".repeat(64),
|
||||
detailSignature = "c".repeat(64),
|
||||
detailEvidenceSha256 = "d".repeat(64),
|
||||
specificationEvidenceSha256 = "e".repeat(64)
|
||||
),
|
||||
commandSha256 = "f".repeat(64),
|
||||
authorizationStatus = "ACKNOWLEDGED",
|
||||
acknowledged = true
|
||||
)
|
||||
server.enqueue(
|
||||
jsonResponse(
|
||||
"""{"dry_run":{"id":"dry-run-id","command_id":"command-id","command_sha256":"${"f".repeat(64)}","status":"PREPARING"},"replayed":false}"""
|
||||
)
|
||||
)
|
||||
val started = api.startOrderDryRun(
|
||||
session(),
|
||||
task,
|
||||
execution,
|
||||
"claim-token",
|
||||
command,
|
||||
"dry-run-start-key"
|
||||
)
|
||||
assertEquals("PREPARING", started.status)
|
||||
val startRequest = server.takeRequest()
|
||||
assertEquals(
|
||||
"/api/v1/tasks/task-id/order-dry-runs/start",
|
||||
startRequest.path
|
||||
)
|
||||
assertEquals(
|
||||
"dry-run-start-key",
|
||||
startRequest.getHeader("Idempotency-Key")
|
||||
)
|
||||
|
||||
server.enqueue(
|
||||
jsonResponse(
|
||||
"""{"dry_run":{"id":"dry-run-id","command_id":"command-id","command_sha256":"${"f".repeat(64)}","status":"READY"},"replayed":false}"""
|
||||
)
|
||||
)
|
||||
val readyResult = api.uploadExecutionOutboxItem(
|
||||
session(),
|
||||
task,
|
||||
execution,
|
||||
"claim-token",
|
||||
ExecutionOutboxItem(
|
||||
id = "ready-local-id",
|
||||
type = ExecutionOutboxType.ORDER_DRY_RUN_READY,
|
||||
idempotencyKey = "dry-run-ready-key",
|
||||
payload =
|
||||
"""{"command_id":"command-id","command_sha256":"${"f".repeat(64)}"}"""
|
||||
)
|
||||
)
|
||||
assertEquals("READY", readyResult.orderDryRun?.status)
|
||||
val readyRequest = server.takeRequest()
|
||||
assertEquals(
|
||||
"/api/v1/tasks/task-id/order-dry-runs/command-id/ready",
|
||||
readyRequest.path
|
||||
)
|
||||
assertEquals(
|
||||
"dry-run-ready-key",
|
||||
readyRequest.getHeader("Idempotency-Key")
|
||||
)
|
||||
assertEquals(
|
||||
false,
|
||||
JSONObject(readyRequest.body.readUtf8()).has("command_id")
|
||||
)
|
||||
}
|
||||
|
||||
private fun session() = ProcurementSession(
|
||||
backendUrl = server.url("/").toString().trimEnd('/'),
|
||||
username = "buyer01",
|
||||
|
||||
@@ -205,6 +205,10 @@ func buildRouter(
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
dryRuns, err := usecase.NewOrderDryRunService(store, clock, ids)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
passwords, err := password.NewBcrypt(12)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -215,6 +219,7 @@ func buildRouter(
|
||||
Assets: assets,
|
||||
Results: results,
|
||||
Commands: commands,
|
||||
DryRuns: dryRuns,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
|
||||
@@ -328,6 +328,33 @@ type DeviceOrderCommand struct {
|
||||
AuthorizationStatus OrderAuthorizationStatus
|
||||
}
|
||||
|
||||
type OrderDryRunStatus string
|
||||
|
||||
const (
|
||||
OrderDryRunPreparing OrderDryRunStatus = "PREPARING"
|
||||
OrderDryRunReady OrderDryRunStatus = "READY"
|
||||
)
|
||||
|
||||
type OrderDryRun struct {
|
||||
ID string
|
||||
AuthorizationID string
|
||||
TaskID string
|
||||
ExecutionID string
|
||||
CommandSHA256 string
|
||||
Status OrderDryRunStatus
|
||||
CardSignature *string
|
||||
DetailSignature *string
|
||||
ObservedTitle *string
|
||||
SelectedSKU *string
|
||||
Quantity *int
|
||||
UnitPriceCents *int64
|
||||
TotalPriceCents *int64
|
||||
EvidenceAssetID *string
|
||||
EvidenceSHA256 *string
|
||||
StartedAt time.Time
|
||||
ReadyAt *time.Time
|
||||
}
|
||||
|
||||
type ExecutionReport struct {
|
||||
Events []ExecutionEvent
|
||||
EvidenceAssets []ExecutionEvidenceAsset
|
||||
|
||||
@@ -34,8 +34,11 @@ func TestClaimsMigrationPreservesHistoryAcrossUpDownUp(t *testing.T) {
|
||||
|
||||
if applied, err := runner.Up(ctx); err != nil {
|
||||
t.Fatalf("initial Up() error = %v", err)
|
||||
} else if applied != 9 {
|
||||
t.Fatalf("initial Up() applied = %d, want 9", applied)
|
||||
} else if applied != 10 {
|
||||
t.Fatalf("initial Up() applied = %d, want 10", applied)
|
||||
}
|
||||
if err := runner.Down(ctx); err != nil {
|
||||
t.Fatalf("initial Down(v10) error = %v", err)
|
||||
}
|
||||
if err := runner.Down(ctx); err != nil {
|
||||
t.Fatalf("initial Down(v9) error = %v", err)
|
||||
@@ -56,9 +59,14 @@ func TestClaimsMigrationPreservesHistoryAcrossUpDownUp(t *testing.T) {
|
||||
seedClaimsHistoricalFixture(t, db)
|
||||
|
||||
if applied, err := runner.Up(ctx); err != nil {
|
||||
t.Fatalf("Up(v5-v9) over historical data error = %v", err)
|
||||
} else if applied != 5 {
|
||||
t.Fatalf("Up(v5-v9) applied = %d, want 5", applied)
|
||||
t.Fatalf("Up(v5-v10) over historical data error = %v", err)
|
||||
} else if applied != 6 {
|
||||
t.Fatalf("Up(v5-v10) applied = %d, want 6", applied)
|
||||
}
|
||||
assertClaimsHistory(t, db, true)
|
||||
|
||||
if err := runner.Down(ctx); err != nil {
|
||||
t.Fatalf("Down(v10) with compatible history error = %v", err)
|
||||
}
|
||||
assertClaimsHistory(t, db, true)
|
||||
|
||||
@@ -93,9 +101,9 @@ func TestClaimsMigrationPreservesHistoryAcrossUpDownUp(t *testing.T) {
|
||||
assertClaimsHistory(t, db, false)
|
||||
|
||||
if applied, err := runner.Up(ctx); err != nil {
|
||||
t.Fatalf("final Up(v4-v9) error = %v", err)
|
||||
} else if applied != 6 {
|
||||
t.Fatalf("final Up(v4-v9) applied = %d, want 6", applied)
|
||||
t.Fatalf("final Up(v4-v10) error = %v", err)
|
||||
} else if applied != 7 {
|
||||
t.Fatalf("final Up(v4-v10) applied = %d, want 7", applied)
|
||||
}
|
||||
assertClaimsHistory(t, db, true)
|
||||
}
|
||||
@@ -331,6 +339,9 @@ func TestClaimsMigrationDownFailsClosedForNewAuditData(t *testing.T) {
|
||||
t.Fatalf("insert v4 audit event: %v", err)
|
||||
}
|
||||
|
||||
if err := runner.Down(ctx); err != nil {
|
||||
t.Fatalf("Down(v10) error = %v", err)
|
||||
}
|
||||
if err := runner.Down(ctx); err != nil {
|
||||
t.Fatalf("Down(v9) error = %v", err)
|
||||
}
|
||||
|
||||
@@ -27,19 +27,20 @@ func TestRunnerSupportsUpStatusDownAndIdempotentUp(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("Up() error = %v", err)
|
||||
}
|
||||
if applied != 9 {
|
||||
t.Fatalf("Up() applied = %d, want 9", applied)
|
||||
if applied != 10 {
|
||||
t.Fatalf("Up() applied = %d, want 10", applied)
|
||||
}
|
||||
assertStatuses(t, runner, map[int64]bool{
|
||||
1: true,
|
||||
2: true,
|
||||
3: true,
|
||||
4: true,
|
||||
5: true,
|
||||
6: true,
|
||||
7: true,
|
||||
8: true,
|
||||
9: true,
|
||||
1: true,
|
||||
2: true,
|
||||
3: true,
|
||||
4: true,
|
||||
5: true,
|
||||
6: true,
|
||||
7: true,
|
||||
8: true,
|
||||
9: true,
|
||||
10: true,
|
||||
})
|
||||
|
||||
applied, err = runner.Up(context.Background())
|
||||
@@ -54,15 +55,16 @@ func TestRunnerSupportsUpStatusDownAndIdempotentUp(t *testing.T) {
|
||||
t.Fatalf("Down() error = %v", err)
|
||||
}
|
||||
assertStatuses(t, runner, map[int64]bool{
|
||||
1: true,
|
||||
2: true,
|
||||
3: true,
|
||||
4: true,
|
||||
5: true,
|
||||
6: true,
|
||||
7: true,
|
||||
8: true,
|
||||
9: false,
|
||||
1: true,
|
||||
2: true,
|
||||
3: true,
|
||||
4: true,
|
||||
5: true,
|
||||
6: true,
|
||||
7: true,
|
||||
8: true,
|
||||
9: true,
|
||||
10: false,
|
||||
})
|
||||
|
||||
applied, err = runner.Up(context.Background())
|
||||
@@ -73,15 +75,16 @@ func TestRunnerSupportsUpStatusDownAndIdempotentUp(t *testing.T) {
|
||||
t.Fatalf("final Up() applied = %d, want 1", applied)
|
||||
}
|
||||
assertStatuses(t, runner, map[int64]bool{
|
||||
1: true,
|
||||
2: true,
|
||||
3: true,
|
||||
4: true,
|
||||
5: true,
|
||||
6: true,
|
||||
7: true,
|
||||
8: true,
|
||||
9: true,
|
||||
1: true,
|
||||
2: true,
|
||||
3: true,
|
||||
4: true,
|
||||
5: true,
|
||||
6: true,
|
||||
7: true,
|
||||
8: true,
|
||||
9: true,
|
||||
10: true,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -383,6 +383,9 @@ func TestAuthMigrationCanRollbackWithoutRebuildingPurchaseTasks(
|
||||
if err != nil {
|
||||
t.Fatalf("migration.New() error = %v", err)
|
||||
}
|
||||
if err := runner.Down(context.Background()); err != nil {
|
||||
t.Fatalf("Down(v10) error = %v", err)
|
||||
}
|
||||
if err := runner.Down(context.Background()); err != nil {
|
||||
t.Fatalf("Down(v9) error = %v", err)
|
||||
}
|
||||
@@ -417,9 +420,9 @@ func TestAuthMigrationCanRollbackWithoutRebuildingPurchaseTasks(
|
||||
t.Fatal("purchase_tasks was lost during auth migration rollback")
|
||||
}
|
||||
if applied, err := runner.Up(context.Background()); err != nil {
|
||||
t.Fatalf("Up(v3-v9) error = %v", err)
|
||||
} else if applied != 7 {
|
||||
t.Fatalf("Up(v3-v9) applied = %d, want 7", applied)
|
||||
t.Fatalf("Up(v3-v10) error = %v", err)
|
||||
} else if applied != 8 {
|
||||
t.Fatalf("Up(v3-v10) applied = %d, want 8", applied)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -108,14 +108,15 @@ func (s *Store) PullDeviceOrderCommand(
|
||||
}
|
||||
command.AuthorizationStatus = domain.OrderAuthorizationDelivered
|
||||
case domain.OrderAuthorizationDelivered,
|
||||
domain.OrderAuthorizationAcknowledged:
|
||||
domain.OrderAuthorizationAcknowledged,
|
||||
domain.OrderAuthorizationExecuting:
|
||||
result, err := tx.ExecContext(
|
||||
ctx,
|
||||
`UPDATE order_authorizations
|
||||
SET last_delivered_at = ?,
|
||||
delivery_attempt_count = delivery_attempt_count + 1
|
||||
WHERE id = ?
|
||||
AND status IN ('DELIVERED', 'ACKNOWLEDGED')
|
||||
AND status IN ('DELIVERED', 'ACKNOWLEDGED', 'EXECUTING')
|
||||
AND command_sha256 = ?`,
|
||||
formatTimestamp(write.Now),
|
||||
authorization.ID,
|
||||
@@ -389,7 +390,8 @@ func findActiveDeviceOrderAuthorization(
|
||||
AND oa.status IN (
|
||||
'PENDING_DELIVERY',
|
||||
'DELIVERED',
|
||||
'ACKNOWLEDGED'
|
||||
'ACKNOWLEDGED',
|
||||
'EXECUTING'
|
||||
)
|
||||
ORDER BY oa.authorization_version DESC
|
||||
LIMIT 1`,
|
||||
|
||||
@@ -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 {
|
||||
t.Fatalf("migration.New() error = %v", err)
|
||||
}
|
||||
if err := runner.Down(context.Background()); err != nil {
|
||||
t.Fatalf("order dry-run migration down: %v", err)
|
||||
}
|
||||
if err := runner.Down(context.Background()); err != nil {
|
||||
t.Fatalf("device command migration down: %v", err)
|
||||
}
|
||||
|
||||
@@ -21,11 +21,13 @@ type DeviceServices struct {
|
||||
Assets *usecase.AssetService
|
||||
Results *usecase.ExecutionResultService
|
||||
Commands *usecase.DeviceOrderCommandService
|
||||
DryRuns *usecase.OrderDryRunService
|
||||
}
|
||||
|
||||
func (services DeviceServices) validate() error {
|
||||
if services.Lifecycle == nil || services.Assets == nil ||
|
||||
services.Results == nil || services.Commands == nil {
|
||||
services.Results == nil || services.Commands == nil ||
|
||||
services.DryRuns == nil {
|
||||
return errors.New("device services are required")
|
||||
}
|
||||
return nil
|
||||
@@ -83,12 +85,141 @@ func NewDeviceRouteRegistrar(
|
||||
"/api/v1/tasks/:id/commands/:command_id/ack",
|
||||
handler.acknowledgeOrderCommand,
|
||||
)
|
||||
routes.POST(
|
||||
"/api/v1/tasks/:id/order-dry-runs/start",
|
||||
handler.startOrderDryRun,
|
||||
)
|
||||
routes.POST(
|
||||
"/api/v1/tasks/:id/order-dry-runs/:command_id/ready",
|
||||
handler.readyOrderDryRun,
|
||||
)
|
||||
routes.POST("/api/v1/tasks/:id/complete", handler.completeTask)
|
||||
routes.POST("/api/v1/tasks/:id/fail", handler.failTask)
|
||||
return nil
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (handler *deviceHandlers) startOrderDryRun(ctx *gin.Context) {
|
||||
principal, ok := devicePrincipal(ctx)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var request struct {
|
||||
DeviceID string `json:"device_id"`
|
||||
ExecutionID string `json:"execution_id"`
|
||||
ClaimGeneration int64 `json:"claim_generation"`
|
||||
CommandID string `json:"command_id"`
|
||||
CommandSHA256 string `json:"command_sha256"`
|
||||
}
|
||||
if !decodeDeviceJSON(ctx, &request) ||
|
||||
!deviceIDMatches(ctx, request.DeviceID, principal.DeviceID) {
|
||||
return
|
||||
}
|
||||
result, err := handler.services.DryRuns.Start(
|
||||
ctx.Request.Context(),
|
||||
usecase.StartOrderDryRunCommand{
|
||||
UserID: principal.UserID,
|
||||
DeviceID: principal.DeviceID,
|
||||
TaskID: ctx.Param("id"),
|
||||
ExecutionID: request.ExecutionID,
|
||||
AuthorizationID: request.CommandID,
|
||||
ClaimGeneration: request.ClaimGeneration,
|
||||
ClaimToken: ctx.GetHeader(claimTokenHeader),
|
||||
CommandSHA256: request.CommandSHA256,
|
||||
IdempotencyKey: ctx.GetHeader("Idempotency-Key"),
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
writeUsecaseError(ctx, err)
|
||||
return
|
||||
}
|
||||
ctx.Header("Cache-Control", "no-store")
|
||||
ctx.JSON(http.StatusOK, gin.H{
|
||||
"dry_run": orderDryRunResponse(result.DryRun),
|
||||
"replayed": result.Replayed,
|
||||
})
|
||||
}
|
||||
|
||||
func (handler *deviceHandlers) readyOrderDryRun(ctx *gin.Context) {
|
||||
principal, ok := devicePrincipal(ctx)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var request struct {
|
||||
DeviceID string `json:"device_id"`
|
||||
ExecutionID string `json:"execution_id"`
|
||||
ClaimGeneration int64 `json:"claim_generation"`
|
||||
CommandSHA256 string `json:"command_sha256"`
|
||||
CardSignature string `json:"card_signature"`
|
||||
DetailSignature string `json:"detail_signature"`
|
||||
ObservedTitle string `json:"observed_title"`
|
||||
SelectedSKU string `json:"selected_sku"`
|
||||
Quantity int `json:"quantity"`
|
||||
UnitPriceCents int64 `json:"unit_price_cents"`
|
||||
TotalPriceCents int64 `json:"total_price_cents"`
|
||||
EvidenceAssetID string `json:"evidence_asset_id"`
|
||||
EvidenceSHA256 string `json:"evidence_sha256"`
|
||||
}
|
||||
if !decodeDeviceJSON(ctx, &request) ||
|
||||
!deviceIDMatches(ctx, request.DeviceID, principal.DeviceID) {
|
||||
return
|
||||
}
|
||||
result, err := handler.services.DryRuns.Ready(
|
||||
ctx.Request.Context(),
|
||||
usecase.ReadyOrderDryRunCommand{
|
||||
UserID: principal.UserID,
|
||||
DeviceID: principal.DeviceID,
|
||||
TaskID: ctx.Param("id"),
|
||||
ExecutionID: request.ExecutionID,
|
||||
AuthorizationID: ctx.Param("command_id"),
|
||||
ClaimGeneration: request.ClaimGeneration,
|
||||
ClaimToken: ctx.GetHeader(claimTokenHeader),
|
||||
CommandSHA256: request.CommandSHA256,
|
||||
CardSignature: request.CardSignature,
|
||||
DetailSignature: request.DetailSignature,
|
||||
ObservedTitle: request.ObservedTitle,
|
||||
SelectedSKU: request.SelectedSKU,
|
||||
Quantity: request.Quantity,
|
||||
UnitPriceCents: request.UnitPriceCents,
|
||||
TotalPriceCents: request.TotalPriceCents,
|
||||
EvidenceAssetID: request.EvidenceAssetID,
|
||||
EvidenceSHA256: request.EvidenceSHA256,
|
||||
IdempotencyKey: ctx.GetHeader("Idempotency-Key"),
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
writeUsecaseError(ctx, err)
|
||||
return
|
||||
}
|
||||
ctx.Header("Cache-Control", "no-store")
|
||||
ctx.JSON(http.StatusOK, gin.H{
|
||||
"dry_run": orderDryRunResponse(result.DryRun),
|
||||
"replayed": result.Replayed,
|
||||
})
|
||||
}
|
||||
|
||||
func orderDryRunResponse(dryRun domain.OrderDryRun) gin.H {
|
||||
return gin.H{
|
||||
"id": dryRun.ID,
|
||||
"command_id": dryRun.AuthorizationID,
|
||||
"task_id": dryRun.TaskID,
|
||||
"execution_id": dryRun.ExecutionID,
|
||||
"command_sha256": dryRun.CommandSHA256,
|
||||
"status": dryRun.Status,
|
||||
"card_signature": dryRun.CardSignature,
|
||||
"detail_signature": dryRun.DetailSignature,
|
||||
"observed_title": dryRun.ObservedTitle,
|
||||
"selected_sku": dryRun.SelectedSKU,
|
||||
"quantity": dryRun.Quantity,
|
||||
"unit_price_cents": dryRun.UnitPriceCents,
|
||||
"total_price_cents": dryRun.TotalPriceCents,
|
||||
"evidence_asset_id": dryRun.EvidenceAssetID,
|
||||
"evidence_sha256": dryRun.EvidenceSHA256,
|
||||
"started_at": formatTime(dryRun.StartedAt),
|
||||
"ready_at": formatOptionalTime(dryRun.ReadyAt),
|
||||
}
|
||||
}
|
||||
|
||||
func (handler *deviceHandlers) pullOrderCommand(ctx *gin.Context) {
|
||||
principal, ok := devicePrincipal(ctx)
|
||||
if !ok {
|
||||
|
||||
@@ -713,6 +713,9 @@ func TestDeviceExecutionResultsAreIdempotentAndAuditable(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("migration.New() after review error = %v", err)
|
||||
}
|
||||
if err := runner.Down(context.Background()); err != nil {
|
||||
t.Fatalf("order dry-run migration down: %v", err)
|
||||
}
|
||||
if err := runner.Down(context.Background()); err != nil {
|
||||
t.Fatalf("device command migration down: %v", err)
|
||||
}
|
||||
@@ -721,8 +724,8 @@ func TestDeviceExecutionResultsAreIdempotentAndAuditable(t *testing.T) {
|
||||
}
|
||||
if applied, err := runner.Up(context.Background()); err != nil {
|
||||
t.Fatalf("restore device command migration: %v", err)
|
||||
} else if applied != 1 {
|
||||
t.Fatalf("restored migrations = %d, want 1", applied)
|
||||
} else if applied != 2 {
|
||||
t.Fatalf("restored migrations = %d, want 2", applied)
|
||||
}
|
||||
|
||||
completePayload := fmt.Sprintf(
|
||||
@@ -885,6 +888,7 @@ func TestDeviceOrderCommandDeliveryAndAcknowledgementAreRecoverable(
|
||||
SchemaVersion int `json:"schema_version"`
|
||||
TaskID string `json:"task_id"`
|
||||
ExecutionID string `json:"execution_id"`
|
||||
OriginalSKU string `json:"original_sku"`
|
||||
Quantity int `json:"quantity"`
|
||||
CommandSHA256 string `json:"command_sha256"`
|
||||
AuthorizationStatus string `json:"authorization_status"`
|
||||
@@ -905,6 +909,7 @@ func TestDeviceOrderCommandDeliveryAndAcknowledgementAreRecoverable(
|
||||
command.SchemaVersion != 1 ||
|
||||
command.TaskID != taskID ||
|
||||
command.ExecutionID != started.Execution.ID ||
|
||||
command.OriginalSKU == "" ||
|
||||
command.Quantity != 2 ||
|
||||
len(command.CommandSHA256) != 64 ||
|
||||
command.AuthorizationStatus != "DELIVERED" ||
|
||||
@@ -997,10 +1002,149 @@ func TestDeviceOrderCommandDeliveryAndAcknowledgementAreRecoverable(
|
||||
) {
|
||||
t.Fatalf("acknowledged pull = %s", acknowledgedPull.Body.String())
|
||||
}
|
||||
var deliveredEvents, acknowledgedEvents int
|
||||
startDryRunPayload := fmt.Sprintf(
|
||||
`{"device_id":%q,"execution_id":%q,"claim_generation":%d,"command_id":%q,"command_sha256":%q}`,
|
||||
deviceTestDeviceID,
|
||||
started.Execution.ID,
|
||||
started.Task.ClaimGeneration,
|
||||
command.ID,
|
||||
command.CommandSHA256,
|
||||
)
|
||||
startDryRun := performDeviceRequest(t, fixture.router, deviceRequest{
|
||||
method: http.MethodPost,
|
||||
target: "/api/v1/tasks/" + taskID + "/order-dry-runs/start",
|
||||
contentType: "application/json",
|
||||
body: strings.NewReader(startDryRunPayload),
|
||||
bearerToken: testOpaqueToken,
|
||||
claimToken: testOpaqueToken,
|
||||
idempotencyKey: "order-dry-run-start",
|
||||
})
|
||||
requireDeviceStatus(t, startDryRun, http.StatusOK)
|
||||
if !strings.Contains(startDryRun.Body.String(), `"status":"PREPARING"`) {
|
||||
t.Fatalf("dry-run start = %s", startDryRun.Body.String())
|
||||
}
|
||||
startDryRunReplay := performDeviceRequest(t, fixture.router, deviceRequest{
|
||||
method: http.MethodPost,
|
||||
target: "/api/v1/tasks/" + taskID + "/order-dry-runs/start",
|
||||
contentType: "application/json",
|
||||
body: strings.NewReader(startDryRunPayload),
|
||||
bearerToken: testOpaqueToken,
|
||||
claimToken: testOpaqueToken,
|
||||
idempotencyKey: "order-dry-run-start",
|
||||
})
|
||||
requireDeviceStatus(t, startDryRunReplay, http.StatusOK)
|
||||
if !strings.Contains(startDryRunReplay.Body.String(), `"replayed":true`) {
|
||||
t.Fatalf("dry-run start replay = %s", startDryRunReplay.Body.String())
|
||||
}
|
||||
executingPull := performDeviceRequest(t, fixture.router, deviceRequest{
|
||||
method: http.MethodPost,
|
||||
target: "/api/v1/tasks/" + taskID + "/commands/next",
|
||||
contentType: "application/json",
|
||||
body: strings.NewReader(pullPayload),
|
||||
bearerToken: testOpaqueToken,
|
||||
claimToken: testOpaqueToken,
|
||||
})
|
||||
requireDeviceStatus(t, executingPull, http.StatusOK)
|
||||
if !strings.Contains(
|
||||
executingPull.Body.String(),
|
||||
`"authorization_status":"EXECUTING"`,
|
||||
) {
|
||||
t.Fatalf("executing pull = %s", executingPull.Body.String())
|
||||
}
|
||||
const dryRunEvidenceID = "00000000-0000-4000-8000-000000000077"
|
||||
dryRunEvidenceSHA := strings.Repeat("e", 64)
|
||||
if _, err := fixture.db.Exec(
|
||||
`INSERT INTO execution_evidence_assets (
|
||||
id, task_id, execution_id, media_type, size_bytes, sha256,
|
||||
storage_key, created_at, received_after_execution_expiry
|
||||
) VALUES (?, ?, ?, 'image/jpeg', 10, ?, ?, ?, 0)`,
|
||||
dryRunEvidenceID,
|
||||
taskID,
|
||||
started.Execution.ID,
|
||||
dryRunEvidenceSHA,
|
||||
"dry-run/order-confirmation.jpg",
|
||||
time.Now().UTC().Format(time.RFC3339Nano),
|
||||
); err != nil {
|
||||
t.Fatalf("seed dry-run evidence: %v", err)
|
||||
}
|
||||
readyDryRunPayload := fmt.Sprintf(
|
||||
`{"device_id":%q,"execution_id":%q,"claim_generation":%d,"command_sha256":%q,"card_signature":%q,"detail_signature":%q,"observed_title":%q,"selected_sku":%q,"quantity":2,"unit_price_cents":2150,"total_price_cents":4300,"evidence_asset_id":%q,"evidence_sha256":%q}`,
|
||||
deviceTestDeviceID,
|
||||
started.Execution.ID,
|
||||
started.Task.ClaimGeneration,
|
||||
command.CommandSHA256,
|
||||
command.Candidate.CardSignature,
|
||||
strings.Repeat("f", 64),
|
||||
command.Candidate.Title,
|
||||
command.OriginalSKU,
|
||||
dryRunEvidenceID,
|
||||
dryRunEvidenceSHA,
|
||||
)
|
||||
badSKUDryRun := performDeviceRequest(t, fixture.router, deviceRequest{
|
||||
method: http.MethodPost,
|
||||
target: "/api/v1/tasks/" + taskID +
|
||||
"/order-dry-runs/" + command.ID + "/ready",
|
||||
contentType: "application/json",
|
||||
body: strings.NewReader(strings.Replace(
|
||||
readyDryRunPayload,
|
||||
fmt.Sprintf(`"selected_sku":%q`, command.OriginalSKU),
|
||||
`"selected_sku":"wrong-sku"`,
|
||||
1,
|
||||
)),
|
||||
bearerToken: testOpaqueToken,
|
||||
claimToken: testOpaqueToken,
|
||||
idempotencyKey: "order-dry-run-ready-bad-sku",
|
||||
})
|
||||
requireDeviceStatus(t, badSKUDryRun, http.StatusConflict)
|
||||
badTotalDryRun := performDeviceRequest(t, fixture.router, deviceRequest{
|
||||
method: http.MethodPost,
|
||||
target: "/api/v1/tasks/" + taskID +
|
||||
"/order-dry-runs/" + command.ID + "/ready",
|
||||
contentType: "application/json",
|
||||
body: strings.NewReader(strings.Replace(
|
||||
readyDryRunPayload,
|
||||
`"total_price_cents":4300`,
|
||||
`"total_price_cents":4301`,
|
||||
1,
|
||||
)),
|
||||
bearerToken: testOpaqueToken,
|
||||
claimToken: testOpaqueToken,
|
||||
idempotencyKey: "order-dry-run-ready-bad-total",
|
||||
})
|
||||
requireDeviceStatus(t, badTotalDryRun, http.StatusConflict)
|
||||
readyDryRun := performDeviceRequest(t, fixture.router, deviceRequest{
|
||||
method: http.MethodPost,
|
||||
target: "/api/v1/tasks/" + taskID + "/order-dry-runs/" + command.ID + "/ready",
|
||||
contentType: "application/json",
|
||||
body: strings.NewReader(readyDryRunPayload),
|
||||
bearerToken: testOpaqueToken,
|
||||
claimToken: testOpaqueToken,
|
||||
idempotencyKey: "order-dry-run-ready",
|
||||
})
|
||||
requireDeviceStatus(t, readyDryRun, http.StatusOK)
|
||||
if !strings.Contains(readyDryRun.Body.String(), `"status":"READY"`) {
|
||||
t.Fatalf("dry-run ready = %s", readyDryRun.Body.String())
|
||||
}
|
||||
readyDryRunReplay := performDeviceRequest(t, fixture.router, deviceRequest{
|
||||
method: http.MethodPost,
|
||||
target: "/api/v1/tasks/" + taskID +
|
||||
"/order-dry-runs/" + command.ID + "/ready",
|
||||
contentType: "application/json",
|
||||
body: strings.NewReader(readyDryRunPayload),
|
||||
bearerToken: testOpaqueToken,
|
||||
claimToken: testOpaqueToken,
|
||||
idempotencyKey: "order-dry-run-ready",
|
||||
})
|
||||
requireDeviceStatus(t, readyDryRunReplay, http.StatusOK)
|
||||
if !strings.Contains(readyDryRunReplay.Body.String(), `"replayed":true`) {
|
||||
t.Fatalf("dry-run ready replay = %s", readyDryRunReplay.Body.String())
|
||||
}
|
||||
var deliveredEvents, acknowledgedEvents, dryRunStartedEvents, dryRunReadyEvents int
|
||||
for eventType, target := range map[string]*int{
|
||||
"ORDER_AUTHORIZATION_DELIVERED": &deliveredEvents,
|
||||
"ORDER_AUTHORIZATION_ACKNOWLEDGED": &acknowledgedEvents,
|
||||
"ORDER_DRY_RUN_STARTED": &dryRunStartedEvents,
|
||||
"ORDER_DRY_RUN_READY": &dryRunReadyEvents,
|
||||
} {
|
||||
if err := fixture.db.QueryRow(
|
||||
`SELECT COUNT(*) FROM task_events
|
||||
@@ -1011,11 +1155,14 @@ func TestDeviceOrderCommandDeliveryAndAcknowledgementAreRecoverable(
|
||||
t.Fatalf("count %s events: %v", eventType, err)
|
||||
}
|
||||
}
|
||||
if deliveredEvents != 1 || acknowledgedEvents != 1 {
|
||||
if deliveredEvents != 1 || acknowledgedEvents != 1 ||
|
||||
dryRunStartedEvents != 1 || dryRunReadyEvents != 1 {
|
||||
t.Fatalf(
|
||||
"delivery/ack events = %d/%d",
|
||||
"delivery/ack/dry-run events = %d/%d/%d/%d",
|
||||
deliveredEvents,
|
||||
acknowledgedEvents,
|
||||
dryRunStartedEvents,
|
||||
dryRunReadyEvents,
|
||||
)
|
||||
}
|
||||
runner, err := migration.New(fixture.db)
|
||||
@@ -1023,7 +1170,7 @@ func TestDeviceOrderCommandDeliveryAndAcknowledgementAreRecoverable(
|
||||
t.Fatalf("migration.New() error = %v", err)
|
||||
}
|
||||
if err := runner.Down(context.Background()); err == nil {
|
||||
t.Fatal("device command migration down succeeded with command data")
|
||||
t.Fatal("order dry-run migration down succeeded with dry-run data")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1352,12 +1499,17 @@ func newDeviceHTTPFixture(t *testing.T) *deviceHTTPFixture {
|
||||
if err != nil {
|
||||
t.Fatalf("usecase.NewDeviceOrderCommandService() error = %v", err)
|
||||
}
|
||||
dryRuns, err := usecase.NewOrderDryRunService(store, clock, ids)
|
||||
if err != nil {
|
||||
t.Fatalf("usecase.NewOrderDryRunService() error = %v", err)
|
||||
}
|
||||
deviceRoutes, err := NewDeviceRouteRegistrar(
|
||||
DeviceServices{
|
||||
Lifecycle: lifecycle,
|
||||
Assets: assets,
|
||||
Results: results,
|
||||
Commands: commands,
|
||||
DryRuns: dryRuns,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
|
||||
@@ -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-208 的原始候选观测、模型评估、确定性推荐、逐候选
|
||||
结构化人工理由和修订历史也已完成。T-214 商品持久身份和重新定位指纹、T-215
|
||||
Admin 候选确认、不可变待投递授权及 T-216 设备命令可靠投递与确认均已完成;下一项
|
||||
是正在实现的 T-217 已授权商品重新定位与订单 dry-run。
|
||||
Admin 候选确认、不可变待投递授权、T-216 设备命令可靠投递及 T-217 已授权商品
|
||||
重新定位与订单 dry-run 均已完成;下一项是 T-218 单次订单提交与订单回读。
|
||||
不得直接把候选链接或列表 ordinal 当成授权。
|
||||
手机从管理后端领取任务并回传结果,VLM、拼多多自动化和人工确认在 App 本地完成。
|
||||
T-206 增加有限离线执行;T-207 已复用 Roubao 端上 OpenAI 兼容适配器并加密本地 Key。
|
||||
|
||||
@@ -629,6 +629,11 @@ App 可以从规格弹层进入确认订单页,但 T-217 的 accessibility act
|
||||
确认订单页的提交、付款、地址或优惠选择。READY 同时存在于手机加密状态与后端审计表,
|
||||
才允许 T-218 消费。
|
||||
|
||||
拼多多 8.17 的结算 WebView 偶尔只暴露外层节点。此时 App 可对无障碍服务捕获的当前
|
||||
确认页截图运行 bundled ML Kit 中文 OCR,但它只返回只读证据:标题必须满足有界锚点、
|
||||
颜色/尺码必须通过同一 SKU 别名匹配器、数量必须位于规格与优惠边界之间,实付款必须
|
||||
唯一可解析。任一条件不满足即停止,OCR 层没有点击接口。
|
||||
|
||||
人工理由使用版本化 allowlist。接受或拒绝至少有一个理由且指定主要理由;
|
||||
`OTHER` 才要求 4-200 字备注。拒绝推荐后改选必须同时产生一条原推荐项负标签和一条
|
||||
替代项正标签;全部无匹配时每个曝光候选都有负标签。人工修正追加新 review 并引用
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
| IX-008 | US-006 | App/管理端错误状态 | 自动失败、取消、重试上传 | 显示结构化原因和恢复动作 | P0 | 已定 |
|
||||
| IX-009 | US-008 | App 候选理由/管理端决策详情 | 接受、拒绝、改选或修正 | 保存逐候选结构化人工标签 | P1 | T-208 已实现 |
|
||||
| IX-010 | US-009 | App 独立执行设置/同步状态 | 配置模式、离线执行或补报 | 授权内独立执行并可审计同步 | P0 | T-206 离线控制已实现,结果补报待 T-207 |
|
||||
| IX-011 | US-010 | Admin 候选授权/App 订单执行 | 选择候选并授权、设备领取执行 | 创建一笔可对账的待付款订单并提醒人工付款 | P0 | T-215/T-216 已完成;T-217 进行中 |
|
||||
| IX-011 | US-010 | Admin 候选授权/App 订单执行 | 选择候选并授权、设备领取执行 | 创建一笔可对账的待付款订单并提醒人工付款 | P0 | T-215 至 T-217 已完成;T-218/T-219 待实现 |
|
||||
|
||||
## IX-001 管理 Web 登录
|
||||
|
||||
|
||||
+16
-14
@@ -5,9 +5,9 @@
|
||||
## 当前快照
|
||||
|
||||
- 日期:2026-07-28
|
||||
- 阶段:T-217 已授权商品重新定位与订单 dry-run 进行中
|
||||
- Git:当前分支为 `main`;T-001 至 T-004、T-101 至 T-104、T-201 至 T-207、T-209、
|
||||
T-208、T-210、T-211、T-212、T-213、T-214 均已纳入 Git 历史
|
||||
- 阶段:T-217 已授权商品重新定位与订单 dry-run 已完成;下一项 T-218
|
||||
- Git:当前分支为 `main`;T-001 至 T-004、T-101 至 T-104、T-201 至 T-217
|
||||
均按文档提交、实现提交的顺序纳入历史
|
||||
- 生产代码:`android-buyer/` 已接入 Roubao Android 源码
|
||||
- Android:固定 `main@c8a6d7f03422eb01744b01f3ee77bf7757741f7e`;MIT 许可证已保留
|
||||
|
||||
@@ -17,10 +17,10 @@
|
||||
- 本机 Android 工具:JDK 17.0.13、Command-line Tools 22.0、SDK 34、
|
||||
Build Tools 34.0.0、Platform Tools/ADB 37.0.0;用户级 SDK 环境变量已设置
|
||||
- Android Studio:未安装;`winget` 静默安装卡住后已终止,不阻塞命令行构建
|
||||
- 测试:T-216 Android Debug/Release 单元测试与构建和根 `init.ps1` 通过;
|
||||
Debug APK `1.4.13 (18)` 已构建但未覆盖安装,PKG110 当前仍为 `1.4.12 (17)`
|
||||
- 后端测试:T-216 运行 `go test ./...`、`go test -race ./...`、`go vet ./...`;
|
||||
v9 migration 往返、带命令数据的降级保护及 pull/ACK HTTP 集成测试均通过
|
||||
- 测试:T-217 Android Debug/Release 单元测试与构建和根 `init.ps1` 通过;
|
||||
Debug APK `1.4.14 (19)` 已覆盖安装到 PKG110
|
||||
- 后端测试:T-217 运行 `go test ./...`、`go test -race ./...`、`go vet ./...`;
|
||||
v10 migration 往返、带 dry-run 数据的降级保护及 start/ready HTTP 集成测试通过
|
||||
- 原型:4 个管理 Web 页面和 7 个 Android 页面均可离线独立打开;Playwright
|
||||
以 1440×900、390×844、360×800 验证 36 个页面/视口组合,无页面横向溢出、
|
||||
脚本错误或外部请求,Android 可见交互控件均不小于 44px
|
||||
@@ -72,6 +72,10 @@
|
||||
canonical hash、任务内容和候选四组指纹,先写 Keystore-backed 状态再幂等 ACK。
|
||||
网络响应丢失和进程重启复用同一 ACK key;后台候选不再允许手机本地选择或重复
|
||||
采集。本任务只同步授权,未打开拼多多或执行下单。
|
||||
- T-217 订单 dry-run:App 自动用参考图重新搜索并以旧 card/detail 指纹、标题、
|
||||
唯一颜色/尺码、数量和预算重新核验 Admin 选择;确认页 WebView 折叠时使用端上
|
||||
中文 OCR 做只读 fail-closed 证据提取。后端保存 v10 READY、截图、SKU、数量、
|
||||
单价/总额和事件;无障碍动作集中不含订单提交或支付。
|
||||
- Workflow:纯 Kotlin runner 已支持步骤 timeout、最多 3 次 retry、安全阻塞、
|
||||
用户停止和单 runner 并发拒绝;T-102 已接入搜索加有界候选采集五步
|
||||
- TaskSource:严格 CLI 已生成并验证真实私有 ProbeTask;默认 APK 不含私有 fixture
|
||||
@@ -85,8 +89,7 @@
|
||||
- 离线执行:默认 30 分钟有限授权和 30 秒 best-effort heartbeat 已实现;PKG110
|
||||
真机断开临时后端 95 秒后保持同一 execution,重连后滑动续期;到期持久安全停止,
|
||||
RUNNING 不自动重新分配
|
||||
- 测试设备:OnePlus PKG110,Android 16/API 36;已安装肉包 `1.4.12 (17)`,
|
||||
待安装构建为 `1.4.13 (18)`;拼多多
|
||||
- 测试设备:OnePlus PKG110,Android 16/API 36;已安装肉包 `1.4.14 (19)`;拼多多
|
||||
`8.17.0 (81700)`
|
||||
- 设备就绪:拼多多首页、文字/图片搜索、双列候选卡、详情截图和返回均已通过 8.17.0
|
||||
真机验证;采购员已在 ColorOS 设置中手动启用肉包采购无障碍,APK 覆盖安装后授权
|
||||
@@ -101,7 +104,7 @@
|
||||
已用 CLI 真实导入并逐字段/图片哈希验证,生成物位于被忽略的 `.local/`
|
||||
- 标准启动路径:`$env:RUN_START_COMMAND="1"; .\init.ps1`
|
||||
- 标准验证路径:`.\init.ps1`
|
||||
- 当前 blocker:T-216 无阻塞。真实 VLM 服务地址、模型、设备级测试凭证、成本上限
|
||||
- 当前 blocker:T-217 无阻塞。真实 VLM 服务地址、模型、设备级测试凭证、成本上限
|
||||
和数据留存尚未确认;当前只支持单 SKU/JPEG;候选探针截图要求 Android 11/API 30+
|
||||
|
||||
## 当前目录
|
||||
@@ -134,7 +137,7 @@
|
||||
| `docs/tasks/T-214.md` | DONE | 建立 execution-scoped candidate key 与设备采集指纹 |
|
||||
| `docs/tasks/T-215.md` | DONE | Admin 按 candidate key 选择并创建不可变待投递授权 |
|
||||
| `docs/tasks/T-216.md` | DONE | App 主动拉取并先加密落盘再确认同一条下单命令 |
|
||||
| `docs/tasks/T-217.md` | DOING | 重新核对已授权商品并选择 SKU/数量,停在最终提交前 |
|
||||
| `docs/tasks/T-217.md` | DONE | 重新核对已授权商品并选择 SKU/数量,停在最终提交前 |
|
||||
| `docs/design/` | 已确认 | T-202 原型索引、4 个管理页和 7 个 Android 页面 |
|
||||
| `deepseek总结.txt` | 已有 | 历史讨论摘要,不是正式需求权威 |
|
||||
| `android-buyer/` | 已有 | Roubao `main` 固定 commit 的 Android 基线 |
|
||||
@@ -145,9 +148,8 @@
|
||||
|
||||
## 任务摘要
|
||||
|
||||
- 已完成:T-001 至 T-004、T-101 至 T-104、T-201 至 T-216。
|
||||
- 正在进行:T-217 已授权商品重新定位与订单 dry-run。
|
||||
- 下一步:依次实现单次提交对账和付款提醒。
|
||||
- 已完成:T-001 至 T-004、T-101 至 T-104、T-201 至 T-217。
|
||||
- 下一步:T-218 单次订单提交与订单回读,然后实现 T-219 付款提醒。
|
||||
|
||||
## 当前可运行内容
|
||||
|
||||
|
||||
+27
-11
@@ -4,7 +4,7 @@ title: 已授权商品重新定位与订单 dry-run
|
||||
phase: 2
|
||||
deps:
|
||||
- T-216
|
||||
status: DOING
|
||||
status: DONE
|
||||
created: 2026-07-28
|
||||
context_ref: 2372ab2
|
||||
work_branch: null
|
||||
@@ -79,7 +79,9 @@ READY 证据都存在,T-218 才能消费同一授权执行一次提交。
|
||||
`ORDER_CONFIRMATION`。该动作只是形成待提交订单预览,不是平台订单提交。
|
||||
3. 订单确认页重新读取商品标题/规格摘要/数量/商品金额或应付总额;必须与授权和弹层
|
||||
证据一致,且总额不超过任务最高商品总预算。地址、优惠券、运费或服务选项只读,
|
||||
App 不自动修改。
|
||||
App 不自动修改。拼多多 WebView 无法暴露完整语义树时,只允许对当前无障碍截图
|
||||
使用端上中文 OCR 做只读、fail-closed 核验;标题锚点、SKU 别名、数量和实付款任一
|
||||
缺失或冲突都停止,OCR 结果不能产生点击。
|
||||
4. 捕获受控订单确认截图后,App 先加密保存 READY 证据,再上传截图并幂等调用
|
||||
`POST /api/v1/tasks/{task_id}/order-dry-runs/{command_id}/ready`。服务端保存当前
|
||||
指纹、SKU、数量、价格、金额和 evidence,写 `ORDER_DRY_RUN_READY` 事件。
|
||||
@@ -101,24 +103,38 @@ v10:
|
||||
|
||||
## 验收要点
|
||||
|
||||
- [ ] start/ready 仅接受原身份、有效租约、同一 command hash,并可安全重放。
|
||||
- [ ] 重新定位不按 ordinal/坐标;预算、滚动和候选检查均有硬上限。
|
||||
- [ ] 至少一个旧语义签名、标题、唯一 SKU 和价格/预算同时满足,且只能唯一命中。
|
||||
- [ ] 数量设置逐步复核;禁用、重复、缺失和状态未改变均停止。
|
||||
- [ ] 只允许从规格弹层进入订单确认页,绝不点击订单提交或支付控件。
|
||||
- [ ] READY 先加密落盘再回传,截图/当前指纹/SKU/数量/金额可审计并可恢复。
|
||||
- [ ] v10 migration、Go test/race/vet、Android test/Debug/Release 和根验证通过。
|
||||
- [ ] PKG110 真机 smoke 到订单确认页,确认拼多多订单列表尚无新订单。
|
||||
- [x] start/ready 仅接受原身份、有效租约、同一 command hash,并可安全重放。
|
||||
- [x] 重新定位不按 ordinal/坐标;预算、滚动和候选检查均有硬上限。
|
||||
- [x] 至少一个旧语义签名、标题、唯一 SKU 和价格/预算同时满足,且只能唯一命中。
|
||||
- [x] 数量设置逐步复核;禁用、重复、缺失和状态未改变均停止。
|
||||
- [x] 只允许从规格弹层进入订单确认页,绝不点击订单提交或支付控件。
|
||||
- [x] READY 先加密落盘再回传,截图/当前指纹/SKU/数量/金额可审计并可恢复。
|
||||
- [x] v10 migration、Go test/race/vet、Android test/Debug/Release 和根验证通过。
|
||||
- [x] PKG110 真机 smoke 到订单确认页,后端 READY 且未触发最终提交。
|
||||
|
||||
## 边界
|
||||
|
||||
- 不点击确认订单页最终提交,不创建平台订单;属于 T-218。
|
||||
- 不读取订单列表、订单号或下单时间;属于 T-218。
|
||||
- 不提示或执行付款;属于 T-219/采购员人工操作。
|
||||
- 不用 VLM、OCR、坐标、剪贴板或旧截图 hash 绕过可访问性语义失败。
|
||||
- 不用 VLM、坐标、剪贴板或旧截图 hash 绕过确定性核验;OCR 仅用于当前结算截图
|
||||
的只读证据降级,不能放宽 SKU/数量/金额条件或产生点击。
|
||||
|
||||
## 执行记录
|
||||
|
||||
- 2026-07-28:T-216 实现提交 `2372ab2` 后领取。冻结自动触发、恢复、重新图片搜索、
|
||||
多信号唯一定位、SKU/数量/价格复核和最终提交禁区;计划复用 T-211/T-213 已验证的
|
||||
图片搜索、规格解析和页面分类能力。
|
||||
- 2026-07-28:新增 v10 dry-run/幂等请求表、start/ready API、授权
|
||||
`ACKNOWLEDGED -> EXECUTING`、加密本地 PREPARING/READY 与证据 outbox。Android
|
||||
`1.4.14 (19)` 增加重新定位、规格/数量设置、确认页只读核验和最多 5 次恢复尝试。
|
||||
- 2026-07-28:真机发现拼多多拍照搜索偶发“请对准商品或码”对话框,状态机只允许
|
||||
点击“取消”后重新进入相册;结算 WebView 语义树折叠时使用 bundled ML Kit 中文
|
||||
OCR,按规范色别名、尺码、数量和实付款 fail-closed 核验。
|
||||
- 2026-07-28:PKG110/拼多多 8.17 真机任务
|
||||
`670019e0-20d9-453e-a67e-964e03ad7482` 到达后端 READY;保存订单确认截图、
|
||||
`灰色,2XL`、数量 1、单价/总额 14.35 元以及 STARTED/READY 事件。App 显示
|
||||
“订单已核验,等待提交”,没有调用订单提交或支付动作。
|
||||
- 2026-07-28:`go test ./...`、`go test -race ./...`、`go vet ./...`、Android
|
||||
Debug/Release 单测与构建、根 `.\init.ps1` 全部通过;正式 APK 已覆盖安装并保留
|
||||
READY 状态。
|
||||
|
||||
+12
@@ -174,3 +174,15 @@
|
||||
- 影响:手机直接调用本机已配置的 provider,管理后端不保存 Key、完整 endpoint 或
|
||||
原始模型输出;结果按“事件、截图、候选、终态”顺序重放。下一步 T-208 扩展为可训练
|
||||
的逐候选结构化人工理由与修订历史。
|
||||
|
||||
## 2026-07-28 已授权订单 dry-run
|
||||
|
||||
- 类型:阶段完成
|
||||
- 内容:完成 T-217;后端新增 v10 dry-run/幂等请求与 start/ready,Android
|
||||
`1.4.14 (19)` 自动重新图片搜索、唯一定位候选、选择 SKU/数量并在确认订单页只读
|
||||
核验。拼多多结算 WebView 折叠时使用 bundled 中文 OCR fail-closed 提取证据。
|
||||
- 验证:PKG110/拼多多 8.17 真机已生成后端 READY、受控截图、SKU、数量和 14.35 元
|
||||
单价/总额,App 显示“订单已核验,等待提交”;未点击最终提交或支付。Go
|
||||
test/race/vet、Android Debug/Release 和根 `init.ps1` 均通过。
|
||||
- 影响:T-218 可只消费同一 READY 授权执行一次提交与订单列表对账;T-217 不创建
|
||||
平台订单,T-219 之前仍不提供付款动作。
|
||||
|
||||
Reference in New Issue
Block a user