Compare commits

...
Author SHA1 Message Date
QiuSWandClaude Opus 5 d8382e6518 fix(android): recognize spec panel by address, payment and quantity input (#331)
- PddScreenParser: add REQUIRED_EVIDENCE fallback type after all existing
  branches; requires address entry (masked phone in a nearby clickable row),
  payment entry (specPanel.paymentAreaAliases in a nearby clickable row) and
  one enabled quantity EditText, plus at least one auxiliary signal
  (options, summary, close, +/- or submit action). +/- no longer required
  on this path; existing branches unchanged.
- The three required items must share one panel container: their lowest
  common ancestor must not be a window root and must not cover the whole
  screen. Items from an address dialog, a payment dialog and an unrelated
  input box are not recognized.
- Purchaser hints on SPEC_PANEL_EVIDENCE_NOT_MATCHED, the spec-panel wait
  diagnostic and the address-save timeout when a required item is missing
  (default WeChat Pay / default address / quantity input). No Alipay alias.
- Purchase spec-entry wait and post address-save wait: bounded 5000 ms,
  fallback panels need two identical structure samples, no Back while
  waiting, explicit failure on timeout; boolean/count diagnostics only.
- Final submit target additionally rejects zero-size labels/containers.
- Diagnostics: evidence flags (incl. same-container) in panel evidence and
  spec-panel entry event.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
2026-09-22 10:14:03 +08:00
QiuSWandClaude Opus 5 0edc78a943 fix(android): dedupe spec option nodes and diagnose selection mismatch (#332)
- PddScreenParser: nested clickable nodes of one labelled option block
  (outer block, image, inner text with a badge such as "零差评") are
  collapsed into one spec value named by the outer block (task 535). The
  click node is still chosen by safeOptionRank; selected/checked flags are
  merged from the block members; no badge-text stripping.
- PurchaseRehearsalExecutor: on SPEC_SELECTION_UNCONFIRMED emit a
  specSelectionUnconfirmed diagnostic with the dimension, the target value
  and the parsed option texts (each truncated to 40 chars) with selected
  flags. Spec values are product attributes only.
- Tests: SpecOptionDedupTest with a sanitized task 535 fixture.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
2026-09-22 10:08:25 +08:00
QiuSWandClaude Opus 5 5e0a9d108c docs: sync wiki mirrors after restore and #330 update (#330)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
2026-09-21 16:16:57 +08:00
QiuSWandClaude Opus 5 08b7095cf1 fix(purchase): widen SYB writeback backoff, cover CheckSession, improve message (#330 review)
Address review findings on 01510a8:

1. BLOCKER: sessionRetryBackoff summed to 30min, shorter than the up-to-
   ~60min gap between a session dying and the next hourly SYB sync
   refreshing it. Changed to 5m/10m/15m/30m/30m (total 90min across
   maxSessionRetryAttempts=6), updated the code comment to state the
   ~90min > one hourly sync period rationale, and added
   TestSessionRetryBackoffTotalExceedsHourlySyncWindow to guard it.

2. Test gap: the CheckSession probe added inside
   restoreOrderWritebackClient was only exercised through a fake
   Factory, never through a real sybclient.Client. Added
   httptest-backed tests that run restoreOrderWritebackClient against
   an emulated /am/user/get (matching the envelope shape in
   sybclient/client.go's `envelope` type): valid session returns a
   client, mismatched username maps to ErrSessionInvalid, 5xx/timeout
   map to a non-invalid error — each asserting the syb_session row is
   left untouched. Added an end-to-end worker test using the real
   Factory against the invalid-session server, asserting
   failed/SYB_SESSION_UNAVAILABLE with a scheduled backoff and an
   intact session row.

3. sessionUnavailableMessage: renamed the default category to
   "会话恢复失败(网络/其他)" and wrapped every category in an
   actionable template ("SYB会话不可用(<类别>),将自动重试;如持续
   失败请恢复登录后重试"), still well under the 300-char column limit
   and free of raw error text/credentials.

Tests: go vet ./app/goauto/purchase/... (clean); go test
./app/goauto/purchase/... (ok, 3.4s, includes the new httptest-backed
CheckSession coverage and the backoff-window guard).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
2026-09-21 16:09:34 +08:00
QiuSWandClaude Opus 5 01510a85dc fix(purchase): bounded auto-retry for SYB writeback session failures (#330)
SYB order-number writeback silently gave up on session-class failures
(SYB_SESSION_UNAVAILABLE), requiring manual resubmit even though the
hourly sync job refreshes the session on its own. This adds a bounded,
backoff-scheduled auto-retry for that error code only:

- restoreOrderWritebackClient now actively probes the cached cookie
  jar with sybclient.CheckSession after import, so a remotely-expired
  session is classified as retryable up front instead of surfacing
  later as SYB_READ_FAILED. It never logs in, never triggers OCR and
  never deletes the cached session.
- The dropped Factory error is now categorized into a safe message
  (no cookies/tokens) and recorded in error_message.
- The worker's claim query additionally picks up failed rows with
  error_code=SYB_SESSION_UNAVAILABLE once their backoff
  (lease_expires_at) has elapsed and attempt_count is below
  maxSessionRetryAttempts=6 (1m/2m/4m/8m/15m growing backoff, chosen
  to span the hourly sync window); other failure codes are unchanged.
- CanSubmit no longer hides manual resubmit during that backoff
  window; manual resubmit resets attempt_count to 0 and clears the
  lease so the worker cannot double-claim the same row.

Diff is limited to the purchase package; sybimport/sybclient/
sybinnercode are untouched.

Tests: go test ./app/goauto/purchase/... (new
order_writeback_session_retry_test.go covers backoff scheduling,
reclaim timing, max-attempt cutoff, CheckSession invalid/network
classification with no session deletion, CanSubmit during backoff,
manual resubmit reset, and non-session codes being excluded).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
2026-09-21 16:05:26 +08:00
QiuSW 4261a542ca fix(#328): filter manual PDD association by owned devices 2026-09-21 11:08:41 +08:00
QiuSW beec630187 fix(#329): record device ownership migration 2026-09-21 10:38:54 +08:00
QiuSW 2491a857f7 docs: record purchaser device ownership (#328) 2026-09-21 10:26:33 +08:00
QiuSW 0661b2205f fix(#328): enforce purchaser device ownership 2026-09-21 10:13:23 +08:00
QiuSW bb1a410e8a feat(#329): add device purchaser ownership 2026-09-21 09:59:00 +08:00
42 changed files with 2193 additions and 85 deletions
@@ -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"
@@ -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("待付款", "待支付", "去支付")
@@ -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 -2
View File
@@ -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
View File
@@ -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 -->
# 开发工作流
+8 -2
View File
@@ -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 负责持久运行状态、聚合冲突和分批提交。
+7 -3
View File
@@ -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 -2
View File
@@ -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 -2
View File
@@ -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 -2
View File
@@ -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 -2
View File
@@ -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 -2
View File
@@ -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 -2
View File
@@ -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 -2
View File
@@ -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 -2
View File
@@ -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
View File
@@ -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 文档中心
+2 -2
View File
@@ -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 -->
# 部署文档模板
+2 -2
View File
@@ -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/`。
+66
View File
@@ -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 {
+12 -6
View File
@@ -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
+63
View File
@@ -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
}
+2
View File
@@ -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)
+1
View File
@@ -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 -2
View File
@@ -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
+8 -4
View File
@@ -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)
+38
View File
@@ -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"}
+12 -2
View File
@@ -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
View File
@@ -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`,
+18 -3
View File
@@ -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 },