Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6107c235cf | ||
|
|
19484f0a68 | ||
|
|
3495de96a2 | ||
|
|
ac112b59b5 | ||
|
|
15bab9c989 | ||
|
|
0893b55efc | ||
|
|
c5709a0f3a | ||
|
|
0745b2d894 | ||
|
|
9e54263ee6 | ||
|
|
ac7f81141c | ||
|
|
6cc3bb98d0 | ||
|
|
cc4a55cf60 | ||
|
|
7897d9925b | ||
|
|
16e3d35b8f | ||
|
|
82ed6664b7 | ||
|
|
f334c1dcb0 | ||
|
|
4039fdae2b | ||
|
|
26562fc763 | ||
|
|
d3d3837014 | ||
|
|
a0d2416c49 | ||
|
|
ff8c5361cb | ||
|
|
d45a9b9a24 | ||
|
|
b86f858cb7 | ||
|
|
e966d0f702 | ||
|
|
2ae3e8115f | ||
|
|
9619c38ae3 | ||
|
|
037459391a | ||
|
|
74b71e2b5a | ||
|
|
2f24b4b5b4 | ||
|
|
d8b4f3a9d5 | ||
|
|
23dafcf73f | ||
|
|
3d5e9646f6 | ||
|
|
e351a69912 | ||
|
|
db64a49e69 | ||
|
|
b79e281b97 | ||
|
|
dbe7e98365 | ||
|
|
9f489fdf62 | ||
|
|
a28b869701 | ||
|
|
c18bf78e0e | ||
|
|
f5edd889f7 | ||
|
|
d84728dde9 | ||
|
|
575d5f34cc | ||
|
|
4ad59bd231 | ||
|
|
d528c74c57 | ||
|
|
d8d9ab9edd | ||
|
|
010e9b90ae | ||
|
|
919744ec05 | ||
|
|
72a15f8d9f | ||
|
|
b311885ea8 | ||
|
|
a4a793f449 | ||
|
|
4a669bca86 | ||
|
|
309dce3627 | ||
|
|
8b75a95d67 | ||
|
|
3ee8dfc0aa | ||
|
|
555a954605 | ||
|
|
1e924fa411 | ||
|
|
92aeab8d04 | ||
|
|
c9b7a2dc0d | ||
|
|
dbf9a5f460 | ||
|
|
8f07a19512 | ||
|
|
2c58d5817c |
@@ -11,8 +11,8 @@ android {
|
||||
applicationId = "cn.ilapage.goauto.agent"
|
||||
minSdk = 23
|
||||
targetSdk = 34
|
||||
versionCode = 82
|
||||
versionName = "0.9.69"
|
||||
versionCode = 83
|
||||
versionName = "0.9.70"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
|
||||
@@ -30,9 +30,6 @@ android {
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
debug {
|
||||
versionNameSuffix = "-370-diag1"
|
||||
}
|
||||
release {
|
||||
isMinifyEnabled = false
|
||||
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
|
||||
|
||||
@@ -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>
|
||||
|
||||
+9
@@ -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")
|
||||
}
|
||||
}
|
||||
+109
@@ -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" }
|
||||
}
|
||||
+12
-1
@@ -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 =
|
||||
|
||||
+355
-59
@@ -69,6 +69,9 @@ interface PddCollectorDriver {
|
||||
reason = FreshClickReason.UNKNOWN,
|
||||
)
|
||||
fun swipeSpec(direction: SwipeDirection, anchor: SnapshotNode? = null): Boolean
|
||||
// Why the latest swipeSpec returned false (anchorMissing / anchorAmbiguous
|
||||
// mean the gesture anchor no longer resolves to exactly one node).
|
||||
fun specRowSwipeFailureReason(): String = "unknown"
|
||||
fun pullDownSpecPanel(anchor: SnapshotNode): Boolean = swipeSpec(SwipeDirection.DOWN, anchor)
|
||||
fun pullDownGoodsPage(): Boolean
|
||||
fun back(): Boolean
|
||||
@@ -169,6 +172,8 @@ data class ParsedPddScreen(
|
||||
val requiredEvidenceSameContainer: Boolean = false,
|
||||
/** Only populated by the final-confirmation opt-in; existing selectedSummary behavior is unchanged. */
|
||||
val finalSelectionSummaries: List<String> = emptyList(),
|
||||
/** #379: option cards whose truncated caption (price + badge) was merged into the outer card; count only. */
|
||||
val badgeCaptionMerged: Int = 0,
|
||||
) {
|
||||
/** The three #331 required spec-panel items are all visible. */
|
||||
val hasRequiredPanelEvidence: Boolean get() = hasAddressEntry && hasPaymentEntry && hasQuantityInput
|
||||
@@ -245,6 +250,8 @@ object PddScreenParser {
|
||||
private const val PDD_PACKAGE = "com.xunmeng.pinduoduo"
|
||||
private data class SafeSpecEntry(val anchor: SnapshotNode, val clickTarget: SnapshotNode)
|
||||
private val pricePattern = Regex("[¥¥]\\s*([0-9]+(?:\\.[0-9]{1,2})?)")
|
||||
private val badgeCaptionSuffix =
|
||||
Regex("[\\s\\u00a0\\u3000]*[¥¥][\\s\\u00a0\\u3000]*(\\d+(?:\\.\\d{1,2})?)[\\s\\u00a0\\u3000]+[^¥¥\\d\\s\\u00a0\\u3000]{1,8}$")
|
||||
private val salesPattern = Regex("已拼\\s*[0-9]+(?:\\.[0-9]+)?\\s*(?:万|亿)?\\s*\\+?\\s*(?:件|人)?")
|
||||
private val reviewPattern = Regex("(?:商品评价\\s*[((]?\\s*[0-9]+(?:\\.[0-9]+)?\\s*(?:万|亿)?|[0-9]+(?:\\.[0-9]+)?\\s*(?:万|亿)?\\s*\\+?\\s*条?评价|评价\\s*[0-9]+)")
|
||||
private val excludedOptionWords = listOf(
|
||||
@@ -371,6 +378,7 @@ object PddScreenParser {
|
||||
visible.filter { it.path == region.path || it.path.startsWith("${region.path}/") }
|
||||
} ?: visible
|
||||
val headings = panelVisible.filter { !it.clickable && isHeading(it.label, config) }.sortedBy { it.bounds.top }
|
||||
val badgeMergedBlocks = mutableSetOf<String>()
|
||||
val dimensions = buildList<VisibleDimension> {
|
||||
headings.forEachIndexed { index, heading ->
|
||||
val lower = headings.getOrNull(index + 1)?.bounds?.top ?: Int.MAX_VALUE
|
||||
@@ -394,7 +402,7 @@ object PddScreenParser {
|
||||
// node is still chosen by safeOptionRank among the members.
|
||||
val values = candidates
|
||||
.groupBy { candidate ->
|
||||
optionBlock(candidate, candidates, visibleByPath, sourceByPath, ::inValueRegion)?.path ?: candidate.path
|
||||
optionBlock(candidate, candidates, visibleByPath, sourceByPath, ::inValueRegion) { badgeMergedBlocks += it }?.path ?: candidate.path
|
||||
}
|
||||
.map { (blockPath, members) ->
|
||||
val block = visibleByPath[blockPath]
|
||||
@@ -593,6 +601,7 @@ object PddScreenParser {
|
||||
problem = problem,
|
||||
sourceNodes = visibleNodes,
|
||||
isPddPackage = snapshot.packageName == PDD_PACKAGE,
|
||||
badgeCaptionMerged = badgeMergedBlocks.size,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -612,6 +621,7 @@ object PddScreenParser {
|
||||
visibleByPath: Map<String, SnapshotNode>,
|
||||
sourceByPath: Map<String, SnapshotNode>,
|
||||
inValueRegion: (SnapshotNode) -> Boolean,
|
||||
onBadgeMerge: (String) -> Unit,
|
||||
): SnapshotNode? {
|
||||
fun compact(value: String) = value.replace(Regex("\\s+"), "")
|
||||
var block: SnapshotNode? = null
|
||||
@@ -626,7 +636,7 @@ object PddScreenParser {
|
||||
val outer = compact(resolved.label)
|
||||
val members = candidates.filter { it.path == resolved.path || it.path.startsWith("${resolved.path}/") }
|
||||
val existingBlock = outer.isNotEmpty() && members.all { compact(it.label).startsWith(outer) }
|
||||
if (!existingBlock && !isTruncatedDisplayCard(resolved, members, sourceByPath)) break
|
||||
if (!existingBlock && !isTruncatedDisplayCard(resolved, members, sourceByPath) { onBadgeMerge(resolved.path) }) break
|
||||
block = resolved
|
||||
}
|
||||
parentPath = raw.parentPath
|
||||
@@ -656,6 +666,7 @@ object PddScreenParser {
|
||||
block: SnapshotNode,
|
||||
members: List<SnapshotNode>,
|
||||
sourceByPath: Map<String, SnapshotNode>,
|
||||
onBadgeMerge: () -> Unit,
|
||||
): Boolean {
|
||||
fun name(label: String) = SpecValueNormalizer.normalizeColor(label).filterNot { it.isWhitespace() }
|
||||
val full = name(block.label)
|
||||
@@ -664,7 +675,11 @@ object PddScreenParser {
|
||||
val image = children.singleOrNull { it.className == "android.widget.ImageView" } ?: return false
|
||||
val caption = children.singleOrNull { it.className == "android.widget.TextView" } ?: return false
|
||||
if (children.size != 2 || name(image.label) != full) return false
|
||||
val short = name(caption.label)
|
||||
// #379: a caption such as "名称...\n ¥11.8 零差评" keeps a price plus a badge
|
||||
// after its truncated name, which the terminal-price grammar cannot strip.
|
||||
val plainCaption = SpecValueNormalizer.normalizeColor(caption.label)
|
||||
val badgeName = if (plainCaption.any { it == '¥' || it == '¥' }) badgeCaptionName(caption.label, block.label) else null
|
||||
val short = (badgeName ?: plainCaption).filterNot { it.isWhitespace() }
|
||||
if (short.isEmpty() || short == full || !full.startsWith(short)) return false
|
||||
|
||||
val descendants = sourceByPath.values.filter { it.path.startsWith("${block.path}/") }
|
||||
@@ -682,7 +697,24 @@ object PddScreenParser {
|
||||
minOf(image.bounds.right, caption.bounds.right) <= maxOf(image.bounds.left, caption.bounds.left)) return false
|
||||
|
||||
val prices = members.mapNotNull { SpecValueNormalizer.trailingPriceAmount(it.label) }
|
||||
return prices.isEmpty() || prices.all { it.compareTo(prices.first()) == 0 }
|
||||
val merged = prices.isEmpty() || prices.all { it.compareTo(prices.first()) == 0 }
|
||||
if (merged && badgeName != null) onBadgeMerge()
|
||||
return merged
|
||||
}
|
||||
|
||||
/**
|
||||
* #379: truncated name of a caption shaped `名称... ¥金额 徽标`, or null. The
|
||||
* badge is 1-8 characters without currency symbols, digits or whitespace and
|
||||
* the amount must equal the outer card's terminal price.
|
||||
*/
|
||||
private fun badgeCaptionName(captionLabel: String, fullLabel: String): String? {
|
||||
val match = badgeCaptionSuffix.find(captionLabel) ?: return null
|
||||
val amount = match.groupValues[1].toBigDecimalOrNull() ?: return null
|
||||
val fullAmount = SpecValueNormalizer.trailingPriceAmount(fullLabel) ?: return null
|
||||
if (amount.compareTo(fullAmount) != 0) return null
|
||||
val head = captionLabel.substring(0, match.range.first).filterNot { it.isWhitespace() }
|
||||
.replace(Regex("(?:\\.{3}|…)+$"), "")
|
||||
return head.takeIf { it.isNotEmpty() && head.none { ch -> ch == '¥' || ch == '¥' } }
|
||||
}
|
||||
|
||||
/** The nearby clickable row holding [node], or null when there is none or it is not a plausible entry row. */
|
||||
@@ -1319,7 +1351,7 @@ class PddProductDetailCollector(
|
||||
val missing = linkedSetOf<String>()
|
||||
val unsupported = linkedSetOf<String>()
|
||||
val colorImages = linkedMapOf<String, ByteArray>()
|
||||
val colorNormalization = ColorNormalizationTracker()
|
||||
val colorNormalization = ColorNormalizationTracker(trace)
|
||||
val specPanelContainer = openedScreen.specPanelContainer
|
||||
collectColors(goodsId, config, evidence, deadline, specPanelContainer, colors, prices, missing, unsupported, colorImages, colorNormalization)?.let { return it }
|
||||
|
||||
@@ -1328,15 +1360,20 @@ class PddProductDetailCollector(
|
||||
prices.clear()
|
||||
colorImages.clear()
|
||||
missing += "spec_dimension_invalid:color"
|
||||
} else if (colorNormalization.conflict) {
|
||||
// #379: same normalized name from different raw labels keeps one value.
|
||||
missing += "spec_dimension_conflict:color"
|
||||
}
|
||||
moveSpecPanelToTop(goodsId, config, evidence, deadline, specPanelContainer)?.let { return it }
|
||||
val sizes = linkedMapOf<String, Boolean>()
|
||||
val sizeNormalization = SizeNormalizationTracker()
|
||||
val sizeNormalization = SizeNormalizationTracker(trace)
|
||||
collectSizes(goodsId, config, evidence, deadline, specPanelContainer, sizes, unsupported, sizeNormalization)?.let { return it }
|
||||
|
||||
if (sizeNormalization.invalid) {
|
||||
sizes.clear()
|
||||
missing += "spec_dimension_invalid:size"
|
||||
} else if (sizeNormalization.conflict) {
|
||||
missing += "spec_dimension_conflict:size"
|
||||
}
|
||||
|
||||
if (colors.isEmpty()) missing += "color"
|
||||
@@ -1412,6 +1449,14 @@ class PddProductDetailCollector(
|
||||
// These signatures stay in memory; only the aggregate tri-state is saved.
|
||||
val diagnosticHorizontalRows = mutableMapOf<Set<String>, Boolean?>()
|
||||
var diagnosticTermination = AgentDiagnosticReason.COLOR_FOUND
|
||||
// Keep the earliest abnormal reason; later normal ends must not hide it.
|
||||
fun terminate(reason: AgentDiagnosticReason) {
|
||||
if (diagnosticTermination == AgentDiagnosticReason.COLOR_FOUND ||
|
||||
diagnosticTermination == AgentDiagnosticReason.COLOR_EDGE_REACHED
|
||||
) {
|
||||
diagnosticTermination = reason
|
||||
}
|
||||
}
|
||||
val imageAttempts = mutableSetOf<String>()
|
||||
|
||||
fun collectVisibleImages(values: List<VisibleSpecValue>) {
|
||||
@@ -1448,7 +1493,7 @@ class PddProductDetailCollector(
|
||||
diagnosticRowValues.getOrPut(index) { linkedSetOf() }.addAll(row.map(VisibleSpecValue::text))
|
||||
}
|
||||
val values = rows.flatten()
|
||||
values.forEach(normalization::observe)
|
||||
normalization.observeFrame(values, "parsed", null, screen.badgeCaptionMerged)
|
||||
diagnosticParsedValues += values.map(VisibleSpecValue::text)
|
||||
diagnosticNonClickableCandidates = maxOf(
|
||||
diagnosticNonClickableCandidates,
|
||||
@@ -1479,7 +1524,9 @@ class PddProductDetailCollector(
|
||||
}
|
||||
|
||||
moveSpecPanelToTop(goodsId, config, evidence, deadline, specPanelContainer)?.let { return it }
|
||||
moveColorsToStart(goodsId, config, evidence, deadline)?.let { return it }
|
||||
val colorStart = moveColorsToStart(goodsId, config, evidence, deadline)
|
||||
colorStart.failure?.let { return it }
|
||||
val colorStartConfirmed = colorStart.confirmed
|
||||
val initial = parse(goodsId, config, evidence)
|
||||
initial.problem?.let { return failure(it.code, it.message) }
|
||||
diagnosticInitialSelectedColors = initial.dimensions.filter { it.key == "color" }.flatMap { it.values }
|
||||
@@ -1530,6 +1577,7 @@ class PddProductDetailCollector(
|
||||
FreshActionResult.BLOCKED -> return sizeAdviceFailure()
|
||||
FreshActionResult.AMBIGUOUS -> return failure("RULE_AMBIGUOUS", "颜色“${value.text}”匹配到多个控件")
|
||||
FreshActionResult.NOT_FOUND, FreshActionResult.FAILED -> {
|
||||
traceColorClickMiss(screen, value, rows, clickResult)
|
||||
missing += "selection:${value.text}"
|
||||
continue
|
||||
}
|
||||
@@ -1561,7 +1609,18 @@ class PddProductDetailCollector(
|
||||
// established left/right paging behavior.
|
||||
if (rows.any { it.size > 1 }) {
|
||||
val rowSeeds = rows.map { row -> row.map(VisibleSpecValue::text).toSet() }
|
||||
// Frame-to-frame row tracking: rows are followed by overlap with
|
||||
// their previous frame, not by the pass's starting frame.
|
||||
val tracks = rows.map(::colorRowTrack).toMutableList()
|
||||
val coveredRows = mutableSetOf<Int>()
|
||||
var coveredIncomplete: Boolean? = null
|
||||
rowSeeds.forEachIndexed { rowIndex, seed ->
|
||||
if (rowIndex in coveredRows) {
|
||||
// A shared container already swept this row's values.
|
||||
diagnosticHorizontalRows[seed] = coveredIncomplete
|
||||
?: diagnosticHorizontalRows[seed]?.takeIf { it }
|
||||
return@forEachIndexed
|
||||
}
|
||||
val moveRight = rowIndex % 2 == 0
|
||||
val horizontalSignatureReads = mutableMapOf<List<String>, Int>()
|
||||
var diagnosticPreviousSignature: List<String>? = null
|
||||
@@ -1569,11 +1628,15 @@ class PddProductDetailCollector(
|
||||
var diagnosticMoved = false
|
||||
var diagnosticRowUncertain = false
|
||||
var diagnosticRowIncomplete: Boolean? = null
|
||||
var sharedMoves = 0
|
||||
var sharedBroken = tracks.size < 2
|
||||
var rowEnd = "limit"
|
||||
var rowSkipNote = "none"
|
||||
for (horizontalPass in 0..config.limits.getValue("specHorizontalSwipes")) {
|
||||
var diagnosticBeforeClicks: List<String>? = null
|
||||
collectVisibleColors { observedRows ->
|
||||
diagnosticBeforeClicks = observedRows.filter { row -> row.any { it.text in seed } }
|
||||
.singleOrNull()?.sortedBy { it.node.bounds.left }
|
||||
diagnosticBeforeClicks = matchColorRow(tracks[rowIndex], observedRows, tracks.size, requireUniqueOverlap = true)
|
||||
?.sortedBy { it.node.bounds.left }
|
||||
?.let { if (moveRight) it else it.reversed() }?.let(::optionSignature)
|
||||
}?.let { return it }
|
||||
screen = parse(goodsId, config, evidence)
|
||||
@@ -1581,11 +1644,37 @@ class PddProductDetailCollector(
|
||||
if (!screen.pageEvidenceMatched) return failure("RULE_NOT_MATCHED", "采集期间离开 PDD 商品详情页")
|
||||
rows = colorRows(screen)
|
||||
observeColorDiscovery(screen, rows)
|
||||
val matchedRow = rows.maxByOrNull { row -> row.count { it.text in seed } }
|
||||
?.takeIf { row -> row.any { it.text in seed } }
|
||||
val matches = tracks.map { matchColorRow(it, rows, tracks.size) }
|
||||
val matchedRow = matches[rowIndex]
|
||||
val matchKind = colorRowMatchKind(tracks[rowIndex], rows, tracks.size)
|
||||
val rowMoved = matchedRow != null && positionSignature(matchedRow) != tracks[rowIndex].signature
|
||||
val othersDesc = tracks.indices.filter { it != rowIndex }.joinToString(",") { other ->
|
||||
val match = matches[other]
|
||||
when {
|
||||
match == null -> "none"
|
||||
positionSignature(match) != tracks[other].signature -> "moved"
|
||||
else -> "unchanged"
|
||||
}
|
||||
}
|
||||
if (rowMoved && matchedRow != null) {
|
||||
sharedMoves++
|
||||
// Position (text + bounds), not the text set: a partial
|
||||
// last swipe moves options without changing which are visible.
|
||||
val everyOtherRowMoved = tracks.indices.all { other ->
|
||||
other == rowIndex || matches[other]?.let { match ->
|
||||
positionSignature(match) != tracks[other].signature
|
||||
} == true
|
||||
}
|
||||
if (!everyOtherRowMoved) sharedBroken = true
|
||||
}
|
||||
matches.forEachIndexed { index, match ->
|
||||
match?.let { tracks[index] = colorRowTrack(it) }
|
||||
}
|
||||
if (matchedRow == null) {
|
||||
trace("color-row row=$rowIndex dir=${if (moveRight) "LEFT" else "RIGHT"} match=$matchKind moved=$rowMoved others=$othersDesc sharedMoves=$sharedMoves sharedBroken=$sharedBroken")
|
||||
rowEnd = "reflow"
|
||||
diagnosticHorizontalUnknown = true
|
||||
diagnosticTermination = AgentDiagnosticReason.COLOR_ROW_REFLOWED
|
||||
terminate(AgentDiagnosticReason.COLOR_ROW_REFLOWED)
|
||||
break
|
||||
}
|
||||
val currentRow = matchedRow.sortedBy { it.node.bounds.left }
|
||||
@@ -1605,24 +1694,57 @@ class PddProductDetailCollector(
|
||||
diagnosticPreviousSignature = signature
|
||||
val signatureReads = (horizontalSignatureReads[signature] ?: 0) + 1
|
||||
horizontalSignatureReads[signature] = signatureReads
|
||||
trace("color-row row=$rowIndex dir=${if (moveRight) "LEFT" else "RIGHT"} match=$matchKind moved=$rowMoved others=$othersDesc sharedMoves=$sharedMoves sharedBroken=$sharedBroken uncertain=$diagnosticRowUncertain signatureReads=$signatureReads")
|
||||
if (signatureReads > config.limits.getValue("stableEdgeReads")) {
|
||||
rowEnd = "edge"
|
||||
if (!diagnosticRowUncertain && diagnosticStableReads >= config.limits.getValue("stableEdgeReads")) {
|
||||
diagnosticRowIncomplete = false
|
||||
}
|
||||
// The skip depends only on reaching this row's edge
|
||||
// with every other row having followed every move,
|
||||
// not on the diagnostic certainty: a click-induced
|
||||
// layout change must not force a redundant sweep.
|
||||
val rest = (rowIndex + 1 until tracks.size).toList()
|
||||
if (rest.isNotEmpty()) {
|
||||
// The reverse sweep is the only thing that recovers
|
||||
// values left of an unconfirmed start, so skipping it
|
||||
// needs a sweep that began at a confirmed left edge.
|
||||
val skipOrigin = when {
|
||||
!colorStartConfirmed -> "start_unconfirmed"
|
||||
verticalPass != 0 -> "later_vertical_pass"
|
||||
rowIndex != 0 -> "not_first_row"
|
||||
else -> null
|
||||
}
|
||||
if (skipOrigin != null) {
|
||||
rowSkipNote = "not_skipped reason=$skipOrigin"
|
||||
} else if (!sharedBroken && sharedMoves > 0) {
|
||||
coveredRows += rest
|
||||
coveredIncomplete = diagnosticRowIncomplete
|
||||
rowSkipNote = "skipped=${rest.joinToString(",")}"
|
||||
} else {
|
||||
rowSkipNote = "not_skipped reason=${if (sharedMoves == 0) "no_shared_moves" else "shared_broken"}"
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
if (horizontalPass == config.limits.getValue("specHorizontalSwipes")) {
|
||||
rowEnd = "limit"
|
||||
if (diagnosticMoved && !diagnosticRowUncertain) diagnosticRowIncomplete = true
|
||||
break
|
||||
}
|
||||
val direction = if (moveRight) SwipeDirection.LEFT else SwipeDirection.RIGHT
|
||||
if (!driver.swipeSpec(direction, currentRow.first().node)) {
|
||||
rowEnd = "swipe_failed"
|
||||
if (diagnosticMoved && !diagnosticRowUncertain) diagnosticRowIncomplete = true
|
||||
break
|
||||
}
|
||||
diagnosticHorizontalSwipes++
|
||||
pause(350)
|
||||
val settleStartedAt = now()
|
||||
pause(HORIZONTAL_SETTLE_MILLIS)
|
||||
lastHorizontalSwipeDoneAt = now()
|
||||
trace("color-settle waitedMs=${now() - settleStartedAt}")
|
||||
}
|
||||
trace("color-row-end row=$rowIndex end=$rowEnd skip=$rowSkipNote")
|
||||
// An uncertain revisit cannot erase previously observed
|
||||
// unresolved movement; only reliable stability clears it.
|
||||
diagnosticHorizontalRows[seed] = diagnosticRowIncomplete
|
||||
@@ -1640,7 +1762,7 @@ class PddProductDetailCollector(
|
||||
diagnosticTrailingEmptyReads = if (rows.isEmpty()) diagnosticTrailingEmptyReads + 1 else 0
|
||||
if (rows.isEmpty()) diagnosticHorizontalUnknown = true
|
||||
if (!discoverVertically) {
|
||||
diagnosticTermination = AgentDiagnosticReason.COLOR_EDGE_REACHED
|
||||
terminate(AgentDiagnosticReason.COLOR_EDGE_REACHED)
|
||||
break
|
||||
}
|
||||
|
||||
@@ -1652,21 +1774,21 @@ class PddProductDetailCollector(
|
||||
}
|
||||
previousVerticalSignature = verticalSignature
|
||||
if (verticalStable >= config.limits.getValue("stableEdgeReads")) {
|
||||
diagnosticTermination = AgentDiagnosticReason.COLOR_EDGE_REACHED
|
||||
terminate(AgentDiagnosticReason.COLOR_EDGE_REACHED)
|
||||
break
|
||||
}
|
||||
if (verticalPass == config.limits.getValue("specVerticalSwipes")) {
|
||||
diagnosticTermination = AgentDiagnosticReason.COLOR_SCAN_LIMIT
|
||||
terminate(AgentDiagnosticReason.COLOR_SCAN_LIMIT)
|
||||
break
|
||||
}
|
||||
val anchor = rows.flatten().firstOrNull()?.node ?: specPanelContainer
|
||||
if (anchor == null) {
|
||||
diagnosticHorizontalUnknown = true
|
||||
diagnosticTermination = AgentDiagnosticReason.COLOR_CONTAINER_UNAVAILABLE
|
||||
terminate(AgentDiagnosticReason.COLOR_CONTAINER_UNAVAILABLE)
|
||||
break
|
||||
}
|
||||
if (!driver.swipeSpec(SwipeDirection.UP, anchor)) {
|
||||
diagnosticTermination = AgentDiagnosticReason.COLOR_SWIPE_FAILED
|
||||
terminate(AgentDiagnosticReason.COLOR_SWIPE_FAILED)
|
||||
break
|
||||
}
|
||||
diagnosticVerticalSwipes++
|
||||
@@ -1678,6 +1800,31 @@ class PddProductDetailCollector(
|
||||
|
||||
private fun sizeAdviceFailure() = failure("SIZE_ADVICE_CLICK_BLOCKED", "已阻止点击尺码建议入口")
|
||||
|
||||
private var lastHorizontalSwipeDoneAt = -1L
|
||||
|
||||
private fun traceColorClickMiss(screen: ParsedPddScreen, value: VisibleSpecValue, rows: List<List<VisibleSpecValue>>, result: FreshActionResult) {
|
||||
val rowIndex = rows.indexOfFirst { row -> row.any { it.text == value.text } }
|
||||
val column = rows.getOrNull(rowIndex).orEmpty().sortedBy { it.node.bounds.left }.indexOfFirst { it.text == value.text }
|
||||
val bounds = value.node.bounds
|
||||
val width = bounds.right - bounds.left
|
||||
val byPath = screen.sourceNodes.associateBy(SnapshotNode::path)
|
||||
var container: SnapshotNode? = byPath[value.node.parentPath]
|
||||
var hops = 0
|
||||
while (container != null && !container.scrollable && hops < 6) {
|
||||
container = container.parentPath?.let { byPath[it] }
|
||||
hops++
|
||||
}
|
||||
val exposed = if (width > 0 && container != null) {
|
||||
val shown = (minOf(bounds.right, container.bounds.right) - maxOf(bounds.left, container.bounds.left)).coerceAtLeast(0)
|
||||
"${shown * 100 / width}%"
|
||||
} else {
|
||||
"unknown"
|
||||
}
|
||||
val containerText = container?.let { "${it.bounds}" } ?: "unknown"
|
||||
val sinceSwipe = if (lastHorizontalSwipeDoneAt < 0) "none" else "${now() - lastHorizontalSwipeDoneAt}ms"
|
||||
trace("color-click-miss color=${traceLabel(value.text)} result=$result row=$rowIndex column=$column bounds=$bounds container=$containerText exposed=$exposed sinceSwipe=$sinceSwipe")
|
||||
}
|
||||
|
||||
private fun clickSpecTarget(target: SnapshotNode, stage: AgentDiagnosticStage): FreshActionResult {
|
||||
val outcome = driver.clickFreshDetailed(target)
|
||||
if (taskId > 0) runCatching { diagnostic(specClickDiagnostic(taskId, stage, target, outcome)) }
|
||||
@@ -1837,31 +1984,72 @@ class PddProductDetailCollector(
|
||||
return null
|
||||
}
|
||||
|
||||
private class ColorStart(val failure: PddCollectorResult?, val confirmed: Boolean)
|
||||
|
||||
private fun moveColorsToStart(
|
||||
goodsId: String,
|
||||
config: PddCollectorConfig,
|
||||
evidence: PageEvidence,
|
||||
deadline: Long,
|
||||
): PddCollectorResult? {
|
||||
): ColorStart {
|
||||
var previous = emptyList<String>()
|
||||
var stable = 0
|
||||
repeat(config.limits.getValue("specHorizontalSwipes")) {
|
||||
if (now() > deadline) return failure("RULE_NOT_MATCHED", "采集超过规则总超时")
|
||||
var swipes = 0
|
||||
var anchorRetries = 0
|
||||
var anchorFailures = 0
|
||||
var confirmed = false
|
||||
var end = "limit"
|
||||
// Reset has its own swipe budget; anchor retries do not consume it.
|
||||
val maxSwipes = config.limits.getValue("specHorizontalSwipes") * 2
|
||||
while (swipes < maxSwipes) {
|
||||
if (now() > deadline) return ColorStart(failure("RULE_NOT_MATCHED", "采集超过规则总超时"), false)
|
||||
val screen = parse(goodsId, config, evidence)
|
||||
screen.problem?.let { return failure(it.code, it.message) }
|
||||
if (!screen.pageEvidenceMatched) return failure("RULE_NOT_MATCHED", "采集期间离开 PDD 商品详情页")
|
||||
screen.problem?.let { return ColorStart(failure(it.code, it.message), false) }
|
||||
if (!screen.pageEvidenceMatched) return ColorStart(failure("RULE_NOT_MATCHED", "采集期间离开 PDD 商品详情页"), false)
|
||||
val rows = colorRows(screen)
|
||||
if (rows.size > 1 && rows.all { it.size == 1 }) return null
|
||||
if (rows.size > 1 && rows.all { it.size == 1 }) {
|
||||
end = "single_column"
|
||||
break
|
||||
}
|
||||
val values = rows.flatten()
|
||||
val signature = optionSignature(values)
|
||||
stable = if (signature == previous) stable + 1 else 0
|
||||
previous = signature
|
||||
if (stable >= config.limits.getValue("stableEdgeReads")) return null
|
||||
val anchor = values.firstOrNull()?.node ?: return null
|
||||
if (!driver.swipeSpec(SwipeDirection.RIGHT, anchor)) return null
|
||||
if (stable >= config.limits.getValue("stableEdgeReads")) {
|
||||
confirmed = true
|
||||
end = "stable"
|
||||
break
|
||||
}
|
||||
// After an anchor failure, try a different color node of this frame.
|
||||
val anchor = values.getOrNull(anchorFailures)?.node ?: values.firstOrNull()?.node
|
||||
if (anchor == null) {
|
||||
end = "no_anchor"
|
||||
break
|
||||
}
|
||||
if (!driver.swipeSpec(SwipeDirection.RIGHT, anchor)) {
|
||||
val reason = driver.specRowSwipeFailureReason()
|
||||
anchorFailures++
|
||||
// A failed gesture says nothing about the position, so the next
|
||||
// equal read must not count as stability.
|
||||
previous = emptyList()
|
||||
stable = 0
|
||||
if (
|
||||
(reason == "anchorMissing" || reason == "anchorAmbiguous") &&
|
||||
anchorFailures <= MAX_CONSECUTIVE_RESET_ANCHOR_FAILURES &&
|
||||
anchorRetries < MAX_RESET_ANCHOR_RETRIES
|
||||
) {
|
||||
anchorRetries++
|
||||
continue
|
||||
}
|
||||
end = "swipe_failed"
|
||||
break
|
||||
}
|
||||
anchorFailures = 0
|
||||
swipes++
|
||||
pause(350)
|
||||
}
|
||||
return null
|
||||
trace("color-start confirmed=$confirmed swipes=$swipes anchorRetries=$anchorRetries end=$end")
|
||||
return ColorStart(null, confirmed)
|
||||
}
|
||||
|
||||
private fun collectSizes(
|
||||
@@ -1887,9 +2075,17 @@ class PddProductDetailCollector(
|
||||
screen.dimensions.filter { it.key == "unsupported" }.forEach { unsupported += it.name }
|
||||
val parsedSizes = screen.dimensions.filter { it.key == "size" }.flatMap { it.values }
|
||||
if (parsedSizes.isNotEmpty()) continuation = buildSizeContinuation(screen, parsedSizes)
|
||||
val visible = if (parsedSizes.isNotEmpty()) parsedSizes else continuationValues(screen, continuation, config)
|
||||
// #379: observe every candidate before continuation de-duplication,
|
||||
// otherwise a same-name conflict is swallowed by groupBy.
|
||||
val continuationCandidates = if (parsedSizes.isNotEmpty()) emptyList() else continuationCandidates(screen, continuation, config)
|
||||
normalization.observeFrame(
|
||||
if (parsedSizes.isNotEmpty()) parsedSizes else continuationCandidates,
|
||||
if (parsedSizes.isNotEmpty()) "parsed" else "continuation",
|
||||
pass,
|
||||
screen.badgeCaptionMerged,
|
||||
)
|
||||
val visible = if (parsedSizes.isNotEmpty()) parsedSizes else dedupeContinuation(continuationCandidates)
|
||||
visible.forEach { value ->
|
||||
normalization.observe(value)
|
||||
if (SpecValueNormalizer.isSafeSize(value.text)) {
|
||||
sizes[value.text] = sizes[value.text] == true || value.available
|
||||
}
|
||||
@@ -1934,37 +2130,75 @@ class PddProductDetailCollector(
|
||||
return null
|
||||
}
|
||||
|
||||
private class SizeNormalizationTracker {
|
||||
private val sources = linkedMapOf<String, MutableSet<String>>()
|
||||
/**
|
||||
* #160/#379: an empty or currency-bearing normalized value rejects the whole
|
||||
* dimension ([invalid]); a normalized name backed by several raw labels only
|
||||
* marks a [conflict] and the dimension keeps a single value for that name.
|
||||
*/
|
||||
private open class SpecNormalizationTracker(
|
||||
private val dim: String,
|
||||
private val isSafe: (String) -> Boolean,
|
||||
private val trace: (String) -> Unit,
|
||||
) {
|
||||
// normalized text -> raw label -> frames in which that raw label was seen
|
||||
private val sources = linkedMapOf<String, LinkedHashMap<String, MutableSet<Int>>>()
|
||||
private var frameCounter = 0
|
||||
private var invalidTraced = false
|
||||
private var conflictTraced = false
|
||||
var invalid: Boolean = false
|
||||
private set
|
||||
var conflict: Boolean = false
|
||||
private set
|
||||
|
||||
fun observe(value: VisibleSpecValue) {
|
||||
if (!SpecValueNormalizer.isSafeSize(value.text)) {
|
||||
invalid = true
|
||||
return
|
||||
/** [pass] identifies the frame; without it the tracker numbers its own observations. */
|
||||
fun observeFrame(values: List<VisibleSpecValue>, path: String, pass: Int?, badgeMerged: Int) {
|
||||
val frame = pass ?: ++frameCounter
|
||||
values.forEach { value ->
|
||||
if (!isSafe(value.text)) {
|
||||
invalid = true
|
||||
if (!invalidTraced) {
|
||||
invalidTraced = true
|
||||
val reason = if (value.text.isBlank()) "empty" else "unsafe_currency"
|
||||
traceOutcome("invalid_rejected", reason, path, frame, false, false, 1, values.size, badgeMerged)
|
||||
}
|
||||
return@forEach
|
||||
}
|
||||
val raws = sources.getOrPut(value.text) { linkedMapOf() }
|
||||
val isNewRaw = value.rawText !in raws
|
||||
raws.getOrPut(value.rawText) { linkedSetOf() } += frame
|
||||
if (raws.size > 1) {
|
||||
conflict = true
|
||||
if (isNewRaw && !conflictTraced) {
|
||||
conflictTraced = true
|
||||
traceOutcome(
|
||||
"conflict_kept", "normalized_conflict", path, frame,
|
||||
raws.values.count { frame in it } > 1,
|
||||
raws.keys.map { it.replace(TRAILING_PRICE, "") }.toSet().size == 1,
|
||||
raws.size, values.size, badgeMerged,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
val rawValues = sources.getOrPut(value.text) { linkedSetOf() }
|
||||
rawValues += value.rawText
|
||||
if (rawValues.size > 1) invalid = true
|
||||
}
|
||||
|
||||
// Counts and enum values only: never the raw node text.
|
||||
private fun traceOutcome(
|
||||
outcome: String, reason: String, path: String, pass: Int, sameFrame: Boolean,
|
||||
onlyTrailingPriceDiff: Boolean, rawVariants: Int, candidates: Int, badgeMerged: Int,
|
||||
) {
|
||||
trace(
|
||||
"spec-normalize dim=$dim outcome=$outcome reason=$reason path=$path pass=$pass sameFrame=$sameFrame " +
|
||||
"onlyTrailingPriceDiff=$onlyTrailingPriceDiff rawVariants=$rawVariants candidates=$candidates " +
|
||||
"badgeCaptionMerged=$badgeMerged",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private class ColorNormalizationTracker {
|
||||
private val sources = linkedMapOf<String, MutableSet<String>>()
|
||||
var invalid: Boolean = false
|
||||
private set
|
||||
private class SizeNormalizationTracker(trace: (String) -> Unit) :
|
||||
SpecNormalizationTracker("size", SpecValueNormalizer::isSafeSize, trace)
|
||||
|
||||
fun observe(value: VisibleSpecValue) {
|
||||
if (!SpecValueNormalizer.isSafeColor(value.text)) {
|
||||
invalid = true
|
||||
return
|
||||
}
|
||||
val rawValues = sources.getOrPut(value.text) { linkedSetOf() }
|
||||
rawValues += value.rawText
|
||||
if (rawValues.size > 1) invalid = true
|
||||
}
|
||||
}
|
||||
private class ColorNormalizationTracker(trace: (String) -> Unit) :
|
||||
SpecNormalizationTracker("color", SpecValueNormalizer::isSafeColor, trace)
|
||||
|
||||
private fun recordSizeDiscovery(
|
||||
reason: AgentDiagnosticReason,
|
||||
@@ -2044,7 +2278,8 @@ class PddProductDetailCollector(
|
||||
)
|
||||
}
|
||||
|
||||
private fun continuationValues(screen: ParsedPddScreen, context: SizeContinuation?, config: PddCollectorConfig): List<VisibleSpecValue> {
|
||||
/** Normalized continuation candidates before any de-duplication; rawText keeps the original label. */
|
||||
private fun continuationCandidates(screen: ParsedPddScreen, context: SizeContinuation?, config: PddCollectorConfig): List<VisibleSpecValue> {
|
||||
if (context == null) return emptyList()
|
||||
val headingLabels = (config.colorAliases + config.sizeAliases + config.textAliases.dimension.exactNames +
|
||||
config.textAliases.dimension.adaptiveAliases).map { it.replace(" ", "") }.toSet()
|
||||
@@ -2059,13 +2294,21 @@ class PddProductDetailCollector(
|
||||
.filterNot { it.label in context.colorLabels || it.label.replace(" ", "") in headingLabels }
|
||||
.filterNot { PddScreenParser.isExcludedOptionLabel(it.label) || priceOnly.matches(it.label.replace(" ", "")) }
|
||||
.map { node ->
|
||||
VisibleSpecValue(node.label, node.enabled && listOf("售罄", "缺货", "不可选").none(node.label::contains), node)
|
||||
VisibleSpecValue(
|
||||
SpecValueNormalizer.normalizeSize(node.label),
|
||||
node.enabled && listOf("售罄", "缺货", "不可选").none(node.label::contains),
|
||||
node,
|
||||
rawText = node.label,
|
||||
)
|
||||
}
|
||||
.groupBy(VisibleSpecValue::text)
|
||||
.map { (_, sameLabel) -> sameLabel.minBy { it.node.bounds.top * 10_000 + it.node.bounds.left } }
|
||||
.sortedWith(compareBy({ it.node.bounds.top }, { it.node.bounds.left }))
|
||||
.toList()
|
||||
}
|
||||
|
||||
private fun dedupeContinuation(candidates: List<VisibleSpecValue>): List<VisibleSpecValue> = candidates
|
||||
.groupBy(VisibleSpecValue::text)
|
||||
.map { (_, sameLabel) -> sameLabel.minBy { it.node.bounds.top * 10_000 + it.node.bounds.left } }
|
||||
.sortedWith(compareBy({ it.node.bounds.top }, { it.node.bounds.left }))
|
||||
|
||||
private fun stablePrice(
|
||||
goodsId: String,
|
||||
color: String,
|
||||
@@ -2213,12 +2456,65 @@ class PddProductDetailCollector(
|
||||
private fun traceLabel(value: String): String = value.replace(Regex("[\\r\\n\\t]+"), " ").take(40)
|
||||
|
||||
private companion object {
|
||||
/** Same terminal-price grammar as [SpecValueNormalizer]; used only to classify a trace, never to normalize. */
|
||||
val TRAILING_PRICE = Regex("""[\s\u00a0\u3000]*(?:¥|¥)[\s\u00a0\u3000]*\d+(?:\.\d{1,2})?[\s\u00a0\u3000]*$""")
|
||||
const val PRICE_SETTLE_MILLIS = 300L
|
||||
/** Fixed wait for the color grid to stop coasting after a horizontal swipe (no extra parse). */
|
||||
const val HORIZONTAL_SETTLE_MILLIS = 1200L
|
||||
const val MAX_RESET_ANCHOR_RETRIES = 6
|
||||
const val MAX_CONSECUTIVE_RESET_ANCHOR_FAILURES = 2
|
||||
const val SPEC_PANEL_LOST_STABLE_READS = 2
|
||||
const val COLOR_IMAGE_MAX_COUNT = 64
|
||||
const val COLOR_IMAGE_MAX_TOTAL_BYTES = 8 * 1024 * 1024
|
||||
}
|
||||
|
||||
/** Last observed frame of one color row, used to follow it across horizontal swipes. */
|
||||
private class ColorRowTrack(val texts: Set<String>, val centerY: Int, val tolerance: Int, val signature: List<String>)
|
||||
|
||||
private fun colorRowTrack(row: List<VisibleSpecValue>) = ColorRowTrack(
|
||||
row.map(VisibleSpecValue::text).toSet(),
|
||||
row.map { it.node.bounds.centerY }.average().toInt(),
|
||||
(row.first().node.bounds.height / 2).coerceIn(24, 80),
|
||||
positionSignature(row),
|
||||
)
|
||||
|
||||
private fun colorRowMatchKind(
|
||||
track: ColorRowTrack,
|
||||
rows: List<List<VisibleSpecValue>>,
|
||||
trackedRowCount: Int,
|
||||
): String = when {
|
||||
rows.any { row -> row.any { it.text in track.texts } } -> "overlap"
|
||||
matchColorRow(track, rows, trackedRowCount) != null -> "position"
|
||||
else -> "none"
|
||||
}
|
||||
|
||||
private fun positionSignature(row: List<VisibleSpecValue>): List<String> =
|
||||
optionSignature(row.sortedBy { it.node.bounds.left })
|
||||
|
||||
/**
|
||||
* Finds the current-frame row for a tracked row. Overlap with the previous
|
||||
* frame wins. With no overlap (a whole-page move) a multi-row grid whose
|
||||
* row count is unchanged may match by vertical position, but only when
|
||||
* exactly one current row sits within the colorRows tolerance. Anything
|
||||
* else is not the same row and stays a reflow.
|
||||
*/
|
||||
private fun matchColorRow(
|
||||
track: ColorRowTrack,
|
||||
rows: List<List<VisibleSpecValue>>,
|
||||
trackedRowCount: Int,
|
||||
requireUniqueOverlap: Boolean = false,
|
||||
): List<VisibleSpecValue>? {
|
||||
val overlapping = rows.filter { row -> row.any { it.text in track.texts } }
|
||||
if (overlapping.isNotEmpty()) {
|
||||
if (requireUniqueOverlap && overlapping.size > 1) return null
|
||||
return overlapping.maxByOrNull { row -> row.count { it.text in track.texts } }
|
||||
}
|
||||
if (trackedRowCount < 2 || rows.size != trackedRowCount) return null
|
||||
return rows.filter { row ->
|
||||
kotlin.math.abs(row.map { it.node.bounds.centerY }.average().toInt() - track.centerY) <= track.tolerance
|
||||
}.singleOrNull()
|
||||
}
|
||||
|
||||
private fun colorRows(screen: ParsedPddScreen): List<List<VisibleSpecValue>> {
|
||||
val values = screen.dimensions.filter { it.key == "color" }.flatMap { it.values }
|
||||
.sortedWith(compareBy({ it.node.bounds.centerY }, { it.node.bounds.left }))
|
||||
|
||||
+55
@@ -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
|
||||
}
|
||||
}
|
||||
+33
-50
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
+65
@@ -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)
|
||||
}
|
||||
}
|
||||
+18
-2
@@ -1,7 +1,22 @@
|
||||
package cn.ilapage.goauto.agent.persistence
|
||||
|
||||
internal object AgentDiagnosticSchema {
|
||||
const val VERSION = 5
|
||||
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 (
|
||||
@@ -99,5 +114,6 @@ internal object AgentDiagnosticSchema {
|
||||
(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())
|
||||
} 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,
|
||||
@@ -143,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) {
|
||||
|
||||
+18
-1
@@ -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"
|
||||
|
||||
+234
@@ -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))
|
||||
}
|
||||
}
|
||||
+318
-40
@@ -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,8 @@ 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)
|
||||
repurchaseExecutor.scheduleWithFixedDelay(::refreshDeviceBusy, 1, 2, TimeUnit.SECONDS)
|
||||
diagnosticInstance = this
|
||||
}
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
@@ -174,6 +198,12 @@ class AgentForegroundService : Service() {
|
||||
return START_STICKY
|
||||
}
|
||||
}
|
||||
if (intent?.action == ACTION_OPEN_PDD_PRODUCT) {
|
||||
val goodsId = intent.getStringExtra(EXTRA_OPEN_PDD_GOODS_ID)
|
||||
val purchase = intent.getBooleanExtra(EXTRA_OPEN_PDD_PURCHASE, false)
|
||||
runCatching { repurchaseExecutor.execute { openPddProduct(goodsId, purchase) } }
|
||||
return START_STICKY
|
||||
}
|
||||
if (intent?.action == ACTION_BACKFILL_STOP) {
|
||||
backfillGuard.cancelled.set(true)
|
||||
return START_STICKY
|
||||
@@ -197,10 +227,15 @@ 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()
|
||||
repurchaseState = RepurchaseState(message = "服务已停止;不会恢复整轮重购")
|
||||
deviceBusyReason = null
|
||||
backfillGuard.cancelled.set(true)
|
||||
runCatching { connectivityManager.unregisterNetworkCallback(networkCallback) }
|
||||
cancelIdleReturn("服务已停止")
|
||||
@@ -220,6 +255,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 +294,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 +364,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 +407,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 +443,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)) {
|
||||
@@ -372,9 +505,47 @@ class AgentForegroundService : Service() {
|
||||
updateNotification(state.message, force = true)
|
||||
}
|
||||
|
||||
private fun repurchaseLocalIdle(): Boolean = !working.get() && taskMutex.currentTaskId() == null &&
|
||||
purchaseStore.activeTaskId() == null && purchaseStore.pendingOutbox().isEmpty() &&
|
||||
stateStore.read().currentTaskId == null && !backfillState.running
|
||||
private fun deviceBusyInputs(includeRepurchase: Boolean): DeviceBusyPolicy.Inputs {
|
||||
val mutexTaskId = taskMutex.currentTaskId()
|
||||
return DeviceBusyPolicy.Inputs(
|
||||
repurchaseBusy = includeRepurchase && repurchaseState.busy,
|
||||
backfillRunning = backfillState.running,
|
||||
currentPageCollecting = mutexTaskId == CURRENT_PAGE_RESERVATION_ID,
|
||||
taskActive = working.get() || mutexTaskId != null || purchaseStore.activeTaskId() != null ||
|
||||
stateStore.read().currentTaskId != null,
|
||||
unuploadedPurchase = purchaseStore.pendingOutbox().isNotEmpty(),
|
||||
)
|
||||
}
|
||||
|
||||
// The repurchase round itself asks this question between its tasks, so it must not count its own busy flag.
|
||||
private fun repurchaseLocalIdle(): Boolean = DeviceBusyPolicy.reason(deviceBusyInputs(includeRepurchase = false)) == null
|
||||
|
||||
/** Publishes the read-only busy reason for the history detail buttons; click time re-checks. */
|
||||
private fun refreshDeviceBusy() {
|
||||
runCatching {
|
||||
val reason = if (repurchaseClosed.get()) null else DeviceBusyPolicy.reason(deviceBusyInputs(includeRepurchase = true))
|
||||
if (reason != deviceBusyReason) {
|
||||
deviceBusyReason = reason
|
||||
sendBroadcast(Intent(ACTION_DEVICE_BUSY_STATE).setPackage(packageName))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun openPddProduct(goodsId: String?, purchase: Boolean) {
|
||||
val result = runCatching {
|
||||
PddManualOpenCoordinator(
|
||||
busyReason = { synchronized(taskMutex) { DeviceBusyPolicy.reason(deviceBusyInputs(includeRepurchase = true)) } },
|
||||
invalidateHandoff = probeHandoff::invalidate,
|
||||
launch = { url, preferDirect, diagnostic ->
|
||||
cn.ilapage.goauto.agent.automation.PddLinkLauncher(this).open(url, preferDirect, diagnostic)
|
||||
},
|
||||
).run(goodsId, purchase)
|
||||
}.getOrDefault(PddManualOpenResult.Failed)
|
||||
sendBroadcast(Intent(ACTION_OPEN_PDD_RESULT).apply {
|
||||
setPackage(packageName)
|
||||
putExtra(EXTRA_OPEN_PDD_MESSAGE, PddManualOpenCoordinator.message(result))
|
||||
})
|
||||
}
|
||||
|
||||
private fun repurchaseEnvironment(session: RepurchaseSession): String? {
|
||||
if (repurchaseClosed.get()) return "服务已经停止"
|
||||
@@ -484,6 +655,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 +745,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 +892,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 +921,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 +965,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 +1031,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 +1062,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 +1090,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 +1141,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 +1176,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 +1196,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 +1284,7 @@ class AgentForegroundService : Service() {
|
||||
initialTask: cn.ilapage.goauto.agent.network.AgentTask,
|
||||
token: String,
|
||||
): TaskExecutionSummary {
|
||||
probeHandoff.beginExecution()
|
||||
GoAutoAccessibilityService.instance?.dismissPurchaseResultBubble()
|
||||
acquireTaskWakeLock()
|
||||
return try {
|
||||
@@ -1404,6 +1658,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 +1813,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 +1828,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 +1843,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"
|
||||
@@ -1597,6 +1857,15 @@ class AgentForegroundService : Service() {
|
||||
const val ACTION_BACKFILL_STATE = "cn.ilapage.goauto.agent.BACKFILL_STATE"
|
||||
@Volatile var backfillState = OrderBackfillState()
|
||||
private set
|
||||
const val ACTION_OPEN_PDD_PRODUCT = "cn.ilapage.goauto.agent.OPEN_PDD_PRODUCT"
|
||||
const val ACTION_OPEN_PDD_RESULT = "cn.ilapage.goauto.agent.OPEN_PDD_RESULT"
|
||||
const val ACTION_DEVICE_BUSY_STATE = "cn.ilapage.goauto.agent.DEVICE_BUSY_STATE"
|
||||
const val EXTRA_OPEN_PDD_GOODS_ID = "open_pdd_goods_id"
|
||||
const val EXTRA_OPEN_PDD_PURCHASE = "open_pdd_purchase"
|
||||
const val EXTRA_OPEN_PDD_MESSAGE = "open_pdd_message"
|
||||
/** Null when idle; otherwise the user-facing reason. Updated about every two seconds. */
|
||||
@Volatile var deviceBusyReason: String? = null
|
||||
private set
|
||||
const val ACTION_RECONNECT = "cn.ilapage.goauto.agent.RECONNECT"
|
||||
const val ACTION_CHECK_NOW = "cn.ilapage.goauto.agent.CHECK_NOW"
|
||||
const val ACTION_CHECK_RESULT = "cn.ilapage.goauto.agent.CHECK_RESULT"
|
||||
@@ -1653,6 +1922,15 @@ class AgentForegroundService : Service() {
|
||||
}
|
||||
}
|
||||
|
||||
fun openPddProduct(context: Context, goodsId: String, purchase: Boolean) {
|
||||
val intent = Intent(context, AgentForegroundService::class.java).apply {
|
||||
action = ACTION_OPEN_PDD_PRODUCT
|
||||
putExtra(EXTRA_OPEN_PDD_GOODS_ID, goodsId)
|
||||
putExtra(EXTRA_OPEN_PDD_PURCHASE, purchase)
|
||||
}
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) context.startForegroundService(intent) else context.startService(intent)
|
||||
}
|
||||
|
||||
fun collectCurrentPage(
|
||||
context: Context,
|
||||
requestId: String,
|
||||
|
||||
@@ -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,85 @@
|
||||
package cn.ilapage.goauto.agent.service
|
||||
|
||||
import cn.ilapage.goauto.agent.automation.PddLaunchEvent
|
||||
import cn.ilapage.goauto.agent.automation.PddLaunchPath
|
||||
import cn.ilapage.goauto.agent.automation.PddLaunchStatus
|
||||
|
||||
/** Single source of truth for "the device is busy"; the service and the UI both consume its result. */
|
||||
internal object DeviceBusyPolicy {
|
||||
data class Inputs(
|
||||
val repurchaseBusy: Boolean = false,
|
||||
val backfillRunning: Boolean = false,
|
||||
/** The manual current-page collection holds the task mutex with its own reservation. */
|
||||
val currentPageCollecting: Boolean = false,
|
||||
/** A collection/purchase/image-search task is claimed, running or restored. */
|
||||
val taskActive: Boolean = false,
|
||||
/** Unuploaded purchase results or a purchase waiting for recovery. */
|
||||
val unuploadedPurchase: Boolean = false,
|
||||
)
|
||||
|
||||
const val REPURCHASE = "重购进行中"
|
||||
const val BACKFILL = "订单回填中"
|
||||
const val CURRENT_PAGE = "当前页采集进行中"
|
||||
const val TASK = "设备正在执行任务"
|
||||
const val UNUPLOADED = "有未上传的采购结果"
|
||||
|
||||
/** Null means idle. A collection cooldown is intentionally not an input. */
|
||||
fun reason(inputs: Inputs): String? = when {
|
||||
inputs.repurchaseBusy -> REPURCHASE
|
||||
inputs.backfillRunning -> BACKFILL
|
||||
inputs.currentPageCollecting -> CURRENT_PAGE
|
||||
inputs.taskActive -> TASK
|
||||
inputs.unuploadedPurchase -> UNUPLOADED
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class PddManualOpenResult {
|
||||
object InvalidGoodsId : PddManualOpenResult()
|
||||
data class Busy(val reason: String) : PddManualOpenResult()
|
||||
data class Opened(val fallbackNotice: String?) : PddManualOpenResult()
|
||||
object Failed : PddManualOpenResult()
|
||||
}
|
||||
|
||||
/** Manual "open in PDD" entry. Only opens the product link; it never clicks anything inside PDD. */
|
||||
internal class PddManualOpenCoordinator(
|
||||
private val busyReason: () -> String?,
|
||||
private val invalidateHandoff: () -> Unit,
|
||||
private val launch: (url: String, preferDirect: Boolean, diagnostic: (PddLaunchEvent) -> Unit) -> Boolean,
|
||||
) {
|
||||
fun run(goodsId: String?, purchase: Boolean): PddManualOpenResult {
|
||||
val url = link(goodsId) ?: return PddManualOpenResult.InvalidGoodsId
|
||||
busyReason()?.let { return PddManualOpenResult.Busy(it) }
|
||||
// The opened page is no longer the product a previous probe prepared.
|
||||
invalidateHandoff()
|
||||
val events = mutableListOf<PddLaunchEvent>()
|
||||
val opened = launch(url, purchase) { events += it }
|
||||
return if (opened) PddManualOpenResult.Opened(fallbackNotice(events)) else PddManualOpenResult.Failed
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val OPENED_HINT = "打开期间如有新任务,Agent 会接管屏幕"
|
||||
const val FAILED = "无法打开拼多多链接"
|
||||
const val INVALID = "商品编号无效,无法打开"
|
||||
|
||||
fun link(goodsId: String?): String? =
|
||||
goodsId?.takeIf { it.isNotEmpty() && it.all { c -> c in '0'..'9' } }
|
||||
?.let { "https://mobile.yangkeduo.com/goods.html?goods_id=$it" }
|
||||
|
||||
/** Never contains the URL or an exception message; only the simple exception class name. */
|
||||
fun fallbackNotice(events: List<PddLaunchEvent>): String? {
|
||||
val direct = events.firstOrNull { it.path == PddLaunchPath.DIRECT && it.status != PddLaunchStatus.ACCEPTED }
|
||||
?: return null
|
||||
if (events.none { it.path == PddLaunchPath.BROWSER && it.status == PddLaunchStatus.ACCEPTED }) return null
|
||||
val name = direct.exceptionClass?.substringAfterLast('.')?.takeIf(String::isNotBlank)
|
||||
return "拼多多直接打开失败,已改用浏览器" + (name?.let { "($it)" } ?: "")
|
||||
}
|
||||
|
||||
fun message(result: PddManualOpenResult): String = when (result) {
|
||||
PddManualOpenResult.InvalidGoodsId -> INVALID
|
||||
is PddManualOpenResult.Busy -> "${result.reason},暂不能打开拼多多"
|
||||
is PddManualOpenResult.Opened -> listOfNotNull(result.fallbackNotice, OPENED_HINT).joinToString("\n")
|
||||
PddManualOpenResult.Failed -> FAILED
|
||||
}
|
||||
}
|
||||
}
|
||||
+16
@@ -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)) {
|
||||
|
||||
@@ -74,6 +74,8 @@ class AgentStatusFragment : Fragment() {
|
||||
private lateinit var mediaPermissionDetail: TextView
|
||||
private lateinit var mediaPermissionAction: android.widget.Button
|
||||
private lateinit var deviceText: TextView
|
||||
private lateinit var deviceWifiText: TextView
|
||||
private lateinit var deviceDebugText: TextView
|
||||
private val mediaPermissionRequestLauncher by lazy {
|
||||
registerForActivityResult(androidx.activity.result.contract.ActivityResultContracts.RequestPermission()) {
|
||||
refreshStatus()
|
||||
@@ -168,6 +170,20 @@ class AgentStatusFragment : Fragment() {
|
||||
}
|
||||
addView(mediaPermissionAction)
|
||||
}))
|
||||
addView(context.card(context.cardColumn().apply {
|
||||
addView(context.label("设备连接", 14f, context.getColor(R.color.agent_text_muted)))
|
||||
deviceWifiText = context.label("Wi-Fi IP:—", 18f, context.getColor(R.color.agent_text), true).apply {
|
||||
setPadding(0, context.dp(6), 0, 0)
|
||||
}
|
||||
deviceDebugText = context.label("无线调试:—", 18f, context.getColor(R.color.agent_text_muted), true).apply {
|
||||
setPadding(0, context.dp(6), 0, 0)
|
||||
}
|
||||
addView(deviceWifiText)
|
||||
addView(deviceDebugText)
|
||||
addView(context.label(DeviceConnectionPolicy.PORT_HINT, 14f, context.getColor(R.color.agent_text_muted)).apply {
|
||||
setPadding(0, context.dp(6), 0, 0)
|
||||
})
|
||||
}))
|
||||
}
|
||||
refreshLayout = SwipeRefreshLayout(context).apply {
|
||||
setColorSchemeResources(R.color.agent_primary_light)
|
||||
@@ -245,6 +261,10 @@ class AgentStatusFragment : Fragment() {
|
||||
private fun refreshStatus() {
|
||||
if (!isAdded || view == null) return
|
||||
val context = requireContext()
|
||||
val deviceConnection = DeviceConnectionPolicy.display(DeviceConnectionReader.read(context))
|
||||
deviceWifiText.text = deviceConnection.wifiText
|
||||
deviceDebugText.text = deviceConnection.debugText
|
||||
deviceDebugText.setTextColor(context.getColor(deviceConnection.debugColor))
|
||||
val state = stateStore.read()
|
||||
val connected = state.code in setOf("ONLINE", "BUSY", "COLLECTION_COOLDOWN")
|
||||
connectionTitle.text = when (state.code) {
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
package cn.ilapage.goauto.agent.ui
|
||||
|
||||
import android.content.Context
|
||||
import android.net.ConnectivityManager
|
||||
import android.net.NetworkCapabilities
|
||||
import android.provider.Settings
|
||||
import androidx.annotation.ColorRes
|
||||
import cn.ilapage.goauto.agent.R
|
||||
import java.net.Inet4Address
|
||||
import java.net.InetAddress
|
||||
|
||||
/** 无线调试开关的读取结果;读取失败为 [UNKNOWN]。 */
|
||||
internal enum class WirelessDebugState { ENABLED, DISABLED, UNKNOWN }
|
||||
|
||||
internal data class DeviceConnectionReading(
|
||||
val wifiIpv4: String?,
|
||||
val wirelessDebug: WirelessDebugState,
|
||||
)
|
||||
|
||||
internal data class DeviceConnectionDisplay(
|
||||
val wifiText: String,
|
||||
val debugText: String,
|
||||
@ColorRes val debugColor: Int,
|
||||
)
|
||||
|
||||
/** 原始读数到显示文案/颜色的纯函数策略,不依赖 Android 系统服务。 */
|
||||
internal object DeviceConnectionPolicy {
|
||||
const val PORT_HINT = "端口请在 开发者选项 → 无线调试 中查看"
|
||||
const val NO_WIFI_TEXT = "未连接 Wi-Fi"
|
||||
|
||||
/** 仅当前网络为 Wi-Fi 时,从地址列表中取第一个非回环 IPv4;否则返回 null。 */
|
||||
fun selectWifiIpv4(isWifi: Boolean, addresses: List<InetAddress>): String? {
|
||||
if (!isWifi) return null
|
||||
return addresses.firstOrNull { it is Inet4Address && !it.isLoopbackAddress }?.hostAddress
|
||||
}
|
||||
|
||||
/** adb_wifi_enabled 原始值:1 开启,0 未开启,其他值或读取失败(null)为未知。 */
|
||||
fun debugState(raw: Int?): WirelessDebugState = when (raw) {
|
||||
1 -> WirelessDebugState.ENABLED
|
||||
0 -> WirelessDebugState.DISABLED
|
||||
else -> WirelessDebugState.UNKNOWN
|
||||
}
|
||||
|
||||
fun display(reading: DeviceConnectionReading): DeviceConnectionDisplay {
|
||||
val (text, color) = when (reading.wirelessDebug) {
|
||||
WirelessDebugState.ENABLED -> "已开启" to R.color.agent_primary_light
|
||||
WirelessDebugState.DISABLED -> "未开启" to R.color.agent_warning
|
||||
WirelessDebugState.UNKNOWN -> "无法读取" to R.color.agent_text_muted
|
||||
}
|
||||
return DeviceConnectionDisplay(
|
||||
wifiText = "Wi-Fi IP:${reading.wifiIpv4 ?: NO_WIFI_TEXT}",
|
||||
debugText = "无线调试:$text",
|
||||
debugColor = color,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** 系统读取的薄封装;任何异常都降级为「未知」,不记录日志、不持久化。 */
|
||||
internal object DeviceConnectionReader {
|
||||
fun read(context: Context): DeviceConnectionReading = DeviceConnectionReading(
|
||||
wifiIpv4 = readWifiIpv4(context),
|
||||
wirelessDebug = DeviceConnectionPolicy.debugState(readDebugRaw(context)),
|
||||
)
|
||||
|
||||
private fun readWifiIpv4(context: Context): String? = try {
|
||||
val manager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager
|
||||
val network = manager?.activeNetwork
|
||||
val isWifi = network?.let { manager.getNetworkCapabilities(it) }
|
||||
?.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) == true
|
||||
val addresses = if (isWifi && manager != null && network != null) {
|
||||
manager.getLinkProperties(network)?.linkAddresses?.map { it.address }.orEmpty()
|
||||
} else {
|
||||
emptyList()
|
||||
}
|
||||
DeviceConnectionPolicy.selectWifiIpv4(isWifi, addresses)
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
|
||||
private fun readDebugRaw(context: Context): Int? = try {
|
||||
Settings.Global.getInt(context.contentResolver, "adb_wifi_enabled", 0)
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
@@ -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 -> "未连接 · 同步暂未完成"
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,7 @@ import android.widget.HorizontalScrollView
|
||||
import android.widget.ImageView
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.ProgressBar
|
||||
import android.widget.TextView
|
||||
import android.widget.Toast
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
|
||||
@@ -37,10 +38,13 @@ 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
|
||||
import cn.ilapage.goauto.agent.service.CollectionCooldownPolicy
|
||||
import cn.ilapage.goauto.agent.service.PddManualOpenCoordinator
|
||||
import com.google.android.material.button.MaterialButton
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import com.google.android.material.textfield.TextInputEditText
|
||||
@@ -141,13 +145,33 @@ class TaskHistoryFragment : Fragment() {
|
||||
private val imageLoader = HistoryImageLoader()
|
||||
private val imageRequests = mutableListOf<HistoryImageRequest>()
|
||||
private var currentPageReceiverRegistered = false
|
||||
private var openPddButton: MaterialButton? = null
|
||||
private var openPddReason: TextView? = null
|
||||
private var openPddAwaitingResult = false
|
||||
private var backfillPanel: LinearLayout? = null
|
||||
private var repurchasePanel: LinearLayout? = null
|
||||
private var repurchaseButton: MaterialButton? = null
|
||||
private var repurchaseDialog: androidx.appcompat.app.AlertDialog? = null
|
||||
private var repurchaseDialogRound: String? = null
|
||||
private 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_DEVICE_BUSY_STATE) {
|
||||
applyOpenPddBusy()
|
||||
return
|
||||
}
|
||||
if (intent?.action == AgentForegroundService.ACTION_OPEN_PDD_RESULT) {
|
||||
if (!openPddAwaitingResult) return
|
||||
openPddAwaitingResult = false
|
||||
val message = intent.getStringExtra(AgentForegroundService.EXTRA_OPEN_PDD_MESSAGE).orEmpty()
|
||||
if (message.isNotBlank() && isAdded) Toast.makeText(requireContext(), message, Toast.LENGTH_LONG).show()
|
||||
return
|
||||
}
|
||||
if (intent?.action == AgentForegroundService.ACTION_REPURCHASE_STATE) {
|
||||
renderRepurchase()
|
||||
if (!collection && isResumed && AgentForegroundService.repurchaseState.phase == cn.ilapage.goauto.agent.service.RepurchasePhase.FINISHED) {
|
||||
@@ -200,6 +224,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 +256,7 @@ class TaskHistoryFragment : Fragment() {
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
renderRejections()
|
||||
renderRepurchase()
|
||||
detailState.taskId?.let { taskId ->
|
||||
if (collection) loadCollectionDetail(taskId) else loadPurchaseDetail(taskId)
|
||||
@@ -235,6 +264,8 @@ class TaskHistoryFragment : Fragment() {
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
rejectionPanel = null
|
||||
displayedRejections = null
|
||||
repurchaseDialog?.setOnDismissListener(null)
|
||||
repurchaseDialog?.dismiss()
|
||||
repurchaseDialog = null
|
||||
@@ -247,6 +278,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()
|
||||
@@ -705,6 +759,53 @@ class TaskHistoryFragment : Fragment() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Manual "open in PDD" entry. Execution goes through the service so it can re-check busy state and
|
||||
* invalidate the purchase probe handoff; the button never taps anything inside PDD.
|
||||
*/
|
||||
private fun openInPddSection(goodsId: String?, purchase: Boolean): View? {
|
||||
openPddButton = null
|
||||
openPddReason = null
|
||||
if (PddManualOpenCoordinator.link(goodsId) == null) return null
|
||||
val context = requireContext()
|
||||
val id = goodsId.orEmpty()
|
||||
val button = MaterialButton(context, null, com.google.android.material.R.attr.materialButtonOutlinedStyle).apply {
|
||||
text = "在拼多多中打开"
|
||||
minimumHeight = context.dp(48)
|
||||
contentDescription = "在拼多多中打开商品 $id"
|
||||
setOnClickListener {
|
||||
val busy = AgentForegroundService.deviceBusyReason
|
||||
if (busy != null) {
|
||||
applyOpenPddBusy()
|
||||
toast("$busy,暂不能打开拼多多")
|
||||
} else {
|
||||
openPddAwaitingResult = true
|
||||
runCatching { AgentForegroundService.openPddProduct(requireContext(), id, purchase) }
|
||||
.onFailure { openPddAwaitingResult = false; toast(PddManualOpenCoordinator.FAILED) }
|
||||
}
|
||||
}
|
||||
}
|
||||
val reason = context.label("", 12f, context.getColor(R.color.agent_text_muted)).apply { visibility = View.GONE }
|
||||
openPddButton = button
|
||||
openPddReason = reason
|
||||
applyOpenPddBusy()
|
||||
return LinearLayout(context).apply {
|
||||
orientation = LinearLayout.VERTICAL
|
||||
setPadding(0, context.dp(4), 0, context.dp(8))
|
||||
addView(button)
|
||||
addView(reason)
|
||||
}
|
||||
}
|
||||
|
||||
private fun applyOpenPddBusy() {
|
||||
val button = openPddButton ?: return
|
||||
val reasonView = openPddReason ?: return
|
||||
val busy = AgentForegroundService.deviceBusyReason
|
||||
button.isEnabled = busy == null
|
||||
reasonView.text = busy.orEmpty()
|
||||
reasonView.visibility = if (busy == null) View.GONE else View.VISIBLE
|
||||
}
|
||||
|
||||
private fun renderCollectionDetail(detail: CollectionHistoryDetail) {
|
||||
val context = requireContext()
|
||||
val task = detail.task
|
||||
@@ -719,6 +820,7 @@ class TaskHistoryFragment : Fragment() {
|
||||
}
|
||||
val detailCard = context.cardColumn().apply {
|
||||
addView(context.label("#${task.taskId} · 第 ${task.attemptNumber} 次 · ${task.goodsId.ifBlank { "未识别商品" }}", 20f, context.getColor(R.color.agent_text), true))
|
||||
openInPddSection(task.goodsId, purchase = false)?.let(::addView)
|
||||
if (task.source == "agent_current_page") addView(context.label("来源:Agent 当前页面", 13f, context.getColor(R.color.agent_text_muted)))
|
||||
addView(context.label(collectionStatus(task.status), 14f, statusColor(task.status)).apply { setPadding(0, context.dp(4), 0, context.dp(12)) })
|
||||
addView(context.label(info, 14f))
|
||||
@@ -811,6 +913,9 @@ 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)
|
||||
filter.addAction(AgentForegroundService.ACTION_DEVICE_BUSY_STATE)
|
||||
filter.addAction(AgentForegroundService.ACTION_OPEN_PDD_RESULT)
|
||||
if (Build.VERSION.SDK_INT >= 33) {
|
||||
requireContext().registerReceiver(currentPageReceiver, filter, Context.RECEIVER_NOT_EXPORTED)
|
||||
} else {
|
||||
@@ -893,9 +998,13 @@ 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 infoHead = "蝦皮订单号:${task.shopeeOrderNo.ifBlank { "—" }}\nPDD 商品:${task.pddGoodsId}"
|
||||
val info = buildString {
|
||||
append("蝦皮订单号:${task.shopeeOrderNo.ifBlank { "—" }}\n")
|
||||
append("PDD 商品:${task.pddGoodsId}\n")
|
||||
append("商品标题:${task.pddTitle.ifBlank { "—" }}\n")
|
||||
append("目标规格:${listOf(task.targetColor, task.targetSize).filter(String::isNotBlank).joinToString(" / ").ifBlank { "—" }}\n")
|
||||
append("执行规格:${listOf(task.mappedColor, task.mappedSize).filter(String::isNotBlank).joinToString(" / ").ifBlank { "—" }}\n")
|
||||
@@ -913,6 +1022,8 @@ class TaskHistoryFragment : Fragment() {
|
||||
resultColumn.addView(context.card(context.cardColumn().apply {
|
||||
addView(context.label("CG-${task.taskId}", 20f, context.getColor(R.color.agent_text), true))
|
||||
addView(context.label(purchaseStatus(task.status), 14f, statusColor(task.status)).apply { setPadding(0, context.dp(4), 0, context.dp(12)) })
|
||||
addView(context.label(infoHead, 14f))
|
||||
openInPddSection(task.pddGoodsId, purchase = true)?.let(::addView)
|
||||
addView(context.label(info, 14f))
|
||||
}), collectionCardParams())
|
||||
val lastFailure = detail.lastFailureMessage?.takeIf(String::isNotBlank)
|
||||
|
||||
@@ -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,63 @@
|
||||
package cn.ilapage.goauto.agent
|
||||
|
||||
import cn.ilapage.goauto.agent.ui.DeviceConnectionPolicy
|
||||
import cn.ilapage.goauto.agent.ui.DeviceConnectionReading
|
||||
import cn.ilapage.goauto.agent.ui.WirelessDebugState
|
||||
import java.net.InetAddress
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
|
||||
class DeviceConnectionPolicyTest {
|
||||
private fun ip(text: String) = InetAddress.getByName(text)
|
||||
|
||||
@Test fun wifiWithIpv4ReturnsAddress() {
|
||||
assertEquals("192.168.0.93", DeviceConnectionPolicy.selectWifiIpv4(true, listOf(ip("192.168.0.93"))))
|
||||
}
|
||||
|
||||
@Test fun nonWifiReturnsNull() {
|
||||
assertNull(DeviceConnectionPolicy.selectWifiIpv4(false, listOf(ip("10.0.0.2"))))
|
||||
}
|
||||
|
||||
@Test fun wifiWithOnlyIpv6ReturnsNull() {
|
||||
assertNull(DeviceConnectionPolicy.selectWifiIpv4(true, listOf(ip("2001:db8::1"))))
|
||||
}
|
||||
|
||||
@Test fun wifiWithMultipleAddressesPicksIpv4() {
|
||||
assertEquals(
|
||||
"172.16.1.5",
|
||||
DeviceConnectionPolicy.selectWifiIpv4(true, listOf(ip("2001:db8::1"), ip("172.16.1.5"))),
|
||||
)
|
||||
}
|
||||
|
||||
@Test fun loopbackIpv4IsSkipped() {
|
||||
assertNull(DeviceConnectionPolicy.selectWifiIpv4(true, listOf(ip("127.0.0.1"))))
|
||||
}
|
||||
|
||||
@Test fun debugRawValuesMapToState() {
|
||||
assertEquals(WirelessDebugState.ENABLED, DeviceConnectionPolicy.debugState(1))
|
||||
assertEquals(WirelessDebugState.DISABLED, DeviceConnectionPolicy.debugState(0))
|
||||
assertEquals(WirelessDebugState.UNKNOWN, DeviceConnectionPolicy.debugState(2))
|
||||
assertEquals(WirelessDebugState.UNKNOWN, DeviceConnectionPolicy.debugState(null))
|
||||
}
|
||||
|
||||
@Test fun displayEnabledUsesPrimaryColor() {
|
||||
val d = DeviceConnectionPolicy.display(DeviceConnectionReading("192.168.0.93", WirelessDebugState.ENABLED))
|
||||
assertEquals("Wi-Fi IP:192.168.0.93", d.wifiText)
|
||||
assertEquals("无线调试:已开启", d.debugText)
|
||||
assertEquals(R.color.agent_primary_light, d.debugColor)
|
||||
}
|
||||
|
||||
@Test fun displayDisabledUsesWarningColorAndNoWifiText() {
|
||||
val d = DeviceConnectionPolicy.display(DeviceConnectionReading(null, WirelessDebugState.DISABLED))
|
||||
assertEquals("Wi-Fi IP:未连接 Wi-Fi", d.wifiText)
|
||||
assertEquals("无线调试:未开启", d.debugText)
|
||||
assertEquals(R.color.agent_warning, d.debugColor)
|
||||
}
|
||||
|
||||
@Test fun displayUnknownUsesMutedColor() {
|
||||
val d = DeviceConnectionPolicy.display(DeviceConnectionReading(null, WirelessDebugState.UNKNOWN))
|
||||
assertEquals("无线调试:无法读取", d.debugText)
|
||||
assertEquals(R.color.agent_text_muted, d.debugColor)
|
||||
}
|
||||
}
|
||||
+315
@@ -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") }))
|
||||
}
|
||||
|
||||
+794
-16
@@ -9,6 +9,7 @@ import cn.ilapage.goauto.agent.automation.NodeSelector
|
||||
import cn.ilapage.goauto.agent.automation.PageEvidence
|
||||
import cn.ilapage.goauto.agent.automation.PddCollectorConfig
|
||||
import cn.ilapage.goauto.agent.automation.PddCollectorDriver
|
||||
import cn.ilapage.goauto.agent.automation.PddCollectorResult
|
||||
import cn.ilapage.goauto.agent.automation.PddProductDetailCollector
|
||||
import cn.ilapage.goauto.agent.automation.PddScreenParser
|
||||
import cn.ilapage.goauto.agent.automation.PurchasePanelContext
|
||||
@@ -1105,10 +1106,50 @@ class PddProductDetailCollectorTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `duplicate sizes after price cleanup reject the whole size dimension`() {
|
||||
fun `same-frame sizes with one normalized name keep a single value and mark conflict`() {
|
||||
val driver = FakeCollectorDriver(
|
||||
colors = listOf("黑色"),
|
||||
sizePages = listOf(listOf("XL ¥10", "XL ¥12")),
|
||||
sizePages = listOf(listOf("XL ¥10", "XL ¥12", "S")),
|
||||
prices = mapOf("黑色" to 1000L),
|
||||
)
|
||||
var clock = 0L
|
||||
|
||||
val result = PddProductDetailCollector(driver, { clock }, { clock += it }).collect(GOODS_ID, rule())
|
||||
|
||||
assertTrue(result.successful)
|
||||
val payload = requireNotNull(result.payload)
|
||||
assertEquals("completed_partial", payload.status)
|
||||
assertEquals(listOf("XL", "S"), payload.dimensions.first { it.key == "size" }.values)
|
||||
assertEquals(1, payload.missing.count { it == "spec_dimension_conflict:size" })
|
||||
assertFalse(payload.missing.contains("spec_dimension_invalid:size"))
|
||||
assertFalse(payload.missing.contains("size"))
|
||||
assertEquals(listOf("XL", "S"), payload.skus.map { it.specs.getValue("size") })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `size price appearing between passes keeps the dimension and marks conflict once`() {
|
||||
val driver = FakeCollectorDriver(
|
||||
colors = listOf("黑色"),
|
||||
sizePages = listOf(listOf("XL", "S"), listOf("XL ¥29.9", "M")),
|
||||
prices = mapOf("黑色" to 1000L),
|
||||
)
|
||||
var clock = 0L
|
||||
|
||||
val result = PddProductDetailCollector(driver, { clock }, { clock += it }).collect(GOODS_ID, rule())
|
||||
|
||||
assertTrue(result.successful)
|
||||
val payload = requireNotNull(result.payload)
|
||||
assertEquals("completed_partial", payload.status)
|
||||
assertEquals(listOf("XL", "S", "M"), payload.dimensions.first { it.key == "size" }.values)
|
||||
assertEquals(1, payload.missing.count { it == "spec_dimension_conflict:size" })
|
||||
assertFalse(payload.missing.contains("spec_dimension_invalid:size"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `residual currency in a size still rejects the whole size dimension`() {
|
||||
val driver = FakeCollectorDriver(
|
||||
colors = listOf("黑色"),
|
||||
sizePages = listOf(listOf("XL ¥10 热卖", "S")),
|
||||
prices = mapOf("黑色" to 1000L),
|
||||
)
|
||||
var clock = 0L
|
||||
@@ -1120,9 +1161,151 @@ class PddProductDetailCollectorTest {
|
||||
assertEquals("completed_partial", payload.status)
|
||||
assertTrue(payload.dimensions.none { it.key == "size" })
|
||||
assertTrue(payload.missing.contains("spec_dimension_invalid:size"))
|
||||
assertFalse(payload.missing.contains("spec_dimension_conflict:size"))
|
||||
assertTrue(payload.skus.all { "size" !in it.specs })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an empty normalized size still rejects the whole size dimension`() {
|
||||
val driver = FakeCollectorDriver(
|
||||
colors = listOf("黑色"),
|
||||
sizePages = listOf(listOf("¥10", "S")),
|
||||
prices = mapOf("黑色" to 1000L),
|
||||
)
|
||||
var clock = 0L
|
||||
|
||||
val payload = requireNotNull(
|
||||
PddProductDetailCollector(driver, { clock }, { clock += it }).collect(GOODS_ID, rule()).payload,
|
||||
)
|
||||
|
||||
assertTrue(payload.dimensions.none { it.key == "size" })
|
||||
assertTrue(payload.missing.contains("spec_dimension_invalid:size"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `continuation sizes drop the trailing price like the parsed path`() {
|
||||
val driver = FakeCollectorDriver(
|
||||
colors = listOf("红色"),
|
||||
sizePages = listOf(listOf("S", "M"), listOf("L ¥10", "XL")),
|
||||
hideDimensionHeadingsAfterFirstVerticalPage = true,
|
||||
)
|
||||
var clock = 0L
|
||||
|
||||
val result = PddProductDetailCollector(driver, { clock }, { clock += it }).collect(GOODS_ID, rule())
|
||||
|
||||
assertTrue(result.successful)
|
||||
val payload = requireNotNull(result.payload)
|
||||
assertEquals(listOf("S", "M", "L", "XL"), payload.dimensions.first { it.key == "size" }.values)
|
||||
assertTrue(payload.missing.none { it.startsWith("spec_dimension_") })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `continuation conflict is seen before de-duplication and keeps one value`() {
|
||||
val driver = FakeCollectorDriver(
|
||||
colors = listOf("红色"),
|
||||
sizePages = listOf(listOf("S", "M"), listOf("XL ¥10", "XL", "L")),
|
||||
hideDimensionHeadingsAfterFirstVerticalPage = true,
|
||||
)
|
||||
var clock = 0L
|
||||
|
||||
val result = PddProductDetailCollector(driver, { clock }, { clock += it }).collect(GOODS_ID, rule())
|
||||
|
||||
assertTrue(result.successful)
|
||||
val payload = requireNotNull(result.payload)
|
||||
assertEquals(listOf("S", "M", "XL", "L"), payload.dimensions.first { it.key == "size" }.values)
|
||||
assertEquals(1, payload.missing.count { it == "spec_dimension_conflict:size" })
|
||||
assertFalse(payload.missing.contains("spec_dimension_invalid:size"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `continuation size with residual currency rejects the whole size dimension`() {
|
||||
val driver = FakeCollectorDriver(
|
||||
colors = listOf("红色"),
|
||||
sizePages = listOf(listOf("S", "M"), listOf("L ¥10 热卖", "XL")),
|
||||
hideDimensionHeadingsAfterFirstVerticalPage = true,
|
||||
)
|
||||
var clock = 0L
|
||||
|
||||
val payload = requireNotNull(
|
||||
PddProductDetailCollector(driver, { clock }, { clock += it }).collect(GOODS_ID, rule()).payload,
|
||||
)
|
||||
|
||||
assertTrue(payload.dimensions.none { it.key == "size" })
|
||||
assertTrue(payload.missing.contains("spec_dimension_invalid:size"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `normalization conflict trace carries only counts and enum values`() {
|
||||
val traces = mutableListOf<String>()
|
||||
val driver = FakeCollectorDriver(
|
||||
colors = listOf("黑色"),
|
||||
sizePages = listOf(listOf("XL", "S"), listOf("XL ¥29.9", "M")),
|
||||
prices = mapOf("黑色" to 1000L),
|
||||
)
|
||||
var clock = 0L
|
||||
|
||||
PddProductDetailCollector(driver, { clock }, { clock += it }, trace = traces::add).collect(GOODS_ID, rule())
|
||||
|
||||
val lines = traces.filter { it.startsWith("spec-normalize ") }
|
||||
assertEquals(1, lines.size)
|
||||
assertEquals(
|
||||
"spec-normalize dim=size outcome=conflict_kept reason=normalized_conflict path=parsed pass=1 sameFrame=false " +
|
||||
"onlyTrailingPriceDiff=true rawVariants=2 candidates=2 badgeCaptionMerged=0",
|
||||
lines.single(),
|
||||
)
|
||||
assertTrue(lines.none { it.contains("XL") || it.contains("¥") })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `normalization invalid trace reports reason without node text`() {
|
||||
val traces = mutableListOf<String>()
|
||||
val driver = FakeCollectorDriver(
|
||||
colors = listOf("黑色"),
|
||||
sizePages = listOf(listOf("XL ¥10 热卖", "S")),
|
||||
prices = mapOf("黑色" to 1000L),
|
||||
)
|
||||
var clock = 0L
|
||||
|
||||
PddProductDetailCollector(driver, { clock }, { clock += it }, trace = traces::add).collect(GOODS_ID, rule())
|
||||
|
||||
val line = traces.single { it.startsWith("spec-normalize ") }
|
||||
assertTrue(line.contains("dim=size outcome=invalid_rejected reason=unsafe_currency path=parsed"))
|
||||
assertTrue(line.contains("candidates=2"))
|
||||
assertTrue(!line.contains("热卖") && !line.contains("XL"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `same-frame color conflict trace flags sameFrame and badge count`() {
|
||||
val traces = mutableListOf<String>()
|
||||
val driver = FakeCollectorDriver(
|
||||
colors = listOf("红色 ¥10", "红色 ¥12"),
|
||||
prices = mapOf("红色 ¥10" to 1000L),
|
||||
imageColorCards = true,
|
||||
)
|
||||
var clock = 0L
|
||||
|
||||
PddProductDetailCollector(driver, { clock }, { clock += it }, trace = traces::add).collect(GOODS_ID, rule())
|
||||
|
||||
val line = traces.first { it.startsWith("spec-normalize dim=color") }
|
||||
assertTrue(line.contains("outcome=conflict_kept"))
|
||||
assertTrue(line.contains("sameFrame=true"))
|
||||
assertTrue(line.contains("onlyTrailingPriceDiff=true"))
|
||||
assertTrue(line.contains("rawVariants=2"))
|
||||
assertTrue(line.endsWith("badgeCaptionMerged=0"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parser counts merged badge captions without exposing text`() {
|
||||
val parsed = PddScreenParser.parse(badgeCardsSnapshot(purchase1Cards()), config(), GOODS_ID, evidence())
|
||||
assertEquals(2, parsed.badgeCaptionMerged)
|
||||
|
||||
val plain = PddScreenParser.parse(
|
||||
badgeCardsSnapshot(listOf(badgeCard(3, 36, "黑色 微喇夏天薄款长裤 ¥9.8", "黑色 微喇夏天薄款长裤 ¥9.8"))),
|
||||
config(), GOODS_ID, evidence(),
|
||||
)
|
||||
assertEquals(0, plain.badgeCaptionMerged)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `trailing color prices are normalized across dimension price and sku`() {
|
||||
val driver = FakeCollectorDriver(
|
||||
@@ -1143,10 +1326,32 @@ class PddProductDetailCollectorTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `duplicate colors after price cleanup reject color dependents`() {
|
||||
fun `duplicate colors after price cleanup keep one value and mark conflict`() {
|
||||
val driver = FakeCollectorDriver(
|
||||
colors = listOf("红色 ¥10", "红色 ¥12"),
|
||||
prices = mapOf("红色 ¥10" to 1000L, "红色 ¥12" to 1200L),
|
||||
colors = listOf("红色 ¥10", "红色 ¥12", "蓝色"),
|
||||
prices = mapOf("红色 ¥10" to 1000L, "红色 ¥12" to 1200L, "蓝色" to 1500L),
|
||||
imageColorCards = true,
|
||||
)
|
||||
var clock = 0L
|
||||
|
||||
val result = PddProductDetailCollector(driver, { clock }, { clock += it }).collect(GOODS_ID, rule())
|
||||
|
||||
assertTrue(result.successful)
|
||||
val payload = requireNotNull(result.payload)
|
||||
assertEquals("completed_partial", payload.status)
|
||||
assertEquals(listOf("红色", "蓝色"), payload.dimensions.first { it.key == "color" }.values)
|
||||
assertEquals(listOf("红色", "蓝色"), payload.colorPrices.map { it.color })
|
||||
assertEquals(1000L, payload.colorPrices.first { it.color == "红色" }.priceCent)
|
||||
assertEquals(1, payload.missing.count { it == "spec_dimension_conflict:color" })
|
||||
assertFalse(payload.missing.contains("spec_dimension_invalid:color"))
|
||||
assertEquals(listOf("红色 ¥10", "蓝色"), driver.clickedLabels)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `residual currency in a color still rejects color dependents`() {
|
||||
val driver = FakeCollectorDriver(
|
||||
colors = listOf("红色 ¥10 热卖", "蓝色"),
|
||||
prices = mapOf("红色 ¥10 热卖" to 1000L, "蓝色" to 1500L),
|
||||
imageColorCards = true,
|
||||
)
|
||||
var clock = 0L
|
||||
@@ -1160,6 +1365,7 @@ class PddProductDetailCollectorTest {
|
||||
assertTrue(payload.colorPrices.isEmpty())
|
||||
assertTrue(payload.skus.isEmpty())
|
||||
assertTrue(payload.missing.contains("spec_dimension_invalid:color"))
|
||||
assertFalse(payload.missing.contains("spec_dimension_conflict:color"))
|
||||
assertTrue(result.colorImages.isEmpty())
|
||||
}
|
||||
|
||||
@@ -1212,7 +1418,7 @@ class PddProductDetailCollectorTest {
|
||||
)
|
||||
var clock = 0L
|
||||
|
||||
val result = PddProductDetailCollector(driver, { clock }, { clock += it }).collect(GOODS_ID, rule())
|
||||
val result = PddProductDetailCollector(driver, { clock }, { clock += it }).collect(GOODS_ID, rule(config().copy(timeoutsMs = config().timeoutsMs + ("overall" to 60_000))))
|
||||
|
||||
assertTrue(result.successful)
|
||||
assertEquals(colors, requireNotNull(result.payload).dimensions.first { it.key == "color" }.values)
|
||||
@@ -2092,6 +2298,325 @@ class PddProductDetailCollectorTest {
|
||||
}
|
||||
}
|
||||
|
||||
private fun gridColors(count: Int) = (1..count).map { "款%02d色".format(it) }
|
||||
|
||||
private fun gridConfig() = config().copy(
|
||||
timeoutsMs = config().timeoutsMs + ("overall" to 600_000),
|
||||
limits = config().limits + mapOf("specHorizontalSwipes" to 12, "specVerticalSwipes" to 3, "stableEdgeReads" to 2),
|
||||
)
|
||||
|
||||
private fun sharedGridCollects47Colors(step: Int) {
|
||||
// 47 colors in 2 rows x 24 columns sharing one horizontal container.
|
||||
val colors = gridColors(47)
|
||||
val grid = listOf(colors.filterIndexed { i, _ -> i % 2 == 0 }, colors.filterIndexed { i, _ -> i % 2 == 1 })
|
||||
assertEquals(listOf(24, 23), grid.map { it.size })
|
||||
val prices = colors.mapIndexed { i, c -> c to 1000L + i * 10L }.toMap()
|
||||
val events = mutableListOf<AgentDiagnosticEvent>()
|
||||
val driver = FakeCollectorDriver(
|
||||
colors = colors,
|
||||
prices = prices,
|
||||
horizontalGrid = grid,
|
||||
gridVisibleColumns = 4,
|
||||
gridStep = step,
|
||||
sizePages = listOf(listOf("S"), listOf("M")),
|
||||
hideColorHeadingAfterFirstVerticalPage = true,
|
||||
)
|
||||
var clock = 0L
|
||||
|
||||
val result = PddProductDetailCollector(driver, { clock }, { clock += it }, taskId = 370, diagnostic = events::add)
|
||||
.collect(GOODS_ID, rule(gridConfig()))
|
||||
|
||||
assertTrue(result.successful)
|
||||
val payload = requireNotNull(result.payload)
|
||||
assertEquals("completed", payload.status)
|
||||
assertEquals(colors.toSet(), payload.dimensions.first { it.key == "color" }.values.toSet())
|
||||
assertEquals(47, payload.colorPrices.size)
|
||||
assertEquals(prices, payload.colorPrices.associate { it.color to it.priceCent })
|
||||
// 47 colors x 2 sizes, each SKU carrying its own color's price.
|
||||
assertEquals(94, payload.skus.size)
|
||||
assertEquals(setOf("S", "M"), payload.skus.map { it.specs.getValue("size") }.toSet())
|
||||
payload.skus.forEach { assertEquals(prices.getValue(it.specs.getValue("color")), it.priceCent) }
|
||||
assertTrue(payload.missing.isEmpty())
|
||||
// The shared container is swept once: no reverse sweep away from an edge.
|
||||
assertTrue(driver.gridSwipeLog.none { it.first == SwipeDirection.RIGHT && it.second > 0 })
|
||||
assertTrue(driver.gridSwipeLog.count { it.first == SwipeDirection.LEFT } <= 12)
|
||||
val event = events.single { it.stage == AgentDiagnosticStage.COLOR_DISCOVERY }
|
||||
assertEquals(47, event.clickableColorCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `shared container two row grid collects all colors when swipes overlap`() = sharedGridCollects47Colors(step = 3)
|
||||
|
||||
@Test
|
||||
fun `shared container two row grid collects all colors when swipes move whole pages`() = sharedGridCollects47Colors(step = 4)
|
||||
|
||||
private fun sharedPixelGridCollects(count: Int, step: Int, maxSwipes: Int, jitter: Boolean = false) {
|
||||
// Shared container, ~3.5 columns per screen, pixel-sized swipes whose
|
||||
// last move is shorter than one column; partly visible columns count.
|
||||
val colors = gridColors(count)
|
||||
val grid = listOf(colors.filterIndexed { i, _ -> i % 2 == 0 }, colors.filterIndexed { i, _ -> i % 2 == 1 })
|
||||
val prices = colors.mapIndexed { i, c -> c to 1000L + i * 10L }.toMap()
|
||||
val driver = FakeCollectorDriver(
|
||||
colors = colors,
|
||||
prices = prices,
|
||||
horizontalGrid = grid,
|
||||
gridPixelStep = step,
|
||||
gridClickJitter = jitter,
|
||||
sizePages = listOf(listOf("S"), listOf("M")),
|
||||
hideColorHeadingAfterFirstVerticalPage = true,
|
||||
)
|
||||
var clock = 0L
|
||||
|
||||
val result = PddProductDetailCollector(driver, { clock }, { clock += it }, taskId = 370)
|
||||
.collect(GOODS_ID, rule(gridConfig()))
|
||||
|
||||
assertTrue(result.successful)
|
||||
val payload = requireNotNull(result.payload)
|
||||
assertEquals("completed", payload.status)
|
||||
assertEquals(colors.toSet(), payload.dimensions.first { it.key == "color" }.values.toSet())
|
||||
assertEquals(prices, payload.colorPrices.associate { it.color to it.priceCent })
|
||||
assertTrue(payload.missing.isEmpty())
|
||||
assertTrue(driver.gridSwipeLog.none { it.first == SwipeDirection.RIGHT && it.second > 0 })
|
||||
val horizontal = driver.gridSwipeLog.size
|
||||
assertTrue("horizontal swipes $horizontal > $maxSwipes ${driver.gridSwipeLog}", horizontal <= maxSwipes)
|
||||
assertEquals(count, payload.colorPrices.size)
|
||||
assertEquals(count * 2, payload.skus.size)
|
||||
payload.skus.forEach { assertEquals(prices.getValue(it.specs.getValue("color")), it.priceCent) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `shared container with click layout jitter still skips the reverse sweep`() =
|
||||
sharedPixelGridCollects(count = 47, step = 640, maxSwipes = 12, jitter = true)
|
||||
|
||||
@Test
|
||||
fun `shared container nine colors with click layout jitter does not sweep back`() =
|
||||
sharedPixelGridCollects(count = 9, step = 200, maxSwipes = 6, jitter = true)
|
||||
|
||||
private fun midStartGrid(
|
||||
failAttempts: Set<Int>,
|
||||
initialOffset: Int = 2000,
|
||||
traces: MutableList<String> = mutableListOf(),
|
||||
captureCostMillis: Long = 0L,
|
||||
elapsed: LongArray = LongArray(1),
|
||||
): Pair<FakeCollectorDriver, PddCollectorResult> {
|
||||
val colors = gridColors(47)
|
||||
val grid = listOf(colors.filterIndexed { i, _ -> i % 2 == 0 }, colors.filterIndexed { i, _ -> i % 2 == 1 })
|
||||
val prices = colors.mapIndexed { i, c -> c to 1000L + i * 10L }.toMap()
|
||||
var clock = 0L
|
||||
val driver = FakeCollectorDriver(
|
||||
colors = colors,
|
||||
prices = prices,
|
||||
horizontalGrid = grid,
|
||||
gridPixelStep = 640,
|
||||
captureCostMillis = captureCostMillis,
|
||||
captureAdvance = { clock += it },
|
||||
gridInitialOffset = initialOffset,
|
||||
gridRightFailAttempts = failAttempts,
|
||||
sizePages = listOf(listOf("S"), listOf("M")),
|
||||
hideColorHeadingAfterFirstVerticalPage = true,
|
||||
)
|
||||
val result = PddProductDetailCollector(driver, { clock }, { clock += it }, trace = traces::add, taskId = 370)
|
||||
.collect(GOODS_ID, rule(gridConfig()))
|
||||
elapsed[0] = clock
|
||||
return driver to result
|
||||
}
|
||||
|
||||
private fun assertAll47Collected(result: PddCollectorResult) {
|
||||
assertTrue(result.successful)
|
||||
val payload = requireNotNull(result.payload)
|
||||
assertEquals("completed", payload.status)
|
||||
val collected = payload.dimensions.first { it.key == "color" }.values
|
||||
assertEquals("collected ${collected.size}/47 colors", gridColors(47).toSet(), collected.toSet())
|
||||
assertEquals(47, payload.colorPrices.size)
|
||||
assertTrue(payload.missing.isEmpty())
|
||||
}
|
||||
|
||||
private class SettleRun(
|
||||
val driver: FakeCollectorDriver,
|
||||
val result: PddCollectorResult,
|
||||
val traces: List<String>,
|
||||
val elapsedMs: Long,
|
||||
)
|
||||
|
||||
// The grid keeps coasting until HORIZONTAL_SETTLE_MILLIS-ish of simulated
|
||||
// clock time has passed since the last movement (timeBased), mirroring the
|
||||
// ~1-1.3s measured on a device.
|
||||
private fun inertiaGrid(timeBased: Boolean): SettleRun {
|
||||
val colors = gridColors(47)
|
||||
val grid = listOf(colors.filterIndexed { i, _ -> i % 2 == 0 }, colors.filterIndexed { i, _ -> i % 2 == 1 })
|
||||
val prices = colors.mapIndexed { i, c -> c to 1000L + i * 10L }.toMap()
|
||||
var clock = 0L
|
||||
val driver = FakeCollectorDriver(
|
||||
colors = colors,
|
||||
prices = prices,
|
||||
horizontalGrid = grid,
|
||||
gridPixelStep = 640,
|
||||
gridSettleClock = if (timeBased) ({ clock }) else null,
|
||||
gridSettleMillis = if (timeBased) 1000L else 0L,
|
||||
gridStrictClickPosition = true,
|
||||
sizePages = listOf(listOf("S"), listOf("M")),
|
||||
hideColorHeadingAfterFirstVerticalPage = true,
|
||||
)
|
||||
val traces = mutableListOf<String>()
|
||||
val result = PddProductDetailCollector(driver, { clock }, { clock += it }, trace = traces::add, taskId = 370)
|
||||
.collect(GOODS_ID, rule(gridConfig()))
|
||||
return SettleRun(driver, result, traces, clock)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `color grid waits a fixed time after a horizontal swipe without extra parses`() {
|
||||
val calm = inertiaGrid(timeBased = false)
|
||||
val coasting = inertiaGrid(timeBased = true)
|
||||
println("SIM_ELAPSED_47_COLORS=${coasting.elapsedMs}ms settles=${coasting.traces.count { it.startsWith("color-settle") }}")
|
||||
|
||||
assertAll47Collected(coasting.result)
|
||||
assertTrue(coasting.traces.none { it.startsWith("color-click-miss") })
|
||||
val settles = coasting.traces.filter { it.startsWith("color-settle") }
|
||||
assertTrue(settles.isNotEmpty())
|
||||
settles.forEach { assertEquals("color-settle waitedMs=1200", it) }
|
||||
// No polling reads: the same captures and clicks as a grid that never coasts.
|
||||
assertEquals(calm.driver.captureCount, coasting.driver.captureCount)
|
||||
assertEquals(calm.driver.clickCount, coasting.driver.clickCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `reset from the right end with scattered anchor failures still confirms the left end`() {
|
||||
val traces = mutableListOf<String>()
|
||||
val elapsed = LongArray(1)
|
||||
val (driver, result) = midStartGrid(
|
||||
failAttempts = setOf(1, 3, 5, 7),
|
||||
initialOffset = 4675,
|
||||
traces = traces,
|
||||
captureCostMillis = 900L,
|
||||
elapsed = elapsed,
|
||||
)
|
||||
println("SIM_ELAPSED_RIGHT_START=${elapsed[0]}ms")
|
||||
assertTrue("elapsed ${elapsed[0]}", elapsed[0] < BASELINE_RIGHT_START_ELAPSED_MS * 7 / 10)
|
||||
|
||||
assertAll47Collected(result)
|
||||
assertTrue(traces.toString(), traces.any { it.startsWith("color-start confirmed=true") && it.contains("anchorRetries=4") })
|
||||
val lastRightWithOffset = driver.gridSwipeLog.indexOfLast { it.first == SwipeDirection.RIGHT && it.second > 0 }
|
||||
val firstLeft = driver.gridSwipeLog.indexOfFirst { it.first == SwipeDirection.LEFT }
|
||||
assertTrue("no reverse sweep after the first LEFT", lastRightWithOffset < firstLeft)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `mid start with one anchor failure while resetting retries and still skips the reverse sweep`() {
|
||||
val (driver, result) = midStartGrid(failAttempts = setOf(2))
|
||||
|
||||
assertAll47Collected(result)
|
||||
// The retry used a different color node than the failed attempt.
|
||||
val rights = driver.swipes.filter { it.first == SwipeDirection.RIGHT }
|
||||
assertTrue(rights[0].second?.label != rights[1].second?.label)
|
||||
// Reset reached the left edge, then the sweep ran left-to-right once.
|
||||
val lastRightWithOffset = driver.gridSwipeLog.indexOfLast { it.first == SwipeDirection.RIGHT && it.second > 0 }
|
||||
val firstLeft = driver.gridSwipeLog.indexOfFirst { it.first == SwipeDirection.LEFT }
|
||||
assertTrue("no reverse sweep after the first LEFT", lastRightWithOffset < firstLeft)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `mid start whose reset cannot be confirmed keeps the full snake sweep`() {
|
||||
val (driver, result) = midStartGrid(failAttempts = setOf(1, 2, 3))
|
||||
|
||||
assertAll47Collected(result)
|
||||
// Reverse sweep from the right edge happened after the LEFT sweep.
|
||||
val firstLeft = driver.gridSwipeLog.indexOfFirst { it.first == SwipeDirection.LEFT }
|
||||
assertTrue(driver.gridSwipeLog.drop(firstLeft).any { it.first == SwipeDirection.RIGHT && it.second > 0 })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `shared container with a row that fails to follow keeps the full snake sweep`() {
|
||||
val colors = gridColors(47)
|
||||
val grid = listOf(colors.filterIndexed { i, _ -> i % 2 == 0 }, colors.filterIndexed { i, _ -> i % 2 == 1 })
|
||||
val driver = FakeCollectorDriver(
|
||||
colors = colors,
|
||||
horizontalGrid = grid,
|
||||
gridPixelStep = 640,
|
||||
gridSwipeWhereOnlyFirstRowMoves = 3,
|
||||
sizePages = listOf(listOf("S"), listOf("M")),
|
||||
hideColorHeadingAfterFirstVerticalPage = true,
|
||||
)
|
||||
var clock = 0L
|
||||
|
||||
val result = PddProductDetailCollector(driver, { clock }, { clock += it }, taskId = 370)
|
||||
.collect(GOODS_ID, rule(gridConfig()))
|
||||
|
||||
assertTrue(result.successful)
|
||||
assertTrue(driver.gridSwipeLog.any { it.first == SwipeDirection.RIGHT && it.second > 0 })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `shared container nine colors with partial last swipe does not sweep back`() =
|
||||
sharedPixelGridCollects(count = 9, step = 200, maxSwipes = 6)
|
||||
|
||||
@Test
|
||||
fun `shared container forty seven colors with partial last swipe stays within twelve swipes`() =
|
||||
sharedPixelGridCollects(count = 47, step = 640, maxSwipes = 12)
|
||||
|
||||
@Test
|
||||
fun `independent row grid keeps the snake sweep and does not claim a shared container`() {
|
||||
val colors = gridColors(20)
|
||||
val grid = listOf(colors.take(10), colors.drop(10))
|
||||
val driver = FakeCollectorDriver(
|
||||
colors = colors,
|
||||
horizontalGrid = grid,
|
||||
gridVisibleColumns = 4,
|
||||
gridStep = 3,
|
||||
gridSharedContainer = false,
|
||||
sizePages = listOf(listOf("S"), listOf("M")),
|
||||
hideColorHeadingAfterFirstVerticalPage = true,
|
||||
)
|
||||
var clock = 0L
|
||||
|
||||
val result = PddProductDetailCollector(driver, { clock }, { clock += it }).collect(GOODS_ID, rule(gridConfig()))
|
||||
|
||||
assertTrue(result.successful)
|
||||
val collected = requireNotNull(result.payload).dimensions.first { it.key == "color" }.values
|
||||
// Row 0 is swept to its end; row 1 only exposes its first window because
|
||||
// it starts at its own left edge, exactly as before.
|
||||
assertEquals((colors.take(10) + colors.drop(10).take(4)).toSet(), collected.toSet())
|
||||
assertEquals(14, collected.size)
|
||||
assertTrue(driver.gridSwipeLog.any { it.first == SwipeDirection.RIGHT && it.second > 0 })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `row disappearing keeps reflow reason after later empty vertical reads`() {
|
||||
val colors = listOf("A色", "B色", "C色", "D色", "E色", "F色")
|
||||
val events = mutableListOf<AgentDiagnosticEvent>()
|
||||
val driver = FakeCollectorDriver(
|
||||
colors = colors,
|
||||
colorPages = listOf(colors.take(4), colors.takeLast(2)),
|
||||
rowSize = 2,
|
||||
sizePages = listOf(listOf("S"), listOf("M")),
|
||||
hideColorHeadingAfterFirstVerticalPage = true,
|
||||
)
|
||||
var clock = 0L
|
||||
|
||||
val result = PddProductDetailCollector(driver, { clock }, { clock += it }, taskId = 370, diagnostic = events::add)
|
||||
.collect(GOODS_ID, rule(gridConfig()))
|
||||
|
||||
assertTrue(result.successful)
|
||||
val event = events.single { it.stage == AgentDiagnosticStage.COLOR_DISCOVERY }
|
||||
assertEquals(AgentDiagnosticReason.COLOR_ROW_REFLOWED, event.reason)
|
||||
assertTrue(requireNotNull(event.colorTrailingEmptyReadCount) > 0)
|
||||
assertEquals(null, event.colorHorizontalIncomplete)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `single row disjoint reflow keeps reflow reason`() {
|
||||
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 = 370, diagnostic = events::add,
|
||||
).collect(GOODS_ID, rule())
|
||||
|
||||
assertTrue(result.successful)
|
||||
assertEquals(AgentDiagnosticReason.COLOR_ROW_REFLOWED, events.single { it.stage == AgentDiagnosticStage.COLOR_DISCOVERY }.reason)
|
||||
}
|
||||
|
||||
private class FakeCollectorDriver(
|
||||
colors: List<String> = listOf("红色"),
|
||||
sizes: List<String> = listOf("S"),
|
||||
@@ -2146,6 +2671,39 @@ class PddProductDetailCollectorTest {
|
||||
// restored to top. Heading count drops but color/size values are
|
||||
// untouched, so this must NOT be treated as a collapsed panel.
|
||||
private val specPanelDropsExtraDimensionAfterTopSwipe: Boolean = false,
|
||||
// #370: horizontal multi-row color grid. Each swipe moves gridStep
|
||||
// columns; gridSharedContainer moves all rows together, otherwise only
|
||||
// the anchored row. Left/right swipes are logged with the largest
|
||||
// column offset before the gesture.
|
||||
private val horizontalGrid: List<List<String>>? = null,
|
||||
private val gridVisibleColumns: Int = 4,
|
||||
private val gridStep: Int = 3,
|
||||
private val gridSharedContainer: Boolean = true,
|
||||
// #370: when set, shared-container swipes move this many pixels (not whole
|
||||
// columns) and columns partly inside the 835px viewport count as visible.
|
||||
private val gridPixelStep: Int? = null,
|
||||
// #370: after each click the grid nodes shift a few pixels, like the
|
||||
// small layout change seen on a real device after selecting a color.
|
||||
private val gridClickJitter: Boolean = false,
|
||||
// #370: on this (1-based) horizontal swipe only the first row moves.
|
||||
private val gridSwipeWhereOnlyFirstRowMoves: Int = 0,
|
||||
// #370: the shared grid starts scrolled this many pixels to the right.
|
||||
private val gridInitialOffset: Int = 0,
|
||||
// #370: 1-based RIGHT swipe attempts that fail with anchorMissing.
|
||||
private val gridRightFailAttempts: Set<Int> = emptySet(),
|
||||
// #370: after a horizontal swipe that moved the grid, the next N captures
|
||||
// still show it coasting (nodes drift 30px per remaining capture).
|
||||
private val gridInertiaCaptures: Int = 0,
|
||||
// #370: time-based coasting: captures within gridSettleMillis of the last
|
||||
// grid movement (by gridSettleClock) show the nodes drifted by 30px.
|
||||
private val gridSettleClock: (() -> Long)? = null,
|
||||
private val gridSettleMillis: Long = 0L,
|
||||
// #370: each capture() (a parse) costs this much simulated time.
|
||||
private val captureCostMillis: Long = 0L,
|
||||
private val captureAdvance: ((Long) -> Unit)? = null,
|
||||
// #370: a click is NOT_FOUND when the node is not where the last capture
|
||||
// placed it (position at click time differs from the parsed target).
|
||||
private val gridStrictClickPosition: Boolean = false,
|
||||
) : PddCollectorDriver {
|
||||
var captureCount = 0
|
||||
var clickCount = 0
|
||||
@@ -2155,6 +2713,15 @@ class PddProductDetailCollectorTest {
|
||||
var backCount = 0
|
||||
var entryClickCount = 0
|
||||
var restoreGestures = 0
|
||||
val gridSwipeLog = mutableListOf<Pair<SwipeDirection, Int>>()
|
||||
private val gridOffsets = IntArray(horizontalGrid?.size ?: 0) { gridInitialOffset }
|
||||
private var rightAttempts = 0
|
||||
private var inertiaRemaining = 0
|
||||
private var currentDrift = 0
|
||||
// Parses between a spec swipe and the next click (discovery) cost captureCostMillis.
|
||||
private var costlyParses = false
|
||||
private var lastGridMoveAt = Long.MIN_VALUE / 2
|
||||
private var lastSwipeFailure = "none"
|
||||
private var selected: String? = initialSelectedColor
|
||||
private var previousSelected: String? = null
|
||||
private var horizontalPage = 0
|
||||
@@ -2167,7 +2734,15 @@ class PddProductDetailCollectorTest {
|
||||
private var adviceOpen = false
|
||||
|
||||
override fun capture(): UiSnapshot {
|
||||
if (captureCostMillis > 0 && costlyParses) captureAdvance?.invoke(captureCostMillis)
|
||||
return captureFrame(advanceInertia = true)
|
||||
}
|
||||
|
||||
private fun captureFrame(advanceInertia: Boolean): UiSnapshot {
|
||||
captureCount++
|
||||
currentDrift = inertiaRemaining * 30 +
|
||||
if (gridSettleClock != null && gridSettleClock.invoke() - lastGridMoveAt < gridSettleMillis) 30 else 0
|
||||
if (advanceInertia && inertiaRemaining > 0) inertiaRemaining--
|
||||
fixedSnapshot?.let { return it }
|
||||
if (adviceOpen) return UiSnapshot(PDD_PACKAGE, ACTIVITY, listOf(
|
||||
node("content", "", 0, 0, 1080, 2200, resourceId = "android:id/content", className = "android.widget.FrameLayout"),
|
||||
@@ -2224,21 +2799,33 @@ class PddProductDetailCollectorTest {
|
||||
val continuationPage = hideDimensionHeadingsAfterFirstVerticalPage && verticalPage > 0
|
||||
val panelCollapsedNow = specPanelCollapsesAfterTopSwipe && downSwipeCount >= 1
|
||||
val hideColorNow = specPanelHidesColorInitially && downSwipeCount == 0
|
||||
val visibleColors = colorVerticalPages?.get(verticalPage.coerceAtMost(colorVerticalPages.lastIndex))
|
||||
val gridPlaced = horizontalGrid?.flatMapIndexed { r, row ->
|
||||
if (gridPixelStep != null) {
|
||||
row.mapIndexed { c, color -> Triple(color, r, c) }.filter { (_, _, c) ->
|
||||
val left = 30 + c * 230 - gridOffsets[r]
|
||||
left < GRID_VIEWPORT_RIGHT && left + 190 > 0
|
||||
}
|
||||
} else {
|
||||
row.drop(gridOffsets[r]).take(gridVisibleColumns).mapIndexed { c, color -> Triple(color, r, c) }
|
||||
}
|
||||
}
|
||||
val visibleColors = gridPlaced?.map { it.first }
|
||||
?: colorVerticalPages?.get(verticalPage.coerceAtMost(colorVerticalPages.lastIndex))
|
||||
?: colorPages[horizontalPage.coerceAtMost(colorPages.lastIndex)]
|
||||
val colorRowCount = (visibleColors.size + rowSize - 1) / rowSize
|
||||
val colorRowCount = horizontalGrid?.size ?: ((visibleColors.size + rowSize - 1) / rowSize)
|
||||
val placedColors = gridPlaced ?: visibleColors
|
||||
.filterNot { hideSelectedColorOption && it == selected }
|
||||
.mapIndexed { index, color -> Triple(color, index / rowSize, index % rowSize) }
|
||||
val sizeHeadingTop = maxOf(700, 470 + colorRowCount * 90 + 20)
|
||||
if (!continuationPage && !panelCollapsedNow && !(hideColorHeadingAfterFirstVerticalPage && verticalPage > 0)) {
|
||||
nodes += node("scroll/color-heading", "颜色分类", 20, 400, 300, 450, parentPath = "scroll")
|
||||
}
|
||||
if (!continuationPage && !hideColorNow && !panelCollapsedNow) {
|
||||
visibleColors
|
||||
.filterNot { hideSelectedColorOption && it == selected }
|
||||
.forEachIndexed { index, color ->
|
||||
val row = index / rowSize
|
||||
val column = index % rowSize
|
||||
placedColors.forEach { (color, row, column) ->
|
||||
val path = "scroll/color-$color-$captureCount"
|
||||
val left = 30 + column * 230
|
||||
val shift = if (gridPixelStep != null) gridOffsets[row] else 0
|
||||
val jitter = if (gridClickJitter && horizontalGrid != null) (clickCount % 4) * 2 else 0
|
||||
val left = 30 + column * 230 - shift + jitter + currentDrift
|
||||
val top = 470 + row * 90
|
||||
if (imageColorCards) {
|
||||
nodes += SnapshotNode(
|
||||
@@ -2254,7 +2841,7 @@ class PddProductDetailCollectorTest {
|
||||
)
|
||||
} else {
|
||||
nodes += node(
|
||||
path, color, left, top, 220 + column * 230, top + 70,
|
||||
path, color, left, top, 220 + column * 230 - shift + jitter + currentDrift, top + 70,
|
||||
clickable = true, selected = displayedSelected() == color, parentPath = "scroll",
|
||||
)
|
||||
}
|
||||
@@ -2312,11 +2899,13 @@ class PddProductDetailCollectorTest {
|
||||
|
||||
override fun clickFresh(target: SnapshotNode): FreshActionResult {
|
||||
clickCount++
|
||||
costlyParses = false
|
||||
clickedLabels += target.label
|
||||
if (target.label in failedClicks) return FreshActionResult.FAILED
|
||||
val current = capture().nodes.filter { it.label == target.label && (!imageColorCards || it.clickable) }
|
||||
val current = captureFrame(advanceInertia = false).nodes.filter { it.label == target.label && (!imageColorCards || it.clickable) }
|
||||
if (current.isEmpty()) return FreshActionResult.NOT_FOUND
|
||||
if (current.size > 1) return FreshActionResult.AMBIGUOUS
|
||||
if (gridStrictClickPosition && current.single().bounds != target.bounds) return FreshActionResult.NOT_FOUND
|
||||
if (!panelOpen && target.label == "单独购买") {
|
||||
entryClickCount++
|
||||
when {
|
||||
@@ -2355,8 +2944,43 @@ class PddProductDetailCollectorTest {
|
||||
private fun displayedSelected(): String? =
|
||||
if (staleSelectionEvidence && previousSelected != null) previousSelected else selected
|
||||
|
||||
override fun specRowSwipeFailureReason(): String = lastSwipeFailure
|
||||
|
||||
override fun swipeSpec(direction: SwipeDirection, anchor: SnapshotNode?): Boolean {
|
||||
swipes += direction to anchor
|
||||
costlyParses = true
|
||||
lastSwipeFailure = "none"
|
||||
if (direction == SwipeDirection.RIGHT && horizontalGrid != null) {
|
||||
rightAttempts++
|
||||
if (rightAttempts in gridRightFailAttempts) {
|
||||
lastSwipeFailure = "anchorMissing"
|
||||
return false
|
||||
}
|
||||
}
|
||||
if (horizontalGrid != null && (direction == SwipeDirection.LEFT || direction == SwipeDirection.RIGHT)) {
|
||||
gridSwipeLog += direction to (gridOffsets.maxOrNull() ?: 0)
|
||||
val unit = gridPixelStep ?: gridStep
|
||||
val delta = if (direction == SwipeDirection.LEFT) unit else -unit
|
||||
val moved = if (gridSharedContainer) horizontalGrid.indices.toList()
|
||||
else listOf((((anchor?.bounds?.top ?: 470) - 470) / 90).coerceIn(0, horizontalGrid.lastIndex))
|
||||
val sharedMax = if (gridPixelStep != null) {
|
||||
30 + (horizontalGrid.maxOf { it.size } - 1) * 230 + 190 - GRID_VIEWPORT_RIGHT
|
||||
} else {
|
||||
horizontalGrid.maxOf { it.size } - gridVisibleColumns
|
||||
}
|
||||
val brokenSwipe = gridSwipeWhereOnlyFirstRowMoves > 0 &&
|
||||
gridSwipeLog.size == gridSwipeWhereOnlyFirstRowMoves
|
||||
val offsetsBefore = gridOffsets.toList()
|
||||
moved.filter { !brokenSwipe || it == 0 }.forEach { r ->
|
||||
val max = if (gridSharedContainer) sharedMax else horizontalGrid[r].size - gridVisibleColumns
|
||||
gridOffsets[r] = (gridOffsets[r] + delta).coerceIn(0, maxOf(0, max))
|
||||
}
|
||||
if (gridOffsets.toList() != offsetsBefore) {
|
||||
inertiaRemaining = gridInertiaCaptures
|
||||
gridSettleClock?.let { lastGridMoveAt = it.invoke() }
|
||||
}
|
||||
return true
|
||||
}
|
||||
if ((direction == SwipeDirection.UP || direction == SwipeDirection.DOWN) && !verticalSwipeSucceeds) return false
|
||||
when (direction) {
|
||||
SwipeDirection.LEFT -> horizontalPage = (horizontalPage + 1).coerceAtMost(colorPages.lastIndex)
|
||||
@@ -2391,8 +3015,162 @@ class PddProductDetailCollectorTest {
|
||||
}
|
||||
}
|
||||
|
||||
// #379: purchase-1 card shape: clickable ViewGroup > (View, full-name ImageView,
|
||||
// "打开大图" ImageView, non-clickable LinearLayout > clickable caption TextView).
|
||||
private fun badgeCard(index: Int, left: Int, fullName: String, caption: String): List<SnapshotNode> {
|
||||
fun described(path: String, parent: String?, text: String?, description: String?, bounds: NodeBounds, className: String, clickable: Boolean) =
|
||||
SnapshotNode(
|
||||
path, parent, text, description, null, className, bounds,
|
||||
clickable, scrollable = false, selected = false, checked = false, enabled = true, visible = true,
|
||||
)
|
||||
val card = "scroll/card$index"
|
||||
val right = left + 316
|
||||
return listOf(
|
||||
described(card, "scroll", null, fullName, NodeBounds(left, 470, right, 690), "android.view.ViewGroup", true),
|
||||
described("$card/bg", card, null, null, NodeBounds(left, 470, right, 690), "android.view.View", false),
|
||||
described("$card/image", card, null, fullName, NodeBounds(left, 470, right, 640), "android.widget.ImageView", true),
|
||||
described("$card/big", card, null, "打开大图", NodeBounds(left, 470, left + 111, 581), "android.widget.ImageView", true),
|
||||
described("$card/box", card, null, null, NodeBounds(left, 630, right, 690), "android.widget.LinearLayout", false),
|
||||
described("$card/box/caption", "$card/box", caption, null, NodeBounds(left, 630, right, 690), "android.widget.TextView", true),
|
||||
)
|
||||
}
|
||||
|
||||
private fun badgeCardsSnapshot(cards: List<List<SnapshotNode>>): UiSnapshot = UiSnapshot(
|
||||
PDD_PACKAGE,
|
||||
ACTIVITY,
|
||||
listOf(
|
||||
node("content", "", 0, 0, 1080, 2200, resourceId = "android:id/content", className = "android.widget.FrameLayout"),
|
||||
node("summary", "已选:", 20, 300, 700, 360),
|
||||
node("title", "确认款式", 20, 370, 300, 410),
|
||||
node("scroll", "", 0, 380, 1080, 1900, scrollable = true),
|
||||
node("scroll/color-heading", "颜色分类", 20, 400, 300, 450, parentPath = "scroll"),
|
||||
) + cards.flatten() + listOf(
|
||||
node("scroll/size-heading", "尺码", 20, 700, 300, 750, parentPath = "scroll"),
|
||||
node("scroll/size-s", "S 75斤内穿", 30, 770, 220, 840, clickable = true, parentPath = "scroll"),
|
||||
node("scroll/size-m", "M 80-90斤", 260, 770, 450, 840, clickable = true, parentPath = "scroll"),
|
||||
node("confirm", "确定", 0, 2000, 1080, 2150, clickable = true),
|
||||
),
|
||||
)
|
||||
|
||||
private fun purchase1Cards(captionOne: String = "黑色 微喇春秋加...\n ¥11.8 零差评") = listOf(
|
||||
badgeCard(1, 36, "黑色 微喇春秋加薄绒长裤 (适合现在温度穿) ¥11.8", captionOne),
|
||||
badgeCard(2, 382, " 黑色 微喇春夏款长裤 ¥9.8", " 黑色 微喇春夏...\n ¥9.8 最多人选择"),
|
||||
badgeCard(3, 728, "黑色 微喇夏天薄款长裤 ¥9.8", "黑色 微喇夏天薄款长裤 ¥9.8"),
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `price plus badge caption merges into the outer card and yields three colors`() {
|
||||
val parsed = PddScreenParser.parse(badgeCardsSnapshot(purchase1Cards()), config(), GOODS_ID, evidence())
|
||||
|
||||
val colors = parsed.dimensions.first { it.key == "color" }.values
|
||||
assertEquals(
|
||||
listOf("黑色 微喇春秋加薄绒长裤 (适合现在温度穿)", "黑色 微喇春夏款长裤", "黑色 微喇夏天薄款长裤"),
|
||||
colors.map { it.text },
|
||||
)
|
||||
assertEquals(colors.size, colors.map { it.text }.distinct().size)
|
||||
assertTrue(colors.none { it.text.contains('¥') || it.text.contains('¥') })
|
||||
assertEquals("黑色 微喇春秋加薄绒长裤 (适合现在温度穿) ¥11.8", colors[0].rawText)
|
||||
assertTrue(colors.all { it.node.path.endsWith("/caption") || it.node.path.startsWith("scroll/card") })
|
||||
// Size dimension is unaffected.
|
||||
assertEquals(listOf("S 75斤内穿", "M 80-90斤"), parsed.dimensions.first { it.key == "size" }.values.map { it.text })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `price plus badge cards collect three colors without invalid marker`() {
|
||||
val base = FakeCollectorDriver(colors = listOf("占位"), sizePages = listOf(listOf("S 75斤内穿", "M 80-90斤")))
|
||||
val driver = object : PddCollectorDriver by base {
|
||||
override fun capture(): UiSnapshot {
|
||||
val snapshot = base.capture()
|
||||
return snapshot.copy(
|
||||
nodes = snapshot.nodes.filterNot { it.path.startsWith("scroll/color-") && it.path != "scroll/color-heading" } +
|
||||
purchase1Cards().flatten(),
|
||||
)
|
||||
}
|
||||
}
|
||||
var clock = 0L
|
||||
|
||||
val result = PddProductDetailCollector(driver, { clock }, { clock += it }).collect(GOODS_ID, rule())
|
||||
|
||||
assertTrue(result.successful)
|
||||
val payload = requireNotNull(result.payload)
|
||||
assertEquals(
|
||||
listOf("黑色 微喇春秋加薄绒长裤 (适合现在温度穿)", "黑色 微喇春夏款长裤", "黑色 微喇夏天薄款长裤"),
|
||||
payload.dimensions.first { it.key == "color" }.values,
|
||||
)
|
||||
assertTrue(payload.missing.none { it.startsWith("spec_dimension_") })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `badge caption amount differing from the outer card price is not merged`() {
|
||||
val parsed = PddScreenParser.parse(
|
||||
badgeCardsSnapshot(purchase1Cards("黑色 微喇春秋加...\n ¥11.9 零差评")),
|
||||
config(), GOODS_ID, evidence(),
|
||||
)
|
||||
|
||||
val colors = parsed.dimensions.first { it.key == "color" }.values
|
||||
assertEquals(4, colors.size)
|
||||
assertTrue(colors.any { it.text.contains('¥') })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `badge caption with currency or an overlong badge is not merged`() {
|
||||
for (caption in listOf("黑色 微喇春秋加...\n ¥11.8 零¥差评", "黑色 微喇春秋加...\n ¥11.8 零差评零差评零差评", "黑色 微喇春秋加...\n ¥11.8 零 差评")) {
|
||||
val parsed = PddScreenParser.parse(badgeCardsSnapshot(purchase1Cards(caption)), config(), GOODS_ID, evidence())
|
||||
val colors = parsed.dimensions.first { it.key == "color" }.values
|
||||
assertEquals(caption, 4, colors.size)
|
||||
assertTrue(caption, colors.any { it.text.contains('¥') })
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `badge caption is not merged when the outer card has no price`() {
|
||||
val cards = listOf(badgeCard(1, 36, "黑色 微喇春秋加薄绒长裤", "黑色 微喇春秋加...\n ¥11.8 零差评"))
|
||||
val parsed = PddScreenParser.parse(badgeCardsSnapshot(cards), config(), GOODS_ID, evidence())
|
||||
|
||||
val colors = parsed.dimensions.first { it.key == "color" }.values
|
||||
assertEquals(2, colors.size)
|
||||
assertTrue(colors.any { it.text.contains('¥') })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `badge caption must still be a prefix of the outer name`() {
|
||||
val parsed = PddScreenParser.parse(
|
||||
badgeCardsSnapshot(purchase1Cards("红色 微喇春秋加...\n ¥11.8 零差评")),
|
||||
config(), GOODS_ID, evidence(),
|
||||
)
|
||||
|
||||
val colors = parsed.dimensions.first { it.key == "color" }.values
|
||||
assertEquals(4, colors.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `badge caption does not merge when the card holds a second clickable text`() {
|
||||
val card = badgeCard(1, 36, "黑色 微喇春秋加薄绒长裤 (适合现在温度穿) ¥11.8", "黑色 微喇春秋加...\n ¥11.8 零差评")
|
||||
val extra = SnapshotNode(
|
||||
"scroll/card1/box/other", "scroll/card1/box", "黑色", null, null, "android.widget.TextView",
|
||||
NodeBounds(36, 630, 100, 660), clickable = true, scrollable = false, selected = false,
|
||||
checked = false, enabled = true, visible = true,
|
||||
)
|
||||
val parsed = PddScreenParser.parse(badgeCardsSnapshot(listOf(card + extra)), config(), GOODS_ID, evidence())
|
||||
|
||||
val colors = parsed.dimensions.first { it.key == "color" }.values
|
||||
assertTrue(colors.size > 1)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `truncated caption without badge still merges as before`() {
|
||||
val cards = listOf(badgeCard(1, 36, "黑色 微喇春秋加薄绒长裤 (适合现在温度穿) ¥11.8", "黑色 微喇春秋加薄绒长裤 (适合 ¥11.8"))
|
||||
val parsed = PddScreenParser.parse(badgeCardsSnapshot(cards), config(), GOODS_ID, evidence())
|
||||
|
||||
val colors = parsed.dimensions.first { it.key == "color" }.values
|
||||
assertEquals(listOf("黑色 微喇春秋加薄绒长裤 (适合现在温度穿)"), colors.map { it.text })
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val GOODS_ID = "719834019024"
|
||||
private const val GRID_VIEWPORT_RIGHT = 835
|
||||
// Simulated elapsed time of the right-start 47 color scenario at 9619c38.
|
||||
private const val BASELINE_RIGHT_START_ELAPSED_MS = 127_550L
|
||||
private const val PDD_PACKAGE = "com.xunmeng.pinduoduo"
|
||||
private const val ACTIVITY = "com.xunmeng.pinduoduo.activity.NewPageActivity"
|
||||
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
+302
-99
@@ -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",
|
||||
|
||||
+32
@@ -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))
|
||||
}
|
||||
}
|
||||
+19
@@ -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",
|
||||
|
||||
@@ -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"}]}"""),
|
||||
|
||||
+23
-1
@@ -8,10 +8,32 @@ 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(5, AgentDiagnosticSchema.VERSION)
|
||||
assertEquals(6, AgentDiagnosticSchema.VERSION)
|
||||
assertV5Columns(db)
|
||||
insertOldDiagnostic(db)
|
||||
assertOldDiagnosticPreserved(db)
|
||||
|
||||
+106
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+100
@@ -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)")
|
||||
}
|
||||
}
|
||||
+225
@@ -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() }
|
||||
}
|
||||
}
|
||||
}
|
||||
+168
@@ -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,147 @@
|
||||
package cn.ilapage.goauto.agent.service
|
||||
|
||||
import cn.ilapage.goauto.agent.automation.PddLaunchEvent
|
||||
import cn.ilapage.goauto.agent.automation.PddLaunchFallback
|
||||
import cn.ilapage.goauto.agent.automation.PddLaunchPath
|
||||
import cn.ilapage.goauto.agent.automation.PddLaunchStatus
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class PddManualOpenTest {
|
||||
private class Harness(
|
||||
val busy: String? = null,
|
||||
val direct: () -> Boolean = { true },
|
||||
val browser: () -> Boolean = { true },
|
||||
) {
|
||||
val calls = mutableListOf<String>()
|
||||
var preferDirect: Boolean? = null
|
||||
val coordinator = PddManualOpenCoordinator(
|
||||
busyReason = { calls += "busy"; busy },
|
||||
invalidateHandoff = { calls += "invalidate" },
|
||||
launch = { _, prefer, diagnostic ->
|
||||
calls += "launch"
|
||||
preferDirect = prefer
|
||||
PddLaunchFallback.open(prefer, direct = { calls += "direct"; direct() },
|
||||
browser = { calls += "browser"; browser() }, diagnostic = diagnostic)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Test fun linkUsesGoodsIdOnly() {
|
||||
assertEquals("https://mobile.yangkeduo.com/goods.html?goods_id=825093359197", PddManualOpenCoordinator.link("825093359197"))
|
||||
}
|
||||
|
||||
@Test fun linkRejectsBlankOrNonDigitGoodsId() {
|
||||
listOf(null, "", " ", "12a", "1 2", "12&x=1", "-1", "123").forEach { assertNull(it, PddManualOpenCoordinator.link(it)) }
|
||||
}
|
||||
|
||||
@Test fun invalidGoodsIdNeitherChecksBusyNorInvalidatesNorOpens() {
|
||||
val h = Harness()
|
||||
assertEquals(PddManualOpenResult.InvalidGoodsId, h.coordinator.run("abc", purchase = false))
|
||||
assertTrue(h.calls.isEmpty())
|
||||
}
|
||||
|
||||
@Test fun collectionButtonOnlyUsesBrowser() {
|
||||
val h = Harness(direct = { error("unexpected") })
|
||||
assertEquals(PddManualOpenResult.Opened(null), h.coordinator.run("1", purchase = false))
|
||||
assertEquals(false, h.preferDirect)
|
||||
assertFalse(h.calls.contains("direct"))
|
||||
assertTrue(h.calls.contains("browser"))
|
||||
}
|
||||
|
||||
@Test fun purchaseButtonPrefersDirectAndStopsWhenAccepted() {
|
||||
val h = Harness(browser = { error("unexpected") })
|
||||
assertEquals(PddManualOpenResult.Opened(null), h.coordinator.run("1", purchase = true))
|
||||
assertEquals(true, h.preferDirect)
|
||||
assertFalse(h.calls.contains("browser"))
|
||||
}
|
||||
|
||||
@Test fun purchaseDirectExceptionFallsBackToBrowserWithSimpleClassName() {
|
||||
val h = Harness(direct = { throw SecurityException("secret url and address") })
|
||||
val result = h.coordinator.run("1", purchase = true)
|
||||
assertEquals(PddManualOpenResult.Opened("拼多多直接打开失败,已改用浏览器(SecurityException)"), result)
|
||||
val text = PddManualOpenCoordinator.message(result)
|
||||
assertFalse(text.contains("secret"))
|
||||
assertFalse(text.contains("http"))
|
||||
assertTrue(text.endsWith(PddManualOpenCoordinator.OPENED_HINT))
|
||||
}
|
||||
|
||||
@Test fun purchaseDirectRejectedFallbackNoticeOmitsParenthesesWithoutClass() {
|
||||
val h = Harness(direct = { false })
|
||||
assertEquals(PddManualOpenResult.Opened("拼多多直接打开失败,已改用浏览器"), h.coordinator.run("1", purchase = true))
|
||||
}
|
||||
|
||||
@Test fun allPathsFailingReportsOpenFailure() {
|
||||
val h = Harness(direct = { throw IllegalStateException("x") }, browser = { false })
|
||||
val result = h.coordinator.run("1", purchase = true)
|
||||
assertEquals(PddManualOpenResult.Failed, result)
|
||||
assertEquals("无法打开拼多多链接", PddManualOpenCoordinator.message(result))
|
||||
}
|
||||
|
||||
@Test fun fallbackNoticeNeedsAcceptedBrowser() {
|
||||
val events = listOf(
|
||||
PddLaunchEvent(PddLaunchPath.DIRECT, PddLaunchStatus.EXCEPTION, "java.lang.SecurityException"),
|
||||
PddLaunchEvent(PddLaunchPath.BROWSER, PddLaunchStatus.REJECTED),
|
||||
)
|
||||
assertNull(PddManualOpenCoordinator.fallbackNotice(events))
|
||||
}
|
||||
|
||||
@Test fun busyAtClickTimeDoesNotInvalidateOrOpen() {
|
||||
val h = Harness(busy = DeviceBusyPolicy.TASK)
|
||||
val result = h.coordinator.run("1", purchase = true)
|
||||
assertEquals(PddManualOpenResult.Busy("设备正在执行任务"), result)
|
||||
assertEquals(listOf("busy"), h.calls)
|
||||
assertEquals("设备正在执行任务,暂不能打开拼多多", PddManualOpenCoordinator.message(result))
|
||||
}
|
||||
|
||||
@Test fun handoffIsInvalidatedBeforeAnyLaunch() {
|
||||
val h = Harness()
|
||||
h.coordinator.run("1", purchase = true)
|
||||
assertEquals(listOf("busy", "invalidate", "launch", "direct"), h.calls)
|
||||
}
|
||||
|
||||
@Test fun handoffIsInvalidatedEvenWhenOpeningFails() {
|
||||
val h = Harness(direct = { false }, browser = { false })
|
||||
h.coordinator.run("1", purchase = true)
|
||||
assertTrue(h.calls.indexOf("invalidate") in 0 until h.calls.indexOf("launch"))
|
||||
}
|
||||
|
||||
@Test fun idleInputsAreNotBusy() {
|
||||
assertNull(DeviceBusyPolicy.reason(DeviceBusyPolicy.Inputs()))
|
||||
}
|
||||
|
||||
@Test fun runningTaskIsBusy() {
|
||||
assertEquals("设备正在执行任务", DeviceBusyPolicy.reason(DeviceBusyPolicy.Inputs(taskActive = true)))
|
||||
}
|
||||
|
||||
@Test fun repurchaseRoundIsBusyIncludingBetweenTasks() {
|
||||
// Between two tasks every task input is false but the round is still busy.
|
||||
assertEquals("重购进行中", DeviceBusyPolicy.reason(DeviceBusyPolicy.Inputs(repurchaseBusy = true)))
|
||||
}
|
||||
|
||||
@Test fun backfillIsBusy() {
|
||||
assertEquals("订单回填中", DeviceBusyPolicy.reason(DeviceBusyPolicy.Inputs(backfillRunning = true)))
|
||||
}
|
||||
|
||||
@Test fun currentPageCollectionIsBusy() {
|
||||
assertEquals("当前页采集进行中", DeviceBusyPolicy.reason(DeviceBusyPolicy.Inputs(currentPageCollecting = true, taskActive = true)))
|
||||
}
|
||||
|
||||
@Test fun unuploadedOrRecoverablePurchaseIsBusy() {
|
||||
assertEquals("有未上传的采购结果", DeviceBusyPolicy.reason(DeviceBusyPolicy.Inputs(unuploadedPurchase = true)))
|
||||
}
|
||||
|
||||
@Test fun repurchaseLocalIdleViewIgnoresOwnRoundButKeepsOtherWork() {
|
||||
// The service builds the repurchase-idle inputs with repurchaseBusy=false: the round never blocks itself.
|
||||
assertNull(DeviceBusyPolicy.reason(DeviceBusyPolicy.Inputs(repurchaseBusy = false)))
|
||||
assertEquals("订单回填中", DeviceBusyPolicy.reason(DeviceBusyPolicy.Inputs(repurchaseBusy = false, backfillRunning = true)))
|
||||
}
|
||||
|
||||
@Test fun collectionCooldownIsNotAnInput() {
|
||||
// A cooldown only blocks claiming new collection work; it leaves every busy input false.
|
||||
assertTrue(DeviceBusyPolicy.Inputs::class.java.declaredFields.none { it.name.contains("cooldown", ignoreCase = true) })
|
||||
}
|
||||
}
|
||||
+59
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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: 76854d900b2372ddedd46a2b82b5677d8bf5cb31
|
||||
synchronized_at: 2026-10-09T07:45:10Z
|
||||
wiki_revision: 72fa776c9adb4993c84918494b37516f3156d354
|
||||
synchronized_at: 2026-10-12T09:37:33Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
<!-- gitea-wiki-mirror:start -->
|
||||
@@ -150,6 +150,8 @@ Android Portal/Agent
|
||||
| 管理端闭环页面 | `web/src/views/goauto/` |
|
||||
| 备货采购服务端路径 | `purchase_task.task_type` 与迁移 `1787885300000_stock_purchase.go`;`POST /api/admin/v1/purchase-tasks/stock` 由 `server/app/goauto/purchase/service.go` 校验 PDD 当前可选规格并固化 `direct_select`,复用既有任务状态机和设备/账号互斥;重试、替换和 SYB 回填显式排除 `stock` |
|
||||
| Admin 采购任务列表、详情与人工处理 | `web/src/views/goauto/purchase-tasks/`、`web/src/api/goauto/purchase-tasks.js`;创建入口不在本模块 |
|
||||
| 采购任务创建人与列表筛选(#382) | 迁移 `1791700000000_purchase_task_creator.go` 新增 `purchase_task.created_by`(索引)、`created_source`;`purchase/handler.go` `creatorFromContext` 在 Admin 创建/备货/批量创建与客户端密钥请求时写入,Agent 重试路径 `retry.go` `batchRetryCreate` 写 `agent`;`GET /api/admin/v1/purchase-tasks` 新增 `deviceId`(数字或 `unassigned`)、`createdBy`(数字、`agent`、`unrecorded`),列表/详情返回 `createdBy`、`createdByName`、`createdSource`;`GET /api/admin/v1/purchase-tasks/filter-options`(`admin_query.go`)返回任务中出现过的设备与创建人 |
|
||||
| SYB 已取消订单拦截(#381) | `purchase/return_match_block.go` `sybCancelled`/`rejectIfSYBCancelled`,错误码 `PURCHASE_SYB_CANCELLED`;拦截点:`service.go` 创建、`batch.go` 预览、`ai_match_eligibility.go`、`collection_eligibility.go`、`task/image_search.go`、`process_stage.go`(阶段 `syb_cancelled`);`returnmatch.participatingStages` 不含该阶段 |
|
||||
| Admin AI 规格匹配设置 | `web/src/views/goauto/ai-matching-settings/`、`web/src/api/goauto/ai-matching-settings.js`;管理员可查看、维护和测试 Provider(包括内部明文 API Key);采购员菜单硬排除,仍只可通过受控 API 查看启用状态 |
|
||||
| GoAuto 系统菜单与采购员权限基线 | `server/app/goauto/access/` 统一声明 12 个模块、路由元数据和 Admin API 权限矩阵;迁移 `1787885400000_goauto_menus.go` 幂等维护 `sys_menu`、`sys_menu_api_rule`、采购员默认菜单绑定和 Casbin 固定白名单。`web/src/router/index.js` 只保留公共路由,`web/src/store/modules/permission.js` 在登录时根据 `GET /api/v1/menurole` 返回结果注册业务路由与侧栏;退出、凭据异常和切换角色会清除旧动态路由,未授权深链接回退工作台。菜单可见性与 API 授权彼此独立 |
|
||||
| Admin 失败采购任务批量重试 | `POST /api/admin/v1/purchase-tasks/batch-retry`;服务端 `server/app/goauto/purchase/retry.go` 负责资格判定、逐项幂等创建与部分成功结果,Admin 页面只允许选择服务端标记可重试的行;不修改 Android Agent |
|
||||
@@ -673,3 +675,28 @@ Web 唯一展示位置为“采集采购 → SYB 同步记录”:列表状态
|
||||
- `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当前占据屏幕。
|
||||
|
||||
@@ -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: f8a5ca861965f5da9bdec2235205f23fb9a00840
|
||||
synchronized_at: 2026-10-09T07:45:19Z
|
||||
wiki_revision: c8aa5a4716d0b9a462677b1276a7362d7227c95c
|
||||
synchronized_at: 2026-10-12T09:37:39Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
<!-- gitea-wiki-mirror:start -->
|
||||
@@ -220,6 +220,7 @@ Admin 的「创建时间」范围按 SYB 货运单列表的 `t_stock.created`(
|
||||
- 采购规则的 `probeSpecs.dimensionAliases` 是只读规格维度标题的服务端下发事实源;颜色别名可由管理员增加“组合”等 PDD 页面文案,尺码别名单独维护。该字段只影响探测和 AI 匹配输入,不能改变 `selectSpec` 的精确点击目标;旧任务快照缺省时才使用 Agent 内置兼容值。
|
||||
- PDD 采集规则的 `collector.textAliases` 是规格面板、维度标题、选择摘要、评价入口、售罄主商品证据和购买入口等只读识别文案的服务端下发事实源;缺省字段只为旧快照兼容使用 Agent 内置默认值。颜色/尺码分类不得再用代码字面量扩大 `dimensionAliases`。订单和支付文字可以参与只读页面判定,但 Agent 内不可配置的创建订单/确认订单/支付/付款点击拒绝清单始终优先,规则不能放宽;修改地址和收货地址在只读及点击字段中都禁止。
|
||||
- PDD 颜色采集对首屏已识别为多行或网格的颜色区执行动态行发现:每次动作后重新解析全部可见行,单列不做无效横向滑动,并复用规则中的 `specVerticalSwipes` 与 `stableEdgeReads` 有界纵向翻页;颜色结束后规格面板归顶,再进入尺码发现。选项重排或已选项从无障碍节点消失时,不得静默跳过仍未尝试的可用颜色。
|
||||
- #370 第 2 阶段(合并 main `26562fc`,2026-10-12;测试机回归通过,采购机未装机):横向颜色网格每次横滑后按颜色文字与位置逐帧重新定位同一行;多行共用一个横滑容器时用选项位置签名判断网格是否移动,且只有确认起点已在最左端时才跳过逐行蛇形反扫;横滑后固定等待 1.2 秒再读取;滑回最左端使用独立额度(`specHorizontalSwipes` × 2),锚点暂时丢失的重试不计入次数但有连续与累计上限。异常终止保留最早原因,仍不得静默跳过未尝试的可用颜色。只改 Android Agent 采集遍历,不改 Admin 图搜、自动关联和服务端。
|
||||
- 正式采购规则可以调用独立审核的改地址、创建待付款订单和只读核单动作;采集规则和演练规则不能调用。真机首次安装或验证仍需独立人工授权。
|
||||
- 系统当前不提供自动支付、实时屏幕或管理端远程控制。付款、免密支付及任何等价动作不能配置为点击目标;只读识别字段可以包含支付或订单文字。后续若新增支付能力,必须由独立工单和显式控制重新评估。
|
||||
|
||||
@@ -803,7 +804,7 @@ Android 0.9.64 / versionCode 77,源码 `6550b9f`(分支实现,尚未安装
|
||||
- “SYB状态”仅显示来源取消标记:`isCancel=1` 为“已取消”,`0` 为“未取消”,没有有效证据为“未获取”。“未取消”不等于可采购,不表达其他 SYB 业务状态,不由 status/orderStatus、规格解析或 PDD 排除标记推断。
|
||||
- 正常同步成功取得有效值后覆盖原值,允许由取消变回未取消;失败、字段缺失/null、未知值或空页不改变既有取消状态与取得时间。历史记录不默认未取消。
|
||||
- 仅更新同步范围内实际导入的明细;超出同步创建日期范围的旧订单保留上次状态,未经同步的显示未获取。此列不是实时核验,列表加载不会请求 SYB。
|
||||
- 本单严格只做展示:不修改创建、批量、重试、Claim/Start、下单边界、采购资格或任务状态,不自动拦截已取消订单,不影响已存在 PDD 订单或回填事实。后续采购拦截另行评估。
|
||||
- 本单严格只做展示:不修改创建、批量、重试、Claim/Start、下单边界、采购资格或任务状态,不自动拦截已取消订单,不影响已存在 PDD 订单或回填事实。后续采购拦截已由 #381 实现(见「SYB 已取消订单不进入 PDD 流程(#381)」)。
|
||||
- “匹配退货”仅是原“匹配到的退货商品”的表头缩短和列宽调整,内容、权限、匹配/使用/取消规则不变。允许少量表格横向滚动。
|
||||
|
||||
## SYB 字母尺码与体重连写识别(#358)
|
||||
@@ -891,7 +892,7 @@ Android 0.9.64 / versionCode 77,源码 `6550b9f`(分支实现,尚未安装
|
||||
|
||||
## 采购页面一次性交接与导航稳定等待(#373 v4)
|
||||
|
||||
实现绑定分支 `fix/373-navigation-diagnostics` 提交 `dbf9a5f`,调试版本 `0.9.69-373-fix1`;2026-10-09 尚未合并main、发布或安装该修复版。此节是该提交行为,不代表线上旧版本已生效;替代此前仅凭商品结构复用页面的方式。
|
||||
实现绑定分支 `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耗时保证。
|
||||
@@ -905,7 +906,9 @@ Android 0.9.64 / versionCode 77,源码 `6550b9f`(分支实现,尚未安装
|
||||
|
||||
## 下单前最终规格复核(#374 v3)
|
||||
|
||||
实现绑定 `6f1af70d0d2a4c70c31ba42e05c4321f08c6a78e`(2026-10-09,工单分支 `fix/374-final-spec-confirmation`,基于 main `e26743c`);尚未合并、安装或发布,不含 #373 导航修复,真机采购未验收。
|
||||
> 以下为 #374 版本行为;包含 #376 的分支构建已按本文后续“采购改地址前复核与提交提示检查”章节替代最终复核策略,尚未合并或发布。不把新规则归于未更新的旧 APK。
|
||||
|
||||
实现绑定 `6f1af70d0d2a4c70c31ba42e05c4321f08c6a78e`(原独立工单分支基于 `e26743c`);2026-10-09 已与 #373 集成并推送 main `555a954605a83c12457ed1ada86eb732fe3bb023`。集成版本同时包含导航等待与最终规格复核,未安装或发布,真机采购未验收。
|
||||
|
||||
- 仅替换 PurchaseLiveAutomation.finalConfirmation 的最终规格检查,不修改之前的规格选择/verifySummary、地址、单价、数量、提交目标与不可逆边界。没有 Server/Web、API 或数据库变化。
|
||||
- 最终专用规范化只统一【】〔〕[]、()、全角字母数字与空白;压缩连续空白,不折叠大小写、不改同义词、不用通用 NFKC、不删除括号内容。选项使用解析器既有的规格 text(沿用 UI 尾价分离),目标和摘要不新增尾价或数量删除。
|
||||
@@ -916,3 +919,98 @@ Android 0.9.64 / versionCode 77,源码 `6550b9f`(分支实现,尚未安装
|
||||
- 多个不一致的可信摘要、已知组合歧义均拒绝。单个不匹配的摘要不能补证缺失维度;若所有必需维度已有当前精确选中证明且无上述冲突,则按选中证明优先通过。不声称候选缺失时能证明全部潜在 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。2026-10-12 Server 随 main `15bab9c` 发布到 122.228.200.167 并执行迁移 `1791600000000`;Android 部分尚未装采购机。
|
||||
|
||||
默认心跳 15 秒、离线阈值 45 秒、离线扫描 15 秒不变。扫描逐设备独立事务:锁定最新设备/任务状态后,将设备离线、相关任务和 attempt 收敛、对应状态事件一起提交。锁争用时本设备不作任何部分修改,跳过后继续其他设备,下轮再检查;不自动重试采购或换机。
|
||||
|
||||
运行中采集和未提交订单采购仍转失败;已 `order_submit_started` 的采购仍转 `order_result_unknown`,人工核对、禁止自动重派。两个数量分开记录。诊断历史是尽力保存,不作为设备在线或任务业务状态的事实来源;事件失败回滚当前设备业务变化,其他已提交设备保持不变。
|
||||
|
||||
|
||||
Android同步诊断不改变#377的心跳→恢复→补传→必要的一次任务不一致心跳→符合条件才领任务的顺序。诊断failure是本轮观察到的同步错误,不等同于业务任务失败;恢复内部暂缓处理的API错误也记录,但不改变原恢复占位、后续flush/claim规则,不增加采购重试或付款动作。
|
||||
|
||||
可恢复诊断存储异常不阻断同步,取消和致命错误不记成假成功。同步记录独立保留24小时/6000轮,不占采集50条额度;旧进行中记录只表示中断/不完整,不伪造原现场、失败原因或成功。报告确认不会清除发送期间新增失败;本地确认失败或响应丢失允许重复,报告不能用于改变设备在线或采购状态。
|
||||
|
||||
## 规格规范化同名冲突与徽标说明合并(#379)
|
||||
|
||||
合并 main `4039fda`(2026-10-12);单元测试覆盖,测试机回归通过;采购1(PDD 8.26.0)徽标形态的真机验证待装机时完成。普通采集与采购规格探测共用同一采集器。
|
||||
|
||||
- 变更 #160 的同名冲突规则:同一维度内不同原文规范化后同名(同帧或跨帧,例如尾价时有时无)时不再整维作废,只保留一个值(先出现的原文及其价格、图片),在 missing 写 `spec_dimension_conflict:<dimensionKey>`,任务为 `completed_partial`。用户接受「重复或合并规格」的风险,不接受「整组没有尺码/颜色」。
|
||||
- 规范化后为空或仍含 `¥/¥` 的值维持整维拒绝,写 `spec_dimension_invalid:<dimensionKey>`,不放宽;服务端规范化防线不变。
|
||||
- 尺码续页路径与正常路径一样去掉尾价,冲突检查在去重之前完成。
|
||||
- #362 截短卡片合并扩展:说明文字形如「截短名[...|…] ¥金额 徽标」(徽标 1–8 个字符,不含货币符号、数字和空白)时,金额须等于外层完整名的尾价,去掉省略号后须为完整名的严格前缀,并满足 #362 全部结构条件,才与外层卡片合并;任一条件不满足行为不变。不维护徽标词表。
|
||||
- 残余风险:两个真实不同、规范化后同名、且从未同屏出现的选项会被合并为一个;同屏出现时下单因 `SPEC_TARGET_AMBIGUOUS` 失败,不会任选。
|
||||
- 未覆盖:说明文字只有徽标、不带价格(如「…零差评」)时仍可能多出一个截短重复颜色,不导致作废。
|
||||
|
||||
## Agent 详情页在拼多多中打开商品(#375)
|
||||
|
||||
合并 main `82ed666`(2026-10-12);测试机真机验证通过,采购机未装机。
|
||||
|
||||
- 采集详情与采购详情显示描边按钮「在拼多多中打开」,用任务商品编号拼 `https://mobile.yangkeduo.com/goods.html?goods_id=<编号>`;编号为空或非纯数字时不显示。用途仅为人工查看商品,不复现后台执行过程。
|
||||
- 采集按钮沿用采集的浏览器打开方式;采购按钮沿用采购方式先直达 PDD,失败回退浏览器,并提示异常简单类名。人工入口不代点浏览器中的「打开拼多多APP」,不在 PDD 内点击任何控件;提示不含链接和异常消息原文。
|
||||
- 打开由 Agent 服务执行:点击时再判断设备是否忙,忙则不打开;不忙时先作废 #373 一次性交接凭证再打开,下一次采购必须重新打开本任务链接。
|
||||
- 设备忙:重购一轮进行中(含两个任务之间)、订单回填中、当前页采集进行中、设备正在执行任务(含图搜)、有未上传或待恢复的采购结果;任务结束后的冷却期不算忙。忙时按钮置灰并显示原因,状态约每 2 秒发布,与 `repurchaseLocalIdle` 共用同一判断。
|
||||
- 不暂停领任务,不改调度、心跳和租约;查看期间来了新任务由 Agent 接管屏幕,打开后提示「打开期间如有新任务,Agent 会接管屏幕」。不上传打开结果,不写日志或诊断。
|
||||
|
||||
## SYB 已取消订单不进入 PDD 流程(#381)
|
||||
|
||||
合并 main `c5709a0`,2026-10-12 随 `15bab9c` 发布到线上(Server + Web),待业务验收。
|
||||
|
||||
- `syb_product.syb_is_cancelled = true`(同步取得 `isCancel=1`)的明细:不能创建采购任务(`PURCHASE_SYB_CANCELLED`「SYB 订单已取消,无需采购」),批量采购预览、AI 规格匹配资格、批量采集资格给出同一原因,图搜采集批量逐项拒绝,不参与退货匹配。NULL(未获取)与 false 不拦截,不推断。
|
||||
- 处理阶段新增 `syb_cancelled`「已取消」,优先级:已有任务或订单结果 → 退货匹配 → 已取消 → 无需采购(#340)→ 其余;同时被过滤规则标记的行显示「已取消」,创建接口仍拒绝(返回码可能是 `PURCHASE_PDD_EXCLUDED`)。
|
||||
- 同步照常导入并更新取消状态;订单恢复为未取消后阶段与资格自动恢复。
|
||||
- 不拦截已存在的采购任务(Claim/Start、下单边界与状态机不变);已下单的已取消明细需人工售后。
|
||||
- 取消状态只随同步日期范围(当天与前一天创建的货运单)刷新,更早订单在 SYB 被取消后仍按上次状态处理。
|
||||
|
||||
## 采购任务创建人与采购管理筛选(#382)
|
||||
|
||||
合并 main `0893b55`,2026-10-12 发布并执行迁移 `1791700000000`,待业务验收。
|
||||
|
||||
- `purchase_task.created_by`(可空)与 `created_source` 只在新建任务时写入一次:Admin 单个创建、备货、批量创建为 `admin` + 登录账号;客户端密钥接口为 `client_api` + 授权该密钥的账号;Agent 重试/重购为 `agent`、创建人为空。幂等重放不改写;Admin 批量重试是原地重置原任务,不新建任务、不改创建人。
|
||||
- 迁移前的历史任务 `created_source` 为空,显示「未记录」,不回填、不从操作日志反推。
|
||||
- Admin 采购管理可按设备(含「未分派」)和创建人(含「Agent 发起」「未记录」)筛选,导出沿用当前筛选;列表与详情显示创建人(Agent 显示「Agent」)。不改变可见范围:采购员仍可看到全部任务。
|
||||
|
||||
## Admin 采集任务按设备筛选(#383)
|
||||
|
||||
合并 main `15bab9c`,2026-10-12 随发布上线,待业务验收。采集任务页新增「设备」筛选(含已停用设备),使用服务端既有 `deviceId` 参数;选择设备时「取消未开始任务」按钮禁用,因为批量取消接口不支持设备维度,避免取消范围超出当前列表。
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Troubleshooting
|
||||
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Troubleshooting
|
||||
wiki_revision: 6b370764320d1f5148fbc31f0ef6ce999370173d
|
||||
synchronized_at: 2026-10-09T02:06:57Z
|
||||
wiki_revision: 50f5923623db34cddb9ea152238c11669a25eb95
|
||||
synchronized_at: 2026-10-12T08:31:42Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 故障排查
|
||||
@@ -158,3 +158,89 @@ adb -s <serial> shell run-as cn.ilapage.goauto.agent sqlite3 -readonly databases
|
||||
```
|
||||
|
||||
排查须绑定同一设备、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历史写入成功;响应丢失或确认失败可能重复补报。
|
||||
|
||||
## Android 颜色横向网格遍历(#370 第 2 阶段)
|
||||
|
||||
实现已合并 main(`26562fc`)。遍历过程只写 `Log.i`(标签 `GoAutoCollector`),不落库、不上报;排查时在已授权的 Debug 设备上实时抓取,用完删除:
|
||||
|
||||
```powershell
|
||||
adb -s <serial> logcat -v time -s GoAutoCollector:V
|
||||
```
|
||||
|
||||
- `color-start confirmed= swipes= anchorRetries= end=`:滑回最左端的结果;`confirmed=false` 时不会跳过逐行反扫。
|
||||
- `color-row row= dir= match= moved= others= sharedMoves= sharedBroken= uncertain= signatureReads=`:每次横滑后的行定位与是否移动;`others=moved` 表示多行共用一个横滑容器。
|
||||
- `color-row-end row= end=`:该行结束原因(如 `edge` 到达边缘)。
|
||||
- `color-settle waitedMs=1200`:横滑后的固定等待。
|
||||
- `color-click-miss`:点击目标未生效。
|
||||
- 漏色时先核对同一设备、Agent/PDD 版本和规则快照,再看 `color-row-end` 是否过早出现或 `color-start` 是否未确认。日志缓冲较小时可能被覆盖,缺少日志不能证明没执行。
|
||||
|
||||
## 采购探测报「没有找到可采购的 PDD 颜色或尺码」(#379)
|
||||
|
||||
服务端只看规格候选是否为空,不读 missing,该提示可能掩盖「某维度被整维作废」的真实原因:
|
||||
|
||||
1. 查同一次探测或采集结果的 missing:有 `spec_dimension_invalid:color|size` 表示该维度被整维清空(规范化后为空或仍含货币符号);`spec_dimension_conflict:*` 表示同名冲突已保留一个值,维度仍可用。
|
||||
2. 已知成因(2026-10-12,采购1 / PDD 8.26.0 / 商品 715527668284):颜色卡片的截短说明文字在价格后带「零差评」「最多人选择」等徽标,旧版本无法与外层卡片合并,说明值仍含 `¥` 导致颜色整维作废。#379 后满足条件的徽标说明会合并;同一商品在其他设备或 PDD 版本上可能不出现徽标,不能用测试机结果否定。
|
||||
3. 实时日志 `spec-normalize dim= outcome= reason= path= pass= sameFrame= onlyTrailingPriceDiff= rawVariants= candidates= badgeCaptionMerged=`:`outcome=invalid_rejected` 对应整维作废,`reason=unsafe_currency|empty`;`outcome=conflict_kept` 对应同名冲突保留;`badgeCaptionMerged` 为本帧合并的徽标卡片数。只含计数和枚举,不含规格原文。每维每类只记录首次。
|
||||
4. 若需看规格面板原文,只在用户手动打开面板后做一次只读 uiautomator 导出并过滤地址、电话,用完立即删除;不得为打开面板点击购买类按钮。
|
||||
|
||||
@@ -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: c05446aecd49fb7234f79f5d5e2df68f244166e9
|
||||
synchronized_at: 2026-10-12T08:31:57Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
<!-- gitea-wiki-mirror:start -->
|
||||
@@ -421,6 +421,7 @@ POST /api/agent/v1/tasks/{taskId}/result
|
||||
- size 规格值只允许剥离位于文本尾部的 ¥ / ¥ 数字价格片段,括号说明、体重区间和其他规格身份文本原样保留;Android 与服务端使用相同语义。
|
||||
- 服务端在结果完整性校验前执行规范化。规范化后为空、同维度重复、仍残留货币符号或无法由最小安全规则确定时,整维不接受,并原子丢弃引用该维度的 SKU;color 被拒时同时丢弃 colorPrices。
|
||||
- 服务端强制将任务收敛为 completed_partial,并在 missing 增加稳定值 spec_dimension_invalid:<dimensionKey>,例如 spec_dimension_invalid:size。客户端应把它展示为对应规格维度无法安全采集,不得视为完整档案,也不得自行猜测或补齐候选。
|
||||
- #379 起 Android 在提交前处理规范化同名冲突:同一维度不同原文规范化后同名时只提交一个值,并在 missing 增加稳定值 `spec_dimension_conflict:<dimensionKey>`(任务为 completed_partial,不新增字段)。它表示该维度可能把规范化后同名的选项合并为一个,维度本身仍可用;残留货币符号或空值仍由 Android 整维清空并写 `spec_dimension_invalid:<dimensionKey>`。服务端只保存该 missing 值、不校验其种类;图搜自动关联白名单仍只含 reviewCount、salesText、shopName。
|
||||
- 被拒维度不得以污染值覆盖商品已有档案;历史任务和既有商品数据不自动清洗,须在修复后的 Agent 上重新采集。
|
||||
|
||||
## Android:提交失败
|
||||
@@ -1530,3 +1531,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页面内容。
|
||||
|
||||
@@ -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: 01a3a9cd968510c9435ec1fb2be0040fe17d0282
|
||||
synchronized_at: 2026-10-09T07:45:33Z
|
||||
wiki_revision: 4103d9b6174bdba2886c4916e85e0763ce2dcd53
|
||||
synchronized_at: 2026-10-12T09:37:52Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
<!-- gitea-wiki-mirror:start -->
|
||||
@@ -365,3 +365,70 @@ Provider 故障日志只允许记录调用关联 ID、操作类型、耗时、
|
||||
- 主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分钟心跳验收。
|
||||
- 2026-10-12 Server 已随 main `15bab9c` 发布并执行迁移 `1791600000000`(新建 `agent_heartbeat_log`、`agent_device_status_event`),重启后心跳历史开始写入;Android 部分未装采购机。
|
||||
|
||||
|
||||
## #372 v3 导出店铺列的纯 Web 发布(2026-10-10)
|
||||
|
||||
- 用户授权合并 #372 v3 并部署线上。`feat/372-export-shop` 以 `--no-ff` 合并为 main `3d5e964`;Web 单元测试 93/93 通过,`build:prod` 通过。相对上一线上 Web(`4ad59bd`),Web 只有 v3 的导出改动。
|
||||
- **main 已含 #378 的 Server 代码和迁移,本次没有部署**:线上 Server 仍是 `72a15f8` 构建的二进制。#378 的 Server 发布和迁移需另行授权。
|
||||
- 发布目录 `/home/goauto/releases/20261010-3d5e964-372v3-web`,只替换 dist。goauto-server 与 config 从上一发布逐字复制(cmp/diff 一致),static/temp/var 沿用原目录,旧 js/css 哈希资源保留。index.html SHA256 `b900a40ec4691f0fb2c3d89e1e20826e7ca3205db311a33c24087767a515979a`。
|
||||
- 只原子切换 current,不重启 GoAuto/Nginx;切换前后 PID 4786 不变,服务 active。
|
||||
- 按内容验收:`/` 与 `/login` 返回新 index,10 项 JS/CSS 200,线上导出模块的 SYB 表头含「店铺」,health 200,验证码 code=200,未登录业务接口 401。登录后的实际导出待业务验收。
|
||||
- 回滚:current 原子切回 `/home/goauto/releases/20261010-4ad59bd-372v2-web`,不需要重启。
|
||||
|
||||
## #381/#382/#383 + #378 Server 发布与迁移(2026-10-12)
|
||||
|
||||
- 用户授权按顺序推送、合并、发布、更新 Wiki,并要求「如果线上有采集和采购任务进行着,等待无任务时再重启」;发布前用户选择按 main 发布(含此前未发布的 #378 Server 与迁移)。
|
||||
- main `15bab9c`(#381 `c5709a0`、#382 `0893b55`、#383 `15bab9c`,基于 `16e3d35`)。合并后 `go test ./...`:除既有失败外通过(`sybimport` 基线已有失败;`apprelease` 的 `TestParseBuiltAgentAPKWhenAvailable` 仅在本地存在 Debug APK 时运行,断言旧版本号 0.9.41,属既有过时用例);Linux amd64 `CGO_ENABLED=0` 构建、Web `build:prod` 通过。
|
||||
- 发布目录 `/home/goauto/releases/20261012-15bab9c-381-382-383`:goauto-server SHA256 `f34864441203e4dfba51c06d0c9db6c6648c7ef34c7c2eb78a61b2d869ff3695`(运行进程一致),index.html SHA256 `a4c85aa8c91bdae2d11eda0756ee7482cafbb83b8e1782d61a2f99df0ff5d853`;config 与上一发布 diff 一致,static/temp/var 链接沿用,旧 js/css 哈希资源保留(`cp -rn`)。
|
||||
- 备份:`/home/goauto/backups/20261012-381-382-383/database.sql`(0600,single-transaction,`/opt/mysql84-root/usr/bin/mysqldump`;账号缺 PROCESS 权限只导致表空间信息未导出,可加 `--no-tablespaces` 消除提示)。
|
||||
- 迁移:待执行为 `1791600000000`、`1791700000000`,执行成功(退出码 0),新会话回读 `sys_migration`、新列与索引 `idx_purchase_task_created_by`、两张新表;采购任务 2623 行完好。
|
||||
- `[必须]` 本机 systemd 为 219,`systemd-run` 不支持 `--wait/--pipe`;`/etc/goauto/goauto.env` 含括号与引号,不能用 shell `source`。迁移使用 root 私有脚本 `/root/goauto-migrate-20261012.sh` 逐行读取 `KEY=VALUE`(去掉成对引号,不经 shell 求值)后执行 `goauto-server migrate -c config/settings.yml`;日志存备份目录,不输出凭据。
|
||||
- 迁移前与切换前确认无执行中的采集/采购/SYB 同步/AI 解析/自动匹配;1 个 pending 采购任务保持原状。current 原子切换后重启 `goauto.service`,active;Nginx 未改。
|
||||
- 按内容验收:`/`、`/login` 返回新 index(含 `id="app"`、无欢迎页),10 项 JS/CSS 200,验证码 code=200,未登录访问 `yeeke-returns`、`purchase-tasks/filter-options`、带 `deviceId=unassigned` 的采购列表均为业务码 401;重启后日志无 panic/fatal/1146/1054;采购1、采购2 心跳恢复在线,`agent_heartbeat_log` 开始写入。
|
||||
- 回滚:current 原子切回 `/home/goauto/releases/20261010-3d5e964-372v3-web` 并重启;新增表与列保留不影响旧代码,不回退数据库。
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"github.com/go-admin-team/go-admin-core/sdk/pkg"
|
||||
"github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth/user"
|
||||
"go-admin/app/goauto/device"
|
||||
"go-admin/common/clientprincipal"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
@@ -72,13 +73,21 @@ func (h Handler) Upload(c *gin.Context) {
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
result, err := s.Upload(c.Request.Context(), c.PostForm("releaseNotes"), uint64(user.GetUserId(c)), stream)
|
||||
result, err := s.Upload(c.Request.Context(), c.PostForm("releaseNotes"), operatorID(c), stream)
|
||||
if err != nil {
|
||||
writeError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, gin.H{"code": http.StatusOK, "data": result})
|
||||
}
|
||||
|
||||
// operatorID returns the authorizing user for client API keys and the JWT user otherwise.
|
||||
func operatorID(c *gin.Context) uint64 {
|
||||
if id, ok := clientprincipal.Get(c); ok {
|
||||
return id.AuthorizedBy
|
||||
}
|
||||
return uint64(user.GetUserId(c))
|
||||
}
|
||||
func (h Handler) SetCurrent(c *gin.Context) {
|
||||
var req SetCurrentRequest
|
||||
if err := decode(c, &req); err != nil {
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
package apprelease
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
|
||||
"go-admin/app/goauto/models"
|
||||
"go-admin/common/clientprincipal"
|
||||
)
|
||||
|
||||
// buildTestAPK returns a minimal zip whose binary AndroidManifest.xml carries
|
||||
// the manifest versionCode/versionName attributes that parseAPK reads.
|
||||
func buildTestAPK(t *testing.T, versionCode uint32, versionName string) []byte {
|
||||
t.Helper()
|
||||
pool := []string{"manifest", "versionCode", "versionName", versionName}
|
||||
var strs bytes.Buffer
|
||||
offsets := make([]uint32, len(pool))
|
||||
for i, s := range pool {
|
||||
offsets[i] = uint32(strs.Len())
|
||||
strs.WriteByte(byte(len(s)))
|
||||
strs.WriteByte(byte(len(s)))
|
||||
strs.WriteString(s)
|
||||
strs.WriteByte(0)
|
||||
}
|
||||
for strs.Len()%4 != 0 {
|
||||
strs.WriteByte(0)
|
||||
}
|
||||
headerSize := 28
|
||||
poolSize := headerSize + len(pool)*4 + strs.Len()
|
||||
var m bytes.Buffer
|
||||
le := func(v any) { _ = binary.Write(&m, binary.LittleEndian, v) }
|
||||
le(uint16(0x0003))
|
||||
le(uint16(8))
|
||||
le(uint32(0)) // total size, patched below
|
||||
le(uint16(0x0001))
|
||||
le(uint16(headerSize))
|
||||
le(uint32(poolSize))
|
||||
le(uint32(len(pool)))
|
||||
le(uint32(0))
|
||||
le(uint32(0x100))
|
||||
le(uint32(headerSize + len(pool)*4))
|
||||
le(uint32(0))
|
||||
for _, o := range offsets {
|
||||
le(o)
|
||||
}
|
||||
m.Write(strs.Bytes())
|
||||
attrs := 2
|
||||
le(uint16(0x0102))
|
||||
le(uint16(16))
|
||||
le(uint32(36 + attrs*20))
|
||||
le(uint32(1)) // line
|
||||
le(uint32(0xffffffff)) // comment
|
||||
le(uint32(0xffffffff)) // ns
|
||||
le(uint32(0)) // name -> manifest
|
||||
le(uint16(20)) // attributeStart
|
||||
le(uint16(20)) // attributeSize
|
||||
le(uint16(attrs))
|
||||
le(uint16(0))
|
||||
le(uint16(0))
|
||||
le(uint16(0))
|
||||
attr := func(name uint32, raw uint32, typ byte, data uint32) {
|
||||
le(uint32(0xffffffff))
|
||||
le(name)
|
||||
le(raw)
|
||||
le(uint16(8))
|
||||
le(byte(0))
|
||||
le(typ)
|
||||
le(data)
|
||||
}
|
||||
attr(1, 0xffffffff, 0x10, versionCode)
|
||||
attr(2, 3, 0x03, 3)
|
||||
raw := m.Bytes()
|
||||
binary.LittleEndian.PutUint32(raw[4:], uint32(len(raw)))
|
||||
var out bytes.Buffer
|
||||
zw := zip.NewWriter(&out)
|
||||
w, err := zw.Create("AndroidManifest.xml")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, _ = w.Write(raw)
|
||||
if err = zw.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return out.Bytes()
|
||||
}
|
||||
|
||||
func uploadRequest(t *testing.T, filename string, content []byte) *http.Request {
|
||||
t.Helper()
|
||||
var body bytes.Buffer
|
||||
mw := multipart.NewWriter(&body)
|
||||
part, _ := mw.CreateFormFile("file", filename)
|
||||
_, _ = part.Write(content)
|
||||
_ = mw.WriteField("releaseNotes", "test")
|
||||
_ = mw.Close()
|
||||
req := httptest.NewRequest("POST", "/upload", &body)
|
||||
req.Header.Set("Content-Type", mw.FormDataContentType())
|
||||
return req
|
||||
}
|
||||
|
||||
func TestUploadCreatorUsesClientAuthorizerOrJWTUser(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db := releaseTestDB(t)
|
||||
h := Handler{DB: db, StorageDir: t.TempDir()}
|
||||
router := gin.New()
|
||||
router.POST("/upload", func(c *gin.Context) {
|
||||
if c.GetHeader("X-Test-Client") != "" {
|
||||
clientprincipal.Set(c, clientprincipal.Identity{KeyID: 5, RequestID: "r", AuthorizedBy: 42})
|
||||
} else {
|
||||
c.Set(jwt.JwtPayloadKey, jwt.MapClaims{"identity": float64(7)})
|
||||
}
|
||||
h.Upload(c)
|
||||
})
|
||||
send := func(client bool, filename string, content []byte) *httptest.ResponseRecorder {
|
||||
req := uploadRequest(t, filename, content)
|
||||
if client {
|
||||
req.Header.Set("X-Test-Client", "1")
|
||||
}
|
||||
rr := httptest.NewRecorder()
|
||||
router.ServeHTTP(rr, req)
|
||||
return rr
|
||||
}
|
||||
if rr := send(true, "agent.apk", buildTestAPK(t, 101, "1.0.1")); rr.Code != 201 {
|
||||
t.Fatalf("client upload %d %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
if rr := send(false, "agent.apk", buildTestAPK(t, 102, "1.0.2")); rr.Code != 201 {
|
||||
t.Fatalf("admin upload %d %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
var rows []models.AgentAppRelease
|
||||
if err := db.Order("version_code").Find(&rows).Error; err != nil || len(rows) != 2 {
|
||||
t.Fatalf("rows=%d err=%v", len(rows), err)
|
||||
}
|
||||
if rows[0].CreatedBy != 42 || rows[1].CreatedBy != 7 {
|
||||
t.Fatalf("createdBy client=%d admin=%d", rows[0].CreatedBy, rows[1].CreatedBy)
|
||||
}
|
||||
if rr := send(true, "agent.zip", buildTestAPK(t, 103, "1.0.3")); rr.Code != 422 {
|
||||
t.Fatalf("non-apk accepted %d", rr.Code)
|
||||
}
|
||||
rr := send(true, "again.apk", buildTestAPK(t, 101, "1.0.1"))
|
||||
var res struct{ Code string }
|
||||
_ = json.Unmarshal(rr.Body.Bytes(), &res)
|
||||
if rr.Code != 409 || res.Code != "AGENT_RELEASE_CONFLICT" {
|
||||
t.Fatalf("duplicate versionCode %d %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/go-admin-team/go-admin-core/sdk/pkg"
|
||||
"go-admin/app/goauto/apprelease"
|
||||
"go-admin/app/goauto/clientkey"
|
||||
"go-admin/common/clientprincipal"
|
||||
"gorm.io/gorm"
|
||||
@@ -80,7 +81,7 @@ func Gate(db *gorm.DB, e Endpoint) gin.HandlerFunc {
|
||||
return
|
||||
}
|
||||
clientprincipal.Set(c, clientprincipal.Identity{KeyID: key.ID, RequestID: requestID, AuthorizedBy: key.UpdatedBy})
|
||||
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 16<<20)
|
||||
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, bodyLimit(e))
|
||||
original := c.Writer
|
||||
buffer := &responseBuffer{ResponseWriter: original, status: 200}
|
||||
c.Writer = buffer
|
||||
@@ -104,6 +105,15 @@ func Gate(db *gorm.DB, e Endpoint) gin.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// bodyLimit keeps 16MB for every endpoint except the Agent APK upload, which
|
||||
// needs room for the APK itself plus multipart overhead (same bound as Admin).
|
||||
func bodyLimit(e Endpoint) int64 {
|
||||
if e.Method == "POST" && e.Path == "/agent-app-releases" {
|
||||
return apprelease.MaxAPKBytes + (1 << 20)
|
||||
}
|
||||
return 16 << 20
|
||||
}
|
||||
|
||||
type responseBuffer struct {
|
||||
gin.ResponseWriter
|
||||
body bytes.Buffer
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
package clientapi
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
"go-admin/app/goauto/apprelease"
|
||||
"go-admin/app/goauto/clientkey"
|
||||
"go-admin/app/goauto/models"
|
||||
)
|
||||
|
||||
// releaseAPK builds a minimal APK whose binary manifest carries the version fields.
|
||||
func releaseAPK(t *testing.T, code uint32, name string) []byte {
|
||||
t.Helper()
|
||||
pool := []string{"manifest", "versionCode", "versionName", name}
|
||||
var strs bytes.Buffer
|
||||
offsets := make([]uint32, len(pool))
|
||||
for i, s := range pool {
|
||||
offsets[i] = uint32(strs.Len())
|
||||
strs.WriteByte(byte(len(s)))
|
||||
strs.WriteByte(byte(len(s)))
|
||||
strs.WriteString(s)
|
||||
strs.WriteByte(0)
|
||||
}
|
||||
for strs.Len()%4 != 0 {
|
||||
strs.WriteByte(0)
|
||||
}
|
||||
var m bytes.Buffer
|
||||
le := func(v any) { _ = binary.Write(&m, binary.LittleEndian, v) }
|
||||
le(uint16(3))
|
||||
le(uint16(8))
|
||||
le(uint32(0))
|
||||
le(uint16(1))
|
||||
le(uint16(28))
|
||||
le(uint32(28 + len(pool)*4 + strs.Len()))
|
||||
le(uint32(len(pool)))
|
||||
le(uint32(0))
|
||||
le(uint32(0x100))
|
||||
le(uint32(28 + len(pool)*4))
|
||||
le(uint32(0))
|
||||
for _, o := range offsets {
|
||||
le(o)
|
||||
}
|
||||
m.Write(strs.Bytes())
|
||||
le(uint16(0x0102))
|
||||
le(uint16(16))
|
||||
le(uint32(36 + 40))
|
||||
le(uint32(1))
|
||||
le(uint32(0xffffffff))
|
||||
le(uint32(0xffffffff))
|
||||
le(uint32(0))
|
||||
le(uint16(20))
|
||||
le(uint16(20))
|
||||
le(uint16(2))
|
||||
le(uint16(0))
|
||||
le(uint16(0))
|
||||
le(uint16(0))
|
||||
attr := func(n, raw uint32, typ byte, data uint32) {
|
||||
le(uint32(0xffffffff))
|
||||
le(n)
|
||||
le(raw)
|
||||
le(uint16(8))
|
||||
le(byte(0))
|
||||
le(typ)
|
||||
le(data)
|
||||
}
|
||||
attr(1, 0xffffffff, 0x10, code)
|
||||
attr(2, 3, 0x03, 3)
|
||||
raw := m.Bytes()
|
||||
binary.LittleEndian.PutUint32(raw[4:], uint32(len(raw)))
|
||||
var out bytes.Buffer
|
||||
zw := zip.NewWriter(&out)
|
||||
w, _ := zw.Create("AndroidManifest.xml")
|
||||
_, _ = w.Write(raw)
|
||||
_ = zw.Close()
|
||||
return out.Bytes()
|
||||
}
|
||||
|
||||
func multipartUpload(content []byte, filename string) (*bytes.Buffer, string) {
|
||||
var body bytes.Buffer
|
||||
mw := multipart.NewWriter(&body)
|
||||
part, _ := mw.CreateFormFile("file", filename)
|
||||
_, _ = part.Write(content)
|
||||
_ = mw.Close()
|
||||
return &body, mw.FormDataContentType()
|
||||
}
|
||||
|
||||
func TestAgentReleaseActionsAreSeparateAndExplicit(t *testing.T) {
|
||||
var devices clientkey.Module
|
||||
for _, m := range Catalog(Inventory()) {
|
||||
if m.Key == "devices" {
|
||||
devices = m
|
||||
}
|
||||
}
|
||||
if strings.Join(devices.Actions, ",") != "release_activate,release_upload" || devices.Writable {
|
||||
t.Fatalf("devices catalog=%+v", devices)
|
||||
}
|
||||
db, s := fixture(t)
|
||||
if err := db.AutoMigrate(&models.AgentAppRelease{}, &models.AgentAppReleaseSetting{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h := apprelease.Handler{DB: db, StorageDir: t.TempDir()}
|
||||
router := gin.New()
|
||||
found := 0
|
||||
for _, e := range Inventory() {
|
||||
if !strings.HasPrefix(e.Path, "/agent-app-releases") {
|
||||
continue
|
||||
}
|
||||
found++
|
||||
switch e.Method + e.Path {
|
||||
case "GET/agent-app-releases":
|
||||
e.Handle = h.List
|
||||
case "POST/agent-app-releases":
|
||||
e.Handle = h.Upload
|
||||
case "PUT/agent-app-releases/current":
|
||||
e.Handle = h.SetCurrent
|
||||
default:
|
||||
t.Fatalf("unexpected release route %s", keyFor(e))
|
||||
}
|
||||
router.Handle(e.Method, "/api/client/v1"+e.Path, Gate(db, e), e.Handle)
|
||||
}
|
||||
if found != 3 {
|
||||
t.Fatalf("release routes=%d", found)
|
||||
}
|
||||
newKey := func(grant clientkey.Grant) string {
|
||||
_, token, err := s.Create(context.Background(), "k", []clientkey.Grant{grant}, 42)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return token
|
||||
}
|
||||
do := func(token, method, path, contentType string, body *bytes.Buffer) *httptest.ResponseRecorder {
|
||||
if body == nil {
|
||||
body = &bytes.Buffer{}
|
||||
}
|
||||
req := httptest.NewRequest(method, "https://example.test/api/client/v1"+path, body)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
if contentType != "" {
|
||||
req.Header.Set("Content-Type", contentType)
|
||||
}
|
||||
rr := httptest.NewRecorder()
|
||||
router.ServeHTTP(rr, req)
|
||||
return rr
|
||||
}
|
||||
upload := func(token string, code uint32) *httptest.ResponseRecorder {
|
||||
body, ct := multipartUpload(releaseAPK(t, code, "9.9.9"), "agent.apk")
|
||||
return do(token, "POST", "/agent-app-releases", ct, body)
|
||||
}
|
||||
setCurrent := func(token string, id uint64) *httptest.ResponseRecorder {
|
||||
raw, _ := json.Marshal(map[string]any{"requestId": uuid.NewString(), "releaseId": id})
|
||||
return do(token, "PUT", "/agent-app-releases/current", "application/json", bytes.NewBuffer(raw))
|
||||
}
|
||||
|
||||
// A key with only devices read must be rejected everywhere (devices has no
|
||||
// write capability, so a write grant cannot even be created).
|
||||
if _, _, err := s.Create(context.Background(), "k", []clientkey.Grant{{Module: "devices", Write: true}}, 42); err == nil {
|
||||
t.Fatal("devices write grant must be invalid")
|
||||
}
|
||||
for name, token := range map[string]string{
|
||||
"devices read": newKey(clientkey.Grant{Module: "devices"}),
|
||||
} {
|
||||
if rr := do(token, "GET", "/agent-app-releases", "", nil); rr.Code != 403 {
|
||||
t.Fatalf("%s list %d", name, rr.Code)
|
||||
}
|
||||
if rr := upload(token, 1); rr.Code != 403 {
|
||||
t.Fatalf("%s upload %d", name, rr.Code)
|
||||
}
|
||||
if rr := setCurrent(token, 1); rr.Code != 403 {
|
||||
t.Fatalf("%s activate %d", name, rr.Code)
|
||||
}
|
||||
}
|
||||
uploader := newKey(clientkey.Grant{Module: "devices", Actions: []string{"release_upload"}})
|
||||
if rr := do(uploader, "GET", "/agent-app-releases", "", nil); rr.Code != 200 {
|
||||
t.Fatalf("uploader list %d %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
if rr := upload(uploader, 201); rr.Code != 201 {
|
||||
t.Fatalf("uploader upload %d %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
var row models.AgentAppRelease
|
||||
if err := db.First(&row).Error; err != nil || row.CreatedBy != 42 {
|
||||
t.Fatalf("createdBy=%d err=%v", row.CreatedBy, err)
|
||||
}
|
||||
if rr := setCurrent(uploader, row.ID); rr.Code != 403 {
|
||||
t.Fatalf("uploader activated %d", rr.Code)
|
||||
}
|
||||
activator := newKey(clientkey.Grant{Module: "devices", Actions: []string{"release_activate"}})
|
||||
if rr := upload(activator, 202); rr.Code != 403 {
|
||||
t.Fatalf("activator uploaded %d", rr.Code)
|
||||
}
|
||||
if rr := setCurrent(activator, row.ID); rr.Code != 200 {
|
||||
t.Fatalf("activator set current %d %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
for _, e := range Inventory() {
|
||||
if strings.Contains(e.Path, "download") {
|
||||
t.Fatal("download must not be a client route")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentReleaseUploadBodyLimitIsScopedToUploadEndpoint(t *testing.T) {
|
||||
if got := bodyLimit(Endpoint{Method: "POST", Path: "/agent-app-releases"}); got != apprelease.MaxAPKBytes+(1<<20) {
|
||||
t.Fatalf("upload limit %d", got)
|
||||
}
|
||||
for _, e := range Inventory() {
|
||||
if e.Method == "POST" && e.Path == "/agent-app-releases" {
|
||||
continue
|
||||
}
|
||||
if got := bodyLimit(e); got != 16<<20 {
|
||||
t.Fatalf("%s limit %d", keyFor(e), got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentReleaseUploadAcceptsBodyAbove16MB(t *testing.T) {
|
||||
db, s := fixture(t)
|
||||
if err := db.AutoMigrate(&models.AgentAppRelease{}, &models.AgentAppReleaseSetting{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
e := Endpoint{Method: "POST", Path: "/agent-app-releases", Module: "devices", Capability: "release_upload", Handle: apprelease.Handler{DB: db, StorageDir: t.TempDir()}.Upload}
|
||||
router := gin.New()
|
||||
router.Handle(e.Method, "/api/client/v1"+e.Path, Gate(db, e), e.Handle)
|
||||
_, token, err := s.Create(context.Background(), "k", []clientkey.Grant{{Module: "devices", Actions: []string{"release_upload"}}}, 42)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 17MB of non-APK data passes the transport limit and fails only at APK parsing.
|
||||
body, ct := multipartUpload(bytes.Repeat([]byte{'x'}, 17<<20), "big.apk")
|
||||
req := httptest.NewRequest(http.MethodPost, "https://example.test/api/client/v1"+e.Path, body)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
req.Header.Set("Content-Type", ct)
|
||||
rr := httptest.NewRecorder()
|
||||
router.ServeHTTP(rr, req)
|
||||
if rr.Code != 422 || !strings.Contains(rr.Body.String(), "文件不是有效 APK") {
|
||||
t.Fatalf("status=%d body=%s", rr.Code, rr.Body.String())
|
||||
}
|
||||
var audits []clientkey.Audit
|
||||
if err = db.Where("event = ?", "request").Find(&audits).Error; err != nil || len(audits) != 1 || audits[0].Status != 422 {
|
||||
t.Fatalf("audit=%+v err=%v", audits, err)
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ package clientapi
|
||||
import (
|
||||
"go-admin/app/goauto/access"
|
||||
"go-admin/app/goauto/aimatching"
|
||||
"go-admin/app/goauto/apprelease"
|
||||
"go-admin/app/goauto/clientkey"
|
||||
"go-admin/app/goauto/device"
|
||||
"go-admin/app/goauto/product"
|
||||
@@ -39,9 +40,13 @@ func Inventory() []Endpoint {
|
||||
pr := purchaserule.Handler{}
|
||||
t := task.Handler{}
|
||||
buy := purchase.Handler{}
|
||||
release := apprelease.Handler{}
|
||||
return []Endpoint{
|
||||
{"POST", "/ai-matching-settings/resolve", access.ModuleAIMatching, "match", resolveSpecs},
|
||||
{"GET", "/devices", access.ModuleDevices, "read", device.Handler{}.List},
|
||||
{"GET", "/agent-app-releases", access.ModuleDevices, "release_upload", release.List},
|
||||
{"POST", "/agent-app-releases", access.ModuleDevices, "release_upload", release.Upload},
|
||||
{"PUT", "/agent-app-releases/current", access.ModuleDevices, "release_activate", release.SetCurrent},
|
||||
{"GET", "/pdd-products", access.ModulePDDProducts, "read", p.List},
|
||||
{"GET", "/pdd-products/:productId", access.ModulePDDProducts, "read", p.Detail},
|
||||
{"POST", "/pdd-products", access.ModulePDDProducts, "write", p.Create},
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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(¤t, 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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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" }
|
||||
@@ -170,8 +170,13 @@ type PurchaseTask struct {
|
||||
CancelReason *string `json:"cancelReason" gorm:"size:500"`
|
||||
ErrorCode *string `json:"errorCode" gorm:"size:64;index"`
|
||||
ErrorMessage *string `json:"errorMessage" gorm:"size:1000"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
// CreatedBy/CreatedSource 只在新建任务时写入一次(#382):admin=管理端账号,
|
||||
// client_api=客户端密钥的授权人,agent=设备重试(CreatedBy 为空);空串表示
|
||||
// 迁移前的历史任务,不回填。
|
||||
CreatedBy *uint64 `json:"createdBy" gorm:"index"`
|
||||
CreatedSource string `json:"createdSource" gorm:"size:16;not null;default:''"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
func (PurchaseTask) TableName() string { return "purchase_task" }
|
||||
|
||||
@@ -4,6 +4,8 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
@@ -25,6 +27,75 @@ 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
|
||||
// DeviceID is a numeric device id or "unassigned" (#382).
|
||||
DeviceID string
|
||||
// CreatedBy is a numeric user id, "agent" or "unrecorded" (#382).
|
||||
CreatedBy string
|
||||
}
|
||||
|
||||
const (
|
||||
adminFilterUnassigned = "unassigned"
|
||||
adminFilterAgent = "agent"
|
||||
adminFilterUnrecorded = "unrecorded"
|
||||
)
|
||||
|
||||
// parseAdminIDFilter returns (id, keyword, error); at most one of id/keyword is
|
||||
// set. Empty input means the filter is absent.
|
||||
func parseAdminIDFilter(raw, label string, keywords ...string) (uint64, string, error) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return 0, "", nil
|
||||
}
|
||||
for _, keyword := range keywords {
|
||||
if raw == keyword {
|
||||
return 0, keyword, nil
|
||||
}
|
||||
}
|
||||
id, err := strconv.ParseUint(raw, 10, 64)
|
||||
if err != nil || id == 0 {
|
||||
return 0, "", fail(CodeInvalidRequest, label+" 无效")
|
||||
}
|
||||
return id, "", nil
|
||||
}
|
||||
|
||||
// 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 {
|
||||
@@ -38,6 +109,9 @@ type AdminTaskItem struct {
|
||||
PDDProductID uint64 `json:"pddProductId"`
|
||||
DeviceID *uint64 `json:"deviceId,omitempty"`
|
||||
DeviceName string `json:"deviceName,omitempty"`
|
||||
CreatedBy *uint64 `json:"createdBy,omitempty"`
|
||||
CreatedByName string `json:"createdByName,omitempty"`
|
||||
CreatedSource string `json:"createdSource"`
|
||||
ShopeeItemIDSnapshot string `json:"shopeeItemIdSnapshot"`
|
||||
ShopeeOrderNoSnapshot string `json:"shopeeOrderNoSnapshot"`
|
||||
ShopeeTitleSnapshot string `json:"shopeeTitleSnapshot"`
|
||||
@@ -175,10 +249,40 @@ 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
|
||||
}
|
||||
deviceID, deviceKeyword, err := parseAdminIDFilter(req.DeviceID, "deviceId", adminFilterUnassigned)
|
||||
if err != nil {
|
||||
return AdminListResponse{}, err
|
||||
}
|
||||
createdByID, createdByKeyword, err := parseAdminIDFilter(req.CreatedBy, "createdBy", adminFilterAgent, adminFilterUnrecorded)
|
||||
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)
|
||||
}
|
||||
if deviceID > 0 {
|
||||
query = query.Where("device_id = ?", deviceID)
|
||||
} else if deviceKeyword == adminFilterUnassigned {
|
||||
query = query.Where("device_id IS NULL")
|
||||
}
|
||||
if createdByID > 0 {
|
||||
query = query.Where("created_by = ?", createdByID)
|
||||
} else if createdByKeyword == adminFilterAgent {
|
||||
query = query.Where("created_source = ?", CreatedSourceAgent)
|
||||
} else if createdByKeyword == adminFilterUnrecorded {
|
||||
query = query.Where("created_source = ?", "")
|
||||
}
|
||||
if req.Status == adminStatusSYBWritebackSucceeded {
|
||||
query = query.Where("id IN (?)", s.DB.WithContext(ctx).Model(&models.PurchaseOrderWriteback{}).Select("purchase_task_id").Where("status = ?", "succeeded"))
|
||||
} else if req.Status != "" {
|
||||
@@ -214,6 +318,10 @@ func (s *Service) AdminList(ctx context.Context, req AdminListRequest) (AdminLis
|
||||
if err != nil {
|
||||
return AdminListResponse{}, err
|
||||
}
|
||||
creatorNames, err := loadCreatorNames(s.DB.WithContext(ctx), tasks)
|
||||
if err != nil {
|
||||
return AdminListResponse{}, err
|
||||
}
|
||||
matching, err := s.matchingViews(ctx, tasks)
|
||||
if err != nil {
|
||||
return AdminListResponse{}, err
|
||||
@@ -229,6 +337,7 @@ func (s *Service) AdminList(ctx context.Context, req AdminListRequest) (AdminLis
|
||||
}
|
||||
for _, task := range tasks {
|
||||
item := adminTaskItem(task, deviceNames, s.retryQueryEligibility(ctx, task, true))
|
||||
applyCreatorName(&item, creatorNames)
|
||||
item.OrderWriteback = writebacks[task.ID]
|
||||
item.Matching = matching[task.ID]
|
||||
if task.ShopeeProductID != nil {
|
||||
@@ -277,6 +386,10 @@ func (s *Service) AdminDetail(ctx context.Context, taskID uint64) (AdminDetailRe
|
||||
if err != nil {
|
||||
return AdminDetailResponse{}, err
|
||||
}
|
||||
creatorNames, err := loadCreatorNames(s.DB.WithContext(ctx), []models.PurchaseTask{task})
|
||||
if err != nil {
|
||||
return AdminDetailResponse{}, err
|
||||
}
|
||||
var attempts []models.PurchaseTaskAttempt
|
||||
if err := s.DB.WithContext(ctx).Where("task_id = ?", task.ID).Order("attempt_number ASC, id ASC").Find(&attempts).Error; err != nil {
|
||||
return AdminDetailResponse{}, internal(err)
|
||||
@@ -295,6 +408,7 @@ func (s *Service) AdminDetail(ctx context.Context, taskID uint64) (AdminDetailRe
|
||||
return AdminDetailResponse{}, matchErr
|
||||
}
|
||||
item := adminTaskItem(task, deviceNames, s.retryQueryEligibility(ctx, task, true))
|
||||
applyCreatorName(&item, creatorNames)
|
||||
writebacks, wbErr := s.OrderWritebackViews(ctx, []models.PurchaseTask{task})
|
||||
if wbErr != nil {
|
||||
return AdminDetailResponse{}, wbErr
|
||||
@@ -334,11 +448,107 @@ func loadDeviceNames(db *gorm.DB, tasks []models.PurchaseTask) (map[uint64]strin
|
||||
return names, nil
|
||||
}
|
||||
|
||||
// userDisplayNames returns sys_user display names (nickname first, then
|
||||
// username) for the given ids. Ids without a user row are absent from the map.
|
||||
func userDisplayNames(db *gorm.DB, ids []uint64) (map[uint64]string, error) {
|
||||
names := make(map[uint64]string, len(ids))
|
||||
if len(ids) == 0 {
|
||||
return names, nil
|
||||
}
|
||||
var rows []struct {
|
||||
UserID uint64
|
||||
Username string
|
||||
NickName string
|
||||
}
|
||||
if err := db.Table("sys_user").Select("user_id, username, nick_name").Where("user_id IN ?", ids).Scan(&rows).Error; err != nil {
|
||||
return nil, internal(err)
|
||||
}
|
||||
for _, row := range rows {
|
||||
name := strings.TrimSpace(row.NickName)
|
||||
if name == "" {
|
||||
name = strings.TrimSpace(row.Username)
|
||||
}
|
||||
names[row.UserID] = name
|
||||
}
|
||||
return names, nil
|
||||
}
|
||||
|
||||
func loadCreatorNames(db *gorm.DB, tasks []models.PurchaseTask) (map[uint64]string, error) {
|
||||
ids := make([]uint64, 0, len(tasks))
|
||||
seen := make(map[uint64]bool)
|
||||
for _, task := range tasks {
|
||||
if task.CreatedBy != nil && !seen[*task.CreatedBy] {
|
||||
ids = append(ids, *task.CreatedBy)
|
||||
seen[*task.CreatedBy] = true
|
||||
}
|
||||
}
|
||||
return userDisplayNames(db, ids)
|
||||
}
|
||||
|
||||
func applyCreatorName(item *AdminTaskItem, names map[uint64]string) {
|
||||
if item.CreatedBy != nil {
|
||||
item.CreatedByName = names[*item.CreatedBy]
|
||||
}
|
||||
}
|
||||
|
||||
// AdminFilterOption lists the devices and creators that actually appear on
|
||||
// purchase tasks, so purchasers need no access to device or user management.
|
||||
type AdminFilterOption struct {
|
||||
ID uint64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
type AdminFilterOptionsResponse struct {
|
||||
Devices []AdminFilterOption `json:"devices"`
|
||||
Creators []AdminFilterOption `json:"creators"`
|
||||
}
|
||||
|
||||
func (s *Service) AdminFilterOptions(ctx context.Context) (AdminFilterOptionsResponse, error) {
|
||||
db := s.DB.WithContext(ctx)
|
||||
var deviceIDs, creatorIDs []uint64
|
||||
if err := db.Model(&models.PurchaseTask{}).Where("device_id IS NOT NULL").Distinct().Pluck("device_id", &deviceIDs).Error; err != nil {
|
||||
return AdminFilterOptionsResponse{}, internal(err)
|
||||
}
|
||||
if err := db.Model(&models.PurchaseTask{}).Where("created_by IS NOT NULL").Distinct().Pluck("created_by", &creatorIDs).Error; err != nil {
|
||||
return AdminFilterOptionsResponse{}, internal(err)
|
||||
}
|
||||
result := AdminFilterOptionsResponse{Devices: []AdminFilterOption{}, Creators: []AdminFilterOption{}}
|
||||
if len(deviceIDs) > 0 {
|
||||
var devices []models.AgentDevice
|
||||
if err := db.Select("id, name").Where("id IN ?", deviceIDs).Find(&devices).Error; err != nil {
|
||||
return AdminFilterOptionsResponse{}, internal(err)
|
||||
}
|
||||
for _, device := range devices {
|
||||
result.Devices = append(result.Devices, AdminFilterOption{ID: device.ID, Name: device.Name})
|
||||
}
|
||||
}
|
||||
names, err := userDisplayNames(db, creatorIDs)
|
||||
if err != nil {
|
||||
return AdminFilterOptionsResponse{}, err
|
||||
}
|
||||
for _, id := range creatorIDs {
|
||||
name := names[id]
|
||||
if name == "" {
|
||||
name = "用户#" + strconv.FormatUint(id, 10)
|
||||
}
|
||||
result.Creators = append(result.Creators, AdminFilterOption{ID: id, Name: name})
|
||||
}
|
||||
for _, options := range [][]AdminFilterOption{result.Devices, result.Creators} {
|
||||
sort.Slice(options, func(i, j int) bool {
|
||||
if options[i].Name != options[j].Name {
|
||||
return options[i].Name < options[j].Name
|
||||
}
|
||||
return options[i].ID < options[j].ID
|
||||
})
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func adminTaskItem(task models.PurchaseTask, deviceNames map[uint64]string, retry retryDecision) AdminTaskItem {
|
||||
item := AdminTaskItem{
|
||||
ID: task.ID, TaskType: task.TaskType, ExecutionMode: task.ExecutionMode, Status: task.Status,
|
||||
SYBProductID: task.SYBProductID, ShopeeProductID: task.ShopeeProductID, PDDProductID: task.PDDProductID,
|
||||
DeviceID: task.DeviceID, ShopeeItemIDSnapshot: task.ShopeeItemIDSnapshot, ShopeeOrderNoSnapshot: task.ShopeeOrderNoSnapshot,
|
||||
DeviceID: task.DeviceID, CreatedBy: task.CreatedBy, CreatedSource: task.CreatedSource, ShopeeItemIDSnapshot: task.ShopeeItemIDSnapshot, ShopeeOrderNoSnapshot: task.ShopeeOrderNoSnapshot,
|
||||
ShopeeTitleSnapshot: task.ShopeeTitleSnapshot, ShopeeShopNameSnapshot: task.ShopeeShopNameSnapshot,
|
||||
PDDGoodsIDSnapshot: task.PDDGoodsIDSnapshot, PDDTitleSnapshot: task.PDDTitleSnapshot,
|
||||
TargetColorSnapshot: task.TargetColorSnapshot, TargetSizeSnapshot: task.TargetSizeSnapshot,
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -131,6 +131,9 @@ func aiMatchQualificationForDataset(id uint64, dataset batchPreviewDataset) aiMa
|
||||
if !found {
|
||||
return disabled("SYB 商品不存在或已删除")
|
||||
}
|
||||
if sybCancelled(syb) {
|
||||
return disabled(sybCancelledReason)
|
||||
}
|
||||
if syb.PDDExcluded {
|
||||
return disabled("该商品已标记为无需 PDD 采购")
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ type BatchCreateRequest struct {
|
||||
RequestID string `json:"requestId"`
|
||||
SYBProductIDs []uint64 `json:"sybProductIds"`
|
||||
DeviceID *uint64 `json:"deviceId,omitempty"`
|
||||
creator creatorStamp
|
||||
}
|
||||
|
||||
type BatchPreviewItem struct {
|
||||
@@ -313,7 +314,7 @@ func (s *Service) BatchCreate(ctx context.Context, req BatchCreateRequest) (Batc
|
||||
SYBProductID: &sybID, DeviceID: req.DeviceID,
|
||||
ReferenceUnitPriceCent: preview.ReferenceUnitPriceCent,
|
||||
MinUnitPriceCent: preview.MinUnitPriceCent, MaxUnitPriceCent: preview.MaxUnitPriceCent,
|
||||
Currency: preview.Currency, RuleSnapshot: rawRule,
|
||||
Currency: preview.Currency, RuleSnapshot: rawRule, creator: req.creator,
|
||||
})
|
||||
if createErr != nil {
|
||||
preview.Eligible = false
|
||||
@@ -403,6 +404,12 @@ func (s *Service) previewFromDataset(id uint64, dataset batchPreviewDataset, gua
|
||||
// is the single computation shared by BatchPreview, BatchCreate's
|
||||
// pre-check and quick-replace's ValidateQuickReplacement, so this one
|
||||
// check covers all of them.
|
||||
// #381: a cancelled SYB order is blocked the same way and takes precedence
|
||||
// for the reason shown (matches the process stage priority).
|
||||
if sybCancelled(syb) {
|
||||
item.ReasonCode, item.Reason, item.NextAction = CodeSYBCancelled, sybCancelledReason, ""
|
||||
return item
|
||||
}
|
||||
if syb.PDDExcluded {
|
||||
item.ReasonCode, item.Reason, item.NextAction = CodePDDExcluded, "该商品已标记为无需 PDD 采购", ""
|
||||
return item
|
||||
|
||||
@@ -9,6 +9,10 @@ func (item *BatchPreviewItem) applyCollectionEligibility(id uint64, dataset batc
|
||||
item.CollectionDisabledReason = "SYB 商品不存在或已删除"
|
||||
return
|
||||
}
|
||||
if sybCancelled(syb) {
|
||||
item.CollectionDisabledReason = sybCancelledReason
|
||||
return
|
||||
}
|
||||
if syb.PDDExcluded {
|
||||
item.CollectionDisabledReason = "该商品已标记为无需 PDD 采购"
|
||||
return
|
||||
|
||||
@@ -0,0 +1,417 @@
|
||||
package purchase
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go-admin/app/goauto/models"
|
||||
"go-admin/app/goauto/purchasecontract"
|
||||
"go-admin/common/clientprincipal"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func ensureSysUserTable(t *testing.T, db *gorm.DB) {
|
||||
t.Helper()
|
||||
if err := db.Exec("CREATE TABLE IF NOT EXISTS sys_user (user_id integer primary key, username text, nick_name text)").Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func addSysUser(t *testing.T, db *gorm.DB, id uint64, username, nick string) {
|
||||
t.Helper()
|
||||
ensureSysUserTable(t, db)
|
||||
if err := db.Exec("INSERT INTO sys_user(user_id, username, nick_name) VALUES(?,?,?)", id, username, nick).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func stockRequest(f fixture) StockCreateRequest {
|
||||
return StockCreateRequest{
|
||||
RequestID: uuid.NewString(), ExecutionMode: models.PurchaseExecutionModeLive,
|
||||
PDDProductID: f.pdd.ID, DeviceID: &f.device.ID, Color: "黑色", Size: "XL",
|
||||
Quantity: 1, MinUnitPriceCent: 900, MaxUnitPriceCent: 1500,
|
||||
}
|
||||
}
|
||||
|
||||
func stockFixture(t *testing.T, db *gorm.DB, f fixture) {
|
||||
t.Helper()
|
||||
if err := db.Model(&models.PDDProduct{}).Where("id = ?", f.pdd.ID).Update("specs_json", `[{"name":"颜色","role":"color","values":[{"name":"黑色","selectable":true,"priceCent":1200}]},{"name":"尺码","role":"size","values":[{"name":"XL","selectable":true}]}]`).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func extraDevice(name string) models.AgentDevice {
|
||||
return models.AgentDevice{Name: name, InstallID: uuid.NewString(), Manufacturer: "T", Model: "T", AndroidVersion: "15", AgentVersion: "1", PDDVersion: "7",
|
||||
Status: "online", TokenDigest: uuid.NewString(), TokenIssuedAt: time.Now()}
|
||||
}
|
||||
|
||||
func uptr(v uint64) *uint64 { return &v }
|
||||
|
||||
func TestCreatorFromContextMapsCallerToSourceAndUser(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
newCtx := func() *gin.Context {
|
||||
c, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/", nil)
|
||||
return c
|
||||
}
|
||||
jwtCtx := newCtx()
|
||||
jwtCtx.Set("JWT_PAYLOAD", jwt.MapClaims{"identity": float64(7), "rolekey": "purchaser"})
|
||||
if got := creatorFromContext(jwtCtx); got.Source != CreatedSourceAdmin || got.By == nil || *got.By != 7 {
|
||||
t.Fatalf("jwt creator: %+v", got)
|
||||
}
|
||||
clientCtx := newCtx()
|
||||
clientprincipal.Set(clientCtx, clientprincipal.Identity{KeyID: 3, AuthorizedBy: 9})
|
||||
if got := creatorFromContext(clientCtx); got.Source != CreatedSourceClientAPI || got.By == nil || *got.By != 9 {
|
||||
t.Fatalf("client creator: %+v", got)
|
||||
}
|
||||
anonymous := newCtx()
|
||||
if got := creatorFromContext(anonymous); got.Source != CreatedSourceAdmin || got.By != nil {
|
||||
t.Fatalf("zero operator must be NULL: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateEntriesStampCreatorOnceAndReplayKeepsIt(t *testing.T) {
|
||||
db := testDB(t)
|
||||
f := seed(t, db, liveCaps(), true)
|
||||
stockFixture(t, db, f)
|
||||
service := testService(db)
|
||||
|
||||
req := stockRequest(f)
|
||||
req.creator = creatorStamp{By: uptr(11), Source: CreatedSourceAdmin}
|
||||
first, replayed, err := service.CreateStock(context.Background(), req)
|
||||
if err != nil || replayed {
|
||||
t.Fatalf("create stock: %v replayed=%v", err, replayed)
|
||||
}
|
||||
if first.CreatedBy == nil || *first.CreatedBy != 11 || first.CreatedSource != CreatedSourceAdmin {
|
||||
t.Fatalf("stock creator not stamped: %+v %v", first.CreatedBy, first.CreatedSource)
|
||||
}
|
||||
req.creator = creatorStamp{By: uptr(99), Source: CreatedSourceClientAPI}
|
||||
replay, replayed, err := service.CreateStock(context.Background(), req)
|
||||
if err != nil || !replayed || replay.ID != first.ID {
|
||||
t.Fatalf("stock replay: %v", err)
|
||||
}
|
||||
var stored models.PurchaseTask
|
||||
if err = db.First(&stored, first.ID).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if stored.CreatedBy == nil || *stored.CreatedBy != 11 || stored.CreatedSource != CreatedSourceAdmin {
|
||||
t.Fatalf("replay rewrote creator: %+v %v", stored.CreatedBy, stored.CreatedSource)
|
||||
}
|
||||
|
||||
single, err := createLive(t, service, f)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if single.CreatedBy != nil || single.CreatedSource != "" {
|
||||
t.Fatalf("unstamped create must stay unrecorded: %+v %q", single.CreatedBy, single.CreatedSource)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchCreateStampsCreatorAndReplayDoesNotRewrite(t *testing.T) {
|
||||
db := testDB(t)
|
||||
f := seed(t, db, liveCaps(), true)
|
||||
setCollectedPDDPrice(t, db, f.pdd.ID)
|
||||
service := testService(db)
|
||||
request := BatchCreateRequest{RequestID: uuid.NewString(), SYBProductIDs: []uint64{f.syb.ID}, DeviceID: &f.device.ID,
|
||||
creator: creatorStamp{By: uptr(21), Source: CreatedSourceClientAPI}}
|
||||
first, err := service.BatchCreate(context.Background(), request)
|
||||
if err != nil || first.CreatedCount != 1 || first.Items[0].TaskID == nil {
|
||||
t.Fatalf("batch create: %+v %v", first, err)
|
||||
}
|
||||
request.creator = creatorStamp{By: uptr(22), Source: CreatedSourceAdmin}
|
||||
replay, err := service.BatchCreate(context.Background(), request)
|
||||
if err != nil || replay.CreatedCount != 1 || !replay.Items[0].Replayed {
|
||||
t.Fatalf("batch replay: %+v %v", replay, err)
|
||||
}
|
||||
var task models.PurchaseTask
|
||||
if err = db.First(&task, *first.Items[0].TaskID).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if task.CreatedBy == nil || *task.CreatedBy != 21 || task.CreatedSource != CreatedSourceClientAPI {
|
||||
t.Fatalf("batch creator: %+v %q", task.CreatedBy, task.CreatedSource)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentRetryTaskIsAgentSourceWithoutCreator(t *testing.T) {
|
||||
db := testDB(t)
|
||||
f := seed(t, db, liveCaps(), true)
|
||||
setCollectedPDDPrice(t, db, f.pdd.ID)
|
||||
service := testService(db)
|
||||
failed := failedLiveTask(t, db, service, f)
|
||||
if err := db.Model(&models.SYBProduct{}).Where("id = ?", f.syb.ID).Updates(map[string]any{"target_color": "当前黑色", "target_size": "均碼"}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
result, err := service.AgentRetry(context.Background(), failed.ID, AgentRetryRequest{RequestID: uuid.NewString()}, f.token)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var created, source models.PurchaseTask
|
||||
if err = db.First(&created, result.TaskID).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if created.CreatedBy != nil || created.CreatedSource != CreatedSourceAgent {
|
||||
t.Fatalf("agent retry creator: %+v %q", created.CreatedBy, created.CreatedSource)
|
||||
}
|
||||
if err = db.First(&source, failed.ID).Error; err != nil || source.CreatedSource != "" {
|
||||
t.Fatalf("source task must be untouched: %q %v", source.CreatedSource, err)
|
||||
}
|
||||
}
|
||||
|
||||
func callAdminHandler(t *testing.T, db *gorm.DB, handler func(Handler, *gin.Context), method, target, body string, setup func(*gin.Context)) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
gin.SetMode(gin.TestMode)
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest(method, target, strings.NewReader(body))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
setup(c)
|
||||
handler(Handler{DB: db}, c)
|
||||
return w
|
||||
}
|
||||
|
||||
func TestAdminCreateStockHandlerStampsJWTAndClientCreators(t *testing.T) {
|
||||
db := testDB(t)
|
||||
f := seed(t, db, liveCaps(), true)
|
||||
stockFixture(t, db, f)
|
||||
body := func() string {
|
||||
raw, _ := json.Marshal(stockRequest(f))
|
||||
return string(raw)
|
||||
}
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
setup func(*gin.Context)
|
||||
by uint64
|
||||
source string
|
||||
}{
|
||||
{"admin", func(c *gin.Context) {
|
||||
c.Set("JWT_PAYLOAD", jwt.MapClaims{"identity": float64(5), "rolekey": "purchaser"})
|
||||
}, 5, CreatedSourceAdmin},
|
||||
{"client", func(c *gin.Context) {
|
||||
clientprincipal.Set(c, clientprincipal.Identity{KeyID: 1, AuthorizedBy: 8})
|
||||
c.Set("JWT_PAYLOAD", jwt.MapClaims{"rolekey": "admin"})
|
||||
}, 8, CreatedSourceClientAPI},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
w := callAdminHandler(t, db, Handler.AdminCreateStock, http.MethodPost, "/api/admin/v1/purchase-tasks/stock", body(), tc.setup)
|
||||
var out struct {
|
||||
Data models.PurchaseTask `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &out); err != nil || w.Code != http.StatusOK || out.Data.ID == 0 {
|
||||
t.Fatalf("%d %s", w.Code, w.Body.String())
|
||||
}
|
||||
var stored models.PurchaseTask
|
||||
if err := db.First(&stored, out.Data.ID).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if stored.CreatedBy == nil || *stored.CreatedBy != tc.by || stored.CreatedSource != tc.source {
|
||||
t.Fatalf("stored creator: %+v %q", stored.CreatedBy, stored.CreatedSource)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// seedCreatorRows inserts tasks with distinct devices/creators/sources.
|
||||
func seedCreatorRows(t *testing.T, db *gorm.DB, f fixture) (devB models.AgentDevice, ids map[string]uint64) {
|
||||
t.Helper()
|
||||
s := testService(db)
|
||||
base := createAdminQueryTask(t, s, f)
|
||||
devB = extraDevice("Pixel")
|
||||
if err := db.Session(&gorm.Session{SkipHooks: true}).Create(&devB).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ids = map[string]uint64{}
|
||||
add := func(key string, device *uint64, by *uint64, source string, status string) {
|
||||
row := base
|
||||
row.ID = 0
|
||||
row.CreateRequestID = uuid.NewString()
|
||||
row.DeviceID, row.CreatedBy, row.CreatedSource, row.Status = device, by, source, status
|
||||
if err := db.Session(&gorm.Session{SkipHooks: true}).Create(&row).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ids[key] = row.ID
|
||||
}
|
||||
add("adminA_devA", &f.device.ID, uptr(1), CreatedSourceAdmin, models.PurchaseTaskStatusFailed)
|
||||
add("adminA_devB", &devB.ID, uptr(1), CreatedSourceAdmin, models.PurchaseTaskStatusFailed)
|
||||
add("adminB_unassigned", nil, uptr(2), CreatedSourceAdmin, models.PurchaseTaskStatusCancelled)
|
||||
add("agent_devA", &f.device.ID, nil, CreatedSourceAgent, models.PurchaseTaskStatusFailed)
|
||||
add("legacy_devB", &devB.ID, nil, "", models.PurchaseTaskStatusFailed)
|
||||
if err := db.Session(&gorm.Session{SkipHooks: true}).Model(&models.PurchaseTask{}).Where("id = ?", base.ID).Update("created_source", "").Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ids["base"] = base.ID
|
||||
return devB, ids
|
||||
}
|
||||
|
||||
func listIDs(t *testing.T, s *Service, req AdminListRequest) map[uint64]bool {
|
||||
t.Helper()
|
||||
out, err := s.AdminList(context.Background(), req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := map[uint64]bool{}
|
||||
for _, item := range out.Items {
|
||||
got[item.ID] = true
|
||||
}
|
||||
return got
|
||||
}
|
||||
|
||||
func sameIDs(t *testing.T, got map[uint64]bool, ids map[string]uint64, keys ...string) {
|
||||
t.Helper()
|
||||
if len(got) != len(keys) {
|
||||
t.Fatalf("got %v want keys %v (%v)", got, keys, ids)
|
||||
}
|
||||
for _, key := range keys {
|
||||
if !got[ids[key]] {
|
||||
t.Fatalf("missing %s in %v (%v)", key, got, ids)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminListDeviceAndCreatedByFilters(t *testing.T) {
|
||||
db := testDB(t)
|
||||
f := seed(t, db, []string{purchasecontract.CapabilityPurchaseRehearsalV1}, true)
|
||||
addSysUser(t, db, 1, "alice", "爱丽丝")
|
||||
addSysUser(t, db, 2, "bob", "")
|
||||
devB, ids := seedCreatorRows(t, db, f)
|
||||
s := testService(db)
|
||||
devA := f.device.ID
|
||||
|
||||
sameIDs(t, listIDs(t, s, AdminListRequest{DeviceID: itoa(devA)}), ids, "base", "adminA_devA", "agent_devA")
|
||||
sameIDs(t, listIDs(t, s, AdminListRequest{DeviceID: itoa(devB.ID)}), ids, "adminA_devB", "legacy_devB")
|
||||
sameIDs(t, listIDs(t, s, AdminListRequest{DeviceID: "unassigned"}), ids, "adminB_unassigned")
|
||||
sameIDs(t, listIDs(t, s, AdminListRequest{CreatedBy: "1"}), ids, "adminA_devA", "adminA_devB")
|
||||
sameIDs(t, listIDs(t, s, AdminListRequest{CreatedBy: "agent"}), ids, "agent_devA")
|
||||
sameIDs(t, listIDs(t, s, AdminListRequest{CreatedBy: "unrecorded"}), ids, "base", "legacy_devB")
|
||||
// Combined with each other and with existing filters.
|
||||
sameIDs(t, listIDs(t, s, AdminListRequest{DeviceID: itoa(devB.ID), CreatedBy: "1"}), ids, "adminA_devB")
|
||||
sameIDs(t, listIDs(t, s, AdminListRequest{DeviceID: itoa(devA), CreatedBy: "unrecorded"}), ids, "base")
|
||||
sameIDs(t, listIDs(t, s, AdminListRequest{CreatedBy: "2", Status: models.PurchaseTaskStatusCancelled, DeviceID: "unassigned"}), ids, "adminB_unassigned")
|
||||
sameIDs(t, listIDs(t, s, AdminListRequest{CreatedBy: "1", Status: models.PurchaseTaskStatusCancelled}), ids)
|
||||
|
||||
for _, bad := range []AdminListRequest{
|
||||
{DeviceID: "abc"}, {DeviceID: "0"}, {DeviceID: "-1"}, {DeviceID: "agent"},
|
||||
{CreatedBy: "abc"}, {CreatedBy: "0"}, {CreatedBy: "unassigned"},
|
||||
} {
|
||||
if _, err := s.AdminList(context.Background(), bad); code(err) != CodeInvalidRequest {
|
||||
t.Fatalf("%+v accepted: %v", bad, err)
|
||||
}
|
||||
}
|
||||
|
||||
out, err := s.AdminList(context.Background(), AdminListRequest{PageSize: 100})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, item := range out.Items {
|
||||
switch item.ID {
|
||||
case ids["adminA_devA"]:
|
||||
if item.CreatedBy == nil || *item.CreatedBy != 1 || item.CreatedByName != "爱丽丝" || item.CreatedSource != CreatedSourceAdmin {
|
||||
t.Fatalf("nick name item: %+v", item)
|
||||
}
|
||||
case ids["adminB_unassigned"]:
|
||||
if item.CreatedByName != "bob" {
|
||||
t.Fatalf("username fallback: %q", item.CreatedByName)
|
||||
}
|
||||
case ids["agent_devA"]:
|
||||
if item.CreatedBy != nil || item.CreatedSource != CreatedSourceAgent || item.CreatedByName != "" {
|
||||
t.Fatalf("agent item: %+v", item)
|
||||
}
|
||||
case ids["legacy_devB"]:
|
||||
if item.CreatedSource != "" {
|
||||
t.Fatalf("legacy item: %+v", item)
|
||||
}
|
||||
}
|
||||
}
|
||||
detail, err := s.AdminDetail(context.Background(), ids["adminA_devA"])
|
||||
if err != nil || detail.Task.CreatedByName != "爱丽丝" || detail.Task.CreatedSource != CreatedSourceAdmin {
|
||||
t.Fatalf("detail creator: %+v %v", detail.Task, err)
|
||||
}
|
||||
}
|
||||
|
||||
func itoa(v uint64) string {
|
||||
raw, _ := json.Marshal(v)
|
||||
return string(raw)
|
||||
}
|
||||
|
||||
func TestAdminListHandlerRejectsInvalidDeviceAndCreatedBy(t *testing.T) {
|
||||
db := testDB(t)
|
||||
for _, query := range []string{"deviceId=abc", "createdBy=abc", "deviceId=0"} {
|
||||
w := callAdminHandler(t, db, Handler.AdminList, http.MethodGet, "/api/admin/v1/purchase-tasks?"+query, "", func(c *gin.Context) {
|
||||
c.Set("JWT_PAYLOAD", jwt.MapClaims{"rolekey": "purchaser"})
|
||||
})
|
||||
if w.Code != http.StatusUnprocessableEntity || !strings.Contains(w.Body.String(), CodeInvalidRequest) {
|
||||
t.Fatalf("%s: %d %s", query, w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminFilterOptionsListsOnlyUsedDevicesAndCreatorsSorted(t *testing.T) {
|
||||
db := testDB(t)
|
||||
f := seed(t, db, []string{purchasecontract.CapabilityPurchaseRehearsalV1}, true)
|
||||
addSysUser(t, db, 1, "zed", "")
|
||||
addSysUser(t, db, 2, "amy", "Amy")
|
||||
addSysUser(t, db, 3, "unused", "Unused")
|
||||
devB, ids := seedCreatorRows(t, db, f)
|
||||
_ = ids
|
||||
unused := extraDevice("Unused")
|
||||
if err := db.Session(&gorm.Session{SkipHooks: true}).Create(&unused).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out, err := testService(db).AdminFilterOptions(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(out.Devices) != 2 || out.Devices[0].Name != "Pixel" || out.Devices[0].ID != devB.ID || out.Devices[1].ID != f.device.ID {
|
||||
t.Fatalf("devices: %+v", out.Devices)
|
||||
}
|
||||
if len(out.Creators) != 2 || out.Creators[0].Name != "Amy" || out.Creators[0].ID != 2 || out.Creators[1].Name != "zed" {
|
||||
t.Fatalf("creators: %+v", out.Creators)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminFilterOptionsEmptyReturnsEmptyArrays(t *testing.T) {
|
||||
empty, err := testService(testDB(t)).AdminFilterOptions(context.Background())
|
||||
if err != nil || empty.Devices == nil || empty.Creators == nil || len(empty.Devices)+len(empty.Creators) != 0 {
|
||||
t.Fatalf("empty options must be empty arrays: %+v %v", empty, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminFilterOptionsHandlerAccess(t *testing.T) {
|
||||
db := testDB(t)
|
||||
for _, role := range []string{"admin", "purchaser", "after_sales", "other", ""} {
|
||||
w := callAdminHandler(t, db, Handler.AdminFilterOptions, http.MethodGet, "/api/admin/v1/purchase-tasks/filter-options", "", func(c *gin.Context) {
|
||||
c.Set("JWT_PAYLOAD", jwt.MapClaims{"rolekey": role})
|
||||
})
|
||||
want := role == "admin" || role == "purchaser" || role == "after_sales"
|
||||
if want && w.Code != http.StatusOK {
|
||||
t.Fatalf("role %q denied: %d %s", role, w.Code, w.Body.String())
|
||||
}
|
||||
if !want && w.Code != http.StatusForbidden {
|
||||
t.Fatalf("role %q allowed: %d", role, w.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilterOptionsRouteDoesNotCollideWithTaskID(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
engine := gin.New()
|
||||
group := engine.Group("/api/admin/v1/purchase-tasks")
|
||||
group.GET("", func(c *gin.Context) { c.String(200, "list") })
|
||||
group.GET("/filter-options", func(c *gin.Context) { c.String(200, "options") })
|
||||
group.GET("/:taskId", func(c *gin.Context) { c.String(200, "detail") })
|
||||
for path, want := range map[string]string{"/api/admin/v1/purchase-tasks/filter-options": "options", "/api/admin/v1/purchase-tasks/12": "detail"} {
|
||||
w := httptest.NewRecorder()
|
||||
engine.ServeHTTP(w, httptest.NewRequest(http.MethodGet, path, nil))
|
||||
if w.Body.String() != want {
|
||||
t.Fatalf("%s routed to %q", path, w.Body.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -52,10 +52,14 @@ 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"),
|
||||
DeviceID: c.Query("deviceId"),
|
||||
CreatedBy: c.Query("createdBy"),
|
||||
})
|
||||
if err != nil {
|
||||
writeError(c, err)
|
||||
@@ -64,6 +68,22 @@ func (h Handler) AdminList(c *gin.Context) {
|
||||
writeAdminData(c, response)
|
||||
}
|
||||
|
||||
func (h Handler) AdminFilterOptions(c *gin.Context) {
|
||||
if !allowedOperator(c) {
|
||||
return
|
||||
}
|
||||
service, ok := h.service(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
response, err := service.AdminFilterOptions(c.Request.Context())
|
||||
if err != nil {
|
||||
writeError(c, err)
|
||||
return
|
||||
}
|
||||
writeAdminData(c, response)
|
||||
}
|
||||
|
||||
func (h Handler) AdminCreateStock(c *gin.Context) {
|
||||
if !allowedOperator(c) {
|
||||
return
|
||||
@@ -76,6 +96,7 @@ func (h Handler) AdminCreateStock(c *gin.Context) {
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
req.creator = creatorFromContext(c)
|
||||
record, replayed, err := service.CreateStock(c.Request.Context(), req)
|
||||
if err != nil {
|
||||
writeError(c, err)
|
||||
@@ -116,6 +137,7 @@ func (h Handler) AdminCreate(c *gin.Context) {
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
req.creator = creatorFromContext(c)
|
||||
record, replayed, err := service.Create(c.Request.Context(), req)
|
||||
if err != nil {
|
||||
writeError(c, err)
|
||||
@@ -176,6 +198,7 @@ func (h Handler) AdminBatchCreate(c *gin.Context) {
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
req.creator = creatorFromContext(c)
|
||||
response, err := service.BatchCreate(c.Request.Context(), req)
|
||||
if err != nil {
|
||||
writeError(c, err)
|
||||
@@ -548,6 +571,20 @@ func writeError(c *gin.Context, err error) {
|
||||
}
|
||||
c.JSON(status, gin.H{"code": code, "message": msg, "retryable": retryable})
|
||||
}
|
||||
|
||||
// creatorFromContext maps the authenticated caller to the #382 creator stamp.
|
||||
// A zero operator id is stored as NULL rather than a fake user.
|
||||
func creatorFromContext(c *gin.Context) creatorStamp {
|
||||
stamp := creatorStamp{Source: CreatedSourceAdmin}
|
||||
if _, ok := clientprincipal.Get(c); ok {
|
||||
stamp.Source = CreatedSourceClientAPI
|
||||
}
|
||||
if id := operatorID(c); id > 0 {
|
||||
stamp.By = &id
|
||||
}
|
||||
return stamp
|
||||
}
|
||||
|
||||
func operatorID(c *gin.Context) uint64 {
|
||||
if id, ok := clientprincipal.Get(c); ok {
|
||||
return id.AuthorizedBy
|
||||
|
||||
@@ -31,7 +31,12 @@ const (
|
||||
// pdd_purchase_excluded: it never needs a PDD purchase. It only yields to
|
||||
// an active return match (checked first, same priority order #338 uses
|
||||
// for the pipeline below it) — see processStageFromDataset.
|
||||
ProcessStagePDDExcluded = "pdd_excluded"
|
||||
ProcessStagePDDExcluded = "pdd_excluded"
|
||||
// ProcessStageSYBCancelled is #381's stage for a cancelled SYB order
|
||||
// (syb_is_cancelled = true). It yields to an existing task/order result
|
||||
// and an active return match, and wins over pdd_excluded. It is not in
|
||||
// returnmatch.participatingStages, so cancelled rows are not matched.
|
||||
ProcessStageSYBCancelled = "syb_cancelled"
|
||||
processActionOpenPDDLink = "open_pdd_link"
|
||||
)
|
||||
|
||||
@@ -49,6 +54,7 @@ var processStageLabels = map[string]string{
|
||||
ProcessStageReturnPending: "退货待确认",
|
||||
ProcessStageReturnUsed: "已用退货",
|
||||
ProcessStagePDDExcluded: "无需采购",
|
||||
ProcessStageSYBCancelled: "已取消",
|
||||
}
|
||||
|
||||
type ProcessStageResult struct {
|
||||
@@ -157,6 +163,11 @@ func processStageFromDataset(id uint64, dataset batchPreviewDataset, preview Bat
|
||||
return stage(ProcessStageReturnPending, "已匹配退货待人工确认,暂不能创建采购任务", "open_return_match")
|
||||
}
|
||||
|
||||
// #381: a cancelled SYB order comes next, before the #340 exclusion mark.
|
||||
if syb, ok := dataset.sybByID[id]; ok && sybCancelled(syb) {
|
||||
return stage(ProcessStageSYBCancelled, "SYB 订单已取消,无需 PDD 采购", "")
|
||||
}
|
||||
|
||||
// #340: an excluded row's mark also only overrides after the active-task
|
||||
// and active-return-match checks above, and before every other pipeline
|
||||
// branch below.
|
||||
|
||||
@@ -196,6 +196,7 @@ func (s *Service) batchRetryCreate(ctx context.Context, req BatchRetryRequest) (
|
||||
MaxUnitPriceCent: candidate.preview.MaxUnitPriceCent,
|
||||
Currency: candidate.preview.Currency,
|
||||
RuleSnapshot: rawRule,
|
||||
creator: creatorStamp{Source: CreatedSourceAgent},
|
||||
})
|
||||
item := response.Items[candidate.resultIndex]
|
||||
if createErr != nil {
|
||||
|
||||
@@ -29,6 +29,24 @@ func rejectIfActiveReturnMatch(tx *gorm.DB, sybProductID uint64) error {
|
||||
return internal(err)
|
||||
}
|
||||
|
||||
// sybCancelled reports #381's SYB cancelled mark: only an explicit true
|
||||
// blocks; NULL (not fetched) and false do not.
|
||||
func sybCancelled(syb models.SYBProduct) bool {
|
||||
return syb.SYBIsCancelled != nil && *syb.SYBIsCancelled
|
||||
}
|
||||
|
||||
// sybCancelledReason is the shared user-facing reason for #381 blocks.
|
||||
const sybCancelledReason = "SYB 订单已取消,无需采购"
|
||||
|
||||
// rejectIfSYBCancelled implements #381's hard purchase-creation block for a
|
||||
// cancelled SYB order (single and batch share s.Create).
|
||||
func rejectIfSYBCancelled(syb models.SYBProduct) error {
|
||||
if sybCancelled(syb) {
|
||||
return fail(CodeSYBCancelled, sybCancelledReason)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// rejectIfPDDExcluded implements #340's hard purchase-creation block: a SYB
|
||||
// product marked pdd_purchase_excluded must never get a purchase task,
|
||||
// single or batch (BatchCreate calls s.Create per row, so this one check
|
||||
|
||||
@@ -29,6 +29,7 @@ func InitRouter(engine *gin.Engine, auth *jwt.GinJWTMiddleware) {
|
||||
agent.POST("/:taskId/attempts/:attemptId/failure-snapshot", h.UploadFailureSnapshot)
|
||||
admin := engine.Group("/api/admin/v1/purchase-tasks").Use(auth.MiddlewareFunc()).Use(middleware.AuthCheckRole())
|
||||
admin.GET("", h.AdminList)
|
||||
admin.GET("/filter-options", h.AdminFilterOptions)
|
||||
admin.POST("/batch-preview", h.AdminBatchPreview)
|
||||
admin.POST("/batch-spec-match", h.AdminBatchSpecMatch)
|
||||
admin.POST("/batch", h.AdminBatchCreate)
|
||||
|
||||
@@ -184,6 +184,9 @@ func (s *Service) create(ctx context.Context, req CreateRequest) (models.Purchas
|
||||
if err := rejectIfPDDExcluded(syb); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := rejectIfSYBCancelled(syb); err != nil {
|
||||
return err
|
||||
}
|
||||
if syb.ShopeeProductID == nil {
|
||||
return fail(CodeInvalidRequest, "该商品尚未关联蝦皮商品")
|
||||
}
|
||||
@@ -346,6 +349,7 @@ func (s *Service) create(ctx context.Context, req CreateRequest) (models.Purchas
|
||||
ExecutionMode: req.ExecutionMode, Status: models.PurchaseTaskStatusPending, ShopeeItemIDSnapshot: shopee.ShopeeItemID, ShopeeOrderNoSnapshot: shopeeOrderNoSnapshot, ShopeeTitleSnapshot: shopee.Title, ShopeeShopNameSnapshot: shopee.ShopName,
|
||||
PDDURLSnapshot: pdd.URL, PDDGoodsIDSnapshot: pdd.GoodsID, PDDTitleSnapshot: pdd.Title, TargetColorSnapshot: targetColor, TargetSizeSnapshot: targetSize, MappedColorSnapshot: mappedColor, MappedSizeSnapshot: mappedSize, SpecSource: specSource, SpecDecisionSnapshot: decisionSnapshot,
|
||||
Quantity: quantity, ReferenceUnitPriceCent: ref, MinUnitPriceCent: minPrice, MaxUnitPriceCent: maxPrice, Currency: currency, RuleType: rule.RuleType, RuleSchemaVersion: rule.SchemaVersion, RequiredCapabilitiesJSON: string(required), RuleSnapshot: string(req.RuleSnapshot), CreateRequestID: req.RequestID,
|
||||
CreatedBy: req.creator.By, CreatedSource: req.creator.Source,
|
||||
PaymentReviewStatus: models.PurchasePaymentReviewPending, LogisticsStatus: models.PurchaseLogisticsStatusPending, WritebackStatus: models.PurchaseWritebackStatusNotSelected}
|
||||
if err := tx.Create(&out).Error; err != nil {
|
||||
if isDuplicate(err) {
|
||||
@@ -374,7 +378,7 @@ func (s *Service) CreateStock(ctx context.Context, req StockCreateRequest) (mode
|
||||
DeviceID: req.DeviceID, PDDAccountID: req.PDDAccountID,
|
||||
TargetColor: req.Color, TargetSize: req.Size, Quantity: req.Quantity,
|
||||
MinUnitPriceCent: req.MinUnitPriceCent, MaxUnitPriceCent: req.MaxUnitPriceCent,
|
||||
Currency: "CNY", RuleSnapshot: rule,
|
||||
Currency: "CNY", RuleSnapshot: rule, creator: req.creator,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
package purchase
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go-admin/app/goauto/models"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// #381: a cancelled SYB order (syb_is_cancelled = true) must be blocked by the
|
||||
// PDD entry points; NULL and false must not block.
|
||||
|
||||
func setSYBCancelled(t *testing.T, db *gorm.DB, id uint64, value any) {
|
||||
t.Helper()
|
||||
if err := db.Model(&models.SYBProduct{}).Where("id = ?", id).Update("syb_is_cancelled", value).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreate_RejectsWhenSYBCancelled(t *testing.T) {
|
||||
db := testDB(t)
|
||||
s := testService(db)
|
||||
f := seed(t, db, liveCaps(), true)
|
||||
setSYBCancelled(t, db, f.syb.ID, true)
|
||||
|
||||
_, err := createLive(t, s, f)
|
||||
if se, ok := asServiceError(err); !ok || se.Code != CodeSYBCancelled {
|
||||
t.Fatalf("expected CodeSYBCancelled, got %v", err)
|
||||
}
|
||||
var count int64
|
||||
db.Model(&models.PurchaseTask{}).Where("syb_product_id = ?", f.syb.ID).Count(&count)
|
||||
if count != 0 {
|
||||
t.Fatalf("no task may be created, got %d", count)
|
||||
}
|
||||
|
||||
// Excluded as well: creation is still rejected.
|
||||
if err := db.Model(&models.SYBProduct{}).Where("id = ?", f.syb.ID).Update("pdd_purchase_excluded", true).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := createLive(t, s, f); err == nil {
|
||||
t.Fatalf("cancelled+excluded creation must still be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreate_NotBlockedWhenSYBCancelledFalse(t *testing.T) {
|
||||
db := testDB(t)
|
||||
s := testService(db)
|
||||
f := seed(t, db, liveCaps(), true)
|
||||
setSYBCancelled(t, db, f.syb.ID, false)
|
||||
if _, err := createLive(t, s, f); err != nil {
|
||||
t.Fatalf("false must not block: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreate_NotBlockedWhenSYBCancelledNull(t *testing.T) {
|
||||
db := testDB(t)
|
||||
s := testService(db)
|
||||
f := seed(t, db, liveCaps(), true)
|
||||
if f.syb.SYBIsCancelled != nil {
|
||||
t.Fatalf("fixture must have NULL cancel state")
|
||||
}
|
||||
if _, err := createLive(t, s, f); err != nil {
|
||||
t.Fatalf("NULL must not block: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchPreview_CancelledRowReportsReason(t *testing.T) {
|
||||
db := testDB(t)
|
||||
s := testService(db)
|
||||
f := seed(t, db, liveCaps(), true)
|
||||
setCollectedPDDPrice(t, db, f.pdd.ID)
|
||||
setSYBCancelled(t, db, f.syb.ID, true)
|
||||
// Excluded as well: the cancelled reason and stage win.
|
||||
if err := db.Model(&models.SYBProduct{}).Where("id = ?", f.syb.ID).Update("pdd_purchase_excluded", true).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
resp, err := s.BatchPreview(context.Background(), BatchPreviewRequest{SYBProductIDs: []uint64{f.syb.ID}, DeviceID: &f.device.ID})
|
||||
if err != nil || len(resp.Items) != 1 {
|
||||
t.Fatalf("preview: %v %+v", err, resp)
|
||||
}
|
||||
item := resp.Items[0]
|
||||
if item.Eligible || item.CollectionEligible || item.AIMatchEligible {
|
||||
t.Fatalf("cancelled row must not be eligible: %+v", item)
|
||||
}
|
||||
if item.ReasonCode != CodeSYBCancelled || item.Reason != sybCancelledReason {
|
||||
t.Fatalf("expected cancelled reason, got %+v", item)
|
||||
}
|
||||
if item.CollectionDisabledReason != sybCancelledReason || item.AIMatchDisabledReason != sybCancelledReason {
|
||||
t.Fatalf("expected cancelled disabled reasons, got %+v", item)
|
||||
}
|
||||
if item.ProcessStage != ProcessStageSYBCancelled || item.ProcessStageLabel != "已取消" {
|
||||
t.Fatalf("expected syb_cancelled stage, got %+v", item)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchCreate_SkipsCancelledRowNotWholeBatch(t *testing.T) {
|
||||
db := testDB(t)
|
||||
s := testService(db)
|
||||
f1 := seed(t, db, liveCaps(), true)
|
||||
setCollectedPDDPrice(t, db, f1.pdd.ID)
|
||||
syb2 := models.SYBProduct{OrderCode: "SYB-CANCEL-2", DetailID: 2, StockID: 3, ShopeeItemID: f1.shopee.ShopeeItemID, ShopeeProductID: &f1.shopee.ID, ProductTitle: f1.shopee.Title, TargetColor: "黑色", TargetSize: "XL", Quantity: 1, UnitPriceCent: 2000, ParseStatus: models.SYBParseStatusSuccess, RawJSON: `{}`}
|
||||
if err := db.Create(&syb2).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
setSYBCancelled(t, db, f1.syb.ID, true)
|
||||
|
||||
resp, err := s.BatchCreate(context.Background(), BatchCreateRequest{
|
||||
RequestID: uuid.NewString(), SYBProductIDs: []uint64{f1.syb.ID, syb2.ID}, DeviceID: &f1.device.ID,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if resp.CreatedCount != 1 || resp.FailedCount != 1 {
|
||||
t.Fatalf("expected 1 created + 1 failed, got %+v", resp)
|
||||
}
|
||||
for _, item := range resp.Items {
|
||||
if item.SYBProductID == f1.syb.ID && item.Created {
|
||||
t.Fatalf("cancelled row must not be created: %+v", item)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessStage_CancelledPriorityAndRecovery(t *testing.T) {
|
||||
db := testDB(t)
|
||||
s := testService(db)
|
||||
f := seed(t, db, liveCaps(), true)
|
||||
stageOf := func() ProcessStageResult {
|
||||
t.Helper()
|
||||
stages, err := s.ProcessStages(context.Background(), []uint64{f.syb.ID})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return stages[f.syb.ID]
|
||||
}
|
||||
before := stageOf()
|
||||
if before.Stage == ProcessStageSYBCancelled {
|
||||
t.Fatalf("unexpected cancelled stage before marking: %+v", before)
|
||||
}
|
||||
if !ValidProcessStage(ProcessStageSYBCancelled) {
|
||||
t.Fatalf("syb_cancelled must be a valid filter value")
|
||||
}
|
||||
|
||||
setSYBCancelled(t, db, f.syb.ID, true)
|
||||
if got := stageOf(); got.Stage != ProcessStageSYBCancelled || got.Label != "已取消" {
|
||||
t.Fatalf("expected syb_cancelled, got %+v", got)
|
||||
}
|
||||
|
||||
// An active return match wins over cancelled.
|
||||
match := models.ReturnMatch{SYBProductID: f.syb.ID, YeekeReturnItemID: 1, ActiveSYBProductID: &f.syb.ID, Status: models.ReturnMatchStatusMatched, MatchedAt: time.Now()}
|
||||
if err := db.Create(&match).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := stageOf(); got.Stage != ProcessStageReturnPending {
|
||||
t.Fatalf("return match must win over cancelled, got %+v", got)
|
||||
}
|
||||
if err := db.Model(&models.ReturnMatch{}).Where("id = ?", match.ID).
|
||||
Updates(map[string]any{"active_syb_product_id": nil, "status": models.ReturnMatchStatusCancelled}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Recovery: false restores the original stage.
|
||||
setSYBCancelled(t, db, f.syb.ID, false)
|
||||
if got := stageOf(); got.Stage != before.Stage {
|
||||
t.Fatalf("recovery should restore %s, got %+v", before.Stage, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessStage_ExistingTaskWinsOverCancelled(t *testing.T) {
|
||||
db := testDB(t)
|
||||
s := testService(db)
|
||||
f := seed(t, db, liveCaps(), true)
|
||||
setCollectedPDDPrice(t, db, f.pdd.ID)
|
||||
task, err := createLive(t, s, f)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
setSYBCancelled(t, db, f.syb.ID, true)
|
||||
stages, err := s.ProcessStages(context.Background(), []uint64{f.syb.ID})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := stages[f.syb.ID]; got.Stage != ProcessStageTaskCreated || got.ActiveTaskID == nil || *got.ActiveTaskID != task.ID {
|
||||
t.Fatalf("existing task must win over cancelled, got %+v", got)
|
||||
}
|
||||
}
|
||||
@@ -48,7 +48,11 @@ const (
|
||||
// time and is recorded as 「无需 PDD 采购」, so it must never start any
|
||||
// PDD flow, regardless of the rest of its pipeline state.
|
||||
CodePDDExcluded = "PURCHASE_PDD_EXCLUDED"
|
||||
CodeInternal = "INTERNAL_ERROR"
|
||||
// CodeSYBCancelled is returned by the PDD entry points when the SYB order
|
||||
// is cancelled (syb_is_cancelled = true, #381). NULL (unknown) and false
|
||||
// never block. Existing purchase tasks are not affected.
|
||||
CodeSYBCancelled = "PURCHASE_SYB_CANCELLED"
|
||||
CodeInternal = "INTERNAL_ERROR"
|
||||
)
|
||||
|
||||
type ServiceError struct {
|
||||
@@ -92,8 +96,23 @@ type CreateRequest struct {
|
||||
// immutable source-task value instead of accepting a client-supplied
|
||||
// identity snapshot.
|
||||
shopeeOrderNoSnapshotOverride string
|
||||
// creator is server-only (#382): written once when a task row is created.
|
||||
creator creatorStamp
|
||||
}
|
||||
|
||||
// creatorStamp records who created a purchase task. It is never decoded from
|
||||
// request bodies; the handler or the internal caller fills it.
|
||||
type creatorStamp struct {
|
||||
By *uint64
|
||||
Source string
|
||||
}
|
||||
|
||||
const (
|
||||
CreatedSourceAdmin = "admin"
|
||||
CreatedSourceClientAPI = "client_api"
|
||||
CreatedSourceAgent = "agent"
|
||||
)
|
||||
|
||||
// StockCreateRequest is intentionally detached from SYB/Shopee identities.
|
||||
// The server supplies the reviewed purchase rule and derives the immutable
|
||||
// reference price from the selected color in the collected PDD archive.
|
||||
@@ -108,6 +127,7 @@ type StockCreateRequest struct {
|
||||
Quantity int64 `json:"quantity"`
|
||||
MinUnitPriceCent int64 `json:"minUnitPriceCent"`
|
||||
MaxUnitPriceCent int64 `json:"maxUnitPriceCent"`
|
||||
creator creatorStamp
|
||||
}
|
||||
|
||||
type MatchingView struct {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
package returnmatch
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go-admin/app/goauto/models"
|
||||
)
|
||||
|
||||
// #381: a cancelled SYB order has stage syb_cancelled, which is not a
|
||||
// participating stage, so return matching must skip it; recovery (false)
|
||||
// makes it eligible again.
|
||||
func TestBatchMatch_CancelledRowSkippedAndRecovers(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) }
|
||||
deadline := time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC)
|
||||
syb := seedSYB(t, db, "SYB-CANCEL-1", 1, "白色", "L", time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC))
|
||||
seedReturn(t, db, "白色,L【建議65-75公斤】", &deadline)
|
||||
if err := db.Model(&models.SYBProduct{}).Where("id = ?", syb.ID).Update("syb_is_cancelled", true).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
resp, err := s.BatchMatch(context.Background(), BatchMatchRequest{SYBProductIDs: []uint64{syb.ID}, Operator: "tester"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if resp.MatchedCount != 0 || len(resp.Items) != 1 || resp.Items[0].Matched {
|
||||
t.Fatalf("cancelled row must not be matched: %+v", resp)
|
||||
}
|
||||
|
||||
if err := db.Model(&models.SYBProduct{}).Where("id = ?", syb.ID).Update("syb_is_cancelled", false).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp, err = s.BatchMatch(context.Background(), BatchMatchRequest{SYBProductIDs: []uint64{syb.ID}, Operator: "tester"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if resp.MatchedCount != 1 {
|
||||
t.Fatalf("after recovery the row must be matchable: %+v", resp)
|
||||
}
|
||||
}
|
||||
@@ -159,6 +159,11 @@ func (service *Service) BatchCreateImageSearch(ctx context.Context, request Imag
|
||||
continue
|
||||
}
|
||||
// #340: hard-exclude before grouping/creating any image search task.
|
||||
// #381: cancelled SYB order, same hard block.
|
||||
if row.SYBIsCancelled != nil && *row.SYBIsCancelled {
|
||||
response.Items = append(response.Items, ImageSearchBatchItem{SYBProductIDs: []uint64{id}, Code: "PURCHASE_SYB_CANCELLED", Message: "SYB 订单已取消,无需采购"})
|
||||
continue
|
||||
}
|
||||
if row.PDDExcluded {
|
||||
response.Items = append(response.Items, ImageSearchBatchItem{SYBProductIDs: []uint64{id}, Code: "PURCHASE_PDD_EXCLUDED", Message: "该商品已标记为无需 PDD 采购"})
|
||||
continue
|
||||
|
||||
@@ -42,3 +42,34 @@ func TestBatchCreateImageSearchRejectsExcludedRow(t *testing.T) {
|
||||
t.Fatalf("expected PURCHASE_PDD_EXCLUDED, got %+v", response.Items[0])
|
||||
}
|
||||
}
|
||||
|
||||
// #381: a cancelled SYB order is rejected per row with PURCHASE_SYB_CANCELLED
|
||||
// without fetching an image or creating a task.
|
||||
func TestBatchCreateImageSearchRejectsCancelledRow(t *testing.T) {
|
||||
db := openTaskDatabase(t)
|
||||
rule := models.CollectionRule{Name: "image-search-cancelled", ContentJSON: v2TaskRuleSnapshot()}
|
||||
if err := db.Create(&rule).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
shopee := models.ShopeeProduct{ShopeeItemID: "cancel-1", Title: "t", Currency: "CNY", SpecsJSON: "[]"}
|
||||
if err := db.Create(&shopee).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cancelled := true
|
||||
syb := models.SYBProduct{OrderCode: "ORD-CANCEL", DetailID: 1, StockID: 1, ShopeeItemID: shopee.ShopeeItemID, ShopeeProductID: &shopee.ID, Quantity: 1, UnitPriceCent: 100, ImageURL: "https://example.invalid/c.jpg", ParseStatus: "success", RawJSON: "{}", SYBIsCancelled: &cancelled}
|
||||
if err := db.Create(&syb).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
service := NewService(db)
|
||||
service.FetchImageSearchImage = func(ctx context.Context, url string) (ImageSearchImage, error) {
|
||||
t.Fatalf("must not fetch image for a cancelled row")
|
||||
return ImageSearchImage{}, nil
|
||||
}
|
||||
response, err := service.BatchCreateImageSearch(context.Background(), ImageSearchBatchRequest{RequestID: uuid.NewString(), SYBProductIDs: []uint64{syb.ID}, RuleID: rule.ID})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if response.SuccessCount != 0 || len(response.Items) != 1 || response.Items[0].Code != "PURCHASE_SYB_CANCELLED" {
|
||||
t.Fatalf("expected PURCHASE_SYB_CANCELLED, got %+v", response)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
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), MigratePurchaseTaskCreator)
|
||||
}
|
||||
|
||||
// MigratePurchaseTaskCreator only adds the #382 creator columns to
|
||||
// purchase_task: nullable created_by (indexed) and created_source defaulting
|
||||
// to the empty string. Existing rows are not rewritten or backfilled. Each step is guarded so
|
||||
// a partial run (MySQL DDL commits implicitly) can be re-run safely.
|
||||
func MigratePurchaseTaskCreator(db *gorm.DB, version string) error {
|
||||
m := db.Migrator()
|
||||
for _, column := range []string{"CreatedBy", "CreatedSource"} {
|
||||
if !m.HasColumn(&models.PurchaseTask{}, column) {
|
||||
if err := m.AddColumn(&models.PurchaseTask{}, column); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
if !m.HasIndex(&models.PurchaseTask{}, "idx_purchase_task_created_by") {
|
||||
if err := m.CreateIndex(&models.PurchaseTask{}, "CreatedBy"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return db.Where("version = ?", version).FirstOrCreate(&common.Migration{Version: version}).Error
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user