Compare commits

..
Author SHA1 Message Date
QiuSWandClaude Opus 5 0ab5a96ca4 fix(server): read purchaser identity from JWT claims for owned devices (#333)
go-admin's Authorizator runs per request with the IdentityHandler map,
which carries no user entry, so c.Get("userId") was always 0 and every
purchaser got an empty device list.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
2026-09-22 10:46:01 +08:00
55 changed files with 93 additions and 6662 deletions
-4
View File
@@ -31,7 +31,3 @@
/android/**/build/
__pycache__/
*.py[cod]
# Third-party capture and device dumps: contain credentials, tokens and personal data.
*.har
/xml_data/
+1 -1
View File
@@ -30,7 +30,7 @@
- 不保存原始控件树和整屏设备截图;允许保存按规格值节点边界裁剪的商品图片区域,且裁剪结果不得包含账号、地址、订单、支付及其他个人数据。除此之外只保存结构化任务日志、错误码、任务规则快照和采集结果。
- 一台设备同一时刻只执行一个任务;手机离线时当前采集任务失败,默认不重试、不自动换机。
- Android Agent 端:找不到控件、验证码、风控、人机验证或登录失效时明确失败,不使用 OCR/VLM。
- 服务端顺云宝(SYB)登录与 yeeke(mmt.yeeke.com 对裹)登录:允许调用配置的线上自建 OCR 服务识别登录验证码(SYB 见 #48;yeeke 于 2026-09-23 经用户在 #336 明确批准)。验证码图片会离开本项目发送到该服务,更换服务地址前必须重新评估;OCR 请求和日志不得记录账号、密码、验证码原文或 token。此例外只适用于上述两个服务端登录流程,不扩大到 Agent 端或任何 PDD 相关流程。
- 服务端顺云宝(SYB)登录:允许调用配置的线上 OCR 服务识别登录验证码(见 #48)。验证码图片会离开本项目发送到该服务,更换服务地址前必须重新评估。此例外只适用于 SYB 登录,不扩大到 Agent 端或任何 PDD 相关流程。
- 规格匹配:Agent 本地不得自行猜测规格或点击相近候选,只执行服务端下发的精确规格;人工映射缺失、商品无规格数据或目标规格定位不到时,由服务端 AI 匹配接口决策(见 #46),AI 无结果时明确失败。
- SKU 数据不完整仍须提交并允许在任务详情查看,状态记为 `completed_partial`。
- 规则创建即生效;删除后不能创建新任务,但已有任务继续使用自身规则快照。
+2 -2
View File
@@ -11,8 +11,8 @@ android {
applicationId = "cn.ilapage.goauto.agent"
minSdk = 23
targetSdk = 34
versionCode = 76
versionName = "0.9.63"
versionCode = 72
versionName = "0.9.59"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
@@ -90,28 +90,7 @@ 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,
/**
* #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
}
enum class SpecPanelType { UNKNOWN, NORMAL_SCROLLABLE, NON_SCROLLABLE_CONFIRMATION, QUICK_CONFIRMATION, ORDER_CONFIRMATION }
object PddSoldOutRecoveryDefaults {
const val EXACT_TEXT = "商品已售罄"
@@ -155,54 +134,7 @@ 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 = "相似商品",
@@ -254,13 +186,6 @@ 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 ->
@@ -278,8 +203,6 @@ 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(" ", "") }
@@ -317,23 +240,7 @@ 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
@@ -369,50 +276,27 @@ 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" })
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) }
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 }
.filterNot { isExactHeadingLabel(it.label, config) }
.filterNot { node ->
isExcludedOptionLabel(node.label) ||
descendants(node, visibleNodes).any { descendant -> isExcludedOptionLabel(descendant.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
.map {
val rawText = it.label
val normalizedText = when (dimensionKey) {
"color" -> SpecValueNormalizer.normalizeColor(rawText)
"size" -> SpecValueNormalizer.normalizeSize(rawText)
else -> rawText
}
val stateText = (listOf(option.label) + descendants(option, visibleNodes).map(SnapshotNode::label)).joinToString(" ")
val stateText = (listOf(it.label) + descendants(it, visibleNodes).map(SnapshotNode::label)).joinToString(" ")
VisibleSpecValue(
normalizedText,
option.enabled && !stateText.containsUnavailableWord(),
option,
if (dimensionKey == "color") {
(listOf(option) + members.filter { it.path != option.path })
.firstNotNullOfOrNull { colorImageBoundsForOption(it, visibleNodes) }
} else null,
it.enabled && !stateText.containsUnavailableWord(),
it,
if (dimensionKey == "color") colorImageBoundsForOption(it, visibleNodes) else null,
rawText,
)
}
@@ -455,14 +339,6 @@ 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
@@ -563,12 +439,6 @@ 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,
@@ -583,93 +453,6 @@ 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 ->
@@ -1602,41 +1385,6 @@ class PddProductDetailCollector(
.count()
}
/**
* #334: the panel already shows its topmost color heading (the first
* parsed dimension is "color") with values visible, so no restore swipe
* is required. Swiping an already-top panel drags the bottom sheet
* further and can make the color/size headings vanish.
*/
private fun isSpecPanelAtTop(screen: ParsedPddScreen): Boolean {
val first = screen.dimensions.firstOrNull() ?: return false
return first.key == "color" && first.values.isNotEmpty()
}
private fun recordSpecPanelTopSwipe(
reason: AgentDiagnosticReason,
screen: ParsedPddScreen,
swipeCount: Int,
headingCountBefore: Int?,
headingCountAfter: Int?,
) {
if (taskId <= 0) return
diagnostic(
AgentDiagnosticEvent(
taskId = taskId,
// A dedicated PAGE_STABILITY event on purpose: SPEC_PANEL_ENTRY
// already carries the panel-open/restore diagnostics elsewhere,
// and several tests assert a single SPEC_PANEL_ENTRY event per run.
stage = AgentDiagnosticStage.PAGE_STABILITY,
reason = reason,
attempt = swipeCount,
targetClassName = "type=${screen.specPanelType.name};headBefore=${headingCountBefore ?: -1};" +
"headAfter=${headingCountAfter ?: -1};dim=${screen.dimensions.sumOf { it.values.size }}",
clickableAncestorDepth = screen.panelHeadingCount,
),
)
}
private fun moveSpecPanelToTop(
goodsId: String,
config: PddCollectorConfig,
@@ -1646,40 +1394,17 @@ class PddProductDetailCollector(
): PddCollectorResult? {
var previous: List<String>? = null
var stable = 0
var priorHeadingCount = -1
var priorDimensionCount = -1
var swipes = 0
repeat(config.limits.getValue("specVerticalSwipes")) { pass ->
repeat(config.limits.getValue("specVerticalSwipes")) {
if (now() > deadline) return failure("RULE_NOT_MATCHED", "采集超过规则总超时")
val screen = parse(goodsId, config, evidence)
screen.problem?.let { return failure(it.code, it.message) }
if (!screen.pageEvidenceMatched) return failure("RULE_NOT_MATCHED", "采集期间离开 PDD 商品详情页")
if (pass == 0 && isSpecPanelAtTop(screen)) {
recordSpecPanelTopSwipe(AgentDiagnosticReason.SPEC_PANEL_TOP_ALREADY, screen, swipes, null, screen.panelHeadingCount)
return null
}
val headingCount = screen.panelHeadingCount
val dimensionCount = screen.dimensions.sumOf { it.values.size }
// A normal scroll-to-top can legitimately drop the panel's
// heading count (lower dimensions scroll out of view) without
// losing any values, so heading count alone must never trigger
// this. Only a full loss of every collected spec value, or the
// panel no longer being recognized as open, indicates the sheet
// was dragged too far.
val panelNoLongerRecognized = !screen.specPanelOpen || screen.specPanelType == SpecPanelType.UNKNOWN
if (pass > 0 && priorDimensionCount > 0 && (dimensionCount == 0 || panelNoLongerRecognized)) {
recordSpecPanelTopSwipe(AgentDiagnosticReason.SPEC_PANEL_TOP_COLLAPSED, screen, swipes, priorHeadingCount, headingCount)
return failure("SPEC_PANEL_TOP_COLLAPSED", "回顶滑动导致规格标题消失,已停止滑动")
}
priorHeadingCount = headingCount
priorDimensionCount = dimensionCount
val signature = viewportSignature(screen)
stable = if (previous != null && signature == previous) stable + 1 else 0
previous = signature
if (stable >= config.limits.getValue("stableEdgeReads")) return null
val anchor = screen.dimensions.flatMap { it.values }.firstOrNull()?.node ?: specPanelContainer ?: return null
if (!driver.swipeSpec(SwipeDirection.DOWN, anchor)) return null
swipes++
pause(350)
}
return null
@@ -1847,8 +1572,6 @@ 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,8 +40,6 @@ 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
@@ -525,7 +523,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(expected, suffix)
val savedEvidence = waitForStableAddressEditorExit()
if (!hasFinalSavedAddressEvidence(savedEvidence, expected, suffix)) {
if (isPurchaseConfirmationPanel(savedEvidence)) {
restoreFinalEvidenceInCurrentPanel(savedEvidence, expected, suffix)
@@ -542,7 +540,11 @@ 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 PURCHASE_CONFIRMATION_PANEL_TYPES
return screen.specPanelType in setOf(
SpecPanelType.NORMAL_SCROLLABLE,
SpecPanelType.NON_SCROLLABLE_CONFIRMATION,
SpecPanelType.ORDER_CONFIRMATION,
)
}
private fun restoreFinalEvidenceInCurrentPanel(
@@ -596,14 +598,14 @@ class PurchaseLiveAutomation(
private fun hasFinalSavedAddressEvidence(snapshot: UiSnapshot, expected: String, suffix: String): Boolean =
hasSavedAddressEvidence(snapshot, expected, suffix) && finalSubmitTargets(snapshot).size == 1
private fun waitForStableAddressEditorExit(expected: String, suffix: String): UiSnapshot {
private fun waitForStableAddressEditorExit(): 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 waitForSettledPanelAfterAddressSave(snapshot, expected, suffix)
if (consecutiveExits >= 2) return snapshot
} else {
consecutiveExits = 0
}
@@ -612,68 +614,6 @@ 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 =
@@ -776,159 +716,10 @@ class PurchaseLiveAutomation(
pause(500)
}
/**
* #335 (3rd revision, reviewer cross-check on real dumps — Samsung sample): a
* two-step "row, then leaf" pick, not a single whole-panel leaf scan. A whole-panel
* scan can pick a labelled leaf that lives in a DIFFERENT clickable row than the real
* bottom bar — on the Samsung sample the bottom bar is a label-less 31px FrameLayout
* whose only text node is zero-size, so a naive scan falls through to the next
* lowest labelled leaf, which sits in the PAYMENT-METHOD row above it, and the climb
* from there lands the click on "change payment method" instead of the order button.
*
* Step 1 — row: among visible/enabled/non-zero-size CLICKABLE nodes inside the
* recognized panel's container (#331 judgment) with height ≤ [SUBMIT_ROW_MAX_HEIGHT_PERCENT]
* of screen height (same guard as 2bc624f, keeps a full-sheet/full-body wrapper from
* ever being "the row"), pick the one with the lowest bottom edge; ties go to the
* rightmost. This row — and only this row — may hold the order button. If its own
* subtree matches a payment-method alias (`textAliases.specPanel.paymentAreaAliases`,
* e.g. 微信支付/先用后付/支付方式) or [SUBMIT_TARGET_BLOCKED_MARKERS], fail explicitly;
* never fall back to a higher row — a higher row is never the order button either.
*
* Step 2 — leaf: within that row's own subtree (or the row itself), among
* visible/non-zero-size nodes with a NON-BLANK OWN LABEL whose nearest clickable
* ancestor is EXACTLY that row (not some nested sub-button, and not a higher row),
* pick the bottom-right-most one. [PurchaseUiDriver.clickFresh] re-finds its target
* live by `preferredOrDescendantLabel() == target.label && className && center±32`
* and only then climbs to the nearest clickable ancestor and clicks it (see
* `GoAutoAccessibilityService.clickFreshDetailed`); a label-less container's
* [SnapshotNode.label] is `""` but its live `preferredOrDescendantLabel()` digs into a
* child, so the re-find would always miss (task 594: TARGET_NOT_FOUND right after the
* irreversible boundary). Aiming at the labelled leaf — the same convention already
* used by `ImageSearchCandidatePolicy.clickTargetInside` — lets clickFresh's own
* climb-to-ancestor logic land the tap back on the row. If the row has no such leaf
* (Samsung: only a zero-size text node), fail explicitly (`bottom_row_unlabelled`) —
* never fall back to a higher row.
*/
private fun finalSubmitTargets(snapshot: UiSnapshot): List<SnapshotNode> {
fun hasArea(node: SnapshotNode) = node.bounds.width > 0 && node.bounds.height > 0
val byPath = snapshot.nodes.associateBy { it.path }
fun clickableAncestor(node: SnapshotNode): SnapshotNode? {
var current: SnapshotNode? = node
while (current != null && !current.clickable) current = current.parentPath?.let(byPath::get)
return current
}
val screen = PddScreenParser.parse(snapshot, PurchaseRehearsalExecutor.DEFAULT_COLLECTOR, "", null)
if (screen.specPanelType !in PURCHASE_CONFIRMATION_PANEL_TYPES) {
panelDiagnostic("bottomSubmit;outcome=not_panel;type=${screen.specPanelType}")
return emptyList()
}
val container = panelContainerBounds(snapshot)
if (container == null) {
panelDiagnostic("bottomSubmit;outcome=no_container;type=${screen.specPanelType}")
return emptyList()
}
val screenHeight = snapshot.nodes.filter { it.visible }.maxOfOrNull { it.bounds.bottom }?.coerceAtLeast(1) ?: 1
// Step 1: the bottom-most clickable row.
val rowCandidates = snapshot.nodes.filter { node ->
node.visible && node.enabled && node.clickable && hasArea(node) &&
node.bounds.height.toLong() * 100 <= screenHeight.toLong() * SUBMIT_ROW_MAX_HEIGHT_PERCENT &&
inside(node.bounds, container)
}.distinctBy { it.path }
if (rowCandidates.isEmpty()) {
panelDiagnostic("bottomSubmit;outcome=no_rows;type=${screen.specPanelType}")
return emptyList()
}
val rowMaxBottom = rowCandidates.maxOf { it.bounds.bottom }
val rowBottomTied = rowCandidates.filter { it.bounds.bottom == rowMaxBottom }
val rowTie = rowBottomTied.size > 1
val row = rowBottomTied.maxBy { it.bounds.right }
val rowSubtreeLabels = (listOf(row.label) + subtreeDescendants(row, snapshot).map(SnapshotNode::label)).joinToString("")
val paymentAreaAliases = PurchaseRehearsalExecutor.DEFAULT_COLLECTOR.textAliases.specPanel.paymentAreaAliases
if (paymentAreaAliases.any(rowSubtreeLabels::contains) || SUBMIT_TARGET_BLOCKED_MARKERS.any(rowSubtreeLabels::contains)) {
panelDiagnostic(
"bottomSubmit;outcome=row_blocked;tie=${rowTie.diagFlag()};class=${row.className};" +
"w=${row.bounds.width};h=${row.bounds.height}",
)
return emptyList()
}
// Step 2: the bottom-right-most labelled leaf whose nearest clickable ancestor is exactly this row.
val leafCandidates = (listOf(row) + subtreeDescendants(row, snapshot)).filter { node ->
node.visible && node.enabled && hasArea(node) && node.label.isNotEmpty() &&
clickableAncestor(node)?.path == row.path
}.distinctBy { it.path }
if (leafCandidates.isEmpty()) {
panelDiagnostic(
"bottomSubmit;outcome=bottom_row_unlabelled;tie=${rowTie.diagFlag()};class=${row.className};" +
"w=${row.bounds.width};h=${row.bounds.height}",
)
return emptyList()
}
val leafMaxBottom = leafCandidates.maxOf { it.bounds.bottom }
val leafBottomTied = leafCandidates.filter { it.bounds.bottom == leafMaxBottom }
val leafTie = leafBottomTied.size > 1
val chosen = leafBottomTied.maxBy { it.bounds.right }
val hasPrice = PRICE_PRESENCE.containsMatchIn(rowSubtreeLabels)
val legacyMarker = FINAL_SUBMIT_MARKERS.any { rowSubtreeLabels == it || rowSubtreeLabels.startsWith(it) }
panelDiagnostic(
"bottomSubmit;outcome=ok;rowTie=${rowTie.diagFlag()};leafTie=${leafTie.diagFlag()};" +
"leafClass=${chosen.className};rowClass=${row.className};w=${row.bounds.width};h=${row.bounds.height};" +
"price=${hasPrice.diagFlag()};legacyMarker=${legacyMarker.diagFlag()};type=${screen.specPanelType}",
)
return listOf(chosen)
}
private fun subtreeDescendants(node: SnapshotNode, snapshot: UiSnapshot): List<SnapshotNode> {
val prefix = "${node.path}/"
return snapshot.nodes.filter { it.path.startsWith(prefix) }
}
/**
* #335 follow-up: the submit click target must belong to the recognized spec panel,
* not just be somewhere on screen (e.g. an underlying goods-detail-page bottom bar
* that is still in the accessibility tree behind the panel). [ParsedPddScreen
* .specPanelContainer] (PddScreenParser's `panelScrollable`) is not usable for this:
* it is the inner *scrollable dimension list* used only for heading/option parsing,
* and in a real PDD sheet the address/payment/submit rows sit OUTSIDE it as
* structural siblings, not descendants. Instead this climbs from the panel's unique
* quantity input — present for every recognized #331 panel type used here — to the
* LARGEST ancestor that still does not cover the whole screen, i.e. the whole
* bottom-sheet container. That is the same "does not cover the whole screen" bounded
* notion #331 already uses for `sharesBoundedPanelContainer`/`boundedScrollables`,
* just walked from the quantity input instead of recomputed from private internals.
* A candidate then belongs to the panel when its center point falls inside that
* container's bounds (geometric containment, not path prefix, since PDD's own
* accessibility tree can place the submit bar as a structural sibling of the panel
* container that is still visually part of the sheet). Returns null (no target) when
* the quantity input is missing or not unique, so the caller fails explicitly instead
* of guessing a container.
*/
private fun panelContainerBounds(snapshot: UiSnapshot): NodeBounds? {
val byPath = snapshot.nodes.associateBy { it.path }
val visible = snapshot.nodes.filter { it.visible }
val screenWidth = visible.maxOfOrNull { it.bounds.right } ?: return null
val screenHeight = visible.maxOfOrNull { it.bounds.bottom } ?: return null
val screenArea = screenWidth.toLong() * screenHeight
val quantity = snapshot.nodes.singleOrNull { node ->
node.visible && node.enabled && node.className == "android.widget.EditText" &&
node.label.toIntOrNull()?.let { it > 0 } == true
} ?: return null
var lastBounded: SnapshotNode? = null
var current: SnapshotNode = quantity
while (true) {
val parent = current.parentPath?.let(byPath::get) ?: break
val parentArea = parent.bounds.width.toLong() * parent.bounds.height
if (parentArea <= 0 || parentArea >= screenArea) break
lastBounded = parent
current = parent
}
return lastBounded?.bounds
}
private fun Boolean.diagFlag(): Int = if (this) 1 else 0
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 orderConfirmationReady(snapshot: UiSnapshot): Boolean {
if (snapshot.packageName != PDD_PACKAGE) return false
@@ -1002,27 +793,9 @@ class PurchaseLiveAutomation(
private companion object {
const val PDD_PACKAGE = "com.xunmeng.pinduoduo"
const val WECHAT_PACKAGE = "com.tencent.mm"
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 MASKED_PHONE = Regex("(?<![0-9])[0-9]{3}\\*{4}[0-9]{4}(?![0-9])")
val FINAL_SUBMIT_MARKERS = listOf("提交订单", "现在买,仅", "确认购买")
// #335: presence-only signal for diagnostics; never records the actual price digits.
val PRICE_PRESENCE = Regex("[¥¥][0-9]")
// #335: a bottom-edge candidate taller than this share of screen height is a
// whole-sheet/whole-body wrapper, never the order button itself.
const val SUBMIT_ROW_MAX_HEIGHT_PERCENT = 30
val PAYMENT_MARKERS = listOf("立即支付", "确认支付", "输入支付密码")
// #335: broader than PAYMENT_MARKERS on purpose — this list is used ONLY by
// finalSubmitTargets' bottom-node subtree check, never by pageProblem/order-result
// parsing. "付款" alone must NOT join PAYMENT_MARKERS: UNPAID_MARKERS' "待付款"
// contains "付款" and PAYMENT_MARKERS gates the global pageProblem() payment guard,
// so adding it there would misclassify every ordinary unpaid-order page as a
// forbidden payment page.
val SUBMIT_TARGET_BLOCKED_MARKERS = listOf("立即支付", "确认支付", "去支付", "付款", "输入支付密码")
val UNPAID_MARKERS = listOf("待付款", "待支付", "去支付")
val ORDER_DETAIL_ENTRY_MARKERS = setOf("查看订单", "订单详情")
val ORDER_CONTEXT_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, panelDiagnostic)
val live = PurchaseLiveAutomation(driver, pause)
for (action in rule.actions) {
// The immediate phase-two handoff can reuse the PDD page retained by
// spec_probe. A later manual retry may start from Agent (or another
@@ -394,7 +394,7 @@ class PurchaseRehearsalExecutor(
if (wait.changed) {
return failure(
SPEC_PANEL_EVIDENCE_NOT_MATCHED,
"规格入口点击后页面已变化,但规格面板强证据不足 [${panelEvidence(wait.screen)}]${wait.screen.requiredEvidenceHintSuffix()}",
"规格入口点击后页面已变化,但规格面板强证据不足 [${panelEvidence(wait.screen)}]",
)
}
@@ -415,7 +415,7 @@ class PurchaseRehearsalExecutor(
if (wait.changed) {
return failure(
SPEC_PANEL_EVIDENCE_NOT_MATCHED,
"规格入口手势后页面已变化,但规格面板强证据不足 [${panelEvidence(wait.screen)}]${wait.screen.requiredEvidenceHintSuffix()}",
"规格入口手势后页面已变化,但规格面板强证据不足 [${panelEvidence(wait.screen)}]",
)
}
return failure(
@@ -446,51 +446,17 @@ class PurchaseRehearsalExecutor(
private fun waitForSpecPanel(input: PurchaseExecutionInput, beforeSignature: List<SpecActionNodeSignature>): SpecPanelWait {
var last = currentScreen(input)
var changed = false
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) {
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)) }
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
if (last.specPanelOpen) return SpecPanelWait(last, true, true)
changed = changed || specActionSignature(last) != beforeSignature
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)
}
@@ -512,8 +478,7 @@ 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};" +
screen.requiredEvidenceSummary()
"orderAction=${screen.hasOrderSubmitAction};pageEvidence=${screen.pageEvidenceMatched}"
private fun specEntryEvidence(screen: ParsedPddScreen, candidateCount: Int, entryReadyWaitPolls: Int = 0): String =
"specEntryCandidates=$candidateCount;explicit=${screen.explicitSpecEntryCount};" +
@@ -609,11 +574,9 @@ 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) {
@@ -626,19 +589,6 @@ 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,
@@ -1225,7 +1175,6 @@ 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"
@@ -1,95 +0,0 @@
package cn.ilapage.goauto.agent.automation
import org.json.JSONObject
/**
* #334: a spec probe that produced zero color/size dimensions, or that never
* collected any dimensions because the collector itself failed (e.g. an
* over-swiped spec panel losing its headings — see
* [PddProductDetailCollector.moveSpecPanelToTop]), must fail explicitly
* rather than being reported as a normal `spec_probe_completed` empty
* result or a generic "no matchable spec" outcome.
*
* This lives outside [PurchaseRehearsalExecutor] on purpose: the executor's
* `probeSpecs` callback is opaque by contract (any JSON payload the caller
* wants to hand to the server), and its own unit tests exercise that
* contract with canned payloads unrelated to this device bug. The checks
* instead run once, at the point where
* [cn.ilapage.goauto.agent.service.AgentForegroundService] turns the real
* collector result into the outcome that gets persisted and reported.
* `collectPurchaseProbe` encodes a failed collector run as a
* `probeFailureCode`/`probeFailureMessage` JSON payload (still routed
* through the same opaque `String?` probe callback) instead of `null`, so
* [demote] can tell it apart from a genuinely empty-but-successful probe.
*/
object PurchaseSpecProbePolicy {
const val EMPTY_PROBE_CODE = "PURCHASE_SPEC_PROBE_EMPTY"
const val EMPTY_PROBE_MESSAGE = "规格探测未读取到任何颜色或尺码"
const val FAILED_PROBE_CODE = "PURCHASE_SPEC_PROBE_FAILED"
/** JSON field names used by [collectPurchaseProbeFailurePayload]-style encodings. */
const val PROBE_FAILURE_CODE_FIELD = "probeFailureCode"
const val PROBE_FAILURE_MESSAGE_FIELD = "probeFailureMessage"
/** Purchaser-readable messages for known collector failure codes; anything else falls back to a generic message that still includes the raw code. */
private val knownCollectorFailureMessages = mapOf(
"SPEC_PANEL_TOP_COLLAPSED" to "规格探测时规格面板被拖动,规格标题消失",
)
/** True when the probe JSON's `dimensions` array has no entries, or entries with no values. */
fun isDimensionsEmpty(probedSpecsJson: String?): Boolean {
if (probedSpecsJson.isNullOrBlank()) return true
return try {
val dimensions = JSONObject(probedSpecsJson).optJSONArray("dimensions") ?: return true
var total = 0
for (i in 0 until dimensions.length()) {
total += dimensions.optJSONObject(i)?.optJSONArray("values")?.length() ?: 0
}
total == 0
} catch (_: Exception) {
// Malformed payload cannot be trusted as a real, non-empty probe result.
true
}
}
/** Extracts a collector failure code from a probe JSON payload built by [encodeCollectorFailure], or null when the payload is not a failure encoding. */
fun extractCollectorFailureCode(probedSpecsJson: String?): String? {
if (probedSpecsJson.isNullOrBlank()) return null
return try {
JSONObject(probedSpecsJson).optString(PROBE_FAILURE_CODE_FIELD, "").takeIf(String::isNotBlank)
} catch (_: Exception) {
null
}
}
/** Builds the opaque probe JSON payload used to carry an explicit collector failure through the `probeSpecs: () -> String?` callback. */
fun encodeCollectorFailure(goodsId: String, collectorCode: String, collectorMessage: String): String =
JSONObject()
.put("goodsId", goodsId)
.put(PROBE_FAILURE_CODE_FIELD, collectorCode)
.put(PROBE_FAILURE_MESSAGE_FIELD, collectorMessage)
.toString()
private fun failureMessageFor(collectorCode: String): String =
knownCollectorFailureMessages[collectorCode] ?: "商品规格探测失败:$collectorCode"
/**
* Demotes a `spec_probe_completed` outcome that either carries an
* encoded collector failure, or collected zero dimensions, into an
* explicit failure. Any other outcome (including real failures reported
* some other way, or a probe that did collect dimensions) is returned
* unchanged.
*/
fun demote(outcome: PurchaseExecutionOutcome): PurchaseExecutionOutcome {
if (outcome.resultType != "spec_probe_completed") return outcome
extractCollectorFailureCode(outcome.probedSpecs)?.let { collectorCode ->
return PurchaseExecutionOutcome("failed", FAILED_PROBE_CODE, failureMessageFor(collectorCode))
}
if (!isDimensionsEmpty(outcome.probedSpecs)) return outcome
return PurchaseExecutionOutcome("failed", EMPTY_PROBE_CODE, EMPTY_PROBE_MESSAGE)
}
/** @see demote */
@Deprecated("Use demote(outcome), which also handles encoded collector failures.", ReplaceWith("demote(outcome)"))
fun demoteIfEmpty(outcome: PurchaseExecutionOutcome): PurchaseExecutionOutcome = demote(outcome)
}
@@ -30,10 +30,6 @@ enum class AgentDiagnosticReason {
SPEC_PANEL_RESTORE_LIMIT,
SPEC_PANEL_RESTORE_CONTAINER_UNAVAILABLE,
SPEC_PANEL_RESTORE_GESTURE_FAILED,
/** #334: the spec panel already showed the topmost color heading; the top-restore swipe was skipped. */
SPEC_PANEL_TOP_ALREADY,
/** #334: a top-restore swipe made spec headings or dimensions vanish (the bottom sheet was dragged); swiping stopped. */
SPEC_PANEL_TOP_COLLAPSED,
SPEC_PANEL_EVIDENCE_NOT_MATCHED,
SPEC_ENTRY_CLICK_NO_EFFECT,
SIZE_FOUND,
@@ -38,7 +38,6 @@ import cn.ilapage.goauto.agent.automation.PageEvidence
import cn.ilapage.goauto.agent.automation.NodeSelector
import cn.ilapage.goauto.agent.automation.CollectionRule
import cn.ilapage.goauto.agent.automation.PurchaseAgentCapabilities
import cn.ilapage.goauto.agent.automation.PurchaseSpecProbePolicy
import cn.ilapage.goauto.agent.automation.PurchaseExecutionInput
import cn.ilapage.goauto.agent.automation.PurchaseExecutionOutcome
import cn.ilapage.goauto.agent.automation.PurchaseLiveAutomation
@@ -592,7 +591,7 @@ class AgentForegroundService : Service() {
stateStore.update("BUSY", "正在执行${taskLabel}任务 #${task.taskId}", tokenStored = true)
updateNotification("$taskLabel #${task.taskId}")
val rawOutcome = if (!snapshotHashValid) {
val outcome = if (!snapshotHashValid) {
PurchaseExecutionOutcome("failed", "PURCHASE_RULE_INVALID", "采购规则快照哈希无效")
} else {
var parseFailure: PurchaseExecutionOutcome? = null
@@ -653,9 +652,6 @@ class AgentForegroundService : Service() {
}
}
}
// #334: a spec probe that read zero colors/sizes must fail explicitly
// instead of being reported as a normal, empty spec_probe_completed.
val outcome = PurchaseSpecProbePolicy.demote(rawOutcome)
val requestId = UUID.randomUUID().toString()
val payload = purchaseResultPayload(requestId, task.taskAttemptId, outcome)
purchaseStore.completeAndEnqueue(task.taskId, task.taskAttemptId, requestId, payload)
@@ -695,13 +691,6 @@ class AgentForegroundService : Service() {
collector = collector,
)
val result = PddProductDetailCollector(accessibility).collect(task.pddGoodsId, rule)
// #334: a collector failure (e.g. SPEC_PANEL_TOP_COLLAPSED) must not
// collapse into a generic "spec probe failed" outcome. Carry the
// real code/message through the opaque probeSpecs callback so
// PurchaseSpecProbePolicy.demote can surface it explicitly.
if (!result.successful) {
return PurchaseSpecProbePolicy.encodeCollectorFailure(task.pddGoodsId, result.code, result.message)
}
val payload = result.payload ?: return null
return JSONObject()
.put("goodsId", task.pddGoodsId)
@@ -974,95 +974,6 @@ class PddProductDetailCollectorTest {
assertTrue(driver.captureCount > driver.clickCount)
}
@Test
fun `spec panel already at top skips the restore swipe and still collects colors and sizes`() {
// Default fixture state already renders the color heading first (at
// top). #334: moveSpecPanelToTop must not issue any DOWN swipe in
// that case, since dragging an already-top panel can lose headings
// on a real device.
val driver = FakeCollectorDriver(
colorPages = listOf(listOf("红色", "蓝色")),
sizePages = listOf(listOf("S", "M")),
prices = mapOf("红色" to 1099L, "蓝色" to 1299L),
)
var clock = 0L
val result = PddProductDetailCollector(driver, { clock }, { clock += it }).collect(GOODS_ID, rule())
assertTrue(result.successful)
val payload = requireNotNull(result.payload)
assertEquals(listOf("红色", "蓝色"), payload.colorPrices.map { it.color })
assertEquals(listOf("S", "M"), payload.dimensions.first { it.key == "size" }.values)
assertEquals(0, driver.swipes.count { it.first == SwipeDirection.DOWN })
}
@Test
fun `spec panel not at top restores with the original swipe behavior before collecting`() {
// The color heading is hidden until one DOWN (top-restore) swipe has
// happened, simulating a panel scrolled past its headings.
val driver = FakeCollectorDriver(
colorPages = listOf(listOf("红色", "蓝色")),
sizePages = listOf(listOf("S", "M")),
prices = mapOf("红色" to 1099L, "蓝色" to 1299L),
specPanelHidesColorInitially = true,
)
var clock = 0L
val result = PddProductDetailCollector(driver, { clock }, { clock += it }).collect(GOODS_ID, rule())
assertTrue(result.successful)
val payload = requireNotNull(result.payload)
assertEquals(listOf("红色", "蓝色"), payload.colorPrices.map { it.color })
assertEquals(listOf("S", "M"), payload.dimensions.first { it.key == "size" }.values)
assertTrue(driver.swipes.count { it.first == SwipeDirection.DOWN } >= 1)
}
@Test
fun `spec headings vanishing after a restore swipe stops swiping and fails explicitly`() {
// The panel starts not-at-top (color heading hidden) so the restore
// swipe runs; after that swipe every heading and value disappears,
// simulating the bottom sheet being dragged off screen. #334
// requires the collector to stop and fail instead of reporting an
// empty successful result.
val driver = FakeCollectorDriver(
colorPages = listOf(listOf("红色", "蓝色")),
sizePages = listOf(listOf("S", "M")),
prices = mapOf("红色" to 1099L, "蓝色" to 1299L),
specPanelHidesColorInitially = true,
specPanelCollapsesAfterTopSwipe = true,
)
var clock = 0L
val result = PddProductDetailCollector(driver, { clock }, { clock += it }).collect(GOODS_ID, rule())
assertFalse(result.successful)
assertEquals("SPEC_PANEL_TOP_COLLAPSED", result.code)
assertEquals(1, driver.swipes.count { it.first == SwipeDirection.DOWN })
}
@Test
fun `a lower heading scrolling out of view after a restore swipe does not fail collection`() {
// Three-dimension panel not at top (color heading is present but has
// no values yet, so it does not become dimensions.first()). After the
// restore swipe, color values return but the third, lower "容量"
// heading scrolls out of view — a normal scroll-to-top effect. Total
// heading count drops, but color and size values are present the
// whole time, so this must NOT be treated as a collapsed panel.
val driver = FakeCollectorDriver(
colorPages = listOf(listOf("红色", "蓝色")),
sizePages = listOf(listOf("S", "M")),
prices = mapOf("红色" to 1099L, "蓝色" to 1299L),
extraDimension = true,
specPanelHidesColorInitially = true,
specPanelDropsExtraDimensionAfterTopSwipe = true,
)
var clock = 0L
val result = PddProductDetailCollector(driver, { clock }, { clock += it }).collect(GOODS_ID, rule())
assertTrue(result.successful)
val payload = requireNotNull(result.payload)
assertEquals(listOf("红色", "蓝色"), payload.colorPrices.map { it.color })
assertEquals(listOf("S", "M"), payload.dimensions.first { it.key == "size" }.values)
assertTrue(driver.swipes.count { it.first == SwipeDirection.DOWN } >= 1)
}
@Test
fun `duplicate sizes after price cleanup reject the whole size dimension`() {
val driver = FakeCollectorDriver(
@@ -1133,9 +1044,7 @@ class PddProductDetailCollectorTest {
assertTrue(result.successful)
assertEquals(listOf("A色", "B色", "C色", "F色", "E色", "D色"), driver.clickedLabels)
assertEquals(colors, requireNotNull(result.payload).dimensions.first { it.key == "color" }.values)
// #334: the panel already shows the color heading at top, so
// moveSpecPanelToTop must not issue a needless DOWN restore swipe.
assertTrue(driver.swipes.none { it.first == SwipeDirection.DOWN })
assertTrue(driver.swipes.any { it.first == SwipeDirection.DOWN && it.second != null })
assertTrue(driver.swipes.any { it.first == SwipeDirection.RIGHT && it.second != null })
assertTrue(driver.swipes.any { it.first == SwipeDirection.UP && it.second != null })
}
@@ -1848,21 +1757,6 @@ class PddProductDetailCollectorTest {
private val selectedSummaryPrefix: String = "已选",
private val imageColorCards: Boolean = false,
private val navigateAwayAfterColorClick: Boolean = false,
// #334: simulates a spec panel that is not scrolled to top yet: the
// color heading is present but has no values yet (so it is not the
// first parsed dimension), until one DOWN (top-restore) swipe has
// happened, after which color values render normally.
private val specPanelHidesColorInitially: Boolean = false,
// #334: simulates a bottom sheet dragged past its headings by a
// restore swipe — once at least one DOWN swipe has happened, every
// spec heading and value disappears from the panel.
private val specPanelCollapsesAfterTopSwipe: Boolean = false,
// #334: simulates a normal scroll-to-top on a multi-dimension panel
// where a lower, non-spec-value-bearing heading (here the extra
// "容量" dimension) scrolls out of view once the color heading is
// restored to top. Heading count drops but color/size values are
// untouched, so this must NOT be treated as a collapsed panel.
private val specPanelDropsExtraDimensionAfterTopSwipe: Boolean = false,
) : PddCollectorDriver {
var captureCount = 0
var clickCount = 0
@@ -1876,7 +1770,6 @@ class PddProductDetailCollectorTest {
private var previousSelected: String? = null
private var horizontalPage = 0
private var verticalPage = 0
private var downSwipeCount = 0
private var priceRead = 0
private var panelOpen = !startWithPanelClosed
private var reviewOpen = false
@@ -1939,16 +1832,12 @@ class PddProductDetailCollectorTest {
node("scroll", "", 0, 380, 1080, 1900, scrollable = scrollablePanel),
)
val continuationPage = hideDimensionHeadingsAfterFirstVerticalPage && verticalPage > 0
val panelCollapsedNow = specPanelCollapsesAfterTopSwipe && downSwipeCount >= 1
val hideColorNow = specPanelHidesColorInitially && downSwipeCount == 0
val visibleColors = colorVerticalPages?.get(verticalPage.coerceAtMost(colorVerticalPages.lastIndex))
?: colorPages[horizontalPage.coerceAtMost(colorPages.lastIndex)]
val colorRowCount = (visibleColors.size + rowSize - 1) / rowSize
val sizeHeadingTop = maxOf(700, 470 + colorRowCount * 90 + 20)
if (!continuationPage && !panelCollapsedNow) {
if (!continuationPage) {
nodes += node("scroll/color-heading", "颜色分类", 20, 400, 300, 450, parentPath = "scroll")
}
if (!continuationPage && !hideColorNow && !panelCollapsedNow) {
visibleColors
.filterNot { hideSelectedColorOption && it == selected }
.forEachIndexed { index, color ->
@@ -1988,23 +1877,21 @@ class PddProductDetailCollectorTest {
)
}
}
if (!continuationPage && !panelCollapsedNow) nodes += node("scroll/size-heading", sizeHeadingLabel, 20, sizeHeadingTop, 300, sizeHeadingTop + 50, parentPath = "scroll")
if (!panelCollapsedNow) {
sizePages[verticalPage.coerceAtMost(sizePages.lastIndex)].forEachIndexed { index, size ->
nodes += node(
"scroll/size-$size-$captureCount",
size,
30 + index * 230,
sizeHeadingTop + 70,
220 + index * 230,
sizeHeadingTop + 140,
clickable = true,
selected = size == initialSelectedSize,
parentPath = "scroll",
)
}
if (!continuationPage) nodes += node("scroll/size-heading", sizeHeadingLabel, 20, sizeHeadingTop, 300, sizeHeadingTop + 50, parentPath = "scroll")
sizePages[verticalPage.coerceAtMost(sizePages.lastIndex)].forEachIndexed { index, size ->
nodes += node(
"scroll/size-$size-$captureCount",
size,
30 + index * 230,
sizeHeadingTop + 70,
220 + index * 230,
sizeHeadingTop + 140,
clickable = true,
selected = size == initialSelectedSize,
parentPath = "scroll",
)
}
if (extraDimension && !(specPanelDropsExtraDimensionAfterTopSwipe && downSwipeCount >= 1)) {
if (extraDimension) {
nodes += node("scroll/capacity-heading", "容量", 20, 900, 300, 950, parentPath = "scroll")
nodes += node("scroll/capacity", "大容量", 30, 970, 220, 1040, clickable = true, parentPath = "scroll")
}
@@ -2079,10 +1966,7 @@ class PddProductDetailCollectorTest {
SwipeDirection.LEFT -> horizontalPage = (horizontalPage + 1).coerceAtMost(colorPages.lastIndex)
SwipeDirection.UP -> verticalPage = (verticalPage + 1)
.coerceAtMost(maxOf(sizePages.lastIndex, colorVerticalPages?.lastIndex ?: 0))
SwipeDirection.DOWN -> {
verticalPage = (verticalPage - 1).coerceAtLeast(0)
downSwipeCount++
}
SwipeDirection.DOWN -> verticalPage = (verticalPage - 1).coerceAtLeast(0)
else -> Unit
}
return true
@@ -197,12 +197,7 @@ class PurchaseLiveAutomationTest {
val automation = PurchaseLiveAutomation(driver, pause = {})
val address = automation.updateShippingAddress("_cg11")
assertEquals("广东省广州市天园街道骏景花园骏晖轩1202_cg11", address.expectedAddress)
// #335: the confirmation-page fixture now wraps the masked phone in a clickable
// row (required so the page satisfies #331 REQUIRED_EVIDENCE panel recognition,
// which finalSubmitTargets depends on). That changes address entry activation
// from a bare TAP to a CLICK through the row's path, same as production PDD rows.
assertEquals(0, driver.addressTaps)
assertEquals(1, driver.addressPathClicks)
assertEquals(1, driver.addressTaps)
assertEquals("editor-address", driver.lastInputTargetPath)
val final = automation.finalConfirmation(input(), address)
assertEquals("_cg11", final.addressSuffix)
@@ -877,50 +872,38 @@ class PurchaseLiveAutomationTest {
"unknown" -> UiSnapshot("example.untrusted", "example.untrusted.UnknownActivity", emptyList())
"pdd-home" -> UiSnapshot(PDD, "com.xunmeng.pinduoduo.ui.activity.MainFrameActivity", listOf(node("home", "拼多多首页")))
else -> {
// #335: the confirmation page must satisfy PddScreenParser's spec-panel
// recognition (#331 REQUIRED_EVIDENCE fallback: address entry + payment
// entry + one quantity input, all sharing the "panel" container) so the
// new bottom-most-clickable-node submit rule has a recognized panel to
// operate on. "panel/submit" keeps the legacy exact "提交订单" label so
// existing click-driven state transitions below stay unchanged.
val nodes = mutableListOf(
node("root", "", bounds = NodeBounds(0, 0, 1080, 2200)),
node("panel", "", scrollable = true, parentPath = "root", bounds = NodeBounds(0, 400, 1080, 2100)),
node("panel/price", "¥20.00", parentPath = "panel", bounds = NodeBounds(20, 420, 300, 470)),
node("panel/selected", "已选 黑色 XL", parentPath = "panel", bounds = NodeBounds(20, 480, 700, 530)),
node("panel/quantity", "2", className = "android.widget.EditText", parentPath = "panel", bounds = NodeBounds(400, 560, 600, 620)),
node("panel/payment-row", "", clickable = true, parentPath = "panel", bounds = NodeBounds(20, 640, 1060, 710)),
node("panel/payment-row/label", "微信支付", parentPath = "panel/payment-row", bounds = NodeBounds(40, 650, 300, 700)),
node("panel/submit", "提交订单", clickable = true, parentPath = "panel", bounds = NodeBounds(20, 1900, 1060, 2080)),
node("panel", "", scrollable = true, bounds = NodeBounds(0, 400, 1080, 2100)),
node("price", "¥20.00"), node("selected", "已选 黑色 XL"),
node("quantity", "2", className = "android.widget.EditText"),
node("submit-parent", "", clickable = true), node("submit", "提交订单", parentPath = "submit-parent"),
)
if (duplicatePanels) nodes += node("panel2", "", scrollable = true, bounds = NodeBounds(0, 500, 1080, 2000))
if (addressVisible) {
when {
duplicateSemanticAddressCards -> {
nodes += node("panel/address-layer-a", "", clickable = true, parentPath = "panel", bounds = NodeBounds(0, 820, 1080, 900))
nodes += node("panel/address-layer-a/phone", "138****5678", parentPath = "panel/address-layer-a", bounds = NodeBounds(20, 830, 400, 860))
nodes += node("panel/address-layer-a/detail", "广东省广州市天园街道骏景花园", parentPath = "panel/address-layer-a", bounds = NodeBounds(20, 862, 900, 898))
nodes += node("panel/address-layer-b", "", clickable = true, parentPath = "panel", bounds = NodeBounds(0, 910, 1080, 990))
nodes += node("panel/address-layer-b/phone", "138****5678", parentPath = "panel/address-layer-b", bounds = NodeBounds(20, 920, 400, 950))
nodes += node("panel/address-layer-b/detail", "广东省广州市天园街道骏景花园", parentPath = "panel/address-layer-b", bounds = NodeBounds(20, 952, 900, 988))
nodes += node("address-layer-a", "", clickable = true, bounds = NodeBounds(0, 620, 1080, 840))
nodes += node("address-layer-a/phone", "138****5678", parentPath = "address-layer-a", bounds = NodeBounds(20, 650, 400, 710))
nodes += node("address-layer-a/detail", "广东省广州市天园街道骏景花园", parentPath = "address-layer-a", bounds = NodeBounds(20, 720, 900, 790))
nodes += node("address-layer-b", "", clickable = true, bounds = NodeBounds(0, 900, 1080, 1120))
nodes += node("address-layer-b/phone", "138****5678", parentPath = "address-layer-b", bounds = NodeBounds(20, 930, 400, 990))
nodes += node("address-layer-b/detail", "广东省广州市天园街道骏景花园", parentPath = "address-layer-b", bounds = NodeBounds(20, 1000, 900, 1070))
}
duplicateAddressCards -> {
nodes += node("panel/address-card-a", "", clickable = true, parentPath = "panel", bounds = NodeBounds(0, 820, 1080, 900))
nodes += node("panel/address-card-a/phone", "138****5678", parentPath = "panel/address-card-a", bounds = NodeBounds(20, 830, 400, 860))
nodes += node("panel/address-card-a/detail", "广东省广州市天园街道一号", parentPath = "panel/address-card-a", bounds = NodeBounds(20, 862, 900, 898))
nodes += node("panel/address-card-b", "", clickable = true, parentPath = "panel", bounds = NodeBounds(0, 910, 1080, 990))
nodes += node("panel/address-card-b/phone", "138****5678", parentPath = "panel/address-card-b", bounds = NodeBounds(20, 920, 400, 950))
nodes += node("panel/address-card-b/detail", "广东省广州市天园街道二号", parentPath = "panel/address-card-b", bounds = NodeBounds(20, 952, 900, 988))
nodes += node("address-card-a", "", clickable = true, bounds = NodeBounds(0, 620, 1080, 820))
nodes += node("address-card-a/phone", "138****5678", parentPath = "address-card-a", bounds = NodeBounds(20, 650, 400, 710))
nodes += node("address-card-a/detail", "广东省广州市天园街道一号", parentPath = "address-card-a", bounds = NodeBounds(20, 720, 900, 780))
nodes += node("address-card-b", "", clickable = true, bounds = NodeBounds(0, 840, 1080, 1040))
nodes += node("address-card-b/phone", "138****5678", parentPath = "address-card-b", bounds = NodeBounds(20, 870, 400, 930))
nodes += node("address-card-b/detail", "广东省广州市天园街道二号", parentPath = "address-card-b", bounds = NodeBounds(20, 940, 900, 1000))
}
duplicatePhoneNodesSameCard -> {
nodes += node("panel/address-card", "", clickable = true, parentPath = "panel", bounds = NodeBounds(0, 820, 1080, 900))
nodes += node("panel/address-card/phone-a", "138****5678", parentPath = "panel/address-card", bounds = NodeBounds(20, 830, 400, 860))
nodes += node("panel/address-card/phone-b", "138****5678", parentPath = "panel/address-card", bounds = NodeBounds(20, 862, 440, 892))
}
else -> {
nodes += node("panel/address-row", "", clickable = true, parentPath = "panel", bounds = NodeBounds(0, 820, 1080, 900))
nodes += node("panel/address-row/phone", "138****5678", parentPath = "panel/address-row", bounds = NodeBounds(20, 830, 400, 890))
nodes += node("address-card", "", clickable = true, bounds = NodeBounds(0, 620, 1080, 900))
nodes += node("address-card/phone-a", "138****5678", parentPath = "address-card", bounds = NodeBounds(20, 650, 400, 710))
nodes += node("address-card/phone-b", "138****5678", parentPath = "address-card", bounds = NodeBounds(20, 720, 440, 790))
}
else -> nodes += node("phone", "138****5678")
}
val suffixStart = address.lastIndexOf("_cg")
val addressBody = if (suffixStart >= 0) address.substring(0, suffixStart) else address
@@ -928,10 +911,10 @@ class PurchaseLiveAutomationTest {
when {
hideConfirmationSuffix -> nodes += node("address", addressBody)
splitConfirmationAddress -> {
nodes += node("address-body", addressBody, bounds = NodeBounds(20, 1000, 900, 1080))
nodes += node("address-suffix", addressSuffix, bounds = NodeBounds(20, 1090, 300, 1150))
nodes += node("address-body", addressBody, bounds = NodeBounds(20, 700, 900, 780))
nodes += node("address-suffix", addressSuffix, bounds = NodeBounds(20, 780, 300, 840))
if (duplicateConfirmationSuffix) {
nodes += node("address-suffix-2", addressSuffix, bounds = NodeBounds(500, 1090, 780, 1150))
nodes += node("address-suffix-2", addressSuffix, bounds = NodeBounds(500, 780, 780, 840))
}
}
else -> nodes += node("address", address)
@@ -781,8 +781,7 @@ 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;" +
"addr=0;pay=0;qin=0;adj=0;summary=0;close=0;submit=0;same=0]",
"规格入口点击后页面已变化,但规格面板强证据不足 [type=UNKNOWN;scrollables=0;headings=0;options=0;summary=false;quantity=false;orderAction=false;pageEvidence=true]",
outcome.message,
)
assertEquals(0, driver.specTapCount)
@@ -1,120 +0,0 @@
package cn.ilapage.goauto.agent
import cn.ilapage.goauto.agent.automation.PurchaseExecutionOutcome
import cn.ilapage.goauto.agent.automation.PurchaseSpecProbePolicy
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class PurchaseSpecProbePolicyTest {
@Test
fun `zero dimensions is treated as empty`() {
assertTrue(PurchaseSpecProbePolicy.isDimensionsEmpty("""{"dimensions":[]}"""))
}
@Test
fun `dimensions with no values is treated as empty`() {
assertTrue(
PurchaseSpecProbePolicy.isDimensionsEmpty(
"""{"dimensions":[{"key":"color","name":"颜色","values":[]},{"key":"size","name":"尺码","values":[]}]}""",
),
)
}
@Test
fun `null blank or malformed payload is treated as empty`() {
assertTrue(PurchaseSpecProbePolicy.isDimensionsEmpty(null))
assertTrue(PurchaseSpecProbePolicy.isDimensionsEmpty(""))
assertTrue(PurchaseSpecProbePolicy.isDimensionsEmpty("not json"))
}
@Test
fun `dimensions with at least one value is not empty`() {
assertFalse(
PurchaseSpecProbePolicy.isDimensionsEmpty(
"""{"dimensions":[{"key":"color","name":"颜色","values":["红色"]}]}""",
),
)
}
@Test
fun `demote replaces an empty spec_probe_completed outcome with an explicit failure`() {
val outcome = PurchaseExecutionOutcome(
"spec_probe_completed",
message = "商品规格已回传,等待服务端匹配",
probedSpecs = """{"dimensions":[]}""",
)
val demoted = PurchaseSpecProbePolicy.demote(outcome)
assertEquals("failed", demoted.resultType)
assertEquals("PURCHASE_SPEC_PROBE_EMPTY", demoted.errorCode)
assertEquals("规格探测未读取到任何颜色或尺码", demoted.message)
}
@Test
fun `demote leaves a non-empty probe result unchanged`() {
val outcome = PurchaseExecutionOutcome(
"spec_probe_completed",
message = "商品规格已回传,等待服务端匹配",
probedSpecs = """{"dimensions":[{"key":"color","name":"颜色","values":["红色"]}]}""",
)
val demoted = PurchaseSpecProbePolicy.demote(outcome)
assertEquals(outcome, demoted)
}
@Test
fun `demote leaves other outcome types unchanged`() {
val outcome = PurchaseExecutionOutcome("failed", "PURCHASE_SPEC_NOT_MATCHED", "商品规格探测失败")
val demoted = PurchaseSpecProbePolicy.demote(outcome)
assertEquals(outcome, demoted)
}
@Test
fun `extractCollectorFailureCode reads the encoded collector failure code`() {
val payload = PurchaseSpecProbePolicy.encodeCollectorFailure("719834019024", "SPEC_PANEL_TOP_COLLAPSED", "回顶滑动导致规格标题消失,已停止滑动")
assertEquals("SPEC_PANEL_TOP_COLLAPSED", PurchaseSpecProbePolicy.extractCollectorFailureCode(payload))
}
@Test
fun `extractCollectorFailureCode returns null for a normal probe payload`() {
assertEquals(
null,
PurchaseSpecProbePolicy.extractCollectorFailureCode(
"""{"dimensions":[{"key":"color","name":"颜色","values":["红色"]}]}""",
),
)
assertEquals(null, PurchaseSpecProbePolicy.extractCollectorFailureCode(null))
}
@Test
fun `demote surfaces an encoded SPEC_PANEL_TOP_COLLAPSED collector failure with a purchaser-readable message`() {
val payload = PurchaseSpecProbePolicy.encodeCollectorFailure("719834019024", "SPEC_PANEL_TOP_COLLAPSED", "回顶滑动导致规格标题消失,已停止滑动")
val outcome = PurchaseExecutionOutcome("spec_probe_completed", message = "商品规格已回传,等待服务端匹配", probedSpecs = payload)
val demoted = PurchaseSpecProbePolicy.demote(outcome)
assertEquals("failed", demoted.resultType)
assertEquals("PURCHASE_SPEC_PROBE_FAILED", demoted.errorCode)
assertEquals("规格探测时规格面板被拖动,规格标题消失", demoted.message)
}
@Test
fun `demote falls back to a generic message for an unrecognized collector failure code`() {
val payload = PurchaseSpecProbePolicy.encodeCollectorFailure("719834019024", "RULE_NOT_MATCHED", "采集期间离开 PDD 商品详情页")
val outcome = PurchaseExecutionOutcome("spec_probe_completed", message = "商品规格已回传,等待服务端匹配", probedSpecs = payload)
val demoted = PurchaseSpecProbePolicy.demote(outcome)
assertEquals("failed", demoted.resultType)
assertEquals("PURCHASE_SPEC_PROBE_FAILED", demoted.errorCode)
assertTrue(demoted.message.contains("RULE_NOT_MATCHED"))
}
}
@@ -1,86 +0,0 @@
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"}
]
}"""
}
@@ -1,317 +0,0 @@
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()
}
}
@@ -1,879 +0,0 @@
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 -------------------------------------------
// #335: the order button's text keeps changing across PDD builds (促销文案/价格文案),
// so the click target is now the bottom-most clickable, enabled, non-zero-size node of
// a *recognized* spec panel, not a text match. These fixtures build on
// [SpecPanelFixtures.sheet] (address + payment + quantity present, so #331 recognizes
// the panel) with `submit = "none"` and then splice in the bottom-row scenario under
// test, mirroring how [readySheet] already extends a base fixture elsewhere in this file.
@Test
fun `an unrecognized page never yields a submit target`() {
// #335: the button's own text no longer has to match any known marker; this used
// to fail as "labelMismatch" and must now succeed instead (see the dedicated
// wording tests below). Only "the page is not a recognized spec panel" still blocks.
val driver = StaticDriver(SpecPanelFixtures.addressListPage())
val error = runCatching { PurchaseLiveAutomation(driver, pause = {}).submitOrderOnce() }.exceptionOrNull() as? PurchaseLiveException
assertEquals("PURCHASE_SUBMIT_TARGET_AMBIGUOUS", error?.code)
assertTrue(driver.clicked.isEmpty())
}
@Test
fun `a bottom node whose own label carries a payment word is never a click target`() {
val recognizedBase = SpecPanelFixtures.sheet(Sheet(submit = "none"))
val snapshot = recognizedBase.copy(
nodes = recognizedBase.nodes + node("r/submit", "立即支付", NodeBounds(0, 2135, 1080, 2216), clickable = true, parent = "r"),
)
val driver = StaticDriver(snapshot)
val error = runCatching { PurchaseLiveAutomation(driver, pause = {}).submitOrderOnce() }.exceptionOrNull() as? PurchaseLiveException
// The project-wide page-problem guard (pageProblem) rejects any visible payment
// wording before the submit-target logic even runs; either way, nothing is clicked.
assertEquals("PURCHASE_PAYMENT_FORBIDDEN", error?.code)
assertTrue(driver.clicked.isEmpty())
}
@Test
fun `a bottom node whose subtree carries a payment word is never a click target`() {
val recognizedBase = SpecPanelFixtures.sheet(Sheet(submit = "none"))
val snapshot = recognizedBase.copy(
nodes = recognizedBase.nodes + listOf(
node("r/submit", "", NodeBounds(0, 2135, 1080, 2216), clickable = true, parent = "r"),
node("r/submit/t", "确认支付 ¥23.99", NodeBounds(157, 2150, 922, 2210), parent = "r/submit"),
),
)
val driver = StaticDriver(snapshot)
val error = runCatching { PurchaseLiveAutomation(driver, pause = {}).submitOrderOnce() }.exceptionOrNull() as? PurchaseLiveException
assertEquals("PURCHASE_PAYMENT_FORBIDDEN", error?.code)
assertTrue(driver.clicked.isEmpty())
}
@Test
fun `a bottom node whose own label is go-pay is never a click target`() {
// "去支付" is not in PAYMENT_MARKERS (so the global pageProblem guard does not
// trip on it — a real 待付款/去支付 order-result page must stay usable), but it
// must still block the submit-target logic via SUBMIT_TARGET_BLOCKED_MARKERS.
val recognizedBase = SpecPanelFixtures.sheet(Sheet(submit = "none"))
val snapshot = recognizedBase.copy(
nodes = recognizedBase.nodes + node("r/submit", "去支付", NodeBounds(0, 2135, 1080, 2216), clickable = true, parent = "r"),
)
val driver = StaticDriver(snapshot)
val error = runCatching { PurchaseLiveAutomation(driver, pause = {}).submitOrderOnce() }.exceptionOrNull() as? PurchaseLiveException
assertEquals("PURCHASE_SUBMIT_TARGET_AMBIGUOUS", error?.code)
assertTrue(driver.clicked.isEmpty())
}
@Test
fun `a bottom node whose own label is pay-fee is never a click target`() {
// "付款" alone is not in PAYMENT_MARKERS either (UNPAID_MARKERS' "待付款" contains
// it, so adding it to PAYMENT_MARKERS would misclassify every unpaid-order page),
// but it must still block the submit-target logic here.
val recognizedBase = SpecPanelFixtures.sheet(Sheet(submit = "none"))
val snapshot = recognizedBase.copy(
nodes = recognizedBase.nodes + node("r/submit", "付款", NodeBounds(0, 2135, 1080, 2216), clickable = true, parent = "r"),
)
val driver = StaticDriver(snapshot)
val error = runCatching { PurchaseLiveAutomation(driver, pause = {}).submitOrderOnce() }.exceptionOrNull() as? PurchaseLiveException
assertEquals("PURCHASE_SUBMIT_TARGET_AMBIGUOUS", error?.code)
assertTrue(driver.clicked.isEmpty())
}
@Test
fun `an invisible payment word in the bottom node subtree still blocks the click`() {
// The project-wide pageProblem guard only scans *visible* labels; an invisible
// descendant would slip past it, so the submit-target logic must catch it on its
// own (#335 safety condition 3 covers "自身或子树文字" regardless of visibility).
val recognizedBase = SpecPanelFixtures.sheet(Sheet(submit = "none"))
val hiddenPaymentText = SnapshotNode(
"r/submit/hidden", "r/submit", "输入支付密码", null, null, "android.widget.TextView",
NodeBounds(0, 0, 0, 0), false, false, false, false, true, false,
)
val snapshot = recognizedBase.copy(
nodes = recognizedBase.nodes + listOf(
node("r/submit", "", NodeBounds(0, 2135, 1080, 2216), clickable = true, parent = "r"),
node("r/submit/t", "提交订单", NodeBounds(157, 2150, 922, 2210), parent = "r/submit"),
hiddenPaymentText,
),
)
val driver = StaticDriver(snapshot)
val error = runCatching { PurchaseLiveAutomation(driver, pause = {}).submitOrderOnce() }.exceptionOrNull() as? PurchaseLiveException
assertEquals("PURCHASE_SUBMIT_TARGET_AMBIGUOUS", error?.code)
assertTrue(driver.clicked.isEmpty())
}
@Test
fun `zero size bottom node is ignored in favor of the next non-zero labelled node`() {
// The click target is the labelled LEAF node (#335 2nd revision); the button
// carries its own label directly here (no separate text child) to make the
// assertion check the real click target.
val base = SpecPanelFixtures.sheet(Sheet(submit = "none"))
val snapshot = base.copy(
nodes = base.nodes + listOf(
node("r/real-submit", "选择颜色分类及尺码后,提交订单", NodeBounds(0, 2135, 1080, 2200), clickable = true, parent = "r"),
// Zero-*area* (right==left) and lower on screen: must never win despite
// having its own non-blank label and a clickable ancestor.
node("r/decoy", "¥0.0", NodeBounds(500, 2200, 500, 2216), clickable = true, parent = "r"),
),
)
val driver = StaticDriver(snapshot)
PurchaseLiveAutomation(driver, pause = {}).submitOrderOnce()
assertEquals(listOf("选择颜色分类及尺码后,提交订单"), driver.clicked)
}
@Test
fun `two side by side bottom buttons pick the rightmost and are reported as a tie`() {
val base = SpecPanelFixtures.sheet(Sheet(submit = "none"))
val diagnostics = mutableListOf<String>()
val snapshot = base.copy(
nodes = base.nodes + listOf(
// Avoid quickBuyAliases ("现在买") in either label: it would flip specPanelType
// to QUICK_CONFIRMATION, which is intentionally outside the recognized set here.
node("r/a", "限时优惠 ¥19.6", NodeBounds(0, 2000, 540, 2216), clickable = true, parent = "r"),
node("r/b", "大促价,¥16.9", NodeBounds(540, 2000, 1080, 2216), clickable = true, parent = "r"),
),
)
val driver = StaticDriver(snapshot)
PurchaseLiveAutomation(driver, pause = {}, panelDiagnostic = diagnostics::add).submitOrderOnce()
// r/b is the rightmost of the tied bottom row, so it is the one clicked.
assertEquals(listOf("大促价,¥16.9"), driver.clicked)
assertTrue(diagnostics.any { it.contains("outcome=ok") && it.contains("rowTie=1") })
}
@Test
fun `unique visible submit button is still clickable exactly once regardless of its wording`() {
val base = SpecPanelFixtures.sheet(Sheet(submit = "none"))
val snapshot = base.copy(
nodes = base.nodes + node("r/submit", "提交订单", NodeBounds(0, 2135, 1080, 2216), clickable = true, parent = "r"),
)
val driver = StaticDriver(snapshot)
PurchaseLiveAutomation(driver, pause = {}).submitOrderOnce()
assertEquals(listOf("提交订单"), driver.clicked)
}
@Test
fun `task 570 promo priced bottom row picks the bottom-right-most labelled leaf`() {
// #335 origin: goods 8580 task 570, the bottom button's text was split across
// three sibling text nodes ("大促价," / "仅 ¥30.8" / "¥39.9") instead of one
// "提交订单"-prefixed label. This checks finalSubmitTargets' own choice at the
// SnapshotNode level (fast, no re-find simulation); the full live re-find +
// climb-to-clickable-ancestor path is covered separately below with
// ReFindingDriver, which is what actually caught the task 594 regression.
val base = SpecPanelFixtures.sheet(Sheet(submit = "none"))
val snapshot = base.copy(
nodes = base.nodes + listOf(
node("r/submit", "", NodeBounds(0, 2181, 1080, 2216), clickable = true, parent = "r"),
node("r/submit/l1", "大促价,", NodeBounds(40, 2190, 300, 2210), parent = "r/submit"),
node("r/submit/l2", "仅 ¥30.8", NodeBounds(320, 2190, 600, 2210), parent = "r/submit"),
node("r/submit/l3", "¥39.9", NodeBounds(620, 2190, 780, 2210), parent = "r/submit"),
),
)
val driver = StaticDriver(snapshot)
PurchaseLiveAutomation(driver, pause = {}).submitOrderOnce()
// #335 2nd revision: the target is the labelled leaf (bottom edge tied across
// l1/l2/l3, rightmost wins), NOT the label-less "r/submit" container — a
// label-less target can never be re-found live (task 594).
assertEquals(listOf("¥39.9"), driver.clicked)
}
@Test
fun `task 570 leaf target is re-found live and the click lands on the clickable container`() {
// #335 2nd revision (task 594 real-device failure): StaticDriver's clickFresh just
// records target.label directly, so it cannot catch a target that a REAL driver
// could never re-find. ReFindingDriver instead emulates
// GoAutoAccessibilityService.clickFreshDetailed's real semantics: find the live
// node by (label, className, center within ±32), then climb to the nearest live
// clickable ancestor and click THAT.
val base = SpecPanelFixtures.sheet(Sheet(screenBottom = 2400, submit = "none"))
val snapshot = base.copy(
nodes = base.nodes + listOf(
node("r/sheet/submit", "", NodeBounds(0, 2181, 1080, 2328), clickable = true, parent = "r/sheet"),
node("r/sheet/submit/l1", "大促价,", NodeBounds(364, 2225, 568, 2284), parent = "r/sheet/submit"),
node("r/sheet/submit/l2", "仅 ¥30.8", NodeBounds(568, 2225, 748, 2284), parent = "r/sheet/submit"),
node("r/sheet/submit/l3", "¥39.9", NodeBounds(748, 2222, 896, 2287), parent = "r/sheet/submit"),
),
)
val driver = ReFindingDriver(snapshot)
PurchaseLiveAutomation(driver, pause = {}).submitOrderOnce()
// finalSubmitTargets aims at "r/sheet/submit/l3" ("¥39.9", the bottom-right-most
// labelled leaf); the real click must land on its clickable ancestor, the
// FrameLayout "r/sheet/submit" — not on the leaf itself.
assertEquals("r/sheet/submit", driver.clickedPath)
}
@Test
fun `Samsung 31px bottom bar with only a zero-size label fails explicitly instead of clicking the payment row above it`() {
// #335 (3rd revision): reviewer cross-check on real dumps found that on this
// Samsung sample the bottom-most row is a 31px FrameLayout whose only text node
// is [0,0][0,0] (invisible), while the payment-method row directly above it has a
// real, visible label ("使用#微信支付,更换先用后付可0元下单"). A naive whole-panel
// leaf scan falls through to that payment row's text and the climb lands the click
// on "change payment method" — which must never happen. SpecPanelFixtures'
// `submit = "hidden"` branch already models exactly this shape.
val snapshot = SpecPanelFixtures.sheet(Sheet(submit = "hidden"))
val diagnostics = mutableListOf<String>()
val driver = StaticDriver(snapshot)
val error = runCatching {
PurchaseLiveAutomation(driver, pause = {}, panelDiagnostic = diagnostics::add).submitOrderOnce()
}.exceptionOrNull() as? PurchaseLiveException
assertEquals("PURCHASE_SUBMIT_TARGET_AMBIGUOUS", error?.code)
assertTrue(driver.clicked.isEmpty())
assertTrue(diagnostics.any { it.contains("outcome=bottom_row_unlabelled") })
}
@Test
fun `a payment-method row as the bottom-most clickable row is never a submit target`() {
// Without a submit region at all, the payment-method row ("使用#微信支付,更换先用
// 后付可0元下单") is itself the bottom-most clickable row. It must be rejected by
// its own alias match, not merely skipped in favor of something else.
val snapshot = SpecPanelFixtures.sheet(Sheet(submit = "none"))
val diagnostics = mutableListOf<String>()
val driver = StaticDriver(snapshot)
val error = runCatching {
PurchaseLiveAutomation(driver, pause = {}, panelDiagnostic = diagnostics::add).submitOrderOnce()
}.exceptionOrNull() as? PurchaseLiveException
assertEquals("PURCHASE_SUBMIT_TARGET_AMBIGUOUS", error?.code)
assertTrue(driver.clicked.isEmpty())
assertTrue(diagnostics.any { it.contains("outcome=row_blocked") })
}
@Test
fun `a clickable node outside the recognized panel container is never chosen even if it is bottom most`() {
// A hand-built #331 REQUIRED_EVIDENCE panel ("r/panel", bounded, not full-screen)
// with its own valid submit row, plus an unrelated clickable bar further down the
// same accessibility tree but OUTSIDE "r/panel" — e.g. an underlying goods-detail
// page's "单独购买" bar still present behind the sheet. It is visually and
// structurally lower on screen (bottom=2400 vs the panel submit's bottom=2100),
// so a naive whole-screen "bottom-most clickable node" scan would wrongly pick it.
val t = SpecPanelFixtures.Tree(2400)
t.add("r/panel", "", NodeBounds(0, 300, 1080, 2100), "android.view.ViewGroup")
t.add("r/panel/close", "", NodeBounds(975, 320, 1050, 390), "android.widget.ImageView", clickable = true, description = "关闭")
t.add("r/panel/addr", "", NodeBounds(0, 340, 1080, 460), "android.view.ViewGroup", clickable = true)
t.add("r/panel/addr/phone", "测试,${SpecPanelFixtures.FAKE_PHONE},示例省示例市", NodeBounds(40, 360, 900, 440))
t.add("r/panel/pay", "", NodeBounds(0, 500, 1080, 600), "android.view.ViewGroup", clickable = true)
t.add("r/panel/pay/t", "微信支付", NodeBounds(40, 520, 400, 580))
t.add("r/panel/qty", "1", NodeBounds(400, 650, 600, 720), "android.widget.EditText", clickable = true)
t.add("r/panel/submit", "提交订单", NodeBounds(0, 2020, 1080, 2100), "android.widget.FrameLayout", clickable = true)
// Outside "r/panel": lower on screen, but not part of the recognized panel.
t.add("r/outsideBar", "单独购买", NodeBounds(0, 2200, 1080, 2400), "android.widget.FrameLayout", clickable = true)
val snapshot = t.snapshot()
val screen = PddScreenParser.parse(snapshot, PurchaseRehearsalExecutor.DEFAULT_COLLECTOR, "", null)
assertEquals(SpecPanelType.REQUIRED_EVIDENCE, screen.specPanelType)
val driver = StaticDriver(snapshot)
PurchaseLiveAutomation(driver, pause = {}).submitOrderOnce()
assertEquals(listOf("提交订单"), driver.clicked)
}
@Test
fun `no determinable panel container fails explicitly instead of guessing`() {
// Recognized (NORMAL_SCROLLABLE, via the headed-scrollable + selection-summary
// branch, which does not require a quantity input at all) but with NO quantity
// input anywhere: panelContainerBounds cannot determine a container, so
// finalSubmitTargets must fail explicitly rather than fall back to a whole-screen
// scan that could pick an unrelated bottom-most clickable node.
val recognizedNoQuantity = SpecPanelFixtures.sheet(
Sheet(quantityInput = false, adjustButtons = false, submit = "none"),
)
val screen = PddScreenParser.parse(recognizedNoQuantity, PurchaseRehearsalExecutor.DEFAULT_COLLECTOR, "", null)
assertEquals(SpecPanelType.NORMAL_SCROLLABLE, screen.specPanelType)
val snapshot = recognizedNoQuantity.copy(
nodes = recognizedNoQuantity.nodes + node("r/submit", "提交订单", NodeBounds(0, 2135, 1080, 2216), clickable = true, parent = "r"),
)
val driver = StaticDriver(snapshot)
val error = runCatching { PurchaseLiveAutomation(driver, pause = {}).submitOrderOnce() }.exceptionOrNull() as? PurchaseLiveException
assertEquals("PURCHASE_SUBMIT_TARGET_AMBIGUOUS", error?.code)
assertTrue(driver.clicked.isEmpty())
}
// --- Helpers -------------------------------------------------------------
private fun readySheet(expected: String): UiSnapshot {
// #335 (3rd revision): [SpecPanelFixtures.liveShapedSheet] hardcodes a "hidden"
// 31px submit placeholder (its own separate `submit`-shape fixture concern,
// unrelated to this address-save-wait test). With the row-then-leaf submit-target
// rule that placeholder would tie with "r/final" on the bottom edge and can win
// the rightmost tie-break, so this helper builds the same shape directly with
// `submit = "none"` instead, leaving "r/final" as the only, unambiguous bottom row.
val base = SpecPanelFixtures.sheet(
Sheet(summary = false, listScrollable = false, sizeDimension = false, adjustButtons = false, submit = "none"),
)
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
}
/**
* #335 (2nd revision): emulates `GoAutoAccessibilityService.clickFreshDetailed`'s real
* re-find semantics against a fixed, static "live" node list — match by (own label,
* className, center within ±32 — every fixture node here already carries its own
* label so this is equivalent to `preferredOrDescendantLabel()`), then climb to the
* nearest clickable ancestor and click THAT, exactly like the real accessibility
* service. Exposes the PATH of the node that actually received the click, so a test
* can assert the tap landed on the container, not the labelled leaf `finalSubmitTargets`
* aimed at.
*/
private class ReFindingDriver(private val liveSnapshot: UiSnapshot) : BaseDriver() {
var clickedPath: String? = null
override fun capture() = liveSnapshot
override fun clickFresh(target: SnapshotNode): FreshActionResult {
val byPath = liveSnapshot.nodes.associateBy { it.path }
val matches = liveSnapshot.nodes.filter { candidate ->
candidate.label == target.label && candidate.className == target.className &&
kotlin.math.abs(candidate.bounds.centerX - target.bounds.centerX) <= 32 &&
kotlin.math.abs(candidate.bounds.centerY - target.bounds.centerY) <= 32
}
if (matches.isEmpty()) return FreshActionResult.NOT_FOUND
if (matches.size != 1) return FreshActionResult.AMBIGUOUS
var node = matches.single()
if (target.clickable && !node.clickable) return FreshActionResult.NOT_FOUND
while (!node.clickable) {
node = node.parentPath?.let(byPath::get) ?: return FreshActionResult.FAILED
}
clickedPath = node.path
clicked += node.label
return FreshActionResult.SUCCESS
}
}
/** 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 -9
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: 504e1ffaa2cf1989f55048ac7067d8af50fe3bf3
synchronized_at: 2026-09-22T02:56:22Z
wiki_revision: 8e2cfa74bc7cf228278221e0f7ef488ac1b596d3
synchronized_at: 2026-09-21T08:14:31Z
<!-- gitea-wiki-mirror:end -->
# 架构与代码地图
@@ -513,10 +513,3 @@ Web 唯一展示位置为“采集采购 → SYB 同步记录”:列表状态
- order_writeback.go 的任务资格去掉 order_created 限制;validOrderWritebackTarget 统一有效 stock/detail 边界。OrderWritebackViews 一次批量读取关联而非逐行查询,RequestOrderWriteback 和 worker 执行前再次检查。队列结构、租约、外部写入协议不变。
- web/src/views/goauto/purchase-tasks/index.vue 沿用原详情/弹窗/resolve-unknown API,新增缺字段补录入口、整数分转换、完整性展示及失败保留输入,不增加数据库状态或新的权限资源。旧未知结果请求不带金额继续兼容。
- 回归:server go test -p 1 ./app/goauto/purchase ./app/goauto/clientapi ./app/goauto/sybclient;Web 生产构建及 tests/e2e/purchase-order-writeback.spec.ts 的6项隔离浏览器测试。真实SYB接口用fake/httptest替代,未执行付款、采购或线上写入。
## 规格面板识别回退与选项去重(#331/#332)
- `automation/PddProductDetailCollector.kt`:`SpecPanelType.REQUIRED_EVIDENCE` 回退分支(地址入口/支付入口/唯一数量输入框 + 同一面板容器 + ≥1 辅助证据);`ParsedPddScreen` 增加 `hasAddressEntry`、`hasPaymentEntry`、`hasQuantityInput`、`hasQuantityAdjustControls`、`quantityInputCount` 与 `requiredEvidenceSummary()`;`SpecPanelStabilityPolicy`(等待上限 5000ms,地址返回采样 200ms);规格值按选项块合并(`optionBlock`)。
- `automation/PurchaseRehearsalExecutor.kt`:`waitForSpecPanel` 对回退类型要求连续两次结构一致;`selectionFailureEvidence` 选中失败诊断。
- `automation/PurchaseLiveAutomation.kt`:`waitForSettledPanelAfterAddressSave` 保存地址后稳定等待(不按返回键);`finalSubmitTargets` 拒绝零尺寸文字/容器;新增构造参数 `panelDiagnostic`(仅布尔/计数)。
- 测试:`SpecPanelRecognitionTest`、`SpecOptionDedupTest`、`SpecPanelFixtures`(脱敏构造样本)。
+2 -16
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: 7bb3997cfbe96343f63d4f6ef80201146c27c142
synchronized_at: 2026-09-22T02:56:27Z
wiki_revision: 6cb34c99ccdcf4b64b01cb9a25ff1549d8aeeec9
synchronized_at: 2026-09-21T08:14:37Z
<!-- gitea-wiki-mirror:end -->
# 业务规则与术语
@@ -666,17 +666,3 @@ SYB 回填与资料完整性独立:正式SYB任务已有单号且仍关联有
实现368f2c2,2026-09-19,尚未发布。采购管理现有“状态”下拉框增加“已回填 SYB”,用户选择后点击原查询按钮,按PDD单号回填队列成功记录筛选;不是新的采购任务状态,也不表示物流回填成功或已付款。
筛选后仍展示原采购状态与独立SYB单号回填状态,已成功任务不因查询而允许重复回填。清空或重置恢复原列表,查询回到第一页,其他条件继续叠加。沿用既有下拉框、空/加载/失败状态及权限,不新增页面、审批、数据字段或外部动作。
## 规格面板识别与选项去重(#331/#332)
实现 `0edc78a`(#332)、`d8382e6`(#331),2026-09-22 已合并 main 并发布 Server(Android 0.9.60 仅安装到设备 8);真机采购效果未验证。
- 规格面板识别在原有判定之后新增回退类型 `REQUIRED_EVIDENCE`:**必须**同时存在收货地址入口(打码手机号所在可点击行)、支付方式修改入口(支付别名所在可点击行)和唯一可用的数量输入框,三者须位于同一规格面板容器内;另需至少一项辅助证据(规格选项、已选/请选择摘要、关闭、加减数量、提交订单)。加减按钮不再是必备条件。原有已识别的面板分类不变。
- 地址入口只判断存在与否,不读取、不记录、不上传地址内容;诊断只输出布尔值与计数。
- 点击规格入口后、保存地址返回后,若面板仅靠回退类型识别,须连续两次采样结构一致才继续,上限 5 秒;等待期间不按返回键,超时明确失败。稳定且明显不是采购面板的页面(如地址列表)沿用原返回路径。
- 提交订单点击保持严格并进一步收紧:必须唯一、可点击、文字匹配,且文字节点及其可点击容器尺寸非零。
- 规格选项解析:同一选项块(外层可点击块与其内部文字)只计一个值,文字优先取外层块;不做“零差评/快要抢光”等标签清洗。选中确认失败时诊断列出该维度解析值(截断)、选中状态和目标值。
- 缺少必备证据时,失败信息附带可读提示,例如“未找到支付入口:请确认 PDD 默认支付方式为微信支付”“未找到收货地址入口:请确认 PDD 已设置默认收货地址”“未找到购买数量输入框”。
### 采购手机环境前提
执行采购的 Android 手机必须:PDD App 已登录、微信已登录、PDD 已设置默认收货地址、PDD 默认支付方式为微信支付。规格面板的地址与支付证据依赖该前提;不为支付宝等其他支付方式补充识别别名(有意决定)。条件不满足时 Agent 明确失败,不自动登录、不切换支付方式、不填写地址。
+2 -13
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: cc642ebfa65f429100779e40560a59174ab877a0
synchronized_at: 2026-09-22T02:56:36Z
wiki_revision: 03ea269058b50fea2be7842b9c284018987c82d9
synchronized_at: 2026-09-21T08:14:47Z
<!-- gitea-wiki-mirror:end -->
# 部署与运维
@@ -142,14 +142,3 @@ Provider 故障日志只允许记录调用关联 ID、操作类型、耗时、
- 管理员为密钥显式勾选“采购管理 → 回填(writeback)”。建议 HTTPS;若使用项目既有客户端密钥 HTTP 例外,密钥和订单事实会明文传输,风险边界不扩大到 Cookie、JWT 或 Device Token。
- popup 关闭不停止运行;重新打开可查看持久状态。设置在运行时只读。停止会保留已确认批次并阻止后续请求;重启或超时仅以原批次 UUID 重放未确认批次。
- 排错只查看阶段、已读、可提交、已写入、冲突、未完整扫描原因、脱敏错误码和批次状态;不得记录客户端密钥、地址全文、订单号、Cookie、原始 HTML 或真实订单载荷。没有明确列表末尾证据时只能报告未完整扫描。
## 采购手机准备清单(#331)
采购员配置或更换采购用 Android 手机时逐项确认:
1. 拼多多 App 已登录;
2. 微信已登录;
3. 拼多多已设置默认收货地址;
4. 拼多多默认支付方式为微信支付;
5. GoAuto Agent 已启动,无障碍服务已开启,Admin 设备列表显示在线并已归属对应采购员账号。
任一项不满足时采购会明确失败并提示缺失项(如“未找到支付入口:请确认 PDD 默认支付方式为微信支付”);Agent 不代为登录、切换支付方式或填写地址。安装新版 Agent 后需手动启动一次 App,心跳恢复后设备才显示在线。
-2
View File
@@ -21,7 +21,6 @@ import (
goautosybproductfilter "go-admin/app/goauto/sybproductfilter"
goautosybshop "go-admin/app/goauto/sybshop"
goautotask "go-admin/app/goauto/task"
goautoyeeke "go-admin/app/goauto/yeeke"
common "go-admin/common/middleware"
)
@@ -70,5 +69,4 @@ func InitRouter() {
goautosybinnercode.InitRouter(r, authMiddleware)
goautosybshop.InitRouter(r, authMiddleware)
goautosybproductfilter.InitRouter(r, authMiddleware)
goautoyeeke.InitRouter(r, authMiddleware)
}
-12
View File
@@ -16,8 +16,6 @@ const (
ModuleCollectionTasks = "collection_tasks"
ModulePurchaseTasks = "purchase_tasks"
ModuleAIMatching = "ai_matching"
ModuleYeekeReturns = "yeeke_returns"
ModuleYeekeSyncRuns = "yeeke_sync_runs"
)
// ModuleDefinition is the single source of truth shared by menu migration,
@@ -69,8 +67,6 @@ var goAutoMenuGroupMetadata = []MenuGroupDefinition{
ModulePDDProducts,
ModuleCollectionTasks,
ModulePurchaseTasks,
ModuleYeekeReturns,
ModuleYeekeSyncRuns,
},
},
{
@@ -105,8 +101,6 @@ var goAutoModuleMetadata = []ModuleDefinition{
{Key: ModuleCollectionTasks, Title: "采集任务", Path: "/collection-tasks", RouteName: "GoAutoCollectionTasks", Component: "/goauto/collection-tasks/index", Icon: "list", Sort: 60, PurchaserDefault: true},
{Key: ModulePurchaseTasks, Title: "采购管理", Path: "/purchase-tasks", RouteName: "GoAutoPurchaseTasks", Component: "/goauto/purchase-tasks/index", Icon: "shopping", Sort: 61, PurchaserDefault: true},
{Key: ModuleAIMatching, Title: "AI 规格匹配", Path: "/ai-matching-settings", RouteName: "GoAutoAiMatchingSettings", Component: "/goauto/ai-matching-settings/index", Icon: "setting", Sort: 62, PurchaserHardHidden: true},
{Key: ModuleYeekeReturns, Title: "yeeke 退货包裹", Path: "/yeeke-returns", RouteName: "GoAutoYeekeReturns", Component: "/goauto/yeeke-returns/index", Icon: "goods", Sort: 63, PurchaserDefault: true},
{Key: ModuleYeekeSyncRuns, Title: "yeeke 同步记录", Path: "/yeeke-sync-runs", RouteName: "GoAutoYeekeSyncRuns", Component: "/goauto/yeeke-sync-runs/index", Icon: "time", Sort: 64, PurchaserDefault: true},
}
// GoAutoModules returns independent copies so callers cannot mutate the
@@ -167,12 +161,6 @@ func moduleKeyForAPI(path string) string {
return ModulePurchaseTasks
case strings.HasPrefix(path, "/api/admin/v1/ai-matching-settings"):
return ModuleAIMatching
case strings.HasPrefix(path, "/api/admin/v1/yeeke-returns/sync-runs"):
return ModuleYeekeSyncRuns
case strings.HasPrefix(path, "/api/admin/v1/yeeke-returns/sync"):
return ModuleYeekeSyncRuns
case strings.HasPrefix(path, "/api/admin/v1/yeeke-returns"):
return ModuleYeekeReturns
default:
return ""
}
+5 -29
View File
@@ -10,8 +10,8 @@ import (
func TestGoAutoModulesOwnEveryAdminAPIExactlyOnce(t *testing.T) {
modules := GoAutoModules()
if len(modules) != 15 {
t.Fatalf("got %d modules, want 15", len(modules))
if len(modules) != 13 {
t.Fatalf("got %d modules, want 13", len(modules))
}
owners := make(map[string]int)
@@ -31,30 +31,6 @@ func TestGoAutoModulesOwnEveryAdminAPIExactlyOnce(t *testing.T) {
}
}
func TestYeekeSyncRunsModuleIsRegisteredAndPurchaserVisible(t *testing.T) {
module, ok := ModuleByRouteName("GoAutoYeekeSyncRuns")
if !ok {
t.Fatal("yeeke sync-runs module not found")
}
if module.Key != ModuleYeekeSyncRuns {
t.Fatalf("unexpected key: %s", module.Key)
}
if module.Path != "/yeeke-sync-runs" || module.Component != "/goauto/yeeke-sync-runs/index" {
t.Fatalf("unexpected route wiring: %#v", module)
}
if !module.PurchaserDefault {
t.Fatal("yeeke sync-runs module must be purchaser-visible per #337")
}
if len(module.APIs) == 0 {
t.Fatal("yeeke sync-runs module must own its APIs")
}
for _, permission := range module.APIs {
if permission.Path == "/api/admin/v1/yeeke-returns" && permission.Method == "GET" {
t.Fatal("the return-package list API belongs to yeeke_returns, not yeeke_sync_runs")
}
}
}
func TestAIMatchingIsHardHiddenFromPurchaser(t *testing.T) {
module, ok := ModuleByRouteName("GoAutoAiMatchingSettings")
if !ok {
@@ -80,8 +56,8 @@ func TestGoAutoMenuSortsFitMySQLSignedTinyInt(t *testing.T) {
}
previous = module.Sort
}
if modules[0].Sort != 50 || modules[len(modules)-1].Sort != 64 {
t.Fatalf("GoAuto menu sort range = %d..%d, want 50..64", modules[0].Sort, modules[len(modules)-1].Sort)
if modules[0].Sort != 50 || modules[len(modules)-1].Sort != 62 {
t.Fatalf("GoAuto menu sort range = %d..%d, want 50..62", modules[0].Sort, modules[len(modules)-1].Sort)
}
}
@@ -95,7 +71,7 @@ func TestGoAutoMenuGroupsCoverModulesExactlyOnce(t *testing.T) {
}
wantOrder := [][]string{
{ModuleSYBProducts, ModuleSYBSyncRuns, ModuleSYBInnerCodes, ModuleShopeeProducts, ModulePDDProducts, ModuleCollectionTasks, ModulePurchaseTasks, ModuleYeekeReturns, ModuleYeekeSyncRuns},
{ModuleSYBProducts, ModuleSYBSyncRuns, ModuleSYBInnerCodes, ModuleShopeeProducts, ModulePDDProducts, ModuleCollectionTasks, ModulePurchaseTasks},
{ModuleSYBShops, ModuleSYBProductFilters, ModuleCollectionRules, ModulePurchaseRules, ModuleDevices, ModuleAIMatching},
}
seen := make(map[string]int)
-8
View File
@@ -124,14 +124,6 @@ var AdminAPIs = []APIPermission{
{"取消采购任务", "/api/admin/v1/purchase-tasks/:taskId/cancel", "POST", true},
{"处理结果不明确任务", "/api/admin/v1/purchase-tasks/:taskId/resolve-unknown", "POST", true},
{"查看 yeeke 退货包裹列表", "/api/admin/v1/yeeke-returns", "GET", true},
{"查看 yeeke 退货店铺列表", "/api/admin/v1/yeeke-returns/shops", "GET", true},
{"查看 yeeke 退货商品详情", "/api/admin/v1/yeeke-returns/items/:itemId", "GET", true},
{"查看 yeeke 退货包裹详情", "/api/admin/v1/yeeke-returns/:packageId", "GET", true},
{"查看 yeeke 同步记录", "/api/admin/v1/yeeke-returns/sync-runs", "GET", true},
{"查看 yeeke 同步详情", "/api/admin/v1/yeeke-returns/sync-runs/:runId", "GET", true},
{"手动触发 yeeke 同步", "/api/admin/v1/yeeke-returns/sync", "POST", true},
{"查看 AI 匹配状态", "/api/admin/v1/ai-matching-settings", "GET", true},
{"保存 AI 匹配设置", "/api/admin/v1/ai-matching-settings", "PUT", false},
{"测试 AI 服务连接", "/api/admin/v1/ai-matching-settings/test", "POST", false},
+1 -1
View File
@@ -33,7 +33,7 @@ func fixture(t *testing.T) (*gorm.DB, clientkey.Service) {
func TestEveryRouteIsExplicitlyScoped(t *testing.T) {
db, s := fixture(t)
routes := Inventory()
if len(s.Modules) != 15 {
if len(s.Modules) != 13 {
t.Fatal("menu groups lost")
}
router := gin.New()
-4
View File
@@ -44,10 +44,6 @@ func MigratedModels() []any {
&models.SYBShop{},
&models.SYBProductFilter{},
&models.SYBSyncRun{},
&models.YeekeSession{},
&models.YeekeReturnPackage{},
&models.YeekeReturnItem{},
&models.YeekeSyncRun{},
&models.SYBInnerCodeRecord{},
&models.SYBInnerCodeItem{},
&models.SYBInnerCodeApplyBatch{},
-87
View File
@@ -1,87 +0,0 @@
package models
import "time"
// YeekeSession stores only the opaque session material; credentials are kept
// outside the application database and supplied by the administrator at run time.
type YeekeSession struct {
ID uint64 `gorm:"primaryKey;autoIncrement"`
Username string `gorm:"size:128;not null;uniqueIndex:ux_yeeke_session_username"`
Token string `json:"-" gorm:"type:text;not null"`
CookiesJSON string `json:"-" gorm:"type:text;not null"`
UserID string `gorm:"size:128;not null;default:''"`
ExpiresAt time.Time `gorm:"not null;index"`
CreatedAt time.Time
UpdatedAt time.Time
}
func (YeekeSession) TableName() string { return "yeeke_session" }
type YeekeReturnPackage struct {
ID uint64 `gorm:"primaryKey;autoIncrement"`
ExternalID string `gorm:"size:128;not null;uniqueIndex:ux_yeeke_return_package_external"`
OrderSN string `gorm:"size:128;not null;index"`
TrackingNo string `gorm:"size:128;not null;index"`
ShopID string `gorm:"size:128;not null;default:''"`
ShopName string `gorm:"size:255;not null;default:''"`
WareCode string `gorm:"size:128;not null;default:''"`
WareHouse string `gorm:"size:255;not null;default:''"`
WareName string `gorm:"size:255;not null;default:''"`
ClaimStatus string `gorm:"size:64;not null;default:''"`
// StatusUnrecognized is set when ClaimStatus is not one of the values the
// sync code currently understands. It is never bucketed into a known
// status silently (#336): the raw value is still kept in ClaimStatus, and
// this flag lets an operator find and review these rows.
StatusUnrecognized bool `gorm:"not null;default:false;index"`
ClaimTime *time.Time
CreateTime *time.Time
UpdateTime *time.Time
DestroyDeadLine *time.Time
LastSyncedAt time.Time `gorm:"not null;index"`
SyncStatus string `gorm:"size:32;not null;default:'ok'"`
CreatedAt time.Time
UpdatedAt time.Time
}
func (YeekeReturnPackage) TableName() string { return "yeeke_return_package" }
type YeekeReturnItem struct {
ID uint64 `gorm:"primaryKey;autoIncrement"`
PackageID uint64 `gorm:"not null;uniqueIndex:ux_yeeke_return_item_key,priority:1;index"`
ExternalKey string `gorm:"size:512;not null;uniqueIndex:ux_yeeke_return_item_key,priority:2"`
ItemID string `gorm:"size:128;not null;index"`
VariationID string `gorm:"size:128;not null;default:''"`
ItemName string `gorm:"size:500;not null;default:''"`
VariationName string `gorm:"size:500;not null;default:''"`
Image string `gorm:"type:text;not null"`
Quantity int64 `gorm:"not null;default:0"`
LastSyncedAt time.Time `gorm:"not null;index"`
SyncStatus string `gorm:"size:32;not null;default:'ok'"`
CreatedAt time.Time
UpdatedAt time.Time
}
func (YeekeReturnItem) TableName() string { return "yeeke_return_item" }
type YeekeSyncRun struct {
ID uint64 `gorm:"primaryKey;autoIncrement"`
Status string `gorm:"size:32;not null;index"`
Trigger string `gorm:"size:32;not null;index"`
TotalPages int `gorm:"not null;default:0"`
ReadCount int `gorm:"not null;default:0"`
CreatedCount int `gorm:"not null;default:0"`
UpdatedCount int `gorm:"not null;default:0"`
SkippedCount int `gorm:"not null;default:0"`
FailedCount int `gorm:"not null;default:0"`
ErrorMessage string `gorm:"size:1000;not null;default:''"`
StartedAt time.Time `gorm:"not null"`
FinishedAt *time.Time
LastSuccessAt *time.Time
ActiveSlot *uint8 `gorm:"uniqueIndex:ux_yeeke_sync_run_active_slot"`
LeaseOwner string `gorm:"size:128;not null;default:''"`
LeaseExpiresAt *time.Time
CreatedAt time.Time
UpdatedAt time.Time
}
func (YeekeSyncRun) TableName() string { return "yeeke_sync_run" }
+2 -15
View File
@@ -647,25 +647,12 @@ func (s *Service) resolveProbedSpecs(ctx context.Context, taskID uint64, attempt
request := aimatching.MatchRequest{TargetColor: task.TargetColorSnapshot, TargetSize: task.TargetSizeSnapshot, Colors: candidates.Colors, Sizes: candidates.Sizes}
decision := SpecDecisionRequest{RequestID: uuid.NewString(), TaskAttemptID: attemptID, Source: aimatching.SourceAI}
if !complete {
// #334: a probe that read zero colors and zero sizes (both dimensions
// empty) is a distinct, more specific failure than "candidates present
// but none matched the target" — it usually means the spec panel was
// over-swiped off the device screen. The Agent now fails this case
// explicitly before submitting, but resolveProbedSpecs keeps the same
// explicit code as a defense in depth for any spec_probe_completed
// result that still arrives empty (e.g. older Agent builds).
reason := "规格探测结果没有包含所需的可选颜色或尺码"
code, message := "PURCHASE_SPEC_NOT_MATCHED", "没有找到可采购的 PDD 颜色或尺码"
if len(candidates.Colors) == 0 && len(candidates.Sizes) == 0 {
reason = "规格探测未读取到任何颜色或尺码"
code, message = "PURCHASE_SPEC_PROBE_EMPTY", "规格探测未读取到任何颜色或尺码"
}
snapshot, marshalErr := json.Marshal(aimatching.NoMatchDecision(request, aimatching.SourceAI, reason))
snapshot, marshalErr := json.Marshal(aimatching.NoMatchDecision(request, aimatching.SourceAI, "规格探测结果没有包含所需的可选颜色或尺码"))
if marshalErr != nil {
return TaskPayload{}, internal(marshalErr)
}
decision.NoMatch, decision.Decision = true, snapshot
decision.FailureCode, decision.FailureMessage = code, message
decision.FailureCode, decision.FailureMessage = "PURCHASE_SPEC_NOT_MATCHED", "没有找到可采购的 PDD 颜色或尺码"
} else {
matched, matchErr := s.resolveProbedMatch(ctx, task, request)
valid := matchErr == nil && (matched.Source == "manual_mapping" || matched.Source == aimatching.SourceExact || matched.Source == aimatching.SourceAI) &&
@@ -592,47 +592,6 @@ func TestLiveProbeUsesAccurateMessageWhenCompleteCandidatesCannotBeMatched(t *te
}
}
// #334: a probe with zero colors and zero sizes is a distinct, more specific
// failure ("规格探测未读取到任何颜色或尺码") than the generic "candidates
// present but none matched" message, so operators can tell an over-swiped
// device probe apart from an ordinary spec mismatch.
func TestLiveProbeWithNoDimensionsFailsWithExplicitEmptyProbeCode(t *testing.T) {
db := testDB(t)
f := seed(t, db, liveCaps(), false)
if err := db.Model(&models.SYBProduct{}).Where("id = ?", f.syb.ID).Update("target_color", "象牙白").Error; err != nil {
t.Fatal(err)
}
matcher := &liveProbeMatcher{err: errors.New("AI should not be called for an empty probe")}
s := testService(db)
s.Matcher = matcher
task, err := createLive(t, s, f)
if err != nil {
t.Fatal(err)
}
if _, err = s.Claim(context.Background(), task.ID, ActionRequest{RequestID: uuid.NewString()}, f.token); err != nil {
t.Fatal(err)
}
first, err := s.Start(context.Background(), task.ID, ActionRequest{RequestID: uuid.NewString()}, f.token)
if err != nil {
t.Fatal(err)
}
probe := ResultRequest{RequestID: uuid.NewString(), TaskAttemptID: first.TaskAttemptID, ResultType: "spec_probe_completed", ProbedSpecs: []byte(`{"dimensions":[]}`)}
resolved, err := s.SubmitResult(context.Background(), task.ID, probe, f.token)
if err != nil || matcher.calls != 0 || resolved.Status != models.PurchaseTaskStatusFailed {
t.Fatalf("empty probe did not fail closed without calling AI: %+v calls=%d err=%v", resolved, matcher.calls, err)
}
var saved models.PurchaseTask
if err = db.First(&saved, task.ID).Error; err != nil {
t.Fatal(err)
}
if saved.ErrorCode == nil || *saved.ErrorCode != "PURCHASE_SPEC_PROBE_EMPTY" {
t.Fatalf("empty probe did not use the explicit empty-probe code: %+v", saved)
}
if saved.ErrorMessage == nil || *saved.ErrorMessage != "规格探测未读取到任何颜色或尺码" {
t.Fatalf("empty probe did not use the explicit empty-probe message: %+v", saved)
}
}
func TestSecondSpecProbeFailsClosedWithoutClearingDecision(t *testing.T) {
db := testDB(t)
f := seed(t, db, liveCaps(), false)
-209
View File
@@ -1,209 +0,0 @@
package yeeke
import (
"errors"
"net/http"
"strconv"
"strings"
"time"
"go-admin/app/goauto/models"
"github.com/gin-gonic/gin"
"github.com/go-admin-team/go-admin-core/sdk/api"
"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"
)
// Handler exposes the yeeke read-only admin surface: manual sync trigger and
// sync run history/summary. It never returns credentials, tokens, captcha
// text or a full raw yeeke response — only the counters already stored on
// models.YeekeSyncRun (#336 requirement #7).
type Handler struct {
// DB lets tests inject a database directly; production requests resolve
// it from the gin context via pkg.GetOrm, same as sybimport.Handler.
DB *gorm.DB
}
func (h Handler) db(c *gin.Context) (*gorm.DB, bool) {
db := h.DB
var err error
if db == nil {
db, err = pkg.GetOrm(c)
}
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"code": "INTERNAL", "message": "服务端处理失败"})
return nil, false
}
return db, true
}
// SyncRunDTO is the read-only shape returned to admin/purchaser. It embeds
// only the summary fields already computed by the sync run itself; it never
// carries yeeke_session (token/cookies) or a raw page response.
type SyncRunDTO struct {
ID uint64 `json:"id"`
Status string `json:"status"`
Trigger string `json:"trigger"`
TotalPages int `json:"totalPages"`
ReadCount int `json:"readCount"`
CreatedCount int `json:"createdCount"`
UpdatedCount int `json:"updatedCount"`
SkippedCount int `json:"skippedCount"`
FailedCount int `json:"failedCount"`
ErrorMessage string `json:"errorMessage"`
StartedAt string `json:"startedAt"`
FinishedAt *string `json:"finishedAt"`
LastSuccessAt *string `json:"lastSuccessAt"`
}
func toDTO(r models.YeekeSyncRun) SyncRunDTO {
dto := SyncRunDTO{
ID: r.ID, Status: r.Status, Trigger: r.Trigger, TotalPages: r.TotalPages,
ReadCount: r.ReadCount, CreatedCount: r.CreatedCount, UpdatedCount: r.UpdatedCount,
SkippedCount: r.SkippedCount, FailedCount: r.FailedCount, ErrorMessage: r.ErrorMessage,
StartedAt: r.StartedAt.UTC().Format("2006-01-02T15:04:05Z"),
}
if r.FinishedAt != nil {
s := r.FinishedAt.UTC().Format("2006-01-02T15:04:05Z")
dto.FinishedAt = &s
}
if r.LastSuccessAt != nil {
s := r.LastSuccessAt.UTC().Format("2006-01-02T15:04:05Z")
dto.LastSuccessAt = &s
}
return dto
}
// ListSyncRuns returns the most recent sync runs, newest first, optionally
// filtered by trigger, status and started-at range (#337 backward-compatible
// extension: all filters are optional query params, existing callers with no
// filters keep the #336 behavior). Visible to admin and purchaser alike
// (#336 requirement #7); it is mounted without Casbin role gating, mirroring
// sybimport's /sync-runs.
func (h Handler) ListSyncRuns(c *gin.Context) {
page, err := strconv.Atoi(c.DefaultQuery("page", "1"))
if err != nil || page < 1 {
page = 1
}
pageSize, err := strconv.Atoi(c.DefaultQuery("pageSize", "20"))
if err != nil || pageSize < 1 || pageSize > 100 {
pageSize = 20
}
db, ok := h.db(c)
if !ok {
return
}
query := db.Model(&models.YeekeSyncRun{})
if v := strings.TrimSpace(c.Query("trigger")); v != "" {
query = query.Where("trigger = ?", v)
}
if v := strings.TrimSpace(c.Query("status")); v != "" {
query = query.Where("status = ?", v)
}
if v := strings.TrimSpace(c.Query("startedFrom")); v != "" {
t, perr := parseFilterTime(v)
if perr != nil {
c.JSON(http.StatusBadRequest, gin.H{"code": "INVALID_REQUEST", "message": "startedFrom 格式无效"})
return
}
query = query.Where("started_at >= ?", t)
}
if v := strings.TrimSpace(c.Query("startedTo")); v != "" {
t, perr := parseFilterTime(v)
if perr != nil {
c.JSON(http.StatusBadRequest, gin.H{"code": "INVALID_REQUEST", "message": "startedTo 格式无效"})
return
}
query = query.Where("started_at <= ?", t)
}
var total int64
if err := query.Count(&total).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"code": "INTERNAL", "message": "服务端处理失败"})
return
}
var rows []models.YeekeSyncRun
if err := query.Order("id desc").Offset((page - 1) * pageSize).Limit(pageSize).Find(&rows).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"code": "INTERNAL", "message": "服务端处理失败"})
return
}
items := make([]SyncRunDTO, 0, len(rows))
for _, row := range rows {
items = append(items, toDTO(row))
}
var last models.YeekeSyncRun
lastSuccessAt := ""
if err := db.Where("status = ?", "succeeded").Order("id desc").First(&last).Error; err == nil && last.LastSuccessAt != nil {
lastSuccessAt = last.LastSuccessAt.UTC().Format("2006-01-02T15:04:05Z")
}
c.JSON(http.StatusOK, gin.H{"code": 200, "data": gin.H{
"items": items, "total": total, "page": page, "pageSize": pageSize,
"lastSuccessAt": lastSuccessAt,
}})
}
// SyncRunDetail returns one run's summary.
func (h Handler) SyncRunDetail(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("runId"), 10, 64)
if err != nil || id == 0 {
c.JSON(http.StatusBadRequest, gin.H{"code": "INVALID_REQUEST", "message": "runId 无效"})
return
}
db, ok := h.db(c)
if !ok {
return
}
var row models.YeekeSyncRun
if err := db.First(&row, id).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
c.JSON(http.StatusNotFound, gin.H{"code": "NOT_FOUND", "message": "同步记录不存在"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"code": "INTERNAL", "message": "服务端处理失败"})
return
}
c.JSON(http.StatusOK, gin.H{"code": 200, "data": gin.H{"item": toDTO(row)}})
}
// TriggerSync starts a manual sync run. Only admin and purchaser may call it,
// same as SYB's manual Import (sybimport.Handler.Import) — this mirrors that
// role check exactly.
func (h Handler) TriggerSync(c *gin.Context) {
role, _ := jwt.ExtractClaims(c)["rolekey"].(string)
if role != "admin" && role != "purchaser" {
c.JSON(http.StatusForbidden, gin.H{"code": "FORBIDDEN", "message": "只有管理员或采购员可以开始同步"})
return
}
db, ok := h.db(c)
if !ok {
return
}
result, err := StartSync(c.Request.Context(), db, nil, "manual", false)
if err != nil {
if errors.Is(err, ErrAlreadyRunning) {
c.JSON(http.StatusConflict, gin.H{"code": "ALREADY_RUNNING", "message": err.Error()})
return
}
// `[必须]` err here is only ever a config/connect-stage message built in
// start.go/yeekeclient — never a raw yeeke response, never a token or
// credential. api.GetRequestLogger keeps the same text out of the HTTP
// body while still recording it server-side for operators.
api.GetRequestLogger(c).Errorf("yeeke manual sync failed to start: %v", err)
c.JSON(http.StatusBadGateway, gin.H{"code": "SYNC_START_FAILED", "message": err.Error()})
return
}
c.JSON(http.StatusAccepted, gin.H{"code": 200, "data": gin.H{"runId": result.RunID, "skipped": result.Skipped}})
}
// parseFilterTime accepts either a full RFC3339-ish "2006-01-02T15:04:05Z" or
// a plain "2006-01-02" date, matching the query-string shapes used by both
// the return-package and sync-run filters.
func parseFilterTime(v string) (time.Time, error) {
if t, err := time.Parse("2006-01-02T15:04:05Z", v); err == nil {
return t, nil
}
return time.Parse("2006-01-02", v)
}
-35
View File
@@ -1,35 +0,0 @@
package yeeke
import (
"context"
"errors"
"gorm.io/gorm"
)
// ReturnSyncInvokeTarget is the go-admin job invoke_target key for the
// scheduled yeeke return sync (#336). The job row itself is seeded disabled
// (Status: 2) by migrations/version-local; an admin turns it on explicitly.
const ReturnSyncInvokeTarget = "GoAutoYeekeReturnSync"
// ReturnSyncJob is registered in go-admin's ExecJob map (app/jobs/examples.go).
// ExecWithDB is the production path; Exec exists only to satisfy the legacy
// jobs.JobExec interface and fails closed if an older caller forgets to
// provide the current database.
type ReturnSyncJob struct{}
func (ReturnSyncJob) Exec(_ interface{}) error {
return errors.New("yeeke 定时同步缺少数据库连接")
}
// ExecWithDB starts a sync sharing the same StartSync entry point, and hence
// the same syncGate/active_slot lease, as the manual admin trigger — a
// scheduled tick that lands while a manual run (or a previous tick) is still
// in progress is skipped rather than queued or run concurrently.
func (ReturnSyncJob) ExecWithDB(db *gorm.DB, _ interface{}) error {
_, err := StartSync(context.Background(), db, nil, "scheduled", true)
if errors.Is(err, ErrAlreadyRunning) {
return nil
}
return err
}
-368
View File
@@ -1,368 +0,0 @@
package yeeke
import (
"errors"
"net/http"
"sort"
"strconv"
"strings"
"time"
"go-admin/app/goauto/models"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
// ReturnItemDTO is the read-only shape of one item inside a return package.
// It never carries anything beyond what #336's sync already stores, and the
// image field is always the external yeeke URL (never downloaded/proxied,
// per #337 non-goal).
type ReturnItemDTO struct {
ID uint64 `json:"id"`
ItemID string `json:"itemId"`
VariationID string `json:"variationId"`
ItemName string `json:"itemName"`
VariationName string `json:"variationName"`
Image string `json:"image"`
Quantity int64 `json:"quantity"`
}
// ReturnPackageDTO is the read-only shape of one return package returned to
// admin/purchaser. StatusUnrecognized is surfaced verbatim (#337 #4 / #336):
// an unknown ClaimStatus is never bucketed into a known value.
type ReturnPackageDTO struct {
ID uint64 `json:"id"`
OrderSN string `json:"orderSn"`
TrackingNo string `json:"trackingNo"`
ShopID string `json:"shopId"`
ShopName string `json:"shopName"`
WareCode string `json:"wareCode"`
WareHouse string `json:"wareHouse"`
WareName string `json:"wareName"`
ClaimStatus string `json:"claimStatus"`
StatusUnrecognized bool `json:"statusUnrecognized"`
ItemCount int `json:"itemCount"`
ClaimTime *string `json:"claimTime"`
CreateTime *string `json:"createTime"`
UpdateTime *string `json:"updateTime"`
DestroyDeadLine *string `json:"destroyDeadLine"`
LastSyncedAt string `json:"lastSyncedAt"`
SyncStatus string `json:"syncStatus"`
Items []ReturnItemDTO `json:"items,omitempty"`
}
// ReturnItemRowDTO is one row of the #337 item-level admin list: one row per
// yeeke_return_item, joined with its parent package. A package with no items
// still produces exactly one placeholder row (HasItem=false, item fields
// empty/zero) so it is never silently dropped from the list.
type ReturnItemRowDTO struct {
Key string `json:"key"`
PackageID uint64 `json:"packageId"`
ItemID uint64 `json:"itemId"`
OrderSN string `json:"orderSn"`
Image string `json:"image"`
ItemName string `json:"itemName"`
VariationName string `json:"variationName"`
ShopeeItemID string `json:"shopeeItemId"`
VariationID string `json:"variationId"`
Quantity int64 `json:"quantity"`
ShopName string `json:"shopName"`
WareHouse string `json:"wareHouse"`
WareCode string `json:"wareCode"`
ClaimStatus string `json:"claimStatus"`
StatusUnrecognized bool `json:"statusUnrecognized"`
ClaimTime *string `json:"claimTime"`
DestroyDeadLine *string `json:"destroyDeadLine"`
LastSyncedAt string `json:"lastSyncedAt"`
HasItem bool `json:"hasItem"`
}
func formatTimePtr(t *time.Time) *string {
if t == nil {
return nil
}
s := t.UTC().Format("2006-01-02T15:04:05Z")
return &s
}
func toPackageDTO(p models.YeekeReturnPackage, itemCount int) ReturnPackageDTO {
return ReturnPackageDTO{
ID: p.ID, OrderSN: p.OrderSN, TrackingNo: p.TrackingNo, ShopID: p.ShopID, ShopName: p.ShopName,
WareCode: p.WareCode, WareHouse: p.WareHouse, WareName: p.WareName, ClaimStatus: p.ClaimStatus,
StatusUnrecognized: p.StatusUnrecognized, ItemCount: itemCount,
ClaimTime: formatTimePtr(p.ClaimTime), CreateTime: formatTimePtr(p.CreateTime),
UpdateTime: formatTimePtr(p.UpdateTime), DestroyDeadLine: formatTimePtr(p.DestroyDeadLine),
LastSyncedAt: p.LastSyncedAt.UTC().Format("2006-01-02T15:04:05Z"), SyncStatus: p.SyncStatus,
}
}
func toItemDTO(i models.YeekeReturnItem) ReturnItemDTO {
return ReturnItemDTO{
ID: i.ID, ItemID: i.ItemID, VariationID: i.VariationID, ItemName: i.ItemName,
VariationName: i.VariationName, Image: i.Image, Quantity: i.Quantity,
}
}
// itemRow mirrors one row of the LEFT JOIN query in ListReturnItemRows: every
// package column plus every (nullable) item column.
type itemRow struct {
PackageID uint64
OrderSN string
ShopName string
WareHouse string
WareCode string
ClaimStatus string
StatusUnrecognized bool
ClaimTime *time.Time
DestroyDeadLine *time.Time
PackageLastSynced time.Time
ItemID *uint64
ItemExternalItem *string
ItemVariationID *string
ItemName *string
ItemVariationName *string
ItemImage *string
ItemQuantity *int64
ItemLastSynced *time.Time
}
func toItemRowDTO(r itemRow) ReturnItemRowDTO {
dto := ReturnItemRowDTO{
PackageID: r.PackageID, OrderSN: r.OrderSN, ShopName: r.ShopName,
WareHouse: r.WareHouse, WareCode: r.WareCode, ClaimStatus: r.ClaimStatus,
StatusUnrecognized: r.StatusUnrecognized,
ClaimTime: formatTimePtr(r.ClaimTime),
DestroyDeadLine: formatTimePtr(r.DestroyDeadLine),
LastSyncedAt: r.PackageLastSynced.UTC().Format("2006-01-02T15:04:05Z"),
}
if r.ItemID == nil || *r.ItemID == 0 {
dto.Key = "p" + strconv.FormatUint(r.PackageID, 10)
dto.HasItem = false
return dto
}
dto.Key = strconv.FormatUint(*r.ItemID, 10)
dto.ItemID = *r.ItemID
dto.HasItem = true
if r.ItemExternalItem != nil {
dto.ShopeeItemID = *r.ItemExternalItem
}
if r.ItemVariationID != nil {
dto.VariationID = *r.ItemVariationID
}
if r.ItemName != nil {
dto.ItemName = *r.ItemName
}
if r.ItemVariationName != nil {
dto.VariationName = *r.ItemVariationName
}
if r.ItemImage != nil {
dto.Image = *r.ItemImage
}
if r.ItemQuantity != nil {
dto.Quantity = *r.ItemQuantity
}
if r.ItemLastSynced != nil {
dto.LastSyncedAt = r.ItemLastSynced.UTC().Format("2006-01-02T15:04:05Z")
}
return dto
}
// ListReturnPackages returns a paginated, filtered list of return packages.
// Filters: order sn, tracking no, shop (id or name, partial match), itemId /
// variationId (matched against yeeke_return_item, requires a join), claim
// status (including the literal "unknown" pseudo-value for
// StatusUnrecognized rows), and createTime range. Visible to admin and
// purchaser alike, same gating as ListSyncRuns.
func (h Handler) ListReturnPackages(c *gin.Context) {
page, err := strconv.Atoi(c.DefaultQuery("page", "1"))
if err != nil || page < 1 {
page = 1
}
pageSize, err := strconv.Atoi(c.DefaultQuery("pageSize", "20"))
if err != nil || pageSize < 1 || pageSize > 100 {
pageSize = 20
}
db, ok := h.db(c)
if !ok {
return
}
base := db.Table("yeeke_return_package p").
Joins("LEFT JOIN yeeke_return_item i ON i.package_id = p.id")
if v := strings.TrimSpace(c.Query("orderSn")); v != "" {
base = base.Where("p.order_sn LIKE ?", "%"+v+"%")
}
if v := strings.TrimSpace(c.Query("trackingNo")); v != "" {
base = base.Where("p.tracking_no LIKE ?", "%"+v+"%")
}
// #337 #1: 店铺 filter switched from free-text partial match to an exact
// match against p.shop_name, matching the dropdown surfaced by /shops.
if v := strings.TrimSpace(c.Query("shop")); v != "" {
base = base.Where("p.shop_name = ?", v)
}
claimStatus := strings.TrimSpace(c.Query("claimStatus"))
switch claimStatus {
case "":
// no filter
case "unknown":
base = base.Where("p.status_unrecognized = ?", true)
default:
base = base.Where("p.claim_status = ? AND p.status_unrecognized = ?", claimStatus, false)
}
if v := strings.TrimSpace(c.Query("createTimeFrom")); v != "" {
t, perr := parseFilterTime(v)
if perr != nil {
c.JSON(http.StatusBadRequest, gin.H{"code": "INVALID_REQUEST", "message": "createTimeFrom 格式无效"})
return
}
base = base.Where("p.create_time >= ?", t)
}
if v := strings.TrimSpace(c.Query("createTimeTo")); v != "" {
t, perr := parseFilterTime(v)
if perr != nil {
c.JSON(http.StatusBadRequest, gin.H{"code": "INVALID_REQUEST", "message": "createTimeTo 格式无效"})
return
}
base = base.Where("p.create_time <= ?", t)
}
// itemId/variationId kept for backward compatibility (#337): now filter
// the item-level join directly instead of a package sub-select.
if v := strings.TrimSpace(c.Query("itemId")); v != "" {
base = base.Where("i.item_id = ?", v)
}
if v := strings.TrimSpace(c.Query("variationId")); v != "" {
base = base.Where("i.variation_id = ?", v)
}
var total int64
if err := base.Session(&gorm.Session{}).Count(&total).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"code": "INTERNAL", "message": "服务端处理失败"})
return
}
var rows []itemRow
q := base.Session(&gorm.Session{}).Select(
"p.id as package_id, p.order_sn, p.shop_name, p.ware_house, p.ware_code, p.claim_status, " +
"p.status_unrecognized, p.claim_time, p.destroy_dead_line as destroy_dead_line, p.last_synced_at as package_last_synced, " +
"i.id as item_id, i.item_id as item_external_item, i.variation_id as item_variation_id, i.item_name as item_name, " +
"i.variation_name as item_variation_name, i.image as item_image, i.quantity as item_quantity, i.last_synced_at as item_last_synced",
).Order("p.create_time desc, i.id asc").
Offset((page - 1) * pageSize).Limit(pageSize)
if err := q.Find(&rows).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"code": "INTERNAL", "message": "服务端处理失败"})
return
}
items := make([]ReturnItemRowDTO, 0, len(rows))
for _, r := range rows {
items = append(items, toItemRowDTO(r))
}
c.JSON(http.StatusOK, gin.H{"code": 200, "data": gin.H{
"items": items, "total": total, "page": page, "pageSize": pageSize,
}})
}
// ListReturnShops returns the distinct, non-empty shop names known from
// yeeke_return_package, sorted ascending, for the #337 shop-name dropdown
// filter. It must be registered before the /:packageId wildcard route so it
// is never swallowed by it.
func (h Handler) ListReturnShops(c *gin.Context) {
db, ok := h.db(c)
if !ok {
return
}
var shops []string
if err := db.Model(&models.YeekeReturnPackage{}).
Where("shop_name <> ''").
Distinct("shop_name").
Order("shop_name asc").
Pluck("shop_name", &shops).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"code": "INTERNAL", "message": "服务端处理失败"})
return
}
sort.Strings(shops)
c.JSON(http.StatusOK, gin.H{"code": 200, "data": gin.H{"shops": shops}})
}
// ReturnItemDetail returns one item, its parent package, and the sibling
// items of the same package (excluding itself), for the #337 "退货商品详情"
// drawer. It must be registered before the /:packageId wildcard route so
// /yeeke-returns/items/:itemId is never swallowed by it.
func (h Handler) ReturnItemDetail(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("itemId"), 10, 64)
if err != nil || id == 0 {
c.JSON(http.StatusBadRequest, gin.H{"code": "INVALID_REQUEST", "message": "itemId 无效"})
return
}
db, ok := h.db(c)
if !ok {
return
}
var item models.YeekeReturnItem
if err := db.First(&item, id).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
c.JSON(http.StatusNotFound, gin.H{"code": "NOT_FOUND", "message": "退货商品不存在"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"code": "INTERNAL", "message": "服务端处理失败"})
return
}
var pkg models.YeekeReturnPackage
if err := db.First(&pkg, item.PackageID).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
c.JSON(http.StatusNotFound, gin.H{"code": "NOT_FOUND", "message": "所属退货包裹不存在"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"code": "INTERNAL", "message": "服务端处理失败"})
return
}
var siblingRows []models.YeekeReturnItem
if err := db.Where("package_id = ? AND id <> ?", item.PackageID, item.ID).Order("id asc").Find(&siblingRows).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"code": "INTERNAL", "message": "服务端处理失败"})
return
}
siblings := make([]ReturnItemDTO, 0, len(siblingRows))
for _, s := range siblingRows {
siblings = append(siblings, toItemDTO(s))
}
pkgDTO := toPackageDTO(pkg, len(siblingRows)+1)
c.JSON(http.StatusOK, gin.H{"code": 200, "data": gin.H{
"item": toItemDTO(item), "package": pkgDTO, "siblings": siblings,
}})
}
// ReturnPackageDetail returns one package with its items.
func (h Handler) ReturnPackageDetail(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("packageId"), 10, 64)
if err != nil || id == 0 {
c.JSON(http.StatusBadRequest, gin.H{"code": "INVALID_REQUEST", "message": "packageId 无效"})
return
}
db, ok := h.db(c)
if !ok {
return
}
var row models.YeekeReturnPackage
if err := db.First(&row, id).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
c.JSON(http.StatusNotFound, gin.H{"code": "NOT_FOUND", "message": "退货包裹不存在"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"code": "INTERNAL", "message": "服务端处理失败"})
return
}
var itemRows []models.YeekeReturnItem
if err := db.Where("package_id = ?", row.ID).Order("id asc").Find(&itemRows).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"code": "INTERNAL", "message": "服务端处理失败"})
return
}
dto := toPackageDTO(row, len(itemRows))
dto.Items = make([]ReturnItemDTO, 0, len(itemRows))
for _, i := range itemRows {
dto.Items = append(dto.Items, toItemDTO(i))
}
c.JSON(http.StatusOK, gin.H{"code": 200, "data": gin.H{"item": dto}})
}
-439
View File
@@ -1,439 +0,0 @@
package yeeke
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"go-admin/app/goauto/models"
"github.com/gin-gonic/gin"
)
func setupListTestDB(t *testing.T) (*gin.Engine, func(req *http.Request) *httptest.ResponseRecorder) {
t.Helper()
gin.SetMode(gin.TestMode)
db := testDB(t)
now := time.Date(2026, 9, 20, 10, 0, 0, 0, time.UTC)
older := now.Add(-48 * time.Hour)
if err := db.Create(&models.YeekeReturnPackage{
ExternalID: "p1", OrderSN: "ORDER-1", TrackingNo: "TRACK-1", ShopID: "s1", ShopName: "店铺一",
ClaimStatus: "claimed", StatusUnrecognized: false, CreateTime: &now, LastSyncedAt: now, SyncStatus: "ok",
}).Error; err != nil {
t.Fatal(err)
}
if err := db.Create(&models.YeekeReturnPackage{
ExternalID: "p2", OrderSN: "ORDER-2", TrackingNo: "TRACK-2", ShopID: "s2", ShopName: "店铺二",
ClaimStatus: "weird_status", StatusUnrecognized: true, CreateTime: &older, LastSyncedAt: older, SyncStatus: "ok",
}).Error; err != nil {
t.Fatal(err)
}
// p3 has no items at all: must still produce exactly one placeholder row.
if err := db.Create(&models.YeekeReturnPackage{
ExternalID: "p3", OrderSN: "ORDER-3", TrackingNo: "TRACK-3", ShopID: "s3", ShopName: "店铺三",
ClaimStatus: "pending", StatusUnrecognized: false, CreateTime: &older, LastSyncedAt: older, SyncStatus: "ok",
}).Error; err != nil {
t.Fatal(err)
}
var pkg1, pkg2 models.YeekeReturnPackage
db.Where("external_id = ?", "p1").First(&pkg1)
db.Where("external_id = ?", "p2").First(&pkg2)
if err := db.Create(&models.YeekeReturnItem{
PackageID: pkg1.ID, ExternalKey: "k1", ItemID: "item1", VariationID: "v1", ItemName: "商品1", VariationName: "红色",
Image: "https://cdn.example.com/a.jpg", Quantity: 2, LastSyncedAt: now, SyncStatus: "ok",
}).Error; err != nil {
t.Fatal(err)
}
if err := db.Create(&models.YeekeReturnItem{
PackageID: pkg1.ID, ExternalKey: "k2", ItemID: "item1", VariationID: "v2", ItemName: "商品1", VariationName: "蓝色",
Image: "https://cdn.example.com/b.jpg", Quantity: 1, LastSyncedAt: now, SyncStatus: "ok",
}).Error; err != nil {
t.Fatal(err)
}
if err := db.Create(&models.YeekeReturnItem{
PackageID: pkg2.ID, ExternalKey: "k3", ItemID: "item2", VariationID: "v3", ItemName: "商品2", VariationName: "默认",
Image: "https://cdn.example.com/c.jpg", Quantity: 3, LastSyncedAt: older, SyncStatus: "ok",
}).Error; err != nil {
t.Fatal(err)
}
if err := db.Create(&models.YeekeSyncRun{
Status: "succeeded", Trigger: "manual", StartedAt: now,
}).Error; err != nil {
t.Fatal(err)
}
if err := db.Create(&models.YeekeSyncRun{
Status: "failed", Trigger: "scheduled", StartedAt: older,
}).Error; err != nil {
t.Fatal(err)
}
handler := Handler{DB: db}
engine := gin.New()
engine.GET("/api/admin/v1/yeeke-returns", handler.ListReturnPackages)
engine.GET("/api/admin/v1/yeeke-returns/shops", handler.ListReturnShops)
engine.GET("/api/admin/v1/yeeke-returns/items/:itemId", handler.ReturnItemDetail)
engine.GET("/api/admin/v1/yeeke-returns/:packageId", handler.ReturnPackageDetail)
engine.GET("/api/admin/v1/yeeke-returns/sync-runs", handler.ListSyncRuns)
do := func(req *http.Request) *httptest.ResponseRecorder {
w := httptest.NewRecorder()
engine.ServeHTTP(w, req)
return w
}
return engine, do
}
type itemRowListResp struct {
Data struct {
Items []ReturnItemRowDTO `json:"items"`
Total int64 `json:"total"`
} `json:"data"`
}
func TestListReturnItemsFiltersByOrderSn(t *testing.T) {
_, do := setupListTestDB(t)
w := do(httptest.NewRequest(http.MethodGet, "/api/admin/v1/yeeke-returns?orderSn=ORDER-1", nil))
if w.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", w.Code, w.Body.String())
}
var resp itemRowListResp
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatal(err)
}
if resp.Data.Total != 2 || len(resp.Data.Items) != 2 {
t.Fatalf("expected 2 item rows for a 2-item package, got: %+v", resp.Data)
}
for _, row := range resp.Data.Items {
if row.OrderSN != "ORDER-1" || !row.HasItem {
t.Fatalf("unexpected row: %+v", row)
}
}
}
func TestListReturnItemsPlaceholderRowForItemlessPackage(t *testing.T) {
_, do := setupListTestDB(t)
w := do(httptest.NewRequest(http.MethodGet, "/api/admin/v1/yeeke-returns?orderSn=ORDER-3", nil))
var resp itemRowListResp
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatal(err)
}
if resp.Data.Total != 1 || len(resp.Data.Items) != 1 {
t.Fatalf("expected exactly one placeholder row, got: %+v", resp.Data)
}
row := resp.Data.Items[0]
if row.HasItem {
t.Fatalf("expected hasItem=false for item-less package, got: %+v", row)
}
if row.ItemID != 0 || row.ItemName != "" {
t.Fatalf("expected empty/zero item fields on placeholder row, got: %+v", row)
}
}
func TestListReturnItemsPaginationAndCountOverItemRows(t *testing.T) {
_, do := setupListTestDB(t)
// total rows across all packages: 2 (p1) + 1 (p2) + 1 placeholder (p3) = 4
w := do(httptest.NewRequest(http.MethodGet, "/api/admin/v1/yeeke-returns?pageSize=2&page=1", nil))
var resp itemRowListResp
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatal(err)
}
if resp.Data.Total != 4 {
t.Fatalf("expected total=4 item-level rows, got %d", resp.Data.Total)
}
if len(resp.Data.Items) != 2 {
t.Fatalf("expected page size 2 applied to item-level rows, got %d", len(resp.Data.Items))
}
}
func TestListReturnItemsShopFilterIsExactMatch(t *testing.T) {
_, do := setupListTestDB(t)
w := do(httptest.NewRequest(http.MethodGet, "/api/admin/v1/yeeke-returns?shop=%E5%BA%97", nil)) // partial "店" must not match
var resp itemRowListResp
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatal(err)
}
if resp.Data.Total != 0 {
t.Fatalf("expected no matches for a partial shop name (exact match required), got %d", resp.Data.Total)
}
w2 := do(httptest.NewRequest(http.MethodGet, "/api/admin/v1/yeeke-returns?shop=%E5%BA%97%E9%93%BA%E4%B8%80", nil)) // "店铺一"
var resp2 itemRowListResp
if err := json.Unmarshal(w2.Body.Bytes(), &resp2); err != nil {
t.Fatal(err)
}
if resp2.Data.Total != 2 {
t.Fatalf("expected exact shop match to find package p1's 2 item rows, got %d", resp2.Data.Total)
}
}
func TestListReturnItemsUnknownClaimStatusFilter(t *testing.T) {
_, do := setupListTestDB(t)
w := do(httptest.NewRequest(http.MethodGet, "/api/admin/v1/yeeke-returns?claimStatus=unknown", nil))
var resp itemRowListResp
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatal(err)
}
if resp.Data.Total != 1 || resp.Data.Items[0].OrderSN != "ORDER-2" {
t.Fatalf("unexpected result: %+v", resp.Data)
}
if !resp.Data.Items[0].StatusUnrecognized {
t.Fatal("expected StatusUnrecognized=true, unknown status must be shown verbatim, not bucketed")
}
if resp.Data.Items[0].ClaimStatus != "weird_status" {
t.Fatalf("unknown claim status must be shown verbatim, got %q", resp.Data.Items[0].ClaimStatus)
}
}
func TestListReturnItemsFiltersByItemAndVariationBackwardCompat(t *testing.T) {
_, do := setupListTestDB(t)
w := do(httptest.NewRequest(http.MethodGet, "/api/admin/v1/yeeke-returns?itemId=item1&variationId=v2", nil))
var resp itemRowListResp
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatal(err)
}
if resp.Data.Total != 1 || resp.Data.Items[0].OrderSN != "ORDER-1" || resp.Data.Items[0].VariationID != "v2" {
t.Fatalf("unexpected result: %+v", resp.Data)
}
}
func TestListReturnItemsFiltersByCreateTimeRange(t *testing.T) {
_, do := setupListTestDB(t)
w := do(httptest.NewRequest(http.MethodGet, "/api/admin/v1/yeeke-returns?createTimeFrom=2026-09-19&createTimeTo=2026-09-21", nil))
var resp itemRowListResp
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatal(err)
}
if resp.Data.Total != 2 || resp.Data.Items[0].OrderSN != "ORDER-1" {
t.Fatalf("unexpected result: %+v", resp.Data)
}
}
func TestListReturnShopsReturnsDistinctSortedNames(t *testing.T) {
_, do := setupListTestDB(t)
w := do(httptest.NewRequest(http.MethodGet, "/api/admin/v1/yeeke-returns/shops", nil))
if w.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", w.Code, w.Body.String())
}
var resp struct {
Data struct {
Shops []string `json:"shops"`
} `json:"data"`
}
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatal(err)
}
want := []string{"店铺一", "店铺三", "店铺二"}
if len(resp.Data.Shops) != len(want) {
t.Fatalf("expected %d distinct shops, got %+v", len(want), resp.Data.Shops)
}
for i := range want {
if resp.Data.Shops[i] != want[i] {
t.Fatalf("expected sorted shops %+v, got %+v", want, resp.Data.Shops)
}
}
}
func TestReturnItemDetailReturnsSiblingsExcludingSelf(t *testing.T) {
_, do := setupListTestDB(t)
listW := do(httptest.NewRequest(http.MethodGet, "/api/admin/v1/yeeke-returns?orderSn=ORDER-1", nil))
var listResp itemRowListResp
if err := json.Unmarshal(listW.Body.Bytes(), &listResp); err != nil {
t.Fatal(err)
}
if len(listResp.Data.Items) != 2 {
t.Fatalf("expected 2 item rows to pick from, got %+v", listResp.Data.Items)
}
target := listResp.Data.Items[0]
w := do(httptest.NewRequest(http.MethodGet, "/api/admin/v1/yeeke-returns/items/"+itoa(target.ItemID), nil))
if w.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", w.Code, w.Body.String())
}
var resp struct {
Data struct {
Item ReturnItemDTO `json:"item"`
Package ReturnPackageDTO `json:"package"`
Siblings []ReturnItemDTO `json:"siblings"`
} `json:"data"`
}
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatal(err)
}
if resp.Data.Item.ID != target.ItemID {
t.Fatalf("expected item id %d, got %d", target.ItemID, resp.Data.Item.ID)
}
if resp.Data.Package.OrderSN != "ORDER-1" {
t.Fatalf("expected parent package ORDER-1, got %+v", resp.Data.Package)
}
if len(resp.Data.Siblings) != 1 {
t.Fatalf("expected exactly 1 sibling (the other item in the same package), got %+v", resp.Data.Siblings)
}
if resp.Data.Siblings[0].ID == resp.Data.Item.ID {
t.Fatal("siblings must exclude the current item")
}
}
func TestReturnItemDetailNotFound(t *testing.T) {
_, do := setupListTestDB(t)
w := do(httptest.NewRequest(http.MethodGet, "/api/admin/v1/yeeke-returns/items/999999", nil))
if w.Code != http.StatusNotFound {
t.Fatalf("status=%d body=%s", w.Code, w.Body.String())
}
}
func TestYeekeReturnsRoutesDoNotCollide(t *testing.T) {
// Routing test proving /yeeke-returns/shops and /yeeke-returns/items/:id
// are not swallowed by the /yeeke-returns/:packageId wildcard route.
_, do := setupListTestDB(t)
w := do(httptest.NewRequest(http.MethodGet, "/api/admin/v1/yeeke-returns/shops", nil))
if w.Code != http.StatusOK {
t.Fatalf("/shops should hit ListReturnShops, got status=%d body=%s", w.Code, w.Body.String())
}
var shopsResp struct {
Data struct {
Shops []string `json:"shops"`
} `json:"data"`
}
if err := json.Unmarshal(w.Body.Bytes(), &shopsResp); err != nil {
t.Fatal(err)
}
if len(shopsResp.Data.Shops) == 0 {
t.Fatal("/shops must hit ListReturnShops, not ReturnPackageDetail (which would 400/404 on a non-numeric id)")
}
listW := do(httptest.NewRequest(http.MethodGet, "/api/admin/v1/yeeke-returns?orderSn=ORDER-1", nil))
var listResp itemRowListResp
if err := json.Unmarshal(listW.Body.Bytes(), &listResp); err != nil {
t.Fatal(err)
}
itemID := listResp.Data.Items[0].ItemID
w2 := do(httptest.NewRequest(http.MethodGet, "/api/admin/v1/yeeke-returns/items/"+itoa(itemID), nil))
if w2.Code != http.StatusOK {
t.Fatalf("/items/:itemId should hit ReturnItemDetail, got status=%d body=%s", w2.Code, w2.Body.String())
}
var itemResp struct {
Data struct {
Item ReturnItemDTO `json:"item"`
} `json:"data"`
}
if err := json.Unmarshal(w2.Body.Bytes(), &itemResp); err != nil {
t.Fatal(err)
}
if itemResp.Data.Item.ID != itemID {
t.Fatalf("/items/:itemId must hit ReturnItemDetail, got item=%+v", itemResp.Data.Item)
}
// The plain numeric package-id route must still work unchanged.
w3 := do(httptest.NewRequest(http.MethodGet, "/api/admin/v1/yeeke-returns/999999", nil))
if w3.Code != http.StatusNotFound {
t.Fatalf("/:packageId route must still work for a numeric id, got status=%d", w3.Code)
}
}
func TestReturnPackageDetailIncludesSeparateItemRows(t *testing.T) {
_, do := setupListTestDB(t)
listW := do(httptest.NewRequest(http.MethodGet, "/api/admin/v1/yeeke-returns?orderSn=ORDER-1", nil))
var listResp itemRowListResp
if err := json.Unmarshal(listW.Body.Bytes(), &listResp); err != nil {
t.Fatal(err)
}
id := listResp.Data.Items[0].PackageID
w := do(httptest.NewRequest(http.MethodGet, "/api/admin/v1/yeeke-returns/"+itoa(id), nil))
if w.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", w.Code, w.Body.String())
}
var resp struct {
Data struct {
Item ReturnPackageDTO `json:"item"`
} `json:"data"`
}
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatal(err)
}
if len(resp.Data.Item.Items) != 2 {
t.Fatalf("expected 2 separate item rows for multiple variations, got %d", len(resp.Data.Item.Items))
}
variations := map[string]bool{}
for _, item := range resp.Data.Item.Items {
variations[item.VariationID] = true
}
if !variations["v1"] || !variations["v2"] {
t.Fatalf("expected both variations present as separate rows: %+v", resp.Data.Item.Items)
}
}
func TestReturnPackageDetailNotFound(t *testing.T) {
_, do := setupListTestDB(t)
w := do(httptest.NewRequest(http.MethodGet, "/api/admin/v1/yeeke-returns/999999", nil))
if w.Code != http.StatusNotFound {
t.Fatalf("status=%d body=%s", w.Code, w.Body.String())
}
}
func TestListSyncRunsFiltersByTriggerStatusAndStartedRange(t *testing.T) {
_, do := setupListTestDB(t)
w := do(httptest.NewRequest(http.MethodGet, "/api/admin/v1/yeeke-returns/sync-runs?trigger=manual&status=succeeded", nil))
var resp struct {
Data struct {
Items []SyncRunDTO `json:"items"`
Total int64 `json:"total"`
} `json:"data"`
}
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatal(err)
}
if resp.Data.Total != 1 || resp.Data.Items[0].Trigger != "manual" || resp.Data.Items[0].Status != "succeeded" {
t.Fatalf("unexpected result: %+v", resp.Data)
}
w2 := do(httptest.NewRequest(http.MethodGet, "/api/admin/v1/yeeke-returns/sync-runs?startedFrom=2026-09-19&startedTo=2026-09-21", nil))
var resp2 struct {
Data struct {
Items []SyncRunDTO `json:"items"`
Total int64 `json:"total"`
} `json:"data"`
}
if err := json.Unmarshal(w2.Body.Bytes(), &resp2); err != nil {
t.Fatal(err)
}
if resp2.Data.Total != 1 || resp2.Data.Items[0].Trigger != "manual" {
t.Fatalf("unexpected started-range result: %+v", resp2.Data)
}
}
func TestListSyncRunsWithNoFiltersStaysBackwardCompatible(t *testing.T) {
_, do := setupListTestDB(t)
w := do(httptest.NewRequest(http.MethodGet, "/api/admin/v1/yeeke-returns/sync-runs", nil))
var resp struct {
Data struct {
Items []SyncRunDTO `json:"items"`
Total int64 `json:"total"`
} `json:"data"`
}
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatal(err)
}
if resp.Data.Total != 2 {
t.Fatalf("expected both runs with no filters, got total=%d", resp.Data.Total)
}
}
func itoa(v uint64) string {
if v == 0 {
return "0"
}
digits := []byte{}
for v > 0 {
digits = append([]byte{byte('0' + v%10)}, digits...)
v /= 10
}
return string(digits)
}
-29
View File
@@ -1,29 +0,0 @@
package yeeke
import (
"github.com/gin-gonic/gin"
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
)
// InitRouter mounts the yeeke read-only admin surface (#336): manual sync
// trigger plus sync run history/summary. Every route here only reads from or
// writes to GoAuto's own database and, for the trigger, starts a read-only
// yeeke sync — no yeeke write endpoint is ever called.
//
// `[必须]` These routes are authenticated but intentionally not Casbin-gated
// (middleware.AuthCheckRole), same as sybimport's /sync-runs: #336 requires
// both admin and purchaser to see the summary, and TriggerSync does its own
// admin/purchaser role check inline (mirroring sybimport.Handler.Import).
func InitRouter(engine *gin.Engine, auth *jwt.GinJWTMiddleware) {
handler := Handler{}
group := engine.Group("/api/admin/v1/yeeke-returns").Use(auth.MiddlewareFunc())
group.GET("", handler.ListReturnPackages)
// #337: static-prefixed routes must be registered before the
// /:packageId wildcard route below so they are never swallowed by it.
group.GET("/shops", handler.ListReturnShops)
group.GET("/items/:itemId", handler.ReturnItemDetail)
group.GET("/:packageId", handler.ReturnPackageDetail)
group.GET("/sync-runs", handler.ListSyncRuns)
group.GET("/sync-runs/:runId", handler.SyncRunDetail)
group.POST("/sync", handler.TriggerSync)
}
-110
View File
@@ -1,110 +0,0 @@
package yeeke
import (
"context"
"errors"
"fmt"
"strings"
"sync"
"time"
"go-admin/app/goauto/sybclient"
"go-admin/app/goauto/yeekeclient"
"go-admin/config"
"gorm.io/gorm"
)
// syncTimeout bounds one sync run. It exists so a stalled yeeke response
// cannot pin a background goroutine forever.
const syncTimeout = 30 * time.Minute
// syncGate makes sync runs mutually exclusive within this process, same
// reasoning as sybimport.importGate: manual trigger and the scheduled job
// must never run concurrently (#336 requirement #5). The database-level
// unique active_slot column on yeeke_sync_run is the durable backstop (it
// also covers the unlikely case of two processes sharing one database); this
// in-memory gate exists to fail fast with a clear message before even
// touching the network or the OCR service.
var syncGate = struct {
sync.Mutex
running bool
}{}
// ErrAlreadyRunning is returned by StartSync when a sync is already in
// progress, whether it was started manually or by the scheduled job.
var ErrAlreadyRunning = errors.New("已有 yeeke 退货包裹同步正在执行,请等它结束后再试")
// OCR is satisfied by sybclient.OcrClient; yeeke reuses the same self-hosted
// captcha OCR service already approved for SYB (#336).
type OCR = yeekeclient.OCR
// StartResult reports what StartSync did without exposing any run internals
// that should not cross the HTTP boundary.
type StartResult struct {
RunID uint64
Skipped bool
}
// StartSync is the single entry point shared by the authenticated admin
// handler and the scheduled job, so a scheduled run can never bypass the same
// concurrency guard as a manual run.
//
// `[必须]` This only ever reads from yeeke. Credentials are resolved by the
// caller (config.ExtConfig.Yeeke) and never logged here.
func StartSync(ctx context.Context, db *gorm.DB, ocr OCR, trigger string, skipIfRunning bool) (StartResult, error) {
settings := config.ExtConfig.Yeeke.Resolved()
if !settings.HasCredentials() {
return StartResult{}, fmt.Errorf("yeeke 账号未配置:请设置 GOAUTO_YEEKE_USERNAME / GOAUTO_YEEKE_PASSWORD,或在 config.yaml 的 yeeke 段填写 username/password,然后重启服务端")
}
if ocr == nil {
if strings.TrimSpace(settings.OcrURL) == "" {
return StartResult{}, fmt.Errorf("yeeke 验证码识别服务未配置:请设置 extend.yeeke.ocrurl")
}
// Reuse SYB's OcrClient implementation as-is (#336): independent
// http.Client, no shared cookie jar, captcha bytes stay in memory. See
// sybclient/ocr.go's package comment for why that isolation matters.
client, err := sybclient.NewOcrClient(settings.OcrURL, 0)
if err != nil {
return StartResult{}, err
}
ocr = client
}
syncGate.Lock()
if syncGate.running {
syncGate.Unlock()
if skipIfRunning {
return StartResult{Skipped: true}, nil
}
return StartResult{}, ErrAlreadyRunning
}
syncGate.running = true
syncGate.Unlock()
release := func() {
syncGate.Lock()
syncGate.running = false
syncGate.Unlock()
}
client, err := yeekeclient.Connect(ctx, yeekeclient.NewSessionStore(db), yeekeclient.Credentials{
Username: settings.Username, Password: settings.Password,
}, settings.BaseURL, ocr, settings.OcrMaxAttempts)
if err != nil {
release()
return StartResult{}, err
}
svc := NewService(db, client, Config{PageSize: settings.PageSize, MaxPages: settings.MaxPages, Retry: settings.Retry})
// SyncAsync acquires the DB lease synchronously (so the caller gets a run
// id right away and the active_slot lease is held before this function
// returns) then walks pages in the background, bound to its own timeout
// independent of the HTTP request context. syncGate is released once that
// background walk finishes, not when this function returns.
runID, err := svc.SyncAsync(ctx, trigger, release)
if err != nil {
release()
return StartResult{}, err
}
return StartResult{RunID: runID}, nil
}
-342
View File
@@ -1,342 +0,0 @@
package yeeke
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"go-admin/app/goauto/models"
"go-admin/app/goauto/yeekeclient"
"gorm.io/gorm"
"strconv"
"strings"
"time"
)
type Config struct {
PageSize, MaxPages, Retry int
Lease time.Duration
}
func (c Config) norm() Config {
if c.PageSize <= 0 || c.PageSize > 500 {
c.PageSize = 100
}
if c.MaxPages <= 0 || c.MaxPages > 10000 {
c.MaxPages = 10000
}
if c.Retry < 0 || c.Retry > 5 {
c.Retry = 2
}
if c.Lease <= 0 {
c.Lease = 30 * time.Minute
}
return c
}
type Report struct {
RunID uint64
TotalPages, Read, Created, Updated, Skipped, Failed int
Status string
}
// knownClaimStatuses lists the status values the sync code currently
// understands. The list surface (POST .../relation/list) is queried with
// status=1, so "1" is the only value observed in practice; anything else is
// flagged rather than silently accepted or rejected (#336).
var knownClaimStatuses = map[string]bool{"1": true}
func external(v any) string { return fmt.Sprint(v) }
func stamp(t *yeekeclient.Timestamp) *time.Time {
if t == nil || t.IsZero() {
return nil
}
x := t.Time
return &x
}
func packageKey(p yeekeclient.ReturnPackage) string {
if x := external(p.ID); x != "<nil>" && x != "" {
return x
}
return p.Ordersn + "/" + p.TrackingNo + "/" + external(p.ShopID) + "/" + p.CreateTime.String()
}
// itemKey builds the stable per-item identity used to upsert
// models.YeekeReturnItem without duplicating rows across syncs. It prefers
// the yeeke-issued identifiers (i.ID, then i.ItemID/i.VariationID) because
// those stay the same regardless of the order the API returns items in
// within a package; the positional index n is only used as a last resort
// when none of those identifiers are present, since in that case the index
// is the sole thing distinguishing items of the same package (#336).
func itemKey(p yeekeclient.ReturnPackage, i yeekeclient.ReturnItem, n int) string {
id, itemID, variationID := external(i.ID), external(i.ItemID), external(i.VariationID)
if isEmptyExternal(id) && isEmptyExternal(itemID) && isEmptyExternal(variationID) {
return packageKey(p) + "/" + id + "/" + itemID + "/" + variationID + "/" + strconv.Itoa(n)
}
return packageKey(p) + "/" + id + "/" + itemID + "/" + variationID
}
// isEmptyExternal reports whether external() produced a value that carries
// no real identity: either the field was unset (formatted as "<nil>" by
// fmt.Sprint on a nil/zero value) or it was an explicit empty string.
func isEmptyExternal(v string) bool { return v == "" || v == "<nil>" }
// takeoverStaleLease reclaims a run whose lease has expired, e.g. because the
// process crashed or was restarted mid-sync. It matches the
// lease-with-expiry-takeover idiom used by
// app/goauto/purchase/order_writeback_worker.go: a single conditional UPDATE
// guarded by "status = running AND lease_expires_at <= now" flips the stale
// row to a terminal status and frees active_slot in one statement, so it is
// atomic without a separate row lock. The stale row is never deleted — it is
// left in place with status "failed" and an error_message explaining why, so
// history stays auditable. If two callers race this same UPDATE, only the
// first to reach the database actually changes any row; the second's WHERE
// clause no longer matches (status is no longer "running") and it affects
// zero rows, which is a harmless no-op. Whichever caller then wins the
// subsequent Create (see acquire) is arbitrated by the ux_yeeke_sync_run_active_slot
// unique index, exactly as it already is for two brand-new concurrent runs.
func (s *Service) takeoverStaleLease(ctx context.Context) error {
now := time.Now().UTC()
return s.db.WithContext(ctx).Model(&models.YeekeSyncRun{}).
Where("status = ? AND active_slot = ? AND lease_expires_at IS NOT NULL AND lease_expires_at <= ?", "running", 1, now).
Updates(map[string]any{
"status": "failed",
"active_slot": nil,
"lease_owner": "",
"error_message": "lease expired: run interrupted, likely a process restart mid-sync (stale lease takeover)",
"lease_expires_at": nil,
"finished_at": now,
}).Error
}
func (s *Service) acquire(ctx context.Context, trigger string) (*models.YeekeSyncRun, error) {
if e := s.takeoverStaleLease(ctx); e != nil {
return nil, e
}
now := time.Now().UTC()
owner := fmt.Sprintf("%d", now.UnixNano())
slot := uint8(1)
exp := now.Add(s.cfg.Lease)
r := &models.YeekeSyncRun{Status: "running", Trigger: trigger, StartedAt: now, ActiveSlot: &slot, LeaseOwner: owner, LeaseExpiresAt: &exp}
if e := s.db.WithContext(ctx).Create(r).Error; e != nil {
return nil, e
}
return r, nil
}
type Service struct {
db *gorm.DB
client *yeekeclient.Client
cfg Config
}
func NewService(db *gorm.DB, c *yeekeclient.Client, cfg Config) *Service {
return &Service{db: db, client: c, cfg: cfg.norm()}
}
// Sync acquires the shared lease, runs the page walk synchronously and
// returns the final report. Tests use this directly; StartSync (start.go)
// uses SyncAsync instead so an HTTP request does not block for the whole
// run.
func (s *Service) Sync(ctx context.Context, trigger string) (Report, error) {
r, e := s.acquire(ctx, trigger)
if e != nil {
return Report{}, e
}
return s.run(ctx, r)
}
// SyncAsync acquires the lease synchronously (so the caller gets a run id
// immediately, and the unique active_slot lease is held before returning)
// and continues the page walk in a background goroutine bound to its own
// timeout, independent of the caller's request context.
// onDone, when non-nil, runs after the background page walk finishes
// (success or failure) — StartSync uses it to release the in-memory
// concurrency gate at the right time instead of when this function returns.
func (s *Service) SyncAsync(ctx context.Context, trigger string, onDone func()) (uint64, error) {
r, e := s.acquire(ctx, trigger)
if e != nil {
return 0, e
}
go func() {
bg, cancel := context.WithTimeout(context.Background(), syncTimeout)
defer cancel()
_, _ = s.run(bg, r)
if onDone != nil {
onDone()
}
}()
return r.ID, nil
}
func (s *Service) run(ctx context.Context, r *models.YeekeSyncRun) (Report, error) {
rep := Report{RunID: r.ID, Status: "failed"}
var errMsg string
var runErr error
defer func() {
now := time.Now().UTC()
updates := map[string]any{"status": rep.Status, "total_pages": rep.TotalPages, "read_count": rep.Read, "created_count": rep.Created, "updated_count": rep.Updated, "skipped_count": rep.Skipped, "failed_count": rep.Failed, "error_message": errMsg, "active_slot": nil, "lease_owner": "", "lease_expires_at": nil, "finished_at": now}
if rep.Status == "succeeded" {
updates["last_success_at"] = now
}
s.db.Model(r).Updates(updates)
}()
seen := map[string]bool{}
var firstWriteErr error
for page := 1; page <= s.cfg.MaxPages; page++ {
var p yeekeclient.ReturnPage
var e error
for a := 0; ; a++ {
p, e = s.client.List(ctx, page, s.cfg.PageSize)
if e == nil || a >= s.cfg.Retry {
break
}
select {
case <-ctx.Done():
runErr = ctx.Err()
errMsg = truncateRunError(runErr.Error())
return rep, runErr
case <-time.After(time.Duration(a+1) * 100 * time.Millisecond):
}
}
if e != nil {
// A failed page never overwrites what earlier pages already wrote
// (#336): the run simply stops here and everything upserted so far
// stays as-is, reported through Read/Created/Updated above.
runErr = e
errMsg = truncateRunError(e.Error())
return rep, runErr
}
rep.TotalPages = page
if len(p.Records) == 0 {
break
}
finger := pageFingerprint(p)
if seen[finger] {
rep.Skipped += len(p.Records)
break
}
seen[finger] = true
for _, x := range p.Records {
created, updated, err := s.upsert(ctx, x)
if err != nil {
rep.Failed++
if firstWriteErr == nil {
firstWriteErr = err
}
continue
}
rep.Read++
if created {
rep.Created++
} else if updated {
rep.Updated++
} else {
rep.Skipped++
}
}
if len(p.Records) < s.cfg.PageSize {
break
}
if p.Pages > 0 && page >= p.Pages {
break
}
}
rep.Status = "succeeded"
if rep.Failed > 0 && firstWriteErr != nil {
// Surface why records failed instead of a bare counter.
errMsg = truncateRunError(fmt.Sprintf("%d 条写入失败,首个原因:%v", rep.Failed, firstWriteErr))
if rep.Read == 0 {
rep.Status = "failed"
runErr = errors.New(errMsg)
}
}
return rep, nil
}
// truncateRunError keeps error_message inside the column's size limit. It
// never includes request bodies or headers, so it cannot leak a captcha,
// token or credential: every error path above passes only Go error text from
// HTTP status/timeout/JSON-decoding failures.
func truncateRunError(value string) string {
const limit = 1000
runes := []rune(strings.TrimSpace(value))
if len(runes) <= limit {
return string(runes)
}
return string(runes[:limit])
}
func pageFingerprint(p yeekeclient.ReturnPage) string {
b, _ := json.Marshal(p.Records)
h := sha256.Sum256(b)
return hex.EncodeToString(h[:])
}
func (s *Service) upsert(ctx context.Context, p yeekeclient.ReturnPackage) (bool, bool, error) {
now := time.Now().UTC()
key := packageKey(p)
status := external(p.Status)
var isNew bool
// One transaction per package: a failing item never leaves a package row
// behind without its items. Rows are always inserted fully populated —
// MySQL strict mode (NO_ZERO_DATE) rejects the zero last_synced_at an
// empty placeholder insert would carry, which failed every record.
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var row models.YeekeReturnPackage
e := tx.Where("external_id = ?", key).First(&row).Error
isNew = errors.Is(e, gorm.ErrRecordNotFound)
if e != nil && !isNew {
return e
}
fields := models.YeekeReturnPackage{
ExternalID: key, OrderSN: p.Ordersn, TrackingNo: p.TrackingNo,
ShopID: external(p.ShopID), ShopName: p.ShopName,
WareCode: p.WareCode, WareHouse: p.WareHouse, WareName: p.WareName,
ClaimStatus: status, StatusUnrecognized: !knownClaimStatuses[status],
ClaimTime: stamp(p.ClaimTime), CreateTime: stamp(p.CreateTime),
UpdateTime: stamp(p.UpdateTime), DestroyDeadLine: stamp(p.DestroyDeadLine),
LastSyncedAt: now, SyncStatus: "ok",
}
if isNew {
row = fields
if e = tx.Create(&row).Error; e != nil {
return e
}
} else {
vals := map[string]any{"order_sn": fields.OrderSN, "tracking_no": fields.TrackingNo, "shop_id": fields.ShopID, "shop_name": fields.ShopName, "ware_code": fields.WareCode, "ware_house": fields.WareHouse, "ware_name": fields.WareName, "claim_status": fields.ClaimStatus, "status_unrecognized": fields.StatusUnrecognized, "claim_time": fields.ClaimTime, "create_time": fields.CreateTime, "update_time": fields.UpdateTime, "destroy_dead_line": fields.DestroyDeadLine, "last_synced_at": now, "sync_status": "ok"}
if e = tx.Model(&row).Updates(vals).Error; e != nil {
return e
}
}
for n, i := range p.Items {
ik := itemKey(p, i, n)
item := models.YeekeReturnItem{
PackageID: row.ID, ExternalKey: ik,
ItemID: external(i.ItemID), VariationID: external(i.VariationID),
ItemName: i.ItemName, VariationName: i.VariationName, Image: i.Image,
Quantity: int64(i.Quantity), LastSyncedAt: now, SyncStatus: "ok",
}
var existing models.YeekeReturnItem
ie := tx.Where("package_id = ? AND external_key = ?", row.ID, ik).First(&existing).Error
switch {
case errors.Is(ie, gorm.ErrRecordNotFound):
if e = tx.Create(&item).Error; e != nil {
return e
}
case ie != nil:
return ie
default:
iv := map[string]any{"item_id": item.ItemID, "variation_id": item.VariationID, "item_name": item.ItemName, "variation_name": item.VariationName, "image": item.Image, "quantity": item.Quantity, "last_synced_at": now, "sync_status": "ok"}
if e = tx.Model(&existing).Updates(iv).Error; e != nil {
return e
}
}
}
return nil
})
if err != nil {
return false, false, err
}
return isNew, !isNew, nil
}
-779
View File
@@ -1,779 +0,0 @@
package yeeke
import (
"context"
"encoding/json"
"fmt"
"go-admin/app/goauto/migrations"
"go-admin/app/goauto/models"
"go-admin/app/goauto/yeekeclient"
"go-admin/config"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"net/http"
"net/http/httptest"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
)
type stubOCR struct{ codes []string }
func (s *stubOCR) Recognize(context.Context, []byte) (string, error) {
if len(s.codes) == 0 {
return "", nil
}
c := s.codes[0]
s.codes = s.codes[1:]
return c, nil
}
func testDB(t *testing.T) *gorm.DB {
t.Helper()
db, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if err := migrations.Migrate(db); err != nil {
t.Fatal(err)
}
return db
}
func record(id, itemID, variationID string, status any) string {
b, _ := json.Marshal(map[string]any{
"id": id, "ordersn": "o-" + id, "trackingNo": "t-" + id, "status": status,
"items": []map[string]any{{
"id": id + "-i1", "itemId": itemID, "variationId": variationID,
"itemName": "n", "variationName": "v", "variationQuantityPurchased": 1,
}},
})
return string(b)
}
func page(records []string, total, pages int) string {
return fmt.Sprintf(`{"success":true,"result":{"records":[%s],"total":%d,"pages":%d}}`, strings.Join(records, ","), total, pages)
}
// TestPagingSurvivesTotalChangingMidRun: page 1 reports one total/pages, page
// 2 reports a different total/pages (the underlying data changed between the
// two requests). The walk must still finish using what each page returned.
func TestPagingSurvivesTotalChangingMidRun(t *testing.T) {
db := testDB(t)
var calls int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
n := atomic.AddInt32(&calls, 1)
w.Header().Set("Content-Type", "application/json")
switch n {
case 1:
fmt.Fprint(w, page([]string{record("p1", "i", "v1", 1)}, 100, 2))
case 2:
fmt.Fprint(w, page([]string{record("p2", "i", "v2", 1)}, 50, 1))
default:
fmt.Fprint(w, page(nil, 0, 0))
}
}))
defer srv.Close()
c, _ := yeekeclient.New(srv.URL)
s := NewService(db, c, Config{PageSize: 1})
rep, err := s.Sync(context.Background(), "manual")
if err != nil {
t.Fatal(err)
}
if rep.Status != "succeeded" || rep.Read != 2 {
t.Fatalf("rep=%+v", rep)
}
}
// TestPagingSkipsARepeatedDuplicatePage: the server returns the exact same
// page twice in a row (e.g. a retried request landed after all). The second
// occurrence must be recognized as a duplicate and stop the walk instead of
// looping or double counting.
func TestPagingSkipsARepeatedDuplicatePage(t *testing.T) {
db := testDB(t)
var calls int32
body := page([]string{record("p1", "i", "v1", 1)}, 10, 5)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
n := atomic.AddInt32(&calls, 1)
w.Header().Set("Content-Type", "application/json")
if n <= 2 {
fmt.Fprint(w, body) // identical page served twice
return
}
fmt.Fprint(w, page(nil, 10, 5))
}))
defer srv.Close()
c, _ := yeekeclient.New(srv.URL)
s := NewService(db, c, Config{PageSize: 1})
rep, err := s.Sync(context.Background(), "manual")
if err != nil {
t.Fatal(err)
}
if rep.Status != "succeeded" {
t.Fatalf("rep=%+v", rep)
}
var n int64
db.Model(&models.YeekeReturnPackage{}).Count(&n)
if n != 1 {
t.Fatalf("packages=%d, want 1 (duplicate page must not double-insert)", n)
}
if calls != 2 {
t.Fatalf("calls=%d, want exactly 2 (stop right after recognizing the duplicate)", calls)
}
}
// TestPagingStopsOnEmptyPage confirms an empty page ends the walk cleanly.
func TestPagingStopsOnEmptyPage(t *testing.T) {
db := testDB(t)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, page(nil, 0, 0))
}))
defer srv.Close()
c, _ := yeekeclient.New(srv.URL)
s := NewService(db, c, Config{PageSize: 10})
rep, err := s.Sync(context.Background(), "manual")
if err != nil {
t.Fatal(err)
}
if rep.Status != "succeeded" || rep.TotalPages != 1 || rep.Read != 0 {
t.Fatalf("rep=%+v", rep)
}
}
// TestPagingTimeoutFailsRunButKeepsEarlierPages: page 1 succeeds and is
// persisted; page 2 always times out. The run must end as "failed" (after
// retrying up to cfg.Retry times) but page 1's row must remain intact — a
// failed page must never roll back or overwrite valid prior data.
func TestPagingTimeoutFailsRunButKeepsEarlierPages(t *testing.T) {
db := testDB(t)
var calls int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
n := atomic.AddInt32(&calls, 1)
if n == 1 {
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, page([]string{record("p1", "i", "v1", 1)}, 10, 5))
return
}
// Simulate a slow/timed-out request: this response is deliberately
// slower than the client's own context deadline below, so the client
// side must give up on its own rather than waiting for us.
time.Sleep(2 * time.Second)
}))
defer srv.Close()
c, _ := yeekeclient.New(srv.URL)
s := NewService(db, c, Config{PageSize: 1, Retry: 1})
ctx, cancel := context.WithTimeout(context.Background(), 300*time.Millisecond)
defer cancel()
rep, err := s.Sync(ctx, "manual")
if err == nil {
t.Fatal("expected the timed-out page to surface an error")
}
if rep.Status != "failed" || rep.RunID == 0 {
t.Fatalf("rep=%+v", rep)
}
var row models.YeekeReturnPackage
if e := db.Where("external_id = ?", "p1").First(&row).Error; e != nil {
t.Fatalf("page 1's row must survive a later page's failure: %v", e)
}
if row.ClaimStatus != "1" {
t.Fatalf("page 1's row must be unmodified: %+v", row)
}
var run models.YeekeSyncRun
if e := db.First(&run, rep.RunID).Error; e != nil {
t.Fatal(e)
}
if run.Status != "failed" || run.ErrorMessage == "" {
t.Fatalf("run=%+v", run)
}
}
// TestSyncResumeAfterSimulatedRestart: a first run fails partway through
// (simulating the process being interrupted after committing page 1). A
// second, full run afterwards must succeed and must not duplicate the row
// page 1 already wrote — it converges to exactly one package row.
func TestSyncResumeAfterSimulatedRestart(t *testing.T) {
db := testDB(t)
var calls int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
n := atomic.AddInt32(&calls, 1)
w.Header().Set("Content-Type", "application/json")
if n == 1 {
fmt.Fprint(w, page([]string{record("p1", "i", "v1", 1)}, 10, 2))
return
}
if n == 2 {
w.WriteHeader(http.StatusInternalServerError)
return
}
// Full second run: both pages succeed this time.
if n == 3 {
fmt.Fprint(w, page([]string{record("p1", "i", "v1", 1)}, 10, 2))
return
}
fmt.Fprint(w, page(nil, 10, 2))
}))
defer srv.Close()
c, _ := yeekeclient.New(srv.URL)
s := NewService(db, c, Config{PageSize: 1, Retry: 0})
if _, err := s.Sync(context.Background(), "manual"); err == nil {
t.Fatal("expected the first ('interrupted') run to fail")
}
rep2, err := s.Sync(context.Background(), "manual")
if err != nil {
t.Fatal(err)
}
if rep2.Status != "succeeded" {
t.Fatalf("rep2=%+v", rep2)
}
var n int64
db.Model(&models.YeekeReturnPackage{}).Count(&n)
if n != 1 {
t.Fatalf("packages=%d, want 1 (resume must not duplicate p1)", n)
}
}
// TestIdempotentStatusUpdateInPlace: syncing the same package twice with a
// different status the second time updates the existing row rather than
// creating a second one.
func TestIdempotentStatusUpdateInPlace(t *testing.T) {
db := testDB(t)
var status int32 = 1
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, page([]string{record("p1", "i", "v1", atomic.LoadInt32(&status))}, 1, 1))
}))
defer srv.Close()
c, _ := yeekeclient.New(srv.URL)
s := NewService(db, c, Config{PageSize: 10})
if _, err := s.Sync(context.Background(), "manual"); err != nil {
t.Fatal(err)
}
atomic.StoreInt32(&status, 9) // an unrecognized status the second time
if _, err := s.Sync(context.Background(), "manual"); err != nil {
t.Fatal(err)
}
var n int64
db.Model(&models.YeekeReturnPackage{}).Count(&n)
if n != 1 {
t.Fatalf("packages=%d, want 1 (status change must update in place)", n)
}
var row models.YeekeReturnPackage
db.Where("external_id = ?", "p1").First(&row)
if row.ClaimStatus != "9" || !row.StatusUnrecognized {
t.Fatalf("row=%+v, want claim_status=9 flagged unrecognized", row)
}
}
// TestUnknownStatusIsPreservedVerbatimAndFlagged: an unrecognized status value
// is kept as-is (never remapped into a known bucket) and the row is flagged.
func TestUnknownStatusIsPreservedVerbatimAndFlagged(t *testing.T) {
db := testDB(t)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, page([]string{record("p1", "i", "v1", 7)}, 1, 1))
}))
defer srv.Close()
c, _ := yeekeclient.New(srv.URL)
s := NewService(db, c, Config{PageSize: 10})
if _, err := s.Sync(context.Background(), "manual"); err != nil {
t.Fatal(err)
}
var row models.YeekeReturnPackage
if e := db.Where("external_id = ?", "p1").First(&row).Error; e != nil {
t.Fatal(e)
}
if row.ClaimStatus != "7" {
t.Fatalf("claim_status=%q, want the raw value 7 preserved verbatim", row.ClaimStatus)
}
if !row.StatusUnrecognized {
t.Fatal("an unknown status must be flagged, not silently accepted")
}
}
// TestActiveSlotLeaseRejectsConcurrentRuns exercises the same DB-level
// uniqueness the manual trigger and the scheduled job both rely on
// (models.YeekeSyncRun.ActiveSlot): two Sync calls racing against the same
// database must not both hold the lease at once.
func TestActiveSlotLeaseRejectsConcurrentRuns(t *testing.T) {
db := testDB(t)
release := make(chan struct{})
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
<-release
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, page(nil, 0, 0))
}))
defer srv.Close()
c, _ := yeekeclient.New(srv.URL)
s := NewService(db, c, Config{PageSize: 10})
started := make(chan struct{})
var firstErr, secondErr error
go func() {
close(started)
_, firstErr = s.Sync(context.Background(), "manual")
}()
<-started
time.Sleep(50 * time.Millisecond) // let the first Sync acquire its lease row
_, secondErr = s.Sync(context.Background(), "scheduled")
close(release)
time.Sleep(50 * time.Millisecond)
if secondErr == nil {
t.Fatal("a second concurrent Sync must be rejected by the active_slot lease")
}
_ = firstErr
}
// TestStartSyncGateRejectsConcurrentTriggers exercises StartSync itself (the
// entry point manual and scheduled triggers actually share): a second call
// while one is in flight is rejected, and with skipIfRunning it is reported
// as skipped instead of erroring — this is what the scheduled job uses so a
// tick landing during a manual run does not surface as a failure.
func TestStartSyncGateRejectsConcurrentTriggers(t *testing.T) {
db := testDB(t)
restore := setTestYeekeConfig(t, "op", "secret-pw")
defer restore()
release := make(chan struct{})
var loginCalls, listCalls int32
srv := fakeYeekeServer(t, &loginCalls, &listCalls, release)
defer srv.Close()
restoreURL := setTestYeekeBaseURL(t, srv.URL)
defer restoreURL()
var wg sync.WaitGroup
wg.Add(1)
var firstErr error
go func() {
defer wg.Done()
_, firstErr = StartSync(context.Background(), db, &stubOCR{codes: []string{"abcd"}}, "manual", false)
}()
// Give the first call time to acquire syncGate and the DB lease before
// the second one is attempted.
time.Sleep(150 * time.Millisecond)
_, err := StartSync(context.Background(), db, &stubOCR{}, "scheduled", true)
if err != nil {
t.Fatalf("skipIfRunning=true must not error, got %v", err)
}
_, err2 := StartSync(context.Background(), db, &stubOCR{}, "manual", false)
if err2 == nil || err2 != ErrAlreadyRunning {
t.Fatalf("skipIfRunning=false must report ErrAlreadyRunning, got %v", err2)
}
close(release)
wg.Wait()
if firstErr != nil {
t.Fatalf("first StartSync should have completed cleanly: %v", firstErr)
}
}
// TestLoginFailureMessageNeverLeaksCredentialsOrCaptcha: whatever StartSync
// or the underlying client return as an error, the credential, password and
// recognized captcha text must never appear in it, since that text ends up
// in server logs and (truncated) in yeeke_sync_run.error_message.
func TestLoginFailureMessageNeverLeaksCredentialsOrCaptcha(t *testing.T) {
db := testDB(t)
const secretPassword = "S3cr3t-Do-Not-Leak"
const secretCaptcha = "zZqQ9x"
restore := setTestYeekeConfig(t, "leak-user", secretPassword)
defer restore()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch r.URL.Path {
case "/agent-foreign/sys/randomImage":
json.NewEncoder(w).Encode(map[string]any{"success": true, "result": map[string]string{"image": "data:image/jpg;base64,SGk=", "checkKey": "k"}})
case "/agent-foreign/sys/login":
json.NewEncoder(w).Encode(map[string]any{"success": false, "code": 1, "message": "验证码错误"})
default:
http.NotFound(w, r)
}
}))
defer srv.Close()
restoreURL := setTestYeekeBaseURL(t, srv.URL)
defer restoreURL()
_, err := StartSync(context.Background(), db, &stubOCR{codes: []string{secretCaptcha}}, "manual", false)
if err == nil {
t.Fatal("expected a login failure")
}
msg := err.Error()
if strings.Contains(msg, secretPassword) || strings.Contains(msg, secretCaptcha) || strings.Contains(msg, "leak-user") {
t.Fatalf("error message leaked a credential or captcha text: %q", msg)
}
var run models.YeekeSyncRun
// StartSync failed before acquiring a run row here (Connect failed first),
// so there should be no run row at all to check — that is itself part of
// the guarantee: a failed login never gets far enough to write a summary
// row that could carry sensitive text.
if e := db.Order("id desc").First(&run).Error; e == nil {
if strings.Contains(run.ErrorMessage, secretPassword) || strings.Contains(run.ErrorMessage, secretCaptcha) {
t.Fatalf("run.ErrorMessage leaked a credential or captcha text: %q", run.ErrorMessage)
}
}
}
// fakeYeekeServer serves a minimal login+list surface. It blocks the *list*
// call on release, so a test can hold StartSync's background goroutine open
// long enough to exercise the concurrency gate.
func fakeYeekeServer(t *testing.T, loginCalls, listCalls *int32, release chan struct{}) *httptest.Server {
t.Helper()
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch r.URL.Path {
case "/agent-foreign/sys/randomImage":
json.NewEncoder(w).Encode(map[string]any{"success": true, "result": map[string]string{"image": "data:image/jpg;base64,SGk=", "checkKey": "k"}})
case "/agent-foreign/sys/login":
atomic.AddInt32(loginCalls, 1)
json.NewEncoder(w).Encode(map[string]any{"success": true, "result": map[string]any{"token": "tok", "userInfo": map[string]any{"id": "u1", "username": "op"}}})
case "/agent-foreign/packageClaimRec/relation/list":
atomic.AddInt32(listCalls, 1)
<-release
fmt.Fprint(w, page(nil, 0, 0))
default:
http.NotFound(w, r)
}
}))
}
// The next two helpers isolate StartSync's config.ExtConfig.Yeeke dependency
// for tests, restoring it afterwards so other tests are unaffected.
func setTestYeekeConfig(t *testing.T, username, password string) func() {
t.Helper()
before := config.ExtConfig.Yeeke
config.ExtConfig.Yeeke.Username = username
config.ExtConfig.Yeeke.Password = password
config.ExtConfig.Yeeke.OcrURL = "http://unused.invalid/ocr"
return func() { config.ExtConfig.Yeeke = before }
}
func setTestYeekeBaseURL(t *testing.T, url string) func() {
t.Helper()
before := config.ExtConfig.Yeeke
config.ExtConfig.Yeeke.BaseURL = url
return func() { config.ExtConfig.Yeeke = before }
}
// packageWithItems builds a raw list-page record for one package carrying an
// arbitrary, caller-ordered set of items, so tests can reorder items between
// two syncs of the same package.
func packageWithItems(pkgID string, items ...map[string]any) string {
b, _ := json.Marshal(map[string]any{
"id": pkgID, "ordersn": "o-" + pkgID, "trackingNo": "t-" + pkgID, "status": 1,
"items": items,
})
return string(b)
}
func item(id, itemID, variationID string) map[string]any {
return map[string]any{"id": id, "itemId": itemID, "variationId": variationID, "itemName": "n", "variationName": "v", "variationQuantityPurchased": 1}
}
// itemNoIDs builds an item carrying no yeeke-issued identifiers at all
// (id/itemId/variationId all empty), the case itemKey's positional-index
// fallback exists for.
func itemNoIDs() map[string]any {
return map[string]any{"id": "", "itemId": "", "variationId": "", "itemName": "n", "variationName": "v", "variationQuantityPurchased": 1}
}
// --- Defect 1 (#336): stale lease takeover -------------------------------
// TestStaleLeaseIsTakenOverOnNextAcquire simulates a crash: a "running" row
// is left behind with an active_slot and a lease that has already expired
// (as if the process died mid-sync, long before the lease's normal
// duration). The very next Sync call — scheduled or manual — must reclaim
// the slot rather than being permanently blocked, and the abandoned row must
// end up in a clear terminal state (not silently deleted) recording why.
func TestStaleLeaseIsTakenOverOnNextAcquire(t *testing.T) {
db := testDB(t)
slot := uint8(1)
past := time.Now().UTC().Add(-time.Hour)
stale := models.YeekeSyncRun{
Status: "running", Trigger: "scheduled", StartedAt: past.Add(-time.Minute),
ActiveSlot: &slot, LeaseOwner: "dead-process", LeaseExpiresAt: &past,
}
if e := db.Create(&stale).Error; e != nil {
t.Fatal(e)
}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, page(nil, 0, 0))
}))
defer srv.Close()
c, _ := yeekeclient.New(srv.URL)
s := NewService(db, c, Config{PageSize: 10})
rep, err := s.Sync(context.Background(), "manual")
if err != nil {
t.Fatalf("resume after a stale lease must succeed, got err=%v", err)
}
if rep.Status != "succeeded" {
t.Fatalf("rep=%+v", rep)
}
var reclaimed models.YeekeSyncRun
if e := db.First(&reclaimed, stale.ID).Error; e != nil {
t.Fatal(e)
}
if reclaimed.Status != "failed" {
t.Fatalf("stale run status=%q, want a terminal status (not silently left running or deleted)", reclaimed.Status)
}
if reclaimed.ErrorMessage == "" {
t.Fatal("stale run must record why it was taken over")
}
if reclaimed.ActiveSlot != nil {
t.Fatal("stale run must release active_slot on takeover")
}
// The new run's own row clears active_slot on completion just like any
// other successful run (see run()'s defer), so what proves the takeover
// happened is that a second, distinct run row now exists alongside the
// reclaimed stale one.
var totalRuns int64
db.Model(&models.YeekeSyncRun{}).Count(&totalRuns)
if totalRuns != 2 {
t.Fatalf("expected the stale row plus exactly one new run after takeover, got %d run rows", totalRuns)
}
if rep.RunID == stale.ID {
t.Fatal("the new run must not reuse the stale run's row")
}
}
// TestActiveSlotLeaseRejectsConcurrentRuns above must still pass unmodified:
// a lease that has NOT expired must keep blocking a second run. This test
// pins that same guarantee at the acquire() level directly.
func TestValidLeaseIsNotTakenOver(t *testing.T) {
db := testDB(t)
slot := uint8(1)
future := time.Now().UTC().Add(time.Hour)
holding := models.YeekeSyncRun{
Status: "running", Trigger: "manual", StartedAt: time.Now().UTC(),
ActiveSlot: &slot, LeaseOwner: "still-alive", LeaseExpiresAt: &future,
}
if e := db.Create(&holding).Error; e != nil {
t.Fatal(e)
}
c, _ := yeekeclient.New("http://unused.invalid")
s := NewService(db, c, Config{PageSize: 10})
if _, e := s.acquire(context.Background(), "scheduled"); e == nil {
t.Fatal("a still-valid lease must not be taken over or bypassed")
}
var row models.YeekeSyncRun
if e := db.First(&row, holding.ID).Error; e != nil {
t.Fatal(e)
}
if row.Status != "running" || row.ActiveSlot == nil {
t.Fatalf("holder must be untouched: %+v", row)
}
}
// TestConcurrentTakeoverExactlyOneWins races two acquire() calls against the
// same stale, expired-lease row. Both attempt the takeover UPDATE and then a
// Create; the takeover UPDATE is idempotent (the loser affects zero rows
// since the row's status is no longer "running" by the time it runs), and
// the ux_yeeke_sync_run_active_slot unique index arbitrates the Create race
// the same way it already does for two brand-new concurrent runs. Exactly
// one goroutine must come away holding the slot.
func TestConcurrentTakeoverExactlyOneWins(t *testing.T) {
db := testDB(t)
slot := uint8(1)
past := time.Now().UTC().Add(-time.Hour)
stale := models.YeekeSyncRun{
Status: "running", Trigger: "scheduled", StartedAt: past.Add(-time.Minute),
ActiveSlot: &slot, LeaseOwner: "dead-process", LeaseExpiresAt: &past,
}
if e := db.Create(&stale).Error; e != nil {
t.Fatal(e)
}
// SQLite only allows one writer at a time; serialize connections through
// the Go pool (same pattern as app/goauto/purchase/order_backfill_test.go
// and friends) so the race is decided by acquire()'s own logic rather
// than by spurious "database is locked" errors.
if sqlDB, e := db.DB(); e == nil {
sqlDB.SetMaxOpenConns(1)
}
c, _ := yeekeclient.New("http://unused.invalid")
s := NewService(db, c, Config{PageSize: 10})
const n = 8
var wg sync.WaitGroup
oks := make([]bool, n)
for i := 0; i < n; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
_, e := s.acquire(context.Background(), "manual")
oks[i] = e == nil
}(i)
}
wg.Wait()
winners := 0
for _, ok := range oks {
if ok {
winners++
}
}
if winners != 1 {
t.Fatalf("winners=%d, want exactly 1 (active_slot must arbitrate concurrent takeover attempts)", winners)
}
var holders int64
db.Model(&models.YeekeSyncRun{}).Where("active_slot = ?", 1).Count(&holders)
if holders != 1 {
t.Fatalf("holders=%d, want exactly 1 row holding active_slot after the race", holders)
}
}
// --- Defect 2 (#336): itemKey must not depend on item order --------------
// TestItemKeyStableAcrossReorder syncs the same package twice with its two
// items in reversed order the second time. Reordering must not create new
// rows: each item's identity must key off its own IDs, not its position.
func TestItemKeyStableAcrossReorder(t *testing.T) {
db := testDB(t)
var call int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
n := atomic.AddInt32(&call, 1)
if n == 1 {
fmt.Fprint(w, page([]string{packageWithItems("p1", item("i1", "item-a", "var-a"), item("i2", "item-b", "var-b"))}, 1, 1))
return
}
if n == 2 {
// Same package, items reordered.
fmt.Fprint(w, page([]string{packageWithItems("p1", item("i2", "item-b", "var-b"), item("i1", "item-a", "var-a"))}, 1, 1))
return
}
fmt.Fprint(w, page(nil, 0, 0))
}))
defer srv.Close()
c, _ := yeekeclient.New(srv.URL)
s := NewService(db, c, Config{PageSize: 10})
if _, err := s.Sync(context.Background(), "manual"); err != nil {
t.Fatal(err)
}
if _, err := s.Sync(context.Background(), "manual"); err != nil {
t.Fatal(err)
}
var pkg models.YeekeReturnPackage
if e := db.Where("external_id = ?", "p1").First(&pkg).Error; e != nil {
t.Fatal(e)
}
var n int64
db.Model(&models.YeekeReturnItem{}).Where("package_id = ?", pkg.ID).Count(&n)
if n != 2 {
t.Fatalf("items=%d, want 2 (reordering the same items must not duplicate rows)", n)
}
}
// TestItemKeyIndexFallbackForItemsLackingAllIDs covers a package whose items
// carry no yeeke-issued identifiers at all: the positional index is the only
// thing that can distinguish them, so the fallback must still apply and keep
// them as separate rows.
func TestItemKeyIndexFallbackForItemsLackingAllIDs(t *testing.T) {
db := testDB(t)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, page([]string{packageWithItems("p1", itemNoIDs(), itemNoIDs())}, 1, 1))
}))
defer srv.Close()
c, _ := yeekeclient.New(srv.URL)
s := NewService(db, c, Config{PageSize: 10})
if _, err := s.Sync(context.Background(), "manual"); err != nil {
t.Fatal(err)
}
var pkg models.YeekeReturnPackage
if e := db.Where("external_id = ?", "p1").First(&pkg).Error; e != nil {
t.Fatal(e)
}
var n int64
db.Model(&models.YeekeReturnItem{}).Where("package_id = ?", pkg.ID).Count(&n)
if n != 2 {
t.Fatalf("items=%d, want 2 (items lacking all IDs must still be distinguished by position)", n)
}
}
// TestItemKeyDistinctVariationsOfSameItemID pins existing behavior: two
// items sharing the same itemID but different variationIDs are, and must
// remain, two distinct rows.
func TestItemKeyDistinctVariationsOfSameItemID(t *testing.T) {
db := testDB(t)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, page([]string{packageWithItems("p1", item("i1", "item-a", "var-1"), item("i2", "item-a", "var-2"))}, 1, 1))
}))
defer srv.Close()
c, _ := yeekeclient.New(srv.URL)
s := NewService(db, c, Config{PageSize: 10})
if _, err := s.Sync(context.Background(), "manual"); err != nil {
t.Fatal(err)
}
var pkg models.YeekeReturnPackage
if e := db.Where("external_id = ?", "p1").First(&pkg).Error; e != nil {
t.Fatal(e)
}
var n int64
db.Model(&models.YeekeReturnItem{}).Where("package_id = ?", pkg.ID).Count(&n)
if n != 2 {
t.Fatalf("items=%d, want 2 (same itemID with different variationID must stay distinct)", n)
}
}
// TestResyncUpdatesClaimTimeAndDestroyDeadLine: yeeke may correct a package's
// claim time or destroy deadline between two syncs of the same package. The
// second sync must overwrite the stored values, not just refresh
// last_synced_at.
func TestResyncUpdatesClaimTimeAndDestroyDeadLine(t *testing.T) {
db := testDB(t)
recordWithTimes := func(claimTime, destroyDeadLine string) string {
b, _ := json.Marshal(map[string]any{
"id": "p1", "ordersn": "o-p1", "trackingNo": "t-p1", "status": "1",
"claimTime": claimTime, "destroyDeadLine": destroyDeadLine,
"items": []map[string]any{{
"id": "p1-i1", "itemId": "i", "variationId": "v1",
"itemName": "n", "variationName": "v", "variationQuantityPurchased": "1",
}},
})
return string(b)
}
var body string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, body)
}))
defer srv.Close()
c, _ := yeekeclient.New(srv.URL)
s := NewService(db, c, Config{PageSize: 20})
body = page([]string{recordWithTimes("2026-09-01 10:00:00", "2026-10-01 10:00:00")}, 1, 1)
if _, err := s.Sync(context.Background(), "manual"); err != nil {
t.Fatalf("first sync: %v", err)
}
var row models.YeekeReturnPackage
db.Where("external_id = ?", "p1").First(&row)
if got := row.ClaimTime.Format("2006-01-02"); got != "2026-09-01" {
t.Fatalf("claim_time after first sync = %s", got)
}
body = page([]string{recordWithTimes("2026-09-15 08:00:00", "2026-10-15 08:00:00")}, 1, 1)
if _, err := s.Sync(context.Background(), "manual"); err != nil {
t.Fatalf("second sync: %v", err)
}
db.Where("external_id = ?", "p1").First(&row)
if got := row.ClaimTime.Format("2006-01-02"); got != "2026-09-15" {
t.Fatalf("claim_time after resync = %s, want updated value", got)
}
if got := row.DestroyDeadLine.Format("2006-01-02"); got != "2026-10-15" {
t.Fatalf("destroy_dead_line after resync = %s, want updated value", got)
}
}
-101
View File
@@ -1,101 +0,0 @@
package yeeke
import (
"context"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"go-admin/app/goauto/models"
"go-admin/app/goauto/yeekeclient"
"gorm.io/gorm"
)
// rejectZeroLastSyncedAt emulates MySQL strict mode (NO_ZERO_DATE) in SQLite:
// an INSERT carrying a zero last_synced_at is refused, exactly as production
// MySQL refused the old placeholder insert and failed every record.
func rejectZeroLastSyncedAt(t *testing.T, db *gorm.DB) {
t.Helper()
err := db.Callback().Create().Before("gorm:create").Register("test:strict_no_zero_date", func(tx *gorm.DB) {
switch v := tx.Statement.Dest.(type) {
case *models.YeekeReturnPackage:
if v.LastSyncedAt.IsZero() {
_ = tx.AddError(errors.New("Error 1292: Incorrect datetime value: '0000-00-00' for column 'last_synced_at'"))
}
case *models.YeekeReturnItem:
if v.LastSyncedAt.IsZero() {
_ = tx.AddError(errors.New("Error 1292: Incorrect datetime value: '0000-00-00' for column 'last_synced_at'"))
}
}
})
if err != nil {
t.Fatal(err)
}
}
func TestSyncWritesUnderStrictDatetimeMode(t *testing.T) {
db := testDB(t)
rejectZeroLastSyncedAt(t, db)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, page([]string{record("p1", "i", "v1", "1"), record("p2", "i", "v2", "1")}, 2, 1))
}))
defer srv.Close()
c, _ := yeekeclient.New(srv.URL)
rep, err := NewService(db, c, Config{PageSize: 20}).Sync(context.Background(), "manual")
if err != nil {
t.Fatalf("sync: %v (rep=%+v)", err, rep)
}
if rep.Failed != 0 || rep.Created != 2 {
t.Fatalf("rep=%+v, want 2 created and 0 failed", rep)
}
var pkgs, items int64
db.Model(&models.YeekeReturnPackage{}).Count(&pkgs)
db.Model(&models.YeekeReturnItem{}).Count(&items)
if pkgs != 2 || items != 2 {
t.Fatalf("stored packages=%d items=%d", pkgs, items)
}
var p models.YeekeReturnPackage
db.First(&p)
if p.LastSyncedAt.IsZero() || time.Since(p.LastSyncedAt) > time.Minute {
t.Fatalf("last_synced_at not set: %v", p.LastSyncedAt)
}
}
// When records fail to write, the run must say why and must not claim success
// when nothing was stored.
func TestSyncReportsWriteFailureReason(t *testing.T) {
db := testDB(t)
if err := db.Callback().Create().Before("gorm:create").Register("test:fail_items", func(tx *gorm.DB) {
if _, ok := tx.Statement.Dest.(*models.YeekeReturnItem); ok {
_ = tx.AddError(errors.New("simulated item write failure"))
}
}); err != nil {
t.Fatal(err)
}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, page([]string{record("p1", "i", "v1", "1")}, 1, 1))
}))
defer srv.Close()
c, _ := yeekeclient.New(srv.URL)
rep, _ := NewService(db, c, Config{PageSize: 20}).Sync(context.Background(), "manual")
if rep.Status != "failed" || rep.Failed != 1 {
t.Fatalf("rep=%+v, want failed with 1 failure", rep)
}
var run models.YeekeSyncRun
db.Order("id DESC").First(&run)
if !strings.Contains(run.ErrorMessage, "1 条写入失败") || !strings.Contains(run.ErrorMessage, "simulated item write failure") {
t.Fatalf("error_message = %q", run.ErrorMessage)
}
// The package insert shares the transaction, so no half-written package.
var pkgs int64
db.Model(&models.YeekeReturnPackage{}).Count(&pkgs)
if pkgs != 0 {
t.Fatalf("package rows = %d, want 0 after rolled-back item failure", pkgs)
}
}
-42
View File
@@ -1,42 +0,0 @@
package yeeke
import (
"context"
"go-admin/app/goauto/migrations"
"go-admin/app/goauto/models"
"go-admin/app/goauto/yeekeclient"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"net/http"
"net/http/httptest"
"testing"
)
func TestSyncIsIdempotentAndKeepsVariationsSeparate(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"success":true,"result":{"records":[{"id":"p1","ordersn":"o","trackingNo":"t","status":1,"items":[{"id":"a","itemId":"i","variationId":"v1","itemName":"n","variationName":"red","variationQuantityPurchased":1},{"id":"b","itemId":"i","variationId":"v2","itemName":"n","variationName":"blue","variationQuantityPurchased":1}]}],"total":1,"pages":1}}`))
}))
defer server.Close()
db, _ := gorm.Open(sqlite.Open("file:yeeke-sync?mode=memory&cache=shared"), &gorm.Config{})
if e := migrations.Migrate(db); e != nil {
t.Fatal(e)
}
c, _ := yeekeclient.New(server.URL)
s := NewService(db, c, Config{PageSize: 10})
if _, e := s.Sync(context.Background(), "manual"); e != nil {
t.Fatal(e)
}
if _, e := s.Sync(context.Background(), "manual"); e != nil {
t.Fatal(e)
}
var n int64
db.Model(&models.YeekeReturnPackage{}).Count(&n)
if n != 1 {
t.Fatalf("packages=%d", n)
}
db.Model(&models.YeekeReturnItem{}).Count(&n)
if n != 2 {
t.Fatalf("items=%d", n)
}
}
-385
View File
@@ -1,385 +0,0 @@
package yeekeclient
import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/http/cookiejar"
"net/url"
"strconv"
"strings"
"time"
)
var ErrSessionInvalid = errors.New("yeeke session invalid")
var ErrNoSession = errors.New("yeeke session unavailable")
type Session struct {
Username, Token, CookiesJSON, UserID string
ExpiresAt time.Time
}
type Captcha struct {
Image []byte
CheckKey string
ContentType string
}
type Client struct {
baseURL string
http *http.Client
jar *cookiejar.Jar
token string
retry int
}
func New(baseURL string) (*Client, error) {
baseURL = strings.TrimRight(strings.TrimSpace(baseURL), "/")
if baseURL == "" {
return nil, fmt.Errorf("yeeke base_url required")
}
j, e := cookiejar.New(nil)
if e != nil {
return nil, e
}
return &Client{baseURL: baseURL, jar: j, http: &http.Client{Jar: j, Timeout: 60 * time.Second}}, nil
}
func (c *Client) SetToken(t string) { c.token = t }
func (c *Client) Token() string { return c.token }
type cookieDTO struct{ Name, Value, Path string }
func (c *Client) ExportCookiesJSON() (string, error) {
u, e := url.Parse(c.baseURL)
if e != nil {
return "", e
}
a := []cookieDTO{}
for _, x := range c.jar.Cookies(u) {
a = append(a, cookieDTO{x.Name, x.Value, x.Path})
}
b, e := json.Marshal(a)
return string(b), e
}
func (c *Client) ImportCookiesJSON(s string) error {
var a []cookieDTO
if e := json.Unmarshal([]byte(s), &a); e != nil {
return e
}
u, e := url.Parse(c.baseURL)
if e != nil {
return e
}
cs := []*http.Cookie{}
for _, x := range a {
if x.Name != "" {
p := x.Path
if p == "" {
p = "/"
}
cs = append(cs, &http.Cookie{Name: x.Name, Value: x.Value, Path: p})
}
}
c.jar.SetCookies(u, cs)
return nil
}
func (c *Client) do(ctx context.Context, method, path string, body any, query url.Values) (json.RawMessage, error) {
b := io.Reader(nil)
if body != nil {
x, e := json.Marshal(body)
if e != nil {
return nil, e
}
b = bytes.NewReader(x)
}
u := c.baseURL + path
if len(query) > 0 {
u += "?" + query.Encode()
}
req, e := http.NewRequestWithContext(ctx, method, u, b)
if e != nil {
return nil, e
}
req.Header.Set("Accept", "application/json")
// yeeke is driven through its web front end; a Go default user agent is
// treated differently from the browser (see demo/yeeke_demo.py and the HAR).
req.Header.Set("User-Agent", browserUserAgent)
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
// The yeeke web client authenticates with the X-Access-Token header (HAR);
// keeping the token out of the URL also keeps it out of any access log.
if c.token != "" {
req.Header.Set("X-Access-Token", c.token)
}
resp, e := c.http.Do(req)
if e != nil {
return nil, e
}
defer resp.Body.Close()
raw, e := io.ReadAll(resp.Body)
if e != nil {
return nil, e
}
if resp.StatusCode == 401 || resp.StatusCode == 403 {
return nil, ErrSessionInvalid
}
if resp.StatusCode >= 500 {
return nil, fmt.Errorf("yeeke http %d", resp.StatusCode)
}
var env struct {
Success bool `json:"success"`
Code int `json:"code"`
Message string `json:"message"`
Result json.RawMessage `json:"result"`
}
if e = json.Unmarshal(raw, &env); e != nil {
return nil, e
}
if !env.Success {
return nil, &APIError{Code: env.Code, Message: env.Message}
}
return env.Result, nil
}
// browserUserAgent mirrors the desktop Chrome the yeeke web client runs in.
const browserUserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
// APIError is a yeeke business failure (success=false). Message is yeeke's own
// short text (e.g. "验证码错误"); it never contains our credentials or token.
type APIError struct {
Code int
Message string
}
func (e *APIError) Error() string {
return fmt.Sprintf("yeeke request failed code=%d: %s", e.Code, safeMessage(e.Message))
}
// Is keeps the historical contract: an expired or missing login is reported as
// ErrSessionInvalid so callers can re-login.
func (e *APIError) Is(target error) bool {
if target != ErrSessionInvalid {
return false
}
return e.Code == 401 || strings.Contains(e.Message, "token") || strings.Contains(e.Message, "登录已过期") || strings.Contains(e.Message, "未登录") || strings.Contains(e.Message, "重新登录") || (strings.Contains(e.Message, "登录") && strings.Contains(e.Message, "失效"))
}
// isCaptchaRejected reports a wrong captcha, the only login failure worth
// retrying with a fresh image.
func (e *APIError) isCaptchaRejected() bool { return strings.Contains(e.Message, "验证码") }
func safeMessage(m string) string {
r := []rune(strings.TrimSpace(m))
if len(r) > 60 {
r = r[:60]
}
return string(r)
}
func (c *Client) FetchCaptcha(ctx context.Context) (*Captcha, error) {
q := url.Values{"_t": {strconv.FormatInt(time.Now().UnixMilli(), 10)}}
raw, e := c.do(ctx, http.MethodGet, "/agent-foreign/sys/randomImage", nil, q)
if e != nil {
return nil, e
}
// The live randomImage response carries the captcha key as "key" (seen in
// the HAR evidence for #336); "checkKey" is only the name the login body
// uses. Accept both so a future rename on either side still works.
var p struct {
Image string `json:"image"`
Key string `json:"key"`
CheckKey string `json:"checkKey"`
}
if e = json.Unmarshal(raw, &p); e != nil {
return nil, e
}
key := p.Key
if key == "" {
key = p.CheckKey
}
if key == "" {
return nil, fmt.Errorf("yeeke captcha response missing key")
}
s := p.Image
if i := strings.Index(s, ","); i >= 0 {
s = s[i+1:]
}
img, e := base64.StdEncoding.DecodeString(s)
if e != nil {
return nil, e
}
return &Captcha{Image: img, CheckKey: key, ContentType: "image/jpeg"}, nil
}
type LoginResult struct {
Token, UserID, Username string
ExpiresAt time.Time
}
type OCR interface {
Recognize(context.Context, []byte) (string, error)
}
// LoginWithOCR keeps captcha bytes in memory and never includes credentials or
// recognized text in returned errors. A fresh image is fetched for every try.
func (c *Client) LoginWithOCR(ctx context.Context, ocr OCR, username, password string, maxAttempts int) (*LoginResult, error) {
if ocr == nil {
return nil, fmt.Errorf("yeeke OCR unavailable")
}
if maxAttempts <= 0 || maxAttempts > 5 {
maxAttempts = 3
}
emptyOCR, captchaRejected := 0, 0
for i := 0; i < maxAttempts; i++ {
cap, e := c.FetchCaptcha(ctx)
if e != nil {
return nil, fmt.Errorf("yeeke 获取验证码失败: %w", e)
}
code, e := ocr.Recognize(ctx, cap.Image)
if e != nil {
return nil, fmt.Errorf("yeeke OCR unavailable")
}
code = strings.TrimSpace(code)
if code == "" {
emptyOCR++
continue
}
out, e := c.Login(ctx, username, password, code, cap.CheckKey)
if e == nil {
return out, nil
}
var apiErr *APIError
if errors.As(e, &apiErr) && apiErr.isCaptchaRejected() {
captchaRejected++
continue
}
// Anything else (wrong account/password, locked account, network) will
// not be fixed by another captcha; stop instead of burning attempts.
if errors.As(e, &apiErr) {
return nil, fmt.Errorf("yeeke 登录被拒绝: %s", safeMessage(apiErr.Message))
}
return nil, fmt.Errorf("yeeke 登录请求失败: %w", e)
}
return nil, fmt.Errorf("yeeke login failed after limited captcha attempts (attempts=%d, captcha_rejected=%d, ocr_empty=%d)", maxAttempts, captchaRejected, emptyOCR)
}
func (c *Client) Login(ctx context.Context, username, password, captcha, checkKey string) (*LoginResult, error) {
if username == "" || password == "" || captcha == "" || checkKey == "" {
return nil, fmt.Errorf("login fields required")
}
raw, e := c.do(ctx, http.MethodPost, "/agent-foreign/sys/login", map[string]any{"username": username, "password": password, "captcha": captcha, "checkKey": checkKey, "agentCode": "mmt", "remember_me": true}, nil)
if e != nil {
return nil, e
}
var p struct {
Token string `json:"token"`
UserInfo struct {
ID any `json:"id"`
Username string `json:"username"`
} `json:"userInfo"`
}
if e = json.Unmarshal(raw, &p); e != nil {
return nil, e
}
if p.Token == "" {
return nil, fmt.Errorf("yeeke login response missing token")
}
c.token = p.Token
return &LoginResult{Token: p.Token, UserID: fmt.Sprint(p.UserInfo.ID), Username: p.UserInfo.Username, ExpiresAt: time.Now().UTC().Add(24 * time.Hour)}, nil
}
func (c *Client) CheckSession(ctx context.Context) error {
// /shopee/user/info is what the web client itself calls after login (HAR);
// the earlier /sys/userInfo path does not exist and answered HTTP 500.
q := url.Values{"_t": {strconv.FormatInt(time.Now().UnixMilli(), 10)}}
_, e := c.do(ctx, http.MethodGet, "/agent-foreign/shopee/user/info", nil, q)
return e
}
type ReturnPage struct {
Records []ReturnPackage `json:"records"`
Total int `json:"total"`
Pages int `json:"pages"`
}
type Timestamp struct{ time.Time }
func (t *Timestamp) UnmarshalJSON(b []byte) error {
var s string
if json.Unmarshal(b, &s) != nil || s == "" {
return nil
}
for _, f := range []string{time.RFC3339, "2006-01-02 15:04:05", "2006-01-02"} {
// yeeke returns naive Beijing wall-clock times ("2026-09-21 14:13:29");
// reading them as UTC would shift every time by eight hours.
if x, e := time.ParseInLocation(f, s, beijing); e == nil {
t.Time = x
return nil
}
}
return nil
}
type ReturnPackage struct {
ID any `json:"id"`
Ordersn string `json:"ordersn"`
TrackingNo string `json:"trackingNo"`
ShopID any `json:"shopId"`
ShopName string `json:"shopName"`
WareCode string `json:"wareCode"`
WareHouse string `json:"wareHouse"`
WareName string `json:"wareName"`
Status any `json:"status"`
ClaimTime *Timestamp `json:"claimTime"`
CreateTime *Timestamp `json:"createTime"`
UpdateTime *Timestamp `json:"updateTime"`
DestroyDeadLine *Timestamp `json:"destroyDeadLine"`
Items []ReturnItem `json:"items"`
}
type ReturnItem struct {
ID any `json:"id"`
ItemID any `json:"itemId"`
VariationID any `json:"variationId"`
ItemName string `json:"itemName"`
VariationName string `json:"variationName"`
Image string `json:"image"`
Quantity FlexInt `json:"variationQuantityPurchased"`
}
// beijing is yeeke's server wall clock (fixed UTC+8, no DST).
var beijing = time.FixedZone("CST", 8*3600)
// FlexInt accepts a JSON number or a numeric string; yeeke sends
// variationQuantityPurchased as a string (e.g. "2", see HAR). Empty, null or
// non-numeric values decode as 0 instead of failing the whole page.
type FlexInt int64
func (f *FlexInt) UnmarshalJSON(b []byte) error {
s := strings.Trim(strings.TrimSpace(string(b)), `"`)
if s == "" || s == "null" {
*f = 0
return nil
}
n, err := strconv.ParseInt(s, 10, 64)
if err != nil {
*f = 0
return nil
}
*f = FlexInt(n)
return nil
}
func (c *Client) List(ctx context.Context, pageNo, pageSize int) (ReturnPage, error) {
// Same shape the web client posts (HAR): sort via column/order, filters as strings.
body := map[string]any{"pageNo": pageNo, "pageSize": pageSize, "claimFlag": "1", "status": "1", "relationFlag": "1", "column": "createTime", "order": "desc"}
raw, e := c.do(ctx, http.MethodPost, "/agent-foreign/packageClaimRec/relation/list", body, nil)
if e != nil {
return ReturnPage{}, e
}
var p ReturnPage
if e = json.Unmarshal(raw, &p); e != nil {
return p, e
}
return p, nil
}
@@ -1,260 +0,0 @@
package yeekeclient
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
func TestCaptchaLoginAndReadOnlyList(t *testing.T) {
s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch r.URL.Path {
case "/agent-foreign/sys/randomImage":
json.NewEncoder(w).Encode(map[string]any{"success": true, "result": map[string]string{"image": "data:image/jpg;base64,SGk=", "key": "k"}})
case "/agent-foreign/sys/login":
json.NewEncoder(w).Encode(map[string]any{"success": true, "result": map[string]any{"token": "opaque", "userInfo": map[string]any{"id": "u"}}})
case "/agent-foreign/packageClaimRec/relation/list":
if r.Header.Get("X-Access-Token") != "opaque" {
t.Errorf("X-Access-Token header missing")
}
if r.URL.Query().Get("token") != "" {
t.Errorf("token must not be sent in the URL")
}
var body map[string]any
_ = json.NewDecoder(r.Body).Decode(&body)
if body["column"] != "createTime" || body["claimFlag"] != "1" || body["status"] != "1" || body["relationFlag"] != "1" || body["order"] != "desc" {
t.Errorf("list body shape differs from the web client: %v", body)
}
json.NewEncoder(w).Encode(map[string]any{"success": true, "result": map[string]any{"records": []any{}, "total": 0}})
default:
http.NotFound(w, r)
}
}))
defer s.Close()
c, _ := New(s.URL)
cap, e := c.FetchCaptcha(context.Background())
if e != nil || string(cap.Image) != "Hi" || cap.CheckKey != "k" {
t.Fatalf("captcha=%+v err=%v", cap, e)
}
if _, e = c.Login(context.Background(), "u", "p", "1234", "k"); e != nil {
t.Fatal(e)
}
if _, e = c.List(context.Background(), 1, 10); e != nil {
t.Fatal(e)
}
}
// The live randomImage response names the captcha key "key" (HAR evidence for
// #336). A client that only read "checkKey" sent an empty checkKey, never
// reached the login endpoint, and every OCR attempt "failed". This pins the
// real response shape end to end: the key from randomImage must arrive as the
// login body's checkKey, together with remember_me.
func TestLoginWithOCRSendsCaptchaKeyFromRandomImage(t *testing.T) {
var loginBody map[string]any
s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch r.URL.Path {
case "/agent-foreign/sys/randomImage":
json.NewEncoder(w).Encode(map[string]any{"success": true, "code": 0, "result": map[string]string{"image": "data:image/jpg;base64,SGk=", "key": "0123456789abcdef0123456789abcdef"}})
case "/agent-foreign/sys/login":
_ = json.NewDecoder(r.Body).Decode(&loginBody)
json.NewEncoder(w).Encode(map[string]any{"success": true, "code": 200, "result": map[string]any{"token": "opaque", "userInfo": map[string]any{"id": "u"}}})
default:
http.NotFound(w, r)
}
}))
defer s.Close()
c, _ := New(s.URL)
if _, e := c.LoginWithOCR(context.Background(), fixedOCR("abcd"), "u", "p", 3); e != nil {
t.Fatalf("login: %v", e)
}
if loginBody == nil {
t.Fatal("login endpoint was never called")
}
if loginBody["checkKey"] != "0123456789abcdef0123456789abcdef" {
t.Fatalf("checkKey = %v, want the randomImage key", loginBody["checkKey"])
}
if loginBody["remember_me"] != true || loginBody["captcha"] != "abcd" || loginBody["agentCode"] != "mmt" {
t.Fatalf("unexpected login body shape: %v", keysOnly(loginBody))
}
}
func TestFetchCaptchaRejectsResponseWithoutKey(t *testing.T) {
s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{"success": true, "result": map[string]string{"image": "data:image/jpg;base64,SGk="}})
}))
defer s.Close()
c, _ := New(s.URL)
if _, e := c.FetchCaptcha(context.Background()); e == nil {
t.Fatal("expected an explicit error when the captcha key is missing")
}
}
type fixedOCR string
func (f fixedOCR) Recognize(context.Context, []byte) (string, error) { return string(f), nil }
func keysOnly(m map[string]any) []string {
out := make([]string, 0, len(m))
for k := range m {
out = append(out, k)
}
return out
}
// Mirrors demo/yeeke_demo.py and the HAR: browser user agent on every call and
// a _t cache-buster on randomImage.
func TestRequestsCarryBrowserUserAgentAndCaptchaTimestamp(t *testing.T) {
var captchaQuery, captchaUA, loginUA string
s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch r.URL.Path {
case "/agent-foreign/sys/randomImage":
captchaQuery, captchaUA = r.URL.RawQuery, r.UserAgent()
json.NewEncoder(w).Encode(map[string]any{"success": true, "code": 0, "result": map[string]string{"image": "data:image/jpg;base64,SGk=", "key": "k"}})
case "/agent-foreign/sys/login":
loginUA = r.UserAgent()
json.NewEncoder(w).Encode(map[string]any{"success": true, "code": 200, "result": map[string]any{"token": "opaque"}})
}
}))
defer s.Close()
c, _ := New(s.URL)
if _, e := c.LoginWithOCR(context.Background(), fixedOCR("abcd"), "u", "p", 3); e != nil {
t.Fatal(e)
}
if !strings.HasPrefix(captchaQuery, "_t=") {
t.Fatalf("randomImage query = %q, want _t timestamp", captchaQuery)
}
for _, ua := range []string{captchaUA, loginUA} {
if !strings.Contains(ua, "Chrome/") {
t.Fatalf("user agent = %q, want browser UA", ua)
}
}
}
// A wrong captcha is retried with a fresh image; a wrong password is not.
func TestLoginWithOCRRetriesOnlyCaptchaRejections(t *testing.T) {
cases := []struct {
name string
responses []map[string]any
wantOK bool
wantLogins int
wantErrSubstr string
}{
{
name: "captcha wrong then ok",
responses: []map[string]any{
{"success": false, "code": 500, "message": "验证码错误"},
{"success": true, "code": 200, "result": map[string]any{"token": "opaque"}},
},
wantOK: true, wantLogins: 2,
},
{
name: "wrong password stops immediately",
responses: []map[string]any{
{"success": false, "code": 500, "message": "用户名或密码错误"},
{"success": true, "code": 200, "result": map[string]any{"token": "opaque"}},
},
wantLogins: 1, wantErrSubstr: "用户名或密码错误",
},
{
name: "captcha always wrong",
responses: []map[string]any{
{"success": false, "code": 500, "message": "验证码错误"},
{"success": false, "code": 500, "message": "验证码错误"},
{"success": false, "code": 500, "message": "验证码错误"},
},
wantLogins: 3, wantErrSubstr: "captcha_rejected=3",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
logins := 0
s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch r.URL.Path {
case "/agent-foreign/sys/randomImage":
json.NewEncoder(w).Encode(map[string]any{"success": true, "code": 0, "result": map[string]string{"image": "data:image/jpg;base64,SGk=", "key": "k"}})
case "/agent-foreign/sys/login":
resp := tc.responses[logins]
logins++
json.NewEncoder(w).Encode(resp)
}
}))
defer s.Close()
c, _ := New(s.URL)
_, e := c.LoginWithOCR(context.Background(), fixedOCR("abcd"), "u", "S3cr3t-Do-Not-Leak", 3)
if (e == nil) != tc.wantOK {
t.Fatalf("err = %v, wantOK = %v", e, tc.wantOK)
}
if logins != tc.wantLogins {
t.Fatalf("login calls = %d, want %d", logins, tc.wantLogins)
}
if e != nil {
if !strings.Contains(e.Error(), tc.wantErrSubstr) {
t.Fatalf("err = %q, want it to contain %q", e, tc.wantErrSubstr)
}
if strings.Contains(e.Error(), "S3cr3t-Do-Not-Leak") || strings.Contains(e.Error(), "abcd") {
t.Fatalf("error leaks credentials or captcha text: %q", e)
}
}
})
}
}
func TestAPIErrorMapsExpiredLoginToSessionInvalid(t *testing.T) {
for msg, want := range map[string]bool{"token失效,请重新登录": true, "未登录": true, "登录已失效": true, "验证码错误": false, "用户名或密码错误": false} {
if got := errors.Is(&APIError{Code: 500, Message: msg}, ErrSessionInvalid); got != want {
t.Fatalf("%q: errors.Is(ErrSessionInvalid) = %v, want %v", msg, got, want)
}
}
if !errors.Is(&APIError{Code: 401}, ErrSessionInvalid) {
t.Fatal("code 401 must map to ErrSessionInvalid")
}
}
// Field types taken from the HAR list response (values are fake): quantity is
// a numeric string, ids are strings, many fields are null, and times are
// naive Beijing wall-clock strings.
func TestListDecodesWebClientResponseShape(t *testing.T) {
const page = `{"success":true,"code":200,"message":"操作成功!","result":{
"records":[{"id":"1000000000000000001","ordersn":"FAKE-ORDER-1","trackingNo":"FAKE-TRACK-1",
"shopId":"123","shopName":"示例店铺","wareCode":"W1","wareHouse":"A-01","wareName":"示例仓",
"status":"1","claimTime":"2026-09-21 14:13:29","createTime":"2026-09-21 14:13:29",
"updateTime":"2026-09-21 14:13:29","destroyDeadLine":"2026-10-21 14:13:29",
"airwayBill":null,"secondSaleOrdersn":null,"photoList":null,
"items":[{"id":null,"itemId":"48800000000","variationId":"435000000000","itemName":"示例商品",
"variationName":"黑色,L","image":"https://example.invalid/a.jpg","variationQuantityPurchased":"2",
"aneroidMarkup":false,"num":null},
{"id":null,"itemId":"48800000000","variationId":"435000000001","itemName":"示例商品",
"variationName":"黑色,XL","image":"","variationQuantityPurchased":null}]}],
"total":1,"size":20,"current":1,"pages":1,"searchCount":true}}`
s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(page))
}))
defer s.Close()
c, _ := New(s.URL)
c.SetToken("opaque")
p, e := c.List(context.Background(), 1, 20)
if e != nil {
t.Fatalf("list: %v", e)
}
if len(p.Records) != 1 || len(p.Records[0].Items) != 2 || p.Total != 1 || p.Pages != 1 {
t.Fatalf("unexpected page: %+v", p)
}
if p.Records[0].Items[0].Quantity != 2 || p.Records[0].Items[1].Quantity != 0 {
t.Fatalf("quantities = %d, %d", p.Records[0].Items[0].Quantity, p.Records[0].Items[1].Quantity)
}
got := p.Records[0].CreateTime.Time.UTC().Format(time.RFC3339)
if got != "2026-09-21T06:13:29Z" {
t.Fatalf("createTime = %s, want Beijing 14:13:29 = 06:13:29Z", got)
}
}
-42
View File
@@ -1,42 +0,0 @@
package yeekeclient
import (
"context"
"errors"
"time"
)
type Credentials struct{ Username, Password string }
// Connect restores and validates a cached session. Only an explicit invalid
// response deletes it; timeouts and 5xx preserve the usable cache.
func Connect(ctx context.Context, store *SessionStore, creds Credentials, baseURL string, ocr OCR, maxLogin int) (*Client, error) {
c, e := New(baseURL)
if e != nil {
return nil, e
}
s, e := store.Load(ctx, creds.Username, time.Now().UTC())
if e == nil {
if e = c.ImportCookiesJSON(s.CookiesJSON); e == nil {
c.SetToken(s.Token)
if e = c.CheckSession(ctx); e == nil {
return c, nil
} else if errors.Is(e, ErrSessionInvalid) {
_ = store.Delete(ctx, creds.Username)
} else {
return nil, e
}
}
} else if !errors.Is(e, ErrNoSession) {
return nil, e
}
r, e := c.LoginWithOCR(ctx, ocr, creds.Username, creds.Password, maxLogin)
if e != nil {
return nil, e
}
cookies, _ := c.ExportCookiesJSON()
if e = store.Save(ctx, Session{Username: creds.Username, Token: r.Token, UserID: r.UserID, CookiesJSON: cookies, ExpiresAt: r.ExpiresAt}); e != nil {
return nil, e
}
return c, nil
}
@@ -1,178 +0,0 @@
package yeekeclient
import (
"context"
"encoding/json"
"go-admin/app/goauto/models"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"net/http"
"net/http/httptest"
"testing"
"time"
)
func newDB(t *testing.T) *gorm.DB {
t.Helper()
db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if err := db.AutoMigrate(&models.YeekeSession{}); err != nil {
t.Fatal(err)
}
return db
}
type stubOCR struct {
codes []string
calls int
}
func (s *stubOCR) Recognize(context.Context, []byte) (string, error) {
if s.calls >= len(s.codes) {
return "", nil
}
c := s.codes[s.calls]
s.calls++
return c, nil
}
// server builds a fake yeeke backend. loginOK controls whether /login accepts
// the submitted captcha; sessionValid controls whether /userInfo (used by
// CheckSession) reports the cached token as still good.
func fakeServer(t *testing.T, loginOK func(captcha string) bool, sessionValid func(token string) bool) *httptest.Server {
t.Helper()
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch r.URL.Path {
case "/agent-foreign/sys/randomImage":
json.NewEncoder(w).Encode(map[string]any{"success": true, "result": map[string]string{"image": "data:image/jpg;base64,SGk=", "key": "k"}})
case "/agent-foreign/sys/login":
var body map[string]any
json.NewDecoder(r.Body).Decode(&body)
captcha, _ := body["captcha"].(string)
if loginOK(captcha) {
json.NewEncoder(w).Encode(map[string]any{"success": true, "result": map[string]any{"token": "tok-" + captcha, "userInfo": map[string]any{"id": "u1", "username": "u"}}})
return
}
json.NewEncoder(w).Encode(map[string]any{"success": false, "code": 1, "message": "验证码错误"})
case "/agent-foreign/shopee/user/info":
token := r.Header.Get("X-Access-Token")
if sessionValid(token) {
json.NewEncoder(w).Encode(map[string]any{"success": true, "result": map[string]any{}})
return
}
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]any{"success": false, "code": 401, "message": "登录已失效"})
default:
http.NotFound(w, r)
}
}))
}
// TestConnectLoginsOnceThenReusesSession: a fresh Connect performs exactly one
// login, and a second Connect call with the cached session valid performs no
// login at all (token reuse, no re-login when session valid).
func TestConnectLoginsOnceThenReusesSession(t *testing.T) {
db := newDB(t)
loginCalls := 0
srv := fakeServer(t,
func(captcha string) bool { loginCalls++; return captcha == "abcd" },
func(token string) bool { return token == "tok-abcd" },
)
defer srv.Close()
store := NewSessionStore(db)
ocr := &stubOCR{codes: []string{"abcd"}}
client, err := Connect(context.Background(), store, Credentials{Username: "u", Password: "p"}, srv.URL, ocr, 3)
if err != nil {
t.Fatal(err)
}
if client.Token() != "tok-abcd" {
t.Fatalf("token=%q", client.Token())
}
if loginCalls != 1 {
t.Fatalf("loginCalls=%d, want 1", loginCalls)
}
// Second connect: session is cached and still valid, so this must not
// touch OCR or /login again.
ocr2 := &stubOCR{codes: []string{"should-not-be-used"}}
client2, err := Connect(context.Background(), store, Credentials{Username: "u", Password: "p"}, srv.URL, ocr2, 3)
if err != nil {
t.Fatal(err)
}
if client2.Token() != "tok-abcd" {
t.Fatalf("reused token=%q", client2.Token())
}
if loginCalls != 1 {
t.Fatalf("loginCalls after reuse=%d, want still 1", loginCalls)
}
if ocr2.calls != 0 {
t.Fatalf("OCR must not be called when the cached session is valid")
}
}
// TestConnectReLoginsAfterSessionExpiredAndIsBounded: when the cached session
// is explicitly rejected (ErrSessionInvalid), Connect re-logs in — but only
// up to maxLogin captcha attempts, never looping forever.
func TestConnectReLoginsAfterSessionExpiredAndIsBounded(t *testing.T) {
db := newDB(t)
store := NewSessionStore(db)
// Seed an already-cached, not-yet-expired session so Connect's Load finds
// it and only CheckSession decides it is dead.
if err := store.Save(context.Background(), Session{
Username: "u", Token: "stale", CookiesJSON: `[]`, UserID: "u1",
ExpiresAt: time.Now().UTC().Add(time.Hour),
}); err != nil {
t.Fatal(err)
}
loginAttempts := 0
srv := fakeServer(t,
func(captcha string) bool { loginAttempts++; return false }, // every captcha rejected
func(token string) bool { return false }, // cached session always invalid
)
defer srv.Close()
ocr := &stubOCR{codes: []string{"1", "2", "3", "4", "5", "6"}} // more codes than maxLogin allows
_, err := Connect(context.Background(), store, Credentials{Username: "u", Password: "p"}, srv.URL, ocr, 3)
if err == nil {
t.Fatal("expected login failure")
}
if loginAttempts != 3 {
t.Fatalf("loginAttempts=%d, want exactly maxLogin=3 (bounded, not endless)", loginAttempts)
}
// The rejected cached session must have been deleted, not left in place.
if _, loadErr := store.Load(context.Background(), "u", time.Now().UTC()); loadErr != ErrNoSession {
t.Fatalf("expired/invalid session should have been deleted: %v", loadErr)
}
}
// TestConnectKeepsCachedSessionOnTimeoutOrServerError: a network-level error
// checking the session (not an explicit "invalid") must not discard a
// possibly-still-good cached session.
func TestConnectKeepsCachedSessionOnTimeoutOrServerError(t *testing.T) {
db := newDB(t)
store := NewSessionStore(db)
if err := store.Save(context.Background(), Session{
Username: "u", Token: "tok", CookiesJSON: `[]`, UserID: "u1",
ExpiresAt: time.Now().UTC().Add(time.Hour),
}); err != nil {
t.Fatal(err)
}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
}))
defer srv.Close()
_, err := Connect(context.Background(), store, Credentials{Username: "u", Password: "p"}, srv.URL, &stubOCR{}, 3)
if err == nil {
t.Fatal("expected a propagated 5xx error")
}
if _, loadErr := store.Load(context.Background(), "u", time.Now().UTC()); loadErr != nil {
t.Fatalf("a 5xx must not discard the cached session: %v", loadErr)
}
}
-37
View File
@@ -1,37 +0,0 @@
package yeekeclient
import (
"context"
"errors"
"go-admin/app/goauto/models"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"strings"
"time"
)
type SessionStore struct{ db *gorm.DB }
func NewSessionStore(db *gorm.DB) *SessionStore { return &SessionStore{db: db} }
func (s *SessionStore) Save(ctx context.Context, x Session) error {
if strings.TrimSpace(x.Username) == "" || x.Token == "" || x.ExpiresAt.IsZero() {
return errors.New("invalid yeeke session")
}
return s.db.WithContext(ctx).Clauses(clause.OnConflict{Columns: []clause.Column{{Name: "username"}}, DoUpdates: clause.AssignmentColumns([]string{"token", "cookies_json", "user_id", "expires_at", "updated_at"})}).Create(&models.YeekeSession{Username: strings.TrimSpace(x.Username), Token: x.Token, CookiesJSON: x.CookiesJSON, UserID: x.UserID, ExpiresAt: x.ExpiresAt.UTC()}).Error
}
func (s *SessionStore) Load(ctx context.Context, user string, now time.Time) (Session, error) {
var r models.YeekeSession
if e := s.db.WithContext(ctx).Where("username = ?", strings.TrimSpace(user)).First(&r).Error; e != nil {
if errors.Is(e, gorm.ErrRecordNotFound) {
return Session{}, ErrNoSession
}
return Session{}, e
}
if !now.UTC().Before(r.ExpiresAt) {
return Session{}, ErrNoSession
}
return Session{Username: r.Username, Token: r.Token, CookiesJSON: r.CookiesJSON, UserID: r.UserID, ExpiresAt: r.ExpiresAt}, nil
}
func (s *SessionStore) Delete(ctx context.Context, user string) error {
return s.db.WithContext(ctx).Where("username = ?", strings.TrimSpace(user)).Delete(&models.YeekeSession{}).Error
}
-2
View File
@@ -6,7 +6,6 @@ import (
"go-admin/app/goauto/shopeeproduct"
"go-admin/app/goauto/sybimport"
"go-admin/app/goauto/yeeke"
)
// InitJob
@@ -18,7 +17,6 @@ func InitJob() {
sybimport.HourlySyncInvokeTarget: sybimport.HourlySyncJob{},
sybimport.SpecAIParseInvokeTarget: sybimport.ScheduledSpecAIParseJob{},
shopeeproduct.SpecAutoMatchInvokeTarget: shopeeproduct.ScheduledAutoMatchJob{},
yeeke.ReturnSyncInvokeTarget: yeeke.ReturnSyncJob{},
// ...
}
}
@@ -1,22 +0,0 @@
package version_local
import (
"go-admin/app/goauto/migrations"
"go-admin/cmd/migrate/migration"
common "go-admin/common/models"
"gorm.io/gorm"
"runtime"
)
func init() {
_, f, _, _ := runtime.Caller(0)
migration.Migrate.SetVersion(migration.GetFilename(f), migrateYeekeReturnSync)
}
func migrateYeekeReturnSync(db *gorm.DB, version string) error {
return db.Transaction(func(tx *gorm.DB) error {
if err := migrations.Migrate(tx); err != nil {
return err
}
return tx.Create(&common.Migration{Version: version}).Error
})
}
@@ -1,49 +0,0 @@
package version_local
import (
"errors"
"runtime"
"go-admin/app/goauto/yeeke"
jobsmodels "go-admin/app/jobs/models"
"go-admin/cmd/migrate/migration"
common "go-admin/common/models"
"gorm.io/gorm"
)
func init() {
_, fileName, _, _ := runtime.Caller(0)
migration.Migrate.SetVersion(migration.GetFilename(fileName), migrateYeekeReturnSyncJob)
}
// migrateYeekeReturnSyncJob seeds the scheduled yeeke return sync job row,
// disabled by default (#336 requirement: 默认关闭定时任务,管理员手动开启).
// It follows 1786701600000_syb_hourly_sync_job.go exactly: Status 2 keeps the
// row out of the running cron set (see app/jobs/service/sys_job.go, which
// only adds Status == 1 jobs to the cron at startup).
func migrateYeekeReturnSyncJob(db *gorm.DB, version string) error {
return db.Transaction(func(tx *gorm.DB) error {
if err := ensureYeekeReturnSyncJob(tx); err != nil {
return err
}
return tx.Create(&common.Migration{Version: version}).Error
})
}
func ensureYeekeReturnSyncJob(db *gorm.DB) error {
var existing jobsmodels.SysJob
err := db.Where("invoke_target = ?", yeeke.ReturnSyncInvokeTarget).First(&existing).Error
if err == nil {
return nil
}
if !errors.Is(err, gorm.ErrRecordNotFound) {
return err
}
return db.Create(&jobsmodels.SysJob{
JobName: "yeeke 退货包裹同步", JobGroup: "GoAuto", JobType: 2,
CronExpression: "0 20 * * * *", InvokeTarget: yeeke.ReturnSyncInvokeTarget,
Args: "",
MisfirePolicy: 1, Concurrent: 1, Status: 2,
}).Error
}
@@ -1,90 +0,0 @@
package version_local
import (
"fmt"
"runtime"
"go-admin/app/goauto/access"
"go-admin/cmd/migrate/migration"
migrationmodels "go-admin/cmd/migrate/migration/models"
common "go-admin/common/models"
"gorm.io/gorm"
)
func init() {
_, file, _, _ := runtime.Caller(0)
migration.Migrate.SetVersion(migration.GetFilename(file), migrateYeekeAdminMenu)
}
// migrateYeekeAdminMenu adds the 「yeeke 退货包裹」 and 「yeeke 同步记录」 menu
// entries for #337. #336 registered access.ModuleYeekeReturns and shipped the
// sync backend, but never wrote its sys_menu row or web page, so Admin had no
// entry point; #337 also splits the sync-run history into its own module
// (access.ModuleYeekeSyncRuns), matching the existing SYB 商品 / SYB 同步记录
// split.
//
// `[必须]` Adding a module to access.GoAutoModules() is not enough to make its
// page reachable. Menus are written by the versioned migration that first
// created them, and that version is already recorded in sys_migration on every
// existing database — it never runs again. Startup only reconciles the casbin
// API matrix (access.ReconcilePurchaserPermissions), not sys_menu. Without this
// migration the pages exist, the APIs answer and the routes resolve, yet
// nobody can navigate to them on an already-migrated database. This mirrors
// 1789113000000_syb_product_filter_menu.go exactly.
func migrateYeekeAdminMenu(db *gorm.DB, version string) error {
return db.Transaction(func(tx *gorm.DB) error {
// Both pages belong beside the SYB modules under 采集采购, where the
// admin already manages inbound collection/purchase data sources.
var parent migrationmodels.SysMenu
if err := tx.Where("menu_name = ?", "GoAutoCollectionPurchase").First(&parent).Error; err != nil {
return err
}
for _, key := range []string{access.ModuleYeekeReturns, access.ModuleYeekeSyncRuns} {
module, ok := goAutoModuleByKey(key)
if !ok {
return fmt.Errorf("module %q is not registered in access.GoAutoModules()", key)
}
child, _, err := upsertGoAutoMenu(tx, migrationmodels.SysMenu{
MenuName: module.RouteName,
Title: module.Title,
Icon: module.Icon,
Path: module.Path + "/index",
MenuType: "C",
Action: "无",
ParentId: parent.MenuId,
Component: module.Component,
Sort: module.Sort,
Visible: "0",
IsFrame: "1",
})
if err != nil {
return err
}
if err = tx.Model(&child).Update("paths", fmt.Sprintf("/0/%d/%d", parent.MenuId, child.MenuId)).Error; err != nil {
return err
}
// Bind to both roles the module declares. 采购员 needs it because the
// module is PurchaserDefault; 管理员 needs it so it can always see and
// manually trigger the read-only sync surface.
roleKeys := []string{"admin"}
if module.PurchaserDefault {
roleKeys = append(roleKeys, access.RolePurchaser)
}
for _, roleKey := range roleKeys {
var role migrationmodels.SysRole
if err = tx.Where("role_key = ?", roleKey).First(&role).Error; err != nil {
return err
}
if err = tx.Model(&role).Association("SysMenu").Append(&child); err != nil {
return err
}
}
}
return tx.Create(&common.Migration{Version: version}).Error
})
}
+2 -69
View File
@@ -18,9 +18,8 @@ var ExtConfig Extend
//
// 使用方法: config.ExtConfig......即可!!
type Extend struct {
AMap AMap // 这里配置对应配置文件的结构即可
SYB SYB
Yeeke Yeeke
AMap AMap // 这里配置对应配置文件的结构即可
SYB SYB
}
type AMap struct {
@@ -90,65 +89,6 @@ func (s SYB) HasCredentials() bool {
return strings.TrimSpace(s.Username) != "" && s.Password != ""
}
// Yeeke holds the mmt.yeeke.com 退货包裹只读对接 connection settings (#336).
//
// `[必须]` Username and Password are NOT read from settings.yml — same rule as
// SYB above — they come from GOAUTO_YEEKE_USERNAME / GOAUTO_YEEKE_PASSWORD (or
// config.yaml's yeeke: section) so no credential ever lands in a tracked file.
type Yeeke struct {
BaseURL string
Username string
Password string
PageSize int
MaxPages int
Retry int
// OcrURL is the captcha recognition service shared with SYB (#336, approved
// 2026-09-23). Empty disables OCR; there is no manual-entry fallback here
// because this is a server-side scheduled/triggered flow, not an interactive
// login form, so a disabled OCR simply makes sync fail with a clear error.
OcrURL string
OcrMaxAttempts int
}
// YeekeDefaults are the values used when settings.yml leaves a field blank.
const (
DefaultYeekeBaseURL = "https://mmt.yeeke.com"
DefaultYeekePageSize = 100
DefaultYeekeMaxPages = 10000
DefaultYeekeRetry = 2
DefaultYeekeOcrMaxAttempts = 5
)
// Resolved returns the Yeeke settings with blanks replaced by defaults. It
// never defaults Username or Password: missing credentials must surface as an
// error at the call site, not as an attempt to log in as nobody.
func (y Yeeke) Resolved() Yeeke {
if strings.TrimSpace(y.BaseURL) == "" {
y.BaseURL = DefaultYeekeBaseURL
}
if y.PageSize <= 0 {
y.PageSize = DefaultYeekePageSize
}
if y.MaxPages <= 0 {
y.MaxPages = DefaultYeekeMaxPages
}
if y.Retry < 0 {
y.Retry = DefaultYeekeRetry
}
if y.OcrMaxAttempts <= 0 {
y.OcrMaxAttempts = DefaultYeekeOcrMaxAttempts
}
y.Username = strings.TrimSpace(y.Username)
y.BaseURL = strings.TrimRight(strings.TrimSpace(y.BaseURL), "/")
y.OcrURL = strings.TrimSpace(y.OcrURL)
return y
}
// HasCredentials reports whether both account fields were supplied.
func (y Yeeke) HasCredentials() bool {
return strings.TrimSpace(y.Username) != "" && y.Password != ""
}
// ApplyEnvironment replaces tracked defaults with process-local runtime values.
// Credentials stay outside tracked configuration files. GOAUTO_DB_DRIVER
// defaults to mysql when GOAUTO_DB_DSN is present.
@@ -164,13 +104,6 @@ func ApplyEnvironment() {
if password := os.Getenv("GOAUTO_SYB_PASSWORD"); password != "" {
ExtConfig.SYB.Password = password
}
if username := strings.TrimSpace(os.Getenv("GOAUTO_YEEKE_USERNAME")); username != "" {
ExtConfig.Yeeke.Username = username
}
// `[必须]` Taken verbatim, same reasoning as GOAUTO_SYB_PASSWORD above.
if password := os.Getenv("GOAUTO_YEEKE_PASSWORD"); password != "" {
ExtConfig.Yeeke.Password = password
}
dsn := strings.TrimSpace(os.Getenv("GOAUTO_DB_DSN"))
if dsn == "" {
-40
View File
@@ -62,45 +62,5 @@ func TestSettingsFilesCarryNoSYBCredentials(t *testing.T) {
if file.Settings.Extend.SYB.Username != "" || file.Settings.Extend.SYB.Password != "" {
t.Fatalf("%s 里出现了顺云宝凭据,凭据必须走环境变量", name)
}
if file.Settings.Extend.Yeeke.Username != "" || file.Settings.Extend.Yeeke.Password != "" {
t.Fatalf("%s 里出现了 yeeke 凭据,凭据必须走环境变量 (#336)", name)
}
}
}
// settings.yml 里的 extend.yeeke 必须真的能绑进 ExtConfig.Yeeke,同样的道理
// 见上面 TestSYBSettingsInRepoBindToExtendStruct 的注释 (#336)。
func TestYeekeSettingsInRepoBindToExtendStruct(t *testing.T) {
raw, err := os.ReadFile("settings.yml")
if err != nil {
t.Fatalf("读取 settings.yml 失败: %v", err)
}
var file struct {
Settings struct {
Extend Extend `yaml:"extend"`
} `yaml:"settings"`
}
if err := yaml.Unmarshal(raw, &file); err != nil {
t.Fatalf("解析 settings.yml 失败: %v", err)
}
yeeke := file.Settings.Extend.Yeeke
if yeeke.BaseURL != "https://mmt.yeeke.com" {
t.Fatalf("baseurl 没有绑定成功: %q", yeeke.BaseURL)
}
if yeeke.PageSize != 100 {
t.Fatalf("pagesize 没有绑定成功: %d", yeeke.PageSize)
}
if yeeke.MaxPages != 10000 {
t.Fatalf("maxpages 没有绑定成功: %d", yeeke.MaxPages)
}
if yeeke.Retry != 2 {
t.Fatalf("retry 没有绑定成功: %d", yeeke.Retry)
}
if yeeke.OcrURL == "" {
t.Fatal("ocrurl 没有绑定成功")
}
if yeeke.OcrMaxAttempts != 5 {
t.Fatalf("ocrmaxattempts 没有绑定成功: %d", yeeke.OcrMaxAttempts)
}
}
-55
View File
@@ -74,61 +74,6 @@ func TestApplyEnvironmentLoadsSYBCredentials(t *testing.T) {
}
}
// 同上,yeeke 凭据也只能来自环境变量 (#336)。
func TestApplyEnvironmentLoadsYeekeCredentials(t *testing.T) {
original := ExtConfig.Yeeke
t.Cleanup(func() { ExtConfig.Yeeke = original })
t.Setenv("GOAUTO_YEEKE_USERNAME", " operator ")
t.Setenv("GOAUTO_YEEKE_PASSWORD", " se cret ")
ApplyEnvironment()
if ExtConfig.Yeeke.Username != "operator" {
t.Fatalf("账号应去掉首尾空白: %q", ExtConfig.Yeeke.Username)
}
if ExtConfig.Yeeke.Password != " se cret " {
t.Fatalf("密码不应被修改: %q", ExtConfig.Yeeke.Password)
}
}
func TestYeekeResolvedFillsBlanksButNeverInventsCredentials(t *testing.T) {
resolved := Yeeke{}.Resolved()
if resolved.BaseURL != DefaultYeekeBaseURL {
t.Fatalf("BaseURL 默认值不对: %q", resolved.BaseURL)
}
if resolved.PageSize != DefaultYeekePageSize || resolved.MaxPages != DefaultYeekeMaxPages {
t.Fatalf("分页默认值不对: %+v", resolved)
}
if resolved.OcrMaxAttempts != DefaultYeekeOcrMaxAttempts {
t.Fatalf("OCR 重试次数默认值不对: %d", resolved.OcrMaxAttempts)
}
if resolved.Username != "" || resolved.Password != "" {
t.Fatal("Resolved 不得给账号密码编造默认值")
}
if resolved.OcrURL != "" {
t.Fatalf("空 OcrURL 不应被填充: %q", resolved.OcrURL)
}
}
func TestYeekeHasCredentialsRequiresBothFields(t *testing.T) {
for _, c := range []struct {
name string
yeeke Yeeke
want bool
}{
{"都有", Yeeke{Username: "a", Password: "b"}, true},
{"缺密码", Yeeke{Username: "a"}, false},
{"缺账号", Yeeke{Password: "b"}, false},
{"账号只有空白", Yeeke{Username: " ", Password: "b"}, false},
{"都没有", Yeeke{}, false},
} {
if got := c.yeeke.HasCredentials(); got != c.want {
t.Fatalf("%s: 期望 %v,实际 %v", c.name, c.want, got)
}
}
}
func TestSYBResolvedFillsBlanksButNeverInventsCredentials(t *testing.T) {
resolved := SYB{}.Resolved()
-42
View File
@@ -65,7 +65,6 @@ type localFile struct {
Database map[string]any `yaml:"database"`
Ports map[string]any `yaml:"ports"`
SYB map[string]any `yaml:"syb"`
Yeeke map[string]any `yaml:"yeeke"`
}
// ApplyLocalConfig loads config.yaml, if one is present, over the values
@@ -97,7 +96,6 @@ func ApplyLocalConfig() {
applyLocalDatabase(file.Database)
applyLocalPorts(file.Ports)
ApplyLocalSYB(file.SYB)
ApplyLocalYeeke(file.Yeeke)
logInfo("本地配置:已加载 %s", path)
}
@@ -117,17 +115,6 @@ func LogEffectiveConfig() {
}
logInfo("顺云宝配置:凭据=%v(来源:%s)base_url=%s 验证码识别=%v",
syb.HasCredentials(), source, syb.BaseURL, syb.OcrURL != "")
yeeke := ExtConfig.Yeeke.Resolved()
yeekeSource := "未配置"
switch {
case strings.TrimSpace(os.Getenv("GOAUTO_YEEKE_USERNAME")) != "":
yeekeSource = "环境变量"
case yeeke.HasCredentials():
yeekeSource = LocalConfigName
}
logInfo("yeeke 配置:凭据=%v(来源:%s)base_url=%s 验证码识别=%v",
yeeke.HasCredentials(), yeekeSource, yeeke.BaseURL, yeeke.OcrURL != "")
}
func applyLocalDatabase(database map[string]any) {
@@ -190,35 +177,6 @@ func ApplyLocalSYB(syb map[string]any) {
}
}
// ApplyLocalYeeke folds a config.yaml `yeeke:` section into ExtConfig, mirroring
// ApplyLocalSYB above (#336).
func ApplyLocalYeeke(yeeke map[string]any) {
if username := strings.TrimSpace(scalar(yeeke, "username")); username != "" {
ExtConfig.Yeeke.Username = username
}
if password := scalar(yeeke, "password"); password != "" {
ExtConfig.Yeeke.Password = password
}
if baseURL := strings.TrimSpace(scalar(yeeke, "base_url")); baseURL != "" {
ExtConfig.Yeeke.BaseURL = baseURL
}
if ocrURL := strings.TrimSpace(scalar(yeeke, "ocr_url")); ocrURL != "" {
ExtConfig.Yeeke.OcrURL = ocrURL
}
if pageSize, err := strconv.Atoi(scalar(yeeke, "page_size")); err == nil && pageSize > 0 {
ExtConfig.Yeeke.PageSize = pageSize
}
if maxPages, err := strconv.Atoi(scalar(yeeke, "max_pages")); err == nil && maxPages > 0 {
ExtConfig.Yeeke.MaxPages = maxPages
}
if retry, err := strconv.Atoi(scalar(yeeke, "retry")); err == nil && retry >= 0 {
ExtConfig.Yeeke.Retry = retry
}
if attempts, err := strconv.Atoi(scalar(yeeke, "ocr_max_attempts")); err == nil && attempts > 0 {
ExtConfig.Yeeke.OcrMaxAttempts = attempts
}
}
// logInfo writes an informational startup line to stdout.
//
// `[必须]` Not stderr. The development launcher pipes the server through
-11
View File
@@ -61,17 +61,6 @@ settings:
# `[必须]` 验证码图片会被发送到这个地址;换成别人运营的服务前要重新评估。
ocrurl: https://ocr.ilapage.cn/ocr
ocrmaxattempts: 5
# yeeke(mmt.yeeke.com)退货包裹只读对接,见 #336。
# `[必须]` 账号密码不在这里,走环境变量 GOAUTO_YEEKE_USERNAME / GOAUTO_YEEKE_PASSWORD,
# 以免凭据进 Git。
yeeke:
baseurl: https://mmt.yeeke.com
pagesize: 100
maxpages: 10000
retry: 2
# 验证码识别服务,与 SYB 共用(#336 已批准)。
ocrurl: https://ocr.ilapage.cn/ocr
ocrmaxattempts: 5
cache:
# redis:
# addr: 127.0.0.1:6379
-29
View File
@@ -1,29 +0,0 @@
import request from '@/utils/request'
export function listYeekeReturnPackages(params, options = {}) {
return request({ url: '/api/admin/v1/yeeke-returns', method: 'get', params, ...options })
}
export function getYeekeReturnPackage(packageId) {
return request({ url: `/api/admin/v1/yeeke-returns/${packageId}`, method: 'get' })
}
export function listYeekeReturnShops(options = {}) {
return request({ url: '/api/admin/v1/yeeke-returns/shops', method: 'get', ...options })
}
export function getYeekeReturnItem(itemId) {
return request({ url: `/api/admin/v1/yeeke-returns/items/${itemId}`, method: 'get' })
}
export function listYeekeSyncRuns(params, options = {}) {
return request({ url: '/api/admin/v1/yeeke-returns/sync-runs', method: 'get', params, ...options })
}
export function getYeekeSyncRun(runId) {
return request({ url: `/api/admin/v1/yeeke-returns/sync-runs/${runId}`, method: 'get' })
}
export function triggerYeekeSync() {
return request({ url: '/api/admin/v1/yeeke-returns/sync', method: 'post' })
}
@@ -1,248 +0,0 @@
<template>
<BasicLayout>
<template #wrapper>
<el-card class="page-card" shadow="never">
<el-alert v-if="summaryError" :title="summaryError" type="warning" show-icon :closable="false" class="notice" />
<div v-else class="summary-bar" aria-live="polite">
<span>最后成功同步:<strong>{{ formatTime(summary.lastSuccessAt) }}</strong></span>
<span v-if="summary.lastRun">读取 {{ summary.lastRun.readCount }} / 新增 {{ summary.lastRun.createdCount }} / 更新 {{ summary.lastRun.updatedCount }} / 跳过 {{ summary.lastRun.skippedCount }} / 失败 {{ summary.lastRun.failedCount }}</span>
<span v-if="summary.lastRun"><el-tag :type="statusMeta(summary.lastRun.status).type" size="small">{{ statusMeta(summary.lastRun.status).label }}</el-tag></span>
</div>
<el-form :model="query" :inline="true" class="search-form" @submit.prevent="search">
<el-form-item label="订单号"><el-input v-model="query.orderSn" clearable placeholder="订单号" style="width:150px" /></el-form-item>
<el-form-item label="店铺">
<el-select v-model="query.shop" clearable filterable placeholder="全部店铺" style="width:150px">
<el-option v-for="name in shopOptions" :key="name" :label="name" :value="name" />
</el-select>
</el-form-item>
<el-form-item label="认领状态">
<el-select v-model="query.claimStatus" clearable placeholder="全部" style="width:130px">
<el-option v-for="item in claimStatusOptions" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
</el-form-item>
<el-form-item label="创建时间"><el-date-picker v-model="createTimeRange" type="daterange" value-format="YYYY-MM-DD" range-separator="至" start-placeholder="开始日期" end-placeholder="结束日期" style="width:230px" /></el-form-item>
<el-form-item><el-button type="primary" :icon="Search" @click="search">查询</el-button><el-button :icon="RefreshLeft" @click="reset">重置</el-button></el-form-item>
</el-form>
<el-alert v-if="loadError" :title="loadError" type="error" show-icon :closable="false" class="notice"><template #default><el-button link type="primary" @click="load">重新加载</el-button></template></el-alert>
<el-table v-loading="loading" :data="items" border stripe row-key="key" empty-text="暂无退货商品">
<el-table-column label="订单号" prop="orderSn" min-width="130" />
<el-table-column label="商品参考图" width="76">
<template #default="{ row }">
<el-image v-if="row.hasItem && row.image" :src="row.image" fit="cover" class="thumb" :preview-src-list="[row.image]" preview-teleported />
<div v-else-if="row.hasItem" class="thumb placeholder">无图</div>
<span v-else>—</span>
</template>
</el-table-column>
<el-table-column label="商品标题" min-width="220">
<template #default="{ row }">
<template v-if="row.hasItem">{{ row.itemName || '—' }}</template>
<span v-else class="muted">(无商品明细)</span>
</template>
</el-table-column>
<el-table-column label="蝦皮id / sku_id" min-width="140">
<template #default="{ row }">
<template v-if="row.hasItem">
{{ row.shopeeItemId || '—' }}
<div class="muted">{{ row.variationId || '—' }}</div>
</template>
<span v-else>—</span>
</template>
</el-table-column>
<el-table-column label="数量" width="70">
<template #default="{ row }">{{ row.hasItem ? row.quantity : '—' }}</template>
</el-table-column>
<el-table-column label="店铺" min-width="120"><template #default="{ row }">{{ row.shopName || '—' }}</template></el-table-column>
<el-table-column label="规格数据" min-width="180"><template #default="{ row }">{{ row.variationName || '—' }}</template></el-table-column>
<el-table-column label="认领状态" min-width="120">
<template #default="{ row }">
<el-tag v-if="row.statusUnrecognized" type="danger" effect="plain">未知:{{ row.claimStatus || '—' }}</el-tag>
<el-tag v-else :type="claimStatusMeta(row.claimStatus).type">{{ claimStatusMeta(row.claimStatus).label }}</el-tag>
</template>
</el-table-column>
<el-table-column label="上架时间" min-width="150"><template #default="{ row }">{{ formatTime(row.claimTime) }}</template></el-table-column>
<el-table-column label="销毁截止" min-width="150"><template #default="{ row }">{{ formatTime(row.destroyDeadLine) }}</template></el-table-column>
<el-table-column label="最近同步" min-width="150"><template #default="{ row }">{{ formatTime(row.lastSyncedAt) }}</template></el-table-column>
<el-table-column label="操作" width="90" fixed="right"><template #default="{ row }"><el-button type="primary" link @click="openDetail(row)">详情</el-button></template></el-table-column>
</el-table>
<pagination v-show="total > 0" v-model:current-page="query.page" v-model:page-size="query.pageSize" :total="total" @pagination="load" />
</el-card>
<el-drawer v-model="detail.open" title="退货商品详情" size="720px">
<div v-loading="detail.loading" class="drawer-body">
<template v-if="detail.placeholder && detail.pkg">
<el-alert title="该包裹无商品明细" type="info" show-icon :closable="false" class="notice" />
<h3 class="section-title">所属退货包裹</h3>
<el-descriptions :column="2" border size="small">
<el-descriptions-item label="订单号">{{ detail.pkg.orderSn }}</el-descriptions-item>
<el-descriptions-item label="店铺">{{ detail.pkg.shopName || detail.pkg.shopId || '—' }}</el-descriptions-item>
<el-descriptions-item label="仓库/库位">{{ detail.pkg.wareHouse || detail.pkg.wareCode || '—' }}</el-descriptions-item>
<el-descriptions-item label="认领状态">
<el-tag v-if="detail.pkg.statusUnrecognized" type="danger" effect="plain">未知:{{ detail.pkg.claimStatus || '—' }}</el-tag>
<el-tag v-else :type="claimStatusMeta(detail.pkg.claimStatus).type">{{ claimStatusMeta(detail.pkg.claimStatus).label }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="认领时间">{{ formatTime(detail.pkg.claimTime) }}</el-descriptions-item>
<el-descriptions-item label="创建时间">{{ formatTime(detail.pkg.createTime) }}</el-descriptions-item>
<el-descriptions-item label="更新时间">{{ formatTime(detail.pkg.updateTime) }}</el-descriptions-item>
<el-descriptions-item label="销毁截止">{{ formatTime(detail.pkg.destroyDeadLine) }}</el-descriptions-item>
<el-descriptions-item label="最近同步">{{ formatTime(detail.pkg.lastSyncedAt) }}</el-descriptions-item>
</el-descriptions>
</template>
<template v-else-if="detail.item">
<div class="item-hero">
<el-image v-if="detail.item.image" :src="detail.item.image" fit="cover" class="thumb-lg" :preview-src-list="[detail.item.image]" preview-teleported />
<div v-else class="thumb-lg placeholder">无图</div>
<div class="item-hero-info">
<div class="item-title">{{ detail.item.itemName || '—' }}</div>
<div class="muted">{{ detail.item.variationName || '—' }}</div>
<div class="muted">商品ID:{{ detail.item.itemId || '—' }} / 规格ID:{{ detail.item.variationId || '—' }}</div>
<div class="muted">退货数量:{{ detail.item.quantity }}</div>
</div>
</div>
<h3 class="section-title">所属退货包裹</h3>
<el-descriptions v-if="detail.pkg" :column="2" border size="small">
<el-descriptions-item label="订单号">{{ detail.pkg.orderSn }}</el-descriptions-item>
<el-descriptions-item label="店铺">{{ detail.pkg.shopName || detail.pkg.shopId || '—' }}</el-descriptions-item>
<el-descriptions-item label="仓库/库位">{{ detail.pkg.wareHouse || detail.pkg.wareCode || '—' }}</el-descriptions-item>
<el-descriptions-item label="认领状态">
<el-tag v-if="detail.pkg.statusUnrecognized" type="danger" effect="plain">未知:{{ detail.pkg.claimStatus || '—' }}</el-tag>
<el-tag v-else :type="claimStatusMeta(detail.pkg.claimStatus).type">{{ claimStatusMeta(detail.pkg.claimStatus).label }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="认领时间">{{ formatTime(detail.pkg.claimTime) }}</el-descriptions-item>
<el-descriptions-item label="创建时间">{{ formatTime(detail.pkg.createTime) }}</el-descriptions-item>
<el-descriptions-item label="更新时间">{{ formatTime(detail.pkg.updateTime) }}</el-descriptions-item>
<el-descriptions-item label="销毁截止">{{ formatTime(detail.pkg.destroyDeadLine) }}</el-descriptions-item>
<el-descriptions-item label="最近同步">{{ formatTime(detail.pkg.lastSyncedAt) }}</el-descriptions-item>
</el-descriptions>
<h3 class="section-title">同包裹其他商品</h3>
<el-table :data="detail.siblings" border size="small" empty-text="无其他商品">
<el-table-column label="标题/规格" min-width="220"><template #default="{ row }">{{ row.itemName || '—' }}<div class="muted">{{ row.variationName || '—' }}</div></template></el-table-column>
<el-table-column label="数量" prop="quantity" width="80" />
</el-table>
</template>
</div>
</el-drawer>
</template>
</BasicLayout>
</template>
<script>
import { Search, RefreshLeft } from '@element-plus/icons-vue'
import { listYeekeReturnPackages, getYeekeReturnPackage, getYeekeReturnItem, listYeekeReturnShops, listYeekeSyncRuns } from '@/api/goauto/yeeke-returns'
export default {
name: 'GoAutoYeekeReturns',
setup() { return { Search, RefreshLeft } },
data() {
return {
loading: false, loadError: '', items: [], total: 0, createTimeRange: null,
summary: { lastSuccessAt: '', lastRun: null }, summaryError: '',
shopOptions: [],
query: { page: 1, pageSize: 20, orderSn: '', shop: '', claimStatus: '', createTimeFrom: '', createTimeTo: '' },
detail: { open: false, loading: false, placeholder: false, item: null, pkg: null, siblings: [] },
// yeeke's only confirmed claim-status value is the raw string "1"
// (HAR evidence, #336 requirement doc). Any other value the sync sees
// is preserved verbatim and flagged status_unrecognized — never
// guessed into a label here. "unknown" is a server-side pseudo-filter
// matching status_unrecognized=true, not a real yeeke value.
claimStatusOptions: [
{ label: '已认领', value: '1' },
{ label: '未知(原样展示)', value: 'unknown' }
]
}
},
created() { this.load(); this.loadSummary(); this.loadShops() },
methods: {
// "1" is yeeke's only confirmed known status; anything else reaching
// this function (status_unrecognized=false from the server) is an
// unmapped value we have not seen yet — show it as-is rather than a
// bare digit or a guessed Chinese label.
claimStatusMeta(status) { return status === '1' ? { label: '已认领', type: 'success' } : { label: status ? `其他:${status}` : '—', type: 'info' } },
statusMeta(status) { return { running: { label: '执行中', type: 'primary' }, succeeded: { label: '成功', type: 'success' }, failed: { label: '失败', type: 'danger' }, interrupted: { label: '已中断', type: 'warning' }}[status] || { label: status || '-', type: 'info' } },
formatTime(value) { if (!value) return '—'; return new Date(value).toLocaleString('zh-CN', { hour12: false }) },
async load() {
this.loading = true; this.loadError = ''
try {
const params = { ...this.query }
const r = await listYeekeReturnPackages(params, { suppressNetworkError: true })
this.items = r.data.items
this.total = r.data.total
} catch (error) {
this.loadError = error?.response?.data?.message || error?.message || '退货商品加载失败'
} finally {
this.loading = false
}
},
async loadShops() {
try {
const r = await listYeekeReturnShops({ suppressNetworkError: true, suppressErrorMessage: true })
this.shopOptions = r.data.shops || []
} catch (error) {
// 店铺下拉加载失败时不阻塞列表,筛选项保持为空。
this.shopOptions = []
}
},
async loadSummary() {
this.summaryError = ''
try {
const r = await listYeekeSyncRuns({ page: 1, pageSize: 1 }, { suppressNetworkError: true, suppressErrorMessage: true })
this.summary = { lastSuccessAt: r.data.lastSuccessAt, lastRun: r.data.items[0] || null }
} catch (error) {
// 同步失败时仍显示上次已加载的商品列表,只在摘要条提示。
this.summaryError = '同步状态摘要加载失败,仍显示已有退货商品数据'
}
},
search() {
this.query.page = 1
const [from, to] = this.createTimeRange || ['', '']
this.query.createTimeFrom = from
this.query.createTimeTo = to
this.load()
},
reset() {
this.createTimeRange = null
this.query = { page: 1, pageSize: 20, orderSn: '', shop: '', claimStatus: '', createTimeFrom: '', createTimeTo: '' }
this.load()
},
async openDetail(row) {
this.detail = { open: true, loading: true, placeholder: !row.hasItem, item: null, pkg: null, siblings: [] }
try {
if (!row.hasItem) {
const r = await getYeekeReturnPackage(row.packageId)
this.detail.pkg = r.data.item
} else {
const r = await getYeekeReturnItem(row.itemId)
this.detail.item = r.data.item
this.detail.pkg = r.data.package
this.detail.siblings = r.data.siblings || []
}
} finally {
this.detail.loading = false
}
}
}
}
</script>
<style lang="scss" scoped>
.page-card{min-height:calc(100vh - 124px)}
.page-heading{display:flex;align-items:flex-start;justify-content:space-between;gap:16px;margin-bottom:16px}
.page-heading h1{margin:0 0 6px;font-size:24px;color:#1f2937}
.page-heading p{margin:0;color:#606266;line-height:1.5}
.heading-actions{display:flex;gap:8px;flex-shrink:0}
.summary-bar{display:flex;flex-wrap:wrap;gap:12px 24px;margin-bottom:16px;padding:10px 16px;border:1px solid #dbeafe;border-radius:8px;background:#f8fafc;color:#334155}
.search-form{padding:16px 16px 0;margin-bottom:16px;border:1px solid #e5e7eb;border-radius:8px;background:#f8fafc}
.notice{margin-bottom:16px}
.drawer-body{padding:0 4px 24px}
.section-title{margin:24px 0 12px}
.thumb{width:44px;height:44px;border-radius:4px;object-fit:cover}
.thumb.placeholder{display:flex;align-items:center;justify-content:center;background:#f1f5f9;color:#909399;font-size:11px}
.thumb-lg{width:96px;height:96px;border-radius:6px;object-fit:cover;flex-shrink:0}
.thumb-lg.placeholder{display:flex;align-items:center;justify-content:center;background:#f1f5f9;color:#909399;font-size:12px}
.item-hero{display:flex;gap:16px}
.item-hero-info{flex:1;min-width:0}
.item-title{font-size:15px;font-weight:600;color:#1f2937;margin-bottom:4px}
.muted{font-size:12px;color:#909399}
@media(max-width:800px){.page-heading{flex-direction:column}}
</style>
@@ -1,130 +0,0 @@
<template>
<BasicLayout>
<template #wrapper>
<el-card class="page-card" shadow="never">
<el-form :model="query" :inline="true" class="search-form" @submit.prevent="search">
<el-form-item label="触发方式">
<el-select v-model="query.trigger" clearable placeholder="全部" style="width:120px">
<el-option label="手动" value="manual" />
<el-option label="定时" value="scheduled" />
</el-select>
</el-form-item>
<el-form-item label="状态">
<el-select v-model="query.status" clearable placeholder="全部" style="width:140px">
<el-option v-for="item in statusOptions" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
</el-form-item>
<el-form-item label="开始时间"><el-date-picker v-model="startedRange" type="daterange" value-format="YYYY-MM-DD" range-separator="至" start-placeholder="开始日期" end-placeholder="结束日期" /></el-form-item>
<el-form-item><el-button type="primary" :icon="Search" @click="search">查询</el-button><el-button :icon="RefreshLeft" @click="reset">重置</el-button></el-form-item>
<el-form-item class="sync-action">
<el-button
v-if="canSync"
type="primary"
:loading="manualSyncStarting"
:disabled="manualSyncStarting"
@click="startManualSync"
>手动同步</el-button>
</el-form-item>
</el-form>
<el-alert v-if="loadError" :title="loadError" type="error" show-icon :closable="false" class="notice"><template #default><el-button link type="primary" @click="load">重新加载</el-button></template></el-alert>
<div class="table-wrap">
<el-table v-loading="loading" :data="items" border stripe height="100%" empty-text="暂无同步记录">
<el-table-column label="运行ID" prop="id" width="90" />
<el-table-column label="触发方式" width="100"><template #default="{ row }">{{ triggerLabel(row.trigger) }}</template></el-table-column>
<el-table-column label="状态" width="110"><template #default="{ row }"><el-tag :type="statusMeta(row.status).type">{{ statusMeta(row.status).label }}</el-tag></template></el-table-column>
<el-table-column label="开始时间" min-width="160"><template #default="{ row }">{{ formatTime(row.startedAt) }}</template></el-table-column>
<el-table-column label="结束时间" min-width="160"><template #default="{ row }">{{ formatTime(row.finishedAt) }}</template></el-table-column>
<el-table-column label="页数" prop="totalPages" width="70" />
<el-table-column label="读取" prop="readCount" width="70" />
<el-table-column label="新增" prop="createdCount" width="70" />
<el-table-column label="更新" prop="updatedCount" width="70" />
<el-table-column label="跳过" prop="skippedCount" width="70" />
<el-table-column label="失败" prop="failedCount" width="70" />
<el-table-column label="脱敏原因" min-width="200"><template #default="{ row }">{{ row.errorMessage || '—' }}</template></el-table-column>
</el-table>
</div>
<pagination v-show="total > 0" v-model:current-page="query.page" v-model:page-size="query.pageSize" :total="total" @pagination="load" />
</el-card>
</template>
</BasicLayout>
</template>
<script>
import { Search, RefreshLeft } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import { listYeekeSyncRuns, triggerYeekeSync } from '@/api/goauto/yeeke-returns'
export default {
name: 'GoAutoYeekeSyncRuns',
setup() { return { Search, RefreshLeft } },
data() {
return {
loading: false, loadError: '', items: [], total: 0, startedRange: null, manualSyncStarting: false,
query: { page: 1, pageSize: 20, trigger: '', status: '', startedFrom: '', startedTo: '' },
statusOptions: [
{ label: '执行中', value: 'running' }, { label: '成功', value: 'succeeded' },
{ label: '失败', value: 'failed' }, { label: '已中断', value: 'interrupted' }
]
}
},
computed: {
canSync() { return (this.$store.getters.roles || []).some(role => role === 'admin' || role === 'purchaser') },
hasRunningSync() { return this.items.some(item => item.status === 'running') }
},
created() { this.load() },
methods: {
triggerLabel(trigger) { return { manual: '手动', scheduled: '定时' }[trigger] || trigger || '—' },
statusMeta(status) { return { running: { label: '执行中', type: 'primary' }, succeeded: { label: '成功', type: 'success' }, failed: { label: '失败', type: 'danger' }, interrupted: { label: '已中断', type: 'warning' }}[status] || { label: status || '-', type: 'info' } },
formatTime(value) { if (!value) return '—'; return new Date(value).toLocaleString('zh-CN', { hour12: false }) },
async load() {
this.loading = true; this.loadError = ''
try {
const r = await listYeekeSyncRuns(this.query, { suppressNetworkError: true })
this.items = r.data.items
this.total = r.data.total
} catch (error) {
this.loadError = error?.response?.data?.message || error?.message || '同步记录加载失败'
} finally {
this.loading = false
}
},
search() {
this.query.page = 1
const [from, to] = this.startedRange || ['', '']
this.query.startedFrom = from
this.query.startedTo = to
this.load()
},
reset() {
this.startedRange = null
this.query = { page: 1, pageSize: 20, trigger: '', status: '', startedFrom: '', startedTo: '' }
this.load()
},
async startManualSync() {
if (!this.canSync || this.manualSyncStarting || this.hasRunningSync) return
this.manualSyncStarting = true
try {
await triggerYeekeSync()
ElMessage.success('已开始同步')
await this.load()
} catch (error) {
ElMessage.error(error?.response?.data?.message || error?.message || '同步启动失败')
} finally {
this.manualSyncStarting = false
}
}
}
}
</script>
<style lang="scss" scoped>
// The card fills the viewport and the table takes all remaining height, so
// the pagination sits at the bottom instead of floating mid-page.
.page-card{height:calc(100vh - 124px);display:flex;flex-direction:column}
.page-card :deep(.el-card__body){flex:1;min-height:0;display:flex;flex-direction:column}
.search-form{display:flex;flex-wrap:wrap;align-items:center;padding:16px 16px 0;margin-bottom:16px;border:1px solid #e5e7eb;border-radius:8px;background:#f8fafc}
.sync-action{margin-left:auto}
.notice{margin-bottom:16px}
.table-wrap{flex:1;min-height:240px}
</style>