Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d8382e6518 | ||
|
|
0edc78a943 | ||
|
|
5e0a9d108c | ||
|
|
08b7095cf1 | ||
|
|
01510a85dc | ||
|
|
4261a542ca | ||
|
|
beec630187 | ||
|
|
2491a857f7 | ||
|
|
0661b2205f | ||
|
|
bb1a410e8a |
+229
-10
@@ -90,7 +90,28 @@ data class VisibleSpecValue(
|
||||
val rawText: String = text,
|
||||
)
|
||||
data class VisibleDimension(val key: String, val name: String, val values: List<VisibleSpecValue>)
|
||||
enum class SpecPanelType { UNKNOWN, NORMAL_SCROLLABLE, NON_SCROLLABLE_CONFIRMATION, QUICK_CONFIRMATION, ORDER_CONFIRMATION }
|
||||
enum class SpecPanelType {
|
||||
UNKNOWN,
|
||||
NORMAL_SCROLLABLE,
|
||||
NON_SCROLLABLE_CONFIRMATION,
|
||||
QUICK_CONFIRMATION,
|
||||
ORDER_CONFIRMATION,
|
||||
/**
|
||||
* #331 fallback: only reached when no stronger panel type matched. It
|
||||
* requires an address entry, a payment-method entry and one quantity input
|
||||
* box, plus at least one auxiliary signal. It is recognition only and never
|
||||
* relaxes any click target (the final order-submit click stays strict).
|
||||
*/
|
||||
REQUIRED_EVIDENCE,
|
||||
}
|
||||
|
||||
/** #331 bounded wait for a spec panel that is still loading after an entry click or an address save. */
|
||||
object SpecPanelStabilityPolicy {
|
||||
/** Upper bound for waiting until the required evidence is present and the structure is stable. */
|
||||
const val WAIT_BOUND_MS = 5_000L
|
||||
/** Sampling interval used after an address save returns to the purchase panel. */
|
||||
const val ADDRESS_SAVE_POLL_MS = 200L
|
||||
}
|
||||
|
||||
object PddSoldOutRecoveryDefaults {
|
||||
const val EXACT_TEXT = "商品已售罄"
|
||||
@@ -134,7 +155,54 @@ data class ParsedPddScreen(
|
||||
val hasCloseControl: Boolean = false,
|
||||
val hasPaymentArea: Boolean = false,
|
||||
val purchaseContextMatched: Boolean = false,
|
||||
// #331 evidence flags. Presence only: address text is never kept here.
|
||||
val hasAddressEntry: Boolean = false,
|
||||
val hasPaymentEntry: Boolean = false,
|
||||
val hasQuantityInput: Boolean = false,
|
||||
val hasQuantityAdjustControls: Boolean = false,
|
||||
val quantityInputCount: Int = 0,
|
||||
/** #331: address entry, payment entry and quantity input share one bounded panel container. */
|
||||
val requiredEvidenceSameContainer: Boolean = false,
|
||||
) {
|
||||
/** The three #331 required spec-panel items are all visible. */
|
||||
val hasRequiredPanelEvidence: Boolean get() = hasAddressEntry && hasPaymentEntry && hasQuantityInput
|
||||
|
||||
/** Boolean/count-only evidence string for diagnostics; contains no page text. */
|
||||
fun requiredEvidenceSummary(): String =
|
||||
"addr=${hasAddressEntry.flag()};pay=${hasPaymentEntry.flag()};qin=$quantityInputCount;" +
|
||||
"adj=${hasQuantityAdjustControls.flag()};summary=${hasSelectionSummary.flag()};" +
|
||||
"close=${hasCloseControl.flag()};submit=${hasOrderSubmitAction.flag()};same=${requiredEvidenceSameContainer.flag()}"
|
||||
|
||||
/**
|
||||
* #331 purchaser-readable hints for missing required items. Only emitted
|
||||
* when the page shows at least one required item or parsed options, so an
|
||||
* unrelated page does not produce misleading environment hints. No page
|
||||
* text or personal data is included.
|
||||
*/
|
||||
fun requiredEvidenceHints(): List<String> {
|
||||
val partial = hasAddressEntry || hasPaymentEntry || hasQuantityInput || panelOptionCount > 0
|
||||
if (!partial) return emptyList()
|
||||
return buildList {
|
||||
if (!hasPaymentEntry) add(MISSING_PAYMENT_ENTRY_HINT)
|
||||
if (!hasAddressEntry) add(MISSING_ADDRESS_ENTRY_HINT)
|
||||
if (!hasQuantityInput) add(MISSING_QUANTITY_INPUT_HINT)
|
||||
if (hasRequiredPanelEvidence && !requiredEvidenceSameContainer) add(NOT_SAME_CONTAINER_HINT)
|
||||
}
|
||||
}
|
||||
|
||||
/** Hints joined for a failure message with a leading separator; empty when there is nothing to hint. */
|
||||
fun requiredEvidenceHintSuffix(): String =
|
||||
requiredEvidenceHints().takeIf { it.isNotEmpty() }?.joinToString(";", prefix = ";") ?: ""
|
||||
|
||||
private fun Boolean.flag(): Int = if (this) 1 else 0
|
||||
|
||||
companion object {
|
||||
const val MISSING_PAYMENT_ENTRY_HINT = "未找到支付入口:请确认 PDD 默认支付方式为微信支付"
|
||||
const val MISSING_ADDRESS_ENTRY_HINT = "未找到收货地址入口:请确认 PDD 已设置默认收货地址"
|
||||
const val MISSING_QUANTITY_INPUT_HINT = "未找到购买数量输入框"
|
||||
const val NOT_SAME_CONTAINER_HINT = "收货地址入口、支付入口和购买数量输入框不在同一规格面板内"
|
||||
}
|
||||
|
||||
fun isTransientSoldOut(
|
||||
exactText: String,
|
||||
fallbackTopText: String = "相似商品",
|
||||
@@ -186,6 +254,13 @@ object PddScreenParser {
|
||||
// purchase/order/payment controls must never become collection click targets.
|
||||
private val nonConfigurableClickDenylist = listOf("提交订单", "确认订单", "支付", "付款")
|
||||
|
||||
/** Masked CN mobile number shown in the purchase address row, e.g. 138****5678. Presence only. */
|
||||
val MASKED_PHONE_PATTERN = Regex("(?<![0-9])[0-9]{3}\\*{4}[0-9]{4}(?![0-9])")
|
||||
/** An address/payment entry must be clickable itself or through a nearby row container. */
|
||||
private const val ENTRY_CLICKABLE_ANCESTOR_MAX_DEPTH = 6
|
||||
/** Whole-sheet containers are clickable too; a real entry row stays well below this share of the screen height. */
|
||||
private const val ENTRY_ROW_MAX_HEIGHT_PERCENT = 30
|
||||
|
||||
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 ->
|
||||
@@ -203,6 +278,8 @@ object PddScreenParser {
|
||||
}
|
||||
}
|
||||
val labels = visible.map(SnapshotNode::label)
|
||||
val visibleByPath = visible.associateBy(SnapshotNode::path)
|
||||
val sourceByPath = visibleNodes.associateBy(SnapshotNode::path)
|
||||
val textAliases = config.textAliases
|
||||
val problem = PddPageClassifier.classify(snapshot.packageName, snapshot.activityName, labels)
|
||||
val compactLabels = labels.map { it.replace(" ", "") }
|
||||
@@ -240,7 +317,23 @@ object PddScreenParser {
|
||||
val decreaseControls = visible.filter { it.clickable && it.label.replace(" ", "") in textAliases.specPanel.quantityDecreaseAliases }
|
||||
val increaseControls = visible.filter { it.clickable && it.label.replace(" ", "") in textAliases.specPanel.quantityIncreaseAliases }
|
||||
val hasQuantityControls = quantityInputs.size == 1 && decreaseControls.size == 1 && increaseControls.size == 1
|
||||
// #331: the unique input box is required; the +/- buttons are only auxiliary.
|
||||
val hasQuantityInput = quantityInputs.size == 1 && quantityInputs.single().enabled
|
||||
val hasQuantityAdjustControls = decreaseControls.size == 1 && increaseControls.size == 1
|
||||
val hasPaymentArea = compactLabels.any { label -> textAliases.specPanel.paymentAreaAliases.any(label::contains) }
|
||||
val addressEntryRows = addressEntryRows(visibleNodes, screenHeight)
|
||||
val paymentEntryRows = paymentEntryRows(visibleNodes, screenHeight, textAliases.specPanel.paymentAreaAliases)
|
||||
val hasAddressEntry = addressEntryRows.isNotEmpty()
|
||||
val hasPaymentEntry = paymentEntryRows.isNotEmpty()
|
||||
// #331: the three required items must sit in one panel container, not
|
||||
// merely somewhere on screen (e.g. an address dialog, a payment dialog
|
||||
// and an unrelated input box). Same area bound idea as boundedScrollables.
|
||||
val requiredEvidenceSameContainer = hasAddressEntry && hasPaymentEntry && hasQuantityInput &&
|
||||
addressEntryRows.any { address ->
|
||||
paymentEntryRows.any { payment ->
|
||||
sharesBoundedPanelContainer(listOf(address, payment, quantityInputs.single()), sourceByPath, screenArea)
|
||||
}
|
||||
}
|
||||
val hasQuickBuy = visible.any { node ->
|
||||
node.label.replace(" ", "").let { compact -> textAliases.specPanel.quickBuyAliases.any(compact::contains) } &&
|
||||
node.bounds.centerY.toDouble() >= screenHeight * 0.75
|
||||
@@ -276,27 +369,50 @@ object PddScreenParser {
|
||||
headings.forEachIndexed { index, heading ->
|
||||
val lower = headings.getOrNull(index + 1)?.bounds?.top ?: Int.MAX_VALUE
|
||||
val dimensionKey = dimensionKey(heading.label, config, any { it.key == "color" })
|
||||
val values = panelVisible.asSequence()
|
||||
.filter { it.clickable && it.bounds.top >= heading.bounds.bottom && it.bounds.bottom <= lower }
|
||||
.filter { it.bounds.width > 0 && it.bounds.height > 0 && it.label.length <= 80 }
|
||||
fun inValueRegion(node: SnapshotNode) =
|
||||
node.bounds.top >= heading.bounds.bottom && node.bounds.bottom <= lower &&
|
||||
node.bounds.width > 0 && node.bounds.height > 0 && node.label.length <= 80
|
||||
val candidates = panelVisible.asSequence()
|
||||
.filter { it.clickable && inValueRegion(it) }
|
||||
.filterNot { isExactHeadingLabel(it.label, config) }
|
||||
.filterNot { node ->
|
||||
isExcludedOptionLabel(node.label) ||
|
||||
descendants(node, visibleNodes).any { descendant -> isExcludedOptionLabel(descendant.label) }
|
||||
}
|
||||
.map {
|
||||
val rawText = it.label
|
||||
.toList()
|
||||
// #332/task 535: one option block may expose several clickable
|
||||
// nodes (outer block, image, inner text with a badge such as
|
||||
// "零差评"). Nodes nested in the same labelled option block are
|
||||
// collapsed into one value named by the outer block. The click
|
||||
// node is still chosen by safeOptionRank among the members.
|
||||
val values = candidates
|
||||
.groupBy { candidate ->
|
||||
optionBlock(candidate, candidates, visibleByPath, sourceByPath, ::inValueRegion)?.path ?: candidate.path
|
||||
}
|
||||
.map { (blockPath, members) ->
|
||||
val block = visibleByPath[blockPath]
|
||||
val chosen = members.minBy { option -> safeOptionRank(option, visibleNodes) }
|
||||
val selected = members.any(SnapshotNode::selected) || block?.selected == true
|
||||
val checked = members.any(SnapshotNode::checked) || block?.checked == true
|
||||
Triple(chosen.copy(selected = selected, checked = checked), block?.label ?: chosen.label, members)
|
||||
}
|
||||
.asSequence()
|
||||
.map { (option, blockLabel, members) ->
|
||||
val rawText = blockLabel
|
||||
val normalizedText = when (dimensionKey) {
|
||||
"color" -> SpecValueNormalizer.normalizeColor(rawText)
|
||||
"size" -> SpecValueNormalizer.normalizeSize(rawText)
|
||||
else -> rawText
|
||||
}
|
||||
val stateText = (listOf(it.label) + descendants(it, visibleNodes).map(SnapshotNode::label)).joinToString(" ")
|
||||
val stateText = (listOf(option.label) + descendants(option, visibleNodes).map(SnapshotNode::label)).joinToString(" ")
|
||||
VisibleSpecValue(
|
||||
normalizedText,
|
||||
it.enabled && !stateText.containsUnavailableWord(),
|
||||
it,
|
||||
if (dimensionKey == "color") colorImageBoundsForOption(it, visibleNodes) else null,
|
||||
option.enabled && !stateText.containsUnavailableWord(),
|
||||
option,
|
||||
if (dimensionKey == "color") {
|
||||
(listOf(option) + members.filter { it.path != option.path })
|
||||
.firstNotNullOfOrNull { colorImageBoundsForOption(it, visibleNodes) }
|
||||
} else null,
|
||||
rawText,
|
||||
)
|
||||
}
|
||||
@@ -339,6 +455,14 @@ object PddScreenParser {
|
||||
panelScrollable == null && headings.size >= 2 && dimensions.size >= 2 &&
|
||||
dimensions.sumOf { it.values.size } >= 2 &&
|
||||
hasQuantityControls && hasOrderSubmitAction -> SpecPanelType.NON_SCROLLABLE_CONFIRMATION
|
||||
// #331 fallback, evaluated only after every stronger branch failed.
|
||||
// Required: address entry + payment entry + one enabled quantity
|
||||
// input. Auxiliary (at least one): grouped options, selection
|
||||
// summary, close control, +/- controls or the lower submit action.
|
||||
requiredEvidenceSameContainer && (
|
||||
dimensions.any { it.values.isNotEmpty() } || hasSelectionSummary || hasClose ||
|
||||
hasQuantityAdjustControls || hasOrderSubmitAction
|
||||
) -> SpecPanelType.REQUIRED_EVIDENCE
|
||||
else -> SpecPanelType.UNKNOWN
|
||||
}
|
||||
val panelOpen = specPanelType != SpecPanelType.UNKNOWN
|
||||
@@ -439,6 +563,12 @@ object PddScreenParser {
|
||||
purchaseContextMatched = continuedPurchasePanel,
|
||||
hasQuantityControls = hasQuantityControls,
|
||||
hasOrderSubmitAction = hasOrderSubmitAction,
|
||||
hasAddressEntry = hasAddressEntry,
|
||||
hasPaymentEntry = hasPaymentEntry,
|
||||
hasQuantityInput = hasQuantityInput,
|
||||
hasQuantityAdjustControls = hasQuantityAdjustControls,
|
||||
quantityInputCount = quantityInputs.size,
|
||||
requiredEvidenceSameContainer = requiredEvidenceSameContainer,
|
||||
explicitSpecEntryCount = explicitSpecEntries.size,
|
||||
nestedSpecEntryCount = nestedSpecEntries.size,
|
||||
bottomPurchaseEntryCount = bottomSpecEntries.size,
|
||||
@@ -453,6 +583,93 @@ object PddScreenParser {
|
||||
return compact in excludedExactOptionLabels || excludedOptionWords.any { compact.contains(it) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Outermost labelled clickable ancestor inside the value region whose label
|
||||
* prefixes every candidate nested in it. Rows that contain several distinct
|
||||
* options never qualify because their members do not share its label.
|
||||
*/
|
||||
private fun optionBlock(
|
||||
candidate: SnapshotNode,
|
||||
candidates: List<SnapshotNode>,
|
||||
visibleByPath: Map<String, SnapshotNode>,
|
||||
sourceByPath: Map<String, SnapshotNode>,
|
||||
inValueRegion: (SnapshotNode) -> Boolean,
|
||||
): SnapshotNode? {
|
||||
fun compact(value: String) = value.replace(Regex("\\s+"), "")
|
||||
var block: SnapshotNode? = null
|
||||
var parentPath = candidate.parentPath
|
||||
while (parentPath != null) {
|
||||
val raw = sourceByPath[parentPath] ?: break
|
||||
val resolved = visibleByPath[parentPath]
|
||||
// Only a block with its own label names the option; a blank row
|
||||
// container resolved from its first child never merges options.
|
||||
if (raw.clickable && raw.label.isNotBlank() && resolved != null && inValueRegion(resolved)) {
|
||||
val outer = compact(resolved.label)
|
||||
val members = candidates.filter { it.path == resolved.path || it.path.startsWith("${resolved.path}/") }
|
||||
if (outer.isEmpty() || members.any { !compact(it.label).startsWith(outer) }) break
|
||||
block = resolved
|
||||
}
|
||||
parentPath = raw.parentPath
|
||||
}
|
||||
return block
|
||||
}
|
||||
|
||||
/** The nearby clickable row holding [node], or null when there is none or it is not a plausible entry row. */
|
||||
private fun clickableEntryRow(node: SnapshotNode, sourceByPath: Map<String, SnapshotNode>, screenHeight: Int): SnapshotNode? {
|
||||
var current: SnapshotNode? = node
|
||||
var depth = 0
|
||||
while (current != null && depth <= ENTRY_CLICKABLE_ANCESTOR_MAX_DEPTH) {
|
||||
if (current.clickable) {
|
||||
val plausible = current.visible && current.enabled && current.bounds.width > 0 && current.bounds.height > 0 &&
|
||||
(screenHeight <= 0 || current.bounds.height * 100 <= screenHeight * ENTRY_ROW_MAX_HEIGHT_PERCENT)
|
||||
return current.takeIf { plausible }
|
||||
}
|
||||
current = current.parentPath?.let(sourceByPath::get)
|
||||
depth++
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** Address rows: a masked phone inside a nearby clickable row. The address text is never read out. */
|
||||
private fun addressEntryRows(source: List<SnapshotNode>, screenHeight: Int): List<SnapshotNode> {
|
||||
val byPath = source.associateBy(SnapshotNode::path)
|
||||
return source.filter { node ->
|
||||
node.visible && node.bounds.width > 0 && node.bounds.height > 0 && MASKED_PHONE_PATTERN.containsMatchIn(node.label)
|
||||
}.mapNotNull { clickableEntryRow(it, byPath, screenHeight) }.distinctBy(SnapshotNode::path)
|
||||
}
|
||||
|
||||
/** Payment-method rows via the configurable payment aliases inside a nearby clickable row. */
|
||||
private fun paymentEntryRows(source: List<SnapshotNode>, screenHeight: Int, aliases: List<String>): List<SnapshotNode> {
|
||||
val byPath = source.associateBy(SnapshotNode::path)
|
||||
return source.filter { node ->
|
||||
node.visible && node.bounds.width > 0 && node.bounds.height > 0 &&
|
||||
node.label.replace(Regex("\\s+"), "").let { compact -> aliases.any(compact::contains) }
|
||||
}.mapNotNull { clickableEntryRow(it, byPath, screenHeight) }.distinctBy(SnapshotNode::path)
|
||||
}
|
||||
|
||||
/**
|
||||
* #331: true when the lowest common ancestor of [nodes] is a real panel
|
||||
* container: it is not a window root (a node without parent) and, like
|
||||
* boundedScrollables, it does not cover the whole screen. Items taken from
|
||||
* separate dialogs/pages only meet at the root or a full-screen wrapper.
|
||||
*/
|
||||
private fun sharesBoundedPanelContainer(nodes: List<SnapshotNode>, sourceByPath: Map<String, SnapshotNode>, screenArea: Long): Boolean {
|
||||
fun chain(node: SnapshotNode): List<SnapshotNode> {
|
||||
val result = mutableListOf(node)
|
||||
var current = node.parentPath?.let(sourceByPath::get)
|
||||
while (current != null) {
|
||||
result += current
|
||||
current = current.parentPath?.let(sourceByPath::get)
|
||||
}
|
||||
return result
|
||||
}
|
||||
val others = nodes.drop(1).map { node -> chain(node).map(SnapshotNode::path).toSet() }
|
||||
val common = chain(nodes.first()).firstOrNull { candidate -> others.all { candidate.path in it } } ?: return false
|
||||
val parent = common.parentPath?.let(sourceByPath::get) ?: return false
|
||||
if (parent.path == common.path || common.bounds.width <= 0 || common.bounds.height <= 0) return false
|
||||
return screenArea == 0L || common.bounds.width.toLong() * common.bounds.height < screenArea
|
||||
}
|
||||
|
||||
private fun orderSubmitCandidatesAncestorFree(candidate: SnapshotNode, source: List<SnapshotNode>, aliases: List<String>): Boolean {
|
||||
val candidatePrefix = "${candidate.path}/"
|
||||
return source.none { node ->
|
||||
@@ -1572,6 +1789,8 @@ class PddProductDetailCollector(
|
||||
"type=${it.specPanelType.name};scroll=${it.panelScrollableCount};head=${it.panelHeadingCount};" +
|
||||
"option=${it.panelOptionCount};summary=${it.hasSelectionSummary.toInt()};" +
|
||||
"quantity=${it.hasQuantityControls.toInt()};submit=${it.hasOrderSubmitAction.toInt()};" +
|
||||
"addr=${it.hasAddressEntry.toInt()};pay=${it.hasPaymentEntry.toInt()};qin=${it.quantityInputCount};" +
|
||||
"adj=${it.hasQuantityAdjustControls.toInt()};close=${it.hasCloseControl.toInt()};" +
|
||||
"changed=${screenChanged.toInt()};" +
|
||||
"recovery=${when {
|
||||
recoverySucceeded -> "success"
|
||||
|
||||
+94
-13
@@ -40,6 +40,8 @@ class PurchaseLiveException(val code: String, message: String, val actualUnitPri
|
||||
class PurchaseLiveAutomation(
|
||||
private val driver: PurchaseUiDriver,
|
||||
private val pause: (Long) -> Unit = Thread::sleep,
|
||||
/** Boolean/count-only diagnostics; never receives address text. */
|
||||
private val panelDiagnostic: (String) -> Unit = {},
|
||||
) {
|
||||
private var submitAttempted = false
|
||||
var lastOrderReadFailure: PurchaseOrderReadFailure? = null
|
||||
@@ -523,7 +525,7 @@ 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()
|
||||
val savedEvidence = waitForStableAddressEditorExit(expected, suffix)
|
||||
if (!hasFinalSavedAddressEvidence(savedEvidence, expected, suffix)) {
|
||||
if (isPurchaseConfirmationPanel(savedEvidence)) {
|
||||
restoreFinalEvidenceInCurrentPanel(savedEvidence, expected, suffix)
|
||||
@@ -540,11 +542,7 @@ class PurchaseLiveAutomation(
|
||||
private fun isPurchaseConfirmationPanel(snapshot: UiSnapshot): Boolean {
|
||||
if (snapshot.packageName != PDD_PACKAGE || shippingAddressEditors(snapshot).isNotEmpty()) return false
|
||||
val screen = PddScreenParser.parse(snapshot, PurchaseRehearsalExecutor.DEFAULT_COLLECTOR, "", null)
|
||||
return screen.specPanelType in setOf(
|
||||
SpecPanelType.NORMAL_SCROLLABLE,
|
||||
SpecPanelType.NON_SCROLLABLE_CONFIRMATION,
|
||||
SpecPanelType.ORDER_CONFIRMATION,
|
||||
)
|
||||
return screen.specPanelType in PURCHASE_CONFIRMATION_PANEL_TYPES
|
||||
}
|
||||
|
||||
private fun restoreFinalEvidenceInCurrentPanel(
|
||||
@@ -598,14 +596,14 @@ class PurchaseLiveAutomation(
|
||||
private fun hasFinalSavedAddressEvidence(snapshot: UiSnapshot, expected: String, suffix: String): Boolean =
|
||||
hasSavedAddressEvidence(snapshot, expected, suffix) && finalSubmitTargets(snapshot).size == 1
|
||||
|
||||
private fun waitForStableAddressEditorExit(): UiSnapshot {
|
||||
private fun waitForStableAddressEditorExit(expected: String, suffix: String): UiSnapshot {
|
||||
var consecutiveExits = 0
|
||||
repeat(50) {
|
||||
val snapshot = driver.capture()
|
||||
pageProblem(snapshot)
|
||||
if (snapshot.packageName == PDD_PACKAGE && shippingAddressEditors(snapshot).isEmpty()) {
|
||||
consecutiveExits++
|
||||
if (consecutiveExits >= 2) return snapshot
|
||||
if (consecutiveExits >= 2) return waitForSettledPanelAfterAddressSave(snapshot, expected, suffix)
|
||||
} else {
|
||||
consecutiveExits = 0
|
||||
}
|
||||
@@ -614,6 +612,68 @@ class PurchaseLiveAutomation(
|
||||
fail("PURCHASE_ADDRESS_SAVE_TIMEOUT", "地址保存超时,未创建订单")
|
||||
}
|
||||
|
||||
/**
|
||||
* #331: after the editor closes PDD may still be rebuilding the purchase
|
||||
* sheet. Accept immediately when the final evidence or a stronger panel type
|
||||
* is already present. A sheet recognized only through the required-evidence
|
||||
* fallback must also be structurally stable for two consecutive samples. A
|
||||
* stable page without any purchase-sheet marker (quantity input or payment
|
||||
* entry), e.g. the address list, keeps the existing caller path. A partially
|
||||
* loaded sheet is waited for up to [SpecPanelStabilityPolicy.WAIT_BOUND_MS]
|
||||
* without pressing Back and then fails explicitly.
|
||||
*/
|
||||
private fun waitForSettledPanelAfterAddressSave(initial: UiSnapshot, expected: String, suffix: String): UiSnapshot {
|
||||
var snapshot = initial
|
||||
var previousSignature: String? = null
|
||||
var waitedMs = 0L
|
||||
var samples = 0
|
||||
while (true) {
|
||||
samples++
|
||||
val screen = PddScreenParser.parse(snapshot, PurchaseRehearsalExecutor.DEFAULT_COLLECTOR, "", null)
|
||||
val editorGone = snapshot.packageName == PDD_PACKAGE && shippingAddressEditors(snapshot).isEmpty()
|
||||
val signature = structureSignature(snapshot)
|
||||
val stable = editorGone && signature == previousSignature
|
||||
val outcome = when {
|
||||
editorGone && hasFinalSavedAddressEvidence(snapshot, expected, suffix) -> "final_evidence"
|
||||
editorGone && screen.specPanelType in STRONG_PURCHASE_PANEL_TYPES -> "recognized"
|
||||
stable && screen.specPanelType == SpecPanelType.REQUIRED_EVIDENCE -> "stable_required"
|
||||
stable && !screen.hasQuantityInput && !screen.hasPaymentEntry -> "non_panel"
|
||||
else -> null
|
||||
}
|
||||
if (outcome != null) {
|
||||
addressSaveDiagnostic(outcome, waitedMs, samples, screen)
|
||||
return snapshot
|
||||
}
|
||||
if (waitedMs >= SpecPanelStabilityPolicy.WAIT_BOUND_MS) {
|
||||
addressSaveDiagnostic("timeout", waitedMs, samples, screen)
|
||||
fail(
|
||||
"PURCHASE_ADDRESS_SAVE_TIMEOUT",
|
||||
"地址保存后规格面板未完整加载,未按返回键,未创建订单 [waitedMs=$waitedMs;samples=$samples;${screen.requiredEvidenceSummary()}]${screen.requiredEvidenceHintSuffix()}",
|
||||
)
|
||||
}
|
||||
previousSignature = signature
|
||||
pause(SpecPanelStabilityPolicy.ADDRESS_SAVE_POLL_MS)
|
||||
waitedMs += SpecPanelStabilityPolicy.ADDRESS_SAVE_POLL_MS
|
||||
snapshot = driver.capture()
|
||||
pageProblem(snapshot)
|
||||
}
|
||||
}
|
||||
|
||||
private fun addressSaveDiagnostic(outcome: String, waitedMs: Long, samples: Int, screen: ParsedPddScreen) {
|
||||
panelDiagnostic(
|
||||
"addressSaveWait;outcome=$outcome;waitedMs=$waitedMs;samples=$samples;backPressed=false;" +
|
||||
"type=${screen.specPanelType};${screen.requiredEvidenceSummary()}",
|
||||
)
|
||||
}
|
||||
|
||||
/** In-memory structure only (path, class, bounds, flags); no text is included. */
|
||||
private fun structureSignature(snapshot: UiSnapshot): String = snapshot.nodes
|
||||
.filter { it.visible }
|
||||
.joinToString("|") { node ->
|
||||
listOf(node.path, node.className.orEmpty(), node.bounds.left, node.bounds.top, node.bounds.right, node.bounds.bottom, node.clickable, node.scrollable, node.enabled)
|
||||
.joinToString(":")
|
||||
}
|
||||
|
||||
private fun normalizeAddressText(value: String): String = value.filterNot(Char::isWhitespace)
|
||||
|
||||
private fun hasExactTaskSuffix(value: String, suffix: String): Boolean =
|
||||
@@ -716,10 +776,25 @@ class PurchaseLiveAutomation(
|
||||
pause(500)
|
||||
}
|
||||
|
||||
private fun finalSubmitTargets(snapshot: UiSnapshot): List<SnapshotNode> = uniqueClickable(
|
||||
snapshot,
|
||||
snapshot.nodes.filter { node -> node.visible && node.enabled && FINAL_SUBMIT_MARKERS.any { node.label == it || node.label.startsWith(it) } },
|
||||
)
|
||||
private fun finalSubmitTargets(snapshot: UiSnapshot): List<SnapshotNode> {
|
||||
val byPath = snapshot.nodes.associateBy { it.path }
|
||||
fun hasArea(node: SnapshotNode) = node.bounds.width > 0 && node.bounds.height > 0
|
||||
// #331: recognition may treat the submit action as auxiliary, but the
|
||||
// final click target stays strict. A zero-size label or a zero-size
|
||||
// clickable container is never a click target.
|
||||
fun clickableAncestorHasArea(node: SnapshotNode): Boolean {
|
||||
var current: SnapshotNode? = node
|
||||
while (current != null && !current.clickable) current = current.parentPath?.let(byPath::get)
|
||||
return current != null && hasArea(current)
|
||||
}
|
||||
return uniqueClickable(
|
||||
snapshot,
|
||||
snapshot.nodes.filter { node ->
|
||||
node.visible && node.enabled && hasArea(node) && clickableAncestorHasArea(node) &&
|
||||
FINAL_SUBMIT_MARKERS.any { node.label == it || node.label.startsWith(it) }
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
private fun orderConfirmationReady(snapshot: UiSnapshot): Boolean {
|
||||
if (snapshot.packageName != PDD_PACKAGE) return false
|
||||
@@ -793,7 +868,13 @@ class PurchaseLiveAutomation(
|
||||
private companion object {
|
||||
const val PDD_PACKAGE = "com.xunmeng.pinduoduo"
|
||||
const val WECHAT_PACKAGE = "com.tencent.mm"
|
||||
val MASKED_PHONE = Regex("(?<![0-9])[0-9]{3}\\*{4}[0-9]{4}(?![0-9])")
|
||||
val MASKED_PHONE = PddScreenParser.MASKED_PHONE_PATTERN
|
||||
val STRONG_PURCHASE_PANEL_TYPES = setOf(
|
||||
SpecPanelType.NORMAL_SCROLLABLE,
|
||||
SpecPanelType.NON_SCROLLABLE_CONFIRMATION,
|
||||
SpecPanelType.ORDER_CONFIRMATION,
|
||||
)
|
||||
val PURCHASE_CONFIRMATION_PANEL_TYPES = STRONG_PURCHASE_PANEL_TYPES + SpecPanelType.REQUIRED_EVIDENCE
|
||||
val FINAL_SUBMIT_MARKERS = listOf("提交订单", "现在买,仅", "确认购买")
|
||||
val PAYMENT_MARKERS = listOf("立即支付", "确认支付", "输入支付密码")
|
||||
val UNPAID_MARKERS = listOf("待付款", "待支付", "去支付")
|
||||
|
||||
+58
-7
@@ -100,7 +100,7 @@ class PurchaseRehearsalExecutor(
|
||||
var addressProof: ShippingAddressProof? = null
|
||||
var irreversibleStarted = false
|
||||
val specSelectionProofs = mutableMapOf<String, ExactSpecSelectionProof>()
|
||||
val live = PurchaseLiveAutomation(driver, pause)
|
||||
val live = PurchaseLiveAutomation(driver, pause, panelDiagnostic)
|
||||
for (action in rule.actions) {
|
||||
// The immediate phase-two handoff can reuse the PDD page retained by
|
||||
// spec_probe. A later manual retry may start from Agent (or another
|
||||
@@ -394,7 +394,7 @@ class PurchaseRehearsalExecutor(
|
||||
if (wait.changed) {
|
||||
return failure(
|
||||
SPEC_PANEL_EVIDENCE_NOT_MATCHED,
|
||||
"规格入口点击后页面已变化,但规格面板强证据不足 [${panelEvidence(wait.screen)}]",
|
||||
"规格入口点击后页面已变化,但规格面板强证据不足 [${panelEvidence(wait.screen)}]${wait.screen.requiredEvidenceHintSuffix()}",
|
||||
)
|
||||
}
|
||||
|
||||
@@ -415,7 +415,7 @@ class PurchaseRehearsalExecutor(
|
||||
if (wait.changed) {
|
||||
return failure(
|
||||
SPEC_PANEL_EVIDENCE_NOT_MATCHED,
|
||||
"规格入口手势后页面已变化,但规格面板强证据不足 [${panelEvidence(wait.screen)}]",
|
||||
"规格入口手势后页面已变化,但规格面板强证据不足 [${panelEvidence(wait.screen)}]${wait.screen.requiredEvidenceHintSuffix()}",
|
||||
)
|
||||
}
|
||||
return failure(
|
||||
@@ -446,17 +446,51 @@ class PurchaseRehearsalExecutor(
|
||||
private fun waitForSpecPanel(input: PurchaseExecutionInput, beforeSignature: List<SpecActionNodeSignature>): SpecPanelWait {
|
||||
var last = currentScreen(input)
|
||||
var changed = false
|
||||
repeat(SPEC_POST_CLICK_VERIFY_POLLS) {
|
||||
var polls = 0
|
||||
var waitedMs = 0L
|
||||
var requiredEvidenceSeen = false
|
||||
var previousStructure: List<SpecActionNodeSignature>? = null
|
||||
fun waitDiagnostic(outcome: String) {
|
||||
if (requiredEvidenceSeen) {
|
||||
panelDiagnostic(
|
||||
"specPanelWait;outcome=$outcome;waitedMs=$waitedMs;samples=$polls;backPressed=false;" +
|
||||
last.requiredEvidenceSummary() + last.requiredEvidenceHintSuffix(),
|
||||
)
|
||||
}
|
||||
}
|
||||
while (true) {
|
||||
last = currentScreen(input)
|
||||
panelDiagnostic(panelEvidence(last))
|
||||
if (last.reviewPageOpen) {
|
||||
return SpecPanelWait(last, false, true, leaveUnexpectedReviewPage(input))
|
||||
}
|
||||
last.problem?.let { return SpecPanelWait(last, false, true, failure(it.code, it.message)) }
|
||||
if (last.specPanelOpen) return SpecPanelWait(last, true, true)
|
||||
changed = changed || specActionSignature(last) != beforeSignature
|
||||
val structure = specActionSignature(last)
|
||||
// Stronger panel types keep their immediate acceptance. The #331
|
||||
// fallback type additionally needs two identical consecutive
|
||||
// structures so a still-loading sheet is not acted on too early.
|
||||
if (last.specPanelOpen && (last.specPanelType != SpecPanelType.REQUIRED_EVIDENCE || structure == previousStructure)) {
|
||||
waitDiagnostic("opened")
|
||||
return SpecPanelWait(last, true, true)
|
||||
}
|
||||
changed = changed || structure != beforeSignature
|
||||
requiredEvidenceSeen = requiredEvidenceSeen ||
|
||||
last.hasAddressEntry || last.hasPaymentEntry || last.hasQuantityInput
|
||||
previousStructure = structure
|
||||
pause(SPEC_SELECTION_POLL_MILLIS)
|
||||
waitedMs += SPEC_SELECTION_POLL_MILLIS
|
||||
polls++
|
||||
// A partially loaded purchase sheet may take longer than the plain
|
||||
// entry verification window; it gets the bounded #331 wait. No Back
|
||||
// is pressed while waiting; the caller fails explicitly on timeout.
|
||||
val limitReached = if (requiredEvidenceSeen) {
|
||||
waitedMs >= SpecPanelStabilityPolicy.WAIT_BOUND_MS
|
||||
} else {
|
||||
polls >= SPEC_POST_CLICK_VERIFY_POLLS
|
||||
}
|
||||
if (limitReached) break
|
||||
}
|
||||
waitDiagnostic("timeout")
|
||||
return SpecPanelWait(last, false, changed)
|
||||
}
|
||||
|
||||
@@ -478,7 +512,8 @@ class PurchaseRehearsalExecutor(
|
||||
private fun panelEvidence(screen: ParsedPddScreen): String =
|
||||
"type=${screen.specPanelType};scrollables=${screen.panelScrollableCount};headings=${screen.panelHeadingCount};" +
|
||||
"options=${screen.panelOptionCount};summary=${screen.hasSelectionSummary};quantity=${screen.hasQuantityControls};" +
|
||||
"orderAction=${screen.hasOrderSubmitAction};pageEvidence=${screen.pageEvidenceMatched}"
|
||||
"orderAction=${screen.hasOrderSubmitAction};pageEvidence=${screen.pageEvidenceMatched};" +
|
||||
screen.requiredEvidenceSummary()
|
||||
|
||||
private fun specEntryEvidence(screen: ParsedPddScreen, candidateCount: Int, entryReadyWaitPolls: Int = 0): String =
|
||||
"specEntryCandidates=$candidateCount;explicit=${screen.explicitSpecEntryCount};" +
|
||||
@@ -574,9 +609,11 @@ class PurchaseRehearsalExecutor(
|
||||
verification = waitForExactSelection(input, dimension, target, selectionProofs, SPEC_SELECTION_SUCCESS_VERIFY_POLLS)
|
||||
verification.failure?.let { return it }
|
||||
if (verification.confirmed) return null
|
||||
panelDiagnostic(selectionFailureEvidence(currentScreen(input), dimension, target))
|
||||
return failure(SPEC_SELECTION_UNCONFIRMED, "无障碍点击和受控手势后未能确认精确选中状态")
|
||||
}
|
||||
if (outcome.result == FreshActionResult.SUCCESS) {
|
||||
panelDiagnostic(selectionFailureEvidence(currentScreen(input), dimension, target))
|
||||
return failure(SPEC_SELECTION_UNCONFIRMED, "点击规格后未能确认精确选中状态")
|
||||
}
|
||||
val subreason = when (outcome.reason) {
|
||||
@@ -589,6 +626,19 @@ class PurchaseRehearsalExecutor(
|
||||
return failure(SPEC_CLICK_FAILED, subreason)
|
||||
}
|
||||
|
||||
/**
|
||||
* #332/task 535: parsed option texts and selected flags of one dimension.
|
||||
* Spec values are product attributes, not personal data; each is truncated.
|
||||
*/
|
||||
private fun selectionFailureEvidence(screen: ParsedPddScreen, dimension: String, target: String): String {
|
||||
fun clip(value: String) = value.replace(Regex("[\r\n\t;|]+"), " ").take(SELECTION_DIAGNOSTIC_TEXT_CHARS)
|
||||
val values = screen.dimensions.filter { it.key == dimension }.flatMap { it.values }
|
||||
return "specSelectionUnconfirmed;dimension=$dimension;target=${clip(target)};valueCount=${values.size};values=" +
|
||||
values.joinToString("|") { value ->
|
||||
"${clip(value.text)}:${(value.node.selected || value.node.checked).toString().first()}"
|
||||
}
|
||||
}
|
||||
|
||||
private data class ExactSelectionWait(
|
||||
val confirmed: Boolean,
|
||||
val failure: PurchaseExecutionOutcome? = null,
|
||||
@@ -1175,6 +1225,7 @@ class PurchaseRehearsalExecutor(
|
||||
private const val SPEC_SELECTION_SUCCESS_VERIFY_POLLS = 20
|
||||
private const val SPEC_SELECTION_FAILED_VERIFY_POLLS = 5
|
||||
private const val SPEC_SELECTION_POLL_MILLIS = 100L
|
||||
private const val SELECTION_DIAGNOSTIC_TEXT_CHARS = 40
|
||||
private const val SOLD_OUT_PANEL_CLOSE_MILLIS = 300L
|
||||
private const val SPEC_ENTRY_NOT_FOUND = "PURCHASE_SPEC_ENTRY_NOT_FOUND"
|
||||
private const val SPEC_ENTRY_TARGET_AMBIGUOUS = "PURCHASE_SPEC_ENTRY_TARGET_AMBIGUOUS"
|
||||
|
||||
@@ -781,7 +781,8 @@ class PurchaseRehearsalExecutorTest {
|
||||
|
||||
assertEquals("PURCHASE_SPEC_PANEL_EVIDENCE_NOT_MATCHED", outcome.errorCode)
|
||||
assertEquals(
|
||||
"规格入口点击后页面已变化,但规格面板强证据不足 [type=UNKNOWN;scrollables=0;headings=0;options=0;summary=false;quantity=false;orderAction=false;pageEvidence=true]",
|
||||
"规格入口点击后页面已变化,但规格面板强证据不足 [type=UNKNOWN;scrollables=0;headings=0;options=0;summary=false;quantity=false;orderAction=false;pageEvidence=true;" +
|
||||
"addr=0;pay=0;qin=0;adj=0;summary=0;close=0;submit=0;same=0]",
|
||||
outcome.message,
|
||||
)
|
||||
assertEquals(0, driver.specTapCount)
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
package cn.ilapage.goauto.agent
|
||||
|
||||
import cn.ilapage.goauto.agent.automation.FreshActionResult
|
||||
import cn.ilapage.goauto.agent.automation.PddScreenParser
|
||||
import cn.ilapage.goauto.agent.automation.PurchaseAgentCapabilities
|
||||
import cn.ilapage.goauto.agent.automation.PurchaseExecutionInput
|
||||
import cn.ilapage.goauto.agent.automation.PurchaseRehearsalExecutor
|
||||
import cn.ilapage.goauto.agent.automation.PurchaseRuleParser
|
||||
import cn.ilapage.goauto.agent.automation.PurchaseUiDriver
|
||||
import cn.ilapage.goauto.agent.automation.SnapshotNode
|
||||
import cn.ilapage.goauto.agent.automation.SwipeDirection
|
||||
import cn.ilapage.goauto.agent.automation.UiSnapshot
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/** #332 spec-option dedup (task 535) and the selection-failure diagnostic. */
|
||||
class SpecOptionDedupTest {
|
||||
private fun parse(snapshot: UiSnapshot) =
|
||||
PddScreenParser.parse(snapshot, PurchaseRehearsalExecutor.DEFAULT_COLLECTOR, "719834019024", null)
|
||||
|
||||
@Test
|
||||
fun `nested clickable nodes of one option block form one value`() {
|
||||
val screen = parse(SpecPanelFixtures.taskOptionDedupSheet())
|
||||
val colors = screen.dimensions.single { it.key == "color" }.values
|
||||
val sizes = screen.dimensions.single { it.key == "size" }.values
|
||||
|
||||
assertEquals(listOf("兰条纹", "白条纹"), colors.map { it.text })
|
||||
assertTrue(colors.single { it.text == "兰条纹" }.node.selected)
|
||||
assertFalse(colors.single { it.text == "白条纹" }.node.selected)
|
||||
assertEquals(5, sizes.size)
|
||||
assertEquals(listOf("2XL建议130-150斤"), sizes.filter { it.node.selected }.map { it.rawText })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unconfirmed selection reports parsed values selected flags and target`() {
|
||||
// The click never changes the page, so the white option stays unselected.
|
||||
val driver = object : PurchaseUiDriver {
|
||||
var opened = false
|
||||
override fun capture(): UiSnapshot =
|
||||
if (opened) SpecPanelFixtures.taskOptionDedupSheet() else SpecPanelFixtures.productDetailPage()
|
||||
override fun clickFresh(target: SnapshotNode): FreshActionResult {
|
||||
if (target.label.startsWith("请选择")) opened = true
|
||||
return FreshActionResult.SUCCESS
|
||||
}
|
||||
override fun tapPurchaseFresh(target: SnapshotNode) = FreshActionResult.FAILED
|
||||
override fun inputFresh(target: SnapshotNode, value: String) = FreshActionResult.FAILED
|
||||
override fun swipePurchase(direction: SwipeDirection, durationMs: Long) = false
|
||||
override fun swipePurchaseIn(target: SnapshotNode, direction: SwipeDirection, durationMs: Long) = false
|
||||
override fun backPurchase() = false
|
||||
}
|
||||
val diagnostics = mutableListOf<String>()
|
||||
|
||||
val outcome = PurchaseRehearsalExecutor(driver, { true }, { null }, pause = {}, panelDiagnostic = diagnostics::add)
|
||||
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
|
||||
|
||||
assertEquals("PURCHASE_SPEC_SELECTION_UNCONFIRMED", outcome.errorCode)
|
||||
val evidence = diagnostics.last { it.startsWith("specSelectionUnconfirmed;") }
|
||||
assertTrue(evidence, evidence.contains("dimension=color;target=白条纹;valueCount=2;values=兰条纹:t|白条纹:f"))
|
||||
assertFalse(evidence.contains("****"))
|
||||
}
|
||||
|
||||
private fun input() = PurchaseExecutionInput(
|
||||
taskId = 332,
|
||||
executionMode = "rehearsal",
|
||||
phase = "purchase",
|
||||
url = "https://mobile.yangkeduo.com/goods.html?goods_id=719834019024",
|
||||
goodsId = "719834019024",
|
||||
mappedColor = "白条纹",
|
||||
mappedSize = "",
|
||||
quantity = 1,
|
||||
minUnitPriceCent = 1_000,
|
||||
maxUnitPriceCent = 3_000,
|
||||
)
|
||||
|
||||
private fun rule() = """{
|
||||
"schemaVersion":1,
|
||||
"ruleType":"pddPurchase",
|
||||
"requiredCapabilities":["purchase.rehearsal.v1"],
|
||||
"actions":[
|
||||
{"type":"openProduct"},{"type":"verifyProduct"},{"type":"openSpecPanel"},{"type":"selectSpec"},
|
||||
{"type":"setQuantity"},{"type":"verifyUnitPrice"},{"type":"verifyOrderSummary"}
|
||||
]
|
||||
}"""
|
||||
}
|
||||
@@ -0,0 +1,317 @@
|
||||
package cn.ilapage.goauto.agent
|
||||
|
||||
import cn.ilapage.goauto.agent.automation.NodeBounds
|
||||
import cn.ilapage.goauto.agent.automation.SnapshotNode
|
||||
import cn.ilapage.goauto.agent.automation.UiSnapshot
|
||||
|
||||
/**
|
||||
* Sanitized spec-panel fixtures (#332 option dedup, #331 recognition). The layout mirrors the structure of real
|
||||
* PDD purchase sheets, but every name, phone number and address is fake.
|
||||
*/
|
||||
internal object SpecPanelFixtures {
|
||||
const val PDD = "com.xunmeng.pinduoduo"
|
||||
const val ACTIVITY = "com.xunmeng.pinduoduo.activity.NewPageActivity"
|
||||
const val FAKE_PHONE = "139****0000"
|
||||
|
||||
class Tree(private val screenBottom: Int) {
|
||||
val nodes = mutableListOf<SnapshotNode>()
|
||||
|
||||
init {
|
||||
add("r", "", NodeBounds(0, 0, 1080, screenBottom), className = "android.widget.FrameLayout")
|
||||
}
|
||||
|
||||
fun add(
|
||||
path: String,
|
||||
text: String,
|
||||
bounds: NodeBounds,
|
||||
className: String = "android.widget.TextView",
|
||||
clickable: Boolean = false,
|
||||
scrollable: Boolean = false,
|
||||
selected: Boolean = false,
|
||||
visible: Boolean = true,
|
||||
enabled: Boolean = true,
|
||||
description: String? = null,
|
||||
) {
|
||||
val parent = path.substringBeforeLast('/', "").ifEmpty { null }
|
||||
nodes += SnapshotNode(
|
||||
path, parent, text, description, null, className, bounds,
|
||||
clickable, scrollable, selected, false, enabled, visible,
|
||||
)
|
||||
}
|
||||
|
||||
fun snapshot(packageName: String = PDD, activity: String = ACTIVITY) = UiSnapshot(packageName, activity, nodes.toList())
|
||||
}
|
||||
|
||||
/** Options for the purchase sheet shaped like the two unselected real samples. */
|
||||
data class Sheet(
|
||||
val screenBottom: Int = 2216,
|
||||
val address: Boolean = true,
|
||||
val payment: Boolean = true,
|
||||
val quantityInput: Boolean = true,
|
||||
val adjustButtons: Boolean = true,
|
||||
val close: Boolean = true,
|
||||
val summary: Boolean = true,
|
||||
val sizeDimension: Boolean = true,
|
||||
val listScrollable: Boolean = true,
|
||||
/** "visible" = normal submit bar; "hidden" = Samsung-like 31px bar whose [0,0][0,0] label is not visible; "none". */
|
||||
val submit: String = "visible",
|
||||
val offset: Int = 0,
|
||||
val colorLabels: List<String> = listOf("黑色 示例款", "卡其色 示例款"),
|
||||
val sizeLabels: List<String> = listOf("S 建议75-90斤", "M 建议90-105斤", "L 建议105-115斤"),
|
||||
)
|
||||
|
||||
fun sheet(options: Sheet = Sheet()): UiSnapshot {
|
||||
val o = options.offset
|
||||
val t = Tree(options.screenBottom)
|
||||
t.add("r/sheet", "", NodeBounds(0, 84, 1080, options.screenBottom), "android.widget.LinearLayout", clickable = true)
|
||||
t.add("r/sheet/top", "", NodeBounds(0, 219, 1080, 330), "android.widget.ViewSwitcher", clickable = true)
|
||||
t.add("r/sheet/top/tags", "#示例标签#七天退换", NodeBounds(193, 240, 886, 308))
|
||||
if (options.close) {
|
||||
t.add("r/sheet/close", "", NodeBounds(975, 237, 1050, 312), "android.widget.ImageView", clickable = true, description = "关闭")
|
||||
}
|
||||
t.add("r/sheet/body", "", NodeBounds(0, 330, 1080, options.screenBottom), "android.view.ViewGroup", clickable = true)
|
||||
if (options.address) {
|
||||
// Address row: clickable row five levels above the masked phone text.
|
||||
t.add("r/sheet/body/addr", "", NodeBounds(0, 330 + o, 1080, 484 + o), "android.view.ViewGroup", clickable = true)
|
||||
t.add("r/sheet/body/addr/a", "", NodeBounds(0, 340 + o, 1080, 470 + o), "android.widget.LinearLayout")
|
||||
t.add("r/sheet/body/addr/a/b", "", NodeBounds(36, 340 + o, 1044, 400 + o), "android.widget.LinearLayout")
|
||||
t.add("r/sheet/body/addr/a/b/c", "", NodeBounds(36, 340 + o, 1044, 400 + o), "android.widget.LinearLayout")
|
||||
t.add("r/sheet/body/addr/a/b/c/tag", "7天无理由退货", NodeBounds(138, 348 + o, 394, 394 + o))
|
||||
t.add("r/sheet/body/addr/a/b/c/phone", "测试,$FAKE_PHONE,示例省示例市", NodeBounds(412, 346 + o, 993, 395 + o))
|
||||
t.add("r/sheet/body/addr/a/detail", "示例区示例街道示例路1号", NodeBounds(132, 408 + o, 937, 457 + o))
|
||||
}
|
||||
t.add("r/sheet/body/image", "", NodeBounds(36, 638, 360, 962), "android.widget.FrameLayout", clickable = true, description = "商品主图")
|
||||
t.add("r/sheet/body/price", "¥19.6", NodeBounds(384, 640, 523, 707))
|
||||
if (options.summary) t.add("r/sheet/body/summary", "请选择: 颜色分类 尺码", NodeBounds(384, 791, 1068, 852))
|
||||
t.add("r/sheet/body/qty", "", NodeBounds(384, 888, 633, 963), "android.widget.LinearLayout")
|
||||
if (options.adjustButtons) {
|
||||
t.add("r/sheet/body/qty/dec", "", NodeBounds(384, 888, 462, 963), "android.widget.ImageView", clickable = true, description = "减少数量")
|
||||
t.add("r/sheet/body/qty/inc", "", NodeBounds(555, 888, 633, 963), "android.widget.ImageView", clickable = true, description = "增加数量")
|
||||
}
|
||||
if (options.quantityInput) {
|
||||
t.add("r/sheet/body/qty/input", "1", NodeBounds(468, 888, 549, 963), "android.widget.EditText", clickable = true)
|
||||
}
|
||||
val list = "r/sheet/body/list"
|
||||
t.add(list, "", NodeBounds(0, 1136, 1080, 2036), "androidx.recyclerview.widget.RecyclerView", scrollable = options.listScrollable)
|
||||
t.add("$list/color", "", NodeBounds(0, 1136, 1080, 1686), "android.widget.LinearLayout")
|
||||
t.add("$list/color/h", "颜色分类", NodeBounds(36, 1161, 216, 1222))
|
||||
options.colorLabels.forEachIndexed { index, label ->
|
||||
val step = if (options.colorLabels.size > 3) 258 else 346
|
||||
val w = if (options.colorLabels.size > 3) 240 else 316
|
||||
val left = 36 + index * step
|
||||
val block = "$list/color/o$index"
|
||||
t.add(block, "", NodeBounds(left, 1247, left + w, 1650), "android.view.ViewGroup", clickable = true, description = label)
|
||||
t.add("$block/img", "", NodeBounds(left, 1247, left + w, 1563), "android.widget.ImageView", clickable = true, description = label)
|
||||
t.add("$block/big", "", NodeBounds(left, 1247, left + 111, 1358), "android.widget.ImageView", clickable = true, description = "打开大图")
|
||||
t.add("$block/l", "", NodeBounds(left, 1563, left + w, 1650), "android.widget.LinearLayout")
|
||||
t.add("$block/l/t", label, NodeBounds(left, 1563, left + w, 1650), clickable = true)
|
||||
}
|
||||
if (options.sizeDimension) {
|
||||
t.add("$list/size", "", NodeBounds(0, 1686, 1080, 2036), "android.widget.LinearLayout")
|
||||
t.add("$list/size/row", "", NodeBounds(36, 1686, 1044, 1767), "android.widget.LinearLayout", clickable = true)
|
||||
t.add("$list/size/row/h", "尺码", NodeBounds(36, 1700, 126, 1753))
|
||||
t.add("$list/size/row/hint", "查看尺码建议", NodeBounds(150, 1702, 402, 1751))
|
||||
options.sizeLabels.forEachIndexed { index, label ->
|
||||
val top = 1779 + (index / 2) * 85
|
||||
val left = 36 + (index % 2) * 420
|
||||
t.add("$list/size/o$index", "", NodeBounds(left, top, left + 364, top + 80), "android.view.ViewGroup", clickable = true)
|
||||
t.add("$list/size/o$index/t", label, NodeBounds(left, top, left + 364, top + 80), clickable = true)
|
||||
}
|
||||
}
|
||||
if (options.payment) {
|
||||
t.add("r/sheet/pay", "", NodeBounds(0, 2036, 1080, 2135), "android.view.ViewGroup", clickable = true)
|
||||
t.add("r/sheet/pay/l", "", NodeBounds(94, 2057, 930, 2114), "android.widget.LinearLayout")
|
||||
t.add("r/sheet/pay/l/t", "使用#微信支付,更换先用后付可0元下单", NodeBounds(112, 2057, 930, 2114))
|
||||
}
|
||||
when (options.submit) {
|
||||
"visible" -> {
|
||||
t.add("r/sheet/submit", "", NodeBounds(0, 2135, 1080, options.screenBottom), "android.widget.FrameLayout", clickable = true)
|
||||
t.add("r/sheet/submit/l", "", NodeBounds(145, 2135, 934, options.screenBottom), "android.widget.LinearLayout")
|
||||
t.add("r/sheet/submit/l/t", "选择颜色分类及尺码后,提交订单", NodeBounds(157, 2179, 922, options.screenBottom))
|
||||
}
|
||||
"hidden" -> {
|
||||
t.add("r/sheet/submit", "", NodeBounds(0, options.screenBottom - 31, 1080, options.screenBottom), "android.widget.FrameLayout", clickable = true)
|
||||
t.add("r/sheet/submit/l", "", NodeBounds(0, 0, 0, 0), "android.widget.LinearLayout", visible = false)
|
||||
t.add("r/sheet/submit/l/t", "选择颜色分类及尺码后,提交订单", NodeBounds(0, 0, 0, 0), visible = false)
|
||||
}
|
||||
}
|
||||
return t.snapshot()
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimal fallback sheet: no bounded scrollable, a single heading, no
|
||||
* summary, no +/- and no readable submit. Only the #331 required-evidence
|
||||
* fallback can recognize it. (Not the goods 8580 shape; see [sample8580ShapedSheet].)
|
||||
*/
|
||||
fun liveShapedSheet(options: Sheet = Sheet()): UiSnapshot = sheet(
|
||||
options.copy(summary = false, listScrollable = false, sizeDimension = false, adjustButtons = false, submit = "hidden"),
|
||||
)
|
||||
|
||||
/**
|
||||
* Shape of the 13 goods 8580 failures reported in #331:
|
||||
* `type=UNKNOWN;scrollables=1;headings=2;options=9;summary=false;quantity=true;orderAction=false;pageEvidenceMatched=true`.
|
||||
* One bounded scrollable, two headings, nine options, +/- and input present,
|
||||
* but no selection summary and no readable submit action.
|
||||
*/
|
||||
fun sample8580ShapedSheet(options: Sheet = Sheet()): UiSnapshot = sheet(
|
||||
options.copy(
|
||||
summary = false,
|
||||
submit = "hidden",
|
||||
colorLabels = listOf("黑色 示例款", "卡其色 示例款", "白色 示例款", "灰色 示例款"),
|
||||
sizeLabels = listOf("S 示例", "M 示例", "L 示例", "XL 示例", "2XL 示例"),
|
||||
),
|
||||
)
|
||||
|
||||
/**
|
||||
* #331 counter-example: a masked-phone row inside an address dialog, a
|
||||
* payment row inside a separate payment dialog and a quantity input in a
|
||||
* third place, together with a parsed option group. Every item exists but
|
||||
* they do not share one panel container.
|
||||
*/
|
||||
fun scatteredRequiredItemsPage(fullScreenWrapper: Boolean = false): UiSnapshot {
|
||||
val t = Tree(2216)
|
||||
val root = if (fullScreenWrapper) {
|
||||
t.add("r/content", "", NodeBounds(0, 0, 1080, 2216), "android.widget.FrameLayout")
|
||||
"r/content"
|
||||
} else "r"
|
||||
t.add("$root/addrDialog", "", NodeBounds(60, 200, 1020, 700), "android.widget.FrameLayout")
|
||||
t.add("$root/addrDialog/title", "选择收货地址", NodeBounds(360, 220, 720, 290))
|
||||
t.add("$root/addrDialog/row", "", NodeBounds(60, 320, 1020, 470), "android.view.ViewGroup", clickable = true)
|
||||
t.add("$root/addrDialog/row/phone", "测试,$FAKE_PHONE,示例省示例市", NodeBounds(100, 340, 980, 400))
|
||||
t.add("$root/payDialog", "", NodeBounds(60, 800, 1020, 1200), "android.widget.FrameLayout")
|
||||
t.add("$root/payDialog/row", "", NodeBounds(60, 900, 1020, 1010), "android.view.ViewGroup", clickable = true)
|
||||
t.add("$root/payDialog/row/t", "微信支付", NodeBounds(120, 920, 400, 990))
|
||||
t.add("$root/other", "", NodeBounds(0, 1250, 1080, 2216), "android.widget.LinearLayout")
|
||||
t.add("$root/other/close", "", NodeBounds(975, 1260, 1050, 1335), "android.widget.ImageView", clickable = true, description = "关闭")
|
||||
t.add("$root/other/input", "1", NodeBounds(468, 1350, 549, 1425), "android.widget.EditText", clickable = true)
|
||||
t.add("$root/other/h", "颜色分类", NodeBounds(36, 1500, 216, 1560))
|
||||
t.add("$root/other/o0", "黑色 示例款", NodeBounds(36, 1600, 352, 1700), clickable = true)
|
||||
t.add("$root/other/o1", "白色 示例款", NodeBounds(382, 1600, 698, 1700), clickable = true)
|
||||
return t.snapshot()
|
||||
}
|
||||
|
||||
/** Task 535 layout: the selected option block exposes an outer block, an image and an inner text with a badge. */
|
||||
fun taskOptionDedupSheet(): UiSnapshot {
|
||||
val t = Tree(2328)
|
||||
t.add("r/sheet", "", NodeBounds(0, 120, 1080, 2328), "android.widget.LinearLayout", clickable = true)
|
||||
t.add("r/sheet/close", "", NodeBounds(975, 273, 1050, 348), "android.widget.ImageView", clickable = true, description = "关闭")
|
||||
t.add("r/sheet/body", "", NodeBounds(0, 366, 1080, 2328), "android.view.ViewGroup", clickable = true)
|
||||
t.add("r/sheet/body/addr", "", NodeBounds(0, 366, 1080, 520), "android.view.ViewGroup", clickable = true)
|
||||
t.add("r/sheet/body/addr/a", "", NodeBounds(0, 376, 1080, 510), "android.widget.LinearLayout")
|
||||
t.add("r/sheet/body/addr/a/phone", "测试,$FAKE_PHONE,示例省示例市", NodeBounds(412, 382, 993, 431))
|
||||
t.add("r/sheet/body/addr/a/detail", "示例区示例路2号", NodeBounds(132, 444, 993, 493))
|
||||
t.add("r/sheet/body/price", "快抢光 ¥23.99", NodeBounds(396, 568, 722, 635))
|
||||
t.add("r/sheet/body/selected", "#快抢光#兰条纹 2XL建议130-150斤", NodeBounds(396, 719, 1053, 781))
|
||||
t.add("r/sheet/body/qty", "", NodeBounds(396, 817, 614, 895), "android.widget.LinearLayout")
|
||||
t.add("r/sheet/body/qty/dec", "", NodeBounds(396, 817, 462, 895), "android.widget.ImageView", clickable = true, description = "减少数量")
|
||||
t.add("r/sheet/body/qty/input", "1", NodeBounds(463, 817, 547, 895), "android.widget.EditText", clickable = true)
|
||||
t.add("r/sheet/body/qty/inc", "", NodeBounds(548, 817, 614, 895), "android.widget.ImageView", clickable = true, description = "增加数量")
|
||||
val list = "r/sheet/body/list"
|
||||
t.add(list, "", NodeBounds(0, 933, 1080, 2079), "androidx.recyclerview.widget.RecyclerView", scrollable = true)
|
||||
t.add("$list/color", "", NodeBounds(0, 933, 1080, 1483), "android.widget.LinearLayout")
|
||||
t.add("$list/color/h", "颜色分类", NodeBounds(36, 958, 216, 1019))
|
||||
val blue = "$list/color/o0"
|
||||
t.add(blue, "", NodeBounds(36, 1044, 352, 1447), "android.view.ViewGroup", clickable = true, selected = true, description = " 兰条纹")
|
||||
t.add("$blue/img", "", NodeBounds(36, 1044, 352, 1360), "android.widget.ImageView", clickable = true, selected = true, description = " 兰条纹")
|
||||
t.add("$blue/big", "", NodeBounds(36, 1044, 147, 1155), "android.widget.ImageView", clickable = true, selected = true, description = "打开大图")
|
||||
t.add("$blue/l", "", NodeBounds(36, 1339, 352, 1447), "android.widget.LinearLayout", selected = true)
|
||||
t.add("$blue/l/t", " 兰条纹\n 零差评", NodeBounds(36, 1339, 352, 1447), clickable = true, selected = true)
|
||||
val white = "$list/color/o1"
|
||||
t.add(white, "", NodeBounds(382, 1044, 698, 1447), "android.view.ViewGroup", clickable = true, description = "白条纹")
|
||||
t.add("$white/img", "", NodeBounds(382, 1044, 698, 1360), "android.widget.ImageView", clickable = true, description = "白条纹")
|
||||
t.add("$white/big", "", NodeBounds(382, 1044, 493, 1155), "android.widget.ImageView", clickable = true, description = "打开大图")
|
||||
t.add("$white/badge", "快要抢光", NodeBounds(521, 1044, 698, 1093))
|
||||
t.add("$white/l", "", NodeBounds(382, 1339, 698, 1447), "android.widget.LinearLayout")
|
||||
t.add("$white/l/t", "白条纹", NodeBounds(382, 1339, 698, 1447), clickable = true)
|
||||
t.add("$list/size", "", NodeBounds(0, 1483, 1080, 1938), "android.widget.LinearLayout")
|
||||
t.add("$list/size/row", "", NodeBounds(36, 1483, 1044, 1564), "android.widget.LinearLayout", clickable = true)
|
||||
t.add("$list/size/row/h", "尺码", NodeBounds(36, 1497, 126, 1550))
|
||||
listOf(
|
||||
Triple("L建议100-115斤", NodeBounds(36, 1576, 371, 1661), false),
|
||||
Triple("XL建议115-130斤", NodeBounds(401, 1576, 762, 1661), false),
|
||||
Triple("2XL建议130-150斤", NodeBounds(36, 1691, 430, 1776), true),
|
||||
Triple("3XL建议150-170斤", NodeBounds(460, 1691, 853, 1776), false),
|
||||
Triple("4XL建议170-190斤", NodeBounds(36, 1806, 431, 1891), false),
|
||||
).forEachIndexed { index, (label, bounds, selected) ->
|
||||
t.add("$list/size/o$index", "", bounds, "android.view.ViewGroup", clickable = true)
|
||||
t.add("$list/size/o$index/t", label, bounds, clickable = true, selected = selected)
|
||||
}
|
||||
t.add("r/sheet/pay", "", NodeBounds(0, 2079, 1080, 2178), "android.view.ViewGroup", clickable = true)
|
||||
t.add("r/sheet/pay/t", "使用#微信支付,更换先用后付可0元下单", NodeBounds(112, 2100, 930, 2157))
|
||||
t.add("r/sheet/submit", "", NodeBounds(0, 2181, 1080, 2328), "android.widget.FrameLayout", clickable = true)
|
||||
t.add("r/sheet/submit/t", "提交订单 ¥23.99", NodeBounds(365, 2225, 715, 2284))
|
||||
return t.snapshot()
|
||||
}
|
||||
|
||||
fun productDetailPage(): UiSnapshot {
|
||||
val t = Tree(2216)
|
||||
t.add("r/pager", "示例商品标题连衣裙夏季新款示例", NodeBounds(0, 84, 1080, 1100), "androidx.viewpager.widget.ViewPager")
|
||||
t.add("r/price", "¥19.6", NodeBounds(36, 1120, 300, 1200))
|
||||
t.add("r/sales", "已拼1万+件", NodeBounds(700, 1120, 1044, 1200))
|
||||
t.add("r/deliver", "配送至 示例省示例市", NodeBounds(36, 1300, 1044, 1360))
|
||||
t.add("r/spec", "请选择 颜色分类 尺码", NodeBounds(36, 1400, 1044, 1480), clickable = true)
|
||||
t.add("r/review", "商品评价(2000+)", NodeBounds(36, 1500, 1044, 1580), clickable = true)
|
||||
t.add("r/single", "单独购买", NodeBounds(420, 2080, 720, 2200), clickable = true)
|
||||
t.add("r/group", "发起拼单", NodeBounds(740, 2080, 1060, 2200), clickable = true)
|
||||
return t.snapshot()
|
||||
}
|
||||
|
||||
fun reviewPage(): UiSnapshot {
|
||||
val t = Tree(2216)
|
||||
t.add("r/title", "商品评价", NodeBounds(400, 120, 680, 200))
|
||||
t.add("r/all", "全部", NodeBounds(36, 260, 150, 320), clickable = true)
|
||||
t.add("r/media", "有图/视频", NodeBounds(170, 260, 360, 320), clickable = true)
|
||||
t.add("r/body", "质量不错,微信支付很方便", NodeBounds(36, 400, 1044, 500))
|
||||
return t.snapshot()
|
||||
}
|
||||
|
||||
fun addressListPage(): UiSnapshot {
|
||||
val t = Tree(2216)
|
||||
t.add("r/title", "收货地址", NodeBounds(400, 120, 680, 200))
|
||||
listOf(0, 1).forEach { index ->
|
||||
val top = 300 + index * 260
|
||||
t.add("r/card$index", "", NodeBounds(0, top, 1080, top + 240), "android.view.ViewGroup", clickable = true)
|
||||
t.add("r/card$index/phone", "测试 $FAKE_PHONE", NodeBounds(36, top + 20, 700, top + 80))
|
||||
t.add("r/card$index/detail", "示例省示例市示例路${index + 1}号", NodeBounds(36, top + 100, 900, top + 160))
|
||||
t.add("r/card$index/edit", "修改", NodeBounds(950, top + 20, 1044, top + 80), clickable = true)
|
||||
}
|
||||
return t.snapshot()
|
||||
}
|
||||
|
||||
fun addressEditPage(): UiSnapshot {
|
||||
val t = Tree(2216)
|
||||
t.add("r/title", "修改收货地址", NodeBounds(360, 120, 720, 200))
|
||||
t.add("r/name", "测试收货人", NodeBounds(180, 190, 900, 270), "android.widget.EditText", clickable = true)
|
||||
t.add("r/phone", "13900000000", NodeBounds(180, 290, 900, 370), "android.widget.EditText", clickable = true)
|
||||
t.add("r/detailLabel", "详细地址", NodeBounds(20, 400, 160, 460))
|
||||
t.add("r/detail", "示例路1号", NodeBounds(180, 390, 900, 480), "android.widget.EditText", clickable = true)
|
||||
t.add("r/save", "保存", NodeBounds(36, 2080, 1044, 2180), clickable = true)
|
||||
return t.snapshot()
|
||||
}
|
||||
|
||||
fun paymentMethodDialog(): UiSnapshot {
|
||||
val t = Tree(2216)
|
||||
t.add("r/title", "选择支付方式", NodeBounds(360, 1200, 720, 1280))
|
||||
t.add("r/wechat", "", NodeBounds(0, 1300, 1080, 1420), "android.view.ViewGroup", clickable = true)
|
||||
t.add("r/wechat/t", "微信支付", NodeBounds(120, 1330, 400, 1390))
|
||||
t.add("r/later", "", NodeBounds(0, 1440, 1080, 1560), "android.view.ViewGroup", clickable = true)
|
||||
t.add("r/later/t", "先用后付", NodeBounds(120, 1470, 400, 1530))
|
||||
t.add("r/ok", "确定", NodeBounds(36, 2080, 1044, 2180), clickable = true)
|
||||
return t.snapshot()
|
||||
}
|
||||
|
||||
fun postSubmitPaymentPage(): UiSnapshot {
|
||||
val t = Tree(2216)
|
||||
t.add("r/amount", "¥19.60", NodeBounds(360, 300, 720, 400))
|
||||
t.add("r/wechat", "", NodeBounds(0, 600, 1080, 720), "android.view.ViewGroup", clickable = true)
|
||||
t.add("r/wechat/t", "微信支付", NodeBounds(120, 630, 400, 690))
|
||||
t.add("r/pay", "立即支付", NodeBounds(36, 2080, 1044, 2180), clickable = true)
|
||||
return t.snapshot(activity = "com.xunmeng.pinduoduo.app_pay.core.PayActivity")
|
||||
}
|
||||
|
||||
fun quantityOnlyPage(): UiSnapshot {
|
||||
val t = Tree(2216)
|
||||
t.add("r/input", "1", NodeBounds(468, 888, 549, 963), "android.widget.EditText", clickable = true)
|
||||
return t.snapshot()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,575 @@
|
||||
package cn.ilapage.goauto.agent
|
||||
|
||||
import cn.ilapage.goauto.agent.SpecPanelFixtures.Sheet
|
||||
import cn.ilapage.goauto.agent.automation.FreshActionResult
|
||||
import cn.ilapage.goauto.agent.automation.NodeBounds
|
||||
import cn.ilapage.goauto.agent.automation.ParsedPddScreen
|
||||
import cn.ilapage.goauto.agent.automation.PddScreenParser
|
||||
import cn.ilapage.goauto.agent.automation.PurchaseAgentCapabilities
|
||||
import cn.ilapage.goauto.agent.automation.PurchaseExecutionInput
|
||||
import cn.ilapage.goauto.agent.automation.PurchaseLiveAutomation
|
||||
import cn.ilapage.goauto.agent.automation.PurchaseLiveException
|
||||
import cn.ilapage.goauto.agent.automation.PurchaseRehearsalExecutor
|
||||
import cn.ilapage.goauto.agent.automation.PurchaseRuleParser
|
||||
import cn.ilapage.goauto.agent.automation.PurchaseUiDriver
|
||||
import cn.ilapage.goauto.agent.automation.SnapshotNode
|
||||
import cn.ilapage.goauto.agent.automation.SpecPanelStabilityPolicy
|
||||
import cn.ilapage.goauto.agent.automation.SpecPanelType
|
||||
import cn.ilapage.goauto.agent.automation.SwipeDirection
|
||||
import cn.ilapage.goauto.agent.automation.UiSnapshot
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNotEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/** #331 spec-panel recognition, same-container check, hints, bounded waits and strict submit targets. */
|
||||
class SpecPanelRecognitionTest {
|
||||
private fun parse(snapshot: UiSnapshot) =
|
||||
PddScreenParser.parse(snapshot, PurchaseRehearsalExecutor.DEFAULT_COLLECTOR, "719834019024", null)
|
||||
|
||||
// --- Recognition -------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `both sanitized unselected sheet samples are recognized as spec panels`() {
|
||||
val first = parse(SpecPanelFixtures.sheet())
|
||||
val samsung = parse(SpecPanelFixtures.sheet(Sheet(screenBottom = 2020, submit = "hidden")))
|
||||
|
||||
listOf(first, samsung).forEach { screen ->
|
||||
assertTrue(screen.specPanelOpen)
|
||||
assertTrue(screen.hasAddressEntry)
|
||||
assertTrue(screen.hasPaymentEntry)
|
||||
assertTrue(screen.hasQuantityInput)
|
||||
}
|
||||
// The zero-size submit label is not visible, so the submit action is absent.
|
||||
assertFalse(samsung.hasOrderSubmitAction)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `live shaped samples are recognized only through the required evidence fallback`() {
|
||||
listOf(2216, 2020).forEach { bottom ->
|
||||
val screen = parse(SpecPanelFixtures.liveShapedSheet(Sheet(screenBottom = bottom)))
|
||||
|
||||
assertEquals(SpecPanelType.REQUIRED_EVIDENCE, screen.specPanelType)
|
||||
assertTrue(screen.specPanelOpen)
|
||||
assertEquals(0, screen.panelScrollableCount)
|
||||
assertFalse(screen.hasSelectionSummary)
|
||||
assertFalse(screen.hasQuantityControls)
|
||||
assertFalse(screen.hasQuantityAdjustControls)
|
||||
assertFalse(screen.hasOrderSubmitAction)
|
||||
assertEquals(listOf("黑色 示例款", "卡其色 示例款"), screen.dimensions.single().values.map { it.text })
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `zero size submit label sample is still recognized because submit is auxiliary`() {
|
||||
val screen = parse(SpecPanelFixtures.liveShapedSheet(Sheet(screenBottom = 2020)))
|
||||
|
||||
assertFalse(screen.hasOrderSubmitAction)
|
||||
assertEquals(SpecPanelType.REQUIRED_EVIDENCE, screen.specPanelType)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `missing address entry is not recognized`() {
|
||||
val screen = parse(SpecPanelFixtures.liveShapedSheet(Sheet(address = false)))
|
||||
assertFalse(screen.hasAddressEntry)
|
||||
assertEquals(SpecPanelType.UNKNOWN, screen.specPanelType)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `missing payment entry is not recognized`() {
|
||||
val screen = parse(SpecPanelFixtures.liveShapedSheet(Sheet(payment = false)))
|
||||
assertFalse(screen.hasPaymentEntry)
|
||||
assertEquals(SpecPanelType.UNKNOWN, screen.specPanelType)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `missing quantity input is not recognized`() {
|
||||
val screen = parse(SpecPanelFixtures.liveShapedSheet(Sheet(quantityInput = false)))
|
||||
assertFalse(screen.hasQuantityInput)
|
||||
assertEquals(SpecPanelType.UNKNOWN, screen.specPanelType)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `required items without any auxiliary evidence are not recognized`() {
|
||||
val t = SpecPanelFixtures.Tree(2216)
|
||||
t.add("r/panel", "", NodeBounds(0, 300, 1080, 2216), "android.widget.LinearLayout")
|
||||
t.add("r/panel/addr", "", NodeBounds(0, 330, 1080, 484), "android.view.ViewGroup", clickable = true)
|
||||
t.add("r/panel/addr/phone", "测试,${SpecPanelFixtures.FAKE_PHONE}", NodeBounds(412, 346, 993, 395))
|
||||
t.add("r/panel/qty", "1", NodeBounds(468, 888, 549, 963), "android.widget.EditText", clickable = true)
|
||||
t.add("r/panel/pay", "", NodeBounds(0, 2036, 1080, 2135), "android.view.ViewGroup", clickable = true)
|
||||
t.add("r/panel/pay/t", "使用微信支付", NodeBounds(112, 2057, 930, 2114))
|
||||
|
||||
val screen = parse(t.snapshot())
|
||||
|
||||
assertTrue(screen.hasRequiredPanelEvidence)
|
||||
assertTrue(screen.requiredEvidenceSameContainer)
|
||||
assertEquals(SpecPanelType.UNKNOWN, screen.specPanelType)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `quantity input alone is not recognized`() {
|
||||
val screen = parse(SpecPanelFixtures.quantityOnlyPage())
|
||||
assertTrue(screen.hasQuantityInput)
|
||||
assertEquals(SpecPanelType.UNKNOWN, screen.specPanelType)
|
||||
assertFalse(screen.specPanelOpen)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `non panel pages are never recognized as spec panels`() {
|
||||
mapOf(
|
||||
"product" to SpecPanelFixtures.productDetailPage(),
|
||||
"review" to SpecPanelFixtures.reviewPage(),
|
||||
"addressList" to SpecPanelFixtures.addressListPage(),
|
||||
"addressEdit" to SpecPanelFixtures.addressEditPage(),
|
||||
"paymentDialog" to SpecPanelFixtures.paymentMethodDialog(),
|
||||
"paymentPage" to SpecPanelFixtures.postSubmitPaymentPage(),
|
||||
).forEach { (name, snapshot) ->
|
||||
val screen = parse(snapshot)
|
||||
assertEquals(name, SpecPanelType.UNKNOWN, screen.specPanelType)
|
||||
assertFalse(name, screen.specPanelOpen)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `whole sheet clickable containers do not count as an address row`() {
|
||||
val t = SpecPanelFixtures.Tree(2216)
|
||||
t.add("r/sheet", "", NodeBounds(0, 84, 1080, 2216), "android.widget.LinearLayout", clickable = true)
|
||||
t.add("r/sheet/phone", "测试,${SpecPanelFixtures.FAKE_PHONE}", NodeBounds(412, 346, 993, 395))
|
||||
assertFalse(parse(t.snapshot()).hasAddressEntry)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `single dimension sheet without adjust buttons is recognized`() {
|
||||
val screen = parse(SpecPanelFixtures.sheet(Sheet(sizeDimension = false, adjustButtons = false, summary = false, listScrollable = false, submit = "none")))
|
||||
|
||||
assertEquals(SpecPanelType.REQUIRED_EVIDENCE, screen.specPanelType)
|
||||
assertEquals(listOf("color"), screen.dimensions.map { it.key })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `required evidence diagnostics hold booleans and counts only`() {
|
||||
val screen = parse(SpecPanelFixtures.liveShapedSheet())
|
||||
val summary = screen.requiredEvidenceSummary()
|
||||
|
||||
assertEquals("addr=1;pay=1;qin=1;adj=0;summary=0;close=1;submit=0;same=1", summary)
|
||||
assertFalse(summary.contains("****"))
|
||||
assertFalse(summary.contains("示例"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `goods 8580 shaped sheet is recognized through the required evidence fallback`() {
|
||||
val screen = parse(SpecPanelFixtures.sample8580ShapedSheet())
|
||||
|
||||
// Shape of the 13 live failures: scrollables=1;headings=2;options=9;
|
||||
// summary=false;quantity=true;orderAction=false (was UNKNOWN before #331).
|
||||
assertEquals(1, screen.panelScrollableCount)
|
||||
assertEquals(2, screen.panelHeadingCount)
|
||||
assertEquals(9, screen.panelOptionCount)
|
||||
assertFalse(screen.hasSelectionSummary)
|
||||
assertTrue(screen.hasQuantityControls)
|
||||
assertFalse(screen.hasOrderSubmitAction)
|
||||
assertEquals(SpecPanelType.REQUIRED_EVIDENCE, screen.specPanelType)
|
||||
assertTrue(screen.requiredEvidenceSameContainer)
|
||||
}
|
||||
|
||||
// --- Same panel container --------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `required items inside one panel container are recognized`() {
|
||||
listOf(SpecPanelFixtures.liveShapedSheet(), SpecPanelFixtures.sample8580ShapedSheet()).forEach { snapshot ->
|
||||
val screen = parse(snapshot)
|
||||
assertTrue(screen.requiredEvidenceSameContainer)
|
||||
assertEquals(SpecPanelType.REQUIRED_EVIDENCE, screen.specPanelType)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `address dialog payment dialog and a separate quantity input are not a spec panel`() {
|
||||
listOf(false, true).forEach { wrapper ->
|
||||
val screen = parse(SpecPanelFixtures.scatteredRequiredItemsPage(fullScreenWrapper = wrapper))
|
||||
|
||||
// Every required item exists somewhere on screen, plus auxiliary
|
||||
// options and a close control, but not inside one panel container.
|
||||
assertTrue(screen.hasAddressEntry)
|
||||
assertTrue(screen.hasPaymentEntry)
|
||||
assertTrue(screen.hasQuantityInput)
|
||||
assertTrue(screen.dimensions.any { it.values.isNotEmpty() })
|
||||
assertFalse(screen.requiredEvidenceSameContainer)
|
||||
assertEquals(SpecPanelType.UNKNOWN, screen.specPanelType)
|
||||
assertFalse(screen.specPanelOpen)
|
||||
assertEquals(listOf(ParsedPddScreen.NOT_SAME_CONTAINER_HINT), screen.requiredEvidenceHints())
|
||||
}
|
||||
}
|
||||
|
||||
// --- Purchaser hints for missing required items --------------------------
|
||||
|
||||
@Test
|
||||
fun `missing required items produce purchaser hints`() {
|
||||
val noPay = parse(SpecPanelFixtures.sample8580ShapedSheet(Sheet(payment = false)))
|
||||
val noAddr = parse(SpecPanelFixtures.sample8580ShapedSheet(Sheet(address = false)))
|
||||
val noQty = parse(SpecPanelFixtures.sample8580ShapedSheet(Sheet(quantityInput = false)))
|
||||
|
||||
assertEquals(listOf("未找到支付入口:请确认 PDD 默认支付方式为微信支付"), noPay.requiredEvidenceHints())
|
||||
assertEquals(listOf("未找到收货地址入口:请确认 PDD 已设置默认收货地址"), noAddr.requiredEvidenceHints())
|
||||
assertEquals(listOf("未找到购买数量输入框"), noQty.requiredEvidenceHints())
|
||||
assertTrue(parse(SpecPanelFixtures.sample8580ShapedSheet()).requiredEvidenceHints().isEmpty())
|
||||
// Unrelated pages without any required item or option do not get environment hints.
|
||||
assertTrue(parse(SpecPanelFixtures.reviewPage()).requiredEvidenceHints().isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `spec panel evidence failure message carries the missing payment hint`() {
|
||||
val driver = SpecEntryDriver(listOf(SpecPanelFixtures.sample8580ShapedSheet(Sheet(payment = false))))
|
||||
|
||||
val outcome = PurchaseRehearsalExecutor(driver, { true }, { null }, pause = {})
|
||||
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
|
||||
|
||||
assertEquals("PURCHASE_SPEC_PANEL_EVIDENCE_NOT_MATCHED", outcome.errorCode)
|
||||
assertTrue(outcome.message.orEmpty().contains("未找到支付入口:请确认 PDD 默认支付方式为微信支付"))
|
||||
assertFalse(outcome.message.orEmpty().contains("****"))
|
||||
assertEquals(0, driver.backs)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `spec panel evidence failure message carries the missing address and quantity hints`() {
|
||||
mapOf(
|
||||
Sheet(address = false) to "未找到收货地址入口:请确认 PDD 已设置默认收货地址",
|
||||
Sheet(quantityInput = false) to "未找到购买数量输入框",
|
||||
).forEach { (options, hint) ->
|
||||
val driver = SpecEntryDriver(listOf(SpecPanelFixtures.sample8580ShapedSheet(options)))
|
||||
val outcome = PurchaseRehearsalExecutor(driver, { true }, { null }, pause = {})
|
||||
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
|
||||
|
||||
assertEquals("PURCHASE_SPEC_PANEL_EVIDENCE_NOT_MATCHED", outcome.errorCode)
|
||||
assertTrue(outcome.message.orEmpty(), outcome.message.orEmpty().contains(hint))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `address save timeout message carries the missing address hint`() {
|
||||
val loading = SpecPanelFixtures.liveShapedSheet(Sheet(address = false))
|
||||
val driver = AddressSaveDriver(listOf(loading)) { loading }
|
||||
|
||||
val error = runCatching { PurchaseLiveAutomation(driver, pause = {}).updateShippingAddress("_cg35") }
|
||||
.exceptionOrNull() as PurchaseLiveException
|
||||
|
||||
assertEquals("PURCHASE_ADDRESS_SAVE_TIMEOUT", error.code)
|
||||
assertTrue(error.message.orEmpty().contains("未找到收货地址入口:请确认 PDD 已设置默认收货地址"))
|
||||
}
|
||||
|
||||
// --- Bounded wait after the first spec-entry click -----------------------
|
||||
|
||||
@Test
|
||||
fun `loading sheet is accepted after required evidence becomes complete and stable without back`() {
|
||||
val loading = SpecPanelFixtures.liveShapedSheet(Sheet(address = false))
|
||||
val ready = SpecPanelFixtures.liveShapedSheet()
|
||||
val driver = SpecEntryDriver(listOf(loading, loading, loading, ready, ready))
|
||||
val diagnostics = mutableListOf<String>()
|
||||
val steps = mutableListOf<String>()
|
||||
|
||||
PurchaseRehearsalExecutor(driver, { true }, { null }, pause = {}, stepChanged = steps::add, panelDiagnostic = diagnostics::add)
|
||||
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
|
||||
|
||||
assertTrue(steps.contains("selectSpec"))
|
||||
assertEquals(0, driver.backs)
|
||||
assertTrue(diagnostics.any { it.startsWith("specPanelWait;outcome=opened;") && it.contains("backPressed=false") })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `always incomplete sheet fails explicitly at the wait bound without back`() {
|
||||
val loading = SpecPanelFixtures.liveShapedSheet(Sheet(address = false))
|
||||
val driver = SpecEntryDriver(listOf(loading))
|
||||
val diagnostics = mutableListOf<String>()
|
||||
|
||||
val outcome = PurchaseRehearsalExecutor(driver, { true }, { null }, pause = {}, panelDiagnostic = diagnostics::add)
|
||||
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
|
||||
|
||||
assertEquals("PURCHASE_SPEC_PANEL_EVIDENCE_NOT_MATCHED", outcome.errorCode)
|
||||
assertEquals(0, driver.backs)
|
||||
val timeout = diagnostics.single { it.startsWith("specPanelWait;outcome=timeout;") }
|
||||
assertTrue(timeout.contains("waitedMs=${SpecPanelStabilityPolicy.WAIT_BOUND_MS};"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `fallback sheet whose structure keeps changing is never accepted early`() {
|
||||
val first = SpecPanelFixtures.liveShapedSheet(Sheet(screenBottom = 2216))
|
||||
val second = SpecPanelFixtures.liveShapedSheet(Sheet(screenBottom = 2200))
|
||||
assertEquals(SpecPanelType.REQUIRED_EVIDENCE, parse(second).specPanelType)
|
||||
val driver = SpecEntryDriver(listOf(first, second), cycle = true)
|
||||
val diagnostics = mutableListOf<String>()
|
||||
|
||||
val outcome = PurchaseRehearsalExecutor(driver, { true }, { null }, pause = {}, panelDiagnostic = diagnostics::add)
|
||||
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
|
||||
|
||||
assertEquals("PURCHASE_SPEC_PANEL_EVIDENCE_NOT_MATCHED", outcome.errorCode)
|
||||
assertEquals(0, driver.backs)
|
||||
assertFalse(diagnostics.any { it.startsWith("specPanelWait;outcome=opened") })
|
||||
assertTrue(diagnostics.any { it.contains("waitedMs=${SpecPanelStabilityPolicy.WAIT_BOUND_MS};") })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `stronger panel type is still accepted immediately`() {
|
||||
val driver = SpecEntryDriver(listOf(SpecPanelFixtures.sheet()))
|
||||
val diagnostics = mutableListOf<String>()
|
||||
val steps = mutableListOf<String>()
|
||||
|
||||
PurchaseRehearsalExecutor(driver, { true }, { null }, pause = {}, stepChanged = steps::add, panelDiagnostic = diagnostics::add)
|
||||
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
|
||||
|
||||
assertTrue(steps.contains("selectSpec"))
|
||||
assertFalse(diagnostics.any { it.startsWith("specPanelWait;") })
|
||||
}
|
||||
|
||||
// --- Bounded wait after the address save ---------------------------------
|
||||
|
||||
@Test
|
||||
fun `address save waits for the loading sheet without pressing back`() {
|
||||
val loading = SpecPanelFixtures.liveShapedSheet(Sheet(address = false))
|
||||
val driver = AddressSaveDriver(listOf(loading, loading, loading)) { expected -> readySheet(expected) }
|
||||
val diagnostics = mutableListOf<String>()
|
||||
|
||||
val proof = PurchaseLiveAutomation(driver, pause = {}, panelDiagnostic = diagnostics::add).updateShippingAddress("_cg31")
|
||||
|
||||
assertEquals("_cg31", proof.suffix)
|
||||
assertEquals(0, driver.backs)
|
||||
val done = diagnostics.single { it.startsWith("addressSaveWait;") }
|
||||
assertTrue(done.contains("outcome=final_evidence"))
|
||||
assertTrue(done.contains("waitedMs=${2 * SpecPanelStabilityPolicy.ADDRESS_SAVE_POLL_MS};"))
|
||||
assertTrue(done.contains("backPressed=false"))
|
||||
assertFalse(diagnostics.any { it.contains("_cg31") || it.contains("示例") || it.contains("****") })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `address save with a sheet that never completes fails at the bound without back`() {
|
||||
val loading = SpecPanelFixtures.liveShapedSheet(Sheet(address = false))
|
||||
val driver = AddressSaveDriver(listOf(loading)) { loading }
|
||||
val diagnostics = mutableListOf<String>()
|
||||
|
||||
val error = runCatching { PurchaseLiveAutomation(driver, pause = {}, panelDiagnostic = diagnostics::add).updateShippingAddress("_cg32") }
|
||||
.exceptionOrNull() as PurchaseLiveException
|
||||
|
||||
assertEquals("PURCHASE_ADDRESS_SAVE_TIMEOUT", error.code)
|
||||
assertEquals(0, driver.backs)
|
||||
assertTrue(diagnostics.single().contains("outcome=timeout;waitedMs=${SpecPanelStabilityPolicy.WAIT_BOUND_MS};"))
|
||||
assertFalse(error.message.orEmpty().contains("示例"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `address save with an unstable fallback sheet does not decide early`() {
|
||||
val first = SpecPanelFixtures.liveShapedSheet(Sheet(screenBottom = 2216))
|
||||
val second = SpecPanelFixtures.liveShapedSheet(Sheet(screenBottom = 2200))
|
||||
val driver = AddressSaveDriver(listOf(first, second), cycle = true) { first }
|
||||
val diagnostics = mutableListOf<String>()
|
||||
|
||||
val error = runCatching { PurchaseLiveAutomation(driver, pause = {}, panelDiagnostic = diagnostics::add).updateShippingAddress("_cg33") }
|
||||
.exceptionOrNull() as PurchaseLiveException
|
||||
|
||||
assertEquals("PURCHASE_ADDRESS_SAVE_TIMEOUT", error.code)
|
||||
assertEquals(0, driver.backs)
|
||||
assertTrue(diagnostics.single().contains("outcome=timeout"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `address save with a stable fallback sheet is accepted as purchase panel without back`() {
|
||||
val sheet = SpecPanelFixtures.liveShapedSheet()
|
||||
val driver = AddressSaveDriver(listOf(sheet)) { sheet }
|
||||
val diagnostics = mutableListOf<String>()
|
||||
|
||||
// The stable sheet has no saved suffix and no scroll region, so the
|
||||
// existing in-panel restore path fails closed; it must not press Back.
|
||||
val error = runCatching { PurchaseLiveAutomation(driver, pause = {}, panelDiagnostic = diagnostics::add).updateShippingAddress("_cg34") }
|
||||
.exceptionOrNull() as PurchaseLiveException
|
||||
|
||||
assertEquals(0, driver.backs)
|
||||
assertTrue(diagnostics.single().contains("outcome=stable_required;waitedMs=${SpecPanelStabilityPolicy.ADDRESS_SAVE_POLL_MS};"))
|
||||
assertNotEquals("PURCHASE_ADDRESS_UPDATE_FAILED", error.code)
|
||||
}
|
||||
|
||||
// --- Strict final submit click -------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `submit click fails for zero size hidden unmatched or duplicated buttons`() {
|
||||
fun page(build: SpecPanelFixtures.Tree.() -> Unit) = SpecPanelFixtures.Tree(2216).apply(build).snapshot()
|
||||
val cases = mapOf(
|
||||
"hiddenZeroLabel" to SpecPanelFixtures.sheet(Sheet(submit = "hidden")),
|
||||
"zeroSizeVisibleLabel" to page {
|
||||
add("r/submit", "", NodeBounds(0, 2185, 1080, 2216), "android.widget.FrameLayout", clickable = true)
|
||||
add("r/submit/t", "提交订单", NodeBounds(0, 0, 0, 0))
|
||||
},
|
||||
"zeroSizeContainer" to page {
|
||||
add("r/submit", "", NodeBounds(0, 0, 0, 0), "android.widget.FrameLayout", clickable = true)
|
||||
add("r/submit/t", "提交订单", NodeBounds(157, 2179, 922, 2216))
|
||||
},
|
||||
"labelMismatch" to SpecPanelFixtures.sheet(),
|
||||
"duplicated" to page {
|
||||
add("r/a", "", NodeBounds(0, 2000, 540, 2216), "android.widget.FrameLayout", clickable = true)
|
||||
add("r/a/t", "提交订单", NodeBounds(20, 2050, 520, 2150))
|
||||
add("r/b", "", NodeBounds(540, 2000, 1080, 2216), "android.widget.FrameLayout", clickable = true)
|
||||
add("r/b/t", "提交订单", NodeBounds(560, 2050, 1060, 2150))
|
||||
},
|
||||
)
|
||||
cases.forEach { (name, snapshot) ->
|
||||
val driver = StaticDriver(snapshot)
|
||||
val error = runCatching { PurchaseLiveAutomation(driver, pause = {}).submitOrderOnce() }.exceptionOrNull() as? PurchaseLiveException
|
||||
|
||||
assertEquals(name, "PURCHASE_SUBMIT_TARGET_AMBIGUOUS", error?.code)
|
||||
assertTrue(name, driver.clicked.isEmpty())
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unique visible submit button is still clickable exactly once`() {
|
||||
val driver = StaticDriver(
|
||||
SpecPanelFixtures.Tree(2216).apply {
|
||||
add("r/submit", "", NodeBounds(0, 2135, 1080, 2216), "android.widget.FrameLayout", clickable = true)
|
||||
add("r/submit/t", "提交订单", NodeBounds(157, 2150, 922, 2210))
|
||||
}.snapshot(),
|
||||
)
|
||||
|
||||
PurchaseLiveAutomation(driver, pause = {}).submitOrderOnce()
|
||||
|
||||
assertEquals(listOf("提交订单"), driver.clicked)
|
||||
}
|
||||
|
||||
// --- Helpers -------------------------------------------------------------
|
||||
|
||||
private fun readySheet(expected: String): UiSnapshot {
|
||||
val base = SpecPanelFixtures.liveShapedSheet()
|
||||
val extra = listOf(
|
||||
node("r/sheet/body/addr/a/saved", expected, NodeBounds(132, 460, 937, 480), parent = "r/sheet/body/addr/a"),
|
||||
node("r/final", "", NodeBounds(600, 2150, 1060, 2216), clickable = true, parent = "r"),
|
||||
node("r/final/t", "提交订单", NodeBounds(620, 2160, 1040, 2210), parent = "r/final"),
|
||||
)
|
||||
return base.copy(nodes = base.nodes + extra)
|
||||
}
|
||||
|
||||
private fun input() = PurchaseExecutionInput(
|
||||
taskId = 331,
|
||||
executionMode = "rehearsal",
|
||||
phase = "purchase",
|
||||
url = "https://mobile.yangkeduo.com/goods.html?goods_id=719834019024",
|
||||
goodsId = "719834019024",
|
||||
mappedColor = "黑色 示例款",
|
||||
mappedSize = "",
|
||||
quantity = 1,
|
||||
minUnitPriceCent = 1_000,
|
||||
maxUnitPriceCent = 3_000,
|
||||
)
|
||||
|
||||
private fun rule() = """{
|
||||
"schemaVersion":1,
|
||||
"ruleType":"pddPurchase",
|
||||
"requiredCapabilities":["purchase.rehearsal.v1"],
|
||||
"actions":[
|
||||
{"type":"openProduct"},{"type":"verifyProduct"},{"type":"openSpecPanel"},{"type":"selectSpec"},
|
||||
{"type":"setQuantity"},{"type":"verifyUnitPrice"},{"type":"verifyOrderSummary"}
|
||||
]
|
||||
}"""
|
||||
|
||||
private open class BaseDriver : PurchaseUiDriver {
|
||||
var backs = 0
|
||||
val clicked = mutableListOf<String>()
|
||||
override fun capture(): UiSnapshot = UiSnapshot(null, null, emptyList())
|
||||
override fun clickFresh(target: SnapshotNode): FreshActionResult {
|
||||
clicked += target.label
|
||||
return FreshActionResult.SUCCESS
|
||||
}
|
||||
override fun tapPurchaseFresh(target: SnapshotNode) = FreshActionResult.FAILED
|
||||
override fun inputFresh(target: SnapshotNode, value: String) = FreshActionResult.FAILED
|
||||
override fun swipePurchase(direction: SwipeDirection, durationMs: Long) = false
|
||||
override fun swipePurchaseIn(target: SnapshotNode, direction: SwipeDirection, durationMs: Long) = false
|
||||
override fun backPurchase(): Boolean {
|
||||
backs++
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private class StaticDriver(private val snapshot: UiSnapshot) : BaseDriver() {
|
||||
override fun capture() = snapshot
|
||||
}
|
||||
|
||||
/** Product page first; after the spec-entry click it serves [panels] in order (the last repeats unless [cycle]). */
|
||||
private class SpecEntryDriver(private val panels: List<UiSnapshot>, private val cycle: Boolean = false) : BaseDriver() {
|
||||
private var opened = false
|
||||
var panelCaptures = 0
|
||||
|
||||
override fun capture(): UiSnapshot {
|
||||
if (!opened) return SpecPanelFixtures.productDetailPage()
|
||||
val index = if (cycle) panelCaptures % panels.size else minOf(panelCaptures, panels.lastIndex)
|
||||
panelCaptures++
|
||||
return panels[index]
|
||||
}
|
||||
|
||||
override fun clickFresh(target: SnapshotNode): FreshActionResult {
|
||||
clicked += target.label
|
||||
if (target.label.startsWith("请选择")) opened = true
|
||||
return FreshActionResult.SUCCESS
|
||||
}
|
||||
}
|
||||
|
||||
/** Address flow driver: sheet -> address list -> editor -> [afterSave] samples -> [settled]. */
|
||||
private class AddressSaveDriver(
|
||||
private val afterSave: List<UiSnapshot>,
|
||||
private val cycle: Boolean = false,
|
||||
private val settled: (String) -> UiSnapshot,
|
||||
) : BaseDriver() {
|
||||
private var page = "sheet"
|
||||
private var address = "示例路1号-old"
|
||||
private var afterSaveCaptures = 0
|
||||
|
||||
override fun capture(): UiSnapshot = when (page) {
|
||||
"sheet" -> SpecPanelFixtures.sheet()
|
||||
"list" -> SpecPanelFixtures.Tree(2216).apply {
|
||||
add("r/title", "收货地址", NodeBounds(400, 120, 680, 200))
|
||||
add("r/modify", "", NodeBounds(900, 300, 1044, 380), "android.view.ViewGroup", clickable = true)
|
||||
add("r/modify/t", "修改", NodeBounds(920, 310, 1030, 370))
|
||||
}.snapshot()
|
||||
"edit" -> SpecPanelFixtures.Tree(2216).apply {
|
||||
add("r/title", "修改收货地址", NodeBounds(360, 120, 720, 200))
|
||||
add("r/detailLabel", "详细地址", NodeBounds(20, 400, 160, 460))
|
||||
add("r/detail", address, NodeBounds(180, 390, 900, 480), "android.widget.EditText", clickable = true)
|
||||
add("r/save", "", NodeBounds(36, 2080, 1044, 2180), "android.view.ViewGroup", clickable = true)
|
||||
add("r/save/t", "保存", NodeBounds(400, 2100, 700, 2160))
|
||||
}.snapshot()
|
||||
else -> {
|
||||
val index = afterSaveCaptures++
|
||||
when {
|
||||
cycle -> afterSave[index % afterSave.size]
|
||||
index < afterSave.size -> afterSave[index]
|
||||
else -> settled(address)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun clickFresh(target: SnapshotNode): FreshActionResult {
|
||||
clicked += target.label
|
||||
when (target.label) {
|
||||
"修改" -> page = "edit"
|
||||
"保存" -> page = "saved"
|
||||
}
|
||||
return FreshActionResult.SUCCESS
|
||||
}
|
||||
|
||||
override fun clickAddressEntryFresh(target: SnapshotNode): cn.ilapage.goauto.agent.automation.FreshClickOutcome {
|
||||
page = "list"
|
||||
return cn.ilapage.goauto.agent.automation.FreshClickOutcome(
|
||||
FreshActionResult.SUCCESS,
|
||||
cn.ilapage.goauto.agent.automation.FreshClickReason.SUCCESS,
|
||||
1,
|
||||
1,
|
||||
)
|
||||
}
|
||||
|
||||
override fun inputFresh(target: SnapshotNode, value: String): FreshActionResult {
|
||||
address = value
|
||||
return FreshActionResult.SUCCESS
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
fun node(path: String, text: String, bounds: NodeBounds, clickable: Boolean = false, parent: String?) = SnapshotNode(
|
||||
path, parent, text, null, null, "android.widget.TextView", bounds, clickable, false, false, false, true, true,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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: 3b78360779ae520f1ff9e51be3118a04cd549f51
|
||||
synchronized_at: 2026-09-07T09:27:40Z
|
||||
wiki_revision: 03ea269058b50fea2be7842b9c284018987c82d9
|
||||
synchronized_at: 2026-09-21T08:14:21Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 项目档案
|
||||
|
||||
+2
-2
@@ -2,8 +2,8 @@
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Development-Workflow
|
||||
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Development-Workflow.-
|
||||
wiki_revision: 62ddbe4469740c02ce4a6ca2fd1966a89a79322f
|
||||
synchronized_at: 2026-09-05T07:16:44Z
|
||||
wiki_revision: 03ea269058b50fea2be7842b9c284018987c82d9
|
||||
synchronized_at: 2026-09-21T08:14:27Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 开发工作流
|
||||
|
||||
@@ -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: 46067d78327476f14e6cfd202458a53f295bf763
|
||||
synchronized_at: 2026-09-19T07:18:28Z
|
||||
wiki_revision: 8e2cfa74bc7cf228278221e0f7ef488ac1b596d3
|
||||
synchronized_at: 2026-09-21T08:14:31Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 架构与代码地图
|
||||
@@ -486,6 +486,12 @@ Web 唯一展示位置为“采集采购 → SYB 同步记录”:列表状态
|
||||
- Web purchase-tasks API/页面:共享勾选、按动作筛选、独立状态列/详情及逐项接受结果;付款和物流流程保持原样。
|
||||
- 验证入口:go test ./app/goauto/purchase ./app/goauto/sybclient ./app/goauto/access ./app/goauto/migrations ./cmd/migrate/migration/version-local;Web tests/e2e/purchase-order-writeback.spec.ts。测试只使用隔离SQLite和fake/httptest,不代表MySQL多实例或真实SYB验收。
|
||||
|
||||
### 会话类失败自动重试(#330)
|
||||
|
||||
- purchase/order_writeback_worker.go:restoreOrderWritebackClient 在 ImportCookiesJSON 后调用 sybclient.CheckSession,UserID<=0 显式判不可用;finishSessionUnavailable 复用 lease_expires_at 作为退避到期时间(maxSessionRetryAttempts=6,sessionRetryBackoff 5/10/15/30/30m),领取条件增加 failed+SYB_SESSION_UNAVAILABLE+到期+未达上限。无迁移。
|
||||
- purchase/order_writeback.go:会话类失败的 CanSubmit 不受退避租约限制;手工重新提交 attempt_count 置 0。
|
||||
- 验证:go test ./app/goauto/purchase(含 httptest 模拟 /am/user/get 与断言 syb_session 未删除)。
|
||||
|
||||
## Chrome PDD 订单回填扩展(#316)
|
||||
|
||||
- `chrome-extension/` 是独立 Manifest V3 交付单元:popup 只负责配置、启动/停止、状态轮询和逐项结果;content script 只在 `mobile.yangkeduo.com` 的隔离世界中按可见 DOM 串行读取;service worker 负责持久运行状态、聚合冲突和分批提交。
|
||||
|
||||
@@ -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: b6df3e2d3d497827c9f3cc5b5ec859d5bda3c1f2
|
||||
synchronized_at: 2026-09-19T07:39:56Z
|
||||
wiki_revision: 6cb34c99ccdcf4b64b01cb9a25ff1549d8aeeec9
|
||||
synchronized_at: 2026-09-21T08:14:37Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 业务规则与术语
|
||||
@@ -332,7 +332,7 @@ synchronized_at: 2026-09-19T07:39:56Z
|
||||
- 一个 PDD 商品可能被多个虾皮商品共用,因此规格匹配按受影响的每个虾皮商品分项记录。主表只表达总体进度;Agent 展示和“继续采购”资格必须读取当前任务对应虾皮商品的分项状态。
|
||||
- 选错替代商品时,连续执行 B→C 不等于撤销 A→B,因为 B 可能还关联其他虾皮商品。正确纠错语义是把原 A→B 记录置为 `superseded`,再建立 A→C,并只处理原记录分项中冻结的影响集合。
|
||||
- 创建请求按 `create_request_id` 幂等;重放时源商品、替代商品、来源类型、来源任务、采集证据和发起设备必须一致,否则返回幂等冲突,不能静默覆盖。
|
||||
- `created_by_device_id` 只代表 Agent 设备。当前系统没有设备到采购员账号的绑定,多人多机场景若需要个人责任追踪,必须另建工单实现设备绑定操作员。
|
||||
- `created_by_device_id` 只代表 Agent 设备。设备可由管理员绑定到一个采购员账号;一个采购员可拥有多台设备,一台设备最多归属一个采购员,也允许暂不归属。SYB 商品一键关联/替换按当前采购员选择的归属设备读取该设备最新临时采集,不使用其他账号或其他设备的全局最新记录。
|
||||
|
||||
|
||||
## PDD 商品替换生效与规格匹配(#131)
|
||||
@@ -630,6 +630,10 @@ Web 唯一展示位置为“采集采购 → SYB 同步记录”:列表状态
|
||||
订单回填状态独立于采购成功、支付复核及物流 writeback_status。实付金额只存 Admin,SYB cost=0 为接口固定参数,不以金额推断付款。SYB 接口会同时更新采购状态/平台/时间,不能视作纯展示修改。
|
||||
|
||||
采购管理增加独立状态列、批量回填和详情补偿;复用既有访问权限,不增支付确认或审批。批量受理与最终成功分开展示;重试采购和回填分别筛选勾选项。远端无原子CAS,对系统外人工并发修改/超长延迟请求不能承诺绝对互斥;有冲突应人工核对,禁止强制覆盖。
|
||||
### SYB 会话类失败的有界自动重试(#330)
|
||||
|
||||
实现 01510a8/08b7095(2026-09-21,已合并 main,未部署、未生产验证)。回填 worker 从缓存会话恢复客户端后调用 SYB 会话校验;会话缺失/过期、串号失效、校验网络错误等均记为 `SYB_SESSION_UNAVAILABLE`,error_message 只记录类别和“将自动重试;如持续失败请恢复登录后重试”,不含原始错误。该类失败发生在任何写入之前,最多自动重试 6 次,退避 5/10/15/30/30 分钟(约 90 分钟,大于一个整点同步周期),达上限保持 failed 等人工。会话仍只由每小时 SYB 同步刷新;回填不登录、不 OCR、不删除或写入会话。其他失败码仍不自动重试。退避期内可手工重新回填,手工提交重置尝试次数。历史失败记录不会被自动领取。
|
||||
|
||||
## Agent 回填订单入口兼容(#307)
|
||||
|
||||
- 打开我的订单后最多采样8次,每次间隔1秒,连续两次订单导航结构一致才继续;登录/风控仍立即停止,超时给出明确原因。
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Local-Development-and-Verification
|
||||
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Local-Development-and-Verification.-
|
||||
wiki_revision: f8996a09b2158253553c9e929eab72e1e53e8f61
|
||||
synchronized_at: 2026-09-18T07:57:06Z
|
||||
wiki_revision: 03ea269058b50fea2be7842b9c284018987c82d9
|
||||
synchronized_at: 2026-09-21T08:14:42Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 本地开发与验证
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Common-Changes
|
||||
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Common-Changes.-
|
||||
wiki_revision: b1b1b343917e66288f4282bc6b3b90ea4ff3cca0
|
||||
synchronized_at: 2026-09-04T11:30:12Z
|
||||
wiki_revision: 03ea269058b50fea2be7842b9c284018987c82d9
|
||||
synchronized_at: 2026-09-21T08:14:50Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 常见修改指南
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Troubleshooting
|
||||
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Troubleshooting
|
||||
wiki_revision: 18744477bfd17f396e8c76ec7a2fcc6720acdf6f
|
||||
synchronized_at: 2026-09-11T09:10:00Z
|
||||
wiki_revision: 03ea269058b50fea2be7842b9c284018987c82d9
|
||||
synchronized_at: 2026-09-21T08:14:54Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 故障排查
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Product-Requirements-Overview
|
||||
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Product-Requirements-Overview.-
|
||||
wiki_revision: b1b1b343917e66288f4282bc6b3b90ea4ff3cca0
|
||||
synchronized_at: 2026-09-04T11:30:20Z
|
||||
wiki_revision: 03ea269058b50fea2be7842b9c284018987c82d9
|
||||
synchronized_at: 2026-09-21T08:15:00Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 产品需求总览与当前 MVP
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Android-Agent-API-Contract
|
||||
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Android-Agent-API-Contract.-
|
||||
wiki_revision: 275caa306765440b0888dea701198d45481bfb40
|
||||
synchronized_at: 2026-09-19T07:40:19Z
|
||||
wiki_revision: 03ea269058b50fea2be7842b9c284018987c82d9
|
||||
synchronized_at: 2026-09-21T08:15:05Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
<!-- gitea-wiki-mirror:start -->
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Delivery-Issues
|
||||
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Delivery-Issues.-
|
||||
wiki_revision: b1b1b343917e66288f4282bc6b3b90ea4ff3cca0
|
||||
synchronized_at: 2026-09-04T11:30:28Z
|
||||
wiki_revision: 03ea269058b50fea2be7842b9c284018987c82d9
|
||||
synchronized_at: 2026-09-21T08:15:09Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 当前 MVP 交付工单索引
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: OnePlus-Real-Device-Acceptance
|
||||
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/OnePlus-Real-Device-Acceptance.-
|
||||
wiki_revision: b1b1b343917e66288f4282bc6b3b90ea4ff3cca0
|
||||
synchronized_at: 2026-09-04T11:30:32Z
|
||||
wiki_revision: 03ea269058b50fea2be7842b9c284018987c82d9
|
||||
synchronized_at: 2026-09-21T08:15:13Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 一加真机验收记录
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: PDD-Detail-Rule-Migration-Analysis
|
||||
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/PDD-Detail-Rule-Migration-Analysis.-
|
||||
wiki_revision: b1b1b343917e66288f4282bc6b3b90ea4ff3cca0
|
||||
synchronized_at: 2026-09-04T11:30:37Z
|
||||
wiki_revision: 03ea269058b50fea2be7842b9c284018987c82d9
|
||||
synchronized_at: 2026-09-21T08:15:17Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# PDD 商品详情采集规则迁移分析
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: SYB-ERP-Interface-Contract
|
||||
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/SYB-ERP-Interface-Contract.-
|
||||
wiki_revision: 432e392ebe428ea19c0aa260f8e5938f36e4c3f0
|
||||
synchronized_at: 2026-09-18T02:18:53Z
|
||||
wiki_revision: 03ea269058b50fea2be7842b9c284018987c82d9
|
||||
synchronized_at: 2026-09-21T08:15:30Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 12 顺云宝(SYB)ERP 接口契约
|
||||
|
||||
@@ -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: c208aeeeb4baa3ae2da29f13f3651910c500e360
|
||||
synchronized_at: 2026-09-18T07:57:16Z
|
||||
wiki_revision: 03ea269058b50fea2be7842b9c284018987c82d9
|
||||
synchronized_at: 2026-09-21T08:14:47Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 部署与运维
|
||||
|
||||
+2
-2
@@ -2,8 +2,8 @@
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Home
|
||||
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Home
|
||||
wiki_revision: b1b1b343917e66288f4282bc6b3b90ea4ff3cca0
|
||||
synchronized_at: 2026-09-04T11:29:34Z
|
||||
wiki_revision: 03ea269058b50fea2be7842b9c284018987c82d9
|
||||
synchronized_at: 2026-09-21T08:14:18Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# GoAuto 文档中心
|
||||
|
||||
Vendored
+2
-2
@@ -2,8 +2,8 @@
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Deployment-Template
|
||||
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Deployment-Template.-
|
||||
wiki_revision: b1b1b343917e66288f4282bc6b3b90ea4ff3cca0
|
||||
synchronized_at: 2026-09-04T11:30:41Z
|
||||
wiki_revision: 03ea269058b50fea2be7842b9c284018987c82d9
|
||||
synchronized_at: 2026-09-21T08:15:22Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 部署文档模板
|
||||
|
||||
Vendored
+2
-2
@@ -2,8 +2,8 @@
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Task-Archive-Template
|
||||
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Task-Archive-Template.-
|
||||
wiki_revision: b1b1b343917e66288f4282bc6b3b90ea4ff3cca0
|
||||
synchronized_at: 2026-09-04T11:30:46Z
|
||||
wiki_revision: 03ea269058b50fea2be7842b9c284018987c82d9
|
||||
synchronized_at: 2026-09-21T08:15:26Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
> 本模板只用于用户明确要求的专项历史快照或读取既有归档,不属于标准任务闭环。单次任务的唯一事实来源是 Gitea 工单;不要为了完成普通任务创建本页面,也不要自动导出到 `docs/task/`。
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/go-admin-team/go-admin-core/sdk/pkg"
|
||||
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
@@ -29,6 +30,14 @@ func (handler Handler) List(context *gin.Context) {
|
||||
writeError(context, internalError(err))
|
||||
return
|
||||
}
|
||||
if role, _ := jwt.ExtractClaims(context)["rolekey"].(string); role != "admin" {
|
||||
id := currentUserID(context)
|
||||
if id > 0 {
|
||||
request.OwnerUserID = &id
|
||||
} else {
|
||||
request.OwnerUserID = new(uint64)
|
||||
}
|
||||
}
|
||||
response, err := NewService(db).List(context.Request.Context(), request)
|
||||
if err != nil {
|
||||
writeError(context, err)
|
||||
@@ -37,6 +46,63 @@ func (handler Handler) List(context *gin.Context) {
|
||||
context.JSON(http.StatusOK, gin.H{"code": http.StatusOK, "data": response})
|
||||
}
|
||||
|
||||
func currentUserID(c *gin.Context) uint64 {
|
||||
value, ok := c.Get("userId")
|
||||
if !ok {
|
||||
return 0
|
||||
}
|
||||
switch id := value.(type) {
|
||||
case int:
|
||||
return uint64(id)
|
||||
case int64:
|
||||
return uint64(id)
|
||||
case uint64:
|
||||
return id
|
||||
case float64:
|
||||
return uint64(id)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (handler Handler) Owners(context *gin.Context) {
|
||||
db, err := handler.database(context)
|
||||
if err != nil {
|
||||
writeError(context, internalError(err))
|
||||
return
|
||||
}
|
||||
rows, err := NewService(db).Owners(context.Request.Context())
|
||||
if err != nil {
|
||||
writeError(context, err)
|
||||
return
|
||||
}
|
||||
context.JSON(http.StatusOK, gin.H{"code": http.StatusOK, "data": rows})
|
||||
}
|
||||
|
||||
func (handler Handler) SetOwner(context *gin.Context) {
|
||||
deviceID, err := strconv.ParseUint(context.Param("deviceId"), 10, 64)
|
||||
if err != nil || deviceID == 0 {
|
||||
writeError(context, invalidRequest("deviceId 无效"))
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
OwnerUserID *uint64 `json:"ownerUserId"`
|
||||
}
|
||||
if err := decodeJSON(context, &req); err != nil {
|
||||
writeError(context, invalidRequest("请求 JSON 无效"))
|
||||
return
|
||||
}
|
||||
db, err := handler.database(context)
|
||||
if err != nil {
|
||||
writeError(context, internalError(err))
|
||||
return
|
||||
}
|
||||
if err := NewService(db).SetOwner(context.Request.Context(), deviceID, req.OwnerUserID); err != nil {
|
||||
writeError(context, err)
|
||||
return
|
||||
}
|
||||
context.JSON(http.StatusOK, gin.H{"code": http.StatusOK, "data": gin.H{"deviceId": deviceID, "ownerUserId": req.OwnerUserID}})
|
||||
}
|
||||
|
||||
func (handler Handler) Register(context *gin.Context) {
|
||||
request, err := decodeRegisterRequest(context)
|
||||
if err != nil {
|
||||
|
||||
@@ -13,10 +13,11 @@ import (
|
||||
)
|
||||
|
||||
type ListRequest struct {
|
||||
Page int
|
||||
PageSize int
|
||||
Name string
|
||||
Status string
|
||||
Page int
|
||||
PageSize int
|
||||
Name string
|
||||
Status string
|
||||
OwnerUserID *uint64
|
||||
}
|
||||
|
||||
type DeviceListItem struct {
|
||||
@@ -36,6 +37,8 @@ type DeviceListItem struct {
|
||||
LastHeartbeatAt *time.Time `json:"lastHeartbeatAt"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
OwnerUserID *uint64 `json:"ownerUserId"`
|
||||
OwnerName string `json:"ownerName"`
|
||||
}
|
||||
|
||||
type DeviceListResponse struct {
|
||||
@@ -68,6 +71,9 @@ func (service *Service) List(ctx context.Context, request ListRequest) (DeviceLi
|
||||
if request.Status != "" {
|
||||
query = query.Where("status = ?", request.Status)
|
||||
}
|
||||
if request.OwnerUserID != nil {
|
||||
query = query.Where("owner_user_id = ?", *request.OwnerUserID)
|
||||
}
|
||||
var total int64
|
||||
if err := query.Count(&total).Error; err != nil {
|
||||
return DeviceListResponse{}, internalError(err)
|
||||
@@ -100,8 +106,8 @@ func (service *Service) List(ctx context.Context, request ListRequest) (DeviceLi
|
||||
AndroidVersion: record.AndroidVersion, AgentVersion: record.AgentVersion, PDDVersion: record.PDDVersion,
|
||||
Capabilities: capabilities,
|
||||
Status: record.Status, LastHeartbeatAt: record.LastHeartbeatAt,
|
||||
TokenRevoked: record.TokenRevokedAt != nil,
|
||||
CreatedAt: record.CreatedAt, UpdatedAt: record.UpdatedAt,
|
||||
TokenRevoked: record.TokenRevokedAt != nil, OwnerUserID: record.OwnerUserID,
|
||||
CreatedAt: record.CreatedAt, UpdatedAt: record.UpdatedAt,
|
||||
}
|
||||
if taskID, busy := currentTasks[record.ID]; busy {
|
||||
item.Busy = true
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
package device
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"go-admin/app/goauto/models"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type DeviceOwnerItem struct {
|
||||
UserID uint64 `json:"userId"`
|
||||
Username string `json:"username"`
|
||||
NickName string `json:"nickName"`
|
||||
}
|
||||
|
||||
func (service *Service) Owners(ctx context.Context) ([]DeviceOwnerItem, error) {
|
||||
var rows []DeviceOwnerItem
|
||||
if err := service.DB.WithContext(ctx).Table("sys_user u").Select("u.user_id AS user_id, u.username, u.nick_name").Joins("JOIN sys_role r ON r.role_id = u.role_id").Where("u.status <> ? AND r.role_key <> ?", "1", "admin").Order("u.user_id").Scan(&rows).Error; err != nil {
|
||||
return nil, internalError(err)
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
func (service *Service) SetOwner(ctx context.Context, deviceID uint64, owner *uint64) error {
|
||||
if deviceID == 0 {
|
||||
return invalidRequest("deviceId 无效")
|
||||
}
|
||||
if owner != nil && *owner == 0 {
|
||||
return invalidRequest("ownerUserId 无效")
|
||||
}
|
||||
if owner != nil {
|
||||
var count int64
|
||||
if err := service.DB.WithContext(ctx).Table("sys_user u").Joins("JOIN sys_role r ON r.role_id = u.role_id").Where("u.user_id = ? AND u.status <> ? AND r.role_key <> ?", *owner, "1", "admin").Count(&count).Error; err != nil {
|
||||
return internalError(err)
|
||||
}
|
||||
if count != 1 {
|
||||
return invalidRequest("采购员不存在或不可分配")
|
||||
}
|
||||
}
|
||||
result := service.DB.WithContext(ctx).Model(&models.AgentDevice{}).Where("id = ?", deviceID).Update("owner_user_id", owner)
|
||||
if result.Error != nil {
|
||||
return internalError(result.Error)
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return gorm.ErrRecordNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseOwnerID(value string) (*uint64, error) {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return nil, nil
|
||||
}
|
||||
id, err := strconv.ParseUint(value, 10, 64)
|
||||
if err != nil || id == 0 {
|
||||
return nil, fmt.Errorf("ownerUserId 无效")
|
||||
}
|
||||
return &id, nil
|
||||
}
|
||||
@@ -21,6 +21,8 @@ func InitRouter(engine *gin.Engine, authMiddleware *jwt.GinJWTMiddleware) {
|
||||
|
||||
admin := engine.Group("/api/admin/v1/devices").Use(authMiddleware.MiddlewareFunc()).Use(middleware.AuthCheckRole())
|
||||
admin.GET("", handler.List)
|
||||
admin.GET("/owners", middleware.RequireRoleKey("admin"), handler.Owners)
|
||||
admin.PATCH("/:deviceId/owner", middleware.RequireRoleKey("admin"), handler.SetOwner)
|
||||
admin.POST("/:deviceId/disable", middleware.RequireRoleKey("admin"), handler.Disable)
|
||||
admin.POST("/:deviceId/identity-reset", middleware.RequireRoleKey("admin"), handler.ResetIdentity)
|
||||
admin.POST("/:deviceId/token/revoke", middleware.RequireRoleKey("admin"), handler.RevokeToken)
|
||||
|
||||
@@ -38,6 +38,7 @@ type AgentDevice struct {
|
||||
AndroidVersion string `json:"androidVersion" gorm:"size:32;not null"`
|
||||
AgentVersion string `json:"agentVersion" gorm:"size:32;not null"`
|
||||
PDDVersion string `json:"pddVersion" gorm:"size:32;not null"`
|
||||
OwnerUserID *uint64 `json:"ownerUserId" gorm:"column:owner_user_id;index"`
|
||||
CapabilitiesJSON string `json:"-" gorm:"size:4096;not null;default:'[]'"`
|
||||
Status string `json:"status" gorm:"size:16;not null;index;check:ck_agent_device_status,status IN ('online','offline','disabled')"`
|
||||
TokenDigest string `json:"-" gorm:"size:64;not null;uniqueIndex:ux_agent_device_token_digest"`
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/go-admin-team/go-admin-core/sdk/pkg"
|
||||
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
@@ -30,9 +31,16 @@ func (handler Handler) List(c *gin.Context) {
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
response, err := service.List(c.Request.Context(), ListRequest{
|
||||
request := ListRequest{
|
||||
Page: page, PageSize: pageSize, GoodsID: c.Query("goodsId"), Keyword: c.Query("keyword"), Status: strings.TrimSpace(c.Query("status")),
|
||||
})
|
||||
}
|
||||
claims := jwt.ExtractClaims(c)
|
||||
if role, _ := claims["rolekey"].(string); role != "admin" {
|
||||
id, _ := claims["identity"].(float64)
|
||||
owner := uint64(id)
|
||||
request.CollectionOwnerUserID = &owner
|
||||
}
|
||||
response, err := service.List(c.Request.Context(), request)
|
||||
if err != nil {
|
||||
writeError(c, err)
|
||||
return
|
||||
|
||||
@@ -64,10 +64,11 @@ type UpdateRequest struct {
|
||||
}
|
||||
|
||||
type ListRequest struct {
|
||||
Page, PageSize int
|
||||
Keyword string
|
||||
GoodsID string
|
||||
Status string
|
||||
Page, PageSize int
|
||||
Keyword string
|
||||
GoodsID string
|
||||
Status string
|
||||
CollectionOwnerUserID *uint64
|
||||
}
|
||||
|
||||
type ProductView struct {
|
||||
@@ -266,6 +267,9 @@ func (service *Service) List(ctx context.Context, request ListRequest) (ListResp
|
||||
}
|
||||
query = query.Where("status = ?", request.Status)
|
||||
}
|
||||
if request.CollectionOwnerUserID != nil {
|
||||
query = query.Where("EXISTS (SELECT 1 FROM collection_task ct JOIN agent_device ad ON ad.id = ct.device_id WHERE ct.pdd_product_id = pdd_product.id AND ct.source = ? AND ct.status IN ? AND ad.owner_user_id = ?)", models.CollectionTaskSourceAgentCurrentPage, []string{models.TaskStatusCompleted, models.TaskStatusCompletedPartial}, *request.CollectionOwnerUserID)
|
||||
}
|
||||
var total int64
|
||||
if err := query.Count(&total).Error; err != nil {
|
||||
return ListResponse{}, internalError(err)
|
||||
|
||||
@@ -205,6 +205,44 @@ func TestListMarksProductsUnavailableForCollection(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestListFiltersManualAssociationProductsByOwnedCollectionDevice(t *testing.T) {
|
||||
db := openProductDatabase(t)
|
||||
service := NewService(db)
|
||||
rule := models.CollectionRule{Name: "owned-rule", ContentJSON: `{}`}
|
||||
if err := db.Create(&rule).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
products := []models.PDDProduct{{GoodsID: "910001", URL: "https://mobile.yangkeduo.com/goods.html?goods_id=910001", Status: "active"}, {GoodsID: "910002", URL: "https://mobile.yangkeduo.com/goods.html?goods_id=910002", Status: "active"}, {GoodsID: "910003", URL: "https://mobile.yangkeduo.com/goods.html?goods_id=910003", Status: "active"}}
|
||||
if err := db.Create(&products).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
owned := models.AgentDevice{InstallID: "owned-manual-list", Name: "owned", Manufacturer: "test", Model: "test", AndroidVersion: "14", AgentVersion: "1", PDDVersion: "1", Status: models.DeviceStatusOffline, TokenDigest: "owned-digest", TokenIssuedAt: time.Now(), OwnerUserID: ptrUint64(41)}
|
||||
other := models.AgentDevice{InstallID: "other-manual-list", Name: "other", Manufacturer: "test", Model: "test", AndroidVersion: "14", AgentVersion: "1", PDDVersion: "1", Status: models.DeviceStatusOffline, TokenDigest: "other-digest", TokenIssuedAt: time.Now(), OwnerUserID: ptrUint64(42)}
|
||||
if err := db.Create(&owned).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Create(&other).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, task := range []models.CollectionTask{{PDDProductID: &products[0].ID, DeviceID: &owned.ID, Source: models.CollectionTaskSourceAgentCurrentPage, Status: models.TaskStatusCompleted}, {PDDProductID: &products[1].ID, DeviceID: &other.ID, Source: models.CollectionTaskSourceAgentCurrentPage, Status: models.TaskStatusCompleted}, {PDDProductID: &products[2].ID, DeviceID: &owned.ID, Source: models.CollectionTaskSourceAdmin, Status: models.TaskStatusCompleted}} {
|
||||
task.RuleID = rule.ID
|
||||
task.URLSnapshot = products[0].URL
|
||||
task.RuleSnapshot = rule.ContentJSON
|
||||
if err := db.Create(&task).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
got, err := service.List(context.Background(), ListRequest{Page: 1, PageSize: 20, CollectionOwnerUserID: ptrUint64(41)})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got.Items) != 1 || got.Items[0].GoodsID != products[0].GoodsID {
|
||||
t.Fatalf("unexpected owned products: %+v", got.Items)
|
||||
}
|
||||
}
|
||||
|
||||
func ptrUint64(value uint64) *uint64 { return &value }
|
||||
|
||||
func TestListDisablesCollectionWhenNoRuleExists(t *testing.T) {
|
||||
db := openProductDatabase(t)
|
||||
product := models.PDDProduct{GoodsID: "444444", URL: "https://mobile.yangkeduo.com/goods.html?goods_id=444444", Status: "active"}
|
||||
|
||||
@@ -108,7 +108,12 @@ func (s *Service) OrderWritebackViews(ctx context.Context, tasks []models.Purcha
|
||||
v.Reason = r.ErrorMessage
|
||||
}
|
||||
v.CompletedAt = r.CompletedAt
|
||||
v.CanSubmit = v.CanSubmit && (r.Status == "failed" || r.Status == "unknown") && (r.LeaseExpiresAt == nil || !r.LeaseExpiresAt.After(s.Now()))
|
||||
// A session-class failure's LeaseExpiresAt is the automatic-retry backoff
|
||||
// deadline (#330 修订2, order_writeback_worker.go finishSessionUnavailable),
|
||||
// not an in-flight write lease — manual "resubmit" must stay available
|
||||
// during that window instead of being hidden until it expires.
|
||||
sessionBackoff := r.Status == "failed" && r.ErrorCode == "SYB_SESSION_UNAVAILABLE"
|
||||
v.CanSubmit = v.CanSubmit && (r.Status == "failed" || r.Status == "unknown") && (sessionBackoff || r.LeaseExpiresAt == nil || !r.LeaseExpiresAt.After(s.Now()))
|
||||
out[r.PurchaseTaskID] = v
|
||||
}
|
||||
return out, nil
|
||||
@@ -197,7 +202,12 @@ func (s *Service) RequestOrderWriteback(ctx context.Context, req OrderWritebackR
|
||||
case row.Status == "pending":
|
||||
a.Result, a.Reason = "pending", "已加入回填"
|
||||
default:
|
||||
if err := tx.Model(&row).Updates(map[string]any{"status": "pending", "write_started": false, "lease_owner": "", "lease_expires_at": nil, "error_code": "", "error_message": ""}).Error; err != nil {
|
||||
// Manual resubmit resets attempt_count to 0 (#330 修订3) so a stale
|
||||
// history of automatic session-class retries never eats into a
|
||||
// fresh manual attempt budget, and clears lease_expires_at so the
|
||||
// worker's bounded auto-retry claim (which requires it non-nil)
|
||||
// does not race a double-claim against this manual pending row.
|
||||
if err := tx.Model(&row).Updates(map[string]any{"status": "pending", "write_started": false, "lease_owner": "", "lease_expires_at": nil, "error_code": "", "error_message": "", "attempt_count": 0}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
a.Result, a.Reason = "pending", "已加入回填,将先回读SYB"
|
||||
|
||||
@@ -0,0 +1,391 @@
|
||||
package purchase
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"go-admin/app/goauto/models"
|
||||
"go-admin/app/goauto/sybclient"
|
||||
"go-admin/config"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// wbFactoryWorker builds a worker whose Factory itself fails, exercising the
|
||||
// restoreOrderWritebackClient failure path (session unavailable) rather than
|
||||
// a remote read/write failure on an otherwise-working client.
|
||||
func wbFactoryWorker(s *Service, err error) *OrderWritebackWorker {
|
||||
return &OrderWritebackWorker{DB: s.DB, Now: s.Now, Factory: func(context.Context, *gorm.DB) (OrderNumberClient, error) { return nil, err }}
|
||||
}
|
||||
|
||||
func TestOrderWritebackSessionFailureSchedulesBoundedRetry(t *testing.T) {
|
||||
s, task := orderWritebackFixture(t)
|
||||
if ok, err := wbFactoryWorker(s, sybclient.ErrNoSession).RunOnce(context.Background()); err != nil || !ok {
|
||||
t.Fatalf("run %v %v", ok, err)
|
||||
}
|
||||
row := loadOrderWriteback(t, s.DB, task.ID)
|
||||
if row.Status != "failed" || row.ErrorCode != "SYB_SESSION_UNAVAILABLE" {
|
||||
t.Fatalf("status=%s code=%s", row.Status, row.ErrorCode)
|
||||
}
|
||||
if row.ErrorMessage == "" || len(row.ErrorMessage) > 300 {
|
||||
t.Fatalf("error message not recorded safely: %q", row.ErrorMessage)
|
||||
}
|
||||
if !strings.Contains(row.ErrorMessage, "会话缺失/已过期") {
|
||||
t.Fatalf("category missing from message: %q", row.ErrorMessage)
|
||||
}
|
||||
if !strings.Contains(row.ErrorMessage, "将自动重试") || !strings.Contains(row.ErrorMessage, "恢复登录") {
|
||||
t.Fatalf("message is not actionable: %q", row.ErrorMessage)
|
||||
}
|
||||
if row.LeaseExpiresAt == nil || !row.LeaseExpiresAt.After(s.Now()) {
|
||||
t.Fatal("no backoff scheduled for first session-class failure")
|
||||
}
|
||||
if row.AttemptCount != 1 {
|
||||
t.Fatalf("attempt_count=%d", row.AttemptCount)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSessionRetryBackoffTotalExceedsHourlySyncWindow guards the ticket's
|
||||
// blocker: the cumulative auto-retry window must outlast one hourly sync
|
||||
// period (up to ~60 minutes from failure to the refresh that fixes it),
|
||||
// otherwise attempts run out before the session has a chance to recover.
|
||||
func TestSessionRetryBackoffTotalExceedsHourlySyncWindow(t *testing.T) {
|
||||
if len(sessionRetryBackoff) != maxSessionRetryAttempts-1 {
|
||||
t.Fatalf("expected %d backoff steps for %d attempts, got %d", maxSessionRetryAttempts-1, maxSessionRetryAttempts, len(sessionRetryBackoff))
|
||||
}
|
||||
var total time.Duration
|
||||
for _, d := range sessionRetryBackoff {
|
||||
total += d
|
||||
}
|
||||
if total <= time.Hour {
|
||||
t.Fatalf("total backoff %s must exceed one hourly sync period", total)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOrderWritebackSessionFailureNotReclaimedBeforeBackoffExpires(t *testing.T) {
|
||||
s, _ := orderWritebackFixture(t)
|
||||
if _, err := wbFactoryWorker(s, sybclient.ErrNoSession).RunOnce(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f := &fakeOrderNumberClient{apply: true}
|
||||
if ok, err := wbWorker(s, f).RunOnce(context.Background()); err != nil || ok {
|
||||
t.Fatalf("claimed before backoff expired: ok=%v err=%v", ok, err)
|
||||
}
|
||||
if f.writes != 0 {
|
||||
t.Fatal("wrote while still inside backoff window")
|
||||
}
|
||||
}
|
||||
|
||||
// loadOrderWriteback in order_writeback_test.go takes (t, db, id); provide a
|
||||
// small adapter so this file reads naturally when task id is already in hand.
|
||||
func loadOrderWritebackByTask(t *testing.T, s *Service, id uint64) models.PurchaseOrderWriteback {
|
||||
return loadOrderWriteback(t, s.DB, id)
|
||||
}
|
||||
|
||||
func TestOrderWritebackSessionFailureReclaimedAfterBackoffExpires(t *testing.T) {
|
||||
s, task := orderWritebackFixture(t)
|
||||
if _, err := wbFactoryWorker(s, sybclient.ErrNoSession).RunOnce(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
row := loadOrderWritebackByTask(t, s, task.ID)
|
||||
s.Now = func() time.Time { return row.LeaseExpiresAt.Add(time.Second) }
|
||||
f := &fakeOrderNumberClient{apply: true}
|
||||
if ok, err := wbWorker(s, f).RunOnce(context.Background()); err != nil || !ok {
|
||||
t.Fatalf("not reclaimed after backoff expired: ok=%v err=%v", ok, err)
|
||||
}
|
||||
if f.writes != 1 {
|
||||
t.Fatal("did not write after successful reclaim")
|
||||
}
|
||||
after := loadOrderWritebackByTask(t, s, task.ID)
|
||||
if after.Status != "succeeded" {
|
||||
t.Fatalf("status=%s", after.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOrderWritebackSessionFailureStopsRetryingAtMaxAttempts(t *testing.T) {
|
||||
s, task := orderWritebackFixture(t)
|
||||
now := s.Now()
|
||||
for i := 0; i < maxSessionRetryAttempts; i++ {
|
||||
s.Now = func() time.Time { return now }
|
||||
if ok, err := wbFactoryWorker(s, sybclient.ErrNoSession).RunOnce(context.Background()); err != nil || !ok {
|
||||
t.Fatalf("attempt %d: ok=%v err=%v", i+1, ok, err)
|
||||
}
|
||||
row := loadOrderWritebackByTask(t, s, task.ID)
|
||||
if row.AttemptCount != i+1 {
|
||||
t.Fatalf("attempt %d: attempt_count=%d", i+1, row.AttemptCount)
|
||||
}
|
||||
if i+1 < maxSessionRetryAttempts {
|
||||
if row.LeaseExpiresAt == nil {
|
||||
t.Fatalf("attempt %d: no backoff scheduled", i+1)
|
||||
}
|
||||
now = row.LeaseExpiresAt.Add(time.Second)
|
||||
} else {
|
||||
if row.LeaseExpiresAt != nil {
|
||||
t.Fatal("lease still scheduled at max attempts")
|
||||
}
|
||||
}
|
||||
}
|
||||
// One more tick past any plausible backoff: the claim query must exclude
|
||||
// attempt_count >= maxSessionRetryAttempts, so nothing is claimed.
|
||||
s.Now = func() time.Time { return now.Add(24 * time.Hour) }
|
||||
f := &fakeOrderNumberClient{apply: true}
|
||||
if ok, err := wbWorker(s, f).RunOnce(context.Background()); err != nil || ok {
|
||||
t.Fatalf("claimed a row past max attempts: ok=%v err=%v", ok, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOrderWritebackCheckSessionInvalidIsSessionClassAndNeverDeletesSession(t *testing.T) {
|
||||
s, task := orderWritebackFixture(t)
|
||||
seedWritebackSession(t, s)
|
||||
if ok, err := wbFactoryWorker(s, sybclient.ErrSessionInvalid).RunOnce(context.Background()); err != nil || !ok {
|
||||
t.Fatalf("run %v %v", ok, err)
|
||||
}
|
||||
row := loadOrderWritebackByTask(t, s, task.ID)
|
||||
if row.Status != "failed" || row.ErrorCode != "SYB_SESSION_UNAVAILABLE" {
|
||||
t.Fatalf("status=%s code=%s", row.Status, row.ErrorCode)
|
||||
}
|
||||
if row.LeaseExpiresAt == nil {
|
||||
t.Fatal("ErrSessionInvalid was not scheduled for retry")
|
||||
}
|
||||
assertWritebackSessionUntouched(t, s)
|
||||
}
|
||||
|
||||
func TestOrderWritebackCheckSessionNetworkErrorIsSessionClassAndNeverDeletesSession(t *testing.T) {
|
||||
s, task := orderWritebackFixture(t)
|
||||
seedWritebackSession(t, s)
|
||||
if ok, err := wbFactoryWorker(s, errors.New("dial tcp: i/o timeout")).RunOnce(context.Background()); err != nil || !ok {
|
||||
t.Fatalf("run %v %v", ok, err)
|
||||
}
|
||||
row := loadOrderWritebackByTask(t, s, task.ID)
|
||||
if row.Status != "failed" || row.ErrorCode != "SYB_SESSION_UNAVAILABLE" {
|
||||
t.Fatalf("status=%s code=%s", row.Status, row.ErrorCode)
|
||||
}
|
||||
if row.LeaseExpiresAt == nil {
|
||||
t.Fatal("network error was not scheduled for retry")
|
||||
}
|
||||
assertWritebackSessionUntouched(t, s)
|
||||
}
|
||||
|
||||
func seedWritebackSession(t *testing.T, s *Service) {
|
||||
t.Helper()
|
||||
store := sybclient.NewSessionStore(s.DB)
|
||||
// restoreOrderWritebackClient's SessionStore.Load compares against real
|
||||
// wall-clock time.Now(), not the service's mocked s.Now (which fixtures
|
||||
// pin to a fixed past date) — so the session must expire relative to the
|
||||
// real clock or Load reports ErrNoSession even though a row exists.
|
||||
if err := store.Save(context.Background(), sybclient.Session{
|
||||
Username: "syb-writeback-test", UserID: 555, CookiesJSON: `[{"name":"SESSION","value":"x"}]`, ExpiresAt: time.Now().Add(time.Hour),
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func assertWritebackSessionUntouched(t *testing.T, s *Service) {
|
||||
t.Helper()
|
||||
var count int64
|
||||
if err := s.DB.Model(&models.SYBSession{}).Where("username = ?", "syb-writeback-test").Count(&count).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if count != 1 {
|
||||
t.Fatal("writeback worker deleted or otherwise removed the cached SYB session")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOrderWritebackCanSubmitDuringSessionBackoff(t *testing.T) {
|
||||
s, task := orderWritebackFixture(t)
|
||||
if _, err := wbFactoryWorker(s, sybclient.ErrNoSession).RunOnce(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
updated := loadBackfillTask(t, s.DB, task.ID)
|
||||
views, err := s.OrderWritebackViews(context.Background(), []models.PurchaseTask{updated})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
v := views[task.ID]
|
||||
if v.Status != "failed" || !v.CanSubmit {
|
||||
t.Fatalf("expected resubmit available during backoff: %+v", v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOrderWritebackManualResubmitResetsAttemptCountAndLease(t *testing.T) {
|
||||
s, task := orderWritebackFixture(t)
|
||||
now := s.Now()
|
||||
for i := 0; i < 3; i++ {
|
||||
s.Now = func() time.Time { return now }
|
||||
if _, err := wbFactoryWorker(s, sybclient.ErrNoSession).RunOnce(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
row := loadOrderWritebackByTask(t, s, task.ID)
|
||||
now = row.LeaseExpiresAt.Add(time.Second)
|
||||
}
|
||||
before := loadOrderWritebackByTask(t, s, task.ID)
|
||||
if before.AttemptCount != 3 {
|
||||
t.Fatalf("attempt_count=%d", before.AttemptCount)
|
||||
}
|
||||
if _, err := s.RequestOrderWriteback(context.Background(), OrderWritebackRequest{uuid.NewString(), []uint64{task.ID}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
after := loadOrderWritebackByTask(t, s, task.ID)
|
||||
if after.Status != "pending" || after.AttemptCount != 0 || after.LeaseExpiresAt != nil {
|
||||
t.Fatalf("manual resubmit did not reset state: %+v", after)
|
||||
}
|
||||
f := &fakeOrderNumberClient{apply: true}
|
||||
if ok, err := wbWorker(s, f).RunOnce(context.Background()); err != nil || !ok || f.writes != 1 {
|
||||
t.Fatalf("worker could not process post-resubmit row: ok=%v err=%v writes=%d", ok, err, f.writes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOrderWritebackOtherFailureCodesAreNotAutoRetried(t *testing.T) {
|
||||
s, task := orderWritebackFixture(t)
|
||||
f := &fakeOrderNumberClient{readErr: errors.New("offline")}
|
||||
if ok, err := wbWorker(s, f).RunOnce(context.Background()); err != nil || !ok {
|
||||
t.Fatalf("run %v %v", ok, err)
|
||||
}
|
||||
row := loadOrderWritebackByTask(t, s, task.ID)
|
||||
if row.Status != "failed" || row.ErrorCode != "SYB_READ_FAILED" {
|
||||
t.Fatalf("status=%s code=%s", row.Status, row.ErrorCode)
|
||||
}
|
||||
if row.LeaseExpiresAt != nil {
|
||||
t.Fatal("non-session failure code must not be scheduled for automatic retry")
|
||||
}
|
||||
s.Now = func() time.Time { return row.CreatedAt.Add(24 * time.Hour) }
|
||||
again := &fakeOrderNumberClient{apply: true}
|
||||
if ok, err := wbWorker(s, again).RunOnce(context.Background()); err != nil || ok {
|
||||
t.Fatalf("a non-session failure code was auto-reclaimed: ok=%v err=%v", ok, err)
|
||||
}
|
||||
}
|
||||
|
||||
// --- restoreOrderWritebackClient against a real sybclient.Client + emulated
|
||||
// SYB /am/user/get, so the CheckSession probe added by #330 is actually
|
||||
// exercised end to end instead of only through a fake Factory. ---
|
||||
|
||||
// sybUserGetServer emulates the one endpoint restoreOrderWritebackClient's
|
||||
// CheckSession call depends on, using the real envelope shape documented in
|
||||
// sybclient/client.go's `envelope` type and asserted against in
|
||||
// sybclient/client_test.go.
|
||||
func sybUserGetServer(t *testing.T, handler func(w http.ResponseWriter, r *http.Request)) *httptest.Server {
|
||||
t.Helper()
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/am/user/get", handler)
|
||||
srv := httptest.NewServer(mux)
|
||||
t.Cleanup(srv.Close)
|
||||
return srv
|
||||
}
|
||||
|
||||
// withWritebackSYBConfig points config.ExtConfig.SYB at the given test
|
||||
// server for the duration of the test, restoring the previous value
|
||||
// afterwards so other tests (and any parallel config reads) are unaffected.
|
||||
func withWritebackSYBConfig(t *testing.T, baseURL, username string) {
|
||||
t.Helper()
|
||||
prev := config.ExtConfig.SYB
|
||||
config.ExtConfig.SYB = config.SYB{BaseURL: baseURL, Username: username}
|
||||
t.Cleanup(func() { config.ExtConfig.SYB = prev })
|
||||
}
|
||||
|
||||
func envelopeOK(w http.ResponseWriter, data any) {
|
||||
body, _ := json.Marshal(data)
|
||||
env, _ := json.Marshal(map[string]any{"status": true, "msg": "获取成功", "data": json.RawMessage(body), "code": nil})
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write(env)
|
||||
}
|
||||
|
||||
func TestRestoreOrderWritebackClientValidSessionReturnsClient(t *testing.T) {
|
||||
s, _ := orderWritebackFixture(t)
|
||||
seedWritebackSession(t, s)
|
||||
srv := sybUserGetServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Query().Get("id") != strconv.FormatInt(555, 10) {
|
||||
t.Fatalf("unexpected id query: %s", r.URL.RawQuery)
|
||||
}
|
||||
envelopeOK(w, map[string]any{"id": 555, "username": "syb-writeback-test"})
|
||||
})
|
||||
withWritebackSYBConfig(t, srv.URL, "syb-writeback-test")
|
||||
client, err := restoreOrderWritebackClient(context.Background(), s.DB)
|
||||
if err != nil || client == nil {
|
||||
t.Fatalf("expected a usable client, got client=%v err=%v", client, err)
|
||||
}
|
||||
assertWritebackSessionUntouched(t, s)
|
||||
}
|
||||
|
||||
func TestRestoreOrderWritebackClientMismatchedUsernameIsSessionInvalid(t *testing.T) {
|
||||
s, _ := orderWritebackFixture(t)
|
||||
seedWritebackSession(t, s)
|
||||
srv := sybUserGetServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
// SYB says the cookie now belongs to a different account (12 §3.5):
|
||||
// treated the same as an explicit logout.
|
||||
envelopeOK(w, map[string]any{"id": 555, "username": "somebody-else"})
|
||||
})
|
||||
withWritebackSYBConfig(t, srv.URL, "syb-writeback-test")
|
||||
_, err := restoreOrderWritebackClient(context.Background(), s.DB)
|
||||
if !errors.Is(err, sybclient.ErrSessionInvalid) {
|
||||
t.Fatalf("expected ErrSessionInvalid, got %v", err)
|
||||
}
|
||||
assertWritebackSessionUntouched(t, s)
|
||||
}
|
||||
|
||||
func TestRestoreOrderWritebackClient500IsNotSessionInvalid(t *testing.T) {
|
||||
s, _ := orderWritebackFixture(t)
|
||||
seedWritebackSession(t, s)
|
||||
srv := sybUserGetServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
})
|
||||
withWritebackSYBConfig(t, srv.URL, "syb-writeback-test")
|
||||
_, err := restoreOrderWritebackClient(context.Background(), s.DB)
|
||||
if err == nil {
|
||||
t.Fatal("expected an error for a 5xx response")
|
||||
}
|
||||
if errors.Is(err, sybclient.ErrSessionInvalid) {
|
||||
t.Fatalf("a 5xx must not be classified as a confirmed logout, got %v", err)
|
||||
}
|
||||
assertWritebackSessionUntouched(t, s)
|
||||
}
|
||||
|
||||
func TestRestoreOrderWritebackClientTimeoutIsNotSessionInvalid(t *testing.T) {
|
||||
s, _ := orderWritebackFixture(t)
|
||||
seedWritebackSession(t, s)
|
||||
srv := sybUserGetServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
<-r.Context().Done() // never respond; the client-side ctx timeout fires first
|
||||
})
|
||||
withWritebackSYBConfig(t, srv.URL, "syb-writeback-test")
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
|
||||
defer cancel()
|
||||
_, err := restoreOrderWritebackClient(ctx, s.DB)
|
||||
if err == nil {
|
||||
t.Fatal("expected an error for a request that never completes")
|
||||
}
|
||||
if errors.Is(err, sybclient.ErrSessionInvalid) {
|
||||
t.Fatalf("a timeout must not be classified as a confirmed logout, got %v", err)
|
||||
}
|
||||
assertWritebackSessionUntouched(t, s)
|
||||
}
|
||||
|
||||
// TestOrderWritebackWorkerWithRealFactoryOnInvalidSession is the requested
|
||||
// end-to-end case: the worker's actual Factory (restoreOrderWritebackClient)
|
||||
// against a server that reports the cached session invalid. It must record
|
||||
// SYB_SESSION_UNAVAILABLE with a scheduled backoff and must not touch the
|
||||
// cached session row.
|
||||
func TestOrderWritebackWorkerWithRealFactoryOnInvalidSession(t *testing.T) {
|
||||
s, task := orderWritebackFixture(t)
|
||||
seedWritebackSession(t, s)
|
||||
srv := sybUserGetServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
envelopeOK(w, map[string]any{"id": 555, "username": "somebody-else"})
|
||||
})
|
||||
withWritebackSYBConfig(t, srv.URL, "syb-writeback-test")
|
||||
w := &OrderWritebackWorker{DB: s.DB, Now: s.Now, Factory: restoreOrderWritebackClient}
|
||||
if ok, err := w.RunOnce(context.Background()); err != nil || !ok {
|
||||
t.Fatalf("run %v %v", ok, err)
|
||||
}
|
||||
row := loadOrderWritebackByTask(t, s, task.ID)
|
||||
if row.Status != "failed" || row.ErrorCode != "SYB_SESSION_UNAVAILABLE" {
|
||||
t.Fatalf("status=%s code=%s", row.Status, row.ErrorCode)
|
||||
}
|
||||
if row.LeaseExpiresAt == nil {
|
||||
t.Fatal("no backoff scheduled")
|
||||
}
|
||||
assertWritebackSessionUntouched(t, s)
|
||||
}
|
||||
@@ -24,12 +24,80 @@ type OrderWritebackWorker struct {
|
||||
Factory func(context.Context, *gorm.DB) (OrderNumberClient, error)
|
||||
}
|
||||
|
||||
// errSessionUserIDMissing marks a cached session whose UserID column is not a
|
||||
// positive SYB account id. SessionStore.Save (session.go) rejects UserID<=0
|
||||
// before it is ever persisted, so this should be unreachable in practice; it
|
||||
// exists so a corrupted/legacy row fails loudly and safely instead of calling
|
||||
// CheckSession with id=0 (#330 修订1).
|
||||
var errSessionUserIDMissing = errors.New("SYB 会话记录缺少有效 user id")
|
||||
|
||||
// Bounded auto-retry for session-class writeback failures (#330). A session
|
||||
// outage self-heals once GoAutoSYBHourlySync refreshes syb_session, but that
|
||||
// refresh only happens once per hour (at :05) and only fires the run *after*
|
||||
// the session is found dead — so the wait from failure to refresh can be
|
||||
// close to a full hour. The backoff schedule below sums to ~90 minutes
|
||||
// (5+10+15+30+30) across maxSessionRetryAttempts=6 attempts, deliberately
|
||||
// longer than one hourly sync period so a session recovered by "the next"
|
||||
// hourly run is still caught automatically instead of exhausting attempts
|
||||
// first. maxSessionRetryAttempts caps the automatic attempts so a session
|
||||
// that never recovers still lands back in "failed" for a human instead of
|
||||
// retrying forever.
|
||||
const maxSessionRetryAttempts = 6
|
||||
|
||||
var sessionRetryBackoff = []time.Duration{
|
||||
5 * time.Minute,
|
||||
10 * time.Minute,
|
||||
15 * time.Minute,
|
||||
30 * time.Minute,
|
||||
30 * time.Minute,
|
||||
}
|
||||
|
||||
// sessionRetryDelay returns the backoff before the next automatic attempt,
|
||||
// given the attempt number (1-based, i.e. the count already recorded for the
|
||||
// attempt that just failed).
|
||||
func sessionRetryDelay(attempt int) time.Duration {
|
||||
idx := attempt - 1
|
||||
if idx < 0 {
|
||||
idx = 0
|
||||
}
|
||||
if idx >= len(sessionRetryBackoff) {
|
||||
idx = len(sessionRetryBackoff) - 1
|
||||
}
|
||||
return sessionRetryBackoff[idx]
|
||||
}
|
||||
|
||||
// sessionUnavailableMessage classifies why the cached SYB session could not
|
||||
// be used, without ever including cookies, tokens or other credential
|
||||
// material (#330 修订1点3). The category — not the raw error text — is what
|
||||
// gets persisted to error_message, wrapped in a fixed, actionable template
|
||||
// that stays well under the 300-char column limit.
|
||||
func sessionUnavailableMessage(err error) string {
|
||||
category := "会话恢复失败(网络/其他)"
|
||||
switch {
|
||||
case errors.Is(err, sybclient.ErrNoSession):
|
||||
category = "会话缺失/已过期"
|
||||
case errors.Is(err, errSessionUserIDMissing):
|
||||
category = "会话记录异常,缺少 user id"
|
||||
case errors.Is(err, sybclient.ErrSessionInvalid):
|
||||
category = "会话校验失效"
|
||||
}
|
||||
return "SYB会话不可用(" + category + "),将自动重试;如持续失败请恢复登录后重试"
|
||||
}
|
||||
|
||||
// restoreOrderWritebackClient rebuilds a SYB client from the cached session
|
||||
// only. It never logs in, never triggers OCR and never deletes the cached
|
||||
// session (that stays the exclusive responsibility of sybimport.Connect's
|
||||
// login/refresh path) — it only reports whether the cached cookies still
|
||||
// work, via CheckSession, so the caller can classify the failure (#330).
|
||||
func restoreOrderWritebackClient(ctx context.Context, db *gorm.DB) (OrderNumberClient, error) {
|
||||
cfg := config.ExtConfig.SYB.Resolved()
|
||||
session, err := sybclient.NewSessionStore(db).Load(ctx, cfg.Username, time.Now())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if session.UserID <= 0 {
|
||||
return nil, errSessionUserIDMissing
|
||||
}
|
||||
c, err := sybclient.New(cfg.BaseURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -37,6 +105,16 @@ func restoreOrderWritebackClient(ctx context.Context, db *gorm.DB) (OrderNumberC
|
||||
if err = c.ImportCookiesJSON(session.CookiesJSON); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Active probe (#330 修订1): without this, a remotely-expired cookie jar
|
||||
// imports cleanly and only fails later inside read(), which would record
|
||||
// it as SYB_READ_FAILED instead of the retryable session-class outcome.
|
||||
// Any error here — ErrSessionInvalid or network/format — is treated as
|
||||
// session-class; only ErrSessionInvalid is a confirmed logout, but a
|
||||
// network/format error is not confirmed-valid either, so it is still
|
||||
// retried rather than attempted as a write.
|
||||
if err = c.CheckSession(ctx, session.UserID, cfg.Username); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
@@ -78,7 +156,10 @@ func (w *OrderWritebackWorker) RunOnce(ctx context.Context) (bool, error) {
|
||||
var item models.PurchaseOrderWriteback
|
||||
recovering := false
|
||||
err := db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("status = ? OR (status = ? AND lease_expires_at <= ?)", "pending", "running", now).Order("id").First(&item).Error; err != nil {
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where(
|
||||
"status = ? OR (status = ? AND lease_expires_at <= ?) OR (status = ? AND error_code = ? AND lease_expires_at IS NOT NULL AND lease_expires_at <= ? AND attempt_count < ?)",
|
||||
"pending", "running", now, "failed", "SYB_SESSION_UNAVAILABLE", now, maxSessionRetryAttempts,
|
||||
).Order("id").First(&item).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
recovering = item.Status == "running"
|
||||
@@ -90,6 +171,11 @@ func (w *OrderWritebackWorker) RunOnce(ctx context.Context) (bool, error) {
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
// tx.Model(&item).Updates used gorm.Expr("attempt_count + 1") above, which
|
||||
// GORM does not read back into the struct; sync it here so downstream
|
||||
// bounded-retry math (finishSessionUnavailable) sees the true post-claim
|
||||
// count instead of being off by one.
|
||||
item.AttemptCount++
|
||||
finish := func(status, code, message string) error {
|
||||
updates := map[string]any{"status": status, "error_code": code, "error_message": message, "lease_owner": ""}
|
||||
if status != "unknown" {
|
||||
@@ -100,6 +186,23 @@ func (w *OrderWritebackWorker) RunOnce(ctx context.Context) (bool, error) {
|
||||
}
|
||||
return db.Model(&models.PurchaseOrderWriteback{}).Where("id = ? AND status = 'running' AND lease_owner = ?", item.ID, owner).Updates(updates).Error
|
||||
}
|
||||
// finishSessionUnavailable is the bounded-retry counterpart of finish for
|
||||
// SYB_SESSION_UNAVAILABLE: instead of clearing the lease, it schedules the
|
||||
// next automatic attempt (item.AttemptCount was already incremented by the
|
||||
// claim above) until maxSessionRetryAttempts is reached, at which point it
|
||||
// behaves like finish("failed", ...) and stops retrying (#330).
|
||||
finishSessionUnavailable := func(err error) error {
|
||||
updates := map[string]any{
|
||||
"status": "failed", "error_code": "SYB_SESSION_UNAVAILABLE",
|
||||
"error_message": sessionUnavailableMessage(err), "lease_owner": "",
|
||||
}
|
||||
if item.AttemptCount < maxSessionRetryAttempts {
|
||||
updates["lease_expires_at"] = w.Now().Add(sessionRetryDelay(item.AttemptCount))
|
||||
} else {
|
||||
updates["lease_expires_at"] = nil
|
||||
}
|
||||
return db.Model(&models.PurchaseOrderWriteback{}).Where("id = ? AND status = 'running' AND lease_owner = ?", item.ID, owner).Updates(updates).Error
|
||||
}
|
||||
var task models.PurchaseTask
|
||||
if err = db.First(&task, item.PurchaseTaskID).Error; err != nil {
|
||||
return true, finish("failed", "TASK_UNAVAILABLE", "采购任务不可用,请人工核对")
|
||||
@@ -115,7 +218,7 @@ func (w *OrderWritebackWorker) RunOnce(ctx context.Context) (bool, error) {
|
||||
client, err := w.Factory(callCtx, db)
|
||||
cancel()
|
||||
if err != nil {
|
||||
return true, finish("failed", "SYB_SESSION_UNAVAILABLE", "SYB会话不可用,请恢复登录后重试")
|
||||
return true, finishSessionUnavailable(err)
|
||||
}
|
||||
read := func() (string, string, error) {
|
||||
readCtx, stop := context.WithTimeout(ctx, 20*time.Second)
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/go-admin-team/go-admin-core/sdk/pkg"
|
||||
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
@@ -120,6 +121,8 @@ func (handler Handler) LinkPDD(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
service.ReplacementEligibility = handler.ReplacementEligibility
|
||||
service.OperatorUserID = currentUserID(c)
|
||||
service.OperatorIsAdmin = currentRole(c) == "admin"
|
||||
response, err := service.LinkPDD(c.Request.Context(), id, request)
|
||||
respond(c, response, err)
|
||||
}
|
||||
@@ -442,6 +445,11 @@ func currentUserID(c *gin.Context) uint64 {
|
||||
}
|
||||
}
|
||||
|
||||
func currentRole(c *gin.Context) string {
|
||||
value, _ := jwt.ExtractClaims(c)["rolekey"].(string)
|
||||
return value
|
||||
}
|
||||
|
||||
func respond(c *gin.Context, response SaveResponse, err error) {
|
||||
if err != nil {
|
||||
writeError(c, err)
|
||||
@@ -489,6 +497,8 @@ func writeError(c *gin.Context, err error) {
|
||||
status = http.StatusNotFound
|
||||
case CodePDDProductDisabled, CodeSpecContextStale, CodeLatestCollectionUnavailable, CodeLinkConflict:
|
||||
status = http.StatusConflict
|
||||
case CodeDeviceOwnershipForbidden:
|
||||
status = http.StatusForbidden
|
||||
case CodeAIUnavailable:
|
||||
status = http.StatusServiceUnavailable
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
|
||||
const CodeLatestCollectionUnavailable = "LATEST_COLLECTION_UNAVAILABLE"
|
||||
const CodeLinkConflict = "PDD_LINK_CONFLICT"
|
||||
const CodeDeviceOwnershipForbidden = "DEVICE_OWNERSHIP_FORBIDDEN"
|
||||
|
||||
type LatestCollectionRequest struct {
|
||||
SYBProductID uint64 `json:"sybProductId"`
|
||||
@@ -105,6 +106,9 @@ func (service *Service) linkLatestCollection(ctx context.Context, id uint64, req
|
||||
if device.Status != models.DeviceStatusOnline && device.Status != models.DeviceStatusOffline {
|
||||
return latestUnavailable("手机已停用,请重新选择")
|
||||
}
|
||||
if service.OperatorUserID > 0 && !service.OperatorIsAdmin && (device.OwnerUserID == nil || *device.OwnerUserID != service.OperatorUserID) {
|
||||
return &ServiceError{Code: CodeDeviceOwnershipForbidden, Message: "该手机不属于当前采购员,无法使用其采集记录", Retryable: false}
|
||||
}
|
||||
var task models.CollectionTask
|
||||
query := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("device_id = ? AND source = ? AND status IN ?", r.DeviceID, models.CollectionTaskSourceAgentCurrentPage, []string{models.TaskStatusCompleted, models.TaskStatusCompletedPartial})
|
||||
if replacement != nil && !replacement.Preview {
|
||||
|
||||
@@ -63,6 +63,21 @@ func TestLatestCollectionOfflineAndOnlyIfUnlinked(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestLatestCollectionRejectsDeviceOwnedByAnotherUser(t *testing.T) {
|
||||
s, id, req, _ := latestFixture(t)
|
||||
owner := uint64(21)
|
||||
s.DB.Model(&models.AgentDevice{}).Where("id = ?", req.LatestCollection.DeviceID).Update("owner_user_id", owner)
|
||||
s.OperatorUserID = 22
|
||||
if _, err := s.LinkPDD(context.Background(), id, req); err == nil || errCode(t, err) != CodeDeviceOwnershipForbidden {
|
||||
t.Fatalf("expected ownership rejection: %v", err)
|
||||
}
|
||||
var product models.ShopeeProduct
|
||||
s.DB.First(&product, id)
|
||||
if product.PDDProductID != nil {
|
||||
t.Fatal("ownership rejection wrote association")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLatestCollectionRejectsInvalidContextWithoutWrite(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
|
||||
@@ -62,6 +62,8 @@ type ReplacementEligibility func(context.Context, *gorm.DB, uint64) error
|
||||
type Service struct {
|
||||
DB *gorm.DB
|
||||
ReplacementEligibility ReplacementEligibility
|
||||
OperatorUserID uint64
|
||||
OperatorIsAdmin bool
|
||||
}
|
||||
|
||||
func NewService(db *gorm.DB) *Service { return &Service{DB: db} }
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
package version_local
|
||||
|
||||
import (
|
||||
goautomigrations "go-admin/app/goauto/migrations"
|
||||
"go-admin/cmd/migrate/migration"
|
||||
common "go-admin/common/models"
|
||||
"gorm.io/gorm"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
func init() {
|
||||
fileName := filepath.Base("1789800300000_device_owner.go")
|
||||
migration.Migrate.SetVersion(migration.GetFilename(fileName), migrateDeviceOwner)
|
||||
}
|
||||
func migrateDeviceOwner(tx *gorm.DB, version string) error {
|
||||
return tx.Transaction(func(db *gorm.DB) error {
|
||||
if err := goautomigrations.Migrate(db); err != nil {
|
||||
return err
|
||||
}
|
||||
return db.Create(&common.Migration{Version: version}).Error
|
||||
})
|
||||
}
|
||||
@@ -8,6 +8,14 @@ export function listDevices(params) {
|
||||
})
|
||||
}
|
||||
|
||||
export function listDeviceOwners() {
|
||||
return request({ url: '/api/admin/v1/devices/owners', method: 'get' })
|
||||
}
|
||||
|
||||
export function setDeviceOwner(deviceId, ownerUserId) {
|
||||
return request({ url: `/api/admin/v1/devices/${deviceId}/owner`, method: 'patch', data: { ownerUserId} })
|
||||
}
|
||||
|
||||
export function disableDevice(deviceId) {
|
||||
return request({
|
||||
url: `/api/admin/v1/devices/${deviceId}/disable`,
|
||||
|
||||
@@ -34,6 +34,9 @@
|
||||
<div class="device-model">{{ row.manufacturer }} {{ row.model }}</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="所属采购员" min-width="150">
|
||||
<template #default="{ row }">{{ ownerLabel(row) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="系统" min-width="130">
|
||||
<template #default="{ row }">Android {{ row.androidVersion }}</template>
|
||||
</el-table-column>
|
||||
@@ -70,6 +73,7 @@
|
||||
</el-table-column>
|
||||
<el-table-column v-if="isAdmin" label="操作" width="270" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button type="primary" link @click="openOwnerDialog(row)">归属</el-button>
|
||||
<el-button
|
||||
type="warning"
|
||||
link
|
||||
@@ -90,7 +94,7 @@
|
||||
>吊销 Token</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-table>
|
||||
|
||||
<pagination
|
||||
v-show="total > 0"
|
||||
@@ -100,6 +104,13 @@
|
||||
@pagination="getList"
|
||||
/>
|
||||
</el-card>
|
||||
<el-dialog v-model="ownerDialog.open" title="设置所属采购员" width="420px">
|
||||
<p v-if="ownerDialog.row">设备:{{ ownerDialog.row.name }}(#{{ ownerDialog.row.id }})</p>
|
||||
<el-select v-model="ownerDialog.ownerUserId" clearable placeholder="未归属" style="width: 100%" :loading="ownerDialog.loading">
|
||||
<el-option v-for="owner in ownerDialog.owners" :key="owner.userId" :label="owner.nickName || owner.username" :value="owner.userId" />
|
||||
</el-select>
|
||||
<template #footer><el-button @click="ownerDialog.open = false">取消</el-button><el-button type="primary" :loading="ownerDialog.saving" @click="saveOwner">保存</el-button></template>
|
||||
</el-dialog>
|
||||
<el-drawer v-model="releaseDrawer.open" title="Agent 版本管理" size="720px" destroy-on-close>
|
||||
<el-alert type="info" :closable="false" show-icon title="上传后需明确设为当前版本;APK 下载需要 Admin 或设备 Token,不提供公开静态地址。" />
|
||||
<el-form label-position="top" class="release-upload-form">
|
||||
@@ -122,7 +133,7 @@
|
||||
<script>
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Refresh, RefreshLeft, Search } from '@element-plus/icons-vue'
|
||||
import { disableDevice, listDevices, resetDeviceIdentity, revokeDeviceToken } from '@/api/goauto/devices'
|
||||
import { disableDevice, listDeviceOwners, listDevices, resetDeviceIdentity, revokeDeviceToken, setDeviceOwner } from '@/api/goauto/devices'
|
||||
import { downloadAgentAppRelease, listAgentAppReleases, setCurrentAgentAppRelease, uploadAgentAppRelease } from '@/api/goauto/agent-app-releases'
|
||||
import { createRequestId } from '@/utils/request-id'
|
||||
|
||||
@@ -137,7 +148,8 @@ export default {
|
||||
devices: [],
|
||||
total: 0,
|
||||
query: { page: 1, pageSize: 20, name: '', status: '' },
|
||||
releaseDrawer: { open: false, loading: false, uploading: false, progress: 0, file: null, notes: '', items: [] }
|
||||
releaseDrawer: { open: false, loading: false, uploading: false, progress: 0, file: null, notes: '', items: [] },
|
||||
ownerDialog: { open: false, loading: false, saving: false, row: null, ownerUserId: null, owners: [] }
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
@@ -171,6 +183,9 @@ export default {
|
||||
statusType(status) {
|
||||
return { online: 'success', offline: 'info', disabled: 'danger' }[status] || 'info'
|
||||
},
|
||||
ownerLabel(row) { if (!row.ownerUserId) return '未归属'; const owner = this.ownerDialog.owners.find(item => item.userId === row.ownerUserId); return owner ? (owner.nickName || owner.username) : `用户 #${row.ownerUserId}` },
|
||||
async openOwnerDialog(row) { this.ownerDialog.row = row; this.ownerDialog.ownerUserId = row.ownerUserId || null; this.ownerDialog.open = true; this.ownerDialog.loading = true; try { this.ownerDialog.owners = (await listDeviceOwners()).data || [] } finally { this.ownerDialog.loading = false } },
|
||||
async saveOwner() { if (!this.ownerDialog.row) return; this.ownerDialog.saving = true; try { await setDeviceOwner(this.ownerDialog.row.id, this.ownerDialog.ownerUserId); ElMessage.success('设备归属已更新'); this.ownerDialog.open = false; await this.getList() } finally { this.ownerDialog.saving = false } },
|
||||
async openReleases() { this.releaseDrawer.open = true; await this.loadReleases() },
|
||||
async loadReleases() { this.releaseDrawer.loading = true; try { const response = await listAgentAppReleases({ page: 1, pageSize: 100 }); this.releaseDrawer.items = response.data.items } finally { this.releaseDrawer.loading = false } },
|
||||
onAPKChange(file) { this.releaseDrawer.file = file.raw }, onAPKRemove() { this.releaseDrawer.file = null },
|
||||
|
||||
Reference in New Issue
Block a user