feat(t217): verify authorized order dry runs

This commit is contained in:
QiuSW
2026-07-28 16:52:29 +08:00
parent 913107c28d
commit 1e87b6273a
45 changed files with 4358 additions and 128 deletions
+5 -2
View File
@@ -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")
@@ -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
}
@@ -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*$")
}
@@ -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()
@@ -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()
}
@@ -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))
@@ -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
}
@@ -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
}
}
@@ -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}$")
}
}
@@ -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
}
@@ -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
@@ -43,7 +43,8 @@ object OrderCommandIntegrity {
) { "后台订单命令指纹无效" }
require(
command.authorizationStatus == "DELIVERED" ||
command.authorizationStatus == "ACKNOWLEDGED"
command.authorizationStatus == "ACKNOWLEDGED" ||
command.authorizationStatus == "EXECUTING"
) { "后台订单命令状态无效" }
require(sha256(command) == command.commandSha256) {
"后台订单命令完整性校验失败"
@@ -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 {
@@ -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)
}
}
}
@@ -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)
@@ -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
@@ -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,
@@ -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",
@@ -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)
@@ -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 -> "授权到期,已停止"
}
@@ -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轻奢小众夏季气质新款百搭漂亮上衣出片"
}
}
@@ -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
@@ -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)
}
}
@@ -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
)
}
@@ -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)
}
}
@@ -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
)
}
@@ -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",
+5
View File
@@ -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 {
+27
View File
@@ -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);
+2 -2
View File
@@ -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。
+5
View File
@@ -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 并引用
+1 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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 之前仍不提供付款动作。