Compare commits
34
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
095dbfacbf | ||
|
|
cdcb6930d0 | ||
|
|
d84f6ddf21 | ||
|
|
f40331189b | ||
|
|
2895f3d72d | ||
|
|
e4051ed8df | ||
|
|
5859a819c8 | ||
|
|
8d2e61bb72 | ||
|
|
96cc2ab700 | ||
|
|
bd386cfae4 | ||
|
|
c09ddc8474 | ||
|
|
683aa9c671 | ||
|
|
1ac50064bd | ||
|
|
8c76e0932b | ||
|
|
22755e05e3 | ||
|
|
494969d4f8 | ||
|
|
9e800ce023 | ||
|
|
ac6a57e4f3 | ||
|
|
fd013c634b | ||
|
|
57eaad8414 | ||
|
|
13b1580fd5 | ||
|
|
043d71da64 | ||
|
|
58e058f8ac | ||
|
|
2d6d244d8a | ||
|
|
73cb94e073 | ||
|
|
bd4bc5f4f0 | ||
|
|
415d1ff2ad | ||
|
|
a8e01b9809 | ||
|
|
74f55f43cc | ||
|
|
2c263e687e | ||
|
|
13164adde0 | ||
|
|
379a83fe94 | ||
|
|
fc28632d48 | ||
|
|
e85f237f09 |
@@ -11,8 +11,8 @@ android {
|
||||
applicationId = "cn.ilapage.goauto.agent"
|
||||
minSdk = 23
|
||||
targetSdk = 34
|
||||
versionCode = 52
|
||||
versionName = "0.9.39"
|
||||
versionCode = 59
|
||||
versionName = "0.9.46"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
|
||||
|
||||
@@ -49,6 +49,7 @@ class MainActivity : AppCompatActivity() {
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
cn.ilapage.goauto.agent.automation.GoAutoAccessibilityService.instance?.dismissPurchaseResultBubble()
|
||||
screenPolicyHandler.removeCallbacks(screenPolicyRefresh)
|
||||
screenPolicyHandler.post(screenPolicyRefresh)
|
||||
}
|
||||
|
||||
+68
-29
@@ -18,6 +18,8 @@ import android.util.Log
|
||||
import android.view.Display
|
||||
import android.view.accessibility.AccessibilityEvent
|
||||
import android.view.accessibility.AccessibilityNodeInfo
|
||||
import cn.ilapage.goauto.agent.ui.PurchaseResultBubbleController
|
||||
import cn.ilapage.goauto.agent.ui.PurchaseResultBubblePresentation
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
@@ -32,6 +34,9 @@ class GoAutoAccessibilityService : AccessibilityService(), UiDriver, PddCollecto
|
||||
ActivityEvidenceTracker { packageName, className -> isDeclaredActivity(packageName, className) }
|
||||
}
|
||||
private var accessibilityButtonCallback: AccessibilityButtonController.AccessibilityButtonCallback? = null
|
||||
private val purchaseResultBubble by lazy {
|
||||
PurchaseResultBubbleController(this) { currentPackage() == PDD_PACKAGE }
|
||||
}
|
||||
|
||||
override fun onServiceConnected() {
|
||||
serviceInfo = serviceInfo.apply {
|
||||
@@ -48,18 +53,19 @@ class GoAutoAccessibilityService : AccessibilityService(), UiDriver, PddCollecto
|
||||
|
||||
override fun onAccessibilityEvent(event: AccessibilityEvent?) {
|
||||
if (event?.eventType == AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED) {
|
||||
val packageName = event.packageName?.toString()
|
||||
if (packageName != null && packageName != lastForegroundPackage) {
|
||||
lastForegroundPackage = packageName
|
||||
val foregroundPackage = event.packageName?.toString()
|
||||
if (foregroundPackage != null && foregroundPackage != lastForegroundPackage) {
|
||||
lastForegroundPackage = foregroundPackage
|
||||
foregroundRevision.incrementAndGet()
|
||||
}
|
||||
if (packageName == PDD_PACKAGE) lastPddForegroundAt.set(SystemClock.elapsedRealtime())
|
||||
activityTracker.observe(packageName, event.className?.toString())
|
||||
if (foregroundPackage == PDD_PACKAGE) lastPddForegroundAt.set(SystemClock.elapsedRealtime())
|
||||
activityTracker.observe(foregroundPackage, event.className?.toString())
|
||||
}
|
||||
}
|
||||
override fun onInterrupt() = Unit
|
||||
|
||||
override fun onDestroy() {
|
||||
dismissPurchaseResultBubble()
|
||||
unregisterAccessibilityButton()
|
||||
if (instance === this) instance = null
|
||||
super.onDestroy()
|
||||
@@ -71,6 +77,7 @@ class GoAutoAccessibilityService : AccessibilityService(), UiDriver, PddCollecto
|
||||
runCatching {
|
||||
val callback = object : AccessibilityButtonController.AccessibilityButtonCallback() {
|
||||
override fun onClicked(controller: AccessibilityButtonController) {
|
||||
dismissPurchaseResultBubble()
|
||||
AccessibilityButtonPolicy.handleClick(::openAgentPreservingTab)
|
||||
}
|
||||
}
|
||||
@@ -115,6 +122,7 @@ class GoAutoAccessibilityService : AccessibilityService(), UiDriver, PddCollecto
|
||||
).restore(packageName, timeoutMillis)
|
||||
|
||||
fun openAgentPreservingTab(): Boolean = runCatching {
|
||||
dismissPurchaseResultBubble()
|
||||
startActivity(
|
||||
android.content.Intent(this, cn.ilapage.goauto.agent.MainActivity::class.java).apply {
|
||||
addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK or android.content.Intent.FLAG_ACTIVITY_CLEAR_TOP or android.content.Intent.FLAG_ACTIVITY_SINGLE_TOP)
|
||||
@@ -123,6 +131,14 @@ class GoAutoAccessibilityService : AccessibilityService(), UiDriver, PddCollecto
|
||||
true
|
||||
}.getOrDefault(false)
|
||||
|
||||
fun showPurchaseResultBubble(presentation: PurchaseResultBubblePresentation) {
|
||||
purchaseResultBubble.show(presentation)
|
||||
}
|
||||
|
||||
fun dismissPurchaseResultBubble() {
|
||||
purchaseResultBubble.dismiss()
|
||||
}
|
||||
|
||||
override fun visibleTexts(): List<String> {
|
||||
val root = rootInActiveWindow ?: return emptyList()
|
||||
val texts = mutableListOf<String>()
|
||||
@@ -333,31 +349,32 @@ class GoAutoAccessibilityService : AccessibilityService(), UiDriver, PddCollecto
|
||||
if (bounds.width() < 2 || bounds.height() < 2 || Build.VERSION.SDK_INT < Build.VERSION_CODES.N) {
|
||||
return FreshActionResult.FAILED
|
||||
}
|
||||
val path = Path().apply {
|
||||
moveTo(bounds.centerX().toFloat(), bounds.centerY().toFloat())
|
||||
}
|
||||
val completed = AtomicBoolean(false)
|
||||
val latch = CountDownLatch(1)
|
||||
val queued = dispatchGesture(
|
||||
GestureDescription.Builder().addStroke(GestureDescription.StrokeDescription(path, 0, 80)).build(),
|
||||
object : GestureResultCallback() {
|
||||
override fun onCompleted(gestureDescription: GestureDescription?) {
|
||||
completed.set(true)
|
||||
latch.countDown()
|
||||
}
|
||||
return if (dispatchCenterTap(bounds)) FreshActionResult.SUCCESS else FreshActionResult.FAILED
|
||||
}
|
||||
|
||||
override fun onCancelled(gestureDescription: GestureDescription?) {
|
||||
latch.countDown()
|
||||
}
|
||||
},
|
||||
null,
|
||||
)
|
||||
if (!queued) return FreshActionResult.FAILED
|
||||
return if (latch.await(1_500, TimeUnit.MILLISECONDS) && completed.get()) {
|
||||
FreshActionResult.SUCCESS
|
||||
} else {
|
||||
FreshActionResult.FAILED
|
||||
override fun tapSpecFresh(target: SnapshotNode): FreshActionResult {
|
||||
val width = resources.displayMetrics.widthPixels
|
||||
val height = resources.displayMetrics.heightPixels
|
||||
if (!PurchaseSpecGesturePolicy.isAllowed(target, width, height)) return FreshActionResult.FAILED
|
||||
val root = rootInActiveWindow ?: return FreshActionResult.NOT_FOUND
|
||||
val candidates = mutableListOf<AccessibilityNodeInfo>()
|
||||
walk(root) { node ->
|
||||
val bounds = Rect().also(node::getBoundsInScreen)
|
||||
if (node.isVisibleToUser && node.isEnabled &&
|
||||
node.preferredOrDescendantLabel() == target.label &&
|
||||
node.className?.toString() == target.className &&
|
||||
kotlin.math.abs(bounds.centerX() - target.bounds.centerX) <= 32 &&
|
||||
kotlin.math.abs(bounds.centerY() - target.bounds.centerY) <= 32
|
||||
) candidates += node
|
||||
}
|
||||
if (candidates.isEmpty()) return FreshActionResult.NOT_FOUND
|
||||
if (candidates.size != 1) return FreshActionResult.AMBIGUOUS
|
||||
val bounds = Rect().also(candidates.single()::getBoundsInScreen)
|
||||
val freshTarget = target.copy(bounds = NodeBounds(bounds.left, bounds.top, bounds.right, bounds.bottom))
|
||||
if (!PurchaseSpecGesturePolicy.isAllowed(freshTarget, width, height)) {
|
||||
return FreshActionResult.FAILED
|
||||
}
|
||||
return if (dispatchCenterTap(bounds)) FreshActionResult.SUCCESS else FreshActionResult.FAILED
|
||||
}
|
||||
|
||||
override fun inputFresh(target: SnapshotNode, value: String): FreshActionResult {
|
||||
@@ -407,7 +424,7 @@ class GoAutoAccessibilityService : AccessibilityService(), UiDriver, PddCollecto
|
||||
) matches += node
|
||||
}
|
||||
if (matches.size != 1) return false
|
||||
return swipeNode(matches.single(), direction, durationMs, preferScrollAction = false)
|
||||
return swipeNode(matches.single(), direction, durationMs, preferScrollAction = true)
|
||||
}
|
||||
|
||||
override fun backPurchase(): Boolean = performGlobalAction(GLOBAL_ACTION_BACK)
|
||||
@@ -617,6 +634,28 @@ class GoAutoAccessibilityService : AccessibilityService(), UiDriver, PddCollecto
|
||||
return latch.await(1500, TimeUnit.MILLISECONDS) && completed.get()
|
||||
}
|
||||
|
||||
private fun dispatchCenterTap(bounds: Rect): Boolean {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N || bounds.width() < 2 || bounds.height() < 2) return false
|
||||
val path = Path().apply { moveTo(bounds.centerX().toFloat(), bounds.centerY().toFloat()) }
|
||||
val completed = AtomicBoolean(false)
|
||||
val latch = CountDownLatch(1)
|
||||
val queued = dispatchGesture(
|
||||
GestureDescription.Builder().addStroke(GestureDescription.StrokeDescription(path, 0, 80)).build(),
|
||||
object : GestureResultCallback() {
|
||||
override fun onCompleted(gestureDescription: GestureDescription?) {
|
||||
completed.set(true)
|
||||
latch.countDown()
|
||||
}
|
||||
|
||||
override fun onCancelled(gestureDescription: GestureDescription?) {
|
||||
latch.countDown()
|
||||
}
|
||||
},
|
||||
null,
|
||||
)
|
||||
return queued && latch.await(1_500, TimeUnit.MILLISECONDS) && completed.get()
|
||||
}
|
||||
|
||||
private fun walk(node: AccessibilityNodeInfo, visit: (AccessibilityNodeInfo) -> Unit) {
|
||||
visit(node)
|
||||
for (index in 0 until node.childCount) node.getChild(index)?.let { walk(it, visit) }
|
||||
|
||||
+69
-12
@@ -123,6 +123,9 @@ data class ParsedPddScreen(
|
||||
val hasSelectionSummary: Boolean,
|
||||
val hasQuantityControls: Boolean,
|
||||
val hasOrderSubmitAction: Boolean,
|
||||
val explicitSpecEntryCount: Int,
|
||||
val nestedSpecEntryCount: Int,
|
||||
val bottomPurchaseEntryCount: Int,
|
||||
val problem: PageProblem?,
|
||||
val sourceNodes: List<SnapshotNode>,
|
||||
) {
|
||||
@@ -306,6 +309,14 @@ object PddScreenParser {
|
||||
orderConfirmationEvidence -> SpecPanelType.ORDER_CONFIRMATION
|
||||
panelScrollable != null && (hasSelectionSummary || hasSubmitHint || (hasPanelTitle && hasPanelAction)) -> SpecPanelType.NORMAL_SCROLLABLE
|
||||
hasSelectionSummary && hasPanelTitle && hasPanelAction -> SpecPanelType.NON_SCROLLABLE_CONFIRMATION
|
||||
// Some PDD builds expose the complete selector as non-scrollable
|
||||
// before a value is selected, so an "已选" summary is absent. Two
|
||||
// parsed dimensions with selectable values plus the unique quantity
|
||||
// controls and lower-page order action remain required; generic
|
||||
// product/review pages cannot satisfy this combined evidence.
|
||||
panelScrollable == null && headings.size >= 2 && dimensions.size >= 2 &&
|
||||
dimensions.sumOf { it.values.size } >= 2 &&
|
||||
hasQuantityControls && hasOrderSubmitAction -> SpecPanelType.NON_SCROLLABLE_CONFIRMATION
|
||||
else -> SpecPanelType.UNKNOWN
|
||||
}
|
||||
val panelOpen = specPanelType != SpecPanelType.UNKNOWN
|
||||
@@ -314,19 +325,30 @@ object PddScreenParser {
|
||||
.filter { it.bounds.top < firstHeadingTop }
|
||||
.mapNotNull { node -> pricePattern.find(node.label)?.groupValues?.get(1)?.let(::priceCent) }
|
||||
.firstOrNull()
|
||||
val explicitSpecEntry = if (panelOpen) null else visible
|
||||
val explicitSpecEntries = if (panelOpen) emptyList() else visible
|
||||
.filter { it.clickable && isSpecEntry(it.label, config) && !hasReviewContext(it, visibleNodes, config) }
|
||||
.maxByOrNull { it.bounds.top }
|
||||
val bottomSpecEntry = if (panelOpen || explicitSpecEntry != null) null else safeBottomSpecEntry(visibleNodes, visible, config)
|
||||
val candidateSpecEntry = explicitSpecEntry ?: bottomSpecEntry?.anchor
|
||||
val candidateClickTarget = explicitSpecEntry ?: bottomSpecEntry?.clickTarget
|
||||
val explicitSpecEntry = explicitSpecEntries.maxByOrNull { it.bounds.top }
|
||||
val nestedSpecEntries = if (panelOpen || explicitSpecEntry != null) emptyList() else
|
||||
safeNestedSpecEntries(visibleNodes, visible, config)
|
||||
// A nested selection row is accepted only when it is the single safe
|
||||
// candidate. This covers PDD layouts that split “请选择” and the
|
||||
// dimension name across child nodes of one clickable parent, without
|
||||
// turning arbitrary page text into a click target.
|
||||
val nestedSpecEntry = nestedSpecEntries.singleOrNull()
|
||||
val bottomSpecEntries = if (panelOpen || explicitSpecEntry != null || nestedSpecEntry != null) emptyList() else
|
||||
safeBottomSpecEntries(visibleNodes, visible, config)
|
||||
val bottomSpecEntry = bottomSpecEntries.firstOrNull()
|
||||
val candidateSpecEntry = explicitSpecEntry ?: nestedSpecEntry?.anchor ?: bottomSpecEntry?.anchor
|
||||
val candidateClickTarget = explicitSpecEntry ?: nestedSpecEntry?.clickTarget ?: bottomSpecEntry?.clickTarget
|
||||
val reviewPageOpen = isReviewPage(visibleNodes, visible, screenHeight, candidateSpecEntry, config)
|
||||
val specEntry = candidateSpecEntry.takeUnless { reviewPageOpen }
|
||||
val specEntryClickTarget = candidateClickTarget.takeUnless { reviewPageOpen }
|
||||
val specEntrySource = when {
|
||||
reviewPageOpen -> null
|
||||
explicitSpecEntry != null -> "explicit_selection"
|
||||
bottomSpecEntry != null -> "bottom_purchase"
|
||||
nestedSpecEntry != null -> "nested_selection"
|
||||
bottomSpecEntries.size == 1 -> "bottom_purchase"
|
||||
bottomSpecEntry != null -> "bottom_purchase_rightmost"
|
||||
else -> null
|
||||
}
|
||||
val quickConfirmationEntry = if (quickConfirmationEvidence) quickConfirmationSpecEntry(visibleNodes, visible, config) else null
|
||||
@@ -370,6 +392,9 @@ object PddScreenParser {
|
||||
hasSelectionSummary = hasSelectionSummary,
|
||||
hasQuantityControls = hasQuantityControls,
|
||||
hasOrderSubmitAction = hasOrderSubmitAction,
|
||||
explicitSpecEntryCount = explicitSpecEntries.size,
|
||||
nestedSpecEntryCount = nestedSpecEntries.size,
|
||||
bottomPurchaseEntryCount = bottomSpecEntries.size,
|
||||
problem = problem,
|
||||
sourceNodes = visibleNodes,
|
||||
)
|
||||
@@ -458,10 +483,40 @@ object PddScreenParser {
|
||||
return hasSpecWord && config.textAliases.selection.specEntryPrefixes.any(compact::startsWith)
|
||||
}
|
||||
|
||||
private fun safeBottomSpecEntry(source: List<SnapshotNode>, visible: List<SnapshotNode>, config: PddCollectorConfig): SafeSpecEntry? {
|
||||
val screenWidth = source.maxOfOrNull { it.bounds.right } ?: return null
|
||||
val screenHeight = source.maxOfOrNull { it.bounds.bottom } ?: return null
|
||||
if (screenWidth <= 0 || screenHeight <= 0) return null
|
||||
private fun safeNestedSpecEntries(source: List<SnapshotNode>, visible: List<SnapshotNode>, config: PddCollectorConfig): List<SafeSpecEntry> {
|
||||
val screenHeight = source.maxOfOrNull { it.bounds.bottom } ?: return emptyList()
|
||||
if (screenHeight <= 0) return emptyList()
|
||||
return source.asSequence()
|
||||
.filter { it.visible && it.enabled && it.clickable && it.bounds.width > 0 && it.bounds.height > 0 }
|
||||
// The fixed purchase bar begins at the lower fifth of the screen.
|
||||
// A specs row has no reason to be inside that action-only zone.
|
||||
.filter { it.bounds.centerY.toDouble() < screenHeight * 0.8 }
|
||||
.mapNotNull { candidate ->
|
||||
val context = (listOf(candidate.label) + descendants(candidate, source).map(SnapshotNode::label))
|
||||
.joinToString("") { it.replace(Regex("\\s+"), "") }
|
||||
if (!isSpecEntryContext(context, config) ||
|
||||
nonConfigurableClickDenylist.any(context::contains) ||
|
||||
hasReviewContext(candidate, source, config)
|
||||
) return@mapNotNull null
|
||||
val anchor = visible.firstOrNull { it.path == candidate.path } ?: candidate
|
||||
SafeSpecEntry(anchor, candidate)
|
||||
}
|
||||
.distinctBy { it.clickTarget.path }
|
||||
.toList()
|
||||
}
|
||||
|
||||
private fun isSpecEntryContext(compact: String, config: PddCollectorConfig): Boolean {
|
||||
if (config.textAliases.review.entryAliases.any(compact::contains)) return false
|
||||
val hasSpecWord = (config.colorAliases + config.sizeAliases +
|
||||
config.textAliases.dimension.exactNames + config.textAliases.dimension.adaptiveAliases)
|
||||
.any(compact::contains)
|
||||
return hasSpecWord && config.textAliases.selection.specEntryPrefixes.any(compact::contains)
|
||||
}
|
||||
|
||||
private fun safeBottomSpecEntries(source: List<SnapshotNode>, visible: List<SnapshotNode>, config: PddCollectorConfig): List<SafeSpecEntry> {
|
||||
val screenWidth = source.maxOfOrNull { it.bounds.right } ?: return emptyList()
|
||||
val screenHeight = source.maxOfOrNull { it.bounds.bottom } ?: return emptyList()
|
||||
if (screenWidth <= 0 || screenHeight <= 0) return emptyList()
|
||||
val byPath = source.associateBy(SnapshotNode::path)
|
||||
val normalizedByPath = visible.associateBy(SnapshotNode::path)
|
||||
val buyWords = config.textAliases.purchase.buyWords
|
||||
@@ -485,8 +540,10 @@ object PddScreenParser {
|
||||
val normalizedTarget = normalizedByPath[clickTarget.path] ?: return@mapNotNull null
|
||||
SafeSpecEntry(normalizedAnchor, normalizedTarget) to clickTarget.bounds.width.toLong() * clickTarget.bounds.height
|
||||
}
|
||||
.minWithOrNull(compareBy<Pair<SafeSpecEntry, Long>> { it.second }.thenByDescending { it.first.clickTarget.bounds.centerX })
|
||||
?.first
|
||||
.sortedWith(compareByDescending<Pair<SafeSpecEntry, Long>> { it.first.clickTarget.bounds.centerX }.thenBy { it.second })
|
||||
.map { it.first }
|
||||
.distinctBy { it.clickTarget.path }
|
||||
.toList()
|
||||
}
|
||||
|
||||
private fun hasReviewContext(node: SnapshotNode, source: List<SnapshotNode>, config: PddCollectorConfig): Boolean {
|
||||
|
||||
+99
-5
@@ -36,6 +36,49 @@ class PurchaseLiveAutomation(
|
||||
private var submitAttempted = false
|
||||
var lastOrderReadFailure: PurchaseOrderReadFailure? = null
|
||||
private set
|
||||
|
||||
/**
|
||||
* Advances an already verified spec selector to the order confirmation
|
||||
* page. Only the selector's unique exact confirm button is clickable; an
|
||||
* order-submit or payment control can never satisfy this transition.
|
||||
*/
|
||||
fun advanceToOrderConfirmation() {
|
||||
var snapshot = driver.capture()
|
||||
pageProblem(snapshot)
|
||||
if (orderConfirmationReady(snapshot)) return
|
||||
if (snapshot.packageName != PDD_PACKAGE) {
|
||||
fail("PURCHASE_SPEC_CONFIRMATION_NOT_READY", "当前不是拼多多规格页面,未创建订单")
|
||||
}
|
||||
val screen = PddScreenParser.parse(snapshot, PurchaseRehearsalExecutor.DEFAULT_COLLECTOR, "", null)
|
||||
if (screen.specPanelType !in setOf(SpecPanelType.NORMAL_SCROLLABLE, SpecPanelType.NON_SCROLLABLE_CONFIRMATION)) {
|
||||
fail("PURCHASE_SPEC_CONFIRMATION_NOT_READY", "当前规格面板不能安全确认,未创建订单")
|
||||
}
|
||||
val aliases = PurchaseRehearsalExecutor.DEFAULT_COLLECTOR.textAliases.specPanel.confirmAliases
|
||||
.map { it.replace(" ", "") }
|
||||
.toSet()
|
||||
val targets = snapshot.nodes.filter { node ->
|
||||
node.visible && node.enabled && node.clickable && node.label.replace(" ", "") in aliases
|
||||
}.distinctBy { it.path }
|
||||
if (targets.isEmpty()) {
|
||||
fail("PURCHASE_SPEC_CONFIRM_TARGET_MISSING", "没有找到唯一的规格确认按钮,未创建订单")
|
||||
}
|
||||
if (targets.size > 1) {
|
||||
fail("PURCHASE_SPEC_CONFIRM_TARGET_AMBIGUOUS", "规格确认按钮不唯一,未创建订单")
|
||||
}
|
||||
when (driver.clickFresh(targets.single())) {
|
||||
FreshActionResult.SUCCESS -> Unit
|
||||
FreshActionResult.AMBIGUOUS -> fail("PURCHASE_SPEC_CONFIRM_TARGET_AMBIGUOUS", "规格确认按钮不唯一,未创建订单")
|
||||
else -> fail("PURCHASE_SPEC_CONFIRM_CLICK_FAILED", "规格确认按钮点击失败,未创建订单")
|
||||
}
|
||||
repeat(SPEC_CONFIRMATION_MAX_SAMPLES) {
|
||||
pause(SPEC_CONFIRMATION_SAMPLE_INTERVAL_MS)
|
||||
snapshot = driver.capture()
|
||||
pageProblem(snapshot)
|
||||
if (orderConfirmationReady(snapshot)) return
|
||||
}
|
||||
fail("PURCHASE_SPEC_CONFIRMATION_UNCONFIRMED", "规格确认后没有进入订单确认页,未创建订单")
|
||||
}
|
||||
|
||||
fun updateShippingAddress(addressSuffix: String): ShippingAddressProof {
|
||||
if (!addressSuffix.matches(Regex("^_cg[1-9][0-9]*$"))) fail("PURCHASE_ADDRESS_UPDATE_FAILED", "采购任务的地址标记无效")
|
||||
var snapshot = driver.capture()
|
||||
@@ -117,10 +160,12 @@ class PurchaseLiveAutomation(
|
||||
return null
|
||||
}
|
||||
val labels = linkedSetOf<String>()
|
||||
var backedOutOfPayment = false
|
||||
var paymentBackAttempts = 0
|
||||
var consecutivePaymentSamplesAfterBack = 0
|
||||
var backedOutOfChooser = false
|
||||
var restoredFromWechat = false
|
||||
var pddObservedAfterWechatRestore = false
|
||||
var orderDetailEntryOpened = false
|
||||
var wechatRestorePendingSamples = 0
|
||||
var consecutiveEmptySnapshots = 0
|
||||
repeat(ORDER_RESULT_MAX_SAMPLES) { index ->
|
||||
@@ -162,22 +207,52 @@ class PurchaseLiveAutomation(
|
||||
return unknown("PURCHASE_ORDER_UNEXPECTED_APP", "核单期间出现未授权应用")
|
||||
}
|
||||
if (restoredFromWechat) pddObservedAfterWechatRestore = true
|
||||
val paymentVisible = isKnownPddPaymentActivity(snapshot)
|
||||
val orderContextVisible = currentLabels.any { label -> ORDER_CONTEXT_MARKERS.any(label::contains) }
|
||||
val unpaidContextVisible = currentLabels.any { label -> UNPAID_MARKERS.any(label::contains) }
|
||||
// PDD can reuse its payment Activity for a read-only unpaid order page.
|
||||
// Visible order-result evidence takes precedence over the Activity name:
|
||||
// it permits only bounded reading gestures below, never a payment click.
|
||||
val paymentVisible = isKnownPddPaymentActivity(snapshot) && !orderContextVisible && !unpaidContextVisible
|
||||
if (paymentVisible) {
|
||||
if (!backedOutOfPayment) {
|
||||
backedOutOfPayment = true
|
||||
if (paymentBackAttempts == 0) {
|
||||
paymentBackAttempts++
|
||||
if (!driver.backPurchase()) {
|
||||
return unknown("PURCHASE_ORDER_PAYMENT_BACK_FAILED", "支付页无法安全返回订单详情")
|
||||
}
|
||||
pause(500)
|
||||
} else {
|
||||
return unknown("PURCHASE_ORDER_PAYMENT_REPEATED", "支付页重复出现,已停止自动核单")
|
||||
consecutivePaymentSamplesAfterBack++
|
||||
if (consecutivePaymentSamplesAfterBack >= ORDER_RESULT_PAYMENT_POST_BACK_MAX_SAMPLES) {
|
||||
return unknown(
|
||||
"PURCHASE_ORDER_PAYMENT_REPEATED",
|
||||
"支付页安全返回后持续无订单证据,已停止自动核单" +
|
||||
"[paymentBackAttempts=$paymentBackAttempts;" +
|
||||
"consecutivePaymentSamplesAfterBack=$consecutivePaymentSamplesAfterBack]",
|
||||
)
|
||||
}
|
||||
pause(ORDER_RESULT_SAMPLE_INTERVAL_MS)
|
||||
}
|
||||
return@repeat
|
||||
}
|
||||
consecutivePaymentSamplesAfterBack = 0
|
||||
if (!orderContextVisible && !unpaidContextVisible) {
|
||||
val entries = orderDetailEntryTargets(snapshot)
|
||||
if (entries.size > 1) {
|
||||
return unknown("PURCHASE_ORDER_DETAIL_ENTRY_AMBIGUOUS", "订单详情入口不唯一,已停止只读核单")
|
||||
}
|
||||
if (entries.size == 1) {
|
||||
if (orderDetailEntryOpened) {
|
||||
return unknown("PURCHASE_ORDER_DETAIL_ENTRY_TIMEOUT", "进入订单详情后页面未出现可验证证据")
|
||||
}
|
||||
when (driver.clickFresh(entries.single())) {
|
||||
FreshActionResult.SUCCESS -> Unit
|
||||
FreshActionResult.AMBIGUOUS -> return unknown("PURCHASE_ORDER_DETAIL_ENTRY_AMBIGUOUS", "订单详情入口不唯一,已停止只读核单")
|
||||
else -> return unknown("PURCHASE_ORDER_DETAIL_ENTRY_FAILED", "订单详情入口点击失败,已停止只读核单")
|
||||
}
|
||||
orderDetailEntryOpened = true
|
||||
pause(500)
|
||||
return@repeat
|
||||
}
|
||||
pause(ORDER_RESULT_SAMPLE_INTERVAL_MS)
|
||||
return@repeat
|
||||
}
|
||||
@@ -375,6 +450,21 @@ class PurchaseLiveAutomation(
|
||||
snapshot.nodes.filter { node -> node.visible && node.enabled && FINAL_SUBMIT_MARKERS.any { node.label == it || node.label.startsWith(it) } },
|
||||
)
|
||||
|
||||
private fun orderConfirmationReady(snapshot: UiSnapshot): Boolean {
|
||||
if (snapshot.packageName != PDD_PACKAGE) return false
|
||||
if (snapshot.nodes.any { it.visible && it.enabled && MASKED_PHONE.containsMatchIn(it.label) }) return true
|
||||
return PddScreenParser.parse(snapshot, PurchaseRehearsalExecutor.DEFAULT_COLLECTOR, "", null).specPanelType ==
|
||||
SpecPanelType.ORDER_CONFIRMATION
|
||||
}
|
||||
|
||||
/** A post-submit navigation target is allowed only when it is one exact, non-payment PDD order-detail entry. */
|
||||
private fun orderDetailEntryTargets(snapshot: UiSnapshot): List<SnapshotNode> = uniqueClickable(
|
||||
snapshot,
|
||||
snapshot.nodes.filter { node ->
|
||||
node.visible && node.enabled && node.label in ORDER_DETAIL_ENTRY_MARKERS
|
||||
},
|
||||
)
|
||||
|
||||
private fun pageProblem(snapshot: UiSnapshot) {
|
||||
val labels = snapshot.nodes.filter { it.visible }.map { it.label }
|
||||
PddPageClassifier.classify(snapshot.packageName, snapshot.activityName, labels)?.let { fail(it.code, it.message) }
|
||||
@@ -425,6 +515,7 @@ class PurchaseLiveAutomation(
|
||||
val FINAL_SUBMIT_MARKERS = listOf("提交订单", "现在买,仅", "确认购买")
|
||||
val PAYMENT_MARKERS = listOf("立即支付", "确认支付", "输入支付密码")
|
||||
val UNPAID_MARKERS = listOf("待付款", "待支付", "去支付")
|
||||
val ORDER_DETAIL_ENTRY_MARKERS = setOf("查看订单", "订单详情")
|
||||
val ORDER_CONTEXT_MARKERS = listOf("订单编号", "订单号", "下单时间", "创建时间")
|
||||
val ORDER_NO = Regex("(?:订单编号|订单号)\\s*[::]?\\s*([A-Za-z0-9-]{6,64})")
|
||||
val ORDER_TIME = Regex("(?:下单时间|创建时间)\\s*[::]?\\s*(20[0-9]{2}[-/.年][0-9]{1,2}[-/.月][0-9]{1,2}日?\\s+[0-9]{1,2}:[0-9]{2}(?::[0-9]{2})?)")
|
||||
@@ -439,7 +530,10 @@ class PurchaseLiveAutomation(
|
||||
const val ORDER_RESULT_MAX_SAMPLES = 60
|
||||
const val ORDER_RESULT_MAX_EMPTY_SAMPLES = 15
|
||||
const val ORDER_RESULT_WECHAT_RESTORE_MAX_SAMPLES = 15
|
||||
const val ORDER_RESULT_PAYMENT_POST_BACK_MAX_SAMPLES = 3
|
||||
const val ORDER_RESULT_SCROLL_SAMPLE_INTERVAL = 15
|
||||
const val ORDER_RESULT_SAMPLE_INTERVAL_MS = 200L
|
||||
const val SPEC_CONFIRMATION_MAX_SAMPLES = 20
|
||||
const val SPEC_CONFIRMATION_SAMPLE_INTERVAL_MS = 100L
|
||||
}
|
||||
}
|
||||
|
||||
+381
-94
@@ -11,6 +11,12 @@ interface PurchaseUiDriver {
|
||||
reason = FreshClickReason.UNKNOWN,
|
||||
)
|
||||
fun tapPurchaseFresh(target: SnapshotNode): FreshActionResult
|
||||
/**
|
||||
* Reacquires and center-taps a target that the purchase parser has already
|
||||
* accepted as either a safe spec entry or one exact spec value. Callers
|
||||
* must still verify the semantic postcondition from a fresh snapshot.
|
||||
*/
|
||||
fun tapSpecFresh(target: SnapshotNode): FreshActionResult = FreshActionResult.FAILED
|
||||
fun inputFresh(target: SnapshotNode, value: String): FreshActionResult
|
||||
fun swipePurchase(direction: SwipeDirection, durationMs: Long): Boolean
|
||||
fun swipePurchaseIn(target: SnapshotNode, direction: SwipeDirection, durationMs: Long): Boolean
|
||||
@@ -20,6 +26,21 @@ interface PurchaseUiDriver {
|
||||
fun bringPddToForeground(): Boolean = false
|
||||
}
|
||||
|
||||
internal object PurchaseSpecGesturePolicy {
|
||||
private val forbiddenLabels = listOf(
|
||||
"提交订单", "创建订单", "确认订单", "确认购买", "支付", "付款",
|
||||
"收货地址", "修改地址", "保存地址",
|
||||
)
|
||||
|
||||
fun isAllowed(target: SnapshotNode, screenWidth: Int, screenHeight: Int): Boolean {
|
||||
val label = target.label.replace(" ", "")
|
||||
return target.visible && target.enabled && label.isNotBlank() &&
|
||||
forbiddenLabels.none(label::contains) &&
|
||||
target.bounds.width >= 2 && target.bounds.height >= 2 &&
|
||||
target.bounds.centerX in 0 until screenWidth && target.bounds.centerY in 0 until screenHeight
|
||||
}
|
||||
}
|
||||
|
||||
data class PurchaseExecutionInput(
|
||||
val taskId: Long,
|
||||
val executionMode: String,
|
||||
@@ -51,6 +72,7 @@ class PurchaseRehearsalExecutor(
|
||||
private val probeSpecs: () -> String?,
|
||||
private val pause: (Long) -> Unit = Thread::sleep,
|
||||
private val stepChanged: (String) -> Unit = {},
|
||||
private val panelDiagnostic: (String) -> Unit = {},
|
||||
private val beforeOrderSubmit: (FinalConfirmationEvidence) -> Unit = { throw PurchaseLiveException("PURCHASE_MODE_NOT_ALLOWED", "当前执行器没有正式采购授权") },
|
||||
) {
|
||||
fun execute(input: PurchaseExecutionInput, rule: PurchaseRule, supportedCapabilities: Set<String>): PurchaseExecutionOutcome {
|
||||
@@ -61,6 +83,10 @@ class PurchaseRehearsalExecutor(
|
||||
val specSelectionProofs = mutableMapOf<String, ExactSpecSelectionProof>()
|
||||
val live = PurchaseLiveAutomation(driver, pause)
|
||||
for (action in rule.actions) {
|
||||
// spec_probe already opened this task's URL and the server reserves
|
||||
// the device while matching. Skip the whole navigation action,
|
||||
// including its configured wait/swipe hooks, in phase two.
|
||||
if (input.phase == "purchase" && action.type == PurchaseActionType.OPEN_PRODUCT) continue
|
||||
stepChanged(action.type.wireName)
|
||||
val failure = when (action.type) {
|
||||
PurchaseActionType.OPEN_PRODUCT -> openProduct(input, action)
|
||||
@@ -79,6 +105,7 @@ class PurchaseRehearsalExecutor(
|
||||
null
|
||||
}
|
||||
PurchaseActionType.UPDATE_SHIPPING_ADDRESS -> try {
|
||||
live.advanceToOrderConfirmation()
|
||||
addressProof = live.updateShippingAddress(input.addressSuffix)
|
||||
null
|
||||
} catch (error: PurchaseLiveException) {
|
||||
@@ -123,7 +150,7 @@ class PurchaseRehearsalExecutor(
|
||||
}
|
||||
return failure
|
||||
}
|
||||
applyPostAction(action)?.let { return it }
|
||||
applyPostAction(input, action)?.let { return it }
|
||||
}
|
||||
if (input.phase == "spec_probe") return failure("PURCHASE_RULE_INVALID", "规格探测任务缺少 probeSpecs 动作")
|
||||
return if (input.executionMode == "rehearsal") PurchaseExecutionOutcome("rehearsal_completed", message = "商品、规格、数量和价格复核完成,已在下单前安全停止", actualUnitPriceCent = observedPrice)
|
||||
@@ -186,10 +213,20 @@ class PurchaseRehearsalExecutor(
|
||||
var clickAttempted = false
|
||||
var nextClickPoll = 0
|
||||
var lastClickReason = FreshClickReason.UNKNOWN
|
||||
var stableEvidenceReads = 0
|
||||
var pddForegroundObserved = false
|
||||
repeat(OPEN_PRODUCT_POLL_LIMIT) { poll ->
|
||||
val snapshot = driver.capture()
|
||||
pageProblem(snapshot)?.let { return it }
|
||||
if (snapshot.packageName == PDD_PACKAGE) return null
|
||||
if (snapshot.packageName == PDD_PACKAGE) {
|
||||
pddForegroundObserved = true
|
||||
val screen = PddScreenParser.parse(snapshot, DEFAULT_COLLECTOR, input.goodsId, null)
|
||||
stableEvidenceReads = if (screen.hasPurchaseProductEvidence()) stableEvidenceReads + 1 else 0
|
||||
if (stableEvidenceReads >= PRODUCT_PAGE_STABLE_READS) return null
|
||||
pause(OPEN_PRODUCT_POLL_MILLIS)
|
||||
return@repeat
|
||||
}
|
||||
stableEvidenceReads = 0
|
||||
val candidates = snapshot.nodes.filter { it.visible && it.enabled && it.label in aliases }
|
||||
if (candidates.size > 1) return failure("RULE_AMBIGUOUS", "打开拼多多按钮不唯一")
|
||||
if (candidates.size == 1 && poll >= nextClickPoll) {
|
||||
@@ -207,6 +244,9 @@ class PurchaseRehearsalExecutor(
|
||||
}
|
||||
pause(OPEN_PRODUCT_POLL_MILLIS)
|
||||
}
|
||||
if (pddForegroundObserved) {
|
||||
return failure("PDD_DETAIL_ENTRY_FAILED", "打开拼多多后未识别到稳定商品页面")
|
||||
}
|
||||
if (clickAttempted) {
|
||||
val message = when (lastClickReason) {
|
||||
FreshClickReason.ROOT_UNAVAILABLE, FreshClickReason.TARGET_NOT_FOUND -> "打开拼多多入口发生变化"
|
||||
@@ -220,13 +260,20 @@ class PurchaseRehearsalExecutor(
|
||||
}
|
||||
|
||||
private fun verifyProduct(input: PurchaseExecutionInput): PurchaseExecutionOutcome? {
|
||||
repeat(50) {
|
||||
var stableEvidenceReads = 0
|
||||
repeat(PRODUCT_PAGE_POLL_LIMIT) {
|
||||
val snapshot = driver.capture()
|
||||
pageProblem(snapshot)?.let { return it }
|
||||
if (snapshot.packageName == PDD_PACKAGE && snapshot.nodes.any { it.visible }) {
|
||||
return recoverSoldOut(input, PddScreenParser.parse(snapshot, DEFAULT_COLLECTOR, input.goodsId, null))
|
||||
val screen = PddScreenParser.parse(snapshot, DEFAULT_COLLECTOR, input.goodsId, null)
|
||||
if (screen.hasPurchaseProductEvidence()) {
|
||||
stableEvidenceReads++
|
||||
if (stableEvidenceReads >= PRODUCT_PAGE_STABLE_READS) {
|
||||
return recoverSoldOut(input, screen)
|
||||
}
|
||||
} else {
|
||||
stableEvidenceReads = 0
|
||||
}
|
||||
pause(100)
|
||||
pause(OPEN_PRODUCT_POLL_MILLIS)
|
||||
}
|
||||
return failure("PDD_DETAIL_ENTRY_FAILED", "没有进入拼多多商品页面")
|
||||
}
|
||||
@@ -265,37 +312,148 @@ class PurchaseRehearsalExecutor(
|
||||
|
||||
private fun openSpecPanel(input: PurchaseExecutionInput, action: PurchaseAction): PurchaseExecutionOutcome? {
|
||||
var screen = currentScreen(input)
|
||||
if (screen.reviewPageOpen) return leaveUnexpectedReviewPage(input)
|
||||
screen.problem?.let { return failure(it.code, it.message) }
|
||||
if (screen.specPanelOpen) return null
|
||||
val safeCandidates = listOfNotNull(
|
||||
screen.specEntry?.let { anchor -> anchor to (screen.specEntryClickTarget ?: anchor) },
|
||||
screen.quickConfirmationEntry?.let { it to it },
|
||||
).distinctBy { it.second.path }
|
||||
// A rule alias may only narrow the semantic candidates already accepted
|
||||
// by PddScreenParser. It must never turn into a raw-page text lookup:
|
||||
// review cards and unrelated controls can share arbitrary labels.
|
||||
val candidates = action.textAliases?.let { aliases ->
|
||||
safeCandidates.filter { (anchor, _) -> specEntryMatchesAliases(screen, anchor, aliases) }
|
||||
} ?: safeCandidates
|
||||
if (candidates.size > 1) return failure(SPEC_ENTRY_TARGET_AMBIGUOUS, "规格入口候选不唯一")
|
||||
val target = candidates.singleOrNull()?.second ?: return failure(SPEC_ENTRY_NOT_FOUND, "没有找到安全的商品规格入口")
|
||||
val click = driver.clickFreshDetailed(target)
|
||||
when (click.result) {
|
||||
FreshActionResult.AMBIGUOUS -> return failure(SPEC_ENTRY_TARGET_AMBIGUOUS, "规格入口候选不唯一")
|
||||
FreshActionResult.SUCCESS -> Unit
|
||||
else -> return failure(SPEC_ENTRY_CLICK_FAILED, click.reason.specEntrySubreason())
|
||||
}
|
||||
repeat(30) {
|
||||
screen = currentScreen(input)
|
||||
var entryReadyWaitPolls = 0
|
||||
var target: SnapshotNode? = null
|
||||
while (target == null) {
|
||||
if (screen.reviewPageOpen) return leaveUnexpectedReviewPage(input)
|
||||
screen.problem?.let { return failure(it.code, it.message) }
|
||||
if (screen.specPanelOpen) return null
|
||||
pause(100)
|
||||
val safeCandidates = listOfNotNull(
|
||||
screen.specEntry?.let { anchor -> anchor to (screen.specEntryClickTarget ?: anchor) },
|
||||
screen.quickConfirmationEntry?.let { it to it },
|
||||
).distinctBy { it.second.path }
|
||||
// A rule alias may only narrow the semantic candidates already accepted
|
||||
// by PddScreenParser. It must never turn into a raw-page text lookup:
|
||||
// review cards and unrelated controls can share arbitrary labels.
|
||||
val candidates = action.textAliases?.let { aliases ->
|
||||
safeCandidates.filter { (anchor, _) -> specEntryMatchesAliases(screen, anchor, aliases) }
|
||||
} ?: safeCandidates
|
||||
if (candidates.size > 1) {
|
||||
panelDiagnostic(specEntryEvidence(screen, candidates.size, entryReadyWaitPolls))
|
||||
return failure(SPEC_ENTRY_TARGET_AMBIGUOUS, "规格入口候选不唯一 [${specEntryEvidence(screen, candidates.size, entryReadyWaitPolls)}]")
|
||||
}
|
||||
target = candidates.singleOrNull()?.second
|
||||
if (target != null) continue
|
||||
if (entryReadyWaitPolls >= SPEC_ENTRY_READY_WAIT_POLLS) {
|
||||
panelDiagnostic(specEntryEvidence(screen, 0, entryReadyWaitPolls))
|
||||
return failure(SPEC_ENTRY_NOT_FOUND, "没有找到安全的商品规格入口 [${specEntryEvidence(screen, 0, entryReadyWaitPolls)}]")
|
||||
}
|
||||
pause(SPEC_ENTRY_READY_POLL_MILLIS)
|
||||
entryReadyWaitPolls++
|
||||
screen = currentScreen(input)
|
||||
}
|
||||
return failure(SPEC_PANEL_NOT_OPENED, "点击后未识别到商品规格面板")
|
||||
val beforeSignature = specActionSignature(screen)
|
||||
val click = driver.clickFreshDetailed(requireNotNull(target))
|
||||
when (click.result) {
|
||||
// The parser already narrowed to a single semantic candidate; the
|
||||
// ambiguity here comes from the live tree matching that target more
|
||||
// than once at click time, so the wording must not claim otherwise.
|
||||
FreshActionResult.AMBIGUOUS -> return failure(
|
||||
SPEC_ENTRY_TARGET_AMBIGUOUS,
|
||||
"规格入口点击目标不唯一 [${specEntryEvidence(screen, 1, entryReadyWaitPolls)}]",
|
||||
)
|
||||
else -> Unit
|
||||
}
|
||||
|
||||
var wait = waitForSpecPanel(input, beforeSignature)
|
||||
wait.failure?.let { return it }
|
||||
if (wait.opened) return null
|
||||
if (wait.changed) {
|
||||
return failure(
|
||||
SPEC_PANEL_EVIDENCE_NOT_MATCHED,
|
||||
"规格入口点击后页面已变化,但规格面板强证据不足 [${panelEvidence(wait.screen)}]",
|
||||
)
|
||||
}
|
||||
|
||||
when (driver.tapSpecFresh(requireNotNull(target))) {
|
||||
FreshActionResult.AMBIGUOUS -> return failure(
|
||||
SPEC_ENTRY_TARGET_AMBIGUOUS,
|
||||
"规格入口手势目标不唯一 [${specEntryEvidence(screen, 1, entryReadyWaitPolls)}]",
|
||||
)
|
||||
FreshActionResult.SUCCESS -> Unit
|
||||
else -> return failure(
|
||||
SPEC_ENTRY_CLICK_FAILED,
|
||||
click.reason.specEntrySubreasonAfterGestureFailure(),
|
||||
)
|
||||
}
|
||||
wait = waitForSpecPanel(input, specActionSignature(wait.screen))
|
||||
wait.failure?.let { return it }
|
||||
if (wait.opened) return null
|
||||
if (wait.changed) {
|
||||
return failure(
|
||||
SPEC_PANEL_EVIDENCE_NOT_MATCHED,
|
||||
"规格入口手势后页面已变化,但规格面板强证据不足 [${panelEvidence(wait.screen)}]",
|
||||
)
|
||||
}
|
||||
return failure(
|
||||
SPEC_ENTRY_CLICK_NO_EFFECT,
|
||||
"规格入口无障碍点击和受控手势后页面均无变化 [${panelEvidence(wait.screen)}]",
|
||||
)
|
||||
}
|
||||
|
||||
private data class SpecPanelWait(
|
||||
val screen: ParsedPddScreen,
|
||||
val opened: Boolean,
|
||||
val changed: Boolean,
|
||||
val failure: PurchaseExecutionOutcome? = null,
|
||||
)
|
||||
|
||||
private data class SpecActionNodeSignature(
|
||||
val path: String,
|
||||
val className: String?,
|
||||
val bounds: NodeBounds,
|
||||
val clickable: Boolean,
|
||||
val scrollable: Boolean,
|
||||
val selected: Boolean,
|
||||
val checked: Boolean,
|
||||
val enabled: Boolean,
|
||||
val visible: Boolean,
|
||||
)
|
||||
|
||||
private fun waitForSpecPanel(input: PurchaseExecutionInput, beforeSignature: List<SpecActionNodeSignature>): SpecPanelWait {
|
||||
var last = currentScreen(input)
|
||||
var changed = false
|
||||
repeat(SPEC_POST_CLICK_VERIFY_POLLS) {
|
||||
last = currentScreen(input)
|
||||
panelDiagnostic(panelEvidence(last))
|
||||
if (last.reviewPageOpen) {
|
||||
return SpecPanelWait(last, false, true, leaveUnexpectedReviewPage(input))
|
||||
}
|
||||
last.problem?.let { return SpecPanelWait(last, false, true, failure(it.code, it.message)) }
|
||||
if (last.specPanelOpen) return SpecPanelWait(last, true, true)
|
||||
changed = changed || specActionSignature(last) != beforeSignature
|
||||
pause(SPEC_SELECTION_POLL_MILLIS)
|
||||
}
|
||||
return SpecPanelWait(last, false, changed)
|
||||
}
|
||||
|
||||
/** In-memory only; the signature is never logged, persisted, or uploaded. */
|
||||
private fun specActionSignature(screen: ParsedPddScreen): List<SpecActionNodeSignature> = screen.sourceNodes.map { node ->
|
||||
SpecActionNodeSignature(
|
||||
node.path,
|
||||
node.className,
|
||||
node.bounds,
|
||||
node.clickable,
|
||||
node.scrollable,
|
||||
node.selected,
|
||||
node.checked,
|
||||
node.enabled,
|
||||
node.visible,
|
||||
)
|
||||
}
|
||||
|
||||
private fun panelEvidence(screen: ParsedPddScreen): String =
|
||||
"type=${screen.specPanelType};scrollables=${screen.panelScrollableCount};headings=${screen.panelHeadingCount};" +
|
||||
"options=${screen.panelOptionCount};summary=${screen.hasSelectionSummary};quantity=${screen.hasQuantityControls};" +
|
||||
"orderAction=${screen.hasOrderSubmitAction};pageEvidence=${screen.pageEvidenceMatched}"
|
||||
|
||||
private fun specEntryEvidence(screen: ParsedPddScreen, candidateCount: Int, entryReadyWaitPolls: Int = 0): String =
|
||||
"specEntryCandidates=$candidateCount;explicit=${screen.explicitSpecEntryCount};" +
|
||||
"nested=${screen.nestedSpecEntryCount};bottomPurchase=${screen.bottomPurchaseEntryCount};" +
|
||||
"panelAlreadyOpen=${screen.specPanelOpen};reviewPage=${screen.reviewPageOpen};" +
|
||||
"pageEvidence=${screen.pageEvidenceMatched};entryReadyWaitPolls=$entryReadyWaitPolls;" +
|
||||
"entryReadyWaitMillis=${entryReadyWaitPolls * SPEC_ENTRY_READY_POLL_MILLIS}"
|
||||
|
||||
private fun specEntryMatchesAliases(screen: ParsedPddScreen, candidate: SnapshotNode, aliases: List<String>): Boolean {
|
||||
val prefix = "${candidate.path}/"
|
||||
return (sequenceOf(candidate) + screen.sourceNodes.asSequence().filter { it.path.startsWith(prefix) })
|
||||
@@ -323,6 +481,12 @@ class PurchaseRehearsalExecutor(
|
||||
else -> "unknown"
|
||||
}
|
||||
|
||||
private fun FreshClickReason.specEntrySubreasonAfterGestureFailure(): String = when (this) {
|
||||
FreshClickReason.SUCCESS -> "gesture_failed_after_action_click_no_effect"
|
||||
FreshClickReason.UNKNOWN -> "gesture_failed_after_unclassified_action_result"
|
||||
else -> specEntrySubreason()
|
||||
}
|
||||
|
||||
private fun selectSpecs(
|
||||
input: PurchaseExecutionInput,
|
||||
rule: PurchaseRule,
|
||||
@@ -350,44 +514,39 @@ class PurchaseRehearsalExecutor(
|
||||
target: String,
|
||||
selectionProofs: MutableMap<String, ExactSpecSelectionProof>,
|
||||
): PurchaseExecutionOutcome? {
|
||||
var lastClickReason = FreshClickReason.UNKNOWN
|
||||
var clickSucceeded = false
|
||||
repeat(SPEC_SELECTION_CLICK_ATTEMPTS) {
|
||||
val lookup = locateExactSpec(input, dimension, target)
|
||||
lookup.failure?.let { return it }
|
||||
val outcome = driver.clickFreshDetailed(requireNotNull(lookup.node))
|
||||
lastClickReason = outcome.reason
|
||||
if (outcome.result == FreshActionResult.AMBIGUOUS) {
|
||||
return failure(SPEC_TARGET_AMBIGUOUS, "精确规格匹配到多个控件")
|
||||
}
|
||||
clickSucceeded = clickSucceeded || outcome.result == FreshActionResult.SUCCESS
|
||||
val initial = currentScreen(input)
|
||||
initial.problem?.let { return failure(it.code, it.message) }
|
||||
if (rememberExactSelection(initial, dimension, target, selectionProofs)) return null
|
||||
|
||||
// PDD rerenders the remaining options after a color selection. A
|
||||
// stale node can report NOT_FOUND/FAILED even when the exact option
|
||||
// was selected. Always verify the observable selected state before
|
||||
// deciding whether to refind and retry the same exact target.
|
||||
val verifyPolls = if (outcome.result == FreshActionResult.SUCCESS) {
|
||||
SPEC_SELECTION_SUCCESS_VERIFY_POLLS
|
||||
} else {
|
||||
SPEC_SELECTION_FAILED_VERIFY_POLLS
|
||||
}
|
||||
repeat(verifyPolls) {
|
||||
val refreshed = currentScreen(input)
|
||||
refreshed.problem?.let { return failure(it.code, it.message) }
|
||||
if (isExactSpecSelected(refreshed, dimension, target)) {
|
||||
exactSpecSelectionProof(refreshed, dimension, target)?.let { proof ->
|
||||
selectionProofs[dimension] = proof
|
||||
}
|
||||
return null
|
||||
}
|
||||
pause(SPEC_SELECTION_POLL_MILLIS)
|
||||
}
|
||||
val lookup = locateExactSpec(input, dimension, target)
|
||||
lookup.failure?.let { return it }
|
||||
val outcome = driver.clickFreshDetailed(requireNotNull(lookup.node))
|
||||
if (outcome.result == FreshActionResult.AMBIGUOUS) {
|
||||
return failure(SPEC_TARGET_AMBIGUOUS, "精确规格匹配到多个控件")
|
||||
}
|
||||
var verification = waitForExactSelection(input, dimension, target, selectionProofs, SPEC_SELECTION_SUCCESS_VERIFY_POLLS)
|
||||
verification.failure?.let { return it }
|
||||
if (verification.confirmed) return null
|
||||
|
||||
if (clickSucceeded) {
|
||||
// Reacquire the exact option after PDD has had a chance to rerender.
|
||||
// The fallback is still bounded to one center gesture and the same
|
||||
// unique, server-provided exact value.
|
||||
val refreshedLookup = locateExactSpec(input, dimension, target)
|
||||
refreshedLookup.failure?.let { return it }
|
||||
val gesture = driver.tapSpecFresh(requireNotNull(refreshedLookup.node))
|
||||
if (gesture == FreshActionResult.AMBIGUOUS) {
|
||||
return failure(SPEC_TARGET_AMBIGUOUS, "精确规格手势目标不唯一")
|
||||
}
|
||||
if (gesture == FreshActionResult.SUCCESS) {
|
||||
verification = waitForExactSelection(input, dimension, target, selectionProofs, SPEC_SELECTION_SUCCESS_VERIFY_POLLS)
|
||||
verification.failure?.let { return it }
|
||||
if (verification.confirmed) return null
|
||||
return failure(SPEC_SELECTION_UNCONFIRMED, "无障碍点击和受控手势后未能确认精确选中状态")
|
||||
}
|
||||
if (outcome.result == FreshActionResult.SUCCESS) {
|
||||
return failure(SPEC_SELECTION_UNCONFIRMED, "点击规格后未能确认精确选中状态")
|
||||
}
|
||||
val subreason = when (lastClickReason) {
|
||||
val subreason = when (outcome.reason) {
|
||||
FreshClickReason.ROOT_UNAVAILABLE -> SPEC_CLICK_ROOT_UNAVAILABLE
|
||||
FreshClickReason.TARGET_NOT_FOUND -> SPEC_CLICK_TARGET_STALE
|
||||
FreshClickReason.NO_CLICKABLE_ANCESTOR -> SPEC_CLICK_NO_CLICKABLE_ANCESTOR
|
||||
@@ -397,6 +556,38 @@ class PurchaseRehearsalExecutor(
|
||||
return failure(SPEC_CLICK_FAILED, subreason)
|
||||
}
|
||||
|
||||
private data class ExactSelectionWait(
|
||||
val confirmed: Boolean,
|
||||
val failure: PurchaseExecutionOutcome? = null,
|
||||
)
|
||||
|
||||
private fun waitForExactSelection(
|
||||
input: PurchaseExecutionInput,
|
||||
dimension: String,
|
||||
target: String,
|
||||
selectionProofs: MutableMap<String, ExactSpecSelectionProof>,
|
||||
polls: Int,
|
||||
): ExactSelectionWait {
|
||||
repeat(polls) {
|
||||
val refreshed = currentScreen(input)
|
||||
refreshed.problem?.let { return ExactSelectionWait(false, failure(it.code, it.message)) }
|
||||
if (rememberExactSelection(refreshed, dimension, target, selectionProofs)) return ExactSelectionWait(true)
|
||||
pause(SPEC_SELECTION_POLL_MILLIS)
|
||||
}
|
||||
return ExactSelectionWait(false)
|
||||
}
|
||||
|
||||
private fun rememberExactSelection(
|
||||
screen: ParsedPddScreen,
|
||||
dimension: String,
|
||||
target: String,
|
||||
selectionProofs: MutableMap<String, ExactSpecSelectionProof>,
|
||||
): Boolean {
|
||||
if (!isExactSpecSelected(screen, dimension, target)) return false
|
||||
exactSpecSelectionProof(screen, dimension, target)?.let { proof -> selectionProofs[dimension] = proof }
|
||||
return true
|
||||
}
|
||||
|
||||
private fun isExactSpecSelected(screen: ParsedPddScreen, dimension: String, target: String): Boolean {
|
||||
val candidates = screen.dimensions.filter { it.key == dimension }.flatMap { it.values }
|
||||
if (candidates.any { it.text == target && (it.node.selected || it.node.checked) }) return true
|
||||
@@ -417,7 +608,11 @@ class PurchaseRehearsalExecutor(
|
||||
return matchingCandidates.size == 1 && matchingCandidates.single() == target
|
||||
}
|
||||
|
||||
private data class ExactSpecSelectionProof(val dimension: String, val target: String)
|
||||
private data class ExactSpecSelectionProof(
|
||||
val dimension: String,
|
||||
val target: String,
|
||||
val panelType: SpecPanelType,
|
||||
)
|
||||
|
||||
private fun exactSpecSelectionProof(
|
||||
screen: ParsedPddScreen,
|
||||
@@ -429,7 +624,7 @@ class PurchaseRehearsalExecutor(
|
||||
val tokenCandidates = screen.dimensions.filter { it.key == dimension }.flatMap { it.values }.filter { candidate ->
|
||||
if (dimension == "size") SpecValueNormalizer.primarySizeToken(candidate.text) == token else candidate.text == token
|
||||
}
|
||||
return ExactSpecSelectionProof(dimension, target).takeIf {
|
||||
return ExactSpecSelectionProof(dimension, target, screen.specPanelType).takeIf {
|
||||
tokenCandidates.size == 1 && tokenCandidates.single().text == target
|
||||
}
|
||||
}
|
||||
@@ -440,6 +635,9 @@ class PurchaseRehearsalExecutor(
|
||||
val summaryTokenMatched: Boolean,
|
||||
val candidateCount: Int,
|
||||
val proofPresent: Boolean,
|
||||
val targetMatchCount: Int = 0,
|
||||
val relocationAttempted: Boolean = false,
|
||||
val relocationSwipes: Int = 0,
|
||||
)
|
||||
|
||||
private fun verifyExactSpecSelection(
|
||||
@@ -450,15 +648,21 @@ class PurchaseRehearsalExecutor(
|
||||
): FinalSpecVerification {
|
||||
val candidates = screen.dimensions.filter { it.key == dimension }.flatMap { it.values }
|
||||
val matchingNodes = candidates.filter { it.text == target }
|
||||
val proofPresent = proof?.dimension == dimension && proof.target == target
|
||||
val proofPresent = proof?.dimension == dimension && proof.target == target && proof.panelType == screen.specPanelType
|
||||
if (matchingNodes.size == 1 && (matchingNodes.single().node.selected || matchingNodes.single().node.checked)) {
|
||||
return FinalSpecVerification(true, "selected_node", false, candidates.size, proofPresent)
|
||||
return FinalSpecVerification(true, "selected_node", false, candidates.size, proofPresent, matchingNodes.size)
|
||||
}
|
||||
if (candidates.any { it.text != target && (it.node.selected || it.node.checked) }) {
|
||||
return FinalSpecVerification(false, "visible_selected_conflict", false, candidates.size, proofPresent, matchingNodes.size)
|
||||
}
|
||||
|
||||
val token = if (dimension == "size") SpecValueNormalizer.primarySizeToken(target) else target.trim()
|
||||
val summaryTokenMatched = !token.isNullOrBlank() && SpecValueNormalizer.summaryHasExactToken(screen.selectedSummary, token)
|
||||
if (!summaryTokenMatched) {
|
||||
return FinalSpecVerification(false, "summary_token_missing", false, candidates.size, proofPresent)
|
||||
if (screen.selectedSummary == null && screen.specPanelOpen && proofPresent) {
|
||||
return FinalSpecVerification(true, "attempt_selection_state", false, candidates.size, true, matchingNodes.size)
|
||||
}
|
||||
return FinalSpecVerification(false, "summary_token_missing", false, candidates.size, proofPresent, matchingNodes.size)
|
||||
}
|
||||
// PDD can rerender or scroll the previously selected dimension out of
|
||||
// the current viewport while the selected summary remains visible. A
|
||||
@@ -469,18 +673,83 @@ class PurchaseRehearsalExecutor(
|
||||
if (dimension == "size") SpecValueNormalizer.primarySizeToken(candidate.text) == token else candidate.text == token
|
||||
}
|
||||
if (visibleTokenMatches.size == 1 && visibleTokenMatches.single().text == target) {
|
||||
return FinalSpecVerification(true, "visible_candidate", true, candidates.size, proofPresent)
|
||||
return FinalSpecVerification(true, "visible_candidate", true, candidates.size, proofPresent, matchingNodes.size)
|
||||
}
|
||||
if (visibleTokenMatches.isNotEmpty()) {
|
||||
return FinalSpecVerification(false, "visible_candidate_conflict", true, candidates.size, proofPresent)
|
||||
return FinalSpecVerification(false, "visible_candidate_conflict", true, candidates.size, proofPresent, matchingNodes.size)
|
||||
}
|
||||
return if (proofPresent) {
|
||||
FinalSpecVerification(true, "attempt_selection_proof", true, candidates.size, true)
|
||||
FinalSpecVerification(true, "attempt_selection_proof", true, candidates.size, true, matchingNodes.size)
|
||||
} else {
|
||||
FinalSpecVerification(false, "selection_proof_missing", true, candidates.size, false)
|
||||
FinalSpecVerification(false, "selection_proof_missing", true, candidates.size, false, matchingNodes.size)
|
||||
}
|
||||
}
|
||||
|
||||
private fun verifyExactSpecSelectionWithRelocation(
|
||||
input: PurchaseExecutionInput,
|
||||
dimension: String,
|
||||
target: String,
|
||||
proof: ExactSpecSelectionProof?,
|
||||
): FinalSpecVerification {
|
||||
var screen = currentScreen(input)
|
||||
screen.problem?.let {
|
||||
return FinalSpecVerification(false, it.code, false, 0, proof != null)
|
||||
}
|
||||
var verification = verifyExactSpecSelection(screen, dimension, target, proof)
|
||||
if (verification.confirmed || verification.targetMatchCount > 0 || verification.reason == "visible_selected_conflict") return verification
|
||||
if (!screen.specPanelOpen) return verification.copy(reason = "relocation_panel_closed", relocationAttempted = true)
|
||||
|
||||
var signature = screen.dimensions.joinToString("|") { item ->
|
||||
"${item.key}:${item.values.joinToString(",") { value -> "${value.text}:${value.available}" }}"
|
||||
}
|
||||
var swipes = 0
|
||||
for ((direction, limit) in listOf(SwipeDirection.DOWN to 3, SwipeDirection.UP to 6)) {
|
||||
for (attempt in 0 until limit) {
|
||||
val container = screen.specPanelContainer
|
||||
?: return verification.copy(
|
||||
reason = "relocation_container_missing",
|
||||
relocationAttempted = true,
|
||||
relocationSwipes = swipes,
|
||||
)
|
||||
if (!driver.swipePurchaseIn(container, direction, 350)) {
|
||||
return verification.copy(
|
||||
reason = "relocation_swipe_failed",
|
||||
relocationAttempted = true,
|
||||
relocationSwipes = swipes,
|
||||
)
|
||||
}
|
||||
swipes++
|
||||
pause(300)
|
||||
screen = currentScreen(input)
|
||||
screen.problem?.let {
|
||||
return verification.copy(
|
||||
reason = it.code,
|
||||
relocationAttempted = true,
|
||||
relocationSwipes = swipes,
|
||||
)
|
||||
}
|
||||
if (!screen.specPanelOpen) {
|
||||
return verification.copy(
|
||||
reason = "relocation_panel_closed",
|
||||
relocationAttempted = true,
|
||||
relocationSwipes = swipes,
|
||||
)
|
||||
}
|
||||
verification = verifyExactSpecSelection(screen, dimension, target, proof).copy(
|
||||
relocationAttempted = true,
|
||||
relocationSwipes = swipes,
|
||||
)
|
||||
if (verification.confirmed || verification.targetMatchCount > 0 || verification.reason == "visible_selected_conflict") return verification
|
||||
val refreshedSignature = screen.dimensions.joinToString("|") { item ->
|
||||
"${item.key}:${item.values.joinToString(",") { value -> "${value.text}:${value.available}" }}"
|
||||
}
|
||||
if (refreshedSignature == signature) break
|
||||
signature = refreshedSignature
|
||||
}
|
||||
}
|
||||
return verification.copy(relocationAttempted = true, relocationSwipes = swipes)
|
||||
}
|
||||
|
||||
private data class SpecLookup(val node: SnapshotNode? = null, val failure: PurchaseExecutionOutcome? = null)
|
||||
|
||||
/**
|
||||
@@ -491,38 +760,40 @@ class PurchaseRehearsalExecutor(
|
||||
* that direction early.
|
||||
*/
|
||||
private fun locateExactSpec(input: PurchaseExecutionInput, dimension: String, target: String): SpecLookup {
|
||||
fun inspect(): Pair<SpecLookup?, String> {
|
||||
data class Inspection(val lookup: SpecLookup?, val signature: String, val container: SnapshotNode?)
|
||||
fun inspect(): Inspection {
|
||||
val screen = currentScreen(input)
|
||||
screen.problem?.let { return SpecLookup(failure = failure(it.code, it.message)) to "" }
|
||||
screen.problem?.let { return Inspection(SpecLookup(failure = failure(it.code, it.message)), "", null) }
|
||||
if (!screen.specPanelOpen) {
|
||||
return SpecLookup(failure = failure("RULE_NOT_MATCHED", "商品规格面板已经关闭")) to ""
|
||||
return Inspection(SpecLookup(failure = failure("RULE_NOT_MATCHED", "商品规格面板已经关闭")), "", null)
|
||||
}
|
||||
val dimensionValues = screen.dimensions.filter { it.key == dimension }.flatMap { it.values }
|
||||
val exact = dimensionValues.filter { it.text == target }
|
||||
if (exact.size > 1) {
|
||||
return SpecLookup(failure = failure(SPEC_TARGET_AMBIGUOUS, "精确规格匹配到多个控件")) to ""
|
||||
return Inspection(SpecLookup(failure = failure(SPEC_TARGET_AMBIGUOUS, "精确规格匹配到多个控件")), "", screen.specPanelContainer)
|
||||
}
|
||||
if (exact.size == 1 && exact.single().available) return SpecLookup(node = exact.single().node) to ""
|
||||
if (exact.size == 1 && exact.single().available) return Inspection(SpecLookup(node = exact.single().node), "", screen.specPanelContainer)
|
||||
if (exact.size == 1) {
|
||||
return SpecLookup(failure = failure(SPEC_SAFE_TARGET_MISSING, "精确规格当前不可安全点击")) to ""
|
||||
return Inspection(SpecLookup(failure = failure(SPEC_SAFE_TARGET_MISSING, "精确规格当前不可安全点击")), "", screen.specPanelContainer)
|
||||
}
|
||||
val signature = screen.dimensions.joinToString("|") { item ->
|
||||
"${item.key}:${item.values.joinToString(",") { value -> "${value.text}:${value.available}" }}"
|
||||
}
|
||||
return null to signature
|
||||
return Inspection(null, signature, screen.specPanelContainer)
|
||||
}
|
||||
|
||||
var inspected = inspect()
|
||||
inspected.first?.let { return it }
|
||||
var currentSignature = inspected.second
|
||||
inspected.lookup?.let { return it }
|
||||
var currentSignature = inspected.signature
|
||||
for ((direction, limit) in listOf(SwipeDirection.DOWN to 3, SwipeDirection.UP to 6)) {
|
||||
for (attempt in 0 until limit) {
|
||||
if (!driver.swipePurchase(direction, 350)) break
|
||||
val container = inspected.container ?: break
|
||||
if (!driver.swipePurchaseIn(container, direction, 350)) break
|
||||
pause(300)
|
||||
inspected = inspect()
|
||||
inspected.first?.let { return it }
|
||||
if (inspected.second == currentSignature) break
|
||||
currentSignature = inspected.second
|
||||
inspected.lookup?.let { return it }
|
||||
if (inspected.signature == currentSignature) break
|
||||
currentSignature = inspected.signature
|
||||
}
|
||||
}
|
||||
return SpecLookup(failure = failure(SPEC_TARGET_NOT_VISIBLE, "有界搜索后未找到精确规格"))
|
||||
@@ -566,23 +837,26 @@ class PurchaseRehearsalExecutor(
|
||||
observedPrice: Long?,
|
||||
selectionProofs: Map<String, ExactSpecSelectionProof>,
|
||||
): PurchaseExecutionOutcome? {
|
||||
val screen = currentScreen(input)
|
||||
screen.problem?.let { return failure(it.code, it.message) }
|
||||
val selected = listOf("color" to input.mappedColor, "size" to input.mappedSize).filter { it.second.isNotBlank() }.map { (dimension, rawTarget) ->
|
||||
dimension to (normalizedTarget(dimension, rawTarget)
|
||||
?: return failure(SPEC_SAFE_TARGET_MISSING, "下发规格无法安全规范化"))
|
||||
}
|
||||
selected.forEach { (dimension, target) ->
|
||||
val verification = verifyExactSpecSelection(screen, dimension, target, selectionProofs[dimension])
|
||||
val verification = verifyExactSpecSelectionWithRelocation(input, dimension, target, selectionProofs[dimension])
|
||||
if (!verification.confirmed) {
|
||||
val screen = currentScreen(input)
|
||||
val panel = screen.specPanelType.name.lowercase()
|
||||
val diagnostic = "dimension=$dimension,reason=${verification.reason},panel=$panel," +
|
||||
"summary=${screen.selectedSummary != null},tokenMatched=${verification.summaryTokenMatched}," +
|
||||
"candidates=${verification.candidateCount},proof=${verification.proofPresent}"
|
||||
"candidates=${verification.candidateCount},targetMatches=${verification.targetMatchCount}," +
|
||||
"proof=${verification.proofPresent}," +
|
||||
"relocated=${verification.relocationAttempted},relocationSwipes=${verification.relocationSwipes}"
|
||||
return failure(SPEC_SELECTION_UNCONFIRMED, "最终规格复核未能确认精确选中状态 [$diagnostic]")
|
||||
}
|
||||
}
|
||||
if (readQuantity() != input.quantity) return failure("PURCHASE_QUANTITY_MISMATCH", "最终数量复核失败")
|
||||
val screen = currentScreen(input)
|
||||
screen.problem?.let { return failure(it.code, it.message) }
|
||||
val price = screen.priceCent ?: observedPrice ?: return failure("RULE_NOT_MATCHED", "最终价格复核失败")
|
||||
if (price !in input.minUnitPriceCent..input.maxUnitPriceCent) {
|
||||
return failure("PURCHASE_PRICE_OUT_OF_RANGE", "当前商品单价超出允许范围", price)
|
||||
@@ -590,9 +864,17 @@ class PurchaseRehearsalExecutor(
|
||||
return null
|
||||
}
|
||||
|
||||
private fun applyPostAction(action: PurchaseAction): PurchaseExecutionOutcome? {
|
||||
private fun applyPostAction(input: PurchaseExecutionInput, action: PurchaseAction): PurchaseExecutionOutcome? {
|
||||
if (action.waitAfterMs > 0) pause(action.waitAfterMs)
|
||||
action.swipeAfter?.let { swipe ->
|
||||
// The stock purchase rule asks to reveal additional selector rows after
|
||||
// opening the sheet. A fully-evidenced non-scrollable selector has no
|
||||
// scroll target, and treating that absence as an action failure blocks
|
||||
// an otherwise safe exact-spec flow. Keep all other configured swipes
|
||||
// mandatory; this exception is limited to that confirmed panel state.
|
||||
if (action.type == PurchaseActionType.OPEN_SPEC_PANEL &&
|
||||
currentScreen(input).specPanelType == SpecPanelType.NON_SCROLLABLE_CONFIRMATION
|
||||
) return null
|
||||
repeat(swipe.count) { index ->
|
||||
if (!driver.swipePurchase(swipe.direction, swipe.durationMs)) {
|
||||
return failure("RULE_ACTION_FAILED", "规则要求的有限滑动失败")
|
||||
@@ -641,10 +923,14 @@ class PurchaseRehearsalExecutor(
|
||||
|
||||
companion object {
|
||||
private const val PDD_PACKAGE = "com.xunmeng.pinduoduo"
|
||||
private const val OPEN_PRODUCT_POLL_LIMIT = 50
|
||||
private const val OPEN_PRODUCT_POLL_LIMIT = 150
|
||||
private const val PRODUCT_PAGE_POLL_LIMIT = 150
|
||||
private const val PRODUCT_PAGE_STABLE_READS = 2
|
||||
private const val OPEN_PRODUCT_RETRY_POLLS = 10
|
||||
private const val OPEN_PRODUCT_POLL_MILLIS = 100L
|
||||
private const val SPEC_SELECTION_CLICK_ATTEMPTS = 3
|
||||
private const val SPEC_ENTRY_READY_WAIT_POLLS = 20
|
||||
private const val SPEC_ENTRY_READY_POLL_MILLIS = 100L
|
||||
private const val SPEC_POST_CLICK_VERIFY_POLLS = 30
|
||||
private const val SPEC_SELECTION_SUCCESS_VERIFY_POLLS = 20
|
||||
private const val SPEC_SELECTION_FAILED_VERIFY_POLLS = 5
|
||||
private const val SPEC_SELECTION_POLL_MILLIS = 100L
|
||||
@@ -653,7 +939,8 @@ class PurchaseRehearsalExecutor(
|
||||
private const val SPEC_ENTRY_TARGET_AMBIGUOUS = "PURCHASE_SPEC_ENTRY_TARGET_AMBIGUOUS"
|
||||
private const val SPEC_ENTRY_CLICK_FAILED = "PURCHASE_SPEC_ENTRY_CLICK_FAILED"
|
||||
private const val SPEC_ENTRY_OPENED_REVIEW = "PURCHASE_SPEC_ENTRY_OPENED_REVIEW"
|
||||
private const val SPEC_PANEL_NOT_OPENED = "PURCHASE_SPEC_PANEL_NOT_OPENED"
|
||||
private const val SPEC_ENTRY_CLICK_NO_EFFECT = "PURCHASE_SPEC_ENTRY_CLICK_NO_EFFECT"
|
||||
private const val SPEC_PANEL_EVIDENCE_NOT_MATCHED = "PURCHASE_SPEC_PANEL_EVIDENCE_NOT_MATCHED"
|
||||
private const val SPEC_TARGET_NOT_VISIBLE = "PURCHASE_SPEC_TARGET_NOT_VISIBLE"
|
||||
private const val SPEC_TARGET_AMBIGUOUS = "PURCHASE_SPEC_TARGET_AMBIGUOUS"
|
||||
private const val SPEC_SAFE_TARGET_MISSING = "PURCHASE_SPEC_SAFE_TARGET_MISSING"
|
||||
|
||||
@@ -226,6 +226,26 @@ class AgentApiClient(private val serverUrl: String) {
|
||||
)
|
||||
}
|
||||
|
||||
fun recoverRegistration(info: DeviceInfo, token: String?, recoveryCode: String): RegistrationResult {
|
||||
require(recoveryCode.isNotBlank()) { "请输入管理员恢复码" }
|
||||
val payload = JSONObject()
|
||||
.put("requestId", UUID.randomUUID().toString())
|
||||
.put("installId", info.installId)
|
||||
.put("name", info.name)
|
||||
.put("manufacturer", info.manufacturer)
|
||||
.put("model", info.model)
|
||||
.put("androidVersion", info.androidVersion)
|
||||
.put("agentVersion", info.agentVersion)
|
||||
.put("pddVersion", info.pddVersion)
|
||||
.put("capabilities", JSONArray(info.capabilities))
|
||||
val data = post("/api/agent/v1/register", payload, token, recoveryCode).getJSONObject("data")
|
||||
return RegistrationResult(
|
||||
deviceId = data.getLong("deviceId"),
|
||||
deviceToken = data.optString("deviceToken").takeIf { it.isNotBlank() },
|
||||
heartbeatIntervalSeconds = data.optInt("heartbeatIntervalSeconds", 15),
|
||||
)
|
||||
}
|
||||
|
||||
fun heartbeat(token: String, currentTaskId: Long?, capabilities: List<String> = emptyList()): HeartbeatResult {
|
||||
val payload = JSONObject()
|
||||
.put("requestId", UUID.randomUUID().toString())
|
||||
@@ -590,11 +610,11 @@ class AgentApiClient(private val serverUrl: String) {
|
||||
leaseVersion = data.getLong("leaseVersion"),
|
||||
)
|
||||
|
||||
private fun post(path: String, payload: JSONObject, token: String?): JSONObject {
|
||||
return requireNotNull(request("POST", path, payload, token))
|
||||
private fun post(path: String, payload: JSONObject, token: String?, recoveryCode: String? = null): JSONObject {
|
||||
return requireNotNull(request("POST", path, payload, token, recoveryCode))
|
||||
}
|
||||
|
||||
private fun request(method: String, path: String, payload: JSONObject?, token: String?): JSONObject? {
|
||||
private fun request(method: String, path: String, payload: JSONObject?, token: String?, recoveryCode: String? = null): JSONObject? {
|
||||
val connection = (URL(serverUrl + path).openConnection() as HttpURLConnection).apply {
|
||||
requestMethod = method
|
||||
connectTimeout = 10_000
|
||||
@@ -605,6 +625,7 @@ class AgentApiClient(private val serverUrl: String) {
|
||||
setRequestProperty("Accept", "application/json")
|
||||
setRequestProperty("Cache-Control", "no-store")
|
||||
if (!token.isNullOrBlank()) setRequestProperty("Authorization", "Bearer $token")
|
||||
if (!recoveryCode.isNullOrBlank()) setRequestProperty("X-GoAuto-Device-Recovery-Code", recoveryCode)
|
||||
}
|
||||
try {
|
||||
if (payload != null) {
|
||||
|
||||
+29
-4
@@ -57,6 +57,7 @@ import cn.ilapage.goauto.agent.persistence.AgentDiagnosticEvent
|
||||
import cn.ilapage.goauto.agent.persistence.AgentDiagnosticReason
|
||||
import cn.ilapage.goauto.agent.persistence.AgentDiagnosticStage
|
||||
import cn.ilapage.goauto.agent.persistence.SafeAgentDiagnosticRecorder
|
||||
import cn.ilapage.goauto.agent.ui.PurchaseResultBubblePolicy
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import java.util.concurrent.Executors
|
||||
@@ -187,11 +188,12 @@ class AgentForegroundService : Service() {
|
||||
|
||||
if (!registeredThisProcess.get()) {
|
||||
val registration = api.register(deviceInfo(), credentials?.token)
|
||||
if (credentials == null) {
|
||||
if (registration.deviceToken != null) {
|
||||
val issuedToken = registration.deviceToken
|
||||
?: error("注册请求已处理,但未返回新 Token;请联系管理员重新签发")
|
||||
identityStore.saveCredentials(registration.deviceId, issuedToken)
|
||||
credentials = identityStore.credentials() ?: error("设备凭据保存失败")
|
||||
} else if (credentials == null) {
|
||||
error("注册请求已处理,但未返回新 Token;请联系管理员重新签发")
|
||||
} else {
|
||||
check(registration.deviceId == credentials.deviceId) { "服务端设备身份与本地不一致" }
|
||||
}
|
||||
@@ -254,13 +256,20 @@ class AgentForegroundService : Service() {
|
||||
flushPurchaseOutbox(api, token)
|
||||
val collectionCooldown = activeCollectionCooldown()
|
||||
val purchaseTask = api.nextPurchaseTask(token)
|
||||
when (TaskDispatchPolicy.decide(purchaseTask != null, collectionCooldown != null)) {
|
||||
when (TaskDispatchPolicy.decide(purchaseTask?.status, collectionCooldown != null)) {
|
||||
TaskDispatchDecision.RUN_PURCHASE -> {
|
||||
cancelIdleReturn("收到新的采购任务")
|
||||
releaseCollectionCooldownWakeLock()
|
||||
schedulePurchaseTask(api, requireNotNull(purchaseTask), token)
|
||||
return MANUAL_PURCHASE_TASK
|
||||
}
|
||||
TaskDispatchDecision.WAIT_FOR_PURCHASE_MATCH -> {
|
||||
val waitingTask = requireNotNull(purchaseTask)
|
||||
cancelIdleReturn("等待采购规格匹配")
|
||||
stateStore.update("ONLINE", "采购任务 #${waitingTask.taskId} 正在匹配规格", tokenStored = true)
|
||||
updateNotification("采购任务 #${waitingTask.taskId} 等待规格匹配")
|
||||
return MANUAL_PURCHASE_MATCH_PENDING
|
||||
}
|
||||
TaskDispatchDecision.WAIT_FOR_COLLECTION_COOLDOWN -> {
|
||||
val ticket = requireNotNull(collectionCooldown)
|
||||
showCollectionCooldown(ticket)
|
||||
@@ -433,8 +442,10 @@ class AgentForegroundService : Service() {
|
||||
}
|
||||
|
||||
private fun executePurchaseTask(api: AgentApiClient, initial: PurchaseAgentTask, token: String) {
|
||||
GoAutoAccessibilityService.instance?.dismissPurchaseResultBubble()
|
||||
acquireTaskWakeLock()
|
||||
var resultSafelyStored = false
|
||||
val lastStep = AtomicReference("started")
|
||||
try {
|
||||
val claimed = if (initial.status == "pending") {
|
||||
api.claimPurchaseTask(initial.taskId, UUID.randomUUID().toString(), token)
|
||||
@@ -471,7 +482,11 @@ class AgentForegroundService : Service() {
|
||||
driver = accessibility,
|
||||
openLink = { PddLinkLauncher(this).open(it) },
|
||||
probeSpecs = { collectPurchaseProbe(accessibility, task, parsedRule) },
|
||||
stepChanged = { step -> purchaseStore.updateStep(task.taskId, task.taskAttemptId, step) },
|
||||
stepChanged = { step ->
|
||||
lastStep.set(step)
|
||||
purchaseStore.updateStep(task.taskId, task.taskAttemptId, step)
|
||||
},
|
||||
panelDiagnostic = { evidence -> Log.i("GoAutoPurchasePanel", "task=${task.taskId};$evidence") },
|
||||
beforeOrderSubmit = { evidence ->
|
||||
val boundaryRequestId = UUID.randomUUID().toString()
|
||||
val finalEvidence = JSONObject()
|
||||
@@ -511,6 +526,14 @@ class AgentForegroundService : Service() {
|
||||
val payload = purchaseResultPayload(requestId, task.taskAttemptId, outcome)
|
||||
purchaseStore.completeAndEnqueue(task.taskId, task.taskAttemptId, requestId, payload)
|
||||
resultSafelyStored = true
|
||||
PurchaseResultBubblePolicy.create(
|
||||
taskId = task.taskId,
|
||||
resultType = outcome.resultType,
|
||||
lastStep = lastStep.get(),
|
||||
resultMessage = outcome.message,
|
||||
)?.let { presentation ->
|
||||
GoAutoAccessibilityService.instance?.showPurchaseResultBubble(presentation)
|
||||
}
|
||||
beginIdleReturnCooldown()
|
||||
flushPurchaseOutbox(api, token)
|
||||
val message = if (outcome.resultType == "failed") "${outcome.errorCode}:${outcome.message}" else outcome.message
|
||||
@@ -609,6 +632,7 @@ class AgentForegroundService : Service() {
|
||||
initialTask: cn.ilapage.goauto.agent.network.AgentTask,
|
||||
token: String,
|
||||
): TaskExecutionSummary {
|
||||
GoAutoAccessibilityService.instance?.dismissPurchaseResultBubble()
|
||||
acquireTaskWakeLock()
|
||||
return try {
|
||||
executeTaskWhileAwake(api, initialTask, token)
|
||||
@@ -1057,6 +1081,7 @@ class AgentForegroundService : Service() {
|
||||
const val MANUAL_EMPTY = "empty"
|
||||
const val MANUAL_COLLECTION_TASK = "collection_task"
|
||||
const val MANUAL_PURCHASE_TASK = "purchase_task"
|
||||
const val MANUAL_PURCHASE_MATCH_PENDING = "purchase_match_pending"
|
||||
const val MANUAL_BUSY = "busy"
|
||||
const val MANUAL_CONFIG_REQUIRED = "config_required"
|
||||
const val MANUAL_AUTH_ERROR = "auth_error"
|
||||
|
||||
@@ -99,13 +99,15 @@ internal object CollectionCooldownPolicy {
|
||||
|
||||
internal enum class TaskDispatchDecision {
|
||||
RUN_PURCHASE,
|
||||
WAIT_FOR_PURCHASE_MATCH,
|
||||
WAIT_FOR_COLLECTION_COOLDOWN,
|
||||
CHECK_COLLECTION,
|
||||
}
|
||||
|
||||
internal object TaskDispatchPolicy {
|
||||
fun decide(purchaseAvailable: Boolean, collectionCooldownActive: Boolean): TaskDispatchDecision = when {
|
||||
purchaseAvailable -> TaskDispatchDecision.RUN_PURCHASE
|
||||
fun decide(purchaseStatus: String?, collectionCooldownActive: Boolean): TaskDispatchDecision = when {
|
||||
purchaseStatus == "spec_probe_pending" -> TaskDispatchDecision.WAIT_FOR_PURCHASE_MATCH
|
||||
purchaseStatus != null -> TaskDispatchDecision.RUN_PURCHASE
|
||||
collectionCooldownActive -> TaskDispatchDecision.WAIT_FOR_COLLECTION_COOLDOWN
|
||||
else -> TaskDispatchDecision.CHECK_COLLECTION
|
||||
}
|
||||
|
||||
+114
@@ -0,0 +1,114 @@
|
||||
package cn.ilapage.goauto.agent.ui
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.PixelFormat
|
||||
import android.graphics.drawable.GradientDrawable
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.view.Gravity
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.view.WindowManager
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.TextView
|
||||
import cn.ilapage.goauto.agent.R
|
||||
|
||||
class PurchaseResultBubbleController(
|
||||
private val context: Context,
|
||||
private val canShow: () -> Boolean,
|
||||
) {
|
||||
private val mainHandler = Handler(Looper.getMainLooper())
|
||||
private val windowManager = context.getSystemService(WindowManager::class.java)
|
||||
private val session = PurchaseResultBubbleSession()
|
||||
private var bubbleView: View? = null
|
||||
|
||||
fun show(presentation: PurchaseResultBubblePresentation) {
|
||||
val revision = session.replace()
|
||||
mainHandler.post {
|
||||
if (!session.isCurrent(revision)) return@post
|
||||
removeCurrentView()
|
||||
if (!canShow()) return@post
|
||||
val view = buildView(presentation)
|
||||
runCatching { windowManager.addView(view, layoutParams()) }
|
||||
.onSuccess {
|
||||
bubbleView = view
|
||||
mainHandler.postDelayed({
|
||||
if (session.isCurrent(revision)) {
|
||||
session.dismiss()
|
||||
removeCurrentView()
|
||||
}
|
||||
}, presentation.durationMillis)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun dismiss() {
|
||||
session.dismiss()
|
||||
mainHandler.post(::removeCurrentView)
|
||||
}
|
||||
|
||||
private fun buildView(presentation: PurchaseResultBubblePresentation): View {
|
||||
val horizontalPadding = context.dp(16)
|
||||
val verticalPadding = context.dp(12)
|
||||
return LinearLayout(context).apply {
|
||||
orientation = LinearLayout.VERTICAL
|
||||
setPadding(horizontalPadding, verticalPadding, horizontalPadding, verticalPadding)
|
||||
elevation = context.dp(8).toFloat()
|
||||
background = GradientDrawable().apply {
|
||||
cornerRadius = context.dp(16).toFloat()
|
||||
setColor(context.getColor(if (presentation.isFailure) R.color.purchase_result_failure_background else R.color.purchase_result_success_background))
|
||||
}
|
||||
contentDescription = "${presentation.title}。${presentation.message}"
|
||||
importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_YES
|
||||
descendantFocusability = ViewGroup.FOCUS_BLOCK_DESCENDANTS
|
||||
addView(TextView(context).apply {
|
||||
text = presentation.title
|
||||
setTextColor(context.getColor(R.color.purchase_result_text))
|
||||
textSize = 14f
|
||||
setTypeface(typeface, android.graphics.Typeface.BOLD)
|
||||
maxLines = 2
|
||||
importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO
|
||||
}, LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT))
|
||||
addView(TextView(context).apply {
|
||||
text = presentation.message
|
||||
setTextColor(context.getColor(R.color.purchase_result_text_secondary))
|
||||
textSize = 13f
|
||||
maxLines = 3
|
||||
importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO
|
||||
}, LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT).apply {
|
||||
topMargin = context.dp(4)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
private fun layoutParams(): WindowManager.LayoutParams {
|
||||
val availableWidth = context.resources.displayMetrics.widthPixels - context.dp(32)
|
||||
return WindowManager.LayoutParams(
|
||||
availableWidth.coerceAtMost(context.dp(360)),
|
||||
WindowManager.LayoutParams.WRAP_CONTENT,
|
||||
WindowManager.LayoutParams.TYPE_ACCESSIBILITY_OVERLAY,
|
||||
WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or
|
||||
WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE or
|
||||
WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL,
|
||||
PixelFormat.TRANSLUCENT,
|
||||
).apply {
|
||||
gravity = Gravity.TOP or Gravity.CENTER_HORIZONTAL
|
||||
y = context.statusBarHeight() + context.dp(8)
|
||||
windowAnimations = android.R.style.Animation_Toast
|
||||
}
|
||||
}
|
||||
|
||||
private fun removeCurrentView() {
|
||||
val view = bubbleView ?: return
|
||||
bubbleView = null
|
||||
runCatching { windowManager.removeViewImmediate(view) }
|
||||
}
|
||||
|
||||
private fun Context.dp(value: Int): Int = (value * resources.displayMetrics.density).toInt()
|
||||
|
||||
@Suppress("DiscouragedApi")
|
||||
private fun Context.statusBarHeight(): Int {
|
||||
val resourceId = resources.getIdentifier("status_bar_height", "dimen", "android")
|
||||
return if (resourceId > 0) resources.getDimensionPixelSize(resourceId) else dp(24)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package cn.ilapage.goauto.agent.ui
|
||||
|
||||
data class PurchaseResultBubblePresentation(
|
||||
val title: String,
|
||||
val message: String,
|
||||
val isFailure: Boolean,
|
||||
val durationMillis: Long,
|
||||
)
|
||||
|
||||
object PurchaseResultBubblePolicy {
|
||||
const val SUCCESS_DURATION_MILLIS = 3_000L
|
||||
const val FAILURE_DURATION_MILLIS = 8_000L
|
||||
private const val MAX_MESSAGE_LENGTH = 96
|
||||
|
||||
private val stepLabels = mapOf(
|
||||
"started" to "准备采购",
|
||||
"openProduct" to "打开商品",
|
||||
"verifyProduct" to "核对商品",
|
||||
"openSpecPanel" to "打开规格面板",
|
||||
"selectSpec" to "选择颜色与尺码",
|
||||
"setQuantity" to "设置数量",
|
||||
"verifyUnitPrice" to "核对价格",
|
||||
"verifyOrderSummary" to "核对订单",
|
||||
"probeSpecs" to "采集规格",
|
||||
"updateShippingAddress" to "更新收货地址",
|
||||
"createOrder" to "创建待付款订单",
|
||||
"order_submit_started" to "创建待付款订单",
|
||||
"readOrderResult" to "读取订单编号和下单时间",
|
||||
)
|
||||
|
||||
fun create(
|
||||
taskId: Long,
|
||||
resultType: String,
|
||||
lastStep: String?,
|
||||
resultMessage: String?,
|
||||
): PurchaseResultBubblePresentation? {
|
||||
if (resultType == "spec_probe_completed") return null
|
||||
val failure = resultType == "failed" || resultType == "order_result_unknown"
|
||||
if (failure) {
|
||||
val label = stepLabels[lastStep] ?: "处理采购任务"
|
||||
return PurchaseResultBubblePresentation(
|
||||
title = if (resultType == "order_result_unknown") "CG-$taskId 待人工核对|$label" else "CG-$taskId 失败于:$label",
|
||||
message = sanitize(resultMessage, "采购失败,请返回 Agent 查看详情"),
|
||||
isFailure = true,
|
||||
durationMillis = FAILURE_DURATION_MILLIS,
|
||||
)
|
||||
}
|
||||
val successMessage = when (resultType) {
|
||||
"order_created" -> "已获取订单编号和下单时间"
|
||||
"rehearsal_completed" -> "商品、规格、数量和价格复核完成"
|
||||
else -> sanitize(resultMessage, "采购结果已安全保存")
|
||||
}
|
||||
return PurchaseResultBubblePresentation(
|
||||
title = "CG-$taskId 采购完成",
|
||||
message = successMessage,
|
||||
isFailure = false,
|
||||
durationMillis = SUCCESS_DURATION_MILLIS,
|
||||
)
|
||||
}
|
||||
|
||||
private fun sanitize(value: String?, fallback: String): String {
|
||||
val normalized = value.orEmpty()
|
||||
.replace(Regex("https?://\\S+", RegexOption.IGNORE_CASE), "[链接已隐藏]")
|
||||
.replace(Regex("(?i)(token|authorization|cookie)\\s*[:=]\\s*\\S+")) { match ->
|
||||
"${match.groupValues[1]}=[已隐藏]"
|
||||
}
|
||||
.replace(Regex("[\\r\\n\\t]+"), " ")
|
||||
.replace(Regex("\\s{2,}"), " ")
|
||||
.trim()
|
||||
.ifBlank { fallback }
|
||||
return normalized.take(MAX_MESSAGE_LENGTH)
|
||||
}
|
||||
}
|
||||
|
||||
class PurchaseResultBubbleSession {
|
||||
private var revision = 0L
|
||||
|
||||
@Synchronized
|
||||
fun replace(): Long = ++revision
|
||||
|
||||
@Synchronized
|
||||
fun dismiss(): Long = ++revision
|
||||
|
||||
@Synchronized
|
||||
fun isCurrent(candidate: Long): Boolean = candidate == revision
|
||||
}
|
||||
@@ -36,8 +36,6 @@ import cn.ilapage.goauto.agent.network.HistoryPage
|
||||
import cn.ilapage.goauto.agent.network.HistoryColorImage
|
||||
import cn.ilapage.goauto.agent.network.PurchaseHistoryDetail
|
||||
import cn.ilapage.goauto.agent.network.PurchaseHistoryItem
|
||||
import cn.ilapage.goauto.agent.network.PurchaseResetResult
|
||||
import cn.ilapage.goauto.agent.network.PurchaseRetryResult
|
||||
import cn.ilapage.goauto.agent.persistence.TaskHistoryCache
|
||||
import cn.ilapage.goauto.agent.service.AgentForegroundService
|
||||
import cn.ilapage.goauto.agent.service.AgentSettingsStore
|
||||
@@ -88,7 +86,7 @@ internal object PurchaseRetryPolicy {
|
||||
fun confirmationMessage(continuing: Boolean = false): String = if (continuing) {
|
||||
"替代商品已完成匹配。系统会保留原任务并创建一笔新采购任务;可能创建拼多多待付款订单,但不会支付。"
|
||||
} else {
|
||||
"将使用服务端最新采购规则重跑当前任务,任务号和商品、规格、价格快照不变;可能创建拼多多待付款订单,但不会支付。"
|
||||
"系统会保留原任务,并根据当前商品档案和最新采购规则创建一笔新采购任务;可能创建拼多多待付款订单,但不会支付。"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -748,7 +746,7 @@ class TaskHistoryFragment : Fragment() {
|
||||
contentDescription = "重试采购任务 CG-${task.taskId}"
|
||||
setOnClickListener { confirmPurchaseRetry(task) }
|
||||
}, collectionCardParams())
|
||||
resultColumn.addView(context.centeredMessage("重试边界", "复用当前任务并刷新采购规则;不会执行支付。"))
|
||||
resultColumn.addView(context.centeredMessage("重试边界", "保留当前失败任务并创建新任务;新任务读取当前商品档案和采购规则,不会执行支付。"))
|
||||
} else if (!replacementInProgress && task.status == "failed") {
|
||||
val reason = task.retryDisabledReason?.takeIf(String::isNotBlank) ?: "请在管理端核对任务状态。"
|
||||
resultColumn.addView(context.centeredMessage("不可重试", reason))
|
||||
@@ -869,17 +867,13 @@ class TaskHistoryFragment : Fragment() {
|
||||
runCatching {
|
||||
val client = AgentApiClient(serverUrl)
|
||||
val requestId = UUID.randomUUID().toString()
|
||||
if (continuing) client.retryPurchaseTask(taskId, requestId, credentials.token)
|
||||
else client.resetPurchaseTask(taskId, requestId, credentials.token)
|
||||
client.retryPurchaseTask(taskId, requestId, credentials.token)
|
||||
}
|
||||
.onSuccess { result ->
|
||||
resultColumn.post {
|
||||
if (!isAdded || generation != requestGeneration) return@post
|
||||
AgentForegroundService.start(requireContext())
|
||||
when (result) {
|
||||
is PurchaseRetryResult -> showPurchaseRetrySuccess(result.sourceTaskNo, result.taskNo, result.taskId)
|
||||
is PurchaseResetResult -> showPurchaseResetSuccess(result.taskNo, result.taskId, result.attemptNumber)
|
||||
}
|
||||
showPurchaseRetrySuccess(result.sourceTaskNo, result.taskNo, result.taskId)
|
||||
}
|
||||
}
|
||||
.onFailure { error ->
|
||||
@@ -910,24 +904,6 @@ class TaskHistoryFragment : Fragment() {
|
||||
}, collectionCardParams())
|
||||
}
|
||||
|
||||
private fun showPurchaseResetSuccess(taskNo: String, taskId: Long, attemptNumber: Int) {
|
||||
resultColumn.removeAllViews()
|
||||
resultColumn.addView(requireContext().centeredMessage(
|
||||
"已加入重试队列 $taskNo",
|
||||
"任务号保持不变,将使用最新采购规则进行第 $attemptNumber 次尝试;系统不会支付。",
|
||||
))
|
||||
resultColumn.addView(MaterialButton(requireContext()).apply {
|
||||
text = "查看当前任务"
|
||||
minimumHeight = requireContext().dp(48)
|
||||
setOnClickListener { loadPurchaseDetail(taskId) }
|
||||
}, collectionCardParams())
|
||||
resultColumn.addView(MaterialButton(requireContext(), null, com.google.android.material.R.attr.materialButtonOutlinedStyle).apply {
|
||||
text = "返回采购记录"
|
||||
minimumHeight = requireContext().dp(48)
|
||||
setOnClickListener { page = 1; load() }
|
||||
}, collectionCardParams())
|
||||
}
|
||||
|
||||
private fun addPagination(currentPage: Int, pageSize: Int) {
|
||||
if (total <= pageSize && currentPage == 1) return
|
||||
val context = requireContext()
|
||||
|
||||
@@ -8,4 +8,8 @@
|
||||
<color name="agent_text_muted">#CBD5E1</color>
|
||||
<color name="agent_warning">#FBBF24</color>
|
||||
<color name="agent_error">#F87171</color>
|
||||
<color name="purchase_result_success_background">#166534</color>
|
||||
<color name="purchase_result_failure_background">#991B1B</color>
|
||||
<color name="purchase_result_text">#FFFFFF</color>
|
||||
<color name="purchase_result_text_secondary">#F1F5F9</color>
|
||||
</resources>
|
||||
|
||||
@@ -86,15 +86,19 @@ class CollectionCooldownPolicyTest {
|
||||
fun `purchase keeps priority while collection waits for cooldown`() {
|
||||
assertEquals(
|
||||
TaskDispatchDecision.RUN_PURCHASE,
|
||||
TaskDispatchPolicy.decide(purchaseAvailable = true, collectionCooldownActive = true),
|
||||
TaskDispatchPolicy.decide(purchaseStatus = "pending", collectionCooldownActive = true),
|
||||
)
|
||||
assertEquals(
|
||||
TaskDispatchDecision.WAIT_FOR_PURCHASE_MATCH,
|
||||
TaskDispatchPolicy.decide(purchaseStatus = "spec_probe_pending", collectionCooldownActive = true),
|
||||
)
|
||||
assertEquals(
|
||||
TaskDispatchDecision.WAIT_FOR_COLLECTION_COOLDOWN,
|
||||
TaskDispatchPolicy.decide(purchaseAvailable = false, collectionCooldownActive = true),
|
||||
TaskDispatchPolicy.decide(purchaseStatus = null, collectionCooldownActive = true),
|
||||
)
|
||||
assertEquals(
|
||||
TaskDispatchDecision.CHECK_COLLECTION,
|
||||
TaskDispatchPolicy.decide(purchaseAvailable = false, collectionCooldownActive = false),
|
||||
TaskDispatchPolicy.decide(purchaseStatus = null, collectionCooldownActive = false),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -367,6 +367,64 @@ class PddProductDetailCollectorTest {
|
||||
assertEquals("bottom_purchase", parsed.specEntrySource)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun bottomPurchaseEntriesPreferRightmostCandidate() {
|
||||
val snapshot = UiSnapshot(
|
||||
PDD_PACKAGE,
|
||||
ACTIVITY,
|
||||
listOf(
|
||||
node("content", "", 0, 0, 1080, 2200, resourceId = "android:id/content", className = "android.widget.FrameLayout"),
|
||||
node("buy-left", "单独购买", 446, 2000, 685, 2160, clickable = true),
|
||||
node("buy-right", "发起拼单", 685, 2000, 1080, 2160, clickable = true),
|
||||
),
|
||||
)
|
||||
|
||||
val parsed = PddScreenParser.parse(snapshot, config(), GOODS_ID, evidence())
|
||||
|
||||
assertEquals("buy-right", parsed.specEntry?.path)
|
||||
assertEquals("buy-right", parsed.specEntryClickTarget?.path)
|
||||
assertEquals("bottom_purchase_rightmost", parsed.specEntrySource)
|
||||
assertEquals(2, parsed.bottomPurchaseEntryCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun equallyRightmostBottomPurchaseEntriesPreferSmallerArea() {
|
||||
val snapshot = UiSnapshot(
|
||||
PDD_PACKAGE,
|
||||
ACTIVITY,
|
||||
listOf(
|
||||
node("content", "", 0, 0, 1080, 2200, resourceId = "android:id/content", className = "android.widget.FrameLayout"),
|
||||
node("buy-large", "发起拼单", 685, 2000, 1080, 2160, clickable = true),
|
||||
node("buy-small", "立即购买", 785, 2000, 980, 2140, clickable = true),
|
||||
),
|
||||
)
|
||||
|
||||
val parsed = PddScreenParser.parse(snapshot, config(), GOODS_ID, evidence())
|
||||
|
||||
assertEquals("buy-small", parsed.specEntry?.path)
|
||||
assertEquals("bottom_purchase_rightmost", parsed.specEntrySource)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun bottomPurchaseCandidatesStillExcludeReviewOrderAndPaymentContexts() {
|
||||
val snapshot = UiSnapshot(
|
||||
PDD_PACKAGE,
|
||||
ACTIVITY,
|
||||
listOf(
|
||||
node("content", "", 0, 0, 1080, 2200, resourceId = "android:id/content", className = "android.widget.FrameLayout"),
|
||||
node("reviews", "商品评价", 446, 1900, 685, 2160, clickable = true),
|
||||
node("reviews/buy", "购买", 480, 2000, 650, 2100, parentPath = "reviews"),
|
||||
node("order", "购买并提交订单", 685, 2000, 880, 2160, clickable = true),
|
||||
node("payment", "购买后立即支付", 880, 2000, 1080, 2160, clickable = true),
|
||||
),
|
||||
)
|
||||
|
||||
val parsed = PddScreenParser.parse(snapshot, config(), GOODS_ID, evidence())
|
||||
|
||||
assertEquals(null, parsed.specEntry)
|
||||
assertEquals(0, parsed.bottomPurchaseEntryCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun bottomPurchaseInsideReviewCardIsNeverSpecEntry() {
|
||||
val snapshot = UiSnapshot(
|
||||
@@ -571,6 +629,74 @@ class PddProductDetailCollectorTest {
|
||||
assertTrue(parsed.dimensions.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun nonScrollableSelectorWithTwoDimensionsDoesNotRequireInitialSelectedSummary() {
|
||||
val snapshot = UiSnapshot(
|
||||
PDD_PACKAGE,
|
||||
ACTIVITY,
|
||||
listOf(
|
||||
node("content", "", 0, 0, 1080, 2200, resourceId = "android:id/content", className = "android.widget.FrameLayout"),
|
||||
node("color-heading", "颜色", 20, 420, 300, 470),
|
||||
node("color", "豹纹", 20, 490, 300, 550, clickable = true),
|
||||
node("size-heading", "尺码", 20, 650, 300, 700),
|
||||
node("size", "均码", 20, 720, 300, 780, clickable = true),
|
||||
node("quantity", "1", 480, 1400, 600, 1480, className = "android.widget.EditText"),
|
||||
node("decrease", "减少数量", 360, 1400, 470, 1480, clickable = true),
|
||||
node("increase", "增加数量", 610, 1400, 720, 1480, clickable = true),
|
||||
node("order", "提交订单", 20, 1900, 1060, 2100, clickable = true),
|
||||
),
|
||||
)
|
||||
|
||||
val parsed = PddScreenParser.parse(snapshot, config(), GOODS_ID, evidence())
|
||||
|
||||
assertTrue(parsed.specPanelOpen)
|
||||
assertEquals(SpecPanelType.NON_SCROLLABLE_CONFIRMATION, parsed.specPanelType)
|
||||
assertFalse(parsed.hasSelectionSummary)
|
||||
assertEquals(2, parsed.dimensions.size)
|
||||
assertEquals(2, parsed.panelOptionCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun nestedSelectionRowCombinesPrefixAndDimensionBeforeChoosingClickableParent() {
|
||||
val snapshot = UiSnapshot(
|
||||
PDD_PACKAGE,
|
||||
ACTIVITY,
|
||||
listOf(
|
||||
node("content", "", 0, 0, 1080, 2200, resourceId = "android:id/content", className = "android.widget.FrameLayout"),
|
||||
node("selection-row", "", 20, 720, 1060, 860, clickable = true),
|
||||
node("selection-row/prefix", "请选择", 48, 750, 220, 810, parentPath = "selection-row"),
|
||||
node("selection-row/dimension", "颜色分类", 240, 750, 480, 810, parentPath = "selection-row"),
|
||||
),
|
||||
)
|
||||
|
||||
val parsed = PddScreenParser.parse(snapshot, config(), GOODS_ID, evidence())
|
||||
|
||||
assertEquals("selection-row", parsed.specEntry?.path)
|
||||
assertEquals("selection-row", parsed.specEntryClickTarget?.path)
|
||||
assertEquals("nested_selection", parsed.specEntrySource)
|
||||
assertEquals(1, parsed.nestedSpecEntryCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun nestedSelectionSemanticsNeverAcceptsOrderOrPaymentContainer() {
|
||||
val snapshot = UiSnapshot(
|
||||
PDD_PACKAGE,
|
||||
ACTIVITY,
|
||||
listOf(
|
||||
node("content", "", 0, 0, 1080, 2200, resourceId = "android:id/content", className = "android.widget.FrameLayout"),
|
||||
node("unsafe", "", 20, 720, 1060, 860, clickable = true),
|
||||
node("unsafe/prefix", "请选择", 48, 750, 220, 810, parentPath = "unsafe"),
|
||||
node("unsafe/dimension", "颜色", 240, 750, 480, 810, parentPath = "unsafe"),
|
||||
node("unsafe/order", "提交订单", 700, 750, 1020, 810, parentPath = "unsafe"),
|
||||
),
|
||||
)
|
||||
|
||||
val parsed = PddScreenParser.parse(snapshot, config(), GOODS_ID, evidence())
|
||||
|
||||
assertEquals(null, parsed.specEntry)
|
||||
assertEquals(0, parsed.nestedSpecEntryCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun genericQuantityAndBuyControlsDoNotProveQuickConfirmation() {
|
||||
val snapshot = UiSnapshot(
|
||||
|
||||
@@ -16,6 +16,46 @@ import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class PurchaseLiveAutomationTest {
|
||||
@Test
|
||||
fun `verified spec panel advances through one exact confirm target`() {
|
||||
val driver = SpecConfirmationDriver()
|
||||
|
||||
PurchaseLiveAutomation(driver, pause = {}).advanceToOrderConfirmation()
|
||||
|
||||
assertEquals(listOf("确定"), driver.clicked)
|
||||
assertEquals("order", driver.page)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ambiguous missing and unchanged spec confirmation stop safely`() {
|
||||
val ambiguous = SpecConfirmationDriver(confirmLabels = listOf("确定", "确认"))
|
||||
val ambiguousError = runCatching { PurchaseLiveAutomation(ambiguous, pause = {}).advanceToOrderConfirmation() }
|
||||
.exceptionOrNull() as PurchaseLiveException
|
||||
assertEquals("PURCHASE_SPEC_CONFIRM_TARGET_AMBIGUOUS", ambiguousError.code)
|
||||
assertTrue(ambiguous.clicked.isEmpty())
|
||||
|
||||
val missing = SpecConfirmationDriver(confirmLabels = emptyList(), specPanelScrollable = true)
|
||||
val missingError = runCatching { PurchaseLiveAutomation(missing, pause = {}).advanceToOrderConfirmation() }
|
||||
.exceptionOrNull() as PurchaseLiveException
|
||||
assertEquals("PURCHASE_SPEC_CONFIRM_TARGET_MISSING", missingError.code)
|
||||
assertTrue(missing.clicked.isEmpty())
|
||||
|
||||
val unchanged = SpecConfirmationDriver(advanceAfterClick = false)
|
||||
val unchangedError = runCatching { PurchaseLiveAutomation(unchanged, pause = {}).advanceToOrderConfirmation() }
|
||||
.exceptionOrNull() as PurchaseLiveException
|
||||
assertEquals("PURCHASE_SPEC_CONFIRMATION_UNCONFIRMED", unchangedError.code)
|
||||
assertEquals(listOf("确定"), unchanged.clicked)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `existing order confirmation does not click submit or payment controls`() {
|
||||
val driver = SpecConfirmationDriver(startOnOrderPage = true)
|
||||
|
||||
PurchaseLiveAutomation(driver, pause = {}).advanceToOrderConfirmation()
|
||||
|
||||
assertTrue(driver.clicked.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `address is retagged verified and final order button can only be clicked once`() {
|
||||
val driver = LiveDriver()
|
||||
@@ -137,8 +177,87 @@ class PurchaseLiveAutomationTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `repeated known payment activity still stops without scrolling or payment clicks`() {
|
||||
val driver = LiveDriver(postSubmitCaptureSequence = listOf("payment", "payment"))
|
||||
fun `successful order page enters the unique order detail before reading the result`() {
|
||||
val driver = LiveDriver(orderDetailEntryAfterSubmit = true)
|
||||
val automation = PurchaseLiveAutomation(driver, pause = {})
|
||||
val address = automation.updateShippingAddress("_cg56")
|
||||
automation.finalConfirmation(input().copy(addressSuffix = "_cg56"), address)
|
||||
automation.submitOrderOnce()
|
||||
|
||||
val order = automation.readOrderResult()
|
||||
|
||||
assertEquals("PDD-202608210001", order?.orderNo)
|
||||
assertEquals(1, driver.orderDetailEntryClicks)
|
||||
assertEquals("order", driver.currentPage)
|
||||
assertFalse(driver.clicked.any { it.contains("支付") })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `order detail entered from success page scrolls only to reveal folded result evidence`() {
|
||||
val driver = LiveDriver(orderDetailEntryAfterSubmit = true, orderDetailEvidenceBelowFold = true)
|
||||
val automation = PurchaseLiveAutomation(driver, pause = {})
|
||||
val address = automation.updateShippingAddress("_cg58")
|
||||
automation.finalConfirmation(input().copy(addressSuffix = "_cg58"), address)
|
||||
automation.submitOrderOnce()
|
||||
|
||||
val order = automation.readOrderResult()
|
||||
|
||||
assertEquals("PDD-202608210001", order?.orderNo)
|
||||
assertEquals(1, driver.orderDetailEntryClicks)
|
||||
assertEquals(1, driver.genericSwipes)
|
||||
assertFalse(driver.clicked.any { it.contains("支付") })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unpaid order evidence on reused payment activity scrolls read only instead of backing out`() {
|
||||
val driver = LiveDriver(postSubmitCaptureSequence = listOf("order-folded-payment-activity"))
|
||||
val automation = PurchaseLiveAutomation(driver, pause = {})
|
||||
val address = automation.updateShippingAddress("_cg59")
|
||||
automation.finalConfirmation(input().copy(addressSuffix = "_cg59"), address)
|
||||
automation.submitOrderOnce()
|
||||
|
||||
val order = automation.readOrderResult()
|
||||
|
||||
assertEquals("PDD-202608210001", order?.orderNo)
|
||||
assertEquals(0, driver.postSubmitBackCount)
|
||||
assertEquals(1, driver.genericSwipes)
|
||||
assertFalse(driver.clicked.any { it.contains("支付") })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ambiguous order detail entries stop without navigating`() {
|
||||
val driver = LiveDriver(orderDetailEntryAfterSubmit = true, duplicateOrderDetailEntry = true)
|
||||
val automation = PurchaseLiveAutomation(driver, pause = {})
|
||||
val address = automation.updateShippingAddress("_cg57")
|
||||
automation.finalConfirmation(input().copy(addressSuffix = "_cg57"), address)
|
||||
automation.submitOrderOnce()
|
||||
|
||||
assertEquals(null, automation.readOrderResult())
|
||||
assertEquals("PURCHASE_ORDER_DETAIL_ENTRY_AMBIGUOUS", automation.lastOrderReadFailure?.code)
|
||||
assertEquals(0, driver.orderDetailEntryClicks)
|
||||
assertFalse(driver.clicked.any { it.contains("支付") })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `payment transition frame after safe back reaches unpaid order evidence without payment clicks`() {
|
||||
val driver = LiveDriver(postSubmitCaptureSequence = listOf("payment", "payment", "order"))
|
||||
val automation = PurchaseLiveAutomation(driver, pause = {})
|
||||
val address = automation.updateShippingAddress("_cg55")
|
||||
automation.finalConfirmation(input().copy(addressSuffix = "_cg55"), address)
|
||||
automation.submitOrderOnce()
|
||||
|
||||
val order = automation.readOrderResult()
|
||||
|
||||
assertEquals("PDD-202608210001", order?.orderNo)
|
||||
assertEquals("2026-08-21T02:30:00Z", order?.submittedAt)
|
||||
assertEquals(1, driver.postSubmitBackCount)
|
||||
assertEquals(0, driver.genericSwipes)
|
||||
assertFalse(driver.clicked.any { it.contains("支付") })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `continuous payment activity still stops at bounded post back samples without payment clicks`() {
|
||||
val driver = LiveDriver(postSubmitCaptureSequence = List(4) { "payment" })
|
||||
val automation = PurchaseLiveAutomation(driver, pause = {})
|
||||
val address = automation.updateShippingAddress("_cg55")
|
||||
automation.finalConfirmation(input().copy(addressSuffix = "_cg55"), address)
|
||||
@@ -146,6 +265,12 @@ class PurchaseLiveAutomationTest {
|
||||
|
||||
assertEquals(null, automation.readOrderResult())
|
||||
assertEquals("PURCHASE_ORDER_PAYMENT_REPEATED", automation.lastOrderReadFailure?.code)
|
||||
assertEquals(
|
||||
"支付页安全返回后持续无订单证据,已停止自动核单" +
|
||||
"[paymentBackAttempts=1;consecutivePaymentSamplesAfterBack=3]",
|
||||
automation.lastOrderReadFailure?.message,
|
||||
)
|
||||
assertEquals(4, driver.postSubmitCaptureCount)
|
||||
assertEquals(1, driver.postSubmitBackCount)
|
||||
assertEquals(0, driver.genericSwipes)
|
||||
assertFalse(driver.clicked.any { it.contains("支付") })
|
||||
@@ -328,6 +453,68 @@ class PurchaseLiveAutomationTest {
|
||||
addressSuffix = "_cg11",
|
||||
)
|
||||
|
||||
private class SpecConfirmationDriver(
|
||||
private val confirmLabels: List<String> = listOf("确定"),
|
||||
private val advanceAfterClick: Boolean = true,
|
||||
private val specPanelScrollable: Boolean = false,
|
||||
startOnOrderPage: Boolean = false,
|
||||
) : PurchaseUiDriver {
|
||||
var page = if (startOnOrderPage) "order" else "spec"
|
||||
val clicked = mutableListOf<String>()
|
||||
|
||||
override fun capture(): UiSnapshot = if (page == "order") {
|
||||
snapshot(listOf(
|
||||
node("root", "", bounds = NodeBounds(0, 0, 1080, 2200)),
|
||||
node("scroll", "", scrollable = true, bounds = NodeBounds(0, 400, 1080, 2100)),
|
||||
node("summary", "已选 黑色 均码"),
|
||||
node("quantity", "1", className = "android.widget.EditText"),
|
||||
node("phone", "138****5678"),
|
||||
node("submit", "提交订单", clickable = true),
|
||||
node("payment", "微信支付"),
|
||||
))
|
||||
} else {
|
||||
snapshot(buildList {
|
||||
add(node("root", "", bounds = NodeBounds(0, 0, 1080, 2200)))
|
||||
if (specPanelScrollable) {
|
||||
add(node("scroll", "", scrollable = true, bounds = NodeBounds(0, 400, 1080, 1500)))
|
||||
}
|
||||
add(node("title", "确认款式"))
|
||||
add(node("summary", "已选 黑色 均码"))
|
||||
val parent = "scroll".takeIf { specPanelScrollable }
|
||||
val prefix = if (specPanelScrollable) "scroll/" else ""
|
||||
add(node("${prefix}color-heading", "颜色分类", parentPath = parent))
|
||||
add(node("${prefix}color", "黑色", clickable = true, parentPath = parent))
|
||||
add(node("${prefix}size-heading", "尺码", parentPath = parent))
|
||||
add(node("${prefix}size", "均码", clickable = true, parentPath = parent))
|
||||
add(node("quantity", "1", className = "android.widget.EditText"))
|
||||
confirmLabels.forEachIndexed { index, label -> add(node("confirm-$index", label, clickable = true)) }
|
||||
})
|
||||
}
|
||||
|
||||
override fun clickFresh(target: SnapshotNode): FreshActionResult {
|
||||
clicked += target.label
|
||||
if (advanceAfterClick && target.label in confirmLabels) page = "order"
|
||||
return FreshActionResult.SUCCESS
|
||||
}
|
||||
|
||||
override fun tapPurchaseFresh(target: SnapshotNode) = FreshActionResult.FAILED
|
||||
override fun inputFresh(target: SnapshotNode, value: String) = FreshActionResult.FAILED
|
||||
override fun swipePurchase(direction: SwipeDirection, durationMs: Long) = false
|
||||
override fun swipePurchaseIn(target: SnapshotNode, direction: SwipeDirection, durationMs: Long) = false
|
||||
override fun backPurchase() = false
|
||||
|
||||
private fun snapshot(nodes: List<SnapshotNode>) = UiSnapshot(PDD, ACTIVITY, nodes)
|
||||
private fun node(
|
||||
path: String,
|
||||
text: String,
|
||||
clickable: Boolean = false,
|
||||
scrollable: Boolean = false,
|
||||
className: String = "android.widget.TextView",
|
||||
bounds: NodeBounds = NodeBounds(20, 100, 900, 180),
|
||||
parentPath: String? = null,
|
||||
) = SnapshotNode(path, parentPath, text, null, null, className, bounds, clickable, scrollable, false, false, true, true)
|
||||
}
|
||||
|
||||
private class LiveDriver(
|
||||
private val addressClipped: Boolean = false,
|
||||
private val duplicatePanels: Boolean = false,
|
||||
@@ -342,6 +529,9 @@ class PurchaseLiveAutomationTest {
|
||||
private val wechatLoginAfterSubmit: Boolean = false,
|
||||
private val wechatRestoreStuck: Boolean = false,
|
||||
private val orderEvidenceBelowFold: Boolean = false,
|
||||
private val orderDetailEntryAfterSubmit: Boolean = false,
|
||||
private val orderDetailEvidenceBelowFold: Boolean = false,
|
||||
private val duplicateOrderDetailEntry: Boolean = false,
|
||||
postSubmitCaptureSequence: List<String> = emptyList(),
|
||||
) : PurchaseUiDriver {
|
||||
private var page = "confirmation"
|
||||
@@ -358,6 +548,7 @@ class PurchaseLiveAutomationTest {
|
||||
var backCount = 0
|
||||
var postSubmitBackCount = 0
|
||||
var pddRestoreCount = 0
|
||||
var orderDetailEntryClicks = 0
|
||||
var postSubmitCaptureCount = 0
|
||||
val currentPage: String get() = page
|
||||
|
||||
@@ -392,7 +583,19 @@ class PurchaseLiveAutomationTest {
|
||||
))
|
||||
"order" -> snapshot(listOf(node("status", "待付款"), node("order", "订单号:PDD-202608210001"), node("time", "下单时间:2026-08-21 10:30:00"), node("pay", "立即支付", clickable = true)))
|
||||
"order-folded" -> snapshot(listOf(node("status", "待付款"), node("pay", "立即支付", clickable = true)))
|
||||
"order-folded-payment-activity" -> UiSnapshot(PDD, "com.xunmeng.pinduoduo.app_pay.core.PayActivity", listOf(
|
||||
node("status", "待付款"), node("pay", "立即支付", clickable = true),
|
||||
))
|
||||
"order-no-time" -> snapshot(listOf(node("status", "待付款"), node("order", "订单号:PDD-202608210001"), node("pay", "去支付", clickable = true)))
|
||||
"success" -> snapshot(buildList {
|
||||
add(node("success-title", "购买成功"))
|
||||
add(node("detail-parent", "", clickable = true))
|
||||
add(node("detail", "查看订单", parentPath = "detail-parent"))
|
||||
if (duplicateOrderDetailEntry) {
|
||||
add(node("detail-parent-2", "", clickable = true))
|
||||
add(node("detail-2", "订单详情", parentPath = "detail-parent-2"))
|
||||
}
|
||||
})
|
||||
"chooser" -> UiSnapshot(if (trustedChooser) "android" else "example.untrusted", "com.android.internal.app.ChooserActivity", listOf(
|
||||
node("chooser-title", "选择要使用的应用"), node("wechat-1", "微信"), node("wechat-2", "微信分身"),
|
||||
))
|
||||
@@ -442,9 +645,14 @@ class PurchaseLiveAutomationTest {
|
||||
page = when {
|
||||
chooserAfterSubmit -> "chooser"
|
||||
wechatLoginAfterSubmit -> "wechat-login"
|
||||
orderDetailEntryAfterSubmit -> "success"
|
||||
else -> "order"
|
||||
}
|
||||
}
|
||||
"查看订单", "订单详情" -> {
|
||||
orderDetailEntryClicks++
|
||||
page = if (orderDetailEvidenceBelowFold) "order-folded" else "order"
|
||||
}
|
||||
}
|
||||
return FreshActionResult.SUCCESS
|
||||
}
|
||||
@@ -463,7 +671,7 @@ class PurchaseLiveAutomationTest {
|
||||
}
|
||||
override fun swipePurchase(direction: SwipeDirection, durationMs: Long): Boolean {
|
||||
genericSwipes++
|
||||
if (page == "order-folded" && direction == SwipeDirection.UP) page = "order"
|
||||
if (page in setOf("order-folded", "order-folded-payment-activity") && direction == SwipeDirection.UP) page = "order"
|
||||
return true
|
||||
}
|
||||
override fun swipePurchaseIn(target: SnapshotNode, direction: SwipeDirection, durationMs: Long): Boolean {
|
||||
|
||||
+356
-37
@@ -8,6 +8,7 @@ import cn.ilapage.goauto.agent.automation.PurchaseAgentCapabilities
|
||||
import cn.ilapage.goauto.agent.automation.PurchaseExecutionInput
|
||||
import cn.ilapage.goauto.agent.automation.PurchaseRehearsalExecutor
|
||||
import cn.ilapage.goauto.agent.automation.PurchaseRuleParser
|
||||
import cn.ilapage.goauto.agent.automation.PurchaseSpecGesturePolicy
|
||||
import cn.ilapage.goauto.agent.automation.PurchaseUiDriver
|
||||
import cn.ilapage.goauto.agent.automation.RuleValidationException
|
||||
import cn.ilapage.goauto.agent.automation.SnapshotNode
|
||||
@@ -21,6 +22,22 @@ import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class PurchaseRehearsalExecutorTest {
|
||||
@Test
|
||||
fun `spec gesture policy excludes irreversible and out of bounds targets`() {
|
||||
fun target(label: String, bounds: NodeBounds = NodeBounds(20, 100, 300, 180)) = SnapshotNode(
|
||||
"target", null, label, null, null, "android.widget.TextView", bounds,
|
||||
clickable = true, scrollable = false, selected = false, checked = false, enabled = true, visible = true,
|
||||
)
|
||||
|
||||
assertTrue(PurchaseSpecGesturePolicy.isAllowed(target("选择规格"), 1080, 2200))
|
||||
assertTrue(PurchaseSpecGesturePolicy.isAllowed(target("XL"), 1080, 2200))
|
||||
assertFalse(PurchaseSpecGesturePolicy.isAllowed(target("提交订单"), 1080, 2200))
|
||||
assertFalse(PurchaseSpecGesturePolicy.isAllowed(target("确认购买"), 1080, 2200))
|
||||
assertFalse(PurchaseSpecGesturePolicy.isAllowed(target("立即支付"), 1080, 2200))
|
||||
assertFalse(PurchaseSpecGesturePolicy.isAllowed(target("修改地址"), 1080, 2200))
|
||||
assertFalse(PurchaseSpecGesturePolicy.isAllowed(target("XL", NodeBounds(-20, 100, -2, 180)), 1080, 2200))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `shared current live rule matches Android contract`() {
|
||||
val raw = requireNotNull(javaClass.classLoader?.getResource("purchase-rule-live-v1.json")) {
|
||||
@@ -64,12 +81,12 @@ class PurchaseRehearsalExecutorTest {
|
||||
|
||||
assertEquals("rehearsal_completed", outcome.resultType)
|
||||
assertEquals(2_000L, outcome.actualUnitPriceCent)
|
||||
assertEquals(1, openCount)
|
||||
assertEquals(0, openCount)
|
||||
assertEquals(2, driver.swipeCount)
|
||||
assertEquals(2L, driver.quantity)
|
||||
assertTrue(driver.clicked.containsAll(listOf("打开", "选择规格", "黑色", "XL")))
|
||||
assertTrue(driver.clicked.containsAll(listOf("选择规格", "黑色", "XL")))
|
||||
assertFalse(driver.clicked.any { it.contains("订单") || it.contains("支付") })
|
||||
assertTrue(pauses.contains(700))
|
||||
assertFalse(pauses.contains(700))
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -130,6 +147,8 @@ class PurchaseRehearsalExecutorTest {
|
||||
assertEquals("rehearsal_completed", outcome.resultType)
|
||||
assertTrue(driver.clicked.contains("XL"))
|
||||
assertEquals(3, driver.upSwipeCount)
|
||||
assertTrue(driver.swipeInPaths.isNotEmpty())
|
||||
assertTrue(driver.swipeInPaths.all { it == "scroll" })
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -147,18 +166,31 @@ class PurchaseRehearsalExecutorTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `rerendered exact size is refound and selected on the second attempt`() {
|
||||
fun `rerendered exact size falls back to one verified center gesture`() {
|
||||
val driver = FakePurchaseDriver(
|
||||
sizeClickResults = mutableListOf(FreshActionResult.NOT_FOUND, FreshActionResult.SUCCESS),
|
||||
sizeClickResults = mutableListOf(FreshActionResult.NOT_FOUND),
|
||||
specTapResult = FreshActionResult.SUCCESS,
|
||||
)
|
||||
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
|
||||
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
|
||||
|
||||
assertEquals("rehearsal_completed", outcome.resultType)
|
||||
assertEquals(2, driver.sizeClickCount)
|
||||
assertEquals(1, driver.sizeClickCount)
|
||||
assertEquals(1, driver.specTapCount)
|
||||
assertEquals("XL", driver.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `already selected exact size is not clicked again`() {
|
||||
val driver = FakePurchaseDriver(initialSize = "XL")
|
||||
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
|
||||
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
|
||||
|
||||
assertEquals("rehearsal_completed", outcome.resultType)
|
||||
assertEquals(0, driver.sizeClickCount)
|
||||
assertEquals(0, driver.specTapCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `long exact size can be verified by one unambiguous short summary token`() {
|
||||
val target = "XL【建议121-140斤】"
|
||||
@@ -201,7 +233,7 @@ class PurchaseRehearsalExecutorTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `final verification rejects prior proof when selected summary is no longer visible`() {
|
||||
fun `final verification keeps exact attempt selection when summary is no longer visible`() {
|
||||
val driver = FakePurchaseDriver(
|
||||
hideColorAfterQuantitySet = true,
|
||||
hideSelectedSummaryAfterQuantitySet = true,
|
||||
@@ -209,11 +241,42 @@ class PurchaseRehearsalExecutorTest {
|
||||
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
|
||||
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
|
||||
|
||||
assertEquals("rehearsal_completed", outcome.resultType)
|
||||
assertEquals(1, driver.clicked.count { it == "黑色" })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `final verification reuses exact attempt state without scrolling back`() {
|
||||
val driver = FakePurchaseDriver(
|
||||
hideColorAfterQuantitySet = true,
|
||||
hideSelectedSummaryAfterQuantitySet = true,
|
||||
restoreHiddenColorOnDownSwipe = true,
|
||||
)
|
||||
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
|
||||
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
|
||||
|
||||
assertEquals("rehearsal_completed", outcome.resultType)
|
||||
assertFalse(driver.swipeInPaths.contains("scroll"))
|
||||
assertEquals(1, driver.clicked.count { it == "黑色" })
|
||||
assertEquals(1, driver.clicked.count { it == "XL" })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `final verification rejects explicit conflicting selected color`() {
|
||||
val driver = FakePurchaseDriver(
|
||||
colors = listOf("黑色", "白色"),
|
||||
hideSelectedSummaryAfterQuantitySet = true,
|
||||
selectedColorOverrideAfterQuantitySet = "白色",
|
||||
)
|
||||
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
|
||||
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
|
||||
|
||||
assertEquals("failed", outcome.resultType)
|
||||
assertEquals("PURCHASE_SPEC_SELECTION_UNCONFIRMED", outcome.errorCode)
|
||||
assertTrue(outcome.message.contains("dimension=color"))
|
||||
assertTrue(outcome.message.contains("reason=summary_token_missing"))
|
||||
assertTrue(outcome.message.contains("proof=true"))
|
||||
assertTrue(outcome.message.contains("reason=visible_selected_conflict"))
|
||||
assertTrue(outcome.message.contains("targetMatches=1"))
|
||||
assertEquals(1, driver.clicked.count { it == "黑色" })
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -280,16 +343,17 @@ class PurchaseRehearsalExecutorTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `persistent exact size click failure stops after three attempts`() {
|
||||
fun `persistent exact size click failure stops after one action and one bounded gesture`() {
|
||||
val driver = FakePurchaseDriver(
|
||||
sizeClickResults = MutableList(3) { FreshActionResult.FAILED },
|
||||
sizeClickResults = mutableListOf(FreshActionResult.FAILED),
|
||||
)
|
||||
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
|
||||
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
|
||||
|
||||
assertEquals("PURCHASE_SPEC_CLICK_FAILED", outcome.errorCode)
|
||||
assertEquals("action_click_false", outcome.message)
|
||||
assertEquals(3, driver.sizeClickCount)
|
||||
assertEquals(1, driver.sizeClickCount)
|
||||
assertEquals(1, driver.specTapCount)
|
||||
assertEquals(null, driver.size)
|
||||
}
|
||||
|
||||
@@ -371,8 +435,8 @@ class PurchaseRehearsalExecutorTest {
|
||||
@Test
|
||||
fun `ambiguous browser target stops safely`() {
|
||||
val driver = FakePurchaseDriver(duplicateOpen = true)
|
||||
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
|
||||
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
|
||||
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { "{}" }, pause = {})
|
||||
.execute(input().copy(phase = "spec_probe"), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
|
||||
assertEquals("RULE_AMBIGUOUS", outcome.errorCode)
|
||||
assertTrue(driver.clicked.isEmpty())
|
||||
}
|
||||
@@ -383,10 +447,10 @@ class PurchaseRehearsalExecutorTest {
|
||||
openClickResults = mutableListOf(FreshActionResult.FAILED),
|
||||
openPddOnFailedClick = true,
|
||||
)
|
||||
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
|
||||
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
|
||||
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { "{}" }, pause = {})
|
||||
.execute(input().copy(phase = "spec_probe"), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
|
||||
|
||||
assertEquals("rehearsal_completed", outcome.resultType)
|
||||
assertEquals("spec_probe_completed", outcome.resultType)
|
||||
assertEquals(1, driver.openClickCount)
|
||||
}
|
||||
|
||||
@@ -396,10 +460,10 @@ class PurchaseRehearsalExecutorTest {
|
||||
openClickResults = mutableListOf(FreshActionResult.NOT_FOUND, FreshActionResult.SUCCESS),
|
||||
)
|
||||
val pauses = mutableListOf<Long>()
|
||||
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = pauses::add)
|
||||
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
|
||||
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { "{}" }, pause = pauses::add)
|
||||
.execute(input().copy(phase = "spec_probe"), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
|
||||
|
||||
assertEquals("rehearsal_completed", outcome.resultType)
|
||||
assertEquals("spec_probe_completed", outcome.resultType)
|
||||
assertEquals(2, driver.openClickCount)
|
||||
assertTrue(pauses.size <= 50)
|
||||
}
|
||||
@@ -407,15 +471,165 @@ class PurchaseRehearsalExecutorTest {
|
||||
@Test
|
||||
fun `persistent open click failure is bounded and remains safely failed`() {
|
||||
val driver = FakePurchaseDriver(
|
||||
openClickResults = MutableList(10) { FreshActionResult.FAILED },
|
||||
openClickResults = MutableList(20) { FreshActionResult.FAILED },
|
||||
)
|
||||
val pauses = mutableListOf<Long>()
|
||||
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { "{}" }, pause = pauses::add)
|
||||
.execute(input().copy(phase = "spec_probe"), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
|
||||
|
||||
assertEquals("RULE_ACTION_FAILED", outcome.errorCode)
|
||||
assertTrue(driver.openClickCount in 1..15)
|
||||
assertEquals(150, pauses.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `five second browser interstitial can still reach a stable product page`() {
|
||||
val pauses = mutableListOf<Long>()
|
||||
val driver = FakePurchaseDriver(browserOpenVisibleAfterCaptures = 50)
|
||||
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { "{}" }, pause = pauses::add)
|
||||
.execute(input().copy(phase = "spec_probe"), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
|
||||
|
||||
assertEquals("spec_probe_completed", outcome.resultType)
|
||||
assertEquals(1, driver.openClickCount)
|
||||
assertTrue(pauses.count { it == 100L } in 52..60)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `pdd foreground without stable product evidence times out at fifteen seconds`() {
|
||||
val pauses = mutableListOf<Long>()
|
||||
val driver = FakePurchaseDriver(loadingPddCaptures = 200)
|
||||
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { "{}" }, pause = pauses::add)
|
||||
.execute(input().copy(phase = "spec_probe"), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
|
||||
|
||||
assertEquals("PDD_DETAIL_ENTRY_FAILED", outcome.errorCode)
|
||||
assertEquals("打开拼多多后未识别到稳定商品页面", outcome.message)
|
||||
assertEquals(150, pauses.count { it == 100L })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `explicit pdd login page fails before the product timeout`() {
|
||||
val pauses = mutableListOf<Long>()
|
||||
val driver = FakePurchaseDriver(pddProblemLabels = listOf("手机号登录", "登录后继续"))
|
||||
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { "{}" }, pause = pauses::add)
|
||||
.execute(input().copy(phase = "spec_probe"), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
|
||||
|
||||
assertEquals("PDD_LOGIN_REQUIRED", outcome.errorCode)
|
||||
assertTrue(pauses.count { it == 100L } < 5)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `open spec panel skips required follow-up swipe only for confirmed non-scrollable panel`() {
|
||||
val driver = FakePurchaseDriver(nonScrollablePanel = true, purchaseSwipeSucceeds = false)
|
||||
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
|
||||
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
|
||||
|
||||
assertEquals("rehearsal_completed", outcome.resultType)
|
||||
assertEquals(0, driver.swipeCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unrecognized opened panel returns only scalar panel evidence`() {
|
||||
val driver = FakePurchaseDriver(unrecognizedPanel = true)
|
||||
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
|
||||
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
|
||||
|
||||
assertEquals("PURCHASE_SPEC_PANEL_EVIDENCE_NOT_MATCHED", outcome.errorCode)
|
||||
assertEquals(
|
||||
"规格入口点击后页面已变化,但规格面板强证据不足 [type=UNKNOWN;scrollables=0;headings=0;options=0;summary=false;quantity=false;orderAction=false;pageEvidence=true]",
|
||||
outcome.message,
|
||||
)
|
||||
assertEquals(0, driver.specTapCount)
|
||||
assertFalse(outcome.message.orEmpty().contains("确认款式"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unchanged spec entry action uses one verified center gesture`() {
|
||||
val driver = FakePurchaseDriver(
|
||||
entryActionHasEffect = false,
|
||||
specTapResult = FreshActionResult.SUCCESS,
|
||||
)
|
||||
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
|
||||
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
|
||||
|
||||
assertEquals("rehearsal_completed", outcome.resultType)
|
||||
assertEquals(1, driver.specTapCount)
|
||||
assertTrue(driver.panel)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unchanged spec entry after action and gesture returns no effect`() {
|
||||
val driver = FakePurchaseDriver(
|
||||
entryActionHasEffect = false,
|
||||
specTapResult = FreshActionResult.SUCCESS,
|
||||
specTapHasEffect = false,
|
||||
)
|
||||
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
|
||||
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
|
||||
|
||||
assertEquals("PURCHASE_SPEC_ENTRY_CLICK_NO_EFFECT", outcome.errorCode)
|
||||
assertEquals(1, driver.specTapCount)
|
||||
assertFalse(driver.panel)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `failed spec entry gesture preserves the action click no effect reason`() {
|
||||
val driver = FakePurchaseDriver(
|
||||
entryActionHasEffect = false,
|
||||
specTapResult = FreshActionResult.FAILED,
|
||||
)
|
||||
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
|
||||
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
|
||||
|
||||
assertEquals("PURCHASE_SPEC_ENTRY_CLICK_FAILED", outcome.errorCode)
|
||||
assertEquals("gesture_failed_after_action_click_no_effect", outcome.message)
|
||||
assertFalse(outcome.message.contains("unknown"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `missing spec entry emits scalar source counts without node text`() {
|
||||
val diagnostics = mutableListOf<String>()
|
||||
val pauses = mutableListOf<Long>()
|
||||
val driver = FakePurchaseDriver(missingSpecEntry = true)
|
||||
val outcome = PurchaseRehearsalExecutor(
|
||||
driver,
|
||||
{ driver.browser = true; true },
|
||||
{ null },
|
||||
pause = pauses::add,
|
||||
panelDiagnostic = diagnostics::add,
|
||||
).execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
|
||||
|
||||
assertEquals("PURCHASE_SPEC_ENTRY_NOT_FOUND", outcome.errorCode)
|
||||
assertEquals(
|
||||
"specEntryCandidates=0;explicit=0;nested=0;bottomPurchase=0;panelAlreadyOpen=false;reviewPage=false;pageEvidence=true;entryReadyWaitPolls=20;entryReadyWaitMillis=2000",
|
||||
diagnostics.single(),
|
||||
)
|
||||
assertEquals(21, pauses.count { it == 100L })
|
||||
assertFalse(diagnostics.single().contains("选择规格"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `open spec panel waits for a late safe bottom purchase entry`() {
|
||||
val pauses = mutableListOf<Long>()
|
||||
val driver = FakePurchaseDriver(bottomPurchaseEntry = true, specEntryVisibleAfterPddCaptures = 4)
|
||||
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { "{\"dimensions\":[]}" }, pause = pauses::add)
|
||||
.execute(input().copy(executionMode = "live", phase = "spec_probe"), PurchaseRuleParser.parse(liveRule()), PurchaseAgentCapabilities.supported)
|
||||
|
||||
assertEquals("spec_probe_completed", outcome.resultType)
|
||||
assertTrue(driver.clickedPaths.contains("buy"))
|
||||
// One 100ms pause belongs to the existing open-product foreground poll;
|
||||
// two belong to the entry-ready wait before the bottom bar appears.
|
||||
assertEquals(3, pauses.count { it == 100L })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `verify product waits for product evidence instead of a visible loading frame`() {
|
||||
val pauses = mutableListOf<Long>()
|
||||
val driver = FakePurchaseDriver(loadingPddCaptures = 1)
|
||||
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = pauses::add)
|
||||
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
|
||||
|
||||
assertEquals("RULE_ACTION_FAILED", outcome.errorCode)
|
||||
assertTrue(driver.openClickCount in 1..5)
|
||||
assertEquals(50, pauses.size)
|
||||
assertEquals("rehearsal_completed", outcome.resultType)
|
||||
assertEquals(2, pauses.count { it == 100L })
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -482,11 +696,22 @@ class PurchaseRehearsalExecutorTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `live rule spec probe returns before address or order actions`() {
|
||||
fun `live rule spec probe ignores historical mapping and returns before address or order actions`() {
|
||||
val driver = FakePurchaseDriver()
|
||||
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { "{\"dimensions\":[]}" }, pause = {})
|
||||
.execute(input().copy(executionMode = "live", phase = "spec_probe", addressSuffix = "_cg42"), PurchaseRuleParser.parse(liveRule()), PurchaseAgentCapabilities.supported)
|
||||
.execute(
|
||||
input().copy(
|
||||
executionMode = "live",
|
||||
phase = "spec_probe",
|
||||
mappedColor = "历史颜色",
|
||||
mappedSize = "历史尺码",
|
||||
addressSuffix = "_cg42",
|
||||
),
|
||||
PurchaseRuleParser.parse(liveRule()),
|
||||
PurchaseAgentCapabilities.supported,
|
||||
)
|
||||
assertEquals("spec_probe_completed", outcome.resultType)
|
||||
assertEquals("{\"dimensions\":[]}", outcome.probedSpecs)
|
||||
assertFalse(driver.clicked.any { it.contains("订单") || it.contains("地址") || it.contains("支付") })
|
||||
}
|
||||
|
||||
@@ -561,6 +786,29 @@ class PurchaseRehearsalExecutorTest {
|
||||
assertFalse(driver.clicked.contains("商品评价"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `spec entry failures include scalar diagnostics only`() {
|
||||
val driver = FakePurchaseDriver(missingSpecEntry = true)
|
||||
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
|
||||
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
|
||||
|
||||
assertEquals("PURCHASE_SPEC_ENTRY_NOT_FOUND", outcome.errorCode)
|
||||
assertEquals(
|
||||
"没有找到安全的商品规格入口 [specEntryCandidates=0;explicit=0;nested=0;bottomPurchase=0;panelAlreadyOpen=false;reviewPage=false;pageEvidence=true;entryReadyWaitPolls=20;entryReadyWaitMillis=2000]",
|
||||
outcome.message,
|
||||
)
|
||||
|
||||
val ambiguousDriver = FakePurchaseDriver(forcedEntryClickReason = FreshClickReason.TARGET_AMBIGUOUS)
|
||||
val ambiguous = PurchaseRehearsalExecutor(ambiguousDriver, { ambiguousDriver.browser = true; true }, { null }, pause = {})
|
||||
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
|
||||
|
||||
assertEquals("PURCHASE_SPEC_ENTRY_TARGET_AMBIGUOUS", ambiguous.errorCode)
|
||||
assertEquals(
|
||||
"规格入口点击目标不唯一 [specEntryCandidates=1;explicit=1;nested=0;bottomPurchase=0;panelAlreadyOpen=false;reviewPage=false;pageEvidence=true;entryReadyWaitPolls=0;entryReadyWaitMillis=0]",
|
||||
ambiguous.message,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parser keeps type only compatibility and rejects dangerous or unknown fields`() {
|
||||
val legacy = """{"schemaVersion":1,"ruleType":"pddPurchase","requiredCapabilities":["purchase.rehearsal.v1"],"actions":[{"type":"openProduct"}]}"""
|
||||
@@ -709,6 +957,11 @@ class PurchaseRehearsalExecutorTest {
|
||||
private val priceCent: Long = 2_000,
|
||||
private val duplicateOpen: Boolean = false,
|
||||
private val bottomPurchaseEntry: Boolean = false,
|
||||
private val missingSpecEntry: Boolean = false,
|
||||
private val specEntryVisibleAfterPddCaptures: Int = 0,
|
||||
private val loadingPddCaptures: Int = 0,
|
||||
private val browserOpenVisibleAfterCaptures: Int = 0,
|
||||
private val pddProblemLabels: List<String> = emptyList(),
|
||||
private val includeReviewEntry: Boolean = false,
|
||||
private val openReviewOnBottomClick: Boolean = false,
|
||||
private val reviewBackSucceeds: Boolean = true,
|
||||
@@ -718,12 +971,18 @@ class PurchaseRehearsalExecutorTest {
|
||||
private val sizeClickResults: MutableList<FreshActionResult> = mutableListOf(),
|
||||
private val forcedSizeClickReason: FreshClickReason? = null,
|
||||
private val forcedEntryClickReason: FreshClickReason? = null,
|
||||
private val initialSize: String? = null,
|
||||
private val entryActionHasEffect: Boolean = true,
|
||||
private val specTapResult: FreshActionResult = FreshActionResult.FAILED,
|
||||
private val specTapHasEffect: Boolean = true,
|
||||
private val sizeSelectsOnFailedClick: Boolean = false,
|
||||
private val selectedSizeSummaryOverride: String? = null,
|
||||
private val hideSizeSelectedState: Boolean = false,
|
||||
private val hideColorAfterQuantitySet: Boolean = false,
|
||||
private val hideSizeAfterQuantitySet: Boolean = false,
|
||||
private val hideSelectedSummaryAfterQuantitySet: Boolean = false,
|
||||
private val restoreHiddenColorOnDownSwipe: Boolean = false,
|
||||
private val selectedColorOverrideAfterQuantitySet: String? = null,
|
||||
private val selectedSummaryOverrideAfterQuantitySet: String? = null,
|
||||
private val finalSizesAfterQuantitySet: List<String>? = null,
|
||||
soldOut: Boolean = false,
|
||||
@@ -732,11 +991,14 @@ class PurchaseRehearsalExecutorTest {
|
||||
private val loseEvidenceAfterPull: Boolean = false,
|
||||
private val unavailableSizes: Set<String> = emptySet(),
|
||||
allSpecsUnavailable: Boolean = false,
|
||||
private val nonScrollablePanel: Boolean = false,
|
||||
private val unrecognizedPanel: Boolean = false,
|
||||
private val purchaseSwipeSucceeds: Boolean = true,
|
||||
) : PurchaseUiDriver {
|
||||
var browser = false
|
||||
var panel = false
|
||||
var color: String? = null
|
||||
var size: String? = null
|
||||
var size: String? = initialSize
|
||||
var quantity = 1L
|
||||
var swipeCount = 0
|
||||
var upSwipeCount = 0
|
||||
@@ -744,20 +1006,38 @@ class PurchaseRehearsalExecutorTest {
|
||||
var sizeClickCount = 0
|
||||
var pullDownCount = 0
|
||||
var backCount = 0
|
||||
var specTapCount = 0
|
||||
val swipeInPaths = mutableListOf<String>()
|
||||
private var soldOut = soldOut
|
||||
private var allSpecsUnavailable = allSpecsUnavailable
|
||||
private var productEvidenceLost = false
|
||||
private var reviewPage = false
|
||||
private var pddCaptureCount = 0
|
||||
private var browserCaptureCount = 0
|
||||
private var hiddenColorRestored = false
|
||||
val clicked = mutableListOf<String>()
|
||||
val clickedPaths = mutableListOf<String>()
|
||||
|
||||
override fun capture(): UiSnapshot {
|
||||
if (browser && !panel && color == null && size == null) {
|
||||
browserCaptureCount++
|
||||
if (browserCaptureCount <= browserOpenVisibleAfterCaptures) {
|
||||
return UiSnapshot("com.heytap.browser", "BrowserActivity", listOf(node("content", "", 0, 0, 1080, 2200)))
|
||||
}
|
||||
val openNodes = mutableListOf(node("open", "打开", 0, 100, 300, 180, clickable = true))
|
||||
if (duplicateOpen) openNodes += node("open2", "打开", 400, 100, 700, 180, clickable = true)
|
||||
openNodes += node("content", "", 0, 0, 1080, 2200)
|
||||
return UiSnapshot("com.heytap.browser", "BrowserActivity", openNodes)
|
||||
}
|
||||
pddCaptureCount++
|
||||
if (pddCaptureCount <= loadingPddCaptures) {
|
||||
return UiSnapshot(PDD, ACTIVITY, listOf(node("content", "", 0, 0, 1080, 2200)))
|
||||
}
|
||||
if (pddProblemLabels.isNotEmpty()) {
|
||||
return UiSnapshot(PDD, ACTIVITY, pddProblemLabels.mapIndexed { index, label ->
|
||||
node("problem-$index", label, 20, 100 + index * 100, 900, 180 + index * 100)
|
||||
})
|
||||
}
|
||||
if (!panel) {
|
||||
if (reviewPage) {
|
||||
return UiSnapshot(PDD, ACTIVITY, listOf(
|
||||
@@ -773,24 +1053,33 @@ class PurchaseRehearsalExecutorTest {
|
||||
if (soldOut) {
|
||||
return UiSnapshot(PDD, ACTIVITY, listOf(
|
||||
node("content", "", 0, 0, 1080, 2200),
|
||||
node("title", "测试商品标题文本", 20, 200, 900, 280, className = "android.widget.ViewPager"),
|
||||
node("sold-out", "商品已售罄", 100, 300, 900, 380),
|
||||
node("similar", "相似商品", 100, 500, 900, 580),
|
||||
))
|
||||
}
|
||||
val nodes = mutableListOf(
|
||||
node("content", "", 0, 0, 1080, 2200),
|
||||
node("title", "测试商品标题文本", 20, 200, 900, 280, className = "android.widget.ViewPager"),
|
||||
)
|
||||
if (bottomPurchaseEntry) {
|
||||
val specEntryReady = pddCaptureCount > specEntryVisibleAfterPddCaptures
|
||||
if (bottomPurchaseEntry && specEntryReady) {
|
||||
nodes += node("buy", "", 500, 1800, 1080, 2180, clickable = true)
|
||||
nodes += node("buy/price", "¥20.00", 560, 1840, 760, 1910, parentPath = "buy")
|
||||
nodes += node("buy/label", "免拼购买", 780, 1840, 1040, 1910, parentPath = "buy")
|
||||
} else {
|
||||
} else if (!missingSpecEntry && specEntryReady) {
|
||||
nodes += node("spec", "选择规格", 20, 1000, 900, 1100, clickable = true)
|
||||
}
|
||||
if (includeReviewEntry) nodes += node("review", "商品评价", 20, 1200, 900, 1300, clickable = true)
|
||||
return UiSnapshot(PDD, ACTIVITY, nodes)
|
||||
}
|
||||
val hideColor = hideColorAfterQuantitySet && quantity == 2L
|
||||
if (unrecognizedPanel) {
|
||||
return UiSnapshot(PDD, ACTIVITY, listOf(
|
||||
node("content", "", 0, 0, 1080, 2200),
|
||||
node("panel-title", "确认款式", 20, 396, 300, 430),
|
||||
))
|
||||
}
|
||||
val hideColor = hideColorAfterQuantitySet && quantity == 2L && !hiddenColorRestored
|
||||
val hideSize = hideSizeAfterQuantitySet && quantity == 2L
|
||||
val hideSummary = hideSelectedSummaryAfterQuantitySet && quantity == 2L
|
||||
val displayedSummary = if (quantity == 2L && selectedSummaryOverrideAfterQuantitySet != null) {
|
||||
@@ -802,15 +1091,27 @@ class PurchaseRehearsalExecutorTest {
|
||||
node("content", "", 0, 0, 1080, 2200),
|
||||
node("price", "¥${priceCent / 100}.${(priceCent % 100).toString().padStart(2, '0')}", 20, 300, 300, 360),
|
||||
node("title", "确认款式", 20, 396, 300, 430),
|
||||
node("scroll", "", 0, 400, 1080, 950, scrollable = true),
|
||||
)
|
||||
if (!nonScrollablePanel) nodes += node("scroll", "", 0, 400, 1080, 950, scrollable = true)
|
||||
if (!hideSummary) {
|
||||
nodes += node("selected", "已选 $displayedSummary", 20, 365, 700, 395)
|
||||
}
|
||||
if (!hideColor) {
|
||||
nodes += node("scroll/color-heading", "颜色分类", 20, 410, 300, 450, parentPath = "scroll")
|
||||
val selectedColor = if (quantity == 2L) selectedColorOverrideAfterQuantitySet ?: color else color
|
||||
colors.forEachIndexed { index, value ->
|
||||
nodes += node("scroll/color-$index", value, 20 + index * 220, 470, 200 + index * 220, 540, clickable = true, selected = color == value, enabled = !allSpecsUnavailable, parentPath = "scroll")
|
||||
nodes += node(
|
||||
"scroll/color-$index",
|
||||
value,
|
||||
20 + index * 220,
|
||||
470,
|
||||
200 + index * 220,
|
||||
540,
|
||||
clickable = true,
|
||||
selected = selectedColor == value,
|
||||
enabled = !allSpecsUnavailable,
|
||||
parentPath = "scroll",
|
||||
)
|
||||
}
|
||||
}
|
||||
if (!hideSize) {
|
||||
@@ -848,7 +1149,9 @@ class PurchaseRehearsalExecutorTest {
|
||||
clicked += target.label
|
||||
clickedPaths += target.path
|
||||
if (target.path == "buy") {
|
||||
if (openReviewOnBottomClick) reviewPage = true else panel = true
|
||||
if (entryActionHasEffect) {
|
||||
if (openReviewOnBottomClick) reviewPage = true else panel = true
|
||||
}
|
||||
return FreshActionResult.SUCCESS
|
||||
}
|
||||
when (target.label) {
|
||||
@@ -858,7 +1161,7 @@ class PurchaseRehearsalExecutorTest {
|
||||
if (result == FreshActionResult.SUCCESS || openPddOnFailedClick) browser = false
|
||||
return result
|
||||
}
|
||||
"选择规格", "免拼购买" -> panel = true
|
||||
"选择规格", "免拼购买" -> if (entryActionHasEffect) panel = true
|
||||
in sizes -> {
|
||||
sizeClickCount++
|
||||
val result = sizeClickResults.removeFirstOrNull() ?: FreshActionResult.SUCCESS
|
||||
@@ -904,6 +1207,17 @@ class PurchaseRehearsalExecutorTest {
|
||||
|
||||
override fun tapPurchaseFresh(target: SnapshotNode): FreshActionResult = clickFresh(target)
|
||||
|
||||
override fun tapSpecFresh(target: SnapshotNode): FreshActionResult {
|
||||
specTapCount++
|
||||
if (specTapResult != FreshActionResult.SUCCESS || !specTapHasEffect) return specTapResult
|
||||
when {
|
||||
target.path in setOf("spec", "buy") -> panel = true
|
||||
target.label in sizes -> size = target.label
|
||||
target.label in colors -> color = target.label
|
||||
}
|
||||
return FreshActionResult.SUCCESS
|
||||
}
|
||||
|
||||
override fun inputFresh(target: SnapshotNode, value: String): FreshActionResult {
|
||||
quantity = value.toLong()
|
||||
return FreshActionResult.SUCCESS
|
||||
@@ -912,11 +1226,16 @@ class PurchaseRehearsalExecutorTest {
|
||||
override fun swipePurchase(direction: SwipeDirection, durationMs: Long): Boolean {
|
||||
swipeCount++
|
||||
if (direction == SwipeDirection.UP) upSwipeCount++
|
||||
return true
|
||||
return purchaseSwipeSucceeds
|
||||
}
|
||||
|
||||
override fun swipePurchaseIn(target: SnapshotNode, direction: SwipeDirection, durationMs: Long): Boolean =
|
||||
swipePurchase(direction, durationMs)
|
||||
override fun swipePurchaseIn(target: SnapshotNode, direction: SwipeDirection, durationMs: Long): Boolean {
|
||||
swipeInPaths += target.path
|
||||
if (restoreHiddenColorOnDownSwipe && quantity == 2L && direction == SwipeDirection.DOWN) {
|
||||
hiddenColorRestored = true
|
||||
}
|
||||
return swipePurchase(direction, durationMs)
|
||||
}
|
||||
|
||||
override fun pullDownGoodsPage(): Boolean {
|
||||
pullDownCount++
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
package cn.ilapage.goauto.agent
|
||||
|
||||
import cn.ilapage.goauto.agent.ui.PurchaseResultBubblePolicy
|
||||
import cn.ilapage.goauto.agent.ui.PurchaseResultBubbleSession
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class PurchaseResultBubblePolicyTest {
|
||||
@Test
|
||||
fun `spec probe does not display a final result bubble`() {
|
||||
assertNull(PurchaseResultBubblePolicy.create(69, "spec_probe_completed", "probeSpecs", "规格已回传"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `failure includes the real step and sanitized reason for eight seconds`() {
|
||||
val result = requireNotNull(PurchaseResultBubblePolicy.create(
|
||||
69,
|
||||
"failed",
|
||||
"selectSpec",
|
||||
"没有找到精确规格\n请重试 token=secret-value",
|
||||
))
|
||||
|
||||
assertEquals("CG-69 失败于:选择颜色与尺码", result.title)
|
||||
assertEquals("没有找到精确规格 请重试 token=[已隐藏]", result.message)
|
||||
assertTrue(result.isFailure)
|
||||
assertEquals(8_000L, result.durationMillis)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unknown order result is presented as a failure requiring manual check`() {
|
||||
val result = requireNotNull(PurchaseResultBubblePolicy.create(
|
||||
70,
|
||||
"order_result_unknown",
|
||||
"readOrderResult",
|
||||
"无法确认订单是否创建,请人工检查",
|
||||
))
|
||||
|
||||
assertEquals("CG-70 待人工核对|读取订单编号和下单时间", result.title)
|
||||
assertTrue(result.isFailure)
|
||||
assertEquals(8_000L, result.durationMillis)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `successful order uses a short factual summary for three seconds`() {
|
||||
val result = requireNotNull(PurchaseResultBubblePolicy.create(
|
||||
71,
|
||||
"order_created",
|
||||
"readOrderResult",
|
||||
"ignored",
|
||||
))
|
||||
|
||||
assertEquals("CG-71 采购完成", result.title)
|
||||
assertEquals("已获取订单编号和下单时间", result.message)
|
||||
assertFalse(result.isFailure)
|
||||
assertEquals(3_000L, result.durationMillis)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `new result and dismiss invalidate an older scheduled bubble`() {
|
||||
val session = PurchaseResultBubbleSession()
|
||||
val first = session.replace()
|
||||
val second = session.replace()
|
||||
assertFalse(session.isCurrent(first))
|
||||
assertTrue(session.isCurrent(second))
|
||||
|
||||
session.dismiss()
|
||||
assertFalse(session.isCurrent(second))
|
||||
}
|
||||
}
|
||||
@@ -16,11 +16,12 @@ class PurchaseRetryPolicyTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `retry confirmation explains same task latest rule and no payment`() {
|
||||
fun `retry confirmation explains new task current archive and no payment`() {
|
||||
val message = PurchaseRetryPolicy.confirmationMessage()
|
||||
assertTrue(message.contains("当前任务"))
|
||||
assertTrue(message.contains("保留原任务"))
|
||||
assertTrue(message.contains("当前商品档案"))
|
||||
assertTrue(message.contains("最新采购规则"))
|
||||
assertFalse(message.contains("新采购任务"))
|
||||
assertTrue(message.contains("新采购任务"))
|
||||
assertTrue(message.contains("待付款订单"))
|
||||
assertTrue(message.contains("不会支付"))
|
||||
}
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Project-Profile
|
||||
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Project-Profile.-
|
||||
wiki_revision: 6207da4ffac6b5821eda7869b9ea335b7585826d
|
||||
synchronized_at: 2026-08-31T16:06:21Z
|
||||
wiki_revision: 7468b9fbdd4d0bbbb9a73580c22ec868b3085753
|
||||
synchronized_at: 2026-09-05T07:16:39Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 项目档案
|
||||
@@ -21,16 +21,20 @@ synchronized_at: 2026-08-31T16:06:21Z
|
||||
| 后续设计范围 | 从 SYB 创建采购任务、创建待付款订单、物流采集与自动回填 |
|
||||
| 预计规模 | 20 台 Android;每天约 100 个采集任务、200 个采购任务 |
|
||||
|
||||
## 项目治理模式
|
||||
|
||||
GoAuto 默认采用轻量治理:文案、注释、格式、局部样式或布局、预期行为明确的小 Bug,以及不改变接口、数据结构、权限和安全边界的单模块低风险调整可以直接实施,无需为了留痕补建工单。完整独立需求、新页面、跨模块功能,以及涉及 API、数据结构、权限、安全、迁移或范围不明确的变化必须建立单元工单。采购、创建订单、真实个人或生产数据、权限、安全、并发、迁移、删除、发布和不可逆操作始终升级为高风险,必须具备对象和范围明确的人工授权;已有有效授权时不机械重复确认,范围或环境发生实质变化时重新确认。永久禁止付款以及设备、隐私、AI 和真机安全红线不可裁剪。
|
||||
|
||||
## 建设基线
|
||||
|
||||
| 基线 | 来源与版本 | 许可证 / 使用方式 | GoAuto 适配 |
|
||||
|---|---|---|---|
|
||||
| DevHarness | `D:\OPC\dev_harness`,目标提交 `4bbacf4d7fb265984396bb5589c544105043fa0b` | 开发流程与文档模板 | 2026-08-27 升级(#114):在 #76 已采用的单人工单事实源基础上,增量加入部署模板、Gitea MCP 与最小工单读取、线上原型默认审核与按需 HTML 导出、PowerShell UTF-8/ExecutionPolicy 边界;继续保留 GoAuto 专用安全与设计门禁 |
|
||||
| DevHarness | `D:\OPC\dev_harness`,目标提交 `ecab899` | 开发流程与文档模板 | 2026-09-05 升级(#221):在既有单人工单事实源基础上,选择性加入轻量治理、明确授权边界、中文默认沟通、Windows PowerShell 安全规则,以及按 revision 增量同步与显式深度检查;继续保留 GoAuto 专用高风险与安全门禁 |
|
||||
| 服务端 | `go-admin` v2.3.0 | 上游开源管理端基线;升级时复核许可证和安全公告 | 保留认证、菜单、配置和管理端基础能力,新增 GoAuto 业务模块 |
|
||||
| 管理端 | `go-admin-ui` v3.0.0,`web/package.json` 标注 MIT | Vue 管理界面基线 | 保留应用外壳与通用组件,新增 GoAuto 页面 |
|
||||
| Android | 原生 Kotlin Agent | 自研业务客户端 | 通过管理员明确配置的 HTTP 或 HTTPS Origin 直连服务端,不保留 Windows 桌面 Client/ADB 作为生产拓扑 |
|
||||
|
||||
升级必须比较当前记录的目标提交与新的明确提交,不能笼统复制“最新版”。本次上一基线为 `bfdf648962d11a8024f62768380d8571e1f45f68`,目标为 `4bbacf4d7fb265984396bb5589c544105043fa0b`,区间共 13 个提交;更早基线 `b1f500128d6eb100985792d4a715db8b6b5ae203` 的适配见 #47。模板内容一律按 GoAuto 事实改写:不复制 DevHarness 的项目事实、任务记录、占位部署参数或历史归档;`harness.py` 继续校验 GoAuto 实际核心页面与产品 README,GoAuto 更严格的付款、订单、设备、数据和真机门禁继续优先。
|
||||
升级必须比较当前记录的目标提交与新的明确提交,不能笼统复制“最新版”。本次上一基线为 `4bbacf4d7fb265984396bb5589c544105043fa0b`,目标为 `ecab899`,选择性适配其后的 7 个提交;更早基线适配见 #47 与 #114。模板内容一律按 GoAuto 事实改写:不复制 DevHarness 的项目事实、任务记录、占位部署参数或历史归档;`harness.py` 继续校验 GoAuto 实际核心页面与产品 README,GoAuto 更严格的付款、订单、设备、数据和真机门禁继续优先。
|
||||
|
||||
## 交付单元
|
||||
|
||||
|
||||
+22
-3
@@ -2,12 +2,18 @@
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Development-Workflow
|
||||
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Development-Workflow.-
|
||||
wiki_revision: 3167457d79824afe532dd5560990af8a485a57d1
|
||||
synchronized_at: 2026-08-27T09:37:51Z
|
||||
wiki_revision: 62ddbe4469740c02ce4a6ca2fd1966a89a79322f
|
||||
synchronized_at: 2026-09-05T07:16:44Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 开发工作流
|
||||
|
||||
## 语言与术语
|
||||
|
||||
- 用户可以使用中文、英文或合理的中英混合语言交流;默认使用中文分析、回复、编写工单和维护内部项目文档。
|
||||
- 代码标识符、命令、参数、路径、文件名、API 名称、协议名、日志和错误原文保持原样;必要时补充简短中文解释。
|
||||
- 用户明确要求某次回复或交付物使用其他语言时,按该次要求执行,不改写接口契约或影响搜索和执行的原文。
|
||||
|
||||
## 事实来源
|
||||
|
||||
| 信息 | 唯一事实来源 |
|
||||
@@ -18,7 +24,7 @@ synchronized_at: 2026-08-27T09:37:51Z
|
||||
| 源码、迁移、测试、版本绑定分析、本地原型和核心 Wiki 镜像 | Git |
|
||||
| 可编辑交互设计 | QuantUX;App ID、版本、链接和确认状态记录在工单 |
|
||||
|
||||
核心页面通过 `wiki-docs.json` 显式映射,固定执行 Wiki → `docs/` 单向同步。既有 Wiki 任务归档和 `docs/task/` 只作历史兼容;标准任务不创建或导出,只有用户明确要求专项快照时才使用 `archive` / `export`。
|
||||
核心页面通过 `wiki-docs.json` 显式映射,固定执行 Wiki → `docs/` 单向同步。日常 `sync` 与 `sync --check` 先比较页面 revision,revision 未变化时不重复下载正文;疑似镜像损坏或需要完整核对时显式使用 `sync --deep-check`。既有 Wiki 任务归档和 `docs/task/` 只作历史兼容;标准任务不创建或导出,只有用户明确要求专项快照时才使用 `archive` / `export`。
|
||||
|
||||
## 权威源与事实边界
|
||||
|
||||
@@ -53,6 +59,19 @@ synchronized_at: 2026-08-27T09:37:51Z
|
||||
- 每个核心页面写入后必须在线回读并取得 revision。页面缺失、回读失败或没有 revision 时停止初始化。
|
||||
- 产品编码前运行 `python dev_scripts/harness.py sync --verify`;全部成功才表示初始化完成。
|
||||
|
||||
## 项目治理模式与不可裁剪底线
|
||||
|
||||
GoAuto 默认采用轻量治理。文案、注释、格式、局部样式或布局、预期行为明确的小 Bug,以及不改变接口、数据结构、权限和安全边界的单模块低风险调整可以直接实施,无需为了留痕补建工单。完整独立需求、新页面、跨模块功能,以及 API、数据结构、权限、安全、迁移或范围不明确的变化必须建单。
|
||||
|
||||
采购、创建订单、真实个人或生产数据、权限、安全、并发、数据库迁移、删除数据、发布和其他不可逆操作始终升级为高风险。无论任务采用何种最小门禁,凭据保护、永久禁止付款、个人与生产数据最小化、设备互斥、Agent 禁止 OCR/VLM、控件树与整屏截图禁存、工作区保护、真实测试和人工验收均不可裁剪。
|
||||
|
||||
### 明确授权后的执行
|
||||
|
||||
- 当前聊天中用户给出的明确指令,或 Gitea 工单中能够归属于有权人工的明确授权,可以作为执行依据,不要求把同一授权重复复制到工单后再次确认。
|
||||
- 授权必须能识别操作、对象和范围;Agent 自动生成的工单、草稿、摘要或对用户意图的转述不能单独构成人工授权。
|
||||
- 获得有效授权后,只核对准确目标、授权范围和当前状态等最小必要前提,不得仅因操作不可逆而重复询问或拒绝。
|
||||
- 授权不自动覆盖相邻对象或后续任务;环境、对象、范围或影响发生实质变化时重新确认。平台自身强制的审批、安全策略或权限限制继续有效。
|
||||
|
||||
## 工单与设计证据双门禁
|
||||
|
||||
正式实施前先判断是否需要工单,再判断需要什么设计证据。工单不能替代原型确认,原型也不能替代技术方案、安全检查和单元工单。
|
||||
|
||||
@@ -2,16 +2,8 @@
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Architecture-and-Code-Map
|
||||
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Architecture-and-Code-Map.-
|
||||
wiki_revision: de7e400503d6e7b9c335657114893de345bfead5
|
||||
synchronized_at: 2026-08-31T03:33:36Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
<!-- gitea-wiki-mirror:start -->
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Architecture-and-Code-Map
|
||||
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Architecture-and-Code-Map.-
|
||||
wiki_revision: dc5ccd2104e8ded715c0c0bb23d3827dc9a7fd7a
|
||||
synchronized_at: 2026-08-29T01:39:35Z
|
||||
wiki_revision: b1b1b343917e66288f4282bc6b3b90ea4ff3cca0
|
||||
synchronized_at: 2026-09-04T11:29:50Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 架构与代码地图
|
||||
@@ -56,7 +48,7 @@ Android Portal/Agent
|
||||
- Android 本地互斥与服务端原子领取共同保证单设备串行。
|
||||
- 正式采购在 Android 本地 SQLite 事务中先保存不可逆状态、稳定服务端请求 ID 和脱敏最终确认快照,再通知服务端并只允许一次创建订单点击;重启后只重放服务端标记、只读核单或上报,不再次点击。
|
||||
- 原始控件树和截图不持久化;Android Agent 端第一期不使用 OCR/VLM。服务端 SYB 登录验证码识别是唯一例外,见 [#48](https://git.ilapage.cn/OPC/goauto/issues/48)。
|
||||
- 蝦皮规格映射独立保存在 `shopee_product.specs_json`:颜色只能选择关联 PDD 当前可选颜色,允许多个蝦皮颜色共用一个 PDD 颜色;尺码自动匹配只预览格式统一后的唯一确定结果。PDD 目标规格消失后页面标记失效,映射保存和采购创建均拒绝继续使用;无需新增数据库表或 Android 能力。
|
||||
- 蝦皮规格映射独立保存在 `shopee_product.specs_json`:颜色只能选择关联 PDD 当前可选颜色,允许多个蝦皮颜色共用一个 PDD 颜色。Admin 商品详情的“一键匹配颜色和尺码”在服务端统一计算两个维度:保留当前仍有效的已确认映射,将唯一确定匹配及达到阈值、具备理由且候选仍有效的 AI 匹配,在重新校验规格上下文后于同一事务直接写为 `confirmed`,无需人工确认;低置信度或无结果保持未匹配,Provider 异常或上下文变化时不写入任何本次结果。PDD 目标规格消失后页面标记失效,映射保存和采购创建均拒绝继续使用;无需新增数据库表或 Android 能力。
|
||||
|
||||
## 最小业务数据
|
||||
|
||||
@@ -318,3 +310,26 @@ PddProductDetailCollector
|
||||
- `GET /api/admin/v1/pdd-products/{productId}/related-syb-products` 返回统一扁平分页,参数为 `page`、`pageSize`(默认 20、最大 100)、`scope=actionable|all`(默认 actionable)和可选 `shopeeProductId`。管理员和采购员可读,其他角色由 Casbin 拒绝。
|
||||
- `purchase.Service.ProcessStages` 是 SYB 页和 PDD 关联订单共用的只读阶段入口;批量加载、不调用 AI。当前采购规则缺失或无效时仍返回基础关联、采集和已有任务事实,依赖规则才能创建的行明确标记不可采购。
|
||||
- Web 的 PDD 商品详情把“创建备货采购”保留在顶部,把“关联订单继续采购”放在关联商品区块;订单行使用统一分页,并复用既有 `batch-preview` / `batch` 创建路径。
|
||||
|
||||
## 蝦皮规格自动匹配批处理(#195)
|
||||
|
||||
- 服务端在 `server/app/goauto/shopeeproduct/auto_match_batch.go` 复用单商品“一键匹配颜色和尺码”原子服务,定时任务与 Admin 手动执行共用同一批处理入口。
|
||||
- `shopee_spec_auto_match_run` 保存触发来源、稳定 `requestId`、运行摘要和可空唯一 `active_slot`;活动槽与租约保证多实例、定时和手动同时触发时全局最多一个运行批次。
|
||||
- `shopee_spec_auto_match_work_item` 按蝦皮商品唯一保存输入指纹、尝试次数、下次尝试时间和逐商品租约。已完成或低置信度/无结果的相同输入不重复调用 AI;规格、关联或 AI 设置更新时间变化后才允许重新处理。
|
||||
- 迁移 `1788290000000_shopee_spec_auto_match.go` 幂等创建两张表并写入调用目标 `GoAutoShopeeSpecAutoMatch`。系统任务默认关闭,默认 Cron 为每小时第 15 分钟、每批最多 20 个商品。
|
||||
- Admin 蝦皮商品列表通过异步手动接口启动同一批次并轮询最近运行摘要;该入口仅管理员可用,不创建采购任务、订单,不调用 Android Agent,也不执行付款。
|
||||
|
||||
## SYB 异常规格 AI 定时解析(#198)
|
||||
|
||||
- `server/app/goauto/sybimport/ai_parse_batch.go` 以 SYB 明细为单位执行“确定性重解析 → 封闭候选 AI 解析”,与 #195 的“蝦皮规格 → PDD 规格”任务保持独立。
|
||||
- `syb_spec_ai_parse_run` 保存全局批次、活动槽、租约与结构化计数;`syb_spec_ai_parse_work_item` 按 `syb_product_id` 唯一保存输入指纹、尝试次数、冷却和逐行租约。
|
||||
- `syb_product` 的 `ai_confirmed`、置信度、限长理由、确认时间和隐藏输入指纹记录 AI 确认事实;`parse_status` 仍只记录确定性解析器结果,`manually_confirmed` 仍只代表人工决定。
|
||||
- Provider 只接收单条 `productSpec` 和关联蝦皮商品的颜色/尺码候选;返回值必须逐字属于对应候选。采购可信门禁接受仍有效的 AI 确认,但候选消失后立即 fail-closed。
|
||||
|
||||
## 定时任务持久化执行历史(#199)
|
||||
|
||||
- `server/app/jobs/execution_log.go` 为 Exec 与 HTTP 两类调度入口统一记录一次执行生命周期;`server/app/jobs/models/sys_job_execution_log.go` 对应表 `sys_job_execution_log`,保存执行标识、任务/调用目标快照、定时触发类型、开始/结束时间、耗时、`running` / `succeeded` / `failed` / `interrupted` 状态及脱敏错误码和摘要。
|
||||
- 服务启动时按当前“每个数据库一个调度器实例”的拓扑,把上次进程遗留的 `running` 记录安全收敛为 `interrupted`;任务删除后历史仍保留并可只读查询。
|
||||
- `GET /api/v1/sysjob/:id/execution-logs` 由 `server/app/jobs/service/execution_log.go`、`apis/execution_log.go` 和 `router/sys_job.go` 提供任务级分页、状态与开始时间过滤;沿用隐藏菜单 `JobLog` 的角色菜单绑定和精确 GET 权限。Web 入口为 `web/src/views/schedule/index.vue` 的单选“日志”按钮,详情页为 `web/src/views/schedule/log.vue`。
|
||||
- 执行历史明确不保存任务参数、AI Provider 地址或密钥、第三方原始响应和业务原始载荷;当前不提供删除、保留期限自动化、WebSocket 实时流或立即执行动作。
|
||||
- 追加迁移为 `server/cmd/migrate/migration/version-local/1788357000000_sys_job_execution_log.go`:创建执行历史表、登记只读 API、关联 `JobLog` 菜单,并只给迁移前已绑定该菜单的角色补充精确 Casbin 权限。
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Business-Rules-and-Glossary
|
||||
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Business-Rules-and-Glossary.-
|
||||
wiki_revision: a6f63cc745cf0e0df7ca94521a70fb6af3adea26
|
||||
synchronized_at: 2026-09-01T09:55:26Z
|
||||
wiki_revision: 21ad1681d12a40e7042567f03716d899d49b737e
|
||||
synchronized_at: 2026-09-05T07:16:58Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 业务规则与术语
|
||||
@@ -31,9 +31,9 @@ synchronized_at: 2026-09-01T09:55:26Z
|
||||
- 关联 PDD 商品时校验目标存在且非 `disabled`;不提供手工输入商品 ID 的入口,只能通过搜索选择。
|
||||
- 参考售价 `sale_price_cent` 为整数分,`currency` 为 ISO 4217 代码;币种取系统配置默认值,不逐商品选择,缺省回退为 `TWD`。
|
||||
- 规格值来源分 `import`(SYB 导入,不可人工删除,只能清除映射)与 `manual`(人工添加,可删除);导入与人工值按「维度 + 名称」合并,不重复创建。
|
||||
- 映射来源分 `manual`(人工,允许创建时即为已确认)、`exact_match`(名称标准化后唯一一致)、`ai_match`(AI 建议)。通用 `SetMapping` 入口仍将 `exact_match` 与 `ai_match` 一律写为 `pending`,必须人工确认后才生效;#188 的 SYB 商品页批量 AI 匹配是独立例外,其进入条件已由 #190 放开:只要蝦皮与 PDD 关联完整、PDD 当前为 `active` 且能给出可选颜色/尺码候选、并且已解析出至少一个目标颜色或尺码,即可进入批量匹配。解析状态(含 `parse_status=uncertain` 与 `failed`)、PDD 含颜色尺码之外的可选规格、蝦皮档案中未找到目标颜色或尺码、缺少完整可售 SKU 组合证据,自 #190 起都不再阻断匹配;本项目为内部系统,由此产生的“以错误目标规格进行匹配并保存映射”的风险由人工承担。唯一确定性 `exact_match` 不调用外部 AI,可直接保存为 `confirmed`;其余结果只有在 `ai_match` 置信度存在且达到服务端阈值、理由非空、返回值属于当前可选候选并命中同一个可售颜色+尺码组合时,才可直接持久化为 `confirmed`。低置信度、无组合证据、无效组合或 Provider 异常不得改写现有映射。
|
||||
- 映射来源分 `manual`(人工,允许创建时即为已确认)、`exact_match`(名称标准化后唯一一致)、`ai_match`(AI 建议)。通用 `SetMapping` 入口仍将 `exact_match` 与 `ai_match` 一律写为 `pending`,必须人工确认后才生效;#188 的 SYB 商品页批量 AI 匹配与 #194 的 Admin 蝦皮商品详情一键匹配是两个独立例外。#188 的进入条件已由 #190 放开:只要蝦皮与 PDD 关联完整、PDD 当前为 `active` 且能给出可选颜色/尺码候选、并且已解析出至少一个目标颜色或尺码,即可进入批量匹配。解析状态(含 `parse_status=uncertain` 与 `failed`)、PDD 含颜色尺码之外的可选规格、蝦皮档案中未找到目标颜色或尺码、缺少完整可售 SKU 组合证据,自 #190 起都不再阻断匹配;本项目为内部系统,由此产生的“以错误目标规格进行匹配并保存映射”的风险由人工承担。唯一确定性 `exact_match` 不调用外部 AI,可直接保存为 `confirmed`;SYB 商品页批量 `ai_match` 只要 Provider 成功返回规格结果且理由非空、返回值属于当前可选候选即可直接持久化为 `confirmed`,置信度仅记录供审计、不作为放行门槛。若当前 PDD 档案存在完整可售颜色+尺码 SKU 组合证据,结果还必须命中其中同一个组合;人工录入或外部导入导致组合证据缺失时不阻断保存或创建采购。无结果、已有组合证据中的无效组合或 Provider 异常不得改写现有映射。
|
||||
- 颜色映射只能从关联 PDD 商品当前可选颜色中选择,不允许自由输入;未使用颜色优先显示,已被其他蝦皮颜色使用的颜色仍可选择并显示占用者,因此支持多对一。
|
||||
- 尺码“一键自动匹配”只生成可审阅草稿:繁简体、首尾/重复空格、大小写和全半角统一后只有唯一结果才预填;没有唯一结果时保持待人工选择。保存只改映射,不改蝦皮或 PDD 原始规格。
|
||||
- Admin 蝦皮商品详情只保留一个“一键匹配颜色和尺码”入口。已确认且目标仍存在的映射保留;名称标准化后的唯一确定结果直接写为 `exact_match + confirmed`;其余只有在 AI 置信度达到服务端当前阈值、理由非空、返回值仍属于当前可选候选时才写为 `ai_match + confirmed`。低置信度或无结果保持未匹配;Provider 失败、AI 未启用、关联或规格上下文变化时本次不写入。操作前有未保存的人工修改时禁用一键匹配;保存只改映射,不改蝦皮或 PDD 原始规格。
|
||||
- PDD 重新采集或更换关联后,目标规格仍存在则映射继续有效;目标规格消失时详情标记“已失效”,服务端拒绝保存不存在的目标,采购预检和创建也拒绝使用失效映射并提示重新选择。
|
||||
- 批量软删除逐条校验引用(虾皮商品被 SYB 明细或采购任务引用时不可删除)并逐条返回结果;已删除商品默认不出现在列表,可筛选查看并恢复。
|
||||
|
||||
@@ -41,6 +41,8 @@ synchronized_at: 2026-09-01T09:55:26Z
|
||||
|
||||
- 一行对应 SYB 一条明确的商品/颜色/尺码/数量明细;唯一键为「蝦皮订单号 `code` + 来源明细 `id`」组合,不是全局唯一 ID(未在多货运单样本中验证过全局唯一性)。
|
||||
- `productSpec` 自由文本解析:按最后一个逗号切分为颜色/尺码 → 剥离【】备注 → 判断残留分隔符;解析状态分 `success`/`uncertain`/`failed`,永不猜测原文中不存在的颜色或尺码。
|
||||
|
||||
- 采购放行不以 uncertain 单独阻断:success 与已提取出至少一个非空颜色或尺码的 uncertain 明细,都可继续进入规格匹配和采购预检;只有 failed 或颜色、尺码均为空的异常明细才要求人工处理。uncertain 状态与解析说明继续保留供审计,不会自动猜测规格。
|
||||
- 只有 `success` 状态的解析结果会合并进虾皮商品档案的规格值(来源标记 `import`);`uncertain`/`failed` 只停留在明细行上供人工复核,不污染共享档案。
|
||||
- 金额单位按接口分别定义:`/am/stock/detail/listByStock` 的 `productPrice` 是十进制金额,与 `/am/stock/list` 的 `amtOrder`(×100 整数)不是同一套换算,不可共用。
|
||||
- 关联虾皮商品档案:命中存活记录直接关联;命中软删除记录则复活并保留原有人工映射;均不存在时创建最小档案。命中既有档案时只补写为空的参考图与售价,不覆盖人工修改过的标题、店铺和映射。
|
||||
@@ -62,6 +64,7 @@ synchronized_at: 2026-09-01T09:55:26Z
|
||||
## SYB 店铺过滤
|
||||
|
||||
- `syb_shop` 保存允许导入的 SYB 店铺;店铺名比较前统一去除首尾空白、转换全角/半角字符并忽略大小写。规范化后同名的店铺只能保留一条有效记录。
|
||||
- 版本迁移会对历史存活店铺回填 `normalized_name = Normalize(display_name)`;回填只改匹配键且可重复执行。若两个存活店铺回填后会得到同一键,迁移必须整体失败并保留原数据,管理员先人工消除歧义后再执行,不能静默合并、删除或改变店铺启用状态。
|
||||
- 店铺可以从 SYB 真实货运单列表发现,也允许管理员手工补充。只有管理员可以新增、改名、启停和软删除,采购员等其他角色只读。
|
||||
- 没有任何启用店铺时,导入必须在读取凭据、建立会话、验证码 OCR 和任意 SYB 网络请求之前失败,并给出“请先启用店铺”的可读提示。
|
||||
- 同步必须先拉取并校验当天原始全量列表的总数、分页和唯一 ID,再按本次同步开始时固定的启用店铺快照过滤;过滤不能降低完整性校验的请求范围或容量上限。
|
||||
@@ -73,7 +76,7 @@ synchronized_at: 2026-09-01T09:55:26Z
|
||||
- 导入由管理员创建,创建成功后立即转入后台执行;页面关闭不取消任务。采购员等其他已登录角色可以查看同步记录,不能开始导入。
|
||||
- 系统同一时刻只运行一个 SYB 导入任务。`syb_sync_run` 的唯一执行槽负责跨进程互斥,内存锁减少同一进程内的竞争;服务重启后遗留的执行中记录标记为“已中断”。
|
||||
- 导入失败或中断时保留已写入商品,记录明确的失败原因和已处理进度;重新导入相同范围按「订单号 + 明细 ID」覆盖,不产生重复商品。
|
||||
- 每条同步记录保存本次启用店铺集合的 SHA-256 哈希以及各店铺“已导入/已跳过”数量,不保存账号、密码、Cookie、验证码图片或 SYB 原始响应。
|
||||
- 创建同步记录时冻结本次启用店铺的规范化匹配集合、展示名称快照及 SHA-256 哈希;后台执行必须只使用这一份快照,不得在开始后重新读取 `syb_shop`。同步详情保存并返回快照可用标记、快照店铺名称、哈希及各店铺“已导入/已跳过”数量。#212 之前的历史记录只有哈希和统计,明确标记为无完整快照。上述记录不保存账号、密码、Cookie、Token、验证码图片或 SYB 原始响应。
|
||||
- 后台任务创建前的内部失败仍向普通用户显示“服务端处理失败”,但使用稳定错误码区分阶段:店铺预检为 `SYNC_SHOP_PREFLIGHT_FAILED`,同步记录创建为 `SYNC_RUN_CREATE_FAILED`。服务端只记录阶段和经过脱敏、截断的底层原因,不记录请求体、凭据、Cookie、Token、验证码或原始响应。
|
||||
|
||||
## 采集规则
|
||||
@@ -84,12 +87,14 @@ synchronized_at: 2026-09-01T09:55:26Z
|
||||
- 删除采用软删除;删除后不能创建新任务,但已有任务继续执行自身快照。
|
||||
- 商品详情步骤应声明精确 `activityName`,并与包名和唯一控件共同作为页面证据;进入 PDD 登录 Activity 必须返回 `PDD_LOGIN_REQUIRED`,不能提交采集成功。
|
||||
- 唯一文字节点不可点击时,Agent 只可点击其最近的可点击父容器;不得改点兄弟节点或相似文字。
|
||||
- 商品页规格入口只接受带明确选择语义的规格摘要,或底部真实“购买/拼单”文字按钮;评价、评论、晒单、问答区域及其父容器一律排除。商品评价页与详情页共用 Activity 时,首次误触只允许安全返回并重新定位一次,重复误触明确失败;点击无变化、误入评价页和面板结构无法确认使用不同错误码。
|
||||
- 商品页规格入口只接受带明确选择语义的规格摘要,或底部真实“购买/拼单”文字按钮。若“请选择”和颜色/尺码语义分散在同一可点击父容器的子节点中,也只能在该容器唯一、位于非底部动作区且同时具备两类语义时作为规格入口;规则别名只能收窄已识别的安全候选,不能按页面原始文字扩张。底部购买入口在通过全部安全过滤后仍存在多个候选时,按最右优先(`centerX` 降序,并列时可点击面积升序)确定性选出唯一入口,不再因候选不唯一直接失败;该情形记为 `bottom_purchase_rightmost`,唯一候选仍记为 `bottom_purchase`。“单独购买”与“发起拼单”打开同一规格面板,但对应不同成交形态与价格,最右优先是经人工确认的取舍。评价、评论、晒单、问答、订单、提交订单、支付和付款区域及其父容器一律排除。商品评价页与详情页共用 Activity 时,首次误触只允许安全返回并重新定位一次,重复误触明确失败;点击无变化、误入评价页和面板结构无法确认使用不同错误码。
|
||||
- v2 规则使用类型化动作和固定阶段钩子。已由 Agent 支持的选择器、别名、超时、滑动方向和有限次数可以只更新规则;新增动作类型或页面算法才需要升级 Agent。
|
||||
- v2 任务首次未进入精确商品详情页时,可以按规则显式重开同一浏览器 URL 一次;登录、验证码、风控、无效链接和详情页内采集失败不触发该恢复。
|
||||
- 假售罄恢复只使用主商品规格/购买强证据判断页面是否正常;顶部“相似商品”可以作为受控兜底证据,推荐卡片的标题、价格和销量不得冒充主商品证据。
|
||||
- 商品规格遍历必须用已识别规格节点锁定横向颜色容器和纵向面板容器;颜色按视觉行蛇形遍历,滑动完成后重新读取节点,尺码只读并允许在标题滚出后沿已锁定容器续页。
|
||||
- PDD 已选规格的订单确认形态只可由详情页、选择摘要、唯一数量控件、支付区、唯一底部提交动作和唯一主要滚动容器的组合证据确认;底部提交动作只读且永久禁止点击。Agent 只在该已确认容器内最多三次向下拖动回顶,每次重新读取节点;“参考分类”作为尺码的精确标题别名处理。
|
||||
|
||||
- PDD 完整规格选择器若无主滚动容器且尚未选择规格而没有已选摘要,仍可识别为非滚动规格面板,但必须同时出现至少两个已解析维度、至少两个可选项、唯一数量控件和唯一底部订单动作;缺少其中任一强证据时保持未识别。该订单动作仍仅作页面证据,永久禁止点击。
|
||||
- Agent 可扩展,但规则必须按任务类型授权:采集规则不能创建订单,采购规则只能使用独立审核的创建订单能力。当前项目不实现支付动作、入口或测试;支付、下单和订单相关文字可以作为只读页面证据配置,但不得成为点击目标。后续支付能力必须单独评估并至少具备显式能力位、服务端开关、单笔金额上限和人工授权。
|
||||
|
||||
## 采集任务
|
||||
@@ -135,27 +140,27 @@ synchronized_at: 2026-09-01T09:55:26Z
|
||||
- 演练规则在服务端契约层拒绝改地址、创建订单和核单动作;正式动作还必须通过版本化能力协商。规则只包含类型化动作,禁止任意脚本。
|
||||
- 价格保护保存参考单价、最低单价、最高单价和币种,执行时以 PDD App 实际单价判断;价格越界明确失败,不考虑优惠券。
|
||||
- Admin 从 SYB 当前页选择商品,可在“创建采购”和“重新解析”两种批量用途间切换;采购模式只允许选择预检合格行,表头全选不跨页。批量预检和正式创建均最多 100 条并逐条重新校验,每条 SYB 明细只创建一个独立任务;部分失败不回滚其他成功项。
|
||||
- SYB 商品列表在列表数据返回后立即展示,采购准备状态独立异步加载;只读批量预检只执行批量数据库读取、已确认映射和本地确定性识别,禁止调用 AI Provider。未保存的确定性结果仍显示“规格待匹配”,不能作为“可创建采购”的依据。SYB 批量创建采购按最新数据完整复核,只有目标颜色和尺码都已保存为确认映射,且共同命中最近一次成功或部分成功采集中的完整可售 SKU 组合时才允许创建;否则必须先执行批量 AI 匹配或人工确认,不能把未匹配明细带入采购任务内的人工匹配步骤。
|
||||
- SYB 商品列表在列表数据返回后立即展示,采购准备状态独立异步加载;只读批量预检只执行批量数据库读取和本地确定性识别,禁止调用 AI Provider。自 #215 起,未保存或已失效的长期规格映射不再阻断创建采购;满足商品关联、PDD 状态、价格、规则、设备和订单安全门禁后即可创建任务,每个新 SYB 采购任务都在首个 attempt 以当次 PDD 页面候选重新决策。
|
||||
- SYB 商品页的 PDD 采集资格独立于采购处理阶段:只要已关联的 PDD 商品未停用、没有 `pending` / `running` 采集任务且存在可用采集规则,即可创建采集任务;因此已完成采集并进入“可创建采购”的商品也可以重新采集。相同 PDD 商品在批量创建前按商品去重,服务端创建时仍按最新状态复核。
|
||||
- 批量创建读取管理员选定的当前采购规则并重新执行契约校验,每条任务保存不可变快照;当前规则缺失或无效时明确阻断,不回退到代码常量。设备默认人工指定,也可以留空由符合能力的空闲设备领取。
|
||||
- 批量创建的价格保护来自 PDD 商品档案而不是 SYB/Shopee 的 TWD 售价:当前采购规则可用 `priceGuard.minRatio`(0.1~1.0)和 `maxRatio`(1.0~3.0)配置比例,缺省仍为 0.2 / 1.5;最低价向下取整、最高价向上取整到人民币分。已确认颜色映射时按该颜色计算,待规格探测时以全部可用颜色的最低/最高价计算,参考价取最高价;没有可用颜色价格时不能创建。
|
||||
- 地址后缀为 `_cg{purchase_task.id}`。选好规格和数量后,Agent 仍停留在当前规格/下单面板;若收货地址被面板裁切,只允许在唯一、可见且占据主要宽度的纵向滚动容器内有限向下拉动来显示地址,禁止按页面最大滚动区域盲目滑动。Agent 只在唯一地址入口、唯一修改按钮和唯一详细地址输入框均成立时修改;脱敏手机号文字本身不可点击时,仍以该唯一文字节点的中心坐标执行一次精确手势点击,不沿用可能覆盖整个下单面板的可点击祖先。“修改”“保存”和“提交订单”等文字节点即使依赖可点击父节点,也必须保留原始文字节点作为每次重新定位的锚点,父节点只用于验证存在可点击路径。近乎完全重叠的无障碍重复节点按一个目标处理,仍有多个独立目标或页面切换超时则明确失败。按首个 `-` 或 `_` 截取地址主体后追加当前后缀,保存后必须回读完整新地址。修改或回读失败时禁止创建订单,地址全文和控件树不落库。
|
||||
- 地址后缀为 `_cg{purchase_task.id}`。精确规格、数量、价格和摘要核验通过后,若仍处于已识别的规格面板,Agent 只允许点击唯一、可见、启用、可点击且文案精确命中“确定/确认”别名的规格确认控件;不得点击提交订单、确认购买、付款、支付、地址修改或地址保存控件,也不使用中心手势兜底。点击后必须重新读取并确认已出现订单确认页或地址入口强证据,否则明确失败。进入订单确认页后,若收货地址被面板裁切,只允许在唯一、可见且占据主要宽度的纵向滚动容器内有限向下拉动来显示地址,禁止按页面最大滚动区域盲目滑动。Agent 只在唯一地址入口、唯一修改按钮和唯一详细地址输入框均成立时修改;脱敏手机号文字本身不可点击时,仍以该唯一文字节点的中心坐标执行一次精确手势点击,不沿用可能覆盖整个下单面板的可点击祖先。“修改”“保存”和“提交订单”等文字节点即使依赖可点击父节点,也必须保留原始文字节点作为每次重新定位的锚点,父节点只用于验证存在可点击路径。近乎完全重叠的无障碍重复节点按一个目标处理,仍有多个独立目标或页面切换超时则明确失败。按首个 `-` 或 `_` 截取地址主体后追加当前后缀,保存后必须回读完整新地址。修改或回读失败时禁止创建订单,地址全文和控件树不落库。
|
||||
- 地址编辑页可以同时存在收货人、手机号和详细地址等多个输入框;Agent 只选择与“详细地址”标签纵向重叠且位于其右侧的唯一输入框,不能用页面输入框总数或顺序猜测。
|
||||
- 点击创建订单前,Agent 必须先在本地事务保存 `order_submit_started`、不可逆时间、稳定请求 ID 和不含地址全文的最终确认快照,再用同一请求 ID通知服务端;两侧成功后才允许精确点击唯一创建订单按钮一次。
|
||||
- 进入不可逆边界后,进程重启、断网、点击结果不明或无法取得唯一订单号/下单时间时只允许只读核单并进入 `order_result_unknown`,禁止再次点击;任务与订单正式关联仍以完整 PDD 订单号为准。
|
||||
- 创建订单后出现 Android 多微信应用选择器时,Agent 只有在系统包和 `ChooserActivity` / `ResolverActivity` 白名单、已知选择器标题、微信候选三类证据同时成立时,才允许按一次返回,绝不点击微信候选。若已进入真实微信包 `com.tencent.mm`,Agent 不点击、不输入、不登录、不支付、不强制停止微信,只允许用一次无参数 PDD 启动 Intent 把既有 PDD 任务栈拉回前台;第二次仍见微信或拉起失败即保持结果未知。回到 PDD 已知支付 Activity 或明确支付动作页面后最多再返回一次,再只读读取唯一订单号和下单时间;任一页面不在白名单、恢复动作重复、无法到达订单详情或结果不唯一时保持 `order_result_unknown`,禁止重下单或支付。
|
||||
- 创建订单后出现 Android 多微信应用选择器时,Agent 只有在系统包和 `ChooserActivity` / `ResolverActivity` 白名单、已知选择器标题、微信候选三类证据同时成立时,才允许按一次返回,绝不点击微信候选。若已进入真实微信包 `com.tencent.mm`,Agent 不点击、不输入、不登录、不支付、不强制停止微信,只允许用一次无参数 PDD 启动 Intent 把既有 PDD 任务栈拉回前台;第二次仍见微信或拉起失败即保持结果未知。回到 PDD 已知支付 Activity 或明确支付动作页面后最多再返回一次,再只读读取唯一订单号和下单时间;下单成功页仅可点击唯一、精确的“查看订单”或“订单详情”入口一次,进入 PDD 订单详情后才允许有限向上手势下翻读取折叠内容。PDD 页面出现“待付款”“待支付”“订单编号”或“下单时间”等订单结果信号时,该只读信号优先于复用的支付 Activity 名称:允许在唯一主要纵向容器内有限下翻并重新读取,仍不得点击支付;没有任何订单结果信号的支付页或未知页面不得盲目滑动。入口缺失或不唯一、点击后未出现详情证据、任一页面不在白名单、恢复动作重复或结果不唯一时保持 `order_result_unknown`,禁止重下单或支付。
|
||||
- Agent 本地 SQLite/Outbox 负责断网和重启恢复,服务端以 `task_id + task_attempt_id` 幂等接收并保存最终事实。
|
||||
- 人工支付复核只记录 `paid` / `unpaid`;系统不执行或识别支付。快递单号与回填状态属于采购任务,后续物流工单实现。
|
||||
- 采购任务领取时同时占用设备租约和可选 PDD 账号租约;租约过期后才可释放并重新领取。设备还存在采集任务时不能领取采购任务。
|
||||
- 采购规格由服务端按顺序决策:先使用已确认的人工映射;否则仅在同一规格角色的可选 PDD 原始标签中做唯一确定性匹配(繁体转简体、空格/全半角/大小写统一,以及公斤/斤换算);仍无唯一结果才调用已启用的服务端 AI。AI 必须返回候选集中的原始标签,候选不完整、歧义、AI 无结果或服务不可用均明确失败,不派发第二趟、更不创建订单。
|
||||
- 自 #215 起,每个新 SYB 采购任务无论是否存在已确认长期映射,都先由 Agent 在当次 PDD 规格面板内有界遍历颜色和尺码并回传原始候选。服务端只以任务冻结的 SYB 目标规格与当次候选先做唯一确定性匹配(繁体转简体、空格/全半角/大小写统一,以及公斤/斤换算),仍无唯一结果才调用已启用的服务端 AI。AI 必须逐字返回当次候选集中的原始标签;候选不完整、集合外返回、歧义、AI 无结果或服务不可用均明确失败,不派发第二趟、更不创建订单。
|
||||
- Agent 选择服务端下发的精确颜色或尺码时,只能在已确认打开的规格面板内有限纵向滑动、每次重新读取可选节点并按完整原始文字点击;连续没有新证据或达到上限即停止,不得点击相近规格。第一趟探测并固化规格后,第二趟仍无法精确选择而再次提交探测时,服务端必须明确失败并释放活动槽,保留第一次决策证据,禁止清空决策、循环派发或进入地址与创建订单动作。
|
||||
- 采购 Agent 在确认进入 PDD 商品页后、打开规格前,复用采集侧的假售罄识别;规格面板已打开且当前解析到的规格值全部不可选时也按售罄处理。两种情况都只允许关闭规格面板后对商品页执行一次有界恢复(默认下拉 2 次、间隔 1000 毫秒、等待 2000 毫秒),恢复动作失败或恢复后仍售罄时返回 `PDD_GOODS_SOLD_OUT`,恢复后商品页证据丢失时按页面规则不匹配失败;不得继续选择规格、修改地址或创建订单。该失败码继续进入既有替代商品资格判定。
|
||||
- 规格映射不完整、已保存的确认映射对当前 PDD 档案失效、确定性匹配失败、PDD 档案为待采集或没有规格时,规则必须具有 `purchase.spec-probe.v1`;自 #190 起前两种情况不再拒绝创建,而是把 `spec_source` 降级为 `unresolved` 并交由规格探测解析,规则不具备该能力时仍然拒绝创建;第一趟只探测规格并释放租约,服务端固化同一 attempt 的决策后才派发第二趟。Android 不自行匹配或猜测;其只接收服务端已经固化的精确原始规格标签。
|
||||
- 所有新 SYB 采购任务的规则都必须具有 `purchase.spec-probe.v1`;创建时任务级 `mappedColor` / `mappedSize` 为空且 `spec_source=unresolved`,长期映射和 PDD 档案候选都不能跳过首趟探测。第一趟只探测规格并释放租约,服务端固化同一 attempt 的决策后才派发第二趟;探测阶段从服务端拒绝进入 `order_submit_started`。Android 不自行匹配或猜测,只在第二趟接收并精确选择服务端固化的当次 PDD 原始标签。
|
||||
- Agent 提交的相同 attempt 最终结果只能写入一次;相同请求重放返回原事实,不同内容拒绝覆盖。`order_result_unknown` 不参与自动派发,只能人工解除。
|
||||
- 已创建订单默认禁止再次采购;管理员或采购员可以做一次性重新采购授权,新任务创建成功时在同一事务消耗授权,旧任务和旧订单保留。已标记为已支付的订单不能授权或创建重新采购任务。
|
||||
- 人工回填候选只允许从已支付订单选择;同一 SYB 明细后来选择的订单覆盖旧候选,但不删除旧订单事实。
|
||||
- Admin 采购管理只查看和处理已有任务,不提供创建入口或支付按钮;单条和批量采购任务都从 SYB 商品列表发起。订单结果未知时必须先人工核对并解除;处于该状态时页面不提供重新采购授权。
|
||||
- 采购失败任务可在采购管理当前页批量勾选重试,最多 100 条。重试不修改旧任务,而是用当前 SYB/PDD 档案、当前规格映射、当前价格保护和当前采购规则创建新的 `pending` 任务,并生成新任务编号与地址后缀;来源蝦皮订单号沿用失败任务的不可变快照。
|
||||
- 采购失败任务可在采购管理当前页批量勾选重试,最多 100 条。重试不修改旧任务,而是用当前 SYB/PDD 档案、目标规格、当前价格保护和当前采购规则创建新的 `pending` 任务,并生成新任务编号与地址后缀;来源蝦皮订单号沿用失败任务的不可变快照。新任务不复用旧任务或长期档案的执行规格,仍必须先完成当次真机规格探测。
|
||||
- 只有正式采购、未进入不可逆边界、没有订单号或下单时间、且仍是同一 SYB 最新记录的 `failed` 任务可重试。原设备离线、停用、忙碌或能力不足时该项失败且不自动换机;未指定设备时仍由空闲设备领取。
|
||||
- 批量重试逐项处理并允许部分成功;同一请求幂等重放不会重复创建。失败任务不再使用一次性重新采购授权,该授权只保留给已经创建过订单且满足条件的任务。
|
||||
|
||||
@@ -251,16 +256,14 @@ synchronized_at: 2026-09-01T09:55:26Z
|
||||
|
||||
## Agent 受控重试采购
|
||||
|
||||
- 当前设备只可就地重跑自身最近 30 天内、服务端标记 `retryable=true` 的正式采购失败任务;列表和详情都只能发起单任务重试,不支持多选、批量或自动重试。
|
||||
- 普通“重试采购”复用原 `purchase_task.id`,不新建任务,不改变商品、虾皮/SYB 身份、目标规格、已映射规格、价格保护、地址后缀与其他业务快照。重试只把任务恢复为 `pending`,清理错误、租约和运行守卫。
|
||||
- 重试时刷新服务端当前采购规则及其类型、schema 和能力要求。#127 尚未实施前,当前规则仍为服务端内置规则;#127 完成后才切换为数据库单例设置,不能在本工单提前引入第二事实源。
|
||||
- 新规则下发前必须重新校验原设备在线、空闲且具备全部能力。规则不可用或能力不匹配时拒绝重试,原任务保持失败状态。
|
||||
- 已出现 `order_submit_started` 证据,或存在不可逆时间、订单提交请求、PDD 订单号、下单时间的任务一律拒绝就地重试,并提示走既有“授权重新采购”流程,防止重复下单。
|
||||
- 每次成功受理就地重试都会为同一任务预建一个新的 `pending` attempt,保存本次规则哈希;正常 Start 复用并转为 `running`。因此尝试次数、每次规则哈希和失败原因均可追溯,且不限制人工重试次数。
|
||||
- `requestId` 按“任务 + 请求”幂等;相同请求重放不重复修改任务或增加 attempt。
|
||||
- Agent 详情显示已尝试次数与上次失败原因。确认界面必须说明同一任务使用最新规则重跑、可能创建待付款订单且系统不会支付。
|
||||
- “继续采购”与普通“重试采购”语义不同:替代商品匹配完成后的“继续采购”继续调用既有 `AgentRetry → BatchRetry → Create`,保留旧任务并按当前替代商品档案创建新任务;Admin 批量重试同样继续创建新任务。二者均不得改成就地更新旧任务。
|
||||
- 取消订单、修改既有订单和支付仍禁止;真机重跑可能进入创建待付款订单流程,执行前必须再次取得人工授权。
|
||||
- 当前设备只可重试自身最近 30 天内、服务端标记 `retryable=true` 的正式采购失败任务;列表和详情都只能发起单任务重试,不支持多选、批量或自动重试。
|
||||
- 普通“重试采购”调用既有 `AgentRetry → BatchRetry → Create`:原失败任务及其商品、目标规格、执行规格、价格和执行记录保持不可变;服务端根据当前 SYB、虾皮/PDD 档案、当前采购规则和当前设备创建不同 `purchase_task.id` 的新任务。
|
||||
- 新 SYB 采购任务继续遵循 #215 的强制当次规格探测,首趟不得直接使用历史任务的规格决策;当前档案、规则、价格、设备或能力门禁不通过时拒绝创建,旧任务保持失败状态。
|
||||
- 已出现 `order_submit_started` 证据,或存在不可逆时间、订单提交请求、PDD 订单号、下单时间的任务一律拒绝重试,并提示走既有“授权重新采购”流程,防止重复下单。
|
||||
- `requestId` 按“来源任务 + 请求”幂等;相同请求重放返回同一新任务,不重复创建。
|
||||
- Android 只在服务端 `retryable=true` 且状态为 `failed` 时显示普通“重试采购”。确认和成功反馈必须说明旧任务保留、新任务读取当前档案和规则、可能创建待付款订单且系统不会支付。
|
||||
- 历史兼容的就地 `/reset` 服务端入口不得把“目标规格存在但对应执行规格为空”的任务恢复到正式采购阶段;此类异常快照必须拒绝,并提示创建新任务。Android 普通重试不再调用该入口。
|
||||
- 替代商品匹配完成后的“继续采购”和 Admin 批量重试继续使用同一新任务语义;取消订单、修改既有订单和支付仍禁止。真机重试可能进入创建待付款订单流程,执行前必须再次取得人工授权。
|
||||
|
||||
## Agent 状态页手动检查任务
|
||||
|
||||
@@ -331,12 +334,16 @@ synchronized_at: 2026-09-01T09:55:26Z
|
||||
- 自动确认必须保存来源、实际置信度和脱敏限长原因;人工确认完成后同步对应分项及主表总体状态。worker 不得覆盖已由人工改变的映射。
|
||||
- 纠错不能简单执行 B→C;必须把原 A→B 记录置为 `superseded`,建立 A→C,并只处理原记录冻结的虾皮商品影响集合。
|
||||
|
||||
## 创建采购任务时的异步规格匹配(#148)
|
||||
## 历史:创建采购任务时的异步规格匹配(#148,#215 后仅兼容存量)
|
||||
|
||||
- 创建任务继续同步使用已确认映射和唯一确定性 `exact_match`;只有需要外部 AI 时,任务与 `purchase_spec_match_work_item` 在同一事务创建,接口立即返回。活动工作项或 `manual_required` 未处理前,`Next`、`Claim`、`Start` 均拒绝任务。
|
||||
- AI 结果只固化到采购任务快照,不自动写回虾皮商品长期规格映射。`ai_match` 必须达到设置阈值、严格属于当前候选、原因非空且写入前输入指纹未变化;否则转人工,Agent 不猜测。
|
||||
- provider 网络、超时或 5xx 按 30 秒、2 分钟、10 分钟退避,最多尝试 3 次;预算内恢复会自动继续,耗尽后转 `manual_required`。Admin 可重新入队或从当前候选人工选择。
|
||||
- 异步 provider 单次调用最长 60 秒,即 `min(配置超时, 60 秒)`;设置页测试连接仍使用完整配置超时。取消任务会同时终结活动工作项,输入变化会关闭旧工作项并要求重新入队。
|
||||
- #215 起,新建 SYB 采购任务不再根据 PDD 档案创建 `purchase_spec_match_work_item`,也不在首次派发前调用外部 AI;规格决策统一来自首趟真机探测的当次候选。
|
||||
- 部署前已存在的匹配工作项继续保留审计、恢复、重试和人工处理能力;其 AI 封闭候选、置信度、输入指纹、退避和最长 60 秒调用边界不变,但不能成为新任务跳过当次探测的依据。
|
||||
|
||||
## SYB 采购强制当次规格探测(#215)
|
||||
|
||||
- 每个新 SYB 采购任务固定执行“首趟只读探测 → 服务端确定性优先/必要时 AI → 固化任务级精确规格 → 第二趟正式采购”。首趟只打开一次浏览器商品链接;匹配期间当前设备保留给同一任务,不领取其他采购或采集任务;第二趟复用 PDD 当前页,不再次打开链接,也不严格核验标题、goodsId 或页面指纹,但仍要求 PDD 包名与商品/规格/订单页面结构安全证据。已有长期映射只作商品档案事实,不直接进入任务执行规格。
|
||||
- 首趟候选与 `taskId`、`taskAttemptId`、`deviceId`、规则快照哈希和幂等结果哈希关联;第二趟失败不得回到首趟循环探测。备货 `stock/direct_select` 没有 SYB 目标规格,继续使用用户逐字选择的档案规格,不进入本规则。
|
||||
- 候选和 Provider 结果仅保存颜色、尺码原始标签及结构化决策,不保存控件树、整屏截图、账号、地址、订单或支付数据;付款仍永久禁止。
|
||||
|
||||
## Agent 手动采集替代商品(#130)
|
||||
|
||||
@@ -386,6 +393,13 @@ synchronized_at: 2026-09-01T09:55:26Z
|
||||
- Agent APK 版本由管理员在设备页抽屉中上传、核对并显式设为当前。服务端从 APK Manifest 读取整数 `versionCode` 和 `versionName`,保存 SHA-256、大小、说明和创建人;APK 位于非公开目录,Admin 与设备下载均需认证。
|
||||
- Agent 只以整数 `versionCode` 判断更新。启动时静默检查一次,设置页可手动检查、显示下载进度并取消;下载完成必须校验大小和 SHA-256。设备有活动任务时禁止检查、下载和安装;安装始终交给 Android 系统确认,项目不绕过未知来源权限。
|
||||
|
||||
## Android 采购规格动作安全执行(#214)
|
||||
|
||||
- 规格入口与精确规格选择统一按“重新定位唯一目标 → 执行一次无障碍点击 → 读取新页面验证”执行;不得复用旧无障碍节点,不选择相近规格,也不在多候选时默认点击第一个。
|
||||
- 无障碍点击后页面完全无变化时,只允许对解析器已确认的安全规格入口或服务端下发且唯一命中的精确规格执行一次中心手势兜底。目标必须可见、启用、边界有效,手势后仍须以规格面板强证据或精确选中证据确认结果。
|
||||
- 页面发生变化但规格面板强证据不足时,不再继续手势或猜测页面,明确失败并只记录面板类型、候选数量和证据布尔值等无敏感标量。原始控件树、节点文字集合和整屏截图仍不得保存或上传。
|
||||
- 规格已处于精确选中状态时不得重复点击。规格查找只在解析器唯一识别的规格面板容器内有限滚动,每次滚动后重新定位容器与目标;容器缺失、歧义、到边或验证失败均 fail-closed。
|
||||
- 中心手势兜底不得用于修改/保存地址、创建或提交订单、订单详情入口以及任何支付/付款目标;正式创建订单的一次性不可逆门禁与永久禁止支付规则不变。
|
||||
## PDD 商品反向关联与继续订单采购(#161)
|
||||
|
||||
- 一个 PDD 商品可被多个虾皮商品共用。PDD 详情返回全部关联虾皮商品摘要;订单行由独立统一分页接口读取,不按虾皮商品伪造嵌套分页。
|
||||
@@ -393,3 +407,27 @@ synchronized_at: 2026-09-01T09:55:26Z
|
||||
- 规格映射摘要使用 `specMappingStatus=complete|incomplete|not_required`、确认数和总数。`not_required` 只表示虾皮商品没有颜色或尺码维度;商品摘要不替代订单行采购资格。
|
||||
- “关联订单继续采购”只处理已有 SYB 订单;“创建备货采购”不关联 SYB 订单。两者入口和文案必须明确区分。继续采购复用既有批量预检和批量创建,价格、映射、并发、订单和不可逆门禁不变。
|
||||
- SYB 订单号、目标颜色/尺码、数量和当前采购任务状态只允许管理员与采购员读取;其他角色由服务端拒绝。查询路径不调用 AI Provider。
|
||||
|
||||
## 定时与手动蝦皮规格自动匹配(#195)
|
||||
|
||||
- 系统只扫描存活且已关联 `active` PDD 商品、两边至少共享颜色或尺码角色、并且至少存在一个未确认或已失效映射的蝦皮商品。
|
||||
- 每个商品复用详情页“一键匹配颜色和尺码”的规则:保留当前仍有效的 `confirmed` 映射;唯一确定匹配和达到服务端阈值、理由非空、候选仍有效的 AI 结果直接保存为 `confirmed`;低置信度、无结果、Provider 异常或上下文漂移不猜测、不写入错误映射。
|
||||
- 定时与管理员手动执行共用全局活动槽和逐商品工作状态。单批默认最多 20 个商品;同一规格上下文与 AI 设置更新时间未变化时,已完成、低置信度或无结果商品不重复调用 AI。
|
||||
- Provider 临时失败最多尝试 3 次,间隔至少 60 分钟;输入变化后重新计算指纹并允许重新处理。运行记录只保存结构化计数和脱敏限长错误,不保存 API Key、Provider 原始响应、商品原始 JSON 或个人数据。
|
||||
- 系统定时任务迁移后默认关闭,须由管理员明确启用。该能力仅维护蝦皮与 PDD 颜色/尺码映射,不创建采购任务、PDD 订单,不触发 Agent,也不执行付款。
|
||||
|
||||
## SYB 异常采购规格 AI 解析(#198)
|
||||
|
||||
- SYB 采购规格采用两阶段流程:先把货运单明细 `productSpec` 解析为目标颜色/尺码,再由 #195 把蝦皮规格匹配到 PDD 规格;两阶段不得混为同一匹配事实。
|
||||
- 定时任务只处理未人工确认且确定性解析为 `uncertain` / `failed` 的明细。每条先重跑确定性解析;空 `productSpec`、无关联蝦皮商品、无颜色/尺码候选或同一角色存在多个候选维度时不调用 AI,继续人工处理。
|
||||
- AI 只在关联蝦皮商品的封闭候选集合中返回原始颜色/尺码,并必须提供达到当前自动确认阈值的置信度和非空理由;集合外值、缺失角色、低置信度、歧义、无结果和输入漂移都不得确认。
|
||||
- `parse_status` 保留确定性解析器结论;AI 与人工确认分别记录,人工优先级最高。重复同步不得覆盖人工值;完全相同输入保留 AI 值,来源或关联变化会清除旧 AI 确认。
|
||||
- 同一输入的低置信度或无结果不重复调用 Provider;临时故障至少 60 分钟后重试,最多 3 次。任务不创建采集/采购任务、订单,不执行 Android 动作或付款。
|
||||
|
||||
## 设备身份恢复
|
||||
|
||||
- 管理员可在设备管理对未停用设备发起“重置设备身份”。此动作不删除设备记录、不改变设备 ID、能力或已绑定的待领取任务;它立即使旧 Token 无效,并只生成一次、有效期 10 分钟的恢复码。
|
||||
- 恢复码仅显示给发起操作的管理员一次,服务端只保存摘要;不得进入列表、日志、任务记录、Android 持久化或普通接口。手机操作员必须在同一安装实例的 Agent 设置中手动输入。
|
||||
- 服务端只接受同一 `installId`、未过期且尚未使用的恢复码完成重新注册,成功后签发新 Token 并使恢复码失效。过期、重复使用、installId 不符或停用均明确失败;不能通过清空数据、直接改库或“吊销 Token”恢复原任务归属。
|
||||
|
||||
- 自 #223 起,任务创建时把与冻结 SYB 目标对应的已确认商品规格映射保存为不可变的探测指导快照,但仍不得跳过首趟真机探测。探测完成后,每个角色先验证快照映射能否按既有规范化规则唯一对应当次候选,能对应时固化当次候选原文;不能对应时只对该未解决角色执行确定性匹配,仍无结果才调用 AI。已解决角色不重复交给 AI,任一最终值仍必须逐字属于当次候选;历史映射失效、规范化后歧义或角色不符时不得复用。候选完整但无法决策时提示“已采集到当前规格,但未能确定颜色或尺码映射”,不再误报候选不存在。
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Local-Development-and-Verification
|
||||
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Local-Development-and-Verification.-
|
||||
wiki_revision: 0bb8d63fe2a3c6b1021bfa18800f5062de726868
|
||||
synchronized_at: 2026-08-31T16:06:55Z
|
||||
wiki_revision: 835494c4a63a1494601658561fde1ab76657be3d
|
||||
synchronized_at: 2026-09-05T07:17:05Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 本地开发与验证
|
||||
@@ -24,6 +24,32 @@ T01 已建立可执行的三端骨架。建议从仓库根目录运行统一脚
|
||||
- 不得仅为设置编码重复启动一层 PowerShell;嵌套进程会增加启动时间、转义复杂度和错误定位成本。
|
||||
- 代码发现优先使用项目配置的代码图工具;检索字符串、配置和非代码文件,或图工具不足时使用 `rg`。
|
||||
|
||||
### PowerShell 语法与外部命令
|
||||
|
||||
- Windows 命令不得默认套用 Bash 语法;复杂正则优先先赋给变量或使用 `rg -e`,避免在多层引号中继续嵌套。
|
||||
- 多行 Python 或 JSON 正文使用单引号 PowerShell here-string,避免 `$()`、反引号和变量被 PowerShell 提前展开:
|
||||
|
||||
```powershell
|
||||
$script = @'
|
||||
print("保持原文")
|
||||
'@
|
||||
$script | python -
|
||||
```
|
||||
|
||||
- `foreach`、`if` 等语句块应保留在同一个 PowerShell 解析上下文中;需要收集表达式结果时使用数组表达式:
|
||||
|
||||
```powershell
|
||||
$items = @(foreach ($path in $paths) {
|
||||
if (Test-Path -LiteralPath $path) { Get-Item -LiteralPath $path }
|
||||
})
|
||||
```
|
||||
|
||||
- `rg` 使用真实目录配合 `-g/--glob`,不要把 Bash 风格通配路径作为目录参数:
|
||||
|
||||
```powershell
|
||||
rg -n -g '*.md' 'sync --check' docs
|
||||
```
|
||||
|
||||
### 文件编码与控制台输出
|
||||
|
||||
文件解码和控制台输出是两个边界。读取 UTF-8 文本时,在命令支持的情况下显式指定字面路径与编码:
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Common-Changes
|
||||
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Common-Changes.-
|
||||
wiki_revision: b7d5520da954c544248e979c4ababb86d2784a43
|
||||
synchronized_at: 2026-08-27T09:38:36Z
|
||||
wiki_revision: b1b1b343917e66288f4282bc6b3b90ea4ff3cca0
|
||||
synchronized_at: 2026-09-04T11:30:12Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 常见修改指南
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Troubleshooting
|
||||
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Troubleshooting
|
||||
wiki_revision: a8d4790811aa825c78830cb9c71c6b21723e4584
|
||||
synchronized_at: 2026-08-28T03:35:05Z
|
||||
wiki_revision: b1b1b343917e66288f4282bc6b3b90ea4ff3cca0
|
||||
synchronized_at: 2026-09-04T11:30:16Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 故障排查
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Product-Requirements-Overview
|
||||
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Product-Requirements-Overview.-
|
||||
wiki_revision: 0d9924b4566e567edf167ba678fedde28a41cfd3
|
||||
synchronized_at: 2026-08-28T02:27:12Z
|
||||
wiki_revision: b1b1b343917e66288f4282bc6b3b90ea4ff3cca0
|
||||
synchronized_at: 2026-09-04T11:30:20Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 产品需求总览与当前 MVP
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Android-Agent-API-Contract
|
||||
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Android-Agent-API-Contract.-
|
||||
wiki_revision: c214791a0b055e5b56c0ad05e73829a1f141dd46
|
||||
synchronized_at: 2026-09-01T08:03:51Z
|
||||
wiki_revision: e5a443e16e288143c2c890c674038c531abe3f16
|
||||
synchronized_at: 2026-09-05T07:17:37Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# MVP 共享 API 契约
|
||||
@@ -405,7 +405,7 @@ POST /api/agent/v1/tasks/{taskId}/fail
|
||||
|
||||
- `executionMode` 只能是 `rehearsal` 或 `live`,创建后不可修改。
|
||||
- 正式 `live` 任务必须引用一条 `syb_product`;无副作用的 `rehearsal` 可以不引用 SYB。
|
||||
- 创建时固化 SYB、蝦皮订单号、虾皮商品、PDD 商品、目标规格、映射规格、数量、价格区间、币种、URL、`goods_id`、规则和可选 PDD 账号引用。正式任务的 `shopeeOrderNoSnapshot` 来自创建时的 `syb_product.order_code`;同一订单号可对应多条任务,之后来源商品修改不会改写任务快照。
|
||||
- 创建时固化 SYB、蝦皮订单号、虾皮商品、PDD 商品、目标规格、数量、价格区间、币种、URL、`goods_id`、规则和可选 PDD 账号引用。新建 SYB 任务的执行映射初始为空,首趟当次 PDD 规格探测决策成功后才固化 `mappedColor` / `mappedSize`;正式任务的 `shopeeOrderNoSnapshot` 来自创建时的 `syb_product.order_code`,之后来源商品修改不会改写任务快照。
|
||||
- Android 无法可靠读取当前 PDD 账号,因此 `pddAccountId` 和 `pddAccountRefSnapshot` 均可空。已知账号时参与账号级串行,未知时不阻止任务。
|
||||
- 地址后缀由任务 ID 唯一确定为 `_cg{taskId}`;#34 创建任务时必须在同一事务内回写快照。
|
||||
- 一个任务最多对应一个 PDD 订单;重新采购必须新建任务,旧任务和旧订单保留。
|
||||
@@ -415,7 +415,7 @@ POST /api/agent/v1/tasks/{taskId}/fail
|
||||
| 状态 | 含义 | 是否占用 SYB 活动槽 |
|
||||
|---|---|---|
|
||||
| `pending` | 待执行 | 是 |
|
||||
| `spec_probe_pending` | 第一趟探测结束,待服务端固化规格并重新派发 | 是 |
|
||||
| `spec_probe_pending` | 第一趟探测结束,服务端规格匹配中;当前设备保留同任务连续流程且不得领取其他任务 | 是 |
|
||||
| `running` | Agent 执行中 | 是 |
|
||||
| `rehearsal_completed` | 演练安全结束,未改地址、未创建订单 | 否 |
|
||||
| `order_submit_started` | 不可逆标记已落库,只能核单,禁止再次点击 | 是 |
|
||||
@@ -493,6 +493,16 @@ POST /api/agent/v1/tasks/{taskId}/fail
|
||||
|
||||
演练规则必须包含 `purchase.rehearsal.v1`,并且不能包含 `updateShippingAddress`、`createOrder`、`readOrderResult`。正式规则必须包含 `purchase.live.v1`;改地址和创建订单还分别要求 `purchase.address-update.v1`、`purchase.order-create.v1`。`probeSpecs` 要求 `purchase.spec-probe.v1`。任意模式下,尚未实现的 `pay`、名称包含 `payment` 的动作以及未知动作一律拒绝;服务端不下发任意脚本。
|
||||
|
||||
### Android 规格动作执行边界(#214)
|
||||
|
||||
本工单不改变采购规则 schema、Agent HTTP 字段或能力标识,只收紧 Android 对 `openSpecPanel` 与 `selectSpec` 的执行语义:
|
||||
|
||||
- 每次操作从最新 `rootInActiveWindow` 重新定位;规格入口必须是解析器已接受的唯一安全候选,规格值必须逐字等于任务固化的服务端映射值且在当前维度唯一、可用。
|
||||
- 动作前先检查后置条件;规格已精确选中时直接成功,不重复点击。否则最多分发一次 `ACTION_CLICK`,再从新快照验证规格面板强证据或精确选中证据。
|
||||
- `ACTION_CLICK` 后页面结构完全无变化时,Android 可以重新定位同一唯一目标,并在目标可见、启用、中心点位于屏幕内且边界有效时执行一次中心手势。手势后仍未取得后置证据时明确失败,不继续点击。
|
||||
- 点击后页面已变化但面板分类仍为未知时返回 `PURCHASE_SPEC_PANEL_EVIDENCE_NOT_MATCHED`;两种点击后页面均无变化时返回 `PURCHASE_SPEC_ENTRY_CLICK_NO_EFFECT`。诊断只携带面板类型、滚动容器数、标题数、选项数及证据布尔值,不携带节点文字或完整控件树。
|
||||
- 有界查找规格时只使用当前解析结果中的唯一 `specPanelContainer`;每次滚动前重新定位该容器,优先使用节点滚动动作,失败后才在容器边界内使用手势。缺少唯一容器时停止,不回退到全页面最大滚动区域。
|
||||
- 受控中心手势接口固定拒绝地址、保存地址、创建/提交/确认订单、确认购买和支付/付款语义;`updateShippingAddress`、`createOrder`、`readOrderResult` 不使用该兜底。创建订单的一次性不可逆状态机与禁止支付契约不变。
|
||||
### 管理端接口
|
||||
|
||||
| 方法 | 路径 | 幂等键 / 说明 |
|
||||
@@ -501,7 +511,7 @@ POST /api/agent/v1/tasks/{taskId}/fail
|
||||
| `GET` | `/api/admin/v1/purchase-tasks/{taskId}` | 只读任务详情与 attempt 历史;不返回规则原文、Token、凭据、完整地址、控件树或截图 |
|
||||
| `POST` | `/api/admin/v1/purchase-tasks` | `requestId`;单条创建 |
|
||||
| `POST` | `/api/admin/v1/purchase-tasks/stock` | 创建备货采购;`requestId`、`executionMode`、`pddProductId`、可选 `deviceId` / `pddAccountId`、`color`、可选 `size`、`quantity`、`minUnitPriceCent`、`maxUnitPriceCent`。服务端使用内置规则并从所选颜色归档派生参考价;相同 `requestId` 幂等返回原任务 |
|
||||
| `POST` | `/api/admin/v1/purchase-tasks/batch-preview` | `sybProductIds`(1~100)和可选 `deviceId`;逐条返回采购是否可创建、价格区间、原因和下一步,并独立返回 PDD 采集资格 `collectionEligible` / `collectionDisabledReason` 与规格匹配资格 `aiMatchEligible` / `aiMatchDisabledReason`,不创建任务。只有目标颜色和尺码已保存为确认映射,且共同命中最近一次成功或部分成功采集中的完整可售 SKU 组合时,`eligible` 才为 `true`;只在内存中得到的确定性结果不算采购就绪 |
|
||||
| `POST` | `/api/admin/v1/purchase-tasks/batch-preview` | `sybProductIds`(1~100)和可选 `deviceId`;逐条返回采购是否可创建、价格区间、原因和下一步,并独立返回 PDD 采集资格 `collectionEligible` / `collectionDisabledReason` 与长期规格匹配资格 `aiMatchEligible` / `aiMatchDisabledReason`,不创建任务且不调用 AI Provider。自 #215 起,长期映射缺失或失效不再单独使 `eligible=false`;新任务会在首趟当次 PDD 页面探测中决定执行规格 |
|
||||
| `POST` | `/api/admin/v1/purchase-tasks/batch-spec-match` | `sybProductIds`(1~100);仅处理服务端预检 `aiMatchEligible=true` 的明细,其他明细按具体禁用原因返回 `skipped`,并逐条返回 `auto_confirmed` / `pending` / `failed` / `skipped`。资格要求 SYB 采购规格解析成功、蝦皮与 PDD 关联完整、PDD 当前规格可用,且最近一次成功或部分成功采集存在完整可售 SKU 组合证据;未人工确认的 `parse_status=uncertain` 不进入自动确认,`manuallyConfirmed=true` 与 `parse_status=success` 同等视为可信规格。唯一确定性 `exact_match` 不调用 Provider,直接通过独立路径保存为 `confirmed`;其余结果只有 `ai_match` 置信度达到 `autoConfirmMinConfidence`、理由非空、返回值属于当前候选且颜色+尺码命中同一个可售 SKU 组合时才保存为 `confirmed`。低置信度、无效组合和 Provider 异常不改写现有映射 |
|
||||
| `POST` | `/api/admin/v1/purchase-tasks/batch` | 批次 `requestId`、`sybProductIds`(1~100)和可选 `deviceId`;每条派生稳定幂等键并独立创建,部分失败不回滚成功项 |
|
||||
| `POST` | `/api/admin/v1/purchase-tasks/{taskId}/authorize-repurchase` | 一次性授权;创建新任务后自动消耗 |
|
||||
@@ -519,17 +529,17 @@ Admin 列表与详情由 #35 实现;#67 增加 `shopeeOrderNoSnapshot` 的列
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|---|---|---|
|
||||
| `GET` | `/api/agent/v1/purchase-tasks/next` | 返回与设备能力兼容的指定任务或空闲任务 |
|
||||
| `GET` | `/api/agent/v1/purchase-tasks/next` | 优先返回当前设备的运行任务;存在 `spec_probe_pending` 时返回同一等待任务以阻止其他任务插队,否则返回能力兼容的指定任务或空闲任务 |
|
||||
| `POST` | `/api/agent/v1/purchase-tasks/{taskId}/claim` | `requestId` 原子领取,并建立设备/可选账号租约 |
|
||||
| `POST` | `/api/agent/v1/purchase-tasks/{taskId}/start` | 创建不可变 `taskAttemptId` |
|
||||
| `POST` | `/api/agent/v1/purchase-tasks/{taskId}/order-submit-started` | 创建订单前先落不可逆标记;演练任务永远拒绝 |
|
||||
| `POST` | `/api/agent/v1/purchase-tasks/{taskId}/order-submit-started` | 创建订单前先落不可逆标记;演练任务和 `spec_probe` attempt 永远拒绝 |
|
||||
| `POST` | `/api/agent/v1/purchase-tasks/{taskId}/result` | 请求体携带 `taskAttemptId` 和 `requestId`;幂等提交演练、规格探测、订单或失败结果 |
|
||||
|
||||
创建阶段需要外部 AI 的任务会先以 `pending` 与持久匹配工作项同事务创建并立即返回 Admin。工作项处于 `pending`、`running`、`retry_wait` 或 `manual_required` 时,`next` 不返回该任务,直接调用 `claim` 或 `start` 也会返回状态冲突。匹配完成后 Agent 仍只接收服务端固化的精确 PDD 原始标签;Android 接口和请求体不新增 AI、候选或人工决策字段。
|
||||
自 #215 起,新建 SYB 采购任务不再从 PDD 档案创建持久匹配工作项,也不在首次派发前调用外部 AI;部署前已存在的 `purchase_spec_match_work_item` 继续按原状态兼容处理。新任务首次 `start` 固定得到 `phase=spec_probe`,Android 通过浏览器打开任务链接一次并经既有结果字段回传当次候选;匹配成功后的第二次 `start` 才得到 `phase=purchase` 和服务端固化的精确 PDD 原始标签。第二阶段直接复用首趟保留的 PDD 页面,不再次打开浏览器链接,也不以标题、goodsId 或页面指纹做严格同页校验;仍必须通过 PDD 包名和商品/规格/订单页面结构安全证据。Android 不接收 AI 配置或自由决策权限。
|
||||
|
||||
结果提交至少关联 `taskId`、`taskAttemptId`、`deviceId`、规则快照哈希和结构化结果。相同 attempt 的相同结果重复提交返回同一事实;不同内容拒绝覆盖。慢路径第一趟提交规格后释放设备与已知账号租约,任务进入 `spec_probe_pending`;服务端先使用已确认人工映射,否则对实时/档案可选规格做繁简、空白/全半角/大小写及公斤/斤的唯一确定性匹配,仍无唯一结果才调用 AI。第二趟只会收到服务端已固化的精确 PDD 原始标签;Agent 只在已打开的规格面板内做有限纵向滑动,每次重新读取节点并按完整规范化文字精确点击,连续没有新证据或达到上限即停止。尺码的任务目标与页面值在选择边界使用同一安全尾价规范化;不改写任务快照,规范化为空、仍含货币符号或多个原始候选折叠为同一值时安全失败。
|
||||
结果提交至少关联 `taskId`、`taskAttemptId`、`deviceId`、规则快照哈希和结构化结果。相同 attempt 的相同结果重复提交返回同一事实;不同内容拒绝覆盖。每个新 SYB 采购任务的第一趟只读遍历当次 PDD 规格面板并提交颜色、尺码原始候选,随后释放数据库租约和已知账号运行守卫并进入 `spec_probe_pending`,但服务端调度与 Agent 必须把当前设备保留给同一采购流程:`next` 返回该等待任务,Agent 只轮询等待,不领取其他采购或采集任务。服务端只以任务冻结的 SYB 目标和当次候选先做繁简、空白/全半角/大小写及公斤/斤的唯一确定性匹配,仍无唯一结果才调用 AI。AI 的颜色和尺码必须逐字属于当次对应候选,否则按无匹配失败。第二趟只会收到服务端固化的精确 PDD 原始标签;Agent 复用首趟仍打开的页面,只在已打开的规格面板内做有限纵向滑动,每次重新读取节点并按完整规范化文字精确点击,连续没有新证据或达到上限即停止。尺码的任务目标与页面值在选择边界使用同一安全尾价规范化;不改写任务快照,规范化为空、仍含货币符号或多个原始候选折叠为同一值时安全失败。
|
||||
|
||||
任务 payload 新增必传布尔字段 `specResolutionAllowed`,它是 Android 是否可以把一次 `PURCHASE_SPEC_TARGET_NOT_VISIBLE` 转为 `spec_probe_completed` 的唯一资格事实。服务端仅在 `taskType=syb_order`、规则声明 `purchase.spec-probe.v1`、没有固化 `SpecDecisionRequestID` 且规格来源允许既有慢路径时返回 `true`;`stock`、`direct_select`、已固化规格决策、能力缺失及其他组合均返回 `false`。普通就地重试保留 `SpecDecisionRequestID`、目标规格、映射规格和规格决策快照,不能恢复探测资格。Android 不得根据映射是否非空、执行 phase、错误文字或本地判断扩大资格。
|
||||
任务 payload 的必传布尔字段 `specResolutionAllowed` 是 Android 是否可以提交规格探测的唯一资格事实。新建 `taskType=syb_order` 任务必须由声明 `purchase.spec-probe.v1` 的规则创建,初始 `SpecDecisionRequestID` 为空且 `specSource=unresolved`,首趟返回 `true`;当次决策固化后返回 `false`。`stock`、`direct_select`、已固化规格决策、能力缺失及其他组合均返回 `false`。历史兼容的就地 `/reset` 保留 `SpecDecisionRequestID`、目标规格、映射规格和规格决策快照,不能恢复探测资格;映射不完整时必须拒绝,不能进入正式采购阶段。普通 Agent 重试创建新任务并重新取得一次探测资格。Android 不得根据映射是否非空、错误文字或本地判断扩大资格。
|
||||
|
||||
Android 规格失败使用五个稳定阶段:`PURCHASE_SPEC_TARGET_NOT_VISIBLE`、`PURCHASE_SPEC_TARGET_AMBIGUOUS`、`PURCHASE_SPEC_SAFE_TARGET_MISSING`、`PURCHASE_SPEC_CLICK_FAILED` 和 `PURCHASE_SPEC_SELECTION_UNCONFIRMED`。`PURCHASE_SPEC_CLICK_FAILED` 的 `errorMessage` 只允许稳定子原因 `root_unavailable`、`target_stale`、`no_clickable_ancestor`、`action_click_false` 或 `unknown`;其他阶段的消息不得包含规格原文、坐标、控件树或截图。只有 `PURCHASE_SPEC_TARGET_NOT_VISIBLE && specResolutionAllowed=true` 可以提交规格探测,其他四态直接提交真实失败,服务端原样保留稳定阶段/子原因。旧 Agent 在资格已用尽后再次提交 `spec_probe_completed` 时,服务端以 `PURCHASE_SPEC_REPROBE_REJECTED` fail-closed,释放租约并保留第一次规格决策,不再冒充新的选择根因或再次派发。无匹配、候选不完整、歧义或 Provider 异常同样使任务失败。`order_result_unknown` 只允许管理员或采购员人工解除,永不自动重派。
|
||||
|
||||
@@ -607,12 +617,12 @@ Content-Type: application/json
|
||||
- 响应返回 `taskId`、`attemptNumber`、`status` 和可选的 `replayed`,不返回规则快照、URL、Token、控件树或截图。
|
||||
- 设备离线、任务非终态、设备忙、规则不可用或同商品存在活动任务时返回明确冲突,不支持离线排队。
|
||||
|
||||
## Agent 受控采购重试(#95、#157)
|
||||
## Agent 受控采购重试(#95、#157、#217)
|
||||
|
||||
普通失败任务的“重试采购”改为就地重跑:
|
||||
普通失败任务的“重试采购”和替代商品匹配完成后的“继续采购”统一调用新任务接口:
|
||||
|
||||
```http
|
||||
POST /api/agent/v1/purchase-tasks/{taskId}/reset
|
||||
POST /api/agent/v1/purchase-tasks/{taskId}/retry
|
||||
Authorization: Bearer <device-token>
|
||||
Content-Type: application/json
|
||||
|
||||
@@ -624,37 +634,35 @@ Content-Type: application/json
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"taskId": 12,
|
||||
"taskNo": "CG-12",
|
||||
"attemptNumber": 2,
|
||||
"status": "pending",
|
||||
"sourceTaskId": 12,
|
||||
"sourceTaskNo": "CG-12",
|
||||
"taskId": 13,
|
||||
"taskNo": "CG-13",
|
||||
"replayed": false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- 原任务必须属于当前 Device Token、在最近 30 天内、状态为 `failed`,且是分配给该设备的正式 SYB 采购任务;跨设备或超期按任务不存在处理。
|
||||
- 任务不得存在 `irreversibleAt`、`orderSubmitRequestId`、PDD 订单号或下单时间。已有任何不可逆证据时返回 `PURCHASE_RETRY_UNSAFE`,提示走“授权重新采购”,不得恢复为待执行。
|
||||
- 服务端在同一事务锁定任务和设备,确认同一 SYB 商品没有更新任务、设备在线且空闲,然后读取当前服务端采购规则,重新校验 schema、动作安全边界与设备能力。
|
||||
- 成功时复用原 `purchase_task.id`,只刷新 `ruleSnapshot`、`ruleType`、`ruleSchemaVersion`、`requiredCapabilities`,清除错误、租约和运行守卫并恢复 `pending`。商品、Target、Mapped、价格保护、数量、地址后缀及其他业务快照逐字段保持不变。
|
||||
- 每次受理创建该任务的新 `pending` attempt 并记录规则哈希;Start 复用该 attempt 转为 `running`,不会重复创建执行记录。相同 `requestId` 重放返回相同 attempt 且 `replayed=true`;不同请求可在任务再次失败后继续重试,不限制次数。
|
||||
- 采购详情新增 `attemptCount`、可选 `lastFailureCode` 和 `lastFailureMessage`,供 Agent 显示已尝试次数与上次失败原因;不返回规则快照、Token、地址、控件树或截图。
|
||||
- Android 仍只在服务端 `retryable=true` 且状态为 `failed` 时显示普通“重试采购”。确认文案必须说明任务号不变、使用最新规则重跑、可能产生待付款订单且系统不会支付。
|
||||
- 规则无效、设备离线/忙、能力不匹配、任务状态变化或同一 SYB 商品已有更新任务时,服务端明确拒绝且不得部分修改任务。
|
||||
- 来源任务必须属于当前 Device Token、在最近 30 天内、状态为 `failed`,且是分配给该设备的正式 SYB 采购任务;跨设备或超期按任务不存在处理。
|
||||
- 来源任务不得存在 `irreversibleAt`、`orderSubmitRequestId`、PDD 订单号或下单时间。已有任何不可逆证据时返回 `PURCHASE_RETRY_UNSAFE`,提示走“授权重新采购”,不得创建新任务。
|
||||
- `AgentRetry → BatchRetry → Create` 保留来源失败任务并创建不同 `purchase_task.id` 的新任务;新任务重新读取当前 SYB、虾皮/PDD 档案、当前采购规则、价格保护和设备能力,重新生成地址后缀,不继承来源任务的旧规格决策。
|
||||
- 新 SYB 任务按 #215 固定从 `spec_probe` 开始。相同 `requestId` 重放返回同一新任务且 `replayed=true`;不同 requestId 再次请求受同一 SYB 商品最新任务和设备并发门禁约束。
|
||||
- Android 只在服务端 `retryable=true` 且状态为 `failed` 时显示普通“重试采购”;确认文案和成功反馈必须说明原任务保留、新任务使用当前档案与规则、可能产生待付款订单且系统不会支付。
|
||||
- 规则无效、当前档案或价格不合格、设备离线/忙、能力不匹配、任务状态变化或同一 SYB 商品已有更新任务时,服务端明确拒绝且不得部分创建。
|
||||
- Admin 批量重试继续使用相同的新任务语义;替代商品“继续采购”仍在 AgentRetry 前额外验证替换分项与继续采购资格。
|
||||
|
||||
既有新建任务接口保留原语义:
|
||||
历史兼容的就地重置接口仍保留,但 Android 普通重试不再调用:
|
||||
|
||||
```http
|
||||
POST /api/agent/v1/purchase-tasks/{taskId}/retry
|
||||
POST /api/agent/v1/purchase-tasks/{taskId}/reset
|
||||
Authorization: Bearer <device-token>
|
||||
Content-Type: application/json
|
||||
|
||||
{"requestId":"<uuid>"}
|
||||
```
|
||||
|
||||
- `/retry` 的 `AgentRetry → BatchRetry → Create` 行为不变:保留来源失败任务并创建新任务,返回 `sourceTaskId`、`sourceTaskNo`、新 `taskId`、新 `taskNo` 和 `replayed`。
|
||||
- Android 普通“重试采购”不再调用 `/retry`;只有 #132 替代商品规格匹配完成后的“继续采购”继续调用它。Admin 批量重试行为也不变。
|
||||
- “继续采购”会重新解析替代商品、规格映射与价格并生成新地址后缀;这与普通失败任务保持快照的就地重跑不可互换。
|
||||
- `/reset` 只允许安全失败、无不可逆证据且不存在更新任务的原任务;它保留原业务快照并刷新当前规则。
|
||||
- 目标颜色存在但映射颜色为空,或目标尺码存在但映射尺码为空时,必须返回 `PURCHASE_SPEC_MAPPING_REQUIRED`,不得创建 `purchase` attempt 或下发正式采购 payload。
|
||||
- 两个入口都不执行支付。真机调用可能创建待付款订单,必须先取得人工授权。
|
||||
|
||||
## Agent 任务记录范围与同步(#99)
|
||||
@@ -845,3 +853,17 @@ file=<JPEG 二进制>
|
||||
| `GET` | `/api/agent/v1/app/releases/{releaseId}/download` | Device Token;私有 APK 下载 |
|
||||
|
||||
Agent 只比较整数 `versionCode`。设备有活动任务时禁止检查、下载和安装;下载到应用私有缓存并校验响应大小与 SHA-256,失败立即删除。安装使用 FileProvider 和 Android 系统安装确认页;未知来源权限必须由用户在系统设置授权,不静默安装。
|
||||
|
||||
## 设备身份恢复(#201)
|
||||
|
||||
```http
|
||||
POST /api/admin/v1/devices/{deviceId}/identity-reset
|
||||
POST /api/agent/v1/register
|
||||
X-GoAuto-Device-Recovery-Code: <one-time-code>
|
||||
```
|
||||
|
||||
仅管理员可以对未停用的既有设备发起身份重置。服务端立即使旧 Device Token 无效,并生成 10 分钟内仅能使用一次的恢复码;恢复码只在该管理员操作的响应中返回一次,服务端仅保存不可逆摘要,管理端设备列表、日志、任务接口和 Android 本地持久化均不得保存或返回原文。管理员将恢复码经受控人工渠道输入同一安装实例的 Agent 设置页。
|
||||
|
||||
Agent 携带既有 Token(可已失效)及恢复码重新调用注册接口。服务端必须同时校验同一 `installId`、未停用状态、恢复码摘要、未过期和未使用;成功后使用原 `deviceId` 写入新 Token 摘要并返回一次新 Token,清除恢复码摘要和有效期。旧 Token 与恢复码都立即失效,已分配的 pending 采集或采购任务保持原 `deviceId`,不创建替代设备记录。缺少或错误恢复码仍为 `DEVICE_INSTALL_ID_CONFLICT`;过期码为 `DEVICE_RECOVERY_EXPIRED`;停用设备为 `DEVICE_DISABLED`。
|
||||
|
||||
自 #223 起,新建 SYB 任务在保持 `mappedColor` / `mappedSize` 为空和首趟 `spec_probe` 不变的同时,把创建时与目标规格对应的 confirmed 商品映射冻结为仅供服务端决策的指导快照。服务端收到当次候选后按角色验证该快照:只有规范化后唯一对应当次候选时才复用,并固化当次候选原文;否则该角色继续执行确定性匹配,仍未解决才把该角色及其封闭候选交给 AI。已解决角色不得重复发送给 AI,最终颜色和尺码仍须逐字属于各自当次候选。任务决策快照通过 `roleSources` 记录每个角色的 `manual_mapping` / `exact_match` / `ai_match` 来源;任务级 `specSource` 使用现有枚举汇总,不新增 Agent 决策权限。
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Delivery-Issues
|
||||
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Delivery-Issues.-
|
||||
wiki_revision: 0eed28dd5dc629ce66071833d5fefbdf24d567d3
|
||||
synchronized_at: 2026-08-24T02:35:58Z
|
||||
wiki_revision: b1b1b343917e66288f4282bc6b3b90ea4ff3cca0
|
||||
synchronized_at: 2026-09-04T11:30:28Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 当前 MVP 交付工单索引
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: OnePlus-Real-Device-Acceptance
|
||||
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/OnePlus-Real-Device-Acceptance.-
|
||||
wiki_revision: 5b74a3b915cda6307cd59087bf9ce295f4c5e806
|
||||
synchronized_at: 2026-08-21T08:08:57Z
|
||||
wiki_revision: b1b1b343917e66288f4282bc6b3b90ea4ff3cca0
|
||||
synchronized_at: 2026-09-04T11:30:32Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 一加真机验收记录
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: PDD-Detail-Rule-Migration-Analysis
|
||||
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/PDD-Detail-Rule-Migration-Analysis.-
|
||||
wiki_revision: 1fe9ed083c4fc565a50b4186571233d1dfab58a7
|
||||
synchronized_at: 2026-08-17T03:28:38Z
|
||||
wiki_revision: b1b1b343917e66288f4282bc6b3b90ea4ff3cca0
|
||||
synchronized_at: 2026-09-04T11:30:37Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# PDD 商品详情采集规则迁移分析
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: SYB-ERP-Interface-Contract
|
||||
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/SYB-ERP-Interface-Contract.-
|
||||
wiki_revision: f596931468caf524cbd9d90b3c299b32a8cf8228
|
||||
synchronized_at: 2026-08-28T02:29:07Z
|
||||
wiki_revision: ad9adc22e69e48c9a8fd87d7121066eecca55fd6
|
||||
synchronized_at: 2026-09-04T11:30:50Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 12 顺云宝(SYB)ERP 接口契约
|
||||
@@ -393,19 +393,32 @@ POST /am/stock/detail/listByStock?hist=0
|
||||
|
||||
顺运宝给的是**商品级 ID + 规格原文**,没有規格 ID。
|
||||
|
||||
### 6.3 `productSpec` 的格式和蝦皮报表完全一致
|
||||
### 6.3 `productSpec` 的角色顺序不是固定的
|
||||
|
||||
```text
|
||||
蝦皮目录 `spec_raw` 黑色,M【建議40-50公斤】
|
||||
顺运宝 productSpec 白色,L【建議50-60公斤】
|
||||
黑色+白色【純棉兩件裝】 簡約親膚,L【建議52.5-60公斤】
|
||||
均碼,黑色
|
||||
```
|
||||
|
||||
`[必须]` 规格身份统一复用 `spec.SpecKey()`:只折叠空白,不猜颜色、尺码或建议。
|
||||
第三方目录脚本提交 `spec_raw` 和明确的解析结果;顺运宝匹配不到时交给人工确认。
|
||||
2026-09-04 的真实数据确认:`productSpec` 至少存在 `颜色,尺码` 与
|
||||
`尺码,颜色` 两种顺序,不能再固定把逗号前当颜色、逗号后当尺码。
|
||||
|
||||
`[必须]` 先按最后一个 ASCII 逗号拆成两段并剥离 `【...】` 备注。只有两侧
|
||||
恰好一侧带有明确尺码信号(如均码、字母尺码或明确尺码单位)时,才把该侧判为
|
||||
尺码、另一侧判为颜色。两侧都像尺码时标记存疑;不得使用模糊颜色词库、AI 或
|
||||
跨维度候选猜测角色。两侧都没有明确尺码信号时,暂按既有 `颜色,尺码` 契约兼容,
|
||||
并继续执行原有复杂分隔符和空值校验。
|
||||
|
||||
`[必须]` 修正规格角色时保留原始 `raw_json`,不得覆盖人工确认值。已错误写入
|
||||
虾皮档案的导入规格只能在确认无其他 SYB 明细引用且没有规格映射时移除;人工规格、
|
||||
映射及历史采购任务保持不变。
|
||||
|
||||
`[必须]` 规格身份继续复用 `spec.SpecKey()`:只折叠空白,不改写用于 PDD
|
||||
精确点击的原始候选标签。第三方目录脚本提交 `spec_raw` 和明确解析结果;角色或
|
||||
规格比对不明确时保持存疑,不得跨颜色/尺码猜测。
|
||||
|
||||
### 6.4 由此推导出的匹配路径
|
||||
|
||||
```text
|
||||
productId ──→ shopee_products.goods_id 商品级,直接相等
|
||||
productSpec ──→ SpecKey() ──→ 稳定规格身份键
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Deployment-and-Operations
|
||||
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Deployment-and-Operations.-
|
||||
wiki_revision: 80ae5c8cea87c624fff63ce3e302aa7bf204811e
|
||||
synchronized_at: 2026-08-31T16:07:03Z
|
||||
wiki_revision: b1b1b343917e66288f4282bc6b3b90ea4ff3cca0
|
||||
synchronized_at: 2026-09-04T11:30:08Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 部署与运维
|
||||
@@ -39,6 +39,34 @@ GOAUTO_ALLOW_INSECURE_AGENT_HTTP=true
|
||||
|
||||
发布、重启、设置当前 Agent 版本和真机安装都属于高风险操作,必须有对应工单和人工确认。不得在部署过程中执行付款、创建订单或清理生产数据。
|
||||
|
||||
### API 长请求超时约束
|
||||
|
||||
Admin 蝦皮规格 AI 匹配会同步等待外部 Provider:Provider 配置允许 3~600 秒,复合的一键匹配共享 600 秒总预算,Admin 客户端等待 610 秒。因此服务端 `settings.application.writetimeout` 必须至少为 620 秒,受版本管理的标准配置固定为 `620`;启动时低于该值必须明确失败,禁止以 2 秒等短写超时运行,否则连接会在领域错误返回前被截断并表现为代理 502。反向代理的读/发送超时也必须大于服务端窗口。
|
||||
|
||||
Provider 故障日志只允许记录调用关联 ID、操作类型、耗时、上游 HTTP 状态码或网络错误分类;不得记录 API Key、Authorization、Provider URL、模型输入、候选规格、商品原始内容或响应正文。Provider 失败时接口返回 HTTP 503 与 `AI_MATCHING_UNAVAILABLE`,供 Admin 显示安全中文提示。
|
||||
|
||||
## 回滚
|
||||
|
||||
服务异常时把 `/home/goauto/current` 切回上一已验证发布目录并重启 `goauto.service`,随后复核服务状态和 Agent 接口。不要删除当前或历史 APK/发布目录来代替回滚;数据库变化如需回退必须单独评估。
|
||||
|
||||
## 蝦皮规格自动匹配任务运维(#195)
|
||||
|
||||
- 迁移 `1788290000000_shopee_spec_auto_match.go` 创建运行与工作状态表,并幂等写入 `sys_job.invoke_target=GoAutoShopeeSpecAutoMatch`。首次部署状态为关闭,迁移重跑不会覆盖管理员后来修改的 Cron、参数或启停状态。
|
||||
- 默认 Cron 为 `0 15 * * * *`(每小时第 15 分钟),默认 `batchLimit` 为 20,合法范围 1~100。上线后先保持关闭,在 Admin 蝦皮商品列表手动执行小批次并核对运行摘要和映射,再由管理员在定时任务页明确启用。
|
||||
- 定时和手动触发共享数据库唯一活动槽。看到“已有自动匹配正在运行”属于正常互斥结果;运行租约超过 30 分钟会在下次触发时安全释放并把旧运行标记失败。处理每个商品前会续租。
|
||||
- Provider 临时失败的商品至少 60 分钟后重试,最多 3 次;低置信度或无结果在输入指纹不变时不会重复调用。排错优先查看 `shopee_spec_auto_match_run` 的结构化计数与脱敏错误,以及 `shopee_spec_auto_match_work_item` 的状态、尝试次数、下次尝试时间和租约;不得输出或复制 API Key、Provider 原始响应、商品原始 JSON 或个人数据。
|
||||
- 紧急回退只需关闭系统任务并停止使用 Admin 手动入口;保留运行记录和既有已确认映射用于审计,不删除数据,也不回滚 #194。
|
||||
|
||||
## SYB 异常规格 AI 定时任务(#198)
|
||||
|
||||
- 追加迁移 `1788354164329_syb_spec_ai_parse.go` 创建 AI 确认字段、`syb_spec_ai_parse_run`、`syb_spec_ai_parse_work_item`,并幂等写入 `sys_job.invoke_target=GoAutoSYBSpecAIParse`。
|
||||
- 系统任务默认关闭,默认 Cron 为每小时第 5 分钟、参数 `{"batchLimit":20}`;迁移重跑不得覆盖管理员后续修改的 Cron、参数或启停状态。建议先运行该任务,再由默认第 15 分钟的 #195 完成蝦皮到 PDD 的规格匹配。
|
||||
- 多实例通过可空唯一活动槽和租约保证全局单批运行;逐行工作项按输入指纹去重。Provider 临时失败至少 60 分钟后重试且最多 3 次,低置信度/无结果在输入不变时不重复调用。
|
||||
- 上线顺序为:备份数据库、执行追加迁移、发布服务并保持任务关闭、核验表/字段/任务种子,再由管理员决定是否启用。排错只查看运行计数、工作状态和脱敏错误,不输出 API Key、Provider 原始响应、完整 RawJSON 或订单数据。
|
||||
|
||||
## 定时任务执行历史运维(#199)
|
||||
|
||||
- 发布 #199 时先备份数据库,再执行追加迁移 `1788357000000_sys_job_execution_log.go`,随后发布服务与 Web。迁移创建 `sys_job_execution_log`、登记 `GET /api/v1/sysjob/:id/execution-logs`,并只为原本绑定隐藏菜单 `JobLog` 的角色写入精确读取权限;菜单缺失时迁移明确失败,不扩大角色授权。
|
||||
- 服务启动会将上次进程遗留的 `running` 执行记录标记为 `interrupted`。该恢复依赖当前线上每个数据库只运行一个调度器实例;扩展为多调度器前必须另建工单引入实例租约,不能直接复用此判断。
|
||||
- 排错从 Admin 定时任务页单选任务后进入“日志”,按状态和开始时间查询。记录只含稳定错误码和脱敏摘要;需要定位细节时查看受控服务日志,不得把任务参数、Provider 配置/响应、密钥或业务原始数据复制进执行历史。
|
||||
- 当前没有执行历史删除接口和自动保留策略;删除定时任务不删除历史。数据库容量治理需要另建工单评估。#198 的 `GoAutoSYBSpecAIParse` 在 #199 发布和迁移后仍保持关闭,启用必须由管理员另行确认。
|
||||
|
||||
+2
-2
@@ -2,8 +2,8 @@
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Home
|
||||
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Home
|
||||
wiki_revision: d5a46210da9dffa27247d80b20ab31b40790e48a
|
||||
synchronized_at: 2026-08-31T16:06:14Z
|
||||
wiki_revision: b1b1b343917e66288f4282bc6b3b90ea4ff3cca0
|
||||
synchronized_at: 2026-09-04T11:29:34Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# GoAuto 文档中心
|
||||
|
||||
Vendored
+2
-2
@@ -2,8 +2,8 @@
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Deployment-Template
|
||||
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Deployment-Template.-
|
||||
wiki_revision: 389f09d292b0aa408f26f1967e33eec82d37cedf
|
||||
synchronized_at: 2026-08-27T09:39:50Z
|
||||
wiki_revision: b1b1b343917e66288f4282bc6b3b90ea4ff3cca0
|
||||
synchronized_at: 2026-09-04T11:30:41Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 部署文档模板
|
||||
|
||||
Vendored
+2
-2
@@ -2,8 +2,8 @@
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Task-Archive-Template
|
||||
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Task-Archive-Template.-
|
||||
wiki_revision: 53927b3b69fa26943f323f9be53153f80ed54530
|
||||
synchronized_at: 2026-08-24T08:23:07Z
|
||||
wiki_revision: b1b1b343917e66288f4282bc6b3b90ea4ff3cca0
|
||||
synchronized_at: 2026-09-04T11:30:46Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
> 本模板只用于用户明确要求的专项历史快照或读取既有归档,不属于标准任务闭环。单次任务的唯一事实来源是 Gitea 工单;不要为了完成普通任务创建本页面,也不要自动导出到 `docs/task/`。
|
||||
|
||||
@@ -47,6 +47,7 @@ var AdminAPIs = []APIPermission{
|
||||
{"预览尺码匹配", "/api/admin/v1/shopee-products/:productId/specs/mapping/preview-auto-size", "POST", true},
|
||||
{"AI 建议颜色映射", "/api/admin/v1/shopee-products/:productId/specs/mapping/suggest-colors", "POST", true},
|
||||
{"AI 建议尺码映射", "/api/admin/v1/shopee-products/:productId/specs/mapping/suggest-sizes", "POST", true},
|
||||
{"一键匹配并确认颜色尺码", "/api/admin/v1/shopee-products/:productId/specs/mapping/auto-match", "POST", true},
|
||||
|
||||
{"查看 SYB 商品", "/api/admin/v1/syb-products", "GET", true},
|
||||
{"查看 SYB 商品详情", "/api/admin/v1/syb-products/:productId", "GET", true},
|
||||
|
||||
@@ -62,3 +62,12 @@ func TestPurchaserMayRunBatchSpecMatch(t *testing.T) {
|
||||
}
|
||||
t.Fatal("missing purchaser batch spec match permission")
|
||||
}
|
||||
|
||||
func TestPurchaserMayAutoMatchShopeeProductSpecs(t *testing.T) {
|
||||
for _, permission := range PurchaserAPIs() {
|
||||
if permission.Method == "POST" && permission.Path == "/api/admin/v1/shopee-products/:productId/specs/mapping/auto-match" {
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Fatal("missing purchaser shopee auto-match permission")
|
||||
}
|
||||
|
||||
@@ -35,11 +35,12 @@ type CandidateSnapshot struct {
|
||||
}
|
||||
|
||||
type DecisionSnapshot struct {
|
||||
Source string `json:"source"`
|
||||
Provider string `json:"provider,omitempty"`
|
||||
Model string `json:"model,omitempty"`
|
||||
Candidates CandidateSnapshot `json:"candidates"`
|
||||
Matched struct {
|
||||
Source string `json:"source"`
|
||||
Provider string `json:"provider,omitempty"`
|
||||
Model string `json:"model,omitempty"`
|
||||
Candidates CandidateSnapshot `json:"candidates"`
|
||||
RoleSources map[string]string `json:"roleSources,omitempty"`
|
||||
Matched struct {
|
||||
Color string `json:"color,omitempty"`
|
||||
Size string `json:"size,omitempty"`
|
||||
} `json:"matched"`
|
||||
|
||||
@@ -18,7 +18,7 @@ func TestParseBuiltAgentAPKWhenAvailable(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if metadata.VersionCode != 49 || metadata.VersionName != "0.9.36" {
|
||||
if metadata.VersionCode != 54 || metadata.VersionName != "0.9.41" {
|
||||
t.Fatalf("metadata=%+v", metadata)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,7 +48,7 @@ func (handler Handler) Register(context *gin.Context) {
|
||||
writeError(context, internalError(err))
|
||||
return
|
||||
}
|
||||
response, err := NewService(db).Register(context.Request.Context(), request, bearerToken(context.GetHeader("Authorization")))
|
||||
response, err := NewService(db).Register(context.Request.Context(), request, bearerToken(context.GetHeader("Authorization")), strings.TrimSpace(context.GetHeader("X-GoAuto-Device-Recovery-Code")))
|
||||
if err != nil {
|
||||
writeError(context, err)
|
||||
return
|
||||
@@ -84,6 +84,26 @@ func (handler Handler) RevokeToken(context *gin.Context) {
|
||||
handler.adminAction(context, (*Service).RevokeToken)
|
||||
}
|
||||
|
||||
func (handler Handler) ResetIdentity(context *gin.Context) {
|
||||
deviceID, err := strconv.ParseUint(context.Param("deviceId"), 10, 64)
|
||||
if err != nil || deviceID == 0 {
|
||||
writeError(context, invalidRequest("deviceId 无效"))
|
||||
return
|
||||
}
|
||||
db, err := handler.database(context)
|
||||
if err != nil {
|
||||
writeError(context, internalError(err))
|
||||
return
|
||||
}
|
||||
response, err := NewService(db).ResetIdentity(context.Request.Context(), deviceID)
|
||||
if err != nil {
|
||||
writeError(context, err)
|
||||
return
|
||||
}
|
||||
context.Header("Cache-Control", "no-store")
|
||||
context.JSON(http.StatusOK, gin.H{"code": http.StatusOK, "data": response})
|
||||
}
|
||||
|
||||
func (handler Handler) adminAction(context *gin.Context, action func(*Service, stdcontext.Context, uint64) error) {
|
||||
deviceID, err := strconv.ParseUint(context.Param("deviceId"), 10, 64)
|
||||
if err != nil || deviceID == 0 {
|
||||
|
||||
@@ -22,5 +22,6 @@ func InitRouter(engine *gin.Engine, authMiddleware *jwt.GinJWTMiddleware) {
|
||||
admin := engine.Group("/api/admin/v1/devices").Use(authMiddleware.MiddlewareFunc()).Use(middleware.AuthCheckRole())
|
||||
admin.GET("", handler.List)
|
||||
admin.POST("/:deviceId/disable", middleware.RequireRoleKey("admin"), handler.Disable)
|
||||
admin.POST("/:deviceId/identity-reset", middleware.RequireRoleKey("admin"), handler.ResetIdentity)
|
||||
admin.POST("/:deviceId/token/revoke", middleware.RequireRoleKey("admin"), handler.RevokeToken)
|
||||
}
|
||||
|
||||
@@ -28,8 +28,12 @@ const (
|
||||
CodeDeviceTaskMismatch = "DEVICE_TASK_MISMATCH"
|
||||
CodeDeviceNotFound = "DEVICE_NOT_FOUND"
|
||||
CodeInternal = "INTERNAL_ERROR"
|
||||
CodeRecoveryInvalid = "DEVICE_RECOVERY_INVALID"
|
||||
CodeRecoveryExpired = "DEVICE_RECOVERY_EXPIRED"
|
||||
)
|
||||
|
||||
const deviceRecoveryLifetime = 10 * time.Minute
|
||||
|
||||
type ServiceError struct {
|
||||
Code string
|
||||
Message string
|
||||
@@ -67,6 +71,11 @@ type RegisterResponse struct {
|
||||
Replayed bool `json:"replayed,omitempty"`
|
||||
}
|
||||
|
||||
type ResetIdentityResponse struct {
|
||||
DeviceID uint64 `json:"deviceId"`
|
||||
ExpiresAt time.Time `json:"expiresAt"`
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
DB *gorm.DB
|
||||
Now func() time.Time
|
||||
@@ -108,6 +117,10 @@ func tokenMatches(token, digest string) bool {
|
||||
return subtle.ConstantTimeCompare(got[:], want) == 1
|
||||
}
|
||||
|
||||
func digestMatches(value string, digest *string) bool {
|
||||
return digest != nil && tokenMatches(value, *digest)
|
||||
}
|
||||
|
||||
// Authenticate returns the active device represented by a bearer token.
|
||||
// Agent feature packages use this method so token verification stays in one
|
||||
// place and raw tokens never leave request memory.
|
||||
@@ -131,7 +144,11 @@ func (service *Service) Authenticate(ctx context.Context, token string) (models.
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (service *Service) Register(ctx context.Context, request RegisterRequest, presentedToken string) (RegisterResponse, error) {
|
||||
func (service *Service) Register(ctx context.Context, request RegisterRequest, presentedToken string, recoveryCodes ...string) (RegisterResponse, error) {
|
||||
recoveryCode := ""
|
||||
if len(recoveryCodes) > 0 {
|
||||
recoveryCode = recoveryCodes[0]
|
||||
}
|
||||
request = normalizeRegisterRequest(request)
|
||||
if err := validateRegisterRequest(request); err != nil {
|
||||
return RegisterResponse{}, err
|
||||
@@ -162,9 +179,15 @@ func (service *Service) Register(ctx context.Context, request RegisterRequest, p
|
||||
if existing.Status == models.DeviceStatusDisabled {
|
||||
return RegisterResponse{}, &ServiceError{Code: CodeDeviceDisabled, Message: "设备已停用", Retryable: false}
|
||||
}
|
||||
if !tokenMatches(presentedToken, existing.TokenDigest) {
|
||||
return RegisterResponse{}, &ServiceError{
|
||||
Code: CodeInstallIDConflict, Message: "installId 已注册,需要该设备的有效 Token", Retryable: false,
|
||||
usingRecovery := !tokenMatches(presentedToken, existing.TokenDigest)
|
||||
if usingRecovery {
|
||||
validCode := recoveryCode != "" && digestMatches(recoveryCode, existing.RecoveryCodeDigest)
|
||||
autoRecovery := recoveryCode == "" && existing.RecoveryCodeDigest == nil && existing.RecoveryExpiresAt != nil && existing.RecoveryUsedAt == nil
|
||||
if !validCode && !autoRecovery {
|
||||
return RegisterResponse{}, &ServiceError{Code: CodeInstallIDConflict, Message: "installId 已注册,需要该设备的有效 Token", Retryable: false}
|
||||
}
|
||||
if existing.RecoveryExpiresAt == nil || !service.Now().Before(*existing.RecoveryExpiresAt) {
|
||||
return RegisterResponse{}, &ServiceError{Code: CodeRecoveryExpired, Message: "设备身份重置窗口已过期,请在后台重新操作", Retryable: false}
|
||||
}
|
||||
}
|
||||
updates := map[string]any{
|
||||
@@ -175,6 +198,20 @@ func (service *Service) Register(ctx context.Context, request RegisterRequest, p
|
||||
if request.Capabilities != nil {
|
||||
updates["capabilities_json"] = encodeCapabilities(request.Capabilities)
|
||||
}
|
||||
if usingRecovery {
|
||||
newToken, generateErr := service.GenerateToken()
|
||||
if generateErr != nil {
|
||||
return RegisterResponse{}, internalError(generateErr)
|
||||
}
|
||||
now := service.Now()
|
||||
updates["token_digest"] = tokenDigest(newToken)
|
||||
updates["token_issued_at"] = now
|
||||
updates["recovery_code_digest"] = nil
|
||||
updates["recovery_expires_at"] = nil
|
||||
updates["recovery_used_at"] = now
|
||||
updates["status"] = models.DeviceStatusOnline
|
||||
response.DeviceToken = newToken
|
||||
}
|
||||
result := db.Model(&models.AgentDevice{}).
|
||||
Where("id = ? AND token_digest = ? AND token_revoked_at IS NULL", existing.ID, existing.TokenDigest).
|
||||
Updates(updates)
|
||||
@@ -222,6 +259,42 @@ func (service *Service) Register(ctx context.Context, request RegisterRequest, p
|
||||
return response, nil
|
||||
}
|
||||
|
||||
// ResetIdentity invalidates the current token and opens a short-lived automatic
|
||||
// re-registration window for the same installId. It retains the device row and
|
||||
// task bindings, so no recovery code needs to leave the Admin workflow.
|
||||
func (service *Service) ResetIdentity(ctx context.Context, deviceID uint64) (ResetIdentityResponse, error) {
|
||||
if service.DB == nil {
|
||||
return ResetIdentityResponse{}, internalError(errors.New("database is nil"))
|
||||
}
|
||||
now := service.Now()
|
||||
expiresAt := now.Add(deviceRecoveryLifetime)
|
||||
response := ResetIdentityResponse{DeviceID: deviceID, ExpiresAt: expiresAt}
|
||||
err := service.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
var device models.AgentDevice
|
||||
if err := tx.First(&device, deviceID).Error; errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return &ServiceError{Code: CodeDeviceNotFound, Message: "设备不存在", Retryable: false}
|
||||
} else if err != nil {
|
||||
return internalError(err)
|
||||
}
|
||||
if device.Status == models.DeviceStatusDisabled {
|
||||
return &ServiceError{Code: CodeDeviceDisabled, Message: "设备已停用", Retryable: false}
|
||||
}
|
||||
invalidatedToken, generateErr := service.GenerateToken()
|
||||
if generateErr != nil {
|
||||
return internalError(generateErr)
|
||||
}
|
||||
return tx.Model(&models.AgentDevice{}).Where("id = ?", deviceID).Updates(map[string]any{
|
||||
"token_digest": tokenDigest(invalidatedToken), "token_issued_at": now,
|
||||
"recovery_code_digest": nil, "recovery_expires_at": expiresAt,
|
||||
"recovery_used_at": nil, "status": models.DeviceStatusOffline,
|
||||
}).Error
|
||||
})
|
||||
if err != nil {
|
||||
return ResetIdentityResponse{}, err
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
|
||||
func (service *Service) Disable(ctx context.Context, deviceID uint64) error {
|
||||
return service.deactivate(ctx, deviceID, false)
|
||||
}
|
||||
|
||||
@@ -185,6 +185,72 @@ func TestDisableAndRevokePreventFurtherAuthentication(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestResetIdentityRecoversSameDeviceAndRotatesToken(t *testing.T) {
|
||||
db := openTestDatabase(t)
|
||||
service := newTestService(t, db)
|
||||
tokens := []string{"original-token", "invalidated-token", "replacement-token"}
|
||||
service.GenerateToken = func() (string, error) {
|
||||
if len(tokens) == 0 {
|
||||
t.Fatal("unexpected token generation")
|
||||
}
|
||||
value := tokens[0]
|
||||
tokens = tokens[1:]
|
||||
return value, nil
|
||||
}
|
||||
request := validRegisterRequest()
|
||||
registered, err := service.Register(context.Background(), request, "")
|
||||
if err != nil {
|
||||
t.Fatalf("first registration: %v", err)
|
||||
}
|
||||
recovery, err := service.ResetIdentity(context.Background(), registered.DeviceID)
|
||||
if err != nil {
|
||||
t.Fatalf("reset identity: %v", err)
|
||||
}
|
||||
if !recovery.ExpiresAt.After(service.Now()) {
|
||||
t.Fatalf("unexpected recovery response: %+v", recovery)
|
||||
}
|
||||
request.RequestID = uuid.NewString()
|
||||
recovered, err := service.Register(context.Background(), request, "original-token")
|
||||
if err != nil {
|
||||
t.Fatalf("recover registration: %v", err)
|
||||
}
|
||||
if recovered.DeviceID != registered.DeviceID || recovered.DeviceToken != "replacement-token" {
|
||||
t.Fatalf("unexpected recovery result: %+v", recovered)
|
||||
}
|
||||
var stored models.AgentDevice
|
||||
if err := db.First(&stored, registered.DeviceID).Error; err != nil {
|
||||
t.Fatalf("load recovered device: %v", err)
|
||||
}
|
||||
if stored.InstallID != strings.ToLower(request.InstallID) || stored.Status != models.DeviceStatusOnline || stored.RecoveryCodeDigest != nil || stored.RecoveryUsedAt == nil {
|
||||
t.Fatalf("recovery did not retain device safely: %+v", stored)
|
||||
}
|
||||
request.RequestID = uuid.NewString()
|
||||
if _, err := service.Register(context.Background(), request, "original-token"); serviceErrorCode(t, err) != CodeInstallIDConflict {
|
||||
t.Fatal("automatic recovery window was reusable")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResetIdentityRejectsExpiredRecoveryCode(t *testing.T) {
|
||||
db := openTestDatabase(t)
|
||||
service := newTestService(t, db)
|
||||
tokens := []string{"original-token", "invalidated-token"}
|
||||
service.GenerateToken = func() (string, error) { value := tokens[0]; tokens = tokens[1:]; return value, nil }
|
||||
request := validRegisterRequest()
|
||||
registered, err := service.Register(context.Background(), request, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
recovery, err := service.ResetIdentity(context.Background(), registered.DeviceID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
service.Now = func() time.Time { return recovery.ExpiresAt.Add(time.Second) }
|
||||
request.RequestID = uuid.NewString()
|
||||
if _, err := service.Register(context.Background(), request, ""); serviceErrorCode(t, err) != CodeRecoveryExpired {
|
||||
t.Fatalf("expected expired recovery code, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegistrationValidationRejectsInvalidUUIDs(t *testing.T) {
|
||||
service := newTestService(t, openTestDatabase(t))
|
||||
request := validRegisterRequest()
|
||||
|
||||
@@ -39,6 +39,9 @@ type AgentDevice struct {
|
||||
TokenDigest string `json:"-" gorm:"size:64;not null;uniqueIndex:ux_agent_device_token_digest"`
|
||||
TokenIssuedAt time.Time `json:"tokenIssuedAt" gorm:"not null"`
|
||||
TokenRevokedAt *time.Time `json:"tokenRevokedAt" gorm:"index"`
|
||||
RecoveryCodeDigest *string `json:"-" gorm:"size:64;index"`
|
||||
RecoveryExpiresAt *time.Time `json:"-" gorm:"index"`
|
||||
RecoveryUsedAt *time.Time `json:"-"`
|
||||
LastRegisterRequestID *string `json:"-" gorm:"size:36;uniqueIndex:ux_agent_device_last_register_request_id"`
|
||||
LastHeartbeatRequestID *string `json:"-" gorm:"size:36;uniqueIndex:ux_agent_device_last_heartbeat_request_id"`
|
||||
LastHeartbeatAt *time.Time `json:"lastHeartbeatAt" gorm:"index"`
|
||||
|
||||
@@ -2,7 +2,6 @@ package purchase
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -72,6 +71,11 @@ func TestAgentRetryCreatesOneFixedDeviceTaskAndReplays(t *testing.T) {
|
||||
setCollectedPDDPrice(t, db, f.pdd.ID)
|
||||
service := testService(db)
|
||||
failed := failedLiveTask(t, db, service, f)
|
||||
if err := db.Model(&models.SYBProduct{}).Where("id = ?", f.syb.ID).Updates(map[string]any{
|
||||
"target_color": "当前黑色", "target_size": "均碼",
|
||||
}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
history, err := service.AgentHistoryDetail(context.Background(), failed.ID, f.token)
|
||||
if err != nil || !history.Task.Retryable || history.Task.RetryDisabledReason != "" {
|
||||
@@ -93,6 +97,9 @@ func TestAgentRetryCreatesOneFixedDeviceTaskAndReplays(t *testing.T) {
|
||||
if oldTask.Status != models.PurchaseTaskStatusFailed || newTask.Status != models.PurchaseTaskStatusPending || newTask.DeviceID == nil || *newTask.DeviceID != f.device.ID || newTask.AddressSuffix == oldTask.AddressSuffix {
|
||||
t.Fatalf("retry did not preserve old task or fix new task to device: old=%+v new=%+v", oldTask, newTask)
|
||||
}
|
||||
if newTask.TargetColorSnapshot != "当前黑色" || newTask.TargetSizeSnapshot != "均碼" || newTask.SpecSource != "unresolved" || purchaseAttemptPhase(newTask) != models.PurchaseAttemptPhaseSpecProbe {
|
||||
t.Fatalf("retry did not use current SYB targets and a fresh probe: %+v", newTask)
|
||||
}
|
||||
|
||||
replay, err := service.AgentRetry(context.Background(), failed.ID, request, f.token)
|
||||
if err != nil || !replay.Replayed || replay.TaskID != first.TaskID {
|
||||
@@ -158,7 +165,7 @@ func TestMatchedReplacementExposesContinueAndReusesAgentRetry(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchedReplacementRejectsClearedMappingWithoutCallingAI(t *testing.T) {
|
||||
func TestMatchedReplacementAllowsRetryWithoutPersistedMappingAndDoesNotCallAI(t *testing.T) {
|
||||
db := testDB(t)
|
||||
f := seed(t, db, liveCaps(), true)
|
||||
setCollectedPDDPrice(t, db, f.pdd.ID)
|
||||
@@ -172,11 +179,11 @@ func TestMatchedReplacementRejectsClearedMappingWithoutCallingAI(t *testing.T) {
|
||||
service.Matcher = matcher
|
||||
|
||||
detail, err := service.AgentHistoryDetail(context.Background(), failed.ID, f.token)
|
||||
if err != nil || detail.ContinuePurchaseEligible || !strings.Contains(detail.ContinuePurchaseDisabledReason, "规格匹配已失效") || matcher.calls != 0 {
|
||||
if err != nil || !detail.ContinuePurchaseEligible || detail.ContinuePurchaseDisabledReason != "" || matcher.calls != 0 {
|
||||
t.Fatalf("cleared mapping qualification mismatch: detail=%+v calls=%d error=%v", detail, matcher.calls, err)
|
||||
}
|
||||
_, err = service.AgentRetry(context.Background(), failed.ID, AgentRetryRequest{RequestID: uuid.NewString()}, f.token)
|
||||
if code(err) != CodeMappingRequired || matcher.calls != 0 {
|
||||
t.Fatalf("cleared mapping reached AI or retry: code=%s calls=%d error=%v", code(err), matcher.calls, err)
|
||||
created, err := service.AgentRetry(context.Background(), failed.ID, AgentRetryRequest{RequestID: uuid.NewString()}, f.token)
|
||||
if err != nil || created.TaskID == 0 || matcher.calls != 0 {
|
||||
t.Fatalf("cleared mapping retry did not create probe task: result=%+v calls=%d error=%v", created, matcher.calls, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,7 +32,10 @@ type skuCombinationRow struct {
|
||||
}
|
||||
|
||||
func sybSpecsTrusted(syb models.SYBProduct) bool {
|
||||
return syb.ParseStatus == models.SYBParseStatusSuccess || syb.ManuallyConfirmed
|
||||
if syb.ParseStatus == models.SYBParseStatusFailed {
|
||||
return false
|
||||
}
|
||||
return strings.TrimSpace(syb.TargetColor) != "" || strings.TrimSpace(syb.TargetSize) != ""
|
||||
}
|
||||
|
||||
func (s *Service) loadLatestSKUCombinations(ctx context.Context, pddIDs []uint64, dataset *batchPreviewDataset) error {
|
||||
@@ -140,7 +143,10 @@ func aiMatchQualificationForDataset(id uint64, dataset batchPreviewDataset) aiMa
|
||||
if request.TargetSize != "" {
|
||||
size = matched.MappedSize
|
||||
}
|
||||
if validSKUCombination(dataset.skuCombinationsByPDD[pdd.ID], syb.TargetColor, syb.TargetSize, color, size) {
|
||||
// 人工录入或外部导入的 PDD 档案可以没有采集任务 SKU 证据。没有
|
||||
// 证据时只确认候选值;一旦有证据,仍必须命中同一个可售组合。
|
||||
combinations := dataset.skuCombinationsByPDD[pdd.ID]
|
||||
if len(combinations) == 0 || validSKUCombination(combinations, syb.TargetColor, syb.TargetSize, color, size) {
|
||||
return aiMatchQualification{Eligible: true, Request: request, MappedColor: mappedColor, MappedSize: mappedSize, Deterministic: &matched}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -105,8 +105,7 @@ func (s *Service) BatchPreview(ctx context.Context, req BatchPreviewRequest) (Ba
|
||||
items := make([]BatchPreviewItem, 0, len(ids))
|
||||
eligible := 0
|
||||
for _, id := range ids {
|
||||
item := s.previewFromDataset(ctx, id, dataset, false, guard)
|
||||
item.enforcePersistedMatch(id, dataset)
|
||||
item := s.previewFromDataset(id, dataset, guard)
|
||||
item.applyCollectionEligibility(id, dataset)
|
||||
qualification := aiMatchQualificationForDataset(id, dataset)
|
||||
item.AIMatchEligible = qualification.Eligible
|
||||
@@ -331,22 +330,6 @@ func (s *Service) validateBatchDevice(ctx context.Context, deviceID *uint64, rul
|
||||
return ensureCapabilities(record, purchasecontract.RequiredCapabilities(rule))
|
||||
}
|
||||
|
||||
func (s *Service) previewOne(ctx context.Context, id uint64) BatchPreviewItem {
|
||||
_, _, currentRule, ruleErr := purchaserule.CurrentRule(ctx, s.DB, models.PurchaseExecutionModeLive)
|
||||
if ruleErr != nil {
|
||||
item := BatchPreviewItem{SYBProductID: id}
|
||||
item.ReasonCode, item.Reason, item.NextAction = serviceErrorFields(ruleErr)
|
||||
return item
|
||||
}
|
||||
dataset, err := s.loadBatchPreviewDataset(ctx, []uint64{id})
|
||||
if err != nil {
|
||||
item := BatchPreviewItem{SYBProductID: id}
|
||||
item.ReasonCode, item.Reason, item.NextAction = CodeInternal, "服务端处理失败", "retry"
|
||||
return item
|
||||
}
|
||||
return s.previewFromDataset(ctx, id, dataset, true, purchasecontract.EffectivePriceGuard(currentRule))
|
||||
}
|
||||
|
||||
func (s *Service) previewOneDeterministic(ctx context.Context, id uint64, guard purchasecontract.PriceLimits) BatchPreviewItem {
|
||||
dataset, err := s.loadBatchPreviewDataset(ctx, []uint64{id})
|
||||
if err != nil {
|
||||
@@ -354,8 +337,7 @@ func (s *Service) previewOneDeterministic(ctx context.Context, id uint64, guard
|
||||
item.ReasonCode, item.Reason, item.NextAction = CodeInternal, "服务端处理失败", "retry"
|
||||
return item
|
||||
}
|
||||
item := s.previewFromDataset(ctx, id, dataset, false, guard)
|
||||
item.enforcePersistedMatch(id, dataset)
|
||||
item := s.previewFromDataset(id, dataset, guard)
|
||||
return item
|
||||
}
|
||||
|
||||
@@ -379,7 +361,7 @@ func taskPointerValue(value *uint64) uint64 {
|
||||
return *value
|
||||
}
|
||||
|
||||
func (s *Service) previewFromDataset(ctx context.Context, id uint64, dataset batchPreviewDataset, allowAI bool, guard purchasecontract.PriceLimits) BatchPreviewItem {
|
||||
func (s *Service) previewFromDataset(id uint64, dataset batchPreviewDataset, guard purchasecontract.PriceLimits) BatchPreviewItem {
|
||||
item := BatchPreviewItem{SYBProductID: id}
|
||||
syb, found := dataset.sybByID[id]
|
||||
if !found {
|
||||
@@ -423,26 +405,15 @@ func (s *Service) previewFromDataset(ctx context.Context, id uint64, dataset bat
|
||||
}
|
||||
mappedColor, mappedSize, source := confirmedMappings(shopee.SpecsJSON, syb.TargetColor, syb.TargetSize)
|
||||
item.MappedColor, item.MappedSize = mappedColor, mappedSize
|
||||
candidates, archiveUsable := archiveCandidates(pdd.SpecsJSON, syb.TargetColor, syb.TargetSize)
|
||||
candidates, _ := archiveCandidates(pdd.SpecsJSON, syb.TargetColor, syb.TargetSize)
|
||||
// #190:失效的已保存映射不再拦截,降级为 unresolved,与创建路径保持一致。
|
||||
if source != "unresolved" && !mappingTargetsValid(candidates, syb.TargetColor, syb.TargetSize, mappedColor, mappedSize) {
|
||||
mappedColor, mappedSize, source = "", "", "unresolved"
|
||||
item.MappedColor, item.MappedSize = "", ""
|
||||
}
|
||||
if source == "unresolved" {
|
||||
if !archiveUsable {
|
||||
item.ReasonCode, item.Reason, item.NextAction = CodeMappingRequired, "关联的 PDD 商品没有完整可选规格", "open_pdd"
|
||||
return item
|
||||
}
|
||||
request := aimatching.MatchRequest{TargetColor: syb.TargetColor, TargetSize: syb.TargetSize, Colors: candidates.Colors, Sizes: candidates.Sizes}
|
||||
if allowAI {
|
||||
matched, matchErr := s.matcher().Resolve(ctx, request)
|
||||
if matchErr != nil {
|
||||
item.ReasonCode, item.Reason, item.NextAction = serviceErrorFields(purchaseMatchError(matchErr))
|
||||
return item
|
||||
}
|
||||
item.MappedColor, item.MappedSize = matched.MappedColor, matched.MappedSize
|
||||
} else if matched, ok := aimatching.DeterministicMatch(request); ok {
|
||||
if matched, ok := aimatching.DeterministicMatch(request); ok {
|
||||
item.MappedColor, item.MappedSize = matched.MappedColor, matched.MappedSize
|
||||
}
|
||||
// #190:映射不完整不再拦截,任务以 unresolved 建立并交由规格探测解析。
|
||||
@@ -476,46 +447,6 @@ func (s *Service) previewFromDataset(ctx context.Context, id uint64, dataset bat
|
||||
return item
|
||||
}
|
||||
|
||||
// enforcePersistedMatch is the #188 gate for the SYB list and batch-create
|
||||
// workflow. A transient deterministic result is not purchase readiness: the
|
||||
// color/size mapping must already be confirmed on the Shopee product and must
|
||||
// still identify one complete, available SKU combination from the latest
|
||||
// successful collection.
|
||||
func (item *BatchPreviewItem) enforcePersistedMatch(id uint64, dataset batchPreviewDataset) {
|
||||
if item.ReasonCode != "" && item.ReasonCode != CodeMappingRequired {
|
||||
return
|
||||
}
|
||||
syb, ok := dataset.sybByID[id]
|
||||
if !ok || syb.ShopeeProductID == nil {
|
||||
return
|
||||
}
|
||||
shopee, ok := dataset.shopeeByID[*syb.ShopeeProductID]
|
||||
if !ok || shopee.PDDProductID == nil {
|
||||
return
|
||||
}
|
||||
pdd, ok := dataset.pddByID[*shopee.PDDProductID]
|
||||
if !ok || pdd.Status != "active" {
|
||||
return
|
||||
}
|
||||
mappedColor, mappedSize, source := confirmedMappings(shopee.SpecsJSON, syb.TargetColor, syb.TargetSize)
|
||||
item.MappedColor, item.MappedSize = mappedColor, mappedSize
|
||||
if source == "unresolved" {
|
||||
item.Eligible = false
|
||||
item.ReasonCode, item.Reason, item.NextAction = CodeMappingRequired, "规格尚未匹配并保存,请先完成 AI 匹配或人工确认", "open_mapping"
|
||||
return
|
||||
}
|
||||
combinations := dataset.skuCombinationsByPDD[pdd.ID]
|
||||
if len(combinations) == 0 {
|
||||
item.Eligible = false
|
||||
item.ReasonCode, item.Reason, item.NextAction = CodeMappingRequired, "缺少当前 PDD 商品的完整可售 SKU 组合,请先重新采集", "open_pdd"
|
||||
return
|
||||
}
|
||||
if !validSKUCombination(combinations, syb.TargetColor, syb.TargetSize, mappedColor, mappedSize) {
|
||||
item.Eligible = false
|
||||
item.ReasonCode, item.Reason, item.NextAction = CodeMappingRequired, "已保存规格映射不属于当前可售的 PDD 规格组合,请重新匹配", "open_mapping"
|
||||
}
|
||||
}
|
||||
|
||||
func purchasePriceRange(raw, mappedColor string, guard purchasecontract.PriceLimits) (reference, minPrice, maxPrice int64, err error) {
|
||||
var dimensions []productspec.Dimension
|
||||
if json.Unmarshal([]byte(raw), &dimensions) != nil {
|
||||
|
||||
@@ -50,11 +50,6 @@ func (s *Service) BatchSpecMatch(ctx context.Context, req BatchSpecMatchRequest)
|
||||
if err != nil {
|
||||
return BatchSpecMatchResponse{}, internal(err)
|
||||
}
|
||||
settings, err := aimatching.NewService(s.DB).Settings(ctx)
|
||||
if err != nil {
|
||||
return BatchSpecMatchResponse{}, internal(err)
|
||||
}
|
||||
|
||||
response := BatchSpecMatchResponse{Items: make([]BatchSpecMatchItem, 0, len(ids))}
|
||||
for _, id := range ids {
|
||||
item := BatchSpecMatchItem{SYBProductID: id, Status: BatchSpecMatchFailed}
|
||||
@@ -87,9 +82,11 @@ func (s *Service) BatchSpecMatch(ctx context.Context, req BatchSpecMatchRequest)
|
||||
}
|
||||
}
|
||||
item.Source, item.Confidence = matched.Source, matched.Decision.Confidence
|
||||
autoConfirm := qualification.Deterministic != nil || (matched.Source == aimatching.SourceAI && matched.Decision.Confidence != nil && *matched.Decision.Confidence >= settings.AutoConfirmMinConfidence && strings.TrimSpace(matched.Decision.Reason) != "")
|
||||
// #200:在 SYB 批量入口,AI 只要返回了可保存的规格结果,就由后续的
|
||||
// 候选与可售 SKU 组合校验决定是否放行;置信度仅保留为审计信息。
|
||||
autoConfirm := qualification.Deterministic != nil || (matched.Source == aimatching.SourceAI && strings.TrimSpace(matched.Decision.Reason) != "")
|
||||
if !autoConfirm {
|
||||
item.Status, item.Reason = BatchSpecMatchPending, "匹配结果未达到自动确认阈值,请人工确认"
|
||||
item.Status, item.Reason = BatchSpecMatchPending, "AI 未返回可用规格结果,请人工确认"
|
||||
if strings.TrimSpace(matched.Decision.Reason) != "" {
|
||||
item.Reason += ":" + strings.TrimSpace(matched.Decision.Reason)
|
||||
}
|
||||
@@ -104,7 +101,8 @@ func (s *Service) BatchSpecMatch(ctx context.Context, req BatchSpecMatchRequest)
|
||||
if request.TargetSize != "" {
|
||||
mappedSize = matched.MappedSize
|
||||
}
|
||||
if !mappingTargetsValid(specCandidates{Colors: request.Colors, Sizes: request.Sizes}, syb.TargetColor, syb.TargetSize, mappedColor, mappedSize) || !validSKUCombination(dataset.skuCombinationsByPDD[pdd.ID], syb.TargetColor, syb.TargetSize, mappedColor, mappedSize) {
|
||||
combinations := dataset.skuCombinationsByPDD[pdd.ID]
|
||||
if !mappingTargetsValid(specCandidates{Colors: request.Colors, Sizes: request.Sizes}, syb.TargetColor, syb.TargetSize, mappedColor, mappedSize) || (len(combinations) > 0 && !validSKUCombination(combinations, syb.TargetColor, syb.TargetSize, mappedColor, mappedSize)) {
|
||||
item.Status, item.Reason = BatchSpecMatchPending, "AI 结果不是当前可售的 PDD 规格组合,请人工确认"
|
||||
response.PendingCount++
|
||||
response.Items = append(response.Items, item)
|
||||
@@ -117,7 +115,7 @@ func (s *Service) BatchSpecMatch(ctx context.Context, req BatchSpecMatchRequest)
|
||||
response.Items = append(response.Items, item)
|
||||
continue
|
||||
}
|
||||
if _, err := shopeeproduct.NewService(s.DB).ApplyResolvedMappings(ctx, shopee.ID, uuid.NewString(), settings.AutoConfirmMinConfidence, writes); err != nil {
|
||||
if _, err := shopeeproduct.NewService(s.DB).ApplyResolvedMappings(ctx, shopee.ID, uuid.NewString(), writes); err != nil {
|
||||
item.Reason = "规格映射保存失败,请刷新后重试"
|
||||
response.FailedCount++
|
||||
response.Items = append(response.Items, item)
|
||||
|
||||
@@ -9,7 +9,6 @@ import (
|
||||
"go-admin/app/goauto/models"
|
||||
"go-admin/app/goauto/shopeeproduct"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
@@ -137,18 +136,14 @@ func exactBatchSpecFixture(t *testing.T) (*Service, fixture) {
|
||||
return testService(db), f
|
||||
}
|
||||
|
||||
func TestBatchSpecMatchPersistsExactMatchBeforePurchaseCreation(t *testing.T) {
|
||||
func TestBatchSpecMatchPersistsExactMatchButPurchaseDoesNotWaitForIt(t *testing.T) {
|
||||
service, f := exactBatchSpecFixture(t)
|
||||
matcher := &batchSpecMatcher{}
|
||||
service.Matcher = matcher
|
||||
|
||||
before, err := service.BatchPreview(context.Background(), BatchPreviewRequest{SYBProductIDs: []uint64{f.syb.ID}})
|
||||
if err != nil || len(before.Items) != 1 || before.Items[0].Eligible || !before.Items[0].AIMatchEligible || before.Items[0].ProcessStage != ProcessStageColorMapping {
|
||||
t.Fatalf("unpersisted exact match was not gated: %+v err=%v", before, err)
|
||||
}
|
||||
blocked, err := service.BatchCreate(context.Background(), BatchCreateRequest{RequestID: uuid.NewString(), SYBProductIDs: []uint64{f.syb.ID}, DeviceID: &f.device.ID})
|
||||
if err != nil || blocked.CreatedCount != 0 || blocked.FailedCount != 1 || blocked.Items[0].ReasonCode != CodeMappingRequired {
|
||||
t.Fatalf("batch create bypassed persisted mapping gate: %+v err=%v", blocked, err)
|
||||
if err != nil || len(before.Items) != 1 || !before.Items[0].Eligible || !before.Items[0].AIMatchEligible || before.Items[0].ProcessStage != ProcessStagePurchaseReady {
|
||||
t.Fatalf("live-probe purchase was incorrectly gated by persisted mapping: %+v err=%v", before, err)
|
||||
}
|
||||
|
||||
matched, err := service.BatchSpecMatch(context.Background(), BatchSpecMatchRequest{SYBProductIDs: []uint64{f.syb.ID}})
|
||||
@@ -175,10 +170,10 @@ func TestBatchSpecMatchPersistsExactMatchBeforePurchaseCreation(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestManuallyConfirmedUncertainSpecsCanBeMatchedBeforePurchase(t *testing.T) {
|
||||
func TestExtractedUncertainSpecsCanBeMatchedBeforePurchase(t *testing.T) {
|
||||
service, f := exactBatchSpecFixture(t)
|
||||
if err := service.DB.Model(&models.SYBProduct{}).Where("id = ?", f.syb.ID).Updates(map[string]any{
|
||||
"parse_status": models.SYBParseStatusUncertain, "manually_confirmed": true,
|
||||
"parse_status": models.SYBParseStatusUncertain, "manually_confirmed": false,
|
||||
}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -186,16 +181,16 @@ func TestManuallyConfirmedUncertainSpecsCanBeMatchedBeforePurchase(t *testing.T)
|
||||
service.Matcher = matcher
|
||||
|
||||
before, err := service.BatchPreview(context.Background(), BatchPreviewRequest{SYBProductIDs: []uint64{f.syb.ID}})
|
||||
if err != nil || len(before.Items) != 1 || before.Items[0].Eligible || !before.Items[0].AIMatchEligible || before.Items[0].ProcessStage != ProcessStageColorMapping {
|
||||
t.Fatalf("manually confirmed specs did not enter matching: %+v err=%v", before, err)
|
||||
if err != nil || len(before.Items) != 1 || !before.Items[0].Eligible || !before.Items[0].AIMatchEligible || before.Items[0].ProcessStage != ProcessStagePurchaseReady {
|
||||
t.Fatalf("extracted uncertain specs were not purchase-ready for live probing: %+v err=%v", before, err)
|
||||
}
|
||||
matched, err := service.BatchSpecMatch(context.Background(), BatchSpecMatchRequest{SYBProductIDs: []uint64{f.syb.ID}})
|
||||
if err != nil || matched.AutoConfirmedCount != 1 || matcher.calls != 0 {
|
||||
t.Fatalf("manual correction did not allow exact match: %+v calls=%d err=%v", matched, matcher.calls, err)
|
||||
t.Fatalf("extracted uncertain specs did not allow exact match: %+v calls=%d err=%v", matched, matcher.calls, err)
|
||||
}
|
||||
after, err := service.BatchPreview(context.Background(), BatchPreviewRequest{SYBProductIDs: []uint64{f.syb.ID}})
|
||||
if err != nil || !after.Items[0].Eligible || after.Items[0].ProcessStage != ProcessStagePurchaseReady {
|
||||
t.Fatalf("saved mapping did not unlock manual correction: %+v err=%v", after, err)
|
||||
t.Fatalf("saved mapping did not unlock extracted uncertain specs: %+v err=%v", after, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -236,7 +231,7 @@ func TestBatchPreviewExposesExplicitAIMatchEligibility(t *testing.T) {
|
||||
t.Fatalf("preview failed: %+v err=%v", preview, err)
|
||||
}
|
||||
item := preview.Items[0]
|
||||
if !item.AIMatchEligible || item.AIMatchDisabledReason != "" || item.Eligible || item.ProcessStage != ProcessStageColorMapping {
|
||||
if !item.AIMatchEligible || item.AIMatchDisabledReason != "" || !item.Eligible || item.ProcessStage != ProcessStagePurchaseReady {
|
||||
t.Fatalf("explicit AI eligibility mismatch: %+v", item)
|
||||
}
|
||||
}
|
||||
@@ -265,21 +260,38 @@ func TestBatchPreviewAllowsUncertainParseAndMissingSKUCombination(t *testing.T)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchSpecMatchLeavesLowConfidenceForManualHandling(t *testing.T) {
|
||||
func TestBatchSpecMatchAndPreviewAllowMissingSKUCombinationEvidence(t *testing.T) {
|
||||
service, f := unresolvedBatchSpecFixture(t)
|
||||
if err := service.DB.Where("pdd_product_id = ?", f.pdd.ID).Delete(&models.CollectionTask{}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
service.Matcher = &batchSpecMatcher{results: []aimatching.MatchResult{aiBatchResult(0.6)}}
|
||||
|
||||
matched, err := service.BatchSpecMatch(context.Background(), BatchSpecMatchRequest{SYBProductIDs: []uint64{f.syb.ID}})
|
||||
if err != nil || matched.AutoConfirmedCount != 1 || matched.PendingCount != 0 {
|
||||
t.Fatalf("AI result without SKU evidence was not confirmed: %+v err=%v", matched, err)
|
||||
}
|
||||
preview, err := service.BatchPreview(context.Background(), BatchPreviewRequest{SYBProductIDs: []uint64{f.syb.ID}})
|
||||
if err != nil || len(preview.Items) != 1 || !preview.Items[0].Eligible || preview.Items[0].ProcessStage != ProcessStagePurchaseReady {
|
||||
t.Fatalf("missing SKU evidence unexpectedly blocked purchase: %+v err=%v", preview, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchSpecMatchAutoConfirmsReturnedAIMatchRegardlessOfConfidence(t *testing.T) {
|
||||
service, f := unresolvedBatchSpecFixture(t)
|
||||
service.Matcher = &batchSpecMatcher{results: []aimatching.MatchResult{aiBatchResult(0.6)}}
|
||||
|
||||
response, err := service.BatchSpecMatch(context.Background(), BatchSpecMatchRequest{SYBProductIDs: []uint64{f.syb.ID}})
|
||||
if err != nil || response.PendingCount != 1 || response.AutoConfirmedCount != 0 {
|
||||
t.Fatalf("unexpected low-confidence result: %+v err=%v", response, err)
|
||||
if err != nil || response.PendingCount != 0 || response.AutoConfirmedCount != 1 {
|
||||
t.Fatalf("low-confidence AI result was not auto-confirmed: %+v err=%v", response, err)
|
||||
}
|
||||
mapping := savedColorMapping(t, service, f.shopee.ID)
|
||||
if mapping == nil || mapping.PDDValue != "旧白色" || mapping.Status != shopeeproduct.MappingStatusConfirmed {
|
||||
t.Fatalf("low-confidence result changed the saved mapping: %+v", mapping)
|
||||
if mapping == nil || mapping.PDDValue != "米白色" || mapping.Status != shopeeproduct.MappingStatusConfirmed || mapping.Confidence == nil || *mapping.Confidence != 0.6 {
|
||||
t.Fatalf("AI result was not saved as confirmed: %+v", mapping)
|
||||
}
|
||||
preview, err := service.BatchPreview(context.Background(), BatchPreviewRequest{SYBProductIDs: []uint64{f.syb.ID}})
|
||||
if err != nil || preview.Items[0].Eligible || preview.Items[0].ProcessStage != ProcessStageColorMapping {
|
||||
t.Fatalf("pending mapping unexpectedly became purchase-ready: %+v err=%v", preview, err)
|
||||
if err != nil || !preview.Items[0].Eligible || preview.Items[0].ProcessStage != ProcessStagePurchaseReady {
|
||||
t.Fatalf("saved AI result did not become purchase-ready: %+v err=%v", preview, err)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -44,6 +44,27 @@ func TestBatchPreviewUsesPDDPriceAndExplainsIneligibleRows(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSybSpecsTrustedOnlyBlocksFailedOrEmptyExtraction(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
syb models.SYBProduct
|
||||
trust bool
|
||||
}{
|
||||
{"success", models.SYBProduct{ParseStatus: models.SYBParseStatusSuccess, TargetColor: "黑色", TargetSize: "XL"}, true},
|
||||
{"uncertain with color", models.SYBProduct{ParseStatus: models.SYBParseStatusUncertain, TargetColor: "套装"}, true},
|
||||
{"uncertain with size", models.SYBProduct{ParseStatus: models.SYBParseStatusUncertain, TargetSize: "均码"}, true},
|
||||
{"failed with values", models.SYBProduct{ParseStatus: models.SYBParseStatusFailed, TargetColor: "黑色"}, false},
|
||||
{"uncertain without values", models.SYBProduct{ParseStatus: models.SYBParseStatusUncertain}, false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := sybSpecsTrusted(tt.syb); got != tt.trust {
|
||||
t.Fatalf("sybSpecsTrusted=%v, want %v", got, tt.trust)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchPreviewExposesIndependentCollectionEligibility(t *testing.T) {
|
||||
db := testDB(t)
|
||||
fixture := seed(t, db, liveCaps(), true)
|
||||
@@ -267,8 +288,24 @@ func TestBatchPreviewBulkLoadsAndNeverCallsAIMatcher(t *testing.T) {
|
||||
if queries != 7 {
|
||||
t.Fatalf("batch preview used %d queries, want 7 bounded queries including collection eligibility and current purchase rule", queries)
|
||||
}
|
||||
if len(response.Items) != 2 || response.Items[0].Eligible || response.Items[1].Eligible || response.Items[0].ReasonCode != CodeMappingRequired {
|
||||
t.Fatalf("unresolved rows must not be reported as purchase-ready: %+v", response)
|
||||
if len(response.Items) != 2 || !response.Items[0].Eligible || !response.Items[1].Eligible || response.EligibleCount != 2 {
|
||||
t.Fatalf("unresolved rows must be ready for mandatory live probing: %+v", response)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchPreviewAllowsIncompleteArchivedCandidatesForMandatoryLiveProbe(t *testing.T) {
|
||||
db := testDB(t)
|
||||
fixture := seed(t, db, liveCaps(), false)
|
||||
if err := db.Model(&models.PDDProduct{}).Where("id = ?", fixture.pdd.ID).Update(
|
||||
"specs_json",
|
||||
`[{"name":"颜色","role":"color","values":[{"name":"黑色","selectable":true,"priceCent":2000}]}]`,
|
||||
).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
response, err := testService(db).BatchPreview(context.Background(), BatchPreviewRequest{SYBProductIDs: []uint64{fixture.syb.ID}})
|
||||
if err != nil || len(response.Items) != 1 || !response.Items[0].Eligible || response.Items[0].ProcessStage != ProcessStagePurchaseReady {
|
||||
t.Fatalf("incomplete archive blocked live probe task creation: %+v err=%v", response, err)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -33,6 +33,19 @@ func (s *Service) Next(ctx context.Context, token string) (*TaskPayload, error)
|
||||
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, internal(err)
|
||||
}
|
||||
// A completed probe reserves the device's purchase flow while the server
|
||||
// resolves the exact specs. Returning that task as a waiting payload keeps
|
||||
// the Agent from claiming another purchase or collection task and preserves
|
||||
// the PDD page that the probe just inspected.
|
||||
var waiting models.PurchaseTask
|
||||
if err = s.DB.WithContext(ctx).
|
||||
Where("device_id = ? AND status = ?", d.ID, models.PurchaseTaskStatusSpecProbePending).
|
||||
Order("created_at, id").
|
||||
First(&waiting).Error; err == nil {
|
||||
return s.payload(waiting, nil, false)
|
||||
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, internal(err)
|
||||
}
|
||||
now := s.Now()
|
||||
var candidates []models.PurchaseTask
|
||||
if err = s.DB.WithContext(ctx).Where("status IN ? AND (lease_expires_at IS NULL OR lease_expires_at <= ?) AND (device_id IS NULL OR device_id = ?)", []string{models.PurchaseTaskStatusPending, models.PurchaseTaskStatusSpecProbePending}, now, d.ID).Order("CASE WHEN device_id IS NULL THEN 1 ELSE 0 END, created_at, id").Limit(100).Find(&candidates).Error; err != nil {
|
||||
@@ -186,17 +199,14 @@ func (s *Service) Start(ctx context.Context, taskID uint64, req ActionRequest, t
|
||||
if e = ensureAccountFree(tx, t.PDDAccountID, t.ID, s.Now()); e != nil {
|
||||
return e
|
||||
}
|
||||
phase := models.PurchaseAttemptPhasePurchase
|
||||
if t.SpecSource == "unresolved" {
|
||||
phase = models.PurchaseAttemptPhaseSpecProbe
|
||||
}
|
||||
phase := purchaseAttemptPhase(t)
|
||||
ruleSnapshotHash := purchaseRuleSnapshotHash(t.RuleSnapshot)
|
||||
now := s.Now()
|
||||
var a models.PurchaseTaskAttempt
|
||||
if e = tx.Where("task_id = ? AND status = ?", t.ID, models.PurchaseAttemptStatusPending).Order("attempt_number DESC, id DESC").First(&a).Error; e == nil {
|
||||
if a.DeviceID == nil || *a.DeviceID != d.ID || a.RuleSnapshotHash != ruleSnapshotHash {
|
||||
if a.DeviceID == nil || *a.DeviceID != d.ID || a.RuleSnapshotHash != ruleSnapshotHash || a.Phase != phase {
|
||||
failureCode := CodeStateConflict
|
||||
message := "采购规则快照校验失败,请重新重试任务"
|
||||
message := "采购任务执行快照校验失败,请重新创建任务"
|
||||
a.Status = models.PurchaseAttemptStatusFailed
|
||||
a.ErrorCode = &failureCode
|
||||
a.ErrorMessage = &message
|
||||
@@ -266,6 +276,9 @@ func (s *Service) MarkOrderSubmitStarted(ctx context.Context, taskID uint64, req
|
||||
if t.ExecutionMode != models.PurchaseExecutionModeLive {
|
||||
return TaskPayload{}, fail(CodeStateConflict, "演练任务不能创建订单")
|
||||
}
|
||||
if a.Phase != models.PurchaseAttemptPhasePurchase {
|
||||
return TaskPayload{}, fail(CodeStateConflict, "规格探测阶段不能进入创建订单边界")
|
||||
}
|
||||
if _, e := uuid.Parse(req.RequestID); e != nil {
|
||||
return TaskPayload{}, fail(CodeInvalidRequest, "requestId 无效")
|
||||
}
|
||||
@@ -536,19 +549,29 @@ func (s *Service) resolveProbedSpecs(ctx context.Context, taskID uint64, attempt
|
||||
}
|
||||
decision.NoMatch, decision.Decision = true, snapshot
|
||||
decision.FailureCode, decision.FailureMessage = "PURCHASE_SPEC_NOT_MATCHED", "没有找到可采购的 PDD 颜色或尺码"
|
||||
} else if matched, matchErr := s.matcher().Resolve(ctx, request); matchErr == nil {
|
||||
snapshot, marshalErr := json.Marshal(matched.Decision)
|
||||
if marshalErr != nil {
|
||||
return TaskPayload{}, internal(marshalErr)
|
||||
}
|
||||
decision.MappedColor, decision.MappedSize, decision.Source, decision.Decision = matched.MappedColor, matched.MappedSize, matched.Source, snapshot
|
||||
} else {
|
||||
snapshot, marshalErr := json.Marshal(aimatching.NoMatchDecision(request, aimatching.SourceAI, purchaseMatchReason(matchErr)))
|
||||
if marshalErr != nil {
|
||||
return TaskPayload{}, internal(marshalErr)
|
||||
matched, matchErr := s.resolveProbedMatch(ctx, task, request)
|
||||
valid := matchErr == nil && (matched.Source == "manual_mapping" || matched.Source == aimatching.SourceExact || matched.Source == aimatching.SourceAI) &&
|
||||
matchCandidateValid(request.TargetColor, matched.MappedColor, request.Colors) &&
|
||||
matchCandidateValid(request.TargetSize, matched.MappedSize, request.Sizes)
|
||||
if valid {
|
||||
snapshot, marshalErr := json.Marshal(matched.Decision)
|
||||
if marshalErr != nil {
|
||||
return TaskPayload{}, internal(marshalErr)
|
||||
}
|
||||
decision.MappedColor, decision.MappedSize, decision.Source, decision.Decision = matched.MappedColor, matched.MappedSize, matched.Source, snapshot
|
||||
} else {
|
||||
reason := purchaseMatchReason(matchErr)
|
||||
if matchErr == nil {
|
||||
reason = "AI 规格匹配结果不属于当次 PDD 候选"
|
||||
}
|
||||
snapshot, marshalErr := json.Marshal(aimatching.NoMatchDecision(request, aimatching.SourceAI, reason))
|
||||
if marshalErr != nil {
|
||||
return TaskPayload{}, internal(marshalErr)
|
||||
}
|
||||
decision.NoMatch, decision.Decision = true, snapshot
|
||||
decision.FailureCode, decision.FailureMessage = "PURCHASE_SPEC_NOT_MATCHED", reason
|
||||
}
|
||||
decision.NoMatch, decision.Decision = true, snapshot
|
||||
decision.FailureCode, decision.FailureMessage = "PURCHASE_SPEC_NOT_MATCHED", purchaseMatchReason(matchErr)
|
||||
}
|
||||
updated, _, err := s.applySpecDecision(ctx, taskID, decision, nil)
|
||||
if err != nil {
|
||||
@@ -567,7 +590,7 @@ func purchaseMatchReason(err error) string {
|
||||
return "AI 规格匹配暂时不可用,请稍后重新创建采购任务"
|
||||
}
|
||||
}
|
||||
return "没有找到可采购的 PDD 颜色或尺码"
|
||||
return "已采集到当前规格,但未能确定颜色或尺码映射"
|
||||
}
|
||||
|
||||
type probedSpecCandidates struct {
|
||||
@@ -649,7 +672,7 @@ func (s *Service) withRunning(ctx context.Context, taskID uint64, token string,
|
||||
|
||||
func ensureDeviceFree(tx *gorm.DB, deviceID, taskID uint64, now time.Time) error {
|
||||
var count int64
|
||||
if e := tx.Model(&models.PurchaseTask{}).Where("id <> ? AND device_id = ? AND (status IN ? OR (status IN ? AND lease_expires_at > ?))", taskID, deviceID, []string{models.PurchaseTaskStatusRunning, models.PurchaseTaskStatusOrderSubmitStarted}, []string{models.PurchaseTaskStatusPending, models.PurchaseTaskStatusSpecProbePending}, now).Count(&count).Error; e != nil {
|
||||
if e := tx.Model(&models.PurchaseTask{}).Where("id <> ? AND device_id = ? AND (status IN ? OR (status = ? AND lease_expires_at > ?))", taskID, deviceID, []string{models.PurchaseTaskStatusRunning, models.PurchaseTaskStatusOrderSubmitStarted, models.PurchaseTaskStatusSpecProbePending}, models.PurchaseTaskStatusPending, now).Count(&count).Error; e != nil {
|
||||
return internal(e)
|
||||
}
|
||||
if count > 0 {
|
||||
@@ -668,7 +691,7 @@ func ensureAccountFree(tx *gorm.DB, accountID *uint64, taskID uint64, now time.T
|
||||
return nil
|
||||
}
|
||||
var count int64
|
||||
if e := tx.Model(&models.PurchaseTask{}).Where("id <> ? AND pdd_account_id = ? AND (status IN ? OR (status IN ? AND lease_expires_at > ?))", taskID, *accountID, []string{models.PurchaseTaskStatusRunning, models.PurchaseTaskStatusOrderSubmitStarted}, []string{models.PurchaseTaskStatusPending, models.PurchaseTaskStatusSpecProbePending}, now).Count(&count).Error; e != nil {
|
||||
if e := tx.Model(&models.PurchaseTask{}).Where("id <> ? AND pdd_account_id = ? AND (status IN ? OR (status = ? AND lease_expires_at > ?))", taskID, *accountID, []string{models.PurchaseTaskStatusRunning, models.PurchaseTaskStatusOrderSubmitStarted, models.PurchaseTaskStatusSpecProbePending}, models.PurchaseTaskStatusPending, now).Count(&count).Error; e != nil {
|
||||
return internal(e)
|
||||
}
|
||||
if count > 0 {
|
||||
|
||||
@@ -40,6 +40,19 @@ func queuedMatchTask(t *testing.T) (*gorm.DB, fixture, models.PurchaseTask) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fingerprint, snapshot, err := currentPurchaseMatchInput(db, task)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
work := models.PurchaseSpecMatchWorkItem{
|
||||
PurchaseTaskID: task.ID,
|
||||
Status: models.PurchaseMatchPending,
|
||||
InputFingerprint: fingerprint,
|
||||
InputSnapshotJSON: snapshot,
|
||||
}
|
||||
if err := db.Create(&work).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return db, f, task
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
package purchase
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
|
||||
"go-admin/app/goauto/aimatching"
|
||||
"go-admin/app/goauto/models"
|
||||
"go-admin/app/goauto/shopeeproduct"
|
||||
)
|
||||
|
||||
// resolveProbedMatch keeps the live probe as the source of executable labels.
|
||||
// Confirmed product mappings may guide the decision only when they still map
|
||||
// uniquely to a label observed by this attempt.
|
||||
func (s *Service) resolveProbedMatch(ctx context.Context, task models.PurchaseTask, request aimatching.MatchRequest) (aimatching.MatchResult, error) {
|
||||
resolvedColor, resolvedSize := "", ""
|
||||
roleSources := map[string]string{}
|
||||
|
||||
mappings := probeGuidanceFromSnapshot(task.SpecDecisionSnapshot)
|
||||
if candidate, ok := currentMappedCandidate(mappings.color.value, request.Colors); ok {
|
||||
resolvedColor, roleSources[shopeeproduct.RoleColor] = candidate, mappings.color.source
|
||||
}
|
||||
if candidate, ok := currentMappedCandidate(mappings.size.value, request.Sizes); ok {
|
||||
resolvedSize, roleSources[shopeeproduct.RoleSize] = candidate, mappings.size.source
|
||||
}
|
||||
|
||||
if task.TargetColorSnapshot != "" && resolvedColor == "" {
|
||||
if exact, ok := aimatching.DeterministicMatch(aimatching.MatchRequest{TargetColor: task.TargetColorSnapshot, Colors: request.Colors}); ok {
|
||||
resolvedColor, roleSources[shopeeproduct.RoleColor] = exact.MappedColor, aimatching.SourceExact
|
||||
}
|
||||
}
|
||||
if task.TargetSizeSnapshot != "" && resolvedSize == "" {
|
||||
if exact, ok := aimatching.DeterministicMatch(aimatching.MatchRequest{TargetSize: task.TargetSizeSnapshot, Sizes: request.Sizes}); ok {
|
||||
resolvedSize, roleSources[shopeeproduct.RoleSize] = exact.MappedSize, aimatching.SourceExact
|
||||
}
|
||||
}
|
||||
|
||||
missingColor := task.TargetColorSnapshot != "" && resolvedColor == ""
|
||||
missingSize := task.TargetSizeSnapshot != "" && resolvedSize == ""
|
||||
var providerDecision aimatching.DecisionSnapshot
|
||||
if missingColor || missingSize {
|
||||
remaining := aimatching.MatchRequest{}
|
||||
if missingColor {
|
||||
remaining.TargetColor, remaining.Colors = task.TargetColorSnapshot, request.Colors
|
||||
}
|
||||
if missingSize {
|
||||
remaining.TargetSize, remaining.Sizes = task.TargetSizeSnapshot, request.Sizes
|
||||
}
|
||||
matched, err := s.matcher().Resolve(ctx, remaining)
|
||||
if err != nil {
|
||||
return aimatching.MatchResult{}, err
|
||||
}
|
||||
if matched.Source != aimatching.SourceExact && matched.Source != aimatching.SourceAI {
|
||||
return aimatching.MatchResult{}, &aimatching.Error{Code: aimatching.CodeNoMatch, Message: "规格匹配来源无效"}
|
||||
}
|
||||
providerDecision = matched.Decision
|
||||
if missingColor {
|
||||
if !matchCandidateValid(remaining.TargetColor, matched.MappedColor, remaining.Colors) {
|
||||
return aimatching.MatchResult{}, &aimatching.Error{Code: aimatching.CodeNoMatch, Message: "AI 返回的颜色不属于当次候选"}
|
||||
}
|
||||
resolvedColor, roleSources[shopeeproduct.RoleColor] = matched.MappedColor, matched.Source
|
||||
}
|
||||
if missingSize {
|
||||
if !matchCandidateValid(remaining.TargetSize, matched.MappedSize, remaining.Sizes) {
|
||||
return aimatching.MatchResult{}, &aimatching.Error{Code: aimatching.CodeNoMatch, Message: "AI 返回的尺码不属于当次候选"}
|
||||
}
|
||||
resolvedSize, roleSources[shopeeproduct.RoleSize] = matched.MappedSize, matched.Source
|
||||
}
|
||||
}
|
||||
|
||||
source := probeSourceSummary(roleSources)
|
||||
reason := "当次候选完成确定性匹配"
|
||||
if containsRoleSource(roleSources, "manual_mapping") || containsRoleSource(roleSources, aimatching.SourceAI) {
|
||||
reason = "已确认映射经当次候选验证,未解决规格按确定性或 AI 匹配"
|
||||
}
|
||||
result := aimatching.RecordedMatch(request, source, resolvedColor, resolvedSize, reason)
|
||||
result.Decision.RoleSources = roleSources
|
||||
result.Decision.Provider = providerDecision.Provider
|
||||
result.Decision.Model = providerDecision.Model
|
||||
result.Decision.Confidence = providerDecision.Confidence
|
||||
return result, nil
|
||||
}
|
||||
|
||||
type confirmedProbeMapping struct {
|
||||
value string
|
||||
source string
|
||||
invalid bool
|
||||
}
|
||||
|
||||
type confirmedProbeMappingSet struct {
|
||||
color confirmedProbeMapping
|
||||
size confirmedProbeMapping
|
||||
}
|
||||
|
||||
type probeGuidanceSnapshot struct {
|
||||
ConfirmedMappings map[string]probeGuidanceMapping `json:"confirmedMappings,omitempty"`
|
||||
}
|
||||
|
||||
type probeGuidanceMapping struct {
|
||||
Value string `json:"value"`
|
||||
Source string `json:"source"`
|
||||
}
|
||||
|
||||
func newProbeGuidanceSnapshot(raw, targetColor, targetSize string) (string, error) {
|
||||
mappings := confirmedProbeMappings(raw, targetColor, targetSize)
|
||||
snapshot := probeGuidanceSnapshot{ConfirmedMappings: map[string]probeGuidanceMapping{}}
|
||||
if mappings.color.value != "" {
|
||||
snapshot.ConfirmedMappings[shopeeproduct.RoleColor] = probeGuidanceMapping{Value: mappings.color.value, Source: mappings.color.source}
|
||||
}
|
||||
if mappings.size.value != "" {
|
||||
snapshot.ConfirmedMappings[shopeeproduct.RoleSize] = probeGuidanceMapping{Value: mappings.size.value, Source: mappings.size.source}
|
||||
}
|
||||
encoded, err := json.Marshal(snapshot)
|
||||
return string(encoded), err
|
||||
}
|
||||
|
||||
func probeGuidanceFromSnapshot(raw string) confirmedProbeMappingSet {
|
||||
var snapshot probeGuidanceSnapshot
|
||||
if json.Unmarshal([]byte(raw), &snapshot) != nil {
|
||||
return confirmedProbeMappingSet{}
|
||||
}
|
||||
result := confirmedProbeMappingSet{}
|
||||
if mapping, ok := snapshot.ConfirmedMappings[shopeeproduct.RoleColor]; ok {
|
||||
result.color = confirmedProbeMapping{value: strings.TrimSpace(mapping.Value), source: mapping.Source}
|
||||
}
|
||||
if mapping, ok := snapshot.ConfirmedMappings[shopeeproduct.RoleSize]; ok {
|
||||
result.size = confirmedProbeMapping{value: strings.TrimSpace(mapping.Value), source: mapping.Source}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func confirmedProbeMappings(raw, targetColor, targetSize string) confirmedProbeMappingSet {
|
||||
var specs []shopeeproduct.SpecDimension
|
||||
if json.Unmarshal([]byte(raw), &specs) != nil {
|
||||
return confirmedProbeMappingSet{}
|
||||
}
|
||||
result := confirmedProbeMappingSet{}
|
||||
for _, dimension := range specs {
|
||||
for _, value := range dimension.Values {
|
||||
if value.Mapping == nil || value.Mapping.Status != shopeeproduct.MappingStatusConfirmed {
|
||||
continue
|
||||
}
|
||||
mapping := confirmedProbeMapping{value: strings.TrimSpace(value.Mapping.PDDValue), source: mapSource(value.Mapping.Source)}
|
||||
switch {
|
||||
case dimension.Role == shopeeproduct.RoleColor && value.Name == targetColor:
|
||||
result.color = mergeConfirmedProbeMapping(result.color, mapping)
|
||||
case dimension.Role == shopeeproduct.RoleSize && value.Name == targetSize:
|
||||
result.size = mergeConfirmedProbeMapping(result.size, mapping)
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func mergeConfirmedProbeMapping(current, incoming confirmedProbeMapping) confirmedProbeMapping {
|
||||
if current.invalid {
|
||||
return current
|
||||
}
|
||||
if current.value == "" {
|
||||
return incoming
|
||||
}
|
||||
if current.value != incoming.value {
|
||||
return confirmedProbeMapping{invalid: true}
|
||||
}
|
||||
return current
|
||||
}
|
||||
|
||||
func currentMappedCandidate(mapped string, candidates []string) (string, bool) {
|
||||
mapped = strings.TrimSpace(mapped)
|
||||
if mapped == "" {
|
||||
return "", false
|
||||
}
|
||||
matches := make([]string, 0, 1)
|
||||
seen := map[string]bool{}
|
||||
for _, candidate := range candidates {
|
||||
candidate = strings.TrimSpace(candidate)
|
||||
if candidate == "" || seen[candidate] || aimatching.Normalize(candidate) != aimatching.Normalize(mapped) {
|
||||
continue
|
||||
}
|
||||
seen[candidate] = true
|
||||
matches = append(matches, candidate)
|
||||
}
|
||||
if len(matches) != 1 {
|
||||
return "", false
|
||||
}
|
||||
return matches[0], true
|
||||
}
|
||||
|
||||
func probeSourceSummary(roleSources map[string]string) string {
|
||||
if containsRoleSource(roleSources, "manual_mapping") {
|
||||
return "manual_mapping"
|
||||
}
|
||||
if containsRoleSource(roleSources, aimatching.SourceAI) {
|
||||
return aimatching.SourceAI
|
||||
}
|
||||
return aimatching.SourceExact
|
||||
}
|
||||
|
||||
func containsRoleSource(roleSources map[string]string, target string) bool {
|
||||
for _, source := range roleSources {
|
||||
if source == target {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -69,8 +69,7 @@ func (s *Service) ProcessStages(ctx context.Context, ids []uint64) (map[uint64]P
|
||||
return nil, err
|
||||
}
|
||||
for _, id := range ids {
|
||||
preview := s.previewFromDataset(ctx, id, dataset, false, guard)
|
||||
preview.enforcePersistedMatch(id, dataset)
|
||||
preview := s.previewFromDataset(id, dataset, guard)
|
||||
qualification := aiMatchQualificationForDataset(id, dataset)
|
||||
preview.AIMatchEligible = qualification.Eligible
|
||||
preview.AIMatchDisabledReason = qualification.DisabledReason
|
||||
|
||||
@@ -87,6 +87,9 @@ func (s *Service) reset(ctx context.Context, taskID uint64, req PurchaseResetReq
|
||||
if err := validatePurchaseResetState(tx, task); err != nil {
|
||||
return err
|
||||
}
|
||||
if task.SpecSource != "unresolved" && !hasCompletePurchaseSpec(task) {
|
||||
return fail(CodeMappingRequired, "任务没有完整的精确商品规格,请创建新采购任务")
|
||||
}
|
||||
deviceRecord, err := lockPurchaseResetDevice(tx, task, authenticated)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -130,10 +133,7 @@ func (s *Service) reset(ctx context.Context, taskID uint64, req PurchaseResetReq
|
||||
return conflictOrInternal(err)
|
||||
}
|
||||
|
||||
phase := models.PurchaseAttemptPhasePurchase
|
||||
if task.SpecSource == "unresolved" {
|
||||
phase = models.PurchaseAttemptPhaseSpecProbe
|
||||
}
|
||||
phase := purchaseAttemptPhase(task)
|
||||
// MySQL normalizes values written to a JSON column. Reload the task before
|
||||
// hashing so the pending attempt uses the exact representation Start will
|
||||
// read later, rather than the pre-persistence DefaultLiveRule bytes.
|
||||
@@ -155,6 +155,18 @@ func (s *Service) reset(ctx context.Context, taskID uint64, req PurchaseResetReq
|
||||
return response, err
|
||||
}
|
||||
|
||||
func purchaseAttemptPhase(task models.PurchaseTask) string {
|
||||
if task.SpecSource == "unresolved" || !hasCompletePurchaseSpec(task) {
|
||||
return models.PurchaseAttemptPhaseSpecProbe
|
||||
}
|
||||
return models.PurchaseAttemptPhasePurchase
|
||||
}
|
||||
|
||||
func hasCompletePurchaseSpec(task models.PurchaseTask) bool {
|
||||
return (strings.TrimSpace(task.TargetColorSnapshot) == "" || strings.TrimSpace(task.MappedColorSnapshot) != "") &&
|
||||
(strings.TrimSpace(task.TargetSizeSnapshot) == "" || strings.TrimSpace(task.MappedSizeSnapshot) != "")
|
||||
}
|
||||
|
||||
func validatePurchaseResetState(tx *gorm.DB, task models.PurchaseTask) error {
|
||||
switch task.Status {
|
||||
case models.PurchaseTaskStatusOrderSubmitStarted, models.PurchaseTaskStatusOrderCreated, models.PurchaseTaskStatusOrderResultUnknown:
|
||||
|
||||
@@ -108,6 +108,36 @@ func TestPurchaseResetReusesTaskRefreshesRuleAndIsIdempotent(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPurchaseResetRejectsResolvedDecisionWithoutExactSpec(t *testing.T) {
|
||||
db := testDB(t)
|
||||
f := seed(t, db, liveCaps(), true)
|
||||
service := testService(db)
|
||||
failed := failedLiveTask(t, db, service, f)
|
||||
decisionID := uuid.NewString()
|
||||
if err := db.Session(&gorm.Session{SkipHooks: true}).Model(&models.PurchaseTask{}).Where("id = ?", failed.ID).Updates(map[string]any{
|
||||
"spec_source": "ai_match", "spec_decision_request_id": decisionID,
|
||||
"mapped_color_snapshot": "", "mapped_size_snapshot": "",
|
||||
}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_, err := service.ResetForDevice(context.Background(), failed.ID, PurchaseResetRequest{RequestID: uuid.NewString()}, f.token)
|
||||
if code(err) != CodeMappingRequired {
|
||||
t.Fatalf("resolved decision without exact spec was reset: %v", err)
|
||||
}
|
||||
var task models.PurchaseTask
|
||||
if queryErr := db.First(&task, failed.ID).Error; queryErr != nil {
|
||||
t.Fatal(queryErr)
|
||||
}
|
||||
if task.Status != models.PurchaseTaskStatusFailed {
|
||||
t.Fatalf("rejected reset changed task status: %+v", task)
|
||||
}
|
||||
var attemptCount int64
|
||||
if queryErr := db.Model(&models.PurchaseTaskAttempt{}).Where("task_id = ?", failed.ID).Count(&attemptCount).Error; queryErr != nil || attemptCount != 0 {
|
||||
t.Fatalf("rejected reset created attempt: count=%d err=%v", attemptCount, queryErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPurchaseStartPersistsPendingAttemptSnapshotMismatchAsFailure(t *testing.T) {
|
||||
db := testDB(t)
|
||||
f := seed(t, db, liveCaps(), true)
|
||||
|
||||
@@ -218,7 +218,12 @@ func (s *Service) retryEligibility(ctx context.Context, task models.PurchaseTask
|
||||
return decision
|
||||
}
|
||||
|
||||
preview := s.previewOne(ctx, *task.SYBProductID)
|
||||
_, _, currentRule, ruleErr := purchaserule.CurrentRule(ctx, s.DB, models.PurchaseExecutionModeLive)
|
||||
if ruleErr != nil {
|
||||
code, message, _ := serviceErrorFields(ruleErr)
|
||||
return retryDecision{ReasonCode: code, Reason: message}
|
||||
}
|
||||
preview := s.previewOneDeterministic(ctx, *task.SYBProductID, purchasecontract.EffectivePriceGuard(currentRule))
|
||||
if !preview.Eligible {
|
||||
return retryDecision{ReasonCode: preview.ReasonCode, Reason: preview.Reason}
|
||||
}
|
||||
@@ -230,10 +235,9 @@ func (s *Service) retryEligibility(ctx context.Context, task models.PurchaseTask
|
||||
}
|
||||
|
||||
// retryQueryEligibility is deliberately limited to local task and device
|
||||
// safety checks. Admin list/detail queries must never call previewOne because
|
||||
// previewOne can fall back to the external AI provider for unresolved specs.
|
||||
// The batch-retry command runs retryEligibility and performs that complete,
|
||||
// current-archive validation only after the operator submits the retry.
|
||||
// safety checks. The batch-retry command performs deterministic archive and
|
||||
// price validation, but specification resolution belongs to the new task's
|
||||
// mandatory live probe and never calls AI before task creation.
|
||||
func (s *Service) retryQueryEligibility(ctx context.Context, task models.PurchaseTask, checkDeviceBusy bool) retryDecision {
|
||||
decision := s.retryStateEligibility(ctx, task)
|
||||
if !decision.Allowed {
|
||||
@@ -263,25 +267,20 @@ func (s *Service) continuePurchaseEligibility(ctx context.Context, task models.P
|
||||
guard := purchasecontract.EffectivePriceGuard(currentRule)
|
||||
syb, found := dataset.sybByID[*task.SYBProductID]
|
||||
if !found || syb.ShopeeProductID == nil {
|
||||
preview := s.previewFromDataset(ctx, *task.SYBProductID, dataset, false, guard)
|
||||
preview := s.previewFromDataset(*task.SYBProductID, dataset, guard)
|
||||
return retryDecision{ReasonCode: preview.ReasonCode, Reason: preview.Reason}
|
||||
}
|
||||
shopee, found := dataset.shopeeByID[*syb.ShopeeProductID]
|
||||
if !found || shopee.PDDProductID == nil {
|
||||
preview := s.previewFromDataset(ctx, *task.SYBProductID, dataset, false, guard)
|
||||
preview := s.previewFromDataset(*task.SYBProductID, dataset, guard)
|
||||
return retryDecision{ReasonCode: preview.ReasonCode, Reason: preview.Reason}
|
||||
}
|
||||
pdd, found := dataset.pddByID[*shopee.PDDProductID]
|
||||
_, found = dataset.pddByID[*shopee.PDDProductID]
|
||||
if !found {
|
||||
preview := s.previewFromDataset(ctx, *task.SYBProductID, dataset, false, guard)
|
||||
preview := s.previewFromDataset(*task.SYBProductID, dataset, guard)
|
||||
return retryDecision{ReasonCode: preview.ReasonCode, Reason: preview.Reason}
|
||||
}
|
||||
mappedColor, mappedSize, source := confirmedMappings(shopee.SpecsJSON, syb.TargetColor, syb.TargetSize)
|
||||
candidates, _ := archiveCandidates(pdd.SpecsJSON, syb.TargetColor, syb.TargetSize)
|
||||
if source == "unresolved" || !mappingTargetsValid(candidates, syb.TargetColor, syb.TargetSize, mappedColor, mappedSize) {
|
||||
return retryDecision{ReasonCode: CodeMappingRequired, Reason: "规格匹配已失效,请在 Admin 重新确认"}
|
||||
}
|
||||
preview := s.previewFromDataset(ctx, *task.SYBProductID, dataset, false, guard)
|
||||
preview := s.previewFromDataset(*task.SYBProductID, dataset, guard)
|
||||
if !preview.Eligible {
|
||||
return retryDecision{ReasonCode: preview.ReasonCode, Reason: preview.Reason}
|
||||
}
|
||||
|
||||
@@ -223,7 +223,7 @@ func TestAdminListRetryEligibilityRejectsBusyOriginalDevice(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminQueriesDoNotResolveSpecsButBatchRetryStillDoes(t *testing.T) {
|
||||
func TestAdminQueriesAndBatchRetryDoNotResolveArchivedSpecs(t *testing.T) {
|
||||
db := testDB(t)
|
||||
f := seed(t, db, liveCaps(), false)
|
||||
service := testService(db)
|
||||
@@ -253,8 +253,8 @@ func TestAdminQueriesDoNotResolveSpecsButBatchRetryStillDoes(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if matcher.calls != 1 || retry.CreatedCount != 0 || retry.FailedCount != 1 || retry.Items[0].ReasonCode != CodeMappingRequired {
|
||||
t.Fatalf("batch retry skipped full matching: result=%+v matcherCalls=%d", retry, matcher.calls)
|
||||
if matcher.calls != 0 || retry.CreatedCount != 1 || retry.FailedCount != 0 {
|
||||
t.Fatalf("batch retry did not create a mandatory probe task: result=%+v matcherCalls=%d", retry, matcher.calls)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -54,11 +54,6 @@ type SpecMatcher interface {
|
||||
Resolve(context.Context, aimatching.MatchRequest) (aimatching.MatchResult, error)
|
||||
}
|
||||
|
||||
type externalMatchPlan struct {
|
||||
fingerprint string
|
||||
snapshot string
|
||||
}
|
||||
|
||||
type matchFingerprint struct {
|
||||
SYBProductID uint64 `json:"sybProductId"`
|
||||
TargetColor string `json:"targetColor"`
|
||||
@@ -96,51 +91,6 @@ func (s *Service) Create(ctx context.Context, req CreateRequest) (models.Purchas
|
||||
return call.task, call.replayed, call.err
|
||||
}
|
||||
|
||||
func (s *Service) prepareExternalMatch(ctx context.Context, req CreateRequest) (*externalMatchPlan, error) {
|
||||
if req.ExecutionMode != models.PurchaseExecutionModeLive || req.SYBProductID == nil {
|
||||
return nil, nil
|
||||
}
|
||||
var syb models.SYBProduct
|
||||
if err := s.DB.WithContext(ctx).First(&syb, *req.SYBProductID).Error; err != nil {
|
||||
return nil, notFound(err, "顺云宝商品不存在")
|
||||
}
|
||||
if syb.ShopeeProductID == nil {
|
||||
return nil, fail(CodeInvalidRequest, "该商品尚未关联蝦皮商品")
|
||||
}
|
||||
var shopee models.ShopeeProduct
|
||||
if err := s.DB.WithContext(ctx).First(&shopee, *syb.ShopeeProductID).Error; err != nil {
|
||||
return nil, notFound(err, "蝦皮商品不存在")
|
||||
}
|
||||
if shopee.PDDProductID == nil {
|
||||
return nil, fail(CodeInvalidRequest, "该蝦皮商品尚未关联拼多多商品")
|
||||
}
|
||||
var pdd models.PDDProduct
|
||||
if err := s.DB.WithContext(ctx).First(&pdd, *shopee.PDDProductID).Error; err != nil {
|
||||
return nil, notFound(err, "拼多多商品不存在")
|
||||
}
|
||||
targetColor, targetSize := strings.TrimSpace(req.TargetColor), strings.TrimSpace(req.TargetSize)
|
||||
if targetColor == "" {
|
||||
targetColor = syb.TargetColor
|
||||
}
|
||||
if targetSize == "" {
|
||||
targetSize = syb.TargetSize
|
||||
}
|
||||
_, _, source := confirmedMappings(shopee.SpecsJSON, targetColor, targetSize)
|
||||
candidates, usable := archiveCandidates(pdd.SpecsJSON, targetColor, targetSize)
|
||||
if pdd.Status != "active" || source != "unresolved" || !usable {
|
||||
return nil, nil
|
||||
}
|
||||
request := aimatching.MatchRequest{TargetColor: targetColor, TargetSize: targetSize, Colors: candidates.Colors, Sizes: candidates.Sizes}
|
||||
if _, deterministic := aimatching.DeterministicMatch(request); deterministic {
|
||||
return nil, nil
|
||||
}
|
||||
fingerprint, snapshot, err := creationMatchInput(syb, shopee, pdd, request)
|
||||
if err != nil {
|
||||
return nil, internal(err)
|
||||
}
|
||||
return &externalMatchPlan{fingerprint: fingerprint, snapshot: snapshot}, nil
|
||||
}
|
||||
|
||||
func creationMatchInput(syb models.SYBProduct, shopee models.ShopeeProduct, pdd models.PDDProduct, request aimatching.MatchRequest) (string, string, error) {
|
||||
canonical := func(raw string) (json.RawMessage, error) {
|
||||
var value any
|
||||
@@ -194,10 +144,6 @@ func (s *Service) create(ctx context.Context, req CreateRequest) (models.Purchas
|
||||
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return models.PurchaseTask{}, false, internal(err)
|
||||
}
|
||||
externalPlan, err := s.prepareExternalMatch(ctx, req)
|
||||
if err != nil {
|
||||
return models.PurchaseTask{}, false, err
|
||||
}
|
||||
replayed := false
|
||||
err = s.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Where("create_request_id = ?", req.RequestID).First(&out).Error; err == nil {
|
||||
@@ -334,22 +280,25 @@ func (s *Service) create(ctx context.Context, req CreateRequest) (models.Purchas
|
||||
if currency == "" {
|
||||
currency = shopee.Currency
|
||||
}
|
||||
mappedColor, mappedSize, specSource = confirmedMappings(shopee.SpecsJSON, targetColor, targetSize)
|
||||
// Every new SYB purchase must use the candidates observed on the
|
||||
// current PDD page. Freeze confirmed mappings only as guidance for
|
||||
// validation against that future probe; they never skip the probe.
|
||||
mappedColor, mappedSize, specSource = "", "", "unresolved"
|
||||
guidance, marshalErr := newProbeGuidanceSnapshot(shopee.SpecsJSON, targetColor, targetSize)
|
||||
if marshalErr != nil {
|
||||
return internal(marshalErr)
|
||||
}
|
||||
decisionSnapshot = guidance
|
||||
}
|
||||
candidates, archiveUsable := archiveCandidates(pdd.SpecsJSON, targetColor, targetSize)
|
||||
matchRequest := aimatching.MatchRequest{TargetColor: targetColor, TargetSize: targetSize, Colors: candidates.Colors, Sizes: candidates.Sizes}
|
||||
if externalPlan != nil {
|
||||
fingerprint, fingerprintErr := creationMatchFingerprint(syb, shopee, pdd, matchRequest)
|
||||
if fingerprintErr != nil {
|
||||
return internal(fingerprintErr)
|
||||
}
|
||||
if fingerprint != externalPlan.fingerprint {
|
||||
return fail(CodeMappingRequired, "规格匹配输入已变化,请重新创建采购任务")
|
||||
}
|
||||
}
|
||||
if taskType == models.PurchaseTaskTypeStock {
|
||||
// Direct stock selection was already validated above. It never enters
|
||||
// mapping, deterministic matching, or the external AI fallback.
|
||||
} else if req.ExecutionMode == models.PurchaseExecutionModeLive {
|
||||
// The first attempt is always spec_probe for an SYB purchase. The
|
||||
// exact task-level decision is frozen only from that probe result.
|
||||
mappedColor, mappedSize, specSource = "", "", "unresolved"
|
||||
} else if pdd.Status != "active" {
|
||||
mappedColor, mappedSize, specSource = "", "", "unresolved"
|
||||
} else if specSource == "manual_mapping" || specSource == "exact_match" || specSource == "ai_match" {
|
||||
@@ -365,8 +314,6 @@ func (s *Service) create(ctx context.Context, req CreateRequest) (models.Purchas
|
||||
}
|
||||
} else if !archiveUsable {
|
||||
mappedColor, mappedSize, specSource = "", "", "unresolved"
|
||||
} else if externalPlan != nil {
|
||||
mappedColor, mappedSize, specSource = "", "", "unresolved"
|
||||
} else if match, matched := aimatching.DeterministicMatch(matchRequest); !matched {
|
||||
// #190:确定性匹配失败不再拒绝,降级为 unresolved 交给规格探测。
|
||||
mappedColor, mappedSize, specSource = "", "", "unresolved"
|
||||
@@ -404,17 +351,8 @@ func (s *Service) create(ctx context.Context, req CreateRequest) (models.Purchas
|
||||
if err := tx.Model(&out).Update("address_suffix", out.AddressSuffix).Error; err != nil {
|
||||
return internal(err)
|
||||
}
|
||||
if externalPlan != nil {
|
||||
work := models.PurchaseSpecMatchWorkItem{PurchaseTaskID: out.ID, Status: models.PurchaseMatchPending, InputFingerprint: externalPlan.fingerprint, InputSnapshotJSON: externalPlan.snapshot}
|
||||
if err := tx.Create(&work).Error; err != nil {
|
||||
return internal(err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err == nil && !replayed && externalPlan != nil && s.StartMatching != nil {
|
||||
s.StartMatching(s.DB)
|
||||
}
|
||||
return out, replayed, err
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go-admin/app/goauto/aimatching"
|
||||
"go-admin/app/goauto/device"
|
||||
"go-admin/app/goauto/migrations"
|
||||
"go-admin/app/goauto/models"
|
||||
@@ -29,6 +30,19 @@ type fixture struct {
|
||||
token string
|
||||
}
|
||||
|
||||
type liveProbeMatcher struct {
|
||||
result aimatching.MatchResult
|
||||
err error
|
||||
calls int
|
||||
request aimatching.MatchRequest
|
||||
}
|
||||
|
||||
func (matcher *liveProbeMatcher) Resolve(_ context.Context, request aimatching.MatchRequest) (aimatching.MatchResult, error) {
|
||||
matcher.calls++
|
||||
matcher.request = request
|
||||
return matcher.result, matcher.err
|
||||
}
|
||||
|
||||
func testDB(t *testing.T) *gorm.DB {
|
||||
t.Helper()
|
||||
dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared&_foreign_keys=on", strings.ReplaceAll(t.Name(), "/", "_"))
|
||||
@@ -107,6 +121,30 @@ func createLive(t *testing.T, s *Service, f fixture) (models.PurchaseTask, error
|
||||
r, _, err := s.Create(context.Background(), CreateRequest{RequestID: uuid.NewString(), ExecutionMode: models.PurchaseExecutionModeLive, SYBProductID: &f.syb.ID, DeviceID: &f.device.ID, MinUnitPriceCent: 400, MaxUnitPriceCent: 3000, RuleSnapshot: liveRule(true)})
|
||||
return r, err
|
||||
}
|
||||
|
||||
func startLivePurchaseAfterProbe(t *testing.T, s *Service, f fixture, task models.PurchaseTask) TaskPayload {
|
||||
t.Helper()
|
||||
if _, err := s.Claim(context.Background(), task.ID, ActionRequest{RequestID: uuid.NewString()}, f.token); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
probeAttempt, err := s.Start(context.Background(), task.ID, ActionRequest{RequestID: uuid.NewString()}, f.token)
|
||||
if err != nil || probeAttempt.Phase != models.PurchaseAttemptPhaseSpecProbe {
|
||||
t.Fatalf("probe start: %+v %v", probeAttempt, err)
|
||||
}
|
||||
probe := ResultRequest{RequestID: uuid.NewString(), TaskAttemptID: probeAttempt.TaskAttemptID, ResultType: "spec_probe_completed", ProbedSpecs: []byte(`{"dimensions":[{"key":"color","values":["黑色"]},{"key":"size","values":["XL"]}]}`)}
|
||||
if _, err = s.SubmitResult(context.Background(), task.ID, probe, f.token); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err = s.Claim(context.Background(), task.ID, ActionRequest{RequestID: uuid.NewString()}, f.token); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
purchaseAttempt, err := s.Start(context.Background(), task.ID, ActionRequest{RequestID: uuid.NewString()}, f.token)
|
||||
if err != nil || purchaseAttempt.Phase != models.PurchaseAttemptPhasePurchase {
|
||||
t.Fatalf("purchase start: %+v %v", purchaseAttempt, err)
|
||||
}
|
||||
return purchaseAttempt
|
||||
}
|
||||
|
||||
func code(err error) string {
|
||||
var e *ServiceError
|
||||
if errors.As(err, &e) {
|
||||
@@ -224,9 +262,23 @@ func TestCreateAndLifecycleValidateCapabilitiesAndIdempotentResult(t *testing.T)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if start.Phase != models.PurchaseAttemptPhasePurchase {
|
||||
if start.Phase != models.PurchaseAttemptPhaseSpecProbe {
|
||||
t.Fatalf("unexpected phase %s", start.Phase)
|
||||
}
|
||||
if _, err = s.MarkOrderSubmitStarted(context.Background(), task.ID, ActionRequest{RequestID: uuid.NewString()}, f.token); code(err) != CodeStateConflict {
|
||||
t.Fatalf("probe entered order boundary: %v", err)
|
||||
}
|
||||
probe := ResultRequest{RequestID: uuid.NewString(), TaskAttemptID: start.TaskAttemptID, ResultType: "spec_probe_completed", ProbedSpecs: []byte(`{"dimensions":[{"key":"color","values":["黑色"]},{"key":"size","values":["XL"]}]}`)}
|
||||
if _, err = s.SubmitResult(context.Background(), task.ID, probe, f.token); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err = s.Claim(context.Background(), task.ID, ActionRequest{RequestID: uuid.NewString()}, f.token); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
start, err = s.Start(context.Background(), task.ID, ActionRequest{RequestID: uuid.NewString()}, f.token)
|
||||
if err != nil || start.Phase != models.PurchaseAttemptPhasePurchase {
|
||||
t.Fatalf("purchase start: %+v %v", start, err)
|
||||
}
|
||||
if _, err = s.MarkOrderSubmitStarted(context.Background(), task.ID, ActionRequest{RequestID: uuid.NewString()}, f.token); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -243,7 +295,7 @@ func TestCreateAndLifecycleValidateCapabilitiesAndIdempotentResult(t *testing.T)
|
||||
}
|
||||
var count int64
|
||||
db.Model(&models.PurchaseTaskAttempt{}).Where("task_id = ?", task.ID).Count(&count)
|
||||
if count != 1 || first.Status != models.PurchaseTaskStatusOrderCreated {
|
||||
if count != 2 || first.Status != models.PurchaseTaskStatusOrderCreated {
|
||||
t.Fatalf("duplicate attempt or wrong status: %d %+v", count, first)
|
||||
}
|
||||
var stored models.PurchaseTask
|
||||
@@ -344,6 +396,198 @@ func TestSlowPathUsesTwoAttemptsAndFreezesDecision(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestLiveProbeCallsAIOnlyAfterCandidatesAreReturned(t *testing.T) {
|
||||
db := testDB(t)
|
||||
f := seed(t, db, liveCaps(), false)
|
||||
if err := db.Model(&models.SYBProduct{}).Where("id = ?", f.syb.ID).Update("target_color", "象牙白").Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
request := aimatching.MatchRequest{TargetColor: "象牙白", TargetSize: "XL", Colors: []string{"米白色", "白色"}, Sizes: []string{"XL"}}
|
||||
matcher := &liveProbeMatcher{result: aimatching.RecordedMatch(request, aimatching.SourceAI, "米白色", "XL", "在当次候选中唯一匹配")}
|
||||
s := testService(db)
|
||||
s.Matcher = matcher
|
||||
task, err := createLive(t, s, f)
|
||||
if err != nil || matcher.calls != 0 || task.SpecSource != "unresolved" {
|
||||
t.Fatalf("task creation called matcher or skipped probe: task=%+v calls=%d err=%v", task, matcher.calls, err)
|
||||
}
|
||||
if _, err = s.Claim(context.Background(), task.ID, ActionRequest{RequestID: uuid.NewString()}, f.token); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
first, err := s.Start(context.Background(), task.ID, ActionRequest{RequestID: uuid.NewString()}, f.token)
|
||||
if err != nil || first.Phase != models.PurchaseAttemptPhaseSpecProbe {
|
||||
t.Fatalf("probe start: %+v %v", first, err)
|
||||
}
|
||||
probe := ResultRequest{RequestID: uuid.NewString(), TaskAttemptID: first.TaskAttemptID, ResultType: "spec_probe_completed", ProbedSpecs: []byte(`{"dimensions":[{"key":"color","values":["米白色","白色"]},{"key":"size","values":["XL"]}]}`)}
|
||||
resolved, err := s.SubmitResult(context.Background(), task.ID, probe, f.token)
|
||||
if err != nil || matcher.calls != 1 || resolved.Status != models.PurchaseTaskStatusPending || resolved.MappedColor != "米白色" || resolved.MappedSize != "XL" {
|
||||
t.Fatalf("probe AI result: %+v calls=%d err=%v", resolved, matcher.calls, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLiveProbeReusesConfirmedColorAndResolvesCurrentSizeWithoutAI(t *testing.T) {
|
||||
db := testDB(t)
|
||||
f := seed(t, db, liveCaps(), true)
|
||||
if err := db.Model(&models.SYBProduct{}).Where("id = ?", f.syb.ID).Updates(map[string]any{
|
||||
"target_color": "薑黃色",
|
||||
"target_size": "5XL",
|
||||
}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
specs := []shopeeproduct.SpecDimension{
|
||||
{Name: "颜色", Role: shopeeproduct.RoleColor, Values: []shopeeproduct.SpecValue{{Name: "薑黃色", Source: shopeeproduct.ValueSourceImport, Mapping: &shopeeproduct.Mapping{PDDValue: "黄色", Source: shopeeproduct.MappingSourceManual, Status: shopeeproduct.MappingStatusConfirmed}}}},
|
||||
{Name: "尺码", Role: shopeeproduct.RoleSize, Values: []shopeeproduct.SpecValue{{Name: "5XL", Source: shopeeproduct.ValueSourceImport, Mapping: &shopeeproduct.Mapping{PDDValue: "XXXXXL", Source: shopeeproduct.MappingSourceAIMatch, Status: shopeeproduct.MappingStatusConfirmed, Reason: "历史档案原文"}}}},
|
||||
}
|
||||
raw, _ := json.Marshal(specs)
|
||||
if err := db.Model(&models.ShopeeProduct{}).Where("id = ?", f.shopee.ID).Update("specs_json", string(raw)).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
matcher := &liveProbeMatcher{err: errors.New("AI should not be called")}
|
||||
s := testService(db)
|
||||
s.Matcher = matcher
|
||||
task, err := createLive(t, s, f)
|
||||
if err != nil || task.MappedColorSnapshot != "" || task.MappedSizeSnapshot != "" {
|
||||
t.Fatalf("live task skipped mandatory probe: %+v err=%v", task, err)
|
||||
}
|
||||
if err = db.Model(&models.ShopeeProduct{}).Where("id = ?", f.shopee.ID).Update("specs_json", `[]`).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err = s.Claim(context.Background(), task.ID, ActionRequest{RequestID: uuid.NewString()}, f.token); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
first, err := s.Start(context.Background(), task.ID, ActionRequest{RequestID: uuid.NewString()}, f.token)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
probe := ResultRequest{RequestID: uuid.NewString(), TaskAttemptID: first.TaskAttemptID, ResultType: "spec_probe_completed", ProbedSpecs: []byte(`{"dimensions":[{"key":"color","values":["白色","黄色","黑色"]},{"key":"size","values":["4XL","5XL"]}]}`)}
|
||||
resolved, err := s.SubmitResult(context.Background(), task.ID, probe, f.token)
|
||||
if err != nil || matcher.calls != 0 || resolved.Status != models.PurchaseTaskStatusPending {
|
||||
t.Fatalf("confirmed mapping resolution failed: %+v calls=%d err=%v", resolved, matcher.calls, err)
|
||||
}
|
||||
if resolved.MappedColor != "黄色" || resolved.MappedSize != "5XL" {
|
||||
t.Fatalf("task did not freeze current probe labels: %+v", resolved)
|
||||
}
|
||||
var saved models.PurchaseTask
|
||||
if err = db.First(&saved, task.ID).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var decision struct {
|
||||
RoleSources map[string]string `json:"roleSources"`
|
||||
}
|
||||
if err = json.Unmarshal([]byte(saved.SpecDecisionSnapshot), &decision); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if saved.SpecSource != "manual_mapping" || decision.RoleSources[shopeeproduct.RoleColor] != "manual_mapping" || decision.RoleSources[shopeeproduct.RoleSize] != aimatching.SourceExact {
|
||||
t.Fatalf("mixed role sources were not audited: task=%+v decision=%+v", saved, decision)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLiveProbeCallsAIOnlyForRoleWithoutCurrentConfirmedMapping(t *testing.T) {
|
||||
db := testDB(t)
|
||||
f := seed(t, db, liveCaps(), true)
|
||||
if err := db.Model(&models.SYBProduct{}).Where("id = ?", f.syb.ID).Update("target_color", "薑黃色").Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
specs := []shopeeproduct.SpecDimension{
|
||||
{Name: "颜色", Role: shopeeproduct.RoleColor, Values: []shopeeproduct.SpecValue{{Name: "薑黃色", Source: shopeeproduct.ValueSourceImport, Mapping: &shopeeproduct.Mapping{PDDValue: "旧黄色", Source: shopeeproduct.MappingSourceManual, Status: shopeeproduct.MappingStatusConfirmed}}}},
|
||||
{Name: "尺码", Role: shopeeproduct.RoleSize, Values: []shopeeproduct.SpecValue{{Name: "XL", Source: shopeeproduct.ValueSourceImport, Mapping: &shopeeproduct.Mapping{PDDValue: "XL", Source: shopeeproduct.MappingSourceManual, Status: shopeeproduct.MappingStatusConfirmed}}}},
|
||||
}
|
||||
raw, _ := json.Marshal(specs)
|
||||
if err := db.Model(&models.ShopeeProduct{}).Where("id = ?", f.shopee.ID).Update("specs_json", string(raw)).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
aiRequest := aimatching.MatchRequest{TargetColor: "薑黃色", Colors: []string{"黄色"}}
|
||||
matcher := &liveProbeMatcher{result: aimatching.RecordedMatch(aiRequest, aimatching.SourceAI, "黄色", "", "当前颜色候选匹配")}
|
||||
s := testService(db)
|
||||
s.Matcher = matcher
|
||||
task, err := createLive(t, s, f)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err = s.Claim(context.Background(), task.ID, ActionRequest{RequestID: uuid.NewString()}, f.token); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
first, err := s.Start(context.Background(), task.ID, ActionRequest{RequestID: uuid.NewString()}, f.token)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
probe := ResultRequest{RequestID: uuid.NewString(), TaskAttemptID: first.TaskAttemptID, ResultType: "spec_probe_completed", ProbedSpecs: []byte(`{"dimensions":[{"key":"color","values":["黄色"]},{"key":"size","values":["XL"]}]}`)}
|
||||
resolved, err := s.SubmitResult(context.Background(), task.ID, probe, f.token)
|
||||
if err != nil || matcher.calls != 1 || resolved.MappedColor != "黄色" || resolved.MappedSize != "XL" {
|
||||
t.Fatalf("partial AI resolution failed: %+v request=%+v err=%v", resolved, matcher.request, err)
|
||||
}
|
||||
if matcher.request.TargetColor != "薑黃色" || matcher.request.TargetSize != "" || len(matcher.request.Colors) != 1 || len(matcher.request.Sizes) != 0 {
|
||||
t.Fatalf("resolved size was unnecessarily sent to AI: %+v", matcher.request)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLiveProbeRejectsMatcherValueOutsideCurrentCandidates(t *testing.T) {
|
||||
db := testDB(t)
|
||||
f := seed(t, db, liveCaps(), false)
|
||||
if err := db.Model(&models.SYBProduct{}).Where("id = ?", f.syb.ID).Update("target_color", "象牙黑").Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
request := aimatching.MatchRequest{TargetColor: "象牙黑", Colors: []string{"黑色"}}
|
||||
matcher := &liveProbeMatcher{result: aimatching.RecordedMatch(request, aimatching.SourceAI, "候选外颜色", "XL", "无效返回")}
|
||||
s := testService(db)
|
||||
s.Matcher = matcher
|
||||
task, err := createLive(t, s, f)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err = s.Claim(context.Background(), task.ID, ActionRequest{RequestID: uuid.NewString()}, f.token); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
first, err := s.Start(context.Background(), task.ID, ActionRequest{RequestID: uuid.NewString()}, f.token)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
probe := ResultRequest{RequestID: uuid.NewString(), TaskAttemptID: first.TaskAttemptID, ResultType: "spec_probe_completed", ProbedSpecs: []byte(`{"dimensions":[{"key":"color","values":["黑色"]},{"key":"size","values":["XL"]}]}`)}
|
||||
resolved, err := s.SubmitResult(context.Background(), task.ID, probe, f.token)
|
||||
if err != nil || resolved.Status != models.PurchaseTaskStatusFailed || resolved.MappedColor != "" || resolved.MappedSize != "" {
|
||||
t.Fatalf("out-of-candidate result was accepted: %+v err=%v", resolved, err)
|
||||
}
|
||||
var saved models.PurchaseTask
|
||||
if err = db.First(&saved, task.ID).Error; err != nil || saved.ErrorCode == nil || *saved.ErrorCode != "PURCHASE_SPEC_NOT_MATCHED" {
|
||||
t.Fatalf("outside-candidate failure was not persisted: %+v err=%v", saved, err)
|
||||
}
|
||||
if saved.ErrorMessage == nil || *saved.ErrorMessage != "已采集到当前规格,但未能确定颜色或尺码映射" {
|
||||
t.Fatalf("outside-candidate failure reason was not preserved: %+v", saved)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLiveProbeUsesAccurateMessageWhenCompleteCandidatesCannotBeMatched(t *testing.T) {
|
||||
db := testDB(t)
|
||||
f := seed(t, db, liveCaps(), false)
|
||||
if err := db.Model(&models.SYBProduct{}).Where("id = ?", f.syb.ID).Update("target_color", "薑黃色").Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s := testService(db)
|
||||
s.Matcher = &liveProbeMatcher{err: &aimatching.Error{Code: aimatching.CodeNoMatch, Message: "no match"}}
|
||||
task, err := createLive(t, s, f)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err = s.Claim(context.Background(), task.ID, ActionRequest{RequestID: uuid.NewString()}, f.token); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
first, err := s.Start(context.Background(), task.ID, ActionRequest{RequestID: uuid.NewString()}, f.token)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
probe := ResultRequest{RequestID: uuid.NewString(), TaskAttemptID: first.TaskAttemptID, ResultType: "spec_probe_completed", ProbedSpecs: []byte(`{"dimensions":[{"key":"color","values":["黄色"]},{"key":"size","values":["XL"]}]}`)}
|
||||
resolved, err := s.SubmitResult(context.Background(), task.ID, probe, f.token)
|
||||
if err != nil || resolved.Status != models.PurchaseTaskStatusFailed {
|
||||
t.Fatalf("no-match probe did not converge: %+v err=%v", resolved, err)
|
||||
}
|
||||
var saved models.PurchaseTask
|
||||
if err = db.First(&saved, task.ID).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if saved.ErrorMessage == nil || *saved.ErrorMessage != "已采集到当前规格,但未能确定颜色或尺码映射" {
|
||||
t.Fatalf("complete candidates used misleading failure reason: %+v", saved)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSecondSpecProbeFailsClosedWithoutClearingDecision(t *testing.T) {
|
||||
db := testDB(t)
|
||||
f := seed(t, db, liveCaps(), false)
|
||||
@@ -428,7 +672,7 @@ func TestSpecResolutionAllowedMatrix(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateUsesDeterministicTraditionalAndWeightMatching(t *testing.T) {
|
||||
func TestLiveProbeUsesDeterministicTraditionalAndWeightMatching(t *testing.T) {
|
||||
db := testDB(t)
|
||||
f := seed(t, db, liveCaps(), false)
|
||||
if err := db.Model(&models.PDDProduct{}).Where("id = ?", f.pdd.ID).Update("specs_json", `[{"name":"颜色","role":"color","values":[{"name":"浅蓝色","selectable":true,"priceCent":2000}]},{"name":"尺码","role":"size","values":[{"name":"4XL 160-170斤","selectable":true}]}]`).Error; err != nil {
|
||||
@@ -437,12 +681,26 @@ func TestCreateUsesDeterministicTraditionalAndWeightMatching(t *testing.T) {
|
||||
if err := db.Model(&models.SYBProduct{}).Where("id = ?", f.syb.ID).Updates(map[string]any{"target_color": "淺藍色", "target_size": "4XL 80-85公斤"}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
task, err := createLive(t, testService(db), f)
|
||||
if err != nil || task.SpecSource != "exact_match" || task.MappedColorSnapshot != "浅蓝色" || task.MappedSizeSnapshot != "4XL 160-170斤" {
|
||||
t.Fatalf("deterministic match failed: %+v %v", task, err)
|
||||
s := testService(db)
|
||||
task, err := createLive(t, s, f)
|
||||
if err != nil || task.SpecSource != "unresolved" || task.MappedColorSnapshot != "" || task.MappedSizeSnapshot != "" {
|
||||
t.Fatalf("live task skipped probe: %+v %v", task, err)
|
||||
}
|
||||
if strings.Contains(task.SpecDecisionSnapshot, "apiKey") || !strings.Contains(task.SpecDecisionSnapshot, "exact_match") {
|
||||
t.Fatalf("task decision snapshot is unsafe or incomplete: %s", task.SpecDecisionSnapshot)
|
||||
if _, err = s.Claim(context.Background(), task.ID, ActionRequest{RequestID: uuid.NewString()}, f.token); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
first, err := s.Start(context.Background(), task.ID, ActionRequest{RequestID: uuid.NewString()}, f.token)
|
||||
if err != nil || first.Phase != models.PurchaseAttemptPhaseSpecProbe {
|
||||
t.Fatalf("probe start: %+v %v", first, err)
|
||||
}
|
||||
probe := ResultRequest{RequestID: uuid.NewString(), TaskAttemptID: first.TaskAttemptID, ResultType: "spec_probe_completed", ProbedSpecs: []byte(`{"dimensions":[{"key":"color","values":["浅蓝色"]},{"key":"size","values":["4XL 160-170斤"]}]}`)}
|
||||
resolved, err := s.SubmitResult(context.Background(), task.ID, probe, f.token)
|
||||
if err != nil || resolved.MappedColor != "浅蓝色" || resolved.MappedSize != "4XL 160-170斤" {
|
||||
t.Fatalf("deterministic probe match failed: %+v %v", resolved, err)
|
||||
}
|
||||
var saved models.PurchaseTask
|
||||
if err = db.First(&saved, task.ID).Error; err != nil || strings.Contains(saved.SpecDecisionSnapshot, "apiKey") || !strings.Contains(saved.SpecDecisionSnapshot, "exact_match") {
|
||||
t.Fatalf("task decision snapshot is unsafe or incomplete: %s err=%v", saved.SpecDecisionSnapshot, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -457,12 +715,12 @@ func TestCreateDoesNotTrustMappedLabelsFromRequest(t *testing.T) {
|
||||
SYBProductID: &f.syb.ID, DeviceID: &f.device.ID, MappedColor: "白色", MappedSize: "XL",
|
||||
MinUnitPriceCent: 400, MaxUnitPriceCent: 3000, RuleSnapshot: liveRule(true),
|
||||
})
|
||||
if err != nil || task.SpecSource != "exact_match" || task.MappedColorSnapshot != "黑色" || task.MappedSizeSnapshot != "XL" {
|
||||
t.Fatalf("request-provided mapping must not override server match: %+v %v", task, err)
|
||||
if err != nil || task.SpecSource != "unresolved" || task.MappedColorSnapshot != "" || task.MappedSizeSnapshot != "" {
|
||||
t.Fatalf("request-provided mapping must not skip live probe: %+v %v", task, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateQueuesUsableArchiveWhenDeterministicMatchFails(t *testing.T) {
|
||||
func TestCreateDoesNotQueueArchiveMatchingBeforeLiveProbe(t *testing.T) {
|
||||
db := testDB(t)
|
||||
f := seed(t, db, liveCaps(), false)
|
||||
if err := db.Model(&models.PDDProduct{}).Where("id = ?", f.pdd.ID).Update("specs_json", `[{"name":"颜色","role":"color","values":[{"name":"白色","selectable":true,"priceCent":2000}]},{"name":"尺码","role":"size","values":[{"name":"L","selectable":true}]}]`).Error; err != nil {
|
||||
@@ -475,12 +733,12 @@ func TestCreateQueuesUsableArchiveWhenDeterministicMatchFails(t *testing.T) {
|
||||
t.Fatalf("task=%+v err=%v", task, err)
|
||||
}
|
||||
var count int64
|
||||
if err := db.Model(&models.PurchaseSpecMatchWorkItem{}).Where("purchase_task_id = ?", task.ID).Count(&count).Error; err != nil || count != 1 {
|
||||
if err := db.Model(&models.PurchaseSpecMatchWorkItem{}).Where("purchase_task_id = ?", task.ID).Count(&count).Error; err != nil || count != 0 {
|
||||
t.Fatalf("work count=%d err=%v", count, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateQueuesExternalMatchingWithoutCallingProvider(t *testing.T) {
|
||||
func TestCreateDispatchesProbeInsteadOfExternalArchiveMatching(t *testing.T) {
|
||||
db := testDB(t)
|
||||
f := seed(t, db, liveCaps(), false)
|
||||
if err := db.Model(&models.PDDProduct{}).Where("id = ?", f.pdd.ID).Update("specs_json", `[{"name":"颜色","role":"color","values":[{"name":"白色","selectable":true,"priceCent":2000},{"name":"米白色","selectable":true,"priceCent":2000}]},{"name":"尺码","role":"size","values":[{"name":"XL","selectable":true}]}]`).Error; err != nil {
|
||||
@@ -495,12 +753,48 @@ func TestCreateQueuesExternalMatchingWithoutCallingProvider(t *testing.T) {
|
||||
if err != nil || task.ID == 0 || task.SpecSource != "unresolved" {
|
||||
t.Fatalf("queued task=%+v err=%v", task, err)
|
||||
}
|
||||
var work models.PurchaseSpecMatchWorkItem
|
||||
if err := db.Where("purchase_task_id = ?", task.ID).First(&work).Error; err != nil || work.Status != models.PurchaseMatchPending || work.InputFingerprint == "" {
|
||||
t.Fatalf("work=%+v err=%v", work, err)
|
||||
var count int64
|
||||
if err := db.Model(&models.PurchaseSpecMatchWorkItem{}).Where("purchase_task_id = ?", task.ID).Count(&count).Error; err != nil || count != 0 {
|
||||
t.Fatalf("unexpected archive match work count=%d err=%v", count, err)
|
||||
}
|
||||
if next, err := service.Next(context.Background(), f.token); err != nil || next != nil {
|
||||
t.Fatalf("matching task dispatched: %+v %v", next, err)
|
||||
if next, err := service.Next(context.Background(), f.token); err != nil || next == nil || next.MappedColor != "" || next.MappedSize != "" || !next.SpecResolutionAllowed {
|
||||
t.Fatalf("probe task was not dispatchable: %+v %v", next, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNextReturnsAssignedProbeWaitingTaskBeforeOtherWork(t *testing.T) {
|
||||
db := testDB(t)
|
||||
f := seed(t, db, liveCaps(), true)
|
||||
if err := db.Model(&models.PDDProduct{}).Where("id = ?", f.pdd.ID).Update("specs_json", `[{"name":"颜色","role":"color","values":[{"name":"黑色","selectable":true,"priceCent":1200}]},{"name":"尺码","role":"size","values":[{"name":"XL","selectable":true}]}]`).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
service := testService(db)
|
||||
request := StockCreateRequest{
|
||||
RequestID: uuid.NewString(), ExecutionMode: models.PurchaseExecutionModeLive,
|
||||
PDDProductID: f.pdd.ID, DeviceID: &f.device.ID, Color: "黑色", Size: "XL",
|
||||
Quantity: 1, MinUnitPriceCent: 900, MaxUnitPriceCent: 1500,
|
||||
}
|
||||
waiting, _, err := service.CreateStock(context.Background(), request)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
request.RequestID = uuid.NewString()
|
||||
other, _, err := service.CreateStock(context.Background(), request)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = db.Session(&gorm.Session{SkipHooks: true}).Model(&models.PurchaseTask{}).Where("id = ?", waiting.ID).Updates(map[string]any{
|
||||
"status": models.PurchaseTaskStatusSpecProbePending, "mapped_color_snapshot": "", "mapped_size_snapshot": "",
|
||||
}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
next, err := service.Next(context.Background(), f.token)
|
||||
if err != nil || next == nil || next.TaskID != waiting.ID || next.Status != models.PurchaseTaskStatusSpecProbePending {
|
||||
t.Fatalf("waiting probe task not reserved: next=%+v other=%d err=%v", next, other.ID, err)
|
||||
}
|
||||
if _, err = service.Claim(context.Background(), other.ID, ActionRequest{RequestID: uuid.NewString()}, f.token); code(err) != CodeDeviceBusy {
|
||||
t.Fatalf("other purchase claimed while probe waits: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -509,9 +803,10 @@ func TestOrderUnknownIsNotAutomaticallyRedispatched(t *testing.T) {
|
||||
f := seed(t, db, liveCaps(), true)
|
||||
s := testService(db)
|
||||
task, _ := createLive(t, s, f)
|
||||
s.Claim(context.Background(), task.ID, ActionRequest{RequestID: uuid.NewString()}, f.token)
|
||||
started, _ := s.Start(context.Background(), task.ID, ActionRequest{RequestID: uuid.NewString()}, f.token)
|
||||
s.MarkOrderSubmitStarted(context.Background(), task.ID, ActionRequest{RequestID: uuid.NewString()}, f.token)
|
||||
started := startLivePurchaseAfterProbe(t, s, f, task)
|
||||
if _, err := s.MarkOrderSubmitStarted(context.Background(), task.ID, ActionRequest{RequestID: uuid.NewString()}, f.token); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
actual := int64(1999)
|
||||
if _, err := s.SubmitResult(context.Background(), task.ID, ResultRequest{
|
||||
RequestID: uuid.NewString(), TaskAttemptID: started.TaskAttemptID, ResultType: "order_result_unknown",
|
||||
@@ -527,7 +822,7 @@ func TestOrderUnknownIsNotAutomaticallyRedispatched(t *testing.T) {
|
||||
t.Fatalf("unknown task evidence not preserved: %+v", saved)
|
||||
}
|
||||
var attempt models.PurchaseTaskAttempt
|
||||
if err := db.Where("task_id = ?", task.ID).First(&attempt).Error; err != nil {
|
||||
if err := db.Where("task_id = ?", task.ID).Order("attempt_number DESC").First(&attempt).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if attempt.ErrorCode == nil || *attempt.ErrorCode != CodeOrderTimeMissing || attempt.ErrorMessage == nil {
|
||||
@@ -544,9 +839,10 @@ func TestOrderUnknownRejectsOrderFieldsAndUnapprovedFailureCode(t *testing.T) {
|
||||
f := seed(t, db, liveCaps(), true)
|
||||
s := testService(db)
|
||||
task, _ := createLive(t, s, f)
|
||||
s.Claim(context.Background(), task.ID, ActionRequest{RequestID: uuid.NewString()}, f.token)
|
||||
started, _ := s.Start(context.Background(), task.ID, ActionRequest{RequestID: uuid.NewString()}, f.token)
|
||||
s.MarkOrderSubmitStarted(context.Background(), task.ID, ActionRequest{RequestID: uuid.NewString()}, f.token)
|
||||
started := startLivePurchaseAfterProbe(t, s, f, task)
|
||||
if _, err := s.MarkOrderSubmitStarted(context.Background(), task.ID, ActionRequest{RequestID: uuid.NewString()}, f.token); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if _, err := s.SubmitResult(context.Background(), task.ID, ResultRequest{
|
||||
RequestID: uuid.NewString(), TaskAttemptID: started.TaskAttemptID, ResultType: "order_result_unknown",
|
||||
|
||||
@@ -0,0 +1,303 @@
|
||||
package shopeeproduct
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"go-admin/app/goauto/aimatching"
|
||||
"go-admin/app/goauto/models"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
type AutoMatchRequest struct {
|
||||
RequestID string `json:"requestId"`
|
||||
SpecContextVersion string `json:"specContextVersion"`
|
||||
}
|
||||
|
||||
type AutoMatchItem struct {
|
||||
Dimension string `json:"dimension"`
|
||||
Role string `json:"role"`
|
||||
ValueName string `json:"valueName"`
|
||||
PDDValue string `json:"pddValue,omitempty"`
|
||||
Status string `json:"status"` // confirmed | preserved | unmatched
|
||||
Source string `json:"source,omitempty"`
|
||||
Confidence *float64 `json:"confidence,omitempty"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
}
|
||||
|
||||
type AutoMatchResponse struct {
|
||||
Product ProductView `json:"product"`
|
||||
Items []AutoMatchItem `json:"items"`
|
||||
ConfirmedCount int `json:"confirmedCount"`
|
||||
PreservedCount int `json:"preservedCount"`
|
||||
UnmatchedCount int `json:"unmatchedCount"`
|
||||
Replayed bool `json:"replayed"`
|
||||
}
|
||||
|
||||
type autoMatchRef struct {
|
||||
dimension string
|
||||
valueName string
|
||||
}
|
||||
|
||||
// AutoMatchMappings resolves both color and size values, then persists every
|
||||
// accepted result as confirmed in one transaction. Provider calls happen
|
||||
// before the transaction; the transaction rechecks the complete spec context
|
||||
// and PDD candidate set so a stale decision can never be written.
|
||||
func (service *Service) AutoMatchMappings(ctx context.Context, id uint64, request AutoMatchRequest) (AutoMatchResponse, error) {
|
||||
requestID := strings.TrimSpace(request.RequestID)
|
||||
if _, err := uuid.Parse(requestID); err != nil {
|
||||
return AutoMatchResponse{}, invalidRequest("requestId 必须是 UUID")
|
||||
}
|
||||
requestVersion := strings.TrimSpace(request.SpecContextVersion)
|
||||
if requestVersion == "" {
|
||||
return AutoMatchResponse{}, contextVersionStale()
|
||||
}
|
||||
|
||||
db := service.DB.WithContext(ctx)
|
||||
var replay models.ShopeeProduct
|
||||
if err := db.Where("id = ? AND last_update_request_id = ?", id, requestID).First(&replay).Error; err == nil {
|
||||
return service.autoMatchReplay(ctx, id)
|
||||
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return AutoMatchResponse{}, internalError(err)
|
||||
}
|
||||
|
||||
var shopee models.ShopeeProduct
|
||||
if err := db.First(&shopee, id).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return AutoMatchResponse{}, productNotFound()
|
||||
}
|
||||
return AutoMatchResponse{}, internalError(err)
|
||||
}
|
||||
if shopee.PDDProductID == nil {
|
||||
return AutoMatchResponse{}, &ServiceError{Code: CodePDDProductNotFound, Message: "请先关联 PDD 商品"}
|
||||
}
|
||||
var pdd models.PDDProduct
|
||||
if err := db.First(&pdd, *shopee.PDDProductID).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return AutoMatchResponse{}, &ServiceError{Code: CodePDDProductNotFound, Message: "关联的 PDD 商品不存在"}
|
||||
}
|
||||
return AutoMatchResponse{}, internalError(err)
|
||||
}
|
||||
if computeSpecContextVersion(shopee.PDDProductID, shopee.SpecsJSON, pdd.SpecsJSON) != requestVersion {
|
||||
return AutoMatchResponse{}, contextVersionStale()
|
||||
}
|
||||
initialSpecs, err := Unmarshal(shopee.SpecsJSON)
|
||||
if err != nil {
|
||||
return AutoMatchResponse{}, internalError(err)
|
||||
}
|
||||
|
||||
items := make([]AutoMatchItem, 0)
|
||||
for _, role := range []string{RoleColor, RoleSize} {
|
||||
refs := autoMatchRefs(initialSpecs, role)
|
||||
if len(refs) == 0 {
|
||||
continue
|
||||
}
|
||||
preview, err := service.suggestMappings(ctx, id, requestVersion, role)
|
||||
if err != nil {
|
||||
return AutoMatchResponse{}, err
|
||||
}
|
||||
if len(preview.Items) != len(refs) {
|
||||
return AutoMatchResponse{}, internalError(errors.New("AI 匹配结果与规格输入数量不一致"))
|
||||
}
|
||||
for index, suggestion := range preview.Items {
|
||||
item := AutoMatchItem{
|
||||
Dimension: refs[index].dimension,
|
||||
Role: role,
|
||||
ValueName: refs[index].valueName,
|
||||
PDDValue: suggestion.PDDValue,
|
||||
Confidence: suggestion.Confidence,
|
||||
Reason: strings.TrimSpace(suggestion.Reason),
|
||||
Status: "unmatched",
|
||||
}
|
||||
switch suggestion.Status {
|
||||
case "preserved":
|
||||
item.Status = "preserved"
|
||||
case "matched":
|
||||
item.Status = "confirmed"
|
||||
item.Source = MappingSourceExactMatch
|
||||
case "ai_matched":
|
||||
if suggestion.Confidence != nil && item.Reason != "" {
|
||||
item.Status = "confirmed"
|
||||
item.Source = MappingSourceAIMatch
|
||||
}
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
}
|
||||
|
||||
replayed := false
|
||||
err = db.Transaction(func(tx *gorm.DB) error {
|
||||
var current models.ShopeeProduct
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(¤t, id).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return productNotFound()
|
||||
}
|
||||
return internalError(err)
|
||||
}
|
||||
if current.LastUpdateRequestID != nil && *current.LastUpdateRequestID == requestID {
|
||||
replayed = true
|
||||
return nil
|
||||
}
|
||||
if current.PDDProductID == nil || *current.PDDProductID != pdd.ID {
|
||||
return contextVersionStale()
|
||||
}
|
||||
var currentPDD models.PDDProduct
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(¤tPDD, pdd.ID).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return contextVersionStale()
|
||||
}
|
||||
return internalError(err)
|
||||
}
|
||||
if computeSpecContextVersion(current.PDDProductID, current.SpecsJSON, currentPDD.SpecsJSON) != requestVersion {
|
||||
return contextVersionStale()
|
||||
}
|
||||
|
||||
currentSpecs, err := Unmarshal(current.SpecsJSON)
|
||||
if err != nil {
|
||||
return internalError(err)
|
||||
}
|
||||
candidates := map[string]map[string]bool{}
|
||||
for _, role := range []string{RoleColor, RoleSize} {
|
||||
values, err := selectablePDDValues(currentPDD.SpecsJSON, role)
|
||||
if err != nil {
|
||||
return internalError(err)
|
||||
}
|
||||
candidates[role] = values
|
||||
}
|
||||
|
||||
needsAISettings := false
|
||||
for _, item := range items {
|
||||
if item.Status == "confirmed" && item.Source == MappingSourceAIMatch {
|
||||
needsAISettings = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if needsAISettings {
|
||||
settings, err := aimatching.NewService(tx).Settings(ctx)
|
||||
if err != nil {
|
||||
return internalError(err)
|
||||
}
|
||||
if !settings.Enabled {
|
||||
return aiUnavailable("AI 匹配未启用,请先在设置中配置并启用")
|
||||
}
|
||||
for index := range items {
|
||||
item := &items[index]
|
||||
if item.Status == "confirmed" && item.Source == MappingSourceAIMatch &&
|
||||
(item.Confidence == nil || *item.Confidence < settings.AutoConfirmMinConfidence || strings.TrimSpace(item.Reason) == "") {
|
||||
item.Status, item.Source = "unmatched", ""
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
plans := make(map[string]AutoMatchItem, len(items))
|
||||
for _, item := range items {
|
||||
plans[item.Dimension+"\x00"+item.ValueName] = item
|
||||
}
|
||||
for dimensionIndex, dimension := range currentSpecs {
|
||||
if dimension.Role != RoleColor && dimension.Role != RoleSize {
|
||||
continue
|
||||
}
|
||||
for valueIndex, value := range dimension.Values {
|
||||
item, ok := plans[dimension.Name+"\x00"+value.Name]
|
||||
if !ok || item.Status != "confirmed" {
|
||||
continue
|
||||
}
|
||||
if !candidates[dimension.Role][item.PDDValue] {
|
||||
return contextVersionStale()
|
||||
}
|
||||
currentSpecs[dimensionIndex].Values[valueIndex].Mapping = &Mapping{
|
||||
PDDValue: item.PDDValue, Source: item.Source, Status: MappingStatusConfirmed,
|
||||
Confidence: item.Confidence, Reason: item.Reason,
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := Validate(currentSpecs); err != nil {
|
||||
return invalidRequest(err.Error())
|
||||
}
|
||||
specsJSON, err := Marshal(currentSpecs)
|
||||
if err != nil {
|
||||
return internalError(err)
|
||||
}
|
||||
if err := tx.Model(&models.ShopeeProduct{}).Where("id = ?", id).Updates(map[string]any{
|
||||
"specs_json": specsJSON, "last_update_request_id": requestID,
|
||||
}).Error; err != nil {
|
||||
return internalError(err)
|
||||
}
|
||||
if err := syncReplacementItemAfterManualEdit(tx, id, currentSpecs); err != nil {
|
||||
return internalError(err)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return AutoMatchResponse{}, err
|
||||
}
|
||||
if replayed {
|
||||
return service.autoMatchReplay(ctx, id)
|
||||
}
|
||||
response, err := service.autoMatchResponse(ctx, id, items)
|
||||
if err != nil {
|
||||
return AutoMatchResponse{}, err
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
|
||||
func autoMatchRefs(specs []SpecDimension, role string) []autoMatchRef {
|
||||
refs := make([]autoMatchRef, 0)
|
||||
for _, dimension := range specs {
|
||||
if dimension.Role != role {
|
||||
continue
|
||||
}
|
||||
for _, value := range dimension.Values {
|
||||
refs = append(refs, autoMatchRef{dimension: dimension.Name, valueName: value.Name})
|
||||
}
|
||||
}
|
||||
return refs
|
||||
}
|
||||
|
||||
func (service *Service) autoMatchResponse(ctx context.Context, id uint64, items []AutoMatchItem) (AutoMatchResponse, error) {
|
||||
detail, err := service.Detail(ctx, id)
|
||||
if err != nil {
|
||||
return AutoMatchResponse{}, err
|
||||
}
|
||||
response := AutoMatchResponse{Product: detail.Product, Items: items}
|
||||
for _, item := range items {
|
||||
switch item.Status {
|
||||
case "confirmed":
|
||||
response.ConfirmedCount++
|
||||
case "preserved":
|
||||
response.PreservedCount++
|
||||
default:
|
||||
response.UnmatchedCount++
|
||||
}
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
|
||||
func (service *Service) autoMatchReplay(ctx context.Context, id uint64) (AutoMatchResponse, error) {
|
||||
detail, err := service.Detail(ctx, id)
|
||||
if err != nil {
|
||||
return AutoMatchResponse{}, err
|
||||
}
|
||||
response := AutoMatchResponse{Product: detail.Product, Items: []AutoMatchItem{}, Replayed: true}
|
||||
for _, dimension := range detail.Product.Specs {
|
||||
if dimension.Role != RoleColor && dimension.Role != RoleSize {
|
||||
continue
|
||||
}
|
||||
for _, value := range dimension.Values {
|
||||
item := AutoMatchItem{Dimension: dimension.Name, Role: dimension.Role, ValueName: value.Name, Status: "unmatched"}
|
||||
if value.Mapping != nil && value.Mapping.Status == MappingStatusConfirmed {
|
||||
item.PDDValue, item.Status, item.Source = value.Mapping.PDDValue, "preserved", value.Mapping.Source
|
||||
item.Confidence, item.Reason = value.Mapping.Confidence, value.Mapping.Reason
|
||||
response.PreservedCount++
|
||||
} else {
|
||||
response.UnmatchedCount++
|
||||
}
|
||||
response.Items = append(response.Items, item)
|
||||
}
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
package shopeeproduct
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"go-admin/app/goauto/models"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
func TestAutoMatchMappingsConfirmsColorAndSizeInOneRequest(t *testing.T) {
|
||||
provider := httptest.NewServer(chatCompletionResponder(`{"suggestions":[{"sourceId":"s1","candidateId":"c1","confidence":0.96,"reason":"颜色含义唯一一致"}]}`))
|
||||
defer provider.Close()
|
||||
|
||||
db := openTestDB(t)
|
||||
pdd := seedPDDProduct(t, db, "active")
|
||||
setting := seedEnabledAISetting(t, provider.URL, 0.9)
|
||||
if err := db.Create(&setting).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
service := NewService(db)
|
||||
created, err := service.Create(context.Background(), CreateRequest{
|
||||
RequestID: uuid.NewString(), ShopeeItemID: "SP-AUTO-BOTH", PDDProductID: &pdd.ID,
|
||||
Specs: []SpecDimension{
|
||||
{Name: "颜色", Role: RoleColor, Values: []SpecValue{{Name: "深黑", Source: ValueSourceImport}}},
|
||||
{Name: "尺码", Role: RoleSize, Values: []SpecValue{{Name: " xl ", Source: ValueSourceImport}}},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
requestID := uuid.NewString()
|
||||
response, err := service.AutoMatchMappings(context.Background(), created.Product.ID, AutoMatchRequest{RequestID: requestID, SpecContextVersion: created.Product.SpecContextVersion})
|
||||
if err != nil {
|
||||
t.Fatalf("auto match: %v", err)
|
||||
}
|
||||
if response.ConfirmedCount != 2 || response.PreservedCount != 0 || response.UnmatchedCount != 0 {
|
||||
t.Fatalf("unexpected counts: %+v", response)
|
||||
}
|
||||
color := response.Product.Specs[0].Values[0].Mapping
|
||||
size := response.Product.Specs[1].Values[0].Mapping
|
||||
if color == nil || color.Status != MappingStatusConfirmed || color.Source != MappingSourceAIMatch || color.PDDValue != "黑色" {
|
||||
t.Fatalf("color not auto-confirmed: %+v", color)
|
||||
}
|
||||
if size == nil || size.Status != MappingStatusConfirmed || size.Source != MappingSourceExactMatch || size.PDDValue != "XL" {
|
||||
t.Fatalf("size not auto-confirmed: %+v", size)
|
||||
}
|
||||
|
||||
replayed, err := service.AutoMatchMappings(context.Background(), created.Product.ID, AutoMatchRequest{RequestID: requestID, SpecContextVersion: created.Product.SpecContextVersion})
|
||||
if err != nil || !replayed.Replayed || replayed.PreservedCount != 2 {
|
||||
t.Fatalf("idempotent replay failed: response=%+v err=%v", replayed, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutoMatchMappingsLeavesLowConfidenceUnmatched(t *testing.T) {
|
||||
provider := httptest.NewServer(chatCompletionResponder(`{"suggestions":[{"sourceId":"s1","candidateId":"c1","confidence":0.4,"reason":"只能判断为相近颜色"}]}`))
|
||||
defer provider.Close()
|
||||
|
||||
db := openTestDB(t)
|
||||
pdd := seedPDDProduct(t, db, "active")
|
||||
setting := seedEnabledAISetting(t, provider.URL, 0.9)
|
||||
if err := db.Create(&setting).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
service := NewService(db)
|
||||
created, err := service.Create(context.Background(), CreateRequest{
|
||||
RequestID: uuid.NewString(), ShopeeItemID: "SP-AUTO-LOW", PDDProductID: &pdd.ID,
|
||||
Specs: []SpecDimension{
|
||||
{Name: "颜色", Role: RoleColor, Values: []SpecValue{{Name: "浅黑", Source: ValueSourceImport}}},
|
||||
{Name: "尺码", Role: RoleSize, Values: []SpecValue{{Name: "XL", Source: ValueSourceImport}}},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
response, err := service.AutoMatchMappings(context.Background(), created.Product.ID, AutoMatchRequest{RequestID: uuid.NewString(), SpecContextVersion: created.Product.SpecContextVersion})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if response.ConfirmedCount != 1 || response.UnmatchedCount != 1 || response.Product.Specs[0].Values[0].Mapping != nil {
|
||||
t.Fatalf("low confidence color was persisted: %+v", response)
|
||||
}
|
||||
if response.Product.Specs[1].Values[0].Mapping == nil || response.Product.Specs[1].Values[0].Mapping.Status != MappingStatusConfirmed {
|
||||
t.Fatalf("deterministic size was not confirmed: %+v", response.Product.Specs[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutoMatchMappingsProviderFailureDoesNotWrite(t *testing.T) {
|
||||
provider := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
http.Error(w, "unavailable", http.StatusBadGateway)
|
||||
}))
|
||||
defer provider.Close()
|
||||
|
||||
db := openTestDB(t)
|
||||
pdd := seedPDDProduct(t, db, "active")
|
||||
setting := seedEnabledAISetting(t, provider.URL, 0.9)
|
||||
if err := db.Create(&setting).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
service := NewService(db)
|
||||
created, err := service.Create(context.Background(), CreateRequest{RequestID: uuid.NewString(), ShopeeItemID: "SP-AUTO-FAIL", PDDProductID: &pdd.ID, Specs: []SpecDimension{{Name: "颜色", Role: RoleColor, Values: []SpecValue{{Name: "深黑", Source: ValueSourceImport}}}}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := service.AutoMatchMappings(context.Background(), created.Product.ID, AutoMatchRequest{RequestID: uuid.NewString(), SpecContextVersion: created.Product.SpecContextVersion}); err == nil || errCode(t, err) != CodeAIUnavailable {
|
||||
t.Fatalf("expected AI unavailable, got %v", err)
|
||||
}
|
||||
detail, err := service.Detail(context.Background(), created.Product.ID)
|
||||
if err != nil || detail.Product.Specs[0].Values[0].Mapping != nil {
|
||||
t.Fatalf("provider failure changed mappings: detail=%+v err=%v", detail, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutoMatchMappingsRejectsContextDriftBeforeAtomicWrite(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
pdd := seedPDDProduct(t, db, "active")
|
||||
var calls atomic.Int32
|
||||
provider := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
call := calls.Add(1)
|
||||
if call == 2 {
|
||||
if err := db.Model(&models.PDDProduct{}).Where("id = ?", pdd.ID).Update("specs_json", `[{"name":"颜色","role":"color","values":[{"name":"黑色","selectable":true},{"name":"灰色","selectable":true}]},{"name":"尺码","role":"size","values":[{"name":"M","selectable":true}]}]`).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"choices": []map[string]any{{"message": map[string]any{"content": `{"suggestions":[{"sourceId":"s1","candidateId":"c1","confidence":0.98,"reason":"唯一可靠候选"}]}`}}}})
|
||||
}))
|
||||
defer provider.Close()
|
||||
setting := seedEnabledAISetting(t, provider.URL, 0.9)
|
||||
if err := db.Create(&setting).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
service := NewService(db)
|
||||
created, err := service.Create(context.Background(), CreateRequest{
|
||||
RequestID: uuid.NewString(), ShopeeItemID: "SP-AUTO-STALE", PDDProductID: &pdd.ID,
|
||||
Specs: []SpecDimension{
|
||||
{Name: "颜色", Role: RoleColor, Values: []SpecValue{{Name: "深黑", Source: ValueSourceImport}}},
|
||||
{Name: "尺码", Role: RoleSize, Values: []SpecValue{{Name: "小号", Source: ValueSourceImport}}},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := service.AutoMatchMappings(context.Background(), created.Product.ID, AutoMatchRequest{RequestID: uuid.NewString(), SpecContextVersion: created.Product.SpecContextVersion}); err == nil || errCode(t, err) != CodeSpecContextStale {
|
||||
t.Fatalf("expected stale context, got %v", err)
|
||||
}
|
||||
detail, err := service.Detail(context.Background(), created.Product.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, dimension := range detail.Product.Specs {
|
||||
for _, value := range dimension.Values {
|
||||
if value.Mapping != nil {
|
||||
t.Fatalf("context drift caused partial write: %+v", detail.Product.Specs)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,8 @@ import (
|
||||
|
||||
// ResolvedMappingItem is one mapping produced by the narrowly scoped SYB
|
||||
// batch-match entry point. Confirmed writes are restricted to auditable exact
|
||||
// matches and high-confidence AI decisions; SetMapping remains pending-first.
|
||||
// matches and AI decisions with a returned match reason; SetMapping remains
|
||||
// pending-first.
|
||||
type ResolvedMappingItem struct {
|
||||
Dimension string
|
||||
ValueName string
|
||||
@@ -20,14 +21,11 @@ type ResolvedMappingItem struct {
|
||||
|
||||
// ApplyResolvedMappings atomically applies the color/size mappings needed by
|
||||
// one SYB detail row. It is independent from SetMapping so #188's explicit
|
||||
// high-confidence exception cannot change existing callers' pending semantics.
|
||||
func (service *Service) ApplyResolvedMappings(ctx context.Context, id uint64, requestID string, minimumConfidence float64, items []ResolvedMappingItem) (SaveResponse, error) {
|
||||
// confirmed-match exception cannot change existing callers' pending semantics.
|
||||
func (service *Service) ApplyResolvedMappings(ctx context.Context, id uint64, requestID string, items []ResolvedMappingItem) (SaveResponse, error) {
|
||||
if len(items) == 0 || len(items) > 2 {
|
||||
return SaveResponse{}, invalidRequest("必须包含 1 至 2 个待写入规格映射")
|
||||
}
|
||||
if minimumConfidence < 0 || minimumConfidence > 1 {
|
||||
return SaveResponse{}, invalidRequest("自动确认阈值无效")
|
||||
}
|
||||
seen := make(map[string]bool, len(items))
|
||||
for _, item := range items {
|
||||
key := strings.TrimSpace(item.Dimension) + "\x00" + strings.TrimSpace(item.ValueName)
|
||||
@@ -41,9 +39,6 @@ func (service *Service) ApplyResolvedMappings(ctx context.Context, id uint64, re
|
||||
if item.Status != MappingStatusConfirmed || strings.TrimSpace(item.Reason) == "" {
|
||||
return SaveResponse{}, invalidRequest("自动确认映射必须包含确认状态和匹配理由")
|
||||
}
|
||||
if item.Source == MappingSourceAIMatch && (item.Confidence == nil || *item.Confidence < minimumConfidence) {
|
||||
return SaveResponse{}, invalidRequest("AI 自动确认必须达到置信度阈值")
|
||||
}
|
||||
if err := service.validatePDDMappingTarget(ctx, id, item.Dimension, item.ValueName, strings.TrimSpace(item.PDDValue)); err != nil {
|
||||
return SaveResponse{}, err
|
||||
}
|
||||
|
||||
@@ -278,6 +278,28 @@ func (handler Handler) SuggestSizeMappings(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"code": 200, "data": response})
|
||||
}
|
||||
|
||||
func (handler Handler) AutoMatchMappings(c *gin.Context) {
|
||||
id, ok := handler.pathID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var request AutoMatchRequest
|
||||
if err := decodeJSON(c, &request); err != nil {
|
||||
writeError(c, invalidRequest("请求 JSON 无效"))
|
||||
return
|
||||
}
|
||||
service, ok := handler.service(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
response, err := service.AutoMatchMappings(c.Request.Context(), id, request)
|
||||
if err != nil {
|
||||
writeError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"code": 200, "data": response})
|
||||
}
|
||||
|
||||
func (handler Handler) BatchDelete(c *gin.Context) {
|
||||
var request BatchDeleteRequest
|
||||
if err := decodeJSON(c, &request); err != nil {
|
||||
|
||||
@@ -26,4 +26,5 @@ func InitRouter(engine *gin.Engine, auth *jwt.GinJWTMiddleware) {
|
||||
admin.POST("/:productId/specs/mapping/preview-auto-size", handler.PreviewAutoSizeMatches)
|
||||
admin.POST("/:productId/specs/mapping/suggest-colors", handler.SuggestColorMappings)
|
||||
admin.POST("/:productId/specs/mapping/suggest-sizes", handler.SuggestSizeMappings)
|
||||
admin.POST("/:productId/specs/mapping/auto-match", handler.AutoMatchMappings)
|
||||
}
|
||||
|
||||
@@ -85,7 +85,8 @@ func StartImport(ctx context.Context, db *gorm.DB, request ImportRequest, actor
|
||||
}
|
||||
run, err := NewSyncRunService(db).Create(ctx, CreateSyncRunInput{
|
||||
DateFrom: request.DateFrom, DateTo: request.DateTo, ShopFilterHash: enabledShopHash(enabled),
|
||||
OperatorID: actor.ID, OperatorName: strings.TrimSpace(actor.Name),
|
||||
ShopFilterSnapshot: cloneShopSnapshot(enabled),
|
||||
OperatorID: actor.ID, OperatorName: strings.TrimSpace(actor.Name),
|
||||
})
|
||||
if err != nil {
|
||||
importGate.Unlock()
|
||||
@@ -97,7 +98,7 @@ func StartImport(ctx context.Context, db *gorm.DB, request ImportRequest, actor
|
||||
importGate.running = true
|
||||
importGate.Unlock()
|
||||
|
||||
go runImport(db, run.ID, request, settings)
|
||||
go runImport(db, run.ID, request, settings, cloneShopSnapshot(enabled))
|
||||
return StartImportResult{RunID: run.ID, Status: run.Status}, nil
|
||||
}
|
||||
|
||||
@@ -145,7 +146,7 @@ func (handler Handler) Import(c *gin.Context) {
|
||||
c.JSON(http.StatusAccepted, gin.H{"code": 200, "data": gin.H{"runId": result.RunID, "status": result.Status}})
|
||||
}
|
||||
|
||||
func runImport(db *gorm.DB, runID uint64, request ImportRequest, settings config.SYB) {
|
||||
func runImport(db *gorm.DB, runID uint64, request ImportRequest, settings config.SYB, allowed map[string]string) {
|
||||
defer func() { importGate.Lock(); importGate.running = false; importGate.Unlock() }()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), importTimeout)
|
||||
defer cancel()
|
||||
@@ -156,7 +157,7 @@ func runImport(db *gorm.DB, runID uint64, request ImportRequest, settings config
|
||||
OcrURL: settings.OcrURL, OcrMaxAttempts: settings.OcrMaxAttempts,
|
||||
})
|
||||
if err == nil {
|
||||
report, err = SyncWithProgress(ctx, db, client, SyncConfig{PageSize: settings.PageSize, MaxMatches: settings.MaxMatches}, request.DateFrom, request.DateTo,
|
||||
report, err = SyncWithShopSnapshot(ctx, db, client, SyncConfig{PageSize: settings.PageSize, MaxMatches: settings.MaxMatches}, request.DateFrom, request.DateTo, allowed,
|
||||
func(progress SyncProgress) error { return runs.UpdateProgress(ctx, runID, progress) })
|
||||
}
|
||||
status := SyncRunSucceeded
|
||||
@@ -168,6 +169,14 @@ func runImport(db *gorm.DB, runID uint64, request ImportRequest, settings config
|
||||
_ = runs.Finish(finishCtx, runID, status, report, err)
|
||||
}
|
||||
|
||||
func cloneShopSnapshot(source map[string]string) map[string]string {
|
||||
cloned := make(map[string]string, len(source))
|
||||
for normalized, display := range source {
|
||||
cloned[normalized] = display
|
||||
}
|
||||
return cloned
|
||||
}
|
||||
|
||||
func claimUint64(value any) uint64 {
|
||||
switch typed := value.(type) {
|
||||
case float64:
|
||||
|
||||
@@ -13,6 +13,13 @@ import (
|
||||
|
||||
var bracketPattern = regexp.MustCompile(`【[^】]*】`)
|
||||
|
||||
// explicitSizePattern recognizes only values whose spelling carries a strong
|
||||
// size signal. SYB has now been observed returning both "color,size" and
|
||||
// "size,color". A color dictionary would inevitably guess at product-specific
|
||||
// labels, so role reversal is allowed only when exactly one side matches this
|
||||
// deliberately narrow pattern.
|
||||
var explicitSizePattern = regexp.MustCompile(`(?i)^(?:均(?:码|碼|号|號)|one\s*size|free\s*size|x{0,4}[sml]|[2-9]xl|(?:加大|大|中|小)(?:码|碼|号|號)|\d+(?:\.\d+)?(?:cm|mm|m|码|碼|号|號|公分)|\d+(?:\.\d+)?(?:[-~~至到]\d+(?:\.\d+)?)?(?:斤|公斤|千克|kg))$`)
|
||||
|
||||
// ambiguousPattern flags leftover separators or multi-token noise after
|
||||
// bracket stripping — the signal that a "clean" split still isn't reliable.
|
||||
// Observed in the real SYB sample: "黑色+白色【純棉兩件裝】 簡約親膚" strips
|
||||
@@ -29,8 +36,8 @@ type ParseResult struct {
|
||||
Note string
|
||||
}
|
||||
|
||||
// Parse implements the #41 rule: split on the last comma into color/size,
|
||||
// strip 【...】 annotations, and classify the result. It never guesses a
|
||||
// Parse implements the #41/#216 rule: split on the last comma, strip 【...】
|
||||
// annotations, and classify the result. It never guesses a
|
||||
// missing value and never invents a color or size that is not literally
|
||||
// present in the input.
|
||||
//
|
||||
@@ -41,9 +48,13 @@ type ParseResult struct {
|
||||
// brackets stripped, becomes the size candidate; color stays empty. A
|
||||
// single token with no separator cannot be split into two dimensions
|
||||
// without guessing which one it is.
|
||||
// - comma present, last segment (size) and first segment(s) (color) both
|
||||
// non-empty after stripping brackets, and the color candidate has no
|
||||
// leftover separator/whitespace -> success.
|
||||
// - comma present and exactly one side has an explicit size signal -> that
|
||||
// side is size and the other side is color. This supports both observed
|
||||
// SYB orders without allowing AI or a fuzzy color dictionary to swap roles.
|
||||
// - comma present and neither side has an explicit size signal -> retain the
|
||||
// established SYB color,size contract for backward compatibility.
|
||||
// - comma present and both sides have explicit size signals -> uncertain;
|
||||
// there is no safe color decision.
|
||||
// - comma present but either side is empty after stripping, or the color
|
||||
// candidate still carries a leftover '+' or internal whitespace -> the
|
||||
// split happened but is not trustworthy -> uncertain.
|
||||
@@ -65,12 +76,20 @@ func Parse(raw string) ParseResult {
|
||||
return ParseResult{Size: size, Status: models.SYBParseStatusUncertain, Note: "无分隔符,仅识别出单一规格值"}
|
||||
}
|
||||
|
||||
colorPart := stripBrackets(trimmed[:lastComma])
|
||||
sizePart := stripBrackets(trimmed[lastComma+1:])
|
||||
firstPart := stripBrackets(trimmed[:lastComma])
|
||||
secondPart := stripBrackets(trimmed[lastComma+1:])
|
||||
colorPart, sizePart := firstPart, secondPart
|
||||
|
||||
if colorPart == "" || sizePart == "" {
|
||||
return ParseResult{Color: colorPart, Size: sizePart, Status: models.SYBParseStatusUncertain, Note: "按逗号拆分后有一侧为空"}
|
||||
}
|
||||
firstIsSize, secondIsSize := explicitSizePattern.MatchString(firstPart), explicitSizePattern.MatchString(secondPart)
|
||||
if firstIsSize && secondIsSize {
|
||||
return ParseResult{Color: firstPart, Size: secondPart, Status: models.SYBParseStatusUncertain, Note: "逗号两侧均具有尺码特征,无法安全识别颜色"}
|
||||
}
|
||||
if firstIsSize {
|
||||
colorPart, sizePart = secondPart, firstPart
|
||||
}
|
||||
if ambiguousPattern.MatchString(colorPart) {
|
||||
return ParseResult{Color: colorPart, Size: sizePart, Status: models.SYBParseStatusUncertain, Note: "颜色部分含备注文本或多个分隔符,拆分结果可能不准确"}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,27 @@ func TestParseRealSample_CleanColorAndSize(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseReversedSizeAndColorWhenOnlyFirstHasExplicitSizeEvidence(t *testing.T) {
|
||||
result := sybimport.Parse("均碼,黑色")
|
||||
if result.Color != "黑色" || result.Size != "均碼" || result.Status != models.SYBParseStatusSuccess {
|
||||
t.Fatalf("expected reversed role parse 黑色/均碼 success, got %+v", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseKeepsTraditionalColorAndSizeOrderWhenSecondHasSizeEvidence(t *testing.T) {
|
||||
result := sybimport.Parse("黑色,均码")
|
||||
if result.Color != "黑色" || result.Size != "均码" || result.Status != models.SYBParseStatusSuccess {
|
||||
t.Fatalf("expected normal role parse 黑色/均码 success, got %+v", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseTwoExplicitSizesIsUncertainInsteadOfSwapping(t *testing.T) {
|
||||
result := sybimport.Parse("M,L")
|
||||
if result.Status != models.SYBParseStatusUncertain || result.Color != "M" || result.Size != "L" {
|
||||
t.Fatalf("expected conflicting size evidence to stay uncertain, got %+v", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseRealSample_AmbiguousColorWithAnnotationAndSeparator(t *testing.T) {
|
||||
result := sybimport.Parse("黑色+白色【純棉兩件裝】 簡約親膚,L【建議52.5-60公斤】")
|
||||
if result.Status != models.SYBParseStatusUncertain {
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
package sybimport
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"sort"
|
||||
|
||||
"go-admin/app/goauto/models"
|
||||
"go-admin/app/goauto/shopeeproduct"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// SpecRoleRepairReport contains only aggregate migration evidence. It does not
|
||||
// expose order numbers, raw source payloads, or specification text.
|
||||
type SpecRoleRepairReport struct {
|
||||
SYBRows int
|
||||
ShopeeProducts int
|
||||
}
|
||||
|
||||
type specRoleChange struct {
|
||||
OldColor string
|
||||
OldSize string
|
||||
}
|
||||
|
||||
// RepairReversedSpecRoles corrects only the historical shape that #216 can
|
||||
// prove was role-reversed: the newly parsed color/size are exactly the old
|
||||
// size/color. Human-confirmed rows are never overwritten. RawJSON is read for
|
||||
// reparsing and deliberately excluded from every update.
|
||||
func RepairReversedSpecRoles(ctx context.Context, db *gorm.DB) (SpecRoleRepairReport, error) {
|
||||
report := SpecRoleRepairReport{}
|
||||
err := db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
var records []models.SYBProduct
|
||||
if err := tx.Where("manually_confirmed = ?", false).Find(&records).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
changesByProduct := map[uint64][]specRoleChange{}
|
||||
for _, record := range records {
|
||||
var raw rawDetailSpec
|
||||
if json.Unmarshal([]byte(record.RawJSON), &raw) != nil {
|
||||
continue
|
||||
}
|
||||
parsed := Parse(raw.ProductSpec)
|
||||
if parsed.Status == models.SYBParseStatusFailed || parsed.Color == record.TargetColor ||
|
||||
parsed.Color != record.TargetSize || parsed.Size != record.TargetColor {
|
||||
continue
|
||||
}
|
||||
if err := tx.Model(&models.SYBProduct{}).Where("id = ?", record.ID).Updates(map[string]any{
|
||||
"target_color": parsed.Color,
|
||||
"target_size": parsed.Size,
|
||||
"parse_status": parsed.Status,
|
||||
"parse_note": parsed.Note,
|
||||
}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
report.SYBRows++
|
||||
if record.ShopeeProductID != nil {
|
||||
productID := *record.ShopeeProductID
|
||||
changesByProduct[productID] = append(changesByProduct[productID], specRoleChange{
|
||||
OldColor: record.TargetColor,
|
||||
OldSize: record.TargetSize,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
for productID, changes := range changesByProduct {
|
||||
changed, err := reconcileRepairedShopeeSpecs(tx, productID, changes)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if changed {
|
||||
report.ShopeeProducts++
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return report, err
|
||||
}
|
||||
|
||||
func reconcileRepairedShopeeSpecs(tx *gorm.DB, productID uint64, changes []specRoleChange) (bool, error) {
|
||||
var product models.ShopeeProduct
|
||||
if err := tx.First(&product, productID).Error; err != nil {
|
||||
return false, err
|
||||
}
|
||||
existing, err := shopeeproduct.Unmarshal(product.SpecsJSON)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
var rows []models.SYBProduct
|
||||
if err := tx.Where("shopee_product_id = ? AND (parse_status = ? OR manually_confirmed = ?)",
|
||||
productID, models.SYBParseStatusSuccess, true).Find(&rows).Error; err != nil {
|
||||
return false, err
|
||||
}
|
||||
usedColors, usedSizes := map[string]bool{}, map[string]bool{}
|
||||
for _, row := range rows {
|
||||
if row.TargetColor != "" {
|
||||
usedColors[row.TargetColor] = true
|
||||
}
|
||||
if row.TargetSize != "" {
|
||||
usedSizes[row.TargetSize] = true
|
||||
}
|
||||
}
|
||||
oldColors, oldSizes := map[string]bool{}, map[string]bool{}
|
||||
for _, change := range changes {
|
||||
oldColors[change.OldColor] = true
|
||||
oldSizes[change.OldSize] = true
|
||||
}
|
||||
|
||||
cleaned := make([]shopeeproduct.SpecDimension, 0, len(existing))
|
||||
for _, dimension := range existing {
|
||||
values := make([]shopeeproduct.SpecValue, 0, len(dimension.Values))
|
||||
for _, value := range dimension.Values {
|
||||
remove := value.Source == shopeeproduct.ValueSourceImport && value.Mapping == nil
|
||||
switch dimension.Role {
|
||||
case shopeeproduct.RoleColor:
|
||||
remove = remove && oldColors[value.Name] && !usedColors[value.Name]
|
||||
case shopeeproduct.RoleSize:
|
||||
remove = remove && oldSizes[value.Name] && !usedSizes[value.Name]
|
||||
default:
|
||||
remove = false
|
||||
}
|
||||
if !remove {
|
||||
values = append(values, value)
|
||||
}
|
||||
}
|
||||
dimension.Values = values
|
||||
cleaned = append(cleaned, dimension)
|
||||
}
|
||||
|
||||
incoming := make([]shopeeproduct.SpecDimension, 0, 2)
|
||||
if values := sortedImportValues(usedColors); len(values) > 0 {
|
||||
incoming = append(incoming, shopeeproduct.SpecDimension{Name: "颜色", Role: shopeeproduct.RoleColor, Values: values})
|
||||
}
|
||||
if values := sortedImportValues(usedSizes); len(values) > 0 {
|
||||
incoming = append(incoming, shopeeproduct.SpecDimension{Name: "尺码", Role: shopeeproduct.RoleSize, Values: values})
|
||||
}
|
||||
reconciled := shopeeproduct.Merge(cleaned, incoming)
|
||||
if err := shopeeproduct.Validate(reconciled); err != nil {
|
||||
return false, err
|
||||
}
|
||||
encoded, err := shopeeproduct.Marshal(reconciled)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if encoded == product.SpecsJSON {
|
||||
return false, nil
|
||||
}
|
||||
return true, tx.Model(&models.ShopeeProduct{}).Where("id = ?", productID).Update("specs_json", encoded).Error
|
||||
}
|
||||
|
||||
func sortedImportValues(set map[string]bool) []shopeeproduct.SpecValue {
|
||||
names := make([]string, 0, len(set))
|
||||
for name := range set {
|
||||
names = append(names, name)
|
||||
}
|
||||
sort.Strings(names)
|
||||
values := make([]shopeeproduct.SpecValue, 0, len(names))
|
||||
for _, name := range names {
|
||||
values = append(values, shopeeproduct.SpecValue{Name: name, Source: shopeeproduct.ValueSourceImport})
|
||||
}
|
||||
return values
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
package sybimport
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"go-admin/app/goauto/models"
|
||||
"go-admin/app/goauto/shopeeproduct"
|
||||
)
|
||||
|
||||
func TestRepairReversedSpecRolesIsSafeAndIdempotent(t *testing.T) {
|
||||
db := newSyncTestDB(t)
|
||||
manualMapping := &shopeeproduct.Mapping{PDDValue: "藏青", Source: shopeeproduct.MappingSourceManual, Status: shopeeproduct.MappingStatusConfirmed}
|
||||
specs, err := shopeeproduct.Marshal([]shopeeproduct.SpecDimension{
|
||||
{Name: "颜色", Role: shopeeproduct.RoleColor, Values: []shopeeproduct.SpecValue{
|
||||
{Name: "均碼", Source: shopeeproduct.ValueSourceImport},
|
||||
{Name: "手工蓝", Source: shopeeproduct.ValueSourceManual, Mapping: manualMapping},
|
||||
}},
|
||||
{Name: "尺码", Role: shopeeproduct.RoleSize, Values: []shopeeproduct.SpecValue{{Name: "黑色", Source: shopeeproduct.ValueSourceImport}}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
product := models.ShopeeProduct{ShopeeItemID: "ROLE-REPAIR", Title: "测试", ShopName: "测试店铺", Currency: "TWD", SpecsJSON: specs}
|
||||
if err := db.Create(&product).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw := `{"productSpec":"均碼,黑色"}`
|
||||
row := models.SYBProduct{OrderCode: "ROLE-ORDER", DetailID: 1, StockID: 1, ShopeeItemID: product.ShopeeItemID,
|
||||
ShopeeProductID: &product.ID, ProductTitle: "测试", TargetColor: "均碼", TargetSize: "黑色", Quantity: 1,
|
||||
ParseStatus: models.SYBParseStatusSuccess, RawJSON: raw}
|
||||
if err := db.Create(&row).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
manual := models.SYBProduct{OrderCode: "MANUAL-ORDER", DetailID: 2, StockID: 2, ShopeeItemID: product.ShopeeItemID,
|
||||
ShopeeProductID: &product.ID, ProductTitle: "测试", TargetColor: "人工颜色", TargetSize: "人工尺码", Quantity: 1,
|
||||
ParseStatus: models.SYBParseStatusSuccess, ManuallyConfirmed: true, RawJSON: raw}
|
||||
if err := db.Create(&manual).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
report, err := RepairReversedSpecRoles(context.Background(), db)
|
||||
if err != nil || report.SYBRows != 1 || report.ShopeeProducts != 1 {
|
||||
t.Fatalf("unexpected repair report=%+v err=%v", report, err)
|
||||
}
|
||||
var repaired, untouched models.SYBProduct
|
||||
db.First(&repaired, row.ID)
|
||||
db.First(&untouched, manual.ID)
|
||||
if repaired.TargetColor != "黑色" || repaired.TargetSize != "均碼" || repaired.RawJSON != raw {
|
||||
t.Fatalf("repaired row is invalid: %+v", repaired)
|
||||
}
|
||||
if untouched.TargetColor != "人工颜色" || untouched.TargetSize != "人工尺码" {
|
||||
t.Fatalf("manual row was overwritten: %+v", untouched)
|
||||
}
|
||||
|
||||
db.First(&product, product.ID)
|
||||
reconciled, err := shopeeproduct.Unmarshal(product.SpecsJSON)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assertSpecValue(t, reconciled, shopeeproduct.RoleColor, "均碼", false)
|
||||
assertSpecValue(t, reconciled, shopeeproduct.RoleSize, "黑色", false)
|
||||
assertSpecValue(t, reconciled, shopeeproduct.RoleColor, "黑色", true)
|
||||
assertSpecValue(t, reconciled, shopeeproduct.RoleSize, "均碼", true)
|
||||
assertSpecValue(t, reconciled, shopeeproduct.RoleColor, "手工蓝", true)
|
||||
|
||||
replay, err := RepairReversedSpecRoles(context.Background(), db)
|
||||
if err != nil || replay.SYBRows != 0 || replay.ShopeeProducts != 0 {
|
||||
t.Fatalf("repair was not idempotent: report=%+v err=%v", replay, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepairReversedSpecRolesKeepsAmbiguousColorUncertain(t *testing.T) {
|
||||
db := newSyncTestDB(t)
|
||||
specs, err := shopeeproduct.Marshal([]shopeeproduct.SpecDimension{
|
||||
{Name: "颜色", Role: shopeeproduct.RoleColor, Values: []shopeeproduct.SpecValue{{Name: "均碼", Source: shopeeproduct.ValueSourceImport}}},
|
||||
{Name: "尺码", Role: shopeeproduct.RoleSize, Values: []shopeeproduct.SpecValue{{Name: "黑色+白色", Source: shopeeproduct.ValueSourceImport}}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
product := models.ShopeeProduct{ShopeeItemID: "ROLE-UNCERTAIN", Title: "测试", ShopName: "测试店铺", Currency: "TWD", SpecsJSON: specs}
|
||||
if err := db.Create(&product).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
row := models.SYBProduct{OrderCode: "UNCERTAIN-ORDER", DetailID: 1, StockID: 1, ShopeeItemID: product.ShopeeItemID,
|
||||
ShopeeProductID: &product.ID, ProductTitle: "测试", TargetColor: "均碼", TargetSize: "黑色+白色", Quantity: 1,
|
||||
ParseStatus: models.SYBParseStatusSuccess, RawJSON: `{"productSpec":"均碼,黑色+白色"}`}
|
||||
if err := db.Create(&row).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
report, err := RepairReversedSpecRoles(context.Background(), db)
|
||||
if err != nil || report.SYBRows != 1 || report.ShopeeProducts != 1 {
|
||||
t.Fatalf("unexpected repair report=%+v err=%v", report, err)
|
||||
}
|
||||
db.First(&row, row.ID)
|
||||
if row.TargetColor != "黑色+白色" || row.TargetSize != "均碼" || row.ParseStatus != models.SYBParseStatusUncertain {
|
||||
t.Fatalf("ambiguous color role/status was not preserved safely: %+v", row)
|
||||
}
|
||||
db.First(&product, product.ID)
|
||||
reconciled, err := shopeeproduct.Unmarshal(product.SpecsJSON)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assertSpecValue(t, reconciled, shopeeproduct.RoleColor, "均碼", false)
|
||||
assertSpecValue(t, reconciled, shopeeproduct.RoleSize, "黑色+白色", false)
|
||||
}
|
||||
|
||||
func assertSpecValue(t *testing.T, specs []shopeeproduct.SpecDimension, role, name string, want bool) {
|
||||
t.Helper()
|
||||
found := false
|
||||
for _, dimension := range specs {
|
||||
if dimension.Role != role {
|
||||
continue
|
||||
}
|
||||
for _, value := range dimension.Values {
|
||||
if value.Name == name {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if found != want {
|
||||
t.Fatalf("role=%s value=%q found=%v want=%v specs=%+v", role, name, found, want, specs)
|
||||
}
|
||||
}
|
||||
@@ -59,12 +59,16 @@ type SyncReport struct {
|
||||
// it a renamed or newly opened shop is never noticed (#50).
|
||||
ShopBreakdown map[string]ShopBreakdown `json:"shopBreakdown"`
|
||||
ShopFilterHash string `json:"shopFilterHash"`
|
||||
OrderCount int `json:"orderCount"`
|
||||
DetailCount int `json:"detailCount"`
|
||||
Created int `json:"created"`
|
||||
Updated int `json:"updated"`
|
||||
StartedAt time.Time `json:"startedAt"`
|
||||
FinishedAt time.Time `json:"finishedAt"`
|
||||
// ShopFilterSnapshot is the immutable enabled-shop snapshot supplied when
|
||||
// the run was created. It is intentionally a display-name list for audit;
|
||||
// matching continues to use the normalized map passed to the sync.
|
||||
ShopFilterSnapshot []string `json:"shopFilterSnapshot"`
|
||||
OrderCount int `json:"orderCount"`
|
||||
DetailCount int `json:"detailCount"`
|
||||
Created int `json:"created"`
|
||||
Updated int `json:"updated"`
|
||||
StartedAt time.Time `json:"startedAt"`
|
||||
FinishedAt time.Time `json:"finishedAt"`
|
||||
}
|
||||
|
||||
type ShopBreakdown struct {
|
||||
@@ -120,19 +124,24 @@ func Sync(ctx context.Context, db *gorm.DB, client *sybclient.Client, cfg SyncCo
|
||||
}
|
||||
|
||||
func SyncWithProgress(ctx context.Context, db *gorm.DB, client *sybclient.Client, cfg SyncConfig, dateFrom, dateTo string, progress ProgressFunc) (SyncReport, error) {
|
||||
allowed, err := sybshop.EnabledNames(ctx, db)
|
||||
if err != nil {
|
||||
return SyncReport{From: dateFrom, To: dateTo, StartedAt: time.Now().UTC(), ShopBreakdown: map[string]ShopBreakdown{}}, err
|
||||
}
|
||||
return SyncWithShopSnapshot(ctx, db, client, cfg, dateFrom, dateTo, allowed, progress)
|
||||
}
|
||||
|
||||
// SyncWithShopSnapshot runs against the immutable enabled-shop snapshot taken
|
||||
// by StartImport. It must not query syb_shop again: doing so would make the
|
||||
// run's effective allow-list differ from the snapshot recorded on its row.
|
||||
func SyncWithShopSnapshot(ctx context.Context, db *gorm.DB, client *sybclient.Client, cfg SyncConfig, dateFrom, dateTo string, allowed map[string]string, progress ProgressFunc) (SyncReport, error) {
|
||||
report := SyncReport{From: dateFrom, To: dateTo, StartedAt: time.Now().UTC(),
|
||||
ShopBreakdown: map[string]ShopBreakdown{}}
|
||||
ShopBreakdown: map[string]ShopBreakdown{}, ShopFilterSnapshot: shopSnapshotNames(allowed)}
|
||||
|
||||
dates, err := splitDateRange(dateFrom, dateTo)
|
||||
if err != nil {
|
||||
return report, err
|
||||
}
|
||||
// `[必须]` The allow-list is read once and used for the whole run, so a shop
|
||||
// enabled midway cannot make one day behave differently from the next.
|
||||
allowed, err := sybshop.EnabledNames(ctx, db)
|
||||
if err != nil {
|
||||
return report, err
|
||||
}
|
||||
if len(allowed) == 0 {
|
||||
return report, ErrNoEnabledShop
|
||||
}
|
||||
@@ -283,6 +292,15 @@ func SyncWithProgress(ctx context.Context, db *gorm.DB, client *sybclient.Client
|
||||
return report, nil
|
||||
}
|
||||
|
||||
func shopSnapshotNames(allowed map[string]string) []string {
|
||||
names := make([]string, 0, len(allowed))
|
||||
for _, display := range allowed {
|
||||
names = append(names, display)
|
||||
}
|
||||
sort.Strings(names)
|
||||
return names
|
||||
}
|
||||
|
||||
func enabledShopHash(allowed map[string]string) string {
|
||||
names := make([]string, 0, len(allowed))
|
||||
for normalized := range allowed {
|
||||
|
||||
@@ -28,6 +28,7 @@ func NewSyncRunService(db *gorm.DB) *SyncRunService { return &SyncRunService{db:
|
||||
|
||||
type CreateSyncRunInput struct {
|
||||
DateFrom, DateTo, ShopFilterHash string
|
||||
ShopFilterSnapshot map[string]string
|
||||
OperatorID uint64
|
||||
OperatorName string
|
||||
}
|
||||
@@ -38,10 +39,14 @@ func (s *SyncRunService) Create(ctx context.Context, input CreateSyncRunInput) (
|
||||
return nil, invalidRequest(err.Error())
|
||||
}
|
||||
slot := uint8(1)
|
||||
payload, err := marshalSyncRunPayload(nil, shopSnapshotNames(input.ShopFilterSnapshot))
|
||||
if err != nil {
|
||||
return nil, &ServiceError{Code: CodeSyncRunCreateFailed, Message: "服务端处理失败", Cause: err}
|
||||
}
|
||||
run := &models.SYBSyncRun{
|
||||
DateFrom: input.DateFrom, DateTo: input.DateTo, Status: SyncRunRunning,
|
||||
ActiveSlot: &slot, DaysTotal: len(dates), ShopFilterHash: input.ShopFilterHash,
|
||||
ShopBreakdownJSON: "{}", OperatorID: input.OperatorID,
|
||||
ShopBreakdownJSON: payload, OperatorID: input.OperatorID,
|
||||
OperatorName: strings.TrimSpace(input.OperatorName), StartedAt: time.Now().UTC(),
|
||||
}
|
||||
if err := s.db.WithContext(ctx).Create(run).Error; err != nil {
|
||||
@@ -55,7 +60,7 @@ func (s *SyncRunService) Create(ctx context.Context, input CreateSyncRunInput) (
|
||||
}
|
||||
|
||||
func (s *SyncRunService) UpdateProgress(ctx context.Context, id uint64, progress SyncProgress) error {
|
||||
breakdown, err := json.Marshal(progress.Report.ShopBreakdown)
|
||||
payload, err := marshalSyncRunPayload(progress.Report.ShopBreakdown, progress.Report.ShopFilterSnapshot)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -65,7 +70,7 @@ func (s *SyncRunService) UpdateProgress(ctx context.Context, id uint64, progress
|
||||
"order_count": progress.Report.OrderCount, "detail_count": progress.Report.DetailCount,
|
||||
"accepted_count": progress.Report.AcceptedCount, "shop_skipped": progress.Report.ShopSkipped,
|
||||
"created_count": progress.Report.Created, "updated_count": progress.Report.Updated,
|
||||
"shop_filter_hash": progress.Report.ShopFilterHash, "shop_breakdown_json": string(breakdown),
|
||||
"shop_filter_hash": progress.Report.ShopFilterHash, "shop_breakdown_json": payload,
|
||||
}).Error
|
||||
}
|
||||
|
||||
@@ -73,7 +78,7 @@ func (s *SyncRunService) Finish(ctx context.Context, id uint64, status string, r
|
||||
if status != SyncRunSucceeded && status != SyncRunFailed {
|
||||
return fmt.Errorf("invalid terminal sync status %q", status)
|
||||
}
|
||||
breakdown, err := json.Marshal(report.ShopBreakdown)
|
||||
payload, err := marshalSyncRunPayload(report.ShopBreakdown, report.ShopFilterSnapshot)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -87,7 +92,7 @@ func (s *SyncRunService) Finish(ctx context.Context, id uint64, status string, r
|
||||
"order_count": report.OrderCount, "detail_count": report.DetailCount,
|
||||
"accepted_count": report.AcceptedCount, "shop_skipped": report.ShopSkipped,
|
||||
"created_count": report.Created, "updated_count": report.Updated,
|
||||
"shop_breakdown_json": string(breakdown),
|
||||
"shop_breakdown_json": payload,
|
||||
}
|
||||
if report.ShopFilterHash != "" {
|
||||
updates["shop_filter_hash"] = report.ShopFilterHash
|
||||
@@ -146,8 +151,10 @@ type ShopBreakdownItem struct {
|
||||
}
|
||||
type SyncRunDTO struct {
|
||||
models.SYBSyncRun
|
||||
ShopBreakdown []ShopBreakdownItem `json:"shopBreakdown"`
|
||||
ProgressPercent int `json:"progressPercent"`
|
||||
ShopBreakdown []ShopBreakdownItem `json:"shopBreakdown"`
|
||||
ShopFilterSnapshot []string `json:"shopFilterSnapshot"`
|
||||
ShopFilterSnapshotPresent bool `json:"shopFilterSnapshotPresent"`
|
||||
ProgressPercent int `json:"progressPercent"`
|
||||
}
|
||||
|
||||
func (s *SyncRunService) List(ctx context.Context, request SyncRunListRequest) (SyncRunListResponse, error) {
|
||||
@@ -188,8 +195,7 @@ func (s *SyncRunService) Detail(ctx context.Context, id uint64) (SyncRunDTO, err
|
||||
}
|
||||
|
||||
func syncRunDTO(row models.SYBSyncRun) SyncRunDTO {
|
||||
var values map[string]ShopBreakdown
|
||||
_ = json.Unmarshal([]byte(row.ShopBreakdownJSON), &values)
|
||||
values, snapshot, snapshotPresent := parseSyncRunPayload(row.ShopBreakdownJSON)
|
||||
names := make([]string, 0, len(values))
|
||||
for name := range values {
|
||||
names = append(names, name)
|
||||
@@ -210,7 +216,48 @@ func syncRunDTO(row models.SYBSyncRun) SyncRunDTO {
|
||||
if row.Status == SyncRunSucceeded {
|
||||
percent = 100
|
||||
}
|
||||
return SyncRunDTO{SYBSyncRun: row, ShopBreakdown: items, ProgressPercent: percent}
|
||||
return SyncRunDTO{SYBSyncRun: row, ShopBreakdown: items, ShopFilterSnapshot: snapshot,
|
||||
ShopFilterSnapshotPresent: snapshotPresent, ProgressPercent: percent}
|
||||
}
|
||||
|
||||
const syncRunPayloadVersion = 1
|
||||
|
||||
type syncRunPayload struct {
|
||||
Version int `json:"version"`
|
||||
ShopBreakdown map[string]ShopBreakdown `json:"shopBreakdown"`
|
||||
ShopFilterSnapshot []string `json:"shopFilterSnapshot"`
|
||||
}
|
||||
|
||||
func marshalSyncRunPayload(breakdown map[string]ShopBreakdown, snapshot []string) (string, error) {
|
||||
if breakdown == nil {
|
||||
breakdown = map[string]ShopBreakdown{}
|
||||
}
|
||||
if snapshot == nil {
|
||||
snapshot = []string{}
|
||||
}
|
||||
encoded, err := json.Marshal(syncRunPayload{
|
||||
Version: syncRunPayloadVersion, ShopBreakdown: breakdown, ShopFilterSnapshot: snapshot,
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(encoded), nil
|
||||
}
|
||||
|
||||
func parseSyncRunPayload(raw string) (map[string]ShopBreakdown, []string, bool) {
|
||||
var envelope syncRunPayload
|
||||
if err := json.Unmarshal([]byte(raw), &envelope); err == nil && envelope.Version == syncRunPayloadVersion {
|
||||
if envelope.ShopBreakdown == nil {
|
||||
envelope.ShopBreakdown = map[string]ShopBreakdown{}
|
||||
}
|
||||
sort.Strings(envelope.ShopFilterSnapshot)
|
||||
return envelope.ShopBreakdown, envelope.ShopFilterSnapshot, true
|
||||
}
|
||||
// Records written before #212 are a bare breakdown map. Preserve their
|
||||
// existing statistics and state explicitly that no immutable snapshot exists.
|
||||
values := map[string]ShopBreakdown{}
|
||||
_ = json.Unmarshal([]byte(raw), &values)
|
||||
return values, nil, false
|
||||
}
|
||||
|
||||
func truncateRunError(value string, limit int) string {
|
||||
|
||||
@@ -4,6 +4,8 @@ import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"go-admin/app/goauto/models"
|
||||
)
|
||||
|
||||
func TestSyncRunSingleFlightAndTerminalRelease(t *testing.T) {
|
||||
@@ -57,3 +59,32 @@ func TestSyncRunProgressDetailAndRecovery(t *testing.T) {
|
||||
t.Fatalf("启动恢复状态不正确: %+v", detail)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncRunPersistsImmutableShopSnapshotAndReadsLegacyBreakdown(t *testing.T) {
|
||||
db := newSyncTestDB(t)
|
||||
service := NewSyncRunService(db)
|
||||
run, err := service.Create(context.Background(), CreateSyncRunInput{
|
||||
DateFrom: "2026-08-01", DateTo: "2026-08-01", ShopFilterHash: "snapshot-hash",
|
||||
ShopFilterSnapshot: map[string]string{"alpha": "Alpha 店", "beta": "Beta 店"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
detail, err := service.Detail(context.Background(), run.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !detail.ShopFilterSnapshotPresent || strings.Join(detail.ShopFilterSnapshot, ",") != "Alpha 店,Beta 店" {
|
||||
t.Fatalf("新任务必须返回冻结快照: %+v", detail)
|
||||
}
|
||||
if err := db.Model(&models.SYBSyncRun{}).Where("id = ?", run.ID).Update("shop_breakdown_json", `{"旧店":{"accepted":2,"skipped":1}}`).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
legacy, err := service.Detail(context.Background(), run.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if legacy.ShopFilterSnapshotPresent || len(legacy.ShopFilterSnapshot) != 0 || len(legacy.ShopBreakdown) != 1 || legacy.ShopBreakdown[0].Accepted != 2 {
|
||||
t.Fatalf("旧记录必须保留统计且明确没有快照: %+v", legacy)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -429,6 +429,26 @@ func TestSyncMatchesShopNamesThroughNormalization(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncWithShopSnapshotDoesNotRereadChangedShopState(t *testing.T) {
|
||||
db := newSyncTestDB(t)
|
||||
allowed, err := sybshop.EnabledNames(context.Background(), db)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Model(&models.SYBShop{}).Where("normalized_name = ?", "测试店铺").Update("enabled", false).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f := &fakeSYB{perDay: map[string]int{"2026-08-01": 2}, shopNames: []string{"测试店铺"}}
|
||||
report, err := SyncWithShopSnapshot(context.Background(), db, newSyncClient(t, f),
|
||||
SyncConfig{PageSize: 10, MaxMatches: 1000}, "2026-08-01", "2026-08-01", allowed, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("冻结快照同步失败: %v", err)
|
||||
}
|
||||
if report.AcceptedCount != 2 || len(report.ShopFilterSnapshot) != 1 || report.ShopFilterSnapshot[0] != "测试店铺" {
|
||||
t.Fatalf("后台不得重读已变化的店铺状态: %+v", report)
|
||||
}
|
||||
}
|
||||
|
||||
// 列表说属于启用店铺、明细却是另一家店时必须拦住。
|
||||
func TestSyncRechecksShopOnDetailResponse(t *testing.T) {
|
||||
db := newSyncTestDB(t)
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
package sybshop
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"go-admin/app/goauto/models"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// ReconcileNormalizedNames repairs the matching keys of live SYB shops created
|
||||
// before Normalize became the single write-path rule. It changes neither the
|
||||
// display name nor the enabled state. The operation is safe to repeat.
|
||||
//
|
||||
// A normalised-name collision is a data ambiguity. It must be resolved by an
|
||||
// operator, rather than silently merging shops or choosing one arbitrarily.
|
||||
func ReconcileNormalizedNames(ctx context.Context, db *gorm.DB) (int, error) {
|
||||
if db == nil {
|
||||
return 0, fmt.Errorf("syb shop normalized-name repair: database is nil")
|
||||
}
|
||||
|
||||
updated := 0
|
||||
err := db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
var shops []models.SYBShop
|
||||
if err := tx.Order("id ASC").Find(&shops).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
desiredOwners := make(map[string]uint64, len(shops))
|
||||
currentKeys := make(map[string]struct{}, len(shops))
|
||||
type repair struct {
|
||||
id uint64
|
||||
desired string
|
||||
temp string
|
||||
}
|
||||
repairs := make([]repair, 0)
|
||||
for _, shop := range shops {
|
||||
desired := Normalize(shop.DisplayName)
|
||||
if desired == "" {
|
||||
return fmt.Errorf("syb shop normalized-name repair: shop %d has blank display name", shop.ID)
|
||||
}
|
||||
if owner, exists := desiredOwners[desired]; exists && owner != shop.ID {
|
||||
return fmt.Errorf("syb shop normalized-name repair: normalized key conflict between shops %d and %d", owner, shop.ID)
|
||||
}
|
||||
desiredOwners[desired] = shop.ID
|
||||
currentKeys[shop.NormalizedName] = struct{}{}
|
||||
if shop.NormalizedName != desired {
|
||||
repairs = append(repairs, repair{id: shop.ID, desired: desired})
|
||||
}
|
||||
}
|
||||
|
||||
// Keys can be swapped (for example stale "a"/"b" values). Stage every
|
||||
// repair through a unique temporary key so the unique index is preserved
|
||||
// throughout the transaction.
|
||||
reserved := make(map[string]struct{}, len(currentKeys)+len(desiredOwners))
|
||||
for key := range currentKeys {
|
||||
reserved[key] = struct{}{}
|
||||
}
|
||||
for key := range desiredOwners {
|
||||
reserved[key] = struct{}{}
|
||||
}
|
||||
for index := range repairs {
|
||||
base := fmt.Sprintf("__goauto_syb_shop_normalize_repair_%d__", repairs[index].id)
|
||||
temp := base
|
||||
for suffix := 1; ; suffix++ {
|
||||
if _, exists := reserved[temp]; !exists {
|
||||
break
|
||||
}
|
||||
temp = fmt.Sprintf("%s%d", base, suffix)
|
||||
}
|
||||
repairs[index].temp = temp
|
||||
reserved[temp] = struct{}{}
|
||||
}
|
||||
for _, repair := range repairs {
|
||||
if err := tx.Model(&models.SYBShop{}).Where("id = ?", repair.id).
|
||||
UpdateColumn("normalized_name", repair.temp).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
for _, repair := range repairs {
|
||||
if err := tx.Model(&models.SYBShop{}).Where("id = ?", repair.id).
|
||||
UpdateColumn("normalized_name", repair.desired).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
updated = len(repairs)
|
||||
return nil
|
||||
})
|
||||
return updated, err
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package sybshop_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"go-admin/app/goauto/models"
|
||||
"go-admin/app/goauto/sybshop"
|
||||
)
|
||||
|
||||
func TestReconcileNormalizedNamesRepairsLegacyKey(t *testing.T) {
|
||||
db := openDB(t)
|
||||
shop := models.SYBShop{DisplayName: "樂齡樂活美學館|銀髮居家熟齡悅己壯世代優雅生活", NormalizedName: "legacy-key", Enabled: true}
|
||||
if err := db.Create(&shop).Error; err != nil {
|
||||
t.Fatalf("创建历史店铺失败: %v", err)
|
||||
}
|
||||
|
||||
updated, err := sybshop.ReconcileNormalizedNames(context.Background(), db)
|
||||
if err != nil {
|
||||
t.Fatalf("回填失败: %v", err)
|
||||
}
|
||||
if updated != 1 {
|
||||
t.Fatalf("应修复 1 条,实际 %d", updated)
|
||||
}
|
||||
var reloaded models.SYBShop
|
||||
if err := db.First(&reloaded, shop.ID).Error; err != nil {
|
||||
t.Fatalf("读取修复结果失败: %v", err)
|
||||
}
|
||||
if reloaded.NormalizedName != sybshop.Normalize(shop.DisplayName) {
|
||||
t.Fatalf("规范化键不正确: %q", reloaded.NormalizedName)
|
||||
}
|
||||
if reloaded.DisplayName != shop.DisplayName || !reloaded.Enabled {
|
||||
t.Fatalf("回填不应修改展示名或启用状态: %+v", reloaded)
|
||||
}
|
||||
names, err := sybshop.EnabledNames(context.Background(), db)
|
||||
if err != nil || names[sybshop.Normalize(shop.DisplayName)] != shop.DisplayName {
|
||||
t.Fatalf("修复后启用快照未命中: names=%v err=%v", names, err)
|
||||
}
|
||||
|
||||
updated, err = sybshop.ReconcileNormalizedNames(context.Background(), db)
|
||||
if err != nil || updated != 0 {
|
||||
t.Fatalf("重复回填应无变化: updated=%d err=%v", updated, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReconcileNormalizedNamesRejectsCollisionWithoutWriting(t *testing.T) {
|
||||
db := openDB(t)
|
||||
first := models.SYBShop{DisplayName: "A店", NormalizedName: "legacy-one", Enabled: true}
|
||||
second := models.SYBShop{DisplayName: "A店", NormalizedName: "legacy-two", Enabled: true}
|
||||
if err := db.Create(&first).Error; err != nil {
|
||||
t.Fatalf("创建第一条历史店铺失败: %v", err)
|
||||
}
|
||||
if err := db.Create(&second).Error; err != nil {
|
||||
t.Fatalf("创建第二条历史店铺失败: %v", err)
|
||||
}
|
||||
|
||||
updated, err := sybshop.ReconcileNormalizedNames(context.Background(), db)
|
||||
if updated != 0 || err == nil || !strings.Contains(err.Error(), "normalized key conflict") {
|
||||
t.Fatalf("冲突应失败且不报告更新: updated=%d err=%v", updated, err)
|
||||
}
|
||||
var rows []models.SYBShop
|
||||
if err := db.Order("id ASC").Find(&rows).Error; err != nil {
|
||||
t.Fatalf("读取冲突结果失败: %v", err)
|
||||
}
|
||||
if len(rows) != 2 || rows[0].NormalizedName != "legacy-one" || rows[1].NormalizedName != "legacy-two" {
|
||||
t.Fatalf("冲突时不应修改任何键: %+v", rows)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReconcileNormalizedNamesHandlesSwappedLegacyKeys(t *testing.T) {
|
||||
db := openDB(t)
|
||||
first := models.SYBShop{DisplayName: "A", NormalizedName: "b", Enabled: true}
|
||||
second := models.SYBShop{DisplayName: "B", NormalizedName: "a", Enabled: true}
|
||||
if err := db.Create(&first).Error; err != nil {
|
||||
t.Fatalf("创建第一条历史店铺失败: %v", err)
|
||||
}
|
||||
if err := db.Create(&second).Error; err != nil {
|
||||
t.Fatalf("创建第二条历史店铺失败: %v", err)
|
||||
}
|
||||
|
||||
updated, err := sybshop.ReconcileNormalizedNames(context.Background(), db)
|
||||
if err != nil || updated != 2 {
|
||||
t.Fatalf("交换键回填失败: updated=%d err=%v", updated, err)
|
||||
}
|
||||
var rows []models.SYBShop
|
||||
if err := db.Order("id ASC").Find(&rows).Error; err != nil {
|
||||
t.Fatalf("读取回填结果失败: %v", err)
|
||||
}
|
||||
if rows[0].NormalizedName != "a" || rows[1].NormalizedName != "b" {
|
||||
t.Fatalf("交换键未正确回填: %+v", rows)
|
||||
}
|
||||
}
|
||||
@@ -182,9 +182,9 @@ func ensureDeviceIdleForCurrentPage(tx *gorm.DB, deviceID uint64, now time.Time)
|
||||
return serviceError(CodeDeviceBusy, "设备正在执行任务,请稍后再试")
|
||||
}
|
||||
if err := tx.Model(&models.PurchaseTask{}).
|
||||
Where("device_id = ? AND (status IN ? OR (status IN ? AND lease_expires_at > ?))", deviceID,
|
||||
[]string{models.PurchaseTaskStatusRunning, models.PurchaseTaskStatusOrderSubmitStarted},
|
||||
[]string{models.PurchaseTaskStatusPending, models.PurchaseTaskStatusSpecProbePending}, now).
|
||||
Where("device_id = ? AND (status IN ? OR (status = ? AND lease_expires_at > ?))", deviceID,
|
||||
[]string{models.PurchaseTaskStatusRunning, models.PurchaseTaskStatusOrderSubmitStarted, models.PurchaseTaskStatusSpecProbePending},
|
||||
models.PurchaseTaskStatusPending, now).
|
||||
Count(&busy).Error; err != nil {
|
||||
return internalError(err)
|
||||
}
|
||||
|
||||
@@ -246,9 +246,9 @@ func ensureDeviceIdleForReset(tx *gorm.DB, deviceID, taskID uint64, now time.Tim
|
||||
return serviceError(CodeDeviceBusy, "设备正在执行其他任务")
|
||||
}
|
||||
if err := tx.Model(&models.PurchaseTask{}).
|
||||
Where("device_id = ? AND (status IN ? OR (status IN ? AND lease_expires_at > ?))", deviceID,
|
||||
[]string{models.PurchaseTaskStatusRunning, models.PurchaseTaskStatusOrderSubmitStarted},
|
||||
[]string{models.PurchaseTaskStatusPending, models.PurchaseTaskStatusSpecProbePending}, now).
|
||||
Where("device_id = ? AND (status IN ? OR (status = ? AND lease_expires_at > ?))", deviceID,
|
||||
[]string{models.PurchaseTaskStatusRunning, models.PurchaseTaskStatusOrderSubmitStarted, models.PurchaseTaskStatusSpecProbePending},
|
||||
models.PurchaseTaskStatusPending, now).
|
||||
Count(&busy).Error; err != nil {
|
||||
return internalError(err)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
package version_local
|
||||
|
||||
import (
|
||||
"runtime"
|
||||
|
||||
goautomigrations "go-admin/app/goauto/migrations"
|
||||
"go-admin/cmd/migrate/migration"
|
||||
common "go-admin/common/models"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func init() {
|
||||
_, fileName, _, _ := runtime.Caller(0)
|
||||
migration.Migrate.SetVersion(migration.GetFilename(fileName), migrateDeviceIdentityRecovery)
|
||||
}
|
||||
|
||||
func migrateDeviceIdentityRecovery(db *gorm.DB, version string) error {
|
||||
return db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := goautomigrations.Migrate(tx); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Create(&common.Migration{Version: version}).Error
|
||||
})
|
||||
}
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
package version_local
|
||||
|
||||
import (
|
||||
"context"
|
||||
"runtime"
|
||||
|
||||
goautomigrations "go-admin/app/goauto/migrations"
|
||||
"go-admin/app/goauto/sybshop"
|
||||
"go-admin/cmd/migrate/migration"
|
||||
common "go-admin/common/models"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// This migration repairs historical syb_shop.normalized_name values written
|
||||
// before all shop-name write paths used sybshop.Normalize (#213).
|
||||
func init() {
|
||||
_, fileName, _, _ := runtime.Caller(0)
|
||||
migration.Migrate.SetVersion(migration.GetFilename(fileName), migrateSYBShopNormalizedNameBackfill)
|
||||
}
|
||||
|
||||
func migrateSYBShopNormalizedNameBackfill(db *gorm.DB, version string) error {
|
||||
return db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := goautomigrations.Migrate(tx); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := sybshop.ReconcileNormalizedNames(context.Background(), tx); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Create(&common.Migration{Version: version}).Error
|
||||
})
|
||||
}
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
package version_local
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
goautomigrations "go-admin/app/goauto/migrations"
|
||||
"go-admin/app/goauto/models"
|
||||
common "go-admin/common/models"
|
||||
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
)
|
||||
|
||||
func TestMigrateSYBShopNormalizedNameBackfillRepairsLegacyRows(t *testing.T) {
|
||||
db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{
|
||||
Logger: logger.Default.LogMode(logger.Silent),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("打开测试数据库失败: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(&common.Migration{}); err != nil {
|
||||
t.Fatalf("创建迁移记录表失败: %v", err)
|
||||
}
|
||||
if err := goautomigrations.Migrate(db); err != nil {
|
||||
t.Fatalf("创建 GoAuto 表失败: %v", err)
|
||||
}
|
||||
shop := models.SYBShop{DisplayName: "ABC店", NormalizedName: "ABC店", Enabled: true}
|
||||
if err := db.Create(&shop).Error; err != nil {
|
||||
t.Fatalf("创建历史店铺失败: %v", err)
|
||||
}
|
||||
|
||||
const version = "1787983900000"
|
||||
if err := migrateSYBShopNormalizedNameBackfill(db, version); err != nil {
|
||||
t.Fatalf("执行回填迁移失败: %v", err)
|
||||
}
|
||||
var reloaded models.SYBShop
|
||||
if err := db.First(&reloaded, shop.ID).Error; err != nil {
|
||||
t.Fatalf("读取回填结果失败: %v", err)
|
||||
}
|
||||
if reloaded.NormalizedName != "abc店" {
|
||||
t.Fatalf("迁移未回填规范化键: %q", reloaded.NormalizedName)
|
||||
}
|
||||
var records int64
|
||||
if err := db.Model(&common.Migration{}).Where("version = ?", version).Count(&records).Error; err != nil {
|
||||
t.Fatalf("读取迁移记录失败: %v", err)
|
||||
}
|
||||
if records != 1 {
|
||||
t.Fatalf("迁移应留下一个版本记录,实际 %d", records)
|
||||
}
|
||||
}
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
package version_local
|
||||
|
||||
import (
|
||||
"context"
|
||||
"runtime"
|
||||
|
||||
goautomigrations "go-admin/app/goauto/migrations"
|
||||
"go-admin/app/goauto/sybimport"
|
||||
"go-admin/cmd/migrate/migration"
|
||||
common "go-admin/common/models"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// This migration repairs SYB rows whose two specification roles were stored
|
||||
// in reverse before #216 recognized both observed source orders. The domain
|
||||
// repair also removes only now-unreferenced, unmapped import values from the
|
||||
// corresponding Shopee archive; human facts and raw source JSON are retained.
|
||||
func init() {
|
||||
_, fileName, _, _ := runtime.Caller(0)
|
||||
migration.Migrate.SetVersion(migration.GetFilename(fileName), migrateSYBSpecRoleOrderRepair)
|
||||
}
|
||||
|
||||
func migrateSYBSpecRoleOrderRepair(db *gorm.DB, version string) error {
|
||||
return db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := goautomigrations.Migrate(tx); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := sybimport.RepairReversedSpecRoles(context.Background(), tx); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Create(&common.Migration{Version: version}).Error
|
||||
})
|
||||
}
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
package version_local
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
goautomigrations "go-admin/app/goauto/migrations"
|
||||
"go-admin/app/goauto/models"
|
||||
"go-admin/app/goauto/shopeeproduct"
|
||||
common "go-admin/common/models"
|
||||
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
)
|
||||
|
||||
func TestMigrateSYBSpecRoleOrderRepair(t *testing.T) {
|
||||
db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.AutoMigrate(&common.Migration{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := goautomigrations.Migrate(db); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
specs, err := shopeeproduct.Marshal([]shopeeproduct.SpecDimension{
|
||||
{Name: "颜色", Role: shopeeproduct.RoleColor, Values: []shopeeproduct.SpecValue{{Name: "均碼", Source: shopeeproduct.ValueSourceImport}}},
|
||||
{Name: "尺码", Role: shopeeproduct.RoleSize, Values: []shopeeproduct.SpecValue{{Name: "黑色", Source: shopeeproduct.ValueSourceImport}}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
product := models.ShopeeProduct{ShopeeItemID: "MIGRATION-ROLE", Title: "测试", ShopName: "测试店铺", Currency: "TWD", SpecsJSON: specs}
|
||||
if err := db.Create(&product).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
row := models.SYBProduct{OrderCode: "MIGRATION-ORDER", DetailID: 1, StockID: 1, ShopeeItemID: product.ShopeeItemID,
|
||||
ShopeeProductID: &product.ID, ProductTitle: "测试", TargetColor: "均碼", TargetSize: "黑色", Quantity: 1,
|
||||
ParseStatus: models.SYBParseStatusSuccess, RawJSON: `{"productSpec":"均碼,黑色"}`}
|
||||
if err := db.Create(&row).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
const version = "1787984000000"
|
||||
if err := migrateSYBSpecRoleOrderRepair(db, version); err != nil {
|
||||
t.Fatalf("执行规格角色修复迁移失败: %v", err)
|
||||
}
|
||||
if err := db.First(&row, row.ID).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if row.TargetColor != "黑色" || row.TargetSize != "均碼" {
|
||||
t.Fatalf("迁移未修正规格角色: %q/%q", row.TargetColor, row.TargetSize)
|
||||
}
|
||||
var records int64
|
||||
if err := db.Model(&common.Migration{}).Where("version = ?", version).Count(&records).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if records != 1 {
|
||||
t.Fatalf("迁移应记录一次版本,实际 %d", records)
|
||||
}
|
||||
}
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
package version_local
|
||||
|
||||
import (
|
||||
"context"
|
||||
"runtime"
|
||||
|
||||
"go-admin/app/goauto/sybimport"
|
||||
"go-admin/cmd/migrate/migration"
|
||||
common "go-admin/common/models"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// The first #216 local migration was exercised against live data before this
|
||||
// follow-up case was known. Rerunning the idempotent repair lets already-
|
||||
// migrated installations correct proven role reversals whose color remains
|
||||
// uncertain, while fresh installations simply execute an empty second pass.
|
||||
func init() {
|
||||
_, fileName, _, _ := runtime.Caller(0)
|
||||
migration.Migrate.SetVersion(migration.GetFilename(fileName), migrateSYBSpecRoleOrderUncertainRepair)
|
||||
}
|
||||
|
||||
func migrateSYBSpecRoleOrderUncertainRepair(db *gorm.DB, version string) error {
|
||||
return db.Transaction(func(tx *gorm.DB) error {
|
||||
if _, err := sybimport.RepairReversedSpecRoles(context.Background(), tx); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Create(&common.Migration{Version: version}).Error
|
||||
})
|
||||
}
|
||||
@@ -21,3 +21,10 @@ export function revokeDeviceToken(deviceId) {
|
||||
method: 'post'
|
||||
})
|
||||
}
|
||||
|
||||
export function resetDeviceIdentity(deviceId) {
|
||||
return request({
|
||||
url: `/api/admin/v1/devices/${deviceId}/identity-reset`,
|
||||
method: 'post'
|
||||
})
|
||||
}
|
||||
|
||||
@@ -66,6 +66,10 @@ export function suggestShopeeSizeMappings(productId, data) {
|
||||
return request({ url: `/api/admin/v1/shopee-products/${productId}/specs/mapping/suggest-sizes`, method: 'post', data, timeout: aiSuggestTimeoutMs })
|
||||
}
|
||||
|
||||
export function autoMatchShopeeSpecMappings(productId, data) {
|
||||
return request({ url: `/api/admin/v1/shopee-products/${productId}/specs/mapping/auto-match`, method: 'post', data, timeout: aiSuggestTimeoutMs })
|
||||
}
|
||||
|
||||
export function batchDeleteShopeeProducts(data) {
|
||||
return request({ url: '/api/admin/v1/shopee-products/batch-delete', method: 'post', data })
|
||||
}
|
||||
|
||||
@@ -68,8 +68,14 @@
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column v-if="isAdmin" label="操作" width="190" fixed="right">
|
||||
<el-table-column v-if="isAdmin" label="操作" width="270" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button
|
||||
type="warning"
|
||||
link
|
||||
:disabled="row.status === 'disabled'"
|
||||
@click="confirmIdentityReset(row)"
|
||||
>重置设备身份</el-button>
|
||||
<el-button
|
||||
type="danger"
|
||||
link
|
||||
@@ -116,7 +122,7 @@
|
||||
<script>
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Refresh, RefreshLeft, Search } from '@element-plus/icons-vue'
|
||||
import { disableDevice, listDevices, revokeDeviceToken } from '@/api/goauto/devices'
|
||||
import { disableDevice, listDevices, resetDeviceIdentity, revokeDeviceToken } from '@/api/goauto/devices'
|
||||
import { downloadAgentAppRelease, listAgentAppReleases, setCurrentAgentAppRelease, uploadAgentAppRelease } from '@/api/goauto/agent-app-releases'
|
||||
import { createRequestId } from '@/utils/request-id'
|
||||
|
||||
@@ -191,6 +197,16 @@ export default {
|
||||
await revokeDeviceToken(row.id)
|
||||
ElMessage.success('设备 Token 已吊销')
|
||||
await this.getList()
|
||||
},
|
||||
async confirmIdentityReset(row) {
|
||||
await ElMessageBox.confirm(
|
||||
`将立即使“${row.name}”当前 Token 失效,并开启 10 分钟自动重新注册窗口。手机 Agent 保持运行时会自动恢复原设备 #${row.id},已分配的待领取任务保持不变。`,
|
||||
'确认重置设备身份',
|
||||
{ type: 'warning', confirmButtonText: '确认重置', cancelButtonText: '取消', distinguishCancelAndClose: true }
|
||||
)
|
||||
const response = await resetDeviceIdentity(row.id)
|
||||
ElMessage.success(`已开启自动重新注册窗口,有效至 ${new Date(response.data.expiresAt).toLocaleString()}`)
|
||||
await this.getList()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,13 +23,12 @@
|
||||
</el-descriptions>
|
||||
<p v-if="detail.product.pddProductId && detail.product.sharedByPddCount > 1" class="muted shared-note">该 PDD 商品当前被 {{ detail.product.sharedByPddCount }} 个虾皮商品共用,允许共用。</p>
|
||||
|
||||
<div class="section-heading"><h3>规格匹配</h3><span class="mapping-progress">{{ mappingProgressText }}</span></div>
|
||||
<el-alert title="只保存虾皮规格与 PDD 规格的对应关系,不会修改两端的原始颜色和尺码。" type="info" :closable="false" show-icon class="notice" />
|
||||
<div class="section-heading"><h3>规格匹配</h3><div class="mapping-heading-actions"><span class="mapping-progress">{{ mappingProgressText }}</span><el-button type="primary" :loading="detail.aiMatching" :disabled="!canAutoMatch" :title="hasMappingChanges ? '请先保存或撤销当前人工修改' : ''" @click="autoMatchMappings">一键匹配颜色和尺码</el-button></div></div>
|
||||
<el-alert title="成功匹配会直接保存并可用于采购;未通过置信度或候选校验的项目保持未匹配。不会修改蝦皮或 PDD 的原始颜色和尺码。" type="info" :closable="false" show-icon class="notice" />
|
||||
<el-alert v-if="!detail.product.pddProductId" title="请先关联 PDD 商品,再进行规格匹配。" type="warning" :closable="false" show-icon class="notice" />
|
||||
<el-alert v-else-if="detail.pddLoaded && !pddSpecValues('color').length && !pddSpecValues('size').length" title="关联的 PDD 商品暂无可用规格,请先完成商品采集。" type="warning" :closable="false" show-icon class="notice" />
|
||||
<el-tabs v-model="detail.activeSpecTab" class="mapping-tabs">
|
||||
<el-tab-pane label="颜色匹配" name="color">
|
||||
<div class="tab-actions"><span class="muted">AI 只生成未保存的建议,置信度不足不会预填,均需人工确认后保存。</span><el-button type="primary" plain :loading="detail.aiSuggesting.color" :disabled="!canAIMatchColor" @click="previewAISuggestions('color')">AI 匹配</el-button></div>
|
||||
<el-empty v-if="!specRows('color').length" description="虾皮商品暂无颜色规格" />
|
||||
<el-table v-else :data="specRows('color')" :row-class-name="mappingRowClass" border size="small">
|
||||
<el-table-column label="虾皮颜色" min-width="180"><template #default="{ row }"><div class="primary">{{ row.name }}</div><div class="muted">{{ row.source === 'manual' ? '人工添加' : 'SYB 导入' }}</div></template></el-table-column>
|
||||
@@ -39,7 +38,6 @@
|
||||
</el-table>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="尺码匹配" name="size">
|
||||
<div class="tab-actions"><span class="muted">先按格式统一自动匹配;未匹配的项再用 AI 生成建议,置信度不足不会预填。</span><el-button type="primary" plain :loading="detail.aiSuggesting.size" :disabled="!canAIMatchSize" @click="previewAISuggestions('size')">AI 匹配</el-button></div>
|
||||
<el-empty v-if="!specRows('size').length" description="虾皮商品暂无尺码规格" />
|
||||
<el-table v-else :data="specRows('size')" border size="small">
|
||||
<el-table-column label="虾皮尺码" min-width="180"><template #default="{ row }"><div class="primary">{{ row.name }}</div><div class="muted">{{ row.source === 'manual' ? '人工添加' : 'SYB 导入' }}</div></template></el-table-column>
|
||||
@@ -79,7 +77,7 @@ import { ElMessage } from 'element-plus'
|
||||
import {
|
||||
getShopeeProduct, updateShopeeProduct, linkShopeeProductPdd, addShopeeSpecValue,
|
||||
setShopeeSpecMapping, clearShopeeSpecMapping, confirmShopeeSpecMapping,
|
||||
suggestShopeeColorMappings, suggestShopeeSizeMappings
|
||||
autoMatchShopeeSpecMappings
|
||||
} from '@/api/goauto/shopee-products'
|
||||
import { getPddProduct, listPddProducts } from '@/api/goauto/pdd-products'
|
||||
import { createRequestId } from '@/utils/request-id'
|
||||
@@ -107,8 +105,7 @@ export default {
|
||||
allSpecRows() { return this.detail.product ? this.detail.product.specs.flatMap(d => d.values) : [] },
|
||||
hasMappingChanges() { return this.allSpecRows.some(row => (row.pddValueDraft || '') !== (row.originalPddValue || '')) },
|
||||
mappingProgressText() { const rows = this.allSpecRows.filter(row => row.role === 'color' || row.role === 'size'); const valid = rows.filter(row => row.pddValueDraft && this.pddValueExists(row.role, row.pddValueDraft)).length; return rows.length ? `${valid}/${rows.length} 已匹配` : '暂无可匹配规格' },
|
||||
canAIMatchColor() { return this.detail.pddLoaded && this.specRows('color').length > 0 && this.pddSpecValues('color').length > 0 },
|
||||
canAIMatchSize() { return this.detail.pddLoaded && this.specRows('size').length > 0 && this.pddSpecValues('size').length > 0 }
|
||||
canAutoMatch() { return this.detail.pddLoaded && !this.hasMappingChanges && ['color', 'size'].some(role => this.specRows(role).length > 0 && this.pddSpecValues(role).length > 0) }
|
||||
},
|
||||
watch: {
|
||||
modelValue: { immediate: true, handler(open) { if (open) this.requestSwitch(this.productId); else this.closeInternals() } },
|
||||
@@ -117,7 +114,7 @@ export default {
|
||||
action(value) { if (value === 'link_pdd' && this.detail.product && !this.pddPicker.open) this.openPddPicker() }
|
||||
},
|
||||
methods: {
|
||||
emptyDetail() { return { loading: false, saving: false, aiSuggesting: { color: false, size: false }, product: null, pddProduct: null, pddLoaded: false, activeSpecTab: 'color', highlightColor: '' } },
|
||||
emptyDetail() { return { loading: false, saving: false, aiMatching: false, product: null, pddProduct: null, pddLoaded: false, activeSpecTab: 'color', highlightColor: '' } },
|
||||
validProductId(id) { return Number.isInteger(Number(id)) && Number(id) > 0 },
|
||||
isCurrent(generation, id) { return this.modelValue && generation === this.requestGeneration && Number(this.productId) === Number(id) },
|
||||
async confirmDiscard() { if (!this.hasMappingChanges) return true; return this.$confirm('当前有未保存的规格匹配,确定放弃吗?', '放弃修改?', { type: 'warning', confirmButtonText: '放弃修改', cancelButtonText: '继续编辑' }).then(() => true).catch(() => false) },
|
||||
@@ -164,7 +161,7 @@ export default {
|
||||
markManual(row) { row.draftSource = 'manual'; row.previewReason = ''; row.draftConfidence = null },
|
||||
clearDraft(row) { row.pddValueDraft = ''; row.draftSource = 'manual'; row.previewReason = ''; row.draftConfidence = null },
|
||||
mappingRowClass({ row }) { return this.detail.highlightColor && row.role === 'color' && row.name === this.detail.highlightColor ? 'mapping-row-highlight' : '' },
|
||||
async previewAISuggestions(role) { const id = this.loadedProductId; const generation = this.requestGeneration; this.detail.aiSuggesting[role] = true; try { const suggest = role === 'color' ? suggestShopeeColorMappings : suggestShopeeSizeMappings; const r = await suggest(id, { specContextVersion: this.detail.product.specContextVersion }); if (!this.isCurrent(generation, id)) return; const byName = new Map(r.data.items.map(item => [item.valueName, item])); this.specRows(role).forEach(row => { const item = byName.get(row.name); if (!item) return; row.previewReason = item.reason || ''; row.draftConfidence = item.confidence ?? null; if (item.apply && item.status !== 'preserved') { row.pddValueDraft = item.pddValue || ''; row.draftSource = item.status === 'matched' ? 'exact_match' : 'ai_match' } }); const suggestedCount = r.data.items.filter(item => item.status === 'ai_suggested').length; ElMessage.success(`匹配 ${r.data.matchedCount} 项,AI 建议但置信度不足 ${suggestedCount} 项,待人工选择 ${r.data.pendingCount} 项`) } finally { if (this.isCurrent(generation, id)) this.detail.aiSuggesting[role] = false } },
|
||||
async autoMatchMappings() { const id = this.loadedProductId; const generation = this.requestGeneration; this.detail.aiMatching = true; try { const r = await autoMatchShopeeSpecMappings(id, { requestId: createRequestId(), specContextVersion: this.detail.product.specContextVersion }); if (!this.isCurrent(generation, id)) return; const confirmed = Number(r.data.confirmedCount || 0); const preserved = Number(r.data.preservedCount || 0); const unmatched = Number(r.data.unmatchedCount || 0); await this.refreshDetail(); const message = `新匹配并确认 ${confirmed} 项,保留已确认 ${preserved} 项,未匹配 ${unmatched} 项`; if (unmatched > 0) ElMessage.warning(message); else ElMessage.success(message) } finally { if (this.isCurrent(generation, id)) this.detail.aiMatching = false } },
|
||||
async confirmPendingMapping(row) { await confirmShopeeSpecMapping(this.loadedProductId, { requestId: createRequestId(), dimension: row.dimensionName, valueName: row.name }); await this.refreshDetail() },
|
||||
async saveAllMappings() { const changes = this.allSpecRows.filter(row => (row.pddValueDraft || '') !== (row.originalPddValue || '')); if (!changes.length) return; const invalid = changes.find(row => row.pddValueDraft && !this.pddValueExists(row.role, row.pddValueDraft)); if (invalid) { ElMessage.warning(`PDD 规格“${invalid.pddValueDraft}”已不存在,请重新选择`); return } this.detail.saving = true; try { for (const row of changes) { if (!row.pddValueDraft) { if (row.originalPddValue) await clearShopeeSpecMapping(this.loadedProductId, { requestId: createRequestId(), dimension: row.dimensionName, valueName: row.name }); continue } const source = ['exact_match', 'ai_match'].includes(row.draftSource) ? row.draftSource : 'manual'; const payload = { requestId: createRequestId(), dimension: row.dimensionName, valueName: row.name, pddValue: row.pddValueDraft, source }; if (source === 'ai_match') { if (row.draftConfidence !== null && row.draftConfidence !== undefined) payload.confidence = row.draftConfidence; if (row.previewReason) payload.reason = row.previewReason } await setShopeeSpecMapping(this.loadedProductId, payload); if (source === 'exact_match') await confirmShopeeSpecMapping(this.loadedProductId, { requestId: createRequestId(), dimension: row.dimensionName, valueName: row.name }) } ElMessage.success('规格匹配已保存'); await this.refreshDetail() } finally { this.detail.saving = false } },
|
||||
async submitAddValue() { const dimension = this.addValueForm.dimension.trim(); const name = this.addValueForm.name.trim(); if (!dimension || !name) { ElMessage.warning('请填写维度名称和规格值'); return } await addShopeeSpecValue(this.loadedProductId, { requestId: createRequestId(), dimension, role: this.addValueForm.role, name }); this.addValueForm = { dimension: '', role: 'other', name: '' }; await this.refreshDetail() },
|
||||
@@ -175,5 +172,5 @@ export default {
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.drawer-body{padding:0 4px 24px}.drawer-actions{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:16px}.primary{font-weight:600;color:#1f2937}.muted{font-size:12px;color:#909399}.link{color:#1677ff;cursor:pointer}.notice{margin-bottom:16px}.shared-note{margin:8px 0 0}.section-heading{display:flex;align-items:center;justify-content:space-between;margin:24px 0 12px}.section-heading h3{margin:0}.mapping-progress{font-size:13px;color:#606266}.mapping-tabs{margin-top:4px}.mapping-select{width:100%}.tab-actions{display:flex;align-items:center;justify-content:space-between;gap:16px;margin:4px 0 12px}.preview-reason{margin-top:4px}:deep(.mapping-row-highlight td){background:#fff7d6!important}.quick-row{display:flex;align-items:center;gap:8px;margin:6px 0}.quick-label{font-size:12px;color:#606266;width:32px}.picker-help{margin:8px 0 0}.picker-thumb,.picker-image-placeholder{display:flex;width:56px;height:56px;margin:auto;border-radius:4px}.picker-image-placeholder{align-items:center;justify-content:center;background:#f1f5f9;color:#909399;font-size:11px}.edit-help{margin-left:8px}@media(max-width:768px){.tab-actions{align-items:flex-start;flex-direction:column}}
|
||||
.drawer-body{padding:0 4px 24px}.drawer-actions{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:16px}.primary{font-weight:600;color:#1f2937}.muted{font-size:12px;color:#909399}.link{color:#1677ff;cursor:pointer}.notice{margin-bottom:16px}.shared-note{margin:8px 0 0}.section-heading{display:flex;align-items:center;justify-content:space-between;margin:24px 0 12px}.section-heading h3{margin:0}.mapping-heading-actions{display:flex;align-items:center;gap:12px}.mapping-progress{font-size:13px;color:#606266}.mapping-tabs{margin-top:4px}.mapping-select{width:100%}.preview-reason{margin-top:4px}:deep(.mapping-row-highlight td){background:#fff7d6!important}.quick-row{display:flex;align-items:center;gap:8px;margin:6px 0}.quick-label{font-size:12px;color:#606266;width:32px}.picker-help{margin:8px 0 0}.picker-thumb,.picker-image-placeholder{display:flex;width:56px;height:56px;margin:auto;border-radius:4px}.picker-image-placeholder{align-items:center;justify-content:center;background:#f1f5f9;color:#909399;font-size:11px}.edit-help{margin-left:8px}@media(max-width:768px){.section-heading,.mapping-heading-actions{align-items:flex-start;flex-direction:column}}
|
||||
</style>
|
||||
|
||||
@@ -51,22 +51,26 @@ test('PDD 选择器默认最近五条并支持搜索与清空回退', async({ pa
|
||||
expect(listURLs.at(-1)?.searchParams.get('status')).toBe('active')
|
||||
})
|
||||
|
||||
test('虾皮详情原地叠加 PDD 抽屉且低置信度 AI 建议不预填', async({ page, context }) => {
|
||||
test('虾皮详情一键匹配颜色尺码后直接使用并可原地叠加 PDD 抽屉', async({ page, context }) => {
|
||||
await context.addCookies([{ name: 'Admin-Token', value: 'prototype-test-token', domain: 'localhost', path: '/' }])
|
||||
let autoMatched = false
|
||||
let autoMatchBody: Record<string, unknown> | null = null
|
||||
await page.route('**/api/**', async route => {
|
||||
const url = new URL(route.request().url())
|
||||
if (url.pathname.startsWith('/src/api/')) return route.continue()
|
||||
if (url.pathname.endsWith('/api/v1/getinfo')) return route.fulfill({ json: { code: 200, data: { roles: ['purchaser'], name: '采购员', avatar: '', introduction: '', permissions: [] }}})
|
||||
if (url.pathname.endsWith('/api/v1/menurole')) return route.fulfill({ json: { code: 200, data: routes }})
|
||||
if (url.pathname.endsWith('/api/admin/v1/shopee-products')) return route.fulfill({ json: { code: 200, data: { items: [{ id: 9, shopeeItemId: 'S9', title: '叠加抽屉商品', shopName: '测试店', pddProductId: 17, specs: [{ name: '颜色', role: 'color', values: [{ name: '藏青色', source: 'syb', mapping: null }] }] }], total: 1, page: 1, pageSize: 20 }}})
|
||||
if (url.pathname.endsWith('/api/admin/v1/shopee-products/9/specs/mapping/suggest-colors')) {
|
||||
return route.fulfill({ json: { code: 200, data: { items: [{ valueName: '藏青色', pddValue: '黑色', status: 'ai_suggested', apply: false, confidence: 0.45, reason: '置信度不足,请人工选择' }], matchedCount: 0, pendingCount: 1 }}})
|
||||
if (url.pathname.endsWith('/api/admin/v1/shopee-products/9/specs/mapping/auto-match')) {
|
||||
autoMatchBody = route.request().postDataJSON()
|
||||
autoMatched = true
|
||||
return route.fulfill({ json: { code: 200, data: { confirmedCount: 2, preservedCount: 0, unmatchedCount: 0, items: [] }}})
|
||||
}
|
||||
if (url.pathname.endsWith('/api/admin/v1/shopee-products/9')) {
|
||||
return route.fulfill({ json: { code: 200, data: { product: { id: 9, shopeeItemId: 'S9', title: '叠加抽屉商品', shopName: '测试店', pddProductId: 17, specContextVersion: 3, specs: [{ name: '颜色', role: 'color', values: [{ name: '藏青色', source: 'syb', mapping: null }] }] }}}})
|
||||
return route.fulfill({ json: { code: 200, data: { product: { id: 9, shopeeItemId: 'S9', title: '叠加抽屉商品', shopName: '测试店', pddProductId: 17, specContextVersion: 'context-3', specs: [{ name: '颜色', role: 'color', values: [{ name: '藏青色', source: 'import', mapping: autoMatched ? { pddValue: '黑色', source: 'ai_match', status: 'confirmed', confidence: 0.96, reason: '颜色含义唯一一致' } : null }] }, { name: '尺码', role: 'size', values: [{ name: 'XL', source: 'import', mapping: autoMatched ? { pddValue: 'XL', source: 'exact_match', status: 'confirmed', reason: '格式统一后唯一匹配' } : null }] }] }}}})
|
||||
}
|
||||
if (url.pathname.endsWith('/api/admin/v1/pdd-products/17/related-syb-products')) return route.fulfill({ json: { code: 200, data: { items: [], total: 0 }}})
|
||||
if (url.pathname.endsWith('/api/admin/v1/pdd-products/17')) return route.fulfill({ json: { code: 200, data: { product: { id: 17, goodsId: 'P17', title: 'PDD 叠加详情', shopName: 'PDD 店', status: 'active', url: 'https://mobile.yangkeduo.com/goods.html?goods_id=17', specs: [{ name: '颜色分类', role: 'color', values: [{ name: '黑色', selectable: true, priceCent: 1299 }] }], colorImages: [] }, relatedShopeeProducts: [] }}})
|
||||
if (url.pathname.endsWith('/api/admin/v1/pdd-products/17')) return route.fulfill({ json: { code: 200, data: { product: { id: 17, goodsId: 'P17', title: 'PDD 叠加详情', shopName: 'PDD 店', status: 'active', url: 'https://mobile.yangkeduo.com/goods.html?goods_id=17', specs: [{ name: '颜色分类', role: 'color', values: [{ name: '黑色', selectable: true, priceCent: 1299 }] }, { name: '尺码', role: 'size', values: [{ name: 'XL', selectable: true }] }], colorImages: [] }, relatedShopeeProducts: [] }}})
|
||||
return route.fulfill({ json: { code: 200, data: [] }})
|
||||
})
|
||||
|
||||
@@ -75,15 +79,19 @@ test('虾皮详情原地叠加 PDD 抽屉且低置信度 AI 建议不预填', as
|
||||
page.on('popup', () => { popupCount += 1 })
|
||||
await page.getByRole('button', { name: '详情', exact: true }).click()
|
||||
await expect(page.getByText('虾皮商品详情', { exact: true })).toBeVisible()
|
||||
await page.getByRole('button', { name: 'AI 匹配', exact: true }).click()
|
||||
await expect(page.getByText('置信度不足,请人工选择', { exact: true })).toBeVisible()
|
||||
await expect(page.locator('.el-drawer:visible .mapping-select input').first()).toHaveValue('')
|
||||
await expect(page.getByRole('button', { name: '一键匹配颜色和尺码', exact: true })).toHaveCount(1)
|
||||
await expect(page.getByRole('button', { name: 'AI 匹配', exact: true })).toHaveCount(0)
|
||||
await page.getByRole('button', { name: '一键匹配颜色和尺码', exact: true }).click()
|
||||
await expect(page.locator('.el-drawer:visible').getByText('黑色 · ¥12.99', { exact: true })).toBeVisible()
|
||||
await expect(page.getByText('可采购', { exact: true })).toBeVisible()
|
||||
expect(autoMatchBody?.specContextVersion).toBe('context-3')
|
||||
expect(String(autoMatchBody?.requestId || '')).toMatch(/^[0-9a-f-]{36}$/)
|
||||
await page.getByLabel('虾皮商品详情').getByText('PDD-17 ↗', { exact: true }).click()
|
||||
await expect(page.locator('.el-drawer:visible').getByText('PDD 商品详情', { exact: true })).toBeVisible()
|
||||
await expect(page.getByText('PDD 叠加详情', { exact: true })).toBeVisible()
|
||||
await page.keyboard.press('Escape')
|
||||
await expect(page.getByText('PDD 叠加详情', { exact: true })).toBeHidden()
|
||||
await expect(page.getByText('虾皮商品详情', { exact: true })).toBeVisible()
|
||||
await expect(page.getByText('置信度不足,请人工选择', { exact: true })).toBeVisible()
|
||||
await expect(page.locator('.el-drawer:visible').getByText('黑色 · ¥12.99', { exact: true })).toBeVisible()
|
||||
expect(popupCount).toBe(0)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user