feat(t218): reconcile single order submissions
This commit is contained in:
@@ -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
|
||||
|
||||
+41
@@ -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) {
|
||||
|
||||
+143
-1
@@ -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}$")
|
||||
}
|
||||
}
|
||||
|
||||
+70
@@ -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
|
||||
}
|
||||
}
|
||||
+348
@@ -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
|
||||
}
|
||||
+150
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+194
-1
@@ -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? =
|
||||
|
||||
+21
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
+125
-2
@@ -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,
|
||||
|
||||
+483
-7
@@ -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",
|
||||
|
||||
+82
@@ -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)
|
||||
|
||||
+18
-2
@@ -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 -> "授权到期,已停止"
|
||||
}
|
||||
|
||||
+186
@@ -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
|
||||
)
|
||||
}
|
||||
+62
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
@@ -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。
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
| 执行方式 | 采购人员使用 Android App 操作拼多多;需求提取、动态词搜索、最多 5 个候选证据采集、匹配建议和人工确认停止点已可运行。 |
|
||||
| 核心痛点 | 人工把图片和描述转成搜索词、逐条比较商品并记录结果,耗时且不一致。 |
|
||||
| 验证范围 | 一台设备、一个管理身份、一个采购执行人员、拼多多单平台。 |
|
||||
| 资金边界 | MVP 不提交订单、不支付,只验证到人工确认位置。 |
|
||||
| 资金边界 | 仅在 Admin 明确授权和一次性围栏后创建待付款订单;系统不自动付款。 |
|
||||
|
||||
## 二、用户角色
|
||||
|
||||
|
||||
@@ -650,6 +650,9 @@ local READY + backend READY + valid lease
|
||||
submission fence 是 exactly-once 安全边界,不是“点击成功”回执。服务端创建围栏后,
|
||||
任何进程恢复都假定订单可能已创建;宁可留下需要人工对账的未提交意图,也不能通过
|
||||
重按产生重复订单。一个 authorization 和 dry-run 在数据库中只能对应一个 submission。
|
||||
只有同一次未中断调用中新建本地 intent 并收到后台 `FENCED` 响应时才短暂持有点击
|
||||
资格;磁盘恢复出的 intent 只能重放后台请求并进入对账。围栏已经存在后,即使 claim
|
||||
租约到期也只继续对账,不恢复商品操作或提交资格。
|
||||
|
||||
不可逆动作前再次核对 T-217 全部商品字段,并要求明确可见的已配置地址和唯一
|
||||
“提交订单”节点。地址缺失、金额变化、自动扣款方式或“立即支付/确认支付/免密支付/
|
||||
|
||||
@@ -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 登录
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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`,未点击下单或付款。因没有受控收货地址,
|
||||
未执行真实订单提交。
|
||||
|
||||
Reference in New Issue
Block a user