feat(t218): reconcile single order submissions

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