Compare commits
23
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4581ee5687 | ||
|
|
7622795d97 | ||
|
|
4ae33d8827 | ||
|
|
8ac3f2273f | ||
|
|
0138da8560 | ||
|
|
a82a1469a6 | ||
|
|
9fcbc64117 | ||
|
|
151c426e54 | ||
|
|
95847a3f2d | ||
|
|
6781b309d4 | ||
|
|
eb7cb6b5eb | ||
|
|
845853c68a | ||
|
|
955869b5f5 | ||
|
|
49de6f8045 | ||
|
|
279cb55847 | ||
|
|
35119ea261 | ||
|
|
cb00194b0f | ||
|
|
46b92dd6c5 | ||
|
|
1f5bab8192 | ||
|
|
9a4495addd | ||
|
|
75ab25658d | ||
|
|
a49dc696e9 | ||
|
|
64f0e4941f |
@@ -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 = 77
|
||||
versionName = "0.9.64"
|
||||
versionCode = 82
|
||||
versionName = "0.9.69"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
|
||||
|
||||
@@ -339,6 +339,7 @@ class CurrentPageIdentityRunner(
|
||||
when (outcome.result) {
|
||||
FreshActionResult.SUCCESS -> return ShareClickResult(true, "")
|
||||
FreshActionResult.AMBIGUOUS -> return ShareClickResult(false, "商品分享入口不唯一")
|
||||
FreshActionResult.BLOCKED -> return ShareClickResult(false, "已阻止点击尺码建议入口")
|
||||
FreshActionResult.NOT_FOUND, FreshActionResult.FAILED -> Unit
|
||||
}
|
||||
}
|
||||
@@ -366,6 +367,7 @@ class CurrentPageIdentityRunner(
|
||||
when (outcome.result) {
|
||||
FreshActionResult.SUCCESS -> return ShareClickResult(true, "")
|
||||
FreshActionResult.AMBIGUOUS -> return ShareClickResult(false, "复制链接入口不唯一")
|
||||
FreshActionResult.BLOCKED -> return ShareClickResult(false, "已阻止点击尺码建议入口")
|
||||
FreshActionResult.NOT_FOUND, FreshActionResult.FAILED -> Unit
|
||||
}
|
||||
}
|
||||
@@ -528,6 +530,7 @@ class CurrentPageIdentityRunner(
|
||||
FreshClickReason.NO_CLICKABLE_ANCESTOR -> AgentDiagnosticReason.NO_CLICKABLE_ANCESTOR
|
||||
FreshClickReason.ACTION_CLICK_FALSE -> AgentDiagnosticReason.ACTION_CLICK_FALSE
|
||||
FreshClickReason.UNKNOWN -> AgentDiagnosticReason.UNKNOWN
|
||||
FreshClickReason.SIZE_ADVICE_BLOCKED -> AgentDiagnosticReason.SIZE_ADVICE_CLICK_BLOCKED
|
||||
}
|
||||
|
||||
private data class SharePreparation(
|
||||
|
||||
+19
-2
@@ -162,7 +162,7 @@ class GoAutoAccessibilityService : AccessibilityService(), UiDriver, PddCollecto
|
||||
while (!target.isClickable) {
|
||||
target = target.parent ?: return false
|
||||
}
|
||||
return target.performAction(AccessibilityNodeInfo.ACTION_CLICK)
|
||||
return !sizeAdviceBlocked(target) && target.performAction(AccessibilityNodeInfo.ACTION_CLICK)
|
||||
}
|
||||
|
||||
override fun input(node: UiNodeRef, value: String): Boolean {
|
||||
@@ -212,7 +212,7 @@ class GoAutoAccessibilityService : AccessibilityService(), UiDriver, PddCollecto
|
||||
if (!node.isClickable || !node.isEnabled || !node.isVisibleToUser ||
|
||||
NodeBounds(bounds.left, bounds.top, bounds.right, bounds.bottom) != fresh.bounds ||
|
||||
(node.text?.toString()?.trim().takeUnless { it.isNullOrEmpty() } ?: node.contentDescription?.toString()?.trim().orEmpty()) != fresh.label) return false
|
||||
return node.performAction(AccessibilityNodeInfo.ACTION_CLICK)
|
||||
return !sizeAdviceBlocked(node) && node.performAction(AccessibilityNodeInfo.ACTION_CLICK)
|
||||
}
|
||||
|
||||
fun scrollBackfill(): Boolean {
|
||||
@@ -329,6 +329,15 @@ class GoAutoAccessibilityService : AccessibilityService(), UiDriver, PddCollecto
|
||||
|
||||
override fun clickFresh(target: SnapshotNode): FreshActionResult = clickFreshDetailed(target).result
|
||||
|
||||
private fun sizeAdviceBlocked(node: AccessibilityNodeInfo): Boolean =
|
||||
node.packageName?.toString() == "com.xunmeng.pinduoduo" && SizeAdviceClickPolicy.blocked(
|
||||
node,
|
||||
clickable = { it.isClickable },
|
||||
parent = { it.parent },
|
||||
children = { current -> (0 until current.childCount).mapNotNull(current::getChild) },
|
||||
labels = { listOfNotNull(it.text?.toString(), it.contentDescription?.toString()) },
|
||||
)
|
||||
|
||||
override fun clickFreshDetailed(target: SnapshotNode): FreshClickOutcome {
|
||||
val root = rootInActiveWindow ?: return FreshClickOutcome(FreshActionResult.NOT_FOUND, FreshClickReason.ROOT_UNAVAILABLE)
|
||||
val candidates = mutableListOf<AccessibilityNodeInfo>()
|
||||
@@ -356,6 +365,9 @@ class GoAutoAccessibilityService : AccessibilityService(), UiDriver, PddCollecto
|
||||
)
|
||||
ancestorDepth++
|
||||
}
|
||||
if (sizeAdviceBlocked(node)) return FreshClickOutcome(
|
||||
FreshActionResult.BLOCKED, FreshClickReason.SIZE_ADVICE_BLOCKED, candidates.size, ancestorDepth,
|
||||
)
|
||||
return if (node.performAction(AccessibilityNodeInfo.ACTION_CLICK)) {
|
||||
FreshClickOutcome(FreshActionResult.SUCCESS, FreshClickReason.SUCCESS, candidates.size, ancestorDepth)
|
||||
} else {
|
||||
@@ -392,6 +404,9 @@ class GoAutoAccessibilityService : AccessibilityService(), UiDriver, PddCollecto
|
||||
)
|
||||
ancestorDepth++
|
||||
}
|
||||
if (sizeAdviceBlocked(node)) return FreshClickOutcome(
|
||||
FreshActionResult.BLOCKED, FreshClickReason.SIZE_ADVICE_BLOCKED, 1, ancestorDepth,
|
||||
)
|
||||
return if (node.performAction(AccessibilityNodeInfo.ACTION_CLICK)) {
|
||||
FreshClickOutcome(FreshActionResult.SUCCESS, FreshClickReason.SUCCESS, 1, ancestorDepth)
|
||||
} else {
|
||||
@@ -414,6 +429,7 @@ class GoAutoAccessibilityService : AccessibilityService(), UiDriver, PddCollecto
|
||||
}
|
||||
if (candidates.isEmpty()) return FreshActionResult.NOT_FOUND
|
||||
if (candidates.size != 1) return FreshActionResult.AMBIGUOUS
|
||||
if (sizeAdviceBlocked(candidates.single())) return FreshActionResult.BLOCKED
|
||||
val bounds = Rect().also(candidates.single()::getBoundsInScreen)
|
||||
if (bounds.width() < 2 || bounds.height() < 2 || Build.VERSION.SDK_INT < Build.VERSION_CODES.N) {
|
||||
return FreshActionResult.FAILED
|
||||
@@ -438,6 +454,7 @@ class GoAutoAccessibilityService : AccessibilityService(), UiDriver, PddCollecto
|
||||
}
|
||||
if (candidates.isEmpty()) return FreshActionResult.NOT_FOUND
|
||||
if (candidates.size != 1) return FreshActionResult.AMBIGUOUS
|
||||
if (sizeAdviceBlocked(candidates.single())) return FreshActionResult.BLOCKED
|
||||
val bounds = Rect().also(candidates.single()::getBoundsInScreen)
|
||||
val freshTarget = target.copy(bounds = NodeBounds(bounds.left, bounds.top, bounds.right, bounds.bottom))
|
||||
if (!PurchaseSpecGesturePolicy.isAllowed(freshTarget, width, height)) {
|
||||
|
||||
+73
-7
@@ -38,7 +38,7 @@ data class UiSnapshot(
|
||||
val nodes: List<SnapshotNode>,
|
||||
)
|
||||
|
||||
enum class FreshActionResult { SUCCESS, NOT_FOUND, AMBIGUOUS, FAILED }
|
||||
enum class FreshActionResult { SUCCESS, NOT_FOUND, AMBIGUOUS, FAILED, BLOCKED }
|
||||
|
||||
enum class FreshClickReason {
|
||||
SUCCESS,
|
||||
@@ -47,6 +47,7 @@ enum class FreshClickReason {
|
||||
TARGET_AMBIGUOUS,
|
||||
NO_CLICKABLE_ANCESTOR,
|
||||
ACTION_CLICK_FALSE,
|
||||
SIZE_ADVICE_BLOCKED,
|
||||
UNKNOWN,
|
||||
}
|
||||
|
||||
@@ -375,6 +376,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) }
|
||||
@@ -585,8 +587,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,
|
||||
@@ -604,9 +606,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
|
||||
@@ -614,6 +618,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
|
||||
@@ -1021,7 +1076,8 @@ class PddProductDetailCollector(
|
||||
while (opened == null) {
|
||||
val target = entry ?: return failure("RULE_NOT_MATCHED", "未找到 PDD 商品规格入口")
|
||||
val beforeClick = screenSignature(current)
|
||||
val clickResult = driver.clickFresh(target)
|
||||
val clickResult = clickSpecTarget(target, AgentDiagnosticStage.SPEC_ENTRY_CLICK)
|
||||
if (clickResult == FreshActionResult.BLOCKED) return sizeAdviceFailure()
|
||||
trace(
|
||||
"spec-entry source=${entrySource ?: "unknown"} label=${target.label.take(40)} " +
|
||||
"class=${target.className} bounds=${target.bounds} result=$clickResult",
|
||||
@@ -1131,7 +1187,8 @@ class PddProductDetailCollector(
|
||||
opened = screen
|
||||
break
|
||||
}
|
||||
when (driver.clickFresh(recoveryEntry)) {
|
||||
when (clickSpecTarget(recoveryEntry, AgentDiagnosticStage.QUICK_CONFIRMATION_CLICK)) {
|
||||
FreshActionResult.BLOCKED -> return sizeAdviceFailure()
|
||||
FreshActionResult.SUCCESS -> {
|
||||
quickConfirmationRecovered = true
|
||||
pause(100)
|
||||
@@ -1431,9 +1488,10 @@ class PddProductDetailCollector(
|
||||
} ?: return null
|
||||
attempted += value.text
|
||||
val beforeClick = screen
|
||||
val clickResult = driver.clickFresh(value.node)
|
||||
val clickResult = clickSpecTarget(value.node, AgentDiagnosticStage.COLOR_CLICK)
|
||||
trace("color-click color=${traceLabel(value.text)} result=$clickResult")
|
||||
when (clickResult) {
|
||||
FreshActionResult.BLOCKED -> return sizeAdviceFailure()
|
||||
FreshActionResult.AMBIGUOUS -> return failure("RULE_AMBIGUOUS", "颜色“${value.text}”匹配到多个控件")
|
||||
FreshActionResult.NOT_FOUND, FreshActionResult.FAILED -> {
|
||||
missing += "selection:${value.text}"
|
||||
@@ -1538,6 +1596,14 @@ class PddProductDetailCollector(
|
||||
return null
|
||||
}
|
||||
|
||||
private fun sizeAdviceFailure() = failure("SIZE_ADVICE_CLICK_BLOCKED", "已阻止点击尺码建议入口")
|
||||
|
||||
private fun clickSpecTarget(target: SnapshotNode, stage: AgentDiagnosticStage): FreshActionResult {
|
||||
val outcome = driver.clickFreshDetailed(target)
|
||||
if (taskId > 0) runCatching { diagnostic(specClickDiagnostic(taskId, stage, target, outcome)) }
|
||||
return outcome.result
|
||||
}
|
||||
|
||||
private fun recordColorImage(reason: AgentDiagnosticReason) {
|
||||
if (taskId <= 0) return
|
||||
diagnostic(AgentDiagnosticEvent(taskId = taskId, stage = AgentDiagnosticStage.COLOR_IMAGE, reason = reason, attempt = 1))
|
||||
|
||||
+21
-1
@@ -1,5 +1,7 @@
|
||||
package cn.ilapage.goauto.agent.automation
|
||||
|
||||
import cn.ilapage.goauto.agent.persistence.AgentDiagnosticStage
|
||||
|
||||
import java.net.URI
|
||||
import java.net.URLDecoder
|
||||
|
||||
@@ -106,6 +108,7 @@ class PurchaseRehearsalExecutor(
|
||||
private val stepChanged: (String) -> Unit = {},
|
||||
private val panelDiagnostic: (String) -> Unit = {},
|
||||
private val beforeOrderSubmit: (FinalConfirmationEvidence) -> Unit = { throw PurchaseLiveException("PURCHASE_MODE_NOT_ALLOWED", "当前执行器没有正式采购授权") },
|
||||
private val probeClickDiagnostic: (AgentDiagnosticStage, SnapshotNode, FreshClickOutcome) -> Unit = { _, _, _ -> },
|
||||
) {
|
||||
private var purchasePanelContext: PurchasePanelContext? = null
|
||||
|
||||
@@ -393,6 +396,10 @@ class PurchaseRehearsalExecutor(
|
||||
}
|
||||
val beforeSignature = specActionSignature(screen)
|
||||
val click = driver.clickFreshDetailed(requireNotNull(target))
|
||||
if (input.phase == "spec_probe") runCatching {
|
||||
probeClickDiagnostic(AgentDiagnosticStage.SPEC_ENTRY_CLICK, requireNotNull(target), click)
|
||||
}
|
||||
if (click.result == FreshActionResult.BLOCKED) return sizeAdviceFailure()
|
||||
when (click.result) {
|
||||
// The parser already narrowed to a single semantic candidate; the
|
||||
// ambiguity here comes from the live tree matching that target more
|
||||
@@ -414,7 +421,14 @@ class PurchaseRehearsalExecutor(
|
||||
)
|
||||
}
|
||||
|
||||
when (driver.tapSpecFresh(requireNotNull(target))) {
|
||||
val gestureResult = driver.tapSpecFresh(requireNotNull(target))
|
||||
if (input.phase == "spec_probe") runCatching {
|
||||
probeClickDiagnostic(AgentDiagnosticStage.SPEC_ENTRY_GESTURE, requireNotNull(target), FreshClickOutcome(
|
||||
gestureResult, if (gestureResult == FreshActionResult.BLOCKED) FreshClickReason.SIZE_ADVICE_BLOCKED else FreshClickReason.UNKNOWN,
|
||||
))
|
||||
}
|
||||
when (gestureResult) {
|
||||
FreshActionResult.BLOCKED -> return sizeAdviceFailure()
|
||||
FreshActionResult.AMBIGUOUS -> return failure(
|
||||
SPEC_ENTRY_TARGET_AMBIGUOUS,
|
||||
"规格入口手势目标不唯一 [${specEntryEvidence(screen, 1, entryReadyWaitPolls)}]",
|
||||
@@ -605,6 +619,7 @@ class PurchaseRehearsalExecutor(
|
||||
val lookup = locateExactSpec(input, dimension, target)
|
||||
lookup.failure?.let { return it }
|
||||
val outcome = driver.clickFreshDetailed(requireNotNull(lookup.node))
|
||||
if (outcome.result == FreshActionResult.BLOCKED) return sizeAdviceFailure()
|
||||
if (outcome.result == FreshActionResult.AMBIGUOUS) {
|
||||
return failure(SPEC_TARGET_AMBIGUOUS, "精确规格匹配到多个控件")
|
||||
}
|
||||
@@ -618,6 +633,7 @@ class PurchaseRehearsalExecutor(
|
||||
val refreshedLookup = locateExactSpec(input, dimension, target)
|
||||
refreshedLookup.failure?.let { return it }
|
||||
val gesture = driver.tapSpecFresh(requireNotNull(refreshedLookup.node))
|
||||
if (gesture == FreshActionResult.BLOCKED) return sizeAdviceFailure()
|
||||
if (gesture == FreshActionResult.AMBIGUOUS) {
|
||||
return failure(SPEC_TARGET_AMBIGUOUS, "精确规格手势目标不唯一")
|
||||
}
|
||||
@@ -1165,6 +1181,8 @@ class PurchaseRehearsalExecutor(
|
||||
|
||||
private fun applyPostAction(input: PurchaseExecutionInput, action: PurchaseAction): PurchaseExecutionOutcome? {
|
||||
if (action.waitAfterMs > 0) pause(action.waitAfterMs)
|
||||
// The collector owns probe scrolling. Preserve settling time and every purchase-phase action.
|
||||
if (input.phase == "spec_probe" && action.type == PurchaseActionType.OPEN_SPEC_PANEL) return null
|
||||
action.swipeAfter?.let { swipe ->
|
||||
val reveal = action.type == PurchaseActionType.OPEN_SPEC_PANEL
|
||||
val initialScreen = if (reveal) currentScreen(input) else null
|
||||
@@ -1207,6 +1225,8 @@ class PurchaseRehearsalExecutor(
|
||||
return PurchaseExecutionOutcome("spec_probe_completed", message = "商品规格已回传,等待服务端匹配", probedSpecs = result)
|
||||
}
|
||||
|
||||
private fun sizeAdviceFailure() = failure("SIZE_ADVICE_CLICK_BLOCKED", "已阻止点击尺码建议入口")
|
||||
|
||||
private fun normalizedTarget(dimension: String, raw: String): String? {
|
||||
val trimmed = raw.trim()
|
||||
if (dimension != "size") return trimmed.takeIf(String::isNotBlank)
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
package cn.ilapage.goauto.agent.automation
|
||||
|
||||
/** Only inspects the actual click receiver, never the whole page or unrelated siblings. */
|
||||
internal object SizeAdviceClickPolicy {
|
||||
private val phrases = listOf(
|
||||
"查看尺码建议", "查看尺碼建議", "查看尺寸建议", "查看尺寸建議",
|
||||
"尺码助手", "尺碼助手", "尺寸助手", "智能选码", "智能選碼", "选码建议", "選碼建議",
|
||||
)
|
||||
|
||||
fun containsAdvice(label: String): Boolean {
|
||||
val compact = label.filterNot(Char::isWhitespace)
|
||||
return phrases.any(compact::contains)
|
||||
}
|
||||
|
||||
fun <N : Any> blocked(
|
||||
target: N,
|
||||
clickable: (N) -> Boolean,
|
||||
parent: (N) -> N?,
|
||||
children: (N) -> List<N>,
|
||||
labels: (N) -> List<String>,
|
||||
): Boolean {
|
||||
var receiver = target
|
||||
while (!clickable(receiver)) {
|
||||
val ancestor = parent(receiver)
|
||||
if (ancestor == null) {
|
||||
// A gesture can still reach a non-clickable leaf. Inspect the original
|
||||
// target, not the unrelated root subtree, if no receiver is exposed.
|
||||
receiver = target
|
||||
break
|
||||
}
|
||||
receiver = ancestor
|
||||
}
|
||||
val pending = ArrayDeque<N>()
|
||||
pending.add(receiver)
|
||||
while (pending.isNotEmpty()) {
|
||||
val node = pending.removeFirst()
|
||||
if (labels(node).any(::containsAdvice)) return true
|
||||
pending.addAll(children(node))
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package cn.ilapage.goauto.agent.automation
|
||||
|
||||
import cn.ilapage.goauto.agent.persistence.AgentDiagnosticEvent
|
||||
import cn.ilapage.goauto.agent.persistence.AgentDiagnosticReason
|
||||
import cn.ilapage.goauto.agent.persistence.AgentDiagnosticStage
|
||||
|
||||
/** No labels, bounds, selectors, or raw trace strings cross this boundary. */
|
||||
internal fun specClickDiagnostic(
|
||||
taskId: Long, stage: AgentDiagnosticStage, target: SnapshotNode, outcome: FreshClickOutcome,
|
||||
) = AgentDiagnosticEvent(
|
||||
taskId = taskId,
|
||||
stage = stage,
|
||||
reason = when (outcome.reason) {
|
||||
FreshClickReason.SIZE_ADVICE_BLOCKED -> AgentDiagnosticReason.SIZE_ADVICE_CLICK_BLOCKED
|
||||
FreshClickReason.ROOT_UNAVAILABLE -> AgentDiagnosticReason.ROOT_UNAVAILABLE
|
||||
FreshClickReason.NO_CLICKABLE_ANCESTOR -> AgentDiagnosticReason.NO_CLICKABLE_ANCESTOR
|
||||
else -> when (outcome.result) {
|
||||
FreshActionResult.SUCCESS -> AgentDiagnosticReason.CLICK_SUCCESS
|
||||
FreshActionResult.BLOCKED -> AgentDiagnosticReason.SIZE_ADVICE_CLICK_BLOCKED
|
||||
FreshActionResult.AMBIGUOUS -> AgentDiagnosticReason.TARGET_AMBIGUOUS
|
||||
FreshActionResult.NOT_FOUND -> AgentDiagnosticReason.TARGET_NOT_FOUND
|
||||
FreshActionResult.FAILED -> AgentDiagnosticReason.ACTION_CLICK_FALSE
|
||||
}
|
||||
},
|
||||
candidateCount = outcome.candidateCount,
|
||||
targetClassName = target.className?.takeIf { it in setOf(
|
||||
"android.widget.TextView", "android.widget.Button", "android.widget.LinearLayout",
|
||||
"android.widget.FrameLayout", "android.view.View", "android.view.ViewGroup",
|
||||
) },
|
||||
targetClickable = target.clickable,
|
||||
clickableAncestorDepth = outcome.clickableAncestorDepth,
|
||||
)
|
||||
@@ -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)
|
||||
|
||||
+102
@@ -0,0 +1,102 @@
|
||||
package cn.ilapage.goauto.agent.diagnostics
|
||||
|
||||
import android.graphics.Rect
|
||||
import android.os.Build
|
||||
import android.view.accessibility.AccessibilityNodeInfo
|
||||
import android.view.accessibility.AccessibilityWindowInfo
|
||||
import cn.ilapage.goauto.agent.automation.GoAutoAccessibilityService
|
||||
import cn.ilapage.goauto.agent.BuildConfig
|
||||
|
||||
/** Independent getWindows adapter. The normal capture()/UiSnapshot parser stays untouched. */
|
||||
class AccessibilitySnapshotSource(private val service: GoAutoAccessibilityService) {
|
||||
fun windows(): List<SnapshotWindow> {
|
||||
// This guard is part of the timed synchronous sampling callback, not a preceding unbounded read.
|
||||
val started = System.nanoTime()
|
||||
val foreground = try { service.currentPackage() } catch (_: Exception) { throw SnapshotUnavailableException("accessibility_unavailable") }
|
||||
if (foreground == BuildConfig.APPLICATION_ID) throw SnapshotUnavailableException("execution_not_entered")
|
||||
if ((System.nanoTime() - started) / 1_000_000 >= 1500) throw SnapshotUnavailableException("time_limit")
|
||||
return service.windows.map { Window(it) }
|
||||
}
|
||||
|
||||
private inner class Window(private val window: AccessibilityWindowInfo) : SnapshotWindow {
|
||||
private var cachedRoot: AccessibilityNodeInfo? = null
|
||||
private var rootRead = false
|
||||
private fun rawRoot(): AccessibilityNodeInfo? {
|
||||
if (!rootRead) { cachedRoot = window.root; rootRead = true }
|
||||
return cachedRoot
|
||||
}
|
||||
override fun attribute(name: String): SnapshotAttribute = read {
|
||||
when (name) {
|
||||
"id" -> window.id
|
||||
"type" -> window.type
|
||||
"layer" -> window.layer
|
||||
"title" -> if (Build.VERSION.SDK_INT >= 24) window.title else return SnapshotAttribute(state="unsupported")
|
||||
"active" -> window.isActive
|
||||
"focused" -> window.isFocused
|
||||
"bounds" -> Rect().also(window::getBoundsInScreen).flatten()
|
||||
"packageName" -> rawRoot()?.packageName
|
||||
"activityName" -> if (window.isActive) service.currentActivity() else null
|
||||
else -> return SnapshotAttribute(state="unsupported")
|
||||
}
|
||||
}
|
||||
override fun root(): SnapshotNode? = rawRoot()?.let { Node(AccessibilityNodeInfo.obtain(it)) }
|
||||
@Suppress("DEPRECATION")
|
||||
override fun close() { cachedRoot?.recycle(); window.recycle() }
|
||||
}
|
||||
|
||||
private class Node(private val node: AccessibilityNodeInfo) : SnapshotNode {
|
||||
override fun attribute(name: String): SnapshotAttribute = read {
|
||||
when (name) {
|
||||
"text" -> node.text
|
||||
"resource-id" -> node.viewIdResourceName
|
||||
"class" -> node.className
|
||||
"package" -> node.packageName
|
||||
"content-desc" -> node.contentDescription
|
||||
"checkable" -> node.isCheckable
|
||||
"checked" -> node.isChecked
|
||||
"clickable" -> node.isClickable
|
||||
"enabled" -> node.isEnabled
|
||||
"focusable" -> node.isFocusable
|
||||
"focused" -> node.isFocused
|
||||
"scrollable" -> node.isScrollable
|
||||
"long-clickable" -> node.isLongClickable
|
||||
"password" -> node.isPassword
|
||||
"selected" -> node.isSelected
|
||||
"bounds" -> Rect().also(node::getBoundsInScreen).flatten()
|
||||
"visible-to-user" -> node.isVisibleToUser
|
||||
"editable" -> node.isEditable
|
||||
"hint" -> if (Build.VERSION.SDK_INT >= 26) node.hintText else return SnapshotAttribute(state="unsupported")
|
||||
"state-description" -> if (Build.VERSION.SDK_INT >= 30) node.stateDescription else return SnapshotAttribute(state="unsupported")
|
||||
"tooltip" -> if (Build.VERSION.SDK_INT >= 28) node.tooltipText else return SnapshotAttribute(state="unsupported")
|
||||
"input-type" -> node.inputType
|
||||
"max-text-length" -> node.maxTextLength
|
||||
"drawing-order" -> if (Build.VERSION.SDK_INT >= 24) node.drawingOrder else return SnapshotAttribute(state="unsupported")
|
||||
"collection-row-count" -> node.collectionInfo?.rowCount
|
||||
"collection-column-count" -> node.collectionInfo?.columnCount
|
||||
"collection-hierarchical" -> node.collectionInfo?.isHierarchical
|
||||
"collection-selection-mode" -> node.collectionInfo?.selectionMode
|
||||
"collection-item-row-index" -> node.collectionItemInfo?.rowIndex
|
||||
"collection-item-row-span" -> node.collectionItemInfo?.rowSpan
|
||||
"collection-item-column-index" -> node.collectionItemInfo?.columnIndex
|
||||
"collection-item-column-span" -> node.collectionItemInfo?.columnSpan
|
||||
"collection-item-heading" -> node.collectionItemInfo?.isHeading
|
||||
"collection-item-selected" -> node.collectionItemInfo?.isSelected
|
||||
"range-type" -> node.rangeInfo?.type
|
||||
"range-min" -> node.rangeInfo?.min
|
||||
"range-max" -> node.rangeInfo?.max
|
||||
"range-current" -> node.rangeInfo?.current
|
||||
else -> return SnapshotAttribute(state="unsupported")
|
||||
}
|
||||
}
|
||||
override fun childCount() = node.childCount
|
||||
override fun child(index: Int) = node.getChild(index)?.let(::Node)
|
||||
override fun actions() = node.actionList.map { it.id to read { it.label } }
|
||||
@Suppress("DEPRECATION")
|
||||
override fun close() = node.recycle()
|
||||
}
|
||||
|
||||
companion object {
|
||||
private inline fun read(block: () -> Any?): SnapshotAttribute = try { SnapshotAttribute(block()?.toString()) } catch (_: Exception) { SnapshotAttribute(state="read_error") }
|
||||
private fun Rect.flatten() = "[$left,$top][$right,$bottom]"
|
||||
}
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
package cn.ilapage.goauto.agent.diagnostics
|
||||
|
||||
/** Synchronous capture boundary; persistence/upload must never move the live read onto another task. */
|
||||
class FailureSnapshotCapture(
|
||||
private val hasArchive: (String) -> Boolean,
|
||||
private val persist: (FailureSnapshot) -> Unit,
|
||||
private val exporter: FailureSnapshotExporter = FailureSnapshotExporter(),
|
||||
) {
|
||||
fun record(context: SnapshotContext?, resultType: String, safelyStored: Boolean, executionEntered: Boolean,
|
||||
accessibilityAvailable: Boolean, windows: () -> List<SnapshotWindow>, unavailableReason: String? = null) {
|
||||
if (context == null || !FailureSnapshotPolicy.eligible(context.phase,resultType,safelyStored) || hasArchive(context.taskAttemptId)) return
|
||||
val reason = unavailableReason ?: when {
|
||||
!executionEntered -> "execution_not_entered"
|
||||
!accessibilityAvailable -> "accessibility_unavailable"
|
||||
else -> null
|
||||
}
|
||||
val snapshot = try { exporter.capture(context, reason, windows) } catch (_: Exception) { exporter.notCaptured(context,"capture_failed") }
|
||||
persist(snapshot)
|
||||
}
|
||||
}
|
||||
+304
@@ -0,0 +1,304 @@
|
||||
package cn.ilapage.goauto.agent.diagnostics
|
||||
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.ArrayDeque
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
import java.util.TimeZone
|
||||
import java.util.zip.ZipEntry
|
||||
import java.util.zip.ZipOutputStream
|
||||
|
||||
data class FailureSnapshot(val metadataJson: String, val archive: ByteArray?, val serverOrigin: String = "")
|
||||
class SnapshotUnavailableException(val reason: String) : Exception(reason)
|
||||
data class SnapshotAttribute(val value: String? = null, val state: String = if (value == null) "null" else "value")
|
||||
interface SnapshotNode {
|
||||
fun attribute(name: String): SnapshotAttribute
|
||||
fun childCount(): Int
|
||||
fun child(index: Int): SnapshotNode?
|
||||
fun actions(): List<Pair<Int, SnapshotAttribute>> = emptyList()
|
||||
fun close() {}
|
||||
}
|
||||
interface SnapshotWindow {
|
||||
fun attribute(name: String): SnapshotAttribute
|
||||
fun root(): SnapshotNode?
|
||||
fun close() {}
|
||||
}
|
||||
data class SnapshotContext(
|
||||
val taskId: Long, val deviceId: Long, val taskAttemptId: String, val phase: String,
|
||||
val ruleSnapshotHash: String?, val errorCode: String, val agentVersion: String,
|
||||
val source: String, val serverOrigin: String = "",
|
||||
)
|
||||
|
||||
object FailureSnapshotPolicy {
|
||||
fun eligible(phase: String, result: String, safelyStored: Boolean) =
|
||||
!safelyStored && phase in setOf("purchase", "spec_probe") && result in setOf("failed", "order_result_unknown")
|
||||
fun retryUpload(httpStatus: Int) = httpStatus == 0 || httpStatus in setOf(408, 429) || httpStatus >= 500
|
||||
fun exceptionEligible(knownResult: String?, safelyStored: Boolean) = !safelyStored &&
|
||||
knownResult !in setOf("spec_probe_completed", "order_created", "rehearsal_completed")
|
||||
}
|
||||
|
||||
/** No extras, reflection, screenshots, parsing or text normalization. */
|
||||
object SnapshotXml {
|
||||
private const val alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
|
||||
val contentAttributes = setOf("text", "content-desc", "hint", "state-description", "tooltip")
|
||||
val attributes = listOf("text", "resource-id", "class", "package", "content-desc", "checkable", "checked", "clickable", "enabled", "focusable", "focused", "scrollable", "long-clickable", "password", "selected", "bounds", "visible-to-user", "editable", "hint", "state-description", "tooltip", "input-type", "max-text-length", "drawing-order", "collection-row-count", "collection-column-count", "collection-hierarchical", "collection-selection-mode", "collection-item-row-index", "collection-item-row-span", "collection-item-column-index", "collection-item-column-span", "collection-item-heading", "collection-item-selected", "range-type", "range-min", "range-max", "range-current")
|
||||
fun decode(value: String): String {
|
||||
val output = ByteArrayOutputStream()
|
||||
var accumulator = 0; var bits = 0
|
||||
for (c in value) {
|
||||
if (c == '=') break
|
||||
val digit = alphabet.indexOf(c); require(digit >= 0)
|
||||
accumulator = (accumulator shl 6) or digit; bits += 6
|
||||
if (bits >= 8) { bits -= 8; output.write((accumulator shr bits) and 255) }
|
||||
}
|
||||
val bytes = output.toByteArray()
|
||||
return CharArray(bytes.size / 2) { ((bytes[it * 2].toInt() and 255) * 256 + (bytes[it * 2 + 1].toInt() and 255)).toChar() }.concatToString()
|
||||
}
|
||||
private fun encode(value: String): String {
|
||||
val bytes = ByteArray(value.length * 2)
|
||||
value.forEachIndexed { index, c -> bytes[index * 2] = (c.code shr 8).toByte(); bytes[index * 2 + 1] = c.code.toByte() }
|
||||
val encoded = StringBuilder((bytes.size + 2) / 3 * 4)
|
||||
var index = 0
|
||||
while (index < bytes.size) {
|
||||
val remaining = bytes.size - index
|
||||
val value24 = ((bytes[index].toInt() and 255) shl 16) or
|
||||
(if (remaining > 1) (bytes[index + 1].toInt() and 255) shl 8 else 0) or
|
||||
(if (remaining > 2) bytes[index + 2].toInt() and 255 else 0)
|
||||
encoded.append(alphabet[(value24 shr 18) and 63]).append(alphabet[(value24 shr 12) and 63])
|
||||
.append(if (remaining > 1) alphabet[(value24 shr 6) and 63] else '=')
|
||||
.append(if (remaining > 2) alphabet[value24 and 63] else '=')
|
||||
index += 3
|
||||
}
|
||||
return encoded.toString()
|
||||
}
|
||||
private fun legal(value: String): Boolean {
|
||||
var i = 0
|
||||
while (i < value.length) {
|
||||
val c = value[i].code
|
||||
if (c in 0xD800..0xDBFF) {
|
||||
if (i + 1 >= value.length || value[i + 1].code !in 0xDC00..0xDFFF) return false
|
||||
i += 2; continue
|
||||
}
|
||||
if (!(c == 9 || c == 10 || c == 13 || c in 0x20..0xD7FF || c in 0xE000..0xFFFD)) return false
|
||||
i++
|
||||
}
|
||||
return true
|
||||
}
|
||||
fun attribute(name: String, attr: SnapshotAttribute): String {
|
||||
if (attr.state != "value") return " $name-state=\"${attr.state}\""
|
||||
val value = attr.value ?: return " $name-state=\"null\""
|
||||
if (!legal(value)) return " $name-base64=\"${encode(value)}\""
|
||||
return " $name=\"" + value.replace("&", "&").replace("<", "<").replace(">", ">").replace("\"", """).replace("\n", " ").replace("\r", " ").replace("\t", "	") + "\""
|
||||
}
|
||||
}
|
||||
|
||||
class FailureSnapshotExporter(
|
||||
private val nodeLimit: Int = 5000,
|
||||
private val timeLimitMillis: Long = 1500,
|
||||
private val expandedLimit: Int = 8 * 1024 * 1024,
|
||||
private val archiveLimit: Int = 2 * 1024 * 1024,
|
||||
private val monotonicMillis: () -> Long = { System.nanoTime() / 1_000_000 },
|
||||
private val epochMillis: () -> Long = System::currentTimeMillis,
|
||||
) {
|
||||
fun notCaptured(context: SnapshotContext, reason: String): FailureSnapshot = capture(context, reason) { emptyList() }
|
||||
|
||||
fun capture(context: SnapshotContext, unavailableReason: String? = null, windows: () -> List<SnapshotWindow>): FailureSnapshot {
|
||||
val now = timestamp(epochMillis())
|
||||
val reasons = linkedSetOf<String>()
|
||||
val metadata = JSONObject().put("schemaVersion", 1).put("attributeVersion", 1)
|
||||
.put("taskId", context.taskId).put("deviceId", context.deviceId).put("taskAttemptId", context.taskAttemptId)
|
||||
.put("phase", context.phase).put("errorCode", context.errorCode.take(64)).put("agentVersion", context.agentVersion.take(64))
|
||||
.put("recordedAt", now).put("source", context.source)
|
||||
val validHash = context.ruleSnapshotHash?.matches(Regex("[a-f0-9]{64}")) == true
|
||||
metadata.put("ruleSnapshotHash", if (validHash) context.ruleSnapshotHash else JSONObject.NULL).put("ruleSnapshotHashValid", validHash)
|
||||
val windowMetadata = JSONArray()
|
||||
val files = linkedMapOf<String, ByteArray>()
|
||||
data class Cut(val offset: Int, val openNodes: Int)
|
||||
val fileCuts = mutableMapOf<String, List<Cut>>()
|
||||
metadata.put("windows", windowMetadata)
|
||||
val start = monotonicMillis()
|
||||
var count = 0
|
||||
var expanded = 0
|
||||
fun timeAvailable(): Boolean {
|
||||
if (monotonicMillis() - start >= timeLimitMillis) { reasons.add("time_limit"); return false }
|
||||
return true
|
||||
}
|
||||
if (!validHash) reasons.add("rule_hash_invalid")
|
||||
else if (unavailableReason != null) reasons.add(unavailableReason)
|
||||
else {
|
||||
val sources = try { if (timeAvailable()) windows() else emptyList() } catch (error: SnapshotUnavailableException) {
|
||||
reasons.add(error.reason); emptyList()
|
||||
} catch (_: UnsupportedOperationException) {
|
||||
reasons.add("windows_unsupported"); emptyList()
|
||||
} catch (_: Exception) { reasons.add("windows_unavailable"); emptyList() }
|
||||
try {
|
||||
for (window in sources) {
|
||||
if (!timeAvailable()) break
|
||||
// Bound the metadata separately from the expanded archive; retain explicit partial evidence.
|
||||
if (windowMetadata.length() >= 128 || windowMetadata.toString().toByteArray(Charsets.UTF_8).size > 32 * 1024) { reasons.add("expanded_size_limit"); break }
|
||||
val wm = JSONObject().put("capturedAt", now).put("fileName", JSONObject.NULL)
|
||||
val states = JSONObject()
|
||||
val wr = linkedSetOf<String>()
|
||||
val fields = listOf("id", "type", "layer", "title", "active", "focused", "bounds", "packageName", "activityName")
|
||||
for (name in fields) {
|
||||
if (!timeAvailable()) { wm.put(name, JSONObject.NULL); states.put(name, "read_error"); wr.add("time_limit"); continue }
|
||||
val value = try { window.attribute(name) } catch (_: Exception) { SnapshotAttribute(state="read_error") }
|
||||
if (!timeAvailable()) wr.add("time_limit")
|
||||
if (value.state == "read_error") wr.add("window_read_error")
|
||||
if (value.value != null && value.value.length > 4096) {
|
||||
wm.put(name, JSONObject.NULL); states.put(name, "read_error"); wr.add("expanded_size_limit")
|
||||
} else if (value.state != "value" || value.value == null) {
|
||||
wm.put(name, JSONObject.NULL); states.put(name, value.state)
|
||||
} else when (name) {
|
||||
"id", "type", "layer" -> {
|
||||
val number = value.value.toIntOrNull()
|
||||
wm.put(name, number ?: JSONObject.NULL)
|
||||
if (number == null) { states.put(name,"read_error"); wr.add("window_read_error") }
|
||||
}
|
||||
"active", "focused" -> wm.put(name, value.value == "true")
|
||||
else -> wm.put(name, value.value)
|
||||
}
|
||||
}
|
||||
wm.put("attributeStates", states)
|
||||
windowMetadata.put(wm)
|
||||
val id = if (wm.isNull("id")) null else wm.getInt("id")
|
||||
val fileName = id?.let { "window-$it.xml" }
|
||||
var root: SnapshotNode? = null
|
||||
if (fileName != null && !files.containsKey(fileName) && timeAvailable()) {
|
||||
root = try { window.root() } catch (_: Exception) { wr.add("window_read_error"); null }
|
||||
if (!timeAvailable()) { root?.close(); root = null; wr.add("time_limit") }
|
||||
else if (root == null) wr.add("window_root_null")
|
||||
} else if (fileName == null || files.containsKey(fileName)) wr.add("window_read_error")
|
||||
if (root != null) {
|
||||
val xml = StringBuilder("<?xml version=\"1.0\" encoding=\"UTF-8\"?><hierarchy>")
|
||||
var bytes = xml.length
|
||||
data class Frame(val node: SnapshotNode, val path: String, var next: Int = -1, var children: Int = 0)
|
||||
val stack = ArrayDeque<Frame>()
|
||||
stack.push(Frame(root, "0"))
|
||||
var emitted = 0
|
||||
val cuts = mutableListOf<Cut>()
|
||||
try {
|
||||
while (stack.isNotEmpty()) {
|
||||
val frame = requireNotNull(stack.peek())
|
||||
if (!timeAvailable()) { wr.add("time_limit"); break }
|
||||
if (frame.next == -1) {
|
||||
if (count >= nodeLimit || stack.size > 250) { wr.add("node_limit"); break }
|
||||
val attrs = StringBuilder("<node index=\"${frame.path.substringAfterLast('/')}\" path=\"${frame.path}\"")
|
||||
fun read(name: String): SnapshotAttribute {
|
||||
if (!timeAvailable()) { wr.add("time_limit"); return SnapshotAttribute(state="read_error") }
|
||||
val a = try { frame.node.attribute(name) } catch (_: Exception) { SnapshotAttribute(state="read_error") }
|
||||
if (!timeAvailable()) wr.add("time_limit")
|
||||
if (a.state == "read_error") wr.add("node_read_error")
|
||||
return a
|
||||
}
|
||||
val password = read("password")
|
||||
// Fail closed if password status is unavailable: do not retain content-bearing fields.
|
||||
val omitContent = password.state != "value" || password.value == "true"
|
||||
var tooLarge = false
|
||||
for (name in SnapshotXml.attributes) {
|
||||
if (omitContent && name in SnapshotXml.contentAttributes) continue
|
||||
val value = if (name == "password") password else read(name)
|
||||
if ((value.value?.length ?: 0) > expandedLimit / 4) { tooLarge = true; break }
|
||||
attrs.append(SnapshotXml.attribute(name, value))
|
||||
if (attrs.length > expandedLimit / 4) { tooLarge = true; break }
|
||||
}
|
||||
var childCountState: String? = null
|
||||
frame.children = try { if (timeAvailable()) frame.node.childCount().coerceAtLeast(0) else { childCountState = "read_error"; 0 } } catch (_: Exception) { wr.add("node_read_error"); childCountState = "read_error"; 0 }
|
||||
if (!timeAvailable()) wr.add("time_limit")
|
||||
attrs.append(if (childCountState == null) " child-count=\"${frame.children}\"" else " child-count-state=\"$childCountState\"")
|
||||
var actionsReadError = false
|
||||
val actions = try { if (timeAvailable()) frame.node.actions() else { actionsReadError = true; emptyList() } } catch (_: Exception) { wr.add("node_read_error"); actionsReadError = true; emptyList() }
|
||||
if (actionsReadError) attrs.append(" actions-state=\"read_error\"")
|
||||
attrs.append('>')
|
||||
if (!timeAvailable()) wr.add("time_limit")
|
||||
for ((actionId, label) in actions) {
|
||||
attrs.append("<action id=\"$actionId\"")
|
||||
if (!omitContent) {
|
||||
if ((label.value?.length ?: 0) > expandedLimit / 4) { tooLarge = true; break }
|
||||
attrs.append(SnapshotXml.attribute("label", label))
|
||||
if (label.state == "read_error") wr.add("node_read_error")
|
||||
}
|
||||
attrs.append("/>")
|
||||
if (attrs.length > expandedLimit / 4 || !timeAvailable()) { tooLarge = true; break }
|
||||
}
|
||||
val addition = attrs.toString().toByteArray(Charsets.UTF_8).size
|
||||
if (tooLarge || expanded + bytes + addition + stack.size * 7 + 65536 >= expandedLimit) { wr.add("expanded_size_limit"); break }
|
||||
if (!timeAvailable()) { wr.add("time_limit"); break }
|
||||
cuts.add(Cut(xml.length, stack.size - 1))
|
||||
xml.append(attrs); bytes += addition; count++; emitted++; frame.next = 0
|
||||
} else if (frame.next >= frame.children) {
|
||||
xml.append("</node>"); bytes += 7; stack.pop().node.close()
|
||||
} else {
|
||||
if (count >= nodeLimit) { wr.add("node_limit"); break }
|
||||
val index = frame.next++
|
||||
val child = try { frame.node.child(index) } catch (_: Exception) { wr.add("node_read_error"); null }
|
||||
if (child == null) wr.add("window_disappeared")
|
||||
else stack.push(Frame(child, "${frame.path}/$index"))
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
while (stack.isNotEmpty()) {
|
||||
val frame = stack.pop()
|
||||
if (frame.next >= 0) xml.append("</node>")
|
||||
runCatching { frame.node.close() }
|
||||
}
|
||||
}
|
||||
xml.append("</hierarchy>")
|
||||
if (emitted > 0) {
|
||||
val content = xml.toString().toByteArray(Charsets.UTF_8)
|
||||
files[requireNotNull(fileName)] = content; expanded += content.size
|
||||
fileCuts[fileName] = cuts
|
||||
wm.put("fileName", fileName)
|
||||
}
|
||||
}
|
||||
wm.put("status", if (wr.isEmpty() && !wm.isNull("fileName")) "complete" else "partial").put("reasons", JSONArray(wr.toList()))
|
||||
reasons.addAll(wr)
|
||||
}
|
||||
} finally { sources.forEach { runCatching { it.close() } } }
|
||||
if (sources.isEmpty() && reasons.isEmpty()) reasons.add("windows_unavailable")
|
||||
}
|
||||
fun finishMetadata() {
|
||||
metadata.put("status", if (files.isEmpty()) "not_captured" else if (reasons.isEmpty()) "complete" else "partial")
|
||||
.put("capturedAt", if (files.isEmpty()) JSONObject.NULL else now).put("reasons", JSONArray(reasons.toList()))
|
||||
}
|
||||
finishMetadata()
|
||||
var archive: ByteArray? = null
|
||||
while (files.isNotEmpty()) {
|
||||
val output = ByteArrayOutputStream()
|
||||
ZipOutputStream(output).use { zip ->
|
||||
zip.setLevel(1)
|
||||
fun entry(name: String, data: ByteArray) { zip.putNextEntry(ZipEntry(name).apply { time = 0 }); zip.write(data); zip.closeEntry() }
|
||||
entry("manifest.json", metadata.toString().toByteArray(Charsets.UTF_8))
|
||||
files.forEach { (name, data) -> entry(name, data) }
|
||||
}
|
||||
val alreadyTimedOut = "time_limit" in reasons
|
||||
if (!timeAvailable() && !alreadyTimedOut) { finishMetadata(); continue }
|
||||
if (output.size() <= archiveLimit) { archive = output.toByteArray(); break }
|
||||
reasons.add("archive_size_limit")
|
||||
val removed = files.keys.last()
|
||||
val cuts = fileCuts.getValue(removed)
|
||||
if (cuts.size > 1) {
|
||||
val index = (cuts.size / 2).coerceAtLeast(1)
|
||||
val cut = cuts[index]
|
||||
val prefix = String(files.getValue(removed), Charsets.UTF_8).substring(0, cut.offset)
|
||||
files[removed] = (prefix + "</node>".repeat(cut.openNodes) + "</hierarchy>").toByteArray(Charsets.UTF_8)
|
||||
fileCuts[removed] = cuts.take(index)
|
||||
} else files.remove(removed)
|
||||
for (i in 0 until windowMetadata.length()) {
|
||||
val w = windowMetadata.getJSONObject(i)
|
||||
if (w.optString("fileName") == removed) {
|
||||
if (removed !in files) w.put("fileName", JSONObject.NULL)
|
||||
w.put("status", "partial")
|
||||
if (!w.getJSONArray("reasons").toString().contains("archive_size_limit")) w.getJSONArray("reasons").put("archive_size_limit")
|
||||
}
|
||||
}
|
||||
finishMetadata()
|
||||
}
|
||||
return FailureSnapshot(metadata.toString(), archive, context.serverOrigin)
|
||||
}
|
||||
|
||||
private fun timestamp(millis: Long) = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.US).apply { timeZone = TimeZone.getTimeZone("UTC") }.format(Date(millis))
|
||||
}
|
||||
@@ -211,6 +211,45 @@ class AgentApiException(
|
||||
) : Exception(message)
|
||||
|
||||
class AgentApiClient(private val serverUrl: String) {
|
||||
val failureSnapshotOrigin: String get() = ServerUrlPolicy.normalize(serverUrl)
|
||||
/** Stream the two bounded parts; never build another combined copy of the archive. */
|
||||
fun uploadFailureSnapshot(snapshot: cn.ilapage.goauto.agent.diagnostics.FailureSnapshot, token: String) {
|
||||
val metadata = snapshot.metadataJson.toByteArray(Charsets.UTF_8)
|
||||
require(metadata.size <= 64 * 1024 && (snapshot.archive?.size ?: 0) <= 2 * 1024 * 1024)
|
||||
val json = JSONObject(snapshot.metadataJson)
|
||||
val taskId = json.getLong("taskId")
|
||||
val attemptId = json.getString("taskAttemptId")
|
||||
require(taskId > 0 && attemptId.matches(Regex("[0-9a-fA-F]{8}(-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12}")))
|
||||
val boundary = "GoAuto-${UUID.randomUUID()}"
|
||||
val prefix = "--$boundary\r\nContent-Disposition: form-data; name=\"metadata\"\r\nContent-Type: application/json; charset=UTF-8\r\n\r\n".toByteArray(Charsets.UTF_8)
|
||||
val archiveHeader = if (snapshot.archive != null) "\r\n--$boundary\r\nContent-Disposition: form-data; name=\"archive\"; filename=\"failure-snapshot.zip\"\r\nContent-Type: application/zip\r\n\r\n".toByteArray(Charsets.UTF_8) else ByteArray(0)
|
||||
val suffix = "\r\n--$boundary--\r\n".toByteArray(Charsets.UTF_8)
|
||||
val origin = ServerUrlPolicy.normalize(serverUrl)
|
||||
require(snapshot.serverOrigin.isEmpty() || origin == snapshot.serverOrigin)
|
||||
val connection = (URL("$origin/api/agent/v1/purchase-tasks/$taskId/attempts/$attemptId/failure-snapshot").openConnection() as HttpURLConnection).apply {
|
||||
requestMethod = "POST"; connectTimeout = 5000; readTimeout = 5000
|
||||
doOutput = true; useCaches = false; instanceFollowRedirects = false
|
||||
setFixedLengthStreamingMode(prefix.size + metadata.size + archiveHeader.size + (snapshot.archive?.size ?: 0) + suffix.size)
|
||||
setRequestProperty("Content-Type", "multipart/form-data; boundary=$boundary")
|
||||
setRequestProperty("Authorization", "Bearer $token")
|
||||
setRequestProperty("Accept", "application/json")
|
||||
setRequestProperty("Cache-Control", "no-store")
|
||||
}
|
||||
try {
|
||||
connection.outputStream.use { output ->
|
||||
output.write(prefix); output.write(metadata); output.write(archiveHeader)
|
||||
snapshot.archive?.inputStream()?.use { it.copyTo(output, 16 * 1024) }
|
||||
output.write(suffix)
|
||||
}
|
||||
val status = connection.responseCode
|
||||
if (status !in 200..299) throw AgentApiException(status, "SNAPSHOT_UPLOAD_HTTP_$status", "诊断快照上传失败", cn.ilapage.goauto.agent.diagnostics.FailureSnapshotPolicy.retryUpload(status))
|
||||
val response = connection.inputStream.bufferedReader(Charsets.UTF_8).use { it.readText() }
|
||||
val data = JSONObject(response).getJSONObject("data")
|
||||
check(data.getString("taskAttemptId") == attemptId && data.getString("status") == json.getString("status"))
|
||||
data.getBoolean("replayed")
|
||||
} finally { connection.disconnect() }
|
||||
}
|
||||
|
||||
fun backfillOrders(requestId: String, items: List<cn.ilapage.goauto.agent.automation.BackfillItem>, token: String): List<BackfillResult> =
|
||||
parseBackfillResults(post("/api/agent/v1/purchase-tasks/order-backfill", backfillPayload(requestId, items), token))
|
||||
|
||||
|
||||
+37
-1
@@ -1,7 +1,31 @@
|
||||
package cn.ilapage.goauto.agent.persistence
|
||||
|
||||
internal object AgentDiagnosticSchema {
|
||||
const val VERSION = 2
|
||||
const val VERSION = 4
|
||||
|
||||
val failureSnapshotStatements = listOf(
|
||||
"""CREATE TABLE IF NOT EXISTS purchase_failure_snapshot (
|
||||
attempt_id TEXT PRIMARY KEY, server_origin TEXT NOT NULL,
|
||||
task_id INTEGER NOT NULL, device_id INTEGER NOT NULL, phase TEXT NOT NULL,
|
||||
recorded_at INTEGER NOT NULL, expires_at INTEGER NOT NULL,
|
||||
source TEXT NOT NULL, status TEXT NOT NULL, metadata TEXT NOT NULL,
|
||||
archive BLOB, fingerprint TEXT NOT NULL, size_bytes INTEGER NOT NULL
|
||||
)""".trimIndent(),
|
||||
"""CREATE TABLE IF NOT EXISTS purchase_failure_snapshot_queue (
|
||||
attempt_id TEXT PRIMARY KEY, fingerprint TEXT NOT NULL,
|
||||
next_attempt_at INTEGER NOT NULL, attempts INTEGER NOT NULL DEFAULT 0
|
||||
)""".trimIndent(),
|
||||
"""CREATE TABLE IF NOT EXISTS purchase_failure_snapshot_context (
|
||||
attempt_id TEXT PRIMARY KEY, server_origin TEXT NOT NULL,
|
||||
context TEXT NOT NULL, created_at INTEGER NOT NULL
|
||||
)""".trimIndent(),
|
||||
"CREATE INDEX IF NOT EXISTS idx_failure_snapshot_expiry ON purchase_failure_snapshot(expires_at)",
|
||||
)
|
||||
|
||||
val purchaseDiagnosticColumns = linkedMapOf(
|
||||
"task_type" to "TEXT", "task_attempt_id" to "TEXT", "device_id" to "INTEGER",
|
||||
"phase" to "TEXT", "rule_snapshot_hash" to "TEXT",
|
||||
)
|
||||
|
||||
val colorDiagnosticColumns = linkedMapOf(
|
||||
"color_row_count" to "INTEGER",
|
||||
@@ -42,6 +66,11 @@ internal object AgentDiagnosticSchema {
|
||||
initial_selected_size_count INTEGER,
|
||||
selected_summary_present INTEGER,
|
||||
horizontal_swipe_count INTEGER,
|
||||
task_type TEXT,
|
||||
task_attempt_id TEXT,
|
||||
device_id INTEGER,
|
||||
phase TEXT,
|
||||
rule_snapshot_hash TEXT,
|
||||
agent_version TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL
|
||||
)""".trimIndent()
|
||||
@@ -52,4 +81,11 @@ internal object AgentDiagnosticSchema {
|
||||
if (name in existingColumns) null else "ALTER TABLE agent_diagnostic ADD COLUMN $name $definition"
|
||||
}
|
||||
}
|
||||
|
||||
fun migrationStatements(oldVersion: Int, newVersion: Int, existingColumns: Set<String>): List<String> =
|
||||
v2MigrationStatements(oldVersion, newVersion, existingColumns) +
|
||||
(if (oldVersion < 3 && newVersion >= 3) purchaseDiagnosticColumns.mapNotNull { (name, definition) ->
|
||||
if (name in existingColumns) null else "ALTER TABLE agent_diagnostic ADD COLUMN $name $definition"
|
||||
} else emptyList()) +
|
||||
(if (oldVersion < 4 && newVersion >= 4) failureSnapshotStatements else emptyList())
|
||||
}
|
||||
|
||||
+27
-2
@@ -7,6 +7,10 @@ import android.database.sqlite.SQLiteOpenHelper
|
||||
import cn.ilapage.goauto.agent.BuildConfig
|
||||
|
||||
enum class AgentDiagnosticStage {
|
||||
SPEC_ENTRY_CLICK,
|
||||
SPEC_ENTRY_GESTURE,
|
||||
QUICK_CONFIRMATION_CLICK,
|
||||
COLOR_CLICK,
|
||||
DETAIL_ENTRY,
|
||||
SPEC_PANEL_ENTRY,
|
||||
COLOR_DISCOVERY,
|
||||
@@ -20,6 +24,7 @@ enum class AgentDiagnosticStage {
|
||||
}
|
||||
|
||||
enum class AgentDiagnosticReason {
|
||||
SIZE_ADVICE_CLICK_BLOCKED,
|
||||
DETAIL_ENTRY_MATCHED,
|
||||
SPEC_PANEL_OPENED,
|
||||
SPEC_PANEL_RECOVERED,
|
||||
@@ -104,6 +109,11 @@ data class AgentDiagnosticEvent(
|
||||
val selectedSummaryPresent: Boolean? = null,
|
||||
val horizontalSwipeCount: Int? = null,
|
||||
val createdAt: Long = System.currentTimeMillis(),
|
||||
val taskType: String = "collection",
|
||||
val taskAttemptId: String? = null,
|
||||
val deviceId: Long? = null,
|
||||
val phase: String? = null,
|
||||
val ruleSnapshotHash: String? = null,
|
||||
)
|
||||
|
||||
class SafeAgentDiagnosticRecorder(
|
||||
@@ -126,11 +136,12 @@ class AgentDiagnosticStore(context: Context) : SQLiteOpenHelper(context, DATABAS
|
||||
override fun onCreate(db: SQLiteDatabase) {
|
||||
db.execSQL(AgentDiagnosticSchema.createTableSql)
|
||||
db.execSQL("CREATE INDEX idx_agent_diagnostic_task ON agent_diagnostic(task_id, id)")
|
||||
AgentDiagnosticSchema.failureSnapshotStatements.forEach(db::execSQL)
|
||||
}
|
||||
|
||||
override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) {
|
||||
val existing = columnNames(db)
|
||||
AgentDiagnosticSchema.v2MigrationStatements(oldVersion, newVersion, existing).forEach(db::execSQL)
|
||||
AgentDiagnosticSchema.migrationStatements(oldVersion, newVersion, existing).forEach(db::execSQL)
|
||||
}
|
||||
|
||||
private fun columnNames(db: SQLiteDatabase): Set<String> =
|
||||
@@ -144,11 +155,22 @@ class AgentDiagnosticStore(context: Context) : SQLiteOpenHelper(context, DATABAS
|
||||
@Synchronized
|
||||
fun record(event: AgentDiagnosticEvent) {
|
||||
require(event.taskId > 0)
|
||||
require(event.taskType in setOf("collection", "purchase"))
|
||||
if (event.taskType == "purchase") {
|
||||
require(event.taskAttemptId?.matches(Regex("[0-9a-fA-F]{8}(-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12}")) == true)
|
||||
require((event.deviceId ?: 0) > 0 && event.phase in setOf("spec_probe", "purchase"))
|
||||
require(event.ruleSnapshotHash?.matches(Regex("[0-9a-fA-F]{64}")) == true)
|
||||
}
|
||||
val db = writableDatabase
|
||||
db.beginTransaction()
|
||||
try {
|
||||
val values = ContentValues().apply {
|
||||
put("task_id", event.taskId)
|
||||
put("task_type", event.taskType)
|
||||
put("task_attempt_id", event.taskAttemptId)
|
||||
event.deviceId?.let { put("device_id", it) }
|
||||
put("phase", event.phase)
|
||||
put("rule_snapshot_hash", event.ruleSnapshotHash)
|
||||
put("stage", event.stage.name)
|
||||
put("reason", event.reason.name)
|
||||
put("attempt", event.attempt.coerceAtLeast(0))
|
||||
@@ -178,7 +200,10 @@ class AgentDiagnosticStore(context: Context) : SQLiteOpenHelper(context, DATABAS
|
||||
put("agent_version", BuildConfig.VERSION_NAME)
|
||||
put("created_at", event.createdAt)
|
||||
}
|
||||
check(db.insert("agent_diagnostic", null, values) != -1L)
|
||||
val insertedId = db.insertOrThrow("agent_diagnostic", null, values)
|
||||
db.rawQuery("SELECT id FROM agent_diagnostic WHERE id=?", arrayOf(insertedId.toString())).use {
|
||||
check(it.moveToFirst()) { "diagnostic_readback_failed" }
|
||||
}
|
||||
db.delete("agent_diagnostic", "created_at < ?", arrayOf(AgentDiagnosticRetentionPolicy.cutoff(event.createdAt).toString()))
|
||||
db.execSQL(
|
||||
"DELETE FROM agent_diagnostic WHERE id NOT IN " +
|
||||
|
||||
+182
@@ -0,0 +1,182 @@
|
||||
package cn.ilapage.goauto.agent.persistence
|
||||
|
||||
import cn.ilapage.goauto.agent.diagnostics.FailureSnapshot
|
||||
import java.net.URI
|
||||
import java.security.MessageDigest
|
||||
import java.text.SimpleDateFormat
|
||||
import java.text.ParsePosition
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
import java.util.TimeZone
|
||||
import org.json.JSONObject
|
||||
|
||||
/** Small SQL boundary shared by the Android adapter and real SQLite JVM tests. */
|
||||
internal interface FailureSnapshotDatabase {
|
||||
fun execute(sql: String, args: List<Any?> = emptyList())
|
||||
fun query(sql: String, args: List<Any?> = emptyList()): List<Map<String, Any?>>
|
||||
fun <T> transaction(block: () -> T): T
|
||||
}
|
||||
|
||||
internal class FailureSnapshotRepository(
|
||||
private val db: FailureSnapshotDatabase,
|
||||
private val clock: () -> Long = System::currentTimeMillis,
|
||||
private val maxBytes: Long = 64L * 1024 * 1024,
|
||||
) {
|
||||
@Synchronized
|
||||
fun recordFailureSnapshotContext(taskId: Long, attemptId: String, deviceId: Long, phase: String, ruleSnapshotHash: String?, serverOrigin: String) {
|
||||
validateIdentity(taskId, attemptId, deviceId, phase)
|
||||
val origin = origin(serverOrigin)
|
||||
val hash = ruleSnapshotHash?.takeIf(HASH::matches)
|
||||
val now = clock()
|
||||
val context = JSONObject().put("taskId", taskId).put("taskAttemptId", attemptId).put("deviceId", deviceId)
|
||||
.put("phase", phase).put("ruleSnapshotHash", hash ?: JSONObject.NULL).put("ruleSnapshotHashValid", hash != null)
|
||||
.put("recordedAt", utcFormat().format(Date(now))).put("serverOrigin", origin).toString()
|
||||
db.execute("INSERT OR IGNORE INTO purchase_failure_snapshot_context (attempt_id,server_origin,context,created_at) VALUES (?,?,?,?)", listOf(attemptId, origin, context, now))
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun failureSnapshotContext(attemptId: String): String? = db.query(
|
||||
"SELECT context FROM purchase_failure_snapshot_context WHERE attempt_id=? AND created_at>?",
|
||||
listOf(attemptId, clock() - RETENTION_MILLIS),
|
||||
).firstOrNull()?.get("context") as? String
|
||||
|
||||
@Synchronized
|
||||
fun hasFailureSnapshotArchive(attemptId: String): Boolean = db.query(
|
||||
"SELECT attempt_id FROM purchase_failure_snapshot WHERE attempt_id=? AND archive IS NOT NULL AND expires_at>?",
|
||||
listOf(attemptId, clock()),
|
||||
).isNotEmpty()
|
||||
|
||||
@Synchronized
|
||||
fun saveFailureSnapshot(snapshot: FailureSnapshot): Boolean {
|
||||
require(snapshot.metadataJson.toByteArray(Charsets.UTF_8).size <= 64 * 1024)
|
||||
require(snapshot.archive == null || snapshot.archive.size in 1..2 * 1024 * 1024)
|
||||
val metadata = JSONObject(snapshot.metadataJson)
|
||||
val taskId = metadata.getLong("taskId")
|
||||
val attemptId = metadata.getString("taskAttemptId")
|
||||
val deviceId = metadata.getLong("deviceId")
|
||||
val phase = metadata.getString("phase")
|
||||
validateIdentity(taskId, attemptId, deviceId, phase)
|
||||
val serverOrigin = origin(snapshot.serverOrigin)
|
||||
val recordedAt = parseTimestamp(metadata.getString("recordedAt"))
|
||||
val status = metadata.getString("status")
|
||||
val source = metadata.getString("source")
|
||||
require(source in setOf("execution", "recovery"))
|
||||
require(status in setOf("complete", "partial", "not_captured"))
|
||||
require((status == "not_captured") == (snapshot.archive == null))
|
||||
val validHash = metadata.getBoolean("ruleSnapshotHashValid")
|
||||
require(if (validHash) HASH.matches(metadata.getString("ruleSnapshotHash")) else metadata.isNull("ruleSnapshotHash") && snapshot.archive == null)
|
||||
val now = clock()
|
||||
if (recordedAt <= now - RETENTION_MILLIS) return false
|
||||
require(recordedAt <= now + 5 * 60_000)
|
||||
val fingerprint = fingerprint(snapshot)
|
||||
return db.transaction {
|
||||
cleanup(now)
|
||||
val old = db.query("SELECT archive IS NOT NULL AS has_archive,server_origin FROM purchase_failure_snapshot WHERE attempt_id=?", listOf(attemptId)).firstOrNull()
|
||||
if (old != null && ((old["has_archive"] as Number).toInt() != 0 || old["server_origin"] != serverOrigin || snapshot.archive == null || source != "recovery")) return@transaction false
|
||||
db.execute(
|
||||
"INSERT OR REPLACE INTO purchase_failure_snapshot (attempt_id,server_origin,task_id,device_id,phase,recorded_at,expires_at,source,status,metadata,archive,fingerprint,size_bytes) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)",
|
||||
listOf(attemptId, serverOrigin, taskId, deviceId, phase, recordedAt, recordedAt + RETENTION_MILLIS, source, status,
|
||||
snapshot.metadataJson, snapshot.archive, fingerprint, snapshot.metadataJson.toByteArray(Charsets.UTF_8).size.toLong() + (snapshot.archive?.size ?: 0)),
|
||||
)
|
||||
db.execute("INSERT OR REPLACE INTO purchase_failure_snapshot_queue (attempt_id,fingerprint,next_attempt_at,attempts) VALUES (?,?,?,0)", listOf(attemptId, fingerprint, now))
|
||||
check(db.query("SELECT fingerprint FROM purchase_failure_snapshot WHERE attempt_id=?", listOf(attemptId)).single()["fingerprint"] == fingerprint)
|
||||
cleanup(now)
|
||||
db.query("SELECT attempt_id FROM purchase_failure_snapshot WHERE attempt_id=?", listOf(attemptId)).isNotEmpty()
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun pendingFailureSnapshots(serverOrigin: String, nowMillis: Long = clock(), limit: Int = 1): List<FailureSnapshot> = db.transaction {
|
||||
cleanup(nowMillis)
|
||||
db.query("SELECT s.attempt_id,s.metadata,length(s.archive) AS archive_length,s.server_origin FROM purchase_failure_snapshot s JOIN purchase_failure_snapshot_queue q ON s.attempt_id=q.attempt_id AND s.fingerprint=q.fingerprint WHERE s.server_origin=? AND q.next_attempt_at<=? ORDER BY s.recorded_at,s.attempt_id LIMIT ?",
|
||||
listOf(origin(serverOrigin), nowMillis, limit.coerceIn(1, 10))).map { row ->
|
||||
val size = (row["archive_length"] as? Number)?.toInt()
|
||||
require(size == null || size in 1..(2 * 1024 * 1024))
|
||||
val archive = size?.let { length ->
|
||||
// CursorWindow is only ~2MiB on many devices. Never SELECT the whole ZIP row.
|
||||
ByteArray(length).also { bytes ->
|
||||
var offset = 0
|
||||
while (offset < length) {
|
||||
val amount = minOf(256 * 1024, length - offset)
|
||||
val chunk = db.query("SELECT substr(archive,?,?) AS chunk FROM purchase_failure_snapshot WHERE attempt_id=?",
|
||||
listOf(offset + 1, amount, row["attempt_id"])).single()["chunk"] as ByteArray
|
||||
check(chunk.size == amount) { "snapshot_readback_failed" }
|
||||
chunk.copyInto(bytes, offset); offset += amount
|
||||
}
|
||||
}
|
||||
}
|
||||
FailureSnapshot(row["metadata"] as String, archive, row["server_origin"] as String)
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun markFailureSnapshotUploaded(snapshot: FailureSnapshot, terminalStatus: String = "uploaded") {
|
||||
require(terminalStatus in setOf("uploaded", "expired", "rejected"))
|
||||
db.execute("DELETE FROM purchase_failure_snapshot_queue WHERE attempt_id=? AND fingerprint=? AND EXISTS (SELECT 1 FROM purchase_failure_snapshot s WHERE s.attempt_id=purchase_failure_snapshot_queue.attempt_id AND s.server_origin=?)",
|
||||
listOf(JSONObject(snapshot.metadataJson).getString("taskAttemptId"), fingerprint(snapshot), origin(snapshot.serverOrigin)))
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun retryFailureSnapshotLater(snapshot: FailureSnapshot, nowMillis: Long = clock()) = db.transaction {
|
||||
val attemptId = JSONObject(snapshot.metadataJson).getString("taskAttemptId")
|
||||
val digest = fingerprint(snapshot)
|
||||
val queued = db.query("SELECT q.attempts FROM purchase_failure_snapshot_queue q JOIN purchase_failure_snapshot s ON s.attempt_id=q.attempt_id WHERE q.attempt_id=? AND q.fingerprint=? AND s.server_origin=?",
|
||||
listOf(attemptId, digest, origin(snapshot.serverOrigin))).firstOrNull() ?: return@transaction
|
||||
val attempts = (queued["attempts"] as Number).toInt().coerceIn(0, 10)
|
||||
val delay = (60_000L * (1L shl attempts)).coerceAtMost(3_600_000L)
|
||||
db.execute("UPDATE purchase_failure_snapshot_queue SET next_attempt_at=?,attempts=? WHERE attempt_id=? AND fingerprint=?", listOf(nowMillis + delay, attempts + 1, attemptId, digest))
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun cleanupFailureSnapshots(nowMillis: Long = clock()) = db.transaction { cleanup(nowMillis) }
|
||||
|
||||
private fun cleanup(now: Long) {
|
||||
db.execute("DELETE FROM purchase_failure_snapshot WHERE expires_at<=?", listOf(now))
|
||||
db.execute("DELETE FROM purchase_failure_snapshot_context WHERE created_at<=?", listOf(now - RETENTION_MILLIS))
|
||||
val rows = db.query("SELECT attempt_id,size_bytes FROM purchase_failure_snapshot ORDER BY recorded_at,attempt_id")
|
||||
var total = rows.sumOf { (it["size_bytes"] as Number).toLong() }
|
||||
for (row in rows) {
|
||||
if (total <= maxBytes) break
|
||||
db.execute("DELETE FROM purchase_failure_snapshot WHERE attempt_id=?", listOf(row["attempt_id"]))
|
||||
total -= (row["size_bytes"] as Number).toLong()
|
||||
}
|
||||
db.execute("DELETE FROM purchase_failure_snapshot_queue WHERE attempt_id NOT IN (SELECT attempt_id FROM purchase_failure_snapshot)")
|
||||
}
|
||||
|
||||
private fun validateIdentity(taskId: Long, attemptId: String, deviceId: Long, phase: String) {
|
||||
require(taskId > 0 && deviceId > 0 && UUID.matches(attemptId) && phase in setOf("spec_probe", "purchase"))
|
||||
}
|
||||
|
||||
// java.time is unavailable on API 23-25; the exporter writes UTC milliseconds.
|
||||
private fun utcFormat() = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.US).apply {
|
||||
timeZone = TimeZone.getTimeZone("UTC"); isLenient = false
|
||||
}
|
||||
|
||||
private fun parseTimestamp(value: String): Long {
|
||||
val match = TIMESTAMP.matchEntire(value) ?: throw IllegalArgumentException("snapshot_time_invalid")
|
||||
val normalized = match.groupValues[1] + "." + match.groupValues[2].padEnd(3, '0') + "Z"
|
||||
val position = ParsePosition(0)
|
||||
val parsed = utcFormat().parse(normalized, position)
|
||||
require(parsed != null && position.index == normalized.length) { "snapshot_time_invalid" }
|
||||
return parsed.time
|
||||
}
|
||||
|
||||
private fun origin(value: String): String {
|
||||
val normalized = value.trimEnd('/')
|
||||
val uri = URI(normalized)
|
||||
require(uri.scheme in setOf("http", "https") && !uri.host.isNullOrBlank() && uri.userInfo == null && uri.rawQuery == null && uri.fragment == null)
|
||||
return normalized
|
||||
}
|
||||
|
||||
private fun fingerprint(snapshot: FailureSnapshot): String = MessageDigest.getInstance("SHA-256").run {
|
||||
update(snapshot.metadataJson.toByteArray(Charsets.UTF_8)); update(0.toByte()); snapshot.archive?.let(::update)
|
||||
digest().joinToString("") { "%02x".format(it) }
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val RETENTION_MILLIS = 30L * 24 * 60 * 60 * 1000
|
||||
private val UUID = Regex("[0-9a-fA-F]{8}(-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12}")
|
||||
private val HASH = Regex("[0-9a-f]{64}")
|
||||
private val TIMESTAMP = Regex("(\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2})(?:\\.(\\d{1,3}))?Z")
|
||||
}
|
||||
}
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
package cn.ilapage.goauto.agent.persistence
|
||||
|
||||
import android.database.Cursor
|
||||
import cn.ilapage.goauto.agent.diagnostics.FailureSnapshot
|
||||
|
||||
/** Uses the same private SQLite database, without sharing the old 7-day/50-row retention. */
|
||||
class PurchaseFailureSnapshotStore(helper: AgentDiagnosticStore) {
|
||||
private val repository = FailureSnapshotRepository(object : FailureSnapshotDatabase {
|
||||
override fun execute(sql: String, args: List<Any?>) { helper.writableDatabase.execSQL(sql, args.toTypedArray()) }
|
||||
override fun query(sql: String, args: List<Any?>): List<Map<String, Any?>> =
|
||||
helper.readableDatabase.rawQuery(sql, args.map { it?.toString() }.toTypedArray()).use { cursor -> buildList {
|
||||
while (cursor.moveToNext()) add((0 until cursor.columnCount).associate { index ->
|
||||
cursor.getColumnName(index) to when (cursor.getType(index)) {
|
||||
Cursor.FIELD_TYPE_NULL -> null
|
||||
Cursor.FIELD_TYPE_INTEGER -> cursor.getLong(index)
|
||||
Cursor.FIELD_TYPE_FLOAT -> cursor.getDouble(index)
|
||||
Cursor.FIELD_TYPE_BLOB -> cursor.getBlob(index)
|
||||
else -> cursor.getString(index)
|
||||
}
|
||||
})
|
||||
} }
|
||||
override fun <T> transaction(block: () -> T): T {
|
||||
val database = helper.writableDatabase
|
||||
database.beginTransaction()
|
||||
try { val result = block(); database.setTransactionSuccessful(); return result }
|
||||
finally { database.endTransaction() }
|
||||
}
|
||||
})
|
||||
|
||||
fun saveFailureSnapshot(snapshot: FailureSnapshot): Boolean = repository.saveFailureSnapshot(snapshot)
|
||||
fun hasFailureSnapshotArchive(attemptId: String): Boolean = repository.hasFailureSnapshotArchive(attemptId)
|
||||
fun recordFailureSnapshotContext(taskId: Long, attemptId: String, deviceId: Long, phase: String, ruleSnapshotHash: String?, serverOrigin: String) =
|
||||
repository.recordFailureSnapshotContext(taskId, attemptId, deviceId, phase, ruleSnapshotHash, serverOrigin)
|
||||
fun failureSnapshotContext(attemptId: String): String? = repository.failureSnapshotContext(attemptId)
|
||||
fun pendingFailureSnapshots(serverOrigin: String, nowMillis: Long = System.currentTimeMillis(), limit: Int = 1): List<FailureSnapshot> =
|
||||
repository.pendingFailureSnapshots(serverOrigin, nowMillis, limit)
|
||||
fun markFailureSnapshotUploaded(snapshot: FailureSnapshot, terminalStatus: String = "uploaded") = repository.markFailureSnapshotUploaded(snapshot, terminalStatus)
|
||||
fun retryFailureSnapshotLater(snapshot: FailureSnapshot, nowMillis: Long = System.currentTimeMillis()) = repository.retryFailureSnapshotLater(snapshot, nowMillis)
|
||||
fun cleanupFailureSnapshots(nowMillis: Long = System.currentTimeMillis()) = repository.cleanupFailureSnapshots(nowMillis)
|
||||
}
|
||||
+121
-6
@@ -22,6 +22,7 @@ import cn.ilapage.goauto.agent.ClipboardRelayActivity
|
||||
import cn.ilapage.goauto.agent.MainActivity
|
||||
import cn.ilapage.goauto.agent.R
|
||||
import cn.ilapage.goauto.agent.automation.CollectionAssembler
|
||||
import cn.ilapage.goauto.agent.automation.specClickDiagnostic
|
||||
import cn.ilapage.goauto.agent.automation.AgentCapabilities
|
||||
import cn.ilapage.goauto.agent.automation.CurrentPageIdentityRunner
|
||||
import cn.ilapage.goauto.agent.automation.CurrentPageLaunchResult
|
||||
@@ -61,6 +62,11 @@ import cn.ilapage.goauto.agent.persistence.AgentDiagnosticEvent
|
||||
import cn.ilapage.goauto.agent.persistence.AgentDiagnosticReason
|
||||
import cn.ilapage.goauto.agent.persistence.AgentDiagnosticStage
|
||||
import cn.ilapage.goauto.agent.persistence.SafeAgentDiagnosticRecorder
|
||||
import cn.ilapage.goauto.agent.persistence.PurchaseFailureSnapshotStore
|
||||
import cn.ilapage.goauto.agent.diagnostics.AccessibilitySnapshotSource
|
||||
import cn.ilapage.goauto.agent.diagnostics.FailureSnapshotCapture
|
||||
import cn.ilapage.goauto.agent.diagnostics.FailureSnapshotPolicy
|
||||
import cn.ilapage.goauto.agent.diagnostics.SnapshotContext
|
||||
import cn.ilapage.goauto.agent.ui.PurchaseResultBubblePolicy
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
@@ -78,6 +84,7 @@ class AgentForegroundService : Service() {
|
||||
private val executor: ScheduledExecutorService = Executors.newSingleThreadScheduledExecutor()
|
||||
private val taskExecutor: ExecutorService = Executors.newSingleThreadExecutor()
|
||||
private val diagnosticExecutor: ExecutorService = Executors.newSingleThreadExecutor()
|
||||
private val snapshotUploadExecutor: ScheduledExecutorService = Executors.newSingleThreadScheduledExecutor()
|
||||
private val taskMutex = TaskExecutionMutex()
|
||||
private val backfillGuard = OrderBackfillGuard(taskMutex)
|
||||
private val runningTaskId = AtomicReference<Long?>(null)
|
||||
@@ -98,6 +105,7 @@ class AgentForegroundService : Service() {
|
||||
private lateinit var purchaseStore: PurchaseTaskStore
|
||||
private lateinit var diagnosticStore: AgentDiagnosticStore
|
||||
private lateinit var diagnosticRecorder: SafeAgentDiagnosticRecorder
|
||||
private lateinit var failureSnapshotStore: PurchaseFailureSnapshotStore
|
||||
private lateinit var connectivityManager: ConnectivityManager
|
||||
|
||||
private val networkCallback = object : ConnectivityManager.NetworkCallback() {
|
||||
@@ -115,14 +123,15 @@ class AgentForegroundService : Service() {
|
||||
stateStore.setKeepScreenOn(false)
|
||||
purchaseStore = PurchaseTaskStore(this)
|
||||
diagnosticStore = AgentDiagnosticStore(this)
|
||||
failureSnapshotStore = PurchaseFailureSnapshotStore(diagnosticStore)
|
||||
diagnosticRecorder = SafeAgentDiagnosticRecorder(
|
||||
persist = { event ->
|
||||
diagnosticExecutor.execute {
|
||||
runCatching { diagnosticStore.record(event) }
|
||||
.onFailure { error -> Log.w("GoAutoDiagnostic", "agent diagnostic write failed: ${error.javaClass.simpleName}") }
|
||||
.onFailure(::logDiagnosticPersistenceFailure)
|
||||
}
|
||||
},
|
||||
onFailure = { error -> Log.w("GoAutoDiagnostic", "agent diagnostic write failed: ${error.javaClass.simpleName}") },
|
||||
onFailure = ::logDiagnosticPersistenceFailure,
|
||||
)
|
||||
val restoredPurchaseTaskId = purchaseStore.activeTaskId()
|
||||
runningTaskId.set(restoredPurchaseTaskId)
|
||||
@@ -134,6 +143,8 @@ class AgentForegroundService : Service() {
|
||||
registerNetworkCallback()
|
||||
resumeCollectionCooldown()
|
||||
executor.scheduleWithFixedDelay(::triggerSync, 0, HEARTBEAT_SECONDS, TimeUnit.SECONDS)
|
||||
// Independent retention/upload ticks run even when no further task is dispatched.
|
||||
snapshotUploadExecutor.scheduleWithFixedDelay(::maintainFailureSnapshots, 0, 60, TimeUnit.SECONDS)
|
||||
}
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
@@ -171,6 +182,7 @@ class AgentForegroundService : Service() {
|
||||
diagnosticExecutor.shutdown()
|
||||
runCatching { diagnosticExecutor.awaitTermination(1, TimeUnit.SECONDS) }
|
||||
diagnosticExecutor.shutdownNow()
|
||||
snapshotUploadExecutor.shutdownNow()
|
||||
purchaseStore.close()
|
||||
diagnosticStore.close()
|
||||
super.onDestroy()
|
||||
@@ -576,7 +588,17 @@ class AgentForegroundService : Service() {
|
||||
GoAutoAccessibilityService.instance?.dismissPurchaseResultBubble()
|
||||
acquireTaskWakeLock()
|
||||
var resultSafelyStored = false
|
||||
var knownResultType: String? = null
|
||||
var snapshotContext: SnapshotContext? = null
|
||||
var executionEntered = false
|
||||
var snapshotAttempted = false
|
||||
var probeDiagnosticEvents = 0
|
||||
val lastStep = AtomicReference("started")
|
||||
fun failureSnapshot(resultType: String, errorCode: String) {
|
||||
if (snapshotAttempted || !FailureSnapshotPolicy.exceptionEligible(knownResultType, resultSafelyStored)) return
|
||||
snapshotAttempted = true
|
||||
savePurchaseFailureSnapshot(snapshotContext?.copy(errorCode = errorCode), resultType, executionEntered)
|
||||
}
|
||||
try {
|
||||
val claimed = if (initial.status == "pending") {
|
||||
api.claimPurchaseTask(initial.taskId, UUID.randomUUID().toString(), token)
|
||||
@@ -585,10 +607,25 @@ class AgentForegroundService : Service() {
|
||||
api.startPurchaseTask(claimed.taskId, UUID.randomUUID().toString(), token)
|
||||
} else claimed
|
||||
check(task.status == "running" && task.taskAttemptId.isNotBlank()) { "采购任务没有有效 attempt" }
|
||||
val diagnosticDeviceId = runCatching { identityStore.credentials()?.takeIf { it.token == token }?.deviceId }.getOrNull()
|
||||
if (task.taskAttemptId.matches(Regex("[0-9a-fA-F]{8}(-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12}")) &&
|
||||
(diagnosticDeviceId ?: 0) > 0 && task.phase in setOf("spec_probe", "purchase")) {
|
||||
snapshotContext = SnapshotContext(task.taskId, requireNotNull(diagnosticDeviceId), task.taskAttemptId, task.phase,
|
||||
task.ruleSnapshotHash, "PURCHASE_EXECUTION_FAILED", BuildConfig.VERSION_NAME, "execution", api.failureSnapshotOrigin)
|
||||
runCatching { failureSnapshotStore.recordFailureSnapshotContext(task.taskId, task.taskAttemptId, diagnosticDeviceId,
|
||||
task.phase, task.ruleSnapshotHash, api.failureSnapshotOrigin) }.onFailure(::logDiagnosticPersistenceFailure)
|
||||
}
|
||||
val snapshotHashValid = task.ruleSnapshotHash.matches(Regex("^[0-9a-f]{64}$"))
|
||||
val snapshotHash = task.ruleSnapshotHash.takeIf { snapshotHashValid } ?: "0".repeat(64)
|
||||
purchaseStore.recordRunning(task.taskId, task.taskAttemptId, snapshotHash)
|
||||
val taskLabel = if (task.executionMode == "live") "正式采购" else "采购演练"
|
||||
val probeDiagnostic: (AgentDiagnosticEvent) -> Unit = { event ->
|
||||
probeDiagnosticEvents++
|
||||
diagnosticRecorder.record(event.copy(
|
||||
taskId = task.taskId, taskType = "purchase", taskAttemptId = task.taskAttemptId,
|
||||
deviceId = diagnosticDeviceId, phase = task.phase, ruleSnapshotHash = task.ruleSnapshotHash,
|
||||
))
|
||||
}
|
||||
stateStore.update("BUSY", "正在执行${taskLabel}任务 #${task.taskId}", tokenStored = true)
|
||||
updateNotification("$taskLabel #${task.taskId}")
|
||||
|
||||
@@ -612,8 +649,12 @@ class AgentForegroundService : Service() {
|
||||
PurchaseRehearsalExecutor(
|
||||
driver = accessibility,
|
||||
openLink = { PddLinkLauncher(this).open(it, preferDirect = true) },
|
||||
probeSpecs = { collectPurchaseProbe(accessibility, task, parsedRule) },
|
||||
probeSpecs = { collectPurchaseProbe(accessibility, task, parsedRule, probeDiagnostic) },
|
||||
probeClickDiagnostic = { stage, target, outcome ->
|
||||
probeDiagnostic(specClickDiagnostic(task.taskId, stage, target, outcome))
|
||||
},
|
||||
stepChanged = { step ->
|
||||
executionEntered = true
|
||||
lastStep.set(step)
|
||||
purchaseStore.updateStep(task.taskId, task.taskAttemptId, step)
|
||||
},
|
||||
@@ -656,6 +697,9 @@ class AgentForegroundService : Service() {
|
||||
// #334: a spec probe that read zero colors/sizes must fail explicitly
|
||||
// instead of being reported as a normal, empty spec_probe_completed.
|
||||
val outcome = PurchaseSpecProbePolicy.demote(rawOutcome)
|
||||
knownResultType = outcome.resultType
|
||||
// The live read finishes before persistence, the new failure bubble, return to Agent, or lease release.
|
||||
if (FailureSnapshotPolicy.eligible(task.phase, outcome.resultType, false)) failureSnapshot(outcome.resultType, outcome.errorCode ?: "PURCHASE_EXECUTION_FAILED")
|
||||
val requestId = UUID.randomUUID().toString()
|
||||
val payload = purchaseResultPayload(requestId, task.taskAttemptId, outcome)
|
||||
purchaseStore.completeAndEnqueue(task.taskId, task.taskAttemptId, requestId, payload)
|
||||
@@ -674,16 +718,22 @@ class AgentForegroundService : Service() {
|
||||
stateStore.update(if (outcome.resultType == "failed") "TASK_ERROR" else "ONLINE", message, tokenStored = true)
|
||||
updateNotification(if (outcome.resultType == "failed") "$taskLabel #${task.taskId} 失败" else "$taskLabel #${task.taskId} 已提交")
|
||||
} catch (error: AgentApiException) {
|
||||
failureSnapshot("failed", error.code.takeIf { it.matches(Regex("[A-Z][A-Z0-9_]{0,63}")) } ?: "PURCHASE_API_FAILED")
|
||||
stateStore.update("TASK_ERROR", "${error.code}:${error.message}", tokenStored = true)
|
||||
} catch (error: Exception) {
|
||||
failureSnapshot("failed", "PURCHASE_EXECUTION_EXCEPTION")
|
||||
stateStore.update("TASK_ERROR", error.message ?: "采购演练执行异常", tokenStored = true)
|
||||
} finally {
|
||||
if (snapshotContext?.phase == "spec_probe" && probeDiagnosticEvents == 0) Log.i("GoAutoDiagnostic", "purchase_diagnostic_no_event")
|
||||
if (!resultSafelyStored) cancelIdleReturn("采购结果未安全保存")
|
||||
releaseTaskWakeLock()
|
||||
}
|
||||
}
|
||||
|
||||
private fun collectPurchaseProbe(accessibility: GoAutoAccessibilityService, task: PurchaseAgentTask, purchaseRule: PurchaseRule): String? {
|
||||
private fun collectPurchaseProbe(
|
||||
accessibility: GoAutoAccessibilityService, task: PurchaseAgentTask, purchaseRule: PurchaseRule,
|
||||
diagnostic: (AgentDiagnosticEvent) -> Unit,
|
||||
): String? {
|
||||
val snapshot = accessibility.capture()
|
||||
val activity = snapshot.activityName ?: return null
|
||||
val collector = PurchaseRehearsalExecutor.collectorForProbe(purchaseRule)
|
||||
@@ -694,7 +744,8 @@ class AgentForegroundService : Service() {
|
||||
pageEvidence = PageEvidence("com.xunmeng.pinduoduo", activity, NodeSelector()),
|
||||
collector = collector,
|
||||
)
|
||||
val result = PddProductDetailCollector(accessibility).collect(task.pddGoodsId, rule)
|
||||
val result = PddProductDetailCollector(accessibility, taskId = task.taskId, diagnostic = diagnostic)
|
||||
.collect(task.pddGoodsId, rule)
|
||||
// #334: a collector failure (e.g. SPEC_PANEL_TOP_COLLAPSED) must not
|
||||
// collapse into a generic "spec probe failed" outcome. Carry the
|
||||
// real code/message through the opaque probeSpecs callback so
|
||||
@@ -725,7 +776,11 @@ class AgentForegroundService : Service() {
|
||||
try {
|
||||
api.markPurchaseOrderSubmitStarted(interrupted.taskId, boundaryRequestId, token)
|
||||
val automation = GoAutoAccessibilityService.instance?.let(::PurchaseLiveAutomation)
|
||||
val evidence = automation?.readOrderResult()
|
||||
val evidence = try { automation?.readOrderResult() } catch (error: Exception) {
|
||||
savePurchaseFailureSnapshot(restoredFailureSnapshotContext(interrupted.attemptId, api.failureSnapshotOrigin,
|
||||
"PURCHASE_ORDER_RESULT_UNKNOWN"), "order_result_unknown", true)
|
||||
throw error
|
||||
}
|
||||
val outcome = if (evidence == null) {
|
||||
val readFailure = automation?.lastOrderReadFailure
|
||||
PurchaseExecutionOutcome(
|
||||
@@ -737,6 +792,11 @@ class AgentForegroundService : Service() {
|
||||
} else {
|
||||
PurchaseExecutionOutcome("order_created", message = "订单已创建,等待人工检查和支付", pddOrderNo = evidence.orderNo, orderSubmittedAt = evidence.submittedAt, pddOrderAmountCent = evidence.pddOrderAmountCent)
|
||||
}
|
||||
if (outcome.resultType == "order_result_unknown") {
|
||||
val context = restoredFailureSnapshotContext(interrupted.attemptId, api.failureSnapshotOrigin,
|
||||
outcome.errorCode ?: "PURCHASE_ORDER_RESULT_UNKNOWN")
|
||||
savePurchaseFailureSnapshot(context, outcome.resultType, true)
|
||||
}
|
||||
val requestId = UUID.randomUUID().toString()
|
||||
purchaseStore.completeAndEnqueue(interrupted.taskId, interrupted.attemptId, requestId, purchaseResultPayload(requestId, interrupted.attemptId, outcome))
|
||||
} catch (_: AgentApiException) {
|
||||
@@ -751,6 +811,8 @@ class AgentForegroundService : Service() {
|
||||
"AGENT_RESTARTED_DURING_EXECUTION",
|
||||
"手机服务在创建订单前重启,任务已停止且不会重复操作拼多多",
|
||||
)
|
||||
savePurchaseFailureSnapshot(restoredFailureSnapshotContext(interrupted.attemptId, api.failureSnapshotOrigin,
|
||||
"AGENT_RESTARTED_DURING_EXECUTION"), "failed", false, "process_interrupted")
|
||||
purchaseStore.completeAndEnqueue(
|
||||
interrupted.taskId,
|
||||
interrupted.attemptId,
|
||||
@@ -769,6 +831,59 @@ class AgentForegroundService : Service() {
|
||||
runningTaskId.set(purchaseStore.activeTaskId())
|
||||
}
|
||||
|
||||
private fun logDiagnosticPersistenceFailure(error: Throwable) {
|
||||
val category = when (error) {
|
||||
is IllegalArgumentException -> "diagnostic_validation_failed"
|
||||
is android.database.SQLException -> "diagnostic_sql_failed"
|
||||
else -> "diagnostic_persistence_failed"
|
||||
}
|
||||
// Never include exception messages or a stack trace: SQL/binder errors may contain page data.
|
||||
Log.w("GoAutoDiagnostic", category)
|
||||
}
|
||||
|
||||
private fun savePurchaseFailureSnapshot(context: SnapshotContext?, resultType: String, executionEntered: Boolean, reason: String? = null) {
|
||||
if (context == null) { Log.i("GoAutoDiagnostic", "failure_snapshot_attempt_context_missing"); return }
|
||||
runCatching {
|
||||
val accessibility = GoAutoAccessibilityService.instance
|
||||
FailureSnapshotCapture(failureSnapshotStore::hasFailureSnapshotArchive, { failureSnapshotStore.saveFailureSnapshot(it) })
|
||||
.record(context, resultType, false, executionEntered, accessibility != null,
|
||||
{ AccessibilitySnapshotSource(requireNotNull(accessibility)).windows() }, reason)
|
||||
}.onFailure(::logDiagnosticPersistenceFailure)
|
||||
}
|
||||
|
||||
private fun restoredFailureSnapshotContext(attemptId: String, serverOrigin: String, errorCode: String): SnapshotContext? = runCatching {
|
||||
val raw = failureSnapshotStore.failureSnapshotContext(attemptId) ?: return@runCatching null
|
||||
val context = JSONObject(raw)
|
||||
if (context.getString("serverOrigin") != serverOrigin) return@runCatching null
|
||||
SnapshotContext(context.getLong("taskId"), context.getLong("deviceId"), context.getString("taskAttemptId"),
|
||||
context.getString("phase"), if (context.isNull("ruleSnapshotHash")) null else context.getString("ruleSnapshotHash"),
|
||||
errorCode, BuildConfig.VERSION_NAME, "recovery", serverOrigin)
|
||||
}.onFailure(::logDiagnosticPersistenceFailure).getOrNull()
|
||||
|
||||
private fun maintainFailureSnapshots() {
|
||||
runCatching {
|
||||
failureSnapshotStore.cleanupFailureSnapshots()
|
||||
// Result submission has priority. Snapshot transfer never owns the device/task mutex.
|
||||
if (purchaseStore.pendingOutbox().isNotEmpty()) return@runCatching
|
||||
val configured = settingsStore.serverUrl()
|
||||
if (configured.isBlank()) return@runCatching
|
||||
val origin = ServerUrlPolicy.normalize(configured)
|
||||
val credentials = identityStore.credentials() ?: return@runCatching
|
||||
val snapshot = failureSnapshotStore.pendingFailureSnapshots(origin).firstOrNull() ?: return@runCatching
|
||||
if (purchaseStore.pendingOutbox().isNotEmpty() ||
|
||||
ServerUrlPolicy.normalize(settingsStore.serverUrl()) != origin || identityStore.credentials() != credentials) return@runCatching
|
||||
try {
|
||||
AgentApiClient(origin).uploadFailureSnapshot(snapshot, credentials.token)
|
||||
failureSnapshotStore.markFailureSnapshotUploaded(snapshot)
|
||||
} catch (error: AgentApiException) {
|
||||
if (FailureSnapshotPolicy.retryUpload(error.status)) failureSnapshotStore.retryFailureSnapshotLater(snapshot)
|
||||
else failureSnapshotStore.markFailureSnapshotUploaded(snapshot, if (error.status == 410) "expired" else "rejected")
|
||||
} catch (_: Exception) {
|
||||
failureSnapshotStore.retryFailureSnapshotLater(snapshot)
|
||||
}
|
||||
}.onFailure(::logDiagnosticPersistenceFailure)
|
||||
}
|
||||
|
||||
private fun executeTask(
|
||||
api: AgentApiClient,
|
||||
initialTask: cn.ilapage.goauto.agent.network.AgentTask,
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
package cn.ilapage.goauto.agent
|
||||
|
||||
import cn.ilapage.goauto.agent.diagnostics.*
|
||||
import org.json.JSONObject
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Test
|
||||
|
||||
class FailureSnapshotCaptureTest {
|
||||
private val context = SnapshotContext(9,4,"11111111-1111-4111-8111-111111111111","purchase","a".repeat(64),"FAILED","test","execution")
|
||||
@Test fun noAttemptOrSuccessfulStoredResultDoesNotReadWindows() {
|
||||
var calls=0
|
||||
val capture=FailureSnapshotCapture(hasArchive={false}, persist={calls++})
|
||||
val source={ calls++; emptyList<SnapshotWindow>() }
|
||||
capture.record(null,"failed",false,true,true,source)
|
||||
capture.record(context,"order_created",false,true,true,source)
|
||||
capture.record(context,"failed",true,true,true,source)
|
||||
assertEquals(0,calls)
|
||||
}
|
||||
@Test fun notEnteredUnavailableAndInterruptedNeverCaptureCurrentScreen() {
|
||||
val saved=mutableListOf<FailureSnapshot>()
|
||||
val capture=FailureSnapshotCapture(hasArchive={false},persist={saved.add(it)})
|
||||
val source={ fail("must not capture current screen"); emptyList<SnapshotWindow>() }
|
||||
capture.record(context,"failed",false,false,true,source)
|
||||
capture.record(context,"failed",false,true,false,source)
|
||||
capture.record(context.copy(source="recovery"),"failed",false,true,true,source,"process_interrupted")
|
||||
assertEquals(listOf("execution_not_entered","accessibility_unavailable","process_interrupted"),saved.map { JSONObject(it.metadataJson).getJSONArray("reasons").getString(0) })
|
||||
}
|
||||
@Test fun existingArchivePreventsAnyRecaptureDuringRecovery() {
|
||||
val capture=FailureSnapshotCapture(hasArchive={true},persist={fail("must preserve original ZIP")})
|
||||
capture.record(context.copy(source="recovery"),"order_result_unknown",false,true,true,{fail("must preserve original scene"); emptyList()})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
package cn.ilapage.goauto.agent
|
||||
|
||||
import cn.ilapage.goauto.agent.diagnostics.*
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Test
|
||||
import java.io.ByteArrayInputStream
|
||||
import java.util.zip.ZipInputStream
|
||||
import javax.xml.parsers.DocumentBuilderFactory
|
||||
import org.json.JSONObject
|
||||
|
||||
class FailureSnapshotExporterTest {
|
||||
private val context = SnapshotContext(9, 4, "11111111-1111-4111-8111-111111111111", "spec_probe", "a".repeat(64), "TEST_FAILURE", "test", "execution")
|
||||
private fun node(values: Map<String, String> = emptyMap(), children: List<SnapshotNode> = emptyList()) = object : SnapshotNode {
|
||||
override fun attribute(name: String) = SnapshotAttribute(values[name] ?: if (name == "password") "false" else null)
|
||||
override fun childCount() = children.size
|
||||
override fun child(index: Int) = children[index]
|
||||
}
|
||||
private fun window(root: SnapshotNode?) = object : SnapshotWindow {
|
||||
override fun attribute(name: String) = SnapshotAttribute(if (name == "id") "1" else null)
|
||||
override fun root() = root
|
||||
}
|
||||
private fun files(bytes: ByteArray): Map<String, ByteArray> = buildMap {
|
||||
ZipInputStream(ByteArrayInputStream(bytes)).use { zip ->
|
||||
while (true) { val entry = zip.nextEntry ?: break; put(entry.name, zip.readBytes()) }
|
||||
}
|
||||
}
|
||||
@Test fun validXmlPreservesRawOrderInvisibleTextAndIllegalCodeUnits() {
|
||||
val text = "中文😀\n\t\r<&\"\u0001\uD800"
|
||||
val result = FailureSnapshotExporter().capture(context) { listOf(window(node(children=listOf(node(mapOf("text" to text,"visible-to-user" to "false")),node(mapOf("text" to "same")),node(mapOf("text" to "same")))))) }
|
||||
val entries = files(requireNotNull(result.archive))
|
||||
val doc = DocumentBuilderFactory.newInstance().newDocumentBuilder().parse(ByteArrayInputStream(entries.getValue("window-1.xml")))
|
||||
val nodes = doc.getElementsByTagName("node")
|
||||
assertEquals(4, nodes.length)
|
||||
val first = nodes.item(1) as org.w3c.dom.Element
|
||||
assertEquals("0/0", first.getAttribute("path"))
|
||||
assertEquals(text, SnapshotXml.decode(first.getAttribute("text-base64")))
|
||||
assertEquals("false", first.getAttribute("visible-to-user"))
|
||||
assertEquals(JSONObject(result.metadataJson).toString(), JSONObject(String(entries.getValue("manifest.json"), Charsets.UTF_8)).toString())
|
||||
}
|
||||
@Test fun passwordOmitsEveryContentFieldAndBackup() {
|
||||
val values = SnapshotXml.contentAttributes.associateWith { "SECRET\u0001" } + ("password" to "true")
|
||||
val result = FailureSnapshotExporter().capture(context) { listOf(window(node(values))) }
|
||||
val xml = String(files(requireNotNull(result.archive)).getValue("window-1.xml"), Charsets.UTF_8)
|
||||
assertFalse(xml.contains("SECRET"))
|
||||
SnapshotXml.contentAttributes.forEach { assertFalse(xml.contains(" $it=")); assertFalse(xml.contains(" $it-state=")); assertFalse(xml.contains(" $it-base64=")) }
|
||||
}
|
||||
@Test fun rootNullIsNotCapturedAndInvalidHashNeverReadsWindows() {
|
||||
val result = FailureSnapshotExporter().capture(context) { listOf(window(null)) }
|
||||
assertNull(result.archive)
|
||||
assertEquals("not_captured", JSONObject(result.metadataJson).getString("status"))
|
||||
assertTrue(JSONObject(result.metadataJson).isNull("capturedAt"))
|
||||
var called = false
|
||||
val invalid = FailureSnapshotExporter().capture(context.copy(ruleSnapshotHash="bad")) { called=true; emptyList() }
|
||||
assertFalse(called)
|
||||
assertEquals("rule_hash_invalid", JSONObject(invalid.metadataJson).getJSONArray("reasons").getString(0))
|
||||
}
|
||||
@Test fun budgetStopsTraversalWithLegalXmlAndPartialStatus() {
|
||||
val result = FailureSnapshotExporter(nodeLimit=2).capture(context) { listOf(window(node(children=List(10) { node(mapOf("text" to "$it")) }))) }
|
||||
val xml = files(requireNotNull(result.archive)).getValue("window-1.xml")
|
||||
val doc = DocumentBuilderFactory.newInstance().newDocumentBuilder().parse(ByteArrayInputStream(xml))
|
||||
assertEquals(2, doc.getElementsByTagName("node").length)
|
||||
assertEquals("partial", JSONObject(result.metadataJson).getString("status"))
|
||||
assertTrue(JSONObject(result.metadataJson).getJSONArray("reasons").toString().contains("node_limit"))
|
||||
}
|
||||
@Test fun archiveBudgetRetainsLegalPartialTreeInsteadOfDiscardingEntireWindow() {
|
||||
val random=java.util.Random(7)
|
||||
val children=List(30) { node(mapOf("text" to CharArray(1000) { (33+random.nextInt(90)).toChar() }.concatToString())) }
|
||||
val result=FailureSnapshotExporter(archiveLimit=4096).capture(context) { listOf(window(node(children=children))) }
|
||||
assertNotNull("small prefix of a large window must survive ZIP cap",result.archive)
|
||||
assertTrue(result.archive!!.size<=4096)
|
||||
val xml=files(requireNotNull(result.archive)).getValue("window-1.xml")
|
||||
val doc=DocumentBuilderFactory.newInstance().newDocumentBuilder().parse(ByteArrayInputStream(xml))
|
||||
assertTrue(doc.getElementsByTagName("node").length in 1..29)
|
||||
assertTrue(JSONObject(result.metadataJson).getJSONArray("reasons").toString().contains("archive_size_limit"))
|
||||
}
|
||||
@Test fun ordinaryXmlPreservesWhitespaceEmojiAndNullVsUnsupportedVsReadError() {
|
||||
val root=object:SnapshotNode {
|
||||
override fun attribute(name:String)=when(name) {
|
||||
"password" -> SnapshotAttribute("false")
|
||||
"text" -> SnapshotAttribute("中文😀\n\r\t<&\"")
|
||||
"hint" -> SnapshotAttribute(state="unsupported")
|
||||
"state-description" -> SnapshotAttribute(state="read_error")
|
||||
else -> SnapshotAttribute()
|
||||
}
|
||||
override fun childCount()=0
|
||||
override fun child(index:Int):SnapshotNode?=null
|
||||
}
|
||||
val result=FailureSnapshotExporter().capture(context) { listOf(window(root)) }
|
||||
val doc=DocumentBuilderFactory.newInstance().newDocumentBuilder().parse(ByteArrayInputStream(files(result.archive!!).getValue("window-1.xml")))
|
||||
val element=doc.getElementsByTagName("node").item(0) as org.w3c.dom.Element
|
||||
assertEquals("中文😀\n\r\t<&\"",element.getAttribute("text"))
|
||||
assertEquals("unsupported",element.getAttribute("hint-state"))
|
||||
assertEquals("read_error",element.getAttribute("state-description-state"))
|
||||
assertEquals("null",element.getAttribute("tooltip-state"))
|
||||
assertEquals("partial",JSONObject(result.metadataJson).getString("status"))
|
||||
}
|
||||
@Test fun passwordActionLabelsAndUnknownPasswordContentAreNeverReadOrSaved() {
|
||||
for (password in listOf(SnapshotAttribute("true"),SnapshotAttribute(state="read_error"))) {
|
||||
val root=object:SnapshotNode {
|
||||
override fun attribute(name:String):SnapshotAttribute {
|
||||
if(name in SnapshotXml.contentAttributes) fail("password content must not even be queried")
|
||||
return if(name=="password") password else SnapshotAttribute()
|
||||
}
|
||||
override fun actions()=listOf(16 to SnapshotAttribute("secret\u0001"))
|
||||
override fun childCount()=0
|
||||
override fun child(index:Int):SnapshotNode?=null
|
||||
}
|
||||
val result=FailureSnapshotExporter().capture(context) { listOf(window(root)) }
|
||||
val xml=String(files(result.archive!!).getValue("window-1.xml"),Charsets.UTF_8)
|
||||
assertTrue(xml.contains("<action id=\"16\"/>")); assertFalse(xml.contains("label")); assertFalse(xml.contains("secret"))
|
||||
}
|
||||
}
|
||||
@Test fun failedChildCountMustNotPretendTheNodeHasZeroChildren() {
|
||||
val root=object:SnapshotNode {
|
||||
override fun attribute(name:String)=SnapshotAttribute(if(name=="password") "false" else null)
|
||||
override fun childCount():Int=throw IllegalStateException("synthetic")
|
||||
override fun child(index:Int):SnapshotNode?=null
|
||||
}
|
||||
val result=FailureSnapshotExporter().capture(context) { listOf(window(root)) }
|
||||
val xml=String(files(result.archive!!).getValue("window-1.xml"),Charsets.UTF_8)
|
||||
assertTrue(xml.contains("child-count-state=\"read_error\"")); assertFalse(xml.contains("child-count=\"0\""))
|
||||
}
|
||||
@Test fun timeBudgetChecksImmediatelyAfterBinderReadAndNeverVisitsLaterWindow() {
|
||||
var elapsed=0L
|
||||
var laterReads=0
|
||||
val first=object:SnapshotWindow {
|
||||
override fun attribute(name:String):SnapshotAttribute { elapsed=2000; return SnapshotAttribute("1") }
|
||||
override fun root():SnapshotNode? { fail("budget was exceeded"); return null }
|
||||
}
|
||||
val later=object:SnapshotWindow {
|
||||
override fun attribute(name:String):SnapshotAttribute { laterReads++; return SnapshotAttribute() }
|
||||
override fun root():SnapshotNode? { laterReads++; return null }
|
||||
}
|
||||
val result=FailureSnapshotExporter(monotonicMillis={elapsed}).capture(context) { listOf(first,later) }
|
||||
assertEquals(0,laterReads); assertNull(result.archive)
|
||||
assertTrue(JSONObject(result.metadataJson).getJSONArray("reasons").toString().contains("time_limit"))
|
||||
}
|
||||
@Test fun foregroundAgentGuardRunsInsideCaptureBudgetAndReportsNoCapture() {
|
||||
val result=FailureSnapshotExporter().capture(context) { throw SnapshotUnavailableException("execution_not_entered") }
|
||||
assertNull(result.archive)
|
||||
assertEquals("execution_not_entered",JSONObject(result.metadataJson).getJSONArray("reasons").getString(0))
|
||||
}
|
||||
@Test fun expandedAndDepthLimitsRetainParseablePartialTrees() {
|
||||
var deep:SnapshotNode=node()
|
||||
repeat(300) { deep=node(children=listOf(deep)) }
|
||||
val cases=listOf(
|
||||
FailureSnapshotExporter() to deep,
|
||||
FailureSnapshotExporter(expandedLimit=96*1024) to node(children=List(100) { node(mapOf("text" to "中".repeat(1000))) }),
|
||||
)
|
||||
for ((exporter,root) in cases) {
|
||||
val result=exporter.capture(context) { listOf(window(root)) }
|
||||
val entries=files(result.archive!!)
|
||||
val doc=DocumentBuilderFactory.newInstance().newDocumentBuilder().parse(ByteArrayInputStream(entries.getValue("window-1.xml")))
|
||||
assertTrue(doc.getElementsByTagName("node").length in 1..250)
|
||||
assertEquals("partial",JSONObject(result.metadataJson).getString("status"))
|
||||
assertTrue(entries.values.sumOf { it.size }<=8*1024*1024)
|
||||
}
|
||||
}
|
||||
@Test fun utf16EncodingMatchesExternalBase64Standard() {
|
||||
assertEquals(" text-base64=\"AAHYAA==\"",SnapshotXml.attribute("text",SnapshotAttribute("\u0001\uD800")))
|
||||
assertArrayEquals(byteArrayOf(0,1,0xD8.toByte(),0),java.util.Base64.getDecoder().decode("AAHYAA=="))
|
||||
}
|
||||
@Test fun generatesCrossPlatformFixtureFromSyntheticNodesOnly() {
|
||||
fun syntheticWindow(id:Int,root:SnapshotNode?)=object:SnapshotWindow {
|
||||
override fun attribute(name:String)=SnapshotAttribute(when(name) {
|
||||
"id" -> id.toString(); "type" -> "1"; "layer" -> id.toString(); "title" -> "合成测试😀"
|
||||
"active","focused" -> "true"; "bounds" -> "[0,0][100,100]"; "packageName" -> "synthetic.test"; else -> null
|
||||
})
|
||||
override fun root()=root
|
||||
}
|
||||
val password=object:SnapshotNode {
|
||||
override fun attribute(name:String)=SnapshotAttribute(if(name=="password") "true" else if(name in SnapshotXml.contentAttributes) "SYNTHETIC_SECRET\u0001" else null)
|
||||
override fun actions()=listOf(16 to SnapshotAttribute("SYNTHETIC_SECRET\u0001"))
|
||||
override fun childCount()=0
|
||||
override fun child(index:Int):SnapshotNode?=null
|
||||
}
|
||||
val root=node(children=listOf(node(mapOf("text" to "中文😀\n\t\r\u0001\uD800","visible-to-user" to "false")),node(mapOf("text" to "duplicate")),node(mapOf("text" to "duplicate")),password))
|
||||
val result=FailureSnapshotExporter().capture(context) { listOf(syntheticWindow(7,root),syntheticWindow(-8,node(mapOf("text" to "second"))),syntheticWindow(9,null)) }
|
||||
val entries=files(result.archive!!)
|
||||
assertEquals(setOf("manifest.json","window-7.xml","window--8.xml"),entries.keys)
|
||||
for ((name,content) in entries) if(name.endsWith(".xml")) {
|
||||
DocumentBuilderFactory.newInstance().newDocumentBuilder().parse(ByteArrayInputStream(content))
|
||||
assertFalse(String(content,Charsets.UTF_8).contains("SYNTHETIC_SECRET"))
|
||||
}
|
||||
System.getenv("GOAUTO_DIAGNOSTIC_FIXTURE_DIR")?.takeIf { it.isNotBlank() }?.let { path ->
|
||||
val directory=java.io.File(path); check(directory.isDirectory || directory.mkdirs())
|
||||
java.io.File(directory,"manifest.json").writeText(result.metadataJson,Charsets.UTF_8)
|
||||
java.io.File(directory,"archive.zip").writeBytes(requireNotNull(result.archive))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package cn.ilapage.goauto.agent
|
||||
|
||||
import cn.ilapage.goauto.agent.diagnostics.FailureSnapshotPolicy
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Test
|
||||
|
||||
class FailureSnapshotPolicyTest {
|
||||
@Test fun onlyFailedOrUnknownPurchaseOutcomesAreEligible() {
|
||||
for (phase in listOf("spec_probe", "purchase")) {
|
||||
assertTrue(FailureSnapshotPolicy.eligible(phase,"failed",false))
|
||||
assertTrue(FailureSnapshotPolicy.eligible(phase,"order_result_unknown",false))
|
||||
for (result in listOf("spec_probe_completed","order_created","rehearsal_completed")) assertFalse(FailureSnapshotPolicy.eligible(phase,result,false))
|
||||
}
|
||||
assertFalse(FailureSnapshotPolicy.eligible("collection","failed",false))
|
||||
assertFalse(FailureSnapshotPolicy.eligible("purchase","failed",true))
|
||||
}
|
||||
@Test fun retryClassificationConvergesPermanentErrors() {
|
||||
for (status in listOf(400,401,403,404,409,410,413,422)) assertFalse(FailureSnapshotPolicy.retryUpload(status))
|
||||
for (status in listOf(0,408,429,500,502,503)) assertTrue(FailureSnapshotPolicy.retryUpload(status))
|
||||
}
|
||||
@Test fun successfulOutcomeDoesNotBecomeSceneFailureWhenResultStorageThrows() {
|
||||
for (result in listOf("spec_probe_completed","order_created","rehearsal_completed")) {
|
||||
assertFalse(FailureSnapshotPolicy.exceptionEligible(result,false))
|
||||
assertFalse(FailureSnapshotPolicy.exceptionEligible(result,true))
|
||||
}
|
||||
assertTrue(FailureSnapshotPolicy.exceptionEligible(null,false))
|
||||
assertTrue(FailureSnapshotPolicy.exceptionEligible("failed",false))
|
||||
assertFalse(FailureSnapshotPolicy.exceptionEligible("failed",true))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package cn.ilapage.goauto.agent
|
||||
|
||||
import cn.ilapage.goauto.agent.diagnostics.FailureSnapshot
|
||||
import cn.ilapage.goauto.agent.network.AgentApiClient
|
||||
import java.net.ServerSocket
|
||||
import java.util.concurrent.Executors
|
||||
import java.util.concurrent.TimeUnit
|
||||
import org.json.JSONObject
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Test
|
||||
|
||||
class FailureSnapshotUploadTest {
|
||||
@Test fun streamsUtf8MetadataAndArchiveAsSeparateMultipartParts() {
|
||||
val attempt = "11111111-1111-4111-8111-111111111111"
|
||||
val server = ServerSocket(0)
|
||||
val executor = Executors.newSingleThreadExecutor()
|
||||
var body = ByteArray(0)
|
||||
var auth = ""
|
||||
val received = executor.submit {
|
||||
server.accept().use { socket ->
|
||||
val input = socket.getInputStream()
|
||||
fun line(): String { val s=StringBuilder(); while (true) { val c=input.read(); if (c < 0 || c==10) break; if(c!=13) s.append(c.toChar()) }; return s.toString() }
|
||||
assertTrue(line().contains("/api/agent/v1/purchase-tasks/9/attempts/$attempt/failure-snapshot"))
|
||||
var length = 0
|
||||
while(true) { val header=line(); if(header.isEmpty()) break; if(header.startsWith("Content-Length:",true)) length=header.substringAfter(':').trim().toInt(); if(header.startsWith("Authorization:",true)) auth=header.substringAfter(':').trim() }
|
||||
body=ByteArray(length); var offset=0; while(offset<length) { val n=input.read(body,offset,length-offset); check(n>0); offset+=n }
|
||||
val response = "{\"data\":{\"taskAttemptId\":\"$attempt\",\"status\":\"partial\",\"replayed\":false}}".toByteArray()
|
||||
socket.getOutputStream().apply { write("HTTP/1.1 200 OK\r\nContent-Length: ${response.size}\r\nConnection: close\r\n\r\n".toByteArray()); write(response); flush() }
|
||||
}
|
||||
}
|
||||
try {
|
||||
val json = JSONObject().put("taskId",9).put("taskAttemptId",attempt).put("status","partial").put("test","中文😀").toString()
|
||||
AgentApiClient("http://127.0.0.1:${server.localPort}").uploadFailureSnapshot(FailureSnapshot(json,byteArrayOf(0,1,2)), "synthetic-token")
|
||||
received.get(5,TimeUnit.SECONDS)
|
||||
val text = String(body,Charsets.UTF_8)
|
||||
assertTrue(text.contains("name=\"metadata\"")); assertTrue(text.contains("application/json; charset=UTF-8"))
|
||||
assertTrue(text.contains(json)); assertTrue(text.contains("name=\"archive\"")); assertEquals("Bearer synthetic-token",auth)
|
||||
} finally { server.close(); executor.shutdownNow() }
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,47 @@ import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class PddProductDetailCollectorTest {
|
||||
@Test
|
||||
fun `blocked color records ancestor evidence and never clicks a different color`() {
|
||||
val base = FakeCollectorDriver(colors = listOf("红色", "蓝色"))
|
||||
var attempts = 0
|
||||
val driver = object : PddCollectorDriver by base {
|
||||
override fun clickFreshDetailed(target: SnapshotNode): cn.ilapage.goauto.agent.automation.FreshClickOutcome {
|
||||
attempts++
|
||||
return cn.ilapage.goauto.agent.automation.FreshClickOutcome(
|
||||
FreshActionResult.BLOCKED, cn.ilapage.goauto.agent.automation.FreshClickReason.SIZE_ADVICE_BLOCKED, 1, 2,
|
||||
)
|
||||
}
|
||||
}
|
||||
var clock = 0L
|
||||
val events = mutableListOf<AgentDiagnosticEvent>()
|
||||
val result = PddProductDetailCollector(driver, { clock }, { clock += it }, taskId = 1, diagnostic = events::add)
|
||||
.collect(GOODS_ID, rule())
|
||||
assertFalse(result.successful)
|
||||
assertEquals("SIZE_ADVICE_CLICK_BLOCKED", result.code)
|
||||
assertEquals(1, attempts)
|
||||
assertEquals(0, base.clickCount)
|
||||
val event = events.single { it.stage == AgentDiagnosticStage.COLOR_CLICK }
|
||||
assertEquals(AgentDiagnosticReason.SIZE_ADVICE_CLICK_BLOCKED, event.reason)
|
||||
assertEquals(2, event.clickableAncestorDepth)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `probe collector records clicks and discovers offscreen weight labelled sizes`() {
|
||||
val driver = FakeCollectorDriver(
|
||||
colors = listOf("黑色"),
|
||||
sizePages = listOf(listOf("M 建议80-100斤"), listOf("4XL 建议160-180斤")),
|
||||
)
|
||||
var clock = 0L
|
||||
val events = mutableListOf<AgentDiagnosticEvent>()
|
||||
val result = PddProductDetailCollector(driver, { clock }, { clock += it }, taskId = 1, diagnostic = events::add)
|
||||
.collect(GOODS_ID, rule())
|
||||
assertTrue(result.successful)
|
||||
assertEquals(listOf("M 建议80-100斤", "4XL 建议160-180斤"), result.payload!!.dimensions.first { it.key == "size" }.values)
|
||||
assertTrue(driver.swipes.any { it.first == SwipeDirection.UP })
|
||||
assertTrue(events.any { it.stage == AgentDiagnosticStage.COLOR_CLICK && it.reason == AgentDiagnosticReason.CLICK_SUCCESS })
|
||||
}
|
||||
|
||||
private fun prefixlessPanel(): UiSnapshot = UiSnapshot(PDD_PACKAGE, ACTIVITY, listOf(
|
||||
node("root", "", 0, 0, 1080, 2376),
|
||||
node("close", "关闭", 970, 270, 1050, 350, clickable = true),
|
||||
|
||||
+71
-13
@@ -24,6 +24,62 @@ import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class PurchaseRehearsalExecutorTest {
|
||||
@Test
|
||||
fun `probe skips post open swipes but preserves wait and records click`() {
|
||||
val driver = FakePurchaseDriver()
|
||||
val pauses = mutableListOf<Long>()
|
||||
val clicks = mutableListOf<FreshClickOutcome>()
|
||||
val configured = rule().replace("\"type\":\"openSpecPanel\"", "\"type\":\"openSpecPanel\",\"waitAfterMs\":1234")
|
||||
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { "{}" },
|
||||
pause = pauses::add, probeClickDiagnostic = { _, _, click -> clicks += click },
|
||||
).execute(input().copy(phase = "spec_probe"), PurchaseRuleParser.parse(configured), PurchaseAgentCapabilities.supported)
|
||||
assertEquals("spec_probe_completed", outcome.resultType)
|
||||
assertEquals(0, driver.swipeCount)
|
||||
assertEquals(1, pauses.count { it == 1234L })
|
||||
assertTrue(clicks.any { it.result == FreshActionResult.SUCCESS })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `blocked entry stops without gesture or probe`() {
|
||||
val driver = FakePurchaseDriver(forcedEntryClickReason = FreshClickReason.SIZE_ADVICE_BLOCKED)
|
||||
var probes = 0
|
||||
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { probes++; "{}" }, pause = {})
|
||||
.execute(input().copy(phase = "spec_probe"), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
|
||||
assertEquals("SIZE_ADVICE_CLICK_BLOCKED", outcome.errorCode)
|
||||
assertEquals(0, driver.specTapCount)
|
||||
assertEquals(0, probes)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `blocked size stops without gesture retry`() {
|
||||
val driver = FakePurchaseDriver(forcedSizeClickReason = FreshClickReason.SIZE_ADVICE_BLOCKED)
|
||||
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
|
||||
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
|
||||
assertEquals("SIZE_ADVICE_CLICK_BLOCKED", outcome.errorCode)
|
||||
assertEquals(0, driver.specTapCount)
|
||||
assertEquals(1, driver.sizeClickCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `blocked gesture stops without another click`() {
|
||||
val driver = FakePurchaseDriver(forcedEntryClickReason = FreshClickReason.ACTION_CLICK_FALSE, specTapResult = FreshActionResult.BLOCKED)
|
||||
var probes = 0
|
||||
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { probes++; "{}" }, pause = {})
|
||||
.execute(input().copy(phase = "spec_probe"), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
|
||||
assertEquals("SIZE_ADVICE_CLICK_BLOCKED", outcome.errorCode)
|
||||
assertEquals(1, driver.specTapCount)
|
||||
assertEquals(0, probes)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `purchase still fails required post open swipe`() {
|
||||
val driver = FakePurchaseDriver(purchaseSwipeSucceeds = false)
|
||||
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
|
||||
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
|
||||
assertEquals("RULE_ACTION_FAILED", outcome.errorCode)
|
||||
assertEquals(1, driver.swipeCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `color selection then single size heading completes without selecting color again`() {
|
||||
val driver = FakePurchaseDriver(prefixlessSingleHeadingAfterColor = true)
|
||||
@@ -778,7 +834,7 @@ class PurchaseRehearsalExecutorTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `open spec reveal skips missing scroll target regardless of panel classification`() {
|
||||
fun `probe skips reveal regardless of panel classification`() {
|
||||
val driver = FakePurchaseDriver(
|
||||
panelOverride = SpecPanelFixtures.liveShapedSheet(),
|
||||
purchaseSwipeSucceeds = false,
|
||||
@@ -793,11 +849,11 @@ class PurchaseRehearsalExecutorTest {
|
||||
assertEquals(outcome.message, "spec_probe_completed", outcome.resultType)
|
||||
assertEquals(1, probes)
|
||||
assertEquals(0, driver.swipeCount)
|
||||
assertTrue(diagnostics.any { it.contains("panel=REQUIRED_EVIDENCE;scrollables=0;reveal=true") })
|
||||
assertFalse(diagnostics.any { it.startsWith("ruleSwipe;") })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `open spec reveal with a scroll target still fails on gesture failure`() {
|
||||
fun `probe does not invoke failing reveal even with a scroll target`() {
|
||||
val driver = FakePurchaseDriver(purchaseSwipeSucceeds = false)
|
||||
var probes = 0
|
||||
val diagnostics = mutableListOf<String>()
|
||||
@@ -806,11 +862,10 @@ class PurchaseRehearsalExecutorTest {
|
||||
pause = {}, panelDiagnostic = diagnostics::add,
|
||||
).execute(input().copy(phase = "spec_probe"), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
|
||||
|
||||
assertEquals("RULE_ACTION_FAILED", outcome.errorCode)
|
||||
assertEquals(0, probes)
|
||||
assertEquals(1, driver.swipeCount)
|
||||
assertTrue(outcome.message.contains("panel=NORMAL_SCROLLABLE;scrollables=1;reveal=true;reason=failed"))
|
||||
assertTrue(diagnostics.any { it.startsWith("ruleSwipe;outcome=failed;") })
|
||||
assertEquals("spec_probe_completed", outcome.resultType)
|
||||
assertEquals(1, probes)
|
||||
assertEquals(0, driver.swipeCount)
|
||||
assertFalse(diagnostics.any { it.startsWith("ruleSwipe;") })
|
||||
assertFalse(outcome.message.contains("黑色"))
|
||||
assertFalse(outcome.message.contains(input().goodsId))
|
||||
}
|
||||
@@ -822,7 +877,7 @@ class PurchaseRehearsalExecutorTest {
|
||||
))
|
||||
var probes = 0
|
||||
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { probes++; "{}" }, pause = {})
|
||||
.execute(input().copy(phase = "spec_probe"), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
|
||||
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
|
||||
|
||||
assertEquals("RULE_ACTION_FAILED", outcome.errorCode)
|
||||
assertEquals(0, probes)
|
||||
@@ -837,9 +892,9 @@ class PurchaseRehearsalExecutorTest {
|
||||
))
|
||||
val pauses = mutableListOf<Long>()
|
||||
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { "{}" }, pause = pauses::add)
|
||||
.execute(input().copy(phase = "spec_probe"), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
|
||||
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
|
||||
|
||||
assertEquals("spec_probe_completed", outcome.resultType)
|
||||
assertEquals("rehearsal_completed", outcome.resultType)
|
||||
assertEquals(1, driver.swipeCount)
|
||||
assertEquals(1, pauses.count { it == 1000L })
|
||||
}
|
||||
@@ -849,9 +904,9 @@ class PurchaseRehearsalExecutorTest {
|
||||
val driver = FakePurchaseDriver()
|
||||
val pauses = mutableListOf<Long>()
|
||||
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { "{}" }, pause = pauses::add)
|
||||
.execute(input().copy(phase = "spec_probe"), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
|
||||
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
|
||||
|
||||
assertEquals("spec_probe_completed", outcome.resultType)
|
||||
assertEquals("rehearsal_completed", outcome.resultType)
|
||||
assertEquals(2, driver.swipeCount)
|
||||
assertEquals(1, pauses.count { it == 1000L })
|
||||
}
|
||||
@@ -1597,6 +1652,7 @@ class PurchaseRehearsalExecutorTest {
|
||||
FreshClickReason.TARGET_AMBIGUOUS -> FreshActionResult.AMBIGUOUS
|
||||
FreshClickReason.NO_CLICKABLE_ANCESTOR, FreshClickReason.ACTION_CLICK_FALSE, FreshClickReason.UNKNOWN -> FreshActionResult.FAILED
|
||||
FreshClickReason.SUCCESS -> FreshActionResult.SUCCESS
|
||||
FreshClickReason.SIZE_ADVICE_BLOCKED -> FreshActionResult.BLOCKED
|
||||
}
|
||||
return FreshClickOutcome(result, forcedEntryClickReason)
|
||||
}
|
||||
@@ -1607,6 +1663,7 @@ class PurchaseRehearsalExecutorTest {
|
||||
FreshClickReason.NO_CLICKABLE_ANCESTOR, FreshClickReason.ACTION_CLICK_FALSE, FreshClickReason.UNKNOWN -> FreshActionResult.FAILED
|
||||
FreshClickReason.TARGET_AMBIGUOUS -> FreshActionResult.AMBIGUOUS
|
||||
FreshClickReason.SUCCESS -> FreshActionResult.SUCCESS
|
||||
FreshClickReason.SIZE_ADVICE_BLOCKED -> FreshActionResult.BLOCKED
|
||||
}
|
||||
return FreshClickOutcome(result, forcedSizeClickReason)
|
||||
}
|
||||
@@ -1616,6 +1673,7 @@ class PurchaseRehearsalExecutorTest {
|
||||
FreshActionResult.NOT_FOUND -> FreshClickReason.TARGET_NOT_FOUND
|
||||
FreshActionResult.AMBIGUOUS -> FreshClickReason.TARGET_AMBIGUOUS
|
||||
FreshActionResult.FAILED -> FreshClickReason.ACTION_CLICK_FALSE
|
||||
FreshActionResult.BLOCKED -> FreshClickReason.SIZE_ADVICE_BLOCKED
|
||||
}
|
||||
return FreshClickOutcome(result, reason)
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
package cn.ilapage.goauto.agent.automation
|
||||
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Test
|
||||
|
||||
class SizeAdviceClickPolicyTest {
|
||||
private class Node(val label: String, val clickable: Boolean, val description: String = "") {
|
||||
var parent: Node? = null
|
||||
val children = mutableListOf<Node>()
|
||||
fun add(node: Node): Node { children += node; node.parent = this; return node }
|
||||
}
|
||||
private fun blocked(node: Node) = SizeAdviceClickPolicy.blocked(
|
||||
node, { it.clickable }, { it.parent }, { it.children }, { listOf(it.label, it.description) },
|
||||
)
|
||||
|
||||
@Test fun sameRowAdviceBlocksDirectReceiverAndLeafGesture() {
|
||||
val row = Node("", true)
|
||||
val title = row.add(Node("尺码", false))
|
||||
row.add(Node("查看尺码建议", false))
|
||||
assertTrue(blocked(row))
|
||||
assertTrue(blocked(title))
|
||||
}
|
||||
|
||||
@Test fun adviceElsewhereDoesNotBlockNormalOptionOrWeightDescription() {
|
||||
val panel = Node("", true)
|
||||
panel.add(Node("查看尺码建议", true))
|
||||
val option = panel.add(Node("M 建议80-100斤", true))
|
||||
val leaf = option.add(Node("M 建议80-100斤", false))
|
||||
assertFalse(blocked(option))
|
||||
assertFalse(blocked(leaf))
|
||||
}
|
||||
|
||||
@Test fun traditionalAndContentDescriptionAreProtectedButPlainAdviceIsNot() {
|
||||
assertTrue(blocked(Node("", true, "查看尺碼建議")))
|
||||
assertTrue(blocked(Node("查 看 尺 寸 建 议", true)))
|
||||
assertTrue(blocked(Node("智能選碼", true)))
|
||||
assertFalse(blocked(Node("XL 建議55.5-60.0公斤穿", true)))
|
||||
assertFalse(blocked(Node("尺码", true)))
|
||||
}
|
||||
|
||||
@Test fun nonClickableAdviceLeafWithoutClickableAncestorCannotBypassGestureGuard() {
|
||||
val root = Node("", false)
|
||||
assertTrue(blocked(root.add(Node("查看尺码建议", false))))
|
||||
assertFalse(blocked(root.add(Node("M 建议80-100斤", false))))
|
||||
}
|
||||
}
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
package cn.ilapage.goauto.agent.automation
|
||||
|
||||
import cn.ilapage.goauto.agent.persistence.AgentDiagnosticReason
|
||||
import cn.ilapage.goauto.agent.persistence.AgentDiagnosticStage
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Test
|
||||
|
||||
class SpecClickDiagnosticTest {
|
||||
@Test fun recordsOnlyStructuralFieldsIncludingBlockedAncestor() {
|
||||
val node = SnapshotNode("private-path", null, "private-label", "private-description", "private-id",
|
||||
"private-class", NodeBounds(1,2,3,4), false, false, false, false, true, true)
|
||||
val event = specClickDiagnostic(12, AgentDiagnosticStage.COLOR_CLICK, node,
|
||||
FreshClickOutcome(FreshActionResult.BLOCKED, FreshClickReason.SIZE_ADVICE_BLOCKED, 1, 2))
|
||||
assertEquals(AgentDiagnosticReason.SIZE_ADVICE_CLICK_BLOCKED, event.reason)
|
||||
assertEquals(1, event.candidateCount)
|
||||
assertEquals(2, event.clickableAncestorDepth)
|
||||
assertNull(event.targetClassName)
|
||||
assertFalse(event.toString().contains("private"))
|
||||
assertEquals("collection", event.taskType)
|
||||
}
|
||||
}
|
||||
+54
@@ -7,6 +7,60 @@ import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class AgentDiagnosticStoreMigrationTest {
|
||||
@Test
|
||||
fun v4AddsIndependentFailureSnapshotQueueWithoutChangingStructuredRows() = withDatabase { db ->
|
||||
db.createStatement().use {
|
||||
it.execute(AgentDiagnosticSchema.createTableSql)
|
||||
it.execute("INSERT INTO agent_diagnostic (task_id,stage,reason,attempt,elapsed_ms,agent_version,created_at) VALUES (1,'COLOR_CLICK','CLICK_SUCCESS',1,0,'old',1000)")
|
||||
}
|
||||
AgentDiagnosticSchema.migrationStatements(3, 4, columnNames(db)).forEach { sql ->
|
||||
db.createStatement().use { it.execute(sql) }
|
||||
}
|
||||
assertEquals(4, AgentDiagnosticSchema.VERSION)
|
||||
for (table in listOf("purchase_failure_snapshot", "purchase_failure_snapshot_queue", "purchase_failure_snapshot_context")) {
|
||||
db.prepareStatement("SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=?").use {
|
||||
it.setString(1, table)
|
||||
it.executeQuery().use { rows -> assertTrue(rows.next()); assertEquals(table, 1, rows.getInt(1)) }
|
||||
}
|
||||
}
|
||||
assertEquals(1, rowCount(db))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun upgradeFromV1OrV2PreservesOldRowsAndSeparatesPurchaseAttempts() {
|
||||
for (oldVersion in listOf(1, 2)) withDatabase { db ->
|
||||
db.createStatement().use { it.execute(CREATE_V1_TABLE_SQL) }
|
||||
if (oldVersion == 2) migrateV1ToV2(db)
|
||||
db.createStatement().use { it.execute("INSERT INTO agent_diagnostic " +
|
||||
"(task_id,stage,reason,attempt,elapsed_ms,agent_version,created_at) " +
|
||||
"VALUES (1,'DETAIL_ENTRY','DETAIL_ENTRY_MATCHED',2,10,'old',1000)") }
|
||||
AgentDiagnosticSchema.migrationStatements(oldVersion, 3, columnNames(db)).forEach { sql ->
|
||||
db.createStatement().use { it.execute(sql) }
|
||||
}
|
||||
assertTrue(columnNames(db).containsAll(AgentDiagnosticSchema.purchaseDiagnosticColumns.keys))
|
||||
assertTrue(columnNames(db).containsAll(AgentDiagnosticSchema.colorDiagnosticColumns.keys))
|
||||
assertTrue(AgentDiagnosticSchema.migrationStatements(oldVersion, 3, columnNames(db)).isEmpty())
|
||||
db.createStatement().use { statement ->
|
||||
statement.executeQuery("SELECT task_type,task_attempt_id,attempt FROM agent_diagnostic").use {
|
||||
assertTrue(it.next()); assertEquals(null, it.getString(1)); assertEquals(null, it.getString(2)); assertEquals(2, it.getInt(3))
|
||||
}
|
||||
}
|
||||
for (attemptId in listOf("11111111-1111-4111-8111-111111111111", "22222222-2222-4222-8222-222222222222")) {
|
||||
db.prepareStatement("INSERT INTO agent_diagnostic " +
|
||||
"(task_id,stage,reason,attempt,elapsed_ms,agent_version,created_at,task_type,task_attempt_id,device_id,phase,rule_snapshot_hash) " +
|
||||
"VALUES (1,'COLOR_CLICK','CLICK_SUCCESS',3,10,'new',2000,'purchase',?,7,'spec_probe',?)").use {
|
||||
it.setString(1, attemptId); it.setString(2, "a".repeat(64)); assertEquals(1, it.executeUpdate())
|
||||
}
|
||||
}
|
||||
assertEquals(3, rowCount(db))
|
||||
db.createStatement().use { statement ->
|
||||
statement.executeQuery("SELECT COUNT(DISTINCT task_attempt_id) FROM agent_diagnostic WHERE task_type='purchase' AND task_id=1").use {
|
||||
assertTrue(it.next()); assertEquals(2, it.getInt(1))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun freshDatabaseCreatesV2ColumnsAndAcceptsSingleColorDiagnosticRecord() = withDatabase { db ->
|
||||
db.createStatement().use { it.execute(AgentDiagnosticSchema.createTableSql) }
|
||||
|
||||
+131
@@ -0,0 +1,131 @@
|
||||
package cn.ilapage.goauto.agent.persistence
|
||||
|
||||
import cn.ilapage.goauto.agent.diagnostics.FailureSnapshot
|
||||
import java.sql.DriverManager
|
||||
import java.time.Instant
|
||||
import org.json.JSONObject
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Test
|
||||
|
||||
class FailureSnapshotRepositoryTest {
|
||||
private val now = 1_791_400_000_000L
|
||||
private val origin = "https://test.invalid"
|
||||
private val attempt = "11111111-1111-4111-8111-111111111111"
|
||||
|
||||
private fun snapshot(zip: Boolean = false, source: String = "execution", time: Long = now) = FailureSnapshot(
|
||||
JSONObject().put("taskId", 9).put("deviceId", 4).put("taskAttemptId", attempt)
|
||||
.put("phase", "purchase").put("ruleSnapshotHash", "a".repeat(64)).put("ruleSnapshotHashValid", true)
|
||||
.put("recordedAt", Instant.ofEpochMilli(time).toString()).put("source", source)
|
||||
.put("status", if (zip) "partial" else "not_captured").toString(),
|
||||
if (zip) byteArrayOf(1, 2, 3) else null, origin,
|
||||
)
|
||||
|
||||
@Test fun durableQueueIsOriginBoundAndKeepsFirstArchive() = database { db ->
|
||||
val repo = FailureSnapshotRepository(db, clock = { now })
|
||||
val first = snapshot(true)
|
||||
assertTrue(repo.saveFailureSnapshot(first))
|
||||
assertFalse(repo.saveFailureSnapshot(snapshot(true, "recovery")))
|
||||
assertEquals(0, repo.pendingFailureSnapshots("https://other.invalid").size)
|
||||
val restarted = FailureSnapshotRepository(db, clock = { now })
|
||||
assertArrayEquals(first.archive, restarted.pendingFailureSnapshots(origin).single().archive)
|
||||
restarted.markFailureSnapshotUploaded(first)
|
||||
assertTrue(restarted.pendingFailureSnapshots(origin).isEmpty())
|
||||
assertTrue(restarted.hasFailureSnapshotArchive(attempt))
|
||||
}
|
||||
|
||||
@Test fun recoveryPromotionCannotBeAcknowledgedByOldUpload() = database { db ->
|
||||
val repo = FailureSnapshotRepository(db, clock = { now })
|
||||
val old = snapshot()
|
||||
assertTrue(repo.saveFailureSnapshot(old))
|
||||
val recovery = snapshot(true, "recovery")
|
||||
assertTrue(repo.saveFailureSnapshot(recovery))
|
||||
repo.markFailureSnapshotUploaded(old)
|
||||
repo.retryFailureSnapshotLater(old)
|
||||
assertArrayEquals(recovery.archive, repo.pendingFailureSnapshots(origin).single().archive)
|
||||
}
|
||||
|
||||
@Test fun cleanupRemovesExpiredSnapshotsAndQueueButNotStructuredRows() = database { db ->
|
||||
db.execute(AgentDiagnosticSchema.createTableSql)
|
||||
db.execute("INSERT INTO agent_diagnostic (task_id,stage,reason,attempt,elapsed_ms,agent_version,created_at) VALUES (1,'COLOR_CLICK','CLICK_SUCCESS',1,0,'old',1000)")
|
||||
val repo = FailureSnapshotRepository(db, clock = { now })
|
||||
assertTrue(repo.saveFailureSnapshot(snapshot()))
|
||||
repo.cleanupFailureSnapshots(now + FailureSnapshotRepository.RETENTION_MILLIS)
|
||||
assertTrue(repo.pendingFailureSnapshots(origin).isEmpty())
|
||||
assertEquals(0L, db.query("SELECT COUNT(*) AS n FROM purchase_failure_snapshot").single()["n"])
|
||||
assertEquals(0L, db.query("SELECT COUNT(*) AS n FROM purchase_failure_snapshot_queue").single()["n"])
|
||||
assertEquals(1L, db.query("SELECT COUNT(*) AS n FROM agent_diagnostic").single()["n"])
|
||||
}
|
||||
|
||||
@Test fun emptyArchiveIsRejectedBeforeItCanPoisonTheUploadQueue() = database { db ->
|
||||
val repo = FailureSnapshotRepository(db, clock = { now })
|
||||
assertThrows(IllegalArgumentException::class.java) {
|
||||
repo.saveFailureSnapshot(snapshot(true).copy(archive = byteArrayOf()))
|
||||
}
|
||||
assertTrue(repo.pendingFailureSnapshots(origin).isEmpty())
|
||||
}
|
||||
|
||||
@Test fun contextPreservesInvalidHashValidityWithoutInventingHash() = database { db ->
|
||||
val repo = FailureSnapshotRepository(db, clock = { now })
|
||||
repo.recordFailureSnapshotContext(9, attempt, 4, "purchase", "invalid", origin)
|
||||
val context = JSONObject(repo.failureSnapshotContext(attempt)!!)
|
||||
assertFalse(context.getBoolean("ruleSnapshotHashValid"))
|
||||
assertTrue(context.isNull("ruleSnapshotHash"))
|
||||
assertEquals(origin, context.getString("serverOrigin"))
|
||||
assertTrue(repo.pendingFailureSnapshots(origin).isEmpty())
|
||||
}
|
||||
|
||||
@Test fun retryDelayIsPersistentAndCapEvictionRemovesQueue() = database { db ->
|
||||
val repo = FailureSnapshotRepository(db, { now }, maxBytes = 4096)
|
||||
val first = snapshot(true)
|
||||
assertTrue(repo.saveFailureSnapshot(first))
|
||||
repo.retryFailureSnapshotLater(first)
|
||||
assertTrue(repo.pendingFailureSnapshots(origin).isEmpty())
|
||||
assertEquals(1, repo.pendingFailureSnapshots(origin, now + 60_000).size)
|
||||
val tiny = FailureSnapshotRepository(db, { now }, maxBytes = 1)
|
||||
tiny.cleanupFailureSnapshots(now)
|
||||
assertTrue(tiny.pendingFailureSnapshots(origin).isEmpty())
|
||||
assertFalse(tiny.hasFailureSnapshotArchive(attempt))
|
||||
}
|
||||
|
||||
@Test fun nearLimitArchiveUsesSmallCursorRowsForUploadAndDuplicateDetection() = database { db ->
|
||||
val bounded = object : FailureSnapshotDatabase by db {
|
||||
override fun query(sql: String, args: List<Any?>): List<Map<String, Any?>> = db.query(sql, args).also { rows ->
|
||||
rows.forEach { row -> row.values.filterIsInstance<ByteArray>().forEach { assertTrue("blob cursor row exceeds 256KiB", it.size <= 256 * 1024) } }
|
||||
}
|
||||
}
|
||||
val repo = FailureSnapshotRepository(bounded, clock = { now })
|
||||
val large = snapshot(true).copy(archive = ByteArray(2 * 1024 * 1024) { (it % 127).toByte() })
|
||||
assertTrue(repo.saveFailureSnapshot(large))
|
||||
assertFalse(repo.saveFailureSnapshot(large))
|
||||
assertArrayEquals(large.archive, repo.pendingFailureSnapshots(origin).single().archive)
|
||||
}
|
||||
|
||||
private fun database(block: (FailureSnapshotDatabase) -> Unit) {
|
||||
DriverManager.getConnection("jdbc:sqlite::memory:").use { connection ->
|
||||
AgentDiagnosticSchema.failureSnapshotStatements.forEach { connection.createStatement().use { s -> s.execute(it) } }
|
||||
val db = object : FailureSnapshotDatabase {
|
||||
override fun execute(sql: String, args: List<Any?>) {
|
||||
connection.prepareStatement(sql).use { s ->
|
||||
args.forEachIndexed { index, value -> s.setObject(index + 1, value) }; s.executeUpdate()
|
||||
}
|
||||
}
|
||||
override fun query(sql: String, args: List<Any?>): List<Map<String, Any?>> = connection.prepareStatement(sql).use { s ->
|
||||
args.forEachIndexed { index, value -> s.setObject(index + 1, value) }
|
||||
s.executeQuery().use { rows -> buildList {
|
||||
while (rows.next()) add((1..rows.metaData.columnCount).associate { index ->
|
||||
val value = rows.getObject(index)
|
||||
rows.metaData.getColumnLabel(index) to if (value is Number) value.toLong() else value
|
||||
})
|
||||
} }
|
||||
}
|
||||
override fun <T> transaction(block: () -> T): T {
|
||||
connection.autoCommit = false
|
||||
try { val result = block(); connection.commit(); return result }
|
||||
catch (error: Throwable) { connection.rollback(); throw error }
|
||||
finally { connection.autoCommit = true }
|
||||
}
|
||||
}
|
||||
block(db)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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: f73ab171684662d23f298d7e4927168b1a40b01a
|
||||
synchronized_at: 2026-10-06T01:39:19Z
|
||||
wiki_revision: b3d1c9b7ba1dd5820a20e59b0c21a971aaf1b8ee
|
||||
synchronized_at: 2026-10-08T01:41:51Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
<!-- gitea-wiki-mirror:start -->
|
||||
@@ -617,3 +617,25 @@ Web 唯一展示位置为“采集采购 → SYB 同步记录”:列表状态
|
||||
- `sybimport.Handler.List/Detail` 复用产品模型返回 nullable 字段。列表不额外访问 SYB;不新增旧订单扫描、并发、定时任务或采购拦截。
|
||||
- Web 只在 SYB 商品列表订单号后增加固定 72px 的“SYB状态”列;退货列改名“匹配退货”、min-width 200→160,保留图片/告警/操作并允许换行。使用现有标签与加载/失败态,其他列及 Android 不变。
|
||||
- 迁移重复执行和三态接口/导入已在隔离 SQLite 测试,Server/Web 构建通过;尚未验证正式 MySQL 执行或真实同步后的页面。先授权迁移,再发布才能用于业务环境。
|
||||
|
||||
## Android 规格探测与诊断增补(#361)
|
||||
|
||||
实现绑定 a49dc69,Agent 0.9.68 / 81,尚未合并 main。
|
||||
|
||||
- PurchaseRehearsalExecutor.applyPostAction 只在 spec_probe + OPEN_SPEC_PANEL 跳过 swipeAfter,保留等待和 purchase 行为。
|
||||
- SizeAdviceClickPolicy 对最终点击接收者的子树检查明确建议入口短语;GoAutoAccessibilityService 的 ACTION_CLICK 和两种中心手势入口使用该策略。FreshActionResult.BLOCKED / FreshClickReason.SIZE_ADVICE_BLOCKED 阻止规格流程继续兜底。
|
||||
- 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: f5077a19801882ebb4e94c5b0fe93f00fa6ebc7d
|
||||
synchronized_at: 2026-10-06T03:27:21Z
|
||||
wiki_revision: 233b72e9e2415a2456bb8dd27c770df881960db4
|
||||
synchronized_at: 2026-10-08T01:42:04Z
|
||||
<!-- 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 写接口。
|
||||
@@ -801,3 +803,35 @@ Android 0.9.64 / versionCode 77,源码 `6550b9f`(分支实现,尚未安装
|
||||
- RawSpecHalves 复用 Parse,ResolveKeys 复用两者,原始两侧角色、注释塌缩消歧与采购规格键继续共享同一判断,不另立角色推断规则。
|
||||
- 更新规则本身不会改历史数据库。正常重同步沿用现有保护规则;需要定向重新解析时复用 ReparseBatch,force=false 默认跳过人工及 AI 确认行。该流程可能重算同商品其他未确认明细,执行前必须核验完整影响集合。
|
||||
- ERPGo 规格同步及 Merge 仍只追加并保留映射,不自动清除旧反向档案值。清理须独立授权、指定对象并验证引用/映射,保留恢复依据;不自动扩大为全库数据清理,不修改采购任务快照。
|
||||
|
||||
## Android 规格探测点击保护(#361)
|
||||
|
||||
实现绑定 a49dc69(工单分支,Agent 0.9.68 / 81,未合并 main);自动化验证通过,现场采购修复尚待验收。
|
||||
|
||||
- 仅 spec_probe 阶段的 OPEN_SPEC_PANEL 跳过规则 swipeAfter;waitAfterMs 和既有页面稳定等待保留,采集器继续自身的有限规格遍历。purchase 阶段原规则滑动不变。
|
||||
- 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是规格项数。商品变化后可能需要等扫描回绕,不保证固定小时内全部处理。
|
||||
|
||||
+13
-11
@@ -2,8 +2,8 @@
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Troubleshooting
|
||||
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Troubleshooting
|
||||
wiki_revision: 40e77a656d1c21d72454ad722a4f71a0a4491241
|
||||
synchronized_at: 2026-10-05T07:18:16Z
|
||||
wiki_revision: ac3a0b74704f398cec54149267089c87c8375a3e
|
||||
synchronized_at: 2026-10-06T09:50:33Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 故障排查
|
||||
@@ -86,24 +86,26 @@ sqlite3 -readonly agent-diagnostics.db "SELECT task_id,reason,color_row_count,co
|
||||
|
||||
读取时记录设备、Agent 版本、任务号和规则快照;工单只回写查询得到的脱敏聚合数值。读取完成后删除本地导出副本。正式 APK 若不允许 `run-as`,停止排查并确认安全的只读诊断出口,不通过放宽应用安全配置或上传完整数据库绕过。
|
||||
|
||||
## Android 采购规格入口本地诊断(#249)
|
||||
## Android 采购规格入口本地诊断(#249 / #361)
|
||||
|
||||
实现绑定 `99faf5a`,Agent `0.9.67`(versionCode 80)。单元测试与 Debug 构建已通过,新增版本真机留存/读取尚待验证;本节不属于上文既有实测结论。
|
||||
#249 的 JSONL 采购诊断实现位于独立分支(历史绑定 99faf5a),未合并基线 main 64f0e49;不能假定运行该 main 的设备存在 files/purchase_diagnostics。main 已有 goauto_diagnostics.db / agent_diagnostic,本单复用它,不整体合并 #249。
|
||||
|
||||
采购执行器启动后,在应用私有 `files/purchase_diagnostics/<taskId>_<attemptId>.jsonl` 保存白名单结构化入口诊断。元数据包含 taskId、attemptId、deviceId、agentVersion、phase、ruleHash(当前任务规则快照 SHA-256)、timestamp 和 elapsedMs。现有任务接口只有 attemptId,没有 attemptNumber;用 attemptId 对照服务端历史,不推算重试序号。
|
||||
#361 实现绑定 a49dc69,Agent 0.9.68(versionCode 81),当前为工单分支,未合并 main 或发布 Admin。已完成 Debug 构建和单元测试,按用户授权覆盖安装一台设备;未执行真机探测或采购重试,不能视为现场修复验收。
|
||||
|
||||
证据仅包含入口来源枚举、候选数、点击结果/原因枚举、等待轮数、面板结构布尔值/计数,以及受控恢复是否执行及固定拒绝原因。没有商品标题、规格原文、地址、手机号、Cookie、Token、链接、原始控件树或截图。entryWaitMillis 是该等待循环累计的计划等待量;elapsedMs 才是自本次采购执行器诊断起点计量的实际耗时,timestamp 是后台写入时间。
|
||||
规格探测的入口点击/手势、快速确认恢复、颜色点击,以及原有面板/颜色/尺码发现记录接入既有异步诊断队列。来源 stage 为 SPEC_ENTRY_CLICK / SPEC_ENTRY_GESTURE / QUICK_CONFIRMATION_CLICK / COLOR_CLICK;reason 为点击结果或 SIZE_ADVICE_CLICK_BLOCKED 等固定枚举。只存候选数、可点击布尔、白名单类名和可得的祖先层级,不保存原始 trace、标签、地址、手机号、控件树或截图。
|
||||
|
||||
复用现有单线程诊断队列异步写入,写入/排队失败不改变采购结果;不新增点击、滑动、等待或采购门禁。最多保留最近 5 个 attempt 文件、每文件最后 128 条事件,超过 7 天的文件在服务启动或下次写入时清理。异常断电或存储故障仍可能丢失诊断,不能将日志缺失当成动作未执行;不上传 Admin,不增加服务端接口。
|
||||
本地 SQLite schema v3 仅追加可空 task_type、task_attempt_id、device_id、phase、rule_snapshot_hash。采购记录绑定 purchase_task.id、服务端 attempt UUID、设备、阶段与规则哈希;现有 attempt 仍是动作内次数,不能当作采购 attempt ID。新采集记录标识 collection;旧记录新增字段为 NULL,不猜测或回填归属。V1/V2 自动升级保留旧数据;旧版 SQLiteOpenHelper 不保证能降级打开 v3,回退前需单独评估,不卸载清数据。
|
||||
|
||||
仅对允许 run-as 的 Debug APK,在设备已连接且选定准确序列号后只读提取:
|
||||
保留边界沿用全库最近 50 条及 7 天(写入时清理),日志可能因容量、断电或存储异常缺失;缺日志不能证明未点击。写入/排队失败不改变执行结果,不新增上传接口。
|
||||
|
||||
读取须确认准确设备、Debug APK、task ID 与 attempt UUID。设备具备 sqlite3 且允许 run-as 时可执行下面的只读查询;如缺 sqlite3/run-as,则停止并另行确认诊断读取路径,不放宽权限、不导出业务库。命令尚未在本单设备验证:
|
||||
|
||||
```powershell
|
||||
adb -s <device-serial> shell run-as cn.ilapage.goauto.agent ls files/purchase_diagnostics
|
||||
adb -s <device-serial> exec-out run-as cn.ilapage.goauto.agent cat files/purchase_diagnostics/<taskId>_<attemptId>.jsonl
|
||||
adb -s <serial> shell run-as cn.ilapage.goauto.agent sqlite3 -readonly databases/goauto_diagnostics.db "SELECT task_id,task_attempt_id,device_id,phase,rule_snapshot_hash,stage,reason,candidate_count,clickable_ancestor_depth,created_at FROM agent_diagnostic WHERE task_type='purchase' AND task_id=<taskId> AND task_attempt_id='<attemptUUID>' ORDER BY id;"
|
||||
```
|
||||
|
||||
先列出文件,再读取本次任务/attempt 的准确文件名;不得扩大为导出所有应用数据或完整数据库。新版安装前的失败不会补生成日志;需要用户授权后重试才能产生新证据。Release 若禁止 run-as 则停止,不更改权限绕过;首版没有导出界面。该功能用于确定拒绝分支,并不代表已经修复对应采购故障。
|
||||
如果日志出现 SIZE_ADVICE_CLICK_BLOCKED,只能证明保护已触发,不代表正常商品探测成功;正常商品仍必须读到预期颜色尺码。禁止直接重试 live 任务作“仅探测”验证:探测匹配后任务可回 pending 并继续下单。演练按 PDD 商品创建,且可能由档案匹配直接跳过 spec_probe;必须确认实际阶段和覆盖路径,装机与真机测试分别取得授权。
|
||||
|
||||
## SYB 商品列表查询等待(#355)
|
||||
|
||||
实现绑定 `a24c206`,仅 Web 客户端;2026-10-05 与 #353/#354 合并至 main `1b4f7cd` 并配套发布 Server/Web 至现有 167 服务器。已通过本地合成测试及线上只读页面验证:列表请求实际等待预算为 60000ms,原筛选组合超过 10 秒后正常返回。发布记录和回退目标见 #355 工单。
|
||||
|
||||
@@ -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: 640da0a86bf1057d418dbeb062c07a84cb5b1d8e
|
||||
synchronized_at: 2026-10-08T01:42:56Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
<!-- gitea-wiki-mirror:start -->
|
||||
@@ -1472,3 +1472,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` 保留;重启前核验任务空闲,保留追加列与已经保存的映射,不恢复旧备份覆盖此后业务。
|
||||
|
||||
@@ -116,6 +116,8 @@ var AdminAPIs = []APIPermission{
|
||||
{"回填SYB采购单号", "/api/admin/v1/purchase-tasks/syb-order-writeback", "POST", true},
|
||||
{"创建备货采购任务", "/api/admin/v1/purchase-tasks/stock", "POST", true},
|
||||
{"查看采购任务详情", "/api/admin/v1/purchase-tasks/:taskId", "GET", true},
|
||||
{"查看采购失败现场摘要", "/api/admin/v1/purchase-tasks/:taskId/failure-snapshots", "GET", false},
|
||||
{"下载采购失败现场", "/api/admin/v1/purchase-tasks/:taskId/attempts/:attemptId/failure-snapshot/download", "GET", false},
|
||||
{"创建采购任务", "/api/admin/v1/purchase-tasks", "POST", true},
|
||||
{"处理采购规格", "/api/admin/v1/purchase-tasks/:taskId/spec-decision", "POST", true},
|
||||
{"查看采购规格匹配", "/api/admin/v1/purchase-tasks/:taskId/matching", "GET", true},
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -62,6 +62,7 @@ func MigratedModels() []any {
|
||||
&models.PDDAccount{},
|
||||
&models.PurchaseTask{},
|
||||
&models.PurchaseTaskAttempt{},
|
||||
&models.PurchaseFailureSnapshot{},
|
||||
&models.PurchaseOrderWriteback{},
|
||||
&models.PurchaseOrderWritebackLease{},
|
||||
&models.PurchaseOrderWritebackCommand{},
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
// PurchaseFailureSnapshot is deliberately never embedded in a business API DTO.
|
||||
// Private manifest and archive may contain personal information.
|
||||
type PurchaseFailureSnapshot struct {
|
||||
ID uint64 `json:"-" gorm:"primaryKey;autoIncrement"`
|
||||
TaskID uint64 `json:"-" gorm:"not null;index"`
|
||||
AttemptID string `json:"-" gorm:"size:36;not null;uniqueIndex:ux_purchase_failure_snapshot_attempt"`
|
||||
DeviceID uint64 `json:"-" gorm:"not null"`
|
||||
Phase string `json:"-" gorm:"size:16;not null"`
|
||||
RuleSnapshotHash string `json:"-" gorm:"size:64;not null"`
|
||||
Status string `json:"-" gorm:"size:16;not null"`
|
||||
Source string `json:"-" gorm:"size:16;not null"`
|
||||
ErrorCode string `json:"-" gorm:"size:64;not null"`
|
||||
AgentVersion string `json:"-" gorm:"size:64;not null"`
|
||||
CapturedAt *time.Time `json:"-"`
|
||||
RecordedAt time.Time `json:"-" gorm:"not null"`
|
||||
ExpiresAt time.Time `json:"-" gorm:"not null;index"`
|
||||
ReasonsJSON string `json:"-" gorm:"type:text;not null"`
|
||||
ManifestJSON string `json:"-" gorm:"type:longtext;not null"`
|
||||
ZIPData []byte `json:"-" gorm:"column:zip_data;type:longblob"`
|
||||
PayloadSHA256 string `json:"-" gorm:"size:64;not null"`
|
||||
ZIPSize int64 `json:"-" gorm:"column:zip_size;not null"`
|
||||
ExpandedSize int64 `json:"-" gorm:"not null"`
|
||||
CreatedAt time.Time `json:"-"`
|
||||
UpdatedAt time.Time `json:"-"`
|
||||
}
|
||||
|
||||
func (PurchaseFailureSnapshot) TableName() string { return "purchase_failure_snapshot" }
|
||||
@@ -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" }
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
package purchase
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"regexp"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"go-admin/app/goauto/device"
|
||||
"go-admin/app/goauto/models"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
"gorm.io/gorm/logger"
|
||||
)
|
||||
|
||||
const (
|
||||
FailureSnapshotMetadataLimit = 64 << 10
|
||||
FailureSnapshotZIPLimit = 2 << 20
|
||||
FailureSnapshotExpandedLimit = 8 << 20
|
||||
FailureSnapshotRetention = 30 * 24 * time.Hour
|
||||
)
|
||||
|
||||
type FailureSnapshotMetadata struct {
|
||||
SchemaVersion int `json:"schemaVersion"`
|
||||
AttributeVersion int `json:"attributeVersion"`
|
||||
TaskID uint64 `json:"taskId"`
|
||||
DeviceID uint64 `json:"deviceId"`
|
||||
TaskAttemptID string `json:"taskAttemptId"`
|
||||
Phase string `json:"phase"`
|
||||
RuleSnapshotHash *string `json:"ruleSnapshotHash"`
|
||||
RuleSnapshotHashValid bool `json:"ruleSnapshotHashValid"`
|
||||
ErrorCode string `json:"errorCode"`
|
||||
AgentVersion string `json:"agentVersion"`
|
||||
RecordedAt time.Time `json:"recordedAt"`
|
||||
CapturedAt *time.Time `json:"capturedAt"`
|
||||
Source string `json:"source"`
|
||||
Status string `json:"status"`
|
||||
Reasons []string `json:"reasons"`
|
||||
Windows []json.RawMessage `json:"windows"`
|
||||
}
|
||||
|
||||
type FailureSnapshotSummary struct {
|
||||
AttemptID string `json:"attemptId"`
|
||||
Phase string `json:"phase"`
|
||||
Status string `json:"status"`
|
||||
Source string `json:"source"`
|
||||
ErrorCode string `json:"errorCode"`
|
||||
AgentVersion string `json:"agentVersion"`
|
||||
CapturedAt *time.Time `json:"capturedAt"`
|
||||
RecordedAt time.Time `json:"recordedAt"`
|
||||
ExpiresAt time.Time `json:"expiresAt"`
|
||||
Partial bool `json:"partial"`
|
||||
Reasons []string `json:"reasons"`
|
||||
DownloadAvailable bool `json:"downloadAvailable"`
|
||||
ZIPSize int64 `json:"zipSize"`
|
||||
}
|
||||
|
||||
type snapshotError struct {
|
||||
status int
|
||||
code string
|
||||
}
|
||||
|
||||
func (e *snapshotError) Error() string { return e.code }
|
||||
func snapshotFailure(status int, code string) error { return &snapshotError{status, code} }
|
||||
func invalidSnapshot() error { return snapshotFailure(422, "FAILURE_SNAPSHOT_INVALID") }
|
||||
|
||||
// Keep private content out of SQL logging, including failures in tests/debug mode.
|
||||
func (s *Service) snapshotDB(ctx context.Context) *gorm.DB {
|
||||
return s.DB.WithContext(ctx).Session(&gorm.Session{Logger: logger.Default.LogMode(logger.Silent)})
|
||||
}
|
||||
|
||||
var snapshotCodePattern = regexp.MustCompile(`^[A-Z][A-Z0-9_]{0,63}$`)
|
||||
var snapshotVersionPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9.+_-]{0,63}$`)
|
||||
var snapshotHashPattern = regexp.MustCompile(`^[a-f0-9]{64}$`)
|
||||
|
||||
func decodeSnapshotMetadata(raw []byte) (FailureSnapshotMetadata, error) {
|
||||
var m FailureSnapshotMetadata
|
||||
if len(raw) == 0 || len(raw) > FailureSnapshotMetadataLimit {
|
||||
return m, invalidSnapshot()
|
||||
}
|
||||
if err := uniqueSnapshotJSON(raw); err != nil {
|
||||
return m, err
|
||||
}
|
||||
var fields map[string]json.RawMessage
|
||||
if json.Unmarshal(raw, &fields) != nil {
|
||||
return m, invalidSnapshot()
|
||||
}
|
||||
for _, name := range []string{"schemaVersion", "attributeVersion", "taskId", "deviceId", "taskAttemptId", "phase", "ruleSnapshotHash", "ruleSnapshotHashValid", "errorCode", "agentVersion", "recordedAt", "capturedAt", "source", "status", "reasons", "windows"} {
|
||||
if _, present := fields[name]; !present {
|
||||
return m, invalidSnapshot()
|
||||
}
|
||||
}
|
||||
d := json.NewDecoder(bytes.NewReader(raw))
|
||||
d.DisallowUnknownFields()
|
||||
if err := d.Decode(&m); err != nil {
|
||||
return m, invalidSnapshot()
|
||||
}
|
||||
if err := d.Decode(&struct{}{}); !errors.Is(err, io.EOF) {
|
||||
return m, invalidSnapshot()
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (s *Service) UploadFailureSnapshot(ctx context.Context, taskID uint64, attemptID, token string, raw, archive []byte) (string, bool, error) {
|
||||
d, err := device.NewService(s.snapshotDB(ctx)).Authenticate(ctx, token)
|
||||
if err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
m, err := decodeSnapshotMetadata(raw)
|
||||
if err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
parsed, err := uuid.Parse(attemptID)
|
||||
if err != nil || parsed.String() != attemptID || m.TaskID != taskID || m.TaskAttemptID != attemptID || m.DeviceID != d.ID {
|
||||
return "", false, invalidSnapshot()
|
||||
}
|
||||
if err = validateSnapshotMetadata(m, s.Now()); err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
if !m.RecordedAt.Add(FailureSnapshotRetention).After(s.Now()) {
|
||||
return "", false, snapshotFailure(410, "FAILURE_SNAPSHOT_EXPIRED")
|
||||
}
|
||||
expanded, err := validateFailureArchive(m, raw, archive)
|
||||
if err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
canonical, err := canonicalSnapshotJSON(raw)
|
||||
if err != nil {
|
||||
return "", false, invalidSnapshot()
|
||||
}
|
||||
digest := sha256.New()
|
||||
digest.Write(canonical)
|
||||
digest.Write([]byte{0})
|
||||
digest.Write(archive)
|
||||
hash := hex.EncodeToString(digest.Sum(nil))
|
||||
replayed := false
|
||||
err = s.snapshotDB(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
var a models.PurchaseTaskAttempt
|
||||
if e := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("task_id = ? AND attempt_id = ? AND device_id = ?", taskID, attemptID, d.ID).First(&a).Error; e != nil {
|
||||
if errors.Is(e, gorm.ErrRecordNotFound) {
|
||||
return snapshotFailure(404, "FAILURE_SNAPSHOT_NOT_FOUND")
|
||||
}
|
||||
return e
|
||||
}
|
||||
if a.Phase != m.Phase || (m.RuleSnapshotHashValid && (m.RuleSnapshotHash == nil || *m.RuleSnapshotHash != a.RuleSnapshotHash)) {
|
||||
return invalidSnapshot()
|
||||
}
|
||||
if a.Status == models.PurchaseAttemptStatusPending {
|
||||
return snapshotFailure(409, "FAILURE_SNAPSHOT_ATTEMPT_NOT_STARTED")
|
||||
}
|
||||
if a.StartedAt != nil && m.RecordedAt.Before(a.StartedAt.Add(-5*time.Minute)) {
|
||||
return invalidSnapshot()
|
||||
}
|
||||
var old models.PurchaseFailureSnapshot
|
||||
e := tx.Where("attempt_id = ?", attemptID).First(&old).Error
|
||||
if e == nil {
|
||||
if old.PayloadSHA256 == hash {
|
||||
replayed = true
|
||||
return nil
|
||||
}
|
||||
if old.ZIPSize > 0 || len(archive) == 0 || m.Source != "recovery" {
|
||||
return snapshotFailure(409, "FAILURE_SNAPSHOT_CONFLICT")
|
||||
}
|
||||
if !old.ExpiresAt.After(s.Now()) {
|
||||
return snapshotFailure(410, "FAILURE_SNAPSHOT_EXPIRED")
|
||||
}
|
||||
} else if !errors.Is(e, gorm.ErrRecordNotFound) {
|
||||
return e
|
||||
}
|
||||
if a.Status == models.PurchaseAttemptStatusCompleted && a.ResultType != nil && (*a.ResultType == "order_created" || *a.ResultType == "spec_probe_completed" || *a.ResultType == "spec_rematch_completed" || *a.ResultType == "rehearsal_completed") {
|
||||
return snapshotFailure(409, "FAILURE_SNAPSHOT_SUCCESSFUL_ATTEMPT")
|
||||
}
|
||||
reasons, _ := json.Marshal(m.Reasons)
|
||||
row := models.PurchaseFailureSnapshot{TaskID: taskID, AttemptID: attemptID, DeviceID: d.ID, Phase: m.Phase, RuleSnapshotHash: a.RuleSnapshotHash, Status: m.Status, Source: m.Source, ErrorCode: m.ErrorCode, AgentVersion: m.AgentVersion, CapturedAt: m.CapturedAt, RecordedAt: m.RecordedAt, ExpiresAt: m.RecordedAt.Add(FailureSnapshotRetention), ReasonsJSON: string(reasons), ManifestJSON: string(canonical), ZIPData: archive, PayloadSHA256: hash, ZIPSize: int64(len(archive)), ExpandedSize: expanded}
|
||||
if old.ID != 0 {
|
||||
row.ID = old.ID
|
||||
row.CreatedAt = old.CreatedAt
|
||||
return tx.Save(&row).Error
|
||||
}
|
||||
return tx.Create(&row).Error
|
||||
})
|
||||
return m.Status, replayed, err
|
||||
}
|
||||
|
||||
func (s *Service) FailureSnapshotSummaries(ctx context.Context, taskID uint64) ([]FailureSnapshotSummary, error) {
|
||||
var rows []models.PurchaseFailureSnapshot
|
||||
err := s.snapshotDB(ctx).Omit("zip_data", "manifest_json").Where("task_id = ? AND expires_at > ?", taskID, s.Now()).Order("id ASC").Find(&rows).Error
|
||||
out := make([]FailureSnapshotSummary, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
var reasons []string
|
||||
_ = json.Unmarshal([]byte(r.ReasonsJSON), &reasons)
|
||||
out = append(out, FailureSnapshotSummary{AttemptID: r.AttemptID, Phase: r.Phase, Status: r.Status, Source: r.Source, ErrorCode: r.ErrorCode, AgentVersion: r.AgentVersion, CapturedAt: r.CapturedAt, RecordedAt: r.RecordedAt, ExpiresAt: r.ExpiresAt, Partial: r.Status == "partial", Reasons: reasons, DownloadAvailable: r.ZIPSize > 0, ZIPSize: r.ZIPSize})
|
||||
}
|
||||
return out, err
|
||||
}
|
||||
|
||||
func (s *Service) DownloadFailureSnapshot(ctx context.Context, taskID uint64, attemptID string) ([]byte, error) {
|
||||
var row models.PurchaseFailureSnapshot
|
||||
err := s.snapshotDB(ctx).Select("zip_data").Where("task_id = ? AND attempt_id = ? AND expires_at > ? AND zip_size > 0", taskID, attemptID, s.Now()).First(&row).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, snapshotFailure(404, "FAILURE_SNAPSHOT_NOT_FOUND")
|
||||
}
|
||||
return row.ZIPData, err
|
||||
}
|
||||
|
||||
func (s *Service) CleanupFailureSnapshots(ctx context.Context) error {
|
||||
return s.snapshotDB(ctx).Where("expires_at <= ?", s.Now()).Delete(&models.PurchaseFailureSnapshot{}).Error
|
||||
}
|
||||
|
||||
// Starts independently of task execution, including on servers with no new work.
|
||||
func RunFailureSnapshotCleanup(ctx context.Context, s *Service, interval time.Duration, onError func(error)) {
|
||||
if interval <= 0 {
|
||||
interval = time.Hour
|
||||
}
|
||||
clean := func() {
|
||||
if err := s.CleanupFailureSnapshots(ctx); err != nil && ctx.Err() == nil && onError != nil {
|
||||
onError(errors.New("failure snapshot cleanup failed"))
|
||||
}
|
||||
}
|
||||
clean()
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
clean()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package purchase
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// The Android JVM exporter writes only synthetic data when this opt-in path is
|
||||
// set. Consume its actual bytes rather than reconstructing a second Go fixture.
|
||||
func TestFailureSnapshotAndroidExporterContract(t *testing.T) {
|
||||
dir := os.Getenv("GOAUTO_DIAGNOSTIC_FIXTURE_DIR")
|
||||
if dir == "" {
|
||||
t.Skip("set GOAUTO_DIAGNOSTIC_FIXTURE_DIR to the Android synthetic fixture output")
|
||||
}
|
||||
raw, err := os.ReadFile(filepath.Join(dir, "manifest.json"))
|
||||
if err != nil {
|
||||
t.Fatal("Android manifest fixture unavailable")
|
||||
}
|
||||
archive, err := os.ReadFile(filepath.Join(dir, "archive.zip"))
|
||||
if err != nil {
|
||||
t.Fatal("Android archive fixture unavailable")
|
||||
}
|
||||
metadata, err := decodeSnapshotMetadata(raw)
|
||||
if err != nil {
|
||||
t.Fatalf("Android metadata rejected: %v", err)
|
||||
}
|
||||
if err := validateSnapshotMetadata(metadata, metadata.RecordedAt.Add(time.Second)); err != nil {
|
||||
t.Fatalf("Android metadata contract rejected: %v", err)
|
||||
}
|
||||
if _, err := validateFailureArchive(metadata, raw, archive); err != nil {
|
||||
t.Fatalf("Android generated archive rejected: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,371 @@
|
||||
package purchase
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"encoding/xml"
|
||||
"errors"
|
||||
"io"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
var snapshotReasons = map[string]bool{
|
||||
"rule_hash_invalid": true, "execution_not_entered": true, "accessibility_unavailable": true, "process_interrupted": true,
|
||||
"windows_unavailable": true, "windows_unsupported": true, "window_root_null": true, "window_disappeared": true,
|
||||
"window_read_error": true, "node_read_error": true, "attribute_unsupported": true, "node_limit": true, "time_limit": true,
|
||||
"expanded_size_limit": true, "archive_size_limit": true, "capture_failed": true,
|
||||
}
|
||||
|
||||
func validateSnapshotMetadata(m FailureSnapshotMetadata, now time.Time) error {
|
||||
if m.SchemaVersion != 1 || m.AttributeVersion != 1 || m.TaskID == 0 || m.DeviceID == 0 || (m.Phase != "purchase" && m.Phase != "spec_probe") || (m.Source != "execution" && m.Source != "recovery") || !snapshotCodePattern.MatchString(m.ErrorCode) || !snapshotVersionPattern.MatchString(m.AgentVersion) {
|
||||
return invalidSnapshot()
|
||||
}
|
||||
if m.RecordedAt.IsZero() || m.RecordedAt.After(now.Add(5*time.Minute)) {
|
||||
return invalidSnapshot()
|
||||
}
|
||||
_, offset := m.RecordedAt.Zone()
|
||||
if offset != 0 {
|
||||
return invalidSnapshot()
|
||||
}
|
||||
if m.CapturedAt != nil {
|
||||
_, offset = m.CapturedAt.Zone()
|
||||
if offset != 0 || m.CapturedAt.IsZero() || m.CapturedAt.After(m.RecordedAt) || m.CapturedAt.Before(m.RecordedAt.Add(-5*time.Minute)) {
|
||||
return invalidSnapshot()
|
||||
}
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for _, r := range m.Reasons {
|
||||
if !snapshotReasons[r] || seen[r] {
|
||||
return invalidSnapshot()
|
||||
}
|
||||
seen[r] = true
|
||||
}
|
||||
if m.Reasons == nil || m.Windows == nil || len(m.Windows) > 128 {
|
||||
return invalidSnapshot()
|
||||
}
|
||||
if m.RuleSnapshotHashValid {
|
||||
if m.RuleSnapshotHash == nil || !snapshotHashPattern.MatchString(*m.RuleSnapshotHash) {
|
||||
return invalidSnapshot()
|
||||
}
|
||||
} else if m.RuleSnapshotHash != nil || m.Status != "not_captured" || !seen["rule_hash_invalid"] {
|
||||
return invalidSnapshot()
|
||||
}
|
||||
switch m.Status {
|
||||
case "complete":
|
||||
if m.CapturedAt == nil || len(m.Reasons) > 0 {
|
||||
return invalidSnapshot()
|
||||
}
|
||||
case "partial":
|
||||
if m.CapturedAt == nil || len(m.Reasons) == 0 {
|
||||
return invalidSnapshot()
|
||||
}
|
||||
case "not_captured":
|
||||
if m.CapturedAt != nil || len(m.Reasons) == 0 {
|
||||
return invalidSnapshot()
|
||||
}
|
||||
default:
|
||||
return invalidSnapshot()
|
||||
}
|
||||
for _, raw := range m.Windows {
|
||||
var w snapshotWindow
|
||||
if json.Unmarshal(raw, &w) != nil || (m.Status == "complete" && (w.Status != "complete" || w.FileName == nil || len(w.Reasons) > 0)) {
|
||||
return invalidSnapshot()
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type snapshotWindow struct {
|
||||
ID *int `json:"id"`
|
||||
FileName *string `json:"fileName"`
|
||||
Status string `json:"status"`
|
||||
Reasons []string `json:"reasons"`
|
||||
}
|
||||
|
||||
func snapshotWindowFiles(m FailureSnapshotMetadata) (map[string]bool, error) {
|
||||
files := map[string]bool{}
|
||||
ids := map[int]bool{}
|
||||
for _, raw := range m.Windows {
|
||||
var w snapshotWindow
|
||||
if json.Unmarshal(raw, &w) != nil || (w.Status != "complete" && w.Status != "partial") {
|
||||
return nil, invalidSnapshot()
|
||||
}
|
||||
if w.ID != nil {
|
||||
if ids[*w.ID] {
|
||||
return nil, invalidSnapshot()
|
||||
}
|
||||
ids[*w.ID] = true
|
||||
}
|
||||
for _, reason := range w.Reasons {
|
||||
if !snapshotReasons[reason] {
|
||||
return nil, invalidSnapshot()
|
||||
}
|
||||
}
|
||||
if w.FileName == nil {
|
||||
continue
|
||||
}
|
||||
if w.ID == nil || *w.FileName != "window-"+strconv.Itoa(*w.ID)+".xml" || files[*w.FileName] {
|
||||
return nil, invalidSnapshot()
|
||||
}
|
||||
files[*w.FileName] = true
|
||||
}
|
||||
return files, nil
|
||||
}
|
||||
|
||||
func validateFailureArchive(m FailureSnapshotMetadata, raw, archive []byte) (int64, error) {
|
||||
files, err := snapshotWindowFiles(m)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if len(archive) == 0 {
|
||||
if m.Status != "not_captured" || len(files) > 0 {
|
||||
return 0, invalidSnapshot()
|
||||
}
|
||||
return 0, nil
|
||||
}
|
||||
if m.Status == "not_captured" || len(files) == 0 || len(archive) > FailureSnapshotZIPLimit {
|
||||
return 0, invalidSnapshot()
|
||||
}
|
||||
zr, err := zip.NewReader(bytes.NewReader(archive), int64(len(archive)))
|
||||
if err != nil {
|
||||
return 0, invalidSnapshot()
|
||||
}
|
||||
if len(zr.File) != len(files)+1 {
|
||||
return 0, invalidSnapshot()
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
total := int64(0)
|
||||
for _, f := range zr.File {
|
||||
if seen[f.Name] || (f.Name != "manifest.json" && !files[f.Name]) || !f.Mode().IsRegular() || f.Flags&1 != 0 || (f.Method != zip.Store && f.Method != zip.Deflate) {
|
||||
return 0, invalidSnapshot()
|
||||
}
|
||||
seen[f.Name] = true
|
||||
if f.UncompressedSize64 > FailureSnapshotExpandedLimit || f.UncompressedSize64 > uint64(FailureSnapshotExpandedLimit-total) {
|
||||
return 0, invalidSnapshot()
|
||||
}
|
||||
stream, e := f.Open()
|
||||
if e != nil {
|
||||
return 0, invalidSnapshot()
|
||||
}
|
||||
limit := int64(FailureSnapshotExpandedLimit) - total
|
||||
if f.Name == "manifest.json" && limit > FailureSnapshotMetadataLimit {
|
||||
limit = FailureSnapshotMetadataLimit
|
||||
}
|
||||
content, e := io.ReadAll(io.LimitReader(stream, limit+1))
|
||||
closeErr := stream.Close()
|
||||
if e != nil || closeErr != nil || int64(len(content)) > limit || uint64(len(content)) != f.UncompressedSize64 {
|
||||
return 0, invalidSnapshot()
|
||||
}
|
||||
total += int64(len(content))
|
||||
if f.Name == "manifest.json" {
|
||||
if _, e = decodeSnapshotMetadata(content); e != nil {
|
||||
return 0, invalidSnapshot()
|
||||
}
|
||||
left, le := canonicalSnapshotJSON(raw)
|
||||
right, re := canonicalSnapshotJSON(content)
|
||||
if le != nil || re != nil || !bytes.Equal(left, right) {
|
||||
return 0, invalidSnapshot()
|
||||
}
|
||||
} else if e = validateFailureXML(content); e != nil {
|
||||
return 0, e
|
||||
}
|
||||
}
|
||||
if !seen["manifest.json"] {
|
||||
return 0, invalidSnapshot()
|
||||
}
|
||||
return total, nil
|
||||
}
|
||||
|
||||
func canonicalSnapshotJSON(raw []byte) ([]byte, error) {
|
||||
d := json.NewDecoder(bytes.NewReader(raw))
|
||||
d.UseNumber()
|
||||
var v any
|
||||
if err := d.Decode(&v); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return json.Marshal(v)
|
||||
}
|
||||
|
||||
// encoding/json otherwise accepts duplicate keys, allowing conflicting identity
|
||||
// or password-related metadata to be interpreted differently by other readers.
|
||||
func uniqueSnapshotJSON(raw []byte) error {
|
||||
d := json.NewDecoder(bytes.NewReader(raw))
|
||||
d.UseNumber()
|
||||
var value func(int) error
|
||||
value = func(depth int) error {
|
||||
if depth > 64 {
|
||||
return invalidSnapshot()
|
||||
}
|
||||
tok, err := d.Token()
|
||||
if err != nil {
|
||||
return invalidSnapshot()
|
||||
}
|
||||
delim, ok := tok.(json.Delim)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
switch delim {
|
||||
case '{':
|
||||
seen := map[string]bool{}
|
||||
for d.More() {
|
||||
key, e := d.Token()
|
||||
if e != nil {
|
||||
return invalidSnapshot()
|
||||
}
|
||||
name, ok := key.(string)
|
||||
if !ok || seen[name] {
|
||||
return invalidSnapshot()
|
||||
}
|
||||
seen[name] = true
|
||||
if e = value(depth + 1); e != nil {
|
||||
return e
|
||||
}
|
||||
}
|
||||
case '[':
|
||||
for d.More() {
|
||||
if e := value(depth + 1); e != nil {
|
||||
return e
|
||||
}
|
||||
}
|
||||
default:
|
||||
return invalidSnapshot()
|
||||
}
|
||||
_, err = d.Token()
|
||||
return err
|
||||
}
|
||||
if err := value(0); err != nil {
|
||||
return invalidSnapshot()
|
||||
}
|
||||
if _, err := d.Token(); !errors.Is(err, io.EOF) {
|
||||
return invalidSnapshot()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var snapshotXMLAttribute = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9_-]*$`)
|
||||
var snapshotSecretAttributes = map[string]bool{"text": true, "content-desc": true, "contentDescription": true, "hint": true, "state-description": true, "stateDescription": true, "tooltip": true, "label": true}
|
||||
|
||||
func validateFailureXML(raw []byte) error {
|
||||
d := xml.NewDecoder(bytes.NewReader(raw))
|
||||
d.Strict = true
|
||||
type frame struct {
|
||||
name string
|
||||
password bool
|
||||
}
|
||||
stack := []frame{}
|
||||
roots := 0
|
||||
nodes := 0
|
||||
for {
|
||||
token, err := d.Token()
|
||||
if errors.Is(err, io.EOF) {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return invalidSnapshot()
|
||||
}
|
||||
switch v := token.(type) {
|
||||
case xml.Directive:
|
||||
return invalidSnapshot()
|
||||
case xml.ProcInst:
|
||||
if v.Target != "xml" || roots != 0 {
|
||||
return invalidSnapshot()
|
||||
}
|
||||
case xml.Comment:
|
||||
return invalidSnapshot()
|
||||
case xml.CharData:
|
||||
if strings.TrimSpace(string(v)) != "" {
|
||||
return invalidSnapshot()
|
||||
}
|
||||
case xml.StartElement:
|
||||
if v.Name.Space != "" || len(stack) > 256 {
|
||||
return invalidSnapshot()
|
||||
}
|
||||
if len(stack) == 0 {
|
||||
roots++
|
||||
if roots != 1 || v.Name.Local != "hierarchy" {
|
||||
return invalidSnapshot()
|
||||
}
|
||||
} else {
|
||||
parent := stack[len(stack)-1].name
|
||||
if (v.Name.Local != "node" && v.Name.Local != "action") || parent == "action" || (v.Name.Local == "action" && parent != "node") {
|
||||
return invalidSnapshot()
|
||||
}
|
||||
}
|
||||
if v.Name.Local == "node" {
|
||||
nodes++
|
||||
if nodes > 5000 {
|
||||
return invalidSnapshot()
|
||||
}
|
||||
}
|
||||
// A missing/unreadable password flag cannot establish that content is
|
||||
// safe to retain. Only an explicit false on the node opens that gate.
|
||||
secret := v.Name.Local == "node"
|
||||
if len(stack) > 0 && v.Name.Local == "action" {
|
||||
secret = stack[len(stack)-1].password
|
||||
}
|
||||
passwordState := false
|
||||
for _, a := range v.Attr {
|
||||
if v.Name.Local == "node" && a.Name.Local == "password" && a.Value == "false" {
|
||||
secret = false
|
||||
}
|
||||
if a.Name.Local == "password" && strings.EqualFold(a.Value, "true") {
|
||||
secret = true
|
||||
}
|
||||
if a.Name.Local == "password-state" {
|
||||
passwordState = true
|
||||
}
|
||||
if a.Name.Local == "password-base64" || (a.Name.Local == "password" && a.Value != "true" && a.Value != "false") {
|
||||
return invalidSnapshot()
|
||||
}
|
||||
}
|
||||
secret = secret || passwordState
|
||||
attrs := map[string]string{}
|
||||
for _, a := range v.Attr {
|
||||
if a.Name.Space != "" || !snapshotXMLAttribute.MatchString(a.Name.Local) {
|
||||
return invalidSnapshot()
|
||||
}
|
||||
name := a.Name.Local
|
||||
if _, ok := attrs[name]; ok {
|
||||
return invalidSnapshot()
|
||||
}
|
||||
attrs[name] = a.Value
|
||||
base := strings.TrimSuffix(strings.TrimSuffix(name, "-state"), "-base64")
|
||||
if secret && snapshotSecretAttributes[base] {
|
||||
return invalidSnapshot()
|
||||
}
|
||||
if strings.HasSuffix(name, "-state") && a.Value != "null" && a.Value != "unsupported" && a.Value != "read_error" {
|
||||
return invalidSnapshot()
|
||||
}
|
||||
if strings.HasSuffix(name, "-base64") {
|
||||
decoded, e := base64.StdEncoding.Strict().DecodeString(a.Value)
|
||||
if e != nil || len(decoded)%2 != 0 {
|
||||
return invalidSnapshot()
|
||||
}
|
||||
}
|
||||
}
|
||||
for name := range attrs {
|
||||
if strings.HasSuffix(name, "-base64") || strings.HasSuffix(name, "-state") {
|
||||
base := strings.TrimSuffix(strings.TrimSuffix(name, "-state"), "-base64")
|
||||
if _, ok := attrs[base]; ok {
|
||||
return invalidSnapshot()
|
||||
}
|
||||
}
|
||||
}
|
||||
stack = append(stack, frame{v.Name.Local, secret})
|
||||
case xml.EndElement:
|
||||
if len(stack) == 0 {
|
||||
return invalidSnapshot()
|
||||
}
|
||||
stack = stack[:len(stack)-1]
|
||||
}
|
||||
}
|
||||
if roots != 1 || len(stack) != 0 {
|
||||
return invalidSnapshot()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
package purchase
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
|
||||
"github.com/google/uuid"
|
||||
"go-admin/app/goauto/device"
|
||||
"go-admin/common/clientprincipal"
|
||||
)
|
||||
|
||||
func (h Handler) UploadFailureSnapshot(c *gin.Context) {
|
||||
s, ok := h.service(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
// Authenticate before reading an untrusted archive.
|
||||
if _, err := device.NewService(s.snapshotDB(c.Request.Context())).Authenticate(c.Request.Context(), bearer(c.GetHeader("Authorization"))); err != nil {
|
||||
writeError(c, err)
|
||||
return
|
||||
}
|
||||
taskID, ok := pathID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, FailureSnapshotZIPLimit+FailureSnapshotMetadataLimit+(16<<10))
|
||||
reader, err := c.Request.MultipartReader()
|
||||
if err != nil {
|
||||
writeSnapshotError(c, invalidSnapshot())
|
||||
return
|
||||
}
|
||||
var metadata, archive []byte
|
||||
seen := map[string]bool{}
|
||||
for {
|
||||
part, e := reader.NextPart()
|
||||
if errors.Is(e, io.EOF) {
|
||||
break
|
||||
}
|
||||
if e != nil {
|
||||
var tooLarge *http.MaxBytesError
|
||||
if errors.As(e, &tooLarge) {
|
||||
writeSnapshotError(c, snapshotFailure(413, "FAILURE_SNAPSHOT_TOO_LARGE"))
|
||||
} else {
|
||||
writeSnapshotError(c, invalidSnapshot())
|
||||
}
|
||||
return
|
||||
}
|
||||
name := part.FormName()
|
||||
limit := int64(FailureSnapshotMetadataLimit)
|
||||
if name == "archive" {
|
||||
limit = FailureSnapshotZIPLimit
|
||||
} else if name != "metadata" {
|
||||
part.Close()
|
||||
writeSnapshotError(c, invalidSnapshot())
|
||||
return
|
||||
}
|
||||
if seen[name] {
|
||||
part.Close()
|
||||
writeSnapshotError(c, invalidSnapshot())
|
||||
return
|
||||
}
|
||||
seen[name] = true
|
||||
raw, e := io.ReadAll(io.LimitReader(part, limit+1))
|
||||
part.Close()
|
||||
if e != nil || int64(len(raw)) > limit {
|
||||
writeSnapshotError(c, snapshotFailure(413, "FAILURE_SNAPSHOT_TOO_LARGE"))
|
||||
return
|
||||
}
|
||||
if name == "metadata" {
|
||||
metadata = raw
|
||||
} else {
|
||||
archive = raw
|
||||
}
|
||||
}
|
||||
if seen["archive"] && len(archive) == 0 {
|
||||
writeSnapshotError(c, invalidSnapshot())
|
||||
return
|
||||
}
|
||||
status, replayed, err := s.UploadFailureSnapshot(c.Request.Context(), taskID, c.Param("attemptId"), bearer(c.GetHeader("Authorization")), metadata, archive)
|
||||
if err != nil {
|
||||
writeSnapshotError(c, err)
|
||||
return
|
||||
}
|
||||
c.Header("Cache-Control", "no-store")
|
||||
c.JSON(200, gin.H{"data": gin.H{"taskAttemptId": c.Param("attemptId"), "status": status, "replayed": replayed}})
|
||||
}
|
||||
|
||||
func snapshotAdminOnly(c *gin.Context) bool {
|
||||
_, client := clientprincipal.Get(c)
|
||||
role, _ := jwt.ExtractClaims(c)["rolekey"].(string)
|
||||
if !client && role == "admin" {
|
||||
return true
|
||||
}
|
||||
c.AbortWithStatusJSON(403, gin.H{"code": "FORBIDDEN", "message": "仅管理员可访问现场诊断"})
|
||||
return false
|
||||
}
|
||||
|
||||
func (h Handler) FailureSnapshotSummaries(c *gin.Context) {
|
||||
if !snapshotAdminOnly(c) {
|
||||
return
|
||||
}
|
||||
id, ok := pathID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
s, ok := h.service(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
items, err := s.FailureSnapshotSummaries(c.Request.Context(), id)
|
||||
if err != nil {
|
||||
writeSnapshotError(c, err)
|
||||
return
|
||||
}
|
||||
c.Header("Cache-Control", "no-store")
|
||||
writeAdminData(c, gin.H{"items": items})
|
||||
}
|
||||
|
||||
func (h Handler) DownloadFailureSnapshot(c *gin.Context) {
|
||||
if !snapshotAdminOnly(c) {
|
||||
return
|
||||
}
|
||||
id, ok := pathID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
attempt, err := uuid.Parse(c.Param("attemptId"))
|
||||
if err != nil || attempt.String() != c.Param("attemptId") {
|
||||
writeSnapshotError(c, invalidSnapshot())
|
||||
return
|
||||
}
|
||||
s, ok := h.service(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
raw, err := s.DownloadFailureSnapshot(c.Request.Context(), id, attempt.String())
|
||||
if err != nil {
|
||||
writeSnapshotError(c, err)
|
||||
return
|
||||
}
|
||||
c.Header("Cache-Control", "no-store")
|
||||
c.Header("X-Content-Type-Options", "nosniff")
|
||||
c.Header("Content-Disposition", `attachment; filename="purchase-`+strconv.FormatUint(id, 10)+`-`+attempt.String()+`.zip"`)
|
||||
c.Data(200, "application/zip", raw)
|
||||
}
|
||||
|
||||
func writeSnapshotError(c *gin.Context, err error) {
|
||||
var e *snapshotError
|
||||
if errors.As(err, &e) {
|
||||
c.JSON(e.status, gin.H{"code": e.code, "message": "现场诊断请求未被接受", "retryable": false})
|
||||
return
|
||||
}
|
||||
writeError(c, err)
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
package purchase
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func TestFailureSnapshotMissingPasswordFlagRejectsContents(t *testing.T) {
|
||||
for _, raw := range []string{
|
||||
`<hierarchy><node text="secret"/></hierarchy>`,
|
||||
`<hierarchy><node text-base64="AHMAZQBjAHIAZQB0"/></hierarchy>`,
|
||||
`<hierarchy><node><action id="1" label="secret"/></node></hierarchy>`,
|
||||
`<hierarchy><node><action id="1" password="false" label="secret"/></node></hierarchy>`,
|
||||
} {
|
||||
if validateFailureXML([]byte(raw)) == nil {
|
||||
t.Errorf("missing password flag retained content: %s", raw)
|
||||
}
|
||||
}
|
||||
for _, raw := range []string{
|
||||
`<hierarchy><node password="false" text="public"><action id="1" label="public"/></node></hierarchy>`,
|
||||
`<hierarchy><node password="false" text-base64="AGE="/></hierarchy>`,
|
||||
`<hierarchy><node path="0" child-count-state="read_error" actions-state="read_error"><action id="1"/></node></hierarchy>`,
|
||||
} {
|
||||
if err := validateFailureXML([]byte(raw)); err != nil {
|
||||
t.Fatalf("safe structure or explicit non-password rejected: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func exactSnapshotZIP(t *testing.T, m FailureSnapshotMetadata, size int) []byte {
|
||||
t.Helper()
|
||||
makeZIP := func(padding int) []byte {
|
||||
var b bytes.Buffer
|
||||
w := zip.NewWriter(&b)
|
||||
raw, _ := json.Marshal(m)
|
||||
for _, entry := range []struct {
|
||||
name string
|
||||
data []byte
|
||||
}{{"manifest.json", raw}, {"window-1.xml", []byte(`<hierarchy><node password="false" text="` + strings.Repeat("x", padding) + `"/></hierarchy>`)}} {
|
||||
part, err := w.CreateHeader(&zip.FileHeader{Name: entry.name, Method: zip.Store})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err = part.Write(entry.data); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := w.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return b.Bytes()
|
||||
}
|
||||
base := makeZIP(0)
|
||||
if size < len(base) {
|
||||
t.Fatal("requested archive size too small")
|
||||
}
|
||||
result := makeZIP(size - len(base))
|
||||
if len(result) != size {
|
||||
t.Fatalf("ZIP fixture size=%d want=%d", len(result), size)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func TestFailureSnapshotHTTPMultipartLimits(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
metadataSize, archiveSize int
|
||||
oversizedPreamble bool
|
||||
want int
|
||||
}{
|
||||
{name: "metadata_exact_64KiB", metadataSize: FailureSnapshotMetadataLimit, want: 200},
|
||||
{name: "metadata_64KiB_plus_one", metadataSize: FailureSnapshotMetadataLimit + 1, want: 413},
|
||||
{name: "archive_exact_2MiB", archiveSize: FailureSnapshotZIPLimit, want: 200},
|
||||
{name: "archive_2MiB_plus_one", archiveSize: FailureSnapshotZIPLimit + 1, want: 413},
|
||||
{name: "whole_body_exceeded_in_next_part", oversizedPreamble: true, want: 413},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
s, f, a, m := snapshotFixture(t)
|
||||
m.RecordedAt = time.Now().UTC()
|
||||
var archive []byte
|
||||
if tc.archiveSize > 0 {
|
||||
m = capturedMetadata(m)
|
||||
archive = exactSnapshotZIP(t, m, tc.archiveSize)
|
||||
}
|
||||
raw, _ := json.Marshal(m)
|
||||
if tc.metadataSize > 0 {
|
||||
raw = append(raw, bytes.Repeat([]byte(" "), tc.metadataSize-len(raw))...)
|
||||
}
|
||||
var body bytes.Buffer
|
||||
mw := multipart.NewWriter(&body)
|
||||
if tc.oversizedPreamble {
|
||||
limit := FailureSnapshotMetadataLimit + FailureSnapshotZIPLimit + (16 << 10)
|
||||
body.WriteString(strings.Repeat("ignored-preamble\r\n", limit/18+2))
|
||||
}
|
||||
if err := mw.WriteField("metadata", string(raw)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if archive != nil {
|
||||
part, err := mw.CreateFormFile("archive", "synthetic.zip")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err = io.Copy(part, bytes.NewReader(archive)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := mw.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r := gin.New()
|
||||
r.POST("/:taskId/attempts/:attemptId/failure-snapshot", (Handler{DB: s.DB}).UploadFailureSnapshot)
|
||||
req := httptest.NewRequest("POST", fmt.Sprintf("/%d/attempts/%s/failure-snapshot", a.TaskID, a.AttemptID), &body)
|
||||
req.Header.Set("Content-Type", mw.FormDataContentType())
|
||||
req.Header.Set("Authorization", "Bearer "+f.token)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
if w.Code != tc.want {
|
||||
t.Fatalf("status=%d want=%d body=%s", w.Code, tc.want, w.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,507 @@
|
||||
package purchase
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/gin-gonic/gin"
|
||||
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
|
||||
"github.com/google/uuid"
|
||||
"go-admin/app/goauto/access"
|
||||
"go-admin/app/goauto/models"
|
||||
"go-admin/common/clientprincipal"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
"log"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestFailureSnapshotDedicatedRoutesAndPrivateSchema(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
InitRouter(r, &jwt.GinJWTMiddleware{})
|
||||
expected := map[string]bool{
|
||||
"POST /api/agent/v1/purchase-tasks/:taskId/attempts/:attemptId/failure-snapshot": false,
|
||||
"GET /api/admin/v1/purchase-tasks/:taskId/failure-snapshots": false,
|
||||
"GET /api/admin/v1/purchase-tasks/:taskId/attempts/:attemptId/failure-snapshot/download": false,
|
||||
}
|
||||
for _, route := range r.Routes() {
|
||||
key := route.Method + " " + route.Path
|
||||
if _, ok := expected[key]; ok {
|
||||
expected[key] = true
|
||||
}
|
||||
}
|
||||
for route, found := range expected {
|
||||
if !found {
|
||||
t.Errorf("dedicated snapshot route missing: %s", route)
|
||||
}
|
||||
}
|
||||
for _, path := range []string{"/api/admin/v1/purchase-tasks/:taskId/failure-snapshots", "/api/admin/v1/purchase-tasks/:taskId/attempts/:attemptId/failure-snapshot/download"} {
|
||||
found := false
|
||||
for _, p := range access.AdminAPIs {
|
||||
if p.Path == path && p.Method == "GET" {
|
||||
found = true
|
||||
if p.Purchaser {
|
||||
t.Error("snapshot permission leaked to purchaser")
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("admin-only permission missing: %s", path)
|
||||
}
|
||||
}
|
||||
db := testDB(t)
|
||||
if !db.Migrator().HasTable("purchase_failure_snapshot") {
|
||||
t.Error("private snapshot table missing from fresh schema")
|
||||
}
|
||||
}
|
||||
|
||||
func snapshotFixture(t *testing.T) (*Service, fixture, models.PurchaseTaskAttempt, FailureSnapshotMetadata) {
|
||||
t.Helper()
|
||||
db := testDB(t)
|
||||
f := seed(t, db, liveCaps(), true)
|
||||
s := testService(db)
|
||||
task := backfillTask(t, db, f, models.PurchaseTaskStatusFailed)
|
||||
hash := strings.Repeat("a", 64)
|
||||
a := models.PurchaseTaskAttempt{TaskID: task.ID, AttemptID: uuid.NewString(), AttemptNumber: 1, Phase: "purchase", Status: "failed", DeviceID: &f.device.ID, RuleSnapshotHash: hash, SpecDecisionSnapshot: "{}"}
|
||||
if err := db.Create(&a).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m := FailureSnapshotMetadata{SchemaVersion: 1, AttributeVersion: 1, TaskID: task.ID, DeviceID: f.device.ID, TaskAttemptID: a.AttemptID, Phase: a.Phase, RuleSnapshotHash: &hash, RuleSnapshotHashValid: true, ErrorCode: "PURCHASE_ORDER_CONTEXT_NOT_FOUND", AgentVersion: "0.9.65", RecordedAt: s.Now(), Source: "execution", Status: "not_captured", Reasons: []string{"process_interrupted"}, Windows: []json.RawMessage{}}
|
||||
return s, f, a, m
|
||||
}
|
||||
|
||||
func uploadSnapshot(t *testing.T, s *Service, f fixture, m FailureSnapshotMetadata, archive []byte) (bool, error) {
|
||||
t.Helper()
|
||||
raw, _ := json.Marshal(m)
|
||||
_, replay, err := s.UploadFailureSnapshot(context.Background(), m.TaskID, m.TaskAttemptID, f.token, raw, archive)
|
||||
return replay, err
|
||||
}
|
||||
|
||||
func snapshotZIP(t *testing.T, m FailureSnapshotMetadata, xml string, extra map[string]string) []byte {
|
||||
t.Helper()
|
||||
var b bytes.Buffer
|
||||
w := zip.NewWriter(&b)
|
||||
raw, _ := json.Marshal(m)
|
||||
p, _ := w.Create("manifest.json")
|
||||
p.Write(raw)
|
||||
if xml != "" {
|
||||
p, _ = w.Create("window-1.xml")
|
||||
p.Write([]byte(xml))
|
||||
}
|
||||
for n, v := range extra {
|
||||
p, _ = w.Create(n)
|
||||
p.Write([]byte(v))
|
||||
}
|
||||
if err := w.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return b.Bytes()
|
||||
}
|
||||
|
||||
func capturedMetadata(m FailureSnapshotMetadata) FailureSnapshotMetadata {
|
||||
m.Status = "complete"
|
||||
m.Source = "recovery"
|
||||
m.CapturedAt = &m.RecordedAt
|
||||
m.Reasons = []string{}
|
||||
m.Windows = []json.RawMessage{json.RawMessage(`{"id":1,"fileName":"window-1.xml","status":"complete","reasons":[]}`)}
|
||||
return m
|
||||
}
|
||||
|
||||
func TestFailureSnapshotNoCaptureReplayUpgradeAndImmutableZIP(t *testing.T) {
|
||||
s, f, a, m := snapshotFixture(t)
|
||||
if replay, err := uploadSnapshot(t, s, f, m, nil); err != nil || replay {
|
||||
t.Fatalf("initial: replay=%v err=%v", replay, err)
|
||||
}
|
||||
if replay, err := uploadSnapshot(t, s, f, m, nil); err != nil || !replay {
|
||||
t.Fatalf("replay: replay=%v err=%v", replay, err)
|
||||
}
|
||||
// Current task ownership/hash may change after reset; attempt remains authority.
|
||||
if err := s.DB.Table("purchase_task").Where("id = ?", a.TaskID).Updates(map[string]any{"device_id": nil, "rule_snapshot": "{}"}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m = capturedMetadata(m)
|
||||
archive := snapshotZIP(t, m, `<hierarchy><node password="false" text="SYNTHETIC_PRIVATE"/></hierarchy>`, nil)
|
||||
if _, err := uploadSnapshot(t, s, f, m, archive); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if replay, err := uploadSnapshot(t, s, f, m, archive); err != nil || !replay {
|
||||
t.Fatalf("ZIP replay: %v %v", replay, err)
|
||||
}
|
||||
other := snapshotZIP(t, m, `<hierarchy><node password="false" text="OTHER"/></hierarchy>`, nil)
|
||||
if _, err := uploadSnapshot(t, s, f, m, other); err == nil {
|
||||
t.Fatal("ZIP overwritten")
|
||||
}
|
||||
raw, err := s.DownloadFailureSnapshot(context.Background(), a.TaskID, a.AttemptID)
|
||||
if err != nil || !bytes.Equal(raw, archive) {
|
||||
t.Fatalf("original lost: err=%v bytes=%d expected=%d", err, len(raw), len(archive))
|
||||
}
|
||||
views, err := s.FailureSnapshotSummaries(context.Background(), a.TaskID)
|
||||
if err != nil || len(views) != 1 || !views[0].DownloadAvailable {
|
||||
t.Fatal("summary missing")
|
||||
}
|
||||
out, _ := json.Marshal(views)
|
||||
if bytes.Contains(out, []byte("SYNTHETIC_PRIVATE")) || bytes.Contains(out, []byte("windows")) {
|
||||
t.Fatal("summary leak")
|
||||
}
|
||||
var row models.PurchaseFailureSnapshot
|
||||
s.DB.First(&row)
|
||||
out, _ = json.Marshal(row)
|
||||
if string(out) != "{}" {
|
||||
t.Fatal("model has public fields")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFailureSnapshotOwnershipHashPhaseAndSuccessfulAttempt(t *testing.T) {
|
||||
for _, kind := range []string{"device", "task", "attempt", "phase", "hash", "success", "expired", "future", "invalid_hash_zip"} {
|
||||
t.Run(kind, func(t *testing.T) {
|
||||
s, f, a, m := snapshotFixture(t)
|
||||
switch kind {
|
||||
case "device":
|
||||
m.DeviceID++
|
||||
case "task":
|
||||
m.TaskID++
|
||||
case "attempt":
|
||||
m.TaskAttemptID = uuid.NewString()
|
||||
case "phase":
|
||||
m.Phase = "spec_probe"
|
||||
case "hash":
|
||||
h := strings.Repeat("b", 64)
|
||||
m.RuleSnapshotHash = &h
|
||||
case "success":
|
||||
s.DB.Model(&a).Updates(map[string]any{"result_type": "order_created", "status": "completed"})
|
||||
case "expired":
|
||||
m.RecordedAt = m.RecordedAt.Add(-FailureSnapshotRetention)
|
||||
case "future":
|
||||
m.RecordedAt = m.RecordedAt.Add(time.Hour)
|
||||
case "invalid_hash_zip":
|
||||
m = capturedMetadata(m)
|
||||
m.RuleSnapshotHashValid = false
|
||||
m.RuleSnapshotHash = nil
|
||||
}
|
||||
if _, err := uploadSnapshot(t, s, f, m, nil); err == nil {
|
||||
t.Fatalf("accepted %s", kind)
|
||||
}
|
||||
var count int64
|
||||
s.DB.Model(&models.PurchaseFailureSnapshot{}).Count(&count)
|
||||
if count != 0 {
|
||||
t.Fatal("rejected request persisted")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFailureSnapshotDedicatedAdminGuard(t *testing.T) {
|
||||
for _, role := range []string{"admin", "purchaser", "after_sales", ""} {
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Set("JWT_PAYLOAD", jwt.MapClaims{"rolekey": role})
|
||||
if got := snapshotAdminOnly(c); got != (role == "admin") {
|
||||
t.Fatalf("role %s", role)
|
||||
}
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Set("JWT_PAYLOAD", jwt.MapClaims{"rolekey": "admin"})
|
||||
clientprincipal.Set(c, clientprincipal.Identity{KeyID: 1})
|
||||
if snapshotAdminOnly(c) {
|
||||
t.Fatal("client key accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFailureSnapshotServerDemotionStillAcceptsDiagnostic(t *testing.T) {
|
||||
s, f, a, m := snapshotFixture(t)
|
||||
s.DB.Model(&a).Update("result_type", "order_created")
|
||||
if _, err := uploadSnapshot(t, s, f, m, nil); err != nil {
|
||||
t.Fatalf("demoted failed attempt rejected: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFailureSnapshotSemanticReplayAndDuplicateJSON(t *testing.T) {
|
||||
s, f, _, m := snapshotFixture(t)
|
||||
m = capturedMetadata(m)
|
||||
archive := snapshotZIP(t, m, `<hierarchy/>`, nil)
|
||||
if _, err := uploadSnapshot(t, s, f, m, archive); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m.Windows = []json.RawMessage{json.RawMessage(`{"status":"complete","fileName":"window-1.xml","id":1,"reasons":[]}`)}
|
||||
if replay, err := uploadSnapshot(t, s, f, m, archive); err != nil || !replay {
|
||||
t.Fatalf("semantic replay: %v %v", replay, err)
|
||||
}
|
||||
raw, _ := json.Marshal(m)
|
||||
raw = append([]byte(`{"status":"not_captured",`), raw[1:]...)
|
||||
if _, err := decodeSnapshotMetadata(raw); err == nil {
|
||||
t.Fatal("duplicate JSON key accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFailureSnapshotHTTPIsolationMultipartAndDownload(t *testing.T) {
|
||||
s, f, a, m := snapshotFixture(t)
|
||||
m.RecordedAt = time.Now().UTC()
|
||||
m = capturedMetadata(m)
|
||||
archive := snapshotZIP(t, m, `<hierarchy><node password="false" text="PRIVATE_FIXTURE"/></hierarchy>`, nil)
|
||||
h := Handler{DB: s.DB}
|
||||
r := gin.New()
|
||||
r.POST("/:taskId/attempts/:attemptId/failure-snapshot", h.UploadFailureSnapshot)
|
||||
for _, tc := range []struct {
|
||||
name, token string
|
||||
duplicate bool
|
||||
want int
|
||||
}{{"missing_token", "", false, 401}, {"ok", f.token, false, 200}, {"duplicate", f.token, true, 422}} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
var b bytes.Buffer
|
||||
mw := multipart.NewWriter(&b)
|
||||
raw, _ := json.Marshal(m)
|
||||
mw.WriteField("metadata", string(raw))
|
||||
if tc.duplicate {
|
||||
mw.WriteField("metadata", string(raw))
|
||||
}
|
||||
p, _ := mw.CreateFormFile("archive", "snapshot.zip")
|
||||
p.Write(archive)
|
||||
mw.Close()
|
||||
req := httptest.NewRequest("POST", fmt.Sprintf("/%d/attempts/%s/failure-snapshot", a.TaskID, a.AttemptID), &b)
|
||||
req.Header.Set("Content-Type", mw.FormDataContentType())
|
||||
req.Header.Set("Authorization", "Bearer "+tc.token)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
if w.Code != tc.want {
|
||||
t.Fatalf("status %d body %s", w.Code, w.Body.String())
|
||||
}
|
||||
if strings.Contains(w.Body.String(), "PRIVATE_FIXTURE") {
|
||||
t.Fatal("HTTP leak")
|
||||
}
|
||||
})
|
||||
}
|
||||
for _, role := range []string{"admin", "purchaser", "after_sales"} {
|
||||
router := gin.New()
|
||||
router.Use(func(c *gin.Context) { c.Set("JWT_PAYLOAD", jwt.MapClaims{"rolekey": role}) })
|
||||
router.GET("/:taskId/failure-snapshots", h.FailureSnapshotSummaries)
|
||||
router.GET("/:taskId/attempts/:attemptId/download", h.DownloadFailureSnapshot)
|
||||
for _, suffix := range []string{"failure-snapshots", "attempts/" + a.AttemptID + "/download"} {
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, httptest.NewRequest(http.MethodGet, fmt.Sprintf("/%d/%s", a.TaskID, suffix), nil))
|
||||
if role != "admin" {
|
||||
if w.Code != 403 {
|
||||
t.Fatalf("%s: %d", role, w.Code)
|
||||
}
|
||||
} else {
|
||||
if w.Code != 200 {
|
||||
t.Fatalf("admin %s: %d %s", suffix, w.Code, w.Body.String())
|
||||
}
|
||||
if strings.HasSuffix(suffix, "download") {
|
||||
if w.Header().Get("Content-Type") != "application/zip" || !bytes.Equal(w.Body.Bytes(), archive) || w.Header().Get("Cache-Control") != "no-store" {
|
||||
t.Fatal("download contract")
|
||||
}
|
||||
} else if strings.Contains(w.Body.String(), "PRIVATE_FIXTURE") || strings.Contains(w.Body.String(), "manifest") {
|
||||
t.Fatal("metadata leak")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFailureSnapshotArchiveValidation(t *testing.T) {
|
||||
_, _, _, base := snapshotFixture(t)
|
||||
m := capturedMetadata(base)
|
||||
raw, _ := json.Marshal(m)
|
||||
for _, tc := range []struct {
|
||||
name, xml string
|
||||
extra map[string]string
|
||||
}{
|
||||
{"doctype", `<!DOCTYPE hierarchy [<!ENTITY x "private">]><hierarchy/>`, nil},
|
||||
{"multiple_roots", `<hierarchy/><hierarchy/>`, nil},
|
||||
{"password_text", `<hierarchy><node password="true" text="secret"/></hierarchy>`, nil},
|
||||
{"password_description", `<hierarchy><node password="true" content-desc="secret"/></hierarchy>`, nil},
|
||||
{"password_action", `<hierarchy><node password="true"><action id="1" label="secret"/></node></hierarchy>`, nil},
|
||||
{"traversal", `<hierarchy/>`, map[string]string{"../outside.xml": "x"}},
|
||||
{"unknown_file", `<hierarchy/>`, map[string]string{"extra.xml": "x"}},
|
||||
{"expanded_limit", "<hierarchy>" + strings.Repeat(" ", FailureSnapshotExpandedLimit) + "</hierarchy>", nil},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
archive := snapshotZIP(t, m, tc.xml, tc.extra)
|
||||
if _, err := validateFailureArchive(m, raw, archive); err == nil {
|
||||
t.Fatal("unsafe archive accepted")
|
||||
}
|
||||
})
|
||||
}
|
||||
valid := snapshotZIP(t, m, `<hierarchy><node text="a&b😀" password="false"/></hierarchy>`, nil)
|
||||
if _, err := validateFailureArchive(m, raw, valid); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wrong := m
|
||||
wrong.TaskID++
|
||||
bad := snapshotZIP(t, wrong, `<hierarchy/>`, nil)
|
||||
if _, err := validateFailureArchive(m, raw, bad); err == nil {
|
||||
t.Fatal("wrong manifest accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFailureSnapshotExpiryAndIdleCleanup(t *testing.T) {
|
||||
s, f, a, m := snapshotFixture(t)
|
||||
if _, err := uploadSnapshot(t, s, f, m, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
now := s.Now().Add(FailureSnapshotRetention)
|
||||
s.Now = func() time.Time { return now }
|
||||
if _, err := uploadSnapshot(t, s, f, m, nil); err == nil {
|
||||
t.Fatal("expired replay accepted")
|
||||
} else {
|
||||
var e *snapshotError
|
||||
if !errors.As(err, &e) || e.status != 410 {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
views, err := s.FailureSnapshotSummaries(context.Background(), a.TaskID)
|
||||
if err != nil || len(views) != 0 {
|
||||
t.Fatal("expired summary visible")
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
RunFailureSnapshotCleanup(ctx, s, time.Millisecond, nil)
|
||||
// Exercise actual idle ticker independently of upload traffic.
|
||||
ctx, cancel = context.WithCancel(context.Background())
|
||||
done := make(chan struct{})
|
||||
go func() { RunFailureSnapshotCleanup(ctx, s, time.Millisecond, nil); close(done) }()
|
||||
deadline := time.Now().Add(time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
var count int64
|
||||
s.DB.Model(&models.PurchaseFailureSnapshot{}).Count(&count)
|
||||
if count == 0 {
|
||||
cancel()
|
||||
<-done
|
||||
return
|
||||
}
|
||||
time.Sleep(time.Millisecond)
|
||||
}
|
||||
cancel()
|
||||
<-done
|
||||
t.Fatal("idle cleanup did not run")
|
||||
}
|
||||
|
||||
func TestFailureSnapshotSQLAndOrdinaryViewsDoNotLeak(t *testing.T) {
|
||||
s, f, a, m := snapshotFixture(t)
|
||||
var logs bytes.Buffer
|
||||
s.DB = s.DB.Session(&gorm.Session{Logger: logger.New(log.New(&logs, "", 0), logger.Config{LogLevel: logger.Info})})
|
||||
if _, err := uploadSnapshot(t, s, f, m, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m = capturedMetadata(m)
|
||||
m.Windows = []json.RawMessage{json.RawMessage(`{"id":1,"fileName":"window-1.xml","status":"complete","reasons":[],"title":"PRIVATE_WINDOW_SENTINEL"}`)}
|
||||
archive := snapshotZIP(t, m, `<hierarchy><node password="false" text="PRIVATE_NODE_SENTINEL"/></hierarchy>`, nil)
|
||||
// Force a private-table persistence error after validation. The SQL must stay silent.
|
||||
s.DB.Exec("CREATE TRIGGER reject_snapshot_update BEFORE UPDATE ON purchase_failure_snapshot BEGIN SELECT RAISE(FAIL, 'synthetic'); END")
|
||||
if _, err := uploadSnapshot(t, s, f, m, archive); err == nil {
|
||||
t.Fatal("synthetic write failure did not happen")
|
||||
}
|
||||
s.DB.Exec("DROP TRIGGER reject_snapshot_update")
|
||||
if _, err := uploadSnapshot(t, s, f, m, archive); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
detail, err := s.AdminDetail(context.Background(), a.TaskID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw, _ := json.Marshal(detail)
|
||||
if bytes.Contains(raw, []byte("PRIVATE_")) || bytes.Contains(raw, []byte("manifest")) || strings.Contains(logs.String(), "PRIVATE_") {
|
||||
t.Fatal("private data leaked")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFailureSnapshotZIPRejectsDuplicateCorruptionAndEmptyArchive(t *testing.T) {
|
||||
_, _, _, base := snapshotFixture(t)
|
||||
m := capturedMetadata(base)
|
||||
raw, _ := json.Marshal(m)
|
||||
var b bytes.Buffer
|
||||
w := zip.NewWriter(&b)
|
||||
for _, name := range []string{"manifest.json", "window-1.xml", "window-1.xml"} {
|
||||
p, _ := w.Create(name)
|
||||
if name == "manifest.json" {
|
||||
p.Write(raw)
|
||||
} else {
|
||||
p.Write([]byte("<hierarchy/>"))
|
||||
}
|
||||
}
|
||||
w.Close()
|
||||
if _, err := validateFailureArchive(m, raw, b.Bytes()); err == nil {
|
||||
t.Fatal("duplicate entry accepted")
|
||||
}
|
||||
if _, err := validateFailureArchive(m, raw, nil); err == nil {
|
||||
t.Fatal("empty captured archive accepted")
|
||||
}
|
||||
bad := snapshotZIP(t, m, `<hierarchy/>`, nil)
|
||||
bad[len(bad)/2] ^= 0xff
|
||||
if _, err := validateFailureArchive(m, raw, bad); err == nil {
|
||||
t.Fatal("corruption accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFailureSnapshotPasswordVariantsAndReversibleAttributes(t *testing.T) {
|
||||
for _, name := range []string{"text", "content-desc", "hint", "state-description", "tooltip"} {
|
||||
for _, suffix := range []string{"", "-state", "-base64"} {
|
||||
value := "private"
|
||||
if suffix == "-state" {
|
||||
value = "null"
|
||||
}
|
||||
if suffix == "-base64" {
|
||||
value = "AGE="
|
||||
}
|
||||
raw := []byte(`<hierarchy><node password="true" ` + name + suffix + `="` + value + `"/></hierarchy>`)
|
||||
if validateFailureXML(raw) == nil {
|
||||
t.Fatalf("password %s leaked", name+suffix)
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, raw := range []string{`<hierarchy><node text-base64="2AA=" password="false" hint-state="unsupported"/></hierarchy>`, `<hierarchy><node password="true" path="0" child-count="0"><action id="1"/></node></hierarchy>`, `<hierarchy><node password="false" text="line two	&😀" collection-row-count="2" range-current="1.5"/></hierarchy>`} {
|
||||
if err := validateFailureXML([]byte(raw)); err != nil {
|
||||
t.Fatalf("valid XML rejected: %v", err)
|
||||
}
|
||||
}
|
||||
for _, raw := range []string{`<hierarchy><node text="x" text-base64="AGE="/></hierarchy>`, `<hierarchy><node hint-state="invalid"/></hierarchy>`, `<hierarchy><node text-base64="YQ=="/></hierarchy>`} {
|
||||
if validateFailureXML([]byte(raw)) == nil {
|
||||
t.Fatal("ambiguous/invalid attribute accepted")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFailureSnapshotUnreadablePasswordFlagFailsClosed(t *testing.T) {
|
||||
for _, raw := range []string{`<hierarchy><node password-state="read_error" text="secret"/></hierarchy>`, `<hierarchy><node password-base64="AHQAcgB1AGU=" text="secret"/></hierarchy>`, `<hierarchy><node password="maybe" text="secret"/></hierarchy>`} {
|
||||
if validateFailureXML([]byte(raw)) == nil {
|
||||
t.Error("unreliable password flag accepted with content")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFailureSnapshotRejectsPendingAttemptAndContradictoryWindow(t *testing.T) {
|
||||
s, f, a, m := snapshotFixture(t)
|
||||
s.DB.Model(&a).Update("status", "pending")
|
||||
if _, err := uploadSnapshot(t, s, f, m, nil); err == nil {
|
||||
t.Error("pending attempt accepted")
|
||||
}
|
||||
m = capturedMetadata(m)
|
||||
m.Windows = append(m.Windows, json.RawMessage(`{"id":2,"fileName":null,"status":"partial","reasons":["window_root_null"]}`))
|
||||
if err := validateSnapshotMetadata(m, s.Now()); err == nil {
|
||||
t.Error("incomplete windows declared complete")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFailureSnapshotMetadataRequiresExplicitValidityAndNullCapture(t *testing.T) {
|
||||
_, _, _, m := snapshotFixture(t)
|
||||
m.RuleSnapshotHash = nil
|
||||
m.RuleSnapshotHashValid = false
|
||||
m.Reasons = []string{"rule_hash_invalid"}
|
||||
raw, _ := json.Marshal(m)
|
||||
for _, field := range []string{`"ruleSnapshotHashValid":false,`, `"capturedAt":null,`, `"ruleSnapshotHash":null,`} {
|
||||
missing := bytes.Replace(raw, []byte(field), nil, 1)
|
||||
if _, err := decodeSnapshotMetadata(missing); err == nil {
|
||||
t.Errorf("missing explicit field accepted: %s", field)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,7 @@ func InitRouter(engine *gin.Engine, auth *jwt.GinJWTMiddleware) {
|
||||
agent.POST("/:taskId/start", h.Start)
|
||||
agent.POST("/:taskId/order-submit-started", h.OrderSubmitStarted)
|
||||
agent.POST("/:taskId/result", h.Result)
|
||||
agent.POST("/:taskId/attempts/:attemptId/failure-snapshot", h.UploadFailureSnapshot)
|
||||
admin := engine.Group("/api/admin/v1/purchase-tasks").Use(auth.MiddlewareFunc()).Use(middleware.AuthCheckRole())
|
||||
admin.GET("", h.AdminList)
|
||||
admin.POST("/batch-preview", h.AdminBatchPreview)
|
||||
@@ -35,6 +36,8 @@ func InitRouter(engine *gin.Engine, auth *jwt.GinJWTMiddleware) {
|
||||
admin.POST("/syb-order-writeback", h.AdminOrderWriteback)
|
||||
admin.POST("/stock", h.AdminCreateStock)
|
||||
admin.GET("/:taskId", h.AdminDetail)
|
||||
admin.GET("/:taskId/failure-snapshots", h.FailureSnapshotSummaries)
|
||||
admin.GET("/:taskId/attempts/:attemptId/failure-snapshot/download", h.DownloadFailureSnapshot)
|
||||
admin.POST("", h.AdminCreate)
|
||||
admin.POST("/:taskId/spec-decision", h.SpecDecision)
|
||||
admin.GET("/:taskId/matching", h.MatchingDetail)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -129,6 +129,10 @@ func run() error {
|
||||
defer stopOfflineMonitors()
|
||||
for _, db := range sdk.Runtime.GetDb() {
|
||||
service := goautodevice.NewService(db)
|
||||
go goautopurchase.RunFailureSnapshotCleanup(
|
||||
offlineMonitorContext, goautopurchase.NewService(db), time.Hour,
|
||||
func(err error) { log.Error("purchase failure snapshot cleanup failed") },
|
||||
)
|
||||
go goautodevice.RunOfflineMonitor(
|
||||
offlineMonitorContext, service, goautodevice.DefaultOfflineScan, goautodevice.DefaultOfflineThreshold,
|
||||
func(err error) { log.Errorf("device offline monitor failed: %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")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
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), MigratePurchaseFailureSnapshot)
|
||||
}
|
||||
|
||||
// MigratePurchaseFailureSnapshot applies only the reviewed #364 table migration.
|
||||
func MigratePurchaseFailureSnapshot(db *gorm.DB, version string) error {
|
||||
return db.Transaction(func(tx *gorm.DB) error {
|
||||
if !tx.Migrator().HasTable(&models.PurchaseFailureSnapshot{}) {
|
||||
if err := tx.Migrator().CreateTable(&models.PurchaseFailureSnapshot{}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return tx.Where("version = ?", version).FirstOrCreate(&common.Migration{Version: version}).Error
|
||||
})
|
||||
}
|
||||
+93
@@ -0,0 +1,93 @@
|
||||
package version_local
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
drivermysql "github.com/go-sql-driver/mysql"
|
||||
"go-admin/app/goauto/access"
|
||||
"go-admin/app/goauto/models"
|
||||
"gorm.io/driver/mysql"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
)
|
||||
|
||||
// Explicit opt-in for the user-authorized newly initialized local database.
|
||||
// Never accepts a remote host and never starts a worker or creates an order.
|
||||
func TestFailureSnapshotAuthorizedLocalMySQL(t *testing.T) {
|
||||
dsn := os.Getenv("GOAUTO_364_LOCAL_MYSQL_DSN")
|
||||
if dsn == "" {
|
||||
t.Skip("local MySQL verification requires explicit opt-in")
|
||||
}
|
||||
cfg, err := drivermysql.ParseDSN(dsn)
|
||||
if err != nil || cfg.Net != "tcp" || cfg.Addr != "127.0.0.1:3308" || cfg.DBName != "goauto" {
|
||||
t.Fatal("unexpected local database target")
|
||||
}
|
||||
db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
|
||||
if err != nil {
|
||||
t.Fatal("local database unavailable")
|
||||
}
|
||||
sqlDB, _ := db.DB()
|
||||
defer sqlDB.Close()
|
||||
var orders int64
|
||||
if err := db.Model(&models.PurchaseTask{}).Count(&orders).Error; err != nil || orders != 0 {
|
||||
t.Fatal("requires newly initialized local database with no purchase tasks")
|
||||
}
|
||||
for i := 0; i < 2; i++ {
|
||||
if err := MigratePurchaseFailureSnapshot(db, "1791400000000"); err != nil {
|
||||
t.Fatal("local additive migration failed")
|
||||
}
|
||||
}
|
||||
if err := access.ReconcilePurchaserPermissions(db); err != nil {
|
||||
t.Fatal("local permission reconciliation failed")
|
||||
}
|
||||
var catalog, ordinary int64
|
||||
if err := db.Table("sys_api").Where("path LIKE ?", "%failure-snapshot%").Count(&catalog).Error; err != nil || catalog != 2 {
|
||||
t.Fatal("dedicated API catalogue is incomplete")
|
||||
}
|
||||
if err := db.Table("casbin_rule").Where("v1 LIKE ? AND v0 <> ?", "%failure-snapshot%", "admin").Count(&ordinary).Error; err != nil || ordinary != 0 {
|
||||
t.Fatal("ordinary roles received private diagnostic grants")
|
||||
}
|
||||
var columns []struct {
|
||||
Name string `gorm:"column:column_name"`
|
||||
Kind string `gorm:"column:data_type"`
|
||||
}
|
||||
if err := db.Raw("SELECT column_name,data_type FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='purchase_failure_snapshot'").Scan(&columns).Error; err != nil {
|
||||
t.Fatal("column inspection failed")
|
||||
}
|
||||
kinds := map[string]string{}
|
||||
for _, col := range columns {
|
||||
kinds[col.Name] = col.Kind
|
||||
}
|
||||
if kinds["manifest_json"] != "longtext" || kinds["zip_data"] != "longblob" {
|
||||
t.Fatal("private payload columns cannot hold the contract limit")
|
||||
}
|
||||
rollbackFixture := errors.New("rollback synthetic fixture")
|
||||
err = db.Transaction(func(tx *gorm.DB) error {
|
||||
row := models.PurchaseFailureSnapshot{TaskID: 1, AttemptID: "36400000-0000-4000-8000-000000000001", DeviceID: 1, Phase: "purchase", RuleSnapshotHash: strings.Repeat("a", 64), Status: "partial", Source: "execution", ErrorCode: "SYNTHETIC_TEST", AgentVersion: "test", RecordedAt: time.Now(), ExpiresAt: time.Now().Add(time.Hour), ReasonsJSON: "[]", ManifestJSON: strings.Repeat("x", 64*1024), ZIPData: bytes.Repeat([]byte{0x41}, 2*1024*1024), PayloadSHA256: strings.Repeat("b", 64), ZIPSize: 2 * 1024 * 1024}
|
||||
if e := tx.Create(&row).Error; e != nil {
|
||||
return errors.New("MySQL contract-limit insert failed")
|
||||
}
|
||||
var readback models.PurchaseFailureSnapshot
|
||||
if e := tx.First(&readback, row.ID).Error; e != nil || !bytes.Equal(readback.ZIPData, row.ZIPData) || readback.ManifestJSON != row.ManifestJSON {
|
||||
return errors.New("MySQL private payload readback failed")
|
||||
}
|
||||
duplicate := row
|
||||
duplicate.ID = 0
|
||||
if e := tx.Create(&duplicate).Error; e == nil {
|
||||
return errors.New("attempt uniqueness is missing")
|
||||
}
|
||||
return rollbackFixture
|
||||
})
|
||||
if !errors.Is(err, rollbackFixture) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var fixtures int64
|
||||
if err := db.Model(&models.PurchaseFailureSnapshot{}).Where("attempt_id=?", "36400000-0000-4000-8000-000000000001").Count(&fixtures).Error; err != nil || fixtures != 0 {
|
||||
t.Fatal("synthetic fixture was not rolled back")
|
||||
}
|
||||
}
|
||||
+65
@@ -0,0 +1,65 @@
|
||||
package version_local
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"go-admin/app/goauto/models"
|
||||
common "go-admin/common/models"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestMigratePurchaseFailureSnapshotPreservesBusinessAndIsIdempotent(t *testing.T) {
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
conn, _ := db.DB()
|
||||
defer conn.Close()
|
||||
if err = db.AutoMigrate(&common.Migration{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = db.Exec("CREATE TABLE purchase_task (id integer primary key, status text NOT NULL)").Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = db.Exec("INSERT INTO purchase_task(id,status) VALUES(1,'failed')").Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for i := 0; i < 2; i++ {
|
||||
if err = MigratePurchaseFailureSnapshot(db, "test_364"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
row := models.PurchaseFailureSnapshot{TaskID: 1, AttemptID: "00000000-0000-4000-8000-000000000001", DeviceID: 2, Phase: "purchase", RuleSnapshotHash: "hash", Status: "not_captured", Source: "execution", ErrorCode: "TEST", AgentVersion: "1", RecordedAt: time.Now(), ExpiresAt: time.Now().Add(time.Hour), ReasonsJSON: "[]", ManifestJSON: "{}", PayloadSHA256: "digest"}
|
||||
if err = db.Create(&row).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
duplicate := row
|
||||
duplicate.ID = 0
|
||||
if db.Create(&duplicate).Error == nil {
|
||||
t.Fatal("attempt unique index missing")
|
||||
}
|
||||
if err = MigratePurchaseFailureSnapshot(db, "test_364"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var status string
|
||||
db.Table("purchase_task").Select("status").Where("id=1").Scan(&status)
|
||||
if status != "failed" {
|
||||
t.Fatal("business row changed")
|
||||
}
|
||||
cols, err := db.Migrator().ColumnTypes("purchase_task")
|
||||
if err != nil || len(cols) != 2 {
|
||||
t.Fatal("business schema changed")
|
||||
}
|
||||
var count int64
|
||||
db.Model(&common.Migration{}).Where("version = ?", "test_364").Count(&count)
|
||||
if count != 1 {
|
||||
t.Fatal("version not idempotent")
|
||||
}
|
||||
raw, _ := json.Marshal(row)
|
||||
if string(raw) != "{}" {
|
||||
t.Fatal("private row can be serialized")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type snapshotBodySpy struct {
|
||||
reads int
|
||||
body io.Reader
|
||||
}
|
||||
|
||||
func (s *snapshotBodySpy) Read(p []byte) (int, error) { s.reads++; return s.body.Read(p) }
|
||||
func (s *snapshotBodySpy) Close() error { return nil }
|
||||
|
||||
func TestFailureSnapshotLoggerNeverReadsOrRecordsBody(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
for _, path := range []string{
|
||||
"/api/agent/v1/purchase-tasks/1/attempts/00000000-0000-4000-8000-000000000001/failure-snapshot",
|
||||
"/api/admin/v1/purchase-tasks/1/failure-snapshots",
|
||||
"/api/admin/v1/purchase-tasks/1/attempts/00000000-0000-4000-8000-000000000001/failure-snapshot/download",
|
||||
} {
|
||||
t.Run(path, func(t *testing.T) {
|
||||
spy := &snapshotBodySpy{body: strings.NewReader("SYNTHETIC_PRIVATE_CONTENT")}
|
||||
r := gin.New()
|
||||
r.Use(LoggerToFile())
|
||||
r.POST(path, func(c *gin.Context) {
|
||||
if spy.reads != 0 {
|
||||
t.Error("logger consumed private body before handler")
|
||||
}
|
||||
c.Status(http.StatusNoContent)
|
||||
})
|
||||
req := httptest.NewRequest(http.MethodPost, path, nil)
|
||||
req.Body = spy
|
||||
r.ServeHTTP(httptest.NewRecorder(), req)
|
||||
if spy.reads != 0 {
|
||||
t.Error("logger consumed private body")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -26,7 +26,7 @@ func LoggerToFile() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
// #237: client request/response bodies and one-time credentials must never
|
||||
// enter the legacy operation logger. The client gateway keeps metadata-only audit.
|
||||
if strings.HasPrefix(c.Request.URL.Path, "/api/client/") || strings.HasPrefix(c.Request.URL.Path, "/api/admin/v1/client-keys") {
|
||||
if strings.HasPrefix(c.Request.URL.Path, "/api/client/") || strings.HasPrefix(c.Request.URL.Path, "/api/admin/v1/client-keys") || isPrivateFailureSnapshotPath(c.Request.URL.Path) {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
@@ -111,6 +111,22 @@ func LoggerToFile() gin.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// #364: these dedicated endpoints carry private accessibility diagnostics.
|
||||
// Skip before reading the body; their handlers enforce authentication and limits.
|
||||
func isPrivateFailureSnapshotPath(path string) bool {
|
||||
parts := strings.Split(strings.Trim(path, "/"), "/")
|
||||
if len(parts) < 6 || parts[0] != "api" || parts[2] != "v1" || parts[3] != "purchase-tasks" {
|
||||
return false
|
||||
}
|
||||
if parts[1] == "admin" && len(parts) == 6 && parts[5] == "failure-snapshots" {
|
||||
return true
|
||||
}
|
||||
if len(parts) >= 8 && parts[5] == "attempts" && parts[7] == "failure-snapshot" {
|
||||
return (parts[1] == "agent" && len(parts) == 8) || (parts[1] == "admin" && len(parts) == 9 && parts[8] == "download")
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// SetDBOperLog 写入操作日志表 fixme 该方法后续即将弃用
|
||||
func SetDBOperLog(c *gin.Context, clientIP string, statusCode int, reqUri string, reqMethod string, latencyTime time.Duration, body string, result string, status int) {
|
||||
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import request from '@/utils/request'
|
||||
|
||||
export function listFailureSnapshots(taskId) {
|
||||
return request({ url: `/api/admin/v1/purchase-tasks/${encodeURIComponent(taskId)}/failure-snapshots`, method: 'get', suppressErrorMessage: true })
|
||||
}
|
||||
|
||||
export async function downloadFailureSnapshot(taskId, attemptId) {
|
||||
const response = await request({
|
||||
url: `/api/admin/v1/purchase-tasks/${encodeURIComponent(taskId)}/attempts/${encodeURIComponent(attemptId)}/failure-snapshot/download`,
|
||||
method: 'get', responseType: 'blob', suppressErrorMessage: true,
|
||||
// Adapt only this endpoint to the existing JSON-envelope interceptor.
|
||||
transformResponse: [data => ({ code: 200, data })]
|
||||
})
|
||||
const blob = response.data
|
||||
if (!(blob instanceof Blob) || blob.type.split(';')[0] !== 'application/zip') throw new Error('下载失败,请重试')
|
||||
const signature = new Uint8Array(await blob.slice(0, 4).arrayBuffer())
|
||||
if (signature.length !== 4 || signature[0] !== 80 || signature[1] !== 75 || signature[2] !== 3 || signature[3] !== 4) throw new Error('下载失败,请重试')
|
||||
return blob
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
<template>
|
||||
<div class="snapshot-cell">
|
||||
<span v-if="loading" class="muted">加载中…</span>
|
||||
<el-button v-else-if="error" type="primary" link @click="$emit('retry')">读取失败,重试</el-button>
|
||||
<template v-else-if="snapshot?.downloadAvailable">
|
||||
<div><el-tag size="small" :type="snapshot.partial ? 'warning' : 'success'">{{ snapshot.partial ? '部分' : '完整' }}</el-tag></div>
|
||||
<div v-if="snapshot.partial" class="muted">{{ reasonText }}</div>
|
||||
<el-button type="primary" link :loading="downloading" :disabled="downloading" @click="download">下载控件树</el-button>
|
||||
<div v-if="downloadError" role="alert" class="download-error">下载失败,请重试</div>
|
||||
</template>
|
||||
<span v-else-if="snapshot?.status === 'not_captured'" class="muted">未保存:{{ reasonText }}</span>
|
||||
<span v-else class="muted">暂无现场数据</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { downloadFailureSnapshot } from '@/api/goauto/purchase-failure-snapshots'
|
||||
|
||||
const reasons = {
|
||||
rule_hash_invalid: '规则哈希无效', execution_not_entered: '未进入设备执行', accessibility_unavailable: '无障碍不可用',
|
||||
process_interrupted: '进程中断,原现场未保存', windows_unavailable: '无法获取窗口', windows_unsupported: '系统不支持窗口读取',
|
||||
window_root_null: '窗口根节点为空', window_disappeared: '窗口已消失', window_read_error: '窗口读取失败',
|
||||
node_read_error: '节点读取失败', attribute_unsupported: '系统不支持部分属性', node_limit: '已达到节点上限',
|
||||
time_limit: '已达到截取耗时上限', expanded_size_limit: '已达到解压大小上限', archive_size_limit: '已达到压缩大小上限', capture_failed: '截取失败'
|
||||
}
|
||||
|
||||
export default {
|
||||
name: 'FailureSnapshotCell',
|
||||
props: { taskId: { type: [Number, String], required: true }, attemptId: { type: String, required: true }, snapshot: { type: Object, default: null }, loading: Boolean, error: Boolean },
|
||||
emits: ['retry'],
|
||||
data() { return { downloading: false, downloadError: false, active: true } },
|
||||
computed: { reasonText() { return [...new Set((this.snapshot?.reasons || []).map(reason => Object.hasOwn(reasons, reason) ? reasons[reason] : '未能截取现场'))].join(';') || '未能截取现场' } },
|
||||
beforeUnmount() { this.active = false },
|
||||
methods: {
|
||||
async download() {
|
||||
if (this.downloading || !this.snapshot?.downloadAvailable) return
|
||||
this.downloading = true; this.downloadError = false
|
||||
try {
|
||||
const blob = await downloadFailureSnapshot(this.taskId, this.attemptId)
|
||||
if (!this.active) return
|
||||
const url = URL.createObjectURL(blob)
|
||||
const anchor = document.createElement('a')
|
||||
anchor.href = url
|
||||
const safe = value => String(value).replace(/[^a-zA-Z0-9_-]/g, '_').slice(0, 80)
|
||||
anchor.download = `purchase-${safe(this.taskId)}-attempt-${safe(this.attemptId)}.zip`
|
||||
try { document.body.appendChild(anchor); anchor.click() } finally { anchor.remove(); setTimeout(() => URL.revokeObjectURL(url), 0) }
|
||||
} catch { if (this.active) this.downloadError = true } finally { if (this.active) this.downloading = false }
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.snapshot-cell{display:flex;flex-direction:column;align-items:flex-start;gap:4px}.muted{color:#909399;font-size:12px;line-height:1.5}.download-error{color:#f56c6c;font-size:12px}
|
||||
</style>
|
||||
@@ -73,6 +73,7 @@
|
||||
|
||||
<section><h3>执行记录</h3><el-table :data="detail.attempts" border size="small" empty-text="暂无执行记录">
|
||||
<el-table-column label="次数" width="70"><template #default="{ row }">#{{ row.attemptNumber }}</template></el-table-column><el-table-column label="阶段" prop="phase" min-width="120" /><el-table-column label="状态" prop="status" min-width="110" /><el-table-column label="结果" min-width="150"><template #default="{ row }">{{ row.resultType || row.errorCode || '—' }}</template></el-table-column><el-table-column label="说明" min-width="220"><template #default="{ row }">{{ row.errorMessage || '—' }}</template></el-table-column><el-table-column label="开始时间" width="170"><template #default="{ row }">{{ formatTime(row.startedAt || row.createdAt) }}</template></el-table-column>
|
||||
<el-table-column v-if="isSnapshotAdmin" label="现场诊断" width="200"><template #default="{ row }"><FailureSnapshotCell v-if="detail.open" :key="`${detailSession}-${row.attemptId}`" :task-id="detail.task.id" :attempt-id="row.attemptId" :snapshot="snapshots.items[row.attemptId]" :loading="snapshots.loading" :error="snapshots.error" @retry="loadSnapshots" /></template></el-table-column>
|
||||
</el-table></section>
|
||||
|
||||
<el-alert v-if="detail.task.errorCode" :title="`${detail.task.errorCode}:${detail.task.errorMessage || '任务执行失败'}`" type="error" :closable="false" show-icon class="notice" />
|
||||
@@ -153,6 +154,8 @@ import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { RefreshLeft, Search } from '@element-plus/icons-vue'
|
||||
import { authorizeRepurchase, cancelPurchaseTask, choosePurchaseMatching, getPurchaseTask, listPurchaseTasks, requeuePurchaseMatching, resolveUnknownPurchaseTask, retryPurchaseTasksBatch, reviewPurchasePayment, selectPurchaseWriteback, writebackPurchaseOrderNumbers } from '@/api/goauto/purchase-tasks'
|
||||
import { createRequestId } from '@/utils/request-id'
|
||||
import { listFailureSnapshots } from '@/api/goauto/purchase-failure-snapshots'
|
||||
import FailureSnapshotCell from './FailureSnapshotCell.vue'
|
||||
|
||||
const statusOptions = [
|
||||
['pending', '待执行'], ['spec_probe_pending', '待探测规格'], ['running', '执行中'], ['rehearsal_completed', '演练完成'],
|
||||
@@ -161,10 +164,12 @@ const statusOptions = [
|
||||
|
||||
export default {
|
||||
name: 'GoAutoPurchaseTasks',
|
||||
components: { FailureSnapshotCell },
|
||||
setup() { return { RefreshLeft, Search } },
|
||||
data() {
|
||||
return {
|
||||
loading: false, loadError: '', tasks: [], total: 0, statuses: [...statusOptions, { value: 'syb_writeback_succeeded', label: '已回填 SYB' }],
|
||||
detailSession: 0, snapshots: { items: {}, loading: false, error: false },
|
||||
orderWriteback: { open: false, saving: false, items: [] },
|
||||
query: { page: 1, pageSize: 20, taskId: '', taskType: '', shopeeOrderNo: '', status: '', executionMode: 'live', sybProductId: '', pddOrderNo: '' },
|
||||
detail: { open: false, loading: false, task: null, attempts: [], matching: null },
|
||||
@@ -178,6 +183,7 @@ export default {
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
isSnapshotAdmin() { return (this.$store.getters.roles || []).includes('admin') },
|
||||
orderFilter() { return parsePurchaseOrderFilter(this.query.shopeeOrderNo) },
|
||||
retryCandidates() { return this.retrySelection.filter(this.isRetrySelectable) },
|
||||
orderWritebackCandidates() { return this.retrySelection.filter(this.canOrderWriteback) },
|
||||
@@ -197,6 +203,12 @@ export default {
|
||||
canSubmitMatching() { const m = this.detail.matching || {}; return (!m.targetColor || this.matchingDialog.color) && (!m.targetSize || this.matchingDialog.size) },
|
||||
firstRetriedTask() { return this.retryResult.items.find(item => item.created && item.taskId) || null }
|
||||
},
|
||||
watch: {
|
||||
'detail.open'(open) { if (!open) this.clearSnapshots() },
|
||||
isSnapshotAdmin(admin) { if (!admin) this.clearSnapshots() }
|
||||
},
|
||||
beforeUnmount() { this.clearSnapshots() },
|
||||
deactivated() { this.detail.open = false; this.clearSnapshots() },
|
||||
created() {
|
||||
const taskId = Number(this.$route.query.taskId)
|
||||
if (Number.isInteger(taskId) && taskId > 0) {
|
||||
@@ -223,8 +235,20 @@ export default {
|
||||
},
|
||||
search() { this.query.page = 1; this.load() },
|
||||
reset() { this.query = { page: 1, pageSize: 20, taskId: '', taskType: '', shopeeOrderNo: '', status: '', executionMode: 'live', sybProductId: '', pddOrderNo: '' }; this.load() },
|
||||
async openDetail(row) { this.detail = { open: true, loading: true, task: null, attempts: [], matching: null }; try { await this.refreshDetail(row.id) } finally { this.detail.loading = false } },
|
||||
async refreshDetail(id = this.detail.task?.id) { const r = await getPurchaseTask(id); this.detail.task = r.data.task; this.detail.attempts = r.data.attempts || []; this.detail.matching = r.data.matching || r.data.task?.matching || null },
|
||||
clearSnapshots() { this.detailSession++; this.snapshots = { items: {}, loading: false, error: false } },
|
||||
async loadSnapshots() {
|
||||
if (!this.isSnapshotAdmin || !this.detail.open || !this.detail.task || this.snapshots.loading) return
|
||||
const session = this.detailSession
|
||||
this.snapshots = { items: {}, loading: true, error: false }
|
||||
const state = this.snapshots
|
||||
try {
|
||||
const r = await listFailureSnapshots(this.detail.task.id)
|
||||
if (session !== this.detailSession || !this.detail.open || !this.isSnapshotAdmin) return
|
||||
state.items = Object.fromEntries((r.data.items || []).map(item => [item.attemptId, item]))
|
||||
} catch { if (session === this.detailSession) state.error = true } finally { if (session === this.detailSession) state.loading = false }
|
||||
},
|
||||
async openDetail(row) { this.clearSnapshots(); const session = this.detailSession; this.detail = { open: true, loading: true, task: null, attempts: [], matching: null }; try { await this.refreshDetail(row.id) } finally { if (session === this.detailSession) this.detail.loading = false } },
|
||||
async refreshDetail(id = this.detail.task?.id) { const session = this.detailSession; const r = await getPurchaseTask(id); if (session !== this.detailSession || !this.detail.open) return; this.detail.task = r.data.task; this.detail.attempts = r.data.attempts || []; this.detail.matching = r.data.matching || r.data.task?.matching || null; this.loadSnapshots() },
|
||||
statusLabel(value) { return statusOptions.find(item => item.value === value)?.label || value || '—' },
|
||||
statusType(value) { return { pending: 'info', spec_probe_pending: 'warning', running: 'warning', rehearsal_completed: 'success', order_submit_started: 'warning', order_created: 'success', order_result_unknown: 'danger', failed: 'danger', cancelled: 'info' }[value] || 'info' },
|
||||
matchingLabel(value) { return { pending: '等待匹配', running: '匹配中', retry_wait: '等待重试', matched: '已匹配', manual_required: '需要人工处理', cancelled: '输入已失效', unresolved: '执行时探测' }[value] || '未记录' },
|
||||
|
||||
@@ -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,108 @@
|
||||
import { expect, test } from '@playwright/test'
|
||||
|
||||
const menu = [{ path: '/collection-purchase', component: 'Layout', menuName: 'GoAutoCollectionPurchase', title: '采集采购', visible: '0', children: [{ path: '/purchase-tasks/index', component: '/goauto/purchase-tasks/index', menuName: 'GoAutoPurchaseTasks', title: '采购管理', visible: '0' }] }]
|
||||
async function setup(page: any, roles = ['admin']) {
|
||||
const state = { reads: 0, downloads: 0, failRead: false, failDownload: false, delay: 0, downloadDelay: 0 }
|
||||
await page.context().addCookies([{ name: 'Admin-Token', value: 'synthetic-test-token', domain: 'localhost', path: '/' }])
|
||||
await page.route('**/api/**', async route => {
|
||||
const path = new URL(route.request().url()).pathname
|
||||
if (path.startsWith('/src/api/')) return route.continue()
|
||||
const ok = data => route.fulfill({ json: { code: 200, data } })
|
||||
if (path.endsWith('/getinfo')) return ok({ roles, name: '测试用户', avatar: '', permissions: [] })
|
||||
if (path.endsWith('/menurole')) return ok(menu)
|
||||
if (path.endsWith('/failure-snapshot/download')) {
|
||||
state.downloads++
|
||||
if (state.downloadDelay) await new Promise(resolve => setTimeout(resolve, state.downloadDelay))
|
||||
if (state.failDownload) return route.fulfill({ json: { code: 403, msg: 'synthetic-private-error' } })
|
||||
return route.fulfill({ contentType: 'application/zip', body: Buffer.from([80, 75, 3, 4, 0, 0]) })
|
||||
}
|
||||
if (path.endsWith('/failure-snapshots')) {
|
||||
state.reads++
|
||||
const delayed = state.delay
|
||||
if (delayed) await new Promise(resolve => setTimeout(resolve, delayed))
|
||||
if (state.failRead) return route.fulfill({ status: 500, json: { code: 500 } })
|
||||
if (path.includes('/12/')) return ok({ items: [] })
|
||||
return ok({ items: [
|
||||
{ attemptId: 'a-1', phase: 'purchase', status: 'captured', partial: false, reasons: [], downloadAvailable: true },
|
||||
{ attemptId: 'a-2', phase: 'purchase', status: 'captured', partial: true, reasons: ['node_limit'], downloadAvailable: true },
|
||||
{ attemptId: 'a-3', phase: 'purchase', status: 'not_captured', partial: false, reasons: ['synthetic-private-reason'], downloadAvailable: false }
|
||||
] })
|
||||
}
|
||||
const tasks = [11, 12].map(id => ({ id, status: 'failed', executionMode: 'live', taskType: 'stock', errorCode: 'TEST_FAILURE' }))
|
||||
if (/purchase-tasks\/\d+$/.test(path)) return ok({ task: tasks.find(t => path.endsWith('/' + t.id)), attempts: [1, 2, 3, 4].map(n => ({ attemptId: 'a-' + n, attemptNumber: n, phase: 'purchase', status: 'failed' })) })
|
||||
if (path.endsWith('/purchase-tasks')) return ok({ items: tasks, total: 2 })
|
||||
return ok([])
|
||||
})
|
||||
await page.goto('/#/purchase-tasks/index')
|
||||
await page.getByRole('button', { name: '详情', exact: true }).first().click()
|
||||
return state
|
||||
}
|
||||
|
||||
test('管理员现场列显示完整、部分、未截取和空状态,并安全下载与重试', async ({ page }) => {
|
||||
const state = await setup(page)
|
||||
await expect(page.getByText('现场诊断', { exact: true })).toBeVisible()
|
||||
const buttons = page.getByRole('button', { name: '下载控件树', exact: true })
|
||||
await expect(buttons).toHaveCount(2)
|
||||
await expect(page.getByText('完整', { exact: true })).toBeVisible()
|
||||
await expect(page.getByText('部分', { exact: true })).toBeVisible()
|
||||
await expect(page.getByText('已达到节点上限')).toBeVisible()
|
||||
await expect(page.getByText('未保存:未能截取现场')).toBeVisible()
|
||||
await expect(page.getByText('暂无现场数据')).toBeVisible()
|
||||
state.failDownload = true
|
||||
const downloads: string[] = []
|
||||
page.on('download', d => downloads.push(d.suggestedFilename()))
|
||||
await buttons.first().click()
|
||||
await expect(page.getByText('下载失败,请重试')).toBeVisible()
|
||||
expect(downloads).toEqual([])
|
||||
await expect(page.getByText('synthetic-private-error')).toHaveCount(0)
|
||||
state.failDownload = false
|
||||
const download = page.waitForEvent('download')
|
||||
await buttons.first().click()
|
||||
expect((await download).suggestedFilename()).toBe('purchase-11-attempt-a-1.zip')
|
||||
})
|
||||
|
||||
for (const roles of [['purchaser'], ['after_sales'], ['api_key']]) {
|
||||
test(`非管理员 ${roles[0]} 不显示诊断且不请求专用接口`, async ({ page }) => {
|
||||
const state = await setup(page, roles)
|
||||
await expect(page.getByText('执行记录', { exact: true })).toBeVisible()
|
||||
await expect(page.getByText('现场诊断', { exact: true })).toHaveCount(0)
|
||||
expect(state.reads).toBe(0)
|
||||
expect(state.downloads).toBe(0)
|
||||
})
|
||||
}
|
||||
|
||||
test('元信息失败可重试;关闭再切换任务丢弃旧响应', async ({ page }) => {
|
||||
const state = await setup(page)
|
||||
await expect(page.getByRole('button', { name: '下载控件树', exact: true })).toHaveCount(2)
|
||||
await page.locator('.el-drawer__close-btn').click()
|
||||
state.failRead = true
|
||||
await page.getByRole('button', { name: '详情', exact: true }).first().click()
|
||||
await expect(page.getByRole('button', { name: '读取失败,重试' }).first()).toBeVisible()
|
||||
state.failRead = false
|
||||
await page.getByRole('button', { name: '读取失败,重试' }).first().click()
|
||||
await expect(page.getByRole('button', { name: '下载控件树', exact: true })).toHaveCount(2)
|
||||
await page.locator('.el-drawer__close-btn').click()
|
||||
state.delay = 1200
|
||||
await page.getByRole('button', { name: '详情', exact: true }).first().click()
|
||||
await expect(page.getByText('加载中…').first()).toBeVisible()
|
||||
await page.locator('.el-drawer__close-btn').click()
|
||||
state.delay = 0
|
||||
await page.getByRole('button', { name: '详情', exact: true }).nth(1).click()
|
||||
await expect(page.getByText('暂无现场数据')).toHaveCount(4)
|
||||
await page.waitForTimeout(1400)
|
||||
await expect(page.getByRole('button', { name: '下载控件树', exact: true })).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('下载期间禁用按钮,离开详情后不触发旧下载', async ({ page }) => {
|
||||
const state = await setup(page)
|
||||
state.downloadDelay = 1500
|
||||
const downloads: string[] = []
|
||||
page.on('download', d => downloads.push(d.suggestedFilename()))
|
||||
const button = page.getByRole('button', { name: '下载控件树', exact: true }).first()
|
||||
await button.click()
|
||||
await expect(button).toBeDisabled()
|
||||
expect(state.downloads).toBe(1)
|
||||
await page.locator('.el-drawer__close-btn').click()
|
||||
await page.waitForTimeout(1800)
|
||||
expect(downloads).toEqual([])
|
||||
})
|
||||
@@ -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