Compare commits
27
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d2083e71fb | ||
|
|
5206a6fd7d | ||
|
|
c55455d35a | ||
|
|
37714d39b5 | ||
|
|
d1996d28ee | ||
|
|
7fdfe04658 | ||
|
|
53143131be | ||
|
|
650f3a6ba1 | ||
|
|
7622795d97 | ||
|
|
4ae33d8827 | ||
|
|
8ac3f2273f | ||
|
|
0138da8560 | ||
|
|
a82a1469a6 | ||
|
|
9fcbc64117 | ||
|
|
151c426e54 | ||
|
|
95847a3f2d | ||
|
|
6781b309d4 | ||
|
|
eb7cb6b5eb | ||
|
|
845853c68a | ||
|
|
955869b5f5 | ||
|
|
49de6f8045 | ||
|
|
279cb55847 | ||
|
|
35119ea261 | ||
|
|
cb00194b0f | ||
|
|
46b92dd6c5 | ||
|
|
1f5bab8192 | ||
|
|
9a4495addd |
@@ -27,7 +27,12 @@
|
||||
- 不把密码、Token、Cookie、私钥、PDD 账号凭据、个人数据或生产数据写入代码、日志、工单和文档。例外:经用户于 2026-08-21 明确确认的 #62 内部 AI Provider API Key,可明文保存在专用 `ai_matching_setting` 数据表,并只返回给管理员用于下次查看和替换;它仍不得出现在代码、日志、工单、Wiki、任务快照、采购员接口或 Android 接口中。
|
||||
- 不执行付款。当前项目不实现任何自动支付动作、入口或测试;后续如需实现,必须单独建单评估,并至少具备显式能力位、服务端开关、单笔金额上限与人工授权四项控制。支付、下单和订单相关文字允许作为只读识别信号出现在采集与采购规则中,用于判断页面形态;任何规则都不得把它们配置为点击目标。
|
||||
- 当前采集 MVP 只实现 PDD 商品、规则、任务、Android 执行和任务详情。采购是独立的后续高风险 MVP,未通过对应原型和工单门禁前不能混入采集代码;采集规则可以描述订单确认面板的只读特征,这不构成采购代码混入采集。
|
||||
- 不保存原始控件树和整屏设备截图;允许保存按规格值节点边界裁剪的商品图片区域,且裁剪结果不得包含账号、地址、订单、支付及其他个人数据。除此之外只保存结构化任务日志、错误码、任务规则快照和采集结果。
|
||||
- 不保存整屏设备截图;允许保存按规格值节点边界裁剪的商品图片区域,且裁剪结果不得包含账号、地址、订单、支付及其他个人数据。除此之外只保存结构化任务日志、错误码、任务规则快照、采集结果和下一条的失败现场控件树。
|
||||
- 失败现场控件树(用户于 2026-10-07 确认,GoAuto 为内部系统):Android Agent 在任务失败或结果待核对时,可以把当时的原始无障碍控件树保存到本机 SQLite 并上传服务端,供管理员下载调试。限制如下:
|
||||
- 只在失败、结果待核对等需要排查的时刻保存,不在正常执行中逐步采集;每次与 `taskId`、`deviceId`、尝试次数和错误码关联。
|
||||
- 控件树会包含收件人、电话、地址、订单号等个人数据:只允许保存在本机诊断库和服务端数据库,只对管理员开放下载;不得写入代码、普通日志、工单、Wiki、任务快照、SynapBus 消息或采购员、Android 普通接口。分析时只摘录定位问题必需的片段,并去掉个人数据。
|
||||
- 密码输入框内容不得保存;本条不放宽付款、整屏截图及 OCR/VLM 的限制。
|
||||
- 本机和服务端都必须自动清理过期数据,默认保留 30 天。
|
||||
- 一台设备同一时刻只执行一个任务;手机离线时当前采集任务失败,默认不重试、不自动换机。
|
||||
- Android Agent 端:找不到控件、验证码、风控、人机验证或登录失效时明确失败,不使用 OCR/VLM。
|
||||
- 服务端顺云宝(SYB)登录与 yeeke(mmt.yeeke.com 对裹)登录:允许调用配置的线上自建 OCR 服务识别登录验证码(SYB 见 #48;yeeke 于 2026-09-23 经用户在 #336 明确批准)。验证码图片会离开本项目发送到该服务,更换服务地址前必须重新评估;OCR 请求和日志不得记录账号、密码、验证码原文或 token。此例外只适用于上述两个服务端登录流程,不扩大到 Agent 端或任何 PDD 相关流程。
|
||||
|
||||
@@ -11,8 +11,8 @@ android {
|
||||
applicationId = "cn.ilapage.goauto.agent"
|
||||
minSdk = 23
|
||||
targetSdk = 34
|
||||
versionCode = 81
|
||||
versionName = "0.9.68"
|
||||
versionCode = 82
|
||||
versionName = "0.9.69"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
|
||||
|
||||
+2
@@ -248,6 +248,8 @@ class GoAutoAccessibilityService : AccessibilityService(), UiDriver, PddCollecto
|
||||
checked = node.isChecked,
|
||||
enabled = node.isEnabled,
|
||||
visible = node.isVisibleToUser,
|
||||
hintText = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) node.hintText?.toString() else null,
|
||||
showingHintText = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) node.isShowingHintText else null,
|
||||
)
|
||||
for (index in 0 until node.childCount) {
|
||||
node.getChild(index)?.let { snapshot(it, "$path/$index", path) }
|
||||
|
||||
+60
-3
@@ -27,6 +27,9 @@ data class SnapshotNode(
|
||||
val checked: Boolean,
|
||||
val enabled: Boolean,
|
||||
val visible: Boolean,
|
||||
// In-memory accessibility metadata; not part of result or diagnostic payloads.
|
||||
val hintText: String? = null,
|
||||
val showingHintText: Boolean? = null,
|
||||
) {
|
||||
val label: String get() = text?.trim().takeUnless { it.isNullOrEmpty() }
|
||||
?: contentDescription?.trim().orEmpty()
|
||||
@@ -376,6 +379,7 @@ object PddScreenParser {
|
||||
val candidates = panelVisible.asSequence()
|
||||
.filter { it.clickable && inValueRegion(it) }
|
||||
.filterNot { isExactHeadingLabel(it.label, config) }
|
||||
.filterNot { containsDistinctOptionCard(it, sourceByPath) }
|
||||
.filterNot { node ->
|
||||
isExcludedOptionLabel(node.label) ||
|
||||
descendants(node, visibleNodes).any { descendant -> isExcludedOptionLabel(descendant.label) }
|
||||
@@ -586,8 +590,8 @@ object PddScreenParser {
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* prefixes every candidate nested in it, or a single image/caption card with
|
||||
* a truncated caption. A shared ancestor alone is not card identity.
|
||||
*/
|
||||
private fun optionBlock(
|
||||
candidate: SnapshotNode,
|
||||
@@ -605,9 +609,11 @@ object PddScreenParser {
|
||||
// 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)) {
|
||||
if (containsDistinctOptionCard(resolved, sourceByPath)) break
|
||||
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
|
||||
val existingBlock = outer.isNotEmpty() && members.all { compact(it.label).startsWith(outer) }
|
||||
if (!existingBlock && !isTruncatedDisplayCard(resolved, members, sourceByPath)) break
|
||||
block = resolved
|
||||
}
|
||||
parentPath = raw.parentPath
|
||||
@@ -615,6 +621,57 @@ object PddScreenParser {
|
||||
return block
|
||||
}
|
||||
|
||||
/** A labelled group must neither name its children nor become a synthetic selected option. */
|
||||
private fun containsDistinctOptionCard(block: SnapshotNode, sourceByPath: Map<String, SnapshotNode>): Boolean {
|
||||
val raw = sourceByPath[block.path] ?: return false
|
||||
if (raw.label.isBlank()) return false
|
||||
val name = SpecValueNormalizer.normalizeColor(raw.label).filterNot { it.isWhitespace() }
|
||||
return sourceByPath.values.any { child ->
|
||||
child.path.startsWith("${block.path}/") && child.clickable && child.label.isNotBlank() &&
|
||||
SpecValueNormalizer.normalizeColor(child.label).filterNot { it.isWhitespace() } != name &&
|
||||
sourceByPath.values.any { it.parentPath == child.path }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* #362: narrowly recognize one full-labelled image above one truncated text
|
||||
* leaf. Do not reverse the old prefix rule for arbitrary nested options.
|
||||
* Non-clickable layout wrappers are fine; another clickable container is
|
||||
* ambiguous, even if its label/price also happens to be a prefix match.
|
||||
*/
|
||||
private fun isTruncatedDisplayCard(
|
||||
block: SnapshotNode,
|
||||
members: List<SnapshotNode>,
|
||||
sourceByPath: Map<String, SnapshotNode>,
|
||||
): Boolean {
|
||||
fun name(label: String) = SpecValueNormalizer.normalizeColor(label).filterNot { it.isWhitespace() }
|
||||
val full = name(block.label)
|
||||
if (full.isEmpty()) return false
|
||||
val children = members.filter { it.path != block.path }
|
||||
val image = children.singleOrNull { it.className == "android.widget.ImageView" } ?: return false
|
||||
val caption = children.singleOrNull { it.className == "android.widget.TextView" } ?: return false
|
||||
if (children.size != 2 || name(image.label) != full) return false
|
||||
val short = name(caption.label)
|
||||
if (short.isEmpty() || short == full || !full.startsWith(short)) return false
|
||||
|
||||
val descendants = sourceByPath.values.filter { it.path.startsWith("${block.path}/") }
|
||||
// Image and caption must be leaves, not two independently labelled cards.
|
||||
if (children.any { child -> descendants.any { it.parentPath == child.path } }) return false
|
||||
if (descendants.any { node ->
|
||||
node.clickable && node.label.isNotBlank() &&
|
||||
descendants.any { it.parentPath == node.path }
|
||||
}) return false
|
||||
if (children.any { child ->
|
||||
child.bounds.left < block.bounds.left || child.bounds.right > block.bounds.right ||
|
||||
child.bounds.top < block.bounds.top || child.bounds.bottom > block.bounds.bottom
|
||||
}) return false
|
||||
if (image.bounds.top >= caption.bounds.top || image.bounds.bottom >= caption.bounds.bottom ||
|
||||
minOf(image.bounds.right, caption.bounds.right) <= maxOf(image.bounds.left, caption.bounds.left)) return false
|
||||
|
||||
val prices = members.mapNotNull { SpecValueNormalizer.trailingPriceAmount(it.label) }
|
||||
return prices.isEmpty() || prices.all { it.compareTo(prices.first()) == 0 }
|
||||
}
|
||||
|
||||
/** 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
|
||||
|
||||
+108
-34
@@ -42,6 +42,7 @@ class PurchaseLiveAutomation(
|
||||
private val pause: (Long) -> Unit = Thread::sleep,
|
||||
/** Boolean/count-only diagnostics; never receives address text. */
|
||||
private val panelDiagnostic: (String) -> Unit = {},
|
||||
private val monotonicClockMs: () -> Long = { System.nanoTime() / 1_000_000L },
|
||||
) {
|
||||
private var submitAttempted = false
|
||||
var lastOrderReadFailure: PurchaseOrderReadFailure? = null
|
||||
@@ -366,48 +367,83 @@ class PurchaseLiveAutomation(
|
||||
var consecutiveEmptySnapshots = 0
|
||||
var unpaidStartSample: Int? = null
|
||||
var unpaidSwipes = 0
|
||||
repeat(ORDER_RESULT_MAX_SAMPLES + ORDER_RESULT_UNPAID_MAX_SAMPLES) { index ->
|
||||
var paymentBackSucceededAt: Long? = null
|
||||
var orderContextObserved = false
|
||||
// Extra observations cannot reopen navigation/scroll budgets. Late unpaid evidence
|
||||
// still receives its existing independent budget below.
|
||||
var passiveExtension = false
|
||||
fun observationRemainingMs(): Long? {
|
||||
val startedAt = paymentBackSucceededAt ?: return null
|
||||
if (unpaidStartSample != null || (orderContextObserved && !passiveExtension)) return null
|
||||
return ORDER_RESULT_POST_PAYMENT_OBSERVATION_MS - (monotonicClockMs() - startedAt)
|
||||
}
|
||||
fun observePause(durationMs: Long) {
|
||||
val remaining = observationRemainingMs()
|
||||
val duration = if (remaining == null) durationMs else minOf(durationMs, remaining.coerceAtLeast(0))
|
||||
if (duration > 0) pause(duration)
|
||||
}
|
||||
var nextSample = 0
|
||||
while (true) {
|
||||
val index = nextSample++
|
||||
// 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) ||
|
||||
if (unpaidStart == null && index >= ORDER_RESULT_MAX_SAMPLES &&
|
||||
paymentBackSucceededAt != null && !orderContextObserved) {
|
||||
passiveExtension = true
|
||||
}
|
||||
if ((observationRemainingMs()?.let { it <= 0 } == true) ||
|
||||
(unpaidStart == null && index >= ORDER_RESULT_MAX_SAMPLES && !passiveExtension) ||
|
||||
(unpaidStart != null && index - unpaidStart >= ORDER_RESULT_UNPAID_MAX_SAMPLES)) {
|
||||
val failure = orderEvidenceFailure(labels)
|
||||
return unknown(failure.code, failure.message)
|
||||
}
|
||||
val snapshot = driver.capture()
|
||||
// A blocking capture can finish after the deadline. Do not act on or accept that frame.
|
||||
if (observationRemainingMs()?.let { it <= 0 } == true) {
|
||||
val failure = orderEvidenceFailure(labels)
|
||||
return unknown(failure.code, failure.message)
|
||||
}
|
||||
if (snapshot.packageName.isNullOrBlank()) {
|
||||
consecutiveEmptySnapshots++
|
||||
if (consecutiveEmptySnapshots >= ORDER_RESULT_MAX_EMPTY_SAMPLES) {
|
||||
return unknown("PURCHASE_ORDER_EMPTY_TIMEOUT", "等待订单页面时无障碍窗口持续为空")
|
||||
}
|
||||
pause(ORDER_RESULT_SAMPLE_INTERVAL_MS)
|
||||
return@repeat
|
||||
observePause(ORDER_RESULT_SAMPLE_INTERVAL_MS)
|
||||
continue
|
||||
}
|
||||
consecutiveEmptySnapshots = 0
|
||||
val currentLabels = snapshot.nodes.filter { it.visible }.map { it.label }
|
||||
if (isKnownAndroidWechatChooser(snapshot, currentLabels)) {
|
||||
if (passiveExtension && !backedOutOfChooser) {
|
||||
val failure = orderEvidenceFailure(labels)
|
||||
return unknown(failure.code, failure.message)
|
||||
}
|
||||
if (backedOutOfChooser || !driver.backPurchase()) {
|
||||
return unknown("PURCHASE_ORDER_CHOOSER_BACK_FAILED", "系统应用选择页无法安全返回")
|
||||
}
|
||||
backedOutOfChooser = true
|
||||
pause(500)
|
||||
return@repeat
|
||||
observePause(500)
|
||||
continue
|
||||
}
|
||||
if (snapshot.packageName == WECHAT_PACKAGE) {
|
||||
if (!restoredFromWechat) {
|
||||
if (passiveExtension) {
|
||||
val failure = orderEvidenceFailure(labels)
|
||||
return unknown(failure.code, failure.message)
|
||||
}
|
||||
if (!driver.bringPddToForeground()) {
|
||||
return unknown("PURCHASE_ORDER_WECHAT_RESTORE_FAILED", "从微信恢复拼多多的请求失败")
|
||||
}
|
||||
restoredFromWechat = true
|
||||
pause(500)
|
||||
return@repeat
|
||||
observePause(500)
|
||||
continue
|
||||
}
|
||||
if (pddObservedAfterWechatRestore || ++wechatRestorePendingSamples >= ORDER_RESULT_WECHAT_RESTORE_MAX_SAMPLES) {
|
||||
return unknown("PURCHASE_ORDER_WECHAT_RESTORE_TIMEOUT", "从微信恢复拼多多后未在限定时间到达订单页面")
|
||||
}
|
||||
pause(ORDER_RESULT_SAMPLE_INTERVAL_MS)
|
||||
return@repeat
|
||||
observePause(ORDER_RESULT_SAMPLE_INTERVAL_MS)
|
||||
continue
|
||||
}
|
||||
if (snapshot.packageName != PDD_PACKAGE) {
|
||||
return unknown("PURCHASE_ORDER_UNEXPECTED_APP", "核单期间出现未授权应用")
|
||||
@@ -426,7 +462,8 @@ class PurchaseLiveAutomation(
|
||||
if (!driver.backPurchase()) {
|
||||
return unknown("PURCHASE_ORDER_PAYMENT_BACK_FAILED", "支付页无法安全返回订单详情")
|
||||
}
|
||||
pause(500)
|
||||
paymentBackSucceededAt = monotonicClockMs()
|
||||
observePause(500)
|
||||
} else {
|
||||
consecutivePaymentSamplesAfterBack++
|
||||
if (consecutivePaymentSamplesAfterBack >= ORDER_RESULT_PAYMENT_POST_BACK_MAX_SAMPLES) {
|
||||
@@ -437,20 +474,21 @@ class PurchaseLiveAutomation(
|
||||
"consecutivePaymentSamplesAfterBack=$consecutivePaymentSamplesAfterBack]",
|
||||
)
|
||||
}
|
||||
pause(ORDER_RESULT_SAMPLE_INTERVAL_MS)
|
||||
observePause(ORDER_RESULT_SAMPLE_INTERVAL_MS)
|
||||
}
|
||||
return@repeat
|
||||
continue
|
||||
}
|
||||
consecutivePaymentSamplesAfterBack = 0
|
||||
if (orderContextVisible) orderContextObserved = true
|
||||
if (unpaidContextVisible) {
|
||||
paymentPageObserved = true
|
||||
if (unpaidStartSample == null) unpaidStartSample = index
|
||||
}
|
||||
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
|
||||
if (unpaidStartSample != null || passiveExtension) {
|
||||
// Neither unpaid reading nor the added observation budget permits a new click.
|
||||
observePause(ORDER_RESULT_SAMPLE_INTERVAL_MS)
|
||||
continue
|
||||
}
|
||||
val entries = orderDetailEntryTargets(snapshot)
|
||||
if (entries.size > 1) {
|
||||
@@ -466,27 +504,25 @@ class PurchaseLiveAutomation(
|
||||
else -> return unknown("PURCHASE_ORDER_DETAIL_ENTRY_FAILED", "订单详情入口点击失败,已停止只读核单")
|
||||
}
|
||||
orderDetailEntryOpened = true
|
||||
pause(500)
|
||||
return@repeat
|
||||
observePause(500)
|
||||
continue
|
||||
}
|
||||
pause(ORDER_RESULT_SAMPLE_INTERVAL_MS)
|
||||
return@repeat
|
||||
observePause(ORDER_RESULT_SAMPLE_INTERVAL_MS)
|
||||
continue
|
||||
}
|
||||
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
|
||||
observePause(ORDER_RESULT_UNPAID_SETTLE_MS)
|
||||
continue
|
||||
}
|
||||
if (unpaidStartSample == null && index > 0 && index % ORDER_RESULT_SCROLL_SAMPLE_INTERVAL == 0) {
|
||||
if (!passiveExtension && unpaidStartSample == null && index > 0 && index % ORDER_RESULT_SCROLL_SAMPLE_INTERVAL == 0) {
|
||||
driver.swipePurchase(SwipeDirection.UP, 400)
|
||||
}
|
||||
pause(ORDER_RESULT_SAMPLE_INTERVAL_MS)
|
||||
observePause(ORDER_RESULT_SAMPLE_INTERVAL_MS)
|
||||
}
|
||||
val failure = orderEvidenceFailure(labels)
|
||||
return unknown(failure.code, failure.message)
|
||||
}
|
||||
|
||||
// orderEvidenceFailure 之外的路径(选择器卡住、微信恢复超时等)同样要带上
|
||||
@@ -509,16 +545,12 @@ class PurchaseLiveAutomation(
|
||||
val modify = addressModifyTargets(panel)
|
||||
if (modify.size != 1) fail("PURCHASE_ADDRESS_EDIT_AMBIGUOUS", "没有找到唯一的地址修改按钮,未创建订单")
|
||||
click(modify.single(), "修改地址")
|
||||
val edit = waitFor("PURCHASE_ADDRESS_EDIT_TIMEOUT", "地址编辑页面打开超时,未创建订单") { snapshot ->
|
||||
snapshot.nodes.any { it.visible && it.label.replace(" ", "").contains("详细地址") }
|
||||
}
|
||||
val editors = shippingAddressEditors(edit)
|
||||
if (editors.size != 1) fail("PURCHASE_ADDRESS_UPDATE_FAILED", "没有找到唯一的详细地址输入框,未创建订单")
|
||||
val current = editors.single().label.trim()
|
||||
val editor = waitForInitialAddressEditor()
|
||||
val current = editor.text.orEmpty().trim()
|
||||
val body = current.split(Regex("[-_]"), limit = 2).firstOrNull()?.trim().orEmpty()
|
||||
if (body.isBlank()) fail("PURCHASE_ADDRESS_UPDATE_FAILED", "当前详细地址无效,未创建订单")
|
||||
val expected = body + suffix
|
||||
if (driver.inputFresh(editors.single(), expected) != FreshActionResult.SUCCESS) fail("PURCHASE_ADDRESS_UPDATE_FAILED", "详细地址修改失败,未创建订单")
|
||||
if (driver.inputFresh(editor, expected) != FreshActionResult.SUCCESS) fail("PURCHASE_ADDRESS_UPDATE_FAILED", "详细地址修改失败,未创建订单")
|
||||
val stable = waitFor("PURCHASE_ADDRESS_INPUT_MISMATCH", "详细地址修改后回读不一致,未创建订单") { snapshot ->
|
||||
snapshot.nodes.count { it.visible && it.enabled && it.className?.endsWith("EditText") == true && it.label == expected } == 1
|
||||
}
|
||||
@@ -539,6 +571,47 @@ class PurchaseLiveAutomation(
|
||||
return ShippingAddressProof(expected, suffix)
|
||||
}
|
||||
|
||||
/** Initial readiness only; return the same sampled node for the original value and fresh input anchor. */
|
||||
private fun waitForInitialAddressEditor(): SnapshotNode {
|
||||
fun isHint(editor: SnapshotNode): Boolean {
|
||||
val hint = editor.hintText?.trim().orEmpty()
|
||||
return editor.showingHintText == true || (hint.isNotEmpty() && editor.text?.trim() == hint)
|
||||
}
|
||||
var titleSeen = false
|
||||
var structural = emptyList<SnapshotNode>()
|
||||
var readyCount = 0
|
||||
repeat(50) {
|
||||
val snapshot = driver.capture()
|
||||
pageProblem(snapshot)
|
||||
val labels = snapshot.nodes.filter { it.visible && it.label.replace(" ", "").contains("详细地址") }
|
||||
titleSeen = titleSeen || labels.isNotEmpty()
|
||||
// Keep the existing same-row/rightward region, but count empty and disabled inputs too.
|
||||
// Their presence is structural ambiguity, even when just one input is ready.
|
||||
structural = snapshot.nodes.filter { editor ->
|
||||
editor.visible && editor.className?.endsWith("EditText") == true &&
|
||||
labels.any { label ->
|
||||
editor.bounds.top <= label.bounds.bottom &&
|
||||
editor.bounds.bottom >= label.bounds.top &&
|
||||
editor.bounds.left >= label.bounds.left
|
||||
}
|
||||
}.distinctBy { it.path }
|
||||
readyCount = structural.count { it.enabled && !it.text.isNullOrBlank() && !isHint(it) }
|
||||
if (structural.size == 1 && readyCount == 1) return structural.single()
|
||||
pause(200)
|
||||
}
|
||||
val reason = when {
|
||||
!titleSeen -> "title_missing"
|
||||
structural.isEmpty() -> "editor_missing"
|
||||
structural.size > 1 -> "editor_ambiguous"
|
||||
!structural.single().enabled -> "editor_disabled"
|
||||
structural.single().text.isNullOrBlank() -> "editor_empty"
|
||||
else -> "editor_hint"
|
||||
}
|
||||
val diagnostic = "stage=address_editor_ready;reason=$reason;titleSeen=$titleSeen;structural=${structural.size};ready=$readyCount"
|
||||
if (!titleSeen) fail("PURCHASE_ADDRESS_EDIT_TIMEOUT", "地址编辑页面打开超时,未创建订单 [$diagnostic]")
|
||||
fail("PURCHASE_ADDRESS_UPDATE_FAILED", "详细地址输入框未就绪,未创建订单 [$diagnostic]")
|
||||
}
|
||||
|
||||
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)
|
||||
@@ -1038,6 +1111,7 @@ class PurchaseLiveAutomation(
|
||||
val ANDROID_CHOOSER_TITLES = setOf("选择要使用的应用", "使用以下应用打开", "完成操作时使用")
|
||||
val PDD_PAYMENT_ACTIVITIES = setOf("com.xunmeng.pinduoduo.app_pay.core.PayActivity")
|
||||
const val ORDER_RESULT_MAX_SAMPLES = 60
|
||||
const val ORDER_RESULT_POST_PAYMENT_OBSERVATION_MS = 30_000L
|
||||
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
|
||||
|
||||
@@ -13,6 +13,10 @@ object SpecValueNormalizer {
|
||||
|
||||
fun normalizeSize(value: String): String = normalizeTrailingPrice(value)
|
||||
|
||||
/** The same terminal-price grammar as normalization; never reads numbers inside a spec. */
|
||||
internal fun trailingPriceAmount(value: String): java.math.BigDecimal? =
|
||||
trailingPrice.find(value)?.value?.replace(currency, "")?.trim()?.toBigDecimalOrNull()
|
||||
|
||||
fun isSafeColor(value: String): Boolean = isSafeValue(value)
|
||||
|
||||
fun isSafeSize(value: String): Boolean = isSafeValue(value)
|
||||
|
||||
@@ -18,6 +18,354 @@ import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class PurchaseLiveAutomationTest {
|
||||
@Test
|
||||
fun `hint delayed address update preserves fresh payment back budget for late order evidence`() {
|
||||
val observationDriver = OrderObservationDriver { sample ->
|
||||
observationPage(when {
|
||||
sample == 1 -> "payment"
|
||||
sample < 140 -> "loading"
|
||||
else -> "complete"
|
||||
})
|
||||
}.apply { backDuration = 4_000 }
|
||||
lateinit var addressDriver: LiveDriver
|
||||
addressDriver = LiveDriver(initialEditFrame = { snapshot, sample ->
|
||||
assertEquals(0, addressDriver.inputCount)
|
||||
assertFalse(addressDriver.clicked.contains("保存"))
|
||||
if (sample <= 4) observationDriver.elapsed += 10_000
|
||||
snapshot.changeAddressEditor {
|
||||
it.copy(
|
||||
text = if (sample <= 4) "合成提示占位标记" else "合成测试区域示例路段1号-old",
|
||||
hintText = "合成提示占位标记",
|
||||
showingHintText = sample <= 4,
|
||||
)
|
||||
}
|
||||
})
|
||||
var observingOrder = false
|
||||
val driver = object : PurchaseUiDriver by addressDriver {
|
||||
override fun capture(): UiSnapshot =
|
||||
if (observingOrder) observationDriver.capture() else addressDriver.capture()
|
||||
override fun backPurchase(): Boolean =
|
||||
if (observingOrder) observationDriver.backPurchase() else addressDriver.backPurchase()
|
||||
override fun clickFresh(target: SnapshotNode): FreshActionResult =
|
||||
if (observingOrder) observationDriver.clickFresh(target) else addressDriver.clickFresh(target)
|
||||
override fun swipePurchase(direction: SwipeDirection, durationMs: Long): Boolean =
|
||||
if (observingOrder) observationDriver.swipePurchase(direction, durationMs)
|
||||
else addressDriver.swipePurchase(direction, durationMs)
|
||||
override fun bringPddToForeground(): Boolean =
|
||||
if (observingOrder) observationDriver.bringPddToForeground() else addressDriver.bringPddToForeground()
|
||||
}
|
||||
val automation = PurchaseLiveAutomation(
|
||||
driver,
|
||||
pause = { observationDriver.elapsed += it },
|
||||
monotonicClockMs = { observationDriver.elapsed },
|
||||
)
|
||||
|
||||
val address = automation.updateShippingAddress("_cg366")
|
||||
assertEquals(5, addressDriver.initialEditCaptures)
|
||||
assertEquals("合成测试区域示例路段1号-old", addressDriver.lastInputTarget?.text)
|
||||
assertTrue(address.expectedAddress.endsWith("_cg366"))
|
||||
assertFalse(address.expectedAddress.contains("合成提示占位标记"))
|
||||
assertAddressEditorActions(addressDriver, saved = true)
|
||||
assertTrue(observationDriver.elapsed > 40_000)
|
||||
assertEquals("_cg366", automation.finalConfirmation(input().copy(addressSuffix = "_cg366"), address).addressSuffix)
|
||||
automation.submitOrderOnce()
|
||||
val clicksBeforeObservation = addressDriver.clicked.toList()
|
||||
observingOrder = true
|
||||
|
||||
val order = automation.readOrderResult()
|
||||
|
||||
assertEquals("PDD-DEMO-365", order?.orderNo)
|
||||
assertEquals("2026-10-08T03:21:43Z", order?.submittedAt)
|
||||
assertEquals(140, observationDriver.captures)
|
||||
val elapsedAfterBack = observationDriver.elapsed - observationDriver.backCompletedAt!!
|
||||
assertTrue(elapsedAfterBack in 28_000L until 30_000L)
|
||||
assertTrue(elapsedAfterBack + observationDriver.backDuration > 30_000)
|
||||
assertObservationActions(observationDriver, backs = 1)
|
||||
assertEquals(1, addressDriver.inputCount)
|
||||
assertEquals(1, addressDriver.clicked.count { it == "保存" })
|
||||
assertEquals(1, addressDriver.submitClicks)
|
||||
assertEquals(clicksBeforeObservation, addressDriver.clicked)
|
||||
assertEquals(0, addressDriver.genericSwipes + addressDriver.scopedSwipes)
|
||||
assertFalse(addressDriver.clicked.any { it.contains("支付") })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `payment back observes late complete order beyond original sample budget`() {
|
||||
val driver = OrderObservationDriver { sample ->
|
||||
when {
|
||||
sample == 1 -> observationPage("payment")
|
||||
sample < 100 -> observationPage("loading")
|
||||
else -> observationPage("complete")
|
||||
}
|
||||
}
|
||||
val order = observationAutomation(driver).readOrderResult()
|
||||
|
||||
assertEquals("PDD-DEMO-365", order?.orderNo)
|
||||
assertEquals(100, driver.captures)
|
||||
assertEquals(1, driver.backs)
|
||||
assertEquals(0, driver.swipes)
|
||||
assertEquals(0, driver.clicks)
|
||||
assertEquals(0, driver.foregroundRequests)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `deadline starts at late successful back and never resets on incomplete evidence or jitter`() {
|
||||
val driver = OrderObservationDriver { sample ->
|
||||
observationPage(when {
|
||||
sample < 58 -> "loading"
|
||||
sample == 58 -> "payment"
|
||||
sample > 60 && sample % 3 == 0 -> "incomplete"
|
||||
else -> "loading"
|
||||
})
|
||||
}.apply { backDuration = 4_000; captureDuration = 25 }
|
||||
val automation = observationAutomation(driver, pauseOverhead = 17)
|
||||
|
||||
assertEquals(null, automation.readOrderResult())
|
||||
assertTrue(driver.captures > 100)
|
||||
assertEquals(driver.backCompletedAt!! + 30_000 + 17, driver.elapsed)
|
||||
assertTrue(automation.lastOrderReadFailure!!.paymentPageObserved)
|
||||
assertObservationActions(driver, backs = 1)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `capture that crosses deadline cannot accept late complete evidence or trigger unpaid scroll`() {
|
||||
for (latePage in listOf("complete", "unpaid", "entry", "chooser", "wechat")) {
|
||||
lateinit var driver: OrderObservationDriver
|
||||
driver = OrderObservationDriver { sample ->
|
||||
if (sample == 1) {
|
||||
driver.captureDuration = 30_000
|
||||
observationPage("payment")
|
||||
} else observationPage(latePage)
|
||||
}
|
||||
val automation = observationAutomation(driver)
|
||||
|
||||
assertEquals(null, automation.readOrderResult())
|
||||
assertEquals(2, driver.captures)
|
||||
assertEquals(30_500L, driver.elapsed)
|
||||
assertObservationActions(driver, backs = 1)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `slow captures exhaust fixed observation deadline before original sixty samples`() {
|
||||
val driver = OrderObservationDriver { sample -> observationPage(if (sample == 1) "payment" else "loading") }
|
||||
.apply { captureDuration = 1_000 }
|
||||
val automation = observationAutomation(driver)
|
||||
|
||||
assertEquals(null, automation.readOrderResult())
|
||||
assertTrue(driver.captures < 60)
|
||||
assertTrue(driver.elapsed >= driver.backCompletedAt!! + 30_000)
|
||||
assertTrue(driver.elapsed < driver.backCompletedAt!! + 31_000)
|
||||
assertObservationActions(driver, backs = 1)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `incomplete context first appearing during extension stays passive until late time arrives`() {
|
||||
val driver = OrderObservationDriver { sample -> observationPage(when {
|
||||
sample == 1 -> "payment"
|
||||
sample < 80 -> "loading"
|
||||
sample < 100 -> "incomplete"
|
||||
else -> "complete"
|
||||
}) }
|
||||
|
||||
assertEquals("PDD-DEMO-365", observationAutomation(driver).readOrderResult()?.orderNo)
|
||||
assertEquals(100, driver.captures)
|
||||
assertObservationActions(driver, backs = 1)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `late detail entry chooser and WeChat cannot add navigation during extension`() {
|
||||
for (latePage in listOf("entry", "chooser", "wechat")) {
|
||||
val driver = OrderObservationDriver { sample -> observationPage(when {
|
||||
sample == 1 -> "payment"
|
||||
sample <= 60 -> "loading"
|
||||
else -> latePage
|
||||
}) }
|
||||
val automation = observationAutomation(driver)
|
||||
assertEquals(null, automation.readOrderResult())
|
||||
assertEquals("PURCHASE_ORDER_CONTEXT_NOT_FOUND", automation.lastOrderReadFailure?.code)
|
||||
assertTrue(driver.captures > 60)
|
||||
assertObservationActions(driver, backs = 1)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `previous chooser and WeChat recovery retain repeat failures during extension`() {
|
||||
for (kind in listOf("chooser", "wechat")) {
|
||||
val driver = OrderObservationDriver { sample -> observationPage(when {
|
||||
sample == 1 || sample > 60 -> kind
|
||||
sample == 2 -> "payment"
|
||||
else -> "loading"
|
||||
}) }
|
||||
val automation = observationAutomation(driver)
|
||||
assertEquals(null, automation.readOrderResult())
|
||||
assertEquals(
|
||||
if (kind == "chooser") "PURCHASE_ORDER_CHOOSER_BACK_FAILED" else "PURCHASE_ORDER_WECHAT_RESTORE_TIMEOUT",
|
||||
automation.lastOrderReadFailure?.code,
|
||||
)
|
||||
assertEquals(61, driver.captures)
|
||||
assertEquals(if (kind == "chooser") 2 else 1, driver.backs)
|
||||
assertEquals(if (kind == "wechat") 1 else 0, driver.foregroundRequests)
|
||||
assertEquals(0, driver.swipes)
|
||||
assertEquals(0, driver.clicks)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `late unpaid context retains its independent thirty samples and four swipes`() {
|
||||
val driver = OrderObservationDriver { sample -> observationPage(when {
|
||||
sample == 1 -> "payment"
|
||||
sample < 140 -> "loading"
|
||||
else -> "unpaid"
|
||||
}) }
|
||||
val automation = observationAutomation(driver)
|
||||
|
||||
assertEquals(null, automation.readOrderResult())
|
||||
assertEquals(169, driver.captures)
|
||||
assertTrue(driver.elapsed > 30_000)
|
||||
assertObservationActions(driver, backs = 1, swipes = 4)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unpaid markers alone never activate payment back extension`() {
|
||||
val driver = OrderObservationDriver { observationPage("unpaid") }
|
||||
val automation = observationAutomation(driver)
|
||||
|
||||
assertEquals(null, automation.readOrderResult())
|
||||
assertEquals(30, driver.captures)
|
||||
assertTrue(automation.lastOrderReadFailure!!.paymentPageObserved)
|
||||
assertObservationActions(driver, swipes = 4)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `missing payment back retains original sixty samples and existing context scroll budget`() {
|
||||
for (kind in listOf("loading", "incomplete")) {
|
||||
val driver = OrderObservationDriver { observationPage(kind) }
|
||||
assertEquals(null, observationAutomation(driver).readOrderResult())
|
||||
assertEquals(60, driver.captures)
|
||||
assertObservationActions(driver, swipes = if (kind == "incomplete") 3 else 0)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `order context within original budget retains original sampling and gestures after back`() {
|
||||
val driver = OrderObservationDriver { sample -> observationPage(if (sample == 1) "payment" else "incomplete") }
|
||||
assertEquals(null, observationAutomation(driver).readOrderResult())
|
||||
assertEquals(60, driver.captures)
|
||||
assertObservationActions(driver, backs = 1, swipes = 3)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `failed payment back stops immediately without activating observation`() {
|
||||
val driver = OrderObservationDriver { observationPage("payment") }.apply { backSucceeds = false }
|
||||
val automation = observationAutomation(driver)
|
||||
assertEquals(null, automation.readOrderResult())
|
||||
assertEquals("PURCHASE_ORDER_PAYMENT_BACK_FAILED", automation.lastOrderReadFailure?.code)
|
||||
assertEquals(1, driver.captures)
|
||||
assertEquals(0L, driver.elapsed)
|
||||
assertObservationActions(driver, backs = 1)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `extension preserves repeated payment empty and unexpected app early failures`() {
|
||||
for ((kind, code, expectedCaptures) in listOf(
|
||||
Triple("payment", "PURCHASE_ORDER_PAYMENT_REPEATED", 85),
|
||||
Triple("empty", "PURCHASE_ORDER_EMPTY_TIMEOUT", 75),
|
||||
Triple("unexpected", "PURCHASE_ORDER_UNEXPECTED_APP", 61),
|
||||
)) {
|
||||
val driver = OrderObservationDriver { sample -> observationPage(when {
|
||||
sample == 1 -> "payment"
|
||||
sample <= 60 -> "loading"
|
||||
else -> kind
|
||||
}) }
|
||||
val automation = observationAutomation(driver)
|
||||
assertEquals(null, automation.readOrderResult())
|
||||
assertEquals(code, automation.lastOrderReadFailure?.code)
|
||||
assertEquals(expectedCaptures, driver.captures)
|
||||
assertObservationActions(driver, backs = 1)
|
||||
}
|
||||
}
|
||||
|
||||
private fun observationAutomation(driver: OrderObservationDriver, pauseOverhead: Long = 0) = PurchaseLiveAutomation(
|
||||
driver,
|
||||
pause = { driver.elapsed += it + pauseOverhead },
|
||||
monotonicClockMs = { driver.elapsed },
|
||||
)
|
||||
|
||||
private fun assertObservationActions(driver: OrderObservationDriver, backs: Int = 0, swipes: Int = 0) {
|
||||
assertEquals(backs, driver.backs)
|
||||
assertEquals(swipes, driver.swipes)
|
||||
assertEquals(0, driver.clicks)
|
||||
assertEquals(0, driver.foregroundRequests)
|
||||
}
|
||||
|
||||
private class OrderObservationDriver(
|
||||
val frame: (Int) -> UiSnapshot,
|
||||
) : PurchaseUiDriver by LiveDriver() {
|
||||
var elapsed = 0L
|
||||
var captures = 0
|
||||
var backs = 0
|
||||
var swipes = 0
|
||||
var clicks = 0
|
||||
var foregroundRequests = 0
|
||||
var captureDuration = 0L
|
||||
var backDuration = 0L
|
||||
var backCompletedAt: Long? = null
|
||||
var backSucceeds = true
|
||||
override fun capture(): UiSnapshot {
|
||||
elapsed += captureDuration
|
||||
return frame(++captures)
|
||||
}
|
||||
override fun backPurchase(): Boolean {
|
||||
backs++
|
||||
elapsed += backDuration
|
||||
if (backSucceeds) backCompletedAt = elapsed
|
||||
return backSucceeds
|
||||
}
|
||||
override fun swipePurchase(direction: SwipeDirection, durationMs: Long): Boolean {
|
||||
swipes++
|
||||
return true
|
||||
}
|
||||
override fun clickFresh(target: SnapshotNode): FreshActionResult {
|
||||
clicks++
|
||||
return FreshActionResult.SUCCESS
|
||||
}
|
||||
override fun bringPddToForeground(): Boolean {
|
||||
foregroundRequests++
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
private fun observationPage(kind: String): UiSnapshot {
|
||||
val labels = when (kind) {
|
||||
"payment" -> listOf("立即支付")
|
||||
"complete" -> listOf("订单号:PDD-DEMO-365", "下单时间:2026-10-08 11:21:43", "立即支付")
|
||||
"incomplete" -> listOf("订单号:PDD-DEMO-365", "立即支付")
|
||||
"unpaid" -> listOf("待付款")
|
||||
"entry" -> listOf("查看订单")
|
||||
"chooser" -> listOf("选择要使用的应用", "微信")
|
||||
else -> emptyList()
|
||||
}
|
||||
return UiSnapshot(
|
||||
when (kind) {
|
||||
"empty" -> null
|
||||
"wechat" -> "com.tencent.mm"
|
||||
"unexpected" -> "com.example.unexpected"
|
||||
"chooser" -> "android"
|
||||
else -> PDD
|
||||
},
|
||||
when (kind) {
|
||||
"payment" -> "com.xunmeng.pinduoduo.app_pay.core.PayActivity"
|
||||
"chooser" -> "com.android.internal.app.ChooserActivity"
|
||||
else -> ACTIVITY
|
||||
},
|
||||
labels.map { text ->
|
||||
SnapshotNode(text, null, text, null, null, "android.widget.TextView",
|
||||
NodeBounds(0, 100, 500, 180), true, false, false, false, true, true)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `two second payment transition and two needed scrolls yield order and payable amount without clicks`() {
|
||||
val driver = ReadOnlyOrderDriver(paymentMs = 2500)
|
||||
@@ -219,6 +567,338 @@ class PurchaseLiveAutomationTest {
|
||||
assertFalse(driver.clicked.any { it.contains("支付") })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `address editor waits for an input arriving after its title`() {
|
||||
assertDelayedAddressEditor { snapshot, sample ->
|
||||
if (sample <= 4) snapshot.copy(nodes = snapshot.nodes.filterNot { it.path == "editor-address" }) else snapshot
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `address editor waits for a populated value`() {
|
||||
assertDelayedAddressEditor { snapshot, sample ->
|
||||
snapshot.changeAddressEditor { if (sample <= 4) it.copy(text = " ") else it }
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `address editor description cannot substitute for empty text`() {
|
||||
for (text in listOf(null, "", " ")) {
|
||||
assertAddressEditorTimeout("editor_empty", 1, 0) { snapshot, _ ->
|
||||
snapshot.changeAddressEditor { it.copy(text = text, contentDescription = "合成说明占位标记") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `address editor waits until hint is replaced by real text`() {
|
||||
assertDelayedAddressEditor { snapshot, sample ->
|
||||
snapshot.changeAddressEditor {
|
||||
it.copy(
|
||||
text = if (sample <= 4) "合成提示占位标记" else it.text,
|
||||
hintText = "合成提示占位标记",
|
||||
showingHintText = sample <= 4,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `address editor persistent hint text fails after the full budget`() {
|
||||
assertAddressEditorTimeout("editor_hint", 1, 0) { snapshot, _ ->
|
||||
snapshot.changeAddressEditor {
|
||||
it.copy(text = "合成提示占位标记", hintText = "合成提示占位标记", showingHintText = true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `address editor rejects text equal to trimmed hint despite false or absent showing flag`() {
|
||||
for (showing in listOf(false, null)) {
|
||||
assertAddressEditorTimeout("editor_hint", 1, 0) { snapshot, _ ->
|
||||
snapshot.changeAddressEditor {
|
||||
it.copy(text = " 合成提示占位标记 ", hintText = " 合成提示占位标记 ", showingHintText = showing)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `address editor showing hint flag blocks nonmatching or missing hint text`() {
|
||||
for (hint in listOf(null, "", "合成提示占位标记")) {
|
||||
assertAddressEditorTimeout("editor_hint", 1, 0) { snapshot, _ ->
|
||||
snapshot.changeAddressEditor { it.copy(hintText = hint, showingHintText = true) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `address editor empty text remains empty even when hint and description are present`() {
|
||||
assertAddressEditorTimeout("editor_empty", 1, 0) { snapshot, _ ->
|
||||
snapshot.changeAddressEditor {
|
||||
it.copy(text = " ", contentDescription = "合成说明占位标记", hintText = "合成提示占位标记", showingHintText = true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `address editor missing hint metadata remains compatible and uses actual text`() {
|
||||
val driver = LiveDriver(initialEditFrame = { snapshot, _ ->
|
||||
snapshot.changeAddressEditor {
|
||||
assertEquals(null, it.hintText)
|
||||
assertEquals(null, it.showingHintText)
|
||||
it.copy(text = " 合成真实地址-old ", contentDescription = "合成说明占位标记")
|
||||
}
|
||||
})
|
||||
|
||||
val proof = PurchaseLiveAutomation(driver, pause = {}).updateShippingAddress("_cg366")
|
||||
|
||||
assertEquals("合成真实地址_cg366", proof.expectedAddress)
|
||||
assertEquals(1, driver.initialEditCaptures)
|
||||
assertTrue(driver.lastInputTarget === driver.lastInitialEditSnapshot?.nodes?.single { it.path == "editor-address" })
|
||||
assertAddressEditorActions(driver, saved = true)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `address editor accepts actual text with absent false or blank hint metadata`() {
|
||||
for (showing in listOf(null, false)) {
|
||||
for (hint in listOf(null, "", " ", "合成提示占位标记")) {
|
||||
val driver = LiveDriver(initialEditFrame = { snapshot, _ ->
|
||||
snapshot.changeAddressEditor { it.copy(hintText = hint, showingHintText = showing) }
|
||||
})
|
||||
|
||||
val proof = PurchaseLiveAutomation(driver, pause = {}).updateShippingAddress("_cg366")
|
||||
|
||||
assertTrue(proof.expectedAddress.endsWith("_cg366"))
|
||||
assertEquals(1, driver.initialEditCaptures)
|
||||
assertAddressEditorActions(driver, saved = true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `address editor waits for an enabled input`() {
|
||||
assertDelayedAddressEditor { snapshot, sample ->
|
||||
snapshot.changeAddressEditor { if (sample <= 4) it.copy(enabled = false) else it }
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `address editor tolerates more than three ambiguous frames before unique readiness`() {
|
||||
assertDelayedAddressEditor { snapshot, sample ->
|
||||
if (sample <= 4) snapshot.withSecondAddressEditor() else snapshot
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `address editor uses the value and node from the successful readiness frame`() {
|
||||
val driver = LiveDriver(initialEditFrame = { snapshot, sample ->
|
||||
snapshot.changeAddressEditor { it.copy(text = if (sample == 1) "" else "合成地址第${sample}帧-old") }
|
||||
})
|
||||
|
||||
val proof = PurchaseLiveAutomation(driver, pause = {}).updateShippingAddress("_cg366")
|
||||
|
||||
assertEquals("合成地址第2帧_cg366", proof.expectedAddress)
|
||||
assertEquals(2, driver.initialEditCaptures)
|
||||
assertTrue(driver.lastInputTarget === driver.lastInitialEditSnapshot?.nodes?.single { it.path == "editor-address" })
|
||||
assertAddressEditorActions(driver, saved = true)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `address editor structural ambiguity waits full budget even with only one ready input`() {
|
||||
for (second in listOf<(SnapshotNode) -> SnapshotNode>(
|
||||
{ it.copy(text = "") }, { it.copy(enabled = false) },
|
||||
)) {
|
||||
assertAddressEditorTimeout("editor_ambiguous", structural = 2, ready = 1) { snapshot, _ ->
|
||||
snapshot.withSecondAddressEditor(second)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `address editor final zero inputs reports scalar missing reason`() {
|
||||
assertAddressEditorTimeout("editor_missing", 0, 0) { snapshot, _ ->
|
||||
snapshot.copy(nodes = snapshot.nodes.filterNot { it.path == "editor-address" })
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `address editor final empty input reports scalar empty reason`() {
|
||||
assertAddressEditorTimeout("editor_empty", 1, 0) { snapshot, _ ->
|
||||
snapshot.changeAddressEditor { it.copy(text = " ") }
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `address editor final disabled input reports scalar disabled reason`() {
|
||||
assertAddressEditorTimeout("editor_disabled", 1, 0) { snapshot, _ ->
|
||||
snapshot.changeAddressEditor { it.copy(enabled = false) }
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `address editor final multiple inputs reports scalar ambiguity reason`() {
|
||||
assertAddressEditorTimeout("editor_ambiguous", 2, 2) { snapshot, _ -> snapshot.withSecondAddressEditor() }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `address editor never seeing a title keeps edit timeout and one shared budget`() {
|
||||
assertAddressEditorTimeout("title_missing", 0, 0, titleSeen = false) { snapshot, _ ->
|
||||
snapshot.copy(nodes = snapshot.nodes.filterNot { it.path == "detail" })
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `address editor remembers a title even when the final snapshot is empty`() {
|
||||
assertAddressEditorTimeout("editor_missing", 0, 0) { snapshot, sample ->
|
||||
if (sample == 1) snapshot.copy(nodes = snapshot.nodes.filterNot { it.path == "editor-address" })
|
||||
else snapshot.copy(nodes = emptyList())
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `address editor late title does not start a second wait budget`() {
|
||||
assertAddressEditorTimeout("editor_empty", 1, 0) { snapshot, sample ->
|
||||
val emptyEditor = snapshot.changeAddressEditor { it.copy(text = "") }
|
||||
if (sample < 50) emptyEditor.copy(nodes = emptyEditor.nodes.filterNot { it.path == "detail" }) else emptyEditor
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `address editor can become ready on the final sample of its shared budget`() {
|
||||
val driver = LiveDriver(initialEditFrame = { snapshot, sample ->
|
||||
if (sample < 50) snapshot.copy(nodes = emptyList()) else snapshot
|
||||
})
|
||||
val editPauses = mutableListOf<Long>()
|
||||
|
||||
PurchaseLiveAutomation(driver, pause = {
|
||||
if (driver.currentPage == "edit" && driver.inputCount == 0) editPauses += it
|
||||
}).updateShippingAddress("_cg366")
|
||||
|
||||
assertEquals(50, driver.initialEditCaptures)
|
||||
assertEquals(listOf(500L) + List(49) { 200L }, editPauses)
|
||||
assertAddressEditorActions(driver, saved = true)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `address editor excludes recipient phone invisible and leftward inputs and deduplicates paths`() {
|
||||
val driver = LiveDriver(initialEditFrame = { snapshot, _ ->
|
||||
val editor = snapshot.nodes.single { it.path == "editor-address" }
|
||||
snapshot.copy(nodes = snapshot.nodes + listOf(
|
||||
editor,
|
||||
editor.copy(path = "hidden-input", visible = false),
|
||||
editor.copy(path = "left-input", bounds = NodeBounds(0, 390, 10, 480)),
|
||||
))
|
||||
})
|
||||
|
||||
PurchaseLiveAutomation(driver, pause = {}).updateShippingAddress("_cg366")
|
||||
|
||||
assertEquals(1, driver.initialEditCaptures)
|
||||
assertEquals("editor-address", driver.lastInputTargetPath)
|
||||
assertAddressEditorActions(driver, saved = true)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `address editor small screen row overlap remains ambiguous`() {
|
||||
assertAddressEditorTimeout("editor_ambiguous", 2, 2) { snapshot, _ ->
|
||||
snapshot.copy(nodes = snapshot.nodes.map {
|
||||
if (it.path == "editor-phone") it.copy(bounds = NodeBounds(180, 360, 900, 410)) else it
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `address editor fresh input failures never retry or save`() {
|
||||
for (result in FreshActionResult.values().filter { it != FreshActionResult.SUCCESS }) {
|
||||
val driver = LiveDriver(inputResult = result)
|
||||
|
||||
val error = runCatching { PurchaseLiveAutomation(driver, pause = {}).updateShippingAddress("_cg366") }
|
||||
.exceptionOrNull() as PurchaseLiveException
|
||||
|
||||
assertEquals("PURCHASE_ADDRESS_UPDATE_FAILED", error.code)
|
||||
assertEquals(1, driver.inputCount)
|
||||
assertEquals(1, driver.initialEditCaptures)
|
||||
assertEquals(listOf("138****5678", "修改"), driver.clicked)
|
||||
assertEquals(0, driver.backCount)
|
||||
assertEquals(0, driver.scopedSwipes + driver.genericSwipes + driver.submitClicks)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `address editor page safety problem interrupts readiness without input`() {
|
||||
val driver = LiveDriver(initialEditFrame = { snapshot, sample ->
|
||||
if (sample == 1) snapshot.changeAddressEditor { it.copy(text = "") }
|
||||
else snapshot.copy(nodes = snapshot.nodes.map { if (it.path == "title") it.copy(text = "立即支付") else it })
|
||||
})
|
||||
|
||||
val error = runCatching { PurchaseLiveAutomation(driver, pause = {}).updateShippingAddress("_cg366") }
|
||||
.exceptionOrNull() as PurchaseLiveException
|
||||
|
||||
assertEquals("PURCHASE_PAYMENT_FORBIDDEN", error.code)
|
||||
assertEquals(2, driver.initialEditCaptures)
|
||||
assertAddressEditorActions(driver, saved = false)
|
||||
}
|
||||
|
||||
private fun assertDelayedAddressEditor(frame: (UiSnapshot, Int) -> UiSnapshot) {
|
||||
val driver = LiveDriver(initialEditFrame = frame)
|
||||
val editPauses = mutableListOf<Long>()
|
||||
val automation = PurchaseLiveAutomation(driver, pause = {
|
||||
if (driver.currentPage == "edit" && driver.inputCount == 0) editPauses += it
|
||||
})
|
||||
|
||||
val proof = automation.updateShippingAddress("_cg366")
|
||||
|
||||
assertTrue(proof.expectedAddress.endsWith("_cg366"))
|
||||
assertEquals(5, driver.initialEditCaptures)
|
||||
assertEquals(listOf(500L, 200L, 200L, 200L, 200L), editPauses)
|
||||
assertEquals("editor-address", driver.lastInputTargetPath)
|
||||
assertAddressEditorActions(driver, saved = true)
|
||||
}
|
||||
|
||||
private fun assertAddressEditorTimeout(
|
||||
reason: String,
|
||||
structural: Int,
|
||||
ready: Int,
|
||||
titleSeen: Boolean = true,
|
||||
frame: (UiSnapshot, Int) -> UiSnapshot,
|
||||
) {
|
||||
val driver = LiveDriver(initialEditFrame = frame)
|
||||
val diagnostics = mutableListOf<String>()
|
||||
val editPauses = mutableListOf<Long>()
|
||||
val automation = PurchaseLiveAutomation(driver, pause = {
|
||||
if (driver.currentPage == "edit" && driver.inputCount == 0) editPauses += it
|
||||
}, panelDiagnostic = diagnostics::add)
|
||||
|
||||
val failure = runCatching { automation.updateShippingAddress("_cg366") }.exceptionOrNull()
|
||||
assertTrue("Readiness must fail without changing the address", failure is PurchaseLiveException)
|
||||
val error = failure as PurchaseLiveException
|
||||
|
||||
assertEquals(if (titleSeen) "PURCHASE_ADDRESS_UPDATE_FAILED" else "PURCHASE_ADDRESS_EDIT_TIMEOUT", error.code)
|
||||
val diagnostic = "stage=address_editor_ready;reason=$reason;titleSeen=$titleSeen;structural=$structural;ready=$ready"
|
||||
assertTrue(error.message.orEmpty(), error.message.orEmpty().endsWith("[$diagnostic]"))
|
||||
assertEquals(50, driver.initialEditCaptures)
|
||||
assertEquals(listOf(500L) + List(50) { 200L }, editPauses)
|
||||
val emitted = error.message.orEmpty() + diagnostics.joinToString()
|
||||
listOf("广东", "骏景", "测试收货人", "13800000000", "editor-address", "_cg366", "合成提示占位标记", "合成说明占位标记").forEach {
|
||||
assertFalse("Sensitive fixture value in readiness diagnostics", emitted.contains(it))
|
||||
}
|
||||
assertAddressEditorActions(driver, saved = false)
|
||||
}
|
||||
|
||||
private fun assertAddressEditorActions(driver: LiveDriver, saved: Boolean) {
|
||||
assertEquals(if (saved) 1 else 0, driver.inputCount)
|
||||
assertEquals(listOf("138****5678", "修改") + if (saved) listOf("保存") else emptyList(), driver.clicked)
|
||||
assertEquals(if (saved) 1 else 0, driver.backCount)
|
||||
assertEquals(1, driver.addressPathClicks)
|
||||
assertEquals(0, driver.addressTaps + driver.genericSwipes + driver.scopedSwipes + driver.submitClicks)
|
||||
}
|
||||
|
||||
private fun UiSnapshot.changeAddressEditor(change: (SnapshotNode) -> SnapshotNode): UiSnapshot =
|
||||
copy(nodes = nodes.map { if (it.path == "editor-address") change(it) else it })
|
||||
|
||||
private fun UiSnapshot.withSecondAddressEditor(change: (SnapshotNode) -> SnapshotNode = { it }): UiSnapshot =
|
||||
copy(nodes = nodes + change(nodes.single { it.path == "editor-address" }.copy(path = "editor-address-2")))
|
||||
|
||||
@Test
|
||||
fun `split confirmation address keeps the unique saved task suffix proof`() {
|
||||
val driver = LiveDriver(splitConfirmationAddress = true)
|
||||
@@ -788,6 +1468,8 @@ class PurchaseLiveAutomationTest {
|
||||
private val orderDetailEntryAfterSubmit: Boolean = false,
|
||||
private val orderDetailEvidenceBelowFold: Boolean = false,
|
||||
private val duplicateOrderDetailEntry: Boolean = false,
|
||||
private val initialEditFrame: (UiSnapshot, Int) -> UiSnapshot = { snapshot, _ -> snapshot },
|
||||
private val inputResult: FreshActionResult = FreshActionResult.SUCCESS,
|
||||
postSubmitCaptureSequence: List<String> = emptyList(),
|
||||
) : PurchaseUiDriver {
|
||||
private var page = "confirmation"
|
||||
@@ -802,6 +1484,10 @@ class PurchaseLiveAutomationTest {
|
||||
var addressTaps = 0
|
||||
var addressPathClicks = 0
|
||||
var lastInputTargetPath: String? = null
|
||||
var lastInputTarget: SnapshotNode? = null
|
||||
var lastInitialEditSnapshot: UiSnapshot? = null
|
||||
var initialEditCaptures = 0
|
||||
var inputCount = 0
|
||||
var backCount = 0
|
||||
var postSubmitBackCount = 0
|
||||
var pddRestoreCount = 0
|
||||
@@ -814,7 +1500,12 @@ class PurchaseLiveAutomationTest {
|
||||
postSubmitCaptureCount++
|
||||
if (pendingPostSubmitPages.isNotEmpty()) page = pendingPostSubmitPages.removeAt(0)
|
||||
}
|
||||
return currentSnapshot()
|
||||
val snapshot = currentSnapshot()
|
||||
if (page == "edit" && inputCount == 0) {
|
||||
initialEditCaptures++
|
||||
return initialEditFrame(snapshot, initialEditCaptures).also { lastInitialEditSnapshot = it }
|
||||
}
|
||||
return snapshot
|
||||
}
|
||||
|
||||
private fun currentSnapshot(): UiSnapshot = when (page) {
|
||||
@@ -986,7 +1677,10 @@ class PurchaseLiveAutomationTest {
|
||||
}
|
||||
|
||||
override fun inputFresh(target: SnapshotNode, value: String): FreshActionResult {
|
||||
inputCount++
|
||||
lastInputTarget = target
|
||||
lastInputTargetPath = target.path
|
||||
if (inputResult != FreshActionResult.SUCCESS) return inputResult
|
||||
address = value
|
||||
return FreshActionResult.SUCCESS
|
||||
}
|
||||
|
||||
@@ -0,0 +1,255 @@
|
||||
package cn.ilapage.goauto.agent
|
||||
|
||||
import cn.ilapage.goauto.agent.automation.*
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Test
|
||||
|
||||
/** Synthetic cards only: no device tree, address or order data. */
|
||||
class TruncatedSpecCardTest {
|
||||
private val card = "r/sheet/body/list/color/o0"
|
||||
private val other = "r/sheet/body/list/color/o1"
|
||||
private val full = "黑色示例长裤【有抽绳】 有口袋不起球"
|
||||
private val short = "黑色示例长裤【有抽绳】 有口袋"
|
||||
private val size = "2XL建议130-150斤"
|
||||
|
||||
private fun sheet(
|
||||
outer: String = "$full ¥18.8",
|
||||
caption: String = "$short ¥18.8",
|
||||
image: String = outer,
|
||||
selected: Boolean = true,
|
||||
otherSelected: Boolean = false,
|
||||
): UiSnapshot = SpecPanelFixtures.taskOptionDedupSheet().let { snapshot ->
|
||||
snapshot.copy(nodes = snapshot.nodes.map { node ->
|
||||
when (node.path) {
|
||||
card, "$card/img" -> node.copy(text = null, contentDescription = if (node.path == card) outer else image, selected = selected)
|
||||
"$card/l/t" -> node.copy(text = caption, selected = selected)
|
||||
"r/sheet/body/selected" -> node.copy(text = "请选择: 尺码")
|
||||
else -> when {
|
||||
node.path.startsWith("$card/") -> node.copy(selected = selected)
|
||||
node.path == other || node.path.startsWith("$other/") -> node.copy(selected = otherSelected)
|
||||
else -> node
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private fun parse(snapshot: UiSnapshot) = PddScreenParser.parse(
|
||||
snapshot, PurchaseRehearsalExecutor.DEFAULT_COLLECTOR, "123456789", null,
|
||||
)
|
||||
|
||||
private fun colors(snapshot: UiSnapshot) = parse(snapshot).dimensions.single { it.key == "color" }.values
|
||||
|
||||
@Test fun `full image and truncated clickable caption are one selected color`() {
|
||||
val result = colors(sheet())
|
||||
assertEquals(listOf(full, "白条纹"), result.map { it.text })
|
||||
assertTrue(result.first().node.selected)
|
||||
assertFalse(result.last().node.selected)
|
||||
assertEquals(5, parse(sheet()).dimensions.single { it.key == "size" }.values.size)
|
||||
}
|
||||
|
||||
@Test fun `checked display state survives grouping`() {
|
||||
val snapshot = sheet(selected = false).let { it.copy(nodes = it.nodes.map { n ->
|
||||
if (n.path == "$card/l/t") n.copy(checked = true) else n
|
||||
}) }
|
||||
assertTrue(colors(snapshot).single { it.text == full }.node.checked)
|
||||
assertFalse(colors(snapshot).any { it.text == short })
|
||||
}
|
||||
|
||||
@Test fun `whitespace and equivalent terminal prices do not change full identity`() {
|
||||
assertEquals(listOf(full, "白条纹"), colors(sheet(caption = "黑色示例长裤【有抽绳】\n有口袋 ¥18.80")).map { it.text })
|
||||
}
|
||||
|
||||
@Test fun `one or both absent prices still require card evidence`() {
|
||||
for ((outer, caption) in listOf(full to short, "$full ¥18.8" to short, full to "$short ¥18.8")) {
|
||||
assertEquals(listOf(full, "白条纹"), colors(sheet(outer, caption)).map { it.text })
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun `different prices refuse truncated merge`() {
|
||||
assertTrue(colors(sheet(caption = "$short ¥28.8")).any { it.text == short })
|
||||
}
|
||||
|
||||
@Test fun `non prefix caption remains independent`() {
|
||||
assertTrue(colors(sheet(caption = "灰色示例 ¥18.8")).any { it.text == "灰色示例" })
|
||||
}
|
||||
|
||||
@Test fun `empty price only and missing complete image are not prefix evidence`() {
|
||||
for (caption in listOf("", "¥18.8")) {
|
||||
val snapshot = sheet(caption = caption, selected = false).let { it.copy(nodes = it.nodes.map { n ->
|
||||
if (n.path == "$card/l/t") n.copy(checked = true) else n
|
||||
}) }
|
||||
// An empty label may be omitted by the parser; a pure-price value
|
||||
// must not be promoted into the full card via empty-prefix matching.
|
||||
if (caption.isNotEmpty()) assertTrue(colors(snapshot).any { it.text.isEmpty() && it.node.checked })
|
||||
}
|
||||
assertTrue(colors(sheet(image = "$short ¥18.8")).any { it.text == short })
|
||||
assertTrue(colors(sheet(outer = "¥18.8")).any { it.text == short })
|
||||
}
|
||||
|
||||
@Test fun `truncated leaf outside card geometry is not merged`() {
|
||||
val snapshot = sheet().let { it.copy(nodes = it.nodes.map { n ->
|
||||
if (n.path == "$card/l/t") n.copy(bounds = NodeBounds(710, 1340, 1040, 1447)) else n
|
||||
}) }
|
||||
assertTrue(colors(snapshot).any { it.text == short })
|
||||
}
|
||||
|
||||
@Test fun `clickable caption wrapper is not silently assumed to be a display leaf`() {
|
||||
val snapshot = sheet().let { it.copy(nodes = it.nodes.map { n ->
|
||||
if (n.path == "$card/l") n.copy(text = "$short ¥18.8", clickable = true) else n
|
||||
}) }
|
||||
assertTrue(colors(snapshot).any { it.text == short })
|
||||
}
|
||||
|
||||
@Test fun `adjacent prefix named cards stay distinct even at same price`() {
|
||||
val snapshot = sheet(outer = "黑色加绒 ¥18.8", caption = "黑色加 ¥18.8").let { it.copy(nodes = it.nodes.map { n ->
|
||||
if (n.path == other || n.path == "$other/img" || n.path == "$other/l/t")
|
||||
n.copy(text = "黑色 ¥18.8", contentDescription = null) else n
|
||||
}) }
|
||||
assertEquals(listOf("黑色加绒", "黑色"), colors(snapshot).map { it.text })
|
||||
}
|
||||
|
||||
@Test fun `labelled clickable common parent cannot swallow independent cards`() {
|
||||
val snapshot = sheet().let { it.copy(nodes = it.nodes.map { n ->
|
||||
if (n.path == "r/sheet/body/list/color") n.copy(text = "$full ¥18.8", clickable = true,
|
||||
bounds = NodeBounds(0, 1044, 1080, 1447)) else n
|
||||
}) }
|
||||
assertTrue(colors(snapshot).any { it.text == "白条纹" })
|
||||
}
|
||||
|
||||
@Test fun `nested same price prefix card with own image and caption remains independent`() {
|
||||
val base = sheet(outer = "黑色加绒 ¥18.8", caption = "黑色加 ¥18.8")
|
||||
val parent = base.nodes.single { it.path == card }
|
||||
val nested = listOf(
|
||||
parent.copy(path = "$card/nested", parentPath = card, text = "黑色 ¥18.8", contentDescription = null),
|
||||
parent.copy(path = "$card/nested/img", parentPath = "$card/nested", text = "黑色 ¥18.8", contentDescription = null, className = "android.widget.ImageView"),
|
||||
parent.copy(path = "$card/nested/text", parentPath = "$card/nested", text = "黑色 ¥18.8", contentDescription = null, className = "android.widget.TextView"),
|
||||
)
|
||||
assertTrue(colors(base.copy(nodes = base.nodes + nested)).any { it.text == "黑色" })
|
||||
}
|
||||
|
||||
@Test fun `short labelled common parent does not merge its longer independent cards`() {
|
||||
val parent = "r/sheet/body/list/color/cards"
|
||||
val base = sheet(outer = "黑色加绒", caption = "黑色加绒")
|
||||
val nodes = base.nodes.map { n ->
|
||||
val isOther = n.path == other || n.path == "$other/img" || n.path == "$other/l/t"
|
||||
val child = if (isOther) n.copy(text = "黑色", contentDescription = null) else n
|
||||
child.copy(
|
||||
path = child.path.replace(card, "$parent/o0").replace(other, "$parent/o1"),
|
||||
parentPath = child.parentPath?.replace(card, "$parent/o0")?.replace(other, "$parent/o1")
|
||||
?.let { if ((n.path == card || n.path == other)) parent else it },
|
||||
)
|
||||
}
|
||||
val container = base.nodes.single { it.path == card }.copy(
|
||||
path = parent, parentPath = parent.substringBeforeLast('/'), text = "黑色", contentDescription = null,
|
||||
bounds = NodeBounds(36, 1044, 698, 1447),
|
||||
)
|
||||
val values = colors(base.copy(nodes = nodes + container))
|
||||
assertEquals(setOf("黑色加绒", "黑色"), values.map { it.text }.toSet())
|
||||
assertTrue(values.single { it.text == "黑色加绒" }.node.selected)
|
||||
assertFalse(values.single { it.text == "黑色" }.node.selected)
|
||||
}
|
||||
|
||||
@Test fun `same named clickable caption wrapper keeps original badge merging`() {
|
||||
val snapshot = SpecPanelFixtures.taskOptionDedupSheet().let { it.copy(nodes = it.nodes.map { n ->
|
||||
if (n.path == "$card/l") n.copy(text = "兰条纹", clickable = true) else n
|
||||
}) }
|
||||
assertEquals(listOf("兰条纹", "白条纹"), colors(snapshot).map { it.text })
|
||||
}
|
||||
|
||||
@Test fun `numeric and weight size values and badge regression remain intact`() {
|
||||
val baseline = SpecPanelFixtures.taskOptionDedupSheet()
|
||||
assertEquals(listOf("兰条纹", "白条纹"), colors(baseline).map { it.text })
|
||||
val snapshot = sheet().let { it.copy(nodes = it.nodes.map { n ->
|
||||
if (n.path.endsWith("size/o0/t")) n.copy(text = "42 ¥18.8") else n
|
||||
}) }
|
||||
val sizes = parse(snapshot).dimensions.single { it.key == "size" }.values.map { it.text }
|
||||
assertTrue(sizes.containsAll(listOf("42", size, "3XL建议150-170斤")))
|
||||
}
|
||||
|
||||
@Test fun `collector used by collection and spec probe emits only full color values`() {
|
||||
var selectedColor = full
|
||||
val driver = object : PddCollectorDriver {
|
||||
override fun capture() = sheet(selected = selectedColor == full, otherSelected = selectedColor == "白条纹")
|
||||
override fun clickFresh(target: SnapshotNode): FreshActionResult {
|
||||
selectedColor = if (target.path.startsWith(card)) full else "白条纹"
|
||||
return FreshActionResult.SUCCESS
|
||||
}
|
||||
override fun swipeSpec(direction: SwipeDirection, anchor: SnapshotNode?) = false
|
||||
override fun pullDownGoodsPage() = false
|
||||
override fun back() = false
|
||||
}
|
||||
var clock = 0L
|
||||
val rule = CollectionRule(
|
||||
schemaVersion = 2, steps = emptyList(), ruleType = "pddProductDetail",
|
||||
pageEvidence = PageEvidence(SpecPanelFixtures.PDD, SpecPanelFixtures.ACTIVITY, NodeSelector(text = "颜色分类")),
|
||||
collector = PurchaseRehearsalExecutor.DEFAULT_COLLECTOR,
|
||||
)
|
||||
val result = PddProductDetailCollector(driver, { clock }, { clock += it }).collect("123456789", rule)
|
||||
assertTrue(result.message, result.successful)
|
||||
assertEquals(listOf(full, "白条纹"), requireNotNull(result.payload).dimensions.single { it.key == "color" }.values)
|
||||
}
|
||||
|
||||
private fun executor(driver: PurchaseUiDriver) = PurchaseRehearsalExecutor(driver, { true }, { null }, pause = {})
|
||||
|
||||
private class Driver(private val snapshot: UiSnapshot) : PurchaseUiDriver {
|
||||
var opened = false
|
||||
var sizeSelected = false
|
||||
val clicks = mutableListOf<String>()
|
||||
override fun capture() = if (opened) snapshot.copy(nodes = snapshot.nodes.map { n ->
|
||||
if (n.path.contains("/size/o")) n.copy(selected = sizeSelected && n.path.startsWith("r/sheet/body/list/size/o2")) else n
|
||||
}) else SpecPanelFixtures.productDetailPage()
|
||||
override fun clickFresh(target: SnapshotNode): FreshActionResult {
|
||||
clicks += target.label
|
||||
if (target.label.startsWith("请选择")) opened = true
|
||||
if (target.label == "2XL建议130-150斤") sizeSelected = 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
|
||||
}
|
||||
|
||||
private fun run(snapshot: UiSnapshot, target: String = full, driver: Driver = Driver(snapshot)) = executor(driver).execute(
|
||||
PurchaseExecutionInput(362, "rehearsal", "purchase", "https://mobile.yangkeduo.com/goods.html?goods_id=123456789",
|
||||
"123456789", target, size, quantity = 1, minUnitPriceCent = 1000, maxUnitPriceCent = 3000),
|
||||
PurchaseRuleParser.parse("""{"schemaVersion":1,"ruleType":"pddPurchase","requiredCapabilities":["purchase.rehearsal.v1"],
|
||||
"actions":[{"type":"openProduct"},{"type":"verifyProduct"},{"type":"openSpecPanel"},{"type":"selectSpec"},
|
||||
{"type":"setQuantity"},{"type":"verifyUnitPrice"},{"type":"verifyOrderSummary"}]}"""),
|
||||
PurchaseAgentCapabilities.supported,
|
||||
)
|
||||
|
||||
@Test fun `dedup lets existing confirmation complete color size and rehearsal`() {
|
||||
val snapshot = sheet()
|
||||
val driver = Driver(snapshot)
|
||||
val result = run(snapshot, driver = driver)
|
||||
assertEquals(result.message, "rehearsal_completed", result.resultType)
|
||||
assertEquals(1, driver.clicks.count { it == size })
|
||||
assertFalse(driver.clicks.any { it.contains("订单") || it.contains("支付") })
|
||||
}
|
||||
|
||||
@Test fun `other selected card still fails immediate confirmation`() {
|
||||
for (selected in listOf(false, true)) {
|
||||
val result = run(sheet(selected = selected, otherSelected = true))
|
||||
assertEquals("PURCHASE_SPEC_SELECTION_UNCONFIRMED", result.errorCode)
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun `old truncated mapped target is not silently upgraded`() {
|
||||
assertEquals("PURCHASE_SPEC_TARGET_NOT_VISIBLE", run(sheet(), short).errorCode)
|
||||
}
|
||||
|
||||
@Test fun `existing final confirmation target first ordering is documented not changed`() {
|
||||
val screen = parse(sheet(otherSelected = true))
|
||||
val executor = executor(Driver(sheet()))
|
||||
val immediate = PurchaseRehearsalExecutor::class.java.declaredMethods.single { it.name == "isExactSpecSelected" }
|
||||
immediate.isAccessible = true
|
||||
assertEquals(false, immediate.invoke(executor, screen, "color", full))
|
||||
val final = PurchaseRehearsalExecutor::class.java.declaredMethods.single { it.name == "verifyExactSpecSelection" }
|
||||
final.isAccessible = true
|
||||
val result = final.invoke(executor, screen, "color", full, null)
|
||||
val confirmed = result.javaClass.getDeclaredField("confirmed").apply { isAccessible = true }
|
||||
assertEquals(true, confirmed.get(result)) // Existing baseline; NOT a newly approved multi-selection behavior.
|
||||
}
|
||||
}
|
||||
@@ -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: 3165d7d419a46f41fa70a63286799deda9a8ce9f
|
||||
synchronized_at: 2026-10-06T09:17:36Z
|
||||
wiki_revision: b3d1c9b7ba1dd5820a20e59b0c21a971aaf1b8ee
|
||||
synchronized_at: 2026-10-08T01:41:51Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
<!-- gitea-wiki-mirror:start -->
|
||||
@@ -627,3 +627,15 @@ Web 唯一展示位置为“采集采购 → SYB 同步记录”:列表状态
|
||||
- PddProductDetailCollector 的入口、快速确认恢复和颜色点击通过 clickFreshDetailed 获取结果;SpecClickDiagnostic 构造白名单结构事件。AgentForegroundService 给探测执行器与采集器接入既有 SafeAgentDiagnosticRecorder / 单线程队列,未接入原始 trace。
|
||||
- AgentDiagnosticSchema v3:agent_diagnostic 追加可空 task_type TEXT、task_attempt_id TEXT、device_id INTEGER、phase TEXT、rule_snapshot_hash TEXT。onUpgrade 支持 V1/V2 追加并检查已有列;保留旧行且新字段为 NULL。AgentDiagnosticStore 写采购记录时验证类型、UUID、正设备 ID、阶段和 64 位十六进制哈希。动作 attempt 与采购 attempt UUID 分离,全库 50 条/7 天保留边界不变。
|
||||
- 无 Server/Web/业务库或共享接口字段变化,无订单提交流程变化。新错误沿既有 errorCode 字符串回传;真实探测/采购验收仍待用户授权。
|
||||
|
||||
## 自动匹配扫描游标与租约守卫(#359)
|
||||
|
||||
实现绑定 `9fcbc64117bcee0cbed25c3957a14a25d826f637`,main 合并 `0138da85602fb1baf5e208b4bc451f9357190e9d` 已于 2026-10-08 部署到 122.228.200.167。MySQL 8.4.8 追加迁移 1791300000000 已执行,两列及非负约束回读通过;不代表 MySQL 锁竞争或真实多轮 AI 批次已验收。
|
||||
|
||||
- `server/app/goauto/shopeeproduct/auto_match_batch.go` 按商品ID键集分页,200/页、2000/轮、10分钟预算,复用原单商品匹配。30分钟运行租约与唯一active_slot不变,运行及工作项变更增加所有权检查。
|
||||
- `shopee_spec_auto_match_run.resume_after_id` 为可空、非负BIGINT:NULL不提交位置,0回绕,从最近已终结非NULL运行读取;`stop_reason` 为VARCHAR(24)、NOT NULL DEFAULT ''。完成更新在相同所有权守卫下原子提交统计和位置。
|
||||
- `1791300000000_shopee_spec_auto_match_resume.go` 只追加两列,重复执行幂等;既有运行初始化NULL/空字符串,不改商品或工作项,不改变定时任务启停。旧代码忽略新列,回退代码保留列和既有映射。
|
||||
- 批处理私有context将所有权检查传递到`ai_suggest.go`的Provider调用以及`auto_match.go`的映射事务;复用当前事务锁定运行,非批处理上下文不引入运行查询。AI决策算法和匹配规则不变。
|
||||
- 因预算超时不能再使用已取消context写统计,收尾仅使用最多5秒的独立上下文执行受所有权保护的完成更新,不启动新商品领取或AI调用;失租不强制落库。
|
||||
- MySQL默认返回实际修改行数;续租更新返回0时,只在当前持有行锁的事务内再次核验owner/状态/槽位/实时有效租约,以区分同毫秒值未变化与真实失租;其他完成/工作项更新仍要求恰好一行。
|
||||
- AI配置读取先返回数据库错误,再判断停用,避免基础设施错误被误记为业务跳过。仅批次私有上下文把基础设施错误作为本轮错误终止;普通单商品Provider重试策略保持不变。
|
||||
|
||||
@@ -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: ec20bf476551c3dbc1d5aaa6301605bfe695e102
|
||||
synchronized_at: 2026-10-06T09:17:39Z
|
||||
wiki_revision: 25fdd0c4837882dccb27c7b9e740d6d97065d490
|
||||
synchronized_at: 2026-10-08T03:25:26Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
<!-- gitea-wiki-mirror:start -->
|
||||
@@ -339,6 +339,8 @@ Admin 的「创建时间」范围按 SYB 货运单列表的 `t_stock.created`(
|
||||
|
||||
## SYB 档口入库码导入、匹配与回写
|
||||
|
||||
- 页面分页(#363,实现绑定 `955869b`,2026-10-07 已合并 main `eb7cb6b` 并发布至 122.228.200.167):仅档口入库码页首次进入或刷新默认第 1 页、每页 200 条,保留 20/50/100/200 条选项;搜索和清空筛选保持当前选定条数。其他页面、公共分页组件及后端未传 pageSize 时默认 100 的行为不变;当前页全选仍仅限当前页,匹配、删除和回写的资格与确认规则不变。
|
||||
|
||||
- 只接受不超过 10 MB、最多 5000 条非空数据行且包含“标签入库码映射”工作表的 Excel。一次文件只能包含一个营业日期;同日单件入库码必须唯一,同一业务键按营业日期、订单号、档口和规范化规格合并并保留 Excel 顺序。
|
||||
- 导入事务提交后自动创建并启动只读匹配任务,手动批量重匹配入口见 #350 的版本绑定规则。再次导入同日数据只允许整体替换仍处于等待匹配的数据;存在匹配或回写证据时拒绝覆盖。
|
||||
- 匹配必须按精确订单号取得唯一货运单,再以规格、原始 SKU 和档口证据确定唯一商品;歧义、冲突或数量不一致均不猜测。匹配阶段不能调用任何 SYB 写接口。
|
||||
@@ -668,10 +670,21 @@ Web 唯一展示位置为“采集采购 → SYB 同步记录”:列表状态
|
||||
|
||||
实现分支 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分钟唤醒锁,也不宣称新增了执行器总体墙钟超时机制。
|
||||
原 #325 过渡阶段最多60次采样。首次识别到待付款证据后单独分配30次采样预算,不被之前绕路消耗,不因文字反复出现重置;原核单总采样上限90次。#365 工单分支增加下节所述成功支付页返回后的定时观察例外,不能再把90次视为该例外路径的总上限。缺少唯一订单号或时间时立即向上滑动读取下方内容,最多4次、手势400ms、每次后等500ms;读全立即停止,已有完整字段不滑动。该待付款读取分支不点击任何控件,也不点击付款。纯支付页不使用该滑动分支;出现可读待付款证据优先于复用的 Activity 名称。按采样间隔约18秒加有限手势/额外等待,实际耗时还含capture等调用;未改变2分钟任务租约或5分钟唤醒锁,也不宣称新增了执行器总体墙钟超时机制。
|
||||
|
||||
应付金额从同一订单的可见标签集合中读取,兼容“应付:,13元”等标点;不跨无关联节点拼接,不读拼单价、优惠或实付。父子重复同金额去重,多值/缺失/无效/溢出省略,不因金额失败丢弃订单号与时间。仅存Admin,不推断支付、不改SYB金额、不修改地址行为。历史回填及界面语义未在本单统一,详见API契约#325补充。
|
||||
|
||||
## 支付页安全返回后的核单观察(#365)
|
||||
|
||||
实现绑定 `37714d39b5ac8ff720cfa1d0d2609f9a8af3fbd9`,工单分支已实现,未合并 main、未安装或发布;自动化验证不代表真机核单成功率已改善。仅 Android 变化,Admin、Server、数据库和上传状态契约不改。
|
||||
|
||||
- 只有识别到 PDD 支付页且既有一次 Back 成功后,才记录单调时钟起点。在尚未取得订单/待付款上下文时,允许观察至该起点后约30秒,不因反复采样或上下文缺失重置;不能用 paymentPageObserved 代替成功返回事实。
|
||||
- 无成功返回,或在原60次内已取得普通订单上下文的路径,保持原预算。返回失败、持续支付页25次、空窗口15次、微信恢复失败和未授权应用等独立失败保持,不保证每种失败等满30秒。
|
||||
- 原60次之后的新增观察只采样,不因迟到的详情入口、选择器、微信页面新增点击、返回、拉前台或滑动。延长期出现非待付款不完整订单上下文,可只读等待迟到字段至同一期限;完整证据立即结束。
|
||||
- 首次出现待付款证据后,仍使用原独立30次采样及最多4次有界读取滑动;不新增该分支额度。该分支有自己的预算,30秒不是整个核单方法的全局时限。
|
||||
- 固定期限检查包含采样耗时与既有暂停,单次 capture 本身不能被此机制中断;不承诺严格墙钟总耗时。不增加采样硬上限来提前替代30秒期限。
|
||||
- 仍无完整证据时沿用已有失败原因与 order_result_unknown 处理,不自动补录、重试采购或再次创建订单;永久禁止付款。这只是对加载慢的有界缓解,不是对历史未知原因的根因修复。
|
||||
|
||||
## 采购订单资料完整性及简化单号回填(#326)
|
||||
|
||||
用户2026-09-19确认内部系统简化流程,原型v1通过。实现3a2472d,尚未发布或真实SYB验收。
|
||||
@@ -810,3 +823,26 @@ Android 0.9.64 / versionCode 77,源码 `6550b9f`(分支实现,尚未安装
|
||||
- PDD 点击按最终接收动作的控件或最近可点击父容器检查尺码建议类明确短语,包含简繁体;无障碍直接点击、祖先点击与中心手势共享判断。不可因面板其他位置存在建议入口而拒绝所有规格;“M 建议80-100斤”等合法选项不按“建议”泛化拦截。命中返回 SIZE_ADVICE_CLICK_BLOCKED,规格入口/规格选择不再手势兜底,颜色采集不改点其他候选。
|
||||
- 规格探测的结构化诊断复用本地诊断库,以 purchase 类型、task ID、服务端 attempt UUID、设备、phase 和规则快照哈希关联;不保存原始 trace/标签/树/截图。诊断失败不改变任务结果。
|
||||
- 真机验证不能依赖探测失败作为停止边界:live 任务探测匹配成功后可继续正式下单。必须明确演练覆盖 spec_probe 的方式,或另获正式创建订单授权;永久禁止付款。
|
||||
|
||||
## Android 同一卡片截短规格显示去重(#362)
|
||||
|
||||
解析修复绑定 9a4495a + 1f5bab8(基于 main 64f0e49);用户随后授权构建安装,工单分支于 35119ea 集成 #361,Agent 0.9.69 / 82 已于 2026-10-06 覆盖安装到指定设备。尚未合并 main,现场采购效果未验证;集成不表示 #361 的诊断疑点已解决或整体验收通过。
|
||||
|
||||
- 共享 PddScreenParser.optionBlock 保留原“成员以外层完整标签开头”的分支(含徽标/附加说明)。只新增已知图片卡片形态的保守兼容:外层有自身完整标签,候选显示成员恰为一个完整标签 ImageView 叶节点和一个截短 TextView 叶节点;允许非点击布局包装,不跨另一个带标签且可点击的容器。
|
||||
- 若带自身标签的候选容器包含另一个有不同规范化名称、可点击且有自身子节点的卡片,该容器不作为规格值,也不能成为这些卡片的合并块。这一结构边界同时保护原前缀分支,避免共同容器“黑色”吞并子卡片“黑色加绒”,以及容器聚合 selected 污染另一张卡片;同名包装和原徽标分支保留。
|
||||
- 两个显示叶节点必须位于卡片边界内,图片在字幕上方且横向相交(允许图片/字幕边缘重叠)。缺少完整图片、多个字幕或图片、嵌套独立卡片、文字归属不明确时不采用新增合并分支。相邻同价且前缀相似的不同规格不能凭文字合并。
|
||||
- 比较使用既有 SpecValueNormalizer 尾部价格语法和空白压缩,双方非空,截短名须为完整名的严格前缀;所有实际存在的尾价须数值一致,18.8 与 18.80 等价。缺失价格不构成同卡证据,必须仍满足全部结构条件。不去掉体重、尺码数字或款式文字。
|
||||
- 合并后仍使用外层完整原文按既有规则去尾价作为规格值,沿用原点击节点排序、selected/checked 聚合;普通采集与采购探测共用这一路径。精确定位、即时确认、选中证明、最终复核及下单行为不改,不允许模糊点击。
|
||||
- 历史任务若已映射到截短值,不自动升格为完整值,不修改任务快照、映射或历史数据;找不到精确目标仍明确失败。即时确认先查其他选中值,而最终确认先接受唯一目标已选中,是既有实现差异,本修复不调整或掩盖该差异。
|
||||
- #361 的诊断未写入疑点继续独立核查。解析合成测试可先行,后续用于手机安装的集成版本须包含 #361,并经明确授权安装/真机验证;不以已有单次采购成功代替完整验收,不执行付款。
|
||||
|
||||
## 蝦皮规格自动匹配有界续扫(#359)
|
||||
|
||||
实现绑定 `9fcbc64117bcee0cbed25c3957a14a25d826f637`,已通过 main 合并 `0138da85602fb1baf5e208b4bc451f9357190e9d` 于 2026-10-08 部署到 122.228.200.167,追加迁移已执行并重启 GoAuto。定时任务启停/Cron/参数保持原样;未手动触发真实 AI,真实多轮续扫及匹配效果仍待自然运行验收。
|
||||
|
||||
- 定时及管理员批量匹配沿用原匹配算法、阈值、人工/有效确认映射保护和指纹重试规则;仅修复固定首段扫描无法到达后方候选。单商品手动匹配不受批次租约检查影响。
|
||||
- 使用商品 ID 升序键集分页,每页最多200件,每轮实际检查最多2000件,默认实际领取处理最多20件(batchLimit原校验范围不变)。整轮数据库及AI操作共享10分钟预算,逐商品串行,30分钟租约不变。
|
||||
- SQL排除明确空规格,Go先检查蝦皮端非空颜色/尺码再读PDD;只有颜色或只有尺码仍合法,只有other/空values不能成为匹配候选。
|
||||
- 正常完成或预算退出只保存最后已确定处理/跳过的位置;页中提前退出不跳到预取末尾。确实消费完末页才回绕0;下轮/进程重启从最近已终结且有有效游标的运行续扫,NULL不是有效游标,0是有效回绕点。
|
||||
- 单运行所有权在分页续期、领取、Provider调用及保存映射时检查。失租旧运行不能继续领取或覆盖新owner;基础设施错误或失租不提交新游标。
|
||||
- completed只表示本轮正常结束,处理0件可能合法;scanned为实际检查数,不是预取数或全表数,processed不是成功数,confirmed/unmatched是规格项数。商品变化后可能需要等扫描回绕,不保证固定小时内全部处理。
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Troubleshooting
|
||||
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Troubleshooting
|
||||
wiki_revision: ac3a0b74704f398cec54149267089c87c8375a3e
|
||||
synchronized_at: 2026-10-06T09:17:51Z
|
||||
wiki_revision: fee37efc79b644ecfa3237283dd45f0a09796f44
|
||||
synchronized_at: 2026-10-08T03:25:33Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 故障排查
|
||||
@@ -114,3 +114,25 @@ adb -s <serial> shell run-as cn.ilapage.goauto.agent sqlite3 -readonly databases
|
||||
- 保留现有加载、错误提示、取消和旧请求隔离逻辑,不增加自动重试。慢请求在 10~60 秒间完成时不再被原 10 秒客户端预算提前中止;超过 60 秒仍会超时,上游更短的超时也可能先终止请求。
|
||||
- 此调整不优化后端执行速度。处理阶段筛选当前先加载候选并计算阶段、后分页;不选店铺且采购类型为全部时,候选可能很大。遇到持续慢查询,应另行分析候选预筛选与数据库执行计划,不能据此认为延长前端预算已解决后端性能问题。
|
||||
- 只需发布包含该提交的 Web 资源即可生效,不要求数据库迁移、Android 安装或后端参数变更;发布仍需人工授权。
|
||||
|
||||
## Android 地址编辑页控件就绪等待(#366,环节②)
|
||||
|
||||
实现绑定 `7fdfe04658d16a05ebcb016ee964a7263eb74802`,位于独立工单分支,未合并 main、未装机或发布;合成测试与 APK 构建结果见 #366 工单,不代表真机采购验收。只修改点击“修改”后等待详细地址输入框就绪的环节;地址列表识别(①)仍缺真实单/多地址列表证据,保存退出(③)、返回与最终复核(④)不改。
|
||||
|
||||
- 标题出现不再立即触发输入框缺失失败。在“详细地址”标签的既有同排区域,统计可见 EditText 的 structural 数量(包括空值/禁用)及其 enabled、实际 text 非空且未判为 hint 的子集 ready 数量。只有 structural=1 且 ready=1 才继续,读取的地址与就绪判定来自同一帧;后续 inputFresh 仍重新定位且只尝试一次。
|
||||
- 原地址只读取同帧实际 text.trim(),不使用 contentDescription 补空。API 26+ 捕获内存 hintText / showingHintText;showingHintText=true 或 text.trim() 等于非空 hintText.trim() 时不就绪,继续等待。全局 label 和其他调用方的语义保持不变,这两个新增元数据不落盘、不上报、不记日志。
|
||||
- API <26 或应用未正确提供 hint 元数据时,不能完全区分非空 text 是提示还是实际值;仅凭此修复不能保证所有设备/页面的提示文字均被识别,不使用提示词猜测。
|
||||
- 沿用单轮最多 50 次采样、原 200ms 暂停及点击后的既有暂停。不再叠加标题等待与控件等待,不设置“三次多候选即失败”;采样与解析另有耗时,不能将其表述为严格 10 秒墙钟超时。既有安全异常仍可提前终止。
|
||||
- 等待超时使用现有错误码:从未观察到详细地址标题为 PURCHASE_ADDRESS_EDIT_TIMEOUT;曾见标题但候选未就绪/不唯一为 PURCHASE_ADDRESS_UPDATE_FAILED。titleSeen 跨帧保留,因此最后一帧空树不会抹掉此前的标题证据。
|
||||
- 诊断仅含固定 stage=address_editor_ready、reason 分类以及 titleSeen、structural、ready 标量,不含地址、收货人、电话或节点文本。具体原因以实现和测试中的固定分类为准;这不是新增 SQLite 字段或上传接口。
|
||||
- 收货人/电话不能作为备用目标;区域重叠造成歧义时不输入、不保存。无额外点击、滑动、返回、保存或下单重试,正常单次动作路径保持。
|
||||
- 此修复不能证明历史失败均因慢加载,也不解决保存地址后返回超时。识别①不能仅凭“不是规格面板+无提交按钮”推断已进入地址列表:两项都依赖同一个面板解析结果。
|
||||
|
||||
## 支付页返回后核单仍缺少证据(#365)
|
||||
|
||||
实现绑定 `37714d39b5ac8ff720cfa1d0d2609f9a8af3fbd9`(工单分支,未合并、安装或发布)。成功执行既有一次支付页安全返回后,在无订单上下文阶段以固定单调时钟起点观察约30秒;原预算后的新增观察不增加页面动作,待付款分支仍沿用独立读取预算。
|
||||
|
||||
- 先确认运行版本是否包含此提交,再区分失败码:持续支付页、空窗口、微信恢复和未授权应用等独立条件可提前结束,并非所有失败都等待30秒。
|
||||
- 完整订单证据出现就结束;超时保留原订单结果未知语义,不能因为等待更久就把任务改为可重试失败或自动重下单。
|
||||
- 固定期限从 Back 成功时起算,不是从提交订单或进入核单函数起算,也不会因新样本重置;capture 阻塞不能被该检查中断,待付款分支拥有独立预算。
|
||||
- 不因本次延长断言历史问题就是加载慢。延长期出现必须点击才能展开的入口仍不会新增点击;页面识别/挽留弹窗等原因需要真实诊断证据另行分析。#364 的失败现场诊断不属于本单实现。
|
||||
|
||||
@@ -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: 1ad78ac33cf5876c6b7db6a89107854978066373
|
||||
synchronized_at: 2026-10-06T01:39:51Z
|
||||
wiki_revision: f97ac6b8784fc200ee68e9e2eb4a1201296049f0
|
||||
synchronized_at: 2026-10-08T03:25:37Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
<!-- gitea-wiki-mirror:start -->
|
||||
@@ -641,6 +641,17 @@ Agent 主动提交 `order_result_unknown` 时保持订单号和下单时间为
|
||||
| `PURCHASE_ORDER_RESULT_UNKNOWN` | 无法确认订单是否创建,请人工检查 |
|
||||
| `PURCHASE_PAYMENT_FORBIDDEN` | 系统禁止自动付款 |
|
||||
|
||||
### 地址编辑页初始等待诊断(#366,环节②)
|
||||
|
||||
实现绑定 `7fdfe04658d16a05ebcb016ee964a7263eb74802`(工单分支,尚未合并 main 或发布)。本修订只细化 Android 点击“修改”后的输入框等待及错误说明,不增加 HTTP 字段、数据库字段或错误码,不改变任务结果提交、设备/任务/规则关联和保存后的最终复核。
|
||||
|
||||
- 一个现有 50 次采样预算内,只有详细地址区域可见 EditText 的结构候选数为 1,且其中 enabled、实际 text 非空并排除明确 hint 的就绪候选数也为 1,才允许后续既有单次输入;结构计数包括空值和禁用候选。
|
||||
- ready 和原地址不使用 contentDescription 补空;showingHintText=true 或实际 text.trim() 等于非空 hintText.trim() 时排除。hintText / showingHintText 仅为 API 26+ 的内存节点元数据,低版本为 null,不新增持久化或上传字段,不改变全局 label。应用缺失/错误的 hint 元数据或 API <26 时无法完全区分提示与实际值。
|
||||
- 超时从未观察到“详细地址”标题时沿用 `PURCHASE_ADDRESS_EDIT_TIMEOUT`;曾见标题但输入框缺失、为空、仅显示 hint、禁用或存在歧义时沿用 `PURCHASE_ADDRESS_UPDATE_FAILED`。
|
||||
- 此等待失败的 `errorMessage` 可附固定诊断 `stage=address_editor_ready;reason=<固定分类>;titleSeen=<布尔>;structural=<数量>;ready=<数量>`。reason 为 `title_missing`、`editor_missing`、`editor_ambiguous`、`editor_disabled`、`editor_empty` 或 `editor_hint`;titleSeen 累计保留,数量取最后采样帧,不包含地址值、收货人、电话及节点文本。
|
||||
- 既有页面安全异常优先失败,不必耗尽预算;`inputFresh` 失败仍不重试。其它使用 `PURCHASE_ADDRESS_UPDATE_FAILED` 的路径保持原义,不要求它们附本段诊断。
|
||||
- ①地址列表识别仍待真实场景证据,③保存退出、④返回与最终复核不改;本段不代表整单完成或真机验收。
|
||||
|
||||
## Agent 当前设备任务历史(#90)
|
||||
|
||||
四个只读接口统一使用设备注册所得的 Device Token,只返回该 Token 对应设备最近 30 天内的任务;设备 A 查询设备 B 的任务时按不存在处理,不泄露任务是否存在。
|
||||
@@ -1472,3 +1483,13 @@ Web 输入去重后一个值提交旧标量、多个值提交重复集合键,
|
||||
| sybStatusSyncedAt | RFC3339 时间字符串或 null | 最近一次正常同步取得有效取消值的 UTC 时间,不代表上游取消发生时间 |
|
||||
|
||||
只使用上游 isCancel;不兼容性猜测字符串/布尔/其他数值,缺失或无效值保留原字段。成功同步的 false 可覆盖 true。无有效值的新记录两个字段均为 null;旧客户端可忽略新增字段,新 Web 对旧响应缺字段显示未获取。列表与详情不触发额外上游请求。取消状态仅展示,不改变采购准备阶段、创建/重试资格、任务执行或现有订单事实。
|
||||
|
||||
## Admin 蝦皮规格自动匹配运行摘要追加字段(#359)
|
||||
|
||||
实现绑定 `9fcbc64117bcee0cbed25c3957a14a25d826f637`,main 合并 `0138da85602fb1baf5e208b4bc451f9357190e9d` 已于 2026-10-08 迁移并部署线上。本节仅扩展既有Admin批次接口,不修改Android Agent接口、Web页面或权限。本轮公共健康及未认证/非管理员拒绝边界通过;现有私有凭据为采购员,管理员运行摘要新字段的在线认证回读未完成,不能用数据库字段校验代替该项。
|
||||
|
||||
- `POST /api/admin/v1/shopee-spec-auto-match/runs` 与 `GET /api/admin/v1/shopee-spec-auto-match/runs/latest` 的既有运行对象增加`resumeAfterId`、`stopReason`,请求参数、原字段和状态保持兼容。
|
||||
- `resumeAfterId`:可空非负整数;null表示该运行没有提交有效续扫点,0表示下一轮从头扫描,正数表示最后已完成检查的位置,不是预取页末商品。运行中/旧记录可能为null。
|
||||
- `stopReason`:旧记录默认空字符串;完成原因是`batch_limit`、`scan_budget`、`time_budget`、`end_of_scan`、`lease_lost`或`error`。失租旧进程不能为填此字段越权更新;合法回收路径标记lease_lost。
|
||||
- `scannedCount`改为实际检查的候选数;SQL已过滤的空档案及预取未检查项不计入。eligibleCount为Go资格通过数,processedCount为实际领取处理数;confirmedCount/unmatchedCount仍为规格项数,不能据此直接混算商品成功率。
|
||||
- 单轮处理默认20、每页200、实际扫描上限2000、整轮预算10分钟。正常0处理仍可completed,预算退出有持久游标;错误/失租不提交新游标。
|
||||
|
||||
@@ -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: 8be38ac192b9e1930fdc3d998c23027fda17e8f3
|
||||
synchronized_at: 2026-10-06T03:27:28Z
|
||||
wiki_revision: db99427fa2460989d34d28ef864230a907fc5e6d
|
||||
synchronized_at: 2026-10-08T01:42:22Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
<!-- gitea-wiki-mirror:start -->
|
||||
@@ -276,3 +276,35 @@ Provider 故障日志只允许记录调用关联 ID、操作类型、耗时、
|
||||
- 服务端 SHA256:`a4cf6c7028a2bbbfd86a1d657ad9c3daad7b4d08445405d8ca9a778adc584760`;远端校验一致。
|
||||
- 发布验收:公网 `/`、`/index.html`、`/syb-products/index` 与发布 dist/index.html 字节相同,10 项入口 JS/CSS 和 `/api/v1/health` 均正常;日志无 panic/fatal/缺表缺字段错误。合并后的 #358 合成解析/导入/重解析及 task/purchase 规格键定向回归通过。未将健康验证视为历史数据已纠正。
|
||||
- 回滚目标 `/home/goauto/releases/20261006-0a79c83-356` 保留,需按执行中任务检查后切回并重启;不通过恢复数据库覆盖后续业务数据。
|
||||
## #363 档口入库码默认 200 条的 Web 发布(2026-10-07)
|
||||
|
||||
- 用户授权合并 main 和线上发布;main `eb7cb6b5eb9a57041429c3dfb1d88dc8236d32f3` 已推送,发布目录 `/home/goauto/releases/20261007-eb7cb6b-363`。仅本页初始化 pageSize 改为 200,选项仍为20/50/100/200;后端缺省100不变,没有迁移、权限或业务数据修改。
|
||||
- 对比上一线上源码 `e76de6f`,Server 无差异,复用原二进制并逐字节验证;Web 从合并提交重建。沿用原 config、环境、static/temp/var 的真实目录;新 dist 保留上版不同哈希 JS/CSS,避免已打开页面的延迟加载失效。
|
||||
- 发布检查发现有采购任务执行中,未停止或重置任务。核验 Nginx root 及运行进程 GOAUTO_WEB_DIST 均指向 `/home/goauto/current/dist`,代码在请求时读取文件且没有提前解析软链接。因此本次只原子切换 current,不重启 GoAuto/Nginx;进程 PID 43738 前后一致且 active。此方式仅适用于后端二进制、配置与持久资源路径均不变的纯 Web 发布,不能推广为后端发布免重启。
|
||||
- 公网首页、index.html、login、档口入库码路由均与新 dist/index.html 字节一致;10 项入口 JS/CSS 与健康接口正常,日志检查无 panic/fatal/1146/1054。真实 Chrome 已认证只读检查:首次默认 page=1/pageSize=200、当天空态与200条/页正常;清除日期后200行渲染通过,查询至渲染约987ms。未发起匹配、删除或回写,未保存原始生产数据或截图。
|
||||
- Web包 SHA256 `a6b4429280fae37e46e81bd21a594941cfee3b5e8c48c105a09254314af32311`;Server SHA256 `a4cf6c7028a2bbbfd86a1d657ad9c3daad7b4d08445405d8ca9a778adc584760`(未变)。
|
||||
- 回滚目录 `/home/goauto/releases/20261006-e76de6f-358` 保留。对本次纯 Web 变更可将 current 原子切回,不需要恢复数据库、删除文件或中断任务;若此后已升级后端,不能复用这一免重启结论。
|
||||
|
||||
## #359 续扫版本迁移与验证边界
|
||||
|
||||
源码 `9fcbc64117bcee0cbed25c3957a14a25d826f637` 经 main 合并 `0138da85602fb1baf5e208b4bc451f9357190e9d`,已于 2026-10-08 按用户授权迁移并发布到 122.228.200.167。本地业务库未迁移,未手动触发真实匹配;下文保留部署约束,本次发布事实见后续小节。
|
||||
|
||||
1. 复核无冲突运行及现有迁移版本,按既有受限备份流程备份。追加迁移`1791300000000_shopee_spec_auto_match_resume.go`仅新增运行游标与停止原因,必须先迁移再运行新版本;不修改定时任务配置或历史商品。
|
||||
2. 发布后观察运行的stopReason/resumeAfterId及真实计数,确认多轮向后推进、末尾回绕,而非反复固定首段。合法无候选仍允许processedCount=0,不能要求每轮强制匹配成功。
|
||||
3. 批次结构化日志按run_id记录停止原因、实际扫描/领取/规格项计数及跳过类别,不记录商品规格原文或Provider响应。lost lease旧进程不能覆盖新运行,合法过期回收标记failed/lease_lost且不提交游标。
|
||||
4. 回退旧二进制时保留追加列和已保存映射,不通过数据库回滚覆盖后续业务。恢复处理会带来原本预期的AI调用和映射写入,仍受默认20件、串行与总时间预算限制。
|
||||
|
||||
本地测试使用SQLite内存库和模拟Provider;MySQL8.4.3只执行合成JSON粗过滤SELECT验证。本轮线上MySQL8.4.8追加迁移及结构回读已完成,MySQL锁竞争集成和真实多轮批次仍未验证,未手动调用线上AI。
|
||||
|
||||
|
||||
### #359 线上迁移与 Server 发布(2026-10-08)
|
||||
|
||||
- 用户于 2026-10-07 暂缓发布,2026-10-08 明确重新授权执行迁移和重启更新。迁移前和切换前检查采集、采购及 attempt、同步、AI匹配/解析、回填、入库码批次和调度执行记录,均无执行中任务;没有取消/重置任务或手动触发真实业务。
|
||||
- 使用已核验 Server 包,源码 main 合并 `0138da85602fb1baf5e208b4bc451f9357190e9d`,发布目录 `/home/goauto/releases/20261007-0138da8-359`(昨日预备目录,今日启用)。current 已切换,`goauto.service` 重启 active;运行进程二进制 SHA256 与发布包一致:`4962738a7fe56244e13748d861f8e8214743ac22d8bf6440c6c8e27d381f88b6`。
|
||||
- 重新生成当时受限备份 `/home/goauto/backups/20261008-359/database.sql`,目录0700/文件0600,不下载生产数据。迁移前唯一缺失1791300000000;执行后无缺失,`resume_after_id` nullable BIGINT、`stop_reason` VARCHAR(24)默认空字符串以及非负CHECK约束均回读正确,旧运行保留NULL/空字符串。
|
||||
- Web与#363无源码变化,完整沿用已验证dist;config、环境、static/temp/var真实路径不变。sys_job启停、Cron、参数发布前后摘要一致,关闭的任务仍关闭,开启的任务继续原调度。没有构建或安装Android。
|
||||
- 发布中发现新release目录受严格umask影响为0700,Nginx读取index被拒绝导致根入口404。已定向恢复到上一release相同的0755,未删除dist、未改Nginx路由。后续发布应分别设置:备份目录0700与文件0600;对外静态服务所经release目录具备与已验证基线一致的遍历权限。不能认为mkdir传mode=0755就不会被umask收紧;切换前用实际Nginx读取身份验证可访问性,切换后按页面内容验收。
|
||||
- 公网 `/`、`/index.html`、`/login`、`/syb-products/index` 均与dist入口哈希一致;10项入口JS/CSS、健康与验证码接口通过。健康接口约定是HTTP200空正文,不应强制JSON解析。未认证请求业务码401,现有私有采购员凭据访问管理员运行摘要为403,未绕过或改权限;管理员新字段认证回读尚待有效管理员会话验证。
|
||||
- 新版启动日志结构检查未见panic/fatal/1146/1054。服务器systemd较旧,读取PID用 `systemctl show goauto -p MainPID` 并解析键值,不使用不支持的 `--value`;journalctl起始时间使用服务器本地格式 `YYYY-MM-DD HH:MM:SS`。
|
||||
- Nginx配置检查通过,本次无配置变化、不需重启或reload;仅恢复发布目录可读性即恢复前端。真实多轮续扫、末尾回绕和AI实际匹配仍待自然运行验收,健康检查不等于业务效果验收。
|
||||
- 回滚代码目标 `/home/goauto/releases/20261007-eb7cb6b-363` 保留;重启前核验任务空闲,保留追加列与已经保存的映射,不恢复旧备份覆盖此后业务。
|
||||
|
||||
@@ -249,12 +249,15 @@ func (s *Service) ResolveSYBSpec(ctx context.Context, request SYBSpecParseReques
|
||||
|
||||
func (s *Service) activeSetting(ctx context.Context) (models.AIMatchingSetting, string, error) {
|
||||
setting, err := s.setting(ctx)
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) || !setting.Enabled {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return models.AIMatchingSetting{}, "", fail(CodeNotConfigured, "AI 规格匹配未启用")
|
||||
}
|
||||
if err != nil {
|
||||
return models.AIMatchingSetting{}, "", err
|
||||
}
|
||||
if !setting.Enabled {
|
||||
return models.AIMatchingSetting{}, "", fail(CodeNotConfigured, "AI 规格匹配未启用")
|
||||
}
|
||||
if strings.TrimSpace(setting.APIKey) == "" {
|
||||
return models.AIMatchingSetting{}, "", fail(CodeNotConfigured, "AI 规格匹配未配置 API Key")
|
||||
}
|
||||
|
||||
@@ -26,6 +26,9 @@ type ShopeeSpecAutoMatchRun struct {
|
||||
FinishedAt *time.Time `json:"finishedAt,omitempty"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
// NULL means no committed checkpoint; zero explicitly restarts at the head.
|
||||
ResumeAfterID *uint64 `json:"resumeAfterId" gorm:"type:bigint;check:ck_shopee_spec_auto_match_resume,resume_after_id IS NULL OR resume_after_id >= 0"`
|
||||
StopReason string `json:"stopReason" gorm:"size:24;not null;default:''"`
|
||||
}
|
||||
|
||||
func (ShopeeSpecAutoMatchRun) TableName() string { return "shopee_spec_auto_match_run" }
|
||||
|
||||
@@ -194,10 +194,33 @@ func (service *Service) suggestMappings(ctx context.Context, id uint64, requestC
|
||||
Dimension: role, ShopeeTitle: shopee.Title, PDDTitle: pdd.Title,
|
||||
Sources: sources, Candidates: candidates,
|
||||
}
|
||||
result, err := aiService.SuggestBatch(ctx, suggestReq)
|
||||
// Batch ownership can change between role calls or provider retries.
|
||||
var guardErr error
|
||||
suggest := func(request aimatching.SuggestRequest) (aimatching.SuggestResult, error) {
|
||||
if err := checkAutoMatchRunContext(ctx, service.DB); err != nil {
|
||||
guardErr = err
|
||||
return aimatching.SuggestResult{}, err
|
||||
}
|
||||
result, err := aiService.SuggestBatch(ctx, request)
|
||||
if _, batch := ctx.Value(autoMatchRunContextKey{}).(autoMatchRunGuard); batch && err != nil {
|
||||
var providerErr *aimatching.Error
|
||||
if !errors.As(err, &providerErr) {
|
||||
guardErr = internalError(err)
|
||||
return aimatching.SuggestResult{}, guardErr
|
||||
}
|
||||
}
|
||||
return result, err
|
||||
}
|
||||
result, err := suggest(suggestReq)
|
||||
if guardErr != nil {
|
||||
return AISuggestResponse{}, guardErr
|
||||
}
|
||||
suggestCalls := 1
|
||||
if err != nil {
|
||||
result, err = aiService.SuggestBatch(ctx, suggestReq)
|
||||
result, err = suggest(suggestReq)
|
||||
if guardErr != nil {
|
||||
return AISuggestResponse{}, guardErr
|
||||
}
|
||||
suggestCalls++
|
||||
if err != nil {
|
||||
return AISuggestResponse{}, aiUnavailable(aiSuggestErrorMessage(err))
|
||||
@@ -216,10 +239,13 @@ func (service *Service) suggestMappings(ctx context.Context, id uint64, requestC
|
||||
}
|
||||
}
|
||||
if len(retrySources) > 0 && suggestCalls < 2 {
|
||||
retryResult, retryErr := aiService.SuggestBatch(ctx, aimatching.SuggestRequest{
|
||||
retryResult, retryErr := suggest(aimatching.SuggestRequest{
|
||||
Dimension: role, ShopeeTitle: shopee.Title, PDDTitle: pdd.Title,
|
||||
Sources: retrySources, Candidates: candidates,
|
||||
})
|
||||
if guardErr != nil {
|
||||
return AISuggestResponse{}, guardErr
|
||||
}
|
||||
if retryErr == nil {
|
||||
for _, source := range retrySources {
|
||||
if decision, ok := retryResult.Decisions[source.ID]; ok {
|
||||
|
||||
@@ -148,6 +148,9 @@ func (service *Service) autoMatchMappings(ctx context.Context, id uint64, reques
|
||||
|
||||
replayed := false
|
||||
err = db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := checkAutoMatchRunContext(ctx, tx.Clauses(clause.Locking{Strength: "UPDATE"})); err != nil {
|
||||
return err
|
||||
}
|
||||
var current models.ShopeeProduct
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(¤t, id).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
|
||||
@@ -13,8 +13,10 @@ import (
|
||||
"go-admin/app/goauto/aimatching"
|
||||
"go-admin/app/goauto/models"
|
||||
|
||||
log "github.com/go-admin-team/go-admin-core/logger"
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -23,6 +25,10 @@ const (
|
||||
autoMatchLeaseDuration = 30 * time.Minute
|
||||
autoMatchRetryDelay = time.Hour
|
||||
maxAutoMatchAttempts = 3
|
||||
autoMatchPageSize = 200
|
||||
autoMatchScanBudget = 2000
|
||||
autoMatchTimeBudget = 10 * time.Minute
|
||||
autoMatchNonEmptySpecsSQL = "TRIM(CAST(shopee_product.specs_json AS CHAR)) <> ? AND TRIM(CAST(shopee_product.specs_json AS CHAR)) <> ? AND TRIM(CAST(shopee_product.specs_json AS CHAR)) <> ? AND TRIM(CAST(shopee_product.specs_json AS CHAR)) <> ?"
|
||||
)
|
||||
|
||||
type AutoMatchRunView struct {
|
||||
@@ -41,11 +47,11 @@ func (service *Service) StartAutoMatchRun(ctx context.Context, trigger, requestI
|
||||
if trigger != "manual" && trigger != "scheduled" {
|
||||
return AutoMatchRunView{}, false, invalidRequest("trigger 无效")
|
||||
}
|
||||
if batchLimit <= 0 {
|
||||
if batchLimit == 0 {
|
||||
batchLimit = defaultAutoMatchBatchLimit
|
||||
}
|
||||
if batchLimit > 100 {
|
||||
return AutoMatchRunView{}, false, invalidRequest("batchLimit 不能超过 100")
|
||||
if batchLimit < 1 || batchLimit > 100 {
|
||||
return AutoMatchRunView{}, false, invalidRequest("batchLimit 必须在 1 到 100 之间")
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
lease := now.Add(autoMatchLeaseDuration)
|
||||
@@ -55,8 +61,8 @@ func (service *Service) StartAutoMatchRun(ctx context.Context, trigger, requestI
|
||||
created := false
|
||||
err := service.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Model(&models.ShopeeSpecAutoMatchRun{}).
|
||||
Where("status = ? AND active_slot = ? AND lease_expires_at < ?", "running", 1, now).
|
||||
Updates(map[string]any{"status": "failed", "active_slot": nil, "lease_owner": "", "lease_expires_at": nil, "error_summary": "上次运行租约过期,已安全释放", "finished_at": now}).Error; err != nil {
|
||||
Where("status = ? AND active_slot = ? AND lease_expires_at <= ?", "running", 1, now).
|
||||
Updates(map[string]any{"status": "failed", "active_slot": nil, "lease_owner": "", "lease_expires_at": nil, "error_summary": "上次运行租约过期,已安全释放", "finished_at": now, "resume_after_id": nil, "stop_reason": "lease_lost"}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Where("request_id = ?", requestID).First(&result).Error; err == nil {
|
||||
@@ -109,6 +115,8 @@ func (service *Service) LatestAutoMatchRun(ctx context.Context) (*AutoMatchRunVi
|
||||
// HTTP-launched goroutine or the scheduler because only the run owning the
|
||||
// active slot may update and finish itself.
|
||||
func (service *Service) ProcessAutoMatchRun(ctx context.Context, runID uint64) error {
|
||||
ctx, cancel := context.WithTimeout(ctx, autoMatchTimeBudget)
|
||||
defer cancel()
|
||||
var run models.ShopeeSpecAutoMatchRun
|
||||
if err := service.DB.WithContext(ctx).First(&run, runID).Error; err != nil {
|
||||
return err
|
||||
@@ -120,98 +128,149 @@ func (service *Service) ProcessAutoMatchRun(ctx context.Context, runID uint64) e
|
||||
if limit <= 0 || limit > 100 {
|
||||
limit = defaultAutoMatchBatchLimit
|
||||
}
|
||||
var candidates []models.ShopeeProduct
|
||||
queryLimit := limit * 25
|
||||
if queryLimit < 100 {
|
||||
queryLimit = 100
|
||||
ctx, cancelLease := context.WithCancelCause(ctx)
|
||||
defer cancelLease(nil)
|
||||
ctx = context.WithValue(ctx, autoMatchRunContextKey{}, autoMatchRunGuard{run: run, cancel: func() { cancelLease(errAutoMatchLeaseLost) }})
|
||||
stats := autoMatchBatchStats{}
|
||||
cursor := uint64(0)
|
||||
var previous models.ShopeeSpecAutoMatchRun
|
||||
err := service.DB.WithContext(ctx).Where("status <> ? AND resume_after_id IS NOT NULL", "running").Order("id DESC").First(&previous).Error
|
||||
if err == nil {
|
||||
cursor = *previous.ResumeAfterID
|
||||
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return service.endAutoMatchBatch(ctx, run, stats, cursor, "error", err)
|
||||
}
|
||||
if queryLimit > 1000 {
|
||||
queryLimit = 1000
|
||||
}
|
||||
if err := service.DB.WithContext(ctx).
|
||||
Joins("JOIN pdd_product ON pdd_product.id = shopee_product.pdd_product_id AND pdd_product.status = ?", "active").
|
||||
Where("shopee_product.pdd_product_id IS NOT NULL").
|
||||
Order("shopee_product.updated_at ASC, shopee_product.id ASC").Limit(queryLimit).Find(&candidates).Error; err != nil {
|
||||
service.finishAutoMatchRun(run, "failed", 0, 0, 0, 0, 0, 1, "扫描符合条件的商品失败")
|
||||
return err
|
||||
}
|
||||
|
||||
eligible, processed, confirmed, unmatched, failed := 0, 0, 0, 0, 0
|
||||
firstError := ""
|
||||
for _, product := range candidates {
|
||||
if processed >= limit {
|
||||
break
|
||||
stats.checkpointLoaded = true
|
||||
for {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return service.endAutoMatchBatch(ctx, run, stats, cursor, "time_budget", err)
|
||||
}
|
||||
fingerprint, ok, err := service.autoMatchEligibility(ctx, product)
|
||||
if err != nil {
|
||||
failed++
|
||||
if firstError == "" {
|
||||
firstError = safeBatchError(err)
|
||||
if stats.processed >= limit {
|
||||
return service.endAutoMatchBatch(ctx, run, stats, cursor, "batch_limit", nil)
|
||||
}
|
||||
if stats.scanned >= autoMatchScanBudget {
|
||||
return service.endAutoMatchBatch(ctx, run, stats, cursor, "scan_budget", nil)
|
||||
}
|
||||
if err := service.renewAutoMatchRun(ctx, run); err != nil {
|
||||
return service.endAutoMatchBatch(ctx, run, stats, cursor, "error", err)
|
||||
}
|
||||
pageLimit := min(autoMatchPageSize, autoMatchScanBudget-stats.scanned)
|
||||
var candidates []models.ShopeeProduct
|
||||
if err := service.DB.WithContext(ctx).
|
||||
Joins("JOIN pdd_product ON pdd_product.id = shopee_product.pdd_product_id AND pdd_product.status = ?", "active").
|
||||
Where("shopee_product.pdd_product_id IS NOT NULL AND shopee_product.id > ?", cursor).
|
||||
// Cast the JSON column to text before comparing: no JSON NOT IN/coercion.
|
||||
Where(autoMatchNonEmptySpecsSQL, "", "[]", "null", `""`).
|
||||
Order("shopee_product.id ASC").Limit(pageLimit).Find(&candidates).Error; err != nil {
|
||||
return service.endAutoMatchBatch(ctx, run, stats, cursor, "error", err)
|
||||
}
|
||||
for _, product := range candidates {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return service.endAutoMatchBatch(ctx, run, stats, cursor, "time_budget", err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
eligible++
|
||||
work, claimed, err := service.claimAutoMatchWork(ctx, run, product.ID, fingerprint)
|
||||
if err != nil {
|
||||
failed++
|
||||
if firstError == "" {
|
||||
firstError = safeBatchError(err)
|
||||
if stats.processed >= limit {
|
||||
return service.endAutoMatchBatch(ctx, run, stats, cursor, "batch_limit", nil)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if !claimed {
|
||||
continue
|
||||
}
|
||||
processed++
|
||||
service.renewAutoMatchRun(run)
|
||||
response, matchErr := service.autoMatchMappings(ctx, product.ID, AutoMatchRequest{RequestID: uuid.NewString(), SpecContextVersion: fingerprint[:64]}, aimatching.MaxProviderTimeout)
|
||||
// fingerprint begins with the 64-character context version.
|
||||
postFingerprint := fingerprint
|
||||
if next, _, nextErr := service.autoMatchEligibility(ctx, product); nextErr == nil && next != "" {
|
||||
postFingerprint = next
|
||||
}
|
||||
if matchErr != nil {
|
||||
failed++
|
||||
if firstError == "" {
|
||||
firstError = safeBatchError(matchErr)
|
||||
stats.scanned++
|
||||
fingerprint, skip, err := service.autoMatchEligibilityReason(ctx, product)
|
||||
if err != nil {
|
||||
return service.endAutoMatchBatch(ctx, run, stats, cursor, "error", err)
|
||||
}
|
||||
service.completeAutoMatchWork(work, postFingerprint, 0, 0, matchErr)
|
||||
continue
|
||||
if skip != "" {
|
||||
stats.skip(skip)
|
||||
cursor = product.ID
|
||||
continue
|
||||
}
|
||||
stats.eligible++
|
||||
work, claimed, err := service.claimAutoMatchWork(ctx, run, product.ID, fingerprint)
|
||||
if err != nil {
|
||||
return service.endAutoMatchBatch(ctx, run, stats, cursor, "error", err)
|
||||
}
|
||||
if !claimed {
|
||||
stats.skip(autoMatchWorkSkip(work, fingerprint, time.Now().UTC()))
|
||||
cursor = product.ID
|
||||
continue
|
||||
}
|
||||
stats.processed++
|
||||
if err := checkAutoMatchRunContext(ctx, service.DB); err != nil {
|
||||
return service.endAutoMatchBatch(ctx, run, stats, cursor, "error", err)
|
||||
}
|
||||
response, matchErr := service.autoMatchMappings(ctx, product.ID, AutoMatchRequest{RequestID: uuid.NewString(), SpecContextVersion: fingerprint[:64]}, aimatching.MaxProviderTimeout)
|
||||
if err := checkAutoMatchRunContext(ctx, service.DB); err != nil {
|
||||
return service.endAutoMatchBatch(ctx, run, stats, cursor, "error", err)
|
||||
}
|
||||
if matchErr != nil && batchErrorCode(matchErr) == CodeInternal {
|
||||
return service.endAutoMatchBatch(ctx, run, stats, cursor, "error", matchErr)
|
||||
}
|
||||
postFingerprint := fingerprint
|
||||
if next, _, err := service.autoMatchEligibility(ctx, product); err != nil {
|
||||
return service.endAutoMatchBatch(ctx, run, stats, cursor, "error", err)
|
||||
} else if next != "" {
|
||||
postFingerprint = next
|
||||
}
|
||||
if err := service.completeAutoMatchWork(ctx, work, postFingerprint, response.ConfirmedCount, response.UnmatchedCount, matchErr); err != nil {
|
||||
return service.endAutoMatchBatch(ctx, run, stats, cursor, "error", err)
|
||||
}
|
||||
if matchErr != nil {
|
||||
stats.failed++
|
||||
if stats.summary == "" {
|
||||
stats.summary = safeBatchError(matchErr)
|
||||
}
|
||||
} else {
|
||||
stats.confirmed += response.ConfirmedCount
|
||||
stats.unmatched += response.UnmatchedCount
|
||||
}
|
||||
cursor = product.ID
|
||||
}
|
||||
// Only a fully consumed short page proves the actual end of the scan.
|
||||
if len(candidates) < pageLimit {
|
||||
return service.endAutoMatchBatch(ctx, run, stats, 0, "end_of_scan", nil)
|
||||
}
|
||||
confirmed += response.ConfirmedCount
|
||||
unmatched += response.UnmatchedCount
|
||||
service.completeAutoMatchWork(work, postFingerprint, response.ConfirmedCount, response.UnmatchedCount, nil)
|
||||
}
|
||||
status := "completed"
|
||||
if failed > 0 {
|
||||
status = "completed_partial"
|
||||
}
|
||||
return service.finishAutoMatchRun(run, status, len(candidates), eligible, processed, confirmed, unmatched, failed, firstError)
|
||||
}
|
||||
|
||||
func (service *Service) autoMatchEligibility(ctx context.Context, product models.ShopeeProduct) (string, bool, error) {
|
||||
fingerprint, skip, err := service.autoMatchEligibilityReason(ctx, product)
|
||||
return fingerprint, skip == "" && err == nil, err
|
||||
}
|
||||
|
||||
func (service *Service) autoMatchEligibilityReason(ctx context.Context, product models.ShopeeProduct) (string, string, error) {
|
||||
if product.PDDProductID == nil {
|
||||
return "", false, nil
|
||||
return "", "no_specs", nil
|
||||
}
|
||||
var pdd models.PDDProduct
|
||||
if err := service.DB.WithContext(ctx).First(&pdd, *product.PDDProductID).Error; err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
if pdd.Status != "active" {
|
||||
return "", false, nil
|
||||
if strings.TrimSpace(product.SpecsJSON) == `""` {
|
||||
return "", "no_specs", nil
|
||||
}
|
||||
shopeeSpecs, err := Unmarshal(product.SpecsJSON)
|
||||
if err != nil {
|
||||
return "", false, err
|
||||
return "", "", err
|
||||
}
|
||||
usable := false
|
||||
for _, dimension := range shopeeSpecs {
|
||||
if dimension.Role != RoleColor && dimension.Role != RoleSize {
|
||||
continue
|
||||
}
|
||||
for _, value := range dimension.Values {
|
||||
if strings.TrimSpace(value.Name) != "" {
|
||||
usable = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if !usable {
|
||||
return "", "no_specs", nil
|
||||
}
|
||||
var pdd models.PDDProduct
|
||||
if err := service.DB.WithContext(ctx).First(&pdd, *product.PDDProductID).Error; err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
if pdd.Status != "active" {
|
||||
return "", "no_specs", nil
|
||||
}
|
||||
shared, needsMatch := false, false
|
||||
for _, role := range []string{RoleColor, RoleSize} {
|
||||
pddValues, err := selectablePDDValues(pdd.SpecsJSON, role)
|
||||
if err != nil {
|
||||
return "", false, err
|
||||
return "", "", err
|
||||
}
|
||||
if len(pddValues) == 0 {
|
||||
continue
|
||||
@@ -220,24 +279,32 @@ func (service *Service) autoMatchEligibility(ctx context.Context, product models
|
||||
if dimension.Role != role || len(dimension.Values) == 0 {
|
||||
continue
|
||||
}
|
||||
shared = true
|
||||
for _, value := range dimension.Values {
|
||||
if strings.TrimSpace(value.Name) == "" {
|
||||
continue
|
||||
}
|
||||
shared = true
|
||||
if value.Mapping == nil || value.Mapping.Status != MappingStatusConfirmed || !pddValues[value.Mapping.PDDValue] {
|
||||
needsMatch = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if !shared || !needsMatch {
|
||||
return "", false, nil
|
||||
if !shared {
|
||||
return "", "no_specs", nil
|
||||
}
|
||||
if !needsMatch {
|
||||
return "", "confirmed", nil
|
||||
}
|
||||
contextVersion := computeSpecContextVersion(product.PDDProductID, product.SpecsJSON, pdd.SpecsJSON)
|
||||
var setting struct{ UpdatedAt time.Time }
|
||||
_ = service.DB.WithContext(ctx).Table((models.AIMatchingSetting{}).TableName()).Select("updated_at").Where("id = ?", 1).Scan(&setting).Error
|
||||
if err := service.DB.WithContext(ctx).Table((models.AIMatchingSetting{}).TableName()).Select("updated_at").Where("id = ?", 1).Scan(&setting).Error; err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
h := sha256.Sum256([]byte(contextVersion + "\x00" + setting.UpdatedAt.UTC().Format(time.RFC3339Nano)))
|
||||
// Keeping the context version as a prefix lets ProcessAutoMatchRun pass the
|
||||
// exact version to #194 without re-reading a potentially drifting input.
|
||||
return contextVersion + hex.EncodeToString(h[:]), true, nil
|
||||
return contextVersion + hex.EncodeToString(h[:]), "", nil
|
||||
}
|
||||
|
||||
func (service *Service) claimAutoMatchWork(ctx context.Context, run models.ShopeeSpecAutoMatchRun, productID uint64, fingerprint string) (models.ShopeeSpecAutoMatchWorkItem, bool, error) {
|
||||
@@ -245,6 +312,9 @@ func (service *Service) claimAutoMatchWork(ctx context.Context, run models.Shope
|
||||
lease := now.Add(autoMatchLeaseDuration)
|
||||
var work models.ShopeeSpecAutoMatchWorkItem
|
||||
err := service.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if err := lockAutoMatchRun(ctx, tx, run); err != nil {
|
||||
return err
|
||||
}
|
||||
err := tx.Where("shopee_product_id = ?", productID).First(&work).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
work = models.ShopeeSpecAutoMatchWorkItem{ShopeeProductID: productID, RunID: &run.ID, InputFingerprint: fingerprint, Status: "running", AttemptCount: 1, LeaseOwner: run.LeaseOwner, LeaseExpiresAt: &lease}
|
||||
@@ -253,11 +323,10 @@ func (service *Service) claimAutoMatchWork(ctx context.Context, run models.Shope
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if work.InputFingerprint == fingerprint {
|
||||
if work.Status == "completed" || work.Status == "unmatched" || work.AttemptCount >= maxAutoMatchAttempts || (work.NextAttemptAt != nil && work.NextAttemptAt.After(now)) || (work.Status == "running" && work.LeaseExpiresAt != nil && work.LeaseExpiresAt.After(now)) {
|
||||
return errWorkNotClaimed
|
||||
}
|
||||
} else {
|
||||
if autoMatchWorkSkip(work, fingerprint, now) != "" {
|
||||
return errWorkNotClaimed
|
||||
}
|
||||
if work.InputFingerprint != fingerprint {
|
||||
work.AttemptCount = 0
|
||||
}
|
||||
updates := map[string]any{"run_id": run.ID, "input_fingerprint": fingerprint, "status": "running", "attempt_count": work.AttemptCount + 1, "next_attempt_at": nil, "lease_owner": run.LeaseOwner, "lease_expires_at": lease, "last_error_code": "", "last_error": ""}
|
||||
@@ -274,7 +343,7 @@ func (service *Service) claimAutoMatchWork(ctx context.Context, run models.Shope
|
||||
|
||||
var errWorkNotClaimed = errors.New("auto match work not claimed")
|
||||
|
||||
func (service *Service) completeAutoMatchWork(work models.ShopeeSpecAutoMatchWorkItem, fingerprint string, confirmed, unmatched int, matchErr error) {
|
||||
func (service *Service) completeAutoMatchWork(ctx context.Context, work models.ShopeeSpecAutoMatchWorkItem, fingerprint string, confirmed, unmatched int, matchErr error) error {
|
||||
now := time.Now().UTC()
|
||||
updates := map[string]any{"input_fingerprint": fingerprint, "lease_owner": "", "lease_expires_at": nil, "confirmed_count": confirmed, "unmatched_count": unmatched}
|
||||
if matchErr == nil {
|
||||
@@ -294,18 +363,189 @@ func (service *Service) completeAutoMatchWork(work models.ShopeeSpecAutoMatchWor
|
||||
updates["next_attempt_at"] = nil
|
||||
}
|
||||
}
|
||||
_ = service.DB.Model(&models.ShopeeSpecAutoMatchWorkItem{}).Where("id = ?", work.ID).Updates(updates).Error
|
||||
return service.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if err := checkAutoMatchRunContext(ctx, tx.Clauses(clause.Locking{Strength: "UPDATE"})); err != nil {
|
||||
return err
|
||||
}
|
||||
result := tx.Model(&models.ShopeeSpecAutoMatchWorkItem{}).Where("id = ? AND status = ? AND lease_owner = ? AND lease_expires_at > ?", work.ID, "running", work.LeaseOwner, now).Updates(updates)
|
||||
return autoMatchOwnedUpdate(result)
|
||||
})
|
||||
}
|
||||
|
||||
func (service *Service) renewAutoMatchRun(run models.ShopeeSpecAutoMatchRun) {
|
||||
lease := time.Now().UTC().Add(autoMatchLeaseDuration)
|
||||
_ = service.DB.Model(&models.ShopeeSpecAutoMatchRun{}).Where("id = ? AND status = ? AND lease_owner = ?", run.ID, "running", run.LeaseOwner).Update("lease_expires_at", lease).Error
|
||||
func (service *Service) renewAutoMatchRun(ctx context.Context, run models.ShopeeSpecAutoMatchRun) error {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
return service.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if err := lockAutoMatchRun(ctx, tx, run); err != nil {
|
||||
return err
|
||||
}
|
||||
result := autoMatchOwnedRun(tx, run).Update("lease_expires_at", time.Now().UTC().Add(autoMatchLeaseDuration))
|
||||
if result.Error == nil && result.RowsAffected == 0 {
|
||||
// MySQL's changed-row count can be zero when datetime precision
|
||||
// rounds a rapid renewal to the stored value. Under the same row
|
||||
// lock, distinguish that no-op from an expired or lost lease.
|
||||
return lockAutoMatchRun(ctx, tx, run)
|
||||
}
|
||||
return autoMatchOwnedUpdate(result)
|
||||
})
|
||||
}
|
||||
|
||||
func (service *Service) finishAutoMatchRun(run models.ShopeeSpecAutoMatchRun, status string, scanned, eligible, processed, confirmed, unmatched, failed int, summary string) error {
|
||||
func (service *Service) finishAutoMatchRun(ctx context.Context, run models.ShopeeSpecAutoMatchRun, stats autoMatchBatchStats, status, reason string, cursor *uint64) error {
|
||||
now := time.Now().UTC()
|
||||
updates := map[string]any{"status": status, "active_slot": nil, "lease_owner": "", "lease_expires_at": nil, "scanned_count": scanned, "eligible_count": eligible, "processed_count": processed, "confirmed_count": confirmed, "unmatched_count": unmatched, "failed_count": failed, "error_summary": truncateBatchText(summary), "finished_at": now}
|
||||
return service.DB.Model(&models.ShopeeSpecAutoMatchRun{}).Where("id = ? AND status = ? AND lease_owner = ?", run.ID, "running", run.LeaseOwner).Updates(updates).Error
|
||||
updates := map[string]any{"status": status, "active_slot": nil, "lease_owner": "", "lease_expires_at": nil, "scanned_count": stats.scanned, "eligible_count": stats.eligible, "processed_count": stats.processed, "confirmed_count": stats.confirmed, "unmatched_count": stats.unmatched, "failed_count": stats.failed, "error_summary": truncateBatchText(stats.summary), "finished_at": now, "resume_after_id": cursor, "stop_reason": reason}
|
||||
return service.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if err := lockAutoMatchRun(ctx, tx, run); err != nil {
|
||||
return err
|
||||
}
|
||||
return autoMatchOwnedUpdate(autoMatchOwnedRun(tx, run).Updates(updates))
|
||||
})
|
||||
}
|
||||
|
||||
var errAutoMatchLeaseLost = errors.New("auto match run lease lost")
|
||||
|
||||
type autoMatchRunContextKey struct{}
|
||||
type autoMatchRunGuard struct {
|
||||
run models.ShopeeSpecAutoMatchRun
|
||||
cancel context.CancelFunc
|
||||
}
|
||||
|
||||
func autoMatchOwnedRun(db *gorm.DB, run models.ShopeeSpecAutoMatchRun) *gorm.DB {
|
||||
return db.Model(&models.ShopeeSpecAutoMatchRun{}).Where("id = ? AND status = ? AND active_slot = ? AND lease_owner = ? AND lease_expires_at > ?", run.ID, "running", 1, run.LeaseOwner, time.Now().UTC())
|
||||
}
|
||||
|
||||
func autoMatchOwnedUpdate(result *gorm.DB) error {
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
if result.RowsAffected != 1 {
|
||||
return errAutoMatchLeaseLost
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func lockAutoMatchRun(ctx context.Context, tx *gorm.DB, run models.ShopeeSpecAutoMatchRun) error {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
var owned models.ShopeeSpecAutoMatchRun
|
||||
err := autoMatchOwnedRun(tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}), run).Take(&owned).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return errAutoMatchLeaseLost
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
if owned.LeaseExpiresAt == nil || !owned.LeaseExpiresAt.After(time.Now().UTC()) {
|
||||
return errAutoMatchLeaseLost
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Only scheduled/manual batch runs attach this context; individual matching
|
||||
// keeps its existing behavior. Reuse the caller's transaction for row locks.
|
||||
func checkAutoMatchRunContext(ctx context.Context, db *gorm.DB) error {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
guard, ok := ctx.Value(autoMatchRunContextKey{}).(autoMatchRunGuard)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
var owned models.ShopeeSpecAutoMatchRun
|
||||
err := autoMatchOwnedRun(db.WithContext(ctx), guard.run).Take(&owned).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
err = errAutoMatchLeaseLost
|
||||
}
|
||||
if err == nil && (owned.LeaseExpiresAt == nil || !owned.LeaseExpiresAt.After(time.Now().UTC())) {
|
||||
err = errAutoMatchLeaseLost
|
||||
}
|
||||
if errors.Is(err, errAutoMatchLeaseLost) {
|
||||
guard.cancel()
|
||||
}
|
||||
if err == nil {
|
||||
err = ctx.Err()
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func autoMatchWorkSkip(work models.ShopeeSpecAutoMatchWorkItem, fingerprint string, now time.Time) string {
|
||||
if work.InputFingerprint != fingerprint {
|
||||
return ""
|
||||
}
|
||||
if work.Status == "completed" || work.Status == "unmatched" {
|
||||
return "unchanged"
|
||||
}
|
||||
if work.AttemptCount >= maxAutoMatchAttempts {
|
||||
return "max_retry"
|
||||
}
|
||||
if work.NextAttemptAt != nil && work.NextAttemptAt.After(now) {
|
||||
return "cooldown"
|
||||
}
|
||||
if work.Status == "running" && work.LeaseExpiresAt != nil && work.LeaseExpiresAt.After(now) {
|
||||
return "occupied"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
type autoMatchBatchStats struct {
|
||||
scanned, eligible, processed, confirmed, unmatched, failed int
|
||||
summary string
|
||||
skips map[string]int
|
||||
checkpointLoaded bool
|
||||
}
|
||||
|
||||
func (stats *autoMatchBatchStats) skip(reason string) {
|
||||
if stats.skips == nil {
|
||||
stats.skips = map[string]int{}
|
||||
}
|
||||
stats.skips[reason]++
|
||||
}
|
||||
|
||||
func (service *Service) endAutoMatchBatch(ctx context.Context, run models.ShopeeSpecAutoMatchRun, stats autoMatchBatchStats, cursor uint64, reason string, cause error) error {
|
||||
checkpoint := &cursor
|
||||
if !stats.checkpointLoaded {
|
||||
checkpoint = nil
|
||||
}
|
||||
status := "completed"
|
||||
if stats.failed > 0 {
|
||||
status = "completed_partial"
|
||||
}
|
||||
if errors.Is(cause, errAutoMatchLeaseLost) || errors.Is(context.Cause(ctx), errAutoMatchLeaseLost) {
|
||||
reason, cause = "lease_lost", errAutoMatchLeaseLost
|
||||
} else if ctx.Err() != nil {
|
||||
reason = "time_budget"
|
||||
}
|
||||
if reason == "lease_lost" || reason == "error" {
|
||||
checkpoint = nil
|
||||
status = "failed"
|
||||
stats.failed++
|
||||
stats.summary = safeBatchError(cause)
|
||||
}
|
||||
// Finalization is the sole exception to the scan deadline: a fresh bounded
|
||||
// context records the last fully decided item after a time-budget stop.
|
||||
finishCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
var finishErr error
|
||||
if reason != "lease_lost" {
|
||||
finishErr = service.finishAutoMatchRun(finishCtx, run, stats, status, reason, checkpoint)
|
||||
}
|
||||
if errors.Is(finishErr, errAutoMatchLeaseLost) {
|
||||
reason = "lease_lost"
|
||||
} else if finishErr != nil {
|
||||
reason = "error"
|
||||
}
|
||||
log.Infof("shopee_spec_auto_match run_id=%d stop_reason=%s scanned=%d eligible=%d processed=%d confirmed=%d unmatched=%d failed=%d skip_no_specs=%d skip_confirmed=%d skip_unchanged=%d skip_max_retry=%d skip_cooldown=%d skip_occupied=%d", run.ID, reason, stats.scanned, stats.eligible, stats.processed, stats.confirmed, stats.unmatched, stats.failed, stats.skips["no_specs"], stats.skips["confirmed"], stats.skips["unchanged"], stats.skips["max_retry"], stats.skips["cooldown"], stats.skips["occupied"])
|
||||
if finishErr != nil {
|
||||
return finishErr
|
||||
}
|
||||
if reason == "time_budget" {
|
||||
return nil
|
||||
}
|
||||
return cause
|
||||
}
|
||||
|
||||
func batchErrorCode(err error) string {
|
||||
|
||||
@@ -3,6 +3,7 @@ package shopeeproduct
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go-admin/app/goauto/models"
|
||||
|
||||
@@ -66,7 +67,8 @@ func TestUnchangedUnmatchedWorkIsNotClaimedAgain(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
service := NewService(db)
|
||||
one := uint8(1)
|
||||
run := models.ShopeeSpecAutoMatchRun{RequestID: uuid.NewString(), Trigger: "manual", Status: "running", ActiveSlot: &one, LeaseOwner: uuid.NewString(), BatchLimit: 20}
|
||||
lease := time.Now().UTC().Add(autoMatchLeaseDuration)
|
||||
run := models.ShopeeSpecAutoMatchRun{RequestID: uuid.NewString(), Trigger: "manual", Status: "running", ActiveSlot: &one, LeaseOwner: uuid.NewString(), LeaseExpiresAt: &lease, BatchLimit: 20}
|
||||
if err := db.Create(&run).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -74,7 +76,9 @@ func TestUnchangedUnmatchedWorkIsNotClaimedAgain(t *testing.T) {
|
||||
if err != nil || !claimed {
|
||||
t.Fatalf("work=%+v claimed=%v err=%v", work, claimed, err)
|
||||
}
|
||||
service.completeAutoMatchWork(work, "fingerprint", 0, 1, nil)
|
||||
if err := service.completeAutoMatchWork(context.Background(), work, "fingerprint", 0, 1, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, claimed, err = service.claimAutoMatchWork(context.Background(), run, 99, "fingerprint")
|
||||
if err != nil || claimed {
|
||||
t.Fatalf("unchanged unmatched claimed=%v err=%v", claimed, err)
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
package shopeeproduct
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gorm.io/driver/mysql"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
)
|
||||
|
||||
// Opt-in, synthetic SELECTs only: no schema selection is required, and no
|
||||
// tables, production rows, migrations or credentials are written or logged.
|
||||
func TestAutoMatchScanMySQLJSONCoarseFilter(t *testing.T) {
|
||||
dsn := os.Getenv("GOAUTO_TEST_MYSQL_READONLY_DSN")
|
||||
if dsn == "" {
|
||||
t.Skip("set GOAUTO_TEST_MYSQL_READONLY_DSN to opt in to read-only MySQL compatibility checks")
|
||||
}
|
||||
db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
|
||||
if err != nil {
|
||||
t.Fatal("cannot connect to opted-in MySQL")
|
||||
}
|
||||
sqlDB, err := db.DB()
|
||||
if err != nil {
|
||||
t.Fatal("cannot access opted-in MySQL connection")
|
||||
}
|
||||
t.Cleanup(func() { sqlDB.Close() })
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
for _, tc := range []struct {
|
||||
name, json string
|
||||
want int
|
||||
}{
|
||||
{"empty_string", `""`, 0}, {"empty_array", `[]`, 0}, {"json_null", `null`, 0}, {"spaced_array", `[ ]`, 0},
|
||||
{"empty_values", `[{"role":"size","values":[]}]`, 1}, {"size_only", sizeScanSpecs, 1},
|
||||
{"color_only", `[{"role":"color","values":[{"name":"黑色"}]}]`, 1},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
var count int
|
||||
if err := db.WithContext(ctx).Raw("SELECT COUNT(*) FROM (SELECT CAST(? AS JSON) AS specs_json) shopee_product WHERE "+autoMatchNonEmptySpecsSQL, tc.json, "", "[]", "null", `""`).Scan(&count).Error; err != nil {
|
||||
t.Fatal("MySQL JSON coarse filter query failed")
|
||||
}
|
||||
if count != tc.want {
|
||||
t.Fatalf("count=%d want=%d", count, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,784 @@
|
||||
package shopeeproduct
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"go-admin/app/goauto/models"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const sizeScanSpecs = `[{"name":"尺码","role":"size","values":[{"name":"XL","source":"import"}]}]`
|
||||
const otherScanSpecs = `[{"name":"材质","role":"other","values":[{"name":"棉","source":"import"}]}]`
|
||||
|
||||
func openScanTestDB(t *testing.T) *gorm.DB {
|
||||
t.Helper()
|
||||
db := openTestDB(t)
|
||||
sqlDB, err := db.DB()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Closing the final connection releases the named in-memory database,
|
||||
// including when go test repeats the same t.Name via -count.
|
||||
t.Cleanup(func() {
|
||||
if err := sqlDB.Close(); err != nil {
|
||||
t.Errorf("close scan test database: %v", err)
|
||||
}
|
||||
})
|
||||
return db
|
||||
}
|
||||
|
||||
func seedScanProducts(t *testing.T, db *gorm.DB, pddID uint64, count int, specs string) []models.ShopeeProduct {
|
||||
t.Helper()
|
||||
products := make([]models.ShopeeProduct, count)
|
||||
for i := range products {
|
||||
products[i] = models.ShopeeProduct{ShopeeItemID: uuid.NewString(), PDDProductID: &pddID, SpecsJSON: specs}
|
||||
}
|
||||
if err := db.CreateInBatches(&products, 100).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return products
|
||||
}
|
||||
|
||||
func runScan(t *testing.T, service *Service, limit int) *AutoMatchRunView {
|
||||
t.Helper()
|
||||
run, created, err := service.StartAutoMatchRun(context.Background(), "manual", uuid.NewString(), nil, limit)
|
||||
if err != nil || !created {
|
||||
t.Fatalf("start: created=%v err=%v", created, err)
|
||||
}
|
||||
if err := service.ProcessAutoMatchRun(context.Background(), run.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
latest, err := service.LatestAutoMatchRun(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return latest
|
||||
}
|
||||
|
||||
func scanCheckpoint(t *testing.T, run *AutoMatchRunView, cursor uint64, reason string) {
|
||||
t.Helper()
|
||||
raw, err := json.Marshal(run)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var fields map[string]any
|
||||
if err := json.Unmarshal(raw, &fields); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if fields["resumeAfterId"] != float64(cursor) || fields["stopReason"] != reason {
|
||||
t.Fatalf("checkpoint got cursor=%v reason=%v; want %d %s", fields["resumeAfterId"], fields["stopReason"], cursor, reason)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutoMatchScanPassesLongEmptyPrefix(t *testing.T) {
|
||||
db := openScanTestDB(t)
|
||||
pdd := seedPDDProduct(t, db, "active")
|
||||
seedScanProducts(t, db, pdd.ID, 1812, `[]`)
|
||||
seedScanProducts(t, db, pdd.ID, 1, sizeScanSpecs)
|
||||
run := runScan(t, NewService(db), 20)
|
||||
if run.ProcessedCount != 1 || run.ScannedCount != 1 || run.ConfirmedCount != 1 {
|
||||
t.Fatalf("run=%+v", run)
|
||||
}
|
||||
scanCheckpoint(t, run, 0, "end_of_scan")
|
||||
}
|
||||
|
||||
func TestAutoMatchScanRotatesAcrossServiceRestart(t *testing.T) {
|
||||
db := openScanTestDB(t)
|
||||
pdd := seedPDDProduct(t, db, "active")
|
||||
prefix := seedScanProducts(t, db, pdd.ID, 2001, otherScanSpecs)
|
||||
seedScanProducts(t, db, pdd.ID, 1, sizeScanSpecs)
|
||||
first := runScan(t, NewService(db), 20)
|
||||
if first.ScannedCount != 2000 || first.ProcessedCount != 0 {
|
||||
t.Fatalf("first=%+v", first)
|
||||
}
|
||||
scanCheckpoint(t, first, prefix[1999].ID, "scan_budget")
|
||||
second := runScan(t, NewService(db), 20)
|
||||
if second.ScannedCount != 2 || second.ProcessedCount != 1 {
|
||||
t.Fatalf("second=%+v", second)
|
||||
}
|
||||
scanCheckpoint(t, second, 0, "end_of_scan")
|
||||
if err := db.Model(&prefix[0]).Update("specs_json", sizeScanSpecs).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
third := runScan(t, NewService(db), 20)
|
||||
if third.ProcessedCount != 1 {
|
||||
t.Fatalf("changed low ID not visited: %+v", third)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutoMatchScanBatchLimitKeepsLastExaminedOnShortPage(t *testing.T) {
|
||||
db := openScanTestDB(t)
|
||||
pdd := seedPDDProduct(t, db, "active")
|
||||
products := seedScanProducts(t, db, pdd.ID, 3, sizeScanSpecs)
|
||||
for i := 0; i < 3; i++ {
|
||||
run := runScan(t, NewService(db), 1)
|
||||
if run.ProcessedCount != 1 || run.ScannedCount != 1 {
|
||||
t.Fatalf("run=%+v", run)
|
||||
}
|
||||
if i < 2 {
|
||||
scanCheckpoint(t, run, products[i].ID, "batch_limit")
|
||||
} else {
|
||||
scanCheckpoint(t, run, 0, "end_of_scan")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutoMatchScanExactBudgetDoesNotAssumeEnd(t *testing.T) {
|
||||
db := openScanTestDB(t)
|
||||
pdd := seedPDDProduct(t, db, "active")
|
||||
products := seedScanProducts(t, db, pdd.ID, 2000, otherScanSpecs)
|
||||
first := runScan(t, NewService(db), 20)
|
||||
scanCheckpoint(t, first, products[1999].ID, "scan_budget")
|
||||
second := runScan(t, NewService(db), 20)
|
||||
scanCheckpoint(t, second, 0, "end_of_scan")
|
||||
if second.ScannedCount != 0 {
|
||||
t.Fatalf("second scanned %d", second.ScannedCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutoMatchEligibilityRejectsUnusableSpecsBeforePDDRead(t *testing.T) {
|
||||
for i, specs := range []string{"", `[]`, `null`, `""`, otherScanSpecs, `[{"role":"size","values":[]}]`, `[{"role":"color","values":[{"name":" "}]}]`} {
|
||||
t.Run(fmt.Sprint(i), func(t *testing.T) {
|
||||
db := openScanTestDB(t)
|
||||
missing := uint64(999)
|
||||
_, eligible, err := NewService(db).autoMatchEligibility(context.Background(), models.ShopeeProduct{PDDProductID: &missing, SpecsJSON: specs})
|
||||
if err != nil || eligible {
|
||||
t.Fatalf("eligible=%v err=%v", eligible, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutoMatchScanEmptyRepresentationsAndSingleDimension(t *testing.T) {
|
||||
db := openScanTestDB(t)
|
||||
pdd := seedPDDProduct(t, db, "active")
|
||||
for _, specs := range []string{`[]`, `null`, `""`} {
|
||||
seedScanProducts(t, db, pdd.ID, 1, specs)
|
||||
}
|
||||
seedScanProducts(t, db, pdd.ID, 1, `[{"name":"颜色","role":"color","values":[{"name":"黑色","source":"import"}]}]`)
|
||||
seedScanProducts(t, db, pdd.ID, 1, sizeScanSpecs)
|
||||
run := runScan(t, NewService(db), 20)
|
||||
if run.ScannedCount != 2 || run.ProcessedCount != 2 || run.ConfirmedCount != 2 {
|
||||
t.Fatalf("run=%+v", run)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutoMatchScanLatestCommittedZeroWinsAndNullIsIgnored(t *testing.T) {
|
||||
for _, latest := range []uint64{0, 2} {
|
||||
t.Run(fmt.Sprint(latest), func(t *testing.T) {
|
||||
db := openScanTestDB(t)
|
||||
pdd := seedPDDProduct(t, db, "active")
|
||||
products := seedScanProducts(t, db, pdd.ID, 3, otherScanSpecs)
|
||||
old := uint64(1)
|
||||
for _, checkpoint := range []*uint64{&old, &latest, nil} {
|
||||
run := models.ShopeeSpecAutoMatchRun{RequestID: uuid.NewString(), Trigger: "manual", Status: "completed", ResumeAfterID: checkpoint}
|
||||
if err := db.Create(&run).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
run := runScan(t, NewService(db), 20)
|
||||
if run.ScannedCount != len(products)-int(latest) {
|
||||
t.Fatalf("wrong checkpoint: %+v", run)
|
||||
}
|
||||
scanCheckpoint(t, run, 0, "end_of_scan")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutoMatchRenewAndFinishRejectLostLease(t *testing.T) {
|
||||
for _, change := range []string{"owner", "expired", "slot", "status"} {
|
||||
t.Run(change, func(t *testing.T) {
|
||||
db := openScanTestDB(t)
|
||||
s := NewService(db)
|
||||
run, _, err := s.StartAutoMatchRun(context.Background(), "manual", uuid.NewString(), nil, 20)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
updates := map[string]any{}
|
||||
switch change {
|
||||
case "owner":
|
||||
updates["lease_owner"] = "new-owner"
|
||||
case "expired":
|
||||
updates["lease_expires_at"] = time.Now().UTC().Add(-time.Second)
|
||||
case "slot":
|
||||
updates["active_slot"] = nil
|
||||
case "status":
|
||||
updates["status"] = "failed"
|
||||
}
|
||||
if err := db.Model(&models.ShopeeSpecAutoMatchRun{}).Where("id = ?", run.ID).Updates(updates).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.renewAutoMatchRun(context.Background(), run.ShopeeSpecAutoMatchRun); err != errAutoMatchLeaseLost {
|
||||
t.Fatalf("renew=%v", err)
|
||||
}
|
||||
cursor := uint64(999)
|
||||
if err := s.finishAutoMatchRun(context.Background(), run.ShopeeSpecAutoMatchRun, autoMatchBatchStats{}, "completed", "end_of_scan", &cursor); err != errAutoMatchLeaseLost {
|
||||
t.Fatalf("finish=%v", err)
|
||||
}
|
||||
var current models.ShopeeSpecAutoMatchRun
|
||||
if err := db.First(¤t, run.ID).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if current.ResumeAfterID != nil || current.StopReason != "" {
|
||||
t.Fatalf("old owner committed: %+v", current)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutoMatchScanDatabaseErrorDoesNotCommitCheckpoint(t *testing.T) {
|
||||
db := openScanTestDB(t)
|
||||
pdd := seedPDDProduct(t, db, "active")
|
||||
products := seedScanProducts(t, db, pdd.ID, 2, sizeScanSpecs)
|
||||
s := NewService(db)
|
||||
run, _, err := s.StartAutoMatchRun(context.Background(), "manual", uuid.NewString(), nil, 20)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Callback().Row().Before("gorm:row").Register("test_settings_error", func(tx *gorm.DB) {
|
||||
if tx.Statement.Table == "ai_matching_setting" {
|
||||
tx.AddError(fmt.Errorf("synthetic database error"))
|
||||
}
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.ProcessAutoMatchRun(context.Background(), run.ID); err == nil {
|
||||
t.Fatal("database error ignored")
|
||||
}
|
||||
if err := db.Callback().Row().Remove("test_settings_error"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
latest, err := s.LatestAutoMatchRun(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if latest.Status != "failed" || latest.StopReason != "error" || latest.ResumeAfterID != nil {
|
||||
t.Fatalf("latest=%+v", latest)
|
||||
}
|
||||
next := runScan(t, NewService(db), 20)
|
||||
if next.ProcessedCount != len(products) {
|
||||
t.Fatalf("restart=%+v", next)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutoMatchClaimRejectsLostOrExpiredRun(t *testing.T) {
|
||||
for _, change := range []string{"owner", "expired", "slot", "status"} {
|
||||
t.Run(change, func(t *testing.T) {
|
||||
db := openScanTestDB(t)
|
||||
s := NewService(db)
|
||||
run, _, err := s.StartAutoMatchRun(context.Background(), "manual", uuid.NewString(), nil, 20)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
updates := map[string]any{}
|
||||
switch change {
|
||||
case "owner":
|
||||
updates["lease_owner"] = uuid.NewString()
|
||||
case "expired":
|
||||
updates["lease_expires_at"] = time.Now().UTC().Add(-time.Second)
|
||||
case "slot":
|
||||
updates["active_slot"] = nil
|
||||
case "status":
|
||||
updates["status"] = "failed"
|
||||
}
|
||||
if err := db.Model(&models.ShopeeSpecAutoMatchRun{}).Where("id = ?", run.ID).Updates(updates).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, claimed, err := s.claimAutoMatchWork(context.Background(), run.ShopeeSpecAutoMatchRun, 999, "fingerprint")
|
||||
if err == nil || claimed {
|
||||
t.Fatalf("lost run claimed=%v err=%v", claimed, err)
|
||||
}
|
||||
var count int64
|
||||
if err := db.Model(&models.ShopeeSpecAutoMatchWorkItem{}).Count(&count).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if count != 0 {
|
||||
t.Fatalf("lost run wrote work: %d", count)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutoMatchScanOwnerLossDuringProviderStopsNextCallAndSave(t *testing.T) {
|
||||
db := openScanTestDB(t)
|
||||
s := NewService(db)
|
||||
pdd := seedPDDProduct(t, db, "active")
|
||||
products := seedScanProducts(t, db, pdd.ID, 2, `[{"name":"颜色","role":"color","values":[{"name":"深黑","source":"import"}]},{"name":"尺码","role":"size","values":[{"name":"大号","source":"import"}]}]`)
|
||||
run, _, err := s.StartAutoMatchRun(context.Background(), "manual", uuid.NewString(), nil, 20)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var calls atomic.Int32
|
||||
provider := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
calls.Add(1)
|
||||
if err := db.Model(&models.ShopeeSpecAutoMatchRun{}).Where("id = ?", run.ID).Update("lease_owner", "replacement-owner").Error; err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
chatCompletionResponder(`{"suggestions":[{"sourceId":"s1","candidateId":"c1","confidence":0.96,"reason":"unique match"}]}`)(w, r)
|
||||
}))
|
||||
defer provider.Close()
|
||||
setting := seedEnabledAISetting(t, provider.URL, 0.9)
|
||||
if err := db.Create(&setting).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.ProcessAutoMatchRun(context.Background(), run.ID); err == nil {
|
||||
t.Fatal("owner loss must be returned")
|
||||
}
|
||||
if calls.Load() != 1 {
|
||||
t.Fatalf("provider calls after owner loss: %d", calls.Load())
|
||||
}
|
||||
var current models.ShopeeSpecAutoMatchRun
|
||||
if err := db.First(¤t, run.ID).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if current.Status != "running" || current.LeaseOwner != "replacement-owner" {
|
||||
t.Fatalf("old owner overwrote run: %+v", current)
|
||||
}
|
||||
var product models.ShopeeProduct
|
||||
if err := db.First(&product, products[0].ID).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if product.SpecsJSON != products[0].SpecsJSON {
|
||||
t.Fatal("old owner saved mapping")
|
||||
}
|
||||
var count int64
|
||||
if err := db.Model(&models.ShopeeSpecAutoMatchWorkItem{}).Count(&count).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if count != 1 {
|
||||
t.Fatalf("unexpected work claims: %d", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutoMatchScanDeadlineStopsProviderAndKeepsLastDecision(t *testing.T) {
|
||||
db := openScanTestDB(t)
|
||||
pdd := seedPDDProduct(t, db, "active")
|
||||
first := seedScanProducts(t, db, pdd.ID, 1, otherScanSpecs)[0]
|
||||
products := seedScanProducts(t, db, pdd.ID, 2, `[{"name":"颜色","role":"color","values":[{"name":"深黑","source":"import"}]}]`)
|
||||
var calls atomic.Int32
|
||||
provider := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
calls.Add(1)
|
||||
select {
|
||||
case <-r.Context().Done():
|
||||
case <-time.After(time.Second):
|
||||
}
|
||||
}))
|
||||
defer provider.Close()
|
||||
setting := seedEnabledAISetting(t, provider.URL, 0.9)
|
||||
if err := db.Create(&setting).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s := NewService(db)
|
||||
run, _, err := s.StartAutoMatchRun(context.Background(), "manual", uuid.NewString(), nil, 20)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 150*time.Millisecond)
|
||||
defer cancel()
|
||||
started := time.Now()
|
||||
if err := s.ProcessAutoMatchRun(ctx, run.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if time.Since(started) > time.Second {
|
||||
t.Fatal("provider outlived batch deadline")
|
||||
}
|
||||
if calls.Load() != 1 {
|
||||
t.Fatalf("provider calls=%d", calls.Load())
|
||||
}
|
||||
latest, err := s.LatestAutoMatchRun(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
scanCheckpoint(t, latest, first.ID, "time_budget")
|
||||
if latest.ScannedCount != 2 || latest.ProcessedCount != 1 || latest.FailedCount != 0 {
|
||||
t.Fatalf("latest=%+v", latest)
|
||||
}
|
||||
var current models.ShopeeProduct
|
||||
if err := db.First(¤t, products[0].ID).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if current.SpecsJSON != products[0].SpecsJSON {
|
||||
t.Fatal("timeout saved mapping")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutoMatchScanDeadlineBoundsDatabaseAndNoClaimAfterBudget(t *testing.T) {
|
||||
db := openScanTestDB(t)
|
||||
pdd := seedPDDProduct(t, db, "active")
|
||||
seedScanProducts(t, db, pdd.ID, 2, sizeScanSpecs)
|
||||
s := NewService(db)
|
||||
run, _, err := s.StartAutoMatchRun(context.Background(), "manual", uuid.NewString(), nil, 20)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
|
||||
defer cancel()
|
||||
queries := 0
|
||||
if err := db.Callback().Query().Before("gorm:query").Register("test_database_deadline", func(tx *gorm.DB) {
|
||||
deadline, ok := tx.Statement.Context.Deadline()
|
||||
if !ok || time.Until(deadline) > autoMatchTimeBudget {
|
||||
t.Error("database missed total deadline")
|
||||
}
|
||||
if tx.Statement.Table == "pdd_product" {
|
||||
queries++
|
||||
<-tx.Statement.Context.Done()
|
||||
tx.AddError(tx.Statement.Context.Err())
|
||||
}
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.ProcessAutoMatchRun(ctx, run.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Callback().Query().Remove("test_database_deadline"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
latest, err := s.LatestAutoMatchRun(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
scanCheckpoint(t, latest, 0, "time_budget")
|
||||
if queries != 1 || latest.ProcessedCount != 0 || latest.ScannedCount != 1 {
|
||||
t.Fatalf("queries=%d latest=%+v", queries, latest)
|
||||
}
|
||||
var count int64
|
||||
if err := db.Model(&models.ShopeeSpecAutoMatchWorkItem{}).Count(&count).Error; err != nil || count != 0 {
|
||||
t.Fatalf("work=%d err=%v", count, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutoMatchScanRecoveryIgnoresExpiredCheckpoint(t *testing.T) {
|
||||
db := openScanTestDB(t)
|
||||
s := NewService(db)
|
||||
run, _, err := s.StartAutoMatchRun(context.Background(), "manual", uuid.NewString(), nil, 20)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Model(&models.ShopeeSpecAutoMatchRun{}).Where("id = ?", run.ID).Update("lease_expires_at", time.Now().UTC().Add(-time.Second)).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, created, err := s.StartAutoMatchRun(context.Background(), "scheduled", uuid.NewString(), nil, 20); err != nil || !created {
|
||||
t.Fatalf("recovery created=%v err=%v", created, err)
|
||||
}
|
||||
var old models.ShopeeSpecAutoMatchRun
|
||||
if err := db.First(&old, run.ID).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if old.Status != "failed" || old.StopReason != "lease_lost" || old.ResumeAfterID != nil {
|
||||
t.Fatalf("old=%+v", old)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutoMatchScanExactPageBoundaryAndCandidateFilters(t *testing.T) {
|
||||
db := openScanTestDB(t)
|
||||
pdd := seedPDDProduct(t, db, "active")
|
||||
seedScanProducts(t, db, pdd.ID, 200, otherScanSpecs)
|
||||
seedScanProducts(t, db, pdd.ID, 1, sizeScanSpecs)
|
||||
disabled := seedPDDProduct(t, db, "disabled")
|
||||
seedScanProducts(t, db, disabled.ID, 1, sizeScanSpecs)
|
||||
deleted := seedScanProducts(t, db, pdd.ID, 1, sizeScanSpecs)[0]
|
||||
if err := db.Delete(&deleted).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
unlinked := models.ShopeeProduct{ShopeeItemID: uuid.NewString(), SpecsJSON: sizeScanSpecs}
|
||||
if err := db.Create(&unlinked).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
run := runScan(t, NewService(db), 20)
|
||||
if run.ScannedCount != 201 || run.ProcessedCount != 1 {
|
||||
t.Fatalf("run=%+v", run)
|
||||
}
|
||||
scanCheckpoint(t, run, 0, "end_of_scan")
|
||||
}
|
||||
|
||||
func TestAutoMatchWorkRetryAndCooldownPreserved(t *testing.T) {
|
||||
for _, status := range []string{"completed", "unmatched", "max_retry", "cooldown", "occupied", "retryable"} {
|
||||
t.Run(status, func(t *testing.T) {
|
||||
db := openScanTestDB(t)
|
||||
s := NewService(db)
|
||||
run, _, err := s.StartAutoMatchRun(context.Background(), "manual", uuid.NewString(), nil, 20)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
future := time.Now().UTC().Add(time.Hour)
|
||||
work := models.ShopeeSpecAutoMatchWorkItem{ShopeeProductID: 99, InputFingerprint: "same", Status: status, AttemptCount: 1}
|
||||
switch status {
|
||||
case "max_retry":
|
||||
work.Status, work.AttemptCount = "failed", 3
|
||||
case "cooldown":
|
||||
work.Status, work.NextAttemptAt = "failed", &future
|
||||
case "occupied":
|
||||
work.Status, work.LeaseExpiresAt = "running", &future
|
||||
case "retryable":
|
||||
work.Status = "failed"
|
||||
}
|
||||
if err := db.Create(&work).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, claimed, err := s.claimAutoMatchWork(context.Background(), run.ShopeeSpecAutoMatchRun, 99, "same")
|
||||
if err != nil || claimed != (status == "retryable") {
|
||||
t.Fatalf("claimed=%v err=%v", claimed, err)
|
||||
}
|
||||
if claimed {
|
||||
if got.AttemptCount != 2 {
|
||||
t.Fatalf("attempts=%d", got.AttemptCount)
|
||||
}
|
||||
if err := s.completeAutoMatchWork(context.Background(), got, "same", 0, 0, aiUnavailable("synthetic unavailable")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var saved models.ShopeeSpecAutoMatchWorkItem
|
||||
if err := db.First(&saved, got.ID).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if saved.Status != "failed" || saved.NextAttemptAt == nil || saved.LastErrorCode != CodeAIUnavailable {
|
||||
t.Fatalf("saved=%+v", saved)
|
||||
}
|
||||
}
|
||||
changed, claimed, err := s.claimAutoMatchWork(context.Background(), run.ShopeeSpecAutoMatchRun, 99, "changed")
|
||||
if err != nil || !claimed || changed.AttemptCount != 1 {
|
||||
t.Fatalf("changed=%+v claimed=%v err=%v", changed, claimed, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutoMatchClaimDoesNotUseLeaseTimeBeforeLockWait(t *testing.T) {
|
||||
db := openScanTestDB(t)
|
||||
s := NewService(db)
|
||||
run, _, err := s.StartAutoMatchRun(context.Background(), "manual", uuid.NewString(), nil, 20)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
expiry := time.Now().UTC().Add(50 * time.Millisecond)
|
||||
if err := db.Model(&models.ShopeeSpecAutoMatchRun{}).Where("id = ?", run.ID).Update("lease_expires_at", expiry).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Callback().Query().Before("gorm:query").Register("test_lock_wait", func(tx *gorm.DB) {
|
||||
if tx.Statement.Table == "shopee_spec_auto_match_run" {
|
||||
time.Sleep(time.Until(expiry) + 10*time.Millisecond)
|
||||
}
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, claimed, err := s.claimAutoMatchWork(context.Background(), run.ShopeeSpecAutoMatchRun, 99, "same")
|
||||
if err != errAutoMatchLeaseLost || claimed {
|
||||
t.Fatalf("claimed=%v err=%v", claimed, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutoMatchProviderGuardDatabaseErrorMustNotRetryOrCheckpoint(t *testing.T) {
|
||||
db := openScanTestDB(t)
|
||||
pdd := seedPDDProduct(t, db, "active")
|
||||
seedScanProducts(t, db, pdd.ID, 1, `[{"name":"颜色","role":"color","values":[{"name":"深黑","source":"import"}]}]`)
|
||||
var calls atomic.Int32
|
||||
provider := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
calls.Add(1)
|
||||
chatCompletionResponder(`{"suggestions":[{"sourceId":"s1","candidateId":"c1","confidence":0.96,"reason":"unique match"}]}`)(w, r)
|
||||
}))
|
||||
defer provider.Close()
|
||||
setting := seedEnabledAISetting(t, provider.URL, 0.9)
|
||||
if err := db.Create(&setting).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s := NewService(db)
|
||||
run, _, err := s.StartAutoMatchRun(context.Background(), "manual", uuid.NewString(), nil, 20)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// The settings read in suggestMappings immediately precedes its provider guard.
|
||||
armed, injected := false, false
|
||||
if err := db.Callback().Query().Before("gorm:query").Register("test_provider_guard_error", func(tx *gorm.DB) {
|
||||
if tx.Statement.Table == "ai_matching_setting" {
|
||||
armed = true
|
||||
}
|
||||
if armed && !injected && tx.Statement.Table == "shopee_spec_auto_match_run" {
|
||||
injected = true
|
||||
tx.AddError(fmt.Errorf("synthetic provider guard database error"))
|
||||
}
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
err = s.ProcessAutoMatchRun(context.Background(), run.ID)
|
||||
if err == nil || !injected || calls.Load() != 0 {
|
||||
t.Fatalf("err=%v injected=%v calls=%d", err, injected, calls.Load())
|
||||
}
|
||||
latest, err := s.LatestAutoMatchRun(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if latest.ResumeAfterID != nil || latest.StopReason != "error" {
|
||||
t.Fatalf("latest=%+v", latest)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutoMatchProviderSettingsDatabaseErrorMustNotRetryOrCheckpoint(t *testing.T) {
|
||||
db := openScanTestDB(t)
|
||||
pdd := seedPDDProduct(t, db, "active")
|
||||
seedScanProducts(t, db, pdd.ID, 1, `[{"name":"颜色","role":"color","values":[{"name":"深黑","source":"import"}]}]`)
|
||||
var calls atomic.Int32
|
||||
provider := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
calls.Add(1)
|
||||
chatCompletionResponder(`{"suggestions":[{"sourceId":"s1","candidateId":"c1","confidence":0.96,"reason":"unique match"}]}`)(w, r)
|
||||
}))
|
||||
defer provider.Close()
|
||||
setting := seedEnabledAISetting(t, provider.URL, 0.9)
|
||||
if err := db.Create(&setting).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s := NewService(db)
|
||||
run, _, err := s.StartAutoMatchRun(context.Background(), "manual", uuid.NewString(), nil, 20)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
settingReads := 0
|
||||
if err := db.Callback().Query().Before("gorm:query").Register("test_provider_settings_error", func(tx *gorm.DB) {
|
||||
if tx.Statement.Table == "ai_matching_setting" {
|
||||
settingReads++
|
||||
if settingReads == 2 {
|
||||
tx.AddError(fmt.Errorf("synthetic nested settings error"))
|
||||
}
|
||||
}
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
err = s.ProcessAutoMatchRun(context.Background(), run.ID)
|
||||
if err == nil || settingReads != 2 || calls.Load() != 0 {
|
||||
t.Fatalf("err=%v settings_reads=%d calls=%d", err, settingReads, calls.Load())
|
||||
}
|
||||
latest, err := s.LatestAutoMatchRun(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if latest.ResumeAfterID != nil || latest.StopReason != "error" {
|
||||
t.Fatalf("latest=%+v", latest)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutoMatchRunDatabaseWriteFailuresAreReturned(t *testing.T) {
|
||||
for _, operation := range []string{"renew", "finish", "work"} {
|
||||
t.Run(operation, func(t *testing.T) {
|
||||
db := openScanTestDB(t)
|
||||
s := NewService(db)
|
||||
run, _, err := s.StartAutoMatchRun(context.Background(), "manual", uuid.NewString(), nil, 20)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
work, _, err := s.claimAutoMatchWork(context.Background(), run.ShopeeSpecAutoMatchRun, 99, "same")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
injected := fmt.Errorf("synthetic update failure")
|
||||
if err := db.Callback().Update().Before("gorm:update").Register("test_update_error", func(tx *gorm.DB) { tx.AddError(injected) }); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
switch operation {
|
||||
case "renew":
|
||||
err = s.renewAutoMatchRun(context.Background(), run.ShopeeSpecAutoMatchRun)
|
||||
case "finish":
|
||||
cursor := uint64(99)
|
||||
err = s.finishAutoMatchRun(context.Background(), run.ShopeeSpecAutoMatchRun, autoMatchBatchStats{}, "completed", "end_of_scan", &cursor)
|
||||
case "work":
|
||||
err = s.completeAutoMatchWork(context.Background(), work, "same", 1, 0, nil)
|
||||
}
|
||||
if err != injected {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
var saved models.ShopeeSpecAutoMatchRun
|
||||
if err := db.First(&saved, run.ID).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if saved.ResumeAfterID != nil || saved.Status != "running" {
|
||||
t.Fatalf("saved=%+v", saved)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutoMatchDeadlineDuringCheckpointReadCannotCommitFalseHead(t *testing.T) {
|
||||
db := openScanTestDB(t)
|
||||
s := NewService(db)
|
||||
checkpoint := uint64(1700)
|
||||
old := models.ShopeeSpecAutoMatchRun{RequestID: uuid.NewString(), Trigger: "manual", Status: "completed", ResumeAfterID: &checkpoint}
|
||||
if err := db.Create(&old).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
run, _, err := s.StartAutoMatchRun(context.Background(), "manual", uuid.NewString(), nil, 20)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
queries := 0
|
||||
if err := db.Callback().Query().Before("gorm:query").Register("test_checkpoint_timeout", func(tx *gorm.DB) {
|
||||
if tx.Statement.Table == "shopee_spec_auto_match_run" {
|
||||
queries++
|
||||
if queries == 2 {
|
||||
<-tx.Statement.Context.Done()
|
||||
tx.AddError(tx.Statement.Context.Err())
|
||||
}
|
||||
}
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
|
||||
defer cancel()
|
||||
if err := s.ProcessAutoMatchRun(ctx, run.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
latest, err := s.LatestAutoMatchRun(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if latest.ResumeAfterID != nil || latest.StopReason != "time_budget" {
|
||||
t.Fatalf("unknown cursor committed: %+v", latest)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutoMatchRenewZeroChangedRowsRequiresLiveOwnership(t *testing.T) {
|
||||
for _, change := range []string{"unchanged", "expired", "owner", "multiple_rows"} {
|
||||
t.Run(change, func(t *testing.T) {
|
||||
db := openScanTestDB(t)
|
||||
s := NewService(db)
|
||||
run, _, err := s.StartAutoMatchRun(context.Background(), "manual", uuid.NewString(), nil, 20)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Callback().Update().After("gorm:update").Register("test_renew_zero_changed", func(tx *gorm.DB) {
|
||||
if tx.Statement.Table != "shopee_spec_auto_match_run" || tx.Error != nil {
|
||||
return
|
||||
}
|
||||
// MySQL reports changed rows by default: datetime(3) may round a
|
||||
// same-millisecond renewal to the value already stored.
|
||||
switch change {
|
||||
case "expired":
|
||||
err = tx.Session(&gorm.Session{NewDB: true}).Exec("UPDATE shopee_spec_auto_match_run SET lease_expires_at = ? WHERE id = ?", time.Now().UTC().Add(-time.Second), run.ID).Error
|
||||
case "owner":
|
||||
err = tx.Session(&gorm.Session{NewDB: true}).Exec("UPDATE shopee_spec_auto_match_run SET lease_owner = ? WHERE id = ?", "replacement-owner", run.ID).Error
|
||||
}
|
||||
if err != nil {
|
||||
tx.AddError(err)
|
||||
}
|
||||
tx.RowsAffected = 0
|
||||
if change == "multiple_rows" {
|
||||
tx.RowsAffected = 2
|
||||
}
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
err = s.renewAutoMatchRun(context.Background(), run.ShopeeSpecAutoMatchRun)
|
||||
if change == "unchanged" {
|
||||
if err != nil {
|
||||
t.Fatalf("live no-op renewal rejected: %v", err)
|
||||
}
|
||||
} else if err != errAutoMatchLeaseLost {
|
||||
t.Fatalf("lost lease accepted after zero changed rows: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
package version_local
|
||||
|
||||
import (
|
||||
"go-admin/app/goauto/models"
|
||||
"go-admin/cmd/migrate/migration"
|
||||
common "go-admin/common/models"
|
||||
"gorm.io/gorm"
|
||||
"runtime"
|
||||
)
|
||||
|
||||
func init() {
|
||||
_, file, _, _ := runtime.Caller(0)
|
||||
migration.Migrate.SetVersion(migration.GetFilename(file), migrateShopeeSpecAutoMatchResume)
|
||||
}
|
||||
|
||||
func migrateShopeeSpecAutoMatchResume(db *gorm.DB, version string) error {
|
||||
return db.Transaction(func(tx *gorm.DB) error {
|
||||
if !tx.Migrator().HasColumn(&models.ShopeeSpecAutoMatchRun{}, "ResumeAfterID") {
|
||||
// GORM AddColumn omits CHECK tags. Inline the portable constraint so
|
||||
// this stays additive (SQLite otherwise rebuilds tables for checks).
|
||||
if err := tx.Exec("ALTER TABLE shopee_spec_auto_match_run ADD COLUMN resume_after_id BIGINT NULL CONSTRAINT ck_shopee_spec_auto_match_resume CHECK (resume_after_id IS NULL OR resume_after_id >= 0)").Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if !tx.Migrator().HasColumn(&models.ShopeeSpecAutoMatchRun{}, "StopReason") {
|
||||
if err := tx.Migrator().AddColumn(&models.ShopeeSpecAutoMatchRun{}, "StopReason"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return tx.Where("version = ?", version).FirstOrCreate(&common.Migration{Version: version}).Error
|
||||
})
|
||||
}
|
||||
+79
@@ -0,0 +1,79 @@
|
||||
package version_local
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"go-admin/app/goauto/models"
|
||||
common "go-admin/common/models"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func TestMigrateShopeeSpecAutoMatchResumePreservesLegacyAndIsIdempotent(t *testing.T) {
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sqlDB, _ := db.DB()
|
||||
t.Cleanup(func() { sqlDB.Close() })
|
||||
if err := db.Exec("CREATE TABLE shopee_spec_auto_match_run (id integer primary key, status varchar(24) NOT NULL)").Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Exec("INSERT INTO shopee_spec_auto_match_run(id,status) VALUES(1,'completed')").Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.AutoMigrate(&common.Migration{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for i := 0; i < 2; i++ {
|
||||
if err := migrateShopeeSpecAutoMatchResume(db, "test_auto_match_resume"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
var row models.ShopeeSpecAutoMatchRun
|
||||
if err := db.First(&row, 1).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if row.Status != "completed" || row.ResumeAfterID != nil || row.StopReason != "" {
|
||||
t.Fatalf("legacy changed: %+v", row)
|
||||
}
|
||||
columns, err := db.Migrator().ColumnTypes(&row)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(columns) != 4 {
|
||||
t.Fatalf("migration must append exactly two columns: %d", len(columns))
|
||||
}
|
||||
if err := db.Model(&row).UpdateColumns(map[string]any{"resume_after_id": 0, "stop_reason": "end_of_scan"}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := migrateShopeeSpecAutoMatchResume(db, "test_auto_match_resume"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
row = models.ShopeeSpecAutoMatchRun{}
|
||||
if err := db.First(&row, 1).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if row.ResumeAfterID == nil || *row.ResumeAfterID != 0 || row.StopReason != "end_of_scan" {
|
||||
t.Fatalf("zero overwritten: %+v", row)
|
||||
}
|
||||
var count int64
|
||||
if err := db.Model(&common.Migration{}).Count(&count).Error; err != nil || count != 1 {
|
||||
t.Fatalf("versions=%d err=%v", count, err)
|
||||
}
|
||||
raw, err := json.Marshal(row)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var oldClient struct {
|
||||
ID uint64 `json:"id"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &oldClient); err != nil || oldClient.ID != 1 || oldClient.Status != "completed" {
|
||||
t.Fatalf("old client=%+v err=%v", oldClient, err)
|
||||
}
|
||||
if err := db.Exec("UPDATE shopee_spec_auto_match_run SET resume_after_id = -1 WHERE id = 1").Error; err == nil {
|
||||
t.Fatal("negative checkpoint accepted")
|
||||
}
|
||||
}
|
||||
@@ -68,7 +68,7 @@ import { createRequestId } from '@/utils/request-id'
|
||||
export default {
|
||||
name: 'GoAutoSybInnerCodes',
|
||||
setup() { return { FolderOpened, RefreshLeft, Search, Upload } },
|
||||
data() { const now = new Date(); const today = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`; return { loading: false, importing: false, busy: false, matchSubmitting: false, loadError: '', items: [], total: 0, selected: [], selectedFile: null, dateRange: [today, today], rowActionId: 0, pollTimer: null, activeJob: null, activeBatch: null, query: { page: 1, pageSize: 100, dateFrom: today, dateTo: today, keyword: '' }, applyDialog: { open: false, loading: false, saving: false, ready: false, error: '', ids: [], selectedCount: 0, preview: {}}, deleteDialog: { open: false, saving: false }, detail: { open: false, loading: false, item: null }} },
|
||||
data() { const now = new Date(); const today = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`; return { loading: false, importing: false, busy: false, matchSubmitting: false, loadError: '', items: [], total: 0, selected: [], selectedFile: null, dateRange: [today, today], rowActionId: 0, pollTimer: null, activeJob: null, activeBatch: null, query: { page: 1, pageSize: 200, dateFrom: today, dateTo: today, keyword: '' }, applyDialog: { open: false, loading: false, saving: false, ready: false, error: '', ids: [], selectedCount: 0, preview: {}}, deleteDialog: { open: false, saving: false }, detail: { open: false, loading: false, item: null }} },
|
||||
computed: {
|
||||
matchingBusy() { return this.matchSubmitting || ['pending', 'running'].includes(this.activeJob?.status) },
|
||||
matchEligibleRows() { const ids = new Set(this.selected.map(item => item.id)); return this.items.filter(item => ids.has(item.id) && ['pending', 'failed', 'skipped'].includes(item.status)) },
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import { expect, test } from '@playwright/test'
|
||||
|
||||
test('page-local 200 default, pagination, filters, selection and other-page isolation', async ({ page, context }) => {
|
||||
await page.setViewportSize({ width: 1920, height: 1080 })
|
||||
await context.addCookies([{ name: 'Admin-Token', value: 'isolated-test-token', domain: 'localhost', path: '/' }])
|
||||
const lists: URL[] = []; const shopLists: URL[] = []; const previews: number[][] = []; const writes: string[] = []
|
||||
const rows = Array.from({ length: 205 }, (_, i) => ({ id: i + 1, businessDate: '2026-10-07', orderNumber: `MOCK-${i + 1}`, status: 'ready', specRaw: '模拟规格', items: [{ ordinal: 1, code: `CODE-${i + 1}` }] }))
|
||||
await page.route('**/api/**', async route => {
|
||||
const request = route.request(); const url = new URL(request.url()); const path = url.pathname
|
||||
if (path.startsWith('/src/api/')) return route.continue()
|
||||
const reply = (data: unknown) => route.fulfill({ json: { code: 200, data } })
|
||||
if (path.endsWith('/api/v1/getinfo')) return reply({ roles: ['purchaser'], name: '模拟采购员', avatar: '', permissions: [] })
|
||||
if (path.endsWith('/api/v1/menurole')) return reply([{ path: '/workbench', component: 'Layout', menuName: 'Workbench', title: '工作台', visible: '0', children: [
|
||||
{ path: '/syb-inner-codes', component: '/goauto/syb-inner-codes/index', menuName: 'GoAutoSybInnerCodes', title: '档口入库码', visible: '0' },
|
||||
{ path: '/syb-shops', component: '/goauto/syb-shops/index', menuName: 'GoAutoSybShops', title: '店铺管理', visible: '0' }
|
||||
] }])
|
||||
if (path.endsWith('/syb-inner-codes') && request.method() === 'GET') {
|
||||
lists.push(url)
|
||||
const pageNo = Number(url.searchParams.get('page')); const size = Number(url.searchParams.get('pageSize'))
|
||||
const filtered = url.searchParams.get('keyword') === 'empty' ? [] : rows
|
||||
return reply({ items: filtered.slice((pageNo - 1) * size, pageNo * size), total: filtered.length, page: pageNo, pageSize: size })
|
||||
}
|
||||
if (path.endsWith('/apply-preview')) {
|
||||
const ids = request.postDataJSON().ids; previews.push(ids)
|
||||
return reply({ records: ids.length, inboundCodes: ids.length, placeholderDetails: 0, replaceOldCodes: 0, blocked: [] })
|
||||
}
|
||||
if (path.endsWith('/syb-shops') && request.method() === 'GET') { shopLists.push(url); return reply({ items: [], total: 0 }) }
|
||||
if (request.method() !== 'GET') writes.push(path)
|
||||
return reply({ items: [], total: 0 })
|
||||
})
|
||||
const tableRows = page.locator('.el-table__body-wrapper tbody tr')
|
||||
const pagination = page.locator('.pagination-container')
|
||||
const started = Date.now()
|
||||
await page.goto('/#/syb-inner-codes')
|
||||
await expect(tableRows).toHaveCount(200)
|
||||
console.log(`mock 200-row initial navigation and render: ${Date.now() - started}ms`)
|
||||
expect(lists[0].searchParams.get('page')).toBe('1')
|
||||
expect(lists[0].searchParams.get('pageSize')).toBe('200')
|
||||
await expect(pagination.locator('.el-select')).toContainText('200')
|
||||
|
||||
await page.locator('.el-table__header-wrapper .el-checkbox').first().click()
|
||||
await page.getByRole('button', { name: '回写(200)', exact: true }).click()
|
||||
await expect.poll(() => previews.length).toBe(1)
|
||||
expect(previews[0]).toEqual(rows.slice(0, 200).map(row => row.id))
|
||||
await page.getByRole('dialog').getByRole('button', { name: '取消', exact: true }).click()
|
||||
expect(writes).toEqual([])
|
||||
|
||||
await pagination.locator('.btn-next').click()
|
||||
await expect(tableRows).toHaveCount(5)
|
||||
await expect(page.getByRole('cell', { name: 'MOCK-201', exact: true })).toBeVisible()
|
||||
expect(lists.at(-1)!.searchParams.get('page')).toBe('2')
|
||||
await expect(page.getByRole('button', { name: '回写(0)', exact: true })).toBeDisabled()
|
||||
await page.getByRole('button', { name: '搜索', exact: true }).click()
|
||||
await expect(tableRows).toHaveCount(200)
|
||||
|
||||
for (const size of [20, 50, 100, 200]) {
|
||||
await pagination.locator('.el-select').click()
|
||||
await page.getByRole('option', { name: new RegExp(`^${size}\\s*条/页$`) }).click()
|
||||
await expect(tableRows).toHaveCount(size)
|
||||
expect(lists.at(-1)!.searchParams.get('pageSize')).toBe(String(size))
|
||||
for (const name of ['搜索', '清除']) {
|
||||
const before = lists.length
|
||||
await page.getByRole('button', { name, exact: true }).click()
|
||||
await expect.poll(() => lists.length).toBeGreaterThan(before)
|
||||
expect(lists.at(-1)!.searchParams.get('pageSize')).toBe(String(size))
|
||||
}
|
||||
}
|
||||
|
||||
await page.getByPlaceholder('订单号或入库码').fill('empty')
|
||||
await page.getByRole('button', { name: '搜索', exact: true }).click()
|
||||
await expect(tableRows).toHaveCount(0)
|
||||
await expect(pagination).not.toBeVisible()
|
||||
await page.reload()
|
||||
await expect(tableRows).toHaveCount(200)
|
||||
expect(lists.at(-1)!.searchParams.get('pageSize')).toBe('200')
|
||||
|
||||
await page.goto('/#/syb-shops')
|
||||
await expect.poll(() => shopLists.length).toBeGreaterThan(0)
|
||||
expect(shopLists[0].searchParams.get('pageSize')).toBe('20')
|
||||
expect(writes).toEqual([])
|
||||
})
|
||||
@@ -0,0 +1,40 @@
|
||||
const { test } = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
const fs = require('node:fs')
|
||||
const path = require('node:path')
|
||||
|
||||
const page = fs.readFileSync(path.join(__dirname, '../../src/views/goauto/syb-inner-codes/index.vue'), 'utf8')
|
||||
const source = page.split('<script>')[1].split('</script>')[0].replace(/^import .*$/gm, '').replace('export default', 'return')
|
||||
const options = new Function(source)()
|
||||
|
||||
test('inner code page defaults to 200 and keeps a manual size through search and clear', async () => {
|
||||
const vm = { ...options.data(), ...options.methods }
|
||||
const requests = []
|
||||
vm.load = async () => { requests.push({ ...vm.query }) }
|
||||
|
||||
const now = new Date()
|
||||
const today = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`
|
||||
assert.equal(vm.query.page, 1)
|
||||
assert.equal(vm.query.pageSize, 200)
|
||||
assert.deepEqual(vm.dateRange, [today, today])
|
||||
assert.match(page, /:page-sizes="\[20, 50, 100, 200\]"/)
|
||||
|
||||
vm.query.pageSize = 50
|
||||
vm.query.page = 4
|
||||
vm.query.keyword = 'code'
|
||||
vm.dateRange = ['2026-10-01', '2026-10-02']
|
||||
await vm.search()
|
||||
assert.equal(requests[0].page, 1)
|
||||
assert.equal(requests[0].pageSize, 50)
|
||||
|
||||
vm.query.page = 3
|
||||
await vm.clearFilters()
|
||||
assert.equal(requests[1].page, 1)
|
||||
assert.equal(requests[1].pageSize, 50)
|
||||
assert.equal(requests[1].keyword, '')
|
||||
assert.equal(requests[1].dateFrom, '')
|
||||
assert.equal(requests[1].dateTo, '')
|
||||
|
||||
const freshVm = { ...options.data() }
|
||||
assert.equal(freshVm.query.pageSize, 200)
|
||||
})
|
||||
Reference in New Issue
Block a user