Compare commits
50
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
04c42dca43 | ||
|
|
37e8b567e8 | ||
|
|
ade48a7a38 | ||
|
|
fe321b668c | ||
|
|
c292e58f75 | ||
|
|
a11ad65577 | ||
|
|
7ff2596646 | ||
|
|
074e5aab29 | ||
|
|
659bbc6e1a | ||
|
|
99faf5ad95 | ||
|
|
8a3367a4ea | ||
|
|
c80c746943 | ||
|
|
bec8c84321 | ||
|
|
7ea34e32c1 | ||
|
|
d1ddb04959 | ||
|
|
ca815c30de | ||
|
|
66e0bdfc07 | ||
|
|
93aab6a59b | ||
|
|
ec4802dfe7 | ||
|
|
ec8b14ae21 | ||
|
|
7ac1355a71 | ||
|
|
58a6c1c86d | ||
|
|
9d5242b371 | ||
|
|
9a10d82cf4 | ||
|
|
ac3c63ead6 | ||
|
|
71f7751754 | ||
|
|
76c94e33e0 | ||
|
|
57b0f1595f | ||
|
|
29ba16e4f9 | ||
|
|
ff2d0ca0e5 | ||
|
|
bf58ad0005 | ||
|
|
1900dab32e | ||
|
|
0cb36b1e73 | ||
|
|
3efe4f64a5 | ||
|
|
486dff29fd | ||
|
|
c8e5b99b0c | ||
|
|
666d19ad66 | ||
|
|
c2c1044dbb | ||
|
|
4e6afc2d25 | ||
|
|
7b9fcfb8c1 | ||
|
|
1f40a7fcb1 | ||
|
|
c9aaade6e9 | ||
|
|
5ee3b62906 | ||
|
|
81c1a7ead2 | ||
|
|
aa2ecc6ef7 | ||
|
|
f27ec16307 | ||
|
|
1d029d34c7 | ||
|
|
b12460825c | ||
|
|
d7924619b6 | ||
|
|
041cd8d03f |
@@ -11,8 +11,8 @@ android {
|
||||
applicationId = "cn.ilapage.goauto.agent"
|
||||
minSdk = 23
|
||||
targetSdk = 34
|
||||
versionCode = 62
|
||||
versionName = "0.9.49"
|
||||
versionCode = 85
|
||||
versionName = "0.9.72"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
|
||||
|
||||
+100
-19
@@ -330,6 +330,42 @@ class GoAutoAccessibilityService : AccessibilityService(), UiDriver, PddCollecto
|
||||
}
|
||||
}
|
||||
|
||||
override fun clickAddressEntryFresh(target: SnapshotNode): FreshClickOutcome {
|
||||
val root = rootInActiveWindow ?: return FreshClickOutcome(FreshActionResult.NOT_FOUND, FreshClickReason.ROOT_UNAVAILABLE)
|
||||
val indexes = target.path.split('/').mapNotNull(String::toIntOrNull)
|
||||
if (indexes.isEmpty() || indexes.first() != 0 || indexes.size != target.path.split('/').size) {
|
||||
return FreshClickOutcome(FreshActionResult.NOT_FOUND, FreshClickReason.TARGET_NOT_FOUND)
|
||||
}
|
||||
var node = root
|
||||
for (index in indexes.drop(1)) {
|
||||
node = node.getChild(index)
|
||||
?: return FreshClickOutcome(FreshActionResult.NOT_FOUND, FreshClickReason.TARGET_NOT_FOUND)
|
||||
}
|
||||
val bounds = Rect().also(node::getBoundsInScreen)
|
||||
if (!node.isVisibleToUser || !node.isEnabled ||
|
||||
node.preferredOrDescendantLabel() != target.label ||
|
||||
node.className?.toString() != target.className ||
|
||||
kotlin.math.abs(bounds.centerX() - target.bounds.centerX) > 32 ||
|
||||
kotlin.math.abs(bounds.centerY() - target.bounds.centerY) > 32
|
||||
) {
|
||||
return FreshClickOutcome(FreshActionResult.NOT_FOUND, FreshClickReason.TARGET_NOT_FOUND)
|
||||
}
|
||||
var ancestorDepth = 0
|
||||
while (!node.isClickable) {
|
||||
node = node.parent ?: return FreshClickOutcome(
|
||||
FreshActionResult.FAILED,
|
||||
FreshClickReason.NO_CLICKABLE_ANCESTOR,
|
||||
clickableAncestorDepth = ancestorDepth,
|
||||
)
|
||||
ancestorDepth++
|
||||
}
|
||||
return if (node.performAction(AccessibilityNodeInfo.ACTION_CLICK)) {
|
||||
FreshClickOutcome(FreshActionResult.SUCCESS, FreshClickReason.SUCCESS, 1, ancestorDepth)
|
||||
} else {
|
||||
FreshClickOutcome(FreshActionResult.FAILED, FreshClickReason.ACTION_CLICK_FALSE, 1, ancestorDepth)
|
||||
}
|
||||
}
|
||||
|
||||
override fun tapPurchaseFresh(target: SnapshotNode): FreshActionResult {
|
||||
val root = rootInActiveWindow ?: return FreshActionResult.NOT_FOUND
|
||||
val candidates = mutableListOf<AccessibilityNodeInfo>()
|
||||
@@ -398,8 +434,16 @@ 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 {
|
||||
val root = rootInActiveWindow ?: return false
|
||||
lastPurchaseSwipeFailure = PurchaseSwipeFailureReason.UNKNOWN
|
||||
val root = rootInActiveWindow ?: run {
|
||||
lastPurchaseSwipeFailure = PurchaseSwipeFailureReason.ROOT_UNAVAILABLE
|
||||
return false
|
||||
}
|
||||
val candidates = mutableListOf<AccessibilityNodeInfo>()
|
||||
walk(root) { node -> if (node.isVisibleToUser && node.isScrollable) candidates += node }
|
||||
val horizontal = direction == SwipeDirection.LEFT || direction == SwipeDirection.RIGHT
|
||||
@@ -408,23 +452,37 @@ class GoAutoAccessibilityService : AccessibilityService(), UiDriver, PddCollecto
|
||||
}
|
||||
val target = (directional.ifEmpty { candidates }).maxByOrNull { candidate ->
|
||||
Rect().also(candidate::getBoundsInScreen).let { it.width().toLong() * it.height() }
|
||||
} ?: return false
|
||||
return swipeNode(target, direction, durationMs, preferScrollAction = false)
|
||||
} ?: run {
|
||||
lastPurchaseSwipeFailure = PurchaseSwipeFailureReason.NO_SCROLLABLE
|
||||
return false
|
||||
}
|
||||
return swipeNode(target, direction, durationMs, preferScrollAction = false,
|
||||
onFailure = { lastPurchaseSwipeFailure = it })
|
||||
}
|
||||
|
||||
override fun swipePurchaseIn(target: SnapshotNode, direction: SwipeDirection, durationMs: Long): Boolean {
|
||||
val root = rootInActiveWindow ?: return false
|
||||
val matches = mutableListOf<AccessibilityNodeInfo>()
|
||||
walk(root) { node ->
|
||||
val bounds = Rect().also(node::getBoundsInScreen)
|
||||
if (node.isVisibleToUser && node.isEnabled && node.isScrollable &&
|
||||
node.className?.toString() == target.className &&
|
||||
kotlin.math.abs(bounds.centerX() - target.bounds.centerX) <= 32 &&
|
||||
kotlin.math.abs(bounds.centerY() - target.bounds.centerY) <= 32
|
||||
) matches += node
|
||||
val candidates = mutableListOf<PurchaseScrollCandidate>()
|
||||
val liveNodes = mutableMapOf<String, AccessibilityNodeInfo>()
|
||||
fun visit(node: AccessibilityNodeInfo, path: String) {
|
||||
if (node.isVisibleToUser && node.isEnabled && node.isScrollable) {
|
||||
val bounds = Rect().also(node::getBoundsInScreen)
|
||||
candidates += PurchaseScrollCandidate(
|
||||
path, node.className?.toString(),
|
||||
NodeBounds(bounds.left, bounds.top, bounds.right, bounds.bottom),
|
||||
)
|
||||
liveNodes[path] = node
|
||||
}
|
||||
for (index in 0 until node.childCount) {
|
||||
node.getChild(index)?.let { visit(it, "$path/$index") }
|
||||
}
|
||||
}
|
||||
if (matches.size != 1) return false
|
||||
return swipeNode(matches.single(), direction, durationMs, preferScrollAction = true)
|
||||
visit(root, "0")
|
||||
val resolved = PurchaseScrollLocator.locate(
|
||||
PurchaseScrollCandidate(target.path, target.className, target.bounds), candidates,
|
||||
) ?: return false
|
||||
val current = liveNodes[resolved.path] ?: return false
|
||||
return swipeNode(current, direction, durationMs, preferScrollAction = true)
|
||||
}
|
||||
|
||||
override fun backPurchase(): Boolean = performGlobalAction(GLOBAL_ACTION_BACK)
|
||||
@@ -438,9 +496,17 @@ class GoAutoAccessibilityService : AccessibilityService(), UiDriver, PddCollecto
|
||||
}.getOrDefault(false)
|
||||
}
|
||||
|
||||
private var lastSpecRowSwipeFailure = "none"
|
||||
|
||||
override fun specRowSwipeFailureReason(): String = lastSpecRowSwipeFailure
|
||||
|
||||
override fun swipeSpec(direction: SwipeDirection, anchor: SnapshotNode?): Boolean {
|
||||
lastSpecRowSwipeFailure = "none"
|
||||
if (anchor == null) return swipe(SemanticTarget.SPEC_PANEL, direction)
|
||||
val root = rootInActiveWindow ?: return false
|
||||
val root = rootInActiveWindow ?: run {
|
||||
lastSpecRowSwipeFailure = "windowMissing"
|
||||
return false
|
||||
}
|
||||
val matches = mutableListOf<AccessibilityNodeInfo>()
|
||||
walk(root) { node ->
|
||||
val bounds = Rect().also(node::getBoundsInScreen)
|
||||
@@ -458,6 +524,7 @@ class GoAutoAccessibilityService : AccessibilityService(), UiDriver, PddCollecto
|
||||
val horizontal = direction == SwipeDirection.LEFT || direction == SwipeDirection.RIGHT
|
||||
if (matches.size != 1) {
|
||||
if (!SpecSwipeSafety.allowGlobalFallback(direction)) {
|
||||
lastSpecRowSwipeFailure = if (matches.isEmpty()) "anchorMissing" else "anchorAmbiguous"
|
||||
Log.i("GoAutoCollector", "swipe direction=$direction result=blocked reason=anchor-not-unique matches=${matches.size}")
|
||||
return false
|
||||
}
|
||||
@@ -481,19 +548,27 @@ class GoAutoAccessibilityService : AccessibilityService(), UiDriver, PddCollecto
|
||||
if (anchoredTarget != null) {
|
||||
val bounds = Rect().also(anchoredTarget::getBoundsInScreen)
|
||||
Log.i("GoAutoCollector", "swipe direction=$direction anchored=true bounds=$bounds class=${anchoredTarget.className}")
|
||||
return swipeNode(
|
||||
val success = swipeNode(
|
||||
anchoredTarget,
|
||||
direction,
|
||||
preferScrollAction = SpecSwipeSafety.preferAccessibilityScrollAction(direction),
|
||||
)
|
||||
if (!success) lastSpecRowSwipeFailure = "gestureFailed"
|
||||
return success
|
||||
}
|
||||
if (!SpecSwipeSafety.allowGlobalFallback(direction)) {
|
||||
lastSpecRowSwipeFailure = "horizontalContainerMissing"
|
||||
Log.i("GoAutoCollector", "swipe direction=$direction result=blocked reason=no-anchored-horizontal-container")
|
||||
return false
|
||||
}
|
||||
return swipe(SemanticTarget.SPEC_PANEL, direction)
|
||||
}
|
||||
|
||||
override fun swipeSpecRow(target: SnapshotNode, direction: SwipeDirection): Boolean {
|
||||
if (direction != SwipeDirection.LEFT && direction != SwipeDirection.RIGHT) return false
|
||||
return swipeSpec(direction, target)
|
||||
}
|
||||
|
||||
override fun pullDownSpecPanel(anchor: SnapshotNode): Boolean {
|
||||
if (!anchor.scrollable) return false
|
||||
val root = rootInActiveWindow ?: return false
|
||||
@@ -583,16 +658,21 @@ 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 false
|
||||
if (bounds.width() < 2 || bounds.height() < 2) return failed(PurchaseSwipeFailureReason.INVALID_BOUNDS)
|
||||
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 false
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) return failed(PurchaseSwipeFailureReason.GESTURE_UNSUPPORTED)
|
||||
val left = bounds.left + bounds.width() * 25 / 100
|
||||
val right = bounds.left + bounds.width() * 75 / 100
|
||||
val top = bounds.top + bounds.height() * 25 / 100
|
||||
@@ -630,8 +710,9 @@ class GoAutoAccessibilityService : AccessibilityService(), UiDriver, PddCollecto
|
||||
},
|
||||
null,
|
||||
)
|
||||
if (!queued) return false
|
||||
return latch.await(1500, TimeUnit.MILLISECONDS) && completed.get()
|
||||
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)
|
||||
}
|
||||
|
||||
private fun dispatchCenterTap(bounds: Rect): Boolean {
|
||||
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package cn.ilapage.goauto.agent.automation
|
||||
|
||||
internal object PddLaunchFallback {
|
||||
fun open(preferDirect: Boolean, direct: () -> Boolean, browser: () -> Boolean): Boolean {
|
||||
if (preferDirect && runCatching(direct).getOrDefault(false)) return true
|
||||
return runCatching(browser).getOrDefault(false)
|
||||
}
|
||||
}
|
||||
@@ -31,11 +31,21 @@ object PddPageClassifier {
|
||||
}
|
||||
|
||||
class PddLinkLauncher(private val context: Context) {
|
||||
fun open(url: String): Boolean {
|
||||
fun open(url: String, preferDirect: Boolean = false): Boolean {
|
||||
val uri = runCatching { Uri.parse(url) }.getOrNull() ?: return false
|
||||
if (uri.scheme !in setOf("http", "https") || !isPddHost(uri.host) || uri.getQueryParameter("goods_id").isNullOrBlank()) {
|
||||
return false
|
||||
}
|
||||
return PddLaunchFallback.open(preferDirect, direct = {
|
||||
val direct = Intent(Intent.ACTION_VIEW, uri)
|
||||
.setPackage("com.xunmeng.pinduoduo")
|
||||
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK)
|
||||
context.startActivity(direct)
|
||||
true
|
||||
}, browser = { openBrowser(uri) })
|
||||
}
|
||||
|
||||
private fun openBrowser(uri: Uri): Boolean {
|
||||
val base = Intent(Intent.ACTION_VIEW, uri).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
val browser = BROWSER_PACKAGES.firstOrNull { packageName ->
|
||||
runCatching { context.packageManager.getPackageInfo(packageName, 0) }.isSuccess
|
||||
|
||||
+50
-2
@@ -99,6 +99,8 @@ object PddSoldOutRecoveryDefaults {
|
||||
const val SETTLE_MILLIS = 2_000L
|
||||
}
|
||||
|
||||
data class PurchasePanelContext(val container: SnapshotNode, val exactColor: String)
|
||||
|
||||
data class ParsedPddScreen(
|
||||
val summary: ProductSummary,
|
||||
val dimensions: List<VisibleDimension>,
|
||||
@@ -128,6 +130,10 @@ data class ParsedPddScreen(
|
||||
val bottomPurchaseEntryCount: Int,
|
||||
val problem: PageProblem?,
|
||||
val sourceNodes: List<SnapshotNode>,
|
||||
val isPddPackage: Boolean,
|
||||
val hasCloseControl: Boolean = false,
|
||||
val hasPaymentArea: Boolean = false,
|
||||
val purchaseContextMatched: Boolean = false,
|
||||
) {
|
||||
fun isTransientSoldOut(
|
||||
exactText: String,
|
||||
@@ -162,6 +168,7 @@ data class ParsedPddScreen(
|
||||
}
|
||||
|
||||
object PddScreenParser {
|
||||
private const val PDD_PACKAGE = "com.xunmeng.pinduoduo"
|
||||
private data class SafeSpecEntry(val anchor: SnapshotNode, val clickTarget: SnapshotNode)
|
||||
private val pricePattern = Regex("[¥¥]\\s*([0-9]+(?:\\.[0-9]{1,2})?)")
|
||||
private val salesPattern = Regex("已拼\\s*[0-9]+(?:\\.[0-9]+)?\\s*(?:万|亿)?\\s*\\+?\\s*(?:件|人)?")
|
||||
@@ -179,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?): 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)
|
||||
@@ -304,9 +311,24 @@ object PddScreenParser {
|
||||
// Opening evidence is intentionally independent from whether the current
|
||||
// viewport still exposes a clickable spec value. PDD may hide the only
|
||||
// selected value or restore a previously scrolled confirmation panel.
|
||||
val continuedPurchasePanel = snapshot.packageName == PDD_PACKAGE && problem == null &&
|
||||
purchaseContext != null && headedPanelScrollable != null &&
|
||||
headedPanelScrollable.path == purchaseContext.container.path &&
|
||||
headedPanelScrollable.className == purchaseContext.container.className &&
|
||||
kotlin.math.abs(headedPanelScrollable.bounds.left - purchaseContext.container.bounds.left) <= 32 &&
|
||||
kotlin.math.abs(headedPanelScrollable.bounds.right - purchaseContext.container.bounds.right) <= 32 &&
|
||||
minOf(headedPanelScrollable.bounds.bottom, purchaseContext.container.bounds.bottom) >
|
||||
maxOf(headedPanelScrollable.bounds.top, purchaseContext.container.bounds.top)
|
||||
val structuredSelectionPanel = headedPanelScrollable != null &&
|
||||
(headings.size >= 2 || (continuedPurchasePanel && headings.isNotEmpty())) &&
|
||||
dimensions.any { it.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
|
||||
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
|
||||
@@ -320,6 +342,28 @@ object PddScreenParser {
|
||||
else -> SpecPanelType.UNKNOWN
|
||||
}
|
||||
val panelOpen = specPanelType != SpecPanelType.UNKNOWN
|
||||
val unprefixedSummary = if (structuredSelectionPanel) {
|
||||
val quantity = quantityInputs.single()
|
||||
val knownColors = (dimensions.filter { it.key == "color" }.flatMap { it.values }.map { it.text } +
|
||||
listOfNotNull(purchaseContext?.exactColor?.takeIf { continuedPurchasePanel && it.isNotBlank() })).distinct()
|
||||
val byPath = visibleNodes.associateBy { it.path }
|
||||
var region = quantity.parentPath?.let(byPath::get)
|
||||
var summary: String? = null
|
||||
while (region != null && region.bounds.bottom <= headedPanelScrollable!!.bounds.top) {
|
||||
val candidates = visible.filter { node ->
|
||||
node.path.startsWith("${region!!.path}/") && !node.clickable &&
|
||||
node.className?.endsWith("TextView") == true &&
|
||||
node.bounds.bottom <= quantity.bounds.top && node.label.length <= 160 &&
|
||||
knownColors.any { SpecValueNormalizer.summaryHasExactToken(node.label, it) }
|
||||
}.map { it.label }.distinct()
|
||||
if (candidates.isNotEmpty()) {
|
||||
summary = candidates.singleOrNull()
|
||||
break
|
||||
}
|
||||
region = region.parentPath?.let(byPath::get)
|
||||
}
|
||||
summary
|
||||
} else null
|
||||
val firstHeadingTop = headings.firstOrNull()?.bounds?.top ?: Int.MAX_VALUE
|
||||
val price = visible.asSequence()
|
||||
.filter { it.bounds.top < firstHeadingTop }
|
||||
@@ -371,7 +415,7 @@ object PddScreenParser {
|
||||
selectedSummary = labels.firstOrNull {
|
||||
val compact = it.replace(" ", "")
|
||||
textAliases.selection.selectedPrefixes.any(compact::startsWith)
|
||||
},
|
||||
} ?: unprefixedSummary,
|
||||
priceCent = price,
|
||||
specPanelOpen = panelOpen,
|
||||
specPanelType = specPanelType,
|
||||
@@ -390,6 +434,9 @@ object PddScreenParser {
|
||||
panelHeadingCount = headings.size,
|
||||
panelOptionCount = dimensions.sumOf { it.values.size },
|
||||
hasSelectionSummary = hasSelectionSummary,
|
||||
hasCloseControl = hasClose,
|
||||
hasPaymentArea = hasPaymentArea,
|
||||
purchaseContextMatched = continuedPurchasePanel,
|
||||
hasQuantityControls = hasQuantityControls,
|
||||
hasOrderSubmitAction = hasOrderSubmitAction,
|
||||
explicitSpecEntryCount = explicitSpecEntries.size,
|
||||
@@ -397,6 +444,7 @@ object PddScreenParser {
|
||||
bottomPurchaseEntryCount = bottomSpecEntries.size,
|
||||
problem = problem,
|
||||
sourceNodes = visibleNodes,
|
||||
isPddPackage = snapshot.packageName == PDD_PACKAGE,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+334
-23
@@ -34,6 +34,9 @@ class PurchaseLiveAutomation(
|
||||
private val pause: (Long) -> Unit = Thread::sleep,
|
||||
) {
|
||||
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
|
||||
|
||||
@@ -42,7 +45,7 @@ class PurchaseLiveAutomation(
|
||||
* page. Only the selector's unique exact confirm button is clickable; an
|
||||
* order-submit or payment control can never satisfy this transition.
|
||||
*/
|
||||
fun advanceToOrderConfirmation() {
|
||||
fun advanceToOrderConfirmation(allowUnclassifiedPanelWithSelectionProof: Boolean = false) {
|
||||
var snapshot = driver.capture()
|
||||
pageProblem(snapshot)
|
||||
if (orderConfirmationReady(snapshot)) return
|
||||
@@ -50,7 +53,16 @@ class PurchaseLiveAutomation(
|
||||
fail("PURCHASE_SPEC_CONFIRMATION_NOT_READY", "当前不是拼多多规格页面,未创建订单")
|
||||
}
|
||||
val screen = PddScreenParser.parse(snapshot, PurchaseRehearsalExecutor.DEFAULT_COLLECTOR, "", null)
|
||||
if (screen.specPanelType !in setOf(SpecPanelType.NORMAL_SCROLLABLE, SpecPanelType.NON_SCROLLABLE_CONFIRMATION)) {
|
||||
val unclassifiedSelectionPanelWithProof = allowUnclassifiedPanelWithSelectionProof &&
|
||||
screen.specPanelType == SpecPanelType.UNKNOWN &&
|
||||
screen.priceCent != null &&
|
||||
snapshot.nodes.count {
|
||||
it.visible && it.enabled && it.className?.endsWith("EditText") == true && it.label.toLongOrNull() != null
|
||||
} == 1
|
||||
if (
|
||||
screen.specPanelType !in setOf(SpecPanelType.NORMAL_SCROLLABLE, SpecPanelType.NON_SCROLLABLE_CONFIRMATION) &&
|
||||
!unclassifiedSelectionPanelWithProof
|
||||
) {
|
||||
fail("PURCHASE_SPEC_CONFIRMATION_NOT_READY", "当前规格面板不能安全确认,未创建订单")
|
||||
}
|
||||
val aliases = PurchaseRehearsalExecutor.DEFAULT_COLLECTOR.textAliases.specPanel.confirmAliases
|
||||
@@ -86,12 +98,31 @@ class PurchaseLiveAutomation(
|
||||
var unchangedCount = 0
|
||||
for (attempt in 0 until 5) {
|
||||
pageProblem(snapshot)
|
||||
val entries = mergedDirect(snapshot.nodes.filter { it.visible && it.enabled && MASKED_PHONE.containsMatchIn(it.label) })
|
||||
if (entries.size == 1) {
|
||||
when (driver.tapPurchaseFresh(entries.single())) {
|
||||
val resolution = resolveAddressEntries(snapshot)
|
||||
if (resolution.addressCardCount > 1 || resolution.tapTargetCount > 1) {
|
||||
fail("PURCHASE_ADDRESS_ENTRY_AMBIGUOUS", "收货地址入口不唯一,未创建订单 [${resolution.diagnostic()}]")
|
||||
}
|
||||
if (resolution.target != null) {
|
||||
pause(ADDRESS_ENTRY_STABLE_INTERVAL_MS)
|
||||
val stableSnapshot = driver.capture()
|
||||
pageProblem(stableSnapshot)
|
||||
val stableResolution = resolveAddressEntries(stableSnapshot)
|
||||
if (stableResolution.addressCardCount > 1 || stableResolution.tapTargetCount > 1) {
|
||||
fail("PURCHASE_ADDRESS_ENTRY_AMBIGUOUS", "收货地址入口不唯一,未创建订单 [${stableResolution.diagnostic()}]")
|
||||
}
|
||||
if (stableResolution.target == null || stableResolution.fingerprint() != resolution.fingerprint()) {
|
||||
snapshot = stableSnapshot
|
||||
continue
|
||||
}
|
||||
val target = stableResolution.target
|
||||
val result = when (target.activation) {
|
||||
AddressEntryActivation.CLICK -> driver.clickAddressEntryFresh(target.node).result
|
||||
AddressEntryActivation.TAP -> driver.tapPurchaseFresh(target.node)
|
||||
}
|
||||
when (result) {
|
||||
FreshActionResult.SUCCESS -> Unit
|
||||
FreshActionResult.AMBIGUOUS -> fail("PURCHASE_ADDRESS_ENTRY_AMBIGUOUS", "收货地址入口不唯一,未创建订单")
|
||||
else -> fail("PURCHASE_ADDRESS_ENTRY_NOT_READY", "收货地址入口点击失败,未创建订单")
|
||||
FreshActionResult.AMBIGUOUS -> fail("PURCHASE_ADDRESS_ENTRY_AMBIGUOUS", "收货地址入口不唯一,未创建订单 [${stableResolution.diagnostic()}]")
|
||||
else -> fail("PURCHASE_ADDRESS_ENTRY_NOT_READY", "收货地址入口点击失败,未创建订单 [${stableResolution.diagnostic()}]")
|
||||
}
|
||||
pause(1_000)
|
||||
snapshot = waitFor("PURCHASE_ADDRESS_PANEL_TIMEOUT", "收货地址页面打开超时,未创建订单") {
|
||||
@@ -99,7 +130,9 @@ class PurchaseLiveAutomation(
|
||||
}
|
||||
return editAndVerifyAddress(snapshot, addressSuffix)
|
||||
}
|
||||
if (entries.size > 1) fail("PURCHASE_ADDRESS_ENTRY_AMBIGUOUS", "收货地址入口不唯一,未创建订单")
|
||||
if (resolution.phoneNodeCount > 0) {
|
||||
fail("PURCHASE_ADDRESS_ENTRY_NOT_READY", "收货地址入口无法安全定位,未创建订单 [${resolution.diagnostic()}]")
|
||||
}
|
||||
val panel = purchasePanelScrollTargets(snapshot)
|
||||
if (panel.size != 1) fail("PURCHASE_ADDRESS_ENTRY_AMBIGUOUS", "没有找到唯一的规格面板滚动区域,未创建订单")
|
||||
val signature = viewportSignature(snapshot, panel.single())
|
||||
@@ -115,11 +148,167 @@ class PurchaseLiveAutomation(
|
||||
fail("PURCHASE_ADDRESS_PANEL_TIMEOUT", "规格面板下拉后仍未找到收货地址,未创建订单")
|
||||
}
|
||||
|
||||
private enum class AddressEntryActivation { CLICK, TAP }
|
||||
|
||||
private data class AddressEntryTarget(
|
||||
val node: SnapshotNode,
|
||||
val activation: AddressEntryActivation,
|
||||
val cardKey: String,
|
||||
)
|
||||
|
||||
private data class AddressCardCandidate(
|
||||
val phone: SnapshotNode,
|
||||
val clickableCard: SnapshotNode?,
|
||||
val semanticKey: String?,
|
||||
)
|
||||
|
||||
private data class AddressEntryResolution(
|
||||
val phoneNodeCount: Int,
|
||||
val addressCardCount: Int,
|
||||
val tapTargetCount: Int,
|
||||
val target: AddressEntryTarget?,
|
||||
) {
|
||||
fun diagnostic(): String =
|
||||
"phoneNodes=$phoneNodeCount;addressCards=$addressCardCount;tapTargets=$tapTargetCount"
|
||||
|
||||
fun fingerprint(): String? = target?.let {
|
||||
listOf(
|
||||
it.activation.name,
|
||||
it.cardKey,
|
||||
it.node.label,
|
||||
it.node.className.orEmpty(),
|
||||
it.node.bounds.left,
|
||||
it.node.bounds.top,
|
||||
it.node.bounds.right,
|
||||
it.node.bounds.bottom,
|
||||
).joinToString(":")
|
||||
}
|
||||
}
|
||||
|
||||
private fun resolveAddressEntries(snapshot: UiSnapshot): AddressEntryResolution {
|
||||
val phones = snapshot.nodes.filter {
|
||||
it.visible && it.enabled && maskedPhone(it.label) != null
|
||||
}.distinctBy { it.path }
|
||||
if (phones.isEmpty()) return AddressEntryResolution(0, 0, 0, null)
|
||||
|
||||
val byPath = snapshot.nodes.associateBy(SnapshotNode::path)
|
||||
val candidates = phones.map { phone ->
|
||||
val card = nearestClickableAncestor(phone, byPath)
|
||||
AddressCardCandidate(phone, card, card?.let { addressCardSemanticKey(it, snapshot) })
|
||||
}
|
||||
val groups = mutableListOf<MutableList<AddressCardCandidate>>()
|
||||
candidates.forEach { candidate ->
|
||||
val matching = groups.filter { group -> group.any { sameLogicalAddress(it, candidate) } }
|
||||
if (matching.isEmpty()) {
|
||||
groups += mutableListOf(candidate)
|
||||
} else {
|
||||
val primary = matching.first()
|
||||
primary += candidate
|
||||
matching.drop(1).forEach { duplicate ->
|
||||
primary += duplicate
|
||||
groups.remove(duplicate)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val targets = groups.mapIndexed { index, group ->
|
||||
val clickable = group.filter { it.clickableCard != null }
|
||||
.minWithOrNull(compareBy<AddressCardCandidate>({ nodeArea(requireNotNull(it.clickableCard)) }, { it.phone.path }))
|
||||
if (clickable != null) {
|
||||
AddressEntryTarget(clickable.phone, AddressEntryActivation.CLICK, logicalAddressKey(group, index))
|
||||
} else {
|
||||
AddressEntryTarget(group.minBy { nodeArea(it.phone) }.phone, AddressEntryActivation.TAP, logicalAddressKey(group, index))
|
||||
}
|
||||
}
|
||||
|
||||
val actionable = targets.filter { it.node.bounds.width >= 2 && it.node.bounds.height >= 2 }
|
||||
return AddressEntryResolution(
|
||||
phoneNodeCount = phones.size,
|
||||
addressCardCount = targets.size,
|
||||
tapTargetCount = actionable.size,
|
||||
target = actionable.singleOrNull(),
|
||||
)
|
||||
}
|
||||
|
||||
private fun nearestClickableAncestor(
|
||||
source: SnapshotNode,
|
||||
byPath: Map<String, SnapshotNode>,
|
||||
): SnapshotNode? {
|
||||
var current: SnapshotNode? = source
|
||||
while (current != null) {
|
||||
if (current.clickable && current.visible && current.enabled) return current
|
||||
current = current.parentPath?.let(byPath::get)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private fun addressCardSemanticKey(card: SnapshotNode, snapshot: UiSnapshot): String? {
|
||||
val labels = snapshot.nodes.asSequence()
|
||||
.filter { it.visible && (it.path == card.path || it.path.startsWith("${card.path}/")) }
|
||||
.map { normalizeAddressSemantic(it.label) }
|
||||
.filter { it.isNotBlank() && maskedPhone(it) == null && it !in ADDRESS_CARD_GENERIC_LABELS }
|
||||
.distinct()
|
||||
.sorted()
|
||||
.toList()
|
||||
if (labels.none { it.length >= ADDRESS_SEMANTIC_MIN_LENGTH }) return null
|
||||
return labels.joinToString("|")
|
||||
}
|
||||
|
||||
private fun normalizeAddressSemantic(value: String): String = value
|
||||
.replace(Regex("\\s+"), "")
|
||||
.replace(",", ",")
|
||||
.replace(":", ":")
|
||||
.lowercase()
|
||||
|
||||
private fun sameLogicalAddress(first: AddressCardCandidate, second: AddressCardCandidate): Boolean {
|
||||
if (maskedPhone(first.phone.label) != maskedPhone(second.phone.label)) return false
|
||||
if (first.clickableCard?.path != null && first.clickableCard.path == second.clickableCard?.path) return true
|
||||
if (sameAddressRegion(first.phone, second.phone)) return true
|
||||
val firstCard = first.clickableCard
|
||||
val secondCard = second.clickableCard
|
||||
if (firstCard != null && secondCard != null && boundsRepresentSameRegion(firstCard.bounds, secondCard.bounds)) return true
|
||||
return first.semanticKey != null && first.semanticKey == second.semanticKey
|
||||
}
|
||||
|
||||
private fun boundsRepresentSameRegion(first: NodeBounds, second: NodeBounds): Boolean {
|
||||
val overlapWidth = (minOf(first.right, second.right) - maxOf(first.left, second.left)).coerceAtLeast(0)
|
||||
val overlapHeight = (minOf(first.bottom, second.bottom) - maxOf(first.top, second.top)).coerceAtLeast(0)
|
||||
val overlap = overlapWidth.toLong() * overlapHeight
|
||||
val smaller = minOf(first.width.toLong() * first.height, second.width.toLong() * second.height)
|
||||
return smaller > 0 && overlap * 100 >= smaller * ADDRESS_CARD_OVERLAP_PERCENT
|
||||
}
|
||||
|
||||
private fun logicalAddressKey(group: List<AddressCardCandidate>, index: Int): String {
|
||||
val semantic = group.mapNotNull(AddressCardCandidate::semanticKey).distinct().singleOrNull()
|
||||
return semantic?.let { "semantic:$it" } ?: "geometry:$index:${group.minOf { it.phone.bounds.top }}"
|
||||
}
|
||||
|
||||
private fun sameAddressRegion(first: SnapshotNode, second: SnapshotNode): Boolean {
|
||||
if (maskedPhone(first.label) != maskedPhone(second.label)) return false
|
||||
val overlapWidth = (minOf(first.bounds.right, second.bounds.right) - maxOf(first.bounds.left, second.bounds.left)).coerceAtLeast(0)
|
||||
val overlapHeight = (minOf(first.bounds.bottom, second.bounds.bottom) - maxOf(first.bounds.top, second.bounds.top)).coerceAtLeast(0)
|
||||
val overlap = overlapWidth.toLong() * overlapHeight
|
||||
val smaller = minOf(nodeArea(first), nodeArea(second))
|
||||
val containsCenter = (
|
||||
first.bounds.centerX in second.bounds.left..second.bounds.right &&
|
||||
first.bounds.centerY in second.bounds.top..second.bounds.bottom
|
||||
) || (
|
||||
second.bounds.centerX in first.bounds.left..first.bounds.right &&
|
||||
second.bounds.centerY in first.bounds.top..first.bounds.bottom
|
||||
)
|
||||
return (smaller > 0 && overlap * 100 >= smaller * 50) || containsCenter
|
||||
}
|
||||
|
||||
private fun maskedPhone(value: String): String? = MASKED_PHONE.find(value)?.value
|
||||
|
||||
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 (!hasFinalSavedAddressEvidence(snapshot, address.expectedAddress, address.suffix)) {
|
||||
if (!hasSavedAddressEvidence(snapshot, address.expectedAddress, address.suffix)) {
|
||||
fail("PURCHASE_ADDRESS_UPDATE_FAILED", "收货地址保存后复核失败,未创建订单")
|
||||
}
|
||||
val submit = finalSubmitTargets(snapshot)
|
||||
@@ -133,6 +322,7 @@ 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())
|
||||
}
|
||||
|
||||
@@ -144,6 +334,18 @@ 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
|
||||
@@ -281,6 +483,9 @@ 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(), "修改地址")
|
||||
@@ -300,18 +505,27 @@ 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(), "保存地址")
|
||||
val savedEvidence = waitForStableAddressEditorExit()
|
||||
if (!hasFinalSavedAddressEvidence(savedEvidence, expected, suffix)) {
|
||||
var savedEvidence = waitForStableAddressEditorExit()
|
||||
if (!savedAddressPageReady(savedEvidence, expected, suffix)) {
|
||||
if (isPurchaseConfirmationPanel(savedEvidence)) {
|
||||
restoreFinalEvidenceInCurrentPanel(savedEvidence, expected, suffix)
|
||||
savedEvidence = restoreFinalEvidenceInCurrentPanel(savedEvidence, expected, suffix)
|
||||
} else {
|
||||
if (!driver.backPurchase()) fail("PURCHASE_ADDRESS_UPDATE_FAILED", "地址保存后无法返回订单页面,未创建订单")
|
||||
waitFor("PURCHASE_ADDRESS_SAVE_TIMEOUT", "地址保存后无法返回订单页面,未创建订单") {
|
||||
hasFinalSavedAddressEvidence(it, expected, suffix)
|
||||
val returned = waitFor("PURCHASE_ADDRESS_SAVE_TIMEOUT", "地址保存后无法返回订单页面,未创建订单") {
|
||||
savedAddressPageReady(it, expected, suffix) || isPurchaseConfirmationPanel(it)
|
||||
}
|
||||
savedEvidence = if (!savedAddressPageReady(returned, expected, suffix)) {
|
||||
restoreFinalEvidenceInCurrentPanel(returned, expected, suffix)
|
||||
} else returned
|
||||
}
|
||||
}
|
||||
return ShippingAddressProof(expected, suffix)
|
||||
val proof = ShippingAddressProof(expected, suffix)
|
||||
savedAddressProof = proof
|
||||
savedAddressActivity = savedEvidence.activityName
|
||||
if (finalSubmitTargets(savedEvidence).size != 1) {
|
||||
fail("PURCHASE_SUBMIT_TARGET_AMBIGUOUS", "地址已保存,但未能识别唯一的下单按钮,未创建订单")
|
||||
}
|
||||
return proof
|
||||
}
|
||||
|
||||
private fun isPurchaseConfirmationPanel(snapshot: UiSnapshot): Boolean {
|
||||
@@ -328,17 +542,18 @@ class PurchaseLiveAutomation(
|
||||
initial: UiSnapshot,
|
||||
expected: String,
|
||||
suffix: String,
|
||||
) {
|
||||
): UiSnapshot {
|
||||
var snapshot = initial
|
||||
var previousSignature: String? = null
|
||||
repeat(ADDRESS_CONFIRMATION_SCROLL_LIMIT) { attempt ->
|
||||
if (hasFinalSavedAddressEvidence(snapshot, expected, suffix)) return
|
||||
if (savedAddressPageReady(snapshot, expected, suffix)) return snapshot
|
||||
if (!isPurchaseConfirmationPanel(snapshot)) {
|
||||
fail("PURCHASE_ADDRESS_SAVE_TIMEOUT", "地址保存后采购面板发生变化,未创建订单")
|
||||
}
|
||||
val panels = purchasePanelScrollTargets(snapshot)
|
||||
val panels = savedAddressPanelScrollTargets(snapshot)
|
||||
if (panels.size != 1) {
|
||||
fail("PURCHASE_ADDRESS_ENTRY_AMBIGUOUS", "地址保存后没有找到唯一的规格面板滚动区域,未创建订单")
|
||||
val reason = if (panels.isEmpty()) "未找到可用的规格面板滚动区域" else "找到多个规格面板滚动区域"
|
||||
fail("PURCHASE_ADDRESS_ENTRY_AMBIGUOUS", "地址保存后${reason},未创建订单")
|
||||
}
|
||||
val panel = panels.single()
|
||||
val signature = viewportSignature(snapshot, panel)
|
||||
@@ -353,9 +568,10 @@ class PurchaseLiveAutomation(
|
||||
snapshot = driver.capture()
|
||||
pageProblem(snapshot)
|
||||
}
|
||||
if (!hasFinalSavedAddressEvidence(snapshot, expected, suffix)) {
|
||||
if (!savedAddressPageReady(snapshot, expected, suffix)) {
|
||||
fail("PURCHASE_ADDRESS_SAVE_TIMEOUT", "地址保存后规格面板未找到完整订单确认信息,未创建订单")
|
||||
}
|
||||
return snapshot
|
||||
}
|
||||
|
||||
private fun hasSavedAddressEvidence(snapshot: UiSnapshot, expected: String, suffix: String): Boolean {
|
||||
@@ -372,8 +588,9 @@ class PurchaseLiveAutomation(
|
||||
return suffixMatches.size == 1
|
||||
}
|
||||
|
||||
private fun hasFinalSavedAddressEvidence(snapshot: UiSnapshot, expected: String, suffix: String): Boolean =
|
||||
hasSavedAddressEvidence(snapshot, expected, suffix) && finalSubmitTargets(snapshot).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 waitForStableAddressEditorExit(): UiSnapshot {
|
||||
var consecutiveExits = 0
|
||||
@@ -406,6 +623,20 @@ 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
|
||||
@@ -493,11 +724,84 @@ class PurchaseLiveAutomation(
|
||||
pause(500)
|
||||
}
|
||||
|
||||
private fun finalSubmitTargets(snapshot: UiSnapshot): List<SnapshotNode> = uniqueClickable(
|
||||
private fun legacySubmitTargets(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
|
||||
@@ -561,6 +865,9 @@ 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("查看订单", "订单详情")
|
||||
@@ -583,6 +890,10 @@ class PurchaseLiveAutomation(
|
||||
const val ORDER_RESULT_SAMPLE_INTERVAL_MS = 200L
|
||||
const val SPEC_CONFIRMATION_MAX_SAMPLES = 20
|
||||
const val SPEC_CONFIRMATION_SAMPLE_INTERVAL_MS = 100L
|
||||
const val ADDRESS_ENTRY_STABLE_INTERVAL_MS = 200L
|
||||
const val ADDRESS_CARD_OVERLAP_PERCENT = 70
|
||||
const val ADDRESS_SEMANTIC_MIN_LENGTH = 4
|
||||
val ADDRESS_CARD_GENERIC_LABELS = setOf("收货地址", "修改", "默认地址", "默认")
|
||||
const val ADDRESS_CONFIRMATION_SCROLL_LIMIT = 5
|
||||
const val ADDRESS_CONFIRMATION_SCROLL_INTERVAL_MS = 500L
|
||||
}
|
||||
|
||||
+337
-34
@@ -3,6 +3,11 @@ 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
|
||||
@@ -10,6 +15,13 @@ interface PurchaseUiDriver {
|
||||
result = clickFresh(target),
|
||||
reason = FreshClickReason.UNKNOWN,
|
||||
)
|
||||
/**
|
||||
* Reacquires one address-entry node by its accessibility path and validates
|
||||
* its immutable snapshot traits before clicking its nearest clickable
|
||||
* ancestor. This is intentionally limited to the reversible transition
|
||||
* from order confirmation into address management.
|
||||
*/
|
||||
fun clickAddressEntryFresh(target: SnapshotNode): FreshClickOutcome = clickFreshDetailed(target)
|
||||
fun tapPurchaseFresh(target: SnapshotNode): FreshActionResult
|
||||
/**
|
||||
* Reacquires and center-taps a target that the purchase parser has already
|
||||
@@ -17,8 +29,15 @@ interface PurchaseUiDriver {
|
||||
* must still verify the semantic postcondition from a fresh snapshot.
|
||||
*/
|
||||
fun tapSpecFresh(target: SnapshotNode): FreshActionResult = FreshActionResult.FAILED
|
||||
/**
|
||||
* Reacquires a parsed spec option and swipes only its nearest horizontal
|
||||
* scrollable ancestor. There is deliberately no global fallback.
|
||||
*/
|
||||
fun swipeSpecRow(target: SnapshotNode, direction: SwipeDirection): Boolean = false
|
||||
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
|
||||
@@ -75,7 +94,12 @@ class PurchaseRehearsalExecutor(
|
||||
private val panelDiagnostic: (String) -> Unit = {},
|
||||
private val beforeOrderSubmit: (FinalConfirmationEvidence) -> Unit = { throw PurchaseLiveException("PURCHASE_MODE_NOT_ALLOWED", "当前执行器没有正式采购授权") },
|
||||
) {
|
||||
private var purchasePanelContext: PurchasePanelContext? = null
|
||||
private var pageIdentity: Pair<String?, String?> = null to null
|
||||
private var entryRecoveryReason = "not_checked"
|
||||
|
||||
fun execute(input: PurchaseExecutionInput, rule: PurchaseRule, supportedCapabilities: Set<String>): PurchaseExecutionOutcome {
|
||||
purchasePanelContext = null
|
||||
validateBeforeDeviceAction(input, rule, supportedCapabilities)?.let { return it }
|
||||
var observedPrice: Long? = null
|
||||
var addressProof: ShippingAddressProof? = null
|
||||
@@ -110,7 +134,7 @@ class PurchaseRehearsalExecutor(
|
||||
null
|
||||
}
|
||||
PurchaseActionType.UPDATE_SHIPPING_ADDRESS -> try {
|
||||
live.advanceToOrderConfirmation()
|
||||
live.advanceToOrderConfirmation(hasAllRecordedSelectionProofs(input, specSelectionProofs))
|
||||
addressProof = live.updateShippingAddress(input.addressSuffix)
|
||||
null
|
||||
} catch (error: PurchaseLiveException) {
|
||||
@@ -218,6 +242,7 @@ class PurchaseRehearsalExecutor(
|
||||
}
|
||||
|
||||
private fun openProduct(input: PurchaseExecutionInput, action: PurchaseAction): PurchaseExecutionOutcome? {
|
||||
purchasePanelContext = null
|
||||
if (!openLink(input.url)) return failure("PDD_LINK_INVALID", "任务中的 PDD 链接无法打开")
|
||||
val aliases = action.textAliases ?: listOf("打开拼多多APP", "打开拼多多 App", "打开")
|
||||
var clickAttempted = false
|
||||
@@ -353,7 +378,9 @@ class PurchaseRehearsalExecutor(
|
||||
screen = currentScreen(input)
|
||||
}
|
||||
val beforeSignature = specActionSignature(screen)
|
||||
val beforeIdentity = pageIdentity
|
||||
val click = driver.clickFreshDetailed(requireNotNull(target))
|
||||
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
|
||||
@@ -368,14 +395,22 @@ class PurchaseRehearsalExecutor(
|
||||
var wait = waitForSpecPanel(input, beforeSignature)
|
||||
wait.failure?.let { return it }
|
||||
if (wait.opened) return null
|
||||
if (wait.changed) {
|
||||
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)}")
|
||||
return failure(
|
||||
SPEC_PANEL_EVIDENCE_NOT_MATCHED,
|
||||
"规格入口点击后页面已变化,但规格面板强证据不足 [${panelEvidence(wait.screen)}]",
|
||||
)
|
||||
}
|
||||
|
||||
when (driver.tapSpecFresh(requireNotNull(target))) {
|
||||
val gesture = driver.tapSpecFresh(requireNotNull(recoveryTarget))
|
||||
panelDiagnostic("entryRecovery=attempted;gesture=$gesture;pageChanged=${wait.changed}")
|
||||
when (gesture) {
|
||||
FreshActionResult.AMBIGUOUS -> return failure(
|
||||
SPEC_ENTRY_TARGET_AMBIGUOUS,
|
||||
"规格入口手势目标不唯一 [${specEntryEvidence(screen, 1, entryReadyWaitPolls)}]",
|
||||
@@ -389,7 +424,8 @@ class PurchaseRehearsalExecutor(
|
||||
wait = waitForSpecPanel(input, specActionSignature(wait.screen))
|
||||
wait.failure?.let { return it }
|
||||
if (wait.opened) return null
|
||||
if (wait.changed) {
|
||||
if (wait.changed && recoverableSpecEntry(screen, wait.screen, action, beforeIdentity) == null) {
|
||||
panelDiagnostic("entryRecovery=rejected;reason=$entryRecoveryReason;pageChanged=${wait.changed};${panelEvidence(wait.screen)}")
|
||||
return failure(
|
||||
SPEC_PANEL_EVIDENCE_NOT_MATCHED,
|
||||
"规格入口手势后页面已变化,但规格面板强证据不足 [${panelEvidence(wait.screen)}]",
|
||||
@@ -401,6 +437,48 @@ 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"
|
||||
after.hasPaymentArea -> "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,
|
||||
@@ -423,9 +501,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) {
|
||||
repeat(SPEC_POST_CLICK_VERIFY_POLLS) { poll ->
|
||||
last = currentScreen(input)
|
||||
panelDiagnostic(panelEvidence(last))
|
||||
panelDiagnostic("${panelEvidence(last)};entryWaitPolls=${poll + 1};entryWaitMillis=${poll * SPEC_SELECTION_POLL_MILLIS};closeControl=${last.hasCloseControl};paymentArea=${last.hasPaymentArea}")
|
||||
if (last.reviewPageOpen) {
|
||||
return SpecPanelWait(last, false, true, leaveUnexpectedReviewPage(input))
|
||||
}
|
||||
@@ -526,6 +604,7 @@ 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)
|
||||
@@ -600,10 +679,18 @@ 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 true
|
||||
if (screen.specPanelOpen && fullSummaryTargetMatches(screen.selectedSummary, target)) return true
|
||||
return summarySelectionMatches(screen.selectedSummary, dimension, target, candidates)
|
||||
}
|
||||
|
||||
private fun fullSummaryTargetMatches(summary: String?, target: String): Boolean =
|
||||
summary != null && Regex("(^|[\\s,,、/|;;::])" + Regex.escape(target) + "($|[\\s,,、/|;;::])")
|
||||
.containsMatchIn(summary)
|
||||
|
||||
private fun summarySelectionMatches(
|
||||
summary: String?,
|
||||
dimension: String,
|
||||
@@ -635,7 +722,8 @@ class PurchaseRehearsalExecutor(
|
||||
if (dimension == "size") SpecValueNormalizer.primarySizeToken(candidate.text) == token else candidate.text == token
|
||||
}
|
||||
return ExactSpecSelectionProof(dimension, target, screen.specPanelType).takeIf {
|
||||
tokenCandidates.size == 1 && tokenCandidates.single().text == target
|
||||
(tokenCandidates.size == 1 && tokenCandidates.single().text == target) ||
|
||||
(tokenCandidates.isEmpty() && screen.specPanelOpen && fullSummaryTargetMatches(screen.selectedSummary, target))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -644,7 +732,8 @@ class PurchaseRehearsalExecutor(
|
||||
val reason: String,
|
||||
val summaryTokenMatched: Boolean,
|
||||
val candidateCount: Int,
|
||||
val proofPresent: Boolean,
|
||||
val proofRecorded: Boolean,
|
||||
val proofUsable: Boolean,
|
||||
val targetMatchCount: Int = 0,
|
||||
val relocationAttempted: Boolean = false,
|
||||
val relocationSwipes: Int = 0,
|
||||
@@ -658,21 +747,25 @@ class PurchaseRehearsalExecutor(
|
||||
): FinalSpecVerification {
|
||||
val candidates = screen.dimensions.filter { it.key == dimension }.flatMap { it.values }
|
||||
val matchingNodes = candidates.filter { it.text == target }
|
||||
val proofPresent = proof?.dimension == dimension && proof.target == target && proof.panelType == screen.specPanelType
|
||||
val proofRecorded = proof?.dimension == dimension && proof.target == target
|
||||
val proofUsable = proofRecorded && (
|
||||
proof?.panelType == screen.specPanelType ||
|
||||
(screen.specPanelType == SpecPanelType.UNKNOWN && screen.isPddPackage && screen.rootAvailable)
|
||||
)
|
||||
if (matchingNodes.size == 1 && (matchingNodes.single().node.selected || matchingNodes.single().node.checked)) {
|
||||
return FinalSpecVerification(true, "selected_node", false, candidates.size, proofPresent, matchingNodes.size)
|
||||
return FinalSpecVerification(true, "selected_node", false, candidates.size, proofRecorded, proofUsable, matchingNodes.size)
|
||||
}
|
||||
if (candidates.any { it.text != target && (it.node.selected || it.node.checked) }) {
|
||||
return FinalSpecVerification(false, "visible_selected_conflict", false, candidates.size, proofPresent, matchingNodes.size)
|
||||
return FinalSpecVerification(false, "visible_selected_conflict", false, candidates.size, proofRecorded, proofUsable, matchingNodes.size)
|
||||
}
|
||||
|
||||
val token = if (dimension == "size") SpecValueNormalizer.primarySizeToken(target) else target.trim()
|
||||
val summaryTokenMatched = !token.isNullOrBlank() && SpecValueNormalizer.summaryHasExactToken(screen.selectedSummary, token)
|
||||
if (!summaryTokenMatched) {
|
||||
if (screen.selectedSummary == null && screen.specPanelOpen && proofPresent) {
|
||||
return FinalSpecVerification(true, "attempt_selection_state", false, candidates.size, true, matchingNodes.size)
|
||||
if (screen.selectedSummary == null && proofUsable) {
|
||||
return FinalSpecVerification(true, "attempt_selection_state", false, candidates.size, true, true, matchingNodes.size)
|
||||
}
|
||||
return FinalSpecVerification(false, "summary_token_missing", false, candidates.size, proofPresent, matchingNodes.size)
|
||||
return FinalSpecVerification(false, "summary_token_missing", false, candidates.size, proofRecorded, proofUsable, matchingNodes.size)
|
||||
}
|
||||
// PDD can rerender or scroll the previously selected dimension out of
|
||||
// the current viewport while the selected summary remains visible. A
|
||||
@@ -683,15 +776,15 @@ class PurchaseRehearsalExecutor(
|
||||
if (dimension == "size") SpecValueNormalizer.primarySizeToken(candidate.text) == token else candidate.text == token
|
||||
}
|
||||
if (visibleTokenMatches.size == 1 && visibleTokenMatches.single().text == target) {
|
||||
return FinalSpecVerification(true, "visible_candidate", true, candidates.size, proofPresent, matchingNodes.size)
|
||||
return FinalSpecVerification(true, "visible_candidate", true, candidates.size, proofRecorded, proofUsable, matchingNodes.size)
|
||||
}
|
||||
if (visibleTokenMatches.isNotEmpty()) {
|
||||
return FinalSpecVerification(false, "visible_candidate_conflict", true, candidates.size, proofPresent, matchingNodes.size)
|
||||
return FinalSpecVerification(false, "visible_candidate_conflict", true, candidates.size, proofRecorded, proofUsable, matchingNodes.size)
|
||||
}
|
||||
return if (proofPresent) {
|
||||
FinalSpecVerification(true, "attempt_selection_proof", true, candidates.size, true, matchingNodes.size)
|
||||
return if (proofUsable) {
|
||||
FinalSpecVerification(true, "attempt_selection_proof", true, candidates.size, true, true, matchingNodes.size)
|
||||
} else {
|
||||
FinalSpecVerification(false, "selection_proof_missing", true, candidates.size, false, matchingNodes.size)
|
||||
FinalSpecVerification(false, "selection_proof_missing", true, candidates.size, proofRecorded, false, matchingNodes.size)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -703,7 +796,7 @@ class PurchaseRehearsalExecutor(
|
||||
): FinalSpecVerification {
|
||||
var screen = currentScreen(input)
|
||||
screen.problem?.let {
|
||||
return FinalSpecVerification(false, it.code, false, 0, proof != null)
|
||||
return FinalSpecVerification(false, it.code, false, 0, proof != null, false)
|
||||
}
|
||||
var verification = verifyExactSpecSelection(screen, dimension, target, proof)
|
||||
if (verification.confirmed || verification.targetMatchCount > 0 || verification.reason == "visible_selected_conflict") return verification
|
||||
@@ -762,6 +855,18 @@ 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
|
||||
@@ -769,13 +874,27 @@ class PurchaseRehearsalExecutor(
|
||||
* Every gesture is followed by a fresh parse, and unchanged evidence ends
|
||||
* that direction early.
|
||||
*/
|
||||
private fun panelRecognitionFailure(screen: ParsedPddScreen, dimension: String): PurchaseExecutionOutcome =
|
||||
failure("RULE_NOT_MATCHED",
|
||||
"未能识别当前商品规格面板 [dimension=$dimension;panel=${screen.specPanelType};headings=${screen.panelHeadingCount};options=${screen.panelOptionCount};summary=${screen.hasSelectionSummary};quantity=${screen.hasQuantityControls};orderAction=${screen.hasOrderSubmitAction};close=${screen.hasCloseControl};payment=${screen.hasPaymentArea};contextMatched=${screen.purchaseContextMatched}]")
|
||||
|
||||
private fun stableSelectionScreen(input: PurchaseExecutionInput): ParsedPddScreen {
|
||||
var screen = currentScreen(input)
|
||||
repeat(2) {
|
||||
if (screen.specPanelOpen || screen.problem != null) return screen
|
||||
pause(SPEC_SELECTION_POLL_MILLIS)
|
||||
screen = currentScreen(input)
|
||||
}
|
||||
return screen
|
||||
}
|
||||
|
||||
private fun locateExactSpec(input: PurchaseExecutionInput, dimension: String, target: String): SpecLookup {
|
||||
data class Inspection(val lookup: SpecLookup?, val signature: String, val container: SnapshotNode?)
|
||||
fun inspect(): Inspection {
|
||||
val screen = currentScreen(input)
|
||||
val screen = stableSelectionScreen(input)
|
||||
screen.problem?.let { return Inspection(SpecLookup(failure = failure(it.code, it.message)), "", null) }
|
||||
if (!screen.specPanelOpen) {
|
||||
return Inspection(SpecLookup(failure = failure("RULE_NOT_MATCHED", "商品规格面板已经关闭")), "", null)
|
||||
return Inspection(SpecLookup(failure = panelRecognitionFailure(screen, dimension)), "", null)
|
||||
}
|
||||
val dimensionValues = screen.dimensions.filter { it.key == dimension }.flatMap { it.values }
|
||||
val exact = dimensionValues.filter { it.text == target }
|
||||
@@ -784,7 +903,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 = failure(SPEC_SAFE_TARGET_MISSING, "精确规格当前不可安全点击")), "", screen.specPanelContainer)
|
||||
return Inspection(SpecLookup(failure = unavailableSpec(dimension)), "", screen.specPanelContainer)
|
||||
}
|
||||
val signature = screen.dimensions.joinToString("|") { item ->
|
||||
"${item.key}:${item.values.joinToString(",") { value -> "${value.text}:${value.available}" }}"
|
||||
@@ -806,9 +925,171 @@ class PurchaseRehearsalExecutor(
|
||||
currentSignature = inspected.signature
|
||||
}
|
||||
}
|
||||
if (dimension == "color" || dimension == "size") {
|
||||
locateExactDimensionHorizontally(input, dimension, target)?.let { return it }
|
||||
}
|
||||
return SpecLookup(failure = failure(SPEC_TARGET_NOT_VISIBLE, "有界搜索后未找到精确规格"))
|
||||
}
|
||||
|
||||
/**
|
||||
* Compatibility fallback for horizontally paged PDD color or size rows. The
|
||||
* established visible/vertical lookup above remains the primary path, so
|
||||
* already successful purchases never enter this branch.
|
||||
*/
|
||||
private fun locateExactDimensionHorizontally(
|
||||
input: PurchaseExecutionInput,
|
||||
dimension: String,
|
||||
target: String,
|
||||
): SpecLookup? {
|
||||
val swipeLimit = DEFAULT_COLLECTOR.limits.getValue("specHorizontalSwipes")
|
||||
val stableReadLimit = DEFAULT_COLLECTOR.limits.getValue("stableEdgeReads")
|
||||
data class ColorInspection(
|
||||
val lookup: SpecLookup?,
|
||||
val screen: ParsedPddScreen,
|
||||
val rows: List<List<VisibleSpecValue>>,
|
||||
val signature: String,
|
||||
)
|
||||
|
||||
fun inspect(): ColorInspection {
|
||||
val screen = stableSelectionScreen(input)
|
||||
screen.problem?.let {
|
||||
return ColorInspection(SpecLookup(failure = failure(it.code, it.message)), screen, emptyList(), "")
|
||||
}
|
||||
if (!screen.specPanelOpen) {
|
||||
return ColorInspection(
|
||||
SpecLookup(failure = panelRecognitionFailure(screen, dimension)),
|
||||
screen,
|
||||
emptyList(),
|
||||
"",
|
||||
)
|
||||
}
|
||||
val values = screen.dimensions.filter { it.key == dimension }.flatMap { it.values }
|
||||
val exact = values.filter { it.text == target }
|
||||
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))
|
||||
else -> null
|
||||
}
|
||||
val rows = specValueRows(values)
|
||||
val signature = screen.dimensions.flatMap { it.values }.joinToString("|") { value ->
|
||||
val bounds = value.node.bounds
|
||||
"${value.text}:${bounds.left},${bounds.top},${bounds.right},${bounds.bottom}:${value.available}"
|
||||
}
|
||||
return ColorInspection(lookup, screen, rows, signature)
|
||||
}
|
||||
|
||||
var inspected = inspect()
|
||||
inspected.lookup?.let { return it }
|
||||
var verticalSwipes = 0
|
||||
var horizontalSwipes = 0
|
||||
var lastHorizontalFailure = "none"
|
||||
var termination = "budgetExhausted"
|
||||
val horizontalBudget = mutableMapOf(SwipeDirection.RIGHT to swipeLimit, SwipeDirection.LEFT to swipeLimit)
|
||||
|
||||
fun searchVisibleRows(): SpecLookup? {
|
||||
for (direction in listOf(SwipeDirection.RIGHT, SwipeDirection.LEFT)) {
|
||||
var stableReads = 0
|
||||
var previousSignature = inspected.signature
|
||||
while (horizontalBudget.getValue(direction) > 0) {
|
||||
// Always reacquire a supported row; never fall back to a stale node.
|
||||
val anchor = horizontalSpecRow(inspected.screen, inspected.rows)?.firstOrNull()?.node ?: break
|
||||
horizontalBudget[direction] = horizontalBudget.getValue(direction) - 1
|
||||
if (!driver.swipeSpecRow(anchor, direction)) {
|
||||
lastHorizontalFailure = driver.specRowSwipeFailureReason()
|
||||
// A failed gesture is not evidence that the target is absent.
|
||||
inspected = inspect()
|
||||
inspected.lookup?.let { return it }
|
||||
break
|
||||
}
|
||||
horizontalSwipes++
|
||||
pause(300)
|
||||
inspected = inspect()
|
||||
inspected.lookup?.let { return it }
|
||||
stableReads = if (previousSignature == inspected.signature) stableReads + 1 else 0
|
||||
previousSignature = inspected.signature
|
||||
if (stableReads >= stableReadLimit) break
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// The original fast path remains first. Recover both ends of a vertical
|
||||
// grid, inspecting each viewport for the exact target and supported rows.
|
||||
val verticalLimit = DEFAULT_COLLECTOR.limits.getValue("specVerticalSwipes")
|
||||
for (direction in listOf(SwipeDirection.DOWN, SwipeDirection.UP)) {
|
||||
var stableReads = 0
|
||||
var previousSignature = inspected.signature
|
||||
for (attempt in 0..verticalLimit) {
|
||||
searchVisibleRows()?.let { return it }
|
||||
if (attempt == verticalLimit) {
|
||||
termination = "budgetExhausted"
|
||||
break
|
||||
}
|
||||
val container = inspected.screen.specPanelContainer
|
||||
if (container == null) {
|
||||
termination = "verticalContainerMissing"
|
||||
break
|
||||
}
|
||||
if (!driver.swipePurchaseIn(container, direction, 350)) {
|
||||
termination = "verticalSwipeFailed"
|
||||
break
|
||||
}
|
||||
verticalSwipes++
|
||||
pause(300)
|
||||
inspected = inspect()
|
||||
inspected.lookup?.let { return it }
|
||||
stableReads = if (previousSignature == inspected.signature) stableReads + 1 else 0
|
||||
previousSignature = inspected.signature
|
||||
if (stableReads >= stableReadLimit) {
|
||||
searchVisibleRows()?.let { return it }
|
||||
termination = "stableViewport"
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return SpecLookup(failure = failure(
|
||||
SPEC_TARGET_NOT_VISIBLE,
|
||||
"有界搜索后未找到精确规格 [dimension=$dimension;horizontalSwipes=$horizontalSwipes;verticalRecoverySwipes=$verticalSwipes;visibleCandidates=${inspected.rows.flatten().size};reason=$termination;horizontalFailure=$lastHorizontalFailure]",
|
||||
))
|
||||
}
|
||||
|
||||
private fun horizontalSpecRow(
|
||||
screen: ParsedPddScreen,
|
||||
rows: List<List<VisibleSpecValue>>,
|
||||
): List<VisibleSpecValue>? = rows.firstOrNull { row ->
|
||||
row.isNotEmpty() && row.all { value -> hasDedicatedHorizontalAncestor(screen, value.node) }
|
||||
}
|
||||
|
||||
private fun hasDedicatedHorizontalAncestor(screen: ParsedPddScreen, source: SnapshotNode): Boolean {
|
||||
val byPath = screen.sourceNodes.associateBy(SnapshotNode::path)
|
||||
var current: SnapshotNode? = source
|
||||
while (current != null) {
|
||||
if (current.path == screen.specPanelContainer?.path) return false
|
||||
if (current.scrollable && current.bounds.width > current.bounds.height) return true
|
||||
current = current.parentPath?.let(byPath::get)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private fun specValueRows(values: List<VisibleSpecValue>): List<List<VisibleSpecValue>> {
|
||||
val sorted = values.sortedWith(compareBy({ it.node.bounds.centerY }, { it.node.bounds.left }))
|
||||
val rows = mutableListOf<MutableList<VisibleSpecValue>>()
|
||||
val centers = mutableListOf<Int>()
|
||||
sorted.forEach { value ->
|
||||
val tolerance = (value.node.bounds.height / 2).coerceIn(24, 80)
|
||||
val index = centers.indexOfFirst { kotlin.math.abs(value.node.bounds.centerY - it) <= tolerance }
|
||||
if (index < 0) {
|
||||
rows += mutableListOf(value)
|
||||
centers += value.node.bounds.centerY
|
||||
} else {
|
||||
rows[index] += value
|
||||
centers[index] = rows[index].map { it.node.bounds.centerY }.average().toInt()
|
||||
}
|
||||
}
|
||||
return rows.onEach { row -> row.sortBy { it.node.bounds.left } }
|
||||
}
|
||||
|
||||
private fun setQuantity(quantity: Long): PurchaseExecutionOutcome? {
|
||||
val snapshot = driver.capture()
|
||||
pageProblem(snapshot)?.let { return it }
|
||||
@@ -859,7 +1140,7 @@ class PurchaseRehearsalExecutor(
|
||||
val diagnostic = "dimension=$dimension,reason=${verification.reason},panel=$panel," +
|
||||
"summary=${screen.selectedSummary != null},tokenMatched=${verification.summaryTokenMatched}," +
|
||||
"candidates=${verification.candidateCount},targetMatches=${verification.targetMatchCount}," +
|
||||
"proof=${verification.proofPresent}," +
|
||||
"proofRecorded=${verification.proofRecorded},proofUsable=${verification.proofUsable}," +
|
||||
"relocated=${verification.relocationAttempted},relocationSwipes=${verification.relocationSwipes}"
|
||||
return failure(SPEC_SELECTION_UNCONFIRMED, "最终规格复核未能确认精确选中状态 [$diagnostic]")
|
||||
}
|
||||
@@ -874,19 +1155,32 @@ class PurchaseRehearsalExecutor(
|
||||
return null
|
||||
}
|
||||
|
||||
private fun hasAllRecordedSelectionProofs(
|
||||
input: PurchaseExecutionInput,
|
||||
proofs: Map<String, ExactSpecSelectionProof>,
|
||||
): Boolean {
|
||||
val required = listOf("color" to input.mappedColor, "size" to input.mappedSize)
|
||||
.filter { it.second.isNotBlank() }
|
||||
.map { (dimension, rawTarget) -> dimension to normalizedTarget(dimension, rawTarget) }
|
||||
return required.isNotEmpty() && required.all { (dimension, target) ->
|
||||
target != null && proofs[dimension]?.let { it.dimension == dimension && it.target == target } == true
|
||||
}
|
||||
}
|
||||
|
||||
private fun applyPostAction(input: PurchaseExecutionInput, action: PurchaseAction): PurchaseExecutionOutcome? {
|
||||
if (action.waitAfterMs > 0) pause(action.waitAfterMs)
|
||||
action.swipeAfter?.let { swipe ->
|
||||
// The stock purchase rule asks to reveal additional selector rows after
|
||||
// opening the sheet. A fully-evidenced non-scrollable selector has no
|
||||
// scroll target, and treating that absence as an action failure blocks
|
||||
// an otherwise safe exact-spec flow. Keep all other configured swipes
|
||||
// mandatory; this exception is limited to that confirmed panel state.
|
||||
if (action.type == PurchaseActionType.OPEN_SPEC_PANEL &&
|
||||
currentScreen(input).specPanelType == SpecPanelType.NON_SCROLLABLE_CONFIRMATION
|
||||
) return null
|
||||
// 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
|
||||
}
|
||||
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)
|
||||
@@ -908,8 +1202,17 @@ class PurchaseRehearsalExecutor(
|
||||
return normalized.takeIf(SpecValueNormalizer::isSafeSize)
|
||||
}
|
||||
|
||||
private fun currentScreen(input: PurchaseExecutionInput): ParsedPddScreen =
|
||||
PddScreenParser.parse(driver.capture(), DEFAULT_COLLECTOR, input.goodsId, null)
|
||||
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)
|
||||
purchasePanelContext = if (screen.isPddPackage && screen.problem == null && screen.specPanelOpen) {
|
||||
screen.specPanelContainer?.let {
|
||||
PurchasePanelContext(it, normalizedTarget("color", input.mappedColor).orEmpty())
|
||||
}
|
||||
} else null
|
||||
return screen
|
||||
}
|
||||
|
||||
private fun pageProblem(snapshot: UiSnapshot): PurchaseExecutionOutcome? =
|
||||
PddPageClassifier.classify(snapshot.packageName, snapshot.activityName, snapshot.nodes.filter { it.visible }.map { it.label })
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
package cn.ilapage.goauto.agent.automation
|
||||
|
||||
internal data class PurchaseScrollCandidate(
|
||||
val path: String,
|
||||
val className: String?,
|
||||
val bounds: NodeBounds,
|
||||
)
|
||||
|
||||
internal object PurchaseScrollLocator {
|
||||
fun locate(target: PurchaseScrollCandidate, candidates: List<PurchaseScrollCandidate>): PurchaseScrollCandidate? {
|
||||
val compatible = candidates.filter {
|
||||
it.className == target.className && boundsMatch(it.bounds, target.bounds)
|
||||
}
|
||||
val samePath = compatible.filter { it.path == target.path }
|
||||
if (samePath.isNotEmpty()) return samePath.singleOrNull()
|
||||
return compatible.singleOrNull()
|
||||
}
|
||||
|
||||
private fun boundsMatch(a: NodeBounds, b: NodeBounds): Boolean =
|
||||
kotlin.math.abs(a.left - b.left) <= 32 &&
|
||||
kotlin.math.abs(a.top - b.top) <= 32 &&
|
||||
kotlin.math.abs(a.right - b.right) <= 32 &&
|
||||
kotlin.math.abs(a.bottom - b.bottom) <= 32
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+92
@@ -0,0 +1,92 @@
|
||||
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")
|
||||
private val booleans = setOf("panelAlreadyOpen", "reviewPage", "pageEvidence", "pageChanged", "summary", "quantity",
|
||||
"orderAction", "closeControl", "paymentArea")
|
||||
private val enums = mapOf(
|
||||
"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
|
||||
}
|
||||
}
|
||||
}
|
||||
+57
-18
@@ -18,6 +18,10 @@ 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
|
||||
@@ -91,6 +95,7 @@ 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
|
||||
@@ -109,6 +114,8 @@ 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 ->
|
||||
@@ -350,11 +357,16 @@ class AgentForegroundService : Service() {
|
||||
}
|
||||
runningTaskId.set(task.taskId)
|
||||
stateStore.setActiveTask(task.taskId, "collection")
|
||||
val outcome = executeTask(api, task, credentials.token)
|
||||
val continuous = ContinuousCollectionPolicy.applies(
|
||||
settingsStore.continuousCollection(), task.source, task.attemptNumber, task.replacementOriginType,
|
||||
)
|
||||
val outcome = executeTask(api, task, credentials.token, continuous)
|
||||
publishCurrentPageResult(
|
||||
task.taskId,
|
||||
if (outcome.successful) {
|
||||
if (task.replacementOriginType.isNotBlank()) {
|
||||
if (continuous) {
|
||||
outcome.message ?: "采集已结束,请查看采集记录。"
|
||||
} else if (task.replacementOriginType.isNotBlank()) {
|
||||
"已替换,正在匹配规格"
|
||||
} else {
|
||||
"临时采集任务 #${task.taskId} 已结束,请查看采集记录。"
|
||||
@@ -478,15 +490,29 @@ 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 recordDiagnostic: (String) -> Unit = { evidence ->
|
||||
val elapsed = SystemClock.elapsedRealtime() - diagnosticStarted
|
||||
runCatching { diagnosticExecutor.execute { purchaseDiagnostics.record(diagnosticContext, elapsed, evidence) } }
|
||||
Unit
|
||||
}
|
||||
recordDiagnostic("event=started")
|
||||
PurchaseRehearsalExecutor(
|
||||
driver = accessibility,
|
||||
openLink = { PddLinkLauncher(this).open(it) },
|
||||
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 -> Log.i("GoAutoPurchasePanel", "task=${task.taskId};$evidence") },
|
||||
panelDiagnostic = { evidence ->
|
||||
recordDiagnostic(evidence)
|
||||
Log.i("GoAutoPurchasePanel", "task=${task.taskId};attempt=$diagnosticAttempt;device=$diagnosticDeviceId;rule=$snapshotHash;$evidence")
|
||||
},
|
||||
beforeOrderSubmit = { evidence ->
|
||||
val boundaryRequestId = UUID.randomUUID().toString()
|
||||
val finalEvidence = JSONObject()
|
||||
@@ -631,11 +657,29 @@ class AgentForegroundService : Service() {
|
||||
api: AgentApiClient,
|
||||
initialTask: cn.ilapage.goauto.agent.network.AgentTask,
|
||||
token: String,
|
||||
continuous: Boolean = false,
|
||||
): TaskExecutionSummary {
|
||||
GoAutoAccessibilityService.instance?.dismissPurchaseResultBubble()
|
||||
acquireTaskWakeLock()
|
||||
return try {
|
||||
executeTaskWhileAwake(api, initialTask, token)
|
||||
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) }
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
releaseTaskWakeLock()
|
||||
}
|
||||
@@ -645,6 +689,7 @@ 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
|
||||
@@ -751,25 +796,25 @@ class AgentForegroundService : Service() {
|
||||
}
|
||||
}
|
||||
stateStore.update("ONLINE", "任务 #${task.taskId} 已提交:${result.status}", tokenStored = true)
|
||||
beginPostCollectionCooldowns()
|
||||
TaskExecutionSummary(successful = true)
|
||||
beginPostCollectionCooldowns(returnToAgent = !continuous)
|
||||
TaskExecutionSummary(successful = true, resultStatus = result.status)
|
||||
} catch (error: TaskFailure) {
|
||||
if (started) {
|
||||
resultSafelySubmitted = failSafely(api, initialTask.taskId, token, error.code, error.message ?: "采集失败")
|
||||
if (resultSafelySubmitted) beginPostCollectionCooldowns()
|
||||
if (resultSafelySubmitted) beginPostCollectionCooldowns(returnToAgent = !continuous)
|
||||
}
|
||||
TaskExecutionSummary(false, error.code, error.message)
|
||||
} catch (error: AgentApiException) {
|
||||
if (error.code == "REPLACEMENT_ACTIVATION_FAILED") {
|
||||
resultSafelySubmitted = true
|
||||
beginPostCollectionCooldowns()
|
||||
beginPostCollectionCooldowns(returnToAgent = !continuous)
|
||||
}
|
||||
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()
|
||||
if (resultSafelySubmitted) beginPostCollectionCooldowns(returnToAgent = !continuous)
|
||||
}
|
||||
TaskExecutionSummary(false, "AGENT_EXECUTION_ERROR", error.message)
|
||||
} finally {
|
||||
@@ -828,8 +873,8 @@ class AgentForegroundService : Service() {
|
||||
executor.schedule(::triggerSync, IdleReturnCoordinator.DEFAULT_COOLDOWN_MILLIS, TimeUnit.MILLISECONDS)
|
||||
}
|
||||
|
||||
private fun beginPostCollectionCooldowns() {
|
||||
beginIdleReturnCooldown()
|
||||
private fun beginPostCollectionCooldowns(returnToAgent: Boolean = true) {
|
||||
if (returnToAgent) beginIdleReturnCooldown() else cancelIdleReturn("连续采集不自动返回 Agent")
|
||||
val ticket = CollectionCooldownPolicy.arm(
|
||||
System.currentTimeMillis(),
|
||||
settingsStore.collectionIntervalRange(),
|
||||
@@ -1148,9 +1193,3 @@ 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,6 +1,7 @@
|
||||
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
|
||||
@@ -113,14 +114,38 @@ internal object TaskDispatchPolicy {
|
||||
}
|
||||
}
|
||||
|
||||
class AgentSettingsStore(context: Context) {
|
||||
private val preferences = context.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE)
|
||||
class AgentSettingsStore internal constructor(private val preferences: SharedPreferences) {
|
||||
constructor(context: Context) : this(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 {
|
||||
@@ -177,6 +202,8 @@ class AgentSettingsStore(context: Context) {
|
||||
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
@@ -0,0 +1,27 @@
|
||||
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,
|
||||
)
|
||||
@@ -34,6 +34,7 @@ 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
|
||||
@@ -64,6 +65,10 @@ 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
|
||||
@@ -257,6 +262,69 @@ 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 {
|
||||
@@ -654,11 +722,23 @@ 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
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package cn.ilapage.goauto.agent
|
||||
|
||||
import cn.ilapage.goauto.agent.automation.PddLaunchFallback
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Test
|
||||
|
||||
class PddLaunchFallbackTest {
|
||||
@Test fun directSuccessDoesNotStartBrowser() {
|
||||
assertTrue(PddLaunchFallback.open(true, { true }, { error("browser must not start") }))
|
||||
}
|
||||
@Test fun missingHandlerFallsBackOnce() {
|
||||
var browserCalls = 0
|
||||
assertTrue(PddLaunchFallback.open(true, { throw IllegalStateException("no handler") }, { browserCalls++; true }))
|
||||
assertEquals(1, browserCalls)
|
||||
}
|
||||
@Test fun rejectedDirectLaunchFallsBack() {
|
||||
assertTrue(PddLaunchFallback.open(true, { false }, { true }))
|
||||
assertFalse(PddLaunchFallback.open(true, { false }, { false }))
|
||||
}
|
||||
@Test fun collectionKeepsBrowserEntry() {
|
||||
var directCalls = 0
|
||||
assertTrue(PddLaunchFallback.open(false, { directCalls++; true }, { true }))
|
||||
assertEquals(0, directCalls)
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import cn.ilapage.goauto.agent.automation.PddCollectorConfig
|
||||
import cn.ilapage.goauto.agent.automation.PddCollectorDriver
|
||||
import cn.ilapage.goauto.agent.automation.PddProductDetailCollector
|
||||
import cn.ilapage.goauto.agent.automation.PddScreenParser
|
||||
import cn.ilapage.goauto.agent.automation.PurchasePanelContext
|
||||
import cn.ilapage.goauto.agent.automation.SnapshotNode
|
||||
import cn.ilapage.goauto.agent.automation.SpecPanelType
|
||||
import cn.ilapage.goauto.agent.automation.SwipeDirection
|
||||
@@ -26,6 +27,62 @@ import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class PddProductDetailCollectorTest {
|
||||
private fun prefixlessPanel(): UiSnapshot = UiSnapshot(PDD_PACKAGE, ACTIVITY, listOf(
|
||||
node("root", "", 0, 0, 1080, 2376),
|
||||
node("close", "关闭", 970, 270, 1050, 350, clickable = true),
|
||||
node("info", "", 0, 770, 1080, 1157),
|
||||
node("info/summary", "米白色(有里布) 2XL 建议131到150斤", 396, 878, 1053, 992, parentPath = "info"),
|
||||
node("info/quantity", "1", 480, 1046, 561, 1121, className = "android.widget.EditText", parentPath = "info"),
|
||||
node("minus", "减少数量", 396, 1046, 470, 1121, clickable = true),
|
||||
node("plus", "增加数量", 570, 1046, 645, 1121, clickable = true),
|
||||
node("scroll", "", 0, 1294, 1080, 2079, scrollable = true),
|
||||
node("scroll/color-title", "颜色分类", 36, 1319, 216, 1380, parentPath = "scroll"),
|
||||
node("scroll/color", "米白色(有里布)", 36, 1405, 352, 1808, clickable = true, selected = true, parentPath = "scroll"),
|
||||
node("scroll/size-title", "尺码", 36, 1858, 126, 1911, parentPath = "scroll"),
|
||||
node("scroll/size", "XL 建议111到130斤", 36, 1930, 440, 2015, clickable = true, parentPath = "scroll"),
|
||||
node("payment", "微信支付", 112, 2100, 929, 2157),
|
||||
node("submit", "提交订单", 375, 2225, 705, 2284, clickable = true),
|
||||
))
|
||||
|
||||
@Test fun `prefixless selected panel retains structural recognition and product summary`() {
|
||||
val parsed = PddScreenParser.parse(prefixlessPanel(), config(), GOODS_ID, evidence())
|
||||
assertEquals(SpecPanelType.NORMAL_SCROLLABLE, parsed.specPanelType)
|
||||
assertEquals("米白色(有里布) 2XL 建议131到150斤", parsed.selectedSummary)
|
||||
}
|
||||
|
||||
@Test fun `prefixless panel requires quantity and order evidence`() {
|
||||
for (missing in listOf("plus", "submit", "payment")) {
|
||||
val snapshot = prefixlessPanel()
|
||||
val parsed = PddScreenParser.parse(snapshot.copy(nodes = snapshot.nodes.filterNot { it.path == missing }), config(), GOODS_ID, evidence())
|
||||
assertFalse(parsed.specPanelOpen)
|
||||
assertEquals(null, parsed.selectedSummary)
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun `ambiguous product summaries are not used`() {
|
||||
val snapshot = prefixlessPanel()
|
||||
val extra = node("info/other", "米白色(有里布) XL 建议111到130斤", 396, 800, 1053, 870, parentPath = "info")
|
||||
val parsed = PddScreenParser.parse(snapshot.copy(nodes = snapshot.nodes + extra), config(), GOODS_ID, evidence())
|
||||
assertEquals(null, parsed.selectedSummary)
|
||||
}
|
||||
|
||||
@Test fun `scrolling away color heading and options preserves known purchase panel and summary`() {
|
||||
val initial = prefixlessPanel()
|
||||
val opened = PddScreenParser.parse(initial, config(), GOODS_ID, evidence())
|
||||
val context = PurchasePanelContext(requireNotNull(opened.specPanelContainer), "米白色(有里布)")
|
||||
val scrolled = initial.copy(nodes = initial.nodes.filterNot { it.path.startsWith("scroll/color") })
|
||||
val continued = PddScreenParser.parse(scrolled, config(), GOODS_ID, evidence(), context)
|
||||
assertEquals(1, continued.panelHeadingCount)
|
||||
assertTrue(continued.specPanelOpen)
|
||||
assertEquals("米白色(有里布) 2XL 建议131到150斤", continued.selectedSummary)
|
||||
assertTrue(continued.dimensions.none { it.key == "color" })
|
||||
assertFalse(PddScreenParser.parse(scrolled, config(), GOODS_ID, evidence()).specPanelOpen)
|
||||
assertFalse(PddScreenParser.parse(scrolled, config(), GOODS_ID, evidence(),
|
||||
context.copy(container = context.container.copy(path = "other"))).specPanelOpen)
|
||||
assertFalse(PddScreenParser.parse(scrolled.copy(nodes = scrolled.nodes.filterNot { it.path == "submit" }),
|
||||
config(), GOODS_ID, evidence(), context).specPanelOpen)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parser removes only trailing size price and keeps raw evidence`() {
|
||||
val snapshot = UiSnapshot(
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package cn.ilapage.goauto.agent
|
||||
|
||||
import cn.ilapage.goauto.agent.automation.FreshActionResult
|
||||
import cn.ilapage.goauto.agent.automation.FreshClickOutcome
|
||||
import cn.ilapage.goauto.agent.automation.FreshClickReason
|
||||
import cn.ilapage.goauto.agent.automation.NodeBounds
|
||||
import cn.ilapage.goauto.agent.automation.PurchaseExecutionInput
|
||||
import cn.ilapage.goauto.agent.automation.PurchaseLiveAutomation
|
||||
@@ -16,6 +18,161 @@ import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class PurchaseLiveAutomationTest {
|
||||
@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()
|
||||
@@ -56,6 +213,41 @@ class PurchaseLiveAutomationTest {
|
||||
assertTrue(driver.clicked.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `recorded exact selections allow one unique confirm on an unclassified pdd panel`() {
|
||||
val driver = SpecConfirmationDriver(unclassifiedSpecPanel = true)
|
||||
|
||||
PurchaseLiveAutomation(driver, pause = {}).advanceToOrderConfirmation(
|
||||
allowUnclassifiedPanelWithSelectionProof = true,
|
||||
)
|
||||
|
||||
assertEquals(listOf("确定"), driver.clicked)
|
||||
assertEquals("order", driver.page)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unclassified pdd panel without recorded selections remains blocked`() {
|
||||
val driver = SpecConfirmationDriver(unclassifiedSpecPanel = true)
|
||||
val error = runCatching { PurchaseLiveAutomation(driver, pause = {}).advanceToOrderConfirmation() }
|
||||
.exceptionOrNull() as PurchaseLiveException
|
||||
|
||||
assertEquals("PURCHASE_SPEC_CONFIRMATION_NOT_READY", error.code)
|
||||
assertTrue(driver.clicked.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `recorded selections never authorize a generic unclassified pdd dialog`() {
|
||||
val driver = SpecConfirmationDriver(unclassifiedSpecPanel = true, unclassifiedHasPurchaseEvidence = false)
|
||||
val error = runCatching {
|
||||
PurchaseLiveAutomation(driver, pause = {}).advanceToOrderConfirmation(
|
||||
allowUnclassifiedPanelWithSelectionProof = true,
|
||||
)
|
||||
}.exceptionOrNull() as PurchaseLiveException
|
||||
|
||||
assertEquals("PURCHASE_SPEC_CONFIRMATION_NOT_READY", error.code)
|
||||
assertTrue(driver.clicked.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `address is retagged verified and final order button can only be clicked once`() {
|
||||
val driver = LiveDriver()
|
||||
@@ -104,6 +296,72 @@ 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)
|
||||
@@ -464,6 +722,52 @@ class PurchaseLiveAutomationTest {
|
||||
assertTrue(driver.clicked.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `duplicate masked phone nodes in one clickable address card are activated once`() {
|
||||
val driver = LiveDriver(duplicatePhoneNodesSameCard = true)
|
||||
|
||||
PurchaseLiveAutomation(driver, pause = {}).updateShippingAddress("_cg94")
|
||||
|
||||
assertEquals(1, driver.clicked.count { it == "138****5678" })
|
||||
assertEquals(0, driver.addressTaps)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `duplicate semantic address cards in separate accessibility trees are activated once`() {
|
||||
val driver = LiveDriver(duplicateSemanticAddressCards = true)
|
||||
|
||||
PurchaseLiveAutomation(driver, pause = {}).updateShippingAddress("_cg94")
|
||||
|
||||
assertEquals(1, driver.addressPathClicks)
|
||||
assertEquals(1, driver.clicked.count { it == "138****5678" })
|
||||
assertEquals(0, driver.addressTaps)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `two independent address cards remain ambiguous without any click`() {
|
||||
val driver = LiveDriver(duplicateAddressCards = true)
|
||||
val error = runCatching { PurchaseLiveAutomation(driver, pause = {}).updateShippingAddress("_cg95") }
|
||||
.exceptionOrNull() as PurchaseLiveException
|
||||
|
||||
assertEquals("PURCHASE_ADDRESS_ENTRY_AMBIGUOUS", error.code)
|
||||
assertTrue(error.message.orEmpty().contains("phoneNodes=2;addressCards=2;tapTargets=2"))
|
||||
assertFalse(error.message.orEmpty().contains("138"))
|
||||
assertTrue(driver.clicked.isEmpty())
|
||||
assertEquals(0, driver.addressTaps)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `address entry path drift fails without fallback tap`() {
|
||||
val driver = LiveDriver(duplicatePhoneNodesSameCard = true, addressEntryPathUnavailable = true)
|
||||
val error = runCatching { PurchaseLiveAutomation(driver, pause = {}).updateShippingAddress("_cg94") }
|
||||
.exceptionOrNull() as PurchaseLiveException
|
||||
|
||||
assertEquals("PURCHASE_ADDRESS_ENTRY_NOT_READY", error.code)
|
||||
assertTrue(driver.clicked.isEmpty())
|
||||
assertEquals(1, driver.addressPathClicks)
|
||||
assertEquals(0, driver.addressTaps)
|
||||
}
|
||||
|
||||
private fun input() = PurchaseExecutionInput(
|
||||
taskId = 11,
|
||||
executionMode = "live",
|
||||
@@ -482,6 +786,8 @@ class PurchaseLiveAutomationTest {
|
||||
private val confirmLabels: List<String> = listOf("确定"),
|
||||
private val advanceAfterClick: Boolean = true,
|
||||
private val specPanelScrollable: Boolean = false,
|
||||
private val unclassifiedSpecPanel: Boolean = false,
|
||||
private val unclassifiedHasPurchaseEvidence: Boolean = true,
|
||||
startOnOrderPage: Boolean = false,
|
||||
) : PurchaseUiDriver {
|
||||
var page = if (startOnOrderPage) "order" else "spec"
|
||||
@@ -500,6 +806,14 @@ class PurchaseLiveAutomationTest {
|
||||
} else {
|
||||
snapshot(buildList {
|
||||
add(node("root", "", bounds = NodeBounds(0, 0, 1080, 2200)))
|
||||
if (unclassifiedSpecPanel) {
|
||||
if (unclassifiedHasPurchaseEvidence) {
|
||||
add(node("price", "¥20.00"))
|
||||
add(node("quantity", "1", className = "android.widget.EditText"))
|
||||
}
|
||||
confirmLabels.forEachIndexed { index, label -> add(node("confirm-$index", label, clickable = true)) }
|
||||
return@buildList
|
||||
}
|
||||
if (specPanelScrollable) {
|
||||
add(node("scroll", "", scrollable = true, bounds = NodeBounds(0, 400, 1080, 1500)))
|
||||
}
|
||||
@@ -552,7 +866,15 @@ 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,
|
||||
private val addressEntryPathUnavailable: Boolean = false,
|
||||
private val wechatLoginAfterSubmit: Boolean = false,
|
||||
private val wechatRestoreStuck: Boolean = false,
|
||||
private val orderEvidenceBelowFold: Boolean = false,
|
||||
@@ -571,6 +893,7 @@ class PurchaseLiveAutomationTest {
|
||||
var scopedSwipes = 0
|
||||
var genericSwipes = 0
|
||||
var addressTaps = 0
|
||||
var addressPathClicks = 0
|
||||
var lastInputTargetPath: String? = null
|
||||
var backCount = 0
|
||||
var postSubmitBackCount = 0
|
||||
@@ -578,16 +901,33 @@ class PurchaseLiveAutomationTest {
|
||||
var orderDetailEntryClicks = 0
|
||||
var postSubmitCaptureCount = 0
|
||||
val currentPage: String get() = page
|
||||
var structuredTransform: (UiSnapshot) -> UiSnapshot = { it }
|
||||
|
||||
override fun capture(): UiSnapshot {
|
||||
if (submitClicks > 0) {
|
||||
postSubmitCaptureCount++
|
||||
if (pendingPostSubmitPages.isNotEmpty()) page = pendingPostSubmitPages.removeAt(0)
|
||||
}
|
||||
return currentSnapshot()
|
||||
return currentSnapshot().let { if (page == "structured") structuredTransform(it) else it }
|
||||
}
|
||||
|
||||
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),
|
||||
@@ -610,7 +950,7 @@ class PurchaseLiveAutomationTest {
|
||||
))
|
||||
"post-save-spec" -> snapshot(listOf(
|
||||
node("root", "", bounds = NodeBounds(0, 0, 1080, 2200)),
|
||||
node("panel", "", scrollable = true, bounds = NodeBounds(0, 400, 1080, 2100)),
|
||||
node("panel", "", scrollable = true, bounds = savedPanelBounds),
|
||||
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)),
|
||||
@@ -622,7 +962,8 @@ 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(
|
||||
@@ -656,7 +997,30 @@ class PurchaseLiveAutomationTest {
|
||||
)
|
||||
if (duplicatePanels) nodes += node("panel2", "", scrollable = true, bounds = NodeBounds(0, 500, 1080, 2000))
|
||||
if (addressVisible) {
|
||||
nodes += node("phone", "138****5678")
|
||||
when {
|
||||
duplicateSemanticAddressCards -> {
|
||||
nodes += node("address-layer-a", "", clickable = true, bounds = NodeBounds(0, 620, 1080, 840))
|
||||
nodes += node("address-layer-a/phone", "138****5678", parentPath = "address-layer-a", bounds = NodeBounds(20, 650, 400, 710))
|
||||
nodes += node("address-layer-a/detail", "广东省广州市天园街道骏景花园", parentPath = "address-layer-a", bounds = NodeBounds(20, 720, 900, 790))
|
||||
nodes += node("address-layer-b", "", clickable = true, bounds = NodeBounds(0, 900, 1080, 1120))
|
||||
nodes += node("address-layer-b/phone", "138****5678", parentPath = "address-layer-b", bounds = NodeBounds(20, 930, 400, 990))
|
||||
nodes += node("address-layer-b/detail", "广东省广州市天园街道骏景花园", parentPath = "address-layer-b", bounds = NodeBounds(20, 1000, 900, 1070))
|
||||
}
|
||||
duplicateAddressCards -> {
|
||||
nodes += node("address-card-a", "", clickable = true, bounds = NodeBounds(0, 620, 1080, 820))
|
||||
nodes += node("address-card-a/phone", "138****5678", parentPath = "address-card-a", bounds = NodeBounds(20, 650, 400, 710))
|
||||
nodes += node("address-card-a/detail", "广东省广州市天园街道一号", parentPath = "address-card-a", bounds = NodeBounds(20, 720, 900, 780))
|
||||
nodes += node("address-card-b", "", clickable = true, bounds = NodeBounds(0, 840, 1080, 1040))
|
||||
nodes += node("address-card-b/phone", "138****5678", parentPath = "address-card-b", bounds = NodeBounds(20, 870, 400, 930))
|
||||
nodes += node("address-card-b/detail", "广东省广州市天园街道二号", parentPath = "address-card-b", bounds = NodeBounds(20, 940, 900, 1000))
|
||||
}
|
||||
duplicatePhoneNodesSameCard -> {
|
||||
nodes += node("address-card", "", clickable = true, bounds = NodeBounds(0, 620, 1080, 900))
|
||||
nodes += node("address-card/phone-a", "138****5678", parentPath = "address-card", bounds = NodeBounds(20, 650, 400, 710))
|
||||
nodes += node("address-card/phone-b", "138****5678", parentPath = "address-card", bounds = NodeBounds(20, 720, 440, 790))
|
||||
}
|
||||
else -> nodes += node("phone", "138****5678")
|
||||
}
|
||||
val suffixStart = address.lastIndexOf("_cg")
|
||||
val addressBody = if (suffixStart >= 0) address.substring(0, suffixStart) else address
|
||||
val addressSuffix = if (suffixStart >= 0) address.substring(suffixStart) else ""
|
||||
@@ -678,6 +1042,11 @@ 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"
|
||||
@@ -685,6 +1054,7 @@ class PurchaseLiveAutomationTest {
|
||||
page = when {
|
||||
saveReturnsToSpecPanel -> "post-save-spec"
|
||||
savedTransitionWithoutLegacyContext -> "saved-transition"
|
||||
savedStructuredFooter != null -> "structured"
|
||||
else -> "panel"
|
||||
}
|
||||
}
|
||||
@@ -705,6 +1075,14 @@ class PurchaseLiveAutomationTest {
|
||||
return FreshActionResult.SUCCESS
|
||||
}
|
||||
|
||||
override fun clickAddressEntryFresh(target: SnapshotNode): FreshClickOutcome {
|
||||
addressPathClicks++
|
||||
if (addressEntryPathUnavailable) {
|
||||
return FreshClickOutcome(FreshActionResult.NOT_FOUND, FreshClickReason.TARGET_NOT_FOUND)
|
||||
}
|
||||
return FreshClickOutcome(clickFresh(target), FreshClickReason.SUCCESS, 1, 1)
|
||||
}
|
||||
|
||||
override fun tapPurchaseFresh(target: SnapshotNode): FreshActionResult {
|
||||
addressTaps++
|
||||
clicked += target.label
|
||||
@@ -734,6 +1112,7 @@ 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
|
||||
|
||||
+509
-22
@@ -9,19 +9,79 @@ 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 {
|
||||
@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)
|
||||
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
|
||||
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
|
||||
assertEquals(outcome.message, "rehearsal_completed", outcome.resultType)
|
||||
assertEquals(1, driver.clicked.count { it == "黑色" })
|
||||
assertEquals(1, driver.clicked.count { it == "XL" })
|
||||
}
|
||||
@Test
|
||||
fun `full exact summary confirms size after option leaves viewport`() {
|
||||
val target = "2XL 建议131到150斤"
|
||||
val driver = FakePurchaseDriver(sizes = listOf(target), hideSizeAfterSelection = true)
|
||||
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
|
||||
.execute(input().copy(mappedSize = target), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
|
||||
assertEquals(outcome.message, "rehearsal_completed", outcome.resultType)
|
||||
assertEquals(1, driver.clicked.count { it == target })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `same size token with different full range cannot establish selection`() {
|
||||
val target = "2XL 建议131到150斤"
|
||||
val driver = FakePurchaseDriver(
|
||||
sizes = listOf(target), hideSizeAfterSelection = true,
|
||||
selectedSizeSummaryOverride = "2XL 建议151到170斤",
|
||||
)
|
||||
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
|
||||
.execute(input().copy(mappedSize = target), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
|
||||
assertEquals("failed", outcome.resultType)
|
||||
}
|
||||
@Test
|
||||
fun `spec gesture policy excludes irreversible and out of bounds targets`() {
|
||||
fun target(label: String, bounds: NodeBounds = NodeBounds(20, 100, 300, 180)) = SnapshotNode(
|
||||
@@ -68,7 +128,7 @@ class PurchaseRehearsalExecutorTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `rule parameters drive aliases waits and bounded swipes without dangerous clicks`() {
|
||||
fun `rule parameters drive aliases and waits while deprecated panel swipes are ignored`() {
|
||||
val driver = FakePurchaseDriver()
|
||||
val pauses = mutableListOf<Long>()
|
||||
var openCount = 0
|
||||
@@ -82,7 +142,7 @@ class PurchaseRehearsalExecutorTest {
|
||||
assertEquals("rehearsal_completed", outcome.resultType)
|
||||
assertEquals(2_000L, outcome.actualUnitPriceCent)
|
||||
assertEquals(0, openCount)
|
||||
assertEquals(2, driver.swipeCount)
|
||||
assertEquals(0, driver.swipeCount)
|
||||
assertEquals(2L, driver.quantity)
|
||||
assertTrue(driver.clicked.containsAll(listOf("选择规格", "黑色", "XL")))
|
||||
assertFalse(driver.clicked.any { it.contains("订单") || it.contains("支付") })
|
||||
@@ -202,6 +262,159 @@ class PurchaseRehearsalExecutorTest {
|
||||
assertTrue(driver.swipeInPaths.all { it == "scroll" })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `exact color on later horizontal page is found by bounded fallback`() {
|
||||
val driver = FakePurchaseDriver(
|
||||
horizontalColorPages = listOf(listOf("黑色", "白色"), listOf("蓝色", "富贵粉")),
|
||||
)
|
||||
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
|
||||
.execute(input().copy(mappedColor = "富贵粉"), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
|
||||
|
||||
assertEquals("rehearsal_completed", outcome.resultType)
|
||||
assertEquals("富贵粉", driver.color)
|
||||
assertTrue(driver.horizontalSpecDirections.contains(SwipeDirection.LEFT))
|
||||
assertEquals(1, driver.clicked.count { it == "富贵粉" })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `visible exact color keeps established path without horizontal fallback`() {
|
||||
val driver = FakePurchaseDriver(colors = listOf("黑色", "白色"))
|
||||
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
|
||||
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
|
||||
|
||||
assertEquals("rehearsal_completed", outcome.resultType)
|
||||
assertTrue(driver.horizontalSpecDirections.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `single column colors never trigger horizontal fallback`() {
|
||||
val driver = FakePurchaseDriver(
|
||||
horizontalColorPages = listOf(listOf("黑色"), listOf("富贵粉")),
|
||||
)
|
||||
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
|
||||
.execute(input().copy(mappedColor = "富贵粉"), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
|
||||
|
||||
assertEquals("PURCHASE_SPEC_TARGET_NOT_VISIBLE", outcome.errorCode)
|
||||
assertTrue(outcome.message.contains("horizontalSwipes=0"))
|
||||
assertTrue(driver.horizontalSpecDirections.isEmpty())
|
||||
assertFalse(driver.clicked.contains("黑色"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `failed horizontal color swipe remains safely failed`() {
|
||||
val driver = FakePurchaseDriver(
|
||||
horizontalColorPages = listOf(listOf("黑色", "白色"), listOf("蓝色", "富贵粉")),
|
||||
horizontalSpecSwipeSucceeds = false,
|
||||
)
|
||||
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
|
||||
.execute(input().copy(mappedColor = "富贵粉"), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
|
||||
|
||||
assertEquals("PURCHASE_SPEC_TARGET_NOT_VISIBLE", outcome.errorCode)
|
||||
assertTrue(outcome.message.contains("horizontalFailure=gestureFailed"))
|
||||
assertTrue(driver.clicked.none { it == "富贵粉" })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `long exact size on later horizontal page is found after color selection`() {
|
||||
val targetSize = "3XL 推荐140-155斤"
|
||||
val driver = FakePurchaseDriver(
|
||||
horizontalSizePages = listOf(
|
||||
listOf("S 推荐80-95斤", "M 推荐95-110斤"),
|
||||
listOf("2XL 推荐125-140斤", targetSize),
|
||||
),
|
||||
)
|
||||
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
|
||||
.execute(input().copy(mappedSize = targetSize), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
|
||||
|
||||
assertEquals("rehearsal_completed", outcome.resultType)
|
||||
assertEquals(targetSize, driver.size)
|
||||
assertTrue(driver.horizontalSpecDimensions.contains("size"))
|
||||
assertEquals(1, driver.clicked.count { it == targetSize })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `single visible long size uses its dedicated horizontal container`() {
|
||||
val targetSize = "3XL 推荐140-155斤"
|
||||
val driver = FakePurchaseDriver(
|
||||
horizontalSizePages = listOf(listOf("S 推荐80-95斤"), listOf(targetSize)),
|
||||
)
|
||||
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
|
||||
.execute(input().copy(mappedSize = targetSize), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
|
||||
|
||||
assertEquals("rehearsal_completed", outcome.resultType)
|
||||
assertEquals(targetSize, driver.size)
|
||||
assertTrue(driver.horizontalSpecDimensions.contains("size"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `color and size horizontal fallbacks reacquire their own row anchors`() {
|
||||
val targetSize = "3XL 推荐140-155斤"
|
||||
val driver = FakePurchaseDriver(
|
||||
horizontalColorPages = listOf(listOf("黑色", "白色"), listOf("蓝色", "富贵粉")),
|
||||
horizontalSizePages = listOf(listOf("S 推荐80-95斤", "M 推荐95-110斤"), listOf("2XL 推荐125-140斤", targetSize)),
|
||||
)
|
||||
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
|
||||
.execute(
|
||||
input().copy(mappedColor = "富贵粉", mappedSize = targetSize),
|
||||
PurchaseRuleParser.parse(rule()),
|
||||
PurchaseAgentCapabilities.supported,
|
||||
)
|
||||
|
||||
assertEquals("rehearsal_completed", outcome.resultType)
|
||||
assertTrue(driver.horizontalSpecDimensions.containsAll(listOf("color", "size")))
|
||||
assertTrue(driver.horizontalSpecAnchorPaths.filterIndexed { index, _ -> driver.horizontalSpecDimensions[index] == "size" }
|
||||
.all { it.contains("size-row") })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `two column size grid continues vertically without horizontal container`() {
|
||||
val target = "3XL 推荐140-155斤"
|
||||
val driver = FakePurchaseDriver(sizes = listOf(target), revealGridSizeAfterUpSwipes = 7)
|
||||
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
|
||||
.execute(input().copy(mappedSize = target), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
|
||||
assertEquals("rehearsal_completed", outcome.resultType)
|
||||
assertEquals(target, driver.size)
|
||||
assertTrue(driver.horizontalSpecDirections.isEmpty())
|
||||
assertEquals(1, driver.clicked.count { it == target })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `failed horizontal gesture still permits vertical exact size recovery`() {
|
||||
val target = "3XL 推荐140-155斤"
|
||||
val driver = FakePurchaseDriver(
|
||||
sizes = listOf(target),
|
||||
horizontalSizePages = listOf(listOf("S", "M")),
|
||||
horizontalSpecSwipeSucceeds = false,
|
||||
revealGridSizeAfterUpSwipes = 7,
|
||||
)
|
||||
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
|
||||
.execute(input().copy(mappedSize = target), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
|
||||
assertEquals(outcome.message, "rehearsal_completed", outcome.resultType)
|
||||
assertEquals(target, driver.size)
|
||||
assertTrue(driver.horizontalSpecDirections.isNotEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `missing size terminates at stable viewport with bounded gestures`() {
|
||||
val driver = FakePurchaseDriver(sizes = listOf("S", "M"))
|
||||
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
|
||||
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
|
||||
assertEquals("PURCHASE_SPEC_TARGET_NOT_VISIBLE", outcome.errorCode)
|
||||
assertTrue(outcome.message.contains("reason=stableViewport"))
|
||||
assertTrue(driver.horizontalSpecDirections.isEmpty())
|
||||
assertTrue(driver.swipeCount < 35)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `visible exact size does not enter horizontal fallback`() {
|
||||
val driver = FakePurchaseDriver(sizes = listOf("L", "XL"))
|
||||
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
|
||||
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
|
||||
|
||||
assertEquals("rehearsal_completed", outcome.resultType)
|
||||
assertFalse(driver.horizontalSpecDimensions.contains("size"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `failed size click result continues when exact size is actually selected`() {
|
||||
val driver = FakePurchaseDriver(
|
||||
@@ -296,6 +509,17 @@ class PurchaseRehearsalExecutorTest {
|
||||
assertEquals(1, driver.clicked.count { it == "黑色" })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `final verification keeps current attempt proof when selected panel becomes unclassified`() {
|
||||
val driver = FakePurchaseDriver(panelBecomesUnknownAfterSizeProof = true)
|
||||
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
|
||||
.execute(input().copy(quantity = 1), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
|
||||
|
||||
assertEquals("rehearsal_completed", outcome.resultType)
|
||||
assertEquals(1, driver.clicked.count { it == "黑色" })
|
||||
assertEquals(1, driver.clicked.count { it == "XL" })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `final verification reuses exact attempt state without scrolling back`() {
|
||||
val driver = FakePurchaseDriver(
|
||||
@@ -361,7 +585,8 @@ class PurchaseRehearsalExecutorTest {
|
||||
assertEquals("PURCHASE_SPEC_SELECTION_UNCONFIRMED", outcome.errorCode)
|
||||
assertTrue(outcome.message.contains("dimension=size"))
|
||||
assertTrue(outcome.message.contains("reason=visible_candidate_conflict"))
|
||||
assertTrue(outcome.message.contains("proof=true"))
|
||||
assertTrue(outcome.message.contains("proofRecorded=true"))
|
||||
assertTrue(outcome.message.contains("proofUsable=true"))
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -569,7 +794,7 @@ class PurchaseRehearsalExecutorTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `open spec panel skips required follow-up swipe only for confirmed non-scrollable panel`() {
|
||||
fun `open spec panel still skips legacy follow-up swipe 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)
|
||||
@@ -578,6 +803,86 @@ 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)
|
||||
@@ -607,6 +912,84 @@ 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 `changed entry identity or unsafe page never receives recovery tap`() {
|
||||
for (mode in listOf("missing", "duplicate", "activity", "payment")) {
|
||||
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").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(
|
||||
@@ -1006,6 +1389,9 @@ class PurchaseRehearsalExecutorTest {
|
||||
|
||||
private class FakePurchaseDriver(
|
||||
private val colors: List<String> = listOf("黑色"),
|
||||
private val horizontalColorPages: List<List<String>>? = null,
|
||||
private val horizontalSizePages: List<List<String>>? = null,
|
||||
private val horizontalSpecSwipeSucceeds: Boolean = true,
|
||||
private val sizes: List<String> = listOf("XL"),
|
||||
private val priceCent: Long = 2_000,
|
||||
private val duplicateOpen: Boolean = false,
|
||||
@@ -1019,6 +1405,9 @@ class PurchaseRehearsalExecutorTest {
|
||||
private val openReviewOnBottomClick: Boolean = false,
|
||||
private val reviewBackSucceeds: Boolean = true,
|
||||
private val hiddenSizeUntilUpSwipes: Int = 0,
|
||||
private val prefixlessSingleHeadingAfterColor: Boolean = false,
|
||||
private val hideSizeAfterSelection: Boolean = false,
|
||||
private val revealGridSizeAfterUpSwipes: Int? = null,
|
||||
private val openClickResults: MutableList<FreshActionResult> = mutableListOf(),
|
||||
private val openPddOnFailedClick: Boolean = false,
|
||||
private val sizeClickResults: MutableList<FreshActionResult> = mutableListOf(),
|
||||
@@ -1026,6 +1415,10 @@ 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,
|
||||
@@ -1043,11 +1436,15 @@ 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 {
|
||||
var browser = false
|
||||
private var inAgent = initiallyInAgent
|
||||
@@ -1062,6 +1459,9 @@ class PurchaseRehearsalExecutorTest {
|
||||
var pullDownCount = 0
|
||||
var backCount = 0
|
||||
var specTapCount = 0
|
||||
val horizontalSpecDirections = mutableListOf<SwipeDirection>()
|
||||
val horizontalSpecDimensions = mutableListOf<String>()
|
||||
val horizontalSpecAnchorPaths = mutableListOf<String>()
|
||||
val swipeInPaths = mutableListOf<String>()
|
||||
private var soldOut = soldOut
|
||||
private var allSpecsUnavailable = allSpecsUnavailable
|
||||
@@ -1070,6 +1470,10 @@ class PurchaseRehearsalExecutorTest {
|
||||
private var pddCaptureCount = 0
|
||||
private var browserCaptureCount = 0
|
||||
private var hiddenColorRestored = false
|
||||
private var horizontalColorPage = 0
|
||||
private var horizontalSizePage = 0
|
||||
private var capturesAfterSizeSelection = 0
|
||||
private var entryAttempted = false
|
||||
val clicked = mutableListOf<String>()
|
||||
val clickedPaths = mutableListOf<String>()
|
||||
|
||||
@@ -1087,6 +1491,17 @@ class PurchaseRehearsalExecutorTest {
|
||||
openNodes += node("content", "", 0, 0, 1080, 2200)
|
||||
return UiSnapshot("com.heytap.browser", "BrowserActivity", openNodes)
|
||||
}
|
||||
if (panelBecomesUnknownAfterSizeProof && panel && size != null) {
|
||||
capturesAfterSizeSelection++
|
||||
if (capturesAfterSizeSelection > 1) {
|
||||
return UiSnapshot(PDD, ACTIVITY, listOf(
|
||||
node("content", "", 0, 0, 1080, 2200),
|
||||
node("price", "¥20.00", 20, 300, 300, 360),
|
||||
node("quantity", "1", 400, 800, 600, 870, className = "android.widget.EditText"),
|
||||
node("confirm", "确定", 20, 900, 500, 980, clickable = true),
|
||||
))
|
||||
}
|
||||
}
|
||||
pddCaptureCount++
|
||||
if (pddCaptureCount <= loadingPddCaptures) {
|
||||
return UiSnapshot(PDD, ACTIVITY, listOf(node("content", "", 0, 0, 1080, 2200)))
|
||||
@@ -1121,6 +1536,8 @@ 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")
|
||||
@@ -1129,6 +1546,12 @@ 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") nodes += node("payment", "微信支付", 20, 1800, 900, 1900)
|
||||
if (afterEntryMutation == "activity") return UiSnapshot(PDD, "OtherActivity", nodes)
|
||||
}
|
||||
return UiSnapshot(PDD, ACTIVITY, nodes)
|
||||
}
|
||||
if (unrecognizedPanel) {
|
||||
@@ -1137,8 +1560,9 @@ class PurchaseRehearsalExecutorTest {
|
||||
node("panel-title", "确认款式", 20, 396, 300, 430),
|
||||
))
|
||||
}
|
||||
val hideColor = hideColorAfterQuantitySet && quantity == 2L && !hiddenColorRestored
|
||||
val hideSize = hideSizeAfterQuantitySet && quantity == 2L
|
||||
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
|
||||
val displayedSummary = if (quantity == 2L && selectedSummaryOverrideAfterQuantitySet != null) {
|
||||
selectedSummaryOverrideAfterQuantitySet
|
||||
@@ -1151,13 +1575,35 @@ class PurchaseRehearsalExecutorTest {
|
||||
node("title", "确认款式", 20, 396, 300, 430),
|
||||
)
|
||||
if (!nonScrollablePanel) nodes += node("scroll", "", 0, 400, 1080, 950, scrollable = true)
|
||||
if (!hideSummary) {
|
||||
if (singleHeading) {
|
||||
nodes += node("info", "", 0, 300, 1080, 400)
|
||||
nodes += node("info/summary", displayedSummary, 20, 320, 700, 350, parentPath = "info")
|
||||
nodes += node("close", "关闭", 980, 300, 1060, 350, clickable = true)
|
||||
nodes += node("minus", "减少数量", 300, 360, 380, 390, clickable = true)
|
||||
nodes += node("plus", "增加数量", 620, 360, 700, 390, clickable = true)
|
||||
nodes += node("payment", "微信支付", 600, 1000, 900, 1050)
|
||||
} else if (!hideSummary) {
|
||||
nodes += node("selected", "已选 $displayedSummary", 20, 365, 700, 395)
|
||||
}
|
||||
if (!hideColor) {
|
||||
nodes += node("scroll/color-heading", "颜色分类", 20, 410, 300, 450, parentPath = "scroll")
|
||||
val selectedColor = if (quantity == 2L) selectedColorOverrideAfterQuantitySet ?: color else color
|
||||
colors.forEachIndexed { index, value ->
|
||||
val visibleColors = horizontalColorPages?.get(horizontalColorPage) ?: colors
|
||||
val colorParent = if (horizontalColorPages != null && visibleColors.size > 1) "scroll/color-row" else "scroll"
|
||||
if (colorParent != "scroll") {
|
||||
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,
|
||||
@@ -1167,38 +1613,54 @@ class PurchaseRehearsalExecutorTest {
|
||||
540,
|
||||
clickable = true,
|
||||
selected = selectedColor == value,
|
||||
enabled = !allSpecsUnavailable,
|
||||
parentPath = "scroll",
|
||||
enabled = !allSpecsUnavailable && value !in unavailableColors,
|
||||
parentPath = colorParent,
|
||||
)
|
||||
}
|
||||
}
|
||||
if (!hideSize) {
|
||||
nodes += node("scroll/size-heading", "尺码", 20, 650, 300, 690, parentPath = "scroll")
|
||||
val visibleSizes = if (quantity == 2L && finalSizesAfterQuantitySet != null) {
|
||||
val visibleSizes = if (revealGridSizeAfterUpSwipes != null) {
|
||||
if (upSwipeCount >= revealGridSizeAfterUpSwipes) sizes else listOf("S", "M")
|
||||
} else horizontalSizePages?.get(horizontalSizePage) ?: if (quantity == 2L && finalSizesAfterQuantitySet != null) {
|
||||
finalSizesAfterQuantitySet
|
||||
} else if (upSwipeCount >= hiddenSizeUntilUpSwipes) {
|
||||
sizes
|
||||
} else {
|
||||
listOf("S")
|
||||
}
|
||||
val sizeParent = if (horizontalSizePages != null) "scroll/size-row" else "scroll"
|
||||
if (horizontalSizePages != null) {
|
||||
nodes += node(sizeParent, "", 0, 700, 1080, 800, scrollable = true, parentPath = "scroll")
|
||||
}
|
||||
visibleSizes.forEachIndexed { index, visibleSize ->
|
||||
nodes += node(
|
||||
"scroll/size-$index",
|
||||
"$sizeParent/size-$index",
|
||||
visibleSize,
|
||||
20 + index * 250,
|
||||
710,
|
||||
710 - if (revealGridSizeAfterUpSwipes != null) upSwipeCount.coerceAtMost(10) * 2 else 0,
|
||||
220 + index * 250,
|
||||
780,
|
||||
780 - if (revealGridSizeAfterUpSwipes != null) upSwipeCount.coerceAtMost(10) * 2 else 0,
|
||||
clickable = true,
|
||||
selected = !hideSizeSelectedState && size == visibleSize,
|
||||
enabled = !allSpecsUnavailable && visibleSize !in unavailableSizes,
|
||||
parentPath = "scroll",
|
||||
parentPath = sizeParent,
|
||||
)
|
||||
}
|
||||
}
|
||||
nodes += node("quantity", quantity.toString(), 400, 800, 600, 870, className = "android.widget.EditText")
|
||||
nodes += node("confirm", "确定", 20, 900, 500, 980, clickable = true)
|
||||
nodes += node("order", "提交订单", 20, 1100, 500, 1180, clickable = true)
|
||||
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("pay", "立即支付", 520, 1100, 1020, 1180, clickable = true)
|
||||
return UiSnapshot(PDD, ACTIVITY, nodes)
|
||||
}
|
||||
@@ -1225,13 +1687,13 @@ class PurchaseRehearsalExecutorTest {
|
||||
return result
|
||||
}
|
||||
"选择规格", "免拼购买" -> if (entryActionHasEffect) panel = true
|
||||
in sizes -> {
|
||||
in (horizontalSizePages.orEmpty().flatten() + sizes) -> {
|
||||
sizeClickCount++
|
||||
val result = sizeClickResults.removeFirstOrNull() ?: FreshActionResult.SUCCESS
|
||||
if (result == FreshActionResult.SUCCESS || sizeSelectsOnFailedClick) size = target.label
|
||||
return result
|
||||
}
|
||||
in colors -> color = target.label
|
||||
in (horizontalColorPages?.flatten() ?: colors) -> color = target.label
|
||||
"增加数量" -> quantity++
|
||||
"减少数量" -> quantity--
|
||||
}
|
||||
@@ -1239,6 +1701,7 @@ 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
|
||||
@@ -1248,7 +1711,7 @@ class PurchaseRehearsalExecutorTest {
|
||||
}
|
||||
return FreshClickOutcome(result, forcedEntryClickReason)
|
||||
}
|
||||
if (target.label in sizes && forcedSizeClickReason != null) {
|
||||
if (target.label in (horizontalSizePages?.flatten() ?: sizes) && forcedSizeClickReason != null) {
|
||||
sizeClickCount++
|
||||
val result = when (forcedSizeClickReason) {
|
||||
FreshClickReason.ROOT_UNAVAILABLE, FreshClickReason.TARGET_NOT_FOUND -> FreshActionResult.NOT_FOUND
|
||||
@@ -1275,7 +1738,7 @@ class PurchaseRehearsalExecutorTest {
|
||||
if (specTapResult != FreshActionResult.SUCCESS || !specTapHasEffect) return specTapResult
|
||||
when {
|
||||
target.path in setOf("spec", "buy") -> panel = true
|
||||
target.label in sizes -> size = target.label
|
||||
target.label in (horizontalSizePages?.flatten() ?: sizes) -> size = target.label
|
||||
target.label in colors -> color = target.label
|
||||
}
|
||||
return FreshActionResult.SUCCESS
|
||||
@@ -1292,6 +1755,8 @@ 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) {
|
||||
@@ -1300,6 +1765,28 @@ class PurchaseRehearsalExecutorTest {
|
||||
return swipePurchase(direction, durationMs)
|
||||
}
|
||||
|
||||
override fun swipeSpecRow(target: SnapshotNode, direction: SwipeDirection): Boolean {
|
||||
horizontalSpecDirections += direction
|
||||
horizontalSpecAnchorPaths += target.path
|
||||
val dimension = if (target.label in (horizontalSizePages?.flatten() ?: emptyList())) "size" else "color"
|
||||
horizontalSpecDimensions += dimension
|
||||
if (!horizontalSpecSwipeSucceeds) return false
|
||||
if (dimension == "size") {
|
||||
horizontalSizePage = when (direction) {
|
||||
SwipeDirection.LEFT -> (horizontalSizePage + 1).coerceAtMost((horizontalSizePages?.lastIndex ?: 0))
|
||||
SwipeDirection.RIGHT -> (horizontalSizePage - 1).coerceAtLeast(0)
|
||||
else -> horizontalSizePage
|
||||
}
|
||||
} else {
|
||||
horizontalColorPage = when (direction) {
|
||||
SwipeDirection.LEFT -> (horizontalColorPage + 1).coerceAtMost((horizontalColorPages?.lastIndex ?: 0))
|
||||
SwipeDirection.RIGHT -> (horizontalColorPage - 1).coerceAtLeast(0)
|
||||
else -> horizontalColorPage
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
override fun pullDownGoodsPage(): Boolean {
|
||||
pullDownCount++
|
||||
if (!pullDownSucceeds) return false
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
package cn.ilapage.goauto.agent
|
||||
|
||||
import cn.ilapage.goauto.agent.automation.NodeBounds
|
||||
import cn.ilapage.goauto.agent.automation.PurchaseScrollCandidate
|
||||
import cn.ilapage.goauto.agent.automation.PurchaseScrollLocator
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
|
||||
class PurchaseScrollLocatorTest {
|
||||
private val outer = PurchaseScrollCandidate("0/1", "RecyclerView", NodeBounds(0, 1038, 1080, 2079))
|
||||
private val inner = PurchaseScrollCandidate("0/1/0", "RecyclerView", NodeBounds(36, 1149, 1080, 2007))
|
||||
|
||||
@Test fun nestedContainersWithNearbyCentersResolveOuter() {
|
||||
assertEquals(outer, PurchaseScrollLocator.locate(outer, listOf(inner, outer)))
|
||||
}
|
||||
|
||||
@Test fun pathDisambiguatesIdenticalBounds() {
|
||||
val nested = outer.copy(path = "0/1/0")
|
||||
assertEquals(outer, PurchaseScrollLocator.locate(outer, listOf(nested, outer)))
|
||||
}
|
||||
|
||||
@Test fun changedPathRequiresUniqueFullBoundsMatch() {
|
||||
val moved = outer.copy(path = "0/2")
|
||||
assertEquals(moved, PurchaseScrollLocator.locate(outer, listOf(inner, moved)))
|
||||
assertNull(PurchaseScrollLocator.locate(outer, listOf(moved, moved.copy(path = "0/3"))))
|
||||
}
|
||||
|
||||
@Test fun reusedPathWithDifferentGeometryIsRejected() {
|
||||
assertNull(PurchaseScrollLocator.locate(outer, listOf(inner.copy(path = outer.path))))
|
||||
}
|
||||
|
||||
@Test fun classAndAllEdgesAreValidated() {
|
||||
assertNull(PurchaseScrollLocator.locate(outer, listOf(outer.copy(className = "ScrollView"))))
|
||||
assertNull(PurchaseScrollLocator.locate(outer, listOf(
|
||||
outer.copy(bounds = NodeBounds(0, 1138, 1080, 1979)),
|
||||
)))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
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)))
|
||||
}
|
||||
}
|
||||
+81
@@ -0,0 +1,81 @@
|
||||
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 `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())
|
||||
}
|
||||
}
|
||||
@@ -2,8 +2,8 @@
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Project-Profile
|
||||
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Project-Profile.-
|
||||
wiki_revision: 7468b9fbdd4d0bbbb9a73580c22ec868b3085753
|
||||
synchronized_at: 2026-09-05T07:16:39Z
|
||||
wiki_revision: 3b78360779ae520f1ff9e51be3118a04cd549f51
|
||||
synchronized_at: 2026-09-07T09:27:40Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 项目档案
|
||||
@@ -61,6 +61,7 @@ GoAuto 默认采用轻量治理:文案、注释、格式、局部样式或布
|
||||
## 环境与凭据
|
||||
|
||||
- 服务端正式环境默认必须使用 HTTPS。仅当管理员接受 Device Token、任务内容和执行结果明文传输风险,并显式设置 `GOAUTO_ALLOW_INSECURE_AGENT_HTTP=true` 时,Agent `/api/agent/v1/**` 可通过 HTTP;管理端和第三方服务不因此放宽。
|
||||
- #237 客户端密钥例外(用户 2026-09-07 明确接受风险):提交 `71f7751` 起,管理员密钥管理及 `/api/client/v1` 默认兼容 HTTP/HTTPS,无开关;HTTP 明文传输密钥及业务数据,建议优先 HTTPS。实际迁移部署仍须单独授权;此例外不改变其他第三方服务的安全边界。
|
||||
- 每台设备使用独立 Device Token;Token 只存安全配置,不进入仓库。
|
||||
- PDD 账号密码、Cookie、验证码和用户个人数据不得进入日志。
|
||||
- 根目录 `gitea.env` 是本机工单访问配置,已被 Git 忽略。
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Architecture-and-Code-Map
|
||||
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Architecture-and-Code-Map.-
|
||||
wiki_revision: b1b1b343917e66288f4282bc6b3b90ea4ff3cca0
|
||||
synchronized_at: 2026-09-04T11:29:50Z
|
||||
wiki_revision: 223cb9035a6b6e63072aa6b075889fcd0d6c2f23
|
||||
synchronized_at: 2026-09-09T08:18:24Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 架构与代码地图
|
||||
@@ -68,6 +68,8 @@ Android Portal/Agent
|
||||
| `pdd_account` | 可选的账号调度引用,只保存名称和状态,不保存凭据 |
|
||||
| `purchase_task` | 商品外键和不可变快照、执行模式、状态/租约 guard、价格边界、订单、人工支付复核、物流与回填事实 |
|
||||
| `purchase_task_attempt` | `task_id + attempt_id` 幂等执行记录、阶段、规则哈希、固化规格决策和结构化错误 |
|
||||
|
||||
> #241 新增:`server/app/goauto/purchase/order_backfill.go` 与 `order_backfill_handler.go` 提供 `POST /api/agent/v1/purchase-tasks/order-backfill`(Device Token 鉴权,逐条事务、逐条结果);`server/app/goauto/models/purchase_order_guard.go` 在 `PurchaseTask.BeforeSave` 上全局强制订单号唯一,以 `purchase_rule_setting` 单例行串行化订单号分配,避免新增唯一索引迁移;`purchasecontract.ParseAddressSuffix` 为 `AddressSuffix` 的反解,通过回比而非负向前瞻实现(Go RE2 不支持前瞻)。
|
||||
| `ai_matching_setting` | 唯一单例的启用状态、OpenAI-compatible Base URL、模型、超时、内部部署明文 API Key 和更新人;仅管理员设置接口可以读取该字段 |
|
||||
|
||||
`collection_task` 的状态仅为 `pending`、`running`、`completed`、`completed_partial`、`failed`。设备身份和心跳表属于 Agent 领取任务的必要基础,不承载 PDD 业务数据。
|
||||
@@ -333,3 +335,45 @@ PddProductDetailCollector
|
||||
- `GET /api/v1/sysjob/:id/execution-logs` 由 `server/app/jobs/service/execution_log.go`、`apis/execution_log.go` 和 `router/sys_job.go` 提供任务级分页、状态与开始时间过滤;沿用隐藏菜单 `JobLog` 的角色菜单绑定和精确 GET 权限。Web 入口为 `web/src/views/schedule/index.vue` 的单选“日志”按钮,详情页为 `web/src/views/schedule/log.vue`。
|
||||
- 执行历史明确不保存任务参数、AI Provider 地址或密钥、第三方原始响应和业务原始载荷;当前不提供删除、保留期限自动化、WebSocket 实时流或立即执行动作。
|
||||
- 追加迁移为 `server/cmd/migrate/migration/version-local/1788357000000_sys_job_execution_log.go`:创建执行历史表、登记只读 API、关联 `JobLog` 菜单,并只给迁移前已绑定该菜单的角色补充精确 Casbin 权限。
|
||||
|
||||
## 客户端密钥访问架构(#237)
|
||||
|
||||
代码基线 `71f7751`(含用户确认的默认 HTTP/HTTPS 兼容),2026-09-07 完成 Server/Web 代码与隔离测试;本机迁移、管理员菜单写入与 Server/Web 启动已于 2026-09-07 授权完成,真实 HTTP 管理列表和模块目录加载通过;线上仍未部署。
|
||||
|
||||
- `server/app/goauto/clientkey` 管理独立密钥、授权及审计;`clientapi/routes.go` 的显式 Inventory 将 `/api/client/v1` 映射到既有业务处理器,绝不转发任意 Admin 路由。
|
||||
- `clientapi/gateway.go` 默认接受 HTTP 与 HTTPS,无协议开关或转发协议头门禁,按 Bearer 密钥、模块及能力顺序校验,每次请求读取数据库,无授权缓存。`common/clientprincipal` 传递独立客户端身份,不生成或伪装管理员 JWT。
|
||||
- `client_api_key` 保存名称、随机 256 位密钥的 SHA-256 摘要、前缀、授权 JSON、启用状态、版本、创建/修改人和最后使用时间;完整密钥仅创建响应一次返回。`client_api_key_audit` 保存管理变更和客户端请求元数据,不保存请求正文、查询参数、响应正文或凭据。
|
||||
- 编辑与停用采用启用状态和 version 条件更新,并与变更审计同事务提交;冲突返回 409。业务执行前先持久化请求审计意图,失败则不执行业务。完成后更新状态;更新失败或进程中断可能留下 status=0,表示结果待核对,不能据此自动重放。
|
||||
- 部分既有业务操作人字段使用该密钥最近授权管理员的 ID 兼容现有外键;实际调用方以独立审计的 key_id 为准,不能把业务字段当成人工操作证据。
|
||||
- Admin 页面 `web/src/views/goauto/client-keys/index.vue` 复用创建/编辑授权弹窗;菜单位于“采采管理”,仅管理员可见。新追加迁移 `1788798000000_client_api_key.go` 创建两表及管理员菜单,不改 Android。
|
||||
|
||||
## 采购规格面板预滑动兼容(#238)
|
||||
|
||||
代码基线 `58a6c1c`,Android 0.9.60 / versionCode 73(构建完成不等同于已安装/发布)。`PurchaseRehearsalExecutor.applyPostAction` 对 `openSpecPanel.swipeAfter` 只兼容解析、不执行机械预滑动,`waitAfterMs` 保留;首趟继续原 `probeSpecs` 遍历,第二趟继续原 `selectSpec` 精确查找与容器内有界滚动。其他动作的后置滑动仍沿用既有执行语义,失败不会被统一忽略。
|
||||
|
||||
`GoAutoAccessibilityService.swipePurchase` 的失败分类由 `PurchaseSwipeFailureReason` 枚举提供;共享 `swipeNode` 仅增加可选分类回调,不改变手势目标、轨迹、1500ms 回调等待或其他调用者行为。`GoAutoPurchasePanel` 日志经 `AgentForegroundService` 关联 task、attempt、device 与规则快照哈希,新增预滑动跳过/必需滑动失败标量;不记录节点文字、坐标、原始控件树、截图或凭据。
|
||||
|
||||
Server/Web、数据库和任务快照不变;旧 APK 仍有预滑动行为,必须更新 Agent 才生效。相关验证在 `PurchaseRehearsalExecutorTest`,Android 全量测试与 APK 构建入口不变。
|
||||
|
||||
## SYB 逐页保存与部分成功(#239)
|
||||
|
||||
实现绑定 c6a962d;代码已实现不代表当前线上已部署。每页完整明细在外部请求结束后按页事务保存;页回滚不累计明细/新增/覆盖数,已提交页保留。日期局部读取失败继续下一日期,全局数据库/进度/会话/取消故障停止。当天漂移不在一次运行内重扫;后续运行重新扫描并幂等补齐。
|
||||
|
||||
同步状态增加 `partial_success`(部分成功,15 字符,复用现有 varchar(16),无需迁移)。有错误且 created+updated>0 为部分成功;有错误无已提交明细为 failed;完整且无错误为 succeeded(包括无符合店铺的数据)。中断仍为 interrupted,不把中断追认为成功。所有终态沿用活动槽释放规则。
|
||||
|
||||
`orderCount` 是已验证页的原始列表读取数量;`detailCount`、`created`、`updated` 为已提交明细及其新增/覆盖数量;`daysProcessed` 是完整通过的日期数,不是已尝试日期数。失败日期/页码/阶段写入现有脱敏限长 errorMessage。部分成功不刷新店铺的完整同步统计。
|
||||
|
||||
Web 唯一展示位置为“采集采购 → SYB 同步记录”:列表状态、状态筛选及详情支持部分成功,详情保留已保存数量、错误原因与重新同步补齐提示。定时任务日志只表示异步任务受理,不等于最终业务同步成功。
|
||||
|
||||
入口为 `SyncWithShopSnapshot → loadDailyList → importSyncPage`;`server/app/goauto/sybimport/sync_page.go` 封装页事务和提交后计数;`import_handler.go` 判定终态,`sync_run.go` 保存及筛选,Web 复用 `web/src/views/goauto/syb-sync-runs/index.vue`。
|
||||
|
||||
## Android 连续临时采集收尾(#250)
|
||||
|
||||
当前实现绑定 `37e8b56`(Agent 0.9.72 / 85),用户确认 v5 按次数返回后切回 Agent。仅 Android,无 Admin/API/权限/数据库迁移;取代 v1~v4 首页导航。
|
||||
|
||||
- `AgentSettingsFragment` 在采集间隔后复用卡片和 SwitchMaterial;仅开启显示“返回次数”按钮,Material 单选框选择 1~5 次(默认 3),立即保存或取消。成功无常驻反馈,错误/忙碌显示原因。任务忙碌禁用,弹窗选定时再检查。
|
||||
- `AgentSettingsStore` 在私有 `goauto_agent_settings` 保存 `continuous_collection`(false)与 `continuous_collection_return_count`(3);次数越界或读取错误回落 3,写入失败恢复旧值,无网络加载。
|
||||
- `requestCurrentPageCollection` 保留原范围判断;`executeTask` 在采集开始固定次数。`executeTaskWhileAwake` 提交结果、结束图片上传后返回 resultStatus;`ContinuousCollectionPolicy.finish` 仅对完整确认成功调用新 `PddCollectionReturnNavigator`。失败/部分成功不导航。冷却间隔不变,连续模式不启动旧 idle-return。
|
||||
- 删除 `PddCollectionHomeNavigator` 和专属首页/搜索层判据。新导航器仅使用 capture/back、当前包名、openAgentPreservingTab;1~5 次 Back,每次后 500ms,前台离开 PDD 终止剩余 Back;保留风险场景停止。最后打开 Agent 并等待 500ms 验证包名,不验证 PDD 首页。仍在原任务互斥与唤醒锁范围内完成,结束后原 finally 释放。
|
||||
- `GoAutoCollector` 日志关联 taskId、attemptNumber、deviceId、规则快照 SHA-256;记录 continuousReturn、configuredBacks、固定 reason 和 actions。原因含 count_completed、left_pdd、back_failed、unsafe_page、agent_launch_failed、agent_not_confirmed、navigation_exception、invalid_count;actions 为 Back 尝试数,不代表最终页面。无节点文字、树、截图或个人数据落盘;不扩大采购诊断存储。
|
||||
- `ContinuousCollectionTest` 覆盖范围/终态、1~5 次、无变化仍按次数、前台变化、风险、动作/启动失败、包名确认和异常;`ContinuousCollectionSettingsTest` 覆盖开关与次数持久化、默认/越界/写入失败。`:app:testDebugUnitTest :app:assembleDebug` 376 项通过。旧首页专属测试随旧行为替换,新版未安装,UI 大字体/横屏、多设备和完整采集真机链路尚未验证。
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Business-Rules-and-Glossary
|
||||
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Business-Rules-and-Glossary.-
|
||||
wiki_revision: 240e8fb9e365d534bbf0c7b2cd8ff1438bc8857b
|
||||
synchronized_at: 2026-09-05T09:03:48Z
|
||||
wiki_revision: 5a49f94d2c8bebe4e05bf3343834567e294e3894
|
||||
synchronized_at: 2026-09-09T08:18:31Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 业务规则与术语
|
||||
@@ -58,6 +58,13 @@ synchronized_at: 2026-09-05T09:03:48Z
|
||||
- 对采购人员展示的阶段固定为:待人工处理、未关联 PDD、PDD 待采集、PDD 采集中、PDD 采集失败、规格待匹配、可创建采购、已创建任务、采购成功、待人工核对。
|
||||
- 主阶段优先级为:待人工核对 → 采购成功 → 已创建任务 → 待人工处理 → 未关联 PDD → PDD 待采集/采集中/采集失败 → 规格待匹配 → 可创建采购。每行只显示一个阶段和对应下一步。
|
||||
- “待人工核对”表示订单结果不明确,必须先人工核查并禁止自动重试;“采购成功”表示已取得 PDD 订单号和下单时间,不代表已经支付。
|
||||
- 一个 PDD 订单号只能属于一个采购任务,该唯一性在采购任务保存路径上全局强制(#241)。人工处理结果未知、取消及 lifecycle 保存路径撞号时返回 `PURCHASE_ORDER_NUMBER_ALREADY_USED`,提示订单号已属于哪个任务,由采购员人工核对,不静默覆盖原值。
|
||||
- 不可逆边界之后的 `order_created` 结果回传是上述规则的例外:此时 PDD 真单已创建,发现订单号已属于其他任务时不回滚、不判失败,而是把任务降级为 `order_result_unknown`,冲突订单号以「读到订单号 X,但该号已属于任务 CG-yy」保存在任务与 attempt 的 `error_message`,`pdd_order_no` 留空以维持唯一性,保留下单时间与不可逆时间,进入既有人工处理结果未知通道。首要目标是保住「真单已存在」这一事实,不制造无记录的真实订单。
|
||||
- Agent 可按收货地址后缀 `_cg<任务号>` 批量回填订单号与下单时间(#241)。
|
||||
- Agent 侧回填为**人工触发的只读扫描**(#242):入口在采购记录页搜索按钮右侧,输入天数(默认 2)后确认启动。天数口径为滚动 N×24 小时,基准是页面读到的下单时间。采集、采购、回填三者互斥,复用既有任务互斥锁并加原子防重入,忙碌时拒绝启动而不排队、不抢占。
|
||||
- 回填扫描不假设订单列表有序:只有连续观察到至少 5 单且下单时间严格递减、期间无缺失时间时,才允许「超过指定时间即停止」;一旦出现时间回升或缺失,改为扫至内部上限并明确标记「未完整扫描」,不得把不完整结果显示为已扫完。内部上限用于限制设备占用,不作为用户可配置的门禁。
|
||||
- 回填扫描全程只读:确认收货、申请退款、催发货、去支付、立即支付、提交订单、付款、退款、取消订单、再次购买、删除订单永久排除为点击目标;点击目标必须自身可点、子树不含上述词,且不与任何含上述词的可见控件几何重叠;不得按固定坐标盲点。遇登录、验证码或风控立即停止并报告。
|
||||
- 解析不出规范后缀的订单一律跳过,其订单数据不缓存、不上传、不入日志;上传载荷只含后缀、订单号与可选下单时间。本地缓存只采纳服务端确认的事实,并区分页面读到的真实时间与 `irreversible_at` 回落估算值。后缀只承载任务号,请求不含地址全文或收件人信息;只允许回填该设备自己的正式采购任务;页面下单时间优先,缺失时回落该任务的 `irreversible_at` 并标记时间来源,两者皆空则该条失败。
|
||||
- 已失败、已取消或演练完成的旧采购任务不单独占用主阶段;当前数据仍满足条件时恢复显示“可创建采购”,旧任务继续在采购管理留痕和按既有规则处理。
|
||||
- 未选择处理阶段时,商品列表仍先返回,当前页阶段和采购准备继续异步批量读取且不调用 AI Provider;选择阶段筛选时,服务端必须先对完整查询结果派生并筛选阶段,再计算总数和分页,不能只过滤当前页。
|
||||
|
||||
@@ -67,13 +74,13 @@ synchronized_at: 2026-09-05T09:03:48Z
|
||||
- 版本迁移会对历史存活店铺回填 `normalized_name = Normalize(display_name)`;回填只改匹配键且可重复执行。若两个存活店铺回填后会得到同一键,迁移必须整体失败并保留原数据,管理员先人工消除歧义后再执行,不能静默合并、删除或改变店铺启用状态。
|
||||
- 店铺可以从 SYB 真实货运单列表发现,也允许管理员手工补充。只有管理员可以新增、改名、启停和软删除,采购员等其他角色只读。
|
||||
- 没有任何启用店铺时,导入必须在读取凭据、建立会话、验证码 OCR 和任意 SYB 网络请求之前失败,并给出“请先启用店铺”的可读提示。
|
||||
- 同步必须先拉取并校验当天原始全量列表的总数、分页和唯一 ID,再按本次同步开始时固定的启用店铺快照过滤;过滤不能降低完整性校验的请求范围或容量上限。
|
||||
- 同步先预检全范围总数及上限,每页校验原始列表条数、合法 ID 和重复,再按冻结店铺快照获取明细并页事务保存;整日结束核对总数。过滤不能降低完整性校验范围,已保存不能冒充整日完整(#239,c6a962d,已于 2026-09-08 随 d403f3b 部署线上)。
|
||||
- 只有列表与明细响应的店铺名都非空且命中启用快照时才允许写入。明细店铺名为空、变化为未启用店铺或无法匹配时跳过,并计入跳过数量。
|
||||
- 停用或软删除店铺只影响后续导入,不删除历史 SYB 商品、虾皮商品或任务数据。已有错误导入数据的清理必须先给出精确 SQL 和影响行数,再由用户单独确认。
|
||||
|
||||
## SYB 后台导入记录
|
||||
|
||||
- 导入由管理员创建,创建成功后立即转入后台执行;页面关闭不取消任务。采购员等其他已登录角色可以查看同步记录,不能开始导入。
|
||||
- 导入允许管理员和采购员(purchaser)创建,创建成功后立即转入后台执行;页面关闭不取消任务。其他已登录角色只能查看同步记录,不能开始导入(#236)。
|
||||
- 系统同一时刻只运行一个 SYB 导入任务。`syb_sync_run` 的唯一执行槽负责跨进程互斥,内存锁减少同一进程内的竞争;服务重启后遗留的执行中记录标记为“已中断”。
|
||||
- 导入失败或中断时保留已写入商品,记录明确的失败原因和已处理进度;重新导入相同范围按「订单号 + 明细 ID」覆盖,不产生重复商品。
|
||||
- 创建同步记录时冻结本次启用店铺的规范化匹配集合、展示名称快照及 SHA-256 哈希;后台执行必须只使用这一份快照,不得在开始后重新读取 `syb_shop`。同步详情保存并返回快照可用标记、快照店铺名称、哈希及各店铺“已导入/已跳过”数量。#212 之前的历史记录只有哈希和统计,明确标记为无完整快照。上述记录不保存账号、密码、Cookie、Token、验证码图片或 SYB 原始响应。
|
||||
@@ -168,7 +175,7 @@ synchronized_at: 2026-09-05T09:03:48Z
|
||||
|
||||
- 管理端固定支持 `admin`(管理员)和 `purchaser`(采购员)两类业务角色;用户必须绑定一个存在且启用的角色,`role_id=0` 或停用角色不能创建或保存。
|
||||
- 采购员可读取设备状态,维护 PDD/虾皮商品和规格映射,查看与修正 SYB 商品,读取 SYB 店铺及同步记录,读取采集规则,创建/重置/删除采集任务,并创建、查看、重试及人工处理采购任务。
|
||||
- 仅管理员可管理用户、角色、菜单、接口、部门和岗位;停用设备或吊销 Device Token;新增、改名、启停、删除或发现 SYB 店铺;手动启动 SYB 同步;新增、编辑或删除采集规则;保存或测试 AI Provider 配置。
|
||||
- 仅管理员可管理用户、角色、菜单、接口、部门和岗位;停用设备或吊销 Device Token;新增、改名、启停、删除或发现 SYB 店铺;新增、编辑或删除采集规则;保存或测试 AI Provider 配置。
|
||||
- AI 规格匹配菜单对采购员硬排除:采购员不显示该菜单,角色配置也不能为采购员选中该模块;既有 API 权限不变,仍只允许读取是否启用,不得读取 Provider 地址、模型、API Key,也不得保存或测试。
|
||||
- GoAuto 菜单由代码维护的模块定义幂等写入系统菜单和菜单/API 关联;迁移只为采购员追加首次引入且默认开放的模块,不会把采购员人工取消勾选的既有模块重新加回。
|
||||
- 采购员 Casbin API 白名单由代码权限矩阵全量重建,不从角色菜单勾选反推;减少权限时旧策略必须删除。菜单勾选只控制可见模块,不能扩大采购员 API 权限。
|
||||
@@ -230,7 +237,7 @@ synchronized_at: 2026-09-05T09:03:48Z
|
||||
- 定时任务失败时明确记录失败原因,不自动重试。服务重启后由既有启动恢复逻辑处理遗留的运行中记录。
|
||||
- 单次同步内部的 SYB 只读请求(货运单总数、列表和明细)遇到暂时网络故障、5xx 或异常响应时最多执行 3 次,分别退避 1 秒、2 秒,单次 HTTP 总超时 60 秒;这不等于失败任务自动重试。明确未登录、业务失败、数据完整性错误以及任何写操作均不自动重试。
|
||||
- SYB 商品页不再提供“导入”和“同步记录”快捷按钮,也不查询、展示或轮询同步状态;后台同步成功、失败或中断均不在商品页弹出消息,商品数据由用户主动查询或刷新获取。
|
||||
- 独立“SYB 同步记录”页面是同步结果的唯一 Web 展示位置,保留运行状态、进度、数量、失败原因和店铺统计;go-admin 定时任务中的 `GoAutoSYBHourlySync` 提供“执行记录”入口。管理员可在同步记录页人工发起覆盖昨天和今天的同步,采购员只读;人工与定时同步共用导入服务、执行记录和互斥,不排队、不并发,也不立即重试。
|
||||
- 独立“SYB 同步记录”页面是同步结果的唯一 Web 展示位置,保留运行状态、进度、数量、失败原因和店铺统计;go-admin 定时任务中的 `GoAutoSYBHourlySync` 提供“执行记录”入口。管理员和采购员可在同步记录页人工发起覆盖昨天和今天的同步,其他角色只读(#236);人工与定时同步共用导入服务、执行记录和互斥,不排队、不并发,也不立即重试。
|
||||
|
||||
|
||||
## cmautobuy 商品导入
|
||||
@@ -397,7 +404,7 @@ synchronized_at: 2026-09-05T09:03:48Z
|
||||
|
||||
- 规格入口与精确规格选择统一按“重新定位唯一目标 → 执行一次无障碍点击 → 读取新页面验证”执行;不得复用旧无障碍节点,不选择相近规格,也不在多候选时默认点击第一个。
|
||||
- 无障碍点击后页面完全无变化时,只允许对解析器已确认的安全规格入口或服务端下发且唯一命中的精确规格执行一次中心手势兜底。目标必须可见、启用、边界有效,手势后仍须以规格面板强证据或精确选中证据确认结果。
|
||||
- 页面发生变化但规格面板强证据不足时,不再继续手势或猜测页面,明确失败并只记录面板类型、候选数量和证据布尔值等无敏感标量。原始控件树、节点文字集合和整屏截图仍不得保存或上传。
|
||||
- 页面发生变化但规格面板强证据不足时,默认明确失败;#243 的同商品页安全入口恢复是限定例外:确认包/Activity 未变、商品标题非空且未变、入口来源与语义未变,无面板、关闭、数量、已选、支付、订单或页面异常信号时,重新定位入口并最多执行一次受控手势。整页普通动态节点变化不单独阻断该恢复;入口缺失或显式/嵌套入口歧义仍拒绝。商品标题一致是当前上下文约束,不宣称已从页面核验 goods_id。诊断只记录入口来源、点击结果/原因、恢复结果和面板数量/布尔等无敏感标量。原始控件树、节点文字集合和整屏截图仍不得保存或上传。
|
||||
- 规格已处于精确选中状态时不得重复点击。规格查找只在解析器唯一识别的规格面板容器内有限滚动,每次滚动后重新定位容器与目标;容器缺失、歧义、到边或验证失败均 fail-closed。
|
||||
- 中心手势兜底不得用于修改/保存地址、创建或提交订单、订单详情入口以及任何支付/付款目标;正式创建订单的一次性不可逆门禁与永久禁止支付规则不变。
|
||||
## PDD 商品反向关联与继续订单采购(#161)
|
||||
@@ -431,3 +438,88 @@ synchronized_at: 2026-09-05T09:03:48Z
|
||||
- 服务端只接受同一 `installId`、未过期且尚未使用的恢复码完成重新注册,成功后签发新 Token 并使恢复码失效。过期、重复使用、installId 不符或停用均明确失败;不能通过清空数据、直接改库或“吊销 Token”恢复原任务归属。
|
||||
|
||||
- 自 #223 起,任务创建时把与冻结 SYB 目标对应的已确认商品规格映射保存为不可变的探测指导快照,但仍不得跳过首趟真机探测。探测完成后,每个角色先验证快照映射能否按既有规范化规则唯一对应当次候选,能对应时固化当次候选原文;不能对应时只对该未解决角色执行确定性匹配,仍无结果才调用 AI。已解决角色不重复交给 AI,任一最终值仍必须逐字属于当次候选;历史映射失效、规范化后歧义或角色不符时不得复用。候选完整但无法决策时提示“已采集到当前规格,但未能确定颜色或尺码映射”,不再误报候选不存在。
|
||||
|
||||
## 采购商品深链入口(#232)
|
||||
|
||||
- Android 采购新任务(含探测与人工重试)打开商品时,优先使用指定 PDD 包名的 ACTION_VIEW,并设置 NEW_TASK 与 CLEAR_TASK,以清理旧任务栈后交付该任务链接。清理的是 Activity 返回历史,不是清除应用数据或强制停止进程。
|
||||
- 直接启动失败(包括无可处理 Activity)时回退既有浏览器入口;启动请求被接受后仍由执行器验证稳定商品页面,停留首页不会仅因启动成功而判定完成。
|
||||
- 清栈不用于改地址后返回、下单后回到 PDD、订单核查;采集入口继续使用既有浏览器流程,避免绕过规则中的浏览器步骤。
|
||||
- 当前通用商品页面结构验证不能独立证明页面 goods_id;解析结果携带的任务 goodsId 不是页面回读证据。首次直接深链上线仍需以实际目标商品真机核对,ADB 实验不替代 Agent 上下文验证。
|
||||
## SYB 批量采购设备偏好(#233)
|
||||
|
||||
- SYB 商品页“批量创建采购任务”按当前登录用户 ID、当前浏览器来源与 API 环境记住最近一次设备选择;主动清空也记忆为不指定设备。只保存设备 ID,不保存 Token、账号凭据或订单信息。
|
||||
- 弹窗打开后先按现有在线/可选/采购能力条件加载设备,再恢复选择并以相同设备预检。记忆设备当前不可用时保留偏好并提示用户重新选择或明确清空,不静默切换设备或自动领取。
|
||||
- 预检加载中、失败或记忆设备不可用时不能提交;过期预检结果不覆盖新的设备选择。服务端原有设备及采购资格校验不变。
|
||||
- 偏好只作用于此入口,不影响采集、其他创建入口和采购重试。浏览器存储失败时仍允许手动操作;刷新或关闭再打开浏览器可恢复,清理浏览器数据或沿用现有退出登录清理存储行为后需重新选择。
|
||||
|
||||
## 客户端密钥与可编辑模块授权(#237)
|
||||
|
||||
以下为提交 `71f7751` 已实现的规则;2026-09-07 本机已迁移并验证管理页面可用,2026-09-08 已随 d403f3b 完成线上迁移与部署,真实客户端密钥执行闭环尚未验证。
|
||||
|
||||
- 仅管理员创建、查看、编辑授权或停用密钥。首版不提供密钥改名、到期、轮换、恢复启用、删除或任意接口授权。
|
||||
- 模块选择复用“采集采购/采采管理”现有 12 个业务模块;分组勾选只影响当前子模块,新菜单不会自动获得授权。客户端密钥管理、系统账号权限及支付不在可授权模块中。
|
||||
- 勾选模块默认只读,至少保留一个模块。读写仅开放清单中的普通写操作;同步、采集、采购、删除、导入、重解析、匹配、回写及切换当前采购规则分别独立授权,默认关闭。没有普通写接口的模块不允许勾选读写。
|
||||
- 编辑既有密钥不会更换密钥,名称和前缀只读;移除模块同时移除其动作。取消保留原授权;失败保留输入;版本冲突要求刷新重开。停用不可编辑或恢复。
|
||||
- 保存后新请求按最新授权校验,已经通过校验的在途请求可能完成,不追溯回滚。模块授权不是行级、店铺级或租户隔离,授予读取即允许读取该模块明确接口可返回的业务范围。
|
||||
- 用户于 2026-09-07 明确接受明文风险:密钥管理及客户端接口默认兼容 HTTP/HTTPS,无需开关。HTTP 会明文传输密钥与业务数据,建议优先使用 HTTPS;兼容不改变管理员身份与模块授权边界。
|
||||
- 客户端与 Admin 登录 JWT、Agent Device Token 独立。响应排除凭据及原始载荷字段;AI 设置只返回 enabled,不开放 Provider 配置读写或连接测试。设备仅开放列表,不开放身份重置、令牌或解锁接口。
|
||||
- 执行动作复用既有业务门禁、幂等参数及状态机;客户端采购 batch-retry 沿用 Admin 原有语义,不等同于 Agent 就地 reset。授权重采购、支付复核、取消订单等未列入接口不开放;永久禁止付款。
|
||||
- 创建响应丢失时不可找回完整密钥,应核对列表并停用可能已创建的记录,再明确创建新密钥,不能盲目自动重试。完整密钥只在创建结果弹窗内存中显示,关闭或离开页面清空,不写浏览器持久存储。
|
||||
|
||||
## 打开采购规格面板后按需滚动(#238)
|
||||
|
||||
- Android 0.9.60 / versionCode 73,代码 `58a6c1c` 起,规则 `openSpecPanel.swipeAfter` 保留格式校验与旧快照兼容,但不执行打开面板后的固定次数预滑动;不以“必须滑两次成功”作为进入规格探测/选择的条件。动作后的 `waitAfterMs` 仍生效。
|
||||
- 首趟规格探测和第二趟精确选择仍使用各自既有的按需横向/纵向、有界与稳定终止策略。取消预滑动不等于不探测隐藏规格,也不等于只看首屏。目标不存在、歧义、页面证据不足或必要的有界查找失败时仍明确失败。
|
||||
- 此调整覆盖所有已经安全识别打开的面板,不再仅特判 NON_SCROLLABLE_CONFIRMATION;不弱化面板验证、精确选中、地址、价格、任务租约、创建订单边界或禁止支付规则。
|
||||
- 其他动作的后置滑动沿用原行为,必需滑动失败仍返回 RULE_ACTION_FAILED。旧规则 JSON 不回写、不迁移;原任务 ID、历史 attempt、商品与规格快照不变。旧 APK 行为不变,需升级 Agent;本单未改线上规则或执行 CG68 真机采购。
|
||||
|
||||
## 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 同步记录”:列表状态、状态筛选及详情支持部分成功,详情保留已保存数量、错误原因与重新同步补齐提示。定时任务日志只表示异步任务受理,不等于最终业务同步成功。
|
||||
|
||||
|
||||
## 商品页动态变化下的采购入口恢复(#243)
|
||||
|
||||
实现 ec8b14a,Android 0.9.61/versionCode 74;已构建、未安装或真机验收。规格探测和正式采购共用入口函数,面板已打开时不新增点击;仍在同一商品上下文且安全入口存在时,一次重新定位手势恢复不再依赖整页完全静止。手势后仍未打开则报告入口无效果,不循环点击。恢复不能用于地址、创建订单、支付或未知面板,不修改面板分类器及正式下单边界。CG82/CG85 原首次点击未生效的底层原因尚待新日志和授权真机验证。
|
||||
|
||||
|
||||
## 同一颜色卡片重复选中节点(#244)
|
||||
|
||||
实现 93aab6a,Android 0.9.62/versionCode 75;已构建并授权覆盖安装,真实采购效果待验收。仅在颜色维度完整精确目标已选中、但存在非目标已选候选时,识别同一卡片重复表达:卡片必须是滚动容器直接子级的可点击非滚动 android.view.ViewGroup,节点父链明确、可见启用且边界包含;其他冲突节点仅可为该卡片自身或其中的 ImageView。另一文字选项、另一张卡片、多个精确目标、目标未选中、父链/边界不明均不适用例外。额外核对内存源节点以拒绝被解析器按同名去重隐藏的跨卡片目标;不保存原始树。
|
||||
|
||||
不删除规格数字、不模糊匹配、不改变通用解析器、尺码确认、摘要兜底、入口/滚动/地址/价格/创建订单。没有非目标选中冲突时继续原判断;仅把同卡片容器/图片的重复选中表达从冲突中排除,不新增点击。
|
||||
|
||||
|
||||
## 精确规格不可用与入口恢复诊断(#245)
|
||||
|
||||
实现 ca815c3,Android 0.9.63/versionCode 76;已通过单元测试并构建,未安装、未进行真实采购验收。入口恢复沿用 #243 条件,拒绝时通过 entryRecovery=rejected;reason=<固定原因码> 区分页面身份、商品上下文、面板/支付区域、数量/摘要、标题、入口来源/文案、歧义、规则别名及可用性;不放宽条件,不新增点击。
|
||||
|
||||
在规格面板识别出唯一完整精确候选且 available=false 时,立即返回既有 PURCHASE_SPEC_SAFE_TARGET_MISSING,提示目标颜色/尺码已售罄或当前不可选,未创建订单;已选中目标同样不能绕过不可用检查。只记录 dimension 和 exact_target_unavailable 固定结构证据,不记录规格原文或原始树。原有纵向与横向定位在此条件下本就停止,不能将本改动描述为修复“已识别售罄仍继续搜索”。未识别精确候选时继续既有有界搜索,不推断售罄,不替换规格;解析器、匹配规则、滚动预算、地址与下单流程不变。
|
||||
|
||||
## 改地址返回后的动态提交按钮(#248)
|
||||
|
||||
实现绑定 `c80c746`,Android 0.9.66 / versionCode 79;已通过 353 项 Android 单元测试并构建,尚未安装或进行真实采购验收。
|
||||
|
||||
- 地址回读与最终提交按钮定位分开判断。旧“提交订单”“现在买,仅”“确认购买”识别继续优先;存在旧文案候选但歧义时不使用结构补充选择其中一个。地址确实缺失时保留既有有限恢复;地址及采购面板上下文已确认后,不因底部按钮文字变化而滚动规格列表。按钮缺失或不唯一使用 `PURCHASE_SUBMIT_TARGET_AMBIGUOUS` 提示按钮问题,不误报地址保存超时。
|
||||
- 结构补充只属于当前执行器中已完成地址修改后的返回流程,不能由其他页面或旧执行的地址证明启用。当前须为 PDD、地址后缀按既有规则回读唯一、地址入口唯一,并存在唯一数量输入或已选摘要;通过地址入口与该证据的最近共同祖先确定整个采购弹层,不把颜色/尺码滚动区域当成弹层。排除全屏大祖先、地址编辑、已知支付 Activity 及订单结果/支付页面,后续定位要求 Activity 与地址返回时一致。
|
||||
- 旧文案无候选时,只接受弹层底部固定区域内一个独立、可见启用、非滚动的可点击容器;不在规格滚动区内选择按钮,不取控件树末项,不使用固定屏幕点击坐标。文字与人民币金额子节点合并为一个按钮;支持如“复购价,”及非支付展示文字变化,但单独价格、多个操作按钮或支付/付款/地址/返回等非提交动作均不放行。
|
||||
- 地址返回检查、最终复核和提交共用目标定位。结构路径保留原文字节点为重新定位锚点,由已有无障碍点击重新查找其可点击父级;不新增下单手势兜底。最终复核成功后才允许该结构路径提交;提交前再次检查路径、类别、文字与边界,发生变化即停止;复核失败清除本地结构提交锚点。
|
||||
- 既有规格、数量、价格和地址复核,任务租约、服务端/本地不可逆边界及单次提交规则不变。结构识别不支持支付,不增加采购员操作、配置或审批;不改变 Admin、接口、数据库、规则 JSON、通用解析器及滑动执行器。原始控件树、整屏截图、地址全文和凭据不得落盘或上传。
|
||||
|
||||
## 临时采集连续模式(#250)
|
||||
|
||||
当前实现绑定 `37e8b56`,Agent 0.9.72 / versionCode 85。2026-09-09 用户批准 v5:按配置次数返回后切回 Agent,替代此前“识别并停留 PDD 首页”的方案;不保证 PDD 最终停在首页。代码、376 项 Android 单测及 Debug 构建通过,新版设置与完整真机闭环待验收。
|
||||
|
||||
- 连续采集默认关闭,本机即时保存,失败恢复原值;开启后显示返回次数选择,1~5 次、默认 3,取消不改,写入失败恢复旧值。任务忙碌时开关和次数均禁用,选择提交时再次检查;无新增权限。只用于本次人工新建普通临时采集(source=agent_current_page、attemptNumber=1、无替换来源),Admin 下发、重试、替代与采购不参与。
|
||||
- 单任务执行开始固定返回次数。完整 completed、服务端提交确认且既有图片上传流程结束后,执行有限 Back,每次后等待 500ms;不要求识别商品/搜索/相机/首页,也不要求画面发生变化。图片上传仍沿用既有非致命行为,不因收尾更改结果。
|
||||
- 每次 Back 前检查前台仍为 PDD,读取页面后再次检查;离开 PDD 则停止余下 Back。次数完成、离开 PDD 或 Back 动作失败后,尝试打开 Agent 保持原 Tab,等待 500ms 并验证前台包名。动作失败不重试,不把请求启动成功直接当作切回成功。
|
||||
- 沿用风险页安全边界:登录/验证码/风控、地址编辑、订单详情或支付等证据时保留现场,不发送 Back 或强行切回;Activity 缺失、读取异常明确停止。以上例外不引入首页门禁。
|
||||
- 导航在原任务互斥和唤醒锁内完成,不变更租约/调度。返回失败只显示人工处理提示,不把成功采集改为失败或自动重采。
|
||||
- 失败、部分成功、提交未确认保留现场,不运行返回步骤,也不触发旧空闲返回倒计时。关闭开关保持原流程,采集间隔不变。
|
||||
- 不自动拍照、搜索或创建下一任务,不涉及采购/订单/付款;不使用深链、浏览器、OCR/VLM 或坐标兜底。
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Troubleshooting
|
||||
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Troubleshooting
|
||||
wiki_revision: b1b1b343917e66288f4282bc6b3b90ea4ff3cca0
|
||||
synchronized_at: 2026-09-04T11:30:16Z
|
||||
wiki_revision: 18744477bfd17f396e8c76ec7a2fcc6720acdf6f
|
||||
synchronized_at: 2026-09-09T03:16:51Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 故障排查
|
||||
@@ -85,3 +85,22 @@ sqlite3 -readonly agent-diagnostics.db "SELECT task_id,reason,color_row_count,co
|
||||
```
|
||||
|
||||
读取时记录设备、Agent 版本、任务号和规则快照;工单只回写查询得到的脱敏聚合数值。读取完成后删除本地导出副本。正式 APK 若不允许 `run-as`,停止排查并确认安全的只读诊断出口,不通过放宽应用安全配置或上传完整数据库绕过。
|
||||
|
||||
## Android 采购规格入口本地诊断(#249)
|
||||
|
||||
实现绑定 `99faf5a`,Agent `0.9.67`(versionCode 80)。单元测试与 Debug 构建已通过,新增版本真机留存/读取尚待验证;本节不属于上文既有实测结论。
|
||||
|
||||
采购执行器启动后,在应用私有 `files/purchase_diagnostics/<taskId>_<attemptId>.jsonl` 保存白名单结构化入口诊断。元数据包含 taskId、attemptId、deviceId、agentVersion、phase、ruleHash(当前任务规则快照 SHA-256)、timestamp 和 elapsedMs。现有任务接口只有 attemptId,没有 attemptNumber;用 attemptId 对照服务端历史,不推算重试序号。
|
||||
|
||||
证据仅包含入口来源枚举、候选数、点击结果/原因枚举、等待轮数、面板结构布尔值/计数,以及受控恢复是否执行及固定拒绝原因。没有商品标题、规格原文、地址、手机号、Cookie、Token、链接、原始控件树或截图。entryWaitMillis 是该等待循环累计的计划等待量;elapsedMs 才是自本次采购执行器诊断起点计量的实际耗时,timestamp 是后台写入时间。
|
||||
|
||||
复用现有单线程诊断队列异步写入,写入/排队失败不改变采购结果;不新增点击、滑动、等待或采购门禁。最多保留最近 5 个 attempt 文件、每文件最后 128 条事件,超过 7 天的文件在服务启动或下次写入时清理。异常断电或存储故障仍可能丢失诊断,不能将日志缺失当成动作未执行;不上传 Admin,不增加服务端接口。
|
||||
|
||||
仅对允许 run-as 的 Debug APK,在设备已连接且选定准确序列号后只读提取:
|
||||
|
||||
```powershell
|
||||
adb -s <device-serial> shell run-as cn.ilapage.goauto.agent ls files/purchase_diagnostics
|
||||
adb -s <device-serial> exec-out run-as cn.ilapage.goauto.agent cat files/purchase_diagnostics/<taskId>_<attemptId>.jsonl
|
||||
```
|
||||
|
||||
先列出文件,再读取本次任务/attempt 的准确文件名;不得扩大为导出所有应用数据或完整数据库。新版安装前的失败不会补生成日志;需要用户授权后重试才能产生新证据。Release 若禁止 run-as 则停止,不更改权限绕过;首版没有导出界面。该功能用于确定拒绝分支,并不代表已经修复对应采购故障。
|
||||
|
||||
@@ -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: f3242938de4ac55c47f5c6eebd69184024e6a0ea
|
||||
synchronized_at: 2026-09-05T09:04:42Z
|
||||
wiki_revision: 49dec4c35da793a32822a82e00f3b657757b6d44
|
||||
synchronized_at: 2026-09-07T13:35:25Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# MVP 共享 API 契约
|
||||
@@ -75,7 +75,7 @@ POST /api/admin/v1/syb-products/reparse-batch
|
||||
PATCH /api/admin/v1/syb-products/{productId}/correction
|
||||
```
|
||||
|
||||
`import` 仅允许管理员调用。请求体提交 `dateFrom`、`dateTo` 后创建持久化后台任务并立即以 `202` 返回 `runId` 和 `status=running`;关闭弹窗、刷新或离开页面不影响任务。没有启用店铺时必须在读取凭据、登录、验证码 OCR 和任意 SYB 网络请求之前返回 `422`。任意时刻只能有一条 `running` 记录,内存锁与数据库唯一执行槽共同阻止单进程和跨进程重复导入;冲突时返回正在执行任务的日期范围。
|
||||
`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`;中途失败或中断已经写入的数据保留,重新导入仍按唯一键覆盖。
|
||||
|
||||
@@ -472,7 +472,7 @@ POST /api/agent/v1/tasks/{taskId}/fail
|
||||
|---|---:|---:|---:|---:|
|
||||
| `openProduct` | 是 | 是 | 是 | 否 |
|
||||
| `verifyProduct` | 是 | 是 | 否 | 否 |
|
||||
| `openSpecPanel` | 是 | 是 | 是 | 否 |
|
||||
| `openSpecPanel` | 是 | 是 | 兼容读取、不执行(0.9.60+,见 #238) | 否 |
|
||||
| `selectSpec` | 是 | 是 | 是 | 否 |
|
||||
| `setQuantity` | 是 | 是 | 否 | 否 |
|
||||
| `verifyUnitPrice` | 是 | 是 | 否 | 否 |
|
||||
@@ -486,7 +486,7 @@ POST /api/agent/v1/tasks/{taskId}/fail
|
||||
- 文字候选按控件文字或内容描述**精确匹配**;候选合并后必须唯一命中。点击动作只允许点击唯一文字节点或其最近的可点击父容器,不允许模糊匹配、猜测相近候选、改点兄弟节点。
|
||||
- 动作 `textAliases` 不能包含地址修改、创建/提交订单、订单号或支付相关文字,防止用安全 action 绕过危险动作类型和能力门禁。只读识别字段使用独立校验:允许订单和支付证据,仍拒绝修改地址、收货地址,并沿用各字段声明的数量、长度、去重和空白限制。
|
||||
- `waitAfterMs` 表示动作成功后的等待时间,范围为 0~30000 毫秒;省略时为 0。
|
||||
- `swipeAfter` 表示动作成功后执行一个有限滑动计划。`direction` 只能为 `up` / `down` / `left` / `right`,`count` 为 1~10,`durationMs` 为 100~2000,`intervalMs` 为 0~5000 且省略时为 0。
|
||||
- `swipeAfter` 通常表示动作成功后执行一个有限滑动计划;Android 0.9.60+ 的 `openSpecPanel` 例外,只兼容读取而不执行预滑动(见 #238)。`direction` 只能为 `up` / `down` / `left` / `right`,`count` 为 1~10,`durationMs` 为 100~2000,`intervalMs` 为 0~5000 且省略时为 0。
|
||||
- 未在矩阵中授权的 action/参数组合、未知字段、空候选和越界值一律拒绝。`updateShippingAddress`、`createOrder`、`readOrderResult` 等正式动作在其独立高风险契约完成前不接受上述参数。
|
||||
- 旧的仅含 `actions[].type` 的规则继续有效:候选使用 Agent 内置语义,等待为 0,不执行动作后滑动。
|
||||
- 服务端保存创建任务时收到的完整原始规则快照;规则后来更新为规则 B,不会改变已有任务中的规则 A 快照。
|
||||
@@ -867,3 +867,111 @@ X-GoAuto-Device-Recovery-Code: <one-time-code>
|
||||
Agent 携带既有 Token(可已失效)及恢复码重新调用注册接口。服务端必须同时校验同一 `installId`、未停用状态、恢复码摘要、未过期和未使用;成功后使用原 `deviceId` 写入新 Token 摘要并返回一次新 Token,清除恢复码摘要和有效期。旧 Token 与恢复码都立即失效,已分配的 pending 采集或采购任务保持原 `deviceId`,不创建替代设备记录。缺少或错误恢复码仍为 `DEVICE_INSTALL_ID_CONFLICT`;过期码为 `DEVICE_RECOVERY_EXPIRED`;停用设备为 `DEVICE_DISABLED`。
|
||||
|
||||
自 #223 起,新建 SYB 任务在保持 `mappedColor` / `mappedSize` 为空和首趟 `spec_probe` 不变的同时,把创建时与目标规格对应的 confirmed 商品映射冻结为仅供服务端决策的指导快照。服务端收到当次候选后按角色验证该快照:只有规范化后唯一对应当次候选时才复用,并固化当次候选原文;否则该角色继续执行确定性匹配,仍未解决才把该角色及其封闭候选交给 AI。已解决角色不得重复发送给 AI,最终颜色和尺码仍须逐字属于各自当次候选。任务决策快照通过 `roleSources` 记录每个角色的 `manual_mapping` / `exact_match` / `ai_match` 来源;任务级 `specSource` 使用现有枚举汇总,不新增 Agent 决策权限。
|
||||
|
||||
## 客户端 API 与管理员密钥管理(#237)
|
||||
|
||||
实现基线 `71f7751`;以下接口已通过隔离测试;2026-09-07 本机 MySQL 迁移及管理员通过 HTTP 读取列表、模块目录已验证,真实密钥创建/编辑/停用及业务访问仍未联调,线上尚未部署。Android 接口不变。
|
||||
|
||||
### 管理接口
|
||||
|
||||
前缀 `/api/admin/v1/client-keys`,要求 Admin JWT 和 admin 角色,默认接受 HTTP/HTTPS,响应 `Cache-Control: no-store`。
|
||||
|
||||
| 方法与相对路径 | 输入 | 成功 data |
|
||||
|---|---|---|
|
||||
| GET 空路径 | page,固定每页 20 | items、total、page、pageSize |
|
||||
| GET /modules | 无 | 模块数组:key、title、group、writable、actions |
|
||||
| POST 空路径 | name(1~80 字符)、grants | key 元数据与仅本次返回的 secret |
|
||||
| PATCH /:keyId/grants | version、grants | 更新后的元数据 |
|
||||
| POST /:keyId/disable | version | 停用后的元数据 |
|
||||
|
||||
授权示例:`{"module":"pdd_products","write":false,"actions":[]}`;grants 为非空数组。元数据包括 id、name、prefix、enabled、version、grants、createdBy、updatedBy、createdAt、updatedAt、lastUsedAt,不返回摘要或完整密钥。管理请求只接受单个 JSON 对象、拒绝未知字段、最大 64 KiB。成功 code=200;无效输入 422、不存在 404、授权版本冲突或已停用 409。
|
||||
|
||||
### 客户端访问与错误语义
|
||||
|
||||
- 前缀 `/api/client/v1`,只接受 `Authorization: Bearer <客户端密钥>`,不接受 Cookie 或 URL 凭据,不与 JWT、Device Token 通用。
|
||||
- 根据用户 2026-09-07 的明确确认,管理与客户端接口在所有环境默认接受 HTTP/HTTPS,不设置协议开关,也不依赖 X-Forwarded-Proto 或 GOAUTO_TRUST_FORWARDED_PROTO。HTTP 明文传输密钥及业务数据,优先使用 HTTPS;不影响其他接口各自的协议要求。
|
||||
- 不再因 HTTP 返回 426;401 为缺失、无效或停用密钥;403 为模块/动作未授权;400 为查询参数携带凭据;503 为认证或审计暂不可用。业务错误沿用各既有接口。
|
||||
- 一般请求体最大 16 MiB;响应只支持有限 JSON(32 MiB),递归过滤凭据与原始载荷字段。不支持直接流式/二进制文件接口。响应不可序列化或超限时返回 502;业务可能已执行,必须先核对结果,不要自动重试。
|
||||
- 通过密钥认证的请求由服务端生成 `X-Client-Request-Id` 关联审计;它不是业务幂等键,原业务接口要求的 requestId 等字段仍须提供。允许请求必须先落审计意图;完成状态更新失败可留下 status=0。无效密钥没有 key_id 关联审计;拒绝授权的 403 审计为尽力记录。
|
||||
- GET `/ai-matching-settings` 只返回 `data.enabled`。POST `/ai-matching-settings/resolve` 接受 targetColor、targetSize、colors、sizes;每组最多 200 项,每个值最多 255 字符,总请求最大 64 KiB;确定性优先,必要时使用当前 Provider,返回 mappedColor、mappedSize、source;不保存映射、不创建任务,无法可靠匹配返回 422 安全提示。
|
||||
|
||||
### 明确开放的接口清单
|
||||
|
||||
下表路径均相对 `/api/client/v1`;普通业务请求字段沿用本文对应 Admin 业务契约。read=选中模块,write=模块读写,其他值均需 actions 逐项授权。没有列出的 Admin 接口不能用客户端密钥调用;新增 Admin 路由不会自动开放。
|
||||
|
||||
| 方法 | 路径 | 模块键 | 能力 |
|
||||
|---|---|---|---|
|
||||
| POST | `/ai-matching-settings/resolve` | ai_matching | match |
|
||||
| GET | `/devices` | devices | read |
|
||||
| GET | `/pdd-products` | pdd_products | read |
|
||||
| GET | `/pdd-products/:productId` | pdd_products | read |
|
||||
| POST | `/pdd-products` | pdd_products | write |
|
||||
| PATCH | `/pdd-products/:productId` | pdd_products | write |
|
||||
| GET | `/shopee-products` | shopee_products | read |
|
||||
| GET | `/shopee-products/:productId` | shopee_products | read |
|
||||
| POST | `/shopee-products` | shopee_products | write |
|
||||
| PATCH | `/shopee-products/:productId` | shopee_products | write |
|
||||
| POST | `/shopee-products/:productId/link-pdd` | shopee_products | write |
|
||||
| POST | `/shopee-products/:productId/specs/values` | shopee_products | write |
|
||||
| PUT | `/shopee-products/:productId/specs/mapping` | shopee_products | write |
|
||||
| DELETE | `/shopee-products/:productId/specs/values` | shopee_products | delete |
|
||||
| DELETE | `/shopee-products/:productId/specs/mapping` | shopee_products | delete |
|
||||
| POST | `/shopee-products/batch-delete` | shopee_products | delete |
|
||||
| POST | `/shopee-products/:productId/specs/mapping/auto-match` | shopee_products | match |
|
||||
| POST | `/shopee-products/:productId/specs/mapping/confirm` | shopee_products | match |
|
||||
| GET | `/syb-products` | syb_products | read |
|
||||
| GET | `/syb-products/:productId` | syb_products | read |
|
||||
| PATCH | `/syb-products/:productId/correction` | syb_products | write |
|
||||
| POST | `/syb-products/:productId/reparse` | syb_products | reparse |
|
||||
| POST | `/syb-products/reparse-batch` | syb_products | reparse |
|
||||
| GET | `/syb-products/sync-runs` | syb_sync_runs | read |
|
||||
| GET | `/syb-products/sync-runs/:runId` | syb_sync_runs | read |
|
||||
| POST | `/syb-products/import` | syb_sync_runs | sync |
|
||||
| GET | `/syb-inner-codes` | syb_inner_codes | read |
|
||||
| GET | `/syb-inner-codes/:recordId` | syb_inner_codes | read |
|
||||
| GET | `/syb-inner-codes/match-jobs/:jobId` | syb_inner_codes | read |
|
||||
| GET | `/syb-inner-codes/apply-batches/:batchId` | syb_inner_codes | read |
|
||||
| POST | `/syb-inner-codes/rematch` | syb_inner_codes | match |
|
||||
| POST | `/syb-inner-codes/import` | syb_inner_codes | import |
|
||||
| POST | `/syb-inner-codes/batch-delete` | syb_inner_codes | delete |
|
||||
| POST | `/syb-inner-codes/apply-preview` | syb_inner_codes | writeback |
|
||||
| POST | `/syb-inner-codes/apply` | syb_inner_codes | writeback |
|
||||
| POST | `/syb-inner-codes/:recordId/recheck` | syb_inner_codes | match |
|
||||
| GET | `/syb-shops` | syb_shops | read |
|
||||
| POST | `/syb-shops` | syb_shops | write |
|
||||
| PATCH | `/syb-shops/:shopId/name` | syb_shops | write |
|
||||
| PATCH | `/syb-shops/:shopId/enabled` | syb_shops | write |
|
||||
| DELETE | `/syb-shops/:shopId` | syb_shops | delete |
|
||||
| GET | `/collection-rules` | collection_rules | read |
|
||||
| POST | `/collection-rules` | collection_rules | write |
|
||||
| PATCH | `/collection-rules/:ruleId` | collection_rules | write |
|
||||
| DELETE | `/collection-rules/:ruleId` | collection_rules | delete |
|
||||
| GET | `/purchase-rules` | purchase_rules | read |
|
||||
| GET | `/purchase-rules/current` | purchase_rules | read |
|
||||
| POST | `/purchase-rules` | purchase_rules | write |
|
||||
| PATCH | `/purchase-rules/:ruleId` | purchase_rules | write |
|
||||
| DELETE | `/purchase-rules/:ruleId` | purchase_rules | delete |
|
||||
| PUT | `/purchase-rules/current` | purchase_rules | activate |
|
||||
| GET | `/collection-tasks` | collection_tasks | read |
|
||||
| GET | `/collection-tasks/:taskId` | collection_tasks | read |
|
||||
| POST | `/collection-tasks` | collection_tasks | collect |
|
||||
| POST | `/collection-tasks/batch` | collection_tasks | collect |
|
||||
| POST | `/collection-tasks/:taskId/reset` | collection_tasks | collect |
|
||||
| DELETE | `/collection-tasks/:taskId` | collection_tasks | delete |
|
||||
| GET | `/purchase-tasks` | purchase_tasks | read |
|
||||
| GET | `/purchase-tasks/:taskId` | purchase_tasks | read |
|
||||
| POST | `/purchase-tasks/batch-preview` | purchase_tasks | purchase |
|
||||
| POST | `/purchase-tasks` | purchase_tasks | purchase |
|
||||
| POST | `/purchase-tasks/batch` | purchase_tasks | purchase |
|
||||
| POST | `/purchase-tasks/batch-retry` | purchase_tasks | purchase |
|
||||
| POST | `/purchase-tasks/stock` | purchase_tasks | purchase |
|
||||
| GET | `/ai-matching-settings` | ai_matching | read |
|
||||
|
||||
### openSpecPanel 后置滑动兼容与诊断(#238)
|
||||
|
||||
版本边界:Android 0.9.60 / versionCode 73,代码 `58a6c1c`。不修改 JSON schema、能力标识、任务接口或已有快照哈希。`openSpecPanel.swipeAfter` 仍按 direction/count/durationMs/intervalMs 原约束校验;解析成功后不执行该准备性滑动,`waitAfterMs` 保留。其他动作后置滑动沿用旧执行语义。旧 Server 可继续下发原快照;旧 APK 仍按原策略执行,不能将本契约描述当作旧设备已获得兼容。
|
||||
|
||||
规格探测与精确选择自行负责按需有界滚动,原始候选、精确点击和选中复核不变。跳过预滑动不作为规格探测成功或订单创建证据。
|
||||
|
||||
本地 `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,现有结果提交字段不变。
|
||||
|
||||
@@ -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: ad9adc22e69e48c9a8fd87d7121066eecca55fd6
|
||||
synchronized_at: 2026-09-04T11:30:50Z
|
||||
wiki_revision: a4f4ab200af052bab7ffbabe267238528bcd7cf9
|
||||
synchronized_at: 2026-09-07T07:02:57Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 12 顺云宝(SYB)ERP 接口契约
|
||||
@@ -228,9 +228,20 @@ Admin 默认 `max_matches = 10000`,可以在配置中调整;上限针对整
|
||||
读取完整明细并按既有 upsert 保存,但本次同步仍记为失败、明确提示当天未形成
|
||||
稳定快照且不推进游标,下一次继续覆盖今天。任何尝试都不得突破 `max_matches`;
|
||||
网络/业务错误、非法 ID 或不完整明细不属于可放宽的快照漂移。
|
||||
`[必须,#235]` 当天跨页重复 ID 纳入上述最多 3 次列表快照尝试(含首次),
|
||||
不增加另一层重试次数。发现跨页重复后丢弃本次列表,从预检总数和第一页重新开始,
|
||||
使用全新 ID 集合;最后一次仍重复时直接失败,不得去重后按成功或降级数据保存。
|
||||
已经完成的历史日期保持其已有结果,不重复拉取;历史日期重复不适用此恢复。
|
||||
每页先检查非法 ID 和页内重复,再检查跨页重叠;同页同时存在页内重复和跨页重叠时
|
||||
仍作为硬错误停止。原有总数漂移/短页的合法唯一列表降级保存条件保持不变。
|
||||
重复诊断只记录日期、当天尝试序号、首次/当前页码与行号、start、pageSize、
|
||||
expectedTotal 和已获取唯一数量,不记录真实重复 ID、原始响应或个人数据。
|
||||
|
||||
### 4.4 统一日期范围同步与覆盖游标
|
||||
|
||||
GoAuto 同步记录页的立即同步允许管理员和采购员(purchaser)使用(#236),固定覆盖昨天和今天;其他已登录角色仅可查看记录。复用既有同步互斥、日期校验、启用店铺筛选及操作人审计,不授予店铺配置、凭据或定时任务管理权限。权限代码发布并完成启动对账后生效。
|
||||
|
||||
|
||||
页面只有一个同步入口,操作员确认工具条上的开始日和结束日后发起。首次打开页面
|
||||
固定默认昨天到今天,不因 `last_synced_at` 更早而自动扩大范围;需要补历史缺口时
|
||||
由操作员明确选择日期,单次仍不得超过 31 天。
|
||||
|
||||
@@ -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: b1b1b343917e66288f4282bc6b3b90ea4ff3cca0
|
||||
synchronized_at: 2026-09-04T11:30:08Z
|
||||
wiki_revision: f951dbc8b6a555cbf8f44cda073910406ba55191
|
||||
synchronized_at: 2026-09-07T09:43:56Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 部署与运维
|
||||
@@ -70,3 +70,26 @@ Provider 故障日志只允许记录调用关联 ID、操作类型、耗时、
|
||||
- 服务启动会将上次进程遗留的 `running` 执行记录标记为 `interrupted`。该恢复依赖当前线上每个数据库只运行一个调度器实例;扩展为多调度器前必须另建工单引入实例租约,不能直接复用此判断。
|
||||
- 排错从 Admin 定时任务页单选任务后进入“日志”,按状态和开始时间查询。记录只含稳定错误码和脱敏摘要;需要定位细节时查看受控服务日志,不得把任务参数、Provider 配置/响应、密钥或业务原始数据复制进执行历史。
|
||||
- 当前没有执行历史删除接口和自动保留策略;删除定时任务不删除历史。数据库容量治理需要另建工单评估。#198 的 `GoAutoSYBSpecAIParse` 在 #199 发布和迁移后仍保持关闭,启用必须由管理员另行确认。
|
||||
|
||||
## 客户端密钥部署与验证(#237)
|
||||
|
||||
实现基线 `71f7751`;2026-09-07 已按用户授权完成本机 MySQL 迁移、管理员菜单写入和 Server/Web 构建重启;管理页面 HTTP 列表与模块加载已验证。线上仍未部署。
|
||||
|
||||
- 发布前须单独授权追加迁移 `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。
|
||||
- 客户端密钥功能不依赖 GOAUTO_TRUST_FORWARDED_PROTO、X-Forwarded-Proto 或 Agent HTTP 例外。既有其他路由的协议和代理配置保持不变;不伪造协议头,不新增明文放行开关。
|
||||
- 代理、APM、应用日志均不得记录 Authorization、创建响应 secret 或原始业务载荷。应用对两类客户端密钥路由跳过旧请求/响应正文日志,使用专用元数据审计;实际代理日志脱敏仍须部署验收。
|
||||
- 请求审计 status=0 可能表示在途、进程中断或结果审计更新失败;先按请求关联号核对业务结果,不自动重试采购、采集、同步等操作。停用阻止后续认证,不保证取消已开始的业务操作。
|
||||
- 隔离验证:Server `go test ./app/goauto/clientkey ./app/goauto/clientapi ./cmd/migrate/migration/version-local`;Web `pnpm exec jest tests/unit/client-keys.spec.js --runInBand` 与 `pnpm run build:prod`。浏览器模拟入口 `/tests/fixtures/client-keys.html` 仅由本地 Vite 开发服务承载,使用内存模拟请求和无效示例密钥,不连接真实数据库,不证明线上鉴权已验收。
|
||||
- 真实部署验收须另行验证实际 HTTP/HTTPS 入口、管理员创建/编辑/停用、普通用户拒绝、读写/独立动作隔离、停用后的后续请求拒绝及日志无密钥;任何真实业务执行继续按独立授权范围进行。
|
||||
|
||||
|
||||
## Windows 本机运行目录与 #237 迁移验证(2026-09-07)
|
||||
|
||||
- Supervisor 实际配置 `D:/supervisor/programs/goauto.conf`;程序仅 `goauto-admin-api` 和 `goauto-admin-ui`。从已有干净工作区 `D:/OPC/goauto-worktrees/main-runtime` 的 main 分支运行;源码运行基线 `ac3c63e`,版本化启动模板更新提交 `9a10d82`。
|
||||
- 原目录 `D:/OPC/goauto` 的用户改动保持原样;本机敏感配置继续读取 `D:/OPC/goauto/config.yaml`,不复制凭据到运行工作区或版本库。数据库 `127.0.0.1:3307/goauto`,API `http://127.0.0.1:8010`,Web `http://127.0.0.1:9527`。
|
||||
- API 命令:`pwsh.exe -NoLogo -NoProfile -File "D:/OPC/goauto-worktrees/main-runtime/scripts/start-server.ps1" -ConfigPath "D:/OPC/goauto/config.yaml" -SkipMigration`;Web 同目录 `scripts/start-web.ps1` 与相同 ConfigPath。已验证无需 ExecutionPolicy Bypass。
|
||||
- 真实迁移必须单独授权并先确认唯一待执行版本;常驻 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 未重启或发布。
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[program:goauto-admin-api]
|
||||
command=pwsh.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -File "D:/OPC/goauto/scripts/start-server.ps1" -ConfigPath "D:/OPC/goauto/config.yaml"
|
||||
directory=D:/OPC/goauto
|
||||
command=pwsh.exe -NoLogo -NoProfile -File "D:/OPC/goauto-worktrees/main-runtime/scripts/start-server.ps1" -ConfigPath "D:/OPC/goauto/config.yaml" -SkipMigration
|
||||
directory=D:/OPC/goauto-worktrees/main-runtime
|
||||
autostart=true
|
||||
autorestart=true
|
||||
startsecs=3
|
||||
@@ -14,8 +14,8 @@ stdout_logfile_maxbytes=50MB
|
||||
stdout_logfile_backups=5
|
||||
|
||||
[program:goauto-admin-ui]
|
||||
command=pwsh.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -File "D:/OPC/goauto/scripts/start-web.ps1" -ConfigPath "D:/OPC/goauto/config.yaml"
|
||||
directory=D:/OPC/goauto
|
||||
command=pwsh.exe -NoLogo -NoProfile -File "D:/OPC/goauto-worktrees/main-runtime/scripts/start-web.ps1" -ConfigPath "D:/OPC/goauto/config.yaml"
|
||||
directory=D:/OPC/goauto-worktrees/main-runtime
|
||||
autostart=true
|
||||
autorestart=true
|
||||
startsecs=3
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
goautoclientapi "go-admin/app/goauto/clientapi"
|
||||
"os"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -53,6 +54,7 @@ func InitRouter() {
|
||||
|
||||
// 注册 GoAuto Agent 与设备管理路由。
|
||||
goautodevice.InitRouter(r, authMiddleware)
|
||||
goautoclientapi.InitRouter(r, authMiddleware)
|
||||
goautoapprelease.InitRouter(r, authMiddleware)
|
||||
goautoaimatching.InitRouter(r, authMiddleware)
|
||||
goautotask.InitRouter(r, authMiddleware)
|
||||
|
||||
@@ -136,6 +136,8 @@ func moduleKeyForAPI(path string) string {
|
||||
return ModulePDDProducts
|
||||
case strings.HasPrefix(path, "/api/admin/v1/shopee-products"):
|
||||
return ModuleShopeeProducts
|
||||
case strings.HasPrefix(path, "/api/admin/v1/shopee-spec-auto-match"):
|
||||
return ModuleShopeeProducts
|
||||
case strings.HasPrefix(path, "/api/admin/v1/syb-products/sync-runs"):
|
||||
return ModuleSYBSyncRuns
|
||||
case strings.HasPrefix(path, "/api/admin/v1/syb-products"):
|
||||
|
||||
@@ -48,13 +48,15 @@ var AdminAPIs = []APIPermission{
|
||||
{"AI 建议颜色映射", "/api/admin/v1/shopee-products/:productId/specs/mapping/suggest-colors", "POST", true},
|
||||
{"AI 建议尺码映射", "/api/admin/v1/shopee-products/:productId/specs/mapping/suggest-sizes", "POST", true},
|
||||
{"一键匹配并确认颜色尺码", "/api/admin/v1/shopee-products/:productId/specs/mapping/auto-match", "POST", true},
|
||||
{"手动执行虾皮规格自动匹配", "/api/admin/v1/shopee-spec-auto-match/runs", "POST", false},
|
||||
{"查看最近虾皮规格自动匹配", "/api/admin/v1/shopee-spec-auto-match/runs/latest", "GET", false},
|
||||
|
||||
{"查看 SYB 商品", "/api/admin/v1/syb-products", "GET", true},
|
||||
{"查看 SYB 商品详情", "/api/admin/v1/syb-products/:productId", "GET", true},
|
||||
{"重新解析 SYB 商品", "/api/admin/v1/syb-products/:productId/reparse", "POST", true},
|
||||
{"批量重新解析 SYB 商品", "/api/admin/v1/syb-products/reparse-batch", "POST", true},
|
||||
{"人工修正 SYB 商品", "/api/admin/v1/syb-products/:productId/correction", "PATCH", true},
|
||||
{"手动同步 SYB 商品", "/api/admin/v1/syb-products/import", "POST", false},
|
||||
{"手动同步 SYB 商品", "/api/admin/v1/syb-products/import", "POST", true},
|
||||
{"查看 SYB 同步记录", "/api/admin/v1/syb-products/sync-runs", "GET", true},
|
||||
{"查看 SYB 同步详情", "/api/admin/v1/syb-products/sync-runs/:runId", "GET", true},
|
||||
|
||||
|
||||
@@ -15,10 +15,12 @@ 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-products/import": true,
|
||||
"POST /api/admin/v1/collection-rules": true,
|
||||
"PUT /api/admin/v1/ai-matching-settings": true,
|
||||
"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,
|
||||
}
|
||||
for _, permission := range PurchaserAPIs() {
|
||||
if denied[permission.Method+" "+permission.Path] {
|
||||
|
||||
@@ -55,7 +55,9 @@ func TestReconcilePurchaserPermissions(t *testing.T) {
|
||||
}
|
||||
|
||||
assertPolicyCount(t, db, "custom-role", "/custom", "GET", 1)
|
||||
assertPolicyCount(t, db, RolePurchaser, "/api/admin/v1/syb-products/import", "POST", 0)
|
||||
assertPolicyCount(t, db, RolePurchaser, "/api/admin/v1/syb-products/import", "POST", 1)
|
||||
assertPolicyCount(t, db, RolePurchaser, "/api/admin/v1/syb-shops/discover", "POST", 0)
|
||||
assertPolicyCount(t, db, RolePurchaser, "/api/admin/v1/syb-shops", "POST", 0)
|
||||
}
|
||||
|
||||
func TestReconcilePurchaserPermissionsRemovesOnlyStalePurchaserGrant(t *testing.T) {
|
||||
|
||||
@@ -25,12 +25,28 @@ const (
|
||||
defaultAutoConfirmMinConfidence = 0.9
|
||||
)
|
||||
|
||||
// MaxProviderTimeout is also the total budget used by composite synchronous
|
||||
// AI operations. This keeps their HTTP response inside the Admin and API
|
||||
// transport windows even when an operation needs more than one provider call.
|
||||
const MaxProviderTimeout = 600 * time.Second
|
||||
|
||||
// Service owns the internal AI Provider configuration. The API key exception
|
||||
// is deliberately narrow: it is plain text only in the dedicated settings
|
||||
// table and is returned only by the administrator settings handler.
|
||||
type Service struct {
|
||||
DB *gorm.DB
|
||||
HTTPClient *http.Client
|
||||
DB *gorm.DB
|
||||
HTTPClient *http.Client
|
||||
ProviderFailureLogger func(ProviderFailureDiagnostic)
|
||||
}
|
||||
|
||||
// ProviderFailureDiagnostic deliberately contains no URL, model, prompt,
|
||||
// candidates, response body or credential. It is safe for operational logs.
|
||||
type ProviderFailureDiagnostic struct {
|
||||
CallID string
|
||||
Operation string
|
||||
Kind string
|
||||
StatusCode int
|
||||
Duration time.Duration
|
||||
}
|
||||
|
||||
func NewService(db *gorm.DB) *Service {
|
||||
@@ -179,6 +195,58 @@ func (s *Service) Resolve(ctx context.Context, request MatchRequest) (MatchResul
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// ResolveSYBSpec parses one SYB productSpec into the linked Shopee product's
|
||||
// exact color/size labels. Unlike Resolve, this operation does not map to PDD:
|
||||
// every non-empty answer must be an exact member of the supplied Shopee set.
|
||||
func (s *Service) ResolveSYBSpec(ctx context.Context, request SYBSpecParseRequest) (SYBSpecParseResult, error) {
|
||||
request.ProductSpec = strings.TrimSpace(request.ProductSpec)
|
||||
request.Colors = usableCandidates(request.Colors)
|
||||
request.Sizes = usableCandidates(request.Sizes)
|
||||
if request.ProductSpec == "" || (len(request.Colors) == 0 && len(request.Sizes) == 0) {
|
||||
return SYBSpecParseResult{}, fail(CodeNoMatch, "SYB 采购规格缺少可判断的原文或蝦皮候选")
|
||||
}
|
||||
setting, apiKey, err := s.activeSetting(ctx)
|
||||
if err != nil {
|
||||
return SYBSpecParseResult{}, err
|
||||
}
|
||||
payload := openAIChatRequest{Model: setting.Model, Temperature: 0, Messages: []openAIMessage{
|
||||
{Role: "system", Content: "你只负责把一条 SYB 商品规格原文解析成给定蝦皮候选中的原始颜色和尺码。不得猜测、不得改写候选、不得返回候选外文本。只返回 JSON:{\"color\":\"颜色候选原文或空\",\"size\":\"尺码候选原文或空\",\"reason\":\"简短原因\",\"confidence\":0到1}。提供了某角色候选时必须唯一可靠地选择一个,否则对应字段留空。"},
|
||||
{Role: "user", Content: sybSpecParsePrompt(request)},
|
||||
}}
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return SYBSpecParseResult{}, &Error{Code: CodeProviderUnavailable, Message: "SYB 规格 AI 解析请求生成失败", Cause: err}
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(ctx, time.Duration(setting.TimeoutSeconds)*time.Second)
|
||||
defer cancel()
|
||||
httpRequest, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint(setting.BaseURL, "chat/completions"), bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return SYBSpecParseResult{}, fail(CodeInvalidSetting, "AI 服务地址无效")
|
||||
}
|
||||
httpRequest.Header.Set("Authorization", "Bearer "+apiKey)
|
||||
httpRequest.Header.Set("Content-Type", "application/json")
|
||||
response, err := s.httpClient().Do(httpRequest)
|
||||
if err != nil {
|
||||
return SYBSpecParseResult{}, &Error{Code: CodeProviderUnavailable, Message: "SYB 规格 AI 解析服务暂时不可用", Cause: err}
|
||||
}
|
||||
defer response.Body.Close()
|
||||
responseBody, readErr := io.ReadAll(io.LimitReader(response.Body, 1<<20))
|
||||
if readErr != nil || response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices {
|
||||
return SYBSpecParseResult{}, fail(CodeProviderUnavailable, "SYB 规格 AI 解析服务暂时不可用")
|
||||
}
|
||||
choice, err := parseProviderChoice(responseBody)
|
||||
if err != nil || !validClosedChoice(choice.Color, request.Colors) || !validClosedChoice(choice.Size, request.Sizes) {
|
||||
return SYBSpecParseResult{}, fail(CodeNoMatch, "AI 未能在蝦皮候选中唯一解析采购规格")
|
||||
}
|
||||
if choice.Confidence == nil || *choice.Confidence < 0 || *choice.Confidence > 1 || strings.TrimSpace(choice.Reason) == "" {
|
||||
return SYBSpecParseResult{}, fail(CodeNoMatch, "AI 解析结果缺少有效置信度或理由")
|
||||
}
|
||||
return SYBSpecParseResult{
|
||||
Color: choice.Color, Size: choice.Size, Provider: ProviderOpenAICompatible,
|
||||
Model: setting.Model, Reason: safeReason(choice.Reason), Confidence: choice.Confidence,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Service) activeSetting(ctx context.Context) (models.AIMatchingSetting, string, error) {
|
||||
setting, err := s.setting(ctx)
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) || !setting.Enabled {
|
||||
@@ -294,6 +362,22 @@ func validChoice(target, selected string, candidates []string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func validClosedChoice(selected string, candidates []string) bool {
|
||||
selected = strings.TrimSpace(selected)
|
||||
if len(candidates) == 0 {
|
||||
return selected == ""
|
||||
}
|
||||
if selected == "" {
|
||||
return false
|
||||
}
|
||||
for _, candidate := range candidates {
|
||||
if candidate == selected {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func safeReason(reason string) string {
|
||||
reason = strings.TrimSpace(reason)
|
||||
if reason == "" {
|
||||
@@ -316,6 +400,16 @@ func matchPrompt(request MatchRequest) string {
|
||||
return string(raw)
|
||||
}
|
||||
|
||||
func sybSpecParsePrompt(request SYBSpecParseRequest) string {
|
||||
payload := struct {
|
||||
ProductSpec string `json:"productSpec"`
|
||||
Colors []string `json:"shopeeColorCandidates,omitempty"`
|
||||
Sizes []string `json:"shopeeSizeCandidates,omitempty"`
|
||||
}{request.ProductSpec, request.Colors, request.Sizes}
|
||||
raw, _ := json.Marshal(payload)
|
||||
return string(raw)
|
||||
}
|
||||
|
||||
type openAIMessage struct {
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
|
||||
@@ -2,6 +2,7 @@ package aimatching
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
@@ -135,3 +136,61 @@ func TestAutoConfirmThresholdDefaultsPersistsAndValidates(t *testing.T) {
|
||||
t.Fatalf("invalid threshold err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveSYBSpecUsesOnlyProductSpecAndClosedShopeeCandidates(t *testing.T) {
|
||||
service := matcherTestService(t)
|
||||
if _, err := service.SaveSettings(context.Background(), SaveSettingsRequest{Enabled: true, BaseURL: "https://provider.example/v1", Model: "test-model", APIKey: "test-secret", TimeoutSeconds: 8}, 7); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var sent map[string]any
|
||||
service.HTTPClient = &http.Client{Transport: roundTripper(func(request *http.Request) (*http.Response, error) {
|
||||
raw, err := io.ReadAll(request.Body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := json.Unmarshal(raw, &sent); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body := `{"choices":[{"message":{"content":"{\"color\":\"黑色\",\"size\":\"XL\",\"reason\":\"原文对应唯一候选\",\"confidence\":0.95}"}}]}`
|
||||
return &http.Response{StatusCode: http.StatusOK, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(body)), Request: request}, nil
|
||||
})}
|
||||
result, err := service.ResolveSYBSpec(context.Background(), SYBSpecParseRequest{ProductSpec: "黑色 XL【备注】", Colors: []string{"黑色", "白色"}, Sizes: []string{"L", "XL"}})
|
||||
if err != nil || result.Color != "黑色" || result.Size != "XL" || result.Confidence == nil || *result.Confidence != 0.95 {
|
||||
t.Fatalf("result=%+v err=%v", result, err)
|
||||
}
|
||||
encoded, _ := json.Marshal(sent)
|
||||
for _, forbidden := range []string{"orderCode", "address", "rawJson", "price", "test-secret"} {
|
||||
if strings.Contains(string(encoded), forbidden) {
|
||||
t.Fatalf("provider payload leaked forbidden field %q: %s", forbidden, encoded)
|
||||
}
|
||||
}
|
||||
for _, required := range []string{"productSpec", "shopeeColorCandidates", "shopeeSizeCandidates"} {
|
||||
if !strings.Contains(string(encoded), required) {
|
||||
t.Fatalf("provider payload missing %q: %s", required, encoded)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveSYBSpecRejectsCandidateOutsideClosedSetAndMissingConfidence(t *testing.T) {
|
||||
service := matcherTestService(t)
|
||||
if _, err := service.SaveSettings(context.Background(), SaveSettingsRequest{Enabled: true, BaseURL: "https://provider.example/v1", Model: "test-model", APIKey: "test-secret", TimeoutSeconds: 8}, 7); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
responses := []string{
|
||||
`{"choices":[{"message":{"content":"{\"color\":\"灰色\",\"size\":\"XL\",\"reason\":\"猜测\",\"confidence\":0.99}"}}]}`,
|
||||
`{"choices":[{"message":{"content":"{\"color\":\"黑色\",\"size\":\"XL\",\"reason\":\"候选\"}"}}]}`,
|
||||
}
|
||||
service.HTTPClient = &http.Client{Transport: roundTripper(func(request *http.Request) (*http.Response, error) {
|
||||
body := responses[0]
|
||||
responses = responses[1:]
|
||||
return &http.Response{StatusCode: http.StatusOK, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(body)), Request: request}, nil
|
||||
})}
|
||||
request := SYBSpecParseRequest{ProductSpec: "黑 XL", Colors: []string{"黑色"}, Sizes: []string{"XL"}}
|
||||
for i := 0; i < 2; i++ {
|
||||
_, err := service.ResolveSYBSpec(context.Background(), request)
|
||||
var target *Error
|
||||
if !errors.As(err, &target) || target.Code != CodeNoMatch {
|
||||
t.Fatalf("attempt %d err=%v", i, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,9 @@ import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
log "github.com/go-admin-team/go-admin-core/logger"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// Suggestion limits are enforced defensively here too, even though callers
|
||||
@@ -95,18 +98,26 @@ func (s *Service) SuggestBatch(ctx context.Context, request SuggestRequest) (Sug
|
||||
}
|
||||
httpRequest.Header.Set("Authorization", "Bearer "+apiKey)
|
||||
httpRequest.Header.Set("Content-Type", "application/json")
|
||||
callID, startedAt := uuid.NewString(), time.Now()
|
||||
response, err := s.httpClient().Do(httpRequest)
|
||||
if err != nil {
|
||||
s.logProviderFailure(ProviderFailureDiagnostic{CallID: callID, Operation: "suggest_batch", Kind: providerNetworkErrorKind(err), Duration: time.Since(startedAt)})
|
||||
return SuggestResult{}, &Error{Code: CodeProviderUnavailable, Message: "AI 建议服务暂时不可用", Cause: err}
|
||||
}
|
||||
defer response.Body.Close()
|
||||
limited := io.LimitReader(response.Body, 1<<20)
|
||||
responseBody, readErr := io.ReadAll(limited)
|
||||
if readErr != nil || response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices {
|
||||
if readErr != nil {
|
||||
s.logProviderFailure(ProviderFailureDiagnostic{CallID: callID, Operation: "suggest_batch", Kind: "read_error", StatusCode: response.StatusCode, Duration: time.Since(startedAt)})
|
||||
return SuggestResult{}, fail(CodeProviderUnavailable, "AI 建议服务暂时不可用")
|
||||
}
|
||||
if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices {
|
||||
s.logProviderFailure(ProviderFailureDiagnostic{CallID: callID, Operation: "suggest_batch", Kind: "http_status", StatusCode: response.StatusCode, Duration: time.Since(startedAt)})
|
||||
return SuggestResult{}, fail(CodeProviderUnavailable, "AI 建议服务暂时不可用")
|
||||
}
|
||||
raw, err := parseSuggestChoices(responseBody)
|
||||
if err != nil {
|
||||
s.logProviderFailure(ProviderFailureDiagnostic{CallID: callID, Operation: "suggest_batch", Kind: "invalid_response", StatusCode: response.StatusCode, Duration: time.Since(startedAt)})
|
||||
return SuggestResult{}, fail(CodeProviderUnavailable, "AI 建议响应无效")
|
||||
}
|
||||
|
||||
@@ -145,6 +156,22 @@ func (s *Service) SuggestBatch(ctx context.Context, request SuggestRequest) (Sug
|
||||
return SuggestResult{Decisions: decisions, Provider: ProviderOpenAICompatible, Model: setting.Model}, nil
|
||||
}
|
||||
|
||||
func (s *Service) logProviderFailure(diagnostic ProviderFailureDiagnostic) {
|
||||
if s.ProviderFailureLogger != nil {
|
||||
s.ProviderFailureLogger(diagnostic)
|
||||
return
|
||||
}
|
||||
log.Warnf("AI provider call failed: call_id=%s operation=%s kind=%s status=%d duration_ms=%d",
|
||||
diagnostic.CallID, diagnostic.Operation, diagnostic.Kind, diagnostic.StatusCode, diagnostic.Duration.Milliseconds())
|
||||
}
|
||||
|
||||
func providerNetworkErrorKind(err error) string {
|
||||
if errors.Is(err, context.DeadlineExceeded) {
|
||||
return "timeout"
|
||||
}
|
||||
return "network_error"
|
||||
}
|
||||
|
||||
func suggestSystemPrompt(dimension string) string {
|
||||
noun := "颜色或尺码"
|
||||
switch dimension {
|
||||
|
||||
@@ -6,7 +6,9 @@ import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go-admin/app/goauto/models"
|
||||
|
||||
@@ -134,3 +136,93 @@ func TestSuggestBatchRequiresConfiguredProvider(t *testing.T) {
|
||||
t.Fatalf("expected CodeNotConfigured, got %v", target.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSuggestBatchLogsSafeDiagnosticForProvider502(t *testing.T) {
|
||||
const sensitiveBody = "api-key-and-provider-body-must-not-be-logged"
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
http.Error(w, sensitiveBody, http.StatusBadGateway)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
db := openSuggestTestDB(t)
|
||||
seedEnabledSetting(t, db, server.URL)
|
||||
var diagnostic ProviderFailureDiagnostic
|
||||
service := NewService(db)
|
||||
service.ProviderFailureLogger = func(value ProviderFailureDiagnostic) { diagnostic = value }
|
||||
|
||||
_, err := service.SuggestBatch(context.Background(), SuggestRequest{
|
||||
Sources: []SuggestSource{{ID: "s1", Label: "sensitive-source"}},
|
||||
Candidates: []SuggestCandidate{{ID: "c1", Label: "sensitive-candidate"}},
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected provider failure")
|
||||
}
|
||||
if diagnostic.Operation != "suggest_batch" || diagnostic.Kind != "http_status" || diagnostic.StatusCode != http.StatusBadGateway || diagnostic.CallID == "" {
|
||||
t.Fatalf("unexpected diagnostic: %+v", diagnostic)
|
||||
}
|
||||
printed := fmt.Sprintf("%+v", diagnostic)
|
||||
for _, secret := range []string{sensitiveBody, "sensitive-source", "sensitive-candidate", "test-key", server.URL} {
|
||||
if strings.Contains(printed, secret) {
|
||||
t.Fatalf("diagnostic leaked %q: %s", secret, printed)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSuggestBatchClassifiesProviderTimeout(t *testing.T) {
|
||||
db := openSuggestTestDB(t)
|
||||
seedEnabledSetting(t, db, "http://provider.invalid")
|
||||
var diagnostic ProviderFailureDiagnostic
|
||||
service := NewService(db)
|
||||
service.HTTPClient = &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) {
|
||||
return nil, context.DeadlineExceeded
|
||||
})}
|
||||
service.ProviderFailureLogger = func(value ProviderFailureDiagnostic) { diagnostic = value }
|
||||
|
||||
_, err := service.SuggestBatch(context.Background(), SuggestRequest{
|
||||
Sources: []SuggestSource{{ID: "s1", Label: "黑色"}}, Candidates: []SuggestCandidate{{ID: "c1", Label: "黑色"}},
|
||||
})
|
||||
if err == nil || diagnostic.Kind != "timeout" || diagnostic.StatusCode != 0 {
|
||||
t.Fatalf("timeout was not safely classified: diagnostic=%+v err=%v", diagnostic, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSuggestBatchClassifiesInvalidResponseWithoutLoggingBody(t *testing.T) {
|
||||
const sensitiveBody = "not-json-with-sensitive-provider-details"
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = w.Write([]byte(sensitiveBody))
|
||||
}))
|
||||
defer server.Close()
|
||||
db := openSuggestTestDB(t)
|
||||
seedEnabledSetting(t, db, server.URL)
|
||||
var diagnostic ProviderFailureDiagnostic
|
||||
service := NewService(db)
|
||||
service.ProviderFailureLogger = func(value ProviderFailureDiagnostic) { diagnostic = value }
|
||||
|
||||
_, err := service.SuggestBatch(context.Background(), SuggestRequest{
|
||||
Sources: []SuggestSource{{ID: "s1", Label: "黑色"}}, Candidates: []SuggestCandidate{{ID: "c1", Label: "黑色"}},
|
||||
})
|
||||
if err == nil || diagnostic.Kind != "invalid_response" || strings.Contains(fmt.Sprintf("%+v", diagnostic), sensitiveBody) {
|
||||
t.Fatalf("invalid response diagnostic is unsafe or missing: diagnostic=%+v err=%v", diagnostic, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSuggestBatchAllowsProviderResponseAfterTwoSeconds(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
time.Sleep(2100 * time.Millisecond)
|
||||
chatCompletionResponder(`{"suggestions":[{"sourceId":"s1","candidateId":"c1","confidence":0.95,"reason":"match"}]}`)(w, r)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
db := openSuggestTestDB(t)
|
||||
seedEnabledSetting(t, db, server.URL)
|
||||
result, err := NewService(db).SuggestBatch(context.Background(), SuggestRequest{
|
||||
Sources: []SuggestSource{{ID: "s1", Label: "黑色"}}, Candidates: []SuggestCandidate{{ID: "c1", Label: "黑色"}},
|
||||
})
|
||||
if err != nil || result.Decisions["s1"].CandidateID != "c1" {
|
||||
t.Fatalf("delayed provider response failed: result=%+v err=%v", result, err)
|
||||
}
|
||||
}
|
||||
|
||||
type roundTripFunc func(*http.Request) (*http.Response, error)
|
||||
|
||||
func (fn roundTripFunc) RoundTrip(request *http.Request) (*http.Response, error) { return fn(request) }
|
||||
|
||||
@@ -29,6 +29,24 @@ type MatchRequest struct {
|
||||
Sizes []string
|
||||
}
|
||||
|
||||
// SYBSpecParseRequest contains the only source text and closed Shopee
|
||||
// candidate sets that may leave GoAuto for an AI-assisted SYB parse. It must
|
||||
// never contain the shipment/order, account, address, price or full raw JSON.
|
||||
type SYBSpecParseRequest struct {
|
||||
ProductSpec string
|
||||
Colors []string
|
||||
Sizes []string
|
||||
}
|
||||
|
||||
type SYBSpecParseResult struct {
|
||||
Color string
|
||||
Size string
|
||||
Provider string
|
||||
Model string
|
||||
Reason string
|
||||
Confidence *float64
|
||||
}
|
||||
|
||||
type CandidateSnapshot struct {
|
||||
Colors []string `json:"colors,omitempty"`
|
||||
Sizes []string `json:"sizes,omitempty"`
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
package clientapi
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/go-admin-team/go-admin-core/sdk/pkg"
|
||||
"go-admin/app/goauto/clientkey"
|
||||
"go-admin/common/clientprincipal"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// Both HTTP and HTTPS are supported by explicit operator decision (#237).
|
||||
// Transport does not grant identity or permissions; secrets remain non-cacheable.
|
||||
func NoStore(c *gin.Context) {
|
||||
c.Header("Cache-Control", "no-store")
|
||||
c.Next()
|
||||
}
|
||||
func Gate(db *gorm.DB, e Endpoint) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
c.Header("Cache-Control", "no-store")
|
||||
deny := func(status int, message string) {
|
||||
c.AbortWithStatusJSON(status, gin.H{"code": status, "message": message})
|
||||
}
|
||||
// Never accept a credential supplied through a URL or cookie fallback.
|
||||
for k := range c.Request.URL.Query() {
|
||||
if sensitive(k) || strings.EqualFold(k, "token") {
|
||||
deny(400, "密钥只能通过 Authorization 请求头发送")
|
||||
return
|
||||
}
|
||||
}
|
||||
header := strings.Fields(c.GetHeader("Authorization"))
|
||||
if len(header) != 2 || !strings.EqualFold(header[0], "Bearer") {
|
||||
deny(401, "需要客户端密钥")
|
||||
return
|
||||
}
|
||||
connection := db
|
||||
var err error
|
||||
if connection == nil {
|
||||
connection, err = pkg.GetOrm(c)
|
||||
}
|
||||
if err != nil || connection == nil {
|
||||
deny(503, "客户端认证暂不可用")
|
||||
return
|
||||
}
|
||||
key, err := (clientkey.Service{DB: connection}).Authenticate(c.Request.Context(), header[1])
|
||||
if err != nil {
|
||||
if errors.Is(err, clientkey.ErrCredential) {
|
||||
deny(401, "客户端密钥无效或已停用")
|
||||
} else {
|
||||
deny(503, "客户端认证暂不可用")
|
||||
}
|
||||
return
|
||||
}
|
||||
random := make([]byte, 16)
|
||||
if _, err = rand.Read(random); err != nil {
|
||||
deny(503, "审计暂不可用")
|
||||
return
|
||||
}
|
||||
requestID := hex.EncodeToString(random)
|
||||
c.Header("X-Client-Request-Id", requestID)
|
||||
record := clientkey.Audit{KeyID: key.ID, Event: "request", RequestID: requestID, Method: e.Method, Route: "/api/client/v1" + e.Path}
|
||||
if !key.Allows(e.Module, e.Capability) {
|
||||
record.Status = 403
|
||||
_ = connection.Create(&record).Error
|
||||
deny(403, "密钥未授权此模块或执行动作")
|
||||
return
|
||||
}
|
||||
// The intent must be durable before any business handler can run.
|
||||
if err = connection.WithContext(c.Request.Context()).Create(&record).Error; err != nil {
|
||||
deny(503, "审计暂不可用,未执行操作")
|
||||
return
|
||||
}
|
||||
clientprincipal.Set(c, clientprincipal.Identity{KeyID: key.ID, RequestID: requestID, AuthorizedBy: key.UpdatedBy})
|
||||
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 16<<20)
|
||||
original := c.Writer
|
||||
buffer := &responseBuffer{ResponseWriter: original, status: 200}
|
||||
c.Writer = buffer
|
||||
defer func() { c.Writer = original }()
|
||||
c.Next()
|
||||
c.Writer = original
|
||||
status := buffer.status
|
||||
var value any
|
||||
if buffer.overflow || json.Unmarshal(buffer.body.Bytes(), &value) != nil {
|
||||
status = 502
|
||||
value = gin.H{"code": 502, "message": "无法生成客户端响应,请先核对操作结果,不要自动重试"}
|
||||
} else {
|
||||
value = redact(value)
|
||||
}
|
||||
auditCtx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer cancel()
|
||||
_ = connection.WithContext(auditCtx).Model(&clientkey.Audit{}).Where("id = ?", record.ID).Update("status", status).Error
|
||||
now := time.Now().UTC()
|
||||
_ = connection.WithContext(auditCtx).Model(&clientkey.Key{}).Where("id = ?", key.ID).UpdateColumn("last_used_at", now).Error
|
||||
c.JSON(status, value)
|
||||
}
|
||||
}
|
||||
|
||||
type responseBuffer struct {
|
||||
gin.ResponseWriter
|
||||
body bytes.Buffer
|
||||
status int
|
||||
overflow bool
|
||||
}
|
||||
|
||||
func (w *responseBuffer) WriteHeader(status int) { w.status = status }
|
||||
func (w *responseBuffer) WriteHeaderNow() {}
|
||||
func (w *responseBuffer) Status() int { return w.status }
|
||||
func (w *responseBuffer) Size() int { return w.body.Len() }
|
||||
func (w *responseBuffer) Written() bool { return w.body.Len() > 0 }
|
||||
func (w *responseBuffer) Write(b []byte) (int, error) {
|
||||
if w.body.Len()+len(b) > 32<<20 {
|
||||
w.overflow = true
|
||||
return len(b), nil
|
||||
}
|
||||
return w.body.Write(b)
|
||||
}
|
||||
func (w *responseBuffer) WriteString(s string) (int, error) { return w.Write([]byte(s)) }
|
||||
func (w *responseBuffer) Flush() {}
|
||||
|
||||
func sensitive(k string) bool {
|
||||
key := strings.ToLower(strings.ReplaceAll(strings.ReplaceAll(k, "_", ""), "-", ""))
|
||||
for _, part := range []string{"password", "passwd", "secret", "credential", "cookie", "authorization", "apikey", "accesstoken", "devicetoken", "refreshtoken", "recoverycode"} {
|
||||
if strings.Contains(key, part) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
switch key {
|
||||
case "token", "tokenhash", "digest", "rawjson", "rawpayload", "rawresponse", "requestheaders", "responseheaders":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
func redact(v any) any {
|
||||
switch value := v.(type) {
|
||||
case map[string]any:
|
||||
for k, item := range value {
|
||||
if sensitive(k) {
|
||||
delete(value, k)
|
||||
} else {
|
||||
value[k] = redact(item)
|
||||
}
|
||||
}
|
||||
case []any:
|
||||
for i, item := range value {
|
||||
value[i] = redact(item)
|
||||
}
|
||||
}
|
||||
return v
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
package clientapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"encoding/json"
|
||||
"github.com/gin-gonic/gin"
|
||||
"go-admin/app/goauto/clientkey"
|
||||
"go-admin/common/clientprincipal"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func fixture(t *testing.T) (*gorm.DB, clientkey.Service) {
|
||||
t.Helper()
|
||||
gin.SetMode(gin.TestMode)
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sql, _ := db.DB()
|
||||
sql.SetMaxOpenConns(1)
|
||||
t.Cleanup(func() { sql.Close() })
|
||||
if err = db.AutoMigrate(&clientkey.Key{}, &clientkey.Audit{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return db, clientkey.Service{DB: db, Modules: Catalog(Inventory())}
|
||||
}
|
||||
func TestEveryRouteIsExplicitlyScoped(t *testing.T) {
|
||||
db, s := fixture(t)
|
||||
routes := Inventory()
|
||||
if len(s.Modules) != 12 {
|
||||
t.Fatal("menu groups lost")
|
||||
}
|
||||
router := gin.New()
|
||||
seen := map[string]bool{}
|
||||
for _, e := range routes {
|
||||
key := e.Method + e.Path
|
||||
if seen[key] {
|
||||
t.Fatal("duplicate route")
|
||||
}
|
||||
seen[key] = true
|
||||
if strings.Contains(e.Path, "payment") || strings.Contains(e.Path, "token") || strings.Contains(e.Path, "client-keys") || e.Handle == nil {
|
||||
t.Fatal("forbidden route")
|
||||
}
|
||||
router.Handle(e.Method, "/api/client/v1"+e.Path, Gate(db, e), func(c *gin.Context) { c.JSON(200, gin.H{"data": "ok"}) })
|
||||
if e.Method != "GET" && e.Capability == "read" {
|
||||
t.Fatal("mutating read permission")
|
||||
}
|
||||
}
|
||||
for _, e := range routes {
|
||||
for _, scheme := range []string{"http", "https"} {
|
||||
grant := clientkey.Grant{Module: e.Module}
|
||||
v, token, err := s.Create(context.Background(), "test", []clientkey.Grant{grant}, 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
path := e.Path
|
||||
for _, param := range []string{":productId", ":runId", ":recordId", ":jobId", ":batchId", ":shopId", ":ruleId", ":taskId"} {
|
||||
path = strings.ReplaceAll(path, param, "1")
|
||||
}
|
||||
req := httptest.NewRequest(e.Method, scheme+"://example.test/api/client/v1"+path, nil)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
rr := httptest.NewRecorder()
|
||||
router.ServeHTTP(rr, req)
|
||||
want := 200
|
||||
if e.Capability != "read" {
|
||||
want = 403
|
||||
}
|
||||
if rr.Code != want {
|
||||
t.Fatalf("%s got %d want %d", keyFor(e), rr.Code, want)
|
||||
}
|
||||
if e.Capability == "write" {
|
||||
grant.Write = true
|
||||
} else if e.Capability != "read" {
|
||||
grant.Actions = []string{e.Capability}
|
||||
}
|
||||
if _, err = s.Update(context.Background(), v.ID, 1, 1, []clientkey.Grant{grant}, false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rr = httptest.NewRecorder()
|
||||
router.ServeHTTP(rr, req.Clone(context.Background()))
|
||||
if rr.Code != 200 {
|
||||
t.Fatalf("authorized %s failed: %d", keyFor(e), rr.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
func keyFor(e Endpoint) string { return e.Method + " " + e.Path }
|
||||
func TestRevocationTransportRedactionAndAudit(t *testing.T) {
|
||||
db, s := fixture(t)
|
||||
v, token, err := s.Create(context.Background(), "test", []clientkey.Grant{{Module: "pdd_products"}}, 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
e := Endpoint{Method: "GET", Path: "/pdd-products", Module: "pdd_products", Capability: "read"}
|
||||
router := gin.New()
|
||||
calls := 0
|
||||
router.GET("/api/client/v1/pdd-products", Gate(db, e), func(c *gin.Context) {
|
||||
calls++
|
||||
id, ok := clientprincipal.Get(c)
|
||||
if !ok || id.KeyID != v.ID {
|
||||
t.Error("client attribution missing")
|
||||
}
|
||||
c.JSON(200, gin.H{"code": 200, "data": gin.H{"apiKey": "sentinel-secret", "rawJson": "sentinel-raw", "title": "product", "nested": []any{gin.H{"device_token": "sentinel-token"}}}})
|
||||
})
|
||||
send := func(tlsOn bool, credential, path string) *httptest.ResponseRecorder {
|
||||
req := httptest.NewRequest("GET", "http://example.test"+path, nil)
|
||||
if tlsOn {
|
||||
req.TLS = &tls.ConnectionState{}
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+credential)
|
||||
rr := httptest.NewRecorder()
|
||||
router.ServeHTTP(rr, req)
|
||||
return rr
|
||||
}
|
||||
path := "/api/client/v1/pdd-products"
|
||||
for _, tlsOn := range []bool{false, true} {
|
||||
if send(tlsOn, "jwt", path).Code != 401 || send(tlsOn, "", path).Code != 401 || send(tlsOn, token, path+"?token=invalid").Code != 400 {
|
||||
t.Fatal("credential fallback accepted")
|
||||
}
|
||||
rr := send(tlsOn, token, path)
|
||||
if rr.Code != 200 || strings.Contains(rr.Body.String(), "sentinel") || !strings.Contains(rr.Body.String(), "product") {
|
||||
t.Fatal("redaction failed")
|
||||
}
|
||||
if rr.Header().Get("Cache-Control") != "no-store" || rr.Header().Get("X-Client-Request-Id") == "" {
|
||||
t.Fatal("cache or audit headers missing")
|
||||
}
|
||||
}
|
||||
if _, err = s.Update(context.Background(), v.ID, 1, 1, nil, true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if send(true, token, path).Code != 401 || send(false, token, path).Code != 401 || calls != 2 {
|
||||
t.Fatal("revocation not immediate")
|
||||
}
|
||||
var logs []clientkey.Audit
|
||||
db.Find(&logs)
|
||||
raw, _ := json.Marshal(logs)
|
||||
if strings.Contains(string(raw), token) || strings.Contains(string(raw), "sentinel") {
|
||||
t.Fatal("audit leaked payload")
|
||||
}
|
||||
}
|
||||
func TestAuditUnavailableDoesNotExecute(t *testing.T) {
|
||||
db, s := fixture(t)
|
||||
_, token, err := s.Create(context.Background(), "test", []clientkey.Grant{{Module: "pdd_products", Write: true}}, 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
db.Migrator().DropTable(&clientkey.Audit{})
|
||||
router := gin.New()
|
||||
called := false
|
||||
e := Endpoint{Method: "POST", Path: "/pdd-products", Module: "pdd_products", Capability: "write"}
|
||||
router.POST(e.Path, Gate(db, e), func(c *gin.Context) { called = true; c.JSON(200, gin.H{}) })
|
||||
req := httptest.NewRequest("POST", "https://example.test"+e.Path, nil)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
rr := httptest.NewRecorder()
|
||||
router.ServeHTTP(rr, req)
|
||||
if rr.Code != 503 || called {
|
||||
t.Fatal("executed without audit")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package clientapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"github.com/gin-gonic/gin"
|
||||
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
|
||||
"go-admin/app/goauto/clientkey"
|
||||
"go-admin/app/goauto/models"
|
||||
"go-admin/app/goauto/product"
|
||||
"go-admin/common/middleware"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestClientCanCreateProductWithoutLoginAndReplay(t *testing.T) {
|
||||
db, s := fixture(t)
|
||||
if err := db.AutoMigrate(&models.PDDProduct{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, token, err := s.Create(context.Background(), "test", []clientkey.Grant{{Module: "pdd_products", Write: true}}, 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
e := Endpoint{Method: "POST", Path: "/pdd-products", Module: "pdd_products", Capability: "write"}
|
||||
router := gin.New()
|
||||
router.Use(func(c *gin.Context) { c.Set("db", db); c.Next() })
|
||||
router.POST("/api/client/v1/pdd-products", Gate(db, e), product.Handler{}.Create)
|
||||
for n := 0; n < 2; n++ {
|
||||
req := httptest.NewRequest("POST", "https://example.test/api/client/v1/pdd-products", strings.NewReader(`{"requestId":"00000000-0000-4000-8000-000000000237","url":"https://mobile.yangkeduo.com/goods.html?goods_id=100000000001"}`))
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
rr := httptest.NewRecorder()
|
||||
router.ServeHTTP(rr, req)
|
||||
if rr.Code != 200 {
|
||||
t.Fatalf("product create failed status %d", rr.Code)
|
||||
}
|
||||
var body struct {
|
||||
Data struct {
|
||||
Replayed bool `json:"replayed"`
|
||||
}
|
||||
}
|
||||
json.Unmarshal(rr.Body.Bytes(), &body)
|
||||
if (n == 1) != body.Data.Replayed {
|
||||
t.Fatal("business idempotency changed")
|
||||
}
|
||||
}
|
||||
var count int64
|
||||
db.Model(&models.PDDProduct{}).Count(&count)
|
||||
if count != 1 {
|
||||
t.Fatal("duplicate business write")
|
||||
}
|
||||
}
|
||||
|
||||
func TestManagementRequiresAdminNotClientIdentity(t *testing.T) {
|
||||
db, s := fixture(t)
|
||||
h := clientkey.Handler{DB: db, Modules: s.Modules}
|
||||
for _, role := range []string{"admin", "purchaser", "client", ""} {
|
||||
for _, scheme := range []string{"http", "https"} {
|
||||
router := gin.New()
|
||||
router.Use(func(c *gin.Context) {
|
||||
c.Set(jwt.JwtPayloadKey, jwt.MapClaims{"rolekey": role, "identity": float64(7)})
|
||||
c.Next()
|
||||
})
|
||||
router.POST("/keys", middleware.RequireRoleKey("admin"), NoStore, h.Create)
|
||||
req := httptest.NewRequest("POST", scheme+"://example.test/keys", strings.NewReader(`{"name":"test","grants":[{"module":"pdd_products","write":false,"actions":[]}]}`))
|
||||
rr := httptest.NewRecorder()
|
||||
router.ServeHTTP(rr, req)
|
||||
want := 403
|
||||
if role == "admin" {
|
||||
want = 200
|
||||
}
|
||||
if rr.Code != want {
|
||||
t.Fatalf("role %q status %d", role, rr.Code)
|
||||
}
|
||||
if role == "admin" && rr.Header().Get("Cache-Control") != "no-store" {
|
||||
t.Fatal("one-time secret cacheable")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package clientapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/go-admin-team/go-admin-core/sdk/pkg"
|
||||
"go-admin/app/goauto/aimatching"
|
||||
"io"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// resolveSpecs accepts only specification text, never a provider URL/key,
|
||||
// prompt, raw order, address or account. It returns a suggestion, not an order.
|
||||
func resolveSpecs(c *gin.Context) {
|
||||
var req struct {
|
||||
TargetColor string `json:"targetColor"`
|
||||
TargetSize string `json:"targetSize"`
|
||||
Colors []string `json:"colors"`
|
||||
Sizes []string `json:"sizes"`
|
||||
}
|
||||
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 64<<10)
|
||||
d := json.NewDecoder(c.Request.Body)
|
||||
d.DisallowUnknownFields()
|
||||
if d.Decode(&req) != nil || d.Decode(&struct{}{}) != io.EOF || len(req.Colors) > 200 || len(req.Sizes) > 200 {
|
||||
c.JSON(422, gin.H{"code": 422, "message": "规格请求无效"})
|
||||
return
|
||||
}
|
||||
for _, s := range append(append([]string{req.TargetColor, req.TargetSize}, req.Colors...), req.Sizes...) {
|
||||
if len([]rune(s)) > 255 {
|
||||
c.JSON(422, gin.H{"code": 422, "message": "规格值过长"})
|
||||
return
|
||||
}
|
||||
}
|
||||
db, err := pkg.GetOrm(c)
|
||||
if err != nil {
|
||||
c.JSON(503, gin.H{"code": 503})
|
||||
return
|
||||
}
|
||||
v, err := aimatching.NewService(db).Resolve(c.Request.Context(), aimatching.MatchRequest{TargetColor: req.TargetColor, TargetSize: req.TargetSize, Colors: req.Colors, Sizes: req.Sizes})
|
||||
if err != nil {
|
||||
c.JSON(422, gin.H{"code": 422, "message": "未获得可靠匹配,请检查候选规格或联系管理员"})
|
||||
return
|
||||
}
|
||||
c.JSON(200, gin.H{"code": 200, "data": gin.H{"mappedColor": v.MappedColor, "mappedSize": v.MappedSize, "source": v.Source}})
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
// Package clientapi exposes only the reviewed client route inventory. It never
|
||||
// forwards arbitrary URLs or synthesizes a logged-in administrator identity.
|
||||
package clientapi
|
||||
|
||||
import (
|
||||
"go-admin/app/goauto/access"
|
||||
"go-admin/app/goauto/aimatching"
|
||||
"go-admin/app/goauto/clientkey"
|
||||
"go-admin/app/goauto/device"
|
||||
"go-admin/app/goauto/product"
|
||||
"go-admin/app/goauto/purchase"
|
||||
"go-admin/app/goauto/purchaserule"
|
||||
"go-admin/app/goauto/rule"
|
||||
"go-admin/app/goauto/shopeeproduct"
|
||||
"go-admin/app/goauto/sybimport"
|
||||
"go-admin/app/goauto/sybinnercode"
|
||||
"go-admin/app/goauto/sybshop"
|
||||
"go-admin/app/goauto/task"
|
||||
"go-admin/common/middleware"
|
||||
"sort"
|
||||
|
||||
"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"
|
||||
)
|
||||
|
||||
type Endpoint struct {
|
||||
Method, Path, Module, Capability string
|
||||
Handle gin.HandlerFunc
|
||||
}
|
||||
|
||||
func Inventory() []Endpoint {
|
||||
p := product.Handler{}
|
||||
s := shopeeproduct.Handler{}
|
||||
y := sybimport.Handler{}
|
||||
i := sybinnercode.Handler{}
|
||||
shop := sybshop.Handler{}
|
||||
r := rule.Handler{}
|
||||
pr := purchaserule.Handler{}
|
||||
t := task.Handler{}
|
||||
buy := purchase.Handler{}
|
||||
return []Endpoint{
|
||||
{"POST", "/ai-matching-settings/resolve", access.ModuleAIMatching, "match", resolveSpecs},
|
||||
{"GET", "/devices", access.ModuleDevices, "read", device.Handler{}.List},
|
||||
{"GET", "/pdd-products", access.ModulePDDProducts, "read", p.List},
|
||||
{"GET", "/pdd-products/:productId", access.ModulePDDProducts, "read", p.Detail},
|
||||
{"POST", "/pdd-products", access.ModulePDDProducts, "write", p.Create},
|
||||
{"PATCH", "/pdd-products/:productId", access.ModulePDDProducts, "write", p.Update},
|
||||
{"GET", "/shopee-products", access.ModuleShopeeProducts, "read", s.List},
|
||||
{"GET", "/shopee-products/:productId", access.ModuleShopeeProducts, "read", s.Detail},
|
||||
{"POST", "/shopee-products", access.ModuleShopeeProducts, "write", s.Create},
|
||||
{"PATCH", "/shopee-products/:productId", access.ModuleShopeeProducts, "write", s.Update},
|
||||
{"POST", "/shopee-products/:productId/link-pdd", access.ModuleShopeeProducts, "write", s.LinkPDD},
|
||||
{"POST", "/shopee-products/:productId/specs/values", access.ModuleShopeeProducts, "write", s.AddSpecValue},
|
||||
{"PUT", "/shopee-products/:productId/specs/mapping", access.ModuleShopeeProducts, "write", s.SetMapping},
|
||||
{"DELETE", "/shopee-products/:productId/specs/values", access.ModuleShopeeProducts, "delete", s.RemoveSpecValue},
|
||||
{"DELETE", "/shopee-products/:productId/specs/mapping", access.ModuleShopeeProducts, "delete", s.ClearMapping},
|
||||
{"POST", "/shopee-products/batch-delete", access.ModuleShopeeProducts, "delete", s.BatchDelete},
|
||||
{"POST", "/shopee-products/:productId/specs/mapping/auto-match", access.ModuleShopeeProducts, "match", s.AutoMatchMappings},
|
||||
{"POST", "/shopee-products/:productId/specs/mapping/confirm", access.ModuleShopeeProducts, "match", s.ConfirmMapping},
|
||||
{"GET", "/syb-products", access.ModuleSYBProducts, "read", y.List},
|
||||
{"GET", "/syb-products/:productId", access.ModuleSYBProducts, "read", y.Detail},
|
||||
{"PATCH", "/syb-products/:productId/correction", access.ModuleSYBProducts, "write", y.ManualCorrect},
|
||||
{"POST", "/syb-products/:productId/reparse", access.ModuleSYBProducts, "reparse", y.Reparse},
|
||||
{"POST", "/syb-products/reparse-batch", access.ModuleSYBProducts, "reparse", y.ReparseBatch},
|
||||
{"GET", "/syb-products/sync-runs", access.ModuleSYBSyncRuns, "read", y.ListSyncRuns},
|
||||
{"GET", "/syb-products/sync-runs/:runId", access.ModuleSYBSyncRuns, "read", y.SyncRunDetail},
|
||||
{"POST", "/syb-products/import", access.ModuleSYBSyncRuns, "sync", y.Import},
|
||||
{"GET", "/syb-inner-codes", access.ModuleSYBInnerCodes, "read", i.List},
|
||||
{"GET", "/syb-inner-codes/:recordId", access.ModuleSYBInnerCodes, "read", i.Detail},
|
||||
{"GET", "/syb-inner-codes/match-jobs/:jobId", access.ModuleSYBInnerCodes, "read", i.MatchJob},
|
||||
{"GET", "/syb-inner-codes/apply-batches/:batchId", access.ModuleSYBInnerCodes, "read", i.ApplyBatch},
|
||||
{"POST", "/syb-inner-codes/rematch", access.ModuleSYBInnerCodes, "match", i.Rematch},
|
||||
{"POST", "/syb-inner-codes/import", access.ModuleSYBInnerCodes, "import", i.Import},
|
||||
{"POST", "/syb-inner-codes/batch-delete", access.ModuleSYBInnerCodes, "delete", i.Delete},
|
||||
{"POST", "/syb-inner-codes/apply-preview", access.ModuleSYBInnerCodes, "writeback", i.ApplyPreview},
|
||||
{"POST", "/syb-inner-codes/apply", access.ModuleSYBInnerCodes, "writeback", i.Apply},
|
||||
{"POST", "/syb-inner-codes/:recordId/recheck", access.ModuleSYBInnerCodes, "match", i.Recheck},
|
||||
{"GET", "/syb-shops", access.ModuleSYBShops, "read", shop.List},
|
||||
{"POST", "/syb-shops", access.ModuleSYBShops, "write", shop.Create},
|
||||
{"PATCH", "/syb-shops/:shopId/name", access.ModuleSYBShops, "write", shop.Rename},
|
||||
{"PATCH", "/syb-shops/:shopId/enabled", access.ModuleSYBShops, "write", shop.SetEnabled},
|
||||
{"DELETE", "/syb-shops/:shopId", access.ModuleSYBShops, "delete", shop.Delete},
|
||||
{"GET", "/collection-rules", access.ModuleCollectionRules, "read", r.List},
|
||||
{"POST", "/collection-rules", access.ModuleCollectionRules, "write", r.Create},
|
||||
{"PATCH", "/collection-rules/:ruleId", access.ModuleCollectionRules, "write", r.Update},
|
||||
{"DELETE", "/collection-rules/:ruleId", access.ModuleCollectionRules, "delete", r.Delete},
|
||||
{"GET", "/purchase-rules", access.ModulePurchaseRules, "read", pr.List},
|
||||
{"GET", "/purchase-rules/current", access.ModulePurchaseRules, "read", pr.Current},
|
||||
{"POST", "/purchase-rules", access.ModulePurchaseRules, "write", pr.Create},
|
||||
{"PATCH", "/purchase-rules/:ruleId", access.ModulePurchaseRules, "write", pr.Update},
|
||||
{"DELETE", "/purchase-rules/:ruleId", access.ModulePurchaseRules, "delete", pr.Delete},
|
||||
{"PUT", "/purchase-rules/current", access.ModulePurchaseRules, "activate", pr.SetCurrent},
|
||||
{"GET", "/collection-tasks", access.ModuleCollectionTasks, "read", t.AdminList},
|
||||
{"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/:taskId/reset", access.ModuleCollectionTasks, "collect", t.AdminReset},
|
||||
{"DELETE", "/collection-tasks/:taskId", access.ModuleCollectionTasks, "delete", t.AdminDelete},
|
||||
{"GET", "/purchase-tasks", access.ModulePurchaseTasks, "read", buy.AdminList},
|
||||
{"GET", "/purchase-tasks/:taskId", access.ModulePurchaseTasks, "read", buy.AdminDetail},
|
||||
{"POST", "/purchase-tasks/batch-preview", access.ModulePurchaseTasks, "purchase", buy.AdminBatchPreview},
|
||||
{"POST", "/purchase-tasks", access.ModulePurchaseTasks, "purchase", buy.AdminCreate},
|
||||
{"POST", "/purchase-tasks/batch", access.ModulePurchaseTasks, "purchase", buy.AdminBatchCreate},
|
||||
{"POST", "/purchase-tasks/batch-retry", access.ModulePurchaseTasks, "purchase", buy.AdminBatchRetry},
|
||||
{"POST", "/purchase-tasks/stock", access.ModulePurchaseTasks, "purchase", buy.AdminCreateStock},
|
||||
{"GET", "/ai-matching-settings", access.ModuleAIMatching, "read", func(c *gin.Context) {
|
||||
db, err := pkg.GetOrm(c)
|
||||
if err != nil {
|
||||
c.JSON(500, gin.H{"code": 500})
|
||||
return
|
||||
}
|
||||
v, err := aimatching.NewService(db).Settings(c.Request.Context())
|
||||
if err != nil {
|
||||
c.JSON(500, gin.H{"code": 500})
|
||||
return
|
||||
}
|
||||
c.JSON(200, gin.H{"code": 200, "data": gin.H{"enabled": v.Enabled}})
|
||||
}},
|
||||
}
|
||||
}
|
||||
|
||||
func Catalog(routes []Endpoint) []clientkey.Module {
|
||||
mods := map[string]clientkey.Module{}
|
||||
for _, m := range access.GoAutoModules() {
|
||||
mods[m.Key] = clientkey.Module{Key: m.Key, Title: m.Title, Actions: []string{}}
|
||||
}
|
||||
for _, e := range routes {
|
||||
m := mods[e.Module]
|
||||
if e.Capability == "write" {
|
||||
m.Writable = true
|
||||
} else if e.Capability != "read" {
|
||||
found := false
|
||||
for _, a := range m.Actions {
|
||||
if a == e.Capability {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
m.Actions = append(m.Actions, e.Capability)
|
||||
}
|
||||
}
|
||||
mods[e.Module] = m
|
||||
}
|
||||
out := []clientkey.Module{}
|
||||
for _, g := range access.GoAutoMenuGroups() {
|
||||
for _, key := range g.ModuleKeys {
|
||||
m := mods[key]
|
||||
m.Group = g.Title
|
||||
sort.Strings(m.Actions)
|
||||
out = append(out, m)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func InitRouter(engine *gin.Engine, auth *jwt.GinJWTMiddleware) {
|
||||
routes := Inventory()
|
||||
catalog := Catalog(routes)
|
||||
h := clientkey.Handler{Modules: catalog}
|
||||
management := engine.Group("/api/admin/v1/client-keys", auth.MiddlewareFunc(), middleware.RequireRoleKey("admin"), NoStore)
|
||||
management.GET("", h.List)
|
||||
management.GET("/modules", h.Catalog)
|
||||
management.POST("", h.Create)
|
||||
management.PATCH("/:keyId/grants", h.Edit)
|
||||
management.POST("/:keyId/disable", h.Disable)
|
||||
for _, e := range routes {
|
||||
engine.Handle(e.Method, "/api/client/v1"+e.Path, Gate(nil, e), e.Handle)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
package clientkey
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/go-admin-team/go-admin-core/sdk/pkg"
|
||||
"github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth/user"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type Handler struct {
|
||||
DB *gorm.DB
|
||||
Modules []Module
|
||||
}
|
||||
|
||||
func (h Handler) service(c *gin.Context) (Service, bool) {
|
||||
db := h.DB
|
||||
var err error
|
||||
if db == nil {
|
||||
db, err = pkg.GetOrm(c)
|
||||
}
|
||||
if err != nil || db == nil {
|
||||
failure(c, errors.New("database unavailable"))
|
||||
return Service{}, false
|
||||
}
|
||||
c.Header("Cache-Control", "no-store")
|
||||
return Service{db, h.Modules}, true
|
||||
}
|
||||
func failure(c *gin.Context, err error) {
|
||||
status, message := 500, "服务端处理失败"
|
||||
switch {
|
||||
case errors.Is(err, ErrInvalid):
|
||||
status, message = 422, ErrInvalid.Error()
|
||||
case errors.Is(err, ErrConflict):
|
||||
status, message = 409, ErrConflict.Error()
|
||||
case errors.Is(err, gorm.ErrRecordNotFound):
|
||||
status, message = 404, "密钥不存在"
|
||||
}
|
||||
c.AbortWithStatusJSON(status, gin.H{"code": status, "message": message})
|
||||
}
|
||||
func (h Handler) Catalog(c *gin.Context) { c.JSON(200, gin.H{"code": 200, "data": h.Modules}) }
|
||||
func (h Handler) List(c *gin.Context) {
|
||||
s, ok := h.service(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
size := 20
|
||||
var total int64
|
||||
var keys []Key
|
||||
if err := s.DB.WithContext(c.Request.Context()).Model(&Key{}).Count(&total).Error; err != nil {
|
||||
failure(c, err)
|
||||
return
|
||||
}
|
||||
if err := s.DB.WithContext(c.Request.Context()).Order("id DESC").Offset((page - 1) * size).Limit(size).Find(&keys).Error; err != nil {
|
||||
failure(c, err)
|
||||
return
|
||||
}
|
||||
items := make([]View, 0, len(keys))
|
||||
for _, k := range keys {
|
||||
items = append(items, view(k))
|
||||
}
|
||||
c.JSON(200, gin.H{"code": 200, "data": gin.H{"items": items, "total": total, "page": page, "pageSize": size}})
|
||||
}
|
||||
func decode(c *gin.Context, v any) bool {
|
||||
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 64<<10)
|
||||
d := json.NewDecoder(c.Request.Body)
|
||||
d.DisallowUnknownFields()
|
||||
if d.Decode(v) != nil || d.Decode(&struct{}{}) != io.EOF {
|
||||
failure(c, ErrInvalid)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
func (h Handler) Create(c *gin.Context) {
|
||||
var req struct {
|
||||
Name string `json:"name"`
|
||||
Grants []Grant `json:"grants"`
|
||||
}
|
||||
if !decode(c, &req) {
|
||||
return
|
||||
}
|
||||
s, ok := h.service(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
result, secret, err := s.Create(c.Request.Context(), req.Name, req.Grants, uint64(user.GetUserId(c)))
|
||||
if err != nil {
|
||||
failure(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(200, gin.H{"code": 200, "data": gin.H{"key": result, "secret": secret}})
|
||||
}
|
||||
func (h Handler) Edit(c *gin.Context) { h.update(c, false) }
|
||||
func (h Handler) Disable(c *gin.Context) { h.update(c, true) }
|
||||
func (h Handler) update(c *gin.Context, disable bool) {
|
||||
id, err := strconv.ParseUint(c.Param("keyId"), 10, 64)
|
||||
if err != nil {
|
||||
failure(c, ErrInvalid)
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Version uint64 `json:"version"`
|
||||
Grants []Grant `json:"grants"`
|
||||
}
|
||||
if !decode(c, &req) {
|
||||
return
|
||||
}
|
||||
s, ok := h.service(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
result, err := s.Update(c.Request.Context(), id, req.Version, uint64(user.GetUserId(c)), req.Grants, disable)
|
||||
if err != nil {
|
||||
failure(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(200, gin.H{"code": 200, "data": result})
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
// Package clientkey implements independent, revocable client credentials (#237).
|
||||
package clientkey
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
var ErrInvalid = errors.New("名称或授权无效")
|
||||
var ErrConflict = errors.New("密钥已停用或授权已被修改,请刷新后重试")
|
||||
var ErrCredential = errors.New("客户端密钥无效或已停用")
|
||||
|
||||
type Grant struct {
|
||||
Module string `json:"module"`
|
||||
Write bool `json:"write"`
|
||||
Actions []string `json:"actions"`
|
||||
}
|
||||
type Module struct {
|
||||
Key string `json:"key"`
|
||||
Title string `json:"title"`
|
||||
Group string `json:"group"`
|
||||
Writable bool `json:"writable"`
|
||||
Actions []string `json:"actions"`
|
||||
}
|
||||
type Key struct {
|
||||
ID uint64 `gorm:"primaryKey" json:"id"`
|
||||
Name string `gorm:"size:80;not null" json:"name"`
|
||||
Prefix string `gorm:"size:24;not null" json:"prefix"`
|
||||
Digest string `gorm:"size:64;uniqueIndex;not null" json:"-"`
|
||||
GrantsJSON string `gorm:"type:text;not null" json:"-"`
|
||||
Enabled bool `gorm:"not null" json:"enabled"`
|
||||
Version uint64 `gorm:"not null" json:"version"`
|
||||
CreatedBy uint64 `json:"createdBy"`
|
||||
UpdatedBy uint64 `json:"updatedBy"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
LastUsedAt *time.Time `json:"lastUsedAt"`
|
||||
}
|
||||
|
||||
func (Key) TableName() string { return "client_api_key" }
|
||||
|
||||
type Audit struct {
|
||||
ID uint64 `gorm:"primaryKey"`
|
||||
KeyID uint64 `gorm:"index;not null"`
|
||||
ActorID uint64
|
||||
Event string `gorm:"size:32;not null"`
|
||||
RequestID string `gorm:"size:32;index"`
|
||||
Method string `gorm:"size:10"`
|
||||
Route string `gorm:"size:180"`
|
||||
Status int
|
||||
Changes string `gorm:"type:text"`
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
func (Audit) TableName() string { return "client_api_key_audit" }
|
||||
|
||||
type View struct {
|
||||
Key
|
||||
Grants []Grant `json:"grants"`
|
||||
}
|
||||
|
||||
func view(k Key) View {
|
||||
var g []Grant
|
||||
_ = json.Unmarshal([]byte(k.GrantsJSON), &g)
|
||||
return View{Key: k, Grants: g}
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
DB *gorm.DB
|
||||
Modules []Module
|
||||
}
|
||||
|
||||
func (s Service) Normalize(in []Grant) ([]Grant, error) {
|
||||
if len(in) == 0 || len(in) > len(s.Modules) {
|
||||
return nil, ErrInvalid
|
||||
}
|
||||
catalog := map[string]Module{}
|
||||
for _, m := range s.Modules {
|
||||
catalog[m.Key] = m
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
out := make([]Grant, 0, len(in))
|
||||
for _, g := range in {
|
||||
m, ok := catalog[g.Module]
|
||||
if !ok || seen[g.Module] || (g.Write && !m.Writable) {
|
||||
return nil, ErrInvalid
|
||||
}
|
||||
seen[g.Module] = true
|
||||
allowed := map[string]bool{}
|
||||
for _, a := range m.Actions {
|
||||
allowed[a] = true
|
||||
}
|
||||
used := map[string]bool{}
|
||||
actions := []string{}
|
||||
for _, a := range g.Actions {
|
||||
if !allowed[a] || used[a] {
|
||||
return nil, ErrInvalid
|
||||
}
|
||||
used[a] = true
|
||||
actions = append(actions, a)
|
||||
}
|
||||
sort.Strings(actions)
|
||||
out = append(out, Grant{g.Module, g.Write, actions})
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].Module < out[j].Module })
|
||||
return out, nil
|
||||
}
|
||||
func (s Service) Create(ctx context.Context, name string, grants []Grant, actor uint64) (View, string, error) {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" || len([]rune(name)) > 80 || actor == 0 {
|
||||
return View{}, "", ErrInvalid
|
||||
}
|
||||
g, err := s.Normalize(grants)
|
||||
if err != nil {
|
||||
return View{}, "", err
|
||||
}
|
||||
raw, _ := json.Marshal(g)
|
||||
secret := make([]byte, 32)
|
||||
if _, err = rand.Read(secret); err != nil {
|
||||
return View{}, "", err
|
||||
}
|
||||
token := "gak_" + hex.EncodeToString(secret)
|
||||
digest := sha256.Sum256([]byte(token))
|
||||
k := Key{Name: name, Prefix: token[:16], Digest: hex.EncodeToString(digest[:]), GrantsJSON: string(raw), Enabled: true, Version: 1, CreatedBy: actor, UpdatedBy: actor}
|
||||
err = s.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Create(&k).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Create(&Audit{KeyID: k.ID, ActorID: actor, Event: "create", Changes: string(raw)}).Error
|
||||
})
|
||||
if err != nil {
|
||||
return View{}, "", err
|
||||
}
|
||||
return view(k), token, nil
|
||||
}
|
||||
func (s Service) Update(ctx context.Context, id, version, actor uint64, grants []Grant, disable bool) (View, error) {
|
||||
if id == 0 || version == 0 || actor == 0 {
|
||||
return View{}, ErrInvalid
|
||||
}
|
||||
var raw []byte
|
||||
if !disable {
|
||||
g, err := s.Normalize(grants)
|
||||
if err != nil {
|
||||
return View{}, err
|
||||
}
|
||||
raw, _ = json.Marshal(g)
|
||||
}
|
||||
var k Key
|
||||
err := s.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.First(&k, id).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
changes := map[string]any{"version": version + 1, "updated_by": actor, "updated_at": time.Now().UTC()}
|
||||
event := "edit"
|
||||
if disable {
|
||||
changes["enabled"] = false
|
||||
event = "disable"
|
||||
} else {
|
||||
changes["grants_json"] = string(raw)
|
||||
}
|
||||
result := tx.Model(&Key{}).Where("id = ? AND version = ? AND enabled = ?", id, version, true).Updates(changes)
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
if result.RowsAffected != 1 {
|
||||
return ErrConflict
|
||||
}
|
||||
diff, _ := json.Marshal(map[string]string{"before": k.GrantsJSON, "after": string(raw)})
|
||||
if err := tx.Create(&Audit{KeyID: id, ActorID: actor, Event: event, Changes: string(diff)}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.First(&k, id).Error
|
||||
})
|
||||
return view(k), err
|
||||
}
|
||||
func (s Service) Authenticate(ctx context.Context, token string) (Key, error) {
|
||||
if len(token) != 68 || !strings.HasPrefix(token, "gak_") {
|
||||
return Key{}, ErrCredential
|
||||
}
|
||||
if _, err := hex.DecodeString(token[4:]); err != nil {
|
||||
return Key{}, ErrCredential
|
||||
}
|
||||
digest := sha256.Sum256([]byte(token))
|
||||
var k Key
|
||||
err := s.DB.WithContext(ctx).Where("digest = ? AND enabled = ?", hex.EncodeToString(digest[:]), true).First(&k).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return Key{}, ErrCredential
|
||||
}
|
||||
return k, err
|
||||
}
|
||||
func (k Key) Allows(module, capability string) bool {
|
||||
var grants []Grant
|
||||
if json.Unmarshal([]byte(k.GrantsJSON), &grants) != nil {
|
||||
return false
|
||||
}
|
||||
for _, g := range grants {
|
||||
if g.Module != module {
|
||||
continue
|
||||
}
|
||||
if capability == "read" {
|
||||
return true
|
||||
}
|
||||
if capability == "write" {
|
||||
return g.Write
|
||||
}
|
||||
for _, a := range g.Actions {
|
||||
if a == capability {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
package clientkey
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func testService(t *testing.T) Service {
|
||||
t.Helper()
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sql, _ := db.DB()
|
||||
sql.SetMaxOpenConns(1)
|
||||
t.Cleanup(func() { sql.Close() })
|
||||
if err = db.AutoMigrate(&Key{}, &Audit{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return Service{db, []Module{{Key: "products", Writable: true, Actions: []string{"delete"}}, {Key: "devices", Actions: []string{}}}}
|
||||
}
|
||||
func TestCredentialLifecycle(t *testing.T) {
|
||||
s := testService(t)
|
||||
ctx := context.Background()
|
||||
v, token, err := s.Create(ctx, "Tool", []Grant{{Module: "products"}}, 7)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(token) != 68 || v.Digest == token {
|
||||
t.Fatal("invalid credential generation")
|
||||
}
|
||||
wire, _ := json.Marshal(v)
|
||||
if strings.Contains(string(wire), token) || strings.Contains(string(wire), v.Digest) {
|
||||
t.Fatal("secret serialized")
|
||||
}
|
||||
k, err := s.Authenticate(ctx, token)
|
||||
if err != nil || !k.Allows("products", "read") || k.Allows("products", "write") || k.Allows("devices", "read") {
|
||||
t.Fatal("default grants")
|
||||
}
|
||||
updated, err := s.Update(ctx, k.ID, 1, 8, []Grant{{Module: "products", Write: true, Actions: []string{"delete"}}}, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
k, err = s.Authenticate(ctx, token)
|
||||
if err != nil || !k.Allows("products", "write") || !k.Allows("products", "delete") || updated.Version != 2 || k.Digest != v.Digest {
|
||||
t.Fatal("edit changed credential or failed to apply grants")
|
||||
}
|
||||
if _, err = s.Update(ctx, k.ID, 1, 8, []Grant{{Module: "devices"}}, false); !errors.Is(err, ErrConflict) {
|
||||
t.Fatal("stale edit accepted")
|
||||
}
|
||||
if _, err = s.Update(ctx, k.ID, 2, 8, nil, true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err = s.Authenticate(ctx, token); !errors.Is(err, ErrCredential) {
|
||||
t.Fatal("disabled credential accepted")
|
||||
}
|
||||
if _, err = s.Update(ctx, k.ID, 3, 8, []Grant{{Module: "products"}}, false); !errors.Is(err, ErrConflict) {
|
||||
t.Fatal("disabled key edited")
|
||||
}
|
||||
var logs []Audit
|
||||
s.DB.Find(&logs)
|
||||
if len(logs) != 3 {
|
||||
t.Fatalf("audit count %d", len(logs))
|
||||
}
|
||||
data, _ := json.Marshal(logs)
|
||||
if strings.Contains(string(data), token) {
|
||||
t.Fatal("secret in audit")
|
||||
}
|
||||
}
|
||||
func TestInvalidGrantsAndCredentials(t *testing.T) {
|
||||
s := testService(t)
|
||||
for _, g := range [][]Grant{nil, {{Module: "admin"}}, {{Module: "devices", Write: true}}, {{Module: "products", Actions: []string{"payment"}}}, {{Module: "products"}, {Module: "products"}}} {
|
||||
if _, err := s.Normalize(g); err == nil {
|
||||
t.Fatal("invalid grant accepted")
|
||||
}
|
||||
}
|
||||
for _, token := range []string{"", "jwt", "gak_" + strings.Repeat("z", 64), strings.Repeat("a", 68)} {
|
||||
if _, err := s.Authenticate(context.Background(), token); !errors.Is(err, ErrCredential) {
|
||||
t.Fatal("invalid credential accepted")
|
||||
}
|
||||
}
|
||||
}
|
||||
func TestAuditFailureRollsBack(t *testing.T) {
|
||||
s := testService(t)
|
||||
ctx := context.Background()
|
||||
v, _, err := s.Create(ctx, "Tool", []Grant{{Module: "products"}}, 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = s.DB.Migrator().DropTable(&Audit{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err = s.Update(ctx, v.ID, 1, 1, nil, true); err == nil {
|
||||
t.Fatal("audit failure ignored")
|
||||
}
|
||||
var k Key
|
||||
s.DB.First(&k, v.ID)
|
||||
if !k.Enabled || k.Version != 1 {
|
||||
t.Fatal("mutation not rolled back")
|
||||
}
|
||||
if _, secret, err := s.Create(ctx, "Failed", []Grant{{Module: "products"}}, 1); err == nil || secret != "" {
|
||||
t.Fatal("creation audit failure ignored")
|
||||
}
|
||||
var count int64
|
||||
s.DB.Model(&Key{}).Count(&count)
|
||||
if count != 1 {
|
||||
t.Fatal("key persisted without audit")
|
||||
}
|
||||
}
|
||||
@@ -34,7 +34,11 @@ func MigratedModels() []any {
|
||||
&models.PDDProduct{},
|
||||
&models.AIMatchingSetting{},
|
||||
&models.ShopeeProduct{},
|
||||
&models.ShopeeSpecAutoMatchRun{},
|
||||
&models.ShopeeSpecAutoMatchWorkItem{},
|
||||
&models.SYBProduct{},
|
||||
&models.SYBSpecAIParseRun{},
|
||||
&models.SYBSpecAIParseWorkItem{},
|
||||
&models.SYBSession{},
|
||||
&models.SYBShop{},
|
||||
&models.SYBSyncRun{},
|
||||
|
||||
@@ -536,6 +536,15 @@ type SYBProduct struct {
|
||||
// recording the parser's own last output for audit even after a manual
|
||||
// correction; it is not overwritten by the correction itself.
|
||||
ManuallyConfirmed bool `json:"manuallyConfirmed" gorm:"not null;default:false"`
|
||||
// AIConfirmed is independent of ParseStatus: ParseStatus remains the
|
||||
// deterministic parser's audit result, while these fields record a closed-
|
||||
// candidate, high-confidence AI decision. Human correction always clears
|
||||
// and supersedes this decision.
|
||||
AIConfirmed bool `json:"aiConfirmed" gorm:"not null;default:false;index"`
|
||||
AIConfidence *float64 `json:"aiConfidence,omitempty"`
|
||||
AIReason string `json:"aiReason,omitempty" gorm:"size:500;not null;default:''"`
|
||||
AIConfirmedAt *time.Time `json:"aiConfirmedAt,omitempty"`
|
||||
AIInputFingerprint string `json:"-" gorm:"size:64;not null;default:'';index"`
|
||||
|
||||
// RawJSON is the untouched `details[]` element as SYB returned it. It is
|
||||
// what reparse (#41: "适用于解析规则更新后批量重跑,只读取已保存的原始
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
// ShopeeSpecAutoMatchRun is one scheduled or administrator-triggered batch.
|
||||
// ActiveSlot is 1 only while running; its nullable unique index is the
|
||||
// database-level cross-process mutex shared by both trigger paths.
|
||||
type ShopeeSpecAutoMatchRun struct {
|
||||
ID uint64 `json:"id" gorm:"primaryKey;autoIncrement"`
|
||||
RequestID string `json:"requestId" gorm:"size:36;not null;uniqueIndex:ux_shopee_spec_auto_match_run_request"`
|
||||
Trigger string `json:"trigger" gorm:"size:16;not null;index"`
|
||||
Status string `json:"status" gorm:"size:24;not null;index"`
|
||||
ActiveSlot *uint8 `json:"-" gorm:"uniqueIndex:ux_shopee_spec_auto_match_run_active"`
|
||||
LeaseOwner string `json:"-" gorm:"size:64;not null;default:''"`
|
||||
LeaseExpiresAt *time.Time `json:"-" gorm:"index"`
|
||||
RequestedBy *uint64 `json:"requestedBy,omitempty"`
|
||||
BatchLimit int `json:"batchLimit" gorm:"not null;default:20"`
|
||||
ScannedCount int `json:"scannedCount" gorm:"not null;default:0"`
|
||||
EligibleCount int `json:"eligibleCount" gorm:"not null;default:0"`
|
||||
ProcessedCount int `json:"processedCount" gorm:"not null;default:0"`
|
||||
ConfirmedCount int `json:"confirmedCount" gorm:"not null;default:0"`
|
||||
UnmatchedCount int `json:"unmatchedCount" gorm:"not null;default:0"`
|
||||
FailedCount int `json:"failedCount" gorm:"not null;default:0"`
|
||||
ErrorSummary string `json:"errorSummary,omitempty" gorm:"size:500;not null;default:''"`
|
||||
StartedAt time.Time `json:"startedAt" gorm:"not null"`
|
||||
FinishedAt *time.Time `json:"finishedAt,omitempty"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
func (ShopeeSpecAutoMatchRun) TableName() string { return "shopee_spec_auto_match_run" }
|
||||
|
||||
// ShopeeSpecAutoMatchWorkItem remembers the last input fingerprint and retry
|
||||
// state for each product, preventing unchanged low-confidence inputs from
|
||||
// repeatedly spending AI calls.
|
||||
type ShopeeSpecAutoMatchWorkItem struct {
|
||||
ID uint64 `json:"id" gorm:"primaryKey;autoIncrement"`
|
||||
ShopeeProductID uint64 `json:"shopeeProductId" gorm:"not null;uniqueIndex:ux_shopee_spec_auto_match_work_product"`
|
||||
RunID *uint64 `json:"runId,omitempty" gorm:"index"`
|
||||
InputFingerprint string `json:"inputFingerprint" gorm:"size:128;not null;default:'';index"`
|
||||
Status string `json:"status" gorm:"size:24;not null;index"`
|
||||
AttemptCount int `json:"attemptCount" gorm:"not null;default:0"`
|
||||
NextAttemptAt *time.Time `json:"nextAttemptAt,omitempty" gorm:"index"`
|
||||
LeaseOwner string `json:"-" gorm:"size:64;not null;default:''"`
|
||||
LeaseExpiresAt *time.Time `json:"-" gorm:"index"`
|
||||
ConfirmedCount int `json:"confirmedCount" gorm:"not null;default:0"`
|
||||
UnmatchedCount int `json:"unmatchedCount" gorm:"not null;default:0"`
|
||||
LastErrorCode string `json:"lastErrorCode,omitempty" gorm:"size:64;not null;default:''"`
|
||||
LastError string `json:"lastError,omitempty" gorm:"size:500;not null;default:''"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
func (ShopeeSpecAutoMatchWorkItem) TableName() string {
|
||||
return "shopee_spec_auto_match_work_item"
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
// SYBSpecAIParseRun is one globally serialized scheduled batch.
|
||||
type SYBSpecAIParseRun struct {
|
||||
ID uint64 `json:"id" gorm:"primaryKey;autoIncrement"`
|
||||
RequestID string `json:"requestId" gorm:"size:36;not null;uniqueIndex:ux_syb_spec_ai_parse_run_request"`
|
||||
Trigger string `json:"trigger" gorm:"size:16;not null;index"`
|
||||
Status string `json:"status" gorm:"size:24;not null;index"`
|
||||
ActiveSlot *uint8 `json:"-" gorm:"uniqueIndex:ux_syb_spec_ai_parse_run_active"`
|
||||
LeaseOwner string `json:"-" gorm:"size:64;not null;default:''"`
|
||||
LeaseExpiresAt *time.Time `json:"-" gorm:"index"`
|
||||
BatchLimit int `json:"batchLimit" gorm:"not null;default:20"`
|
||||
ScannedCount int `json:"scannedCount" gorm:"not null;default:0"`
|
||||
EligibleCount int `json:"eligibleCount" gorm:"not null;default:0"`
|
||||
ProcessedCount int `json:"processedCount" gorm:"not null;default:0"`
|
||||
ConfirmedCount int `json:"confirmedCount" gorm:"not null;default:0"`
|
||||
UnmatchedCount int `json:"unmatchedCount" gorm:"not null;default:0"`
|
||||
FailedCount int `json:"failedCount" gorm:"not null;default:0"`
|
||||
ErrorSummary string `json:"errorSummary,omitempty" gorm:"size:500;not null;default:''"`
|
||||
StartedAt time.Time `json:"startedAt" gorm:"not null"`
|
||||
FinishedAt *time.Time `json:"finishedAt,omitempty"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
func (SYBSpecAIParseRun) TableName() string { return "syb_spec_ai_parse_run" }
|
||||
|
||||
// SYBSpecAIParseWorkItem prevents unchanged ambiguous input from repeatedly
|
||||
// spending provider calls and owns the per-row recovery lease.
|
||||
type SYBSpecAIParseWorkItem struct {
|
||||
ID uint64 `json:"id" gorm:"primaryKey;autoIncrement"`
|
||||
SYBProductID uint64 `json:"sybProductId" gorm:"not null;uniqueIndex:ux_syb_spec_ai_parse_work_product"`
|
||||
RunID *uint64 `json:"runId,omitempty" gorm:"index"`
|
||||
InputFingerprint string `json:"inputFingerprint" gorm:"size:64;not null;default:'';index"`
|
||||
Status string `json:"status" gorm:"size:24;not null;index"`
|
||||
AttemptCount int `json:"attemptCount" gorm:"not null;default:0"`
|
||||
NextAttemptAt *time.Time `json:"nextAttemptAt,omitempty" gorm:"index"`
|
||||
LeaseOwner string `json:"-" gorm:"size:64;not null;default:''"`
|
||||
LeaseExpiresAt *time.Time `json:"-" gorm:"index"`
|
||||
LastErrorCode string `json:"lastErrorCode,omitempty" gorm:"size:64;not null;default:''"`
|
||||
LastError string `json:"lastError,omitempty" gorm:"size:500;not null;default:''"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
func (SYBSpecAIParseWorkItem) TableName() string { return "syb_spec_ai_parse_work_item" }
|
||||
@@ -32,10 +32,44 @@ type skuCombinationRow struct {
|
||||
}
|
||||
|
||||
func sybSpecsTrusted(syb models.SYBProduct) bool {
|
||||
if syb.ParseStatus == models.SYBParseStatusFailed {
|
||||
if strings.TrimSpace(syb.TargetColor) == "" && strings.TrimSpace(syb.TargetSize) == "" {
|
||||
return false
|
||||
}
|
||||
return strings.TrimSpace(syb.TargetColor) != "" || strings.TrimSpace(syb.TargetSize) != ""
|
||||
return syb.ParseStatus != models.SYBParseStatusFailed || syb.ManuallyConfirmed || syb.AIConfirmed
|
||||
}
|
||||
|
||||
func aiConfirmedSpecsCurrent(syb models.SYBProduct, shopee models.ShopeeProduct) bool {
|
||||
if !syb.AIConfirmed {
|
||||
return true
|
||||
}
|
||||
specs, err := shopeeproduct.Unmarshal(shopee.SpecsJSON)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
wanted := map[string]string{shopeeproduct.RoleColor: strings.TrimSpace(syb.TargetColor), shopeeproduct.RoleSize: strings.TrimSpace(syb.TargetSize)}
|
||||
foundAny := false
|
||||
for role, target := range wanted {
|
||||
if target == "" {
|
||||
continue
|
||||
}
|
||||
foundAny = true
|
||||
found := false
|
||||
for _, dimension := range specs {
|
||||
if dimension.Role != role {
|
||||
continue
|
||||
}
|
||||
for _, value := range dimension.Values {
|
||||
if value.Name == target {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return foundAny
|
||||
}
|
||||
|
||||
func (s *Service) loadLatestSKUCombinations(ctx context.Context, pddIDs []uint64, dataset *batchPreviewDataset) error {
|
||||
|
||||
@@ -388,6 +388,10 @@ func (s *Service) previewFromDataset(id uint64, dataset batchPreviewDataset, gua
|
||||
item.ReasonCode, item.Reason, item.NextAction = "SHOPEE_NOT_FOUND", "关联的蝦皮商品不存在,请先处理商品档案", "open_shopee"
|
||||
return item
|
||||
}
|
||||
if !aiConfirmedSpecsCurrent(syb, shopee) {
|
||||
item.ReasonCode, item.Reason, item.NextAction = "SYB_PARSE_FAILED", "AI 解析依据已变化,请等待重新解析或人工修正", "reparse"
|
||||
return item
|
||||
}
|
||||
if shopee.PDDProductID == nil {
|
||||
item.ReasonCode, item.Reason, item.NextAction = "PDD_NOT_LINKED", "尚未关联 PDD 商品,请先关联", processActionOpenPDDLink
|
||||
return item
|
||||
|
||||
@@ -54,6 +54,9 @@ func TestSybSpecsTrustedOnlyBlocksFailedOrEmptyExtraction(t *testing.T) {
|
||||
{"uncertain with color", models.SYBProduct{ParseStatus: models.SYBParseStatusUncertain, TargetColor: "套装"}, true},
|
||||
{"uncertain with size", models.SYBProduct{ParseStatus: models.SYBParseStatusUncertain, TargetSize: "均码"}, true},
|
||||
{"failed with values", models.SYBProduct{ParseStatus: models.SYBParseStatusFailed, TargetColor: "黑色"}, false},
|
||||
{"failed AI confirmed", models.SYBProduct{ParseStatus: models.SYBParseStatusFailed, TargetColor: "黑色", AIConfirmed: true}, true},
|
||||
{"failed manually confirmed", models.SYBProduct{ParseStatus: models.SYBParseStatusFailed, TargetColor: "黑色", ManuallyConfirmed: true}, true},
|
||||
{"AI confirmed without values", models.SYBProduct{ParseStatus: models.SYBParseStatusFailed, AIConfirmed: true}, false},
|
||||
{"uncertain without values", models.SYBProduct{ParseStatus: models.SYBParseStatusUncertain}, false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
@@ -65,6 +68,28 @@ func TestSybSpecsTrustedOnlyBlocksFailedOrEmptyExtraction(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSYBSpecsTrustedAcceptsAIWithoutCallingItManual(t *testing.T) {
|
||||
row := models.SYBProduct{ParseStatus: models.SYBParseStatusUncertain, TargetColor: "黑色", AIConfirmed: true}
|
||||
if !sybSpecsTrusted(row) {
|
||||
t.Fatal("a valid AI-confirmed parse must pass the purchase parse gate")
|
||||
}
|
||||
if row.ManuallyConfirmed {
|
||||
t.Fatal("AI confirmation must not be represented as human confirmation")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAIConfirmedSpecsBecomeUntrustedWhenShopeeCandidateDisappears(t *testing.T) {
|
||||
row := models.SYBProduct{TargetColor: "黑色", TargetSize: "XL", ParseStatus: models.SYBParseStatusUncertain, AIConfirmed: true}
|
||||
product := models.ShopeeProduct{SpecsJSON: `[{"name":"颜色","role":"color","values":[{"name":"白色","source":"import"}]},{"name":"尺码","role":"size","values":[{"name":"XL","source":"import"}]}]`}
|
||||
if aiConfirmedSpecsCurrent(row, product) {
|
||||
t.Fatal("removed Shopee candidate must invalidate the AI parse gate")
|
||||
}
|
||||
product.SpecsJSON = `[{"name":"颜色","role":"color","values":[{"name":"黑色","source":"import"}]},{"name":"尺码","role":"size","values":[{"name":"XL","source":"import"}]}]`
|
||||
if !aiConfirmedSpecsCurrent(row, product) {
|
||||
t.Fatal("unchanged exact Shopee candidates should keep AI parse valid")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchPreviewExposesIndependentCollectionEligibility(t *testing.T) {
|
||||
db := testDB(t)
|
||||
fixture := seed(t, db, liveCaps(), true)
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"go-admin/common/clientprincipal"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
@@ -470,6 +471,9 @@ func writeAdminReplay(c *gin.Context, data any, replayed bool) {
|
||||
}
|
||||
|
||||
func allowedOperator(c *gin.Context) bool {
|
||||
if _, ok := clientprincipal.Get(c); ok {
|
||||
return true
|
||||
}
|
||||
role, _ := jwt.ExtractClaims(c)["rolekey"].(string)
|
||||
if role == "admin" || role == "purchaser" {
|
||||
return true
|
||||
@@ -544,6 +548,9 @@ func writeError(c *gin.Context, err error) {
|
||||
c.JSON(status, gin.H{"code": code, "message": msg, "retryable": retryable})
|
||||
}
|
||||
func operatorID(c *gin.Context) uint64 {
|
||||
if id, ok := clientprincipal.Get(c); ok {
|
||||
return id.AuthorizedBy
|
||||
}
|
||||
claims := jwt.ExtractClaims(c)
|
||||
switch v := claims["identity"].(type) {
|
||||
case float64:
|
||||
|
||||
@@ -136,7 +136,7 @@ func processStageFromDataset(id uint64, dataset batchPreviewDataset, preview Bat
|
||||
if !ok {
|
||||
return stage(ProcessStageManualAction, "SYB 商品不存在或已删除", "refresh")
|
||||
}
|
||||
if syb.ParseStatus == models.SYBParseStatusFailed {
|
||||
if syb.ParseStatus == models.SYBParseStatusFailed && !syb.ManuallyConfirmed && !syb.AIConfirmed {
|
||||
return stage(ProcessStageManualAction, "解析失败,请先处理", "reparse")
|
||||
}
|
||||
if syb.ShopeeProductID == nil {
|
||||
|
||||
@@ -48,6 +48,9 @@ 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)
|
||||
defer cancel()
|
||||
|
||||
requestID := strings.TrimSpace(request.RequestID)
|
||||
if _, err := uuid.Parse(requestID); err != nil {
|
||||
return AutoMatchResponse{}, invalidRequest("requestId 必须是 UUID")
|
||||
|
||||
@@ -0,0 +1,360 @@
|
||||
package shopeeproduct
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go-admin/app/goauto/models"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const (
|
||||
SpecAutoMatchInvokeTarget = "GoAutoShopeeSpecAutoMatch"
|
||||
defaultAutoMatchBatchLimit = 20
|
||||
autoMatchLeaseDuration = 30 * time.Minute
|
||||
autoMatchRetryDelay = time.Hour
|
||||
maxAutoMatchAttempts = 3
|
||||
)
|
||||
|
||||
type AutoMatchRunView struct {
|
||||
models.ShopeeSpecAutoMatchRun
|
||||
AlreadyRunning bool `json:"alreadyRunning,omitempty"`
|
||||
Replayed bool `json:"replayed,omitempty"`
|
||||
}
|
||||
|
||||
// StartAutoMatchRun acquires the single database-backed activity slot. A
|
||||
// repeated requestId is idempotent; a concurrent trigger receives the current
|
||||
// run instead of starting a second batch.
|
||||
func (service *Service) StartAutoMatchRun(ctx context.Context, trigger, requestID string, requestedBy *uint64, batchLimit int) (AutoMatchRunView, bool, error) {
|
||||
if _, err := uuid.Parse(strings.TrimSpace(requestID)); err != nil {
|
||||
return AutoMatchRunView{}, false, invalidRequest("requestId 必须是 UUID")
|
||||
}
|
||||
if trigger != "manual" && trigger != "scheduled" {
|
||||
return AutoMatchRunView{}, false, invalidRequest("trigger 无效")
|
||||
}
|
||||
if batchLimit <= 0 {
|
||||
batchLimit = defaultAutoMatchBatchLimit
|
||||
}
|
||||
if batchLimit > 100 {
|
||||
return AutoMatchRunView{}, false, invalidRequest("batchLimit 不能超过 100")
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
lease := now.Add(autoMatchLeaseDuration)
|
||||
owner := uuid.NewString()
|
||||
one := uint8(1)
|
||||
var result models.ShopeeSpecAutoMatchRun
|
||||
created := false
|
||||
err := service.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Model(&models.ShopeeSpecAutoMatchRun{}).
|
||||
Where("status = ? AND active_slot = ? AND lease_expires_at < ?", "running", 1, now).
|
||||
Updates(map[string]any{"status": "failed", "active_slot": nil, "lease_owner": "", "lease_expires_at": nil, "error_summary": "上次运行租约过期,已安全释放", "finished_at": now}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Where("request_id = ?", requestID).First(&result).Error; err == nil {
|
||||
return nil
|
||||
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return err
|
||||
}
|
||||
if err := tx.Where("status = ? AND active_slot = ?", "running", 1).First(&result).Error; err == nil {
|
||||
result.ActiveSlot = &one
|
||||
return nil
|
||||
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return err
|
||||
}
|
||||
result = models.ShopeeSpecAutoMatchRun{RequestID: requestID, Trigger: trigger, Status: "running", ActiveSlot: &one, LeaseOwner: owner, LeaseExpiresAt: &lease, RequestedBy: requestedBy, BatchLimit: batchLimit, StartedAt: now}
|
||||
if err := tx.Create(&result).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
created = true
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
// A unique-slot race means another instance won after our read. Return
|
||||
// its run as the stable, non-error result.
|
||||
if findErr := service.DB.WithContext(ctx).Where("status = ? AND active_slot = ?", "running", 1).First(&result).Error; findErr == nil {
|
||||
return AutoMatchRunView{ShopeeSpecAutoMatchRun: result, AlreadyRunning: true}, false, nil
|
||||
}
|
||||
return AutoMatchRunView{}, false, internalError(err)
|
||||
}
|
||||
view := AutoMatchRunView{ShopeeSpecAutoMatchRun: result}
|
||||
if !created {
|
||||
view.AlreadyRunning = result.RequestID != requestID
|
||||
view.Replayed = result.RequestID == requestID
|
||||
}
|
||||
return view, created, nil
|
||||
}
|
||||
|
||||
func (service *Service) LatestAutoMatchRun(ctx context.Context) (*AutoMatchRunView, error) {
|
||||
var run models.ShopeeSpecAutoMatchRun
|
||||
err := service.DB.WithContext(ctx).Order("id DESC").First(&run).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, internalError(err)
|
||||
}
|
||||
return &AutoMatchRunView{ShopeeSpecAutoMatchRun: run}, nil
|
||||
}
|
||||
|
||||
// ProcessAutoMatchRun performs a bounded batch. It is safe to call from an
|
||||
// HTTP-launched goroutine or the scheduler because only the run owning the
|
||||
// active slot may update and finish itself.
|
||||
func (service *Service) ProcessAutoMatchRun(ctx context.Context, runID uint64) error {
|
||||
var run models.ShopeeSpecAutoMatchRun
|
||||
if err := service.DB.WithContext(ctx).First(&run, runID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if run.Status != "running" || run.ActiveSlot == nil || *run.ActiveSlot != 1 {
|
||||
return nil
|
||||
}
|
||||
limit := run.BatchLimit
|
||||
if limit <= 0 || limit > 100 {
|
||||
limit = defaultAutoMatchBatchLimit
|
||||
}
|
||||
var candidates []models.ShopeeProduct
|
||||
queryLimit := limit * 25
|
||||
if queryLimit < 100 {
|
||||
queryLimit = 100
|
||||
}
|
||||
if queryLimit > 1000 {
|
||||
queryLimit = 1000
|
||||
}
|
||||
if err := service.DB.WithContext(ctx).
|
||||
Joins("JOIN pdd_product ON pdd_product.id = shopee_product.pdd_product_id AND pdd_product.status = ?", "active").
|
||||
Where("shopee_product.pdd_product_id IS NOT NULL").
|
||||
Order("shopee_product.updated_at ASC, shopee_product.id ASC").Limit(queryLimit).Find(&candidates).Error; err != nil {
|
||||
service.finishAutoMatchRun(run, "failed", 0, 0, 0, 0, 0, 1, "扫描符合条件的商品失败")
|
||||
return err
|
||||
}
|
||||
|
||||
eligible, processed, confirmed, unmatched, failed := 0, 0, 0, 0, 0
|
||||
firstError := ""
|
||||
for _, product := range candidates {
|
||||
if processed >= limit {
|
||||
break
|
||||
}
|
||||
fingerprint, ok, err := service.autoMatchEligibility(ctx, product)
|
||||
if err != nil {
|
||||
failed++
|
||||
if firstError == "" {
|
||||
firstError = safeBatchError(err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
eligible++
|
||||
work, claimed, err := service.claimAutoMatchWork(ctx, run, product.ID, fingerprint)
|
||||
if err != nil {
|
||||
failed++
|
||||
if firstError == "" {
|
||||
firstError = safeBatchError(err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if !claimed {
|
||||
continue
|
||||
}
|
||||
processed++
|
||||
service.renewAutoMatchRun(run)
|
||||
response, matchErr := service.AutoMatchMappings(ctx, product.ID, AutoMatchRequest{RequestID: uuid.NewString(), SpecContextVersion: fingerprint[:64]})
|
||||
// fingerprint begins with the 64-character context version.
|
||||
postFingerprint := fingerprint
|
||||
if next, _, nextErr := service.autoMatchEligibility(ctx, product); nextErr == nil && next != "" {
|
||||
postFingerprint = next
|
||||
}
|
||||
if matchErr != nil {
|
||||
failed++
|
||||
if firstError == "" {
|
||||
firstError = safeBatchError(matchErr)
|
||||
}
|
||||
service.completeAutoMatchWork(work, postFingerprint, 0, 0, matchErr)
|
||||
continue
|
||||
}
|
||||
confirmed += response.ConfirmedCount
|
||||
unmatched += response.UnmatchedCount
|
||||
service.completeAutoMatchWork(work, postFingerprint, response.ConfirmedCount, response.UnmatchedCount, nil)
|
||||
}
|
||||
status := "completed"
|
||||
if failed > 0 {
|
||||
status = "completed_partial"
|
||||
}
|
||||
return service.finishAutoMatchRun(run, status, len(candidates), eligible, processed, confirmed, unmatched, failed, firstError)
|
||||
}
|
||||
|
||||
func (service *Service) autoMatchEligibility(ctx context.Context, product models.ShopeeProduct) (string, bool, error) {
|
||||
if product.PDDProductID == nil {
|
||||
return "", false, nil
|
||||
}
|
||||
var pdd models.PDDProduct
|
||||
if err := service.DB.WithContext(ctx).First(&pdd, *product.PDDProductID).Error; err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
if pdd.Status != "active" {
|
||||
return "", false, nil
|
||||
}
|
||||
shopeeSpecs, err := Unmarshal(product.SpecsJSON)
|
||||
if err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
shared, needsMatch := false, false
|
||||
for _, role := range []string{RoleColor, RoleSize} {
|
||||
pddValues, err := selectablePDDValues(pdd.SpecsJSON, role)
|
||||
if err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
if len(pddValues) == 0 {
|
||||
continue
|
||||
}
|
||||
for _, dimension := range shopeeSpecs {
|
||||
if dimension.Role != role || len(dimension.Values) == 0 {
|
||||
continue
|
||||
}
|
||||
shared = true
|
||||
for _, value := range dimension.Values {
|
||||
if value.Mapping == nil || value.Mapping.Status != MappingStatusConfirmed || !pddValues[value.Mapping.PDDValue] {
|
||||
needsMatch = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if !shared || !needsMatch {
|
||||
return "", false, nil
|
||||
}
|
||||
contextVersion := computeSpecContextVersion(product.PDDProductID, product.SpecsJSON, pdd.SpecsJSON)
|
||||
var setting struct{ UpdatedAt time.Time }
|
||||
_ = service.DB.WithContext(ctx).Table((models.AIMatchingSetting{}).TableName()).Select("updated_at").Where("id = ?", 1).Scan(&setting).Error
|
||||
h := sha256.Sum256([]byte(contextVersion + "\x00" + setting.UpdatedAt.UTC().Format(time.RFC3339Nano)))
|
||||
// Keeping the context version as a prefix lets ProcessAutoMatchRun pass the
|
||||
// exact version to #194 without re-reading a potentially drifting input.
|
||||
return contextVersion + hex.EncodeToString(h[:]), true, nil
|
||||
}
|
||||
|
||||
func (service *Service) claimAutoMatchWork(ctx context.Context, run models.ShopeeSpecAutoMatchRun, productID uint64, fingerprint string) (models.ShopeeSpecAutoMatchWorkItem, bool, error) {
|
||||
now := time.Now().UTC()
|
||||
lease := now.Add(autoMatchLeaseDuration)
|
||||
var work models.ShopeeSpecAutoMatchWorkItem
|
||||
err := service.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
err := tx.Where("shopee_product_id = ?", productID).First(&work).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
work = models.ShopeeSpecAutoMatchWorkItem{ShopeeProductID: productID, RunID: &run.ID, InputFingerprint: fingerprint, Status: "running", AttemptCount: 1, LeaseOwner: run.LeaseOwner, LeaseExpiresAt: &lease}
|
||||
return tx.Create(&work).Error
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if work.InputFingerprint == fingerprint {
|
||||
if work.Status == "completed" || work.Status == "unmatched" || work.AttemptCount >= maxAutoMatchAttempts || (work.NextAttemptAt != nil && work.NextAttemptAt.After(now)) || (work.Status == "running" && work.LeaseExpiresAt != nil && work.LeaseExpiresAt.After(now)) {
|
||||
return errWorkNotClaimed
|
||||
}
|
||||
} else {
|
||||
work.AttemptCount = 0
|
||||
}
|
||||
updates := map[string]any{"run_id": run.ID, "input_fingerprint": fingerprint, "status": "running", "attempt_count": work.AttemptCount + 1, "next_attempt_at": nil, "lease_owner": run.LeaseOwner, "lease_expires_at": lease, "last_error_code": "", "last_error": ""}
|
||||
if err := tx.Model(&models.ShopeeSpecAutoMatchWorkItem{}).Where("id = ?", work.ID).Updates(updates).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.First(&work, work.ID).Error
|
||||
})
|
||||
if errors.Is(err, errWorkNotClaimed) {
|
||||
return work, false, nil
|
||||
}
|
||||
return work, err == nil, err
|
||||
}
|
||||
|
||||
var errWorkNotClaimed = errors.New("auto match work not claimed")
|
||||
|
||||
func (service *Service) completeAutoMatchWork(work models.ShopeeSpecAutoMatchWorkItem, fingerprint string, confirmed, unmatched int, matchErr error) {
|
||||
now := time.Now().UTC()
|
||||
updates := map[string]any{"input_fingerprint": fingerprint, "lease_owner": "", "lease_expires_at": nil, "confirmed_count": confirmed, "unmatched_count": unmatched}
|
||||
if matchErr == nil {
|
||||
if unmatched > 0 {
|
||||
updates["status"] = "unmatched"
|
||||
} else {
|
||||
updates["status"] = "completed"
|
||||
}
|
||||
updates["next_attempt_at"], updates["last_error_code"], updates["last_error"] = nil, "", ""
|
||||
} else {
|
||||
code := batchErrorCode(matchErr)
|
||||
updates["status"], updates["last_error_code"], updates["last_error"] = "failed", code, safeBatchError(matchErr)
|
||||
if code == CodeAIUnavailable && work.AttemptCount < maxAutoMatchAttempts {
|
||||
next := now.Add(autoMatchRetryDelay)
|
||||
updates["next_attempt_at"] = next
|
||||
} else {
|
||||
updates["next_attempt_at"] = nil
|
||||
}
|
||||
}
|
||||
_ = service.DB.Model(&models.ShopeeSpecAutoMatchWorkItem{}).Where("id = ?", work.ID).Updates(updates).Error
|
||||
}
|
||||
|
||||
func (service *Service) renewAutoMatchRun(run models.ShopeeSpecAutoMatchRun) {
|
||||
lease := time.Now().UTC().Add(autoMatchLeaseDuration)
|
||||
_ = service.DB.Model(&models.ShopeeSpecAutoMatchRun{}).Where("id = ? AND status = ? AND lease_owner = ?", run.ID, "running", run.LeaseOwner).Update("lease_expires_at", lease).Error
|
||||
}
|
||||
|
||||
func (service *Service) finishAutoMatchRun(run models.ShopeeSpecAutoMatchRun, status string, scanned, eligible, processed, confirmed, unmatched, failed int, summary string) error {
|
||||
now := time.Now().UTC()
|
||||
updates := map[string]any{"status": status, "active_slot": nil, "lease_owner": "", "lease_expires_at": nil, "scanned_count": scanned, "eligible_count": eligible, "processed_count": processed, "confirmed_count": confirmed, "unmatched_count": unmatched, "failed_count": failed, "error_summary": truncateBatchText(summary), "finished_at": now}
|
||||
return service.DB.Model(&models.ShopeeSpecAutoMatchRun{}).Where("id = ? AND status = ? AND lease_owner = ?", run.ID, "running", run.LeaseOwner).Updates(updates).Error
|
||||
}
|
||||
|
||||
func batchErrorCode(err error) string {
|
||||
var serviceErr *ServiceError
|
||||
if errors.As(err, &serviceErr) {
|
||||
return serviceErr.Code
|
||||
}
|
||||
return CodeInternal
|
||||
}
|
||||
|
||||
func safeBatchError(err error) string {
|
||||
var serviceErr *ServiceError
|
||||
if errors.As(err, &serviceErr) {
|
||||
return truncateBatchText(serviceErr.Message)
|
||||
}
|
||||
return "服务端处理失败"
|
||||
}
|
||||
|
||||
func truncateBatchText(value string) string {
|
||||
runes := []rune(strings.TrimSpace(value))
|
||||
if len(runes) > 500 {
|
||||
runes = runes[:500]
|
||||
}
|
||||
return string(runes)
|
||||
}
|
||||
|
||||
type scheduledAutoMatchArgs struct {
|
||||
BatchLimit int `json:"batchLimit"`
|
||||
}
|
||||
|
||||
type ScheduledAutoMatchJob struct{}
|
||||
|
||||
func (ScheduledAutoMatchJob) Exec(_ interface{}) error {
|
||||
return errors.New("规格自动匹配定时任务缺少数据库连接")
|
||||
}
|
||||
|
||||
func (ScheduledAutoMatchJob) ExecWithDB(db *gorm.DB, arg interface{}) error {
|
||||
args := scheduledAutoMatchArgs{BatchLimit: defaultAutoMatchBatchLimit}
|
||||
if raw, ok := arg.(string); ok && strings.TrimSpace(raw) != "" {
|
||||
if err := json.Unmarshal([]byte(raw), &args); err != nil {
|
||||
return fmt.Errorf("规格自动匹配参数不是合法 JSON: %w", err)
|
||||
}
|
||||
}
|
||||
if args.BatchLimit < 1 || args.BatchLimit > 100 {
|
||||
return errors.New("规格自动匹配 batchLimit 必须在 1 到 100 之间")
|
||||
}
|
||||
service := NewService(db)
|
||||
run, created, err := service.StartAutoMatchRun(context.Background(), "scheduled", uuid.NewString(), nil, args.BatchLimit)
|
||||
if err != nil || !created {
|
||||
return err
|
||||
}
|
||||
return service.ProcessAutoMatchRun(context.Background(), run.ID)
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package shopeeproduct
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"go-admin/app/goauto/models"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
func TestAutoMatchRunIsIdempotentAndGloballySerialized(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
service := NewService(db)
|
||||
requestID := uuid.NewString()
|
||||
first, created, err := service.StartAutoMatchRun(context.Background(), "manual", requestID, nil, 20)
|
||||
if err != nil || !created {
|
||||
t.Fatalf("first=%+v created=%v err=%v", first, created, err)
|
||||
}
|
||||
replay, created, err := service.StartAutoMatchRun(context.Background(), "manual", requestID, nil, 20)
|
||||
if err != nil || created || !replay.Replayed || replay.ID != first.ID {
|
||||
t.Fatalf("replay=%+v created=%v err=%v", replay, created, err)
|
||||
}
|
||||
concurrent, created, err := service.StartAutoMatchRun(context.Background(), "scheduled", uuid.NewString(), nil, 20)
|
||||
if err != nil || created || !concurrent.AlreadyRunning || concurrent.ID != first.ID {
|
||||
t.Fatalf("concurrent=%+v created=%v err=%v", concurrent, created, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessAutoMatchRunConfirmsExactSizeAndFinishes(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
pdd := seedPDDProduct(t, db, "active")
|
||||
service := NewService(db)
|
||||
createdProduct, err := service.Create(context.Background(), CreateRequest{
|
||||
RequestID: uuid.NewString(), ShopeeItemID: "SP-BATCH-EXACT", PDDProductID: &pdd.ID,
|
||||
Specs: []SpecDimension{{Name: "尺码", Role: RoleSize, Values: []SpecValue{{Name: " xl ", Source: ValueSourceImport}}}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
run, started, err := service.StartAutoMatchRun(context.Background(), "manual", uuid.NewString(), nil, 20)
|
||||
if err != nil || !started {
|
||||
t.Fatalf("run=%+v started=%v err=%v", run, started, err)
|
||||
}
|
||||
if err := service.ProcessAutoMatchRun(context.Background(), run.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
latest, err := service.LatestAutoMatchRun(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if latest == nil || latest.Status != "completed" || latest.ProcessedCount != 1 || latest.ConfirmedCount != 1 || latest.ActiveSlot != nil {
|
||||
t.Fatalf("latest=%+v", latest)
|
||||
}
|
||||
detail, err := service.Detail(context.Background(), createdProduct.Product.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mapping := detail.Product.Specs[0].Values[0].Mapping
|
||||
if mapping == nil || mapping.Status != MappingStatusConfirmed || mapping.PDDValue != "XL" {
|
||||
t.Fatalf("mapping=%+v", mapping)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnchangedUnmatchedWorkIsNotClaimedAgain(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
service := NewService(db)
|
||||
one := uint8(1)
|
||||
run := models.ShopeeSpecAutoMatchRun{RequestID: uuid.NewString(), Trigger: "manual", Status: "running", ActiveSlot: &one, LeaseOwner: uuid.NewString(), BatchLimit: 20}
|
||||
if err := db.Create(&run).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
work, claimed, err := service.claimAutoMatchWork(context.Background(), run, 99, "fingerprint")
|
||||
if err != nil || !claimed {
|
||||
t.Fatalf("work=%+v claimed=%v err=%v", work, claimed, err)
|
||||
}
|
||||
service.completeAutoMatchWork(work, "fingerprint", 0, 1, nil)
|
||||
_, claimed, err = service.claimAutoMatchWork(context.Background(), run, 99, "fingerprint")
|
||||
if err != nil || claimed {
|
||||
t.Fatalf("unchanged unmatched claimed=%v err=%v", claimed, err)
|
||||
}
|
||||
_, claimed, err = service.claimAutoMatchWork(context.Background(), run, 99, "changed")
|
||||
if err != nil || !claimed {
|
||||
t.Fatalf("changed input claimed=%v err=%v", claimed, err)
|
||||
}
|
||||
}
|
||||
@@ -5,11 +5,13 @@ import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"go-admin/app/goauto/models"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
@@ -116,6 +118,20 @@ func TestAutoMatchMappingsProviderFailureDoesNotWrite(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteErrorMapsAIUnavailableToStructured503(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
recorder := httptest.NewRecorder()
|
||||
context, _ := gin.CreateTestContext(recorder)
|
||||
writeError(context, aiUnavailable("AI 匹配服务暂时不可用,请稍后重试"))
|
||||
|
||||
if recorder.Code != http.StatusServiceUnavailable {
|
||||
t.Fatalf("status = %d, body = %s", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
if !strings.Contains(recorder.Body.String(), `"code":"AI_MATCHING_UNAVAILABLE"`) {
|
||||
t.Fatalf("response is not structured: %s", recorder.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutoMatchMappingsRejectsContextDriftBeforeAtomicWrite(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
pdd := seedPDDProduct(t, db, "active")
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package shopeeproduct
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
@@ -300,6 +301,45 @@ func (handler Handler) AutoMatchMappings(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"code": 200, "data": response})
|
||||
}
|
||||
|
||||
func (handler Handler) StartAutoMatchRun(c *gin.Context) {
|
||||
var request struct {
|
||||
RequestID string `json:"requestId"`
|
||||
}
|
||||
if err := decodeJSON(c, &request); err != nil {
|
||||
writeError(c, invalidRequest("请求 JSON 无效"))
|
||||
return
|
||||
}
|
||||
service, ok := handler.service(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
operator := currentUserID(c)
|
||||
run, created, err := service.StartAutoMatchRun(c.Request.Context(), "manual", request.RequestID, &operator, defaultAutoMatchBatchLimit)
|
||||
if err != nil {
|
||||
writeError(c, err)
|
||||
return
|
||||
}
|
||||
if created {
|
||||
go func(runID uint64, db *gorm.DB) {
|
||||
_ = NewService(db).ProcessAutoMatchRun(context.Background(), runID)
|
||||
}(run.ID, service.DB)
|
||||
}
|
||||
c.JSON(http.StatusAccepted, gin.H{"code": 200, "data": gin.H{"run": run}})
|
||||
}
|
||||
|
||||
func (handler Handler) LatestAutoMatchRun(c *gin.Context) {
|
||||
service, ok := handler.service(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
run, err := service.LatestAutoMatchRun(c.Request.Context())
|
||||
if err != nil {
|
||||
writeError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"code": 200, "data": gin.H{"run": run}})
|
||||
}
|
||||
|
||||
func (handler Handler) BatchDelete(c *gin.Context) {
|
||||
var request BatchDeleteRequest
|
||||
if err := decodeJSON(c, &request); err != nil {
|
||||
|
||||
@@ -10,6 +10,9 @@ import (
|
||||
func InitRouter(engine *gin.Engine, auth *jwt.GinJWTMiddleware) {
|
||||
handler := Handler{}
|
||||
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)
|
||||
adminOnlyRuns.GET("/latest", handler.LatestAutoMatchRun)
|
||||
admin.GET("", handler.List)
|
||||
admin.POST("", handler.Create)
|
||||
admin.POST("/batch-delete", handler.BatchDelete)
|
||||
|
||||
@@ -0,0 +1,489 @@
|
||||
package sybimport
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go-admin/app/goauto/aimatching"
|
||||
"go-admin/app/goauto/models"
|
||||
"go-admin/app/goauto/shopeeproduct"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const (
|
||||
SpecAIParseInvokeTarget = "GoAutoSYBSpecAIParse"
|
||||
defaultAIParseBatchLimit = 20
|
||||
aiParseLeaseDuration = 30 * time.Minute
|
||||
aiParseRetryDelay = time.Hour
|
||||
maxAIParseAttempts = 3
|
||||
)
|
||||
|
||||
var (
|
||||
errAIParseWorkNotClaimed = errors.New("syb spec ai parse work not claimed")
|
||||
errAIParseInputChanged = errors.New("syb spec ai parse input changed")
|
||||
)
|
||||
|
||||
type aiParseInput struct {
|
||||
ProductSpec string
|
||||
Colors []string
|
||||
Sizes []string
|
||||
Fingerprint string
|
||||
}
|
||||
|
||||
func StartSpecAIParseRun(ctx context.Context, db *gorm.DB, requestID string, batchLimit int) (models.SYBSpecAIParseRun, bool, error) {
|
||||
if _, err := uuid.Parse(strings.TrimSpace(requestID)); err != nil {
|
||||
return models.SYBSpecAIParseRun{}, false, fmt.Errorf("requestId 必须是 UUID")
|
||||
}
|
||||
if batchLimit <= 0 {
|
||||
batchLimit = defaultAIParseBatchLimit
|
||||
}
|
||||
if batchLimit > 100 {
|
||||
return models.SYBSpecAIParseRun{}, false, fmt.Errorf("batchLimit 不能超过 100")
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
lease := now.Add(aiParseLeaseDuration)
|
||||
one := uint8(1)
|
||||
owner := uuid.NewString()
|
||||
var run models.SYBSpecAIParseRun
|
||||
created := false
|
||||
err := db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Model(&models.SYBSpecAIParseRun{}).
|
||||
Where("status = ? AND active_slot = ? AND lease_expires_at < ?", "running", 1, now).
|
||||
Updates(map[string]any{"status": "failed", "active_slot": nil, "lease_owner": "", "lease_expires_at": nil, "error_summary": "上次运行租约过期,已安全释放", "finished_at": now}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Where("request_id = ?", requestID).First(&run).Error; err == nil {
|
||||
return nil
|
||||
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return err
|
||||
}
|
||||
if err := tx.Where("status = ? AND active_slot = ?", "running", 1).First(&run).Error; err == nil {
|
||||
return nil
|
||||
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return err
|
||||
}
|
||||
run = models.SYBSpecAIParseRun{
|
||||
RequestID: requestID, Trigger: "scheduled", Status: "running", ActiveSlot: &one,
|
||||
LeaseOwner: owner, LeaseExpiresAt: &lease, BatchLimit: batchLimit, StartedAt: now,
|
||||
}
|
||||
if err := tx.Create(&run).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
created = true
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
if findErr := db.WithContext(ctx).Where("status = ? AND active_slot = ?", "running", 1).First(&run).Error; findErr == nil {
|
||||
return run, false, nil
|
||||
}
|
||||
return models.SYBSpecAIParseRun{}, false, err
|
||||
}
|
||||
return run, created, nil
|
||||
}
|
||||
|
||||
func ProcessSpecAIParseRun(ctx context.Context, db *gorm.DB, runID uint64) error {
|
||||
var run models.SYBSpecAIParseRun
|
||||
if err := db.WithContext(ctx).First(&run, runID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if run.Status != "running" || run.ActiveSlot == nil || *run.ActiveSlot != 1 {
|
||||
return nil
|
||||
}
|
||||
limit := run.BatchLimit
|
||||
if limit <= 0 || limit > 100 {
|
||||
limit = defaultAIParseBatchLimit
|
||||
}
|
||||
queryLimit := limit * 25
|
||||
if queryLimit < 100 {
|
||||
queryLimit = 100
|
||||
}
|
||||
if queryLimit > 1000 {
|
||||
queryLimit = 1000
|
||||
}
|
||||
var candidates []models.SYBProduct
|
||||
if err := db.WithContext(ctx).
|
||||
Where("manually_confirmed = ?", false).
|
||||
Where("parse_status IN ?", []string{models.SYBParseStatusUncertain, models.SYBParseStatusFailed}).
|
||||
Order("updated_at ASC, id ASC").Limit(queryLimit).Find(&candidates).Error; err != nil {
|
||||
finishSpecAIParseRun(db, run, "failed", 0, 0, 0, 0, 0, 1, "扫描异常规格失败")
|
||||
return err
|
||||
}
|
||||
|
||||
eligible, processed, confirmed, unmatched, failed := 0, 0, 0, 0, 0
|
||||
firstError := ""
|
||||
for _, candidate := range candidates {
|
||||
if processed >= limit {
|
||||
break
|
||||
}
|
||||
if candidate.AIConfirmed {
|
||||
current, currentErr := aiConfirmationTargetsCurrent(ctx, db, candidate)
|
||||
if currentErr != nil {
|
||||
failed++
|
||||
continue
|
||||
}
|
||||
if current {
|
||||
continue
|
||||
}
|
||||
if err := db.WithContext(ctx).Model(&models.SYBProduct{}).
|
||||
Where("id = ? AND manually_confirmed = ?", candidate.ID, false).
|
||||
Updates(map[string]any{"ai_confirmed": false, "ai_confidence": nil, "ai_reason": "", "ai_confirmed_at": nil, "ai_input_fingerprint": ""}).Error; err != nil {
|
||||
failed++
|
||||
continue
|
||||
}
|
||||
candidate.AIConfirmed = false
|
||||
}
|
||||
outcome, err := Reparse(ctx, db, candidate.ID, false)
|
||||
if err != nil {
|
||||
failed++
|
||||
if firstError == "" {
|
||||
firstError = "确定性重新解析失败"
|
||||
}
|
||||
continue
|
||||
}
|
||||
if outcome.NewStatus == models.SYBParseStatusSuccess {
|
||||
processed++
|
||||
confirmed++
|
||||
continue
|
||||
}
|
||||
if err := db.WithContext(ctx).First(&candidate, candidate.ID).Error; err != nil {
|
||||
failed++
|
||||
continue
|
||||
}
|
||||
input, ok, err := buildAIParseInput(ctx, db, candidate)
|
||||
if err != nil {
|
||||
failed++
|
||||
if firstError == "" {
|
||||
firstError = "读取 AI 解析上下文失败"
|
||||
}
|
||||
continue
|
||||
}
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
eligible++
|
||||
work, claimed, err := claimSpecAIParseWork(ctx, db, run, candidate.ID, input.Fingerprint)
|
||||
if err != nil {
|
||||
failed++
|
||||
continue
|
||||
}
|
||||
if !claimed {
|
||||
continue
|
||||
}
|
||||
processed++
|
||||
renewSpecAIParseRun(db, run)
|
||||
matcher := aimatching.NewService(db)
|
||||
result, matchErr := matcher.ResolveSYBSpec(ctx, aimatching.SYBSpecParseRequest{
|
||||
ProductSpec: input.ProductSpec, Colors: input.Colors, Sizes: input.Sizes,
|
||||
})
|
||||
if matchErr == nil {
|
||||
settings, settingsErr := matcher.Settings(ctx)
|
||||
if settingsErr != nil {
|
||||
matchErr = settingsErr
|
||||
} else if result.Confidence == nil || *result.Confidence < settings.AutoConfirmMinConfidence || strings.TrimSpace(result.Reason) == "" {
|
||||
unmatched++
|
||||
completeSpecAIParseWork(db, work, input.Fingerprint, false, nil)
|
||||
continue
|
||||
} else {
|
||||
matchErr = applyAIParseResult(ctx, db, candidate.ID, input.Fingerprint, result)
|
||||
}
|
||||
}
|
||||
if matchErr != nil {
|
||||
if isNoAIParseMatch(matchErr) || errors.Is(matchErr, errAIParseInputChanged) {
|
||||
unmatched++
|
||||
completeSpecAIParseWork(db, work, input.Fingerprint, false, nil)
|
||||
continue
|
||||
}
|
||||
failed++
|
||||
if firstError == "" {
|
||||
firstError = safeAIParseError(matchErr)
|
||||
}
|
||||
completeSpecAIParseWork(db, work, input.Fingerprint, false, matchErr)
|
||||
continue
|
||||
}
|
||||
confirmed++
|
||||
completeSpecAIParseWork(db, work, input.Fingerprint, true, nil)
|
||||
}
|
||||
status := "completed"
|
||||
if failed > 0 {
|
||||
status = "completed_partial"
|
||||
}
|
||||
return finishSpecAIParseRun(db, run, status, len(candidates), eligible, processed, confirmed, unmatched, failed, firstError)
|
||||
}
|
||||
|
||||
func buildAIParseInput(ctx context.Context, db *gorm.DB, record models.SYBProduct) (aiParseInput, bool, error) {
|
||||
if record.ManuallyConfirmed || (record.ParseStatus != models.SYBParseStatusUncertain && record.ParseStatus != models.SYBParseStatusFailed) || record.ShopeeProductID == nil {
|
||||
return aiParseInput{}, false, nil
|
||||
}
|
||||
var raw rawDetailSpec
|
||||
if err := json.Unmarshal([]byte(record.RawJSON), &raw); err != nil {
|
||||
return aiParseInput{}, false, err
|
||||
}
|
||||
raw.ProductSpec = strings.TrimSpace(raw.ProductSpec)
|
||||
if raw.ProductSpec == "" {
|
||||
return aiParseInput{}, false, nil
|
||||
}
|
||||
var product models.ShopeeProduct
|
||||
if err := db.WithContext(ctx).First(&product, *record.ShopeeProductID).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return aiParseInput{}, false, nil
|
||||
}
|
||||
return aiParseInput{}, false, err
|
||||
}
|
||||
specs, err := shopeeproduct.Unmarshal(product.SpecsJSON)
|
||||
if err != nil {
|
||||
return aiParseInput{}, false, err
|
||||
}
|
||||
colors, sizes, ambiguous := closedShopeeCandidates(specs)
|
||||
if ambiguous || (len(colors) == 0 && len(sizes) == 0) {
|
||||
return aiParseInput{}, false, nil
|
||||
}
|
||||
var setting models.AIMatchingSetting
|
||||
if err := db.WithContext(ctx).First(&setting, 1).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return aiParseInput{}, false, nil
|
||||
}
|
||||
return aiParseInput{}, false, err
|
||||
}
|
||||
if !setting.Enabled || strings.TrimSpace(setting.APIKey) == "" {
|
||||
return aiParseInput{}, false, nil
|
||||
}
|
||||
fingerprintPayload := struct {
|
||||
ProductSpec string
|
||||
ShopeeProductID uint64
|
||||
ShopeeSpecsJSON string
|
||||
SettingUpdatedAt string
|
||||
}{raw.ProductSpec, product.ID, product.SpecsJSON, setting.UpdatedAt.UTC().Format(time.RFC3339Nano)}
|
||||
encoded, _ := json.Marshal(fingerprintPayload)
|
||||
hash := sha256.Sum256(encoded)
|
||||
return aiParseInput{ProductSpec: raw.ProductSpec, Colors: colors, Sizes: sizes, Fingerprint: hex.EncodeToString(hash[:])}, true, nil
|
||||
}
|
||||
|
||||
func aiConfirmationTargetsCurrent(ctx context.Context, db *gorm.DB, record models.SYBProduct) (bool, error) {
|
||||
if !record.AIConfirmed || record.ShopeeProductID == nil {
|
||||
return false, nil
|
||||
}
|
||||
var product models.ShopeeProduct
|
||||
if err := db.WithContext(ctx).First(&product, *record.ShopeeProductID).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return false, nil
|
||||
}
|
||||
return false, err
|
||||
}
|
||||
specs, err := shopeeproduct.Unmarshal(product.SpecsJSON)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
colors, sizes, ambiguous := closedShopeeCandidates(specs)
|
||||
if ambiguous {
|
||||
return false, nil
|
||||
}
|
||||
return closedCandidateContains(record.TargetColor, colors) && closedCandidateContains(record.TargetSize, sizes) &&
|
||||
(strings.TrimSpace(record.TargetColor) != "" || strings.TrimSpace(record.TargetSize) != ""), nil
|
||||
}
|
||||
|
||||
func closedCandidateContains(value string, candidates []string) bool {
|
||||
value = strings.TrimSpace(value)
|
||||
if len(candidates) == 0 {
|
||||
return value == ""
|
||||
}
|
||||
for _, candidate := range candidates {
|
||||
if candidate == value {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func closedShopeeCandidates(specs []shopeeproduct.SpecDimension) (colors, sizes []string, ambiguous bool) {
|
||||
roleDimensions := map[string]int{}
|
||||
for _, dimension := range specs {
|
||||
if dimension.Role != shopeeproduct.RoleColor && dimension.Role != shopeeproduct.RoleSize {
|
||||
continue
|
||||
}
|
||||
values := make([]string, 0, len(dimension.Values))
|
||||
seen := map[string]bool{}
|
||||
for _, value := range dimension.Values {
|
||||
name := strings.TrimSpace(value.Name)
|
||||
if name != "" && !seen[name] {
|
||||
seen[name] = true
|
||||
values = append(values, name)
|
||||
}
|
||||
}
|
||||
if len(values) == 0 {
|
||||
continue
|
||||
}
|
||||
roleDimensions[dimension.Role]++
|
||||
if roleDimensions[dimension.Role] > 1 {
|
||||
return nil, nil, true
|
||||
}
|
||||
if dimension.Role == shopeeproduct.RoleColor {
|
||||
colors = values
|
||||
} else {
|
||||
sizes = values
|
||||
}
|
||||
}
|
||||
return colors, sizes, false
|
||||
}
|
||||
|
||||
func applyAIParseResult(ctx context.Context, db *gorm.DB, id uint64, fingerprint string, result aimatching.SYBSpecParseResult) error {
|
||||
now := time.Now().UTC()
|
||||
return db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
var record models.SYBProduct
|
||||
if err := tx.First(&record, id).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
input, ok, err := buildAIParseInput(ctx, tx, record)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !ok || input.Fingerprint != fingerprint {
|
||||
return errAIParseInputChanged
|
||||
}
|
||||
if result.Confidence == nil || strings.TrimSpace(result.Reason) == "" {
|
||||
return errAIParseInputChanged
|
||||
}
|
||||
write := tx.Model(&models.SYBProduct{}).Where("id = ? AND manually_confirmed = ? AND ai_confirmed = ?", id, false, false).Updates(map[string]any{
|
||||
"target_color": result.Color, "target_size": result.Size,
|
||||
"ai_confirmed": true, "ai_confidence": *result.Confidence, "ai_reason": truncateAIParseText(result.Reason),
|
||||
"ai_confirmed_at": now, "ai_input_fingerprint": fingerprint,
|
||||
})
|
||||
if write.Error != nil {
|
||||
return write.Error
|
||||
}
|
||||
if write.RowsAffected != 1 {
|
||||
return errAIParseInputChanged
|
||||
}
|
||||
return mergeParsedSpec(tx, *record.ShopeeProductID, ParseResult{Color: result.Color, Size: result.Size, Status: models.SYBParseStatusSuccess})
|
||||
})
|
||||
}
|
||||
|
||||
func claimSpecAIParseWork(ctx context.Context, db *gorm.DB, run models.SYBSpecAIParseRun, productID uint64, fingerprint string) (models.SYBSpecAIParseWorkItem, bool, error) {
|
||||
now := time.Now().UTC()
|
||||
lease := now.Add(aiParseLeaseDuration)
|
||||
var work models.SYBSpecAIParseWorkItem
|
||||
err := db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
err := tx.Where("syb_product_id = ?", productID).First(&work).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
work = models.SYBSpecAIParseWorkItem{SYBProductID: productID, RunID: &run.ID, InputFingerprint: fingerprint, Status: "running", AttemptCount: 1, LeaseOwner: run.LeaseOwner, LeaseExpiresAt: &lease}
|
||||
return tx.Create(&work).Error
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if work.InputFingerprint == fingerprint {
|
||||
if work.Status == "completed" || work.Status == "unmatched" || work.AttemptCount >= maxAIParseAttempts || (work.NextAttemptAt != nil && work.NextAttemptAt.After(now)) || (work.Status == "running" && work.LeaseExpiresAt != nil && work.LeaseExpiresAt.After(now)) {
|
||||
return errAIParseWorkNotClaimed
|
||||
}
|
||||
} else {
|
||||
work.AttemptCount = 0
|
||||
}
|
||||
updates := map[string]any{"run_id": run.ID, "input_fingerprint": fingerprint, "status": "running", "attempt_count": work.AttemptCount + 1, "next_attempt_at": nil, "lease_owner": run.LeaseOwner, "lease_expires_at": lease, "last_error_code": "", "last_error": ""}
|
||||
if err := tx.Model(&models.SYBSpecAIParseWorkItem{}).Where("id = ?", work.ID).Updates(updates).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.First(&work, work.ID).Error
|
||||
})
|
||||
if errors.Is(err, errAIParseWorkNotClaimed) {
|
||||
return work, false, nil
|
||||
}
|
||||
return work, err == nil, err
|
||||
}
|
||||
|
||||
func completeSpecAIParseWork(db *gorm.DB, work models.SYBSpecAIParseWorkItem, fingerprint string, confirmed bool, parseErr error) {
|
||||
now := time.Now().UTC()
|
||||
updates := map[string]any{"input_fingerprint": fingerprint, "lease_owner": "", "lease_expires_at": nil}
|
||||
if parseErr == nil {
|
||||
if confirmed {
|
||||
updates["status"] = "completed"
|
||||
} else {
|
||||
updates["status"] = "unmatched"
|
||||
}
|
||||
updates["next_attempt_at"], updates["last_error_code"], updates["last_error"] = nil, "", ""
|
||||
} else {
|
||||
code := aiParseErrorCode(parseErr)
|
||||
updates["status"], updates["last_error_code"], updates["last_error"] = "failed", code, safeAIParseError(parseErr)
|
||||
if code == aimatching.CodeProviderUnavailable && work.AttemptCount < maxAIParseAttempts {
|
||||
next := now.Add(aiParseRetryDelay)
|
||||
updates["next_attempt_at"] = next
|
||||
} else {
|
||||
updates["next_attempt_at"] = nil
|
||||
}
|
||||
}
|
||||
_ = db.Model(&models.SYBSpecAIParseWorkItem{}).Where("id = ?", work.ID).Updates(updates).Error
|
||||
}
|
||||
|
||||
func renewSpecAIParseRun(db *gorm.DB, run models.SYBSpecAIParseRun) {
|
||||
lease := time.Now().UTC().Add(aiParseLeaseDuration)
|
||||
_ = db.Model(&models.SYBSpecAIParseRun{}).Where("id = ? AND status = ? AND lease_owner = ?", run.ID, "running", run.LeaseOwner).Update("lease_expires_at", lease).Error
|
||||
}
|
||||
|
||||
func finishSpecAIParseRun(db *gorm.DB, run models.SYBSpecAIParseRun, status string, scanned, eligible, processed, confirmed, unmatched, failed int, summary string) error {
|
||||
now := time.Now().UTC()
|
||||
return db.Model(&models.SYBSpecAIParseRun{}).Where("id = ? AND status = ? AND lease_owner = ?", run.ID, "running", run.LeaseOwner).Updates(map[string]any{
|
||||
"status": status, "active_slot": nil, "lease_owner": "", "lease_expires_at": nil,
|
||||
"scanned_count": scanned, "eligible_count": eligible, "processed_count": processed,
|
||||
"confirmed_count": confirmed, "unmatched_count": unmatched, "failed_count": failed,
|
||||
"error_summary": truncateAIParseText(summary), "finished_at": now,
|
||||
}).Error
|
||||
}
|
||||
|
||||
func isNoAIParseMatch(err error) bool { return aiParseErrorCode(err) == aimatching.CodeNoMatch }
|
||||
|
||||
func aiParseErrorCode(err error) string {
|
||||
var target *aimatching.Error
|
||||
if errors.As(err, &target) {
|
||||
return target.Code
|
||||
}
|
||||
return CodeInternal
|
||||
}
|
||||
|
||||
func safeAIParseError(err error) string {
|
||||
var target *aimatching.Error
|
||||
if errors.As(err, &target) {
|
||||
return truncateAIParseText(target.Message)
|
||||
}
|
||||
return "服务端处理失败"
|
||||
}
|
||||
|
||||
func truncateAIParseText(value string) string {
|
||||
runes := []rune(strings.TrimSpace(value))
|
||||
if len(runes) > 500 {
|
||||
runes = runes[:500]
|
||||
}
|
||||
return string(runes)
|
||||
}
|
||||
|
||||
type scheduledSpecAIParseArgs struct {
|
||||
BatchLimit int `json:"batchLimit"`
|
||||
}
|
||||
|
||||
type ScheduledSpecAIParseJob struct{}
|
||||
|
||||
func (ScheduledSpecAIParseJob) Exec(_ interface{}) error {
|
||||
return errors.New("SYB 规格 AI 解析定时任务缺少数据库连接")
|
||||
}
|
||||
|
||||
func (ScheduledSpecAIParseJob) ExecWithDB(db *gorm.DB, arg interface{}) error {
|
||||
args := scheduledSpecAIParseArgs{BatchLimit: defaultAIParseBatchLimit}
|
||||
if raw, ok := arg.(string); ok && strings.TrimSpace(raw) != "" {
|
||||
if err := json.Unmarshal([]byte(raw), &args); err != nil {
|
||||
return fmt.Errorf("SYB 规格 AI 解析参数不是合法 JSON: %w", err)
|
||||
}
|
||||
}
|
||||
if args.BatchLimit < 1 || args.BatchLimit > 100 {
|
||||
return errors.New("SYB 规格 AI 解析 batchLimit 必须在 1 到 100 之间")
|
||||
}
|
||||
run, created, err := StartSpecAIParseRun(context.Background(), db, uuid.NewString(), args.BatchLimit)
|
||||
if err != nil || !created {
|
||||
return err
|
||||
}
|
||||
return ProcessSpecAIParseRun(context.Background(), db, run.ID)
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
package sybimport_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go-admin/app/goauto/models"
|
||||
"go-admin/app/goauto/shopeeproduct"
|
||||
"go-admin/app/goauto/sybimport"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func seedAIParseCandidate(t *testing.T, db *gorm.DB, serverURL string, detailID uint64, productSpec string) models.SYBProduct {
|
||||
t.Helper()
|
||||
detail := realDetailA()
|
||||
detail.ID = detailID
|
||||
detail.ProductSpec = productSpec
|
||||
detail.Raw = []byte(fmt.Sprintf(`{"id":%d,"productSpec":%q}`, detail.ID, productSpec))
|
||||
applied, err := sybimport.ApplyDetail(context.Background(), db, realOrder(), detail)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
specs, err := shopeeproduct.Marshal([]shopeeproduct.SpecDimension{
|
||||
{Name: "颜色", Role: shopeeproduct.RoleColor, Values: []shopeeproduct.SpecValue{{Name: "黑色", Source: shopeeproduct.ValueSourceImport}, {Name: "白色", Source: shopeeproduct.ValueSourceImport}}},
|
||||
{Name: "尺码", Role: shopeeproduct.RoleSize, Values: []shopeeproduct.SpecValue{{Name: "L", Source: shopeeproduct.ValueSourceImport}, {Name: "XL", Source: shopeeproduct.ValueSourceImport}}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Model(&models.ShopeeProduct{}).Where("id = ?", *applied.SYBProduct.ShopeeProductID).Update("specs_json", specs).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
setting := models.AIMatchingSetting{ID: 1, Enabled: true, Provider: "openai_compatible", BaseURL: serverURL, Model: "test-model", APIKey: "test-key", TimeoutSeconds: 5, AutoConfirmMinConfidence: 0.9}
|
||||
if err := db.Save(&setting).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return applied.SYBProduct
|
||||
}
|
||||
|
||||
func TestScheduledAIParseConfirmsClosedCandidatesAndDoesNotRepeat(t *testing.T) {
|
||||
var calls atomic.Int32
|
||||
provider := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
|
||||
calls.Add(1)
|
||||
response.Header().Set("Content-Type", "application/json")
|
||||
_, _ = response.Write([]byte(`{"choices":[{"message":{"content":"{\"color\":\"黑色\",\"size\":\"XL\",\"reason\":\"原文对应唯一候选\",\"confidence\":0.95}"}}]}`))
|
||||
}))
|
||||
defer provider.Close()
|
||||
db := openTestDB(t)
|
||||
record := seedAIParseCandidate(t, db, provider.URL, 19801, "黑色 XL")
|
||||
rawBefore := record.RawJSON
|
||||
run, created, err := sybimport.StartSpecAIParseRun(context.Background(), db, uuid.NewString(), 20)
|
||||
if err != nil || !created {
|
||||
t.Fatalf("start run: created=%v err=%v", created, err)
|
||||
}
|
||||
if err := sybimport.ProcessSpecAIParseRun(context.Background(), db, run.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.First(&record, record.ID).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !record.AIConfirmed || record.ManuallyConfirmed || record.ParseStatus != models.SYBParseStatusUncertain || record.TargetColor != "黑色" || record.TargetSize != "XL" || record.AIConfidence == nil || *record.AIConfidence != 0.95 || record.AIReason == "" || record.AIInputFingerprint == "" {
|
||||
t.Fatalf("unexpected confirmed record: %+v", record)
|
||||
}
|
||||
if record.RawJSON != rawBefore {
|
||||
t.Fatal("AI confirmation must not rewrite RawJSON")
|
||||
}
|
||||
var finished models.SYBSpecAIParseRun
|
||||
if err := db.First(&finished, run.ID).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if finished.Status != "completed" || finished.ConfirmedCount != 1 || finished.ProcessedCount != 1 {
|
||||
t.Fatalf("unexpected run: %+v", finished)
|
||||
}
|
||||
second, created, err := sybimport.StartSpecAIParseRun(context.Background(), db, uuid.NewString(), 20)
|
||||
if err != nil || !created {
|
||||
t.Fatalf("second start: created=%v err=%v", created, err)
|
||||
}
|
||||
if err := sybimport.ProcessSpecAIParseRun(context.Background(), db, second.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if calls.Load() != 1 {
|
||||
t.Fatalf("unchanged confirmed input called provider %d times", calls.Load())
|
||||
}
|
||||
}
|
||||
|
||||
func TestScheduledAIParseLeavesLowConfidenceUnmatchedForSameFingerprint(t *testing.T) {
|
||||
var calls atomic.Int32
|
||||
provider := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
|
||||
calls.Add(1)
|
||||
_, _ = response.Write([]byte(`{"choices":[{"message":{"content":"{\"color\":\"黑色\",\"size\":\"XL\",\"reason\":\"仍有歧义\",\"confidence\":0.4}"}}]}`))
|
||||
}))
|
||||
defer provider.Close()
|
||||
db := openTestDB(t)
|
||||
record := seedAIParseCandidate(t, db, provider.URL, 19802, "黑色 XL")
|
||||
for i := 0; i < 2; i++ {
|
||||
run, created, err := sybimport.StartSpecAIParseRun(context.Background(), db, uuid.NewString(), 20)
|
||||
if err != nil || !created {
|
||||
t.Fatalf("start %d: created=%v err=%v", i, created, err)
|
||||
}
|
||||
if err := sybimport.ProcessSpecAIParseRun(context.Background(), db, run.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := db.First(&record, record.ID).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if record.AIConfirmed || calls.Load() != 1 {
|
||||
t.Fatalf("low confidence must remain unconfirmed and not repeat: confirmed=%v calls=%d", record.AIConfirmed, calls.Load())
|
||||
}
|
||||
var work models.SYBSpecAIParseWorkItem
|
||||
if err := db.Where("syb_product_id = ?", record.ID).First(&work).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if work.Status != "unmatched" {
|
||||
t.Fatalf("work status=%s", work.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScheduledAIParseSkipsEmptySourceAndManualConfirmation(t *testing.T) {
|
||||
var calls atomic.Int32
|
||||
provider := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
|
||||
calls.Add(1)
|
||||
response.WriteHeader(http.StatusInternalServerError)
|
||||
}))
|
||||
defer provider.Close()
|
||||
db := openTestDB(t)
|
||||
empty := seedAIParseCandidate(t, db, provider.URL, 19803, "")
|
||||
manual := seedAIParseCandidate(t, db, provider.URL, 19804, "黑色 XL")
|
||||
if _, err := sybimport.ManualCorrect(context.Background(), db, manual.ID, "黑色", "XL"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
run, created, err := sybimport.StartSpecAIParseRun(context.Background(), db, uuid.NewString(), 20)
|
||||
if err != nil || !created {
|
||||
t.Fatalf("start: created=%v err=%v", created, err)
|
||||
}
|
||||
if err := sybimport.ProcessSpecAIParseRun(context.Background(), db, run.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if calls.Load() != 0 {
|
||||
t.Fatalf("ineligible rows called provider %d times", calls.Load())
|
||||
}
|
||||
var workCount int64
|
||||
if err := db.Model(&models.SYBSpecAIParseWorkItem{}).Where("syb_product_id IN ?", []uint64{empty.ID, manual.ID}).Count(&workCount).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if workCount != 0 {
|
||||
t.Fatalf("ineligible rows created %d work items", workCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSpecAIParseRunHasSingleGlobalActiveSlot(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
first, created, err := sybimport.StartSpecAIParseRun(context.Background(), db, uuid.NewString(), 20)
|
||||
if err != nil || !created {
|
||||
t.Fatalf("first: created=%v err=%v", created, err)
|
||||
}
|
||||
second, created, err := sybimport.StartSpecAIParseRun(context.Background(), db, uuid.NewString(), 20)
|
||||
if err != nil || created || second.ID != first.ID {
|
||||
t.Fatalf("second must reuse active run: first=%d second=%d created=%v err=%v", first.ID, second.ID, created, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScheduledAIParseRetriesProviderFailureAtMostThreeTimes(t *testing.T) {
|
||||
var calls atomic.Int32
|
||||
provider := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
|
||||
calls.Add(1)
|
||||
response.WriteHeader(http.StatusBadGateway)
|
||||
}))
|
||||
defer provider.Close()
|
||||
db := openTestDB(t)
|
||||
record := seedAIParseCandidate(t, db, provider.URL, 19805, "黑色 XL")
|
||||
for attempt := 1; attempt <= 4; attempt++ {
|
||||
run, created, err := sybimport.StartSpecAIParseRun(context.Background(), db, uuid.NewString(), 20)
|
||||
if err != nil || !created {
|
||||
t.Fatalf("start %d: created=%v err=%v", attempt, created, err)
|
||||
}
|
||||
if err := sybimport.ProcessSpecAIParseRun(context.Background(), db, run.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if attempt < 3 {
|
||||
past := time.Now().UTC().Add(-time.Minute)
|
||||
if err := db.Model(&models.SYBSpecAIParseWorkItem{}).Where("syb_product_id = ?", record.ID).Update("next_attempt_at", past).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
if calls.Load() != 3 {
|
||||
t.Fatalf("provider calls=%d, want 3", calls.Load())
|
||||
}
|
||||
var work models.SYBSpecAIParseWorkItem
|
||||
if err := db.Where("syb_product_id = ?", record.ID).First(&work).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if work.AttemptCount != 3 || work.NextAttemptAt != nil {
|
||||
t.Fatalf("retry state=%+v", work)
|
||||
}
|
||||
}
|
||||
@@ -130,13 +130,37 @@ func ApplyDetail(ctx context.Context, db *gorm.DB, order OrderInput, detail Deta
|
||||
result.Outcome = OutcomeCreated
|
||||
case err == nil:
|
||||
record.ID = existing.ID
|
||||
if err := tx.Model(&models.SYBProduct{}).Where("id = ?", existing.ID).Updates(map[string]any{
|
||||
// Human-confirmed target values are authoritative and survive every
|
||||
// source re-import. ParseStatus/ParseNote below still record what the
|
||||
// current deterministic parser observed for audit.
|
||||
if existing.ManuallyConfirmed {
|
||||
record.TargetColor, record.TargetSize = existing.TargetColor, existing.TargetSize
|
||||
record.ManuallyConfirmed = true
|
||||
}
|
||||
updates := map[string]any{
|
||||
"stock_id": record.StockID, "shop_name": record.ShopName, "shopee_item_id": record.ShopeeItemID,
|
||||
"shopee_product_id": record.ShopeeProductID, "product_title": record.ProductTitle,
|
||||
"target_color": record.TargetColor, "target_size": record.TargetSize,
|
||||
"quantity": record.Quantity, "unit_price_cent": record.UnitPriceCent, "image_url": record.ImageURL,
|
||||
"parse_status": record.ParseStatus, "parse_note": record.ParseNote, "raw_json": record.RawJSON,
|
||||
}).Error; err != nil {
|
||||
}
|
||||
// An identical re-import keeps a valid AI decision. Changed source,
|
||||
// link, or a newly deterministic parse invalidates it atomically.
|
||||
preserveAI := existing.AIConfirmed && !existing.ManuallyConfirmed && parsed.Status != models.SYBParseStatusSuccess &&
|
||||
existing.RawJSON == record.RawJSON && sameOptionalID(existing.ShopeeProductID, record.ShopeeProductID)
|
||||
if preserveAI {
|
||||
record.TargetColor, record.TargetSize = existing.TargetColor, existing.TargetSize
|
||||
updates["target_color"], updates["target_size"] = existing.TargetColor, existing.TargetSize
|
||||
record.AIConfirmed, record.AIConfidence, record.AIReason = true, existing.AIConfidence, existing.AIReason
|
||||
record.AIConfirmedAt, record.AIInputFingerprint = existing.AIConfirmedAt, existing.AIInputFingerprint
|
||||
} else {
|
||||
updates["ai_confirmed"], updates["ai_confidence"], updates["ai_reason"] = false, nil, ""
|
||||
updates["ai_confirmed_at"], updates["ai_input_fingerprint"] = nil, ""
|
||||
}
|
||||
if existing.ManuallyConfirmed {
|
||||
updates["target_color"], updates["target_size"] = existing.TargetColor, existing.TargetSize
|
||||
}
|
||||
if err := tx.Model(&models.SYBProduct{}).Where("id = ?", existing.ID).Updates(updates).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
result.Outcome = OutcomeUpdated
|
||||
@@ -161,6 +185,13 @@ func ApplyDetail(ctx context.Context, db *gorm.DB, order OrderInput, detail Deta
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func sameOptionalID(left, right *uint64) bool {
|
||||
if left == nil || right == nil {
|
||||
return left == nil && right == nil
|
||||
}
|
||||
return *left == *right
|
||||
}
|
||||
|
||||
// findOrCreateShopeeProduct implements #40's revival rule: a live match wins,
|
||||
// a soft-deleted match is revived (keeping its prior mapping), and only when
|
||||
// neither exists does the import create a minimal archive. On an existing
|
||||
|
||||
@@ -3,6 +3,8 @@ package sybimport
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"go-admin/common/clientprincipal"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -113,8 +115,10 @@ func isImportAlreadyRunning(err error) bool {
|
||||
// `[必须]` This remains the authenticated manual entry point. Both it and the
|
||||
// scheduler delegate to StartImport, and every downstream SYB call is read-only.
|
||||
func (handler Handler) Import(c *gin.Context) {
|
||||
if claimString(jwt.ExtractClaims(c)["rolekey"]) != "admin" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"code": "FORBIDDEN", "message": "只有管理员可以开始导入"})
|
||||
role := claimString(jwt.ExtractClaims(c)["rolekey"])
|
||||
client, clientOK := clientprincipal.Get(c)
|
||||
if role != "admin" && role != "purchaser" && !clientOK {
|
||||
c.JSON(http.StatusForbidden, gin.H{"code": "FORBIDDEN", "message": "只有管理员或采购员可以开始同步"})
|
||||
return
|
||||
}
|
||||
var request ImportRequest
|
||||
@@ -127,9 +131,13 @@ func (handler Handler) Import(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
claims := jwt.ExtractClaims(c)
|
||||
result, err := StartImport(c.Request.Context(), service.DB, request, ImportActor{
|
||||
actor := ImportActor{
|
||||
ID: claimUint64(claims["identity"]), Name: claimString(claims["nice"]),
|
||||
}, false)
|
||||
}
|
||||
if clientOK {
|
||||
actor = ImportActor{ID: client.AuthorizedBy, Name: fmt.Sprintf("client-key:%d", client.KeyID)}
|
||||
}
|
||||
result, err := StartImport(c.Request.Context(), service.DB, request, actor, false)
|
||||
if err != nil {
|
||||
var serviceErr *ServiceError
|
||||
if errors.As(err, &serviceErr) {
|
||||
|
||||
@@ -53,17 +53,26 @@ func TestImportRejectsNoEnabledShopBeforeConnect(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestImportRequiresAdminRole(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
engine := gin.New()
|
||||
engine.Use(func(c *gin.Context) { c.Set(jwt.JwtPayloadKey, jwt.MapClaims{"rolekey": "purchaser"}); c.Next() })
|
||||
engine.POST("/api/admin/v1/syb-products/import", Handler{}.Import)
|
||||
request := httptest.NewRequest(http.MethodPost, "/api/admin/v1/syb-products/import", bytes.NewBufferString(`{"dateFrom":"2026-08-19","dateTo":"2026-08-19"}`))
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
recorder := httptest.NewRecorder()
|
||||
engine.ServeHTTP(recorder, request)
|
||||
if recorder.Code != http.StatusForbidden || !strings.Contains(recorder.Body.String(), "只有管理员") {
|
||||
t.Fatalf("采购员应被拒绝: status=%d body=%s", recorder.Code, recorder.Body.String())
|
||||
func TestImportRoleBoundary(t *testing.T) {
|
||||
for _, role := range []string{"admin", "purchaser", "viewer", "", "custom-role"} {
|
||||
t.Run(role, func(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
engine := gin.New()
|
||||
engine.Use(func(c *gin.Context) { c.Set(jwt.JwtPayloadKey, jwt.MapClaims{"rolekey": role}); c.Next() })
|
||||
engine.POST("/api/admin/v1/syb-products/import", Handler{}.Import)
|
||||
// Authorized roles reach JSON validation; no DB or external SYB call is made.
|
||||
request := httptest.NewRequest(http.MethodPost, "/api/admin/v1/syb-products/import", bytes.NewBufferString(`{`))
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
recorder := httptest.NewRecorder()
|
||||
engine.ServeHTTP(recorder, request)
|
||||
want := http.StatusForbidden
|
||||
if role == "admin" || role == "purchaser" {
|
||||
want = http.StatusUnprocessableEntity
|
||||
}
|
||||
if recorder.Code != want {
|
||||
t.Fatalf("role=%q status=%d want=%d body=%s", role, recorder.Code, want, recorder.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
package sybimport
|
||||
|
||||
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)
|
||||
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 {
|
||||
return []int64{1001, 1002}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
db := newSyncTestDB(t)
|
||||
report, err := Sync(context.Background(), db, newSyncClient(t, f), SyncConfig{PageSize: 2, MaxMatches: 100}, "2026-08-28", "2026-08-29")
|
||||
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"} {
|
||||
if !strings.Contains(err.Error(), token) {
|
||||
t.Fatalf("missing %s in %v", token, err)
|
||||
}
|
||||
}
|
||||
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 {
|
||||
t.Fatalf("unexpected retry/import boundary: pages=%d details=%d orders=%d", len(f.listCalls), f.detailCalls, report.OrderCount)
|
||||
}
|
||||
var count int64
|
||||
if e := db.Table("syb_product").Count(&count).Error; e != nil || count != 2 {
|
||||
t.Fatalf("yesterday not preserved: count=%d err=%v", count, e)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPaginationHardErrorsDoNotUseOverlapRecovery(t *testing.T) {
|
||||
freezePaginationToday(t)
|
||||
for _, tc := range []struct {
|
||||
name, date, message string
|
||||
page int
|
||||
ids []int64
|
||||
calls int
|
||||
}{
|
||||
{"same page", "2026-08-29", "同页重复", 0, []int64{1000, 1000}, 1},
|
||||
{"mixed overlap and same page", "2026-08-29", "同页重复", 2, []int64{1001, 1001}, 2},
|
||||
{"overlap and invalid ID", "2026-08-29", "非法 id", 2, []int64{1001, 0}, 2},
|
||||
{"historical overlap", "2026-08-28", "跨页重复", 2, []int64{1001, 1002}, 2},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
f := &fakeSYB{perDay: map[string]int{tc.date: 4}}
|
||||
f.pageIDs = func(_ string, start, _ int) []int64 {
|
||||
if start == tc.page {
|
||||
return tc.ids
|
||||
}
|
||||
return nil
|
||||
}
|
||||
rows, err := loadDailyListWithRecovery(context.Background(), newSyncClient(t, f), tc.date, 2, 4, 100)
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -28,7 +28,7 @@ type rawDetailSpec struct {
|
||||
// result page's per-line feedback.
|
||||
type ReparseOutcome struct {
|
||||
SYBProductID uint64 `json:"sybProductId"`
|
||||
Outcome string `json:"outcome"` // reparsed | skipped_manual | unchanged
|
||||
Outcome string `json:"outcome"` // reparsed | skipped_manual | skipped_ai | unchanged
|
||||
OldStatus string `json:"oldStatus"`
|
||||
NewStatus string `json:"newStatus"`
|
||||
}
|
||||
@@ -36,6 +36,7 @@ type ReparseOutcome struct {
|
||||
const (
|
||||
ReparseOutcomeReparsed = "reparsed"
|
||||
ReparseOutcomeSkippedManual = "skipped_manual"
|
||||
ReparseOutcomeSkippedAI = "skipped_ai"
|
||||
ReparseOutcomeUnchanged = "unchanged"
|
||||
)
|
||||
|
||||
@@ -65,6 +66,11 @@ func Reparse(ctx context.Context, db *gorm.DB, sybProductID uint64, force bool)
|
||||
outcome.NewStatus = record.ParseStatus
|
||||
return nil
|
||||
}
|
||||
if record.AIConfirmed && !force {
|
||||
outcome.Outcome = ReparseOutcomeSkippedAI
|
||||
outcome.NewStatus = record.ParseStatus
|
||||
return nil
|
||||
}
|
||||
|
||||
var raw rawDetailSpec
|
||||
if err := json.Unmarshal([]byte(record.RawJSON), &raw); err != nil {
|
||||
@@ -76,8 +82,11 @@ func Reparse(ctx context.Context, db *gorm.DB, sybProductID uint64, force bool)
|
||||
if parsed.Color == record.TargetColor && parsed.Size == record.TargetSize && parsed.Status == record.ParseStatus {
|
||||
outcome.Outcome = ReparseOutcomeUnchanged
|
||||
if force {
|
||||
record.ManuallyConfirmed = false
|
||||
if err := tx.Model(&models.SYBProduct{}).Where("id = ?", record.ID).Update("manually_confirmed", false).Error; err != nil {
|
||||
record.ManuallyConfirmed, record.AIConfirmed = false, false
|
||||
if err := tx.Model(&models.SYBProduct{}).Where("id = ?", record.ID).Updates(map[string]any{
|
||||
"manually_confirmed": false, "ai_confirmed": false, "ai_confidence": nil,
|
||||
"ai_reason": "", "ai_confirmed_at": nil, "ai_input_fingerprint": "",
|
||||
}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
@@ -87,6 +96,8 @@ func Reparse(ctx context.Context, db *gorm.DB, sybProductID uint64, force bool)
|
||||
updates := map[string]any{
|
||||
"target_color": parsed.Color, "target_size": parsed.Size,
|
||||
"parse_status": parsed.Status, "parse_note": parsed.Note, "manually_confirmed": false,
|
||||
"ai_confirmed": false, "ai_confidence": nil, "ai_reason": "",
|
||||
"ai_confirmed_at": nil, "ai_input_fingerprint": "",
|
||||
}
|
||||
if err := tx.Model(&models.SYBProduct{}).Where("id = ?", record.ID).Updates(updates).Error; err != nil {
|
||||
return err
|
||||
@@ -140,8 +151,12 @@ func ManualCorrect(ctx context.Context, db *gorm.DB, sybProductID uint64, color,
|
||||
return err
|
||||
}
|
||||
record.TargetColor, record.TargetSize, record.ManuallyConfirmed = color, size, true
|
||||
record.AIConfirmed, record.AIConfidence, record.AIReason = false, nil, ""
|
||||
record.AIConfirmedAt, record.AIInputFingerprint = nil, ""
|
||||
if err := tx.Model(&models.SYBProduct{}).Where("id = ?", sybProductID).Updates(map[string]any{
|
||||
"target_color": color, "target_size": size, "manually_confirmed": true,
|
||||
"ai_confirmed": false, "ai_confidence": nil, "ai_reason": "",
|
||||
"ai_confirmed_at": nil, "ai_input_fingerprint": "",
|
||||
}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -60,6 +60,35 @@ func TestReparseSkipsManuallyConfirmedRowByDefault(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestReparseSkipsAIConfirmationUnlessForced(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
applied, err := sybimport.ApplyDetail(context.Background(), db, realOrder(), realDetailB())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
confidence := 0.95
|
||||
if err := db.Model(&models.SYBProduct{}).Where("id = ?", applied.SYBProduct.ID).Updates(map[string]any{
|
||||
"target_color": "AI颜色", "target_size": "AI尺码", "ai_confirmed": true,
|
||||
"ai_confidence": confidence, "ai_reason": "AI 结果", "ai_input_fingerprint": strings.Repeat("c", 64),
|
||||
}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
outcome, err := sybimport.Reparse(context.Background(), db, applied.SYBProduct.ID, false)
|
||||
if err != nil || outcome.Outcome != sybimport.ReparseOutcomeSkippedAI {
|
||||
t.Fatalf("unforced outcome=%+v err=%v", outcome, err)
|
||||
}
|
||||
if _, err := sybimport.Reparse(context.Background(), db, applied.SYBProduct.ID, true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var record models.SYBProduct
|
||||
if err := db.First(&record, applied.SYBProduct.ID).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if record.AIConfirmed || record.AIConfidence != nil || record.AIReason != "" || record.AIInputFingerprint != "" {
|
||||
t.Fatalf("forced reparse retained AI state: %+v", record)
|
||||
}
|
||||
}
|
||||
|
||||
// 可勾选强制覆盖.
|
||||
func TestReparseWithForceOverridesManualCorrection(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
@@ -151,6 +180,83 @@ func TestManualCorrectMergesIntoArchiveLikeASuccessfulParse(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestManualCorrectSupersedesAIConfirmation(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
applied, err := sybimport.ApplyDetail(context.Background(), db, realOrder(), realDetailB())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
confidence := 0.96
|
||||
if err := db.Model(&models.SYBProduct{}).Where("id = ?", applied.SYBProduct.ID).Updates(map[string]any{
|
||||
"ai_confirmed": true, "ai_confidence": confidence, "ai_reason": "旧 AI 结果", "ai_input_fingerprint": strings.Repeat("a", 64),
|
||||
}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
corrected, err := sybimport.ManualCorrect(context.Background(), db, applied.SYBProduct.ID, "人工颜色", "人工尺码")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !corrected.ManuallyConfirmed || corrected.AIConfirmed || corrected.AIConfidence != nil || corrected.AIReason != "" || corrected.AIInputFingerprint != "" {
|
||||
t.Fatalf("human correction did not supersede AI state: %+v", corrected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReimportPreservesIdenticalAIInputAndInvalidatesChangedSource(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
order, detail := realOrder(), realDetailB()
|
||||
first, err := sybimport.ApplyDetail(context.Background(), db, order, detail)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
confidence := 0.95
|
||||
if err := db.Model(&models.SYBProduct{}).Where("id = ?", first.SYBProduct.ID).Updates(map[string]any{
|
||||
"target_color": "AI颜色", "target_size": "AI尺码", "ai_confirmed": true,
|
||||
"ai_confidence": confidence, "ai_reason": "已确认", "ai_input_fingerprint": strings.Repeat("b", 64),
|
||||
}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
same, err := sybimport.ApplyDetail(context.Background(), db, order, detail)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !same.SYBProduct.AIConfirmed {
|
||||
t.Fatal("identical re-import must preserve AI confirmation")
|
||||
}
|
||||
if same.SYBProduct.TargetColor != "AI颜色" || same.SYBProduct.TargetSize != "AI尺码" {
|
||||
t.Fatal("identical re-import must preserve AI-confirmed target values")
|
||||
}
|
||||
detail.ProductSpec += " 新备注"
|
||||
detail.Raw = []byte(`{"productSpec":"changed"}`)
|
||||
changed, err := sybimport.ApplyDetail(context.Background(), db, order, detail)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if changed.SYBProduct.AIConfirmed || changed.SYBProduct.AIConfidence != nil || changed.SYBProduct.AIReason != "" {
|
||||
t.Fatalf("changed source retained stale AI state: %+v", changed.SYBProduct)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReimportNeverOverwritesManualTargetValues(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
order, detail := realOrder(), realDetailB()
|
||||
first, err := sybimport.ApplyDetail(context.Background(), db, order, detail)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := sybimport.ManualCorrect(context.Background(), db, first.SYBProduct.ID, "人工颜色", "人工尺码"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
detail.ProductSpec = "来源新颜色,来源新尺码"
|
||||
detail.Raw = []byte(`{"productSpec":"来源新颜色,来源新尺码"}`)
|
||||
updated, err := sybimport.ApplyDetail(context.Background(), db, order, detail)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !updated.SYBProduct.ManuallyConfirmed || updated.SYBProduct.TargetColor != "人工颜色" || updated.SYBProduct.TargetSize != "人工尺码" {
|
||||
t.Fatalf("re-import overwrote human decision: %+v", updated.SYBProduct)
|
||||
}
|
||||
}
|
||||
|
||||
// Regression test: ReparseOutcome originally had no json tags at all, so Go's
|
||||
// default marshaling produced PascalCase keys ("SYBProductID", "OldStatus")
|
||||
// instead of the camelCase the rest of this API and the admin frontend use.
|
||||
|
||||
@@ -19,7 +19,7 @@ func InitRouter(engine *gin.Engine, auth *jwt.GinJWTMiddleware) {
|
||||
admin.POST("/reparse-batch", handler.ReparseBatch)
|
||||
admin.POST("/import", handler.Import)
|
||||
// Run history is intentionally authenticated but not Casbin-gated: #50
|
||||
// makes it read-only for every role, while Import enforces admin itself.
|
||||
// makes it readable for every role; Import also requires admin/purchaser.
|
||||
readonly := engine.Group("/api/admin/v1/syb-products").Use(auth.MiddlewareFunc())
|
||||
readonly.GET("/sync-runs", handler.ListSyncRuns)
|
||||
readonly.GET("/sync-runs/:runId", handler.SyncRunDetail)
|
||||
|
||||
@@ -358,7 +358,7 @@ func loadDailyListWithRecovery(ctx context.Context, client *sybclient.Client, da
|
||||
}
|
||||
var drift *snapshotDriftError
|
||||
if !errors.As(err, &drift) {
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("今天第 %d/%d 次拉取失败: %w", attempt, maxTodaySnapshotAttempts, err)
|
||||
}
|
||||
last = drift
|
||||
}
|
||||
@@ -370,7 +370,8 @@ func loadDailyListWithRecovery(ctx context.Context, client *sybclient.Client, da
|
||||
|
||||
func loadDailyList(ctx context.Context, client *sybclient.Client, date string, pageSize, expectedTotal int) ([]sybclient.StockRow, error) {
|
||||
rows := make([]sybclient.StockRow, 0, expectedTotal)
|
||||
seen := make(map[int64]struct{}, expectedTotal)
|
||||
type position struct{ page, row int }
|
||||
seen := make(map[int64]position, expectedTotal)
|
||||
|
||||
for start := 0; start < expectedTotal; start += pageSize {
|
||||
pageIndex := start/pageSize + 1
|
||||
@@ -386,14 +387,27 @@ 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))
|
||||
}
|
||||
for _, row := range 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))
|
||||
for index, row := range page {
|
||||
if row.ID <= 0 {
|
||||
return nil, fmt.Errorf("%s 货运单列表包含非法 id=%d", date, row.ID)
|
||||
}
|
||||
if _, duplicate := seen[row.ID]; duplicate {
|
||||
return nil, fmt.Errorf("%s 货运单列表重复返回 id=%d", date, row.ID)
|
||||
if firstRow, duplicate := pageSeen[row.ID]; duplicate {
|
||||
return nil, fmt.Errorf("%s 货运单列表同页重复 [firstPage=%d,firstRow=%d,page=%d,row=%d,start=%d,pageSize=%d,expectedTotal=%d,unique=%d]",
|
||||
date, pageIndex, firstRow, pageIndex, index+1, start, pageSize, expectedTotal, len(rows))
|
||||
}
|
||||
seen[row.ID] = struct{}{}
|
||||
pageSeen[row.ID] = index + 1
|
||||
}
|
||||
for index, row := range page {
|
||||
if first, duplicate := seen[row.ID]; duplicate {
|
||||
// No rows/valid flag: overlapping pages are never eligible for
|
||||
// the existing final-attempt degraded save, even after deduping.
|
||||
return nil, &snapshotDriftError{message: fmt.Sprintf("%s 货运单列表跨页重复 [firstPage=%d,firstRow=%d,page=%d,row=%d,start=%d,pageSize=%d,expectedTotal=%d,unique=%d]",
|
||||
date, first.page, first.row, pageIndex, index+1, start, pageSize, expectedTotal, len(rows))}
|
||||
}
|
||||
seen[row.ID] = position{page: pageIndex, row: index + 1}
|
||||
rows = append(rows, row)
|
||||
}
|
||||
if len(page) != expectedPageCount {
|
||||
|
||||
@@ -60,6 +60,9 @@ type fakeSYB struct {
|
||||
totalOverride map[int]int
|
||||
shortPageAtIndex int
|
||||
detailDropID int64
|
||||
listCalls []string
|
||||
pageIDs func(date string, start, call int) []int64
|
||||
detailCalls int
|
||||
// shopNames 按货运单序号轮换;留空表示全部用「测试店铺」。
|
||||
shopNames []string
|
||||
// detailShopName 非空时,明细响应里的 shopName 用它覆盖,
|
||||
@@ -96,6 +99,7 @@ func (f *fakeSYB) server(t *testing.T) *httptest.Server {
|
||||
start := int(body["start"].(float64))
|
||||
pageSize := int(body["length"].(float64))
|
||||
total := f.perDay[date]
|
||||
f.listCalls = append(f.listCalls, fmt.Sprintf("%s:%d", date, start))
|
||||
|
||||
rows := []map[string]any{}
|
||||
for i := start; i < total && len(rows) < pageSize; i++ {
|
||||
@@ -108,9 +112,18 @@ func (f *fakeSYB) server(t *testing.T) *httptest.Server {
|
||||
if f.shortPageAtIndex > 0 && start/pageSize+1 == f.shortPageAtIndex && len(rows) > 0 {
|
||||
rows = rows[:len(rows)-1]
|
||||
}
|
||||
if f.pageIDs != nil {
|
||||
if ids := f.pageIDs(date, start, len(f.listCalls)); ids != nil {
|
||||
rows = nil
|
||||
for _, id := range ids {
|
||||
rows = append(rows, map[string]any{"id": id, "code": "TEST", "shopName": f.shopFor(0)})
|
||||
}
|
||||
}
|
||||
}
|
||||
writeEnvelope(w, map[string]any{"list": rows, "total": len(rows)})
|
||||
})
|
||||
mux.HandleFunc("/am/stock/detail/listByStock", func(w http.ResponseWriter, r *http.Request) {
|
||||
f.detailCalls++
|
||||
var body struct {
|
||||
IDs []int64 `json:"ids"`
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package sybinnercode
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"go-admin/common/clientprincipal"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
@@ -81,13 +82,19 @@ func (h Handler) Import(c *gin.Context) {
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
result, err := service.Import(c.Request.Context(), src, file.Filename, uint64(user.GetUserId(c)), c.PostForm("requestId"))
|
||||
result, err := service.Import(c.Request.Context(), src, file.Filename, clientOperator(c), c.PostForm("requestId"))
|
||||
if err != nil {
|
||||
writeError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"code": 200, "data": result})
|
||||
}
|
||||
func clientOperator(c *gin.Context) uint64 {
|
||||
if p, ok := clientprincipal.Get(c); ok {
|
||||
return p.AuthorizedBy
|
||||
}
|
||||
return uint64(user.GetUserId(c))
|
||||
}
|
||||
func (h Handler) Delete(c *gin.Context) {
|
||||
var request DeleteRequest
|
||||
if err := decode(c, &request); err != nil {
|
||||
@@ -98,7 +105,7 @@ func (h Handler) Delete(c *gin.Context) {
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
result, err := service.Delete(c.Request.Context(), uint64(user.GetUserId(c)), request)
|
||||
result, err := service.Delete(c.Request.Context(), clientOperator(c), request)
|
||||
if err != nil {
|
||||
writeError(c, err)
|
||||
return
|
||||
@@ -150,7 +157,7 @@ func (h Handler) Apply(c *gin.Context) {
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
result, err := service.QueueApply(c.Request.Context(), uint64(user.GetUserId(c)), request)
|
||||
result, err := service.QueueApply(c.Request.Context(), clientOperator(c), request)
|
||||
if err != nil {
|
||||
writeError(c, err)
|
||||
return
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
package apis
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
jobservice "go-admin/app/jobs/service"
|
||||
)
|
||||
|
||||
func (e SysJob) ListExecutionLogs(c *gin.Context) {
|
||||
jobID, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil || jobID < 1 {
|
||||
c.JSON(http.StatusUnprocessableEntity, gin.H{"code": 400, "msg": "jobId 无效"})
|
||||
return
|
||||
}
|
||||
page, err := positiveQueryInt(c.Query("pageIndex"), 1)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnprocessableEntity, gin.H{"code": 400, "msg": "pageIndex 必须是正整数"})
|
||||
return
|
||||
}
|
||||
pageSize, err := positiveQueryInt(c.Query("pageSize"), 20)
|
||||
if err != nil || pageSize > 100 {
|
||||
c.JSON(http.StatusUnprocessableEntity, gin.H{"code": 400, "msg": "pageSize 必须是 1 到 100 的整数"})
|
||||
return
|
||||
}
|
||||
startedFrom, err := optionalTime(c.Query("startedFrom"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnprocessableEntity, gin.H{"code": 400, "msg": "startedFrom 必须是 RFC3339 时间"})
|
||||
return
|
||||
}
|
||||
startedTo, err := optionalTime(c.Query("startedTo"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnprocessableEntity, gin.H{"code": 400, "msg": "startedTo 必须是 RFC3339 时间"})
|
||||
return
|
||||
}
|
||||
|
||||
e.MakeContext(c)
|
||||
db, err := e.GetOrm()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "msg": "服务端处理失败"})
|
||||
return
|
||||
}
|
||||
response, err := jobservice.NewExecutionLogService(db).List(c.Request.Context(), jobID, jobservice.ExecutionLogListRequest{
|
||||
Page: page, PageSize: pageSize, Status: strings.TrimSpace(c.Query("status")),
|
||||
StartedFrom: startedFrom, StartedTo: startedTo,
|
||||
})
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, jobservice.ErrExecutionLogInvalidRequest):
|
||||
c.JSON(http.StatusUnprocessableEntity, gin.H{"code": 400, "msg": err.Error()})
|
||||
case errors.Is(err, jobservice.ErrExecutionLogJobNotFound):
|
||||
c.JSON(http.StatusNotFound, gin.H{"code": 404, "msg": "定时任务不存在"})
|
||||
default:
|
||||
e.GetLogger().Errorf("list scheduled job execution logs failed job_id=%d: %v", jobID, err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "msg": "服务端处理失败"})
|
||||
}
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"code": 200, "data": response})
|
||||
}
|
||||
|
||||
func positiveQueryInt(value string, fallback int) (int, error) {
|
||||
if strings.TrimSpace(value) == "" {
|
||||
return fallback, nil
|
||||
}
|
||||
parsed, err := strconv.Atoi(value)
|
||||
if err != nil || parsed < 1 {
|
||||
return 0, errors.New("invalid positive integer")
|
||||
}
|
||||
return parsed, nil
|
||||
}
|
||||
|
||||
func optionalTime(value string) (*time.Time, error) {
|
||||
if strings.TrimSpace(value) == "" {
|
||||
return nil, nil
|
||||
}
|
||||
parsed, err := time.Parse(time.RFC3339, value)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &parsed, nil
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"go-admin/app/goauto/shopeeproduct"
|
||||
"go-admin/app/goauto/sybimport"
|
||||
)
|
||||
|
||||
@@ -12,8 +13,10 @@ import (
|
||||
// 字典 key 可以配置到 自动任务 调用目标 中;
|
||||
func InitJob() {
|
||||
jobList = map[string]JobExec{
|
||||
"ExamplesOne": ExamplesOne{},
|
||||
sybimport.HourlySyncInvokeTarget: sybimport.HourlySyncJob{},
|
||||
"ExamplesOne": ExamplesOne{},
|
||||
sybimport.HourlySyncInvokeTarget: sybimport.HourlySyncJob{},
|
||||
sybimport.SpecAIParseInvokeTarget: sybimport.ScheduledSpecAIParseJob{},
|
||||
shopeeproduct.SpecAutoMatchInvokeTarget: shopeeproduct.ScheduledAutoMatchJob{},
|
||||
// ...
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
package jobs
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
log "github.com/go-admin-team/go-admin-core/logger"
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"go-admin/app/jobs/models"
|
||||
)
|
||||
|
||||
const (
|
||||
executionErrorTargetMissing = "JOB_TARGET_NOT_FOUND"
|
||||
executionErrorExecFailed = "JOB_EXECUTION_FAILED"
|
||||
executionErrorHTTPFailed = "JOB_HTTP_FAILED"
|
||||
executionErrorPanicked = "JOB_EXECUTION_PANICKED"
|
||||
executionErrorInterrupted = "JOB_INTERRUPTED"
|
||||
)
|
||||
|
||||
type executionFailure struct {
|
||||
code string
|
||||
message string
|
||||
cause error
|
||||
}
|
||||
|
||||
func (failure *executionFailure) Error() string {
|
||||
if failure.cause != nil {
|
||||
return failure.cause.Error()
|
||||
}
|
||||
return failure.message
|
||||
}
|
||||
|
||||
func newExecutionFailure(code, message string, cause error) error {
|
||||
return &executionFailure{code: code, message: message, cause: cause}
|
||||
}
|
||||
|
||||
func runWithExecutionLog(db *gorm.DB, core JobCore, execute func() error) (executionErr error) {
|
||||
startedAt := time.Now().UTC()
|
||||
record := &models.SysJobExecutionLog{
|
||||
ExecutionID: uuid.NewString(), JobID: core.JobId,
|
||||
JobNameSnapshot: core.Name, InvokeTargetSnapshot: core.InvokeTarget,
|
||||
TriggerType: models.JobTriggerScheduled, Status: models.JobExecutionRunning,
|
||||
StartedAt: startedAt,
|
||||
}
|
||||
created := false
|
||||
if db != nil {
|
||||
if err := db.WithContext(context.Background()).Create(record).Error; err != nil {
|
||||
log.Errorf("[Job] execution log create failed job_id=%d: %v", core.JobId, err)
|
||||
} else {
|
||||
created = true
|
||||
}
|
||||
}
|
||||
|
||||
defer func() {
|
||||
if recovered := recover(); recovered != nil {
|
||||
executionErr = newExecutionFailure(executionErrorPanicked, "任务执行异常中断", nil)
|
||||
if created {
|
||||
finishExecutionLog(db, core.JobId, record, startedAt, executionErr)
|
||||
}
|
||||
return
|
||||
}
|
||||
if created {
|
||||
finishExecutionLog(db, core.JobId, record, startedAt, executionErr)
|
||||
}
|
||||
}()
|
||||
executionErr = execute()
|
||||
return executionErr
|
||||
}
|
||||
|
||||
func finishExecutionLog(db *gorm.DB, jobID int, record *models.SysJobExecutionLog, startedAt time.Time, executionErr error) {
|
||||
finishedAt := time.Now().UTC()
|
||||
updates := map[string]any{
|
||||
"status": models.JobExecutionSucceeded, "finished_at": finishedAt,
|
||||
"duration_ms": finishedAt.Sub(startedAt).Milliseconds(), "error_code": "", "error_message": "",
|
||||
}
|
||||
if executionErr != nil {
|
||||
code, message := publicExecutionFailure(executionErr)
|
||||
updates["status"] = models.JobExecutionFailed
|
||||
updates["error_code"] = code
|
||||
updates["error_message"] = message
|
||||
}
|
||||
if err := db.WithContext(context.Background()).Model(&models.SysJobExecutionLog{}).
|
||||
Where("id = ? AND status = ?", record.ID, models.JobExecutionRunning).Updates(updates).Error; err != nil {
|
||||
log.Errorf("[Job] execution log finish failed job_id=%d execution_id=%s: %v", jobID, record.ExecutionID, err)
|
||||
}
|
||||
}
|
||||
|
||||
func publicExecutionFailure(err error) (string, string) {
|
||||
var failure *executionFailure
|
||||
if errors.As(err, &failure) {
|
||||
return failure.code, failure.message
|
||||
}
|
||||
return executionErrorExecFailed, "任务执行失败,请查看受控服务日志"
|
||||
}
|
||||
|
||||
// RecoverInterruptedExecutionLogs closes invocations left running by the
|
||||
// previous process. Production currently runs one scheduler per database.
|
||||
func RecoverInterruptedExecutionLogs(db *gorm.DB) error {
|
||||
if db == nil {
|
||||
return nil
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
var records []models.SysJobExecutionLog
|
||||
if err := db.WithContext(context.Background()).Where("status = ?", models.JobExecutionRunning).Find(&records).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return db.WithContext(context.Background()).Transaction(func(tx *gorm.DB) error {
|
||||
for _, record := range records {
|
||||
duration := now.Sub(record.StartedAt).Milliseconds()
|
||||
if duration < 0 {
|
||||
duration = 0
|
||||
}
|
||||
if err := tx.Model(&models.SysJobExecutionLog{}).
|
||||
Where("id = ? AND status = ?", record.ID, models.JobExecutionRunning).Updates(map[string]any{
|
||||
"status": models.JobExecutionInterrupted, "finished_at": now,
|
||||
"duration_ms": duration, "error_code": executionErrorInterrupted,
|
||||
"error_message": "服务重启前任务未完成",
|
||||
}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func missingExecutionTarget(target string) error {
|
||||
return newExecutionFailure(executionErrorTargetMissing, "任务调用目标未注册", fmt.Errorf("job target %q is not registered", target))
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
package jobs
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"go-admin/app/jobs/models"
|
||||
)
|
||||
|
||||
func jobExecutionTestDB(t *testing.T) *gorm.DB {
|
||||
t.Helper()
|
||||
db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.AutoMigrate(&models.SysJobExecutionLog{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return db
|
||||
}
|
||||
|
||||
func TestRunWithExecutionLogRecordsSuccessAndSanitizedFailure(t *testing.T) {
|
||||
db := jobExecutionTestDB(t)
|
||||
core := JobCore{JobId: 7, Name: "测试任务", InvokeTarget: "TestTarget"}
|
||||
if err := runWithExecutionLog(db, core, func() error { return nil }); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rawSecret := "token=secret-value productSpec=private"
|
||||
if err := runWithExecutionLog(db, core, func() error { return errors.New(rawSecret) }); err == nil {
|
||||
t.Fatal("failed execution must return its error")
|
||||
}
|
||||
var records []models.SysJobExecutionLog
|
||||
if err := db.Order("id asc").Find(&records).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(records) != 2 || records[0].Status != models.JobExecutionSucceeded || records[1].Status != models.JobExecutionFailed {
|
||||
t.Fatalf("unexpected records: %+v", records)
|
||||
}
|
||||
if records[1].ErrorCode != executionErrorExecFailed || records[1].ErrorMessage == rawSecret || records[1].ErrorMessage == "" {
|
||||
t.Fatalf("failure was not safely summarized: %+v", records[1])
|
||||
}
|
||||
if records[0].FinishedAt == nil || records[1].FinishedAt == nil || records[0].ExecutionID == records[1].ExecutionID {
|
||||
t.Fatal("execution lifecycle or unique IDs were not recorded")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecoverInterruptedExecutionLogs(t *testing.T) {
|
||||
db := jobExecutionTestDB(t)
|
||||
started := time.Now().UTC().Add(-2 * time.Second)
|
||||
record := models.SysJobExecutionLog{
|
||||
ExecutionID: "00000000-0000-4000-8000-000000000010", JobID: 9,
|
||||
JobNameSnapshot: "中断任务", InvokeTargetSnapshot: "Interrupted",
|
||||
TriggerType: models.JobTriggerScheduled, Status: models.JobExecutionRunning, StartedAt: started,
|
||||
}
|
||||
if err := db.Create(&record).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := RecoverInterruptedExecutionLogs(db); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.First(&record, record.ID).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if record.Status != models.JobExecutionInterrupted || record.FinishedAt == nil || record.ErrorCode != executionErrorInterrupted || record.DurationMS < 1000 {
|
||||
t.Fatalf("record was not safely interrupted: %+v", record)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMissingExecutionTargetHasPublicSafeMessage(t *testing.T) {
|
||||
code, message := publicExecutionFailure(missingExecutionTarget("SecretTarget"))
|
||||
if code != executionErrorTargetMissing || message != "任务调用目标未注册" {
|
||||
t.Fatalf("unexpected target failure %s %s", code, message)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecJobRecordsMissingTargetAsFailure(t *testing.T) {
|
||||
db := jobExecutionTestDB(t)
|
||||
previousJobList := jobList
|
||||
jobList = map[string]JobExec{}
|
||||
t.Cleanup(func() { jobList = previousJobList })
|
||||
|
||||
job := &ExecJob{JobCore: JobCore{JobId: 10, Name: "未注册任务", InvokeTarget: "MissingTarget"}, DB: db}
|
||||
job.Run()
|
||||
|
||||
var record models.SysJobExecutionLog
|
||||
if err := db.First(&record).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if record.Status != models.JobExecutionFailed || record.ErrorCode != executionErrorTargetMissing || record.ErrorMessage != "任务调用目标未注册" {
|
||||
t.Fatalf("missing target was not safely recorded: %+v", record)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunWithExecutionLogSafelyRecordsPanic(t *testing.T) {
|
||||
db := jobExecutionTestDB(t)
|
||||
err := runWithExecutionLog(db, JobCore{JobId: 11, Name: "异常任务", InvokeTarget: "Panic"}, func() error {
|
||||
panic("provider-secret")
|
||||
})
|
||||
if code, message := publicExecutionFailure(err); code != executionErrorPanicked || message != "任务执行异常中断" {
|
||||
t.Fatalf("panic was not returned as a safe failure: %s %s", code, message)
|
||||
}
|
||||
var record models.SysJobExecutionLog
|
||||
if err := db.First(&record).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if record.Status != models.JobExecutionFailed || record.ErrorCode != executionErrorPanicked || record.ErrorMessage != "任务执行异常中断" {
|
||||
t.Fatalf("panic was not safely persisted: %+v", record)
|
||||
}
|
||||
}
|
||||
+33
-22
@@ -33,6 +33,7 @@ type JobCore struct {
|
||||
// HttpJob 任务类型 http
|
||||
type HttpJob struct {
|
||||
JobCore
|
||||
DB *gorm.DB
|
||||
}
|
||||
|
||||
type ExecJob struct {
|
||||
@@ -42,14 +43,16 @@ type ExecJob struct {
|
||||
|
||||
func (e *ExecJob) Run() {
|
||||
startTime := time.Now()
|
||||
var obj = jobList[e.InvokeTarget]
|
||||
if obj == nil {
|
||||
log.Warn("[Job] ExecJob Run job nil")
|
||||
return
|
||||
}
|
||||
err := CallExecWithDB(obj.(JobExec), e.DB, e.Args)
|
||||
err := runWithExecutionLog(e.DB, e.JobCore, func() error {
|
||||
obj := jobList[e.InvokeTarget]
|
||||
if obj == nil {
|
||||
return missingExecutionTarget(e.InvokeTarget)
|
||||
}
|
||||
return CallExecWithDB(obj, e.DB, e.Args)
|
||||
})
|
||||
if err != nil {
|
||||
log.Errorf("[Job] JobCore %s failed: %v", e.Name, err)
|
||||
code, _ := publicExecutionFailure(err)
|
||||
log.Errorf("[Job] JobCore %s failed error_code=%s", e.Name, code)
|
||||
return
|
||||
}
|
||||
// 结束时间
|
||||
@@ -68,22 +71,26 @@ func (e *ExecJob) Run() {
|
||||
func (h *HttpJob) Run() {
|
||||
|
||||
startTime := time.Now()
|
||||
var count = 0
|
||||
var err error
|
||||
var str string
|
||||
/* 循环 */
|
||||
LOOP:
|
||||
if count < retryCount {
|
||||
/* 跳过迭代 */
|
||||
str, err = pkg.Get(h.InvokeTarget)
|
||||
if err != nil {
|
||||
// 如果失败暂停一段时间重试
|
||||
log.Warnf("[Job] mission failed! %v", err)
|
||||
log.Warnf("[Job] Retry after the task fails %d seconds! %s \n", (count+1)*5, str)
|
||||
time.Sleep(time.Duration(count+1) * 5 * time.Second)
|
||||
count = count + 1
|
||||
goto LOOP
|
||||
err := runWithExecutionLog(h.DB, h.JobCore, func() error {
|
||||
var lastErr error
|
||||
for count := 0; count < retryCount; count++ {
|
||||
_, requestErr := pkg.Get(h.InvokeTarget)
|
||||
if requestErr == nil {
|
||||
return nil
|
||||
}
|
||||
lastErr = requestErr
|
||||
log.Warnf("[Job] HTTP mission failed attempt=%d", count+1)
|
||||
if count+1 < retryCount {
|
||||
log.Warnf("[Job] Retry after the task fails %d seconds!\n", (count+1)*5)
|
||||
time.Sleep(time.Duration(count+1) * 5 * time.Second)
|
||||
}
|
||||
}
|
||||
return newExecutionFailure(executionErrorHTTPFailed, "HTTP 任务请求失败", lastErr)
|
||||
})
|
||||
if err != nil {
|
||||
code, _ := publicExecutionFailure(err)
|
||||
log.Errorf("[Job] JobCore %s failed error_code=%s", h.Name, code)
|
||||
return
|
||||
}
|
||||
// 结束时间
|
||||
endTime := time.Now()
|
||||
@@ -102,6 +109,9 @@ func Setup(dbs map[string]*gorm.DB) {
|
||||
fmt.Println(time.Now().Format(timeFormat), " [INFO] JobCore Starting...")
|
||||
|
||||
for k, db := range dbs {
|
||||
if err := RecoverInterruptedExecutionLogs(db); err != nil {
|
||||
log.Errorf("[Job] recover interrupted execution logs failed: %v", err)
|
||||
}
|
||||
sdk.Runtime.SetCrontab(k, cronjob.NewWithSeconds())
|
||||
setup(k, db)
|
||||
}
|
||||
@@ -127,6 +137,7 @@ func setup(key string, db *gorm.DB) {
|
||||
for i := 0; i < len(jobList); i++ {
|
||||
if jobList[i].JobType == 1 {
|
||||
j := &HttpJob{}
|
||||
j.DB = db
|
||||
j.InvokeTarget = jobList[i].InvokeTarget
|
||||
j.CronExpression = jobList[i].CronExpression
|
||||
j.JobId = jobList[i].JobId
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
const (
|
||||
JobExecutionRunning = "running"
|
||||
JobExecutionSucceeded = "succeeded"
|
||||
JobExecutionFailed = "failed"
|
||||
JobExecutionInterrupted = "interrupted"
|
||||
JobTriggerScheduled = "scheduled"
|
||||
)
|
||||
|
||||
// SysJobExecutionLog stores one scheduler invocation. Job arguments and raw
|
||||
// provider responses are deliberately excluded from this audit record.
|
||||
type SysJobExecutionLog struct {
|
||||
ID uint64 `json:"id" gorm:"primaryKey;autoIncrement"`
|
||||
ExecutionID string `json:"executionId" gorm:"size:36;not null;uniqueIndex:ux_sys_job_execution_id"`
|
||||
JobID int `json:"jobId" gorm:"not null;index:idx_sys_job_execution_job_started,priority:1"`
|
||||
JobNameSnapshot string `json:"jobName" gorm:"size:255;not null"`
|
||||
InvokeTargetSnapshot string `json:"invokeTarget" gorm:"size:255;not null"`
|
||||
TriggerType string `json:"triggerType" gorm:"size:16;not null"`
|
||||
Status string `json:"status" gorm:"size:16;not null;index:idx_sys_job_execution_status_started,priority:1"`
|
||||
StartedAt time.Time `json:"startedAt" gorm:"not null;index:idx_sys_job_execution_job_started,priority:2;index:idx_sys_job_execution_status_started,priority:2"`
|
||||
FinishedAt *time.Time `json:"finishedAt,omitempty"`
|
||||
DurationMS int64 `json:"durationMs" gorm:"not null;default:0"`
|
||||
ErrorCode string `json:"errorCode,omitempty" gorm:"size:64;not null;default:''"`
|
||||
ErrorMessage string `json:"errorMessage,omitempty" gorm:"size:500;not null;default:''"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
func (SysJobExecutionLog) TableName() string { return "sys_job_execution_log" }
|
||||
@@ -24,6 +24,8 @@ func registerSysJobRouter(v1 *gin.RouterGroup, authMiddleware *jwt.GinJWTMiddlew
|
||||
list := make([]models2.SysJob, 0)
|
||||
return &list
|
||||
}))
|
||||
jobAPI := apis.SysJob{}
|
||||
r.GET("/:id/execution-logs", actions.PermissionAction(), jobAPI.ListExecutionLogs)
|
||||
r.GET("/:id", actions.PermissionAction(), actions.ViewAction(new(dto2.SysJobById), func() interface{} {
|
||||
return &dto2.SysJobItem{}
|
||||
}))
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"go-admin/app/jobs/models"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrExecutionLogInvalidRequest = errors.New("invalid execution log request")
|
||||
ErrExecutionLogJobNotFound = errors.New("scheduled job not found")
|
||||
)
|
||||
|
||||
type ExecutionLogListRequest struct {
|
||||
Page int
|
||||
PageSize int
|
||||
Status string
|
||||
StartedFrom *time.Time
|
||||
StartedTo *time.Time
|
||||
}
|
||||
|
||||
type ExecutionLogJob struct {
|
||||
JobID int `json:"jobId"`
|
||||
JobName string `json:"jobName"`
|
||||
InvokeTarget string `json:"invokeTarget"`
|
||||
Deleted bool `json:"deleted"`
|
||||
}
|
||||
|
||||
type ExecutionLogListResponse struct {
|
||||
Job ExecutionLogJob `json:"job"`
|
||||
Items []models.SysJobExecutionLog `json:"items"`
|
||||
Total int64 `json:"total"`
|
||||
Page int `json:"page"`
|
||||
PageSize int `json:"pageSize"`
|
||||
}
|
||||
|
||||
type ExecutionLogService struct{ db *gorm.DB }
|
||||
|
||||
func NewExecutionLogService(db *gorm.DB) *ExecutionLogService {
|
||||
return &ExecutionLogService{db: db}
|
||||
}
|
||||
|
||||
func (service *ExecutionLogService) List(ctx context.Context, jobID int, request ExecutionLogListRequest) (ExecutionLogListResponse, error) {
|
||||
if service.db == nil || jobID < 1 {
|
||||
return ExecutionLogListResponse{}, fmt.Errorf("%w: jobId 无效", ErrExecutionLogInvalidRequest)
|
||||
}
|
||||
if request.Page < 1 {
|
||||
request.Page = 1
|
||||
}
|
||||
if request.PageSize < 1 {
|
||||
request.PageSize = 20
|
||||
}
|
||||
if request.PageSize > 100 {
|
||||
return ExecutionLogListResponse{}, fmt.Errorf("%w: pageSize 必须是 1 到 100 的整数", ErrExecutionLogInvalidRequest)
|
||||
}
|
||||
if request.Status != "" && !validExecutionStatus(request.Status) {
|
||||
return ExecutionLogListResponse{}, fmt.Errorf("%w: status 无效", ErrExecutionLogInvalidRequest)
|
||||
}
|
||||
if request.StartedFrom != nil && request.StartedTo != nil && request.StartedFrom.After(*request.StartedTo) {
|
||||
return ExecutionLogListResponse{}, fmt.Errorf("%w: 开始时间范围无效", ErrExecutionLogInvalidRequest)
|
||||
}
|
||||
|
||||
var job models.SysJob
|
||||
if err := service.db.WithContext(ctx).Unscoped().First(&job, jobID).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return ExecutionLogListResponse{}, ErrExecutionLogJobNotFound
|
||||
}
|
||||
return ExecutionLogListResponse{}, err
|
||||
}
|
||||
|
||||
query := service.db.WithContext(ctx).Model(&models.SysJobExecutionLog{}).Where("job_id = ?", jobID)
|
||||
if request.Status != "" {
|
||||
query = query.Where("status = ?", request.Status)
|
||||
}
|
||||
if request.StartedFrom != nil {
|
||||
query = query.Where("started_at >= ?", request.StartedFrom.UTC())
|
||||
}
|
||||
if request.StartedTo != nil {
|
||||
query = query.Where("started_at <= ?", request.StartedTo.UTC())
|
||||
}
|
||||
var total int64
|
||||
if err := query.Count(&total).Error; err != nil {
|
||||
return ExecutionLogListResponse{}, err
|
||||
}
|
||||
items := make([]models.SysJobExecutionLog, 0, request.PageSize)
|
||||
if err := query.Order("started_at DESC, id DESC").Offset((request.Page - 1) * request.PageSize).Limit(request.PageSize).Find(&items).Error; err != nil {
|
||||
return ExecutionLogListResponse{}, err
|
||||
}
|
||||
return ExecutionLogListResponse{
|
||||
Job: ExecutionLogJob{JobID: job.JobId, JobName: job.JobName, InvokeTarget: job.InvokeTarget, Deleted: job.DeletedAt.Valid},
|
||||
Items: items, Total: total, Page: request.Page, PageSize: request.PageSize,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func validExecutionStatus(status string) bool {
|
||||
switch status {
|
||||
case models.JobExecutionRunning, models.JobExecutionSucceeded, models.JobExecutionFailed, models.JobExecutionInterrupted:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"go-admin/app/jobs/models"
|
||||
)
|
||||
|
||||
func executionLogTestDB(t *testing.T) *gorm.DB {
|
||||
t.Helper()
|
||||
db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.AutoMigrate(&models.SysJob{}, &models.SysJobExecutionLog{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return db
|
||||
}
|
||||
|
||||
func TestExecutionLogListFiltersPaginatesAndKeepsDeletedJob(t *testing.T) {
|
||||
db := executionLogTestDB(t)
|
||||
job := models.SysJob{JobName: "测试任务", InvokeTarget: "TestTarget"}
|
||||
if err := db.Create(&job).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
base := time.Date(2026, 9, 2, 10, 0, 0, 0, time.UTC)
|
||||
records := []models.SysJobExecutionLog{
|
||||
{ExecutionID: "00000000-0000-4000-8000-000000000001", JobID: job.JobId, JobNameSnapshot: job.JobName, InvokeTargetSnapshot: job.InvokeTarget, TriggerType: models.JobTriggerScheduled, Status: models.JobExecutionSucceeded, StartedAt: base},
|
||||
{ExecutionID: "00000000-0000-4000-8000-000000000002", JobID: job.JobId, JobNameSnapshot: job.JobName, InvokeTargetSnapshot: job.InvokeTarget, TriggerType: models.JobTriggerScheduled, Status: models.JobExecutionFailed, StartedAt: base.Add(time.Hour)},
|
||||
{ExecutionID: "00000000-0000-4000-8000-000000000003", JobID: job.JobId, JobNameSnapshot: job.JobName, InvokeTargetSnapshot: job.InvokeTarget, TriggerType: models.JobTriggerScheduled, Status: models.JobExecutionFailed, StartedAt: base.Add(2 * time.Hour)},
|
||||
}
|
||||
if err := db.Create(&records).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
from, to := base.Add(30*time.Minute), base.Add(3*time.Hour)
|
||||
result, err := NewExecutionLogService(db).List(context.Background(), job.JobId, ExecutionLogListRequest{
|
||||
Page: 1, PageSize: 1, Status: models.JobExecutionFailed, StartedFrom: &from, StartedTo: &to,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.Total != 2 || len(result.Items) != 1 || result.Items[0].ExecutionID != records[2].ExecutionID {
|
||||
t.Fatalf("unexpected filtered page: %+v", result)
|
||||
}
|
||||
if err := db.Delete(&job).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
deleted, err := NewExecutionLogService(db).List(context.Background(), job.JobId, ExecutionLogListRequest{Page: 1, PageSize: 20})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !deleted.Job.Deleted || deleted.Job.JobName != job.JobName || deleted.Total != 3 {
|
||||
t.Fatalf("deleted job history unavailable: %+v", deleted)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecutionLogListValidationAndNotFound(t *testing.T) {
|
||||
db := executionLogTestDB(t)
|
||||
service := NewExecutionLogService(db)
|
||||
if _, err := service.List(context.Background(), 0, ExecutionLogListRequest{}); !errors.Is(err, ErrExecutionLogInvalidRequest) {
|
||||
t.Fatalf("invalid job id error = %v", err)
|
||||
}
|
||||
if _, err := service.List(context.Background(), 1, ExecutionLogListRequest{PageSize: 101}); !errors.Is(err, ErrExecutionLogInvalidRequest) {
|
||||
t.Fatalf("invalid page size error = %v", err)
|
||||
}
|
||||
job := models.SysJob{JobName: "测试任务", InvokeTarget: "TestTarget"}
|
||||
if err := db.Create(&job).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := service.List(context.Background(), job.JobId, ExecutionLogListRequest{Status: "unknown"}); !errors.Is(err, ErrExecutionLogInvalidRequest) {
|
||||
t.Fatalf("invalid status error = %v", err)
|
||||
}
|
||||
from, to := time.Now(), time.Now().Add(-time.Hour)
|
||||
if _, err := service.List(context.Background(), job.JobId, ExecutionLogListRequest{StartedFrom: &from, StartedTo: &to}); !errors.Is(err, ErrExecutionLogInvalidRequest) {
|
||||
t.Fatalf("invalid range error = %v", err)
|
||||
}
|
||||
if _, err := service.List(context.Background(), 999, ExecutionLogListRequest{}); !errors.Is(err, ErrExecutionLogJobNotFound) {
|
||||
t.Fatalf("not found error = %v", err)
|
||||
}
|
||||
}
|
||||
@@ -62,6 +62,7 @@ func (e *SysJob) StartJob(c *dto.GeneralGetDto) error {
|
||||
|
||||
if data.JobType == 1 {
|
||||
var j = &jobs.HttpJob{}
|
||||
j.DB = e.Orm.WithContext(context.Background())
|
||||
j.InvokeTarget = data.InvokeTarget
|
||||
j.CronExpression = data.CronExpression
|
||||
j.JobId = data.JobId
|
||||
|
||||
@@ -54,6 +54,12 @@ var (
|
||||
}
|
||||
)
|
||||
|
||||
// The synchronous Admin AI endpoints allow a provider timeout of up to 600
|
||||
// seconds and the browser waits 610 seconds. Keep the HTTP server alive a
|
||||
// little longer so it can return the domain response instead of truncating
|
||||
// the connection and surfacing a proxy-level 502.
|
||||
const minimumAPIWriteTimeout = 620 * time.Second
|
||||
|
||||
var AppRouters = make([]func(), 0)
|
||||
|
||||
func init() {
|
||||
@@ -125,11 +131,15 @@ func run() error {
|
||||
)
|
||||
}
|
||||
|
||||
writeTimeout, err := validatedAPIWriteTimeout(config.ApplicationConfig.WriterTimeout)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
srv := &http.Server{
|
||||
Addr: fmt.Sprintf("%s:%d", config.ApplicationConfig.Host, config.ApplicationConfig.Port),
|
||||
Handler: sdk.Runtime.GetEngine(),
|
||||
ReadTimeout: time.Duration(config.ApplicationConfig.ReadTimeout) * time.Second,
|
||||
WriteTimeout: time.Duration(config.ApplicationConfig.WriterTimeout) * time.Second,
|
||||
WriteTimeout: writeTimeout,
|
||||
}
|
||||
|
||||
go func() {
|
||||
@@ -193,6 +203,14 @@ func run() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func validatedAPIWriteTimeout(seconds int) (time.Duration, error) {
|
||||
timeout := time.Duration(seconds) * time.Second
|
||||
if timeout < minimumAPIWriteTimeout {
|
||||
return 0, fmt.Errorf("application writetimeout must be at least %s for synchronous AI requests", minimumAPIWriteTimeout)
|
||||
}
|
||||
return timeout, nil
|
||||
}
|
||||
|
||||
type policyLoader interface {
|
||||
LoadPolicy() error
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestValidatedAPIWriteTimeoutProtectsSynchronousAIRequests(t *testing.T) {
|
||||
if _, err := validatedAPIWriteTimeout(2); err == nil {
|
||||
t.Fatal("two-second write timeout must be rejected")
|
||||
}
|
||||
got, err := validatedAPIWriteTimeout(620)
|
||||
if err != nil {
|
||||
t.Fatalf("620-second write timeout should be accepted: %v", err)
|
||||
}
|
||||
if got != 620*time.Second {
|
||||
t.Fatalf("write timeout = %s, want 620s", got)
|
||||
}
|
||||
}
|
||||
@@ -38,11 +38,11 @@ func TestEnsurePurchaserRoleAndPolicies(t *testing.T) {
|
||||
if count != int64(len(access.PurchaserAPIs())) {
|
||||
t.Fatalf("got %d policies, want %d", count, len(access.PurchaserAPIs()))
|
||||
}
|
||||
var forbidden int64
|
||||
var allowed int64
|
||||
db.Model(&purchaserCasbinRule{}).
|
||||
Where("v0 = ? AND v1 = ? AND v2 = ?", access.RolePurchaser, "/api/admin/v1/syb-products/import", "POST").
|
||||
Count(&forbidden)
|
||||
if forbidden != 0 {
|
||||
t.Fatal("manual SYB import must remain administrator-only")
|
||||
Count(&allowed)
|
||||
if allowed != 1 {
|
||||
t.Fatal("manual SYB import must follow the current purchaser permission matrix")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
package version_local
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"runtime"
|
||||
|
||||
goautomigrations "go-admin/app/goauto/migrations"
|
||||
"go-admin/app/goauto/shopeeproduct"
|
||||
jobsmodels "go-admin/app/jobs/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), migrateShopeeSpecAutoMatch)
|
||||
}
|
||||
|
||||
func migrateShopeeSpecAutoMatch(db *gorm.DB, version string) error {
|
||||
return db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := goautomigrations.Migrate(tx); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := ensureShopeeSpecAutoMatchJob(tx); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Create(&common.Migration{Version: version}).Error
|
||||
})
|
||||
}
|
||||
|
||||
func ensureShopeeSpecAutoMatchJob(db *gorm.DB) error {
|
||||
var existing jobsmodels.SysJob
|
||||
err := db.Where("invoke_target = ?", shopeeproduct.SpecAutoMatchInvokeTarget).First(&existing).Error
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return err
|
||||
}
|
||||
return db.Create(&jobsmodels.SysJob{
|
||||
JobName: "蝦皮规格自动匹配", JobGroup: "GoAuto", JobType: 2,
|
||||
CronExpression: "0 15 * * * *", InvokeTarget: shopeeproduct.SpecAutoMatchInvokeTarget,
|
||||
Args: `{"batchLimit":20}`, MisfirePolicy: 1, Concurrent: 1, Status: 1,
|
||||
}).Error
|
||||
}
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
package version_local
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"go-admin/app/goauto/shopeeproduct"
|
||||
jobsmodels "go-admin/app/jobs/models"
|
||||
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func TestEnsureShopeeSpecAutoMatchJobIsDisabledIdempotentAndPreservesChanges(t *testing.T) {
|
||||
db, err := gorm.Open(sqlite.Open("file:shopee-spec-auto-match-job?mode=memory&cache=shared"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.AutoMigrate(&jobsmodels.SysJob{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := ensureShopeeSpecAutoMatchJob(db); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var job jobsmodels.SysJob
|
||||
if err := db.Where("invoke_target = ?", shopeeproduct.SpecAutoMatchInvokeTarget).First(&job).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if job.Status != 1 || job.CronExpression != "0 15 * * * *" || job.Args != `{"batchLimit":20}` {
|
||||
t.Fatalf("unexpected seed: %+v", job)
|
||||
}
|
||||
if err := db.Model(&job).Updates(map[string]any{"status": 2, "cron_expression": "0 30 * * * *", "args": `{"batchLimit":5}`}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := ensureShopeeSpecAutoMatchJob(db); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var count int64
|
||||
if err := db.Model(&jobsmodels.SysJob{}).Where("invoke_target = ?", shopeeproduct.SpecAutoMatchInvokeTarget).Count(&count).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if count != 1 {
|
||||
t.Fatalf("count=%d", count)
|
||||
}
|
||||
if err := db.Where("invoke_target = ?", shopeeproduct.SpecAutoMatchInvokeTarget).First(&job).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if job.Status != 2 || job.CronExpression != "0 30 * * * *" || job.Args != `{"batchLimit":5}` {
|
||||
t.Fatalf("admin changes overwritten: %+v", job)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package version_local
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"runtime"
|
||||
|
||||
goautomigrations "go-admin/app/goauto/migrations"
|
||||
"go-admin/app/goauto/sybimport"
|
||||
jobsmodels "go-admin/app/jobs/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), migrateSYBSpecAIParse)
|
||||
}
|
||||
|
||||
func migrateSYBSpecAIParse(db *gorm.DB, version string) error {
|
||||
return db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := goautomigrations.Migrate(tx); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := ensureSYBSpecAIParseJob(tx); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Create(&common.Migration{Version: version}).Error
|
||||
})
|
||||
}
|
||||
|
||||
func ensureSYBSpecAIParseJob(db *gorm.DB) error {
|
||||
var existing jobsmodels.SysJob
|
||||
err := db.Where("invoke_target = ?", sybimport.SpecAIParseInvokeTarget).First(&existing).Error
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return err
|
||||
}
|
||||
return db.Create(&jobsmodels.SysJob{
|
||||
JobName: "SYB 异常规格 AI 解析", JobGroup: "GoAuto", JobType: 2,
|
||||
CronExpression: "0 5 * * * *", InvokeTarget: sybimport.SpecAIParseInvokeTarget,
|
||||
Args: `{"batchLimit":20}`, MisfirePolicy: 1, Concurrent: 1, Status: 1,
|
||||
}).Error
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package version_local
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"go-admin/app/goauto/sybimport"
|
||||
jobsmodels "go-admin/app/jobs/models"
|
||||
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func TestEnsureSYBSpecAIParseJobIsDisabledIdempotentAndPreservesChanges(t *testing.T) {
|
||||
db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.AutoMigrate(&jobsmodels.SysJob{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := ensureSYBSpecAIParseJob(db); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var job jobsmodels.SysJob
|
||||
if err := db.Where("invoke_target = ?", sybimport.SpecAIParseInvokeTarget).First(&job).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if job.Status != 1 || job.CronExpression != "0 5 * * * *" || job.Args != `{"batchLimit":20}` {
|
||||
t.Fatalf("unexpected initial job: %+v", job)
|
||||
}
|
||||
if err := db.Model(&job).Updates(map[string]any{"status": 2, "cron_expression": "0 7 * * * *", "args": `{"batchLimit":7}`}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := ensureSYBSpecAIParseJob(db); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var count int64
|
||||
if err := db.Model(&jobsmodels.SysJob{}).Where("invoke_target = ?", sybimport.SpecAIParseInvokeTarget).Count(&count).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if count != 1 {
|
||||
t.Fatalf("job count=%d, want 1", count)
|
||||
}
|
||||
if err := db.First(&job, job.JobId).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if job.Status != 2 || job.CronExpression != "0 7 * * * *" || job.Args != `{"batchLimit":7}` {
|
||||
t.Fatalf("existing administrator changes were overwritten: %+v", job)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package version_local
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"runtime"
|
||||
|
||||
jobsmodels "go-admin/app/jobs/models"
|
||||
"go-admin/cmd/migrate/migration"
|
||||
migrationmodels "go-admin/cmd/migrate/migration/models"
|
||||
common "go-admin/common/models"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const jobExecutionLogsAPIPath = "/api/v1/sysjob/:id/execution-logs"
|
||||
|
||||
func init() {
|
||||
_, fileName, _, _ := runtime.Caller(0)
|
||||
migration.Migrate.SetVersion(migration.GetFilename(fileName), migrateSysJobExecutionLog)
|
||||
}
|
||||
|
||||
func migrateSysJobExecutionLog(db *gorm.DB, version string) error {
|
||||
return db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := ensureSysJobExecutionLog(tx); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Create(&common.Migration{Version: version}).Error
|
||||
})
|
||||
}
|
||||
|
||||
func ensureSysJobExecutionLog(db *gorm.DB) error {
|
||||
if err := db.AutoMigrate(&jobsmodels.SysJobExecutionLog{}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var logMenu migrationmodels.SysMenu
|
||||
if err := db.Where("menu_name = ?", "JobLog").First(&logMenu).Error; err != nil {
|
||||
return fmt.Errorf("find JobLog menu: %w", err)
|
||||
}
|
||||
api := migrationmodels.SysApi{}
|
||||
if err := db.Where(migrationmodels.SysApi{Path: jobExecutionLogsAPIPath, Action: "GET"}).
|
||||
Attrs(migrationmodels.SysApi{Title: "查询定时任务执行日志", Type: "BUS"}).FirstOrCreate(&api).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := db.Model(&logMenu).Association("SysApi").Append(&api); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var roleIDs []int
|
||||
if err := db.Table("sys_role_menu").Where("menu_id = ?", logMenu.MenuId).Distinct().Pluck("role_id", &roleIDs).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if len(roleIDs) == 0 {
|
||||
return nil
|
||||
}
|
||||
var roles []migrationmodels.SysRole
|
||||
if err := db.Where("role_id IN ?", roleIDs).Find(&roles).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
for _, role := range roles {
|
||||
rule := purchaserCasbinRule{Ptype: "p", V0: role.RoleKey, V1: jobExecutionLogsAPIPath, V2: "GET"}
|
||||
if err := db.Where("ptype = ? AND v0 = ? AND v1 = ? AND v2 = ?", rule.Ptype, rule.V0, rule.V1, rule.V2).
|
||||
FirstOrCreate(&rule).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
+78
@@ -0,0 +1,78 @@
|
||||
package version_local
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
jobsmodels "go-admin/app/jobs/models"
|
||||
migrationmodels "go-admin/cmd/migrate/migration/models"
|
||||
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func TestEnsureSysJobExecutionLogIsIdempotentAndGrantsOnlyBoundRoles(t *testing.T) {
|
||||
db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.AutoMigrate(
|
||||
&migrationmodels.SysMenu{}, &migrationmodels.SysApi{}, &migrationmodels.SysRole{}, &purchaserCasbinRule{},
|
||||
); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
menu := migrationmodels.SysMenu{MenuName: "JobLog", Title: "日志", Path: "/schedule/log", Component: "/schedule/log"}
|
||||
if err := db.Create(&menu).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
bound := migrationmodels.SysRole{RoleName: "日志管理员", RoleKey: "job_logger"}
|
||||
unbound := migrationmodels.SysRole{RoleName: "无日志权限", RoleKey: "no_job_logs"}
|
||||
if err := db.Create(&bound).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Create(&unbound).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Model(&bound).Association("SysMenu").Append(&menu); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
custom := purchaserCasbinRule{Ptype: "p", V0: bound.RoleKey, V1: "/custom", V2: "GET"}
|
||||
if err := db.Create(&custom).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := ensureSysJobExecutionLog(db); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := ensureSysJobExecutionLog(db); err != nil {
|
||||
t.Fatalf("migration helper must be repeatable: %v", err)
|
||||
}
|
||||
if !db.Migrator().HasTable(&jobsmodels.SysJobExecutionLog{}) {
|
||||
t.Fatal("execution log table was not created")
|
||||
}
|
||||
var apiCount, menuAPI, boundPolicy, unboundPolicy, customPolicy int64
|
||||
db.Model(&migrationmodels.SysApi{}).Where("path = ? AND action = ?", jobExecutionLogsAPIPath, "GET").Count(&apiCount)
|
||||
var api migrationmodels.SysApi
|
||||
if err := db.Where("path = ? AND action = ?", jobExecutionLogsAPIPath, "GET").First(&api).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
db.Table("sys_menu_api_rule").Where("sys_menu_menu_id = ? AND sys_api_id = ?", menu.MenuId, api.Id).Count(&menuAPI)
|
||||
db.Model(&purchaserCasbinRule{}).Where("v0 = ? AND v1 = ? AND v2 = ?", bound.RoleKey, jobExecutionLogsAPIPath, "GET").Count(&boundPolicy)
|
||||
db.Model(&purchaserCasbinRule{}).Where("v0 = ? AND v1 = ? AND v2 = ?", unbound.RoleKey, jobExecutionLogsAPIPath, "GET").Count(&unboundPolicy)
|
||||
db.Model(&purchaserCasbinRule{}).Where("v0 = ? AND v1 = ?", bound.RoleKey, "/custom").Count(&customPolicy)
|
||||
if apiCount != 1 || menuAPI != 1 || boundPolicy != 1 || unboundPolicy != 0 || customPolicy != 1 {
|
||||
t.Fatalf("unexpected permission state api=%d menu=%d bound=%d unbound=%d custom=%d", apiCount, menuAPI, boundPolicy, unboundPolicy, customPolicy)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureSysJobExecutionLogRequiresExistingMenu(t *testing.T) {
|
||||
db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.AutoMigrate(&migrationmodels.SysMenu{}, &migrationmodels.SysApi{}, &migrationmodels.SysRole{}, &purchaserCasbinRule{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := ensureSysJobExecutionLog(db); err == nil {
|
||||
t.Fatal("missing JobLog menu must fail closed")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package version_local
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"go-admin/app/goauto/clientkey"
|
||||
"go-admin/cmd/migrate/migration"
|
||||
migrationmodels "go-admin/cmd/migrate/migration/models"
|
||||
common "go-admin/common/models"
|
||||
"gorm.io/gorm"
|
||||
"runtime"
|
||||
)
|
||||
|
||||
func init() {
|
||||
_, file, _, _ := runtime.Caller(0)
|
||||
migration.Migrate.SetVersion(migration.GetFilename(file), migrateClientAPIKey)
|
||||
}
|
||||
func migrateClientAPIKey(db *gorm.DB, version string) error {
|
||||
if err := db.AutoMigrate(&clientkey.Key{}, &clientkey.Audit{}); err != nil {
|
||||
return err
|
||||
}
|
||||
return db.Transaction(func(tx *gorm.DB) error {
|
||||
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: "GoAutoClientKeys", Title: "客户端密钥", Icon: "lock", Path: "/client-keys/index", MenuType: "C", Action: "无", ParentId: parent.MenuId, Component: "/goauto/client-keys/index", Sort: 6, 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
|
||||
}
|
||||
var admin migrationmodels.SysRole
|
||||
if err = tx.Where("role_key = ?", "admin").First(&admin).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err = tx.Model(&admin).Association("SysMenu").Append(&child); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Create(&common.Migration{Version: version}).Error
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package version_local
|
||||
|
||||
import (
|
||||
"go-admin/app/goauto/clientkey"
|
||||
migrationmodels "go-admin/cmd/migrate/migration/models"
|
||||
common "go-admin/common/models"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestClientKeyMigrationOnlyAdminMenu(t *testing.T) {
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sql, _ := db.DB()
|
||||
defer sql.Close()
|
||||
if err = db.AutoMigrate(&migrationmodels.SysRole{}, &migrationmodels.SysMenu{}, &migrationmodels.SysApi{}, &common.Migration{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
admin := migrationmodels.SysRole{RoleKey: "admin"}
|
||||
purchaser := migrationmodels.SysRole{RoleKey: "purchaser"}
|
||||
parent := migrationmodels.SysMenu{MenuName: "GoAutoCollectionManagement"}
|
||||
for _, v := range []any{&admin, &purchaser, &parent} {
|
||||
if err = db.Create(v).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err = migrateClientAPIKey(db, "client-key-test"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !db.Migrator().HasTable(&clientkey.Key{}) || !db.Migrator().HasTable(&clientkey.Audit{}) {
|
||||
t.Fatal("missing tables")
|
||||
}
|
||||
var child migrationmodels.SysMenu
|
||||
if err = db.Where("menu_name = ?", "GoAutoClientKeys").First(&child).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if child.ParentId != parent.MenuId {
|
||||
t.Fatal("wrong menu group")
|
||||
}
|
||||
assertRoleHasMenu(t, db, admin.RoleId, child.MenuId, true)
|
||||
assertRoleHasMenu(t, db, purchaser.RoleId, child.MenuId, false)
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
// Package clientprincipal carries an authenticated client identity, never an
|
||||
// administrator or purchaser JWT. Only the client gateway sets it.
|
||||
package clientprincipal
|
||||
|
||||
import "github.com/gin-gonic/gin"
|
||||
|
||||
const contextKey = "goauto.authenticatedClient"
|
||||
|
||||
type Identity struct {
|
||||
KeyID uint64
|
||||
RequestID string
|
||||
AuthorizedBy uint64
|
||||
}
|
||||
|
||||
func Set(c *gin.Context, id Identity) { c.Set(contextKey, id) }
|
||||
func Get(c *gin.Context) (Identity, bool) {
|
||||
v, ok := c.Get(contextKey)
|
||||
id, valid := v.(Identity)
|
||||
return id, ok && valid && id.KeyID > 0
|
||||
}
|
||||
@@ -24,6 +24,12 @@ import (
|
||||
// LoggerToFile 日志记录到文件
|
||||
func LoggerToFile() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
// #237: client request/response bodies and one-time credentials must never
|
||||
// enter the legacy operation logger. The client gateway keeps metadata-only audit.
|
||||
if strings.HasPrefix(c.Request.URL.Path, "/api/client/") || strings.HasPrefix(c.Request.URL.Path, "/api/admin/v1/client-keys") {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
log := api.GetRequestLogger(c)
|
||||
// 开始时间
|
||||
startTime := time.Now()
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user