Compare commits

...
Author SHA1 Message Date
QiuSWandClaude Sonnet 5.5 e351a69912 feat(web): add shop column to SYB product export (#372)
Insert a text "店铺" column (row.shopName) right after SYB订单 and move
the 0.00 number format to the shifted price column. Sync the wiki mirror.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
2026-10-10 11:50:18 +08:00
QiuSW db64a49e69 merge: heartbeat history and Android sync diagnostics (#378) 2026-10-10 11:15:38 +08:00
QiuSW b79e281b97 docs: complete Android heartbeat diagnostic contracts (#378) 2026-10-10 11:12:07 +08:00
QiuSW dbe7e98365 fix(android): record handled purchase recovery failures in sync diagnostics (#378) 2026-10-10 11:02:46 +08:00
QiuSW 9f489fdf62 test: validate Android heartbeat reports through Server handler (#378) 2026-10-10 10:58:35 +08:00
QiuSW a28b869701 feat(android): persist bounded sync diagnostics and negotiate heartbeat reports (#378) 2026-10-10 10:57:33 +08:00
QiuSW c18bf78e0e docs: record Server heartbeat diagnostics and contention boundaries (#378) 2026-10-10 10:26:58 +08:00
QiuSW f5edd889f7 test(server): clarify heartbeat concurrency evidence (#378) 2026-10-10 10:24:00 +08:00
QiuSW d84728dde9 fix(server): isolate offline scan contention per device (#378) 2026-10-10 10:19:44 +08:00
QiuSWandClaude Opus 5.5 575d5f34cc docs: record #372 v2 web release (#372)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
2026-10-10 09:54:04 +08:00
QiuSWandClaude Opus 5.5 4ad59bd231 merge: SYB price column and numeric amounts in exports (#372)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
2026-10-10 09:48:42 +08:00
QiuSWandClaude Sonnet 5.5 d528c74c57 docs: mirror #372 v2 export rules (#372)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
2026-10-10 09:47:57 +08:00
QiuSWandClaude Sonnet 5.5 d8d9ab9edd feat(web): add SYB price column and numeric amount cells to exports (#372)
SYB product export gets "SYB售价(TWD)" after "虾皮商品ID" (unitPriceCent / 100,
same source as the page price column). Purchase price and SYB price are written
as numeric cells; an optional numberFormatCols option on export_json_to_excel
shows them as 0.00 without changing other pages' exports.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
2026-10-10 09:40:02 +08:00
QiuSW 010e9b90ae feat: record bounded heartbeat and device transition diagnostics (#378) 2026-10-10 09:26:45 +08:00
QiuSWandClaude Opus 5.5 919744ec05 docs: record #372 release (#372)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
2026-10-10 09:19:12 +08:00
QiuSW 72a15f8d9f merge: list export for purchase tasks and SYB products (#372)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F

# Conflicts:
#	docs/03-business-rules-and-glossary.md
2026-10-10 09:10:23 +08:00
QiuSWandClaude Sonnet 5.5 b311885ea8 docs: sync Wiki mirror for list export (#372)
Business-Rules-and-Glossary@1c57a86, Architecture-and-Code-Map@08da88a.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
2026-10-10 09:01:11 +08:00
QiuSWandClaude Sonnet 5.5 a4a793f449 feat(web): export all filtered pages to Excel for purchase tasks and SYB products (#372)
- Web: shared list-export util (5000-row cap, progress, no partial file,
  filters frozen at click time) and Export buttons on the purchase
  management and SYB products pages; return matches are read per batch
  of product ids and only matched/confirmed ones are used.
- Server: purchase list gains inclusive orderSubmittedFrom/To day filter
  (UTC+8, same as SYB createdFrom/To, INVALID_REQUEST on bad dates);
  return-matches list items gain read-only yeekeQuantity from the
  existing join. No migration, no new endpoint or permission.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
2026-10-10 08:58:28 +08:00
QiuSW 4a669bca86 docs: document rejected purchase recovery and verification (#377) 2026-10-09 18:30:38 +08:00
QiuSW 309dce3627 fix(android): reconcile rejected runtime state and sync admission #377 2026-10-09 18:26:03 +08:00
QiuSW 8b75a95d67 fix(android): isolate rejected purchase results and restore sync #377 2026-10-09 18:22:09 +08:00
QiuSW 3ee8dfc0aa docs: record main integration and combined APK (#373 #374) 2026-10-09 15:59:36 +08:00
QiuSW 555a954605 merge: scoped final specification confirmation (#374) 2026-10-09 15:54:04 +08:00
QiuSW 1e924fa411 merge: navigation readiness and one-shot probe handoff (#373) 2026-10-09 15:53:49 +08:00
QiuSW 1c67f3fdd0 docs: sync final specification confirmation contract #374 2026-10-09 15:47:14 +08:00
QiuSW 6f1af70d0d fix(android): verify scoped final purchase spec evidence (#374) 2026-10-09 15:42:58 +08:00
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 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
88 changed files with 7691 additions and 323 deletions
+3
View File
@@ -30,6 +30,9 @@ android {
}
buildTypes {
debug {
versionNameSuffix = "-373-fix1"
}
release {
isMinifyEnabled = false
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
+4 -1
View File
@@ -1,4 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<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>
@@ -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" }
}
@@ -122,6 +122,7 @@ class GoAutoAccessibilityService : AccessibilityService(), UiDriver, PddCollecto
).restore(packageName, timeoutMillis)
fun openAgentPreservingTab(): Boolean = runCatching {
foregroundRevision.incrementAndGet() // Explicit navigation invalidates handoff even before its window event arrives.
dismissPurchaseResultBubble()
startActivity(
android.content.Intent(this, cn.ilapage.goauto.agent.MainActivity::class.java).apply {
@@ -340,7 +341,16 @@ class GoAutoAccessibilityService : AccessibilityService(), UiDriver, PddCollecto
labels = { listOfNotNull(it.text?.toString(), it.contentDescription?.toString()) },
)
override fun clickFreshDetailed(target: SnapshotNode): FreshClickOutcome {
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 candidates = mutableListOf<AccessibilityNodeInfo>()
walk(root) { node ->
@@ -370,6 +380,7 @@ class GoAutoAccessibilityService : AccessibilityService(), UiDriver, PddCollecto
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)) {
FreshClickOutcome(FreshActionResult.SUCCESS, FreshClickReason.SUCCESS, candidates.size, ancestorDepth)
} else {
@@ -1,8 +1,26 @@
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 {
fun open(preferDirect: Boolean, direct: () -> Boolean, browser: () -> Boolean): Boolean {
if (preferDirect && runCatching(direct).getOrDefault(false)) return true
return runCatching(browser).getOrDefault(false)
fun open(preferDirect: Boolean, direct: () -> Boolean, browser: () -> Boolean,
diagnostic: (PddLaunchEvent) -> Unit = {}): Boolean {
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) {
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
if (uri.scheme !in setOf("http", "https") || !isPddHost(uri.host) || uri.getQueryParameter("goods_id").isNullOrBlank()) {
return false
@@ -42,7 +42,7 @@ class PddLinkLauncher(private val context: Context) {
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK)
context.startActivity(direct)
true
}, browser = { openBrowser(uri) })
}, browser = { openBrowser(uri) }, diagnostic = diagnostic)
}
private fun openBrowser(uri: Uri): Boolean {
@@ -51,7 +51,9 @@ class PddLinkLauncher(private val context: Context) {
runCatching { context.packageManager.getPackageInfo(packageName, 0) }.isSuccess
}
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 =
@@ -167,6 +167,8 @@ data class ParsedPddScreen(
val quantityInputCount: Int = 0,
/** #331: address entry, payment entry and quantity input share one bounded panel container. */
val requiredEvidenceSameContainer: Boolean = false,
/** Only populated by the final-confirmation opt-in; existing selectedSummary behavior is unchanged. */
val finalSelectionSummaries: List<String> = emptyList(),
) {
/** The three #331 required spec-panel items are all visible. */
val hasRequiredPanelEvidence: Boolean get() = hasAddressEntry && hasPaymentEntry && hasQuantityInput
@@ -265,7 +267,7 @@ object PddScreenParser {
/** Whole-sheet containers are clickable too; a real entry row stays well below this share of the screen height. */
private const val ENTRY_ROW_MAX_HEIGHT_PERCENT = 30
fun parse(snapshot: UiSnapshot, config: PddCollectorConfig, goodsId: String, evidence: PageEvidence?, purchaseContext: PurchasePanelContext? = null): ParsedPddScreen {
fun parse(snapshot: UiSnapshot, config: PddCollectorConfig, goodsId: String, evidence: PageEvidence?, purchaseContext: PurchasePanelContext? = null, forFinalConfirmation: Boolean = false): ParsedPddScreen {
val visibleNodes = snapshot.nodes.filter { it.visible }
val visible = visibleNodes.mapNotNull { node ->
val descendants = descendants(node, visibleNodes)
@@ -422,9 +424,12 @@ object PddScreenParser {
)
}
.toList()
.groupBy(VisibleSpecValue::rawText)
.map { (_, sameLabel) ->
sameLabel.minBy { option -> safeOptionRank(option.node, visibleNodes) }
.let { physicalOptions ->
// Keep optionBlock's physical parent/child aggregation in both modes.
// Only final confirmation must detect separate same-label option blocks.
if (forFinalConfirmation) physicalOptions else physicalOptions
.groupBy(VisibleSpecValue::rawText)
.map { (_, sameLabel) -> sameLabel.minBy { option -> safeOptionRank(option.node, visibleNodes) } }
}
if (values.isNotEmpty()) add(VisibleDimension(dimensionKey, heading.label, values))
}
@@ -471,6 +476,7 @@ object PddScreenParser {
else -> SpecPanelType.UNKNOWN
}
val panelOpen = specPanelType != SpecPanelType.UNKNOWN
var unprefixedSummaryCandidates = emptyList<String>()
val unprefixedSummary = if (structuredSelectionPanel) {
val quantity = quantityInputs.single()
val knownColors = (dimensions.filter { it.key == "color" }.flatMap { it.values }.map { it.text } +
@@ -486,6 +492,7 @@ object PddScreenParser {
knownColors.any { SpecValueNormalizer.summaryHasExactToken(node.label, it) }
}.map { it.label }.distinct()
if (candidates.isNotEmpty()) {
unprefixedSummaryCandidates = candidates
summary = candidates.singleOrNull()
break
}
@@ -574,6 +581,12 @@ object PddScreenParser {
hasQuantityAdjustControls = hasQuantityAdjustControls,
quantityInputCount = quantityInputs.size,
requiredEvidenceSameContainer = requiredEvidenceSameContainer,
finalSelectionSummaries = if (forFinalConfirmation) {
// Use own node labels, not labels propagated into clickable ancestors.
(visibleNodes.map(SnapshotNode::label).filter { label ->
textAliases.selection.selectedPrefixes.any { label.startsWith(it) }
} + unprefixedSummaryCandidates).distinct()
} else emptyList(),
explicitSpecEntryCount = explicitSpecEntries.size,
nestedSpecEntryCount = nestedSpecEntries.size,
bottomPurchaseEntryCount = bottomSpecEntries.size,
@@ -1392,6 +1405,12 @@ class PddProductDetailCollector(
var diagnosticInitialSelectedSizes = 0
var diagnosticSelectedSummaryPresent = false
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
val imageAttempts = mutableSetOf<String>()
@@ -1448,6 +1467,13 @@ class PddProductDetailCollector(
initialSelectedSizeCount = diagnosticInitialSelectedSizes,
selectedSummaryPresent = diagnosticSelectedSummaryPresent,
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,
)
}
@@ -1464,6 +1490,8 @@ class PddProductDetailCollector(
val initialRows = colorRows(initial)
observeColorDiscovery(initial, initialRows)
if (initialRows.isEmpty()) {
diagnosticTrailingEmptyReads = 1
diagnosticHorizontalUnknown = true
finishColorDiscovery(
if (diagnosticNonClickableCandidates > 0) AgentDiagnosticReason.COLOR_VALUES_NOT_CLICKABLE
else AgentDiagnosticReason.COLOR_EDGE_REACHED,
@@ -1471,7 +1499,8 @@ class PddProductDetailCollector(
return null
}
val attempted = mutableSetOf<String>()
fun collectVisibleColors(): PddCollectorResult? {
fun collectVisibleColors(onFirstRows: ((List<List<VisibleSpecValue>>) -> Unit)? = null): PddCollectorResult? {
var firstRead = true
while (true) {
if (now() > deadline) return failure("RULE_NOT_MATCHED", "采集超过规则总超时")
val screen = parse(goodsId, config, evidence)
@@ -1479,6 +1508,10 @@ class PddProductDetailCollector(
if (!screen.pageEvidenceMatched) return failure("RULE_NOT_MATCHED", "采集期间离开 PDD 商品详情页")
screen.dimensions.filter { it.key == "unsupported" }.forEach { unsupported += it.name }
val rows = colorRows(screen)
if (firstRead) {
onFirstRows?.invoke(rows)
firstRead = false
}
observeColorDiscovery(screen, rows)
rows.flatten().filter { SpecValueNormalizer.isSafeColor(it.text) }.forEach { value ->
colors[value.text] = colors[value.text] == true || value.available
@@ -1531,8 +1564,18 @@ class PddProductDetailCollector(
rowSeeds.forEachIndexed { rowIndex, seed ->
val moveRight = rowIndex % 2 == 0
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")) {
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.problem?.let { return failure(it.code, it.message) }
if (!screen.pageEvidenceMatched) return failure("RULE_NOT_MATCHED", "采集期间离开 PDD 商品详情页")
@@ -1541,21 +1584,49 @@ class PddProductDetailCollector(
val matchedRow = rows.maxByOrNull { row -> row.count { it.text in seed } }
?.takeIf { row -> row.any { it.text in seed } }
if (matchedRow == null) {
diagnosticHorizontalUnknown = true
diagnosticTermination = AgentDiagnosticReason.COLOR_ROW_REFLOWED
break
}
val currentRow = matchedRow.sortedBy { it.node.bounds.left }
.let { if (moveRight) it else it.reversed() }
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
horizontalSignatureReads[signature] = signatureReads
if (signatureReads > config.limits.getValue("stableEdgeReads")) break
if (horizontalPass == config.limits.getValue("specHorizontalSwipes")) break
if (signatureReads > config.limits.getValue("stableEdgeReads")) {
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
if (!driver.swipeSpec(direction, currentRow.first().node)) break
if (!driver.swipeSpec(direction, currentRow.first().node)) {
if (diagnosticMoved && !diagnosticRowUncertain) diagnosticRowIncomplete = true
break
}
diagnosticHorizontalSwipes++
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 }
screen = parse(goodsId, config, evidence)
@@ -1564,6 +1635,10 @@ class PddProductDetailCollector(
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) {
diagnosticTermination = AgentDiagnosticReason.COLOR_EDGE_REACHED
break
@@ -1586,6 +1661,7 @@ class PddProductDetailCollector(
}
val anchor = rows.flatten().firstOrNull()?.node ?: specPanelContainer
if (anchor == null) {
diagnosticHorizontalUnknown = true
diagnosticTermination = AgentDiagnosticReason.COLOR_CONTAINER_UNAVAILABLE
break
}
@@ -1593,6 +1669,7 @@ class PddProductDetailCollector(
diagnosticTermination = AgentDiagnosticReason.COLOR_SWIPE_FAILED
break
}
diagnosticVerticalSwipes++
pause(350)
}
finishColorDiscovery()
@@ -1621,6 +1698,9 @@ class PddProductDetailCollector(
initialSelectedSizeCount: Int,
selectedSummaryPresent: Boolean,
horizontalSwipes: Int,
verticalSwipes: Int,
trailingEmptyReads: Int,
horizontalIncomplete: Boolean?,
elapsedMs: Long,
) {
if (taskId <= 0) return
@@ -1639,6 +1719,9 @@ class PddProductDetailCollector(
initialSelectedSizeCount = initialSelectedSizeCount,
selectedSummaryPresent = selectedSummaryPresent,
horizontalSwipeCount = horizontalSwipes,
colorVerticalSwipeCount = verticalSwipes,
colorTrailingEmptyReadCount = trailingEmptyReads,
colorHorizontalIncomplete = horizontalIncomplete,
),
)
}
@@ -0,0 +1,126 @@
package cn.ilapage.goauto.agent.automation
/** Current-capture evidence only. No clicks, historical selection proof, or whole-screen text fallback. */
internal object PurchaseFinalSpecVerifier {
private fun normalize(value: String) = SpecValueNormalizer.normalizeFinalConfirmation(value)
fun verify(snapshot: UiSnapshot, input: PurchaseExecutionInput, diagnostic: (String) -> Unit) {
val config = PurchaseRehearsalExecutor.DEFAULT_COLLECTOR
val byPath = snapshot.nodes.associateBy(SnapshotNode::path)
fun within(node: SnapshotNode, container: SnapshotNode): Boolean {
val visited = mutableSetOf<String>()
var current: SnapshotNode? = node
while (current != null && visited.add(current.path)) {
if (current.path == container.path) return true
current = current.parentPath?.let(byPath::get)
}
return false
}
fun contained(node: SnapshotNode, container: SnapshotNode) =
node.bounds.width > 0 && node.bounds.height > 0 &&
node.bounds.left >= container.bounds.left && node.bounds.right <= container.bounds.right &&
node.bounds.top >= container.bounds.top && node.bounds.bottom <= container.bounds.bottom
val original = PddScreenParser.parse(snapshot, config, input.goodsId, null, forFinalConfirmation = true)
val fullPanel = boundedQuantityPanel(snapshot).takeIf { original.specPanelOpen }
// #331/#335 sheet ancestry, as represented by SpecPanelFixtures.sheet and taskOptionDedupSheet.
// The inner scroll list is not the full sheet: summary and quantity may be its siblings.
val scoped = fullPanel?.let { panel ->
val nodes = snapshot.nodes.filter { node ->
(within(node, panel) && contained(node, panel)) ||
(node.parentPath == null && node.label.isBlank() && !node.clickable)
}
PddScreenParser.parse(snapshot.copy(nodes = nodes), config, input.goodsId, null, forFinalConfirmation = true)
.takeIf { it.specPanelOpen }
}
val optionScope = if (scoped != null) fullPanel else original.specPanelContainer
val dimensions = (scoped ?: original).dimensions.groupBy(VisibleDimension::key).mapValues { (_, groups) ->
val options = groups.flatMap(VisibleDimension::values).filter { option ->
optionScope != null && within(option.node, optionScope) && contained(option.node, optionScope)
}
// A blank clickable wrapper and its single text child may survive optionBlock separately.
// The #331 size fixture exposes exactly the same rectangle for both. Aggregate only a
// same-value ancestor chain with that physical rectangle, never same-label siblings.
options.groupBy { option ->
options.filter { other -> other.node.bounds == option.node.bounds &&
normalize(other.text) == normalize(option.text) && within(option.node, other.node)
}.minBy { it.node.path.length }.node.path
}.values.map { members ->
members.first().let { first -> first.copy(
available = members.all(VisibleSpecValue::available),
node = first.node.copy(
selected = members.any { it.node.selected },
checked = members.any { it.node.checked },
),
) }
}
}
val summaries = scoped?.finalSelectionSummaries.orEmpty().map { raw ->
// Only the anchored recognized prefix and its optional colon are removed.
val trimmed = raw.trim()
val prefix = config.textAliases.selection.selectedPrefixes.firstOrNull(trimmed::startsWith)
val value = if (prefix == null) trimmed else trimmed.removePrefix(prefix).trimStart().let {
if (it.startsWith(':') || it.startsWith(':')) it.drop(1) else it
}
normalize(value)
}.distinct()
val targets = linkedMapOf("color" to normalize(input.mappedColor), "size" to normalize(input.mappedSize))
.filterValues(String::isNotBlank)
fun reject(dimension: String, stage: String, selected: Boolean = false, conflict: Boolean = false): Nothing {
val detail = "finalSpec;dimension=$dimension;stage=$stage;summaryPresent=${if (summaries.isNotEmpty()) 1 else 0};" +
"selectedFound=${if (selected) 1 else 0};conflict=${if (conflict) 1 else 0}"
diagnostic(detail)
throw PurchaseLiveException("PURCHASE_SPEC_NOT_MATCHED", "创建订单前规格复核失败;$detail")
}
if (targets.isEmpty()) reject("none", "missing_target")
val selectedProof = mutableSetOf<String>()
targets.forEach { (dimension, target) ->
val options = dimensions[dimension].orEmpty()
val selected = options.filter { it.node.selected || it.node.checked }
// Parsed text already follows the established UI trailing-price separation contract.
// The final-only normalizer removes no additional content from values, input or summary.
val exact = options.filter { normalize(it.text) == target }
if (selected.any { normalize(it.text) != target }) reject(dimension, "selected_conflict", selected = true, conflict = true)
if (exact.size > 1) reject(dimension, "candidate_collision", selected.isNotEmpty(), conflict = true)
if (exact.any { !it.available }) reject(dimension, "unavailable", selected.isNotEmpty(), conflict = true)
if (exact.any { it.node.selected || it.node.checked }) selectedProof += dimension
}
if (summaries.size > 1) reject(targets.keys.first(), "summary_conflict", selectedProof.isNotEmpty(), conflict = true)
val summary = summaries.singleOrNull()
// Evidence only supports color then size with a space. Do not guess reverse order or comma separators.
val expected = targets.values.joinToString(" ")
if (summary != null && targets.size == 2) {
val colors = (dimensions["color"].orEmpty().map { normalize(it.text) } + targets.getValue("color")).distinct()
val sizes = (dimensions["size"].orEmpty().map { normalize(it.text) } + targets.getValue("size")).distinct()
if (colors.any { color -> sizes.any { size ->
"$color $size" == summary && (color != targets["color"] || size != targets["size"])
} }) reject("color", "composition_ambiguity", selectedProof.isNotEmpty(), conflict = true)
}
targets.keys.forEach { dimension ->
if (dimension !in selectedProof && summary != expected) reject(dimension, "missing_proof")
}
}
/** Existing #335 quantity-ancestor form, retaining identity as well as bounds for summary ownership. */
private fun boundedQuantityPanel(snapshot: UiSnapshot): SnapshotNode? {
val visible = snapshot.nodes.filter(SnapshotNode::visible)
val screenWidth = visible.maxOfOrNull { it.bounds.right } ?: return null
val screenHeight = visible.maxOfOrNull { it.bounds.bottom } ?: return null
val screenArea = screenWidth.toLong() * screenHeight
val byPath = visible.associateBy(SnapshotNode::path)
var current = visible.singleOrNull {
it.enabled && it.className == "android.widget.EditText" && it.label.toIntOrNull()?.let { value -> value > 0 } == true
} ?: return null
var panel: SnapshotNode? = null
val visited = mutableSetOf(current.path)
while (true) {
val parent = current.parentPath?.let(byPath::get) ?: break
if (!visited.add(parent.path)) return null
val area = parent.bounds.width.toLong() * parent.bounds.height
if (area <= 0 || area >= screenArea || parent.parentPath == null) break
panel = parent
current = parent
}
return panel
}
}
@@ -321,10 +321,7 @@ class PurchaseLiveAutomation(
val submit = finalSubmitTargets(snapshot)
if (submit.size != 1) fail("PURCHASE_SUBMIT_TARGET_AMBIGUOUS", "创建订单按钮不是唯一目标,禁止创建订单")
val screen = PddScreenParser.parse(snapshot, PurchaseRehearsalExecutor.DEFAULT_COLLECTOR, input.goodsId, null)
val selected = listOf(input.mappedColor, input.mappedSize).filter(String::isNotBlank)
if (selected.any { value -> screen.selectedSummary?.contains(value) != true && snapshot.nodes.none { it.visible && it.label.contains(value) } }) {
fail("PURCHASE_SPEC_NOT_MATCHED", "创建订单前规格复核失败")
}
PurchaseFinalSpecVerifier.verify(snapshot, input, panelDiagnostic)
val price = screen.priceCent ?: fail("RULE_NOT_MATCHED", "创建订单前没有读取到商品单价")
if (price !in input.minUnitPriceCent..input.maxUnitPriceCent) fail("PURCHASE_PRICE_OUT_OF_RANGE", "当前商品单价超出允许范围", price)
val quantities = snapshot.nodes.filter { it.visible && it.enabled && it.className?.endsWith("EditText") == true }.mapNotNull { it.label.toLongOrNull() }
@@ -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
}
}
@@ -16,6 +16,9 @@ interface PurchaseUiDriver {
result = clickFresh(target),
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
* its immutable snapshot traits before clicking its nearest clickable
@@ -85,6 +88,7 @@ data class PurchaseExecutionInput(
val minUnitPriceCent: Long,
val maxUnitPriceCent: Long,
val addressSuffix: String = "",
val reuseProbeProduct: Boolean = false,
)
data class PurchaseExecutionOutcome(
@@ -109,6 +113,9 @@ class PurchaseRehearsalExecutor(
private val panelDiagnostic: (String) -> Unit = {},
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
@@ -121,12 +128,9 @@ class PurchaseRehearsalExecutor(
val specSelectionProofs = mutableMapOf<String, ExactSpecSelectionProof>()
val live = PurchaseLiveAutomation(driver, pause, panelDiagnostic)
for (action in rule.actions) {
// The immediate phase-two handoff can reuse the PDD page retained by
// spec_probe. A later manual retry may start from Agent (or another
// unrelated screen), so only skip navigation when a fresh snapshot
// still carries safe PDD product/spec evidence.
// Only the service's one-shot probe handoff permits reuse; fresh structure alone is insufficient.
if (
input.phase == "purchase" &&
input.phase == "purchase" && input.reuseProbeProduct &&
action.type == PurchaseActionType.OPEN_PRODUCT &&
canReuseCurrentProduct(input)
) continue
@@ -205,7 +209,7 @@ class PurchaseRehearsalExecutor(
private fun canReuseCurrentProduct(input: PurchaseExecutionInput): Boolean =
currentScreen(input).let { screen ->
screen.problem == null && screen.hasPurchaseProductEvidence()
screen.problem == null && screen.hasPurchaseProductEvidence() && confirmProbeHandoff()
}
private fun validateBeforeDeviceAction(
@@ -260,57 +264,38 @@ class PurchaseRehearsalExecutor(
private fun openProduct(input: PurchaseExecutionInput, action: PurchaseAction): PurchaseExecutionOutcome? {
purchasePanelContext = null
val startedAt = monotonicNow()
if (!openLink(input.url)) return failure("PDD_LINK_INVALID", "任务中的 PDD 链接无法打开")
val readiness = PurchaseNavigationReadiness(startedAt, acceptedLaunchPath(), monotonicNow())
val aliases = action.textAliases ?: listOf("打开拼多多APP", "打开拼多多 App", "打开")
var clickAttempted = false
var nextClickPoll = 0
var lastClickReason = FreshClickReason.UNKNOWN
var stableEvidenceReads = 0
var pddForegroundObserved = false
repeat(OPEN_PRODUCT_POLL_LIMIT) { poll ->
repeat(300) {
if (readiness.expired(monotonicNow())) return navigationTimeout()
val snapshot = driver.capture()
if (readiness.expired(monotonicNow())) return navigationTimeout()
pageProblem(snapshot)?.let { return it }
if (snapshot.packageName == PDD_PACKAGE) {
pddForegroundObserved = true
val screen = PddScreenParser.parse(snapshot, DEFAULT_COLLECTOR, input.goodsId, null)
stableEvidenceReads = if (screen.hasPurchaseProductEvidence()) stableEvidenceReads + 1 else 0
if (stableEvidenceReads >= PRODUCT_PAGE_STABLE_READS) return null
pause(OPEN_PRODUCT_POLL_MILLIS)
return@repeat
}
stableEvidenceReads = 0
val candidates = snapshot.nodes.filter { it.visible && it.enabled && it.label in aliases }
if (candidates.size > 1) return failure("RULE_AMBIGUOUS", "打开拼多多按钮不唯一")
if (candidates.size == 1 && poll >= nextClickPoll) {
clickAttempted = true
val outcome = driver.clickFreshDetailed(candidates.single())
lastClickReason = outcome.reason
if (outcome.result == FreshActionResult.AMBIGUOUS) {
return failure("RULE_AMBIGUOUS", "打开拼多多按钮不唯一")
val screen = PddScreenParser.parse(snapshot, DEFAULT_COLLECTOR, input.goodsId, null)
val key = if (screen.problem == null && screen.hasPurchaseProductEvidence()) ProductStabilityKey(
screen.summary.title, screen.specEntry != null, screen.quickConfirmationEntry != null, screen.specPanelOpen,
) else null
if (readiness.observe(monotonicNow(), snapshot.packageName, key)) return null
if (readiness.mayClickBrowser(monotonicNow(), snapshot.packageName)) {
val candidates = snapshot.nodes.filter { it.visible && it.enabled && it.label in aliases }
if (candidates.size > 1) return failure("RULE_AMBIGUOUS", "打开拼多多按钮不唯一")
if (candidates.size == 1 && !readiness.expired(monotonicNow())) {
val outcome = driver.clickNavigationFresh(candidates.single()) { !readiness.expired(monotonicNow()) }
readiness.clicked(monotonicNow())
if (readiness.expired(monotonicNow())) return navigationTimeout()
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 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", "没有进入拼多多商品页面")
return navigationTimeout()
}
private fun navigationTimeout() = failure("PDD_DETAIL_ENTRY_FAILED", "打开拼多多后未识别到稳定商品页面")
private fun verifyProduct(input: PurchaseExecutionInput): PurchaseExecutionOutcome? {
var stableEvidenceReads = 0
repeat(PRODUCT_PAGE_POLL_LIMIT) {
@@ -1267,10 +1252,8 @@ class PurchaseRehearsalExecutor(
companion object {
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_STABLE_READS = 2
private const val OPEN_PRODUCT_RETRY_POLLS = 10
private const val OPEN_PRODUCT_POLL_MILLIS = 100L
private const val SPEC_ENTRY_READY_WAIT_POLLS = 20
private const val SPEC_ENTRY_READY_POLL_MILLIS = 100L
@@ -13,6 +13,20 @@ object SpecValueNormalizer {
fun normalizeSize(value: String): String = normalizeTrailingPrice(value)
/** Final purchase confirmation only: display equivalents, never semantic/content removal. */
fun normalizeFinalConfirmation(value: String): String = buildString {
value.forEach { ch ->
append(when (ch) {
'【', '〔', '[' -> '['
'】', '〕', ']' -> ']'
'(' -> '('
')' -> ')'
in 'A'..'Z', in 'a'..'z', in '0'..'9' -> (ch.code - 0xFEE0).toChar()
else -> if (ch.isWhitespace()) ' ' else ch
})
}
}.replace(Regex(" +"), " ").trim()
/** 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()
@@ -35,6 +35,7 @@ data class HeartbeatResult(
val online: Boolean,
val busy: Boolean,
val heartbeatIntervalSeconds: Int,
val acceptsClientReport: Boolean = false,
)
data class AgentAppRelease(
@@ -103,6 +104,16 @@ data class PurchaseAgentTask(
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(
val taskId: Long,
val attemptNumber: Int,
@@ -211,7 +222,10 @@ class AgentApiException(
val retryable: Boolean,
) : Exception(message)
class AgentApiClient(private val serverUrl: String) {
class AgentApiClient(
internal val serverUrl: String,
private val connectionFactory: (URL) -> HttpURLConnection = { it.openConnection() as HttpURLConnection },
) {
@Volatile var lastResponseServerTimeMillis: Long? = null
private set
val failureSnapshotOrigin: String get() = ServerUrlPolicy.normalize(serverUrl)
@@ -299,17 +313,19 @@ class AgentApiClient(private val serverUrl: String) {
)
}
fun heartbeat(token: String, currentTaskId: Long?, capabilities: List<String> = emptyList()): HeartbeatResult {
fun heartbeat(token: String, currentTaskId: Long?, capabilities: List<String> = emptyList(), clientReport: JSONObject? = null, requestId: String = UUID.randomUUID().toString()): HeartbeatResult {
val payload = JSONObject()
.put("requestId", UUID.randomUUID().toString())
.put("requestId", requestId)
.put("currentTaskId", currentTaskId ?: JSONObject.NULL)
.put("capabilities", JSONArray(capabilities))
if (clientReport != null) payload.put("clientReport", clientReport)
val data = post("/api/agent/v1/heartbeat", payload, token).getJSONObject("data")
return HeartbeatResult(
deviceId = data.getLong("deviceId"),
online = data.getBoolean("online"),
busy = data.getBoolean("busy"),
heartbeatIntervalSeconds = data.optInt("heartbeatIntervalSeconds", 15),
acceptsClientReport = data.opt("acceptsClientReport") == true,
)
}
@@ -527,9 +543,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"))
}
fun submitPurchaseResult(taskId: Long, payloadJson: String, token: String) {
fun submitPurchaseResult(taskId: Long, payloadJson: String, token: String): PurchaseResultAcknowledgement {
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> {
@@ -696,7 +713,7 @@ class AgentApiClient(private val serverUrl: String) {
}
private fun request(method: String, path: String, payload: JSONObject?, token: String?, recoveryCode: String? = null): JSONObject? {
val connection = (URL(serverUrl + path).openConnection() as HttpURLConnection).apply {
val connection = connectionFactory(URL(serverUrl + path)).apply {
requestMethod = method
connectTimeout = 10_000
readTimeout = 15_000
@@ -708,10 +725,14 @@ class AgentApiClient(private val serverUrl: String) {
if (!token.isNullOrBlank()) setRequestProperty("Authorization", "Bearer $token")
if (!recoveryCode.isNullOrBlank()) setRequestProperty("X-GoAuto-Device-Recovery-Code", recoveryCode)
}
var stage = RequestStage.CONNECT
try {
connection.connect()
stage = RequestStage.UNKNOWN
if (payload != null) {
connection.outputStream.use { it.write(payload.toString().toByteArray(Charsets.UTF_8)) }
}
stage = RequestStage.RESPONSE
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.
@@ -729,6 +750,8 @@ class AgentApiClient(private val serverUrl: String) {
)
}
return json
} catch (error: java.net.SocketTimeoutException) {
throw AgentTransportTimeout(stage, error)
} finally {
connection.disconnect()
}
@@ -0,0 +1,65 @@
package cn.ilapage.goauto.agent.network
import java.util.UUID
import org.json.JSONObject
/** Service lifetime, never persisted: a new process must negotiate again. */
internal class HeartbeatReportNegotiation {
var acceptsReport = false
private set
private var origin: String? = null
private var token: String? = null
fun heartbeat(
api: AgentApiClient,
token: String,
taskId: Long?,
capabilities: List<String>,
report: () -> JSONObject?,
ack: () -> Unit,
validate: (HeartbeatResult) -> Unit = {},
): HeartbeatResult {
if (origin != api.serverUrl || this.token != token) {
acceptsReport = false
origin = api.serverUrl
this.token = token
}
val snapshot = if (acceptsReport) report() else null
val requestId = UUID.randomUUID().toString()
val result =
try {
api.heartbeat(token, taskId, capabilities, snapshot, requestId)
} catch (error: AgentApiException) {
if (
snapshot == null ||
error.status != 422 ||
error.code != "INVALID_REQUEST" ||
error.message != "请求 JSON 无效"
)
throw error
acceptsReport = false
// A fallback is never an acknowledgement or a capability grant.
return api.heartbeat(token, taskId, capabilities, null, requestId).also(validate)
}
validate(result)
acceptsReport = result.acceptsClientReport
if (snapshot != null) ack()
return result
}
}
internal enum class RequestStage {
CONNECT,
RESPONSE,
UNKNOWN,
}
internal class AgentTransportTimeout(
val stage: RequestStage,
cause: java.net.SocketTimeoutException,
) : java.net.SocketTimeoutException("agent_transport_timeout") {
init {
initCause(cause)
}
}
@@ -1,7 +1,22 @@
package cn.ilapage.goauto.agent.persistence
internal object AgentDiagnosticSchema {
const val VERSION = 4
const val VERSION = 6
val syncStatements = listOf(
"""CREATE TABLE IF NOT EXISTS sync_round (
id INTEGER PRIMARY KEY AUTOINCREMENT, process_id TEXT NOT NULL,
started_at INTEGER NOT NULL, ended_at INTEGER, duration_ms INTEGER, start_gap_ms INTEGER,
device_id INTEGER, task_id INTEGER, attempt_id TEXT, phase TEXT,
validated_network INTEGER, transport TEXT NOT NULL, foreground INTEGER NOT NULL,
status TEXT NOT NULL, steps TEXT NOT NULL DEFAULT '[]',
failure_at INTEGER, failure_step TEXT, failure_category TEXT, failure_duration_ms INTEGER,
http_status INTEGER, api_code TEXT, exception_class TEXT,
completion_seq INTEGER, reported INTEGER NOT NULL DEFAULT 0
)""".trimIndent(),
"CREATE TABLE IF NOT EXISTS sync_meta (id INTEGER PRIMARY KEY CHECK(id=1), sequence INTEGER NOT NULL)",
"INSERT OR IGNORE INTO sync_meta(id,sequence) VALUES(1,0)",
)
val failureSnapshotStatements = listOf(
"""CREATE TABLE IF NOT EXISTS purchase_failure_snapshot (
@@ -38,6 +53,12 @@ internal object AgentDiagnosticSchema {
"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 =
"""CREATE TABLE agent_diagnostic (
id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -66,6 +87,9 @@ internal object AgentDiagnosticSchema {
initial_selected_size_count INTEGER,
selected_summary_present 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,
@@ -87,5 +111,9 @@ internal object AgentDiagnosticSchema {
(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 < 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()) +
(if (oldVersion < 6 && newVersion >= 6) syncStatements else emptyList())
}
@@ -7,6 +7,7 @@ import android.database.sqlite.SQLiteOpenHelper
import cn.ilapage.goauto.agent.BuildConfig
enum class AgentDiagnosticStage {
PRODUCT_NAVIGATION,
SPEC_ENTRY_CLICK,
SPEC_ENTRY_GESTURE,
QUICK_CONFIRMATION_CLICK,
@@ -24,6 +25,12 @@ enum class AgentDiagnosticStage {
}
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,
SPEC_PANEL_OPENED,
@@ -114,6 +121,12 @@ data class AgentDiagnosticEvent(
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(
@@ -137,6 +150,7 @@ class AgentDiagnosticStore(context: Context) : SQLiteOpenHelper(context, DATABAS
db.execSQL(AgentDiagnosticSchema.createTableSql)
db.execSQL("CREATE INDEX idx_agent_diagnostic_task ON agent_diagnostic(task_id, id)")
AgentDiagnosticSchema.failureSnapshotStatements.forEach(db::execSQL)
AgentDiagnosticSchema.syncStatements.forEach(db::execSQL)
}
override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) {
@@ -197,6 +211,9 @@ class AgentDiagnosticStore(context: Context) : SQLiteOpenHelper(context, DATABAS
putNullableInt("initial_selected_size_count", event.initialSelectedSizeCount)
putNullableBoolean("selected_summary_present", event.selectedSummaryPresent)
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("created_at", event.createdAt)
}
@@ -1,15 +1,32 @@
package cn.ilapage.goauto.agent.persistence
import cn.ilapage.goauto.agent.network.AgentApiException
object PurchaseRejectionPolicy {
val codes = setOf("PURCHASE_STATE_CONFLICT", "PURCHASE_LEASE_EXPIRED", "PURCHASE_RESULT_CONFLICT")
fun terminalCode(error: Exception): String? = (error as? AgentApiException)
?.takeIf { it.status == 409 && it.code in codes }?.code
}
/** Uploads already-persisted results only; it never invokes device automation. */
class PurchaseOutboxUploader(
private val pending: () -> List<PendingPurchaseOutbox>,
private val submit: (PendingPurchaseOutbox) -> Unit,
private val markUploaded: (PendingPurchaseOutbox) -> Unit,
private val afterUploaded: (PendingPurchaseOutbox) -> Unit = {},
private val markRejected: (PendingPurchaseOutbox, String) -> Unit = { _, _ -> error("Rejection persistence not configured") },
) {
fun flush() {
pending().forEach { item ->
submit(item)
try {
submit(item)
} catch (error: Exception) {
val code = PurchaseRejectionPolicy.terminalCode(error) ?: throw error
markRejected(item, code)
return@forEach
}
markUploaded(item)
afterUploaded(item)
}
}
}
@@ -0,0 +1,27 @@
package cn.ilapage.goauto.agent.persistence
data class RejectedPurchaseResult(
val id: Long, val taskId: Long, val attemptId: String, val payloadJson: String,
val rejectedAt: Long, val errorCode: String, val acknowledgedAt: Long?,
)
internal object PurchaseRejectionSql {
val migration = listOf(
"ALTER TABLE purchase_outbox ADD COLUMN rejected_at INTEGER",
"ALTER TABLE purchase_outbox ADD COLUMN rejection_error_code TEXT",
"ALTER TABLE purchase_outbox ADD COLUMN acknowledged_at INTEGER",
)
const val activeTask = "SELECT task_id FROM purchase_task WHERE upload_status != 'rejected' AND (status IN ('running','order_submit_started') OR upload_status='pending') ORDER BY updated_at ASC LIMIT 1"
const val acknowledge = "UPDATE purchase_outbox SET acknowledged_at=? WHERE id=? AND upload_status='rejected' AND acknowledged_at IS NULL"
/** The caller must wrap both writes in one transaction. Zero task rows means a newer attempt replaced it. */
fun reject(item: PendingPurchaseOutbox, code: String, now: Long, update: (String, List<Any>) -> Int) {
require(code in PurchaseRejectionPolicy.codes)
check(update(
"UPDATE purchase_outbox SET upload_status='rejected',rejected_at=?,rejection_error_code=?,updated_at=? WHERE id=? AND task_id=? AND attempt_id=? AND upload_status='pending'",
listOf(now, code, now, item.id, item.taskId, item.attemptId),
) == 1)
update("UPDATE purchase_task SET upload_status='rejected',updated_at=? WHERE task_id=? AND attempt_id=?",
listOf(now, item.taskId, item.attemptId))
}
}
@@ -66,6 +66,7 @@ class PurchaseTaskStore(context: Context) : SQLiteOpenHelper(context, DATABASE_N
)
db.execSQL("CREATE INDEX idx_purchase_outbox_pending ON purchase_outbox(upload_status, id)")
db.execSQL("CREATE INDEX idx_purchase_attempt_task ON purchase_attempt(task_id, created_at)")
PurchaseRejectionSql.migration.forEach(db::execSQL)
}
override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) {
@@ -74,6 +75,7 @@ class PurchaseTaskStore(context: Context) : SQLiteOpenHelper(context, DATABASE_N
db.execSQL("ALTER TABLE purchase_task ADD COLUMN final_confirmation_json TEXT")
db.execSQL("ALTER TABLE purchase_task ADD COLUMN irreversible_at INTEGER")
}
if (oldVersion < 3 && newVersion >= 3) PurchaseRejectionSql.migration.forEach(db::execSQL)
}
@Synchronized
@@ -225,8 +227,8 @@ class PurchaseTaskStore(context: Context) : SQLiteOpenHelper(context, DATABASE_N
@Synchronized
fun activeTaskId(): Long? = readableDatabase.rawQuery(
"SELECT task_id FROM purchase_task WHERE status IN (?,?) OR upload_status=? ORDER BY updated_at ASC LIMIT 1",
arrayOf(STATUS_RUNNING, STATUS_ORDER_SUBMIT_STARTED, UPLOAD_PENDING),
PurchaseRejectionSql.activeTask,
null,
).use { cursor -> if (cursor.moveToFirst()) cursor.getLong(0) else null }
@Synchronized
@@ -243,9 +245,53 @@ class PurchaseTaskStore(context: Context) : SQLiteOpenHelper(context, DATABASE_N
}
}
@Synchronized
fun markRejected(item: PendingPurchaseOutbox, code: String) {
val db = writableDatabase
db.beginTransaction()
try {
PurchaseRejectionSql.reject(item, code, System.currentTimeMillis()) { sql, args ->
db.compileStatement(sql).use { statement ->
args.forEachIndexed { index, value ->
if (value is Long) statement.bindLong(index + 1, value)
else statement.bindString(index + 1, value.toString())
}
statement.executeUpdateDelete()
}
}
db.setTransactionSuccessful()
} finally { db.endTransaction() }
}
@Synchronized
fun rejectedResults(): List<RejectedPurchaseResult> = readableDatabase.rawQuery(
"SELECT id,task_id,attempt_id,payload_json,rejected_at,rejection_error_code,acknowledged_at FROM purchase_outbox WHERE upload_status='rejected' ORDER BY id DESC", null,
).use { cursor -> buildList {
while (cursor.moveToNext()) add(RejectedPurchaseResult(
cursor.getLong(0), cursor.getLong(1), cursor.getString(2), cursor.getString(3),
cursor.getLong(4), cursor.getString(5), if (cursor.isNull(6)) null else cursor.getLong(6),
))
} }
@Synchronized
fun rejectedCount(): Int = readableDatabase.rawQuery(
"SELECT COUNT(*) FROM purchase_outbox WHERE upload_status='rejected'", null,
).use { it.moveToFirst(); it.getInt(0) }
@Synchronized
fun isCurrentAttemptRejected(taskId: Long, attemptId: String): Boolean = readableDatabase.rawQuery(
"SELECT 1 FROM purchase_task WHERE task_id=? AND attempt_id=? AND upload_status='rejected'",
arrayOf(taskId.toString(), attemptId),
).use { it.moveToFirst() }
@Synchronized
fun acknowledgeRejection(outboxId: Long) {
writableDatabase.execSQL(PurchaseRejectionSql.acknowledge, arrayOf(System.currentTimeMillis(), outboxId))
}
companion object {
private const val DATABASE_NAME = "goauto_purchase.db"
private const val DATABASE_VERSION = 2
private const val DATABASE_VERSION = 3
private const val STATUS_RUNNING = "running"
const val STATUS_ORDER_SUBMIT_STARTED = "order_submit_started"
private const val STATUS_COMPLETED = "completed"
@@ -0,0 +1,234 @@
package cn.ilapage.goauto.agent.persistence
import cn.ilapage.goauto.agent.network.AgentApiException
import cn.ilapage.goauto.agent.network.AgentTransportTimeout
import cn.ilapage.goauto.agent.network.RequestStage
import java.util.concurrent.CancellationException
import org.json.JSONArray
import org.json.JSONObject
/** Collection and purchase IDs belong to separate domains and can have the same numeric value. */
internal fun syncTaskIdentity(
taskId: Long?,
taskType: String?,
executionActive: Boolean,
live: SyncContext?,
recoveredAttempt: () -> String?,
): SyncContext {
if (taskType == "purchase" && live?.taskId == taskId && live != null) return live
return SyncContext(
taskId = taskId,
attemptId = if (!executionActive && taskId != null) recoveredAttempt() else null,
)
}
/** Only fixed classifications enter this store; exception text, URLs and payloads never do. */
internal fun syncFailure(error: Exception, step: String, at: Long, duration: Long): SyncFailure {
val api = error as? AgentApiException
val timeout = error as? AgentTransportTimeout
val category =
when {
api != null ->
when {
cn.ilapage.goauto.agent.service.isAgentAuthenticationError(api) -> "api_auth"
api.status == 409 -> "api_conflict"
api.status >= 500 -> "api_server"
else -> "api_other"
}
timeout != null ->
when (timeout.stage) {
RequestStage.CONNECT -> "timeout_connect"
RequestStage.RESPONSE -> "timeout_response"
RequestStage.UNKNOWN -> "timeout_unknown"
}
error is java.net.SocketTimeoutException -> "timeout_unknown"
error is java.io.IOException -> "network"
else -> "exception"
}
val code =
api?.code?.takeIf {
it in
setOf(
"DEVICE_TOKEN_INVALID",
"DEVICE_DISABLED",
"DEVICE_INSTALL_ID_CONFLICT",
"DEVICE_TASK_MISMATCH",
"INVALID_REQUEST",
"PURCHASE_STATE_CONFLICT",
"PURCHASE_LEASE_EXPIRED",
"PURCHASE_RESULT_CONFLICT",
)
}
val type =
when (error) {
is java.net.SocketTimeoutException -> "SocketTimeoutException"
is java.net.UnknownHostException -> "UnknownHostException"
is java.net.ConnectException -> "ConnectException"
is javax.net.ssl.SSLException -> "SSLException"
is java.net.SocketException -> "SocketException"
is java.io.EOFException -> "EOFException"
else -> null
}
return SyncFailure(
step,
category,
at.coerceAtLeast(1),
duration.coerceAtLeast(0),
api?.status,
code,
type,
)
}
internal class SyncDiagnosticRecorder(
private val store: SyncDiagnosticStore,
private val process: String,
private val wall: () -> Long,
private val elapsed: () -> Long,
private val warning: () -> Unit = {},
) {
private var previousStart: Long? = null
fun begin(context: SyncContext): Round {
val start = elapsed()
val at = wall()
val gap = previousStart?.let { (start - it).coerceAtLeast(0) }
previousStart = start
val id = safe { store.start(process, at, gap, context) }
val round = Round(id, at, start)
round.persistSteps()
safe { store.cleanup(at) }
return round
}
fun snapshot(): SyncReportSnapshot? = safe { store.snapshot(process, wall()) }
fun ack(snapshot: SyncReportSnapshot) {
safe { store.ack(snapshot) }
}
private fun <T> safe(action: () -> T): T? =
try {
action()
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
try {
warning()
} catch (cancel: CancellationException) {
throw cancel
} catch (ignored: Exception) {}
null
}
inner class Round(private val id: Long?, at: Long, private val start: Long) {
private val entries =
JSONArray()
.put(
JSONObject()
.put("ordinal", 0)
.put("step", "sync")
.put("startedAt", at)
.put("startedOffsetMs", 0)
.put("status", "running")
)
private var failure: SyncFailure? = null
private var persistenceFailed = id == null
private var aborted = false
private var completed = false
private var activeStep: Pair<JSONObject, Long>? = null
fun <T> step(name: String, action: () -> T): T {
require(name in setOf("register", "heartbeat", "recover", "flush", "claim"))
val begin = elapsed()
val entry =
JSONObject()
.put("ordinal", entries.length())
.put("step", name)
.put("startedAt", wall())
.put("startedOffsetMs", (begin - start).coerceAtLeast(0))
.put("status", "running")
entries.put(entry)
activeStep = entry to begin
persistSteps()
try {
val value = action()
if (entry.getString("status") != "failure") entry.put("status", "success")
return value
} catch (e: CancellationException) {
aborted = true
throw e
} catch (e: Exception) {
failure = syncFailure(e, name, wall(), elapsed() - begin)
entry.put("status", "failure").put("category", failure!!.category)
throw e
} catch (e: Error) {
aborted = true
throw e
} finally {
if (!aborted) {
entry
.put("endedAt", wall())
.put("endedOffsetMs", (elapsed() - start).coerceAtLeast(0))
.put("durationMs", (elapsed() - begin).coerceAtLeast(0))
persistSteps()
}
activeStep = null
}
}
/**
* Observe a business-handled exception without changing that operation's return/throw
* policy.
*/
fun handledFailure(error: Exception) {
if (error is CancellationException) {
aborted = true
throw error
}
val (entry, begin) = activeStep ?: return failed(error)
failure = syncFailure(error, entry.getString("step"), wall(), elapsed() - begin)
entry.put("status", "failure").put("category", failure!!.category)
persistSteps()
}
fun failed(error: Exception) {
if (error is CancellationException) {
aborted = true
throw error
}
if (failure == null) failure = syncFailure(error, "sync", wall(), elapsed() - start)
}
fun abort() {
aborted = true
}
fun finish() {
if (completed || aborted) return
completed = true
entries
.getJSONObject(0)
.put("endedAt", wall())
.put("endedOffsetMs", (elapsed() - start).coerceAtLeast(0))
.put("durationMs", (elapsed() - start).coerceAtLeast(0))
.put("status", if (failure == null) "success" else "failure")
persistSteps()
// A failed start/step write must never turn an incomplete durable trace into a fake
// success.
if (!persistenceFailed && id != null)
safe { store.finish(id, wall(), (elapsed() - start).coerceAtLeast(0), failure) }
}
internal fun persistSteps() {
if (
id != null &&
safe {
store.steps(id, entries.toString())
true
} != true
)
persistenceFailed = true
}
}
}
@@ -0,0 +1,218 @@
package cn.ilapage.goauto.agent.persistence
import android.database.sqlite.SQLiteDatabase
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import java.util.TimeZone
import org.json.JSONObject
/** Shared SQL is exercised against SQLite JDBC; Android only supplies the driver. */
internal interface SyncSql {
fun execute(sql: String, args: List<Any?> = emptyList())
fun query(sql: String, args: List<Any?> = emptyList()): List<Map<String, String?>>
fun <T> transaction(block: () -> T): T
}
internal class AndroidSyncSql(private val open: () -> SQLiteDatabase) : SyncSql {
override fun execute(sql: String, args: List<Any?>) = open().execSQL(sql, args.toTypedArray())
override fun query(sql: String, args: List<Any?>): List<Map<String, String?>> =
open().rawQuery(sql, args.map { it?.toString() }.toTypedArray()).use { c ->
buildList {
while (c.moveToNext()) add(
c.columnNames.associateWith { name ->
val i = c.getColumnIndexOrThrow(name)
if (c.isNull(i)) null else c.getString(i)
}
)
}
}
override fun <T> transaction(block: () -> T): T {
val db = open()
db.beginTransaction()
try {
val result = block()
db.setTransactionSuccessful()
return result
} finally {
db.endTransaction()
}
}
}
internal data class SyncContext(
val deviceId: Long? = null,
val taskId: Long? = null,
val attemptId: String? = null,
val phase: String? = null,
val validatedNetwork: Boolean? = null,
val transport: String = "unknown",
val foreground: Boolean = true,
)
internal data class SyncFailure(
val step: String,
val category: String,
val at: Long,
val durationMs: Long,
val httpStatus: Int? = null,
val apiCode: String? = null,
val exceptionClass: String? = null,
)
internal data class SyncReportSnapshot(val report: JSONObject, val boundary: Long)
internal class SyncDiagnosticStore(private val db: SyncSql) {
@Synchronized
fun start(process: String, at: Long, gap: Long?, context: SyncContext): Long =
db.transaction {
db.execute(
"INSERT INTO sync_round(process_id,started_at,start_gap_ms,device_id,task_id,attempt_id,phase,validated_network,transport,foreground,status) VALUES(?,?,?,?,?,?,?,?,?,?,'running')",
listOf(
process,
at,
gap,
context.deviceId?.takeIf { it > 0 },
context.taskId?.takeIf { it > 0 },
context.attemptId?.takeIf { ATTEMPT_UUID.matches(it) },
context.phase?.takeIf { it in setOf("spec_probe", "purchase") },
context.validatedNetwork?.let { if (it) 1 else 0 },
context.transport.takeIf { it in setOf("wifi", "cellular", "none", "unknown") }
?: "unknown",
if (context.foreground) 1 else 0,
),
)
db.query("SELECT last_insert_rowid() AS id").single().getValue("id")!!.toLong()
}
@Synchronized
fun steps(id: Long, json: String) {
db.execute(
"UPDATE sync_round SET steps=? WHERE id=? AND status='running'",
listOf(json, id),
)
}
@Synchronized
fun finish(id: Long, at: Long, duration: Long, failure: SyncFailure?) =
db.transaction {
val sequence = nextSequence()
db.execute(
"UPDATE sync_round SET ended_at=?,duration_ms=?,status=?,failure_at=?,failure_step=?,failure_category=?,failure_duration_ms=?,http_status=?,api_code=?,exception_class=?,completion_seq=? WHERE id=? AND status='running'",
listOf(
at,
duration,
if (failure == null) "success" else "failure",
failure?.at,
failure?.step,
failure?.category,
failure?.durationMs,
failure?.httpStatus,
failure?.apiCode,
failure?.exceptionClass,
sequence,
id,
),
)
}
@Synchronized
fun cleanup(now: Long) =
db.transaction {
db.execute("DELETE FROM sync_round WHERE started_at < ?", listOf(now - RETENTION_MS))
db.execute(
"DELETE FROM sync_round WHERE id NOT IN (SELECT id FROM sync_round ORDER BY id DESC LIMIT 6000)"
)
}
@Synchronized
fun snapshot(process: String, now: Long): SyncReportSnapshot? =
db.transaction {
val cutoff = now - RETENTION_MS
val boundary =
db.query("SELECT sequence FROM sync_meta WHERE id=1")
.single()
.getValue("sequence")!!
.toLong()
val failures =
db.query(
"SELECT * FROM sync_round WHERE status='failure' AND reported=0 AND completion_seq<=? AND started_at>=? ORDER BY completion_seq DESC",
listOf(boundary, cutoff),
)
val previous =
db.query(
"SELECT duration_ms FROM sync_round WHERE status!='running' AND started_at>=? ORDER BY completion_seq DESC LIMIT 1",
listOf(cutoff),
)
.firstOrNull()
val interrupted =
db.query(
"SELECT MAX(id) AS id FROM sync_round WHERE status='running' AND process_id!=? AND started_at>=?",
listOf(process, cutoff),
)
.single()["id"]
?.toLong() ?: 0L
val sequence = nextSequence()
val latest = failures.firstOrNull()
val report =
JSONObject()
.put("snapshotSeq", sequence)
.put("failedRounds", failures.size)
.put(
"latestFailureAt",
latest?.get("failure_at")?.toLong()?.let(::isoTime) ?: JSONObject.NULL,
)
.put("latestFailureStep", latest?.get("failure_step") ?: JSONObject.NULL)
.put(
"latestFailureCategory",
latest?.get("failure_category") ?: JSONObject.NULL,
)
.put(
"latestFailureDurationMs",
latest?.get("failure_duration_ms")?.toLong()?.coerceIn(0, RETENTION_MS)
?: JSONObject.NULL,
)
.put(
"previousSyncDurationMs",
previous?.get("duration_ms")?.toLong()?.coerceIn(0, RETENTION_MS)
?: JSONObject.NULL,
)
.put("hasInterruptedRound", interrupted > 0)
check(
sequence in 1..9_007_199_254_740_991L &&
report.toString().toByteArray(Charsets.UTF_8).size <= 2048
)
SyncReportSnapshot(report, boundary)
}
@Synchronized
fun ack(snapshot: SyncReportSnapshot) =
db.transaction {
db.execute(
"UPDATE sync_round SET reported=1 WHERE status!='running' AND completion_seq<=?",
listOf(snapshot.boundary),
)
}
private fun nextSequence(): Long {
db.execute("UPDATE sync_meta SET sequence=sequence+1 WHERE id=1")
return db.query("SELECT sequence FROM sync_meta WHERE id=1")
.single()
.getValue("sequence")!!
.toLong()
}
companion object {
const val RETENTION_MS = 86_400_000L
private val ATTEMPT_UUID = Regex("[0-9a-fA-F]{8}(-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12}")
private fun isoTime(time: Long) =
SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.US)
.apply { timeZone = TimeZone.getTimeZone("UTC") }
.format(Date(time))
}
}
@@ -41,6 +41,11 @@ import cn.ilapage.goauto.agent.automation.CollectionRule
import cn.ilapage.goauto.agent.automation.PurchaseAgentCapabilities
import cn.ilapage.goauto.agent.automation.PurchaseSpecProbePolicy
import cn.ilapage.goauto.agent.automation.PurchaseExecutionInput
import cn.ilapage.goauto.agent.automation.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.PurchaseLiveAutomation
import cn.ilapage.goauto.agent.automation.PurchaseRehearsalExecutor
@@ -52,6 +57,13 @@ import cn.ilapage.goauto.agent.automation.RuleValidationException
import cn.ilapage.goauto.agent.identity.SecureDeviceStore
import cn.ilapage.goauto.agent.network.AgentApiClient
import cn.ilapage.goauto.agent.network.AgentApiException
import cn.ilapage.goauto.agent.network.HeartbeatReportNegotiation
import cn.ilapage.goauto.agent.persistence.AndroidSyncSql
import cn.ilapage.goauto.agent.persistence.SyncContext
import cn.ilapage.goauto.agent.persistence.SyncDiagnosticRecorder
import cn.ilapage.goauto.agent.persistence.SyncDiagnosticStore
import cn.ilapage.goauto.agent.persistence.SyncReportSnapshot
import cn.ilapage.goauto.agent.persistence.syncTaskIdentity
import cn.ilapage.goauto.agent.network.DeviceInfo
import cn.ilapage.goauto.agent.network.PurchaseAgentTask
import cn.ilapage.goauto.agent.network.ServerUrlPolicy
@@ -91,6 +103,7 @@ class AgentForegroundService : Service() {
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 probeHandoff = PurchaseProbeHandoff()
private val backfillGuard = OrderBackfillGuard(taskMutex)
private val runningTaskId = AtomicReference<Long?>(null)
private val working = AtomicBoolean(false)
@@ -109,6 +122,10 @@ class AgentForegroundService : Service() {
private lateinit var stateStore: AgentStateStore
private lateinit var purchaseStore: PurchaseTaskStore
private lateinit var diagnosticStore: AgentDiagnosticStore
private lateinit var syncDiagnostics: SyncDiagnosticRecorder
private val heartbeatReports = HeartbeatReportNegotiation()
private val syncTaskContext = AtomicReference<SyncContext?>(null)
@Volatile private var foregroundStarted = false
private lateinit var diagnosticRecorder: SafeAgentDiagnosticRecorder
private lateinit var failureSnapshotStore: PurchaseFailureSnapshotStore
private lateinit var connectivityManager: ConnectivityManager
@@ -129,6 +146,10 @@ class AgentForegroundService : Service() {
stateStore.setKeepScreenOn(false)
purchaseStore = PurchaseTaskStore(this)
diagnosticStore = AgentDiagnosticStore(this)
syncDiagnostics = SyncDiagnosticRecorder(
SyncDiagnosticStore(AndroidSyncSql { diagnosticStore.writableDatabase }), UUID.randomUUID().toString(),
System::currentTimeMillis, SystemClock::elapsedRealtime,
) { Log.w("GoAutoDiagnostic", "sync_diagnostic_persistence_failed") }
failureSnapshotStore = PurchaseFailureSnapshotStore(diagnosticStore)
diagnosticRecorder = SafeAgentDiagnosticRecorder(
persist = { event ->
@@ -146,6 +167,7 @@ class AgentForegroundService : Service() {
connectivityManager = getSystemService(ConnectivityManager::class.java)
createNotificationChannel()
startForeground(NOTIFICATION_ID, notification("正在启动"))
foregroundStarted = true
registerNetworkCallback()
resumeCollectionCooldown()
executor.scheduleWithFixedDelay(::triggerSync, 0, HEARTBEAT_SECONDS, TimeUnit.SECONDS)
@@ -154,6 +176,7 @@ class AgentForegroundService : Service() {
// 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 {
@@ -197,6 +220,10 @@ class AgentForegroundService : Service() {
}
override fun onDestroy() {
foregroundStarted = false
stateStore.stop()
probeHandoff.invalidate()
if (diagnosticInstance === this) diagnosticInstance = null
repurchaseClosed.set(true)
repurchase.requestStop(repurchase.state.roundId)
repurchaseExecutor.shutdownNow()
@@ -220,6 +247,30 @@ class AgentForegroundService : Service() {
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() {
if (!synchronized(taskMutex) { working.compareAndSet(false, true) }) return
executor.execute {
@@ -235,15 +286,63 @@ class AgentForegroundService : Service() {
}
private fun synchronizeAgent(): String {
val round = syncDiagnostics.begin(syncDiagnosticContext())
try {
return synchronizeAgent(round)
} catch (error: Error) {
round.abort()
throw error
} catch (error: java.util.concurrent.CancellationException) {
round.abort()
throw error
} finally {
round.finish()
}
}
private fun syncDiagnosticContext(): SyncContext = try {
val taskId = runningTaskId.get() ?: purchaseStore.activeTaskId()
val state = stateStore.read()
val taskContext = syncTaskIdentity(
taskId, state.currentTaskType.takeIf { state.currentTaskId == taskId },
taskMutex.currentTaskId() != null, syncTaskContext.get(),
) {
purchaseStore.interruptedAttempts().firstOrNull { it.taskId == taskId }?.attemptId
}
val network = connectivityManager.activeNetwork
val capabilities = network?.let(connectivityManager::getNetworkCapabilities)
SyncContext(
deviceId = identityStore.credentials()?.deviceId,
taskId = taskId,
attemptId = taskContext.attemptId,
phase = taskContext.phase,
validatedNetwork = capabilities?.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED),
transport = when {
network == null -> "none"
capabilities?.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) == true -> "wifi"
capabilities?.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) == true -> "cellular"
else -> "unknown"
},
foreground = foregroundStarted,
)
} catch (error: java.util.concurrent.CancellationException) {
throw error
} catch (error: Exception) {
Log.w("GoAutoDiagnostic", "sync_diagnostic_context_failed")
SyncContext(foreground = foregroundStarted)
}
private fun synchronizeAgent(round: SyncDiagnosticRecorder.Round): String {
var heartbeatSucceeded = false
return try {
val serverUrl = ServerUrlPolicy.normalize(settingsStore.serverUrl())
val api = AgentApiClient(serverUrl)
var credentials = identityStore.credentials()
stateStore.update("CONNECTING", "正在连接并校验设备身份", credentials?.deviceId ?: 0L, credentials != null)
updateNotification("正在连接")
stateStore.update("CONNECTING", "正在同步", credentials?.deviceId ?: 0L, credentials != null, connection = true)
updateNotification("正在同步")
if (!registeredThisProcess.get()) {
val registration = api.register(deviceInfo(), credentials?.token)
val registration = round.step("register") { api.register(deviceInfo(), credentials?.token) }
if (registration.deviceToken != null) {
val issuedToken = registration.deviceToken
identityStore.saveCredentials(registration.deviceId, issuedToken)
@@ -257,17 +356,41 @@ class AgentForegroundService : Service() {
}
val activeCredentials = credentials ?: error("设备尚未取得认证凭据")
if (taskMutex.currentTaskId() == null) {
recoverInterruptedPurchases(api, activeCredentials.token)
flushPurchaseOutbox(api, activeCredentials.token)
runningTaskId.set(purchaseStore.activeTaskId())
val sync = AgentSyncCycle(
heartbeat = { currentTaskId ->
round.step("heartbeat") {
var snapshot: SyncReportSnapshot? = null
heartbeatReports.heartbeat(
api, activeCredentials.token, currentTaskId, AgentCapabilities.supported,
report = { syncDiagnostics.snapshot().also { snapshot = it }?.report },
ack = { snapshot?.let(syncDiagnostics::ack) },
validate = { check(it.deviceId == activeCredentials.deviceId) { "心跳设备身份不一致" } },
)
}
},
activeTaskId = {
if (taskMutex.currentTaskId() != null) runningTaskId.get()
else purchaseStore.activeTaskId().also(runningTaskId::set)
},
executionActive = { taskMutex.currentTaskId() != null },
recover = {
round.step("recover") {
recoverInterruptedPurchases(api, activeCredentials.token, round::handledFailure)
}
},
flush = { round.step("flush") { flushPurchaseOutbox(api, activeCredentials.token) } },
hasPending = { purchaseStore.pendingOutbox().isNotEmpty() },
).run()
sync.failure?.let(round::failed)
if (sync.failureCode != null) {
cancelIdleReturn("同步未完成")
val message = cn.ilapage.goauto.agent.ui.PurchaseRejectionPresentation.connection(sync.failureCode, 0)
stateStore.update(sync.failureCode, message, activeCredentials.deviceId, true, connection = true)
updateNotification(message)
return if (sync.failureCode == "AUTH_ERROR") MANUAL_AUTH_ERROR else MANUAL_NETWORK_ERROR
}
val heartbeat = api.heartbeat(
activeCredentials.token,
currentTaskId = runningTaskId.get(),
capabilities = AgentCapabilities.supported,
)
check(heartbeat.deviceId == activeCredentials.deviceId) { "心跳返回了不同的设备身份" }
val heartbeat = requireNotNull(sync.heartbeat)
heartbeatSucceeded = true
stateStore.update(
code = if (heartbeat.busy) "BUSY" else "ONLINE",
message = if (heartbeat.busy) "设备在线,正在执行任务" else "设备在线空闲",
@@ -276,30 +399,34 @@ class AgentForegroundService : Service() {
heartbeat = true,
)
updateNotification(if (heartbeat.busy) "在线 · 执行中" else "在线 · 空闲")
scheduleTask(api, activeCredentials.token)
if (sync.canClaim) round.step("claim") { scheduleTask(api, activeCredentials.token) } else MANUAL_BUSY
} catch (error: AgentApiException) {
round.failed(error)
cancelIdleReturn("网络或服务端请求失败")
val authenticationError = error.code in setOf(
"DEVICE_TOKEN_INVALID", "DEVICE_INSTALL_ID_CONFLICT", "DEVICE_DISABLED",
)
val authenticationError = isAgentAuthenticationError(error)
val connectionFailure = syncConnectionFailureCode(error, heartbeatSucceeded)
val code = connectionFailure ?: "TASK_ERROR"
val storedCredentials = runCatching { identityStore.credentials() }.getOrNull()
stateStore.update(
code = if (authenticationError) "AUTH_ERROR" else "NETWORK_ERROR",
message = "${error.code}:${error.message}",
code = code,
message = if (connectionFailure == null) "任务同步暂未完成,请稍后重试" else cn.ilapage.goauto.agent.ui.PurchaseRejectionPresentation.connection(code, 0),
deviceId = storedCredentials?.deviceId ?: 0L,
tokenStored = storedCredentials != null,
connection = connectionFailure != null,
)
updateNotification(if (authenticationError) "设备认证失败" else "连接失败,等待网络恢复")
updateNotification(if (authenticationError) "设备认证失败" else if (heartbeatSucceeded) "任务同步暂未完成" else "连接失败,等待恢复")
if (authenticationError) MANUAL_AUTH_ERROR else MANUAL_NETWORK_ERROR
} catch (error: Exception) {
round.failed(error)
cancelIdleReturn("Agent 运行异常")
val configured = settingsStore.serverUrl().isNotBlank()
val storedCredentials = runCatching { identityStore.credentials() }.getOrNull()
stateStore.update(
code = if (configured) "ERROR" else "CONFIG_REQUIRED",
message = error.message ?: "Agent 运行失败",
code = if (heartbeatSucceeded) "TASK_ERROR" else if (configured) "ERROR" else "CONFIG_REQUIRED",
message = if (configured) "同步暂未完成,请稍后重试" else "请配置服务端",
deviceId = storedCredentials?.deviceId ?: 0L,
tokenStored = storedCredentials != null,
connection = !heartbeatSucceeded,
)
updateNotification(if (configured) "运行异常" else "等待配置服务端")
if (configured) MANUAL_ERROR else MANUAL_CONFIG_REQUIRED
@@ -308,8 +435,6 @@ class AgentForegroundService : Service() {
private fun scheduleTask(api: AgentApiClient, token: String): String {
if (taskMutex.currentTaskId() != null) return MANUAL_BUSY
recoverInterruptedPurchases(api, token)
flushPurchaseOutbox(api, token)
val collectionCooldown = activeCollectionCooldown()
val purchaseTask = api.nextPurchaseTask(token)
when (TaskDispatchPolicy.decide(purchaseTask?.status, collectionCooldown != null)) {
@@ -484,6 +609,7 @@ class AgentForegroundService : Service() {
}
try {
val window = cn.ilapage.goauto.agent.automation.OrderBackfillWindow(days, confirmedAt)
probeHandoff.beginExecution()
val server = settingsStore.serverUrl()
val credentials = identityStore.credentials() ?: error("设备尚未注册")
val accessibility = GoAutoAccessibilityService.instance ?: error("请先启用无障碍服务")
@@ -573,11 +699,12 @@ class AgentForegroundService : Service() {
publishCurrentPageResult(0L, "采集间隔中,还需 $remaining 秒。", replacementOriginType, replacementOriginTaskId)
return
}
if (!taskMutex.tryAcquire(CURRENT_PAGE_RESERVATION_ID)) {
if (!tryAcquireCurrentPage(taskMutex, working, CURRENT_PAGE_RESERVATION_ID)) {
publishCurrentPageResult(0L, "设备正在执行任务,请稍后再试。", replacementOriginType, replacementOriginTaskId)
return
}
cancelIdleReturn("正在创建当前页面采集任务")
probeHandoff.beginExecution()
taskExecutor.execute {
var taskId = 0L
try {
@@ -719,11 +846,18 @@ class AgentForegroundService : Service() {
}
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()
acquireTaskWakeLock()
var resultSafelyStored = false
var knownResultType: String? = null
var snapshotContext: SnapshotContext? = null
var observedSyncTaskContext: SyncContext? = null
var executionEntered = false
var snapshotAttempted = false
var probeDiagnosticEvents = 0
@@ -741,6 +875,8 @@ class AgentForegroundService : Service() {
api.startPurchaseTask(claimed.taskId, UUID.randomUUID().toString(), token)
} else claimed
check(task.status == "running" && task.taskAttemptId.isNotBlank()) { "采购任务没有有效 attempt" }
observedSyncTaskContext = SyncContext(taskId = task.taskId, attemptId = task.taskAttemptId, phase = task.phase)
syncTaskContext.set(observedSyncTaskContext)
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}")) &&
@@ -783,8 +919,35 @@ class AgentForegroundService : Service() {
} else {
PurchaseRehearsalExecutor(
driver = accessibility,
openLink = { PddLinkLauncher(this).open(it, preferDirect = true) },
probeSpecs = { collectPurchaseProbe(accessibility, task, parsedRule, probeDiagnostic) },
openLink = { url ->
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))
},
@@ -822,6 +985,10 @@ class AgentForegroundService : Service() {
minUnitPriceCent = task.minUnitPriceCent,
maxUnitPriceCent = task.maxUnitPriceCent,
addressSuffix = task.addressSuffix,
reuseProbeProduct = probeHandoff.eligible(
handoffExecution, task.taskId, task.pddGoodsId, task.phase, task.taskAttemptId,
accessibility, accessibility.currentForegroundRevision(), SystemClock.elapsedRealtime(),
),
),
parsedRule,
PurchaseAgentCapabilities.supported,
@@ -849,10 +1016,26 @@ class AgentForegroundService : Service() {
GoAutoAccessibilityService.instance?.showPurchaseResultBubble(presentation)
}
beginIdleReturnCooldown()
flushPurchaseOutbox(api, token)
val message = if (outcome.resultType == "failed") "${outcome.errorCode}:${outcome.message}" else outcome.message
// 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 rejected = purchaseStore.rejectedResults().any { it.taskId == task.taskId && it.attemptId == task.taskAttemptId }
val message = if (rejected) "采购结果被服务端拒收,请在采购页或设置中查看" else if (outcome.resultType == "failed") "${outcome.errorCode}:${outcome.message}" else outcome.message
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 (rejected) "$taskLabel #${task.taskId} 结果被拒收" else if (outcome.resultType == "failed") "$taskLabel #${task.taskId} 失败" else "$taskLabel #${task.taskId} 已提交")
} catch (error: AgentApiException) {
failureSnapshot("failed", error.code.takeIf { it.matches(Regex("[A-Z][A-Z0-9_]{0,63}")) } ?: "PURCHASE_API_FAILED")
stateStore.update("TASK_ERROR", "${error.code}:${error.message}", tokenStored = true)
@@ -861,6 +1044,7 @@ class AgentForegroundService : Service() {
stateStore.update("TASK_ERROR", error.message ?: "采购演练执行异常", tokenStored = true)
} finally {
if (snapshotContext?.phase == "spec_probe" && probeDiagnosticEvents == 0) Log.i("GoAutoDiagnostic", "purchase_diagnostic_no_event")
observedSyncTaskContext?.let { syncTaskContext.compareAndSet(it, null) }
if (!resultSafelyStored) cancelIdleReturn("采购结果未安全保存")
releaseTaskWakeLock()
}
@@ -911,11 +1095,16 @@ class AgentForegroundService : Service() {
.toString()
}
private fun recoverInterruptedPurchases(api: AgentApiClient, token: String) {
private fun recoverInterruptedPurchases(
api: AgentApiClient,
token: String,
onHandledFailure: (AgentApiException) -> Unit,
) {
purchaseStore.interruptedAttempts().forEach { interrupted ->
probeHandoff.beginExecution()
if (interrupted.status == PurchaseTaskStore.STATUS_ORDER_SUBMIT_STARTED) {
val boundaryRequestId = interrupted.orderSubmitRequestId ?: return@forEach
try {
recoverPurchaseBoundary(onHandledFailure) {
api.markPurchaseOrderSubmitStarted(interrupted.taskId, boundaryRequestId, token)
val automation = GoAutoAccessibilityService.instance?.let(::PurchaseLiveAutomation)
val evidence = try { automation?.readOrderResult() } catch (error: Exception) {
@@ -941,9 +1130,6 @@ class AgentForegroundService : Service() {
}
val requestId = UUID.randomUUID().toString()
purchaseStore.completeAndEnqueue(interrupted.taskId, interrupted.attemptId, requestId, purchaseResultPayload(requestId, interrupted.attemptId, outcome))
} catch (_: AgentApiException) {
// Keep the local irreversible marker. The same server request ID
// is replayed after connectivity returns; the order click is never repeated.
}
return@forEach
}
@@ -964,11 +1150,32 @@ 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(
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) },
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) }
},
markRejected = { item, code ->
probeHandoff.invalidate()
purchaseStore.markRejected(item, code)
synchronized(taskMutex) {
val runtime = stateStore.read()
if (shouldClearRejectedPurchase(item.taskId, runtime.currentTaskId, runtime.currentTaskType,
taskMutex.currentTaskId(), purchaseStore.isCurrentAttemptRejected(item.taskId, item.attemptId))) {
stateStore.clearActiveTask(item.taskId)
}
}
sendBroadcast(Intent(ACTION_PURCHASE_REJECTIONS_CHANGED).setPackage(packageName))
},
).flush()
runningTaskId.set(purchaseStore.activeTaskId())
}
@@ -1031,6 +1238,7 @@ class AgentForegroundService : Service() {
initialTask: cn.ilapage.goauto.agent.network.AgentTask,
token: String,
): TaskExecutionSummary {
probeHandoff.beginExecution()
GoAutoAccessibilityService.instance?.dismissPurchaseResultBubble()
acquireTaskWakeLock()
return try {
@@ -1404,6 +1612,7 @@ class AgentForegroundService : Service() {
}
private fun requestReturnToAgent(accessibility: GoAutoAccessibilityService) {
probeHandoff.invalidate()
val launched = accessibility.openAgentPreservingTab()
if (launched) SystemClock.sleep(RETURN_CONFIRM_DELAY_MILLIS)
val returned = launched && accessibility.currentPackage() == packageName
@@ -1558,6 +1767,7 @@ class AgentForegroundService : Service() {
* 归位要的是相反的效果,所以单独加 FLAG_ACTIVITY_CLEAR_TOP,两条路径各自保留(#292)。
*/
private fun resetPddToHome(): PddLaunchResult {
probeHandoff.invalidate()
val launchIntent = packageManager.getLaunchIntentForPackage(PDD_PACKAGE)
?: return PddLaunchResult.INTENT_MISSING
launchIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP)
@@ -1572,6 +1782,7 @@ class AgentForegroundService : Service() {
}
private fun launchPddToForeground(): PddLaunchResult {
probeHandoff.invalidate()
val launchIntent = packageManager.getLaunchIntentForPackage(PDD_PACKAGE)
?: return PddLaunchResult.INTENT_MISSING
launchIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
@@ -1586,10 +1797,13 @@ class AgentForegroundService : Service() {
}
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"
const val ACTION_PURCHASE_REJECTIONS_CHANGED = "cn.ilapage.goauto.agent.PURCHASE_REJECTIONS_CHANGED"
@Volatile var repurchaseState = RepurchaseState()
private set
const val ACTION_BACKFILL_START = "cn.ilapage.goauto.agent.BACKFILL_START"
@@ -191,17 +191,27 @@ class AgentSettingsStore(context: Context) {
class AgentStateStore(context: Context) {
private val preferences = context.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE)
private val publicationGate = AgentStatePublicationGate()
fun update(code: String, message: String, deviceId: Long? = null, tokenStored: Boolean? = null, heartbeat: Boolean = false) {
fun update(code: String, message: String, deviceId: Long? = null, tokenStored: Boolean? = null, heartbeat: Boolean = false, connection: Boolean = false) = publicationGate.publish {
val editor = preferences.edit()
.putString(STATE_CODE, code)
.putString(STATE_MESSAGE, message)
deviceId?.let { editor.putLong(DEVICE_ID, it) }
tokenStored?.let { editor.putBoolean(TOKEN_STORED, it) }
if (heartbeat) editor.putLong(LAST_HEARTBEAT, System.currentTimeMillis())
if (heartbeat || connection) editor.putString(CONNECTION_CODE, code)
editor.apply()
}
/** This service instance cannot publish a late heartbeat over STOPPED or a restarted service. */
fun stop() = publicationGate.stop {
preferences.edit().putString(STATE_CODE, "STOPPED").putString(CONNECTION_CODE, "STOPPED")
.putString(STATE_MESSAGE, "前台服务已停止").apply()
}
fun connectionCode(): String = preferences.getString(CONNECTION_CODE, "STOPPED") ?: "STOPPED"
fun read(): AgentState = AgentState(
code = preferences.getString(STATE_CODE, "STOPPED") ?: "STOPPED",
message = preferences.getString(STATE_MESSAGE, "前台服务尚未启动") ?: "前台服务尚未启动",
@@ -279,6 +289,7 @@ class AgentStateStore(context: Context) {
private companion object {
const val PREFERENCES = "goauto_agent_runtime"
const val STATE_CODE = "state_code"
const val CONNECTION_CODE = "connection_code"
const val STATE_MESSAGE = "state_message"
const val DEVICE_ID = "device_id"
const val LAST_HEARTBEAT = "last_heartbeat"
@@ -0,0 +1,87 @@
package cn.ilapage.goauto.agent.service
import cn.ilapage.goauto.agent.network.AgentApiException
import cn.ilapage.goauto.agent.network.HeartbeatResult
internal fun isAgentAuthenticationError(error: Throwable?): Boolean = error is AgentApiException &&
(error.status in setOf(401, 403) || error.code in setOf("DEVICE_TOKEN_INVALID", "DEVICE_INSTALL_ID_CONFLICT", "DEVICE_DISABLED"))
internal fun syncConnectionFailureCode(error: Throwable, heartbeatSucceeded: Boolean): String? = when {
isAgentAuthenticationError(error) -> "AUTH_ERROR"
heartbeatSucceeded -> null
error is AgentApiException && error.code == "DEVICE_TASK_MISMATCH" -> "TASK_MISMATCH"
error is AgentApiException && error.status >= 500 -> "SERVER_ERROR"
else -> "NETWORK_ERROR"
}
internal data class AgentSyncResult(
val heartbeat: HeartbeatResult?,
val failureCode: String?,
val canClaim: Boolean,
val failure: Exception? = null,
)
/** Run under the service's existing sync exclusion; device execution keeps its task mutex. */
internal class AgentSyncCycle(
private val heartbeat: (Long?) -> HeartbeatResult,
private val activeTaskId: () -> Long?,
private val executionActive: () -> Boolean,
private val recover: () -> Unit,
private val flush: () -> Unit,
private val hasPending: () -> Boolean,
) {
fun run(): AgentSyncResult {
var pulse = captureSync { heartbeat(activeTaskId()) }
var authFailure = isAgentAuthenticationError(pulse.exceptionOrNull())
var recovered = false
var flushed = false
if (!authFailure && !executionActive()) {
val recovery = captureSync(recover)
recovered = recovery.isSuccess
authFailure = isAgentAuthenticationError(recovery.exceptionOrNull())
if (!authFailure) {
val upload = captureSync(flush)
flushed = upload.isSuccess
authFailure = isAgentAuthenticationError(upload.exceptionOrNull())
}
if (!authFailure && (pulse.exceptionOrNull() as? AgentApiException)?.code == "DEVICE_TASK_MISMATCH") {
pulse = captureSync { heartbeat(activeTaskId()) }
}
}
val failure = pulse.exceptionOrNull()
return AgentSyncResult(
pulse.getOrNull(),
when {
authFailure -> "AUTH_ERROR"
failure == null -> null
isAgentAuthenticationError(failure) -> "AUTH_ERROR"
(failure as? AgentApiException)?.code == "DEVICE_TASK_MISMATCH" -> "TASK_MISMATCH"
failure is AgentApiException && failure.status >= 500 -> "SERVER_ERROR"
else -> "NETWORK_ERROR"
},
pulse.isSuccess && !authFailure && recovered && flushed && !executionActive() && !hasPending() && activeTaskId() == null,
failure as? Exception,
)
}
}
private fun <T> captureSync(action: () -> T): Result<T> = try {
Result.success(action())
} catch (error: java.util.concurrent.CancellationException) {
throw error
} catch (error: Exception) {
Result.failure(error)
}
/** Preserve the local irreversible marker when replay fails; the original request ID is reused. */
internal fun recoverPurchaseBoundary(
onHandledFailure: (AgentApiException) -> Unit,
recover: () -> Unit,
) {
try {
recover()
} catch (error: AgentApiException) {
if (isAgentAuthenticationError(error)) throw error
onHandledFailure(error)
}
}
@@ -0,0 +1,16 @@
package cn.ilapage.goauto.agent.service
internal fun shouldClearRejectedPurchase(
rejectedTaskId: Long, runtimeTaskId: Long?, runtimeTaskType: String?,
executingTaskId: Long?, currentAttemptRejected: Boolean,
): Boolean = executingTaskId == null && runtimeTaskId == rejectedTaskId &&
runtimeTaskType == "purchase" && currentAttemptRejected
internal class AgentStatePublicationGate {
private var stopped = false
@Synchronized fun publish(write: () -> Unit) { if (!stopped) write() }
@Synchronized fun stop(write: () -> Unit) { stopped = true; write() }
}
internal fun tryAcquireCurrentPage(mutex: TaskExecutionMutex, syncing: java.util.concurrent.atomic.AtomicBoolean, reservation: Long): Boolean =
synchronized(mutex) { !syncing.get() && mutex.tryAcquire(reservation) }
@@ -24,6 +24,7 @@ import cn.ilapage.goauto.agent.network.CollectionHistoryItem
import cn.ilapage.goauto.agent.network.PurchaseHistoryItem
import cn.ilapage.goauto.agent.network.ServerUrlPolicy
import cn.ilapage.goauto.agent.persistence.TaskHistoryCache
import cn.ilapage.goauto.agent.persistence.PurchaseTaskStore
import cn.ilapage.goauto.agent.service.AgentForegroundService
import cn.ilapage.goauto.agent.service.AgentSettingsStore
import cn.ilapage.goauto.agent.service.AgentStateStore
@@ -58,6 +59,7 @@ class AgentSettingsFragment : Fragment() {
private lateinit var connectionFeedback: TextView
private lateinit var disabledReason: TextView
private lateinit var diagnostics: TextView
private lateinit var rejectedResultsButton: MaterialButton
private lateinit var accessibilityText: TextView
private lateinit var collectionIntervalStartLayout: TextInputLayout
private lateinit var collectionIntervalStartInput: TextInputEditText
@@ -326,6 +328,20 @@ class AgentSettingsFragment : Fragment() {
diagnostics = context.label("—", 14f, context.getColor(R.color.agent_text))
diagnostics.setPadding(0, context.dp(10), 0, 0)
addView(diagnostics)
rejectedResultsButton = MaterialButton(context).apply {
text = "查看拒收结果"
minHeight = context.dp(48)
setOnClickListener {
val records = PurchaseTaskStore(context).use { it.rejectedResults() }
MaterialAlertDialogBuilder(context).setTitle("采购结果被服务端拒收")
.setMessage(records.joinToString("\n\n") {
PurchaseRejectionPresentation.notice(it) + "\n${it.errorCode}" +
if (it.acknowledgedAt != null) "\n已核对(仅本机记录)" else ""
}.ifBlank { "暂无拒收结果" })
.setPositiveButton("知道了", null).show()
}
}
addView(rejectedResultsButton, fullWidth(8))
}))
}
return context.page(content)
@@ -669,12 +685,14 @@ class AgentSettingsFragment : Fragment() {
disabledReason.text = if (busy) "任务执行中,暂时不能修改服务器或设备名称。" else ""
val installId = runCatching { identityStore.installId() }.getOrElse { "读取失败" }
val rejectedCount = PurchaseTaskStore(context).use { it.rejectedCount() }
rejectedResultsButton.visibility = if (rejectedCount > 0) View.VISIBLE else View.GONE
diagnostics.text = buildString {
append("installId:$installId\n")
append("Device Token:${if (state.tokenStored) "已配置" else "未配置"}\n")
append("注册状态:${if (state.deviceId > 0) "已注册(设备 ${state.deviceId})" else "未注册"}\n")
append("Agent 版本:${BuildConfig.VERSION_NAME}\n")
append("服务端连接:${if (state.code in setOf("ONLINE", "BUSY", "COLLECTION_COOLDOWN")) "已连接" else "未连接"}\n")
append("服务端连接:${PurchaseRejectionPresentation.connection(stateStore.connectionCode(), rejectedCount)}\n")
append("保持屏幕常亮:${if (state.keepScreenOn) "已开启" else "仅在任务执行或采集间隔时开启"}")
}
accessibilityText.text = when (AccessibilityReadinessDetector.current(context)) {
@@ -0,0 +1,28 @@
package cn.ilapage.goauto.agent.ui
import cn.ilapage.goauto.agent.persistence.RejectedPurchaseResult
import org.json.JSONObject
internal object PurchaseRejectionPresentation {
private fun payload(item: RejectedPurchaseResult) = runCatching { JSONObject(item.payloadJson) }.getOrNull()
private fun orderNo(item: RejectedPurchaseResult): String? = payload(item)?.optString("pddOrderNo")
?.takeIf { it.matches(Regex("[0-9A-Za-z-]{1,80}")) }
fun showBanner(item: RejectedPurchaseResult): Boolean = item.acknowledgedAt == null &&
(payload(item)?.optString("resultType") in setOf("order_created", "order_result_unknown") ||
!payload(item)?.optString("pddOrderNo").isNullOrBlank())
fun notice(item: RejectedPurchaseResult): String = buildString {
append("采购任务 #${item.taskId} 的结果被服务端拒收")
orderNo(item)?.let { append("\n拼多多订单号:$it") }
append("\n请人工核对拼多多订单及后台任务,避免重复采购")
}
fun connection(code: String, rejected: Int): String = when (code) {
"CONNECTING" -> "正在同步"
"ONLINE", "BUSY", "COLLECTION_COOLDOWN", "TASK_ERROR" -> if (rejected > 0) "已连接 · 有 $rejected 条采购结果被服务端拒收" else "已连接"
"AUTH_ERROR" -> "未连接 · 设备认证失败"
"TASK_MISMATCH" -> "未连接 · 设备任务状态不一致"
"NETWORK_ERROR" -> "未连接 · 网络暂不可用"
"SERVER_ERROR" -> "未连接 · 服务端暂不可用"
"CONFIG_REQUIRED" -> "未连接 · 请配置服务端"
else -> "未连接 · 同步暂未完成"
}
}
@@ -37,6 +37,8 @@ import cn.ilapage.goauto.agent.network.HistoryColorImage
import cn.ilapage.goauto.agent.network.PurchaseHistoryDetail
import cn.ilapage.goauto.agent.network.PurchaseHistoryItem
import cn.ilapage.goauto.agent.persistence.TaskHistoryCache
import cn.ilapage.goauto.agent.persistence.PurchaseTaskStore
import cn.ilapage.goauto.agent.persistence.RejectedPurchaseResult
import cn.ilapage.goauto.agent.service.AgentForegroundService
import cn.ilapage.goauto.agent.service.AgentSettingsStore
import cn.ilapage.goauto.agent.service.AgentStateStore
@@ -146,8 +148,14 @@ class TaskHistoryFragment : Fragment() {
private var repurchaseButton: MaterialButton? = null
private var repurchaseDialog: androidx.appcompat.app.AlertDialog? = null
private var repurchaseDialogRound: String? = null
private var rejectionPanel: LinearLayout? = null
private var displayedRejections: List<RejectedPurchaseResult>? = null
private val currentPageReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
if (intent?.action == AgentForegroundService.ACTION_PURCHASE_REJECTIONS_CHANGED) {
renderRejections()
return
}
if (intent?.action == AgentForegroundService.ACTION_REPURCHASE_STATE) {
renderRepurchase()
if (!collection && isResumed && AgentForegroundService.repurchaseState.phase == cn.ilapage.goauto.agent.service.RepurchasePhase.FINISHED) {
@@ -200,6 +208,10 @@ class TaskHistoryFragment : Fragment() {
val context = requireContext()
pageColumn = context.column()
pageColumn.addView(context.screenTitle(if (collection) "采集记录" else "采购记录"))
if (!collection) {
rejectionPanel = context.column(0)
pageColumn.addView(rejectionPanel)
}
pageColumn.addView(buildSearch())
if (!collection) {
repurchasePanel = context.column(0)
@@ -228,6 +240,7 @@ class TaskHistoryFragment : Fragment() {
override fun onResume() {
super.onResume()
renderRejections()
renderRepurchase()
detailState.taskId?.let { taskId ->
if (collection) loadCollectionDetail(taskId) else loadPurchaseDetail(taskId)
@@ -235,6 +248,8 @@ class TaskHistoryFragment : Fragment() {
}
override fun onDestroyView() {
rejectionPanel = null
displayedRejections = null
repurchaseDialog?.setOnDismissListener(null)
repurchaseDialog?.dismiss()
repurchaseDialog = null
@@ -247,6 +262,29 @@ class TaskHistoryFragment : Fragment() {
super.onDestroyView()
}
private fun renderRejections() {
val panel = rejectionPanel ?: return
val context = context ?: return
val records = PurchaseTaskStore(context).use { it.rejectedResults() }.filter(PurchaseRejectionPresentation::showBanner)
if (records == displayedRejections) return
displayedRejections = records
panel.removeAllViews()
records.forEach { record ->
panel.addView(context.card(context.cardColumn().apply {
addView(context.label(PurchaseRejectionPresentation.notice(record), 14f, context.getColor(R.color.agent_warning), true))
addView(MaterialButton(context).apply {
text = "已核对"
minHeight = context.dp(48)
contentDescription = "采购任务 #${record.taskId} 已核对,仅隐藏本机提醒"
setOnClickListener {
val saved = runCatching { PurchaseTaskStore(context).use { it.acknowledgeRejection(record.id) } }.isSuccess
if (saved) renderRejections() else toast("暂时无法保存核对记录,请稍后重试")
}
}, fullWidth(8))
}))
}
}
override fun onDestroy() {
unregisterCurrentPageReceiver()
imageLoader.close()
@@ -811,6 +849,7 @@ class TaskHistoryFragment : Fragment() {
val filter = IntentFilter(AgentForegroundService.ACTION_CURRENT_PAGE_RESULT)
filter.addAction(AgentForegroundService.ACTION_BACKFILL_STATE)
filter.addAction(AgentForegroundService.ACTION_REPURCHASE_STATE)
filter.addAction(AgentForegroundService.ACTION_PURCHASE_REJECTIONS_CHANGED)
if (Build.VERSION.SDK_INT >= 33) {
requireContext().registerReceiver(currentPageReceiver, filter, Context.RECEIVER_NOT_EXPORTED)
} else {
@@ -893,6 +932,11 @@ class TaskHistoryFragment : Fragment() {
private fun renderPurchaseDetail(detail: PurchaseHistoryDetail) {
val context = requireContext()
val task = detail.task
PurchaseTaskStore(context).use { it.rejectedResults() }.filter { it.taskId == task.taskId }.forEach { record ->
resultColumn.addView(context.card(context.cardColumn().apply {
addView(context.label(PurchaseRejectionPresentation.notice(record) + "\n${record.errorCode}", 14f, context.getColor(R.color.agent_warning)))
}))
}
val info = buildString {
append("蝦皮订单号:${task.shopeeOrderNo.ifBlank { "—" }}\n")
append("PDD 商品:${task.pddGoodsId}\n")
@@ -9,6 +9,27 @@ import org.junit.Assert.assertEquals
import org.junit.Test
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
fun diagnosticsAreBoundedToFiftyRecordsAndSevenDays() {
assertEquals(50, AgentDiagnosticRetentionPolicy.MAX_RECORDS)
@@ -0,0 +1,76 @@
package cn.ilapage.goauto.agent
import cn.ilapage.goauto.agent.network.*
import cn.ilapage.goauto.agent.persistence.syncFailure
import java.net.HttpURLConnection
import java.net.SocketTimeoutException
import java.net.URL
import org.junit.Assert.*
import org.junit.Test
class AgentTransportTimeoutTest {
@Test
fun loopbackPeerThatWithholdsResponseUsesExistingReadTimeoutAndResponseCategory() {
val socket = java.net.ServerSocket(0, 1, java.net.InetAddress.getLoopbackAddress())
val release = java.util.concurrent.CountDownLatch(1)
val accepted = java.util.concurrent.CountDownLatch(1)
val worker =
kotlin.concurrent.thread(isDaemon = true) {
socket.accept().use {
accepted.countDown()
release.await(20, java.util.concurrent.TimeUnit.SECONDS)
}
}
try {
val error =
assertThrows(AgentTransportTimeout::class.java) {
AgentApiClient("http://127.0.0.1:${socket.localPort}")
.heartbeat("synthetic", null)
}
assertEquals(0, accepted.count)
assertEquals(RequestStage.RESPONSE, error.stage)
assertEquals("timeout_response", syncFailure(error, "heartbeat", 1000, 1).category)
} finally {
release.countDown()
socket.close()
worker.join(1000)
}
}
@Test
fun timeoutCategoryComesFromActualConnectionStageNotExceptionMessage() {
for (stage in RequestStage.values()) {
val connection =
object : HttpURLConnection(URL("http://127.0.0.1")) {
override fun connect() {
if (stage == RequestStage.CONNECT)
throw SocketTimeoutException("Read timed out")
}
override fun disconnect() {}
override fun usingProxy() = false
override fun getOutputStream(): java.io.OutputStream {
if (stage == RequestStage.UNKNOWN)
throw SocketTimeoutException("connect timed out")
return java.io.ByteArrayOutputStream()
}
override fun getResponseCode(): Int {
throw SocketTimeoutException("connect timed out")
}
}
val api = AgentApiClient("http://127.0.0.1", connectionFactory = { connection })
val error =
assertThrows(AgentTransportTimeout::class.java) { api.heartbeat("synthetic", null) }
assertEquals(stage, error.stage)
assertEquals(
"timeout_${stage.name.lowercase()}",
syncFailure(error, "heartbeat", 1000, 1).category,
)
assertEquals(10_000, connection.connectTimeout)
assertEquals(15_000, connection.readTimeout)
}
}
}
@@ -0,0 +1,315 @@
package cn.ilapage.goauto.agent
import cn.ilapage.goauto.agent.network.*
import cn.ilapage.goauto.agent.persistence.*
import java.net.InetAddress
import java.net.ServerSocket
import java.sql.DriverManager
import java.util.concurrent.CopyOnWriteArrayList
import kotlin.concurrent.thread
import org.json.JSONArray
import org.json.JSONObject
import org.junit.Assert.*
import org.junit.Test
class HeartbeatReportCompatibilityTest {
@Test
fun trueFalseMissingFreshProcessAndTokenChangeControlNextRequest() {
val responses =
listOf(
success(true),
success(false),
success(true),
success(null),
success(true),
success(true),
success(true),
)
HttpFixture(responses).use { http ->
val negotiation = HeartbeatReportNegotiation()
var ack = 0
repeat(5) {
negotiation.heartbeat(
AgentApiClient(http.origin),
"token",
null,
emptyList(),
{ report() },
{ ack++ },
)
}
negotiation.heartbeat(
AgentApiClient(http.origin),
"changed",
null,
emptyList(),
{ report() },
{ ack++ },
)
HeartbeatReportNegotiation()
.heartbeat(
AgentApiClient(http.origin),
"changed",
null,
emptyList(),
{ report() },
{ ack++ },
)
assertEquals(
listOf(false, true, false, true, false, false, false),
http.requests.map { it.has("clientReport") },
)
assertEquals(2, ack)
}
}
@Test
fun originChangeRenegotiatesEvenWhenTokenUnchanged() {
HttpFixture(listOf(success(true))).use { first ->
HttpFixture(listOf(success(true))).use { second ->
val negotiation = HeartbeatReportNegotiation()
for (http in listOf(first, second)) negotiation.heartbeat(
AgentApiClient(http.origin),
"token",
null,
emptyList(),
{ report() },
{ fail("new origin cannot acknowledge") },
)
assertFalse(second.requests.single().has("clientReport"))
}
}
}
@Test
fun onlyExactOldServerRejectionCanDowngradeAndFallbackIsAtMostOnce() {
for ((status, code, message) in
listOf(
401 to "INVALID_REQUEST" to "请求 JSON 无效",
403 to "INVALID_REQUEST" to "请求 JSON 无效",
409 to "DEVICE_TASK_MISMATCH" to "请求 JSON 无效",
500 to "INVALID_REQUEST" to "请求 JSON 无效",
422 to "INVALID_REQUEST" to "different",
422 to "OTHER" to "请求 JSON 无效",
)
.map { Triple(it.first.first, it.first.second, it.second) }) {
HttpFixture(listOf(success(true), failure(status, code, message))).use { http ->
val negotiation = HeartbeatReportNegotiation()
val api = AgentApiClient(http.origin)
negotiation.heartbeat(
api,
"token",
null,
emptyList(),
{ report() },
{ fail("ack") },
)
assertThrows(AgentApiException::class.java) {
negotiation.heartbeat(
api,
"token",
null,
emptyList(),
{ report() },
{ fail("ack") },
)
}
assertEquals(2, http.requests.size)
}
}
HttpFixture(
listOf(
success(true),
failure(422, "INVALID_REQUEST", "请求 JSON 无效"),
failure(422, "INVALID_REQUEST", "请求 JSON 无效"),
)
)
.use { http ->
val negotiation = HeartbeatReportNegotiation()
val api = AgentApiClient(http.origin)
negotiation.heartbeat(
api,
"token",
null,
emptyList(),
{ report() },
{ fail("ack") },
)
assertThrows(AgentApiException::class.java) {
negotiation.heartbeat(
api,
"token",
null,
emptyList(),
{ report() },
{ fail("ack") },
)
}
assertEquals(3, http.requests.size)
}
}
@Test
fun missingReportDoesNotFallbackAndInvalidIdentityCannotAcknowledge() {
HttpFixture(listOf(failure(422, "INVALID_REQUEST", "请求 JSON 无效"))).use { http ->
assertThrows(AgentApiException::class.java) {
HeartbeatReportNegotiation()
.heartbeat(
AgentApiClient(http.origin),
"t",
null,
emptyList(),
{ report() },
{ fail("ack") },
)
}
assertEquals(1, http.requests.size)
}
HttpFixture(listOf(success(true), success(false))).use { http ->
val negotiation = HeartbeatReportNegotiation()
val api = AgentApiClient(http.origin)
negotiation.heartbeat(api, "token", null, emptyList(), { report() }, { fail("ack") })
assertThrows(IllegalStateException::class.java) {
negotiation.heartbeat(
api,
"token",
null,
emptyList(),
{ report() },
{ fail("ack") },
) {
check(it.deviceId == 2L)
}
}
}
}
@Test
fun lostResponseDoesNotAcknowledgeOrResend() {
HttpFixture(listOf(success(true), null)).use { http ->
val negotiation = HeartbeatReportNegotiation()
val api = AgentApiClient(http.origin)
negotiation.heartbeat(api, "t", null, emptyList(), { report() }, { fail("ack") })
assertThrows(java.io.IOException::class.java) {
negotiation.heartbeat(api, "t", null, emptyList(), { report() }, { fail("ack") })
}
assertEquals(2, http.requests.size)
}
}
@Test
fun actualSerializedReportsFromSqliteReachHttpAndExportCrossLanguageFixtures() {
DriverManager.getConnection("jdbc:sqlite::memory:").use { db ->
AgentDiagnosticSchema.syncStatements.forEach {
db.createStatement().use { s -> s.execute(it) }
}
val store = SyncDiagnosticStore(JdbcSyncSql(db))
val at = 1_700_000_000_000L
val zero = store.snapshot("p", at)!!
val id = store.start("p", at, null, SyncContext())
store.finish(id, at + 100, 100, SyncFailure("heartbeat", "network", at + 100, 100))
val failed = store.snapshot("p", at + 200)!!
store.ack(failed)
store.start("old", at + 300, null, SyncContext())
val interrupted = store.snapshot("p", at + 400)!!
HttpFixture(List(4) { success(true) }).use { http ->
val negotiation = HeartbeatReportNegotiation()
val api = AgentApiClient(http.origin)
negotiation.heartbeat(api, "synthetic", null, emptyList(), { null }, {})
for (snapshot in listOf(zero, failed, interrupted)) negotiation.heartbeat(
api,
"synthetic",
null,
emptyList(),
{ snapshot.report },
{},
)
val reports =
JSONArray(http.requests.drop(1).map { it.getJSONObject("clientReport") })
assertEquals(3, reports.length())
for (i in 0..2) {
assertEquals(8, reports.getJSONObject(i).length())
assertTrue(reports.getJSONObject(i).toString().toByteArray().size <= 2048)
}
val file = java.io.File("../build/diagnostics378-client-reports.json")
requireNotNull(file.parentFile).mkdirs()
file.writeText(reports.toString(2), Charsets.UTF_8)
}
}
}
private fun report() =
JSONObject()
.put("snapshotSeq", 1)
.put("failedRounds", 0)
.put("latestFailureAt", JSONObject.NULL)
.put("latestFailureStep", JSONObject.NULL)
.put("latestFailureCategory", JSONObject.NULL)
.put("latestFailureDurationMs", JSONObject.NULL)
.put("previousSyncDurationMs", JSONObject.NULL)
.put("hasInterruptedRound", false)
private fun success(capability: Boolean?): Pair<Int, String> =
200 to
JSONObject()
.put(
"data",
JSONObject().put("deviceId", 1).put("online", true).put("busy", false).apply {
capability?.let { put("acceptsClientReport", it) }
},
)
.toString()
private fun failure(status: Int, code: String, message: String) =
status to JSONObject().put("code", code).put("message", message).toString()
}
private class HttpFixture(responses: List<Pair<Int, String>?>) : AutoCloseable {
private val socket =
ServerSocket(0, 10, InetAddress.getLoopbackAddress()).apply { soTimeout = 5000 }
val origin = "http://127.0.0.1:${socket.localPort}"
val requests = CopyOnWriteArrayList<JSONObject>()
private var error: Throwable? = null
private val worker =
thread(isDaemon = true) {
try {
for (response in responses) socket.accept().use { client ->
client.soTimeout = 5000
val input = client.getInputStream().bufferedReader()
var length = 0
while (true) {
val line = input.readLine() ?: error("missing headers")
if (line.isEmpty()) break
if (line.startsWith("Content-Length:", true))
length = line.substringAfter(':').trim().toInt()
}
check(length in 1..4096)
val body = CharArray(length)
var read = 0
while (read < length) {
val count = input.read(body, read, length - read)
check(count > 0)
read += count
}
requests.add(JSONObject(String(body)))
if (response != null) {
val bytes = response.second.toByteArray(Charsets.UTF_8)
client
.getOutputStream()
.write(
("HTTP/1.1 ${response.first} OK\r\nContent-Length: ${bytes.size}\r\nConnection: close\r\n\r\n")
.toByteArray() + bytes
)
}
}
} catch (e: Throwable) {
if (!socket.isClosed) error = e
}
}
override fun close() {
socket.close()
worker.join(1000)
error?.let { throw AssertionError("HTTP fixture failed", it) }
}
}
@@ -0,0 +1,86 @@
package cn.ilapage.goauto.agent
import cn.ilapage.goauto.agent.network.*
import java.net.ServerSocket
import kotlin.concurrent.thread
import org.json.JSONObject
import org.junit.Assert.*
import org.junit.Test
class HeartbeatReportProtocolTest {
@Test
fun negotiatesThenDowngradesOnceWithSameRequestIdAndNoAck() {
val requests = mutableListOf<JSONObject>()
val server =
ServerSocket(0, 3, java.net.InetAddress.getLoopbackAddress()).apply { soTimeout = 5000 }
val worker =
thread(isDaemon = true) {
repeat(3) {
server.accept().use { socket ->
socket.soTimeout = 5000
val input = socket.getInputStream().bufferedReader()
var length = 0
while (true) {
val line = input.readLine() ?: error("missing header")
if (line.isEmpty()) break
if (line.startsWith("Content-Length:", true))
length = line.substringAfter(':').trim().toInt()
}
check(length in 1..4096)
val chars = CharArray(length)
var read = 0
while (read < length) {
val count = input.read(chars, read, length - read)
check(count > 0)
read += count
}
val request = JSONObject(String(chars))
requests.add(request)
val reject = request.has("clientReport")
val response =
if (reject) """{"code":"INVALID_REQUEST","message":"请求 JSON 无效"}"""
else
"""{"data":{"deviceId":1,"online":true,"busy":false,"acceptsClientReport":true}}"""
val bytes = response.toByteArray()
socket
.getOutputStream()
.write(
("HTTP/1.1 ${if(reject) 422 else 200} OK\r\nContent-Length: ${bytes.size}\r\nConnection: close\r\n\r\n")
.toByteArray() + bytes
)
}
}
}
try {
val negotiation = HeartbeatReportNegotiation()
var ack = 0
val api = AgentApiClient("http://127.0.0.1:${server.localPort}")
negotiation.heartbeat(
api,
"token",
null,
emptyList(),
{ JSONObject().put("snapshotSeq", 1) },
{ ack++ },
)
negotiation.heartbeat(
api,
"token",
null,
emptyList(),
{ JSONObject().put("snapshotSeq", 1) },
{ ack++ },
)
assertEquals(3, requests.size)
assertFalse(requests[0].has("clientReport"))
assertTrue(requests[1].has("clientReport"))
assertFalse(requests[2].has("clientReport"))
assertEquals(requests[1].getString("requestId"), requests[2].getString("requestId"))
assertEquals(0, ack)
assertFalse(negotiation.acceptsReport)
} finally {
server.close()
worker.join(1000)
}
}
}
@@ -1,10 +1,43 @@
package cn.ilapage.goauto.agent
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.Test
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() {
assertTrue(PddLaunchFallback.open(true, { true }, { error("browser must not start") }))
}
@@ -1220,6 +1220,246 @@ class PddProductDetailCollectorTest {
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
fun `color discovery records only bounded aggregate evidence`() {
val colors = listOf("A色", "B色", "C色", "D色")
@@ -1857,6 +2097,7 @@ class PddProductDetailCollectorTest {
sizes: List<String> = listOf("S"),
private val colorPages: List<List<String>> = listOf(colors),
private val colorVerticalPages: List<List<String>>? = null,
private val resetHorizontalPageOnVerticalSwipe: Boolean = false,
private val sizePages: List<List<String>> = listOf(sizes),
private val prices: Map<String, Long> = colors.associateWith { 1000L },
private val failedClicks: Set<String> = emptySet(),
@@ -1867,6 +2108,7 @@ class PddProductDetailCollectorTest {
private val rowSize: Int = 4,
private val hideSelectedColorOption: Boolean = false,
private val hideDimensionHeadingsAfterFirstVerticalPage: Boolean = false,
private val hideColorHeadingAfterFirstVerticalPage: Boolean = false,
private val soldOutUntilPulls: Int = 0,
private val startWithPanelClosed: Boolean = false,
private val firstEntryOpensReview: Boolean = false,
@@ -1986,7 +2228,7 @@ class PddProductDetailCollectorTest {
?: colorPages[horizontalPage.coerceAtMost(colorPages.lastIndex)]
val colorRowCount = (visibleColors.size + rowSize - 1) / rowSize
val sizeHeadingTop = maxOf(700, 470 + colorRowCount * 90 + 20)
if (!continuationPage && !panelCollapsedNow) {
if (!continuationPage && !panelCollapsedNow && !(hideColorHeadingAfterFirstVerticalPage && verticalPage > 0)) {
nodes += node("scroll/color-heading", "颜色分类", 20, 400, 300, 450, parentPath = "scroll")
}
if (!continuationPage && !hideColorNow && !panelCollapsedNow) {
@@ -2118,8 +2360,11 @@ class PddProductDetailCollectorTest {
if ((direction == SwipeDirection.UP || direction == SwipeDirection.DOWN) && !verticalSwipeSucceeds) return false
when (direction) {
SwipeDirection.LEFT -> horizontalPage = (horizontalPage + 1).coerceAtMost(colorPages.lastIndex)
SwipeDirection.UP -> verticalPage = (verticalPage + 1)
.coerceAtMost(maxOf(sizePages.lastIndex, colorVerticalPages?.lastIndex ?: 0))
SwipeDirection.UP -> {
verticalPage = (verticalPage + 1)
.coerceAtMost(maxOf(sizePages.lastIndex, colorVerticalPages?.lastIndex ?: 0))
if (resetHorizontalPageOnVerticalSwipe) horizontalPage = 0
}
SwipeDirection.DOWN -> {
verticalPage = (verticalPage - 1).coerceAtLeast(0)
downSwipeCount++
@@ -0,0 +1,241 @@
package cn.ilapage.goauto.agent
import cn.ilapage.goauto.agent.automation.*
import org.junit.Assert.*
import org.junit.Test
/** Final capture only; derived from the existing #331/#332 bounded purchase-sheet fixtures. */
class PurchaseFinalSpecConfirmationTest {
@Test fun `bracket display variants and offscreen color are proven by full panel summary`() {
val snapshot = sheet("已选 [香槟]RW圆领 L码[105-115斤]", colors = listOf("其他颜色"), sizes = listOf("L码[105-115斤]"))
.selected("size/o0/t")
accepts(snapshot, "【香槟】RW圆领", "L码【105-115斤】")
}
@Test fun `summary above scroll area is accepted for scrollable and non scrollable sheets`() {
for (scrollable in listOf(true, false)) {
accepts(sheet("已选 黑色 XL", scrollable = scrollable))
}
}
@Test fun `checked only exact option and physical parent child duplicates provide selection proof`() {
accepts(sheet(null).selected("color/o0/img", checked = true).selected("size/o0/t", checked = true))
}
@Test fun `selected other color or size overrides matching summary and positive target`() {
rejects(sheet("已选 黑色 XL", colors = listOf("黑色", "白色")).selected("color/o0").selected("color/o1"))
rejects(sheet("已选 黑色 XL", sizes = listOf("XL", "L")).selected("size/o1/t"))
}
@Test fun `selected bracket variants are not a conflict`() {
accepts(sheet("已选 [白色] XL", colors = listOf("[白色]")).selected("color/o0").selected("size/o0/t"), "【白色】")
}
@Test fun `distinct physical identical or normalized target options are collisions`() {
for (colors in listOf(listOf("黑色", "黑色"), listOf("【白色】", "[白色]"))) {
rejects(sheet("已选 ${colors[0]} XL", colors = colors).selected("color/o0"), colors[0])
}
}
@Test fun `explicit unavailable target cannot be rescued by summary`() {
rejects(sheet("已选 黑色 XL").mapNodes { if (it.path.contains("color/o0")) it.copy(enabled = false) else it })
}
@Test fun `all required selected proofs take priority over a single non proving summary`() {
accepts(sheet("已选 其他颜色 其他尺码").selected("color/o0/img").selected("size/o0/t"))
rejects(sheet("已选 白色 L", colors = listOf("黑色", "白色"), sizes = listOf("XL", "L"))
.selected("color/o0/img").selected("size/o0/t")) // Known alternative composition is a conflict.
}
@Test fun `selected options retain established parser separation of trailing display price`() {
accepts(sheet("已选 黑色 XL", colors = listOf("黑色 ¥19.60"), sizes = listOf("XL ¥19.60"))
.selected("color/o0/img").selected("size/o0/t"))
rejects(sheet("已选 黑色 XL", colors = listOf("黑色加绒 ¥19.60")).selected("color/o0/img"))
rejects(sheet("已选 黑色 XL", colors = listOf("黑色 ¥19.60", "黑色 ¥20.00")).selected("color/o0/img"))
}
@Test fun `unselected visible options alone and unrelated title cannot prove selection`() {
rejects(sheet(null).addText("r/title", "示例黑色 XL 商品"))
}
@Test fun `panel external prefixed summary is ignored even at same coordinates`() {
rejects(sheet(null).addText("r/fake", "已选 黑色 XL"))
accepts(sheet("已选 黑色 XL").addText("r/fake", "已选 白色 L"))
}
@Test fun `structural ancestry overrides textual path prefix`() {
rejects(sheet(null).addText("r/sheet/fake", "已选 黑色 XL", parent = "r"))
}
@Test fun `whole summary rejects substring sizes colors extra dimensions quantity and unsupported separators`() {
for (summary in listOf("已选 黑色 XL", "已选 黑色 加绒 L", "已选 黑色 L 2件", "已选 黑色,L", "已选 L 黑色")) {
rejects(sheet(summary, sizes = listOf("L")), size = "L")
}
rejects(sheet("已选 [白色+黑色]超值两件装 XL", colors = emptyList()), "【白色】")
}
@Test fun `known candidates making another full composition reject ambiguous summary`() {
rejects(sheet("已选 黑色 加绒 XL", colors = listOf("黑色", "黑色 加绒"), sizes = listOf("加绒 XL", "XL")), size = "加绒 XL")
rejects(sheet("已选 黑色 加绒 XL", colors = listOf("黑色", "黑色 加绒"), sizes = listOf("加绒 XL", "XL")), color = "黑色 加绒")
}
@Test fun `summary permits invisible color despite another unselected visible color`() {
accepts(sheet("已选 黑色 XL", colors = listOf("白色")))
}
@Test fun `required single dimension must equal whole summary and empty pair is never proof`() {
accepts(sheet("已选 黑色"), size = "")
accepts(sheet("已选 XL"), color = "")
rejects(sheet("已选 黑色 XL"), size = "")
rejects(sheet("已选 黑色 XL"), color = "")
rejects(sheet("已选 黑色 XL"), color = " ", size = "")
}
@Test fun `anchored selected prefix variants preserve internal colons commas and whitespace`() {
for (prefix in listOf("已选", "已选 ", "已选:", "已选: ", " 已选: ")) {
accepts(sheet("$prefix 黑色:加绒,款 XL", colors = emptyList()), color = "黑色:加绒,款")
}
accepts(sheet("已选 黑色 加绒 XL", colors = emptyList()), color = "黑色 加绒")
rejects(sheet("已选 黑色 加绒 XL", colors = emptyList()), color = "黑色加绒")
rejects(sheet("已选 黑色:加绒 XL", colors = emptyList()), color = "黑色加绒")
rejects(sheet("已选::黑色 XL"))
accepts(sheet("已选::黑色 XL", colors = emptyList()), color = ":黑色")
}
@Test fun `conflicting scoped summaries reject even both exact options selected`() {
rejects(sheet("已选 黑色 XL").selected("color/o0").selected("size/o0/t").addText("r/sheet/second", "已选 白色 L"))
accepts(sheet("已选 黑色 XL").addText("r/sheet/second", "已选:黑色 XL"))
}
@Test fun `display normalization is narrow and content preserving`() {
accepts(sheet("已选 [白色](加绒) RW12 XL", colors = emptyList()), color = "〔白色〕(加绒) RW12")
rejects(sheet("已选 白色 RW12 XL", colors = emptyList()), color = "白色 rw12")
rejects(sheet("已选 白色 1 XL", colors = emptyList()), color = "白色 ①")
rejects(sheet("已选 白色 XL", colors = emptyList()), color = "白色 ¥19.6")
}
@Test fun `existing structurally recognized unprefixed summary is usable`() {
accepts(prefixlessSheet())
rejects(sheet("黑色 XL")) // No quantity/header relationship in this fixture.
rejects(sheet(null).addText("r/title", "黑色 XL"))
rejects(prefixlessSheet().addText("r/sheet/body/info/second", "黑色 L"))
}
@Test fun `same dimension groups retain earlier selected conflicts and physical candidates`() {
val base = sheet("已选 黑色 XL").addText("r/sheet/body/list/extra/h", "颜色分类")
.addText("r/sheet/body/list/extra/o", "白色")
.mapNodes { when (it.path) {
"r/sheet/body/list/extra/h" -> it.copy(bounds = NodeBounds(20, 1000, 500, 1040), parentPath = "r/sheet/body/list")
"r/sheet/body/list/extra/o" -> it.copy(bounds = NodeBounds(20, 1045, 500, 1100), parentPath = "r/sheet/body/list", clickable = true, selected = true)
"r/sheet/body/list" -> it.copy(bounds = NodeBounds(0, 980, 1080, 2036))
else -> it
} }
rejects(base)
rejects(base.mapNodes { if (it.path.endsWith("extra/o")) it.copy(text = "黑色") else it })
}
@Test fun `collector default still collapses same raw labels and leaves summary behavior unchanged`() {
val snapshot = sheet("已选 黑色 XL", colors = listOf("黑色", "黑色"))
val config = PurchaseRehearsalExecutor.DEFAULT_COLLECTOR
val regular = PddScreenParser.parse(snapshot, config, "374", null)
val final = PddScreenParser.parse(snapshot, config, "374", null, forFinalConfirmation = true)
assertEquals(1, regular.dimensions.single { it.key == "color" }.values.size)
assertEquals(2, final.dimensions.single { it.key == "color" }.values.size)
assertEquals(regular.selectedSummary, final.selectedSummary)
assertTrue(regular.finalSelectionSummaries.isEmpty())
}
@Test fun `independently bounded selected options survive absent full panel boundary`() {
val snapshot = sheet(null).selected("color/o0/img").selected("size/o0/t")
.mapNodes { if (it.path == "r/sheet") it.copy(bounds = NodeBounds(0, 0, 1080, 2216)) else it }
PurchaseFinalSpecVerifier.verify(snapshot, input()) { fail(it) }
val noSelections = snapshot.mapNodes { it.copy(selected = false, checked = false) }
.addText("r/sheet/fake", "已选 黑色 XL")
try {
PurchaseFinalSpecVerifier.verify(noSelections, input()) {}
fail("unproven full panel must not supply summary")
} catch (failure: PurchaseLiveException) { assertEquals("PURCHASE_SPEC_NOT_MATCHED", failure.code) }
}
@Test fun `price quantity and address checks remain enforced`() {
rejects(sheet("已选 黑色 XL"), expectedCode = "PURCHASE_PRICE_OUT_OF_RANGE", input = input().copy(maxUnitPriceCent = 1))
rejects(sheet("已选 黑色 XL"), expectedCode = "PURCHASE_QUANTITY_MISMATCH", input = input().copy(quantity = 2))
rejects(sheet("已选 黑色 XL").mapNodes { if (it.path == "r/sheet/body/addr/a/detail") it.copy(text = "示例地址") else it }, expectedCode = "PURCHASE_ADDRESS_UPDATE_FAILED")
}
private fun sheet(
summary: String?, colors: List<String> = listOf("黑色"), sizes: List<String> = listOf("XL"), scrollable: Boolean = true,
): UiSnapshot = SpecPanelFixtures.sheet(SpecPanelFixtures.Sheet(colorLabels = colors, sizeLabels = sizes, listScrollable = scrollable))
.mapNodes { when (it.path) {
"r/sheet/body/summary" -> it.copy(text = summary, visible = summary != null)
"r/sheet/body/addr/a/detail" -> it.copy(text = ADDRESS)
else -> it
} }
/** Reuses the info-above-list shape from PddProductDetailCollectorTest.prefixlessPanel. */
private fun prefixlessSheet(): UiSnapshot {
val base = sheet("黑色 XL")
val info = base.nodes.single { it.path == "r/sheet/body/qty" }.copy(
path = "r/sheet/body/info", bounds = NodeBounds(0, 770, 1080, 1000), parentPath = "r/sheet/body",
)
return base.mapNodes { when {
it.path == "r/sheet/body/summary" -> it.copy(path = "r/sheet/body/info/summary", parentPath = info.path)
it.path == "r/sheet/body/qty" -> it.copy(path = "r/sheet/body/info/qty", parentPath = info.path)
it.path.startsWith("r/sheet/body/qty/") -> it.copy(path = it.path.replace("body/qty/", "body/info/qty/"), parentPath = "r/sheet/body/info/qty")
else -> it
} }.let { it.copy(nodes = it.nodes + info) }
}
private fun UiSnapshot.mapNodes(transform: (SnapshotNode) -> SnapshotNode) = copy(nodes = nodes.map(transform))
private fun UiSnapshot.selected(suffix: String, checked: Boolean = false) = mapNodes {
if (it.path.endsWith(suffix)) it.copy(selected = !checked, checked = checked) else it
}
private fun UiSnapshot.addText(path: String, text: String, parent: String = path.substringBeforeLast('/')) = copy(nodes = nodes + SnapshotNode(
path, parent, text, null, null, "android.widget.TextView", NodeBounds(384, 791, 1068, 852),
false, false, false, false, true, true,
))
private fun input(color: String = "黑色", size: String = "XL") = PurchaseExecutionInput(
taskId = 374, executionMode = "live", phase = "purchase", url = "https://mobile.yangkeduo.com/goods.html?goods_id=374",
goodsId = "374", mappedColor = color, mappedSize = size, quantity = 1,
minUnitPriceCent = 1, maxUnitPriceCent = 3000, addressSuffix = "_cg374",
)
private fun check(snapshot: UiSnapshot, input: PurchaseExecutionInput, diagnostics: MutableList<String> = mutableListOf()): FinalConfirmationEvidence {
val driver = object : PurchaseUiDriver {
override fun capture() = snapshot
override fun clickFresh(target: SnapshotNode): FreshActionResult = error("final confirmation must be read only")
override fun tapPurchaseFresh(target: SnapshotNode): FreshActionResult = error("unexpected tap")
override fun inputFresh(target: SnapshotNode, value: String): FreshActionResult = error("unexpected input")
override fun swipePurchase(direction: SwipeDirection, durationMs: Long): Boolean = error("unexpected swipe")
override fun swipePurchaseIn(target: SnapshotNode, direction: SwipeDirection, durationMs: Long): Boolean = error("unexpected scoped swipe")
override fun backPurchase(): Boolean = error("unexpected back")
}
return PurchaseLiveAutomation(driver, pause = { error("unexpected pause") }, panelDiagnostic = diagnostics::add)
.finalConfirmation(input, ShippingAddressProof(ADDRESS, "_cg374"))
}
private fun accepts(snapshot: UiSnapshot, color: String = "黑色", size: String = "XL") {
val diagnostics = mutableListOf<String>()
val result = try { check(snapshot, input(color, size), diagnostics) } catch (failure: PurchaseLiveException) {
throw AssertionError("${failure.code}: ${diagnostics.lastOrNull()}", failure)
}
assertEquals(color, result.mappedColor)
}
private fun rejects(snapshot: UiSnapshot, color: String = "黑色", size: String = "XL", expectedCode: String = "PURCHASE_SPEC_NOT_MATCHED", input: PurchaseExecutionInput = input(color, size)) {
val diagnostics = mutableListOf<String>()
try {
check(snapshot, input, diagnostics)
fail("expected $expectedCode")
} catch (failure: PurchaseLiveException) {
assertEquals(expectedCode, failure.code)
if (expectedCode == "PURCHASE_SPEC_NOT_MATCHED") {
val detail = diagnostics.last()
assertTrue(detail, detail.matches(Regex("finalSpec;dimension=(color|size|none);stage=[a-z_]+;summaryPresent=[01];selectedFound=[01];conflict=[01]")))
assertTrue(failure.message.orEmpty(), failure.message.orEmpty().contains(detail))
assertFalse(failure.message.orEmpty().contains(ADDRESS))
assertFalse(failure.message.orEmpty().contains("黑色"))
assertFalse(failure.message.orEmpty().contains("XL"))
}
}
}
private companion object { const val ADDRESS = "示例区示例路374号_cg374" }
}
@@ -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,83 @@
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 `terminal conflict does not block later pending result`() {
val submitted = mutableListOf<Long>()
val uploaded = mutableListOf<Long>()
val result = runCatching {
PurchaseOutboxUploader(
{ listOf(item, item.copy(id = 2, taskId = 43)) },
{ submitted += it.id; if (it.id == 1L) throw cn.ilapage.goauto.agent.network.AgentApiException(409, "PURCHASE_STATE_CONFLICT", "untrusted", false) },
{ uploaded += it.id },
markRejected = { _, _ -> },
).flush()
}
assertTrue(result.isSuccess)
assertEquals(listOf(1L, 2L), submitted)
assertEquals(listOf(2L), uploaded)
}
@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)
}
@Test fun `only whitelisted 409 is terminal independent of retryable`() {
for (code in listOf("PURCHASE_STATE_CONFLICT", "PURCHASE_LEASE_EXPIRED", "PURCHASE_RESULT_CONFLICT", "OTHER")) {
for (status in listOf(409, 401, 403, 500)) for (retryable in listOf(false, true)) {
val events = mutableListOf<String>()
val result = runCatching {
PurchaseOutboxUploader({ listOf(item) },
{ throw cn.ilapage.goauto.agent.network.AgentApiException(status, code, "secret URL", retryable) },
{ events += "uploaded" }, { events += "handoff" },
{ _, fixedCode -> events += fixedCode },
).flush()
}
val rejected = status == 409 && code != "OTHER"
assertEquals(rejected, result.isSuccess)
assertEquals(if (rejected) listOf(code) else emptyList<String>(), events)
}
}
}
@Test fun `network and rejected persistence failures retain pending and stop flush`() {
for (diskFailure in listOf(false, true)) {
val events = mutableListOf<String>()
val result = runCatching {
PurchaseOutboxUploader({ listOf(item, item.copy(id = 2)) },
{ events += "submit"; if (diskFailure) throw cn.ilapage.goauto.agent.network.AgentApiException(409, "PURCHASE_STATE_CONFLICT", "raw", false) else error("network") },
{ events += "uploaded" }, { events += "handoff" },
{ _, _ -> events += "reject"; error("disk") },
).flush()
}
assertTrue(result.isFailure)
assertEquals(if (diskFailure) listOf("submit", "reject") else listOf("submit"), events)
}
}
}
@@ -0,0 +1,54 @@
package cn.ilapage.goauto.agent
import cn.ilapage.goauto.agent.network.AgentApiClient
import cn.ilapage.goauto.agent.network.AgentApiException
import cn.ilapage.goauto.agent.persistence.PendingPurchaseOutbox
import cn.ilapage.goauto.agent.persistence.PurchaseOutboxUploader
import java.net.ServerSocket
import java.util.concurrent.Executors
import java.util.concurrent.TimeUnit
import org.junit.Assert.*
import org.junit.Test
class PurchaseOutboxHttpTest {
@Test fun `missing retryable is not evidence that an unknown 409 is terminal`() {
for (code in listOf("PURCHASE_STATE_CONFLICT", "PURCHASE_LEASE_EXPIRED", "PURCHASE_RESULT_CONFLICT", "UNKNOWN_CONFLICT")) {
ServerSocket(0).use { server ->
server.soTimeout = 3000
val executor = Executors.newSingleThreadExecutor()
val serving = executor.submit {
server.accept().use { socket ->
socket.soTimeout = 3000
val reader = socket.getInputStream().bufferedReader()
var contentLength = 0
while (true) {
val line = reader.readLine() ?: break
if (line.isEmpty()) break
if (line.startsWith("Content-Length:", true)) contentLength = line.substringAfter(':').trim().toInt()
}
repeat(contentLength) { check(reader.read() >= 0) }
val response = """{"code":"$code","message":"untrusted diagnostic"}"""
socket.getOutputStream().write(("HTTP/1.1 409 Conflict\r\nContent-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}")
var pending = true
val result = runCatching {
PurchaseOutboxUploader(
{ listOf(PendingPurchaseOutbox(1,42,"probe","request","{}")) },
{ api.submitPurchaseResult(it.taskId,it.payloadJson,"synthetic") },
{ fail("409 is never uploaded") },
{ fail("409 is never a handoff") },
{ _, _ -> pending = false },
).flush()
}
assertEquals(code == "UNKNOWN_CONFLICT", pending)
assertEquals(code != "UNKNOWN_CONFLICT", result.isSuccess)
if (pending) assertFalse((result.exceptionOrNull() as AgentApiException).retryable)
serving.get(5, TimeUnit.SECONDS)
} finally { executor.shutdownNow() }
}
}
}
}
@@ -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))
}
}
@@ -12,6 +12,8 @@ import cn.ilapage.goauto.agent.automation.PurchaseSpecGesturePolicy
import cn.ilapage.goauto.agent.automation.PurchaseSwipeOutcome
import cn.ilapage.goauto.agent.automation.PurchaseSwipeResult
import cn.ilapage.goauto.agent.automation.PurchaseUiDriver
import cn.ilapage.goauto.agent.automation.PddLaunchPath
import cn.ilapage.goauto.agent.persistence.AgentDiagnosticStage
import cn.ilapage.goauto.agent.automation.RuleValidationException
import cn.ilapage.goauto.agent.automation.SnapshotNode
import cn.ilapage.goauto.agent.automation.SwipeDirection
@@ -24,13 +26,197 @@ import org.junit.Assert.assertTrue
import org.junit.Test
class PurchaseRehearsalExecutorTest {
@Test
fun `slow first browser lookup cannot compress the actual retry interval`() {
var now = 0L
val clickedAt = mutableListOf<Long>()
val underlying = FakePurchaseDriver().apply { browser = true }
val driver = object : PurchaseUiDriver by underlying {
override fun clickNavigationFresh(target: SnapshotNode, allowed: () -> Boolean): FreshClickOutcome {
if (clickedAt.isEmpty()) now += 900
if (allowed()) clickedAt += now
return FreshClickOutcome(FreshActionResult.FAILED, FreshClickReason.ACTION_CLICK_FALSE)
}
}
val outcome = PurchaseRehearsalExecutor(driver, { true }, { error("probe must not run") },
pause = { now += it }, acceptedLaunchPath = { PddLaunchPath.BROWSER }, monotonicNow = { now },
).execute(input().copy(phase = "spec_probe"), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals("PDD_DETAIL_ENTRY_FAILED", outcome.errorCode)
assertTrue(clickedAt.size > 1)
assertTrue(clickedAt.zipWithNext().all { (a, b) -> b - a >= 1_000 })
assertTrue(clickedAt.all { it < 30_000 })
}
@Test
fun `unapproved package with matching open button is never clicked`() {
var now = 0L
val underlying = FakePurchaseDriver().apply { browser = true }
val driver = object : PurchaseUiDriver by underlying {
override fun capture() = underlying.capture().copy(packageName = "other.application")
}
val outcome = PurchaseRehearsalExecutor(driver, { true }, { error("probe must not run") },
pause = { now += it }, acceptedLaunchPath = { PddLaunchPath.BROWSER }, monotonicNow = { now },
).execute(input().copy(phase = "spec_probe"), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals("PDD_DETAIL_ENTRY_FAILED", outcome.errorCode)
assertTrue(underlying.clicked.isEmpty())
}
@Test
fun `launch dispatch consumes the navigation deadline before first capture`() {
var now = 0L
var captures = 0
val underlying = FakePurchaseDriver()
val driver = object : PurchaseUiDriver by underlying {
override fun capture(): UiSnapshot { captures++; return underlying.capture() }
}
val outcome = PurchaseRehearsalExecutor(driver, { now = 30_000; true }, { error("probe must not run") },
pause = { now += it }, acceptedLaunchPath = { PddLaunchPath.DIRECT }, monotonicNow = { now },
).execute(input().copy(phase = "spec_probe"), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals("PDD_DETAIL_ENTRY_FAILED", outcome.errorCode)
assertEquals(0, captures)
assertTrue(underlying.clicked.isEmpty())
}
@Test
fun `handoff invalidated during fresh capture reopens instead of reusing`() {
var now = 29_999L
var opens = 0
val underlying = FakePurchaseDriver()
val driver = object : PurchaseUiDriver by underlying {
override fun capture(): UiSnapshot { now = 30_000; return underlying.capture() }
}
val outcome = PurchaseRehearsalExecutor(driver, { opens++; false }, { null }, pause = {},
monotonicNow = { now }, confirmProbeHandoff = { now < 30_000 },
).execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals(1, opens)
assertEquals("PDD_LINK_INVALID", outcome.errorCode)
assertTrue(underlying.clicked.isEmpty())
}
@Test
fun `input reuse flag defaults false`() {
val task = PurchaseExecutionInput(42, "rehearsal", "purchase", input().url, input().goodsId,
"黑色", "XL", quantity = 2, minUnitPriceCent = 1000, maxUnitPriceCent = 3000)
assertFalse(task.reuseProbeProduct)
}
@Test
fun `diag3 timeline waits for browser and returned product buffer before any spec action`() {
var now = 921L
var probeAt = -1L
val underlying = FakePurchaseDriver()
val clicksAt = mutableListOf<Long>()
val browserSnapshot = FakePurchaseDriver().apply { browser = true }.capture()
val driver = object : PurchaseUiDriver by underlying {
override fun capture(): UiSnapshot {
if (now in 1_739 until 12_189) return browserSnapshot
val current = underlying.capture()
return if (now < 13_218) current else current.copy(nodes = current.nodes.map {
if (it.path == "title") it.copy(text = "另一件商品标题文本") else it
})
}
override fun clickNavigationFresh(target: SnapshotNode, allowed: () -> Boolean): FreshClickOutcome {
assertTrue(now in 1_739 until 12_189)
clicksAt += now
return FreshClickOutcome(FreshActionResult.SUCCESS, FreshClickReason.SUCCESS)
}
override fun clickFreshDetailed(target: SnapshotNode): FreshClickOutcome {
assertTrue("spec action at $now", now >= 15_200)
return underlying.clickFreshDetailed(target)
}
}
val outcome = PurchaseRehearsalExecutor(driver, { true }, { probeAt = now; "{}" },
pause = { now += it }, acceptedLaunchPath = { PddLaunchPath.BROWSER }, monotonicNow = { now },
).execute(input().copy(phase = "spec_probe", reuseProbeProduct = false), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals("spec_probe_completed", outcome.resultType)
assertTrue(probeAt >= 15_200)
assertTrue(clicksAt.zipWithNext().all { (a, b) -> b - a >= 1_000 })
}
@Test
fun `slow capture crossing deadline cannot click browser or enter specs`() {
var now = 0L
val underlying = FakePurchaseDriver().apply { browser = true }
val driver = object : PurchaseUiDriver by underlying {
override fun capture(): UiSnapshot { now = 30_000; return underlying.capture() }
}
val outcome = PurchaseRehearsalExecutor(driver, { true }, { error("probe must not run") },
pause = { now += it }, acceptedLaunchPath = { PddLaunchPath.BROWSER }, monotonicNow = { now },
).execute(input().copy(phase = "spec_probe"), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals("PDD_DETAIL_ENTRY_FAILED", outcome.errorCode)
assertTrue(underlying.clicked.isEmpty())
}
@Test
fun `fresh browser node lookup crossing deadline declines action`() {
var now = 0L
val underlying = FakePurchaseDriver().apply { browser = true }
val driver = object : PurchaseUiDriver by underlying {
override fun clickNavigationFresh(target: SnapshotNode, allowed: () -> Boolean): FreshClickOutcome {
now = 30_000
assertFalse(allowed())
return FreshClickOutcome(FreshActionResult.FAILED, FreshClickReason.UNKNOWN)
}
}
val outcome = PurchaseRehearsalExecutor(driver, { true }, { error("probe must not run") },
pause = { now += it }, acceptedLaunchPath = { PddLaunchPath.BROWSER }, monotonicNow = { now },
).execute(input().copy(phase = "spec_probe"), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals("PDD_DETAIL_ENTRY_FAILED", outcome.errorCode)
assertTrue(underlying.clicked.isEmpty())
}
@Test
fun `direct accepted launch buffers same package product without empty frame`() {
var now = 0L
var probeAt = -1L
val driver = FakePurchaseDriver()
val outcome = PurchaseRehearsalExecutor(driver, { true }, { probeAt = now; "{}" },
pause = { now += it }, acceptedLaunchPath = { PddLaunchPath.DIRECT }, monotonicNow = { now },
).execute(input().copy(phase = "spec_probe"), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals("spec_probe_completed", outcome.resultType)
assertTrue(probeAt >= 3_000)
assertEquals(0, driver.openClickCount)
}
@Test
fun `browser launch without observed browser times out on stable old pdd`() {
var now = 0L
val driver = FakePurchaseDriver()
val outcome = PurchaseRehearsalExecutor(driver, { true }, { error("probe must not run") },
pause = { now += it }, acceptedLaunchPath = { PddLaunchPath.BROWSER }, monotonicNow = { now },
).execute(input().copy(phase = "spec_probe"), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals("PDD_DETAIL_ENTRY_FAILED", outcome.errorCode)
assertEquals(30_000L, now)
assertTrue(driver.clicked.isEmpty())
}
@Test
fun `untrusted current product does not bypass navigation`() {
var opens = 0
val outcome = executor(FakePurchaseDriver(), { opens++; false }, { null }, pause = {})
.execute(input().copy(reuseProbeProduct = false), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals(1, opens)
assertEquals("PDD_LINK_INVALID", outcome.errorCode)
}
@Test
fun `old pdd product without accepted launch evidence times out before probe`() {
var probes = 0
val driver = FakePurchaseDriver()
val outcome = executor(driver, { true }, { probes++; "{}" }, pause = {}, path = null)
.execute(input().copy(phase = "spec_probe"), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals("PDD_DETAIL_ENTRY_FAILED", outcome.errorCode)
assertEquals(0, probes)
assertTrue(driver.clicked.isEmpty())
}
@Test
fun `probe skips post open swipes but preserves wait and records click`() {
val driver = FakePurchaseDriver()
val pauses = mutableListOf<Long>()
val clicks = mutableListOf<FreshClickOutcome>()
val configured = rule().replace("\"type\":\"openSpecPanel\"", "\"type\":\"openSpecPanel\",\"waitAfterMs\":1234")
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { "{}" },
val outcome = executor(driver, { driver.browser = true; true }, { "{}" },
pause = pauses::add, probeClickDiagnostic = { _, _, click -> clicks += click },
).execute(input().copy(phase = "spec_probe"), PurchaseRuleParser.parse(configured), PurchaseAgentCapabilities.supported)
assertEquals("spec_probe_completed", outcome.resultType)
@@ -43,7 +229,7 @@ class PurchaseRehearsalExecutorTest {
fun `blocked entry stops without gesture or probe`() {
val driver = FakePurchaseDriver(forcedEntryClickReason = FreshClickReason.SIZE_ADVICE_BLOCKED)
var probes = 0
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { probes++; "{}" }, pause = {})
val outcome = executor(driver, { driver.browser = true; true }, { probes++; "{}" }, pause = {})
.execute(input().copy(phase = "spec_probe"), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals("SIZE_ADVICE_CLICK_BLOCKED", outcome.errorCode)
assertEquals(0, driver.specTapCount)
@@ -53,7 +239,7 @@ class PurchaseRehearsalExecutorTest {
@Test
fun `blocked size stops without gesture retry`() {
val driver = FakePurchaseDriver(forcedSizeClickReason = FreshClickReason.SIZE_ADVICE_BLOCKED)
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
val outcome = executor(driver, { driver.browser = true; true }, { null }, pause = {})
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals("SIZE_ADVICE_CLICK_BLOCKED", outcome.errorCode)
assertEquals(0, driver.specTapCount)
@@ -64,7 +250,7 @@ class PurchaseRehearsalExecutorTest {
fun `blocked gesture stops without another click`() {
val driver = FakePurchaseDriver(forcedEntryClickReason = FreshClickReason.ACTION_CLICK_FALSE, specTapResult = FreshActionResult.BLOCKED)
var probes = 0
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { probes++; "{}" }, pause = {})
val outcome = executor(driver, { driver.browser = true; true }, { probes++; "{}" }, pause = {})
.execute(input().copy(phase = "spec_probe"), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals("SIZE_ADVICE_CLICK_BLOCKED", outcome.errorCode)
assertEquals(1, driver.specTapCount)
@@ -74,7 +260,7 @@ class PurchaseRehearsalExecutorTest {
@Test
fun `purchase still fails required post open swipe`() {
val driver = FakePurchaseDriver(purchaseSwipeSucceeds = false)
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
val outcome = executor(driver, { driver.browser = true; true }, { null }, pause = {})
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals("RULE_ACTION_FAILED", outcome.errorCode)
assertEquals(1, driver.swipeCount)
@@ -83,7 +269,7 @@ class PurchaseRehearsalExecutorTest {
@Test
fun `color selection then single size heading completes without selecting color again`() {
val driver = FakePurchaseDriver(prefixlessSingleHeadingAfterColor = true)
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
val outcome = executor(driver, { driver.browser = true; true }, { null }, pause = {})
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals(outcome.message, "rehearsal_completed", outcome.resultType)
assertEquals(1, driver.clicked.count { it == "黑色" })
@@ -93,7 +279,7 @@ class PurchaseRehearsalExecutorTest {
fun `full exact summary confirms size after option leaves viewport`() {
val target = "2XL 建议131到150斤"
val driver = FakePurchaseDriver(sizes = listOf(target), hideSizeAfterSelection = true)
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
val outcome = executor(driver, { driver.browser = true; true }, { null }, pause = {})
.execute(input().copy(mappedSize = target), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals(outcome.message, "rehearsal_completed", outcome.resultType)
assertEquals(1, driver.clicked.count { it == target })
@@ -106,7 +292,7 @@ class PurchaseRehearsalExecutorTest {
sizes = listOf(target), hideSizeAfterSelection = true,
selectedSizeSummaryOverride = "2XL 建议151到170斤",
)
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
val outcome = executor(driver, { driver.browser = true; true }, { null }, pause = {})
.execute(input().copy(mappedSize = target), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals("failed", outcome.resultType)
}
@@ -160,7 +346,7 @@ class PurchaseRehearsalExecutorTest {
val driver = FakePurchaseDriver()
val pauses = mutableListOf<Long>()
var openCount = 0
val outcome = PurchaseRehearsalExecutor(
val outcome = executor(
driver,
openLink = { openCount++; driver.browser = true; true },
probeSpecs = { null },
@@ -181,7 +367,7 @@ class PurchaseRehearsalExecutorTest {
fun `manual purchase retry from agent reopens the task product url`() {
val driver = FakePurchaseDriver(initiallyInAgent = true)
var openCount = 0
val outcome = PurchaseRehearsalExecutor(
val outcome = executor(
driver,
openLink = {
openCount++
@@ -201,7 +387,7 @@ class PurchaseRehearsalExecutorTest {
fun `purchase phase reopens when pdd foreground has no product evidence`() {
val driver = FakePurchaseDriver(loadingPddCaptures = 1)
var openCount = 0
val outcome = PurchaseRehearsalExecutor(
val outcome = executor(
driver,
openLink = { openCount++; driver.browser = true; true },
probeSpecs = { null },
@@ -217,7 +403,7 @@ class PurchaseRehearsalExecutorTest {
fun `purchase phase does not reuse a pdd login page`() {
val driver = FakePurchaseDriver(pddProblemLabels = listOf("手机号登录", "登录后继续"))
var openCount = 0
val outcome = PurchaseRehearsalExecutor(
val outcome = executor(
driver,
openLink = { openCount++; driver.browser = true; true },
probeSpecs = { null },
@@ -231,7 +417,7 @@ class PurchaseRehearsalExecutorTest {
@Test
fun `price range failure reports the observed unit price`() {
val driver = FakePurchaseDriver()
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
val outcome = executor(driver, { driver.browser = true; true }, { null }, pause = {})
.execute(input().copy(maxUnitPriceCent = 1_999), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals("PURCHASE_PRICE_OUT_OF_RANGE", outcome.errorCode)
@@ -246,7 +432,7 @@ class PurchaseRehearsalExecutorTest {
"\"purchase.rehearsal.v1\",\"purchase.spec-probe.v1\"",
"\"purchase.rehearsal.v1\",\"purchase.spec-probe.v1\",\"purchase.future.v1\"",
)
val outcome = PurchaseRehearsalExecutor(driver, { openCount++; true }, { null }, pause = {})
val outcome = executor(driver, { openCount++; true }, { null }, pause = {})
.execute(input(), PurchaseRuleParser.parse(unsupported), PurchaseAgentCapabilities.supported)
assertEquals("AGENT_CAPABILITY_MISMATCH", outcome.errorCode)
assertEquals(0, openCount)
@@ -256,7 +442,7 @@ class PurchaseRehearsalExecutorTest {
@Test
fun `missing exact spec returns probe and never chooses similar value`() {
val driver = FakePurchaseDriver(colors = listOf("黑色加绒"))
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { "{\"dimensions\":[]}" }, pause = {})
val outcome = executor(driver, { driver.browser = true; true }, { "{\"dimensions\":[]}" }, pause = {})
.execute(input().copy(specResolutionAllowed = true), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals("spec_probe_completed", outcome.resultType)
assertEquals("{\"dimensions\":[]}", outcome.probedSpecs)
@@ -268,7 +454,7 @@ class PurchaseRehearsalExecutorTest {
fun `missing exact spec without server permission preserves the real failure`() {
val driver = FakePurchaseDriver(colors = listOf("黑色加绒"))
var probes = 0
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { probes++; "{\"dimensions\":[]}" }, pause = {})
val outcome = executor(driver, { driver.browser = true; true }, { probes++; "{\"dimensions\":[]}" }, pause = {})
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals("failed", outcome.resultType)
@@ -280,7 +466,7 @@ class PurchaseRehearsalExecutorTest {
@Test
fun `exact size below the visible panel is found after a bounded vertical search`() {
val driver = FakePurchaseDriver(hiddenSizeUntilUpSwipes = 3)
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
val outcome = executor(driver, { driver.browser = true; true }, { null }, pause = {})
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals("rehearsal_completed", outcome.resultType)
@@ -295,7 +481,7 @@ class PurchaseRehearsalExecutorTest {
val driver = FakePurchaseDriver(
horizontalColorPages = listOf(listOf("黑色", "白色"), listOf("蓝色", "富贵粉")),
)
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
val outcome = executor(driver, { driver.browser = true; true }, { null }, pause = {})
.execute(input().copy(mappedColor = "富贵粉"), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals("rehearsal_completed", outcome.resultType)
@@ -307,7 +493,7 @@ class PurchaseRehearsalExecutorTest {
@Test
fun `visible exact color keeps established path without horizontal fallback`() {
val driver = FakePurchaseDriver(colors = listOf("黑色", "白色"))
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
val outcome = executor(driver, { driver.browser = true; true }, { null }, pause = {})
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals("rehearsal_completed", outcome.resultType)
@@ -319,7 +505,7 @@ class PurchaseRehearsalExecutorTest {
val driver = FakePurchaseDriver(
horizontalColorPages = listOf(listOf("黑色"), listOf("富贵粉")),
)
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
val outcome = executor(driver, { driver.browser = true; true }, { null }, pause = {})
.execute(input().copy(mappedColor = "富贵粉"), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals("PURCHASE_SPEC_TARGET_NOT_VISIBLE", outcome.errorCode)
@@ -334,7 +520,7 @@ class PurchaseRehearsalExecutorTest {
horizontalColorPages = listOf(listOf("黑色", "白色"), listOf("蓝色", "富贵粉")),
horizontalSpecSwipeSucceeds = false,
)
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
val outcome = executor(driver, { driver.browser = true; true }, { null }, pause = {})
.execute(input().copy(mappedColor = "富贵粉"), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals("PURCHASE_SPEC_TARGET_NOT_VISIBLE", outcome.errorCode)
@@ -351,7 +537,7 @@ class PurchaseRehearsalExecutorTest {
listOf("2XL 推荐125-140斤", targetSize),
),
)
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
val outcome = executor(driver, { driver.browser = true; true }, { null }, pause = {})
.execute(input().copy(mappedSize = targetSize), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals("rehearsal_completed", outcome.resultType)
@@ -366,7 +552,7 @@ class PurchaseRehearsalExecutorTest {
val driver = FakePurchaseDriver(
horizontalSizePages = listOf(listOf("S 推荐80-95斤"), listOf(targetSize)),
)
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
val outcome = executor(driver, { driver.browser = true; true }, { null }, pause = {})
.execute(input().copy(mappedSize = targetSize), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals("rehearsal_completed", outcome.resultType)
@@ -381,7 +567,7 @@ class PurchaseRehearsalExecutorTest {
horizontalColorPages = listOf(listOf("黑色", "白色"), listOf("蓝色", "富贵粉")),
horizontalSizePages = listOf(listOf("S 推荐80-95斤", "M 推荐95-110斤"), listOf("2XL 推荐125-140斤", targetSize)),
)
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
val outcome = executor(driver, { driver.browser = true; true }, { null }, pause = {})
.execute(
input().copy(mappedColor = "富贵粉", mappedSize = targetSize),
PurchaseRuleParser.parse(rule()),
@@ -398,7 +584,7 @@ class PurchaseRehearsalExecutorTest {
fun `two column size grid continues vertically without horizontal container`() {
val target = "3XL 推荐140-155斤"
val driver = FakePurchaseDriver(sizes = listOf(target), revealGridSizeAfterUpSwipes = 7)
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
val outcome = executor(driver, { driver.browser = true; true }, { null }, pause = {})
.execute(input().copy(mappedSize = target), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals("rehearsal_completed", outcome.resultType)
assertEquals(target, driver.size)
@@ -415,7 +601,7 @@ class PurchaseRehearsalExecutorTest {
horizontalSpecSwipeSucceeds = false,
revealGridSizeAfterUpSwipes = 7,
)
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
val outcome = executor(driver, { driver.browser = true; true }, { null }, pause = {})
.execute(input().copy(mappedSize = target), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals(outcome.message, "rehearsal_completed", outcome.resultType)
assertEquals(target, driver.size)
@@ -425,7 +611,7 @@ class PurchaseRehearsalExecutorTest {
@Test
fun `missing size terminates at stable viewport with bounded gestures`() {
val driver = FakePurchaseDriver(sizes = listOf("S", "M"))
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
val outcome = executor(driver, { driver.browser = true; true }, { null }, pause = {})
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals("PURCHASE_SPEC_TARGET_NOT_VISIBLE", outcome.errorCode)
assertTrue(outcome.message.contains("reason=stableViewport"))
@@ -436,7 +622,7 @@ class PurchaseRehearsalExecutorTest {
@Test
fun `visible exact size does not enter horizontal fallback`() {
val driver = FakePurchaseDriver(sizes = listOf("L", "XL"))
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
val outcome = executor(driver, { driver.browser = true; true }, { null }, pause = {})
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals("rehearsal_completed", outcome.resultType)
@@ -449,7 +635,7 @@ class PurchaseRehearsalExecutorTest {
sizeClickResults = mutableListOf(FreshActionResult.FAILED),
sizeSelectsOnFailedClick = true,
)
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
val outcome = executor(driver, { driver.browser = true; true }, { null }, pause = {})
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals("rehearsal_completed", outcome.resultType)
@@ -463,7 +649,7 @@ class PurchaseRehearsalExecutorTest {
sizeClickResults = mutableListOf(FreshActionResult.NOT_FOUND),
specTapResult = FreshActionResult.SUCCESS,
)
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
val outcome = executor(driver, { driver.browser = true; true }, { null }, pause = {})
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals("rehearsal_completed", outcome.resultType)
@@ -475,7 +661,7 @@ class PurchaseRehearsalExecutorTest {
@Test
fun `already selected exact size is not clicked again`() {
val driver = FakePurchaseDriver(initialSize = "XL")
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
val outcome = executor(driver, { driver.browser = true; true }, { null }, pause = {})
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals("rehearsal_completed", outcome.resultType)
@@ -491,7 +677,7 @@ class PurchaseRehearsalExecutorTest {
selectedSizeSummaryOverride = "XL",
hideSizeSelectedState = true,
)
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
val outcome = executor(driver, { driver.browser = true; true }, { null }, pause = {})
.execute(input().copy(mappedSize = target), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals("rehearsal_completed", outcome.resultType)
@@ -501,7 +687,7 @@ class PurchaseRehearsalExecutorTest {
@Test
fun `final verification accepts current attempt proof when selected color scrolls out of viewport`() {
val driver = FakePurchaseDriver(hideColorAfterQuantitySet = true)
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
val outcome = executor(driver, { driver.browser = true; true }, { null }, pause = {})
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals("rehearsal_completed", outcome.resultType)
@@ -517,7 +703,7 @@ class PurchaseRehearsalExecutorTest {
selectedSizeSummaryOverride = "XL",
hideSizeAfterQuantitySet = true,
)
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
val outcome = executor(driver, { driver.browser = true; true }, { null }, pause = {})
.execute(input().copy(mappedSize = target), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals("rehearsal_completed", outcome.resultType)
@@ -530,7 +716,7 @@ class PurchaseRehearsalExecutorTest {
hideColorAfterQuantitySet = true,
hideSelectedSummaryAfterQuantitySet = true,
)
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
val outcome = executor(driver, { driver.browser = true; true }, { null }, pause = {})
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals("rehearsal_completed", outcome.resultType)
@@ -540,7 +726,7 @@ class PurchaseRehearsalExecutorTest {
@Test
fun `final verification keeps current attempt proof when selected panel becomes unclassified`() {
val driver = FakePurchaseDriver(panelBecomesUnknownAfterSizeProof = true)
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
val outcome = executor(driver, { driver.browser = true; true }, { null }, pause = {})
.execute(input().copy(quantity = 1), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals("rehearsal_completed", outcome.resultType)
@@ -555,7 +741,7 @@ class PurchaseRehearsalExecutorTest {
hideSelectedSummaryAfterQuantitySet = true,
restoreHiddenColorOnDownSwipe = true,
)
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
val outcome = executor(driver, { driver.browser = true; true }, { null }, pause = {})
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals("rehearsal_completed", outcome.resultType)
@@ -571,7 +757,7 @@ class PurchaseRehearsalExecutorTest {
hideSelectedSummaryAfterQuantitySet = true,
selectedColorOverrideAfterQuantitySet = "白色",
)
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
val outcome = executor(driver, { driver.browser = true; true }, { null }, pause = {})
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals("failed", outcome.resultType)
@@ -589,7 +775,7 @@ class PurchaseRehearsalExecutorTest {
hideSizeAfterQuantitySet = true,
selectedSummaryOverrideAfterQuantitySet = "黑色 2XL",
)
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
val outcome = executor(driver, { driver.browser = true; true }, { null }, pause = {})
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals("failed", outcome.resultType)
@@ -606,7 +792,7 @@ class PurchaseRehearsalExecutorTest {
selectedSizeSummaryOverride = "XL",
finalSizesAfterQuantitySet = listOf("XL【建议B】"),
)
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
val outcome = executor(driver, { driver.browser = true; true }, { null }, pause = {})
.execute(input().copy(mappedSize = target), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals("failed", outcome.resultType)
@@ -624,7 +810,7 @@ class PurchaseRehearsalExecutorTest {
selectedSizeSummaryOverride = "2XL",
hideSizeSelectedState = true,
)
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { "{\"dimensions\":[]}" }, pause = {})
val outcome = executor(driver, { driver.browser = true; true }, { "{\"dimensions\":[]}" }, pause = {})
.execute(input().copy(specResolutionAllowed = true), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals("failed", outcome.resultType)
@@ -639,7 +825,7 @@ class PurchaseRehearsalExecutorTest {
selectedSizeSummaryOverride = "XL",
hideSizeSelectedState = true,
)
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { "{\"dimensions\":[]}" }, pause = {})
val outcome = executor(driver, { driver.browser = true; true }, { "{\"dimensions\":[]}" }, pause = {})
.execute(input().copy(mappedSize = target), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals("failed", outcome.resultType)
@@ -651,7 +837,7 @@ class PurchaseRehearsalExecutorTest {
val driver = FakePurchaseDriver(
sizeClickResults = mutableListOf(FreshActionResult.FAILED),
)
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
val outcome = executor(driver, { driver.browser = true; true }, { null }, pause = {})
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals("PURCHASE_SPEC_CLICK_FAILED", outcome.errorCode)
@@ -672,7 +858,7 @@ class PurchaseRehearsalExecutorTest {
cases.forEach { (reason, expected) ->
val driver = FakePurchaseDriver(forcedSizeClickReason = reason)
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
val outcome = executor(driver, { driver.browser = true; true }, { null }, pause = {})
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals("PURCHASE_SPEC_CLICK_FAILED", outcome.errorCode)
@@ -684,7 +870,7 @@ class PurchaseRehearsalExecutorTest {
fun `visible unavailable exact target fails as safe target missing without probing`() {
val driver = FakePurchaseDriver(unavailableSizes = setOf("XL"))
var probes = 0
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { probes++; "{}" }, pause = {})
val outcome = executor(driver, { driver.browser = true; true }, { probes++; "{}" }, pause = {})
.execute(input().copy(specResolutionAllowed = true), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals("PURCHASE_SPEC_SAFE_TARGET_MISSING", outcome.errorCode)
@@ -697,7 +883,7 @@ class PurchaseRehearsalExecutorTest {
listOf("¥15.78", "XL ¥15.78起").forEach { unsafeTarget ->
val driver = FakePurchaseDriver()
var probes = 0
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { probes++; "{}" }, pause = {})
val outcome = executor(driver, { driver.browser = true; true }, { probes++; "{}" }, pause = {})
.execute(input().copy(mappedSize = unsafeTarget, specResolutionAllowed = true), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals("PURCHASE_SPEC_SAFE_TARGET_MISSING", outcome.errorCode)
@@ -709,7 +895,7 @@ class PurchaseRehearsalExecutorTest {
fun `legacy task target with a safe trailing price selects the clean page value`() {
val clean = "XL【建议121-140斤】"
val driver = FakePurchaseDriver(sizes = listOf(clean))
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
val outcome = executor(driver, { driver.browser = true; true }, { null }, pause = {})
.execute(input().copy(mappedSize = "$clean ¥15.78"), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals("rehearsal_completed", outcome.resultType)
@@ -719,7 +905,7 @@ class PurchaseRehearsalExecutorTest {
@Test
fun `two raw page values that normalize to one size fail as ambiguous`() {
val driver = FakePurchaseDriver(sizes = listOf("XL ¥15.78", "XL ¥16.80"))
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
val outcome = executor(driver, { driver.browser = true; true }, { null }, pause = {})
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals("failed", outcome.resultType)
@@ -730,7 +916,7 @@ class PurchaseRehearsalExecutorTest {
@Test
fun `price outside range fails before any order action`() {
val driver = FakePurchaseDriver(priceCent = 4_000)
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
val outcome = executor(driver, { driver.browser = true; true }, { null }, pause = {})
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals("PURCHASE_PRICE_OUT_OF_RANGE", outcome.errorCode)
assertFalse(driver.clicked.any { it.contains("订单") || it.contains("支付") })
@@ -739,7 +925,7 @@ class PurchaseRehearsalExecutorTest {
@Test
fun `ambiguous browser target stops safely`() {
val driver = FakePurchaseDriver(duplicateOpen = true)
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { "{}" }, pause = {})
val outcome = executor(driver, { driver.browser = true; true }, { "{}" }, pause = {})
.execute(input().copy(phase = "spec_probe"), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals("RULE_AMBIGUOUS", outcome.errorCode)
assertTrue(driver.clicked.isEmpty())
@@ -751,7 +937,7 @@ class PurchaseRehearsalExecutorTest {
openClickResults = mutableListOf(FreshActionResult.FAILED),
openPddOnFailedClick = true,
)
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { "{}" }, pause = {})
val outcome = executor(driver, { driver.browser = true; true }, { "{}" }, pause = {})
.execute(input().copy(phase = "spec_probe"), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals("spec_probe_completed", outcome.resultType)
@@ -764,7 +950,7 @@ class PurchaseRehearsalExecutorTest {
openClickResults = mutableListOf(FreshActionResult.NOT_FOUND, FreshActionResult.SUCCESS),
)
val pauses = mutableListOf<Long>()
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { "{}" }, pause = pauses::add)
val outcome = executor(driver, { driver.browser = true; true }, { "{}" }, pause = pauses::add)
.execute(input().copy(phase = "spec_probe"), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals("spec_probe_completed", outcome.resultType)
@@ -775,46 +961,46 @@ class PurchaseRehearsalExecutorTest {
@Test
fun `persistent open click failure is bounded and remains safely failed`() {
val driver = FakePurchaseDriver(
openClickResults = MutableList(20) { FreshActionResult.FAILED },
openClickResults = MutableList(40) { FreshActionResult.FAILED },
)
val pauses = mutableListOf<Long>()
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { "{}" }, pause = pauses::add)
val outcome = executor(driver, { driver.browser = true; true }, { "{}" }, pause = pauses::add)
.execute(input().copy(phase = "spec_probe"), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals("RULE_ACTION_FAILED", outcome.errorCode)
assertTrue(driver.openClickCount in 1..15)
assertEquals(150, pauses.size)
assertEquals("PDD_DETAIL_ENTRY_FAILED", outcome.errorCode)
assertEquals(30, driver.openClickCount)
assertEquals(300, pauses.size)
}
@Test
fun `five second browser interstitial can still reach a stable product page`() {
val pauses = mutableListOf<Long>()
val driver = FakePurchaseDriver(browserOpenVisibleAfterCaptures = 50)
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { "{}" }, pause = pauses::add)
val outcome = executor(driver, { driver.browser = true; true }, { "{}" }, pause = pauses::add)
.execute(input().copy(phase = "spec_probe"), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals("spec_probe_completed", outcome.resultType)
assertEquals(1, driver.openClickCount)
assertTrue(pauses.count { it == 100L } in 52..60)
assertTrue(pauses.count { it == 100L } in 82..90)
}
@Test
fun `pdd foreground without stable product evidence times out at fifteen seconds`() {
fun `pdd foreground without stable product evidence times out at thirty seconds`() {
val pauses = mutableListOf<Long>()
val driver = FakePurchaseDriver(loadingPddCaptures = 200)
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { "{}" }, pause = pauses::add)
val driver = FakePurchaseDriver(loadingPddCaptures = 400)
val outcome = executor(driver, { driver.browser = true; true }, { "{}" }, pause = pauses::add)
.execute(input().copy(phase = "spec_probe"), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals("PDD_DETAIL_ENTRY_FAILED", outcome.errorCode)
assertEquals("打开拼多多后未识别到稳定商品页面", outcome.message)
assertEquals(150, pauses.count { it == 100L })
assertEquals(300, pauses.count { it == 100L })
}
@Test
fun `explicit pdd login page fails before the product timeout`() {
val pauses = mutableListOf<Long>()
val driver = FakePurchaseDriver(pddProblemLabels = listOf("手机号登录", "登录后继续"))
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { "{}" }, pause = pauses::add)
val outcome = executor(driver, { driver.browser = true; true }, { "{}" }, pause = pauses::add)
.execute(input().copy(phase = "spec_probe"), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals("PDD_LOGIN_REQUIRED", outcome.errorCode)
@@ -825,7 +1011,7 @@ class PurchaseRehearsalExecutorTest {
fun `open spec panel skips required follow-up swipe only for confirmed non-scrollable panel`() {
val driver = FakePurchaseDriver(nonScrollablePanel = true, purchaseSwipeSucceeds = false)
val diagnostics = mutableListOf<String>()
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {}, panelDiagnostic = diagnostics::add)
val outcome = executor(driver, { driver.browser = true; true }, { null }, pause = {}, panelDiagnostic = diagnostics::add)
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals("rehearsal_completed", outcome.resultType)
@@ -841,7 +1027,7 @@ class PurchaseRehearsalExecutorTest {
)
var probes = 0
val diagnostics = mutableListOf<String>()
val outcome = PurchaseRehearsalExecutor(
val outcome = executor(
driver, { driver.browser = true; true }, { probes++; "{}" },
pause = {}, panelDiagnostic = diagnostics::add,
).execute(input().copy(phase = "spec_probe"), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
@@ -857,7 +1043,7 @@ class PurchaseRehearsalExecutorTest {
val driver = FakePurchaseDriver(purchaseSwipeSucceeds = false)
var probes = 0
val diagnostics = mutableListOf<String>()
val outcome = PurchaseRehearsalExecutor(
val outcome = executor(
driver, { driver.browser = true; true }, { probes++; "{}" },
pause = {}, panelDiagnostic = diagnostics::add,
).execute(input().copy(phase = "spec_probe"), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
@@ -876,7 +1062,7 @@ class PurchaseRehearsalExecutorTest {
PurchaseSwipeOutcome(PurchaseSwipeResult.ROOT_UNAVAILABLE, 0),
))
var probes = 0
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { probes++; "{}" }, pause = {})
val outcome = executor(driver, { driver.browser = true; true }, { probes++; "{}" }, pause = {})
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals("RULE_ACTION_FAILED", outcome.errorCode)
@@ -891,7 +1077,7 @@ class PurchaseRehearsalExecutorTest {
PurchaseSwipeOutcome(PurchaseSwipeResult.NO_SCROLLABLE_TARGET, 0),
))
val pauses = mutableListOf<Long>()
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { "{}" }, pause = pauses::add)
val outcome = executor(driver, { driver.browser = true; true }, { "{}" }, pause = pauses::add)
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals("rehearsal_completed", outcome.resultType)
@@ -903,7 +1089,7 @@ class PurchaseRehearsalExecutorTest {
fun `scrollable spec reveal preserves configured swipe count and interval`() {
val driver = FakePurchaseDriver()
val pauses = mutableListOf<Long>()
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { "{}" }, pause = pauses::add)
val outcome = executor(driver, { driver.browser = true; true }, { "{}" }, pause = pauses::add)
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals("rehearsal_completed", outcome.resultType)
@@ -918,7 +1104,7 @@ class PurchaseRehearsalExecutorTest {
"\"waitAfterMs\":700}",
"\"waitAfterMs\":700,\"swipeAfter\":{\"direction\":\"up\",\"count\":1,\"durationMs\":500,\"intervalMs\":0}}",
)
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { "{}" }, pause = {})
val outcome = executor(driver, { driver.browser = true; true }, { "{}" }, pause = {})
.execute(input().copy(phase = "spec_probe"), PurchaseRuleParser.parse(configured), PurchaseAgentCapabilities.supported)
assertEquals("RULE_ACTION_FAILED", outcome.errorCode)
@@ -930,7 +1116,7 @@ class PurchaseRehearsalExecutorTest {
fun `skipping a reveal does not skip a failed specification probe`() {
val driver = FakePurchaseDriver(panelOverride = SpecPanelFixtures.liveShapedSheet())
var probes = 0
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { probes++; null }, pause = {})
val outcome = executor(driver, { driver.browser = true; true }, { probes++; null }, pause = {})
.execute(input().copy(phase = "spec_probe"), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals("PURCHASE_SPEC_NOT_MATCHED", outcome.errorCode)
@@ -940,7 +1126,7 @@ class PurchaseRehearsalExecutorTest {
@Test
fun `unrecognized opened panel returns only scalar panel evidence`() {
val driver = FakePurchaseDriver(unrecognizedPanel = true)
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
val outcome = executor(driver, { driver.browser = true; true }, { null }, pause = {})
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals("PURCHASE_SPEC_PANEL_EVIDENCE_NOT_MATCHED", outcome.errorCode)
@@ -959,7 +1145,7 @@ class PurchaseRehearsalExecutorTest {
entryActionHasEffect = false,
specTapResult = FreshActionResult.SUCCESS,
)
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
val outcome = executor(driver, { driver.browser = true; true }, { null }, pause = {})
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals("rehearsal_completed", outcome.resultType)
@@ -974,7 +1160,7 @@ class PurchaseRehearsalExecutorTest {
specTapResult = FreshActionResult.SUCCESS,
specTapHasEffect = false,
)
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
val outcome = executor(driver, { driver.browser = true; true }, { null }, pause = {})
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals("PURCHASE_SPEC_ENTRY_CLICK_NO_EFFECT", outcome.errorCode)
@@ -988,7 +1174,7 @@ class PurchaseRehearsalExecutorTest {
entryActionHasEffect = false,
specTapResult = FreshActionResult.FAILED,
)
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
val outcome = executor(driver, { driver.browser = true; true }, { null }, pause = {})
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals("PURCHASE_SPEC_ENTRY_CLICK_FAILED", outcome.errorCode)
@@ -1001,7 +1187,7 @@ class PurchaseRehearsalExecutorTest {
val diagnostics = mutableListOf<String>()
val pauses = mutableListOf<Long>()
val driver = FakePurchaseDriver(missingSpecEntry = true)
val outcome = PurchaseRehearsalExecutor(
val outcome = executor(
driver,
{ driver.browser = true; true },
{ null },
@@ -1022,33 +1208,31 @@ class PurchaseRehearsalExecutorTest {
fun `open spec panel waits for a late safe bottom purchase entry`() {
val pauses = mutableListOf<Long>()
val driver = FakePurchaseDriver(bottomPurchaseEntry = true, specEntryVisibleAfterPddCaptures = 4)
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { "{\"dimensions\":[]}" }, pause = pauses::add)
val outcome = executor(driver, { driver.browser = true; true }, { "{\"dimensions\":[]}" }, pause = pauses::add)
.execute(input().copy(executionMode = "live", phase = "spec_probe"), PurchaseRuleParser.parse(liveRule()), PurchaseAgentCapabilities.supported)
assertEquals("spec_probe_completed", outcome.resultType)
assertTrue(driver.clickedPaths.contains("buy"))
// One 100ms pause belongs to the existing open-product foreground poll;
// two belong to the entry-ready wait before the bottom bar appears.
assertEquals(3, pauses.count { it == 100L })
// The browser return gets a full three-second buffer before the spec entry can be used.
assertEquals(32, pauses.count { it == 100L })
}
@Test
fun `purchase reopens and waits for product evidence instead of a visible loading frame`() {
val pauses = mutableListOf<Long>()
val driver = FakePurchaseDriver(loadingPddCaptures = 1)
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = pauses::add)
val outcome = executor(driver, { driver.browser = true; true }, { null }, pause = pauses::add)
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals("rehearsal_completed", outcome.resultType)
// Two stable reads belong to reopening the product; verifyProduct then
// independently requires its second stable read before continuing.
assertEquals(3, pauses.count { it == 100L })
// Browser return buffer overlaps stable reads; verifyProduct performs its independent check.
assertEquals(32, pauses.count { it == 100L })
}
@Test
fun `transient sold out page recovers before opening specs`() {
val driver = FakePurchaseDriver(soldOut = true, recoverSoldOutAfterPull = true)
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
val outcome = executor(driver, { driver.browser = true; true }, { null }, pause = {})
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals("rehearsal_completed", outcome.resultType)
@@ -1059,7 +1243,7 @@ class PurchaseRehearsalExecutorTest {
@Test
fun `persistent sold out page returns replacement eligible error`() {
val driver = FakePurchaseDriver(soldOut = true)
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
val outcome = executor(driver, { driver.browser = true; true }, { null }, pause = {})
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals("PDD_GOODS_SOLD_OUT", outcome.errorCode)
@@ -1070,7 +1254,7 @@ class PurchaseRehearsalExecutorTest {
@Test
fun `sold out recovery gesture failure stops immediately`() {
val driver = FakePurchaseDriver(soldOut = true, pullDownSucceeds = false)
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
val outcome = executor(driver, { driver.browser = true; true }, { null }, pause = {})
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals("PDD_GOODS_SOLD_OUT", outcome.errorCode)
@@ -1080,7 +1264,7 @@ class PurchaseRehearsalExecutorTest {
@Test
fun `sold out recovery rejects a page that lost product evidence`() {
val driver = FakePurchaseDriver(soldOut = true, loseEvidenceAfterPull = true)
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
val outcome = executor(driver, { driver.browser = true; true }, { null }, pause = {})
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals("RULE_NOT_MATCHED", outcome.errorCode)
@@ -1091,7 +1275,7 @@ class PurchaseRehearsalExecutorTest {
@Test
fun `all unavailable specs recover on goods page and reopen panel`() {
val driver = FakePurchaseDriver(allSpecsUnavailable = true, recoverSoldOutAfterPull = true)
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
val outcome = executor(driver, { driver.browser = true; true }, { null }, pause = {})
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals("rehearsal_completed", outcome.resultType)
@@ -1102,7 +1286,7 @@ class PurchaseRehearsalExecutorTest {
@Test
fun `goods id mismatch fails before opening pdd`() {
var opened = false
val outcome = PurchaseRehearsalExecutor(FakePurchaseDriver(), { opened = true; true }, { null }, pause = {})
val outcome = executor(FakePurchaseDriver(), { opened = true; true }, { null }, pause = {})
.execute(input().copy(goodsId = "999"), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals("PDD_GOODS_MISMATCH", outcome.errorCode)
assertFalse(opened)
@@ -1111,7 +1295,7 @@ class PurchaseRehearsalExecutorTest {
@Test
fun `live rule spec probe ignores historical mapping and returns before address or order actions`() {
val driver = FakePurchaseDriver()
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { "{\"dimensions\":[]}" }, pause = {})
val outcome = executor(driver, { driver.browser = true; true }, { "{\"dimensions\":[]}" }, pause = {})
.execute(
input().copy(
executionMode = "live",
@@ -1131,7 +1315,7 @@ class PurchaseRehearsalExecutorTest {
@Test
fun `spec probe opens panel through safe bottom purchase entry`() {
val driver = FakePurchaseDriver(bottomPurchaseEntry = true)
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { "{\"dimensions\":[]}" }, pause = {})
val outcome = executor(driver, { driver.browser = true; true }, { "{\"dimensions\":[]}" }, pause = {})
.execute(input().copy(executionMode = "live", phase = "spec_probe"), PurchaseRuleParser.parse(liveRule()), PurchaseAgentCapabilities.supported)
assertEquals("spec_probe_completed", outcome.resultType)
@@ -1142,7 +1326,7 @@ class PurchaseRehearsalExecutorTest {
@Test
fun `purchase returns once and fails explicitly when spec entry opens reviews`() {
val driver = FakePurchaseDriver(bottomPurchaseEntry = true, openReviewOnBottomClick = true)
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
val outcome = executor(driver, { driver.browser = true; true }, { null }, pause = {})
.execute(input().copy(executionMode = "live", phase = "spec_probe"), PurchaseRuleParser.parse(liveRule()), PurchaseAgentCapabilities.supported)
assertEquals("failed", outcome.resultType)
@@ -1163,7 +1347,7 @@ class PurchaseRehearsalExecutorTest {
)
cases.forEach { (reason, expected) ->
val driver = FakePurchaseDriver(forcedEntryClickReason = reason)
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
val outcome = executor(driver, { driver.browser = true; true }, { null }, pause = {})
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals("PURCHASE_SPEC_ENTRY_CLICK_FAILED", outcome.errorCode)
@@ -1179,7 +1363,7 @@ class PurchaseRehearsalExecutorTest {
openReviewOnBottomClick = true,
reviewBackSucceeds = false,
)
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
val outcome = executor(driver, { driver.browser = true; true }, { null }, pause = {})
.execute(input().copy(executionMode = "live", phase = "spec_probe"), PurchaseRuleParser.parse(liveRule()), PurchaseAgentCapabilities.supported)
assertEquals("PURCHASE_SPEC_ENTRY_OPENED_REVIEW", outcome.errorCode)
@@ -1192,7 +1376,7 @@ class PurchaseRehearsalExecutorTest {
fun `spec aliases cannot bypass semantic safe candidates`() {
val driver = FakePurchaseDriver(includeReviewEntry = true)
val unsafeRule = rule().replace("[\"选择规格\"]", "[\"商品评价\"]")
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
val outcome = executor(driver, { driver.browser = true; true }, { null }, pause = {})
.execute(input(), PurchaseRuleParser.parse(unsafeRule), PurchaseAgentCapabilities.supported)
assertEquals("PURCHASE_SPEC_ENTRY_NOT_FOUND", outcome.errorCode)
@@ -1202,7 +1386,7 @@ class PurchaseRehearsalExecutorTest {
@Test
fun `spec entry failures include scalar diagnostics only`() {
val driver = FakePurchaseDriver(missingSpecEntry = true)
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
val outcome = executor(driver, { driver.browser = true; true }, { null }, pause = {})
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals("PURCHASE_SPEC_ENTRY_NOT_FOUND", outcome.errorCode)
@@ -1212,7 +1396,7 @@ class PurchaseRehearsalExecutorTest {
)
val ambiguousDriver = FakePurchaseDriver(forcedEntryClickReason = FreshClickReason.TARGET_AMBIGUOUS)
val ambiguous = PurchaseRehearsalExecutor(ambiguousDriver, { ambiguousDriver.browser = true; true }, { null }, pause = {})
val ambiguous = executor(ambiguousDriver, { ambiguousDriver.browser = true; true }, { null }, pause = {})
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals("PURCHASE_SPEC_ENTRY_TARGET_AMBIGUOUS", ambiguous.errorCode)
@@ -1329,6 +1513,7 @@ class PurchaseRehearsalExecutorTest {
assertEquals(1, submitted.map { it.payloadJson }.distinct().size)
}
/** Existing spec/price tests explicitly model the trusted immediate probe handoff. */
private fun input() = PurchaseExecutionInput(
taskId = 42,
executionMode = "rehearsal",
@@ -1340,8 +1525,26 @@ class PurchaseRehearsalExecutorTest {
quantity = 2,
minUnitPriceCent = 1_000,
maxUnitPriceCent = 3_000,
reuseProbeProduct = true,
)
private fun executor(
driver: PurchaseUiDriver,
openLink: (String) -> Boolean,
probeSpecs: () -> String?,
pause: (Long) -> Unit,
panelDiagnostic: (String) -> Unit = {},
probeClickDiagnostic: (AgentDiagnosticStage, SnapshotNode, FreshClickOutcome) -> Unit = { _, _, _ -> },
path: PddLaunchPath? = PddLaunchPath.BROWSER,
): PurchaseRehearsalExecutor {
var now = 0L
return PurchaseRehearsalExecutor(driver, openLink, probeSpecs,
pause = { millis -> now += millis; pause(millis) },
panelDiagnostic = panelDiagnostic, probeClickDiagnostic = probeClickDiagnostic,
acceptedLaunchPath = { path }, monotonicNow = { now },
)
}
private fun rule() = """{
"schemaVersion":1,
"ruleType":"pddPurchase",
@@ -0,0 +1,32 @@
package cn.ilapage.goauto.agent
import cn.ilapage.goauto.agent.persistence.RejectedPurchaseResult
import cn.ilapage.goauto.agent.ui.PurchaseRejectionPresentation
import org.junit.Assert.*
import org.junit.Test
class PurchaseRejectionPresentationTest {
private fun item(payload: String, acknowledged: Long? = null) = RejectedPurchaseResult(1,42,"attempt",payload,100,"PURCHASE_STATE_CONFLICT",acknowledged)
@Test fun `only unacknowledged order evidence appears on procurement banner`() {
for (payload in listOf("""{"resultType":"order_created"}""", """{"resultType":"order_result_unknown"}""", """{"resultType":"failed","pddOrderNo":"123-456"}""")) {
assertTrue(PurchaseRejectionPresentation.showBanner(item(payload)))
assertFalse(PurchaseRejectionPresentation.showBanner(item(payload, 200)))
}
assertFalse(PurchaseRejectionPresentation.showBanner(item("""{"resultType":"spec_probe_completed"}""")))
assertFalse(PurchaseRejectionPresentation.showBanner(item("""{"resultType":"spec_probe_completed","pddOrderNo":null}""")))
}
@Test fun `notice includes task optional order and fixed review instruction without raw errors`() {
val message = PurchaseRejectionPresentation.notice(item("""{"resultType":"order_created","pddOrderNo":"123-456","message":"https://secret"}"""))
assertTrue(message.contains("#42")); assertTrue(message.contains("123-456"))
assertTrue(message.contains("请人工核对拼多多订单及后台任务,避免重复采购"))
assertFalse(message.contains("secret"))
}
@Test fun `connection classifies sync heartbeat and rejected separately`() {
assertEquals("正在同步", PurchaseRejectionPresentation.connection("CONNECTING", 2))
assertEquals("已连接 · 有 2 条采购结果被服务端拒收", PurchaseRejectionPresentation.connection("ONLINE", 2))
assertEquals("未连接 · 设备认证失败", PurchaseRejectionPresentation.connection("AUTH_ERROR", 2))
assertEquals("未连接 · 设备任务状态不一致", PurchaseRejectionPresentation.connection("TASK_MISMATCH", 2))
assertEquals("未连接 · 网络暂不可用", PurchaseRejectionPresentation.connection("NETWORK_ERROR", 2))
assertEquals("未连接 · 服务端暂不可用", PurchaseRejectionPresentation.connection("SERVER_ERROR", 2))
}
}
@@ -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)
}
}
@@ -63,6 +63,7 @@ class SpecOptionDedupTest {
private fun input() = PurchaseExecutionInput(
taskId = 332,
reuseProbeProduct = true, // This test starts at the trusted probe-to-purchase spec handoff.
executionMode = "rehearsal",
phase = "purchase",
url = "https://mobile.yangkeduo.com/goods.html?goods_id=719834019024",
@@ -716,6 +716,7 @@ class SpecPanelRecognitionTest {
private fun input() = PurchaseExecutionInput(
taskId = 331,
reuseProbeProduct = true, // Panel tests start at the trusted probe-to-purchase handoff.
executionMode = "rehearsal",
phase = "purchase",
url = "https://mobile.yangkeduo.com/goods.html?goods_id=719834019024",
@@ -8,6 +8,15 @@ import org.junit.Assert.assertTrue
import org.junit.Test
class SpecValueNormalizerTest {
@Test
fun `final confirmation maps only display brackets alphanumerics and whitespace`() {
assertEquals("[白][黑][灰](薄款) RW12 XL", SpecValueNormalizer.normalizeFinalConfirmation(" 【白】〔黑〕[灰](薄款) RW12\t\nXL "))
assertEquals("白色,加绒:XL ¥19.60 ① 2件", SpecValueNormalizer.normalizeFinalConfirmation("白色,加绒:XL ¥19.60 ① 2件"))
assertEquals("xl", SpecValueNormalizer.normalizeFinalConfirmation("xl"))
assertEquals("【白色】 XL", SpecValueNormalizer.normalizeColor("【白色】 XL"))
assertEquals("【白色】 XL", SpecValueNormalizer.normalizeSize("【白色】 XL"))
}
@Test
fun `only trailing price is removed`() {
assertEquals("紫色", SpecValueNormalizer.normalizeColor("紫色 ¥15.78"))
@@ -213,7 +213,8 @@ class TruncatedSpecCardTest {
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),
"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"}]}"""),
@@ -3,10 +3,134 @@ package cn.ilapage.goauto.agent.persistence
import java.sql.Connection
import java.sql.DriverManager
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class AgentDiagnosticStoreMigrationTest {
@Test fun v1AndV5UpgradeToV6PreservesEveryExistingTableAndReopens() {
for (version in listOf(1,5)) {
val file=java.io.File.createTempFile("sync-v6-upgrade-",".db")
try {
val old: Map<String,List<List<String?>>>
DriverManager.getConnection("jdbc:sqlite:${file.absolutePath}").use { db ->
createOldDatabase(db,version); insertOldDiagnostic(db); old=snapshotTables(db)
migrate(db,version,6); assertOldDiagnosticPreserved(db)
old.forEach { (name,rows)-> assertEquals(rows,tableRows(db,name)) }
val store=SyncDiagnosticStore(JdbcSyncSql(db))
val id=store.start("first",1000,null,SyncContext())
store.finish(id,1100,100,SyncFailure("heartbeat","network",1100,100))
store.ack(store.snapshot("first",1200)!!)
}
DriverManager.getConnection("jdbc:sqlite:${file.absolutePath}").use { db ->
assertOldDiagnosticPreserved(db); old.forEach { (name,rows)-> assertEquals(rows,tableRows(db,name)) }
val snapshot=SyncDiagnosticStore(JdbcSyncSql(db)).snapshot("reopened",1300)!!
assertEquals(0,snapshot.report.getInt("failedRounds")); assertTrue(snapshot.report.getLong("snapshotSeq")>2)
}
} finally { check(file.delete()) }
}
}
@Test
fun freshV5DatabaseHasNullableColorDiscoveryOutcomeColumns() = withDatabase { db ->
db.createStatement().use { it.execute(AgentDiagnosticSchema.createTableSql) }
assertEquals(6, 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 {
@@ -16,7 +140,6 @@ class AgentDiagnosticStoreMigrationTest {
AgentDiagnosticSchema.migrationStatements(3, 4, columnNames(db)).forEach { sql ->
db.createStatement().use { it.execute(sql) }
}
assertEquals(4, AgentDiagnosticSchema.VERSION)
for (table in listOf("purchase_failure_snapshot", "purchase_failure_snapshot_queue", "purchase_failure_snapshot_context")) {
db.prepareStatement("SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=?").use {
it.setString(1, table)
@@ -137,6 +260,80 @@ class AgentDiagnosticStoreMigrationTest {
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) {
AgentDiagnosticSchema.v2MigrationStatements(1, 2, columnNames(db)).forEach { sql ->
db.createStatement().use { it.execute(sql) }
@@ -166,6 +363,7 @@ class AgentDiagnosticStoreMigrationTest {
}
private companion object {
val V5_COLUMNS = setOf("color_vertical_swipe_count", "color_trailing_empty_read_count", "color_horizontal_incomplete")
val CREATE_V1_TABLE_SQL =
"""CREATE TABLE agent_diagnostic (
id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -0,0 +1,106 @@
package cn.ilapage.goauto.agent.persistence
import cn.ilapage.goauto.agent.network.AgentApiException
import cn.ilapage.goauto.agent.network.HeartbeatResult
import cn.ilapage.goauto.agent.service.AgentSyncCycle
import cn.ilapage.goauto.agent.service.recoverPurchaseBoundary
import java.sql.DriverManager
import java.util.concurrent.CancellationException
import org.json.JSONArray
import org.junit.Assert.*
import org.junit.Test
class HandledRecoveryDiagnosticTest {
@Test
fun handledBoundaryApiFailureIsPersistedButDoesNotChangeRecoveryOrClaimPolicy() {
for (status in listOf(500, 409)) {
for (pending in listOf(false, true)) {
DriverManager.getConnection("jdbc:sqlite::memory:").use { db ->
val sql = JdbcSyncSql(db)
AgentDiagnosticSchema.syncStatements.forEach { sql.execute(it) }
val store = SyncDiagnosticStore(sql)
var elapsed = 10L
val round =
SyncDiagnosticRecorder(store, "p", { 1000 }, { elapsed })
.begin(SyncContext())
val events = mutableListOf<String>()
var originalMarkerPresent = true
val replayBoundary: () -> Unit = {
events += "recover"
elapsed = 45
throw AgentApiException(
status,
"PRIVATE_CODE",
"private URL and payload",
false,
)
}
val cycle =
AgentSyncCycle(
heartbeat = {
round.step("heartbeat") {
events += "heartbeat"
HeartbeatResult(1, true, false, 15)
}
},
activeTaskId = { null },
executionActive = { false },
recover = {
round.step("recover") {
recoverPurchaseBoundary(round::handledFailure) {
replayBoundary()
originalMarkerPresent = false
}
}
},
flush = { round.step("flush") { events += "flush" } },
hasPending = { pending },
)
.run()
round.finish()
assertEquals(listOf("heartbeat", "recover", "flush"), events)
assertTrue(originalMarkerPresent)
assertNull(cycle.failureCode)
assertEquals(!pending, cycle.canClaim)
val row = sql.query("SELECT * FROM sync_round").single()
assertEquals("failure", row["status"])
assertEquals("recover", row["failure_step"])
assertEquals(
if (status == 500) "api_server" else "api_conflict",
row["failure_category"],
)
assertEquals("35", row["failure_duration_ms"])
assertEquals(status.toString(), row["http_status"])
assertNull(row["api_code"])
val steps = JSONArray(row["steps"])
assertEquals("failure", steps.getJSONObject(2).getString("status"))
assertEquals(35, steps.getJSONObject(2).getLong("durationMs"))
assertEquals("success", steps.getJSONObject(3).getString("status"))
assertFalse(row.toString().contains("private"))
assertEquals(1, store.snapshot("p", 1100)!!.report.getInt("failedRounds"))
}
}
}
}
@Test
fun boundaryPolicyStillRethrowsAuthenticationCancellationAndFatalErrors() {
for (error in
listOf(
AgentApiException(401, "OTHER", "private", false),
AgentApiException(409, "DEVICE_DISABLED", "private", false),
CancellationException(),
OutOfMemoryError(),
)) {
val thrown =
assertThrows(error.javaClass) {
recoverPurchaseBoundary({
fail("only handled nonauthentication API errors are observed")
}) {
throw error
}
}
assertSame(error, thrown)
}
}
}
@@ -0,0 +1,100 @@
package cn.ilapage.goauto.agent.persistence
import java.sql.Connection
import java.sql.DriverManager
import org.junit.Assert.*
import org.junit.Test
class PurchaseRejectionStoreTest {
@Test fun `v2 upgrade adds exactly three nullable columns and preserves all old data`() = database { db ->
val before = rows(db, "SELECT * FROM purchase_outbox")
val taskBefore = rows(db, "SELECT * FROM purchase_task")
migrate(db)
assertEquals(3, PurchaseRejectionSql.migration.size)
assertEquals(taskBefore, rows(db, "SELECT * FROM purchase_task"))
assertEquals(before, rows(db, "SELECT id, task_id, attempt_id, request_id, payload_json, upload_status, created_at, updated_at FROM purchase_outbox"))
assertEquals(listOf(listOf(null, null, null)), rows(db, "SELECT rejected_at,rejection_error_code,acknowledged_at FROM purchase_outbox"))
val columns = rows(db, "PRAGMA table_info(purchase_outbox)").takeLast(3)
assertTrue(columns.all { it[3] == "0" && it[4] == null })
}
@Test fun `reject is atomic preserves payload excludes pending and targets current attempt only`() = database { db ->
migrate(db)
reject(db)
assertEquals(listOf(listOf("rejected", "payload", "100", "PURCHASE_STATE_CONFLICT")), rows(db, "SELECT upload_status,payload_json,rejected_at,rejection_error_code FROM purchase_outbox"))
assertEquals(listOf(listOf("completed", "rejected", "payload")), rows(db, "SELECT status,upload_status,result_json FROM purchase_task"))
assertTrue(rows(db, PurchaseRejectionSql.activeTask).isEmpty())
assertTrue(rows(db, "SELECT id FROM purchase_outbox WHERE upload_status='pending'").isEmpty())
}
@Test fun `old outbox rejection cannot overwrite a newer task attempt`() = database { db ->
migrate(db)
db.createStatement().use { it.execute("UPDATE purchase_task SET attempt_id='new',status='running',upload_status='none'") }
reject(db)
assertEquals(listOf(listOf("new", "running", "none")), rows(db, "SELECT attempt_id,status,upload_status FROM purchase_task"))
}
@Test fun `rejected record never counts active even if legacy local status says running`() = database { db ->
migrate(db); reject(db)
db.createStatement().use { it.execute("UPDATE purchase_task SET status='running'") }
assertTrue(rows(db, PurchaseRejectionSql.activeTask).isEmpty())
}
@Test fun `invalid whitelist and mismatched outbox identity cannot change evidence`() = database { db ->
migrate(db)
for ((item, code) in listOf(
PendingPurchaseOutbox(1,42,"old","request","payload") to "UNKNOWN",
PendingPurchaseOutbox(1,42,"new","request","payload") to "PURCHASE_STATE_CONFLICT",
)) {
assertTrue(runCatching { PurchaseRejectionSql.reject(item, code, 100) { sql, args -> update(db, sql, args) } }.isFailure)
}
assertEquals(listOf(listOf("pending", null, null)), rows(db, "SELECT upload_status,rejected_at,rejection_error_code FROM purchase_outbox"))
}
@Test fun `task update failure rolls back rejection evidence too`() = database { db ->
migrate(db)
db.createStatement().use { it.execute("CREATE TRIGGER fail_task BEFORE UPDATE ON purchase_task BEGIN SELECT RAISE(ABORT, 'test'); END") }
assertTrue(runCatching { reject(db) }.isFailure)
assertEquals(listOf(listOf("pending", null, null)), rows(db, "SELECT upload_status,rejected_at,rejection_error_code FROM purchase_outbox"))
assertEquals(listOf(listOf("pending")), rows(db, "SELECT upload_status FROM purchase_task"))
}
@Test fun `ack survives database reopen and changes no payload or task state`() {
val file = java.io.File.createTempFile("purchase-rejection-", ".db")
try {
connect(file.absolutePath).use { db ->
seed(db); migrate(db); reject(db)
update(db, PurchaseRejectionSql.acknowledge, listOf(200L, 1L))
}
connect(file.absolutePath).use { db ->
assertEquals(listOf(listOf("200", "payload", "rejected")), rows(db, "SELECT acknowledged_at,payload_json,upload_status FROM purchase_outbox"))
assertEquals(listOf(listOf("completed", "rejected")), rows(db, "SELECT status,upload_status FROM purchase_task"))
}
} finally { check(file.delete()) }
}
private fun reject(db: Connection) {
db.autoCommit = false
try {
PurchaseRejectionSql.reject(PendingPurchaseOutbox(1,42,"old","request","payload"), "PURCHASE_STATE_CONFLICT", 100) { sql, args -> update(db, sql, args) }
db.commit()
} catch (error: Exception) { db.rollback(); throw error }
finally { db.autoCommit = true }
}
private fun update(db: Connection, sql: String, args: List<Any>): Int = db.prepareStatement(sql).use { statement ->
args.forEachIndexed { index, arg -> statement.setObject(index + 1, arg) }
statement.executeUpdate()
}
private fun migrate(db: Connection) = PurchaseRejectionSql.migration.forEach { sql -> db.createStatement().use { it.execute(sql) } }
private fun connect(path: String): Connection { Class.forName("org.sqlite.JDBC"); return DriverManager.getConnection("jdbc:sqlite:$path") }
private fun database(block: (Connection) -> Unit) = connect(":memory:").use { seed(it); block(it) }
private fun rows(db: Connection, sql: String): List<List<String?>> = db.createStatement().use { statement ->
statement.executeQuery(sql).use { r -> buildList { while(r.next()) add((1..r.metaData.columnCount).map { r.getString(it) }) } }
}
private fun seed(db: Connection) = db.createStatement().use {
it.execute("CREATE TABLE purchase_task (task_id INTEGER PRIMARY KEY,attempt_id TEXT NOT NULL,rule_snapshot_hash TEXT NOT NULL,current_step TEXT NOT NULL,status TEXT NOT NULL,result_json TEXT,upload_status TEXT NOT NULL,created_at INTEGER NOT NULL,updated_at INTEGER NOT NULL,order_submit_request_id TEXT,final_confirmation_json TEXT,irreversible_at INTEGER)")
it.execute("CREATE TABLE purchase_outbox (id INTEGER PRIMARY KEY AUTOINCREMENT,task_id INTEGER NOT NULL,attempt_id TEXT NOT NULL,request_id TEXT NOT NULL UNIQUE,payload_json TEXT NOT NULL,upload_status TEXT NOT NULL,created_at INTEGER NOT NULL,updated_at INTEGER NOT NULL)")
it.execute("INSERT INTO purchase_task VALUES (42,'old','hash','submit_result','completed','payload','pending',1,2,NULL,NULL,NULL)")
it.execute("INSERT INTO purchase_outbox VALUES (1,42,'old','request','payload','pending',1,2)")
}
}
@@ -0,0 +1,225 @@
package cn.ilapage.goauto.agent.persistence
import java.sql.DriverManager
import java.util.concurrent.CancellationException
import org.junit.Assert.*
import org.junit.Test
class SyncDiagnosticRecorderTest {
@Test
fun collectionWithSameNumericIdCannotInheritPurchaseAttempt() {
val old = SyncContext(taskId = 7, attemptId = "old-purchase", phase = "purchase")
val collection =
syncTaskIdentity(7, "collection", true, old) {
error("must not read purchase fallback")
}
assertEquals(7L, collection.taskId)
assertNull(collection.attemptId)
assertNull(collection.phase)
val purchase = syncTaskIdentity(7, "purchase", true, old) { null }
assertEquals("old-purchase", purchase.attemptId)
val recovered = syncTaskIdentity(8, null, false, old) { "recovered" }
assertEquals("recovered", recovered.attemptId)
assertNull(recovered.phase)
}
@Test
fun failureOfEachPersistenceStageIsIsolatedAndNeverCreatesFakeSuccess() {
for (target in
listOf(
"open",
"INSERT INTO sync_round",
"UPDATE sync_round SET steps",
"UPDATE sync_round SET ended_at",
"SELECT sequence",
"DELETE FROM sync_round",
)) {
DriverManager.getConnection("jdbc:sqlite::memory:").use { db ->
val real = JdbcSyncSql(db)
AgentDiagnosticSchema.syncStatements.forEach { real.execute(it) }
val broken =
object : SyncSql {
override fun execute(sql: String, args: List<Any?>) {
if (sql.startsWith(target)) error("private secret")
real.execute(sql, args)
}
override fun query(
sql: String,
args: List<Any?>,
): List<Map<String, String?>> {
if (sql.startsWith(target)) error("private secret")
return real.query(sql, args)
}
override fun <T> transaction(block: () -> T): T {
if (target == "open") error("private secret")
return real.transaction(block)
}
}
var warnings = 0
val recorder =
SyncDiagnosticRecorder(SyncDiagnosticStore(broken), "p", { 1000 }, { 10 }) {
warnings++
}
var business = 0
val round = recorder.begin(SyncContext())
round.step("heartbeat") { business++ }
round.finish()
assertEquals(target, 1, business)
assertTrue(target, warnings > 0)
val rows = real.query("SELECT * FROM sync_round")
if (target != "DELETE FROM sync_round")
assertTrue(target, rows.none { it["status"] == "success" })
assertFalse(rows.toString().contains("private secret"))
}
}
}
@Test
fun readAckCleanupFailuresKeepUnacknowledgedFailuresAndCancellationIsNotSwallowed() {
DriverManager.getConnection("jdbc:sqlite::memory:").use { db ->
val real = JdbcSyncSql(db)
AgentDiagnosticSchema.syncStatements.forEach { real.execute(it) }
val store = SyncDiagnosticStore(real)
val id = store.start("p", 1000, null, SyncContext())
store.finish(id, 1100, 100, SyncFailure("flush", "exception", 1100, 100))
val snapshot = store.snapshot("p", 1200)!!
var failure: Exception = IllegalStateException("secret")
val broken =
object : SyncSql {
override fun execute(sql: String, args: List<Any?>) {
throw failure
}
override fun query(sql: String, args: List<Any?>): List<Map<String, String?>> {
throw failure
}
override fun <T> transaction(block: () -> T): T = real.transaction(block)
}
val recorder =
SyncDiagnosticRecorder(SyncDiagnosticStore(broken), "p", { 1300 }, { 10 })
recorder.ack(snapshot)
assertNull(recorder.snapshot())
assertEquals(1, store.snapshot("p", 1400)!!.report.getInt("failedRounds"))
failure = CancellationException()
assertThrows(CancellationException::class.java) { recorder.snapshot() }
assertThrows(CancellationException::class.java) { recorder.begin(SyncContext()) }
}
}
@Test
fun freshRecorderDoesNotReusePreviousElapsedClockAndAbortedRoundStaysRunning() {
DriverManager.getConnection("jdbc:sqlite::memory:").use { db ->
val sql = JdbcSyncSql(db)
AgentDiagnosticSchema.syncStatements.forEach { sql.execute(it) }
val store = SyncDiagnosticStore(sql)
SyncDiagnosticRecorder(store, "old", { 1000 }, { 9000 }).begin(SyncContext()).finish()
val round = SyncDiagnosticRecorder(store, "new", { 2000 }, { 5 }).begin(SyncContext())
round.abort()
round.finish()
val row = sql.query("SELECT * FROM sync_round ORDER BY id DESC LIMIT 1").single()
assertNull(row["start_gap_ms"])
assertEquals("running", row["status"])
}
}
@Test
fun wallClockJumpDoesNotChangeElapsedDurationAndRepeatedStepsHaveDistinctOrdinals() {
DriverManager.getConnection("jdbc:sqlite::memory:").use { db ->
AgentDiagnosticSchema.syncStatements.forEach {
db.createStatement().use { s -> s.execute(it) }
}
val sql = JdbcSyncSql(db)
var wall = 1000L
var elapsed = 10L
val recorder =
SyncDiagnosticRecorder(SyncDiagnosticStore(sql), "process", { wall }, { elapsed })
val round = recorder.begin(SyncContext())
round.step("heartbeat") {
wall = -500
elapsed = 30
}
round.step("heartbeat") { elapsed = 40 }
round.finish()
val row = sql.query("SELECT * FROM sync_round").single()
assertEquals("30", row["duration_ms"])
val steps = org.json.JSONArray(row["steps"])
assertEquals(3, steps.length())
assertEquals(1, steps.getJSONObject(1).getInt("ordinal"))
assertEquals(2, steps.getJSONObject(2).getInt("ordinal"))
assertEquals(0, steps.getJSONObject(1).getLong("startedOffsetMs"))
assertEquals(20, steps.getJSONObject(1).getLong("endedOffsetMs"))
assertEquals(20, steps.getJSONObject(2).getLong("startedOffsetMs"))
assertEquals(30, steps.getJSONObject(2).getLong("endedOffsetMs"))
elapsed = 110
recorder.begin(SyncContext()).finish()
assertEquals(
"100",
sql.query("SELECT start_gap_ms FROM sync_round ORDER BY id DESC LIMIT 1")
.single()["start_gap_ms"],
)
}
}
@Test
fun authenticationCodesAndUninstrumentedTimeoutAreSafelyClassified() {
assertEquals(
"api_auth",
syncFailure(
cn.ilapage.goauto.agent.network.AgentApiException(
409,
"DEVICE_DISABLED",
"private",
false,
),
"heartbeat",
1000,
10,
)
.category,
)
val failure =
syncFailure(
java.net.SocketTimeoutException("connect timed out url secret"),
"register",
1000,
10,
)
assertEquals("timeout_unknown", failure.category)
assertEquals("SocketTimeoutException", failure.exceptionClass)
assertNull(
syncFailure(IllegalArgumentException("private"), "flush", 1000, 10).exceptionClass
)
}
@Test
fun storageFailuresNeverBlockBusinessButCancellationAndFatalErrorsPropagate() {
val sql =
object : SyncSql {
override fun execute(sql: String, args: List<Any?>) {
throw IllegalStateException("private message")
}
override fun query(sql: String, args: List<Any?>): List<Map<String, String?>> {
throw IllegalStateException("private message")
}
override fun <T> transaction(block: () -> T): T = block()
}
val recorder = SyncDiagnosticRecorder(SyncDiagnosticStore(sql), "p", { 1000 }, { 10 })
var count = 0
val round = recorder.begin(SyncContext())
round.step("heartbeat") { count++ }
round.finish()
assertEquals(1, count)
assertNull(recorder.snapshot())
assertThrows(CancellationException::class.java) {
round.step("flush") { throw CancellationException() }
}
assertThrows(OutOfMemoryError::class.java) {
round.step("flush") { throw OutOfMemoryError() }
}
}
}
@@ -0,0 +1,168 @@
package cn.ilapage.goauto.agent.persistence
import java.sql.DriverManager
import org.junit.Assert.*
import org.junit.Test
class SyncDiagnosticStoreTest {
@Test
fun contextRejectsArbitraryTextInsteadOfPersistingIt() {
DriverManager.getConnection("jdbc:sqlite::memory:").use { db ->
val sql = JdbcSyncSql(db)
AgentDiagnosticSchema.syncStatements.forEach { sql.execute(it) }
SyncDiagnosticStore(sql)
.start(
"p",
1000,
null,
SyncContext(
deviceId = -1,
taskId = -1,
attemptId = "secret URL",
phase = "secret URL",
transport = "secret URL",
),
)
val row = sql.query("SELECT * FROM sync_round").single()
assertNull(row["device_id"])
assertNull(row["task_id"])
assertNull(row["attempt_id"])
assertNull(row["phase"])
assertEquals("unknown", row["transport"])
assertFalse(row.toString().contains("secret URL"))
}
}
@Test
fun retentionBoundsSyncHistoryOnlyAndSequenceDoesNotReset() {
DriverManager.getConnection("jdbc:sqlite::memory:").use { db ->
val sql = JdbcSyncSql(db)
AgentDiagnosticSchema.syncStatements.forEach { sql.execute(it) }
sql.execute(AgentDiagnosticSchema.createTableSql)
sql.execute(
"INSERT INTO agent_diagnostic(task_id,stage,reason,attempt,elapsed_ms,agent_version,created_at) VALUES(1,'x','x',0,0,'old',1)"
)
val store = SyncDiagnosticStore(sql)
val expired = store.start("p", 1, null, SyncContext())
store.finish(expired, 2, 1, SyncFailure("sync", "exception", 2, 1))
val before = store.snapshot("p", 10)!!.report.getLong("snapshotSeq")
val now = SyncDiagnosticStore.RETENTION_MS + 1000
store.start("p", 999, null, SyncContext())
store.start("p", 1000, null, SyncContext())
store.cleanup(now)
assertEquals("1", sql.query("SELECT COUNT(*) AS n FROM sync_round").single()["n"])
repeat(6001) { store.start("p", now, null, SyncContext()) }
store.cleanup(now)
assertEquals("6000", sql.query("SELECT COUNT(*) AS n FROM sync_round").single()["n"])
assertEquals("1", sql.query("SELECT COUNT(*) AS n FROM agent_diagnostic").single()["n"])
assertTrue(store.snapshot("p", now)!!.report.getLong("snapshotSeq") > before)
}
}
@Test
fun ackCannotEraseRunningRoundThatFailsAfterSnapshotAndRestartKeepsSequence() {
DriverManager.getConnection("jdbc:sqlite::memory:").use { db ->
AgentDiagnosticSchema.syncStatements.forEach { sql ->
db.createStatement().use { it.execute(sql) }
}
val sql = JdbcSyncSql(db)
val store = SyncDiagnosticStore(sql)
val first = store.start("p1", 1000, null, SyncContext())
store.finish(first, 1100, 100, SyncFailure("heartbeat", "network", 1100, 100))
val running = store.start("p1", 1200, 200, SyncContext())
val snapshot = store.snapshot("p1", 1250)!!
assertEquals(1, snapshot.report.getInt("failedRounds"))
store.finish(running, 1300, 100, SyncFailure("flush", "exception", 1300, 100))
store.ack(snapshot)
val restart = SyncDiagnosticStore(sql).snapshot("p2", 1400)!!
assertEquals(1, restart.report.getInt("failedRounds"))
assertEquals("flush", restart.report.getString("latestFailureStep"))
assertTrue(
restart.report.getLong("snapshotSeq") > snapshot.report.getLong("snapshotSeq")
)
assertEquals(1, store.snapshot("p2", 1450)!!.report.getInt("failedRounds"))
}
}
@Test
fun oldRunningIsInterruptedAndNeverInventsFailure() {
DriverManager.getConnection("jdbc:sqlite::memory:").use { db ->
AgentDiagnosticSchema.syncStatements.forEach { sql ->
db.createStatement().use { it.execute(sql) }
}
val store = SyncDiagnosticStore(JdbcSyncSql(db))
store.start("old", 1000, null, SyncContext())
val snapshot = store.snapshot("new", 1200)!!
assertTrue(snapshot.report.getBoolean("hasInterruptedRound"))
assertEquals(0, snapshot.report.getInt("failedRounds"))
assertTrue(snapshot.report.isNull("latestFailureAt"))
store.ack(snapshot)
assertTrue(store.snapshot("new", 1300)!!.report.getBoolean("hasInterruptedRound"))
store.cleanup(1001 + SyncDiagnosticStore.RETENTION_MS)
assertFalse(
store
.snapshot("new", 1001 + SyncDiagnosticStore.RETENTION_MS)!!
.report
.getBoolean("hasInterruptedRound")
)
}
}
@Test
fun v6AddsSyncHistoryWithoutChangingLegacyRetention() {
assertEquals(6, AgentDiagnosticSchema.VERSION)
assertEquals(50, AgentDiagnosticRetentionPolicy.MAX_RECORDS)
DriverManager.getConnection("jdbc:sqlite::memory:").use { db ->
db.createStatement().use { it.execute(AgentDiagnosticSchema.createTableSql) }
AgentDiagnosticSchema.failureSnapshotStatements.forEach { sql ->
db.createStatement().use { it.execute(sql) }
}
AgentDiagnosticSchema.migrationStatements(5, 6, emptySet()).forEach { sql ->
db.createStatement().use { it.execute(sql) }
}
db.createStatement().use { s ->
s.executeQuery("SELECT COUNT(*) FROM sync_round").use {
assertTrue(it.next())
assertEquals(0, it.getInt(1))
}
}
}
}
}
internal class JdbcSyncSql(private val db: java.sql.Connection) : SyncSql {
override fun execute(sql: String, args: List<Any?>) {
db.prepareStatement(sql).use { s ->
args.forEachIndexed { i, v -> s.setObject(i + 1, v) }
s.executeUpdate()
}
}
override fun query(sql: String, args: List<Any?>): List<Map<String, String?>> =
db.prepareStatement(sql).use { s ->
args.forEachIndexed { i, v -> s.setObject(i + 1, v) }
s.executeQuery().use { r ->
buildList {
while (r.next()) add(
(1..r.metaData.columnCount).associate {
r.metaData.getColumnLabel(it) to r.getString(it)
}
)
}
}
}
override fun <T> transaction(block: () -> T): T {
db.autoCommit = false
try {
val value = block()
db.commit()
return value
} catch (e: Throwable) {
db.rollback()
throw e
} finally {
db.autoCommit = true
}
}
}
@@ -0,0 +1,128 @@
package cn.ilapage.goauto.agent.service
import cn.ilapage.goauto.agent.network.AgentApiException
import cn.ilapage.goauto.agent.network.HeartbeatResult
import cn.ilapage.goauto.agent.persistence.PendingPurchaseOutbox
import cn.ilapage.goauto.agent.persistence.PurchaseOutboxUploader
import org.junit.Assert.*
import org.junit.Test
class AgentSyncCycleTest {
@Test fun taskIdentityReadFailureRemainsObservableWithoutChangingCycleOrder() {
val error=IllegalStateException("private")
val result=AgentSyncCycle({ online }, { throw error }, { false }, {}, {}, { false }).run()
assertSame(error,result.failure)
assertFalse(result.canClaim)
}
@Test fun cancellationAndFatalErrorsAreNeverConvertedToBusinessFailures() {
for(error in listOf(java.util.concurrent.CancellationException(), OutOfMemoryError())) {
assertThrows(error.javaClass) {
AgentSyncCycle({ throw error }, { null }, { false }, {}, {}, { false }).run()
}
}
}
private val online = HeartbeatResult(7, true, false, 15)
private fun api(status: Int, code: String) = AgentApiException(status, code, "untrusted URL", false)
@Test fun `stale completed probe heartbeat mismatch rejected then refreshed heartbeat permits next claim`() {
val events = mutableListOf<String>()
var active: Long? = 42
var pending = true
val result = AgentSyncCycle(
heartbeat = { id -> events += "heartbeat:$id"; if (id != null) throw api(409, "DEVICE_TASK_MISMATCH") else online },
activeTaskId = { active }, executionActive = { false },
recover = { events += "recover" },
flush = {
PurchaseOutboxUploader(
{ listOf(PendingPurchaseOutbox(1,42,"probe","request","{}")) },
{ throw api(409,"PURCHASE_STATE_CONFLICT") },
{ fail("rejected probe must not be marked uploaded") },
{ fail("rejected probe must not establish handoff") },
{ _, _ -> events += "reject-probe"; pending = false; active = null },
).flush()
},
hasPending = { pending },
).run()
if (result.canClaim) events += "claim"
assertEquals(listOf("heartbeat:42", "recover", "reject-probe", "heartbeat:null", "claim"), events)
assertEquals(online, result.heartbeat)
}
@Test fun `authentication errors skip recovery and flush including unrecognized HTTP 401 and 403`() {
for ((status, code) in listOf(401 to "OTHER", 403 to "OTHER", 409 to "DEVICE_TOKEN_INVALID", 409 to "DEVICE_INSTALL_ID_CONFLICT", 409 to "DEVICE_DISABLED")) {
val result = AgentSyncCycle({ throw api(status, code) }, { 42 }, { false },
{ fail("recovery after auth failure") }, { fail("flush after auth failure") }, { true }).run()
assertFalse(result.canClaim)
assertEquals("AUTH_ERROR", result.failureCode)
}
}
@Test fun `heartbeat survives recovery or flush failure but claiming stops`() {
for (recoverFails in listOf(false, true)) {
var flushed = false
val result = AgentSyncCycle({ online }, { null }, { false },
{ if (recoverFails) error("disk") },
{ flushed = true; if (!recoverFails) throw api(500, "INTERNAL") }, { false }).run()
assertTrue(flushed)
assertEquals(online, result.heartbeat)
assertFalse(result.canClaim)
}
}
@Test fun `failed heartbeat still flushes but never claims and mismatch retries only once`() {
for (code in listOf("DEVICE_TASK_MISMATCH", "INTERNAL")) {
var heartbeats = 0
var flushed = false
val result = AgentSyncCycle({ heartbeats++; throw api(409, code) }, { null }, { false }, {},
{ flushed = true }, { false }).run()
assertTrue(flushed)
assertEquals(if (code == "DEVICE_TASK_MISMATCH") 2 else 1, heartbeats)
assertFalse(result.canClaim)
}
}
@Test fun `network failure leaves pending even if heartbeat succeeds`() {
var pending = true
val result = AgentSyncCycle({ online }, { 42 }, { false }, {}, { throw java.io.IOException("network") }, { pending }).run()
assertTrue(pending)
assertEquals(online, result.heartbeat)
assertFalse(result.canClaim)
}
@Test fun `active execution sends heartbeat without recovery or flush`() {
val result = AgentSyncCycle({ online }, { 42 }, { true }, { fail("recover") }, { fail("flush") }, { false }).run()
assertEquals(online, result.heartbeat)
assertFalse(result.canClaim)
}
@Test fun `retained irreversible local task prevents claim even without outbox`() {
val result = AgentSyncCycle({ online }, { 42 }, { false }, {}, {}, { false }).run()
assertFalse(result.canClaim)
}
@Test fun `authentication failure during recovery or flush stops round without heartbeat retry`() {
for (duringRecovery in listOf(true, false)) {
var heartbeats = 0
var flushes = 0
val result = AgentSyncCycle({ heartbeats++; throw api(409,"DEVICE_TASK_MISMATCH") }, { 42 }, { false },
{ if (duringRecovery) throw api(401,"OTHER") },
{ flushes++; throw api(403,"OTHER") }, { true }).run()
assertEquals(1, heartbeats)
assertEquals(if (duringRecovery) 0 else 1, flushes)
assertEquals("AUTH_ERROR", result.failureCode)
assertFalse(result.canClaim)
}
}
@Test fun `server failure is classified separately from transport failure`() {
val result = AgentSyncCycle({ throw api(503,"UNAVAILABLE") }, { null }, { false }, {}, {}, { false }).run()
assertEquals("SERVER_ERROR", result.failureCode)
}
@Test fun `claim failure cannot erase successful heartbeat except authentication`() {
assertNull(syncConnectionFailureCode(api(409,"PURCHASE_STATE_CONFLICT"), true))
assertNull(syncConnectionFailureCode(IllegalStateException("disk"), true))
assertNull(syncConnectionFailureCode(api(503,"INTERNAL"), true))
assertEquals("AUTH_ERROR", syncConnectionFailureCode(api(401,"OTHER"), true))
}
}
@@ -0,0 +1,59 @@
package cn.ilapage.goauto.agent.service
import org.junit.Assert.*
import org.junit.Test
class PurchaseRuntimeReconciliationTest {
@Test fun `manual current page cannot enter while sync owns recovery and flush`() {
val mutex = TaskExecutionMutex()
val syncing = java.util.concurrent.atomic.AtomicBoolean(false)
val syncEntered = java.util.concurrent.CountDownLatch(1)
val finishSync = java.util.concurrent.CountDownLatch(1)
val worker = java.util.concurrent.Executors.newSingleThreadExecutor()
val future = worker.submit {
synchronized(mutex) { assertTrue(syncing.compareAndSet(false, true)) }
syncEntered.countDown()
check(finishSync.await(3, java.util.concurrent.TimeUnit.SECONDS))
syncing.set(false)
}
try {
assertTrue(syncEntered.await(3, java.util.concurrent.TimeUnit.SECONDS))
assertFalse(tryAcquireCurrentPage(mutex, syncing, Long.MAX_VALUE))
assertNull(mutex.currentTaskId())
finishSync.countDown()
future.get(3, java.util.concurrent.TimeUnit.SECONDS)
assertTrue(tryAcquireCurrentPage(mutex, syncing, Long.MAX_VALUE))
} finally { finishSync.countDown(); worker.shutdownNow() }
}
@Test fun `reboot restored rejected task clears runtime so repurchase becomes idle`() {
var runtimeTaskId: Long? = 42
val currentDatabaseAttemptRejected = true
if (shouldClearRejectedPurchase(42, runtimeTaskId, "purchase", null, currentDatabaseAttemptRejected)) runtimeTaskId = null
assertNull(runtimeTaskId)
assertTrue(runtimeTaskId == null) // final runtime predicate in repurchaseLocalIdle
}
@Test fun `another task collection active executor or newer attempt cannot be cleared`() {
assertFalse(shouldClearRejectedPurchase(42, 43, "purchase", null, true))
assertFalse(shouldClearRejectedPurchase(42, 42, "collection", null, true))
assertFalse(shouldClearRejectedPurchase(42, 42, "purchase", 42, true))
assertFalse(shouldClearRejectedPurchase(42, 42, "purchase", 43, true))
assertFalse(shouldClearRejectedPurchase(42, 42, "purchase", null, false))
}
@Test fun `stopped service remains stopped after an in flight heartbeat completes`() {
val gate = AgentStatePublicationGate()
var connection = "CONNECTING"
gate.publish { connection = "ONLINE" }
assertEquals("ONLINE", connection)
gate.stop { connection = "STOPPED" }
gate.publish { connection = "ONLINE" }
assertEquals("STOPPED", connection)
val restartedService = AgentStatePublicationGate()
restartedService.publish { connection = "CONNECTING" }
assertEquals("CONNECTING", connection)
gate.publish { connection = "ONLINE" }
assertEquals("CONNECTING", connection)
}
}
@@ -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))
}
}
+37 -3
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Architecture-and-Code-Map
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Architecture-and-Code-Map.-
wiki_revision: bf3ce6efd67fde1fdb6eacd3248f186aea880391
synchronized_at: 2026-10-08T10:15:16Z
wiki_revision: 5508fe99e1040dff61d3bc6dbdf08ce0a1fd02ad
synchronized_at: 2026-10-10T03:10:31Z
<!-- gitea-wiki-mirror:end -->
<!-- gitea-wiki-mirror:start -->
@@ -656,7 +656,7 @@ Web 唯一展示位置为“采集采购 → SYB 同步记录”:列表状态
## Android 重购编排(#367)
实现绑定 `5430dc1`,工单分支尚未合并/安装/发布;无Server/Web、数据库或接口迁移。
实现绑定 `5430dc1`,2026-10-09 已合并 main `e26743c` 并安装集成APK;无Server/Web、数据库或接口迁移,真机批量采购仍待验收。
- `TaskHistoryFragment` 把采购搜索栏入口改为“重购”,发送 PREPARE/CONFIRM/STOP、显示 Service 内存状态;一次确认与逐条摘要由 `RepurchaseState` 提供,不由 Fragment 驱动采购生命周期。
- `RepurchaseBatchDiscovery` 注入既有设备历史查询和服务端HTTP Date时钟,负责全状态/50条页/30天窗口、相邻60秒边界、去重、完整性及重读首页;最多200页,不使用本地历史缓存执行重购。
@@ -664,3 +664,37 @@ Web 唯一展示位置为“采集采购 → SYB 同步记录”:列表状态
- `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 安装升级分别验收,测试不代替真实竖向颜色采集验证。
## Admin 列表按当前筛选导出全部页(#372,待验收)
实现绑定 `a4a793f`(分支 `feat/372-list-export`,未合并、未发布)。
- 采购管理与 SYB 商品页各增「导出」按钮:`web/src/utils/list-export.js` 先按当前筛选读第 1 页取总数,超过 5000 条不导出,否则逐页读取(采购每页 100、SYB 每页 500)再用 `web/src/vendor/Export2Excel.js`(经 `utils/save-excel.js` 按需加载)生成 xlsx;任一页或批次失败不生成文件;筛选条件以点击时复制的参数为准。无服务端导出接口、无异步导出任务。
- `GET /api/admin/v1/purchase-tasks` 的 `AdminListRequest`(`server/app/goauto/purchase/admin_query.go`)新增可选 `orderSubmittedFrom` / `orderSubmittedTo`(`YYYY-MM-DD`,含首尾两天,按 `order_submitted_at`,UTC+8 与 SYB 创建时间筛选一致);格式错误或起止倒置返回 `INVALID_REQUEST`;无下单时间的任务在设置该筛选时不出现。采购页筛选栏用日期范围选择器。
- `GET /api/admin/v1/return-matches` 的 `returnmatch.ListItem` 新增只读 `yeekeQuantity`(`server/app/goauto/returnmatch/service.go`,来自既有 `yeeke_return_item` join,不参与匹配);该接口原本就支持一次传多个 `sybProductId`,SYB 导出按每批 200 个商品 ID 调用,不逐行请求,且只采用 `matched`/`confirmed` 的匹配。
- 无数据库迁移、无新接口、无新权限;导出按钮不单独鉴权,能打开列表的用户(含采购员)都可导出。
## 设备心跳诊断与逐设备离线事务(#378,实现基线)
Server 实现绑定 `d84728d`,Android 绑定 `dbe7e98`,分支 `feat/378-heartbeat-diagnostics`;main 合并记录见 #378;未执行线上迁移、发布或装机。以下是分支实现,不代表线上或现有手机已经更新。
- `server/app/goauto/device/heartbeat.go`:成功且非 requestId 重放的心跳在核心事务提交后、HTTP 返回前尽力保存历史;设备 offline→online、超时离线和已有停用入口在状态变化事务内保存事件。没有新增启用入口,身份恢复/重新注册不补造配对事件。
- `device/diagnostics.go`:严格校验报告、独立 200ms 写历史 context、按生命周期运行清理;历史失败不改变在线判断。清理与每 15 秒离线扫描分离。
- `agent_heartbeat_log` 保存 device_id、received_at、request_id、上报/服务端任务 ID、busy、agent_version、校验后的报告 JSON 和状态。索引 `ix_heartbeat_device_received(device_id,received_at)`、`ix_heartbeat_received(received_at)`。
- `agent_device_status_event` 保存 device_id、occurred_at、from_status/to_status、固定 reason、last_heartbeat_at、failed_task_count、order_result_unknown_count;索引 `ix_device_event_occurred(occurred_at)`。失败与订单结果未知分别计数。
- 离线候选列表在事务外读取;每台设备单独事务,设备主键及相关任务 NOWAIT 锁定当前读,复核心跳和状态后使用已经锁定的任务 ID/状态更新任务与 attempt、设备和事件。禁止用跨设备旧快照重选任务。
- MySQL 3572 只回滚并跳过当前设备,下一轮再试;不持有前面设备的锁等待后面设备,不本轮循环重试。其他错误保留错误语义及此前真实提交计数。事件日志只在提交后输出;没有全系统锁顺序重构。
- Android `AgentForegroundService` 在实际同步开始时创建记录,包装注册、心跳、恢复、补传与领任务;`SyncDiagnosticRecorder` 保存步骤序号、单调时钟起止偏移与总耗时。恢复函数内部已处理的非认证 API 错误也被观察,原业务吞错、占位与后续流程不变。
- `SyncDiagnosticStore` 在 `goauto_diagnostics.db` v6 追加 `sync_round`,并以单行 `sync_meta` 保存跨清理、重启的递增序号。既有采集诊断、三个采购失败快照表及其保留策略不变。同步表每轮尽力清理到最近24小时且最多6000轮。
- `HeartbeatReportNegotiation` 由服务实例持有,不因每轮重建 `AgentApiClient` 而丢失协商状态;只确认实际发出的已完成报告边界。HTTP请求阶段由 `AgentApiClient.request` 标注,不依靠异常消息区分连接/响应超时。
- 当前任务上下文只采用实际可核验的设备/任务ID、attempt UUID及固定phase;采集和采购即使数字ID相同也不复用旧采购上下文。foreground表示Android前台服务已启动,不表示Agent Activity当前占据屏幕。
+90 -5
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Business-Rules-and-Glossary
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Business-Rules-and-Glossary.-
wiki_revision: c4870fd01a4569059fc0afc77179d14fbd286384
synchronized_at: 2026-10-08T10:15:21Z
wiki_revision: d427a46283561df71714929c13b7156ee42debea
synchronized_at: 2026-10-10T03:47:23Z
<!-- gitea-wiki-mirror:end -->
<!-- gitea-wiki-mirror:start -->
@@ -375,6 +375,8 @@ Admin 的「创建时间」范围按 SYB 货运单列表的 `t_stock.created`(
## SYB 采购强制当次规格探测(#215)
以下为 #373 之前的复用行为;#373 分支的限定复用和等待规则见“采购页面一次性交接与导航稳定等待”章节,未安装新版不能按新规则判断。
- 每个新 SYB 采购任务固定执行“首趟只读探测 → 服务端确定性优先/必要时 AI → 固化任务级精确规格 → 第二趟正式采购”。首趟只打开一次浏览器商品链接;匹配期间当前设备保留给同一任务,不领取其他采购或采集任务。连续进入第二趟且当前仍有 PDD 商品页或规格面板强证据时复用当前页、不再次打开链接;手动同任务重试,或当前处于 Agent、其他应用及缺少上述强证据时,重新打开任务固化的商品 URL。复用或重开均不严格核验标题、goodsId 或页面指纹,但仍要求 PDD 包名与商品/规格/订单页面结构安全证据。已有长期映射只作商品档案事实,不直接进入任务执行规格。
- 首趟候选与 `taskId`、`taskAttemptId`、`deviceId`、规则快照哈希和幂等结果哈希关联;第二趟失败不得回到首趟循环探测。备货 `stock/direct_select` 没有 SYB 目标规格,继续使用用户逐字选择的档案规格,不进入本规则。
- 候选和 Provider 结果仅保存颜色、尺码原始标签及结构化决策,不保存控件树、整屏截图、账号、地址、订单或支付数据;付款仍永久禁止。
@@ -676,7 +678,7 @@ Web 唯一展示位置为“采集采购 → SYB 同步记录”:列表状态
## 支付页安全返回后的核单观察(#365)
实现绑定 `37714d39b5ac8ff720cfa1d0d2609f9a8af3fbd9`,工单分支已实现,未合并 main、未安装或发布;自动化验证不代表真机核单成功率已改善。仅 Android 变化,Admin、Server、数据库和上传状态契约不改。
实现绑定 `37714d39b5ac8ff720cfa1d0d2609f9a8af3fbd9`,已合并 main 并随2026-10-09集成APK安装;自动化验证不代表真机核单成功率已改善。仅 Android 变化,Admin、Server、数据库和上传状态契约不改。
- 只有识别到 PDD 支付页且既有一次 Back 成功后,才记录单调时钟起点。在尚未取得订单/待付款上下文时,允许观察至该起点后约30秒,不因反复采样或上下文缺失重置;不能用 paymentPageObserved 代替成功返回事实。
- 无成功返回,或在原60次内已取得普通订单上下文的路径,保持原预算。返回失败、持续支付页25次、空窗口15次、微信恢复失败和未授权应用等独立失败保持,不保证每种失败等满30秒。
@@ -862,7 +864,7 @@ Android 0.9.64 / versionCode 77,源码 `6550b9f`(分支实现,尚未安装
## 图搜采集部分完成的自动关联资格(#368)
实现基线:工单分支 `fix/368-image-search-partial-link` 提交 `15ccab3`(2026-10-08);尚未合并 main 或发布,生产是否启用以发布记录为准。
实现基线:`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` 时允许自动关联。三项均为可选描述信息。
@@ -875,7 +877,7 @@ Android 0.9.64 / versionCode 77,源码 `6550b9f`(分支实现,尚未安装
## Android 本设备最后一批重购(#367)
实现绑定 `5430dc1f64fc0a4d8ef124ada7356e21a975151f`(2026-10-08,工单分支 `feat/367-last-batch-repurchase`);未合并 main、未安装或发布,真机批量采购仍待独立授权验收。
实现绑定 `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 拒绝;此轻量方案不保证任意并发删除/归属变动的数据库快照。
@@ -885,3 +887,86 @@ Android 0.9.64 / versionCode 77,源码 `6550b9f`(分支实现,尚未安装
- Service 独立串行后台调度观察,真正领取、执行、上传仍由原执行器和租约/任务锁负责。终态且执行锁、活跃记录和Outbox均收尾后才继续。检测新采集或采购工作停止剩余,本轮当前task本身不算新工作。
- 页面和通知栏“停止重购”只停止后续,已提交/执行的任务沿用原流程;切Tab、切PDD、界面重建不中断。单条单调时钟等待上限15分钟,超时只停止编排,不取消任务、不重置或再次下单。进程死亡/服务重建不恢复整轮,原单任务恢复不改。
- 本轮摘要与最小attempt关联仅在内存,不新增地址/树采集或数据库。接口、服务端、Web、权限及 #364 诊断契约不变。自动化回归和APK构建通过不代表真机页面/批量下单已验收。
## 采购页面一次性交接与导航稳定等待(#373 v4)
实现绑定分支 `fix/373-navigation-diagnostics` 提交 `dbf9a5f`;2026-10-09 与 #374 依次合并并推送 main,集成提交 `555a954605a83c12457ed1ada86eb732fe3bb023`。集成APK仍标识 `0.9.69-373-fix1 / code82`,须以哈希区分;未安装或发布。此节不代表线上旧版本已生效;替代此前仅凭商品结构复用页面的方式。
- 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或页面文本落盘。
## 下单前最终规格复核(#374 v3)
> 以下为 #374 版本行为;包含 #376 的分支构建已按本文后续“采购改地址前复核与提交提示检查”章节替代最终复核策略,尚未合并或发布。不把新规则归于未更新的旧 APK。
实现绑定 `6f1af70d0d2a4c70c31ba42e05c4321f08c6a78e`(原独立工单分支基于 `e26743c`);2026-10-09 已与 #373 集成并推送 main `555a954605a83c12457ed1ada86eb732fe3bb023`。集成版本同时包含导航等待与最终规格复核,未安装或发布,真机采购未验收。
- 仅替换 PurchaseLiveAutomation.finalConfirmation 的最终规格检查,不修改之前的规格选择/verifySummary、地址、单价、数量、提交目标与不可逆边界。没有 Server/Web、API 或数据库变化。
- 最终专用规范化只统一【】〔〕[]、()、全角字母数字与空白;压缩连续空白,不折叠大小写、不改同义词、不用通用 NFKC、不删除括号内容。选项使用解析器既有的规格 text(沿用 UI 尾价分离),目标和摘要不新增尾价或数量删除。
- 复用既有数量输入有界祖先的完整面板形态,但保留真实祖先关系与边界双重归属;滚动列表不等于整个弹层,摘要可位于列表上方/兄弟区域。范围内重新确认面板结构,不将全屏/root 当面板,不用仅坐标重叠的面板外文字。完整范围不可靠时禁用摘要,只使用可独立确认的有界规格滚动区域内选项。
- 最终确认专用解析参数默认 false,普通采集及既有调用不变。仅该路径保留同名独立物理选项;沿用原 optionBlock selected/checked 聚合,并合并同值、同矩形、真实祖先链的同一选项,不吞并同名兄弟候选。同一维度多组统一检查。
- 只检查下发非空维度,先拒绝同维度明确选中非目标、目标不可用、不同实际候选归一到同一目标或已知候选组合歧义;随后当前面板内规范化完全相等且 selected/checked 的目标可证明该维度。没有选中证明但也没有冲突时才用可信摘要,即使其他未选中候选仍可见也允许摘要补证,不使用历史 attempt 证明。
- 摘要仅剥离开头已识别的已选前缀和紧随的一个可选冒号,保留正文标点与内容。无前缀摘要仅沿用已有 quantity/header 结构识别且须属于完整面板。当前双维证据只覆盖“颜色 空格 尺码”,逗号分隔、反序、数量后缀或未知格式不猜测支持;仅一个维度时整段须等于该值,两维均空不作为证明。
- 多个不一致的可信摘要、已知组合歧义均拒绝。单个不匹配的摘要不能补证缺失维度;若所有必需维度已有当前精确选中证明且无上述冲突,则按选中证明优先通过。不声称候选缺失时能证明全部潜在 SKU 唯一性。
- 拒绝仍为 PURCHASE_SPEC_NOT_MATCHED,错误信息与诊断只附固定 dimension/stage/summaryPresent/selectedFound/conflict,不附页面文字、规格原文或地址。原本靠任意可见控件含有规格子串的通过路径已移除。
- 自动化证据:8 个相关套件共366项通过,0失败/错误/跳过;debug构建、release单测源码编译通过。完整单测因未修改的图搜返回测试忙循环而中止,不能宣称全量通过。结构夹具依据为 SpecPanelFixtures.sheet/taskOptionDedupSheet 及 PddProductDetailCollectorTest.prefixlessPanel;未执行真实采购,不以合成测试代替真机效果。
## 采购改地址前复核与提交提示检查(#376 v3)
实现绑定工单分支 `fix/376-pre-address-spec-verification` 提交 `9c7357609cee7c73b57edd030136ed12ef9485b6`(2026-10-09),尚未合并 main 或发布;此规则仅适用于包含 #376 的 Android 构建,旧版 #374 行为不因此改变。没有 Server/Web、业务库迁移或 API 边界协议变化。
- 改地址前,verifySummary 在每个目标维度和既有重定位滑动后的当前面板中检查未选提示。面板摘要的“请选择/請選擇”或唯一提交按钮的选择提示导致 PURCHASE_SPEC_SELECTION_UNCONFIRMED、reason=selection_prompt,停止后续地址动作;无唯一提交按钮时仍可检查摘要,不增加改地址前缺少按钮的门禁。
- 本次选择历史证明仅用于既有可见性缺口;目标当前可见、可选却未选中,同维无其他选中项且没有已选摘要时,不再由历史证明路径放行,reason=target_visible_unselected。目标不可见时保留原历史证明行为;不暴露选中属性且无摘要的布局可能提前失败。此前规格选择点击、数量设置、改地址前价格/数量复核不变。
- 修改地址后的 finalConfirmation 不再比较颜色/尺码值,不要求选中证据,也不核对当前数量;保留地址、提交目标唯一性和单价区间检查。摘要未选提示在此只记录,不拦截。唯一提交按钮的完整文字去空白后匹配 `^(选择|選擇).+(后|後)[,,](提交订单|提交訂單)$` 才新增拒绝 PURCHASE_SPEC_NOT_MATCHED、finalSpec;stage=selection_prompt。
- 按钮来源沿用原面板底部行 row 和点击文字节点 chosen;提示只组合该行内可见、自身带文字且没有带文字子孙、最近可点击祖先恰为 row 的节点,按 top/left 排序,不重复拼父子汇总、不跨独立按钮/摘要/规格列表或不可见节点。实际点击目标、行/叶并列选择和支付排除保持不变;enabled=true 不是已选规格的证明。
- finalConfirmation 位于 beforeOrderSubmit 之前,拒绝时不写提交边界、不点击,普通失败可让重购继续。submitOrderOnce 重新取屏后的同类提示只作兜底,零点击但边界已写入,仍为 order_result_unknown,重购停止等待人工核对;不移动持久化边界、不增加重试或付款。
- 本地 final_confirmation_json 新记录以 targetQuantity 表示下发数量,追加 observedQuantity(唯一可见可用数字数量输入,否则 null)、selectedSummaryPresent 与 summaryPromptPresent。仅记录数字/布尔观察值,不新增页面文字或个人信息;不用于拦截、不上传,旧记录不改写。
- 用户明确接受剩余风险:修改地址后规格被换成其他值,或数量重置,但按钮仍正常时会放行;价格与地址检查不能替代规格/数量一致性。观察值仅供事后核对,不证明采购结果正确。缺少提示也不构成 PDD 已选正确的正向证明。
- 不新增分享、OCR/VLM、滑动取证、自动重新选规格或付款;合成测试与 APK 构建不代表真实订单验证。安装、真实采购、合并和发布须按相应授权执行。
验证:19 个相关套件共418项通过,0失败/错误/跳过;Debug APK与release单测源码编译通过。执行器测试模拟提交边界回调,并验证本地JSON序列化;未执行真实SQLite/服务端边界端到端或真实采购验证。不宣称全量Android测试通过(既有图搜返回忙循环用例不在本次回归范围)。
## Android 采购结果明确拒收恢复(#377)
实现位于工单分支 fix/377-rejected-purchase-outbox(实现提交 8b75a95,运行占位与互斥补齐 309dce3);仅 Android,未合并 main、未安装或发布,真机恢复尚待独立授权验证。
- 每轮先发心跳,再在无任务执行锁时恢复中断任务及补传。心跳任务不一致不能挡住拒收收敛;收尾后只额外重发一次心跳。认证错误停止补传,只有最终心跳成功、没有未完成本地任务/待上传结果且收尾正常才领取新任务。不放宽服务端任务身份校验,不修改心跳间隔或离线阈值。
- 只有 HTTP 409 且错误码精确属于 PURCHASE_STATE_CONFLICT、PURCHASE_LEASE_EXPIRED、PURCHASE_RESULT_CONFLICT 才视为明确拒收。未知 409、网络异常、5xx 及认证错误不按拒收处理;缺少 retryable 不构成拒收证据。
- 本地拒收使用 rejected,不伪装 sent。按 outbox 与 task/attempt 归属事务保存,保留原 payload;不再自动补传该条,继续处理后续结果。拒收不触发上传成功或规格探测交接,交接凭证失效。拒收记录不再充当待上传占位;不改变服务端采购结果和原重购停止边界。
- order_created、order_result_unknown 或带订单号的拒收结果在采购页持续提示人工核对。“已核对”只持久隐藏提醒,不删除结果、不调用服务端、不允许再次下单;非订单拒收仍可在设置及任务详情查看。
- goauto_purchase.db v2→v3 只追加拒收时间、白名单错误码及人工核对时间三个可空列,旧结果与记录保持不变;v1 升级仍经过原 v2 追加列。旧版 SQLiteOpenHelper 不保证能打开 v3,不能靠卸载清数据回退,优先使用保留 v3 schema 的修复版本。
- 设置页区分同步中、真实心跳失败、已连接但有拒收;新说明采用固定文案,不展示任意服务端错误消息。本单只修复明确拒收后的持续阻塞,不代表最初心跳中断的原因已查明,历史诊断仍属于 #378。
- 拒收后只在执行锁空闲、当前运行标记属于同一采购任务且本地当前 attempt 已拒收时清理恢复占位,不清除其他采集/采购或新 attempt;手动当前页采集与同步共用既有 working/任务锁门禁。服务停止写 STOPPED,原服务实例的迟到状态更新不覆盖它。
## Admin 列表导出 Excel(#372,待验收)
实现绑定 `a4a793f`,分支 `feat/372-list-export`,未合并、未发布。
- 范围:采购管理和 SYB 商品页的「导出」导出**当前筛选条件下的全部页**,不是只导出当前页;筛选以点击导出时的值为准。总数超过 **5000** 条时提示缩小筛选范围并不导出;任一页读取失败不生成文件;无数据提示且不生成文件。分页读取期间数据变化可能有少量重复或遗漏,以导出时刻近似结果为准。
- 格式:时间 `YYYY-MM-DD HH:mm:ss`(浏览器本地时区);金额单位元,写成 Excel **数字单元格**(按分换算,例如 1230 分 → 12.3,可直接求和)并显示为 `0.00`;空值留空,0 写成 0;文件名 `采购导出_YYYYMMDD-HHmmss.xlsx`、`SYB商品导出_YYYYMMDD-HHmmss.xlsx`。不导出收货人、地址、账号等个人信息。
- 采购导出列(按顺序):SYB订单号、虾皮商品ID、颜色、尺码(均为 SYB 目标规格快照)、数量、状态(页面同款中文)、PDD单号、下单时间、价格(元)(数字单元格)。价格取 `pddOrderAmountCent / 100`,即 Agent 在 PDD 待付款页读到的应付金额;旧版 Agent 或人工补录前为空则留空。要导出成功采购,先把状态筛为「订单已创建」并按需选择下单日期。
- 采购管理新增「下单日期」筛选:含首尾两天,按 `order_submitted_at`,日期边界 UTC+8,与 SYB 商品页创建时间筛选一致;没有下单时间的任务在设置该筛选时不出现。
- SYB 商品导出列(按顺序):SYB订单、**店铺**、虾皮商品ID、**SYB售价(TWD)**、SYB颜色、SYB尺码、SYB数量、yeeke颜色、yeeke尺码、yeeke数量、匹配时间。店铺取商品行 `shopName`(SYB 订单的店铺,与页面「店铺」列同值,文本单元格,无值留空,不使用店铺筛选下拉的显示名);yeeke 颜色/尺码来自该商品有效(`matched` 或 `confirmed`)退货匹配的 `variationName`,按第一个英文或中文逗号拆分,没有逗号时整段放 yeeke 颜色;yeeke 数量为对应退货商品数量;SYB售价(TWD)取商品行 `unitPriceCent / 100`,与页面「售价」列同源(SYB `detail/listByStock` 的 `productPrice`,单件成交单价,元/TWD),数字单元格,无值留空;没有有效匹配时 yeeke 列与匹配时间留空。要导出已用退货,先把处理阶段筛为「已用退货」。
- 权限:不新增按钮权限,能打开列表的用户(含采购员)都可导出。
## 设备离线诊断的事务边界(#378,实现基线)
Server实现 `d84728d`、Android实现 `dbe7e98`,main 合并记录见 #378;未发布或装机;本节不代表线上/手机行为已经更新。
默认心跳 15 秒、离线阈值 45 秒、离线扫描 15 秒不变。扫描逐设备独立事务:锁定最新设备/任务状态后,将设备离线、相关任务和 attempt 收敛、对应状态事件一起提交。锁争用时本设备不作任何部分修改,跳过后继续其他设备,下轮再检查;不自动重试采购或换机。
运行中采集和未提交订单采购仍转失败;已 `order_submit_started` 的采购仍转 `order_result_unknown`,人工核对、禁止自动重派。两个数量分开记录。诊断历史是尽力保存,不作为设备在线或任务业务状态的事实来源;事件失败回滚当前设备业务变化,其他已提交设备保持不变。
Android同步诊断不改变#377的心跳→恢复→补传→必要的一次任务不一致心跳→符合条件才领任务的顺序。诊断failure是本轮观察到的同步错误,不等同于业务任务失败;恢复内部暂缓处理的API错误也记录,但不改变原恢复占位、后续flush/claim规则,不增加采购重试或付款动作。
可恢复诊断存储异常不阻断同步,取消和致命错误不记成假成功。同步记录独立保留24小时/6000轮,不占采集50条额度;旧进行中记录只表示中断/不完整,不伪造原现场、失败原因或成功。报告确认不会清除发送期间新增失败;本地确认失败或响应丢失允许重复,报告不能用于改变设备在线或采购状态。
+87 -3
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Troubleshooting
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Troubleshooting
wiki_revision: fee37efc79b644ecfa3237283dd45f0a09796f44
synchronized_at: 2026-10-08T03:25:33Z
wiki_revision: d06fa26d85339641e3d67e5ee6314205173c24c5
synchronized_at: 2026-10-10T03:10:51Z
<!-- gitea-wiki-mirror:end -->
# 故障排查
@@ -76,7 +76,7 @@ Agent 0.9.13 起,颜色发现阶段在本地 `goauto_diagnostics.db` 的 `agen
记录只包含颜色行数、各行值数量、可点击颜色数、不可点击候选数、首次颜色点击前的颜色/尺码选中数、是否存在“已选”摘要、横向滑动次数、终止原因和耗时,不保存规格文案、价格、摘要原文、坐标、链接、goods_id、控件树、XML 或截图。
只对 Debug APK 使用以下读取流程:
以下仅为 v4 之前纯结构化诊断库的历史读取方式。v4 及之后包含采购失败私有现场,不再按此导出整库;请使用下方“Android 颜色发现诊断 v5”中的定向只读方式:
```powershell
adb shell run-as cn.ilapage.goauto.agent ls databases
@@ -136,3 +136,87 @@ adb -s <serial> shell run-as cn.ilapage.goauto.agent sqlite3 -readonly databases
- 完整订单证据出现就结束;超时保留原订单结果未知语义,不能因为等待更久就把任务改为可重试失败或自动重下单。
- 固定期限从 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 工单为准。
## 采购结果拒收后设备持续离线(#377)
修复绑定工单分支 fix/377-rejected-purchase-outbox 的 8b75a95、309dce3;尚未合并 main、安装或发布。自动化覆盖不等于真实设备验收,不能假定线上手机已具备本节恢复行为。
旧版同步在心跳之前补传,一条结果被服务端永久拒收就能让每轮跳过心跳及领任务。离线之后本地仍携带旧任务 ID,又可能被心跳的 DEVICE_TASK_MISMATCH 拒绝;仅把心跳前置不足以恢复。#377 先发心跳,仍进行有界拒收收尾,再在任务不一致时重发一次心跳;认证失败不会继续收尾,执行中的任务不会被恢复流程并发接管。
排查步骤:
1. 核实 APK 对应提交,不仅看版本名(本次未改 versionCode/versionName)。设置页“正在同步”不代表断线;真实心跳网络、服务端或认证失败使用固定原因说明;任务处理错误不再覆盖此前成功的连接状态。
2. “已连接 · 有 N 条采购结果被服务端拒收”表示本地已有 rejected 结果,不等于服务端接收成功。可在设置页“查看拒收结果”和对应采购详情查看;有订单证据的结果还在采购页顶部持续提醒。
3. 人工核对 PDD 实际订单及后台任务。需要补录订单时沿用 Admin 原有人工流程;Agent 的“已核对”只隐藏这条本机提醒,不能代替后台保存、恢复回填、解锁重试或完成任务。原结果/订单号与核对时间继续保存。
4. 未知 409、网络失败或 5xx 保持 pending;如果仍卡住,核对错误分类及原任务状态,不自动把 pending 改成 sent。不反复重试真实采购来试探是否已下单。
5. 早期现场曾经另获授权,通过同时修改 outbox 与本地任务 upload_status=sent 恢复设备,但这不代表服务端已收件,也会把订单号留在手机。它只是历史应急处置,不是新版运维步骤,不应复制到脚本或作为常规修复。
数据库升级仅限手机私有 goauto_purchase.db:v3 在 purchase_outbox 添加 rejected_at、rejection_error_code、acknowledged_at;保留原始 payload 和任务数据。不得把整库、订单/地址内容上传到工单、Wiki、普通日志或 SynapBus。旧 APK 不保证能够降级打开 v3;回退优先使用保留 v3 的兼容修复构建,不卸载清数据、不擅自降低库版本。
已验证 SQLite JDBC 执行同一生产迁移/拒收/核对 SQL、事务回滚及文件重开;尚未执行 Android SQLiteOpenHelper 仪器测试、真机界面或真实采购。最初心跳和结果同时中断的原因仍未查明,#378 的诊断计划不属于本修复。
## 心跳历史、离线事件与锁争用(#378,实现基线)
Server适用 `d84728d`、Android适用 `dbe7e98`,main 合并记录见 #378;未发布或装机;先确认目标实例/手机确实包含实现并完成对应迁移。旧版本没有下列表,不能直接执行这些查询。
经授权只读排查时用参数化查询指定设备和时间,不输出 Token、任务正文或其他个人数据:
```sql
SELECT device_id, received_at, request_id, current_task_id, running_task_id,
busy, agent_version, client_report_status
FROM agent_heartbeat_log
WHERE device_id = ? AND received_at >= ? AND received_at < ?
ORDER BY received_at, id;
SELECT device_id, occurred_at, from_status, to_status, reason,
last_heartbeat_at, failed_task_count, order_result_unknown_count
FROM agent_device_status_event
WHERE device_id = ? AND occurred_at >= ? AND occurred_at < ?
ORDER BY occurred_at, id;
```
- 心跳记录相邻间隔超过 45 秒只说明历史有缺口;200ms 历史写入超时或失败也会缺记录,不能单凭此宣称设备离线。
- 实际离线次数/时长以同设备 timeout/resumed 事件配对为准;仅有一端、跨时间窗、停用或身份恢复造成的不配对单列,不把缺少事件伪造为完整区间。
- `heartbeat_timeout` 中失败数不包含 `order_result_unknown_count`,后者表示订单已提交后的未知结果,不能自动重下单。
- 离线扫描遇到 MySQL 3572 只记固定锁争用分类及 deviceId 的 warn;它表示该设备当前被操作占用,未发生离线状态变更,等下一轮扫描。其他设备继续处理。
- 非 3572 数据库错误仍报告;事件写入失败会回滚该设备的状态与任务,已经成功处理的其他设备不回滚。不得通过删除任务或清空诊断来掩盖错误。
- 需要核对锁范围时对实际锁定 SQL 做 EXPLAIN;小型测试数据的执行计划不等于线上计划,不未经授权修改生产索引。
手机上经授权只读查询 `goauto_diagnostics.db` 的同步表,参数时间为epoch毫秒;不要为心跳排错公开整库,库内其他表可能含敏感失败现场:
```sql
SELECT id, process_id, started_at, ended_at, start_gap_ms, duration_ms,
device_id, task_id, attempt_id, phase, validated_network, transport,
foreground, status, failure_step, failure_category, failure_duration_ms,
http_status, api_code, exception_class, steps, completion_seq, reported
FROM sync_round
WHERE started_at >= ? AND started_at < ?
ORDER BY id;
```
- started_at/ended_at是墙钟对照;start_gap_ms、duration_ms、steps里的startedOffsetMs/endedOffsetMs来自单调时钟,不用墙钟相减解释耗时。新服务实例不延用上个实例的单调起点。
- process_id是诊断服务实例的随机标识,不是Android系统PID。
- running表示诊断没有完整结束,可能是卡住、进程退出、取消或诊断写入失败,不能仅凭它断言崩溃原因。旧实例running在保留期内一直触发hasInterruptedRound,报告确认不会把它改成成功/失败或隐藏。
- 一轮中观察到步骤失败(包括恢复内部暂缓处理的500/409),诊断可记failure,即使业务随后恢复连接;这不是采集/采购任务失败,也不能据此重下单。已正常收敛的明确拒收仍按#377处理。
- timeout_connect/timeout_response/timeout_unknown根据请求实际阶段分类,未知阶段不猜。诊断故障只输出sync_diagnostic_persistence_failed或sync_diagnostic_context_failed固定提示;不附错误正文、URL或SQL参数。
- reported只表示Agent已收到该报告请求的成功响应并在本地确认,不保证Server历史写入成功;响应丢失或确认失败可能重复补报。
+33 -2
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Android-Agent-API-Contract
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Android-Agent-API-Contract.-
wiki_revision: 2102252a3f0ecb646b49941cc603423abb76ca2c
synchronized_at: 2026-10-08T07:11:01Z
wiki_revision: 1dc3c7550c0c4da4291e08b840170d861042e97a
synchronized_at: 2026-10-10T03:10:58Z
<!-- gitea-wiki-mirror:end -->
<!-- gitea-wiki-mirror:start -->
@@ -1530,3 +1530,34 @@ Server追加迁移 `1791400000000_purchase_failure_snapshot.go` 建立purchase_f
- 有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设置,不新增开关;传输私有原始内容时同样存在明文风险,部署须确认现有传输策略。
## 心跳诊断报告契约(#378,Server/Android 实现基线)
Server绑定 `d84728d`、Android绑定 `dbe7e98`;main 合并记录见 #378;未在线迁移/发布或装机。本节契约已在实现基线并作本地跨端验证,不代表现有手机已经补报。
心跳成功响应追加 `acceptsClientReport: true`;原 requestId 幂等及 currentTaskId 一致性校验不变。请求可以省略 `clientReport`(记录状态 none);显式 null、非法类型/字段/值只使报告为 client_report_invalid,历史 JSON 为 NULL,不让合法基础心跳失败。主 JSON 非法仍按原错误处理,整个请求上限仍为 64KiB。
报告 JSON 原始片段最多 2048 bytes,必须包含以下 8 个精确 camelCase 键(大小写别名不接受),不允许额外键:
| 字段 | 约束 |
|---|---|
| snapshotSeq | 整数 1..9007199254740991 |
| failedRounds | 整数 0..1000000 |
| latestFailureAt | null 或有效非零 RFC3339 时间 |
| latestFailureStep | null 或 sync/register/heartbeat/recover/flush/claim |
| latestFailureCategory | null 或 api_auth/api_conflict/api_server/api_other/timeout_connect/timeout_response/timeout_unknown/network/exception |
| latestFailureDurationMs | null 或整数 0..86400000 |
| previousSyncDurationMs | null 或整数 0..86400000 |
| hasInterruptedRound | 非 null 布尔 |
failedRounds 为 0 时四个 latestFailure* 必须全为 null;大于 0 时四个均须有效且非 null。前三个非 nullable 标量为 snapshotSeq、failedRounds、hasInterruptedRound。只存经校验并重新编码的白名单,不存 URL、异常消息、正文或页面内容。无有效正数快照序号时应省略报告。
服务端仅对成功非 requestId 重放的心跳写历史,核心事务提交后使用独立 200ms context,写失败不改变响应;acceptsClientReport 表示协议能力,不是历史持久化保证。
Android 已实现的协商边界:上次成功心跳明确 true 才在下一次携带报告;缺少标志/false、进程重启、地址或认证上下文变化撤销缓存。带报告请求遇旧版明确 HTTP422/INVALID_REQUEST/“请求 JSON 无效”时,可移除报告并沿用 requestId 最多重发一次基础心跳;401/403、任务冲突、网络或5xx不触发此兼容重发。基础回退成功不能确认报告。只确认实际发送快照及之前的本地失败,发送期间新失败保留;丢响应/确认失败允许重复,不代表服务端按 snapshotSeq 去重。
Agent在响应设备身份校验通过后才更新协商和确认报告。兼容回退响应不确认报告,也不直接授予补报能力,下轮基础心跳重新协商。HTTP连接/响应超时仍为10秒/15秒,不因诊断改变。
snapshotSeq来自持久递增序号;本地同时捕获“已完成记录”的序号边界,成功响应只确认这条边界以前的失败。当前正在运行的一轮即使在发送期间后来失败,也不会被较早快照清除。hasInterruptedRound表示保留期内是否存在旧服务实例的running记录,确认报告不清除该事实。
诊断数据库打开、写入、读取、确认或清理的可恢复异常只损失诊断证据,正常同步继续;不吞取消或fatal error,不将不完整持久化伪装为成功。报告和本地同步表均不保存异常message、URL、请求/响应正文或PDD页面内容。
+92 -3
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Deployment-and-Operations
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Deployment-and-Operations.-
wiki_revision: 6ca0b35cb1e8058b2930a9a01029822af8b571d2
synchronized_at: 2026-10-08T07:10:52Z
wiki_revision: b50250e0d61eff0b33f468e7cdf74139fe76bb65
synchronized_at: 2026-10-10T03:10:43Z
<!-- gitea-wiki-mirror:end -->
<!-- gitea-wiki-mirror:start -->
@@ -312,7 +312,7 @@ Provider 故障日志只允许记录调用关联 ID、操作类型、耗时、
## 采购失败现场诊断部署与保留(#364)
实现绑定 `4581ee5`;本节为新版本部署要求,不表示线上已迁移/发布。#365/#366 为Android局部行为,#366仅②编辑框就绪/hint保护已交付,①地址列表识别仍待现场证据。
实现绑定 `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。
@@ -320,3 +320,92 @@ Provider 故障日志只允许记录调用关联 ID、操作类型、耗时、
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接受就认定商品已加载。
## #373 + #374 合并 main 与集成构建(2026-10-09)
- 按用户授权的顺序先合并 #373(`1e924fa41195281a3223003a69c8c16ae37f8aa5`),再合并 #374(`555a954605a83c12457ed1ada86eb732fe3bb023`),保留两单提交历史;main已推送。源码无冲突,三份Wiki镜像选择包含两单内容的较新已导出版本,未手改规则正文解决冲突。
- 集成验证25套件478项通过,0失败/错误/跳过;compileReleaseUnitTestKotlin与assembleDebug通过。不是全量单测结论;#374记录的未修改图搜返回测试忙循环未在本次重跑。未进行真机采购。
- 集成APK位于本机 `D:/OPC/goauto-worktrees/release-373-374/android/app/build/outputs/apk/debug/app-debug.apk`,版本仍为 `0.9.69-373-fix1 / code82`,**已同时包含#374,不能只靠版本文字区分前一#373单独包**。SHA256 `6e2d0e0ed1eb943b3f168bb16d628d2c2bb4ce3b7c7c8b9e567dcdf8259150f8`;apksigner校验通过,沿用原签名证书 `bf86d7465c6092be74ce9c4187eb30c9a7d045c3b91891a547e2598429766dec`。
- 本次仅合并、验证和推送,未覆盖安装、未执行真实采购/付款、未发布或重启服务;不需要业务库迁移。手机仍使用此前安装版本。后续装机须另行授权,保留原签名及用户数据。
## #372 Admin 列表导出发布(2026-10-10)
- 用户授权合并 main 并发布。#372 以 `--no-ff` 合并为 main `72a15f8`(同时包含此前已在 main 的 #377 Android 提交,本次不构建或安装 Android)。合并时 `docs/03` 镜像冲突,选用包含 #377 与 #372 两段的较新导出版本(Business-Rules-and-Glossary@1c57a86),`sync --check` 通过。
- 发布目录 `/home/goauto/releases/20261010-72a15f8-372`,上一目录 `/home/goauto/releases/20261009-e26743c-integrated` 保留供回滚。自上次发布 `e26743c` 以来 Server/Web 的源码变化只有 #372:采购列表新增可选下单日期参数、`return-matches` 列表新增只读 `yeekeQuantity`、两页导出按钮。没有数据库迁移、权限或菜单变更,未执行 migrate。
- 切换前复查:采集、采购及 attempt 都没有执行中的任务;有 1 个 pending 采购任务保持原状,没有取消、重置或发起业务。config 从上一发布逐字复制(diff 一致);static/temp/var 沿用同一真实目录;上一发布的 js/css 哈希资源以不覆盖方式保留,便于已打开页面加载旧分块。发布目录 0755、`www` 用户可读 index 已在切换前确认。
- Server 为 Linux amd64、CGO_ENABLED=0、go1.26.5 构建,SHA256 `ab3943a1261f438eaad4d4acf006910e8a996cf97afc18a399ad5bee6c8f3a1f`,运行进程二进制一致;Web 入口 index.html SHA256 `c3465960bd6f450d7b0d51994475bfdc126c7b6a47068d92bad9b4ac751d0529`。current 原子切换,goauto.service 重启后 active;Nginx 配置未变,未 reload。
- 按内容验收:公网 `/` 与 `/login` 返回同一 index(含 `id="app"`,无欢迎页),10 项入口 JS/CSS 200,`/api/v1/health` 200,验证码 code=200;未登录采购列表(带下单日期参数)与 return-matches 业务码 401;服务日志无 panic/fatal/1146/1054。
- 未验证:登录后采购管理、SYB 商品多页导出,以及采购员账号导出,待业务验收。两台采购手机在发布前(10-09 晚)已离线,与本次发布无关。
- 回滚:Server/Web 成套把 current 切回 `/home/goauto/releases/20261009-e26743c-integrated` 并重启;无迁移,不需要处理数据库。
## #372 v2 导出售价列的纯 Web 发布(2026-10-10)
- 用户授权合并 #372 v2 并更新线上。`feat/372-export-price` 以 `--no-ff` 合并为 main `4ad59bd`;合并后 Web 单元测试 93/93 通过,`build:prod` 通过。与上一发布 `72a15f8` 相比,Server 源码没有变化。
- 发布目录 `/home/goauto/releases/20261010-4ad59bd-372v2-web`,只替换 dist。goauto-server 从上一发布目录逐字复制(cmp 一致),config 一致,static/temp/var 沿用同一真实目录;上一版 js/css 哈希资源以不覆盖方式保留。index.html SHA256 `83c2dfa845c29073234bbdaaabac0690c46b51d32c85609aaf2743b854915a57`。
- 已确认 Nginx root 与运行进程 `GOAUTO_WEB_DIST` 都指向 `/home/goauto/current/dist`,所以按 #363 的纯 Web 方式只原子切换 current,不重启 GoAuto 或 Nginx;切换前后进程 PID 4786 不变,服务 active。
- 按内容验收:`/` 与 `/login` 返回新 index(含 `id="app"`,无欢迎页),10 项入口 JS/CSS 200,导出模块包含新表头「SYB售价(TWD)」,health 200,验证码 code=200,未登录业务接口 401。
- 未验证:登录后实际导出,以及 Excel 打开后售价与页面一致、能否求和,待业务验收。
- 回滚:把 current 原子切回 `/home/goauto/releases/20261010-72a15f8-372` 即可,不需要重启或处理数据库。
## 设备心跳诊断迁移与回退(#378,实现基线)
Server实现 `d84728d`、Android实现 `dbe7e98`,main 合并记录见 #378;未执行线上迁移、发布、重启或装机。以下为后续经授权部署时的边界,不是本次线上操作记录。
- 追加迁移 `1791600000000_agent_diagnostics.go` 已注册,新增 `agent_heartbeat_log`、`agent_device_status_event` 及索引;不改业务表结构。本轮并发修正不另加迁移或索引。
- 本地验证只能使用明确隔离且初始为空的 MySQL 8 库 `goauto_378_test`,凭据由当前进程注入 `GOAUTO_378_TEST_MYSQL_DSN`,执行 `go test ./app/goauto/device -run TestMySQLAgentDiagnostics -count=1 -v`(cwd server)。不得把线上或本地业务库 DSN 用于该测试;测试会创建并清理合成表。
- 现有迁移框架只有 up;本地删除新诊断表/版本记录后 re-up 的测试不等于存在生产 down 命令。MySQL DDL 隐式提交,不能承诺事务回滚整批 DDL。
- 常规运行回退停止诊断写入和能力广告,保留表及数据;不自动 DROP 表。删除生产数据或执行迁移仍需单独授权。
- API 生命周期中独立清理:心跳历史每小时清理严格超过 7 天的数据,状态事件每天清理严格超过30天的数据,每次至多5000条,剩余等下轮;不占离线15秒扫描,不修改Admin定时任务开关。
- 离线扫描使用 MySQL8 的 NOWAIT,争用只跳过本设备。测试执行计划仅是测试库证据,不代表线上数据分布;不据此擅自新增索引。
- Android诊断库v5→v6仅追加sync_round和单行sync_meta;v1旧库按既有升级链追加,保留原采集及采购失败快照。旧APK的SQLiteOpenHelper不保证能打开v6,回退应使用保留v6结构的修复构建,不卸载/清数据、不自动降库。
- 本地Android验证(cwd仓库根,JDK17及现有Android SDK):`./android/gradlew.bat -p android :app:testDebugUnitTest --tests '*HeartbeatReport*Test' --tests '*AgentTransportTimeoutTest' --tests '*SyncDiagnostic*Test' --tests '*HandledRecoveryDiagnosticTest' --tests '*AgentDiagnosticStoreMigrationTest' --tests '*AgentSyncCycleTest' :app:assembleDebug :app:compileReleaseUnitTestKotlin`。发布前还应执行#377受影响的outbox、运行占位、互斥、重购等回归;SQLite JDBC验证不等同于真机升级。
- Android兼容性测试在`android/build/diagnostics378-client-reports.json`生成纯合成HTTP捕获报告;在server目录将该绝对路径注入`GOAUTO_378_ANDROID_REPORTS`,执行`go test ./app/goauto/device -run TestAndroidGeneratedHeartbeatReports -count=1 -v`,使用真实validator/handler验证三类报告与重放。未设置路径时该项明确skip,不得据此宣称跨端通过;产物不提交Git。
- 线上迁移、发布、重启、装机仍需单独授权;本地构建/夹具不代表真机或线上30分钟心跳验收。
@@ -0,0 +1,89 @@
package device
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
// Feed reports captured by Android's real HTTP client into the real Server
// handler. This optional cross-build check uses synthetic test data only.
func TestAndroidGeneratedHeartbeatReports(t *testing.T) {
path := os.Getenv("GOAUTO_378_ANDROID_REPORTS")
if path == "" {
t.Skip("set GOAUTO_378_ANDROID_REPORTS to the Android test-generated JSON array")
}
data, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
var reports []json.RawMessage
if err := json.Unmarshal(data, &reports); err != nil {
t.Fatal("Android report fixture is not a JSON array")
}
if len(reports) < 3 {
t.Fatal("expected zero-failure, failed and interrupted reports")
}
db := openTestDatabase(t)
service := newTestService(t, db)
_, registered := registerHeartbeatDevice(t, service)
gin.SetMode(gin.TestMode)
router := gin.New()
router.POST("/heartbeat", Handler{DB: db}.Heartbeat)
var sawZero, sawFailure, sawInterrupted bool
for i, raw := range reports {
t.Run(fmt.Sprintf("android_report_%d", i), func(t *testing.T) {
canonical, status := validateHeartbeatClientReport(raw)
if status != "accepted" || canonical == nil {
t.Fatalf("Android-generated report rejected: classification=%s", status)
}
var report heartbeatClientReport
if err := json.Unmarshal(raw, &report); err != nil {
t.Fatal("report decode failed")
}
sawZero = sawZero || report.FailedRounds == 0
sawFailure = sawFailure || report.FailedRounds > 0
sawInterrupted = sawInterrupted || report.HasInterruptedRound
id := uuid.NewString()
body := fmt.Sprintf(`{"requestId":%q,"currentTaskId":null,"clientReport":%s}`, id, raw)
for replay := 0; replay < 2; replay++ {
req := httptest.NewRequest(http.MethodPost, "/heartbeat", strings.NewReader(body))
req.Header.Set("Authorization", "Bearer "+testDeviceToken)
res := httptest.NewRecorder()
router.ServeHTTP(res, req)
if res.Code != http.StatusOK {
t.Fatalf("heartbeat status=%d", res.Code)
}
var response struct {
Data struct {
AcceptsClientReport bool `json:"acceptsClientReport"`
} `json:"data"`
}
if json.Unmarshal(res.Body.Bytes(), &response) != nil || !response.Data.AcceptsClientReport {
t.Fatal("successful response did not advertise report capability")
}
}
var rows []struct {
DeviceID uint64
ClientReportStatus string
ClientReportJSON *string
}
if err := db.Table("agent_heartbeat_log").Where("request_id = ?", id).Find(&rows).Error; err != nil {
t.Fatal(err)
}
if len(rows) != 1 || rows[0].DeviceID != registered.DeviceID || rows[0].ClientReportStatus != "accepted" || rows[0].ClientReportJSON == nil || *rows[0].ClientReportJSON != *canonical {
t.Fatal("accepted Android report was not stored once with the correct device and canonical payload")
}
})
}
if !sawZero || !sawFailure || !sawInterrupted {
t.Fatal("fixture must cover zero-failure, failed and interrupted reports")
}
}
+165
View File
@@ -0,0 +1,165 @@
package device
import (
"bytes"
"context"
"encoding/json"
"errors"
"time"
"go-admin/app/goauto/models"
log "github.com/go-admin-team/go-admin-core/logger"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
const heartbeatHistoryBudget = 200 * time.Millisecond
type heartbeatClientReport struct {
SnapshotSeq int64 `json:"snapshotSeq"`
FailedRounds int64 `json:"failedRounds"`
LatestFailureAt *time.Time `json:"latestFailureAt"`
LatestFailureStep *string `json:"latestFailureStep"`
LatestFailureCategory *string `json:"latestFailureCategory"`
LatestFailureDurationMs *int64 `json:"latestFailureDurationMs"`
PreviousSyncDurationMs *int64 `json:"previousSyncDurationMs"`
HasInterruptedRound bool `json:"hasInterruptedRound"`
}
func validateHeartbeatClientReport(raw json.RawMessage) (*string, string) {
if len(raw) == 0 {
return nil, "none"
}
invalid := func() (*string, string) { return nil, "client_report_invalid" }
if len(raw) > 2048 {
return invalid()
}
var fields map[string]json.RawMessage
if json.Unmarshal(raw, &fields) != nil || len(fields) != 8 {
return invalid()
}
// encoding/json matches struct keys case-insensitively. Require every
// exact contract key before decoding so aliases cannot hide a missing key.
for _, name := range []string{"snapshotSeq", "failedRounds", "latestFailureAt", "latestFailureStep", "latestFailureCategory", "latestFailureDurationMs", "previousSyncDurationMs", "hasInterruptedRound"} {
if _, ok := fields[name]; !ok {
return invalid()
}
}
for _, name := range []string{"snapshotSeq", "failedRounds", "hasInterruptedRound"} {
if v, ok := fields[name]; !ok || bytes.Equal(bytes.TrimSpace(v), []byte("null")) {
return invalid()
}
}
var report heartbeatClientReport
decoder := json.NewDecoder(bytes.NewReader(raw))
decoder.DisallowUnknownFields()
if decoder.Decode(&report) != nil || report.SnapshotSeq < 1 || report.SnapshotSeq > 9007199254740991 || report.FailedRounds < 0 || report.FailedRounds > 1000000 {
return invalid()
}
for _, v := range []*int64{report.LatestFailureDurationMs, report.PreviousSyncDurationMs} {
if v != nil && (*v < 0 || *v > 86400000) {
return invalid()
}
}
if report.FailedRounds == 0 {
if report.LatestFailureAt != nil || report.LatestFailureStep != nil || report.LatestFailureCategory != nil || report.LatestFailureDurationMs != nil {
return invalid()
}
} else {
if report.LatestFailureAt == nil || report.LatestFailureAt.IsZero() || report.LatestFailureStep == nil || report.LatestFailureCategory == nil || report.LatestFailureDurationMs == nil {
return invalid()
}
if !oneOf(*report.LatestFailureStep, "sync", "register", "heartbeat", "recover", "flush", "claim") || !oneOf(*report.LatestFailureCategory, "api_auth", "api_conflict", "api_server", "api_other", "timeout_connect", "timeout_response", "timeout_unknown", "network", "exception") {
return invalid()
}
}
canonical, _ := json.Marshal(report)
value := string(canonical)
return &value, "accepted"
}
func oneOf(value string, allowed ...string) bool {
for _, v := range allowed {
if value == v {
return true
}
}
return false
}
func (s *Service) saveHeartbeatHistory(ctx context.Context, row models.AgentHeartbeatLog) {
// Client cancellation after the core commit must not suppress history. There
// is deliberately no goroutine: the caller waits at most this DB deadline.
writeCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), heartbeatHistoryBudget)
defer cancel()
err := s.DB.WithContext(writeCtx).Session(&gorm.Session{Logger: logger.Default.LogMode(logger.Silent), SkipDefaultTransaction: true}).Create(&row).Error
if err != nil {
classification := "database_error"
if errors.Is(err, context.DeadlineExceeded) || writeCtx.Err() != nil {
classification = "deadline_exceeded"
}
log.Warnf("agent heartbeat history skipped: device_id=%d classification=%s", row.DeviceID, classification)
}
}
func insertDeviceStatusEvent(tx *gorm.DB, event *models.AgentDeviceStatusEvent) error {
// Never issue a diagnostic SELECT or log the SQL error/body.
if err := tx.Session(&gorm.Session{Logger: logger.Default.LogMode(logger.Silent)}).Create(event).Error; err != nil {
return internalError(errors.New("device status event write failed"))
}
return nil
}
func logDeviceStatusEvent(event models.AgentDeviceStatusEvent) {
log.Infof("agent device status changed: device_id=%d reason=%s failed_task_count=%d order_result_unknown_count=%d", event.DeviceID, event.Reason, event.FailedTaskCount, event.OrderResultUnknownCount)
}
const diagnosticsCleanupBatch = 5000
// RunDiagnosticsCleanup shares the API server lifecycle but never occupies the
// offline monitor's 15-second scan loop.
func RunDiagnosticsCleanup(ctx context.Context, s *Service, onError func(error)) {
heartbeats := time.NewTicker(time.Hour)
defer heartbeats.Stop()
events := time.NewTicker(24 * time.Hour)
defer events.Stop()
runDiagnosticsCleanup(ctx, s, heartbeats.C, events.C, onError)
}
func runDiagnosticsCleanup(ctx context.Context, s *Service, heartbeats, events <-chan time.Time, onError func(error)) {
for {
select {
case <-ctx.Done():
return
case <-heartbeats:
if _, err := s.CleanupHeartbeatHistory(ctx); err != nil && ctx.Err() == nil && onError != nil {
onError(err)
}
case <-events:
if _, err := s.CleanupDeviceStatusEvents(ctx); err != nil && ctx.Err() == nil && onError != nil {
onError(err)
}
}
}
}
func (s *Service) CleanupHeartbeatHistory(ctx context.Context) (int64, error) {
return cleanupDiagnostics(s.DB.WithContext(ctx), &models.AgentHeartbeatLog{}, "received_at", s.Now().Add(-7*24*time.Hour))
}
func (s *Service) CleanupDeviceStatusEvents(ctx context.Context) (int64, error) {
return cleanupDiagnostics(s.DB.WithContext(ctx), &models.AgentDeviceStatusEvent{}, "occurred_at", s.Now().Add(-30*24*time.Hour))
}
func cleanupDiagnostics(db *gorm.DB, model any, timestamp string, cutoff time.Time) (int64, error) {
// A fixed-size ID batch works on both MySQL and SQLite. Recheck expiration
// in DELETE, and never loop through an unbounded backlog in one run.
var ids []uint64
if err := db.Model(model).Where(timestamp+" < ?", cutoff).Order(timestamp+", id").Limit(diagnosticsCleanupBatch).Pluck("id", &ids).Error; err != nil {
return 0, errors.New("diagnostics cleanup read failed")
}
if len(ids) == 0 {
return 0, nil
}
result := db.Where("id IN ? AND "+timestamp+" < ?", ids, cutoff).Delete(model)
if result.Error != nil {
return 0, errors.New("diagnostics cleanup delete failed")
}
return result.RowsAffected, nil
}
@@ -0,0 +1,372 @@
package device_test
import (
"context"
"errors"
"net"
"os"
"strings"
"sync"
"testing"
"time"
. "go-admin/app/goauto/device"
"go-admin/app/goauto/models"
versionlocal "go-admin/cmd/migrate/migration/version-local"
common "go-admin/common/models"
driver "github.com/go-sql-driver/mysql"
"github.com/google/uuid"
"gorm.io/driver/mysql"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"gorm.io/gorm/logger"
)
// Explicit opt-in only; refuses production/remote DSNs and any nonempty schema.
// Credentials are injected by the caller and never included in test errors.
func TestMySQLAgentDiagnostics(t *testing.T) {
raw := os.Getenv("GOAUTO_378_TEST_MYSQL_DSN")
if raw == "" {
t.Skip("isolated local MySQL DSN not provided")
}
cfg, err := driver.ParseDSN(raw)
if err != nil {
t.Fatal("invalid test DSN")
}
host, _, err := net.SplitHostPort(cfg.Addr)
if err != nil || cfg.Net != "tcp" || (host != "127.0.0.1" && host != "localhost" && host != "::1") || cfg.DBName != "goauto_378_test" {
t.Fatal("requires local tcp database goauto_378_test")
}
cfg.ParseTime = true
db, err := gorm.Open(mysql.Open(cfg.FormatDSN()), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
if err != nil {
t.Fatal("test MySQL connection failed")
}
sqlDB, err := db.DB()
if err != nil {
t.Fatal("test database unavailable")
}
t.Cleanup(func() { _ = sqlDB.Close() })
sqlDB.SetMaxOpenConns(8)
var version string
if db.Raw("SELECT VERSION()").Scan(&version).Error != nil || !strings.HasPrefix(version, "8.") {
t.Fatal("requires MySQL 8")
}
tables, err := db.Migrator().GetTables()
if err != nil || len(tables) != 0 {
t.Fatal("test schema must be empty")
}
owned := []any{&common.Migration{}, &models.AgentDevice{}, &models.PDDProduct{}, &models.CollectionRule{}, &models.CollectionTask{}, &models.PurchaseTask{}, &models.PurchaseTaskAttempt{}, &models.PDDAccount{}, &models.ShopeeProduct{}, &models.SYBProduct{}}
t.Cleanup(func() {
for _, model := range append([]any{&models.AgentHeartbeatLog{}, &models.AgentDeviceStatusEvent{}}, owned...) {
if err := db.Migrator().DropTable(model); err != nil {
t.Errorf("test table cleanup failed for %T", model)
}
}
})
if err := db.AutoMigrate(owned...); err != nil {
t.Fatal("test base migration failed")
}
const migrationVersion = "1791600000000_agent_diagnostics"
for i := 0; i < 2; i++ {
if versionlocal.MigrateAgentDiagnostics(db, migrationVersion) != nil {
t.Fatal("diagnostic migration failed")
}
}
for _, idx := range []struct {
model any
name string
}{{&models.AgentHeartbeatLog{}, "ix_heartbeat_device_received"}, {&models.AgentHeartbeatLog{}, "ix_heartbeat_received"}, {&models.AgentDeviceStatusEvent{}, "ix_device_event_occurred"}} {
if !db.Migrator().HasIndex(idx.model, idx.name) {
t.Fatalf("missing MySQL index %s", idx.name)
}
}
// Exercise reviewed rollback only on fresh diagnostic tables in this schema.
if db.Migrator().DropTable(&models.AgentHeartbeatLog{}, &models.AgentDeviceStatusEvent{}) != nil {
t.Fatal("diagnostic test rollback failed")
}
if db.Where("version = ?", migrationVersion).Delete(&common.Migration{}).Error != nil {
t.Fatal("test version rollback failed")
}
if versionlocal.MigrateAgentDiagnostics(db, migrationVersion) != nil {
t.Fatal("diagnostic reapply failed")
}
seed := func(t *testing.T) (*Service, RegisterResponse, string) {
t.Helper()
s := NewService(db)
s.Now = func() time.Time { return time.Date(2026, 10, 10, 0, 0, 0, 0, time.UTC) }
token := "isolated-mysql-" + uuid.NewString()
s.GenerateToken = func() (string, error) { return token, nil }
d, err := s.Register(context.Background(), RegisterRequest{RequestID: uuid.NewString(), InstallID: uuid.NewString(), Name: "test", Manufacturer: "test", Model: "test", AndroidVersion: "14", AgentVersion: "test", PDDVersion: "test"}, "")
if err != nil {
t.Fatal("synthetic device registration failed")
}
if db.Model(&models.AgentDevice{}).Where("id = ?", d.DeviceID).Update("last_heartbeat_at", s.Now().Add(-time.Minute)).Error != nil {
t.Fatal("test seed failed")
}
t.Cleanup(func() {
if db.Model(&models.AgentDevice{}).Where("id = ?", d.DeviceID).Update("status", models.DeviceStatusDisabled).Error != nil {
t.Error("synthetic device cleanup failed")
}
})
return s, d, token
}
t.Run("v4", func(t *testing.T) { runOfflineV4MySQLTests(t, db, seed) })
t.Run("scan_revalidates_real_heartbeat_after_candidate_read", func(t *testing.T) {
s, d, token := seed(t)
type scanMarker struct{}
callback := "v4_heartbeat_before_scan_lock"
if db.Callback().Query().Before("gorm:query").Register(callback, func(tx *gorm.DB) {
if tx.Statement.Context.Value(scanMarker{}) != true || tx.Statement.Table != "agent_device" {
return
}
if _, ok := tx.Statement.Clauses["FOR"]; !ok {
return
}
if _, err := s.Heartbeat(context.Background(), HeartbeatRequest{RequestID: uuid.NewString()}, token); err != nil {
tx.AddError(errors.New("concurrent heartbeat failed"))
}
}) != nil {
t.Fatal("register heartbeat barrier failed")
}
defer db.Callback().Query().Remove(callback)
n, err := s.MarkStaleDevicesOffline(context.WithValue(context.Background(), scanMarker{}, true), DefaultOfflineThreshold)
if err != nil || n != 0 {
t.Fatal("fresh heartbeat falsely marked offline")
}
requireOfflineDevice(t, db, d.DeviceID, models.DeviceStatusOnline, 0)
})
t.Run("concurrent_scans_and_resume_are_unique", func(t *testing.T) {
s, d, testDeviceToken := seed(t)
var wg sync.WaitGroup
errs := make(chan error, 2)
for i := 0; i < 2; i++ {
wg.Add(1)
go func() {
defer wg.Done()
_, err := s.MarkStaleDevicesOffline(context.Background(), DefaultOfflineThreshold)
errs <- err
}()
}
wg.Wait()
for i := 0; i < 2; i++ {
if err := <-errs; err != nil {
var dbErr *driver.MySQLError
if errors.As(err, &dbErr) {
t.Fatalf("concurrent scan failed mysql_errno=%d", dbErr.Number)
}
t.Fatalf("concurrent scan failed classification=%T", err)
}
}
req := HeartbeatRequest{RequestID: uuid.NewString()}
for i := 0; i < 2; i++ {
wg.Add(1)
go func() {
defer wg.Done()
_, err := s.Heartbeat(context.Background(), req, testDeviceToken)
errs <- err
}()
}
wg.Wait()
for i := 0; i < 2; i++ {
if <-errs != nil {
t.Fatal("concurrent heartbeat failed")
}
}
var events []models.AgentDeviceStatusEvent
db.Where("device_id = ?", d.DeviceID).Order("id").Find(&events)
if len(events) != 2 || events[0].Reason != "heartbeat_timeout" || events[1].Reason != "heartbeat_resumed" {
t.Fatalf("transition count=%d", len(events))
}
var n int64
db.Model(&models.AgentHeartbeatLog{}).Where("device_id = ?", d.DeviceID).Count(&n)
if n != 1 {
t.Fatalf("replay history count=%d", n)
}
})
t.Run("event_failure_rolls_back_real_mysql", func(t *testing.T) {
s, d, _ := seed(t)
product := models.PDDProduct{GoodsID: "37801", URL: "https://example.invalid/test"}
rule := models.CollectionRule{Name: "test", ContentJSON: `{}`}
if db.Create(&product).Error != nil || db.Create(&rule).Error != nil {
t.Fatal("synthetic task input failed")
}
task := models.CollectionTask{PDDProductID: &product.ID, RuleID: rule.ID, DeviceID: &d.DeviceID, Status: models.TaskStatusRunning, URLSnapshot: product.URL, GoodsIDSnapshot: product.GoodsID, RuleSnapshot: rule.ContentJSON}
if db.Create(&task).Error != nil {
t.Fatal("synthetic task creation failed")
}
if db.Exec("CREATE TRIGGER diagnostic_test_reject BEFORE INSERT ON agent_device_status_event FOR EACH ROW SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'synthetic_failure'").Error != nil {
t.Fatal("test trigger creation failed")
}
defer db.Exec("DROP TRIGGER IF EXISTS diagnostic_test_reject")
n, err := s.MarkStaleDevicesOffline(context.Background(), DefaultOfflineThreshold)
if err == nil || n != 0 {
t.Fatal("failed event did not abort")
}
var device models.AgentDevice
db.First(&device, d.DeviceID)
if device.Status != models.DeviceStatusOnline {
t.Fatal("state escaped rollback")
}
var storedTask models.CollectionTask
if db.First(&storedTask, task.ID).Error != nil || storedTask.Status != models.TaskStatusRunning || storedTask.DeviceRunSlot == nil {
t.Fatal("task escaped rollback")
}
if db.Exec("DROP TRIGGER diagnostic_test_reject").Error != nil {
t.Fatal("test trigger removal failed")
}
n, err = s.MarkStaleDevicesOffline(context.Background(), DefaultOfflineThreshold)
if err != nil || n != 1 {
t.Fatal("retry failed")
}
var event models.AgentDeviceStatusEvent
if db.Where("device_id = ?", d.DeviceID).First(&event).Error != nil || event.FailedTaskCount != 1 || event.OrderResultUnknownCount != 0 {
t.Fatal("per-device counts incorrect")
}
})
for _, domain := range []string{"collection", "purchase"} {
t.Run("scan_does_not_deadlock_task_then_device_reset_"+domain, func(t *testing.T) {
s, d, _ := seed(t)
goodsID := "37802"
if domain == "purchase" {
goodsID = "37803"
}
product := models.PDDProduct{GoodsID: goodsID, URL: "https://example.invalid/test"}
rule := models.CollectionRule{Name: "test", ContentJSON: `{}`}
if db.Create(&product).Error != nil || db.Create(&rule).Error != nil {
t.Fatal("synthetic inputs failed")
}
var taskID uint64
var taskModel any
if domain == "collection" {
task := models.CollectionTask{PDDProductID: &product.ID, RuleID: rule.ID, DeviceID: &d.DeviceID, Status: models.TaskStatusRunning, URLSnapshot: product.URL, GoodsIDSnapshot: product.GoodsID, RuleSnapshot: rule.ContentJSON}
if db.Create(&task).Error != nil {
t.Fatal("synthetic task failed")
}
taskID = task.ID
taskModel = &models.CollectionTask{}
} else {
task := models.PurchaseTask{PDDProductID: product.ID, DeviceID: &d.DeviceID, ExecutionMode: models.PurchaseExecutionModeRehearsal, Status: models.PurchaseTaskStatusRunning, PDDURLSnapshot: product.URL, PDDGoodsIDSnapshot: product.GoodsID, Quantity: 1, ReferenceUnitPriceCent: 100, MinUnitPriceCent: 20, MaxUnitPriceCent: 150, Currency: "CNY", RuleType: "pddPurchase", RuleSchemaVersion: 1, RequiredCapabilitiesJSON: `[]`, RuleSnapshot: `{}`, CreateRequestID: uuid.NewString()}
if db.Create(&task).Error != nil {
t.Fatal("synthetic purchase task failed")
}
taskID = task.ID
taskModel = &models.PurchaseTask{}
}
resetTx := db.Begin()
defer resetTx.Rollback()
if resetTx.Clauses(clause.Locking{Strength: "UPDATE"}).First(taskModel, taskID).Error != nil {
t.Fatal("reset task lock failed")
}
type scanMarker struct{}
deviceLocked, releaseScan := make(chan struct{}), make(chan struct{})
callback := "diagnostic_test_scan_lock_barrier"
if db.Callback().Query().After("gorm:query").Register(callback, func(tx *gorm.DB) {
if tx.Statement.Context.Value(scanMarker{}) == true && tx.Statement.Table == "agent_device" {
if _, ok := tx.Statement.Clauses["FOR"]; ok {
close(deviceLocked)
<-releaseScan
}
}
}) != nil {
t.Fatal("register scan barrier failed")
}
defer db.Callback().Query().Remove(callback)
scanDone, resetDone := make(chan error, 1), make(chan error, 1)
go func() {
_, err := s.MarkStaleDevicesOffline(context.WithValue(context.Background(), scanMarker{}, true), DefaultOfflineThreshold)
scanDone <- err
}()
select {
case <-deviceLocked:
case <-time.After(5 * time.Second):
close(releaseScan)
t.Fatal("scan device lock not reached")
}
go func() {
var device models.AgentDevice
err := resetTx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&device, d.DeviceID).Error
resetTx.Rollback()
resetDone <- err
}()
close(releaseScan)
scanErr, resetErr := <-scanDone, <-resetDone
if resetErr != nil {
var mysqlErr *driver.MySQLError
if errors.As(resetErr, &mysqlErr) {
t.Fatalf("reset lock failed mysql_errno=%d", mysqlErr.Number)
}
t.Fatal("reset lock failed")
}
if scanErr != nil {
t.Fatal("scan must skip busy task without returning contention")
}
var eventCount int64
db.Model(&models.AgentDeviceStatusEvent{}).Where("device_id = ?", d.DeviceID).Count(&eventCount)
if eventCount != 0 {
t.Fatal("busy task created event")
}
var device models.AgentDevice
db.First(&device, d.DeviceID)
if device.Status != models.DeviceStatusOnline {
t.Fatal("busy task changed status")
}
db.Callback().Query().Remove(callback)
n, err := s.MarkStaleDevicesOffline(context.Background(), DefaultOfflineThreshold)
if err != nil || n != 1 {
t.Fatal("scan did not retry after reset released locks")
}
})
}
t.Run("history_timeout_is_bounded_on_real_lock", func(t *testing.T) {
s, _, testDeviceToken := seed(t)
// Hold the diagnostic table's insert gap; liveness updates remain free.
lock := db.Begin()
defer lock.Rollback()
if lock.Exec("SELECT id FROM agent_heartbeat_log WHERE id > 0 FOR UPDATE").Error != nil {
t.Fatal("history lock failed")
}
started := time.Now()
res, err := s.Heartbeat(context.Background(), HeartbeatRequest{RequestID: uuid.NewString()}, testDeviceToken)
t.Logf("heartbeat with locked history table elapsed=%s", time.Since(started))
if err != nil || !res.Online || time.Since(started) > time.Second {
t.Fatal("history lock changed heartbeat outcome or exceeded budget")
}
})
t.Run("strict_cleanup", func(t *testing.T) {
s, d, _ := seed(t)
boundary := models.AgentHeartbeatLog{DeviceID: d.DeviceID, ReceivedAt: s.Now().Add(-7 * 24 * time.Hour), RequestID: uuid.NewString(), AgentVersion: "test", ClientReportStatus: "none"}
expired := boundary
expired.ReceivedAt = expired.ReceivedAt.Add(-time.Second)
db.Create(&boundary)
db.Create(&expired)
n, err := s.CleanupHeartbeatHistory(context.Background())
if err != nil || n != 1 {
t.Fatalf("cleanup=%d failed=%v", n, err != nil)
}
history := make([]models.AgentHeartbeatLog, 5001)
events := make([]models.AgentDeviceStatusEvent, 5001)
for i := range history {
history[i] = models.AgentHeartbeatLog{DeviceID: d.DeviceID, ReceivedAt: s.Now().Add(-31 * 24 * time.Hour), ClientReportStatus: "none"}
events[i] = models.AgentDeviceStatusEvent{DeviceID: d.DeviceID, OccurredAt: s.Now().Add(-31 * 24 * time.Hour)}
}
if db.CreateInBatches(&history, 500).Error != nil || db.CreateInBatches(&events, 500).Error != nil {
t.Fatal("synthetic cleanup batch seed failed")
}
n, err = s.CleanupHeartbeatHistory(context.Background())
if err != nil || n != 5000 {
t.Fatalf("history cleanup batch=%d failed=%v", n, err != nil)
}
n, err = s.CleanupDeviceStatusEvents(context.Background())
if err != nil || n != 5000 {
t.Fatalf("event cleanup batch=%d failed=%v", n, err != nil)
}
var remaining int64
db.Model(&models.AgentHeartbeatLog{}).Where("device_id = ?", d.DeviceID).Count(&remaining)
if remaining != 2 {
t.Fatalf("history boundary/remaining=%d", remaining)
}
})
}
@@ -0,0 +1,434 @@
package device
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"go-admin/app/goauto/models"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"gorm.io/gorm"
)
func TestHeartbeatDiagnosticsHistoryAndReportIsolation(t *testing.T) {
db := openTestDatabase(t)
service := newTestService(t, db)
_, registered := registerHeartbeatDevice(t, service)
gin.SetMode(gin.TestMode)
router := gin.New()
router.POST("/heartbeat", Handler{DB: db}.Heartbeat)
for _, tc := range []struct{ name, report, status string }{
{"missing", "", "none"},
{"accepted", `,"clientReport":{"snapshotSeq":1,"failedRounds":0,"latestFailureAt":null,"latestFailureStep":null,"latestFailureCategory":null,"latestFailureDurationMs":null,"previousSyncDurationMs":25,"hasInterruptedRound":false}`, "accepted"},
{"wrong_type", `,"clientReport":"secret-not-stored"`, "client_report_invalid"},
{"unknown_field", `,"clientReport":{"url":"secret-not-stored"}`, "client_report_invalid"},
} {
t.Run(tc.name, func(t *testing.T) {
id := uuid.NewString()
body := fmt.Sprintf(`{"requestId":%q,"currentTaskId":null%s}`, id, tc.report)
for i := 0; i < 2; i++ {
req := httptest.NewRequest(http.MethodPost, "/heartbeat", strings.NewReader(body))
req.Header.Set("Authorization", "Bearer "+testDeviceToken)
res := httptest.NewRecorder()
router.ServeHTTP(res, req)
if res.Code != 200 {
t.Fatalf("heartbeat status=%d body=%s", res.Code, res.Body.String())
}
var envelope struct {
Data map[string]any `json:"data"`
}
_ = json.Unmarshal(res.Body.Bytes(), &envelope)
if envelope.Data["acceptsClientReport"] != true {
t.Fatal("capability missing")
}
}
var rows []struct {
DeviceID uint64
ClientReportStatus string
ClientReportJSON *string
}
if err := db.Table("agent_heartbeat_log").Where("request_id = ?", id).Find(&rows).Error; err != nil {
t.Fatal(err)
}
if len(rows) != 1 || rows[0].DeviceID != registered.DeviceID || rows[0].ClientReportStatus != tc.status || (rows[0].ClientReportJSON != nil) != (tc.status == "accepted") {
t.Fatalf("history=%+v", rows)
}
})
}
}
func TestDisableWritesOneAtomicStatusEvent(t *testing.T) {
db := openTestDatabase(t)
s := newTestService(t, db)
_, d := registerHeartbeatDevice(t, s)
for i := 0; i < 2; i++ {
if err := s.Disable(context.Background(), d.DeviceID); err != nil {
t.Fatal(err)
}
}
var events []models.AgentDeviceStatusEvent
if err := db.Find(&events).Error; err != nil {
t.Fatal(err)
}
if len(events) != 1 || events[0].Reason != "disabled" || events[0].FromStatus != "online" || events[0].ToStatus != "disabled" {
t.Fatalf("events=%+v", events)
}
}
func TestStatusEventFailureRollsBackAndCanRetry(t *testing.T) {
for _, action := range []string{"timeout", "resumed", "disabled"} {
t.Run(action, func(t *testing.T) {
db := openTestDatabase(t)
s := newTestService(t, db)
_, d := registerHeartbeatDevice(t, s)
status := models.DeviceStatusOnline
if action == "resumed" {
status = models.DeviceStatusOffline
}
old := s.Now().Add(-time.Minute)
if err := db.Model(&models.AgentDevice{}).Where("id = ?", d.DeviceID).Updates(map[string]any{"status": status, "last_heartbeat_at": old}).Error; err != nil {
t.Fatal(err)
}
var task models.CollectionTask
if action == "timeout" || action == "disabled" {
product := models.PDDProduct{GoodsID: "1001", URL: "https://example.invalid/test"}
rule := models.CollectionRule{Name: "test", ContentJSON: `{}`}
if err := db.Create(&product).Error; err != nil {
t.Fatal(err)
}
if err := db.Create(&rule).Error; err != nil {
t.Fatal(err)
}
task = newHeartbeatTask(product, rule, d.DeviceID)
if err := db.Create(&task).Error; err != nil {
t.Fatal(err)
}
}
operation := func() error {
switch action {
case "timeout":
_, err := s.MarkStaleDevicesOffline(context.Background(), DefaultOfflineThreshold)
return err
case "disabled":
return s.Disable(context.Background(), d.DeviceID)
default:
_, err := s.Heartbeat(context.Background(), HeartbeatRequest{RequestID: uuid.NewString()}, testDeviceToken)
return err
}
}
callback := "diagnostic_event_failure"
if err := db.Callback().Create().Before("gorm:create").Register(callback, func(tx *gorm.DB) {
if tx.Statement.Table == "agent_device_status_event" {
tx.AddError(errors.New("injected private error"))
}
}); err != nil {
t.Fatal(err)
}
if err := operation(); err == nil {
t.Fatal("event failure must abort transaction")
}
var stored models.AgentDevice
if err := db.First(&stored, d.DeviceID).Error; err != nil {
t.Fatal(err)
}
if stored.Status != status || !stored.LastHeartbeatAt.Equal(old) {
t.Fatalf("state escaped rollback=%+v", stored.Status)
}
if task.ID != 0 {
var storedTask models.CollectionTask
if err := db.First(&storedTask, task.ID).Error; err != nil {
t.Fatal(err)
}
if storedTask.Status != models.TaskStatusRunning || storedTask.DeviceRunSlot == nil {
t.Fatal("task changes escaped rollback")
}
}
_ = db.Callback().Create().Remove(callback)
if err := operation(); err != nil {
t.Fatal(err)
}
var n int64
db.Model(&models.AgentDeviceStatusEvent{}).Count(&n)
if n != 1 {
t.Fatalf("events=%d", n)
}
})
}
}
func TestOfflineEventCountsPerDeviceIncludingUnknownBoundary(t *testing.T) {
db := openTestDatabase(t)
s := newTestService(t, db)
ids := make([]uint64, 3)
for i := range ids {
s.GenerateToken = func() (string, error) { return fmt.Sprintf("test-token-%d", i), nil }
_, registered := registerHeartbeatDevice(t, s)
ids[i] = registered.DeviceID
if err := db.Model(&models.AgentDevice{}).Where("id = ?", ids[i]).Update("last_heartbeat_at", s.Now().Add(-time.Minute)).Error; err != nil {
t.Fatal(err)
}
}
product := models.PDDProduct{GoodsID: "1002", URL: "https://example.invalid/test"}
rule := models.CollectionRule{Name: "test", ContentJSON: `{}`}
if err := db.Create(&product).Error; err != nil {
t.Fatal(err)
}
if err := db.Create(&rule).Error; err != nil {
t.Fatal(err)
}
collection := newHeartbeatTask(product, rule, ids[0])
if err := db.Create(&collection).Error; err != nil {
t.Fatal(err)
}
for i, status := range []string{models.PurchaseTaskStatusRunning, models.PurchaseTaskStatusOrderSubmitStarted} {
task := newHeartbeatPurchaseTask(product, ids[i], status)
if err := db.Create(&task).Error; err != nil {
t.Fatal(err)
}
attempt := newRunningPurchaseAttempt(task.ID, ids[i])
if err := db.Create(&attempt).Error; err != nil {
t.Fatal(err)
}
}
if n, err := s.MarkStaleDevicesOffline(context.Background(), DefaultOfflineThreshold); err != nil || n != 3 {
t.Fatalf("scan=%d %v", n, err)
}
var events []models.AgentDeviceStatusEvent
if err := db.Order("device_id").Find(&events).Error; err != nil {
t.Fatal(err)
}
if len(events) != 3 || events[0].FailedTaskCount != 2 || events[0].OrderResultUnknownCount != 0 || events[1].FailedTaskCount != 0 || events[1].OrderResultUnknownCount != 1 || events[2].FailedTaskCount != 0 || events[2].OrderResultUnknownCount != 0 {
t.Fatalf("events=%+v", events)
}
}
func TestHeartbeatHistoryOnlyForSuccessfulMainRequest(t *testing.T) {
db := openTestDatabase(t)
s := newTestService(t, db)
registerHeartbeatDevice(t, s)
if _, err := s.Heartbeat(context.Background(), HeartbeatRequest{RequestID: uuid.NewString()}, "wrong-token"); err == nil {
t.Fatal("bad token accepted")
}
if _, err := s.Heartbeat(context.Background(), HeartbeatRequest{RequestID: "invalid"}, testDeviceToken); err == nil {
t.Fatal("bad request accepted")
}
id := uint64(999)
if _, err := s.Heartbeat(context.Background(), HeartbeatRequest{RequestID: uuid.NewString(), CurrentTaskID: &id}, testDeviceToken); err == nil {
t.Fatal("task mismatch accepted")
}
var count int64
if err := db.Model(&models.AgentHeartbeatLog{}).Count(&count).Error; err != nil {
t.Fatal(err)
}
if count != 0 {
t.Fatal("failed request stored history")
}
}
func TestHeartbeatHistoryBoundedIndependentAndBestEffort(t *testing.T) {
db := openTestDatabase(t)
s := newTestService(t, db)
_, d := registerHeartbeatDevice(t, s)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
callback := "history_wait_for_deadline"
if err := db.Callback().Create().Before("gorm:create").Register(callback, func(tx *gorm.DB) {
if tx.Statement.Table != "agent_heartbeat_log" {
return
}
// We are already outside the core transaction; cancellation must not
// cancel the independent history context.
cancel()
if tx.Statement.Context.Err() != nil {
t.Error("history inherited request cancellation")
}
deadline, ok := tx.Statement.Context.Deadline()
if !ok || time.Until(deadline) > 201*time.Millisecond {
t.Error("missing bounded deadline")
}
var current models.AgentDevice
if err := db.First(&current, d.DeviceID).Error; err != nil || current.LastHeartbeatAt == nil {
t.Error("core was not committed before history")
}
<-tx.Statement.Context.Done()
tx.AddError(tx.Statement.Context.Err())
}); err != nil {
t.Fatal(err)
}
started := time.Now()
res, err := s.Heartbeat(ctx, HeartbeatRequest{RequestID: uuid.NewString()}, testDeviceToken)
if err != nil || !res.Online || time.Since(started) > time.Second {
t.Fatalf("best effort changed result: %v", err)
}
_ = db.Callback().Create().Remove(callback)
if err := db.Migrator().DropTable(&models.AgentHeartbeatLog{}); err != nil {
t.Fatal(err)
}
if _, err := s.Heartbeat(context.Background(), HeartbeatRequest{RequestID: uuid.NewString()}, testDeviceToken); err != nil {
t.Fatal(err)
}
}
func TestClientReportWhitelistBounds(t *testing.T) {
valid := `{"snapshotSeq":1,"failedRounds":1,"latestFailureAt":"2026-10-10T00:00:00Z","latestFailureStep":"heartbeat","latestFailureCategory":"network","latestFailureDurationMs":12,"previousSyncDurationMs":null,"hasInterruptedRound":false}`
for _, tc := range []struct{ name, raw, status string }{
{"accepted", valid, "accepted"}, {"none", "", "none"}, {"null", "null", "client_report_invalid"}, {"array", "[]", "client_report_invalid"},
{"sequence", strings.Replace(valid, `"snapshotSeq":1`, `"snapshotSeq":0`, 1), "client_report_invalid"},
{"duration", strings.Replace(valid, `"latestFailureDurationMs":12`, `"latestFailureDurationMs":86400001`, 1), "client_report_invalid"},
{"unknown_step", strings.Replace(valid, `"heartbeat"`, `"http://private"`, 1), "client_report_invalid"},
{"inconsistent", strings.Replace(valid, `"failedRounds":1`, `"failedRounds":0`, 1), "client_report_invalid"},
{"unknown", strings.Replace(valid, `"snapshotSeq"`, `"privateText"`, 1), "client_report_invalid"},
{"null_bool", strings.Replace(valid, `"hasInterruptedRound":false`, `"hasInterruptedRound":null`, 1), "client_report_invalid"},
{"case_alias_cannot_replace_missing_required", `{"snapshotSeq":1,"failedRounds":0,"latestFailureAt":null,"latestFailureStep":null,"latestFailureCategory":null,"SNAPSHOTSEQ":2,"previousSyncDurationMs":null,"hasInterruptedRound":false}`, "client_report_invalid"},
{"oversize", strings.Repeat(" ", 2049) + valid, "client_report_invalid"},
} {
t.Run(tc.name, func(t *testing.T) {
value, status := validateHeartbeatClientReport(json.RawMessage(tc.raw))
if status != tc.status || (status != "accepted" && value != nil) {
t.Fatalf("status=%s valuePresent=%v", status, value != nil)
}
})
}
}
func TestDiagnosticsCleanupIsBoundedAndStrictlyExpired(t *testing.T) {
db := openTestDatabase(t)
s := newTestService(t, db)
now := s.Now()
old := now.Add(-31 * 24 * time.Hour)
heartbeats := make([]models.AgentHeartbeatLog, 5001)
events := make([]models.AgentDeviceStatusEvent, 5001)
for i := range heartbeats {
heartbeats[i] = models.AgentHeartbeatLog{DeviceID: 1, ReceivedAt: old, RequestID: uuid.NewString(), AgentVersion: "test", ClientReportStatus: "none"}
events[i] = models.AgentDeviceStatusEvent{DeviceID: 1, OccurredAt: old, FromStatus: "online", ToStatus: "offline", Reason: "heartbeat_timeout"}
}
if err := db.CreateInBatches(&heartbeats, 100).Error; err != nil {
t.Fatal(err)
}
if err := db.CreateInBatches(&events, 100).Error; err != nil {
t.Fatal(err)
}
boundaryHeartbeat := models.AgentHeartbeatLog{DeviceID: 1, ReceivedAt: now.Add(-7 * 24 * time.Hour), RequestID: uuid.NewString(), AgentVersion: "test", ClientReportStatus: "none"}
boundaryEvent := models.AgentDeviceStatusEvent{DeviceID: 1, OccurredAt: now.Add(-30 * 24 * time.Hour), FromStatus: "online", ToStatus: "offline", Reason: "heartbeat_timeout"}
db.Create(&boundaryHeartbeat)
db.Create(&boundaryEvent)
n, err := s.CleanupHeartbeatHistory(context.Background())
if err != nil || n != 5000 {
t.Fatalf("heartbeat cleanup=%d %v", n, err)
}
n, err = s.CleanupDeviceStatusEvents(context.Background())
if err != nil || n != 5000 {
t.Fatalf("event cleanup=%d %v", n, err)
}
var count int64
db.Model(&models.AgentHeartbeatLog{}).Count(&count)
if count != 2 {
t.Fatalf("remaining=%d", count)
}
db.Model(&models.AgentDeviceStatusEvent{}).Count(&count)
if count != 2 {
t.Fatalf("remaining=%d", count)
}
n, err = s.CleanupHeartbeatHistory(context.Background())
if err != nil || n != 1 {
t.Fatalf("strict heartbeat cleanup=%d %v", n, err)
}
n, err = s.CleanupDeviceStatusEvents(context.Background())
if err != nil || n != 1 {
t.Fatalf("strict event cleanup=%d %v", n, err)
}
}
func TestDiagnosticsCleanupRunsOnSeparateSchedulesAndStops(t *testing.T) {
db := openTestDatabase(t)
s := newTestService(t, db)
old := s.Now().Add(-31 * 24 * time.Hour)
history := models.AgentHeartbeatLog{ReceivedAt: old, ClientReportStatus: "none"}
event := models.AgentDeviceStatusEvent{OccurredAt: old}
if err := db.Create(&history).Error; err != nil {
t.Fatal(err)
}
if err := db.Create(&event).Error; err != nil {
t.Fatal(err)
}
heartbeats, events := make(chan time.Time), make(chan time.Time)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
done := make(chan struct{})
go func() {
defer close(done)
runDiagnosticsCleanup(ctx, s, heartbeats, events, func(err error) { t.Error(err) })
}()
heartbeats <- s.Now()
// A second tick synchronizes completion of the first cleanup.
heartbeats <- s.Now()
var n int64
db.Model(&models.AgentHeartbeatLog{}).Count(&n)
if n != 0 {
t.Fatal("hourly history cleanup did not run")
}
db.Model(&models.AgentDeviceStatusEvent{}).Count(&n)
if n != 1 {
t.Fatal("hourly tick cleaned daily events")
}
events <- s.Now()
events <- s.Now()
cancel()
select {
case <-done:
case <-time.After(time.Second):
t.Fatal("cleanup did not stop")
}
db.Model(&models.AgentDeviceStatusEvent{}).Count(&n)
if n != 0 {
t.Fatal("daily event cleanup did not run")
}
}
func TestOfflineDiagnosticsAtomicAndResumed(t *testing.T) {
db := openTestDatabase(t)
s := newTestService(t, db)
_, d := registerHeartbeatDevice(t, s)
old := s.Now().Add(-time.Minute)
if err := db.Model(&models.AgentDevice{}).Where("id = ?", d.DeviceID).Update("last_heartbeat_at", old).Error; err != nil {
t.Fatal(err)
}
for i := 0; i < 2; i++ {
n, err := s.MarkStaleDevicesOffline(context.Background(), DefaultOfflineThreshold)
if err != nil || n != int64(1-i) {
t.Fatalf("scan=%d %v", n, err)
}
}
var events []struct {
Reason, FromStatus, ToStatus string
LastHeartbeatAt *time.Time
FailedTaskCount, OrderResultUnknownCount int64
}
if err := db.Table("agent_device_status_event").Find(&events).Error; err != nil {
t.Fatal(err)
}
if len(events) != 1 || events[0].Reason != "heartbeat_timeout" || events[0].FromStatus != "online" || events[0].ToStatus != "offline" || events[0].LastHeartbeatAt == nil || !events[0].LastHeartbeatAt.Equal(old) {
t.Fatalf("events=%+v", events)
}
req := HeartbeatRequest{RequestID: uuid.NewString()}
for i := 0; i < 2; i++ {
if _, err := s.Heartbeat(context.Background(), req, testDeviceToken); err != nil {
t.Fatal(err)
}
}
events = nil
if err := db.Table("agent_device_status_event").Order("id").Find(&events).Error; err != nil {
t.Fatal(err)
}
if len(events) != 2 || events[1].Reason != "heartbeat_resumed" {
t.Fatalf("events=%+v", events)
}
}
+166 -57
View File
@@ -2,14 +2,19 @@ package device
import (
"context"
"encoding/json"
"errors"
"fmt"
"time"
"go-admin/app/goauto/models"
log "github.com/go-admin-team/go-admin-core/logger"
mysqlDriver "github.com/go-sql-driver/mysql"
"github.com/google/uuid"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"gorm.io/gorm/logger"
)
const (
@@ -18,9 +23,10 @@ const (
)
type HeartbeatRequest struct {
RequestID string `json:"requestId"`
CurrentTaskID *uint64 `json:"currentTaskId"`
Capabilities []string `json:"capabilities,omitempty"`
RequestID string `json:"requestId"`
CurrentTaskID *uint64 `json:"currentTaskId"`
Capabilities []string `json:"capabilities,omitempty"`
ClientReport json.RawMessage `json:"clientReport,omitempty"`
}
type HeartbeatResponse struct {
@@ -32,6 +38,7 @@ type HeartbeatResponse struct {
ServerTime string `json:"serverTime"`
HeartbeatIntervalSeconds int `json:"heartbeatIntervalSeconds"`
Replayed bool `json:"replayed,omitempty"`
AcceptsClientReport bool `json:"acceptsClientReport"`
}
func (service *Service) Heartbeat(ctx context.Context, request HeartbeatRequest, presentedToken string) (HeartbeatResponse, error) {
@@ -50,10 +57,12 @@ func (service *Service) Heartbeat(ctx context.Context, request HeartbeatRequest,
interval = DefaultHeartbeatIntervalSeconds
}
var response HeartbeatResponse
var history models.AgentHeartbeatLog
var resumed *models.AgentDeviceStatusEvent
err = service.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var device models.AgentDevice
digest := tokenDigest(presentedToken)
if err := tx.Where("token_digest = ? AND token_revoked_at IS NULL", digest).First(&device).Error; err != nil {
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("token_digest = ? AND token_revoked_at IS NULL", digest).First(&device).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return tokenInvalidError()
}
@@ -93,14 +102,30 @@ func (service *Service) Heartbeat(ctx context.Context, request HeartbeatRequest,
if result.RowsAffected == 0 {
return tokenInvalidError()
}
if device.Status == models.DeviceStatusOffline {
resumed = &models.AgentDeviceStatusEvent{DeviceID: device.ID, OccurredAt: now, FromStatus: device.Status, ToStatus: models.DeviceStatusOnline, Reason: "heartbeat_resumed", LastHeartbeatAt: device.LastHeartbeatAt}
if err := insertDeviceStatusEvent(tx, resumed); err != nil {
return err
}
}
}
response = HeartbeatResponse{
RequestID: request.RequestID, DeviceID: device.ID, CurrentTaskID: runningTaskID,
Online: true, Busy: runningTaskID != nil, ServerTime: now.UTC().Format(time.RFC3339Nano),
HeartbeatIntervalSeconds: interval, Replayed: replayed,
HeartbeatIntervalSeconds: interval, Replayed: replayed, AcceptsClientReport: true,
}
history = models.AgentHeartbeatLog{DeviceID: device.ID, ReceivedAt: now, RequestID: request.RequestID, CurrentTaskID: request.CurrentTaskID, RunningTaskID: runningTaskID, Busy: runningTaskID != nil, AgentVersion: device.AgentVersion}
return nil
})
if err == nil {
if resumed != nil {
logDeviceStatusEvent(*resumed)
}
if !response.Replayed {
history.ClientReportJSON, history.ClientReportStatus = validateHeartbeatClientReport(request.ClientReport)
service.saveHeartbeatHistory(ctx, history)
}
}
return response, err
}
@@ -115,8 +140,8 @@ func tokenInvalidError() error {
return &ServiceError{Code: CodeTokenInvalid, Message: "设备 Token 无效", Retryable: false}
}
// MarkStaleDevicesOffline atomically marks stale devices offline and fails any
// running task held by those devices. It returns the number of devices changed.
// MarkStaleDevicesOffline commits each device, its running tasks and its event
// independently. The count always describes committed device transitions.
func (service *Service) MarkStaleDevicesOffline(ctx context.Context, threshold time.Duration) (int64, error) {
if threshold <= 0 {
return 0, fmt.Errorf("offline threshold must be positive")
@@ -124,69 +149,152 @@ func (service *Service) MarkStaleDevicesOffline(ctx context.Context, threshold t
now := service.Now()
cutoff := now.Add(-threshold)
var changed int64
err := service.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var deviceIDs []uint64
if err := tx.Model(&models.AgentDevice{}).
Where("status = ? AND COALESCE(last_heartbeat_at, created_at) < ?", models.DeviceStatusOnline, cutoff).
Pluck("id", &deviceIDs).Error; err != nil {
return internalError(err)
var deviceIDs []uint64
if err := service.DB.WithContext(ctx).Model(&models.AgentDevice{}).
Where("status = ? AND COALESCE(last_heartbeat_at, created_at) < ?", models.DeviceStatusOnline, cutoff).
Order("id").Pluck("id", &deviceIDs).Error; err != nil {
return 0, internalError(err)
}
if len(deviceIDs) == 0 {
return 0, nil
}
for _, deviceID := range deviceIDs {
if err := ctx.Err(); err != nil {
return changed, err
}
if len(deviceIDs) == 0 {
return nil
var event *models.AgentDeviceStatusEvent
// Expected NOWAIT contention must not be printed by GORM with SQL or
// parameters. The fixed warning below is the only contention log.
err := service.DB.WithContext(ctx).Session(&gorm.Session{Logger: logger.Default.LogMode(logger.Silent)}).Transaction(func(tx *gorm.DB) error {
var device models.AgentDevice
// The unlocked candidate list is only a hint. Lock the exact primary
// key without waiting, then revalidate its current state and heartbeat.
if err := tx.Clauses(clause.Locking{Strength: "UPDATE", Options: "NOWAIT"}).First(&device, deviceID).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil
}
return internalError(err)
}
last := device.CreatedAt
if device.LastHeartbeatAt != nil {
last = *device.LastHeartbeatAt
}
if device.Status != models.DeviceStatusOnline || !last.Before(cutoff) {
return nil
}
// Existing Admin reset paths lock task -> device, while collection
// Claim locks device -> task. Never wait for a task holding this device:
// contention rolls back only this device; a later scan retries it.
tasks, err := lockOfflineTasksNowait(tx, device.ID)
if err != nil {
return err
}
var collectionFailed int64
if len(tasks.CollectionIDs) > 0 {
result := tx.Session(&gorm.Session{SkipHooks: true}).Model(&models.CollectionTask{}).
Where("id IN ? AND status = ?", tasks.CollectionIDs, models.TaskStatusRunning).
Updates(map[string]any{
"status": models.TaskStatusFailed, "active_slot": gorm.Expr("NULL"), "device_run_slot": gorm.Expr("NULL"),
"lease_expires_at": gorm.Expr("NULL"), "error_code": "DEVICE_OFFLINE",
"error_message": "设备心跳超时,任务失败且不会自动重试", "finished_at": now,
})
if result.Error != nil {
return internalError(result.Error)
}
collectionFailed = result.RowsAffected
}
failed, unknown, err := markOfflinePurchaseTasks(tx, tasks, now)
if err != nil {
return err
}
event = &models.AgentDeviceStatusEvent{DeviceID: device.ID, OccurredAt: now, FromStatus: device.Status, ToStatus: models.DeviceStatusOffline, Reason: "heartbeat_timeout", LastHeartbeatAt: device.LastHeartbeatAt, FailedTaskCount: collectionFailed + failed, OrderResultUnknownCount: unknown}
result := tx.Model(&models.AgentDevice{}).Where("id = ? AND status = ?", device.ID, models.DeviceStatusOnline).
Update("status", models.DeviceStatusOffline)
if result.Error != nil {
return internalError(result.Error)
}
return insertDeviceStatusEvent(tx, event)
})
if err != nil {
if ctxErr := ctx.Err(); ctxErr != nil {
return changed, ctxErr
}
var mysqlErr *mysqlDriver.MySQLError
if errors.As(err, &mysqlErr) && mysqlErr.Number == 3572 {
log.Warnf("agent offline scan skipped: device_id=%d classification=lock_busy", deviceID)
continue
}
return changed, err
}
if err := tx.Session(&gorm.Session{SkipHooks: true}).Model(&models.CollectionTask{}).
Where("device_id IN ? AND status = ?", deviceIDs, models.TaskStatusRunning).
Updates(map[string]any{
"status": models.TaskStatusFailed, "active_slot": gorm.Expr("NULL"), "device_run_slot": gorm.Expr("NULL"),
"lease_expires_at": gorm.Expr("NULL"), "error_code": "DEVICE_OFFLINE",
"error_message": "设备心跳超时,任务失败且不会自动重试", "finished_at": now,
}).Error; err != nil {
return internalError(err)
if event != nil {
changed++
logDeviceStatusEvent(*event)
}
if err := markOfflinePurchaseTasks(tx, deviceIDs, now); err != nil {
return err
}
result := tx.Model(&models.AgentDevice{}).Where("id IN ? AND status = ?", deviceIDs, models.DeviceStatusOnline).
Update("status", models.DeviceStatusOffline)
if result.Error != nil {
return internalError(result.Error)
}
changed = result.RowsAffected
return nil
})
return changed, err
}
return changed, nil
}
func markOfflinePurchaseTasks(tx *gorm.DB, deviceIDs []uint64, now time.Time) error {
if err := markOfflinePurchaseStatus(
tx, deviceIDs, models.PurchaseTaskStatusRunning, models.PurchaseTaskStatusFailed,
"failed", "设备心跳超时,采购任务失败且不会自动重试", now,
); err != nil {
return err
type offlineTasks struct {
CollectionIDs []uint64
PurchaseRunningIDs []uint64
PurchaseSubmitStartedIDs []uint64
}
func lockOfflineTasksNowait(tx *gorm.DB, deviceID uint64) (offlineTasks, error) {
var tasks offlineTasks
locking := clause.Locking{Strength: "UPDATE", Options: "NOWAIT"}
if err := tx.Model(&models.CollectionTask{}).Clauses(locking).
Where("device_id = ? AND status = ?", deviceID, models.TaskStatusRunning).
Order("id").Pluck("id", &tasks.CollectionIDs).Error; err != nil {
return tasks, internalError(err)
}
return markOfflinePurchaseStatus(
tx, deviceIDs, models.PurchaseTaskStatusOrderSubmitStarted, models.PurchaseTaskStatusOrderResultUnknown,
var purchases []struct {
ID uint64
Status string
}
if err := tx.Model(&models.PurchaseTask{}).Select("id, status").Clauses(locking).
Where("device_id = ? AND status IN ?", deviceID, []string{models.PurchaseTaskStatusRunning, models.PurchaseTaskStatusOrderSubmitStarted}).
Order("id").Find(&purchases).Error; err != nil {
return tasks, internalError(err)
}
for _, task := range purchases {
if task.Status == models.PurchaseTaskStatusRunning {
tasks.PurchaseRunningIDs = append(tasks.PurchaseRunningIDs, task.ID)
} else {
tasks.PurchaseSubmitStartedIDs = append(tasks.PurchaseSubmitStartedIDs, task.ID)
}
}
return tasks, nil
}
func markOfflinePurchaseTasks(tx *gorm.DB, tasks offlineTasks, now time.Time) (int64, int64, error) {
failed, err := markOfflinePurchaseStatus(
tx, tasks.PurchaseRunningIDs, models.PurchaseTaskStatusRunning, models.PurchaseTaskStatusFailed,
"failed", "设备心跳超时,采购任务失败且不会自动重试", now,
)
if err != nil {
return 0, 0, err
}
unknown, err := markOfflinePurchaseStatus(
tx, tasks.PurchaseSubmitStartedIDs, models.PurchaseTaskStatusOrderSubmitStarted, models.PurchaseTaskStatusOrderResultUnknown,
"order_result_unknown", "设备心跳超时,订单结果未知,请人工核对,禁止自动重试", now,
)
return failed, unknown, err
}
func markOfflinePurchaseStatus(
tx *gorm.DB,
deviceIDs []uint64,
taskIDs []uint64,
fromStatus string,
toStatus string,
resultType string,
errorMessage string,
now time.Time,
) error {
var taskIDs []uint64
if err := tx.Model(&models.PurchaseTask{}).
Where("device_id IN ? AND status = ?", deviceIDs, fromStatus).
Pluck("id", &taskIDs).Error; err != nil {
return internalError(err)
}
) (int64, error) {
// Reuse the IDs and source states from the locking current read. An
// ordinary SELECT here could see an older REPEATABLE READ snapshot.
if len(taskIDs) == 0 {
return nil
return 0, nil
}
errorCode := "DEVICE_OFFLINE"
if err := tx.Model(&models.PurchaseTaskAttempt{}).
@@ -195,7 +303,7 @@ func markOfflinePurchaseStatus(
"status": models.PurchaseAttemptStatusFailed, "result_type": resultType,
"error_code": errorCode, "error_message": errorMessage, "finished_at": now,
}).Error; err != nil {
return internalError(err)
return 0, internalError(err)
}
updates := map[string]any{
"status": toStatus, "device_run_slot": gorm.Expr("NULL"), "account_run_slot": gorm.Expr("NULL"),
@@ -205,12 +313,13 @@ func markOfflinePurchaseStatus(
if toStatus == models.PurchaseTaskStatusFailed {
updates["active_slot"] = gorm.Expr("NULL")
}
if err := tx.Session(&gorm.Session{SkipHooks: true}).Model(&models.PurchaseTask{}).
result := tx.Session(&gorm.Session{SkipHooks: true}).Model(&models.PurchaseTask{}).
Where("id IN ? AND status = ?", taskIDs, fromStatus).
Updates(updates).Error; err != nil {
return internalError(err)
Updates(updates)
if result.Error != nil {
return 0, internalError(result.Error)
}
return nil
return result.RowsAffected, nil
}
func RunOfflineMonitor(ctx context.Context, service *Service, scanInterval, threshold time.Duration, onError func(error)) {
@@ -0,0 +1,432 @@
package device_test
import (
"context"
"errors"
"fmt"
"strings"
"sync"
"testing"
"time"
"go-admin/app/goauto/device"
"go-admin/app/goauto/models"
"go-admin/app/goauto/purchase"
"go-admin/app/goauto/task"
driver "github.com/go-sql-driver/mysql"
"github.com/google/uuid"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
type offlineSeed func(*testing.T) (*device.Service, device.RegisterResponse, string)
func offlineCollection(t *testing.T, db *gorm.DB, deviceID uint64, status string) models.CollectionTask {
t.Helper()
product := models.PDDProduct{GoodsID: strings.ReplaceAll(uuid.NewString(), "-", ""), URL: "https://example.invalid/test"}
rule := models.CollectionRule{Name: "test", ContentJSON: `{"steps":[]}`}
if err := db.Create(&product).Error; err != nil {
t.Fatal("synthetic product failed")
}
if err := db.Create(&rule).Error; err != nil {
t.Fatal("synthetic rule failed")
}
task := models.CollectionTask{PDDProductID: &product.ID, RuleID: rule.ID, DeviceID: &deviceID, Status: status, URLSnapshot: product.URL, GoodsIDSnapshot: product.GoodsID, RuleSnapshot: rule.ContentJSON}
if err := db.Create(&task).Error; err != nil {
t.Fatal("synthetic collection task failed")
}
return task
}
func offlinePurchase(t *testing.T, db *gorm.DB, deviceID uint64, status string) models.PurchaseTask {
t.Helper()
product := models.PDDProduct{GoodsID: strings.ReplaceAll(uuid.NewString(), "-", ""), URL: "https://example.invalid/test"}
if err := db.Create(&product).Error; err != nil {
t.Fatal("synthetic product failed")
}
task := models.PurchaseTask{PDDProductID: product.ID, DeviceID: &deviceID, ExecutionMode: models.PurchaseExecutionModeRehearsal, Status: status, PDDURLSnapshot: product.URL, PDDGoodsIDSnapshot: product.GoodsID, Quantity: 1, ReferenceUnitPriceCent: 100, MinUnitPriceCent: 20, MaxUnitPriceCent: 150, Currency: "CNY", RuleType: "pddPurchase", RuleSchemaVersion: 1, RequiredCapabilitiesJSON: `[]`, RuleSnapshot: `{}`, CreateRequestID: uuid.NewString()}
if err := db.Create(&task).Error; err != nil {
t.Fatal("synthetic purchase task failed")
}
return task
}
func requireOfflineDevice(t *testing.T, db *gorm.DB, id uint64, status string, events int64) {
t.Helper()
var row models.AgentDevice
if db.First(&row, id).Error != nil || row.Status != status {
t.Fatalf("device %d status=%s want=%s", id, row.Status, status)
}
var count int64
if db.Model(&models.AgentDeviceStatusEvent{}).Where("device_id = ?", id).Count(&count).Error != nil || count != events {
t.Fatalf("device %d events=%d want=%d", id, count, events)
}
}
func runOfflineV4MySQLTests(t *testing.T, db *gorm.DB, seed offlineSeed) {
for _, busyKind := range []string{"device", "collection", "purchase"} {
t.Run("busy_"+busyKind+"_does_not_block_other_devices", func(t *testing.T) {
s, first, _ := seed(t)
_, busy, _ := seed(t)
_, last, _ := seed(t)
var lockedModel any
var lockedID uint64
switch busyKind {
case "device":
lockedModel = &models.AgentDevice{}
lockedID = busy.DeviceID
case "collection":
task := offlineCollection(t, db, busy.DeviceID, models.TaskStatusRunning)
lockedModel = &models.CollectionTask{}
lockedID = task.ID
case "purchase":
task := offlinePurchase(t, db, busy.DeviceID, models.PurchaseTaskStatusRunning)
lockedModel = &models.PurchaseTask{}
lockedID = task.ID
}
holder := db.Begin()
if holder.Error != nil {
t.Fatal("begin failed")
}
defer holder.Rollback()
if holder.Clauses(clause.Locking{Strength: "UPDATE"}).First(lockedModel, lockedID).Error != nil {
t.Fatal("synthetic lock failed")
}
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
n, err := s.MarkStaleDevicesOffline(ctx, device.DefaultOfflineThreshold)
if err != nil || n != 2 {
t.Fatalf("busy %s prevented unrelated devices: changed=%d failed=%v", busyKind, n, err != nil)
}
requireOfflineDevice(t, db, first.DeviceID, models.DeviceStatusOffline, 1)
requireOfflineDevice(t, db, busy.DeviceID, models.DeviceStatusOnline, 0)
requireOfflineDevice(t, db, last.DeviceID, models.DeviceStatusOffline, 1)
if holder.Rollback().Error != nil {
t.Fatal("release failed")
}
n, err = s.MarkStaleDevicesOffline(context.Background(), device.DefaultOfflineThreshold)
if err != nil || n != 1 {
t.Fatal("busy device not retried on next scan")
}
})
}
for _, target := range []string{models.PurchaseTaskStatusRunning, models.PurchaseTaskStatusOrderSubmitStarted} {
t.Run("second_device_current_read_"+target, func(t *testing.T) {
s, first, _ := seed(t)
_, second, _ := seed(t)
offlinePurchase(t, db, first.DeviceID, models.PurchaseTaskStatusRunning)
task := offlinePurchase(t, db, second.DeviceID, models.PurchaseTaskStatusPending)
attempt := models.PurchaseTaskAttempt{TaskID: task.ID, AttemptID: uuid.NewString(), AttemptNumber: 1, Phase: models.PurchaseAttemptPhasePurchase, Status: models.PurchaseAttemptStatusRunning, DeviceID: &second.DeviceID, RuleSnapshotHash: "test", SpecDecisionSnapshot: `{}`}
if db.Create(&attempt).Error != nil {
t.Fatal("synthetic attempt failed")
}
callback := "v4_advance_second_device"
if db.Callback().Create().After("gorm:create").Register(callback, func(tx *gorm.DB) {
event, ok := tx.Statement.Dest.(*models.AgentDeviceStatusEvent)
if !ok || event.DeviceID != first.DeviceID {
return
}
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
if err := db.WithContext(ctx).Session(&gorm.Session{SkipHooks: true}).Model(&models.PurchaseTask{}).Where("id = ?", task.ID).Updates(map[string]any{"status": target, "device_run_slot": 1}).Error; err != nil {
tx.AddError(errors.New("synthetic second device advancement failed"))
}
}) != nil {
t.Fatal("register advancement failed")
}
defer db.Callback().Create().Remove(callback)
n, err := s.MarkStaleDevicesOffline(context.Background(), device.DefaultOfflineThreshold)
if err != nil || n != 2 {
t.Fatalf("scan changed=%d failed=%v", n, err != nil)
}
var stored models.PurchaseTask
var storedAttempt models.PurchaseTaskAttempt
var event models.AgentDeviceStatusEvent
if db.First(&stored, task.ID).Error != nil || db.First(&storedAttempt, attempt.ID).Error != nil || db.Where("device_id = ?", second.DeviceID).First(&event).Error != nil {
t.Fatal("read outcome failed")
}
expected := models.PurchaseTaskStatusFailed
failed, unknown := int64(1), int64(0)
if target == models.PurchaseTaskStatusOrderSubmitStarted {
expected = models.PurchaseTaskStatusOrderResultUnknown
failed, unknown = 0, 1
}
if stored.Status != expected || storedAttempt.Status != models.PurchaseAttemptStatusFailed || event.FailedTaskCount != failed || event.OrderResultUnknownCount != unknown {
t.Fatalf("stale task snapshot: status=%s attempt=%s failed=%d unknown=%d", stored.Status, storedAttempt.Status, event.FailedTaskCount, event.OrderResultUnknownCount)
}
})
}
t.Run("event_failure_preserves_prior_device_commit", func(t *testing.T) {
s, first, _ := seed(t)
_, second, _ := seed(t)
_, last, _ := seed(t)
task := offlineCollection(t, db, second.DeviceID, models.TaskStatusRunning)
statement := fmt.Sprintf("CREATE TRIGGER v4_reject_event BEFORE INSERT ON agent_device_status_event FOR EACH ROW BEGIN IF NEW.device_id = %d THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'synthetic_failure'; END IF; END", second.DeviceID)
if db.Exec(statement).Error != nil {
t.Fatal("test trigger failed")
}
defer db.Exec("DROP TRIGGER IF EXISTS v4_reject_event")
n, err := s.MarkStaleDevicesOffline(context.Background(), device.DefaultOfflineThreshold)
if err == nil || n != 1 {
t.Fatalf("must return earlier committed count on event failure: changed=%d failed=%v", n, err != nil)
}
requireOfflineDevice(t, db, first.DeviceID, models.DeviceStatusOffline, 1)
requireOfflineDevice(t, db, second.DeviceID, models.DeviceStatusOnline, 0)
requireOfflineDevice(t, db, last.DeviceID, models.DeviceStatusOnline, 0)
var stored models.CollectionTask
if db.First(&stored, task.ID).Error != nil || stored.Status != models.TaskStatusRunning {
t.Fatal("current task escaped rollback")
}
if db.Exec("DROP TRIGGER v4_reject_event").Error != nil {
t.Fatal("remove trigger failed")
}
n, err = s.MarkStaleDevicesOffline(context.Background(), device.DefaultOfflineThreshold)
if err != nil || n != 2 {
t.Fatal("next scan retry failed")
}
})
for _, kind := range []string{"1213", "1205", "text_3572", "cancel", "cancel_with_3572"} {
t.Run("non_contention_error_"+kind, func(t *testing.T) {
s, first, _ := seed(t)
_, second, _ := seed(t)
_, last, _ := seed(t)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
callback := "v4_inject_non_contention"
if db.Callback().Query().After("gorm:query").Register(callback, func(tx *gorm.DB) {
row, ok := tx.Statement.Dest.(*models.AgentDevice)
if !ok || row.ID != second.DeviceID {
return
}
if _, locking := tx.Statement.Clauses["FOR"]; !locking {
return
}
switch kind {
case "1213":
tx.AddError(&driver.MySQLError{Number: 1213, Message: "synthetic"})
case "1205":
tx.AddError(&driver.MySQLError{Number: 1205, Message: "synthetic"})
case "text_3572":
tx.AddError(errors.New("synthetic 3572"))
case "cancel":
cancel()
tx.AddError(context.Canceled)
case "cancel_with_3572":
cancel()
tx.AddError(&driver.MySQLError{Number: 3572, Message: "synthetic"})
}
}) != nil {
t.Fatal("register injection failed")
}
defer db.Callback().Query().Remove(callback)
n, err := s.MarkStaleDevicesOffline(ctx, device.DefaultOfflineThreshold)
if err == nil || n != 1 {
t.Fatalf("non-contention error swallowed or lost prior commit: changed=%d failed=%v", n, err != nil)
}
if (kind == "cancel" || kind == "cancel_with_3572") && !errors.Is(err, context.Canceled) {
t.Fatal("cancellation not preserved")
}
requireOfflineDevice(t, db, first.DeviceID, models.DeviceStatusOffline, 1)
requireOfflineDevice(t, db, second.DeviceID, models.DeviceStatusOnline, 0)
requireOfflineDevice(t, db, last.DeviceID, models.DeviceStatusOnline, 0)
})
}
for _, domain := range []string{"collection_claim", "purchase_replayed_claim"} {
t.Run("actual_"+domain+"_does_not_deadlock_scan", func(t *testing.T) {
s, d, token := seed(t)
_, other, _ := seed(t)
type claimMarker struct{}
locked, release := make(chan struct{}), make(chan struct{})
var once sync.Once
callback := "v4_actual_claim_barrier"
if db.Callback().Query().After("gorm:query").Register(callback, func(tx *gorm.DB) {
if tx.Statement.Context.Value(claimMarker{}) != true {
return
}
lockTable := "agent_device"
if domain == "purchase_replayed_claim" {
lockTable = "purchase_task"
}
if tx.Statement.Table != lockTable {
return
}
if _, ok := tx.Statement.Clauses["FOR"]; !ok {
return
}
once.Do(func() { close(locked); <-release })
}) != nil {
t.Fatal("register actual claim barrier failed")
}
defer db.Callback().Query().Remove(callback)
ctx, cancel := context.WithTimeout(context.WithValue(context.Background(), claimMarker{}, true), 5*time.Second)
defer cancel()
done := make(chan error, 1)
var taskID uint64
if domain == "collection_claim" {
record := offlineCollection(t, db, d.DeviceID, models.TaskStatusPending)
taskID = record.ID
service := task.NewService(db)
service.Now = s.Now
go func() {
_, err := service.Claim(ctx, record.ID, task.ActionRequest{RequestID: uuid.NewString()}, token)
done <- err
}()
} else {
record := offlinePurchase(t, db, d.DeviceID, models.PurchaseTaskStatusRunning)
taskID = record.ID
requestID := uuid.NewString()
if db.Session(&gorm.Session{SkipHooks: true}).Model(&models.PurchaseTask{}).Where("id = ?", record.ID).Update("claim_request_id", requestID).Error != nil {
t.Fatal("replay fixture failed")
}
service := purchase.NewService(db)
service.Now = s.Now
go func() {
_, err := service.Claim(ctx, record.ID, purchase.ActionRequest{RequestID: requestID}, token)
done <- err
}()
}
select {
case <-locked:
case <-ctx.Done():
close(release)
t.Fatal("actual claim lock not reached")
}
scanCtx, stopScan := context.WithTimeout(context.Background(), 2*time.Second)
n, err := s.MarkStaleDevicesOffline(scanCtx, device.DefaultOfflineThreshold)
stopScan()
close(release)
claimErr := <-done
if err != nil || claimErr != nil || n != 1 {
t.Fatalf("claim/scan conflict: changed=%d scanFailed=%v claimFailed=%v", n, err != nil, claimErr != nil)
}
requireOfflineDevice(t, db, d.DeviceID, models.DeviceStatusOnline, 0)
requireOfflineDevice(t, db, other.DeviceID, models.DeviceStatusOffline, 1)
if domain == "collection_claim" {
var stored models.CollectionTask
if db.First(&stored, taskID).Error != nil || stored.Status != models.TaskStatusPending || stored.LeaseExpiresAt == nil || stored.ClaimRequestID == nil {
t.Fatal("claim partially persisted")
}
}
})
}
t.Run("real_heartbeat_started_during_scan_resumes_once", func(t *testing.T) {
s, d, token := seed(t)
type scanMarker struct{}
type heartbeatMarker struct{}
locked, release, heartbeatStarted := make(chan struct{}), make(chan struct{}), make(chan struct{})
scanCallback, heartbeatCallback := "v4_scan_heartbeat_barrier", "v4_heartbeat_scan_barrier"
if db.Callback().Query().After("gorm:query").Register(scanCallback, func(tx *gorm.DB) {
if tx.Statement.Context.Value(scanMarker{}) == true && tx.Statement.Table == "agent_device" {
if _, ok := tx.Statement.Clauses["FOR"]; ok {
close(locked)
<-release
}
}
}) != nil {
t.Fatal("register scan barrier failed")
}
defer db.Callback().Query().Remove(scanCallback)
if db.Callback().Query().Before("gorm:query").Register(heartbeatCallback, func(tx *gorm.DB) {
if tx.Statement.Context.Value(heartbeatMarker{}) == true && tx.Statement.Table == "agent_device" {
if _, ok := tx.Statement.Clauses["FOR"]; ok {
close(heartbeatStarted)
}
}
}) != nil {
t.Fatal("register heartbeat barrier failed")
}
defer db.Callback().Query().Remove(heartbeatCallback)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
scanDone, heartbeatDone := make(chan error, 1), make(chan error, 1)
go func() {
n, err := s.MarkStaleDevicesOffline(context.WithValue(ctx, scanMarker{}, true), device.DefaultOfflineThreshold)
if err == nil && n != 1 {
err = errors.New("wrong scan count")
}
scanDone <- err
}()
select {
case <-locked:
case <-ctx.Done():
close(release)
t.Fatal("scan lock not reached")
}
go func() {
_, err := s.Heartbeat(context.WithValue(ctx, heartbeatMarker{}, true), device.HeartbeatRequest{RequestID: uuid.NewString()}, token)
heartbeatDone <- err
}()
select {
case <-heartbeatStarted:
case <-ctx.Done():
close(release)
t.Fatal("heartbeat not started")
}
close(release)
if err := <-scanDone; err != nil {
t.Fatal("scan failed")
}
if err := <-heartbeatDone; err != nil {
t.Fatal("heartbeat failed")
}
requireOfflineDevice(t, db, d.DeviceID, models.DeviceStatusOnline, 2)
})
t.Run("explain_actual_lock_queries", func(t *testing.T) {
s, d, _ := seed(t)
offlineCollection(t, db, d.DeviceID, models.TaskStatusRunning)
offlinePurchase(t, db, d.DeviceID, models.PurchaseTaskStatusRunning)
type captured struct {
sql string
vars []any
}
queries := map[string]captured{}
callback := "v4_capture_lock_sql"
if db.Callback().Query().After("gorm:query").Register(callback, func(tx *gorm.DB) {
if _, ok := tx.Statement.Clauses["FOR"]; ok {
queries[tx.Statement.Table] = captured{tx.Statement.SQL.String(), append([]any(nil), tx.Statement.Vars...)}
}
}) != nil {
t.Fatal("capture SQL failed")
}
n, err := s.MarkStaleDevicesOffline(context.Background(), device.DefaultOfflineThreshold)
db.Callback().Query().Remove(callback)
if err != nil || n != 1 {
t.Fatal("capture scan failed")
}
for _, table := range []string{"agent_device", "collection_task", "purchase_task"} {
query, ok := queries[table]
if !ok {
t.Fatalf("missing production lock query %s", table)
}
var plan []struct {
Type string
Key *string
PossibleKeys *string
Rows int64
Extra string
}
if db.Raw("EXPLAIN "+query.sql, query.vars...).Scan(&plan).Error != nil {
t.Fatal("EXPLAIN failed")
}
t.Logf("synthetic fixture only table=%s sql=%s", table, query.sql)
for _, row := range plan {
key := "NULL"
if row.Key != nil {
key = *row.Key
}
possible := "NULL"
if row.PossibleKeys != nil {
possible = *row.PossibleKeys
}
t.Logf("type=%s key=%s possible_keys=%s estimated_rows=%d extra=%s", row.Type, key, possible, row.Rows, row.Extra)
}
}
})
}
+22 -4
View File
@@ -16,6 +16,7 @@ import (
"github.com/google/uuid"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
const DefaultHeartbeatIntervalSeconds = 15
@@ -305,7 +306,15 @@ func (service *Service) RevokeToken(ctx context.Context, deviceID uint64) error
func (service *Service) deactivate(ctx context.Context, deviceID uint64, revoke bool) error {
now := service.Now()
return service.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var event *models.AgentDeviceStatusEvent
err := service.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var device models.AgentDevice
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&device, deviceID).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return &ServiceError{Code: CodeDeviceNotFound, Message: "设备不存在", Retryable: false}
}
return internalError(err)
}
updates := map[string]any{"status": models.DeviceStatusDisabled}
if revoke {
updates["token_revoked_at"] = now
@@ -324,17 +333,26 @@ func (service *Service) deactivate(ctx context.Context, deviceID uint64, revoke
}
}
errorCode, errorMessage := CodeDeviceDisabled, "设备已由管理员停用"
if err := tx.Session(&gorm.Session{SkipHooks: true}).Model(&models.CollectionTask{}).
result = tx.Session(&gorm.Session{SkipHooks: true}).Model(&models.CollectionTask{}).
Where("device_id = ? AND status = ?", deviceID, models.TaskStatusRunning).
Updates(map[string]any{
"status": models.TaskStatusFailed, "active_slot": gorm.Expr("NULL"),
"device_run_slot": gorm.Expr("NULL"), "lease_expires_at": gorm.Expr("NULL"),
"error_code": errorCode, "error_message": errorMessage, "finished_at": now,
}).Error; err != nil {
return internalError(err)
})
if result.Error != nil {
return internalError(result.Error)
}
if device.Status != models.DeviceStatusDisabled {
event = &models.AgentDeviceStatusEvent{DeviceID: device.ID, OccurredAt: now, FromStatus: device.Status, ToStatus: models.DeviceStatusDisabled, Reason: "disabled", LastHeartbeatAt: device.LastHeartbeatAt, FailedTaskCount: result.RowsAffected}
return insertDeviceStatusEvent(tx, event)
}
return nil
})
if err == nil && event != nil {
logDeviceStatusEvent(*event)
}
return err
}
func normalizeRegisterRequest(request RegisterRequest) RegisterRequest {
+2
View File
@@ -30,6 +30,8 @@ LIMIT 1`
func MigratedModels() []any {
return []any{
&models.AgentDevice{},
&models.AgentHeartbeatLog{},
&models.AgentDeviceStatusEvent{},
&models.AgentAppRelease{},
&models.AgentAppReleaseSetting{},
&models.PDDProduct{},
@@ -0,0 +1,33 @@
package models
import "time"
// AgentHeartbeatLog is diagnostic history, never a device liveness authority.
type AgentHeartbeatLog struct {
ID uint64 `gorm:"primaryKey;autoIncrement"`
DeviceID uint64 `gorm:"not null;index:ix_heartbeat_device_received,priority:1"`
ReceivedAt time.Time `gorm:"not null;index:ix_heartbeat_device_received,priority:2;index:ix_heartbeat_received"`
RequestID string `gorm:"size:36;not null"`
CurrentTaskID *uint64
RunningTaskID *uint64
Busy bool `gorm:"not null"`
AgentVersion string `gorm:"size:32;not null"`
ClientReportJSON *string `gorm:"type:text"`
ClientReportStatus string `gorm:"size:32;not null;check:ck_heartbeat_report_status,client_report_status IN ('none','accepted','client_report_invalid')"`
}
func (AgentHeartbeatLog) TableName() string { return "agent_heartbeat_log" }
type AgentDeviceStatusEvent struct {
ID uint64 `gorm:"primaryKey;autoIncrement"`
DeviceID uint64 `gorm:"not null"`
OccurredAt time.Time `gorm:"not null;index:ix_device_event_occurred"`
FromStatus string `gorm:"size:16;not null"`
ToStatus string `gorm:"size:16;not null"`
Reason string `gorm:"size:32;not null"`
LastHeartbeatAt *time.Time
FailedTaskCount int64 `gorm:"not null"`
OrderResultUnknownCount int64 `gorm:"not null"`
}
func (AgentDeviceStatusEvent) TableName() string { return "agent_device_status_event" }
+50
View File
@@ -25,6 +25,46 @@ type AdminListRequest struct {
ShopeeOrderNo string
ShopeeOrderNos []string
PDDOrderNo string
// OrderSubmittedFrom/To are inclusive YYYY-MM-DD days (UTC+8, same as the
// SYB products createdFrom/createdTo filter) over order_submitted_at.
OrderSubmittedFrom string
OrderSubmittedTo string
}
// adminOrderDayLocation matches the SYB products page's createdFrom/createdTo
// day boundary (sybimport.sybStockLocation) so both pages filter the same way.
var adminOrderDayLocation = time.FixedZone("SYB UTC+8", 8*60*60)
// adminOrderSubmittedRange returns [start, endExclusive); a nil bound means
// that side is open. Both days are inclusive for the caller.
func adminOrderSubmittedRange(from, to string) (*time.Time, *time.Time, error) {
from, to = strings.TrimSpace(from), strings.TrimSpace(to)
parse := func(value, label string) (*time.Time, error) {
if value == "" {
return nil, nil
}
parsed, err := time.ParseInLocation("2006-01-02", value, adminOrderDayLocation)
if err != nil {
return nil, fail(CodeInvalidRequest, label+" 必须是 YYYY-MM-DD")
}
return &parsed, nil
}
start, err := parse(from, "orderSubmittedFrom")
if err != nil {
return nil, nil, err
}
endDay, err := parse(to, "orderSubmittedTo")
if err != nil {
return nil, nil, err
}
if start != nil && endDay != nil && start.After(*endDay) {
return nil, nil, fail(CodeInvalidRequest, "orderSubmittedFrom 不能晚于 orderSubmittedTo")
}
if endDay != nil {
end := endDay.AddDate(0, 0, 1)
endDay = &end
}
return start, endDay, nil
}
type AdminTaskItem struct {
@@ -175,7 +215,17 @@ func (s *Service) AdminList(ctx context.Context, req AdminListRequest) (AdminLis
if req.TaskType != "" && req.TaskType != models.PurchaseTaskTypeSYBOrder && req.TaskType != models.PurchaseTaskTypeStock {
return AdminListResponse{}, fail(CodeInvalidRequest, "taskType 无效")
}
submittedFrom, submittedTo, err := adminOrderSubmittedRange(req.OrderSubmittedFrom, req.OrderSubmittedTo)
if err != nil {
return AdminListResponse{}, err
}
query := s.DB.WithContext(ctx).Model(&models.PurchaseTask{})
if submittedFrom != nil {
query = query.Where("order_submitted_at >= ?", *submittedFrom)
}
if submittedTo != nil {
query = query.Where("order_submitted_at < ?", *submittedTo)
}
if req.TaskID > 0 {
query = query.Where("id = ?", req.TaskID)
}
@@ -0,0 +1,129 @@
package purchase
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"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/models"
"go-admin/app/goauto/purchasecontract"
)
// #372: order_submitted_at day filter, inclusive on both ends, UTC+8 days.
func TestAdminOrderSubmittedRange(t *testing.T) {
db := testDB(t)
f := seed(t, db, []string{purchasecontract.CapabilityPurchaseRehearsalV1}, true)
s := testService(db)
base := createAdminQueryTask(t, s, f)
loc := time.FixedZone("UTC+8", 8*3600)
at := func(day string, h, m int) *time.Time {
d, _ := time.ParseInLocation("2006-01-02", day, loc)
v := d.Add(time.Duration(h)*time.Hour + time.Duration(m)*time.Minute)
return &v
}
cases := []struct {
name string
status string
at *time.Time
}{
{"before", models.PurchaseTaskStatusOrderCreated, at("2026-10-01", 23, 59)},
{"first-day-start", models.PurchaseTaskStatusOrderCreated, at("2026-10-02", 0, 0)},
{"middle", models.PurchaseTaskStatusOrderCreated, at("2026-10-03", 12, 0)},
{"last-day-end", models.PurchaseTaskStatusOrderCreated, at("2026-10-04", 23, 59)},
{"after", models.PurchaseTaskStatusOrderCreated, at("2026-10-05", 0, 0)},
{"middle-failed", models.PurchaseTaskStatusFailed, at("2026-10-03", 13, 0)},
{"no-time", models.PurchaseTaskStatusOrderCreated, nil},
}
for _, tc := range cases {
row := base
row.ID = 0
row.CreateRequestID = uuid.NewString()
row.ShopeeOrderNoSnapshot = tc.name
row.Status = tc.status
row.OrderSubmittedAt = tc.at
if err := db.Create(&row).Error; err != nil {
t.Fatal(err)
}
}
names := func(req AdminListRequest) map[string]bool {
t.Helper()
out, err := s.AdminList(context.Background(), req)
if err != nil {
t.Fatal(err)
}
got := map[string]bool{}
for _, item := range out.Items {
got[item.ShopeeOrderNoSnapshot] = true
}
if int(out.Total) != len(got) {
t.Fatalf("total %d != items %d", out.Total, len(got))
}
return got
}
both := names(AdminListRequest{OrderSubmittedFrom: "2026-10-02", OrderSubmittedTo: "2026-10-04"})
if len(both) != 4 || !both["first-day-start"] || !both["middle"] || !both["last-day-end"] || !both["middle-failed"] {
t.Fatalf("both days must be inclusive: %v", both)
}
combined := names(AdminListRequest{OrderSubmittedFrom: "2026-10-02", OrderSubmittedTo: "2026-10-04", Status: models.PurchaseTaskStatusOrderCreated})
if len(combined) != 3 || combined["middle-failed"] {
t.Fatalf("must combine with status: %v", combined)
}
if only := names(AdminListRequest{OrderSubmittedFrom: "2026-10-04"}); len(only) != 2 || !only["last-day-end"] || !only["after"] {
t.Fatalf("from only: %v", only)
}
if only := names(AdminListRequest{OrderSubmittedTo: "2026-10-02"}); len(only) != 2 || !only["before"] || !only["first-day-start"] {
t.Fatalf("to only: %v", only)
}
if one := names(AdminListRequest{OrderSubmittedFrom: "2026-10-03", OrderSubmittedTo: "2026-10-03"}); len(one) != 2 {
t.Fatalf("single day: %v", one)
}
if none := names(AdminListRequest{}); !none["no-time"] {
t.Fatalf("no filter must keep rows without order time: %v", none)
}
}
func TestAdminOrderSubmittedRangeInvalid(t *testing.T) {
gin.SetMode(gin.TestMode)
db := testDB(t)
f := seed(t, db, []string{purchasecontract.CapabilityPurchaseRehearsalV1}, true)
s := testService(db)
createAdminQueryTask(t, s, f)
for _, req := range []AdminListRequest{
{OrderSubmittedFrom: "2026/10/02"},
{OrderSubmittedTo: "10-02"},
{OrderSubmittedFrom: "2026-13-01"},
{OrderSubmittedFrom: "2026-10-05", OrderSubmittedTo: "2026-10-04"},
} {
if _, err := s.AdminList(context.Background(), req); code(err) != CodeInvalidRequest {
t.Fatalf("%+v: expected invalid request, got %v", req, err)
}
}
for _, query := range []string{"orderSubmittedFrom=abc", "orderSubmittedTo=2026-02-30"} {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Set("JWT_PAYLOAD", jwt.MapClaims{"rolekey": "purchaser"})
c.Request = httptest.NewRequest(http.MethodGet, "/api/admin/v1/purchase-tasks?"+query, nil)
(Handler{DB: db}).AdminList(c)
if !strings.Contains(w.Body.String(), CodeInvalidRequest) {
t.Fatalf("handler accepted %s: %s", query, w.Body.String())
}
}
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Set("JWT_PAYLOAD", jwt.MapClaims{"rolekey": "purchaser"})
c.Request = httptest.NewRequest(http.MethodGet, "/api/admin/v1/purchase-tasks?orderSubmittedFrom=2026-10-01&orderSubmittedTo=2026-10-02", nil)
(Handler{DB: db}).AdminList(c)
var result struct {
Data AdminListResponse `json:"data"`
}
if err := json.Unmarshal(w.Body.Bytes(), &result); err != nil || w.Code != http.StatusOK {
t.Fatalf("valid range: %d %s", w.Code, w.Body.String())
}
}
+6 -4
View File
@@ -52,10 +52,12 @@ func (h Handler) AdminList(c *gin.Context) {
response, err := service.AdminList(c.Request.Context(), AdminListRequest{
Page: page, PageSize: pageSize, TaskID: taskID, SYBProductID: sybProductID,
Status: strings.TrimSpace(c.Query("status")), ExecutionMode: strings.TrimSpace(c.Query("executionMode")),
TaskType: strings.TrimSpace(c.Query("taskType")),
ShopeeOrderNo: strings.TrimSpace(c.Query("shopeeOrderNo")),
ShopeeOrderNos: c.Request.URL.Query()["shopeeOrderNos"],
PDDOrderNo: strings.TrimSpace(c.Query("pddOrderNo")),
TaskType: strings.TrimSpace(c.Query("taskType")),
ShopeeOrderNo: strings.TrimSpace(c.Query("shopeeOrderNo")),
ShopeeOrderNos: c.Request.URL.Query()["shopeeOrderNos"],
PDDOrderNo: strings.TrimSpace(c.Query("pddOrderNo")),
OrderSubmittedFrom: c.Query("orderSubmittedFrom"),
OrderSubmittedTo: c.Query("orderSubmittedTo"),
})
if err != nil {
writeError(c, err)
+11 -5
View File
@@ -495,9 +495,12 @@ type ListFilter struct {
// sn, spec) so it never has to issue a second request per row.
type ListItem struct {
models.ReturnMatch
OrderSN string `json:"orderSn,omitempty"`
Image string `json:"image,omitempty"`
VariationName string `json:"variationName,omitempty"`
OrderSN string `json:"orderSn,omitempty"`
Image string `json:"image,omitempty"`
VariationName string `json:"variationName,omitempty"`
// YeekeQuantity is the yeeke return item's own quantity (read-only, #372
// export); it comes from the same join and is never used for matching.
YeekeQuantity int64 `json:"yeekeQuantity"`
DestroyDeadline *time.Time `json:"destroyDeadline,omitempty"`
// SyncStatus/MissingSince (#338) surface the yeeke return item's own
// current availability ("ok"/"missing") so the SYB products match column
@@ -538,6 +541,7 @@ func (s *Service) List(ctx context.Context, filter ListFilter) ([]ListItem, erro
OrderSN string
Image string
VariationName string
Quantity int64
DestroyDeadLine *time.Time
SyncStatus string
MissingSince *time.Time
@@ -545,7 +549,7 @@ func (s *Service) List(ctx context.Context, filter ListFilter) ([]ListItem, erro
PackageMissingAt *time.Time `gorm:"column:package_missing_since"`
}
if err := s.DB.WithContext(ctx).Table("yeeke_return_item AS i").
Select("i.id AS id, p.order_sn AS order_sn, i.image AS image, i.variation_name AS variation_name, p.destroy_dead_line AS destroy_dead_line, "+
Select("i.id AS id, p.order_sn AS order_sn, i.image AS image, i.variation_name AS variation_name, i.quantity AS quantity, p.destroy_dead_line AS destroy_dead_line, "+
"i.sync_status AS sync_status, i.missing_since AS missing_since, p.sync_status AS package_sync_status, p.missing_since AS package_missing_since").
Joins("JOIN yeeke_return_package AS p ON p.id = i.package_id").
Where("i.id IN ?", returnIDs).
@@ -556,6 +560,7 @@ func (s *Service) List(ctx context.Context, filter ListFilter) ([]ListItem, erro
OrderSN string
Image string
VariationName string
Quantity int64
DestroyDeadLine *time.Time
SyncStatus string
MissingSince *time.Time
@@ -569,13 +574,14 @@ func (s *Service) List(ctx context.Context, filter ListFilter) ([]ListItem, erro
if j.PackageSyncStat != "ok" {
syncStatus, missingSince = j.PackageSyncStat, j.PackageMissingAt
}
byID[j.ID] = extraFields{j.OrderSN, j.Image, j.VariationName, j.DestroyDeadLine, syncStatus, missingSince}
byID[j.ID] = extraFields{j.OrderSN, j.Image, j.VariationName, j.Quantity, j.DestroyDeadLine, syncStatus, missingSince}
}
for i := range items {
if extra, ok := byID[items[i].YeekeReturnItemID]; ok {
items[i].OrderSN = extra.OrderSN
items[i].Image = extra.Image
items[i].VariationName = extra.VariationName
items[i].YeekeQuantity = extra.Quantity
items[i].DestroyDeadline = extra.DestroyDeadLine
items[i].SyncStatus = extra.SyncStatus
items[i].MissingSince = extra.MissingSince
@@ -2,6 +2,7 @@ package returnmatch
import (
"context"
"encoding/json"
"errors"
"fmt"
"strings"
@@ -484,3 +485,27 @@ func TestList_EnrichesWithYeekeReturnDisplayFields(t *testing.T) {
t.Fatalf("expected enriched list item: %+v", items)
}
}
// #372: List exposes the yeeke return item's quantity (read-only export field).
func TestList_ExposesYeekeQuantity(t *testing.T) {
db := testDB(t)
s := NewService(db)
s.Now = func() time.Time { return time.Date(2026, 9, 24, 0, 0, 0, 0, time.UTC) }
syb := seedSYB(t, db, "SYB-Q", 1, "白色", "L", time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC))
deadline := time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC)
item := seedReturn(t, db, "白色,L", &deadline)
if err := db.Model(&models.YeekeReturnItem{}).Where("id = ?", item.ID).Update("quantity", 3).Error; err != nil {
t.Fatal(err)
}
if resp, err := s.BatchMatch(context.Background(), BatchMatchRequest{SYBProductIDs: []uint64{syb.ID}}); err != nil || resp.MatchedCount != 1 {
t.Fatalf("setup failed: %v %+v", err, resp)
}
items, err := s.List(context.Background(), ListFilter{SYBProductIDs: []uint64{syb.ID}})
if err != nil || len(items) != 1 || items[0].YeekeQuantity != 3 {
t.Fatalf("expected yeekeQuantity 3: %+v %v", items, err)
}
raw, _ := json.Marshal(items[0])
if !strings.Contains(string(raw), `"yeekeQuantity":3`) {
t.Fatalf("json missing yeekeQuantity: %s", raw)
}
}
+29 -3
View File
@@ -2,6 +2,7 @@ package task
import (
"context"
"encoding/json"
"errors"
"fmt"
"log"
@@ -15,8 +16,8 @@ import (
)
// autoLinkImageSearch is deliberately a narrow post-result hook. It only
// writes an association when the task was a complete collection, the frozen
// original association is unchanged, and the target product is active. Manual
// writes an association when the collection is complete or missing only optional
// 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.
// autoLinkedImageSearch 汇报本次是否确实写入了关联,以及关联覆盖了哪些 SYB 明细。
// 只有确实写入时才值得触发后续的规格匹配(#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 {
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
}
snapshot, err := loadImageSearchSnapshot(task)
@@ -94,6 +95,31 @@ func (service *Service) autoLinkImageSearch(ctx context.Context, taskID uint64)
}, 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
// 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")
}
})
}
}
+3
View File
@@ -129,6 +129,9 @@ func run() error {
defer stopOfflineMonitors()
for _, db := range sdk.Runtime.GetDb() {
service := goautodevice.NewService(db)
go goautodevice.RunDiagnosticsCleanup(offlineMonitorContext, service,
func(err error) { log.Error("device diagnostics cleanup failed") },
)
go goautopurchase.RunFailureSnapshotCleanup(
offlineMonitorContext, goautopurchase.NewService(db), time.Hour,
func(err error) { log.Error("purchase failure snapshot cleanup failed") },
@@ -0,0 +1,29 @@
package version_local
import (
"runtime"
"go-admin/app/goauto/models"
"go-admin/cmd/migrate/migration"
common "go-admin/common/models"
"gorm.io/gorm"
)
func init() {
_, file, _, _ := runtime.Caller(0)
migration.Migrate.SetVersion(migration.GetFilename(file), MigrateAgentDiagnostics)
}
// MigrateAgentDiagnostics only adds #378 diagnostic tables. MySQL DDL commits
// implicitly: idempotent table creation allows recovery after a partial up.
func MigrateAgentDiagnostics(db *gorm.DB, version string) error {
for _, model := range []any{&models.AgentHeartbeatLog{}, &models.AgentDeviceStatusEvent{}} {
if !db.Migrator().HasTable(model) {
if err := db.Migrator().CreateTable(model); err != nil {
return err
}
}
}
return db.Where("version = ?", version).FirstOrCreate(&common.Migration{Version: version}).Error
}
@@ -0,0 +1,48 @@
package version_local
import (
"go-admin/app/goauto/models"
common "go-admin/common/models"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"testing"
)
func TestAgentDiagnosticsMigrationAppendAndRollback(t *testing.T) {
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if err := db.AutoMigrate(&common.Migration{}); err != nil {
t.Fatal(err)
}
for i := 0; i < 2; i++ {
if err := MigrateAgentDiagnostics(db, "test378"); err != nil {
t.Fatal(err)
}
}
for _, model := range []any{&models.AgentHeartbeatLog{}, &models.AgentDeviceStatusEvent{}} {
if !db.Migrator().HasTable(model) {
t.Fatalf("missing table %T", model)
}
}
for _, idx := range []struct {
model any
name string
}{{&models.AgentHeartbeatLog{}, "ix_heartbeat_device_received"}, {&models.AgentHeartbeatLog{}, "ix_heartbeat_received"}, {&models.AgentDeviceStatusEvent{}, "ix_device_event_occurred"}} {
if !db.Migrator().HasIndex(idx.model, idx.name) {
t.Fatalf("missing index %s", idx.name)
}
}
// Existing migration runner only supports up. Rollback is an explicit,
// operator-authorized removal of these two new diagnostic tables/version.
if err := db.Migrator().DropTable(&models.AgentHeartbeatLog{}, &models.AgentDeviceStatusEvent{}); err != nil {
t.Fatal(err)
}
if err := db.Where("version = ?", "test378").Delete(&common.Migration{}).Error; err != nil {
t.Fatal(err)
}
if err := MigrateAgentDiagnostics(db, "test378"); err != nil {
t.Fatal(err)
}
}
+148
View File
@@ -0,0 +1,148 @@
// #372: export every page of an admin list (current filters) to xlsx.
// Pure helpers with injected dependencies so they can be unit tested without a
// browser; the pages inject the API calls and the Export2Excel saver.
export const EXPORT_MAX_ROWS = 5000
const pad = (value, width = 2) => String(value).padStart(width, '0')
// YYYY-MM-DD HH:mm:ss in the browser's local time zone; empty for blank/invalid.
export function formatExportTime(value) {
if (!value) return ''
const date = new Date(value)
if (Number.isNaN(date.getTime())) return ''
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`
}
// YYYYMMDD-HHmmss for file names.
export function formatExportStamp(date = new Date()) {
return `${date.getFullYear()}${pad(date.getMonth() + 1)}${pad(date.getDate())}-${pad(date.getHours())}${pad(date.getMinutes())}${pad(date.getSeconds())}`
}
// Cents -> yuan text with two decimals; null/undefined/'' stay empty.
export function centToYuan(cent) {
if (cent === null || cent === undefined || cent === '') return ''
const value = Number(cent)
if (!Number.isFinite(value)) return ''
const sign = value < 0 ? '-' : ''
const abs = Math.abs(Math.round(value))
return `${sign}${Math.floor(abs / 100)}.${pad(abs % 100)}`
}
// Cents -> yuan number (1230 -> 12.3) for numeric Excel cells; null/undefined/''/NaN stay empty, 0 stays 0.
export function centToYuanNumber(cent) {
if (cent === null || cent === undefined || cent === '') return ''
const value = Number(cent)
if (!Number.isFinite(value)) return ''
return Math.round(value) / 100
}
// yeeke only has one variation_name ("color,size"); split at the first English
// or Chinese comma. Without a comma the whole text is the color.
export function splitYeekeVariation(name) {
const text = String(name ?? '').trim()
if (!text) return { color: '', size: '' }
const index = text.search(/[,,]/)
if (index < 0) return { color: text, size: '' }
return { color: text.slice(0, index).trim(), size: text.slice(index + 1).trim() }
}
const blank = value => (value === null || value === undefined ? '' : value)
// Column indexes written as numbers; the saver shows them as 0.00.
export const PURCHASE_EXPORT_NUMBER_COLS = [8]
export const SYB_PRODUCT_EXPORT_NUMBER_COLS = [3]
export const PURCHASE_EXPORT_HEADER = ['SYB订单号', '虾皮商品ID', '颜色', '尺码', '数量', '状态', 'PDD单号', '下单时间', '价格(元)']
// statusLabel: the page's own status -> Chinese label function.
export function purchaseExportRow(task, statusLabel = value => value) {
return [
blank(task.shopeeOrderNoSnapshot), blank(task.shopeeItemIdSnapshot), blank(task.targetColorSnapshot), blank(task.targetSizeSnapshot),
blank(task.quantity), task.status ? statusLabel(task.status) : '', blank(task.pddOrderNo),
formatExportTime(task.orderSubmittedAt), centToYuanNumber(task.pddOrderAmountCent)
]
}
export const SYB_PRODUCT_EXPORT_HEADER = ['SYB订单', '店铺', '虾皮商品ID', 'SYB售价(TWD)', 'SYB颜色', 'SYB尺码', 'SYB数量', 'yeeke颜色', 'yeeke尺码', 'yeeke数量', '匹配时间']
export const isActiveReturnMatch = match => Boolean(match) && (match.status === 'matched' || match.status === 'confirmed')
// match: the active (matched/confirmed) return match of this product, or null.
export function sybProductExportRow(product, match) {
const active = isActiveReturnMatch(match) ? match : null
const yeeke = active ? splitYeekeVariation(active.variationName) : { color: '', size: '' }
return [
blank(product.orderCode), blank(product.shopName), blank(product.shopeeItemId), centToYuanNumber(product.unitPriceCent), blank(product.targetColor), blank(product.targetSize), blank(product.quantity),
yeeke.color, yeeke.size, active ? blank(active.yeekeQuantity) : '', active ? formatExportTime(active.matchedAt) : ''
]
}
export function exportFailureMessage(result, maxRows = EXPORT_MAX_ROWS) {
switch (result.reason) {
case 'empty': return '当前筛选没有数据,未生成文件'
case 'too_many': return `共 ${result.total} 条,超过 ${maxRows} 条上限,请缩小筛选范围`
case 'page_failed': return `导出失败:第 ${result.page} 页读取失败,未生成文件`
case 'enrich_failed': return '导出失败:匹配信息读取失败,未生成文件'
default: return '导出失败,未生成文件'
}
}
function chunk(items, size) {
const result = []
for (let i = 0; i < items.length; i += size) result.push(items.slice(i, i + size))
return result
}
// Reads page 1 for the total, refuses above maxRows, then reads the remaining
// pages. Any failure returns { ok: false } and nothing is saved.
// fetchPage(page, pageSize) -> { items, total }
// enrich(items) -> decorated items of the same length (optional, per batch)
// mapRow(item) -> array of cells
// save({ header, data, filename }) -> writes the file (only after success)
export async function runListExport({
fetchPage, pageSize, maxRows = EXPORT_MAX_ROWS, enrich = null, enrichBatchSize = 200,
header, mapRow, filename, save, numberFormatCols = [], onProgress = () => {}
}) {
let first
try {
first = await fetchPage(1, pageSize)
} catch (error) {
return { ok: false, reason: 'page_failed', page: 1, error }
}
const total = Number(first?.total) || 0
if (total === 0) return { ok: false, reason: 'empty', total: 0 }
if (total > maxRows) return { ok: false, reason: 'too_many', total }
const pages = Math.ceil(total / pageSize)
const enrichSteps = enrich ? Math.ceil(total / enrichBatchSize) : 0
const steps = pages + enrichSteps
let done = 1
onProgress(done, steps)
let items = [...(first.items || [])]
for (let page = 2; page <= pages; page++) {
let response
try {
response = await fetchPage(page, pageSize)
} catch (error) {
return { ok: false, reason: 'page_failed', page, error }
}
items = items.concat(response?.items || [])
onProgress(++done, steps)
}
if (enrich) {
const decorated = []
for (const part of chunk(items, enrichBatchSize)) {
try {
decorated.push(...await enrich(part))
} catch (error) {
return { ok: false, reason: 'enrich_failed', error }
}
onProgress(++done, steps)
}
items = decorated
}
if (items.length === 0) return { ok: false, reason: 'empty', total: 0 }
const data = items.map(mapRow)
await save({ header, data, filename: `${filename}_${formatExportStamp()}`, numberFormatCols })
return { ok: true, total, rows: data.length }
}
+5
View File
@@ -0,0 +1,5 @@
// Loads the xlsx writer on demand so the list pages do not pull it into their chunk.
export async function saveExcel(file) {
const { export_json_to_excel: exportJsonToExcel } = await import('@/vendor/Export2Excel')
exportJsonToExcel(file)
}
+8 -3
View File
@@ -85,7 +85,11 @@ function sheet_from_array_of_arrays(data, opts) {
r: R
});
if (typeof cell.v === 'number') cell.t = 'n';
if (typeof cell.v === 'number') {
cell.t = 'n';
// optional display format for chosen columns (#372); unset keeps the old output
if (opts && opts.numberFormatCols && opts.numberFormatCols.indexOf(C) >= 0) cell.z = '0.00';
}
else if (typeof cell.v === 'boolean') cell.t = 'b';
else if (cell.v instanceof Date) {
cell.t = 'n';
@@ -151,7 +155,8 @@ export function export_json_to_excel({
filename,
merges = [],
autoWidth = true,
bookType = 'xlsx'
bookType = 'xlsx',
numberFormatCols = []
} = {}) {
/* original data */
filename = filename || 'excel-list'
@@ -164,7 +169,7 @@ export function export_json_to_excel({
var ws_name = "SheetJS";
var wb = new Workbook(),
ws = sheet_from_array_of_arrays(data);
ws = sheet_from_array_of_arrays(data, { numberFormatCols: numberFormatCols });
if (merges.length > 0) {
if (!ws['!merges']) ws['!merges'] = [];
+28 -4
View File
@@ -16,7 +16,8 @@
<el-form-item label="状态"><el-select v-model="query.status" clearable placeholder="全部状态" style="width:160px"><el-option v-for="item in statuses" :key="item.value" :label="item.label" :value="item.value" /></el-select></el-form-item>
<el-form-item label="SYB 商品"><el-input v-model="query.sybProductId" class="syb-input" clearable placeholder="商品 ID" @keyup.enter="search" /></el-form-item>
<el-form-item label="PDD 订单号"><el-input v-model="query.pddOrderNo" class="order-input" clearable placeholder="订单号" @keyup.enter="search" /></el-form-item>
<el-form-item><el-button type="primary" :icon="Search" :disabled="Boolean(orderFilter.error)" @click="search">查询</el-button><el-button :icon="RefreshLeft" @click="reset">重置</el-button></el-form-item>
<el-form-item label="下单日期"><el-date-picker v-model="query.orderSubmittedRange" type="daterange" value-format="YYYY-MM-DD" range-separator="至" start-placeholder="开始日期" end-placeholder="结束日期" clearable style="width:260px" /></el-form-item>
<el-form-item><el-button type="primary" :icon="Search" :disabled="Boolean(orderFilter.error)" @click="search">查询</el-button><el-button :icon="RefreshLeft" @click="reset">重置</el-button><el-button :disabled="exporting || Boolean(orderFilter.error)" @click="exportExcel">{{ exporting ? `导出中 ${exportProgress.done}/${exportProgress.steps}` : '导出' }}</el-button></el-form-item>
</el-form>
<div class="batch-actions"><span class="selected-copy">已选择 <strong>{{ retrySelection.length }}</strong> 条</span><el-button type="warning" :disabled="retryCandidates.length === 0" :loading="retryDialog.saving" @click="openRetryDialog">重试({{ retryCandidates.length }})</el-button><el-button type="primary" plain :disabled="orderWritebackCandidates.length === 0" :loading="orderWriteback.saving" @click="submitOrderWriteback(orderWritebackCandidates)">批量回填 SYB({{ orderWritebackCandidates.length }})</el-button></div>
@@ -154,6 +155,8 @@ import { ElMessage, ElMessageBox } from 'element-plus'
import { RefreshLeft, Search } from '@element-plus/icons-vue'
import { authorizeRepurchase, cancelPurchaseTask, choosePurchaseMatching, getPurchaseTask, listPurchaseTasks, requeuePurchaseMatching, resolveUnknownPurchaseTask, retryPurchaseTasksBatch, reviewPurchasePayment, selectPurchaseWriteback, writebackPurchaseOrderNumbers } from '@/api/goauto/purchase-tasks'
import { createRequestId } from '@/utils/request-id'
import { saveExcel } from '@/utils/save-excel'
import { EXPORT_MAX_ROWS, PURCHASE_EXPORT_HEADER, PURCHASE_EXPORT_NUMBER_COLS, exportFailureMessage, purchaseExportRow, runListExport } from '@/utils/list-export'
import { listFailureSnapshots } from '@/api/goauto/purchase-failure-snapshots'
import FailureSnapshotCell from './FailureSnapshotCell.vue'
@@ -169,9 +172,10 @@ export default {
data() {
return {
loading: false, loadError: '', tasks: [], total: 0, statuses: [...statusOptions, { value: 'syb_writeback_succeeded', label: '已回填 SYB' }],
exporting: false, exportProgress: { done: 0, steps: 0 },
detailSession: 0, snapshots: { items: {}, loading: false, error: false },
orderWriteback: { open: false, saving: false, items: [] },
query: { page: 1, pageSize: 20, taskId: '', taskType: '', shopeeOrderNo: '', status: '', executionMode: 'live', sybProductId: '', pddOrderNo: '' },
query: { page: 1, pageSize: 20, taskId: '', taskType: '', shopeeOrderNo: '', status: '', executionMode: 'live', sybProductId: '', pddOrderNo: '', orderSubmittedRange: [] },
detail: { open: false, loading: false, task: null, attempts: [], matching: null },
matchingAction: { saving: false }, matchingDialog: { open: false, saving: false, color: '', size: '' },
retrySelection: [],
@@ -227,14 +231,34 @@ export default {
try { const r = await listPurchaseTasks(this.cleanQuery()); this.tasks = r.data.items || []; this.total = r.data.total || 0; this.retrySelection = []; this.$nextTick(() => this.$refs.taskTable?.clearSelection()) } catch (error) { this.tasks = []; this.total = 0; this.loadError = error?.message || '采购任务加载失败,请稍后重试' } finally { this.loading = false }
},
cleanQuery() {
const params = Object.fromEntries(Object.entries(this.query).filter(([key, value]) => key !== 'shopeeOrderNo' && value !== ''))
const params = Object.fromEntries(Object.entries(this.query).filter(([key, value]) => key !== 'shopeeOrderNo' && key !== 'orderSubmittedRange' && value !== ''))
const [orderSubmittedFrom, orderSubmittedTo] = this.query.orderSubmittedRange || []
if (orderSubmittedFrom) params.orderSubmittedFrom = orderSubmittedFrom
if (orderSubmittedTo) params.orderSubmittedTo = orderSubmittedTo
const values = this.orderFilter.values
if (values.length === 1) params.shopeeOrderNo = values[0]
else if (values.length > 1) params.shopeeOrderNos = values
return params
},
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: '', orderSubmittedRange: [] }; this.load() },
// #372: export every page of the filters as they are at click time (params are copied once).
async exportExcel() {
if (this.exporting || this.orderFilter.error) return
const filters = this.cleanQuery(); delete filters.page; delete filters.pageSize
this.exporting = true; this.exportProgress = { done: 0, steps: 0 }
try {
const result = await runListExport({
pageSize: 100, maxRows: EXPORT_MAX_ROWS, header: PURCHASE_EXPORT_HEADER, numberFormatCols: PURCHASE_EXPORT_NUMBER_COLS, filename: '采购导出',
fetchPage: async(pageNo, size) => { const r = await listPurchaseTasks({ ...filters, page: pageNo, pageSize: size }); return { items: r.data.items || [], total: r.data.total || 0 } },
mapRow: task => purchaseExportRow(task, this.statusLabel),
save: saveExcel,
onProgress: (done, steps) => { this.exportProgress = { done, steps } }
})
if (!result.ok) ElMessage.warning(exportFailureMessage(result, EXPORT_MAX_ROWS))
else ElMessage.success(`已导出 ${result.rows} 条;导出期间数据变化可能有少量差异`)
} catch (error) { ElMessage.error('导出失败,未生成文件') } finally { this.exporting = false }
},
clearSnapshots() { this.detailSession++; this.snapshots = { items: {}, loading: false, error: false } },
async loadSnapshots() {
if (!this.isSnapshotAdmin || !this.detail.open || !this.detail.task || this.snapshots.loading) return
+33 -1
View File
@@ -26,7 +26,7 @@
<el-form-item label="解析状态"><el-select v-model="query.parseStatus" clearable placeholder="全部" style="width: 110px"><el-option label="成功" value="success" /><el-option label="失败" value="failed" /></el-select></el-form-item>
<el-form-item label="处理阶段"><el-select v-model="query.processStage" clearable placeholder="全部" style="width: 132px" @change="onProcessStageChange"><el-option v-for="item in processStageOptions" :key="item.value" :label="item.label" :value="item.value" /></el-select></el-form-item>
<el-form-item label="采购类型"><el-select v-model="query.purchaseType" style="width: 132px"><el-option label="需 PDD 采购" value="pdd" /><el-option label="无需 PDD 采购" value="excluded" /><el-option label="全部" value="all" /></el-select></el-form-item>
<el-form-item class="toolbar-actions"><el-button type="primary" :icon="Search" @click="search">查询</el-button><el-button :icon="RefreshLeft" @click="reset">重置</el-button><el-tooltip v-if="canPurchase" :content="aiMatchButtonReason" :disabled="!aiMatchButtonReason" placement="top"><span class="action-button-wrap"><el-button :loading="specMatchLoading" :disabled="purchaseReadinessLoading || specMatchLoading || aiMatchCandidates.length === 0 || aiMatchButtonOverLimit" @click="runBatchSpecMatch">AI 匹配<span class="action-count">{{ aiMatchCandidates.length }}</span></el-button></span></el-tooltip><el-tooltip v-if="canPurchase" :content="imageSearchButtonReason" :disabled="!imageSearchButtonReason" placement="top"><span class="action-button-wrap"><el-button type="success" plain :disabled="imageSearchRows.length === 0 || imageSearchButtonOverLimit" @click="openImageSearchBatch">图搜采集<span class="action-count">{{ imageSearchRows.length }}</span></el-button></span></el-tooltip><el-tooltip v-if="canPurchase" :content="collectionButtonReason" :disabled="!collectionButtonReason" placement="top"><span class="action-button-wrap"><el-button type="primary" :loading="purchaseReadinessLoading" :disabled="purchaseReadinessLoading || specMatchLoading || collectionCandidates.length === 0 || collectionButtonOverLimit" @click="openCollectionBatch">创建采集<span class="action-count action-count-primary">{{ collectionCandidates.length }}</span></el-button></span></el-tooltip><el-tooltip v-if="canPurchase" :content="purchaseButtonReason" :disabled="!purchaseButtonReason" placement="top"><span class="action-button-wrap"><el-button type="primary" :loading="purchaseReadinessLoading" :disabled="purchaseReadinessLoading || specMatchLoading || purchaseCandidates.length === 0 || purchaseButtonOverLimit" @click="openPurchaseBatch">创建采购<span class="action-count action-count-primary">{{ purchaseCandidates.length }}</span></el-button></span></el-tooltip><el-tooltip v-if="canPurchase" :content="returnMatchButtonReason" :disabled="!returnMatchButtonReason" placement="top"><span class="action-button-wrap"><el-button :loading="returnMatchBatchLoading" :disabled="purchaseReadinessLoading || returnMatchBatchLoading || returnConfirmBatchLoading || returnCancelBatchLoading || returnTransitionLoading || matchDetail.saving || returnMatchCandidateIds.length === 0 || returnMatchButtonOverLimit" @click="runBatchMatchReturns">匹配退货<span class="action-count">{{ returnMatchCandidateIds.length }}</span></el-button></span></el-tooltip><el-tooltip v-if="canPurchase" :content="returnCancelButtonReason" :disabled="!returnCancelButtonReason" placement="top"><span class="action-button-wrap"><el-button :loading="returnCancelBatchLoading" :disabled="Boolean(returnCancelButtonReason)" @click="runBatchCancelReturns">取消匹配<span class="action-count">{{ returnCancelCandidates.length }}</span></el-button></span></el-tooltip><el-tooltip v-if="canPurchase" :content="returnConfirmButtonReason" :disabled="!returnConfirmButtonReason" placement="top"><span class="action-button-wrap"><el-button type="success" plain :loading="returnConfirmBatchLoading" :disabled="Boolean(returnConfirmButtonReason)" @click="runBatchConfirmReturns">使用退货<span class="action-count">{{ returnConfirmCandidates.length }}</span></el-button></span></el-tooltip></el-form-item>
<el-form-item class="toolbar-actions"><el-button type="primary" :icon="Search" @click="search">查询</el-button><el-button :icon="RefreshLeft" @click="reset">重置</el-button><el-button :disabled="exporting" @click="exportExcel">{{ exporting ? `导出中 ${exportProgress.done}/${exportProgress.steps}` : '导出' }}</el-button><el-tooltip v-if="canPurchase" :content="aiMatchButtonReason" :disabled="!aiMatchButtonReason" placement="top"><span class="action-button-wrap"><el-button :loading="specMatchLoading" :disabled="purchaseReadinessLoading || specMatchLoading || aiMatchCandidates.length === 0 || aiMatchButtonOverLimit" @click="runBatchSpecMatch">AI 匹配<span class="action-count">{{ aiMatchCandidates.length }}</span></el-button></span></el-tooltip><el-tooltip v-if="canPurchase" :content="imageSearchButtonReason" :disabled="!imageSearchButtonReason" placement="top"><span class="action-button-wrap"><el-button type="success" plain :disabled="imageSearchRows.length === 0 || imageSearchButtonOverLimit" @click="openImageSearchBatch">图搜采集<span class="action-count">{{ imageSearchRows.length }}</span></el-button></span></el-tooltip><el-tooltip v-if="canPurchase" :content="collectionButtonReason" :disabled="!collectionButtonReason" placement="top"><span class="action-button-wrap"><el-button type="primary" :loading="purchaseReadinessLoading" :disabled="purchaseReadinessLoading || specMatchLoading || collectionCandidates.length === 0 || collectionButtonOverLimit" @click="openCollectionBatch">创建采集<span class="action-count action-count-primary">{{ collectionCandidates.length }}</span></el-button></span></el-tooltip><el-tooltip v-if="canPurchase" :content="purchaseButtonReason" :disabled="!purchaseButtonReason" placement="top"><span class="action-button-wrap"><el-button type="primary" :loading="purchaseReadinessLoading" :disabled="purchaseReadinessLoading || specMatchLoading || purchaseCandidates.length === 0 || purchaseButtonOverLimit" @click="openPurchaseBatch">创建采购<span class="action-count action-count-primary">{{ purchaseCandidates.length }}</span></el-button></span></el-tooltip><el-tooltip v-if="canPurchase" :content="returnMatchButtonReason" :disabled="!returnMatchButtonReason" placement="top"><span class="action-button-wrap"><el-button :loading="returnMatchBatchLoading" :disabled="purchaseReadinessLoading || returnMatchBatchLoading || returnConfirmBatchLoading || returnCancelBatchLoading || returnTransitionLoading || matchDetail.saving || returnMatchCandidateIds.length === 0 || returnMatchButtonOverLimit" @click="runBatchMatchReturns">匹配退货<span class="action-count">{{ returnMatchCandidateIds.length }}</span></el-button></span></el-tooltip><el-tooltip v-if="canPurchase" :content="returnCancelButtonReason" :disabled="!returnCancelButtonReason" placement="top"><span class="action-button-wrap"><el-button :loading="returnCancelBatchLoading" :disabled="Boolean(returnCancelButtonReason)" @click="runBatchCancelReturns">取消匹配<span class="action-count">{{ returnCancelCandidates.length }}</span></el-button></span></el-tooltip><el-tooltip v-if="canPurchase" :content="returnConfirmButtonReason" :disabled="!returnConfirmButtonReason" placement="top"><span class="action-button-wrap"><el-button type="success" plain :loading="returnConfirmBatchLoading" :disabled="Boolean(returnConfirmButtonReason)" @click="runBatchConfirmReturns">使用退货<span class="action-count">{{ returnConfirmCandidates.length }}</span></el-button></span></el-tooltip></el-form-item>
</div>
<div class="search-row search-row-secondary">
<el-form-item label="创建时间"><el-date-picker v-model="query.createdAtRange" type="daterange" value-format="YYYY-MM-DD" range-separator="至" start-placeholder="开始日期" end-placeholder="结束日期" clearable /></el-form-item>
@@ -395,6 +395,8 @@ import { createPurchaseTasksBatch, matchPurchaseSpecsBatch, previewPurchaseTasks
import { batchCreateCollectionTasks, batchCreateImageSearchCollectionTasks } from '@/api/goauto/collection-tasks'
import { listCollectionRules } from '@/api/goauto/collection-rules'
import { listReturnMatches, batchMatchReturns, getReturnMatch, confirmReturnMatch, cancelReturnMatch, remarkReturnMatch } from '@/api/goauto/return-matches'
import { saveExcel } from '@/utils/save-excel'
import { EXPORT_MAX_ROWS, SYB_PRODUCT_EXPORT_HEADER, SYB_PRODUCT_EXPORT_NUMBER_COLS, exportFailureMessage, isActiveReturnMatch, runListExport, sybProductExportRow } from '@/utils/list-export'
import { createRequestId } from '@/utils/request-id'
import { readPurchaseDevice, rememberPurchaseDevice } from '@/utils/purchase-device-preference'
import ShopeeProductDetailDrawer from '../shopee-products/ShopeeProductDetailDrawer.vue'
@@ -430,6 +432,8 @@ export default {
collectionBatchData: { ruleId: null, deviceId: null },
collectionBatchRules: { ruleId: [{ required: true, message: '请选择采集规则', trigger: 'change' }] },
query: { page: 1, pageSize: 200, shopIds: [], orderCodesText: '', parseStatus: '', processStage: '', purchaseType: 'pdd', createdAtRange: [] },
exporting: false,
exportProgress: { done: 0, steps: 0 },
returnMatchByProductId: {},
returnMatchLoading: false,
returnMatchBatchLoading: false,
@@ -529,6 +533,34 @@ export default {
}
},
search() { this.query.page = 1; this.load() },
// #372: export every page of the filters as they are at click time (params are copied once);
// return matches are read per batch of product ids, never per row.
async exportExcel() {
if (this.exporting) return
const orderCodes = this.normalizedOrderCodes()
if (orderCodes === null) return
const [createdFrom = '', createdTo = ''] = this.query.createdAtRange || []
const filters = { shopIds: [...this.query.shopIds], orderCodes: orderCodes.join(','), parseStatus: this.query.parseStatus, processStage: this.query.processStage, purchaseType: this.query.purchaseType, createdFrom, createdTo }
this.exporting = true; this.exportProgress = { done: 0, steps: 0 }
try {
const result = await runListExport({
pageSize: 500, maxRows: EXPORT_MAX_ROWS, header: SYB_PRODUCT_EXPORT_HEADER, numberFormatCols: SYB_PRODUCT_EXPORT_NUMBER_COLS, filename: 'SYB商品导出',
fetchPage: async(page, size) => { const r = await listSybProducts({ ...filters, page, pageSize: size }, { suppressErrorMessage: true }); return { items: r.data.items || [], total: r.data.total || 0 } },
enrichBatchSize: 200,
enrich: async items => {
const r = await listReturnMatches({ sybProductId: items.map(item => item.id) }, { suppressErrorMessage: true })
const byProduct = {}
for (const match of (r.data?.items || [])) if (isActiveReturnMatch(match) && !byProduct[match.sybProductId]) byProduct[match.sybProductId] = match
return items.map(item => ({ item, match: byProduct[item.id] || null }))
},
mapRow: ({ item, match }) => sybProductExportRow(item, match),
save: saveExcel,
onProgress: (done, steps) => { this.exportProgress = { done, steps } }
})
if (!result.ok) ElMessage.warning(exportFailureMessage(result, EXPORT_MAX_ROWS))
else ElMessage.success(`已导出 ${result.rows} 条;导出期间数据变化可能有少量差异`)
} catch (error) { ElMessage.error('导出失败,未生成文件') } finally { this.exporting = false }
},
reset() { this.query = { page: 1, pageSize: 200, shopIds: [], orderCodesText: '', parseStatus: '', processStage: '', purchaseType: 'pdd', createdAtRange: [] }; this.load() },
onPagination() {
// Both controls may emit after a size change clamps the old page. Coalesce
+187
View File
@@ -0,0 +1,187 @@
const { test } = require('node:test')
const assert = require('node:assert/strict')
const fs = require('node:fs')
const path = require('node:path')
const source = fs.readFileSync(path.join(__dirname, '../../src/utils/list-export.js'), 'utf8')
const lib = new Function(source.replace(/^export /gm, '') + `
return { EXPORT_MAX_ROWS, formatExportTime, formatExportStamp, centToYuan, splitYeekeVariation, purchaseExportRow, PURCHASE_EXPORT_HEADER,
sybProductExportRow, SYB_PRODUCT_EXPORT_HEADER, centToYuanNumber, PURCHASE_EXPORT_NUMBER_COLS, SYB_PRODUCT_EXPORT_NUMBER_COLS, exportFailureMessage, runListExport }`)()
test('time and stamp use local time, empty and invalid stay empty', () => {
const date = new Date(2026, 9, 9, 8, 5, 3)
assert.equal(lib.formatExportTime(date.toISOString()), '2026-10-09 08:05:03')
assert.equal(lib.formatExportTime(null), '')
assert.equal(lib.formatExportTime(''), '')
assert.equal(lib.formatExportTime('not a date'), '')
assert.equal(lib.formatExportStamp(date), '20261009-080503')
})
test('cent to yuan keeps two decimals and leaves empty values empty', () => {
assert.equal(lib.centToYuan(12345), '123.45')
assert.equal(lib.centToYuan(5), '0.05')
assert.equal(lib.centToYuan(1000), '10.00')
assert.equal(lib.centToYuan(0), '0.00')
for (const value of [null, undefined, '']) assert.equal(lib.centToYuan(value), '')
})
test('yeeke variation splits at the first English or Chinese comma, none puts all in color', () => {
assert.deepEqual(lib.splitYeekeVariation('白色,L【建議65-75公斤】'), { color: '白色', size: 'L【建議65-75公斤】' })
assert.deepEqual(lib.splitYeekeVariation('黑色+白色,簡約,L'), { color: '黑色+白色', size: '簡約,L' })
assert.deepEqual(lib.splitYeekeVariation('均碼'), { color: '均碼', size: '' })
assert.deepEqual(lib.splitYeekeVariation(''), { color: '', size: '' })
assert.deepEqual(lib.splitYeekeVariation(undefined), { color: '', size: '' })
})
test('purchase row maps columns in order with status label, amount and blanks', () => {
assert.equal(lib.PURCHASE_EXPORT_HEADER.length, 9)
const row = lib.purchaseExportRow({
shopeeOrderNoSnapshot: '001', shopeeItemIdSnapshot: '5020', targetColorSnapshot: '白色', targetSizeSnapshot: 'L', quantity: 2,
status: 'order_created', pddOrderNo: 'PDD-1', orderSubmittedAt: new Date(2026, 9, 9, 1, 2, 3).toISOString(), pddOrderAmountCent: 3990,
receiverName: 'must not leak', address: 'must not leak'
}, value => ({ order_created: '订单已创建' })[value])
assert.deepEqual(row, ['001', '5020', '白色', 'L', 2, '订单已创建', 'PDD-1', '2026-10-09 01:02:03', 39.9])
assert.deepEqual(lib.purchaseExportRow({ quantity: 1 }), ['', '', '', '', 1, '', '', '', ''])
assert.equal(lib.purchaseExportRow({ shopeeOrderNoSnapshot: 'A', status: 'failed', pddOrderAmountCent: null })[8], '')
assert.equal(lib.purchaseExportRow({ pddOrderAmountCent: undefined })[8], '')
assert.equal(typeof row[8], 'number')
assert.equal(lib.purchaseExportRow({ pddOrderAmountCent: 0 })[8], 0)
assert.deepEqual(lib.PURCHASE_EXPORT_NUMBER_COLS, [8])
assert.equal(lib.PURCHASE_EXPORT_HEADER[8], '价格(元)')
})
test('cent to yuan number is numeric, zero stays zero, empty stays empty', () => {
assert.equal(lib.centToYuanNumber(1230), 12.3)
assert.equal(lib.centToYuanNumber(0), 0)
assert.equal(lib.centToYuanNumber('1230'), 12.3)
for (const value of [null, undefined, '', 'abc']) assert.equal(lib.centToYuanNumber(value), '')
})
test('syb product row uses only an active match and ignores cancelled ones', () => {
const product = { orderCode: 'O1', shopName: 'shop-a', shopeeItemId: '99', unitPriceCent: 1230, targetColor: '白色', targetSize: 'L', quantity: 1 }
const matchedAt = new Date(2026, 9, 9, 9, 0, 0).toISOString()
assert.deepEqual(lib.SYB_PRODUCT_EXPORT_HEADER, ['SYB订单', '店铺', '虾皮商品ID', 'SYB售价(TWD)', 'SYB颜色', 'SYB尺码', 'SYB数量', 'yeeke颜色', 'yeeke尺码', 'yeeke数量', '匹配时间'])
assert.deepEqual(lib.SYB_PRODUCT_EXPORT_NUMBER_COLS, [3])
assert.deepEqual(lib.sybProductExportRow(product, { status: 'confirmed', variationName: '白色,L', yeekeQuantity: 3, matchedAt }),
['O1', 'shop-a', '99', 12.3, '白色', 'L', 1, '白色', 'L', 3, '2026-10-09 09:00:00'])
assert.deepEqual(lib.sybProductExportRow(product, { status: 'matched', variationName: '均碼', yeekeQuantity: 1, matchedAt }).slice(7, 10), ['均碼', '', 1])
assert.deepEqual(lib.sybProductExportRow(product, { status: 'cancelled', variationName: 'x', yeekeQuantity: 1, matchedAt }), ['O1', 'shop-a', '99', 12.3, '白色', 'L', 1, '', '', '', ''])
assert.deepEqual(lib.sybProductExportRow(product, null), ['O1', 'shop-a', '99', 12.3, '白色', 'L', 1, '', '', '', ''])
assert.equal(lib.sybProductExportRow({ ...product, unitPriceCent: 0 }, null)[3], 0)
for (const value of [null, undefined]) assert.equal(lib.sybProductExportRow({ ...product, unitPriceCent: value }, null)[3], '')
for (const value of [null, undefined]) assert.equal(lib.sybProductExportRow({ ...product, shopName: value }, null)[1], '')
})
// Runs the real Export2Excel writer with file-saver stubbed and reads the xlsx back.
function writeWorkbook(options) {
const XLSX = require('xlsx')
const src = fs.readFileSync(path.join(__dirname, '../../src/vendor/Export2Excel.js'), 'utf8')
.replace(/^import .*$/gm, '').replace(/^export function/gm, 'function')
let blobParts
class FakeBlob { constructor(parts) { blobParts = parts } }
new Function('XLSX', 'saveAs', 'Blob', 'opts', src + '\nexport_json_to_excel(opts)')(XLSX, () => {}, FakeBlob, options)
return XLSX.read(blobParts[0], { type: 'array', cellNF: true }).Sheets.SheetJS
}
test('excel writer makes numeric cells, 0.00 format only for requested columns', () => {
const header = ['a', 'b']
const data = [[12.3, 12.3], [0, 0], ['', '']]
const withFmt = writeWorkbook({ header, data, filename: 't', numberFormatCols: [0] })
assert.equal(withFmt.A2.t, 'n'); assert.equal(withFmt.A2.v, 12.3); assert.equal(withFmt.A2.z, '0.00')
assert.equal(withFmt.A3.t, 'n'); assert.equal(withFmt.A3.v, 0)
assert.equal(withFmt.B2.t, 'n'); assert.notEqual(withFmt.B2.z, '0.00')
const plain = writeWorkbook({ header, data, filename: 't' })
assert.equal(plain.A2.t, 'n'); assert.notEqual(plain.A2.z, '0.00')
})
test('real export row types and format end up in the sheet', () => {
const header = lib.SYB_PRODUCT_EXPORT_HEADER
const rows = [
lib.sybProductExportRow({ orderCode: 'O1', shopName: 'shop-a', unitPriceCent: 1230 }, null),
lib.sybProductExportRow({ orderCode: 'O2', unitPriceCent: 0 }, null),
lib.sybProductExportRow({ orderCode: 'O3', unitPriceCent: null }, null)
]
const sheet = writeWorkbook({ header, data: rows, filename: 't', numberFormatCols: lib.SYB_PRODUCT_EXPORT_NUMBER_COLS })
assert.equal(sheet.B1.v, '店铺')
assert.equal(sheet.B2.t, 's'); assert.equal(sheet.B2.v, 'shop-a'); assert.notEqual(sheet.B2.z, '0.00')
assert.equal(sheet.D1.v, 'SYB售价(TWD)')
assert.equal(sheet.D2.t, 'n'); assert.equal(sheet.D2.v, 12.3); assert.equal(sheet.D2.z, '0.00')
assert.equal(sheet.D3.t, 'n'); assert.equal(sheet.D3.v, 0)
assert.equal(sheet.D4.v, ''); assert.notEqual(sheet.D4.t, 'n')
})
function harness(total, options = {}) {
const calls = []; const saved = []; const progress = []
const pageSize = options.pageSize || 100
const run = () => lib.runListExport({
pageSize, header: ['id'], filename: '测试', mapRow: item => [item.id],
fetchPage: async (page, size) => {
calls.push(page)
if (options.failPage === page) throw new Error('boom')
const start = (page - 1) * size
return { total, items: Array.from({ length: Math.max(0, Math.min(size, total - start)) }, (_, i) => ({ id: start + i + 1 })) }
},
save: async file => { saved.push(file) }, onProgress: (done, steps) => progress.push([done, steps]), ...options.extra
})
return { run, calls, saved, progress }
}
test('reads every page, reports progress, saves once with a timestamped name', async () => {
const h = harness(250)
const result = await h.run()
assert.deepEqual(result, { ok: true, total: 250, rows: 250 })
assert.deepEqual(h.calls, [1, 2, 3])
assert.deepEqual(h.progress, [[1, 3], [2, 3], [3, 3]])
assert.equal(h.saved.length, 1)
assert.equal(h.saved[0].data.length, 250)
assert.deepEqual(h.saved[0].data[249], [250])
assert.match(h.saved[0].filename, /^测试_\d{8}-\d{6}$/)
})
test('exactly 5000 rows are allowed, 5001 is refused after reading only page 1', async () => {
const ok = harness(5000, { pageSize: 500 })
assert.equal((await ok.run()).ok, true)
assert.equal(ok.calls.length, 10)
const over = harness(5001, { pageSize: 500 })
const result = await over.run()
assert.deepEqual(result, { ok: false, reason: 'too_many', total: 5001 })
assert.deepEqual(over.calls, [1])
assert.equal(over.saved.length, 0)
assert.equal(lib.exportFailureMessage(result), '共 5001 条,超过 5000 条上限,请缩小筛选范围')
})
test('empty result does not save', async () => {
const h = harness(0)
const result = await h.run()
assert.deepEqual(result, { ok: false, reason: 'empty', total: 0 })
assert.equal(h.saved.length, 0)
assert.match(lib.exportFailureMessage(result), /没有数据/)
})
test('a failing page stops the export without saving', async () => {
const h = harness(450, { failPage: 3 })
const result = await h.run()
assert.equal(result.ok, false)
assert.equal(result.reason, 'page_failed')
assert.equal(result.page, 3)
assert.deepEqual(h.calls, [1, 2, 3])
assert.equal(h.saved.length, 0)
assert.equal(lib.exportFailureMessage(result), '导出失败:第 3 页读取失败,未生成文件')
const first = harness(10, { failPage: 1 })
assert.equal((await first.run()).page, 1)
assert.equal(first.saved.length, 0)
})
test('enrich runs per batch (not per row), counts in progress, and a failure saves nothing', async () => {
const batches = []
const h = harness(450, { pageSize: 200, extra: { enrichBatchSize: 200, enrich: async items => { batches.push(items.length); return items.map(item => ({ ...item, id: item.id * 10 })) } } })
const result = await h.run()
assert.equal(result.ok, true)
assert.deepEqual(batches, [200, 200, 50])
assert.deepEqual(h.saved[0].data[0], [10])
assert.deepEqual(h.progress[h.progress.length - 1], [6, 6])
const bad = harness(450, { pageSize: 200, extra: { enrich: async () => { throw new Error('boom') } } })
const failed = await bad.run()
assert.equal(failed.reason, 'enrich_failed')
assert.equal(bad.saved.length, 0)
})