Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
683aa9c671 | ||
|
|
1ac50064bd | ||
|
|
8c76e0932b | ||
|
|
22755e05e3 | ||
|
|
494969d4f8 |
+47
-24
@@ -333,31 +333,32 @@ class GoAutoAccessibilityService : AccessibilityService(), UiDriver, PddCollecto
|
||||
if (bounds.width() < 2 || bounds.height() < 2 || Build.VERSION.SDK_INT < Build.VERSION_CODES.N) {
|
||||
return FreshActionResult.FAILED
|
||||
}
|
||||
val path = Path().apply {
|
||||
moveTo(bounds.centerX().toFloat(), bounds.centerY().toFloat())
|
||||
}
|
||||
val completed = AtomicBoolean(false)
|
||||
val latch = CountDownLatch(1)
|
||||
val queued = dispatchGesture(
|
||||
GestureDescription.Builder().addStroke(GestureDescription.StrokeDescription(path, 0, 80)).build(),
|
||||
object : GestureResultCallback() {
|
||||
override fun onCompleted(gestureDescription: GestureDescription?) {
|
||||
completed.set(true)
|
||||
latch.countDown()
|
||||
}
|
||||
return if (dispatchCenterTap(bounds)) FreshActionResult.SUCCESS else FreshActionResult.FAILED
|
||||
}
|
||||
|
||||
override fun onCancelled(gestureDescription: GestureDescription?) {
|
||||
latch.countDown()
|
||||
}
|
||||
},
|
||||
null,
|
||||
)
|
||||
if (!queued) return FreshActionResult.FAILED
|
||||
return if (latch.await(1_500, TimeUnit.MILLISECONDS) && completed.get()) {
|
||||
FreshActionResult.SUCCESS
|
||||
} else {
|
||||
FreshActionResult.FAILED
|
||||
override fun tapSpecFresh(target: SnapshotNode): FreshActionResult {
|
||||
val width = resources.displayMetrics.widthPixels
|
||||
val height = resources.displayMetrics.heightPixels
|
||||
if (!PurchaseSpecGesturePolicy.isAllowed(target, width, height)) return FreshActionResult.FAILED
|
||||
val root = rootInActiveWindow ?: return FreshActionResult.NOT_FOUND
|
||||
val candidates = mutableListOf<AccessibilityNodeInfo>()
|
||||
walk(root) { node ->
|
||||
val bounds = Rect().also(node::getBoundsInScreen)
|
||||
if (node.isVisibleToUser && node.isEnabled &&
|
||||
node.preferredOrDescendantLabel() == target.label &&
|
||||
node.className?.toString() == target.className &&
|
||||
kotlin.math.abs(bounds.centerX() - target.bounds.centerX) <= 32 &&
|
||||
kotlin.math.abs(bounds.centerY() - target.bounds.centerY) <= 32
|
||||
) candidates += node
|
||||
}
|
||||
if (candidates.isEmpty()) return FreshActionResult.NOT_FOUND
|
||||
if (candidates.size != 1) return FreshActionResult.AMBIGUOUS
|
||||
val bounds = Rect().also(candidates.single()::getBoundsInScreen)
|
||||
val freshTarget = target.copy(bounds = NodeBounds(bounds.left, bounds.top, bounds.right, bounds.bottom))
|
||||
if (!PurchaseSpecGesturePolicy.isAllowed(freshTarget, width, height)) {
|
||||
return FreshActionResult.FAILED
|
||||
}
|
||||
return if (dispatchCenterTap(bounds)) FreshActionResult.SUCCESS else FreshActionResult.FAILED
|
||||
}
|
||||
|
||||
override fun inputFresh(target: SnapshotNode, value: String): FreshActionResult {
|
||||
@@ -407,7 +408,7 @@ class GoAutoAccessibilityService : AccessibilityService(), UiDriver, PddCollecto
|
||||
) matches += node
|
||||
}
|
||||
if (matches.size != 1) return false
|
||||
return swipeNode(matches.single(), direction, durationMs, preferScrollAction = false)
|
||||
return swipeNode(matches.single(), direction, durationMs, preferScrollAction = true)
|
||||
}
|
||||
|
||||
override fun backPurchase(): Boolean = performGlobalAction(GLOBAL_ACTION_BACK)
|
||||
@@ -617,6 +618,28 @@ class GoAutoAccessibilityService : AccessibilityService(), UiDriver, PddCollecto
|
||||
return latch.await(1500, TimeUnit.MILLISECONDS) && completed.get()
|
||||
}
|
||||
|
||||
private fun dispatchCenterTap(bounds: Rect): Boolean {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N || bounds.width() < 2 || bounds.height() < 2) return false
|
||||
val path = Path().apply { moveTo(bounds.centerX().toFloat(), bounds.centerY().toFloat()) }
|
||||
val completed = AtomicBoolean(false)
|
||||
val latch = CountDownLatch(1)
|
||||
val queued = dispatchGesture(
|
||||
GestureDescription.Builder().addStroke(GestureDescription.StrokeDescription(path, 0, 80)).build(),
|
||||
object : GestureResultCallback() {
|
||||
override fun onCompleted(gestureDescription: GestureDescription?) {
|
||||
completed.set(true)
|
||||
latch.countDown()
|
||||
}
|
||||
|
||||
override fun onCancelled(gestureDescription: GestureDescription?) {
|
||||
latch.countDown()
|
||||
}
|
||||
},
|
||||
null,
|
||||
)
|
||||
return queued && latch.await(1_500, TimeUnit.MILLISECONDS) && completed.get()
|
||||
}
|
||||
|
||||
private fun walk(node: AccessibilityNodeInfo, visit: (AccessibilityNodeInfo) -> Unit) {
|
||||
visit(node)
|
||||
for (index in 0 until node.childCount) node.getChild(index)?.let { walk(it, visit) }
|
||||
|
||||
+16
-4
@@ -117,7 +117,8 @@ class PurchaseLiveAutomation(
|
||||
return null
|
||||
}
|
||||
val labels = linkedSetOf<String>()
|
||||
var backedOutOfPayment = false
|
||||
var paymentBackAttempts = 0
|
||||
var consecutivePaymentSamplesAfterBack = 0
|
||||
var backedOutOfChooser = false
|
||||
var restoredFromWechat = false
|
||||
var pddObservedAfterWechatRestore = false
|
||||
@@ -170,17 +171,27 @@ class PurchaseLiveAutomation(
|
||||
// it permits only bounded reading gestures below, never a payment click.
|
||||
val paymentVisible = isKnownPddPaymentActivity(snapshot) && !orderContextVisible && !unpaidContextVisible
|
||||
if (paymentVisible) {
|
||||
if (!backedOutOfPayment) {
|
||||
backedOutOfPayment = true
|
||||
if (paymentBackAttempts == 0) {
|
||||
paymentBackAttempts++
|
||||
if (!driver.backPurchase()) {
|
||||
return unknown("PURCHASE_ORDER_PAYMENT_BACK_FAILED", "支付页无法安全返回订单详情")
|
||||
}
|
||||
pause(500)
|
||||
} else {
|
||||
return unknown("PURCHASE_ORDER_PAYMENT_REPEATED", "支付页重复出现,已停止自动核单")
|
||||
consecutivePaymentSamplesAfterBack++
|
||||
if (consecutivePaymentSamplesAfterBack >= ORDER_RESULT_PAYMENT_POST_BACK_MAX_SAMPLES) {
|
||||
return unknown(
|
||||
"PURCHASE_ORDER_PAYMENT_REPEATED",
|
||||
"支付页安全返回后持续无订单证据,已停止自动核单" +
|
||||
"[paymentBackAttempts=$paymentBackAttempts;" +
|
||||
"consecutivePaymentSamplesAfterBack=$consecutivePaymentSamplesAfterBack]",
|
||||
)
|
||||
}
|
||||
pause(ORDER_RESULT_SAMPLE_INTERVAL_MS)
|
||||
}
|
||||
return@repeat
|
||||
}
|
||||
consecutivePaymentSamplesAfterBack = 0
|
||||
if (!orderContextVisible && !unpaidContextVisible) {
|
||||
val entries = orderDetailEntryTargets(snapshot)
|
||||
if (entries.size > 1) {
|
||||
@@ -469,6 +480,7 @@ class PurchaseLiveAutomation(
|
||||
const val ORDER_RESULT_MAX_SAMPLES = 60
|
||||
const val ORDER_RESULT_MAX_EMPTY_SAMPLES = 15
|
||||
const val ORDER_RESULT_WECHAT_RESTORE_MAX_SAMPLES = 15
|
||||
const val ORDER_RESULT_PAYMENT_POST_BACK_MAX_SAMPLES = 3
|
||||
const val ORDER_RESULT_SCROLL_SAMPLE_INTERVAL = 15
|
||||
const val ORDER_RESULT_SAMPLE_INTERVAL_MS = 200L
|
||||
}
|
||||
|
||||
+220
-82
@@ -11,6 +11,12 @@ interface PurchaseUiDriver {
|
||||
reason = FreshClickReason.UNKNOWN,
|
||||
)
|
||||
fun tapPurchaseFresh(target: SnapshotNode): FreshActionResult
|
||||
/**
|
||||
* Reacquires and center-taps a target that the purchase parser has already
|
||||
* accepted as either a safe spec entry or one exact spec value. Callers
|
||||
* must still verify the semantic postcondition from a fresh snapshot.
|
||||
*/
|
||||
fun tapSpecFresh(target: SnapshotNode): FreshActionResult = FreshActionResult.FAILED
|
||||
fun inputFresh(target: SnapshotNode, value: String): FreshActionResult
|
||||
fun swipePurchase(direction: SwipeDirection, durationMs: Long): Boolean
|
||||
fun swipePurchaseIn(target: SnapshotNode, direction: SwipeDirection, durationMs: Long): Boolean
|
||||
@@ -20,6 +26,21 @@ interface PurchaseUiDriver {
|
||||
fun bringPddToForeground(): Boolean = false
|
||||
}
|
||||
|
||||
internal object PurchaseSpecGesturePolicy {
|
||||
private val forbiddenLabels = listOf(
|
||||
"提交订单", "创建订单", "确认订单", "确认购买", "支付", "付款",
|
||||
"收货地址", "修改地址", "保存地址",
|
||||
)
|
||||
|
||||
fun isAllowed(target: SnapshotNode, screenWidth: Int, screenHeight: Int): Boolean {
|
||||
val label = target.label.replace(" ", "")
|
||||
return target.visible && target.enabled && label.isNotBlank() &&
|
||||
forbiddenLabels.none(label::contains) &&
|
||||
target.bounds.width >= 2 && target.bounds.height >= 2 &&
|
||||
target.bounds.centerX in 0 until screenWidth && target.bounds.centerY in 0 until screenHeight
|
||||
}
|
||||
}
|
||||
|
||||
data class PurchaseExecutionInput(
|
||||
val taskId: Long,
|
||||
val executionMode: String,
|
||||
@@ -224,8 +245,9 @@ class PurchaseRehearsalExecutor(
|
||||
repeat(50) {
|
||||
val snapshot = driver.capture()
|
||||
pageProblem(snapshot)?.let { return it }
|
||||
if (snapshot.packageName == PDD_PACKAGE && snapshot.nodes.any { it.visible }) {
|
||||
return recoverSoldOut(input, PddScreenParser.parse(snapshot, DEFAULT_COLLECTOR, input.goodsId, null))
|
||||
val screen = PddScreenParser.parse(snapshot, DEFAULT_COLLECTOR, input.goodsId, null)
|
||||
if (screen.hasPurchaseProductEvidence()) {
|
||||
return recoverSoldOut(input, screen)
|
||||
}
|
||||
pause(100)
|
||||
}
|
||||
@@ -266,48 +288,131 @@ class PurchaseRehearsalExecutor(
|
||||
|
||||
private fun openSpecPanel(input: PurchaseExecutionInput, action: PurchaseAction): PurchaseExecutionOutcome? {
|
||||
var screen = currentScreen(input)
|
||||
if (screen.reviewPageOpen) return leaveUnexpectedReviewPage(input)
|
||||
screen.problem?.let { return failure(it.code, it.message) }
|
||||
if (screen.specPanelOpen) return null
|
||||
val safeCandidates = listOfNotNull(
|
||||
screen.specEntry?.let { anchor -> anchor to (screen.specEntryClickTarget ?: anchor) },
|
||||
screen.quickConfirmationEntry?.let { it to it },
|
||||
).distinctBy { it.second.path }
|
||||
// A rule alias may only narrow the semantic candidates already accepted
|
||||
// by PddScreenParser. It must never turn into a raw-page text lookup:
|
||||
// review cards and unrelated controls can share arbitrary labels.
|
||||
val candidates = action.textAliases?.let { aliases ->
|
||||
safeCandidates.filter { (anchor, _) -> specEntryMatchesAliases(screen, anchor, aliases) }
|
||||
} ?: safeCandidates
|
||||
if (candidates.size > 1) {
|
||||
panelDiagnostic(specEntryEvidence(screen, candidates.size))
|
||||
return failure(SPEC_ENTRY_TARGET_AMBIGUOUS, "规格入口候选不唯一 [${specEntryEvidence(screen, candidates.size)}]")
|
||||
var entryReadyWaitPolls = 0
|
||||
var target: SnapshotNode? = null
|
||||
while (target == null) {
|
||||
if (screen.reviewPageOpen) return leaveUnexpectedReviewPage(input)
|
||||
screen.problem?.let { return failure(it.code, it.message) }
|
||||
if (screen.specPanelOpen) return null
|
||||
val safeCandidates = listOfNotNull(
|
||||
screen.specEntry?.let { anchor -> anchor to (screen.specEntryClickTarget ?: anchor) },
|
||||
screen.quickConfirmationEntry?.let { it to it },
|
||||
).distinctBy { it.second.path }
|
||||
// A rule alias may only narrow the semantic candidates already accepted
|
||||
// by PddScreenParser. It must never turn into a raw-page text lookup:
|
||||
// review cards and unrelated controls can share arbitrary labels.
|
||||
val candidates = action.textAliases?.let { aliases ->
|
||||
safeCandidates.filter { (anchor, _) -> specEntryMatchesAliases(screen, anchor, aliases) }
|
||||
} ?: safeCandidates
|
||||
if (candidates.size > 1) {
|
||||
panelDiagnostic(specEntryEvidence(screen, candidates.size, entryReadyWaitPolls))
|
||||
return failure(SPEC_ENTRY_TARGET_AMBIGUOUS, "规格入口候选不唯一 [${specEntryEvidence(screen, candidates.size, entryReadyWaitPolls)}]")
|
||||
}
|
||||
target = candidates.singleOrNull()?.second
|
||||
if (target != null) continue
|
||||
if (entryReadyWaitPolls >= SPEC_ENTRY_READY_WAIT_POLLS) {
|
||||
panelDiagnostic(specEntryEvidence(screen, 0, entryReadyWaitPolls))
|
||||
return failure(SPEC_ENTRY_NOT_FOUND, "没有找到安全的商品规格入口 [${specEntryEvidence(screen, 0, entryReadyWaitPolls)}]")
|
||||
}
|
||||
pause(SPEC_ENTRY_READY_POLL_MILLIS)
|
||||
entryReadyWaitPolls++
|
||||
screen = currentScreen(input)
|
||||
}
|
||||
val target = candidates.singleOrNull()?.second ?: run {
|
||||
panelDiagnostic(specEntryEvidence(screen, 0))
|
||||
return failure(SPEC_ENTRY_NOT_FOUND, "没有找到安全的商品规格入口 [${specEntryEvidence(screen, 0)}]")
|
||||
}
|
||||
val click = driver.clickFreshDetailed(target)
|
||||
val beforeSignature = specActionSignature(screen)
|
||||
val click = driver.clickFreshDetailed(requireNotNull(target))
|
||||
when (click.result) {
|
||||
// The parser already narrowed to a single semantic candidate; the
|
||||
// ambiguity here comes from the live tree matching that target more
|
||||
// than once at click time, so the wording must not claim otherwise.
|
||||
FreshActionResult.AMBIGUOUS -> return failure(
|
||||
SPEC_ENTRY_TARGET_AMBIGUOUS,
|
||||
"规格入口点击目标不唯一 [${specEntryEvidence(screen, candidates.size)}]",
|
||||
"规格入口点击目标不唯一 [${specEntryEvidence(screen, 1, entryReadyWaitPolls)}]",
|
||||
)
|
||||
else -> Unit
|
||||
}
|
||||
|
||||
var wait = waitForSpecPanel(input, beforeSignature)
|
||||
wait.failure?.let { return it }
|
||||
if (wait.opened) return null
|
||||
if (wait.changed) {
|
||||
return failure(
|
||||
SPEC_PANEL_EVIDENCE_NOT_MATCHED,
|
||||
"规格入口点击后页面已变化,但规格面板强证据不足 [${panelEvidence(wait.screen)}]",
|
||||
)
|
||||
}
|
||||
|
||||
when (driver.tapSpecFresh(requireNotNull(target))) {
|
||||
FreshActionResult.AMBIGUOUS -> return failure(
|
||||
SPEC_ENTRY_TARGET_AMBIGUOUS,
|
||||
"规格入口手势目标不唯一 [${specEntryEvidence(screen, 1, entryReadyWaitPolls)}]",
|
||||
)
|
||||
FreshActionResult.SUCCESS -> Unit
|
||||
else -> return failure(SPEC_ENTRY_CLICK_FAILED, click.reason.specEntrySubreason())
|
||||
}
|
||||
repeat(30) {
|
||||
screen = currentScreen(input)
|
||||
panelDiagnostic(panelEvidence(screen))
|
||||
if (screen.reviewPageOpen) return leaveUnexpectedReviewPage(input)
|
||||
screen.problem?.let { return failure(it.code, it.message) }
|
||||
if (screen.specPanelOpen) return null
|
||||
pause(100)
|
||||
wait = waitForSpecPanel(input, specActionSignature(wait.screen))
|
||||
wait.failure?.let { return it }
|
||||
if (wait.opened) return null
|
||||
if (wait.changed) {
|
||||
return failure(
|
||||
SPEC_PANEL_EVIDENCE_NOT_MATCHED,
|
||||
"规格入口手势后页面已变化,但规格面板强证据不足 [${panelEvidence(wait.screen)}]",
|
||||
)
|
||||
}
|
||||
return failure(SPEC_PANEL_NOT_OPENED, "点击后未识别到商品规格面板 [${panelEvidence(screen)}]")
|
||||
return failure(
|
||||
SPEC_ENTRY_CLICK_NO_EFFECT,
|
||||
"规格入口无障碍点击和受控手势后页面均无变化 [${panelEvidence(wait.screen)}]",
|
||||
)
|
||||
}
|
||||
|
||||
private data class SpecPanelWait(
|
||||
val screen: ParsedPddScreen,
|
||||
val opened: Boolean,
|
||||
val changed: Boolean,
|
||||
val failure: PurchaseExecutionOutcome? = null,
|
||||
)
|
||||
|
||||
private data class SpecActionNodeSignature(
|
||||
val path: String,
|
||||
val className: String?,
|
||||
val bounds: NodeBounds,
|
||||
val clickable: Boolean,
|
||||
val scrollable: Boolean,
|
||||
val selected: Boolean,
|
||||
val checked: Boolean,
|
||||
val enabled: Boolean,
|
||||
val visible: Boolean,
|
||||
)
|
||||
|
||||
private fun waitForSpecPanel(input: PurchaseExecutionInput, beforeSignature: List<SpecActionNodeSignature>): SpecPanelWait {
|
||||
var last = currentScreen(input)
|
||||
var changed = false
|
||||
repeat(SPEC_POST_CLICK_VERIFY_POLLS) {
|
||||
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
|
||||
pause(SPEC_SELECTION_POLL_MILLIS)
|
||||
}
|
||||
return SpecPanelWait(last, false, changed)
|
||||
}
|
||||
|
||||
/** In-memory only; the signature is never logged, persisted, or uploaded. */
|
||||
private fun specActionSignature(screen: ParsedPddScreen): List<SpecActionNodeSignature> = screen.sourceNodes.map { node ->
|
||||
SpecActionNodeSignature(
|
||||
node.path,
|
||||
node.className,
|
||||
node.bounds,
|
||||
node.clickable,
|
||||
node.scrollable,
|
||||
node.selected,
|
||||
node.checked,
|
||||
node.enabled,
|
||||
node.visible,
|
||||
)
|
||||
}
|
||||
|
||||
private fun panelEvidence(screen: ParsedPddScreen): String =
|
||||
@@ -315,11 +420,12 @@ class PurchaseRehearsalExecutor(
|
||||
"options=${screen.panelOptionCount};summary=${screen.hasSelectionSummary};quantity=${screen.hasQuantityControls};" +
|
||||
"orderAction=${screen.hasOrderSubmitAction};pageEvidence=${screen.pageEvidenceMatched}"
|
||||
|
||||
private fun specEntryEvidence(screen: ParsedPddScreen, candidateCount: Int): String =
|
||||
private fun specEntryEvidence(screen: ParsedPddScreen, candidateCount: Int, entryReadyWaitPolls: Int = 0): String =
|
||||
"specEntryCandidates=$candidateCount;explicit=${screen.explicitSpecEntryCount};" +
|
||||
"nested=${screen.nestedSpecEntryCount};bottomPurchase=${screen.bottomPurchaseEntryCount};" +
|
||||
"panelAlreadyOpen=${screen.specPanelOpen};reviewPage=${screen.reviewPageOpen};" +
|
||||
"pageEvidence=${screen.pageEvidenceMatched}"
|
||||
"pageEvidence=${screen.pageEvidenceMatched};entryReadyWaitPolls=$entryReadyWaitPolls;" +
|
||||
"entryReadyWaitMillis=${entryReadyWaitPolls * SPEC_ENTRY_READY_POLL_MILLIS}"
|
||||
|
||||
private fun specEntryMatchesAliases(screen: ParsedPddScreen, candidate: SnapshotNode, aliases: List<String>): Boolean {
|
||||
val prefix = "${candidate.path}/"
|
||||
@@ -375,44 +481,39 @@ class PurchaseRehearsalExecutor(
|
||||
target: String,
|
||||
selectionProofs: MutableMap<String, ExactSpecSelectionProof>,
|
||||
): PurchaseExecutionOutcome? {
|
||||
var lastClickReason = FreshClickReason.UNKNOWN
|
||||
var clickSucceeded = false
|
||||
repeat(SPEC_SELECTION_CLICK_ATTEMPTS) {
|
||||
val lookup = locateExactSpec(input, dimension, target)
|
||||
lookup.failure?.let { return it }
|
||||
val outcome = driver.clickFreshDetailed(requireNotNull(lookup.node))
|
||||
lastClickReason = outcome.reason
|
||||
if (outcome.result == FreshActionResult.AMBIGUOUS) {
|
||||
return failure(SPEC_TARGET_AMBIGUOUS, "精确规格匹配到多个控件")
|
||||
}
|
||||
clickSucceeded = clickSucceeded || outcome.result == FreshActionResult.SUCCESS
|
||||
val initial = currentScreen(input)
|
||||
initial.problem?.let { return failure(it.code, it.message) }
|
||||
if (rememberExactSelection(initial, dimension, target, selectionProofs)) return null
|
||||
|
||||
// PDD rerenders the remaining options after a color selection. A
|
||||
// stale node can report NOT_FOUND/FAILED even when the exact option
|
||||
// was selected. Always verify the observable selected state before
|
||||
// deciding whether to refind and retry the same exact target.
|
||||
val verifyPolls = if (outcome.result == FreshActionResult.SUCCESS) {
|
||||
SPEC_SELECTION_SUCCESS_VERIFY_POLLS
|
||||
} else {
|
||||
SPEC_SELECTION_FAILED_VERIFY_POLLS
|
||||
}
|
||||
repeat(verifyPolls) {
|
||||
val refreshed = currentScreen(input)
|
||||
refreshed.problem?.let { return failure(it.code, it.message) }
|
||||
if (isExactSpecSelected(refreshed, dimension, target)) {
|
||||
exactSpecSelectionProof(refreshed, dimension, target)?.let { proof ->
|
||||
selectionProofs[dimension] = proof
|
||||
}
|
||||
return null
|
||||
}
|
||||
pause(SPEC_SELECTION_POLL_MILLIS)
|
||||
}
|
||||
val lookup = locateExactSpec(input, dimension, target)
|
||||
lookup.failure?.let { return it }
|
||||
val outcome = driver.clickFreshDetailed(requireNotNull(lookup.node))
|
||||
if (outcome.result == FreshActionResult.AMBIGUOUS) {
|
||||
return failure(SPEC_TARGET_AMBIGUOUS, "精确规格匹配到多个控件")
|
||||
}
|
||||
var verification = waitForExactSelection(input, dimension, target, selectionProofs, SPEC_SELECTION_SUCCESS_VERIFY_POLLS)
|
||||
verification.failure?.let { return it }
|
||||
if (verification.confirmed) return null
|
||||
|
||||
if (clickSucceeded) {
|
||||
// Reacquire the exact option after PDD has had a chance to rerender.
|
||||
// The fallback is still bounded to one center gesture and the same
|
||||
// unique, server-provided exact value.
|
||||
val refreshedLookup = locateExactSpec(input, dimension, target)
|
||||
refreshedLookup.failure?.let { return it }
|
||||
val gesture = driver.tapSpecFresh(requireNotNull(refreshedLookup.node))
|
||||
if (gesture == FreshActionResult.AMBIGUOUS) {
|
||||
return failure(SPEC_TARGET_AMBIGUOUS, "精确规格手势目标不唯一")
|
||||
}
|
||||
if (gesture == FreshActionResult.SUCCESS) {
|
||||
verification = waitForExactSelection(input, dimension, target, selectionProofs, SPEC_SELECTION_SUCCESS_VERIFY_POLLS)
|
||||
verification.failure?.let { return it }
|
||||
if (verification.confirmed) return null
|
||||
return failure(SPEC_SELECTION_UNCONFIRMED, "无障碍点击和受控手势后未能确认精确选中状态")
|
||||
}
|
||||
if (outcome.result == FreshActionResult.SUCCESS) {
|
||||
return failure(SPEC_SELECTION_UNCONFIRMED, "点击规格后未能确认精确选中状态")
|
||||
}
|
||||
val subreason = when (lastClickReason) {
|
||||
val subreason = when (outcome.reason) {
|
||||
FreshClickReason.ROOT_UNAVAILABLE -> SPEC_CLICK_ROOT_UNAVAILABLE
|
||||
FreshClickReason.TARGET_NOT_FOUND -> SPEC_CLICK_TARGET_STALE
|
||||
FreshClickReason.NO_CLICKABLE_ANCESTOR -> SPEC_CLICK_NO_CLICKABLE_ANCESTOR
|
||||
@@ -422,6 +523,38 @@ class PurchaseRehearsalExecutor(
|
||||
return failure(SPEC_CLICK_FAILED, subreason)
|
||||
}
|
||||
|
||||
private data class ExactSelectionWait(
|
||||
val confirmed: Boolean,
|
||||
val failure: PurchaseExecutionOutcome? = null,
|
||||
)
|
||||
|
||||
private fun waitForExactSelection(
|
||||
input: PurchaseExecutionInput,
|
||||
dimension: String,
|
||||
target: String,
|
||||
selectionProofs: MutableMap<String, ExactSpecSelectionProof>,
|
||||
polls: Int,
|
||||
): ExactSelectionWait {
|
||||
repeat(polls) {
|
||||
val refreshed = currentScreen(input)
|
||||
refreshed.problem?.let { return ExactSelectionWait(false, failure(it.code, it.message)) }
|
||||
if (rememberExactSelection(refreshed, dimension, target, selectionProofs)) return ExactSelectionWait(true)
|
||||
pause(SPEC_SELECTION_POLL_MILLIS)
|
||||
}
|
||||
return ExactSelectionWait(false)
|
||||
}
|
||||
|
||||
private fun rememberExactSelection(
|
||||
screen: ParsedPddScreen,
|
||||
dimension: String,
|
||||
target: String,
|
||||
selectionProofs: MutableMap<String, ExactSpecSelectionProof>,
|
||||
): Boolean {
|
||||
if (!isExactSpecSelected(screen, dimension, target)) return false
|
||||
exactSpecSelectionProof(screen, dimension, target)?.let { proof -> selectionProofs[dimension] = proof }
|
||||
return true
|
||||
}
|
||||
|
||||
private fun isExactSpecSelected(screen: ParsedPddScreen, dimension: String, target: String): Boolean {
|
||||
val candidates = screen.dimensions.filter { it.key == dimension }.flatMap { it.values }
|
||||
if (candidates.any { it.text == target && (it.node.selected || it.node.checked) }) return true
|
||||
@@ -516,38 +649,40 @@ class PurchaseRehearsalExecutor(
|
||||
* that direction early.
|
||||
*/
|
||||
private fun locateExactSpec(input: PurchaseExecutionInput, dimension: String, target: String): SpecLookup {
|
||||
fun inspect(): Pair<SpecLookup?, String> {
|
||||
data class Inspection(val lookup: SpecLookup?, val signature: String, val container: SnapshotNode?)
|
||||
fun inspect(): Inspection {
|
||||
val screen = currentScreen(input)
|
||||
screen.problem?.let { return SpecLookup(failure = failure(it.code, it.message)) to "" }
|
||||
screen.problem?.let { return Inspection(SpecLookup(failure = failure(it.code, it.message)), "", null) }
|
||||
if (!screen.specPanelOpen) {
|
||||
return SpecLookup(failure = failure("RULE_NOT_MATCHED", "商品规格面板已经关闭")) to ""
|
||||
return Inspection(SpecLookup(failure = failure("RULE_NOT_MATCHED", "商品规格面板已经关闭")), "", null)
|
||||
}
|
||||
val dimensionValues = screen.dimensions.filter { it.key == dimension }.flatMap { it.values }
|
||||
val exact = dimensionValues.filter { it.text == target }
|
||||
if (exact.size > 1) {
|
||||
return SpecLookup(failure = failure(SPEC_TARGET_AMBIGUOUS, "精确规格匹配到多个控件")) to ""
|
||||
return Inspection(SpecLookup(failure = failure(SPEC_TARGET_AMBIGUOUS, "精确规格匹配到多个控件")), "", screen.specPanelContainer)
|
||||
}
|
||||
if (exact.size == 1 && exact.single().available) return SpecLookup(node = exact.single().node) to ""
|
||||
if (exact.size == 1 && exact.single().available) return Inspection(SpecLookup(node = exact.single().node), "", screen.specPanelContainer)
|
||||
if (exact.size == 1) {
|
||||
return SpecLookup(failure = failure(SPEC_SAFE_TARGET_MISSING, "精确规格当前不可安全点击")) to ""
|
||||
return Inspection(SpecLookup(failure = failure(SPEC_SAFE_TARGET_MISSING, "精确规格当前不可安全点击")), "", screen.specPanelContainer)
|
||||
}
|
||||
val signature = screen.dimensions.joinToString("|") { item ->
|
||||
"${item.key}:${item.values.joinToString(",") { value -> "${value.text}:${value.available}" }}"
|
||||
}
|
||||
return null to signature
|
||||
return Inspection(null, signature, screen.specPanelContainer)
|
||||
}
|
||||
|
||||
var inspected = inspect()
|
||||
inspected.first?.let { return it }
|
||||
var currentSignature = inspected.second
|
||||
inspected.lookup?.let { return it }
|
||||
var currentSignature = inspected.signature
|
||||
for ((direction, limit) in listOf(SwipeDirection.DOWN to 3, SwipeDirection.UP to 6)) {
|
||||
for (attempt in 0 until limit) {
|
||||
if (!driver.swipePurchase(direction, 350)) break
|
||||
val container = inspected.container ?: break
|
||||
if (!driver.swipePurchaseIn(container, direction, 350)) break
|
||||
pause(300)
|
||||
inspected = inspect()
|
||||
inspected.first?.let { return it }
|
||||
if (inspected.second == currentSignature) break
|
||||
currentSignature = inspected.second
|
||||
inspected.lookup?.let { return it }
|
||||
if (inspected.signature == currentSignature) break
|
||||
currentSignature = inspected.signature
|
||||
}
|
||||
}
|
||||
return SpecLookup(failure = failure(SPEC_TARGET_NOT_VISIBLE, "有界搜索后未找到精确规格"))
|
||||
@@ -677,7 +812,9 @@ class PurchaseRehearsalExecutor(
|
||||
private const val OPEN_PRODUCT_POLL_LIMIT = 50
|
||||
private const val OPEN_PRODUCT_RETRY_POLLS = 10
|
||||
private const val OPEN_PRODUCT_POLL_MILLIS = 100L
|
||||
private const val SPEC_SELECTION_CLICK_ATTEMPTS = 3
|
||||
private const val SPEC_ENTRY_READY_WAIT_POLLS = 20
|
||||
private const val SPEC_ENTRY_READY_POLL_MILLIS = 100L
|
||||
private const val SPEC_POST_CLICK_VERIFY_POLLS = 30
|
||||
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
|
||||
@@ -686,7 +823,8 @@ class PurchaseRehearsalExecutor(
|
||||
private const val SPEC_ENTRY_TARGET_AMBIGUOUS = "PURCHASE_SPEC_ENTRY_TARGET_AMBIGUOUS"
|
||||
private const val SPEC_ENTRY_CLICK_FAILED = "PURCHASE_SPEC_ENTRY_CLICK_FAILED"
|
||||
private const val SPEC_ENTRY_OPENED_REVIEW = "PURCHASE_SPEC_ENTRY_OPENED_REVIEW"
|
||||
private const val SPEC_PANEL_NOT_OPENED = "PURCHASE_SPEC_PANEL_NOT_OPENED"
|
||||
private const val SPEC_ENTRY_CLICK_NO_EFFECT = "PURCHASE_SPEC_ENTRY_CLICK_NO_EFFECT"
|
||||
private const val SPEC_PANEL_EVIDENCE_NOT_MATCHED = "PURCHASE_SPEC_PANEL_EVIDENCE_NOT_MATCHED"
|
||||
private const val SPEC_TARGET_NOT_VISIBLE = "PURCHASE_SPEC_TARGET_NOT_VISIBLE"
|
||||
private const val SPEC_TARGET_AMBIGUOUS = "PURCHASE_SPEC_TARGET_AMBIGUOUS"
|
||||
private const val SPEC_SAFE_TARGET_MISSING = "PURCHASE_SPEC_SAFE_TARGET_MISSING"
|
||||
|
||||
@@ -199,8 +199,25 @@ class PurchaseLiveAutomationTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `repeated known payment activity still stops without scrolling or payment clicks`() {
|
||||
val driver = LiveDriver(postSubmitCaptureSequence = listOf("payment", "payment"))
|
||||
fun `payment transition frame after safe back reaches unpaid order evidence without payment clicks`() {
|
||||
val driver = LiveDriver(postSubmitCaptureSequence = listOf("payment", "payment", "order"))
|
||||
val automation = PurchaseLiveAutomation(driver, pause = {})
|
||||
val address = automation.updateShippingAddress("_cg55")
|
||||
automation.finalConfirmation(input().copy(addressSuffix = "_cg55"), address)
|
||||
automation.submitOrderOnce()
|
||||
|
||||
val order = automation.readOrderResult()
|
||||
|
||||
assertEquals("PDD-202608210001", order?.orderNo)
|
||||
assertEquals("2026-08-21T02:30:00Z", order?.submittedAt)
|
||||
assertEquals(1, driver.postSubmitBackCount)
|
||||
assertEquals(0, driver.genericSwipes)
|
||||
assertFalse(driver.clicked.any { it.contains("支付") })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `continuous payment activity still stops at bounded post back samples without payment clicks`() {
|
||||
val driver = LiveDriver(postSubmitCaptureSequence = List(4) { "payment" })
|
||||
val automation = PurchaseLiveAutomation(driver, pause = {})
|
||||
val address = automation.updateShippingAddress("_cg55")
|
||||
automation.finalConfirmation(input().copy(addressSuffix = "_cg55"), address)
|
||||
@@ -208,6 +225,12 @@ class PurchaseLiveAutomationTest {
|
||||
|
||||
assertEquals(null, automation.readOrderResult())
|
||||
assertEquals("PURCHASE_ORDER_PAYMENT_REPEATED", automation.lastOrderReadFailure?.code)
|
||||
assertEquals(
|
||||
"支付页安全返回后持续无订单证据,已停止自动核单" +
|
||||
"[paymentBackAttempts=1;consecutivePaymentSamplesAfterBack=3]",
|
||||
automation.lastOrderReadFailure?.message,
|
||||
)
|
||||
assertEquals(4, driver.postSubmitCaptureCount)
|
||||
assertEquals(1, driver.postSubmitBackCount)
|
||||
assertEquals(0, driver.genericSwipes)
|
||||
assertFalse(driver.clicked.any { it.contains("支付") })
|
||||
|
||||
+142
-19
@@ -8,6 +8,7 @@ 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.PurchaseSpecGesturePolicy
|
||||
import cn.ilapage.goauto.agent.automation.PurchaseUiDriver
|
||||
import cn.ilapage.goauto.agent.automation.RuleValidationException
|
||||
import cn.ilapage.goauto.agent.automation.SnapshotNode
|
||||
@@ -21,6 +22,22 @@ import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class PurchaseRehearsalExecutorTest {
|
||||
@Test
|
||||
fun `spec gesture policy excludes irreversible and out of bounds targets`() {
|
||||
fun target(label: String, bounds: NodeBounds = NodeBounds(20, 100, 300, 180)) = SnapshotNode(
|
||||
"target", null, label, null, null, "android.widget.TextView", bounds,
|
||||
clickable = true, scrollable = false, selected = false, checked = false, enabled = true, visible = true,
|
||||
)
|
||||
|
||||
assertTrue(PurchaseSpecGesturePolicy.isAllowed(target("选择规格"), 1080, 2200))
|
||||
assertTrue(PurchaseSpecGesturePolicy.isAllowed(target("XL"), 1080, 2200))
|
||||
assertFalse(PurchaseSpecGesturePolicy.isAllowed(target("提交订单"), 1080, 2200))
|
||||
assertFalse(PurchaseSpecGesturePolicy.isAllowed(target("确认购买"), 1080, 2200))
|
||||
assertFalse(PurchaseSpecGesturePolicy.isAllowed(target("立即支付"), 1080, 2200))
|
||||
assertFalse(PurchaseSpecGesturePolicy.isAllowed(target("修改地址"), 1080, 2200))
|
||||
assertFalse(PurchaseSpecGesturePolicy.isAllowed(target("XL", NodeBounds(-20, 100, -2, 180)), 1080, 2200))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `shared current live rule matches Android contract`() {
|
||||
val raw = requireNotNull(javaClass.classLoader?.getResource("purchase-rule-live-v1.json")) {
|
||||
@@ -130,6 +147,8 @@ class PurchaseRehearsalExecutorTest {
|
||||
assertEquals("rehearsal_completed", outcome.resultType)
|
||||
assertTrue(driver.clicked.contains("XL"))
|
||||
assertEquals(3, driver.upSwipeCount)
|
||||
assertTrue(driver.swipeInPaths.isNotEmpty())
|
||||
assertTrue(driver.swipeInPaths.all { it == "scroll" })
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -147,18 +166,31 @@ class PurchaseRehearsalExecutorTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `rerendered exact size is refound and selected on the second attempt`() {
|
||||
fun `rerendered exact size falls back to one verified center gesture`() {
|
||||
val driver = FakePurchaseDriver(
|
||||
sizeClickResults = mutableListOf(FreshActionResult.NOT_FOUND, FreshActionResult.SUCCESS),
|
||||
sizeClickResults = mutableListOf(FreshActionResult.NOT_FOUND),
|
||||
specTapResult = FreshActionResult.SUCCESS,
|
||||
)
|
||||
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
|
||||
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
|
||||
|
||||
assertEquals("rehearsal_completed", outcome.resultType)
|
||||
assertEquals(2, driver.sizeClickCount)
|
||||
assertEquals(1, driver.sizeClickCount)
|
||||
assertEquals(1, driver.specTapCount)
|
||||
assertEquals("XL", driver.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `already selected exact size is not clicked again`() {
|
||||
val driver = FakePurchaseDriver(initialSize = "XL")
|
||||
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
|
||||
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
|
||||
|
||||
assertEquals("rehearsal_completed", outcome.resultType)
|
||||
assertEquals(0, driver.sizeClickCount)
|
||||
assertEquals(0, driver.specTapCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `long exact size can be verified by one unambiguous short summary token`() {
|
||||
val target = "XL【建议121-140斤】"
|
||||
@@ -280,16 +312,17 @@ class PurchaseRehearsalExecutorTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `persistent exact size click failure stops after three attempts`() {
|
||||
fun `persistent exact size click failure stops after one action and one bounded gesture`() {
|
||||
val driver = FakePurchaseDriver(
|
||||
sizeClickResults = MutableList(3) { FreshActionResult.FAILED },
|
||||
sizeClickResults = mutableListOf(FreshActionResult.FAILED),
|
||||
)
|
||||
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
|
||||
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
|
||||
|
||||
assertEquals("PURCHASE_SPEC_CLICK_FAILED", outcome.errorCode)
|
||||
assertEquals("action_click_false", outcome.message)
|
||||
assertEquals(3, driver.sizeClickCount)
|
||||
assertEquals(1, driver.sizeClickCount)
|
||||
assertEquals(1, driver.specTapCount)
|
||||
assertEquals(null, driver.size)
|
||||
}
|
||||
|
||||
@@ -434,34 +467,93 @@ class PurchaseRehearsalExecutorTest {
|
||||
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
|
||||
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
|
||||
|
||||
assertEquals("PURCHASE_SPEC_PANEL_NOT_OPENED", outcome.errorCode)
|
||||
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]",
|
||||
outcome.message,
|
||||
)
|
||||
assertEquals(0, driver.specTapCount)
|
||||
assertFalse(outcome.message.orEmpty().contains("确认款式"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unchanged spec entry action uses one verified center gesture`() {
|
||||
val driver = FakePurchaseDriver(
|
||||
entryActionHasEffect = false,
|
||||
specTapResult = FreshActionResult.SUCCESS,
|
||||
)
|
||||
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
|
||||
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
|
||||
|
||||
assertEquals("rehearsal_completed", outcome.resultType)
|
||||
assertEquals(1, driver.specTapCount)
|
||||
assertTrue(driver.panel)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unchanged spec entry after action and gesture returns no effect`() {
|
||||
val driver = FakePurchaseDriver(
|
||||
entryActionHasEffect = false,
|
||||
specTapResult = FreshActionResult.SUCCESS,
|
||||
specTapHasEffect = false,
|
||||
)
|
||||
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
|
||||
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
|
||||
|
||||
assertEquals("PURCHASE_SPEC_ENTRY_CLICK_NO_EFFECT", outcome.errorCode)
|
||||
assertEquals(1, driver.specTapCount)
|
||||
assertFalse(driver.panel)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `missing spec entry emits scalar source counts without node text`() {
|
||||
val diagnostics = mutableListOf<String>()
|
||||
val pauses = mutableListOf<Long>()
|
||||
val driver = FakePurchaseDriver(missingSpecEntry = true)
|
||||
val outcome = PurchaseRehearsalExecutor(
|
||||
driver,
|
||||
{ driver.browser = true; true },
|
||||
{ null },
|
||||
pause = {},
|
||||
pause = pauses::add,
|
||||
panelDiagnostic = diagnostics::add,
|
||||
).execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
|
||||
|
||||
assertEquals("PURCHASE_SPEC_ENTRY_NOT_FOUND", outcome.errorCode)
|
||||
assertEquals(
|
||||
"specEntryCandidates=0;explicit=0;nested=0;bottomPurchase=0;panelAlreadyOpen=false;reviewPage=false;pageEvidence=true",
|
||||
"specEntryCandidates=0;explicit=0;nested=0;bottomPurchase=0;panelAlreadyOpen=false;reviewPage=false;pageEvidence=true;entryReadyWaitPolls=20;entryReadyWaitMillis=2000",
|
||||
diagnostics.single(),
|
||||
)
|
||||
// One 100ms pause belongs to the existing open-product foreground poll;
|
||||
// the diagnostic proves the entry-ready loop itself used exactly 20.
|
||||
assertEquals(21, pauses.count { it == 100L })
|
||||
assertFalse(diagnostics.single().contains("选择规格"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `open spec panel waits for a late safe bottom purchase entry`() {
|
||||
val pauses = mutableListOf<Long>()
|
||||
val driver = FakePurchaseDriver(bottomPurchaseEntry = true, specEntryVisibleAfterPddCaptures = 4)
|
||||
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { "{\"dimensions\":[]}" }, pause = pauses::add)
|
||||
.execute(input().copy(executionMode = "live", phase = "spec_probe"), PurchaseRuleParser.parse(liveRule()), PurchaseAgentCapabilities.supported)
|
||||
|
||||
assertEquals("spec_probe_completed", outcome.resultType)
|
||||
assertTrue(driver.clickedPaths.contains("buy"))
|
||||
// One 100ms pause belongs to the existing open-product foreground poll;
|
||||
// two belong to the entry-ready wait before the bottom bar appears.
|
||||
assertEquals(3, pauses.count { it == 100L })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `verify product waits for product evidence instead of a visible loading frame`() {
|
||||
val pauses = mutableListOf<Long>()
|
||||
val driver = FakePurchaseDriver(loadingPddCaptures = 1)
|
||||
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = pauses::add)
|
||||
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
|
||||
|
||||
assertEquals("rehearsal_completed", outcome.resultType)
|
||||
assertEquals(1, pauses.count { it == 100L })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `transient sold out page recovers before opening specs`() {
|
||||
val driver = FakePurchaseDriver(soldOut = true, recoverSoldOutAfterPull = true)
|
||||
@@ -613,7 +705,7 @@ class PurchaseRehearsalExecutorTest {
|
||||
|
||||
assertEquals("PURCHASE_SPEC_ENTRY_NOT_FOUND", outcome.errorCode)
|
||||
assertEquals(
|
||||
"没有找到安全的商品规格入口 [specEntryCandidates=0;explicit=0;nested=0;bottomPurchase=0;panelAlreadyOpen=false;reviewPage=false;pageEvidence=true]",
|
||||
"没有找到安全的商品规格入口 [specEntryCandidates=0;explicit=0;nested=0;bottomPurchase=0;panelAlreadyOpen=false;reviewPage=false;pageEvidence=true;entryReadyWaitPolls=20;entryReadyWaitMillis=2000]",
|
||||
outcome.message,
|
||||
)
|
||||
|
||||
@@ -623,7 +715,7 @@ class PurchaseRehearsalExecutorTest {
|
||||
|
||||
assertEquals("PURCHASE_SPEC_ENTRY_TARGET_AMBIGUOUS", ambiguous.errorCode)
|
||||
assertEquals(
|
||||
"规格入口点击目标不唯一 [specEntryCandidates=1;explicit=1;nested=0;bottomPurchase=0;panelAlreadyOpen=false;reviewPage=false;pageEvidence=true]",
|
||||
"规格入口点击目标不唯一 [specEntryCandidates=1;explicit=1;nested=0;bottomPurchase=0;panelAlreadyOpen=false;reviewPage=false;pageEvidence=true;entryReadyWaitPolls=0;entryReadyWaitMillis=0]",
|
||||
ambiguous.message,
|
||||
)
|
||||
}
|
||||
@@ -777,6 +869,8 @@ class PurchaseRehearsalExecutorTest {
|
||||
private val duplicateOpen: Boolean = false,
|
||||
private val bottomPurchaseEntry: Boolean = false,
|
||||
private val missingSpecEntry: Boolean = false,
|
||||
private val specEntryVisibleAfterPddCaptures: Int = 0,
|
||||
private val loadingPddCaptures: Int = 0,
|
||||
private val includeReviewEntry: Boolean = false,
|
||||
private val openReviewOnBottomClick: Boolean = false,
|
||||
private val reviewBackSucceeds: Boolean = true,
|
||||
@@ -786,6 +880,10 @@ class PurchaseRehearsalExecutorTest {
|
||||
private val sizeClickResults: MutableList<FreshActionResult> = mutableListOf(),
|
||||
private val forcedSizeClickReason: FreshClickReason? = null,
|
||||
private val forcedEntryClickReason: FreshClickReason? = null,
|
||||
private val initialSize: String? = null,
|
||||
private val entryActionHasEffect: Boolean = true,
|
||||
private val specTapResult: FreshActionResult = FreshActionResult.FAILED,
|
||||
private val specTapHasEffect: Boolean = true,
|
||||
private val sizeSelectsOnFailedClick: Boolean = false,
|
||||
private val selectedSizeSummaryOverride: String? = null,
|
||||
private val hideSizeSelectedState: Boolean = false,
|
||||
@@ -807,7 +905,7 @@ class PurchaseRehearsalExecutorTest {
|
||||
var browser = false
|
||||
var panel = false
|
||||
var color: String? = null
|
||||
var size: String? = null
|
||||
var size: String? = initialSize
|
||||
var quantity = 1L
|
||||
var swipeCount = 0
|
||||
var upSwipeCount = 0
|
||||
@@ -815,10 +913,13 @@ class PurchaseRehearsalExecutorTest {
|
||||
var sizeClickCount = 0
|
||||
var pullDownCount = 0
|
||||
var backCount = 0
|
||||
var specTapCount = 0
|
||||
val swipeInPaths = mutableListOf<String>()
|
||||
private var soldOut = soldOut
|
||||
private var allSpecsUnavailable = allSpecsUnavailable
|
||||
private var productEvidenceLost = false
|
||||
private var reviewPage = false
|
||||
private var pddCaptureCount = 0
|
||||
val clicked = mutableListOf<String>()
|
||||
val clickedPaths = mutableListOf<String>()
|
||||
|
||||
@@ -829,6 +930,10 @@ class PurchaseRehearsalExecutorTest {
|
||||
openNodes += node("content", "", 0, 0, 1080, 2200)
|
||||
return UiSnapshot("com.heytap.browser", "BrowserActivity", openNodes)
|
||||
}
|
||||
pddCaptureCount++
|
||||
if (pddCaptureCount <= loadingPddCaptures) {
|
||||
return UiSnapshot(PDD, ACTIVITY, listOf(node("content", "", 0, 0, 1080, 2200)))
|
||||
}
|
||||
if (!panel) {
|
||||
if (reviewPage) {
|
||||
return UiSnapshot(PDD, ACTIVITY, listOf(
|
||||
@@ -844,18 +949,21 @@ class PurchaseRehearsalExecutorTest {
|
||||
if (soldOut) {
|
||||
return UiSnapshot(PDD, ACTIVITY, listOf(
|
||||
node("content", "", 0, 0, 1080, 2200),
|
||||
node("title", "测试商品标题文本", 20, 200, 900, 280, className = "android.widget.ViewPager"),
|
||||
node("sold-out", "商品已售罄", 100, 300, 900, 380),
|
||||
node("similar", "相似商品", 100, 500, 900, 580),
|
||||
))
|
||||
}
|
||||
val nodes = mutableListOf(
|
||||
node("content", "", 0, 0, 1080, 2200),
|
||||
node("title", "测试商品标题文本", 20, 200, 900, 280, className = "android.widget.ViewPager"),
|
||||
)
|
||||
if (bottomPurchaseEntry) {
|
||||
val specEntryReady = pddCaptureCount > specEntryVisibleAfterPddCaptures
|
||||
if (bottomPurchaseEntry && specEntryReady) {
|
||||
nodes += node("buy", "", 500, 1800, 1080, 2180, clickable = true)
|
||||
nodes += node("buy/price", "¥20.00", 560, 1840, 760, 1910, parentPath = "buy")
|
||||
nodes += node("buy/label", "免拼购买", 780, 1840, 1040, 1910, parentPath = "buy")
|
||||
} else if (!missingSpecEntry) {
|
||||
} else if (!missingSpecEntry && specEntryReady) {
|
||||
nodes += node("spec", "选择规格", 20, 1000, 900, 1100, clickable = true)
|
||||
}
|
||||
if (includeReviewEntry) nodes += node("review", "商品评价", 20, 1200, 900, 1300, clickable = true)
|
||||
@@ -925,7 +1033,9 @@ class PurchaseRehearsalExecutorTest {
|
||||
clicked += target.label
|
||||
clickedPaths += target.path
|
||||
if (target.path == "buy") {
|
||||
if (openReviewOnBottomClick) reviewPage = true else panel = true
|
||||
if (entryActionHasEffect) {
|
||||
if (openReviewOnBottomClick) reviewPage = true else panel = true
|
||||
}
|
||||
return FreshActionResult.SUCCESS
|
||||
}
|
||||
when (target.label) {
|
||||
@@ -935,7 +1045,7 @@ class PurchaseRehearsalExecutorTest {
|
||||
if (result == FreshActionResult.SUCCESS || openPddOnFailedClick) browser = false
|
||||
return result
|
||||
}
|
||||
"选择规格", "免拼购买" -> panel = true
|
||||
"选择规格", "免拼购买" -> if (entryActionHasEffect) panel = true
|
||||
in sizes -> {
|
||||
sizeClickCount++
|
||||
val result = sizeClickResults.removeFirstOrNull() ?: FreshActionResult.SUCCESS
|
||||
@@ -981,6 +1091,17 @@ class PurchaseRehearsalExecutorTest {
|
||||
|
||||
override fun tapPurchaseFresh(target: SnapshotNode): FreshActionResult = clickFresh(target)
|
||||
|
||||
override fun tapSpecFresh(target: SnapshotNode): FreshActionResult {
|
||||
specTapCount++
|
||||
if (specTapResult != FreshActionResult.SUCCESS || !specTapHasEffect) return specTapResult
|
||||
when {
|
||||
target.path in setOf("spec", "buy") -> panel = true
|
||||
target.label in sizes -> size = target.label
|
||||
target.label in colors -> color = target.label
|
||||
}
|
||||
return FreshActionResult.SUCCESS
|
||||
}
|
||||
|
||||
override fun inputFresh(target: SnapshotNode, value: String): FreshActionResult {
|
||||
quantity = value.toLong()
|
||||
return FreshActionResult.SUCCESS
|
||||
@@ -992,8 +1113,10 @@ class PurchaseRehearsalExecutorTest {
|
||||
return purchaseSwipeSucceeds
|
||||
}
|
||||
|
||||
override fun swipePurchaseIn(target: SnapshotNode, direction: SwipeDirection, durationMs: Long): Boolean =
|
||||
swipePurchase(direction, durationMs)
|
||||
override fun swipePurchaseIn(target: SnapshotNode, direction: SwipeDirection, durationMs: Long): Boolean {
|
||||
swipeInPaths += target.path
|
||||
return swipePurchase(direction, durationMs)
|
||||
}
|
||||
|
||||
override fun pullDownGoodsPage(): Boolean {
|
||||
pullDownCount++
|
||||
|
||||
@@ -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: 9b195f3c578f980b808e6eb28f36e7bf5294e75b
|
||||
synchronized_at: 2026-09-03T01:55:28Z
|
||||
wiki_revision: c7f56f233263b3c83df7e31cf75c89c48ff24111
|
||||
synchronized_at: 2026-09-04T10:22:02Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 业务规则与术语
|
||||
@@ -41,6 +41,8 @@ synchronized_at: 2026-09-03T01:55:28Z
|
||||
|
||||
- 一行对应 SYB 一条明确的商品/颜色/尺码/数量明细;唯一键为「蝦皮订单号 `code` + 来源明细 `id`」组合,不是全局唯一 ID(未在多货运单样本中验证过全局唯一性)。
|
||||
- `productSpec` 自由文本解析:按最后一个逗号切分为颜色/尺码 → 剥离【】备注 → 判断残留分隔符;解析状态分 `success`/`uncertain`/`failed`,永不猜测原文中不存在的颜色或尺码。
|
||||
|
||||
- 采购放行不以 uncertain 单独阻断:success 与已提取出至少一个非空颜色或尺码的 uncertain 明细,都可继续进入规格匹配和采购预检;只有 failed 或颜色、尺码均为空的异常明细才要求人工处理。uncertain 状态与解析说明继续保留供审计,不会自动猜测规格。
|
||||
- 只有 `success` 状态的解析结果会合并进虾皮商品档案的规格值(来源标记 `import`);`uncertain`/`failed` 只停留在明细行上供人工复核,不污染共享档案。
|
||||
- 金额单位按接口分别定义:`/am/stock/detail/listByStock` 的 `productPrice` 是十进制金额,与 `/am/stock/list` 的 `amtOrder`(×100 整数)不是同一套换算,不可共用。
|
||||
- 关联虾皮商品档案:命中存活记录直接关联;命中软删除记录则复活并保留原有人工映射;均不存在时创建最小档案。命中既有档案时只补写为空的参考图与售价,不覆盖人工修改过的标题、店铺和映射。
|
||||
@@ -62,6 +64,7 @@ synchronized_at: 2026-09-03T01:55:28Z
|
||||
## SYB 店铺过滤
|
||||
|
||||
- `syb_shop` 保存允许导入的 SYB 店铺;店铺名比较前统一去除首尾空白、转换全角/半角字符并忽略大小写。规范化后同名的店铺只能保留一条有效记录。
|
||||
- 版本迁移会对历史存活店铺回填 `normalized_name = Normalize(display_name)`;回填只改匹配键且可重复执行。若两个存活店铺回填后会得到同一键,迁移必须整体失败并保留原数据,管理员先人工消除歧义后再执行,不能静默合并、删除或改变店铺启用状态。
|
||||
- 店铺可以从 SYB 真实货运单列表发现,也允许管理员手工补充。只有管理员可以新增、改名、启停和软删除,采购员等其他角色只读。
|
||||
- 没有任何启用店铺时,导入必须在读取凭据、建立会话、验证码 OCR 和任意 SYB 网络请求之前失败,并给出“请先启用店铺”的可读提示。
|
||||
- 同步必须先拉取并校验当天原始全量列表的总数、分页和唯一 ID,再按本次同步开始时固定的启用店铺快照过滤;过滤不能降低完整性校验的请求范围或容量上限。
|
||||
@@ -73,7 +76,7 @@ synchronized_at: 2026-09-03T01:55:28Z
|
||||
- 导入由管理员创建,创建成功后立即转入后台执行;页面关闭不取消任务。采购员等其他已登录角色可以查看同步记录,不能开始导入。
|
||||
- 系统同一时刻只运行一个 SYB 导入任务。`syb_sync_run` 的唯一执行槽负责跨进程互斥,内存锁减少同一进程内的竞争;服务重启后遗留的执行中记录标记为“已中断”。
|
||||
- 导入失败或中断时保留已写入商品,记录明确的失败原因和已处理进度;重新导入相同范围按「订单号 + 明细 ID」覆盖,不产生重复商品。
|
||||
- 每条同步记录保存本次启用店铺集合的 SHA-256 哈希以及各店铺“已导入/已跳过”数量,不保存账号、密码、Cookie、验证码图片或 SYB 原始响应。
|
||||
- 创建同步记录时冻结本次启用店铺的规范化匹配集合、展示名称快照及 SHA-256 哈希;后台执行必须只使用这一份快照,不得在开始后重新读取 `syb_shop`。同步详情保存并返回快照可用标记、快照店铺名称、哈希及各店铺“已导入/已跳过”数量。#212 之前的历史记录只有哈希和统计,明确标记为无完整快照。上述记录不保存账号、密码、Cookie、Token、验证码图片或 SYB 原始响应。
|
||||
- 后台任务创建前的内部失败仍向普通用户显示“服务端处理失败”,但使用稳定错误码区分阶段:店铺预检为 `SYNC_SHOP_PREFLIGHT_FAILED`,同步记录创建为 `SYNC_RUN_CREATE_FAILED`。服务端只记录阶段和经过脱敏、截断的底层原因,不记录请求体、凭据、Cookie、Token、验证码或原始响应。
|
||||
|
||||
## 采集规则
|
||||
@@ -84,12 +87,14 @@ synchronized_at: 2026-09-03T01:55:28Z
|
||||
- 删除采用软删除;删除后不能创建新任务,但已有任务继续执行自身快照。
|
||||
- 商品详情步骤应声明精确 `activityName`,并与包名和唯一控件共同作为页面证据;进入 PDD 登录 Activity 必须返回 `PDD_LOGIN_REQUIRED`,不能提交采集成功。
|
||||
- 唯一文字节点不可点击时,Agent 只可点击其最近的可点击父容器;不得改点兄弟节点或相似文字。
|
||||
- 商品页规格入口只接受带明确选择语义的规格摘要,或底部真实“购买/拼单”文字按钮;评价、评论、晒单、问答区域及其父容器一律排除。商品评价页与详情页共用 Activity 时,首次误触只允许安全返回并重新定位一次,重复误触明确失败;点击无变化、误入评价页和面板结构无法确认使用不同错误码。
|
||||
- 商品页规格入口只接受带明确选择语义的规格摘要,或底部真实“购买/拼单”文字按钮。若“请选择”和颜色/尺码语义分散在同一可点击父容器的子节点中,也只能在该容器唯一、位于非底部动作区且同时具备两类语义时作为规格入口;规则别名只能收窄已识别的安全候选,不能按页面原始文字扩张。底部购买入口在通过全部安全过滤后仍存在多个候选时,按最右优先(`centerX` 降序,并列时可点击面积升序)确定性选出唯一入口,不再因候选不唯一直接失败;该情形记为 `bottom_purchase_rightmost`,唯一候选仍记为 `bottom_purchase`。“单独购买”与“发起拼单”打开同一规格面板,但对应不同成交形态与价格,最右优先是经人工确认的取舍。评价、评论、晒单、问答、订单、提交订单、支付和付款区域及其父容器一律排除。商品评价页与详情页共用 Activity 时,首次误触只允许安全返回并重新定位一次,重复误触明确失败;点击无变化、误入评价页和面板结构无法确认使用不同错误码。
|
||||
- v2 规则使用类型化动作和固定阶段钩子。已由 Agent 支持的选择器、别名、超时、滑动方向和有限次数可以只更新规则;新增动作类型或页面算法才需要升级 Agent。
|
||||
- v2 任务首次未进入精确商品详情页时,可以按规则显式重开同一浏览器 URL 一次;登录、验证码、风控、无效链接和详情页内采集失败不触发该恢复。
|
||||
- 假售罄恢复只使用主商品规格/购买强证据判断页面是否正常;顶部“相似商品”可以作为受控兜底证据,推荐卡片的标题、价格和销量不得冒充主商品证据。
|
||||
- 商品规格遍历必须用已识别规格节点锁定横向颜色容器和纵向面板容器;颜色按视觉行蛇形遍历,滑动完成后重新读取节点,尺码只读并允许在标题滚出后沿已锁定容器续页。
|
||||
- PDD 已选规格的订单确认形态只可由详情页、选择摘要、唯一数量控件、支付区、唯一底部提交动作和唯一主要滚动容器的组合证据确认;底部提交动作只读且永久禁止点击。Agent 只在该已确认容器内最多三次向下拖动回顶,每次重新读取节点;“参考分类”作为尺码的精确标题别名处理。
|
||||
|
||||
- PDD 完整规格选择器若无主滚动容器且尚未选择规格而没有已选摘要,仍可识别为非滚动规格面板,但必须同时出现至少两个已解析维度、至少两个可选项、唯一数量控件和唯一底部订单动作;缺少其中任一强证据时保持未识别。该订单动作仍仅作页面证据,永久禁止点击。
|
||||
- Agent 可扩展,但规则必须按任务类型授权:采集规则不能创建订单,采购规则只能使用独立审核的创建订单能力。当前项目不实现支付动作、入口或测试;支付、下单和订单相关文字可以作为只读页面证据配置,但不得成为点击目标。后续支付能力必须单独评估并至少具备显式能力位、服务端开关、单笔金额上限和人工授权。
|
||||
|
||||
## 采集任务
|
||||
@@ -143,7 +148,7 @@ synchronized_at: 2026-09-03T01:55:28Z
|
||||
- 地址编辑页可以同时存在收货人、手机号和详细地址等多个输入框;Agent 只选择与“详细地址”标签纵向重叠且位于其右侧的唯一输入框,不能用页面输入框总数或顺序猜测。
|
||||
- 点击创建订单前,Agent 必须先在本地事务保存 `order_submit_started`、不可逆时间、稳定请求 ID 和不含地址全文的最终确认快照,再用同一请求 ID通知服务端;两侧成功后才允许精确点击唯一创建订单按钮一次。
|
||||
- 进入不可逆边界后,进程重启、断网、点击结果不明或无法取得唯一订单号/下单时间时只允许只读核单并进入 `order_result_unknown`,禁止再次点击;任务与订单正式关联仍以完整 PDD 订单号为准。
|
||||
- 创建订单后出现 Android 多微信应用选择器时,Agent 只有在系统包和 `ChooserActivity` / `ResolverActivity` 白名单、已知选择器标题、微信候选三类证据同时成立时,才允许按一次返回,绝不点击微信候选。若已进入真实微信包 `com.tencent.mm`,Agent 不点击、不输入、不登录、不支付、不强制停止微信,只允许用一次无参数 PDD 启动 Intent 把既有 PDD 任务栈拉回前台;第二次仍见微信或拉起失败即保持结果未知。回到 PDD 已知支付 Activity 或明确支付动作页面后最多再返回一次,再只读读取唯一订单号和下单时间;任一页面不在白名单、恢复动作重复、无法到达订单详情或结果不唯一时保持 `order_result_unknown`,禁止重下单或支付。
|
||||
- 创建订单后出现 Android 多微信应用选择器时,Agent 只有在系统包和 `ChooserActivity` / `ResolverActivity` 白名单、已知选择器标题、微信候选三类证据同时成立时,才允许按一次返回,绝不点击微信候选。若已进入真实微信包 `com.tencent.mm`,Agent 不点击、不输入、不登录、不支付、不强制停止微信,只允许用一次无参数 PDD 启动 Intent 把既有 PDD 任务栈拉回前台;第二次仍见微信或拉起失败即保持结果未知。回到 PDD 已知支付 Activity 或明确支付动作页面后最多再返回一次,再只读读取唯一订单号和下单时间;下单成功页仅可点击唯一、精确的“查看订单”或“订单详情”入口一次,进入 PDD 订单详情后才允许有限向上手势下翻读取折叠内容。PDD 页面出现“待付款”“待支付”“订单编号”或“下单时间”等订单结果信号时,该只读信号优先于复用的支付 Activity 名称:允许在唯一主要纵向容器内有限下翻并重新读取,仍不得点击支付;没有任何订单结果信号的支付页或未知页面不得盲目滑动。入口缺失或不唯一、点击后未出现详情证据、任一页面不在白名单、恢复动作重复或结果不唯一时保持 `order_result_unknown`,禁止重下单或支付。
|
||||
- Agent 本地 SQLite/Outbox 负责断网和重启恢复,服务端以 `task_id + task_attempt_id` 幂等接收并保存最终事实。
|
||||
- 人工支付复核只记录 `paid` / `unpaid`;系统不执行或识别支付。快递单号与回填状态属于采购任务,后续物流工单实现。
|
||||
- 采购任务领取时同时占用设备租约和可选 PDD 账号租约;租约过期后才可释放并重新领取。设备还存在采集任务时不能领取采购任务。
|
||||
@@ -386,6 +391,13 @@ synchronized_at: 2026-09-03T01:55:28Z
|
||||
- Agent APK 版本由管理员在设备页抽屉中上传、核对并显式设为当前。服务端从 APK Manifest 读取整数 `versionCode` 和 `versionName`,保存 SHA-256、大小、说明和创建人;APK 位于非公开目录,Admin 与设备下载均需认证。
|
||||
- Agent 只以整数 `versionCode` 判断更新。启动时静默检查一次,设置页可手动检查、显示下载进度并取消;下载完成必须校验大小和 SHA-256。设备有活动任务时禁止检查、下载和安装;安装始终交给 Android 系统确认,项目不绕过未知来源权限。
|
||||
|
||||
## Android 采购规格动作安全执行(#214)
|
||||
|
||||
- 规格入口与精确规格选择统一按“重新定位唯一目标 → 执行一次无障碍点击 → 读取新页面验证”执行;不得复用旧无障碍节点,不选择相近规格,也不在多候选时默认点击第一个。
|
||||
- 无障碍点击后页面完全无变化时,只允许对解析器已确认的安全规格入口或服务端下发且唯一命中的精确规格执行一次中心手势兜底。目标必须可见、启用、边界有效,手势后仍须以规格面板强证据或精确选中证据确认结果。
|
||||
- 页面发生变化但规格面板强证据不足时,不再继续手势或猜测页面,明确失败并只记录面板类型、候选数量和证据布尔值等无敏感标量。原始控件树、节点文字集合和整屏截图仍不得保存或上传。
|
||||
- 规格已处于精确选中状态时不得重复点击。规格查找只在解析器唯一识别的规格面板容器内有限滚动,每次滚动后重新定位容器与目标;容器缺失、歧义、到边或验证失败均 fail-closed。
|
||||
- 中心手势兜底不得用于修改/保存地址、创建或提交订单、订单详情入口以及任何支付/付款目标;正式创建订单的一次性不可逆门禁与永久禁止支付规则不变。
|
||||
## PDD 商品反向关联与继续订单采购(#161)
|
||||
|
||||
- 一个 PDD 商品可被多个虾皮商品共用。PDD 详情返回全部关联虾皮商品摘要;订单行由独立统一分页接口读取,不按虾皮商品伪造嵌套分页。
|
||||
@@ -409,3 +421,9 @@ synchronized_at: 2026-09-03T01:55:28Z
|
||||
- AI 只在关联蝦皮商品的封闭候选集合中返回原始颜色/尺码,并必须提供达到当前自动确认阈值的置信度和非空理由;集合外值、缺失角色、低置信度、歧义、无结果和输入漂移都不得确认。
|
||||
- `parse_status` 保留确定性解析器结论;AI 与人工确认分别记录,人工优先级最高。重复同步不得覆盖人工值;完全相同输入保留 AI 值,来源或关联变化会清除旧 AI 确认。
|
||||
- 同一输入的低置信度或无结果不重复调用 Provider;临时故障至少 60 分钟后重试,最多 3 次。任务不创建采集/采购任务、订单,不执行 Android 动作或付款。
|
||||
|
||||
## 设备身份恢复
|
||||
|
||||
- 管理员可在设备管理对未停用设备发起“重置设备身份”。此动作不删除设备记录、不改变设备 ID、能力或已绑定的待领取任务;它立即使旧 Token 无效,并只生成一次、有效期 10 分钟的恢复码。
|
||||
- 恢复码仅显示给发起操作的管理员一次,服务端只保存摘要;不得进入列表、日志、任务记录、Android 持久化或普通接口。手机操作员必须在同一安装实例的 Agent 设置中手动输入。
|
||||
- 服务端只接受同一 `installId`、未过期且尚未使用的恢复码完成重新注册,成功后签发新 Token 并使恢复码失效。过期、重复使用、installId 不符或停用均明确失败;不能通过清空数据、直接改库或“吊销 Token”恢复原任务归属。
|
||||
|
||||
@@ -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: c214791a0b055e5b56c0ad05e73829a1f141dd46
|
||||
synchronized_at: 2026-09-01T08:03:51Z
|
||||
wiki_revision: a48b6a104c9b96834bbe122a226ba50baf8e4f93
|
||||
synchronized_at: 2026-09-04T10:22:06Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# MVP 共享 API 契约
|
||||
@@ -493,6 +493,16 @@ POST /api/agent/v1/tasks/{taskId}/fail
|
||||
|
||||
演练规则必须包含 `purchase.rehearsal.v1`,并且不能包含 `updateShippingAddress`、`createOrder`、`readOrderResult`。正式规则必须包含 `purchase.live.v1`;改地址和创建订单还分别要求 `purchase.address-update.v1`、`purchase.order-create.v1`。`probeSpecs` 要求 `purchase.spec-probe.v1`。任意模式下,尚未实现的 `pay`、名称包含 `payment` 的动作以及未知动作一律拒绝;服务端不下发任意脚本。
|
||||
|
||||
### Android 规格动作执行边界(#214)
|
||||
|
||||
本工单不改变采购规则 schema、Agent HTTP 字段或能力标识,只收紧 Android 对 `openSpecPanel` 与 `selectSpec` 的执行语义:
|
||||
|
||||
- 每次操作从最新 `rootInActiveWindow` 重新定位;规格入口必须是解析器已接受的唯一安全候选,规格值必须逐字等于任务固化的服务端映射值且在当前维度唯一、可用。
|
||||
- 动作前先检查后置条件;规格已精确选中时直接成功,不重复点击。否则最多分发一次 `ACTION_CLICK`,再从新快照验证规格面板强证据或精确选中证据。
|
||||
- `ACTION_CLICK` 后页面结构完全无变化时,Android 可以重新定位同一唯一目标,并在目标可见、启用、中心点位于屏幕内且边界有效时执行一次中心手势。手势后仍未取得后置证据时明确失败,不继续点击。
|
||||
- 点击后页面已变化但面板分类仍为未知时返回 `PURCHASE_SPEC_PANEL_EVIDENCE_NOT_MATCHED`;两种点击后页面均无变化时返回 `PURCHASE_SPEC_ENTRY_CLICK_NO_EFFECT`。诊断只携带面板类型、滚动容器数、标题数、选项数及证据布尔值,不携带节点文字或完整控件树。
|
||||
- 有界查找规格时只使用当前解析结果中的唯一 `specPanelContainer`;每次滚动前重新定位该容器,优先使用节点滚动动作,失败后才在容器边界内使用手势。缺少唯一容器时停止,不回退到全页面最大滚动区域。
|
||||
- 受控中心手势接口固定拒绝地址、保存地址、创建/提交/确认订单、确认购买和支付/付款语义;`updateShippingAddress`、`createOrder`、`readOrderResult` 不使用该兜底。创建订单的一次性不可逆状态机与禁止支付契约不变。
|
||||
### 管理端接口
|
||||
|
||||
| 方法 | 路径 | 幂等键 / 说明 |
|
||||
@@ -845,3 +855,15 @@ file=<JPEG 二进制>
|
||||
| `GET` | `/api/agent/v1/app/releases/{releaseId}/download` | Device Token;私有 APK 下载 |
|
||||
|
||||
Agent 只比较整数 `versionCode`。设备有活动任务时禁止检查、下载和安装;下载到应用私有缓存并校验响应大小与 SHA-256,失败立即删除。安装使用 FileProvider 和 Android 系统安装确认页;未知来源权限必须由用户在系统设置授权,不静默安装。
|
||||
|
||||
## 设备身份恢复(#201)
|
||||
|
||||
```http
|
||||
POST /api/admin/v1/devices/{deviceId}/identity-reset
|
||||
POST /api/agent/v1/register
|
||||
X-GoAuto-Device-Recovery-Code: <one-time-code>
|
||||
```
|
||||
|
||||
仅管理员可以对未停用的既有设备发起身份重置。服务端立即使旧 Device Token 无效,并生成 10 分钟内仅能使用一次的恢复码;恢复码只在该管理员操作的响应中返回一次,服务端仅保存不可逆摘要,管理端设备列表、日志、任务接口和 Android 本地持久化均不得保存或返回原文。管理员将恢复码经受控人工渠道输入同一安装实例的 Agent 设置页。
|
||||
|
||||
Agent 携带既有 Token(可已失效)及恢复码重新调用注册接口。服务端必须同时校验同一 `installId`、未停用状态、恢复码摘要、未过期和未使用;成功后使用原 `deviceId` 写入新 Token 摘要并返回一次新 Token,清除恢复码摘要和有效期。旧 Token 与恢复码都立即失效,已分配的 pending 采集或采购任务保持原 `deviceId`,不创建替代设备记录。缺少或错误恢复码仍为 `DEVICE_INSTALL_ID_CONFLICT`;过期码为 `DEVICE_RECOVERY_EXPIRED`;停用设备为 `DEVICE_DISABLED`。
|
||||
|
||||
@@ -85,7 +85,8 @@ func StartImport(ctx context.Context, db *gorm.DB, request ImportRequest, actor
|
||||
}
|
||||
run, err := NewSyncRunService(db).Create(ctx, CreateSyncRunInput{
|
||||
DateFrom: request.DateFrom, DateTo: request.DateTo, ShopFilterHash: enabledShopHash(enabled),
|
||||
OperatorID: actor.ID, OperatorName: strings.TrimSpace(actor.Name),
|
||||
ShopFilterSnapshot: cloneShopSnapshot(enabled),
|
||||
OperatorID: actor.ID, OperatorName: strings.TrimSpace(actor.Name),
|
||||
})
|
||||
if err != nil {
|
||||
importGate.Unlock()
|
||||
@@ -97,7 +98,7 @@ func StartImport(ctx context.Context, db *gorm.DB, request ImportRequest, actor
|
||||
importGate.running = true
|
||||
importGate.Unlock()
|
||||
|
||||
go runImport(db, run.ID, request, settings)
|
||||
go runImport(db, run.ID, request, settings, cloneShopSnapshot(enabled))
|
||||
return StartImportResult{RunID: run.ID, Status: run.Status}, nil
|
||||
}
|
||||
|
||||
@@ -145,7 +146,7 @@ func (handler Handler) Import(c *gin.Context) {
|
||||
c.JSON(http.StatusAccepted, gin.H{"code": 200, "data": gin.H{"runId": result.RunID, "status": result.Status}})
|
||||
}
|
||||
|
||||
func runImport(db *gorm.DB, runID uint64, request ImportRequest, settings config.SYB) {
|
||||
func runImport(db *gorm.DB, runID uint64, request ImportRequest, settings config.SYB, allowed map[string]string) {
|
||||
defer func() { importGate.Lock(); importGate.running = false; importGate.Unlock() }()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), importTimeout)
|
||||
defer cancel()
|
||||
@@ -156,7 +157,7 @@ func runImport(db *gorm.DB, runID uint64, request ImportRequest, settings config
|
||||
OcrURL: settings.OcrURL, OcrMaxAttempts: settings.OcrMaxAttempts,
|
||||
})
|
||||
if err == nil {
|
||||
report, err = SyncWithProgress(ctx, db, client, SyncConfig{PageSize: settings.PageSize, MaxMatches: settings.MaxMatches}, request.DateFrom, request.DateTo,
|
||||
report, err = SyncWithShopSnapshot(ctx, db, client, SyncConfig{PageSize: settings.PageSize, MaxMatches: settings.MaxMatches}, request.DateFrom, request.DateTo, allowed,
|
||||
func(progress SyncProgress) error { return runs.UpdateProgress(ctx, runID, progress) })
|
||||
}
|
||||
status := SyncRunSucceeded
|
||||
@@ -168,6 +169,14 @@ func runImport(db *gorm.DB, runID uint64, request ImportRequest, settings config
|
||||
_ = runs.Finish(finishCtx, runID, status, report, err)
|
||||
}
|
||||
|
||||
func cloneShopSnapshot(source map[string]string) map[string]string {
|
||||
cloned := make(map[string]string, len(source))
|
||||
for normalized, display := range source {
|
||||
cloned[normalized] = display
|
||||
}
|
||||
return cloned
|
||||
}
|
||||
|
||||
func claimUint64(value any) uint64 {
|
||||
switch typed := value.(type) {
|
||||
case float64:
|
||||
|
||||
@@ -59,12 +59,16 @@ type SyncReport struct {
|
||||
// it a renamed or newly opened shop is never noticed (#50).
|
||||
ShopBreakdown map[string]ShopBreakdown `json:"shopBreakdown"`
|
||||
ShopFilterHash string `json:"shopFilterHash"`
|
||||
OrderCount int `json:"orderCount"`
|
||||
DetailCount int `json:"detailCount"`
|
||||
Created int `json:"created"`
|
||||
Updated int `json:"updated"`
|
||||
StartedAt time.Time `json:"startedAt"`
|
||||
FinishedAt time.Time `json:"finishedAt"`
|
||||
// ShopFilterSnapshot is the immutable enabled-shop snapshot supplied when
|
||||
// the run was created. It is intentionally a display-name list for audit;
|
||||
// matching continues to use the normalized map passed to the sync.
|
||||
ShopFilterSnapshot []string `json:"shopFilterSnapshot"`
|
||||
OrderCount int `json:"orderCount"`
|
||||
DetailCount int `json:"detailCount"`
|
||||
Created int `json:"created"`
|
||||
Updated int `json:"updated"`
|
||||
StartedAt time.Time `json:"startedAt"`
|
||||
FinishedAt time.Time `json:"finishedAt"`
|
||||
}
|
||||
|
||||
type ShopBreakdown struct {
|
||||
@@ -120,19 +124,24 @@ func Sync(ctx context.Context, db *gorm.DB, client *sybclient.Client, cfg SyncCo
|
||||
}
|
||||
|
||||
func SyncWithProgress(ctx context.Context, db *gorm.DB, client *sybclient.Client, cfg SyncConfig, dateFrom, dateTo string, progress ProgressFunc) (SyncReport, error) {
|
||||
allowed, err := sybshop.EnabledNames(ctx, db)
|
||||
if err != nil {
|
||||
return SyncReport{From: dateFrom, To: dateTo, StartedAt: time.Now().UTC(), ShopBreakdown: map[string]ShopBreakdown{}}, err
|
||||
}
|
||||
return SyncWithShopSnapshot(ctx, db, client, cfg, dateFrom, dateTo, allowed, progress)
|
||||
}
|
||||
|
||||
// SyncWithShopSnapshot runs against the immutable enabled-shop snapshot taken
|
||||
// by StartImport. It must not query syb_shop again: doing so would make the
|
||||
// run's effective allow-list differ from the snapshot recorded on its row.
|
||||
func SyncWithShopSnapshot(ctx context.Context, db *gorm.DB, client *sybclient.Client, cfg SyncConfig, dateFrom, dateTo string, allowed map[string]string, progress ProgressFunc) (SyncReport, error) {
|
||||
report := SyncReport{From: dateFrom, To: dateTo, StartedAt: time.Now().UTC(),
|
||||
ShopBreakdown: map[string]ShopBreakdown{}}
|
||||
ShopBreakdown: map[string]ShopBreakdown{}, ShopFilterSnapshot: shopSnapshotNames(allowed)}
|
||||
|
||||
dates, err := splitDateRange(dateFrom, dateTo)
|
||||
if err != nil {
|
||||
return report, err
|
||||
}
|
||||
// `[必须]` The allow-list is read once and used for the whole run, so a shop
|
||||
// enabled midway cannot make one day behave differently from the next.
|
||||
allowed, err := sybshop.EnabledNames(ctx, db)
|
||||
if err != nil {
|
||||
return report, err
|
||||
}
|
||||
if len(allowed) == 0 {
|
||||
return report, ErrNoEnabledShop
|
||||
}
|
||||
@@ -283,6 +292,15 @@ func SyncWithProgress(ctx context.Context, db *gorm.DB, client *sybclient.Client
|
||||
return report, nil
|
||||
}
|
||||
|
||||
func shopSnapshotNames(allowed map[string]string) []string {
|
||||
names := make([]string, 0, len(allowed))
|
||||
for _, display := range allowed {
|
||||
names = append(names, display)
|
||||
}
|
||||
sort.Strings(names)
|
||||
return names
|
||||
}
|
||||
|
||||
func enabledShopHash(allowed map[string]string) string {
|
||||
names := make([]string, 0, len(allowed))
|
||||
for normalized := range allowed {
|
||||
|
||||
@@ -28,6 +28,7 @@ func NewSyncRunService(db *gorm.DB) *SyncRunService { return &SyncRunService{db:
|
||||
|
||||
type CreateSyncRunInput struct {
|
||||
DateFrom, DateTo, ShopFilterHash string
|
||||
ShopFilterSnapshot map[string]string
|
||||
OperatorID uint64
|
||||
OperatorName string
|
||||
}
|
||||
@@ -38,10 +39,14 @@ func (s *SyncRunService) Create(ctx context.Context, input CreateSyncRunInput) (
|
||||
return nil, invalidRequest(err.Error())
|
||||
}
|
||||
slot := uint8(1)
|
||||
payload, err := marshalSyncRunPayload(nil, shopSnapshotNames(input.ShopFilterSnapshot))
|
||||
if err != nil {
|
||||
return nil, &ServiceError{Code: CodeSyncRunCreateFailed, Message: "服务端处理失败", Cause: err}
|
||||
}
|
||||
run := &models.SYBSyncRun{
|
||||
DateFrom: input.DateFrom, DateTo: input.DateTo, Status: SyncRunRunning,
|
||||
ActiveSlot: &slot, DaysTotal: len(dates), ShopFilterHash: input.ShopFilterHash,
|
||||
ShopBreakdownJSON: "{}", OperatorID: input.OperatorID,
|
||||
ShopBreakdownJSON: payload, OperatorID: input.OperatorID,
|
||||
OperatorName: strings.TrimSpace(input.OperatorName), StartedAt: time.Now().UTC(),
|
||||
}
|
||||
if err := s.db.WithContext(ctx).Create(run).Error; err != nil {
|
||||
@@ -55,7 +60,7 @@ func (s *SyncRunService) Create(ctx context.Context, input CreateSyncRunInput) (
|
||||
}
|
||||
|
||||
func (s *SyncRunService) UpdateProgress(ctx context.Context, id uint64, progress SyncProgress) error {
|
||||
breakdown, err := json.Marshal(progress.Report.ShopBreakdown)
|
||||
payload, err := marshalSyncRunPayload(progress.Report.ShopBreakdown, progress.Report.ShopFilterSnapshot)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -65,7 +70,7 @@ func (s *SyncRunService) UpdateProgress(ctx context.Context, id uint64, progress
|
||||
"order_count": progress.Report.OrderCount, "detail_count": progress.Report.DetailCount,
|
||||
"accepted_count": progress.Report.AcceptedCount, "shop_skipped": progress.Report.ShopSkipped,
|
||||
"created_count": progress.Report.Created, "updated_count": progress.Report.Updated,
|
||||
"shop_filter_hash": progress.Report.ShopFilterHash, "shop_breakdown_json": string(breakdown),
|
||||
"shop_filter_hash": progress.Report.ShopFilterHash, "shop_breakdown_json": payload,
|
||||
}).Error
|
||||
}
|
||||
|
||||
@@ -73,7 +78,7 @@ func (s *SyncRunService) Finish(ctx context.Context, id uint64, status string, r
|
||||
if status != SyncRunSucceeded && status != SyncRunFailed {
|
||||
return fmt.Errorf("invalid terminal sync status %q", status)
|
||||
}
|
||||
breakdown, err := json.Marshal(report.ShopBreakdown)
|
||||
payload, err := marshalSyncRunPayload(report.ShopBreakdown, report.ShopFilterSnapshot)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -87,7 +92,7 @@ func (s *SyncRunService) Finish(ctx context.Context, id uint64, status string, r
|
||||
"order_count": report.OrderCount, "detail_count": report.DetailCount,
|
||||
"accepted_count": report.AcceptedCount, "shop_skipped": report.ShopSkipped,
|
||||
"created_count": report.Created, "updated_count": report.Updated,
|
||||
"shop_breakdown_json": string(breakdown),
|
||||
"shop_breakdown_json": payload,
|
||||
}
|
||||
if report.ShopFilterHash != "" {
|
||||
updates["shop_filter_hash"] = report.ShopFilterHash
|
||||
@@ -146,8 +151,10 @@ type ShopBreakdownItem struct {
|
||||
}
|
||||
type SyncRunDTO struct {
|
||||
models.SYBSyncRun
|
||||
ShopBreakdown []ShopBreakdownItem `json:"shopBreakdown"`
|
||||
ProgressPercent int `json:"progressPercent"`
|
||||
ShopBreakdown []ShopBreakdownItem `json:"shopBreakdown"`
|
||||
ShopFilterSnapshot []string `json:"shopFilterSnapshot"`
|
||||
ShopFilterSnapshotPresent bool `json:"shopFilterSnapshotPresent"`
|
||||
ProgressPercent int `json:"progressPercent"`
|
||||
}
|
||||
|
||||
func (s *SyncRunService) List(ctx context.Context, request SyncRunListRequest) (SyncRunListResponse, error) {
|
||||
@@ -188,8 +195,7 @@ func (s *SyncRunService) Detail(ctx context.Context, id uint64) (SyncRunDTO, err
|
||||
}
|
||||
|
||||
func syncRunDTO(row models.SYBSyncRun) SyncRunDTO {
|
||||
var values map[string]ShopBreakdown
|
||||
_ = json.Unmarshal([]byte(row.ShopBreakdownJSON), &values)
|
||||
values, snapshot, snapshotPresent := parseSyncRunPayload(row.ShopBreakdownJSON)
|
||||
names := make([]string, 0, len(values))
|
||||
for name := range values {
|
||||
names = append(names, name)
|
||||
@@ -210,7 +216,48 @@ func syncRunDTO(row models.SYBSyncRun) SyncRunDTO {
|
||||
if row.Status == SyncRunSucceeded {
|
||||
percent = 100
|
||||
}
|
||||
return SyncRunDTO{SYBSyncRun: row, ShopBreakdown: items, ProgressPercent: percent}
|
||||
return SyncRunDTO{SYBSyncRun: row, ShopBreakdown: items, ShopFilterSnapshot: snapshot,
|
||||
ShopFilterSnapshotPresent: snapshotPresent, ProgressPercent: percent}
|
||||
}
|
||||
|
||||
const syncRunPayloadVersion = 1
|
||||
|
||||
type syncRunPayload struct {
|
||||
Version int `json:"version"`
|
||||
ShopBreakdown map[string]ShopBreakdown `json:"shopBreakdown"`
|
||||
ShopFilterSnapshot []string `json:"shopFilterSnapshot"`
|
||||
}
|
||||
|
||||
func marshalSyncRunPayload(breakdown map[string]ShopBreakdown, snapshot []string) (string, error) {
|
||||
if breakdown == nil {
|
||||
breakdown = map[string]ShopBreakdown{}
|
||||
}
|
||||
if snapshot == nil {
|
||||
snapshot = []string{}
|
||||
}
|
||||
encoded, err := json.Marshal(syncRunPayload{
|
||||
Version: syncRunPayloadVersion, ShopBreakdown: breakdown, ShopFilterSnapshot: snapshot,
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(encoded), nil
|
||||
}
|
||||
|
||||
func parseSyncRunPayload(raw string) (map[string]ShopBreakdown, []string, bool) {
|
||||
var envelope syncRunPayload
|
||||
if err := json.Unmarshal([]byte(raw), &envelope); err == nil && envelope.Version == syncRunPayloadVersion {
|
||||
if envelope.ShopBreakdown == nil {
|
||||
envelope.ShopBreakdown = map[string]ShopBreakdown{}
|
||||
}
|
||||
sort.Strings(envelope.ShopFilterSnapshot)
|
||||
return envelope.ShopBreakdown, envelope.ShopFilterSnapshot, true
|
||||
}
|
||||
// Records written before #212 are a bare breakdown map. Preserve their
|
||||
// existing statistics and state explicitly that no immutable snapshot exists.
|
||||
values := map[string]ShopBreakdown{}
|
||||
_ = json.Unmarshal([]byte(raw), &values)
|
||||
return values, nil, false
|
||||
}
|
||||
|
||||
func truncateRunError(value string, limit int) string {
|
||||
|
||||
@@ -4,6 +4,8 @@ import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"go-admin/app/goauto/models"
|
||||
)
|
||||
|
||||
func TestSyncRunSingleFlightAndTerminalRelease(t *testing.T) {
|
||||
@@ -57,3 +59,32 @@ func TestSyncRunProgressDetailAndRecovery(t *testing.T) {
|
||||
t.Fatalf("启动恢复状态不正确: %+v", detail)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncRunPersistsImmutableShopSnapshotAndReadsLegacyBreakdown(t *testing.T) {
|
||||
db := newSyncTestDB(t)
|
||||
service := NewSyncRunService(db)
|
||||
run, err := service.Create(context.Background(), CreateSyncRunInput{
|
||||
DateFrom: "2026-08-01", DateTo: "2026-08-01", ShopFilterHash: "snapshot-hash",
|
||||
ShopFilterSnapshot: map[string]string{"alpha": "Alpha 店", "beta": "Beta 店"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
detail, err := service.Detail(context.Background(), run.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !detail.ShopFilterSnapshotPresent || strings.Join(detail.ShopFilterSnapshot, ",") != "Alpha 店,Beta 店" {
|
||||
t.Fatalf("新任务必须返回冻结快照: %+v", detail)
|
||||
}
|
||||
if err := db.Model(&models.SYBSyncRun{}).Where("id = ?", run.ID).Update("shop_breakdown_json", `{"旧店":{"accepted":2,"skipped":1}}`).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
legacy, err := service.Detail(context.Background(), run.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if legacy.ShopFilterSnapshotPresent || len(legacy.ShopFilterSnapshot) != 0 || len(legacy.ShopBreakdown) != 1 || legacy.ShopBreakdown[0].Accepted != 2 {
|
||||
t.Fatalf("旧记录必须保留统计且明确没有快照: %+v", legacy)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -429,6 +429,26 @@ func TestSyncMatchesShopNamesThroughNormalization(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncWithShopSnapshotDoesNotRereadChangedShopState(t *testing.T) {
|
||||
db := newSyncTestDB(t)
|
||||
allowed, err := sybshop.EnabledNames(context.Background(), db)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Model(&models.SYBShop{}).Where("normalized_name = ?", "测试店铺").Update("enabled", false).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f := &fakeSYB{perDay: map[string]int{"2026-08-01": 2}, shopNames: []string{"测试店铺"}}
|
||||
report, err := SyncWithShopSnapshot(context.Background(), db, newSyncClient(t, f),
|
||||
SyncConfig{PageSize: 10, MaxMatches: 1000}, "2026-08-01", "2026-08-01", allowed, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("冻结快照同步失败: %v", err)
|
||||
}
|
||||
if report.AcceptedCount != 2 || len(report.ShopFilterSnapshot) != 1 || report.ShopFilterSnapshot[0] != "测试店铺" {
|
||||
t.Fatalf("后台不得重读已变化的店铺状态: %+v", report)
|
||||
}
|
||||
}
|
||||
|
||||
// 列表说属于启用店铺、明细却是另一家店时必须拦住。
|
||||
func TestSyncRechecksShopOnDetailResponse(t *testing.T) {
|
||||
db := newSyncTestDB(t)
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
package sybshop
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"go-admin/app/goauto/models"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// ReconcileNormalizedNames repairs the matching keys of live SYB shops created
|
||||
// before Normalize became the single write-path rule. It changes neither the
|
||||
// display name nor the enabled state. The operation is safe to repeat.
|
||||
//
|
||||
// A normalised-name collision is a data ambiguity. It must be resolved by an
|
||||
// operator, rather than silently merging shops or choosing one arbitrarily.
|
||||
func ReconcileNormalizedNames(ctx context.Context, db *gorm.DB) (int, error) {
|
||||
if db == nil {
|
||||
return 0, fmt.Errorf("syb shop normalized-name repair: database is nil")
|
||||
}
|
||||
|
||||
updated := 0
|
||||
err := db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
var shops []models.SYBShop
|
||||
if err := tx.Order("id ASC").Find(&shops).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
desiredOwners := make(map[string]uint64, len(shops))
|
||||
currentKeys := make(map[string]struct{}, len(shops))
|
||||
type repair struct {
|
||||
id uint64
|
||||
desired string
|
||||
temp string
|
||||
}
|
||||
repairs := make([]repair, 0)
|
||||
for _, shop := range shops {
|
||||
desired := Normalize(shop.DisplayName)
|
||||
if desired == "" {
|
||||
return fmt.Errorf("syb shop normalized-name repair: shop %d has blank display name", shop.ID)
|
||||
}
|
||||
if owner, exists := desiredOwners[desired]; exists && owner != shop.ID {
|
||||
return fmt.Errorf("syb shop normalized-name repair: normalized key conflict between shops %d and %d", owner, shop.ID)
|
||||
}
|
||||
desiredOwners[desired] = shop.ID
|
||||
currentKeys[shop.NormalizedName] = struct{}{}
|
||||
if shop.NormalizedName != desired {
|
||||
repairs = append(repairs, repair{id: shop.ID, desired: desired})
|
||||
}
|
||||
}
|
||||
|
||||
// Keys can be swapped (for example stale "a"/"b" values). Stage every
|
||||
// repair through a unique temporary key so the unique index is preserved
|
||||
// throughout the transaction.
|
||||
reserved := make(map[string]struct{}, len(currentKeys)+len(desiredOwners))
|
||||
for key := range currentKeys {
|
||||
reserved[key] = struct{}{}
|
||||
}
|
||||
for key := range desiredOwners {
|
||||
reserved[key] = struct{}{}
|
||||
}
|
||||
for index := range repairs {
|
||||
base := fmt.Sprintf("__goauto_syb_shop_normalize_repair_%d__", repairs[index].id)
|
||||
temp := base
|
||||
for suffix := 1; ; suffix++ {
|
||||
if _, exists := reserved[temp]; !exists {
|
||||
break
|
||||
}
|
||||
temp = fmt.Sprintf("%s%d", base, suffix)
|
||||
}
|
||||
repairs[index].temp = temp
|
||||
reserved[temp] = struct{}{}
|
||||
}
|
||||
for _, repair := range repairs {
|
||||
if err := tx.Model(&models.SYBShop{}).Where("id = ?", repair.id).
|
||||
UpdateColumn("normalized_name", repair.temp).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
for _, repair := range repairs {
|
||||
if err := tx.Model(&models.SYBShop{}).Where("id = ?", repair.id).
|
||||
UpdateColumn("normalized_name", repair.desired).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
updated = len(repairs)
|
||||
return nil
|
||||
})
|
||||
return updated, err
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package sybshop_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"go-admin/app/goauto/models"
|
||||
"go-admin/app/goauto/sybshop"
|
||||
)
|
||||
|
||||
func TestReconcileNormalizedNamesRepairsLegacyKey(t *testing.T) {
|
||||
db := openDB(t)
|
||||
shop := models.SYBShop{DisplayName: "樂齡樂活美學館|銀髮居家熟齡悅己壯世代優雅生活", NormalizedName: "legacy-key", Enabled: true}
|
||||
if err := db.Create(&shop).Error; err != nil {
|
||||
t.Fatalf("创建历史店铺失败: %v", err)
|
||||
}
|
||||
|
||||
updated, err := sybshop.ReconcileNormalizedNames(context.Background(), db)
|
||||
if err != nil {
|
||||
t.Fatalf("回填失败: %v", err)
|
||||
}
|
||||
if updated != 1 {
|
||||
t.Fatalf("应修复 1 条,实际 %d", updated)
|
||||
}
|
||||
var reloaded models.SYBShop
|
||||
if err := db.First(&reloaded, shop.ID).Error; err != nil {
|
||||
t.Fatalf("读取修复结果失败: %v", err)
|
||||
}
|
||||
if reloaded.NormalizedName != sybshop.Normalize(shop.DisplayName) {
|
||||
t.Fatalf("规范化键不正确: %q", reloaded.NormalizedName)
|
||||
}
|
||||
if reloaded.DisplayName != shop.DisplayName || !reloaded.Enabled {
|
||||
t.Fatalf("回填不应修改展示名或启用状态: %+v", reloaded)
|
||||
}
|
||||
names, err := sybshop.EnabledNames(context.Background(), db)
|
||||
if err != nil || names[sybshop.Normalize(shop.DisplayName)] != shop.DisplayName {
|
||||
t.Fatalf("修复后启用快照未命中: names=%v err=%v", names, err)
|
||||
}
|
||||
|
||||
updated, err = sybshop.ReconcileNormalizedNames(context.Background(), db)
|
||||
if err != nil || updated != 0 {
|
||||
t.Fatalf("重复回填应无变化: updated=%d err=%v", updated, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReconcileNormalizedNamesRejectsCollisionWithoutWriting(t *testing.T) {
|
||||
db := openDB(t)
|
||||
first := models.SYBShop{DisplayName: "A店", NormalizedName: "legacy-one", Enabled: true}
|
||||
second := models.SYBShop{DisplayName: "A店", NormalizedName: "legacy-two", Enabled: true}
|
||||
if err := db.Create(&first).Error; err != nil {
|
||||
t.Fatalf("创建第一条历史店铺失败: %v", err)
|
||||
}
|
||||
if err := db.Create(&second).Error; err != nil {
|
||||
t.Fatalf("创建第二条历史店铺失败: %v", err)
|
||||
}
|
||||
|
||||
updated, err := sybshop.ReconcileNormalizedNames(context.Background(), db)
|
||||
if updated != 0 || err == nil || !strings.Contains(err.Error(), "normalized key conflict") {
|
||||
t.Fatalf("冲突应失败且不报告更新: updated=%d err=%v", updated, err)
|
||||
}
|
||||
var rows []models.SYBShop
|
||||
if err := db.Order("id ASC").Find(&rows).Error; err != nil {
|
||||
t.Fatalf("读取冲突结果失败: %v", err)
|
||||
}
|
||||
if len(rows) != 2 || rows[0].NormalizedName != "legacy-one" || rows[1].NormalizedName != "legacy-two" {
|
||||
t.Fatalf("冲突时不应修改任何键: %+v", rows)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReconcileNormalizedNamesHandlesSwappedLegacyKeys(t *testing.T) {
|
||||
db := openDB(t)
|
||||
first := models.SYBShop{DisplayName: "A", NormalizedName: "b", Enabled: true}
|
||||
second := models.SYBShop{DisplayName: "B", NormalizedName: "a", Enabled: true}
|
||||
if err := db.Create(&first).Error; err != nil {
|
||||
t.Fatalf("创建第一条历史店铺失败: %v", err)
|
||||
}
|
||||
if err := db.Create(&second).Error; err != nil {
|
||||
t.Fatalf("创建第二条历史店铺失败: %v", err)
|
||||
}
|
||||
|
||||
updated, err := sybshop.ReconcileNormalizedNames(context.Background(), db)
|
||||
if err != nil || updated != 2 {
|
||||
t.Fatalf("交换键回填失败: updated=%d err=%v", updated, err)
|
||||
}
|
||||
var rows []models.SYBShop
|
||||
if err := db.Order("id ASC").Find(&rows).Error; err != nil {
|
||||
t.Fatalf("读取回填结果失败: %v", err)
|
||||
}
|
||||
if rows[0].NormalizedName != "a" || rows[1].NormalizedName != "b" {
|
||||
t.Fatalf("交换键未正确回填: %+v", rows)
|
||||
}
|
||||
}
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
package version_local
|
||||
|
||||
import (
|
||||
"context"
|
||||
"runtime"
|
||||
|
||||
goautomigrations "go-admin/app/goauto/migrations"
|
||||
"go-admin/app/goauto/sybshop"
|
||||
"go-admin/cmd/migrate/migration"
|
||||
common "go-admin/common/models"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// This migration repairs historical syb_shop.normalized_name values written
|
||||
// before all shop-name write paths used sybshop.Normalize (#213).
|
||||
func init() {
|
||||
_, fileName, _, _ := runtime.Caller(0)
|
||||
migration.Migrate.SetVersion(migration.GetFilename(fileName), migrateSYBShopNormalizedNameBackfill)
|
||||
}
|
||||
|
||||
func migrateSYBShopNormalizedNameBackfill(db *gorm.DB, version string) error {
|
||||
return db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := goautomigrations.Migrate(tx); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := sybshop.ReconcileNormalizedNames(context.Background(), tx); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Create(&common.Migration{Version: version}).Error
|
||||
})
|
||||
}
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
package version_local
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
goautomigrations "go-admin/app/goauto/migrations"
|
||||
"go-admin/app/goauto/models"
|
||||
common "go-admin/common/models"
|
||||
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
)
|
||||
|
||||
func TestMigrateSYBShopNormalizedNameBackfillRepairsLegacyRows(t *testing.T) {
|
||||
db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{
|
||||
Logger: logger.Default.LogMode(logger.Silent),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("打开测试数据库失败: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(&common.Migration{}); err != nil {
|
||||
t.Fatalf("创建迁移记录表失败: %v", err)
|
||||
}
|
||||
if err := goautomigrations.Migrate(db); err != nil {
|
||||
t.Fatalf("创建 GoAuto 表失败: %v", err)
|
||||
}
|
||||
shop := models.SYBShop{DisplayName: "ABC店", NormalizedName: "ABC店", Enabled: true}
|
||||
if err := db.Create(&shop).Error; err != nil {
|
||||
t.Fatalf("创建历史店铺失败: %v", err)
|
||||
}
|
||||
|
||||
const version = "1787983900000"
|
||||
if err := migrateSYBShopNormalizedNameBackfill(db, version); err != nil {
|
||||
t.Fatalf("执行回填迁移失败: %v", err)
|
||||
}
|
||||
var reloaded models.SYBShop
|
||||
if err := db.First(&reloaded, shop.ID).Error; err != nil {
|
||||
t.Fatalf("读取回填结果失败: %v", err)
|
||||
}
|
||||
if reloaded.NormalizedName != "abc店" {
|
||||
t.Fatalf("迁移未回填规范化键: %q", reloaded.NormalizedName)
|
||||
}
|
||||
var records int64
|
||||
if err := db.Model(&common.Migration{}).Where("version = ?", version).Count(&records).Error; err != nil {
|
||||
t.Fatalf("读取迁移记录失败: %v", err)
|
||||
}
|
||||
if records != 1 {
|
||||
t.Fatalf("迁移应留下一个版本记录,实际 %d", records)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user