Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
981b3d43f2 | ||
|
|
f15a390e20 |
@@ -31,7 +31,3 @@
|
||||
/android/**/build/
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
|
||||
# Third-party capture and device dumps: contain credentials, tokens and personal data.
|
||||
*.har
|
||||
/xml_data/
|
||||
|
||||
@@ -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`。
|
||||
- 规则创建即生效;删除后不能创建新任务,但已有任务继续使用自身规则快照。
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
+6
-4
@@ -194,16 +194,18 @@ class GoAutoAccessibilityService : AccessibilityService(), UiDriver, PddCollecto
|
||||
return swipeNode(node, direction)
|
||||
}
|
||||
|
||||
/** Backfill never uses ancestor clicks or coordinate/gesture fallbacks. */
|
||||
fun clickBackfill(target: SnapshotNode): Boolean {
|
||||
/** Only the unique All navigation tab may use a gesture; order actions never do. */
|
||||
fun clickBackfill(target: SnapshotNode, allTabConfirmed: Boolean = false): Boolean {
|
||||
val page = capture()
|
||||
BackfillPagePolicy.validate(page)
|
||||
val fresh = page.nodes.singleOrNull { it.path == target.path && it.label == target.label &&
|
||||
it.bounds == target.bounds && it.className == target.className } ?: return false
|
||||
val allowed = BackfillPagePolicy.cards(page).any { it.path == fresh.path } ||
|
||||
val allTab = BackfillPagePolicy.allTab(page)?.path == fresh.path
|
||||
val allowed = BackfillPagePolicy.cards(page, allTabConfirmed).any { it.path == fresh.path } ||
|
||||
BackfillPagePolicy.expansion(page)?.path == fresh.path ||
|
||||
(fresh.label == "全部" && page.nodes.any { it.label in setOf("我的订单", "全部订单") })
|
||||
if (!allowed || !BackfillPagePolicy.safe(page, fresh)) return false
|
||||
if (allTab && !fresh.clickable) return dispatchCenterTap(Rect(fresh.bounds.left, fresh.bounds.top, fresh.bounds.right, fresh.bounds.bottom))
|
||||
if (!allowed || (!allTab && !BackfillPagePolicy.safe(page, fresh))) return false
|
||||
val root = rootInActiveWindow ?: return false
|
||||
if (root.packageName?.toString() != BackfillPagePolicy.PDD) return false
|
||||
var node = root
|
||||
|
||||
+51
-10
@@ -21,6 +21,26 @@ object BackfillPagePolicy {
|
||||
fun list(page: UiSnapshot): Boolean = page.nodes.any { it.visible && it.label == "全部" && it.selected } &&
|
||||
page.nodes.any { it.visible && it.label in setOf("我的订单", "全部订单") }
|
||||
fun detail(page: UiSnapshot): Boolean = page.nodes.any { it.visible && (it.label == "订单详情" || it.label.contains("订单编号")) }
|
||||
// Only the navigation tab may use a bounded gesture, never an order action.
|
||||
fun allTab(page: UiSnapshot): SnapshotNode? {
|
||||
if (detail(page) || page.nodes.none { it.visible && it.label in setOf("我的订单", "全部订单") }) return null
|
||||
val tab = page.nodes.filter { it.visible && it.enabled && it.label == "全部" }.singleOrNull() ?: return null
|
||||
val peers = page.nodes.filter { it.visible && it.label in setOf("待付款", "待分享", "待发货", "待收货", "待评价") &&
|
||||
it.bounds.top < tab.bounds.bottom && it.bounds.bottom > tab.bounds.top }
|
||||
val centerX = tab.bounds.left.toLong() + tab.bounds.width / 2
|
||||
if (peers.map { it.label }.distinct().size < 2 || peers.any {
|
||||
it.bounds.width <= 0 || it.bounds.left.toLong() + it.bounds.width / 2 <= centerX ||
|
||||
centerX >= it.bounds.left && centerX < it.bounds.right
|
||||
}) return null
|
||||
// WebView navigation labels can overlap by a few pixels. Exclude only
|
||||
// these verified peers (e.g. 待付款), not actual payment/action nodes.
|
||||
val navigationPage = page.copy(nodes = page.nodes.filter { it !in peers })
|
||||
return tab.takeIf { safe(navigationPage, it.copy(clickable = true)) }
|
||||
}
|
||||
|
||||
fun confirmedList(page: UiSnapshot, tabConfirmed: Boolean): Boolean = list(page) ||
|
||||
(tabConfirmed && allTab(page) != null && page.nodes.none { it.visible && it.selected &&
|
||||
it.label in setOf("待付款", "待分享", "待发货", "待收货", "待评价") })
|
||||
fun expansion(page: UiSnapshot): SnapshotNode? {
|
||||
if (!detail(page) || page.nodes.none { it.visible && it.label.contains("订单编号") }) return null
|
||||
val order = page.nodes.first { it.visible && it.label.contains("订单编号") }
|
||||
@@ -28,8 +48,8 @@ object BackfillPagePolicy {
|
||||
return page.nodes.filter { it.label == "展开" && safe(page, it) && it.bounds.top >= order.bounds.top &&
|
||||
it.bounds.top < snapshot.bounds.bottom && it.bounds.bottom > snapshot.bounds.top }.singleOrNull()
|
||||
}
|
||||
fun cards(page: UiSnapshot): List<SnapshotNode> {
|
||||
if (!list(page)) return emptyList()
|
||||
fun cards(page: UiSnapshot, tabConfirmed: Boolean = false): List<SnapshotNode> {
|
||||
if (!confirmedList(page, tabConfirmed)) return emptyList()
|
||||
return page.nodes.filter { node ->
|
||||
if (!safe(page, node)) return@filter false
|
||||
if (node.label in setOf("订单详情", "查看详情")) return@filter true
|
||||
@@ -64,17 +84,38 @@ class OrderBackfillScanner(
|
||||
private val submit: (BackfillItem) -> Unit,
|
||||
private val progress: (Int) -> Unit,
|
||||
) {
|
||||
private var tabConfirmed = false
|
||||
private fun listReady(page: UiSnapshot) = BackfillPagePolicy.confirmedList(page, tabConfirmed)
|
||||
private fun awaitOrders(): UiSnapshot {
|
||||
var previous: String? = null
|
||||
repeat(8) {
|
||||
checkActive()
|
||||
val page = driver.capture()
|
||||
if (page.packageName == BackfillPagePolicy.PDD && page.activityName?.startsWith(BackfillPagePolicy.PDD) == true) {
|
||||
BackfillPagePolicy.validate(page)
|
||||
if (BackfillPagePolicy.list(page) || BackfillPagePolicy.allTab(page) != null) {
|
||||
val signature = page.nodes.filter { it.visible && it.label in setOf("我的订单", "全部订单", "全部", "待付款", "待发货", "待收货", "待评价") }
|
||||
.joinToString { "${it.label}:${it.bounds}:${it.selected}" }
|
||||
if (previous == signature) return page
|
||||
previous = signature
|
||||
} else previous = null
|
||||
} else previous = null
|
||||
driver.pause()
|
||||
}
|
||||
error("等待订单列表加载超时,未完整扫描")
|
||||
}
|
||||
fun scan(): String {
|
||||
checkActive()
|
||||
tabConfirmed = false
|
||||
driver.openOrders()
|
||||
driver.pause()
|
||||
var page = read()
|
||||
var page = awaitOrders()
|
||||
if (!BackfillPagePolicy.list(page)) {
|
||||
val tab = page.nodes.filter { it.label == "全部" && BackfillPagePolicy.safe(page, it) }.singleOrNull()
|
||||
check(page.nodes.any { it.label in setOf("我的订单", "全部订单") } && tab != null) { "未识别我的订单-全部" }
|
||||
val tab = BackfillPagePolicy.allTab(page)
|
||||
check(tab != null) { "未识别我的订单-全部" }
|
||||
act { driver.click(tab) }
|
||||
page = read()
|
||||
check(BackfillPagePolicy.list(page)) { "无法确认全部订单标签" }
|
||||
tabConfirmed = true
|
||||
page = awaitOrders()
|
||||
check(listReady(page)) { "无法确认全部订单标签" }
|
||||
}
|
||||
if (page.nodes.any { it.visible && it.label == "暂无订单" }) return "扫描完成,未发现订单"
|
||||
val seenCards = mutableSetOf<String>()
|
||||
@@ -82,8 +123,8 @@ class OrderBackfillScanner(
|
||||
var noProgress = 0
|
||||
while (window.checked < OrderBackfillWindow.MAX_ORDERS) {
|
||||
checkActive()
|
||||
check(BackfillPagePolicy.list(page)) { "返回后未识别全部订单列表" }
|
||||
val card = BackfillPagePolicy.cards(page).firstOrNull { BackfillPagePolicy.fingerprint(page, it) !in seenCards }
|
||||
check(listReady(page)) { "返回后未识别全部订单列表" }
|
||||
val card = BackfillPagePolicy.cards(page, tabConfirmed).firstOrNull { BackfillPagePolicy.fingerprint(page, it) !in seenCards }
|
||||
if (card == null) {
|
||||
if (++noProgress >= 3) return finish("列表无进展或卡片无法安全识别,未完整扫描")
|
||||
checkActive()
|
||||
|
||||
+11
-288
@@ -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"
|
||||
|
||||
+19
-285
@@ -19,7 +19,7 @@ data class FinalConfirmationEvidence(
|
||||
val activityName: String,
|
||||
)
|
||||
|
||||
data class PurchaseOrderEvidence(val orderNo: String, val submittedAt: String, val pddOrderAmountCent: Long? = null)
|
||||
data class PurchaseOrderEvidence(val orderNo: String, val submittedAt: String)
|
||||
data class PurchaseOrderReadFailure(
|
||||
val code: String,
|
||||
val message: String,
|
||||
@@ -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
|
||||
@@ -364,17 +362,7 @@ class PurchaseLiveAutomation(
|
||||
var orderDetailEntryOpened = false
|
||||
var wechatRestorePendingSamples = 0
|
||||
var consecutiveEmptySnapshots = 0
|
||||
var unpaidStartSample: Int? = null
|
||||
var unpaidSwipes = 0
|
||||
repeat(ORDER_RESULT_MAX_SAMPLES + ORDER_RESULT_UNPAID_MAX_SAMPLES) { index ->
|
||||
// The first unpaid page receives its own bounded budget, even after a long handoff.
|
||||
// Never reset it on repeated labels or navigation back to payment.
|
||||
val unpaidStart = unpaidStartSample
|
||||
if ((unpaidStart == null && index >= ORDER_RESULT_MAX_SAMPLES) ||
|
||||
(unpaidStart != null && index - unpaidStart >= ORDER_RESULT_UNPAID_MAX_SAMPLES)) {
|
||||
val failure = orderEvidenceFailure(labels)
|
||||
return unknown(failure.code, failure.message)
|
||||
}
|
||||
repeat(ORDER_RESULT_MAX_SAMPLES) { index ->
|
||||
val snapshot = driver.capture()
|
||||
if (snapshot.packageName.isNullOrBlank()) {
|
||||
consecutiveEmptySnapshots++
|
||||
@@ -442,16 +430,8 @@ class PurchaseLiveAutomation(
|
||||
return@repeat
|
||||
}
|
||||
consecutivePaymentSamplesAfterBack = 0
|
||||
if (unpaidContextVisible) {
|
||||
paymentPageObserved = true
|
||||
if (unpaidStartSample == null) unpaidStartSample = index
|
||||
}
|
||||
if (unpaidContextVisible) paymentPageObserved = true
|
||||
if (!orderContextVisible && !unpaidContextVisible) {
|
||||
if (unpaidStartSample != null) {
|
||||
// After reaching the unpaid page only observe; never click a newly exposed control.
|
||||
pause(ORDER_RESULT_SAMPLE_INTERVAL_MS)
|
||||
return@repeat
|
||||
}
|
||||
val entries = orderDetailEntryTargets(snapshot)
|
||||
if (entries.size > 1) {
|
||||
return unknown("PURCHASE_ORDER_DETAIL_ENTRY_AMBIGUOUS", "订单详情入口不唯一,已停止只读核单")
|
||||
@@ -474,13 +454,7 @@ class PurchaseLiveAutomation(
|
||||
}
|
||||
currentLabels.forEach(labels::add)
|
||||
parseOrderEvidence(labels)?.let { return it }
|
||||
if (unpaidStartSample != null && unpaidSwipes < ORDER_RESULT_UNPAID_MAX_SWIPES) {
|
||||
unpaidSwipes++
|
||||
driver.swipePurchase(SwipeDirection.UP, 400)
|
||||
pause(ORDER_RESULT_UNPAID_SETTLE_MS)
|
||||
return@repeat
|
||||
}
|
||||
if (unpaidStartSample == null && index > 0 && index % ORDER_RESULT_SCROLL_SAMPLE_INTERVAL == 0) {
|
||||
if (index > 0 && index % ORDER_RESULT_SCROLL_SAMPLE_INTERVAL == 0) {
|
||||
driver.swipePurchase(SwipeDirection.UP, 400)
|
||||
}
|
||||
pause(ORDER_RESULT_SAMPLE_INTERVAL_MS)
|
||||
@@ -525,7 +499,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 +516,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 +574,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 +590,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 +692,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
|
||||
@@ -961,18 +728,7 @@ class PurchaseLiveAutomation(
|
||||
runCatching { SimpleDateFormat(pattern, Locale.ROOT).apply { isLenient = false; timeZone = TimeZone.getTimeZone("Asia/Shanghai") }.parse(normalized) }.getOrNull()
|
||||
} ?: return null
|
||||
val output = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.ROOT).apply { timeZone = TimeZone.getTimeZone("UTC") }.format(parsed)
|
||||
return PurchaseOrderEvidence(orderNumbers.single(), output, parsePayableAmount(labels))
|
||||
}
|
||||
|
||||
private fun parsePayableAmount(labels: Collection<String>): Long? {
|
||||
val amounts = labels.flatMap { label ->
|
||||
PAYABLE_AMOUNT.findAll(label).map { match ->
|
||||
runCatching { match.groupValues[1].toBigDecimal().movePointRight(2).longValueExact() }.getOrNull()
|
||||
}.toList()
|
||||
}
|
||||
// Repeated parent/child labels are fine; conflicting/overflow amounts are omitted.
|
||||
if (amounts.any { it == null }) return null
|
||||
return amounts.distinct().singleOrNull()
|
||||
return PurchaseOrderEvidence(orderNumbers.single(), output)
|
||||
}
|
||||
|
||||
private fun orderEvidenceFailure(labels: Collection<String>): PurchaseOrderReadFailure {
|
||||
@@ -1002,31 +758,12 @@ 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("订单编号", "订单号", "下单时间", "创建时间")
|
||||
val PAYABLE_AMOUNT = Regex("(?<![\\p{L}])应付[ \\t::,,]*[¥¥]?[ \\t]*(\\d+(?:\\.\\d{1,2})?)[ \\t]*元(?![0-9.])")
|
||||
val ORDER_NO = Regex("(?:订单编号|订单号)\\s*[::]?\\s*([A-Za-z0-9-]{6,64})")
|
||||
val ORDER_TIME = Regex("(?:下单时间|创建时间)\\s*[::]?\\s*(20[0-9]{2}[-/.年][0-9]{1,2}[-/.月][0-9]{1,2}日?\\s+[0-9]{1,2}:[0-9]{2}(?::[0-9]{2})?)")
|
||||
val ANDROID_CHOOSER_PACKAGES = setOf("android", "com.android.intentresolver")
|
||||
@@ -1040,10 +777,7 @@ class PurchaseLiveAutomation(
|
||||
const val ORDER_RESULT_MAX_SAMPLES = 60
|
||||
const val ORDER_RESULT_MAX_EMPTY_SAMPLES = 15
|
||||
const val ORDER_RESULT_WECHAT_RESTORE_MAX_SAMPLES = 15
|
||||
const val ORDER_RESULT_PAYMENT_POST_BACK_MAX_SAMPLES = 25
|
||||
const val ORDER_RESULT_UNPAID_MAX_SAMPLES = 30
|
||||
const val ORDER_RESULT_UNPAID_MAX_SWIPES = 4
|
||||
const val ORDER_RESULT_UNPAID_SETTLE_MS = 500L
|
||||
const val ORDER_RESULT_PAYMENT_POST_BACK_MAX_SAMPLES = 3
|
||||
const val ORDER_RESULT_SCROLL_SAMPLE_INTERVAL = 15
|
||||
const val ORDER_RESULT_SAMPLE_INTERVAL_MS = 200L
|
||||
const val SPEC_CONFIRMATION_MAX_SAMPLES = 20
|
||||
|
||||
+8
-60
@@ -79,7 +79,6 @@ data class PurchaseExecutionOutcome(
|
||||
val actualUnitPriceCent: Long? = null,
|
||||
// 仅 order_result_unknown 有意义:见 PurchaseOrderReadFailure 的说明(#302)。
|
||||
val paymentPageObserved: Boolean = false,
|
||||
val pddOrderAmountCent: Long? = null,
|
||||
)
|
||||
|
||||
class PurchaseRehearsalExecutor(
|
||||
@@ -100,7 +99,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
|
||||
@@ -155,7 +154,7 @@ class PurchaseRehearsalExecutor(
|
||||
}
|
||||
PurchaseActionType.READ_ORDER_RESULT -> {
|
||||
if (!irreversibleStarted) failure("PURCHASE_RULE_INVALID", "尚未进入创建订单边界")
|
||||
else live.readOrderResult()?.let { PurchaseExecutionOutcome("order_created", message = "订单已创建,等待人工检查和支付", pddOrderNo = it.orderNo, orderSubmittedAt = it.submittedAt, actualUnitPriceCent = observedPrice, pddOrderAmountCent = it.pddOrderAmountCent) }
|
||||
else live.readOrderResult()?.let { PurchaseExecutionOutcome("order_created", message = "订单已创建,等待人工检查和支付", pddOrderNo = it.orderNo, orderSubmittedAt = it.submittedAt, actualUnitPriceCent = observedPrice) }
|
||||
?: live.lastOrderReadFailure.let { readFailure ->
|
||||
PurchaseExecutionOutcome(
|
||||
"order_result_unknown",
|
||||
@@ -394,7 +393,7 @@ class PurchaseRehearsalExecutor(
|
||||
if (wait.changed) {
|
||||
return failure(
|
||||
SPEC_PANEL_EVIDENCE_NOT_MATCHED,
|
||||
"规格入口点击后页面已变化,但规格面板强证据不足 [${panelEvidence(wait.screen)}]${wait.screen.requiredEvidenceHintSuffix()}",
|
||||
"规格入口点击后页面已变化,但规格面板强证据不足 [${panelEvidence(wait.screen)}]",
|
||||
)
|
||||
}
|
||||
|
||||
@@ -415,7 +414,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 +445,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 +477,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 +573,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 +588,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 +1174,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"
|
||||
|
||||
-95
@@ -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,
|
||||
|
||||
+13
-15
@@ -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
|
||||
@@ -324,7 +323,10 @@ class AgentForegroundService : Service() {
|
||||
private fun publishBackfill(state: OrderBackfillState) {
|
||||
backfillState = state
|
||||
sendBroadcast(Intent(ACTION_BACKFILL_STATE).setPackage(packageName))
|
||||
updateNotification(if (state.running) "订单回填 · 已检查 ${state.checked}" else "订单回填已停止,请查看采购页结果")
|
||||
updateNotification(if (state.running) "订单回填 · 已检查 ${state.checked}" else "订单回填:${state.message}")
|
||||
if (!state.running) android.os.Handler(android.os.Looper.getMainLooper()).post {
|
||||
android.widget.Toast.makeText(this, "订单回填:${state.message}", android.widget.Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}
|
||||
|
||||
private fun requestOrderBackfill(days: String, confirmedAt: Long) {
|
||||
@@ -359,13 +361,19 @@ class AgentForegroundService : Service() {
|
||||
checkActive = ::checkActive,
|
||||
)
|
||||
val driver = object : cn.ilapage.goauto.agent.automation.BackfillDriver {
|
||||
private var allTabConfirmed = false
|
||||
override fun openOrders() {
|
||||
checkActive()
|
||||
startActivity(Intent(Intent.ACTION_VIEW, android.net.Uri.parse("https://mobile.yangkeduo.com/orders.html"))
|
||||
.setPackage(cn.ilapage.goauto.agent.automation.BackfillPagePolicy.PDD).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
|
||||
}
|
||||
override fun capture() = accessibility.capture()
|
||||
override fun click(node: cn.ilapage.goauto.agent.automation.SnapshotNode): Boolean { checkActive(); return accessibility.clickBackfill(node) }
|
||||
override fun click(node: cn.ilapage.goauto.agent.automation.SnapshotNode): Boolean {
|
||||
checkActive()
|
||||
val clicked = accessibility.clickBackfill(node, allTabConfirmed)
|
||||
if (clicked && node.label == "全部") allTabConfirmed = true
|
||||
return clicked
|
||||
}
|
||||
override fun scroll(): Boolean { checkActive(); return accessibility.scrollBackfill() }
|
||||
override fun back(): Boolean {
|
||||
checkActive()
|
||||
@@ -592,7 +600,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 +661,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 +700,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)
|
||||
@@ -735,7 +733,7 @@ class AgentForegroundService : Service() {
|
||||
paymentPageObserved = readFailure?.paymentPageObserved ?: false,
|
||||
)
|
||||
} else {
|
||||
PurchaseExecutionOutcome("order_created", message = "订单已创建,等待人工检查和支付", pddOrderNo = evidence.orderNo, orderSubmittedAt = evidence.submittedAt, pddOrderAmountCent = evidence.pddOrderAmountCent)
|
||||
PurchaseExecutionOutcome("order_created", message = "订单已创建,等待人工检查和支付", pddOrderNo = evidence.orderNo, orderSubmittedAt = evidence.submittedAt)
|
||||
}
|
||||
val requestId = UUID.randomUUID().toString()
|
||||
purchaseStore.completeAndEnqueue(interrupted.taskId, interrupted.attemptId, requestId, purchaseResultPayload(requestId, interrupted.attemptId, outcome))
|
||||
|
||||
@@ -24,8 +24,5 @@ internal fun purchaseResultPayload(
|
||||
outcome.pddOrderNo?.let { put("pddOrderNo", it) }
|
||||
outcome.orderSubmittedAt?.let { put("orderSubmittedAt", it) }
|
||||
outcome.actualUnitPriceCent?.let { put("actualUnitPriceCent", it) }
|
||||
if (outcome.resultType == "order_created") {
|
||||
outcome.pddOrderAmountCent?.let { put("pddOrderAmountCent", it) }
|
||||
}
|
||||
}
|
||||
.toString()
|
||||
|
||||
@@ -259,6 +259,74 @@ class OrderBackfillTest {
|
||||
}
|
||||
|
||||
private val item = BackfillItem("_cg7", "TEST-000007", null)
|
||||
private fun missingSelectedPage() = page(
|
||||
node("0/0", "我的订单").copy(bounds = NodeBounds(0, 0, 500, 50)),
|
||||
node("0/1", "全部").copy(clickable = false, bounds = NodeBounds(0, 60, 100, 100)),
|
||||
node("0/2", "待发货").copy(bounds = NodeBounds(100, 60, 200, 100)),
|
||||
node("0/3", "待收货").copy(bounds = NodeBounds(200, 60, 300, 100)))
|
||||
|
||||
@Test fun `missing selected requires unique safe tab and explicit confirmation`() {
|
||||
val p = missingSelectedPage()
|
||||
assertNotNull(BackfillPagePolicy.allTab(p))
|
||||
assertFalse(BackfillPagePolicy.confirmedList(p, false))
|
||||
assertTrue(BackfillPagePolicy.confirmedList(p, true))
|
||||
assertNull(BackfillPagePolicy.allTab(p.copy(nodes = p.nodes + p.nodes[1].copy(path = "0/9"))))
|
||||
assertFalse(BackfillPagePolicy.confirmedList(p.copy(nodes = p.nodes.map { if (it.label == "待收货") it.copy(selected = true) else it }), true))
|
||||
assertNull(BackfillPagePolicy.allTab(p.copy(nodes = p.nodes + p.nodes[1].copy(path = "0/9", text = "去支付"))))
|
||||
}
|
||||
|
||||
@Test fun `real navigation bounds tolerate three pixel overlap but never payment actions`() {
|
||||
val p = page(
|
||||
node("0/0", "我的订单").copy(bounds = NodeBounds(438, 120, 642, 258)),
|
||||
node("0/1", "全部").copy(clickable = false, bounds = NodeBounds(0, 258, 201, 372)),
|
||||
node("0/2", "待付款").copy(clickable = false, bounds = NodeBounds(198, 258, 399, 372)),
|
||||
node("0/3", "待分享").copy(clickable = false, bounds = NodeBounds(396, 258, 597, 372)),
|
||||
node("0/4", "待发货").copy(clickable = false, bounds = NodeBounds(594, 258, 795, 372)))
|
||||
assertEquals(p.nodes[1], BackfillPagePolicy.allTab(p))
|
||||
assertTrue(BackfillPagePolicy.confirmedList(p, true))
|
||||
assertFalse(BackfillPagePolicy.confirmedList(p, false))
|
||||
assertNull(BackfillPagePolicy.allTab(p.copy(nodes = p.nodes +
|
||||
node("0/9", "去支付").copy(bounds = NodeBounds(198, 258, 399, 372)))))
|
||||
assertNull(BackfillPagePolicy.allTab(p.copy(nodes = p.nodes.map {
|
||||
if (it.label == "待付款") it.copy(bounds = NodeBounds(90, 258, 399, 372)) else it
|
||||
})))
|
||||
assertNull(BackfillPagePolicy.allTab(p.copy(nodes = p.nodes.map {
|
||||
if (it.label == "待付款") it.copy(bounds = NodeBounds(-100, 258, 90, 372)) else it
|
||||
})))
|
||||
}
|
||||
|
||||
@Test fun `scanner waits for stable list and taps only all when selected is missing`() {
|
||||
var reads = 0
|
||||
var clicks = 0
|
||||
val driver = object : BackfillDriver {
|
||||
override fun openOrders() = Unit
|
||||
override fun pause() = Unit
|
||||
override fun capture(): UiSnapshot = if (++reads < 3) UiSnapshot(null, null, emptyList()) else missingSelectedPage()
|
||||
override fun click(node: SnapshotNode): Boolean { assertEquals("全部", node.label); clicks++; return true }
|
||||
override fun scroll() = false
|
||||
override fun back() = false
|
||||
}
|
||||
val reason = OrderBackfillScanner(driver, OrderBackfillWindow("2", now), {}, { fail("no upload") }, {}).scan()
|
||||
assertEquals(1, clicks)
|
||||
assertTrue(reads >= 6)
|
||||
assertTrue(reason.contains("未完整扫描"))
|
||||
}
|
||||
|
||||
@Test fun `missing order page times out without any click`() {
|
||||
var pauses = 0
|
||||
val driver = object : BackfillDriver {
|
||||
override fun openOrders() = Unit
|
||||
override fun pause() { pauses++ }
|
||||
override fun capture() = UiSnapshot(null, null, emptyList())
|
||||
override fun click(node: SnapshotNode): Boolean { fail("unexpected click"); return false }
|
||||
override fun scroll() = false
|
||||
override fun back() = false
|
||||
}
|
||||
try { OrderBackfillScanner(driver, OrderBackfillWindow("2", now), {}, {}, {}).scan(); fail() }
|
||||
catch (e: IllegalStateException) { assertTrue(e.message!!.contains("加载超时")) }
|
||||
assertEquals(8, pauses)
|
||||
}
|
||||
|
||||
private fun failure(code: String) = BackfillResult(0, 7, "failed", code, "", 0, null, null, "")
|
||||
private fun success() = BackfillResult(0, 7, "backfilled", "BACKFILLED", "order_created", 3, item.pddOrderNo, null, "page")
|
||||
private fun scanner(driver: FakeDriver, items: MutableList<BackfillItem>) = OrderBackfillScanner(driver, OrderBackfillWindow("2", now), {}, items::add, {})
|
||||
|
||||
+17
-133
@@ -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
|
||||
|
||||
@@ -18,104 +18,6 @@ import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class PurchaseLiveAutomationTest {
|
||||
@Test
|
||||
fun `two second payment transition and two needed scrolls yield order and payable amount without clicks`() {
|
||||
val driver = ReadOnlyOrderDriver(paymentMs = 2500)
|
||||
val order = PurchaseLiveAutomation(driver, pause = { driver.elapsed += it }).readOrderResult()
|
||||
assertEquals("PDD-DEMO-325", order?.orderNo)
|
||||
assertEquals("2026-09-19T03:21:43Z", order?.submittedAt)
|
||||
assertEquals(1300L, order?.pddOrderAmountCent)
|
||||
assertEquals(1, driver.backs)
|
||||
assertEquals(2, driver.swipes)
|
||||
assertEquals(0, driver.clicks)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unpaid page arriving near original limit gets independent read budget`() {
|
||||
val driver = ReadOnlyOrderDriver(preludeSamples = 58)
|
||||
val order = PurchaseLiveAutomation(driver, pause = { driver.elapsed += it }).readOrderResult()
|
||||
assertEquals("PDD-DEMO-325", order?.orderNo)
|
||||
assertTrue(driver.captures > 60)
|
||||
assertEquals(2, driver.swipes)
|
||||
assertEquals(0, driver.clicks)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unpaid scrolling is immediate and bounded when order never becomes visible`() {
|
||||
val driver = ReadOnlyOrderDriver(requiredSwipes = 100)
|
||||
val automation = PurchaseLiveAutomation(driver, pause = { driver.elapsed += it })
|
||||
assertEquals(null, automation.readOrderResult())
|
||||
assertEquals(4, driver.swipes)
|
||||
assertEquals(0L, driver.firstSwipeAt)
|
||||
assertTrue(driver.captures <= 30)
|
||||
assertEquals(0, driver.clicks)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `already visible order never scrolls and missing or conflicting payable amount never blocks order`() {
|
||||
val cases = listOf(
|
||||
emptyList<String>() to null,
|
||||
listOf("拼单价¥16", "平台优惠-¥3", "实付:13元") to null,
|
||||
listOf("应付:13元", "应付:14元") to null,
|
||||
listOf("应付:-13元", "应付:13.123元", "预计应付:13元") to null,
|
||||
listOf("应付:999999999999999999999元") to null,
|
||||
listOf("应付:13.20元", "应付:13.20元") to 1320L,
|
||||
listOf("应付:0元") to 0L,
|
||||
)
|
||||
for ((amounts, expected) in cases) {
|
||||
val driver = ReadOnlyOrderDriver(requiredSwipes = 0, amounts = amounts)
|
||||
val order = PurchaseLiveAutomation(driver, pause = {}).readOrderResult()
|
||||
assertEquals("PDD-DEMO-325", order?.orderNo)
|
||||
assertEquals(expected, order?.pddOrderAmountCent)
|
||||
assertEquals(0, driver.swipes)
|
||||
assertEquals(0, driver.clicks)
|
||||
}
|
||||
}
|
||||
|
||||
private class ReadOnlyOrderDriver(
|
||||
val paymentMs: Long = 0,
|
||||
val preludeSamples: Int = 0,
|
||||
val requiredSwipes: Int = 2,
|
||||
val amounts: List<String> = listOf("拼单价¥16", "平台优惠-¥3", "已优惠3元使用3元平台无门槛券,应付:,13元,(免运费)"),
|
||||
) : PurchaseUiDriver by LiveDriver() {
|
||||
var elapsed = 0L
|
||||
var captures = 0
|
||||
var backs = 0
|
||||
var swipes = 0
|
||||
var clicks = 0
|
||||
var firstSwipeAt: Long? = null
|
||||
override fun capture(): UiSnapshot {
|
||||
captures++
|
||||
if (captures <= preludeSamples) return UiSnapshot(PDD, ACTIVITY, emptyList())
|
||||
if (elapsed < paymentMs) return UiSnapshot(PDD, "com.xunmeng.pinduoduo.app_pay.core.PayActivity", listOf(node("立即支付")))
|
||||
return UiSnapshot(PDD, ACTIVITY, buildList {
|
||||
add(node("待付款"))
|
||||
// Amount scrolls away before the order fields become visible.
|
||||
if (swipes == 0) amounts.forEach { add(node(it)) }
|
||||
add(node("订单号:PDD-DEMO-325", swipes >= requiredSwipes))
|
||||
add(node("下单时间:2026-09-19 11:21:43", swipes >= requiredSwipes))
|
||||
add(node("去支付"))
|
||||
})
|
||||
}
|
||||
override fun backPurchase(): Boolean { backs++; return true }
|
||||
override fun swipePurchase(direction: SwipeDirection, durationMs: Long): Boolean {
|
||||
assertEquals(SwipeDirection.UP, direction)
|
||||
assertTrue(elapsed >= paymentMs)
|
||||
if (firstSwipeAt == null) firstSwipeAt = elapsed
|
||||
swipes++
|
||||
return true
|
||||
}
|
||||
override fun clickFresh(target: SnapshotNode): FreshActionResult {
|
||||
clicks++
|
||||
throw AssertionError("Read-only unpaid branch must never click")
|
||||
}
|
||||
private fun node(text: String, visible: Boolean = true) = SnapshotNode(
|
||||
text, null, text, null, null, "android.widget.TextView",
|
||||
NodeBounds(0, 100, 500, if (visible) 180 else 100),
|
||||
false, false, false, false, true, visible,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `verified spec panel advances through one exact confirm target`() {
|
||||
val driver = SpecConfirmationDriver()
|
||||
@@ -197,12 +99,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)
|
||||
@@ -461,10 +358,10 @@ class PurchaseLiveAutomationTest {
|
||||
assertEquals("PURCHASE_ORDER_PAYMENT_REPEATED", automation.lastOrderReadFailure?.code)
|
||||
assertEquals(
|
||||
"支付页安全返回后持续无订单证据,已停止自动核单" +
|
||||
"[paymentBackAttempts=1;consecutivePaymentSamplesAfterBack=25]",
|
||||
"[paymentBackAttempts=1;consecutivePaymentSamplesAfterBack=3]",
|
||||
automation.lastOrderReadFailure?.message,
|
||||
)
|
||||
assertEquals(26, driver.postSubmitCaptureCount)
|
||||
assertEquals(4, driver.postSubmitCaptureCount)
|
||||
assertEquals(1, driver.postSubmitBackCount)
|
||||
assertEquals(0, driver.genericSwipes)
|
||||
assertFalse(driver.clicked.any { it.contains("支付") })
|
||||
@@ -877,50 +774,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 +813,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)
|
||||
|
||||
@@ -8,16 +8,6 @@ import org.junit.Assert.assertFalse
|
||||
import org.junit.Test
|
||||
|
||||
class PurchaseResultPayloadTest {
|
||||
@Test
|
||||
fun `created order serializes optional payable total separately from unit price`() {
|
||||
val outcome = PurchaseExecutionOutcome("order_created", message = "test", actualUnitPriceCent = 1600, pddOrderAmountCent = 1300)
|
||||
val payload = JSONObject(purchaseResultPayload("r", "a", outcome))
|
||||
assertEquals(1300L, payload.getLong("pddOrderAmountCent"))
|
||||
assertEquals(1600L, payload.getLong("actualUnitPriceCent"))
|
||||
assertFalse(JSONObject(purchaseResultPayload("r", "a", outcome.copy(pddOrderAmountCent = null))).has("pddOrderAmountCent"))
|
||||
assertFalse(JSONObject(purchaseResultPayload("r", "a", outcome.copy(resultType = "order_result_unknown"))).has("pddOrderAmountCent"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unknown order result keeps stable failure and verified price without order fields`() {
|
||||
val payload = JSONObject(purchaseResultPayload(
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
node_modules/
|
||||
@@ -1,7 +0,0 @@
|
||||
# GoAuto PDD 订单回填扩展
|
||||
|
||||
Manifest V3 轻量扩展。用户在已登录的 PDD 我的订单页手动开始;扩展读取必要订单事实,先完成本次范围内的 CG 冲突聚合,再通过已授权 Admin Origin 的客户端密钥接口分批提交。
|
||||
|
||||
密钥只保存在扩展可信上下文,内容脚本不能读取;网络请求禁止重定向且不携带 Cookie。当前自动化测试只使用合成文本与合成 DOM fixture,不包含真实订单、地址、Cookie 或网页 HTML。
|
||||
|
||||
开发验证:`npm test`。解压安装、真实 PDD 遍历和真实 Admin 上传必须另行授权,本仓库验证不会执行这些动作。
|
||||
@@ -1,9 +0,0 @@
|
||||
"use strict";const Core=self.GoAutoBackfillCore,SAFE=/^(订单详情|查看详情)$/,RISK=/(付款|支付|确认收货|退款|售后|取消订单|更多)/,END=/^(没有更多了|没有更多|已经到底了)$/,LOGIN=/(请登录|登录后查看|验证码|安全验证)/;
|
||||
const txt=e=>(e?.innerText||e?.textContent||"").trim(),vis=e=>{const r=e.getBoundingClientRect();return r.width>0&&r.height>0},clickable=e=>e.closest("button,a,[role=button],[tabindex]");
|
||||
function entries(){const explicit=[...document.querySelectorAll("button,a,[role=button],[tabindex],div,span")].filter(e=>vis(e)&&SAFE.test(txt(e))).map(clickable).filter(Boolean).filter(e=>!RISK.test(txt(e)));if(explicit.length)return[...new Set(explicit)];return[...document.querySelectorAll(".react-base-list > div")].map(card=>[...card.children].slice(1).find(row=>vis(row)&&row.querySelector("img")&&!RISK.test(txt(row)))).filter(Boolean)}
|
||||
function summary(){const b=txt(document.body);return{ok:true,entryCount:entries().length,endEvidence:[...document.querySelectorAll("div,span,p")].some(e=>vis(e)&&END.test(txt(e))),loginExpired:LOGIN.test(b),empty:/^(暂无订单|没有订单)$/m.test(b),scrollY,scrollHeight:document.documentElement.scrollHeight}}
|
||||
function labelValue(labels){for(const n of document.querySelectorAll("div,span,p,li")){const own=txt(n).replace(/[::]$/u,"").trim();if(!labels.includes(own))continue;const row=n.parentElement,parts=[...row.children].filter(x=>x!==n).map(x=>txt(x).replace(/复制\s*$/u,"").trim()).filter(Boolean);if(parts.length)return parts.join("")}return""}
|
||||
function paidValue(){for(const n of document.querySelectorAll("div,span,p,li")){if(!["实付","实际支付"].includes(txt(n).replace(/[::]$/u,"").trim()))continue;for(const x of n.parentElement.children){const value=txt(x);if(x!==n&&Core.moneyCent(value)!=null)return value}}return""}
|
||||
async function detail(){const controls=[...new Set([...document.querySelectorAll("button,[role=button],[tabindex],div,span")].filter(e=>vis(e)&&/^(展开|展开收货信息|展开订单信息|查看完整信息|订单信息)$/.test(txt(e))).map(clickable).filter(Boolean).filter(e=>!RISK.test(txt(e))))];for(const control of controls.slice(0,2)){control.click();await new Promise(r=>setTimeout(r,100))}let stable=0,last="",until=Date.now()+5000;while(Date.now()<until){await new Promise(r=>setTimeout(r,250));const order=labelValue(["订单号"]),time=labelValue(["下单时间","创建时间"]),v=`${order}|${time}`;stable=order&&time&&v===last?stable+1:0;last=v;if(stable>=2)break}return{address:txt(document.body),orderNo:labelValue(["订单号"]),submittedAt:labelValue(["下单时间","创建时间"]),paidAmount:paidValue()}}
|
||||
chrome.runtime.onMessage.addListener((m,_s,reply)=>{if(m?.source!=="goauto-service"||!["LIST","SCROLL","OPEN_ENTRY","DETAIL","RESTORE"].includes(m.type))return;(async()=>{if(m.type==="LIST")return summary();if(m.type==="SCROLL"){scrollTo({top:document.documentElement.scrollHeight,behavior:"instant"});return summary()}if(m.type==="RESTORE"){scrollTo({top:Number(m.scrollY)||0,behavior:"instant"});return summary()}if(m.type==="OPEN_ENTRY"){const e=entries();if(!e[m.index])throw Error("安全的订单详情入口不存在");e[m.index].click();return{ok:true}}if(LOGIN.test(txt(document.body)))return{ok:false,loginExpired:true};return{ok:true,classified:Core.classifyDetail(await detail())}})().then(reply).catch(e=>reply({ok:false,error:e.message}));return true});
|
||||
if(typeof module!=="undefined")module.exports={entries,summary,labelValue,paidValue,detail};
|
||||
@@ -1,15 +0,0 @@
|
||||
(function(root){"use strict";
|
||||
const MAX_BATCH=50,CG=/_cg([1-9]\d*)\b/ig,TERMINAL=new Set(["completed","incomplete","stopped","failed"]);
|
||||
function normalizeOrigin(raw){const u=new URL(String(raw||"").trim());if(!/^https?:$/.test(u.protocol)||u.username||u.password||u.pathname!=="/"||u.search||u.hash)throw Error("Admin Origin 必须是 http(s) 根地址");return u.origin}
|
||||
function addressSuffixes(v){return[...new Set([...String(v||"").matchAll(CG)].map(m=>`_cg${m[1]}`))]}
|
||||
function addressSuffix(v){const a=addressSuffixes(v);return a.length===1?a[0]:""}
|
||||
function moneyCent(v){const m=String(v||"").trim().replace(/,/g,"").match(/^(?:¥|¥)?\s*(0|[1-9]\d*)(?:\.(\d{1,2}))?\s*元?$/);return m?Number(m[1])*100+Number((m[2]||"").padEnd(2,"0")):null}
|
||||
function normalizeOrderNo(v){const s=String(v||"").replace(/\s*复制\s*$/u,"").trim();return/^[A-Za-z0-9-]{1,100}$/.test(s)?s:""}
|
||||
function normalizeTime(v){const m=String(v||"").trim().match(/^(\d{4})[-/.年](\d{1,2})[-/.月](\d{1,2})日?\s+(\d{1,2}):(\d{2})(?::(\d{2}))?$/);if(!m)return null;const p=n=>String(n).padStart(2,"0"),d=new Date(`${m[1]}-${p(m[2])}-${p(m[3])}T${p(m[4])}:${m[5]}:${p(m[6]||0)}+08:00`);return Number.isNaN(d.valueOf())?null:d.toISOString()}
|
||||
function classifyDetail(f){const s=addressSuffixes(f.address||""),item={addressSuffix:s.length===1?s[0]:"",pddOrderNo:normalizeOrderNo(f.orderNo),orderSubmittedAt:normalizeTime(f.submittedAt),pddOrderAmountCent:f.paidAmount==null||f.paidAmount===""?null:moneyCent(f.paidAmount)};if(s.length>1)return{kind:"error",code:"CG_AMBIGUOUS",item};if(!s.length)return{kind:"skip",code:"CG_MISSING",item};if(!item.pddOrderNo)return{kind:"error",code:"ORDER_NO_INVALID",item};if(!item.orderSubmittedAt)return{kind:"error",code:"ORDER_TIME_INVALID",item};if(f.paidAmount!=null&&f.paidAmount!==""&&item.pddOrderAmountCent==null)return{kind:"error",code:"ORDER_AMOUNT_INVALID",item};return{kind:"candidate",code:"",item}}
|
||||
function aggregate(rows){const cs=rows.filter(x=>x.kind==="candidate").map(x=>x.item),cg=new Map,orders=new Map,uniq=new Map;for(const x of cs){if(!cg.has(x.addressSuffix))cg.set(x.addressSuffix,new Set);if(!orders.has(x.pddOrderNo))orders.set(x.pddOrderNo,new Set);cg.get(x.addressSuffix).add(x.pddOrderNo);orders.get(x.pddOrderNo).add(x.addressSuffix);uniq.set(`${x.addressSuffix}\0${x.pddOrderNo}`,x)}const cc=new Set([...cg].filter(([,v])=>v.size>1).map(([k])=>k)),oc=new Set([...orders].filter(([,v])=>v.size>1).map(([k])=>k));return{ready:[...uniq.values()].filter(x=>!cc.has(x.addressSuffix)&&!oc.has(x.pddOrderNo)),cgConflicts:[...cc],orderConflicts:[...oc],skippedNoCG:rows.filter(x=>x.code==="CG_MISSING").length,localErrors:rows.filter(x=>x.kind==="error")}}
|
||||
function batches(a,n=MAX_BATCH){const o=[];for(let i=0;i<a.length;i+=n)o.push(a.slice(i,i+n));return o}
|
||||
function inWindow(x,start,days){return new Date(x.orderSubmittedAt).valueOf()>=new Date(start).valueOf()-Number(days)*86400000}
|
||||
function validPddURL(v){try{const u=new URL(v);return u.protocol==="https:"&&u.hostname==="mobile.yangkeduo.com"}catch(_){return false}}
|
||||
function trustedUISender(s,id){try{const u=new URL(s.url);return s?.id===id&&!s.tab&&u.protocol==="chrome-extension:"&&u.hostname===id}catch(_){return false}}
|
||||
const api={MAX_BATCH,normalizeOrigin,addressSuffixes,addressSuffix,moneyCent,normalizeOrderNo,normalizeTime,classifyDetail,aggregate,batches,inWindow,validPddURL,trustedUISender,terminal:p=>TERMINAL.has(p)};if(typeof module!=="undefined")module.exports=api;root.GoAutoBackfillCore=api})(typeof self!=="undefined"?self:globalThis);
|
||||
@@ -1,16 +0,0 @@
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"name": "GoAuto PDD 订单回填",
|
||||
"version": "0.1.0",
|
||||
"description": "手动扫描最近 PDD 订单并将必要订单事实回填到已授权的 GoAuto Admin。",
|
||||
"permissions": ["storage", "tabs"],
|
||||
"host_permissions": ["https://mobile.yangkeduo.com/*"],
|
||||
"optional_host_permissions": ["https://*/*", "http://*/*"],
|
||||
"background": {"service_worker": "service-worker.js"},
|
||||
"action": {"default_popup": "popup.html"},
|
||||
"content_scripts": [{
|
||||
"matches": ["https://mobile.yangkeduo.com/*"],
|
||||
"js": ["core.js", "content.js"],
|
||||
"run_at": "document_idle"
|
||||
}]
|
||||
}
|
||||
Generated
-545
@@ -1,545 +0,0 @@
|
||||
{
|
||||
"name": "goauto-pdd-order-backfill",
|
||||
"version": "0.1.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "goauto-pdd-order-backfill",
|
||||
"version": "0.1.0",
|
||||
"devDependencies": {
|
||||
"jsdom": "^26.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@asamuzakjp/css-color": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz",
|
||||
"integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@csstools/css-calc": "^2.1.3",
|
||||
"@csstools/css-color-parser": "^3.0.9",
|
||||
"@csstools/css-parser-algorithms": "^3.0.4",
|
||||
"@csstools/css-tokenizer": "^3.0.3",
|
||||
"lru-cache": "^10.4.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@csstools/color-helpers": {
|
||||
"version": "5.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz",
|
||||
"integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/csstools"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/csstools"
|
||||
}
|
||||
],
|
||||
"license": "MIT-0",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@csstools/css-calc": {
|
||||
"version": "2.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz",
|
||||
"integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/csstools"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/csstools"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@csstools/css-parser-algorithms": "^3.0.5",
|
||||
"@csstools/css-tokenizer": "^3.0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@csstools/css-color-parser": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz",
|
||||
"integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/csstools"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/csstools"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@csstools/color-helpers": "^5.1.0",
|
||||
"@csstools/css-calc": "^2.1.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@csstools/css-parser-algorithms": "^3.0.5",
|
||||
"@csstools/css-tokenizer": "^3.0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@csstools/css-parser-algorithms": {
|
||||
"version": "3.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz",
|
||||
"integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/csstools"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/csstools"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@csstools/css-tokenizer": "^3.0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@csstools/css-tokenizer": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz",
|
||||
"integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/csstools"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/csstools"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/agent-base": {
|
||||
"version": "7.1.4",
|
||||
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
|
||||
"integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"node_modules/cssstyle": {
|
||||
"version": "4.6.0",
|
||||
"resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz",
|
||||
"integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@asamuzakjp/css-color": "^3.2.0",
|
||||
"rrweb-cssom": "^0.8.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/data-urls": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz",
|
||||
"integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"whatwg-mimetype": "^4.0.0",
|
||||
"whatwg-url": "^14.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/debug": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ms": "^2.1.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"supports-color": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/decimal.js": {
|
||||
"version": "10.6.0",
|
||||
"resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
|
||||
"integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/entities": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz",
|
||||
"integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/html-encoding-sniffer": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz",
|
||||
"integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"whatwg-encoding": "^3.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/http-proxy-agent": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz",
|
||||
"integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"agent-base": "^7.1.0",
|
||||
"debug": "^4.3.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"node_modules/https-proxy-agent": {
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
|
||||
"integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"agent-base": "^7.1.2",
|
||||
"debug": "4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"node_modules/iconv-lite": {
|
||||
"version": "0.6.3",
|
||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
|
||||
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safer-buffer": ">= 2.1.2 < 3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/is-potential-custom-element-name": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz",
|
||||
"integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/jsdom": {
|
||||
"version": "26.1.0",
|
||||
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-26.1.0.tgz",
|
||||
"integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cssstyle": "^4.2.1",
|
||||
"data-urls": "^5.0.0",
|
||||
"decimal.js": "^10.5.0",
|
||||
"html-encoding-sniffer": "^4.0.0",
|
||||
"http-proxy-agent": "^7.0.2",
|
||||
"https-proxy-agent": "^7.0.6",
|
||||
"is-potential-custom-element-name": "^1.0.1",
|
||||
"nwsapi": "^2.2.16",
|
||||
"parse5": "^7.2.1",
|
||||
"rrweb-cssom": "^0.8.0",
|
||||
"saxes": "^6.0.0",
|
||||
"symbol-tree": "^3.2.4",
|
||||
"tough-cookie": "^5.1.1",
|
||||
"w3c-xmlserializer": "^5.0.0",
|
||||
"webidl-conversions": "^7.0.0",
|
||||
"whatwg-encoding": "^3.1.1",
|
||||
"whatwg-mimetype": "^4.0.0",
|
||||
"whatwg-url": "^14.1.1",
|
||||
"ws": "^8.18.0",
|
||||
"xml-name-validator": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"canvas": "^3.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"canvas": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/lru-cache": {
|
||||
"version": "10.4.3",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz",
|
||||
"integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/nwsapi": {
|
||||
"version": "2.2.27",
|
||||
"resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.27.tgz",
|
||||
"integrity": "sha512-gQPNF78qebCQ6tvVFBYrvJdBNOrYZm90ZlXgpIFm06p6qHDHq/XC4TnJftN6OMbxVE0UTBAoRgcsDeJBBooITw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/parse5": {
|
||||
"version": "7.3.0",
|
||||
"resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz",
|
||||
"integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"entities": "^6.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/inikulin/parse5?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/punycode": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
||||
"integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/rrweb-cssom": {
|
||||
"version": "0.8.0",
|
||||
"resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz",
|
||||
"integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/safer-buffer": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
|
||||
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/saxes": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz",
|
||||
"integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"xmlchars": "^2.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=v12.22.7"
|
||||
}
|
||||
},
|
||||
"node_modules/symbol-tree": {
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz",
|
||||
"integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tldts": {
|
||||
"version": "6.1.86",
|
||||
"resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz",
|
||||
"integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tldts-core": "^6.1.86"
|
||||
},
|
||||
"bin": {
|
||||
"tldts": "bin/cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/tldts-core": {
|
||||
"version": "6.1.86",
|
||||
"resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz",
|
||||
"integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tough-cookie": {
|
||||
"version": "5.1.2",
|
||||
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz",
|
||||
"integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"tldts": "^6.1.32"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16"
|
||||
}
|
||||
},
|
||||
"node_modules/tr46": {
|
||||
"version": "5.1.1",
|
||||
"resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz",
|
||||
"integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"punycode": "^2.3.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/w3c-xmlserializer": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz",
|
||||
"integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"xml-name-validator": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/webidl-conversions": {
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz",
|
||||
"integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/whatwg-encoding": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz",
|
||||
"integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==",
|
||||
"deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"iconv-lite": "0.6.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/whatwg-mimetype": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz",
|
||||
"integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/whatwg-url": {
|
||||
"version": "14.2.0",
|
||||
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz",
|
||||
"integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tr46": "^5.1.0",
|
||||
"webidl-conversions": "^7.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/ws": {
|
||||
"version": "8.21.3",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz",
|
||||
"integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"bufferutil": "^4.0.1",
|
||||
"utf-8-validate": ">=5.0.2"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"bufferutil": {
|
||||
"optional": true
|
||||
},
|
||||
"utf-8-validate": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/xml-name-validator": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz",
|
||||
"integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/xmlchars": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz",
|
||||
"integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
{"name":"goauto-pdd-order-backfill","private":true,"version":"0.1.0","scripts":{"test":"node --test test/*.test.js"},"devDependencies":{"jsdom":"^26.1.0"}}
|
||||
@@ -1 +0,0 @@
|
||||
:root{font-family:Arial,sans-serif;color:#134e4a;background:#f0fdfa}body{margin:0;width:390px}main{padding:20px}h1{font-size:24px;margin:0 0 6px}.muted,.help{color:#64748b;font-size:12px}label{display:block;font-weight:700;margin-top:14px}input{box-sizing:border-box;width:100%;height:42px;margin-top:6px;border:1px solid #cbd5e1;border-radius:8px;padding:0 12px;font-size:14px}.notice{margin:18px 0;padding:12px;border:1px solid #99f6e4;border-radius:10px;background:#fff;font-size:12px;line-height:1.5}button{width:100%;height:46px;border:0;border-radius:10px;background:#0d9488;color:#fff;font-weight:700;font-size:15px;cursor:pointer;margin-top:10px}.secondary{background:#fff;color:#0d9488;border:1px solid #0d9488}button:disabled,input:disabled{opacity:.55;cursor:not-allowed}#error{color:#b91c1c;font-size:12px}#status{white-space:pre-line;font-size:13px;line-height:1.6;margin-top:12px}
|
||||
@@ -1,9 +0,0 @@
|
||||
<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width"><link rel="stylesheet" href="popup.css"><title>PDD 订单回填</title></head><body>
|
||||
<main><h1>PDD 订单回填</h1><p class="muted">扫描最近订单,聚合冲突后分批上传 Admin。</p>
|
||||
<label>Admin Origin<input id="origin" type="url" placeholder="https://admin.example.internal"></label>
|
||||
<label>客户端密钥<input id="key" type="password" autocomplete="off" placeholder="仅保存在扩展可信上下文"></label>
|
||||
<p class="help">密钥需授权:采购管理 → 回填。</p>
|
||||
<label>最近天数<input id="days" type="number" min="1" max="30" value="2"></label>
|
||||
<div class="notice">开始后冻结配置与时间窗口;完成扫描并聚合冲突后才上传。</div>
|
||||
<button id="start">开始扫描并上传</button><button id="stop" class="secondary">停止</button>
|
||||
<p id="error" role="alert"></p><section id="status" aria-live="polite"></section><button id="retry" class="secondary" hidden>重试可重试项</button><ol id="results"></ol></main><script src="core.js"></script><script src="popup.js"></script></body></html>
|
||||
@@ -1,6 +0,0 @@
|
||||
"use strict";const Core=self.GoAutoBackfillCore,$=id=>document.getElementById(id);let timer;
|
||||
const command=(type,data={})=>new Promise(resolve=>chrome.runtime.sendMessage({source:"goauto-popup",type,...data},resolve));
|
||||
async function load(){const c=await chrome.storage.local.get(["adminOrigin","clientKey","days"]);$("origin").value=c.adminOrigin||"";$("key").value=c.clientKey||"";$("days").value=c.days||2;await refresh();timer=setInterval(refresh,750)}
|
||||
async function refresh(){const q=await command("STATUS"),r=q?.run;if(!r){$("status").textContent="空闲";return}const busy=!Core.terminal(r.phase),complete=r.scanComplete?"已确认列表末尾":"未完整扫描";$("status").textContent=`状态:${r.phase}(${complete})\n已读 ${r.stats.read} · 可提交 ${r.stats.ready} · 已写入 ${r.stats.written}\n冲突 ${r.stats.conflicts} · 无 CG 跳过 ${r.stats.skippedNoCG}${r.incompleteReason?`\n${r.incompleteReason}`:""}${r.error?`\n${r.error.message}`:""}`;for(const id of["origin","key","days","start"])$(id).disabled=busy;$("stop").disabled=!busy;$("retry").hidden=!(r.phase==="incomplete"&&r.batches?.some(b=>b.status==="retryable"));const pending=(r.batches||[]).filter(b=>b.status==="retryable").flatMap(b=>b.results||[]),errors=(r.localErrors||[]).map((x,i)=>({index:i,result:"local_error",code:x.code}));$("results").replaceChildren(...[...(r.results||[]),...pending,...errors].map(x=>{const li=document.createElement("li");li.textContent=`第 ${Number(x.index)+1} 项:${x.result} / ${x.code}`;return li}))}
|
||||
$("start").addEventListener("click",async()=>{try{$("error").textContent="";const origin=Core.normalizeOrigin($("origin").value),clientKey=$("key").value.trim(),days=Number($("days").value);if(!clientKey||!Number.isInteger(days)||days<1||days>30)throw Error("请填写密钥,最近天数须为 1–30");if(!await chrome.permissions.request({origins:[`${origin}/*`]}))throw Error("未授予此 Admin Origin 的访问权限");await chrome.storage.local.setAccessLevel({accessLevel:"TRUSTED_CONTEXTS"});await chrome.storage.local.set({adminOrigin:origin,clientKey,days});const[tab]=await chrome.tabs.query({active:true,currentWindow:true}),res=await command("START",{tabId:tab.id,clientKey,frozen:{origin,days,startedAt:new Date().toISOString()}});if(!res?.ok)throw Error(res?.error||"启动失败");await refresh()}catch(e){$("error").textContent=e.message}});
|
||||
$("stop").addEventListener("click",async()=>{await command("STOP");await refresh()});$("retry").addEventListener("click",async()=>{await command("RETRY");await refresh()});addEventListener("unload",()=>clearInterval(timer));load();
|
||||
@@ -1,24 +0,0 @@
|
||||
"use strict";importScripts("core.js");const Core=self.GoAutoBackfillCore,STATE="orderBackfillRun",SECRET="orderBackfillSecret";let active=null,starting=false;
|
||||
const delay=ms=>new Promise(r=>setTimeout(r,ms)),uuid=()=>crypto.randomUUID();
|
||||
async function secure(){await chrome.storage.local.setAccessLevel({accessLevel:"TRUSTED_CONTEXTS"});await chrome.storage.session.setAccessLevel({accessLevel:"TRUSTED_CONTEXTS"})}
|
||||
chrome.runtime.onInstalled.addListener(secure);chrome.runtime.onStartup.addListener(()=>{secure();resume()});secure();
|
||||
async function getRun(){return(await chrome.storage.session.get(STATE))[STATE]||null}async function getSecret(){return(await chrome.storage.session.get(SECRET))[SECRET]||null}
|
||||
async function write(run){const latest=await getRun();if(latest&&latest.id!==run.id)throw Error("运行已被替换");if(latest?.stopRequested)run.stopRequested=true;await chrome.storage.session.set({[STATE]:run})}
|
||||
async function cancelled(run){const latest=await getRun();return!latest||latest.id!==run.id||latest.stopRequested}
|
||||
async function send(run,type,extra={}){if(await cancelled(run))throw Object.assign(Error("已停止"),{code:"STOPPED"});let done=false,value,error;chrome.tabs.sendMessage(run.tabId,{source:"goauto-service",type,...extra}).then(v=>{done=true;value=v},e=>{done=true;error=e});const end=Date.now()+(self.__GOAUTO_TEST_TIMEOUT__||15000);while(!done&&Date.now()<end){if(await cancelled(run))throw Object.assign(Error("已停止"),{code:"STOPPED"});await delay(100)}if(!done){if(await cancelled(run))throw Object.assign(Error("已停止"),{code:"STOPPED"});throw Object.assign(Error("页面响应超时"),{code:"CONTENT_TIMEOUT"})}if(error)throw error;return value}
|
||||
async function loaded(tabId,timeout=15000){const end=Date.now()+timeout;while(Date.now()<end){if((await chrome.tabs.get(tabId)).status==="complete"){await delay(300);return}await delay(200)}throw Object.assign(Error("页面加载超时"),{code:"PAGE_TIMEOUT"})}
|
||||
async function navigated(run,from,timeout=15000){const end=Date.now()+timeout;while(Date.now()<end){if(await cancelled(run))throw Object.assign(Error("已停止"),{code:"STOPPED"});const tab=await chrome.tabs.get(run.tabId);if(tab.url!==from&&tab.status==="complete"){await delay(300);return}await delay(150)}throw Object.assign(Error("订单详情未打开"),{code:"DETAIL_NOT_OPENED"})}
|
||||
async function returnList(run){await chrome.tabs.goBack(run.tabId);await loaded(run.tabId);const tab=await chrome.tabs.get(run.tabId);if(!tab.url.startsWith(run.listURL.split("?")[0]))await chrome.tabs.update(run.tabId,{url:run.listURL});await loaded(run.tabId);await send(run,"RESTORE",{scrollY:run.scrollY})}
|
||||
function isListURL(raw){try{return new URL(raw).pathname==="/orders.html"}catch(_){return false}}
|
||||
function prepare(run){const inRange=run.rows.filter(x=>x.kind!=="candidate"||Core.inWindow(x.item,run.frozen.startedAt,run.frozen.days));const grouped=Core.aggregate(inRange);run.localErrors=grouped.localErrors;run.stats.ready=grouped.ready.length;run.stats.conflicts=grouped.cgConflicts.length+grouped.orderConflicts.length;run.stats.skippedNoCG=grouped.skippedNoCG;run.batches=Core.batches(grouped.ready).map((items,i)=>({index:i,requestId:uuid(),status:"pending",items,results:[],attempts:0}));}
|
||||
function validate(payload,count){const items=payload?.data?.items;if(!Array.isArray(items)||items.length!==count)throw Object.assign(Error("服务端返回结果不完整"),{code:"INVALID_RESPONSE"});const seen=new Set;for(const x of items){if(!Number.isInteger(x.index)||x.index<0||x.index>=count||seen.has(x.index)||(x.taskId!=null&&(!Number.isInteger(x.taskId)||x.taskId<0))||typeof x.result!=="string"||typeof x.code!=="string"||typeof x.retryable!=="boolean")throw Object.assign(Error("服务端返回结果索引或字段无效"),{code:"INVALID_RESPONSE"});seen.add(x.index)}return items.sort((a,b)=>a.index-b.index)}
|
||||
async function post(run,batch){const secret=await getSecret();if(!secret||secret.runId!==run.id||secret.origin!==run.frozen.origin)throw Error("冻结的运行凭据不可用");const ctl=new AbortController,t=setTimeout(()=>ctl.abort(),self.__GOAUTO_TEST_TIMEOUT__||15000);try{const res=await fetch(`${run.frozen.origin}/api/client/v1/purchase-tasks/order-backfill`,{method:"POST",redirect:"error",cache:"no-store",credentials:"omit",signal:ctl.signal,headers:{Authorization:`Bearer ${secret.clientKey}`,"Content-Type":"application/json"},body:JSON.stringify({requestId:batch.requestId,items:batch.items})});if(!res.ok)throw Object.assign(Error(res.status===401?"客户端密钥无效":res.status===403?"密钥未授权采购管理 → 回填":`提交失败 (${res.status})`),{code:`HTTP_${res.status}`,retryable:res.status>=500});return validate(await res.json(),batch.items.length)}catch(e){if(e.name==="AbortError")throw Object.assign(Error("提交超时,可用相同批次标识重试"),{code:"TIMEOUT",retryable:true});throw e}finally{clearTimeout(t)}}
|
||||
async function execute(run){try{
|
||||
if(run.phase==="scanning"){let stalled=0;while(run.entryCount<500){const view=await send(run,"LIST");if(view.loginExpired)throw Object.assign(Error("PDD 登录失效或出现安全验证"),{code:"LOGIN_EXPIRED"});if(view.empty){run.scanComplete=true;break}run.entryCount=Math.max(run.entryCount,view.entryCount);run.scrollY=view.scrollY;if(view.endEvidence){run.scanComplete=true;break}const before=run.entryCount;await send(run,"SCROLL");await delay(500);const after=await send(run,"LIST");if(after.loginExpired)throw Object.assign(Error("PDD 登录失效或出现安全验证"),{code:"LOGIN_EXPIRED"});run.entryCount=Math.max(run.entryCount,after.entryCount);run.scrollY=after.scrollY;if(after.endEvidence){run.scanComplete=true;break}stalled=run.entryCount===before?stalled+1:0;await write(run);if(stalled>=3){run.incompleteReason="未看到列表末尾标记";break}}if(run.entryCount>=500)run.incompleteReason="达到单次 500 条有界扫描上限";run.phase="reading";run.detailState="list";await write(run)}
|
||||
if(run.phase==="reading"){while(run.cursor<run.entryCount){if(await cancelled(run))throw Object.assign(Error("已停止"),{code:"STOPPED"});let tab=await chrome.tabs.get(run.tabId);if(isListURL(tab.url)&&run.detailState==="returning"){run.cursor++;run.detailState="list";await write(run);continue}if(isListURL(tab.url)){await send(run,"RESTORE",{scrollY:run.scrollY});run.detailState="opening";await write(run);await send(run,"OPEN_ENTRY",{index:run.cursor});await navigated(run,tab.url);run.detailState="detail";await write(run)}else if(run.detailState==="list"){await chrome.tabs.update(run.tabId,{url:run.listURL});await loaded(run.tabId);continue}if(run.detailState!=="returning"){const d=await send(run,"DETAIL");if(d.loginExpired)throw Object.assign(Error("PDD 登录失效或出现安全验证"),{code:"LOGIN_EXPIRED"});if(!d.ok)throw Error(d.error||"订单详情读取失败");run.rows.push(d.classified);run.stats.read++;run.detailState="returning";await write(run)}await returnList(run);run.cursor++;run.detailState="list";await write(run)}run.phase="aggregating";await write(run)}
|
||||
if(run.phase==="aggregating"){prepare(run);run.phase="submitting";await write(run)}
|
||||
if(run.phase==="submitting"){for(const b of run.batches){if(b.status==="confirmed")continue;if(await cancelled(run))throw Object.assign(Error("已停止"),{code:"STOPPED"});b.status="sending";b.attempts++;await write(run);try{b.results=await post(run,b);if(b.results.some(x=>x.retryable)){b.status="retryable";b.error={code:"ITEM_RETRYABLE",message:"服务端返回可重试项"};run.phase="incomplete";run.incompleteReason=b.error.message;await write(run);return}b.status="confirmed";run.results.push(...b.results);run.stats.written+=b.results.filter(x=>x.result==="backfilled"||x.result==="already_backfilled").length}catch(e){b.status=e.retryable?"retryable":"failed";b.error={code:e.code||"SUBMIT_FAILED",message:e.message};run.phase=e.retryable?"incomplete":"failed";run.incompleteReason=e.retryable?e.message:run.incompleteReason;await write(run);return}await write(run)}run.phase=run.scanComplete?"completed":"incomplete";await write(run)}
|
||||
}catch(e){run.phase=e.code==="STOPPED"?"stopped":e.code==="LOGIN_EXPIRED"?"failed":"failed";run.error={code:e.code||"RUN_FAILED",message:e.message};if(e.code==="LOGIN_EXPIRED")run.incompleteReason=e.message;await write(run)}finally{active=null}}
|
||||
function resume(){if(active)return;active=(async()=>{const r=await getRun();if(r&&!Core.terminal(r.phase))await execute(r)})().finally(()=>active=null)}
|
||||
async function start(tabId,frozen,key){const old=await getRun();if(old&&!Core.terminal(old.phase))throw Error("已有回填任务正在运行");const tab=await chrome.tabs.get(tabId);if(!Core.validPddURL(tab.url)||!/\/orders\.html/.test(new URL(tab.url).pathname))throw Error("请在 PDD 我的订单页开始");const run={id:uuid(),tabId,listURL:tab.url,phase:"scanning",frozen:{origin:Core.normalizeOrigin(frozen.origin),days:frozen.days,startedAt:frozen.startedAt},entryCount:0,cursor:0,scrollY:0,rows:[],results:[],batches:[],localErrors:[],stats:{read:0,ready:0,written:0,conflicts:0,skippedNoCG:0},scanComplete:false,incompleteReason:"",stopRequested:false,error:null};await chrome.storage.session.set({[SECRET]:{runId:run.id,origin:run.frozen.origin,clientKey:key}});await chrome.storage.session.set({[STATE]:run});resume();return run}
|
||||
chrome.runtime.onMessage.addListener((m,s,reply)=>{if(m?.source!=="goauto-popup")return;(async()=>{if(!Core.trustedUISender(s,chrome.runtime.id))throw Error("拒绝非扩展界面的控制消息");if(m.type==="STATUS")return{ok:true,run:await getRun()};if(m.type==="START"){if(starting)throw Error("已有回填任务正在启动");starting=true;try{return{ok:true,run:await start(m.tabId,m.frozen,m.clientKey)}}finally{starting=false}}if(m.type==="STOP"){const r=await getRun();if(r&&!Core.terminal(r.phase)){r.stopRequested=true;await write(r)}return{ok:true,run:await getRun()}}if(m.type==="RETRY"){const r=await getRun();if(!r||r.phase!=="incomplete"||!r.batches?.some(b=>b.status==="retryable"))throw Error("没有可重试批次");r.stopRequested=false;r.phase="submitting";await write(r);resume();return{ok:true,run:r}}throw Error("未知命令")})().then(reply).catch(e=>reply({ok:false,error:e.message}));return true});resume();
|
||||
@@ -1,9 +0,0 @@
|
||||
const test=require('node:test'),assert=require('node:assert/strict'),c=require('../core');
|
||||
test('requires one unambiguous strict CG suffix',()=>{assert.deepEqual(c.addressSuffixes('A_cg123'),['_cg123']);assert.deepEqual(c.addressSuffixes('_cg123 / _cg456'),['_cg123','_cg456']);assert.equal(c.addressSuffix('_cg01'),'')});
|
||||
test('amount accepts exact value and rejects truncation negative or estimate text',()=>{assert.equal(c.moneyCent('¥22.34'),2234);assert.equal(c.moneyCent('22.345'),null);assert.equal(c.moneyCent('-22.34'),null);assert.equal(c.moneyCent('预估实付 22.34'),null)});
|
||||
test('order number removes only copy tail and rejects prose',()=>{assert.equal(c.normalizeOrderNo('ABC-123 复制'),'ABC-123');assert.equal(c.normalizeOrderNo('ABC-123 其他'),'')});
|
||||
test('classification counts no CG before time validation',()=>{const x=c.classifyDetail({address:'普通地址',orderNo:'A1',submittedAt:'',paidAmount:''});assert.deepEqual([x.kind,x.code],['skip','CG_MISSING'])});
|
||||
test('CG without order number is explicit error',()=>{const x=c.classifyDetail({address:'地址_cg123',orderNo:'',submittedAt:'2026-09-18 10:00',paidAmount:''});assert.equal(x.code,'ORDER_NO_INVALID')});
|
||||
test('aggregation detects CG and reused-order conflicts before dedupe',()=>{const f=(cg,no)=>({kind:'candidate',item:{addressSuffix:cg,pddOrderNo:no,orderSubmittedAt:'2026-09-18T02:00:00.000Z',pddOrderAmountCent:null}}),x=c.aggregate([f('_cg123','A'),f('_cg123','B'),f('_cg456','C'),f('_cg789','C')]);assert.deepEqual(x.cgConflicts,['_cg123']);assert.deepEqual(x.orderConflicts,['C']);assert.equal(x.ready.length,0)});
|
||||
test('batches never exceed fifty',()=>assert.deepEqual(c.batches(Array.from({length:101}),50).map(x=>x.length),[50,50,1]));
|
||||
test('trusted UI sender cannot be PDD content sender',()=>{assert.equal(c.trustedUISender({id:'id',url:'chrome-extension://id/popup.html'},'id'),true);assert.equal(c.trustedUISender({id:'id',url:'https://mobile.yangkeduo.com/orders.html',tab:{}},'id'),false)});
|
||||
@@ -1,5 +0,0 @@
|
||||
const test=require('node:test'),assert=require('node:assert/strict'),fs=require('node:fs'),path=require('node:path'),{JSDOM}=require('jsdom'),Core=require('../core');
|
||||
function load(name){const d=new JSDOM(fs.readFileSync(path.join(__dirname,'fixtures',name),'utf8'),{url:'https://mobile.yangkeduo.com/orders.html'});global.document=d.window.document;global.window=d.window;global.scrollY=0;global.scrollTo=()=>{};d.window.HTMLElement.prototype.getBoundingClientRect=()=>({width:100,height:20});global.self={GoAutoBackfillCore:Core};global.chrome={runtime:{onMessage:{addListener(){}}}};delete require.cache[require.resolve('../content')];return require('../content')}
|
||||
test('synthetic list exposes one product row, excluding header and payment',()=>{const x=load('list.html');assert.equal(x.entries().length,1);assert.equal(x.entries()[0].className,'product');assert.equal(x.summary().endEvidence,true)});
|
||||
test('synthetic detail parses label siblings, not estimated-payment prose',async()=>{const x=load('detail.html'),f=await x.detail(),r=Core.classifyDetail(f);assert.equal(r.kind,'candidate');assert.equal(r.item.addressSuffix,'_cg123');assert.equal(r.item.pddOrderNo,'ABC-123');assert.equal(r.item.pddOrderAmountCent,2234)});
|
||||
test('detail expands address and order controls and waits for delayed fields',async()=>{const x=load('detail-delayed.html');document.getElementById('address').onclick=()=>setTimeout(()=>document.getElementById('address-fields').innerHTML='<div><span>收货地址</span><span>合成路_cg123</span></div>',150);document.getElementById('order').onclick=()=>setTimeout(()=>document.getElementById('order-fields').innerHTML='<div><span>订单号:</span><span>ABC-123</span><i>复制</i></div><div><span>下单时间:</span><span>2026-09-18 10:00:00</span></div>',300);const r=Core.classifyDetail(await x.detail());assert.equal(r.kind,'candidate');assert.equal(r.item.pddOrderNo,'ABC-123')});
|
||||
@@ -1 +0,0 @@
|
||||
<!doctype html><body><button id="address">展开收货信息</button><button id="order">展开订单信息</button><section id="address-fields"></section><section id="order-fields"></section></body>
|
||||
-1
@@ -1 +0,0 @@
|
||||
<!doctype html><body><button>展开收货信息</button><section><div><span>收货地址</span><span>合成路_cg123</span></div><div><span>订单号</span><span>ABC-123</span><i>复制</i></div><div><span>下单时间</span><span>2026-09-18 10:00:00</span></div><div><span>实付</span><span>22.34</span></div></section></body>
|
||||
-1
@@ -1 +0,0 @@
|
||||
<!doctype html><body><div class="react-base-list"><div class="synthetic-card"><div class="shop"><img alt="合成店铺"></div><div class="product"><img alt="合成商品"></div><div>实付</div><div>立即付款</div></div></div><p>没有更多了</p></body>
|
||||
@@ -1,12 +0,0 @@
|
||||
const test=require('node:test'),assert=require('node:assert/strict'),fs=require('node:fs'),vm=require('node:vm'),path=require('node:path'),{webcrypto}=require('node:crypto'),Core=require('../core');
|
||||
const source=fs.readFileSync(path.join(__dirname,'..','service-worker.js'),'utf8');
|
||||
function harness({initial={},content,fetcher,initialURL='https://mobile.yangkeduo.com/orders.html'}={}){const session={...initial},listeners=[];let tabURL=initialURL;const area={async get(k){if(Array.isArray(k))return Object.fromEntries(k.map(x=>[x,session[x]]));return{[k]:session[k]}},async set(v){Object.assign(session,v)},async setAccessLevel(){}};const events=()=>({addListener(){}});const chrome={runtime:{id:'extid',onInstalled:events(),onStartup:events(),onMessage:{addListener(fn){listeners.push(fn)}}},storage:{session:area,local:area},tabs:{async get(){return{id:7,url:tabURL,status:'complete'}},async sendMessage(_id,m){return content?content(m):{ok:true,empty:true,entryCount:0,endEvidence:true,scrollY:0}},async update(_id,v){if(v.url)tabURL=v.url},async goBack(){tabURL='https://mobile.yangkeduo.com/orders.html'}}};const ctx={self:null,chrome,URL,AbortController,setTimeout,clearTimeout,fetch:fetcher||global.fetch,crypto:webcrypto,console,__GOAUTO_TEST_TIMEOUT__:5};ctx.self=ctx;ctx.importScripts=()=>ctx.self.GoAutoBackfillCore=Core;vm.runInNewContext(source,ctx);const send=(m,s={id:'extid',url:'chrome-extension://extid/popup.html'})=>new Promise(resolve=>listeners[0](m,s,resolve));return{session,send}}
|
||||
const sleep=ms=>new Promise(r=>setTimeout(r,ms));
|
||||
test('rejects content sender, serializes START, and persists stopped terminal',async()=>{const h=harness({content:async()=>new Promise(()=>{})});let r=await h.send({source:'goauto-popup',type:'START',tabId:7,clientKey:'k',frozen:{origin:'https://admin.test',days:2,startedAt:new Date().toISOString()}},{id:'extid',url:'https://mobile.yangkeduo.com/orders.html',tab:{}});assert.equal(r.ok,false);r=await h.send({source:'goauto-popup',type:'START',tabId:7,clientKey:'k',frozen:{origin:'https://admin.test',days:2,startedAt:new Date().toISOString()}});assert.equal(r.ok,true);const second=await h.send({source:'goauto-popup',type:'START',tabId:7,clientKey:'k2',frozen:{origin:'https://other.test',days:2,startedAt:new Date().toISOString()}});assert.equal(second.ok,false);await h.send({source:'goauto-popup',type:'STOP'});await sleep(120);assert.equal(h.session.orderBackfillRun.phase,'stopped')});
|
||||
test('runtime error persists failed terminal state',async()=>{const h=harness({content:async()=>{throw Error('synthetic failure')}});await h.send({source:'goauto-popup',type:'START',tabId:7,clientKey:'k',frozen:{origin:'https://admin.test',days:2,startedAt:new Date().toISOString()}});await sleep(120);assert.equal(h.session.orderBackfillRun.phase,'failed');assert.equal(h.session.orderBackfillRun.error.message,'synthetic failure')});
|
||||
test('restart resumes stable batch with frozen origin/key and validates response',async()=>{let call;const stable='00000000-0000-4000-8000-000000000316',item={addressSuffix:'_cg123',pddOrderNo:'A',orderSubmittedAt:'2026-09-18T02:00:00Z',pddOrderAmountCent:null},run={id:'run1',tabId:7,listURL:'https://mobile.yangkeduo.com/orders.html',phase:'submitting',frozen:{origin:'https://frozen.test',days:2,startedAt:'2026-09-18T03:00:00Z'},batches:[{index:0,requestId:stable,status:'sending',items:[item],results:[],attempts:1}],results:[],rows:[],localErrors:[],stats:{read:1,ready:1,written:0,conflicts:0,skippedNoCG:0},scanComplete:true,stopRequested:false};const h=harness({initial:{orderBackfillRun:run,orderBackfillSecret:{runId:'run1',origin:'https://frozen.test',clientKey:'secret'}},fetcher:async(url,o)=>{call={url,auth:o.headers.Authorization,body:JSON.parse(o.body)};return{ok:true,async json(){return{data:{items:[{index:0,taskId:123,result:'backfilled',code:'BACKFILLED',retryable:false}]}}}}}});await sleep(10);assert.equal(call.url,'https://frozen.test/api/client/v1/purchase-tasks/order-backfill');assert.equal(call.auth,'Bearer secret');assert.equal(call.body.requestId,stable);assert.equal(h.session.orderBackfillRun.phase,'completed')});
|
||||
test('timeout remains retryable and does not confirm batch',async()=>{const item={addressSuffix:'_cg123',pddOrderNo:'A'},run={id:'run2',tabId:7,listURL:'https://mobile.yangkeduo.com/orders.html',phase:'submitting',frozen:{origin:'https://frozen.test',days:2,startedAt:'2026-09-18T03:00:00Z'},batches:[{index:0,requestId:'run2:batch:0',status:'pending',items:[item],results:[],attempts:0}],results:[],rows:[],localErrors:[],stats:{read:1,ready:1,written:0,conflicts:0,skippedNoCG:0},scanComplete:true,stopRequested:false};const h=harness({initial:{orderBackfillRun:run,orderBackfillSecret:{runId:'run2',origin:'https://frozen.test',clientKey:'secret'}},fetcher:(_u,o)=>new Promise((_r,reject)=>o.signal.addEventListener('abort',()=>reject(Object.assign(Error('abort'),{name:'AbortError'}))))});h.session.__unused=1;await sleep(30);assert.notEqual(h.session.orderBackfillRun.batches[0].status,'confirmed')});
|
||||
test('retryable item response remains resumable and unconfirmed',async()=>{const item={addressSuffix:'_cg123',pddOrderNo:'A'},run={id:'run3',tabId:7,listURL:'https://mobile.yangkeduo.com/orders.html',phase:'submitting',frozen:{origin:'https://frozen.test',days:2,startedAt:'2026-09-18T03:00:00Z'},batches:[{index:0,requestId:'00000000-0000-4000-8000-000000000317',status:'pending',items:[item],results:[],attempts:0}],results:[],rows:[],localErrors:[],stats:{read:1,ready:1,written:0,conflicts:0,skippedNoCG:0},scanComplete:true,stopRequested:false};const h=harness({initial:{orderBackfillRun:run,orderBackfillSecret:{runId:'run3',origin:'https://frozen.test',clientKey:'secret'}},fetcher:async()=>({ok:true,async json(){return{data:{items:[{index:0,taskId:123,result:'failed',code:'INTERNAL',retryable:true}]}}}})});await sleep(15);assert.equal(h.session.orderBackfillRun.phase,'incomplete');assert.equal(h.session.orderBackfillRun.batches[0].status,'retryable');assert.equal(h.session.orderBackfillRun.results.length,0)});
|
||||
test('scan compares after lazy load and reaches explicit end evidence',async()=>{let count=1;const h=harness({content:async m=>{if(m.type==='SCROLL'){setTimeout(()=>count++,20);return{ok:true}}if(m.type==='LIST')return{ok:true,entryCount:count,endEvidence:count>=4,scrollY:count*100};return new Promise(()=>{})}});await h.send({source:'goauto-popup',type:'START',tabId:7,clientKey:'k',frozen:{origin:'https://admin.test',days:2,startedAt:new Date().toISOString()}});await sleep(3200);assert.equal(h.session.orderBackfillRun.entryCount,4);assert.equal(h.session.orderBackfillRun.scanComplete,true);assert.notEqual(h.session.orderBackfillRun.incompleteReason,'未看到列表末尾标记')});
|
||||
test('reading restart after saved row on list advances cursor without duplicate',async()=>{const saved={kind:'skip',code:'CG_MISSING',item:{}},run={id:'read1',tabId:7,listURL:'https://mobile.yangkeduo.com/orders.html',phase:'reading',detailState:'returning',entryCount:1,cursor:0,scrollY:0,rows:[saved],results:[],batches:[],localErrors:[],stats:{read:1,ready:0,written:0,conflicts:0,skippedNoCG:0},frozen:{origin:'https://admin.test',days:2,startedAt:new Date().toISOString()},scanComplete:true,stopRequested:false};const h=harness({initial:{orderBackfillRun:run,orderBackfillSecret:{runId:'read1',origin:'https://admin.test',clientKey:'k'}}});await sleep(20);assert.equal(h.session.orderBackfillRun.cursor,1);assert.equal(h.session.orderBackfillRun.rows.length,1);assert.equal(h.session.orderBackfillRun.phase,'completed')});
|
||||
test('reading restart on detail finishes current row then returns to list',async()=>{const run={id:'read2',tabId:7,listURL:'https://mobile.yangkeduo.com/orders.html',phase:'reading',detailState:'detail',entryCount:1,cursor:0,scrollY:200,rows:[],results:[],batches:[],localErrors:[],stats:{read:0,ready:0,written:0,conflicts:0,skippedNoCG:0},frozen:{origin:'https://admin.test',days:2,startedAt:new Date().toISOString()},scanComplete:true,stopRequested:false};const h=harness({initialURL:'https://mobile.yangkeduo.com/order.html',initial:{orderBackfillRun:run,orderBackfillSecret:{runId:'read2',origin:'https://admin.test',clientKey:'k'}},content:async m=>m.type==='DETAIL'?{ok:true,classified:{kind:'skip',code:'CG_MISSING',item:{}}}:{ok:true}});await sleep(1100);assert.equal(h.session.orderBackfillRun.cursor,1);assert.equal(h.session.orderBackfillRun.rows.length,1);assert.equal(h.session.orderBackfillRun.phase,'completed')});
|
||||
@@ -2,8 +2,8 @@
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Project-Profile
|
||||
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Project-Profile.-
|
||||
wiki_revision: 03ea269058b50fea2be7842b9c284018987c82d9
|
||||
synchronized_at: 2026-09-21T08:14:21Z
|
||||
wiki_revision: 3b78360779ae520f1ff9e51be3118a04cd549f51
|
||||
synchronized_at: 2026-09-07T09:27:40Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 项目档案
|
||||
|
||||
+2
-2
@@ -2,8 +2,8 @@
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Development-Workflow
|
||||
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Development-Workflow.-
|
||||
wiki_revision: 03ea269058b50fea2be7842b9c284018987c82d9
|
||||
synchronized_at: 2026-09-21T08:14:27Z
|
||||
wiki_revision: 62ddbe4469740c02ce4a6ca2fd1966a89a79322f
|
||||
synchronized_at: 2026-09-05T07:16:44Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 开发工作流
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Architecture-and-Code-Map
|
||||
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Architecture-and-Code-Map.-
|
||||
wiki_revision: 504e1ffaa2cf1989f55048ac7067d8af50fe3bf3
|
||||
synchronized_at: 2026-09-22T02:56:22Z
|
||||
wiki_revision: c8dd6b1f25bf367d065e0969a4de858d857a3181
|
||||
synchronized_at: 2026-09-18T02:18:10Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 架构与代码地图
|
||||
@@ -485,38 +485,3 @@ Web 唯一展示位置为“采集采购 → SYB 同步记录”:列表状态
|
||||
- sybclient/order_number.go:一次 POST updateDetailPurchaseCode,不跟随重定向、不使用读接口重试策略。会话仅从现有 SessionStore 恢复,不在 worker 自动登录/OCR;外部原始错误不写入任务原因。
|
||||
- Web purchase-tasks API/页面:共享勾选、按动作筛选、独立状态列/详情及逐项接受结果;付款和物流流程保持原样。
|
||||
- 验证入口:go test ./app/goauto/purchase ./app/goauto/sybclient ./app/goauto/access ./app/goauto/migrations ./cmd/migrate/migration/version-local;Web tests/e2e/purchase-order-writeback.spec.ts。测试只使用隔离SQLite和fake/httptest,不代表MySQL多实例或真实SYB验收。
|
||||
|
||||
### 会话类失败自动重试(#330)
|
||||
|
||||
- purchase/order_writeback_worker.go:restoreOrderWritebackClient 在 ImportCookiesJSON 后调用 sybclient.CheckSession,UserID<=0 显式判不可用;finishSessionUnavailable 复用 lease_expires_at 作为退避到期时间(maxSessionRetryAttempts=6,sessionRetryBackoff 5/10/15/30/30m),领取条件增加 failed+SYB_SESSION_UNAVAILABLE+到期+未达上限。无迁移。
|
||||
- purchase/order_writeback.go:会话类失败的 CanSubmit 不受退避租约限制;手工重新提交 attempt_count 置 0。
|
||||
- 验证:go test ./app/goauto/purchase(含 httptest 模拟 /am/user/get 与断言 syb_session 未删除)。
|
||||
|
||||
## Chrome PDD 订单回填扩展(#316)
|
||||
|
||||
- `chrome-extension/` 是独立 Manifest V3 交付单元:popup 只负责配置、启动/停止、状态轮询和逐项结果;content script 只在 `mobile.yangkeduo.com` 的隔离世界中按可见 DOM 串行读取;service worker 负责持久运行状态、聚合冲突和分批提交。
|
||||
- 列表遍历限定虚拟列表订单卡片的商品行,排除店铺头和支付、确认收货、退款、售后、取消、更多等动作区;详情只展开收货/订单信息。未看到明确列表末尾证据时必须标记未完整扫描,不得宣称全量。
|
||||
- 客户端密钥与冻结的 Admin Origin 只保存在扩展可信上下文;content script 无权读取。请求禁用 Cookie 与重定向,批次最多 50 条,稳定 UUID 可在 service worker 重启或超时后重放;已确认批次不重复计数。
|
||||
- 服务端 `POST /api/client/v1/purchase-tasks/order-backfill` 复用 Agent 回填事务校验,但使用独立 client principal,不伪造 Device Token 或 deviceId。Agent 路径仍严格保持设备归属校验及失败后按设备范围回读。
|
||||
|
||||
## 下单结果读取及应付总额链路(#325)
|
||||
|
||||
分支 fix/325-order-result,2026-09-19,尚未发布及真实下单验收。PurchaseLiveAutomation.readOrderResult 采用25次支付页返回后采样、首次待付款页独立30次采样/最多4次按需滑动;原过渡60次与总90次采样上限保留有界终止。parsePayableAmount只从本次只读标签集合提取明确应付总额。
|
||||
|
||||
数据链路:PurchaseOrderEvidence.pddOrderAmountCent → PurchaseExecutionOutcome → service/PurchaseResultPayload.kt → purchase.ResultRequest → lifecycle.go 的 order_created 分支 → 既有 purchase_task.pdd_order_amount_cent。重启只读核单也沿用同一可选字段。复用已有结果Outbox、幂等哈希和订单号唯一性;不新增表、配置、任务或SYB金额字段。未修改我的订单回填、Chrome扩展和Web,不能将旧入口的实付含义描述为本次已改成应付。
|
||||
|
||||
## Admin 订单资料补全与一致回填资格(#326)
|
||||
|
||||
实现3a2472d(2026-09-19),Server/Web,未合并发布;无迁移、无Android/插件改动。
|
||||
|
||||
- purchase/types.go ManualRequest 增加可选 pddOrderAmountCent;manual.go ResolveUnknown 复用既有行锁事务和模型单号保护,扩展为未知结果确认或已创建订单补缺。仅此操作启用新增重放内容校验和事务内 ensureOrderWriteback,其他人工付款/取消/授权流程不新增入队副作用。
|
||||
- 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,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: 4b61d5dcc75c459831e6b1f0d556b09d6acd0310
|
||||
synchronized_at: 2026-09-18T02:58:16Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 业务规则与术语
|
||||
@@ -332,7 +332,7 @@ synchronized_at: 2026-09-22T02:56:27Z
|
||||
- 一个 PDD 商品可能被多个虾皮商品共用,因此规格匹配按受影响的每个虾皮商品分项记录。主表只表达总体进度;Agent 展示和“继续采购”资格必须读取当前任务对应虾皮商品的分项状态。
|
||||
- 选错替代商品时,连续执行 B→C 不等于撤销 A→B,因为 B 可能还关联其他虾皮商品。正确纠错语义是把原 A→B 记录置为 `superseded`,再建立 A→C,并只处理原记录分项中冻结的影响集合。
|
||||
- 创建请求按 `create_request_id` 幂等;重放时源商品、替代商品、来源类型、来源任务、采集证据和发起设备必须一致,否则返回幂等冲突,不能静默覆盖。
|
||||
- `created_by_device_id` 只代表 Agent 设备。设备可由管理员绑定到一个采购员账号;一个采购员可拥有多台设备,一台设备最多归属一个采购员,也允许暂不归属。SYB 商品一键关联/替换按当前采购员选择的归属设备读取该设备最新临时采集,不使用其他账号或其他设备的全局最新记录。
|
||||
- `created_by_device_id` 只代表 Agent 设备。当前系统没有设备到采购员账号的绑定,多人多机场景若需要个人责任追踪,必须另建工单实现设备绑定操作员。
|
||||
|
||||
|
||||
## PDD 商品替换生效与规格匹配(#131)
|
||||
@@ -630,53 +630,9 @@ Web 唯一展示位置为“采集采购 → SYB 同步记录”:列表状态
|
||||
订单回填状态独立于采购成功、支付复核及物流 writeback_status。实付金额只存 Admin,SYB cost=0 为接口固定参数,不以金额推断付款。SYB 接口会同时更新采购状态/平台/时间,不能视作纯展示修改。
|
||||
|
||||
采购管理增加独立状态列、批量回填和详情补偿;复用既有访问权限,不增支付确认或审批。批量受理与最终成功分开展示;重试采购和回填分别筛选勾选项。远端无原子CAS,对系统外人工并发修改/超长延迟请求不能承诺绝对互斥;有冲突应人工核对,禁止强制覆盖。
|
||||
### SYB 会话类失败的有界自动重试(#330)
|
||||
|
||||
实现 01510a8/08b7095(2026-09-21,已合并 main,未部署、未生产验证)。回填 worker 从缓存会话恢复客户端后调用 SYB 会话校验;会话缺失/过期、串号失效、校验网络错误等均记为 `SYB_SESSION_UNAVAILABLE`,error_message 只记录类别和“将自动重试;如持续失败请恢复登录后重试”,不含原始错误。该类失败发生在任何写入之前,最多自动重试 6 次,退避 5/10/15/30/30 分钟(约 90 分钟,大于一个整点同步周期),达上限保持 failed 等人工。会话仍只由每小时 SYB 同步刷新;回填不登录、不 OCR、不删除或写入会话。其他失败码仍不自动重试。退避期内可手工重新回填,手工提交重置尝试次数。历史失败记录不会被自动领取。
|
||||
|
||||
## Agent 回填订单入口兼容(#307)
|
||||
|
||||
- 打开我的订单后最多采样8次,每次间隔1秒,连续两次订单导航结构一致才继续;登录/风控仍立即停止,超时给出明确原因。
|
||||
- 保留全部标签selected=true的原路径。PDD不提供selected属性时,要求订单标题、唯一全部标签以及同排至少两个订单状态标签;仅该安全导航标签允许一次中心手势,不扩大到订单卡片、地址、支付或其他操作。
|
||||
- 本轮主动点击全部后复核订单导航结构;本轮上下文不能跨扫描复用,出现其他已选中订单状态时停止。卡片识别与支付禁令保持不变。
|
||||
- 回填结束/失败通过现有通知与Toast显示脱敏原因;详细结果仍在Agent采购页。本变更不调整服务端回填接口、采集或采购流程。
|
||||
|
||||
## 下单后待付款核单与应付金额(#325)
|
||||
|
||||
实现分支 fix/325-order-result,2026-09-19;未部署、未真机验收。下单点击及不可逆边界不改。已知 PayActivity 允许一次原有 Back,随后从3次放宽为25次200ms采样(约5秒等待,不含页面捕获耗时),持续无证据仍为 PURCHASE_ORDER_PAYMENT_REPEATED。微信恢复、系统选择器、空窗口和陌生应用的既有边界保留。
|
||||
|
||||
过渡阶段最多60次采样。首次识别到待付款证据后单独分配30次采样预算,不被之前绕路消耗,不因文字反复出现重置;核单总采样上限90次。缺少唯一订单号或时间时立即向上滑动读取下方内容,最多4次、手势400ms、每次后等500ms;读全立即停止,已有完整字段不滑动。该待付款读取分支不点击任何控件,也不点击付款。纯支付页不使用该滑动分支;出现可读待付款证据优先于复用的 Activity 名称。按采样间隔约18秒加有限手势/额外等待,实际耗时还含capture等调用;未改变2分钟任务租约或5分钟唤醒锁,也不宣称新增了执行器总体墙钟超时机制。
|
||||
|
||||
应付金额从同一订单的可见标签集合中读取,兼容“应付:,13元”等标点;不跨无关联节点拼接,不读拼单价、优惠或实付。父子重复同金额去重,多值/缺失/无效/溢出省略,不因金额失败丢弃订单号与时间。仅存Admin,不推断支付、不改SYB金额、不修改地址行为。历史回填及界面语义未在本单统一,详见API契约#325补充。
|
||||
|
||||
## 采购订单资料完整性及简化单号回填(#326)
|
||||
|
||||
用户2026-09-19确认内部系统简化流程,原型v1通过。实现3a2472d,尚未发布或真实SYB验收。
|
||||
|
||||
采购任务保存 PDD 订单号、下单时间和订单金额即为资料完整,不检查是否已付款;金额0元有效。复用 pddOrderNo/orderSubmittedAt/pddOrderAmountCent,金额以人民币分存储。order_created 不改义,仍是已创建订单事实;缺金额展示“订单信息待补全”,不能当作采购失败再次下单。页面统一称“订单金额”,其来源可能是#325应付或既有回填实付,不能将历史值统一称已付金额。
|
||||
|
||||
Admin 复用未知结果处理弹窗增加金额输入,并为 live 已创建且缺资料任务提供“补全订单信息”;已有字段只读、缺项可填。保存中禁用提交,失败保留输入;沿用既有权限,不增加审批。非执行中的未知结果人工确认与已有订单资料补全都会事务内保证单号回填记录,不修改支付复核或触发设备操作。
|
||||
|
||||
SYB 回填与资料完整性独立:正式SYB任务已有单号且仍关联有效明细即可入队,不要求 order_created 状态、时间、金额、付款确认或地址后缀。只有订单号上传SYB;沿用同号成功、异号冲突不覆盖及既有在途防重复规则,不倒扫历史任务。历史补偿仍手动勾选,已成功或在途任务不可重复发起。
|
||||
|
||||
自动下单读取和Admin补录是后续目标来源;本单尚未删除插件或Agent我的订单回填,不能宣称上游地址后缀已取消或历史错误归属已修复。金额缺失不得自动采购,订单号唯一归属保护和永久不付款规则不变。
|
||||
|
||||
## 采购管理已回填 SYB 筛选(#327)
|
||||
|
||||
实现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,8 +2,8 @@
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Local-Development-and-Verification
|
||||
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Local-Development-and-Verification.-
|
||||
wiki_revision: 03ea269058b50fea2be7842b9c284018987c82d9
|
||||
synchronized_at: 2026-09-21T08:14:42Z
|
||||
wiki_revision: 835494c4a63a1494601658561fde1ab76657be3d
|
||||
synchronized_at: 2026-09-05T07:17:05Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 本地开发与验证
|
||||
@@ -335,18 +335,3 @@ adb shell am start -n cn.ilapage.goauto.agent/.MainActivity
|
||||
- APK 文件目录使用 `GOAUTO_AGENT_RELEASE_DIR`;缺省为服务端工作目录下 `var/goauto-agent-releases`,不得映射成公开静态目录。部署需让 Admin/API 进程对该目录具有创建、写入、读取和删除临时失败文件的权限。
|
||||
- 真机发布验证前确认设备空闲,再由管理员上传一个签名一致、versionCode 更高的 APK并显式设为当前。依次验证启动静默提示、设置页手动检查、下载进度/取消、断网、哈希不一致删除、未知来源引导和系统确认安装。任务执行中全部更新动作必须被阻止,心跳与前台服务继续运行。
|
||||
- 上传或设为当前属于发布动作,安装会改变设备应用版本;没有用户独立授权时不得执行。安装后系统可能关闭无障碍服务,只能由用户在系统设置重新开启。
|
||||
|
||||
## Chrome 订单回填扩展验证(#316)
|
||||
|
||||
从仓库根目录执行:
|
||||
|
||||
```powershell
|
||||
cd chrome-extension
|
||||
npm install
|
||||
npm test
|
||||
node --check service-worker.js
|
||||
node --check content.js
|
||||
node --check popup.js
|
||||
```
|
||||
|
||||
自动化测试只使用合成 DOM 与 Chrome API mock,覆盖列表商品行负向选择、双展开与延迟字段、解析/冲突、启动互斥、停止、错误终态、扫描延迟增长、详情恢复、提交恢复、冻结配置、超时和可重试项。真实 PDD 浏览器点击、扩展安装和真实 Admin 上传不属于自动化验证,必须取得独立授权并在结果中明确区分。
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Common-Changes
|
||||
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Common-Changes.-
|
||||
wiki_revision: 03ea269058b50fea2be7842b9c284018987c82d9
|
||||
synchronized_at: 2026-09-21T08:14:50Z
|
||||
wiki_revision: b1b1b343917e66288f4282bc6b3b90ea4ff3cca0
|
||||
synchronized_at: 2026-09-04T11:30:12Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 常见修改指南
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Troubleshooting
|
||||
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Troubleshooting
|
||||
wiki_revision: 03ea269058b50fea2be7842b9c284018987c82d9
|
||||
synchronized_at: 2026-09-21T08:14:54Z
|
||||
wiki_revision: 18744477bfd17f396e8c76ec7a2fcc6720acdf6f
|
||||
synchronized_at: 2026-09-11T09:10:00Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 故障排查
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Product-Requirements-Overview
|
||||
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Product-Requirements-Overview.-
|
||||
wiki_revision: 03ea269058b50fea2be7842b9c284018987c82d9
|
||||
synchronized_at: 2026-09-21T08:15:00Z
|
||||
wiki_revision: b1b1b343917e66288f4282bc6b3b90ea4ff3cca0
|
||||
synchronized_at: 2026-09-04T11:30:20Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 产品需求总览与当前 MVP
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Android-Agent-API-Contract
|
||||
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Android-Agent-API-Contract.-
|
||||
wiki_revision: 03ea269058b50fea2be7842b9c284018987c82d9
|
||||
synchronized_at: 2026-09-21T08:15:05Z
|
||||
wiki_revision: 5a61e4f2cf2bb33b824ca722e9cee03071e1dffd
|
||||
synchronized_at: 2026-09-18T02:18:33Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
<!-- gitea-wiki-mirror:start -->
|
||||
@@ -1350,7 +1350,7 @@ Android 仅人工“回填”扫描识别单节点“实付”标签,兼容实
|
||||
|
||||
实现绑定 e89de1a(2026-09-18,feat/305-syb-order-writeback),已完成代码及隔离测试;未合并、未部署、未执行业务库迁移或真实 SYB 写入。
|
||||
|
||||
- 普通 Agent order_created 结果和人工 order-backfill 首次成功/同单号 already_backfilled 共用事务内 ensureOrderWriteback。仅 live + syb_order + 有效 PDD 单号与 SYB stock/detail 关联时建立唯一 pending 记录(#326 起不限制任务状态);网络写入由事务外 worker 执行。不改变 Agent 请求/响应字段;金额缺失/冲突不阻止单号入队,不依赖 paymentReviewStatus。
|
||||
- 普通 Agent order_created 结果和人工 order-backfill 首次成功/同单号 already_backfilled 共用事务内 ensureOrderWriteback。仅 live + syb_order + order_created + 有效 PDD 单号与 SYB stock/detail 关联时建立唯一 pending 记录;网络写入由事务外 worker 执行。不改变 Agent 请求/响应字段;金额缺失/冲突不阻止单号入队,不依赖 paymentReviewStatus。
|
||||
- POST /api/admin/v1/purchase-tasks/syb-order-writeback:沿用管理员/采购员采购管理权限;body 为 {requestId: UUID, purchaseTaskIds: 正整数数组},1~100条,不允许重复ID。单条补偿也使用本接口。
|
||||
- 返回 HTTP 200、{code:200,data:{items:[{taskId,result,reason?}]}};result 为 pending/succeeded/skipped/conflict。pending 仅表示受理,不代表远端已写入。无资格或不存在的条目独立 skipped,其他条目继续;数据库事务故障整次命令回滚。服务端校验与冲突错误沿用采购错误信封。
|
||||
- requestId 持久幂等:对排序后的 ID 集合取摘要,相同 requestId/集合返回原接受结果,即使后台状态已推进;改变集合返回422。查询当前完成状态应重新GET,不能以重放响应判断最终成功。
|
||||
@@ -1359,43 +1359,3 @@ Android 仅人工“回填”扫描识别单节点“实付”标签,兼容实
|
||||
- 批量勾选取“可采购重试 OR 可单号回填”的并集,两个按钮各自过滤子集;不得把 order_created 任务送入采购重试。冲突只查看,不提供覆盖。unknown 人工补偿仍先回读,成功仅指回读确认相同单号与 pdd 平台。
|
||||
|
||||
追加迁移 1789800200000_purchase_order_writeback.go 建立独立记录、命令和单例租约表。先授权迁移及权限对账后再运行新版 Server/Web;本实现基于 #306,发布时同时核验其金额列迁移依赖。历史记录不在迁移时自动入队。
|
||||
|
||||
## Chrome 客户端采购订单回填(#316)
|
||||
|
||||
`POST /api/client/v1/purchase-tasks/order-backfill`
|
||||
|
||||
- 认证:`Authorization: Bearer <客户端密钥>`;模块 `purchase_tasks`,必须显式具有 action `writeback`。模块 read、通用 write 或 purchase action 均不隐含 writeback;不接受 Admin Cookie/JWT、Device Token、URL 凭据或客户端指定 deviceId。
|
||||
- 请求和逐项响应沿用本文“Agent 采购订单批量回填”契约,`requestId` 为 UUID、每批 1~50 条。客户端先扫描并按 CG 与订单号双向聚合全部冲突,再分批提交;金额缺失省略,不能发送 0 代替。
|
||||
- 客户端入口不要求任务归属某一 Android 设备,但仍复用任务状态、订单号唯一、同批冲突、金额和时间校验。Agent 入口继续要求认证设备归属,两个入口的错误回读范围不得互相扩大。
|
||||
- `order_created` 且订单号相同只补空金额和空下单时间;已有金额或时间不覆盖。补入页面时间时保存 `backfill:page:` 来源标记并返回 `timeSource=page`;历史已有时间且无来源仍返回 `existing_unknown`。
|
||||
- 扩展必须冻结启动时的 Origin、客户端身份和天数窗口;禁止 content script 接触密钥。网络超时或服务端逐项 `retryable=true` 时使用原稳定批次 UUID 重试,只有通过数量、索引和字段完整性校验的响应才确认批次。
|
||||
|
||||
## 下单结果可选应付总额(#325)
|
||||
|
||||
实现分支 fix/325-order-result,2026-09-19;未部署、未真机下单验收。POST /api/agent/v1/purchase-tasks/:taskId/result 的 resultType=order_created 新增可省略/可空 int64 字段 pddOrderAmountCent,人民币分,语义为本次待付款页明确“应付”的订单总额。actualUnitPriceCent 仍为商品单价。仅在唯一订单号和下单时间完整时随成功结果发送,金额缺失/冲突/无效/溢出时 Agent 省略金额,不影响订单事实。服务端拒绝负数或其他 resultType 携带非空金额,字符串/小数/溢出按现有严格 JSON 解码失败。
|
||||
|
||||
服务端只为已成功确认且无订单号占用冲突的任务补写空 pdd_order_amount_cent;省略、null 或已有非空值均不清空/覆盖。字段纳入现有结果哈希,相同 requestId/attempt/载荷幂等,不同载荷拒绝。不改支付状态,不传金额到 SYB。原列已存在,无追加迁移;新 Agent 上报前须先发布此 Server,旧 Server 不接受新结果字段。
|
||||
|
||||
本单不修改 #306 人工订单回填、#316 Chrome 回填、Web“实付价格”显示或历史值:这些路径仍沿用原语义,不能据此宣称全局金额已统一为应付。此节仅定义新增采购结果入口。
|
||||
|
||||
## Admin 订单资料补全与字段型 SYB 入队(#326)
|
||||
|
||||
实现提交 3a2472d,2026-09-19,feat/326-order-information;隔离测试及构建通过,未合并 main、未发布、未执行真实 SYB 写入。
|
||||
|
||||
复用 POST /api/admin/v1/purchase-tasks/:id/resolve-unknown 及其原有管理员/采购员权限:live 的 order_result_unknown 可人工确认订单或取消;live 的 order_created 可补全订单信息,但不能通过此接口改成 cancelled。status=order_created 时沿用 pddOrderNo、orderSubmittedAt,增加可选非负整数分 pddOrderAmountCent,复用原数据库列。订单号非空、无首尾空白/换行且不超过100字符,时间须为有效非零时间;旧客户端省略金额仍接受并保留已有值。新 Admin 补录表单要求金额,0元合法,最多两位小数,转换为整数分后提交。
|
||||
|
||||
已保存非空字段只补缺,不覆盖不同单号、时间或金额;订单号唯一归属仍由原模型保护。相同 requestId/已保存内容重放不重复保存,改变处理结果或已保存字段返回原采购冲突错误。前端网络失败保留输入及相同载荷的 requestId;修改载荷使用新 requestId。复用原审计字段 UnknownResolveRequestID,无新增表或持久化状态。
|
||||
|
||||
资料完整性由订单号、有效下单时间和非空非负订单金额推导,页面显示“信息完整”或“订单信息待补全”。order_created 仍表示已创建订单,缺金额不转 failed,不代表已付款,不重新采购。
|
||||
|
||||
人工确认/补录订单在同一事务中 ensureOrderWriteback;原 Agent order_created 与插件/Agent order-backfill 保持兼容。SYB 回填仅要求 live + syb_order + 非空订单号 + 有效关联 stock/detail;不检查任务状态、时间、金额、支付复核或地址后缀。列表批量读取有效关联,提交与 worker 校验相同目标范围。既有 pending/running/succeeded 不重复入队,conflict 不覆盖,unknown 保留原回读及租约规则。字段补全不恢复已失败队列,失败队列仍通过原“回填 SYB 单号”补偿操作处理。
|
||||
|
||||
仅回填单号,cost=0/created为空;金额、时间只存 Admin。旧物流 paid 候选流程不改,Android 与 Chrome 上传定位契约未改;今后停用插件和 Agent 回填须另行实施。本次无需数据库迁移;先发布新 Server 再发布发送金额字段的新 Web。
|
||||
|
||||
## Admin 采购列表已回填单号筛选(#327)
|
||||
|
||||
实现368f2c2(2026-09-19),feat/327-syb-writeback-filter;测试和构建通过,未合并发布。
|
||||
|
||||
GET /api/admin/v1/purchase-tasks 的既有 status 参数新增查询专用值 syb_writeback_succeeded。该值按独立 purchase_order_writeback.status=succeeded 且 purchase_task_id 对应当前采购任务过滤,不查询旧物流 writeback_status,不新增或改写任务状态。原 status 合法值及其他参数行为保持不变。
|
||||
|
||||
匹配成功回填的历史任务,不额外要求当前采购状态为 order_created;返回的 task.status 仍为真实采购状态。无单号队列及 pending/running/failed/unknown/conflict 不命中。count及分页共用子查询过滤,可与 taskId、executionMode、taskType、sybProductId、shopeeOrderNo、pddOrderNo 组合;status为空恢复不限制状态。权限与返回信封不变,不写入数据、不触发采购或SYB请求。先发布Server再发布新增筛选值的Web,无数据库迁移或Android变更。
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Delivery-Issues
|
||||
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Delivery-Issues.-
|
||||
wiki_revision: 03ea269058b50fea2be7842b9c284018987c82d9
|
||||
synchronized_at: 2026-09-21T08:15:09Z
|
||||
wiki_revision: b1b1b343917e66288f4282bc6b3b90ea4ff3cca0
|
||||
synchronized_at: 2026-09-04T11:30:28Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 当前 MVP 交付工单索引
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: OnePlus-Real-Device-Acceptance
|
||||
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/OnePlus-Real-Device-Acceptance.-
|
||||
wiki_revision: 03ea269058b50fea2be7842b9c284018987c82d9
|
||||
synchronized_at: 2026-09-21T08:15:13Z
|
||||
wiki_revision: b1b1b343917e66288f4282bc6b3b90ea4ff3cca0
|
||||
synchronized_at: 2026-09-04T11:30:32Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 一加真机验收记录
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: PDD-Detail-Rule-Migration-Analysis
|
||||
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/PDD-Detail-Rule-Migration-Analysis.-
|
||||
wiki_revision: 03ea269058b50fea2be7842b9c284018987c82d9
|
||||
synchronized_at: 2026-09-21T08:15:17Z
|
||||
wiki_revision: b1b1b343917e66288f4282bc6b3b90ea4ff3cca0
|
||||
synchronized_at: 2026-09-04T11:30:37Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# PDD 商品详情采集规则迁移分析
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: SYB-ERP-Interface-Contract
|
||||
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/SYB-ERP-Interface-Contract.-
|
||||
wiki_revision: 03ea269058b50fea2be7842b9c284018987c82d9
|
||||
synchronized_at: 2026-09-21T08:15:30Z
|
||||
wiki_revision: 432e392ebe428ea19c0aa260f8e5938f36e4c3f0
|
||||
synchronized_at: 2026-09-18T02:18:53Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 12 顺云宝(SYB)ERP 接口契约
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Deployment-and-Operations
|
||||
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Deployment-and-Operations.-
|
||||
wiki_revision: cc642ebfa65f429100779e40560a59174ab877a0
|
||||
synchronized_at: 2026-09-22T02:56:36Z
|
||||
wiki_revision: 26ff59a7203d84dfe1a8e929fda24204960f1d7c
|
||||
synchronized_at: 2026-09-18T02:32:37Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 部署与运维
|
||||
@@ -135,21 +135,3 @@ Provider 故障日志只允许记录调用关联 ID、操作类型、耗时、
|
||||
- 原20260916目录的static/var与20260914目录互相循环,不能继续通过旧current间接链接。新release分别直接引用已确认真实存在的/home/goauto/releases/20260907-29ba16e-236/static和/home/goauto/releases/20260903-2d6d244-r1/var;不删除或改写历史资源,不修改其他站点Nginx配置。后续清理旧release前必须检查这些被引用的数据目录。
|
||||
- 公网入口http://185.216.248.75:9527首页和10项JS/CSS资源HTTP200,/api/v1/health HTTP200;未认证采购接口业务码401。二进制SHA256=4833d779843f0613455ea2b56cadff6efd88d2b96c13e00cb4423cac02e72cb7;发布后结构日志检查无panic/fatal/1146/1054。
|
||||
- 回滚代码目标保留/home/goauto/releases/20260916-03647dd,追加数据库表/列保留;该旧目录原有static/var循环是已知问题,回退不能宣称资源链自动恢复。不要删除新队列记录或恢复旧数据覆盖后续业务;停止服务亦不保证已发送远端请求取消。
|
||||
|
||||
## Chrome 订单回填扩展交付与运行(#316)
|
||||
|
||||
- 扩展以 `chrome-extension/manifest.json` 为入口,不随 Server 自动部署。解压安装、授予 Admin Origin、创建或修改客户端密钥授权、真实 PDD 遍历及订单回填都必须按目标环境另行授权;发布 Server 不等于安装或启用扩展。
|
||||
- 管理员为密钥显式勾选“采购管理 → 回填(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
-2
@@ -2,8 +2,8 @@
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Home
|
||||
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Home
|
||||
wiki_revision: 03ea269058b50fea2be7842b9c284018987c82d9
|
||||
synchronized_at: 2026-09-21T08:14:18Z
|
||||
wiki_revision: b1b1b343917e66288f4282bc6b3b90ea4ff3cca0
|
||||
synchronized_at: 2026-09-04T11:29:34Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# GoAuto 文档中心
|
||||
|
||||
Vendored
+2
-2
@@ -2,8 +2,8 @@
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Deployment-Template
|
||||
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Deployment-Template.-
|
||||
wiki_revision: 03ea269058b50fea2be7842b9c284018987c82d9
|
||||
synchronized_at: 2026-09-21T08:15:22Z
|
||||
wiki_revision: b1b1b343917e66288f4282bc6b3b90ea4ff3cca0
|
||||
synchronized_at: 2026-09-04T11:30:41Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 部署文档模板
|
||||
|
||||
Vendored
+2
-2
@@ -2,8 +2,8 @@
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Task-Archive-Template
|
||||
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Task-Archive-Template.-
|
||||
wiki_revision: 03ea269058b50fea2be7842b9c284018987c82d9
|
||||
synchronized_at: 2026-09-21T08:15:26Z
|
||||
wiki_revision: b1b1b343917e66288f4282bc6b3b90ea4ff3cca0
|
||||
synchronized_at: 2026-09-04T11:30:46Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
> 本模板只用于用户明确要求的专项历史快照或读取既有归档,不属于标准任务闭环。单次任务的唯一事实来源是 Gitea 工单;不要为了完成普通任务创建本页面,也不要自动导出到 `docs/task/`。
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 ""
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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},
|
||||
|
||||
@@ -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()
|
||||
@@ -91,43 +91,6 @@ func TestEveryRouteIsExplicitlyScoped(t *testing.T) {
|
||||
}
|
||||
}
|
||||
func keyFor(e Endpoint) string { return e.Method + " " + e.Path }
|
||||
|
||||
func TestOrderBackfillRequiresExplicitWritebackAction(t *testing.T) {
|
||||
db, s := fixture(t)
|
||||
e := Endpoint{Method: "POST", Path: "/purchase-tasks/order-backfill", Module: "purchase_tasks", Capability: "writeback"}
|
||||
router := gin.New()
|
||||
called := 0
|
||||
router.POST("/api/client/v1"+e.Path, Gate(db, e), func(c *gin.Context) { called++; c.JSON(200, gin.H{"data": "ok"}) })
|
||||
send := func(token string) int {
|
||||
req := httptest.NewRequest("POST", "https://example.test/api/client/v1"+e.Path, strings.NewReader(`{"requestId":"00000000-0000-4000-8000-000000000316","items":[]}`))
|
||||
if token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
}
|
||||
rr := httptest.NewRecorder()
|
||||
router.ServeHTTP(rr, req)
|
||||
return rr.Code
|
||||
}
|
||||
if got := send(""); got != 401 {
|
||||
t.Fatalf("missing key status %d", got)
|
||||
}
|
||||
if (clientkey.Key{GrantsJSON: `[{"module":"purchase_tasks","write":true}]`}).Allows("purchase_tasks", "writeback") {
|
||||
t.Fatal("generic write must not imply writeback")
|
||||
}
|
||||
_, writeOnly, err := s.Create(context.Background(), "purchase-only", []clientkey.Grant{{Module: "purchase_tasks", Actions: []string{"purchase"}}}, 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := send(writeOnly); got != 403 {
|
||||
t.Fatalf("generic write unexpectedly granted writeback: %d", got)
|
||||
}
|
||||
_, authorized, err := s.Create(context.Background(), "writeback", []clientkey.Grant{{Module: "purchase_tasks", Actions: []string{"writeback"}}}, 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := send(authorized); got != 200 || called != 1 {
|
||||
t.Fatalf("explicit writeback status=%d called=%d", got, called)
|
||||
}
|
||||
}
|
||||
func TestRevocationTransportRedactionAndAudit(t *testing.T) {
|
||||
db, s := fixture(t)
|
||||
v, token, err := s.Create(context.Background(), "test", []clientkey.Grant{{Module: "pdd_products"}}, 1)
|
||||
|
||||
@@ -105,7 +105,6 @@ func Inventory() []Endpoint {
|
||||
{"POST", "/purchase-tasks/batch", access.ModulePurchaseTasks, "purchase", buy.AdminBatchCreate},
|
||||
{"POST", "/purchase-tasks/batch-retry", access.ModulePurchaseTasks, "purchase", buy.AdminBatchRetry},
|
||||
{"POST", "/purchase-tasks/stock", access.ModulePurchaseTasks, "purchase", buy.AdminCreateStock},
|
||||
{"POST", "/purchase-tasks/order-backfill", access.ModulePurchaseTasks, "writeback", buy.ClientBackfillOrders},
|
||||
{"GET", "/ai-matching-settings", access.ModuleAIMatching, "read", func(c *gin.Context) {
|
||||
db, err := pkg.GetOrm(c)
|
||||
if err != nil {
|
||||
|
||||
@@ -11,7 +11,6 @@ import (
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/go-admin-team/go-admin-core/sdk/pkg"
|
||||
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
@@ -30,14 +29,6 @@ func (handler Handler) List(context *gin.Context) {
|
||||
writeError(context, internalError(err))
|
||||
return
|
||||
}
|
||||
if role, _ := jwt.ExtractClaims(context)["rolekey"].(string); role != "admin" {
|
||||
id := currentUserID(context)
|
||||
if id > 0 {
|
||||
request.OwnerUserID = &id
|
||||
} else {
|
||||
request.OwnerUserID = new(uint64)
|
||||
}
|
||||
}
|
||||
response, err := NewService(db).List(context.Request.Context(), request)
|
||||
if err != nil {
|
||||
writeError(context, err)
|
||||
@@ -46,68 +37,6 @@ func (handler Handler) List(context *gin.Context) {
|
||||
context.JSON(http.StatusOK, gin.H{"code": http.StatusOK, "data": response})
|
||||
}
|
||||
|
||||
func currentUserID(c *gin.Context) uint64 {
|
||||
// #333: go-admin's Authorizator runs on every request with the
|
||||
// IdentityHandler map, which has no "user" entry, so c.Get("userId") is
|
||||
// always 0 there. The JWT "identity" claim is the authenticated user id.
|
||||
switch id := jwt.ExtractClaims(c)["identity"].(type) {
|
||||
case float64:
|
||||
if id > 0 {
|
||||
return uint64(id)
|
||||
}
|
||||
case int:
|
||||
if id > 0 {
|
||||
return uint64(id)
|
||||
}
|
||||
case int64:
|
||||
if id > 0 {
|
||||
return uint64(id)
|
||||
}
|
||||
case uint64:
|
||||
return id
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (handler Handler) Owners(context *gin.Context) {
|
||||
db, err := handler.database(context)
|
||||
if err != nil {
|
||||
writeError(context, internalError(err))
|
||||
return
|
||||
}
|
||||
rows, err := NewService(db).Owners(context.Request.Context())
|
||||
if err != nil {
|
||||
writeError(context, err)
|
||||
return
|
||||
}
|
||||
context.JSON(http.StatusOK, gin.H{"code": http.StatusOK, "data": rows})
|
||||
}
|
||||
|
||||
func (handler Handler) SetOwner(context *gin.Context) {
|
||||
deviceID, err := strconv.ParseUint(context.Param("deviceId"), 10, 64)
|
||||
if err != nil || deviceID == 0 {
|
||||
writeError(context, invalidRequest("deviceId 无效"))
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
OwnerUserID *uint64 `json:"ownerUserId"`
|
||||
}
|
||||
if err := decodeJSON(context, &req); err != nil {
|
||||
writeError(context, invalidRequest("请求 JSON 无效"))
|
||||
return
|
||||
}
|
||||
db, err := handler.database(context)
|
||||
if err != nil {
|
||||
writeError(context, internalError(err))
|
||||
return
|
||||
}
|
||||
if err := NewService(db).SetOwner(context.Request.Context(), deviceID, req.OwnerUserID); err != nil {
|
||||
writeError(context, err)
|
||||
return
|
||||
}
|
||||
context.JSON(http.StatusOK, gin.H{"code": http.StatusOK, "data": gin.H{"deviceId": deviceID, "ownerUserId": req.OwnerUserID}})
|
||||
}
|
||||
|
||||
func (handler Handler) Register(context *gin.Context) {
|
||||
request, err := decodeRegisterRequest(context)
|
||||
if err != nil {
|
||||
|
||||
@@ -13,11 +13,10 @@ import (
|
||||
)
|
||||
|
||||
type ListRequest struct {
|
||||
Page int
|
||||
PageSize int
|
||||
Name string
|
||||
Status string
|
||||
OwnerUserID *uint64
|
||||
Page int
|
||||
PageSize int
|
||||
Name string
|
||||
Status string
|
||||
}
|
||||
|
||||
type DeviceListItem struct {
|
||||
@@ -37,8 +36,6 @@ type DeviceListItem struct {
|
||||
LastHeartbeatAt *time.Time `json:"lastHeartbeatAt"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
OwnerUserID *uint64 `json:"ownerUserId"`
|
||||
OwnerName string `json:"ownerName"`
|
||||
}
|
||||
|
||||
type DeviceListResponse struct {
|
||||
@@ -71,9 +68,6 @@ func (service *Service) List(ctx context.Context, request ListRequest) (DeviceLi
|
||||
if request.Status != "" {
|
||||
query = query.Where("status = ?", request.Status)
|
||||
}
|
||||
if request.OwnerUserID != nil {
|
||||
query = query.Where("owner_user_id = ?", *request.OwnerUserID)
|
||||
}
|
||||
var total int64
|
||||
if err := query.Count(&total).Error; err != nil {
|
||||
return DeviceListResponse{}, internalError(err)
|
||||
@@ -106,8 +100,8 @@ func (service *Service) List(ctx context.Context, request ListRequest) (DeviceLi
|
||||
AndroidVersion: record.AndroidVersion, AgentVersion: record.AgentVersion, PDDVersion: record.PDDVersion,
|
||||
Capabilities: capabilities,
|
||||
Status: record.Status, LastHeartbeatAt: record.LastHeartbeatAt,
|
||||
TokenRevoked: record.TokenRevokedAt != nil, OwnerUserID: record.OwnerUserID,
|
||||
CreatedAt: record.CreatedAt, UpdatedAt: record.UpdatedAt,
|
||||
TokenRevoked: record.TokenRevokedAt != nil,
|
||||
CreatedAt: record.CreatedAt, UpdatedAt: record.UpdatedAt,
|
||||
}
|
||||
if taskID, busy := currentTasks[record.ID]; busy {
|
||||
item.Busy = true
|
||||
|
||||
@@ -1,84 +0,0 @@
|
||||
package device
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
|
||||
"go-admin/app/goauto/models"
|
||||
)
|
||||
|
||||
// #333: mirrors the real go-admin middleware, whose Authorizator sets
|
||||
// userId to 0 on every request while the JWT claims carry the identity.
|
||||
func listDevicesAs(t *testing.T, handler Handler, claims jwt.MapClaims) []DeviceListItem {
|
||||
t.Helper()
|
||||
gin.SetMode(gin.TestMode)
|
||||
engine := gin.New()
|
||||
engine.GET("/devices", func(c *gin.Context) {
|
||||
c.Set(jwt.JwtPayloadKey, claims)
|
||||
c.Set("userId", 0)
|
||||
c.Next()
|
||||
}, handler.List)
|
||||
recorder := httptest.NewRecorder()
|
||||
engine.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/devices?page=1&pageSize=20", nil))
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("status=%d body=%s", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
var body struct {
|
||||
Data DeviceListResponse `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(recorder.Body.Bytes(), &body); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
return body.Data.Items
|
||||
}
|
||||
|
||||
func seedOwnedDevice(t *testing.T, handler Handler, name string, owner *uint64) models.AgentDevice {
|
||||
t.Helper()
|
||||
device := models.AgentDevice{
|
||||
InstallID: "install-" + name, Name: name, Manufacturer: "test", Model: "test",
|
||||
AndroidVersion: "14", AgentVersion: "1", PDDVersion: "1", CapabilitiesJSON: "[]",
|
||||
Status: models.DeviceStatusOnline, TokenDigest: fmt.Sprintf("digest-%s", name),
|
||||
TokenIssuedAt: time.Now(), OwnerUserID: owner,
|
||||
}
|
||||
if err := handler.DB.Create(&device).Error; err != nil {
|
||||
t.Fatalf("create device: %v", err)
|
||||
}
|
||||
return device
|
||||
}
|
||||
|
||||
func deviceNames(items []DeviceListItem) map[string]bool {
|
||||
names := map[string]bool{}
|
||||
for _, item := range items {
|
||||
names[item.Name] = true
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
func TestDeviceListPurchaserSeesOnlyOwnDevicesFromJWTIdentity(t *testing.T) {
|
||||
handler := Handler{DB: openTestDatabase(t)}
|
||||
two, three := uint64(2), uint64(3)
|
||||
seedOwnedDevice(t, handler, "caigou1-phone", &two)
|
||||
seedOwnedDevice(t, handler, "caigou2-phone", &three)
|
||||
seedOwnedDevice(t, handler, "unowned-phone", nil)
|
||||
|
||||
names := deviceNames(listDevicesAs(t, handler, jwt.MapClaims{"rolekey": "purchaser", "identity": float64(3)}))
|
||||
if len(names) != 1 || !names["caigou2-phone"] {
|
||||
t.Fatalf("purchaser 3 should see only own device, got %v", names)
|
||||
}
|
||||
|
||||
all := deviceNames(listDevicesAs(t, handler, jwt.MapClaims{"rolekey": "admin", "identity": float64(1)}))
|
||||
if len(all) != 3 {
|
||||
t.Fatalf("admin should see all devices, got %v", all)
|
||||
}
|
||||
|
||||
none := listDevicesAs(t, handler, jwt.MapClaims{"rolekey": "purchaser"})
|
||||
if len(none) != 0 {
|
||||
t.Fatalf("purchaser without identity must see no devices, got %d", len(none))
|
||||
}
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
package device
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"go-admin/app/goauto/models"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type DeviceOwnerItem struct {
|
||||
UserID uint64 `json:"userId"`
|
||||
Username string `json:"username"`
|
||||
NickName string `json:"nickName"`
|
||||
}
|
||||
|
||||
func (service *Service) Owners(ctx context.Context) ([]DeviceOwnerItem, error) {
|
||||
var rows []DeviceOwnerItem
|
||||
if err := service.DB.WithContext(ctx).Table("sys_user u").Select("u.user_id AS user_id, u.username, u.nick_name").Joins("JOIN sys_role r ON r.role_id = u.role_id").Where("u.status <> ? AND r.role_key <> ?", "1", "admin").Order("u.user_id").Scan(&rows).Error; err != nil {
|
||||
return nil, internalError(err)
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
func (service *Service) SetOwner(ctx context.Context, deviceID uint64, owner *uint64) error {
|
||||
if deviceID == 0 {
|
||||
return invalidRequest("deviceId 无效")
|
||||
}
|
||||
if owner != nil && *owner == 0 {
|
||||
return invalidRequest("ownerUserId 无效")
|
||||
}
|
||||
if owner != nil {
|
||||
var count int64
|
||||
if err := service.DB.WithContext(ctx).Table("sys_user u").Joins("JOIN sys_role r ON r.role_id = u.role_id").Where("u.user_id = ? AND u.status <> ? AND r.role_key <> ?", *owner, "1", "admin").Count(&count).Error; err != nil {
|
||||
return internalError(err)
|
||||
}
|
||||
if count != 1 {
|
||||
return invalidRequest("采购员不存在或不可分配")
|
||||
}
|
||||
}
|
||||
result := service.DB.WithContext(ctx).Model(&models.AgentDevice{}).Where("id = ?", deviceID).Update("owner_user_id", owner)
|
||||
if result.Error != nil {
|
||||
return internalError(result.Error)
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return gorm.ErrRecordNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseOwnerID(value string) (*uint64, error) {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return nil, nil
|
||||
}
|
||||
id, err := strconv.ParseUint(value, 10, 64)
|
||||
if err != nil || id == 0 {
|
||||
return nil, fmt.Errorf("ownerUserId 无效")
|
||||
}
|
||||
return &id, nil
|
||||
}
|
||||
@@ -21,8 +21,6 @@ func InitRouter(engine *gin.Engine, authMiddleware *jwt.GinJWTMiddleware) {
|
||||
|
||||
admin := engine.Group("/api/admin/v1/devices").Use(authMiddleware.MiddlewareFunc()).Use(middleware.AuthCheckRole())
|
||||
admin.GET("", handler.List)
|
||||
admin.GET("/owners", middleware.RequireRoleKey("admin"), handler.Owners)
|
||||
admin.PATCH("/:deviceId/owner", middleware.RequireRoleKey("admin"), handler.SetOwner)
|
||||
admin.POST("/:deviceId/disable", middleware.RequireRoleKey("admin"), handler.Disable)
|
||||
admin.POST("/:deviceId/identity-reset", middleware.RequireRoleKey("admin"), handler.ResetIdentity)
|
||||
admin.POST("/:deviceId/token/revoke", middleware.RequireRoleKey("admin"), handler.RevokeToken)
|
||||
|
||||
@@ -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{},
|
||||
|
||||
@@ -38,7 +38,6 @@ type AgentDevice struct {
|
||||
AndroidVersion string `json:"androidVersion" gorm:"size:32;not null"`
|
||||
AgentVersion string `json:"agentVersion" gorm:"size:32;not null"`
|
||||
PDDVersion string `json:"pddVersion" gorm:"size:32;not null"`
|
||||
OwnerUserID *uint64 `json:"ownerUserId" gorm:"column:owner_user_id;index"`
|
||||
CapabilitiesJSON string `json:"-" gorm:"size:4096;not null;default:'[]'"`
|
||||
Status string `json:"status" gorm:"size:16;not null;index;check:ck_agent_device_status,status IN ('online','offline','disabled')"`
|
||||
TokenDigest string `json:"-" gorm:"size:64;not null;uniqueIndex:ux_agent_device_token_digest"`
|
||||
|
||||
@@ -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" }
|
||||
@@ -10,7 +10,6 @@ import (
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/go-admin-team/go-admin-core/sdk/pkg"
|
||||
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
@@ -31,16 +30,9 @@ func (handler Handler) List(c *gin.Context) {
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
request := ListRequest{
|
||||
response, err := service.List(c.Request.Context(), ListRequest{
|
||||
Page: page, PageSize: pageSize, GoodsID: c.Query("goodsId"), Keyword: c.Query("keyword"), Status: strings.TrimSpace(c.Query("status")),
|
||||
}
|
||||
claims := jwt.ExtractClaims(c)
|
||||
if role, _ := claims["rolekey"].(string); role != "admin" {
|
||||
id, _ := claims["identity"].(float64)
|
||||
owner := uint64(id)
|
||||
request.CollectionOwnerUserID = &owner
|
||||
}
|
||||
response, err := service.List(c.Request.Context(), request)
|
||||
})
|
||||
if err != nil {
|
||||
writeError(c, err)
|
||||
return
|
||||
|
||||
@@ -64,11 +64,10 @@ type UpdateRequest struct {
|
||||
}
|
||||
|
||||
type ListRequest struct {
|
||||
Page, PageSize int
|
||||
Keyword string
|
||||
GoodsID string
|
||||
Status string
|
||||
CollectionOwnerUserID *uint64
|
||||
Page, PageSize int
|
||||
Keyword string
|
||||
GoodsID string
|
||||
Status string
|
||||
}
|
||||
|
||||
type ProductView struct {
|
||||
@@ -267,9 +266,6 @@ func (service *Service) List(ctx context.Context, request ListRequest) (ListResp
|
||||
}
|
||||
query = query.Where("status = ?", request.Status)
|
||||
}
|
||||
if request.CollectionOwnerUserID != nil {
|
||||
query = query.Where("EXISTS (SELECT 1 FROM collection_task ct JOIN agent_device ad ON ad.id = ct.device_id WHERE ct.pdd_product_id = pdd_product.id AND ct.source = ? AND ct.status IN ? AND ad.owner_user_id = ?)", models.CollectionTaskSourceAgentCurrentPage, []string{models.TaskStatusCompleted, models.TaskStatusCompletedPartial}, *request.CollectionOwnerUserID)
|
||||
}
|
||||
var total int64
|
||||
if err := query.Count(&total).Error; err != nil {
|
||||
return ListResponse{}, internalError(err)
|
||||
|
||||
@@ -205,44 +205,6 @@ func TestListMarksProductsUnavailableForCollection(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestListFiltersManualAssociationProductsByOwnedCollectionDevice(t *testing.T) {
|
||||
db := openProductDatabase(t)
|
||||
service := NewService(db)
|
||||
rule := models.CollectionRule{Name: "owned-rule", ContentJSON: `{}`}
|
||||
if err := db.Create(&rule).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
products := []models.PDDProduct{{GoodsID: "910001", URL: "https://mobile.yangkeduo.com/goods.html?goods_id=910001", Status: "active"}, {GoodsID: "910002", URL: "https://mobile.yangkeduo.com/goods.html?goods_id=910002", Status: "active"}, {GoodsID: "910003", URL: "https://mobile.yangkeduo.com/goods.html?goods_id=910003", Status: "active"}}
|
||||
if err := db.Create(&products).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
owned := models.AgentDevice{InstallID: "owned-manual-list", Name: "owned", Manufacturer: "test", Model: "test", AndroidVersion: "14", AgentVersion: "1", PDDVersion: "1", Status: models.DeviceStatusOffline, TokenDigest: "owned-digest", TokenIssuedAt: time.Now(), OwnerUserID: ptrUint64(41)}
|
||||
other := models.AgentDevice{InstallID: "other-manual-list", Name: "other", Manufacturer: "test", Model: "test", AndroidVersion: "14", AgentVersion: "1", PDDVersion: "1", Status: models.DeviceStatusOffline, TokenDigest: "other-digest", TokenIssuedAt: time.Now(), OwnerUserID: ptrUint64(42)}
|
||||
if err := db.Create(&owned).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Create(&other).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, task := range []models.CollectionTask{{PDDProductID: &products[0].ID, DeviceID: &owned.ID, Source: models.CollectionTaskSourceAgentCurrentPage, Status: models.TaskStatusCompleted}, {PDDProductID: &products[1].ID, DeviceID: &other.ID, Source: models.CollectionTaskSourceAgentCurrentPage, Status: models.TaskStatusCompleted}, {PDDProductID: &products[2].ID, DeviceID: &owned.ID, Source: models.CollectionTaskSourceAdmin, Status: models.TaskStatusCompleted}} {
|
||||
task.RuleID = rule.ID
|
||||
task.URLSnapshot = products[0].URL
|
||||
task.RuleSnapshot = rule.ContentJSON
|
||||
if err := db.Create(&task).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
got, err := service.List(context.Background(), ListRequest{Page: 1, PageSize: 20, CollectionOwnerUserID: ptrUint64(41)})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got.Items) != 1 || got.Items[0].GoodsID != products[0].GoodsID {
|
||||
t.Fatalf("unexpected owned products: %+v", got.Items)
|
||||
}
|
||||
}
|
||||
|
||||
func ptrUint64(value uint64) *uint64 { return &value }
|
||||
|
||||
func TestListDisablesCollectionWhenNoRuleExists(t *testing.T) {
|
||||
db := openProductDatabase(t)
|
||||
product := models.PDDProduct{GoodsID: "444444", URL: "https://mobile.yangkeduo.com/goods.html?goods_id=444444", Status: "active"}
|
||||
|
||||
@@ -109,9 +109,6 @@ type AdminDetailResponse struct {
|
||||
Matching MatchingView `json:"matching"`
|
||||
}
|
||||
|
||||
// Query-only value: never a PurchaseTask status or a logistics writeback state.
|
||||
const adminStatusSYBWritebackSucceeded = "syb_writeback_succeeded"
|
||||
|
||||
func (s *Service) AdminList(ctx context.Context, req AdminListRequest) (AdminListResponse, error) {
|
||||
if req.Page < 1 {
|
||||
req.Page = 1
|
||||
@@ -122,7 +119,7 @@ func (s *Service) AdminList(ctx context.Context, req AdminListRequest) (AdminLis
|
||||
if req.PageSize > 100 {
|
||||
req.PageSize = 100
|
||||
}
|
||||
if req.Status != "" && req.Status != adminStatusSYBWritebackSucceeded && !validPurchaseStatus(req.Status) {
|
||||
if req.Status != "" && !validPurchaseStatus(req.Status) {
|
||||
return AdminListResponse{}, fail(CodeInvalidRequest, "status 无效")
|
||||
}
|
||||
if req.ExecutionMode != "" && req.ExecutionMode != models.PurchaseExecutionModeRehearsal && req.ExecutionMode != models.PurchaseExecutionModeLive {
|
||||
@@ -135,9 +132,7 @@ func (s *Service) AdminList(ctx context.Context, req AdminListRequest) (AdminLis
|
||||
if req.TaskID > 0 {
|
||||
query = query.Where("id = ?", req.TaskID)
|
||||
}
|
||||
if req.Status == adminStatusSYBWritebackSucceeded {
|
||||
query = query.Where("id IN (?)", s.DB.WithContext(ctx).Model(&models.PurchaseOrderWriteback{}).Select("purchase_task_id").Where("status = ?", "succeeded"))
|
||||
} else if req.Status != "" {
|
||||
if req.Status != "" {
|
||||
query = query.Where("status = ?", req.Status)
|
||||
}
|
||||
if req.ExecutionMode != "" {
|
||||
|
||||
@@ -105,63 +105,6 @@ func TestAdminQueryValidationAndNotFound(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminListSYBWritebackSucceededFilter(t *testing.T) {
|
||||
s, first := orderWritebackFixture(t)
|
||||
db := s.DB
|
||||
if err := db.Model(&models.PurchaseOrderWriteback{}).Where("purchase_task_id = ?", first.ID).Update("status", "succeeded").Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var successIDs []uint64
|
||||
successIDs = append(successIDs, first.ID)
|
||||
for _, state := range []string{"succeeded", "pending", "running", "failed", "unknown", "conflict", "none"} {
|
||||
task := first
|
||||
task.ID = 0
|
||||
task.Status = models.PurchaseTaskStatusCancelled
|
||||
task.PDDOrderNo = nil
|
||||
task.WritebackStatus = models.PurchaseWritebackStatusSucceeded // old logistics must not match
|
||||
task.CreateRequestID = uuid.NewString()
|
||||
task.UnknownResolveRequestID = nil
|
||||
if err := db.Create(&task).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if state != "none" {
|
||||
if err := db.Create(&models.PurchaseOrderWriteback{PurchaseTaskID: task.ID, StockID: 2, DetailID: 1, OrderNo: "FILTER-DEMO", Status: state}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if state == "succeeded" {
|
||||
successIDs = append(successIDs, task.ID)
|
||||
}
|
||||
}
|
||||
for page := 1; page <= 2; page++ {
|
||||
out, err := s.AdminList(context.Background(), AdminListRequest{Status: adminStatusSYBWritebackSucceeded, Page: page, PageSize: 1})
|
||||
if err != nil || out.Total != 2 || len(out.Items) != 1 || out.Items[0].ID != successIDs[2-page] {
|
||||
t.Fatalf("page %d: %+v, %v", page, out, err)
|
||||
}
|
||||
}
|
||||
for _, req := range []AdminListRequest{
|
||||
{Status: adminStatusSYBWritebackSucceeded, TaskID: first.ID},
|
||||
{Status: models.PurchaseTaskStatusOrderCreated},
|
||||
{Status: adminStatusSYBWritebackSucceeded, PDDOrderNo: *first.PDDOrderNo, ExecutionMode: "live", TaskType: "syb_order", SYBProductID: *first.SYBProductID},
|
||||
} {
|
||||
out, err := s.AdminList(context.Background(), req)
|
||||
if err != nil || out.Total != 1 || len(out.Items) != 1 || out.Items[0].ID != first.ID {
|
||||
t.Fatalf("combined filter: %+v %v", out, err)
|
||||
}
|
||||
}
|
||||
out, err := s.AdminList(context.Background(), AdminListRequest{})
|
||||
if err != nil || out.Total != 8 {
|
||||
t.Fatalf("clear filter: %+v %v", out, err)
|
||||
}
|
||||
out, err = s.AdminList(context.Background(), AdminListRequest{Status: adminStatusSYBWritebackSucceeded, PDDOrderNo: "not-found"})
|
||||
if err != nil || out.Total != 0 {
|
||||
t.Fatalf("empty filter: %+v %v", out, err)
|
||||
}
|
||||
if validPurchaseStatus(adminStatusSYBWritebackSucceeded) {
|
||||
t.Fatal("filter became task state")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminQueryHandlersRequireOperatorRole(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
for _, path := range []string{"/api/admin/v1/purchase-tasks", "/api/admin/v1/purchase-tasks/1"} {
|
||||
|
||||
@@ -341,9 +341,6 @@ func (s *Service) SubmitResult(ctx context.Context, taskID uint64, req ResultReq
|
||||
if req.ActualUnitPriceCent != nil && *req.ActualUnitPriceCent < 0 {
|
||||
return TaskPayload{}, fail(CodeInvalidRequest, "实际单价无效")
|
||||
}
|
||||
if req.PDDOrderAmountCent != nil && (*req.PDDOrderAmountCent < 0 || req.ResultType != "order_created") {
|
||||
return TaskPayload{}, fail(CodeInvalidRequest, "订单应付金额仅可随已创建订单提交且不能为负数")
|
||||
}
|
||||
now := s.Now()
|
||||
next := ""
|
||||
switch req.ResultType {
|
||||
@@ -399,9 +396,6 @@ func (s *Service) SubmitResult(ctx context.Context, taskID uint64, req ResultReq
|
||||
a.Status = models.PurchaseAttemptStatusFailed
|
||||
a.ErrorCode, a.ErrorMessage = &failureCode, &message
|
||||
}
|
||||
if next == models.PurchaseTaskStatusOrderCreated && req.PDDOrderAmountCent != nil && t.PDDOrderAmountCent == nil {
|
||||
t.PDDOrderAmountCent = req.PDDOrderAmountCent
|
||||
}
|
||||
case "order_result_unknown":
|
||||
if t.ExecutionMode != models.PurchaseExecutionModeLive || t.Status != models.PurchaseTaskStatusOrderSubmitStarted {
|
||||
return TaskPayload{}, fail(CodeStateConflict, "当前任务不能标记订单结果未知")
|
||||
@@ -647,25 +641,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) &&
|
||||
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"go-admin/app/goauto/models"
|
||||
"gorm.io/gorm"
|
||||
@@ -116,31 +115,19 @@ func (s *Service) Cancel(ctx context.Context, id uint64, req ManualRequest) (mod
|
||||
|
||||
func (s *Service) ResolveUnknown(ctx context.Context, id uint64, req ManualRequest) (models.PurchaseTask, bool, error) {
|
||||
return s.manual(ctx, id, req, func(t *models.PurchaseTask) error {
|
||||
if t.ExecutionMode != models.PurchaseExecutionModeLive || (t.Status != models.PurchaseTaskStatusOrderResultUnknown && t.Status != models.PurchaseTaskStatusOrderCreated) {
|
||||
return fail(CodeStateConflict, "仅正式采购的未知结果或已创建订单允许补录")
|
||||
}
|
||||
if t.Status == models.PurchaseTaskStatusOrderCreated && req.Status != models.PurchaseTaskStatusOrderCreated {
|
||||
return fail(CodeStateConflict, "已创建订单仅允许补全订单信息")
|
||||
if t.Status != models.PurchaseTaskStatusOrderResultUnknown {
|
||||
return fail(CodeStateConflict, "任务不是订单结果未知状态")
|
||||
}
|
||||
if req.Status != models.PurchaseTaskStatusOrderCreated && req.Status != models.PurchaseTaskStatusCancelled {
|
||||
return fail(CodeInvalidRequest, "人工处理结果只支持已创建订单或已取消")
|
||||
}
|
||||
now := s.Now()
|
||||
if req.Status == models.PurchaseTaskStatusOrderCreated {
|
||||
if strings.TrimSpace(req.PDDOrderNo) == "" || strings.TrimSpace(req.PDDOrderNo) != req.PDDOrderNo || utf8.RuneCountInString(req.PDDOrderNo) > 100 || strings.ContainsAny(req.PDDOrderNo, "\r\n\t") || req.OrderSubmittedAt == nil || req.OrderSubmittedAt.IsZero() || req.OrderSubmittedAt.Year() < 1000 || req.OrderSubmittedAt.Year() > 9999 {
|
||||
if strings.TrimSpace(req.PDDOrderNo) == "" || req.OrderSubmittedAt == nil {
|
||||
return fail(CodeInvalidRequest, "请填写订单号和下单时间")
|
||||
}
|
||||
if req.PDDOrderAmountCent != nil && *req.PDDOrderAmountCent < 0 {
|
||||
return fail(CodeInvalidRequest, "订单金额不能为负数")
|
||||
}
|
||||
if (t.PDDOrderNo != nil && *t.PDDOrderNo != "" && *t.PDDOrderNo != req.PDDOrderNo) || (t.OrderSubmittedAt != nil && !t.OrderSubmittedAt.Equal(*req.OrderSubmittedAt)) || (t.PDDOrderAmountCent != nil && req.PDDOrderAmountCent != nil && *t.PDDOrderAmountCent != *req.PDDOrderAmountCent) {
|
||||
return fail(CodeResultConflict, "已有不同订单信息,本次未覆盖")
|
||||
}
|
||||
t.PDDOrderNo = &req.PDDOrderNo
|
||||
t.OrderSubmittedAt = req.OrderSubmittedAt
|
||||
if req.PDDOrderAmountCent != nil {
|
||||
t.PDDOrderAmountCent = req.PDDOrderAmountCent
|
||||
}
|
||||
} else {
|
||||
t.CancelledAt = &now
|
||||
t.CancelledBy = &req.OperatorID
|
||||
@@ -153,13 +140,12 @@ func (s *Service) ResolveUnknown(ctx context.Context, id uint64, req ManualReque
|
||||
t.StatusVersion++
|
||||
t.StatusChangedAt = now
|
||||
return nil
|
||||
}, true)
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Service) manual(ctx context.Context, id uint64, req ManualRequest, apply func(*models.PurchaseTask) error, orderInfo ...bool) (models.PurchaseTask, bool, error) {
|
||||
func (s *Service) manual(ctx context.Context, id uint64, req ManualRequest, apply func(*models.PurchaseTask) error) (models.PurchaseTask, bool, error) {
|
||||
var out models.PurchaseTask
|
||||
replayed := false
|
||||
isOrderInfo := len(orderInfo) > 0 && orderInfo[0]
|
||||
if strings.TrimSpace(req.RequestID) == "" || req.OperatorID == 0 {
|
||||
return out, false, fail(CodeInvalidRequest, "requestId 或操作人无效")
|
||||
}
|
||||
@@ -171,12 +157,6 @@ func (s *Service) manual(ctx context.Context, id uint64, req ManualRequest, appl
|
||||
return internal(e)
|
||||
}
|
||||
if requestAlreadyApplied(out, req.RequestID) {
|
||||
if isOrderInfo && out.Status != req.Status {
|
||||
return fail(CodeResultConflict, "同一requestId不能改变处理结果")
|
||||
}
|
||||
if isOrderInfo && req.Status == models.PurchaseTaskStatusOrderCreated && (out.PDDOrderNo == nil || *out.PDDOrderNo != req.PDDOrderNo || out.OrderSubmittedAt == nil || req.OrderSubmittedAt == nil || !out.OrderSubmittedAt.Equal(*req.OrderSubmittedAt) || (req.PDDOrderAmountCent != nil && (out.PDDOrderAmountCent == nil || *out.PDDOrderAmountCent != *req.PDDOrderAmountCent))) {
|
||||
return fail(CodeResultConflict, "同一requestId不能改变订单信息")
|
||||
}
|
||||
replayed = true
|
||||
return nil
|
||||
}
|
||||
@@ -192,9 +172,6 @@ func (s *Service) manual(ctx context.Context, id uint64, req ManualRequest, appl
|
||||
if e := tx.Save(&out).Error; e != nil {
|
||||
return conflictOrInternal(e)
|
||||
}
|
||||
if isOrderInfo && req.Status == models.PurchaseTaskStatusOrderCreated {
|
||||
return ensureOrderWriteback(tx, out)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return out, replayed, err
|
||||
|
||||
@@ -61,43 +61,18 @@ type OrderBackfillResponse struct {
|
||||
Items []OrderBackfillResult `json:"items"`
|
||||
}
|
||||
|
||||
type orderBackfillScope struct{ deviceID *uint64 }
|
||||
|
||||
func agentBackfillScope(deviceID uint64) orderBackfillScope {
|
||||
return orderBackfillScope{deviceID: &deviceID}
|
||||
}
|
||||
func clientBackfillScope() orderBackfillScope { return orderBackfillScope{} }
|
||||
func (scope orderBackfillScope) allows(task models.PurchaseTask) bool {
|
||||
return scope.deviceID == nil || (task.DeviceID != nil && *task.DeviceID == *scope.deviceID)
|
||||
}
|
||||
func (scope orderBackfillScope) readable(db *gorm.DB, taskID uint64, saved *models.PurchaseTask) bool {
|
||||
query := db.Where("id = ?", taskID)
|
||||
if scope.deviceID != nil {
|
||||
query = query.Where("device_id = ?", *scope.deviceID)
|
||||
}
|
||||
return query.First(saved).Error == nil
|
||||
}
|
||||
|
||||
func (s *Service) BackfillOrders(ctx context.Context, req OrderBackfillRequest, token string) (OrderBackfillResponse, error) {
|
||||
out := OrderBackfillResponse{RequestID: req.RequestID}
|
||||
d, err := device.NewService(s.DB).Authenticate(ctx, token)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
return s.backfillOrders(ctx, req, agentBackfillScope(d.ID))
|
||||
}
|
||||
|
||||
func (s *Service) BackfillOrdersForClient(ctx context.Context, req OrderBackfillRequest) (OrderBackfillResponse, error) {
|
||||
return s.backfillOrders(ctx, req, clientBackfillScope())
|
||||
}
|
||||
|
||||
func (s *Service) backfillOrders(ctx context.Context, req OrderBackfillRequest, scope orderBackfillScope) (OrderBackfillResponse, error) {
|
||||
out := OrderBackfillResponse{RequestID: req.RequestID}
|
||||
if _, err := uuid.Parse(req.RequestID); err != nil || len(req.Items) == 0 || len(req.Items) > MaxOrderBackfillItems {
|
||||
return out, fail(CodeInvalidRequest, "requestId 必须为 UUID,items 必须包含 1 到 50 条")
|
||||
}
|
||||
ids := make([]uint64, len(req.Items))
|
||||
orders, conflicts := make(map[uint64]string), make(map[uint64]bool)
|
||||
orders := make(map[uint64]string)
|
||||
conflicts := make(map[uint64]bool)
|
||||
for i, item := range req.Items {
|
||||
id, err := purchasecontract.ParseAddressSuffix(item.AddressSuffix)
|
||||
if err != nil {
|
||||
@@ -115,7 +90,7 @@ func (s *Service) backfillOrders(ctx context.Context, req OrderBackfillRequest,
|
||||
if ids[i] == 0 {
|
||||
r.Code = CodeBackfillSuffix
|
||||
} else {
|
||||
r = s.backfillOrder(ctx, scope, ids[i], req.RequestID, item, conflicts[ids[i]])
|
||||
r = s.backfillOrder(ctx, d.ID, ids[i], req.RequestID, item, conflicts[ids[i]])
|
||||
r.Index = i
|
||||
}
|
||||
out.Items[i] = r
|
||||
@@ -123,7 +98,7 @@ func (s *Service) backfillOrders(ctx context.Context, req OrderBackfillRequest,
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *Service) backfillOrder(ctx context.Context, scope orderBackfillScope, taskID uint64, requestID string, item OrderBackfillItem, batchConflict bool) OrderBackfillResult {
|
||||
func (s *Service) backfillOrder(ctx context.Context, deviceID, taskID uint64, requestID string, item OrderBackfillItem, batchConflict bool) OrderBackfillResult {
|
||||
r := OrderBackfillResult{TaskID: taskID, Result: "failed"}
|
||||
var task models.PurchaseTask
|
||||
// SQL errors must not print bound order numbers or the task's address snapshot.
|
||||
@@ -132,7 +107,7 @@ func (s *Service) backfillOrder(ctx context.Context, scope orderBackfillScope, t
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&task, taskID).Error; err != nil {
|
||||
return purchaseNotFound(err)
|
||||
}
|
||||
if !scope.allows(task) {
|
||||
if task.DeviceID == nil || *task.DeviceID != deviceID {
|
||||
return fail(CodeBackfillDevice, "任务不属于当前设备")
|
||||
}
|
||||
if batchConflict {
|
||||
@@ -158,37 +133,18 @@ func (s *Service) backfillOrder(ctx context.Context, scope orderBackfillScope, t
|
||||
if task.PDDOrderNo == nil || *task.PDDOrderNo != item.PDDOrderNo {
|
||||
return fail(CodeStateConflict, "已创建订单缺少匹配订单号")
|
||||
}
|
||||
changed := false
|
||||
filledSubmittedAt := false
|
||||
if task.OrderSubmittedAt == nil && item.OrderSubmittedAt != nil {
|
||||
submitted, parseErr := time.Parse(time.RFC3339Nano, *item.OrderSubmittedAt)
|
||||
if parseErr != nil || submitted.IsZero() || submitted.Year() < 1000 || submitted.Year() > 9999 {
|
||||
return fail(CodeOrderTimeInvalid, "下单时间必须为 RFC3339")
|
||||
}
|
||||
submitted = submitted.UTC()
|
||||
task.OrderSubmittedAt = &submitted
|
||||
changed = true
|
||||
filledSubmittedAt = true
|
||||
}
|
||||
if item.PDDOrderAmountCent != nil {
|
||||
if task.PDDOrderAmountCent == nil {
|
||||
task.PDDOrderAmountCent = item.PDDOrderAmountCent
|
||||
changed = true
|
||||
task.StatusVersion++
|
||||
task.StatusChangedAt = s.Now()
|
||||
if err := tx.Save(&task).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
} else if *task.PDDOrderAmountCent != *item.PDDOrderAmountCent {
|
||||
r.WarningCode, r.WarningMessage = CodeBackfillAmountConflict, "已有不同实付金额,本次未覆盖"
|
||||
}
|
||||
}
|
||||
if changed {
|
||||
if filledSubmittedAt {
|
||||
marker := "backfill:page:" + uuid.NewSHA1(uuid.NameSpaceOID, []byte(requestID+":"+item.AddressSuffix)).String()
|
||||
task.UnknownResolveRequestID = &marker
|
||||
}
|
||||
task.StatusVersion++
|
||||
task.StatusChangedAt = s.Now()
|
||||
if err := tx.Save(&task).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
r.Result, r.Code = "already_backfilled", "ALREADY_BACKFILLED"
|
||||
return ensureOrderWriteback(tx, task)
|
||||
}
|
||||
@@ -252,7 +208,7 @@ func (s *Service) backfillOrder(ctx context.Context, scope orderBackfillScope, t
|
||||
readable := err == nil
|
||||
if !readable {
|
||||
saved = models.PurchaseTask{}
|
||||
readable = scope.readable(db, taskID, &saved)
|
||||
readable = db.Where("id = ? AND device_id = ?", taskID, deviceID).First(&saved).Error == nil
|
||||
}
|
||||
if readable {
|
||||
r.Status, r.StatusVersion = saved.Status, saved.StatusVersion
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package purchase
|
||||
|
||||
import (
|
||||
"go-admin/common/clientprincipal"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -24,25 +23,3 @@ func (h Handler) BackfillOrders(c *gin.Context) {
|
||||
c.Header("Cache-Control", "no-store")
|
||||
c.JSON(http.StatusOK, gin.H{"data": out})
|
||||
}
|
||||
|
||||
func (h Handler) ClientBackfillOrders(c *gin.Context) {
|
||||
if _, ok := clientprincipal.Get(c); !ok {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"code": http.StatusUnauthorized, "message": "需要客户端身份"})
|
||||
return
|
||||
}
|
||||
var req OrderBackfillRequest
|
||||
if !decode(c, &req) {
|
||||
return
|
||||
}
|
||||
s, ok := h.service(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
out, err := s.BackfillOrdersForClient(c.Request.Context(), req)
|
||||
if err != nil {
|
||||
writeError(c, err)
|
||||
return
|
||||
}
|
||||
c.Header("Cache-Control", "no-store")
|
||||
c.JSON(http.StatusOK, gin.H{"data": out})
|
||||
}
|
||||
|
||||
@@ -402,55 +402,6 @@ func TestOrderBackfillHTTPBoundary(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientOrderBackfillDoesNotRequireDeviceOwnership(t *testing.T) {
|
||||
db := testDB(t)
|
||||
f := seed(t, db, liveCaps(), true)
|
||||
task := backfillTask(t, db, f, models.PurchaseTaskStatusOrderResultUnknown)
|
||||
if err := db.Model(&task).Update("device_id", nil).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out, err := testService(db).BackfillOrdersForClient(context.Background(), OrderBackfillRequest{
|
||||
RequestID: uuid.NewString(), Items: []OrderBackfillItem{backfillItem(task.ID, "CLIENT-ORDER")},
|
||||
})
|
||||
if err != nil || len(out.Items) != 1 || out.Items[0].Code != "BACKFILLED" {
|
||||
t.Fatalf("client backfill: %+v %v", out, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientOrderBackfillFillsOnlyMissingSubmittedTime(t *testing.T) {
|
||||
db := testDB(t)
|
||||
f := seed(t, db, liveCaps(), true)
|
||||
s := testService(db)
|
||||
task := backfillTask(t, db, f, models.PurchaseTaskStatusOrderCreated)
|
||||
order := "EXISTING-ORDER"
|
||||
task.PDDOrderNo, task.OrderSubmittedAt = &order, nil
|
||||
if err := db.Save(&task).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
first := "2026-09-18T10:00:00+08:00"
|
||||
item := backfillItem(task.ID, order)
|
||||
item.OrderSubmittedAt = &first
|
||||
out, err := s.BackfillOrdersForClient(context.Background(), OrderBackfillRequest{RequestID: uuid.NewString(), Items: []OrderBackfillItem{item}})
|
||||
if err != nil || out.Items[0].Code != "ALREADY_BACKFILLED" || out.Items[0].TimeSource != "page" {
|
||||
t.Fatalf("fill time: %+v %v", out, err)
|
||||
}
|
||||
want, _ := time.Parse(time.RFC3339, first)
|
||||
got := loadBackfillTask(t, db, task.ID)
|
||||
if got.OrderSubmittedAt == nil || !got.OrderSubmittedAt.Equal(want) {
|
||||
t.Fatalf("missing time not filled: %+v", got.OrderSubmittedAt)
|
||||
}
|
||||
second := "2026-09-19T10:00:00+08:00"
|
||||
item.OrderSubmittedAt = &second
|
||||
out, err = s.BackfillOrdersForClient(context.Background(), OrderBackfillRequest{RequestID: uuid.NewString(), Items: []OrderBackfillItem{item}})
|
||||
if err != nil || out.Items[0].TimeSource != "page" {
|
||||
t.Fatalf("preserve source: %+v %v", out, err)
|
||||
}
|
||||
got = loadBackfillTask(t, db, task.ID)
|
||||
if got.OrderSubmittedAt == nil || !got.OrderSubmittedAt.Equal(want) {
|
||||
t.Fatalf("existing time overwritten: %+v", got.OrderSubmittedAt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOrderBackfillConcurrentSameOrderDifferentTasks(t *testing.T) {
|
||||
db := testDB(t)
|
||||
f := seed(t, db, liveCaps(), true)
|
||||
|
||||
@@ -1,171 +0,0 @@
|
||||
package purchase
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"go-admin/app/goauto/models"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func TestOrderInformationManualCompletion(t *testing.T) {
|
||||
for _, amount := range []int64{0, 1300} {
|
||||
t.Run(taskNumber(uint64(amount)), func(t *testing.T) {
|
||||
db := testDB(t)
|
||||
f := seed(t, db, liveCaps(), true)
|
||||
s := testService(db)
|
||||
task := backfillTask(t, db, f, models.PurchaseTaskStatusOrderResultUnknown)
|
||||
task.TaskType, task.SYBProductID = models.PurchaseTaskTypeSYBOrder, &f.syb.ID
|
||||
if err := db.Save(&task).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req := ManualRequest{RequestID: uuid.NewString(), OperatorID: 1, Status: models.PurchaseTaskStatusOrderCreated, PDDOrderNo: "DEMO-MANUAL", OrderSubmittedAt: task.IrreversibleAt}
|
||||
got, _, err := s.ResolveUnknown(context.Background(), task.ID, req)
|
||||
if err != nil || got.Status != models.PurchaseTaskStatusOrderCreated || got.PDDOrderAmountCent != nil {
|
||||
t.Fatalf("legacy save: %v", err)
|
||||
}
|
||||
row := loadOrderWriteback(t, db, task.ID)
|
||||
if row.Status != "pending" {
|
||||
t.Fatal(row.Status)
|
||||
}
|
||||
req.RequestID = uuid.NewString()
|
||||
req.PDDOrderAmountCent = &amount
|
||||
got, replay, err := s.ResolveUnknown(context.Background(), task.ID, req)
|
||||
if err != nil || replay || got.PDDOrderAmountCent == nil || *got.PDDOrderAmountCent != amount {
|
||||
t.Fatalf("fill: %v", err)
|
||||
}
|
||||
if got.PaymentReviewStatus != task.PaymentReviewStatus {
|
||||
t.Fatal("payment status changed")
|
||||
}
|
||||
if _, replay, err = s.ResolveUnknown(context.Background(), task.ID, req); err != nil || !replay {
|
||||
t.Fatalf("replay: %v", err)
|
||||
}
|
||||
req.Status = models.PurchaseTaskStatusCancelled
|
||||
if _, _, err = s.ResolveUnknown(context.Background(), task.ID, req); code(err) != CodeResultConflict {
|
||||
t.Fatalf("changed replay status: %v", err)
|
||||
}
|
||||
req.Status = models.PurchaseTaskStatusOrderCreated
|
||||
changed := amount + 1
|
||||
req.PDDOrderAmountCent = &changed
|
||||
if _, _, err = s.ResolveUnknown(context.Background(), task.ID, req); code(err) != CodeResultConflict {
|
||||
t.Fatalf("changed replay: %v", err)
|
||||
}
|
||||
req.RequestID = uuid.NewString()
|
||||
if _, _, err = s.ResolveUnknown(context.Background(), task.ID, req); code(err) != CodeResultConflict {
|
||||
t.Fatalf("overwrite: %v", err)
|
||||
}
|
||||
var count int64
|
||||
db.Model(&models.PurchaseOrderWriteback{}).Where("purchase_task_id = ?", task.ID).Count(&count)
|
||||
if count != 1 {
|
||||
t.Fatalf("duplicate queue: %d", count)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestOrderInformationQueueFailureRollsBack(t *testing.T) {
|
||||
db := testDB(t)
|
||||
f := seed(t, db, liveCaps(), true)
|
||||
s := testService(db)
|
||||
task := backfillTask(t, db, f, models.PurchaseTaskStatusOrderResultUnknown)
|
||||
task.TaskType, task.SYBProductID = models.PurchaseTaskTypeSYBOrder, &f.syb.ID
|
||||
if err := db.Save(&task).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Callback().Create().Before("gorm:create").Register("326_queue_failure", func(tx *gorm.DB) {
|
||||
if _, ok := tx.Statement.Dest.(*models.PurchaseOrderWriteback); ok {
|
||||
tx.AddError(errors.New("synthetic queue failure"))
|
||||
}
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Callback().Create().Remove("326_queue_failure")
|
||||
amount := int64(100)
|
||||
_, _, err := s.ResolveUnknown(context.Background(), task.ID, ManualRequest{RequestID: uuid.NewString(), OperatorID: 1, Status: models.PurchaseTaskStatusOrderCreated, PDDOrderNo: "DEMO-ROLLBACK", OrderSubmittedAt: task.IrreversibleAt, PDDOrderAmountCent: &amount})
|
||||
if err == nil {
|
||||
t.Fatal("expected failure")
|
||||
}
|
||||
got := loadBackfillTask(t, db, task.ID)
|
||||
if got.PDDOrderNo != nil || got.PDDOrderAmountCent != nil || got.Status != task.Status {
|
||||
t.Fatal("partial transaction saved")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOrderWritebackFieldBasedEligibility(t *testing.T) {
|
||||
for _, status := range []string{models.PurchaseTaskStatusOrderCreated, models.PurchaseTaskStatusFailed, models.PurchaseTaskStatusCancelled, models.PurchaseTaskStatusOrderResultUnknown} {
|
||||
t.Run(status, func(t *testing.T) {
|
||||
s, task := orderWritebackFixture(t)
|
||||
task.Status = status
|
||||
task.OrderSubmittedAt = nil
|
||||
task.PDDOrderAmountCent = nil
|
||||
if err := s.DB.Save(&task).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Existing queued items must also pass the same relaxed worker check.
|
||||
fake := &fakeOrderNumberClient{apply: true}
|
||||
if _, err := wbWorker(s, fake).RunOnce(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if fake.writes != 1 || loadOrderWriteback(t, s.DB, task.ID).Status != "succeeded" {
|
||||
t.Fatal("status/time/amount incorrectly gated worker")
|
||||
}
|
||||
// A failed writeback is selectable for manual compensation.
|
||||
s.DB.Model(&models.PurchaseOrderWriteback{}).Where("purchase_task_id = ?", task.ID).Update("status", "failed")
|
||||
views, err := s.OrderWritebackViews(context.Background(), []models.PurchaseTask{task})
|
||||
if err != nil || !views[task.ID].CanSubmit {
|
||||
t.Fatalf("view: %v", err)
|
||||
}
|
||||
result, err := s.RequestOrderWriteback(context.Background(), OrderWritebackRequest{uuid.NewString(), []uint64{task.ID}})
|
||||
if err != nil || result.Items[0].Result != "pending" {
|
||||
t.Fatalf("request: %v", err)
|
||||
}
|
||||
s.DB.Model(&models.SYBProduct{}).Where("id = ?", *task.SYBProductID).Update("detail_id", 0)
|
||||
views, err = s.OrderWritebackViews(context.Background(), []models.PurchaseTask{task})
|
||||
if err != nil || views[task.ID].CanSubmit {
|
||||
t.Fatal("invalid target selectable")
|
||||
}
|
||||
result, err = s.RequestOrderWriteback(context.Background(), OrderWritebackRequest{uuid.NewString(), []uint64{task.ID}})
|
||||
if err != nil || result.Items[0].Result != "skipped" {
|
||||
t.Fatal("invalid target accepted")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestOrderInformationRejectInvalidInputs(t *testing.T) {
|
||||
for _, name := range []string{"negative", "zero_time", "blank_order", "running", "rehearsal"} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
db := testDB(t)
|
||||
f := seed(t, db, liveCaps(), true)
|
||||
s := testService(db)
|
||||
task := backfillTask(t, db, f, models.PurchaseTaskStatusOrderResultUnknown)
|
||||
amount := int64(100)
|
||||
stamp := *task.IrreversibleAt
|
||||
req := ManualRequest{RequestID: uuid.NewString(), OperatorID: 1, Status: models.PurchaseTaskStatusOrderCreated, PDDOrderNo: "DEMO-INVALID", OrderSubmittedAt: &stamp, PDDOrderAmountCent: &amount}
|
||||
switch name {
|
||||
case "negative":
|
||||
amount = -1
|
||||
case "zero_time":
|
||||
stamp = time.Time{}
|
||||
case "blank_order":
|
||||
req.PDDOrderNo = " "
|
||||
case "running":
|
||||
task.Status = models.PurchaseTaskStatusRunning
|
||||
case "rehearsal":
|
||||
task.ExecutionMode = models.PurchaseExecutionModeRehearsal
|
||||
}
|
||||
if err := db.Save(&task).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, err := s.ResolveUnknown(context.Background(), task.ID, req); err == nil {
|
||||
t.Fatal("invalid input accepted")
|
||||
}
|
||||
if loadBackfillTask(t, db, task.ID).PDDOrderNo != nil {
|
||||
t.Fatal("invalid input persisted")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,88 +0,0 @@
|
||||
package purchase
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"go-admin/app/goauto/models"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func TestOrderResultPayableAmount(t *testing.T) {
|
||||
ptr := func(n int64) *int64 { return &n }
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
amount, existing, want *int64
|
||||
result string
|
||||
invalid bool
|
||||
}{
|
||||
{"payable distinct from unit price", ptr(1300), nil, ptr(1300), "order_created", false},
|
||||
{"zero", ptr(0), nil, ptr(0), "order_created", false},
|
||||
{"old agent omitted", nil, nil, nil, "order_created", false},
|
||||
{"omitted preserves existing", nil, ptr(1200), ptr(1200), "order_created", false},
|
||||
{"does not overwrite existing", ptr(1300), ptr(1200), ptr(1200), "order_created", false},
|
||||
{"negative", ptr(-1), nil, nil, "order_created", true},
|
||||
{"unknown cannot attach amount", ptr(1300), nil, nil, "order_result_unknown", true},
|
||||
{"failed cannot attach amount", ptr(1300), nil, nil, "failed", true},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
db := testDB(t)
|
||||
f := seed(t, db, liveCaps(), true)
|
||||
s := testService(db)
|
||||
task, err := createLive(t, s, f)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
started := startLivePurchaseAfterProbe(t, s, f, task)
|
||||
if _, err = s.MarkOrderSubmitStarted(context.Background(), task.ID, ActionRequest{RequestID: uuid.NewString()}, f.token); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if tc.existing != nil {
|
||||
if err = db.Session(&gorm.Session{SkipHooks: true}).Model(&models.PurchaseTask{}).Where("id = ?", task.ID).Update("pdd_order_amount_cent", *tc.existing).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
now := s.Now()
|
||||
req := ResultRequest{RequestID: uuid.NewString(), TaskAttemptID: started.TaskAttemptID,
|
||||
ResultType: tc.result, PDDOrderNo: "PDD-DEMO-325", OrderSubmittedAt: &now,
|
||||
ActualUnitPriceCent: ptr(1600), PDDOrderAmountCent: tc.amount}
|
||||
_, err = s.SubmitResult(context.Background(), task.ID, req, f.token)
|
||||
if tc.invalid {
|
||||
if code(err) != CodeInvalidRequest {
|
||||
t.Fatalf("want invalid request, got %v", err)
|
||||
}
|
||||
} else {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
replay, e := s.SubmitResult(context.Background(), task.ID, req, f.token)
|
||||
if e != nil || !replay.Replayed {
|
||||
t.Fatalf("replay failed: %v", e)
|
||||
}
|
||||
req.PDDOrderAmountCent = ptr(999)
|
||||
if _, e = s.SubmitResult(context.Background(), task.ID, req, f.token); code(e) != CodeResultConflict {
|
||||
t.Fatalf("changed amount replay accepted: %v", e)
|
||||
}
|
||||
queued := loadOrderWriteback(t, db, task.ID)
|
||||
if queued.OrderNo != "PDD-DEMO-325" {
|
||||
t.Fatal("SYB order number queue missing")
|
||||
}
|
||||
}
|
||||
var saved models.PurchaseTask
|
||||
if e := db.First(&saved, task.ID).Error; e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
if (saved.PDDOrderAmountCent == nil) != (tc.want == nil) || (tc.want != nil && *saved.PDDOrderAmountCent != *tc.want) {
|
||||
t.Fatal("unexpected saved payable amount")
|
||||
}
|
||||
if tc.invalid {
|
||||
if saved.Status != models.PurchaseTaskStatusOrderSubmitStarted {
|
||||
t.Fatal("invalid result changed status")
|
||||
}
|
||||
} else if saved.ActualUnitPriceCent == nil || *saved.ActualUnitPriceCent != 1600 {
|
||||
t.Fatal("unit price changed")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -37,11 +37,7 @@ type OrderWritebackResponse struct {
|
||||
}
|
||||
|
||||
func orderWritebackEligible(t models.PurchaseTask) bool {
|
||||
return t.ExecutionMode == models.PurchaseExecutionModeLive && t.TaskType == models.PurchaseTaskTypeSYBOrder && t.SYBProductID != nil && t.PDDOrderNo != nil && strings.TrimSpace(*t.PDDOrderNo) != ""
|
||||
}
|
||||
|
||||
func validOrderWritebackTarget(syb models.SYBProduct) bool {
|
||||
return syb.StockID > 0 && syb.DetailID > 0 && syb.StockID <= 1<<63-1 && syb.DetailID <= 1<<63-1
|
||||
return t.ExecutionMode == models.PurchaseExecutionModeLive && t.TaskType == models.PurchaseTaskTypeSYBOrder && t.Status == models.PurchaseTaskStatusOrderCreated && t.SYBProductID != nil && t.PDDOrderNo != nil && strings.TrimSpace(*t.PDDOrderNo) != ""
|
||||
}
|
||||
|
||||
// Called within the same transaction as the order fact. Never contacts SYB.
|
||||
@@ -56,7 +52,10 @@ func ensureOrderWriteback(tx *gorm.DB, t models.PurchaseTask) error {
|
||||
}
|
||||
return err
|
||||
}
|
||||
if !validOrderWritebackTarget(syb) {
|
||||
if syb.StockID <= 0 || syb.DetailID <= 0 {
|
||||
return nil
|
||||
}
|
||||
if syb.StockID > 1<<63-1 || syb.DetailID > 1<<63-1 {
|
||||
return nil
|
||||
}
|
||||
row := models.PurchaseOrderWriteback{PurchaseTaskID: t.ID, StockID: int64(syb.StockID), DetailID: int64(syb.DetailID), OrderNo: *t.PDDOrderNo, Status: "pending"}
|
||||
@@ -66,31 +65,11 @@ func ensureOrderWriteback(tx *gorm.DB, t models.PurchaseTask) error {
|
||||
func (s *Service) OrderWritebackViews(ctx context.Context, tasks []models.PurchaseTask) (map[uint64]OrderWritebackView, error) {
|
||||
out := map[uint64]OrderWritebackView{}
|
||||
ids := make([]uint64, 0, len(tasks))
|
||||
var sybIDs []uint64
|
||||
for _, t := range tasks {
|
||||
if orderWritebackEligible(t) {
|
||||
sybIDs = append(sybIDs, *t.SYBProductID)
|
||||
}
|
||||
}
|
||||
validTargets := map[uint64]bool{}
|
||||
if len(sybIDs) > 0 {
|
||||
var targets []models.SYBProduct
|
||||
if err := s.DB.WithContext(ctx).Select("id", "stock_id", "detail_id").Where("id IN ?", sybIDs).Find(&targets).Error; err != nil {
|
||||
return nil, internal(err)
|
||||
}
|
||||
for _, target := range targets {
|
||||
validTargets[target.ID] = validOrderWritebackTarget(target)
|
||||
}
|
||||
}
|
||||
for _, t := range tasks {
|
||||
ids = append(ids, t.ID)
|
||||
v := OrderWritebackView{Status: "not_applicable", Reason: "需要正式SYB采购任务、订单号及有效SYB明细"}
|
||||
v := OrderWritebackView{Status: "not_applicable", Reason: "不符合正式SYB订单回填条件"}
|
||||
if orderWritebackEligible(t) {
|
||||
if validTargets[*t.SYBProductID] {
|
||||
v = OrderWritebackView{Status: "not_started", CanSubmit: true}
|
||||
} else {
|
||||
v.Reason = "SYB明细关联已失效"
|
||||
}
|
||||
v = OrderWritebackView{Status: "not_started", CanSubmit: true}
|
||||
}
|
||||
out[t.ID] = v
|
||||
}
|
||||
@@ -104,16 +83,9 @@ func (s *Service) OrderWritebackViews(ctx context.Context, tasks []models.Purcha
|
||||
for _, r := range rows {
|
||||
v := out[r.PurchaseTaskID]
|
||||
v.Status = r.Status
|
||||
if r.ErrorMessage != "" || v.CanSubmit || r.Status == "succeeded" {
|
||||
v.Reason = r.ErrorMessage
|
||||
}
|
||||
v.Reason = r.ErrorMessage
|
||||
v.CompletedAt = r.CompletedAt
|
||||
// A session-class failure's LeaseExpiresAt is the automatic-retry backoff
|
||||
// deadline (#330 修订2, order_writeback_worker.go finishSessionUnavailable),
|
||||
// not an in-flight write lease — manual "resubmit" must stay available
|
||||
// during that window instead of being hidden until it expires.
|
||||
sessionBackoff := r.Status == "failed" && r.ErrorCode == "SYB_SESSION_UNAVAILABLE"
|
||||
v.CanSubmit = v.CanSubmit && (r.Status == "failed" || r.Status == "unknown") && (sessionBackoff || r.LeaseExpiresAt == nil || !r.LeaseExpiresAt.After(s.Now()))
|
||||
v.CanSubmit = v.CanSubmit && (r.Status == "failed" || r.Status == "unknown") && (r.LeaseExpiresAt == nil || !r.LeaseExpiresAt.After(s.Now()))
|
||||
out[r.PurchaseTaskID] = v
|
||||
}
|
||||
return out, nil
|
||||
@@ -169,15 +141,6 @@ func (s *Service) RequestOrderWriteback(ctx context.Context, req OrderWritebackR
|
||||
out.Items = append(out.Items, a)
|
||||
continue
|
||||
}
|
||||
var target models.SYBProduct
|
||||
if err := tx.First(&target, *task.SYBProductID).Error; err != nil || !validOrderWritebackTarget(target) {
|
||||
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return err
|
||||
}
|
||||
a.Reason = "SYB明细关联已失效"
|
||||
out.Items = append(out.Items, a)
|
||||
continue
|
||||
}
|
||||
if err := ensureOrderWriteback(tx, task); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -202,12 +165,7 @@ func (s *Service) RequestOrderWriteback(ctx context.Context, req OrderWritebackR
|
||||
case row.Status == "pending":
|
||||
a.Result, a.Reason = "pending", "已加入回填"
|
||||
default:
|
||||
// Manual resubmit resets attempt_count to 0 (#330 修订3) so a stale
|
||||
// history of automatic session-class retries never eats into a
|
||||
// fresh manual attempt budget, and clears lease_expires_at so the
|
||||
// worker's bounded auto-retry claim (which requires it non-nil)
|
||||
// does not race a double-claim against this manual pending row.
|
||||
if err := tx.Model(&row).Updates(map[string]any{"status": "pending", "write_started": false, "lease_owner": "", "lease_expires_at": nil, "error_code": "", "error_message": "", "attempt_count": 0}).Error; err != nil {
|
||||
if err := tx.Model(&row).Updates(map[string]any{"status": "pending", "write_started": false, "lease_owner": "", "lease_expires_at": nil, "error_code": "", "error_message": ""}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
a.Result, a.Reason = "pending", "已加入回填,将先回读SYB"
|
||||
|
||||
@@ -1,391 +0,0 @@
|
||||
package purchase
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"go-admin/app/goauto/models"
|
||||
"go-admin/app/goauto/sybclient"
|
||||
"go-admin/config"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// wbFactoryWorker builds a worker whose Factory itself fails, exercising the
|
||||
// restoreOrderWritebackClient failure path (session unavailable) rather than
|
||||
// a remote read/write failure on an otherwise-working client.
|
||||
func wbFactoryWorker(s *Service, err error) *OrderWritebackWorker {
|
||||
return &OrderWritebackWorker{DB: s.DB, Now: s.Now, Factory: func(context.Context, *gorm.DB) (OrderNumberClient, error) { return nil, err }}
|
||||
}
|
||||
|
||||
func TestOrderWritebackSessionFailureSchedulesBoundedRetry(t *testing.T) {
|
||||
s, task := orderWritebackFixture(t)
|
||||
if ok, err := wbFactoryWorker(s, sybclient.ErrNoSession).RunOnce(context.Background()); err != nil || !ok {
|
||||
t.Fatalf("run %v %v", ok, err)
|
||||
}
|
||||
row := loadOrderWriteback(t, s.DB, task.ID)
|
||||
if row.Status != "failed" || row.ErrorCode != "SYB_SESSION_UNAVAILABLE" {
|
||||
t.Fatalf("status=%s code=%s", row.Status, row.ErrorCode)
|
||||
}
|
||||
if row.ErrorMessage == "" || len(row.ErrorMessage) > 300 {
|
||||
t.Fatalf("error message not recorded safely: %q", row.ErrorMessage)
|
||||
}
|
||||
if !strings.Contains(row.ErrorMessage, "会话缺失/已过期") {
|
||||
t.Fatalf("category missing from message: %q", row.ErrorMessage)
|
||||
}
|
||||
if !strings.Contains(row.ErrorMessage, "将自动重试") || !strings.Contains(row.ErrorMessage, "恢复登录") {
|
||||
t.Fatalf("message is not actionable: %q", row.ErrorMessage)
|
||||
}
|
||||
if row.LeaseExpiresAt == nil || !row.LeaseExpiresAt.After(s.Now()) {
|
||||
t.Fatal("no backoff scheduled for first session-class failure")
|
||||
}
|
||||
if row.AttemptCount != 1 {
|
||||
t.Fatalf("attempt_count=%d", row.AttemptCount)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSessionRetryBackoffTotalExceedsHourlySyncWindow guards the ticket's
|
||||
// blocker: the cumulative auto-retry window must outlast one hourly sync
|
||||
// period (up to ~60 minutes from failure to the refresh that fixes it),
|
||||
// otherwise attempts run out before the session has a chance to recover.
|
||||
func TestSessionRetryBackoffTotalExceedsHourlySyncWindow(t *testing.T) {
|
||||
if len(sessionRetryBackoff) != maxSessionRetryAttempts-1 {
|
||||
t.Fatalf("expected %d backoff steps for %d attempts, got %d", maxSessionRetryAttempts-1, maxSessionRetryAttempts, len(sessionRetryBackoff))
|
||||
}
|
||||
var total time.Duration
|
||||
for _, d := range sessionRetryBackoff {
|
||||
total += d
|
||||
}
|
||||
if total <= time.Hour {
|
||||
t.Fatalf("total backoff %s must exceed one hourly sync period", total)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOrderWritebackSessionFailureNotReclaimedBeforeBackoffExpires(t *testing.T) {
|
||||
s, _ := orderWritebackFixture(t)
|
||||
if _, err := wbFactoryWorker(s, sybclient.ErrNoSession).RunOnce(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f := &fakeOrderNumberClient{apply: true}
|
||||
if ok, err := wbWorker(s, f).RunOnce(context.Background()); err != nil || ok {
|
||||
t.Fatalf("claimed before backoff expired: ok=%v err=%v", ok, err)
|
||||
}
|
||||
if f.writes != 0 {
|
||||
t.Fatal("wrote while still inside backoff window")
|
||||
}
|
||||
}
|
||||
|
||||
// loadOrderWriteback in order_writeback_test.go takes (t, db, id); provide a
|
||||
// small adapter so this file reads naturally when task id is already in hand.
|
||||
func loadOrderWritebackByTask(t *testing.T, s *Service, id uint64) models.PurchaseOrderWriteback {
|
||||
return loadOrderWriteback(t, s.DB, id)
|
||||
}
|
||||
|
||||
func TestOrderWritebackSessionFailureReclaimedAfterBackoffExpires(t *testing.T) {
|
||||
s, task := orderWritebackFixture(t)
|
||||
if _, err := wbFactoryWorker(s, sybclient.ErrNoSession).RunOnce(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
row := loadOrderWritebackByTask(t, s, task.ID)
|
||||
s.Now = func() time.Time { return row.LeaseExpiresAt.Add(time.Second) }
|
||||
f := &fakeOrderNumberClient{apply: true}
|
||||
if ok, err := wbWorker(s, f).RunOnce(context.Background()); err != nil || !ok {
|
||||
t.Fatalf("not reclaimed after backoff expired: ok=%v err=%v", ok, err)
|
||||
}
|
||||
if f.writes != 1 {
|
||||
t.Fatal("did not write after successful reclaim")
|
||||
}
|
||||
after := loadOrderWritebackByTask(t, s, task.ID)
|
||||
if after.Status != "succeeded" {
|
||||
t.Fatalf("status=%s", after.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOrderWritebackSessionFailureStopsRetryingAtMaxAttempts(t *testing.T) {
|
||||
s, task := orderWritebackFixture(t)
|
||||
now := s.Now()
|
||||
for i := 0; i < maxSessionRetryAttempts; i++ {
|
||||
s.Now = func() time.Time { return now }
|
||||
if ok, err := wbFactoryWorker(s, sybclient.ErrNoSession).RunOnce(context.Background()); err != nil || !ok {
|
||||
t.Fatalf("attempt %d: ok=%v err=%v", i+1, ok, err)
|
||||
}
|
||||
row := loadOrderWritebackByTask(t, s, task.ID)
|
||||
if row.AttemptCount != i+1 {
|
||||
t.Fatalf("attempt %d: attempt_count=%d", i+1, row.AttemptCount)
|
||||
}
|
||||
if i+1 < maxSessionRetryAttempts {
|
||||
if row.LeaseExpiresAt == nil {
|
||||
t.Fatalf("attempt %d: no backoff scheduled", i+1)
|
||||
}
|
||||
now = row.LeaseExpiresAt.Add(time.Second)
|
||||
} else {
|
||||
if row.LeaseExpiresAt != nil {
|
||||
t.Fatal("lease still scheduled at max attempts")
|
||||
}
|
||||
}
|
||||
}
|
||||
// One more tick past any plausible backoff: the claim query must exclude
|
||||
// attempt_count >= maxSessionRetryAttempts, so nothing is claimed.
|
||||
s.Now = func() time.Time { return now.Add(24 * time.Hour) }
|
||||
f := &fakeOrderNumberClient{apply: true}
|
||||
if ok, err := wbWorker(s, f).RunOnce(context.Background()); err != nil || ok {
|
||||
t.Fatalf("claimed a row past max attempts: ok=%v err=%v", ok, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOrderWritebackCheckSessionInvalidIsSessionClassAndNeverDeletesSession(t *testing.T) {
|
||||
s, task := orderWritebackFixture(t)
|
||||
seedWritebackSession(t, s)
|
||||
if ok, err := wbFactoryWorker(s, sybclient.ErrSessionInvalid).RunOnce(context.Background()); err != nil || !ok {
|
||||
t.Fatalf("run %v %v", ok, err)
|
||||
}
|
||||
row := loadOrderWritebackByTask(t, s, task.ID)
|
||||
if row.Status != "failed" || row.ErrorCode != "SYB_SESSION_UNAVAILABLE" {
|
||||
t.Fatalf("status=%s code=%s", row.Status, row.ErrorCode)
|
||||
}
|
||||
if row.LeaseExpiresAt == nil {
|
||||
t.Fatal("ErrSessionInvalid was not scheduled for retry")
|
||||
}
|
||||
assertWritebackSessionUntouched(t, s)
|
||||
}
|
||||
|
||||
func TestOrderWritebackCheckSessionNetworkErrorIsSessionClassAndNeverDeletesSession(t *testing.T) {
|
||||
s, task := orderWritebackFixture(t)
|
||||
seedWritebackSession(t, s)
|
||||
if ok, err := wbFactoryWorker(s, errors.New("dial tcp: i/o timeout")).RunOnce(context.Background()); err != nil || !ok {
|
||||
t.Fatalf("run %v %v", ok, err)
|
||||
}
|
||||
row := loadOrderWritebackByTask(t, s, task.ID)
|
||||
if row.Status != "failed" || row.ErrorCode != "SYB_SESSION_UNAVAILABLE" {
|
||||
t.Fatalf("status=%s code=%s", row.Status, row.ErrorCode)
|
||||
}
|
||||
if row.LeaseExpiresAt == nil {
|
||||
t.Fatal("network error was not scheduled for retry")
|
||||
}
|
||||
assertWritebackSessionUntouched(t, s)
|
||||
}
|
||||
|
||||
func seedWritebackSession(t *testing.T, s *Service) {
|
||||
t.Helper()
|
||||
store := sybclient.NewSessionStore(s.DB)
|
||||
// restoreOrderWritebackClient's SessionStore.Load compares against real
|
||||
// wall-clock time.Now(), not the service's mocked s.Now (which fixtures
|
||||
// pin to a fixed past date) — so the session must expire relative to the
|
||||
// real clock or Load reports ErrNoSession even though a row exists.
|
||||
if err := store.Save(context.Background(), sybclient.Session{
|
||||
Username: "syb-writeback-test", UserID: 555, CookiesJSON: `[{"name":"SESSION","value":"x"}]`, ExpiresAt: time.Now().Add(time.Hour),
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func assertWritebackSessionUntouched(t *testing.T, s *Service) {
|
||||
t.Helper()
|
||||
var count int64
|
||||
if err := s.DB.Model(&models.SYBSession{}).Where("username = ?", "syb-writeback-test").Count(&count).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if count != 1 {
|
||||
t.Fatal("writeback worker deleted or otherwise removed the cached SYB session")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOrderWritebackCanSubmitDuringSessionBackoff(t *testing.T) {
|
||||
s, task := orderWritebackFixture(t)
|
||||
if _, err := wbFactoryWorker(s, sybclient.ErrNoSession).RunOnce(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
updated := loadBackfillTask(t, s.DB, task.ID)
|
||||
views, err := s.OrderWritebackViews(context.Background(), []models.PurchaseTask{updated})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
v := views[task.ID]
|
||||
if v.Status != "failed" || !v.CanSubmit {
|
||||
t.Fatalf("expected resubmit available during backoff: %+v", v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOrderWritebackManualResubmitResetsAttemptCountAndLease(t *testing.T) {
|
||||
s, task := orderWritebackFixture(t)
|
||||
now := s.Now()
|
||||
for i := 0; i < 3; i++ {
|
||||
s.Now = func() time.Time { return now }
|
||||
if _, err := wbFactoryWorker(s, sybclient.ErrNoSession).RunOnce(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
row := loadOrderWritebackByTask(t, s, task.ID)
|
||||
now = row.LeaseExpiresAt.Add(time.Second)
|
||||
}
|
||||
before := loadOrderWritebackByTask(t, s, task.ID)
|
||||
if before.AttemptCount != 3 {
|
||||
t.Fatalf("attempt_count=%d", before.AttemptCount)
|
||||
}
|
||||
if _, err := s.RequestOrderWriteback(context.Background(), OrderWritebackRequest{uuid.NewString(), []uint64{task.ID}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
after := loadOrderWritebackByTask(t, s, task.ID)
|
||||
if after.Status != "pending" || after.AttemptCount != 0 || after.LeaseExpiresAt != nil {
|
||||
t.Fatalf("manual resubmit did not reset state: %+v", after)
|
||||
}
|
||||
f := &fakeOrderNumberClient{apply: true}
|
||||
if ok, err := wbWorker(s, f).RunOnce(context.Background()); err != nil || !ok || f.writes != 1 {
|
||||
t.Fatalf("worker could not process post-resubmit row: ok=%v err=%v writes=%d", ok, err, f.writes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOrderWritebackOtherFailureCodesAreNotAutoRetried(t *testing.T) {
|
||||
s, task := orderWritebackFixture(t)
|
||||
f := &fakeOrderNumberClient{readErr: errors.New("offline")}
|
||||
if ok, err := wbWorker(s, f).RunOnce(context.Background()); err != nil || !ok {
|
||||
t.Fatalf("run %v %v", ok, err)
|
||||
}
|
||||
row := loadOrderWritebackByTask(t, s, task.ID)
|
||||
if row.Status != "failed" || row.ErrorCode != "SYB_READ_FAILED" {
|
||||
t.Fatalf("status=%s code=%s", row.Status, row.ErrorCode)
|
||||
}
|
||||
if row.LeaseExpiresAt != nil {
|
||||
t.Fatal("non-session failure code must not be scheduled for automatic retry")
|
||||
}
|
||||
s.Now = func() time.Time { return row.CreatedAt.Add(24 * time.Hour) }
|
||||
again := &fakeOrderNumberClient{apply: true}
|
||||
if ok, err := wbWorker(s, again).RunOnce(context.Background()); err != nil || ok {
|
||||
t.Fatalf("a non-session failure code was auto-reclaimed: ok=%v err=%v", ok, err)
|
||||
}
|
||||
}
|
||||
|
||||
// --- restoreOrderWritebackClient against a real sybclient.Client + emulated
|
||||
// SYB /am/user/get, so the CheckSession probe added by #330 is actually
|
||||
// exercised end to end instead of only through a fake Factory. ---
|
||||
|
||||
// sybUserGetServer emulates the one endpoint restoreOrderWritebackClient's
|
||||
// CheckSession call depends on, using the real envelope shape documented in
|
||||
// sybclient/client.go's `envelope` type and asserted against in
|
||||
// sybclient/client_test.go.
|
||||
func sybUserGetServer(t *testing.T, handler func(w http.ResponseWriter, r *http.Request)) *httptest.Server {
|
||||
t.Helper()
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/am/user/get", handler)
|
||||
srv := httptest.NewServer(mux)
|
||||
t.Cleanup(srv.Close)
|
||||
return srv
|
||||
}
|
||||
|
||||
// withWritebackSYBConfig points config.ExtConfig.SYB at the given test
|
||||
// server for the duration of the test, restoring the previous value
|
||||
// afterwards so other tests (and any parallel config reads) are unaffected.
|
||||
func withWritebackSYBConfig(t *testing.T, baseURL, username string) {
|
||||
t.Helper()
|
||||
prev := config.ExtConfig.SYB
|
||||
config.ExtConfig.SYB = config.SYB{BaseURL: baseURL, Username: username}
|
||||
t.Cleanup(func() { config.ExtConfig.SYB = prev })
|
||||
}
|
||||
|
||||
func envelopeOK(w http.ResponseWriter, data any) {
|
||||
body, _ := json.Marshal(data)
|
||||
env, _ := json.Marshal(map[string]any{"status": true, "msg": "获取成功", "data": json.RawMessage(body), "code": nil})
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write(env)
|
||||
}
|
||||
|
||||
func TestRestoreOrderWritebackClientValidSessionReturnsClient(t *testing.T) {
|
||||
s, _ := orderWritebackFixture(t)
|
||||
seedWritebackSession(t, s)
|
||||
srv := sybUserGetServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Query().Get("id") != strconv.FormatInt(555, 10) {
|
||||
t.Fatalf("unexpected id query: %s", r.URL.RawQuery)
|
||||
}
|
||||
envelopeOK(w, map[string]any{"id": 555, "username": "syb-writeback-test"})
|
||||
})
|
||||
withWritebackSYBConfig(t, srv.URL, "syb-writeback-test")
|
||||
client, err := restoreOrderWritebackClient(context.Background(), s.DB)
|
||||
if err != nil || client == nil {
|
||||
t.Fatalf("expected a usable client, got client=%v err=%v", client, err)
|
||||
}
|
||||
assertWritebackSessionUntouched(t, s)
|
||||
}
|
||||
|
||||
func TestRestoreOrderWritebackClientMismatchedUsernameIsSessionInvalid(t *testing.T) {
|
||||
s, _ := orderWritebackFixture(t)
|
||||
seedWritebackSession(t, s)
|
||||
srv := sybUserGetServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
// SYB says the cookie now belongs to a different account (12 §3.5):
|
||||
// treated the same as an explicit logout.
|
||||
envelopeOK(w, map[string]any{"id": 555, "username": "somebody-else"})
|
||||
})
|
||||
withWritebackSYBConfig(t, srv.URL, "syb-writeback-test")
|
||||
_, err := restoreOrderWritebackClient(context.Background(), s.DB)
|
||||
if !errors.Is(err, sybclient.ErrSessionInvalid) {
|
||||
t.Fatalf("expected ErrSessionInvalid, got %v", err)
|
||||
}
|
||||
assertWritebackSessionUntouched(t, s)
|
||||
}
|
||||
|
||||
func TestRestoreOrderWritebackClient500IsNotSessionInvalid(t *testing.T) {
|
||||
s, _ := orderWritebackFixture(t)
|
||||
seedWritebackSession(t, s)
|
||||
srv := sybUserGetServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
})
|
||||
withWritebackSYBConfig(t, srv.URL, "syb-writeback-test")
|
||||
_, err := restoreOrderWritebackClient(context.Background(), s.DB)
|
||||
if err == nil {
|
||||
t.Fatal("expected an error for a 5xx response")
|
||||
}
|
||||
if errors.Is(err, sybclient.ErrSessionInvalid) {
|
||||
t.Fatalf("a 5xx must not be classified as a confirmed logout, got %v", err)
|
||||
}
|
||||
assertWritebackSessionUntouched(t, s)
|
||||
}
|
||||
|
||||
func TestRestoreOrderWritebackClientTimeoutIsNotSessionInvalid(t *testing.T) {
|
||||
s, _ := orderWritebackFixture(t)
|
||||
seedWritebackSession(t, s)
|
||||
srv := sybUserGetServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
<-r.Context().Done() // never respond; the client-side ctx timeout fires first
|
||||
})
|
||||
withWritebackSYBConfig(t, srv.URL, "syb-writeback-test")
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
|
||||
defer cancel()
|
||||
_, err := restoreOrderWritebackClient(ctx, s.DB)
|
||||
if err == nil {
|
||||
t.Fatal("expected an error for a request that never completes")
|
||||
}
|
||||
if errors.Is(err, sybclient.ErrSessionInvalid) {
|
||||
t.Fatalf("a timeout must not be classified as a confirmed logout, got %v", err)
|
||||
}
|
||||
assertWritebackSessionUntouched(t, s)
|
||||
}
|
||||
|
||||
// TestOrderWritebackWorkerWithRealFactoryOnInvalidSession is the requested
|
||||
// end-to-end case: the worker's actual Factory (restoreOrderWritebackClient)
|
||||
// against a server that reports the cached session invalid. It must record
|
||||
// SYB_SESSION_UNAVAILABLE with a scheduled backoff and must not touch the
|
||||
// cached session row.
|
||||
func TestOrderWritebackWorkerWithRealFactoryOnInvalidSession(t *testing.T) {
|
||||
s, task := orderWritebackFixture(t)
|
||||
seedWritebackSession(t, s)
|
||||
srv := sybUserGetServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
envelopeOK(w, map[string]any{"id": 555, "username": "somebody-else"})
|
||||
})
|
||||
withWritebackSYBConfig(t, srv.URL, "syb-writeback-test")
|
||||
w := &OrderWritebackWorker{DB: s.DB, Now: s.Now, Factory: restoreOrderWritebackClient}
|
||||
if ok, err := w.RunOnce(context.Background()); err != nil || !ok {
|
||||
t.Fatalf("run %v %v", ok, err)
|
||||
}
|
||||
row := loadOrderWritebackByTask(t, s, task.ID)
|
||||
if row.Status != "failed" || row.ErrorCode != "SYB_SESSION_UNAVAILABLE" {
|
||||
t.Fatalf("status=%s code=%s", row.Status, row.ErrorCode)
|
||||
}
|
||||
if row.LeaseExpiresAt == nil {
|
||||
t.Fatal("no backoff scheduled")
|
||||
}
|
||||
assertWritebackSessionUntouched(t, s)
|
||||
}
|
||||
@@ -223,7 +223,7 @@ func TestOrderWritebackEnqueueRollbackAndSameOrderReplay(t *testing.T) {
|
||||
var dev models.AgentDevice
|
||||
s.DB.First(&dev, *task.DeviceID)
|
||||
// Directly exercise the already_backfilled branch without needing a raw token.
|
||||
r := s.backfillOrder(context.Background(), agentBackfillScope(dev.ID), task.ID, uuid.NewString(), backfillItem(task.ID, *task.PDDOrderNo), false)
|
||||
r := s.backfillOrder(context.Background(), dev.ID, task.ID, uuid.NewString(), backfillItem(task.ID, *task.PDDOrderNo), false)
|
||||
if r.Result != "already_backfilled" {
|
||||
t.Fatal(r.Code)
|
||||
}
|
||||
|
||||
@@ -24,80 +24,12 @@ type OrderWritebackWorker struct {
|
||||
Factory func(context.Context, *gorm.DB) (OrderNumberClient, error)
|
||||
}
|
||||
|
||||
// errSessionUserIDMissing marks a cached session whose UserID column is not a
|
||||
// positive SYB account id. SessionStore.Save (session.go) rejects UserID<=0
|
||||
// before it is ever persisted, so this should be unreachable in practice; it
|
||||
// exists so a corrupted/legacy row fails loudly and safely instead of calling
|
||||
// CheckSession with id=0 (#330 修订1).
|
||||
var errSessionUserIDMissing = errors.New("SYB 会话记录缺少有效 user id")
|
||||
|
||||
// Bounded auto-retry for session-class writeback failures (#330). A session
|
||||
// outage self-heals once GoAutoSYBHourlySync refreshes syb_session, but that
|
||||
// refresh only happens once per hour (at :05) and only fires the run *after*
|
||||
// the session is found dead — so the wait from failure to refresh can be
|
||||
// close to a full hour. The backoff schedule below sums to ~90 minutes
|
||||
// (5+10+15+30+30) across maxSessionRetryAttempts=6 attempts, deliberately
|
||||
// longer than one hourly sync period so a session recovered by "the next"
|
||||
// hourly run is still caught automatically instead of exhausting attempts
|
||||
// first. maxSessionRetryAttempts caps the automatic attempts so a session
|
||||
// that never recovers still lands back in "failed" for a human instead of
|
||||
// retrying forever.
|
||||
const maxSessionRetryAttempts = 6
|
||||
|
||||
var sessionRetryBackoff = []time.Duration{
|
||||
5 * time.Minute,
|
||||
10 * time.Minute,
|
||||
15 * time.Minute,
|
||||
30 * time.Minute,
|
||||
30 * time.Minute,
|
||||
}
|
||||
|
||||
// sessionRetryDelay returns the backoff before the next automatic attempt,
|
||||
// given the attempt number (1-based, i.e. the count already recorded for the
|
||||
// attempt that just failed).
|
||||
func sessionRetryDelay(attempt int) time.Duration {
|
||||
idx := attempt - 1
|
||||
if idx < 0 {
|
||||
idx = 0
|
||||
}
|
||||
if idx >= len(sessionRetryBackoff) {
|
||||
idx = len(sessionRetryBackoff) - 1
|
||||
}
|
||||
return sessionRetryBackoff[idx]
|
||||
}
|
||||
|
||||
// sessionUnavailableMessage classifies why the cached SYB session could not
|
||||
// be used, without ever including cookies, tokens or other credential
|
||||
// material (#330 修订1点3). The category — not the raw error text — is what
|
||||
// gets persisted to error_message, wrapped in a fixed, actionable template
|
||||
// that stays well under the 300-char column limit.
|
||||
func sessionUnavailableMessage(err error) string {
|
||||
category := "会话恢复失败(网络/其他)"
|
||||
switch {
|
||||
case errors.Is(err, sybclient.ErrNoSession):
|
||||
category = "会话缺失/已过期"
|
||||
case errors.Is(err, errSessionUserIDMissing):
|
||||
category = "会话记录异常,缺少 user id"
|
||||
case errors.Is(err, sybclient.ErrSessionInvalid):
|
||||
category = "会话校验失效"
|
||||
}
|
||||
return "SYB会话不可用(" + category + "),将自动重试;如持续失败请恢复登录后重试"
|
||||
}
|
||||
|
||||
// restoreOrderWritebackClient rebuilds a SYB client from the cached session
|
||||
// only. It never logs in, never triggers OCR and never deletes the cached
|
||||
// session (that stays the exclusive responsibility of sybimport.Connect's
|
||||
// login/refresh path) — it only reports whether the cached cookies still
|
||||
// work, via CheckSession, so the caller can classify the failure (#330).
|
||||
func restoreOrderWritebackClient(ctx context.Context, db *gorm.DB) (OrderNumberClient, error) {
|
||||
cfg := config.ExtConfig.SYB.Resolved()
|
||||
session, err := sybclient.NewSessionStore(db).Load(ctx, cfg.Username, time.Now())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if session.UserID <= 0 {
|
||||
return nil, errSessionUserIDMissing
|
||||
}
|
||||
c, err := sybclient.New(cfg.BaseURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -105,16 +37,6 @@ func restoreOrderWritebackClient(ctx context.Context, db *gorm.DB) (OrderNumberC
|
||||
if err = c.ImportCookiesJSON(session.CookiesJSON); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Active probe (#330 修订1): without this, a remotely-expired cookie jar
|
||||
// imports cleanly and only fails later inside read(), which would record
|
||||
// it as SYB_READ_FAILED instead of the retryable session-class outcome.
|
||||
// Any error here — ErrSessionInvalid or network/format — is treated as
|
||||
// session-class; only ErrSessionInvalid is a confirmed logout, but a
|
||||
// network/format error is not confirmed-valid either, so it is still
|
||||
// retried rather than attempted as a write.
|
||||
if err = c.CheckSession(ctx, session.UserID, cfg.Username); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
@@ -156,10 +78,7 @@ func (w *OrderWritebackWorker) RunOnce(ctx context.Context) (bool, error) {
|
||||
var item models.PurchaseOrderWriteback
|
||||
recovering := false
|
||||
err := db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where(
|
||||
"status = ? OR (status = ? AND lease_expires_at <= ?) OR (status = ? AND error_code = ? AND lease_expires_at IS NOT NULL AND lease_expires_at <= ? AND attempt_count < ?)",
|
||||
"pending", "running", now, "failed", "SYB_SESSION_UNAVAILABLE", now, maxSessionRetryAttempts,
|
||||
).Order("id").First(&item).Error; err != nil {
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("status = ? OR (status = ? AND lease_expires_at <= ?)", "pending", "running", now).Order("id").First(&item).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
recovering = item.Status == "running"
|
||||
@@ -171,11 +90,6 @@ func (w *OrderWritebackWorker) RunOnce(ctx context.Context) (bool, error) {
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
// tx.Model(&item).Updates used gorm.Expr("attempt_count + 1") above, which
|
||||
// GORM does not read back into the struct; sync it here so downstream
|
||||
// bounded-retry math (finishSessionUnavailable) sees the true post-claim
|
||||
// count instead of being off by one.
|
||||
item.AttemptCount++
|
||||
finish := func(status, code, message string) error {
|
||||
updates := map[string]any{"status": status, "error_code": code, "error_message": message, "lease_owner": ""}
|
||||
if status != "unknown" {
|
||||
@@ -186,23 +100,6 @@ func (w *OrderWritebackWorker) RunOnce(ctx context.Context) (bool, error) {
|
||||
}
|
||||
return db.Model(&models.PurchaseOrderWriteback{}).Where("id = ? AND status = 'running' AND lease_owner = ?", item.ID, owner).Updates(updates).Error
|
||||
}
|
||||
// finishSessionUnavailable is the bounded-retry counterpart of finish for
|
||||
// SYB_SESSION_UNAVAILABLE: instead of clearing the lease, it schedules the
|
||||
// next automatic attempt (item.AttemptCount was already incremented by the
|
||||
// claim above) until maxSessionRetryAttempts is reached, at which point it
|
||||
// behaves like finish("failed", ...) and stops retrying (#330).
|
||||
finishSessionUnavailable := func(err error) error {
|
||||
updates := map[string]any{
|
||||
"status": "failed", "error_code": "SYB_SESSION_UNAVAILABLE",
|
||||
"error_message": sessionUnavailableMessage(err), "lease_owner": "",
|
||||
}
|
||||
if item.AttemptCount < maxSessionRetryAttempts {
|
||||
updates["lease_expires_at"] = w.Now().Add(sessionRetryDelay(item.AttemptCount))
|
||||
} else {
|
||||
updates["lease_expires_at"] = nil
|
||||
}
|
||||
return db.Model(&models.PurchaseOrderWriteback{}).Where("id = ? AND status = 'running' AND lease_owner = ?", item.ID, owner).Updates(updates).Error
|
||||
}
|
||||
var task models.PurchaseTask
|
||||
if err = db.First(&task, item.PurchaseTaskID).Error; err != nil {
|
||||
return true, finish("failed", "TASK_UNAVAILABLE", "采购任务不可用,请人工核对")
|
||||
@@ -211,14 +108,14 @@ func (w *OrderWritebackWorker) RunOnce(ctx context.Context) (bool, error) {
|
||||
if !orderWritebackEligible(task) || *task.PDDOrderNo != item.OrderNo {
|
||||
return true, finish("conflict", "ORDER_FACT_CHANGED", "采购订单事实已变化,请人工核对")
|
||||
}
|
||||
if err = db.First(&syb, *task.SYBProductID).Error; err != nil || !validOrderWritebackTarget(syb) || int64(syb.StockID) != item.StockID || int64(syb.DetailID) != item.DetailID {
|
||||
if err = db.First(&syb, *task.SYBProductID).Error; err != nil || int64(syb.StockID) != item.StockID || int64(syb.DetailID) != item.DetailID {
|
||||
return true, finish("conflict", "SYB_TARGET_CHANGED", "SYB商品明细关联已变化")
|
||||
}
|
||||
callCtx, cancel := context.WithTimeout(ctx, 25*time.Second)
|
||||
client, err := w.Factory(callCtx, db)
|
||||
cancel()
|
||||
if err != nil {
|
||||
return true, finishSessionUnavailable(err)
|
||||
return true, finish("failed", "SYB_SESSION_UNAVAILABLE", "SYB会话不可用,请恢复登录后重试")
|
||||
}
|
||||
read := func() (string, string, error) {
|
||||
readCtx, stop := context.WithTimeout(ctx, 20*time.Second)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -157,7 +157,6 @@ type ResultRequest struct {
|
||||
PDDOrderNo string `json:"pddOrderNo,omitempty"`
|
||||
OrderSubmittedAt *time.Time `json:"orderSubmittedAt,omitempty"`
|
||||
ActualUnitPriceCent *int64 `json:"actualUnitPriceCent,omitempty"`
|
||||
PDDOrderAmountCent *int64 `json:"pddOrderAmountCent,omitempty"`
|
||||
ErrorCode string `json:"errorCode,omitempty"`
|
||||
ErrorMessage string `json:"errorMessage,omitempty"`
|
||||
ProbedSpecs json.RawMessage `json:"probedSpecs,omitempty"`
|
||||
@@ -180,11 +179,10 @@ type SpecDecisionRequest struct {
|
||||
}
|
||||
|
||||
type ManualRequest struct {
|
||||
RequestID string `json:"requestId"`
|
||||
OperatorID uint64 `json:"operatorId"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
Status string `json:"status,omitempty"`
|
||||
PDDOrderNo string `json:"pddOrderNo,omitempty"`
|
||||
OrderSubmittedAt *time.Time `json:"orderSubmittedAt,omitempty"`
|
||||
PDDOrderAmountCent *int64 `json:"pddOrderAmountCent,omitempty"`
|
||||
RequestID string `json:"requestId"`
|
||||
OperatorID uint64 `json:"operatorId"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
Status string `json:"status,omitempty"`
|
||||
PDDOrderNo string `json:"pddOrderNo,omitempty"`
|
||||
OrderSubmittedAt *time.Time `json:"orderSubmittedAt,omitempty"`
|
||||
}
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
package shopeeproduct
|
||||
|
||||
import (
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
|
||||
)
|
||||
|
||||
// #333: the real middleware sets userId=0 on every request; the operator used
|
||||
// for owned-device collection lookup and audit must come from the JWT identity.
|
||||
func TestCurrentUserIDReadsJWTIdentityNotUserIDKey(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
cases := []struct {
|
||||
claims jwt.MapClaims
|
||||
want uint64
|
||||
}{
|
||||
{jwt.MapClaims{"rolekey": "purchaser", "identity": float64(3)}, 3},
|
||||
{jwt.MapClaims{"rolekey": "purchaser"}, 0},
|
||||
{jwt.MapClaims{"rolekey": "purchaser", "identity": float64(-1)}, 0},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
c, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
c.Set(jwt.JwtPayloadKey, tc.claims)
|
||||
c.Set("userId", 0)
|
||||
if got := currentUserID(c); got != tc.want {
|
||||
t.Fatalf("claims %v: got %d want %d", tc.claims, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,6 @@ import (
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/go-admin-team/go-admin-core/sdk/pkg"
|
||||
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
@@ -121,8 +120,6 @@ func (handler Handler) LinkPDD(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
service.ReplacementEligibility = handler.ReplacementEligibility
|
||||
service.OperatorUserID = currentUserID(c)
|
||||
service.OperatorIsAdmin = currentRole(c) == "admin"
|
||||
response, err := service.LinkPDD(c.Request.Context(), id, request)
|
||||
respond(c, response, err)
|
||||
}
|
||||
@@ -427,31 +424,22 @@ func (handler Handler) service(c *gin.Context) (*Service, bool) {
|
||||
// purchasing roles per #40; role membership itself is enforced by the router
|
||||
// middleware, not here.
|
||||
func currentUserID(c *gin.Context) uint64 {
|
||||
// #333: go-admin's Authorizator runs on every request with the
|
||||
// IdentityHandler map, which has no "user" entry, so c.Get("userId") is
|
||||
// always 0 there. The JWT "identity" claim is the authenticated user id.
|
||||
switch id := jwt.ExtractClaims(c)["identity"].(type) {
|
||||
case float64:
|
||||
if id > 0 {
|
||||
return uint64(id)
|
||||
}
|
||||
value, exists := c.Get("userId")
|
||||
if !exists {
|
||||
return 0
|
||||
}
|
||||
switch id := value.(type) {
|
||||
case int:
|
||||
if id > 0 {
|
||||
return uint64(id)
|
||||
}
|
||||
return uint64(id)
|
||||
case int64:
|
||||
if id > 0 {
|
||||
return uint64(id)
|
||||
}
|
||||
return uint64(id)
|
||||
case uint64:
|
||||
return id
|
||||
case float64:
|
||||
return uint64(id)
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func currentRole(c *gin.Context) string {
|
||||
value, _ := jwt.ExtractClaims(c)["rolekey"].(string)
|
||||
return value
|
||||
}
|
||||
|
||||
func respond(c *gin.Context, response SaveResponse, err error) {
|
||||
@@ -501,8 +489,6 @@ func writeError(c *gin.Context, err error) {
|
||||
status = http.StatusNotFound
|
||||
case CodePDDProductDisabled, CodeSpecContextStale, CodeLatestCollectionUnavailable, CodeLinkConflict:
|
||||
status = http.StatusConflict
|
||||
case CodeDeviceOwnershipForbidden:
|
||||
status = http.StatusForbidden
|
||||
case CodeAIUnavailable:
|
||||
status = http.StatusServiceUnavailable
|
||||
}
|
||||
|
||||
@@ -11,7 +11,6 @@ import (
|
||||
|
||||
const CodeLatestCollectionUnavailable = "LATEST_COLLECTION_UNAVAILABLE"
|
||||
const CodeLinkConflict = "PDD_LINK_CONFLICT"
|
||||
const CodeDeviceOwnershipForbidden = "DEVICE_OWNERSHIP_FORBIDDEN"
|
||||
|
||||
type LatestCollectionRequest struct {
|
||||
SYBProductID uint64 `json:"sybProductId"`
|
||||
@@ -106,9 +105,6 @@ func (service *Service) linkLatestCollection(ctx context.Context, id uint64, req
|
||||
if device.Status != models.DeviceStatusOnline && device.Status != models.DeviceStatusOffline {
|
||||
return latestUnavailable("手机已停用,请重新选择")
|
||||
}
|
||||
if service.OperatorUserID > 0 && !service.OperatorIsAdmin && (device.OwnerUserID == nil || *device.OwnerUserID != service.OperatorUserID) {
|
||||
return &ServiceError{Code: CodeDeviceOwnershipForbidden, Message: "该手机不属于当前采购员,无法使用其采集记录", Retryable: false}
|
||||
}
|
||||
var task models.CollectionTask
|
||||
query := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("device_id = ? AND source = ? AND status IN ?", r.DeviceID, models.CollectionTaskSourceAgentCurrentPage, []string{models.TaskStatusCompleted, models.TaskStatusCompletedPartial})
|
||||
if replacement != nil && !replacement.Preview {
|
||||
|
||||
@@ -63,21 +63,6 @@ func TestLatestCollectionOfflineAndOnlyIfUnlinked(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestLatestCollectionRejectsDeviceOwnedByAnotherUser(t *testing.T) {
|
||||
s, id, req, _ := latestFixture(t)
|
||||
owner := uint64(21)
|
||||
s.DB.Model(&models.AgentDevice{}).Where("id = ?", req.LatestCollection.DeviceID).Update("owner_user_id", owner)
|
||||
s.OperatorUserID = 22
|
||||
if _, err := s.LinkPDD(context.Background(), id, req); err == nil || errCode(t, err) != CodeDeviceOwnershipForbidden {
|
||||
t.Fatalf("expected ownership rejection: %v", err)
|
||||
}
|
||||
var product models.ShopeeProduct
|
||||
s.DB.First(&product, id)
|
||||
if product.PDDProductID != nil {
|
||||
t.Fatal("ownership rejection wrote association")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLatestCollectionRejectsInvalidContextWithoutWrite(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
|
||||
@@ -62,8 +62,6 @@ type ReplacementEligibility func(context.Context, *gorm.DB, uint64) error
|
||||
type Service struct {
|
||||
DB *gorm.DB
|
||||
ReplacementEligibility ReplacementEligibility
|
||||
OperatorUserID uint64
|
||||
OperatorIsAdmin bool
|
||||
}
|
||||
|
||||
func NewService(db *gorm.DB) *Service { return &Service{DB: db} }
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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}})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user