Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4f64c074dd | ||
|
|
4eac72919d | ||
|
|
31c32d6a86 | ||
|
|
55c2b6b254 | ||
|
|
16c9216a5a |
@@ -11,8 +11,8 @@ android {
|
||||
applicationId = "cn.ilapage.goauto.agent"
|
||||
minSdk = 23
|
||||
targetSdk = 34
|
||||
versionCode = 51
|
||||
versionName = "0.9.38"
|
||||
versionCode = 52
|
||||
versionName = "0.9.39"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
|
||||
|
||||
+2
-90
@@ -328,19 +328,8 @@ class GoAutoAccessibilityService : AccessibilityService(), UiDriver, PddCollecto
|
||||
) candidates += node
|
||||
}
|
||||
if (candidates.isEmpty()) return FreshActionResult.NOT_FOUND
|
||||
val candidate = when {
|
||||
candidates.size == 1 -> candidates.single()
|
||||
candidates.shareClickableAncestor() || FreshTapDuplicatePolicy.isSingleVisualTarget(candidates.map { node ->
|
||||
val bounds = Rect().also(node::getBoundsInScreen)
|
||||
NodeBounds(bounds.left, bounds.top, bounds.right, bounds.bottom)
|
||||
}) -> candidates.minBy { node ->
|
||||
val bounds = Rect().also(node::getBoundsInScreen)
|
||||
kotlin.math.abs(bounds.centerX() - target.bounds.centerX) +
|
||||
kotlin.math.abs(bounds.centerY() - target.bounds.centerY)
|
||||
}
|
||||
else -> return FreshActionResult.AMBIGUOUS
|
||||
}
|
||||
val bounds = Rect().also(candidate::getBoundsInScreen)
|
||||
if (candidates.size != 1) return FreshActionResult.AMBIGUOUS
|
||||
val bounds = Rect().also(candidates.single()::getBoundsInScreen)
|
||||
if (bounds.width() < 2 || bounds.height() < 2 || Build.VERSION.SDK_INT < Build.VERSION_CODES.N) {
|
||||
return FreshActionResult.FAILED
|
||||
}
|
||||
@@ -421,42 +410,6 @@ class GoAutoAccessibilityService : AccessibilityService(), UiDriver, PddCollecto
|
||||
return swipeNode(matches.single(), direction, durationMs, preferScrollAction = false)
|
||||
}
|
||||
|
||||
override fun pullDownPurchaseSurface(bounds: NodeBounds, durationMs: Long): Boolean {
|
||||
val root = rootInActiveWindow ?: return false
|
||||
if (root.packageName?.toString() != PDD_PACKAGE || Build.VERSION.SDK_INT < Build.VERSION_CODES.N) return false
|
||||
val rootBounds = Rect().also(root::getBoundsInScreen)
|
||||
val clipped = PurchaseSurfaceGesturePolicy.clippedBounds(
|
||||
bounds,
|
||||
NodeBounds(rootBounds.left, rootBounds.top, rootBounds.right, rootBounds.bottom),
|
||||
) ?: return false
|
||||
val gestureBounds = Rect(clipped.left, clipped.top, clipped.right, clipped.bottom)
|
||||
val centerX = gestureBounds.centerX().toFloat()
|
||||
val startY = (gestureBounds.top + gestureBounds.height() * 25 / 100).toFloat()
|
||||
val endY = (gestureBounds.top + gestureBounds.height() * 75 / 100).toFloat()
|
||||
val path = Path().apply {
|
||||
moveTo(centerX, startY)
|
||||
lineTo(centerX, endY)
|
||||
}
|
||||
val completed = AtomicBoolean(false)
|
||||
val latch = CountDownLatch(1)
|
||||
val queued = dispatchGesture(
|
||||
GestureDescription.Builder().addStroke(GestureDescription.StrokeDescription(path, 0, durationMs)).build(),
|
||||
object : GestureResultCallback() {
|
||||
override fun onCompleted(gestureDescription: GestureDescription?) {
|
||||
completed.set(true)
|
||||
latch.countDown()
|
||||
}
|
||||
|
||||
override fun onCancelled(gestureDescription: GestureDescription?) {
|
||||
latch.countDown()
|
||||
}
|
||||
},
|
||||
null,
|
||||
)
|
||||
if (!queued) return false
|
||||
return latch.await(1_500, TimeUnit.MILLISECONDS) && completed.get()
|
||||
}
|
||||
|
||||
override fun backPurchase(): Boolean = performGlobalAction(GLOBAL_ACTION_BACK)
|
||||
|
||||
override fun bringPddToForeground(): Boolean {
|
||||
@@ -664,15 +617,6 @@ class GoAutoAccessibilityService : AccessibilityService(), UiDriver, PddCollecto
|
||||
return latch.await(1500, TimeUnit.MILLISECONDS) && completed.get()
|
||||
}
|
||||
|
||||
private fun List<AccessibilityNodeInfo>.shareClickableAncestor(): Boolean {
|
||||
val ancestors = map { source ->
|
||||
var node: AccessibilityNodeInfo? = source.parent
|
||||
while (node != null && !node.isClickable) node = node.parent
|
||||
node
|
||||
}
|
||||
return ancestors.all { it != null } && ancestors.distinct().size == 1
|
||||
}
|
||||
|
||||
private fun walk(node: AccessibilityNodeInfo, visit: (AccessibilityNodeInfo) -> Unit) {
|
||||
visit(node)
|
||||
for (index in 0 until node.childCount) node.getChild(index)?.let { walk(it, visit) }
|
||||
@@ -721,35 +665,3 @@ internal object SpecSwipeSafety {
|
||||
fun preferAccessibilityScrollAction(direction: SwipeDirection): Boolean =
|
||||
direction == SwipeDirection.UP || direction == SwipeDirection.DOWN
|
||||
}
|
||||
|
||||
internal object FreshTapDuplicatePolicy {
|
||||
fun isSingleVisualTarget(bounds: List<NodeBounds>): Boolean {
|
||||
if (bounds.isEmpty()) return false
|
||||
return bounds.indices.all { first ->
|
||||
((first + 1) until bounds.size).all { second -> nearDuplicate(bounds[first], bounds[second]) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun nearDuplicate(first: NodeBounds, second: NodeBounds): Boolean {
|
||||
val overlapWidth = (minOf(first.right, second.right) - maxOf(first.left, second.left)).coerceAtLeast(0)
|
||||
val overlapHeight = (minOf(first.bottom, second.bottom) - maxOf(first.top, second.top)).coerceAtLeast(0)
|
||||
val overlap = overlapWidth.toLong() * overlapHeight
|
||||
val smaller = minOf(first.width.toLong() * first.height, second.width.toLong() * second.height)
|
||||
return smaller > 0 && overlap * 100 >= smaller * 98
|
||||
}
|
||||
}
|
||||
|
||||
internal object PurchaseSurfaceGesturePolicy {
|
||||
fun clippedBounds(requested: NodeBounds, root: NodeBounds): NodeBounds? {
|
||||
val clipped = NodeBounds(
|
||||
maxOf(requested.left, root.left),
|
||||
maxOf(requested.top, root.top),
|
||||
minOf(requested.right, root.right),
|
||||
minOf(requested.bottom, root.bottom),
|
||||
)
|
||||
if (root.width < 2 || root.height < 2 || clipped.width * 100 < root.width * 60 ||
|
||||
clipped.height * 100 < root.height * 20
|
||||
) return null
|
||||
return clipped
|
||||
}
|
||||
}
|
||||
|
||||
+10
-90
@@ -36,31 +36,14 @@ class PurchaseLiveAutomation(
|
||||
private var submitAttempted = false
|
||||
var lastOrderReadFailure: PurchaseOrderReadFailure? = null
|
||||
private set
|
||||
fun updateShippingAddress(input: PurchaseExecutionInput): ShippingAddressProof =
|
||||
updateShippingAddress(input.addressSuffix, input)
|
||||
|
||||
fun updateShippingAddress(addressSuffix: String): ShippingAddressProof =
|
||||
updateShippingAddress(addressSuffix, null)
|
||||
|
||||
private fun updateShippingAddress(addressSuffix: String, input: PurchaseExecutionInput?): ShippingAddressProof {
|
||||
fun updateShippingAddress(addressSuffix: String): ShippingAddressProof {
|
||||
if (!addressSuffix.matches(Regex("^_cg[1-9][0-9]*$"))) fail("PURCHASE_ADDRESS_UPDATE_FAILED", "采购任务的地址标记无效")
|
||||
var snapshot = driver.capture()
|
||||
var previousSignature: String? = null
|
||||
var unchangedCount = 0
|
||||
var verifiedPanelBounds: NodeBounds? = null
|
||||
var trustedPurchaseSurfaceBounds: NodeBounds? = null
|
||||
for (attempt in 0 until 5) {
|
||||
pageProblem(snapshot)
|
||||
val panel = purchasePanelScrollTargets(snapshot)
|
||||
if (panel.size == 1) verifiedPanelBounds = panel.single().bounds
|
||||
if (trustedPurchaseSurfaceBounds == null && input != null) {
|
||||
trustedPurchaseSurfaceBounds = verifiedPurchaseSurfaceBounds(snapshot, input)
|
||||
}
|
||||
val entries = mergedDirect(
|
||||
listOfNotNull(trustedPurchaseSurfaceBounds, verifiedPanelBounds)
|
||||
.distinct()
|
||||
.flatMap { bounds -> addressEntryTargets(snapshot, bounds) },
|
||||
)
|
||||
val entries = mergedDirect(snapshot.nodes.filter { it.visible && it.enabled && MASKED_PHONE.containsMatchIn(it.label) })
|
||||
if (entries.size == 1) {
|
||||
when (driver.tapPurchaseFresh(entries.single())) {
|
||||
FreshActionResult.SUCCESS -> Unit
|
||||
@@ -73,22 +56,14 @@ class PurchaseLiveAutomation(
|
||||
}
|
||||
return editAndVerifyAddress(snapshot, addressSuffix)
|
||||
}
|
||||
val fallbackBounds = if (panel.size == 1) panel.single().bounds else trustedPurchaseSurfaceBounds
|
||||
?: input?.let { verifiedPurchaseSurfaceBounds(snapshot, it) }
|
||||
?: fail("PURCHASE_ADDRESS_ENTRY_AMBIGUOUS", "无法确认唯一的采购页面滑动区域,未创建订单")
|
||||
val signature = viewportSignature(snapshot, fallbackBounds)
|
||||
if (entries.size > 1) fail("PURCHASE_ADDRESS_ENTRY_AMBIGUOUS", "收货地址入口不唯一,未创建订单")
|
||||
val panel = purchasePanelScrollTargets(snapshot)
|
||||
if (panel.size != 1) fail("PURCHASE_ADDRESS_ENTRY_AMBIGUOUS", "没有找到唯一的规格面板滚动区域,未创建订单")
|
||||
val signature = viewportSignature(snapshot, panel.single())
|
||||
unchangedCount = if (signature == previousSignature) unchangedCount + 1 else 0
|
||||
if (unchangedCount >= 2 || attempt == 4) {
|
||||
if (entries.size > 1) fail("PURCHASE_ADDRESS_ENTRY_AMBIGUOUS", "收货地址入口不唯一,未创建订单")
|
||||
break
|
||||
}
|
||||
if (unchangedCount >= 2 || attempt == 4) break
|
||||
previousSignature = signature
|
||||
val swiped = if (panel.size == 1) {
|
||||
driver.swipePurchaseIn(panel.single(), SwipeDirection.DOWN, 350)
|
||||
} else {
|
||||
driver.pullDownPurchaseSurface(fallbackBounds, 350)
|
||||
}
|
||||
if (!swiped) {
|
||||
if (!driver.swipePurchaseIn(panel.single(), SwipeDirection.DOWN, 350)) {
|
||||
fail("PURCHASE_ADDRESS_PANEL_TIMEOUT", "规格面板无法下拉显示收货地址,未创建订单")
|
||||
}
|
||||
pause(500)
|
||||
@@ -319,62 +294,8 @@ class PurchaseLiveAutomation(
|
||||
}.distinctBy { it.path }
|
||||
}
|
||||
|
||||
private fun addressEntryTargets(snapshot: UiSnapshot, panelBounds: NodeBounds): List<SnapshotNode> {
|
||||
val byPath = snapshot.nodes.associateBy { it.path }
|
||||
val cards = snapshot.nodes.filter { node ->
|
||||
node.visible && node.enabled && MASKED_PHONE.containsMatchIn(node.label)
|
||||
}.mapNotNull { source ->
|
||||
var target: SnapshotNode? = source
|
||||
while (target != null && !target.clickable) target = target.parentPath?.let(byPath::get)
|
||||
target?.takeIf { card ->
|
||||
card.visible && card.enabled && visiblyIntersects(card.bounds, panelBounds)
|
||||
}
|
||||
}.distinctBy { it.path }
|
||||
return mergedDirect(cards)
|
||||
}
|
||||
|
||||
private fun visiblyIntersects(target: NodeBounds, scope: NodeBounds): Boolean {
|
||||
val overlapWidth = (minOf(target.right, scope.right) - maxOf(target.left, scope.left)).coerceAtLeast(0)
|
||||
val overlapHeight = (minOf(target.bottom, scope.bottom) - maxOf(target.top, scope.top)).coerceAtLeast(0)
|
||||
val comparableWidth = minOf(target.width, scope.width)
|
||||
return comparableWidth > 0 && target.height > 0 &&
|
||||
overlapWidth * 100 >= comparableWidth * 60 &&
|
||||
overlapHeight >= minOf(ADDRESS_CARD_MIN_VISIBLE_HEIGHT, target.height)
|
||||
}
|
||||
|
||||
private fun verifiedPurchaseSurfaceBounds(snapshot: UiSnapshot, input: PurchaseExecutionInput): NodeBounds? {
|
||||
if (snapshot.packageName != PDD_PACKAGE || finalSubmitTargets(snapshot).size != 1) return null
|
||||
val screen = PddScreenParser.parse(snapshot, PurchaseRehearsalExecutor.DEFAULT_COLLECTOR, input.goodsId, null)
|
||||
val selected = listOf(input.mappedColor, input.mappedSize).filter(String::isNotBlank)
|
||||
if (selected.any { value ->
|
||||
screen.selectedSummary?.contains(value) != true && snapshot.nodes.none { it.visible && it.label.contains(value) }
|
||||
}
|
||||
) return null
|
||||
val quantities = snapshot.nodes.filter {
|
||||
it.visible && it.enabled && it.className?.endsWith("EditText") == true
|
||||
}.mapNotNull { it.label.toLongOrNull() }
|
||||
if (quantities.singleOrNull() != input.quantity) return null
|
||||
val price = screen.priceCent ?: return null
|
||||
if (price !in input.minUnitPriceCent..input.maxUnitPriceCent) return null
|
||||
val visibleBounds = snapshot.nodes.filter { it.visible && it.bounds.width > 1 && it.bounds.height > 1 }.map { it.bounds }
|
||||
if (visibleBounds.isEmpty()) return null
|
||||
val screenBounds = NodeBounds(
|
||||
visibleBounds.minOf { it.left },
|
||||
visibleBounds.minOf { it.top },
|
||||
visibleBounds.maxOf { it.right },
|
||||
visibleBounds.maxOf { it.bottom },
|
||||
)
|
||||
if (screenBounds.width < 2 || screenBounds.height < 4) return null
|
||||
return NodeBounds(
|
||||
screenBounds.left,
|
||||
screenBounds.top + screenBounds.height * 20 / 100,
|
||||
screenBounds.right,
|
||||
screenBounds.top + screenBounds.height * 90 / 100,
|
||||
)
|
||||
}
|
||||
|
||||
private fun viewportSignature(snapshot: UiSnapshot, bounds: NodeBounds): String = snapshot.nodes
|
||||
.filter { node -> node.visible && inside(node.bounds, bounds) }
|
||||
private fun viewportSignature(snapshot: UiSnapshot, panel: SnapshotNode): String = snapshot.nodes
|
||||
.filter { node -> node.visible && inside(node.bounds, panel.bounds) }
|
||||
.joinToString("|") { node ->
|
||||
listOf(node.className.orEmpty(), node.bounds.left, node.bounds.top, node.bounds.right, node.bounds.bottom, node.clickable, node.scrollable).joinToString(":")
|
||||
}
|
||||
@@ -501,7 +422,6 @@ class PurchaseLiveAutomation(
|
||||
const val PDD_PACKAGE = "com.xunmeng.pinduoduo"
|
||||
const val WECHAT_PACKAGE = "com.tencent.mm"
|
||||
val MASKED_PHONE = Regex("(?<![0-9])[0-9]{3}\\*{4}[0-9]{4}(?![0-9])")
|
||||
const val ADDRESS_CARD_MIN_VISIBLE_HEIGHT = 24
|
||||
val FINAL_SUBMIT_MARKERS = listOf("提交订单", "现在买,仅", "确认购买")
|
||||
val PAYMENT_MARKERS = listOf("立即支付", "确认支付", "输入支付密码")
|
||||
val UNPAID_MARKERS = listOf("待付款", "待支付", "去支付")
|
||||
|
||||
+1
-2
@@ -14,7 +14,6 @@ interface PurchaseUiDriver {
|
||||
fun inputFresh(target: SnapshotNode, value: String): FreshActionResult
|
||||
fun swipePurchase(direction: SwipeDirection, durationMs: Long): Boolean
|
||||
fun swipePurchaseIn(target: SnapshotNode, direction: SwipeDirection, durationMs: Long): Boolean
|
||||
fun pullDownPurchaseSurface(bounds: NodeBounds, durationMs: Long): Boolean = false
|
||||
fun pullDownGoodsPage(): Boolean = swipePurchase(SwipeDirection.DOWN, 550)
|
||||
fun backPurchase(): Boolean
|
||||
/** Requests the existing PDD task stack in the foreground; callers must verify the observed package afterwards. */
|
||||
@@ -80,7 +79,7 @@ class PurchaseRehearsalExecutor(
|
||||
null
|
||||
}
|
||||
PurchaseActionType.UPDATE_SHIPPING_ADDRESS -> try {
|
||||
addressProof = live.updateShippingAddress(input)
|
||||
addressProof = live.updateShippingAddress(input.addressSuffix)
|
||||
null
|
||||
} catch (error: PurchaseLiveException) {
|
||||
failure(error.code, error.message ?: "收货地址修改失败,未创建订单")
|
||||
|
||||
@@ -303,94 +303,6 @@ class PurchaseLiveAutomationTest {
|
||||
assertEquals(0, driver.genericSwipes)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `duplicate partial address hints are scrolled before choosing the complete entry`() {
|
||||
val driver = LiveDriver(addressClipped = true, duplicateAddressHintsBeforeReveal = true)
|
||||
val address = PurchaseLiveAutomation(driver, pause = {}).updateShippingAddress("_cg38")
|
||||
|
||||
assertEquals("广东省广州市天园街道骏景花园骏晖轩1202_cg38", address.expectedAddress)
|
||||
assertEquals(1, driver.scopedSwipes)
|
||||
assertEquals(1, driver.addressTaps)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `revealed address is used when panel stops reporting scrollable after swipe`() {
|
||||
val driver = LiveDriver(addressClipped = true, panelScrollableAfterReveal = false)
|
||||
val address = PurchaseLiveAutomation(driver, pause = {}).updateShippingAddress("_cg41")
|
||||
|
||||
assertEquals("广东省广州市天园街道骏景花园骏晖轩1202_cg41", address.expectedAddress)
|
||||
assertEquals(1, driver.scopedSwipes)
|
||||
assertEquals(1, driver.addressTaps)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `verified purchase surface pulls down when PDD exposes no scrollable panel`() {
|
||||
val driver = LiveDriver(addressClipped = true, panelInitiallyScrollable = false, addressAtTopAfterReveal = true)
|
||||
val address = PurchaseLiveAutomation(driver, pause = {}).updateShippingAddress(input().copy(addressSuffix = "_cg56"))
|
||||
|
||||
assertEquals("广东省广州市天园街道骏景花园骏晖轩1202_cg56", address.expectedAddress)
|
||||
assertEquals(0, driver.scopedSwipes)
|
||||
assertEquals(1, driver.surfacePullDowns)
|
||||
assertEquals(1, driver.addressTaps)
|
||||
assertEquals("address-card", driver.lastAddressTapPath)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `verified purchase surface finds address above a separate scrollable spec panel`() {
|
||||
val driver = LiveDriver(addressClipped = true, addressAboveScrollablePanel = true)
|
||||
val address = PurchaseLiveAutomation(driver, pause = {}).updateShippingAddress(input().copy(addressSuffix = "_cg58"))
|
||||
|
||||
assertEquals("广东省广州市天园街道骏景花园骏晖轩1202_cg58", address.expectedAddress)
|
||||
assertEquals(1, driver.scopedSwipes)
|
||||
assertEquals(0, driver.surfacePullDowns)
|
||||
assertEquals(1, driver.addressTaps)
|
||||
assertEquals("address-card", driver.lastAddressTapPath)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `verified purchase surface pulls down when PDD exposes multiple scrollable panels`() {
|
||||
val driver = LiveDriver(addressClipped = true, duplicatePanels = true)
|
||||
val address = PurchaseLiveAutomation(driver, pause = {}).updateShippingAddress(input().copy(addressSuffix = "_cg57"))
|
||||
|
||||
assertEquals("广东省广州市天园街道骏景花园骏晖轩1202_cg57", address.expectedAddress)
|
||||
assertEquals(0, driver.scopedSwipes)
|
||||
assertEquals(1, driver.surfacePullDowns)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `missing exact purchase evidence blocks surface gesture`() {
|
||||
val driver = LiveDriver(addressClipped = true, panelInitiallyScrollable = false, hideSelectedSummary = true)
|
||||
val error = runCatching {
|
||||
PurchaseLiveAutomation(driver, pause = {}).updateShippingAddress(input().copy(addressSuffix = "_cg58"))
|
||||
}.exceptionOrNull() as PurchaseLiveException
|
||||
|
||||
assertEquals("PURCHASE_ADDRESS_ENTRY_AMBIGUOUS", error.code)
|
||||
assertEquals(0, driver.scopedSwipes)
|
||||
assertEquals(0, driver.surfacePullDowns)
|
||||
assertEquals(0, driver.addressTaps)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `duplicate phone nodes in one clickable address card are treated as one entry`() {
|
||||
val driver = LiveDriver(duplicateAddressNodesSameCard = true)
|
||||
val address = PurchaseLiveAutomation(driver, pause = {}).updateShippingAddress("_cg39")
|
||||
|
||||
assertEquals("广东省广州市天园街道骏景花园骏晖轩1202_cg39", address.expectedAddress)
|
||||
assertEquals(0, driver.scopedSwipes)
|
||||
assertEquals(1, driver.addressTaps)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `two distinct address cards remain ambiguous after bounded panel review`() {
|
||||
val driver = LiveDriver(distinctAddressCards = true)
|
||||
val error = runCatching { PurchaseLiveAutomation(driver, pause = {}).updateShippingAddress("_cg40") }
|
||||
.exceptionOrNull() as PurchaseLiveException
|
||||
|
||||
assertEquals("PURCHASE_ADDRESS_ENTRY_AMBIGUOUS", error.code)
|
||||
assertEquals(2, driver.scopedSwipes)
|
||||
assertEquals(0, driver.addressTaps)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ambiguous purchase panels fail without swiping or clicking`() {
|
||||
val driver = LiveDriver(addressClipped = true, duplicatePanels = true)
|
||||
@@ -419,14 +331,6 @@ class PurchaseLiveAutomationTest {
|
||||
private class LiveDriver(
|
||||
private val addressClipped: Boolean = false,
|
||||
private val duplicatePanels: Boolean = false,
|
||||
private val duplicateAddressHintsBeforeReveal: Boolean = false,
|
||||
private val duplicateAddressNodesSameCard: Boolean = false,
|
||||
private val distinctAddressCards: Boolean = false,
|
||||
private val panelInitiallyScrollable: Boolean = true,
|
||||
private val panelScrollableAfterReveal: Boolean = true,
|
||||
private val hideSelectedSummary: Boolean = false,
|
||||
private val addressAtTopAfterReveal: Boolean = false,
|
||||
private val addressAboveScrollablePanel: Boolean = false,
|
||||
private val chooserAfterSubmit: Boolean = false,
|
||||
private val trustedChooser: Boolean = true,
|
||||
private val splitConfirmationAddress: Boolean = false,
|
||||
@@ -449,9 +353,7 @@ class PurchaseLiveAutomationTest {
|
||||
var submitClicks = 0
|
||||
var scopedSwipes = 0
|
||||
var genericSwipes = 0
|
||||
var surfacePullDowns = 0
|
||||
var addressTaps = 0
|
||||
var lastAddressTapPath: String? = null
|
||||
var lastInputTargetPath: String? = null
|
||||
var backCount = 0
|
||||
var postSubmitBackCount = 0
|
||||
@@ -502,42 +404,14 @@ class PurchaseLiveAutomationTest {
|
||||
else -> {
|
||||
val nodes = mutableListOf(
|
||||
node("root", "", bounds = NodeBounds(0, 0, 1080, 2200)),
|
||||
node(
|
||||
"panel",
|
||||
"",
|
||||
scrollable = if (addressVisible) panelScrollableAfterReveal else panelInitiallyScrollable,
|
||||
bounds = if (addressAboveScrollablePanel) NodeBounds(0, 900, 1080, 2079) else NodeBounds(0, 400, 1080, 2100),
|
||||
),
|
||||
node("price", "¥20.00"),
|
||||
node("panel", "", scrollable = true, bounds = NodeBounds(0, 400, 1080, 2100)),
|
||||
node("price", "¥20.00"), node("selected", "已选 黑色 XL"),
|
||||
node("quantity", "2", className = "android.widget.EditText"),
|
||||
node("submit-parent", "", clickable = true), node("submit", "提交订单", parentPath = "submit-parent"),
|
||||
)
|
||||
if (!hideSelectedSummary) nodes += node("selected", "已选 黑色 XL")
|
||||
if (duplicatePanels) nodes += node("panel2", "", scrollable = true, bounds = NodeBounds(0, 500, 1080, 2000))
|
||||
if (addressVisible) {
|
||||
when {
|
||||
distinctAddressCards -> {
|
||||
nodes += node("address-card-1", "", clickable = true, bounds = NodeBounds(0, 620, 1080, 820))
|
||||
nodes += node("phone-1", "138****5678", bounds = NodeBounds(20, 650, 300, 710), parentPath = "address-card-1")
|
||||
nodes += node("address-card-2", "", clickable = true, bounds = NodeBounds(0, 850, 1080, 1050))
|
||||
nodes += node("phone-2", "139****5678", bounds = NodeBounds(20, 880, 300, 940), parentPath = "address-card-2")
|
||||
}
|
||||
duplicateAddressNodesSameCard -> {
|
||||
nodes += node("address-card", "", clickable = true, bounds = NodeBounds(0, 620, 1080, 850))
|
||||
nodes += node("phone", "138****5678", bounds = NodeBounds(20, 650, 300, 710), parentPath = "address-card")
|
||||
nodes += node("phone-duplicate", "138****5678", bounds = NodeBounds(20, 735, 300, 795), parentPath = "address-card")
|
||||
}
|
||||
addressAtTopAfterReveal || addressAboveScrollablePanel -> {
|
||||
val cardBounds = if (addressAboveScrollablePanel) NodeBounds(0, 366, 1080, 520) else NodeBounds(0, 300, 1080, 520)
|
||||
val phoneBounds = if (addressAboveScrollablePanel) NodeBounds(412, 382, 993, 431) else NodeBounds(300, 330, 800, 380)
|
||||
nodes += node("address-card", "", clickable = true, bounds = cardBounds)
|
||||
nodes += node("phone", "138****5678", bounds = phoneBounds, parentPath = "address-card")
|
||||
}
|
||||
else -> {
|
||||
nodes += node("address-card", "", clickable = true, bounds = NodeBounds(0, 620, 1080, 850))
|
||||
nodes += node("phone", "138****5678", bounds = NodeBounds(20, 650, 300, 710), parentPath = "address-card")
|
||||
}
|
||||
}
|
||||
nodes += node("phone", "138****5678")
|
||||
val suffixStart = address.lastIndexOf("_cg")
|
||||
val addressBody = if (suffixStart >= 0) address.substring(0, suffixStart) else address
|
||||
val addressSuffix = if (suffixStart >= 0) address.substring(suffixStart) else ""
|
||||
@@ -550,11 +424,8 @@ class PurchaseLiveAutomationTest {
|
||||
nodes += node("address-suffix-2", addressSuffix, bounds = NodeBounds(500, 780, 780, 840))
|
||||
}
|
||||
}
|
||||
else -> nodes += node("address", address, bounds = NodeBounds(20, 720, 900, 800))
|
||||
else -> nodes += node("address", address)
|
||||
}
|
||||
} else if (duplicateAddressHintsBeforeReveal) {
|
||||
nodes += node("phone-hint-1", "138****5678", bounds = NodeBounds(20, 420, 300, 480))
|
||||
nodes += node("phone-hint-2", "138****5678", bounds = NodeBounds(20, 510, 300, 570))
|
||||
}
|
||||
snapshot(nodes)
|
||||
}
|
||||
@@ -580,9 +451,8 @@ class PurchaseLiveAutomationTest {
|
||||
|
||||
override fun tapPurchaseFresh(target: SnapshotNode): FreshActionResult {
|
||||
addressTaps++
|
||||
lastAddressTapPath = target.path
|
||||
clicked += target.label
|
||||
if (target.path.startsWith("address-card")) page = "panel"
|
||||
if (target.label == "138****5678") page = "panel"
|
||||
return FreshActionResult.SUCCESS
|
||||
}
|
||||
|
||||
@@ -601,11 +471,6 @@ class PurchaseLiveAutomationTest {
|
||||
if (target.path == "panel" && direction == SwipeDirection.DOWN) addressVisible = true
|
||||
return true
|
||||
}
|
||||
override fun pullDownPurchaseSurface(bounds: NodeBounds, durationMs: Long): Boolean {
|
||||
surfacePullDowns++
|
||||
addressVisible = true
|
||||
return true
|
||||
}
|
||||
override fun backPurchase(): Boolean {
|
||||
backCount++
|
||||
if (page == "chooser" || page == "payment") postSubmitBackCount++
|
||||
|
||||
-21
@@ -18,25 +18,4 @@ class GoAutoAccessibilityServicePolicyTest {
|
||||
assertTrue(SpecSwipeSafety.preferAccessibilityScrollAction(SwipeDirection.UP))
|
||||
assertTrue(SpecSwipeSafety.preferAccessibilityScrollAction(SwipeDirection.DOWN))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun freshTapCollapsesOnlyNodesOccupyingTheSameVisualTarget() {
|
||||
assertTrue(FreshTapDuplicatePolicy.isSingleVisualTarget(listOf(
|
||||
NodeBounds(20, 650, 300, 710),
|
||||
NodeBounds(20, 650, 300, 710),
|
||||
)))
|
||||
assertFalse(FreshTapDuplicatePolicy.isSingleVisualTarget(listOf(
|
||||
NodeBounds(20, 650, 300, 710),
|
||||
NodeBounds(20, 850, 300, 910),
|
||||
)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun purchaseSurfaceGestureRequiresABroadRegionInsideTheCurrentRoot() {
|
||||
val root = NodeBounds(0, 0, 1080, 2376)
|
||||
assertTrue(PurchaseSurfaceGesturePolicy.clippedBounds(NodeBounds(0, 400, 1080, 2100), root) != null)
|
||||
assertTrue(PurchaseSurfaceGesturePolicy.clippedBounds(NodeBounds(-100, 400, 1180, 2100), root) != null)
|
||||
assertTrue(PurchaseSurfaceGesturePolicy.clippedBounds(NodeBounds(0, 0, 400, 2376), root) == null)
|
||||
assertTrue(PurchaseSurfaceGesturePolicy.clippedBounds(NodeBounds(0, 2300, 1080, 2500), root) == null)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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: 3e4f463cfa026a09ba125f2eb376bd42ef41c880
|
||||
synchronized_at: 2026-09-01T08:03:37Z
|
||||
wiki_revision: a6f63cc745cf0e0df7ca94521a70fb6af3adea26
|
||||
synchronized_at: 2026-09-01T09:55:26Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 业务规则与术语
|
||||
@@ -31,7 +31,7 @@ synchronized_at: 2026-09-01T08:03:37Z
|
||||
- 关联 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 匹配是独立例外:只有 SYB 采购规格解析成功、蝦皮与 PDD 关联完整、PDD 当前规格可用,并且最近一次成功或部分成功采集提供完整可售 SKU 组合证据时才进入批量匹配;未人工确认的 `parse_status=uncertain` 必须先人工修正;`manually_confirmed=true` 与 `parse_status=success` 同等视为可信规格。唯一确定性 `exact_match` 不调用外部 AI,可直接保存为 `confirmed`;其余结果只有在 `ai_match` 置信度存在且达到服务端阈值、理由非空、返回值属于当前可选候选并命中同一个可售颜色+尺码组合时,才可直接持久化为 `confirmed`。低置信度、无组合证据、无效组合或 Provider 异常不得改写现有映射。
|
||||
- 映射来源分 `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 异常不得改写现有映射。
|
||||
- 颜色映射只能从关联 PDD 商品当前可选颜色中选择,不允许自由输入;未使用颜色优先显示,已被其他蝦皮颜色使用的颜色仍可选择并显示占用者,因此支持多对一。
|
||||
- 尺码“一键自动匹配”只生成可审阅草稿:繁简体、首尾/重复空格、大小写和全半角统一后只有唯一结果才预填;没有唯一结果时保持待人工选择。保存只改映射,不改蝦皮或 PDD 原始规格。
|
||||
- PDD 重新采集或更换关联后,目标规格仍存在则映射继续有效;目标规格消失时详情标记“已失效”,服务端拒绝保存不存在的目标,采购预检和创建也拒绝使用失效映射并提示重新选择。
|
||||
@@ -150,7 +150,7 @@ synchronized_at: 2026-09-01T08:03:37Z
|
||||
- 采购规格由服务端按顺序决策:先使用已确认的人工映射;否则仅在同一规格角色的可选 PDD 原始标签中做唯一确定性匹配(繁体转简体、空格/全半角/大小写统一,以及公斤/斤换算);仍无唯一结果才调用已启用的服务端 AI。AI 必须返回候选集中的原始标签,候选不完整、歧义、AI 无结果或服务不可用均明确失败,不派发第二趟、更不创建订单。
|
||||
- Agent 选择服务端下发的精确颜色或尺码时,只能在已确认打开的规格面板内有限纵向滑动、每次重新读取可选节点并按完整原始文字点击;连续没有新证据或达到上限即停止,不得点击相近规格。第一趟探测并固化规格后,第二趟仍无法精确选择而再次提交探测时,服务端必须明确失败并释放活动槽,保留第一次决策证据,禁止清空决策、循环派发或进入地址与创建订单动作。
|
||||
- 采购 Agent 在确认进入 PDD 商品页后、打开规格前,复用采集侧的假售罄识别;规格面板已打开且当前解析到的规格值全部不可选时也按售罄处理。两种情况都只允许关闭规格面板后对商品页执行一次有界恢复(默认下拉 2 次、间隔 1000 毫秒、等待 2000 毫秒),恢复动作失败或恢复后仍售罄时返回 `PDD_GOODS_SOLD_OUT`,恢复后商品页证据丢失时按页面规则不匹配失败;不得继续选择规格、修改地址或创建订单。该失败码继续进入既有替代商品资格判定。
|
||||
- 规格映射不完整、PDD 档案为待采集或没有规格时,规则必须具有 `purchase.spec-probe.v1`;第一趟只探测规格并释放租约,服务端固化同一 attempt 的决策后才派发第二趟。Android 不自行匹配或猜测;其只接收服务端已经固化的精确原始规格标签。
|
||||
- 规格映射不完整、已保存的确认映射对当前 PDD 档案失效、确定性匹配失败、PDD 档案为待采集或没有规格时,规则必须具有 `purchase.spec-probe.v1`;自 #190 起前两种情况不再拒绝创建,而是把 `spec_source` 降级为 `unresolved` 并交由规格探测解析,规则不具备该能力时仍然拒绝创建;第一趟只探测规格并释放租约,服务端固化同一 attempt 的决策后才派发第二趟。Android 不自行匹配或猜测;其只接收服务端已经固化的精确原始规格标签。
|
||||
- Agent 提交的相同 attempt 最终结果只能写入一次;相同请求重放返回原事实,不同内容拒绝覆盖。`order_result_unknown` 不参与自动派发,只能人工解除。
|
||||
- 已创建订单默认禁止再次采购;管理员或采购员可以做一次性重新采购授权,新任务创建成功时在同一事务消耗授权,旧任务和旧订单保留。已标记为已支付的订单不能授权或创建重新采购任务。
|
||||
- 人工回填候选只允许从已支付订单选择;同一 SYB 明细后来选择的订单覆盖旧候选,但不删除旧订单事实。
|
||||
|
||||
@@ -2,13 +2,11 @@ package purchase
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"go-admin/app/goauto/aimatching"
|
||||
"go-admin/app/goauto/models"
|
||||
"go-admin/app/goauto/productspec"
|
||||
"go-admin/app/goauto/shopeeproduct"
|
||||
)
|
||||
|
||||
@@ -96,12 +94,6 @@ func aiMatchQualificationForDataset(id uint64, dataset batchPreviewDataset) aiMa
|
||||
if !found {
|
||||
return disabled("SYB 商品不存在或已删除")
|
||||
}
|
||||
if !sybSpecsTrusted(syb) {
|
||||
if syb.ParseStatus == models.SYBParseStatusUncertain {
|
||||
return disabled("采购规格解析存疑,请先人工修正并确认")
|
||||
}
|
||||
return disabled("采购规格解析失败,请先重新解析或人工修正")
|
||||
}
|
||||
if strings.TrimSpace(syb.TargetColor) == "" && strings.TrimSpace(syb.TargetSize) == "" {
|
||||
return disabled("未解析出需要采购的颜色或尺码")
|
||||
}
|
||||
@@ -119,9 +111,6 @@ func aiMatchQualificationForDataset(id uint64, dataset batchPreviewDataset) aiMa
|
||||
if !found || pdd.Status != "active" {
|
||||
return disabled("关联的 PDD 商品尚未采集完成或已停用")
|
||||
}
|
||||
if hasSelectableOtherDimension(pdd.SpecsJSON) {
|
||||
return disabled("PDD 商品包含颜色、尺码之外的可选规格,请人工处理")
|
||||
}
|
||||
candidates, usable := archiveCandidates(pdd.SpecsJSON, syb.TargetColor, syb.TargetSize)
|
||||
if !usable {
|
||||
return disabled("关联的 PDD 商品没有完整可选规格")
|
||||
@@ -139,18 +128,9 @@ func aiMatchQualificationForDataset(id uint64, dataset batchPreviewDataset) aiMa
|
||||
request := aimatching.MatchRequest{Colors: candidates.Colors, Sizes: candidates.Sizes}
|
||||
if mappedColor == "" {
|
||||
request.TargetColor = syb.TargetColor
|
||||
if !shopeeHasTarget(shopee.SpecsJSON, shopeeproduct.RoleColor, syb.TargetColor) {
|
||||
return disabled("蝦皮商品中未找到目标颜色,请先修正商品规格")
|
||||
}
|
||||
}
|
||||
if mappedSize == "" {
|
||||
request.TargetSize = syb.TargetSize
|
||||
if !shopeeHasTarget(shopee.SpecsJSON, shopeeproduct.RoleSize, syb.TargetSize) {
|
||||
return disabled("蝦皮商品中未找到目标尺码,请先修正商品规格")
|
||||
}
|
||||
}
|
||||
if len(dataset.skuCombinationsByPDD[pdd.ID]) == 0 {
|
||||
return disabled("缺少当前 PDD 商品的完整可售 SKU 组合,请先重新采集")
|
||||
}
|
||||
if matched, ok := aimatching.DeterministicMatch(request); ok {
|
||||
color, size := mappedColor, mappedSize
|
||||
@@ -181,42 +161,3 @@ func validSKUCombination(combinations []pddSKUCombination, targetColor, targetSi
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func shopeeHasTarget(raw, role, target string) bool {
|
||||
if strings.TrimSpace(target) == "" {
|
||||
return true
|
||||
}
|
||||
specs, err := shopeeproduct.Unmarshal(raw)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
for _, dimension := range specs {
|
||||
if dimension.Role != role {
|
||||
continue
|
||||
}
|
||||
for _, value := range dimension.Values {
|
||||
if value.Name == target {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func hasSelectableOtherDimension(raw string) bool {
|
||||
var dimensions []productspec.Dimension
|
||||
if json.Unmarshal([]byte(raw), &dimensions) != nil {
|
||||
return false
|
||||
}
|
||||
for _, dimension := range dimensions {
|
||||
if dimension.Role != "other" {
|
||||
continue
|
||||
}
|
||||
for _, value := range dimension.Values {
|
||||
if value.Selectable {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -424,9 +424,10 @@ 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)
|
||||
// #190:失效的已保存映射不再拦截,降级为 unresolved,与创建路径保持一致。
|
||||
if source != "unresolved" && !mappingTargetsValid(candidates, syb.TargetColor, syb.TargetSize, mappedColor, mappedSize) {
|
||||
item.ReasonCode, item.Reason, item.NextAction = CodeMappingRequired, "规格匹配已失效,请重新选择 PDD 规格", "open_mapping"
|
||||
return item
|
||||
mappedColor, mappedSize, source = "", "", "unresolved"
|
||||
item.MappedColor, item.MappedSize = "", ""
|
||||
}
|
||||
if source == "unresolved" {
|
||||
if !archiveUsable {
|
||||
@@ -443,10 +444,8 @@ func (s *Service) previewFromDataset(ctx context.Context, id uint64, dataset bat
|
||||
item.MappedColor, item.MappedSize = matched.MappedColor, matched.MappedSize
|
||||
} else if matched, ok := aimatching.DeterministicMatch(request); ok {
|
||||
item.MappedColor, item.MappedSize = matched.MappedColor, matched.MappedSize
|
||||
} else {
|
||||
item.ReasonCode, item.Reason, item.NextAction = CodeMappingRequired, "规格映射不完整,需要 AI 匹配或人工确认", "open_mapping"
|
||||
return item
|
||||
}
|
||||
// #190:映射不完整不再拦截,任务以 unresolved 建立并交由规格探测解析。
|
||||
}
|
||||
reference, minPrice, maxPrice, err := purchasePriceRange(pdd.SpecsJSON, item.MappedColor, guard)
|
||||
if err != nil {
|
||||
|
||||
@@ -241,14 +241,16 @@ func TestBatchPreviewExposesExplicitAIMatchEligibility(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchPreviewRejectsUncertainParseAndMissingSKUCombination(t *testing.T) {
|
||||
// #190 放开了 AI 匹配的解析状态与可售 SKU 组合前置:两者都不再阻断匹配,
|
||||
// 采购侧自身的解析门禁(previewFromDataset)不在本次放开范围内,仍然生效。
|
||||
func TestBatchPreviewAllowsUncertainParseAndMissingSKUCombination(t *testing.T) {
|
||||
service, f := unresolvedBatchSpecFixture(t)
|
||||
if err := service.DB.Model(&models.SYBProduct{}).Where("id = ?", f.syb.ID).Update("parse_status", models.SYBParseStatusUncertain).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
preview, err := service.BatchPreview(context.Background(), BatchPreviewRequest{SYBProductIDs: []uint64{f.syb.ID}})
|
||||
if err != nil || preview.Items[0].AIMatchEligible || preview.Items[0].AIMatchDisabledReason != "采购规格解析存疑,请先人工修正并确认" || preview.Items[0].ProcessStage != ProcessStageManualAction {
|
||||
t.Fatalf("uncertain parse entered AI matching: %+v err=%v", preview, err)
|
||||
if err != nil || !preview.Items[0].AIMatchEligible || preview.Items[0].AIMatchDisabledReason != "" {
|
||||
t.Fatalf("uncertain parse still blocked AI matching: %+v err=%v", preview, err)
|
||||
}
|
||||
|
||||
if err := service.DB.Model(&models.SYBProduct{}).Where("id = ?", f.syb.ID).Update("parse_status", models.SYBParseStatusSuccess).Error; err != nil {
|
||||
@@ -258,8 +260,8 @@ func TestBatchPreviewRejectsUncertainParseAndMissingSKUCombination(t *testing.T)
|
||||
t.Fatal(err)
|
||||
}
|
||||
preview, err = service.BatchPreview(context.Background(), BatchPreviewRequest{SYBProductIDs: []uint64{f.syb.ID}})
|
||||
if err != nil || preview.Items[0].AIMatchEligible || preview.Items[0].AIMatchDisabledReason != "缺少当前 PDD 商品的完整可售 SKU 组合,请先重新采集" {
|
||||
t.Fatalf("missing SKU combination entered AI matching: %+v err=%v", preview, err)
|
||||
if err != nil || !preview.Items[0].AIMatchEligible || preview.Items[0].AIMatchDisabledReason != "" {
|
||||
t.Fatalf("missing SKU combination still blocked AI matching: %+v err=%v", preview, err)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -353,23 +353,24 @@ func (s *Service) create(ctx context.Context, req CreateRequest) (models.Purchas
|
||||
} else if pdd.Status != "active" {
|
||||
mappedColor, mappedSize, specSource = "", "", "unresolved"
|
||||
} else if specSource == "manual_mapping" || specSource == "exact_match" || specSource == "ai_match" {
|
||||
// #190:失效的映射不再拒绝,降级为 unresolved 交给规格探测。
|
||||
if !mappingTargetsValid(candidates, targetColor, targetSize, mappedColor, mappedSize) {
|
||||
return fail(CodeMappingRequired, "规格匹配已失效,请重新选择 PDD 规格")
|
||||
mappedColor, mappedSize, specSource = "", "", "unresolved"
|
||||
} else {
|
||||
decision, marshalErr := json.Marshal(aimatching.RecordedMatch(matchRequest, specSource, mappedColor, mappedSize, "已使用人工确认的规格映射").Decision)
|
||||
if marshalErr != nil {
|
||||
return internal(marshalErr)
|
||||
}
|
||||
decisionSnapshot = string(decision)
|
||||
}
|
||||
decision, marshalErr := json.Marshal(aimatching.RecordedMatch(matchRequest, specSource, mappedColor, mappedSize, "已使用人工确认的规格映射").Decision)
|
||||
if marshalErr != nil {
|
||||
return internal(marshalErr)
|
||||
}
|
||||
decisionSnapshot = string(decision)
|
||||
} 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"
|
||||
} else {
|
||||
match, matched := aimatching.DeterministicMatch(matchRequest)
|
||||
if !matched {
|
||||
return fail(CodeMappingRequired, "规格映射不完整,需要异步规格匹配")
|
||||
}
|
||||
mappedColor, mappedSize, specSource = match.MappedColor, match.MappedSize, match.Source
|
||||
decision, marshalErr := json.Marshal(match.Decision)
|
||||
if marshalErr != nil {
|
||||
|
||||
@@ -176,15 +176,20 @@ func TestCreateLiveSnapshotsShopeeOrderNumber(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateLiveRejectsExpiredConfirmedMapping(t *testing.T) {
|
||||
// #190 起失效的已确认映射不再拒绝创建,而是降级为 unresolved 并交由规格探测解析。
|
||||
// 规则不支持 spec-probe 时仍然拒绝,那道门不在本次放开范围内。
|
||||
func TestCreateLiveDowngradesExpiredConfirmedMappingToSpecProbe(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}]},{"name":"尺码","role":"size","values":[{"name":"XL","selectable":true}]}]`).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err := createLive(t, testService(db), f)
|
||||
if code(err) != CodeMappingRequired {
|
||||
t.Fatalf("expected invalid mapping rejection, got %v", err)
|
||||
task, err := createLive(t, testService(db), f)
|
||||
if err != nil {
|
||||
t.Fatalf("expired mapping should downgrade, not reject: %v", err)
|
||||
}
|
||||
if task.SpecSource != "unresolved" || task.MappedColorSnapshot != "" || task.MappedSizeSnapshot != "" {
|
||||
t.Fatalf("expired mapping was not downgraded: source=%q color=%q size=%q", task.SpecSource, task.MappedColorSnapshot, task.MappedSizeSnapshot)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user