Compare commits

...
Author SHA1 Message Date
QiuSW 92aeab8d04 docs: record verified navigation readiness contract #373 2026-10-09 15:26:55 +08:00
QiuSW c9b7a2dc0d docs: close out previously published integration wiki mirrors (#365 #367 #368 #370) 2026-10-09 15:21:03 +08:00
QiuSW dbf9a5f460 fix(android): gate probe handoff and wait for navigation readiness #373 2026-10-09 15:14:50 +08:00
QiuSW 8f07a19512 docs: mirror navigation diagnostic operations (#373) 2026-10-09 14:51:00 +08:00
QiuSW 2c58d5817c feat(android): add bounded navigation diagnostics without purchase actions (#373) 2026-10-09 14:44:47 +08:00
QiuSW e26743c817 merge: retry last purchase batch on device (#367)
# Conflicts:
#	docs/02-architecture-and-code-map.md
#	docs/03-business-rules-and-glossary.md
2026-10-09 11:32:04 +08:00
QiuSW e5feab8d6b merge: allow eligible partial image-search results (#368) 2026-10-09 11:31:46 +08:00
QiuSW 0ec8497672 merge: color discovery diagnostic stage (#370) 2026-10-09 10:15:18 +08:00
QiuSW 954552ed11 docs: document color discovery diagnostic stage (#370) 2026-10-09 10:09:24 +08:00
QiuSW 9e5f09abc5 feat(android): record bounded color discovery diagnostics (#370) 2026-10-09 10:03:42 +08:00
QiuSW 136765dbb0 docs: record Android last-batch repurchase behavior (#367) 2026-10-08 18:15:53 +08:00
QiuSW 5430dc1f64 feat(android): repurchase last failed batch in place (#367) 2026-10-08 18:12:40 +08:00
QiuSW b5a781b714 fix(android): require server clock for repurchase cutoff (#367) 2026-10-08 17:53:29 +08:00
QiuSW 87192f75d4 feat(android): discover last repurchase batch safely (#367) 2026-10-08 17:49:02 +08:00
QiuSW fcd6426f80 docs: record partial image-search linking rules (#368) 2026-10-08 16:56:15 +08:00
QiuSW 15ccab3960 fix(task): auto-link optional-metadata partial image searches (#368) 2026-10-08 16:54:48 +08:00
QiuSW e450ac2898 merge: purchase failure diagnostics and readiness fixes (#364 #365 #366) 2026-10-08 15:17:34 +08:00
QiuSW 0c24c3897a docs: record private failure diagnostics contracts and operations (#364) 2026-10-08 15:13:36 +08:00
QiuSW 8c4940dc43 merge: integrate purchase diagnostics and readiness fixes (#364 #365 #366) 2026-10-08 15:05:33 +08:00
QiuSW dac66b8384 merge: refresh main before diagnostics integration (#364) 2026-10-08 15:05:33 +08:00
QiuSW 4581ee5687 feat: capture private purchase failure diagnostics end to end (#364) 2026-10-08 15:05:32 +08:00
QiuSWandClaude Opus 5.5 5f459329ed merge: require Chinese replies in AGENTS rules
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
2026-10-08 14:27:40 +08:00
QiuSWandClaude Opus 5.5 2cb8e1ad86 docs: require Chinese replies in AGENTS rules
The user may write in Chinese or English; agents must reply in Chinese
and keep technical English terms, identifiers, commands, paths, API
fields and error codes untranslated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
2026-10-08 14:27:37 +08:00
QiuSW d2083e71fb test(android): verify address and order observation integration (#365, #366) 2026-10-08 11:49:58 +08:00
QiuSW 5206a6fd7d merge: integrate Android fixes for validation (#365, #366) 2026-10-08 11:48:00 +08:00
QiuSW c55455d35a docs: record bounded post-payment observation (#365) 2026-10-08 11:28:44 +08:00
QiuSW 37714d39b5 fix(android): extend passive order observation after payment back (#365) 2026-10-08 11:20:42 +08:00
QiuSW d1996d28ee docs: document address hint readiness guard (#366) 2026-10-08 11:02:14 +08:00
QiuSW 7fdfe04658 fix(android): reject address editor hint values (#366) 2026-10-08 10:57:38 +08:00
QiuSW 53143131be docs(#366): document initial editor readiness diagnostics 2026-10-08 10:39:51 +08:00
QiuSW 650f3a6ba1 fix(android): wait for unique ready address editor (#366) 2026-10-08 10:34:57 +08:00
QiuSW 7622795d97 docs(#359): record production migration and verified rollout 2026-10-08 09:44:03 +08:00
QiuSWandClaude Opus 5.5 4ae33d8827 merge: allow failure-time accessibility trees in AGENTS rules
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
2026-10-08 09:05:57 +08:00
QiuSWandClaude Opus 5.5 8ac3f2273f docs: allow saving failure-time accessibility trees for debugging
User confirmed on 2026-10-07 that GoAuto is an internal system and raw
accessibility trees are needed to debug failures such as unresolved
order results. Allow saving them only at failure/unknown moments, in the
device diagnostic DB and server DB with admin-only download, with
30-day automatic cleanup. Screenshots, payment and OCR/VLM limits are
unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
2026-10-07 17:59:31 +08:00
QiuSW 0138da8560 merge: resume bounded Shopee auto-match scans (#359) 2026-10-07 16:11:47 +08:00
QiuSW a82a1469a6 docs(#359): sync bounded auto-match scan contracts and verification limits 2026-10-07 16:09:17 +08:00
QiuSW 9fcbc64117 fix: accept verified no-op auto-match lease renewals (#359) 2026-10-07 16:05:22 +08:00
QiuSW 151c426e54 test: isolate repeated auto-match scan regressions (#359) 2026-10-07 16:01:31 +08:00
QiuSW 95847a3f2d fix: resume bounded Shopee auto-match scans safely (#359) 2026-10-07 15:55:23 +08:00
QiuSW 6781b309d4 docs(#363): record production Web rollout and verification 2026-10-07 11:29:02 +08:00
QiuSW eb7cb6b5eb merge: default inner code list to 200 per page (#363) 2026-10-07 11:19:56 +08:00
QiuSW 845853c68a test(#363): verify page-local pagination and document default 2026-10-07 11:15:35 +08:00
QiuSW 955869b5f5 fix(#363): default inner code page size to 200 2026-10-07 11:09:26 +08:00
QiuSW 49de6f8045 merge: Android spec probe safety and truncated card handling (#361, #362) 2026-10-07 10:00:02 +08:00
QiuSW 279cb55847 docs: bind installed integrated Agent 0.9.69 (#362) 2026-10-06 18:03:31 +08:00
QiuSW 35119ea261 merge: retain #361 in #362 Android 0.9.69 installation build 2026-10-06 17:59:10 +08:00
QiuSW cb00194b0f docs: record independent spec card boundary (#362) 2026-10-06 17:57:41 +08:00
QiuSW 46b92dd6c5 docs: sync spec card dedup and prerequisite Wiki facts (#362) 2026-10-06 17:55:45 +08:00
QiuSW 1f5bab8192 fix(android): preserve independent cards under labelled groups (#362) 2026-10-06 17:54:50 +08:00
QiuSW 9a4495addd fix(android): deduplicate truncated same-card spec captions (#362) 2026-10-06 17:49:11 +08:00
QiuSW 75ab25658d docs: document spec probe diagnostics and safe verification (#361) 2026-10-06 17:19:19 +08:00
QiuSW a49dc696e9 fix(android): guard spec probe clicks and persist attempt diagnostics (#361) 2026-10-06 17:15:30 +08:00
QiuSWandClaude Opus 5.5 64f0e4941f merge: AGENTS worktree cleanup and SynapBus messaging rules
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 14:36:21 +08:00
99 changed files with 9853 additions and 373 deletions
+7 -1
View File
@@ -27,7 +27,12 @@
- 不把密码、Token、Cookie、私钥、PDD 账号凭据、个人数据或生产数据写入代码、日志、工单和文档。例外:经用户于 2026-08-21 明确确认的 #62 内部 AI Provider API Key,可明文保存在专用 `ai_matching_setting` 数据表,并只返回给管理员用于下次查看和替换;它仍不得出现在代码、日志、工单、Wiki、任务快照、采购员接口或 Android 接口中。 - 不把密码、Token、Cookie、私钥、PDD 账号凭据、个人数据或生产数据写入代码、日志、工单和文档。例外:经用户于 2026-08-21 明确确认的 #62 内部 AI Provider API Key,可明文保存在专用 `ai_matching_setting` 数据表,并只返回给管理员用于下次查看和替换;它仍不得出现在代码、日志、工单、Wiki、任务快照、采购员接口或 Android 接口中。
- 不执行付款。当前项目不实现任何自动支付动作、入口或测试;后续如需实现,必须单独建单评估,并至少具备显式能力位、服务端开关、单笔金额上限与人工授权四项控制。支付、下单和订单相关文字允许作为只读识别信号出现在采集与采购规则中,用于判断页面形态;任何规则都不得把它们配置为点击目标。 - 不执行付款。当前项目不实现任何自动支付动作、入口或测试;后续如需实现,必须单独建单评估,并至少具备显式能力位、服务端开关、单笔金额上限与人工授权四项控制。支付、下单和订单相关文字允许作为只读识别信号出现在采集与采购规则中,用于判断页面形态;任何规则都不得把它们配置为点击目标。
- 当前采集 MVP 只实现 PDD 商品、规则、任务、Android 执行和任务详情。采购是独立的后续高风险 MVP,未通过对应原型和工单门禁前不能混入采集代码;采集规则可以描述订单确认面板的只读特征,这不构成采购代码混入采集。 - 当前采集 MVP 只实现 PDD 商品、规则、任务、Android 执行和任务详情。采购是独立的后续高风险 MVP,未通过对应原型和工单门禁前不能混入采集代码;采集规则可以描述订单确认面板的只读特征,这不构成采购代码混入采集。
- 不保存原始控件树和整屏设备截图;允许保存按规格值节点边界裁剪的商品图片区域,且裁剪结果不得包含账号、地址、订单、支付及其他个人数据。除此之外只保存结构化任务日志、错误码、任务规则快照和采集结果。 - 不保存整屏设备截图;允许保存按规格值节点边界裁剪的商品图片区域,且裁剪结果不得包含账号、地址、订单、支付及其他个人数据。除此之外只保存结构化任务日志、错误码、任务规则快照、采集结果和下一条的失败现场控件树。
- 失败现场控件树(用户于 2026-10-07 确认,GoAuto 为内部系统):Android Agent 在任务失败或结果待核对时,可以把当时的原始无障碍控件树保存到本机 SQLite 并上传服务端,供管理员下载调试。限制如下:
- 只在失败、结果待核对等需要排查的时刻保存,不在正常执行中逐步采集;每次与 `taskId`、`deviceId`、尝试次数和错误码关联。
- 控件树会包含收件人、电话、地址、订单号等个人数据:只允许保存在本机诊断库和服务端数据库,只对管理员开放下载;不得写入代码、普通日志、工单、Wiki、任务快照、SynapBus 消息或采购员、Android 普通接口。分析时只摘录定位问题必需的片段,并去掉个人数据。
- 密码输入框内容不得保存;本条不放宽付款、整屏截图及 OCR/VLM 的限制。
- 本机和服务端都必须自动清理过期数据,默认保留 30 天。
- 一台设备同一时刻只执行一个任务;手机离线时当前采集任务失败,默认不重试、不自动换机。 - 一台设备同一时刻只执行一个任务;手机离线时当前采集任务失败,默认不重试、不自动换机。
- Android Agent 端:找不到控件、验证码、风控、人机验证或登录失效时明确失败,不使用 OCR/VLM。 - Android Agent 端:找不到控件、验证码、风控、人机验证或登录失效时明确失败,不使用 OCR/VLM。
- 服务端顺云宝(SYB)登录与 yeeke(mmt.yeeke.com 对裹)登录:允许调用配置的线上自建 OCR 服务识别登录验证码(SYB 见 #48;yeeke 于 2026-09-23 经用户在 #336 明确批准)。验证码图片会离开本项目发送到该服务,更换服务地址前必须重新评估;OCR 请求和日志不得记录账号、密码、验证码原文或 token。此例外只适用于上述两个服务端登录流程,不扩大到 Agent 端或任何 PDD 相关流程。 - 服务端顺云宝(SYB)登录与 yeeke(mmt.yeeke.com 对裹)登录:允许调用配置的线上自建 OCR 服务识别登录验证码(SYB 见 #48;yeeke 于 2026-09-23 经用户在 #336 明确批准)。验证码图片会离开本项目发送到该服务,更换服务地址前必须重新评估;OCR 请求和日志不得记录账号、密码、验证码原文或 token。此例外只适用于上述两个服务端登录流程,不扩大到 Agent 端或任何 PDD 相关流程。
@@ -39,6 +44,7 @@
- 保留与当前工单无关的工作区改动,不重置、不覆盖、不顺手修改。 - 保留与当前工单无关的工作区改动,不重置、不覆盖、不顺手修改。
- 测试结果必须真实;未执行或无法覆盖的真机、多设备、云环境和高风险行为必须明确记录。 - 测试结果必须真实;未执行或无法覆盖的真机、多设备、云环境和高风险行为必须明确记录。
- 高风险修改必须停止并等待人工确认:创建订单、权限、安全、并发、数据库迁移、删除数据、发布和其他不可逆操作。 - 高风险修改必须停止并等待人工确认:创建订单、权限、安全、并发、数据库迁移、删除数据、发布和其他不可逆操作。
- 回答语言:用户可以用中文或英文发言,Agent 一律用中文回答;技术英文词组、代码标识符、命令、文件路径、API 字段和错误码保持原文,不翻译。
## 2. 哪些改动需要工单 ## 2. 哪些改动需要工单
+5 -2
View File
@@ -11,8 +11,8 @@ android {
applicationId = "cn.ilapage.goauto.agent" applicationId = "cn.ilapage.goauto.agent"
minSdk = 23 minSdk = 23
targetSdk = 34 targetSdk = 34
versionCode = 77 versionCode = 82
versionName = "0.9.64" versionName = "0.9.69"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
@@ -30,6 +30,9 @@ android {
} }
buildTypes { buildTypes {
debug {
versionNameSuffix = "-373-fix1"
}
release { release {
isMinifyEnabled = false isMinifyEnabled = false
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
+4 -1
View File
@@ -1,4 +1,7 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application android:usesCleartextTraffic="true" /> <application android:usesCleartextTraffic="true">
<receiver android:name=".diagnostics.NavigationProbeReceiver"
android:exported="true" android:permission="android.permission.DUMP" />
</application>
</manifest> </manifest>
@@ -0,0 +1,9 @@
package cn.ilapage.goauto.agent.diagnostics
internal object BrowserProbeClickPolicy {
fun allowed(rootPackage: String?, nodePackage: String?, label: String, visible: Boolean,
enabled: Boolean, clickable: Boolean, rootWindow: Int, nodeWindow: Int): Boolean =
rootPackage in setOf("com.heytap.browser", "com.android.chrome", "com.android.browser") &&
nodePackage == rootPackage && label in setOf("打开拼多多APP", "打开拼多多 App") &&
visible && enabled && clickable && rootWindow >= 0 && nodeWindow == rootWindow
}
@@ -0,0 +1,94 @@
package cn.ilapage.goauto.agent.diagnostics
import cn.ilapage.goauto.agent.automation.*
import cn.ilapage.goauto.agent.automation.SnapshotNode as UiSnapshotNode
internal class NavigationProbe(
private val capture: () -> UiSnapshot,
private val launch: ((PddLaunchEvent) -> Unit) -> Boolean,
private val clickBrowserEntry: (UiSnapshotNode) -> FreshClickOutcome,
private val now: () -> Long,
private val pause: (Long) -> Unit,
private val emit: (String) -> Unit,
private val windowId: () -> Int? = { null },
private val isActive: () -> Boolean = { true },
) {
fun run() {
val started = now()
fun canAct() = isActive() && !Thread.currentThread().isInterrupted && now() - started < 20_000
if (!canAct()) { emit("stopped=inactive_or_expired"); return }
val before = capture()
if (!canAct()) { emit("stopped=inactive_or_expired"); return }
fun problem(page: UiSnapshot) = PddPageClassifier.classify(page.packageName, page.activityName,
page.nodes.filter { it.visible }.map { it.label }) != null
if (problem(before)) { emit("stopped=page_problem"); return }
val initial = PddScreenParser.parse(before, PurchaseRehearsalExecutor.DEFAULT_COLLECTOR, "", null)
// Values remain in memory. Neither text nor its hash is logged/persisted.
val oldTitle = initial.summary.title
val oldWindow = windowId()
val initiallyPdd = before.packageName == "com.xunmeng.pinduoduo"
emit("baseline;pdd=$initiallyPdd;root=${initial.rootAvailable};product=${initiallyPdd && initial.hasPurchaseProductEvidence()};title=${oldTitle != null};window=$oldWindow")
if (!canAct()) { emit("stopped=inactive_or_expired"); return }
if (!launch { event ->
emit("launch;t=${now() - started};path=${event.path};status=${event.status};exception=${event.exceptionClass ?: "none"}")
}) { emit("stopped=launch_failed"); return }
var stable = 0
var legacyAccepted = false
var browserClicks = 0
var nextBrowserClick = 0L
var polls = 0
var lastSignal: String? = null
while (canAct() && polls < 201) {
val readStarted = now()
val page = capture()
if (!canAct()) { emit("stopped=inactive_or_expired;t=${now() - started}"); return }
if (problem(page)) { emit("stopped=page_problem;t=${now() - started}"); return }
val screen = PddScreenParser.parse(page, PurchaseRehearsalExecutor.DEFAULT_COLLECTOR, "", null)
val product = page.packageName == "com.xunmeng.pinduoduo" && screen.hasPurchaseProductEvidence()
stable = if (product) stable + 1 else 0
val titleComparison = when {
oldTitle == null || screen.summary.title == null -> "unknown"
oldTitle == screen.summary.title -> "same"
else -> "different"
}
val category = when (page.packageName) {
"com.xunmeng.pinduoduo" -> "pdd"
in BROWSERS -> "browser"
null -> "none"
else -> "other"
}
val signal = "pkg=$category;root=${screen.rootAvailable};product=$product;panel=${screen.specPanelOpen};entry=${screen.specEntry != null};nodes=${page.nodes.size};title=$titleComparison;window=${windowId()}"
if (signal != lastSignal || polls % 10 == 0) {
emit("sample;t=${now() - started};readMs=${now() - readStarted};$signal")
lastSignal = signal
}
// Observation only: never treats this as proof of goods identity or calls the executor.
if (stable >= 2 && !legacyAccepted) {
legacyAccepted = true
emit("legacy_would_accept;t=${now() - started};title=$titleComparison;identityVerified=false")
}
if (category == "browser") {
val entries = page.nodes.filter { it.visible && it.enabled && it.label in OPEN_PDD_ALIASES }
if (entries.size > 1) { emit("stopped=browser_ambiguous"); return }
if (entries.size == 1 && browserClicks < 3 && now() >= nextBrowserClick) {
if (!canAct()) { emit("stopped=inactive_or_expired"); return }
val result = clickBrowserEntry(entries.single())
browserClicks++
emit("browser_click;t=${now() - started};attempt=$browserClicks;result=${result.result};reason=${result.reason}")
if (result.result == FreshActionResult.AMBIGUOUS || result.result == FreshActionResult.BLOCKED) {
emit("stopped=browser_click_unsafe"); return
}
nextBrowserClick = now() + 1_000
}
}
polls++
pause(100)
}
emit("finished;t=${now() - started};samples=$polls;legacyAccepted=$legacyAccepted;identityVerified=false")
}
companion object {
private val BROWSERS = setOf("com.heytap.browser", "com.android.chrome", "com.android.browser")
private val OPEN_PDD_ALIASES = setOf("打开拼多多APP", "打开拼多多 App")
}
}
@@ -0,0 +1,109 @@
package cn.ilapage.goauto.agent.diagnostics
import android.content.BroadcastReceiver
import android.content.Context
import android.content.ContextWrapper
import android.content.Intent
import android.os.SystemClock
import android.util.Log
import android.view.accessibility.AccessibilityNodeInfo
import cn.ilapage.goauto.agent.automation.GoAutoAccessibilityService
import cn.ilapage.goauto.agent.automation.PddLinkLauncher
import cn.ilapage.goauto.agent.automation.FreshClickOutcome
import cn.ilapage.goauto.agent.automation.FreshActionResult
import cn.ilapage.goauto.agent.automation.FreshClickReason
import cn.ilapage.goauto.agent.service.AgentForegroundService
import java.util.UUID
/** Debug APK only; manifest requires the shell/system DUMP permission. No arbitrary URL or action. */
class NavigationProbeReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
val goodsId = intent.getStringExtra("goodsId").orEmpty()
val mode = intent.getStringExtra("mode").orEmpty()
if (!goodsId.matches(Regex("[1-9][0-9]{0,19}")) || mode !in setOf("direct", "browser")) {
Log.i(TAG, "rejected=invalid_arguments")
return
}
val service = AgentForegroundService.diagnosticInstance
val accessibility = GoAutoAccessibilityService.instance
if (service == null || accessibility == null) {
Log.i(TAG, "rejected=service_unavailable")
return
}
val runId = UUID.randomUUID().toString()
val accepted = service.runNavigationProbe { serviceContext ->
// Delayed execution must not use a destroyed accessibility instance.
if (GoAutoAccessibilityService.instance !== accessibility) {
Log.i(TAG, "run=$runId;stopped=accessibility_changed")
return@runNavigationProbe
}
Log.i(TAG, "run=$runId;started;mode=$mode;synthetic=true;noBusinessTask=true")
val deadline = SystemClock.elapsedRealtime() + 20_000
val canAct = { AgentForegroundService.diagnosticInstance === service &&
GoAutoAccessibilityService.instance === accessibility && !Thread.currentThread().isInterrupted &&
SystemClock.elapsedRealtime() < deadline }
val guardedContext = object : ContextWrapper(serviceContext) {
override fun startActivity(intent: Intent) {
// Includes direct-failure fallback and package-manager Binder delays.
check(canAct()) { "probe_inactive" }
super.startActivity(intent)
}
}
NavigationProbe(
capture = {
check(canAct()) { "probe_inactive" }
accessibility.capture()
},
launch = { report -> canAct() && PddLinkLauncher(guardedContext).open(
"https://mobile.yangkeduo.com/goods.html?goods_id=$goodsId", mode == "direct", report,
) },
clickBrowserEntry = { safeBrowserClick(accessibility, canAct) },
now = SystemClock::elapsedRealtime,
pause = { Thread.sleep(it) },
emit = { Log.i(TAG, "run=$runId;$it") },
windowId = { accessibility.rootInActiveWindow?.let { root ->
try { root.windowId } finally { @Suppress("DEPRECATION") root.recycle() }
} },
isActive = canAct,
).run()
}
if (!accepted) Log.i(TAG, "run=$runId;rejected=device_busy")
}
/** Fresh native node only: no ancestors, gestures, coordinates, or PDD-package actions. */
private fun safeBrowserClick(service: GoAutoAccessibilityService, canAct: () -> Boolean): FreshClickOutcome {
fun blocked() = FreshClickOutcome(FreshActionResult.BLOCKED, FreshClickReason.TARGET_NOT_FOUND)
if (!canAct()) return blocked()
val root = service.rootInActiveWindow ?: return blocked()
val nodes = mutableListOf<AccessibilityNodeInfo>()
fun visit(node: AccessibilityNodeInfo) {
nodes += node
if (nodes.size >= 2_000 || !canAct()) return
for (i in 0 until node.childCount) {
if (nodes.size >= 2_000 || !canAct()) break
node.getChild(i)?.let(::visit)
}
}
try {
visit(root)
if (nodes.size >= 2_000 || !canAct()) return blocked()
val candidates = nodes.filter { node ->
val label = node.text?.toString()?.trim().takeUnless { it.isNullOrEmpty() }
?: node.contentDescription?.toString()?.trim().orEmpty()
BrowserProbeClickPolicy.allowed(root.packageName?.toString(), node.packageName?.toString(), label,
node.isVisibleToUser, node.isEnabled, node.isClickable, root.windowId, node.windowId)
}
if (candidates.size != 1) return blocked()
val current = service.rootInActiveWindow ?: return blocked()
val sameWindow = try { current.windowId == root.windowId && current.packageName == root.packageName }
finally { @Suppress("DEPRECATION") current.recycle() }
if (!sameWindow || !canAct()) return blocked()
val clicked = candidates.single().performAction(AccessibilityNodeInfo.ACTION_CLICK)
return FreshClickOutcome(if (clicked) FreshActionResult.SUCCESS else FreshActionResult.FAILED,
if (clicked) FreshClickReason.SUCCESS else FreshClickReason.ACTION_CLICK_FALSE)
} finally {
nodes.forEach { @Suppress("DEPRECATION") it.recycle() }
}
}
private companion object { const val TAG = "GoAutoNavProbe" }
}
@@ -339,6 +339,7 @@ class CurrentPageIdentityRunner(
when (outcome.result) { when (outcome.result) {
FreshActionResult.SUCCESS -> return ShareClickResult(true, "") FreshActionResult.SUCCESS -> return ShareClickResult(true, "")
FreshActionResult.AMBIGUOUS -> return ShareClickResult(false, "商品分享入口不唯一") FreshActionResult.AMBIGUOUS -> return ShareClickResult(false, "商品分享入口不唯一")
FreshActionResult.BLOCKED -> return ShareClickResult(false, "已阻止点击尺码建议入口")
FreshActionResult.NOT_FOUND, FreshActionResult.FAILED -> Unit FreshActionResult.NOT_FOUND, FreshActionResult.FAILED -> Unit
} }
} }
@@ -366,6 +367,7 @@ class CurrentPageIdentityRunner(
when (outcome.result) { when (outcome.result) {
FreshActionResult.SUCCESS -> return ShareClickResult(true, "") FreshActionResult.SUCCESS -> return ShareClickResult(true, "")
FreshActionResult.AMBIGUOUS -> return ShareClickResult(false, "复制链接入口不唯一") FreshActionResult.AMBIGUOUS -> return ShareClickResult(false, "复制链接入口不唯一")
FreshActionResult.BLOCKED -> return ShareClickResult(false, "已阻止点击尺码建议入口")
FreshActionResult.NOT_FOUND, FreshActionResult.FAILED -> Unit FreshActionResult.NOT_FOUND, FreshActionResult.FAILED -> Unit
} }
} }
@@ -528,6 +530,7 @@ class CurrentPageIdentityRunner(
FreshClickReason.NO_CLICKABLE_ANCESTOR -> AgentDiagnosticReason.NO_CLICKABLE_ANCESTOR FreshClickReason.NO_CLICKABLE_ANCESTOR -> AgentDiagnosticReason.NO_CLICKABLE_ANCESTOR
FreshClickReason.ACTION_CLICK_FALSE -> AgentDiagnosticReason.ACTION_CLICK_FALSE FreshClickReason.ACTION_CLICK_FALSE -> AgentDiagnosticReason.ACTION_CLICK_FALSE
FreshClickReason.UNKNOWN -> AgentDiagnosticReason.UNKNOWN FreshClickReason.UNKNOWN -> AgentDiagnosticReason.UNKNOWN
FreshClickReason.SIZE_ADVICE_BLOCKED -> AgentDiagnosticReason.SIZE_ADVICE_CLICK_BLOCKED
} }
private data class SharePreparation( private data class SharePreparation(
@@ -122,6 +122,7 @@ class GoAutoAccessibilityService : AccessibilityService(), UiDriver, PddCollecto
).restore(packageName, timeoutMillis) ).restore(packageName, timeoutMillis)
fun openAgentPreservingTab(): Boolean = runCatching { fun openAgentPreservingTab(): Boolean = runCatching {
foregroundRevision.incrementAndGet() // Explicit navigation invalidates handoff even before its window event arrives.
dismissPurchaseResultBubble() dismissPurchaseResultBubble()
startActivity( startActivity(
android.content.Intent(this, cn.ilapage.goauto.agent.MainActivity::class.java).apply { android.content.Intent(this, cn.ilapage.goauto.agent.MainActivity::class.java).apply {
@@ -162,7 +163,7 @@ class GoAutoAccessibilityService : AccessibilityService(), UiDriver, PddCollecto
while (!target.isClickable) { while (!target.isClickable) {
target = target.parent ?: return false 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 { override fun input(node: UiNodeRef, value: String): Boolean {
@@ -212,7 +213,7 @@ class GoAutoAccessibilityService : AccessibilityService(), UiDriver, PddCollecto
if (!node.isClickable || !node.isEnabled || !node.isVisibleToUser || if (!node.isClickable || !node.isEnabled || !node.isVisibleToUser ||
NodeBounds(bounds.left, bounds.top, bounds.right, bounds.bottom) != fresh.bounds || 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 (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 { fun scrollBackfill(): Boolean {
@@ -248,6 +249,8 @@ class GoAutoAccessibilityService : AccessibilityService(), UiDriver, PddCollecto
checked = node.isChecked, checked = node.isChecked,
enabled = node.isEnabled, enabled = node.isEnabled,
visible = node.isVisibleToUser, visible = node.isVisibleToUser,
hintText = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) node.hintText?.toString() else null,
showingHintText = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) node.isShowingHintText else null,
) )
for (index in 0 until node.childCount) { for (index in 0 until node.childCount) {
node.getChild(index)?.let { snapshot(it, "$path/$index", path) } node.getChild(index)?.let { snapshot(it, "$path/$index", path) }
@@ -329,7 +332,25 @@ class GoAutoAccessibilityService : AccessibilityService(), UiDriver, PddCollecto
override fun clickFresh(target: SnapshotNode): FreshActionResult = clickFreshDetailed(target).result override fun clickFresh(target: SnapshotNode): FreshActionResult = clickFreshDetailed(target).result
override fun clickFreshDetailed(target: SnapshotNode): FreshClickOutcome { 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 = clickFreshDetailed(target) { true }
override fun clickNavigationFresh(target: SnapshotNode, allowed: () -> Boolean): FreshClickOutcome =
clickFreshDetailed(target) { node ->
val targetIsBrowser = PurchaseNavigationReadiness.isBrowser(node.packageName?.toString())
val foregroundIsBrowser = PurchaseNavigationReadiness.isBrowser(currentPackage())
targetIsBrowser && foregroundIsBrowser && allowed()
}
private fun clickFreshDetailed(target: SnapshotNode, beforeClick: (AccessibilityNodeInfo) -> Boolean): FreshClickOutcome {
val root = rootInActiveWindow ?: return FreshClickOutcome(FreshActionResult.NOT_FOUND, FreshClickReason.ROOT_UNAVAILABLE) val root = rootInActiveWindow ?: return FreshClickOutcome(FreshActionResult.NOT_FOUND, FreshClickReason.ROOT_UNAVAILABLE)
val candidates = mutableListOf<AccessibilityNodeInfo>() val candidates = mutableListOf<AccessibilityNodeInfo>()
walk(root) { node -> walk(root) { node ->
@@ -356,6 +377,10 @@ class GoAutoAccessibilityService : AccessibilityService(), UiDriver, PddCollecto
) )
ancestorDepth++ ancestorDepth++
} }
if (sizeAdviceBlocked(node)) return FreshClickOutcome(
FreshActionResult.BLOCKED, FreshClickReason.SIZE_ADVICE_BLOCKED, candidates.size, ancestorDepth,
)
if (!beforeClick(node)) return FreshClickOutcome(FreshActionResult.FAILED, FreshClickReason.UNKNOWN)
return if (node.performAction(AccessibilityNodeInfo.ACTION_CLICK)) { return if (node.performAction(AccessibilityNodeInfo.ACTION_CLICK)) {
FreshClickOutcome(FreshActionResult.SUCCESS, FreshClickReason.SUCCESS, candidates.size, ancestorDepth) FreshClickOutcome(FreshActionResult.SUCCESS, FreshClickReason.SUCCESS, candidates.size, ancestorDepth)
} else { } else {
@@ -392,6 +417,9 @@ class GoAutoAccessibilityService : AccessibilityService(), UiDriver, PddCollecto
) )
ancestorDepth++ ancestorDepth++
} }
if (sizeAdviceBlocked(node)) return FreshClickOutcome(
FreshActionResult.BLOCKED, FreshClickReason.SIZE_ADVICE_BLOCKED, 1, ancestorDepth,
)
return if (node.performAction(AccessibilityNodeInfo.ACTION_CLICK)) { return if (node.performAction(AccessibilityNodeInfo.ACTION_CLICK)) {
FreshClickOutcome(FreshActionResult.SUCCESS, FreshClickReason.SUCCESS, 1, ancestorDepth) FreshClickOutcome(FreshActionResult.SUCCESS, FreshClickReason.SUCCESS, 1, ancestorDepth)
} else { } else {
@@ -414,6 +442,7 @@ class GoAutoAccessibilityService : AccessibilityService(), UiDriver, PddCollecto
} }
if (candidates.isEmpty()) return FreshActionResult.NOT_FOUND if (candidates.isEmpty()) return FreshActionResult.NOT_FOUND
if (candidates.size != 1) return FreshActionResult.AMBIGUOUS if (candidates.size != 1) return FreshActionResult.AMBIGUOUS
if (sizeAdviceBlocked(candidates.single())) return FreshActionResult.BLOCKED
val bounds = Rect().also(candidates.single()::getBoundsInScreen) val bounds = Rect().also(candidates.single()::getBoundsInScreen)
if (bounds.width() < 2 || bounds.height() < 2 || Build.VERSION.SDK_INT < Build.VERSION_CODES.N) { if (bounds.width() < 2 || bounds.height() < 2 || Build.VERSION.SDK_INT < Build.VERSION_CODES.N) {
return FreshActionResult.FAILED return FreshActionResult.FAILED
@@ -438,6 +467,7 @@ class GoAutoAccessibilityService : AccessibilityService(), UiDriver, PddCollecto
} }
if (candidates.isEmpty()) return FreshActionResult.NOT_FOUND if (candidates.isEmpty()) return FreshActionResult.NOT_FOUND
if (candidates.size != 1) return FreshActionResult.AMBIGUOUS if (candidates.size != 1) return FreshActionResult.AMBIGUOUS
if (sizeAdviceBlocked(candidates.single())) return FreshActionResult.BLOCKED
val bounds = Rect().also(candidates.single()::getBoundsInScreen) val bounds = Rect().also(candidates.single()::getBoundsInScreen)
val freshTarget = target.copy(bounds = NodeBounds(bounds.left, bounds.top, bounds.right, bounds.bottom)) val freshTarget = target.copy(bounds = NodeBounds(bounds.left, bounds.top, bounds.right, bounds.bottom))
if (!PurchaseSpecGesturePolicy.isAllowed(freshTarget, width, height)) { if (!PurchaseSpecGesturePolicy.isAllowed(freshTarget, width, height)) {
@@ -1,8 +1,26 @@
package cn.ilapage.goauto.agent.automation package cn.ilapage.goauto.agent.automation
enum class PddLaunchPath { DIRECT, BROWSER }
enum class PddLaunchStatus { ACCEPTED, REJECTED, EXCEPTION }
/** No URL, exception message, or page content may enter this event. */
data class PddLaunchEvent(val path: PddLaunchPath, val status: PddLaunchStatus, val exceptionClass: String? = null)
internal object PddLaunchFallback { internal object PddLaunchFallback {
fun open(preferDirect: Boolean, direct: () -> Boolean, browser: () -> Boolean): Boolean { fun open(preferDirect: Boolean, direct: () -> Boolean, browser: () -> Boolean,
if (preferDirect && runCatching(direct).getOrDefault(false)) return true diagnostic: (PddLaunchEvent) -> Unit = {}): Boolean {
return runCatching(browser).getOrDefault(false) fun attempt(path: PddLaunchPath, launch: () -> Boolean): Boolean {
val result = runCatching(launch)
val event = PddLaunchEvent(path, when {
result.isFailure -> PddLaunchStatus.EXCEPTION
result.getOrDefault(false) -> PddLaunchStatus.ACCEPTED
else -> PddLaunchStatus.REJECTED
}, result.exceptionOrNull()?.javaClass?.name)
// Observability must never change routing or turn an accepted launch into a retry.
runCatching { diagnostic(event) }
return result.getOrDefault(false)
}
if (preferDirect && attempt(PddLaunchPath.DIRECT, direct)) return true
return attempt(PddLaunchPath.BROWSER, browser)
} }
} }
@@ -31,7 +31,7 @@ object PddPageClassifier {
} }
class PddLinkLauncher(private val context: Context) { class PddLinkLauncher(private val context: Context) {
fun open(url: String, preferDirect: Boolean = false): Boolean { fun open(url: String, preferDirect: Boolean = false, diagnostic: (PddLaunchEvent) -> Unit = {}): Boolean {
val uri = runCatching { Uri.parse(url) }.getOrNull() ?: return false val uri = runCatching { Uri.parse(url) }.getOrNull() ?: return false
if (uri.scheme !in setOf("http", "https") || !isPddHost(uri.host) || uri.getQueryParameter("goods_id").isNullOrBlank()) { if (uri.scheme !in setOf("http", "https") || !isPddHost(uri.host) || uri.getQueryParameter("goods_id").isNullOrBlank()) {
return false return false
@@ -42,7 +42,7 @@ class PddLinkLauncher(private val context: Context) {
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK) .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK)
context.startActivity(direct) context.startActivity(direct)
true true
}, browser = { openBrowser(uri) }) }, browser = { openBrowser(uri) }, diagnostic = diagnostic)
} }
private fun openBrowser(uri: Uri): Boolean { private fun openBrowser(uri: Uri): Boolean {
@@ -51,7 +51,9 @@ class PddLinkLauncher(private val context: Context) {
runCatching { context.packageManager.getPackageInfo(packageName, 0) }.isSuccess runCatching { context.packageManager.getPackageInfo(packageName, 0) }.isSuccess
} }
if (browser != null) base.setPackage(browser) if (browser != null) base.setPackage(browser)
return runCatching { context.startActivity(base); true }.getOrDefault(false) // The outer fallback boundary retains the previous false-on-error behavior and records only the class.
context.startActivity(base)
return true
} }
private fun isPddHost(host: String?): Boolean = private fun isPddHost(host: String?): Boolean =
@@ -27,6 +27,9 @@ data class SnapshotNode(
val checked: Boolean, val checked: Boolean,
val enabled: Boolean, val enabled: Boolean,
val visible: Boolean, val visible: Boolean,
// In-memory accessibility metadata; not part of result or diagnostic payloads.
val hintText: String? = null,
val showingHintText: Boolean? = null,
) { ) {
val label: String get() = text?.trim().takeUnless { it.isNullOrEmpty() } val label: String get() = text?.trim().takeUnless { it.isNullOrEmpty() }
?: contentDescription?.trim().orEmpty() ?: contentDescription?.trim().orEmpty()
@@ -38,7 +41,7 @@ data class UiSnapshot(
val nodes: List<SnapshotNode>, val nodes: List<SnapshotNode>,
) )
enum class FreshActionResult { SUCCESS, NOT_FOUND, AMBIGUOUS, FAILED } enum class FreshActionResult { SUCCESS, NOT_FOUND, AMBIGUOUS, FAILED, BLOCKED }
enum class FreshClickReason { enum class FreshClickReason {
SUCCESS, SUCCESS,
@@ -47,6 +50,7 @@ enum class FreshClickReason {
TARGET_AMBIGUOUS, TARGET_AMBIGUOUS,
NO_CLICKABLE_ANCESTOR, NO_CLICKABLE_ANCESTOR,
ACTION_CLICK_FALSE, ACTION_CLICK_FALSE,
SIZE_ADVICE_BLOCKED,
UNKNOWN, UNKNOWN,
} }
@@ -375,6 +379,7 @@ object PddScreenParser {
val candidates = panelVisible.asSequence() val candidates = panelVisible.asSequence()
.filter { it.clickable && inValueRegion(it) } .filter { it.clickable && inValueRegion(it) }
.filterNot { isExactHeadingLabel(it.label, config) } .filterNot { isExactHeadingLabel(it.label, config) }
.filterNot { containsDistinctOptionCard(it, sourceByPath) }
.filterNot { node -> .filterNot { node ->
isExcludedOptionLabel(node.label) || isExcludedOptionLabel(node.label) ||
descendants(node, visibleNodes).any { descendant -> isExcludedOptionLabel(descendant.label) } descendants(node, visibleNodes).any { descendant -> isExcludedOptionLabel(descendant.label) }
@@ -585,8 +590,8 @@ object PddScreenParser {
/** /**
* Outermost labelled clickable ancestor inside the value region whose label * Outermost labelled clickable ancestor inside the value region whose label
* prefixes every candidate nested in it. Rows that contain several distinct * prefixes every candidate nested in it, or a single image/caption card with
* options never qualify because their members do not share its label. * a truncated caption. A shared ancestor alone is not card identity.
*/ */
private fun optionBlock( private fun optionBlock(
candidate: SnapshotNode, candidate: SnapshotNode,
@@ -604,9 +609,11 @@ object PddScreenParser {
// Only a block with its own label names the option; a blank row // Only a block with its own label names the option; a blank row
// container resolved from its first child never merges options. // container resolved from its first child never merges options.
if (raw.clickable && raw.label.isNotBlank() && resolved != null && inValueRegion(resolved)) { if (raw.clickable && raw.label.isNotBlank() && resolved != null && inValueRegion(resolved)) {
if (containsDistinctOptionCard(resolved, sourceByPath)) break
val outer = compact(resolved.label) val outer = compact(resolved.label)
val members = candidates.filter { it.path == resolved.path || it.path.startsWith("${resolved.path}/") } 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 block = resolved
} }
parentPath = raw.parentPath parentPath = raw.parentPath
@@ -614,6 +621,57 @@ object PddScreenParser {
return block 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. */ /** 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? { private fun clickableEntryRow(node: SnapshotNode, sourceByPath: Map<String, SnapshotNode>, screenHeight: Int): SnapshotNode? {
var current: SnapshotNode? = node var current: SnapshotNode? = node
@@ -1021,7 +1079,8 @@ class PddProductDetailCollector(
while (opened == null) { while (opened == null) {
val target = entry ?: return failure("RULE_NOT_MATCHED", "未找到 PDD 商品规格入口") val target = entry ?: return failure("RULE_NOT_MATCHED", "未找到 PDD 商品规格入口")
val beforeClick = screenSignature(current) val beforeClick = screenSignature(current)
val clickResult = driver.clickFresh(target) val clickResult = clickSpecTarget(target, AgentDiagnosticStage.SPEC_ENTRY_CLICK)
if (clickResult == FreshActionResult.BLOCKED) return sizeAdviceFailure()
trace( trace(
"spec-entry source=${entrySource ?: "unknown"} label=${target.label.take(40)} " + "spec-entry source=${entrySource ?: "unknown"} label=${target.label.take(40)} " +
"class=${target.className} bounds=${target.bounds} result=$clickResult", "class=${target.className} bounds=${target.bounds} result=$clickResult",
@@ -1131,7 +1190,8 @@ class PddProductDetailCollector(
opened = screen opened = screen
break break
} }
when (driver.clickFresh(recoveryEntry)) { when (clickSpecTarget(recoveryEntry, AgentDiagnosticStage.QUICK_CONFIRMATION_CLICK)) {
FreshActionResult.BLOCKED -> return sizeAdviceFailure()
FreshActionResult.SUCCESS -> { FreshActionResult.SUCCESS -> {
quickConfirmationRecovered = true quickConfirmationRecovered = true
pause(100) pause(100)
@@ -1332,6 +1392,12 @@ class PddProductDetailCollector(
var diagnosticInitialSelectedSizes = 0 var diagnosticInitialSelectedSizes = 0
var diagnosticSelectedSummaryPresent = false var diagnosticSelectedSummaryPresent = false
var diagnosticHorizontalSwipes = 0 var diagnosticHorizontalSwipes = 0
var diagnosticVerticalSwipes = 0
var diagnosticTrailingEmptyReads = 0
var diagnosticHorizontalUnknown = false
// Exact seed sets can reconcile a later stable visit to the same row.
// These signatures stay in memory; only the aggregate tri-state is saved.
val diagnosticHorizontalRows = mutableMapOf<Set<String>, Boolean?>()
var diagnosticTermination = AgentDiagnosticReason.COLOR_FOUND var diagnosticTermination = AgentDiagnosticReason.COLOR_FOUND
val imageAttempts = mutableSetOf<String>() val imageAttempts = mutableSetOf<String>()
@@ -1388,6 +1454,13 @@ class PddProductDetailCollector(
initialSelectedSizeCount = diagnosticInitialSelectedSizes, initialSelectedSizeCount = diagnosticInitialSelectedSizes,
selectedSummaryPresent = diagnosticSelectedSummaryPresent, selectedSummaryPresent = diagnosticSelectedSummaryPresent,
horizontalSwipes = diagnosticHorizontalSwipes, horizontalSwipes = diagnosticHorizontalSwipes,
verticalSwipes = diagnosticVerticalSwipes,
trailingEmptyReads = diagnosticTrailingEmptyReads,
horizontalIncomplete = when {
diagnosticHorizontalRows.values.any { it == true } -> true
diagnosticHorizontalUnknown || diagnosticHorizontalRows.values.any { it == null } -> null
else -> false
},
elapsedMs = now() - diagnosticStartedAt, elapsedMs = now() - diagnosticStartedAt,
) )
} }
@@ -1404,6 +1477,8 @@ class PddProductDetailCollector(
val initialRows = colorRows(initial) val initialRows = colorRows(initial)
observeColorDiscovery(initial, initialRows) observeColorDiscovery(initial, initialRows)
if (initialRows.isEmpty()) { if (initialRows.isEmpty()) {
diagnosticTrailingEmptyReads = 1
diagnosticHorizontalUnknown = true
finishColorDiscovery( finishColorDiscovery(
if (diagnosticNonClickableCandidates > 0) AgentDiagnosticReason.COLOR_VALUES_NOT_CLICKABLE if (diagnosticNonClickableCandidates > 0) AgentDiagnosticReason.COLOR_VALUES_NOT_CLICKABLE
else AgentDiagnosticReason.COLOR_EDGE_REACHED, else AgentDiagnosticReason.COLOR_EDGE_REACHED,
@@ -1411,7 +1486,8 @@ class PddProductDetailCollector(
return null return null
} }
val attempted = mutableSetOf<String>() val attempted = mutableSetOf<String>()
fun collectVisibleColors(): PddCollectorResult? { fun collectVisibleColors(onFirstRows: ((List<List<VisibleSpecValue>>) -> Unit)? = null): PddCollectorResult? {
var firstRead = true
while (true) { while (true) {
if (now() > deadline) return failure("RULE_NOT_MATCHED", "采集超过规则总超时") if (now() > deadline) return failure("RULE_NOT_MATCHED", "采集超过规则总超时")
val screen = parse(goodsId, config, evidence) val screen = parse(goodsId, config, evidence)
@@ -1419,6 +1495,10 @@ class PddProductDetailCollector(
if (!screen.pageEvidenceMatched) return failure("RULE_NOT_MATCHED", "采集期间离开 PDD 商品详情页") if (!screen.pageEvidenceMatched) return failure("RULE_NOT_MATCHED", "采集期间离开 PDD 商品详情页")
screen.dimensions.filter { it.key == "unsupported" }.forEach { unsupported += it.name } screen.dimensions.filter { it.key == "unsupported" }.forEach { unsupported += it.name }
val rows = colorRows(screen) val rows = colorRows(screen)
if (firstRead) {
onFirstRows?.invoke(rows)
firstRead = false
}
observeColorDiscovery(screen, rows) observeColorDiscovery(screen, rows)
rows.flatten().filter { SpecValueNormalizer.isSafeColor(it.text) }.forEach { value -> rows.flatten().filter { SpecValueNormalizer.isSafeColor(it.text) }.forEach { value ->
colors[value.text] = colors[value.text] == true || value.available colors[value.text] = colors[value.text] == true || value.available
@@ -1431,9 +1511,10 @@ class PddProductDetailCollector(
} ?: return null } ?: return null
attempted += value.text attempted += value.text
val beforeClick = screen 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") trace("color-click color=${traceLabel(value.text)} result=$clickResult")
when (clickResult) { when (clickResult) {
FreshActionResult.BLOCKED -> return sizeAdviceFailure()
FreshActionResult.AMBIGUOUS -> return failure("RULE_AMBIGUOUS", "颜色“${value.text}”匹配到多个控件") FreshActionResult.AMBIGUOUS -> return failure("RULE_AMBIGUOUS", "颜色“${value.text}”匹配到多个控件")
FreshActionResult.NOT_FOUND, FreshActionResult.FAILED -> { FreshActionResult.NOT_FOUND, FreshActionResult.FAILED -> {
missing += "selection:${value.text}" missing += "selection:${value.text}"
@@ -1470,8 +1551,18 @@ class PddProductDetailCollector(
rowSeeds.forEachIndexed { rowIndex, seed -> rowSeeds.forEachIndexed { rowIndex, seed ->
val moveRight = rowIndex % 2 == 0 val moveRight = rowIndex % 2 == 0
val horizontalSignatureReads = mutableMapOf<List<String>, Int>() val horizontalSignatureReads = mutableMapOf<List<String>, Int>()
var diagnosticPreviousSignature: List<String>? = null
var diagnosticStableReads = 0
var diagnosticMoved = false
var diagnosticRowUncertain = false
var diagnosticRowIncomplete: Boolean? = null
for (horizontalPass in 0..config.limits.getValue("specHorizontalSwipes")) { for (horizontalPass in 0..config.limits.getValue("specHorizontalSwipes")) {
collectVisibleColors()?.let { return it } var diagnosticBeforeClicks: List<String>? = null
collectVisibleColors { observedRows ->
diagnosticBeforeClicks = observedRows.filter { row -> row.any { it.text in seed } }
.singleOrNull()?.sortedBy { it.node.bounds.left }
?.let { if (moveRight) it else it.reversed() }?.let(::optionSignature)
}?.let { return it }
screen = parse(goodsId, config, evidence) screen = parse(goodsId, config, evidence)
screen.problem?.let { return failure(it.code, it.message) } screen.problem?.let { return failure(it.code, it.message) }
if (!screen.pageEvidenceMatched) return failure("RULE_NOT_MATCHED", "采集期间离开 PDD 商品详情页") if (!screen.pageEvidenceMatched) return failure("RULE_NOT_MATCHED", "采集期间离开 PDD 商品详情页")
@@ -1480,21 +1571,49 @@ class PddProductDetailCollector(
val matchedRow = rows.maxByOrNull { row -> row.count { it.text in seed } } val matchedRow = rows.maxByOrNull { row -> row.count { it.text in seed } }
?.takeIf { row -> row.any { it.text in seed } } ?.takeIf { row -> row.any { it.text in seed } }
if (matchedRow == null) { if (matchedRow == null) {
diagnosticHorizontalUnknown = true
diagnosticTermination = AgentDiagnosticReason.COLOR_ROW_REFLOWED diagnosticTermination = AgentDiagnosticReason.COLOR_ROW_REFLOWED
break break
} }
val currentRow = matchedRow.sortedBy { it.node.bounds.left } val currentRow = matchedRow.sortedBy { it.node.bounds.left }
.let { if (moveRight) it else it.reversed() } .let { if (moveRight) it else it.reversed() }
val signature = optionSignature(currentRow) val signature = optionSignature(currentRow)
// Observe the frame immediately after the prior swipe,
// before clicking newly visible values. A click reflow or
// ambiguous row match cannot prove horizontal movement.
if (diagnosticBeforeClicks == null || diagnosticBeforeClicks != signature) {
diagnosticRowUncertain = true
diagnosticHorizontalUnknown = true
}
if (!diagnosticRowUncertain && diagnosticPreviousSignature != null && signature != diagnosticPreviousSignature) {
diagnosticMoved = true
}
diagnosticStableReads = if (signature == diagnosticPreviousSignature) diagnosticStableReads + 1 else 0
diagnosticPreviousSignature = signature
val signatureReads = (horizontalSignatureReads[signature] ?: 0) + 1 val signatureReads = (horizontalSignatureReads[signature] ?: 0) + 1
horizontalSignatureReads[signature] = signatureReads horizontalSignatureReads[signature] = signatureReads
if (signatureReads > config.limits.getValue("stableEdgeReads")) break if (signatureReads > config.limits.getValue("stableEdgeReads")) {
if (horizontalPass == config.limits.getValue("specHorizontalSwipes")) break if (!diagnosticRowUncertain && diagnosticStableReads >= config.limits.getValue("stableEdgeReads")) {
diagnosticRowIncomplete = false
}
break
}
if (horizontalPass == config.limits.getValue("specHorizontalSwipes")) {
if (diagnosticMoved && !diagnosticRowUncertain) diagnosticRowIncomplete = true
break
}
val direction = if (moveRight) SwipeDirection.LEFT else SwipeDirection.RIGHT val direction = if (moveRight) SwipeDirection.LEFT else SwipeDirection.RIGHT
if (!driver.swipeSpec(direction, currentRow.first().node)) break if (!driver.swipeSpec(direction, currentRow.first().node)) {
if (diagnosticMoved && !diagnosticRowUncertain) diagnosticRowIncomplete = true
break
}
diagnosticHorizontalSwipes++ diagnosticHorizontalSwipes++
pause(350) pause(350)
} }
// An uncertain revisit cannot erase previously observed
// unresolved movement; only reliable stability clears it.
diagnosticHorizontalRows[seed] = diagnosticRowIncomplete
?: diagnosticHorizontalRows[seed]?.takeIf { it }
} }
collectVisibleColors()?.let { return it } collectVisibleColors()?.let { return it }
screen = parse(goodsId, config, evidence) screen = parse(goodsId, config, evidence)
@@ -1503,6 +1622,10 @@ class PddProductDetailCollector(
observeColorDiscovery(screen, rows) observeColorDiscovery(screen, rows)
} }
// One observation per outer discovery round, never per click or
// stable-price reread. Empty parsed rows do not prove an edge.
diagnosticTrailingEmptyReads = if (rows.isEmpty()) diagnosticTrailingEmptyReads + 1 else 0
if (rows.isEmpty()) diagnosticHorizontalUnknown = true
if (!discoverVertically) { if (!discoverVertically) {
diagnosticTermination = AgentDiagnosticReason.COLOR_EDGE_REACHED diagnosticTermination = AgentDiagnosticReason.COLOR_EDGE_REACHED
break break
@@ -1525,6 +1648,7 @@ class PddProductDetailCollector(
} }
val anchor = rows.flatten().firstOrNull()?.node ?: specPanelContainer val anchor = rows.flatten().firstOrNull()?.node ?: specPanelContainer
if (anchor == null) { if (anchor == null) {
diagnosticHorizontalUnknown = true
diagnosticTermination = AgentDiagnosticReason.COLOR_CONTAINER_UNAVAILABLE diagnosticTermination = AgentDiagnosticReason.COLOR_CONTAINER_UNAVAILABLE
break break
} }
@@ -1532,12 +1656,21 @@ class PddProductDetailCollector(
diagnosticTermination = AgentDiagnosticReason.COLOR_SWIPE_FAILED diagnosticTermination = AgentDiagnosticReason.COLOR_SWIPE_FAILED
break break
} }
diagnosticVerticalSwipes++
pause(350) pause(350)
} }
finishColorDiscovery() finishColorDiscovery()
return null 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) { private fun recordColorImage(reason: AgentDiagnosticReason) {
if (taskId <= 0) return if (taskId <= 0) return
diagnostic(AgentDiagnosticEvent(taskId = taskId, stage = AgentDiagnosticStage.COLOR_IMAGE, reason = reason, attempt = 1)) diagnostic(AgentDiagnosticEvent(taskId = taskId, stage = AgentDiagnosticStage.COLOR_IMAGE, reason = reason, attempt = 1))
@@ -1552,6 +1685,9 @@ class PddProductDetailCollector(
initialSelectedSizeCount: Int, initialSelectedSizeCount: Int,
selectedSummaryPresent: Boolean, selectedSummaryPresent: Boolean,
horizontalSwipes: Int, horizontalSwipes: Int,
verticalSwipes: Int,
trailingEmptyReads: Int,
horizontalIncomplete: Boolean?,
elapsedMs: Long, elapsedMs: Long,
) { ) {
if (taskId <= 0) return if (taskId <= 0) return
@@ -1570,6 +1706,9 @@ class PddProductDetailCollector(
initialSelectedSizeCount = initialSelectedSizeCount, initialSelectedSizeCount = initialSelectedSizeCount,
selectedSummaryPresent = selectedSummaryPresent, selectedSummaryPresent = selectedSummaryPresent,
horizontalSwipeCount = horizontalSwipes, horizontalSwipeCount = horizontalSwipes,
colorVerticalSwipeCount = verticalSwipes,
colorTrailingEmptyReadCount = trailingEmptyReads,
colorHorizontalIncomplete = horizontalIncomplete,
), ),
) )
} }
@@ -42,6 +42,7 @@ class PurchaseLiveAutomation(
private val pause: (Long) -> Unit = Thread::sleep, private val pause: (Long) -> Unit = Thread::sleep,
/** Boolean/count-only diagnostics; never receives address text. */ /** Boolean/count-only diagnostics; never receives address text. */
private val panelDiagnostic: (String) -> Unit = {}, private val panelDiagnostic: (String) -> Unit = {},
private val monotonicClockMs: () -> Long = { System.nanoTime() / 1_000_000L },
) { ) {
private var submitAttempted = false private var submitAttempted = false
var lastOrderReadFailure: PurchaseOrderReadFailure? = null var lastOrderReadFailure: PurchaseOrderReadFailure? = null
@@ -366,48 +367,83 @@ class PurchaseLiveAutomation(
var consecutiveEmptySnapshots = 0 var consecutiveEmptySnapshots = 0
var unpaidStartSample: Int? = null var unpaidStartSample: Int? = null
var unpaidSwipes = 0 var unpaidSwipes = 0
repeat(ORDER_RESULT_MAX_SAMPLES + ORDER_RESULT_UNPAID_MAX_SAMPLES) { index -> var paymentBackSucceededAt: Long? = null
var orderContextObserved = false
// Extra observations cannot reopen navigation/scroll budgets. Late unpaid evidence
// still receives its existing independent budget below.
var passiveExtension = false
fun observationRemainingMs(): Long? {
val startedAt = paymentBackSucceededAt ?: return null
if (unpaidStartSample != null || (orderContextObserved && !passiveExtension)) return null
return ORDER_RESULT_POST_PAYMENT_OBSERVATION_MS - (monotonicClockMs() - startedAt)
}
fun observePause(durationMs: Long) {
val remaining = observationRemainingMs()
val duration = if (remaining == null) durationMs else minOf(durationMs, remaining.coerceAtLeast(0))
if (duration > 0) pause(duration)
}
var nextSample = 0
while (true) {
val index = nextSample++
// The first unpaid page receives its own bounded budget, even after a long handoff. // The first unpaid page receives its own bounded budget, even after a long handoff.
// Never reset it on repeated labels or navigation back to payment. // Never reset it on repeated labels or navigation back to payment.
val unpaidStart = unpaidStartSample val unpaidStart = unpaidStartSample
if ((unpaidStart == null && index >= ORDER_RESULT_MAX_SAMPLES) || if (unpaidStart == null && index >= ORDER_RESULT_MAX_SAMPLES &&
paymentBackSucceededAt != null && !orderContextObserved) {
passiveExtension = true
}
if ((observationRemainingMs()?.let { it <= 0 } == true) ||
(unpaidStart == null && index >= ORDER_RESULT_MAX_SAMPLES && !passiveExtension) ||
(unpaidStart != null && index - unpaidStart >= ORDER_RESULT_UNPAID_MAX_SAMPLES)) { (unpaidStart != null && index - unpaidStart >= ORDER_RESULT_UNPAID_MAX_SAMPLES)) {
val failure = orderEvidenceFailure(labels) val failure = orderEvidenceFailure(labels)
return unknown(failure.code, failure.message) return unknown(failure.code, failure.message)
} }
val snapshot = driver.capture() val snapshot = driver.capture()
// A blocking capture can finish after the deadline. Do not act on or accept that frame.
if (observationRemainingMs()?.let { it <= 0 } == true) {
val failure = orderEvidenceFailure(labels)
return unknown(failure.code, failure.message)
}
if (snapshot.packageName.isNullOrBlank()) { if (snapshot.packageName.isNullOrBlank()) {
consecutiveEmptySnapshots++ consecutiveEmptySnapshots++
if (consecutiveEmptySnapshots >= ORDER_RESULT_MAX_EMPTY_SAMPLES) { if (consecutiveEmptySnapshots >= ORDER_RESULT_MAX_EMPTY_SAMPLES) {
return unknown("PURCHASE_ORDER_EMPTY_TIMEOUT", "等待订单页面时无障碍窗口持续为空") return unknown("PURCHASE_ORDER_EMPTY_TIMEOUT", "等待订单页面时无障碍窗口持续为空")
} }
pause(ORDER_RESULT_SAMPLE_INTERVAL_MS) observePause(ORDER_RESULT_SAMPLE_INTERVAL_MS)
return@repeat continue
} }
consecutiveEmptySnapshots = 0 consecutiveEmptySnapshots = 0
val currentLabels = snapshot.nodes.filter { it.visible }.map { it.label } val currentLabels = snapshot.nodes.filter { it.visible }.map { it.label }
if (isKnownAndroidWechatChooser(snapshot, currentLabels)) { if (isKnownAndroidWechatChooser(snapshot, currentLabels)) {
if (passiveExtension && !backedOutOfChooser) {
val failure = orderEvidenceFailure(labels)
return unknown(failure.code, failure.message)
}
if (backedOutOfChooser || !driver.backPurchase()) { if (backedOutOfChooser || !driver.backPurchase()) {
return unknown("PURCHASE_ORDER_CHOOSER_BACK_FAILED", "系统应用选择页无法安全返回") return unknown("PURCHASE_ORDER_CHOOSER_BACK_FAILED", "系统应用选择页无法安全返回")
} }
backedOutOfChooser = true backedOutOfChooser = true
pause(500) observePause(500)
return@repeat continue
} }
if (snapshot.packageName == WECHAT_PACKAGE) { if (snapshot.packageName == WECHAT_PACKAGE) {
if (!restoredFromWechat) { if (!restoredFromWechat) {
if (passiveExtension) {
val failure = orderEvidenceFailure(labels)
return unknown(failure.code, failure.message)
}
if (!driver.bringPddToForeground()) { if (!driver.bringPddToForeground()) {
return unknown("PURCHASE_ORDER_WECHAT_RESTORE_FAILED", "从微信恢复拼多多的请求失败") return unknown("PURCHASE_ORDER_WECHAT_RESTORE_FAILED", "从微信恢复拼多多的请求失败")
} }
restoredFromWechat = true restoredFromWechat = true
pause(500) observePause(500)
return@repeat continue
} }
if (pddObservedAfterWechatRestore || ++wechatRestorePendingSamples >= ORDER_RESULT_WECHAT_RESTORE_MAX_SAMPLES) { if (pddObservedAfterWechatRestore || ++wechatRestorePendingSamples >= ORDER_RESULT_WECHAT_RESTORE_MAX_SAMPLES) {
return unknown("PURCHASE_ORDER_WECHAT_RESTORE_TIMEOUT", "从微信恢复拼多多后未在限定时间到达订单页面") return unknown("PURCHASE_ORDER_WECHAT_RESTORE_TIMEOUT", "从微信恢复拼多多后未在限定时间到达订单页面")
} }
pause(ORDER_RESULT_SAMPLE_INTERVAL_MS) observePause(ORDER_RESULT_SAMPLE_INTERVAL_MS)
return@repeat continue
} }
if (snapshot.packageName != PDD_PACKAGE) { if (snapshot.packageName != PDD_PACKAGE) {
return unknown("PURCHASE_ORDER_UNEXPECTED_APP", "核单期间出现未授权应用") return unknown("PURCHASE_ORDER_UNEXPECTED_APP", "核单期间出现未授权应用")
@@ -426,7 +462,8 @@ class PurchaseLiveAutomation(
if (!driver.backPurchase()) { if (!driver.backPurchase()) {
return unknown("PURCHASE_ORDER_PAYMENT_BACK_FAILED", "支付页无法安全返回订单详情") return unknown("PURCHASE_ORDER_PAYMENT_BACK_FAILED", "支付页无法安全返回订单详情")
} }
pause(500) paymentBackSucceededAt = monotonicClockMs()
observePause(500)
} else { } else {
consecutivePaymentSamplesAfterBack++ consecutivePaymentSamplesAfterBack++
if (consecutivePaymentSamplesAfterBack >= ORDER_RESULT_PAYMENT_POST_BACK_MAX_SAMPLES) { if (consecutivePaymentSamplesAfterBack >= ORDER_RESULT_PAYMENT_POST_BACK_MAX_SAMPLES) {
@@ -437,20 +474,21 @@ class PurchaseLiveAutomation(
"consecutivePaymentSamplesAfterBack=$consecutivePaymentSamplesAfterBack]", "consecutivePaymentSamplesAfterBack=$consecutivePaymentSamplesAfterBack]",
) )
} }
pause(ORDER_RESULT_SAMPLE_INTERVAL_MS) observePause(ORDER_RESULT_SAMPLE_INTERVAL_MS)
} }
return@repeat continue
} }
consecutivePaymentSamplesAfterBack = 0 consecutivePaymentSamplesAfterBack = 0
if (orderContextVisible) orderContextObserved = true
if (unpaidContextVisible) { if (unpaidContextVisible) {
paymentPageObserved = true paymentPageObserved = true
if (unpaidStartSample == null) unpaidStartSample = index if (unpaidStartSample == null) unpaidStartSample = index
} }
if (!orderContextVisible && !unpaidContextVisible) { if (!orderContextVisible && !unpaidContextVisible) {
if (unpaidStartSample != null) { if (unpaidStartSample != null || passiveExtension) {
// After reaching the unpaid page only observe; never click a newly exposed control. // Neither unpaid reading nor the added observation budget permits a new click.
pause(ORDER_RESULT_SAMPLE_INTERVAL_MS) observePause(ORDER_RESULT_SAMPLE_INTERVAL_MS)
return@repeat continue
} }
val entries = orderDetailEntryTargets(snapshot) val entries = orderDetailEntryTargets(snapshot)
if (entries.size > 1) { if (entries.size > 1) {
@@ -466,27 +504,25 @@ class PurchaseLiveAutomation(
else -> return unknown("PURCHASE_ORDER_DETAIL_ENTRY_FAILED", "订单详情入口点击失败,已停止只读核单") else -> return unknown("PURCHASE_ORDER_DETAIL_ENTRY_FAILED", "订单详情入口点击失败,已停止只读核单")
} }
orderDetailEntryOpened = true orderDetailEntryOpened = true
pause(500) observePause(500)
return@repeat continue
} }
pause(ORDER_RESULT_SAMPLE_INTERVAL_MS) observePause(ORDER_RESULT_SAMPLE_INTERVAL_MS)
return@repeat continue
} }
currentLabels.forEach(labels::add) currentLabels.forEach(labels::add)
parseOrderEvidence(labels)?.let { return it } parseOrderEvidence(labels)?.let { return it }
if (unpaidStartSample != null && unpaidSwipes < ORDER_RESULT_UNPAID_MAX_SWIPES) { if (unpaidStartSample != null && unpaidSwipes < ORDER_RESULT_UNPAID_MAX_SWIPES) {
unpaidSwipes++ unpaidSwipes++
driver.swipePurchase(SwipeDirection.UP, 400) driver.swipePurchase(SwipeDirection.UP, 400)
pause(ORDER_RESULT_UNPAID_SETTLE_MS) observePause(ORDER_RESULT_UNPAID_SETTLE_MS)
return@repeat continue
} }
if (unpaidStartSample == null && index > 0 && index % ORDER_RESULT_SCROLL_SAMPLE_INTERVAL == 0) { if (!passiveExtension && unpaidStartSample == null && index > 0 && index % ORDER_RESULT_SCROLL_SAMPLE_INTERVAL == 0) {
driver.swipePurchase(SwipeDirection.UP, 400) driver.swipePurchase(SwipeDirection.UP, 400)
} }
pause(ORDER_RESULT_SAMPLE_INTERVAL_MS) observePause(ORDER_RESULT_SAMPLE_INTERVAL_MS)
} }
val failure = orderEvidenceFailure(labels)
return unknown(failure.code, failure.message)
} }
// orderEvidenceFailure 之外的路径(选择器卡住、微信恢复超时等)同样要带上 // orderEvidenceFailure 之外的路径(选择器卡住、微信恢复超时等)同样要带上
@@ -509,16 +545,12 @@ class PurchaseLiveAutomation(
val modify = addressModifyTargets(panel) val modify = addressModifyTargets(panel)
if (modify.size != 1) fail("PURCHASE_ADDRESS_EDIT_AMBIGUOUS", "没有找到唯一的地址修改按钮,未创建订单") if (modify.size != 1) fail("PURCHASE_ADDRESS_EDIT_AMBIGUOUS", "没有找到唯一的地址修改按钮,未创建订单")
click(modify.single(), "修改地址") click(modify.single(), "修改地址")
val edit = waitFor("PURCHASE_ADDRESS_EDIT_TIMEOUT", "地址编辑页面打开超时,未创建订单") { snapshot -> val editor = waitForInitialAddressEditor()
snapshot.nodes.any { it.visible && it.label.replace(" ", "").contains("详细地址") } val current = editor.text.orEmpty().trim()
}
val editors = shippingAddressEditors(edit)
if (editors.size != 1) fail("PURCHASE_ADDRESS_UPDATE_FAILED", "没有找到唯一的详细地址输入框,未创建订单")
val current = editors.single().label.trim()
val body = current.split(Regex("[-_]"), limit = 2).firstOrNull()?.trim().orEmpty() val body = current.split(Regex("[-_]"), limit = 2).firstOrNull()?.trim().orEmpty()
if (body.isBlank()) fail("PURCHASE_ADDRESS_UPDATE_FAILED", "当前详细地址无效,未创建订单") if (body.isBlank()) fail("PURCHASE_ADDRESS_UPDATE_FAILED", "当前详细地址无效,未创建订单")
val expected = body + suffix val expected = body + suffix
if (driver.inputFresh(editors.single(), expected) != FreshActionResult.SUCCESS) fail("PURCHASE_ADDRESS_UPDATE_FAILED", "详细地址修改失败,未创建订单") if (driver.inputFresh(editor, expected) != FreshActionResult.SUCCESS) fail("PURCHASE_ADDRESS_UPDATE_FAILED", "详细地址修改失败,未创建订单")
val stable = waitFor("PURCHASE_ADDRESS_INPUT_MISMATCH", "详细地址修改后回读不一致,未创建订单") { snapshot -> val stable = waitFor("PURCHASE_ADDRESS_INPUT_MISMATCH", "详细地址修改后回读不一致,未创建订单") { snapshot ->
snapshot.nodes.count { it.visible && it.enabled && it.className?.endsWith("EditText") == true && it.label == expected } == 1 snapshot.nodes.count { it.visible && it.enabled && it.className?.endsWith("EditText") == true && it.label == expected } == 1
} }
@@ -539,6 +571,47 @@ class PurchaseLiveAutomation(
return ShippingAddressProof(expected, suffix) return ShippingAddressProof(expected, suffix)
} }
/** Initial readiness only; return the same sampled node for the original value and fresh input anchor. */
private fun waitForInitialAddressEditor(): SnapshotNode {
fun isHint(editor: SnapshotNode): Boolean {
val hint = editor.hintText?.trim().orEmpty()
return editor.showingHintText == true || (hint.isNotEmpty() && editor.text?.trim() == hint)
}
var titleSeen = false
var structural = emptyList<SnapshotNode>()
var readyCount = 0
repeat(50) {
val snapshot = driver.capture()
pageProblem(snapshot)
val labels = snapshot.nodes.filter { it.visible && it.label.replace(" ", "").contains("详细地址") }
titleSeen = titleSeen || labels.isNotEmpty()
// Keep the existing same-row/rightward region, but count empty and disabled inputs too.
// Their presence is structural ambiguity, even when just one input is ready.
structural = snapshot.nodes.filter { editor ->
editor.visible && editor.className?.endsWith("EditText") == true &&
labels.any { label ->
editor.bounds.top <= label.bounds.bottom &&
editor.bounds.bottom >= label.bounds.top &&
editor.bounds.left >= label.bounds.left
}
}.distinctBy { it.path }
readyCount = structural.count { it.enabled && !it.text.isNullOrBlank() && !isHint(it) }
if (structural.size == 1 && readyCount == 1) return structural.single()
pause(200)
}
val reason = when {
!titleSeen -> "title_missing"
structural.isEmpty() -> "editor_missing"
structural.size > 1 -> "editor_ambiguous"
!structural.single().enabled -> "editor_disabled"
structural.single().text.isNullOrBlank() -> "editor_empty"
else -> "editor_hint"
}
val diagnostic = "stage=address_editor_ready;reason=$reason;titleSeen=$titleSeen;structural=${structural.size};ready=$readyCount"
if (!titleSeen) fail("PURCHASE_ADDRESS_EDIT_TIMEOUT", "地址编辑页面打开超时,未创建订单 [$diagnostic]")
fail("PURCHASE_ADDRESS_UPDATE_FAILED", "详细地址输入框未就绪,未创建订单 [$diagnostic]")
}
private fun isPurchaseConfirmationPanel(snapshot: UiSnapshot): Boolean { private fun isPurchaseConfirmationPanel(snapshot: UiSnapshot): Boolean {
if (snapshot.packageName != PDD_PACKAGE || shippingAddressEditors(snapshot).isNotEmpty()) return false if (snapshot.packageName != PDD_PACKAGE || shippingAddressEditors(snapshot).isNotEmpty()) return false
val screen = PddScreenParser.parse(snapshot, PurchaseRehearsalExecutor.DEFAULT_COLLECTOR, "", null) val screen = PddScreenParser.parse(snapshot, PurchaseRehearsalExecutor.DEFAULT_COLLECTOR, "", null)
@@ -1038,6 +1111,7 @@ class PurchaseLiveAutomation(
val ANDROID_CHOOSER_TITLES = setOf("选择要使用的应用", "使用以下应用打开", "完成操作时使用") val ANDROID_CHOOSER_TITLES = setOf("选择要使用的应用", "使用以下应用打开", "完成操作时使用")
val PDD_PAYMENT_ACTIVITIES = setOf("com.xunmeng.pinduoduo.app_pay.core.PayActivity") val PDD_PAYMENT_ACTIVITIES = setOf("com.xunmeng.pinduoduo.app_pay.core.PayActivity")
const val ORDER_RESULT_MAX_SAMPLES = 60 const val ORDER_RESULT_MAX_SAMPLES = 60
const val ORDER_RESULT_POST_PAYMENT_OBSERVATION_MS = 30_000L
const val ORDER_RESULT_MAX_EMPTY_SAMPLES = 15 const val ORDER_RESULT_MAX_EMPTY_SAMPLES = 15
const val ORDER_RESULT_WECHAT_RESTORE_MAX_SAMPLES = 15 const val ORDER_RESULT_WECHAT_RESTORE_MAX_SAMPLES = 15
const val ORDER_RESULT_PAYMENT_POST_BACK_MAX_SAMPLES = 25 const val ORDER_RESULT_PAYMENT_POST_BACK_MAX_SAMPLES = 25
@@ -0,0 +1,55 @@
package cn.ilapage.goauto.agent.automation
/** In-memory stability evidence only. This is not proof of the page's goodsId. */
internal data class ProductStabilityKey(val title: String?, val specEntry: Boolean, val quickEntry: Boolean, val panel: Boolean)
internal class PurchaseNavigationReadiness(
private val startedAt: Long,
private val path: PddLaunchPath?,
private val acceptedAt: Long,
) {
private var browserObserved = false
private var returnedAt: Long? = null
private var stableSince: Long? = null
private var stableKey: ProductStabilityKey? = null
private var samples = 0
private var lastClickAt: Long? = null
fun expired(now: Long): Boolean = now - startedAt !in 0 until TIMEOUT_MS
fun observe(now: Long, packageName: String?, key: ProductStabilityKey?): Boolean {
if (expired(now)) return false
if (isBrowser(packageName)) browserObserved = true
if (packageName != PDD_PACKAGE) returnedAt = null
val eligible = path == PddLaunchPath.DIRECT || (path == PddLaunchPath.BROWSER && browserObserved)
if (!eligible || packageName != PDD_PACKAGE || key == null) {
stableSince = null
stableKey = null
samples = 0
return false
}
if (returnedAt == null) returnedAt = now
if (stableKey != key || stableSince == null) {
stableKey = key
stableSince = now
samples = 0
}
samples++
val bufferStart = if (path == PddLaunchPath.DIRECT) acceptedAt else requireNotNull(returnedAt)
return now - bufferStart >= BUFFER_MS && now - requireNotNull(stableSince) >= STABLE_MS && samples >= 3
}
fun mayClickBrowser(now: Long, packageName: String?): Boolean =
!expired(now) && path == PddLaunchPath.BROWSER && isBrowser(packageName) &&
(lastClickAt == null || now - requireNotNull(lastClickAt) >= 1_000L)
fun clicked(now: Long) { lastClickAt = now }
companion object {
const val TIMEOUT_MS = 30_000L
const val BUFFER_MS = 3_000L
const val STABLE_MS = 1_000L
const val PDD_PACKAGE = "com.xunmeng.pinduoduo"
fun isBrowser(packageName: String?): Boolean = packageName in setOf("com.heytap.browser", "com.android.chrome", "com.android.browser")
}
}
@@ -0,0 +1,59 @@
package cn.ilapage.goauto.agent.automation
/** Service-instance memory only; never restored from results or the upload outbox. */
internal class PurchaseProbeHandoff {
internal data class Credential(
val taskId: Long, val goodsId: String, val sourceAttemptId: String,
val generation: Long, val completedAt: Long, val accessibility: Any, val foregroundRevision: Long,
)
internal data class Execution(val generation: Long, val previous: Credential?)
private var generation = 0L
private var credential: Credential? = null
private var evaluatedGeneration = -1L
private var invalidatedGeneration = -1L
private var authorizedGeneration = -1L
@Synchronized fun beginExecution(): Execution {
val previous = credential
credential = null
return Execution(++generation, previous)
}
@Synchronized fun invalidate() {
credential = null
invalidatedGeneration = generation
}
@Synchronized fun completeProbe(
execution: Execution, taskId: Long, goodsId: String, attemptId: String,
completedAt: Long, accessibility: Any, foregroundRevision: Long,
successful: Boolean, safelyStored: Boolean, submitPending: Boolean, replayed: Boolean,
) {
if (execution.generation != generation || !successful || !safelyStored || !submitPending || replayed || attemptId.isBlank()) return
credential = Credential(taskId, goodsId, attemptId, generation, completedAt, accessibility, foregroundRevision)
}
@Synchronized fun eligible(
execution: Execution, taskId: Long, goodsId: String, phase: String, attemptId: String,
accessibility: Any, foregroundRevision: Long, now: Long,
): Boolean {
if (evaluatedGeneration == execution.generation) return false
evaluatedGeneration = execution.generation
val prior = execution.previous ?: return false
val eligible = generation == execution.generation && invalidatedGeneration != execution.generation &&
prior.generation + 1 == execution.generation && phase == "purchase" &&
prior.taskId == taskId && prior.goodsId == goodsId && attemptId.isNotBlank() && prior.sourceAttemptId != attemptId &&
prior.accessibility === accessibility && prior.foregroundRevision == foregroundRevision &&
now - prior.completedAt in 0 until 30_000L
if (eligible) authorizedGeneration = execution.generation
return eligible
}
/** Revalidate the already consumed credential after a potentially slow fresh capture; never creates another. */
@Synchronized fun stillValid(execution: Execution, accessibility: Any?, foregroundRevision: Long, now: Long): Boolean {
val prior = execution.previous ?: return false
return authorizedGeneration == execution.generation && generation == execution.generation &&
invalidatedGeneration != execution.generation && prior.accessibility === accessibility &&
prior.foregroundRevision == foregroundRevision && now - prior.completedAt in 0 until 30_000L
}
}
@@ -1,5 +1,7 @@
package cn.ilapage.goauto.agent.automation package cn.ilapage.goauto.agent.automation
import cn.ilapage.goauto.agent.persistence.AgentDiagnosticStage
import java.net.URI import java.net.URI
import java.net.URLDecoder import java.net.URLDecoder
@@ -14,6 +16,9 @@ interface PurchaseUiDriver {
result = clickFresh(target), result = clickFresh(target),
reason = FreshClickReason.UNKNOWN, reason = FreshClickReason.UNKNOWN,
) )
/** Recheck after fresh-node Binder reads and immediately before a browser navigation click. */
fun clickNavigationFresh(target: SnapshotNode, allowed: () -> Boolean): FreshClickOutcome =
if (allowed()) clickFreshDetailed(target) else FreshClickOutcome(FreshActionResult.FAILED, FreshClickReason.UNKNOWN)
/** /**
* Reacquires one address-entry node by its accessibility path and validates * Reacquires one address-entry node by its accessibility path and validates
* its immutable snapshot traits before clicking its nearest clickable * its immutable snapshot traits before clicking its nearest clickable
@@ -83,6 +88,7 @@ data class PurchaseExecutionInput(
val minUnitPriceCent: Long, val minUnitPriceCent: Long,
val maxUnitPriceCent: Long, val maxUnitPriceCent: Long,
val addressSuffix: String = "", val addressSuffix: String = "",
val reuseProbeProduct: Boolean = false,
) )
data class PurchaseExecutionOutcome( data class PurchaseExecutionOutcome(
@@ -106,6 +112,10 @@ class PurchaseRehearsalExecutor(
private val stepChanged: (String) -> Unit = {}, private val stepChanged: (String) -> Unit = {},
private val panelDiagnostic: (String) -> Unit = {}, private val panelDiagnostic: (String) -> Unit = {},
private val beforeOrderSubmit: (FinalConfirmationEvidence) -> Unit = { throw PurchaseLiveException("PURCHASE_MODE_NOT_ALLOWED", "当前执行器没有正式采购授权") }, private val beforeOrderSubmit: (FinalConfirmationEvidence) -> Unit = { throw PurchaseLiveException("PURCHASE_MODE_NOT_ALLOWED", "当前执行器没有正式采购授权") },
private val probeClickDiagnostic: (AgentDiagnosticStage, SnapshotNode, FreshClickOutcome) -> Unit = { _, _, _ -> },
private val acceptedLaunchPath: () -> PddLaunchPath? = { null },
private val monotonicNow: () -> Long = { System.nanoTime() / 1_000_000L },
private val confirmProbeHandoff: () -> Boolean = { true },
) { ) {
private var purchasePanelContext: PurchasePanelContext? = null private var purchasePanelContext: PurchasePanelContext? = null
@@ -118,12 +128,9 @@ class PurchaseRehearsalExecutor(
val specSelectionProofs = mutableMapOf<String, ExactSpecSelectionProof>() val specSelectionProofs = mutableMapOf<String, ExactSpecSelectionProof>()
val live = PurchaseLiveAutomation(driver, pause, panelDiagnostic) val live = PurchaseLiveAutomation(driver, pause, panelDiagnostic)
for (action in rule.actions) { for (action in rule.actions) {
// The immediate phase-two handoff can reuse the PDD page retained by // Only the service's one-shot probe handoff permits reuse; fresh structure alone is insufficient.
// spec_probe. A later manual retry may start from Agent (or another
// unrelated screen), so only skip navigation when a fresh snapshot
// still carries safe PDD product/spec evidence.
if ( if (
input.phase == "purchase" && input.phase == "purchase" && input.reuseProbeProduct &&
action.type == PurchaseActionType.OPEN_PRODUCT && action.type == PurchaseActionType.OPEN_PRODUCT &&
canReuseCurrentProduct(input) canReuseCurrentProduct(input)
) continue ) continue
@@ -202,7 +209,7 @@ class PurchaseRehearsalExecutor(
private fun canReuseCurrentProduct(input: PurchaseExecutionInput): Boolean = private fun canReuseCurrentProduct(input: PurchaseExecutionInput): Boolean =
currentScreen(input).let { screen -> currentScreen(input).let { screen ->
screen.problem == null && screen.hasPurchaseProductEvidence() screen.problem == null && screen.hasPurchaseProductEvidence() && confirmProbeHandoff()
} }
private fun validateBeforeDeviceAction( private fun validateBeforeDeviceAction(
@@ -257,57 +264,38 @@ class PurchaseRehearsalExecutor(
private fun openProduct(input: PurchaseExecutionInput, action: PurchaseAction): PurchaseExecutionOutcome? { private fun openProduct(input: PurchaseExecutionInput, action: PurchaseAction): PurchaseExecutionOutcome? {
purchasePanelContext = null purchasePanelContext = null
val startedAt = monotonicNow()
if (!openLink(input.url)) return failure("PDD_LINK_INVALID", "任务中的 PDD 链接无法打开") if (!openLink(input.url)) return failure("PDD_LINK_INVALID", "任务中的 PDD 链接无法打开")
val readiness = PurchaseNavigationReadiness(startedAt, acceptedLaunchPath(), monotonicNow())
val aliases = action.textAliases ?: listOf("打开拼多多APP", "打开拼多多 App", "打开") val aliases = action.textAliases ?: listOf("打开拼多多APP", "打开拼多多 App", "打开")
var clickAttempted = false repeat(300) {
var nextClickPoll = 0 if (readiness.expired(monotonicNow())) return navigationTimeout()
var lastClickReason = FreshClickReason.UNKNOWN
var stableEvidenceReads = 0
var pddForegroundObserved = false
repeat(OPEN_PRODUCT_POLL_LIMIT) { poll ->
val snapshot = driver.capture() val snapshot = driver.capture()
if (readiness.expired(monotonicNow())) return navigationTimeout()
pageProblem(snapshot)?.let { return it } pageProblem(snapshot)?.let { return it }
if (snapshot.packageName == PDD_PACKAGE) { val screen = PddScreenParser.parse(snapshot, DEFAULT_COLLECTOR, input.goodsId, null)
pddForegroundObserved = true val key = if (screen.problem == null && screen.hasPurchaseProductEvidence()) ProductStabilityKey(
val screen = PddScreenParser.parse(snapshot, DEFAULT_COLLECTOR, input.goodsId, null) screen.summary.title, screen.specEntry != null, screen.quickConfirmationEntry != null, screen.specPanelOpen,
stableEvidenceReads = if (screen.hasPurchaseProductEvidence()) stableEvidenceReads + 1 else 0 ) else null
if (stableEvidenceReads >= PRODUCT_PAGE_STABLE_READS) return null if (readiness.observe(monotonicNow(), snapshot.packageName, key)) return null
pause(OPEN_PRODUCT_POLL_MILLIS) if (readiness.mayClickBrowser(monotonicNow(), snapshot.packageName)) {
return@repeat val candidates = snapshot.nodes.filter { it.visible && it.enabled && it.label in aliases }
} if (candidates.size > 1) return failure("RULE_AMBIGUOUS", "打开拼多多按钮不唯一")
stableEvidenceReads = 0 if (candidates.size == 1 && !readiness.expired(monotonicNow())) {
val candidates = snapshot.nodes.filter { it.visible && it.enabled && it.label in aliases } val outcome = driver.clickNavigationFresh(candidates.single()) { !readiness.expired(monotonicNow()) }
if (candidates.size > 1) return failure("RULE_AMBIGUOUS", "打开拼多多按钮不唯一") readiness.clicked(monotonicNow())
if (candidates.size == 1 && poll >= nextClickPoll) { if (readiness.expired(monotonicNow())) return navigationTimeout()
clickAttempted = true if (outcome.result == FreshActionResult.AMBIGUOUS) return failure("RULE_AMBIGUOUS", "打开拼多多按钮不唯一")
val outcome = driver.clickFreshDetailed(candidates.single())
lastClickReason = outcome.reason
if (outcome.result == FreshActionResult.AMBIGUOUS) {
return failure("RULE_AMBIGUOUS", "打开拼多多按钮不唯一")
} }
// The browser can rerender while handing the URL to PDD, and
// ACTION_CLICK may report false after the app transition has
// already started. The observed PDD foreground is the success
// condition; retry only this unique target after a short bound.
nextClickPoll = poll + OPEN_PRODUCT_RETRY_POLLS
} }
pause(OPEN_PRODUCT_POLL_MILLIS) if (readiness.expired(monotonicNow())) return navigationTimeout()
pause(100)
} }
if (pddForegroundObserved) { return navigationTimeout()
return failure("PDD_DETAIL_ENTRY_FAILED", "打开拼多多后未识别到稳定商品页面")
}
if (clickAttempted) {
val message = when (lastClickReason) {
FreshClickReason.ROOT_UNAVAILABLE, FreshClickReason.TARGET_NOT_FOUND -> "打开拼多多入口发生变化"
FreshClickReason.NO_CLICKABLE_ANCESTOR -> "打开拼多多入口暂时无法点击"
FreshClickReason.ACTION_CLICK_FALSE -> "打开拼多多后未进入商品页面"
else -> "打开拼多多失败"
}
return failure("RULE_ACTION_FAILED", message)
}
return failure("PDD_DETAIL_ENTRY_FAILED", "没有进入拼多多商品页面")
} }
private fun navigationTimeout() = failure("PDD_DETAIL_ENTRY_FAILED", "打开拼多多后未识别到稳定商品页面")
private fun verifyProduct(input: PurchaseExecutionInput): PurchaseExecutionOutcome? { private fun verifyProduct(input: PurchaseExecutionInput): PurchaseExecutionOutcome? {
var stableEvidenceReads = 0 var stableEvidenceReads = 0
repeat(PRODUCT_PAGE_POLL_LIMIT) { repeat(PRODUCT_PAGE_POLL_LIMIT) {
@@ -393,6 +381,10 @@ class PurchaseRehearsalExecutor(
} }
val beforeSignature = specActionSignature(screen) val beforeSignature = specActionSignature(screen)
val click = driver.clickFreshDetailed(requireNotNull(target)) 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) { when (click.result) {
// The parser already narrowed to a single semantic candidate; the // The parser already narrowed to a single semantic candidate; the
// ambiguity here comes from the live tree matching that target more // ambiguity here comes from the live tree matching that target more
@@ -414,7 +406,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( FreshActionResult.AMBIGUOUS -> return failure(
SPEC_ENTRY_TARGET_AMBIGUOUS, SPEC_ENTRY_TARGET_AMBIGUOUS,
"规格入口手势目标不唯一 [${specEntryEvidence(screen, 1, entryReadyWaitPolls)}]", "规格入口手势目标不唯一 [${specEntryEvidence(screen, 1, entryReadyWaitPolls)}]",
@@ -605,6 +604,7 @@ class PurchaseRehearsalExecutor(
val lookup = locateExactSpec(input, dimension, target) val lookup = locateExactSpec(input, dimension, target)
lookup.failure?.let { return it } lookup.failure?.let { return it }
val outcome = driver.clickFreshDetailed(requireNotNull(lookup.node)) val outcome = driver.clickFreshDetailed(requireNotNull(lookup.node))
if (outcome.result == FreshActionResult.BLOCKED) return sizeAdviceFailure()
if (outcome.result == FreshActionResult.AMBIGUOUS) { if (outcome.result == FreshActionResult.AMBIGUOUS) {
return failure(SPEC_TARGET_AMBIGUOUS, "精确规格匹配到多个控件") return failure(SPEC_TARGET_AMBIGUOUS, "精确规格匹配到多个控件")
} }
@@ -618,6 +618,7 @@ class PurchaseRehearsalExecutor(
val refreshedLookup = locateExactSpec(input, dimension, target) val refreshedLookup = locateExactSpec(input, dimension, target)
refreshedLookup.failure?.let { return it } refreshedLookup.failure?.let { return it }
val gesture = driver.tapSpecFresh(requireNotNull(refreshedLookup.node)) val gesture = driver.tapSpecFresh(requireNotNull(refreshedLookup.node))
if (gesture == FreshActionResult.BLOCKED) return sizeAdviceFailure()
if (gesture == FreshActionResult.AMBIGUOUS) { if (gesture == FreshActionResult.AMBIGUOUS) {
return failure(SPEC_TARGET_AMBIGUOUS, "精确规格手势目标不唯一") return failure(SPEC_TARGET_AMBIGUOUS, "精确规格手势目标不唯一")
} }
@@ -1165,6 +1166,8 @@ class PurchaseRehearsalExecutor(
private fun applyPostAction(input: PurchaseExecutionInput, action: PurchaseAction): PurchaseExecutionOutcome? { private fun applyPostAction(input: PurchaseExecutionInput, action: PurchaseAction): PurchaseExecutionOutcome? {
if (action.waitAfterMs > 0) pause(action.waitAfterMs) 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 -> action.swipeAfter?.let { swipe ->
val reveal = action.type == PurchaseActionType.OPEN_SPEC_PANEL val reveal = action.type == PurchaseActionType.OPEN_SPEC_PANEL
val initialScreen = if (reveal) currentScreen(input) else null val initialScreen = if (reveal) currentScreen(input) else null
@@ -1207,6 +1210,8 @@ class PurchaseRehearsalExecutor(
return PurchaseExecutionOutcome("spec_probe_completed", message = "商品规格已回传,等待服务端匹配", probedSpecs = result) return PurchaseExecutionOutcome("spec_probe_completed", message = "商品规格已回传,等待服务端匹配", probedSpecs = result)
} }
private fun sizeAdviceFailure() = failure("SIZE_ADVICE_CLICK_BLOCKED", "已阻止点击尺码建议入口")
private fun normalizedTarget(dimension: String, raw: String): String? { private fun normalizedTarget(dimension: String, raw: String): String? {
val trimmed = raw.trim() val trimmed = raw.trim()
if (dimension != "size") return trimmed.takeIf(String::isNotBlank) if (dimension != "size") return trimmed.takeIf(String::isNotBlank)
@@ -1247,10 +1252,8 @@ class PurchaseRehearsalExecutor(
companion object { companion object {
private const val PDD_PACKAGE = "com.xunmeng.pinduoduo" private const val PDD_PACKAGE = "com.xunmeng.pinduoduo"
private const val OPEN_PRODUCT_POLL_LIMIT = 150
private const val PRODUCT_PAGE_POLL_LIMIT = 150 private const val PRODUCT_PAGE_POLL_LIMIT = 150
private const val PRODUCT_PAGE_STABLE_READS = 2 private const val PRODUCT_PAGE_STABLE_READS = 2
private const val OPEN_PRODUCT_RETRY_POLLS = 10
private const val OPEN_PRODUCT_POLL_MILLIS = 100L private const val OPEN_PRODUCT_POLL_MILLIS = 100L
private const val SPEC_ENTRY_READY_WAIT_POLLS = 20 private const val SPEC_ENTRY_READY_WAIT_POLLS = 20
private const val SPEC_ENTRY_READY_POLL_MILLIS = 100L private const val SPEC_ENTRY_READY_POLL_MILLIS = 100L
@@ -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) 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 isSafeColor(value: String): Boolean = isSafeValue(value)
fun isSafeSize(value: String): Boolean = isSafeValue(value) fun isSafeSize(value: String): Boolean = isSafeValue(value)
@@ -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]"
}
}
@@ -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)
}
}
@@ -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("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;").replace("\"", "&quot;").replace("\n", "&#10;").replace("\r", "&#13;").replace("\t", "&#9;") + "\""
}
}
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))
}
@@ -100,8 +100,19 @@ data class PurchaseAgentTask(
val ruleSnapshot: String, val ruleSnapshot: String,
val ruleSnapshotHash: String, val ruleSnapshotHash: String,
val leaseVersion: Long, val leaseVersion: Long,
val attemptNumber: Int = 0,
) )
data class PurchaseResultAcknowledgement(val pending: Boolean, val replayed: Boolean) {
companion object {
fun from(data: JSONObject) = PurchaseResultAcknowledgement(
pending = data.optString("status") == "pending",
// The server deliberately omits replayed=false. A malformed explicit value is not a first response.
replayed = data.has("replayed") && data.opt("replayed") != false,
)
}
}
data class CollectionHistoryItem( data class CollectionHistoryItem(
val taskId: Long, val taskId: Long,
val attemptNumber: Int, val attemptNumber: Int,
@@ -211,6 +222,47 @@ class AgentApiException(
) : Exception(message) ) : Exception(message)
class AgentApiClient(private val serverUrl: String) { class AgentApiClient(private val serverUrl: String) {
@Volatile var lastResponseServerTimeMillis: Long? = null
private set
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> = 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)) parseBackfillResults(post("/api/agent/v1/purchase-tasks/order-backfill", backfillPayload(requestId, items), token))
@@ -485,9 +537,10 @@ class AgentApiClient(private val serverUrl: String) {
return purchaseTask(requireNotNull(request("POST", "/api/agent/v1/purchase-tasks/$taskId/order-submit-started", payload, token)).getJSONObject("data")) return purchaseTask(requireNotNull(request("POST", "/api/agent/v1/purchase-tasks/$taskId/order-submit-started", payload, token)).getJSONObject("data"))
} }
fun submitPurchaseResult(taskId: Long, payloadJson: String, token: String) { fun submitPurchaseResult(taskId: Long, payloadJson: String, token: String): PurchaseResultAcknowledgement {
val payload = JSONObject(payloadJson) val payload = JSONObject(payloadJson)
requireNotNull(request("POST", "/api/agent/v1/purchase-tasks/$taskId/result", payload, token)) val data = requireNotNull(request("POST", "/api/agent/v1/purchase-tasks/$taskId/result", payload, token)).getJSONObject("data")
return PurchaseResultAcknowledgement.from(data)
} }
fun collectionHistory(token: String, page: Int, status: String?, taskNo: String?, days: Int = 30, pageSize: Int = 20): HistoryPage<CollectionHistoryItem> { fun collectionHistory(token: String, page: Int, status: String?, taskNo: String?, days: Int = 30, pageSize: Int = 20): HistoryPage<CollectionHistoryItem> {
@@ -627,6 +680,7 @@ class AgentApiClient(private val serverUrl: String) {
private fun purchaseTask(data: JSONObject) = PurchaseAgentTask( private fun purchaseTask(data: JSONObject) = PurchaseAgentTask(
taskId = data.getLong("taskId"), taskId = data.getLong("taskId"),
taskAttemptId = data.optString("taskAttemptId"), taskAttemptId = data.optString("taskAttemptId"),
attemptNumber = data.optInt("attemptNumber"),
phase = data.optString("phase"), phase = data.optString("phase"),
executionMode = data.getString("executionMode"), executionMode = data.getString("executionMode"),
status = data.getString("status"), status = data.getString("status"),
@@ -670,6 +724,9 @@ class AgentApiClient(private val serverUrl: String) {
connection.outputStream.use { it.write(payload.toString().toByteArray(Charsets.UTF_8)) } connection.outputStream.use { it.write(payload.toString().toByteArray(Charsets.UTF_8)) }
} }
val status = connection.responseCode val status = connection.responseCode
// Metadata only: batch cutoff must use the API clock, never the phone's clock.
// A missing Date invalidates the sample instead of retaining an older response.
lastResponseServerTimeMillis = connection.getHeaderFieldDate("Date", 0L).takeIf { it > 0L }
if (status == HttpURLConnection.HTTP_NO_CONTENT) return null if (status == HttpURLConnection.HTTP_NO_CONTENT) return null
val stream = if (status in 200..299) connection.inputStream else connection.errorStream val stream = if (status in 200..299) connection.inputStream else connection.errorStream
val body = stream?.bufferedReader(Charsets.UTF_8)?.use { it.readText() }.orEmpty() val body = stream?.bufferedReader(Charsets.UTF_8)?.use { it.readText() }.orEmpty()
@@ -1,7 +1,31 @@
package cn.ilapage.goauto.agent.persistence package cn.ilapage.goauto.agent.persistence
internal object AgentDiagnosticSchema { internal object AgentDiagnosticSchema {
const val VERSION = 2 const val VERSION = 5
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( val colorDiagnosticColumns = linkedMapOf(
"color_row_count" to "INTEGER", "color_row_count" to "INTEGER",
@@ -14,6 +38,12 @@ internal object AgentDiagnosticSchema {
"horizontal_swipe_count" to "INTEGER", "horizontal_swipe_count" to "INTEGER",
) )
val colorDiscoveryOutcomeColumns = linkedMapOf(
"color_vertical_swipe_count" to "INTEGER",
"color_trailing_empty_read_count" to "INTEGER",
"color_horizontal_incomplete" to "INTEGER",
)
val createTableSql = val createTableSql =
"""CREATE TABLE agent_diagnostic ( """CREATE TABLE agent_diagnostic (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -42,6 +72,14 @@ internal object AgentDiagnosticSchema {
initial_selected_size_count INTEGER, initial_selected_size_count INTEGER,
selected_summary_present INTEGER, selected_summary_present INTEGER,
horizontal_swipe_count INTEGER, horizontal_swipe_count INTEGER,
color_vertical_swipe_count INTEGER,
color_trailing_empty_read_count INTEGER,
color_horizontal_incomplete INTEGER,
task_type TEXT,
task_attempt_id TEXT,
device_id INTEGER,
phase TEXT,
rule_snapshot_hash TEXT,
agent_version TEXT NOT NULL, agent_version TEXT NOT NULL,
created_at INTEGER NOT NULL created_at INTEGER NOT NULL
)""".trimIndent() )""".trimIndent()
@@ -52,4 +90,14 @@ internal object AgentDiagnosticSchema {
if (name in existingColumns) null else "ALTER TABLE agent_diagnostic ADD COLUMN $name $definition" 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()) +
(if (oldVersion < 5 && newVersion >= 5) colorDiscoveryOutcomeColumns.mapNotNull { (name, definition) ->
if (name in existingColumns) null else "ALTER TABLE agent_diagnostic ADD COLUMN $name $definition"
} else emptyList())
} }
@@ -7,6 +7,11 @@ import android.database.sqlite.SQLiteOpenHelper
import cn.ilapage.goauto.agent.BuildConfig import cn.ilapage.goauto.agent.BuildConfig
enum class AgentDiagnosticStage { enum class AgentDiagnosticStage {
PRODUCT_NAVIGATION,
SPEC_ENTRY_CLICK,
SPEC_ENTRY_GESTURE,
QUICK_CONFIRMATION_CLICK,
COLOR_CLICK,
DETAIL_ENTRY, DETAIL_ENTRY,
SPEC_PANEL_ENTRY, SPEC_PANEL_ENTRY,
COLOR_DISCOVERY, COLOR_DISCOVERY,
@@ -20,6 +25,13 @@ enum class AgentDiagnosticStage {
} }
enum class AgentDiagnosticReason { enum class AgentDiagnosticReason {
NAV_DIRECT_ACCEPTED,
NAV_DIRECT_REJECTED,
NAV_DIRECT_EXCEPTION,
NAV_BROWSER_ACCEPTED,
NAV_BROWSER_REJECTED,
NAV_BROWSER_EXCEPTION,
SIZE_ADVICE_CLICK_BLOCKED,
DETAIL_ENTRY_MATCHED, DETAIL_ENTRY_MATCHED,
SPEC_PANEL_OPENED, SPEC_PANEL_OPENED,
SPEC_PANEL_RECOVERED, SPEC_PANEL_RECOVERED,
@@ -104,6 +116,17 @@ data class AgentDiagnosticEvent(
val selectedSummaryPresent: Boolean? = null, val selectedSummaryPresent: Boolean? = null,
val horizontalSwipeCount: Int? = null, val horizontalSwipeCount: Int? = null,
val createdAt: Long = System.currentTimeMillis(), 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,
/** Successfully dispatched UP swipes during this color discovery; null means unknown. */
val colorVerticalSwipeCount: Int? = null,
/** Consecutive empty parsed-color discovery rounds at termination, excluding click/price reads. */
val colorTrailingEmptyReadCount: Int? = null,
/** Required horizontal exploration still unresolved at termination; not needed or recovered is false. */
val colorHorizontalIncomplete: Boolean? = null,
) )
class SafeAgentDiagnosticRecorder( class SafeAgentDiagnosticRecorder(
@@ -126,11 +149,12 @@ class AgentDiagnosticStore(context: Context) : SQLiteOpenHelper(context, DATABAS
override fun onCreate(db: SQLiteDatabase) { override fun onCreate(db: SQLiteDatabase) {
db.execSQL(AgentDiagnosticSchema.createTableSql) db.execSQL(AgentDiagnosticSchema.createTableSql)
db.execSQL("CREATE INDEX idx_agent_diagnostic_task ON agent_diagnostic(task_id, id)") 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) { override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) {
val existing = columnNames(db) 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> = private fun columnNames(db: SQLiteDatabase): Set<String> =
@@ -144,11 +168,22 @@ class AgentDiagnosticStore(context: Context) : SQLiteOpenHelper(context, DATABAS
@Synchronized @Synchronized
fun record(event: AgentDiagnosticEvent) { fun record(event: AgentDiagnosticEvent) {
require(event.taskId > 0) 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 val db = writableDatabase
db.beginTransaction() db.beginTransaction()
try { try {
val values = ContentValues().apply { val values = ContentValues().apply {
put("task_id", event.taskId) 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("stage", event.stage.name)
put("reason", event.reason.name) put("reason", event.reason.name)
put("attempt", event.attempt.coerceAtLeast(0)) put("attempt", event.attempt.coerceAtLeast(0))
@@ -175,10 +210,16 @@ class AgentDiagnosticStore(context: Context) : SQLiteOpenHelper(context, DATABAS
putNullableInt("initial_selected_size_count", event.initialSelectedSizeCount) putNullableInt("initial_selected_size_count", event.initialSelectedSizeCount)
putNullableBoolean("selected_summary_present", event.selectedSummaryPresent) putNullableBoolean("selected_summary_present", event.selectedSummaryPresent)
putNullableInt("horizontal_swipe_count", event.horizontalSwipeCount) putNullableInt("horizontal_swipe_count", event.horizontalSwipeCount)
putNullableInt("color_vertical_swipe_count", event.colorVerticalSwipeCount)
putNullableInt("color_trailing_empty_read_count", event.colorTrailingEmptyReadCount)
putNullableBoolean("color_horizontal_incomplete", event.colorHorizontalIncomplete)
put("agent_version", BuildConfig.VERSION_NAME) put("agent_version", BuildConfig.VERSION_NAME)
put("created_at", event.createdAt) 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.delete("agent_diagnostic", "created_at < ?", arrayOf(AgentDiagnosticRetentionPolicy.cutoff(event.createdAt).toString()))
db.execSQL( db.execSQL(
"DELETE FROM agent_diagnostic WHERE id NOT IN " + "DELETE FROM agent_diagnostic WHERE id NOT IN " +
@@ -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")
}
}
@@ -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)
}
@@ -5,11 +5,13 @@ class PurchaseOutboxUploader(
private val pending: () -> List<PendingPurchaseOutbox>, private val pending: () -> List<PendingPurchaseOutbox>,
private val submit: (PendingPurchaseOutbox) -> Unit, private val submit: (PendingPurchaseOutbox) -> Unit,
private val markUploaded: (PendingPurchaseOutbox) -> Unit, private val markUploaded: (PendingPurchaseOutbox) -> Unit,
private val afterUploaded: (PendingPurchaseOutbox) -> Unit = {},
) { ) {
fun flush() { fun flush() {
pending().forEach { item -> pending().forEach { item ->
submit(item) submit(item)
markUploaded(item) markUploaded(item)
afterUploaded(item)
} }
} }
} }
@@ -22,6 +22,7 @@ import cn.ilapage.goauto.agent.ClipboardRelayActivity
import cn.ilapage.goauto.agent.MainActivity import cn.ilapage.goauto.agent.MainActivity
import cn.ilapage.goauto.agent.R import cn.ilapage.goauto.agent.R
import cn.ilapage.goauto.agent.automation.CollectionAssembler 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.AgentCapabilities
import cn.ilapage.goauto.agent.automation.CurrentPageIdentityRunner import cn.ilapage.goauto.agent.automation.CurrentPageIdentityRunner
import cn.ilapage.goauto.agent.automation.CurrentPageLaunchResult import cn.ilapage.goauto.agent.automation.CurrentPageLaunchResult
@@ -40,6 +41,11 @@ import cn.ilapage.goauto.agent.automation.CollectionRule
import cn.ilapage.goauto.agent.automation.PurchaseAgentCapabilities import cn.ilapage.goauto.agent.automation.PurchaseAgentCapabilities
import cn.ilapage.goauto.agent.automation.PurchaseSpecProbePolicy import cn.ilapage.goauto.agent.automation.PurchaseSpecProbePolicy
import cn.ilapage.goauto.agent.automation.PurchaseExecutionInput import cn.ilapage.goauto.agent.automation.PurchaseExecutionInput
import cn.ilapage.goauto.agent.automation.PurchaseProbeHandoff
import cn.ilapage.goauto.agent.automation.PddLaunchPath
import cn.ilapage.goauto.agent.automation.PddLaunchStatus
import cn.ilapage.goauto.agent.network.PurchaseResultAcknowledgement
import cn.ilapage.goauto.agent.persistence.PendingPurchaseOutbox
import cn.ilapage.goauto.agent.automation.PurchaseExecutionOutcome import cn.ilapage.goauto.agent.automation.PurchaseExecutionOutcome
import cn.ilapage.goauto.agent.automation.PurchaseLiveAutomation import cn.ilapage.goauto.agent.automation.PurchaseLiveAutomation
import cn.ilapage.goauto.agent.automation.PurchaseRehearsalExecutor import cn.ilapage.goauto.agent.automation.PurchaseRehearsalExecutor
@@ -61,6 +67,11 @@ import cn.ilapage.goauto.agent.persistence.AgentDiagnosticEvent
import cn.ilapage.goauto.agent.persistence.AgentDiagnosticReason import cn.ilapage.goauto.agent.persistence.AgentDiagnosticReason
import cn.ilapage.goauto.agent.persistence.AgentDiagnosticStage import cn.ilapage.goauto.agent.persistence.AgentDiagnosticStage
import cn.ilapage.goauto.agent.persistence.SafeAgentDiagnosticRecorder 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 cn.ilapage.goauto.agent.ui.PurchaseResultBubblePolicy
import org.json.JSONArray import org.json.JSONArray
import org.json.JSONObject import org.json.JSONObject
@@ -78,7 +89,14 @@ class AgentForegroundService : Service() {
private val executor: ScheduledExecutorService = Executors.newSingleThreadScheduledExecutor() private val executor: ScheduledExecutorService = Executors.newSingleThreadScheduledExecutor()
private val taskExecutor: ExecutorService = Executors.newSingleThreadExecutor() private val taskExecutor: ExecutorService = Executors.newSingleThreadExecutor()
private val diagnosticExecutor: ExecutorService = Executors.newSingleThreadExecutor() private val diagnosticExecutor: ExecutorService = Executors.newSingleThreadExecutor()
private val snapshotUploadExecutor: ScheduledExecutorService = Executors.newSingleThreadScheduledExecutor()
private val repurchaseExecutor: ScheduledExecutorService = Executors.newSingleThreadScheduledExecutor()
private val repurchaseClosed = AtomicBoolean(false)
private val repurchase = RepurchaseCoordinator(SystemClock::elapsedRealtime, { UUID.randomUUID().toString() }, ::publishRepurchase)
private var repurchaseSession: RepurchaseSession? = null
private val repurchaseEvidence = java.util.concurrent.ConcurrentHashMap<Long, java.util.concurrent.ConcurrentHashMap<Long, RepurchaseExecutionEvidence>>()
private val taskMutex = TaskExecutionMutex() private val taskMutex = TaskExecutionMutex()
private val probeHandoff = PurchaseProbeHandoff()
private val backfillGuard = OrderBackfillGuard(taskMutex) private val backfillGuard = OrderBackfillGuard(taskMutex)
private val runningTaskId = AtomicReference<Long?>(null) private val runningTaskId = AtomicReference<Long?>(null)
private val working = AtomicBoolean(false) private val working = AtomicBoolean(false)
@@ -98,6 +116,7 @@ class AgentForegroundService : Service() {
private lateinit var purchaseStore: PurchaseTaskStore private lateinit var purchaseStore: PurchaseTaskStore
private lateinit var diagnosticStore: AgentDiagnosticStore private lateinit var diagnosticStore: AgentDiagnosticStore
private lateinit var diagnosticRecorder: SafeAgentDiagnosticRecorder private lateinit var diagnosticRecorder: SafeAgentDiagnosticRecorder
private lateinit var failureSnapshotStore: PurchaseFailureSnapshotStore
private lateinit var connectivityManager: ConnectivityManager private lateinit var connectivityManager: ConnectivityManager
private val networkCallback = object : ConnectivityManager.NetworkCallback() { private val networkCallback = object : ConnectivityManager.NetworkCallback() {
@@ -111,18 +130,20 @@ class AgentForegroundService : Service() {
identityStore = SecureDeviceStore(this) identityStore = SecureDeviceStore(this)
settingsStore = AgentSettingsStore(this) settingsStore = AgentSettingsStore(this)
stateStore = AgentStateStore(this) stateStore = AgentStateStore(this)
repurchaseState = RepurchaseState()
// Wake locks and cooldown tickets are process-local. Never restore a stale UI flag. // Wake locks and cooldown tickets are process-local. Never restore a stale UI flag.
stateStore.setKeepScreenOn(false) stateStore.setKeepScreenOn(false)
purchaseStore = PurchaseTaskStore(this) purchaseStore = PurchaseTaskStore(this)
diagnosticStore = AgentDiagnosticStore(this) diagnosticStore = AgentDiagnosticStore(this)
failureSnapshotStore = PurchaseFailureSnapshotStore(diagnosticStore)
diagnosticRecorder = SafeAgentDiagnosticRecorder( diagnosticRecorder = SafeAgentDiagnosticRecorder(
persist = { event -> persist = { event ->
diagnosticExecutor.execute { diagnosticExecutor.execute {
runCatching { diagnosticStore.record(event) } 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() val restoredPurchaseTaskId = purchaseStore.activeTaskId()
runningTaskId.set(restoredPurchaseTaskId) runningTaskId.set(restoredPurchaseTaskId)
@@ -134,9 +155,32 @@ class AgentForegroundService : Service() {
registerNetworkCallback() registerNetworkCallback()
resumeCollectionCooldown() resumeCollectionCooldown()
executor.scheduleWithFixedDelay(::triggerSync, 0, HEARTBEAT_SECONDS, TimeUnit.SECONDS) 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)
// Separate, serialized I/O ticks keep history reads and result observation off the
// heartbeat scheduler and, especially, off the single purchase execution thread.
repurchaseExecutor.scheduleWithFixedDelay(::tickRepurchase, 2, 2, TimeUnit.SECONDS)
diagnosticInstance = this
} }
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
when (intent?.action) {
ACTION_REPURCHASE_PREPARE -> {
repurchaseExecutor.execute(::prepareRepurchase)
return START_STICKY
}
ACTION_REPURCHASE_CONFIRM -> {
val roundId = intent.getStringExtra("roundId").orEmpty()
repurchaseExecutor.execute { repurchase.confirm(roundId); tickRepurchase() }
return START_STICKY
}
ACTION_REPURCHASE_STOP -> {
// Atomic: a network request must not delay the user's stop signal.
repurchase.requestStop(intent.getStringExtra("roundId").orEmpty())
repurchaseExecutor.execute(::tickRepurchase)
return START_STICKY
}
}
if (intent?.action == ACTION_BACKFILL_STOP) { if (intent?.action == ACTION_BACKFILL_STOP) {
backfillGuard.cancelled.set(true) backfillGuard.cancelled.set(true)
return START_STICKY return START_STICKY
@@ -160,6 +204,12 @@ class AgentForegroundService : Service() {
} }
override fun onDestroy() { override fun onDestroy() {
probeHandoff.invalidate()
if (diagnosticInstance === this) diagnosticInstance = null
repurchaseClosed.set(true)
repurchase.requestStop(repurchase.state.roundId)
repurchaseExecutor.shutdownNow()
repurchaseState = RepurchaseState(message = "服务已停止;不会恢复整轮重购")
backfillGuard.cancelled.set(true) backfillGuard.cancelled.set(true)
runCatching { connectivityManager.unregisterNetworkCallback(networkCallback) } runCatching { connectivityManager.unregisterNetworkCallback(networkCallback) }
cancelIdleReturn("服务已停止") cancelIdleReturn("服务已停止")
@@ -171,6 +221,7 @@ class AgentForegroundService : Service() {
diagnosticExecutor.shutdown() diagnosticExecutor.shutdown()
runCatching { diagnosticExecutor.awaitTermination(1, TimeUnit.SECONDS) } runCatching { diagnosticExecutor.awaitTermination(1, TimeUnit.SECONDS) }
diagnosticExecutor.shutdownNow() diagnosticExecutor.shutdownNow()
snapshotUploadExecutor.shutdownNow()
purchaseStore.close() purchaseStore.close()
diagnosticStore.close() diagnosticStore.close()
super.onDestroy() super.onDestroy()
@@ -178,6 +229,30 @@ class AgentForegroundService : Service() {
override fun onBind(intent: Intent?): IBinder? = null override fun onBind(intent: Intent?): IBinder? = null
/** Shell-protected debug receiver only. Never claims/resets a server task or invokes a purchase executor. */
internal fun runNavigationProbe(probe: (Context) -> Unit): Boolean {
if (!BuildConfig.DEBUG) return false
val reservation = Long.MAX_VALUE - 2
val acquired = synchronized(taskMutex) {
!repurchaseClosed.get() && repurchaseLocalIdle() && !repurchaseState.busy &&
activeCollectionCooldown() == null && taskMutex.tryAcquire(reservation)
}
if (!acquired) return false
probeHandoff.beginExecution()
cancelIdleReturn("导航诊断")
try {
taskExecutor.execute {
try { probe(this) }
catch (error: Exception) { Log.w("GoAutoNavProbe", "probe_exception=${error.javaClass.name}") }
finally { taskMutex.release(reservation) }
}
} catch (error: java.util.concurrent.RejectedExecutionException) {
taskMutex.release(reservation)
return false
}
return true
}
private fun triggerSync() { private fun triggerSync() {
if (!synchronized(taskMutex) { working.compareAndSet(false, true) }) return if (!synchronized(taskMutex) { working.compareAndSet(false, true) }) return
executor.execute { executor.execute {
@@ -321,6 +396,110 @@ class AgentForegroundService : Service() {
}) })
} }
private data class RepurchaseSession(val origin: String, val deviceId: Long, val token: String)
private fun publishRepurchase(state: RepurchaseState) {
if (repurchaseClosed.get()) return
repurchaseState = state
sendBroadcast(Intent(ACTION_REPURCHASE_STATE).setPackage(packageName))
updateNotification(state.message, force = true)
}
private fun repurchaseLocalIdle(): Boolean = !working.get() && taskMutex.currentTaskId() == null &&
purchaseStore.activeTaskId() == null && purchaseStore.pendingOutbox().isEmpty() &&
stateStore.read().currentTaskId == null && !backfillState.running
private fun repurchaseEnvironment(session: RepurchaseSession): String? {
if (repurchaseClosed.get()) return "服务已经停止"
val credentials = identityStore.credentials()
if (credentials?.deviceId != session.deviceId || credentials.token != session.token ||
ServerUrlPolicy.normalize(settingsStore.serverUrl()) != session.origin) return "设备身份或服务器配置已变化,请重新连接"
if (!registeredThisProcess.get()) return "设备尚未完成连接校验,请先测试连接"
if (GoAutoAccessibilityService.instance == null) return "请先开启无障碍服务"
val network = connectivityManager.activeNetwork ?: return "网络不可用"
if (connectivityManager.getNetworkCapabilities(network)?.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) != true) return "网络不可用"
return null
}
private fun prepareRepurchase() {
if (repurchaseClosed.get() || !repurchase.prepare()) return
repurchaseEvidence.clear()
try {
val credentials = identityStore.credentials() ?: error("missing credentials")
val session = RepurchaseSession(ServerUrlPolicy.normalize(settingsStore.serverUrl()), credentials.deviceId, credentials.token)
repurchaseSession = session
repurchaseEnvironment(session)?.let { repurchase.fail(it); return }
val port = repurchasePort(session)
if (!repurchaseLocalIdle() || port.hasOtherWork(null)) {
repurchase.fail("设备已有未结束工作或结果上传尚未收尾,请完成后再重购")
return
}
// Discovery owns its client. No other request can overwrite its page's Date sample.
val api = AgentApiClient(session.origin)
val batch = RepurchaseBatchDiscovery({ query ->
check(!repurchase.isStopRequested() && !repurchaseClosed.get())
check(repurchaseEnvironment(session) == null)
api.purchaseHistory(session.token, query.page, query.status, query.taskNo, query.days, query.pageSize)
}, { requireNotNull(api.lastResponseServerTimeMillis) }).discover()
repurchaseEnvironment(session)?.let { repurchase.fail(it); return }
if (!repurchaseLocalIdle() || port.hasOtherWork(null)) {
repurchase.fail("读取期间出现新的采集或采购工作,请完成后重新读取")
return
}
repurchase.prepared(batch)
} catch (error: RepurchaseBatchException) {
repurchase.fail(if (repurchase.isStopRequested()) "已停止读取,没有发起重购" else when (error.reason) {
RepurchaseBatchFailure.LIST_CHANGED -> "采购列表已变化,请重新读取"
RepurchaseBatchFailure.PAGE_FAILED -> "读取采购记录失败,请检查连接后重试"
else -> "无法完整确定最后一批采购记录,请检查时间或记录后重试(${error.reason})"
})
} catch (_: Exception) {
repurchase.fail("设备连接或记录读取失败,请恢复后重试")
}
}
private fun tickRepurchase() {
if (repurchaseClosed.get() || !repurchase.state.busy) return
val session = repurchaseSession ?: return
val previousTaskId = repurchase.state.currentTaskId
repurchase.tick(repurchasePort(session))
// Reset only makes the original task pending. The original dispatcher owns all
// claim/start/automation/upload steps; the round never executes a PDD action.
if (repurchase.state.currentTaskId != null && repurchase.state.currentTaskId != previousTaskId) triggerSync()
}
private fun repurchasePort(session: RepurchaseSession): RepurchasePort = object : RepurchasePort {
private val api = AgentApiClient(session.origin)
override fun environmentProblem() = repurchaseEnvironment(session)
override fun localIdle() = repurchaseLocalIdle()
override fun latestTaskId() = api.purchaseHistory(session.token, 1, null, null, 30, 1).items.firstOrNull()?.taskId
override fun detail(taskId: Long) = api.purchaseHistoryDetail(taskId, session.token)
override fun executionEvidence(taskId: Long) = repurchaseEvidence[taskId]?.values?.toList().orEmpty()
override fun hasOtherWork(currentTaskId: Long?): Boolean {
if (api.nextTask(session.token) != null) return true // collection IDs are a separate namespace
val next = api.nextPurchaseTask(session.token)
if (next != null && next.taskId != currentTaskId) return true
// /next returns the current running/probing purchase first and can conceal new pending work.
return listOf("pending", "running", "spec_probe_pending", "order_submit_started").any { status ->
val page = api.purchaseHistory(session.token, 1, status, null, 30, 50)
page.items.any { it.taskId != currentTaskId } || page.total > page.items.size
}
}
override fun reset(taskId: Long, requestId: String): cn.ilapage.goauto.agent.network.PurchaseResetResult {
val acquired = synchronized(taskMutex) {
!working.get() && taskMutex.tryAcquire(REPURCHASE_RESERVATION_ID)
}
if (!acquired) throw RepurchaseLocalBusy()
try {
if (environmentProblem() != null || repurchase.isStopRequested() || repurchaseClosed.get() ||
purchaseStore.activeTaskId() != null || purchaseStore.pendingOutbox().isNotEmpty()) {
throw AgentApiException(409, "DEVICE_BUSY", "本地工作边界变化", false)
}
return api.resetPurchaseTask(taskId, requestId, session.token)
} finally { taskMutex.release(REPURCHASE_RESERVATION_ID) }
}
}
private fun publishBackfill(state: OrderBackfillState) { private fun publishBackfill(state: OrderBackfillState) {
backfillState = state backfillState = state
sendBroadcast(Intent(ACTION_BACKFILL_STATE).setPackage(packageName)) sendBroadcast(Intent(ACTION_BACKFILL_STATE).setPackage(packageName))
@@ -338,6 +517,7 @@ class AgentForegroundService : Service() {
} }
try { try {
val window = cn.ilapage.goauto.agent.automation.OrderBackfillWindow(days, confirmedAt) val window = cn.ilapage.goauto.agent.automation.OrderBackfillWindow(days, confirmedAt)
probeHandoff.beginExecution()
val server = settingsStore.serverUrl() val server = settingsStore.serverUrl()
val credentials = identityStore.credentials() ?: error("设备尚未注册") val credentials = identityStore.credentials() ?: error("设备尚未注册")
val accessibility = GoAutoAccessibilityService.instance ?: error("请先启用无障碍服务") val accessibility = GoAutoAccessibilityService.instance ?: error("请先启用无障碍服务")
@@ -432,6 +612,7 @@ class AgentForegroundService : Service() {
return return
} }
cancelIdleReturn("正在创建当前页面采集任务") cancelIdleReturn("正在创建当前页面采集任务")
probeHandoff.beginExecution()
taskExecutor.execute { taskExecutor.execute {
var taskId = 0L var taskId = 0L
try { try {
@@ -573,10 +754,26 @@ class AgentForegroundService : Service() {
} }
private fun executePurchaseTask(api: AgentApiClient, initial: PurchaseAgentTask, token: String) { private fun executePurchaseTask(api: AgentApiClient, initial: PurchaseAgentTask, token: String) {
// Take and clear before claim/start or any eligibility checks. Every execution consumes the previous handoff.
val handoffExecution = probeHandoff.beginExecution()
var probeCompletedAt: Long? = null
var probeAccessibility: GoAutoAccessibilityService? = null
var probeForegroundRevision = -1L
var acceptedPath: PddLaunchPath? = null
GoAutoAccessibilityService.instance?.dismissPurchaseResultBubble() GoAutoAccessibilityService.instance?.dismissPurchaseResultBubble()
acquireTaskWakeLock() acquireTaskWakeLock()
var resultSafelyStored = false var resultSafelyStored = false
var knownResultType: String? = null
var snapshotContext: SnapshotContext? = null
var executionEntered = false
var snapshotAttempted = false
var probeDiagnosticEvents = 0
val lastStep = AtomicReference("started") 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 { try {
val claimed = if (initial.status == "pending") { val claimed = if (initial.status == "pending") {
api.claimPurchaseTask(initial.taskId, UUID.randomUUID().toString(), token) api.claimPurchaseTask(initial.taskId, UUID.randomUUID().toString(), token)
@@ -585,10 +782,26 @@ class AgentForegroundService : Service() {
api.startPurchaseTask(claimed.taskId, UUID.randomUUID().toString(), token) api.startPurchaseTask(claimed.taskId, UUID.randomUUID().toString(), token)
} else claimed } else claimed
check(task.status == "running" && task.taskAttemptId.isNotBlank()) { "采购任务没有有效 attempt" } check(task.status == "running" && task.taskAttemptId.isNotBlank()) { "采购任务没有有效 attempt" }
recordRepurchaseExecution(task, null)
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 snapshotHashValid = task.ruleSnapshotHash.matches(Regex("^[0-9a-f]{64}$"))
val snapshotHash = task.ruleSnapshotHash.takeIf { snapshotHashValid } ?: "0".repeat(64) val snapshotHash = task.ruleSnapshotHash.takeIf { snapshotHashValid } ?: "0".repeat(64)
purchaseStore.recordRunning(task.taskId, task.taskAttemptId, snapshotHash) purchaseStore.recordRunning(task.taskId, task.taskAttemptId, snapshotHash)
val taskLabel = if (task.executionMode == "live") "正式采购" else "采购演练" 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) stateStore.update("BUSY", "正在执行${taskLabel}任务 #${task.taskId}", tokenStored = true)
updateNotification("$taskLabel #${task.taskId}") updateNotification("$taskLabel #${task.taskId}")
@@ -611,9 +824,40 @@ class AgentForegroundService : Service() {
} else { } else {
PurchaseRehearsalExecutor( PurchaseRehearsalExecutor(
driver = accessibility, driver = accessibility,
openLink = { PddLinkLauncher(this).open(it, preferDirect = true) }, openLink = { url ->
probeSpecs = { collectPurchaseProbe(accessibility, task, parsedRule) }, probeHandoff.invalidate()
acceptedPath = null
val navigationStarted = SystemClock.elapsedRealtime()
PddLinkLauncher(this).open(url, preferDirect = true) { event ->
if (event.status == PddLaunchStatus.ACCEPTED) acceptedPath = event.path
probeDiagnostic(AgentDiagnosticEvent(
taskId = task.taskId,
stage = AgentDiagnosticStage.PRODUCT_NAVIGATION,
reason = AgentDiagnosticReason.valueOf("NAV_${event.path}_${event.status}"),
elapsedMs = SystemClock.elapsedRealtime() - navigationStarted,
targetClassName = event.exceptionClass,
))
}
},
acceptedLaunchPath = { acceptedPath },
monotonicNow = SystemClock::elapsedRealtime,
confirmProbeHandoff = {
val current = GoAutoAccessibilityService.instance
probeHandoff.stillValid(handoffExecution, current,
current?.currentForegroundRevision() ?: -1L, SystemClock.elapsedRealtime())
},
probeSpecs = {
collectPurchaseProbe(accessibility, task, parsedRule, probeDiagnostic).also {
probeCompletedAt = SystemClock.elapsedRealtime()
probeAccessibility = accessibility
probeForegroundRevision = accessibility.currentForegroundRevision()
}
},
probeClickDiagnostic = { stage, target, outcome ->
probeDiagnostic(specClickDiagnostic(task.taskId, stage, target, outcome))
},
stepChanged = { step -> stepChanged = { step ->
executionEntered = true
lastStep.set(step) lastStep.set(step)
purchaseStore.updateStep(task.taskId, task.taskAttemptId, step) purchaseStore.updateStep(task.taskId, task.taskAttemptId, step)
}, },
@@ -646,6 +890,10 @@ class AgentForegroundService : Service() {
minUnitPriceCent = task.minUnitPriceCent, minUnitPriceCent = task.minUnitPriceCent,
maxUnitPriceCent = task.maxUnitPriceCent, maxUnitPriceCent = task.maxUnitPriceCent,
addressSuffix = task.addressSuffix, addressSuffix = task.addressSuffix,
reuseProbeProduct = probeHandoff.eligible(
handoffExecution, task.taskId, task.pddGoodsId, task.phase, task.taskAttemptId,
accessibility, accessibility.currentForegroundRevision(), SystemClock.elapsedRealtime(),
),
), ),
parsedRule, parsedRule,
PurchaseAgentCapabilities.supported, PurchaseAgentCapabilities.supported,
@@ -656,6 +904,10 @@ class AgentForegroundService : Service() {
// #334: a spec probe that read zero colors/sizes must fail explicitly // #334: a spec probe that read zero colors/sizes must fail explicitly
// instead of being reported as a normal, empty spec_probe_completed. // instead of being reported as a normal, empty spec_probe_completed.
val outcome = PurchaseSpecProbePolicy.demote(rawOutcome) val outcome = PurchaseSpecProbePolicy.demote(rawOutcome)
recordRepurchaseExecution(task, outcome.resultType)
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 requestId = UUID.randomUUID().toString()
val payload = purchaseResultPayload(requestId, task.taskAttemptId, outcome) val payload = purchaseResultPayload(requestId, task.taskAttemptId, outcome)
purchaseStore.completeAndEnqueue(task.taskId, task.taskAttemptId, requestId, payload) purchaseStore.completeAndEnqueue(task.taskId, task.taskAttemptId, requestId, payload)
@@ -669,21 +921,48 @@ class AgentForegroundService : Service() {
GoAutoAccessibilityService.instance?.showPurchaseResultBubble(presentation) GoAutoAccessibilityService.instance?.showPurchaseResultBubble(presentation)
} }
beginIdleReturnCooldown() beginIdleReturnCooldown()
flushPurchaseOutbox(api, token) // Only this first synchronous upload may establish a handoff. Outbox replays have no callback.
flushPurchaseOutbox(api, token) { item, acknowledgement ->
val completedAt = probeCompletedAt
val accessibility = probeAccessibility
if (item.requestId == requestId && item.attemptId == task.taskAttemptId && completedAt != null &&
accessibility != null && GoAutoAccessibilityService.instance === accessibility &&
accessibility.currentPackage() == "com.xunmeng.pinduoduo" &&
accessibility.currentForegroundRevision() == probeForegroundRevision) {
probeHandoff.completeProbe(
handoffExecution, task.taskId, task.pddGoodsId, task.taskAttemptId, completedAt,
accessibility, probeForegroundRevision,
successful = task.phase == "spec_probe" && outcome.resultType == "spec_probe_completed",
safelyStored = resultSafelyStored, submitPending = acknowledgement.pending, replayed = acknowledgement.replayed,
)
}
}
val message = if (outcome.resultType == "failed") "${outcome.errorCode}:${outcome.message}" else outcome.message val message = if (outcome.resultType == "failed") "${outcome.errorCode}:${outcome.message}" else outcome.message
stateStore.update(if (outcome.resultType == "failed") "TASK_ERROR" else "ONLINE", message, tokenStored = true) 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} 已提交") updateNotification(if (outcome.resultType == "failed") "$taskLabel #${task.taskId} 失败" else "$taskLabel #${task.taskId} 已提交")
} catch (error: AgentApiException) { } 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) stateStore.update("TASK_ERROR", "${error.code}:${error.message}", tokenStored = true)
} catch (error: Exception) { } catch (error: Exception) {
failureSnapshot("failed", "PURCHASE_EXECUTION_EXCEPTION")
stateStore.update("TASK_ERROR", error.message ?: "采购演练执行异常", tokenStored = true) stateStore.update("TASK_ERROR", error.message ?: "采购演练执行异常", tokenStored = true)
} finally { } finally {
if (snapshotContext?.phase == "spec_probe" && probeDiagnosticEvents == 0) Log.i("GoAutoDiagnostic", "purchase_diagnostic_no_event")
if (!resultSafelyStored) cancelIdleReturn("采购结果未安全保存") if (!resultSafelyStored) cancelIdleReturn("采购结果未安全保存")
releaseTaskWakeLock() releaseTaskWakeLock()
} }
} }
private fun collectPurchaseProbe(accessibility: GoAutoAccessibilityService, task: PurchaseAgentTask, purchaseRule: PurchaseRule): String? { private fun recordRepurchaseExecution(task: PurchaseAgentTask, resultType: String?) {
if (!repurchaseState.busy || repurchaseState.currentTaskId != task.taskId || task.attemptNumber <= 0) return
val taskEvidence = repurchaseEvidence.getOrPut(task.taskId) { java.util.concurrent.ConcurrentHashMap() }
taskEvidence[task.attemptNumber.toLong()] = RepurchaseExecutionEvidence(task.attemptNumber.toLong(), task.taskAttemptId, task.phase, resultType)
}
private fun collectPurchaseProbe(
accessibility: GoAutoAccessibilityService, task: PurchaseAgentTask, purchaseRule: PurchaseRule,
diagnostic: (AgentDiagnosticEvent) -> Unit,
): String? {
val snapshot = accessibility.capture() val snapshot = accessibility.capture()
val activity = snapshot.activityName ?: return null val activity = snapshot.activityName ?: return null
val collector = PurchaseRehearsalExecutor.collectorForProbe(purchaseRule) val collector = PurchaseRehearsalExecutor.collectorForProbe(purchaseRule)
@@ -694,7 +973,8 @@ class AgentForegroundService : Service() {
pageEvidence = PageEvidence("com.xunmeng.pinduoduo", activity, NodeSelector()), pageEvidence = PageEvidence("com.xunmeng.pinduoduo", activity, NodeSelector()),
collector = collector, 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 // #334: a collector failure (e.g. SPEC_PANEL_TOP_COLLAPSED) must not
// collapse into a generic "spec probe failed" outcome. Carry the // collapse into a generic "spec probe failed" outcome. Carry the
// real code/message through the opaque probeSpecs callback so // real code/message through the opaque probeSpecs callback so
@@ -720,12 +1000,17 @@ class AgentForegroundService : Service() {
private fun recoverInterruptedPurchases(api: AgentApiClient, token: String) { private fun recoverInterruptedPurchases(api: AgentApiClient, token: String) {
purchaseStore.interruptedAttempts().forEach { interrupted -> purchaseStore.interruptedAttempts().forEach { interrupted ->
probeHandoff.beginExecution()
if (interrupted.status == PurchaseTaskStore.STATUS_ORDER_SUBMIT_STARTED) { if (interrupted.status == PurchaseTaskStore.STATUS_ORDER_SUBMIT_STARTED) {
val boundaryRequestId = interrupted.orderSubmitRequestId ?: return@forEach val boundaryRequestId = interrupted.orderSubmitRequestId ?: return@forEach
try { try {
api.markPurchaseOrderSubmitStarted(interrupted.taskId, boundaryRequestId, token) api.markPurchaseOrderSubmitStarted(interrupted.taskId, boundaryRequestId, token)
val automation = GoAutoAccessibilityService.instance?.let(::PurchaseLiveAutomation) 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 outcome = if (evidence == null) {
val readFailure = automation?.lastOrderReadFailure val readFailure = automation?.lastOrderReadFailure
PurchaseExecutionOutcome( PurchaseExecutionOutcome(
@@ -737,6 +1022,11 @@ class AgentForegroundService : Service() {
} else { } else {
PurchaseExecutionOutcome("order_created", message = "订单已创建,等待人工检查和支付", pddOrderNo = evidence.orderNo, orderSubmittedAt = evidence.submittedAt, pddOrderAmountCent = evidence.pddOrderAmountCent) 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() val requestId = UUID.randomUUID().toString()
purchaseStore.completeAndEnqueue(interrupted.taskId, interrupted.attemptId, requestId, purchaseResultPayload(requestId, interrupted.attemptId, outcome)) purchaseStore.completeAndEnqueue(interrupted.taskId, interrupted.attemptId, requestId, purchaseResultPayload(requestId, interrupted.attemptId, outcome))
} catch (_: AgentApiException) { } catch (_: AgentApiException) {
@@ -751,6 +1041,8 @@ class AgentForegroundService : Service() {
"AGENT_RESTARTED_DURING_EXECUTION", "AGENT_RESTARTED_DURING_EXECUTION",
"手机服务在创建订单前重启,任务已停止且不会重复操作拼多多", "手机服务在创建订单前重启,任务已停止且不会重复操作拼多多",
) )
savePurchaseFailureSnapshot(restoredFailureSnapshotContext(interrupted.attemptId, api.failureSnapshotOrigin,
"AGENT_RESTARTED_DURING_EXECUTION"), "failed", false, "process_interrupted")
purchaseStore.completeAndEnqueue( purchaseStore.completeAndEnqueue(
interrupted.taskId, interrupted.taskId,
interrupted.attemptId, interrupted.attemptId,
@@ -760,20 +1052,83 @@ class AgentForegroundService : Service() {
} }
} }
private fun flushPurchaseOutbox(api: AgentApiClient, token: String) { private fun flushPurchaseOutbox(
api: AgentApiClient, token: String,
onFirstSubmitted: (PendingPurchaseOutbox, PurchaseResultAcknowledgement) -> Unit = { _, _ -> },
) {
var acknowledgement: PurchaseResultAcknowledgement? = null
PurchaseOutboxUploader( PurchaseOutboxUploader(
pending = purchaseStore::pendingOutbox, pending = purchaseStore::pendingOutbox,
submit = { item -> api.submitPurchaseResult(item.taskId, item.payloadJson, token) }, submit = { item -> acknowledgement = api.submitPurchaseResult(item.taskId, item.payloadJson, token) },
markUploaded = { item -> purchaseStore.markUploaded(item.id, item.taskId, item.attemptId) }, markUploaded = { item -> purchaseStore.markUploaded(item.id, item.taskId, item.attemptId) },
afterUploaded = { item ->
// A local upload-mark failure creates no credential; optional handoff inspection cannot fail the upload.
runCatching { onFirstSubmitted(item, requireNotNull(acknowledgement)) }
.onFailure { probeHandoff.invalidate(); logDiagnosticPersistenceFailure(it) }
},
).flush() ).flush()
runningTaskId.set(purchaseStore.activeTaskId()) 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( private fun executeTask(
api: AgentApiClient, api: AgentApiClient,
initialTask: cn.ilapage.goauto.agent.network.AgentTask, initialTask: cn.ilapage.goauto.agent.network.AgentTask,
token: String, token: String,
): TaskExecutionSummary { ): TaskExecutionSummary {
probeHandoff.beginExecution()
GoAutoAccessibilityService.instance?.dismissPurchaseResultBubble() GoAutoAccessibilityService.instance?.dismissPurchaseResultBubble()
acquireTaskWakeLock() acquireTaskWakeLock()
return try { return try {
@@ -1124,6 +1479,7 @@ class AgentForegroundService : Service() {
} }
private fun evaluateIdleReturn() { private fun evaluateIdleReturn() {
if (repurchaseState.phase == RepurchasePhase.RUNNING) return
if (!idleReturn.isArmed()) return if (!idleReturn.isArmed()) return
val accessibility = GoAutoAccessibilityService.instance val accessibility = GoAutoAccessibilityService.instance
if (accessibility == null) { if (accessibility == null) {
@@ -1146,6 +1502,7 @@ class AgentForegroundService : Service() {
} }
private fun requestReturnToAgent(accessibility: GoAutoAccessibilityService) { private fun requestReturnToAgent(accessibility: GoAutoAccessibilityService) {
probeHandoff.invalidate()
val launched = accessibility.openAgentPreservingTab() val launched = accessibility.openAgentPreservingTab()
if (launched) SystemClock.sleep(RETURN_CONFIRM_DELAY_MILLIS) if (launched) SystemClock.sleep(RETURN_CONFIRM_DELAY_MILLIS)
val returned = launched && accessibility.currentPackage() == packageName val returned = launched && accessibility.currentPackage() == packageName
@@ -1268,10 +1625,20 @@ class AgentForegroundService : Service() {
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT) PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT)
builder.addAction(Notification.Action.Builder(null, "停止回填", stop).build()) builder.addAction(Notification.Action.Builder(null, "停止回填", stop).build())
} }
if (repurchaseState.busy) {
val stop = PendingIntent.getService(this, 367,
Intent(this, AgentForegroundService::class.java).setAction(ACTION_REPURCHASE_STOP).putExtra("roundId", repurchaseState.roundId),
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT)
builder.addAction(Notification.Action.Builder(null, "停止重购", stop).build())
}
return builder return builder
.setSmallIcon(android.R.drawable.stat_notify_sync) .setSmallIcon(android.R.drawable.stat_notify_sync)
.setContentTitle(getString(R.string.app_name)) .setContentTitle(getString(R.string.app_name))
.setContentText(if (backfillState.running) "订单回填 · 已检查 ${backfillState.checked}" else content) .setContentText(when {
repurchaseState.busy -> "重购 ${repurchaseState.currentTaskId?.let { "CG-$it" }.orEmpty()} · 成功${repurchaseState.successCount} 失败${repurchaseState.failedCount} 跳过${repurchaseState.skippedCount}"
backfillState.running -> "订单回填 · 已检查 ${backfillState.checked}"
else -> content
})
.setContentIntent(pendingIntent) .setContentIntent(pendingIntent)
.setOngoing(true) .setOngoing(true)
.build() .build()
@@ -1290,6 +1657,7 @@ class AgentForegroundService : Service() {
* 归位要的是相反的效果,所以单独加 FLAG_ACTIVITY_CLEAR_TOP,两条路径各自保留(#292)。 * 归位要的是相反的效果,所以单独加 FLAG_ACTIVITY_CLEAR_TOP,两条路径各自保留(#292)。
*/ */
private fun resetPddToHome(): PddLaunchResult { private fun resetPddToHome(): PddLaunchResult {
probeHandoff.invalidate()
val launchIntent = packageManager.getLaunchIntentForPackage(PDD_PACKAGE) val launchIntent = packageManager.getLaunchIntentForPackage(PDD_PACKAGE)
?: return PddLaunchResult.INTENT_MISSING ?: return PddLaunchResult.INTENT_MISSING
launchIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP) launchIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP)
@@ -1304,6 +1672,7 @@ class AgentForegroundService : Service() {
} }
private fun launchPddToForeground(): PddLaunchResult { private fun launchPddToForeground(): PddLaunchResult {
probeHandoff.invalidate()
val launchIntent = packageManager.getLaunchIntentForPackage(PDD_PACKAGE) val launchIntent = packageManager.getLaunchIntentForPackage(PDD_PACKAGE)
?: return PddLaunchResult.INTENT_MISSING ?: return PddLaunchResult.INTENT_MISSING
launchIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) launchIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
@@ -1318,6 +1687,14 @@ class AgentForegroundService : Service() {
} }
companion object { companion object {
@Volatile internal var diagnosticInstance: AgentForegroundService? = null
private set
const val ACTION_REPURCHASE_PREPARE = "cn.ilapage.goauto.agent.REPURCHASE_PREPARE"
const val ACTION_REPURCHASE_CONFIRM = "cn.ilapage.goauto.agent.REPURCHASE_CONFIRM"
const val ACTION_REPURCHASE_STOP = "cn.ilapage.goauto.agent.REPURCHASE_STOP"
const val ACTION_REPURCHASE_STATE = "cn.ilapage.goauto.agent.REPURCHASE_STATE"
@Volatile var repurchaseState = RepurchaseState()
private set
const val ACTION_BACKFILL_START = "cn.ilapage.goauto.agent.BACKFILL_START" const val ACTION_BACKFILL_START = "cn.ilapage.goauto.agent.BACKFILL_START"
const val ACTION_BACKFILL_STOP = "cn.ilapage.goauto.agent.BACKFILL_STOP" const val ACTION_BACKFILL_STOP = "cn.ilapage.goauto.agent.BACKFILL_STOP"
const val ACTION_BACKFILL_STATE = "cn.ilapage.goauto.agent.BACKFILL_STATE" const val ACTION_BACKFILL_STATE = "cn.ilapage.goauto.agent.BACKFILL_STATE"
@@ -1353,6 +1730,7 @@ class AgentForegroundService : Service() {
private const val COOLDOWN_WAKE_GRACE_MILLIS = 5_000L private const val COOLDOWN_WAKE_GRACE_MILLIS = 5_000L
private const val RETURN_CONFIRM_DELAY_MILLIS = 750L private const val RETURN_CONFIRM_DELAY_MILLIS = 750L
private const val CURRENT_PAGE_RESERVATION_ID = Long.MAX_VALUE private const val CURRENT_PAGE_RESERVATION_ID = Long.MAX_VALUE
private const val REPURCHASE_RESERVATION_ID = Long.MAX_VALUE - 1
private const val PDD_PACKAGE = "com.xunmeng.pinduoduo" private const val PDD_PACKAGE = "com.xunmeng.pinduoduo"
private const val CURRENT_PAGE_RESULT_NOTIFICATION_MILLIS = 30_000L private const val CURRENT_PAGE_RESULT_NOTIFICATION_MILLIS = 30_000L
@@ -0,0 +1,168 @@
package cn.ilapage.goauto.agent.service
import cn.ilapage.goauto.agent.network.HistoryPage
import cn.ilapage.goauto.agent.network.PurchaseHistoryItem
import java.text.ParsePosition
import java.text.SimpleDateFormat
import java.util.Collections
import java.util.Locale
import java.util.TimeZone
data class RepurchaseHistoryQuery(val page: Int, val pageSize: Int = 50, val days: Int = 30, val status: String? = null, val taskNo: String? = null)
enum class RepurchaseBatchFailure { PAGE_FAILED, INVALID_PAGE, INVALID_TIMESTAMP, INVALID_ORDER, INCOMPLETE_BATCH, LIST_CHANGED, PAGE_LIMIT }
class RepurchaseBatchException(val reason: RepurchaseBatchFailure) : IllegalStateException(reason.name)
class RepurchaseBatch internal constructor(items: List<PurchaseHistoryItem>) {
val items: List<PurchaseHistoryItem> = immutable(items)
val candidates: List<PurchaseHistoryItem> = immutable(items.filter { it.status == "failed" && it.retryable })
val failures: List<PurchaseHistoryItem> = immutable(items.filter { it.status == "failed" })
val skips: List<PurchaseHistoryItem> = immutable(failures.filterNot { it.retryable })
val headTaskId = items.firstOrNull()?.taskId
val newestCreatedAt = items.firstOrNull()?.createdAt
val oldestCreatedAt = items.lastOrNull()?.createdAt
private fun immutable(items: List<PurchaseHistoryItem>): List<PurchaseHistoryItem> =
Collections.unmodifiableList(ArrayList(items))
}
/** Read-only discovery. The callback must use the current device's authenticated history endpoint.
*
* [nowMillis] MUST return the trusted server Date time from the response to that fetch, and throw
* when it is missing or invalid. Never fall back to the phone clock or a previous response's Date:
* the server applies the rolling 30-day cutoff, and a slow phone could accept a truncated batch.
* Tests may inject a synthetic server clock. It is read only after each successful page response.
*
* Offset overlap is tolerated; a changed head is rejected. This is not a snapshot guarantee for
* arbitrary concurrent deletion, reassignment or timestamp edits on the server.
*/
class RepurchaseBatchDiscovery(
private val fetchPage: (RepurchaseHistoryQuery) -> HistoryPage<PurchaseHistoryItem>,
private val nowMillis: () -> Long,
private val maxPages: Int = 200,
) {
init { require(maxPages > 0) }
fun discover(): RepurchaseBatch {
var latestServerTime = 0L
fun readWithServerTime(pageNumber: Int): HistoryPage<PurchaseHistoryItem> {
val page = readPage(pageNumber)
val serverTime = try { nowMillis() } catch (_: Exception) {
refuse(RepurchaseBatchFailure.INCOMPLETE_BATCH)
}
if (serverTime <= 0) refuse(RepurchaseBatchFailure.INCOMPLETE_BATCH)
latestServerTime = maxOf(latestServerTime, serverTime)
return page
}
val seen = linkedMapOf<Long, Entry>()
val batch = mutableListOf<PurchaseHistoryItem>()
var head: Long? = null
var previous: Entry? = null
var originalTotal: Long? = null
var foundBoundary = false
var reachedEnd = false
for (pageNumber in 1..maxPages) {
val page = readWithServerTime(pageNumber)
if (originalTotal == null) originalTotal = page.total
else if (page.total != originalTotal) refuse(RepurchaseBatchFailure.LIST_CHANGED)
val entries = parseOrdered(page.items)
if (pageNumber == 1) head = entries.firstOrNull()?.item?.taskId
for (entry in entries) {
val duplicate = seen[entry.item.taskId]
if (duplicate != null) {
if (duplicate.time != entry.time) refuse(RepurchaseBatchFailure.INVALID_ORDER)
continue
}
val prior = previous
if (prior != null) {
if (ENTRY_ORDER.compare(prior, entry) > 0) refuse(RepurchaseBatchFailure.INVALID_ORDER)
if (prior.time.moreThanSixtySecondsAfter(entry.time)) {
foundBoundary = true
break
}
}
seen[entry.item.taskId] = entry
batch += entry.item
previous = entry
}
if (foundBoundary) break
if (pageNumber.toLong() * PAGE_SIZE >= page.total) {
// Offset overlap without enough unique rows cannot establish a complete tail.
if (seen.size.toLong() != page.total) refuse(RepurchaseBatchFailure.INCOMPLETE_BATCH)
reachedEnd = true
break
}
}
if (!foundBoundary && !reachedEnd) refuse(RepurchaseBatchFailure.PAGE_LIMIT)
val finalPage = readWithServerTime(1)
val finalHead = parseOrdered(finalPage.items).firstOrNull()?.item?.taskId
if (finalHead != head || finalPage.total != originalTotal) refuse(RepurchaseBatchFailure.LIST_CHANGED)
if (reachedEnd && previous != null) {
// The API applies a rolling 30-day range, not a midnight-based date filter.
// HTTP Date has whole-second precision; include its possible fractional second.
// Preserve the latest observed time if a later response's Date moves backwards.
val floorMillis = latestServerTime + 1000 - DAYS * 24L * 60 * 60 * 1000
val floorSeconds = floorMillis / 1000 - if (floorMillis < 0 && floorMillis % 1000 != 0L) 1 else 0
val floor = Timestamp(floorSeconds, ((floorMillis - floorSeconds * 1000) * 1_000_000).toInt())
if (!previous.time.moreThanSixtySecondsAfter(floor)) refuse(RepurchaseBatchFailure.INCOMPLETE_BATCH)
}
return RepurchaseBatch(batch)
}
private fun readPage(pageNumber: Int): HistoryPage<PurchaseHistoryItem> {
val page = try {
fetchPage(RepurchaseHistoryQuery(page = pageNumber))
} catch (_: Exception) {
// Do not expose a network exception that might contain credentials or response data.
refuse(RepurchaseBatchFailure.PAGE_FAILED)
}
val offset = (pageNumber - 1L) * PAGE_SIZE
if (page.page != pageNumber || page.pageSize != PAGE_SIZE || page.total < 0 ||
page.items.size.toLong() != (page.total - offset).coerceIn(0, PAGE_SIZE.toLong()) ||
page.items.any { it.taskId <= 0 } || page.items.map { it.taskId }.distinct().size != page.items.size
) refuse(RepurchaseBatchFailure.INVALID_PAGE)
return page
}
private fun parseOrdered(items: List<PurchaseHistoryItem>): List<Entry> {
val entries = items.map { Entry(it, parseTime(it.createdAt)) }
val ordered = entries.sortedWith(ENTRY_ORDER)
if (entries != ordered) refuse(RepurchaseBatchFailure.INVALID_ORDER)
return ordered
}
private fun parseTime(raw: String): Timestamp {
val match = TIMESTAMP.matchEntire(raw) ?: refuse(RepurchaseBatchFailure.INVALID_TIMESTAMP)
val zone = match.groupValues[3].let { if (it == "Z") "+0000" else it.replace(":", "") }
val normalized = match.groupValues[1] + zone
val position = ParsePosition(0)
val parsed = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssZ", Locale.ROOT).apply {
isLenient = false
timeZone = TimeZone.getTimeZone("UTC")
}.parse(normalized, position)
if (parsed == null || position.index != normalized.length) refuse(RepurchaseBatchFailure.INVALID_TIMESTAMP)
return Timestamp(parsed.time / 1000, match.groupValues[2].padEnd(9, '0').toInt())
}
private data class Entry(val item: PurchaseHistoryItem, val time: Timestamp)
private data class Timestamp(val seconds: Long, val nanos: Int) : Comparable<Timestamp> {
override fun compareTo(other: Timestamp): Int =
seconds.compareTo(other.seconds).takeIf { it != 0 } ?: nanos.compareTo(other.nanos)
fun moreThanSixtySecondsAfter(older: Timestamp): Boolean {
val secondsApart = seconds - older.seconds
return secondsApart > 60 || (secondsApart == 60L && nanos > older.nanos)
}
}
companion object {
private const val PAGE_SIZE = 50
private const val DAYS = 30
private val TIMESTAMP = Regex("(\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2})(?:\\.(\\d{1,9}))?(Z|[+-]\\d{2}:\\d{2})")
private val ENTRY_ORDER = compareByDescending<Entry> { it.time }.thenByDescending { it.item.taskId }
private fun refuse(reason: RepurchaseBatchFailure): Nothing = throw RepurchaseBatchException(reason)
}
}
@@ -0,0 +1,235 @@
package cn.ilapage.goauto.agent.service
import cn.ilapage.goauto.agent.network.PurchaseHistoryDetail
import cn.ilapage.goauto.agent.network.PurchaseResetResult
import cn.ilapage.goauto.agent.network.AgentApiException
import java.util.concurrent.atomic.AtomicReference
interface RepurchasePort {
fun environmentProblem(): String?
fun localIdle(): Boolean
fun hasOtherWork(currentTaskId: Long?): Boolean
fun latestTaskId(): Long?
fun detail(taskId: Long): PurchaseHistoryDetail
fun reset(taskId: Long, requestId: String): PurchaseResetResult
fun executionEvidence(taskId: Long): List<RepurchaseExecutionEvidence> = emptyList()
}
/** Only emitted by the original executor after Start and after a known outcome. */
data class RepurchaseExecutionEvidence(val attemptNumber: Long, val attemptId: String, val phase: String, val resultType: String?)
class RepurchaseLocalBusy : IllegalStateException()
class RepurchaseCoordinator(
private val now: () -> Long,
private val requestId: () -> String,
private val publish: (RepurchaseState) -> Unit,
) {
@Volatile var state = RepurchaseState()
private set
private val stopRequest = AtomicReference<String?>(null)
private var position = 0
private var expectedAttempt = 0L
private var issuedAt = 0L
private var busySince: Long? = null
private var headChecked = false
private val resetRequestIds = mutableMapOf<Long, String>()
// All mutation except requestStop is confined to the Service's orchestration worker.
fun prepare(): Boolean {
if (state.busy) return false
stopRequest.set(null)
position = 0
expectedAttempt = 0
busySince = null
headChecked = false
resetRequestIds.clear()
emit(RepurchaseState(phase = RepurchasePhase.READING, roundId = requestId(), message = "正在读取本设备最后一批采购记录…"))
return true
}
fun prepared(batch: RepurchaseBatch) {
if (state.phase != RepurchasePhase.READING) return
if (isStopRequested()) { finish("已停止读取,没有发起重购"); return }
val skips = batch.skips.map { RepurchaseItemResult(it.taskId, RepurchaseResult.SKIPPED, it.retryDisabledReason ?: "当前不可重试") }
val message = when {
batch.items.isEmpty() -> "近30天没有采购记录"
batch.failures.isEmpty() -> "最后一批没有失败任务,不回退更早批次"
batch.candidates.isEmpty() -> "最后一批失败任务均不可重试,请查看逐条结果"
else -> "请确认本轮重购范围"
}
emit(state.copy(phase = if (batch.candidates.isEmpty()) RepurchasePhase.FINISHED else RepurchasePhase.READY,
batch = batch, results = skips, message = message))
}
fun confirm(roundId: String) {
if (state.phase != RepurchasePhase.READY || roundId != state.roundId) return
if (isStopRequested()) { finish("已取消重购"); return }
emit(state.copy(phase = RepurchasePhase.RUNNING, message = "正在复核本轮任务"))
}
fun requestStop(roundId: String) {
if (roundId.isNotBlank() && roundId == state.roundId) stopRequest.set(roundId)
}
fun isStopRequested(): Boolean = stopRequest.get() == state.roundId && state.roundId.isNotBlank()
fun fail(message: String) {
if (!state.busy) return
if (state.currentTaskId == null) finish(message)
else if (state.phase != RepurchasePhase.STOPPING) emit(state.copy(phase = RepurchasePhase.STOPPING, message = "$message;停止后续,当前任务沿用原流程收尾"))
}
fun tick(port: RepurchasePort) {
if (!state.busy) return
if (isStopRequested()) fail("已请求停止重购")
if (state.phase !in setOf(RepurchasePhase.RUNNING, RepurchasePhase.STOPPING)) return
if (state.currentTaskId != null && now() - issuedAt >= TASK_TIMEOUT_MILLIS) {
finish("单条等待已达15分钟,已停止编排;当前任务未被取消,请人工检查")
return
}
try {
port.environmentProblem()?.let { fail(it); return }
val current = state.currentTaskId
if (port.hasOtherWork(current)) fail("检测到新的采集或采购工作")
if (!state.busy) return
if (current != null) {
observe(port, current)
return
}
if (state.phase != RepurchasePhase.RUNNING) return
if (busySince != null && now() - requireNotNull(busySince) >= DRAIN_TIMEOUT_MILLIS) {
fail("设备忙碌或结果上传尚未收尾"); return
}
if (!port.localIdle()) {
val since = busySince ?: now().also { busySince = it }
if (now() - since >= DRAIN_TIMEOUT_MILLIS) fail("设备忙碌或结果上传尚未收尾")
return
}
val batch = requireNotNull(state.batch)
if (!headChecked) {
if (port.latestTaskId() != batch.headTaskId) { fail("采购列表已变化,请重新读取后确认"); return }
headChecked = true
}
if (position >= batch.candidates.size) { finish("本轮重购已结束"); return }
val taskId = batch.candidates[position].taskId
val detail = try { port.detail(taskId) } catch (error: AgentApiException) {
if (error.code == "PURCHASE_TASK_NOT_FOUND") { skip(taskId, "任务已过期或不再属于本设备"); return }
throw error
}
check(detail.task.taskId == taskId)
if (detail.task.status != "failed" || !detail.task.retryable) {
skip(taskId, detail.task.retryDisabledReason ?: "当前状态不可重试")
return
}
// Recheck after network reads. The reset adapter also holds a short local reservation.
if (isStopRequested()) { fail("已请求停止重购"); return }
port.environmentProblem()?.let { fail(it); return }
if (!port.localIdle()) return
if (port.hasOtherWork(null)) { fail("检测到新的采集或采购工作"); return }
if (isStopRequested()) { fail("已请求停止重购"); return }
issueReset(port, taskId, detail.attemptCount)
} catch (_: Exception) {
fail("连接、身份或服务端校验失败,请恢复后再操作")
}
}
private fun issueReset(port: RepurchasePort, taskId: Long, priorAttempt: Long) {
val fixedRequestId = resetRequestIds.getOrPut(taskId, requestId)
issuedAt = now()
expectedAttempt = 0
emit(state.copy(currentTaskId = taskId, message = "正在重置 CG-$taskId(原任务号)"))
var ambiguous = false
repeat(2) { attempt ->
// A stop while an ambiguous request was in flight must not start a fresh write.
if (attempt > 0 && isStopRequested()) { fail("重置响应不明确,已停止,请人工核对当前任务"); return }
try {
val result = port.reset(taskId, fixedRequestId)
check(result.taskId == taskId && result.attemptNumber > priorAttempt)
expectedAttempt = result.attemptNumber.toLong()
busySince = null
emit(state.copy(message = "等待 CG-$taskId 本次执行与结果上传"))
if (isStopRequested()) fail("已请求停止重购")
return
} catch (error: Exception) {
if (!ambiguous && error is RepurchaseLocalBusy) {
emit(state.copy(currentTaskId = null, message = "等待本地忙碌操作收尾"))
if (busySince == null) busySince = now()
return
}
if (!ambiguous && error is AgentApiException && error.status in 400..499 && error.code in BUSINESS_REFUSALS) {
emit(state.copy(currentTaskId = null))
skip(taskId, "当前业务资格拒绝:${error.code}")
return
}
if (error is AgentApiException && error.status in 400..499) {
if (!ambiguous) emit(state.copy(currentTaskId = null))
fail(if (ambiguous) "重置响应不明确,请人工核对" else "设备、规则或身份校验失败")
return
}
ambiguous = true
}
}
fail("重置响应仍不明确,已停止,请人工核对当前任务")
}
private fun observe(port: RepurchasePort, taskId: Long) {
if (expectedAttempt == 0L) return // Never treat an old failed result as this reset's outcome.
val detail = port.detail(taskId)
check(detail.task.taskId == taskId)
if (detail.attemptCount < expectedAttempt) return
if (detail.attemptCount > expectedAttempt) {
val evidence = port.executionEvidence(taskId)
var linkedAttempt = expectedAttempt
// Each extra attempt needs a completed probe/rematch and the original executor's
// Start evidence for the immediately following purchase attempt. A separate reset
// after failed/cancelled/success cannot satisfy this chain.
while (linkedAttempt < detail.attemptCount) {
val from = evidence.singleOrNull { it.attemptNumber == linkedAttempt }
val to = evidence.singleOrNull { it.attemptNumber == linkedAttempt + 1 }
if (from?.resultType !in setOf("spec_probe_completed", "spec_rematch_completed") ||
to?.phase != "purchase" || from?.attemptId.isNullOrBlank() || to.attemptId.isBlank() || from?.attemptId == to.attemptId) {
fail("同任务出现无法关联本轮的后续尝试,已停止,请人工核对")
if (port.localIdle()) finish(state.message)
return
}
linkedAttempt++
}
expectedAttempt = linkedAttempt
}
val status = detail.task.status
if (status == "order_result_unknown") {
fail("订单结果待核对,请人工检查,不能继续重购")
if (port.localIdle()) finish(state.message)
return
}
if (status !in TERMINAL) return
if (!port.localIdle()) return
val failed = status == "failed"
val result = when (status) {
"order_created" -> RepurchaseItemResult(taskId, RepurchaseResult.SUCCESS, "订单已创建")
"cancelled" -> RepurchaseItemResult(taskId, RepurchaseResult.SKIPPED, "本次任务已取消")
else -> RepurchaseItemResult(taskId, RepurchaseResult.FAILED, "再次失败:${detail.task.errorCode ?: "PURCHASE_FAILED"}")
}
position++
val stopping = state.phase == RepurchasePhase.STOPPING
emit(state.copy(currentTaskId = null, results = state.results + result))
if (failed && detail.task.errorCode in ENVIRONMENT_FAILURES) finish("登录、验证码、风控或设备环境异常,已停止后续重购")
else if (stopping || isStopRequested()) finish(state.message)
else if (position >= requireNotNull(state.batch).candidates.size) finish("本轮重购已结束;再次失败的任务不会在本轮重复执行")
}
private fun skip(taskId: Long, reason: String) {
position++
emit(state.copy(results = state.results + RepurchaseItemResult(taskId, RepurchaseResult.SKIPPED, reason)))
if (position >= requireNotNull(state.batch).candidates.size) finish("本轮重购已结束")
}
private fun finish(message: String) = emit(state.copy(phase = RepurchasePhase.FINISHED, message = message))
private fun emit(value: RepurchaseState) { state = value; publish(value) }
companion object {
const val TASK_TIMEOUT_MILLIS = 15 * 60 * 1000L
private const val DRAIN_TIMEOUT_MILLIS = 10_000L
private val TERMINAL = setOf("order_created", "failed", "cancelled")
private val BUSINESS_REFUSALS = setOf("PURCHASE_TASK_NOT_FOUND", "PURCHASE_RETRY_NOT_ALLOWED", "PURCHASE_RETRY_UNSAFE", "PURCHASE_RETRY_STALE", "PURCHASE_SPEC_MAPPING_REQUIRED")
private val ENVIRONMENT_FAILURES = setOf("PDD_LOGIN_REQUIRED", "PDD_CAPTCHA_REQUIRED", "PDD_RISK_CONTROL", "ACCESSIBILITY_NOT_READY", "DEVICE_OFFLINE", "AGENT_RESTARTED_DURING_EXECUTION")
}
}
@@ -0,0 +1,40 @@
package cn.ilapage.goauto.agent.service
enum class RepurchasePhase { IDLE, READING, READY, RUNNING, STOPPING, FINISHED }
enum class RepurchaseResult { SUCCESS, FAILED, SKIPPED }
data class RepurchaseItemResult(val taskId: Long, val result: RepurchaseResult, val message: String)
/** Process-local presentation only; never persists or resumes a round after process death. */
data class RepurchaseState(
val phase: RepurchasePhase = RepurchasePhase.IDLE,
val roundId: String = "",
val batch: RepurchaseBatch? = null,
val currentTaskId: Long? = null,
val results: List<RepurchaseItemResult> = emptyList(),
val message: String = "",
) {
val busy get() = phase in setOf(RepurchasePhase.READING, RepurchasePhase.READY, RepurchasePhase.RUNNING, RepurchasePhase.STOPPING)
val successCount get() = results.count { it.result == RepurchaseResult.SUCCESS }
val failedCount get() = results.count { it.result == RepurchaseResult.FAILED }
val skippedCount get() = results.count { it.result == RepurchaseResult.SKIPPED }
fun confirmationText(): String = buildString {
append("本设备最后一批(近30天全部状态)\n")
append("${batch?.oldestCreatedAt.orEmpty()} ~ ${batch?.newestCreatedAt.orEmpty()}\n")
append("共 ${batch?.items?.size ?: 0} 条 · 失败 ${batch?.failures?.size ?: 0} 条\n")
append("可重购 ${batch?.candidates?.size ?: 0} 条 · 跳过 ${batch?.skips?.size ?: 0} 条\n\n")
append("相邻创建时间不超过60秒推算为同批,不受当前搜索和筛选影响。\n")
append("逐条复用原任务号重试,本轮再次失败不重复执行。单条等待上限15分钟,超时只停止后续编排。\n\n")
append("可能真实创建拼多多待付款订单,系统不会付款。停止重购不取消已经提交或正在执行的任务。")
}
fun summaryText(): String = buildString {
if (message.isNotBlank()) append(message).append('\n')
currentTaskId?.let { append("当前 CG-$it\n") }
append("成功 $successCount · 再次失败 $failedCount · 跳过 $skippedCount")
batch?.let {
val candidateIds = it.candidates.map { task -> task.taskId }.toSet()
val handled = results.count { result -> result.taskId in candidateIds }
append("\n本轮已结束 $handled / ${it.candidates.size} · 未结束 ${(it.candidates.size - handled).coerceAtLeast(0)}")
}
}
}
@@ -142,8 +142,19 @@ class TaskHistoryFragment : Fragment() {
private val imageRequests = mutableListOf<HistoryImageRequest>() private val imageRequests = mutableListOf<HistoryImageRequest>()
private var currentPageReceiverRegistered = false private var currentPageReceiverRegistered = false
private var backfillPanel: LinearLayout? = null private var backfillPanel: LinearLayout? = null
private var repurchasePanel: LinearLayout? = null
private var repurchaseButton: MaterialButton? = null
private var repurchaseDialog: androidx.appcompat.app.AlertDialog? = null
private var repurchaseDialogRound: String? = null
private val currentPageReceiver = object : BroadcastReceiver() { private val currentPageReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) { override fun onReceive(context: Context?, intent: Intent?) {
if (intent?.action == AgentForegroundService.ACTION_REPURCHASE_STATE) {
renderRepurchase()
if (!collection && isResumed && AgentForegroundService.repurchaseState.phase == cn.ilapage.goauto.agent.service.RepurchasePhase.FINISHED) {
detailState.taskId?.let(::loadPurchaseDetail) ?: load()
}
return
}
if (intent?.action == AgentForegroundService.ACTION_BACKFILL_STATE) { if (intent?.action == AgentForegroundService.ACTION_BACKFILL_STATE) {
renderBackfill() renderBackfill()
if (!collection && isResumed && !AgentForegroundService.backfillState.running) { if (!collection && isResumed && !AgentForegroundService.backfillState.running) {
@@ -191,6 +202,9 @@ class TaskHistoryFragment : Fragment() {
pageColumn.addView(context.screenTitle(if (collection) "采集记录" else "采购记录")) pageColumn.addView(context.screenTitle(if (collection) "采集记录" else "采购记录"))
pageColumn.addView(buildSearch()) pageColumn.addView(buildSearch())
if (!collection) { if (!collection) {
repurchasePanel = context.column(0)
pageColumn.addView(repurchasePanel)
renderRepurchase()
backfillPanel = context.column(0) backfillPanel = context.column(0)
pageColumn.addView(backfillPanel) pageColumn.addView(backfillPanel)
renderBackfill() renderBackfill()
@@ -214,12 +228,19 @@ class TaskHistoryFragment : Fragment() {
override fun onResume() { override fun onResume() {
super.onResume() super.onResume()
renderRepurchase()
detailState.taskId?.let { taskId -> detailState.taskId?.let { taskId ->
if (collection) loadCollectionDetail(taskId) else loadPurchaseDetail(taskId) if (collection) loadCollectionDetail(taskId) else loadPurchaseDetail(taskId)
} ?: load() } ?: load()
} }
override fun onDestroyView() { override fun onDestroyView() {
repurchaseDialog?.setOnDismissListener(null)
repurchaseDialog?.dismiss()
repurchaseDialog = null
repurchaseDialogRound = null
repurchasePanel = null
repurchaseButton = null
backfillPanel = null backfillPanel = null
requestGeneration++ requestGeneration++
cancelImageRequests() cancelImageRequests()
@@ -289,11 +310,16 @@ class TaskHistoryFragment : Fragment() {
}, LinearLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, context.dp(48)).apply { marginStart = context.dp(8) }) }, LinearLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, context.dp(48)).apply { marginStart = context.dp(8) })
} else { } else {
row.addView(MaterialButton(context).apply { row.addView(MaterialButton(context).apply {
text = "回填" repurchaseButton = this
text = "重购"
textSize = 14f textSize = 14f
minimumHeight = context.dp(48) minimumHeight = context.dp(48)
contentDescription = "回填拼多多订单号和下单时间" contentDescription = "重试本设备最后一批失败采购"
setOnClickListener { showBackfillInput() } isEnabled = !AgentForegroundService.repurchaseState.busy
setOnClickListener {
isEnabled = false
sendRepurchaseAction(AgentForegroundService.ACTION_REPURCHASE_PREPARE)
}
}, LinearLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, context.dp(48)).apply { marginStart = context.dp(8) }) }, LinearLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, context.dp(48)).apply { marginStart = context.dp(8) })
} }
addView(row, row.fullWidth()) addView(row, row.fullWidth())
@@ -302,6 +328,61 @@ class TaskHistoryFragment : Fragment() {
} }
} }
private fun sendRepurchaseAction(action: String, roundId: String = AgentForegroundService.repurchaseState.roundId) {
val context = context ?: return
val intent = Intent(context, AgentForegroundService::class.java).setAction(action).putExtra("roundId", roundId)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) context.startForegroundService(intent) else context.startService(intent)
}
private fun renderRepurchase() {
val panel = repurchasePanel ?: return
val context = context ?: return
val state = AgentForegroundService.repurchaseState
val phase = state.phase
repurchaseButton?.isEnabled = !state.busy
panel.removeAllViews()
if (phase != cn.ilapage.goauto.agent.service.RepurchasePhase.READY) {
repurchaseDialog?.setOnDismissListener(null)
repurchaseDialog?.dismiss()
repurchaseDialog = null
repurchaseDialogRound = null
}
if (phase == cn.ilapage.goauto.agent.service.RepurchasePhase.IDLE) return
panel.addView(context.card(context.cardColumn().apply {
addView(context.label(if (state.busy) "本轮重购" else "重购结果", 16f))
addView(context.label(state.summaryText(), 14f))
if (state.busy) addView(MaterialButton(context).apply {
text = if (phase == cn.ilapage.goauto.agent.service.RepurchasePhase.STOPPING) "正在收尾…" else "停止重购"
minimumHeight = context.dp(48)
isEnabled = phase != cn.ilapage.goauto.agent.service.RepurchasePhase.STOPPING
setOnClickListener {
isEnabled = false
sendRepurchaseAction(AgentForegroundService.ACTION_REPURCHASE_STOP, state.roundId)
}
})
if (state.results.isNotEmpty()) addView(MaterialButton(context).apply {
text = "查看逐条结果"
minimumHeight = context.dp(48)
setOnClickListener {
MaterialAlertDialogBuilder(context).setTitle("本轮重购明细")
.setMessage(state.results.joinToString("\n") { "CG-${it.taskId}:${it.message}" })
.setPositiveButton("关闭", null).show()
}
})
}))
if (phase == cn.ilapage.goauto.agent.service.RepurchasePhase.READY && isResumed && repurchaseDialogRound != state.roundId) {
repurchaseDialogRound = state.roundId
repurchaseDialog = MaterialAlertDialogBuilder(context).setTitle("重购最后一批")
.setMessage(state.confirmationText())
.setNegativeButton("取消") { _, _ -> sendRepurchaseAction(AgentForegroundService.ACTION_REPURCHASE_STOP, state.roundId) }
.setPositiveButton("确认重购") { _, _ -> sendRepurchaseAction(AgentForegroundService.ACTION_REPURCHASE_CONFIRM, state.roundId) }
.create().also { dialog ->
dialog.setOnCancelListener { sendRepurchaseAction(AgentForegroundService.ACTION_REPURCHASE_STOP, state.roundId) }
dialog.show()
}
}
}
private fun showBackfillInput() { private fun showBackfillInput() {
if (AgentForegroundService.backfillState.running) { if (AgentForegroundService.backfillState.running) {
toast("设备忙碌,请稍后操作") toast("设备忙碌,请稍后操作")
@@ -729,6 +810,7 @@ class TaskHistoryFragment : Fragment() {
if (currentPageReceiverRegistered) return if (currentPageReceiverRegistered) return
val filter = IntentFilter(AgentForegroundService.ACTION_CURRENT_PAGE_RESULT) val filter = IntentFilter(AgentForegroundService.ACTION_CURRENT_PAGE_RESULT)
filter.addAction(AgentForegroundService.ACTION_BACKFILL_STATE) filter.addAction(AgentForegroundService.ACTION_BACKFILL_STATE)
filter.addAction(AgentForegroundService.ACTION_REPURCHASE_STATE)
if (Build.VERSION.SDK_INT >= 33) { if (Build.VERSION.SDK_INT >= 33) {
requireContext().registerReceiver(currentPageReceiver, filter, Context.RECEIVER_NOT_EXPORTED) requireContext().registerReceiver(currentPageReceiver, filter, Context.RECEIVER_NOT_EXPORTED)
} else { } else {
@@ -9,6 +9,27 @@ import org.junit.Assert.assertEquals
import org.junit.Test import org.junit.Test
class AgentDiagnosticRecorderTest { class AgentDiagnosticRecorderTest {
@Test
fun colorDiscoveryOutcomeDefaultsToUnknownAndPreservesExplicitValues() {
val event = AgentDiagnosticEvent(
taskId = 370,
stage = AgentDiagnosticStage.COLOR_DISCOVERY,
reason = AgentDiagnosticReason.COLOR_EDGE_REACHED,
)
assertEquals(null, event.colorVerticalSwipeCount)
assertEquals(null, event.colorTrailingEmptyReadCount)
assertEquals(null, event.colorHorizontalIncomplete)
val completed = event.copy(
colorVerticalSwipeCount = 4,
colorTrailingEmptyReadCount = 2,
colorHorizontalIncomplete = false,
)
assertEquals(4, completed.colorVerticalSwipeCount)
assertEquals(2, completed.colorTrailingEmptyReadCount)
assertEquals(false, completed.colorHorizontalIncomplete)
assertEquals(true, completed.copy(colorHorizontalIncomplete = true).colorHorizontalIncomplete)
}
@Test @Test
fun diagnosticsAreBoundedToFiftyRecordsAndSevenDays() { fun diagnosticsAreBoundedToFiftyRecordsAndSevenDays() {
assertEquals(50, AgentDiagnosticRetentionPolicy.MAX_RECORDS) assertEquals(50, AgentDiagnosticRetentionPolicy.MAX_RECORDS)
@@ -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() }
}
}
@@ -1,10 +1,43 @@
package cn.ilapage.goauto.agent package cn.ilapage.goauto.agent
import cn.ilapage.goauto.agent.automation.PddLaunchFallback import cn.ilapage.goauto.agent.automation.PddLaunchFallback
import cn.ilapage.goauto.agent.automation.PddLaunchEvent
import cn.ilapage.goauto.agent.automation.PddLaunchPath
import cn.ilapage.goauto.agent.automation.PddLaunchStatus
import org.junit.Assert.* import org.junit.Assert.*
import org.junit.Test import org.junit.Test
class PddLaunchFallbackTest { class PddLaunchFallbackTest {
@Test fun reportsDirectAcceptanceWithoutCallingBrowser() {
val events = mutableListOf<PddLaunchEvent>()
assertTrue(PddLaunchFallback.open(true, { true }, { error("unexpected") }, events::add))
assertEquals(listOf(PddLaunchEvent(PddLaunchPath.DIRECT, PddLaunchStatus.ACCEPTED)), events)
}
@Test fun reportsOnlyExceptionClassThenBrowserAcceptance() {
val events = mutableListOf<PddLaunchEvent>()
assertTrue(PddLaunchFallback.open(true, { throw SecurityException("secret URL and address") }, { true }, events::add))
assertEquals(listOf(
PddLaunchEvent(PddLaunchPath.DIRECT, PddLaunchStatus.EXCEPTION, "java.lang.SecurityException"),
PddLaunchEvent(PddLaunchPath.BROWSER, PddLaunchStatus.ACCEPTED),
), events)
assertFalse(events.toString().contains("secret"))
}
@Test fun reportsFalseAndBrowserExceptionWithoutChangingResult() {
val events = mutableListOf<PddLaunchEvent>()
assertFalse(PddLaunchFallback.open(true, { false }, { throw IllegalStateException("private") }, events::add))
assertEquals(PddLaunchStatus.REJECTED, events[0].status)
assertEquals(PddLaunchStatus.EXCEPTION, events[1].status)
}
@Test fun diagnosticFailureDoesNotCauseBrowserFallbackOrChangeResult() {
var browserCalls = 0
assertTrue(PddLaunchFallback.open(true, { true }, { browserCalls++; true }, { error("storage failed") }))
assertEquals(0, browserCalls)
}
@Test fun browserOnlyReportsNoDirectAttempt() {
val events = mutableListOf<PddLaunchEvent>()
assertFalse(PddLaunchFallback.open(false, { error("unexpected") }, { false }, events::add))
assertEquals(listOf(PddLaunchEvent(PddLaunchPath.BROWSER, PddLaunchStatus.REJECTED)), events)
}
@Test fun directSuccessDoesNotStartBrowser() { @Test fun directSuccessDoesNotStartBrowser() {
assertTrue(PddLaunchFallback.open(true, { true }, { error("browser must not start") })) assertTrue(PddLaunchFallback.open(true, { true }, { error("browser must not start") }))
} }
@@ -27,6 +27,47 @@ import org.junit.Assert.assertTrue
import org.junit.Test import org.junit.Test
class PddProductDetailCollectorTest { 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( private fun prefixlessPanel(): UiSnapshot = UiSnapshot(PDD_PACKAGE, ACTIVITY, listOf(
node("root", "", 0, 0, 1080, 2376), node("root", "", 0, 0, 1080, 2376),
node("close", "关闭", 970, 270, 1050, 350, clickable = true), node("close", "关闭", 970, 270, 1050, 350, clickable = true),
@@ -1179,6 +1220,246 @@ class PddProductDetailCollectorTest {
assertTrue(driver.swipes.any { it.first == SwipeDirection.UP }) assertTrue(driver.swipes.any { it.first == SwipeDirection.UP })
} }
@Test
fun `color discovery counts only its own successful vertical swipes`() {
val colors = listOf("A色", "B色", "C色")
val events = mutableListOf<AgentDiagnosticEvent>()
val driver = FakeCollectorDriver(
colors = colors,
colorVerticalPages = listOf(colors.take(2), colors.takeLast(2)),
sizePages = listOf(listOf("S"), listOf("M"), listOf("L")),
rowSize = 1,
hideColorHeadingAfterFirstVerticalPage = true,
)
var clock = 0L
val result = PddProductDetailCollector(driver, { clock }, { clock += it }, taskId = 124, diagnostic = events::add)
.collect(GOODS_ID, rule(config().copy(timeoutsMs = config().timeoutsMs + ("overall" to 30000))))
assertTrue(result.successful)
val event = events.single { it.stage == AgentDiagnosticStage.COLOR_DISCOVERY }
assertEquals(2, event.colorVerticalSwipeCount)
assertTrue(driver.swipes.count { it.first == SwipeDirection.UP } > requireNotNull(event.colorVerticalSwipeCount))
assertTrue(driver.swipes.any { it.first == SwipeDirection.DOWN })
}
@Test
fun `color discovery failed vertical dispatch is not counted`() {
val events = mutableListOf<AgentDiagnosticEvent>()
val driver = FakeCollectorDriver(colors = listOf("A色", "B色"), rowSize = 1, verticalSwipeSucceeds = false)
var clock = 0L
val result = PddProductDetailCollector(driver, { clock }, { clock += it }, taskId = 124, diagnostic = events::add)
.collect(GOODS_ID, rule())
assertTrue(result.successful)
val event = events.single { it.stage == AgentDiagnosticStage.COLOR_DISCOVERY }
assertEquals(AgentDiagnosticReason.COLOR_SWIPE_FAILED, event.reason)
assertEquals(0, event.colorVerticalSwipeCount)
assertEquals(0, event.colorTrailingEmptyReadCount)
assertEquals(false, event.colorHorizontalIncomplete)
}
@Test
fun `color discovery counts empty parsed rounds after heading scrolls away without counting price reads`() {
val colors = listOf("A色", "B色", "C色", "D色")
val events = mutableListOf<AgentDiagnosticEvent>()
val driver = FakeCollectorDriver(
colors = colors,
colorVerticalPages = listOf(colors.take(2), colors.takeLast(2)),
rowSize = 1,
hideColorHeadingAfterFirstVerticalPage = true,
priceDelayReads = colors.associateWith { 2 },
)
var clock = 0L
val result = PddProductDetailCollector(driver, { clock }, { clock += it }, taskId = 124, diagnostic = events::add)
.collect(GOODS_ID, rule(config().copy(timeoutsMs = config().timeoutsMs + ("overall" to 30000))))
assertTrue(result.successful)
// This fixture retains the options while their heading disappears. The
// existing parser/traversal still misses the second page in this stage.
assertEquals(colors.take(2), requireNotNull(result.payload).dimensions.first { it.key == "color" }.values)
val event = events.single { it.stage == AgentDiagnosticStage.COLOR_DISCOVERY }
assertEquals(2, event.colorTrailingEmptyReadCount)
assertEquals(2, event.colorVerticalSwipeCount)
assertEquals(null, event.colorHorizontalIncomplete)
}
@Test
fun `color discovery initially empty is one empty round with unknown horizontal state`() {
val events = mutableListOf<AgentDiagnosticEvent>()
var clock = 0L
val result = PddProductDetailCollector(
FakeCollectorDriver(colors = emptyList()), { clock }, { clock += it }, taskId = 124, diagnostic = events::add,
).collect(GOODS_ID, rule())
assertTrue(result.successful)
val event = events.single { it.stage == AgentDiagnosticStage.COLOR_DISCOVERY }
assertEquals(0, event.colorVerticalSwipeCount)
assertEquals(1, event.colorTrailingEmptyReadCount)
assertEquals(null, event.colorHorizontalIncomplete)
}
@Test
fun `color discovery resets trailing empty rounds after nonempty discovery`() {
val colors = listOf("A色", "B色", "C色", "D色")
val events = mutableListOf<AgentDiagnosticEvent>()
val driver = FakeCollectorDriver(
colors = colors,
colorVerticalPages = listOf(colors.take(2), emptyList(), colors.takeLast(2)),
rowSize = 1,
)
var clock = 0L
val result = PddProductDetailCollector(driver, { clock }, { clock += it }, taskId = 124, diagnostic = events::add)
.collect(GOODS_ID, rule(config().copy(timeoutsMs = config().timeoutsMs + ("overall" to 30000))))
assertTrue(result.successful)
assertEquals(colors, requireNotNull(result.payload).dimensions.first { it.key == "color" }.values)
val event = events.single { it.stage == AgentDiagnosticStage.COLOR_DISCOVERY }
assertEquals(3, event.colorVerticalSwipeCount)
assertEquals(0, event.colorTrailingEmptyReadCount)
}
@Test
fun `color discovery stable horizontal rows are complete`() {
val events = mutableListOf<AgentDiagnosticEvent>()
var clock = 0L
val result = PddProductDetailCollector(
FakeCollectorDriver(colors = listOf("A色", "B色")), { clock }, { clock += it }, taskId = 124, diagnostic = events::add,
).collect(GOODS_ID, rule())
assertTrue(result.successful)
val event = events.single { it.stage == AgentDiagnosticStage.COLOR_DISCOVERY }
assertEquals(false, event.colorHorizontalIncomplete)
assertEquals(0, event.colorTrailingEmptyReadCount)
}
@Test
fun `color discovery horizontal dispatch failure before movement stays unknown`() {
val events = mutableListOf<AgentDiagnosticEvent>()
val base = FakeCollectorDriver(colors = listOf("A色", "B色"))
val driver = object : PddCollectorDriver by base {
override fun swipeSpec(direction: SwipeDirection, anchor: SnapshotNode?): Boolean =
if (direction == SwipeDirection.LEFT) false else base.swipeSpec(direction, anchor)
}
var clock = 0L
val result = PddProductDetailCollector(driver, { clock }, { clock += it }, taskId = 124, diagnostic = events::add)
.collect(GOODS_ID, rule())
assertTrue(result.successful)
val event = events.single { it.stage == AgentDiagnosticStage.COLOR_DISCOVERY }
assertEquals(0, event.horizontalSwipeCount)
assertEquals(null, event.colorHorizontalIncomplete)
}
@Test
fun `color discovery successful horizontal dispatch alone does not prove required exploration`() {
val events = mutableListOf<AgentDiagnosticEvent>()
var clock = 0L
val limited = config().copy(limits = config().limits + mapOf("specHorizontalSwipes" to 1, "stableEdgeReads" to 2))
val result = PddProductDetailCollector(
FakeCollectorDriver(colors = listOf("A色", "B色")), { clock }, { clock += it }, taskId = 124, diagnostic = events::add,
).collect(GOODS_ID, rule(limited))
assertTrue(result.successful)
val event = events.single { it.stage == AgentDiagnosticStage.COLOR_DISCOVERY }
assertEquals(1, event.horizontalSwipeCount)
assertEquals(null, event.colorHorizontalIncomplete)
}
@Test
fun `color discovery observed horizontal movement then failure is incomplete`() {
val colors = listOf("A色", "B色", "C色")
val events = mutableListOf<AgentDiagnosticEvent>()
val base = FakeCollectorDriver(colors = colors, colorPages = listOf(colors.take(2), colors.takeLast(2)))
var leftSwipes = 0
val driver = object : PddCollectorDriver by base {
override fun swipeSpec(direction: SwipeDirection, anchor: SnapshotNode?): Boolean {
if (direction == SwipeDirection.LEFT && leftSwipes++ > 0) return false
return base.swipeSpec(direction, anchor)
}
}
var clock = 0L
val result = PddProductDetailCollector(driver, { clock }, { clock += it }, taskId = 124, diagnostic = events::add)
.collect(GOODS_ID, rule())
assertTrue(result.successful)
assertEquals(colors, requireNotNull(result.payload).dimensions.first { it.key == "color" }.values)
val event = events.single { it.stage == AgentDiagnosticStage.COLOR_DISCOVERY }
assertEquals(1, event.horizontalSwipeCount)
assertEquals(true, event.colorHorizontalIncomplete)
}
@Test
fun `color discovery uncertain revisit preserves proven incomplete horizontal row`() {
val colors = listOf("A色", "B色", "C色", "D色", "E色")
val events = mutableListOf<AgentDiagnosticEvent>()
val base = FakeCollectorDriver(
colors = colors,
colorPages = listOf(listOf("A色", "B色", "D色", "E色"), listOf("B色", "C色", "D色", "E色")),
rowSize = 2,
resetHorizontalPageOnVerticalSwipe = true,
)
var leftSwipes = 0
val driver = object : PddCollectorDriver by base {
override fun swipeSpec(direction: SwipeDirection, anchor: SnapshotNode?): Boolean {
if (direction == SwipeDirection.LEFT && leftSwipes++ > 0) return false
return base.swipeSpec(direction, anchor)
}
}
var clock = 0L
val result = PddProductDetailCollector(driver, { clock }, { clock += it }, taskId = 124, diagnostic = events::add)
.collect(GOODS_ID, rule())
assertTrue(result.successful)
assertTrue(requireNotNull(result.payload).dimensions.first { it.key == "color" }.values.contains("C色"))
assertTrue(leftSwipes > 2)
// The first A/B visit moves then fails; later A/B visits fail before
// movement. Unknown new evidence must not erase that unresolved work.
assertEquals(true, events.single { it.stage == AgentDiagnosticStage.COLOR_DISCOVERY }.colorHorizontalIncomplete)
}
@Test
fun `color discovery observed horizontal movement at budget is incomplete`() {
val colors = listOf("A色", "B色", "C色")
val events = mutableListOf<AgentDiagnosticEvent>()
var clock = 0L
val limited = config().copy(limits = config().limits + ("specHorizontalSwipes" to 1))
val result = PddProductDetailCollector(
FakeCollectorDriver(colors = colors, colorPages = listOf(colors.take(2), colors.takeLast(2))),
{ clock }, { clock += it }, taskId = 124, diagnostic = events::add,
).collect(GOODS_ID, rule(limited))
assertTrue(result.successful)
assertEquals(true, events.single { it.stage == AgentDiagnosticStage.COLOR_DISCOVERY }.colorHorizontalIncomplete)
}
@Test
fun `color discovery disjoint horizontal reflow is unknown`() {
val colors = listOf("A色", "B色", "C色", "D色")
val events = mutableListOf<AgentDiagnosticEvent>()
var clock = 0L
val result = PddProductDetailCollector(
FakeCollectorDriver(colors = colors, colorPages = listOf(colors.take(2), colors.takeLast(2))),
{ clock }, { clock += it }, taskId = 124, diagnostic = events::add,
).collect(GOODS_ID, rule())
assertTrue(result.successful)
val event = events.single { it.stage == AgentDiagnosticStage.COLOR_DISCOVERY }
assertEquals(1, event.horizontalSwipeCount)
assertEquals(null, event.colorHorizontalIncomplete)
}
@Test @Test
fun `color discovery records only bounded aggregate evidence`() { fun `color discovery records only bounded aggregate evidence`() {
val colors = listOf("A色", "B色", "C色", "D色") val colors = listOf("A色", "B色", "C色", "D色")
@@ -1816,6 +2097,7 @@ class PddProductDetailCollectorTest {
sizes: List<String> = listOf("S"), sizes: List<String> = listOf("S"),
private val colorPages: List<List<String>> = listOf(colors), private val colorPages: List<List<String>> = listOf(colors),
private val colorVerticalPages: List<List<String>>? = null, private val colorVerticalPages: List<List<String>>? = null,
private val resetHorizontalPageOnVerticalSwipe: Boolean = false,
private val sizePages: List<List<String>> = listOf(sizes), private val sizePages: List<List<String>> = listOf(sizes),
private val prices: Map<String, Long> = colors.associateWith { 1000L }, private val prices: Map<String, Long> = colors.associateWith { 1000L },
private val failedClicks: Set<String> = emptySet(), private val failedClicks: Set<String> = emptySet(),
@@ -1826,6 +2108,7 @@ class PddProductDetailCollectorTest {
private val rowSize: Int = 4, private val rowSize: Int = 4,
private val hideSelectedColorOption: Boolean = false, private val hideSelectedColorOption: Boolean = false,
private val hideDimensionHeadingsAfterFirstVerticalPage: Boolean = false, private val hideDimensionHeadingsAfterFirstVerticalPage: Boolean = false,
private val hideColorHeadingAfterFirstVerticalPage: Boolean = false,
private val soldOutUntilPulls: Int = 0, private val soldOutUntilPulls: Int = 0,
private val startWithPanelClosed: Boolean = false, private val startWithPanelClosed: Boolean = false,
private val firstEntryOpensReview: Boolean = false, private val firstEntryOpensReview: Boolean = false,
@@ -1945,7 +2228,7 @@ class PddProductDetailCollectorTest {
?: colorPages[horizontalPage.coerceAtMost(colorPages.lastIndex)] ?: colorPages[horizontalPage.coerceAtMost(colorPages.lastIndex)]
val colorRowCount = (visibleColors.size + rowSize - 1) / rowSize val colorRowCount = (visibleColors.size + rowSize - 1) / rowSize
val sizeHeadingTop = maxOf(700, 470 + colorRowCount * 90 + 20) val sizeHeadingTop = maxOf(700, 470 + colorRowCount * 90 + 20)
if (!continuationPage && !panelCollapsedNow) { if (!continuationPage && !panelCollapsedNow && !(hideColorHeadingAfterFirstVerticalPage && verticalPage > 0)) {
nodes += node("scroll/color-heading", "颜色分类", 20, 400, 300, 450, parentPath = "scroll") nodes += node("scroll/color-heading", "颜色分类", 20, 400, 300, 450, parentPath = "scroll")
} }
if (!continuationPage && !hideColorNow && !panelCollapsedNow) { if (!continuationPage && !hideColorNow && !panelCollapsedNow) {
@@ -2077,8 +2360,11 @@ class PddProductDetailCollectorTest {
if ((direction == SwipeDirection.UP || direction == SwipeDirection.DOWN) && !verticalSwipeSucceeds) return false if ((direction == SwipeDirection.UP || direction == SwipeDirection.DOWN) && !verticalSwipeSucceeds) return false
when (direction) { when (direction) {
SwipeDirection.LEFT -> horizontalPage = (horizontalPage + 1).coerceAtMost(colorPages.lastIndex) SwipeDirection.LEFT -> horizontalPage = (horizontalPage + 1).coerceAtMost(colorPages.lastIndex)
SwipeDirection.UP -> verticalPage = (verticalPage + 1) SwipeDirection.UP -> {
.coerceAtMost(maxOf(sizePages.lastIndex, colorVerticalPages?.lastIndex ?: 0)) verticalPage = (verticalPage + 1)
.coerceAtMost(maxOf(sizePages.lastIndex, colorVerticalPages?.lastIndex ?: 0))
if (resetHorizontalPageOnVerticalSwipe) horizontalPage = 0
}
SwipeDirection.DOWN -> { SwipeDirection.DOWN -> {
verticalPage = (verticalPage - 1).coerceAtLeast(0) verticalPage = (verticalPage - 1).coerceAtLeast(0)
downSwipeCount++ downSwipeCount++
@@ -18,6 +18,354 @@ import org.junit.Assert.assertTrue
import org.junit.Test import org.junit.Test
class PurchaseLiveAutomationTest { class PurchaseLiveAutomationTest {
@Test
fun `hint delayed address update preserves fresh payment back budget for late order evidence`() {
val observationDriver = OrderObservationDriver { sample ->
observationPage(when {
sample == 1 -> "payment"
sample < 140 -> "loading"
else -> "complete"
})
}.apply { backDuration = 4_000 }
lateinit var addressDriver: LiveDriver
addressDriver = LiveDriver(initialEditFrame = { snapshot, sample ->
assertEquals(0, addressDriver.inputCount)
assertFalse(addressDriver.clicked.contains("保存"))
if (sample <= 4) observationDriver.elapsed += 10_000
snapshot.changeAddressEditor {
it.copy(
text = if (sample <= 4) "合成提示占位标记" else "合成测试区域示例路段1号-old",
hintText = "合成提示占位标记",
showingHintText = sample <= 4,
)
}
})
var observingOrder = false
val driver = object : PurchaseUiDriver by addressDriver {
override fun capture(): UiSnapshot =
if (observingOrder) observationDriver.capture() else addressDriver.capture()
override fun backPurchase(): Boolean =
if (observingOrder) observationDriver.backPurchase() else addressDriver.backPurchase()
override fun clickFresh(target: SnapshotNode): FreshActionResult =
if (observingOrder) observationDriver.clickFresh(target) else addressDriver.clickFresh(target)
override fun swipePurchase(direction: SwipeDirection, durationMs: Long): Boolean =
if (observingOrder) observationDriver.swipePurchase(direction, durationMs)
else addressDriver.swipePurchase(direction, durationMs)
override fun bringPddToForeground(): Boolean =
if (observingOrder) observationDriver.bringPddToForeground() else addressDriver.bringPddToForeground()
}
val automation = PurchaseLiveAutomation(
driver,
pause = { observationDriver.elapsed += it },
monotonicClockMs = { observationDriver.elapsed },
)
val address = automation.updateShippingAddress("_cg366")
assertEquals(5, addressDriver.initialEditCaptures)
assertEquals("合成测试区域示例路段1号-old", addressDriver.lastInputTarget?.text)
assertTrue(address.expectedAddress.endsWith("_cg366"))
assertFalse(address.expectedAddress.contains("合成提示占位标记"))
assertAddressEditorActions(addressDriver, saved = true)
assertTrue(observationDriver.elapsed > 40_000)
assertEquals("_cg366", automation.finalConfirmation(input().copy(addressSuffix = "_cg366"), address).addressSuffix)
automation.submitOrderOnce()
val clicksBeforeObservation = addressDriver.clicked.toList()
observingOrder = true
val order = automation.readOrderResult()
assertEquals("PDD-DEMO-365", order?.orderNo)
assertEquals("2026-10-08T03:21:43Z", order?.submittedAt)
assertEquals(140, observationDriver.captures)
val elapsedAfterBack = observationDriver.elapsed - observationDriver.backCompletedAt!!
assertTrue(elapsedAfterBack in 28_000L until 30_000L)
assertTrue(elapsedAfterBack + observationDriver.backDuration > 30_000)
assertObservationActions(observationDriver, backs = 1)
assertEquals(1, addressDriver.inputCount)
assertEquals(1, addressDriver.clicked.count { it == "保存" })
assertEquals(1, addressDriver.submitClicks)
assertEquals(clicksBeforeObservation, addressDriver.clicked)
assertEquals(0, addressDriver.genericSwipes + addressDriver.scopedSwipes)
assertFalse(addressDriver.clicked.any { it.contains("支付") })
}
@Test
fun `payment back observes late complete order beyond original sample budget`() {
val driver = OrderObservationDriver { sample ->
when {
sample == 1 -> observationPage("payment")
sample < 100 -> observationPage("loading")
else -> observationPage("complete")
}
}
val order = observationAutomation(driver).readOrderResult()
assertEquals("PDD-DEMO-365", order?.orderNo)
assertEquals(100, driver.captures)
assertEquals(1, driver.backs)
assertEquals(0, driver.swipes)
assertEquals(0, driver.clicks)
assertEquals(0, driver.foregroundRequests)
}
@Test
fun `deadline starts at late successful back and never resets on incomplete evidence or jitter`() {
val driver = OrderObservationDriver { sample ->
observationPage(when {
sample < 58 -> "loading"
sample == 58 -> "payment"
sample > 60 && sample % 3 == 0 -> "incomplete"
else -> "loading"
})
}.apply { backDuration = 4_000; captureDuration = 25 }
val automation = observationAutomation(driver, pauseOverhead = 17)
assertEquals(null, automation.readOrderResult())
assertTrue(driver.captures > 100)
assertEquals(driver.backCompletedAt!! + 30_000 + 17, driver.elapsed)
assertTrue(automation.lastOrderReadFailure!!.paymentPageObserved)
assertObservationActions(driver, backs = 1)
}
@Test
fun `capture that crosses deadline cannot accept late complete evidence or trigger unpaid scroll`() {
for (latePage in listOf("complete", "unpaid", "entry", "chooser", "wechat")) {
lateinit var driver: OrderObservationDriver
driver = OrderObservationDriver { sample ->
if (sample == 1) {
driver.captureDuration = 30_000
observationPage("payment")
} else observationPage(latePage)
}
val automation = observationAutomation(driver)
assertEquals(null, automation.readOrderResult())
assertEquals(2, driver.captures)
assertEquals(30_500L, driver.elapsed)
assertObservationActions(driver, backs = 1)
}
}
@Test
fun `slow captures exhaust fixed observation deadline before original sixty samples`() {
val driver = OrderObservationDriver { sample -> observationPage(if (sample == 1) "payment" else "loading") }
.apply { captureDuration = 1_000 }
val automation = observationAutomation(driver)
assertEquals(null, automation.readOrderResult())
assertTrue(driver.captures < 60)
assertTrue(driver.elapsed >= driver.backCompletedAt!! + 30_000)
assertTrue(driver.elapsed < driver.backCompletedAt!! + 31_000)
assertObservationActions(driver, backs = 1)
}
@Test
fun `incomplete context first appearing during extension stays passive until late time arrives`() {
val driver = OrderObservationDriver { sample -> observationPage(when {
sample == 1 -> "payment"
sample < 80 -> "loading"
sample < 100 -> "incomplete"
else -> "complete"
}) }
assertEquals("PDD-DEMO-365", observationAutomation(driver).readOrderResult()?.orderNo)
assertEquals(100, driver.captures)
assertObservationActions(driver, backs = 1)
}
@Test
fun `late detail entry chooser and WeChat cannot add navigation during extension`() {
for (latePage in listOf("entry", "chooser", "wechat")) {
val driver = OrderObservationDriver { sample -> observationPage(when {
sample == 1 -> "payment"
sample <= 60 -> "loading"
else -> latePage
}) }
val automation = observationAutomation(driver)
assertEquals(null, automation.readOrderResult())
assertEquals("PURCHASE_ORDER_CONTEXT_NOT_FOUND", automation.lastOrderReadFailure?.code)
assertTrue(driver.captures > 60)
assertObservationActions(driver, backs = 1)
}
}
@Test
fun `previous chooser and WeChat recovery retain repeat failures during extension`() {
for (kind in listOf("chooser", "wechat")) {
val driver = OrderObservationDriver { sample -> observationPage(when {
sample == 1 || sample > 60 -> kind
sample == 2 -> "payment"
else -> "loading"
}) }
val automation = observationAutomation(driver)
assertEquals(null, automation.readOrderResult())
assertEquals(
if (kind == "chooser") "PURCHASE_ORDER_CHOOSER_BACK_FAILED" else "PURCHASE_ORDER_WECHAT_RESTORE_TIMEOUT",
automation.lastOrderReadFailure?.code,
)
assertEquals(61, driver.captures)
assertEquals(if (kind == "chooser") 2 else 1, driver.backs)
assertEquals(if (kind == "wechat") 1 else 0, driver.foregroundRequests)
assertEquals(0, driver.swipes)
assertEquals(0, driver.clicks)
}
}
@Test
fun `late unpaid context retains its independent thirty samples and four swipes`() {
val driver = OrderObservationDriver { sample -> observationPage(when {
sample == 1 -> "payment"
sample < 140 -> "loading"
else -> "unpaid"
}) }
val automation = observationAutomation(driver)
assertEquals(null, automation.readOrderResult())
assertEquals(169, driver.captures)
assertTrue(driver.elapsed > 30_000)
assertObservationActions(driver, backs = 1, swipes = 4)
}
@Test
fun `unpaid markers alone never activate payment back extension`() {
val driver = OrderObservationDriver { observationPage("unpaid") }
val automation = observationAutomation(driver)
assertEquals(null, automation.readOrderResult())
assertEquals(30, driver.captures)
assertTrue(automation.lastOrderReadFailure!!.paymentPageObserved)
assertObservationActions(driver, swipes = 4)
}
@Test
fun `missing payment back retains original sixty samples and existing context scroll budget`() {
for (kind in listOf("loading", "incomplete")) {
val driver = OrderObservationDriver { observationPage(kind) }
assertEquals(null, observationAutomation(driver).readOrderResult())
assertEquals(60, driver.captures)
assertObservationActions(driver, swipes = if (kind == "incomplete") 3 else 0)
}
}
@Test
fun `order context within original budget retains original sampling and gestures after back`() {
val driver = OrderObservationDriver { sample -> observationPage(if (sample == 1) "payment" else "incomplete") }
assertEquals(null, observationAutomation(driver).readOrderResult())
assertEquals(60, driver.captures)
assertObservationActions(driver, backs = 1, swipes = 3)
}
@Test
fun `failed payment back stops immediately without activating observation`() {
val driver = OrderObservationDriver { observationPage("payment") }.apply { backSucceeds = false }
val automation = observationAutomation(driver)
assertEquals(null, automation.readOrderResult())
assertEquals("PURCHASE_ORDER_PAYMENT_BACK_FAILED", automation.lastOrderReadFailure?.code)
assertEquals(1, driver.captures)
assertEquals(0L, driver.elapsed)
assertObservationActions(driver, backs = 1)
}
@Test
fun `extension preserves repeated payment empty and unexpected app early failures`() {
for ((kind, code, expectedCaptures) in listOf(
Triple("payment", "PURCHASE_ORDER_PAYMENT_REPEATED", 85),
Triple("empty", "PURCHASE_ORDER_EMPTY_TIMEOUT", 75),
Triple("unexpected", "PURCHASE_ORDER_UNEXPECTED_APP", 61),
)) {
val driver = OrderObservationDriver { sample -> observationPage(when {
sample == 1 -> "payment"
sample <= 60 -> "loading"
else -> kind
}) }
val automation = observationAutomation(driver)
assertEquals(null, automation.readOrderResult())
assertEquals(code, automation.lastOrderReadFailure?.code)
assertEquals(expectedCaptures, driver.captures)
assertObservationActions(driver, backs = 1)
}
}
private fun observationAutomation(driver: OrderObservationDriver, pauseOverhead: Long = 0) = PurchaseLiveAutomation(
driver,
pause = { driver.elapsed += it + pauseOverhead },
monotonicClockMs = { driver.elapsed },
)
private fun assertObservationActions(driver: OrderObservationDriver, backs: Int = 0, swipes: Int = 0) {
assertEquals(backs, driver.backs)
assertEquals(swipes, driver.swipes)
assertEquals(0, driver.clicks)
assertEquals(0, driver.foregroundRequests)
}
private class OrderObservationDriver(
val frame: (Int) -> UiSnapshot,
) : PurchaseUiDriver by LiveDriver() {
var elapsed = 0L
var captures = 0
var backs = 0
var swipes = 0
var clicks = 0
var foregroundRequests = 0
var captureDuration = 0L
var backDuration = 0L
var backCompletedAt: Long? = null
var backSucceeds = true
override fun capture(): UiSnapshot {
elapsed += captureDuration
return frame(++captures)
}
override fun backPurchase(): Boolean {
backs++
elapsed += backDuration
if (backSucceeds) backCompletedAt = elapsed
return backSucceeds
}
override fun swipePurchase(direction: SwipeDirection, durationMs: Long): Boolean {
swipes++
return true
}
override fun clickFresh(target: SnapshotNode): FreshActionResult {
clicks++
return FreshActionResult.SUCCESS
}
override fun bringPddToForeground(): Boolean {
foregroundRequests++
return true
}
}
private fun observationPage(kind: String): UiSnapshot {
val labels = when (kind) {
"payment" -> listOf("立即支付")
"complete" -> listOf("订单号:PDD-DEMO-365", "下单时间:2026-10-08 11:21:43", "立即支付")
"incomplete" -> listOf("订单号:PDD-DEMO-365", "立即支付")
"unpaid" -> listOf("待付款")
"entry" -> listOf("查看订单")
"chooser" -> listOf("选择要使用的应用", "微信")
else -> emptyList()
}
return UiSnapshot(
when (kind) {
"empty" -> null
"wechat" -> "com.tencent.mm"
"unexpected" -> "com.example.unexpected"
"chooser" -> "android"
else -> PDD
},
when (kind) {
"payment" -> "com.xunmeng.pinduoduo.app_pay.core.PayActivity"
"chooser" -> "com.android.internal.app.ChooserActivity"
else -> ACTIVITY
},
labels.map { text ->
SnapshotNode(text, null, text, null, null, "android.widget.TextView",
NodeBounds(0, 100, 500, 180), true, false, false, false, true, true)
},
)
}
@Test @Test
fun `two second payment transition and two needed scrolls yield order and payable amount without clicks`() { fun `two second payment transition and two needed scrolls yield order and payable amount without clicks`() {
val driver = ReadOnlyOrderDriver(paymentMs = 2500) val driver = ReadOnlyOrderDriver(paymentMs = 2500)
@@ -219,6 +567,338 @@ class PurchaseLiveAutomationTest {
assertFalse(driver.clicked.any { it.contains("支付") }) assertFalse(driver.clicked.any { it.contains("支付") })
} }
@Test
fun `address editor waits for an input arriving after its title`() {
assertDelayedAddressEditor { snapshot, sample ->
if (sample <= 4) snapshot.copy(nodes = snapshot.nodes.filterNot { it.path == "editor-address" }) else snapshot
}
}
@Test
fun `address editor waits for a populated value`() {
assertDelayedAddressEditor { snapshot, sample ->
snapshot.changeAddressEditor { if (sample <= 4) it.copy(text = " ") else it }
}
}
@Test
fun `address editor description cannot substitute for empty text`() {
for (text in listOf(null, "", " ")) {
assertAddressEditorTimeout("editor_empty", 1, 0) { snapshot, _ ->
snapshot.changeAddressEditor { it.copy(text = text, contentDescription = "合成说明占位标记") }
}
}
}
@Test
fun `address editor waits until hint is replaced by real text`() {
assertDelayedAddressEditor { snapshot, sample ->
snapshot.changeAddressEditor {
it.copy(
text = if (sample <= 4) "合成提示占位标记" else it.text,
hintText = "合成提示占位标记",
showingHintText = sample <= 4,
)
}
}
}
@Test
fun `address editor persistent hint text fails after the full budget`() {
assertAddressEditorTimeout("editor_hint", 1, 0) { snapshot, _ ->
snapshot.changeAddressEditor {
it.copy(text = "合成提示占位标记", hintText = "合成提示占位标记", showingHintText = true)
}
}
}
@Test
fun `address editor rejects text equal to trimmed hint despite false or absent showing flag`() {
for (showing in listOf(false, null)) {
assertAddressEditorTimeout("editor_hint", 1, 0) { snapshot, _ ->
snapshot.changeAddressEditor {
it.copy(text = " 合成提示占位标记 ", hintText = " 合成提示占位标记 ", showingHintText = showing)
}
}
}
}
@Test
fun `address editor showing hint flag blocks nonmatching or missing hint text`() {
for (hint in listOf(null, "", "合成提示占位标记")) {
assertAddressEditorTimeout("editor_hint", 1, 0) { snapshot, _ ->
snapshot.changeAddressEditor { it.copy(hintText = hint, showingHintText = true) }
}
}
}
@Test
fun `address editor empty text remains empty even when hint and description are present`() {
assertAddressEditorTimeout("editor_empty", 1, 0) { snapshot, _ ->
snapshot.changeAddressEditor {
it.copy(text = " ", contentDescription = "合成说明占位标记", hintText = "合成提示占位标记", showingHintText = true)
}
}
}
@Test
fun `address editor missing hint metadata remains compatible and uses actual text`() {
val driver = LiveDriver(initialEditFrame = { snapshot, _ ->
snapshot.changeAddressEditor {
assertEquals(null, it.hintText)
assertEquals(null, it.showingHintText)
it.copy(text = " 合成真实地址-old ", contentDescription = "合成说明占位标记")
}
})
val proof = PurchaseLiveAutomation(driver, pause = {}).updateShippingAddress("_cg366")
assertEquals("合成真实地址_cg366", proof.expectedAddress)
assertEquals(1, driver.initialEditCaptures)
assertTrue(driver.lastInputTarget === driver.lastInitialEditSnapshot?.nodes?.single { it.path == "editor-address" })
assertAddressEditorActions(driver, saved = true)
}
@Test
fun `address editor accepts actual text with absent false or blank hint metadata`() {
for (showing in listOf(null, false)) {
for (hint in listOf(null, "", " ", "合成提示占位标记")) {
val driver = LiveDriver(initialEditFrame = { snapshot, _ ->
snapshot.changeAddressEditor { it.copy(hintText = hint, showingHintText = showing) }
})
val proof = PurchaseLiveAutomation(driver, pause = {}).updateShippingAddress("_cg366")
assertTrue(proof.expectedAddress.endsWith("_cg366"))
assertEquals(1, driver.initialEditCaptures)
assertAddressEditorActions(driver, saved = true)
}
}
}
@Test
fun `address editor waits for an enabled input`() {
assertDelayedAddressEditor { snapshot, sample ->
snapshot.changeAddressEditor { if (sample <= 4) it.copy(enabled = false) else it }
}
}
@Test
fun `address editor tolerates more than three ambiguous frames before unique readiness`() {
assertDelayedAddressEditor { snapshot, sample ->
if (sample <= 4) snapshot.withSecondAddressEditor() else snapshot
}
}
@Test
fun `address editor uses the value and node from the successful readiness frame`() {
val driver = LiveDriver(initialEditFrame = { snapshot, sample ->
snapshot.changeAddressEditor { it.copy(text = if (sample == 1) "" else "合成地址第${sample}帧-old") }
})
val proof = PurchaseLiveAutomation(driver, pause = {}).updateShippingAddress("_cg366")
assertEquals("合成地址第2帧_cg366", proof.expectedAddress)
assertEquals(2, driver.initialEditCaptures)
assertTrue(driver.lastInputTarget === driver.lastInitialEditSnapshot?.nodes?.single { it.path == "editor-address" })
assertAddressEditorActions(driver, saved = true)
}
@Test
fun `address editor structural ambiguity waits full budget even with only one ready input`() {
for (second in listOf<(SnapshotNode) -> SnapshotNode>(
{ it.copy(text = "") }, { it.copy(enabled = false) },
)) {
assertAddressEditorTimeout("editor_ambiguous", structural = 2, ready = 1) { snapshot, _ ->
snapshot.withSecondAddressEditor(second)
}
}
}
@Test
fun `address editor final zero inputs reports scalar missing reason`() {
assertAddressEditorTimeout("editor_missing", 0, 0) { snapshot, _ ->
snapshot.copy(nodes = snapshot.nodes.filterNot { it.path == "editor-address" })
}
}
@Test
fun `address editor final empty input reports scalar empty reason`() {
assertAddressEditorTimeout("editor_empty", 1, 0) { snapshot, _ ->
snapshot.changeAddressEditor { it.copy(text = " ") }
}
}
@Test
fun `address editor final disabled input reports scalar disabled reason`() {
assertAddressEditorTimeout("editor_disabled", 1, 0) { snapshot, _ ->
snapshot.changeAddressEditor { it.copy(enabled = false) }
}
}
@Test
fun `address editor final multiple inputs reports scalar ambiguity reason`() {
assertAddressEditorTimeout("editor_ambiguous", 2, 2) { snapshot, _ -> snapshot.withSecondAddressEditor() }
}
@Test
fun `address editor never seeing a title keeps edit timeout and one shared budget`() {
assertAddressEditorTimeout("title_missing", 0, 0, titleSeen = false) { snapshot, _ ->
snapshot.copy(nodes = snapshot.nodes.filterNot { it.path == "detail" })
}
}
@Test
fun `address editor remembers a title even when the final snapshot is empty`() {
assertAddressEditorTimeout("editor_missing", 0, 0) { snapshot, sample ->
if (sample == 1) snapshot.copy(nodes = snapshot.nodes.filterNot { it.path == "editor-address" })
else snapshot.copy(nodes = emptyList())
}
}
@Test
fun `address editor late title does not start a second wait budget`() {
assertAddressEditorTimeout("editor_empty", 1, 0) { snapshot, sample ->
val emptyEditor = snapshot.changeAddressEditor { it.copy(text = "") }
if (sample < 50) emptyEditor.copy(nodes = emptyEditor.nodes.filterNot { it.path == "detail" }) else emptyEditor
}
}
@Test
fun `address editor can become ready on the final sample of its shared budget`() {
val driver = LiveDriver(initialEditFrame = { snapshot, sample ->
if (sample < 50) snapshot.copy(nodes = emptyList()) else snapshot
})
val editPauses = mutableListOf<Long>()
PurchaseLiveAutomation(driver, pause = {
if (driver.currentPage == "edit" && driver.inputCount == 0) editPauses += it
}).updateShippingAddress("_cg366")
assertEquals(50, driver.initialEditCaptures)
assertEquals(listOf(500L) + List(49) { 200L }, editPauses)
assertAddressEditorActions(driver, saved = true)
}
@Test
fun `address editor excludes recipient phone invisible and leftward inputs and deduplicates paths`() {
val driver = LiveDriver(initialEditFrame = { snapshot, _ ->
val editor = snapshot.nodes.single { it.path == "editor-address" }
snapshot.copy(nodes = snapshot.nodes + listOf(
editor,
editor.copy(path = "hidden-input", visible = false),
editor.copy(path = "left-input", bounds = NodeBounds(0, 390, 10, 480)),
))
})
PurchaseLiveAutomation(driver, pause = {}).updateShippingAddress("_cg366")
assertEquals(1, driver.initialEditCaptures)
assertEquals("editor-address", driver.lastInputTargetPath)
assertAddressEditorActions(driver, saved = true)
}
@Test
fun `address editor small screen row overlap remains ambiguous`() {
assertAddressEditorTimeout("editor_ambiguous", 2, 2) { snapshot, _ ->
snapshot.copy(nodes = snapshot.nodes.map {
if (it.path == "editor-phone") it.copy(bounds = NodeBounds(180, 360, 900, 410)) else it
})
}
}
@Test
fun `address editor fresh input failures never retry or save`() {
for (result in FreshActionResult.values().filter { it != FreshActionResult.SUCCESS }) {
val driver = LiveDriver(inputResult = result)
val error = runCatching { PurchaseLiveAutomation(driver, pause = {}).updateShippingAddress("_cg366") }
.exceptionOrNull() as PurchaseLiveException
assertEquals("PURCHASE_ADDRESS_UPDATE_FAILED", error.code)
assertEquals(1, driver.inputCount)
assertEquals(1, driver.initialEditCaptures)
assertEquals(listOf("138****5678", "修改"), driver.clicked)
assertEquals(0, driver.backCount)
assertEquals(0, driver.scopedSwipes + driver.genericSwipes + driver.submitClicks)
}
}
@Test
fun `address editor page safety problem interrupts readiness without input`() {
val driver = LiveDriver(initialEditFrame = { snapshot, sample ->
if (sample == 1) snapshot.changeAddressEditor { it.copy(text = "") }
else snapshot.copy(nodes = snapshot.nodes.map { if (it.path == "title") it.copy(text = "立即支付") else it })
})
val error = runCatching { PurchaseLiveAutomation(driver, pause = {}).updateShippingAddress("_cg366") }
.exceptionOrNull() as PurchaseLiveException
assertEquals("PURCHASE_PAYMENT_FORBIDDEN", error.code)
assertEquals(2, driver.initialEditCaptures)
assertAddressEditorActions(driver, saved = false)
}
private fun assertDelayedAddressEditor(frame: (UiSnapshot, Int) -> UiSnapshot) {
val driver = LiveDriver(initialEditFrame = frame)
val editPauses = mutableListOf<Long>()
val automation = PurchaseLiveAutomation(driver, pause = {
if (driver.currentPage == "edit" && driver.inputCount == 0) editPauses += it
})
val proof = automation.updateShippingAddress("_cg366")
assertTrue(proof.expectedAddress.endsWith("_cg366"))
assertEquals(5, driver.initialEditCaptures)
assertEquals(listOf(500L, 200L, 200L, 200L, 200L), editPauses)
assertEquals("editor-address", driver.lastInputTargetPath)
assertAddressEditorActions(driver, saved = true)
}
private fun assertAddressEditorTimeout(
reason: String,
structural: Int,
ready: Int,
titleSeen: Boolean = true,
frame: (UiSnapshot, Int) -> UiSnapshot,
) {
val driver = LiveDriver(initialEditFrame = frame)
val diagnostics = mutableListOf<String>()
val editPauses = mutableListOf<Long>()
val automation = PurchaseLiveAutomation(driver, pause = {
if (driver.currentPage == "edit" && driver.inputCount == 0) editPauses += it
}, panelDiagnostic = diagnostics::add)
val failure = runCatching { automation.updateShippingAddress("_cg366") }.exceptionOrNull()
assertTrue("Readiness must fail without changing the address", failure is PurchaseLiveException)
val error = failure as PurchaseLiveException
assertEquals(if (titleSeen) "PURCHASE_ADDRESS_UPDATE_FAILED" else "PURCHASE_ADDRESS_EDIT_TIMEOUT", error.code)
val diagnostic = "stage=address_editor_ready;reason=$reason;titleSeen=$titleSeen;structural=$structural;ready=$ready"
assertTrue(error.message.orEmpty(), error.message.orEmpty().endsWith("[$diagnostic]"))
assertEquals(50, driver.initialEditCaptures)
assertEquals(listOf(500L) + List(50) { 200L }, editPauses)
val emitted = error.message.orEmpty() + diagnostics.joinToString()
listOf("广东", "骏景", "测试收货人", "13800000000", "editor-address", "_cg366", "合成提示占位标记", "合成说明占位标记").forEach {
assertFalse("Sensitive fixture value in readiness diagnostics", emitted.contains(it))
}
assertAddressEditorActions(driver, saved = false)
}
private fun assertAddressEditorActions(driver: LiveDriver, saved: Boolean) {
assertEquals(if (saved) 1 else 0, driver.inputCount)
assertEquals(listOf("138****5678", "修改") + if (saved) listOf("保存") else emptyList(), driver.clicked)
assertEquals(if (saved) 1 else 0, driver.backCount)
assertEquals(1, driver.addressPathClicks)
assertEquals(0, driver.addressTaps + driver.genericSwipes + driver.scopedSwipes + driver.submitClicks)
}
private fun UiSnapshot.changeAddressEditor(change: (SnapshotNode) -> SnapshotNode): UiSnapshot =
copy(nodes = nodes.map { if (it.path == "editor-address") change(it) else it })
private fun UiSnapshot.withSecondAddressEditor(change: (SnapshotNode) -> SnapshotNode = { it }): UiSnapshot =
copy(nodes = nodes + change(nodes.single { it.path == "editor-address" }.copy(path = "editor-address-2")))
@Test @Test
fun `split confirmation address keeps the unique saved task suffix proof`() { fun `split confirmation address keeps the unique saved task suffix proof`() {
val driver = LiveDriver(splitConfirmationAddress = true) val driver = LiveDriver(splitConfirmationAddress = true)
@@ -788,6 +1468,8 @@ class PurchaseLiveAutomationTest {
private val orderDetailEntryAfterSubmit: Boolean = false, private val orderDetailEntryAfterSubmit: Boolean = false,
private val orderDetailEvidenceBelowFold: Boolean = false, private val orderDetailEvidenceBelowFold: Boolean = false,
private val duplicateOrderDetailEntry: Boolean = false, private val duplicateOrderDetailEntry: Boolean = false,
private val initialEditFrame: (UiSnapshot, Int) -> UiSnapshot = { snapshot, _ -> snapshot },
private val inputResult: FreshActionResult = FreshActionResult.SUCCESS,
postSubmitCaptureSequence: List<String> = emptyList(), postSubmitCaptureSequence: List<String> = emptyList(),
) : PurchaseUiDriver { ) : PurchaseUiDriver {
private var page = "confirmation" private var page = "confirmation"
@@ -802,6 +1484,10 @@ class PurchaseLiveAutomationTest {
var addressTaps = 0 var addressTaps = 0
var addressPathClicks = 0 var addressPathClicks = 0
var lastInputTargetPath: String? = null var lastInputTargetPath: String? = null
var lastInputTarget: SnapshotNode? = null
var lastInitialEditSnapshot: UiSnapshot? = null
var initialEditCaptures = 0
var inputCount = 0
var backCount = 0 var backCount = 0
var postSubmitBackCount = 0 var postSubmitBackCount = 0
var pddRestoreCount = 0 var pddRestoreCount = 0
@@ -814,7 +1500,12 @@ class PurchaseLiveAutomationTest {
postSubmitCaptureCount++ postSubmitCaptureCount++
if (pendingPostSubmitPages.isNotEmpty()) page = pendingPostSubmitPages.removeAt(0) if (pendingPostSubmitPages.isNotEmpty()) page = pendingPostSubmitPages.removeAt(0)
} }
return currentSnapshot() val snapshot = currentSnapshot()
if (page == "edit" && inputCount == 0) {
initialEditCaptures++
return initialEditFrame(snapshot, initialEditCaptures).also { lastInitialEditSnapshot = it }
}
return snapshot
} }
private fun currentSnapshot(): UiSnapshot = when (page) { private fun currentSnapshot(): UiSnapshot = when (page) {
@@ -986,7 +1677,10 @@ class PurchaseLiveAutomationTest {
} }
override fun inputFresh(target: SnapshotNode, value: String): FreshActionResult { override fun inputFresh(target: SnapshotNode, value: String): FreshActionResult {
inputCount++
lastInputTarget = target
lastInputTargetPath = target.path lastInputTargetPath = target.path
if (inputResult != FreshActionResult.SUCCESS) return inputResult
address = value address = value
return FreshActionResult.SUCCESS return FreshActionResult.SUCCESS
} }
@@ -0,0 +1,75 @@
package cn.ilapage.goauto.agent
import cn.ilapage.goauto.agent.automation.*
import org.junit.Assert.*
import org.junit.Test
class PurchaseNavigationReadinessTest {
private val pdd = PurchaseNavigationReadiness.PDD_PACKAGE
private val browser = "com.heytap.browser"
private val old = ProductStabilityKey("old", true, false, false)
private val fresh = old.copy(title = "new")
@Test fun `diag3 browser route ignores old product before browser then buffers returned pdd`() {
val policy = PurchaseNavigationReadiness(0, PddLaunchPath.BROWSER, 0)
for (now in listOf(921L, 1_000L, 1_738L)) assertFalse(policy.observe(now, pdd, old))
assertFalse(policy.observe(1_739, browser, null))
assertFalse(policy.observe(12_189, pdd, old))
assertFalse(policy.observe(13_000, pdd, old))
assertFalse(policy.observe(13_218, pdd, fresh))
assertFalse(policy.observe(14_218, pdd, fresh))
assertFalse(policy.observe(15_188, pdd, fresh))
assertTrue(policy.observe(15_189, pdd, fresh))
}
@Test fun `stable initial product cannot pass browser route even after buffer`() {
val policy = PurchaseNavigationReadiness(0, PddLaunchPath.BROWSER, 0)
for (now in 0L..30_000L step 100) assertFalse(policy.observe(now, pdd, old))
assertTrue(policy.expired(30_000))
}
@Test fun `direct route requires buffer plus three stable samples without an empty frame`() {
val policy = PurchaseNavigationReadiness(0, PddLaunchPath.DIRECT, 500)
assertFalse(policy.observe(500, pdd, old))
assertFalse(policy.observe(2_500, pdd, old))
assertFalse(policy.observe(3_499, pdd, old))
assertTrue(policy.observe(3_500, pdd, old))
val three = PurchaseNavigationReadiness(0, PddLaunchPath.DIRECT, 0)
assertFalse(three.observe(3_000, pdd, old))
assertFalse(three.observe(4_000, pdd, old))
assertTrue(three.observe(4_001, pdd, old))
}
@Test fun `stability resets on package root missing evidence title and structure changes`() {
for ((pkg, key) in listOf(browser to null, pdd to null, pdd to fresh, pdd to old.copy(panel = true))) {
val policy = PurchaseNavigationReadiness(0, PddLaunchPath.DIRECT, 0)
policy.observe(2_000, pdd, old)
policy.observe(2_500, pdd, old)
assertFalse(policy.observe(2_999, pkg, key))
assertFalse(policy.observe(3_000, pdd, old))
assertFalse(policy.observe(3_999, pdd, old))
assertTrue(policy.observe(4_000, pdd, old))
}
}
@Test fun `unknown path never passes and only approved browser route clicks at least one second apart`() {
val unknown = PurchaseNavigationReadiness(0, null, 0)
assertFalse(unknown.observe(4_000, pdd, old))
assertFalse(unknown.mayClickBrowser(4_000, browser))
val policy = PurchaseNavigationReadiness(0, PddLaunchPath.BROWSER, 0)
for (pkg in listOf(pdd, "cn.ilapage.goauto.agent", "other.browser")) assertFalse(policy.mayClickBrowser(0, pkg))
for (pkg in listOf(browser, "com.android.chrome", "com.android.browser")) assertTrue(policy.mayClickBrowser(0, pkg))
policy.clicked(0)
assertFalse(policy.mayClickBrowser(999, browser))
assertTrue(policy.mayClickBrowser(1_000, browser))
assertFalse(policy.mayClickBrowser(30_000, browser))
}
@Test fun `deadline includes launch time and never accepts at thirty seconds`() {
val policy = PurchaseNavigationReadiness(0, PddLaunchPath.DIRECT, 29_000)
assertFalse(policy.observe(29_000, pdd, old))
assertFalse(policy.observe(29_500, pdd, old))
assertFalse(policy.observe(30_000, pdd, old))
assertTrue(policy.expired(-1))
}
}
@@ -0,0 +1,34 @@
package cn.ilapage.goauto.agent
import cn.ilapage.goauto.agent.persistence.PendingPurchaseOutbox
import cn.ilapage.goauto.agent.persistence.PurchaseOutboxUploader
import org.junit.Assert.*
import org.junit.Test
class PurchaseOutboxHandoffTest {
private val item = PendingPurchaseOutbox(1, 42, "probe", "request", "{}")
@Test fun `handoff callback runs only after successful submit and local upload mark`() {
for (failure in listOf("submit", "mark", "none")) {
val events = mutableListOf<String>()
val uploader = PurchaseOutboxUploader(
pending = { listOf(item) },
submit = { events += "submit"; if (failure == "submit") error("offline") },
markUploaded = { events += "mark"; if (failure == "mark") error("disk") },
afterUploaded = { events += "handoff" },
)
runCatching(uploader::flush)
assertEquals(when (failure) {
"submit" -> listOf("submit")
"mark" -> listOf("submit", "mark")
else -> listOf("submit", "mark", "handoff")
}, events)
}
}
@Test fun `ordinary replay uploader has no handoff callback`() {
var submitted = 0
PurchaseOutboxUploader({ listOf(item) }, { submitted++ }, {}).flush()
assertEquals(1, submitted)
}
}
@@ -0,0 +1,78 @@
package cn.ilapage.goauto.agent
import cn.ilapage.goauto.agent.automation.PurchaseProbeHandoff
import org.junit.Assert.*
import org.junit.Test
class PurchaseProbeHandoffTest {
@Test fun `post capture confirmation rejects expiry foreground change recreation and later execution`() {
val state = seeded()
val execution = state.beginExecution()
assertTrue(state.eligible(execution, 42, "123", "purchase", "next", accessibility, 7, 1_000))
assertTrue(state.stillValid(execution, accessibility, 7, 30_999))
assertFalse(state.stillValid(execution, accessibility, 7, 31_000))
assertFalse(state.stillValid(execution, accessibility, 8, 1_001))
assertFalse(state.stillValid(execution, Any(), 7, 1_001))
state.beginExecution()
assertFalse(state.stillValid(execution, accessibility, 7, 1_001))
}
private val accessibility = Any()
private fun seeded(age: Long = 0, success: Boolean = true, persisted: Boolean = true, pending: Boolean = true, replayed: Boolean = false): PurchaseProbeHandoff {
return PurchaseProbeHandoff().also { state ->
val execution = state.beginExecution()
state.completeProbe(execution, 42, "123", "probe-attempt", 1_000 - age, accessibility, 7,
successful = success, safelyStored = persisted, submitPending = pending, replayed = replayed)
}
}
@Test fun `handoff age is completion based exclusive thirty seconds`() {
for (age in listOf(0L, 29_999L, 30_000L, -1L)) {
val state = seeded(age)
assertEquals(age in 0..29_999, state.eligible(state.beginExecution(), 42, "123", "purchase", "purchase-attempt", accessibility, 7, 1_000))
}
}
@Test fun `handoff is consumed before all eligibility checks`() {
val state = seeded()
assertFalse(state.eligible(state.beginExecution(), 43, "123", "purchase", "next", accessibility, 7, 1_000))
assertFalse(state.eligible(state.beginExecution(), 42, "123", "purchase", "next", accessibility, 7, 1_000))
}
@Test fun `different source and purchase attempts are normal but source replay cannot reuse`() {
val state = seeded()
assertTrue(state.eligible(state.beginExecution(), 42, "123", "purchase", "next", accessibility, 7, 1_000))
assertFalse(state.eligible(state.beginExecution(), 42, "123", "purchase", "next", accessibility, 7, 1_000))
val replay = seeded()
assertFalse(replay.eligible(replay.beginExecution(), 42, "123", "purchase", "probe-attempt", accessibility, 7, 1_000))
}
@Test fun `failed persistence failed matching and replayed uploads create no credential`() {
for (state in listOf(seeded(success = false), seeded(persisted = false), seeded(pending = false), seeded(replayed = true))) {
assertFalse(state.eligible(state.beginExecution(), 42, "123", "purchase", "next", accessibility, 7, 1_000))
}
}
@Test fun `interleaving generation accessibility foreground service and explicit navigation invalidate`() {
val interleaved = seeded()
val ticket = interleaved.beginExecution()
interleaved.beginExecution()
assertFalse(interleaved.eligible(ticket, 42, "123", "purchase", "next", accessibility, 7, 1_000))
val replaced = seeded()
assertFalse(replaced.eligible(replaced.beginExecution(), 42, "123", "purchase", "next", Any(), 7, 1_000))
val foreground = seeded()
assertFalse(foreground.eligible(foreground.beginExecution(), 42, "123", "purchase", "next", accessibility, 8, 1_000))
val freshService = PurchaseProbeHandoff()
assertFalse(freshService.eligible(freshService.beginExecution(), 42, "123", "purchase", "next", accessibility, 7, 1_000))
val navigated = seeded()
navigated.invalidate()
assertFalse(navigated.eligible(navigated.beginExecution(), 42, "123", "purchase", "next", accessibility, 7, 1_000))
}
@Test fun `wrong goods or phase consumes and delayed completion cannot overwrite later execution`() {
for ((goods, phase) in listOf("456" to "purchase", "123" to "spec_probe", "123" to "collection")) {
val state = seeded()
assertFalse(state.eligible(state.beginExecution(), 42, goods, phase, "next", accessibility, 7, 1_000))
}
val state = PurchaseProbeHandoff()
val old = state.beginExecution()
state.beginExecution()
state.completeProbe(old, 42, "123", "probe", 1_000, accessibility, 7, true, true, true, false)
assertFalse(state.eligible(state.beginExecution(), 42, "123", "purchase", "next", accessibility, 7, 1_000))
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,19 @@
package cn.ilapage.goauto.agent
import cn.ilapage.goauto.agent.network.PurchaseResultAcknowledgement
import org.json.JSONObject
import org.junit.Assert.*
import org.junit.Test
class PurchaseResultAcknowledgementTest {
@Test fun `server omits replayed false on pending first response`() {
val ack = PurchaseResultAcknowledgement.from(JSONObject("""{"status":"pending"}"""))
assertTrue(ack.pending)
assertFalse(ack.replayed)
}
@Test fun `failed matching and explicit or malformed replay never qualify`() {
assertFalse(PurchaseResultAcknowledgement.from(JSONObject("""{"status":"failed"}""")).pending)
assertTrue(PurchaseResultAcknowledgement.from(JSONObject("""{"status":"pending","replayed":true}""")).replayed)
assertTrue(PurchaseResultAcknowledgement.from(JSONObject("""{"status":"pending","replayed":"invalid"}""")).replayed)
}
}
@@ -0,0 +1,37 @@
package cn.ilapage.goauto.agent
import cn.ilapage.goauto.agent.network.AgentApiClient
import org.junit.Assert.*
import org.junit.Test
import java.net.ServerSocket
import java.util.concurrent.Executors
import java.util.concurrent.TimeUnit
class RepurchaseServerClockTest {
@Test fun historyCapturesServerDateAndMissingDateClearsPreviousSample() {
ServerSocket(0).use { server ->
val executor = Executors.newSingleThreadExecutor()
val response = "{\"data\":{\"items\":[],\"total\":0,\"page\":1,\"pageSize\":50}}"
val serving = executor.submit {
repeat(2) { index ->
server.accept().use { socket ->
socket.soTimeout = 3000
val reader = socket.getInputStream().bufferedReader()
while (!reader.readLine().isNullOrEmpty()) { }
val date = if (index == 0) "Date: Thu, 08 Oct 2026 09:00:00 GMT\r\n" else ""
socket.getOutputStream().write(("HTTP/1.1 200 OK\r\n" + date +
"Content-Type: application/json\r\nContent-Length: ${response.toByteArray().size}\r\nConnection: close\r\n\r\n$response").toByteArray())
}
}
}
try {
val api = AgentApiClient("http://127.0.0.1:${server.localPort}")
api.purchaseHistory("synthetic", 1, null, null, 30, 50)
assertEquals(1791450000000L, api.lastResponseServerTimeMillis)
api.purchaseHistory("synthetic", 1, null, null, 30, 50)
assertNull(api.lastResponseServerTimeMillis)
serving.get(5, TimeUnit.SECONDS)
} finally { executor.shutdownNow() }
}
}
}
@@ -63,6 +63,7 @@ class SpecOptionDedupTest {
private fun input() = PurchaseExecutionInput( private fun input() = PurchaseExecutionInput(
taskId = 332, taskId = 332,
reuseProbeProduct = true, // This test starts at the trusted probe-to-purchase spec handoff.
executionMode = "rehearsal", executionMode = "rehearsal",
phase = "purchase", phase = "purchase",
url = "https://mobile.yangkeduo.com/goods.html?goods_id=719834019024", url = "https://mobile.yangkeduo.com/goods.html?goods_id=719834019024",
@@ -716,6 +716,7 @@ class SpecPanelRecognitionTest {
private fun input() = PurchaseExecutionInput( private fun input() = PurchaseExecutionInput(
taskId = 331, taskId = 331,
reuseProbeProduct = true, // Panel tests start at the trusted probe-to-purchase handoff.
executionMode = "rehearsal", executionMode = "rehearsal",
phase = "purchase", phase = "purchase",
url = "https://mobile.yangkeduo.com/goods.html?goods_id=719834019024", url = "https://mobile.yangkeduo.com/goods.html?goods_id=719834019024",
@@ -0,0 +1,256 @@
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,
reuseProbeProduct = true), // Exact-card tests explicitly model the immediate trusted handoff.
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.
}
}
@@ -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))))
}
}
@@ -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)
}
}
@@ -3,10 +3,165 @@ package cn.ilapage.goauto.agent.persistence
import java.sql.Connection import java.sql.Connection
import java.sql.DriverManager import java.sql.DriverManager
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue import org.junit.Assert.assertTrue
import org.junit.Test import org.junit.Test
class AgentDiagnosticStoreMigrationTest { class AgentDiagnosticStoreMigrationTest {
@Test
fun freshV5DatabaseHasNullableColorDiscoveryOutcomeColumns() = withDatabase { db ->
db.createStatement().use { it.execute(AgentDiagnosticSchema.createTableSql) }
assertEquals(5, AgentDiagnosticSchema.VERSION)
assertV5Columns(db)
insertOldDiagnostic(db)
assertOldDiagnosticPreserved(db)
for (incomplete in listOf(0, 1)) {
db.createStatement().use { statement ->
statement.executeUpdate(
"UPDATE agent_diagnostic SET color_vertical_swipe_count=4, " +
"color_trailing_empty_read_count=2, color_horizontal_incomplete=$incomplete",
)
statement.executeQuery(
"SELECT color_vertical_swipe_count, color_trailing_empty_read_count, " +
"color_horizontal_incomplete FROM agent_diagnostic",
).use { row ->
assertTrue(row.next())
assertEquals(4, row.getInt(1))
assertEquals(2, row.getInt(2))
assertEquals(incomplete, row.getInt(3))
}
}
}
}
@Test
fun everySupportedVersionUpgradesToV5WithoutChangingExistingRows() {
for (oldVersion in 1..4) withDatabase { db ->
createOldDatabase(db, oldVersion)
insertOldDiagnostic(db)
val existingTables = snapshotTables(db)
migrate(db, oldVersion, 5)
assertV5Columns(db)
assertOldDiagnosticPreserved(db)
existingTables.forEach { (name, rows) -> assertEquals(name, rows, tableRows(db, name)) }
assertTrue(columnNames(db).containsAll(AgentDiagnosticSchema.colorDiagnosticColumns.keys))
assertTrue(columnNames(db).containsAll(AgentDiagnosticSchema.purchaseDiagnosticColumns.keys))
assertEquals(3, snapshotTables(db).size)
migrate(db, oldVersion, 5)
assertOldDiagnosticPreserved(db)
existingTables.forEach { (name, rows) -> assertEquals(name, rows, tableRows(db, name)) }
}
}
@Test
fun v4UpgradeAddsOnlyMissingColumnsAndKeepsExistingV5Values() = withDatabase { db ->
createOldDatabase(db, 4)
insertOldDiagnostic(db)
db.createStatement().use {
it.execute("ALTER TABLE agent_diagnostic ADD COLUMN color_vertical_swipe_count INTEGER")
it.executeUpdate("UPDATE agent_diagnostic SET color_vertical_swipe_count=3")
}
val statements = AgentDiagnosticSchema.migrationStatements(4, 5, columnNames(db))
assertEquals(2, statements.size)
assertTrue(statements.all { it.startsWith("ALTER TABLE agent_diagnostic ADD COLUMN ") })
migrate(db, 4, 5)
assertV5Columns(db)
db.createStatement().use { statement ->
statement.executeQuery("SELECT color_vertical_swipe_count, color_trailing_empty_read_count, color_horizontal_incomplete FROM agent_diagnostic").use {
assertTrue(it.next())
assertEquals(3, it.getInt(1))
assertNull(it.getObject(2))
assertNull(it.getObject(3))
}
}
assertTrue(AgentDiagnosticSchema.migrationStatements(4, 5, columnNames(db)).isEmpty())
assertTrue(AgentDiagnosticSchema.migrationStatements(5, 5, columnNames(db)).isEmpty())
assertTrue(AgentDiagnosticSchema.migrationStatements(4, 4, columnNames(db)).isEmpty())
}
@Test
fun v5UpgradeSurvivesDatabaseReopen() {
val file = java.io.File.createTempFile("goauto-diagnostic-migration-", ".db")
try {
Class.forName("org.sqlite.JDBC")
DriverManager.getConnection("jdbc:sqlite:${file.absolutePath}").use { db ->
createOldDatabase(db, 4)
insertOldDiagnostic(db)
migrate(db, 4, 5)
db.createStatement().use { it.execute("PRAGMA user_version=5") }
}
DriverManager.getConnection("jdbc:sqlite:${file.absolutePath}").use { db ->
assertV5Columns(db)
assertOldDiagnosticPreserved(db)
assertTrue(AgentDiagnosticSchema.migrationStatements(4, 5, columnNames(db)).isEmpty())
assertEquals(3, snapshotTables(db).size)
db.createStatement().use { statement ->
statement.executeQuery("PRAGMA user_version").use {
assertTrue(it.next())
assertEquals(5, it.getInt(1))
}
}
}
} finally {
check(file.delete()) { "Could not remove temporary migration test database" }
}
}
@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) }
}
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 @Test
fun freshDatabaseCreatesV2ColumnsAndAcceptsSingleColorDiagnosticRecord() = withDatabase { db -> fun freshDatabaseCreatesV2ColumnsAndAcceptsSingleColorDiagnosticRecord() = withDatabase { db ->
db.createStatement().use { it.execute(AgentDiagnosticSchema.createTableSql) } db.createStatement().use { it.execute(AgentDiagnosticSchema.createTableSql) }
@@ -83,6 +238,80 @@ class AgentDiagnosticStoreMigrationTest {
assertEquals(1, rowCount(db)) assertEquals(1, rowCount(db))
} }
private fun assertV5Columns(db: Connection) {
val actual = mutableSetOf<String>()
db.createStatement().use { statement ->
statement.executeQuery("PRAGMA table_info(agent_diagnostic)").use { rows ->
while (rows.next()) {
val name = rows.getString("name")
if (name in V5_COLUMNS) {
actual.add(name)
assertEquals(name, "INTEGER", rows.getString("type"))
assertEquals(name, 0, rows.getInt("notnull"))
assertNull(name, rows.getObject("dflt_value"))
}
}
}
}
assertEquals(V5_COLUMNS, actual)
}
private fun createOldDatabase(db: Connection, version: Int) {
db.createStatement().use { it.execute(CREATE_V1_TABLE_SQL) }
migrate(db, 1, version)
if (version >= 4) db.createStatement().use {
it.execute("INSERT INTO purchase_failure_snapshot VALUES ('attempt','test-origin',106,7,'spec_probe',1000,2000,'test','ready','{}',X'0102','test-fingerprint',2)")
it.execute("INSERT INTO purchase_failure_snapshot_queue VALUES ('attempt','test-fingerprint',1500,2)")
it.execute("INSERT INTO purchase_failure_snapshot_context VALUES ('attempt','test-origin','{}',1000)")
}
}
private fun insertOldDiagnostic(db: Connection) {
db.createStatement().use {
it.execute("INSERT INTO agent_diagnostic (task_id,stage,reason,attempt,elapsed_ms,agent_version,created_at) VALUES (106,'COLOR_DISCOVERY','COLOR_EDGE_REACHED',2,10,'old',1000)")
}
}
private fun assertOldDiagnosticPreserved(db: Connection) {
assertEquals(1, rowCount(db))
db.createStatement().use { statement ->
statement.executeQuery("SELECT id,task_id,stage,reason,attempt,elapsed_ms,agent_version,created_at,color_vertical_swipe_count,color_trailing_empty_read_count,color_horizontal_incomplete FROM agent_diagnostic").use {
assertTrue(it.next())
assertEquals(1L, it.getLong(1))
assertEquals(106L, it.getLong(2))
assertEquals("COLOR_DISCOVERY", it.getString(3))
assertEquals("COLOR_EDGE_REACHED", it.getString(4))
assertEquals(2, it.getInt(5))
assertEquals(10L, it.getLong(6))
assertEquals("old", it.getString(7))
assertEquals(1000L, it.getLong(8))
for (index in 9..11) assertNull(it.getObject(index))
}
}
}
private fun snapshotTables(db: Connection): Map<String, List<List<String?>>> =
db.createStatement().use { statement ->
statement.executeQuery("SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'purchase_failure_snapshot%'").use { rows ->
buildList { while (rows.next()) add(rows.getString(1)) }
}
}.associateWith { tableRows(db, it) }
private fun tableRows(db: Connection, table: String): List<List<String?>> =
db.createStatement().use { statement ->
statement.executeQuery("SELECT * FROM $table ORDER BY attempt_id").use { rows ->
buildList {
while (rows.next()) add((1..rows.metaData.columnCount).map { rows.getString(it) })
}
}
}
private fun migrate(db: Connection, oldVersion: Int, newVersion: Int) {
AgentDiagnosticSchema.migrationStatements(oldVersion, newVersion, columnNames(db)).forEach { sql ->
db.createStatement().use { it.execute(sql) }
}
}
private fun migrateV1ToV2(db: Connection) { private fun migrateV1ToV2(db: Connection) {
AgentDiagnosticSchema.v2MigrationStatements(1, 2, columnNames(db)).forEach { sql -> AgentDiagnosticSchema.v2MigrationStatements(1, 2, columnNames(db)).forEach { sql ->
db.createStatement().use { it.execute(sql) } db.createStatement().use { it.execute(sql) }
@@ -112,6 +341,7 @@ class AgentDiagnosticStoreMigrationTest {
} }
private companion object { private companion object {
val V5_COLUMNS = setOf("color_vertical_swipe_count", "color_trailing_empty_read_count", "color_horizontal_incomplete")
val CREATE_V1_TABLE_SQL = val CREATE_V1_TABLE_SQL =
"""CREATE TABLE agent_diagnostic ( """CREATE TABLE agent_diagnostic (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -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)
}
}
}
@@ -0,0 +1,241 @@
package cn.ilapage.goauto.agent.service
import cn.ilapage.goauto.agent.network.HistoryPage
import cn.ilapage.goauto.agent.network.PurchaseHistoryItem
import org.junit.Assert.*
import org.junit.Test
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import java.util.TimeZone
class RepurchaseBatchTest {
private val now = 1_791_446_400_000L
private fun at(secondsAgo: Long) = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.ROOT).apply {
timeZone = TimeZone.getTimeZone("UTC")
}.format(Date(now - secondsAgo * 1000))
private fun item(id: Long, secondsAgo: Long = 0, status: String = "failed", retryable: Boolean = true) = PurchaseHistoryItem(
id, status, "synthetic", "synthetic", "synthetic", "", "", "", "", 1, null, "CNY", null, null,
null, null, retryable, if (retryable) null else "不可重试", at(secondsAgo),
)
private fun discover(items: List<PurchaseHistoryItem>): RepurchaseBatch = RepurchaseBatchDiscovery({ q ->
HistoryPage(items.drop((q.page - 1) * 50).take(50), items.size.toLong(), q.page, 50)
}, { now }).discover()
private fun refused(reason: RepurchaseBatchFailure, block: () -> Unit) {
try { block(); fail("Expected $reason") } catch (e: RepurchaseBatchException) { assertEquals(reason, e.reason) }
}
@Test fun sixtySecondsJoinsTransitivelyButSixtyOneStartsOlderBatch() {
val batch = discover(listOf(item(5), item(4, 60), item(3, 120), item(2, 181), item(1, 182)))
assertEquals(listOf(5L, 4L, 3L), batch.items.map { it.taskId })
assertEquals(at(0), batch.newestCreatedAt)
assertEquals(at(120), batch.oldestCreatedAt)
assertEquals(5L, batch.headTaskId)
}
@Test fun equalInstantsUseDescendingIdsAndRespectTimezones() {
val batch = discover(listOf(item(3).copy(createdAt = "2026-10-08T08:00:00.123456789+08:00"),
item(2).copy(createdAt = "2026-10-08T00:00:00.123456789Z"),
item(1).copy(createdAt = "2026-10-07T20:00:00.123456789-04:00")))
assertEquals(listOf(3L, 2L, 1L), batch.items.map { it.taskId })
}
@Test fun subMillisecondGapAboveSixtySecondsIsBoundary() {
val batch = discover(listOf(item(2).copy(createdAt = "2026-10-08T00:01:00.000000001Z"),
item(1).copy(createdAt = "2026-10-08T00:00:00Z")))
assertEquals(listOf(2L), batch.items.map { it.taskId })
}
@Test fun readsFullStateHistoryAcrossFiftyItemBoundaryAndRechecksHead() {
val requests = mutableListOf<RepurchaseHistoryQuery>()
val data = (70L downTo 1).map { item(it, 70 - it, if (it % 2 == 0L) "failed" else "order_created") }
val result = RepurchaseBatchDiscovery({ q ->
requests += q
HistoryPage(data.drop((q.page - 1) * 50).take(50), 70, q.page, 50)
}, { now }).discover()
assertEquals(70, result.items.size)
assertEquals(35, result.candidates.size)
assertEquals(listOf(1, 2, 1), requests.map { it.page })
assertTrue(requests.all { it.days == 30 && it.pageSize == 50 && it.status == null && it.taskNo == null })
}
@Test fun overlappingPagesDeduplicateTaskIdsBeforeBoundary() {
val data = (60L downTo 1).map { item(it, if (it >= 10) 60 - it else 300) }
val result = RepurchaseBatchDiscovery({ q ->
val page = if (q.page == 1) data.take(50) else data.drop(49)
HistoryPage(page, 61, q.page, 50)
}, { now }).discover()
assertEquals(51, result.items.size)
assertEquals(51, result.items.map { it.taskId }.distinct().size)
}
@Test fun headChangeRefusesEvenWhenBoundaryAlreadyFound() {
var calls = 0
refused(RepurchaseBatchFailure.LIST_CHANGED) {
RepurchaseBatchDiscovery({ q ->
calls++
HistoryPage(if (calls == 1) listOf(item(2), item(1, 100)) else listOf(item(3), item(2), item(1, 100)),
if (calls == 1) 2 else 3, q.page, 50)
}, { now }).discover()
}
}
@Test fun invalidDatesAndMissingZonesAreRefused() {
listOf("", "2026-02-30T00:00:00Z", "2026-10-08T00:00:00", "2026-10-08T00:00:00+25:00", "2026-10-08T00:00:00Zjunk").forEach {
refused(RepurchaseBatchFailure.INVALID_TIMESTAMP) { discover(listOf(item(1).copy(createdAt = it))) }
}
}
@Test fun invertedChronologicalOrIdOrderingIsRefused() {
refused(RepurchaseBatchFailure.INVALID_ORDER) { discover(listOf(item(2, 20), item(1))) }
refused(RepurchaseBatchFailure.INVALID_ORDER) { discover(listOf(item(1), item(2))) }
}
@Test fun nearThirtyDayCutoffCannotProveBatchComplete() {
val days30 = 30L * 24 * 60 * 60
listOf(days30, days30 - 60, days30 - 61, days30 + 1).forEach { age ->
refused(RepurchaseBatchFailure.INCOMPLETE_BATCH) { discover(listOf(item(1, age))) }
}
assertEquals(1, discover(listOf(item(1, days30 - 62))).items.size)
}
@Test fun usesServerResponseClockEvenWhenPhoneIsFiveMinutesSlow() {
val phoneNow = now
val serverNow = phoneNow + 5 * 60 * 1000
val ageFromPhoneSeconds = 30L * 24 * 60 * 60 - 5 * 60 - 30
var responseTime: Long? = null
refused(RepurchaseBatchFailure.INCOMPLETE_BATCH) {
RepurchaseBatchDiscovery({ q ->
responseTime = serverNow
HistoryPage(listOf(item(1, ageFromPhoneSeconds)), 1, q.page, 50)
}, { checkNotNull(responseTime) }).discover()
}
}
@Test fun responseTimeIsNotRequiredBeforeFirstFetch() {
var responseTime: Long? = null
val batch = RepurchaseBatchDiscovery({ q ->
responseTime = now
HistoryPage(listOf(item(1)), 1, q.page, 50)
}, { checkNotNull(responseTime) }).discover()
assertEquals(1, batch.items.size)
}
@Test fun missingServerResponseClockRefusesWithoutPhoneClockFallback() {
refused(RepurchaseBatchFailure.INCOMPLETE_BATCH) {
RepurchaseBatchDiscovery({ q -> HistoryPage(listOf(item(1)), 1, q.page, 50) },
{ error("No server Date") }).discover()
}
}
@Test fun retainsLatestObservedServerClockWhenLaterResponseTimeRegresses() {
var responseTime = now
var calls = 0
refused(RepurchaseBatchFailure.INCOMPLETE_BATCH) {
RepurchaseBatchDiscovery({ q ->
responseTime = if (++calls == 1) now + 300_000 else now
HistoryPage(listOf(item(1, 30L * 24 * 60 * 60 - 330)), 1, q.page, 50)
}, { responseTime }).discover()
}
}
@Test fun exactlyFiftyRecordsCanEstablishTrustworthyEnd() {
val requested = mutableListOf<Int>()
val batch = RepurchaseBatchDiscovery({ q ->
requested += q.page
HistoryPage((50L downTo 1).map { item(it, 50 - it) }, 50, q.page, 50)
}, { now }).discover()
assertEquals(50, batch.items.size)
assertEquals(listOf(1, 1), requested)
}
@Test fun latestSuccessfulBatchDoesNotFallBackToOlderFailures() {
val result = discover(listOf(item(2, status = "order_created"), item(1, 61)))
assertEquals(1, result.items.size)
assertTrue(result.failures.isEmpty())
assertTrue(result.candidates.isEmpty())
}
@Test fun onlyFailedRetryableAreCandidatesAndSecondRoundUsesRemainingFailures() {
val items = (10L downTo 1).map { item(it, status = if (it <= 4) "failed" else "order_created") }
val first = discover(items)
assertEquals(listOf(4L, 3L, 2L, 1L), first.candidates.map { it.taskId })
val second = discover(items.map { if (it.taskId in 3L..4L) it.copy(status = "order_created") else it })
assertEquals(listOf(2L, 1L), second.candidates.map { it.taskId })
val blocked = discover(listOf(item(2, retryable = false), item(1, status = "pending")))
assertEquals(1, blocked.failures.size)
assertEquals("不可重试", blocked.skips.single().retryDisabledReason)
assertTrue(blocked.candidates.isEmpty())
}
@Test fun pageFailureCannotReturnPartOfBatch() {
refused(RepurchaseBatchFailure.PAGE_FAILED) {
RepurchaseBatchDiscovery({ q ->
if (q.page == 2) error("synthetic network failure")
HistoryPage((60L downTo 11).map { item(it, 60 - it) }, 60, q.page, 50)
}, { now }).discover()
}
}
@Test fun shortPageWithUnseenTotalAndWrongMetadataAreRefused() {
listOf(HistoryPage(listOf(item(1)), 2, 1, 50), HistoryPage(listOf(item(1)), 1, 2, 50),
HistoryPage(listOf(item(1)), 1, 1, 20), HistoryPage(emptyList(), -1, 1, 50)).forEach { page ->
refused(RepurchaseBatchFailure.INVALID_PAGE) { RepurchaseBatchDiscovery({ page }, { now }).discover() }
}
}
@Test fun maximumPagesCannotReturnTruncatedBatch() {
refused(RepurchaseBatchFailure.PAGE_LIMIT) {
RepurchaseBatchDiscovery({ q -> HistoryPage((60L downTo 11).map { item(it, 60 - it) }, 60, q.page, 50) },
{ now }, maxPages = 1).discover()
}
}
@Test fun crossPageOrderInversionAndChangedDuplicateTimestampAreRefused() {
listOf(item(1, 1), item(11, 99)).forEach { next ->
refused(RepurchaseBatchFailure.INVALID_ORDER) {
RepurchaseBatchDiscovery({ q ->
HistoryPage(if (q.page == 1) (60L downTo 11).map { item(it, 60 - it) } else listOf(next),
51, q.page, 50)
}, { now }).discover()
}
}
}
@Test fun duplicatesCannotMakeAnIncompleteTailLookComplete() {
refused(RepurchaseBatchFailure.INCOMPLETE_BATCH) {
RepurchaseBatchDiscovery({ q ->
HistoryPage(if (q.page == 1) (60L downTo 11).map { item(it, 60 - it) } else listOf(item(11, 49)),
51, q.page, 50)
}, { now }).discover()
}
}
@Test fun failedHeadRecheckRefusesTheAlreadyFoundBatch() {
var calls = 0
refused(RepurchaseBatchFailure.PAGE_FAILED) {
RepurchaseBatchDiscovery({ q ->
if (++calls > 1) error("synthetic network failure")
HistoryPage(listOf(item(1)), 1, q.page, 50)
}, { now }).discover()
}
}
@Test fun emptyHistoryIsRecheckedAndReturnedEmpty() {
var calls = 0
val batch = RepurchaseBatchDiscovery({ q -> calls++; HistoryPage(emptyList(), 0, q.page, 50) }, { now }).discover()
assertTrue(batch.items.isEmpty())
assertNull(batch.headTaskId)
assertEquals(2, calls)
}
@Test fun immutableSnapshotDoesNotExposeMutableLists() {
val batch = discover(listOf(item(1)))
listOf(batch.items, batch.candidates, batch.failures).forEach { items ->
try { (items as MutableList).clear(); fail("Mutable snapshot") } catch (_: UnsupportedOperationException) { }
}
}
}
@@ -0,0 +1,182 @@
package cn.ilapage.goauto.agent.service
import cn.ilapage.goauto.agent.network.*
import org.junit.Assert.*
import org.junit.Test
import java.io.IOException
class RepurchaseCoordinatorTest {
private fun item(id: Long, status: String = "failed", retryable: Boolean = true, error: String? = null) = PurchaseHistoryItem(
id, status, "", "", "", "", "", "", "", 1, null, "CNY", null, null, error, null, retryable, "不可重试", "2026-10-08T09:00:00Z")
private fun detail(id: Long, status: String = "failed", count: Long = 1, retryable: Boolean = true, error: String? = null) =
PurchaseHistoryDetail(item(id, status, retryable, error), count, error, null, false, null, null, null, null, false, null)
private inner class Fake : RepurchasePort {
var clock = 0L
var busy = false
var otherWork = false
var environment: String? = null
var head: Long? = 2L
var resetFailure: Exception? = null
var failOnce = false
var duringReset: (() -> Unit)? = null
val details = mutableMapOf(2L to this@RepurchaseCoordinatorTest.detail(2), 1L to this@RepurchaseCoordinatorTest.detail(1))
val resets = mutableListOf<Pair<Long, String>>()
var evidence = emptyList<RepurchaseExecutionEvidence>()
override fun executionEvidence(taskId: Long) = evidence
override fun environmentProblem() = environment
override fun localIdle() = !busy
override fun hasOtherWork(currentTaskId: Long?) = otherWork
override fun latestTaskId() = head
override fun detail(taskId: Long) = details.getValue(taskId)
override fun reset(taskId: Long, requestId: String): PurchaseResetResult {
resets += taskId to requestId
duringReset?.invoke()
resetFailure?.let { if (failOnce) resetFailure = null; throw it }
return PurchaseResetResult(taskId, "CG-$taskId", 2, "pending", resets.size > 1)
}
fun runner(): RepurchaseCoordinator {
var sequence = 0
return RepurchaseCoordinator({ clock }, { "request-${++sequence}" }, {}).also {
assertTrue(it.prepare())
it.prepared(RepurchaseBatch(listOf(item(2), item(1))))
it.confirm(it.state.roundId)
}
}
}
@Test fun serialResetsWaitForNewAttemptAndLocalDrain() {
val p = Fake(); val r = p.runner()
r.tick(p); assertEquals(listOf(2L), p.resets.map { it.first })
r.tick(p); assertEquals(0, r.state.results.size) // old failed, attempt1
p.details[2] = detail(2, "order_created", 2); p.busy = true
r.tick(p); assertEquals(0, r.state.results.size)
p.busy = false; r.tick(p); assertEquals(1, r.state.successCount)
r.tick(p); assertEquals(listOf(2L, 1L), p.resets.map { it.first })
p.details[1] = detail(1, count = 2); r.tick(p); r.tick(p)
assertEquals(RepurchasePhase.FINISHED, r.state.phase)
assertEquals(1, r.state.failedCount); assertEquals(2, p.resets.size)
}
@Test fun lostResetResponseUsesSameRequestIdAndReplayIsNotTerminal() {
val p = Fake(); val r = p.runner(); p.resetFailure = IOException(); p.failOnce = true
r.tick(p); r.tick(p)
assertEquals(2, p.resets.size); assertEquals(p.resets[0], p.resets[1])
assertEquals(0, r.state.results.size)
}
@Test fun unresolvedMappedValuesDoNotPreventReset() {
val p = Fake(); val r = p.runner(); r.tick(p)
assertEquals(2L, p.resets.single().first)
}
@Test fun definiteBusinessRefusalSkipsButNetworkAmbiguityStops() {
val p = Fake(); val r = p.runner()
p.resetFailure = AgentApiException(409, "PURCHASE_RETRY_STALE", "safe", false)
r.tick(p); assertEquals(1, r.state.skippedCount)
p.resetFailure = IOException(); r.tick(p); r.tick(p)
assertTrue(r.state.phase in setOf(RepurchasePhase.STOPPING, RepurchasePhase.FINISHED))
assertEquals(3, p.resets.size)
}
@Test fun newWorkStopsSubsequentButNotCurrent() {
val p = Fake(); val r = p.runner(); r.tick(p); p.otherWork = true
r.tick(p); assertEquals(RepurchasePhase.STOPPING, r.state.phase)
p.details[2] = detail(2, "order_created", 2); r.tick(p); r.tick(p)
assertEquals(RepurchasePhase.FINISHED, r.state.phase); assertEquals(1, p.resets.size)
}
@Test fun stopBeforeResetIssuesNothingAndStaleStopIgnored() {
val p = Fake(); val r = p.runner(); r.requestStop("old"); r.tick(p)
assertEquals(1, p.resets.size)
val p2 = Fake(); val r2 = p2.runner(); r2.requestStop(r2.state.roundId); r2.tick(p2)
assertEquals(0, p2.resets.size); assertEquals(RepurchasePhase.FINISHED, r2.state.phase)
}
@Test fun stopDuringIssuedResetAllowsOnlyCurrentToDrain() {
val p = Fake(); val r = p.runner(); p.duringReset = { r.requestStop(r.state.roundId) }
r.tick(p); p.details[2] = detail(2, "order_created", 2); r.tick(p); r.tick(p)
assertEquals(1, p.resets.size); assertEquals(RepurchasePhase.FINISHED, r.state.phase)
}
@Test fun timeoutStopsOrchestrationWithoutAnotherReset() {
val p = Fake(); val r = p.runner(); r.tick(p); p.clock = 15 * 60 * 1000L
r.tick(p); assertEquals(RepurchasePhase.FINISHED, r.state.phase); assertEquals(1, p.resets.size)
}
@Test fun unknownAndCaptchaStopTheRound() {
for ((status, error) in listOf("order_result_unknown" to null, "failed" to "PDD_CAPTCHA_REQUIRED")) {
val p = Fake(); val r = p.runner(); r.tick(p); p.details[2] = detail(2, status, 2, error = error)
r.tick(p); r.tick(p)
assertEquals(RepurchasePhase.FINISHED, r.state.phase); assertEquals(1, p.resets.size)
}
}
@Test fun headChangedOrEnvironmentUnavailableDoesNotReset() {
val p = Fake(); val r = p.runner(); p.head = 3; r.tick(p)
assertEquals(RepurchasePhase.FINISHED, r.state.phase); assertTrue(p.resets.isEmpty())
val p2 = Fake(); val r2 = p2.runner(); p2.environment = "无障碍未开启"; r2.tick(p2)
assertEquals(RepurchasePhase.FINISHED, r2.state.phase); assertTrue(p2.resets.isEmpty())
}
@Test fun busyHasBoundedDrainAndProcessRestartDoesNotRestoreRound() {
val p = Fake(); val r = p.runner(); p.busy = true; r.tick(p)
p.clock = 10000; r.tick(p); assertEquals(RepurchasePhase.FINISHED, r.state.phase)
assertFalse(RepurchaseCoordinator({0}, {"x"}, {}).state.busy)
}
@Test fun noRetryableCandidatesDoesNotConfirmOrFallBack() {
val r = RepurchaseCoordinator({0}, {"x"}, {})
r.prepare(); r.prepared(RepurchaseBatch(listOf(item(3, "order_created"))))
assertEquals(RepurchasePhase.FINISHED, r.state.phase)
r.confirm(r.state.roundId); assertEquals(RepurchasePhase.FINISHED, r.state.phase)
}
@Test fun definiteEnvironmentRefusalStopsWithoutPretendingTaskWasIssued() {
val p = Fake(); val r = p.runner()
p.resetFailure = AgentApiException(401, "DEVICE_TOKEN_INVALID", "unauthorized", false)
r.tick(p)
assertEquals(RepurchasePhase.FINISHED, r.state.phase)
assertNull(r.state.currentTaskId)
assertEquals(1, p.resets.size)
}
@Test fun secondRoundOnlyUsesStillFailedCandidates() {
val p = Fake(); val r = p.runner(); r.tick(p)
p.details[2] = detail(2, "order_created", 2); r.tick(p); r.tick(p)
p.details[1] = detail(1, count = 2); r.tick(p)
assertTrue(r.prepare())
r.prepared(RepurchaseBatch(listOf(item(2, "order_created"), item(1))))
assertEquals(listOf(1L), r.state.batch!!.candidates.map { it.taskId })
r.confirm(r.state.roundId)
// New reset must return a strictly newer attempt than the refreshed detail.
p.details[1] = detail(1)
r.tick(p)
assertEquals(listOf(2L, 1L, 1L), p.resets.map { it.first })
}
@Test fun unexpectedExceptionDoesNotLeakRawErrorOrSkipRest() {
val p = Fake(); val r = p.runner(); p.resetFailure = IOException("private remote body")
r.tick(p)
assertFalse(r.state.message.contains("private"))
assertEquals(RepurchasePhase.STOPPING, r.state.phase)
assertEquals(listOf(2L, 2L), p.resets.map { it.first })
}
@Test fun externalLaterAttemptIsNotCountedAsThisRoundsResult() {
val p = Fake(); val r = p.runner(); r.tick(p)
p.details[2] = detail(2, "order_created", 3)
p.evidence = listOf(RepurchaseExecutionEvidence(2, "attempt2", "purchase", "failed"),
RepurchaseExecutionEvidence(3, "attempt3", "purchase", "order_created"))
r.tick(p); r.tick(p)
assertEquals(RepurchasePhase.FINISHED, r.state.phase)
assertEquals(0, r.state.successCount); assertEquals(1, p.resets.size)
}
@Test fun completedProbeCanLinkToOriginalExecutorsNextPurchaseAttempt() {
val p = Fake(); val r = p.runner(); r.tick(p)
p.details[2] = detail(2, "order_created", 3)
p.evidence = listOf(RepurchaseExecutionEvidence(2, "probe2", "spec_probe", "spec_probe_completed"),
RepurchaseExecutionEvidence(3, "purchase3", "purchase", "order_created"))
r.tick(p)
assertEquals(1, r.state.successCount)
r.tick(p); assertEquals(listOf(2L, 1L), p.resets.map { it.first })
}
@Test fun localReservationBusyNeverPretendsARequestWasSentAndUsesSameId() {
val p = Fake(); val r = p.runner(); p.resetFailure = RepurchaseLocalBusy(); p.failOnce = true
r.tick(p); assertNull(r.state.currentTaskId); assertEquals(RepurchasePhase.RUNNING, r.state.phase)
r.tick(p); assertEquals(p.resets[0], p.resets[1])
val p2 = Fake(); val r2 = p2.runner(); p2.resetFailure = RepurchaseLocalBusy()
r2.tick(p2); p2.clock = 10000; r2.tick(p2)
assertEquals(RepurchasePhase.FINISHED, r2.state.phase); assertNull(r2.state.currentTaskId)
}
}
@@ -0,0 +1,34 @@
package cn.ilapage.goauto.agent.service
import org.junit.Assert.*
import org.junit.Test
class RepurchaseStateTest {
@Test fun confirmationExplainsOriginalTaskAndUnpaidOrderRisk() {
val state = RepurchaseState(phase = RepurchasePhase.READY)
assertTrue(state.confirmationText().contains("不会付款"))
assertTrue(state.confirmationText().contains("原任务号"))
assertTrue(state.confirmationText().contains("15分钟"))
assertTrue(state.confirmationText().contains("60秒"))
}
@Test fun readingReadyRunningAndStoppingPreventDuplicateRounds() {
for (phase in listOf(RepurchasePhase.READING, RepurchasePhase.READY, RepurchasePhase.RUNNING, RepurchasePhase.STOPPING)) {
assertTrue(RepurchaseState(phase = phase).busy)
}
assertFalse(RepurchaseState().busy)
assertFalse(RepurchaseState(phase = RepurchasePhase.FINISHED).busy)
}
@Test fun countsDoNotMarkUnexecutedItemsAsFailures() {
val state = RepurchaseState(results = listOf(
RepurchaseItemResult(1, RepurchaseResult.SUCCESS, "订单已创建"),
RepurchaseItemResult(2, RepurchaseResult.FAILED, "再次失败"),
RepurchaseItemResult(3, RepurchaseResult.SKIPPED, "不可重试"),
))
assertEquals(1, state.successCount)
assertEquals(1, state.failedCount)
assertEquals(1, state.skippedCount)
assertTrue(state.summaryText().contains("成功 1"))
}
}
@@ -0,0 +1,25 @@
package cn.ilapage.goauto.agent
import cn.ilapage.goauto.agent.diagnostics.BrowserProbeClickPolicy
import org.junit.Assert.*
import org.junit.Test
class BrowserProbeClickPolicyTest {
@Test fun onlyExactNativeBrowserEntryIsAllowed() {
assertTrue(BrowserProbeClickPolicy.allowed("com.heytap.browser", "com.heytap.browser", "打开拼多多APP", true, true, true, 3, 3))
}
@Test fun rejectsPackageSwitchHiddenDisabledNonClickableAndStaleWindow() {
fun allowed(root: String? = "com.heytap.browser", node: String? = root, label: String = "打开拼多多APP",
visible: Boolean = true, enabled: Boolean = true, clickable: Boolean = true, window: Int = 3) =
BrowserProbeClickPolicy.allowed(root, node, label, visible, enabled, clickable, 3, window)
assertFalse(allowed(root = "com.xunmeng.pinduoduo"))
assertFalse(allowed(node = "com.xunmeng.pinduoduo"))
assertFalse(allowed(root = null))
assertFalse(allowed(label = "打开"))
assertFalse(allowed(label = "立即支付"))
assertFalse(allowed(visible = false))
assertFalse(allowed(enabled = false))
assertFalse(allowed(clickable = false))
assertFalse(allowed(window = 4))
}
}
@@ -0,0 +1,107 @@
package cn.ilapage.goauto.agent
import cn.ilapage.goauto.agent.automation.*
import cn.ilapage.goauto.agent.diagnostics.NavigationProbe
import org.junit.Assert.*
import org.junit.Test
class NavigationProbeTest {
@Test fun cancellationDuringInitialCapturePreventsLaunch() {
var active = true
var launches = 0
val page = UiSnapshot("com.xunmeng.pinduoduo", null, emptyList())
NavigationProbe(capture = { active = false; page }, launch = { launches++; true },
clickBrowserEntry = { error("no click") }, now = { 0 }, pause = {}, emit = {},
isActive = { active }).run()
assertEquals(0, launches)
}
@Test fun deadlineCrossedDuringCapturePreventsLaunch() {
var clock = 0L
var launches = 0
NavigationProbe(capture = { clock = 20_001; UiSnapshot(null, null, emptyList()) },
launch = { launches++; true }, clickBrowserEntry = { error("no click") }, now = { clock },
pause = {}, emit = {}).run()
assertEquals(0, launches)
}
@Test fun cancellationDuringBrowserCapturePreventsClick() {
var active = true
var reads = 0
var clock = 0L
var clicks = 0
val page = UiSnapshot("com.heytap.browser", null, listOf(node("打开拼多多APP")))
NavigationProbe(capture = { reads++; if (reads > 1) active = false; page }, launch = { true },
clickBrowserEntry = { clicks++; FreshClickOutcome(FreshActionResult.SUCCESS, FreshClickReason.SUCCESS) },
now = { clock }, pause = { clock += it }, emit = {}, isActive = { active }).run()
assertEquals(0, clicks)
}
@Test fun baselineUsesActualPackageRatherThanOptionalParserEvidence() {
val rig = Rig(UiSnapshot("com.heytap.browser", null, listOf(node("发起拼单"))))
rig.run()
assertTrue(rig.lines.first().contains("pdd=false"))
assertFalse(rig.lines.any { it.contains("legacy_would_accept") })
}
private class Rig(var page: UiSnapshot) {
var clock = 0L
var launches = 0
var clicks = 0
val lines = mutableListOf<String>()
fun run(accepted: Boolean = true) = NavigationProbe(
capture = { page },
launch = { report -> launches++; report(PddLaunchEvent(PddLaunchPath.DIRECT, PddLaunchStatus.ACCEPTED)); accepted },
clickBrowserEntry = { clicks++; FreshClickOutcome(FreshActionResult.SUCCESS, FreshClickReason.SUCCESS) },
now = { clock }, pause = { clock += it }, emit = lines::add,
).run()
}
private fun node(text: String, path: String = "0") = SnapshotNode(path, null, text, null, null,
"android.widget.TextView", NodeBounds(0, 0, 100, 100), true, false, false, false, true, true)
@Test fun pddIsReadOnlyAndSamplingStopsWithinBound() {
val rig = Rig(UiSnapshot("com.xunmeng.pinduoduo", null, listOf(node("立即下单"))))
rig.run()
assertEquals(1, rig.launches)
assertEquals(0, rig.clicks)
assertEquals(20_000L, rig.clock)
assertTrue(rig.lines.last().contains("finished"))
}
@Test fun failedLaunchStopsWithoutClicking() {
val rig = Rig(UiSnapshot("com.xunmeng.pinduoduo", null, emptyList()))
rig.run(false)
assertEquals(1, rig.launches)
assertEquals(0L, rig.clock)
assertEquals(0, rig.clicks)
assertTrue(rig.lines.last().contains("launch_failed"))
}
@Test fun browserRequiresUniqueExplicitPddEntryAndBoundedRetries() {
val rig = Rig(UiSnapshot("com.heytap.browser", null, listOf(node("打开拼多多APP"))))
rig.run()
assertEquals(3, rig.clicks)
}
@Test fun ambiguousBrowserEntryStopsWithoutGuessing() {
val rig = Rig(UiSnapshot("com.heytap.browser", null, listOf(node("打开拼多多APP"), node("打开拼多多APP", "1"))))
rig.run()
assertEquals(0, rig.clicks)
assertTrue(rig.lines.last().contains("browser_ambiguous"))
}
@Test fun genericOpenAndPddControlsAreNeverClicked() {
for (pkg in listOf("com.heytap.browser", "com.xunmeng.pinduoduo", "other.package")) {
val rig = Rig(UiSnapshot(pkg, null, listOf(node("打开"), node("立即支付"), node("确认订单"))))
rig.run()
assertEquals(0, rig.clicks)
assertTrue(rig.lines.isNotEmpty())
}
}
@Test fun loginOrCaptchaStopsBeforeLaunch() {
val rig = Rig(UiSnapshot("com.xunmeng.pinduoduo", null, listOf(node("手机号登录"))))
rig.run()
assertEquals(0, rig.launches)
assertEquals(0, rig.clicks)
assertTrue(rig.lines.last().contains("page_problem"))
}
@Test fun pageTextAndExceptionMessagesNeverAppearInOutput() {
val secret = "PRIVATE_ACCOUNT_ADDRESS_DO_NOT_LOG"
val rig = Rig(UiSnapshot("com.xunmeng.pinduoduo", null, listOf(node(secret))))
rig.run()
assertTrue(rig.lines.isNotEmpty())
assertFalse(rig.lines.joinToString().contains(secret))
}
}
+59 -3
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件) generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Architecture-and-Code-Map wiki_page: Architecture-and-Code-Map
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Architecture-and-Code-Map.- wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Architecture-and-Code-Map.-
wiki_revision: f73ab171684662d23f298d7e4927168b1a40b01a wiki_revision: 76854d900b2372ddedd46a2b82b5677d8bf5cb31
synchronized_at: 2026-10-06T01:39:19Z synchronized_at: 2026-10-09T06:46:07Z
<!-- gitea-wiki-mirror:end --> <!-- gitea-wiki-mirror:end -->
<!-- gitea-wiki-mirror:start --> <!-- gitea-wiki-mirror:start -->
@@ -63,7 +63,7 @@ Android Portal/Agent
- 重置在事务中清空原结果,保留 URL、goods_id、规则和设备快照,状态恢复为 `pending`。 - 重置在事务中清空原结果,保留 URL、goods_id、规则和设备快照,状态恢复为 `pending`。
- Android 本地互斥与服务端原子领取共同保证单设备串行。 - Android 本地互斥与服务端原子领取共同保证单设备串行。
- 正式采购在 Android 本地 SQLite 事务中先保存不可逆状态、稳定服务端请求 ID 和脱敏最终确认快照,再通知服务端并只允许一次创建订单点击;重启后只重放服务端标记、只读核单或上报,不再次点击。 - 正式采购在 Android 本地 SQLite 事务中先保存不可逆状态、稳定服务端请求 ID 和脱敏最终确认快照,再通知服务端并只允许一次创建订单点击;重启后只重放服务端标记、只读核单或上报,不再次点击。
- 原始控件树和截图不持久化;Android Agent 端第一期不使用 OCR/VLM。服务端 SYB 登录验证码识别是唯一例外,见 [#48](https://git.ilapage.cn/OPC/goauto/issues/48)。 - 普通原始控件树和截图不持久化;采购失败私有现场诊断是 #364 的限定例外,见本页对应节;Android Agent 端第一期不使用 OCR/VLM。服务端 SYB 登录验证码识别是唯一例外,见 [#48](https://git.ilapage.cn/OPC/goauto/issues/48)。
- 蝦皮规格映射独立保存在 `shopee_product.specs_json`:颜色只能选择关联 PDD 当前可选颜色,允许多个蝦皮颜色共用一个 PDD 颜色。Admin 商品详情的“一键匹配颜色和尺码”在服务端统一计算两个维度:保留当前仍有效的已确认映射,将唯一确定匹配及达到阈值、具备理由且候选仍有效的 AI 匹配,在重新校验规格上下文后于同一事务直接写为 `confirmed`,无需人工确认;低置信度或无结果保持未匹配,Provider 异常或上下文变化时不写入任何本次结果。PDD 目标规格消失后页面标记失效,映射保存和采购创建均拒绝继续使用;无需新增数据库表或 Android 能力。 - 蝦皮规格映射独立保存在 `shopee_product.specs_json`:颜色只能选择关联 PDD 当前可选颜色,允许多个蝦皮颜色共用一个 PDD 颜色。Admin 商品详情的“一键匹配颜色和尺码”在服务端统一计算两个维度:保留当前仍有效的已确认映射,将唯一确定匹配及达到阈值、具备理由且候选仍有效的 AI 匹配,在重新校验规格上下文后于同一事务直接写为 `confirmed`,无需人工确认;低置信度或无结果保持未匹配,Provider 异常或上下文变化时不写入任何本次结果。PDD 目标规格消失后页面标记失效,映射保存和采购创建均拒绝继续使用;无需新增数据库表或 Android 能力。
## 最小业务数据 ## 最小业务数据
@@ -617,3 +617,59 @@ Web 唯一展示位置为“采集采购 → SYB 同步记录”:列表状态
- `sybimport.Handler.List/Detail` 复用产品模型返回 nullable 字段。列表不额外访问 SYB;不新增旧订单扫描、并发、定时任务或采购拦截。 - `sybimport.Handler.List/Detail` 复用产品模型返回 nullable 字段。列表不额外访问 SYB;不新增旧订单扫描、并发、定时任务或采购拦截。
- Web 只在 SYB 商品列表订单号后增加固定 72px 的“SYB状态”列;退货列改名“匹配退货”、min-width 200→160,保留图片/告警/操作并允许换行。使用现有标签与加载/失败态,其他列及 Android 不变。 - Web 只在 SYB 商品列表订单号后增加固定 72px 的“SYB状态”列;退货列改名“匹配退货”、min-width 200→160,保留图片/告警/操作并允许换行。使用现有标签与加载/失败态,其他列及 Android 不变。
- 迁移重复执行和三态接口/导入已在隔离 SQLite 测试,Server/Web 构建通过;尚未验证正式 MySQL 执行或真实同步后的页面。先授权迁移,再发布才能用于业务环境。 - 迁移重复执行和三态接口/导入已在隔离 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重试策略保持不变。
## 采购失败现场诊断(#364,待真机验收)
实现绑定 `4581ee5`,后续合并记录见 #364;尚未发布。以下是普通原始控件树禁存规则的限定例外;普通采集、截图、OCR/VLM 和付款边界不变。
- Android `diagnostics/FailureSnapshotCapture.kt` 统一判断失败/待核对、成功排除及既有 ZIP;`FailureSnapshotExporter.kt` 独立导出有限公开属性,`AccessibilitySnapshotSource.kt` 只读默认显示屏交互窗口,不改变正常 `capture()` 或解析器。
- `AgentForegroundService` 在有效实际 attempt 的失败结果或执行异常处同步有界截取,先于新提示/返回/下一任务;恢复场景单独标识。成功结果后的保存/提示/上传异常不创建失败快照,不改变原业务状态和不可逆标记。
- `goauto_diagnostics.db` 升级 v4;独立 `purchase_failure_snapshot`、`purchase_failure_snapshot_queue`、`purchase_failure_snapshot_context` 表。旧 `agent_diagnostic` 保持 7 天/50 条,新快照30天/64MiB逻辑容量。SQL 核心 `FailureSnapshotRepository` 共用真实 SQLite 测试,Android适配器以256KiB块读取BLOB,避开CursorWindow单行限制。
- 队列绑定 Server Origin;已保存 ZIP 不覆盖,旧上传响应凭 fingerprint 不得清除首次恢复 ZIP 的新队列项。结果 Outbox 优先,诊断独立后台线程只上传已存字节,不持有设备任务锁;启动及每60秒执行维护。
- Server `purchase/failure_snapshot*.go` 提供专用上传/安全摘要/管理员ZIP下载;`models/purchase_failure_snapshot.go` 独立私有表。追加迁移 `1791400000000_purchase_failure_snapshot.go` 保存 LONGBLOB ZIP、LONGTEXT manifest、attempt唯一约束及到期索引。
- `access.AdminAPIs` 注册两个仅管理员读取接口;普通任务DTO和Client API不增加原始诊断字段。通用操作日志精确排除三个诊断端点,私有SQL读写使用静默logger。服务启动立即清理并每小时清理过期诊断,不依赖新任务。
- Web `purchase-tasks/FailureSnapshotCell.vue` 是现有执行记录末尾200px管理员专属列,独立摘要查询、固定原因中文和附件下载;无原始XML在线预览。
## Android 重购编排(#367)
实现绑定 `5430dc1`,2026-10-09 已合并 main `e26743c` 并安装集成APK;无Server/Web、数据库或接口迁移,真机批量采购仍待验收。
- `TaskHistoryFragment` 把采购搜索栏入口改为“重购”,发送 PREPARE/CONFIRM/STOP、显示 Service 内存状态;一次确认与逐条摘要由 `RepurchaseState` 提供,不由 Fragment 驱动采购生命周期。
- `RepurchaseBatchDiscovery` 注入既有设备历史查询和服务端HTTP Date时钟,负责全状态/50条页/30天窗口、相邻60秒边界、去重、完整性及重读首页;最多200页,不使用本地历史缓存执行重购。
- `RepurchaseCoordinator` 通过小型 `RepurchasePort` 执行非阻塞状态推进;只调用原reset和只读详情,固定每任务requestId、expected attempt、10秒本地忙碌复核及15分钟单条上限。依靠原执行器的attemptNumber/UUID/phase/已知结果识别合法探测续行,拒绝外部更晚attempt冒充本轮结果。
- `AgentForegroundService` 独立单线程scheduled executor每2秒推进编排,历史网络不阻塞心跳或taskExecutor;发现使用独享API客户端,reset只短暂预留既有TaskExecutionMutex。原scheduler继续唯一负责claim/start/真实PDD操作及Outbox,编排不持整轮锁、不新建执行器路径。
- 新工作查询同时检查采集next、采购next及本设备活动状态历史,防止采购next优先返回本轮current而遮蔽其他pending。停止信号为原子roundId,配置/身份冻结,仅内存保存摘要;进程重启不恢复整轮,既有单任务恢复及失败私有诊断不变。
- `AgentApiClient` 仅补读已有采购payload的attemptNumber和标准HTTP Date响应元数据;不添加服务端字段或API。Date缺失清空样本,不沿用手机时间。
## Android 颜色发现诊断追加(#370 第一阶段)
实现绑定 `9e5f09a`,已合并 main,2026-10-09 随集成版本 `e26743c` 安装;本阶段不修改颜色遍历和采购结果判定。
- `AgentDiagnosticSchema` 升至 v5,在 `agent_diagnostic` 追加可空 INTEGER `color_vertical_swipe_count`、`color_trailing_empty_read_count`、`color_horizontal_incomplete`;对应事件中的两个 Int? 和一个 Boolean?,Boolean 按 0/1 写入。新库包含列,v1–v4 逐级补齐并跳过已存在列,旧行新列为 NULL;不清表,不改独立采购失败快照、队列和 context。
- `PddProductDetailCollector.collectColors` 只基于既有解析帧记录颜色阶段 UP 派发次数、空发现轮次和横向探索三态;不增加抓取、手势或等待。标签及签名只在内存使用,持久化仍为安全聚合。
- 不新增 Server/Web API 字段、服务端迁移或权限;普通结果 payload/missing 与采购探测失败策略未改变。完整遍历与不完整候选拦截仍属 #370 后续待证据实施范围,不能把本地诊断值作为已实现的业务门禁。
- 读取与三态限制见 Troubleshooting;旧版不保证降级打开 v5。JDBC 测试与实际 Android 安装升级分别验收,测试不代替真实竖向颜色采集验证。
+105 -4
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件) generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Business-Rules-and-Glossary wiki_page: Business-Rules-and-Glossary
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Business-Rules-and-Glossary.- wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Business-Rules-and-Glossary.-
wiki_revision: f5077a19801882ebb4e94c5b0fe93f00fa6ebc7d wiki_revision: 6c1a317ef67a0b015bb16a615b8c490b384188a0
synchronized_at: 2026-10-06T03:27:21Z synchronized_at: 2026-10-09T07:21:39Z
<!-- gitea-wiki-mirror:end --> <!-- gitea-wiki-mirror:end -->
<!-- gitea-wiki-mirror:start --> <!-- gitea-wiki-mirror:start -->
@@ -213,7 +213,7 @@ Admin 的「创建时间」范围按 SYB 货运单列表的 `t_stock.created`(
- 浏览器“打开拼多多APP”和系统确认框“打开”属于允许动作。 - 浏览器“打开拼多多APP”和系统确认框“打开”属于允许动作。
- 登录失效、验证码、风控、人机验证和找不到唯一控件时失败并给出具体错误。 - 登录失效、验证码、风控、人机验证和找不到唯一控件时失败并给出具体错误。
- Android Agent 到 GoAuto 服务端默认使用 HTTPS。管理员可在明确接受 Device Token、任务内容、设备状态和执行结果被链路监听或篡改的风险后,通过服务端 `GOAUTO_ALLOW_INSECURE_AGENT_HTTP=true` 显式允许全部 `/api/agent/v1/**` 使用 HTTP;该例外不放宽 PDD、SYB、OCR 或其他第三方服务约束。 - Android Agent 到 GoAuto 服务端默认使用 HTTPS。管理员可在明确接受 Device Token、任务内容、设备状态和执行结果被链路监听或篡改的风险后,通过服务端 `GOAUTO_ALLOW_INSECURE_AGENT_HTTP=true` 显式允许全部 `/api/agent/v1/**` 使用 HTTP;该例外不放宽 PDD、SYB、OCR 或其他第三方服务约束。
- Android Agent 端不使用 OCR/VLM 兜底,不保存原始控件树或截图。采集阶段不猜测缺失数据。 - Android Agent 端不使用 OCR/VLM 兜底;普通采集不保存原始控件树或截图,采集阶段不猜测缺失数据。采购失败私有现场诊断是 #364 的限定例外,见本页对应节;历史各节的结构化日志/普通接口禁存规则保持不变。
- 服务端顺云宝(SYB)登录例外:允许调用配置的线上 OCR 服务识别登录验证码(见 [#48](https://git.ilapage.cn/OPC/goauto/issues/48))。验证码图片会离开本项目发送到该服务,更换服务地址前必须重新评估。此例外只适用于 SYB 登录,不扩大到 Agent 端或任何 PDD 相关流程。 - 服务端顺云宝(SYB)登录例外:允许调用配置的线上 OCR 服务识别登录验证码(见 [#48](https://git.ilapage.cn/OPC/goauto/issues/48))。验证码图片会离开本项目发送到该服务,更换服务地址前必须重新评估。此例外只适用于 SYB 登录,不扩大到 Agent 端或任何 PDD 相关流程。
- 采购阶段的规格匹配由服务端决策(见 [#46](https://git.ilapage.cn/OPC/goauto/issues/46)、[#62](https://git.ilapage.cn/OPC/goauto/issues/62)):Agent 本地不得自行猜测规格或点击相近候选,只执行服务端下发的精确规格;AI Provider 只有一个 OpenAI-compatible 配置,由管理员维护。根据 #62 已确认的内部部署例外,API Key 明文保存在专用设置表,并只向管理员设置接口返回以便查看和替换;它仍不得写入代码、日志、工单、Wiki、任务快照、采购员接口或 Android 接口。Provider Base URL 不限制内网或公网,支持 HTTP/HTTPS;HTTP 不加密传输中的 API Key,生产环境建议 HTTPS。 - 采购阶段的规格匹配由服务端决策(见 [#46](https://git.ilapage.cn/OPC/goauto/issues/46)、[#62](https://git.ilapage.cn/OPC/goauto/issues/62)):Agent 本地不得自行猜测规格或点击相近候选,只执行服务端下发的精确规格;AI Provider 只有一个 OpenAI-compatible 配置,由管理员维护。根据 #62 已确认的内部部署例外,API Key 明文保存在专用设置表,并只向管理员设置接口返回以便查看和替换;它仍不得写入代码、日志、工单、Wiki、任务快照、采购员接口或 Android 接口。Provider Base URL 不限制内网或公网,支持 HTTP/HTTPS;HTTP 不加密传输中的 API Key,生产环境建议 HTTPS。
- 采购规则的 `openSpecPanel` 默认不写死页面文字。Agent 只在已由页面语义确认的规格入口或底部购买入口中选择;规则若提供 `textAliases`,只能进一步缩小这些安全候选,不能把任意同名页面文字变成可点击入口。 - 采购规则的 `openSpecPanel` 默认不写死页面文字。Agent 只在已由页面语义确认的规格入口或底部购买入口中选择;规则若提供 `textAliases`,只能进一步缩小这些安全候选,不能把任意同名页面文字变成可点击入口。
@@ -339,6 +339,8 @@ Admin 的「创建时间」范围按 SYB 货运单列表的 `t_stock.created`(
## SYB 档口入库码导入、匹配与回写 ## 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 顺序。 - 只接受不超过 10 MB、最多 5000 条非空数据行且包含“标签入库码映射”工作表的 Excel。一次文件只能包含一个营业日期;同日单件入库码必须唯一,同一业务键按营业日期、订单号、档口和规范化规格合并并保留 Excel 顺序。
- 导入事务提交后自动创建并启动只读匹配任务,手动批量重匹配入口见 #350 的版本绑定规则。再次导入同日数据只允许整体替换仍处于等待匹配的数据;存在匹配或回写证据时拒绝覆盖。 - 导入事务提交后自动创建并启动只读匹配任务,手动批量重匹配入口见 #350 的版本绑定规则。再次导入同日数据只允许整体替换仍处于等待匹配的数据;存在匹配或回写证据时拒绝覆盖。
- 匹配必须按精确订单号取得唯一货运单,再以规格、原始 SKU 和档口证据确定唯一商品;歧义、冲突或数量不一致均不猜测。匹配阶段不能调用任何 SYB 写接口。 - 匹配必须按精确订单号取得唯一货运单,再以规格、原始 SKU 和档口证据确定唯一商品;歧义、冲突或数量不一致均不猜测。匹配阶段不能调用任何 SYB 写接口。
@@ -373,6 +375,8 @@ Admin 的「创建时间」范围按 SYB 货运单列表的 `t_stock.created`(
## SYB 采购强制当次规格探测(#215) ## SYB 采购强制当次规格探测(#215)
以下为 #373 之前的复用行为;#373 分支的限定复用和等待规则见“采购页面一次性交接与导航稳定等待”章节,未安装新版不能按新规则判断。
- 每个新 SYB 采购任务固定执行“首趟只读探测 → 服务端确定性优先/必要时 AI → 固化任务级精确规格 → 第二趟正式采购”。首趟只打开一次浏览器商品链接;匹配期间当前设备保留给同一任务,不领取其他采购或采集任务。连续进入第二趟且当前仍有 PDD 商品页或规格面板强证据时复用当前页、不再次打开链接;手动同任务重试,或当前处于 Agent、其他应用及缺少上述强证据时,重新打开任务固化的商品 URL。复用或重开均不严格核验标题、goodsId 或页面指纹,但仍要求 PDD 包名与商品/规格/订单页面结构安全证据。已有长期映射只作商品档案事实,不直接进入任务执行规格。 - 每个新 SYB 采购任务固定执行“首趟只读探测 → 服务端确定性优先/必要时 AI → 固化任务级精确规格 → 第二趟正式采购”。首趟只打开一次浏览器商品链接;匹配期间当前设备保留给同一任务,不领取其他采购或采集任务。连续进入第二趟且当前仍有 PDD 商品页或规格面板强证据时复用当前页、不再次打开链接;手动同任务重试,或当前处于 Agent、其他应用及缺少上述强证据时,重新打开任务固化的商品 URL。复用或重开均不严格核验标题、goodsId 或页面指纹,但仍要求 PDD 包名与商品/规格/订单页面结构安全证据。已有长期映射只作商品档案事实,不直接进入任务执行规格。
- 首趟候选与 `taskId`、`taskAttemptId`、`deviceId`、规则快照哈希和幂等结果哈希关联;第二趟失败不得回到首趟循环探测。备货 `stock/direct_select` 没有 SYB 目标规格,继续使用用户逐字选择的档案规格,不进入本规则。 - 首趟候选与 `taskId`、`taskAttemptId`、`deviceId`、规则快照哈希和幂等结果哈希关联;第二趟失败不得回到首趟循环探测。备货 `stock/direct_select` 没有 SYB 目标规格,继续使用用户逐字选择的档案规格,不进入本规则。
- 候选和 Provider 结果仅保存颜色、尺码原始标签及结构化决策,不保存控件树、整屏截图、账号、地址、订单或支付数据;付款仍永久禁止。 - 候选和 Provider 结果仅保存颜色、尺码原始标签及结构化决策,不保存控件树、整屏截图、账号、地址、订单或支付数据;付款仍永久禁止。
@@ -668,10 +672,21 @@ Web 唯一展示位置为“采集采购 → SYB 同步记录”:列表状态
实现分支 fix/325-order-result,2026-09-19;未部署、未真机验收。下单点击及不可逆边界不改。已知 PayActivity 允许一次原有 Back,随后从3次放宽为25次200ms采样(约5秒等待,不含页面捕获耗时),持续无证据仍为 PURCHASE_ORDER_PAYMENT_REPEATED。微信恢复、系统选择器、空窗口和陌生应用的既有边界保留。 实现分支 fix/325-order-result,2026-09-19;未部署、未真机验收。下单点击及不可逆边界不改。已知 PayActivity 允许一次原有 Back,随后从3次放宽为25次200ms采样(约5秒等待,不含页面捕获耗时),持续无证据仍为 PURCHASE_ORDER_PAYMENT_REPEATED。微信恢复、系统选择器、空窗口和陌生应用的既有边界保留。
过渡阶段最多60次采样。首次识别到待付款证据后单独分配30次采样预算,不被之前绕路消耗,不因文字反复出现重置;核单总采样上限90次。缺少唯一订单号或时间时立即向上滑动读取下方内容,最多4次、手势400ms、每次后等500ms;读全立即停止,已有完整字段不滑动。该待付款读取分支不点击任何控件,也不点击付款。纯支付页不使用该滑动分支;出现可读待付款证据优先于复用的 Activity 名称。按采样间隔约18秒加有限手势/额外等待,实际耗时还含capture等调用;未改变2分钟任务租约或5分钟唤醒锁,也不宣称新增了执行器总体墙钟超时机制。 原 #325 过渡阶段最多60次采样。首次识别到待付款证据后单独分配30次采样预算,不被之前绕路消耗,不因文字反复出现重置;原核单总采样上限90次。#365 工单分支增加下节所述成功支付页返回后的定时观察例外,不能再把90次视为该例外路径的总上限。缺少唯一订单号或时间时立即向上滑动读取下方内容,最多4次、手势400ms、每次后等500ms;读全立即停止,已有完整字段不滑动。该待付款读取分支不点击任何控件,也不点击付款。纯支付页不使用该滑动分支;出现可读待付款证据优先于复用的 Activity 名称。按采样间隔约18秒加有限手势/额外等待,实际耗时还含capture等调用;未改变2分钟任务租约或5分钟唤醒锁,也不宣称新增了执行器总体墙钟超时机制。
应付金额从同一订单的可见标签集合中读取,兼容“应付:,13元”等标点;不跨无关联节点拼接,不读拼单价、优惠或实付。父子重复同金额去重,多值/缺失/无效/溢出省略,不因金额失败丢弃订单号与时间。仅存Admin,不推断支付、不改SYB金额、不修改地址行为。历史回填及界面语义未在本单统一,详见API契约#325补充。 应付金额从同一订单的可见标签集合中读取,兼容“应付:,13元”等标点;不跨无关联节点拼接,不读拼单价、优惠或实付。父子重复同金额去重,多值/缺失/无效/溢出省略,不因金额失败丢弃订单号与时间。仅存Admin,不推断支付、不改SYB金额、不修改地址行为。历史回填及界面语义未在本单统一,详见API契约#325补充。
## 支付页安全返回后的核单观察(#365)
实现绑定 `37714d39b5ac8ff720cfa1d0d2609f9a8af3fbd9`,已合并 main 并随2026-10-09集成APK安装;自动化验证不代表真机核单成功率已改善。仅 Android 变化,Admin、Server、数据库和上传状态契约不改。
- 只有识别到 PDD 支付页且既有一次 Back 成功后,才记录单调时钟起点。在尚未取得订单/待付款上下文时,允许观察至该起点后约30秒,不因反复采样或上下文缺失重置;不能用 paymentPageObserved 代替成功返回事实。
- 无成功返回,或在原60次内已取得普通订单上下文的路径,保持原预算。返回失败、持续支付页25次、空窗口15次、微信恢复失败和未授权应用等独立失败保持,不保证每种失败等满30秒。
- 原60次之后的新增观察只采样,不因迟到的详情入口、选择器、微信页面新增点击、返回、拉前台或滑动。延长期出现非待付款不完整订单上下文,可只读等待迟到字段至同一期限;完整证据立即结束。
- 首次出现待付款证据后,仍使用原独立30次采样及最多4次有界读取滑动;不新增该分支额度。该分支有自己的预算,30秒不是整个核单方法的全局时限。
- 固定期限检查包含采样耗时与既有暂停,单次 capture 本身不能被此机制中断;不承诺严格墙钟总耗时。不增加采样硬上限来提前替代30秒期限。
- 仍无完整证据时沿用已有失败原因与 order_result_unknown 处理,不自动补录、重试采购或再次创建订单;永久禁止付款。这只是对加载慢的有界缓解,不是对历史未知原因的根因修复。
## 采购订单资料完整性及简化单号回填(#326) ## 采购订单资料完整性及简化单号回填(#326)
用户2026-09-19确认内部系统简化流程,原型v1通过。实现3a2472d,尚未发布或真实SYB验收。 用户2026-09-19确认内部系统简化流程,原型v1通过。实现3a2472d,尚未发布或真实SYB验收。
@@ -801,3 +816,89 @@ Android 0.9.64 / versionCode 77,源码 `6550b9f`(分支实现,尚未安装
- RawSpecHalves 复用 Parse,ResolveKeys 复用两者,原始两侧角色、注释塌缩消歧与采购规格键继续共享同一判断,不另立角色推断规则。 - RawSpecHalves 复用 Parse,ResolveKeys 复用两者,原始两侧角色、注释塌缩消歧与采购规格键继续共享同一判断,不另立角色推断规则。
- 更新规则本身不会改历史数据库。正常重同步沿用现有保护规则;需要定向重新解析时复用 ReparseBatch,force=false 默认跳过人工及 AI 确认行。该流程可能重算同商品其他未确认明细,执行前必须核验完整影响集合。 - 更新规则本身不会改历史数据库。正常重同步沿用现有保护规则;需要定向重新解析时复用 ReparseBatch,force=false 默认跳过人工及 AI 确认行。该流程可能重算同商品其他未确认明细,执行前必须核验完整影响集合。
- ERPGo 规格同步及 Merge 仍只追加并保留映射,不自动清除旧反向档案值。清理须独立授权、指定对象并验证引用/映射,保留恢复依据;不自动扩大为全库数据清理,不修改采购任务快照。 - 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是规格项数。商品变化后可能需要等扫描回绕,不保证固定小时内全部处理。
## 采购失败现场私有诊断(#364,尚未发布)
本节是用户已确认的原始控件树禁存规则限定例外,仅适用于采购 `spec_probe/purchase` 本次失败、结果待核对及有真实上下文的执行阶段异常。普通采集及此前各业务模块的普通日志/结果接口不因此放宽。
- 不采集正常执行的连续帧,不抓成功结果;空探测在 demote 后失败才进入诊断。已知成功结果即使后续本地保存、提示或结果上传异常也不新增失败快照。
- 使用实际已领取/开始的 task/device/attempt UUID/phase/规则快照哈希;无有效归属只留本机固定原因,无效hash如实标为无效,不为诊断伪造事实。前置失败只留未进入执行/无障碍不可用等状态,不把Agent页面当作PDD现场。
- 原失败上传重试只复用原ZIP。进程中断不补拍当前页冒充原现场;已有恢复核单失败可生成首次 `source=recovery` ZIP,记录恢复实际时间。恢复前的API/网络异常不抓,恢复成功不抓,不新增任何恢复点击、下单或任务重试。
- 原始订单/地址节点等可能含个人数据,只存手机私有诊断SQLite和服务端私有数据库,只允许管理员下载。不得写入普通日志、工单、Wiki、SynapBus、普通任务结果或采购员接口;不得保存整屏截图。密码标识为true、缺失或不可读时,内容文字及其编码备份、action label均不保存。
- 每attempt最多保留一份ZIP,已有ZIP不可覆盖;只有无ZIP未截取记录可升级为首次恢复ZIP。以该现场 recordedAt 起保留30天,重复上传不续期;新恢复ZIP采用真实恢复时刻。两端定期清理,手机快照逻辑总量64MiB,淘汰时同时终止上传队列。清理不修改采购结果。
- 管理员采购详情可见完整/部分/未截取/暂无现场数据及安全下载入口;普通采购员、售后、客户端密钥均无原始诊断下载权限。
- 1500ms、5000节点、2MiB ZIP、8MiB展开、128窗口与深度保护是合成测试覆盖的开发边界,未经过代表性真机样本验收;单次系统Binder读取不能被这些前后检查强行中断。未知/部分必须如实显示,不宣称拿到了系统未暴露的节点。
## 图搜采集部分完成的自动关联资格(#368)
实现基线:`15ccab3`(2026-10-08);2026-10-09 已合并 main `e26743c` 并部署122.228.200.167,真实图搜采集业务效果仍待验收。
- 此规则仅作用于 `source=image_search` 的结果后置自动关联。正常 `completed` 沿用原行为,包括 `missing_json=[]`。
- `completed_partial` 仅在 `missing_json` 为可解析、非空字符串数组且每一项精确属于 `reviewCount`、`salesText`、`shopName` 时允许自动关联。三项均为可选描述信息。
- partial 的空值、JSON null、空数组、非法 JSON、非字符串项、空项、未知项、颜色/尺码/价格/不支持维度缺失一律不放宽。不修剪字段名、不按前缀推测。
- 保留原图搜快照、目标 PDD active、`OriginalPDDProductID` 乐观比较;人工改动后的关联不被覆盖。只有实际写入关联并返回 Linked,才进入既有规格同步和匹配流程。
- 采集任务仍为 `completed_partial`,missing 保留,界面仍显示部分完成;关联成功不等于 AI 匹配成功。匹配无结果或不可用沿用原处理,不自动创建采购。
- 不改变普通采集、手动当前页采集、手动关联、价格保护、映射算法、同步后置 hook 的耗时及重放机制。同图多结果沿用原提交顺序和乐观比较,不新增历史筛选、补关联或回退旧商品。
- 验证边界:自动化测试使用隔离 SQLite 与本地模拟 ERP 服务验证实际 SubmitResult、关联及映射落库;未以此宣称 MySQL、真实 AI、真机或线上发布验证。请求在匹配开始后取消时,匹配使用原有脱钩 context 继续;最后 Detail 读取仍使用原请求 context,可能向断开的调用端返回错误,已提交数据不因此回滚。
## Android 本设备最后一批重购(#367)
实现绑定 `5430dc1f64fc0a4d8ef124ada7356e21a975151f`(2026-10-08,工单分支 `feat/367-last-batch-repurchase`);2026-10-09 已合并 main `e26743c` 并安装集成APK,真机批量采购仍待独立授权验收。
- 采购页搜索栏的“回填”入口替换为“重购”。原回填弹窗、Service 动作和实现保留但不再由该按钮触发;原单条重试、继续采购不改。重购使用原任务的 `/reset`,保留 CG 编号,不使用创建替代任务的 `/retry`。
- 读取本设备近30天全部状态、不继承页面编号/状态筛选;按创建时间倒序、同刻任务ID倒序,从最新记录逐项比较相邻时间,不超过60秒归入同一推算批次。跨50条分页去重,最多200页,结束复核首页;头部或总量变化、日期/页结构异常、查询截断无法证明完整时不启动,不拿部分列表执行。批次时间边界使用独享API客户端的 HTTP Date,不使用手机时钟,缺少有效 Date 拒绝;此轻量方案不保证任意并发删除/归属变动的数据库快照。
- 最后一批没有失败或失败均不可重试时只提示,不回退更早批次。一次确认显示范围、总数、失败/可重购/跳过数量及真实待付款订单风险;系统永不付款。每轮冻结 `failed && retryable` 候选,每条只重置一次,本轮再次失败不重新入队;下次点击重新发现并确认剩余任务。
- 每条前重新检查当前资格、设备身份/Origin、网络、无障碍、新工作与本地执行/结果上传边界;不额外要求已存在完整映射,沿用服务端 unresolved 规格探测资格。明确业务资格拒绝可跳过;环境、规则、鉴权或结果未知停止后续。
- 每条固定 requestId;重置响应不明最多同ID发两次,仍不明确停止,不换UUID或把旧failed认成本次结果。本地短时忙碌且明确未发出请求时最多等待10秒复核;重放pending不是实时状态。后续 attempt 只有原执行器记录到连续探测/重匹配完成→正式采购启动的证据链才属于本轮,外部同任务另起attempt停止编排。
- Service 独立串行后台调度观察,真正领取、执行、上传仍由原执行器和租约/任务锁负责。终态且执行锁、活跃记录和Outbox均收尾后才继续。检测新采集或采购工作停止剩余,本轮当前task本身不算新工作。
- 页面和通知栏“停止重购”只停止后续,已提交/执行的任务沿用原流程;切Tab、切PDD、界面重建不中断。单条单调时钟等待上限15分钟,超时只停止编排,不取消任务、不重置或再次下单。进程死亡/服务重建不恢复整轮,原单任务恢复不改。
- 本轮摘要与最小attempt关联仅在内存,不新增地址/树采集或数据库。接口、服务端、Web、权限及 #364 诊断契约不变。自动化回归和APK构建通过不代表真机页面/批量下单已验收。
## 采购页面一次性交接与导航稳定等待(#373 v4)
实现绑定分支 `fix/373-navigation-diagnostics` 提交 `dbf9a5f`,调试版本 `0.9.69-373-fix1`;2026-10-09 尚未合并main、发布或安装该修复版。此节是该提交行为,不代表线上旧版本已生效;替代此前仅凭商品结构复用页面的方式。
- Agent在每次采购执行开始时先取出清空服务进程内的交接凭证。凭证只能来自同任务spec_probe正常完成、结果安全落盘、首次提交返回pending且非replayed,并且本地markUploaded成功之后;失败/重传不生成或延长。缺省没有凭证,不因当前有商品结构就复用。
- 凭证绑定taskId、goodsId、源探测attemptId、本地执行代次、无障碍实例、前台变更序号和探测完成时的单调时钟。仅紧接的同任务同商品purchase可消费一次;源/目标attempt本来不同。有效年龄为0至不足30000ms;慢AI或网络等待超过上限只导致重开链接。30秒是基于15秒心跳的保守复用政策,不是实际AI耗时保证。
- 采集、图搜、当前页采集、回填、恢复执行、导航诊断及已知主动离页操作都会使原交接不可用;服务/无障碍重建不恢复。复用仍要求新鲜PDD商品结构,读取后再次核对同一次已消费凭证的期限、实例、前台与代次,避免慢Binder读取期间过期。人工在同一PDD包内切页可能没有可观察前台变化,不能声称完全识别。
- 不符合复用资格时打开本任务链接,沿用直达NEW_TASK|CLEAR_TASK及异常回退浏览器。等待从该次打开开始,不是给整个重购批次加固定间隔。执行器取得实际ACCEPTED路径;未知路径不当作直达。
- 浏览器路径先观察到受支持浏览器,再允许PDD商品结构进入就绪判断,浏览器出现前的旧PDD帧不放行。直达接受后至少3秒缓冲;浏览器返回PDD后至少3秒缓冲。商品结构还需连续稳定至少1秒、至少3次读取,缓冲与稳定时间可以重叠。就绪后立即继续,不无条件等30秒。
- 商品稳定特征只在内存比较标题和规格入口/面板结构,不以销量、价格或滚动拼单变化为依据。非PDD、空窗口、缺少商品证据或结构变化会打断稳定计时;登录、验证码、风控和无效商品保持明确失败。
- 导航总预算为单调时钟30秒、最多300轮、轮间100ms;耗时系统读取可能超过预算,但返回后检查时限,不再继续点击。浏览器入口重试从点击调用返回后至少间隔1秒;原生点击前在新鲜节点重新校验浏览器与剩余预算。超时返回PDD_DETAIL_ENTRY_FAILED,不继续规格、地址或创建订单步骤。
- 用户明确拒绝新增分享/复制链接操作,并接受本版仅解决跨任务无条件复用与导航抢跑。**稳定商品页不是goodsId身份核验;PDD忽略深链而稳定停在旧页、或无可观察变化的同包人工换页,仍可能无法识别。** 不得将任务携带goodsId当作页面回读,不能用本版宣称绝对排除错误商品。
- 不改服务端/API线协议/数据库、采集器、规格选择、地址与下单逻辑、重购批次发现/停止逻辑;不涉及任何付款动作。仅增加Android对既有结果响应status/replayed的本地解析(replayed=false在服务端可省略)。普通流程不新增原始树、截图、分享URL或页面文本落盘。
+14 -2
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件) generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Local-Development-and-Verification wiki_page: Local-Development-and-Verification
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Local-Development-and-Verification.- wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Local-Development-and-Verification.-
wiki_revision: 03ea269058b50fea2be7842b9c284018987c82d9 wiki_revision: 4349298c740c0cb94d8c6bf33423c9c7965ff1c7
synchronized_at: 2026-09-21T08:14:42Z synchronized_at: 2026-10-08T07:10:50Z
<!-- gitea-wiki-mirror:end --> <!-- gitea-wiki-mirror:end -->
# 本地开发与验证 # 本地开发与验证
@@ -350,3 +350,15 @@ node --check popup.js
``` ```
自动化测试只使用合成 DOM 与 Chrome API mock,覆盖列表商品行负向选择、双展开与延迟字段、解析/冲突、启动互斥、停止、错误终态、扫描延迟增长、详情恢复、提交恢复、冻结配置、超时和可重试项。真实 PDD 浏览器点击、扩展安装和真实 Admin 上传不属于自动化验证,必须取得独立授权并在结果中明确区分。 自动化测试只使用合成 DOM 与 Chrome API mock,覆盖列表商品行负向选择、双展开与延迟字段、解析/冲突、启动互斥、停止、错误终态、扫描延迟增长、详情恢复、提交恢复、冻结配置、超时和可重试项。真实 PDD 浏览器点击、扩展安装和真实 Admin 上传不属于自动化验证,必须取得独立授权并在结果中明确区分。
## 采购失败现场诊断验证(#364)
实现 `4581ee5` 的合成验证不连接PDD、不创建订单、不付款、不读取真实个人数据。安装、真实现场抓取和上线须另行授权。
- Android:`cd android; .\\gradlew.bat test assembleDebug`;重点类为 FailureSnapshotExporterTest、FailureSnapshotCaptureTest、FailureSnapshotPolicyTest、FailureSnapshotUploadTest、FailureSnapshotRepositoryTest、AgentDiagnosticStoreMigrationTest。旧结构化诊断实际无障碍回调到Android SQLite的真机闭环仍须另验,不能用JVM测试冒称真机问题已解决。
- 跨端:仅测试时设置 `GOAUTO_DIAGNOSTIC_FIXTURE_DIR` 指向开发构建输出目录,运行 Android `FailureSnapshotExporterTest` 生成纯合成 `manifest.json/archive.zip`;保持同一环境目录后在 `server/` 执行 `go test ./app/goauto/purchase -run TestFailureSnapshotAndroidExporterContract -count=1`。这验证真实Android导出器生成的字节被Go校验器接受;未设置变量时该测试明确skip。禁止以真实下载ZIP替代该合成fixture,输出不提交Git。
- Server:`go test ./app/goauto/purchase ./app/goauto/access ./app/goauto/migrations ./common/middleware ./cmd/migrate/migration/version-local ./cmd/api`;覆盖所有权、成功/待执行attempt拒绝、终态补传、幂等/恢复升级、密码内容排除、ZIP/XML/HTTP容量、日志排除及空闲清理。
- Web:构建后本地preview,使用 `PLAYWRIGHT_TEST_BASE_URL=http://localhost:<端口>`(合成cookie域为localhost),执行 `pnpm exec playwright test tests/e2e/purchase-failure-snapshots.spec.ts tests/e2e/purchase-order-writeback.spec.ts --workers=1`;普通采购员角色为 `purchaser`。
- 迁移:`1791400000000_purchase_failure_snapshot.go` 仅追加私有表与版本记录,提供 `MigratePurchaseFailureSnapshot` 幂等入口;专用API依赖既有启动权限对账。独立测试 `TestFailureSnapshotAuthorizedLocalMySQL` 须获得本地初始化/权限写入授权后显式注入 `GOAUTO_364_LOCAL_MYSQL_DSN`;硬限制本机 `127.0.0.1:3308/goauto` 且采购任务为空。它执行追加迁移、完整既有API/采购员权限对账,并以事务回滚的合成数据验证64KiB manifest/2MiB BLOB回读与attempt唯一。不是只读测试;不得对线上或已有业务库运行,不输出DSN。
- 服务维护:Server清理启动立即运行并每小时运行;Android前台服务启动及每60秒维护,无新任务也清理。无需新增定时任务或开关。通过管理员专用下载路由取得ZIP,禁止把私有诊断表或下载目录暴露为静态资源。
+58 -12
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件) generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Troubleshooting wiki_page: Troubleshooting
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Troubleshooting wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Troubleshooting
wiki_revision: 40e77a656d1c21d72454ad722a4f71a0a4491241 wiki_revision: 6b370764320d1f5148fbc31f0ef6ce999370173d
synchronized_at: 2026-10-05T07:18:16Z synchronized_at: 2026-10-09T02:06:57Z
<!-- gitea-wiki-mirror:end --> <!-- gitea-wiki-mirror:end -->
# 故障排查 # 故障排查
@@ -76,7 +76,7 @@ Agent 0.9.13 起,颜色发现阶段在本地 `goauto_diagnostics.db` 的 `agen
记录只包含颜色行数、各行值数量、可点击颜色数、不可点击候选数、首次颜色点击前的颜色/尺码选中数、是否存在“已选”摘要、横向滑动次数、终止原因和耗时,不保存规格文案、价格、摘要原文、坐标、链接、goods_id、控件树、XML 或截图。 记录只包含颜色行数、各行值数量、可点击颜色数、不可点击候选数、首次颜色点击前的颜色/尺码选中数、是否存在“已选”摘要、横向滑动次数、终止原因和耗时,不保存规格文案、价格、摘要原文、坐标、链接、goods_id、控件树、XML 或截图。
只对 Debug APK 使用以下读取流程: 以下仅为 v4 之前纯结构化诊断库的历史读取方式。v4 及之后包含采购失败私有现场,不再按此导出整库;请使用下方“Android 颜色发现诊断 v5”中的定向只读方式:
```powershell ```powershell
adb shell run-as cn.ilapage.goauto.agent ls databases adb shell run-as cn.ilapage.goauto.agent ls databases
@@ -86,24 +86,26 @@ sqlite3 -readonly agent-diagnostics.db "SELECT task_id,reason,color_row_count,co
读取时记录设备、Agent 版本、任务号和规则快照;工单只回写查询得到的脱敏聚合数值。读取完成后删除本地导出副本。正式 APK 若不允许 `run-as`,停止排查并确认安全的只读诊断出口,不通过放宽应用安全配置或上传完整数据库绕过。 读取时记录设备、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 ```powershell
adb -s <device-serial> shell run-as cn.ilapage.goauto.agent ls files/purchase_diagnostics 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;"
adb -s <device-serial> exec-out run-as cn.ilapage.goauto.agent cat files/purchase_diagnostics/<taskId>_<attemptId>.jsonl
``` ```
先列出文件,再读取本次任务/attempt 的准确文件名;不得扩大为导出所有应用数据或完整数据库。新版安装前的失败不会补生成日志;需要用户授权后重试才能产生新证据。Release 若禁止 run-as 则停止,不更改权限绕过;首版没有导出界面。该功能用于确定拒绝分支,并不代表已经修复对应采购故障。 如果日志出现 SIZE_ADVICE_CLICK_BLOCKED,只能证明保护已触发,不代表正常商品探测成功;正常商品仍必须读到预期颜色尺码。禁止直接重试 live 任务作“仅探测”验证:探测匹配后任务可回 pending 并继续下单。演练按 PDD 商品创建,且可能由档案匹配直接跳过 spec_probe;必须确认实际阶段和覆盖路径,装机与真机测试分别取得授权。
## SYB 商品列表查询等待(#355) ## SYB 商品列表查询等待(#355)
实现绑定 `a24c206`,仅 Web 客户端;2026-10-05 与 #353/#354 合并至 main `1b4f7cd` 并配套发布 Server/Web 至现有 167 服务器。已通过本地合成测试及线上只读页面验证:列表请求实际等待预算为 60000ms,原筛选组合超过 10 秒后正常返回。发布记录和回退目标见 #355 工单。 实现绑定 `a24c206`,仅 Web 客户端;2026-10-05 与 #353/#354 合并至 main `1b4f7cd` 并配套发布 Server/Web 至现有 167 服务器。已通过本地合成测试及线上只读页面验证:列表请求实际等待预算为 60000ms,原筛选组合超过 10 秒后正常返回。发布记录和回退目标见 #355 工单。
@@ -112,3 +114,47 @@ adb -s <device-serial> exec-out run-as cn.ilapage.goauto.agent cat files/purchas
- 保留现有加载、错误提示、取消和旧请求隔离逻辑,不增加自动重试。慢请求在 10~60 秒间完成时不再被原 10 秒客户端预算提前中止;超过 60 秒仍会超时,上游更短的超时也可能先终止请求。 - 保留现有加载、错误提示、取消和旧请求隔离逻辑,不增加自动重试。慢请求在 10~60 秒间完成时不再被原 10 秒客户端预算提前中止;超过 60 秒仍会超时,上游更短的超时也可能先终止请求。
- 此调整不优化后端执行速度。处理阶段筛选当前先加载候选并计算阶段、后分页;不选店铺且采购类型为全部时,候选可能很大。遇到持续慢查询,应另行分析候选预筛选与数据库执行计划,不能据此认为延长前端预算已解决后端性能问题。 - 此调整不优化后端执行速度。处理阶段筛选当前先加载候选并计算阶段、后分页;不选店铺且采购类型为全部时,候选可能很大。遇到持续慢查询,应另行分析候选预筛选与数据库执行计划,不能据此认为延长前端预算已解决后端性能问题。
- 只需发布包含该提交的 Web 资源即可生效,不要求数据库迁移、Android 安装或后端参数变更;发布仍需人工授权。 - 只需发布包含该提交的 Web 资源即可生效,不要求数据库迁移、Android 安装或后端参数变更;发布仍需人工授权。
## Android 地址编辑页控件就绪等待(#366,环节②)
实现绑定 `7fdfe04658d16a05ebcb016ee964a7263eb74802`,位于独立工单分支,未合并 main、未装机或发布;合成测试与 APK 构建结果见 #366 工单,不代表真机采购验收。只修改点击“修改”后等待详细地址输入框就绪的环节;地址列表识别(①)仍缺真实单/多地址列表证据,保存退出(③)、返回与最终复核(④)不改。
- 标题出现不再立即触发输入框缺失失败。在“详细地址”标签的既有同排区域,统计可见 EditText 的 structural 数量(包括空值/禁用)及其 enabled、实际 text 非空且未判为 hint 的子集 ready 数量。只有 structural=1 且 ready=1 才继续,读取的地址与就绪判定来自同一帧;后续 inputFresh 仍重新定位且只尝试一次。
- 原地址只读取同帧实际 text.trim(),不使用 contentDescription 补空。API 26+ 捕获内存 hintText / showingHintText;showingHintText=true 或 text.trim() 等于非空 hintText.trim() 时不就绪,继续等待。全局 label 和其他调用方的语义保持不变,这两个新增元数据不落盘、不上报、不记日志。
- API <26 或应用未正确提供 hint 元数据时,不能完全区分非空 text 是提示还是实际值;仅凭此修复不能保证所有设备/页面的提示文字均被识别,不使用提示词猜测。
- 沿用单轮最多 50 次采样、原 200ms 暂停及点击后的既有暂停。不再叠加标题等待与控件等待,不设置“三次多候选即失败”;采样与解析另有耗时,不能将其表述为严格 10 秒墙钟超时。既有安全异常仍可提前终止。
- 等待超时使用现有错误码:从未观察到详细地址标题为 PURCHASE_ADDRESS_EDIT_TIMEOUT;曾见标题但候选未就绪/不唯一为 PURCHASE_ADDRESS_UPDATE_FAILED。titleSeen 跨帧保留,因此最后一帧空树不会抹掉此前的标题证据。
- 诊断仅含固定 stage=address_editor_ready、reason 分类以及 titleSeen、structural、ready 标量,不含地址、收货人、电话或节点文本。具体原因以实现和测试中的固定分类为准;这不是新增 SQLite 字段或上传接口。
- 收货人/电话不能作为备用目标;区域重叠造成歧义时不输入、不保存。无额外点击、滑动、返回、保存或下单重试,正常单次动作路径保持。
- 此修复不能证明历史失败均因慢加载,也不解决保存地址后返回超时。识别①不能仅凭“不是规格面板+无提交按钮”推断已进入地址列表:两项都依赖同一个面板解析结果。
## 支付页返回后核单仍缺少证据(#365)
实现绑定 `37714d39b5ac8ff720cfa1d0d2609f9a8af3fbd9`(工单分支,未合并、安装或发布)。成功执行既有一次支付页安全返回后,在无订单上下文阶段以固定单调时钟起点观察约30秒;原预算后的新增观察不增加页面动作,待付款分支仍沿用独立读取预算。
- 先确认运行版本是否包含此提交,再区分失败码:持续支付页、空窗口、微信恢复和未授权应用等独立条件可提前结束,并非所有失败都等待30秒。
- 完整订单证据出现就结束;超时保留原订单结果未知语义,不能因为等待更久就把任务改为可重试失败或自动重下单。
- 固定期限从 Back 成功时起算,不是从提交订单或进入核单函数起算,也不会因新样本重置;capture 阻塞不能被该检查中断,待付款分支拥有独立预算。
- 不因本次延长断言历史问题就是加载慢。延长期出现必须点击才能展开的入口仍不会新增点击;页面识别/挽留弹窗等原因需要真实诊断证据另行分析。#364 的失败现场诊断不属于本单实现。
## Android 颜色发现诊断 v5(#370 第一阶段)
实现绑定 `9e5f09a`(工单分支),Debug 标识 `0.9.69-370-diag1`,尚未合并 main。本阶段只补诊断和测试夹具,不改变遍历、停止条件、采集完成状态或采购探测决策;不能据此宣称竖向漏采已修复。
`COLOR_DISCOVERY` 的结束聚合新增三个可空列:
- `color_vertical_swipe_count`:本次颜色循环中成功派发的 UP 次数,排除归顶及尺码阶段;不证明视口实际移动。
- `color_trailing_empty_read_count`:终止前连续解析不到颜色的外层发现轮数;每轮只记一次,点击与采价重读不计,非空轮清零,初始空为 1。
- `color_horizontal_incomplete`:1 表示已观察到同一行签名变化后,必要横向探索因失败或预算耗尽仍未完成;0 表示当前已观察行无需横扫或取得连续稳定证据;NULL 表示无法确认,包括重排、空解析、缺少横向容器或仅成功派发但没有移动证据。同 seed 的未知回访不能清除旧的 1,可靠完成的 0 可以清除。0 不代表整个商品已采全,签名变化也不是物理滑动成功的独立证明。
只保存计数、布尔/NULL 和固定原因码,不保存标签、价格、坐标、原始控件树、截图或个人信息。每行累计数仍不是逐屏轨迹;缺少日志不证明没执行,错误/超时提前退出可能没有结束聚合。现有 50 条/7 天留存不变。
本地 SQLite v4→v5 只补三列;旧行新值保持 NULL,不删除旧诊断或采购失败快照/队列/context。旧程序不保证能降级打开 v5,不能靠卸载清数据回退。
`goauto_diagnostics.db` 从 v4 起还含采购失败私有现场,不能按早期“纯结构化库”的假设导出或上传整库。已授权的 Debug 设备有 sqlite3 时可只查询所需聚合字段;无 sqlite3 时只使用已确认的内存读取方式,避免整库落盘,不放宽应用权限。示例查询(须替换设备,且实际存在 sqlite3):
```powershell
adb -s <serial> shell run-as cn.ilapage.goauto.agent sqlite3 -readonly databases/goauto_diagnostics.db "SELECT task_id,reason,color_vertical_swipe_count,color_trailing_empty_read_count,color_horizontal_incomplete FROM agent_diagnostic WHERE stage='COLOR_DISCOVERY' ORDER BY id DESC LIMIT 1;"
```
排查须绑定同一设备、Agent/PDD 版本、任务与规则快照,并对照同次 App 布局。网页竖排与 App 横排不是同一复现场景;当前横排只能做横向兼容性回归。真机测试结果及待验证项以 #370 工单为准。
+60 -2
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件) generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Android-Agent-API-Contract wiki_page: Android-Agent-API-Contract
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Android-Agent-API-Contract.- wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Android-Agent-API-Contract.-
wiki_revision: 1ad78ac33cf5876c6b7db6a89107854978066373 wiki_revision: 2102252a3f0ecb646b49941cc603423abb76ca2c
synchronized_at: 2026-10-06T01:39:51Z synchronized_at: 2026-10-08T07:11:01Z
<!-- gitea-wiki-mirror:end --> <!-- gitea-wiki-mirror:end -->
<!-- gitea-wiki-mirror:start --> <!-- gitea-wiki-mirror:start -->
@@ -641,6 +641,17 @@ Agent 主动提交 `order_result_unknown` 时保持订单号和下单时间为
| `PURCHASE_ORDER_RESULT_UNKNOWN` | 无法确认订单是否创建,请人工检查 | | `PURCHASE_ORDER_RESULT_UNKNOWN` | 无法确认订单是否创建,请人工检查 |
| `PURCHASE_PAYMENT_FORBIDDEN` | 系统禁止自动付款 | | `PURCHASE_PAYMENT_FORBIDDEN` | 系统禁止自动付款 |
### 地址编辑页初始等待诊断(#366,环节②)
实现绑定 `7fdfe04658d16a05ebcb016ee964a7263eb74802`(工单分支,尚未合并 main 或发布)。本修订只细化 Android 点击“修改”后的输入框等待及错误说明,不增加 HTTP 字段、数据库字段或错误码,不改变任务结果提交、设备/任务/规则关联和保存后的最终复核。
- 一个现有 50 次采样预算内,只有详细地址区域可见 EditText 的结构候选数为 1,且其中 enabled、实际 text 非空并排除明确 hint 的就绪候选数也为 1,才允许后续既有单次输入;结构计数包括空值和禁用候选。
- ready 和原地址不使用 contentDescription 补空;showingHintText=true 或实际 text.trim() 等于非空 hintText.trim() 时排除。hintText / showingHintText 仅为 API 26+ 的内存节点元数据,低版本为 null,不新增持久化或上传字段,不改变全局 label。应用缺失/错误的 hint 元数据或 API <26 时无法完全区分提示与实际值。
- 超时从未观察到“详细地址”标题时沿用 `PURCHASE_ADDRESS_EDIT_TIMEOUT`;曾见标题但输入框缺失、为空、仅显示 hint、禁用或存在歧义时沿用 `PURCHASE_ADDRESS_UPDATE_FAILED`。
- 此等待失败的 `errorMessage` 可附固定诊断 `stage=address_editor_ready;reason=<固定分类>;titleSeen=<布尔>;structural=<数量>;ready=<数量>`。reason 为 `title_missing`、`editor_missing`、`editor_ambiguous`、`editor_disabled`、`editor_empty` 或 `editor_hint`;titleSeen 累计保留,数量取最后采样帧,不包含地址值、收货人、电话及节点文本。
- 既有页面安全异常优先失败,不必耗尽预算;`inputFresh` 失败仍不重试。其它使用 `PURCHASE_ADDRESS_UPDATE_FAILED` 的路径保持原义,不要求它们附本段诊断。
- ①地址列表识别仍待真实场景证据,③保存退出、④返回与最终复核不改;本段不代表整单完成或真机验收。
## Agent 当前设备任务历史(#90) ## Agent 当前设备任务历史(#90)
四个只读接口统一使用设备注册所得的 Device Token,只返回该 Token 对应设备最近 30 天内的任务;设备 A 查询设备 B 的任务时按不存在处理,不泄露任务是否存在。 四个只读接口统一使用设备注册所得的 Device Token,只返回该 Token 对应设备最近 30 天内的任务;设备 A 查询设备 B 的任务时按不存在处理,不泄露任务是否存在。
@@ -1472,3 +1483,50 @@ Web 输入去重后一个值提交旧标量、多个值提交重复集合键,
| sybStatusSyncedAt | RFC3339 时间字符串或 null | 最近一次正常同步取得有效取消值的 UTC 时间,不代表上游取消发生时间 | | sybStatusSyncedAt | RFC3339 时间字符串或 null | 最近一次正常同步取得有效取消值的 UTC 时间,不代表上游取消发生时间 |
只使用上游 isCancel;不兼容性猜测字符串/布尔/其他数值,缺失或无效值保留原字段。成功同步的 false 可覆盖 true。无有效值的新记录两个字段均为 null;旧客户端可忽略新增字段,新 Web 对旧响应缺字段显示未获取。列表与详情不触发额外上游请求。取消状态仅展示,不改变采购准备阶段、创建/重试资格、任务执行或现有订单事实。 只使用上游 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,预算退出有持久游标;错误/失租不提交新游标。
## 采购失败现场诊断 v1(#364,实现 4581ee5,2026-10-08)
本节契约在 `4581ee5` 实现,后续合并记录见 #364;尚未发布或完成真机验收。只覆盖采购spec_probe/purchase的本次失败/结果待核对,不修改正常capture、采购结果/不可逆状态;成功结果后网络/提示异常不抓取。普通采集不纳入。每attempt UUID最多一份ZIP;重启不能补拍当前页面冒充原现场,恢复核单现场必须source=recovery。
### 专用接口
- POST `/api/agent/v1/purchase-tasks/{taskId}/attempts/{attemptId}/failure-snapshot`:Device Token鉴权及现有Agent传输策略。流式multipart,`metadata` UTF-8 JSON(≤64KiB),`archive` 可选ZIP(≤2MiB);不得写临时原始文件。验证attempt持久化的task/device/phase,不依赖task当前设备或running状态,终态允许异步补传。响应沿用Agent JSON envelope,data含taskAttemptId/status/replayed。
- GET `/api/admin/v1/purchase-tasks/{taskId}/failure-snapshots`:仅管理员,返回`{code:200,data:{items:[...]}}`;items仅attemptId/phase/status/source/errorCode/agentVersion/capturedAt/recordedAt/expiresAt/partial/reasons/downloadAvailable/zipSize等安全标量,不包含窗口标题、节点或manifest原文。
- GET `/api/admin/v1/purchase-tasks/{taskId}/attempts/{attemptId}/failure-snapshot/download`:仅管理员,附件ZIP,Content-Type application/zip、Cache-Control no-store、nosniff,服务端生成安全文件名。无快照/过期不可下载,普通采购员/API Key及普通Agent业务接口不得读取。不得在线渲染XML。
### metadata / ZIP
schemaVersion=1、attributeVersion=1;taskId/deviceId正整数、taskAttemptId UUID、phase为spec_probe或purchase;ruleSnapshotHash为有效SHA256或null,ruleSnapshotHashValid明确标记;有效时必须与attempt快照hash相同,无效时仅not_captured并记录rule_hash_invalid,不伪造hash。errorCode为有界代码,agentVersion为有界版本;recordedAt/capturedAt为UTC RFC3339,未截取capturedAt=null。source为execution/recovery;status为complete/partial/not_captured;reasons固定代码数组;windows数组含id/fileName/status及可获取的窗口属性。窗口标题等实际文本只存私有诊断内容,不出现在Admin摘要。
ZIP含manifest.json(与metadata语义一致)和每个可取得root窗口的window-<id>.xml,每个XML仅一个hierarchy根,禁止DTD/外部实体/路径穿越/重复条目;保留系统原顺序及不可见节点。窗口缺失/属性失败/节点数/字节数/耗时超限明确partial且仍可解析。password节点所有可能承载内容的文字字段均省略(text/contentDescription/hint/stateDescription/tooltip/action label等);不反射任意extras。标准UIAutomator属性名,扩展属性合法;XML非法字符采用显式可逆编码,不能丢弃。schema区分空值、不支持和读取失败。
未截取时只上传metadata,无ZIP,不制造空XML;无attempt只保存本机最小原因,不上传虚构归属。服务端解压后总量≤8MiB,不能只相信ZIP声明大小;按白名单条目流式有界验证,异常不回显正文。
### 存储、幂等及清理
Android复用诊断库升级至v4,新独立快照/待上传/实际执行上下文表;旧结构化诊断保持7天/50条,新快照开发容量64MiB和30天独立清理,队列随过期/淘汰终止;无新任务也定期清理。结果上报优先,上传单独线程/持久队列,失败不改变采购结果或阻塞设备释放;重传只复用已存字节。每attempt已有ZIP不得覆盖;仅无ZIP未截取状态允许后续该attempt首次恢复核单ZIP,不能阻止真实恢复现场。
Server追加迁移 `1791400000000_purchase_failure_snapshot.go` 建立purchase_failure_snapshot独立表(ZIP LONGBLOB、私有manifest LONGTEXT、归属与安全摘要,attempt UUID唯一)。同一有效载荷重放返回replayed=true;已有ZIP不同载荷409且保留原件;终态补传有效。按recordedAt起30天,上传不续期,过期补传410供客户端停止;清理启动立即执行并每小时,无任务也执行,下载查询亦拒绝已过期数据。使用既有管理员身份和专用API注册,不给采购员开放权限。
2MiB/8MiB/64MiB是经工单声明的开发测试起点;节点/耗时开发保护暂为5000节点、1500ms,采样前后检查,无法中断系统单次Binder读取的事实须记录;deadline后停止遍历,不异步继续抓下一任务。上述不是代表性真机实测结论,正式现场验收前仍需确认。上线/安装/真机抓取另行授权。
### v1 编码与队列细节
- windows 条目含 id、fileName(可空)、type、layer、title、active、focused、bounds、packageName、activityName、capturedAt、status、reasons、attributeStates;无 root 不制造空 XML。未暴露属性不得猜测。
- XML 节点标准属性含 text/resource-id/class/package/content-desc/checkable/checked/clickable/enabled/focusable/focused/scrollable/long-clickable/password/selected/bounds;扩展 path、child-count、visible-to-user、editable、hint、state-description、tooltip、input-type、max-text-length、drawing-order,collection-* 与 range-* 为扁平字段;node 下的 action 子元素保留 id/label。版本1公开属性名单以 SnapshotXml.attributes 为准,不反射 extras。
- 非值属性使用 `<属性名>-state=null|unsupported|read_error` 区分空值、不支持、读取失败;XML非法代码单元使用 `<属性名>-base64` 保存原始 UTF-16BE 字节的 Base64(包括未配对 surrogate)。换行/回车/Tab 使用数值实体。密码标识为 true、缺失或读取失败时,文字字段、状态/编码备份及 action label 均省略;只有显式 password=false 才允许保存内容,子 action 不得覆盖父节点保护。
- 固定 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。
- 有ZIP的新上传只接受相符的非成功执行 attempt;不能向 pending/成功 attempt 新增失败诊断,已有有效载荷重放仍幂等。manifest 与 multipart metadata 语义相同;ZIP拒绝未知条目、重复路径、加密/未知压缩、CRC错误,XML拒绝DTD和处理指令,除限定XML声明。
- 本地队列绑定 Server Origin 和载荷 fingerprint,切换服务器不向新服务器转交旧ZIP;旧上传响应不删除后来首次恢复ZIP。每60秒维护,网络/408/429/5xx按60秒至1小时退避,410与其他永久4xx停止该队列项。结果Outbox优先,不因设备忙而永久饥饿;上传线程不持有任务互斥、不操作设备。
- 诊断接口不进入通用请求正文日志;私有SQL记录器不输出manifest/BLOB。管理员摘要不包含窗口和节点内容。默认HTTP例外只沿用既有Agent设置,不新增开关;传输私有原始内容时同样存在明文风险,部署须确认现有传输策略。
+91 -2
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件) generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Deployment-and-Operations wiki_page: Deployment-and-Operations
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Deployment-and-Operations.- wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Deployment-and-Operations.-
wiki_revision: 8be38ac192b9e1930fdc3d998c23027fda17e8f3 wiki_revision: 01a3a9cd968510c9435ec1fb2be0040fe17d0282
synchronized_at: 2026-10-06T03:27:28Z synchronized_at: 2026-10-09T07:21:53Z
<!-- gitea-wiki-mirror:end --> <!-- gitea-wiki-mirror:end -->
<!-- gitea-wiki-mirror:start --> <!-- gitea-wiki-mirror:start -->
@@ -276,3 +276,92 @@ Provider 故障日志只允许记录调用关联 ID、操作类型、耗时、
- 服务端 SHA256:`a4cf6c7028a2bbbfd86a1d657ad9c3daad7b4d08445405d8ca9a778adc584760`;远端校验一致。 - 服务端 SHA256:`a4cf6c7028a2bbbfd86a1d657ad9c3daad7b4d08445405d8ca9a778adc584760`;远端校验一致。
- 发布验收:公网 `/`、`/index.html`、`/syb-products/index` 与发布 dist/index.html 字节相同,10 项入口 JS/CSS 和 `/api/v1/health` 均正常;日志无 panic/fatal/缺表缺字段错误。合并后的 #358 合成解析/导入/重解析及 task/purchase 规格键定向回归通过。未将健康验证视为历史数据已纠正。 - 发布验收:公网 `/`、`/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` 保留,需按执行中任务检查后切回并重启;不通过恢复数据库覆盖后续业务数据。 - 回滚目标 `/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` 保留;重启前核验任务空闲,保留追加列与已经保存的映射,不恢复旧备份覆盖此后业务。
## 采购失败现场诊断部署与保留(#364)
实现绑定 `4581ee5`;2026-10-09 已随 main `e26743c` 完成线上迁移及发布,见下方发布记录。#365/#366 为Android局部行为,#366仅②编辑框就绪/hint保护已交付,①地址列表识别仍待现场证据。
1. 先取得目标环境的追加迁移及发布授权,核对唯一待执行版本 `1791400000000_purchase_failure_snapshot.go`,建立独立私有诊断表,再启动配套Server/Web。专用摘要/ZIP下载注册到既有启动权限对账,只允许管理员;对账会维护完整API目录及采购员既有权限,不是只写两条接口。
2. Server接收与权限验证就绪后再安装新Agent。旧Server不支持新诊断接口;不要把“本机保存成功”当作“已上传”。仅合并main不启动服务、不执行线上迁移、不自动安装APK。
3. Server启动立即清理过期诊断,其后每小时维护;Android前台服务启动及每60秒维护。两端默认30天,手机私有诊断逻辑容量64MiB;无需Admin定时任务或新开关,清理不修改采购任务与订单事实。不得把诊断ZIP/数据库映射成静态目录。
4. 管理员从采购详情执行记录下载ZIP;无现场、未截取、partial、上传中/失败应按实际结果区分。排错日志仅固定错误分类,禁止复制ZIP内容到日志、工单、Wiki或消息;分析只摘必要脱敏结构。沿用现有Agent HTTPS/显式HTTP例外,HTTP上传有个人数据明文风险。
5. 代码回滚时保留追加表和已存数据,不降级或删除手机数据库来恢复;回滚到尚不识别schema v4的旧Agent须先单独评估,不承诺可直接覆盖安装。保留期限清理须有仍在运行的相应新版维护组件。
6. 本机换机后的数据库经用户授权初始化于 `127.0.0.1:3308/goauto`,配置仍来自仓库根目录已忽略的config.yaml;已完成baseline迁移及#364幂等、容量、权限验证,未启动业务服务或连接线上。不是将历史3307运行说明套用于所有环境;实际Supervisor目录/发布按目标环境另核对。
## 2026-10-08 换机后本地 Supervisor 联调配置(#364 后续运行准备)
本节是当前电脑的配置,优先于上文历史 D:/supervisor、3307 和 main-runtime 路径;不改变线上拓扑。
- Supervisor 位于 `D:/supervisord`,主配置 `supervisord.conf` 通过 `[include] files=programs/goauto.conf` 引入两个实例:`goauto-admin-api` 和 `goauto-admin-ui`。两者 `autostart=false`、`autorestart=true`,添加/重读配置不等于批准启动所有服务;只对目标实例操作,不重启整个Supervisor。
- 独立运行目录 `D:/OPC/goauto-runtime` 干净地固定在已合并main的 `e450ac28989de6827328b411e4bdc050dded9ab3`,用detached HEAD避免与开发分支混淆。根仓库 `D:/OPC/goauto` 仍保留用户旧分支和未提交资料,不在其中切main或覆盖。
- 两实例读取 `D:/OPC/goauto/config.yaml`;本地MySQL为127.0.0.1:3308/goauto,API端口8010,Web端口9527。后端调用运行目录的 `scripts/start-server.ps1 -ConfigPath D:/OPC/goauto/config.yaml -SkipMigration`,前端调用 `scripts/start-web.ps1 -ConfigPath D:/OPC/goauto/config.yaml`。后端仍会校验/确保数据库存在并执行既有启动权限对账;SkipMigration不是只读启动。
- 当前电脑默认go为1.24且GOTOOLCHAIN=local,继承的GOSUMDB=off又会阻止自动下载1.26.5;仅后端实例的PowerShell命令把已安装 `C:/Users/Qiu/go/pkg/mod/golang.org/toolchain@v0.0.1-go1.26.5.windows-amd64/bin` 前置到进程PATH,并设置GOTOOLCHAIN=local。已用该环境构建验证;未更改全局环境、校验策略或项目go.mod。此绝对路径是本机配置,其他电脑须核实已安装工具链,不照抄。
- 前端依赖属于运行目录自身,按锁文件安装,不链接其他worktree的node_modules。若旧残留依赖出现ERR_MODULE_NOT_FOUND且普通安装未补齐,先用 `node -e "import('vite')"` 复现,再在本目录按锁文件重新安装;不通过修改业务代码或升级依赖绕过。
- 手动启动顺序为API就绪后Web;Web原脚本最多等待API健康60秒,并将/api同源代理到本机8010。浏览器本机入口 `http://127.0.0.1:9527/`,Agent使用电脑实际局域网IP的8010端口,不把127.0.0.1配置给手机。
- 日志为 `D:/supervisord/goauto-admin-api.log`、`D:/supervisord/goauto-admin-ui.log`。检查依赖、端口和实际子进程环境;不能只因Supervisor显示Running就宣称HTTP就绪。
- 新库预置SYB每小时同步与Yeeke同步status=2(启用);蝦皮规格自动匹配、异常规格AI解析status=1(关闭)。本轮只读检查,未改任务状态;用户手动启动API后既有调度会运行,真实外部访问须结合本地凭据/配置确认,不能把本地环境当作自动隔离的沙箱。
- 配置动作未新增迁移、未部署线上、未安装APK;用户随后确认亲自点击了启动。健康/页面验证记录在工单,不将健康检查冒充登录、诊断下载或真机端到端验收。
## 2026-10-09 集成发布与 Android 签名连续性(#364/#365/#366/#367/#368/#370)
- 集成源码 main `e26743c817275137f7dc8a3659461d552371b9e3`。122.228.200.167 当前发布目录 `/home/goauto/releases/20261009-e26743c-integrated`,旧目录 `/home/goauto/releases/20261007-0138da8-359` 保留供代码回退。用户明确要求线上有采集/采购执行时不得重启;迁移前及切换前相关运行计数均为0,没有取消、重置或发起业务。
- 已授权追加迁移 `1791400000000_purchase_failure_snapshot.go`、启动权限对账及发布。私有备份 `/home/goauto/backups/20261009-integrated/database.sql` 目录0700/文件0600;只新增诊断表,回读LONGTEXT/LONGBLOB及attempt唯一索引通过。定时任务启停、Cron与参数摘要前后一致。
- Server SHA256 `ae7e479e94db0485661665306d0d0306ca5c3cd4019694991c29b64d113eb637`,运行进程二进制一致;新Web入口SHA256 `242158568fb3ee9c51e2e16574abf0a1b6b5b692ae167ec78bcb957cdf5e7434`。根入口、index、login、syb-products入口、10项JS/CSS、健康与验证码通过。Nginx实际读取用户可读性切换前确认;保留旧hashed资源与config/static/temp/var。没有更改Nginx配置;未认证诊断接口401、现有非管理员凭据403。
- 本次自动化验证为Android 274项受影响测试、Server任务/采购/商品/规格/权限/中间件/迁移相关包、Web 6项模拟API页面测试及三端构建。未执行真实采购或付款;管理员真实诊断下载及完整失败现场上传仍待业务验收。#366仅环节②,#370仅第一阶段诊断,不能视为剩余根因修复已完成。
- Android集成APK保持 `0.9.69-370-diag1 / versionCode 82`,不能只凭版本号区分旧包;本次最终APK SHA256 `68d7caf9ae7ee5808783056f4f1591266746197b520048fa64c89365723edef7`。2026-10-09已覆盖安装到用户指定手机,安装后文件哈希一致、配置及设备身份前后摘要一致、进程启动存在,没有卸载或清数据。
- 换机生成的调试签名与旧机不同会导致 `INSTALL_FAILED_UPDATE_INCOMPATIBLE`;不可为更新擅自卸载。用户提供的原调试签名已与手机证书核对一致,私有文件位于 `D:/OPC/goauto/debug.keystore`,仅在本地Git排除规则中忽略,绝不提交。应另行私密备份,不能重新生成替代。
- 当前Windows用户级 `C:/Users/Qiu/.gradle/init.d/goauto-debug-signing.gradle` 使用Android Components的 `finalizeDsl`,仅对applicationId `cn.ilapage.goauto.agent` 的debug签名指定原文件;其他项目及release配置不变。普通Gradle调试构建自动生效,文件缺失则明确失败,不退回新签名。更换电脑或GRADLE_USER_HOME时需带入相同私有文件及配置;不要把本机设置当作仓库内或其他电脑已配置。构建后用signingReport与apksigner复核,再以install -r更新。
## Android 商品导航诊断(#373,诊断阶段)
- 分支实现 `2c58d58`,调试包 `0.9.69-373-nav-diag3`;尚未合并 main,不代表旧商品页复用或旧页排除已修复。正式采购打开路径保持原样,新增 `PRODUCT_NAVIGATION` 结构诊断至既有 SQLite `agent_diagnostic`,区分 DIRECT/BROWSER 的 ACCEPTED/REJECTED/EXCEPTION,仅记录异常类名及耗时,沿用任务、attempt、device、phase、rule hash 关联和原50条/7天保留规则,无数据库升级。ACCEPTED只表示startActivity返回,不表示目标商品已加载。
- 仅debug包包含 `cn.ilapage.goauto.agent/.diagnostics.NavigationProbeReceiver`,要求发送者具有 `android.permission.DUMP`(ADB shell/system),release清单不含该入口。输入仅数字goodsId及direct/browser,不接受任意URL、脚本或购买动作。必须先获得指定设备覆盖安装及只打开商品页的授权,确认无业务任务运行;保留原签名用install -r,禁止为安装清数据或卸载。
- Agent主界面启动前台服务且无障碍就绪后,通过ADB执行 `adb -s <设备地址> shell am broadcast -n cn.ilapage.goauto.agent/.diagnostics.NavigationProbeReceiver --es goodsId <数字商品ID> --es mode direct`;浏览器对照使用 `--es mode browser`。忙碌/服务未就绪时拒绝、不排队,不能用取消或重置业务任务强行取得设备。
- 探针占用现有设备互斥锁,预算20秒/最多201轮;耗时Binder读取可能越过观察预算,但其后不再派发导航/点击,不能声称能强制中断系统读取。服务或无障碍实例失效、线程中断、登录/风控/验证码时停止。只打开商品链接,在浏览器中最多尝试3次唯一的精确“打开拼多多APP”/“打开拼多多 App”且节点自身可点击的入口;实际派发前再次校验包、窗口、节点与存活条件,无祖先/坐标/手势兜底。PDD内只读,不调用采购执行器、不进入规格选择/改地址/下单/付款,不创建/重试服务器任务。
- 临时探针输出通过 `adb -s <设备地址> logcat -d -s GoAutoNavProbe:I '*:S'` 读取;只包含随机runId、路径/固定结果、相对毫秒、节点计数、窗口id、布尔证据及仅在内存比较所得标题same/different/unknown,不保存标题/哈希/URL/页面原文/原始树/截图,不写业务数据库。别用logcat -t的全局最后几行冒充完整诊断;若缓冲已覆盖须如实记录。禁止导出完整无过滤系统日志。
- `legacy_would_accept`模拟现有两次商品结构命中,始终标注identityVerified=false;窗口变化、标题变化、商品结构成立都不是目标goodsId证明。shell触发探针与后台任务的系统启动许可可能不同;人工指定browser只验证浏览器路径,不证明当时自动回退的异常原因。正常/失败对照及真实后台时序仍须以任务绑定诊断和人工确认核验。
- 自动化验证应包含debug专用探针单测与 `:app:compileReleaseUnitTestKotlin`;探针测试放 `src/testDebug`,不得在共享 `src/test` 引用debug-only类。构建使用原私有签名,安装后回读包版本和APK哈希,并比较设置/设备身份摘要。
## #373 v4 修复版构建与验证(2026-10-09)
- 源码分支 `fix/373-navigation-diagnostics`,实现提交 `dbf9a5f4606529189e57b6c266449070d5001340`。APK版本 `0.9.69-373-fix1 / code82`,与前一导航诊断版不同;未安装修复版、未合并main、未发布线上,不需要Admin迁移或服务重启。
- APK SHA256 `171f4b12395e2940ce873b62ea394799f521d1a0997f6aaf7582a123c24795e8`;仍使用用户原签名,证书SHA256 `bf86d7465c6092be74ce9c4187eb30c9a7d045c3b91891a547e2598429766dec`。后续经安装授权用install -r保留数据,不能为覆盖擅自卸载。相同versionCode不能证明文件相同,应核对完整版本和哈希。
- 主Agent独立执行相关测试451项(23套件)零失败/错误,compileReleaseUnitTestKotlin与assembleDebug通过;规格及代码质量两轮独立只读审查通过。覆盖一次性交接/首传回放/上传标记失败/慢读取过期、浏览器出现前旧帧、diag3时序、3秒缓冲/1秒稳定、慢Binder点击冷却与超时零后续动作,以及采购、采集、原诊断相关回归。不是全量测试或真机采购验收。
- 修复版的真实采购/重购未执行,页面身份识别限制见业务规则。原debug导航探针依然用于旧判据时序取证,不把其legacy_would_accept日志冒充新版执行器通过证据;新版导航就绪由实际采购执行器及其纯策略判断。
- 诊断仍沿用已有本地记录;无需新schema、服务端权限或接口。运维排查先分清实际DIRECT/BROWSER路径、是否发生浏览器前旧页抢跑、是否缓冲/稳定超时,以及复用凭证是否有效;不能仅因startActivity接受就认定商品已加载。
+2
View File
@@ -116,6 +116,8 @@ var AdminAPIs = []APIPermission{
{"回填SYB采购单号", "/api/admin/v1/purchase-tasks/syb-order-writeback", "POST", true}, {"回填SYB采购单号", "/api/admin/v1/purchase-tasks/syb-order-writeback", "POST", true},
{"创建备货采购任务", "/api/admin/v1/purchase-tasks/stock", "POST", true}, {"创建备货采购任务", "/api/admin/v1/purchase-tasks/stock", "POST", true},
{"查看采购任务详情", "/api/admin/v1/purchase-tasks/:taskId", "GET", 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", "POST", true},
{"处理采购规格", "/api/admin/v1/purchase-tasks/:taskId/spec-decision", "POST", true}, {"处理采购规格", "/api/admin/v1/purchase-tasks/:taskId/spec-decision", "POST", true},
{"查看采购规格匹配", "/api/admin/v1/purchase-tasks/:taskId/matching", "GET", true}, {"查看采购规格匹配", "/api/admin/v1/purchase-tasks/:taskId/matching", "GET", true},
+4 -1
View File
@@ -249,12 +249,15 @@ func (s *Service) ResolveSYBSpec(ctx context.Context, request SYBSpecParseReques
func (s *Service) activeSetting(ctx context.Context) (models.AIMatchingSetting, string, error) { func (s *Service) activeSetting(ctx context.Context) (models.AIMatchingSetting, string, error) {
setting, err := s.setting(ctx) 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 规格匹配未启用") return models.AIMatchingSetting{}, "", fail(CodeNotConfigured, "AI 规格匹配未启用")
} }
if err != nil { if err != nil {
return models.AIMatchingSetting{}, "", err return models.AIMatchingSetting{}, "", err
} }
if !setting.Enabled {
return models.AIMatchingSetting{}, "", fail(CodeNotConfigured, "AI 规格匹配未启用")
}
if strings.TrimSpace(setting.APIKey) == "" { if strings.TrimSpace(setting.APIKey) == "" {
return models.AIMatchingSetting{}, "", fail(CodeNotConfigured, "AI 规格匹配未配置 API Key") return models.AIMatchingSetting{}, "", fail(CodeNotConfigured, "AI 规格匹配未配置 API Key")
} }
+1
View File
@@ -62,6 +62,7 @@ func MigratedModels() []any {
&models.PDDAccount{}, &models.PDDAccount{},
&models.PurchaseTask{}, &models.PurchaseTask{},
&models.PurchaseTaskAttempt{}, &models.PurchaseTaskAttempt{},
&models.PurchaseFailureSnapshot{},
&models.PurchaseOrderWriteback{}, &models.PurchaseOrderWriteback{},
&models.PurchaseOrderWritebackLease{}, &models.PurchaseOrderWritebackLease{},
&models.PurchaseOrderWritebackCommand{}, &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"` FinishedAt *time.Time `json:"finishedAt,omitempty"`
CreatedAt time.Time `json:"createdAt"` CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"` 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" } 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&amp;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&#10;two&#9;&amp;😀" 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)
}
}
}
+3
View File
@@ -26,6 +26,7 @@ func InitRouter(engine *gin.Engine, auth *jwt.GinJWTMiddleware) {
agent.POST("/:taskId/start", h.Start) agent.POST("/:taskId/start", h.Start)
agent.POST("/:taskId/order-submit-started", h.OrderSubmitStarted) agent.POST("/:taskId/order-submit-started", h.OrderSubmitStarted)
agent.POST("/:taskId/result", h.Result) 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 := engine.Group("/api/admin/v1/purchase-tasks").Use(auth.MiddlewareFunc()).Use(middleware.AuthCheckRole())
admin.GET("", h.AdminList) admin.GET("", h.AdminList)
admin.POST("/batch-preview", h.AdminBatchPreview) 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("/syb-order-writeback", h.AdminOrderWriteback)
admin.POST("/stock", h.AdminCreateStock) admin.POST("/stock", h.AdminCreateStock)
admin.GET("/:taskId", h.AdminDetail) 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("", h.AdminCreate)
admin.POST("/:taskId/spec-decision", h.SpecDecision) admin.POST("/:taskId/spec-decision", h.SpecDecision)
admin.GET("/:taskId/matching", h.MatchingDetail) admin.GET("/:taskId/matching", h.MatchingDetail)
+29 -3
View File
@@ -194,10 +194,33 @@ func (service *Service) suggestMappings(ctx context.Context, id uint64, requestC
Dimension: role, ShopeeTitle: shopee.Title, PDDTitle: pdd.Title, Dimension: role, ShopeeTitle: shopee.Title, PDDTitle: pdd.Title,
Sources: sources, Candidates: candidates, 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 suggestCalls := 1
if err != nil { if err != nil {
result, err = aiService.SuggestBatch(ctx, suggestReq) result, err = suggest(suggestReq)
if guardErr != nil {
return AISuggestResponse{}, guardErr
}
suggestCalls++ suggestCalls++
if err != nil { if err != nil {
return AISuggestResponse{}, aiUnavailable(aiSuggestErrorMessage(err)) 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 { 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, Dimension: role, ShopeeTitle: shopee.Title, PDDTitle: pdd.Title,
Sources: retrySources, Candidates: candidates, Sources: retrySources, Candidates: candidates,
}) })
if guardErr != nil {
return AISuggestResponse{}, guardErr
}
if retryErr == nil { if retryErr == nil {
for _, source := range retrySources { for _, source := range retrySources {
if decision, ok := retryResult.Decisions[source.ID]; ok { if decision, ok := retryResult.Decisions[source.ID]; ok {
@@ -148,6 +148,9 @@ func (service *Service) autoMatchMappings(ctx context.Context, id uint64, reques
replayed := false replayed := false
err = db.Transaction(func(tx *gorm.DB) error { 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 var current models.ShopeeProduct
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&current, id).Error; err != nil { if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&current, id).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
@@ -13,8 +13,10 @@ import (
"go-admin/app/goauto/aimatching" "go-admin/app/goauto/aimatching"
"go-admin/app/goauto/models" "go-admin/app/goauto/models"
log "github.com/go-admin-team/go-admin-core/logger"
"github.com/google/uuid" "github.com/google/uuid"
"gorm.io/gorm" "gorm.io/gorm"
"gorm.io/gorm/clause"
) )
const ( const (
@@ -23,6 +25,10 @@ const (
autoMatchLeaseDuration = 30 * time.Minute autoMatchLeaseDuration = 30 * time.Minute
autoMatchRetryDelay = time.Hour autoMatchRetryDelay = time.Hour
maxAutoMatchAttempts = 3 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 { type AutoMatchRunView struct {
@@ -41,11 +47,11 @@ func (service *Service) StartAutoMatchRun(ctx context.Context, trigger, requestI
if trigger != "manual" && trigger != "scheduled" { if trigger != "manual" && trigger != "scheduled" {
return AutoMatchRunView{}, false, invalidRequest("trigger 无效") return AutoMatchRunView{}, false, invalidRequest("trigger 无效")
} }
if batchLimit <= 0 { if batchLimit == 0 {
batchLimit = defaultAutoMatchBatchLimit batchLimit = defaultAutoMatchBatchLimit
} }
if batchLimit > 100 { if batchLimit < 1 || batchLimit > 100 {
return AutoMatchRunView{}, false, invalidRequest("batchLimit 不能超过 100") return AutoMatchRunView{}, false, invalidRequest("batchLimit 必须在 1 到 100 之间")
} }
now := time.Now().UTC() now := time.Now().UTC()
lease := now.Add(autoMatchLeaseDuration) lease := now.Add(autoMatchLeaseDuration)
@@ -55,8 +61,8 @@ func (service *Service) StartAutoMatchRun(ctx context.Context, trigger, requestI
created := false created := false
err := service.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error { err := service.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Model(&models.ShopeeSpecAutoMatchRun{}). if err := tx.Model(&models.ShopeeSpecAutoMatchRun{}).
Where("status = ? AND active_slot = ? AND lease_expires_at < ?", "running", 1, now). 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 { 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 return err
} }
if err := tx.Where("request_id = ?", requestID).First(&result).Error; err == nil { 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 // HTTP-launched goroutine or the scheduler because only the run owning the
// active slot may update and finish itself. // active slot may update and finish itself.
func (service *Service) ProcessAutoMatchRun(ctx context.Context, runID uint64) error { func (service *Service) ProcessAutoMatchRun(ctx context.Context, runID uint64) error {
ctx, cancel := context.WithTimeout(ctx, autoMatchTimeBudget)
defer cancel()
var run models.ShopeeSpecAutoMatchRun var run models.ShopeeSpecAutoMatchRun
if err := service.DB.WithContext(ctx).First(&run, runID).Error; err != nil { if err := service.DB.WithContext(ctx).First(&run, runID).Error; err != nil {
return err return err
@@ -120,98 +128,149 @@ func (service *Service) ProcessAutoMatchRun(ctx context.Context, runID uint64) e
if limit <= 0 || limit > 100 { if limit <= 0 || limit > 100 {
limit = defaultAutoMatchBatchLimit limit = defaultAutoMatchBatchLimit
} }
var candidates []models.ShopeeProduct ctx, cancelLease := context.WithCancelCause(ctx)
queryLimit := limit * 25 defer cancelLease(nil)
if queryLimit < 100 { ctx = context.WithValue(ctx, autoMatchRunContextKey{}, autoMatchRunGuard{run: run, cancel: func() { cancelLease(errAutoMatchLeaseLost) }})
queryLimit = 100 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 { stats.checkpointLoaded = true
queryLimit = 1000 for {
} if err := ctx.Err(); err != nil {
if err := service.DB.WithContext(ctx). return service.endAutoMatchBatch(ctx, run, stats, cursor, "time_budget", err)
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
} }
fingerprint, ok, err := service.autoMatchEligibility(ctx, product) if stats.processed >= limit {
if err != nil { return service.endAutoMatchBatch(ctx, run, stats, cursor, "batch_limit", nil)
failed++ }
if firstError == "" { if stats.scanned >= autoMatchScanBudget {
firstError = safeBatchError(err) 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 stats.processed >= limit {
} return service.endAutoMatchBatch(ctx, run, stats, cursor, "batch_limit", nil)
if !ok {
continue
}
eligible++
work, claimed, err := service.claimAutoMatchWork(ctx, run, product.ID, fingerprint)
if err != nil {
failed++
if firstError == "" {
firstError = safeBatchError(err)
} }
continue stats.scanned++
} fingerprint, skip, err := service.autoMatchEligibilityReason(ctx, product)
if !claimed { if err != nil {
continue return service.endAutoMatchBatch(ctx, run, stats, cursor, "error", err)
}
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)
} }
service.completeAutoMatchWork(work, postFingerprint, 0, 0, matchErr) if skip != "" {
continue 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) { 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 { if product.PDDProductID == nil {
return "", false, nil return "", "no_specs", nil
} }
var pdd models.PDDProduct if strings.TrimSpace(product.SpecsJSON) == `""` {
if err := service.DB.WithContext(ctx).First(&pdd, *product.PDDProductID).Error; err != nil { return "", "no_specs", nil
return "", false, err
}
if pdd.Status != "active" {
return "", false, nil
} }
shopeeSpecs, err := Unmarshal(product.SpecsJSON) shopeeSpecs, err := Unmarshal(product.SpecsJSON)
if err != nil { 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 shared, needsMatch := false, false
for _, role := range []string{RoleColor, RoleSize} { for _, role := range []string{RoleColor, RoleSize} {
pddValues, err := selectablePDDValues(pdd.SpecsJSON, role) pddValues, err := selectablePDDValues(pdd.SpecsJSON, role)
if err != nil { if err != nil {
return "", false, err return "", "", err
} }
if len(pddValues) == 0 { if len(pddValues) == 0 {
continue continue
@@ -220,24 +279,32 @@ func (service *Service) autoMatchEligibility(ctx context.Context, product models
if dimension.Role != role || len(dimension.Values) == 0 { if dimension.Role != role || len(dimension.Values) == 0 {
continue continue
} }
shared = true
for _, value := range dimension.Values { 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] { if value.Mapping == nil || value.Mapping.Status != MappingStatusConfirmed || !pddValues[value.Mapping.PDDValue] {
needsMatch = true needsMatch = true
} }
} }
} }
} }
if !shared || !needsMatch { if !shared {
return "", false, nil return "", "no_specs", nil
}
if !needsMatch {
return "", "confirmed", nil
} }
contextVersion := computeSpecContextVersion(product.PDDProductID, product.SpecsJSON, pdd.SpecsJSON) contextVersion := computeSpecContextVersion(product.PDDProductID, product.SpecsJSON, pdd.SpecsJSON)
var setting struct{ UpdatedAt time.Time } 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))) h := sha256.Sum256([]byte(contextVersion + "\x00" + setting.UpdatedAt.UTC().Format(time.RFC3339Nano)))
// Keeping the context version as a prefix lets ProcessAutoMatchRun pass the // Keeping the context version as a prefix lets ProcessAutoMatchRun pass the
// exact version to #194 without re-reading a potentially drifting input. // 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) { 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) lease := now.Add(autoMatchLeaseDuration)
var work models.ShopeeSpecAutoMatchWorkItem var work models.ShopeeSpecAutoMatchWorkItem
err := service.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error { 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 err := tx.Where("shopee_product_id = ?", productID).First(&work).Error
if errors.Is(err, gorm.ErrRecordNotFound) { 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} 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 { if err != nil {
return err return err
} }
if work.InputFingerprint == fingerprint { if autoMatchWorkSkip(work, fingerprint, now) != "" {
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
return errWorkNotClaimed }
} if work.InputFingerprint != fingerprint {
} else {
work.AttemptCount = 0 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": ""} 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") 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() now := time.Now().UTC()
updates := map[string]any{"input_fingerprint": fingerprint, "lease_owner": "", "lease_expires_at": nil, "confirmed_count": confirmed, "unmatched_count": unmatched} updates := map[string]any{"input_fingerprint": fingerprint, "lease_owner": "", "lease_expires_at": nil, "confirmed_count": confirmed, "unmatched_count": unmatched}
if matchErr == nil { if matchErr == nil {
@@ -294,18 +363,189 @@ func (service *Service) completeAutoMatchWork(work models.ShopeeSpecAutoMatchWor
updates["next_attempt_at"] = nil 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) { func (service *Service) renewAutoMatchRun(ctx context.Context, run models.ShopeeSpecAutoMatchRun) error {
lease := time.Now().UTC().Add(autoMatchLeaseDuration) if err := ctx.Err(); err != nil {
_ = service.DB.Model(&models.ShopeeSpecAutoMatchRun{}).Where("id = ? AND status = ? AND lease_owner = ?", run.ID, "running", run.LeaseOwner).Update("lease_expires_at", lease).Error 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() 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} 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.Model(&models.ShopeeSpecAutoMatchRun{}).Where("id = ? AND status = ? AND lease_owner = ?", run.ID, "running", run.LeaseOwner).Updates(updates).Error 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 { func batchErrorCode(err error) string {
@@ -3,6 +3,7 @@ package shopeeproduct
import ( import (
"context" "context"
"testing" "testing"
"time"
"go-admin/app/goauto/models" "go-admin/app/goauto/models"
@@ -66,7 +67,8 @@ func TestUnchangedUnmatchedWorkIsNotClaimedAgain(t *testing.T) {
db := openTestDB(t) db := openTestDB(t)
service := NewService(db) service := NewService(db)
one := uint8(1) 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 { if err := db.Create(&run).Error; err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -74,7 +76,9 @@ func TestUnchangedUnmatchedWorkIsNotClaimedAgain(t *testing.T) {
if err != nil || !claimed { if err != nil || !claimed {
t.Fatalf("work=%+v claimed=%v err=%v", work, claimed, err) 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") _, claimed, err = service.claimAutoMatchWork(context.Background(), run, 99, "fingerprint")
if err != nil || claimed { if err != nil || claimed {
t.Fatalf("unchanged unmatched claimed=%v err=%v", claimed, err) 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(&current, 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(&current, 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(&current, 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)
}
})
}
}
+29 -3
View File
@@ -2,6 +2,7 @@ package task
import ( import (
"context" "context"
"encoding/json"
"errors" "errors"
"fmt" "fmt"
"log" "log"
@@ -15,8 +16,8 @@ import (
) )
// autoLinkImageSearch is deliberately a narrow post-result hook. It only // autoLinkImageSearch is deliberately a narrow post-result hook. It only
// writes an association when the task was a complete collection, the frozen // writes an association when the collection is complete or missing only optional
// original association is unchanged, and the target product is active. Manual // metadata, the frozen original association is unchanged, and the target product is active. Manual
// changes made while the Agent was running win by the CAS predicate. // changes made while the Agent was running win by the CAS predicate.
// autoLinkedImageSearch 汇报本次是否确实写入了关联,以及关联覆盖了哪些 SYB 明细。 // autoLinkedImageSearch 汇报本次是否确实写入了关联,以及关联覆盖了哪些 SYB 明细。
// 只有确实写入时才值得触发后续的规格匹配(#287)。 // 只有确实写入时才值得触发后续的规格匹配(#287)。
@@ -32,7 +33,7 @@ func (service *Service) autoLinkImageSearch(ctx context.Context, taskID uint64)
if err := service.DB.WithContext(ctx).First(&task, taskID).Error; err != nil { if err := service.DB.WithContext(ctx).First(&task, taskID).Error; err != nil {
return autoLinkedImageSearch{}, internalError(err) return autoLinkedImageSearch{}, internalError(err)
} }
if task.Source != models.CollectionTaskSourceImageSearch || task.Status != models.TaskStatusCompleted || task.PDDProductID == nil || task.ImageSearchSnapshot == nil { if task.Source != models.CollectionTaskSourceImageSearch || !imageSearchResultAllowsAutoLink(task) || task.PDDProductID == nil || task.ImageSearchSnapshot == nil {
return autoLinkedImageSearch{}, nil return autoLinkedImageSearch{}, nil
} }
snapshot, err := loadImageSearchSnapshot(task) snapshot, err := loadImageSearchSnapshot(task)
@@ -94,6 +95,31 @@ func (service *Service) autoLinkImageSearch(ctx context.Context, taskID uint64)
}, nil }, nil
} }
// #368: Keep completed behavior unchanged. A partial result is eligible only
// with explicit, nonempty evidence that every missing field is optional.
// Unknown/malformed missing data must not silently widen this whitelist.
func imageSearchResultAllowsAutoLink(task models.CollectionTask) bool {
if task.Status == models.TaskStatusCompleted {
return true
}
if task.Status != models.TaskStatusCompletedPartial || task.MissingJSON == nil {
return false
}
var missing []string
if err := json.Unmarshal([]byte(*task.MissingJSON), &missing); err != nil || len(missing) == 0 {
return false
}
for _, field := range missing {
switch field {
case "reviewCount", "salesText", "shopName":
// Optional descriptive metadata; never colors, sizes or prices.
default:
return false
}
}
return true
}
// matchSpecsAfterImageSearch triggers the existing SYB batch spec match for the // matchSpecsAfterImageSearch triggers the existing SYB batch spec match for the
// details the image-search task just linked (#287). // details the image-search task just linked (#287).
// //
@@ -0,0 +1,324 @@
package task
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"reflect"
"sync/atomic"
"testing"
"go-admin/app/goauto/models"
"go-admin/app/goauto/shopeeproduct"
"github.com/google/uuid"
"gorm.io/gorm"
)
// This fixture leaves the hook uncalled so tests can change the submitted
// status, missing fields and association independently of the assertions.
func seedPartialImageSearch(t *testing.T, db *gorm.DB, deviceID *uint64) (models.CollectionTask, models.ShopeeProduct) {
t.Helper()
record := createTask(t, db, deviceID)
if err := db.Model(&models.PDDProduct{}).Where("id = ?", *record.PDDProductID).Update("status", "active").Error; err != nil {
t.Fatal(err)
}
shopee := models.ShopeeProduct{
ShopeeItemID: "test-item", Title: "test", Currency: "TWD",
SpecsJSON: `[{"name":"颜色","role":"color","values":[{"name":"黑色","source":"import"}]},{"name":"尺码","role":"size","values":[{"name":"M","source":"import"}]}]`,
}
if err := db.Create(&shopee).Error; err != nil {
t.Fatal(err)
}
syb := models.SYBProduct{
OrderCode: "TEST-ORDER", DetailID: 1, StockID: 1, ShopeeItemID: shopee.ShopeeItemID,
ShopeeProductID: &shopee.ID, TargetColor: "黑色", TargetSize: "M",
Quantity: 1, UnitPriceCent: 1000, ParseStatus: "success", RawJSON: "{}",
}
if err := db.Create(&syb).Error; err != nil {
t.Fatal(err)
}
snapshot := ImageSearchSnapshot{
ShopeeProductID: shopee.ID, RepresentativeSYBProductID: syb.ID, SYBProductIDs: []uint64{syb.ID},
ReferenceCurrency: "TWD", ReferencePriceCent: 1000, MaxPriceRatio: 3, PriceGuardSkipped: true,
ImageSearchImage: ImageSearchImage{ImageURL: "https://example.invalid/a.jpg", MediaType: "image/jpeg", SizeBytes: 1, SHA256: "0123456789012345678901234567890101234567890123456789012345678901"},
}
raw, err := json.Marshal(snapshot)
if err != nil {
t.Fatal(err)
}
if err := db.Model(&record).Updates(map[string]any{"source": models.CollectionTaskSourceImageSearch, "image_search_snapshot": string(raw)}).Error; err != nil {
t.Fatal(err)
}
return record, shopee
}
func TestAutoLinkImageSearchPartialMissingWhitelist(t *testing.T) {
cases := []struct {
name, status, missing string
nilMissing, want bool
}{
{name: "completed_empty", status: models.TaskStatusCompleted, missing: "[]", want: true},
{name: "completed_nil", status: models.TaskStatusCompleted, nilMissing: true, want: true},
{name: "nil", nilMissing: true},
{name: "blank"},
{name: "null", missing: "null"},
{name: "empty", missing: "[]"},
{name: "malformed", missing: `["reviewCount"`},
{name: "object", missing: `{ "reviewCount": true }`},
{name: "scalar", missing: `"reviewCount"`},
{name: "number_entry", missing: `["reviewCount",1]`},
{name: "null_entry", missing: `["reviewCount",null]`},
{name: "blank_entry", missing: `[""]`},
{name: "unknown", missing: `["reviewCount","futureField"]`},
{name: "case_mismatch", missing: `["ReviewCount"]`},
{name: "whitespace", missing: `[" reviewCount "]`},
{name: "color", missing: `["reviewCount","color"]`},
{name: "size", missing: `["size"]`},
{name: "price", missing: `["price"]`},
{name: "color_price", missing: `["reviewCount","price:黑色"]`},
{name: "unsupported_dimension", missing: `["unsupportedDimension:款式"]`},
{name: "invalid_dimension", missing: `["spec_dimension_invalid:size"]`},
{name: "pending", status: models.TaskStatusPending, missing: `["reviewCount"]`},
{name: "failed", status: models.TaskStatusFailed, missing: `["reviewCount"]`},
{name: "duplicate_optional", missing: `["reviewCount","reviewCount"]`, want: true},
}
fields := []string{"reviewCount", "salesText", "shopName"}
for mask := 1; mask < 8; mask++ {
var missing []string
for i, field := range fields {
if mask&(1<<i) != 0 {
missing = append(missing, field)
}
}
raw, _ := json.Marshal(missing)
cases = append(cases, struct {
name, status, missing string
nilMissing, want bool
}{name: fmt.Sprintf("allowed_%d", mask), missing: string(raw), want: true})
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
db := openTaskDatabase(t)
record, shopee := seedPartialImageSearch(t, db, nil)
status := tc.status
if status == "" {
status = models.TaskStatusCompletedPartial
}
var missing *string
if !tc.nilMissing {
missing = &tc.missing
}
if err := record.SetStatus(status); err != nil {
t.Fatal(err)
}
if err := db.Model(&record).Updates(map[string]any{"status": status, "missing_json": missing, "active_slot": record.ActiveSlot, "device_run_slot": record.DeviceRunSlot}).Error; err != nil {
t.Fatal(err)
}
linked, err := NewService(db).autoLinkImageSearch(context.Background(), record.ID)
if err != nil || linked.Linked != tc.want {
t.Fatalf("Linked=%v want=%v error=%v", linked.Linked, tc.want, err)
}
if err := db.First(&shopee, shopee.ID).Error; err != nil {
t.Fatal(err)
}
if shopee.ImageSearchLinked != tc.want || (shopee.PDDProductID != nil) != tc.want {
t.Fatalf("association/marker mismatch: %+v", shopee)
}
if tc.want && (*shopee.PDDProductID != *record.PDDProductID || len(linked.SYBProductIDs) != 1) {
t.Fatal("linked target or downstream SYB identifiers lost")
}
if err := db.First(&record, record.ID).Error; err != nil {
t.Fatal(err)
}
if record.Status != status || !reflect.DeepEqual(record.MissingJSON, missing) {
t.Fatal("eligibility must not rewrite collection status or missing fields")
}
})
}
}
func TestSubmitImageSearchPartialLinkAndMatch(t *testing.T) {
for _, scenario := range []string{"optional_partial", "completed", "critical_missing", "admin_source", "current_page_source", "disabled_pdd", "association_changed", "snapshot_missing", "ai_unavailable", "request_cancelled_during_match"} {
t.Run(scenario, func(t *testing.T) {
requestContext, cancelRequest := context.WithCancel(context.Background())
defer cancelRequest()
var syncCalls atomic.Int32
erp := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
syncCalls.Add(1)
if scenario == "request_cancelled_during_match" {
// Cancel only after the real post-result matching hook has
// started. Its detached context must still persist mappings.
cancelRequest()
}
if scenario == "ai_unavailable" {
w.WriteHeader(http.StatusServiceUnavailable)
return
}
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, `{"data":{"color":["黑色"],"size":["M"]}}`)
}))
defer erp.Close()
t.Setenv("GOAUTO_ERPGO_BASE_URL", erp.URL)
t.Setenv("GOAUTO_ERPGO_APIKEY", "synthetic-test-key")
db := openTaskDatabase(t)
device, token := registerTaskDevice(t, db, "partial-image-search")
record, shopee := seedPartialImageSearch(t, db, &device.ID)
service := newTaskService(db)
// Execution is outside this test's scope; submit through the real
// authenticated running-owner path with a persisted image-search snapshot.
if err := db.Model(&record).Update("status", models.TaskStatusRunning).Error; err != nil {
t.Fatal(err)
}
title, shop, sales, reviews := "商品", "店铺", "已拼100件", int64(12)
request := ResultRequest{
RequestID: uuid.NewString(), Status: models.TaskStatusCompletedPartial,
Product: ResultProduct{PDDGoodsID: record.GoodsIDSnapshot, Title: &title, ShopName: &shop, SalesText: &sales},
Dimensions: []ResultDimension{{Key: "color", Name: "颜色", Values: []string{"黑色"}}, {Key: "size", Name: "尺码", Values: []string{"M"}}},
ColorPrices: []ResultColorPrice{{Color: "黑色", PriceCent: 1000}},
SKUs: []ResultSKU{{Specs: map[string]string{"color": "黑色", "size": "M"}, PriceCent: 1000, Available: true}},
Missing: []string{"reviewCount"},
}
wantLinked, wantMapped := true, true
var unchangedPDD *uint64
switch scenario {
case "completed":
request.Status, request.Missing, request.Product.ReviewCount = models.TaskStatusCompleted, []string{}, &reviews
case "critical_missing":
request.Missing = append(request.Missing, "size")
wantLinked, wantMapped = false, false
case "admin_source", "current_page_source":
source := models.CollectionTaskSourceAdmin
if scenario == "current_page_source" {
source = models.CollectionTaskSourceAgentCurrentPage
}
if err := db.Model(&record).Update("source", source).Error; err != nil {
t.Fatal(err)
}
wantLinked, wantMapped = false, false
case "disabled_pdd":
if err := db.Model(&models.PDDProduct{}).Where("id = ?", *record.PDDProductID).Update("status", "disabled").Error; err != nil {
t.Fatal(err)
}
wantLinked, wantMapped = false, false
case "association_changed":
manual := seedAutoLinkPDD(t, db, "manual-pdd")
unchangedPDD = &manual.ID
if err := db.Model(&shopee).Update("pdd_product_id", manual.ID).Error; err != nil {
t.Fatal(err)
}
wantLinked, wantMapped = false, false
case "snapshot_missing":
if err := db.Model(&record).Update("image_search_snapshot", nil).Error; err != nil {
t.Fatal(err)
}
wantLinked, wantMapped = false, false
case "ai_unavailable":
// No exact match and no configured AI provider: the existing
// matching path must not invent mappings or undo successful collection.
request.Dimensions[0].Values = []string{"白色"}
request.Dimensions[1].Values = []string{"L"}
request.ColorPrices[0].Color = "白色"
request.SKUs[0].Specs = map[string]string{"color": "白色", "size": "L"}
wantMapped = false
}
for attempt := 0; attempt < 2; attempt++ {
ctx := context.Background()
if attempt == 0 {
ctx = requestContext
}
detail, err := service.SubmitResult(ctx, record.ID, request, token)
if scenario == "request_cancelled_during_match" && attempt == 0 {
// Detail still uses the original request context. Preserve that
// existing return behavior, then inspect the committed state
// independently; do not pretend the disconnected client got OK.
if requestContext.Err() != context.Canceled || err == nil {
t.Fatalf("expected cancelled response read, got %v", err)
}
detail, err = service.Detail(context.Background(), record.ID)
}
if err != nil {
t.Fatalf("submit attempt %d: %v", attempt, err)
}
if detail.Task.Status != request.Status || !reflect.DeepEqual(detail.Missing, request.Missing) || detail.Replayed != (attempt == 1) {
t.Fatalf("submission/replay changed result: status=%s missing=%v replayed=%v", detail.Task.Status, detail.Missing, detail.Replayed)
}
if len(detail.SKUs) != 1 || len(detail.Dimensions) != 2 {
t.Fatal("result children missing or duplicated")
}
if err := db.First(&shopee, shopee.ID).Error; err != nil {
t.Fatal(err)
}
if shopee.ImageSearchLinked != wantLinked {
t.Fatalf("image search marker=%v", shopee.ImageSearchLinked)
}
wantPDD := unchangedPDD
if wantLinked {
wantPDD = record.PDDProductID
}
if !reflect.DeepEqual(shopee.PDDProductID, wantPDD) {
t.Fatal("wrong association or concurrent change overwritten")
}
wantCalls := int32(0)
if wantLinked {
wantCalls = 1
}
if syncCalls.Load() != wantCalls {
t.Fatalf("matching hook calls=%d want=%d", syncCalls.Load(), wantCalls)
}
specs, err := shopeeproduct.Unmarshal(shopee.SpecsJSON)
if err != nil {
t.Fatal(err)
}
if len(specs) != 2 {
t.Fatalf("unexpected archive dimensions: %d", len(specs))
}
for _, dimension := range specs {
for _, value := range dimension.Values {
if wantMapped {
if value.Mapping == nil || value.Mapping.Status != shopeeproduct.MappingStatusConfirmed || value.Mapping.PDDValue != value.Name {
t.Fatalf("matching not persisted: %+v", value)
}
} else if value.Mapping != nil {
t.Fatalf("unexpected mapping: %+v", value)
}
}
}
}
if scenario == "optional_partial" {
// A second result for the same image carries the same original
// association snapshot. The first committed link wins; no fallback
// to another result or a second matching pass is introduced.
if err := db.First(&record, record.ID).Error; err != nil {
t.Fatal(err)
}
second := createTask(t, db, &device.ID)
if err := db.Model(&second).Updates(map[string]any{
"source": models.CollectionTaskSourceImageSearch, "image_search_snapshot": record.ImageSearchSnapshot,
"status": models.TaskStatusRunning,
}).Error; err != nil {
t.Fatal(err)
}
request.RequestID, request.Product.PDDGoodsID = uuid.NewString(), second.GoodsIDSnapshot
if _, err := service.SubmitResult(context.Background(), second.ID, request, token); err != nil {
t.Fatal(err)
}
if err := db.First(&shopee, shopee.ID).Error; err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(shopee.PDDProductID, record.PDDProductID) || syncCalls.Load() != 1 {
t.Fatal("competing image-search result replaced association or retriggered matching")
}
}
var purchases int64
if err := db.Model(&models.PurchaseTask{}).Count(&purchases).Error; err != nil {
t.Fatal(err)
}
if purchases != 0 {
t.Fatal("matching must not create a purchase task")
}
})
}
}
+4
View File
@@ -129,6 +129,10 @@ func run() error {
defer stopOfflineMonitors() defer stopOfflineMonitors()
for _, db := range sdk.Runtime.GetDb() { for _, db := range sdk.Runtime.GetDb() {
service := goautodevice.NewService(db) 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( go goautodevice.RunOfflineMonitor(
offlineMonitorContext, service, goautodevice.DefaultOfflineScan, goautodevice.DefaultOfflineThreshold, offlineMonitorContext, service, goautodevice.DefaultOfflineScan, goautodevice.DefaultOfflineThreshold,
func(err error) { log.Errorf("device offline monitor failed: %v", err) }, func(err error) { log.Errorf("device offline monitor failed: %v", err) },
@@ -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
})
}
@@ -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
})
}
@@ -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")
}
}
@@ -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")
}
})
}
}
+17 -1
View File
@@ -26,7 +26,7 @@ func LoggerToFile() gin.HandlerFunc {
return func(c *gin.Context) { return func(c *gin.Context) {
// #237: client request/response bodies and one-time credentials must never // #237: client request/response bodies and one-time credentials must never
// enter the legacy operation logger. The client gateway keeps metadata-only audit. // 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() c.Next()
return 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 该方法后续即将弃用 // SetDBOperLog 写入操作日志表 fixme 该方法后续即将弃用
func SetDBOperLog(c *gin.Context, clientIP string, statusCode int, reqUri string, reqMethod string, latencyTime time.Duration, body string, result string, status int) { 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>
+26 -2
View File
@@ -73,6 +73,7 @@
<section><h3>执行记录</h3><el-table :data="detail.attempts" border size="small" empty-text="暂无执行记录"> <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 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-table></section>
<el-alert v-if="detail.task.errorCode" :title="`${detail.task.errorCode}:${detail.task.errorMessage || '任务执行失败'}`" type="error" :closable="false" show-icon class="notice" /> <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 { 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 { authorizeRepurchase, cancelPurchaseTask, choosePurchaseMatching, getPurchaseTask, listPurchaseTasks, requeuePurchaseMatching, resolveUnknownPurchaseTask, retryPurchaseTasksBatch, reviewPurchasePayment, selectPurchaseWriteback, writebackPurchaseOrderNumbers } from '@/api/goauto/purchase-tasks'
import { createRequestId } from '@/utils/request-id' import { createRequestId } from '@/utils/request-id'
import { listFailureSnapshots } from '@/api/goauto/purchase-failure-snapshots'
import FailureSnapshotCell from './FailureSnapshotCell.vue'
const statusOptions = [ const statusOptions = [
['pending', '待执行'], ['spec_probe_pending', '待探测规格'], ['running', '执行中'], ['rehearsal_completed', '演练完成'], ['pending', '待执行'], ['spec_probe_pending', '待探测规格'], ['running', '执行中'], ['rehearsal_completed', '演练完成'],
@@ -161,10 +164,12 @@ const statusOptions = [
export default { export default {
name: 'GoAutoPurchaseTasks', name: 'GoAutoPurchaseTasks',
components: { FailureSnapshotCell },
setup() { return { RefreshLeft, Search } }, setup() { return { RefreshLeft, Search } },
data() { data() {
return { return {
loading: false, loadError: '', tasks: [], total: 0, statuses: [...statusOptions, { value: 'syb_writeback_succeeded', label: '已回填 SYB' }], 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: [] }, orderWriteback: { open: false, saving: false, items: [] },
query: { page: 1, pageSize: 20, taskId: '', taskType: '', shopeeOrderNo: '', status: '', executionMode: 'live', sybProductId: '', pddOrderNo: '' }, query: { page: 1, pageSize: 20, taskId: '', taskType: '', shopeeOrderNo: '', status: '', executionMode: 'live', sybProductId: '', pddOrderNo: '' },
detail: { open: false, loading: false, task: null, attempts: [], matching: null }, detail: { open: false, loading: false, task: null, attempts: [], matching: null },
@@ -178,6 +183,7 @@ export default {
} }
}, },
computed: { computed: {
isSnapshotAdmin() { return (this.$store.getters.roles || []).includes('admin') },
orderFilter() { return parsePurchaseOrderFilter(this.query.shopeeOrderNo) }, orderFilter() { return parsePurchaseOrderFilter(this.query.shopeeOrderNo) },
retryCandidates() { return this.retrySelection.filter(this.isRetrySelectable) }, retryCandidates() { return this.retrySelection.filter(this.isRetrySelectable) },
orderWritebackCandidates() { return this.retrySelection.filter(this.canOrderWriteback) }, 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) }, 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 } 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() { created() {
const taskId = Number(this.$route.query.taskId) const taskId = Number(this.$route.query.taskId)
if (Number.isInteger(taskId) && taskId > 0) { if (Number.isInteger(taskId) && taskId > 0) {
@@ -223,8 +235,20 @@ export default {
}, },
search() { this.query.page = 1; this.load() }, search() { this.query.page = 1; this.load() },
reset() { this.query = { page: 1, pageSize: 20, taskId: '', taskType: '', shopeeOrderNo: '', status: '', executionMode: 'live', sybProductId: '', pddOrderNo: '' }; 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 } }, clearSnapshots() { this.detailSession++; this.snapshots = { items: {}, loading: false, error: 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 }, 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 || '—' }, 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' }, 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] || '未记录' }, 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 { export default {
name: 'GoAutoSybInnerCodes', name: 'GoAutoSybInnerCodes',
setup() { return { FolderOpened, RefreshLeft, Search, Upload } }, 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: { computed: {
matchingBusy() { return this.matchSubmitting || ['pending', 'running'].includes(this.activeJob?.status) }, 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)) }, 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)
})