Compare commits

..
12 changed files with 383 additions and 600 deletions
-3
View File
@@ -30,9 +30,6 @@ android {
}
buildTypes {
debug {
versionNameSuffix = "-370-diag1"
}
release {
isMinifyEnabled = false
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
@@ -1392,12 +1392,6 @@ class PddProductDetailCollector(
var diagnosticInitialSelectedSizes = 0
var diagnosticSelectedSummaryPresent = false
var diagnosticHorizontalSwipes = 0
var diagnosticVerticalSwipes = 0
var diagnosticTrailingEmptyReads = 0
var diagnosticHorizontalUnknown = false
// Exact seed sets can reconcile a later stable visit to the same row.
// These signatures stay in memory; only the aggregate tri-state is saved.
val diagnosticHorizontalRows = mutableMapOf<Set<String>, Boolean?>()
var diagnosticTermination = AgentDiagnosticReason.COLOR_FOUND
val imageAttempts = mutableSetOf<String>()
@@ -1454,13 +1448,6 @@ class PddProductDetailCollector(
initialSelectedSizeCount = diagnosticInitialSelectedSizes,
selectedSummaryPresent = diagnosticSelectedSummaryPresent,
horizontalSwipes = diagnosticHorizontalSwipes,
verticalSwipes = diagnosticVerticalSwipes,
trailingEmptyReads = diagnosticTrailingEmptyReads,
horizontalIncomplete = when {
diagnosticHorizontalRows.values.any { it == true } -> true
diagnosticHorizontalUnknown || diagnosticHorizontalRows.values.any { it == null } -> null
else -> false
},
elapsedMs = now() - diagnosticStartedAt,
)
}
@@ -1477,8 +1464,6 @@ class PddProductDetailCollector(
val initialRows = colorRows(initial)
observeColorDiscovery(initial, initialRows)
if (initialRows.isEmpty()) {
diagnosticTrailingEmptyReads = 1
diagnosticHorizontalUnknown = true
finishColorDiscovery(
if (diagnosticNonClickableCandidates > 0) AgentDiagnosticReason.COLOR_VALUES_NOT_CLICKABLE
else AgentDiagnosticReason.COLOR_EDGE_REACHED,
@@ -1486,8 +1471,7 @@ class PddProductDetailCollector(
return null
}
val attempted = mutableSetOf<String>()
fun collectVisibleColors(onFirstRows: ((List<List<VisibleSpecValue>>) -> Unit)? = null): PddCollectorResult? {
var firstRead = true
fun collectVisibleColors(): PddCollectorResult? {
while (true) {
if (now() > deadline) return failure("RULE_NOT_MATCHED", "采集超过规则总超时")
val screen = parse(goodsId, config, evidence)
@@ -1495,10 +1479,6 @@ class PddProductDetailCollector(
if (!screen.pageEvidenceMatched) return failure("RULE_NOT_MATCHED", "采集期间离开 PDD 商品详情页")
screen.dimensions.filter { it.key == "unsupported" }.forEach { unsupported += it.name }
val rows = colorRows(screen)
if (firstRead) {
onFirstRows?.invoke(rows)
firstRead = false
}
observeColorDiscovery(screen, rows)
rows.flatten().filter { SpecValueNormalizer.isSafeColor(it.text) }.forEach { value ->
colors[value.text] = colors[value.text] == true || value.available
@@ -1551,18 +1531,8 @@ class PddProductDetailCollector(
rowSeeds.forEachIndexed { rowIndex, seed ->
val moveRight = rowIndex % 2 == 0
val horizontalSignatureReads = mutableMapOf<List<String>, Int>()
var diagnosticPreviousSignature: List<String>? = null
var diagnosticStableReads = 0
var diagnosticMoved = false
var diagnosticRowUncertain = false
var diagnosticRowIncomplete: Boolean? = null
for (horizontalPass in 0..config.limits.getValue("specHorizontalSwipes")) {
var diagnosticBeforeClicks: List<String>? = null
collectVisibleColors { observedRows ->
diagnosticBeforeClicks = observedRows.filter { row -> row.any { it.text in seed } }
.singleOrNull()?.sortedBy { it.node.bounds.left }
?.let { if (moveRight) it else it.reversed() }?.let(::optionSignature)
}?.let { return it }
collectVisibleColors()?.let { return it }
screen = parse(goodsId, config, evidence)
screen.problem?.let { return failure(it.code, it.message) }
if (!screen.pageEvidenceMatched) return failure("RULE_NOT_MATCHED", "采集期间离开 PDD 商品详情页")
@@ -1571,49 +1541,21 @@ class PddProductDetailCollector(
val matchedRow = rows.maxByOrNull { row -> row.count { it.text in seed } }
?.takeIf { row -> row.any { it.text in seed } }
if (matchedRow == null) {
diagnosticHorizontalUnknown = true
diagnosticTermination = AgentDiagnosticReason.COLOR_ROW_REFLOWED
break
}
val currentRow = matchedRow.sortedBy { it.node.bounds.left }
.let { if (moveRight) it else it.reversed() }
val signature = optionSignature(currentRow)
// Observe the frame immediately after the prior swipe,
// before clicking newly visible values. A click reflow or
// ambiguous row match cannot prove horizontal movement.
if (diagnosticBeforeClicks == null || diagnosticBeforeClicks != signature) {
diagnosticRowUncertain = true
diagnosticHorizontalUnknown = true
}
if (!diagnosticRowUncertain && diagnosticPreviousSignature != null && signature != diagnosticPreviousSignature) {
diagnosticMoved = true
}
diagnosticStableReads = if (signature == diagnosticPreviousSignature) diagnosticStableReads + 1 else 0
diagnosticPreviousSignature = signature
val signatureReads = (horizontalSignatureReads[signature] ?: 0) + 1
horizontalSignatureReads[signature] = signatureReads
if (signatureReads > config.limits.getValue("stableEdgeReads")) {
if (!diagnosticRowUncertain && diagnosticStableReads >= config.limits.getValue("stableEdgeReads")) {
diagnosticRowIncomplete = false
}
break
}
if (horizontalPass == config.limits.getValue("specHorizontalSwipes")) {
if (diagnosticMoved && !diagnosticRowUncertain) diagnosticRowIncomplete = true
break
}
if (signatureReads > config.limits.getValue("stableEdgeReads")) break
if (horizontalPass == config.limits.getValue("specHorizontalSwipes")) break
val direction = if (moveRight) SwipeDirection.LEFT else SwipeDirection.RIGHT
if (!driver.swipeSpec(direction, currentRow.first().node)) {
if (diagnosticMoved && !diagnosticRowUncertain) diagnosticRowIncomplete = true
break
}
if (!driver.swipeSpec(direction, currentRow.first().node)) break
diagnosticHorizontalSwipes++
pause(350)
}
// An uncertain revisit cannot erase previously observed
// unresolved movement; only reliable stability clears it.
diagnosticHorizontalRows[seed] = diagnosticRowIncomplete
?: diagnosticHorizontalRows[seed]?.takeIf { it }
}
collectVisibleColors()?.let { return it }
screen = parse(goodsId, config, evidence)
@@ -1622,10 +1564,6 @@ class PddProductDetailCollector(
observeColorDiscovery(screen, rows)
}
// One observation per outer discovery round, never per click or
// stable-price reread. Empty parsed rows do not prove an edge.
diagnosticTrailingEmptyReads = if (rows.isEmpty()) diagnosticTrailingEmptyReads + 1 else 0
if (rows.isEmpty()) diagnosticHorizontalUnknown = true
if (!discoverVertically) {
diagnosticTermination = AgentDiagnosticReason.COLOR_EDGE_REACHED
break
@@ -1648,7 +1586,6 @@ class PddProductDetailCollector(
}
val anchor = rows.flatten().firstOrNull()?.node ?: specPanelContainer
if (anchor == null) {
diagnosticHorizontalUnknown = true
diagnosticTermination = AgentDiagnosticReason.COLOR_CONTAINER_UNAVAILABLE
break
}
@@ -1656,7 +1593,6 @@ class PddProductDetailCollector(
diagnosticTermination = AgentDiagnosticReason.COLOR_SWIPE_FAILED
break
}
diagnosticVerticalSwipes++
pause(350)
}
finishColorDiscovery()
@@ -1685,9 +1621,6 @@ class PddProductDetailCollector(
initialSelectedSizeCount: Int,
selectedSummaryPresent: Boolean,
horizontalSwipes: Int,
verticalSwipes: Int,
trailingEmptyReads: Int,
horizontalIncomplete: Boolean?,
elapsedMs: Long,
) {
if (taskId <= 0) return
@@ -1706,9 +1639,6 @@ class PddProductDetailCollector(
initialSelectedSizeCount = initialSelectedSizeCount,
selectedSummaryPresent = selectedSummaryPresent,
horizontalSwipeCount = horizontalSwipes,
colorVerticalSwipeCount = verticalSwipes,
colorTrailingEmptyReadCount = trailingEmptyReads,
colorHorizontalIncomplete = horizontalIncomplete,
),
)
}
@@ -1,7 +1,7 @@
package cn.ilapage.goauto.agent.persistence
internal object AgentDiagnosticSchema {
const val VERSION = 5
const val VERSION = 4
val failureSnapshotStatements = listOf(
"""CREATE TABLE IF NOT EXISTS purchase_failure_snapshot (
@@ -38,12 +38,6 @@ internal object AgentDiagnosticSchema {
"horizontal_swipe_count" to "INTEGER",
)
val colorDiscoveryOutcomeColumns = linkedMapOf(
"color_vertical_swipe_count" to "INTEGER",
"color_trailing_empty_read_count" to "INTEGER",
"color_horizontal_incomplete" to "INTEGER",
)
val createTableSql =
"""CREATE TABLE agent_diagnostic (
id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -72,9 +66,6 @@ internal object AgentDiagnosticSchema {
initial_selected_size_count INTEGER,
selected_summary_present INTEGER,
horizontal_swipe_count INTEGER,
color_vertical_swipe_count INTEGER,
color_trailing_empty_read_count INTEGER,
color_horizontal_incomplete INTEGER,
task_type TEXT,
task_attempt_id TEXT,
device_id INTEGER,
@@ -96,8 +87,5 @@ internal object AgentDiagnosticSchema {
(if (oldVersion < 3 && newVersion >= 3) purchaseDiagnosticColumns.mapNotNull { (name, definition) ->
if (name in existingColumns) null else "ALTER TABLE agent_diagnostic ADD COLUMN $name $definition"
} else emptyList()) +
(if (oldVersion < 4 && newVersion >= 4) failureSnapshotStatements else emptyList()) +
(if (oldVersion < 5 && newVersion >= 5) colorDiscoveryOutcomeColumns.mapNotNull { (name, definition) ->
if (name in existingColumns) null else "ALTER TABLE agent_diagnostic ADD COLUMN $name $definition"
} else emptyList())
(if (oldVersion < 4 && newVersion >= 4) failureSnapshotStatements else emptyList())
}
@@ -114,12 +114,6 @@ data class AgentDiagnosticEvent(
val deviceId: Long? = null,
val phase: String? = null,
val ruleSnapshotHash: String? = null,
/** Successfully dispatched UP swipes during this color discovery; null means unknown. */
val colorVerticalSwipeCount: Int? = null,
/** Consecutive empty parsed-color discovery rounds at termination, excluding click/price reads. */
val colorTrailingEmptyReadCount: Int? = null,
/** Required horizontal exploration still unresolved at termination; not needed or recovered is false. */
val colorHorizontalIncomplete: Boolean? = null,
)
class SafeAgentDiagnosticRecorder(
@@ -203,9 +197,6 @@ class AgentDiagnosticStore(context: Context) : SQLiteOpenHelper(context, DATABAS
putNullableInt("initial_selected_size_count", event.initialSelectedSizeCount)
putNullableBoolean("selected_summary_present", event.selectedSummaryPresent)
putNullableInt("horizontal_swipe_count", event.horizontalSwipeCount)
putNullableInt("color_vertical_swipe_count", event.colorVerticalSwipeCount)
putNullableInt("color_trailing_empty_read_count", event.colorTrailingEmptyReadCount)
putNullableBoolean("color_horizontal_incomplete", event.colorHorizontalIncomplete)
put("agent_version", BuildConfig.VERSION_NAME)
put("created_at", event.createdAt)
}
@@ -9,27 +9,6 @@ import org.junit.Assert.assertEquals
import org.junit.Test
class AgentDiagnosticRecorderTest {
@Test
fun colorDiscoveryOutcomeDefaultsToUnknownAndPreservesExplicitValues() {
val event = AgentDiagnosticEvent(
taskId = 370,
stage = AgentDiagnosticStage.COLOR_DISCOVERY,
reason = AgentDiagnosticReason.COLOR_EDGE_REACHED,
)
assertEquals(null, event.colorVerticalSwipeCount)
assertEquals(null, event.colorTrailingEmptyReadCount)
assertEquals(null, event.colorHorizontalIncomplete)
val completed = event.copy(
colorVerticalSwipeCount = 4,
colorTrailingEmptyReadCount = 2,
colorHorizontalIncomplete = false,
)
assertEquals(4, completed.colorVerticalSwipeCount)
assertEquals(2, completed.colorTrailingEmptyReadCount)
assertEquals(false, completed.colorHorizontalIncomplete)
assertEquals(true, completed.copy(colorHorizontalIncomplete = true).colorHorizontalIncomplete)
}
@Test
fun diagnosticsAreBoundedToFiftyRecordsAndSevenDays() {
assertEquals(50, AgentDiagnosticRetentionPolicy.MAX_RECORDS)
@@ -1220,246 +1220,6 @@ class PddProductDetailCollectorTest {
assertTrue(driver.swipes.any { it.first == SwipeDirection.UP })
}
@Test
fun `color discovery counts only its own successful vertical swipes`() {
val colors = listOf("A色", "B色", "C色")
val events = mutableListOf<AgentDiagnosticEvent>()
val driver = FakeCollectorDriver(
colors = colors,
colorVerticalPages = listOf(colors.take(2), colors.takeLast(2)),
sizePages = listOf(listOf("S"), listOf("M"), listOf("L")),
rowSize = 1,
hideColorHeadingAfterFirstVerticalPage = true,
)
var clock = 0L
val result = PddProductDetailCollector(driver, { clock }, { clock += it }, taskId = 124, diagnostic = events::add)
.collect(GOODS_ID, rule(config().copy(timeoutsMs = config().timeoutsMs + ("overall" to 30000))))
assertTrue(result.successful)
val event = events.single { it.stage == AgentDiagnosticStage.COLOR_DISCOVERY }
assertEquals(2, event.colorVerticalSwipeCount)
assertTrue(driver.swipes.count { it.first == SwipeDirection.UP } > requireNotNull(event.colorVerticalSwipeCount))
assertTrue(driver.swipes.any { it.first == SwipeDirection.DOWN })
}
@Test
fun `color discovery failed vertical dispatch is not counted`() {
val events = mutableListOf<AgentDiagnosticEvent>()
val driver = FakeCollectorDriver(colors = listOf("A色", "B色"), rowSize = 1, verticalSwipeSucceeds = false)
var clock = 0L
val result = PddProductDetailCollector(driver, { clock }, { clock += it }, taskId = 124, diagnostic = events::add)
.collect(GOODS_ID, rule())
assertTrue(result.successful)
val event = events.single { it.stage == AgentDiagnosticStage.COLOR_DISCOVERY }
assertEquals(AgentDiagnosticReason.COLOR_SWIPE_FAILED, event.reason)
assertEquals(0, event.colorVerticalSwipeCount)
assertEquals(0, event.colorTrailingEmptyReadCount)
assertEquals(false, event.colorHorizontalIncomplete)
}
@Test
fun `color discovery counts empty parsed rounds after heading scrolls away without counting price reads`() {
val colors = listOf("A色", "B色", "C色", "D色")
val events = mutableListOf<AgentDiagnosticEvent>()
val driver = FakeCollectorDriver(
colors = colors,
colorVerticalPages = listOf(colors.take(2), colors.takeLast(2)),
rowSize = 1,
hideColorHeadingAfterFirstVerticalPage = true,
priceDelayReads = colors.associateWith { 2 },
)
var clock = 0L
val result = PddProductDetailCollector(driver, { clock }, { clock += it }, taskId = 124, diagnostic = events::add)
.collect(GOODS_ID, rule(config().copy(timeoutsMs = config().timeoutsMs + ("overall" to 30000))))
assertTrue(result.successful)
// This fixture retains the options while their heading disappears. The
// existing parser/traversal still misses the second page in this stage.
assertEquals(colors.take(2), requireNotNull(result.payload).dimensions.first { it.key == "color" }.values)
val event = events.single { it.stage == AgentDiagnosticStage.COLOR_DISCOVERY }
assertEquals(2, event.colorTrailingEmptyReadCount)
assertEquals(2, event.colorVerticalSwipeCount)
assertEquals(null, event.colorHorizontalIncomplete)
}
@Test
fun `color discovery initially empty is one empty round with unknown horizontal state`() {
val events = mutableListOf<AgentDiagnosticEvent>()
var clock = 0L
val result = PddProductDetailCollector(
FakeCollectorDriver(colors = emptyList()), { clock }, { clock += it }, taskId = 124, diagnostic = events::add,
).collect(GOODS_ID, rule())
assertTrue(result.successful)
val event = events.single { it.stage == AgentDiagnosticStage.COLOR_DISCOVERY }
assertEquals(0, event.colorVerticalSwipeCount)
assertEquals(1, event.colorTrailingEmptyReadCount)
assertEquals(null, event.colorHorizontalIncomplete)
}
@Test
fun `color discovery resets trailing empty rounds after nonempty discovery`() {
val colors = listOf("A色", "B色", "C色", "D色")
val events = mutableListOf<AgentDiagnosticEvent>()
val driver = FakeCollectorDriver(
colors = colors,
colorVerticalPages = listOf(colors.take(2), emptyList(), colors.takeLast(2)),
rowSize = 1,
)
var clock = 0L
val result = PddProductDetailCollector(driver, { clock }, { clock += it }, taskId = 124, diagnostic = events::add)
.collect(GOODS_ID, rule(config().copy(timeoutsMs = config().timeoutsMs + ("overall" to 30000))))
assertTrue(result.successful)
assertEquals(colors, requireNotNull(result.payload).dimensions.first { it.key == "color" }.values)
val event = events.single { it.stage == AgentDiagnosticStage.COLOR_DISCOVERY }
assertEquals(3, event.colorVerticalSwipeCount)
assertEquals(0, event.colorTrailingEmptyReadCount)
}
@Test
fun `color discovery stable horizontal rows are complete`() {
val events = mutableListOf<AgentDiagnosticEvent>()
var clock = 0L
val result = PddProductDetailCollector(
FakeCollectorDriver(colors = listOf("A色", "B色")), { clock }, { clock += it }, taskId = 124, diagnostic = events::add,
).collect(GOODS_ID, rule())
assertTrue(result.successful)
val event = events.single { it.stage == AgentDiagnosticStage.COLOR_DISCOVERY }
assertEquals(false, event.colorHorizontalIncomplete)
assertEquals(0, event.colorTrailingEmptyReadCount)
}
@Test
fun `color discovery horizontal dispatch failure before movement stays unknown`() {
val events = mutableListOf<AgentDiagnosticEvent>()
val base = FakeCollectorDriver(colors = listOf("A色", "B色"))
val driver = object : PddCollectorDriver by base {
override fun swipeSpec(direction: SwipeDirection, anchor: SnapshotNode?): Boolean =
if (direction == SwipeDirection.LEFT) false else base.swipeSpec(direction, anchor)
}
var clock = 0L
val result = PddProductDetailCollector(driver, { clock }, { clock += it }, taskId = 124, diagnostic = events::add)
.collect(GOODS_ID, rule())
assertTrue(result.successful)
val event = events.single { it.stage == AgentDiagnosticStage.COLOR_DISCOVERY }
assertEquals(0, event.horizontalSwipeCount)
assertEquals(null, event.colorHorizontalIncomplete)
}
@Test
fun `color discovery successful horizontal dispatch alone does not prove required exploration`() {
val events = mutableListOf<AgentDiagnosticEvent>()
var clock = 0L
val limited = config().copy(limits = config().limits + mapOf("specHorizontalSwipes" to 1, "stableEdgeReads" to 2))
val result = PddProductDetailCollector(
FakeCollectorDriver(colors = listOf("A色", "B色")), { clock }, { clock += it }, taskId = 124, diagnostic = events::add,
).collect(GOODS_ID, rule(limited))
assertTrue(result.successful)
val event = events.single { it.stage == AgentDiagnosticStage.COLOR_DISCOVERY }
assertEquals(1, event.horizontalSwipeCount)
assertEquals(null, event.colorHorizontalIncomplete)
}
@Test
fun `color discovery observed horizontal movement then failure is incomplete`() {
val colors = listOf("A色", "B色", "C色")
val events = mutableListOf<AgentDiagnosticEvent>()
val base = FakeCollectorDriver(colors = colors, colorPages = listOf(colors.take(2), colors.takeLast(2)))
var leftSwipes = 0
val driver = object : PddCollectorDriver by base {
override fun swipeSpec(direction: SwipeDirection, anchor: SnapshotNode?): Boolean {
if (direction == SwipeDirection.LEFT && leftSwipes++ > 0) return false
return base.swipeSpec(direction, anchor)
}
}
var clock = 0L
val result = PddProductDetailCollector(driver, { clock }, { clock += it }, taskId = 124, diagnostic = events::add)
.collect(GOODS_ID, rule())
assertTrue(result.successful)
assertEquals(colors, requireNotNull(result.payload).dimensions.first { it.key == "color" }.values)
val event = events.single { it.stage == AgentDiagnosticStage.COLOR_DISCOVERY }
assertEquals(1, event.horizontalSwipeCount)
assertEquals(true, event.colorHorizontalIncomplete)
}
@Test
fun `color discovery uncertain revisit preserves proven incomplete horizontal row`() {
val colors = listOf("A色", "B色", "C色", "D色", "E色")
val events = mutableListOf<AgentDiagnosticEvent>()
val base = FakeCollectorDriver(
colors = colors,
colorPages = listOf(listOf("A色", "B色", "D色", "E色"), listOf("B色", "C色", "D色", "E色")),
rowSize = 2,
resetHorizontalPageOnVerticalSwipe = true,
)
var leftSwipes = 0
val driver = object : PddCollectorDriver by base {
override fun swipeSpec(direction: SwipeDirection, anchor: SnapshotNode?): Boolean {
if (direction == SwipeDirection.LEFT && leftSwipes++ > 0) return false
return base.swipeSpec(direction, anchor)
}
}
var clock = 0L
val result = PddProductDetailCollector(driver, { clock }, { clock += it }, taskId = 124, diagnostic = events::add)
.collect(GOODS_ID, rule())
assertTrue(result.successful)
assertTrue(requireNotNull(result.payload).dimensions.first { it.key == "color" }.values.contains("C色"))
assertTrue(leftSwipes > 2)
// The first A/B visit moves then fails; later A/B visits fail before
// movement. Unknown new evidence must not erase that unresolved work.
assertEquals(true, events.single { it.stage == AgentDiagnosticStage.COLOR_DISCOVERY }.colorHorizontalIncomplete)
}
@Test
fun `color discovery observed horizontal movement at budget is incomplete`() {
val colors = listOf("A色", "B色", "C色")
val events = mutableListOf<AgentDiagnosticEvent>()
var clock = 0L
val limited = config().copy(limits = config().limits + ("specHorizontalSwipes" to 1))
val result = PddProductDetailCollector(
FakeCollectorDriver(colors = colors, colorPages = listOf(colors.take(2), colors.takeLast(2))),
{ clock }, { clock += it }, taskId = 124, diagnostic = events::add,
).collect(GOODS_ID, rule(limited))
assertTrue(result.successful)
assertEquals(true, events.single { it.stage == AgentDiagnosticStage.COLOR_DISCOVERY }.colorHorizontalIncomplete)
}
@Test
fun `color discovery disjoint horizontal reflow is unknown`() {
val colors = listOf("A色", "B色", "C色", "D色")
val events = mutableListOf<AgentDiagnosticEvent>()
var clock = 0L
val result = PddProductDetailCollector(
FakeCollectorDriver(colors = colors, colorPages = listOf(colors.take(2), colors.takeLast(2))),
{ clock }, { clock += it }, taskId = 124, diagnostic = events::add,
).collect(GOODS_ID, rule())
assertTrue(result.successful)
val event = events.single { it.stage == AgentDiagnosticStage.COLOR_DISCOVERY }
assertEquals(1, event.horizontalSwipeCount)
assertEquals(null, event.colorHorizontalIncomplete)
}
@Test
fun `color discovery records only bounded aggregate evidence`() {
val colors = listOf("A色", "B色", "C色", "D色")
@@ -2097,7 +1857,6 @@ class PddProductDetailCollectorTest {
sizes: List<String> = listOf("S"),
private val colorPages: List<List<String>> = listOf(colors),
private val colorVerticalPages: List<List<String>>? = null,
private val resetHorizontalPageOnVerticalSwipe: Boolean = false,
private val sizePages: List<List<String>> = listOf(sizes),
private val prices: Map<String, Long> = colors.associateWith { 1000L },
private val failedClicks: Set<String> = emptySet(),
@@ -2108,7 +1867,6 @@ class PddProductDetailCollectorTest {
private val rowSize: Int = 4,
private val hideSelectedColorOption: Boolean = false,
private val hideDimensionHeadingsAfterFirstVerticalPage: Boolean = false,
private val hideColorHeadingAfterFirstVerticalPage: Boolean = false,
private val soldOutUntilPulls: Int = 0,
private val startWithPanelClosed: Boolean = false,
private val firstEntryOpensReview: Boolean = false,
@@ -2228,7 +1986,7 @@ class PddProductDetailCollectorTest {
?: colorPages[horizontalPage.coerceAtMost(colorPages.lastIndex)]
val colorRowCount = (visibleColors.size + rowSize - 1) / rowSize
val sizeHeadingTop = maxOf(700, 470 + colorRowCount * 90 + 20)
if (!continuationPage && !panelCollapsedNow && !(hideColorHeadingAfterFirstVerticalPage && verticalPage > 0)) {
if (!continuationPage && !panelCollapsedNow) {
nodes += node("scroll/color-heading", "颜色分类", 20, 400, 300, 450, parentPath = "scroll")
}
if (!continuationPage && !hideColorNow && !panelCollapsedNow) {
@@ -2360,11 +2118,8 @@ class PddProductDetailCollectorTest {
if ((direction == SwipeDirection.UP || direction == SwipeDirection.DOWN) && !verticalSwipeSucceeds) return false
when (direction) {
SwipeDirection.LEFT -> horizontalPage = (horizontalPage + 1).coerceAtMost(colorPages.lastIndex)
SwipeDirection.UP -> {
verticalPage = (verticalPage + 1)
.coerceAtMost(maxOf(sizePages.lastIndex, colorVerticalPages?.lastIndex ?: 0))
if (resetHorizontalPageOnVerticalSwipe) horizontalPage = 0
}
SwipeDirection.UP -> verticalPage = (verticalPage + 1)
.coerceAtMost(maxOf(sizePages.lastIndex, colorVerticalPages?.lastIndex ?: 0))
SwipeDirection.DOWN -> {
verticalPage = (verticalPage - 1).coerceAtLeast(0)
downSwipeCount++
@@ -3,112 +3,10 @@ package cn.ilapage.goauto.agent.persistence
import java.sql.Connection
import java.sql.DriverManager
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class AgentDiagnosticStoreMigrationTest {
@Test
fun freshV5DatabaseHasNullableColorDiscoveryOutcomeColumns() = withDatabase { db ->
db.createStatement().use { it.execute(AgentDiagnosticSchema.createTableSql) }
assertEquals(5, AgentDiagnosticSchema.VERSION)
assertV5Columns(db)
insertOldDiagnostic(db)
assertOldDiagnosticPreserved(db)
for (incomplete in listOf(0, 1)) {
db.createStatement().use { statement ->
statement.executeUpdate(
"UPDATE agent_diagnostic SET color_vertical_swipe_count=4, " +
"color_trailing_empty_read_count=2, color_horizontal_incomplete=$incomplete",
)
statement.executeQuery(
"SELECT color_vertical_swipe_count, color_trailing_empty_read_count, " +
"color_horizontal_incomplete FROM agent_diagnostic",
).use { row ->
assertTrue(row.next())
assertEquals(4, row.getInt(1))
assertEquals(2, row.getInt(2))
assertEquals(incomplete, row.getInt(3))
}
}
}
}
@Test
fun everySupportedVersionUpgradesToV5WithoutChangingExistingRows() {
for (oldVersion in 1..4) withDatabase { db ->
createOldDatabase(db, oldVersion)
insertOldDiagnostic(db)
val existingTables = snapshotTables(db)
migrate(db, oldVersion, 5)
assertV5Columns(db)
assertOldDiagnosticPreserved(db)
existingTables.forEach { (name, rows) -> assertEquals(name, rows, tableRows(db, name)) }
assertTrue(columnNames(db).containsAll(AgentDiagnosticSchema.colorDiagnosticColumns.keys))
assertTrue(columnNames(db).containsAll(AgentDiagnosticSchema.purchaseDiagnosticColumns.keys))
assertEquals(3, snapshotTables(db).size)
migrate(db, oldVersion, 5)
assertOldDiagnosticPreserved(db)
existingTables.forEach { (name, rows) -> assertEquals(name, rows, tableRows(db, name)) }
}
}
@Test
fun v4UpgradeAddsOnlyMissingColumnsAndKeepsExistingV5Values() = withDatabase { db ->
createOldDatabase(db, 4)
insertOldDiagnostic(db)
db.createStatement().use {
it.execute("ALTER TABLE agent_diagnostic ADD COLUMN color_vertical_swipe_count INTEGER")
it.executeUpdate("UPDATE agent_diagnostic SET color_vertical_swipe_count=3")
}
val statements = AgentDiagnosticSchema.migrationStatements(4, 5, columnNames(db))
assertEquals(2, statements.size)
assertTrue(statements.all { it.startsWith("ALTER TABLE agent_diagnostic ADD COLUMN ") })
migrate(db, 4, 5)
assertV5Columns(db)
db.createStatement().use { statement ->
statement.executeQuery("SELECT color_vertical_swipe_count, color_trailing_empty_read_count, color_horizontal_incomplete FROM agent_diagnostic").use {
assertTrue(it.next())
assertEquals(3, it.getInt(1))
assertNull(it.getObject(2))
assertNull(it.getObject(3))
}
}
assertTrue(AgentDiagnosticSchema.migrationStatements(4, 5, columnNames(db)).isEmpty())
assertTrue(AgentDiagnosticSchema.migrationStatements(5, 5, columnNames(db)).isEmpty())
assertTrue(AgentDiagnosticSchema.migrationStatements(4, 4, columnNames(db)).isEmpty())
}
@Test
fun v5UpgradeSurvivesDatabaseReopen() {
val file = java.io.File.createTempFile("goauto-diagnostic-migration-", ".db")
try {
Class.forName("org.sqlite.JDBC")
DriverManager.getConnection("jdbc:sqlite:${file.absolutePath}").use { db ->
createOldDatabase(db, 4)
insertOldDiagnostic(db)
migrate(db, 4, 5)
db.createStatement().use { it.execute("PRAGMA user_version=5") }
}
DriverManager.getConnection("jdbc:sqlite:${file.absolutePath}").use { db ->
assertV5Columns(db)
assertOldDiagnosticPreserved(db)
assertTrue(AgentDiagnosticSchema.migrationStatements(4, 5, columnNames(db)).isEmpty())
assertEquals(3, snapshotTables(db).size)
db.createStatement().use { statement ->
statement.executeQuery("PRAGMA user_version").use {
assertTrue(it.next())
assertEquals(5, it.getInt(1))
}
}
}
} finally {
check(file.delete()) { "Could not remove temporary migration test database" }
}
}
@Test
fun v4AddsIndependentFailureSnapshotQueueWithoutChangingStructuredRows() = withDatabase { db ->
db.createStatement().use {
@@ -118,6 +16,7 @@ class AgentDiagnosticStoreMigrationTest {
AgentDiagnosticSchema.migrationStatements(3, 4, columnNames(db)).forEach { sql ->
db.createStatement().use { it.execute(sql) }
}
assertEquals(4, AgentDiagnosticSchema.VERSION)
for (table in listOf("purchase_failure_snapshot", "purchase_failure_snapshot_queue", "purchase_failure_snapshot_context")) {
db.prepareStatement("SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=?").use {
it.setString(1, table)
@@ -238,80 +137,6 @@ class AgentDiagnosticStoreMigrationTest {
assertEquals(1, rowCount(db))
}
private fun assertV5Columns(db: Connection) {
val actual = mutableSetOf<String>()
db.createStatement().use { statement ->
statement.executeQuery("PRAGMA table_info(agent_diagnostic)").use { rows ->
while (rows.next()) {
val name = rows.getString("name")
if (name in V5_COLUMNS) {
actual.add(name)
assertEquals(name, "INTEGER", rows.getString("type"))
assertEquals(name, 0, rows.getInt("notnull"))
assertNull(name, rows.getObject("dflt_value"))
}
}
}
}
assertEquals(V5_COLUMNS, actual)
}
private fun createOldDatabase(db: Connection, version: Int) {
db.createStatement().use { it.execute(CREATE_V1_TABLE_SQL) }
migrate(db, 1, version)
if (version >= 4) db.createStatement().use {
it.execute("INSERT INTO purchase_failure_snapshot VALUES ('attempt','test-origin',106,7,'spec_probe',1000,2000,'test','ready','{}',X'0102','test-fingerprint',2)")
it.execute("INSERT INTO purchase_failure_snapshot_queue VALUES ('attempt','test-fingerprint',1500,2)")
it.execute("INSERT INTO purchase_failure_snapshot_context VALUES ('attempt','test-origin','{}',1000)")
}
}
private fun insertOldDiagnostic(db: Connection) {
db.createStatement().use {
it.execute("INSERT INTO agent_diagnostic (task_id,stage,reason,attempt,elapsed_ms,agent_version,created_at) VALUES (106,'COLOR_DISCOVERY','COLOR_EDGE_REACHED',2,10,'old',1000)")
}
}
private fun assertOldDiagnosticPreserved(db: Connection) {
assertEquals(1, rowCount(db))
db.createStatement().use { statement ->
statement.executeQuery("SELECT id,task_id,stage,reason,attempt,elapsed_ms,agent_version,created_at,color_vertical_swipe_count,color_trailing_empty_read_count,color_horizontal_incomplete FROM agent_diagnostic").use {
assertTrue(it.next())
assertEquals(1L, it.getLong(1))
assertEquals(106L, it.getLong(2))
assertEquals("COLOR_DISCOVERY", it.getString(3))
assertEquals("COLOR_EDGE_REACHED", it.getString(4))
assertEquals(2, it.getInt(5))
assertEquals(10L, it.getLong(6))
assertEquals("old", it.getString(7))
assertEquals(1000L, it.getLong(8))
for (index in 9..11) assertNull(it.getObject(index))
}
}
}
private fun snapshotTables(db: Connection): Map<String, List<List<String?>>> =
db.createStatement().use { statement ->
statement.executeQuery("SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'purchase_failure_snapshot%'").use { rows ->
buildList { while (rows.next()) add(rows.getString(1)) }
}
}.associateWith { tableRows(db, it) }
private fun tableRows(db: Connection, table: String): List<List<String?>> =
db.createStatement().use { statement ->
statement.executeQuery("SELECT * FROM $table ORDER BY attempt_id").use { rows ->
buildList {
while (rows.next()) add((1..rows.metaData.columnCount).map { rows.getString(it) })
}
}
}
private fun migrate(db: Connection, oldVersion: Int, newVersion: Int) {
AgentDiagnosticSchema.migrationStatements(oldVersion, newVersion, columnNames(db)).forEach { sql ->
db.createStatement().use { it.execute(sql) }
}
}
private fun migrateV1ToV2(db: Connection) {
AgentDiagnosticSchema.v2MigrationStatements(1, 2, columnNames(db)).forEach { sql ->
db.createStatement().use { it.execute(sql) }
@@ -341,7 +166,6 @@ class AgentDiagnosticStoreMigrationTest {
}
private companion object {
val V5_COLUMNS = setOf("color_vertical_swipe_count", "color_trailing_empty_read_count", "color_horizontal_incomplete")
val CREATE_V1_TABLE_SQL =
"""CREATE TABLE agent_diagnostic (
id INTEGER PRIMARY KEY AUTOINCREMENT,
+2 -23
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Architecture-and-Code-Map
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Architecture-and-Code-Map.-
wiki_revision: 579f053988e3f67f287ff130f9c24a692d897191
synchronized_at: 2026-10-09T02:06:38Z
wiki_revision: 6a6a1a9a13aaf5284e5a81a79f849ad28525ceba
synchronized_at: 2026-10-08T07:10:44Z
<!-- gitea-wiki-mirror:end -->
<!-- gitea-wiki-mirror:start -->
@@ -652,24 +652,3 @@ Web 唯一展示位置为“采集采购 → SYB 同步记录”:列表状态
- Server `purchase/failure_snapshot*.go` 提供专用上传/安全摘要/管理员ZIP下载;`models/purchase_failure_snapshot.go` 独立私有表。追加迁移 `1791400000000_purchase_failure_snapshot.go` 保存 LONGBLOB ZIP、LONGTEXT manifest、attempt唯一约束及到期索引。
- `access.AdminAPIs` 注册两个仅管理员读取接口;普通任务DTO和Client API不增加原始诊断字段。通用操作日志精确排除三个诊断端点,私有SQL读写使用静默logger。服务启动立即清理并每小时清理过期诊断,不依赖新任务。
- Web `purchase-tasks/FailureSnapshotCell.vue` 是现有执行记录末尾200px管理员专属列,独立摘要查询、固定原因中文和附件下载;无原始XML在线预览。
## Android 重购编排(#367)
实现绑定 `5430dc1`,工单分支尚未合并/安装/发布;无Server/Web、数据库或接口迁移。
- `TaskHistoryFragment` 把采购搜索栏入口改为“重购”,发送 PREPARE/CONFIRM/STOP、显示 Service 内存状态;一次确认与逐条摘要由 `RepurchaseState` 提供,不由 Fragment 驱动采购生命周期。
- `RepurchaseBatchDiscovery` 注入既有设备历史查询和服务端HTTP Date时钟,负责全状态/50条页/30天窗口、相邻60秒边界、去重、完整性及重读首页;最多200页,不使用本地历史缓存执行重购。
- `RepurchaseCoordinator` 通过小型 `RepurchasePort` 执行非阻塞状态推进;只调用原reset和只读详情,固定每任务requestId、expected attempt、10秒本地忙碌复核及15分钟单条上限。依靠原执行器的attemptNumber/UUID/phase/已知结果识别合法探测续行,拒绝外部更晚attempt冒充本轮结果。
- `AgentForegroundService` 独立单线程scheduled executor每2秒推进编排,历史网络不阻塞心跳或taskExecutor;发现使用独享API客户端,reset只短暂预留既有TaskExecutionMutex。原scheduler继续唯一负责claim/start/真实PDD操作及Outbox,编排不持整轮锁、不新建执行器路径。
- 新工作查询同时检查采集next、采购next及本设备活动状态历史,防止采购next优先返回本轮current而遮蔽其他pending。停止信号为原子roundId,配置/身份冻结,仅内存保存摘要;进程重启不恢复整轮,既有单任务恢复及失败私有诊断不变。
- `AgentApiClient` 仅补读已有采购payload的attemptNumber和标准HTTP Date响应元数据;不添加服务端字段或API。Date缺失清空样本,不沿用手机时间。
## Android 颜色发现诊断追加(#370 第一阶段)
实现绑定 `9e5f09a`,独立工单分支,尚未合并 main;本阶段不修改颜色遍历和采购结果判定。
- `AgentDiagnosticSchema` 升至 v5,在 `agent_diagnostic` 追加可空 INTEGER `color_vertical_swipe_count`、`color_trailing_empty_read_count`、`color_horizontal_incomplete`;对应事件中的两个 Int? 和一个 Boolean?,Boolean 按 0/1 写入。新库包含列,v1–v4 逐级补齐并跳过已存在列,旧行新列为 NULL;不清表,不改独立采购失败快照、队列和 context。
- `PddProductDetailCollector.collectColors` 只基于既有解析帧记录颜色阶段 UP 派发次数、空发现轮次和横向探索三态;不增加抓取、手势或等待。标签及签名只在内存使用,持久化仍为安全聚合。
- 不新增 Server/Web API 字段、服务端迁移或权限;普通结果 payload/missing 与采购探测失败策略未改变。完整遍历与不完整候选拦截仍属 #370 后续待证据实施范围,不能把本地诊断值作为已实现的业务门禁。
- 读取与三态限制见 Troubleshooting;旧版不保证降级打开 v5。JDBC 测试与实际 Android 安装升级分别验收,测试不代替真实竖向颜色采集验证。
+14 -2
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Business-Rules-and-Glossary
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Business-Rules-and-Glossary.-
wiki_revision: b9efffa2045d01399bfe02ce626cfd1bb379ec41
synchronized_at: 2026-10-08T07:10:48Z
wiki_revision: 2a897541f264a12eee8a455b99da0f5c556972d7
synchronized_at: 2026-10-08T08:55:40Z
<!-- gitea-wiki-mirror:end -->
<!-- gitea-wiki-mirror:start -->
@@ -859,3 +859,15 @@ Android 0.9.64 / versionCode 77,源码 `6550b9f`(分支实现,尚未安装
- 每attempt最多保留一份ZIP,已有ZIP不可覆盖;只有无ZIP未截取记录可升级为首次恢复ZIP。以该现场 recordedAt 起保留30天,重复上传不续期;新恢复ZIP采用真实恢复时刻。两端定期清理,手机快照逻辑总量64MiB,淘汰时同时终止上传队列。清理不修改采购结果。
- 管理员采购详情可见完整/部分/未截取/暂无现场数据及安全下载入口;普通采购员、售后、客户端密钥均无原始诊断下载权限。
- 1500ms、5000节点、2MiB ZIP、8MiB展开、128窗口与深度保护是合成测试覆盖的开发边界,未经过代表性真机样本验收;单次系统Binder读取不能被这些前后检查强行中断。未知/部分必须如实显示,不宣称拿到了系统未暴露的节点。
## 图搜采集部分完成的自动关联资格(#368)
实现基线:工单分支 `fix/368-image-search-partial-link` 提交 `15ccab3`(2026-10-08);尚未合并 main 或发布,生产是否启用以发布记录为准。
- 此规则仅作用于 `source=image_search` 的结果后置自动关联。正常 `completed` 沿用原行为,包括 `missing_json=[]`。
- `completed_partial` 仅在 `missing_json` 为可解析、非空字符串数组且每一项精确属于 `reviewCount`、`salesText`、`shopName` 时允许自动关联。三项均为可选描述信息。
- partial 的空值、JSON null、空数组、非法 JSON、非字符串项、空项、未知项、颜色/尺码/价格/不支持维度缺失一律不放宽。不修剪字段名、不按前缀推测。
- 保留原图搜快照、目标 PDD active、`OriginalPDDProductID` 乐观比较;人工改动后的关联不被覆盖。只有实际写入关联并返回 Linked,才进入既有规格同步和匹配流程。
- 采集任务仍为 `completed_partial`,missing 保留,界面仍显示部分完成;关联成功不等于 AI 匹配成功。匹配无结果或不可用沿用原处理,不自动创建采购。
- 不改变普通采集、手动当前页采集、手动关联、价格保护、映射算法、同步后置 hook 的耗时及重放机制。同图多结果沿用原提交顺序和乐观比较,不新增历史筛选、补关联或回退旧商品。
- 验证边界:自动化测试使用隔离 SQLite 与本地模拟 ERP 服务验证实际 SubmitResult、关联及映射落库;未以此宣称 MySQL、真实 AI、真机或线上发布验证。请求在匹配开始后取消时,匹配使用原有脱钩 context 继续;最后 Detail 读取仍使用原请求 context,可能向断开的调用端返回错误,已提交数据不因此回滚。
+3 -25
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Troubleshooting
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Troubleshooting
wiki_revision: 6b370764320d1f5148fbc31f0ef6ce999370173d
synchronized_at: 2026-10-09T02:06:57Z
wiki_revision: fee37efc79b644ecfa3237283dd45f0a09796f44
synchronized_at: 2026-10-08T03:25:33Z
<!-- gitea-wiki-mirror:end -->
# 故障排查
@@ -76,7 +76,7 @@ Agent 0.9.13 起,颜色发现阶段在本地 `goauto_diagnostics.db` 的 `agen
记录只包含颜色行数、各行值数量、可点击颜色数、不可点击候选数、首次颜色点击前的颜色/尺码选中数、是否存在“已选”摘要、横向滑动次数、终止原因和耗时,不保存规格文案、价格、摘要原文、坐标、链接、goods_id、控件树、XML 或截图。
以下仅为 v4 之前纯结构化诊断库的历史读取方式。v4 及之后包含采购失败私有现场,不再按此导出整库;请使用下方“Android 颜色发现诊断 v5”中的定向只读方式:
只对 Debug APK 使用以下读取流程:
```powershell
adb shell run-as cn.ilapage.goauto.agent ls databases
@@ -136,25 +136,3 @@ adb -s <serial> shell run-as cn.ilapage.goauto.agent sqlite3 -readonly databases
- 完整订单证据出现就结束;超时保留原订单结果未知语义,不能因为等待更久就把任务改为可重试失败或自动重下单。
- 固定期限从 Back 成功时起算,不是从提交订单或进入核单函数起算,也不会因新样本重置;capture 阻塞不能被该检查中断,待付款分支拥有独立预算。
- 不因本次延长断言历史问题就是加载慢。延长期出现必须点击才能展开的入口仍不会新增点击;页面识别/挽留弹窗等原因需要真实诊断证据另行分析。#364 的失败现场诊断不属于本单实现。
## Android 颜色发现诊断 v5(#370 第一阶段)
实现绑定 `9e5f09a`(工单分支),Debug 标识 `0.9.69-370-diag1`,尚未合并 main。本阶段只补诊断和测试夹具,不改变遍历、停止条件、采集完成状态或采购探测决策;不能据此宣称竖向漏采已修复。
`COLOR_DISCOVERY` 的结束聚合新增三个可空列:
- `color_vertical_swipe_count`:本次颜色循环中成功派发的 UP 次数,排除归顶及尺码阶段;不证明视口实际移动。
- `color_trailing_empty_read_count`:终止前连续解析不到颜色的外层发现轮数;每轮只记一次,点击与采价重读不计,非空轮清零,初始空为 1。
- `color_horizontal_incomplete`:1 表示已观察到同一行签名变化后,必要横向探索因失败或预算耗尽仍未完成;0 表示当前已观察行无需横扫或取得连续稳定证据;NULL 表示无法确认,包括重排、空解析、缺少横向容器或仅成功派发但没有移动证据。同 seed 的未知回访不能清除旧的 1,可靠完成的 0 可以清除。0 不代表整个商品已采全,签名变化也不是物理滑动成功的独立证明。
只保存计数、布尔/NULL 和固定原因码,不保存标签、价格、坐标、原始控件树、截图或个人信息。每行累计数仍不是逐屏轨迹;缺少日志不证明没执行,错误/超时提前退出可能没有结束聚合。现有 50 条/7 天留存不变。
本地 SQLite v4→v5 只补三列;旧行新值保持 NULL,不删除旧诊断或采购失败快照/队列/context。旧程序不保证能降级打开 v5,不能靠卸载清数据回退。
`goauto_diagnostics.db` 从 v4 起还含采购失败私有现场,不能按早期“纯结构化库”的假设导出或上传整库。已授权的 Debug 设备有 sqlite3 时可只查询所需聚合字段;无 sqlite3 时只使用已确认的内存读取方式,避免整库落盘,不放宽应用权限。示例查询(须替换设备,且实际存在 sqlite3):
```powershell
adb -s <serial> shell run-as cn.ilapage.goauto.agent sqlite3 -readonly databases/goauto_diagnostics.db "SELECT task_id,reason,color_vertical_swipe_count,color_trailing_empty_read_count,color_horizontal_incomplete FROM agent_diagnostic WHERE stage='COLOR_DISCOVERY' ORDER BY id DESC LIMIT 1;"
```
排查须绑定同一设备、Agent/PDD 版本、任务与规则快照,并对照同次 App 布局。网页竖排与 App 横排不是同一复现场景;当前横排只能做横向兼容性回归。真机测试结果及待验证项以 #370 工单为准。
+29 -3
View File
@@ -2,6 +2,7 @@ package task
import (
"context"
"encoding/json"
"errors"
"fmt"
"log"
@@ -15,8 +16,8 @@ import (
)
// autoLinkImageSearch is deliberately a narrow post-result hook. It only
// writes an association when the task was a complete collection, the frozen
// original association is unchanged, and the target product is active. Manual
// writes an association when the collection is complete or missing only optional
// metadata, the frozen original association is unchanged, and the target product is active. Manual
// changes made while the Agent was running win by the CAS predicate.
// autoLinkedImageSearch 汇报本次是否确实写入了关联,以及关联覆盖了哪些 SYB 明细。
// 只有确实写入时才值得触发后续的规格匹配(#287)。
@@ -32,7 +33,7 @@ func (service *Service) autoLinkImageSearch(ctx context.Context, taskID uint64)
if err := service.DB.WithContext(ctx).First(&task, taskID).Error; err != nil {
return autoLinkedImageSearch{}, internalError(err)
}
if task.Source != models.CollectionTaskSourceImageSearch || task.Status != models.TaskStatusCompleted || task.PDDProductID == nil || task.ImageSearchSnapshot == nil {
if task.Source != models.CollectionTaskSourceImageSearch || !imageSearchResultAllowsAutoLink(task) || task.PDDProductID == nil || task.ImageSearchSnapshot == nil {
return autoLinkedImageSearch{}, nil
}
snapshot, err := loadImageSearchSnapshot(task)
@@ -94,6 +95,31 @@ func (service *Service) autoLinkImageSearch(ctx context.Context, taskID uint64)
}, nil
}
// #368: Keep completed behavior unchanged. A partial result is eligible only
// with explicit, nonempty evidence that every missing field is optional.
// Unknown/malformed missing data must not silently widen this whitelist.
func imageSearchResultAllowsAutoLink(task models.CollectionTask) bool {
if task.Status == models.TaskStatusCompleted {
return true
}
if task.Status != models.TaskStatusCompletedPartial || task.MissingJSON == nil {
return false
}
var missing []string
if err := json.Unmarshal([]byte(*task.MissingJSON), &missing); err != nil || len(missing) == 0 {
return false
}
for _, field := range missing {
switch field {
case "reviewCount", "salesText", "shopName":
// Optional descriptive metadata; never colors, sizes or prices.
default:
return false
}
}
return true
}
// matchSpecsAfterImageSearch triggers the existing SYB batch spec match for the
// details the image-search task just linked (#287).
//
@@ -0,0 +1,324 @@
package task
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"reflect"
"sync/atomic"
"testing"
"go-admin/app/goauto/models"
"go-admin/app/goauto/shopeeproduct"
"github.com/google/uuid"
"gorm.io/gorm"
)
// This fixture leaves the hook uncalled so tests can change the submitted
// status, missing fields and association independently of the assertions.
func seedPartialImageSearch(t *testing.T, db *gorm.DB, deviceID *uint64) (models.CollectionTask, models.ShopeeProduct) {
t.Helper()
record := createTask(t, db, deviceID)
if err := db.Model(&models.PDDProduct{}).Where("id = ?", *record.PDDProductID).Update("status", "active").Error; err != nil {
t.Fatal(err)
}
shopee := models.ShopeeProduct{
ShopeeItemID: "test-item", Title: "test", Currency: "TWD",
SpecsJSON: `[{"name":"颜色","role":"color","values":[{"name":"黑色","source":"import"}]},{"name":"尺码","role":"size","values":[{"name":"M","source":"import"}]}]`,
}
if err := db.Create(&shopee).Error; err != nil {
t.Fatal(err)
}
syb := models.SYBProduct{
OrderCode: "TEST-ORDER", DetailID: 1, StockID: 1, ShopeeItemID: shopee.ShopeeItemID,
ShopeeProductID: &shopee.ID, TargetColor: "黑色", TargetSize: "M",
Quantity: 1, UnitPriceCent: 1000, ParseStatus: "success", RawJSON: "{}",
}
if err := db.Create(&syb).Error; err != nil {
t.Fatal(err)
}
snapshot := ImageSearchSnapshot{
ShopeeProductID: shopee.ID, RepresentativeSYBProductID: syb.ID, SYBProductIDs: []uint64{syb.ID},
ReferenceCurrency: "TWD", ReferencePriceCent: 1000, MaxPriceRatio: 3, PriceGuardSkipped: true,
ImageSearchImage: ImageSearchImage{ImageURL: "https://example.invalid/a.jpg", MediaType: "image/jpeg", SizeBytes: 1, SHA256: "0123456789012345678901234567890101234567890123456789012345678901"},
}
raw, err := json.Marshal(snapshot)
if err != nil {
t.Fatal(err)
}
if err := db.Model(&record).Updates(map[string]any{"source": models.CollectionTaskSourceImageSearch, "image_search_snapshot": string(raw)}).Error; err != nil {
t.Fatal(err)
}
return record, shopee
}
func TestAutoLinkImageSearchPartialMissingWhitelist(t *testing.T) {
cases := []struct {
name, status, missing string
nilMissing, want bool
}{
{name: "completed_empty", status: models.TaskStatusCompleted, missing: "[]", want: true},
{name: "completed_nil", status: models.TaskStatusCompleted, nilMissing: true, want: true},
{name: "nil", nilMissing: true},
{name: "blank"},
{name: "null", missing: "null"},
{name: "empty", missing: "[]"},
{name: "malformed", missing: `["reviewCount"`},
{name: "object", missing: `{ "reviewCount": true }`},
{name: "scalar", missing: `"reviewCount"`},
{name: "number_entry", missing: `["reviewCount",1]`},
{name: "null_entry", missing: `["reviewCount",null]`},
{name: "blank_entry", missing: `[""]`},
{name: "unknown", missing: `["reviewCount","futureField"]`},
{name: "case_mismatch", missing: `["ReviewCount"]`},
{name: "whitespace", missing: `[" reviewCount "]`},
{name: "color", missing: `["reviewCount","color"]`},
{name: "size", missing: `["size"]`},
{name: "price", missing: `["price"]`},
{name: "color_price", missing: `["reviewCount","price:黑色"]`},
{name: "unsupported_dimension", missing: `["unsupportedDimension:款式"]`},
{name: "invalid_dimension", missing: `["spec_dimension_invalid:size"]`},
{name: "pending", status: models.TaskStatusPending, missing: `["reviewCount"]`},
{name: "failed", status: models.TaskStatusFailed, missing: `["reviewCount"]`},
{name: "duplicate_optional", missing: `["reviewCount","reviewCount"]`, want: true},
}
fields := []string{"reviewCount", "salesText", "shopName"}
for mask := 1; mask < 8; mask++ {
var missing []string
for i, field := range fields {
if mask&(1<<i) != 0 {
missing = append(missing, field)
}
}
raw, _ := json.Marshal(missing)
cases = append(cases, struct {
name, status, missing string
nilMissing, want bool
}{name: fmt.Sprintf("allowed_%d", mask), missing: string(raw), want: true})
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
db := openTaskDatabase(t)
record, shopee := seedPartialImageSearch(t, db, nil)
status := tc.status
if status == "" {
status = models.TaskStatusCompletedPartial
}
var missing *string
if !tc.nilMissing {
missing = &tc.missing
}
if err := record.SetStatus(status); err != nil {
t.Fatal(err)
}
if err := db.Model(&record).Updates(map[string]any{"status": status, "missing_json": missing, "active_slot": record.ActiveSlot, "device_run_slot": record.DeviceRunSlot}).Error; err != nil {
t.Fatal(err)
}
linked, err := NewService(db).autoLinkImageSearch(context.Background(), record.ID)
if err != nil || linked.Linked != tc.want {
t.Fatalf("Linked=%v want=%v error=%v", linked.Linked, tc.want, err)
}
if err := db.First(&shopee, shopee.ID).Error; err != nil {
t.Fatal(err)
}
if shopee.ImageSearchLinked != tc.want || (shopee.PDDProductID != nil) != tc.want {
t.Fatalf("association/marker mismatch: %+v", shopee)
}
if tc.want && (*shopee.PDDProductID != *record.PDDProductID || len(linked.SYBProductIDs) != 1) {
t.Fatal("linked target or downstream SYB identifiers lost")
}
if err := db.First(&record, record.ID).Error; err != nil {
t.Fatal(err)
}
if record.Status != status || !reflect.DeepEqual(record.MissingJSON, missing) {
t.Fatal("eligibility must not rewrite collection status or missing fields")
}
})
}
}
func TestSubmitImageSearchPartialLinkAndMatch(t *testing.T) {
for _, scenario := range []string{"optional_partial", "completed", "critical_missing", "admin_source", "current_page_source", "disabled_pdd", "association_changed", "snapshot_missing", "ai_unavailable", "request_cancelled_during_match"} {
t.Run(scenario, func(t *testing.T) {
requestContext, cancelRequest := context.WithCancel(context.Background())
defer cancelRequest()
var syncCalls atomic.Int32
erp := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
syncCalls.Add(1)
if scenario == "request_cancelled_during_match" {
// Cancel only after the real post-result matching hook has
// started. Its detached context must still persist mappings.
cancelRequest()
}
if scenario == "ai_unavailable" {
w.WriteHeader(http.StatusServiceUnavailable)
return
}
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, `{"data":{"color":["黑色"],"size":["M"]}}`)
}))
defer erp.Close()
t.Setenv("GOAUTO_ERPGO_BASE_URL", erp.URL)
t.Setenv("GOAUTO_ERPGO_APIKEY", "synthetic-test-key")
db := openTaskDatabase(t)
device, token := registerTaskDevice(t, db, "partial-image-search")
record, shopee := seedPartialImageSearch(t, db, &device.ID)
service := newTaskService(db)
// Execution is outside this test's scope; submit through the real
// authenticated running-owner path with a persisted image-search snapshot.
if err := db.Model(&record).Update("status", models.TaskStatusRunning).Error; err != nil {
t.Fatal(err)
}
title, shop, sales, reviews := "商品", "店铺", "已拼100件", int64(12)
request := ResultRequest{
RequestID: uuid.NewString(), Status: models.TaskStatusCompletedPartial,
Product: ResultProduct{PDDGoodsID: record.GoodsIDSnapshot, Title: &title, ShopName: &shop, SalesText: &sales},
Dimensions: []ResultDimension{{Key: "color", Name: "颜色", Values: []string{"黑色"}}, {Key: "size", Name: "尺码", Values: []string{"M"}}},
ColorPrices: []ResultColorPrice{{Color: "黑色", PriceCent: 1000}},
SKUs: []ResultSKU{{Specs: map[string]string{"color": "黑色", "size": "M"}, PriceCent: 1000, Available: true}},
Missing: []string{"reviewCount"},
}
wantLinked, wantMapped := true, true
var unchangedPDD *uint64
switch scenario {
case "completed":
request.Status, request.Missing, request.Product.ReviewCount = models.TaskStatusCompleted, []string{}, &reviews
case "critical_missing":
request.Missing = append(request.Missing, "size")
wantLinked, wantMapped = false, false
case "admin_source", "current_page_source":
source := models.CollectionTaskSourceAdmin
if scenario == "current_page_source" {
source = models.CollectionTaskSourceAgentCurrentPage
}
if err := db.Model(&record).Update("source", source).Error; err != nil {
t.Fatal(err)
}
wantLinked, wantMapped = false, false
case "disabled_pdd":
if err := db.Model(&models.PDDProduct{}).Where("id = ?", *record.PDDProductID).Update("status", "disabled").Error; err != nil {
t.Fatal(err)
}
wantLinked, wantMapped = false, false
case "association_changed":
manual := seedAutoLinkPDD(t, db, "manual-pdd")
unchangedPDD = &manual.ID
if err := db.Model(&shopee).Update("pdd_product_id", manual.ID).Error; err != nil {
t.Fatal(err)
}
wantLinked, wantMapped = false, false
case "snapshot_missing":
if err := db.Model(&record).Update("image_search_snapshot", nil).Error; err != nil {
t.Fatal(err)
}
wantLinked, wantMapped = false, false
case "ai_unavailable":
// No exact match and no configured AI provider: the existing
// matching path must not invent mappings or undo successful collection.
request.Dimensions[0].Values = []string{"白色"}
request.Dimensions[1].Values = []string{"L"}
request.ColorPrices[0].Color = "白色"
request.SKUs[0].Specs = map[string]string{"color": "白色", "size": "L"}
wantMapped = false
}
for attempt := 0; attempt < 2; attempt++ {
ctx := context.Background()
if attempt == 0 {
ctx = requestContext
}
detail, err := service.SubmitResult(ctx, record.ID, request, token)
if scenario == "request_cancelled_during_match" && attempt == 0 {
// Detail still uses the original request context. Preserve that
// existing return behavior, then inspect the committed state
// independently; do not pretend the disconnected client got OK.
if requestContext.Err() != context.Canceled || err == nil {
t.Fatalf("expected cancelled response read, got %v", err)
}
detail, err = service.Detail(context.Background(), record.ID)
}
if err != nil {
t.Fatalf("submit attempt %d: %v", attempt, err)
}
if detail.Task.Status != request.Status || !reflect.DeepEqual(detail.Missing, request.Missing) || detail.Replayed != (attempt == 1) {
t.Fatalf("submission/replay changed result: status=%s missing=%v replayed=%v", detail.Task.Status, detail.Missing, detail.Replayed)
}
if len(detail.SKUs) != 1 || len(detail.Dimensions) != 2 {
t.Fatal("result children missing or duplicated")
}
if err := db.First(&shopee, shopee.ID).Error; err != nil {
t.Fatal(err)
}
if shopee.ImageSearchLinked != wantLinked {
t.Fatalf("image search marker=%v", shopee.ImageSearchLinked)
}
wantPDD := unchangedPDD
if wantLinked {
wantPDD = record.PDDProductID
}
if !reflect.DeepEqual(shopee.PDDProductID, wantPDD) {
t.Fatal("wrong association or concurrent change overwritten")
}
wantCalls := int32(0)
if wantLinked {
wantCalls = 1
}
if syncCalls.Load() != wantCalls {
t.Fatalf("matching hook calls=%d want=%d", syncCalls.Load(), wantCalls)
}
specs, err := shopeeproduct.Unmarshal(shopee.SpecsJSON)
if err != nil {
t.Fatal(err)
}
if len(specs) != 2 {
t.Fatalf("unexpected archive dimensions: %d", len(specs))
}
for _, dimension := range specs {
for _, value := range dimension.Values {
if wantMapped {
if value.Mapping == nil || value.Mapping.Status != shopeeproduct.MappingStatusConfirmed || value.Mapping.PDDValue != value.Name {
t.Fatalf("matching not persisted: %+v", value)
}
} else if value.Mapping != nil {
t.Fatalf("unexpected mapping: %+v", value)
}
}
}
}
if scenario == "optional_partial" {
// A second result for the same image carries the same original
// association snapshot. The first committed link wins; no fallback
// to another result or a second matching pass is introduced.
if err := db.First(&record, record.ID).Error; err != nil {
t.Fatal(err)
}
second := createTask(t, db, &device.ID)
if err := db.Model(&second).Updates(map[string]any{
"source": models.CollectionTaskSourceImageSearch, "image_search_snapshot": record.ImageSearchSnapshot,
"status": models.TaskStatusRunning,
}).Error; err != nil {
t.Fatal(err)
}
request.RequestID, request.Product.PDDGoodsID = uuid.NewString(), second.GoodsIDSnapshot
if _, err := service.SubmitResult(context.Background(), second.ID, request, token); err != nil {
t.Fatal(err)
}
if err := db.First(&shopee, shopee.ID).Error; err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(shopee.PDDProductID, record.PDDProductID) || syncCalls.Load() != 1 {
t.Fatal("competing image-search result replaced association or retriggered matching")
}
}
var purchases int64
if err := db.Model(&models.PurchaseTask{}).Count(&purchases).Error; err != nil {
t.Fatal(err)
}
if purchases != 0 {
t.Fatal("matching must not create a purchase task")
}
})
}
}