Compare commits
55
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a16e6920b7 | ||
|
|
def0a86f63 | ||
|
|
ba7d2a6b72 | ||
|
|
a3cf98940a | ||
|
|
56706a0b45 | ||
|
|
a4874091a8 | ||
|
|
9e0dcc254e | ||
|
|
cda1978bc6 | ||
|
|
c80d0c91d5 | ||
|
|
be491c7692 | ||
|
|
90c1668fe3 | ||
|
|
804448ede0 | ||
|
|
5a7e219230 | ||
|
|
36d1f82ce2 | ||
|
|
1c998911be | ||
|
|
45437a9214 | ||
|
|
42b5312198 | ||
|
|
7b540f3163 | ||
|
|
ffa5fc0710 | ||
|
|
66ab36c88c | ||
|
|
35b69fc25a | ||
|
|
b4dc7fc9ae | ||
|
|
baa9408a36 | ||
|
|
e01fcd9860 | ||
|
|
be03972779 | ||
|
|
c137f5072f | ||
|
|
82b4ebd406 | ||
|
|
b83f20c6b7 | ||
|
|
e0ed05d15a | ||
|
|
8163dd10eb | ||
|
|
aed295c8c1 | ||
|
|
c14f5f528b | ||
|
|
8293b27c19 | ||
|
|
c24e8a4439 | ||
|
|
40a254dc41 | ||
|
|
503b3cbbc5 | ||
|
|
cad9f6d185 | ||
|
|
78df997fc3 | ||
|
|
20f59b68bd | ||
|
|
652bf30f5a | ||
|
|
79929036bb | ||
|
|
e4677069d7 | ||
|
|
8cfd32babe | ||
|
|
3181302c16 | ||
|
|
2b01974fe5 | ||
|
|
305062dba0 | ||
|
|
ea38ced58c | ||
|
|
2cb78ad8cd | ||
|
|
ca997f6108 | ||
|
|
b1594dc7bd | ||
|
|
08d939a9b8 | ||
|
|
9088e6b734 | ||
|
|
fdd26afaf0 | ||
|
|
d403f3b3a0 | ||
|
|
c6a962d522 |
@@ -11,8 +11,8 @@ android {
|
||||
applicationId = "cn.ilapage.goauto.agent"
|
||||
minSdk = 23
|
||||
targetSdk = 34
|
||||
versionCode = 90
|
||||
versionName = "0.9.77"
|
||||
versionCode = 72
|
||||
versionName = "0.9.59"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
|
||||
|
||||
@@ -7,6 +7,8 @@
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
||||
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
|
||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32" />
|
||||
|
||||
<queries>
|
||||
<package android:name="com.xunmeng.pinduoduo" />
|
||||
|
||||
@@ -38,6 +38,7 @@ class MainActivity : AppCompatActivity() {
|
||||
)
|
||||
setContentView(buildContent())
|
||||
requestNotificationPermission()
|
||||
requestMediaPermission()
|
||||
if (AgentSettingsStore(this).serverUrl().isNotBlank()) AgentForegroundService.start(this)
|
||||
AgentAppUpdateManager.checkOnceAtStartup(this)
|
||||
}
|
||||
@@ -127,6 +128,14 @@ class MainActivity : AppCompatActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
private fun requestMediaPermission() {
|
||||
val permission = if (Build.VERSION.SDK_INT >= 33) Manifest.permission.READ_MEDIA_IMAGES
|
||||
else Manifest.permission.READ_EXTERNAL_STORAGE
|
||||
if (checkSelfPermission(permission) != PackageManager.PERMISSION_GRANTED) {
|
||||
requestPermissions(arrayOf(permission), MEDIA_PERMISSION_REQUEST)
|
||||
}
|
||||
}
|
||||
|
||||
private fun applyScreenPolicy() {
|
||||
val keepScreenOn = cn.ilapage.goauto.agent.service.AgentStateStore(this).read().keepScreenOn
|
||||
if (keepScreenOn) {
|
||||
@@ -145,5 +154,6 @@ class MainActivity : AppCompatActivity() {
|
||||
private const val TAB_PREFERENCES = "goauto_agent_ui"
|
||||
private const val LAST_SELECTED_TAB = "last_selected_tab"
|
||||
private const val STATE_SELECTED_TAB = "selected_tab"
|
||||
private const val MEDIA_PERMISSION_REQUEST = 101
|
||||
}
|
||||
}
|
||||
|
||||
+7
-25
@@ -434,16 +434,8 @@ class GoAutoAccessibilityService : AccessibilityService(), UiDriver, PddCollecto
|
||||
} else FreshActionResult.FAILED
|
||||
}
|
||||
|
||||
private var lastPurchaseSwipeFailure = PurchaseSwipeFailureReason.UNKNOWN
|
||||
|
||||
override fun purchaseSwipeFailureReason(): PurchaseSwipeFailureReason = lastPurchaseSwipeFailure
|
||||
|
||||
override fun swipePurchase(direction: SwipeDirection, durationMs: Long): Boolean {
|
||||
lastPurchaseSwipeFailure = PurchaseSwipeFailureReason.UNKNOWN
|
||||
val root = rootInActiveWindow ?: run {
|
||||
lastPurchaseSwipeFailure = PurchaseSwipeFailureReason.ROOT_UNAVAILABLE
|
||||
return false
|
||||
}
|
||||
val root = rootInActiveWindow ?: return false
|
||||
val candidates = mutableListOf<AccessibilityNodeInfo>()
|
||||
walk(root) { node -> if (node.isVisibleToUser && node.isScrollable) candidates += node }
|
||||
val horizontal = direction == SwipeDirection.LEFT || direction == SwipeDirection.RIGHT
|
||||
@@ -452,12 +444,8 @@ class GoAutoAccessibilityService : AccessibilityService(), UiDriver, PddCollecto
|
||||
}
|
||||
val target = (directional.ifEmpty { candidates }).maxByOrNull { candidate ->
|
||||
Rect().also(candidate::getBoundsInScreen).let { it.width().toLong() * it.height() }
|
||||
} ?: run {
|
||||
lastPurchaseSwipeFailure = PurchaseSwipeFailureReason.NO_SCROLLABLE
|
||||
return false
|
||||
}
|
||||
return swipeNode(target, direction, durationMs, preferScrollAction = false,
|
||||
onFailure = { lastPurchaseSwipeFailure = it })
|
||||
} ?: return false
|
||||
return swipeNode(target, direction, durationMs, preferScrollAction = false)
|
||||
}
|
||||
|
||||
override fun swipePurchaseIn(target: SnapshotNode, direction: SwipeDirection, durationMs: Long): Boolean {
|
||||
@@ -658,21 +646,16 @@ class GoAutoAccessibilityService : AccessibilityService(), UiDriver, PddCollecto
|
||||
preferScrollAction: Boolean = true,
|
||||
downStartPercent: Int = 25,
|
||||
downEndPercent: Int = 75,
|
||||
onFailure: (PurchaseSwipeFailureReason) -> Unit = {},
|
||||
): Boolean {
|
||||
fun failed(reason: PurchaseSwipeFailureReason): Boolean {
|
||||
onFailure(reason)
|
||||
return false
|
||||
}
|
||||
val bounds = Rect().also(node::getBoundsInScreen)
|
||||
if (bounds.width() < 2 || bounds.height() < 2) return failed(PurchaseSwipeFailureReason.INVALID_BOUNDS)
|
||||
if (bounds.width() < 2 || bounds.height() < 2) return false
|
||||
val scrollAction = if (direction == SwipeDirection.UP || direction == SwipeDirection.LEFT) {
|
||||
AccessibilityNodeInfo.ACTION_SCROLL_FORWARD
|
||||
} else {
|
||||
AccessibilityNodeInfo.ACTION_SCROLL_BACKWARD
|
||||
}
|
||||
if (preferScrollAction && node.performAction(scrollAction)) return true
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) return failed(PurchaseSwipeFailureReason.GESTURE_UNSUPPORTED)
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) return false
|
||||
val left = bounds.left + bounds.width() * 25 / 100
|
||||
val right = bounds.left + bounds.width() * 75 / 100
|
||||
val top = bounds.top + bounds.height() * 25 / 100
|
||||
@@ -710,9 +693,8 @@ class GoAutoAccessibilityService : AccessibilityService(), UiDriver, PddCollecto
|
||||
},
|
||||
null,
|
||||
)
|
||||
if (!queued) return failed(PurchaseSwipeFailureReason.GESTURE_REJECTED)
|
||||
if (!latch.await(1500, TimeUnit.MILLISECONDS)) return failed(PurchaseSwipeFailureReason.GESTURE_TIMEOUT)
|
||||
return completed.get() || failed(PurchaseSwipeFailureReason.GESTURE_CANCELLED)
|
||||
if (!queued) return false
|
||||
return latch.await(1500, TimeUnit.MILLISECONDS) && completed.get()
|
||||
}
|
||||
|
||||
private fun dispatchCenterTap(bounds: Rect): Boolean {
|
||||
|
||||
-58
@@ -1,58 +0,0 @@
|
||||
package cn.ilapage.goauto.agent.automation
|
||||
|
||||
/** Complete temporary collections only; caller retains the existing task lock. */
|
||||
internal class PddCollectionReturnNavigator(
|
||||
private val driver: PddCollectorDriver,
|
||||
private val openAgent: () -> Boolean,
|
||||
private val currentPackage: () -> String?,
|
||||
private val pause: (Long) -> Unit = Thread::sleep,
|
||||
) {
|
||||
var reason = "not_started"
|
||||
private set
|
||||
var actions = 0
|
||||
private set
|
||||
|
||||
fun returnToAgent(count: Int): Boolean {
|
||||
actions = 0
|
||||
if (count !in 1..5) { reason = "invalid_count"; return false }
|
||||
try {
|
||||
reason = "count_completed"
|
||||
for (step in 1..count) {
|
||||
if (currentPackage() != PDD) { reason = "left_pdd"; break }
|
||||
val screen = driver.capture()
|
||||
if (screen.packageName != PDD) { reason = "left_pdd"; break }
|
||||
if (screen.activityName.isNullOrBlank() || unsafe(screen)) {
|
||||
reason = "unsafe_page"
|
||||
return false
|
||||
}
|
||||
// Recheck after capturing: never send Back to another foreground app.
|
||||
if (currentPackage() != PDD) { reason = "left_pdd"; break }
|
||||
actions++
|
||||
if (!driver.back()) { reason = "back_failed"; break }
|
||||
pause(500)
|
||||
}
|
||||
// Retain risk scenes even if the last Back lands on one.
|
||||
if (currentPackage() == PDD && unsafe(driver.capture())) {
|
||||
reason = "unsafe_page"
|
||||
return false
|
||||
}
|
||||
if (!openAgent()) { reason = "agent_launch_failed"; return false }
|
||||
pause(500)
|
||||
if (currentPackage() != AGENT) { reason = "agent_not_confirmed"; return false }
|
||||
return true
|
||||
} catch (_: Exception) {
|
||||
reason = "navigation_exception"
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private fun unsafe(screen: UiSnapshot): Boolean =
|
||||
PddPageClassifier.classify(screen.packageName, screen.activityName,
|
||||
screen.nodes.filter { it.visible }.map { it.label }) != null ||
|
||||
screen.nodes.any { it.visible && listOf("订单详情", "订单待支付", "确认付款", "立即支付", "编辑收货地址", "新增收货地址", "地址管理").any(it.label::contains) }
|
||||
|
||||
companion object {
|
||||
private const val PDD = "com.xunmeng.pinduoduo"
|
||||
private const val AGENT = "cn.ilapage.goauto.agent"
|
||||
}
|
||||
}
|
||||
+2
-22
@@ -186,7 +186,7 @@ object PddScreenParser {
|
||||
// purchase/order/payment controls must never become collection click targets.
|
||||
private val nonConfigurableClickDenylist = listOf("提交订单", "确认订单", "支付", "付款")
|
||||
|
||||
fun parse(snapshot: UiSnapshot, config: PddCollectorConfig, goodsId: String, evidence: PageEvidence?, purchaseContext: PurchasePanelContext? = null, collectionContext: Boolean = false, purchaseEntryContext: Boolean = false): ParsedPddScreen {
|
||||
fun parse(snapshot: UiSnapshot, config: PddCollectorConfig, goodsId: String, evidence: PageEvidence?, purchaseContext: PurchasePanelContext? = null): ParsedPddScreen {
|
||||
val visibleNodes = snapshot.nodes.filter { it.visible }
|
||||
val visible = visibleNodes.mapNotNull { node ->
|
||||
val descendants = descendants(node, visibleNodes)
|
||||
@@ -323,32 +323,12 @@ object PddScreenParser {
|
||||
(headings.size >= 2 || (continuedPurchasePanel && headings.isNotEmpty())) &&
|
||||
dimensions.any { it.values.isNotEmpty() } && hasClose &&
|
||||
hasQuantityControls && hasPaymentArea && hasOrderSubmitAction
|
||||
// Collection can start before any selection summary exists and with only
|
||||
// one dimension visible. Keep this exception out of purchase parsing.
|
||||
val singleDimensionCollectionPanel = collectionContext && purchaseContext == null &&
|
||||
snapshot.packageName == PDD_PACKAGE && problem == null && !hasSelectionSummary &&
|
||||
evidence != null && snapshot.packageName == evidence.packageName &&
|
||||
snapshot.activityName == evidence.activityName &&
|
||||
snapshot.nodes.any { it.visible && it.matches(evidence.selector) } &&
|
||||
boundedScrollables.size == 1 && headedPanelScrollable != null && headings.size == 1 &&
|
||||
dimensions.size == 1 && dimensions.single().values.isNotEmpty() &&
|
||||
hasClose && hasQuantityControls && hasPaymentArea && hasOrderSubmitAction
|
||||
// Only the executor that just activated a safe entry on this same
|
||||
// product Activity can enable this first-view purchase exception.
|
||||
val singleDimensionPurchasePanel = purchaseEntryContext && !collectionContext &&
|
||||
snapshot.packageName == PDD_PACKAGE && !snapshot.activityName.isNullOrBlank() &&
|
||||
goodsId.isNotBlank() && problem == null && !hasSelectionSummary &&
|
||||
boundedScrollables.size == 1 && headedPanelScrollable != null && headings.size == 1 &&
|
||||
dimensions.size == 1 && dimensions.single().values.isNotEmpty() &&
|
||||
hasClose && hasQuantityControls && hasPaymentArea && hasOrderSubmitAction
|
||||
// Some selected-spec panels omit both the "已选" prefix and a confirm
|
||||
// button. Keep the same structural evidence required for checkout.
|
||||
val specPanelType = when {
|
||||
quickConfirmationEvidence -> SpecPanelType.QUICK_CONFIRMATION
|
||||
orderConfirmationEvidence -> SpecPanelType.ORDER_CONFIRMATION
|
||||
structuredSelectionPanel -> SpecPanelType.NORMAL_SCROLLABLE
|
||||
singleDimensionCollectionPanel -> SpecPanelType.NORMAL_SCROLLABLE
|
||||
singleDimensionPurchasePanel -> SpecPanelType.NORMAL_SCROLLABLE
|
||||
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
|
||||
@@ -1785,7 +1765,7 @@ class PddProductDetailCollector(
|
||||
}
|
||||
|
||||
private fun parse(goodsId: String, config: PddCollectorConfig, evidence: PageEvidence) =
|
||||
PddScreenParser.parse(driver.capture(), config, goodsId, evidence, collectionContext = true)
|
||||
PddScreenParser.parse(driver.capture(), config, goodsId, evidence)
|
||||
|
||||
private fun screenSignature(screen: ParsedPddScreen): List<String> = screen.sourceNodes.asSequence()
|
||||
.filter(SnapshotNode::visible)
|
||||
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
package cn.ilapage.goauto.agent.automation
|
||||
|
||||
import android.content.ContentValues
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.provider.MediaStore
|
||||
import cn.ilapage.goauto.agent.network.ImageSearchImage
|
||||
import java.security.MessageDigest
|
||||
import java.util.UUID
|
||||
|
||||
data class PreparedImageSearchAsset(val uri: Uri)
|
||||
|
||||
object ImageSearchAssetPolicy {
|
||||
private const val MAX_BYTES = 10 * 1024 * 1024
|
||||
fun valid(bytes: ByteArray, reference: ImageSearchImage): Boolean {
|
||||
if (bytes.isEmpty() || bytes.size.toLong() != reference.sizeBytes || bytes.size > MAX_BYTES) return false
|
||||
if (reference.mediaType != "image/jpeg" && reference.mediaType != "image/png") return false
|
||||
return sha256(bytes) == reference.sha256.lowercase()
|
||||
}
|
||||
fun sha256(bytes: ByteArray): String = MessageDigest.getInstance("SHA-256").digest(bytes).joinToString("") { "%02x".format(it) }
|
||||
}
|
||||
|
||||
class PinduoduoImageSearchAssetStore(context: Context) {
|
||||
private val resolver = context.applicationContext.contentResolver
|
||||
|
||||
fun prepare(bytes: ByteArray, reference: ImageSearchImage): PreparedImageSearchAsset? {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q || !ImageSearchAssetPolicy.valid(bytes, reference)) return null
|
||||
val values = ContentValues().apply {
|
||||
put(MediaStore.Images.Media.DISPLAY_NAME, "goauto-search-${UUID.randomUUID()}.jpg")
|
||||
put(MediaStore.Images.Media.MIME_TYPE, "image/jpeg")
|
||||
put(MediaStore.Images.Media.RELATIVE_PATH, "Pictures/GoAutoSearch")
|
||||
put(MediaStore.Images.Media.IS_PENDING, 1)
|
||||
}
|
||||
val uri = resolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, values) ?: return null
|
||||
return try {
|
||||
resolver.openOutputStream(uri)?.use { it.write(bytes) } ?: error("image output unavailable")
|
||||
resolver.update(uri, ContentValues().apply { put(MediaStore.Images.Media.IS_PENDING, 0) }, null, null)
|
||||
PreparedImageSearchAsset(uri)
|
||||
} catch (_: Exception) { resolver.delete(uri, null, null); null }
|
||||
}
|
||||
|
||||
fun delete(asset: PreparedImageSearchAsset?) { asset?.let { resolver.delete(it.uri, null, null) } }
|
||||
}
|
||||
+23
-202
@@ -32,12 +32,8 @@ class PurchaseLiveException(val code: String, message: String, val actualUnitPri
|
||||
class PurchaseLiveAutomation(
|
||||
private val driver: PurchaseUiDriver,
|
||||
private val pause: (Long) -> Unit = Thread::sleep,
|
||||
private val diagnostic: (String) -> Unit = {},
|
||||
) {
|
||||
private var submitAttempted = false
|
||||
private var savedAddressProof: ShippingAddressProof? = null
|
||||
private var savedAddressActivity: String? = null
|
||||
private var structuralSubmitAnchor: SnapshotNode? = null
|
||||
var lastOrderReadFailure: PurchaseOrderReadFailure? = null
|
||||
private set
|
||||
|
||||
@@ -127,9 +123,7 @@ class PurchaseLiveAutomation(
|
||||
}
|
||||
pause(1_000)
|
||||
snapshot = waitFor("PURCHASE_ADDRESS_PANEL_TIMEOUT", "收货地址页面打开超时,未创建订单") {
|
||||
it.packageName == PDD_PACKAGE &&
|
||||
it.nodes.any { node -> node.visible && node.label.replace(" ", "") == "收货地址" } &&
|
||||
addressModifyTargets(it).size == 1
|
||||
it.nodes.any { node -> node.visible && node.label.replace(" ", "") == "收货地址" }
|
||||
}
|
||||
return editAndVerifyAddress(snapshot, addressSuffix)
|
||||
}
|
||||
@@ -307,11 +301,10 @@ class PurchaseLiveAutomation(
|
||||
private fun nodeArea(node: SnapshotNode): Long = node.bounds.width.toLong() * node.bounds.height
|
||||
|
||||
fun finalConfirmation(input: PurchaseExecutionInput, address: ShippingAddressProof): FinalConfirmationEvidence {
|
||||
structuralSubmitAnchor = null
|
||||
val snapshot = driver.capture()
|
||||
pageProblem(snapshot)
|
||||
if (snapshot.packageName != PDD_PACKAGE) fail("PURCHASE_CONFIRMATION_LOST", "最终提交前页面已经变化,禁止创建订单")
|
||||
if (!hasSavedAddressEvidence(snapshot, address.expectedAddress, address.suffix)) {
|
||||
if (!hasFinalSavedAddressEvidence(snapshot, address.expectedAddress, address.suffix)) {
|
||||
fail("PURCHASE_ADDRESS_UPDATE_FAILED", "收货地址保存后复核失败,未创建订单")
|
||||
}
|
||||
val submit = finalSubmitTargets(snapshot)
|
||||
@@ -325,7 +318,6 @@ class PurchaseLiveAutomation(
|
||||
if (price !in input.minUnitPriceCent..input.maxUnitPriceCent) fail("PURCHASE_PRICE_OUT_OF_RANGE", "当前商品单价超出允许范围", price)
|
||||
val quantities = snapshot.nodes.filter { it.visible && it.enabled && it.className?.endsWith("EditText") == true }.mapNotNull { it.label.toLongOrNull() }
|
||||
if (quantities.singleOrNull() != input.quantity) fail("PURCHASE_QUANTITY_MISMATCH", "创建订单前数量复核失败")
|
||||
structuralSubmitAnchor = submit.single().takeIf { legacySubmitTargets(snapshot).isEmpty() }
|
||||
return FinalConfirmationEvidence(input.goodsId, input.mappedColor, input.mappedSize, input.quantity, price, address.suffix, snapshot.activityName.orEmpty())
|
||||
}
|
||||
|
||||
@@ -337,18 +329,6 @@ class PurchaseLiveAutomation(
|
||||
if (snapshot.packageName != PDD_PACKAGE || targets.size != 1) {
|
||||
fail("PURCHASE_SUBMIT_TARGET_AMBIGUOUS", "最终页面或创建订单按钮已变化,禁止创建订单")
|
||||
}
|
||||
if (legacySubmitTargets(snapshot).isEmpty() && structuralSubmitAnchor == null) {
|
||||
fail("PURCHASE_SUBMIT_TARGET_AMBIGUOUS", "下单按钮尚未完成最终复核,禁止创建订单")
|
||||
}
|
||||
structuralSubmitAnchor?.let { anchor ->
|
||||
val fresh = targets.single()
|
||||
if (fresh.path != anchor.path || fresh.className != anchor.className || fresh.label != anchor.label ||
|
||||
kotlin.math.abs(fresh.bounds.left - anchor.bounds.left) > 32 ||
|
||||
kotlin.math.abs(fresh.bounds.top - anchor.bounds.top) > 32 ||
|
||||
kotlin.math.abs(fresh.bounds.right - anchor.bounds.right) > 32 ||
|
||||
kotlin.math.abs(fresh.bounds.bottom - anchor.bounds.bottom) > 32
|
||||
) fail("PURCHASE_SUBMIT_TARGET_AMBIGUOUS", "最终下单按钮已变化,禁止创建订单")
|
||||
}
|
||||
submitAttempted = true
|
||||
when (driver.clickFresh(targets.single())) {
|
||||
FreshActionResult.SUCCESS -> Unit
|
||||
@@ -486,13 +466,12 @@ class PurchaseLiveAutomation(
|
||||
snapshot.packageName == PDD_PACKAGE && snapshot.activityName in PDD_PAYMENT_ACTIVITIES
|
||||
|
||||
private fun editAndVerifyAddress(panel: UiSnapshot, suffix: String): ShippingAddressProof {
|
||||
savedAddressProof = null
|
||||
savedAddressActivity = null
|
||||
structuralSubmitAnchor = null
|
||||
val modify = addressModifyTargets(panel)
|
||||
if (modify.size != 1) fail("PURCHASE_ADDRESS_EDIT_AMBIGUOUS", "没有找到唯一的地址修改按钮,未创建订单")
|
||||
click(modify.single(), "修改地址", 1_000)
|
||||
val edit = waitForAddressEditorReady()
|
||||
click(modify.single(), "修改地址")
|
||||
val edit = waitFor("PURCHASE_ADDRESS_EDIT_TIMEOUT", "地址编辑页面打开超时,未创建订单") { snapshot ->
|
||||
snapshot.nodes.any { it.visible && it.label.replace(" ", "").contains("详细地址") }
|
||||
}
|
||||
val editors = shippingAddressEditors(edit)
|
||||
if (editors.size != 1) fail("PURCHASE_ADDRESS_UPDATE_FAILED", "没有找到唯一的详细地址输入框,未创建订单")
|
||||
val current = editors.single().label.trim()
|
||||
@@ -505,30 +484,19 @@ class PurchaseLiveAutomation(
|
||||
}
|
||||
val save = uniqueClickable(stable, stable.nodes.filter { it.visible && it.enabled && it.label == "保存" })
|
||||
if (save.size != 1) fail("PURCHASE_ADDRESS_UPDATE_FAILED", "地址保存按钮不唯一,未创建订单")
|
||||
click(save.single(), "保存地址", 1_000)
|
||||
var savedEvidence = waitForSavedConfirmation(waitForStableAddressEditorExit(), expected, suffix)
|
||||
if (!savedAddressPageReady(savedEvidence, expected, suffix)) {
|
||||
click(save.single(), "保存地址")
|
||||
val savedEvidence = waitForStableAddressEditorExit()
|
||||
if (!hasFinalSavedAddressEvidence(savedEvidence, expected, suffix)) {
|
||||
if (isPurchaseConfirmationPanel(savedEvidence)) {
|
||||
savedEvidence = restoreFinalEvidenceInCurrentPanel(savedEvidence, expected, suffix)
|
||||
restoreFinalEvidenceInCurrentPanel(savedEvidence, expected, suffix)
|
||||
} else {
|
||||
if (!driver.backPurchase()) fail("PURCHASE_ADDRESS_UPDATE_FAILED", "地址保存后无法返回订单页面,未创建订单")
|
||||
pause(1_000)
|
||||
val returned = waitFor("PURCHASE_ADDRESS_SAVE_TIMEOUT", "地址保存后无法返回订单页面,未创建订单") {
|
||||
savedAddressPageReady(it, expected, suffix) || isPurchaseConfirmationPanel(it)
|
||||
waitFor("PURCHASE_ADDRESS_SAVE_TIMEOUT", "地址保存后无法返回订单页面,未创建订单") {
|
||||
hasFinalSavedAddressEvidence(it, expected, suffix)
|
||||
}
|
||||
val settled = waitForSavedConfirmation(returned, expected, suffix)
|
||||
savedEvidence = if (!savedAddressPageReady(settled, expected, suffix)) {
|
||||
restoreFinalEvidenceInCurrentPanel(settled, expected, suffix)
|
||||
} else settled
|
||||
}
|
||||
}
|
||||
val proof = ShippingAddressProof(expected, suffix)
|
||||
savedAddressProof = proof
|
||||
savedAddressActivity = savedEvidence.activityName
|
||||
if (finalSubmitTargets(savedEvidence).size != 1) {
|
||||
fail("PURCHASE_SUBMIT_TARGET_AMBIGUOUS", "地址已保存,但未能识别唯一的下单按钮,未创建订单")
|
||||
}
|
||||
return proof
|
||||
return ShippingAddressProof(expected, suffix)
|
||||
}
|
||||
|
||||
private fun isPurchaseConfirmationPanel(snapshot: UiSnapshot): Boolean {
|
||||
@@ -541,56 +509,21 @@ class PurchaseLiveAutomation(
|
||||
)
|
||||
}
|
||||
|
||||
/** Wait for late address/footer nodes before deciding to scroll. No device actions here. */
|
||||
private fun waitForSavedConfirmation(initial: UiSnapshot, expected: String, suffix: String): UiSnapshot {
|
||||
var snapshot = initial
|
||||
var readyActivity: String? = null
|
||||
repeat(50) { poll ->
|
||||
pageProblem(snapshot)
|
||||
// A stable address list needs the existing single Back, not a footer wait.
|
||||
if (snapshot.nodes.any { it.visible && it.label == "收货地址" } &&
|
||||
addressModifyTargets(snapshot).size == 1) return snapshot
|
||||
val ready = savedAddressPageReady(snapshot, expected, suffix)
|
||||
if (ready && readyActivity != null && readyActivity == snapshot.activityName) {
|
||||
savedConfirmationDiagnostic(snapshot, expected, suffix, poll + 1)
|
||||
return snapshot
|
||||
}
|
||||
readyActivity = if (ready) snapshot.activityName else null
|
||||
if (poll < 49) {
|
||||
pause(200)
|
||||
snapshot = driver.capture()
|
||||
}
|
||||
}
|
||||
savedConfirmationDiagnostic(snapshot, expected, suffix, 50)
|
||||
return snapshot
|
||||
}
|
||||
|
||||
private fun savedConfirmationDiagnostic(snapshot: UiSnapshot, expected: String, suffix: String, polls: Int) {
|
||||
val address = resolveAddressEntries(snapshot)
|
||||
diagnostic("savedAddressMatched=${hasSavedAddressEvidence(snapshot, expected, suffix)};" +
|
||||
"savedPanelMatched=${savedPurchasePanel(snapshot, expected, suffix) != null};" +
|
||||
"savedLegacySubmitCandidates=${legacySubmitTargets(snapshot).size};" +
|
||||
"savedAddressCards=${address.addressCardCount};savedAddressTargets=${address.tapTargetCount};" +
|
||||
"savedScrollCandidates=${savedAddressPanelScrollTargets(snapshot).size};savedConfirmationPolls=$polls")
|
||||
}
|
||||
|
||||
private fun restoreFinalEvidenceInCurrentPanel(
|
||||
initial: UiSnapshot,
|
||||
expected: String,
|
||||
suffix: String,
|
||||
): UiSnapshot {
|
||||
) {
|
||||
var snapshot = initial
|
||||
var previousSignature: String? = null
|
||||
repeat(ADDRESS_CONFIRMATION_SCROLL_LIMIT) { attempt ->
|
||||
if (savedAddressPageReady(snapshot, expected, suffix)) return snapshot
|
||||
if (hasFinalSavedAddressEvidence(snapshot, expected, suffix)) return
|
||||
if (!isPurchaseConfirmationPanel(snapshot)) {
|
||||
fail("PURCHASE_ADDRESS_SAVE_TIMEOUT", "地址保存后采购面板发生变化,未创建订单")
|
||||
}
|
||||
val panels = savedAddressPanelScrollTargets(snapshot)
|
||||
val panels = purchasePanelScrollTargets(snapshot)
|
||||
if (panels.size != 1) {
|
||||
savedConfirmationDiagnostic(snapshot, expected, suffix, 0)
|
||||
val reason = if (panels.isEmpty()) "未找到可用的规格面板滚动区域" else "找到多个规格面板滚动区域"
|
||||
fail("PURCHASE_ADDRESS_ENTRY_AMBIGUOUS", "地址保存后${reason},未创建订单")
|
||||
fail("PURCHASE_ADDRESS_ENTRY_AMBIGUOUS", "地址保存后没有找到唯一的规格面板滚动区域,未创建订单")
|
||||
}
|
||||
val panel = panels.single()
|
||||
val signature = viewportSignature(snapshot, panel)
|
||||
@@ -605,10 +538,9 @@ class PurchaseLiveAutomation(
|
||||
snapshot = driver.capture()
|
||||
pageProblem(snapshot)
|
||||
}
|
||||
if (!savedAddressPageReady(snapshot, expected, suffix)) {
|
||||
if (!hasFinalSavedAddressEvidence(snapshot, expected, suffix)) {
|
||||
fail("PURCHASE_ADDRESS_SAVE_TIMEOUT", "地址保存后规格面板未找到完整订单确认信息,未创建订单")
|
||||
}
|
||||
return snapshot
|
||||
}
|
||||
|
||||
private fun hasSavedAddressEvidence(snapshot: UiSnapshot, expected: String, suffix: String): Boolean {
|
||||
@@ -625,9 +557,8 @@ class PurchaseLiveAutomation(
|
||||
return suffixMatches.size == 1
|
||||
}
|
||||
|
||||
private fun savedAddressPageReady(snapshot: UiSnapshot, expected: String, suffix: String): Boolean =
|
||||
hasSavedAddressEvidence(snapshot, expected, suffix) &&
|
||||
(legacySubmitTargets(snapshot).isNotEmpty() || savedPurchasePanel(snapshot, expected, suffix) != null)
|
||||
private fun hasFinalSavedAddressEvidence(snapshot: UiSnapshot, expected: String, suffix: String): Boolean =
|
||||
hasSavedAddressEvidence(snapshot, expected, suffix) && finalSubmitTargets(snapshot).size == 1
|
||||
|
||||
private fun waitForStableAddressEditorExit(): UiSnapshot {
|
||||
var consecutiveExits = 0
|
||||
@@ -660,20 +591,6 @@ class PurchaseLiveAutomation(
|
||||
fail(code, message)
|
||||
}
|
||||
|
||||
private fun savedAddressPanelScrollTargets(snapshot: UiSnapshot): List<SnapshotNode> {
|
||||
val panel = PddScreenParser.parse(snapshot, PurchaseRehearsalExecutor.DEFAULT_COLLECTOR, "", null)
|
||||
.specPanelContainer ?: return purchasePanelScrollTargets(snapshot)
|
||||
val screenWidth = snapshot.nodes.maxOfOrNull { it.bounds.right }?.coerceAtLeast(1) ?: 1
|
||||
val screenHeight = snapshot.nodes.maxOfOrNull { it.bounds.bottom }?.coerceAtLeast(1) ?: 1
|
||||
return snapshot.nodes.filter { node ->
|
||||
node.visible && node.enabled && node.scrollable &&
|
||||
node.bounds.width * 100 >= screenWidth * 60 &&
|
||||
node.bounds.height * 100 >= screenHeight * 20 &&
|
||||
node.bounds.left >= panel.bounds.left && node.bounds.right <= panel.bounds.right &&
|
||||
node.bounds.top >= panel.bounds.top && node.bounds.bottom <= panel.bounds.bottom
|
||||
}.distinctBy { it.path }
|
||||
}
|
||||
|
||||
private fun purchasePanelScrollTargets(snapshot: UiSnapshot): List<SnapshotNode> {
|
||||
val screenWidth = snapshot.nodes.maxOfOrNull { it.bounds.right }?.coerceAtLeast(1) ?: 1
|
||||
val screenHeight = snapshot.nodes.maxOfOrNull { it.bounds.bottom }?.coerceAtLeast(1) ?: 1
|
||||
@@ -752,113 +669,20 @@ class PurchaseLiveAutomation(
|
||||
}.distinctBy { it.path }
|
||||
}
|
||||
|
||||
private fun waitForAddressEditorReady(): UiSnapshot {
|
||||
var previous: SnapshotNode? = null
|
||||
var previousActivity: String? = null
|
||||
var count = 0
|
||||
repeat(50) { poll ->
|
||||
val snapshot = driver.capture()
|
||||
pageProblem(snapshot)
|
||||
val editors = if (snapshot.packageName == PDD_PACKAGE) shippingAddressEditors(snapshot) else emptyList()
|
||||
count = editors.size
|
||||
diagnostic("addressEditorCandidates=$count;addressEditorPolls=${poll + 1}")
|
||||
val editor = editors.singleOrNull()
|
||||
if (editor != null && previous != null && snapshot.activityName == previousActivity &&
|
||||
editor.path == previous!!.path && editor.bounds == previous!!.bounds && editor.label == previous!!.label) return snapshot
|
||||
previous = editor
|
||||
previousActivity = snapshot.activityName
|
||||
pause(200)
|
||||
}
|
||||
fail("PURCHASE_ADDRESS_UPDATE_FAILED", "详细地址输入框未稳定就绪,未创建订单 [candidates=$count]")
|
||||
}
|
||||
|
||||
private fun click(node: SnapshotNode, label: String, settleMillis: Long = 500) {
|
||||
private fun click(node: SnapshotNode, label: String) {
|
||||
when (driver.clickFresh(node)) {
|
||||
FreshActionResult.SUCCESS -> Unit
|
||||
FreshActionResult.AMBIGUOUS -> fail("RULE_AMBIGUOUS", "$label 不唯一")
|
||||
else -> fail("RULE_ACTION_FAILED", "$label 点击失败")
|
||||
}
|
||||
pause(settleMillis)
|
||||
pause(500)
|
||||
}
|
||||
|
||||
private fun legacySubmitTargets(snapshot: UiSnapshot): List<SnapshotNode> = uniqueClickable(
|
||||
private fun finalSubmitTargets(snapshot: UiSnapshot): List<SnapshotNode> = uniqueClickable(
|
||||
snapshot,
|
||||
snapshot.nodes.filter { node -> node.visible && node.enabled && FINAL_SUBMIT_MARKERS.any { node.label == it || node.label.startsWith(it) } },
|
||||
)
|
||||
|
||||
/** Only the just-saved address flow may use a footer whose display copy is unknown. */
|
||||
private fun finalSubmitTargets(snapshot: UiSnapshot): List<SnapshotNode> {
|
||||
val legacy = legacySubmitTargets(snapshot)
|
||||
if (legacy.isNotEmpty()) return legacy
|
||||
val proof = savedAddressProof ?: return emptyList()
|
||||
if (snapshot.activityName != savedAddressActivity) return emptyList()
|
||||
val panel = savedPurchasePanel(snapshot, proof.expectedAddress, proof.suffix) ?: return emptyList()
|
||||
val visible = snapshot.nodes.filter { it.visible }
|
||||
val byPath = visible.associateBy(SnapshotNode::path)
|
||||
val candidates = visible.filter { node ->
|
||||
node.path.startsWith("${panel.path}/") && node.enabled && node.clickable && !node.scrollable &&
|
||||
node.bounds.width >= panel.bounds.width / 5 && node.bounds.height >= 2 &&
|
||||
node.bounds.height <= panel.bounds.height / 4 &&
|
||||
node.bounds.bottom <= panel.bounds.bottom &&
|
||||
node.bounds.bottom >= panel.bounds.bottom - panel.bounds.height / 20 &&
|
||||
node.bounds.left >= panel.bounds.left && node.bounds.right <= panel.bounds.right
|
||||
}.filter { node ->
|
||||
// A spec option at the scroll edge is not a fixed footer action.
|
||||
var parent = node.parentPath?.let(byPath::get)
|
||||
var inScroller = false
|
||||
while (parent != null && parent.path != panel.path) {
|
||||
if (parent.scrollable) inScroller = true
|
||||
parent = parent.parentPath?.let(byPath::get)
|
||||
}
|
||||
!inScroller && parent?.path == panel.path
|
||||
}
|
||||
val independent = candidates.filter { parent ->
|
||||
candidates.none { it.path.startsWith("${parent.path}/") }
|
||||
}
|
||||
// Do not silently choose one of several footer actions by text or price.
|
||||
if (independent.size != 1) return emptyList()
|
||||
val button = independent.single()
|
||||
val content = visible.filter { it.path == button.path || it.path.startsWith("${button.path}/") }
|
||||
val labels = content.map { it.label.replace(" ", "") }
|
||||
if (labels.any { label -> STRUCTURAL_ACTION_DENY.any(label::contains) } ||
|
||||
labels.none { FOOTER_PRICE.containsMatchIn(it) } ||
|
||||
labels.none { FOOTER_PRICE.replace(it, "").any(Char::isLetter) }
|
||||
) return emptyList()
|
||||
// Keep the original text anchor; clickFresh reacquires it and its nearest clickable parent.
|
||||
return listOfNotNull(content.firstOrNull {
|
||||
it.enabled && it.label.isNotBlank() && nearestClickableAncestor(it, byPath)?.path == button.path
|
||||
})
|
||||
}
|
||||
|
||||
private fun savedPurchasePanel(snapshot: UiSnapshot, expected: String, suffix: String): SnapshotNode? {
|
||||
if (snapshot.packageName != PDD_PACKAGE || snapshot.activityName.isNullOrBlank() ||
|
||||
isKnownPddPaymentActivity(snapshot) || !hasSavedAddressEvidence(snapshot, expected, suffix)
|
||||
) return null
|
||||
val visible = snapshot.nodes.filter { it.visible }
|
||||
if (visible.any { node -> STRUCTURAL_PAGE_DENY.any(node.label::contains) }) return null
|
||||
val resolution = resolveAddressEntries(snapshot)
|
||||
if (resolution.addressCardCount != 1 || resolution.tapTargetCount != 1) return null
|
||||
val address = resolution.target?.node ?: return null
|
||||
val quantities = visible.filter { it.enabled && it.className?.endsWith("EditText") == true && (it.label.toLongOrNull() ?: 0) > 0 }
|
||||
val summaries = visible.filter { it.label.replace(" ", "").startsWith("已选") }
|
||||
val evidence = quantities.singleOrNull() ?: summaries.singleOrNull() ?: return null
|
||||
val byPath = visible.associateBy(SnapshotNode::path)
|
||||
val screenHeight = visible.maxOfOrNull { it.bounds.bottom } ?: return null
|
||||
val screenWidth = visible.maxOfOrNull { it.bounds.right } ?: return null
|
||||
var common = address.parentPath?.let(byPath::get)
|
||||
while (common != null) {
|
||||
if (evidence.path.startsWith("${common.path}/")) {
|
||||
// Nearest common ancestor is the whole popup, not its inner spec RecyclerView.
|
||||
return common.takeIf {
|
||||
!it.scrollable && it.bounds.width >= screenWidth * 3 / 5 &&
|
||||
it.bounds.height > 0 && it.bounds.height < screenHeight * 95 / 100
|
||||
}
|
||||
}
|
||||
common = common.parentPath?.let(byPath::get)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
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
|
||||
@@ -922,9 +746,6 @@ class PurchaseLiveAutomation(
|
||||
const val WECHAT_PACKAGE = "com.tencent.mm"
|
||||
val MASKED_PHONE = Regex("(?<![0-9])[0-9]{3}\\*{4}[0-9]{4}(?![0-9])")
|
||||
val FINAL_SUBMIT_MARKERS = listOf("提交订单", "现在买,仅", "确认购买")
|
||||
val FOOTER_PRICE = Regex("(?:[¥¥]\\s*\\d+(?:\\.\\d{1,2})?|\\d+(?:\\.\\d{1,2})?\\s*元)")
|
||||
val STRUCTURAL_ACTION_DENY = listOf("支付", "付款", "先用后付", "收货", "地址", "修改", "保存", "取消", "返回", "关闭", "客服", "收藏", "订单详情", "查看订单")
|
||||
val STRUCTURAL_PAGE_DENY = listOf("立即支付", "确认支付", "输入支付密码", "待付款", "待支付", "订单编号", "订单号", "订单详情", "修改收货地址", "选择收货地址")
|
||||
val PAYMENT_MARKERS = listOf("立即支付", "确认支付", "输入支付密码")
|
||||
val UNPAID_MARKERS = listOf("待付款", "待支付", "去支付")
|
||||
val ORDER_DETAIL_ENTRY_MARKERS = setOf("查看订单", "订单详情")
|
||||
|
||||
+25
-155
@@ -3,11 +3,6 @@ package cn.ilapage.goauto.agent.automation
|
||||
import java.net.URI
|
||||
import java.net.URLDecoder
|
||||
|
||||
enum class PurchaseSwipeFailureReason {
|
||||
UNKNOWN, ROOT_UNAVAILABLE, NO_SCROLLABLE, INVALID_BOUNDS, GESTURE_UNSUPPORTED,
|
||||
GESTURE_REJECTED, GESTURE_CANCELLED, GESTURE_TIMEOUT,
|
||||
}
|
||||
|
||||
interface PurchaseUiDriver {
|
||||
fun capture(): UiSnapshot
|
||||
fun clickFresh(target: SnapshotNode): FreshActionResult
|
||||
@@ -37,7 +32,6 @@ interface PurchaseUiDriver {
|
||||
fun specRowSwipeFailureReason(): String = "gestureFailed"
|
||||
fun inputFresh(target: SnapshotNode, value: String): FreshActionResult
|
||||
fun swipePurchase(direction: SwipeDirection, durationMs: Long): Boolean
|
||||
fun purchaseSwipeFailureReason(): PurchaseSwipeFailureReason = PurchaseSwipeFailureReason.UNKNOWN
|
||||
fun swipePurchaseIn(target: SnapshotNode, direction: SwipeDirection, durationMs: Long): Boolean
|
||||
fun pullDownGoodsPage(): Boolean = swipePurchase(SwipeDirection.DOWN, 550)
|
||||
fun backPurchase(): Boolean
|
||||
@@ -69,7 +63,6 @@ data class PurchaseExecutionInput(
|
||||
val mappedColor: String,
|
||||
val mappedSize: String,
|
||||
val specResolutionAllowed: Boolean = false,
|
||||
val specRematchAllowed: Boolean = false,
|
||||
val quantity: Long,
|
||||
val minUnitPriceCent: Long,
|
||||
val maxUnitPriceCent: Long,
|
||||
@@ -94,24 +87,17 @@ class PurchaseRehearsalExecutor(
|
||||
private val stepChanged: (String) -> Unit = {},
|
||||
private val panelDiagnostic: (String) -> Unit = {},
|
||||
private val beforeOrderSubmit: (FinalConfirmationEvidence) -> Unit = { throw PurchaseLiveException("PURCHASE_MODE_NOT_ALLOWED", "当前执行器没有正式采购授权") },
|
||||
private val probeHandoffActivity: String? = null,
|
||||
private val onProbeHandoff: (String) -> Unit = {},
|
||||
private val rematchSpecs: ((PurchaseExecutionInput) -> PurchaseExecutionInput?)? = null,
|
||||
) {
|
||||
private var purchasePanelContext: PurchasePanelContext? = null
|
||||
private var pageIdentity: Pair<String?, String?> = null to null
|
||||
private var purchaseEntryIdentity: Pair<String?, String?>? = null
|
||||
private var entryRecoveryReason = "not_checked"
|
||||
|
||||
fun execute(input: PurchaseExecutionInput, rule: PurchaseRule, supportedCapabilities: Set<String>): PurchaseExecutionOutcome {
|
||||
purchasePanelContext = null
|
||||
purchaseEntryIdentity = null
|
||||
validateBeforeDeviceAction(input, rule, supportedCapabilities)?.let { return it }
|
||||
var observedPrice: Long? = null
|
||||
var addressProof: ShippingAddressProof? = null
|
||||
var irreversibleStarted = false
|
||||
val specSelectionProofs = mutableMapOf<String, ExactSpecSelectionProof>()
|
||||
val live = PurchaseLiveAutomation(driver, pause, panelDiagnostic)
|
||||
val live = PurchaseLiveAutomation(driver, pause)
|
||||
for (action in rule.actions) {
|
||||
// The immediate phase-two handoff can reuse the PDD page retained by
|
||||
// spec_probe. A later manual retry may start from Agent (or another
|
||||
@@ -129,14 +115,14 @@ class PurchaseRehearsalExecutor(
|
||||
PurchaseActionType.OPEN_SPEC_PANEL -> {
|
||||
openSpecPanel(input, action) ?: recoverSoldOut(input, closeSpecPanel = true) ?: openSpecPanel(input, action)
|
||||
}
|
||||
PurchaseActionType.SELECT_SPEC -> if (input.phase == "spec_probe") null else selectSpecsWithRematch(input, rule, specSelectionProofs)
|
||||
PurchaseActionType.SELECT_SPEC -> if (input.phase == "spec_probe") null else selectSpecs(input, rule, specSelectionProofs)
|
||||
PurchaseActionType.SET_QUANTITY -> if (input.phase == "spec_probe") null else setQuantity(input.quantity)
|
||||
PurchaseActionType.VERIFY_UNIT_PRICE -> if (input.phase == "spec_probe") null else verifyPrice(input).also {
|
||||
if (it == null) observedPrice = currentScreen(input).priceCent
|
||||
}
|
||||
PurchaseActionType.VERIFY_ORDER_SUMMARY -> if (input.phase == "spec_probe") null else verifySummary(input, observedPrice, specSelectionProofs)
|
||||
PurchaseActionType.PROBE_SPECS -> {
|
||||
if (input.phase == "spec_probe") return probeOutcome(input)
|
||||
if (input.phase == "spec_probe") return probeOutcome()
|
||||
null
|
||||
}
|
||||
PurchaseActionType.UPDATE_SHIPPING_ADDRESS -> try {
|
||||
@@ -181,7 +167,7 @@ class PurchaseRehearsalExecutor(
|
||||
input.specResolutionAllowed &&
|
||||
PurchaseActionType.PROBE_SPECS in rule.actions.map { it.type }
|
||||
) {
|
||||
return probeOutcome(input)
|
||||
return probeOutcome()
|
||||
}
|
||||
return failure
|
||||
}
|
||||
@@ -192,42 +178,10 @@ class PurchaseRehearsalExecutor(
|
||||
else failure("PURCHASE_RULE_INVALID", "正式采购规则缺少核单动作")
|
||||
}
|
||||
|
||||
private fun selectSpecsWithRematch(input: PurchaseExecutionInput, rule: PurchaseRule, proofs: MutableMap<String, ExactSpecSelectionProof>): PurchaseExecutionOutcome? {
|
||||
val first = selectSpecs(input, rule, proofs)
|
||||
if (first?.errorCode != SPEC_TARGET_NOT_VISIBLE || !input.specRematchAllowed || rematchSpecs == null) return first
|
||||
val refreshed = rematchSpecs.invoke(input) ?: return first
|
||||
panelDiagnostic("specRematch=accepted;pageReuse=true")
|
||||
proofs.clear()
|
||||
return selectSpecs(refreshed.copy(specRematchAllowed = false), rule, proofs)
|
||||
}
|
||||
|
||||
private fun canReuseCurrentProduct(input: PurchaseExecutionInput): Boolean {
|
||||
if (probeHandoffActivity == null) {
|
||||
val screen = currentScreen(input)
|
||||
val reuse = screen.problem == null && screen.hasPurchaseProductEvidence()
|
||||
panelDiagnostic("handoffPresent=false;productReused=$reuse;reusePolls=1;reuseReason=legacy_evidence")
|
||||
return reuse
|
||||
private fun canReuseCurrentProduct(input: PurchaseExecutionInput): Boolean =
|
||||
currentScreen(input).let { screen ->
|
||||
screen.problem == null && screen.hasPurchaseProductEvidence()
|
||||
}
|
||||
var consecutive = 0
|
||||
repeat(10) { poll ->
|
||||
purchaseEntryIdentity = PDD_PACKAGE to probeHandoffActivity
|
||||
val screen = currentScreen(input)
|
||||
if ((pageIdentity.first != null && pageIdentity != (PDD_PACKAGE to probeHandoffActivity)) || screen.problem != null) {
|
||||
purchaseEntryIdentity = null
|
||||
panelDiagnostic("handoffPresent=true;productReused=false;reusePolls=${poll + 1};reuseReason=${if (screen.problem != null) "page_problem" else "page_changed"}")
|
||||
return false
|
||||
}
|
||||
consecutive = if (screen.hasPurchaseProductEvidence()) consecutive + 1 else 0
|
||||
if (consecutive >= 2) {
|
||||
panelDiagnostic("handoffPresent=true;productReused=true;reusePolls=${poll + 1};reuseReason=handoff_ready")
|
||||
return true
|
||||
}
|
||||
if (poll < 9) pause(200)
|
||||
}
|
||||
purchaseEntryIdentity = null
|
||||
panelDiagnostic("handoffPresent=true;productReused=false;reusePolls=10;reuseReason=evidence_timeout")
|
||||
return false
|
||||
}
|
||||
|
||||
private fun validateBeforeDeviceAction(
|
||||
input: PurchaseExecutionInput,
|
||||
@@ -416,10 +370,7 @@ class PurchaseRehearsalExecutor(
|
||||
screen = currentScreen(input)
|
||||
}
|
||||
val beforeSignature = specActionSignature(screen)
|
||||
val beforeIdentity = pageIdentity
|
||||
val click = driver.clickFreshDetailed(requireNotNull(target))
|
||||
purchaseEntryIdentity = if (click.result == FreshActionResult.SUCCESS) beforeIdentity else null
|
||||
panelDiagnostic("entrySource=${screen.specEntrySource ?: "unknown"};entryClick=${click.result};entryReason=${click.reason}")
|
||||
when (click.result) {
|
||||
// The parser already narrowed to a single semantic candidate; the
|
||||
// ambiguity here comes from the live tree matching that target more
|
||||
@@ -434,22 +385,14 @@ class PurchaseRehearsalExecutor(
|
||||
var wait = waitForSpecPanel(input, beforeSignature)
|
||||
wait.failure?.let { return it }
|
||||
if (wait.opened) return null
|
||||
val refreshed = currentScreen(input)
|
||||
refreshed.problem?.let { return failure(it.code, it.message) }
|
||||
if (refreshed.reviewPageOpen) return leaveUnexpectedReviewPage(input)
|
||||
if (refreshed.specPanelOpen) return null
|
||||
val recoveryTarget = recoverableSpecEntry(screen, refreshed, action, beforeIdentity)
|
||||
if (recoveryTarget == null) {
|
||||
panelDiagnostic("entryRecovery=rejected;reason=$entryRecoveryReason;pageChanged=${wait.changed};${panelEvidence(refreshed)}")
|
||||
if (wait.changed) {
|
||||
return failure(
|
||||
SPEC_PANEL_EVIDENCE_NOT_MATCHED,
|
||||
"规格入口点击后页面已变化,但规格面板强证据不足 [${panelEvidence(wait.screen)}]",
|
||||
)
|
||||
}
|
||||
|
||||
val gesture = driver.tapSpecFresh(requireNotNull(recoveryTarget))
|
||||
panelDiagnostic("entryRecovery=attempted;gesture=$gesture;pageChanged=${wait.changed}")
|
||||
when (gesture) {
|
||||
when (driver.tapSpecFresh(requireNotNull(target))) {
|
||||
FreshActionResult.AMBIGUOUS -> return failure(
|
||||
SPEC_ENTRY_TARGET_AMBIGUOUS,
|
||||
"规格入口手势目标不唯一 [${specEntryEvidence(screen, 1, entryReadyWaitPolls)}]",
|
||||
@@ -463,8 +406,7 @@ class PurchaseRehearsalExecutor(
|
||||
wait = waitForSpecPanel(input, specActionSignature(wait.screen))
|
||||
wait.failure?.let { return it }
|
||||
if (wait.opened) return null
|
||||
if (wait.changed && recoverableSpecEntry(screen, wait.screen, action, beforeIdentity) == null) {
|
||||
panelDiagnostic("entryRecovery=rejected;reason=$entryRecoveryReason;pageChanged=${wait.changed};${panelEvidence(wait.screen)}")
|
||||
if (wait.changed) {
|
||||
return failure(
|
||||
SPEC_PANEL_EVIDENCE_NOT_MATCHED,
|
||||
"规格入口手势后页面已变化,但规格面板强证据不足 [${panelEvidence(wait.screen)}]",
|
||||
@@ -476,55 +418,6 @@ class PurchaseRehearsalExecutor(
|
||||
)
|
||||
}
|
||||
|
||||
// Whole-page animations are not proof of navigation. Recover only a freshly
|
||||
// parsed, unchanged semantic product entry; never reuse the old node/coordinates.
|
||||
private fun recoverableSpecEntry(
|
||||
before: ParsedPddScreen,
|
||||
after: ParsedPddScreen,
|
||||
action: PurchaseAction,
|
||||
identity: Pair<String?, String?>,
|
||||
): SnapshotNode? {
|
||||
entryRecoveryReason = when {
|
||||
identity.first == null || identity.second == null -> "identity_missing"
|
||||
identity != pageIdentity -> "identity_changed"
|
||||
!after.isPddPackage || !after.pageEvidenceMatched -> "product_context_missing"
|
||||
after.problem != null || after.reviewPageOpen -> "page_problem"
|
||||
after.specPanelOpen -> "panel_open"
|
||||
after.hasCloseControl -> "close_control"
|
||||
// A product-page payment-method promotion is not a checkout page.
|
||||
// Keep real order/payment/address evidence blocking the one entry retry.
|
||||
pageIdentity.second?.contains("pay", ignoreCase = true) == true ||
|
||||
after.sourceNodes.any { node -> node.visible && listOf(
|
||||
"立即支付", "确认支付", "去支付", "去付款", "支付密码", "收银台",
|
||||
"订单详情", "订单编号", "订单号", "待付款", "待支付", "确认订单",
|
||||
"提交订单", "收货地址", "收货信息",
|
||||
).any(node.label::contains) } -> "payment_area"
|
||||
after.hasOrderSubmitAction -> "order_action"
|
||||
after.hasQuantityControls -> "quantity_controls"
|
||||
after.hasSelectionSummary -> "selection_summary"
|
||||
before.summary.title.isNullOrBlank() -> "title_missing"
|
||||
before.summary.title != after.summary.title -> "title_changed"
|
||||
after.specEntry == null -> "entry_missing"
|
||||
before.specEntrySource == null -> "entry_source_missing"
|
||||
before.specEntrySource != after.specEntrySource -> "entry_source_changed"
|
||||
before.specEntry?.label != after.specEntry.label -> "entry_label_changed"
|
||||
after.explicitSpecEntryCount > 1 || after.nestedSpecEntryCount > 1 -> "entry_ambiguous"
|
||||
else -> "eligible"
|
||||
}
|
||||
if (entryRecoveryReason != "eligible") return null
|
||||
val anchor = after.specEntry ?: return null
|
||||
if (action.textAliases?.let { specEntryMatchesAliases(after, anchor, it) } == false) {
|
||||
entryRecoveryReason = "rule_alias_mismatch"
|
||||
return null
|
||||
}
|
||||
val target = after.specEntryClickTarget ?: anchor
|
||||
if (!target.visible || !target.enabled) {
|
||||
entryRecoveryReason = "entry_unavailable"
|
||||
return null
|
||||
}
|
||||
return target
|
||||
}
|
||||
|
||||
private data class SpecPanelWait(
|
||||
val screen: ParsedPddScreen,
|
||||
val opened: Boolean,
|
||||
@@ -547,9 +440,9 @@ class PurchaseRehearsalExecutor(
|
||||
private fun waitForSpecPanel(input: PurchaseExecutionInput, beforeSignature: List<SpecActionNodeSignature>): SpecPanelWait {
|
||||
var last = currentScreen(input)
|
||||
var changed = false
|
||||
repeat(SPEC_POST_CLICK_VERIFY_POLLS) { poll ->
|
||||
repeat(SPEC_POST_CLICK_VERIFY_POLLS) {
|
||||
last = currentScreen(input)
|
||||
panelDiagnostic("${panelEvidence(last)};entryWaitPolls=${poll + 1};entryWaitMillis=${poll * SPEC_SELECTION_POLL_MILLIS};closeControl=${last.hasCloseControl};paymentArea=${last.hasPaymentArea}")
|
||||
panelDiagnostic(panelEvidence(last))
|
||||
if (last.reviewPageOpen) {
|
||||
return SpecPanelWait(last, false, true, leaveUnexpectedReviewPage(input))
|
||||
}
|
||||
@@ -650,7 +543,6 @@ class PurchaseRehearsalExecutor(
|
||||
): PurchaseExecutionOutcome? {
|
||||
val initial = currentScreen(input)
|
||||
initial.problem?.let { return failure(it.code, it.message) }
|
||||
unavailableExactSpec(initial, dimension, target)?.let { return it }
|
||||
if (rememberExactSelection(initial, dimension, target, selectionProofs)) return null
|
||||
|
||||
val lookup = locateExactSpec(input, dimension, target)
|
||||
@@ -725,9 +617,7 @@ class PurchaseRehearsalExecutor(
|
||||
|
||||
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 dimension == "color" && SelectedColorCard.confirms(target, candidates, screen.sourceNodes)
|
||||
}
|
||||
if (candidates.any { it.text != target && (it.node.selected || it.node.checked) }) return false
|
||||
if (candidates.any { it.text == target && (it.node.selected || it.node.checked) }) return true
|
||||
if (screen.specPanelOpen && fullSummaryTargetMatches(screen.selectedSummary, target)) return true
|
||||
return summarySelectionMatches(screen.selectedSummary, dimension, target, candidates)
|
||||
@@ -901,18 +791,6 @@ class PurchaseRehearsalExecutor(
|
||||
|
||||
private data class SpecLookup(val node: SnapshotNode? = null, val failure: PurchaseExecutionOutcome? = null)
|
||||
|
||||
private fun unavailableExactSpec(screen: ParsedPddScreen, dimension: String, target: String): PurchaseExecutionOutcome? {
|
||||
if (!screen.specPanelOpen) return null
|
||||
val exact = screen.dimensions.filter { it.key == dimension }.flatMap { it.values }.filter { it.text == target }
|
||||
return if (exact.size == 1 && !exact.single().available) unavailableSpec(dimension) else null
|
||||
}
|
||||
|
||||
private fun unavailableSpec(dimension: String): PurchaseExecutionOutcome {
|
||||
val role = if (dimension == "color") "颜色" else "尺码"
|
||||
panelDiagnostic("specLookup=unavailable;dimension=$dimension;reason=exact_target_unavailable")
|
||||
return failure(SPEC_SAFE_TARGET_MISSING, "目标${role}已售罄或当前不可选,未创建订单")
|
||||
}
|
||||
|
||||
/**
|
||||
* Searches only parsed, selectable values in the already-open spec panel.
|
||||
* A short downward pass first restores the top when a previous action left
|
||||
@@ -949,7 +827,7 @@ class PurchaseRehearsalExecutor(
|
||||
}
|
||||
if (exact.size == 1 && exact.single().available) return Inspection(SpecLookup(node = exact.single().node), "", screen.specPanelContainer)
|
||||
if (exact.size == 1) {
|
||||
return Inspection(SpecLookup(failure = unavailableSpec(dimension)), "", screen.specPanelContainer)
|
||||
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}" }}"
|
||||
@@ -1014,7 +892,7 @@ class PurchaseRehearsalExecutor(
|
||||
val lookup = when {
|
||||
exact.size > 1 -> SpecLookup(failure = failure(SPEC_TARGET_AMBIGUOUS, "精确规格匹配到多个控件"))
|
||||
exact.size == 1 && exact.single().available -> SpecLookup(node = exact.single().node)
|
||||
exact.size == 1 -> SpecLookup(failure = unavailableSpec(dimension))
|
||||
exact.size == 1 -> SpecLookup(failure = failure(SPEC_SAFE_TARGET_MISSING, "精确规格当前不可安全点击"))
|
||||
else -> null
|
||||
}
|
||||
val rows = specValueRows(values)
|
||||
@@ -1216,17 +1094,16 @@ class PurchaseRehearsalExecutor(
|
||||
private fun applyPostAction(input: PurchaseExecutionInput, action: PurchaseAction): PurchaseExecutionOutcome? {
|
||||
if (action.waitAfterMs > 0) pause(action.waitAfterMs)
|
||||
action.swipeAfter?.let { swipe ->
|
||||
// Legacy rules prescribe a blind scroll immediately after opening.
|
||||
// Opening has already been verified; probe/select owns any necessary
|
||||
// bounded scrolling. Do not let an unnecessary gesture block either
|
||||
// phase, or move already-visible exact specs out of the viewport.
|
||||
if (action.type == PurchaseActionType.OPEN_SPEC_PANEL) {
|
||||
panelDiagnostic("postSwipe=skipped;action=${action.type.wireName};reason=spec_panel_on_demand;panel=${currentScreen(input).specPanelType.name}")
|
||||
return null
|
||||
}
|
||||
// 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)) {
|
||||
panelDiagnostic("postSwipe=failed;action=${action.type.wireName};direction=${swipe.direction.name};swipeIndex=${index + 1};reason=${driver.purchaseSwipeFailureReason().name.lowercase()}")
|
||||
return failure("RULE_ACTION_FAILED", "规则要求的有限滑动失败")
|
||||
}
|
||||
if (index < swipe.count - 1 && swipe.intervalMs > 0) pause(swipe.intervalMs)
|
||||
@@ -1235,13 +1112,9 @@ class PurchaseRehearsalExecutor(
|
||||
return null
|
||||
}
|
||||
|
||||
private fun probeOutcome(input: PurchaseExecutionInput): PurchaseExecutionOutcome {
|
||||
private fun probeOutcome(): PurchaseExecutionOutcome {
|
||||
stepChanged("probeSpecs")
|
||||
val result = probeSpecs() ?: return failure("PURCHASE_SPEC_NOT_MATCHED", "商品规格探测失败")
|
||||
val screen = currentScreen(input)
|
||||
if (input.phase == "spec_probe" && screen.problem == null && screen.isPddPackage && screen.specPanelOpen) {
|
||||
pageIdentity.second?.takeIf(String::isNotBlank)?.let(onProbeHandoff)
|
||||
}
|
||||
return PurchaseExecutionOutcome("spec_probe_completed", message = "商品规格已回传,等待服务端匹配", probedSpecs = result)
|
||||
}
|
||||
|
||||
@@ -1254,10 +1127,7 @@ class PurchaseRehearsalExecutor(
|
||||
|
||||
private fun currentScreen(input: PurchaseExecutionInput): ParsedPddScreen {
|
||||
val snapshot = driver.capture()
|
||||
pageIdentity = snapshot.packageName to snapshot.activityName
|
||||
val screen = PddScreenParser.parse(snapshot, DEFAULT_COLLECTOR, input.goodsId, null, purchasePanelContext,
|
||||
purchaseEntryContext = purchaseEntryIdentity != null && purchaseEntryIdentity == pageIdentity)
|
||||
if (purchaseEntryIdentity != pageIdentity) purchaseEntryIdentity = null
|
||||
val screen = PddScreenParser.parse(snapshot, DEFAULT_COLLECTOR, input.goodsId, null, purchasePanelContext)
|
||||
purchasePanelContext = if (screen.isPddPackage && screen.problem == null && screen.specPanelOpen) {
|
||||
screen.specPanelContainer?.let {
|
||||
PurchasePanelContext(it, normalizedTarget("color", input.mappedColor).orEmpty())
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
package cn.ilapage.goauto.agent.automation
|
||||
|
||||
/** A narrow exception for one selected text option duplicated by its card/image. */
|
||||
internal object SelectedColorCard {
|
||||
fun confirms(target: String, values: List<VisibleSpecValue>, source: List<SnapshotNode>): Boolean {
|
||||
val exact = values.filter { it.text == target }.singleOrNull() ?: return false
|
||||
if (!exact.node.selected && !exact.node.checked) return false
|
||||
val byPath = source.associateBy { it.path }
|
||||
fun contains(outer: NodeBounds, inner: NodeBounds) =
|
||||
inner.width > 0 && inner.height > 0 && inner.left >= outer.left && inner.top >= outer.top &&
|
||||
inner.right <= outer.right && inner.bottom <= outer.bottom
|
||||
fun cardFor(node: SnapshotNode): SnapshotNode? {
|
||||
var current = node
|
||||
val seen = mutableSetOf<String>()
|
||||
while (seen.add(current.path)) {
|
||||
if (current.scrollable || !current.visible || !current.enabled) return null
|
||||
val parent = current.parentPath?.let(byPath::get) ?: return null
|
||||
if (parent.scrollable) {
|
||||
return current.takeIf {
|
||||
it.clickable && it.className == "android.view.ViewGroup" &&
|
||||
contains(parent.bounds, it.bounds) && contains(it.bounds, node.bounds)
|
||||
}
|
||||
}
|
||||
current = parent
|
||||
}
|
||||
return null
|
||||
}
|
||||
val card = cardFor(exact.node) ?: return false
|
||||
// The parser may have deduplicated identical labels across cards. Check
|
||||
// the source as well before treating a selected duplicate as harmless.
|
||||
if (source.any { node ->
|
||||
node.visible && node.enabled && node.clickable &&
|
||||
SpecValueNormalizer.normalizeColor(node.label) == target &&
|
||||
cardFor(node)?.let { it.path != card.path } == true
|
||||
}) return false
|
||||
return values.filter { it.text != target && (it.node.selected || it.node.checked) }.all { other ->
|
||||
val node = other.node
|
||||
// Never merge another textual option, adjacent card, or scroll container.
|
||||
(node.path == card.path || node.className == "android.widget.ImageView") &&
|
||||
cardFor(node)?.path == card.path && contains(card.bounds, node.bounds)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -60,6 +60,14 @@ data class AgentTask(
|
||||
val status: String,
|
||||
val source: String,
|
||||
val replacementOriginType: String,
|
||||
val imageSearch: ImageSearchImage?,
|
||||
)
|
||||
|
||||
data class ImageSearchImage(
|
||||
val imageUrl: String,
|
||||
val mediaType: String,
|
||||
val sizeBytes: Long,
|
||||
val sha256: String,
|
||||
)
|
||||
|
||||
data class CurrentPageIdentity(
|
||||
@@ -84,7 +92,6 @@ data class PurchaseAgentTask(
|
||||
val mappedColor: String,
|
||||
val mappedSize: String,
|
||||
val specResolutionAllowed: Boolean,
|
||||
val specRematchAllowed: Boolean,
|
||||
val quantity: Long,
|
||||
val minUnitPriceCent: Long,
|
||||
val maxUnitPriceCent: Long,
|
||||
@@ -454,16 +461,6 @@ class AgentApiClient(private val serverUrl: String) {
|
||||
requireNotNull(request("POST", "/api/agent/v1/purchase-tasks/$taskId/result", payload, token))
|
||||
}
|
||||
|
||||
fun rematchPurchaseTask(taskId: Long, attemptId: String, probedSpecs: String, token: String): PurchaseAgentTask {
|
||||
val payload = JSONObject().put("requestId", UUID.randomUUID().toString())
|
||||
.put("taskAttemptId", attemptId).put("resultType", "spec_rematch_completed")
|
||||
.put("probedSpecs", JSONObject(probedSpecs))
|
||||
val data = requireNotNull(request("POST", "/api/agent/v1/purchase-tasks/$taskId/result", payload, token)).getJSONObject("data")
|
||||
val pending = purchaseTask(data)
|
||||
val claimed = claimPurchaseTask(taskId, UUID.randomUUID().toString(), token)
|
||||
return startPurchaseTask(claimed.taskId, UUID.randomUUID().toString(), token)
|
||||
}
|
||||
|
||||
fun collectionHistory(token: String, page: Int, status: String?, taskNo: String?, days: Int = 30, pageSize: Int = 20): HistoryPage<CollectionHistoryItem> {
|
||||
val data = requireNotNull(request("GET", historyPath("/api/agent/v1/collection-tasks", page, status, taskNo, days, pageSize), null, token)).getJSONObject("data")
|
||||
return HistoryPage(
|
||||
@@ -595,6 +592,7 @@ class AgentApiClient(private val serverUrl: String) {
|
||||
status = data.getString("status"),
|
||||
source = data.optString("source", "admin"),
|
||||
replacementOriginType = data.optString("replacementOriginType"),
|
||||
imageSearch = data.optJSONObject("imageSearch")?.let { image -> ImageSearchImage(image.getString("imageUrl"), image.getString("mediaType"), image.getLong("sizeBytes"), image.getString("sha256")) },
|
||||
)
|
||||
|
||||
private fun purchaseTask(data: JSONObject) = PurchaseAgentTask(
|
||||
@@ -611,7 +609,6 @@ class AgentApiClient(private val serverUrl: String) {
|
||||
mappedColor = data.optString("mappedColor"),
|
||||
mappedSize = data.optString("mappedSize"),
|
||||
specResolutionAllowed = data.optBoolean("specResolutionAllowed", false),
|
||||
specRematchAllowed = data.optBoolean("specRematchAllowed", false),
|
||||
quantity = data.getLong("quantity"),
|
||||
minUnitPriceCent = data.getLong("minUnitPriceCent"),
|
||||
maxUnitPriceCent = data.getLong("maxUnitPriceCent"),
|
||||
|
||||
-95
@@ -1,95 +0,0 @@
|
||||
package cn.ilapage.goauto.agent.persistence
|
||||
|
||||
import cn.ilapage.goauto.agent.automation.FreshActionResult
|
||||
import cn.ilapage.goauto.agent.automation.FreshClickReason
|
||||
import java.io.File
|
||||
import org.json.JSONObject
|
||||
|
||||
data class PurchaseDiagnosticContext(
|
||||
val taskId: Long,
|
||||
val attemptId: String,
|
||||
val deviceId: Long,
|
||||
val ruleHash: String,
|
||||
val agentVersion: String,
|
||||
val phase: String,
|
||||
)
|
||||
|
||||
/** Private, bounded diagnostics only. Neither raw evidence nor write errors escape this store. */
|
||||
class PurchaseDiagnosticStore(private val directory: File, private val now: () -> Long = System::currentTimeMillis) {
|
||||
@Synchronized
|
||||
fun record(context: PurchaseDiagnosticContext, elapsedMs: Long, evidence: String): Boolean = runCatching {
|
||||
require(context.taskId > 0 && context.deviceId > 0 && ID.matches(context.attemptId))
|
||||
require(HASH.matches(context.ruleHash) && VERSION.matches(context.agentVersion))
|
||||
require(context.phase in setOf("purchase", "spec_probe"))
|
||||
val fields = sanitize(evidence)
|
||||
if (fields.length() == 0) return false
|
||||
val timestamp = now()
|
||||
val line = JSONObject().put("taskId", context.taskId).put("attemptId", context.attemptId)
|
||||
.put("deviceId", context.deviceId).put("ruleHash", context.ruleHash)
|
||||
.put("agentVersion", context.agentVersion).put("phase", context.phase)
|
||||
.put("timestamp", timestamp).put("elapsedMs", elapsedMs.coerceAtLeast(0))
|
||||
.put("evidence", fields).toString()
|
||||
check(directory.isDirectory || directory.mkdirs())
|
||||
val file = File(directory, "${context.taskId}_${context.attemptId}.jsonl")
|
||||
file.appendText(line + "\n", Charsets.UTF_8)
|
||||
val lines = file.readLines(Charsets.UTF_8)
|
||||
if (lines.size > MAX_EVENTS) file.writeText(lines.takeLast(MAX_EVENTS).joinToString("\n", postfix = "\n"), Charsets.UTF_8)
|
||||
file.setLastModified(timestamp)
|
||||
prune(timestamp)
|
||||
true
|
||||
}.getOrDefault(false)
|
||||
|
||||
@Synchronized
|
||||
fun prune(timestamp: Long = now()) {
|
||||
runCatching {
|
||||
val files = directory.listFiles()?.filter { it.isFile && FILE_NAME.matches(it.name) }.orEmpty()
|
||||
files.filter { it.lastModified() < timestamp - RETENTION_MS }.forEach { it.delete() }
|
||||
files.filter(File::exists).sortedWith(compareByDescending<File> { it.lastModified() }.thenBy { it.name })
|
||||
.drop(MAX_ATTEMPTS).forEach { it.delete() }
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val MAX_ATTEMPTS = 5
|
||||
const val MAX_EVENTS = 128
|
||||
const val RETENTION_MS = 7L * 24 * 60 * 60 * 1000
|
||||
private val ID = Regex("[A-Za-z0-9-]{1,80}")
|
||||
private val HASH = Regex("[0-9a-f]{64}")
|
||||
private val VERSION = Regex("[0-9]+\\.[0-9]+\\.[0-9]+")
|
||||
private val FILE_NAME = Regex("[1-9][0-9]*_[A-Za-z0-9-]{1,80}\\.jsonl")
|
||||
private val numbers = setOf("specEntryCandidates", "explicit", "nested", "bottomPurchase", "entryReadyWaitPolls",
|
||||
"entryReadyWaitMillis", "scrollables", "headings", "options", "entryWaitPolls", "entryWaitMillis",
|
||||
"addressEditorCandidates", "addressEditorPolls", "savedLegacySubmitCandidates", "savedAddressCards",
|
||||
"savedAddressTargets", "savedScrollCandidates", "savedConfirmationPolls", "reusePolls")
|
||||
private val booleans = setOf("panelAlreadyOpen", "reviewPage", "pageEvidence", "pageChanged", "summary", "quantity",
|
||||
"orderAction", "closeControl", "paymentArea", "savedAddressMatched", "savedPanelMatched", "handoffPresent", "productReused")
|
||||
private val enums = mapOf(
|
||||
"reuseReason" to setOf("legacy_evidence", "page_problem", "page_changed", "handoff_ready", "evidence_timeout"),
|
||||
"event" to setOf("started"),
|
||||
"entrySource" to setOf("unknown", "explicit_selection", "nested_selection", "bottom_purchase", "bottom_purchase_rightmost"),
|
||||
"entryClick" to FreshActionResult.values().map { it.name }.toSet(),
|
||||
"entryReason" to FreshClickReason.values().map { it.name }.toSet(),
|
||||
"gesture" to FreshActionResult.values().map { it.name }.toSet(),
|
||||
"entryRecovery" to setOf("rejected", "attempted"),
|
||||
"type" to setOf("UNKNOWN", "NORMAL_SCROLLABLE", "NON_SCROLLABLE_CONFIRMATION", "ORDER_CONFIRMATION", "QUICK_CONFIRMATION"),
|
||||
"reason" to setOf("identity_missing", "identity_changed", "product_context_missing", "page_problem", "panel_open",
|
||||
"close_control", "payment_area", "order_action", "quantity_controls", "selection_summary", "title_missing",
|
||||
"title_changed", "entry_missing", "entry_source_missing", "entry_source_changed", "entry_label_changed",
|
||||
"entry_ambiguous", "rule_alias_mismatch", "entry_unavailable", "eligible"),
|
||||
)
|
||||
|
||||
internal fun sanitize(evidence: String): JSONObject {
|
||||
val result = JSONObject()
|
||||
evidence.take(4096).split(';').take(40).forEach { part ->
|
||||
val key = part.substringBefore('=')
|
||||
val value = part.substringAfter('=', "")
|
||||
when {
|
||||
key in numbers && value.matches(Regex("[0-9]{1,8}")) -> result.put(key, value.toLong())
|
||||
key in booleans && value in setOf("true", "false") -> result.put(key, value == "true")
|
||||
value in enums[key].orEmpty() -> result.put(key, value)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
}
|
||||
+22
-81
@@ -18,10 +18,6 @@ import android.os.PowerManager
|
||||
import android.os.SystemClock
|
||||
import android.util.Log
|
||||
import cn.ilapage.goauto.agent.BuildConfig
|
||||
import cn.ilapage.goauto.agent.automation.PddCollectionReturnNavigator
|
||||
import cn.ilapage.goauto.agent.persistence.PurchaseDiagnosticContext
|
||||
import cn.ilapage.goauto.agent.persistence.PurchaseDiagnosticStore
|
||||
import java.io.File
|
||||
import cn.ilapage.goauto.agent.ClipboardRelayActivity
|
||||
import cn.ilapage.goauto.agent.MainActivity
|
||||
import cn.ilapage.goauto.agent.R
|
||||
@@ -87,7 +83,6 @@ class AgentForegroundService : Service() {
|
||||
private val collectionCooldownWakeActive = AtomicBoolean(false)
|
||||
private val currentPageResultNotificationUntil = AtomicLong(0L)
|
||||
private val idleReturn = IdleReturnCoordinator()
|
||||
private val purchaseProbeHandoff = PurchaseProbeHandoff()
|
||||
private var taskWakeLock: PowerManager.WakeLock? = null
|
||||
private var cooldownWakeLock: PowerManager.WakeLock? = null
|
||||
private var collectionCooldownWakeLock: PowerManager.WakeLock? = null
|
||||
@@ -96,7 +91,6 @@ class AgentForegroundService : Service() {
|
||||
private lateinit var settingsStore: AgentSettingsStore
|
||||
private lateinit var stateStore: AgentStateStore
|
||||
private lateinit var purchaseStore: PurchaseTaskStore
|
||||
private lateinit var purchaseDiagnostics: PurchaseDiagnosticStore
|
||||
private lateinit var diagnosticStore: AgentDiagnosticStore
|
||||
private lateinit var diagnosticRecorder: SafeAgentDiagnosticRecorder
|
||||
private lateinit var connectivityManager: ConnectivityManager
|
||||
@@ -115,8 +109,6 @@ class AgentForegroundService : Service() {
|
||||
// Wake locks and cooldown tickets are process-local. Never restore a stale UI flag.
|
||||
stateStore.setKeepScreenOn(false)
|
||||
purchaseStore = PurchaseTaskStore(this)
|
||||
purchaseDiagnostics = PurchaseDiagnosticStore(File(filesDir, "purchase_diagnostics"))
|
||||
runCatching { diagnosticExecutor.execute { purchaseDiagnostics.prune() } }
|
||||
diagnosticStore = AgentDiagnosticStore(this)
|
||||
diagnosticRecorder = SafeAgentDiagnosticRecorder(
|
||||
persist = { event ->
|
||||
@@ -284,7 +276,7 @@ class AgentForegroundService : Service() {
|
||||
evaluateIdleReturn()
|
||||
return "$MANUAL_COLLECTION_COOLDOWN_PREFIX${CollectionCooldownPolicy.remainingSeconds(System.currentTimeMillis(), ticket)}"
|
||||
}
|
||||
TaskDispatchDecision.CHECK_COLLECTION -> purchaseProbeHandoff.clear()
|
||||
TaskDispatchDecision.CHECK_COLLECTION -> Unit
|
||||
}
|
||||
val task = api.nextTask(token)
|
||||
if (task == null) {
|
||||
@@ -292,7 +284,6 @@ class AgentForegroundService : Service() {
|
||||
return MANUAL_EMPTY
|
||||
}
|
||||
cancelIdleReturn("收到新的采集任务")
|
||||
purchaseProbeHandoff.clear()
|
||||
if (!taskMutex.tryAcquire(task.taskId)) return MANUAL_BUSY
|
||||
if (task.status == "running") runningTaskId.set(task.taskId)
|
||||
stateStore.setActiveTask(task.taskId, "collection")
|
||||
@@ -359,16 +350,11 @@ class AgentForegroundService : Service() {
|
||||
}
|
||||
runningTaskId.set(task.taskId)
|
||||
stateStore.setActiveTask(task.taskId, "collection")
|
||||
val continuous = ContinuousCollectionPolicy.applies(
|
||||
settingsStore.continuousCollection(), task.source, task.attemptNumber, task.replacementOriginType,
|
||||
)
|
||||
val outcome = executeTask(api, task, credentials.token, continuous)
|
||||
val outcome = executeTask(api, task, credentials.token)
|
||||
publishCurrentPageResult(
|
||||
task.taskId,
|
||||
if (outcome.successful) {
|
||||
if (continuous) {
|
||||
outcome.message ?: "采集已结束,请查看采集记录。"
|
||||
} else if (task.replacementOriginType.isNotBlank()) {
|
||||
if (task.replacementOriginType.isNotBlank()) {
|
||||
"已替换,正在匹配规格"
|
||||
} else {
|
||||
"临时采集任务 #${task.taskId} 已结束,请查看采集记录。"
|
||||
@@ -464,7 +450,7 @@ class AgentForegroundService : Service() {
|
||||
val claimed = if (initial.status == "pending") {
|
||||
api.claimPurchaseTask(initial.taskId, UUID.randomUUID().toString(), token)
|
||||
} else initial
|
||||
var task = if (claimed.status == "pending") {
|
||||
val task = if (claimed.status == "pending") {
|
||||
api.startPurchaseTask(claimed.taskId, UUID.randomUUID().toString(), token)
|
||||
} else claimed
|
||||
check(task.status == "running" && task.taskAttemptId.isNotBlank()) { "采购任务没有有效 attempt" }
|
||||
@@ -492,44 +478,15 @@ class AgentForegroundService : Service() {
|
||||
if (accessibility == null) {
|
||||
PurchaseExecutionOutcome("failed", "ACCESSIBILITY_NOT_READY", "GoAuto 无障碍服务未开启")
|
||||
} else {
|
||||
val diagnosticDeviceId = runCatching { identityStore.credentials()?.deviceId ?: 0L }.getOrDefault(0L)
|
||||
val diagnosticAttempt = task.taskAttemptId.takeIf { it.matches(Regex("^[a-zA-Z0-9-]{1,80}$")) } ?: "invalid"
|
||||
val diagnosticContext = PurchaseDiagnosticContext(task.taskId, task.taskAttemptId, diagnosticDeviceId,
|
||||
snapshotHash, BuildConfig.VERSION_NAME, task.phase)
|
||||
val diagnosticStarted = SystemClock.elapsedRealtime()
|
||||
val handoffKey = PurchaseProbeHandoff.Key(task.taskId, task.pddGoodsId, snapshotHash, diagnosticDeviceId)
|
||||
val handoffActivity = purchaseProbeHandoff.consume(handoffKey, task.phase,
|
||||
accessibility.currentForegroundRevision(), diagnosticStarted)
|
||||
val recordDiagnostic: (String) -> Unit = { evidence ->
|
||||
val elapsed = SystemClock.elapsedRealtime() - diagnosticStarted
|
||||
runCatching { diagnosticExecutor.execute { purchaseDiagnostics.record(diagnosticContext, elapsed, evidence) } }
|
||||
Unit
|
||||
}
|
||||
recordDiagnostic("event=started")
|
||||
PurchaseRehearsalExecutor(
|
||||
probeHandoffActivity = handoffActivity,
|
||||
onProbeHandoff = { activity -> purchaseProbeHandoff.remember(handoffKey, activity,
|
||||
accessibility.currentForegroundRevision(), SystemClock.elapsedRealtime()) },
|
||||
driver = accessibility,
|
||||
rematchSpecs = { execution ->
|
||||
val raw = collectPurchaseProbe(accessibility, task, parsedRule)
|
||||
if (raw == null) null else {
|
||||
val refreshed = api.rematchPurchaseTask(task.taskId, task.taskAttemptId, raw, token)
|
||||
task = refreshed
|
||||
execution.copy(mappedColor = refreshed.mappedColor, mappedSize = refreshed.mappedSize,
|
||||
specRematchAllowed = refreshed.specRematchAllowed)
|
||||
}
|
||||
},
|
||||
openLink = { PddLinkLauncher(this).open(it, preferDirect = true) },
|
||||
probeSpecs = { collectPurchaseProbe(accessibility, task, parsedRule) },
|
||||
stepChanged = { step ->
|
||||
lastStep.set(step)
|
||||
purchaseStore.updateStep(task.taskId, task.taskAttemptId, step)
|
||||
},
|
||||
panelDiagnostic = { evidence ->
|
||||
recordDiagnostic(evidence)
|
||||
Log.i("GoAutoPurchasePanel", "task=${task.taskId};attempt=$diagnosticAttempt;device=$diagnosticDeviceId;rule=$snapshotHash;$evidence")
|
||||
},
|
||||
panelDiagnostic = { evidence -> Log.i("GoAutoPurchasePanel", "task=${task.taskId};$evidence") },
|
||||
beforeOrderSubmit = { evidence ->
|
||||
val boundaryRequestId = UUID.randomUUID().toString()
|
||||
val finalEvidence = JSONObject()
|
||||
@@ -558,7 +515,6 @@ class AgentForegroundService : Service() {
|
||||
minUnitPriceCent = task.minUnitPriceCent,
|
||||
maxUnitPriceCent = task.maxUnitPriceCent,
|
||||
addressSuffix = task.addressSuffix,
|
||||
specRematchAllowed = task.specRematchAllowed,
|
||||
),
|
||||
parsedRule,
|
||||
PurchaseAgentCapabilities.supported,
|
||||
@@ -568,7 +524,6 @@ class AgentForegroundService : Service() {
|
||||
}
|
||||
val requestId = UUID.randomUUID().toString()
|
||||
val payload = purchaseResultPayload(requestId, task.taskAttemptId, outcome)
|
||||
if (outcome.resultType != "spec_probe_completed") purchaseProbeHandoff.clear()
|
||||
purchaseStore.completeAndEnqueue(task.taskId, task.taskAttemptId, requestId, payload)
|
||||
resultSafelyStored = true
|
||||
PurchaseResultBubblePolicy.create(
|
||||
@@ -589,10 +544,7 @@ class AgentForegroundService : Service() {
|
||||
} catch (error: Exception) {
|
||||
stateStore.update("TASK_ERROR", error.message ?: "采购演练执行异常", tokenStored = true)
|
||||
} finally {
|
||||
if (!resultSafelyStored) {
|
||||
purchaseProbeHandoff.clear()
|
||||
cancelIdleReturn("采购结果未安全保存")
|
||||
}
|
||||
if (!resultSafelyStored) cancelIdleReturn("采购结果未安全保存")
|
||||
releaseTaskWakeLock()
|
||||
}
|
||||
}
|
||||
@@ -679,29 +631,11 @@ class AgentForegroundService : Service() {
|
||||
api: AgentApiClient,
|
||||
initialTask: cn.ilapage.goauto.agent.network.AgentTask,
|
||||
token: String,
|
||||
continuous: Boolean = false,
|
||||
): TaskExecutionSummary {
|
||||
GoAutoAccessibilityService.instance?.dismissPurchaseResultBubble()
|
||||
acquireTaskWakeLock()
|
||||
return try {
|
||||
val returnCount = settingsStore.continuousReturnCount()
|
||||
val outcome = executeTaskWhileAwake(api, initialTask, token, continuous)
|
||||
if (!continuous) outcome else {
|
||||
cancelIdleReturn("连续采集保留 PDD 页面")
|
||||
ContinuousCollectionPolicy.finish(continuous, outcome) {
|
||||
val driver = GoAutoAccessibilityService.instance ?: return@finish false
|
||||
val navigator = PddCollectionReturnNavigator(driver, driver::openAgentPreservingTab, driver::currentPackage)
|
||||
val returned = navigator.returnToAgent(returnCount)
|
||||
runCatching {
|
||||
val hash = java.security.MessageDigest.getInstance("SHA-256")
|
||||
.digest(initialTask.ruleSnapshot.toByteArray(Charsets.UTF_8)).joinToString("") { "%02x".format(it) }
|
||||
Log.i("GoAutoCollector", "task=${initialTask.taskId};attempt=${initialTask.attemptNumber};device=${stateStore.read().deviceId};rule=$hash;continuousReturn=$returned;configuredBacks=$returnCount;reason=${navigator.reason};actions=${navigator.actions}")
|
||||
}
|
||||
returned
|
||||
}.also { result ->
|
||||
if (result.successful) runCatching { stateStore.update("ONLINE", result.message.orEmpty(), tokenStored = true) }
|
||||
}
|
||||
}
|
||||
executeTaskWhileAwake(api, initialTask, token)
|
||||
} finally {
|
||||
releaseTaskWakeLock()
|
||||
}
|
||||
@@ -711,7 +645,6 @@ class AgentForegroundService : Service() {
|
||||
api: AgentApiClient,
|
||||
initialTask: cn.ilapage.goauto.agent.network.AgentTask,
|
||||
token: String,
|
||||
continuous: Boolean = false,
|
||||
): TaskExecutionSummary {
|
||||
var started = initialTask.status == "running"
|
||||
var resultSafelySubmitted = false
|
||||
@@ -775,6 +708,8 @@ class AgentForegroundService : Service() {
|
||||
).collect(identity.goodsId, rule)
|
||||
if (!collection.successful) throw TaskFailure(collection.code, collection.message)
|
||||
CollectionExecution(requireNotNull(collection.payload), collection.colorImages)
|
||||
} else if (task.source == "image_search") {
|
||||
throw TaskFailure("IMAGE_SEARCH_AUTOMATION_UNAVAILABLE", "当前 Agent 版本尚未接入 PDD 图搜自动化,请使用普通采集任务")
|
||||
} else if (rule.schemaVersion == 2) {
|
||||
val trace: (String) -> Unit = { message -> Log.i("GoAutoCollector", message) }
|
||||
val collection = PddDetailEntryRunner(
|
||||
@@ -818,25 +753,25 @@ class AgentForegroundService : Service() {
|
||||
}
|
||||
}
|
||||
stateStore.update("ONLINE", "任务 #${task.taskId} 已提交:${result.status}", tokenStored = true)
|
||||
beginPostCollectionCooldowns(returnToAgent = !continuous)
|
||||
TaskExecutionSummary(successful = true, resultStatus = result.status)
|
||||
beginPostCollectionCooldowns()
|
||||
TaskExecutionSummary(successful = true)
|
||||
} catch (error: TaskFailure) {
|
||||
if (started) {
|
||||
resultSafelySubmitted = failSafely(api, initialTask.taskId, token, error.code, error.message ?: "采集失败")
|
||||
if (resultSafelySubmitted) beginPostCollectionCooldowns(returnToAgent = !continuous)
|
||||
if (resultSafelySubmitted) beginPostCollectionCooldowns()
|
||||
}
|
||||
TaskExecutionSummary(false, error.code, error.message)
|
||||
} catch (error: AgentApiException) {
|
||||
if (error.code == "REPLACEMENT_ACTIVATION_FAILED") {
|
||||
resultSafelySubmitted = true
|
||||
beginPostCollectionCooldowns(returnToAgent = !continuous)
|
||||
beginPostCollectionCooldowns()
|
||||
}
|
||||
stateStore.update("TASK_ERROR", "${error.code}:${error.message}", tokenStored = true)
|
||||
TaskExecutionSummary(false, error.code, error.message)
|
||||
} catch (error: Exception) {
|
||||
if (started) {
|
||||
resultSafelySubmitted = failSafely(api, initialTask.taskId, token, "AGENT_EXECUTION_ERROR", error.message ?: "Android 执行异常")
|
||||
if (resultSafelySubmitted) beginPostCollectionCooldowns(returnToAgent = !continuous)
|
||||
if (resultSafelySubmitted) beginPostCollectionCooldowns()
|
||||
}
|
||||
TaskExecutionSummary(false, "AGENT_EXECUTION_ERROR", error.message)
|
||||
} finally {
|
||||
@@ -895,8 +830,8 @@ class AgentForegroundService : Service() {
|
||||
executor.schedule(::triggerSync, IdleReturnCoordinator.DEFAULT_COOLDOWN_MILLIS, TimeUnit.MILLISECONDS)
|
||||
}
|
||||
|
||||
private fun beginPostCollectionCooldowns(returnToAgent: Boolean = true) {
|
||||
if (returnToAgent) beginIdleReturnCooldown() else cancelIdleReturn("连续采集不自动返回 Agent")
|
||||
private fun beginPostCollectionCooldowns() {
|
||||
beginIdleReturnCooldown()
|
||||
val ticket = CollectionCooldownPolicy.arm(
|
||||
System.currentTimeMillis(),
|
||||
settingsStore.collectionIntervalRange(),
|
||||
@@ -1215,3 +1150,9 @@ class AgentForegroundService : Service() {
|
||||
}
|
||||
|
||||
private class TaskFailure(val code: String, message: String) : Exception(message)
|
||||
|
||||
private data class TaskExecutionSummary(
|
||||
val successful: Boolean,
|
||||
val code: String? = null,
|
||||
val message: String? = null,
|
||||
)
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package cn.ilapage.goauto.agent.service
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import cn.ilapage.goauto.agent.BuildConfig
|
||||
import cn.ilapage.goauto.agent.network.ServerUrlPolicy
|
||||
import kotlin.random.Random
|
||||
@@ -114,38 +113,14 @@ internal object TaskDispatchPolicy {
|
||||
}
|
||||
}
|
||||
|
||||
class AgentSettingsStore internal constructor(private val preferences: SharedPreferences) {
|
||||
constructor(context: Context) : this(context.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE))
|
||||
class AgentSettingsStore(context: Context) {
|
||||
private val preferences = context.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE)
|
||||
|
||||
fun serverUrl(): String = preferences.getString(SERVER_URL, null)
|
||||
?: BuildConfig.DEFAULT_SERVER_URL.takeIf { it.isNotBlank() }.orEmpty()
|
||||
|
||||
fun deviceName(): String = preferences.getString(DEVICE_NAME, "").orEmpty()
|
||||
|
||||
fun continuousCollection(): Boolean = runCatching { preferences.getBoolean(CONTINUOUS_COLLECTION, false) }.getOrDefault(false)
|
||||
|
||||
fun continuousReturnCount(): Int = runCatching {
|
||||
preferences.getInt(CONTINUOUS_RETURN_COUNT, 3).takeIf { it in 1..5 } ?: 3
|
||||
}.getOrDefault(3)
|
||||
|
||||
fun saveContinuousReturnCount(count: Int) {
|
||||
require(count in 1..5) { "返回次数须为 1~5 次" }
|
||||
val previous = continuousReturnCount()
|
||||
if (!preferences.edit().putInt(CONTINUOUS_RETURN_COUNT, count).commit()) {
|
||||
preferences.edit().putInt(CONTINUOUS_RETURN_COUNT, previous).commit()
|
||||
error("返回次数未保存,请重试")
|
||||
}
|
||||
}
|
||||
|
||||
fun saveContinuousCollection(enabled: Boolean) {
|
||||
val previous = continuousCollection()
|
||||
if (!preferences.edit().putBoolean(CONTINUOUS_COLLECTION, enabled).commit()) {
|
||||
// SharedPreferences also updates memory on failed disk commits; restore the displayed value.
|
||||
preferences.edit().putBoolean(CONTINUOUS_COLLECTION, previous).commit()
|
||||
error("连续采集设置未保存,请重试")
|
||||
}
|
||||
}
|
||||
|
||||
fun historyDays(): Int = HistoryRangePolicy.stored(preferences.getInt(HISTORY_DAYS, 7))
|
||||
|
||||
fun collectionIntervalRange(): CollectionIntervalRange {
|
||||
@@ -202,8 +177,6 @@ class AgentSettingsStore internal constructor(private val preferences: SharedPre
|
||||
const val PREFERENCES = "goauto_agent_settings"
|
||||
const val SERVER_URL = "server_url"
|
||||
const val DEVICE_NAME = "device_name"
|
||||
const val CONTINUOUS_COLLECTION = "continuous_collection"
|
||||
const val CONTINUOUS_RETURN_COUNT = "continuous_collection_return_count"
|
||||
const val HISTORY_DAYS = "history_days"
|
||||
const val COLLECTION_INTERVAL_SECONDS = "collection_interval_seconds"
|
||||
const val COLLECTION_INTERVAL_START_SECONDS = "collection_interval_start_seconds"
|
||||
|
||||
-27
@@ -1,27 +0,0 @@
|
||||
package cn.ilapage.goauto.agent.service
|
||||
|
||||
/** Only the foreground, newly requested temporary collection opts in. */
|
||||
internal object ContinuousCollectionPolicy {
|
||||
fun applies(enabled: Boolean, source: String, attemptNumber: Int, replacementOrigin: String): Boolean =
|
||||
enabled && source == "agent_current_page" && attemptNumber == 1 && replacementOrigin.isBlank()
|
||||
|
||||
fun shouldReturn(enabledForTask: Boolean, submitted: Boolean, status: String?): Boolean =
|
||||
enabledForTask && submitted && status == "completed"
|
||||
|
||||
fun finish(enabledForTask: Boolean, outcome: TaskExecutionSummary, navigate: () -> Boolean): TaskExecutionSummary {
|
||||
if (!enabledForTask || !outcome.successful) return outcome
|
||||
if (!shouldReturn(enabledForTask, outcome.successful, outcome.resultStatus)) {
|
||||
return outcome.copy(message = "采集部分成功,已保留现场,请查看采集记录。")
|
||||
}
|
||||
val returned = runCatching(navigate).getOrDefault(false)
|
||||
return outcome.copy(message = if (returned) "采集已完成,返回操作已结束,已切回 Agent。"
|
||||
else "采集已完成,自动返回未完成,请手动处理当前页面并打开 Agent。")
|
||||
}
|
||||
}
|
||||
|
||||
internal data class TaskExecutionSummary(
|
||||
val successful: Boolean,
|
||||
val code: String? = null,
|
||||
val message: String? = null,
|
||||
val resultStatus: String? = null,
|
||||
)
|
||||
@@ -1,23 +0,0 @@
|
||||
package cn.ilapage.goauto.agent.service
|
||||
|
||||
/** One-use, process-local recognition context; never contains nodes or selection proofs. */
|
||||
internal class PurchaseProbeHandoff {
|
||||
data class Key(val taskId: Long, val goodsId: String, val ruleHash: String, val deviceId: Long)
|
||||
private data class Ticket(val key: Key, val activity: String, val revision: Long, val time: Long)
|
||||
private var ticket: Ticket? = null
|
||||
|
||||
@Synchronized fun remember(key: Key, activity: String, revision: Long, now: Long) {
|
||||
ticket = Ticket(key, activity, revision, now)
|
||||
}
|
||||
|
||||
@Synchronized fun consume(key: Key, phase: String, revision: Long, now: Long): String? {
|
||||
val previous = ticket
|
||||
ticket = null
|
||||
return previous?.takeIf {
|
||||
phase == "purchase" && it.key == key && it.revision == revision &&
|
||||
now - it.time in 0..180_000 && it.activity.isNotBlank()
|
||||
}?.activity
|
||||
}
|
||||
|
||||
@Synchronized fun clear() { ticket = null }
|
||||
}
|
||||
@@ -34,7 +34,6 @@ import cn.ilapage.goauto.agent.update.AgentAppUpdateManager
|
||||
import cn.ilapage.goauto.agent.update.UpdateCheckResult
|
||||
import com.google.android.material.button.MaterialButton
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import com.google.android.material.switchmaterial.SwitchMaterial
|
||||
import com.google.android.material.textfield.TextInputEditText
|
||||
import com.google.android.material.textfield.TextInputLayout
|
||||
import java.util.concurrent.Executors
|
||||
@@ -65,10 +64,6 @@ class AgentSettingsFragment : Fragment() {
|
||||
private lateinit var collectionIntervalEndLayout: TextInputLayout
|
||||
private lateinit var collectionIntervalEndInput: TextInputEditText
|
||||
private lateinit var collectionIntervalFeedback: TextView
|
||||
private lateinit var continuousCollectionSwitch: SwitchMaterial
|
||||
private lateinit var continuousReturnCountButton: MaterialButton
|
||||
private lateinit var continuousCollectionFeedback: TextView
|
||||
private var refreshingContinuousCollection = false
|
||||
private lateinit var historyDaysLayout: TextInputLayout
|
||||
private lateinit var historyDaysInput: TextInputEditText
|
||||
private lateinit var historySyncButton: MaterialButton
|
||||
@@ -262,69 +257,6 @@ class AgentSettingsFragment : Fragment() {
|
||||
collectionIntervalFeedback.setPadding(0, context.dp(8), 0, 0)
|
||||
addView(collectionIntervalFeedback)
|
||||
}))
|
||||
addView(context.card(context.cardColumn().apply {
|
||||
continuousCollectionSwitch = SwitchMaterial(context).apply {
|
||||
// Use the switch style supplied by our MaterialComponents (M2) theme.
|
||||
showText = false
|
||||
textOn = "开启"
|
||||
textOff = "关闭"
|
||||
isClickable = true
|
||||
isFocusable = true
|
||||
text = "连续采集"
|
||||
textSize = 18f
|
||||
setTextColor(context.getColor(R.color.agent_text))
|
||||
minHeight = context.dp(48)
|
||||
isChecked = settingsStore.continuousCollection()
|
||||
setOnCheckedChangeListener { _, enabled ->
|
||||
if (!refreshingContinuousCollection) {
|
||||
val current = stateStore.read()
|
||||
val result = runCatching {
|
||||
check(current.code != "BUSY" && current.currentTaskId == null) { "任务结束后可修改" }
|
||||
settingsStore.saveContinuousCollection(enabled)
|
||||
}
|
||||
refreshingContinuousCollection = true
|
||||
isChecked = settingsStore.continuousCollection()
|
||||
refreshingContinuousCollection = false
|
||||
continuousCollectionFeedback.text = result.fold(
|
||||
onSuccess = { "" },
|
||||
onFailure = { "未保存:${it.message ?: "请重试"}" },
|
||||
)
|
||||
continuousCollectionFeedback.visibility = if (continuousCollectionFeedback.text.isEmpty()) View.GONE else View.VISIBLE
|
||||
refreshContinuousReturnCount()
|
||||
}
|
||||
}
|
||||
}
|
||||
addView(continuousCollectionSwitch, fullWidth())
|
||||
continuousReturnCountButton = MaterialButton(context, null, com.google.android.material.R.attr.materialButtonOutlinedStyle).apply {
|
||||
minHeight = context.dp(48)
|
||||
setTextColor(context.getColor(R.color.agent_text))
|
||||
setOnClickListener {
|
||||
MaterialAlertDialogBuilder(context)
|
||||
.setTitle("返回次数")
|
||||
.setSingleChoiceItems(arrayOf("1 次", "2 次", "3 次", "4 次", "5 次"), settingsStore.continuousReturnCount() - 1) { dialog, which ->
|
||||
val result = runCatching {
|
||||
val current = stateStore.read()
|
||||
check(current.code != "BUSY" && current.currentTaskId == null) { "任务结束后可修改" }
|
||||
check(settingsStore.continuousCollection()) { "请先开启连续采集" }
|
||||
settingsStore.saveContinuousReturnCount(which + 1)
|
||||
}
|
||||
continuousCollectionFeedback.text = result.fold(onSuccess = { "" }, onFailure = { "未保存:${it.message ?: "请重试"}" })
|
||||
continuousCollectionFeedback.visibility = if (result.isSuccess) View.GONE else View.VISIBLE
|
||||
refreshContinuousReturnCount()
|
||||
dialog.dismiss()
|
||||
}
|
||||
.setNegativeButton("取消", null)
|
||||
.show()
|
||||
}
|
||||
}
|
||||
addView(continuousReturnCountButton, fullWidth(8))
|
||||
refreshContinuousReturnCount()
|
||||
continuousCollectionFeedback = context.label("", 14f, context.getColor(R.color.agent_text_muted)).apply {
|
||||
visibility = View.GONE
|
||||
accessibilityLiveRegion = View.ACCESSIBILITY_LIVE_REGION_POLITE
|
||||
}
|
||||
addView(continuousCollectionFeedback, fullWidth(8))
|
||||
}))
|
||||
addView(context.card(context.cardColumn().apply {
|
||||
addView(context.label("任务记录", 18f, context.getColor(R.color.agent_text), true))
|
||||
val syncRow = LinearLayout(context).apply {
|
||||
@@ -722,23 +654,11 @@ class AgentSettingsFragment : Fragment() {
|
||||
return normalizedUrl
|
||||
}
|
||||
|
||||
private fun refreshContinuousReturnCount() {
|
||||
val current = stateStore.read()
|
||||
continuousReturnCountButton.text = "返回次数:${settingsStore.continuousReturnCount()} 次"
|
||||
continuousReturnCountButton.visibility = if (settingsStore.continuousCollection()) View.VISIBLE else View.GONE
|
||||
continuousReturnCountButton.isEnabled = current.code != "BUSY" && current.currentTaskId == null
|
||||
}
|
||||
|
||||
private fun refreshDiagnostics() {
|
||||
if (!isAdded || view == null) return
|
||||
val context = requireContext()
|
||||
val state = stateStore.read()
|
||||
val busy = state.code == "BUSY" || state.currentTaskId != null
|
||||
continuousCollectionSwitch.isEnabled = !busy
|
||||
refreshContinuousReturnCount()
|
||||
if (busy) continuousCollectionFeedback.text = "任务结束后可修改"
|
||||
else if (continuousCollectionFeedback.text == "任务结束后可修改") continuousCollectionFeedback.text = ""
|
||||
continuousCollectionFeedback.visibility = if (continuousCollectionFeedback.text.isEmpty()) View.GONE else View.VISIBLE
|
||||
if (busy && updateDownloading) updateCancelled.set(true)
|
||||
val editable = SettingsAvailabilityResolver.editable(state.code, state.currentTaskId, testing)
|
||||
serverInput.isEnabled = editable
|
||||
|
||||
@@ -1,82 +0,0 @@
|
||||
package cn.ilapage.goauto.agent
|
||||
|
||||
import android.content.SharedPreferences
|
||||
import cn.ilapage.goauto.agent.service.AgentSettingsStore
|
||||
import java.lang.reflect.Proxy
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Test
|
||||
|
||||
class ContinuousCollectionSettingsTest {
|
||||
@Test fun `default off persists and survives store recreation`() {
|
||||
val disk = MemoryPreferences()
|
||||
val store = AgentSettingsStore(disk.preferences)
|
||||
assertFalse(store.continuousCollection())
|
||||
store.saveContinuousCollection(true)
|
||||
assertTrue(AgentSettingsStore(disk.preferences).continuousCollection())
|
||||
store.saveContinuousCollection(false)
|
||||
assertFalse(AgentSettingsStore(disk.preferences).continuousCollection())
|
||||
}
|
||||
|
||||
@Test fun `failed disk commit restores previous in memory preference`() {
|
||||
val disk = MemoryPreferences()
|
||||
val store = AgentSettingsStore(disk.preferences)
|
||||
store.saveContinuousCollection(true)
|
||||
disk.failNextCommit = true
|
||||
assertTrue(runCatching { store.saveContinuousCollection(false) }.isFailure)
|
||||
assertTrue(store.continuousCollection())
|
||||
assertTrue(AgentSettingsStore(disk.preferences).continuousCollection())
|
||||
}
|
||||
|
||||
@Test fun `return count defaults three and persists valid bounds`() {
|
||||
val disk = MemoryPreferences()
|
||||
val store = AgentSettingsStore(disk.preferences)
|
||||
assertEquals(3, store.continuousReturnCount())
|
||||
for (count in 1..5) {
|
||||
store.saveContinuousReturnCount(count)
|
||||
assertEquals(count, AgentSettingsStore(disk.preferences).continuousReturnCount())
|
||||
}
|
||||
for (count in listOf(0, 6)) {
|
||||
assertTrue(runCatching { store.saveContinuousReturnCount(count) }.isFailure)
|
||||
assertEquals(5, store.continuousReturnCount())
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun `return count failed write restores previous and invalid stored value defaults`() {
|
||||
val disk = MemoryPreferences()
|
||||
val store = AgentSettingsStore(disk.preferences)
|
||||
store.saveContinuousReturnCount(2)
|
||||
disk.failNextCommit = true
|
||||
assertTrue(runCatching { store.saveContinuousReturnCount(4) }.isFailure)
|
||||
assertEquals(2, AgentSettingsStore(disk.preferences).continuousReturnCount())
|
||||
disk.preferences.edit().putInt("continuous_collection_return_count", 99).commit()
|
||||
assertEquals(3, store.continuousReturnCount())
|
||||
}
|
||||
|
||||
private class MemoryPreferences {
|
||||
private val values = mutableMapOf<String, Any>()
|
||||
var failNextCommit = false
|
||||
val preferences = Proxy.newProxyInstance(SharedPreferences::class.java.classLoader, arrayOf(SharedPreferences::class.java)) { _, method, args ->
|
||||
when (method.name) {
|
||||
"getBoolean", "getInt" -> values[args!![0] as String] ?: args[1]
|
||||
"edit" -> editor()
|
||||
else -> error("Unexpected preference access ${method.name}")
|
||||
}
|
||||
} as SharedPreferences
|
||||
|
||||
private fun editor(): SharedPreferences.Editor {
|
||||
val pending = mutableMapOf<String, Any>()
|
||||
return Proxy.newProxyInstance(SharedPreferences.Editor::class.java.classLoader, arrayOf(SharedPreferences.Editor::class.java)) { proxy, method, args ->
|
||||
when (method.name) {
|
||||
"putBoolean", "putInt" -> { pending[args!![0] as String] = args[1]; proxy }
|
||||
"commit" -> {
|
||||
values.putAll(pending)
|
||||
val success = !failNextCommit
|
||||
failNextCommit = false
|
||||
success
|
||||
}
|
||||
else -> error("Unexpected preference edit ${method.name}")
|
||||
}
|
||||
} as SharedPreferences.Editor
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,163 +0,0 @@
|
||||
package cn.ilapage.goauto.agent
|
||||
|
||||
import cn.ilapage.goauto.agent.automation.*
|
||||
import cn.ilapage.goauto.agent.service.ContinuousCollectionPolicy
|
||||
import cn.ilapage.goauto.agent.service.TaskExecutionSummary
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Test
|
||||
|
||||
class ContinuousCollectionTest {
|
||||
private val complete = TaskExecutionSummary(true, resultStatus = "completed")
|
||||
|
||||
@Test fun `only new ordinary temporary collection participates`() {
|
||||
assertTrue(ContinuousCollectionPolicy.applies(true, "agent_current_page", 1, ""))
|
||||
assertFalse(ContinuousCollectionPolicy.applies(false, "agent_current_page", 1, ""))
|
||||
for (source in listOf("admin", "purchase")) assertFalse(ContinuousCollectionPolicy.applies(true, source, 1, ""))
|
||||
assertFalse(ContinuousCollectionPolicy.applies(true, "agent_current_page", 2, ""))
|
||||
for (origin in listOf("collection", "purchase")) assertFalse(ContinuousCollectionPolicy.applies(true, "agent_current_page", 1, origin))
|
||||
}
|
||||
|
||||
@Test fun `only acknowledged complete result can navigate`() {
|
||||
for (status in listOf("completed_partial", "failed", "running", null)) assertFalse(ContinuousCollectionPolicy.shouldReturn(true, true, status))
|
||||
assertFalse(ContinuousCollectionPolicy.shouldReturn(true, false, "completed"))
|
||||
assertFalse(ContinuousCollectionPolicy.shouldReturn(false, true, "completed"))
|
||||
assertTrue(ContinuousCollectionPolicy.shouldReturn(true, true, "completed"))
|
||||
}
|
||||
|
||||
@Test fun `failure partial and disabled never invoke navigation`() {
|
||||
val failed = TaskExecutionSummary(false, "failed", "failure")
|
||||
assertEquals(failed, ContinuousCollectionPolicy.finish(true, failed) { error("unexpected") })
|
||||
assertEquals(complete, ContinuousCollectionPolicy.finish(false, complete) { error("unexpected") })
|
||||
val partial = ContinuousCollectionPolicy.finish(true, complete.copy(resultStatus = "completed_partial")) { error("unexpected") }
|
||||
assertTrue(partial.message!!.contains("保留现场"))
|
||||
}
|
||||
|
||||
@Test fun `navigation failure never rewrites collection success`() {
|
||||
for (navigate in listOf<() -> Boolean>({ false }, { error("failure") })) {
|
||||
val result = ContinuousCollectionPolicy.finish(true, complete, navigate)
|
||||
assertTrue(result.successful)
|
||||
assertEquals(complete.code, result.code)
|
||||
assertEquals("completed", result.resultStatus)
|
||||
assertTrue(result.message!!.contains("手动"))
|
||||
}
|
||||
val success = ContinuousCollectionPolicy.finish(true, complete) { true }
|
||||
assertTrue(success.message!!.contains("已切回 Agent"))
|
||||
assertFalse(success.message!!.contains("首页"))
|
||||
}
|
||||
|
||||
@Test fun `configured counts send exact backs even when page is unchanged`() {
|
||||
for (count in 1..5) {
|
||||
val d = Driver()
|
||||
val waits = mutableListOf<Long>()
|
||||
val navigator = d.navigator { waits += it }
|
||||
assertTrue(navigator.returnToAgent(count))
|
||||
assertEquals(count, d.backs)
|
||||
assertEquals(count, navigator.actions)
|
||||
assertEquals(1, d.opens)
|
||||
assertEquals(List(count + 1) { 500L }, waits)
|
||||
assertEquals("count_completed", navigator.reason)
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun `invalid counts do not navigate`() {
|
||||
for (count in listOf(-1, 0, 6, Int.MAX_VALUE)) {
|
||||
val d = Driver()
|
||||
assertFalse(d.navigator().returnToAgent(count))
|
||||
assertEquals(0, d.backs + d.opens)
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun `leaving pdd stops remaining backs and returns to agent`() {
|
||||
val d = Driver().apply { leaveAfter = 1 }
|
||||
val navigator = d.navigator()
|
||||
assertTrue(navigator.returnToAgent(3))
|
||||
assertEquals(1, d.backs)
|
||||
assertEquals(1, d.opens)
|
||||
assertEquals("left_pdd", navigator.reason)
|
||||
}
|
||||
|
||||
@Test fun `already outside pdd sends no back`() {
|
||||
val d = Driver().apply { pkg = "launcher" }
|
||||
assertTrue(d.navigator().returnToAgent(3))
|
||||
assertEquals(0, d.backs)
|
||||
assertEquals(1, d.opens)
|
||||
}
|
||||
|
||||
@Test fun `back failure stops without retry but can return to agent`() {
|
||||
val d = Driver().apply { backSuccess = false }
|
||||
val navigator = d.navigator()
|
||||
assertTrue(navigator.returnToAgent(3))
|
||||
assertEquals(1, d.backs)
|
||||
assertEquals("back_failed", navigator.reason)
|
||||
}
|
||||
|
||||
@Test fun `risk pages preserve scene including after last back`() {
|
||||
for (after in listOf(0, 1)) {
|
||||
val d = Driver().apply { riskAfter = after }
|
||||
val navigator = d.navigator()
|
||||
assertFalse(navigator.returnToAgent(1))
|
||||
assertEquals(after, d.backs)
|
||||
assertEquals(0, d.opens)
|
||||
assertEquals("unsafe_page", navigator.reason)
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun `switching foreground during capture never sends back to other app`() {
|
||||
val d = Driver().apply { changeDuringCapture = true }
|
||||
assertTrue(d.navigator().returnToAgent(3))
|
||||
assertEquals(0, d.backs)
|
||||
}
|
||||
|
||||
@Test fun `agent launch and foreground confirmation failures are explicit`() {
|
||||
for (launch in listOf(false, true)) {
|
||||
val d = Driver().apply { launchSuccess = launch; confirmAgent = false }
|
||||
val navigator = d.navigator()
|
||||
assertFalse(navigator.returnToAgent(1))
|
||||
assertEquals(if (launch) "agent_not_confirmed" else "agent_launch_failed", navigator.reason)
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun `missing activity or capture exception does not send back`() {
|
||||
for (throws in listOf(false, true)) {
|
||||
val d = Driver().apply { missingActivity = true; throwCapture = throws }
|
||||
assertFalse(d.navigator().returnToAgent(3))
|
||||
assertEquals(0, d.backs + d.opens)
|
||||
}
|
||||
}
|
||||
|
||||
private class Driver : PddCollectorDriver {
|
||||
var pkg = "com.xunmeng.pinduoduo"
|
||||
var backs = 0
|
||||
var opens = 0
|
||||
var leaveAfter = Int.MAX_VALUE
|
||||
var riskAfter = Int.MAX_VALUE
|
||||
var backSuccess = true
|
||||
var launchSuccess = true
|
||||
var confirmAgent = true
|
||||
var missingActivity = false
|
||||
var throwCapture = false
|
||||
var changeDuringCapture = false
|
||||
fun navigator(pause: (Long) -> Unit = {}) = PddCollectionReturnNavigator(this, {
|
||||
opens++
|
||||
if (launchSuccess && confirmAgent) pkg = "cn.ilapage.goauto.agent"
|
||||
launchSuccess
|
||||
}, { pkg }, pause)
|
||||
override fun capture(): UiSnapshot {
|
||||
if (throwCapture) error("capture failed")
|
||||
val snapshot = UiSnapshot(pkg, if (missingActivity) null else "PddActivity", if (backs >= riskAfter) listOf(
|
||||
SnapshotNode("risk", null, "订单详情", null, null, "android.widget.TextView", NodeBounds(0, 0, 100, 100),
|
||||
false, false, false, false, true, true),
|
||||
) else emptyList())
|
||||
if (changeDuringCapture) pkg = "launcher"
|
||||
return snapshot
|
||||
}
|
||||
override fun back(): Boolean {
|
||||
backs++
|
||||
if (backs >= leaveAfter) pkg = "launcher"
|
||||
return backSuccess
|
||||
}
|
||||
override fun clickFresh(target: SnapshotNode) = error("must not click page controls")
|
||||
override fun swipeSpec(direction: SwipeDirection, anchor: SnapshotNode?) = error("must not swipe")
|
||||
override fun pullDownGoodsPage() = error("must not pull down")
|
||||
}
|
||||
}
|
||||
@@ -27,66 +27,6 @@ import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class PddProductDetailCollectorTest {
|
||||
@Test fun `purchase entry can recognize one heading only with combined structure`() {
|
||||
val snapshot = singleDimensionPanel()
|
||||
assertFalse(PddScreenParser.parse(snapshot, config(), GOODS_ID, null).specPanelOpen)
|
||||
assertEquals(SpecPanelType.NORMAL_SCROLLABLE,
|
||||
PddScreenParser.parse(snapshot, config(), GOODS_ID, null, purchaseEntryContext = true).specPanelType)
|
||||
for (missing in listOf("close", "plus", "minus", "info/quantity", "payment", "submit", "scroll/color-title", "scroll/color")) {
|
||||
val changed = snapshot.copy(nodes = snapshot.nodes.filterNot { it.path == missing })
|
||||
assertFalse(missing, PddScreenParser.parse(changed, config(), GOODS_ID, null, purchaseEntryContext = true).specPanelOpen)
|
||||
}
|
||||
assertFalse(PddScreenParser.parse(snapshot.copy(packageName = "other"), config(), GOODS_ID, null, purchaseEntryContext = true).specPanelOpen)
|
||||
assertFalse(PddScreenParser.parse(snapshot.copy(activityName = null), config(), GOODS_ID, null, purchaseEntryContext = true).specPanelOpen)
|
||||
assertFalse(PddScreenParser.parse(snapshot, config(), "", null, purchaseEntryContext = true).specPanelOpen)
|
||||
}
|
||||
|
||||
private fun singleDimensionPanel(): UiSnapshot {
|
||||
val snapshot = prefixlessPanel()
|
||||
return snapshot.copy(nodes = snapshot.nodes.filterNot {
|
||||
it.path.startsWith("scroll/size") || it.path == "info/summary"
|
||||
}.map {
|
||||
if (it.path == "root") it.copy(resourceId = "android:id/content", className = "android.widget.FrameLayout") else it
|
||||
})
|
||||
}
|
||||
|
||||
@Test fun `single dimension without summary is recognized only during collection`() {
|
||||
for (heading in listOf("颜色分类", "尺码")) {
|
||||
val snapshot = singleDimensionPanel().let { original ->
|
||||
original.copy(nodes = original.nodes.map {
|
||||
if (it.path == "scroll/color-title") it.copy(text = heading) else it
|
||||
})
|
||||
}
|
||||
assertFalse(PddScreenParser.parse(snapshot, config(), GOODS_ID, evidence()).specPanelOpen)
|
||||
val parsed = PddScreenParser.parse(snapshot, config(), GOODS_ID, evidence(), collectionContext = true)
|
||||
assertEquals(SpecPanelType.NORMAL_SCROLLABLE, parsed.specPanelType)
|
||||
assertEquals("scroll", parsed.specPanelContainer?.path)
|
||||
assertEquals(1, parsed.dimensions.size)
|
||||
assertEquals(null, parsed.selectedSummary)
|
||||
assertFalse(parsed.hasSelectionSummary)
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun `single dimension collection requires all structural and page evidence`() {
|
||||
val snapshot = singleDimensionPanel()
|
||||
for (missing in listOf("close", "plus", "minus", "info/quantity", "payment", "submit", "scroll/color-title", "scroll/color", "root")) {
|
||||
val changed = snapshot.copy(nodes = snapshot.nodes.filterNot { it.path == missing })
|
||||
assertFalse(missing, PddScreenParser.parse(changed, config(), GOODS_ID, evidence(), collectionContext = true).specPanelOpen)
|
||||
}
|
||||
val secondScroll = node("other-scroll", "", 0, 500, 1080, 700, scrollable = true)
|
||||
val secondSubmit = node("other-submit", "提交订单", 10, 2200, 300, 2300, clickable = true)
|
||||
for (changed in listOf(
|
||||
snapshot.copy(nodes = snapshot.nodes + secondScroll),
|
||||
snapshot.copy(nodes = snapshot.nodes + secondSubmit),
|
||||
snapshot.copy(packageName = "example.other"),
|
||||
snapshot.copy(activityName = "example.OtherActivity"),
|
||||
snapshot.copy(nodes = snapshot.nodes + node("captcha", "请完成安全验证", 0, 0, 600, 100)),
|
||||
)) {
|
||||
assertFalse(PddScreenParser.parse(changed, config(), GOODS_ID, evidence(), collectionContext = true).specPanelOpen)
|
||||
}
|
||||
assertFalse(PddScreenParser.parse(snapshot, config(), GOODS_ID, null, collectionContext = true).specPanelOpen)
|
||||
}
|
||||
|
||||
private fun prefixlessPanel(): UiSnapshot = UiSnapshot(PDD_PACKAGE, ACTIVITY, listOf(
|
||||
node("root", "", 0, 0, 1080, 2376),
|
||||
node("close", "关闭", 970, 270, 1050, 350, clickable = true),
|
||||
|
||||
@@ -18,213 +18,6 @@ import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class PurchaseLiveAutomationTest {
|
||||
@Test fun `address editor waits for loaded unique stable input after one second transitions`() {
|
||||
val driver = LiveDriver()
|
||||
var polls = 0
|
||||
driver.editTransform = { snapshot ->
|
||||
polls++
|
||||
when (polls) {
|
||||
1 -> snapshot.copy(nodes = snapshot.nodes.filterNot { it.path == "editor-address" })
|
||||
2 -> snapshot.copy(nodes = snapshot.nodes.map { if (it.path == "editor-address") it.copy(text = "") else it })
|
||||
else -> snapshot
|
||||
}
|
||||
}
|
||||
val pauses = mutableListOf<Long>()
|
||||
PurchaseLiveAutomation(driver, pause = { pauses += it }).updateShippingAddress("_cg81")
|
||||
assertTrue(polls >= 4)
|
||||
assertEquals("editor-address", driver.lastInputTargetPath)
|
||||
assertEquals(4, pauses.count { it == 1_000L })
|
||||
assertEquals(0, driver.submitClicks)
|
||||
}
|
||||
|
||||
@Test fun `persistently ambiguous address editors never receive input`() {
|
||||
val driver = LiveDriver()
|
||||
driver.editTransform = { snapshot -> snapshot.copy(nodes = snapshot.nodes + snapshot.nodes.single { it.path == "editor-address" }.copy(path = "duplicate-editor")) }
|
||||
val error = runCatching { PurchaseLiveAutomation(driver, pause = {}).updateShippingAddress("_cg81") }.exceptionOrNull() as PurchaseLiveException
|
||||
assertEquals("PURCHASE_ADDRESS_UPDATE_FAILED", error.code)
|
||||
assertTrue(error.message!!.contains("candidates=2"))
|
||||
assertEquals(null, driver.lastInputTargetPath)
|
||||
assertFalse(driver.clicked.contains("保存地址"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `non scrollable saved panel waits for late address nodes without back or swipe`() {
|
||||
val driver = LiveDriver(savedStructuredFooter = "复购价,")
|
||||
var polls = 0
|
||||
driver.structuredTransform = { s ->
|
||||
polls++
|
||||
s.copy(nodes = s.nodes.filterNot {
|
||||
it.path.startsWith("root/popup/specs") ||
|
||||
(polls < 8 && it.path.startsWith("root/popup/address"))
|
||||
})
|
||||
}
|
||||
val logs = mutableListOf<String>()
|
||||
val automation = PurchaseLiveAutomation(driver, pause = {}, diagnostic = logs::add)
|
||||
val proof = automation.updateShippingAddress("_cg81")
|
||||
automation.finalConfirmation(input().copy(addressSuffix = "_cg81"), proof)
|
||||
assertTrue(polls >= 9)
|
||||
assertEquals(0, driver.scopedSwipes)
|
||||
assertEquals(0, driver.backCount)
|
||||
assertEquals(0, driver.submitClicks)
|
||||
assertTrue(logs.any { it.contains("savedAddressMatched=true") && it.contains("savedScrollCandidates=0") })
|
||||
assertFalse(logs.any { it.contains("_cg81") || it.contains("138") })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `saved address recognizes split dynamic footer outside spec scroller without a swipe`() {
|
||||
for (label in listOf("复购价,", "专享优惠,")) {
|
||||
val driver = LiveDriver(savedStructuredFooter = label)
|
||||
val automation = PurchaseLiveAutomation(driver, pause = {})
|
||||
val proof = automation.updateShippingAddress("_cg81")
|
||||
automation.finalConfirmation(input().copy(addressSuffix = "_cg81"), proof)
|
||||
automation.submitOrderOnce()
|
||||
assertEquals(1, driver.submitClicks)
|
||||
assertEquals(0, driver.scopedSwipes)
|
||||
assertEquals(0, driver.backCount)
|
||||
assertTrue(driver.clicked.contains(label))
|
||||
assertEquals("PURCHASE_SUBMIT_ALREADY_ATTEMPTED",
|
||||
(runCatching { automation.submitOrderOnce() }.exceptionOrNull() as PurchaseLiveException).code)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `one back from address transition can recognize dynamic footer`() {
|
||||
val driver = LiveDriver(savedStructuredFooter = "复购价,", savedTransitionWithoutLegacyContext = true)
|
||||
val automation = PurchaseLiveAutomation(driver, pause = {})
|
||||
val proof = automation.updateShippingAddress("_cg81")
|
||||
automation.finalConfirmation(input().copy(addressSuffix = "_cg81"), proof)
|
||||
assertEquals(1, driver.backCount)
|
||||
assertEquals(0, driver.scopedSwipes)
|
||||
assertEquals(0, driver.submitClicks)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `saved address with missing ambiguous price only or payment footer reports button failure without scrolling`() {
|
||||
val mutations: List<(UiSnapshot) -> UiSnapshot> = listOf(
|
||||
{ s -> s.copy(nodes = s.nodes.filterNot { it.path.startsWith("root/popup/footer") }) },
|
||||
{ s -> s.copy(nodes = s.nodes + s.nodes.filter { it.path.startsWith("root/popup/footer") }.map {
|
||||
it.copy(path = it.path.replace("footer", "second-footer"), parentPath = it.parentPath?.replace("footer", "second-footer"))
|
||||
}) },
|
||||
{ s -> s.copy(nodes = s.nodes.filterNot { it.path == "root/popup/footer/label" }) },
|
||||
{ s -> s.copy(nodes = s.nodes.map { if (it.path == "root/popup/footer") it.copy(clickable = false) else it }) },
|
||||
{ s -> s.copy(nodes = s.nodes.map { if (it.path == "root/popup/footer/label") it.copy(text = "去付款") else it }) },
|
||||
{ s -> s.copy(nodes = s.nodes.map { if (it.path == "root/popup/footer") it.copy(enabled = false) else it }) },
|
||||
)
|
||||
for (mutation in mutations) {
|
||||
val driver = LiveDriver(savedStructuredFooter = "复购价,")
|
||||
driver.structuredTransform = mutation
|
||||
val error = runCatching { PurchaseLiveAutomation(driver, pause = {}).updateShippingAddress("_cg81") }
|
||||
.exceptionOrNull() as PurchaseLiveException
|
||||
assertEquals("PURCHASE_SUBMIT_TARGET_AMBIGUOUS", error.code)
|
||||
assertEquals(0, driver.scopedSwipes)
|
||||
assertEquals(0, driver.backCount)
|
||||
assertEquals(0, driver.submitClicks)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `legacy submit labels retain priority over structure`() {
|
||||
for (label in listOf("提交订单", "现在买,仅20元", "确认购买")) {
|
||||
val driver = LiveDriver(savedStructuredFooter = label)
|
||||
val automation = PurchaseLiveAutomation(driver, pause = {})
|
||||
val proof = automation.updateShippingAddress("_cg81")
|
||||
automation.finalConfirmation(input().copy(addressSuffix = "_cg81"), proof)
|
||||
automation.submitOrderOnce()
|
||||
assertEquals(1, driver.submitClicks)
|
||||
assertEquals(0, driver.scopedSwipes)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `structural footer cannot bypass final spec quantity price or address verification`() {
|
||||
val cases = listOf(
|
||||
input().copy(mappedColor = "白色") to "PURCHASE_SPEC_NOT_MATCHED",
|
||||
input().copy(quantity = 3) to "PURCHASE_QUANTITY_MISMATCH",
|
||||
input().copy(maxUnitPriceCent = 100) to "PURCHASE_PRICE_OUT_OF_RANGE",
|
||||
)
|
||||
for ((request, code) in cases) {
|
||||
val driver = LiveDriver(savedStructuredFooter = "复购价,")
|
||||
val automation = PurchaseLiveAutomation(driver, pause = {})
|
||||
val proof = automation.updateShippingAddress("_cg81")
|
||||
val error = runCatching { automation.finalConfirmation(request.copy(addressSuffix = "_cg81"), proof) }
|
||||
.exceptionOrNull() as PurchaseLiveException
|
||||
assertEquals(code, error.code)
|
||||
assertEquals(0, driver.submitClicks)
|
||||
}
|
||||
val driver = LiveDriver(savedStructuredFooter = "复购价,")
|
||||
val automation = PurchaseLiveAutomation(driver, pause = {})
|
||||
val proof = automation.updateShippingAddress("_cg81")
|
||||
driver.structuredTransform = { s -> s.copy(nodes = s.nodes.filterNot { it.path.endsWith("/address/detail") }) }
|
||||
assertEquals("PURCHASE_ADDRESS_UPDATE_FAILED",
|
||||
(runCatching { automation.finalConfirmation(input(), proof) }.exceptionOrNull() as PurchaseLiveException).code)
|
||||
assertEquals(0, driver.submitClicks)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `structural footer needs this execution saved address and unchanged page at submission`() {
|
||||
val driver = LiveDriver(savedStructuredFooter = "复购价,")
|
||||
val automation = PurchaseLiveAutomation(driver, pause = {})
|
||||
val proof = automation.updateShippingAddress("_cg81")
|
||||
assertEquals("PURCHASE_SUBMIT_TARGET_AMBIGUOUS",
|
||||
(runCatching { automation.submitOrderOnce() }.exceptionOrNull() as PurchaseLiveException).code)
|
||||
assertEquals("PURCHASE_SUBMIT_TARGET_AMBIGUOUS",
|
||||
(runCatching { PurchaseLiveAutomation(driver, pause = {}).finalConfirmation(input(), proof) }
|
||||
.exceptionOrNull() as PurchaseLiveException).code)
|
||||
automation.finalConfirmation(input().copy(addressSuffix = "_cg81"), proof)
|
||||
driver.structuredTransform = { s -> s.copy(nodes = s.nodes.map {
|
||||
if (it.path.endsWith("footer/label")) it.copy(text = "另一操作") else it
|
||||
}) }
|
||||
assertEquals("PURCHASE_SUBMIT_TARGET_AMBIGUOUS",
|
||||
(runCatching { automation.submitOrderOnce() }.exceptionOrNull() as PurchaseLiveException).code)
|
||||
assertEquals(0, driver.submitClicks)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `footer duplicate text nodes remain one action and external overlays are not candidates`() {
|
||||
val driver = LiveDriver(savedStructuredFooter = "复购价,")
|
||||
driver.structuredTransform = { s -> s.copy(nodes = s.nodes + listOf(
|
||||
s.nodes.first { it.path == "root/popup/footer/label" }.copy(
|
||||
path = "root/popup/footer/duplicate-label", bounds = NodeBounds(760, 2203, 950, 2262)),
|
||||
s.nodes.first { it.path == "root/popup/footer" }.copy(path = "root/overlay", parentPath = "root", text = "悬浮按钮"),
|
||||
)) }
|
||||
val automation = PurchaseLiveAutomation(driver, pause = {})
|
||||
val proof = automation.updateShippingAddress("_cg81")
|
||||
automation.finalConfirmation(input().copy(addressSuffix = "_cg81"), proof)
|
||||
automation.submitOrderOnce()
|
||||
assertEquals(1, driver.submitClicks)
|
||||
assertFalse(driver.clicked.contains("悬浮按钮"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `failed revalidation clears structural submit authorization`() {
|
||||
val driver = LiveDriver(savedStructuredFooter = "复购价,")
|
||||
val automation = PurchaseLiveAutomation(driver, pause = {})
|
||||
val proof = automation.updateShippingAddress("_cg81")
|
||||
automation.finalConfirmation(input(), proof)
|
||||
assertTrue(runCatching { automation.finalConfirmation(input().copy(quantity = 3), proof) }.isFailure)
|
||||
assertTrue(runCatching { automation.submitOrderOnce() }.isFailure)
|
||||
assertEquals(0, driver.submitClicks)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `address alone full page ancestor and order or payment pages cannot authorize structural footer`() {
|
||||
val mutations: List<(UiSnapshot) -> UiSnapshot> = listOf(
|
||||
{ s -> s.copy(nodes = s.nodes.filterNot { it.path.endsWith("/quantity") || it.path.endsWith("/selected") }) },
|
||||
{ s -> s.copy(nodes = s.nodes.map { if (it.path == "root/popup") it.copy(bounds = NodeBounds(0, 0, 1080, 2354)) else it }) },
|
||||
{ s -> s.copy(activityName = "com.xunmeng.pinduoduo.app_pay.core.PayActivity") },
|
||||
{ s -> s.copy(packageName = "example.other") },
|
||||
{ s -> s.copy(nodes = s.nodes.map { if (it.path.endsWith("/selected")) it.copy(text = "订单详情") else it }) },
|
||||
)
|
||||
for (mutation in mutations) {
|
||||
val driver = LiveDriver(savedStructuredFooter = "复购价,")
|
||||
val automation = PurchaseLiveAutomation(driver, pause = {})
|
||||
val proof = automation.updateShippingAddress("_cg81")
|
||||
driver.structuredTransform = mutation
|
||||
assertTrue(runCatching { automation.finalConfirmation(input(), proof) }.isFailure)
|
||||
assertEquals(0, driver.submitClicks)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `verified spec panel advances through one exact confirm target`() {
|
||||
val driver = SpecConfirmationDriver()
|
||||
@@ -348,72 +141,6 @@ class PurchaseLiveAutomationTest {
|
||||
assertEquals(0, driver.submitClicks)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `saved address back into short spec panel restores without aspect ratio restriction`() {
|
||||
val driver = LiveDriver(savedTransitionWithoutLegacyContext = true, backReturnsToSpecPanel = true,
|
||||
savedPanelBounds = NodeBounds(0, 400, 1080, 1325))
|
||||
val automation = PurchaseLiveAutomation(driver, pause = {})
|
||||
val address = automation.updateShippingAddress("_cg81")
|
||||
|
||||
assertEquals("_cg81", automation.finalConfirmation(input().copy(addressSuffix = "_cg81"), address).addressSuffix)
|
||||
assertEquals(1, driver.backCount)
|
||||
assertEquals(1, driver.scopedSwipes)
|
||||
assertEquals(0, driver.submitClicks)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `short saved spec panel still stops when unchanged`() {
|
||||
val driver = LiveDriver(saveReturnsToSpecPanel = true, savedSpecPanelRecoveryStuck = true,
|
||||
savedPanelBounds = NodeBounds(0, 400, 1080, 1325))
|
||||
val error = runCatching { PurchaseLiveAutomation(driver, pause = {}).updateShippingAddress("_cg81") }
|
||||
.exceptionOrNull() as PurchaseLiveException
|
||||
assertEquals("PURCHASE_ADDRESS_SAVE_TIMEOUT", error.code)
|
||||
assertEquals(0, driver.backCount)
|
||||
assertTrue(driver.scopedSwipes in 1..5)
|
||||
assertEquals(0, driver.submitClicks)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `missing and duplicate saved panel scroll regions fail distinctly without gestures`() {
|
||||
for (count in listOf(0, 2)) {
|
||||
val driver = LiveDriver(saveReturnsToSpecPanel = true, savedPanelScrollCount = count)
|
||||
val error = runCatching { PurchaseLiveAutomation(driver, pause = {}).updateShippingAddress("_cg81") }
|
||||
.exceptionOrNull() as PurchaseLiveException
|
||||
assertEquals("PURCHASE_ADDRESS_ENTRY_AMBIGUOUS", error.code)
|
||||
assertTrue(error.message.orEmpty().contains(if (count == 0) "未找到可用" else "找到多个"))
|
||||
assertEquals(0, driver.scopedSwipes)
|
||||
assertEquals(0, driver.submitClicks)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `saved address back into spec panel restores final evidence with one back`() {
|
||||
val driver = LiveDriver(savedTransitionWithoutLegacyContext = true, backReturnsToSpecPanel = true)
|
||||
val automation = PurchaseLiveAutomation(driver, pause = {})
|
||||
|
||||
val address = automation.updateShippingAddress("_cg81")
|
||||
val final = automation.finalConfirmation(input().copy(addressSuffix = "_cg81"), address)
|
||||
|
||||
assertEquals("_cg81", final.addressSuffix)
|
||||
assertEquals(1, driver.backCount)
|
||||
assertEquals(1, driver.scopedSwipes)
|
||||
assertEquals(0, driver.submitClicks)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `saved address back into stuck spec panel fails without another back or submit`() {
|
||||
val driver = LiveDriver(savedTransitionWithoutLegacyContext = true, backReturnsToSpecPanel = true,
|
||||
savedSpecPanelRecoveryStuck = true)
|
||||
|
||||
val error = runCatching { PurchaseLiveAutomation(driver, pause = {}).updateShippingAddress("_cg81") }
|
||||
.exceptionOrNull() as PurchaseLiveException
|
||||
|
||||
assertEquals("PURCHASE_ADDRESS_SAVE_TIMEOUT", error.code)
|
||||
assertEquals(1, driver.backCount)
|
||||
assertTrue(driver.scopedSwipes in 1..5)
|
||||
assertEquals(0, driver.submitClicks)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `saved address returning to spec panel is recovered without pressing back`() {
|
||||
val driver = LiveDriver(saveReturnsToSpecPanel = true)
|
||||
@@ -918,11 +645,7 @@ class PurchaseLiveAutomationTest {
|
||||
private val savedTransitionWithoutLegacyContext: Boolean = false,
|
||||
private val savedTransitionHidesSuffix: Boolean = false,
|
||||
private val saveReturnsToSpecPanel: Boolean = false,
|
||||
private val backReturnsToSpecPanel: Boolean = false,
|
||||
private val savedPanelBounds: NodeBounds = NodeBounds(0, 400, 1080, 2100),
|
||||
private val savedPanelScrollCount: Int = 1,
|
||||
private val savedSpecPanelRecoveryStuck: Boolean = false,
|
||||
private val savedStructuredFooter: String? = null,
|
||||
private val duplicatePhoneNodesSameCard: Boolean = false,
|
||||
private val duplicateSemanticAddressCards: Boolean = false,
|
||||
private val duplicateAddressCards: Boolean = false,
|
||||
@@ -953,34 +676,16 @@ class PurchaseLiveAutomationTest {
|
||||
var orderDetailEntryClicks = 0
|
||||
var postSubmitCaptureCount = 0
|
||||
val currentPage: String get() = page
|
||||
var structuredTransform: (UiSnapshot) -> UiSnapshot = { it }
|
||||
var editTransform: (UiSnapshot) -> UiSnapshot = { it }
|
||||
|
||||
override fun capture(): UiSnapshot {
|
||||
if (submitClicks > 0) {
|
||||
postSubmitCaptureCount++
|
||||
if (pendingPostSubmitPages.isNotEmpty()) page = pendingPostSubmitPages.removeAt(0)
|
||||
}
|
||||
return currentSnapshot().let { if (page == "structured") structuredTransform(it) else if (page == "edit") editTransform(it) else it }
|
||||
return currentSnapshot()
|
||||
}
|
||||
|
||||
private fun currentSnapshot(): UiSnapshot = when (page) {
|
||||
"structured" -> snapshot(listOf(
|
||||
node("root", "", bounds = NodeBounds(0, 0, 1080, 2354)),
|
||||
node("root/popup", "", clickable = true, parentPath = "root", bounds = NodeBounds(0, 366, 1080, 2306)),
|
||||
node("root/popup/address", "", clickable = true, parentPath = "root/popup", bounds = NodeBounds(0, 400, 1080, 530)),
|
||||
node("root/popup/address/phone", "138****5678", parentPath = "root/popup/address", bounds = NodeBounds(30, 400, 400, 450)),
|
||||
node("root/popup/address/detail", address, parentPath = "root/popup/address", bounds = NodeBounds(30, 456, 1000, 505)),
|
||||
node("root/popup/price", "¥20.00", parentPath = "root/popup", bounds = NodeBounds(396, 620, 700, 690)),
|
||||
node("root/popup/selected", "已选 黑色 XL", parentPath = "root/popup", bounds = NodeBounds(396, 731, 1053, 845)),
|
||||
node("root/popup/quantity", "2", className = "android.widget.EditText", parentPath = "root/popup", bounds = NodeBounds(462, 881, 540, 959)),
|
||||
node("root/popup/specs", "", scrollable = true, className = "androidx.recyclerview.widget.RecyclerView", parentPath = "root/popup", bounds = NodeBounds(0, 1132, 1080, 2057)),
|
||||
node("root/popup/specs/color", "颜色分类", parentPath = "root/popup/specs", bounds = NodeBounds(36, 1157, 287, 1218)),
|
||||
node("root/popup/payment-method", "微信支付", clickable = true, parentPath = "root/popup", bounds = NodeBounds(0, 2057, 1080, 2156)),
|
||||
node("root/popup/footer", "", clickable = true, className = "android.widget.FrameLayout", parentPath = "root/popup", bounds = NodeBounds(0, 2159, 1080, 2306)),
|
||||
node("root/popup/footer/label", requireNotNull(savedStructuredFooter), parentPath = "root/popup/footer", bounds = NodeBounds(353, 2203, 557, 2262)),
|
||||
node("root/popup/footer/amount", "20.00元", parentPath = "root/popup/footer", bounds = NodeBounds(557, 2203, 727, 2262)),
|
||||
))
|
||||
"panel" -> snapshot(listOf(
|
||||
node("title", "收货地址"),
|
||||
node("modify-parent", "", clickable = true),
|
||||
@@ -1003,7 +708,7 @@ class PurchaseLiveAutomationTest {
|
||||
))
|
||||
"post-save-spec" -> snapshot(listOf(
|
||||
node("root", "", bounds = NodeBounds(0, 0, 1080, 2200)),
|
||||
node("panel", "", scrollable = true, bounds = savedPanelBounds),
|
||||
node("panel", "", scrollable = true, bounds = NodeBounds(0, 400, 1080, 2100)),
|
||||
node("panel-title", "确认款式", bounds = NodeBounds(20, 396, 300, 430)),
|
||||
node("panel/price", "¥20.00", parentPath = "panel", bounds = NodeBounds(20, 460, 300, 520)),
|
||||
node("panel/selected", "已选 黑色 XL", parentPath = "panel", bounds = NodeBounds(20, 540, 700, 600)),
|
||||
@@ -1015,8 +720,7 @@ class PurchaseLiveAutomationTest {
|
||||
node("panel/confirm", "确定", clickable = true, parentPath = "panel", bounds = NodeBounds(20, 1200, 500, 1280)),
|
||||
node("submit-parent", "", clickable = true, bounds = NodeBounds(20, 1900, 1000, 2100)),
|
||||
node("submit", "提交订单", parentPath = "submit-parent", bounds = NodeBounds(520, 1940, 980, 2040)),
|
||||
).map { if (it.path == "panel" && savedPanelScrollCount == 0) it.copy(enabled = false) else it } +
|
||||
if (savedPanelScrollCount > 1) listOf(node("panel/duplicate", "", scrollable = true, bounds = savedPanelBounds, parentPath = "panel")) else emptyList())
|
||||
))
|
||||
"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(
|
||||
@@ -1095,11 +799,6 @@ class PurchaseLiveAutomationTest {
|
||||
|
||||
override fun clickFresh(target: SnapshotNode): FreshActionResult {
|
||||
clicked += target.label
|
||||
if (target.path == "root/popup/footer/label") {
|
||||
submitClicks++
|
||||
page = "order"
|
||||
return FreshActionResult.SUCCESS
|
||||
}
|
||||
when (target.label) {
|
||||
"138****5678" -> page = "panel"
|
||||
"修改" -> page = "edit"
|
||||
@@ -1107,7 +806,6 @@ class PurchaseLiveAutomationTest {
|
||||
page = when {
|
||||
saveReturnsToSpecPanel -> "post-save-spec"
|
||||
savedTransitionWithoutLegacyContext -> "saved-transition"
|
||||
savedStructuredFooter != null -> "structured"
|
||||
else -> "panel"
|
||||
}
|
||||
}
|
||||
@@ -1165,7 +863,6 @@ class PurchaseLiveAutomationTest {
|
||||
page = when (page) {
|
||||
"chooser" -> "payment"
|
||||
"payment" -> if (orderEvidenceBelowFold) "order-folded" else "order"
|
||||
"saved-transition" -> if (savedStructuredFooter != null) "structured" else if (backReturnsToSpecPanel) "post-save-spec" else "confirmation"
|
||||
else -> "confirmation"
|
||||
}
|
||||
return true
|
||||
|
||||
+8
-295
@@ -9,94 +9,19 @@ 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.PurchaseSwipeFailureReason
|
||||
import cn.ilapage.goauto.agent.automation.PurchaseUiDriver
|
||||
import cn.ilapage.goauto.agent.automation.RuleValidationException
|
||||
import cn.ilapage.goauto.agent.automation.SnapshotNode
|
||||
import cn.ilapage.goauto.agent.automation.SwipeDirection
|
||||
import cn.ilapage.goauto.agent.automation.UiSnapshot
|
||||
import cn.ilapage.goauto.agent.persistence.PendingPurchaseOutbox
|
||||
import cn.ilapage.goauto.agent.persistence.PurchaseDiagnosticContext
|
||||
import cn.ilapage.goauto.agent.persistence.PurchaseDiagnosticStore
|
||||
import cn.ilapage.goauto.agent.persistence.PurchaseOutboxUploader
|
||||
import org.json.JSONObject
|
||||
import org.junit.Rule
|
||||
import org.junit.rules.TemporaryFolder
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class PurchaseRehearsalExecutorTest {
|
||||
@Test fun `single heading probe hands recognition to new purchase executor without opening link`() {
|
||||
val driver = FakePurchaseDriver(singleHeadingOnEntry = true)
|
||||
var handoff: String? = null
|
||||
val probe = PurchaseRehearsalExecutor(driver, { true }, { "{}" }, pause = {}, onProbeHandoff = { handoff = it })
|
||||
.execute(input().copy(phase = "spec_probe"), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
|
||||
assertEquals("spec_probe_completed", probe.resultType)
|
||||
assertTrue(handoff != null)
|
||||
var opens = 0
|
||||
val logs = mutableListOf<String>()
|
||||
PurchaseRehearsalExecutor(driver, { opens++; true }, { null }, pause = {},
|
||||
probeHandoffActivity = handoff, panelDiagnostic = logs::add)
|
||||
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
|
||||
assertEquals(0, opens)
|
||||
assertTrue(logs.any { it.contains("handoffPresent=true;productReused=true;reusePolls=2;") })
|
||||
}
|
||||
|
||||
@Test fun `handoff tolerates transient empty tree but never reuses another app`() {
|
||||
for (mode in listOf("transient", "otherApp", "timeout")) {
|
||||
val original = FakePurchaseDriver(singleHeadingOnEntry = true)
|
||||
var handoff: String? = null
|
||||
PurchaseRehearsalExecutor(original, { true }, { "{}" }, pause = {}, onProbeHandoff = { handoff = it })
|
||||
.execute(input().copy(phase = "spec_probe"), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
|
||||
var captures = 0
|
||||
var opens = 0
|
||||
val driver = object : PurchaseUiDriver by original {
|
||||
override fun capture(): UiSnapshot {
|
||||
captures++
|
||||
return if (captures <= if (mode == "timeout") 10 else 2) UiSnapshot(if (mode == "otherApp") "cn.ilapage.goauto.agent" else null, null, emptyList()) else original.capture()
|
||||
}
|
||||
}
|
||||
val logs = mutableListOf<String>()
|
||||
PurchaseRehearsalExecutor(driver, { opens++; true }, { null }, pause = {},
|
||||
probeHandoffActivity = handoff, panelDiagnostic = logs::add)
|
||||
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
|
||||
assertEquals(if (mode == "transient") 0 else 1, opens)
|
||||
assertTrue(logs.any { it.contains("productReused=${mode == "transient"}") })
|
||||
if (mode == "timeout") assertTrue(logs.any { it.contains("reusePolls=10;reuseReason=evidence_timeout") })
|
||||
}
|
||||
}
|
||||
@Test fun `safe entry with one visible heading completes probe without reopening`() {
|
||||
val driver = FakePurchaseDriver(singleHeadingOnEntry = true)
|
||||
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { "{}" }, pause = {})
|
||||
.execute(input().copy(phase = "spec_probe"), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
|
||||
assertEquals("spec_probe_completed", outcome.resultType)
|
||||
}
|
||||
@get:Rule val diagnosticTemporary = TemporaryFolder()
|
||||
|
||||
@Test
|
||||
fun `entry rejection persists without changing outcome when diagnostic storage fails`() {
|
||||
for (writable in listOf(true, false)) {
|
||||
val location = if (writable) diagnosticTemporary.newFolder() else diagnosticTemporary.newFile()
|
||||
val store = PurchaseDiagnosticStore(location)
|
||||
val context = PurchaseDiagnosticContext(81, "attempt-14", 7, "a".repeat(64), "0.9.67", "purchase")
|
||||
val driver = FakePurchaseDriver(entryActionHasEffect = false, dynamicProduct = true, productOverlay = true,
|
||||
specTapResult = FreshActionResult.SUCCESS)
|
||||
val outcome = PurchaseRehearsalExecutor(driver, { true }, { null }, pause = {},
|
||||
panelDiagnostic = { store.record(context, 0, it); Unit })
|
||||
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
|
||||
assertEquals("PURCHASE_SPEC_PANEL_EVIDENCE_NOT_MATCHED", outcome.errorCode)
|
||||
assertEquals(0, driver.specTapCount)
|
||||
if (writable) {
|
||||
val events = location.listFiles()!!.single().readLines().map { JSONObject(it).getJSONObject("evidence") }
|
||||
assertEquals("close_control", events.last().getString("reason"))
|
||||
assertTrue(events.any { it.optInt("entryWaitPolls") == 30 })
|
||||
assertTrue(events.any { it.optString("entryClick") == "SUCCESS" })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `color selection then single size heading completes without selecting color again`() {
|
||||
val driver = FakePurchaseDriver(prefixlessSingleHeadingAfterColor = true)
|
||||
@@ -173,7 +98,7 @@ class PurchaseRehearsalExecutorTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `rule parameters drive aliases and waits while deprecated panel swipes are ignored`() {
|
||||
fun `rule parameters drive aliases waits and bounded swipes without dangerous clicks`() {
|
||||
val driver = FakePurchaseDriver()
|
||||
val pauses = mutableListOf<Long>()
|
||||
var openCount = 0
|
||||
@@ -187,7 +112,7 @@ class PurchaseRehearsalExecutorTest {
|
||||
assertEquals("rehearsal_completed", outcome.resultType)
|
||||
assertEquals(2_000L, outcome.actualUnitPriceCent)
|
||||
assertEquals(0, openCount)
|
||||
assertEquals(0, driver.swipeCount)
|
||||
assertEquals(2, driver.swipeCount)
|
||||
assertEquals(2L, driver.quantity)
|
||||
assertTrue(driver.clicked.containsAll(listOf("选择规格", "黑色", "XL")))
|
||||
assertFalse(driver.clicked.any { it.contains("订单") || it.contains("支付") })
|
||||
@@ -839,7 +764,7 @@ class PurchaseRehearsalExecutorTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `open spec panel still skips legacy follow-up swipe for confirmed non-scrollable panel`() {
|
||||
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)
|
||||
@@ -848,86 +773,6 @@ class PurchaseRehearsalExecutorTest {
|
||||
assertEquals(0, driver.swipeCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `legacy panel preswipe cannot block visible specs in probe or purchase phase`() {
|
||||
for (nonScrollable in listOf(false, true)) {
|
||||
for (phase in listOf("spec_probe", "purchase")) {
|
||||
val driver = FakePurchaseDriver(nonScrollablePanel = nonScrollable, purchaseSwipeSucceeds = false)
|
||||
val diagnostics = mutableListOf<String>()
|
||||
val pauses = mutableListOf<Long>()
|
||||
var probeCount = 0
|
||||
val raw = rule().replace("\"type\":\"openSpecPanel\"", "\"type\":\"openSpecPanel\",\"waitAfterMs\":321")
|
||||
val outcome = PurchaseRehearsalExecutor(
|
||||
driver, { true }, { probeCount++; "{}" }, pause = pauses::add,
|
||||
panelDiagnostic = diagnostics::add,
|
||||
).execute(input().copy(phase = phase), PurchaseRuleParser.parse(raw), PurchaseAgentCapabilities.supported)
|
||||
assertEquals(outcome.message, if (phase == "spec_probe") "spec_probe_completed" else "rehearsal_completed", outcome.resultType)
|
||||
assertEquals(0, driver.swipeCount)
|
||||
assertTrue(pauses.contains(321L))
|
||||
assertFalse(pauses.contains(1000L))
|
||||
assertTrue(diagnostics.any { it.contains("reason=spec_panel_on_demand") })
|
||||
assertEquals(if (phase == "spec_probe") 1 else 0, probeCount)
|
||||
assertEquals(if (phase == "spec_probe") 0 else 1, driver.clicked.count { it == "黑色" })
|
||||
assertFalse(driver.clicked.any { it.contains("订单") || it.contains("支付") })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `non panel mandatory swipes still execute and fail closed`() {
|
||||
val raw = rule().replace("\"type\":\"selectSpec\"", "\"type\":\"selectSpec\",\"swipeAfter\":{\"direction\":\"up\",\"count\":2,\"durationMs\":500,\"intervalMs\":1000}")
|
||||
for (succeeds in listOf(false, true)) {
|
||||
val driver = FakePurchaseDriver(purchaseSwipeSucceeds = succeeds)
|
||||
val diagnostics = mutableListOf<String>()
|
||||
val pauses = mutableListOf<Long>()
|
||||
val outcome = PurchaseRehearsalExecutor(driver, { true }, { null }, pause = pauses::add, panelDiagnostic = diagnostics::add)
|
||||
.execute(input(), PurchaseRuleParser.parse(raw), PurchaseAgentCapabilities.supported)
|
||||
assertEquals(if (succeeds) "rehearsal_completed" else "failed", outcome.resultType)
|
||||
assertEquals(if (succeeds) 2 else 1, driver.swipeCount)
|
||||
assertEquals(succeeds, pauses.contains(1000L))
|
||||
if (!succeeds) {
|
||||
assertEquals("RULE_ACTION_FAILED", outcome.errorCode)
|
||||
assertTrue(diagnostics.any { it.contains("action=selectSpec") && it.contains("reason=unknown") })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `quick and order confirmation selectors also skip legacy panel preswipe`() {
|
||||
for (kind in listOf("QUICK_CONFIRMATION", "ORDER_CONFIRMATION")) {
|
||||
val driver = FakePurchaseDriver(confirmationPanelKind = kind, purchaseSwipeSucceeds = false)
|
||||
val diagnostics = mutableListOf<String>()
|
||||
var probed = false
|
||||
val outcome = PurchaseRehearsalExecutor(
|
||||
driver, { true }, { probed = true; "{}" }, pause = {}, panelDiagnostic = diagnostics::add,
|
||||
).execute(input().copy(phase = "spec_probe"), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
|
||||
assertEquals(outcome.message, "spec_probe_completed", outcome.resultType)
|
||||
assertTrue(probed)
|
||||
assertEquals(0, driver.swipeCount)
|
||||
assertTrue(diagnostics.any { it.contains("postSwipe=skipped") && it.contains("panel=$kind") })
|
||||
assertFalse(driver.clicked.any { it.contains("订单") || it.contains("支付") || it == "现在买" })
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `mandatory swipe diagnostics use fixed reasons without spec text`() {
|
||||
val raw = rule().replace("\"type\":\"selectSpec\"", "\"type\":\"selectSpec\",\"swipeAfter\":{\"direction\":\"up\",\"count\":1,\"durationMs\":500}")
|
||||
for (reason in PurchaseSwipeFailureReason.values()) {
|
||||
val driver = FakePurchaseDriver(purchaseSwipeSucceeds = false, purchaseSwipeFailure = reason)
|
||||
val diagnostics = mutableListOf<String>()
|
||||
val outcome = PurchaseRehearsalExecutor(driver, { true }, { null }, pause = {}, panelDiagnostic = diagnostics::add)
|
||||
.execute(input(), PurchaseRuleParser.parse(raw), PurchaseAgentCapabilities.supported)
|
||||
assertEquals("RULE_ACTION_FAILED", outcome.errorCode)
|
||||
assertTrue(diagnostics.contains("postSwipe=failed;action=selectSpec;direction=UP;swipeIndex=1;reason=${reason.name.lowercase()}"))
|
||||
assertFalse(diagnostics.any { it.contains("黑色") || it.contains("XL") })
|
||||
}
|
||||
}
|
||||
|
||||
@Test(expected = RuleValidationException::class)
|
||||
fun `ignored legacy panel swipe still rejects invalid rule parameters`() {
|
||||
PurchaseRuleParser.parse(rule().replace("\"count\":2", "\"count\":0"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unrecognized opened panel returns only scalar panel evidence`() {
|
||||
val driver = FakePurchaseDriver(unrecognizedPanel = true)
|
||||
@@ -957,97 +802,6 @@ class PurchaseRehearsalExecutorTest {
|
||||
assertTrue(driver.panel)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `selected color card duplicate does not trigger another selection tap`() {
|
||||
val driver = FakePurchaseDriver(duplicateColorCardEvidence = true)
|
||||
val outcome = PurchaseRehearsalExecutor(driver, { true }, { null }, pause = {})
|
||||
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
|
||||
assertEquals(outcome.message, "rehearsal_completed", outcome.resultType)
|
||||
assertEquals(0, driver.specTapCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `dynamic product page recovers ineffective and failed entry clicks once`() {
|
||||
for (reason in listOf(null, FreshClickReason.ACTION_CLICK_FALSE, FreshClickReason.TARGET_NOT_FOUND)) {
|
||||
val driver = FakePurchaseDriver(entryActionHasEffect = false, dynamicProduct = true,
|
||||
forcedEntryClickReason = reason, specTapResult = FreshActionResult.SUCCESS)
|
||||
val logs = mutableListOf<String>()
|
||||
val outcome = PurchaseRehearsalExecutor(driver, { true }, { null }, pause = {}, panelDiagnostic = logs::add)
|
||||
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
|
||||
assertEquals(outcome.message, "rehearsal_completed", outcome.resultType)
|
||||
assertEquals(1, driver.specTapCount)
|
||||
assertTrue(logs.any { it.contains("entryRecovery=attempted") })
|
||||
assertFalse(logs.any { it.contains("测试商品") || it.contains("选择规格") })
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `dynamic product page with ineffective gesture reports no effect without second tap`() {
|
||||
val driver = FakePurchaseDriver(entryActionHasEffect = false, dynamicProduct = true,
|
||||
specTapResult = FreshActionResult.SUCCESS, specTapHasEffect = false)
|
||||
val outcome = PurchaseRehearsalExecutor(driver, { true }, { null }, pause = {})
|
||||
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
|
||||
assertEquals("PURCHASE_SPEC_ENTRY_CLICK_NO_EFFECT", outcome.errorCode)
|
||||
assertEquals(1, driver.specTapCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `dynamic product with close control never receives recovery tap`() {
|
||||
val driver = FakePurchaseDriver(entryActionHasEffect = false, dynamicProduct = true, productOverlay = true,
|
||||
specTapResult = FreshActionResult.SUCCESS)
|
||||
val outcome = PurchaseRehearsalExecutor(driver, { true }, { null }, pause = {})
|
||||
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
|
||||
assertEquals("PURCHASE_SPEC_PANEL_EVIDENCE_NOT_MATCHED", outcome.errorCode)
|
||||
assertEquals(0, driver.specTapCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `payment method promotion alone permits one safe product entry retry`() {
|
||||
for (effect in listOf(true, false)) {
|
||||
val driver = FakePurchaseDriver(entryActionHasEffect = false, dynamicProduct = true,
|
||||
afterEntryMutation = "payment-promotion", specTapResult = FreshActionResult.SUCCESS, specTapHasEffect = effect)
|
||||
val outcome = PurchaseRehearsalExecutor(driver, { true }, { null }, pause = {})
|
||||
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
|
||||
if (effect) assertEquals(outcome.message, "rehearsal_completed", outcome.resultType)
|
||||
else assertEquals("PURCHASE_SPEC_ENTRY_CLICK_NO_EFFECT", outcome.errorCode)
|
||||
assertEquals(1, driver.specTapCount)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `changed entry identity or unsafe page never receives recovery tap`() {
|
||||
for (mode in listOf("missing", "duplicate", "activity", "payment", "order")) {
|
||||
val diagnostics = mutableListOf<String>()
|
||||
val driver = FakePurchaseDriver(entryActionHasEffect = false, dynamicProduct = true,
|
||||
afterEntryMutation = mode, specTapResult = FreshActionResult.SUCCESS)
|
||||
val outcome = PurchaseRehearsalExecutor(driver, { true }, { null }, pause = {}, panelDiagnostic = diagnostics::add)
|
||||
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
|
||||
assertEquals(mode, "failed", outcome.resultType)
|
||||
assertEquals(mode, 0, driver.specTapCount)
|
||||
val expected = mapOf("missing" to "entry_missing", "duplicate" to "entry_ambiguous",
|
||||
"activity" to "identity_changed", "payment" to "payment_area", "order" to "payment_area").getValue(mode)
|
||||
assertTrue(diagnostics.toString(), diagnostics.any { it.contains("entryRecovery=rejected;reason=$expected;") })
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unavailable exact color stops without selection click or searching even when selected`() {
|
||||
for (selected in listOf(null, "黑色")) {
|
||||
val driver = FakePurchaseDriver(colors = listOf("黑色", "白色"),
|
||||
unavailableColors = setOf("黑色")).apply { color = selected }
|
||||
val logs = mutableListOf<String>()
|
||||
val outcome = PurchaseRehearsalExecutor(driver, { true }, { null }, pause = {}, panelDiagnostic = logs::add)
|
||||
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
|
||||
assertEquals("PURCHASE_SPEC_SAFE_TARGET_MISSING", outcome.errorCode)
|
||||
assertEquals("目标颜色已售罄或当前不可选,未创建订单", outcome.message)
|
||||
assertFalse(driver.clicked.contains("黑色"))
|
||||
assertEquals(0, driver.specTapCount)
|
||||
assertEquals(0, driver.swipeCount)
|
||||
assertTrue(logs.any { it == "specLookup=unavailable;dimension=color;reason=exact_target_unavailable" })
|
||||
assertFalse(logs.any { it.contains("黑色") })
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unchanged spec entry after action and gesture returns no effect`() {
|
||||
val driver = FakePurchaseDriver(
|
||||
@@ -1093,10 +847,10 @@ class PurchaseRehearsalExecutorTest {
|
||||
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 { it.startsWith("specEntryCandidates=") },
|
||||
diagnostics.single(),
|
||||
)
|
||||
assertEquals(21, pauses.count { it == 100L })
|
||||
assertFalse(diagnostics.any { it.contains("选择规格") })
|
||||
assertFalse(diagnostics.single().contains("选择规格"))
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -1464,7 +1218,6 @@ class PurchaseRehearsalExecutorTest {
|
||||
private val reviewBackSucceeds: Boolean = true,
|
||||
private val hiddenSizeUntilUpSwipes: Int = 0,
|
||||
private val prefixlessSingleHeadingAfterColor: Boolean = false,
|
||||
private val singleHeadingOnEntry: Boolean = false,
|
||||
private val hideSizeAfterSelection: Boolean = false,
|
||||
private val revealGridSizeAfterUpSwipes: Int? = null,
|
||||
private val openClickResults: MutableList<FreshActionResult> = mutableListOf(),
|
||||
@@ -1474,10 +1227,6 @@ class PurchaseRehearsalExecutorTest {
|
||||
private val forcedEntryClickReason: FreshClickReason? = null,
|
||||
private val initialSize: String? = null,
|
||||
private val entryActionHasEffect: Boolean = true,
|
||||
private val duplicateColorCardEvidence: Boolean = false,
|
||||
private val dynamicProduct: Boolean = false,
|
||||
private val productOverlay: Boolean = false,
|
||||
private val afterEntryMutation: String = "",
|
||||
private val specTapResult: FreshActionResult = FreshActionResult.FAILED,
|
||||
private val specTapHasEffect: Boolean = true,
|
||||
private val sizeSelectsOnFailedClick: Boolean = false,
|
||||
@@ -1495,13 +1244,10 @@ class PurchaseRehearsalExecutorTest {
|
||||
private val pullDownSucceeds: Boolean = true,
|
||||
private val loseEvidenceAfterPull: Boolean = false,
|
||||
private val unavailableSizes: Set<String> = emptySet(),
|
||||
private val unavailableColors: Set<String> = emptySet(),
|
||||
allSpecsUnavailable: Boolean = false,
|
||||
private val nonScrollablePanel: Boolean = false,
|
||||
private val confirmationPanelKind: String? = null,
|
||||
private val unrecognizedPanel: Boolean = false,
|
||||
private val purchaseSwipeSucceeds: Boolean = true,
|
||||
private val purchaseSwipeFailure: PurchaseSwipeFailureReason = PurchaseSwipeFailureReason.UNKNOWN,
|
||||
initiallyInAgent: Boolean = false,
|
||||
private val panelBecomesUnknownAfterSizeProof: Boolean = false,
|
||||
) : PurchaseUiDriver {
|
||||
@@ -1532,7 +1278,6 @@ class PurchaseRehearsalExecutorTest {
|
||||
private var horizontalColorPage = 0
|
||||
private var horizontalSizePage = 0
|
||||
private var capturesAfterSizeSelection = 0
|
||||
private var entryAttempted = false
|
||||
val clicked = mutableListOf<String>()
|
||||
val clickedPaths = mutableListOf<String>()
|
||||
|
||||
@@ -1595,8 +1340,6 @@ class PurchaseRehearsalExecutorTest {
|
||||
node("title", "测试商品标题文本", 20, 200, 900, 280, className = "android.widget.ViewPager"),
|
||||
)
|
||||
val specEntryReady = pddCaptureCount > specEntryVisibleAfterPddCaptures
|
||||
if (dynamicProduct) nodes += node("decoration", "", 20, 400 + pddCaptureCount % 3, 100, 460)
|
||||
if (productOverlay) nodes += node("overlay-close", "关闭", 900, 500, 1000, 570, clickable = true)
|
||||
if (bottomPurchaseEntry && specEntryReady) {
|
||||
nodes += node("buy", "", 500, 1800, 1080, 2180, clickable = true)
|
||||
nodes += node("buy/price", "¥20.00", 560, 1840, 760, 1910, parentPath = "buy")
|
||||
@@ -1605,14 +1348,6 @@ class PurchaseRehearsalExecutorTest {
|
||||
nodes += node("spec", "选择规格", 20, 1000, 900, 1100, clickable = true)
|
||||
}
|
||||
if (includeReviewEntry) nodes += node("review", "商品评价", 20, 1200, 900, 1300, clickable = true)
|
||||
if (entryAttempted) {
|
||||
if (afterEntryMutation == "missing") nodes.removeAll { it.path == "spec" }
|
||||
if (afterEntryMutation == "duplicate") nodes += node("spec2", "选择规格", 20, 1120, 900, 1190, clickable = true)
|
||||
if (afterEntryMutation == "payment-promotion") nodes += node("payment", "微信支付", 20, 1800, 900, 1900)
|
||||
if (afterEntryMutation == "payment") nodes += node("payment", "确认支付", 20, 1800, 900, 1900)
|
||||
if (afterEntryMutation == "order") nodes += node("order", "订单详情", 20, 1800, 900, 1900)
|
||||
if (afterEntryMutation == "activity") return UiSnapshot(PDD, "OtherActivity", nodes)
|
||||
}
|
||||
return UiSnapshot(PDD, ACTIVITY, nodes)
|
||||
}
|
||||
if (unrecognizedPanel) {
|
||||
@@ -1621,7 +1356,7 @@ class PurchaseRehearsalExecutorTest {
|
||||
node("panel-title", "确认款式", 20, 396, 300, 430),
|
||||
))
|
||||
}
|
||||
val singleHeading = singleHeadingOnEntry || (prefixlessSingleHeadingAfterColor && color != null)
|
||||
val singleHeading = prefixlessSingleHeadingAfterColor && color != null
|
||||
val hideColor = singleHeading || (hideColorAfterQuantitySet && quantity == 2L && !hiddenColorRestored)
|
||||
val hideSize = (hideSizeAfterQuantitySet && quantity == 2L) || (hideSizeAfterSelection && size != null)
|
||||
val hideSummary = hideSelectedSummaryAfterQuantitySet && quantity == 2L
|
||||
@@ -1655,16 +1390,6 @@ class PurchaseRehearsalExecutorTest {
|
||||
nodes += node(colorParent, "", 0, 460, 1080, 550, scrollable = true, parentPath = "scroll")
|
||||
}
|
||||
visibleColors.forEachIndexed { index, value ->
|
||||
if (duplicateColorCardEvidence && selectedColor == value) {
|
||||
val cardPath = "scroll/card-$index"
|
||||
nodes += node(cardPath, "$value 199", 20 + index * 220, 460, 200 + index * 220, 550,
|
||||
clickable = true, selected = true, parentPath = "scroll", className = "android.view.ViewGroup")
|
||||
nodes += node("$cardPath/image", "$value 199", 20 + index * 220, 460, 200 + index * 220, 500,
|
||||
clickable = true, selected = true, parentPath = cardPath, className = "android.widget.ImageView")
|
||||
nodes += node("$cardPath/text", value, 20 + index * 220, 500, 200 + index * 220, 540,
|
||||
clickable = true, selected = true, parentPath = cardPath, className = "android.widget.TextView")
|
||||
return@forEachIndexed
|
||||
}
|
||||
nodes += node(
|
||||
"scroll/color-$index",
|
||||
value,
|
||||
@@ -1674,7 +1399,7 @@ class PurchaseRehearsalExecutorTest {
|
||||
540,
|
||||
clickable = true,
|
||||
selected = selectedColor == value,
|
||||
enabled = !allSpecsUnavailable && value !in unavailableColors,
|
||||
enabled = !allSpecsUnavailable,
|
||||
parentPath = colorParent,
|
||||
)
|
||||
}
|
||||
@@ -1712,16 +1437,7 @@ class PurchaseRehearsalExecutorTest {
|
||||
nodes += if (singleHeading) node("info/quantity", quantity.toString(), 400, 360, 600, 390, className = "android.widget.EditText", parentPath = "info")
|
||||
else node("quantity", quantity.toString(), 400, 800, 600, 870, className = "android.widget.EditText")
|
||||
if (!singleHeading) nodes += node("confirm", "确定", 20, 900, 500, 980, clickable = true)
|
||||
if (confirmationPanelKind != null) {
|
||||
nodes += node("close", "关闭", 980, 300, 1060, 350, clickable = true)
|
||||
nodes += node("minus", "减少数量", 300, 800, 380, 870, clickable = true)
|
||||
nodes += node("plus", "增加数量", 620, 800, 700, 870, clickable = true)
|
||||
nodes += node("payment", "微信支付", 600, 1000, 900, 1050)
|
||||
if (confirmationPanelKind == "QUICK_CONFIRMATION") {
|
||||
nodes += node("quick", "现在买", 520, 2000, 1020, 2080, clickable = true)
|
||||
}
|
||||
}
|
||||
nodes += node("order", "提交订单", 20, if (singleHeading || confirmationPanelKind != null) 2000 else 1100, 500, if (singleHeading || confirmationPanelKind != null) 2080 else 1180, clickable = true)
|
||||
nodes += node("order", "提交订单", 20, if (singleHeading) 2000 else 1100, 500, if (singleHeading) 2080 else 1180, clickable = true)
|
||||
nodes += node("pay", "立即支付", 520, 1100, 1020, 1180, clickable = true)
|
||||
return UiSnapshot(PDD, ACTIVITY, nodes)
|
||||
}
|
||||
@@ -1762,7 +1478,6 @@ class PurchaseRehearsalExecutorTest {
|
||||
}
|
||||
|
||||
override fun clickFreshDetailed(target: SnapshotNode): FreshClickOutcome {
|
||||
if (target.path in setOf("spec", "buy")) entryAttempted = true
|
||||
if (target.path in setOf("spec", "buy") && forcedEntryClickReason != null) {
|
||||
val result = when (forcedEntryClickReason) {
|
||||
FreshClickReason.ROOT_UNAVAILABLE, FreshClickReason.TARGET_NOT_FOUND -> FreshActionResult.NOT_FOUND
|
||||
@@ -1816,8 +1531,6 @@ class PurchaseRehearsalExecutorTest {
|
||||
return purchaseSwipeSucceeds
|
||||
}
|
||||
|
||||
override fun purchaseSwipeFailureReason(): PurchaseSwipeFailureReason = purchaseSwipeFailure
|
||||
|
||||
override fun swipePurchaseIn(target: SnapshotNode, direction: SwipeDirection, durationMs: Long): Boolean {
|
||||
swipeInPaths += target.path
|
||||
if (restoreHiddenColorOnDownSwipe && quantity == 2L && direction == SwipeDirection.DOWN) {
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
package cn.ilapage.goauto.agent
|
||||
|
||||
import cn.ilapage.goauto.agent.automation.*
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Test
|
||||
|
||||
class SelectedColorCardTest {
|
||||
private val target = "黑色两件套 19"
|
||||
private fun node(path: String, parent: String?, label: String, kind: String, selected: Boolean = true,
|
||||
scrollable: Boolean = false) = SnapshotNode(path, parent, label, null, null, kind,
|
||||
NodeBounds(0, 0, 300, 400), !scrollable, scrollable, selected, false, true, true)
|
||||
private val scroll = node("s", null, "", "androidx.recyclerview.widget.RecyclerView", false, true)
|
||||
private val card = node("s/c", "s", "黑色两件套 199", "android.view.ViewGroup")
|
||||
private val image = node("s/c/i", "s/c", "黑色两件套 199", "android.widget.ImageView")
|
||||
private val text = node("s/c/t", "s/c", target, "android.widget.TextView")
|
||||
private fun check(nodes: List<SnapshotNode>, candidates: List<SnapshotNode> = nodes.filter { it != scroll }) =
|
||||
SelectedColorCard.confirms(target, candidates.map { VisibleSpecValue(it.label, true, it) }, nodes)
|
||||
|
||||
@Test fun `selected target and own card image duplicates are one selection`() {
|
||||
assertTrue(check(listOf(scroll, card, image, text)))
|
||||
}
|
||||
@Test fun `another selected card remains conflict`() {
|
||||
val other = card.copy(path = "s/other")
|
||||
assertFalse(check(listOf(scroll, card, image, text, other)))
|
||||
}
|
||||
@Test fun `two exact targets are not merged`() {
|
||||
val otherCard = card.copy(path = "s/other", selected = false)
|
||||
val otherText = text.copy(path = "s/other/t", parentPath = otherCard.path)
|
||||
assertFalse(check(listOf(scroll, card, image, text, otherCard, otherText)))
|
||||
assertFalse(check(listOf(scroll, card, image, text, otherCard, otherText), listOf(card, image, text)))
|
||||
}
|
||||
@Test fun `unselected target cannot borrow selection from its container`() {
|
||||
assertFalse(check(listOf(scroll, card, image, text.copy(selected = false))))
|
||||
}
|
||||
@Test fun `scrollable or missing card boundary cannot merge`() {
|
||||
assertFalse(check(listOf(scroll, card.copy(scrollable = true), image, text)))
|
||||
assertFalse(check(listOf(card, image, text)))
|
||||
assertFalse(check(listOf(scroll, card.copy(clickable = false), image, text)))
|
||||
}
|
||||
@Test fun `different text sibling is not treated as image duplicate`() {
|
||||
assertFalse(check(listOf(scroll, card, text, image.copy(className = "android.widget.TextView"))))
|
||||
}
|
||||
@Test fun `out of card bounds and parent cycle are rejected`() {
|
||||
assertFalse(check(listOf(scroll, card, image.copy(bounds = NodeBounds(0, 0, 301, 400)), text)))
|
||||
assertFalse(check(listOf(scroll, card.copy(parentPath = text.path), image, text)))
|
||||
}
|
||||
}
|
||||
-92
@@ -1,92 +0,0 @@
|
||||
package cn.ilapage.goauto.agent.persistence
|
||||
|
||||
import java.io.File
|
||||
import org.json.JSONObject
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.rules.TemporaryFolder
|
||||
|
||||
class PurchaseDiagnosticStoreTest {
|
||||
@get:Rule val temporary = TemporaryFolder()
|
||||
private val context = PurchaseDiagnosticContext(81, "attempt-14", 7, "a".repeat(64), "0.9.67", "purchase")
|
||||
|
||||
@Test fun `only known structural fields and fixed values survive`() {
|
||||
val output = PurchaseDiagnosticStore.sanitize(
|
||||
"entryClick=SUCCESS;entryRecovery=rejected;reason=title_changed;summary=false;options=20;" +
|
||||
"title=敏感标题;address=测试地址;token=secret;entrySource=private-value;gesture=private-value;" +
|
||||
"quantity=private-value;scrollables=-1;unknown=private-value",
|
||||
)
|
||||
assertEquals(5, output.length())
|
||||
assertEquals("title_changed", output.getString("reason"))
|
||||
assertFalse(output.getBoolean("summary"))
|
||||
assertFalse(output.toString().contains("private"))
|
||||
assertFalse(output.toString().contains("secret"))
|
||||
assertEquals(0, PurchaseDiagnosticStore.sanitize("reason=测试地址;entryReason=secret").length())
|
||||
}
|
||||
|
||||
@Test fun `events persist across store recreation with identity and elapsed time`() {
|
||||
val dir = temporary.newFolder()
|
||||
assertTrue(PurchaseDiagnosticStore(dir) { 1000 }.record(context, 2300,
|
||||
"entryRecovery=rejected;reason=close_control;entryWaitPolls=30;entryWaitMillis=2900;closeControl=true"))
|
||||
assertTrue(PurchaseDiagnosticStore(dir) { 2000 }.record(context, 2500, "entryClick=SUCCESS"))
|
||||
val lines = dir.listFiles()!!.single().readLines()
|
||||
assertEquals(2, lines.size)
|
||||
val entry = JSONObject(lines.first())
|
||||
assertEquals(81, entry.getLong("taskId"))
|
||||
assertEquals("attempt-14", entry.getString("attemptId"))
|
||||
assertEquals(7, entry.getLong("deviceId"))
|
||||
assertEquals(context.ruleHash, entry.getString("ruleHash"))
|
||||
assertEquals(2300, entry.getLong("elapsedMs"))
|
||||
assertEquals("0.9.67", entry.getString("agentVersion"))
|
||||
assertEquals("close_control", entry.getJSONObject("evidence").getString("reason"))
|
||||
}
|
||||
|
||||
@Test fun `saved confirmation diagnostics retain only bounded scalars`() {
|
||||
val fields = PurchaseDiagnosticStore.sanitize("savedAddressMatched=false;savedPanelMatched=true;" +
|
||||
"savedLegacySubmitCandidates=0;savedAddressCards=1;savedAddressTargets=1;" +
|
||||
"savedScrollCandidates=0;savedConfirmationPolls=50;savedAddress=secret;" +
|
||||
"savedAddressMatched=secret;savedScrollCandidates=-1")
|
||||
assertEquals(7, fields.length())
|
||||
assertFalse(fields.getBoolean("savedAddressMatched"))
|
||||
assertEquals(50, fields.getInt("savedConfirmationPolls"))
|
||||
assertFalse(fields.toString().contains("secret"))
|
||||
}
|
||||
|
||||
@Test fun `retains last five attempts and last 128 events including failure`() {
|
||||
val dir = temporary.newFolder()
|
||||
var time = 1000L
|
||||
val store = PurchaseDiagnosticStore(dir) { time++ }
|
||||
repeat(7) { store.record(context.copy(attemptId = "attempt-$it"), 0, "event=started") }
|
||||
assertEquals(5, dir.listFiles()!!.size)
|
||||
assertFalse(File(dir, "81_attempt-0.jsonl").exists())
|
||||
repeat(150) { store.record(context, it.toLong(), "entryWaitPolls=$it") }
|
||||
store.record(context, 999, "entryRecovery=rejected;reason=entry_label_changed")
|
||||
val lines = File(dir, "81_attempt-14.jsonl").readLines()
|
||||
assertEquals(128, lines.size)
|
||||
assertEquals("entry_label_changed", JSONObject(lines.last()).getJSONObject("evidence").getString("reason"))
|
||||
assertEquals(5, dir.listFiles()!!.size)
|
||||
}
|
||||
|
||||
@Test fun `expiry deletes only owned files and can run at startup`() {
|
||||
val dir = temporary.newFolder()
|
||||
PurchaseDiagnosticStore(dir) { 1000 }.record(context, 0, "event=started")
|
||||
File(dir, "unrelated.txt").writeText("preserve")
|
||||
File(dir, "unrelated.txt").setLastModified(1000)
|
||||
PurchaseDiagnosticStore(dir) { PurchaseDiagnosticStore.RETENTION_MS + 1001 }.prune()
|
||||
assertEquals(listOf("unrelated.txt"), dir.listFiles()!!.map { it.name })
|
||||
}
|
||||
|
||||
@Test fun `invalid context and filesystem failures never throw`() {
|
||||
val store = PurchaseDiagnosticStore(temporary.newFile())
|
||||
assertFalse(store.record(context, 1, "event=started"))
|
||||
store.prune()
|
||||
val dir = temporary.newFolder()
|
||||
val validStore = PurchaseDiagnosticStore(dir)
|
||||
assertFalse(validStore.record(context.copy(attemptId = "../outside"), 1, "event=started"))
|
||||
assertFalse(validStore.record(context.copy(ruleHash = "secret"), 1, "event=started"))
|
||||
assertFalse(validStore.record(context.copy(phase = "secret"), 1, "event=started"))
|
||||
assertFalse(validStore.record(context, 1, "unknown=secret"))
|
||||
assertTrue(dir.listFiles()!!.isEmpty())
|
||||
}
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
package cn.ilapage.goauto.agent.service
|
||||
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Test
|
||||
|
||||
class PurchaseProbeHandoffTest {
|
||||
private val key = PurchaseProbeHandoff.Key(107, "123456", "a".repeat(64), 7)
|
||||
|
||||
@Test fun `same task consumes once and manual retry cannot inherit`() {
|
||||
val store = PurchaseProbeHandoff()
|
||||
store.remember(key, "PddActivity", 3, 100)
|
||||
assertEquals("PddActivity", store.consume(key, "purchase", 3, 60_100))
|
||||
assertNull(store.consume(key, "purchase", 3, 60_101))
|
||||
}
|
||||
|
||||
@Test fun `mismatched task goods rule device phase revision and expiry reject and consume`() {
|
||||
for (other in listOf(key.copy(taskId = 108), key.copy(goodsId = "654321"),
|
||||
key.copy(ruleHash = "b".repeat(64)), key.copy(deviceId = 8))) {
|
||||
val store = PurchaseProbeHandoff()
|
||||
store.remember(key, "PddActivity", 3, 100)
|
||||
assertNull(store.consume(other, "purchase", 3, 101))
|
||||
assertNull(store.consume(key, "purchase", 3, 102))
|
||||
}
|
||||
for ((phase, revision, now) in listOf(Triple("spec_probe", 3L, 101L),
|
||||
Triple("purchase", 4L, 101L), Triple("purchase", 3L, 180_101L), Triple("purchase", 3L, 99L))) {
|
||||
val store = PurchaseProbeHandoff()
|
||||
store.remember(key, "PddActivity", 3, 100)
|
||||
assertNull(store.consume(key, phase, revision, now))
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun `clear and process recreation discard context`() {
|
||||
val store = PurchaseProbeHandoff()
|
||||
store.remember(key, "PddActivity", 3, 100)
|
||||
store.clear()
|
||||
assertNull(store.consume(key, "purchase", 3, 101))
|
||||
assertNull(PurchaseProbeHandoff().consume(key, "purchase", 3, 101))
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@ 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: 029dc3e155e1828484c62efd0ac804616fcbc714
|
||||
synchronized_at: 2026-09-10T09:13:27Z
|
||||
synchronized_at: 2026-09-11T09:09:07Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 架构与代码地图
|
||||
|
||||
@@ -3,7 +3,7 @@ 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: d8aeff02fba57cba0b53fb22cdb524fd5cc0cb3e
|
||||
synchronized_at: 2026-09-10T09:13:33Z
|
||||
synchronized_at: 2026-09-11T09:09:16Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 业务规则与术语
|
||||
|
||||
@@ -3,7 +3,7 @@ generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Troubleshooting
|
||||
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Troubleshooting
|
||||
wiki_revision: 18744477bfd17f396e8c76ec7a2fcc6720acdf6f
|
||||
synchronized_at: 2026-09-09T03:16:51Z
|
||||
synchronized_at: 2026-09-11T09:10:00Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 故障排查
|
||||
|
||||
@@ -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: 49dec4c35da793a32822a82e00f3b657757b6d44
|
||||
synchronized_at: 2026-09-07T13:35:25Z
|
||||
wiki_revision: ba259178cf6514d5b48ea425005dfd366ba803a3
|
||||
synchronized_at: 2026-09-12T03:09:46Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# MVP 共享 API 契约
|
||||
@@ -77,7 +77,7 @@ PATCH /api/admin/v1/syb-products/{productId}/correction
|
||||
|
||||
`import` 允许已认证的管理员(admin)和采购员(purchaser)调用(#236),仍须通过 Casbin 权限校验;其他角色返回 403。采购员的 POST 权限由既有启动权限对账写入,不需要新增数据库迁移。操作人取已认证 claims,不接受客户端冒名。请求体提交 `dateFrom`、`dateTo` 后创建持久化后台任务并立即以 `202` 返回 `runId` 和 `status=running`;关闭弹窗、刷新或离开页面不影响任务。没有启用店铺时必须在读取凭据、登录、验证码 OCR 和任意 SYB 网络请求之前返回 `422`。任意时刻只能有一条 `running` 记录,内存锁与数据库唯一执行槽共同阻止单进程和跨进程重复导入;冲突时返回正在执行任务的日期范围。
|
||||
|
||||
`sync-runs` 列表支持 `page`、`pageSize`、`status`、`dateFrom`、`dateTo`,详情返回日期范围、状态(`running` / `succeeded` / `failed` / `interrupted`)、处理天数、货运单/明细/新增/覆盖数量、店铺准入与跳过数量、店铺筛选快照哈希、按店铺的 `accepted` / `skipped` 统计、操作人和起止时间。列表和详情对已登录角色只读开放。服务启动时遗留的 `running` 任务改为 `interrupted`;中途失败或中断已经写入的数据保留,重新导入仍按唯一键覆盖。
|
||||
`sync-runs` 列表支持 `page`、`pageSize`、`status`、`dateFrom`、`dateTo`,详情返回日期范围、状态(`running` / `succeeded` / `partial_success` / `failed` / `interrupted`)、处理天数、货运单/明细/新增/覆盖数量、店铺准入与跳过数量、店铺筛选快照哈希、按店铺的 `accepted` / `skipped` 统计、操作人和起止时间。列表和详情对已登录角色只读开放。服务启动时遗留的 `running` 任务改为 `interrupted`;中途失败或中断已经写入的数据保留,重新导入仍按唯一键覆盖。
|
||||
|
||||
列表返回结构化字段(`orderCode`、`shopeeItemId`、`productTitle`、`targetColor`、`targetSize`、`quantity`、`unitPriceCent`、`imageUrl`、`parseStatus`、`parseNote`、`manuallyConfirmed`),不含原始 JSON;`keyword` 匹配订单号、虾皮商品ID 或商品标题,`parseStatus` 筛选 `success`/`uncertain`/`failed`。详情额外返回 `rawJson`(原始 `details[]` 元素,未做任何改写)。
|
||||
|
||||
@@ -100,6 +100,33 @@ POST /api/admin/v1/syb-shops/discover
|
||||
|
||||
`discover` 提交日期范围,从 SYB 真实货运单列表发现店铺,返回 `shopName` 和 `status`(`new` / `exists`);发现只读,不自动新增。店铺比较固定执行首尾去空白、全角/半角统一和忽略大小写。同步先完成原始列表完整性校验,再按一次性启用店铺快照过滤;明细响应中的店铺名为空或不在快照内时不得写入。
|
||||
|
||||
## 管理端:SYB 商品过滤(#269)
|
||||
|
||||
```http
|
||||
GET /api/admin/v1/syb-product-filters
|
||||
POST /api/admin/v1/syb-product-filters
|
||||
PATCH /api/admin/v1/syb-product-filters/{id}/enabled
|
||||
DELETE /api/admin/v1/syb-product-filters/{id}
|
||||
```
|
||||
|
||||
同步时按 `variationSku` 决定明细是否入库。规则分两类,由 `kind` 区分:`char` 是结构判据
|
||||
(`-`、`#`),`keyword` 是关键词清单。列表支持 `kind`、`keyword` 和分页,返回 `keyword`、
|
||||
`normalizedKeyword`、`enabled`、`lastHitCount`、`lastHitAt`、`note`,`char` 另有
|
||||
`lastChangedBy`、`lastChangedAt`。
|
||||
|
||||
`[必须]` 服务端必须拒绝对 `kind=char` 行的 `POST` 新增与 `DELETE` 删除,分别返回
|
||||
`FORBIDDEN`(`结构过滤不可新增` / `结构过滤不可删除`),不得只依赖前端隐藏按钮。
|
||||
`kind=char` 的 `PATCH enabled` 允许,并记录操作人与时间。
|
||||
|
||||
`[必须]` `lastHitCount` 是每条规则各自的命中数,由同步结束时回写;未命中的规则写 0,
|
||||
从未同步过为 `null`。停用 `kind=char` 的二次确认提示条数必须取该行实时的 `lastHitCount`,
|
||||
不得写死——写死的数字在数据变化后会给出错误警告。
|
||||
|
||||
关键词归一化与店铺一致:去首尾空白、全角/半角统一、忽略大小写;**不做简繁转换**,SYB 数据
|
||||
为繁体。唯一性按 (`kind`, `normalizedKeyword`) 判定,重复添加返回
|
||||
`SYB_PRODUCT_FILTER_DUPLICATE`。写操作由 go-admin/Casbin 仅授权管理员,其他角色只读。
|
||||
删除为软删除,不清理已经入库的商品明细。
|
||||
|
||||
## 管理端:采集规则
|
||||
|
||||
```http
|
||||
@@ -556,7 +583,17 @@ Android #42/#36 使用本地 SQLite 保存恢复与重传所需的任务、attem
|
||||
创建订单前先在本地事务保存 `order_submit_started`、不可逆时间、稳定的 `orderSubmitRequestId` 和脱敏最终确认快照,再调用服务端同名接口;只有两侧标记完成并重新校验商品、规格、数量、单价、地址后缀、PDD 包/Activity 与唯一创建订单按钮后,才点击一次。重启时重放同一标记请求并只读核单;无法取得唯一未付款订单号和 PDD 下单时间时提交 `order_result_unknown`。支付文字仅用于识别未付款/离开支付页,永不点击。服务端数据库仍是最终事实来源;双方均不保存原始控件树、截图、PDD 凭据或完整收货地址。
|
||||
|
||||
创建订单后若出现 Android 多微信应用选择器,只读核单器必须同时确认前台包为 `android` / `com.android.intentresolver`、Activity 为白名单 `ChooserActivity` / `ResolverActivity`、页面出现已知系统选择器标题且至少一个候选以“微信”开头,才允许执行一次系统返回;不得点击任何微信候选。若前台已经是精确微信包 `com.tencent.mm`,只读核单器不得点击、输入、登录、支付或强制停止微信,只允许执行一次无参数 PDD 启动 Intent 并等待既有 PDD 任务栈回到前台;`startActivity()` 成功只表示恢复请求已发起。请求后在固定最多 15 次、每次 200ms 的宽限期内允许微信或空窗口短暂残留,不执行点击、返回、输入或滑动;观察到 PDD 后结束宽限,宽限超时、已经观察到 PDD 后再次进入微信或出现稳定未知应用时返回未知结果。随后若前台为 PDD `com.xunmeng.pinduoduo.app_pay.core.PayActivity` 或当前页面出现支付动作文字,最多再返回一次。返回后只读解析唯一订单号和下单时间;选择器或支付页重复出现、白名单不成立、恢复动作重复、无法到达订单详情、结果不唯一或超时均返回未知结果,禁止再次点击创建订单、取消订单或支付。
|
||||
`order_result_unknown` 保持订单号和下单时间为空,但允许携带创建订单前已经严格验证的 `actualUnitPriceCent`。Agent 同时提交脱敏稳定失败阶段,服务端只接受白名单并按错误码写入固定提示,不信任或保存页面原文;阶段覆盖空窗口超时、选择器返回失败、微信恢复失败/超时、未知应用、支付页返回失败/重复、订单上下文缺失、订单号缺失/歧义、下单时间缺失/无效和待付款证据缺失。任务与 attempt 保存同一错误阶段,Admin 和 Agent 历史读取数据库最终事实;旧 Agent 未提交阶段时归一为 `PURCHASE_ORDER_RESULT_UNKNOWN`。
|
||||
Agent 主动提交 `order_result_unknown` 时保持订单号和下单时间为空,但允许携带创建订单前已经严格验证的 `actualUnitPriceCent`。Agent 同时提交脱敏稳定失败阶段,服务端只接受白名单错误码,并按错误码写入固定提示作为面向人的主文案;不采信 Agent 文本作为权威描述,也不保存原始控件树或截图。阶段覆盖空窗口超时、选择器返回失败、微信恢复失败/超时、未知应用、支付页返回失败/重复、订单上下文缺失、订单号缺失/歧义、下单时间缺失/无效和待付款证据缺失。任务与 attempt 保存同一错误阶段,Admin 和 Agent 历史读取数据库最终事实;旧 Agent 未提交阶段时归一为 `PURCHASE_ORDER_RESULT_UNKNOWN`。
|
||||
|
||||
#271 修订:`errorMessage` 的语义由「仅固定提示」改为「**固定提示 + Agent 诊断证据**」。固定提示仍由错误码唯一决定并排在最前,Agent 提交的脱敏诊断追加在其后。
|
||||
|
||||
`[必须]` 追加前压平为单行:去除控制字符与零宽不换行空格、连续空白合并为一个空格;按 `error_message` 列宽 1000 截断,且**固定提示优先完整保留**,被截断的证据以 `…` 结尾。证据与固定提示相同时不重复拼接。Agent 文本为空或错误码不在白名单时行为不变,仍返回 `INVALID_REQUEST`。
|
||||
|
||||
改动原因:原做法丢弃了 Agent 已经算出并上报的诊断。`#210` 为 `PURCHASE_ORDER_PAYMENT_REPEATED` 计算 `paymentBackAttempts` 与 `consecutivePaymentSamplesAfterBack`,但 2026-09-07 至 09-11 线上 61 次该错误的 `error_message` 无一携带这两个值,导致「返回动作未生效」「页面渲染慢于容忍窗口」「页面已是订单页但关键词未命中」三种情形无法区分。相邻的 `failed` 结果分支本就原样保存 Agent 文本,因此本修订不引入新的信任假设。
|
||||
|
||||
`[必须]` `failed` 结果分支同样把错误码与消息写入 attempt,不再只写任务。此前该路径产生的 attempt 行 `error_code` 为 NULL,按 attempt 统计失败时这些记录是隐形的。
|
||||
|
||||
#241 追加:全局订单号唯一性校验在人工处理结果未知、取消及 lifecycle 保存路径返回 `PURCHASE_ORDER_NUMBER_ALREADY_USED`(HTTP 409、`retryable=false`),提示“订单号已属于任务 CG-任务ID”;批量回填继续使用原有 `PURCHASE_BACKFILL_ORDER_ALREADY_USED`。不可逆边界后的 `order_created` 结果回传为例外:发现该号已属于其他任务时成功受理结果,将 `order_submit_started` 降级为 `order_result_unknown`,不回滚或自动重派。冲突订单号不写入 `pdd_order_no`,而以“读到订单号 X,但该号已属于任务 CG-yy”保存到任务和 attempt 的 `error_message`,两者 `error_code` 均为 `PURCHASE_ORDER_NUMBER_ALREADY_USED`;保留下单时间、不可逆时间及实际单价。attempt 以 failed 结束并保留原始 `order_created` 结果类型、请求 ID 和摘要,重复提交按原幂等协议返回;任务释放租约和运行槽,进入既有人工处理结果未知通道,权限不变。
|
||||
|
||||
| 错误码 | 普通提示 |
|
||||
|---|---|
|
||||
@@ -975,3 +1012,160 @@ Agent 携带既有 Token(可已失效)及恢复码重新调用注册接口
|
||||
|
||||
本地 `GoAutoPurchasePanel` 脱敏结构日志关联 task、attempt、device、rule(规则 SHA-256),不上传原始页面。新增事件:`postSwipe=skipped;action=openSpecPanel;reason=spec_panel_on_demand;panel=<枚举>`;其他必需滑动失败为 `postSwipe=failed;action=<动作枚举>;direction=<方向枚举>;swipeIndex=<本动作内第几次滑动>;reason=<固定分类>`。
|
||||
固定失败分类:unknown、root_unavailable、no_scrollable、invalid_bounds、gesture_unsupported、gesture_rejected、gesture_cancelled、gesture_timeout。日志不含规格值、节点文本、坐标、地址、订单、凭据、原始树或截图;结果错误码仍为 RULE_ACTION_FAILED,现有结果提交字段不变。
|
||||
|
||||
## SYB 逐页保存与部分成功(#239)
|
||||
|
||||
实现绑定 c6a962d;代码已实现不代表当前线上已部署。每页完整明细在外部请求结束后按页事务保存;页回滚不累计明细/新增/覆盖数,已提交页保留。日期局部读取失败继续下一日期,全局数据库/进度/会话/取消故障停止。当天漂移不在一次运行内重扫;后续运行重新扫描并幂等补齐。
|
||||
|
||||
同步状态增加 `partial_success`(部分成功,15 字符,复用现有 varchar(16),无需迁移)。有错误且 created+updated>0 为部分成功;有错误无已提交明细为 failed;完整且无错误为 succeeded(包括无符合店铺的数据)。中断仍为 interrupted,不把中断追认为成功。所有终态沿用活动槽释放规则。
|
||||
|
||||
`orderCount` 是已验证页的原始列表读取数量;`detailCount`、`created`、`updated` 为已提交明细及其新增/覆盖数量;`daysProcessed` 是完整通过的日期数,不是已尝试日期数。失败日期/页码/阶段写入现有脱敏限长 errorMessage。部分成功不刷新店铺的完整同步统计。
|
||||
|
||||
Web 唯一展示位置为“采集采购 → SYB 同步记录”:列表状态、状态筛选及详情支持部分成功,详情保留已保存数量、错误原因与重新同步补齐提示。定时任务日志只表示异步任务受理,不等于最终业务同步成功。
|
||||
|
||||
## Agent 采购订单批量回填(#241)
|
||||
|
||||
本节为 #241 服务端实现契约,2026-09-08 按用户授权直接更新本地镜像;线上 Wiki 与其他长期文档由审核阶段同步。本节不表示已经部署或完成真机验收。
|
||||
|
||||
`POST /api/agent/v1/purchase-tasks/order-backfill`
|
||||
|
||||
使用 `Authorization: Bearer <Device Token>`,沿用 `RequireAgentHTTPS`、`GOAUTO_ALLOW_INSECURE_AGENT_HTTP` 与既有可信转发协议策略。无需 Admin JWT、claim、start 或 attempt。设备号只从认证读取,请求不得指定 deviceId、地址全文、收件人、手机号或原始控件树;未知 JSON 字段拒绝。此接口只记录已观察到的订单事实,不执行设备动作、创建订单或付款。
|
||||
|
||||
请求示例(页面时间先按 Asia/Shanghai 理解,再以带时区 RFC3339/RFC3339Nano 发送):
|
||||
|
||||
```json
|
||||
{
|
||||
"requestId": "5826cdda-dcd6-442e-90c3-9b75ba6fb8d8",
|
||||
"items": [
|
||||
{"addressSuffix": "_cg7", "pddOrderNo": "EXAMPLE-ORDER-7", "orderSubmittedAt": "2026-09-08T20:30:00+08:00"},
|
||||
{"addressSuffix": "_cg72", "pddOrderNo": "EXAMPLE-ORDER-72"}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
- requestId 必须为 UUID;items 为 1~50 条,保持输入顺序;请求体上限沿用 1 MiB。
|
||||
- addressSuffix 只接受 `AddressSuffix(id)` 生成的完整字符串。任务号为非零 uint64,拒绝前导零、正负号、空格、尾随文本、多个后缀与溢出;`_cg7` 与 `_cg72` 分别定位任务 7 和 72。
|
||||
- pddOrderNo 必填,最多 100 个 Unicode 字符,不接受首尾空白、换行或制表符,不自动裁剪后覆盖旧值。
|
||||
- orderSubmittedAt 缺失或 null 时回落任务 irreversible_at;空字符串、无时区文本及非法时间是条目错误,不触发回落。存储统一 UTC。页面值与 irreversible_at 都缺失时该条失败。
|
||||
|
||||
有效批次返回 HTTP 200,包括全部条目失败的批次;每条独立事务,失败不撤销其他条目已提交的数据。响应包裹为 `data`,并设 `Cache-Control: no-store`:
|
||||
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"requestId": "5826cdda-dcd6-442e-90c3-9b75ba6fb8d8",
|
||||
"items": [
|
||||
{"index": 0, "taskId": 7, "result": "backfilled", "code": "BACKFILLED", "status": "order_created", "statusVersion": 5, "pddOrderNo": "EXAMPLE-ORDER-7", "orderSubmittedAt": "2026-09-08T12:30:00Z", "timeSource": "page", "retryable": false},
|
||||
{"index": 1, "taskId": 72, "result": "backfilled", "code": "BACKFILLED", "status": "order_created", "statusVersion": 4, "pddOrderNo": "EXAMPLE-ORDER-72", "orderSubmittedAt": "2026-09-08T12:31:00Z", "timeSource": "irreversible_at", "retryable": false}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
index 从 0 开始;后缀无法解析时不返回 taskId。result 为 `backfilled`、`already_backfilled`、`conflict` 或 `failed`。已认证设备所属任务可返回提交后的状态、版本、已保存订单号及时间;拒绝条目尽可能返回当前已提交事实。跨设备任务和不存在任务不返回这些业务字段,事务回滚后的内存值绝不作为最终事实返回。
|
||||
|
||||
timeSource 说明已保存时间的来源:`page` 为页面值,`irreversible_at` 为估算回落,`existing_unknown` 为原先已创建的历史订单且没有可证明的来源。没有已保存时间时省略 timeSource。客户端必须保留估算标记,不得把回落值或 unknown 宣称为页面真实时间。重复回填不会用新页面时间自动校正旧时间。
|
||||
|
||||
| 条目 code | result | 含义 |
|
||||
|---|---|---|
|
||||
| `BACKFILLED` | backfilled | 本条完成回填 |
|
||||
| `ALREADY_BACKFILLED` | already_backfilled | 正式任务已为 order_created 且订单号相同,无写入 |
|
||||
| `PURCHASE_BACKFILL_SUFFIX_INVALID` | failed | 非法、非规范、零、溢出或歧义后缀 |
|
||||
| `PURCHASE_TASK_NOT_FOUND` | failed | 任务不存在 |
|
||||
| `PURCHASE_BACKFILL_DEVICE_MISMATCH` | failed | 未绑定设备或不属于认证设备 |
|
||||
| `PURCHASE_STATE_CONFLICT` | failed | 非正式采购,或状态不允许回填 |
|
||||
| `PURCHASE_INVALID_REQUEST` | failed | 订单号非法 |
|
||||
| `PURCHASE_ORDER_TIME_INVALID` | failed | 提供的页面时间无效 |
|
||||
| `PURCHASE_ORDER_TIME_MISSING` | failed | 页面时间与 irreversible_at 均无有效值 |
|
||||
| `PURCHASE_BACKFILL_ORDER_CONFLICT` | conflict | 任务已有不同订单号 |
|
||||
| `PURCHASE_BACKFILL_BATCH_CONFLICT` | conflict | 同批同任务出现多个不同订单号,该任务所有条目均拒绝 |
|
||||
| `PURCHASE_BACKFILL_ORDER_ALREADY_USED` | conflict | 同一订单号已对应其他任务 |
|
||||
| `INTERNAL_ERROR` | failed | 数据库失败、死锁等,retryable=true,可安全重放 |
|
||||
|
||||
批级 JSON/UUID/数量错误为 HTTP 422 `PURCHASE_INVALID_REQUEST`;鉴权、停用设备和 HTTPS 限制复用既有错误(401 `DEVICE_TOKEN_INVALID`、403 `DEVICE_DISABLED`、426 `HTTPS_REQUIRED`)。批级失败使用既有 `{code,message,retryable}` 包裹,未开始条目写入。
|
||||
|
||||
### 状态、幂等与并发
|
||||
|
||||
新服务在事务中锁定任务并检查来源状态,仅允许当前设备的 `live + order_result_unknown` 首次写入;`live + order_created` 只在订单号相同时返回已回填。其他状态(包括 running、failed、cancelled 与演练)均拒绝。复用 SetStatus 同步占用字段,同一事务递增 statusVersion、设置 statusChangedAt、清空主任务当前错误及租约;原始 attempt、规则快照、支付与物流、SYB 回写字段不变。
|
||||
|
||||
requestId 沿用 UUID 约定,不增加批次表或全局幂等缓存。既有 unknown_resolve_request_id 槽存储 `backfill:<page|irreversible_at>:<由 requestId 和后缀派生的 UUID>`(最多 61 字符),用于任务级关联和保留本功能时间来源。相同任务和订单号即使更换 requestId 也无写入;同 requestId 改内容仍重新执行设备、状态和订单冲突检查,不凭 requestId 直接放行。批内不同任务提交同一订单号时,先成功提交者占用,其余条目返回订单已被使用;已存在的历史重复订单号不自动修复。
|
||||
|
||||
订单号尚无唯一索引,本实现不迁移数据库。共享模型保存钩子复用 `purchase_rule_setting.id=1` 行作短事务互斥锁,再以锁定读检查订单号归属,覆盖回填、原人工解除和旧结果提交路径;单例缺失时拒绝写入,数据库死锁时回滚失败事务。原 ResolveUnknown 与 Admin 鉴权代码保持不变。禁止通过跳过模型钩子的直接 SQL 写入宣称具备此保证。
|
||||
|
||||
新回填路径禁用包含绑定参数的 SQL 日志,不记录请求正文、订单号、地址或原始树;任务号和认证设备号沿既有任务关联不可变 ruleSnapshot。此接口未新增日志载荷或任务/attempt。
|
||||
|
||||
本地测试覆盖事务回滚、并发服务调用和旧写入路径,使用 SQLite;MySQL 8.4 多连接/多进程的实际行锁、生产数据和真机端到端回填尚待环境验收,不以单元测试替代。
|
||||
|
||||
|
||||
## 蝦皮详情匹配等待与回读(#254)
|
||||
|
||||
实现绑定 `9088e6b`(2026-09-10,分支 fix/254-match-loading);已通过合成数据测试,尚未合并 main 或发布线上。
|
||||
|
||||
本节是 Admin 蝦皮详情接口补充,不修改 Android 请求或采购协议。
|
||||
|
||||
- `GET /api/admin/v1/shopee-products/:productId` 的 `data` 增加 `autoMatchTimeoutSeconds` 整数,为后端详情一键匹配整次预算 `2 × AI timeoutSeconds + 10`,配置范围3~600对应16~1210秒。它是非敏感派生预算,沿用商品详情读取权限;不是开放 AI 管理配置读取,响应不包含 Key、Provider、BaseURL 或 Model。现有客户端路由若复用本 Handler 同样仅获得这个数字。
|
||||
- `POST /api/admin/v1/shopee-products/:productId/specs/mapping/auto-match` 请求字段、业务权限、版本核对与响应映射语义不变。服务端按收到请求时的 AI 配置确定整次父预算,读取配置耗时扣除;Provider 请求仍受自身超时及父 context 剩余时间中更短者约束。
|
||||
- Admin 点击前先GET新详情取得预算,POST timeout=(autoMatchTimeoutSeconds+10)×1000毫秒;缺字段、非整数或超范围时明确拒绝开始匹配,不猜配置。前后端需配套升级。
|
||||
- 准备后配置可能变化,浏览器仍可能先超时;不要据超时响应断言事务是否提交。POST失败/断连/超时只执行有限GET回读,不自动重发POST;失败回读显示结果未知。回读结果表示当前已保存状态,不保证这次请求已经永久不再写入,也不保证全部项目都匹配成功。
|
||||
- 普通GET默认10秒;本轮未修改建议接口610秒、其他AI功能或后台批量匹配600秒预算。T=60时130/140秒、T=180时370/380秒。
|
||||
|
||||
|
||||
## SYB 行内一键关联最近采集(#253)
|
||||
|
||||
实现绑定 `b1594dc`,2026-09-10;分支 feat/253-one-click-link,包含 #254 依赖。已完成合成数据测试及构建,未合并 main、未部署,不代表当前线上已具备此能力。
|
||||
|
||||
本节仅补充 Admin API;Android 和客户端密钥旧请求不变。
|
||||
|
||||
`POST /api/admin/v1/shopee-products/{productId}/link-pdd` 新增与非零 pddProductId 互斥的可选模式:
|
||||
|
||||
```json
|
||||
{"requestId":"UUID","latestCollection":{"sybProductId":1,"deviceId":2,"specContextVersion":"详情返回的未关联版本"}}
|
||||
```
|
||||
|
||||
- 校验当前 SYB→Shopee、未关联、规格版本和设备存在/未停用;离线可用。短事务选择最新已结束成功或部分成功的临时采集,排序 finished_at DESC、id DESC;部分成功不跳过回退旧记录。只接受 completed、存在 finishedAt/PDD、PDD active 且存在可选颜色或尺码。
|
||||
- 成功返回原 `product`,及 `collectionSource: {taskId, deviceId, pddProductId, requiresSpecConfirmation}`。product.specContextVersion 是此次事务写入后的冻结版本;requiresSpecConfirmation 对应 failed 且没有人工或AI确认,完整资格仍由现有 batch-preview 派生。不得把后续其他人的商品版本当作本次自动匹配上下文。
|
||||
- 409 `PDD_LINK_CONFLICT` 表示已有关联;409 `LATEST_COLLECTION_UNAVAILABLE` 表示设备/最近采集/候选不可用;409 `SPEC_CONTEXT_VERSION_STALE` 沿用现有语义。非法参数422。重复请求不会覆盖已有关联,成功响应丢失后重发也会冲突,客户端应回读而非自动重试。last_update_request_id 保留本次请求标识;不承诺额外持久化完整重放响应。
|
||||
- `/api/client/v1/.../link-pdd` 带 latestCollection 时返回403 FORBIDDEN,不扩大API Key授权模块;原手动 pddProductId 模式行为不变。
|
||||
- Web 先GET详情及 #254 autoMatchTimeoutSeconds,再提交关联;关联响应中的冻结版本交给现有 auto-match。其匹配仍自动保存,无额外“保存修改”写请求。普通请求/回读每次10秒,auto-match 单独使用派生前端预算2T+20秒;业务拒绝直接提示,网络/匹配失败至多一次详情回读,再刷新准备状态,禁止自动POST重试。
|
||||
- Server/Web 需配套更新并包含 #254;无需迁移/Android升级。现有人工关联、匹配阈值、规格/采购资格、任务与支付边界不变。
|
||||
|
||||
## Admin 一键替换最近采集候选(#258)
|
||||
|
||||
实现绑定 `2b01974`;仅补充 Admin,Android/API Key 不变。沿用 `POST /api/admin/v1/shopee-products/{productId}/link-pdd` 和原权限,不新增路由或迁移。
|
||||
|
||||
先请求只读预览(仍需 UUID requestId):
|
||||
|
||||
```json
|
||||
{"requestId":"UUID","latestCollection":{"sybProductId":1,"deviceId":2,"specContextVersion":"当前详情版本","replacement":{"preview":true,"expectedPddProductId":3}}}
|
||||
```
|
||||
|
||||
响应 `product` 保持原关联,`collectionSource` 返回 taskId、deviceId、pddProductId、requiresSpecConfirmation,并增加 targetVersion、title、goodsId、previousTitle、previousGoodsId、deviceName。预览没有关联写入。
|
||||
|
||||
确认提交沿用同一 SYB/设备/详情版本与原商品ID,replacement 改为:
|
||||
|
||||
```json
|
||||
{"preview":false,"expectedPddProductId":3,"taskId":4,"targetPddProductId":5,"targetVersion":"预览返回值"}
|
||||
```
|
||||
|
||||
- 提交按指定 taskId 核对临时采集来源、设备、completed/finishedAt、目标ID、可用规格及 targetVersion;不重新选择最新任务。旧关联、旧/目标规格版本变更返回现有409上下文冲突;阶段不再允许返回409 PDD_LINK_CONFLICT;来源不可用409 LATEST_COLLECTION_UNAVAILABLE;缺字段422。
|
||||
- 不同商品条件更新旧关联,返回更新后的 product.specContextVersion;同商品不更新关联及 last_update_request_id,但仍返回用于后续匹配的当前版本。请求不是持久化重放协议;客户端超时只回读、不自动重发。
|
||||
- 预览与提交都校验当前允许阶段;采集中拒绝。复用现有人工更换关联的映射语义,后续 auto-match 自动保存及预算沿用 #254。部分匹配/AI故障不回滚已提交关联。
|
||||
- 不提供 replacement 时 #253 行为不变;`/api/client/v1` 携带 latestCollection(包括 replacement)仍403,不扩大客户端密钥授权。
|
||||
|
||||
## 辅助信息缺失的采集关联(#259)
|
||||
|
||||
实现绑定 `e467706`,仅 Server;部署/验收见 #259。替代 #253/#258 中“部分完成一律拒绝”的限制,不改变 completed 原有路径。
|
||||
|
||||
- completed_partial 的 missing 必须是非空合法列表且仅包含 salesText、shopName、reviewCount,才进入辅助信息兼容检查;title、规格/价格缺失、遍历截断、SKU数量上限及未知原因仍拒绝。
|
||||
- `shopeeproduct/partial_collection.go` 核对本次 task 的维度和值、可售SKU的完整标记/规格引用/非负价格、本次颜色价格,以及当前档案可选值由本次有效SKU覆盖,不能借旧档案合并残留的规格或价格通过。真实单维度允许,不虚构另一维度;本次明确报告缺少规格仍拒绝。
|
||||
- 一键关联、替换预览及确认提交共用验证。失败沿用409 LATEST_COLLECTION_UNAVAILABLE及现有提示,不回退更早采集、不换手机。状态 completed_partial 和 missing 原样保留;辅助字段缺失仍可在任务详情查看。
|
||||
- 无接口字段、数据库迁移、Android/Web或权限变更;普通人工关联与采购/AI资格不变。不将“允许关联”视为“已匹配或已采购”。
|
||||
|
||||
## 采购规格现场一次重新匹配(#263)
|
||||
|
||||
2026-09-11 实现绑定 `c6ee6c0`,Server采购与Android Agent;未发布、未安装、未完成真实采购验收。
|
||||
|
||||
- 正式采购阶段仅在服务端下发 `specRematchAllowed=true`、Android 返回稳定的 `PURCHASE_SPEC_TARGET_NOT_VISIBLE` 且尚未进入地址/下单阶段时触发;颜色/尺码选中复核失败、售罄、点击失败、歧义、价格、地址和订单错误不触发。
|
||||
- Agent 在当前PDD规格面板重新读取候选并提交同一采购任务的 `spec_rematch_completed` 结果;Server复用现有确定性优先/必要时AI匹配,结果必须属于当次候选。匹配成功后同task重新领取正式attempt,Agent沿用当前页面继续正式规格、数量、价格、地址和下单校验,不新建任务、不重新打开浏览器链接;页面证据失效时仍按原安全路径处理。
|
||||
- 每个采购任务最多一次;结果attempt和服务端状态原子记录,旧决策指针清除后只允许最新attempt固化新决策。重复请求、无资格、已有不可逆证据或旧attempt覆盖均拒绝;不新增数据库列,依赖现有attempt result_type审计。
|
||||
- 新共享字段 `specRematchAllowed` 和结果类型仅供现有Agent采购接口;旧Agent缺省false,旧 `spec_probe_completed` 重复探测拒绝规则不变。未改变设备租约、并发、支付禁止和任务重试语义。
|
||||
|
||||
@@ -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: a4f4ab200af052bab7ffbabe267238528bcd7cf9
|
||||
synchronized_at: 2026-09-07T07:02:57Z
|
||||
wiki_revision: f1792b7fe35925480c25b890e85fa5e551744812
|
||||
synchronized_at: 2026-09-11T09:11:21Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 12 顺云宝(SYB)ERP 接口契约
|
||||
@@ -220,22 +220,13 @@ Admin 默认 `max_matches = 10000`,可以在配置中调整;上限针对整
|
||||
有限重试:单次 HTTP 总超时 60 秒,最多执行 3 次,重试前分别等待 1 秒、2 秒,等待须
|
||||
响应 context 取消。HTTP 401/403、明确会话失效、业务失败、接口数据完整性错误和本地
|
||||
校验失败不得重试;登录、验证码、单件码写入和任何回填请求也不得使用该机制。重试
|
||||
耗尽后返回最后一次错误,外层继续保留日期、页码和已获取数量上下文并将同步标为失败。
|
||||
耗尽后返回最后一次错误,外层保留日期、页码和已获取数量上下文,按最终已保存成果将同步标为失败或部分成功。
|
||||
|
||||
今天的货运单会在同步期间持续新增。只有 UTC+8 下的今天发生上述快照漂移时,
|
||||
允许只重试今天的列表分页,最多 3 次;已经完成的历史日期不得重复拉取,每次尝试
|
||||
也必须使用独立 ID 集合。第三次仍不稳定时,可以对最后一次取得的合法唯一 ID
|
||||
读取完整明细并按既有 upsert 保存,但本次同步仍记为失败、明确提示当天未形成
|
||||
稳定快照且不推进游标,下一次继续覆盖今天。任何尝试都不得突破 `max_matches`;
|
||||
网络/业务错误、非法 ID 或不完整明细不属于可放宽的快照漂移。
|
||||
`[必须,#235]` 当天跨页重复 ID 纳入上述最多 3 次列表快照尝试(含首次),
|
||||
不增加另一层重试次数。发现跨页重复后丢弃本次列表,从预检总数和第一页重新开始,
|
||||
使用全新 ID 集合;最后一次仍重复时直接失败,不得去重后按成功或降级数据保存。
|
||||
已经完成的历史日期保持其已有结果,不重复拉取;历史日期重复不适用此恢复。
|
||||
每页先检查非法 ID 和页内重复,再检查跨页重叠;同页同时存在页内重复和跨页重叠时
|
||||
仍作为硬错误停止。原有总数漂移/短页的合法唯一列表降级保存条件保持不变。
|
||||
重复诊断只记录日期、当天尝试序号、首次/当前页码与行号、start、pageSize、
|
||||
expectedTotal 和已获取唯一数量,不记录真实重复 ID、原始响应或个人数据。
|
||||
自 #239(实现提交 c6a962d,已于 2026-09-08 随 d403f3b 部署线上)起,改为按页验证和保存:每页先验证条数、合法 ID、页内/跨页重复,再按冻结店铺快照获取并验证全部明细;外部请求完成后才开启页事务。页内任一入库失败回滚整页,已提交的前页保留,计数在事务提交后累计。
|
||||
|
||||
当天和历史日期统一处理:分页/明细失败或最终总数漂移时停止该日期并继续后续日期,不在同次运行中重新扫描当天,以避免重复写入和计数。数据库故障、进度持久化失败、明确会话失效、上下文取消或总任务超时停止整个范围。下一次人工或定时同步重新扫描日期,按 (order_code, detail_id) 幂等覆盖并保留人工确认。跨页重复不去重后冒充完整数据;最终日期完整性校验仍覆盖所有店铺。此流程替代此前 #235 的当天三次整日快照重扫。
|
||||
|
||||
重复诊断只记录日期、首次/当前页码和行号、start、pageSize、expectedTotal、已获取唯一数量,不记录真实重复 ID、原始响应或个人数据。
|
||||
|
||||
### 4.4 统一日期范围同步与覆盖游标
|
||||
|
||||
@@ -258,7 +249,7 @@ GoAuto 同步记录页的立即同步允许管理员和采购员(purchaser)
|
||||
或超过 `max_matches` 时不推进游标。
|
||||
|
||||
`[必须]` 每批明细响应必须与请求的货运单 ID 一一对应。缺失、重复、出现未请求
|
||||
ID,或某张货运单返回空商品明细,都视为不完整并停止同步;已经写入的幂等数据
|
||||
ID,或某张货运单返回空商品明细,都视为不完整并停止当前日期,继续后续日期;已经写入的幂等数据
|
||||
可以保留,但只有所有日期全部成功才推进游标。
|
||||
|
||||
`[必须]` 登录和验证码只是同步前置步骤。日期范围经过自动 OCR 降级、手工
|
||||
@@ -500,9 +491,30 @@ POST /am/stock/detail/updateDetailCode?t=0&id={stockID}&detailId={detailID}&code
|
||||
> 以下「店铺准入」已由 GoAuto #49 采纳并实现;店铺规范化还包括全角/半角统一和忽略大小写。
|
||||
|
||||
`[必须]` **同步先校验原始全量,再做店铺准入。** 顺序固定为:按日期查询原始总数
|
||||
并执行单次容量熔断 → 拉完当天原始列表并核对分页前后总数、页长和唯一 ID → 按
|
||||
并执行单次容量熔断 → 逐页核对页长和唯一 ID,整日结束复核总数 → 按
|
||||
`shopName` 去除首尾空白后与启用店铺精确匹配 → 只为接受的货运单请求明细和入库。
|
||||
不能先过滤再做完整性校验,否则非目标店铺的分页漂移会被掩盖。
|
||||
店铺过滤只决定明细获取和入库,不能减少原始列表完整性校验范围;已保存不代表整日完整。
|
||||
|
||||
`[必须]` **店铺准入之后再按 `variationSku` 过滤商品(#269)。** 顺序固定为:店铺准入 →
|
||||
结构过滤 → 关键词过滤 → 入库。两类规则都存在 `syb_product_filter`,由 `kind` 区分:
|
||||
|
||||
- `kind=char`:`-` 和 `#` 两条,命中任意一条即跳过(**OR,不是 AND**)。这两个字符是
|
||||
「档口-供应商#货号」编码格式的判据。2026-09-11 核验线上 13010 行明细:含 `#` 8498 行、
|
||||
含 `-` 6392 行、两者都含 6386 行、任一 8504 行(65%)。用 AND 只命中 6386 行,会漏掉
|
||||
`DD#004`、`300斤牛奶絲圓領#A057` 这类只含 `#` 的 2118 行。
|
||||
- `kind=keyword`:关键词清单,匹配 `variationSku`,**不匹配 `productTitle`**。同一次核验
|
||||
中六条初始关键词在 `productTitle` 上命中为 0,且全部已被结构过滤覆盖,净增为 0;保留它
|
||||
是为档口改用不含 `#` 的编码时兜底。
|
||||
|
||||
`[必须]` 空 `variationSku` **不过滤**。核验时 2724 行(21%)为空,分布在正常启用店铺且持续
|
||||
产生,属于尚未填写供应商编码的正常商品,不是档口货。
|
||||
|
||||
`[必须]` 两类命中数分别统计并分别回写到每条规则自己的 `last_hit_count`,不得合并成单一
|
||||
「过滤总数」,也不得按 `kind` 写同一个汇总值。前者会使结构判据失效不可观测(关键词净增为 0
|
||||
时无法区分「没漏网」和「规则没生效」);后者会让停用确认对 `#` 和 `-` 提示同一个数字。
|
||||
|
||||
`[必须]` `kind=char` 不可新增、不可删除,只能停用,且停用需要二次确认并记录操作人与时间。
|
||||
判据本身需要变更(例如档口改用 `/` 或 `@`)属于范围变化,应另建工单评估。
|
||||
|
||||
`[必须]` 同步开始时只读取一次启用店铺,整次运行使用同一个快照。列表允许但明细
|
||||
响应中的 `shopName` 变为空或非允许店铺时再次拦截。没有启用店铺时在会话/OCR/
|
||||
@@ -657,3 +669,13 @@ settings:
|
||||
- 客户端实现:`server/app/goauto/sybclient/`
|
||||
- 落库与解析:`server/app/goauto/sybimport/`
|
||||
- 相关工单:[#48 客户端移植](https://git.ilapage.cn/OPC/goauto/issues/48)、[#41 SYB 货运单商品导入](https://git.ilapage.cn/OPC/goauto/issues/41)、[#37 物流调度与货运宝回填](https://git.ilapage.cn/OPC/goauto/issues/37)
|
||||
|
||||
## SYB 逐页保存与部分成功(#239)
|
||||
|
||||
实现绑定 c6a962d;2026-09-08 已随 d403f3b 部署线上,未手动触发真实同步验收;#240 上游尾页超时尚未修复。每页完整明细在外部请求结束后按页事务保存;页回滚不累计明细/新增/覆盖数,已提交页保留。日期局部读取失败继续下一日期,全局数据库/进度/会话/取消故障停止。当天漂移不在一次运行内重扫;后续运行重新扫描并幂等补齐。
|
||||
|
||||
同步状态增加 `partial_success`(部分成功,15 字符,复用现有 varchar(16),无需迁移)。有错误且 created+updated>0 为部分成功;有错误无已提交明细为 failed;完整且无错误为 succeeded(包括无符合店铺的数据)。中断仍为 interrupted,不把中断追认为成功。所有终态沿用活动槽释放规则。
|
||||
|
||||
`orderCount` 是已验证页的原始列表读取数量;`detailCount`、`created`、`updated` 为已提交明细及其新增/覆盖数量;`daysProcessed` 是完整通过的日期数,不是已尝试日期数。失败日期/页码/阶段写入现有脱敏限长 errorMessage。部分成功不刷新店铺的完整同步统计。
|
||||
|
||||
Web 唯一展示位置为“采集采购 → SYB 同步记录”:列表状态、状态筛选及详情支持部分成功,详情保留已保存数量、错误原因与重新同步补齐提示。定时任务日志只表示异步任务受理,不等于最终业务同步成功。
|
||||
|
||||
@@ -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: f951dbc8b6a555cbf8f44cda073910406ba55191
|
||||
synchronized_at: 2026-09-07T09:43:56Z
|
||||
wiki_revision: 933eaf6cc02fed843a6898269fdb5f6848ac2a8a
|
||||
synchronized_at: 2026-09-10T01:42:05Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 部署与运维
|
||||
@@ -41,7 +41,7 @@ GOAUTO_ALLOW_INSECURE_AGENT_HTTP=true
|
||||
|
||||
### API 长请求超时约束
|
||||
|
||||
Admin 蝦皮规格 AI 匹配会同步等待外部 Provider:Provider 配置允许 3~600 秒,复合的一键匹配共享 600 秒总预算,Admin 客户端等待 610 秒。因此服务端 `settings.application.writetimeout` 必须至少为 620 秒,受版本管理的标准配置固定为 `620`;启动时低于该值必须明确失败,禁止以 2 秒等短写超时运行,否则连接会在领域错误返回前被截断并表现为代理 502。反向代理的读/发送超时也必须大于服务端窗口。
|
||||
Admin 蝦皮规格 AI 匹配会同步等待外部 Provider:Provider 配置允许 3~600 秒,旧版详情复合一键匹配共享600秒总预算,旧Admin客户端等待610秒;#254未部署前仍适用,部署后详情入口改用下节动态预算。因此服务端 `settings.application.writetimeout` 必须至少为 620 秒,受版本管理的标准配置固定为 `620`;启动时低于该值必须明确失败,禁止以 2 秒等短写超时运行,否则连接会在领域错误返回前被截断并表现为代理 502。反向代理的读/发送超时也必须大于服务端窗口。
|
||||
|
||||
Provider 故障日志只允许记录调用关联 ID、操作类型、耗时、上游 HTTP 状态码或网络错误分类;不得记录 API Key、Authorization、Provider URL、模型输入、候选规格、商品原始内容或响应正文。Provider 失败时接口返回 HTTP 503 与 `AI_MATCHING_UNAVAILABLE`,供 Admin 显示安全中文提示。
|
||||
|
||||
@@ -73,10 +73,10 @@ Provider 故障日志只允许记录调用关联 ID、操作类型、耗时、
|
||||
|
||||
## 客户端密钥部署与验证(#237)
|
||||
|
||||
实现基线 `71f7751`;2026-09-07 已按用户授权完成本机 MySQL 迁移、管理员菜单写入和 Server/Web 构建重启;管理页面 HTTP 列表与模块加载已验证。线上仍未部署。
|
||||
实现基线 `71f7751`;2026-09-07 已按用户授权完成本机 MySQL 迁移、管理员菜单写入和 Server/Web 构建重启;管理页面 HTTP 列表与模块加载已验证。2026-09-08 经用户授权,线上已发布 Server/Web 基线 `d403f3b`,执行迁移 `1788798000000` 并重启 GoAuto/Nginx;迁移记录、两张密钥表、仅管理员菜单关联已回读。HTTP 页面与健康检查通过;未登录管理请求 HTTP 200、业务码 401。真实密钥创建及客户端业务读写闭环尚未验证。
|
||||
|
||||
- 发布前须单独授权追加迁移 `server/cmd/migrate/migration/version-local/1788798000000_client_api_key.go`,按既有迁移流程创建 client_api_key、client_api_key_audit 和“采采管理/客户端密钥”管理员菜单。前置父菜单必须存在;不应以赋予普通用户管理员角色代替迁移或权限核验。
|
||||
- 用户于 2026-09-07 明确确认默认兼容 HTTP/HTTPS、不设开关并接受明文风险;部署本版本并执行迁移后,现有 `http://185.216.248.75:9527` 可以使用客户端密钥管理及客户端 API。本机已部署验证,不能据此宣称线上已经可用。HTTP 会明文传输密钥和业务数据,仍建议使用 HTTPS。
|
||||
- 用户于 2026-09-07 明确确认默认兼容 HTTP/HTTPS、不设开关并接受明文风险;部署本版本并执行迁移后,现有 `http://185.216.248.75:9527` 可以使用客户端密钥管理及客户端 API。2026-09-08 线上部署及未登录拒绝检查已通过,真实密钥读写验收仍待进行。HTTP 会明文传输密钥和业务数据,仍建议使用 HTTPS。
|
||||
- 客户端密钥功能不依赖 GOAUTO_TRUST_FORWARDED_PROTO、X-Forwarded-Proto 或 Agent HTTP 例外。既有其他路由的协议和代理配置保持不变;不伪造协议头,不新增明文放行开关。
|
||||
- 代理、APM、应用日志均不得记录 Authorization、创建响应 secret 或原始业务载荷。应用对两类客户端密钥路由跳过旧请求/响应正文日志,使用专用元数据审计;实际代理日志脱敏仍须部署验收。
|
||||
- 请求审计 status=0 可能表示在途、进程中断或结果审计更新失败;先按请求关联号核对业务结果,不自动重试采购、采集、同步等操作。停用阻止后续认证,不保证取消已开始的业务操作。
|
||||
@@ -92,4 +92,15 @@ Provider 故障日志只允许记录调用关联 ID、操作类型、耗时、
|
||||
- 真实迁移必须单独授权并先确认唯一待执行版本;常驻 API 加 `-SkipMigration`,日常重启不自动执行未来待审核迁移。#237 迁移 `1788798000000_client_api_key.go` 已执行,sys_migration 记录 `1788798000000`、两张密钥表及管理员菜单已回读;其他角色未新增菜单关联。任务启停状态未改。
|
||||
- 范围明确的服务控制:`D:/supervisor/supervisord.exe -c D:/supervisor/supervisord.conf ctl status goauto-admin-api goauto-admin-ui`,启动/停止/重启将 status 分别替换为 start/stop/restart。不得为了 GoAuto 重启整个 Supervisor 或其他项目。配置内容变化后需重新读取配置;本次调用 `supervisor.reloadConfig`,再对上述两个程序定向 start,已通过进程命令行核对新目录。
|
||||
- 日志:`D:/supervisor/logs/goauto-admin-api.log`、`D:/supervisor/logs/goauto-admin-ui.log`;对外分享只能保留脱敏结构摘要。迁移输出不得暴露凭据或业务原文。
|
||||
- 管理员刷新页面后可进入 `http://127.0.0.1:9527/#/client-keys/index`;菜单缓存未更新时重新登录。已验证列表空态、创建弹窗加载 12 模块及 HTTP 风险提示;未实际创建密钥,真实客户端读写及停用闭环仍待范围明确的验证。线上 GoAuto/Nginx 未重启或发布。
|
||||
- 管理员刷新页面后可进入 `http://127.0.0.1:9527/#/client-keys/index`;菜单缓存未更新时重新登录。已验证列表空态、创建弹窗加载 12 模块及 HTTP 风险提示;未实际创建密钥,真实客户端读写及停用闭环仍待范围明确的验证。本节为 2026-09-07 本机验证记录;线上已于 2026-09-08 另行授权发布,见上节。
|
||||
|
||||
|
||||
## 蝦皮详情匹配等待与回读(#254)
|
||||
|
||||
实现绑定 `9088e6b`(2026-09-10,分支 fix/254-match-loading);已通过合成数据测试,尚未合并 main 或发布线上。
|
||||
|
||||
- 本次只完成代码构建与合成测试,未改线上配置、AI设置、常驻服务或Nginx;部署与重启需另行授权。
|
||||
- 详情匹配新窗口是后端 2T+10 秒、Web POST 2T+20 秒。当前默认API最低写超时校验仍为620秒,这是历史最低值,不保证容纳所有动态预算;发布前读取实际 AI timeoutSeconds、application.writetimeout 和生效代理配置核对。
|
||||
- 建议API写超时至少为 2T+30 秒,且仍满足原最低620秒;代理读/发送超时需留足对应窗口。T=60/180时原620秒足够容纳应用预算;T=600时至少需要1230秒API写超时及适当更长的代理窗口。不得仅设置较大的AI超时却忽略传输层更早截断。
|
||||
- 上述是发布核对要求,不是本单已修改/验证线上代理的结论。超大T带来长连接占用,不能用关闭超时来代替正确预算。新增失败回读不代表必须延长任何线上请求。
|
||||
- 必须配套发布 Server/Web;仅上新版Web连接旧Server时,因缺失派生预算会提示等待时间不可用并停止匹配。原Android不需升级。
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
# PDD 首页控件摘要(设备 192.168.0.9:35727)
|
||||
|
||||
- 采集时间:2026-09-15
|
||||
- 包名:`com.xunmeng.pinduoduo`
|
||||
- 发现语义入口:`content-desc=拍照搜索`
|
||||
- 发现搜索入口:`content-desc=搜索`
|
||||
- 结论:PDD 首页存在稳定的“拍照搜索”无障碍语义入口,可作为 #277 的第一步入口探测。
|
||||
- 未保存坐标、完整控件树、截图、账号或个人数据。
|
||||
@@ -0,0 +1,38 @@
|
||||
param(
|
||||
[string]$BaseUrl = $env:GOAUTO_ADMIN_BASE_URL,
|
||||
[int]$BatchSize = 100,
|
||||
[int]$PageSize = 500,
|
||||
[int]$SleepSeconds = 1,
|
||||
[switch]$Force,
|
||||
[switch]$DryRun
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
if ([string]::IsNullOrWhiteSpace($BaseUrl) -or [string]::IsNullOrWhiteSpace($env:GOAUTO_ADMIN_TOKEN)) {
|
||||
throw '请设置 GOAUTO_ADMIN_BASE_URL 与 GOAUTO_ADMIN_TOKEN 环境变量'
|
||||
}
|
||||
if ($BatchSize -lt 1 -or $BatchSize -gt 500) { throw 'BatchSize 必须在 1 到 500 之间' }
|
||||
$headers = @{ Authorization = "Bearer $($env:GOAUTO_ADMIN_TOKEN)" }
|
||||
$ids = [System.Collections.Generic.List[UInt64]]::new()
|
||||
for ($page = 1; ; $page++) {
|
||||
$uri = "$($BaseUrl.TrimEnd('/'))/api/admin/v1/syb-products?parseStatus=failed&page=$page&pageSize=$PageSize"
|
||||
$response = Invoke-RestMethod -Uri $uri -Headers $headers -Method Get
|
||||
$items = if ($response.data.items) { $response.data.items } elseif ($response.data.list) { $response.data.list } else { @() }
|
||||
foreach ($item in $items) { if ($item.id) { [void]$ids.Add([UInt64]$item.id) } }
|
||||
if ($items.Count -lt $PageSize) { break }
|
||||
}
|
||||
Write-Host "发现失败记录 $($ids.Count) 条"
|
||||
if ($DryRun -or $ids.Count -eq 0) { exit 0 }
|
||||
$ok = 0; $failed = 0
|
||||
for ($i = 0; $i -lt $ids.Count; $i += $BatchSize) {
|
||||
$end = [Math]::Min($i + $BatchSize, $ids.Count)
|
||||
$body = @{ ids = @($ids[$i..($end - 1)]); force = [bool]$Force } | ConvertTo-Json -Compress
|
||||
try {
|
||||
$result = Invoke-RestMethod -Uri "$($BaseUrl.TrimEnd('/'))/api/admin/v1/syb-products/reparse-batch" -Headers ($headers + @{ 'Content-Type' = 'application/json' }) -Method Post -Body $body
|
||||
$rows = @($result.data.results)
|
||||
$ok += @($rows | Where-Object { $_.newStatus -eq 'success' }).Count
|
||||
$failed += @($rows | Where-Object { $_.newStatus -ne 'success' }).Count
|
||||
Write-Host "已处理 $end/$($ids.Count),成功 $ok,仍失败 $failed"
|
||||
} catch { Write-Warning "批次 $($i + 1)-$end 请求失败:$($_.Exception.Message)" }
|
||||
if ($SleepSeconds -gt 0 -and $end -lt $ids.Count) { Start-Sleep -Seconds $SleepSeconds }
|
||||
}
|
||||
@@ -18,6 +18,7 @@ import (
|
||||
goautoshopeeproduct "go-admin/app/goauto/shopeeproduct"
|
||||
goautosybimport "go-admin/app/goauto/sybimport"
|
||||
goautosybinnercode "go-admin/app/goauto/sybinnercode"
|
||||
goautosybproductfilter "go-admin/app/goauto/sybproductfilter"
|
||||
goautosybshop "go-admin/app/goauto/sybshop"
|
||||
goautotask "go-admin/app/goauto/task"
|
||||
common "go-admin/common/middleware"
|
||||
@@ -63,8 +64,9 @@ func InitRouter() {
|
||||
goautoproduct.InitRouter(r, authMiddleware)
|
||||
goautoreplacement.InitRouter(r, authMiddleware)
|
||||
goautorule.InitRouter(r, authMiddleware)
|
||||
goautoshopeeproduct.InitRouter(r, authMiddleware)
|
||||
goautoshopeeproduct.InitRouter(r, authMiddleware, goautopurchase.ValidateQuickReplacement)
|
||||
goautosybimport.InitRouter(r, authMiddleware)
|
||||
goautosybinnercode.InitRouter(r, authMiddleware)
|
||||
goautosybshop.InitRouter(r, authMiddleware)
|
||||
goautosybproductfilter.InitRouter(r, authMiddleware)
|
||||
}
|
||||
|
||||
@@ -3,18 +3,19 @@ package access
|
||||
import "strings"
|
||||
|
||||
const (
|
||||
ModuleDevices = "devices"
|
||||
ModulePDDProducts = "pdd_products"
|
||||
ModuleShopeeProducts = "shopee_products"
|
||||
ModuleSYBProducts = "syb_products"
|
||||
ModuleSYBShops = "syb_shops"
|
||||
ModuleSYBSyncRuns = "syb_sync_runs"
|
||||
ModuleSYBInnerCodes = "syb_inner_codes"
|
||||
ModuleCollectionRules = "collection_rules"
|
||||
ModulePurchaseRules = "purchase_rules"
|
||||
ModuleCollectionTasks = "collection_tasks"
|
||||
ModulePurchaseTasks = "purchase_tasks"
|
||||
ModuleAIMatching = "ai_matching"
|
||||
ModuleDevices = "devices"
|
||||
ModulePDDProducts = "pdd_products"
|
||||
ModuleShopeeProducts = "shopee_products"
|
||||
ModuleSYBProducts = "syb_products"
|
||||
ModuleSYBShops = "syb_shops"
|
||||
ModuleSYBProductFilters = "syb_product_filters"
|
||||
ModuleSYBSyncRuns = "syb_sync_runs"
|
||||
ModuleSYBInnerCodes = "syb_inner_codes"
|
||||
ModuleCollectionRules = "collection_rules"
|
||||
ModulePurchaseRules = "purchase_rules"
|
||||
ModuleCollectionTasks = "collection_tasks"
|
||||
ModulePurchaseTasks = "purchase_tasks"
|
||||
ModuleAIMatching = "ai_matching"
|
||||
)
|
||||
|
||||
// ModuleDefinition is the single source of truth shared by menu migration,
|
||||
@@ -77,6 +78,7 @@ var goAutoMenuGroupMetadata = []MenuGroupDefinition{
|
||||
Sort: 51,
|
||||
ModuleKeys: []string{
|
||||
ModuleSYBShops,
|
||||
ModuleSYBProductFilters,
|
||||
ModuleCollectionRules,
|
||||
ModulePurchaseRules,
|
||||
ModuleDevices,
|
||||
@@ -91,13 +93,14 @@ var goAutoModuleMetadata = []ModuleDefinition{
|
||||
{Key: ModuleShopeeProducts, Title: "虾皮商品", Path: "/shopee-products", RouteName: "GoAutoShopeeProducts", Component: "/goauto/shopee-products/index", Icon: "goods", Sort: 52, PurchaserDefault: true},
|
||||
{Key: ModuleSYBProducts, Title: "SYB 商品", Path: "/syb-products", RouteName: "GoAutoSybProducts", Component: "/goauto/syb-products/index", Icon: "tickets", Sort: 53, PurchaserDefault: true},
|
||||
{Key: ModuleSYBShops, Title: "SYB 店铺", Path: "/syb-shops", RouteName: "GoAutoSybShops", Component: "/goauto/syb-shops/index", Icon: "shopping", Sort: 54, PurchaserDefault: true},
|
||||
{Key: ModuleSYBSyncRuns, Title: "SYB 同步记录", Path: "/syb-sync-runs", RouteName: "GoAutoSybSyncRuns", Component: "/goauto/syb-sync-runs/index", Icon: "time", Sort: 55, PurchaserDefault: true},
|
||||
{Key: ModuleSYBInnerCodes, Title: "档口入库码", Path: "/syb-inner-codes", RouteName: "GoAutoSybInnerCodes", Component: "/goauto/syb-inner-codes/index", Icon: "list", Sort: 56, PurchaserDefault: true},
|
||||
{Key: ModuleCollectionRules, Title: "采集规则", Path: "/collection-rules", RouteName: "GoAutoCollectionRules", Component: "/goauto/collection-rules/index", Icon: "code", Sort: 57, PurchaserDefault: true},
|
||||
{Key: ModulePurchaseRules, Title: "采购规则", Path: "/purchase-rules", RouteName: "GoAutoPurchaseRules", Component: "/goauto/purchase-rules/index", Icon: "setting", Sort: 58, PurchaserHardHidden: true},
|
||||
{Key: ModuleCollectionTasks, Title: "采集任务", Path: "/collection-tasks", RouteName: "GoAutoCollectionTasks", Component: "/goauto/collection-tasks/index", Icon: "list", Sort: 59, PurchaserDefault: true},
|
||||
{Key: ModulePurchaseTasks, Title: "采购管理", Path: "/purchase-tasks", RouteName: "GoAutoPurchaseTasks", Component: "/goauto/purchase-tasks/index", Icon: "shopping", Sort: 60, PurchaserDefault: true},
|
||||
{Key: ModuleAIMatching, Title: "AI 规格匹配", Path: "/ai-matching-settings", RouteName: "GoAutoAiMatchingSettings", Component: "/goauto/ai-matching-settings/index", Icon: "setting", Sort: 61, PurchaserHardHidden: true},
|
||||
{Key: ModuleSYBProductFilters, Title: "SYB 商品过滤", Path: "/syb-product-filters", RouteName: "GoAutoSybProductFilters", Component: "/goauto/syb-product-filters/index", Icon: "filter", Sort: 55, PurchaserDefault: true},
|
||||
{Key: ModuleSYBSyncRuns, Title: "SYB 同步记录", Path: "/syb-sync-runs", RouteName: "GoAutoSybSyncRuns", Component: "/goauto/syb-sync-runs/index", Icon: "time", Sort: 56, PurchaserDefault: true},
|
||||
{Key: ModuleSYBInnerCodes, Title: "档口入库码", Path: "/syb-inner-codes", RouteName: "GoAutoSybInnerCodes", Component: "/goauto/syb-inner-codes/index", Icon: "list", Sort: 57, PurchaserDefault: true},
|
||||
{Key: ModuleCollectionRules, Title: "采集规则", Path: "/collection-rules", RouteName: "GoAutoCollectionRules", Component: "/goauto/collection-rules/index", Icon: "code", Sort: 58, PurchaserDefault: true},
|
||||
{Key: ModulePurchaseRules, Title: "采购规则", Path: "/purchase-rules", RouteName: "GoAutoPurchaseRules", Component: "/goauto/purchase-rules/index", Icon: "setting", Sort: 59, PurchaserHardHidden: true},
|
||||
{Key: ModuleCollectionTasks, Title: "采集任务", Path: "/collection-tasks", RouteName: "GoAutoCollectionTasks", Component: "/goauto/collection-tasks/index", Icon: "list", Sort: 60, PurchaserDefault: true},
|
||||
{Key: ModulePurchaseTasks, Title: "采购管理", Path: "/purchase-tasks", RouteName: "GoAutoPurchaseTasks", Component: "/goauto/purchase-tasks/index", Icon: "shopping", Sort: 61, PurchaserDefault: true},
|
||||
{Key: ModuleAIMatching, Title: "AI 规格匹配", Path: "/ai-matching-settings", RouteName: "GoAutoAiMatchingSettings", Component: "/goauto/ai-matching-settings/index", Icon: "setting", Sort: 62, PurchaserHardHidden: true},
|
||||
}
|
||||
|
||||
// GoAutoModules returns independent copies so callers cannot mutate the
|
||||
@@ -144,6 +147,8 @@ func moduleKeyForAPI(path string) string {
|
||||
return ModuleSYBProducts
|
||||
case strings.HasPrefix(path, "/api/admin/v1/syb-shops"):
|
||||
return ModuleSYBShops
|
||||
case strings.HasPrefix(path, "/api/admin/v1/syb-product-filters"):
|
||||
return ModuleSYBProductFilters
|
||||
case strings.HasPrefix(path, "/api/admin/v1/syb-inner-codes"):
|
||||
return ModuleSYBInnerCodes
|
||||
case strings.HasPrefix(path, "/api/admin/v1/collection-rules"):
|
||||
|
||||
@@ -10,8 +10,8 @@ import (
|
||||
|
||||
func TestGoAutoModulesOwnEveryAdminAPIExactlyOnce(t *testing.T) {
|
||||
modules := GoAutoModules()
|
||||
if len(modules) != 12 {
|
||||
t.Fatalf("got %d modules, want 12", len(modules))
|
||||
if len(modules) != 13 {
|
||||
t.Fatalf("got %d modules, want 13", len(modules))
|
||||
}
|
||||
|
||||
owners := make(map[string]int)
|
||||
@@ -56,8 +56,8 @@ func TestGoAutoMenuSortsFitMySQLSignedTinyInt(t *testing.T) {
|
||||
}
|
||||
previous = module.Sort
|
||||
}
|
||||
if modules[0].Sort != 50 || modules[len(modules)-1].Sort != 61 {
|
||||
t.Fatalf("GoAuto menu sort range = %d..%d, want 50..61", modules[0].Sort, modules[len(modules)-1].Sort)
|
||||
if modules[0].Sort != 50 || modules[len(modules)-1].Sort != 62 {
|
||||
t.Fatalf("GoAuto menu sort range = %d..%d, want 50..62", modules[0].Sort, modules[len(modules)-1].Sort)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,7 +72,7 @@ func TestGoAutoMenuGroupsCoverModulesExactlyOnce(t *testing.T) {
|
||||
|
||||
wantOrder := [][]string{
|
||||
{ModuleSYBProducts, ModuleSYBSyncRuns, ModuleSYBInnerCodes, ModuleShopeeProducts, ModulePDDProducts, ModuleCollectionTasks, ModulePurchaseTasks},
|
||||
{ModuleSYBShops, ModuleCollectionRules, ModulePurchaseRules, ModuleDevices, ModuleAIMatching},
|
||||
{ModuleSYBShops, ModuleSYBProductFilters, ModuleCollectionRules, ModulePurchaseRules, ModuleDevices, ModuleAIMatching},
|
||||
}
|
||||
seen := make(map[string]int)
|
||||
for groupIndex, group := range groups {
|
||||
|
||||
@@ -77,6 +77,10 @@ var AdminAPIs = []APIPermission{
|
||||
{"启停 SYB 店铺", "/api/admin/v1/syb-shops/:shopId/enabled", "PATCH", false},
|
||||
{"删除 SYB 店铺", "/api/admin/v1/syb-shops/:shopId", "DELETE", false},
|
||||
{"从 SYB 发现店铺", "/api/admin/v1/syb-shops/discover", "POST", false},
|
||||
{"查看 SYB 商品过滤", "/api/admin/v1/syb-product-filters", "GET", true},
|
||||
{"新增 SYB 商品过滤", "/api/admin/v1/syb-product-filters", "POST", false},
|
||||
{"启停 SYB 商品过滤", "/api/admin/v1/syb-product-filters/:filterId/enabled", "PATCH", false},
|
||||
{"删除 SYB 商品过滤", "/api/admin/v1/syb-product-filters/:filterId", "DELETE", false},
|
||||
|
||||
{"查看采集规则模板", "/api/admin/v1/collection-rules/templates/:templateId", "GET", true},
|
||||
{"查看采集规则", "/api/admin/v1/collection-rules", "GET", true},
|
||||
@@ -94,6 +98,7 @@ var AdminAPIs = []APIPermission{
|
||||
{"查看采集任务", "/api/admin/v1/collection-tasks", "GET", true},
|
||||
{"创建采集任务", "/api/admin/v1/collection-tasks", "POST", true},
|
||||
{"批量创建采集任务", "/api/admin/v1/collection-tasks/batch", "POST", true},
|
||||
{"批量创建图搜采集任务", "/api/admin/v1/collection-tasks/image-search/batch", "POST", true},
|
||||
{"查看采集任务详情", "/api/admin/v1/collection-tasks/:taskId", "GET", true},
|
||||
{"重置采集任务", "/api/admin/v1/collection-tasks/:taskId/reset", "POST", true},
|
||||
{"删除采集任务", "/api/admin/v1/collection-tasks/:taskId", "DELETE", true},
|
||||
|
||||
@@ -15,12 +15,14 @@ func TestPurchaserPermissionMatrixHasNoDuplicates(t *testing.T) {
|
||||
|
||||
func TestPurchaserExcludesAdministratorOperations(t *testing.T) {
|
||||
denied := map[string]bool{
|
||||
"POST /api/admin/v1/devices/:deviceId/disable": true,
|
||||
"POST /api/admin/v1/syb-shops/discover": true,
|
||||
"POST /api/admin/v1/collection-rules": true,
|
||||
"PUT /api/admin/v1/ai-matching-settings": true,
|
||||
"POST /api/admin/v1/shopee-spec-auto-match/runs": true,
|
||||
"GET /api/admin/v1/shopee-spec-auto-match/runs/latest": true,
|
||||
"POST /api/admin/v1/devices/:deviceId/disable": true,
|
||||
"POST /api/admin/v1/syb-shops/discover": true,
|
||||
"POST /api/admin/v1/syb-product-filters": true,
|
||||
"PATCH /api/admin/v1/syb-product-filters/:filterId/enabled": true,
|
||||
"POST /api/admin/v1/collection-rules": true,
|
||||
"PUT /api/admin/v1/ai-matching-settings": true,
|
||||
"POST /api/admin/v1/shopee-spec-auto-match/runs": true,
|
||||
"GET /api/admin/v1/shopee-spec-auto-match/runs/latest": true,
|
||||
}
|
||||
for _, permission := range PurchaserAPIs() {
|
||||
if denied[permission.Method+" "+permission.Path] {
|
||||
|
||||
@@ -15,6 +15,7 @@ var (
|
||||
traditionalToSimplified *gocc.OpenCC
|
||||
traditionalErr error
|
||||
weightPattern = regexp.MustCompile(`(?i)(\d+(?:\.\d+)?)\s*(?:[-~~至到]\s*(\d+(?:\.\d+)?))?\s*(公斤|千克|kg|斤)`)
|
||||
sizeTokenPattern = regexp.MustCompile(`(?i)^(?:xxxs|xxl|xxxl|xxxxl|[2-9]xl|xs|s|m|l|xl|\d+xl)\b`)
|
||||
)
|
||||
|
||||
// Normalize makes a deterministic comparison key. It intentionally removes
|
||||
@@ -80,7 +81,7 @@ func normalizeWeight(raw string) string {
|
||||
// deliberately treated as no match instead of picking the first label.
|
||||
func DeterministicMatch(request MatchRequest) (MatchResult, bool) {
|
||||
color, colorOK := exactCandidate(request.TargetColor, request.Colors)
|
||||
size, sizeOK := exactCandidate(request.TargetSize, request.Sizes)
|
||||
size, sizeOK := exactSizeCandidate(request.TargetSize, request.Sizes)
|
||||
if !colorOK || !sizeOK {
|
||||
return MatchResult{}, false
|
||||
}
|
||||
@@ -88,6 +89,27 @@ func DeterministicMatch(request MatchRequest) (MatchResult, bool) {
|
||||
return result, true
|
||||
}
|
||||
|
||||
func exactSizeCandidate(target string, candidates []string) (string, bool) {
|
||||
key := sizeTokenPattern.FindString(strings.ToLower(Normalize(target)))
|
||||
if key == "" {
|
||||
return exactCandidate(target, candidates)
|
||||
}
|
||||
matches := make([]string, 0, 1)
|
||||
seen := map[string]bool{}
|
||||
for _, candidate := range candidates {
|
||||
candidate = strings.TrimSpace(candidate)
|
||||
if candidate == "" || sizeTokenPattern.FindString(strings.ToLower(Normalize(candidate))) != key || seen[candidate] {
|
||||
continue
|
||||
}
|
||||
seen[candidate] = true
|
||||
matches = append(matches, candidate)
|
||||
}
|
||||
if len(matches) == 1 {
|
||||
return matches[0], true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
// ManualMatch records an already-confirmed mapping without reinterpreting it.
|
||||
// The caller owns the human-confirmation check; this helper only creates the
|
||||
// same bounded task snapshot shape used by automatic decisions.
|
||||
|
||||
@@ -33,7 +33,7 @@ func fixture(t *testing.T) (*gorm.DB, clientkey.Service) {
|
||||
func TestEveryRouteIsExplicitlyScoped(t *testing.T) {
|
||||
db, s := fixture(t)
|
||||
routes := Inventory()
|
||||
if len(s.Modules) != 12 {
|
||||
if len(s.Modules) != 13 {
|
||||
t.Fatal("menu groups lost")
|
||||
}
|
||||
router := gin.New()
|
||||
|
||||
@@ -95,6 +95,7 @@ func Inventory() []Endpoint {
|
||||
{"GET", "/collection-tasks/:taskId", access.ModuleCollectionTasks, "read", t.AdminDetail},
|
||||
{"POST", "/collection-tasks", access.ModuleCollectionTasks, "collect", t.AdminCreate},
|
||||
{"POST", "/collection-tasks/batch", access.ModuleCollectionTasks, "collect", t.AdminBatchCreate},
|
||||
{"POST", "/collection-tasks/image-search/batch", access.ModuleCollectionTasks, "collect", t.AdminBatchCreateImageSearch},
|
||||
{"POST", "/collection-tasks/:taskId/reset", access.ModuleCollectionTasks, "collect", t.AdminReset},
|
||||
{"DELETE", "/collection-tasks/:taskId", access.ModuleCollectionTasks, "delete", t.AdminDelete},
|
||||
{"GET", "/purchase-tasks", access.ModulePurchaseTasks, "read", buy.AdminList},
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"go-admin/app/goauto/models"
|
||||
"go-admin/app/goauto/sybshop"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
@@ -41,6 +42,7 @@ func MigratedModels() []any {
|
||||
&models.SYBSpecAIParseWorkItem{},
|
||||
&models.SYBSession{},
|
||||
&models.SYBShop{},
|
||||
&models.SYBProductFilter{},
|
||||
&models.SYBSyncRun{},
|
||||
&models.SYBInnerCodeRecord{},
|
||||
&models.SYBInnerCodeItem{},
|
||||
@@ -85,6 +87,15 @@ func Migrate(db *gorm.DB) error {
|
||||
Update("task_type", models.PurchaseTaskTypeSYBOrder).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
// #269: seed immutable structure判据 and the initial keyword safety net.
|
||||
seed := []struct{ kind, keyword string }{{"char", "-"}, {"char", "#"}, {"keyword", "CVC短袖220斤"}, {"keyword", "180克純棉圓領短袖"}, {"keyword", "拼色純棉短袖"}, {"keyword", "300斤牛奶絲圓領"}, {"keyword", "印花大學T"}, {"keyword", "印花帽T"}}
|
||||
for _, item := range seed {
|
||||
norm := sybshop.Normalize(item.keyword)
|
||||
row := models.SYBProductFilter{Kind: item.kind, Keyword: item.keyword, NormalizedKeyword: norm, Enabled: true}
|
||||
if err := db.Where("kind = ? AND normalized_keyword = ?", item.kind, norm).FirstOrCreate(&row).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return ensureMySQLDirectSelectConstraint(db)
|
||||
}
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ const (
|
||||
|
||||
CollectionTaskSourceAdmin = "admin"
|
||||
CollectionTaskSourceAgentCurrentPage = "agent_current_page"
|
||||
CollectionTaskSourceImageSearch = "image_search"
|
||||
)
|
||||
|
||||
// AgentDevice identifies one Agent installation. InstallID is generated by the
|
||||
@@ -181,13 +182,14 @@ type CollectionTask struct {
|
||||
Rule CollectionRule `json:"-" gorm:"constraint:OnUpdate:CASCADE,OnDelete:RESTRICT"`
|
||||
DeviceID *uint64 `json:"deviceId" gorm:"index;uniqueIndex:ux_collection_task_running_device,priority:1"`
|
||||
Device *AgentDevice `json:"-"`
|
||||
Source string `json:"source" gorm:"size:32;not null;default:admin;index;check:ck_collection_task_source,source IN ('admin','agent_current_page')"`
|
||||
Source string `json:"source" gorm:"size:32;not null;default:admin;index;check:ck_collection_task_source,source IN ('admin','agent_current_page','image_search')"`
|
||||
Status string `json:"status" gorm:"size:24;not null;index;check:ck_collection_task_status,status IN ('pending','running','completed','completed_partial','failed')"`
|
||||
ActiveSlot *uint8 `json:"-" gorm:"uniqueIndex:ux_collection_task_active_product,priority:2;check:ck_collection_task_active_slot,(status IN ('pending','running') AND active_slot = 1) OR (status NOT IN ('pending','running') AND active_slot IS NULL)"`
|
||||
DeviceRunSlot *uint8 `json:"-" gorm:"uniqueIndex:ux_collection_task_running_device,priority:2;check:ck_collection_task_device_run_slot,(status = 'running' AND device_id IS NOT NULL AND device_run_slot = 1) OR (status <> 'running' AND device_run_slot IS NULL)"`
|
||||
URLSnapshot string `json:"urlSnapshot" gorm:"type:text;not null"`
|
||||
GoodsIDSnapshot string `json:"goodsIdSnapshot" gorm:"size:32;not null;index"`
|
||||
RuleSnapshot string `json:"ruleSnapshot" gorm:"type:text;not null"`
|
||||
ImageSearchSnapshot *string `json:"-" gorm:"type:text"`
|
||||
AttemptNumber int `json:"attemptNumber" gorm:"not null;default:1;check:ck_collection_task_attempt_number,attempt_number >= 1"`
|
||||
LeaseExpiresAt *time.Time `json:"leaseExpiresAt" gorm:"index"`
|
||||
LeaseVersion uint64 `json:"leaseVersion" gorm:"not null;default:0"`
|
||||
@@ -416,7 +418,8 @@ type ShopeeProduct struct {
|
||||
// PDDProductID stays nullable and must NOT be unique: one Shopee product maps
|
||||
// to at most one PDD product, but several Shopee products may share the same
|
||||
// PDD product.
|
||||
PDDProductID *uint64 `json:"pddProductId" gorm:"index"`
|
||||
PDDProductID *uint64 `json:"pddProductId" gorm:"index"`
|
||||
ImageSearchLinked bool `json:"imageSearchLinked" gorm:"not null;default:false;index"`
|
||||
// ImageURL holds the SYB-provided reference image URL. It is written by the
|
||||
// #41 import and may be overridden manually; the product domain never joins
|
||||
// syb_products at read time.
|
||||
@@ -455,7 +458,7 @@ func (product *ShopeeProduct) BeforeCreate(_ *gorm.DB) error {
|
||||
// Parse status for a SYBProduct's productSpec extraction. See sybimport.Parse.
|
||||
const (
|
||||
SYBParseStatusSuccess = "success"
|
||||
SYBParseStatusUncertain = "uncertain"
|
||||
SYBParseStatusUncertain = "uncertain" // legacy input only; persistence normalizes to failed
|
||||
SYBParseStatusFailed = "failed"
|
||||
)
|
||||
|
||||
@@ -600,32 +603,61 @@ type SYBShop struct {
|
||||
|
||||
func (SYBShop) TableName() string { return "syb_shop" }
|
||||
|
||||
// SYBProductFilter controls which SYB detail variationSku values are skipped.
|
||||
// Char rows are seeded business判据 and may only be enabled/disabled; keyword
|
||||
// rows are operator-maintained. DeletedFlag keeps the composite unique key
|
||||
// reusable after a keyword is soft-deleted.
|
||||
type SYBProductFilter struct {
|
||||
ID uint64 `json:"id" gorm:"primaryKey;autoIncrement"`
|
||||
Kind string `json:"kind" gorm:"size:16;not null;index;uniqueIndex:ux_syb_product_filter_key,priority:1;check:ck_syb_product_filter_kind,kind IN ('char','keyword')"`
|
||||
// Keyword and NormalizedKeyword are sized, not text: MySQL refuses a
|
||||
// BLOB/TEXT column in a key specification without a prefix length, so the
|
||||
// unique index below cannot be built on a text column (Error 1170). SQLite
|
||||
// accepts either, which is why only a real MySQL migration surfaces this.
|
||||
Keyword string `json:"keyword" gorm:"size:200;not null"`
|
||||
NormalizedKeyword string `json:"normalizedKeyword" gorm:"size:200;not null;uniqueIndex:ux_syb_product_filter_key,priority:2"`
|
||||
Enabled bool `json:"enabled" gorm:"not null;default:true"`
|
||||
LastHitAt *time.Time `json:"lastHitAt"`
|
||||
LastHitCount *int `json:"lastHitCount"`
|
||||
Note *string `json:"note" gorm:"type:text"`
|
||||
LastChangedBy string `json:"lastChangedBy" gorm:"size:128;not null;default:''"`
|
||||
LastChangedAt *time.Time `json:"lastChangedAt"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
DeletedAt gorm.DeletedAt `json:"-" gorm:"index"`
|
||||
DeletedFlag uint64 `json:"-" gorm:"not null;default:0;uniqueIndex:ux_syb_product_filter_key,priority:3"`
|
||||
}
|
||||
|
||||
func (SYBProductFilter) TableName() string { return "syb_product_filter" }
|
||||
|
||||
// SYBSyncRun records one asynchronous SYB import. ActiveSlot is set only while
|
||||
// a run is active; its unique index is the database-level single-flight guard
|
||||
// across processes and is cleared when the run reaches a terminal state.
|
||||
type SYBSyncRun struct {
|
||||
ID uint64 `json:"id" gorm:"primaryKey;autoIncrement"`
|
||||
DateFrom string `json:"dateFrom" gorm:"size:10;not null"`
|
||||
DateTo string `json:"dateTo" gorm:"size:10;not null"`
|
||||
Status string `json:"status" gorm:"size:16;not null;index"`
|
||||
ActiveSlot *uint8 `json:"-" gorm:"uniqueIndex:ux_syb_sync_run_active_slot"`
|
||||
DaysTotal int `json:"daysTotal" gorm:"not null;default:0"`
|
||||
DaysProcessed int `json:"daysProcessed" gorm:"not null;default:0"`
|
||||
OrderCount int `json:"orderCount" gorm:"not null;default:0"`
|
||||
DetailCount int `json:"detailCount" gorm:"not null;default:0"`
|
||||
AcceptedCount int `json:"acceptedCount" gorm:"not null;default:0"`
|
||||
ShopSkipped int `json:"shopSkipped" gorm:"not null;default:0"`
|
||||
Created int `json:"created" gorm:"column:created_count;not null;default:0"`
|
||||
Updated int `json:"updated" gorm:"column:updated_count;not null;default:0"`
|
||||
ShopFilterHash string `json:"shopFilterHash" gorm:"size:64;not null"`
|
||||
ShopBreakdownJSON string `json:"-" gorm:"type:text;not null"`
|
||||
ErrorMessage string `json:"errorMessage" gorm:"size:1000;not null;default:''"`
|
||||
OperatorID uint64 `json:"operatorId" gorm:"not null;default:0"`
|
||||
OperatorName string `json:"operatorName" gorm:"size:128;not null;default:''"`
|
||||
StartedAt time.Time `json:"startedAt" gorm:"not null"`
|
||||
FinishedAt *time.Time `json:"finishedAt"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
ID uint64 `json:"id" gorm:"primaryKey;autoIncrement"`
|
||||
DateFrom string `json:"dateFrom" gorm:"size:10;not null"`
|
||||
DateTo string `json:"dateTo" gorm:"size:10;not null"`
|
||||
Status string `json:"status" gorm:"size:16;not null;index"`
|
||||
ActiveSlot *uint8 `json:"-" gorm:"uniqueIndex:ux_syb_sync_run_active_slot"`
|
||||
DaysTotal int `json:"daysTotal" gorm:"not null;default:0"`
|
||||
DaysProcessed int `json:"daysProcessed" gorm:"not null;default:0"`
|
||||
OrderCount int `json:"orderCount" gorm:"not null;default:0"`
|
||||
DetailCount int `json:"detailCount" gorm:"not null;default:0"`
|
||||
AcceptedCount int `json:"acceptedCount" gorm:"not null;default:0"`
|
||||
ShopSkipped int `json:"shopSkipped" gorm:"not null;default:0"`
|
||||
CharFilterSkipped int `json:"charFilterSkipped" gorm:"column:char_filter_skipped;not null;default:0"`
|
||||
KeywordFilterSkipped int `json:"keywordFilterSkipped" gorm:"column:keyword_filter_skipped;not null;default:0"`
|
||||
Created int `json:"created" gorm:"column:created_count;not null;default:0"`
|
||||
Updated int `json:"updated" gorm:"column:updated_count;not null;default:0"`
|
||||
ShopFilterHash string `json:"shopFilterHash" gorm:"size:64;not null"`
|
||||
ShopBreakdownJSON string `json:"-" gorm:"type:text;not null"`
|
||||
ErrorMessage string `json:"errorMessage" gorm:"size:1000;not null;default:''"`
|
||||
OperatorID uint64 `json:"operatorId" gorm:"not null;default:0"`
|
||||
OperatorName string `json:"operatorName" gorm:"size:128;not null;default:''"`
|
||||
StartedAt time.Time `json:"startedAt" gorm:"not null"`
|
||||
FinishedAt *time.Time `json:"finishedAt"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
func (SYBSyncRun) TableName() string { return "syb_sync_run" }
|
||||
|
||||
@@ -422,6 +422,14 @@ func (s *Service) previewFromDataset(id uint64, dataset batchPreviewDataset, gua
|
||||
}
|
||||
// #190:映射不完整不再拦截,任务以 unresolved 建立并交由规格探测解析。
|
||||
}
|
||||
// Readiness must agree with the mapping shown in the product detail. A
|
||||
// deterministic suggestion is only a preview; it is not a persisted,
|
||||
// confirmed mapping and must not make the row appear purchase-ready.
|
||||
if source == "unresolved" || !mappingTargetsValid(candidates, syb.TargetColor, syb.TargetSize, item.MappedColor, item.MappedSize) {
|
||||
// Keep Eligible for the existing live-probe path, but expose the
|
||||
// unresolved state so the UI cannot label the row purchase-ready.
|
||||
item.ReasonCode, item.Reason, item.NextAction = CodeMappingRequired, "采购规格尚未匹配并保存", "open_mapping"
|
||||
}
|
||||
reference, minPrice, maxPrice, err := purchasePriceRange(pdd.SpecsJSON, item.MappedColor, guard)
|
||||
if err != nil {
|
||||
item.ReasonCode, item.Reason, item.NextAction = "PDD_PRICE_MISSING", err.Error(), "open_pdd"
|
||||
|
||||
@@ -142,7 +142,7 @@ func TestBatchSpecMatchPersistsExactMatchButPurchaseDoesNotWaitForIt(t *testing.
|
||||
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 != ProcessStagePurchaseReady {
|
||||
if err != nil || len(before.Items) != 1 || !before.Items[0].Eligible || !before.Items[0].AIMatchEligible || before.Items[0].ProcessStage != ProcessStageColorMapping {
|
||||
t.Fatalf("live-probe purchase was incorrectly gated by persisted mapping: %+v err=%v", before, err)
|
||||
}
|
||||
|
||||
@@ -181,7 +181,7 @@ func TestExtractedUncertainSpecsCanBeMatchedBeforePurchase(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 != ProcessStagePurchaseReady {
|
||||
if err != nil || len(before.Items) != 1 || !before.Items[0].Eligible || !before.Items[0].AIMatchEligible || before.Items[0].ProcessStage != ProcessStageColorMapping {
|
||||
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}})
|
||||
@@ -231,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 != ProcessStagePurchaseReady {
|
||||
if !item.AIMatchEligible || item.AIMatchDisabledReason != "" || !item.Eligible || item.ProcessStage != ProcessStageColorMapping {
|
||||
t.Fatalf("explicit AI eligibility mismatch: %+v", item)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -314,7 +314,7 @@ func TestBatchPreviewBulkLoadsAndNeverCallsAIMatcher(t *testing.T) {
|
||||
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.EligibleCount != 2 {
|
||||
t.Fatalf("unresolved rows must be ready for mandatory live probing: %+v", response)
|
||||
t.Fatalf("unresolved rows remain eligible for live probing but are not purchase-ready: %+v", response)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -329,7 +329,7 @@ func TestBatchPreviewAllowsIncompleteArchivedCandidatesForMandatoryLiveProbe(t *
|
||||
}
|
||||
|
||||
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 {
|
||||
if err != nil || len(response.Items) != 1 || !response.Items[0].Eligible || response.Items[0].ProcessStage != ProcessStageManualAction {
|
||||
t.Fatalf("incomplete archive blocked live probe task creation: %+v err=%v", response, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
|
||||
"go-admin/app/goauto/aimatching"
|
||||
"go-admin/app/goauto/device"
|
||||
@@ -342,22 +343,6 @@ func (s *Service) SubmitResult(ctx context.Context, taskID uint64, req ResultReq
|
||||
now := s.Now()
|
||||
next := ""
|
||||
switch req.ResultType {
|
||||
case "spec_rematch_completed":
|
||||
if t.ExecutionMode != models.PurchaseExecutionModeLive || t.Status != models.PurchaseTaskStatusRunning || t.SpecDecisionRequestID == nil || t.IrreversibleAt != nil || len(req.ProbedSpecs) == 0 {
|
||||
return TaskPayload{}, fail(CodeSpecRematchRejected, "当前任务不满足一次现场重新匹配条件")
|
||||
}
|
||||
var rematchCount int64
|
||||
if e := tx.Model(&models.PurchaseTaskAttempt{}).Where("task_id = ? AND result_type = ?", t.ID, "spec_rematch_completed").Count(&rematchCount).Error; e != nil {
|
||||
return TaskPayload{}, internal(e)
|
||||
}
|
||||
if rematchCount > 0 {
|
||||
return TaskPayload{}, fail(CodeSpecRematchRejected, "该任务的现场重新匹配次数已用尽")
|
||||
}
|
||||
next = models.PurchaseTaskStatusSpecProbePending
|
||||
a.Status = models.PurchaseAttemptStatusCompleted
|
||||
t.SpecDecisionRequestID = nil
|
||||
t.SpecDecisionBy = nil
|
||||
t.MappedColorSnapshot, t.MappedSizeSnapshot, t.SpecSource = "", "", "unresolved"
|
||||
case "spec_probe_completed":
|
||||
if len(req.ProbedSpecs) == 0 {
|
||||
return TaskPayload{}, fail(CodeInvalidRequest, "规格探测结果无效")
|
||||
@@ -413,6 +398,11 @@ func (s *Service) SubmitResult(ctx context.Context, taskID uint64, req ResultReq
|
||||
case "failed":
|
||||
next = models.PurchaseTaskStatusFailed
|
||||
a.Status = models.PurchaseAttemptStatusFailed
|
||||
// `[必须]` Write the failure onto the attempt too, not only the task.
|
||||
// Without it every attempt produced by this branch carries a NULL
|
||||
// error_code, so attempt-level statistics silently omit them — on
|
||||
// production the spec-panel failures were invisible that way (#271).
|
||||
a.ErrorCode, a.ErrorMessage = &req.ErrorCode, &req.ErrorMessage
|
||||
t.ErrorCode = &req.ErrorCode
|
||||
t.ErrorMessage = &req.ErrorMessage
|
||||
t.ActualUnitPriceCent = req.ActualUnitPriceCent
|
||||
@@ -429,7 +419,7 @@ func (s *Service) SubmitResult(ctx context.Context, taskID uint64, req ResultReq
|
||||
a.ResultHash = &digest
|
||||
a.ResultType = &req.ResultType
|
||||
a.FinishedAt = &now
|
||||
if req.ResultType == "spec_probe_completed" || req.ResultType == "spec_rematch_completed" {
|
||||
if req.ResultType == "spec_probe_completed" {
|
||||
a.SpecDecisionSnapshot = string(req.ProbedSpecs)
|
||||
}
|
||||
if e := tx.Omit("Task").Save(a).Error; e != nil {
|
||||
@@ -440,7 +430,7 @@ func (s *Service) SubmitResult(ctx context.Context, taskID uint64, req ResultReq
|
||||
}
|
||||
return valuePayload(s, t, a, false)
|
||||
})
|
||||
if err != nil || (req.ResultType != "spec_probe_completed" && req.ResultType != "spec_rematch_completed") || payload.Replayed || payload.Status != models.PurchaseTaskStatusSpecProbePending {
|
||||
if err != nil || req.ResultType != "spec_probe_completed" || payload.Replayed || payload.Status != models.PurchaseTaskStatusSpecProbePending {
|
||||
return payload, err
|
||||
}
|
||||
return s.resolveProbedSpecs(ctx, taskID, req.TaskAttemptID, req.ProbedSpecs)
|
||||
@@ -463,6 +453,26 @@ var allowedOrderUnknownFailures = map[string]string{
|
||||
CodeOrderUnpaidMissing: "订单详情缺少待付款状态证据",
|
||||
}
|
||||
|
||||
// orderFailureMessageLimit matches the error_message column width on both
|
||||
// purchase_task and purchase_task_attempt (size:1000). The canonical sentence is
|
||||
// never sacrificed to fit: only the appended agent evidence is truncated.
|
||||
const orderFailureMessageLimit = 1000
|
||||
|
||||
// normalizeOrderUnknownFailure keeps the canonical sentence as the message
|
||||
// people read, and appends the agent's own diagnostic behind it.
|
||||
//
|
||||
// `[必须]` The canonical text stays authoritative. The agent supplies free text,
|
||||
// so it must not become the primary description of what happened, and the UI
|
||||
// wording has to stay stable per error code.
|
||||
//
|
||||
// `[必须]` The agent's evidence is nevertheless kept. Discarding it left the
|
||||
// PAYMENT_REPEATED failures unanalysable: #210 computes paymentBackAttempts and
|
||||
// consecutivePaymentSamplesAfterBack, the agent reports them, and none of the 61
|
||||
// production failures between 2026-09-07 and 09-11 carried either value, so
|
||||
// "Back did not work", "the page rendered slower than the ~1.1s window" and "the
|
||||
// page was the order page but no marker matched" could not be told apart. The
|
||||
// sibling "failed" branch already stores agent text verbatim, so keeping it here
|
||||
// introduces no new trust assumption (#271).
|
||||
func normalizeOrderUnknownFailure(code, message string) (string, string, error) {
|
||||
code, message = strings.TrimSpace(code), strings.TrimSpace(message)
|
||||
if code == "" && message == "" {
|
||||
@@ -472,7 +482,53 @@ func normalizeOrderUnknownFailure(code, message string) (string, string, error)
|
||||
if !ok || message == "" {
|
||||
return "", "", fail(CodeInvalidRequest, "订单核单失败阶段无效")
|
||||
}
|
||||
return code, canonicalMessage, nil
|
||||
return code, appendAgentEvidence(canonicalMessage, message), nil
|
||||
}
|
||||
|
||||
// appendAgentEvidence joins the canonical sentence with the agent's diagnostic.
|
||||
//
|
||||
// The evidence is flattened to a single line first: agent text reaches the
|
||||
// purchase list, task detail and exports, and an embedded newline or control
|
||||
// character would break those renderings.
|
||||
func appendAgentEvidence(canonical, evidence string) string {
|
||||
evidence = flattenAgentEvidence(evidence)
|
||||
if evidence == "" || evidence == canonical {
|
||||
return canonical
|
||||
}
|
||||
room := orderFailureMessageLimit - len([]rune(canonical)) - 1
|
||||
if room <= 0 {
|
||||
return canonical
|
||||
}
|
||||
if runes := []rune(evidence); len(runes) > room {
|
||||
if room <= 1 {
|
||||
return canonical
|
||||
}
|
||||
evidence = string(runes[:room-1]) + "…"
|
||||
}
|
||||
return canonical + " " + evidence
|
||||
}
|
||||
|
||||
// flattenAgentEvidence removes control characters and collapses whitespace so
|
||||
// the appended text is always one readable line.
|
||||
func flattenAgentEvidence(evidence string) string {
|
||||
var b strings.Builder
|
||||
lastWasSpace := false
|
||||
for _, r := range evidence {
|
||||
switch {
|
||||
case r == 0xFEFF:
|
||||
// Zero-width no-break space: invisible, so drop it rather than
|
||||
// turning it into a space that would look like a stray gap.
|
||||
case unicode.IsControl(r) || unicode.IsSpace(r):
|
||||
if !lastWasSpace {
|
||||
b.WriteRune(' ')
|
||||
lastWasSpace = true
|
||||
}
|
||||
default:
|
||||
b.WriteRune(r)
|
||||
lastWasSpace = false
|
||||
}
|
||||
}
|
||||
return strings.TrimSpace(b.String())
|
||||
}
|
||||
|
||||
func (s *Service) ApplySpecDecision(ctx context.Context, taskID uint64, req SpecDecisionRequest) (models.PurchaseTask, bool, error) {
|
||||
@@ -716,7 +772,7 @@ func ensureAccountFree(tx *gorm.DB, accountID *uint64, taskID uint64, now time.T
|
||||
return nil
|
||||
}
|
||||
func (s *Service) payload(t models.PurchaseTask, a *models.PurchaseTaskAttempt, replayed bool) (*TaskPayload, error) {
|
||||
p := &TaskPayload{TaskID: t.ID, ExecutionMode: t.ExecutionMode, Status: t.Status, DeviceID: t.DeviceID, PDDProductID: t.PDDProductID, PDDURL: t.PDDURLSnapshot, PDDGoodsID: t.PDDGoodsIDSnapshot, TargetColor: t.TargetColorSnapshot, TargetSize: t.TargetSizeSnapshot, MappedColor: t.MappedColorSnapshot, MappedSize: t.MappedSizeSnapshot, SpecResolutionAllowed: specResolutionAllowed(t), SpecRematchAllowed: t.SpecDecisionRequestID != nil && t.TaskType == models.PurchaseTaskTypeSYBOrder && t.Status == models.PurchaseTaskStatusRunning, Quantity: t.Quantity, MinUnitPriceCent: t.MinUnitPriceCent, MaxUnitPriceCent: t.MaxUnitPriceCent, Currency: t.Currency, AddressSuffix: t.AddressSuffix, RuleSnapshot: json.RawMessage(t.RuleSnapshot), LeaseExpiresAt: t.LeaseExpiresAt, LeaseVersion: t.LeaseVersion, Replayed: replayed}
|
||||
p := &TaskPayload{TaskID: t.ID, ExecutionMode: t.ExecutionMode, Status: t.Status, DeviceID: t.DeviceID, PDDProductID: t.PDDProductID, PDDURL: t.PDDURLSnapshot, PDDGoodsID: t.PDDGoodsIDSnapshot, TargetColor: t.TargetColorSnapshot, TargetSize: t.TargetSizeSnapshot, MappedColor: t.MappedColorSnapshot, MappedSize: t.MappedSizeSnapshot, SpecResolutionAllowed: specResolutionAllowed(t), Quantity: t.Quantity, MinUnitPriceCent: t.MinUnitPriceCent, MaxUnitPriceCent: t.MaxUnitPriceCent, Currency: t.Currency, AddressSuffix: t.AddressSuffix, RuleSnapshot: json.RawMessage(t.RuleSnapshot), LeaseExpiresAt: t.LeaseExpiresAt, LeaseVersion: t.LeaseVersion, Replayed: replayed}
|
||||
if a != nil {
|
||||
p.TaskAttemptID = a.AttemptID
|
||||
p.AttemptNumber = a.AttemptNumber
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
package purchase
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"go-admin/app/goauto/models"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// 这条诊断就是 #210 让 Agent 算出来、却一直没能落库的那一段。
|
||||
const paymentRepeatAgentMessage = "支付页安全返回后持续无订单证据,已停止自动核单" +
|
||||
"[paymentBackAttempts=1;consecutivePaymentSamplesAfterBack=3]"
|
||||
|
||||
func TestNormalizeOrderUnknownFailureKeepsCanonicalAndAgentEvidence(t *testing.T) {
|
||||
code, message, err := normalizeOrderUnknownFailure(CodeOrderPaymentRepeat, paymentRepeatAgentMessage)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if code != CodeOrderPaymentRepeat {
|
||||
t.Fatalf("code changed: %q", code)
|
||||
}
|
||||
canonical := allowedOrderUnknownFailures[CodeOrderPaymentRepeat]
|
||||
if !strings.HasPrefix(message, canonical) {
|
||||
t.Fatalf("canonical sentence must lead the message, got %q", message)
|
||||
}
|
||||
// 没有这两个值就无法区分「Back 没生效」「渲染慢于窗口」「关键词没匹配」。
|
||||
for _, want := range []string{"paymentBackAttempts=1", "consecutivePaymentSamplesAfterBack=3"} {
|
||||
if !strings.Contains(message, want) {
|
||||
t.Fatalf("agent evidence %q missing from %q", want, message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeOrderUnknownFailureFlattensAndBounds(t *testing.T) {
|
||||
canonical := allowedOrderUnknownFailures[CodeOrderPaymentRepeat]
|
||||
|
||||
t.Run("控制字符和换行被压平", func(t *testing.T) {
|
||||
_, message, err := normalizeOrderUnknownFailure(CodeOrderPaymentRepeat, "证据行一\n\t行二\r\n行三\ufeff")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.ContainsAny(message, "\n\r\t") {
|
||||
t.Fatalf("message must stay on one line: %q", message)
|
||||
}
|
||||
if !strings.Contains(message, "证据行一 行二 行三") {
|
||||
t.Fatalf("whitespace should collapse to single spaces: %q", message)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("超长证据被截断且固定文案完整保留", func(t *testing.T) {
|
||||
_, message, err := normalizeOrderUnknownFailure(CodeOrderPaymentRepeat, strings.Repeat("证", 4000))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.HasPrefix(message, canonical) {
|
||||
t.Fatalf("canonical sentence was sacrificed to fit: %q", message)
|
||||
}
|
||||
if n := len([]rune(message)); n > orderFailureMessageLimit {
|
||||
t.Fatalf("message is %d runes, column holds %d", n, orderFailureMessageLimit)
|
||||
}
|
||||
if !strings.HasSuffix(message, "…") {
|
||||
t.Fatalf("truncation should be visible: %q", message[len(message)-30:])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("空消息行为不变", func(t *testing.T) {
|
||||
if _, _, err := normalizeOrderUnknownFailure(CodeOrderPaymentRepeat, ""); code(err) != CodeInvalidRequest {
|
||||
t.Fatalf("empty agent message must still be rejected, got %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("未批准的错误码仍被拒绝", func(t *testing.T) {
|
||||
if _, _, err := normalizeOrderUnknownFailure("UNAPPROVED", "whatever"); code(err) != CodeInvalidRequest {
|
||||
t.Fatalf("unapproved code must be rejected, got %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("证据与固定文案相同时不重复拼接", func(t *testing.T) {
|
||||
_, message, err := normalizeOrderUnknownFailure(CodeOrderPaymentRepeat, canonical)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if message != canonical {
|
||||
t.Fatalf("identical evidence should not be appended twice: %q", message)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 端到端确认证据真的写进了两张表,而不是只在函数里拼对了。
|
||||
func TestSubmitOrderResultUnknownPersistsAgentEvidence(t *testing.T) {
|
||||
db := testDB(t)
|
||||
f := seed(t, db, liveCaps(), true)
|
||||
s := testService(db)
|
||||
task, _ := createLive(t, s, f)
|
||||
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",
|
||||
ErrorCode: CodeOrderPaymentRepeat, ErrorMessage: paymentRepeatAgentMessage,
|
||||
}, f.token); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var saved models.PurchaseTask
|
||||
if err := db.First(&saved, task.ID).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var attempt models.PurchaseTaskAttempt
|
||||
if err := db.Where("task_id = ?", task.ID).Order("attempt_number DESC, id DESC").First(&attempt).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for name, got := range map[string]*string{"task": saved.ErrorMessage, "attempt": attempt.ErrorMessage} {
|
||||
if got == nil {
|
||||
t.Fatalf("%s kept no error message", name)
|
||||
}
|
||||
if !strings.Contains(*got, "consecutivePaymentSamplesAfterBack=3") {
|
||||
t.Fatalf("%s dropped the agent evidence: %q", name, *got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// "failed" 分支过去只写任务不写 attempt,attempt.error_code 全是 NULL,
|
||||
// 导致按 attempt 统计时这些失败是隐形的(线上规格失败即如此)。
|
||||
func TestSubmitFailedResultRecordsErrorOnAttempt(t *testing.T) {
|
||||
db := testDB(t)
|
||||
f := seed(t, db, liveCaps(), true)
|
||||
s := testService(db)
|
||||
task, _ := createLive(t, s, f)
|
||||
started := startLivePurchaseAfterProbe(t, s, f, task)
|
||||
|
||||
const failCode = "PURCHASE_SPEC_PANEL_EVIDENCE_NOT_MATCHED"
|
||||
const failMessage = "规格入口点击后页面已变化,但规格面板强证据不足 [type=UNKNOWN;scrollables=1]"
|
||||
if _, err := s.SubmitResult(context.Background(), task.ID, ResultRequest{
|
||||
RequestID: uuid.NewString(), TaskAttemptID: started.TaskAttemptID, ResultType: "failed",
|
||||
ErrorCode: failCode, ErrorMessage: failMessage,
|
||||
}, f.token); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var attempt models.PurchaseTaskAttempt
|
||||
if err := db.Where("task_id = ?", task.ID).Order("attempt_number DESC, id DESC").First(&attempt).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if attempt.ErrorCode == nil || *attempt.ErrorCode != failCode {
|
||||
t.Fatalf("attempt error code not recorded: %+v", attempt.ErrorCode)
|
||||
}
|
||||
if attempt.ErrorMessage == nil || *attempt.ErrorMessage != failMessage {
|
||||
t.Fatalf("attempt error message not recorded: %+v", attempt.ErrorMessage)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package purchase
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"go-admin/app/goauto/shopeeproduct"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// Called inside the link transaction, after locking the SYB and Shopee rows.
|
||||
// Reuse the authoritative stage calculation, without AI or task creation.
|
||||
func ValidateQuickReplacement(ctx context.Context, tx *gorm.DB, id uint64) error {
|
||||
s := NewService(tx)
|
||||
dataset, err := s.loadBatchPreviewDataset(ctx, []uint64{id})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
guard, _, err := s.readOnlyPriceGuard(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
preview := s.previewFromDataset(id, dataset, guard)
|
||||
qualification := aiMatchQualificationForDataset(id, dataset)
|
||||
preview.AIMatchEligible = qualification.Eligible
|
||||
preview.AIMatchDisabledReason = qualification.DisabledReason
|
||||
stage := processStageFromDataset(id, dataset, preview)
|
||||
syb := dataset.sybByID[id]
|
||||
disabled := false
|
||||
if syb.ShopeeProductID != nil {
|
||||
shopee := dataset.shopeeByID[*syb.ShopeeProductID]
|
||||
if shopee.PDDProductID != nil {
|
||||
pdd := dataset.pddByID[*shopee.PDDProductID]
|
||||
disabled = pdd.Status == "disabled"
|
||||
if _, active := dataset.activeCollectionByPDD[pdd.ID]; active {
|
||||
return &shopeeproduct.ServiceError{Code: shopeeproduct.CodeLinkConflict, Message: "商品正在采集,请结束后刷新再操作"}
|
||||
}
|
||||
}
|
||||
}
|
||||
if stage.NextAction == "open_pdd" && (stage.Stage == ProcessStagePDDPending || stage.Stage == ProcessStagePDDCollectionFail ||
|
||||
(stage.Stage == ProcessStageManualAction && (disabled || preview.ReasonCode == "PDD_PRICE_MISSING"))) {
|
||||
return nil
|
||||
}
|
||||
return &shopeeproduct.ServiceError{Code: shopeeproduct.CodeLinkConflict, Message: "处理阶段已变化,当前不能一键替换,请刷新列表"}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package purchase
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"go-admin/app/goauto/models"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func TestQuickReplacementUsesCurrentStage(t *testing.T) {
|
||||
for _, name := range []string{"pending", "disabled", "price-missing", "ready", "parse-failed", "collecting", "purchase-pending", "purchase-running", "order-created", "order-unknown"} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
db := testDB(t)
|
||||
f := seed(t, db, liveCaps(), true)
|
||||
allow := name == "pending" || name == "disabled" || name == "price-missing"
|
||||
switch name {
|
||||
case "pending", "disabled":
|
||||
db.Model(&f.pdd).Update("status", name)
|
||||
case "ready":
|
||||
setCollectedPDDPrice(t, db, f.pdd.ID)
|
||||
case "parse-failed":
|
||||
db.Model(&f.syb).Update("parse_status", "failed")
|
||||
case "collecting":
|
||||
rule := models.CollectionRule{Name: "synthetic", ContentJSON: "{}"}
|
||||
if err := db.Create(&rule).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Create(&models.CollectionTask{DeviceID: &f.device.ID, PDDProductID: &f.pdd.ID, RuleID: rule.ID, Status: models.TaskStatusRunning, RuleSnapshot: "{}"}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
case "purchase-pending", "purchase-running", "order-created", "order-unknown":
|
||||
status := map[string]string{"purchase-pending": "pending", "purchase-running": "running", "order-created": "order_created", "order-unknown": "order_result_unknown"}[name]
|
||||
if err := db.Session(&gorm.Session{SkipHooks: true}).Create(&models.PurchaseTask{ExecutionMode: "live", Quantity: 1, SYBProductID: &f.syb.ID, PDDProductID: f.pdd.ID, Status: status, CreateRequestID: name}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
err := ValidateQuickReplacement(context.Background(), db, f.syb.ID)
|
||||
if (err == nil) != allow {
|
||||
t.Fatalf("allowed=%v: %v", allow, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -21,7 +21,6 @@ const (
|
||||
CodeRetryUnsafe = "PURCHASE_RETRY_UNSAFE"
|
||||
CodeRetryStale = "PURCHASE_RETRY_STALE"
|
||||
CodeSpecReprobeRejected = "PURCHASE_SPEC_REPROBE_REJECTED"
|
||||
CodeSpecRematchRejected = "PURCHASE_SPEC_REMATCH_REJECTED"
|
||||
CodeOrderResultUnknown = "PURCHASE_ORDER_RESULT_UNKNOWN"
|
||||
CodeOrderEmptyTimeout = "PURCHASE_ORDER_EMPTY_TIMEOUT"
|
||||
CodeOrderChooserBack = "PURCHASE_ORDER_CHOOSER_BACK_FAILED"
|
||||
@@ -138,7 +137,6 @@ type TaskPayload struct {
|
||||
MappedColor string `json:"mappedColor"`
|
||||
MappedSize string `json:"mappedSize"`
|
||||
SpecResolutionAllowed bool `json:"specResolutionAllowed"`
|
||||
SpecRematchAllowed bool `json:"specRematchAllowed"`
|
||||
Quantity int64 `json:"quantity"`
|
||||
MinUnitPriceCent int64 `json:"minUnitPriceCent"`
|
||||
MaxUnitPriceCent int64 `json:"maxUnitPriceCent"`
|
||||
|
||||
@@ -131,6 +131,12 @@ func (service *Service) suggestMappings(ctx context.Context, id uint64, requestC
|
||||
item.Reason = "保留已确认映射"
|
||||
item.Apply = true
|
||||
response.MatchedCount++
|
||||
case role == RoleColor && deterministicColorMatch(value.Name, candidateNames) != "":
|
||||
item.PDDValue = deterministicColorMatch(value.Name, candidateNames)
|
||||
item.Status = "matched"
|
||||
item.Reason = "格式统一后唯一匹配"
|
||||
item.Apply = true
|
||||
response.MatchedCount++
|
||||
case role == RoleSize && deterministicSizeMatch(value.Name, candidateNames) != "":
|
||||
item.PDDValue = deterministicSizeMatch(value.Name, candidateNames)
|
||||
item.Status = "matched"
|
||||
@@ -237,6 +243,15 @@ func deterministicSizeMatch(value string, candidates []string) string {
|
||||
return match.MappedSize
|
||||
}
|
||||
|
||||
// Keep the original candidate label; normalization is only a comparison key.
|
||||
func deterministicColorMatch(value string, candidates []string) string {
|
||||
match, ok := aimatching.DeterministicMatch(aimatching.MatchRequest{TargetColor: value, Colors: candidates})
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
return match.MappedColor
|
||||
}
|
||||
|
||||
// aiSuggestErrorMessage turns an aimatching error into a message safe to
|
||||
// return to the admin UI (no credential, no raw provider body).
|
||||
func aiSuggestErrorMessage(err error) string {
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go-admin/app/goauto/aimatching"
|
||||
"go-admin/app/goauto/models"
|
||||
@@ -48,7 +49,20 @@ type autoMatchRef struct {
|
||||
// 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) {
|
||||
ctx, cancel := context.WithTimeout(ctx, aimatching.MaxProviderTimeout)
|
||||
started := time.Now()
|
||||
// Bound the initial settings lookup too. This budget is specific to this
|
||||
// composite endpoint; other AI operations retain their existing limits.
|
||||
ctx, cancel := context.WithTimeout(ctx, 1210*time.Second)
|
||||
defer cancel()
|
||||
seconds, err := service.autoMatchTimeout(ctx)
|
||||
if err != nil {
|
||||
return AutoMatchResponse{}, err
|
||||
}
|
||||
return service.autoMatchMappings(ctx, id, request, time.Duration(seconds)*time.Second-time.Since(started))
|
||||
}
|
||||
|
||||
func (service *Service) autoMatchMappings(ctx context.Context, id uint64, request AutoMatchRequest, budget time.Duration) (AutoMatchResponse, error) {
|
||||
ctx, cancel := context.WithTimeout(ctx, budget)
|
||||
defer cancel()
|
||||
|
||||
requestID := strings.TrimSpace(request.RequestID)
|
||||
@@ -248,6 +262,21 @@ func (service *Service) AutoMatchMappings(ctx context.Context, id uint64, reques
|
||||
return response, nil
|
||||
}
|
||||
|
||||
func autoMatchBudgetSeconds(providerSeconds int) int {
|
||||
if providerSeconds < 3 || providerSeconds > 600 {
|
||||
providerSeconds = 15 // same default as AI settings; never allow unbounded waits
|
||||
}
|
||||
return providerSeconds*2 + 10
|
||||
}
|
||||
|
||||
func (service *Service) autoMatchTimeout(ctx context.Context) (int, error) {
|
||||
settings, err := aimatching.NewService(service.DB).Settings(ctx)
|
||||
if err != nil {
|
||||
return 0, internalError(err)
|
||||
}
|
||||
return autoMatchBudgetSeconds(settings.TimeoutSeconds), nil
|
||||
}
|
||||
|
||||
func autoMatchRefs(specs []SpecDimension, role string) []autoMatchRef {
|
||||
refs := make([]autoMatchRef, 0)
|
||||
for _, dimension := range specs {
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go-admin/app/goauto/aimatching"
|
||||
"go-admin/app/goauto/models"
|
||||
|
||||
"github.com/google/uuid"
|
||||
@@ -166,7 +167,7 @@ func (service *Service) ProcessAutoMatchRun(ctx context.Context, runID uint64) e
|
||||
}
|
||||
processed++
|
||||
service.renewAutoMatchRun(run)
|
||||
response, matchErr := service.AutoMatchMappings(ctx, product.ID, AutoMatchRequest{RequestID: uuid.NewString(), SpecContextVersion: fingerprint[:64]})
|
||||
response, matchErr := service.autoMatchMappings(ctx, product.ID, AutoMatchRequest{RequestID: uuid.NewString(), SpecContextVersion: fingerprint[:64]}, aimatching.MaxProviderTimeout)
|
||||
// fingerprint begins with the 64-character context version.
|
||||
postFingerprint := fingerprint
|
||||
if next, _, nextErr := service.autoMatchEligibility(ctx, product); nextErr == nil && next != "" {
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
package shopeeproduct
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
func TestAutoMatchBudgetFollowsProviderSetting(t *testing.T) {
|
||||
for _, tc := range []struct{ ai, total int }{{3, 16}, {60, 130}, {180, 370}, {600, 1210}, {0, 40}, {601, 40}} {
|
||||
if got := autoMatchBudgetSeconds(tc.ai); got != tc.total {
|
||||
t.Fatalf("AI %d: got %d want %d", tc.ai, got, tc.total)
|
||||
}
|
||||
}
|
||||
db := openTestDB(t)
|
||||
setting := seedEnabledAISetting(t, "https://example.invalid", 0.9)
|
||||
setting.TimeoutSeconds = 60
|
||||
if err := db.Create(&setting).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s := NewService(db)
|
||||
created, err := s.Create(context.Background(), CreateRequest{RequestID: uuid.NewString(), ShopeeItemID: "budget-test"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
detail, err := s.DetailWithAutoMatchBudget(context.Background(), created.Product.ID)
|
||||
if err != nil || detail.AutoMatchTimeoutSeconds != 130 {
|
||||
t.Fatalf("budget detail: %d %v", detail.AutoMatchTimeoutSeconds, err)
|
||||
}
|
||||
body, _ := json.Marshal(detail)
|
||||
if strings.Contains(string(body), "apiKey") || strings.Contains(string(body), "example.invalid") {
|
||||
t.Fatal("AI configuration leaked")
|
||||
}
|
||||
if err := db.Model(&setting).Update("timeout_seconds", 180).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
detail, err = s.DetailWithAutoMatchBudget(context.Background(), created.Product.ID)
|
||||
if err != nil || detail.AutoMatchTimeoutSeconds != 370 {
|
||||
t.Fatalf("fresh budget: %d %v", detail.AutoMatchTimeoutSeconds, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutoMatchCompositeBudgetCancelsWithoutSaving(t *testing.T) {
|
||||
var calls atomic.Int32
|
||||
responder := chatCompletionResponder(`{"suggestions":[{"sourceId":"s1","candidateId":"c1","confidence":0.96,"reason":"unique match"}]}`)
|
||||
provider := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if calls.Add(1) == 1 {
|
||||
responder(w, r)
|
||||
return
|
||||
}
|
||||
select {
|
||||
case <-r.Context().Done():
|
||||
case <-time.After(time.Second):
|
||||
}
|
||||
}))
|
||||
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)
|
||||
}
|
||||
s := NewService(db)
|
||||
created, err := s.Create(context.Background(), CreateRequest{RequestID: uuid.NewString(), ShopeeItemID: "budget-cancel", 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)
|
||||
}
|
||||
started := time.Now()
|
||||
_, err = s.autoMatchMappings(context.Background(), created.Product.ID, AutoMatchRequest{RequestID: uuid.NewString(), SpecContextVersion: created.Product.SpecContextVersion}, 100*time.Millisecond)
|
||||
if err == nil || time.Since(started) > time.Second {
|
||||
t.Fatalf("budget not enforced: %v", err)
|
||||
}
|
||||
if calls.Load() != 2 {
|
||||
t.Fatalf("expected sequential color and size calls, got %d", calls.Load())
|
||||
}
|
||||
detail, err := s.Detail(context.Background(), created.Product.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if detail.Product.Specs[0].Values[0].Mapping != nil {
|
||||
t.Fatal("timed-out operation saved mappings")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
package shopeeproduct
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"go-admin/app/goauto/models"
|
||||
)
|
||||
|
||||
func TestColorNormalizedMatchWithoutAIKeepsOriginalLabels(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
pdd := seedPDDProduct(t, db, "active")
|
||||
pdd.SpecsJSON = `[{"name":"颜色","role":"color","values":[{"name":"260#大红","selectable":true},{"name":"260#墨绿","selectable":true},{"name":"AbC黑色","selectable":true}]}]`
|
||||
if err := db.Save(&pdd).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
service := NewService(db)
|
||||
sources := []string{"260#大紅", "260#墨綠", "abc 黑色"}
|
||||
targets := []string{"260#大红", "260#墨绿", "AbC黑色"}
|
||||
values := []SpecValue{}
|
||||
for _, name := range sources {
|
||||
values = append(values, SpecValue{Name: name, Source: ValueSourceImport})
|
||||
}
|
||||
created, err := service.Create(context.Background(), CreateRequest{RequestID: uuid.NewString(), ShopeeItemID: "normalized-color", PDDProductID: &pdd.ID, Specs: []SpecDimension{{Name: "颜色", Role: RoleColor, Values: values}}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
preview, err := service.SuggestColorMappings(context.Background(), created.Product.ID, created.Product.SpecContextVersion)
|
||||
if err != nil {
|
||||
t.Fatalf("exact colors must not require AI: %v", err)
|
||||
}
|
||||
for i, item := range preview.Items {
|
||||
if item.Status != "matched" || !item.Apply || item.ValueName != sources[i] || item.PDDValue != targets[i] {
|
||||
t.Fatalf("unexpected preview: %+v", item)
|
||||
}
|
||||
}
|
||||
if len(preview.Items) != 3 || preview.MatchedCount != 3 {
|
||||
t.Fatal("missing exact matches")
|
||||
}
|
||||
var stored models.ShopeeProduct
|
||||
if err := db.First(&stored, created.Product.ID).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
specs, err := Unmarshal(stored.SpecsJSON)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, value := range specs[0].Values {
|
||||
if value.Mapping != nil {
|
||||
t.Fatal("preview wrote mappings")
|
||||
}
|
||||
}
|
||||
result, err := service.AutoMatchMappings(context.Background(), created.Product.ID, AutoMatchRequest{RequestID: uuid.NewString(), SpecContextVersion: created.Product.SpecContextVersion})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.ConfirmedCount != 3 || result.UnmatchedCount != 0 {
|
||||
t.Fatalf("unexpected counts: %+v", result)
|
||||
}
|
||||
for i, value := range result.Product.Specs[0].Values {
|
||||
m := value.Mapping
|
||||
if value.Name != sources[i] || m == nil || m.PDDValue != targets[i] || m.Source != MappingSourceExactMatch || m.Status != MappingStatusConfirmed {
|
||||
t.Fatalf("incorrect persisted mapping: %+v", value)
|
||||
}
|
||||
}
|
||||
var after models.PDDProduct
|
||||
if err := db.First(&after, pdd.ID).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if after.SpecsJSON != pdd.SpecsJSON {
|
||||
t.Fatal("changed original PDD specs")
|
||||
}
|
||||
}
|
||||
|
||||
func TestColorNormalizedAmbiguityAndUnavailableStillRequireAI(t *testing.T) {
|
||||
for _, tc := range []struct{ name, source, specs string }{
|
||||
{"ambiguous", "大紅", `[{"name":"颜色","role":"color","values":[{"name":"大红","selectable":true},{"name":"大紅","selectable":true}]}]`},
|
||||
{"unavailable", "大紅", `[{"name":"颜色","role":"color","values":[{"name":"大红","selectable":false},{"name":"黑色","selectable":true}]}]`},
|
||||
{"unmatched", "深紅", `[{"name":"颜色","role":"color","values":[{"name":"大红","selectable":true}]}]`},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
pdd := seedPDDProduct(t, db, "active")
|
||||
pdd.SpecsJSON = tc.specs
|
||||
if err := db.Save(&pdd).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
service := NewService(db)
|
||||
created, err := service.Create(context.Background(), CreateRequest{RequestID: uuid.NewString(), ShopeeItemID: tc.name, PDDProductID: &pdd.ID, Specs: []SpecDimension{{Name: "颜色", Role: RoleColor, Values: []SpecValue{{Name: tc.source, Source: ValueSourceImport}}}}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = service.SuggestColorMappings(context.Background(), created.Product.ID, created.Product.SpecContextVersion)
|
||||
if err == nil || errCode(t, err) != CodeAIUnavailable {
|
||||
t.Fatalf("must fall back to AI, not guess: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -14,7 +14,10 @@ import (
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type Handler struct{ DB *gorm.DB }
|
||||
type Handler struct {
|
||||
DB *gorm.DB
|
||||
ReplacementEligibility ReplacementEligibility
|
||||
}
|
||||
|
||||
func (handler Handler) List(c *gin.Context) {
|
||||
page, err := queryInt(c.Query("page"), 1)
|
||||
@@ -31,8 +34,18 @@ func (handler Handler) List(c *gin.Context) {
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var imageSearchLinked *bool
|
||||
if raw := strings.TrimSpace(c.Query("imageSearchLinked")); raw != "" {
|
||||
value, parseErr := strconv.ParseBool(raw)
|
||||
if parseErr != nil {
|
||||
writeError(c, invalidRequest("imageSearchLinked 必须是布尔值"))
|
||||
return
|
||||
}
|
||||
imageSearchLinked = &value
|
||||
}
|
||||
response, err := service.List(c.Request.Context(), ListRequest{
|
||||
Page: page, PageSize: pageSize, Keyword: c.Query("keyword"), Status: strings.TrimSpace(c.Query("status")),
|
||||
ImageSearchLinked: imageSearchLinked,
|
||||
})
|
||||
if err != nil {
|
||||
writeError(c, err)
|
||||
@@ -64,7 +77,7 @@ func (handler Handler) Detail(c *gin.Context) {
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
response, err := service.Detail(c.Request.Context(), id)
|
||||
response, err := service.DetailWithAutoMatchBudget(c.Request.Context(), id)
|
||||
respond(c, response, err)
|
||||
}
|
||||
|
||||
@@ -96,10 +109,17 @@ func (handler Handler) LinkPDD(c *gin.Context) {
|
||||
writeError(c, invalidRequest("请求 JSON 无效"))
|
||||
return
|
||||
}
|
||||
// This composite workflow reads SYB and device context. It must not inherit
|
||||
// a client key's Shopee-only write grant through the shared handler.
|
||||
if request.LatestCollection != nil && !strings.HasPrefix(c.FullPath(), "/api/admin/v1/") {
|
||||
c.JSON(http.StatusForbidden, gin.H{"code": "FORBIDDEN", "message": "一键关联仅限 Admin 入口"})
|
||||
return
|
||||
}
|
||||
service, ok := handler.service(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
service.ReplacementEligibility = handler.ReplacementEligibility
|
||||
response, err := service.LinkPDD(c.Request.Context(), id, request)
|
||||
respond(c, response, err)
|
||||
}
|
||||
@@ -467,7 +487,7 @@ func writeError(c *gin.Context, err error) {
|
||||
status = http.StatusConflict
|
||||
case CodeProductNotFound, CodePDDProductNotFound, CodeMappingNotFound, CodeValueNotFound:
|
||||
status = http.StatusNotFound
|
||||
case CodePDDProductDisabled, CodeSpecContextStale:
|
||||
case CodePDDProductDisabled, CodeSpecContextStale, CodeLatestCollectionUnavailable, CodeLinkConflict:
|
||||
status = http.StatusConflict
|
||||
case CodeAIUnavailable:
|
||||
status = http.StatusServiceUnavailable
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
package shopeeproduct
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"go-admin/app/goauto/models"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
const CodeLatestCollectionUnavailable = "LATEST_COLLECTION_UNAVAILABLE"
|
||||
const CodeLinkConflict = "PDD_LINK_CONFLICT"
|
||||
|
||||
type LatestCollectionRequest struct {
|
||||
SYBProductID uint64 `json:"sybProductId"`
|
||||
DeviceID uint64 `json:"deviceId"`
|
||||
SpecContextVersion string `json:"specContextVersion"`
|
||||
Replacement *LatestCollectionReplacement `json:"replacement,omitempty"`
|
||||
}
|
||||
|
||||
type LatestCollectionReplacement struct {
|
||||
Preview bool `json:"preview"`
|
||||
ExpectedPDDProductID uint64 `json:"expectedPddProductId"`
|
||||
TaskID uint64 `json:"taskId"`
|
||||
TargetPDDProductID uint64 `json:"targetPddProductId"`
|
||||
TargetVersion string `json:"targetVersion"`
|
||||
}
|
||||
|
||||
type LatestCollectionSource struct {
|
||||
TaskID uint64 `json:"taskId"`
|
||||
DeviceID uint64 `json:"deviceId"`
|
||||
PDDProductID uint64 `json:"pddProductId"`
|
||||
RequiresSpecConfirmation bool `json:"requiresSpecConfirmation"`
|
||||
TargetVersion string `json:"targetVersion,omitempty"`
|
||||
Title string `json:"title,omitempty"`
|
||||
GoodsID string `json:"goodsId,omitempty"`
|
||||
PreviousTitle string `json:"previousTitle,omitempty"`
|
||||
PreviousGoodsID string `json:"previousGoodsId,omitempty"`
|
||||
DeviceName string `json:"deviceName,omitempty"`
|
||||
}
|
||||
|
||||
func latestUnavailable(message string) error {
|
||||
return &ServiceError{Code: CodeLatestCollectionUnavailable, Message: message}
|
||||
}
|
||||
|
||||
// Resolve the candidate exactly once under short row locks; never hold a
|
||||
// transaction while calling AI. The returned context belongs to this write,
|
||||
// not a later GET that could silently adopt another operator's association.
|
||||
func (service *Service) linkLatestCollection(ctx context.Context, id uint64, request LinkPDDRequest) (SaveResponse, error) {
|
||||
r := request.LatestCollection
|
||||
if request.PDDProductID != 0 || r.DeviceID == 0 || r.SYBProductID == 0 || r.SpecContextVersion == "" {
|
||||
return SaveResponse{}, invalidRequest("一键关联需要 SYB、设备及规格版本,不能同时指定 PDD 商品")
|
||||
}
|
||||
var response SaveResponse
|
||||
err := service.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
var syb models.SYBProduct
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&syb, r.SYBProductID).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return latestUnavailable("SYB 明细不存在,请刷新列表")
|
||||
}
|
||||
return err
|
||||
}
|
||||
if syb.ShopeeProductID == nil || *syb.ShopeeProductID != id {
|
||||
return contextVersionStale()
|
||||
}
|
||||
var shopee models.ShopeeProduct
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&shopee, id).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return productNotFound()
|
||||
}
|
||||
return err
|
||||
}
|
||||
replacement := r.Replacement
|
||||
var old models.PDDProduct
|
||||
if replacement != nil {
|
||||
if shopee.PDDProductID == nil || replacement.ExpectedPDDProductID == 0 || *shopee.PDDProductID != replacement.ExpectedPDDProductID {
|
||||
return contextVersionStale()
|
||||
}
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&old, *shopee.PDDProductID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if service.ReplacementEligibility == nil {
|
||||
return invalidRequest("一键替换入口未配置")
|
||||
}
|
||||
if err := service.ReplacementEligibility(ctx, tx, syb.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
if !replacement.Preview && (replacement.TaskID == 0 || replacement.TargetPDDProductID == 0 || replacement.TargetVersion == "") {
|
||||
return invalidRequest("请先预览并确认替换商品")
|
||||
}
|
||||
} else if shopee.PDDProductID != nil {
|
||||
return &ServiceError{Code: CodeLinkConflict, Message: "商品已有关联,请刷新后查看;一键关联不会覆盖"}
|
||||
}
|
||||
if computeSpecContextVersion(shopee.PDDProductID, shopee.SpecsJSON, old.SpecsJSON) != r.SpecContextVersion {
|
||||
return contextVersionStale()
|
||||
}
|
||||
var device models.AgentDevice
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&device, r.DeviceID).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return latestUnavailable("手机已不存在,请重新选择")
|
||||
}
|
||||
return err
|
||||
}
|
||||
if device.Status != models.DeviceStatusOnline && device.Status != models.DeviceStatusOffline {
|
||||
return latestUnavailable("手机已停用,请重新选择")
|
||||
}
|
||||
var task models.CollectionTask
|
||||
query := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("device_id = ? AND source = ? AND status IN ?", r.DeviceID, models.CollectionTaskSourceAgentCurrentPage, []string{models.TaskStatusCompleted, models.TaskStatusCompletedPartial})
|
||||
if replacement != nil && !replacement.Preview {
|
||||
query = query.Where("id = ?", replacement.TaskID)
|
||||
}
|
||||
err := query.Order("finished_at DESC, id DESC").First(&task).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return latestUnavailable("这台手机暂无成功的临时采集,请先采集商品")
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if task.FinishedAt == nil || task.PDDProductID == nil {
|
||||
return latestUnavailable("最近一次临时采集不完整,请先补采;不会回退关联更早的商品")
|
||||
}
|
||||
var pdd models.PDDProduct
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&pdd, *task.PDDProductID).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return latestUnavailable("最近采集的 PDD 商品已不存在")
|
||||
}
|
||||
return err
|
||||
}
|
||||
if pdd.Status != "active" {
|
||||
return latestUnavailable("最近采集的 PDD 商品尚不可用,请先检查采集结果")
|
||||
}
|
||||
color, err := selectablePDDValues(pdd.SpecsJSON, RoleColor)
|
||||
if err != nil {
|
||||
return latestUnavailable("最近采集的 PDD 规格无效,请先补采")
|
||||
}
|
||||
size, err := selectablePDDValues(pdd.SpecsJSON, RoleSize)
|
||||
if err != nil || len(color)+len(size) == 0 {
|
||||
return latestUnavailable("最近采集的 PDD 缺少可用规格,请先补采")
|
||||
}
|
||||
if task.Status == models.TaskStatusCompletedPartial {
|
||||
if err := validatePartialLinkResult(tx, task, color, size); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
targetVersion := computeSpecContextVersion(&pdd.ID, "", pdd.SpecsJSON)
|
||||
if replacement != nil && !replacement.Preview && (replacement.TargetPDDProductID != pdd.ID || replacement.TargetVersion != targetVersion) {
|
||||
return contextVersionStale()
|
||||
}
|
||||
// Preview never writes. Same-product continuation also preserves the
|
||||
// existing association and request marker; matching is a separate call.
|
||||
if replacement == nil || (!replacement.Preview && old.ID != pdd.ID) {
|
||||
query := tx.Model(&models.ShopeeProduct{}).Where("id = ?", id)
|
||||
if replacement == nil {
|
||||
query = query.Where("pdd_product_id IS NULL")
|
||||
} else {
|
||||
query = query.Where("pdd_product_id = ?", old.ID)
|
||||
}
|
||||
result := query.Updates(map[string]any{"pdd_product_id": pdd.ID, "last_update_request_id": request.RequestID})
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
if result.RowsAffected != 1 {
|
||||
return contextVersionStale()
|
||||
}
|
||||
}
|
||||
response, err = NewService(tx).Detail(ctx, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
response.CollectionSource = &LatestCollectionSource{TaskID: task.ID, DeviceID: device.ID, PDDProductID: pdd.ID, RequiresSpecConfirmation: syb.ParseStatus == models.SYBParseStatusFailed && !syb.ManuallyConfirmed && !syb.AIConfirmed}
|
||||
if replacement != nil {
|
||||
response.CollectionSource.TargetVersion = targetVersion
|
||||
response.CollectionSource.Title = pdd.Title
|
||||
response.CollectionSource.GoodsID = pdd.GoodsID
|
||||
response.CollectionSource.PreviousTitle = old.Title
|
||||
response.CollectionSource.PreviousGoodsID = old.GoodsID
|
||||
response.CollectionSource.DeviceName = device.Name
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
var domain *ServiceError
|
||||
if errors.As(err, &domain) {
|
||||
return SaveResponse{}, err
|
||||
}
|
||||
return SaveResponse{}, internalError(err)
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
package shopeeproduct
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
"go-admin/app/goauto/models"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func latestFixture(t *testing.T) (*Service, uint64, LinkPDDRequest, models.CollectionTask) {
|
||||
t.Helper()
|
||||
db := openTestDB(t)
|
||||
device := models.AgentDevice{InstallID: uuid.NewString(), TokenDigest: uuid.NewString(), Status: models.DeviceStatusOffline}
|
||||
if err := db.Create(&device).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rule := models.CollectionRule{Name: "synthetic", ContentJSON: "{}"}
|
||||
if err := db.Create(&rule).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
pdd := seedPDDProduct(t, db, "active")
|
||||
s := NewService(db)
|
||||
created, err := s.Create(context.Background(), CreateRequest{RequestID: uuid.NewString(), ShopeeItemID: uuid.NewString(), Specs: []SpecDimension{{Name: "颜色", Role: RoleColor, Values: []SpecValue{{Name: "黑色", Source: ValueSourceManual}}}}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
syb := models.SYBProduct{OrderCode: "synthetic", DetailID: 1, Quantity: 1, ParseStatus: "success", ShopeeProductID: &created.Product.ID}
|
||||
if err := db.Create(&syb).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
task := models.CollectionTask{DeviceID: &device.ID, RuleID: rule.ID, PDDProductID: &pdd.ID, Source: models.CollectionTaskSourceAgentCurrentPage, Status: models.TaskStatusCompleted, RuleSnapshot: "{}", FinishedAt: &now}
|
||||
if err := db.Create(&task).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return s, created.Product.ID, LinkPDDRequest{RequestID: uuid.NewString(), LatestCollection: &LatestCollectionRequest{DeviceID: device.ID, SYBProductID: syb.ID, SpecContextVersion: created.Product.SpecContextVersion}}, task
|
||||
}
|
||||
|
||||
func TestLatestCollectionOfflineAndOnlyIfUnlinked(t *testing.T) {
|
||||
s, id, req, task := latestFixture(t)
|
||||
result, err := s.LinkPDD(context.Background(), id, req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.CollectionSource.TaskID != task.ID || *result.Product.PDDProductID != *task.PDDProductID || result.Product.SpecContextVersion == req.LatestCollection.SpecContextVersion {
|
||||
t.Fatalf("invalid frozen response: %+v", result)
|
||||
}
|
||||
// Another request (or an ambiguous network retry) cannot overwrite the link.
|
||||
req.RequestID = uuid.NewString()
|
||||
if _, err := s.LinkPDD(context.Background(), id, req); err == nil || errCode(t, err) != CodeLinkConflict {
|
||||
t.Fatalf("expected conflict: %v", err)
|
||||
}
|
||||
other := seedPDDProduct(t, s.DB, "active")
|
||||
if _, err := s.LinkPDD(context.Background(), id, LinkPDDRequest{RequestID: uuid.NewString(), PDDProductID: other.ID}); err != nil {
|
||||
t.Fatalf("manual replacement regressed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLatestCollectionRejectsInvalidContextWithoutWrite(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
change func(*gorm.DB, *LinkPDDRequest, models.CollectionTask)
|
||||
}{
|
||||
{"partial", func(db *gorm.DB, _ *LinkPDDRequest, task models.CollectionTask) {
|
||||
db.Model(&task).Update("status", "completed_partial")
|
||||
}},
|
||||
{"disabled-device", func(db *gorm.DB, r *LinkPDDRequest, _ models.CollectionTask) {
|
||||
db.Model(&models.AgentDevice{}).Where("id = ?", r.LatestCollection.DeviceID).Update("status", "disabled")
|
||||
}},
|
||||
{"missing-device", func(_ *gorm.DB, r *LinkPDDRequest, _ models.CollectionTask) { r.LatestCollection.DeviceID += 100 }},
|
||||
{"admin-source", func(db *gorm.DB, _ *LinkPDDRequest, task models.CollectionTask) {
|
||||
db.Model(&task).Update("source", "admin")
|
||||
}},
|
||||
{"disabled-pdd", func(db *gorm.DB, _ *LinkPDDRequest, task models.CollectionTask) {
|
||||
db.Model(&models.PDDProduct{}).Where("id = ?", *task.PDDProductID).Update("status", "disabled")
|
||||
}},
|
||||
{"empty-specs", func(db *gorm.DB, _ *LinkPDDRequest, task models.CollectionTask) {
|
||||
db.Model(&models.PDDProduct{}).Where("id = ?", *task.PDDProductID).Update("specs_json", "[]")
|
||||
}},
|
||||
{"stale-version", func(_ *gorm.DB, r *LinkPDDRequest, _ models.CollectionTask) {
|
||||
r.LatestCollection.SpecContextVersion = "stale"
|
||||
}},
|
||||
{"syb-link-changed", func(db *gorm.DB, r *LinkPDDRequest, _ models.CollectionTask) {
|
||||
db.Model(&models.SYBProduct{}).Where("id = ?", r.LatestCollection.SYBProductID).Update("shopee_product_id", nil)
|
||||
}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
s, id, r, task := latestFixture(t)
|
||||
tc.change(s.DB, &r, task)
|
||||
if _, err := s.LinkPDD(context.Background(), id, r); err == nil {
|
||||
t.Fatal("unexpected successful write")
|
||||
}
|
||||
var product models.ShopeeProduct
|
||||
s.DB.First(&product, id)
|
||||
if product.PDDProductID != nil {
|
||||
t.Fatal("rejected request wrote association")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLatestCollectionOrdersByFinishThenIDAndDoesNotSkipPartial(t *testing.T) {
|
||||
s, id, r, first := latestFixture(t)
|
||||
newer := first
|
||||
newer.ID = 0
|
||||
newer.Status = models.TaskStatusCompletedPartial
|
||||
if err := s.DB.Create(&newer).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := s.LinkPDD(context.Background(), id, r); err == nil {
|
||||
t.Fatal("skipped latest partial")
|
||||
}
|
||||
// A newer ID with an older completion time must not win.
|
||||
older := newer
|
||||
older.ID = 0
|
||||
older.Status = models.TaskStatusCompleted
|
||||
ts := first.FinishedAt.Add(-time.Hour)
|
||||
older.FinishedAt = &ts
|
||||
if err := s.DB.Create(&older).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := s.LinkPDD(context.Background(), id, r); err == nil {
|
||||
t.Fatal("sorted by id instead of completion")
|
||||
}
|
||||
if err := s.DB.Model(&newer).Update("status", models.TaskStatusCompleted).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
result, err := s.LinkPDD(context.Background(), id, r)
|
||||
if err != nil || result.CollectionSource.TaskID != newer.ID {
|
||||
t.Fatalf("wrong latest result: %+v %v", result, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLatestCollectionKeepsParseConfirmationBoundary(t *testing.T) {
|
||||
for _, status := range []string{"success", "uncertain", "failed"} {
|
||||
t.Run(status, func(t *testing.T) {
|
||||
s, id, r, _ := latestFixture(t)
|
||||
s.DB.Model(&models.SYBProduct{}).Where("id = ?", r.LatestCollection.SYBProductID).Update("parse_status", status)
|
||||
result, err := s.LinkPDD(context.Background(), id, r)
|
||||
if err != nil || result.CollectionSource.RequiresSpecConfirmation != (status == "failed") {
|
||||
t.Fatalf("parse boundary: %+v %v", result, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientKeyCannotUseAdminCompositeLink(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
r.POST("/api/client/v1/shopee-products/:productId/link-pdd", Handler{}.LinkPDD)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, httptest.NewRequest(http.MethodPost, "/api/client/v1/shopee-products/1/link-pdd", strings.NewReader(`{"latestCollection":{"deviceId":1,"sybProductId":1,"specContextVersion":"x"}}`)))
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Fatalf("unexpected status: %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// SQLite has no row-level FOR UPDATE. A single test connection serializes
|
||||
// transactions to exercise concurrent callers; production MySQL locking must
|
||||
// additionally be verified in its deployment environment.
|
||||
func TestLatestCollectionConcurrentCallersOnlyOneWins(t *testing.T) {
|
||||
s, id, r, _ := latestFixture(t)
|
||||
sqlDB, err := s.DB.DB()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sqlDB.SetMaxOpenConns(1)
|
||||
errors := make(chan error, 2)
|
||||
start := make(chan struct{})
|
||||
for range 2 {
|
||||
go func() { <-start; _, err := s.LinkPDD(context.Background(), id, r); errors <- err }()
|
||||
}
|
||||
close(start)
|
||||
wins := 0
|
||||
for range 2 {
|
||||
err := <-errors
|
||||
if err == nil {
|
||||
wins++
|
||||
} else if errCode(t, err) != CodeLinkConflict {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if wins != 1 {
|
||||
t.Fatalf("expected one write, got %d", wins)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package shopeeproduct
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
|
||||
"go-admin/app/goauto/models"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// Only auxiliary metadata may be missing. Validate the source task, not the
|
||||
// merged product archive, which can retain data from an older collection.
|
||||
func validatePartialLinkResult(tx *gorm.DB, task models.CollectionTask, colors, sizes map[string]bool) error {
|
||||
reject := func() error {
|
||||
return latestUnavailable("最近一次采集的规格或价格仍不完整,请先补采;不会回退关联更早的商品")
|
||||
}
|
||||
var missing []string
|
||||
if task.MissingJSON == nil || json.Unmarshal([]byte(*task.MissingJSON), &missing) != nil || len(missing) == 0 {
|
||||
return reject()
|
||||
}
|
||||
for _, field := range missing {
|
||||
switch field {
|
||||
case "salesText", "shopName", "reviewCount":
|
||||
default:
|
||||
return reject()
|
||||
}
|
||||
}
|
||||
var dimensions []models.CollectionDimension
|
||||
if err := tx.Where("task_id = ?", task.ID).Find(&dimensions).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
values := map[string]map[string]bool{}
|
||||
for _, dimension := range dimensions {
|
||||
if (dimension.Key != RoleColor && dimension.Key != RoleSize) || values[dimension.Key] != nil {
|
||||
return reject()
|
||||
}
|
||||
var rows []models.CollectionDimensionValue
|
||||
if err := tx.Where("dimension_id = ?", dimension.ID).Find(&rows).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if len(rows) == 0 {
|
||||
return reject()
|
||||
}
|
||||
values[dimension.Key] = map[string]bool{}
|
||||
for _, row := range rows {
|
||||
if strings.TrimSpace(row.Value) == "" {
|
||||
return reject()
|
||||
}
|
||||
values[dimension.Key][row.Value] = true
|
||||
}
|
||||
}
|
||||
if len(values) == 0 {
|
||||
return reject()
|
||||
}
|
||||
var skus []models.CollectionSKU
|
||||
if err := tx.Where("task_id = ? AND available = ?", task.ID, true).Find(&skus).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if len(skus) == 0 {
|
||||
return reject()
|
||||
}
|
||||
covered := map[string]map[string]bool{RoleColor: {}, RoleSize: {}}
|
||||
for _, sku := range skus {
|
||||
var specs map[string]string
|
||||
if !sku.Complete || sku.PriceCent < 0 || json.Unmarshal([]byte(sku.SpecKey), &specs) != nil || len(specs) != len(values) {
|
||||
return reject()
|
||||
}
|
||||
for role, value := range specs {
|
||||
if !values[role][value] {
|
||||
return reject()
|
||||
}
|
||||
covered[role][value] = true
|
||||
}
|
||||
}
|
||||
var prices []models.CollectionColorPrice
|
||||
if err := tx.Where("task_id = ?", task.ID).Find(&prices).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
priced := map[string]bool{}
|
||||
for _, price := range prices {
|
||||
if price.PriceCent >= 0 {
|
||||
priced[price.Color] = true
|
||||
}
|
||||
}
|
||||
for role, candidates := range map[string]map[string]bool{RoleColor: colors, RoleSize: sizes} {
|
||||
for value := range candidates {
|
||||
if !covered[role][value] || (role == RoleColor && !priced[value]) {
|
||||
return reject()
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
package shopeeproduct
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"go-admin/app/goauto/models"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func seedPartialResult(t *testing.T, db *gorm.DB, task models.CollectionTask, missing string, single bool) {
|
||||
t.Helper()
|
||||
if err := db.Model(&task).Updates(map[string]any{"status": models.TaskStatusCompletedPartial, "missing_json": missing}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
dimensions := map[string][]string{"color": {"黑色"}, "size": {"M", "L", "XL", "2XL"}}
|
||||
if single {
|
||||
delete(dimensions, "size")
|
||||
if err := db.Model(&models.PDDProduct{}).Where("id = ?", *task.PDDProductID).Update("specs_json", `[{"role":"color","name":"颜色","values":[{"name":"黑色","selectable":true,"priceCent":1000}]}]`).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
for role, values := range dimensions {
|
||||
d := models.CollectionDimension{TaskID: task.ID, Key: role, Name: role}
|
||||
if err := db.Create(&d).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, value := range values {
|
||||
if err := db.Create(&models.CollectionDimensionValue{DimensionID: d.ID, Value: value}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := db.Create(&models.CollectionColorPrice{TaskID: task.ID, Color: "黑色", PriceCent: 1000}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sizes := dimensions["size"]
|
||||
if single {
|
||||
sizes = []string{""}
|
||||
}
|
||||
for _, size := range sizes {
|
||||
specs := map[string]string{"color": "黑色"}
|
||||
if size != "" {
|
||||
specs["size"] = size
|
||||
}
|
||||
raw, _ := json.Marshal(specs)
|
||||
if err := db.Create(&models.CollectionSKU{TaskID: task.ID, SpecKey: string(raw), PriceCent: 1000, Available: true, Complete: true}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPartialAuxiliaryMetadataAllowsLinkAndKeepsStatus(t *testing.T) {
|
||||
for _, missing := range []string{`["salesText"]`, `["shopName","reviewCount","salesText"]`} {
|
||||
t.Run(missing, func(t *testing.T) {
|
||||
s, id, req, task := latestFixture(t)
|
||||
seedPartialResult(t, s.DB, task, missing, false)
|
||||
result, err := s.LinkPDD(context.Background(), id, req)
|
||||
if err != nil || result.CollectionSource.TaskID != task.ID {
|
||||
t.Fatalf("link: %+v %v", result, err)
|
||||
}
|
||||
s.DB.First(&task, task.ID)
|
||||
if task.Status != models.TaskStatusCompletedPartial || *task.MissingJSON != missing {
|
||||
t.Fatal("changed historical completeness")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPartialMetadataAllowsSingleDimensionAndReplacement(t *testing.T) {
|
||||
s, id, req, task, old := replacementFixture(t)
|
||||
seedPartialResult(t, s.DB, task, `["salesText"]`, true)
|
||||
preview := previewReplacement(t, s, id, &req)
|
||||
if *preview.Product.PDDProductID != old.ID {
|
||||
t.Fatal("preview wrote")
|
||||
}
|
||||
result, err := s.LinkPDD(context.Background(), id, req)
|
||||
if err != nil || *result.Product.PDDProductID != *task.PDDProductID {
|
||||
t.Fatalf("replacement: %+v %v", result, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPartialMissingOrResultDefectsStillReject(t *testing.T) {
|
||||
for _, scenario := range []string{"[]", "null", "bad-json", `["salesText","size"]`, `["title"]`, `["price:黑色"]`, `["skuLimit:12>8"]`, `["unknown"]`, "sku-incomplete", "sku-unavailable", "sku-invalid", "no-current-price", "old-archive-value"} {
|
||||
t.Run(scenario, func(t *testing.T) {
|
||||
s, id, req, task := latestFixture(t)
|
||||
missing := scenario
|
||||
if scenario[0] != '[' && scenario != "null" && scenario != "bad-json" {
|
||||
missing = `["salesText"]`
|
||||
}
|
||||
seedPartialResult(t, s.DB, task, missing, false)
|
||||
switch scenario {
|
||||
case "sku-incomplete":
|
||||
s.DB.Model(&models.CollectionSKU{}).Where("task_id = ?", task.ID).Update("complete", false)
|
||||
case "sku-unavailable":
|
||||
s.DB.Model(&models.CollectionSKU{}).Where("task_id = ?", task.ID).Update("available", false)
|
||||
case "sku-invalid":
|
||||
var sku models.CollectionSKU
|
||||
if err := s.DB.Where("task_id = ?", task.ID).First(&sku).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.DB.Model(&sku).Update("spec_key", `{}`).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
case "no-current-price":
|
||||
s.DB.Model(&models.CollectionColorPrice{}).Where("task_id = ?", task.ID).Update("color", "不是本次颜色")
|
||||
case "old-archive-value":
|
||||
s.DB.Model(&models.PDDProduct{}).Where("id = ?", *task.PDDProductID).Update("specs_json", `[{"role":"color","values":[{"name":"旧颜色","selectable":true,"priceCent":1000}]}]`)
|
||||
}
|
||||
if _, err := s.LinkPDD(context.Background(), id, req); err == nil {
|
||||
t.Fatal("accepted incomplete current result")
|
||||
}
|
||||
var product models.ShopeeProduct
|
||||
s.DB.First(&product, id)
|
||||
if product.PDDProductID != nil {
|
||||
t.Fatal("rejected request wrote association")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
package shopeeproduct
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"go-admin/app/goauto/models"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func replacementFixture(t *testing.T) (*Service, uint64, LinkPDDRequest, models.CollectionTask, models.PDDProduct) {
|
||||
s, id, req, task := latestFixture(t)
|
||||
old := seedPDDProduct(t, s.DB, "pending")
|
||||
if err := s.DB.Model(&models.ShopeeProduct{}).Where("id = ?", id).Update("pdd_product_id", old.ID).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
detail, err := s.Detail(context.Background(), id)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req.LatestCollection.SpecContextVersion = detail.Product.SpecContextVersion
|
||||
req.LatestCollection.Replacement = &LatestCollectionReplacement{Preview: true, ExpectedPDDProductID: old.ID}
|
||||
s.ReplacementEligibility = func(context.Context, *gorm.DB, uint64) error { return nil }
|
||||
return s, id, req, task, old
|
||||
}
|
||||
|
||||
func previewReplacement(t *testing.T, s *Service, id uint64, req *LinkPDDRequest) SaveResponse {
|
||||
t.Helper()
|
||||
preview, err := s.LinkPDD(context.Background(), id, *req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r := req.LatestCollection.Replacement
|
||||
r.Preview = false
|
||||
r.TaskID = preview.CollectionSource.TaskID
|
||||
r.TargetPDDProductID = preview.CollectionSource.PDDProductID
|
||||
r.TargetVersion = preview.CollectionSource.TargetVersion
|
||||
return preview
|
||||
}
|
||||
|
||||
func TestReplacementPreviewIsReadOnlyAndFreezesCandidate(t *testing.T) {
|
||||
s, id, req, task, old := replacementFixture(t)
|
||||
preview := previewReplacement(t, s, id, &req)
|
||||
if *preview.Product.PDDProductID != old.ID {
|
||||
t.Fatal("preview changed association")
|
||||
}
|
||||
var record models.ShopeeProduct
|
||||
s.DB.First(&record, id)
|
||||
if *record.PDDProductID != old.ID || record.LastUpdateRequestID != nil {
|
||||
t.Fatal("preview wrote")
|
||||
}
|
||||
newer := task
|
||||
newer.ID = 0
|
||||
newer.Status = models.TaskStatusCompletedPartial
|
||||
if err := s.DB.Create(&newer).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
result, err := s.LinkPDD(context.Background(), id, req)
|
||||
if err != nil || *result.Product.PDDProductID != *task.PDDProductID || result.CollectionSource.TaskID != task.ID {
|
||||
t.Fatalf("frozen candidate: %+v %v", result, err)
|
||||
}
|
||||
if _, err := s.LinkPDD(context.Background(), id, req); err == nil {
|
||||
t.Fatal("duplicate replaced stale association")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReplacementSameProductDoesNotRebind(t *testing.T) {
|
||||
s, id, req, task, _ := replacementFixture(t)
|
||||
s.DB.Model(&models.ShopeeProduct{}).Where("id = ?", id).Update("pdd_product_id", *task.PDDProductID)
|
||||
detail, _ := s.Detail(context.Background(), id)
|
||||
req.LatestCollection.SpecContextVersion = detail.Product.SpecContextVersion
|
||||
req.LatestCollection.Replacement.ExpectedPDDProductID = *task.PDDProductID
|
||||
previewReplacement(t, s, id, &req)
|
||||
if _, err := s.LinkPDD(context.Background(), id, req); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var record models.ShopeeProduct
|
||||
s.DB.First(&record, id)
|
||||
if record.LastUpdateRequestID != nil {
|
||||
t.Fatal("same product wrote association")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReplacementRejectsChangesAfterPreview(t *testing.T) {
|
||||
for _, change := range []string{"association", "specs", "target-specs", "task-status", "device", "stage", "missing-validator", "target-id", "task-id"} {
|
||||
t.Run(change, func(t *testing.T) {
|
||||
s, id, req, task, old := replacementFixture(t)
|
||||
previewReplacement(t, s, id, &req)
|
||||
expected := old.ID
|
||||
switch change {
|
||||
case "association":
|
||||
other := seedPDDProduct(t, s.DB, "active")
|
||||
expected = other.ID
|
||||
s.DB.Model(&models.ShopeeProduct{}).Where("id = ?", id).Update("pdd_product_id", expected)
|
||||
case "specs":
|
||||
s.DB.Model(&models.ShopeeProduct{}).Where("id = ?", id).Update("specs_json", "[]")
|
||||
case "target-specs":
|
||||
s.DB.Model(&models.PDDProduct{}).Where("id = ?", *task.PDDProductID).Update("specs_json", `[{"role":"color","name":"颜色","values":[{"name":"白色","selectable":true}]}]`)
|
||||
case "task-status":
|
||||
s.DB.Model(&task).Update("status", "completed_partial")
|
||||
case "device":
|
||||
s.DB.Model(&models.AgentDevice{}).Where("id = ?", req.LatestCollection.DeviceID).Update("status", "disabled")
|
||||
case "stage":
|
||||
s.ReplacementEligibility = func(context.Context, *gorm.DB, uint64) error { return &ServiceError{Code: CodeLinkConflict} }
|
||||
case "missing-validator":
|
||||
s.ReplacementEligibility = nil
|
||||
case "target-id":
|
||||
req.LatestCollection.Replacement.TargetPDDProductID++
|
||||
case "task-id":
|
||||
req.LatestCollection.Replacement.TaskID++
|
||||
}
|
||||
if _, err := s.LinkPDD(context.Background(), id, req); err == nil {
|
||||
t.Fatal("accepted changed context")
|
||||
}
|
||||
var record models.ShopeeProduct
|
||||
s.DB.First(&record, id)
|
||||
if *record.PDDProductID != expected {
|
||||
t.Fatal("overwrote rejected association")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestReplacementConcurrentConfirmationsOnlyOneWrites(t *testing.T) {
|
||||
s, id, req, _, _ := replacementFixture(t)
|
||||
previewReplacement(t, s, id, &req)
|
||||
connection, err := s.DB.DB()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
connection.SetMaxOpenConns(1) // SQLite serialization, not a MySQL lock claim.
|
||||
results := make(chan error, 2)
|
||||
for range 2 {
|
||||
go func() { _, err := s.LinkPDD(context.Background(), id, req); results <- err }()
|
||||
}
|
||||
wins := 0
|
||||
for range 2 {
|
||||
if <-results == nil {
|
||||
wins++
|
||||
}
|
||||
}
|
||||
if wins != 1 {
|
||||
t.Fatalf("writes=%d", wins)
|
||||
}
|
||||
}
|
||||
@@ -7,8 +7,8 @@ import (
|
||||
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
|
||||
)
|
||||
|
||||
func InitRouter(engine *gin.Engine, auth *jwt.GinJWTMiddleware) {
|
||||
handler := Handler{}
|
||||
func InitRouter(engine *gin.Engine, auth *jwt.GinJWTMiddleware, eligibility ReplacementEligibility) {
|
||||
handler := Handler{ReplacementEligibility: eligibility}
|
||||
admin := engine.Group("/api/admin/v1/shopee-products").Use(auth.MiddlewareFunc()).Use(middleware.AuthCheckRole())
|
||||
adminOnlyRuns := engine.Group("/api/admin/v1/shopee-spec-auto-match/runs").Use(auth.MiddlewareFunc()).Use(middleware.AuthCheckRole()).Use(middleware.RequireRoleKey("admin"))
|
||||
adminOnlyRuns.POST("", handler.StartAutoMatchRun)
|
||||
|
||||
@@ -57,7 +57,12 @@ func (err *ServiceError) Error() string {
|
||||
}
|
||||
func (err *ServiceError) Unwrap() error { return err.Cause }
|
||||
|
||||
type Service struct{ DB *gorm.DB }
|
||||
type ReplacementEligibility func(context.Context, *gorm.DB, uint64) error
|
||||
|
||||
type Service struct {
|
||||
DB *gorm.DB
|
||||
ReplacementEligibility ReplacementEligibility
|
||||
}
|
||||
|
||||
func NewService(db *gorm.DB) *Service { return &Service{DB: db} }
|
||||
|
||||
@@ -76,8 +81,10 @@ type ProductView struct {
|
||||
}
|
||||
|
||||
type SaveResponse struct {
|
||||
Product ProductView `json:"product"`
|
||||
Replayed bool `json:"replayed,omitempty"`
|
||||
Product ProductView `json:"product"`
|
||||
Replayed bool `json:"replayed,omitempty"`
|
||||
AutoMatchTimeoutSeconds int `json:"autoMatchTimeoutSeconds,omitempty"`
|
||||
CollectionSource *LatestCollectionSource `json:"collectionSource,omitempty"`
|
||||
}
|
||||
|
||||
type ListItemView struct {
|
||||
@@ -156,6 +163,16 @@ func (service *Service) Create(ctx context.Context, request CreateRequest) (Save
|
||||
return SaveResponse{Product: view}, err
|
||||
}
|
||||
|
||||
// DetailWithAutoMatchBudget exposes only a derived time budget, never AI settings.
|
||||
func (service *Service) DetailWithAutoMatchBudget(ctx context.Context, id uint64) (SaveResponse, error) {
|
||||
response, err := service.Detail(ctx, id)
|
||||
if err != nil {
|
||||
return SaveResponse{}, err
|
||||
}
|
||||
response.AutoMatchTimeoutSeconds, err = service.autoMatchTimeout(ctx)
|
||||
return response, err
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- update (archive fields only)
|
||||
|
||||
type UpdateRequest struct {
|
||||
@@ -214,7 +231,8 @@ type ListRequest struct {
|
||||
// Status filters by lifecycle: "" (default) live only, "deleted" soft-deleted
|
||||
// only. There is no "all" value: mixing live and deleted rows in one page
|
||||
// would defeat the point of the filter (#40 prototype: 全部(不含已删除)).
|
||||
Status string
|
||||
Status string
|
||||
ImageSearchLinked *bool
|
||||
}
|
||||
|
||||
func (service *Service) List(ctx context.Context, request ListRequest) (ListResponse, error) {
|
||||
@@ -240,6 +258,9 @@ func (service *Service) List(ctx context.Context, request ListRequest) (ListResp
|
||||
like := "%" + request.Keyword + "%"
|
||||
query = query.Where("shopee_item_id LIKE ? OR title LIKE ? OR shop_name LIKE ?", like, like, like)
|
||||
}
|
||||
if request.ImageSearchLinked != nil {
|
||||
query = query.Where("image_search_linked = ?", *request.ImageSearchLinked)
|
||||
}
|
||||
var total int64
|
||||
if err := query.Count(&total).Error; err != nil {
|
||||
return ListResponse{}, internalError(err)
|
||||
@@ -262,8 +283,9 @@ func (service *Service) List(ctx context.Context, request ListRequest) (ListResp
|
||||
// ---------------------------------------------------------------- link PDD
|
||||
|
||||
type LinkPDDRequest struct {
|
||||
RequestID string `json:"requestId"`
|
||||
PDDProductID uint64 `json:"pddProductId"`
|
||||
RequestID string `json:"requestId"`
|
||||
PDDProductID uint64 `json:"pddProductId"`
|
||||
LatestCollection *LatestCollectionRequest `json:"latestCollection,omitempty"`
|
||||
}
|
||||
|
||||
// LinkPDD sets or changes the linked PDD product via the search-and-select
|
||||
@@ -274,6 +296,9 @@ func (service *Service) LinkPDD(ctx context.Context, id uint64, request LinkPDDR
|
||||
if _, err := uuid.Parse(strings.TrimSpace(request.RequestID)); err != nil {
|
||||
return SaveResponse{}, invalidRequest("requestId 必须是 UUID")
|
||||
}
|
||||
if request.LatestCollection != nil {
|
||||
return service.linkLatestCollection(ctx, id, request)
|
||||
}
|
||||
if request.PDDProductID == 0 {
|
||||
return SaveResponse{}, invalidRequest("pddProductId 必填")
|
||||
}
|
||||
@@ -282,7 +307,7 @@ func (service *Service) LinkPDD(ctx context.Context, id uint64, request LinkPDDR
|
||||
return SaveResponse{}, err
|
||||
}
|
||||
result := db.Model(&models.ShopeeProduct{}).Where("id = ?", id).Updates(map[string]any{
|
||||
"pdd_product_id": request.PDDProductID, "last_update_request_id": request.RequestID,
|
||||
"pdd_product_id": request.PDDProductID, "image_search_linked": false, "last_update_request_id": request.RequestID,
|
||||
})
|
||||
if result.Error != nil {
|
||||
return SaveResponse{}, internalError(result.Error)
|
||||
|
||||
@@ -84,6 +84,9 @@ func ApplyDetail(ctx context.Context, db *gorm.DB, order OrderInput, detail Deta
|
||||
}
|
||||
|
||||
parsed := Parse(detail.ProductSpec)
|
||||
if parsed.Status == models.SYBParseStatusUncertain {
|
||||
parsed.Status = models.SYBParseStatusFailed
|
||||
}
|
||||
rawJSON, err := normalizeRaw(detail.Raw)
|
||||
if err != nil {
|
||||
return ApplyResult{}, err
|
||||
|
||||
@@ -171,6 +171,9 @@ func runImport(db *gorm.DB, runID uint64, request ImportRequest, settings config
|
||||
status := SyncRunSucceeded
|
||||
if err != nil {
|
||||
status = SyncRunFailed
|
||||
if report.Created+report.Updated > 0 {
|
||||
status = SyncRunPartial
|
||||
}
|
||||
}
|
||||
finishCtx, finishCancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer finishCancel()
|
||||
|
||||
@@ -4,44 +4,9 @@ import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func freezePaginationToday(t *testing.T) {
|
||||
t.Helper()
|
||||
previous := syncNow
|
||||
syncNow = func() time.Time { return time.Date(2026, 8, 29, 12, 0, 0, 0, time.FixedZone("Asia/Shanghai", 8*60*60)) }
|
||||
t.Cleanup(func() { syncNow = previous })
|
||||
}
|
||||
|
||||
func TestTodayCrossPageOverlapRestartsWithIndependentIDs(t *testing.T) {
|
||||
freezePaginationToday(t)
|
||||
f := &fakeSYB{perDay: map[string]int{"2026-08-29": 4}}
|
||||
f.pageIDs = func(_ string, start, call int) []int64 {
|
||||
if call == 2 {
|
||||
return []int64{1001, 1002}
|
||||
}
|
||||
if call > 2 {
|
||||
return []int64{int64(2000 + start), int64(2001 + start)}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
rows, err := loadDailyListWithRecovery(context.Background(), newSyncClient(t, f), "2026-08-29", 2, 4, 100)
|
||||
if err != nil || len(rows) != 4 || f.listTotalCalls != 2 {
|
||||
t.Fatalf("rows=%d totals=%d err=%v", len(rows), f.listTotalCalls, err)
|
||||
}
|
||||
if got := strings.Join(f.listCalls, ","); got != "2026-08-29:0,2026-08-29:2,2026-08-29:0,2026-08-29:2" {
|
||||
t.Fatalf("did not restart at first page: %s", got)
|
||||
}
|
||||
for index, row := range rows {
|
||||
if row.ID != int64(2000+index) {
|
||||
t.Fatal("rows leaked from abandoned attempt")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTodayPersistentOverlapPreservesYesterdayButDoesNotImportToday(t *testing.T) {
|
||||
freezePaginationToday(t)
|
||||
func TestTodayOverlapPreservesCommittedPagesWithoutImportingOverlappingPage(t *testing.T) {
|
||||
f := &fakeSYB{perDay: map[string]int{"2026-08-28": 2, "2026-08-29": 4}}
|
||||
f.pageIDs = func(date string, start, _ int) []int64 {
|
||||
if date == "2026-08-29" && start == 2 {
|
||||
@@ -54,7 +19,7 @@ func TestTodayPersistentOverlapPreservesYesterdayButDoesNotImportToday(t *testin
|
||||
if err == nil {
|
||||
t.Fatal("overlap was treated as successful sync")
|
||||
}
|
||||
for _, token := range []string{"连续 3 次", "跨页重复", "firstPage=1", "firstRow=2", "page=2", "row=1", "start=2", "pageSize=2", "expectedTotal=4", "unique=2"} {
|
||||
for _, token := range []string{"跨页重复", "firstPage=1", "firstRow=2", "page=2", "row=1", "start=2", "pageSize=2", "expectedTotal=4", "unique=2"} {
|
||||
if !strings.Contains(err.Error(), token) {
|
||||
t.Fatalf("missing %s in %v", token, err)
|
||||
}
|
||||
@@ -62,7 +27,7 @@ func TestTodayPersistentOverlapPreservesYesterdayButDoesNotImportToday(t *testin
|
||||
if strings.Contains(err.Error(), "1001") || strings.Contains(err.Error(), "已保存") {
|
||||
t.Fatalf("unsafe diagnosis/degraded save: %v", err)
|
||||
}
|
||||
if len(f.listCalls) != 7 || f.detailCalls != 1 || report.OrderCount != 2 {
|
||||
if len(f.listCalls) != 3 || f.detailCalls != 2 || report.OrderCount != 4 || report.Created != 2 || report.Updated != 2 {
|
||||
t.Fatalf("unexpected retry/import boundary: pages=%d details=%d orders=%d", len(f.listCalls), f.detailCalls, report.OrderCount)
|
||||
}
|
||||
var count int64
|
||||
@@ -72,7 +37,6 @@ func TestTodayPersistentOverlapPreservesYesterdayButDoesNotImportToday(t *testin
|
||||
}
|
||||
|
||||
func TestPaginationHardErrorsDoNotUseOverlapRecovery(t *testing.T) {
|
||||
freezePaginationToday(t)
|
||||
for _, tc := range []struct {
|
||||
name, date, message string
|
||||
page int
|
||||
@@ -92,7 +56,7 @@ func TestPaginationHardErrorsDoNotUseOverlapRecovery(t *testing.T) {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
rows, err := loadDailyListWithRecovery(context.Background(), newSyncClient(t, f), tc.date, 2, 4, 100)
|
||||
rows, err := loadDailyList(context.Background(), newSyncClient(t, f), tc.date, 2, 4)
|
||||
if rows != nil || err == nil || !strings.Contains(err.Error(), tc.message) || len(f.listCalls) != tc.calls || f.listTotalCalls != 0 {
|
||||
t.Fatalf("unexpected recovery: rows=%d pages=%d totals=%d err=%v", len(rows), len(f.listCalls), f.listTotalCalls, err)
|
||||
}
|
||||
|
||||
@@ -25,7 +25,6 @@ var explicitSizePattern = regexp.MustCompile(`(?i)^(?:均(?:码|碼|号|號)|one
|
||||
// Observed in the real SYB sample: "黑色+白色【純棉兩件裝】 簡約親膚" strips
|
||||
// its bracket to "黑色+白色 簡約親膚", which still carries a '+' and internal
|
||||
// whitespace, so it must not be reported as a confident match.
|
||||
var ambiguousPattern = regexp.MustCompile(`[++\s]`)
|
||||
|
||||
// ParseResult is the color/size candidate extracted from one productSpec
|
||||
// string, plus how much the caller should trust it.
|
||||
@@ -73,7 +72,10 @@ func Parse(raw string) ParseResult {
|
||||
if size == "" {
|
||||
return ParseResult{Status: models.SYBParseStatusFailed, Note: "productSpec 剥离备注后为空"}
|
||||
}
|
||||
return ParseResult{Size: size, Status: models.SYBParseStatusUncertain, Note: "无分隔符,仅识别出单一规格值"}
|
||||
if explicitSizePattern.MatchString(size) {
|
||||
return ParseResult{Size: size, Status: models.SYBParseStatusSuccess, Note: "仅识别到尺码"}
|
||||
}
|
||||
return ParseResult{Color: size, Status: models.SYBParseStatusSuccess, Note: "仅识别到颜色"}
|
||||
}
|
||||
|
||||
firstPart := stripBrackets(trimmed[:lastComma])
|
||||
@@ -81,7 +83,17 @@ func Parse(raw string) ParseResult {
|
||||
colorPart, sizePart := firstPart, secondPart
|
||||
|
||||
if colorPart == "" || sizePart == "" {
|
||||
return ParseResult{Color: colorPart, Size: sizePart, Status: models.SYBParseStatusUncertain, Note: "按逗号拆分后有一侧为空"}
|
||||
only := colorPart
|
||||
if only == "" {
|
||||
only = sizePart
|
||||
}
|
||||
if explicitSizePattern.MatchString(only) {
|
||||
return ParseResult{Size: only, Status: models.SYBParseStatusSuccess, Note: "仅识别到尺码"}
|
||||
}
|
||||
if only != "" {
|
||||
return ParseResult{Color: only, Status: models.SYBParseStatusSuccess, Note: "仅识别到颜色"}
|
||||
}
|
||||
return ParseResult{Color: colorPart, Size: sizePart, Status: models.SYBParseStatusFailed, Note: "按逗号拆分后没有可靠规格"}
|
||||
}
|
||||
firstIsSize, secondIsSize := explicitSizePattern.MatchString(firstPart), explicitSizePattern.MatchString(secondPart)
|
||||
if firstIsSize && secondIsSize {
|
||||
@@ -90,9 +102,6 @@ func Parse(raw string) ParseResult {
|
||||
if firstIsSize {
|
||||
colorPart, sizePart = secondPart, firstPart
|
||||
}
|
||||
if ambiguousPattern.MatchString(colorPart) {
|
||||
return ParseResult{Color: colorPart, Size: sizePart, Status: models.SYBParseStatusUncertain, Note: "颜色部分含备注文本或多个分隔符,拆分结果可能不准确"}
|
||||
}
|
||||
return ParseResult{Color: colorPart, Size: sizePart, Status: models.SYBParseStatusSuccess}
|
||||
}
|
||||
|
||||
|
||||
@@ -77,6 +77,9 @@ func Reparse(ctx context.Context, db *gorm.DB, sybProductID uint64, force bool)
|
||||
return fmt.Errorf("syb product %d: stored raw json is not decodable: %w", record.ID, err)
|
||||
}
|
||||
parsed := Parse(raw.ProductSpec)
|
||||
if parsed.Status == models.SYBParseStatusUncertain {
|
||||
parsed.Status = models.SYBParseStatusFailed
|
||||
}
|
||||
outcome.NewStatus = parsed.Status
|
||||
|
||||
if parsed.Color == record.TargetColor && parsed.Size == record.TargetSize && parsed.Status == record.ParseStatus {
|
||||
|
||||
@@ -42,6 +42,9 @@ func RepairReversedSpecRoles(ctx context.Context, db *gorm.DB) (SpecRoleRepairRe
|
||||
continue
|
||||
}
|
||||
parsed := Parse(raw.ProductSpec)
|
||||
if parsed.Status == models.SYBParseStatusUncertain {
|
||||
parsed.Status = models.SYBParseStatusFailed
|
||||
}
|
||||
if parsed.Status == models.SYBParseStatusFailed || parsed.Color == record.TargetColor ||
|
||||
parsed.Color != record.TargetSize || parsed.Size != record.TargetColor {
|
||||
continue
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"time"
|
||||
|
||||
"go-admin/app/goauto/sybclient"
|
||||
"go-admin/app/goauto/sybproductfilter"
|
||||
"go-admin/app/goauto/sybshop"
|
||||
|
||||
"gorm.io/gorm"
|
||||
@@ -41,8 +42,7 @@ const (
|
||||
// maxSyncDays bounds one request's window. It is a guard against a typo in
|
||||
// the date range turning into tens of thousands of remote reads before
|
||||
// MaxMatches trips.
|
||||
maxSyncDays = 31
|
||||
maxTodaySnapshotAttempts = 3
|
||||
maxSyncDays = 31
|
||||
)
|
||||
|
||||
// SyncReport summarises one sync run.
|
||||
@@ -53,8 +53,14 @@ type SyncReport struct {
|
||||
// ShopSkipped how many did not. OrderCount stays the raw total: keeping the
|
||||
// unfiltered number visible is what makes a shop missing from the
|
||||
// allow-list obvious rather than silent.
|
||||
AcceptedCount int `json:"acceptedCount"`
|
||||
ShopSkipped int `json:"shopSkipped"`
|
||||
AcceptedCount int `json:"acceptedCount"`
|
||||
ShopSkipped int `json:"shopSkipped"`
|
||||
CharFilterSkipped int `json:"charFilterSkipped"`
|
||||
KeywordFilterSkipped int `json:"keywordFilterSkipped"`
|
||||
// filterHits accumulates hits per filter rule id so each rule's own
|
||||
// LastHitCount can be written back. It is unexported because it is run
|
||||
// bookkeeping, not part of the report shown to people or stored as JSON.
|
||||
filterHits sybproductfilter.HitCounter
|
||||
// ShopBreakdown separates accepted and skipped orders for every shop. Without
|
||||
// it a renamed or newly opened shop is never noticed (#50).
|
||||
ShopBreakdown map[string]ShopBreakdown `json:"shopBreakdown"`
|
||||
@@ -82,8 +88,6 @@ type SyncProgress struct {
|
||||
DaysProcessed int
|
||||
}
|
||||
|
||||
var syncNow = time.Now
|
||||
|
||||
type ProgressFunc func(SyncProgress) error
|
||||
|
||||
type snapshotDriftError struct {
|
||||
@@ -94,14 +98,6 @@ type snapshotDriftError struct {
|
||||
|
||||
func (err *snapshotDriftError) Error() string { return err.message }
|
||||
|
||||
func shanghaiToday() string {
|
||||
location, err := time.LoadLocation("Asia/Shanghai")
|
||||
if err != nil {
|
||||
location = time.FixedZone("Asia/Shanghai", 8*60*60)
|
||||
}
|
||||
return syncNow().In(location).Format("2006-01-02")
|
||||
}
|
||||
|
||||
// Sync pulls every shipment order in [dateFrom, dateTo] and folds each detail
|
||||
// line into the SYB/Shopee archive through ApplyDetail.
|
||||
//
|
||||
@@ -109,16 +105,10 @@ func shanghaiToday() string {
|
||||
// a separate concern (see Connect); a session that dies mid-run surfaces as
|
||||
// sybclient.ErrSessionInvalid and is treated like any other mid-run failure.
|
||||
//
|
||||
// `[必须]` Failure stops the run immediately. Rows already written are NOT
|
||||
// rolled back — ApplyDetail is idempotent on (order_code, detail_id), so a
|
||||
// re-run overwrites them rather than duplicating. What must not happen is
|
||||
// reporting a partial run as a complete one, which would let the missing
|
||||
// orders go unnoticed forever.
|
||||
//
|
||||
// `[必须]` Every day is verified for completeness before anything is written:
|
||||
// the per-day total is re-read after paging and must not have drifted. SYB's
|
||||
// list endpoint returns the *page* size in `total` (§4.3, confirmed against
|
||||
// live data), so the paging loop is driven by listTotal, never by list.total.
|
||||
// Each validated page is committed independently. Date-local read failures
|
||||
// preserve committed pages and allow later dates to run. Database, cancellation
|
||||
// and progress persistence failures stop the run. Completeness is verified at
|
||||
// the end of each date; partial results must never be reported as full success.
|
||||
func Sync(ctx context.Context, db *gorm.DB, client *sybclient.Client, cfg SyncConfig, dateFrom, dateTo string) (SyncReport, error) {
|
||||
return SyncWithProgress(ctx, db, client, cfg, dateFrom, dateTo, nil)
|
||||
}
|
||||
@@ -136,7 +126,8 @@ func SyncWithProgress(ctx context.Context, db *gorm.DB, client *sybclient.Client
|
||||
// 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{}, ShopFilterSnapshot: shopSnapshotNames(allowed)}
|
||||
ShopBreakdown: map[string]ShopBreakdown{}, ShopFilterSnapshot: shopSnapshotNames(allowed),
|
||||
filterHits: sybproductfilter.HitCounter{}}
|
||||
|
||||
dates, err := splitDateRange(dateFrom, dateTo)
|
||||
if err != nil {
|
||||
@@ -145,6 +136,10 @@ func SyncWithShopSnapshot(ctx context.Context, db *gorm.DB, client *sybclient.Cl
|
||||
if len(allowed) == 0 {
|
||||
return report, ErrNoEnabledShop
|
||||
}
|
||||
filters, err := sybproductfilter.LoadEnabled(ctx, db)
|
||||
if err != nil {
|
||||
return report, fmt.Errorf("读取商品过滤规则失败: %w", err)
|
||||
}
|
||||
report.ShopFilterHash = enabledShopHash(allowed)
|
||||
emit := func(daysProcessed int) error {
|
||||
if progress == nil {
|
||||
@@ -190,106 +185,41 @@ func SyncWithShopSnapshot(ctx context.Context, db *gorm.DB, client *sybclient.Cl
|
||||
plans = append(plans, dayPlan{date: date, total: total})
|
||||
}
|
||||
|
||||
for dayIndex, plan := range plans {
|
||||
if plan.total == 0 {
|
||||
if err := emit(dayIndex + 1); err != nil {
|
||||
return report, err
|
||||
}
|
||||
continue
|
||||
}
|
||||
// `[必须]` Completeness first, filtering second. loadDailyList proves the
|
||||
// day's snapshot is whole; filtering before that would let drift among
|
||||
// other shops' orders hide a hole in the ones we do want
|
||||
// (docs/12-syb-erp-interface.md §8).
|
||||
rows, listErr := loadDailyListWithRecovery(ctx, client, plan.date, pageSize, plan.total, maxMatches)
|
||||
if listErr != nil && len(rows) == 0 {
|
||||
return report, listErr
|
||||
}
|
||||
report.OrderCount += len(rows)
|
||||
|
||||
byID := make(map[int64]sybclient.StockRow, len(rows))
|
||||
ids := make([]int64, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
name := stringField(row.Raw, "shopName")
|
||||
label := name
|
||||
if sybshop.IsBlank(label) {
|
||||
label = "(无店铺名)"
|
||||
}
|
||||
display, ok := allowed[sybshop.Normalize(name)]
|
||||
if ok {
|
||||
label = display
|
||||
}
|
||||
if !ok {
|
||||
entry := report.ShopBreakdown[label]
|
||||
entry.Skipped++
|
||||
report.ShopBreakdown[label] = entry
|
||||
report.ShopSkipped++
|
||||
continue
|
||||
}
|
||||
entry := report.ShopBreakdown[label]
|
||||
entry.Accepted++
|
||||
report.ShopBreakdown[label] = entry
|
||||
report.AcceptedCount++
|
||||
byID[row.ID] = row
|
||||
ids = append(ids, row.ID)
|
||||
}
|
||||
if len(ids) == 0 {
|
||||
if err := emit(dayIndex + 1); err != nil {
|
||||
return report, err
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
for start := 0; start < len(ids); start += detailBatch {
|
||||
end := start + detailBatch
|
||||
if end > len(ids) {
|
||||
end = len(ids)
|
||||
}
|
||||
batch := ids[start:end]
|
||||
details, err := client.DetailListByStock(ctx, batch)
|
||||
if err != nil {
|
||||
return report, fmt.Errorf("拉取 %s 货运单明细失败(本次同步停止;"+
|
||||
"已写入的数据保留,重跑会按 (order_code, detail_id) 覆盖): %w", plan.date, err)
|
||||
}
|
||||
if err := validateDetailBatch(batch, details); err != nil {
|
||||
return report, fmt.Errorf("%s 货运单明细不完整:%w;本次同步停止", plan.date, err)
|
||||
}
|
||||
for _, detail := range details {
|
||||
// `[必须]` Re-check the shop on the detail response. The list said
|
||||
// this order belongs to an enabled shop; if the detail disagrees,
|
||||
// the two views are inconsistent and importing it would write a
|
||||
// row for a shop nobody enabled.
|
||||
name := stringField(detail.Raw, "shopName")
|
||||
if sybshop.IsBlank(name) {
|
||||
moveAcceptedToSkipped(&report, stringField(byID[detail.ID].Raw, "shopName"), "(无店铺名)", allowed)
|
||||
report.AcceptedCount--
|
||||
report.ShopSkipped++
|
||||
continue
|
||||
}
|
||||
if _, ok := allowed[sybshop.Normalize(name)]; !ok {
|
||||
moveAcceptedToSkipped(&report, stringField(byID[detail.ID].Raw, "shopName"), name, allowed)
|
||||
report.AcceptedCount--
|
||||
report.ShopSkipped++
|
||||
continue
|
||||
}
|
||||
if err := applyStockDetail(ctx, db, byID[detail.ID], detail, &report); err != nil {
|
||||
return report, err
|
||||
}
|
||||
}
|
||||
if err := emit(dayIndex); err != nil {
|
||||
return report, err
|
||||
}
|
||||
}
|
||||
if listErr != nil {
|
||||
return report, listErr
|
||||
}
|
||||
if err := emit(dayIndex + 1); err != nil {
|
||||
daysCompleted := 0
|
||||
var failures []error
|
||||
for _, plan := range plans {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return report, err
|
||||
}
|
||||
_, dayErr := loadDailyList(ctx, client, plan.date, pageSize, plan.total, func(rows []sybclient.StockRow, page int) error {
|
||||
report.OrderCount += len(rows)
|
||||
err := importSyncPage(ctx, db, client, rows, allowed, &report, filters)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s 第 %d 页明细/入库阶段失败: %w", plan.date, page, err)
|
||||
}
|
||||
if err := emit(daysCompleted); err != nil {
|
||||
return &syncFatalError{err}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if dayErr != nil {
|
||||
failures = append(failures, dayErr)
|
||||
var fatal *syncFatalError
|
||||
if ctx.Err() != nil || errors.As(dayErr, &fatal) || errors.Is(dayErr, sybclient.ErrSessionInvalid) {
|
||||
return report, errors.Join(failures...)
|
||||
}
|
||||
continue
|
||||
}
|
||||
daysCompleted++
|
||||
if err := emit(daysCompleted); err != nil {
|
||||
return report, errors.Join(append(failures, err)...)
|
||||
}
|
||||
}
|
||||
|
||||
report.FinishedAt = time.Now().UTC()
|
||||
return report, nil
|
||||
if err := sybproductfilter.UpdateHits(ctx, db, filters, report.filterHits, report.FinishedAt); err != nil {
|
||||
return report, err
|
||||
}
|
||||
return report, errors.Join(failures...)
|
||||
}
|
||||
|
||||
func shopSnapshotNames(allowed map[string]string) []string {
|
||||
@@ -336,39 +266,7 @@ func moveAcceptedToSkipped(report *SyncReport, listName, detailName string, allo
|
||||
// `[必须]` The loop bound comes from expectedTotal (listTotal), because
|
||||
// list.total is the current page's row count, not the filtered total (§4.3).
|
||||
// Driving the loop with the response's own total would stop after page one.
|
||||
func loadDailyListWithRecovery(ctx context.Context, client *sybclient.Client, date string, pageSize, expectedTotal, maxMatches int) ([]sybclient.StockRow, error) {
|
||||
if date != shanghaiToday() {
|
||||
return loadDailyList(ctx, client, date, pageSize, expectedTotal)
|
||||
}
|
||||
var last *snapshotDriftError
|
||||
for attempt := 1; attempt <= maxTodaySnapshotAttempts; attempt++ {
|
||||
if attempt > 1 {
|
||||
total, err := client.ListTotal(ctx, date, date, pageSize)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("重新查询今天 %s 货运单总数失败: %w", date, err)
|
||||
}
|
||||
if total > maxMatches {
|
||||
return nil, fmt.Errorf("今天 %s 的货运单总数 %d 超过单次同步上限 %d", date, total, maxMatches)
|
||||
}
|
||||
expectedTotal = total
|
||||
}
|
||||
rows, err := loadDailyList(ctx, client, date, pageSize, expectedTotal)
|
||||
if err == nil {
|
||||
return rows, nil
|
||||
}
|
||||
var drift *snapshotDriftError
|
||||
if !errors.As(err, &drift) {
|
||||
return nil, fmt.Errorf("今天第 %d/%d 次拉取失败: %w", attempt, maxTodaySnapshotAttempts, err)
|
||||
}
|
||||
last = drift
|
||||
}
|
||||
if last != nil && last.valid && len(last.rows) > 0 {
|
||||
return last.rows, fmt.Errorf("今天持续变化,已保存本次取得的完整明细,但未形成稳定快照;下次同步继续覆盖: %w", last)
|
||||
}
|
||||
return nil, fmt.Errorf("今天持续变化,连续 %d 次未形成稳定快照,本次同步停止: %w", maxTodaySnapshotAttempts, last)
|
||||
}
|
||||
|
||||
func loadDailyList(ctx context.Context, client *sybclient.Client, date string, pageSize, expectedTotal int) ([]sybclient.StockRow, error) {
|
||||
func loadDailyList(ctx context.Context, client *sybclient.Client, date string, pageSize, expectedTotal int, consume ...func([]sybclient.StockRow, int) error) ([]sybclient.StockRow, error) {
|
||||
rows := make([]sybclient.StockRow, 0, expectedTotal)
|
||||
type position struct{ page, row int }
|
||||
seen := make(map[int64]position, expectedTotal)
|
||||
@@ -387,6 +285,24 @@ func loadDailyList(ctx context.Context, client *sybclient.Client, date string, p
|
||||
if pageCount != len(page) {
|
||||
return nil, fmt.Errorf("%s 货运单列表第 %d 页响应条数不自洽:total=%d,list=%d", date, pageIndex, pageCount, len(page))
|
||||
}
|
||||
// SYB may change a page while the list is being read. Re-read once
|
||||
// before treating a boundary-page count drift as a fatal snapshot error.
|
||||
if len(page) != expectedPageCount {
|
||||
pause := time.NewTimer(500 * time.Millisecond)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
pause.Stop()
|
||||
return nil, ctx.Err()
|
||||
case <-pause.C:
|
||||
}
|
||||
page, pageCount, err = client.ListPage(ctx, date, date, start, pageIndex, pageSize)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("重读 %s 货运单列表第 %d 页失败:%w", date, pageIndex, err)
|
||||
}
|
||||
if pageCount != len(page) {
|
||||
return nil, fmt.Errorf("%s 货运单列表第 %d 页重读响应条数不自洽:total=%d,list=%d", date, pageIndex, pageCount, len(page))
|
||||
}
|
||||
}
|
||||
// Validate the whole page first: a same-page duplicate must not be
|
||||
// hidden behind an earlier, recoverable cross-page overlap.
|
||||
pageSeen := make(map[int64]int, len(page))
|
||||
@@ -413,6 +329,11 @@ func loadDailyList(ctx context.Context, client *sybclient.Client, date string, p
|
||||
if len(page) != expectedPageCount {
|
||||
return nil, &snapshotDriftError{message: fmt.Sprintf("%s 货运单列表第 %d 页不完整且相对初始总数发生变化:预期 %d 行,实际 %d 行", date, pageIndex, expectedPageCount, len(page)), rows: rows, valid: len(page) > expectedPageCount}
|
||||
}
|
||||
if len(consume) > 0 {
|
||||
if err := consume[0](page, pageIndex); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Re-read the total: if it moved while we paged, some order was inserted or
|
||||
@@ -459,7 +380,11 @@ func validateDetailBatch(requested []int64, details []sybclient.StockDetail) err
|
||||
// applyStockDetail writes one shipment order's items. Each item is its own
|
||||
// ApplyDetail call and its own transaction — a several-thousand-row sync must
|
||||
// not sit in one long-held transaction.
|
||||
func applyStockDetail(ctx context.Context, db *gorm.DB, row sybclient.StockRow, detail sybclient.StockDetail, report *SyncReport) error {
|
||||
func applyStockDetail(ctx context.Context, db *gorm.DB, row sybclient.StockRow, detail sybclient.StockDetail, report *SyncReport, sets ...sybproductfilter.RuleSet) error {
|
||||
var filters sybproductfilter.RuleSet
|
||||
if len(sets) > 0 {
|
||||
filters = sets[0]
|
||||
}
|
||||
order := OrderInput{
|
||||
Code: detail.Code,
|
||||
StockID: uint64(detail.ID),
|
||||
@@ -469,6 +394,23 @@ func applyStockDetail(ctx context.Context, db *gorm.DB, row sybclient.StockRow,
|
||||
order.Code = row.Code
|
||||
}
|
||||
for _, item := range detail.Details {
|
||||
variation := stringField(item.Raw, "variationSku")
|
||||
if rule := filters.Match(variation); rule != nil {
|
||||
// `[必须]` Count the two kinds separately and per rule. A combined
|
||||
// total would hide a structural rule that stopped matching because
|
||||
// 档口 changed its code format, and a per-kind total would make the
|
||||
// disable-confirmation dialog quote the same figure for "#" and "-"
|
||||
// even though they match very different numbers of rows (#269).
|
||||
if rule.Kind == "char" {
|
||||
report.CharFilterSkipped++
|
||||
} else {
|
||||
report.KeywordFilterSkipped++
|
||||
}
|
||||
if report.filterHits != nil {
|
||||
report.filterHits.Add(rule)
|
||||
}
|
||||
continue
|
||||
}
|
||||
raw, err := json.Marshal(item.Raw)
|
||||
if err != nil {
|
||||
return fmt.Errorf("货运单 %s 明细 %d 原始数据编码失败: %w", order.Code, item.ID, err)
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
package sybimport
|
||||
|
||||
import (
|
||||
"context"
|
||||
"go-admin/app/goauto/sybclient"
|
||||
"go-admin/app/goauto/sybproductfilter"
|
||||
"go-admin/app/goauto/sybshop"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// Database/progress errors stop the entire run, not merely the current date.
|
||||
type syncFatalError struct{ error }
|
||||
|
||||
func (e *syncFatalError) Unwrap() error { return e.error }
|
||||
|
||||
// Read all remote details before opening a page transaction. Publish counters
|
||||
// only after commit, so a rollback never reports records as saved.
|
||||
func importSyncPage(ctx context.Context, db *gorm.DB, client *sybclient.Client, rows []sybclient.StockRow, allowed map[string]string, report *SyncReport, sets ...sybproductfilter.RuleSet) error {
|
||||
var filters sybproductfilter.RuleSet
|
||||
if len(sets) > 0 {
|
||||
filters = sets[0]
|
||||
}
|
||||
next := *report
|
||||
next.ShopBreakdown = make(map[string]ShopBreakdown, len(report.ShopBreakdown))
|
||||
for k, v := range report.ShopBreakdown {
|
||||
next.ShopBreakdown[k] = v
|
||||
}
|
||||
byID := make(map[int64]sybclient.StockRow)
|
||||
var ids []int64
|
||||
for _, row := range rows {
|
||||
name := stringField(row.Raw, "shopName")
|
||||
label := name
|
||||
if sybshop.IsBlank(label) {
|
||||
label = "(无店铺名)"
|
||||
}
|
||||
display, ok := allowed[sybshop.Normalize(name)]
|
||||
if ok {
|
||||
label = display
|
||||
}
|
||||
entry := next.ShopBreakdown[label]
|
||||
if ok {
|
||||
entry.Accepted++
|
||||
next.AcceptedCount++
|
||||
ids = append(ids, row.ID)
|
||||
byID[row.ID] = row
|
||||
} else {
|
||||
entry.Skipped++
|
||||
next.ShopSkipped++
|
||||
}
|
||||
next.ShopBreakdown[label] = entry
|
||||
}
|
||||
var details []sybclient.StockDetail
|
||||
for start := 0; start < len(ids); start += detailBatch {
|
||||
end := start + detailBatch
|
||||
if end > len(ids) {
|
||||
end = len(ids)
|
||||
}
|
||||
batch, err := client.DetailListByStock(ctx, ids[start:end])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateDetailBatch(ids[start:end], batch); err != nil {
|
||||
return err
|
||||
}
|
||||
details = append(details, batch...)
|
||||
}
|
||||
err := db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
for _, detail := range details {
|
||||
name := stringField(detail.Raw, "shopName")
|
||||
_, ok := allowed[sybshop.Normalize(name)]
|
||||
if sybshop.IsBlank(name) || !ok {
|
||||
moveAcceptedToSkipped(&next, stringField(byID[detail.ID].Raw, "shopName"), name, allowed)
|
||||
next.AcceptedCount--
|
||||
next.ShopSkipped++
|
||||
continue
|
||||
}
|
||||
if err := applyStockDetail(ctx, tx, byID[detail.ID], detail, &next, filters); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return &syncFatalError{err}
|
||||
}
|
||||
*report = next
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
package sybimport
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"go-admin/app/goauto/models"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestPageFailurePreservesEarlierPagesContinuesDatesAndRerunsIdempotently(t *testing.T) {
|
||||
db := newSyncTestDB(t)
|
||||
f := &fakeSYB{perDay: map[string]int{"2026-08-01": 3, "2026-08-02": 1}, shortPageAtIndex: 2}
|
||||
var last SyncProgress
|
||||
report, err := SyncWithProgress(context.Background(), db, newSyncClient(t, f), SyncConfig{PageSize: 2, MaxMatches: 100}, "2026-08-01", "2026-08-02", func(p SyncProgress) error { last = p; return nil })
|
||||
if err == nil || !strings.Contains(err.Error(), "第 2 页") || report.Created != 2 || report.Updated != 1 || last.DaysProcessed != 1 {
|
||||
t.Fatalf("report=%+v progress=%+v err=%v", report, last, err)
|
||||
}
|
||||
var count int64
|
||||
db.Model(&models.SYBProduct{}).Count(&count)
|
||||
if count != 2 {
|
||||
t.Fatal(count)
|
||||
}
|
||||
f.shortPageAtIndex = 0
|
||||
report, err = Sync(context.Background(), db, newSyncClient(t, f), SyncConfig{PageSize: 2, MaxMatches: 100}, "2026-08-01", "2026-08-02")
|
||||
if err != nil || report.Created != 1 || report.Updated != 3 {
|
||||
t.Fatalf("rerun=%+v err=%v", report, err)
|
||||
}
|
||||
db.Model(&models.SYBProduct{}).Count(&count)
|
||||
if count != 3 {
|
||||
t.Fatal(count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPageTransactionRollbackDoesNotPublishCountersAndStopsDates(t *testing.T) {
|
||||
db := newSyncTestDB(t)
|
||||
if err := db.Exec("CREATE TRIGGER reject_second BEFORE INSERT ON syb_product WHEN NEW.detail_id = 10011 BEGIN SELECT RAISE(ABORT, 'test database failure'); END").Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f := &fakeSYB{perDay: map[string]int{"2026-08-01": 3, "2026-08-02": 1}}
|
||||
report, err := Sync(context.Background(), db, newSyncClient(t, f), SyncConfig{PageSize: 2, MaxMatches: 100}, "2026-08-01", "2026-08-02")
|
||||
var fatal *syncFatalError
|
||||
if !errors.As(err, &fatal) || report.Created != 0 || report.DetailCount != 0 || len(f.listCalls) != 1 {
|
||||
t.Fatalf("report=%+v calls=%v err=%v", report, f.listCalls, err)
|
||||
}
|
||||
var count int64
|
||||
db.Model(&models.SYBProduct{}).Count(&count)
|
||||
if count != 0 {
|
||||
t.Fatal(count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPageProgressFailureStopsAfterCommittedPage(t *testing.T) {
|
||||
db := newSyncTestDB(t)
|
||||
f := &fakeSYB{perDay: map[string]int{"2026-08-01": 3, "2026-08-02": 1}}
|
||||
calls := 0
|
||||
report, err := SyncWithProgress(context.Background(), db, newSyncClient(t, f), SyncConfig{PageSize: 2, MaxMatches: 100}, "2026-08-01", "2026-08-02", func(SyncProgress) error {
|
||||
calls++
|
||||
if calls == 2 {
|
||||
return errors.New("progress unavailable")
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err == nil || report.Created != 2 || len(f.listCalls) != 1 {
|
||||
t.Fatalf("report=%+v err=%v", report, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPageMissingDetailPreservesPriorPageAndContinuesNextDate(t *testing.T) {
|
||||
db := newSyncTestDB(t)
|
||||
f := &fakeSYB{perDay: map[string]int{"2026-08-01": 3, "2026-08-02": 1}, detailDropID: 1002}
|
||||
report, err := Sync(context.Background(), db, newSyncClient(t, f), SyncConfig{PageSize: 2, MaxMatches: 100}, "2026-08-01", "2026-08-02")
|
||||
if err == nil || report.Created != 2 || report.Updated != 1 || report.OrderCount != 4 || len(f.listCalls) != 3 {
|
||||
t.Fatalf("report=%+v err=%v", report, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPageCancellationPreservesCommitAndStopsNextPage(t *testing.T) {
|
||||
db := newSyncTestDB(t)
|
||||
f := &fakeSYB{perDay: map[string]int{"2026-08-01": 3, "2026-08-02": 1}}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
report, err := SyncWithProgress(ctx, db, newSyncClient(t, f), SyncConfig{PageSize: 2, MaxMatches: 100}, "2026-08-01", "2026-08-02", func(p SyncProgress) error {
|
||||
if p.Report.Created > 0 {
|
||||
cancel()
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if !errors.Is(err, context.Canceled) || report.Created != 2 || len(f.listCalls) != 1 {
|
||||
t.Fatalf("report=%+v err=%v", report, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPartialFinishPreservesProgressReleasesSlotAndIsFilterable(t *testing.T) {
|
||||
db := newSyncTestDB(t)
|
||||
ctx := context.Background()
|
||||
s := NewSyncRunService(db)
|
||||
run, err := s.Create(ctx, CreateSyncRunInput{DateFrom: "2026-08-01", DateTo: "2026-08-02"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
report := SyncReport{Created: 2, DetailCount: 2, ShopBreakdown: map[string]ShopBreakdown{}}
|
||||
if err := s.UpdateProgress(ctx, run.ID, SyncProgress{Report: report, DaysTotal: 2, DaysProcessed: 1}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.Finish(ctx, run.ID, SyncRunPartial, report, errors.New("第 2 页失败")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
row, err := s.Detail(ctx, run.ID)
|
||||
if err != nil || row.Status != SyncRunPartial || row.ActiveSlot != nil || row.ProgressPercent != 50 || row.Created != 2 {
|
||||
t.Fatalf("row=%+v err=%v", row, err)
|
||||
}
|
||||
result, err := s.List(ctx, SyncRunListRequest{Page: 1, PageSize: 20, Status: SyncRunPartial})
|
||||
if err != nil || result.Total != 1 {
|
||||
t.Fatalf("result=%+v err=%v", result, err)
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,7 @@ import (
|
||||
const (
|
||||
SyncRunRunning = "running"
|
||||
SyncRunSucceeded = "succeeded"
|
||||
SyncRunPartial = "partial_success"
|
||||
SyncRunFailed = "failed"
|
||||
SyncRunInterrupted = "interrupted"
|
||||
)
|
||||
@@ -69,13 +70,14 @@ func (s *SyncRunService) UpdateProgress(ctx context.Context, id uint64, progress
|
||||
"days_total": progress.DaysTotal, "days_processed": progress.DaysProcessed,
|
||||
"order_count": progress.Report.OrderCount, "detail_count": progress.Report.DetailCount,
|
||||
"accepted_count": progress.Report.AcceptedCount, "shop_skipped": progress.Report.ShopSkipped,
|
||||
"char_filter_skipped": progress.Report.CharFilterSkipped, "keyword_filter_skipped": progress.Report.KeywordFilterSkipped,
|
||||
"created_count": progress.Report.Created, "updated_count": progress.Report.Updated,
|
||||
"shop_filter_hash": progress.Report.ShopFilterHash, "shop_breakdown_json": payload,
|
||||
}).Error
|
||||
}
|
||||
|
||||
func (s *SyncRunService) Finish(ctx context.Context, id uint64, status string, report SyncReport, runErr error) error {
|
||||
if status != SyncRunSucceeded && status != SyncRunFailed {
|
||||
if status != SyncRunSucceeded && status != SyncRunPartial && status != SyncRunFailed {
|
||||
return fmt.Errorf("invalid terminal sync status %q", status)
|
||||
}
|
||||
payload, err := marshalSyncRunPayload(report.ShopBreakdown, report.ShopFilterSnapshot)
|
||||
@@ -91,6 +93,7 @@ func (s *SyncRunService) Finish(ctx context.Context, id uint64, status string, r
|
||||
"status": status, "active_slot": nil, "finished_at": now, "error_message": message,
|
||||
"order_count": report.OrderCount, "detail_count": report.DetailCount,
|
||||
"accepted_count": report.AcceptedCount, "shop_skipped": report.ShopSkipped,
|
||||
"char_filter_skipped": report.CharFilterSkipped, "keyword_filter_skipped": report.KeywordFilterSkipped,
|
||||
"created_count": report.Created, "updated_count": report.Updated,
|
||||
"shop_breakdown_json": payload,
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ func (handler Handler) ListSyncRuns(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
status := strings.TrimSpace(c.Query("status"))
|
||||
if status != "" && status != SyncRunRunning && status != SyncRunSucceeded && status != SyncRunFailed && status != SyncRunInterrupted {
|
||||
if status != "" && status != SyncRunRunning && status != SyncRunSucceeded && status != SyncRunPartial && status != SyncRunFailed && status != SyncRunInterrupted {
|
||||
writeError(c, invalidRequest("status 无效"))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -36,4 +36,12 @@ func TestSyncRunListAndDetailHandlers(t *testing.T) {
|
||||
if detail.Code != http.StatusOK || !strings.Contains(detail.Body.String(), `"operatorName":"管理员"`) {
|
||||
t.Fatalf("详情响应不正确: %d %s", detail.Code, detail.Body.String())
|
||||
}
|
||||
if err := NewSyncRunService(db).Finish(context.Background(), run.ID, SyncRunPartial, SyncReport{Created: 1}, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
partial := httptest.NewRecorder()
|
||||
engine.ServeHTTP(partial, httptest.NewRequest(http.MethodGet, "/sync-runs?status=partial_success", nil))
|
||||
if partial.Code != http.StatusOK || !strings.Contains(partial.Body.String(), `"total":1`) {
|
||||
t.Fatalf("partial filter: %d %s", partial.Code, partial.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ import (
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go-admin/app/goauto/migrations"
|
||||
"go-admin/app/goauto/models"
|
||||
@@ -276,39 +275,6 @@ func TestSyncStopsWhenTotalDriftsDuringPaging(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestTodaySnapshotDriftRetriesFromFirstPage(t *testing.T) {
|
||||
originalNow := syncNow
|
||||
syncNow = func() time.Time { return time.Date(2026, 8, 29, 12, 0, 0, 0, time.FixedZone("Asia/Shanghai", 8*60*60)) }
|
||||
t.Cleanup(func() { syncNow = originalNow })
|
||||
f := &fakeSYB{perDay: map[string]int{"2026-08-29": 10}, totalOverride: map[int]int{1: 11, 2: 10, 3: 10}}
|
||||
rows, err := loadDailyListWithRecovery(context.Background(), newSyncClient(t, f), "2026-08-29", 10, 10, 1000)
|
||||
if err != nil || len(rows) != 10 || f.listTotalCalls != 3 {
|
||||
t.Fatalf("rows=%d totalCalls=%d err=%v", len(rows), f.listTotalCalls, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTodayThirdDriftReturnsValidLastListForDegradedSave(t *testing.T) {
|
||||
originalNow := syncNow
|
||||
syncNow = func() time.Time { return time.Date(2026, 8, 29, 12, 0, 0, 0, time.FixedZone("Asia/Shanghai", 8*60*60)) }
|
||||
t.Cleanup(func() { syncNow = originalNow })
|
||||
f := &fakeSYB{perDay: map[string]int{"2026-08-29": 10}, totalOverride: map[int]int{1: 11, 2: 10, 3: 11, 4: 10, 5: 11}}
|
||||
rows, err := loadDailyListWithRecovery(context.Background(), newSyncClient(t, f), "2026-08-29", 10, 10, 1000)
|
||||
if err == nil || len(rows) != 10 || !strings.Contains(err.Error(), "已保存本次取得的完整明细") {
|
||||
t.Fatalf("rows=%d err=%v", len(rows), err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHistoricalSnapshotDriftDoesNotRetry(t *testing.T) {
|
||||
originalNow := syncNow
|
||||
syncNow = func() time.Time { return time.Date(2026, 8, 29, 12, 0, 0, 0, time.FixedZone("Asia/Shanghai", 8*60*60)) }
|
||||
t.Cleanup(func() { syncNow = originalNow })
|
||||
f := &fakeSYB{perDay: map[string]int{"2026-08-28": 10}, totalOverride: map[int]int{1: 11}}
|
||||
rows, err := loadDailyListWithRecovery(context.Background(), newSyncClient(t, f), "2026-08-28", 10, 10, 1000)
|
||||
if err == nil || rows != nil || f.listTotalCalls != 1 {
|
||||
t.Fatalf("rows=%v totalCalls=%d err=%v", rows, f.listTotalCalls, err)
|
||||
}
|
||||
}
|
||||
|
||||
// 某页行数少于预期同样是不完整快照。
|
||||
func TestSyncStopsOnShortPage(t *testing.T) {
|
||||
db := newSyncTestDB(t)
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
package sybproductfilter
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/go-admin-team/go-admin-core/sdk/pkg"
|
||||
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
|
||||
"gorm.io/gorm"
|
||||
"net/http"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
type Handler struct{ DB *gorm.DB }
|
||||
|
||||
func (h Handler) service(c *gin.Context) (*Service, bool) {
|
||||
db := h.DB
|
||||
var e error
|
||||
if db == nil {
|
||||
db, e = pkg.GetOrm(c)
|
||||
}
|
||||
if e != nil {
|
||||
writeError(c, internal(e))
|
||||
return nil, false
|
||||
}
|
||||
return NewService(db), true
|
||||
}
|
||||
func (h Handler) List(c *gin.Context) {
|
||||
s, ok := h.service(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
p, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
ps, _ := strconv.Atoi(c.DefaultQuery("pageSize", "50"))
|
||||
r, e := s.List(c.Request.Context(), ListRequest{Page: p, PageSize: ps, Kind: c.Query("kind"), Keyword: c.Query("keyword"), EnabledOnly: c.Query("enabledOnly") == "true"})
|
||||
if e != nil {
|
||||
writeError(c, e)
|
||||
return
|
||||
}
|
||||
c.JSON(200, gin.H{"code": 200, "data": r})
|
||||
}
|
||||
func (h Handler) Create(c *gin.Context) {
|
||||
var r CreateRequest
|
||||
if !decode(c, &r) {
|
||||
return
|
||||
}
|
||||
s, ok := h.service(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
x, e := s.Create(c.Request.Context(), r)
|
||||
if e != nil {
|
||||
writeError(c, e)
|
||||
return
|
||||
}
|
||||
c.JSON(200, gin.H{"code": 200, "data": x})
|
||||
}
|
||||
func (h Handler) SetEnabled(c *gin.Context) {
|
||||
id, ok := idParam(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var r SetEnabledRequest
|
||||
if !decode(c, &r) {
|
||||
return
|
||||
}
|
||||
s, ok := h.service(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
claims := jwt.ExtractClaims(c)
|
||||
name, _ := claims["nice"].(string)
|
||||
x, e := s.SetEnabled(c.Request.Context(), id, r, name)
|
||||
if e != nil {
|
||||
writeError(c, e)
|
||||
return
|
||||
}
|
||||
c.JSON(200, gin.H{"code": 200, "data": x})
|
||||
}
|
||||
func (h Handler) Delete(c *gin.Context) {
|
||||
id, ok := idParam(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
s, ok := h.service(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if e := s.Delete(c.Request.Context(), id); e != nil {
|
||||
writeError(c, e)
|
||||
return
|
||||
}
|
||||
c.JSON(200, gin.H{"code": 200, "data": gin.H{"deleted": true}})
|
||||
}
|
||||
func idParam(c *gin.Context) (uint64, bool) {
|
||||
id, e := strconv.ParseUint(c.Param("filterId"), 10, 64)
|
||||
if e != nil || id == 0 {
|
||||
writeError(c, invalid("过滤规则 id 必须是正整数"))
|
||||
return 0, false
|
||||
}
|
||||
return id, true
|
||||
}
|
||||
func decode(c *gin.Context, t any) bool {
|
||||
d := json.NewDecoder(c.Request.Body)
|
||||
d.DisallowUnknownFields()
|
||||
if e := d.Decode(t); e != nil {
|
||||
writeError(c, invalid("请求体解析失败:"+e.Error()))
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
func writeError(c *gin.Context, e error) {
|
||||
var se *ServiceError
|
||||
if !errors.As(e, &se) {
|
||||
se = internal(e).(*ServiceError)
|
||||
}
|
||||
status := 500
|
||||
switch se.Code {
|
||||
case CodeInvalidRequest:
|
||||
status = 422
|
||||
case CodeConflict:
|
||||
status = 409
|
||||
case CodeNotFound:
|
||||
status = 404
|
||||
case CodeForbidden:
|
||||
status = http.StatusForbidden
|
||||
}
|
||||
c.JSON(status, gin.H{"code": se.Code, "message": se.Message})
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package sybproductfilter
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// 用线上真实取到的 variationSku 样本,验证过滤判定与每条规则各自计数。
|
||||
func TestRealWorldSamplesPerRuleCounting(t *testing.T) {
|
||||
db := testDB(t)
|
||||
ctx := context.Background()
|
||||
set, err := LoadEnabled(ctx, db)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
samples := []struct {
|
||||
v string
|
||||
wantKind string // "" = 不过滤
|
||||
}{
|
||||
{"新寮-雅伊阁大码女装#301", "char"},
|
||||
{"广达-爆款大码女装#9018", "char"},
|
||||
{"DD#004", "char"},
|
||||
{"300斤牛奶絲圓領#A057", "char"},
|
||||
{"上寮-琪琪尚雅网批#2604##(&15)", "char"},
|
||||
{"PDD0582", ""},
|
||||
{"my238", ""},
|
||||
{"le171", ""},
|
||||
{"1", ""},
|
||||
{"引流款", ""},
|
||||
{"", ""},
|
||||
}
|
||||
hits := HitCounter{}
|
||||
var charN, kwN, keptN int
|
||||
for _, s := range samples {
|
||||
rule := set.Match(s.v)
|
||||
if s.wantKind == "" {
|
||||
if rule != nil {
|
||||
t.Fatalf("%q 不应被过滤,却命中了 %q", s.v, rule.Keyword)
|
||||
}
|
||||
keptN++
|
||||
continue
|
||||
}
|
||||
if rule == nil || rule.Kind != s.wantKind {
|
||||
t.Fatalf("%q 应命中 %s 规则,实际 %v", s.v, s.wantKind, rule)
|
||||
}
|
||||
hits.Add(rule)
|
||||
if rule.Kind == "char" {
|
||||
charN++
|
||||
} else {
|
||||
kwN++
|
||||
}
|
||||
}
|
||||
if charN != 5 || kwN != 0 || keptN != 6 {
|
||||
t.Fatalf("统计不符:char=%d keyword=%d kept=%d", charN, kwN, keptN)
|
||||
}
|
||||
if err := UpdateHits(ctx, db, set, hits, time.Now().UTC()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
after, err := LoadEnabled(ctx, db)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := map[string]int{}
|
||||
for _, x := range after.Chars {
|
||||
if x.LastHitCount == nil {
|
||||
t.Fatalf("规则 %q 必须被写入命中数", x.Keyword)
|
||||
}
|
||||
got[x.Keyword] = *x.LastHitCount
|
||||
}
|
||||
// "#" 命中 4 条(301/9018/DD#004/A057 ... 其中带 - 的先被 "-" 规则接走)
|
||||
if got["-"]+got["#"] != 5 {
|
||||
t.Fatalf("两条结构规则命中数之和应为 5,实际 - =%d # =%d", got["-"], got["#"])
|
||||
}
|
||||
if got["-"] == got["#"] {
|
||||
t.Fatalf("两条规则不应记成同一个数(这正是 #269 修的 bug): - =%d # =%d", got["-"], got["#"])
|
||||
}
|
||||
for _, x := range after.Keywords {
|
||||
if x.LastHitCount == nil || *x.LastHitCount != 0 {
|
||||
t.Fatalf("未命中的关键词 %q 应记 0,实际 %v", x.Keyword, x.LastHitCount)
|
||||
}
|
||||
}
|
||||
t.Logf("结构规则命中:- =%d # =%d", got["-"], got["#"])
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package sybproductfilter
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
|
||||
"go-admin/common/middleware"
|
||||
)
|
||||
|
||||
func InitRouter(e *gin.Engine, a *jwt.GinJWTMiddleware) {
|
||||
g := e.Group("/api/admin/v1/syb-product-filters").Use(a.MiddlewareFunc()).Use(middleware.AuthCheckRole())
|
||||
g.GET("", Handler{}.List)
|
||||
g.POST("", middleware.RequireRoleKey("admin"), Handler{}.Create)
|
||||
g.PATCH("/:filterId/enabled", middleware.RequireRoleKey("admin"), Handler{}.SetEnabled)
|
||||
g.DELETE("/:filterId", middleware.RequireRoleKey("admin"), Handler{}.Delete)
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
package sybproductfilter
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go-admin/app/goauto/models"
|
||||
"go-admin/app/goauto/sybshop"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type ServiceError struct {
|
||||
Code, Message string
|
||||
Cause error
|
||||
}
|
||||
|
||||
func (e *ServiceError) Error() string {
|
||||
if e.Cause != nil {
|
||||
return fmt.Sprintf("%s: %v", e.Message, e.Cause)
|
||||
}
|
||||
return e.Message
|
||||
}
|
||||
func (e *ServiceError) Unwrap() error { return e.Cause }
|
||||
|
||||
const (
|
||||
CodeInvalidRequest = "INVALID_REQUEST"
|
||||
CodeNotFound = "SYB_PRODUCT_FILTER_NOT_FOUND"
|
||||
CodeConflict = "SYB_PRODUCT_FILTER_DUPLICATE"
|
||||
CodeForbidden = "FORBIDDEN"
|
||||
)
|
||||
|
||||
func invalid(m string) error { return &ServiceError{Code: CodeInvalidRequest, Message: m} }
|
||||
func internal(e error) error {
|
||||
return &ServiceError{Code: "INTERNAL_ERROR", Message: "服务器内部错误", Cause: e}
|
||||
}
|
||||
|
||||
type Service struct{ DB *gorm.DB }
|
||||
|
||||
func NewService(db *gorm.DB) *Service { return &Service{DB: db} }
|
||||
|
||||
type ListRequest struct {
|
||||
Page, PageSize int
|
||||
Kind, Keyword string
|
||||
EnabledOnly bool
|
||||
}
|
||||
type ListResponse struct {
|
||||
Items []models.SYBProductFilter `json:"items"`
|
||||
Total int64 `json:"total"`
|
||||
EnabledCount int64 `json:"enabledCount"`
|
||||
Page int `json:"page"`
|
||||
PageSize int `json:"pageSize"`
|
||||
}
|
||||
|
||||
func (s *Service) List(ctx context.Context, r ListRequest) (ListResponse, error) {
|
||||
if r.Page < 1 {
|
||||
r.Page = 1
|
||||
}
|
||||
if r.PageSize < 1 || r.PageSize > 200 {
|
||||
r.PageSize = 50
|
||||
}
|
||||
q := s.DB.WithContext(ctx).Model(&models.SYBProductFilter{})
|
||||
if r.Kind != "" {
|
||||
q = q.Where("kind = ?", r.Kind)
|
||||
}
|
||||
if k := sybshop.Normalize(r.Keyword); k != "" {
|
||||
q = q.Where("normalized_keyword LIKE ?", "%"+k+"%")
|
||||
}
|
||||
if r.EnabledOnly {
|
||||
q = q.Where("enabled = ?", true)
|
||||
}
|
||||
var total, enabled int64
|
||||
if err := q.Count(&total).Error; err != nil {
|
||||
return ListResponse{}, internal(err)
|
||||
}
|
||||
items := make([]models.SYBProductFilter, 0, r.PageSize)
|
||||
if err := q.Order("kind ASC, id ASC").Offset((r.Page - 1) * r.PageSize).Limit(r.PageSize).Find(&items).Error; err != nil {
|
||||
return ListResponse{}, internal(err)
|
||||
}
|
||||
if err := s.DB.WithContext(ctx).Model(&models.SYBProductFilter{}).Where("enabled = ?", true).Count(&enabled).Error; err != nil {
|
||||
return ListResponse{}, internal(err)
|
||||
}
|
||||
return ListResponse{Items: items, Total: total, EnabledCount: enabled, Page: r.Page, PageSize: r.PageSize}, nil
|
||||
}
|
||||
|
||||
type CreateRequest struct {
|
||||
Kind string `json:"kind"`
|
||||
Keyword string `json:"keyword"`
|
||||
Enabled *bool `json:"enabled"`
|
||||
Note *string `json:"note"`
|
||||
}
|
||||
|
||||
func validate(kind, keyword string) (string, string, error) {
|
||||
if kind != "char" && kind != "keyword" {
|
||||
return "", "", invalid("kind 必须是 char 或 keyword")
|
||||
}
|
||||
keyword = strings.TrimSpace(keyword)
|
||||
if keyword == "" {
|
||||
return "", "", invalid("关键词不能为空")
|
||||
}
|
||||
if sybshop.ContainsControl(keyword) {
|
||||
return "", "", invalid("关键词不能包含控制字符")
|
||||
}
|
||||
return keyword, sybshop.Normalize(keyword), nil
|
||||
}
|
||||
func (s *Service) Create(ctx context.Context, r CreateRequest) (models.SYBProductFilter, error) {
|
||||
if r.Kind == "char" {
|
||||
return models.SYBProductFilter{}, &ServiceError{Code: CodeForbidden, Message: "结构过滤不可新增"}
|
||||
}
|
||||
k, n, e := validate(r.Kind, r.Keyword)
|
||||
if e != nil {
|
||||
return models.SYBProductFilter{}, e
|
||||
}
|
||||
var x models.SYBProductFilter
|
||||
if err := s.DB.WithContext(ctx).Where("kind = ? AND normalized_keyword = ?", r.Kind, n).First(&x).Error; err == nil {
|
||||
return models.SYBProductFilter{}, &ServiceError{Code: CodeConflict, Message: fmt.Sprintf("该关键词已存在(归一化后与「%s」相同)", x.Keyword)}
|
||||
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return models.SYBProductFilter{}, internal(err)
|
||||
}
|
||||
en := true
|
||||
if r.Enabled != nil {
|
||||
en = *r.Enabled
|
||||
}
|
||||
x = models.SYBProductFilter{Kind: r.Kind, Keyword: k, NormalizedKeyword: n, Enabled: en, Note: r.Note}
|
||||
if err := s.DB.WithContext(ctx).Create(&x).Error; err != nil {
|
||||
return models.SYBProductFilter{}, internal(err)
|
||||
}
|
||||
return x, nil
|
||||
}
|
||||
|
||||
type SetEnabledRequest struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
|
||||
func (s *Service) SetEnabled(ctx context.Context, id uint64, r SetEnabledRequest, operator string) (models.SYBProductFilter, error) {
|
||||
var x models.SYBProductFilter
|
||||
if err := s.DB.WithContext(ctx).First(&x, id).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return x, &ServiceError{Code: CodeNotFound, Message: "过滤规则不存在"}
|
||||
}
|
||||
return x, internal(err)
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
updates := map[string]any{"enabled": r.Enabled, "updated_at": now}
|
||||
if x.Kind == "char" {
|
||||
updates["last_changed_by"] = strings.TrimSpace(operator)
|
||||
updates["last_changed_at"] = now
|
||||
}
|
||||
if err := s.DB.WithContext(ctx).Model(&x).Updates(updates).Error; err != nil {
|
||||
return x, internal(err)
|
||||
}
|
||||
x.Enabled = r.Enabled
|
||||
x.UpdatedAt = now
|
||||
if x.Kind == "char" {
|
||||
x.LastChangedBy = strings.TrimSpace(operator)
|
||||
x.LastChangedAt = &now
|
||||
}
|
||||
return x, nil
|
||||
}
|
||||
func (s *Service) Delete(ctx context.Context, id uint64) error {
|
||||
return s.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
var x models.SYBProductFilter
|
||||
if err := tx.First(&x, id).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return &ServiceError{Code: CodeNotFound, Message: "过滤规则不存在"}
|
||||
}
|
||||
return internal(err)
|
||||
}
|
||||
if x.Kind == "char" {
|
||||
return &ServiceError{Code: CodeForbidden, Message: "结构过滤不可删除"}
|
||||
}
|
||||
return tx.Model(&x).Updates(map[string]any{"deleted_flag": x.ID, "deleted_at": time.Now().UTC()}).Error
|
||||
})
|
||||
}
|
||||
|
||||
type RuleSet struct{ Chars, Keywords []models.SYBProductFilter }
|
||||
|
||||
func LoadEnabled(ctx context.Context, db *gorm.DB) (RuleSet, error) {
|
||||
var rows []models.SYBProductFilter
|
||||
if err := db.WithContext(ctx).Where("enabled = ?", true).Find(&rows).Error; err != nil {
|
||||
return RuleSet{}, err
|
||||
}
|
||||
r := RuleSet{}
|
||||
for _, x := range rows {
|
||||
if x.Kind == "char" {
|
||||
r.Chars = append(r.Chars, x)
|
||||
} else {
|
||||
r.Keywords = append(r.Keywords, x)
|
||||
}
|
||||
}
|
||||
return r, nil
|
||||
}
|
||||
|
||||
// Match reports which enabled rule first matched v, or nil when none did.
|
||||
//
|
||||
// It returns the matching rule rather than a pair of booleans because the
|
||||
// caller has to attribute the hit to one specific rule: every rule keeps its
|
||||
// own LastHitCount, and the disable-confirmation dialog quotes that per-rule
|
||||
// number ("停用「#」会让约 N 条恢复入库"). Collapsing the result to
|
||||
// char/keyword booleans would force every rule of a kind to share one
|
||||
// aggregate, and the dialog would then warn with the wrong figure — on real
|
||||
// data "#" matches 8498 rows and "-" matches 6392, so the two must never be
|
||||
// reported as the same number.
|
||||
//
|
||||
// Structural rules are checked before keyword rules so a hit is attributed the
|
||||
// way the page presents the two sections, and an empty variationSku never
|
||||
// matches: it is a product with no supplier code yet, not a 档口 item.
|
||||
func (r RuleSet) Match(v string) *models.SYBProductFilter {
|
||||
if v == "" {
|
||||
return nil
|
||||
}
|
||||
n := sybshop.Normalize(v)
|
||||
for i, x := range r.Chars {
|
||||
if strings.Contains(n, x.NormalizedKeyword) {
|
||||
return &r.Chars[i]
|
||||
}
|
||||
}
|
||||
for i, x := range r.Keywords {
|
||||
if strings.Contains(n, x.NormalizedKeyword) {
|
||||
return &r.Keywords[i]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// HitCounter accumulates per-rule hits over one sync run, keyed by rule id.
|
||||
type HitCounter map[uint64]int
|
||||
|
||||
// Add records one hit against the rule that matched.
|
||||
func (h HitCounter) Add(rule *models.SYBProductFilter) {
|
||||
if rule == nil {
|
||||
return
|
||||
}
|
||||
h[rule.ID]++
|
||||
}
|
||||
|
||||
// UpdateHits writes each rule's own hit count back to its row.
|
||||
//
|
||||
// Every rule in the set is written, including those with zero hits: leaving a
|
||||
// previous run's number in place would make the confirmation dialog quote a
|
||||
// figure that no longer describes the current data.
|
||||
func UpdateHits(ctx context.Context, db *gorm.DB, r RuleSet, hits HitCounter, at time.Time) error {
|
||||
for _, x := range append(append([]models.SYBProductFilter{}, r.Chars...), r.Keywords...) {
|
||||
if err := db.WithContext(ctx).Model(&models.SYBProductFilter{}).Where("id = ?", x.ID).
|
||||
Updates(map[string]any{"last_hit_count": hits[x.ID], "last_hit_at": at}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
package sybproductfilter
|
||||
|
||||
import (
|
||||
"context"
|
||||
"go-admin/app/goauto/migrations"
|
||||
"go-admin/app/goauto/models"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func testDB(t *testing.T) *gorm.DB {
|
||||
db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = migrations.Migrate(db); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return db
|
||||
}
|
||||
|
||||
func TestMatchORAndNormalization(t *testing.T) {
|
||||
r := RuleSet{
|
||||
Chars: []models.SYBProductFilter{{ID: 1, Kind: "char", NormalizedKeyword: "-"}, {ID: 2, Kind: "char", NormalizedKeyword: "#"}},
|
||||
Keywords: []models.SYBProductFilter{{ID: 3, Kind: "keyword", NormalizedKeyword: "cvc短袖220斤"}},
|
||||
}
|
||||
// `[必须]` OR, never AND. Both of these contain "#" but no "-", and on real
|
||||
// production data that difference is 2118 rows (#269).
|
||||
for _, v := range []string{"DD#004", "300斤牛奶絲圓領#A057"} {
|
||||
hit := r.Match(v)
|
||||
if hit == nil || hit.Kind != "char" {
|
||||
t.Fatalf("%q should match a structural rule, got %v", v, hit)
|
||||
}
|
||||
if hit.ID != 2 {
|
||||
t.Fatalf("%q should be attributed to the \"#\" rule, got id %d", v, hit.ID)
|
||||
}
|
||||
}
|
||||
if hit := r.Match("新寮-雅伊阁大码女装"); hit == nil || hit.ID != 1 {
|
||||
t.Fatalf("dash-only value should be attributed to the \"-\" rule, got %v", hit)
|
||||
}
|
||||
if hit := r.Match(""); hit != nil {
|
||||
t.Fatal("empty variationSku must not match: it is a product without a supplier code")
|
||||
}
|
||||
if hit := r.Match("PDD0582"); hit != nil {
|
||||
t.Fatalf("own product code must not match, got %v", hit)
|
||||
}
|
||||
if hit := r.Match(" CVC短袖220斤 "); hit == nil || hit.Kind != "keyword" {
|
||||
t.Fatalf("full-width and surrounding spaces should normalize to a keyword hit, got %v", hit)
|
||||
}
|
||||
if hit := r.Match("cvc短袖220斤"); hit == nil || hit.Kind != "keyword" {
|
||||
t.Fatalf("lowercase should normalize to a keyword hit, got %v", hit)
|
||||
}
|
||||
if hit := r.Match("纯棉"); hit != nil {
|
||||
t.Fatal("normalization must not convert traditional to simplified Chinese")
|
||||
}
|
||||
}
|
||||
|
||||
// TestUpdateHitsIsPerRule pins the rule that每条规则各记各的命中数. Writing one
|
||||
// aggregate per kind would make the disable-confirmation dialog quote the same
|
||||
// figure for "#" and "-" even though they match very different numbers of rows.
|
||||
func TestUpdateHitsIsPerRule(t *testing.T) {
|
||||
db := testDB(t)
|
||||
ctx := context.Background()
|
||||
set, err := LoadEnabled(ctx, db)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(set.Chars) != 2 {
|
||||
t.Fatalf("migration should seed 2 structural rules, got %d", len(set.Chars))
|
||||
}
|
||||
byKeyword := map[string]uint64{}
|
||||
for _, x := range set.Chars {
|
||||
byKeyword[x.Keyword] = x.ID
|
||||
}
|
||||
hits := HitCounter{}
|
||||
for i := 0; i < 8498; i++ {
|
||||
hits[byKeyword["#"]]++
|
||||
}
|
||||
for i := 0; i < 6392; i++ {
|
||||
hits[byKeyword["-"]]++
|
||||
}
|
||||
if err := UpdateHits(ctx, db, set, hits, time.Now().UTC()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var rows []models.SYBProductFilter
|
||||
if err := db.Find(&rows).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := map[string]int{"#": 8498, "-": 6392}
|
||||
for _, x := range rows {
|
||||
if x.LastHitCount == nil {
|
||||
t.Fatalf("rule %q must be written even when it matched nothing", x.Keyword)
|
||||
}
|
||||
if w, ok := want[x.Keyword]; ok {
|
||||
if *x.LastHitCount != w {
|
||||
t.Fatalf("rule %q should record its own %d hits, got %d", x.Keyword, w, *x.LastHitCount)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if *x.LastHitCount != 0 {
|
||||
t.Fatalf("keyword rule %q matched nothing and must record 0, got %d", x.Keyword, *x.LastHitCount)
|
||||
}
|
||||
if x.LastHitAt == nil {
|
||||
t.Fatalf("rule %q must still be stamped so a stale count is never left behind", x.Keyword)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCharCreateDeleteRejectedAndKeywordDuplicate(t *testing.T) {
|
||||
db := testDB(t)
|
||||
s := NewService(db)
|
||||
ctx := context.Background()
|
||||
if _, e := s.Create(ctx, CreateRequest{Kind: "char", Keyword: "@"}); e == nil {
|
||||
t.Fatal("char create must be rejected")
|
||||
}
|
||||
if e := s.Delete(ctx, 1); e == nil {
|
||||
t.Fatal("seed char delete must be rejected")
|
||||
}
|
||||
if _, e := s.Create(ctx, CreateRequest{Kind: "keyword", Keyword: " CVC "}); e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
if _, e := s.Create(ctx, CreateRequest{Kind: "keyword", Keyword: "cvc"}); e == nil {
|
||||
t.Fatal("normalized duplicate must be rejected")
|
||||
}
|
||||
}
|
||||
@@ -78,6 +78,25 @@ func (handler Handler) AdminBatchCreate(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"code": 200, "data": response})
|
||||
}
|
||||
|
||||
func (handler Handler) AdminBatchCreateImageSearch(c *gin.Context) {
|
||||
var request ImageSearchBatchRequest
|
||||
if err := decodeStrict(c, &request); err != nil {
|
||||
writeError(c, serviceError("INVALID_REQUEST", "请求 JSON 无效"))
|
||||
return
|
||||
}
|
||||
service, _, ok := handler.service(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
response, err := service.BatchCreateImageSearch(c.Request.Context(), request)
|
||||
if err != nil {
|
||||
writeError(c, err)
|
||||
return
|
||||
}
|
||||
c.Header("Cache-Control", "no-store")
|
||||
c.JSON(http.StatusOK, gin.H{"code": 200, "data": response})
|
||||
}
|
||||
|
||||
func (handler Handler) AdminDetail(c *gin.Context) {
|
||||
id, err := taskID(c)
|
||||
if err != nil || id == 0 {
|
||||
|
||||
@@ -211,7 +211,7 @@ func (service *Service) IdentifyCurrentPage(ctx context.Context, taskID uint64,
|
||||
}
|
||||
return CurrentPageIdentifyResponse{}, internalError(err)
|
||||
}
|
||||
if existing.Source != models.CollectionTaskSourceAgentCurrentPage || existing.DeviceID == nil || *existing.DeviceID != deviceRecord.ID {
|
||||
if (existing.Source != models.CollectionTaskSourceAgentCurrentPage && existing.Source != models.CollectionTaskSourceImageSearch) || existing.DeviceID == nil || *existing.DeviceID != deviceRecord.ID {
|
||||
return CurrentPageIdentifyResponse{}, serviceError(CodeTaskNotFound, "任务不存在")
|
||||
}
|
||||
if existing.IdentifyRequestID != nil && *existing.IdentifyRequestID == request.RequestID && existing.PDDProductID != nil {
|
||||
@@ -240,7 +240,7 @@ func (service *Service) IdentifyCurrentPage(ctx context.Context, taskID uint64,
|
||||
}
|
||||
return internalError(err)
|
||||
}
|
||||
if record.Source != models.CollectionTaskSourceAgentCurrentPage || record.DeviceID == nil || *record.DeviceID != deviceRecord.ID {
|
||||
if (record.Source != models.CollectionTaskSourceAgentCurrentPage && record.Source != models.CollectionTaskSourceImageSearch) || record.DeviceID == nil || *record.DeviceID != deviceRecord.ID {
|
||||
return serviceError(CodeTaskNotFound, "任务不存在")
|
||||
}
|
||||
if record.IdentifyRequestID != nil && *record.IdentifyRequestID == request.RequestID && record.PDDProductID != nil {
|
||||
@@ -255,7 +255,9 @@ func (service *Service) IdentifyCurrentPage(ctx context.Context, taskID uint64,
|
||||
return serviceError(CodeCurrentPageIdentityConflict, "当前商品与任务已识别商品不一致")
|
||||
}
|
||||
now := service.Now()
|
||||
if err := tx.Model(&models.CollectionTask{}).Where("id = ?", record.ID).Updates(map[string]any{
|
||||
// Validate the locked task's real state; an empty model fails BeforeSave
|
||||
// when a retried current-page task already has a product binding.
|
||||
if err := tx.Model(&record).Where("id = ?", record.ID).Updates(map[string]any{
|
||||
"identify_request_id": request.RequestID, "identity_resolved_at": now,
|
||||
}).Error; err != nil {
|
||||
return internalError(err)
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
package task
|
||||
|
||||
import (
|
||||
"context"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"go-admin/app/goauto/models"
|
||||
"go-admin/app/goauto/rulecontract"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
func TestCurrentPageReidentifyAfterSameTaskRetry(t *testing.T) {
|
||||
db := openTaskDatabase(t)
|
||||
_, token := registerTaskDeviceWithCapabilities(t, db, "retry-device", []string{
|
||||
rulecontract.CapabilitySchemaV2,
|
||||
rulecontract.CapabilityPDDProductDetailV1,
|
||||
rulecontract.CapabilityPDDCurrentPageShareV1,
|
||||
})
|
||||
_, otherToken := registerTaskDevice(t, db, "other-device")
|
||||
rule := models.CollectionRule{Name: "retry-rule", ContentJSON: v2TaskRuleSnapshot()}
|
||||
if err := db.Create(&rule).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Create(&models.AgentManualCollectionSetting{ID: 1, RuleID: rule.ID}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
service := newTaskService(db)
|
||||
ctx := context.Background()
|
||||
created, err := service.CreateCurrentPage(ctx, CurrentPageCreateRequest{RequestID: uuid.NewString()}, token)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
load := func() models.CollectionTask {
|
||||
t.Helper()
|
||||
var record models.CollectionTask
|
||||
if err := db.First(&record, created.TaskID).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return record
|
||||
}
|
||||
request := CurrentPageIdentifyRequest{
|
||||
RequestID: uuid.NewString(), ShareURL: "https://mobile.yangkeduo.com/goods.html?goods_id=700000000255",
|
||||
}
|
||||
// Direct links resolve locally: the regression never calls PDD or a live DB.
|
||||
identity, err := service.IdentifyCurrentPage(ctx, created.TaskID, request, token)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
const failureCode = "SPEC_PANEL_EVIDENCE_NOT_MATCHED"
|
||||
if _, err := service.SubmitFailure(ctx, created.TaskID, FailRequest{
|
||||
RequestID: uuid.NewString(), Code: failureCode, Message: "Synthetic panel failure",
|
||||
}, token); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
reset, err := service.ResetForDevice(ctx, created.TaskID, ActionRequest{RequestID: uuid.NewString()}, token)
|
||||
if err != nil || reset.TaskID != created.TaskID || reset.AttemptNumber != 2 || reset.Status != models.TaskStatusPending {
|
||||
t.Fatalf("same-task reset: %+v, %v", reset, err)
|
||||
}
|
||||
resetRecord := load()
|
||||
if resetRecord.PDDProductID == nil || *resetRecord.PDDProductID != identity.PDDProductID ||
|
||||
resetRecord.IdentifyRequestID != nil || resetRecord.IdentityResolvedAt != nil {
|
||||
t.Fatal("reset must keep the product and clear per-attempt identification")
|
||||
}
|
||||
if _, err := service.Claim(ctx, created.TaskID, ActionRequest{RequestID: uuid.NewString()}, token); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := service.Start(ctx, created.TaskID, ActionRequest{RequestID: uuid.NewString()}, token); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
before := load()
|
||||
request.RequestID = uuid.NewString()
|
||||
for _, tc := range []struct {
|
||||
name, token, url, code string
|
||||
}{
|
||||
{"other device", otherToken, request.ShareURL, CodeTaskNotFound},
|
||||
{"other product", token, "https://mobile.yangkeduo.com/goods.html?goods_id=700000000256", CodeCurrentPageIdentityConflict},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
_, err := service.IdentifyCurrentPage(ctx, created.TaskID, CurrentPageIdentifyRequest{RequestID: uuid.NewString(), ShareURL: tc.url}, tc.token)
|
||||
if taskErrorCode(t, err) != tc.code {
|
||||
t.Fatalf("expected %s, got %v", tc.code, err)
|
||||
}
|
||||
if !reflect.DeepEqual(before, load()) {
|
||||
t.Fatal("rejected identification changed the task")
|
||||
}
|
||||
})
|
||||
}
|
||||
reidentified, err := service.IdentifyCurrentPage(ctx, created.TaskID, request, token)
|
||||
if err != nil {
|
||||
t.Fatalf("reidentify same product after retry: %v", err)
|
||||
}
|
||||
if reidentified.TaskID != created.TaskID || reidentified.PDDProductID != identity.PDDProductID ||
|
||||
reidentified.GoodsID != identity.GoodsID || reidentified.URL != identity.URL || !reidentified.Replayed {
|
||||
t.Fatalf("identity changed: %+v", reidentified)
|
||||
}
|
||||
after := load()
|
||||
if after.IdentifyRequestID == nil || *after.IdentifyRequestID != request.RequestID || after.IdentityResolvedAt == nil {
|
||||
t.Fatal("new attempt identification was not persisted")
|
||||
}
|
||||
// Apart from identity metadata and the normal update timestamp, every field
|
||||
// (including status, device, rule, lease and uniqueness slots) must survive.
|
||||
expected := before
|
||||
expected.IdentifyRequestID = after.IdentifyRequestID
|
||||
expected.IdentityResolvedAt = after.IdentityResolvedAt
|
||||
expected.UpdatedAt = after.UpdatedAt
|
||||
if !reflect.DeepEqual(expected, after) {
|
||||
t.Fatal("reidentification changed unrelated task fields")
|
||||
}
|
||||
service.ResolveCurrentPageShare = func(context.Context, string) (ResolvedPDDShare, error) {
|
||||
t.Fatal("replay must not resolve the link again")
|
||||
return ResolvedPDDShare{}, nil
|
||||
}
|
||||
replay, err := service.IdentifyCurrentPage(ctx, created.TaskID, request, token)
|
||||
if err != nil || replay != reidentified || !reflect.DeepEqual(after, load()) {
|
||||
t.Fatalf("replay changed identity or task: %+v, %v", replay, err)
|
||||
}
|
||||
service.ResolveCurrentPageShare = nil
|
||||
title := "Synthetic collected product"
|
||||
result, err := service.SubmitResult(ctx, created.TaskID, ResultRequest{
|
||||
RequestID: uuid.NewString(), Status: models.TaskStatusCompleted,
|
||||
Product: ResultProduct{PDDGoodsID: identity.GoodsID, Title: &title},
|
||||
Dimensions: []ResultDimension{{Key: "color", Name: "颜色", Values: []string{"黑色"}}},
|
||||
ColorPrices: []ResultColorPrice{{Color: "黑色", PriceCent: 1200}},
|
||||
SKUs: []ResultSKU{{Specs: map[string]string{"color": "黑色"}, PriceCent: 1200, Available: true}},
|
||||
}, token)
|
||||
if err != nil || result.Task.ID != created.TaskID || result.Task.Status != models.TaskStatusCompleted {
|
||||
t.Fatalf("complete retried collection: %+v, %v", result, err)
|
||||
}
|
||||
terminal := load()
|
||||
request.RequestID = uuid.NewString()
|
||||
if _, err := service.IdentifyCurrentPage(ctx, created.TaskID, request, token); taskErrorCode(t, err) != CodeTaskStateConflict {
|
||||
t.Fatalf("expected terminal-state rejection: %v", err)
|
||||
}
|
||||
if !reflect.DeepEqual(terminal, load()) {
|
||||
t.Fatal("terminal identification changed the task")
|
||||
}
|
||||
var archived []models.CollectionTaskAttempt
|
||||
if err := db.Where("task_id = ?", created.TaskID).Find(&archived).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(archived) != 1 || archived[0].AttemptNumber != 1 || archived[0].Status != models.TaskStatusFailed ||
|
||||
archived[0].ErrorCode == nil || *archived[0].ErrorCode != failureCode {
|
||||
t.Fatal("original failed attempt was not preserved")
|
||||
}
|
||||
for _, model := range []any{&models.CollectionTask{}, &models.PDDProduct{}} {
|
||||
var count int64
|
||||
if err := db.Model(model).Count(&count).Error; err != nil || count != 1 {
|
||||
t.Fatalf("retry duplicated task/product: count=%d, %v", count, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,373 @@
|
||||
package task
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"image"
|
||||
_ "image/jpeg"
|
||||
_ "image/png"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go-admin/app/goauto/device"
|
||||
"go-admin/app/goauto/models"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
const ImageSearchCapability = "pdd.image-search.v1"
|
||||
const imageSearchMaxBytes int64 = 10 << 20
|
||||
|
||||
func validImageMetadata(value ImageSearchImage) bool {
|
||||
if value.SizeBytes <= 0 || value.SizeBytes > imageSearchMaxBytes || len(value.SHA256) != 64 || value.SHA256 != strings.ToLower(value.SHA256) || value.ImageURL == "" {
|
||||
return false
|
||||
}
|
||||
return value.MediaType == "image/jpeg" || value.MediaType == "image/png"
|
||||
}
|
||||
|
||||
func fetchImageSearchImage(ctx context.Context, rawURL string) (ImageSearchImage, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
|
||||
if err != nil {
|
||||
return ImageSearchImage{}, err
|
||||
}
|
||||
response, err := (&http.Client{Timeout: 10 * time.Second}).Do(req)
|
||||
if err != nil {
|
||||
return ImageSearchImage{}, err
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode < 200 || response.StatusCode >= 300 {
|
||||
return ImageSearchImage{}, fmt.Errorf("reference image status %d", response.StatusCode)
|
||||
}
|
||||
data, err := io.ReadAll(io.LimitReader(response.Body, imageSearchMaxBytes+1))
|
||||
if err != nil || int64(len(data)) > imageSearchMaxBytes {
|
||||
return ImageSearchImage{}, errors.New("reference image exceeds limit")
|
||||
}
|
||||
config, format, err := image.DecodeConfig(bytes.NewReader(data))
|
||||
if err != nil || config.Width < 1 || config.Height < 1 {
|
||||
return ImageSearchImage{}, errors.New("unsupported image format")
|
||||
}
|
||||
media := "image/" + format
|
||||
if format == "jpeg" {
|
||||
media = "image/jpeg"
|
||||
}
|
||||
hash := sha256.Sum256(data)
|
||||
return ImageSearchImage{ImageURL: rawURL, MediaType: media, SizeBytes: int64(len(data)), SHA256: hex.EncodeToString(hash[:])}, nil
|
||||
}
|
||||
|
||||
// Only this subset is sent to Agent. Association and price snapshots stay on Server.
|
||||
type ImageSearchImage struct {
|
||||
ImageURL string `json:"imageUrl"`
|
||||
MediaType string `json:"mediaType"`
|
||||
SizeBytes int64 `json:"sizeBytes"`
|
||||
SHA256 string `json:"sha256"`
|
||||
}
|
||||
|
||||
type ImageSearchSnapshot struct {
|
||||
ImageSearchImage
|
||||
ShopeeProductID uint64 `json:"shopeeProductId"`
|
||||
SYBProductIDs []uint64 `json:"sybProductIds"`
|
||||
RepresentativeSYBProductID uint64 `json:"representativeSybProductId"`
|
||||
OriginalPDDProductID *uint64 `json:"originalPddProductId"`
|
||||
ReferencePriceCent int64 `json:"referencePriceCent"`
|
||||
ReferenceCurrency string `json:"referenceCurrency"`
|
||||
MaxPriceRatio int64 `json:"maxPriceRatio"`
|
||||
PriceGuardSkipped bool `json:"priceGuardSkipped"`
|
||||
PriceGuardSkipReason string `json:"priceGuardSkipReason,omitempty"`
|
||||
RequestFingerprint string `json:"requestFingerprint"`
|
||||
}
|
||||
|
||||
type ImageSearchBatchRequest struct {
|
||||
RequestID string `json:"requestId"`
|
||||
SYBProductIDs []uint64 `json:"sybProductIds"`
|
||||
RuleID uint64 `json:"ruleId"`
|
||||
DeviceID *uint64 `json:"deviceId"`
|
||||
OverwriteLinked bool `json:"overwriteLinked"`
|
||||
}
|
||||
|
||||
type ImageSearchBatchItem struct {
|
||||
ShopeeProductID uint64 `json:"shopeeProductId"`
|
||||
SYBProductIDs []uint64 `json:"sybProductIds"`
|
||||
Success bool `json:"success"`
|
||||
TaskID uint64 `json:"taskId,omitempty"`
|
||||
Replayed bool `json:"replayed,omitempty"`
|
||||
Code string `json:"code,omitempty"`
|
||||
Message string `json:"message,omitempty"`
|
||||
}
|
||||
|
||||
type ImageSearchBatchResponse struct {
|
||||
Items []ImageSearchBatchItem `json:"items"`
|
||||
SuccessCount int `json:"successCount"`
|
||||
FailureCount int `json:"failureCount"`
|
||||
SkippedCount int `json:"skippedCount"`
|
||||
}
|
||||
|
||||
func (service *Service) BatchCreateImageSearch(ctx context.Context, request ImageSearchBatchRequest) (ImageSearchBatchResponse, error) {
|
||||
batchID, err := uuid.Parse(strings.TrimSpace(request.RequestID))
|
||||
if err != nil || request.RuleID == 0 || len(request.SYBProductIDs) == 0 || len(request.SYBProductIDs) > 100 || request.DeviceID != nil && *request.DeviceID == 0 {
|
||||
return ImageSearchBatchResponse{}, serviceError("INVALID_REQUEST", "批次、SYB 商品、规则和设备参数无效(最多 100 条)")
|
||||
}
|
||||
seen := map[uint64]bool{}
|
||||
for _, id := range request.SYBProductIDs {
|
||||
if id == 0 || seen[id] {
|
||||
return ImageSearchBatchResponse{}, serviceError("INVALID_REQUEST", "SYB 商品列表包含无效或重复 ID")
|
||||
}
|
||||
seen[id] = true
|
||||
}
|
||||
// Bound the complete synchronous batch, not just each individual download.
|
||||
ctx, cancel := context.WithTimeout(ctx, 45*time.Second)
|
||||
defer cancel()
|
||||
var rows []models.SYBProduct
|
||||
if err := service.DB.WithContext(ctx).Where("id IN ?", request.SYBProductIDs).Find(&rows).Error; err != nil {
|
||||
return ImageSearchBatchResponse{}, internalError(err)
|
||||
}
|
||||
byID := map[uint64]models.SYBProduct{}
|
||||
for _, row := range rows {
|
||||
byID[row.ID] = row
|
||||
}
|
||||
response := ImageSearchBatchResponse{Items: []ImageSearchBatchItem{}}
|
||||
groups := map[uint64]int{}
|
||||
for _, id := range request.SYBProductIDs {
|
||||
row, found := byID[id]
|
||||
if !found || row.ShopeeProductID == nil || *row.ShopeeProductID == 0 {
|
||||
response.Items = append(response.Items, ImageSearchBatchItem{SYBProductIDs: []uint64{id}, Code: "SYB_PRODUCT_UNAVAILABLE", Message: "SYB 商品不存在或未关联蝦皮商品"})
|
||||
continue
|
||||
}
|
||||
if index, ok := groups[*row.ShopeeProductID]; ok {
|
||||
response.Items[index].SYBProductIDs = append(response.Items[index].SYBProductIDs, id)
|
||||
} else {
|
||||
groups[*row.ShopeeProductID] = len(response.Items)
|
||||
response.Items = append(response.Items, ImageSearchBatchItem{ShopeeProductID: *row.ShopeeProductID, SYBProductIDs: []uint64{id}})
|
||||
}
|
||||
}
|
||||
for index := range response.Items {
|
||||
item := &response.Items[index]
|
||||
if item.Code == "" {
|
||||
itemRequestID := uuid.NewSHA1(batchID, []byte(strconv.FormatUint(item.ShopeeProductID, 10))).String()
|
||||
id, replayed, createErr := service.createImageSearch(ctx, request, *item, itemRequestID)
|
||||
if createErr == nil {
|
||||
item.Success, item.TaskID, item.Replayed = true, id, replayed
|
||||
} else {
|
||||
var target *ServiceError
|
||||
if !errors.As(createErr, &target) {
|
||||
target = internalError(createErr).(*ServiceError)
|
||||
}
|
||||
item.Code, item.Message = target.Code, target.Message
|
||||
}
|
||||
}
|
||||
if item.Success {
|
||||
response.SuccessCount++
|
||||
} else if item.Code == "IMAGE_SEARCH_ALREADY_LINKED" {
|
||||
response.SkippedCount++
|
||||
} else {
|
||||
response.FailureCount++
|
||||
}
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
|
||||
func imageRequestFingerprint(request ImageSearchBatchRequest, item ImageSearchBatchItem) string {
|
||||
// Include group membership so a reused request ID cannot silently change intent.
|
||||
request.RequestID = ""
|
||||
request.SYBProductIDs = item.SYBProductIDs
|
||||
raw, _ := json.Marshal(request)
|
||||
hash := sha256.Sum256(raw)
|
||||
return hex.EncodeToString(hash[:])
|
||||
}
|
||||
|
||||
func loadImageSearchSnapshot(record models.CollectionTask) (ImageSearchSnapshot, error) {
|
||||
var snapshot ImageSearchSnapshot
|
||||
if record.ImageSearchSnapshot == nil || json.Unmarshal([]byte(*record.ImageSearchSnapshot), &snapshot) != nil || snapshot.ShopeeProductID == 0 || snapshot.RepresentativeSYBProductID == 0 || snapshot.MaxPriceRatio < 1 || !validImageMetadata(snapshot.ImageSearchImage) {
|
||||
return snapshot, serviceError("IMAGE_SEARCH_SNAPSHOT_INVALID", "图搜任务快照无效")
|
||||
}
|
||||
return snapshot, nil
|
||||
}
|
||||
|
||||
// ImageSearchPriceAllowed deliberately refuses to compare unlike currencies.
|
||||
// A skipped check is returned as comparable=false so callers must require
|
||||
// manual confirmation before automatic association.
|
||||
func ImageSearchPriceAllowed(snapshot ImageSearchSnapshot, pddCurrency string, priceCent int64) (allowed bool, comparable bool) {
|
||||
if priceCent < 0 || snapshot.ReferencePriceCent < 0 || snapshot.ReferencePriceCent == 0 || snapshot.MaxPriceRatio < 1 {
|
||||
return false, false
|
||||
}
|
||||
if strings.ToUpper(strings.TrimSpace(snapshot.ReferenceCurrency)) != strings.ToUpper(strings.TrimSpace(pddCurrency)) {
|
||||
return false, false
|
||||
}
|
||||
return priceCent <= snapshot.ReferencePriceCent*snapshot.MaxPriceRatio, true
|
||||
}
|
||||
|
||||
func (service *Service) createImageSearch(ctx context.Context, request ImageSearchBatchRequest, item ImageSearchBatchItem, requestID string) (uint64, bool, error) {
|
||||
fingerprint := imageRequestFingerprint(request, item)
|
||||
replay := func(db *gorm.DB) (uint64, bool, error) {
|
||||
var record models.CollectionTask
|
||||
err := db.Where("create_request_id = ?", requestID).First(&record).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return 0, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return 0, false, internalError(err)
|
||||
}
|
||||
snapshot, err := loadImageSearchSnapshot(record)
|
||||
if err != nil {
|
||||
return 0, false, err
|
||||
}
|
||||
if record.Source != models.CollectionTaskSourceImageSearch || snapshot.RequestFingerprint != fingerprint {
|
||||
return 0, false, serviceError("IMAGE_SEARCH_REQUEST_CONFLICT", "同一请求 ID 的参数已变化")
|
||||
}
|
||||
return record.ID, true, nil
|
||||
}
|
||||
db := service.DB.WithContext(ctx)
|
||||
if id, found, err := replay(db); found || err != nil {
|
||||
return id, found, err
|
||||
}
|
||||
var shopee models.ShopeeProduct
|
||||
if err := db.First(&shopee, item.ShopeeProductID).Error; err != nil {
|
||||
return 0, false, serviceError("SHOPEE_PRODUCT_NOT_FOUND", "蝦皮商品不存在")
|
||||
}
|
||||
if shopee.PDDProductID != nil && !request.OverwriteLinked {
|
||||
return 0, false, serviceError("IMAGE_SEARCH_ALREADY_LINKED", "商品已关联 PDD,已跳过")
|
||||
}
|
||||
var rule models.CollectionRule
|
||||
if err := db.First(&rule, request.RuleID).Error; err != nil {
|
||||
return 0, false, serviceError(CodeRuleNotFound, "采集规则不存在")
|
||||
}
|
||||
if err := ensureCurrentPageRule(rule.ContentJSON); err != nil {
|
||||
return 0, false, err
|
||||
}
|
||||
if request.DeviceID != nil {
|
||||
var target models.AgentDevice
|
||||
if err := db.First(&target, *request.DeviceID).Error; err != nil || target.Status == models.DeviceStatusDisabled {
|
||||
return 0, false, serviceError(CodeDeviceNotFound, "设备不存在或已停用")
|
||||
}
|
||||
if err := ensureTaskCompatible(target, models.CollectionTask{Source: models.CollectionTaskSourceImageSearch, RuleSnapshot: rule.ContentJSON}); err != nil {
|
||||
return 0, false, err
|
||||
}
|
||||
}
|
||||
ratio := int64(3)
|
||||
if value := strings.TrimSpace(os.Getenv("GOAUTO_IMAGE_SEARCH_MAX_PRICE_RATIO")); value != "" {
|
||||
var err error
|
||||
ratio, err = strconv.ParseInt(value, 10, 64)
|
||||
if err != nil || ratio < 1 || ratio > 100 {
|
||||
return 0, false, serviceError("IMAGE_SEARCH_CONFIG_INVALID", "图搜价格倍数配置须为 1~100 的整数")
|
||||
}
|
||||
}
|
||||
var representative models.SYBProduct
|
||||
var image ImageSearchImage
|
||||
fetch := service.FetchImageSearchImage
|
||||
if fetch == nil {
|
||||
fetch = fetchImageSearchImage
|
||||
}
|
||||
for _, id := range item.SYBProductIDs {
|
||||
var row models.SYBProduct
|
||||
if err := db.First(&row, id).Error; err != nil {
|
||||
return 0, false, internalError(err)
|
||||
}
|
||||
if row.ShopeeProductID == nil || *row.ShopeeProductID != shopee.ID {
|
||||
return 0, false, serviceError("IMAGE_SEARCH_INPUT_CHANGED", "SYB 商品关联已变化,请刷新后重试")
|
||||
}
|
||||
if strings.TrimSpace(row.ImageURL) == "" {
|
||||
continue
|
||||
}
|
||||
var err error
|
||||
image, err = fetch(ctx, row.ImageURL)
|
||||
if err == nil && validImageMetadata(image) {
|
||||
representative = row
|
||||
break
|
||||
}
|
||||
if ctx.Err() != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
if representative.ID == 0 {
|
||||
return 0, false, serviceError("IMAGE_SEARCH_IMAGE_INVALID", "参考图下载失败、格式不支持或超过限制")
|
||||
}
|
||||
referenceCurrency := strings.ToUpper(strings.TrimSpace(shopee.Currency))
|
||||
priceSkipped := referenceCurrency != "CNY"
|
||||
snapshot := ImageSearchSnapshot{ImageSearchImage: image, ShopeeProductID: shopee.ID, SYBProductIDs: item.SYBProductIDs, RepresentativeSYBProductID: representative.ID,
|
||||
OriginalPDDProductID: shopee.PDDProductID, ReferencePriceCent: representative.UnitPriceCent, ReferenceCurrency: referenceCurrency, MaxPriceRatio: ratio, RequestFingerprint: fingerprint,
|
||||
PriceGuardSkipped: priceSkipped}
|
||||
if priceSkipped {
|
||||
snapshot.PriceGuardSkipReason = "CROSS_CURRENCY_TWD_PDD_CNY"
|
||||
}
|
||||
raw, _ := json.Marshal(snapshot)
|
||||
stored := string(raw)
|
||||
var taskID uint64
|
||||
var replayed bool
|
||||
err := db.Transaction(func(tx *gorm.DB) error {
|
||||
var current models.ShopeeProduct
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(¤t, shopee.ID).Error; err != nil {
|
||||
return internalError(err)
|
||||
}
|
||||
if id, found, err := replay(tx); found || err != nil {
|
||||
taskID, replayed = id, found
|
||||
return err
|
||||
}
|
||||
if !sameImageSearchLink(current.PDDProductID, shopee.PDDProductID) || current.Currency != shopee.Currency {
|
||||
return serviceError("IMAGE_SEARCH_INPUT_CHANGED", "蝦皮商品关联或币种已变化,请刷新后重试")
|
||||
}
|
||||
for _, id := range item.SYBProductIDs {
|
||||
var row models.SYBProduct
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&row, id).Error; err != nil {
|
||||
return internalError(err)
|
||||
}
|
||||
if row.ShopeeProductID == nil || *row.ShopeeProductID != shopee.ID || id == representative.ID && (row.ImageURL != representative.ImageURL || row.UnitPriceCent != representative.UnitPriceCent) {
|
||||
return serviceError("IMAGE_SEARCH_INPUT_CHANGED", "参考图片或价格已变化,请刷新后重试")
|
||||
}
|
||||
}
|
||||
var active []models.CollectionTask
|
||||
if err := tx.Where("source = ? AND status IN ?", models.CollectionTaskSourceImageSearch, []string{models.TaskStatusPending, models.TaskStatusRunning}).Find(&active).Error; err != nil {
|
||||
return internalError(err)
|
||||
}
|
||||
for _, candidate := range active {
|
||||
other, err := loadImageSearchSnapshot(candidate)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if other.ShopeeProductID == shopee.ID {
|
||||
return serviceError("IMAGE_SEARCH_TASK_ACTIVE", "该蝦皮商品已有未结束的图搜任务")
|
||||
}
|
||||
}
|
||||
record := models.CollectionTask{Source: models.CollectionTaskSourceImageSearch, Status: models.TaskStatusPending, RuleID: rule.ID, RuleSnapshot: rule.ContentJSON, DeviceID: request.DeviceID, CreateRequestID: &requestID, ImageSearchSnapshot: &stored}
|
||||
if err := tx.Create(&record).Error; err != nil {
|
||||
return internalError(err)
|
||||
}
|
||||
taskID = record.ID
|
||||
return nil
|
||||
})
|
||||
return taskID, replayed, err
|
||||
}
|
||||
|
||||
func sameImageSearchLink(left, right *uint64) bool {
|
||||
return left == nil && right == nil || left != nil && right != nil && *left == *right
|
||||
}
|
||||
|
||||
func taskCompatible(record models.AgentDevice, task models.CollectionTask) (bool, error) {
|
||||
if task.Source == models.CollectionTaskSourceImageSearch {
|
||||
ok, err := device.Supports(record, []string{ImageSearchCapability})
|
||||
if err != nil || !ok {
|
||||
return false, err
|
||||
}
|
||||
}
|
||||
return ruleCompatible(record, task.RuleSnapshot)
|
||||
}
|
||||
|
||||
func ensureTaskCompatible(record models.AgentDevice, task models.CollectionTask) error {
|
||||
ok, err := taskCompatible(record, task)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !ok {
|
||||
return serviceError(CodeDeviceCapabilityMismatch, "设备不支持任务所需能力,请更新 Agent")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package task
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"go-admin/app/goauto/models"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// autoLinkImageSearch is deliberately a narrow post-result hook. It only
|
||||
// writes an association when the task was a complete collection, the frozen
|
||||
// original association is unchanged, and the target product is active. Manual
|
||||
// changes made while the Agent was running win by the CAS predicate.
|
||||
func (service *Service) autoLinkImageSearch(ctx context.Context, taskID uint64) error {
|
||||
var task models.CollectionTask
|
||||
if err := service.DB.WithContext(ctx).First(&task, taskID).Error; err != nil {
|
||||
return internalError(err)
|
||||
}
|
||||
if task.Source != models.CollectionTaskSourceImageSearch || task.Status != models.TaskStatusCompleted || task.PDDProductID == nil || task.ImageSearchSnapshot == nil {
|
||||
return nil
|
||||
}
|
||||
snapshot, err := loadImageSearchSnapshot(task)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if snapshot.PriceGuardSkipped {
|
||||
return nil
|
||||
}
|
||||
var product models.ShopeeProduct
|
||||
if err := service.DB.WithContext(ctx).First(&product, snapshot.ShopeeProductID).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil
|
||||
}
|
||||
return internalError(err)
|
||||
}
|
||||
// PDD products are RMB in the current contract; unlike-currency snapshots
|
||||
// must remain available for manual review and can never auto-link.
|
||||
if strings.ToUpper(strings.TrimSpace(product.Currency)) != "CNY" {
|
||||
return nil
|
||||
}
|
||||
var pdd models.PDDProduct
|
||||
if err := service.DB.WithContext(ctx).Select("id", "status").First(&pdd, *task.PDDProductID).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil
|
||||
}
|
||||
return internalError(err)
|
||||
}
|
||||
if pdd.Status != "active" {
|
||||
return nil
|
||||
}
|
||||
result := service.DB.WithContext(ctx).Model(&models.ShopeeProduct{}).
|
||||
Where("id = ? AND pdd_product_id IS NULL AND image_search_linked = ?", product.ID, false).
|
||||
Updates(map[string]any{"pdd_product_id": *task.PDDProductID, "image_search_linked": true})
|
||||
if result.Error != nil {
|
||||
return internalError(result.Error)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package task
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"go-admin/app/goauto/models"
|
||||
)
|
||||
|
||||
func TestImageSearchPriceAllowedRefusesCrossCurrency(t *testing.T) {
|
||||
snapshot := ImageSearchSnapshot{ReferencePriceCent: 1000, ReferenceCurrency: "TWD", MaxPriceRatio: 3}
|
||||
if allowed, comparable := ImageSearchPriceAllowed(snapshot, "CNY", 2000); allowed || comparable {
|
||||
t.Fatalf("cross-currency price must not be compared: allowed=%v comparable=%v", allowed, comparable)
|
||||
}
|
||||
if allowed, comparable := ImageSearchPriceAllowed(snapshot, "TWD", 3000); !allowed || !comparable {
|
||||
t.Fatalf("same-currency price within ratio should pass: allowed=%v comparable=%v", allowed, comparable)
|
||||
}
|
||||
if allowed, comparable := ImageSearchPriceAllowed(snapshot, "TWD", 3001); allowed || !comparable {
|
||||
t.Fatalf("same-currency price over ratio should fail: allowed=%v comparable=%v", allowed, comparable)
|
||||
}
|
||||
}
|
||||
|
||||
func TestImageSearchPayloadOnlyContainsImageMetadata(t *testing.T) {
|
||||
secret := `{"shopeeProductId":99,"representativeSybProductId":1,"maxPriceRatio":3,"referenceCurrency":"TWD","imageUrl":"https://example.invalid/ref.jpg","mediaType":"image/jpeg","sizeBytes":10,"sha256":"0123456789012345678901234567890101234567890123456789012345678901"}`
|
||||
record := models.CollectionTask{Source: models.CollectionTaskSourceImageSearch, RuleSnapshot: `{}`, ImageSearchSnapshot: &secret}
|
||||
payload, err := NewService(nil).payload(record, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw := string(payload.ImageSearch)
|
||||
if raw == "" || containsAny(raw, "shopeeProductId", "referenceCurrency") {
|
||||
t.Fatalf("internal snapshot fields leaked in Agent payload: %s", raw)
|
||||
}
|
||||
}
|
||||
|
||||
func containsAny(value string, needles ...string) bool {
|
||||
for _, needle := range needles {
|
||||
if strings.Contains(value, needle) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func TestAutoLinkImageSearchDoesNotOverwriteManualAssociation(t *testing.T) {
|
||||
db := openTaskDatabase(t)
|
||||
manualPDD := models.PDDProduct{GoodsID: "manual-pdd", URL: "https://mobile.yangkeduo.com/goods.html?goods_id=manual-pdd", Status: "active"}
|
||||
autoPDD := models.PDDProduct{GoodsID: "auto-pdd", URL: "https://mobile.yangkeduo.com/goods.html?goods_id=auto-pdd", Status: "active"}
|
||||
if err := db.Create(&manualPDD).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Create(&autoPDD).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
shopee := models.ShopeeProduct{ShopeeItemID: "auto-link-test", Title: "test", Currency: "CNY", SpecsJSON: "[]", PDDProductID: &manualPDD.ID}
|
||||
if err := db.Create(&shopee).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rule := models.CollectionRule{Name: "image-search-test", ContentJSON: "{}"}
|
||||
if err := db.Create(&rule).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
snapshot := ImageSearchSnapshot{ShopeeProductID: shopee.ID, RepresentativeSYBProductID: 1, ReferenceCurrency: "CNY", ReferencePriceCent: 100, MaxPriceRatio: 3, ImageSearchImage: ImageSearchImage{ImageURL: "https://example.invalid/a.jpg", MediaType: "image/jpeg", SizeBytes: 1, SHA256: "0123456789012345678901234567890101234567890123456789012345678901"}}
|
||||
raw, _ := json.Marshal(snapshot)
|
||||
task := models.CollectionTask{Source: models.CollectionTaskSourceImageSearch, Status: models.TaskStatusCompleted, PDDProductID: &autoPDD.ID, RuleID: rule.ID, RuleSnapshot: "{}", ImageSearchSnapshot: func() *string { v := string(raw); return &v }()}
|
||||
if err := db.Create(&task).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := NewService(db).autoLinkImageSearch(context.Background(), task.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var saved models.ShopeeProduct
|
||||
if err := db.First(&saved, shopee.ID).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if saved.PDDProductID == nil || *saved.PDDProductID != manualPDD.ID || saved.ImageSearchLinked {
|
||||
t.Fatalf("manual association was overwritten: %+v", saved)
|
||||
}
|
||||
}
|
||||
@@ -123,6 +123,9 @@ func (service *Service) SubmitResult(ctx context.Context, taskID uint64, request
|
||||
if err := service.activatePersistedReplacement(ctx, taskID); err != nil {
|
||||
return DetailResponse{}, err
|
||||
}
|
||||
if err := service.autoLinkImageSearch(ctx, taskID); err != nil {
|
||||
return DetailResponse{}, err
|
||||
}
|
||||
detail, err := service.Detail(ctx, taskID)
|
||||
detail.Replayed = replayed
|
||||
return detail, err
|
||||
|
||||
@@ -31,6 +31,7 @@ func InitRouter(engine *gin.Engine, auth *jwt.GinJWTMiddleware) {
|
||||
admin.GET("", handler.AdminList)
|
||||
admin.POST("", handler.AdminCreate)
|
||||
admin.POST("/batch", handler.AdminBatchCreate)
|
||||
admin.POST("/image-search/batch", handler.AdminBatchCreateImageSearch)
|
||||
admin.GET("/:taskId", handler.AdminDetail)
|
||||
admin.POST("/:taskId/reset", handler.AdminReset)
|
||||
admin.DELETE("/:taskId", handler.AdminDelete)
|
||||
|
||||
@@ -60,6 +60,7 @@ type TaskPayload struct {
|
||||
ReplacementOriginType string `json:"replacementOriginType,omitempty"`
|
||||
RuleID uint64 `json:"ruleId"`
|
||||
RuleSnapshot json.RawMessage `json:"ruleSnapshot"`
|
||||
ImageSearch json.RawMessage `json:"imageSearch,omitempty"`
|
||||
TimeoutSeconds int `json:"timeoutSeconds"`
|
||||
LeaseExpiresAt *time.Time `json:"leaseExpiresAt,omitempty"`
|
||||
LeaseVersion uint64 `json:"leaseVersion"`
|
||||
@@ -74,6 +75,7 @@ type Service struct {
|
||||
TaskTimeout int
|
||||
ResolveCurrentPageShare func(context.Context, string) (ResolvedPDDShare, error)
|
||||
ColorImageDirectory string
|
||||
FetchImageSearchImage func(context.Context, string) (ImageSearchImage, error)
|
||||
}
|
||||
|
||||
func NewService(db *gorm.DB) *Service {
|
||||
@@ -107,7 +109,7 @@ func (service *Service) Next(ctx context.Context, token string) (*TaskPayload, e
|
||||
Where("device_id = ? AND status = ? AND lease_expires_at > ?", deviceRecord.ID, models.TaskStatusPending, now).
|
||||
Order("created_at ASC, id ASC").First(&record).Error
|
||||
if err == nil {
|
||||
if err := ensureRuleCompatible(deviceRecord, record.RuleSnapshot); err != nil {
|
||||
if err := ensureTaskCompatible(deviceRecord, record); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
payload, payloadErr := service.payload(record, false)
|
||||
@@ -124,7 +126,7 @@ func (service *Service) Next(ctx context.Context, token string) (*TaskPayload, e
|
||||
return nil, internalError(err)
|
||||
}
|
||||
for _, candidate := range candidates {
|
||||
compatible, compatibilityErr := ruleCompatible(deviceRecord, candidate.RuleSnapshot)
|
||||
compatible, compatibilityErr := taskCompatible(deviceRecord, candidate)
|
||||
if compatibilityErr != nil {
|
||||
return nil, compatibilityErr
|
||||
}
|
||||
@@ -167,7 +169,7 @@ func (service *Service) Claim(ctx context.Context, taskID uint64, request Action
|
||||
if record.Status != models.TaskStatusPending {
|
||||
return serviceError(CodeTaskStateConflict, "只有待执行任务可以领取")
|
||||
}
|
||||
if err := ensureRuleCompatible(deviceRecord, record.RuleSnapshot); err != nil {
|
||||
if err := ensureTaskCompatible(deviceRecord, record); err != nil {
|
||||
return err
|
||||
}
|
||||
now := service.Now()
|
||||
@@ -235,7 +237,7 @@ func (service *Service) Start(ctx context.Context, taskID uint64, request Action
|
||||
if record.Status != models.TaskStatusPending {
|
||||
return serviceError(CodeTaskStateConflict, "任务当前状态不能开始")
|
||||
}
|
||||
if err := ensureRuleCompatible(deviceRecord, record.RuleSnapshot); err != nil {
|
||||
if err := ensureTaskCompatible(deviceRecord, record); err != nil {
|
||||
return err
|
||||
}
|
||||
if record.DeviceID == nil || *record.DeviceID != deviceRecord.ID {
|
||||
@@ -286,6 +288,17 @@ func (service *Service) payload(record models.CollectionTask, replayed bool) (Ta
|
||||
if !json.Valid(rule) {
|
||||
return TaskPayload{}, internalError(errors.New("stored rule snapshot is invalid JSON"))
|
||||
}
|
||||
var imageSearch json.RawMessage
|
||||
if record.ImageSearchSnapshot != nil && *record.ImageSearchSnapshot != "" {
|
||||
snapshot, snapshotErr := loadImageSearchSnapshot(record)
|
||||
if snapshotErr != nil {
|
||||
return TaskPayload{}, snapshotErr
|
||||
}
|
||||
imageSearch, _ = json.Marshal(snapshot.ImageSearchImage)
|
||||
if !json.Valid(imageSearch) {
|
||||
return TaskPayload{}, internalError(errors.New("stored image search snapshot is invalid JSON"))
|
||||
}
|
||||
}
|
||||
timeout := service.TaskTimeout
|
||||
if timeout <= 0 {
|
||||
timeout = DefaultTaskTimeout
|
||||
@@ -295,6 +308,7 @@ func (service *Service) payload(record models.CollectionTask, replayed bool) (Ta
|
||||
URLSnapshot: record.URLSnapshot, GoodsIDSnapshot: record.GoodsIDSnapshot,
|
||||
Source: record.Source, ReplacementOriginType: pointerValue(record.ReplacementOriginType),
|
||||
RuleID: record.RuleID, RuleSnapshot: rule, TimeoutSeconds: timeout,
|
||||
ImageSearch: imageSearch,
|
||||
LeaseExpiresAt: record.LeaseExpiresAt, LeaseVersion: record.LeaseVersion,
|
||||
Status: record.Status, Replayed: replayed,
|
||||
}, nil
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
package version_local
|
||||
|
||||
import (
|
||||
"runtime"
|
||||
|
||||
"go-admin/app/goauto/models"
|
||||
"go-admin/app/goauto/sybshop"
|
||||
"go-admin/cmd/migrate/migration"
|
||||
common "go-admin/common/models"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func init() {
|
||||
_, file, _, _ := runtime.Caller(0)
|
||||
migration.Migrate.SetVersion(migration.GetFilename(file), migrateSYBProductFilterTable)
|
||||
}
|
||||
|
||||
// migrateSYBProductFilterTable creates syb_product_filter and seeds its rules
|
||||
// for #269.
|
||||
//
|
||||
// `[必须]` Adding the model to migrations.Migrate only covers databases created
|
||||
// from scratch. Every existing database already has the older version numbers
|
||||
// recorded in sys_migration, so that list never runs again there — a new model
|
||||
// needs its own version file, which is what the post-migration table check in
|
||||
// cmd/migrate enforces.
|
||||
//
|
||||
// The seed is deliberately the same set the fresh-database path writes, created
|
||||
// with FirstOrCreate on the normalized key so running both paths against one
|
||||
// database cannot produce duplicates.
|
||||
func migrateSYBProductFilterTable(db *gorm.DB, version string) error {
|
||||
if err := db.AutoMigrate(&models.SYBProductFilter{}); err != nil {
|
||||
return err
|
||||
}
|
||||
return db.Transaction(func(tx *gorm.DB) error {
|
||||
// 结构判据 is what defines a 档口 product; the keywords are the forward
|
||||
// safety net for the day those codes stop carrying "#" (#269).
|
||||
seed := []struct{ kind, keyword string }{
|
||||
{"char", "-"},
|
||||
{"char", "#"},
|
||||
{"keyword", "CVC短袖220斤"},
|
||||
{"keyword", "180克純棉圓領短袖"},
|
||||
{"keyword", "拼色純棉短袖"},
|
||||
{"keyword", "300斤牛奶絲圓領"},
|
||||
{"keyword", "印花大學T"},
|
||||
{"keyword", "印花帽T"},
|
||||
}
|
||||
for _, item := range seed {
|
||||
norm := sybshop.Normalize(item.keyword)
|
||||
row := models.SYBProductFilter{
|
||||
Kind: item.kind,
|
||||
Keyword: item.keyword,
|
||||
NormalizedKeyword: norm,
|
||||
Enabled: true,
|
||||
}
|
||||
if err := tx.Where("kind = ? AND normalized_keyword = ?", item.kind, norm).
|
||||
FirstOrCreate(&row).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return tx.Create(&common.Migration{Version: version}).Error
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package version_local
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"runtime"
|
||||
|
||||
"go-admin/app/goauto/access"
|
||||
"go-admin/cmd/migrate/migration"
|
||||
migrationmodels "go-admin/cmd/migrate/migration/models"
|
||||
common "go-admin/common/models"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func init() {
|
||||
_, file, _, _ := runtime.Caller(0)
|
||||
migration.Migrate.SetVersion(migration.GetFilename(file), migrateSYBProductFilterMenu)
|
||||
}
|
||||
|
||||
// migrateSYBProductFilterMenu adds the 「SYB 商品过滤」 menu entry for #269.
|
||||
//
|
||||
// `[必须]` Adding a module to access.GoAutoModules() is not enough to make its
|
||||
// page reachable. Menus are written by the versioned migration that first
|
||||
// created them, and that version is already recorded in sys_migration on every
|
||||
// existing database — it never runs again. Startup only reconciles the casbin
|
||||
// API matrix (access.ReconcilePurchaserPermissions), not sys_menu. Without this
|
||||
// migration the page exists, the API answers and the route resolves, yet nobody
|
||||
// can navigate to it on an already-migrated database.
|
||||
func migrateSYBProductFilterMenu(db *gorm.DB, version string) error {
|
||||
return db.Transaction(func(tx *gorm.DB) error {
|
||||
module, ok := goAutoModuleByKey(access.ModuleSYBProductFilters)
|
||||
if !ok {
|
||||
return fmt.Errorf("module %q is not registered in access.GoAutoModules()", access.ModuleSYBProductFilters)
|
||||
}
|
||||
|
||||
// The page belongs beside 「SYB 店铺」 under 采采管理, which is where the
|
||||
// admin already manages which SYB data enters the system.
|
||||
var parent migrationmodels.SysMenu
|
||||
if err := tx.Where("menu_name = ?", "GoAutoCollectionManagement").First(&parent).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
child, _, err := upsertGoAutoMenu(tx, migrationmodels.SysMenu{
|
||||
MenuName: module.RouteName,
|
||||
Title: module.Title,
|
||||
Icon: module.Icon,
|
||||
Path: module.Path + "/index",
|
||||
MenuType: "C",
|
||||
Action: "无",
|
||||
ParentId: parent.MenuId,
|
||||
Component: module.Component,
|
||||
Sort: module.Sort,
|
||||
Visible: "0",
|
||||
IsFrame: "1",
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err = tx.Model(&child).Update("paths", fmt.Sprintf("/0/%d/%d", parent.MenuId, child.MenuId)).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Bind to both roles the module declares. 采购员 needs it because the
|
||||
// module is PurchaserDefault; 管理员 needs it because only an admin may
|
||||
// change the rules (the write routes are RequireRoleKey("admin")).
|
||||
roleKeys := []string{"admin"}
|
||||
if module.PurchaserDefault {
|
||||
roleKeys = append(roleKeys, access.RolePurchaser)
|
||||
}
|
||||
for _, key := range roleKeys {
|
||||
var role migrationmodels.SysRole
|
||||
if err = tx.Where("role_key = ?", key).First(&role).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err = tx.Model(&role).Association("SysMenu").Append(&child); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return tx.Create(&common.Migration{Version: version}).Error
|
||||
})
|
||||
}
|
||||
|
||||
func goAutoModuleByKey(key string) (access.ModuleDefinition, bool) {
|
||||
for _, module := range access.GoAutoModules() {
|
||||
if module.Key == key {
|
||||
return module, true
|
||||
}
|
||||
}
|
||||
return access.ModuleDefinition{}, false
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package version_local
|
||||
|
||||
import (
|
||||
"go-admin/app/goauto/models"
|
||||
"go-admin/cmd/migrate/migration"
|
||||
common "go-admin/common/models"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func init() { migration.Migrate.SetVersion("1789200000000", migrateSYBParseStatusBinary) }
|
||||
|
||||
// migrateSYBParseStatusBinary folds legacy uncertain rows into failed and
|
||||
// narrows the check constraint to the two supported states (#272).
|
||||
func migrateSYBParseStatusBinary(db *gorm.DB, version string) error {
|
||||
if err := db.Model(&models.SYBProduct{}).Where("parse_status = ?", "uncertain").Update("parse_status", models.SYBParseStatusFailed).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if db.Dialector.Name() == "mysql" {
|
||||
if err := db.Exec("ALTER TABLE syb_product DROP CHECK ck_syb_product_parse_status").Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := db.Exec("ALTER TABLE syb_product ADD CONSTRAINT ck_syb_product_parse_status CHECK (parse_status IN ('success','failed'))").Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return db.Create(&common.Migration{Version: version}).Error
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package version_local
|
||||
|
||||
import (
|
||||
"runtime"
|
||||
|
||||
"go-admin/app/goauto/models"
|
||||
"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), migrateImageSearchCollection)
|
||||
}
|
||||
|
||||
// #278 permits image-search tasks while preserving existing task rows.
|
||||
func migrateImageSearchCollection(db *gorm.DB, version string) error {
|
||||
return db.Transaction(func(tx *gorm.DB) error {
|
||||
if !tx.Migrator().HasColumn(&models.CollectionTask{}, "ImageSearchSnapshot") {
|
||||
if err := tx.Migrator().AddColumn(&models.CollectionTask{}, "ImageSearchSnapshot"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if tx.Dialector.Name() == "mysql" {
|
||||
// MySQL does not support ALTER CHECK in place. The migration runner
|
||||
// records this version once, so dropping/recreating the named check is
|
||||
// deterministic and does not trigger a whole-schema AutoMigrate.
|
||||
_ = tx.Exec("ALTER TABLE collection_task DROP CHECK ck_collection_task_source").Error
|
||||
if err := tx.Exec("ALTER TABLE collection_task ADD CONSTRAINT ck_collection_task_source CHECK (source IN ('admin','agent_current_page','image_search'))").Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return tx.Create(&common.Migration{Version: version}).Error
|
||||
})
|
||||
}
|
||||
@@ -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), migrateImageSearchLinkMarker)
|
||||
}
|
||||
|
||||
// #279 stores whether the current Shopee→PDD link was created by image search.
|
||||
func migrateImageSearchLinkMarker(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
|
||||
})
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import request from '@/utils/request'
|
||||
export function listCollectionTasks(params) { return request({ url: '/api/admin/v1/collection-tasks', method: 'get', params }) }
|
||||
export function createCollectionTask(data) { return request({ url: '/api/admin/v1/collection-tasks', method: 'post', data }) }
|
||||
export function batchCreateCollectionTasks(data) { return request({ url: '/api/admin/v1/collection-tasks/batch', method: 'post', data }) }
|
||||
export function batchCreateImageSearchCollectionTasks(data) { return request({ url: '/api/admin/v1/collection-tasks/image-search/batch', method: 'post', data }) }
|
||||
export function getCollectionTask(taskId) { return request({ url: `/api/admin/v1/collection-tasks/${taskId}`, method: 'get' }) }
|
||||
export function resetCollectionTask(taskId, data) { return request({ url: `/api/admin/v1/collection-tasks/${taskId}/reset`, method: 'post', data }) }
|
||||
export function deleteCollectionTask(taskId, data) { return request({ url: `/api/admin/v1/collection-tasks/${taskId}`, method: 'delete', data }) }
|
||||
|
||||
@@ -8,16 +8,16 @@ export function createShopeeProduct(data) {
|
||||
return request({ url: '/api/admin/v1/shopee-products', method: 'post', data })
|
||||
}
|
||||
|
||||
export function getShopeeProduct(productId) {
|
||||
return request({ url: `/api/admin/v1/shopee-products/${productId}`, method: 'get' })
|
||||
export function getShopeeProduct(productId, options = {}) {
|
||||
return request({ url: `/api/admin/v1/shopee-products/${productId}`, method: 'get', ...options })
|
||||
}
|
||||
|
||||
export function updateShopeeProduct(productId, data) {
|
||||
return request({ url: `/api/admin/v1/shopee-products/${productId}`, method: 'patch', data })
|
||||
}
|
||||
|
||||
export function linkShopeeProductPdd(productId, data) {
|
||||
return request({ url: `/api/admin/v1/shopee-products/${productId}/link-pdd`, method: 'post', data })
|
||||
export function linkShopeeProductPdd(productId, data, options = {}) {
|
||||
return request({ url: `/api/admin/v1/shopee-products/${productId}/link-pdd`, method: 'post', data, ...options })
|
||||
}
|
||||
|
||||
export function restoreShopeeProduct(productId, data) {
|
||||
@@ -66,8 +66,11 @@ 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 autoMatchShopeeSpecMappings(productId, data, timeoutSeconds) {
|
||||
if (!Number.isInteger(timeoutSeconds) || timeoutSeconds < 16 || timeoutSeconds > 1210) {
|
||||
return Promise.reject(new Error('匹配等待时间不可用,请刷新页面后重试'))
|
||||
}
|
||||
return request({ url: `/api/admin/v1/shopee-products/${productId}/specs/mapping/auto-match`, method: 'post', data, timeout: (timeoutSeconds + 10) * 1000, suppressErrorMessage: true })
|
||||
}
|
||||
|
||||
export function startShopeeSpecAutoMatchRun(data) {
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
import request from '@/utils/request'
|
||||
const base = '/api/admin/v1/syb-product-filters'
|
||||
export function listSybProductFilters(params) { return request({ url: base, method: 'get', params }) }
|
||||
export function createSybProductFilter(data) { return request({ url: base, method: 'post', data }) }
|
||||
export function setSybProductFilterEnabled(id, data) { return request({ url: `${base}/${id}/enabled`, method: 'patch', data }) }
|
||||
export function deleteSybProductFilter(id) { return request({ url: `${base}/${id}`, method: 'delete' }) }
|
||||
@@ -0,0 +1,24 @@
|
||||
// Separate from the purchase-device preference; browser origin provides the
|
||||
// outer isolation and API environment + user identify the inner namespace.
|
||||
function key(userId) {
|
||||
const id = Number(userId)
|
||||
return Number.isSafeInteger(id) && id > 0 ? `goauto:quick-link-device:${encodeURIComponent(process.env.VUE_APP_BASE_API || '/')}:user:${id}` : null
|
||||
}
|
||||
|
||||
export function readQuickLinkDevice(userId) {
|
||||
try {
|
||||
const name = key(userId)
|
||||
if (!name) return null
|
||||
const id = JSON.parse(localStorage.getItem(name) || 'null')
|
||||
return Number.isSafeInteger(id) && id > 0 ? id : null
|
||||
} catch { return null }
|
||||
}
|
||||
|
||||
export function rememberQuickLinkDevice(userId, deviceId) {
|
||||
try {
|
||||
const name = key(userId)
|
||||
if (!name || !Number.isSafeInteger(deviceId) || deviceId <= 0) return false
|
||||
localStorage.setItem(name, JSON.stringify(deviceId))
|
||||
return true
|
||||
} catch { return false }
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user