Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
644e7642ee | ||
|
|
a0a25298c3 | ||
|
|
c548285d59 | ||
|
|
b0256190a5 | ||
|
|
5f4d69b3ae |
+106
-10
@@ -249,7 +249,16 @@ class PurchaseRehearsalExecutor(
|
||||
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
|
||||
if (stableEvidenceReads >= PRODUCT_PAGE_STABLE_READS) {
|
||||
// A product-page accessibility tree can appear before PDD has
|
||||
// finished wiring click listeners and bottom-sheet transitions.
|
||||
// Give the page one short settle window, then require the
|
||||
// evidence again before moving to the spec entry.
|
||||
pause(PRODUCT_PAGE_SETTLE_MILLIS)
|
||||
val settled = PddScreenParser.parse(driver.capture(), DEFAULT_COLLECTOR, input.goodsId, null)
|
||||
if (settled.hasPurchaseProductEvidence()) return null
|
||||
stableEvidenceReads = 0
|
||||
}
|
||||
pause(OPEN_PRODUCT_POLL_MILLIS)
|
||||
return@repeat
|
||||
}
|
||||
@@ -295,7 +304,10 @@ class PurchaseRehearsalExecutor(
|
||||
if (screen.hasPurchaseProductEvidence()) {
|
||||
stableEvidenceReads++
|
||||
if (stableEvidenceReads >= PRODUCT_PAGE_STABLE_READS) {
|
||||
return recoverSoldOut(input, screen)
|
||||
pause(PRODUCT_PAGE_SETTLE_MILLIS)
|
||||
val settled = PddScreenParser.parse(driver.capture(), DEFAULT_COLLECTOR, input.goodsId, null)
|
||||
if (settled.hasPurchaseProductEvidence()) return recoverSoldOut(input, settled)
|
||||
stableEvidenceReads = 0
|
||||
}
|
||||
} else {
|
||||
stableEvidenceReads = 0
|
||||
@@ -341,6 +353,9 @@ class PurchaseRehearsalExecutor(
|
||||
var screen = currentScreen(input)
|
||||
var entryReadyWaitPolls = 0
|
||||
var target: SnapshotNode? = null
|
||||
var stableTargetPath: String? = null
|
||||
var stableTargetBounds: NodeBounds? = null
|
||||
var stableTargetReads = 0
|
||||
while (target == null) {
|
||||
if (screen.reviewPageOpen) return leaveUnexpectedReviewPage(input)
|
||||
screen.problem?.let { return failure(it.code, it.message) }
|
||||
@@ -359,8 +374,38 @@ class PurchaseRehearsalExecutor(
|
||||
panelDiagnostic(specEntryEvidence(screen, candidates.size, entryReadyWaitPolls))
|
||||
return failure(SPEC_ENTRY_TARGET_AMBIGUOUS, "规格入口候选不唯一 [${specEntryEvidence(screen, candidates.size, entryReadyWaitPolls)}]")
|
||||
}
|
||||
target = candidates.singleOrNull()?.second
|
||||
if (target != null) continue
|
||||
val candidate = candidates.singleOrNull()?.second
|
||||
if (candidate != null) {
|
||||
val unchanged = candidate.path == stableTargetPath && candidate.bounds == stableTargetBounds
|
||||
stableTargetReads = if (unchanged) stableTargetReads + 1 else 1
|
||||
stableTargetPath = candidate.path
|
||||
stableTargetBounds = candidate.bounds
|
||||
if (stableTargetReads >= SPEC_ENTRY_STABLE_READS) {
|
||||
// Reacquire once more immediately before clicking so a node
|
||||
// rebuilt during the settle window is never reused.
|
||||
val latest = currentScreen(input)
|
||||
val latestCandidates = listOfNotNull(
|
||||
latest.specEntry?.let { anchor -> anchor to (latest.specEntryClickTarget ?: anchor) },
|
||||
latest.quickConfirmationEntry?.let { it to it },
|
||||
).distinctBy { it.second.path }
|
||||
.let { candidatesToFilter ->
|
||||
action.textAliases?.let { aliases ->
|
||||
candidatesToFilter.filter { (anchor, _) -> specEntryMatchesAliases(latest, anchor, aliases) }
|
||||
} ?: candidatesToFilter
|
||||
}
|
||||
if (latestCandidates.size > 1) {
|
||||
return failure(SPEC_ENTRY_TARGET_AMBIGUOUS, "规格入口点击目标不唯一 [${specEntryEvidence(latest, latestCandidates.size, entryReadyWaitPolls)}]")
|
||||
}
|
||||
target = latestCandidates.singleOrNull()?.second
|
||||
if (target != null) screen = latest
|
||||
if (target != null) continue
|
||||
stableTargetReads = 0
|
||||
}
|
||||
} else {
|
||||
stableTargetReads = 0
|
||||
stableTargetPath = null
|
||||
stableTargetBounds = null
|
||||
}
|
||||
if (entryReadyWaitPolls >= SPEC_ENTRY_READY_WAIT_POLLS) {
|
||||
panelDiagnostic(specEntryEvidence(screen, 0, entryReadyWaitPolls))
|
||||
return failure(SPEC_ENTRY_NOT_FOUND, "没有找到安全的商品规格入口 [${specEntryEvidence(screen, 0, entryReadyWaitPolls)}]")
|
||||
@@ -385,11 +430,58 @@ class PurchaseRehearsalExecutor(
|
||||
var wait = waitForSpecPanel(input, beforeSignature)
|
||||
wait.failure?.let { return it }
|
||||
if (wait.opened) return null
|
||||
var gestureBaseline = wait.screen
|
||||
if (wait.changed) {
|
||||
return failure(
|
||||
SPEC_PANEL_EVIDENCE_NOT_MATCHED,
|
||||
"规格入口点击后页面已变化,但规格面板强证据不足 [${panelEvidence(wait.screen)}]",
|
||||
)
|
||||
// A page transition can be caused by PDD rerendering the entry before
|
||||
// the bottom sheet becomes observable. Re-locate the fresh entry and
|
||||
// allow exactly one controlled retry while still on the same product.
|
||||
if (!wait.screen.isPddPackage || !wait.screen.pageEvidenceMatched) {
|
||||
return failure(SPEC_PANEL_EVIDENCE_NOT_MATCHED, "规格入口点击后页面已变化,但规格面板强证据不足 [${panelEvidence(wait.screen)}]")
|
||||
}
|
||||
val retryScreen = currentScreen(input)
|
||||
if (!retryScreen.isPddPackage || !retryScreen.pageEvidenceMatched || retryScreen.specPanelOpen) {
|
||||
return failure(SPEC_PANEL_EVIDENCE_NOT_MATCHED, "规格入口点击后页面已变化,但规格面板强证据不足 [${panelEvidence(retryScreen)}]")
|
||||
}
|
||||
val retryCandidates = listOfNotNull(
|
||||
retryScreen.specEntry?.let { anchor -> anchor to (retryScreen.specEntryClickTarget ?: anchor) },
|
||||
retryScreen.quickConfirmationEntry?.let { it to it },
|
||||
).distinctBy { it.second.path }
|
||||
.let { candidatesToFilter ->
|
||||
action.textAliases?.let { aliases ->
|
||||
candidatesToFilter.filter { (anchor, _) -> specEntryMatchesAliases(retryScreen, anchor, aliases) }
|
||||
} ?: candidatesToFilter
|
||||
}
|
||||
if (retryCandidates.size != 1) {
|
||||
return failure(SPEC_PANEL_EVIDENCE_NOT_MATCHED, "规格入口点击后页面已变化,但规格面板强证据不足 [${panelEvidence(retryScreen)}]")
|
||||
}
|
||||
target = retryCandidates.single().second
|
||||
screen = retryScreen
|
||||
gestureBaseline = retryScreen
|
||||
}
|
||||
|
||||
// Give PDD's bottom-sheet re-render a short settle window after an
|
||||
// accessibility click that produced no observable transition, then
|
||||
// reacquire the target before the single controlled gesture retry.
|
||||
if (!wait.changed) {
|
||||
pause(SPEC_ENTRY_GESTURE_RETRY_SETTLE_MILLIS)
|
||||
val refreshed = currentScreen(input)
|
||||
if (!refreshed.isPddPackage || !refreshed.pageEvidenceMatched || refreshed.specPanelOpen) {
|
||||
return failure(SPEC_PANEL_EVIDENCE_NOT_MATCHED, "规格入口点击后页面已变化,但规格面板强证据不足 [${panelEvidence(refreshed)}]")
|
||||
}
|
||||
val refreshedCandidates = listOfNotNull(
|
||||
refreshed.specEntry?.let { anchor -> anchor to (refreshed.specEntryClickTarget ?: anchor) },
|
||||
refreshed.quickConfirmationEntry?.let { it to it },
|
||||
).distinctBy { it.second.path }
|
||||
.let { candidatesToFilter ->
|
||||
action.textAliases?.let { aliases ->
|
||||
candidatesToFilter.filter { (anchor, _) -> specEntryMatchesAliases(refreshed, anchor, aliases) }
|
||||
} ?: candidatesToFilter
|
||||
}
|
||||
if (refreshedCandidates.size != 1) {
|
||||
return failure(SPEC_ENTRY_CLICK_FAILED, "gesture_failed_after_action_click_no_effect")
|
||||
}
|
||||
target = refreshedCandidates.single().second
|
||||
gestureBaseline = refreshed
|
||||
}
|
||||
|
||||
when (driver.tapSpecFresh(requireNotNull(target))) {
|
||||
@@ -403,7 +495,7 @@ class PurchaseRehearsalExecutor(
|
||||
click.reason.specEntrySubreasonAfterGestureFailure(),
|
||||
)
|
||||
}
|
||||
wait = waitForSpecPanel(input, specActionSignature(wait.screen))
|
||||
wait = waitForSpecPanel(input, specActionSignature(gestureBaseline))
|
||||
wait.failure?.let { return it }
|
||||
if (wait.opened) return null
|
||||
if (wait.changed) {
|
||||
@@ -477,6 +569,7 @@ class PurchaseRehearsalExecutor(
|
||||
private fun specEntryEvidence(screen: ParsedPddScreen, candidateCount: Int, entryReadyWaitPolls: Int = 0): String =
|
||||
"specEntryCandidates=$candidateCount;explicit=${screen.explicitSpecEntryCount};" +
|
||||
"nested=${screen.nestedSpecEntryCount};bottomPurchase=${screen.bottomPurchaseEntryCount};" +
|
||||
"source=${screen.specEntrySource ?: "none"};" +
|
||||
"panelAlreadyOpen=${screen.specPanelOpen};reviewPage=${screen.reviewPageOpen};" +
|
||||
"pageEvidence=${screen.pageEvidenceMatched};entryReadyWaitPolls=$entryReadyWaitPolls;" +
|
||||
"entryReadyWaitMillis=${entryReadyWaitPolls * SPEC_ENTRY_READY_POLL_MILLIS}"
|
||||
@@ -1161,11 +1254,14 @@ class PurchaseRehearsalExecutor(
|
||||
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 PRODUCT_PAGE_SETTLE_MILLIS = 1_000L
|
||||
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_STABLE_READS = 2
|
||||
private const val SPEC_ENTRY_GESTURE_RETRY_SETTLE_MILLIS = 500L
|
||||
private const val SPEC_ENTRY_READY_POLL_MILLIS = 100L
|
||||
private const val SPEC_POST_CLICK_VERIFY_POLLS = 30
|
||||
private const val SPEC_POST_CLICK_VERIFY_POLLS = 50
|
||||
private const val SPEC_SELECTION_SUCCESS_VERIFY_POLLS = 20
|
||||
private const val SPEC_SELECTION_FAILED_VERIFY_POLLS = 5
|
||||
private const val SPEC_SELECTION_POLL_MILLIS = 100L
|
||||
|
||||
@@ -73,6 +73,7 @@ data class CurrentPageIdentity(
|
||||
data class PurchaseAgentTask(
|
||||
val taskId: Long,
|
||||
val taskAttemptId: String,
|
||||
val attemptNumber: Int,
|
||||
val phase: String,
|
||||
val executionMode: String,
|
||||
val status: String,
|
||||
@@ -589,6 +590,7 @@ class AgentApiClient(private val serverUrl: String) {
|
||||
private fun purchaseTask(data: JSONObject) = PurchaseAgentTask(
|
||||
taskId = data.getLong("taskId"),
|
||||
taskAttemptId = data.optString("taskAttemptId"),
|
||||
attemptNumber = data.optInt("attemptNumber", 1),
|
||||
phase = data.optString("phase"),
|
||||
executionMode = data.getString("executionMode"),
|
||||
status = data.getString("status"),
|
||||
|
||||
+33
-5
@@ -1,7 +1,7 @@
|
||||
package cn.ilapage.goauto.agent.persistence
|
||||
|
||||
internal object AgentDiagnosticSchema {
|
||||
const val VERSION = 2
|
||||
const val VERSION = 3
|
||||
|
||||
val colorDiagnosticColumns = linkedMapOf(
|
||||
"color_row_count" to "INTEGER",
|
||||
@@ -14,6 +14,15 @@ internal object AgentDiagnosticSchema {
|
||||
"horizontal_swipe_count" to "INTEGER",
|
||||
)
|
||||
|
||||
val purchaseFailureColumns = linkedMapOf(
|
||||
"attempt_id" to "TEXT",
|
||||
"device_id" to "INTEGER",
|
||||
"error_code" to "TEXT",
|
||||
"failure_message" to "TEXT",
|
||||
"page_evidence" to "TEXT",
|
||||
"last_step" to "TEXT",
|
||||
)
|
||||
|
||||
val createTableSql =
|
||||
"""CREATE TABLE agent_diagnostic (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
@@ -42,14 +51,33 @@ internal object AgentDiagnosticSchema {
|
||||
initial_selected_size_count INTEGER,
|
||||
selected_summary_present INTEGER,
|
||||
horizontal_swipe_count INTEGER,
|
||||
attempt_id TEXT,
|
||||
device_id INTEGER,
|
||||
error_code TEXT,
|
||||
failure_message TEXT,
|
||||
page_evidence TEXT,
|
||||
last_step TEXT,
|
||||
agent_version TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL
|
||||
)""".trimIndent()
|
||||
|
||||
fun v2MigrationStatements(oldVersion: Int, newVersion: Int, existingColumns: Set<String>): List<String> {
|
||||
if (oldVersion >= 2 || newVersion < 2) return emptyList()
|
||||
return colorDiagnosticColumns.mapNotNull { (name, definition) ->
|
||||
if (name in existingColumns) null else "ALTER TABLE agent_diagnostic ADD COLUMN $name $definition"
|
||||
fun migrationStatements(oldVersion: Int, newVersion: Int, existingColumns: Set<String>): List<String> {
|
||||
if (oldVersion >= newVersion) return emptyList()
|
||||
val statements = mutableListOf<String>()
|
||||
if (oldVersion < 2 && newVersion >= 2) {
|
||||
colorDiagnosticColumns.forEach { (name, definition) ->
|
||||
if (name !in existingColumns) statements += "ALTER TABLE agent_diagnostic ADD COLUMN $name $definition"
|
||||
}
|
||||
}
|
||||
if (oldVersion < 3 && newVersion >= 3) {
|
||||
purchaseFailureColumns.forEach { (name, definition) ->
|
||||
if (name !in existingColumns) statements += "ALTER TABLE agent_diagnostic ADD COLUMN $name $definition"
|
||||
}
|
||||
}
|
||||
return statements
|
||||
}
|
||||
|
||||
// Kept for existing migration tests and callers; new code should use migrationStatements.
|
||||
fun v2MigrationStatements(oldVersion: Int, newVersion: Int, existingColumns: Set<String>): List<String> =
|
||||
migrationStatements(oldVersion, newVersion, existingColumns)
|
||||
}
|
||||
|
||||
+78
-2
@@ -4,6 +4,8 @@ import android.content.ContentValues
|
||||
import android.content.Context
|
||||
import android.database.sqlite.SQLiteDatabase
|
||||
import android.database.sqlite.SQLiteOpenHelper
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import cn.ilapage.goauto.agent.BuildConfig
|
||||
|
||||
enum class AgentDiagnosticStage {
|
||||
@@ -71,6 +73,7 @@ enum class AgentDiagnosticReason {
|
||||
LINK_AMBIGUOUS,
|
||||
LINK_NOT_FOUND,
|
||||
UNKNOWN,
|
||||
PURCHASE_FAILURE,
|
||||
}
|
||||
|
||||
data class AgentDiagnosticEvent(
|
||||
@@ -99,6 +102,12 @@ data class AgentDiagnosticEvent(
|
||||
val initialSelectedSizeCount: Int? = null,
|
||||
val selectedSummaryPresent: Boolean? = null,
|
||||
val horizontalSwipeCount: Int? = null,
|
||||
val attemptId: String? = null,
|
||||
val deviceId: Long? = null,
|
||||
val errorCode: String? = null,
|
||||
val failureMessage: String? = null,
|
||||
val pageEvidence: String? = null,
|
||||
val lastStep: String? = null,
|
||||
val createdAt: Long = System.currentTimeMillis(),
|
||||
)
|
||||
|
||||
@@ -118,7 +127,7 @@ internal object AgentDiagnosticRetentionPolicy {
|
||||
fun cutoff(createdAt: Long): Long = createdAt - RETENTION_MILLIS
|
||||
}
|
||||
|
||||
class AgentDiagnosticStore(context: Context) : SQLiteOpenHelper(context, DATABASE_NAME, null, DATABASE_VERSION) {
|
||||
class AgentDiagnosticStore(private val context: Context) : SQLiteOpenHelper(context, DATABASE_NAME, null, DATABASE_VERSION) {
|
||||
override fun onCreate(db: SQLiteDatabase) {
|
||||
db.execSQL(AgentDiagnosticSchema.createTableSql)
|
||||
db.execSQL("CREATE INDEX idx_agent_diagnostic_task ON agent_diagnostic(task_id, id)")
|
||||
@@ -126,7 +135,7 @@ class AgentDiagnosticStore(context: Context) : SQLiteOpenHelper(context, DATABAS
|
||||
|
||||
override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) {
|
||||
val existing = columnNames(db)
|
||||
AgentDiagnosticSchema.v2MigrationStatements(oldVersion, newVersion, existing).forEach(db::execSQL)
|
||||
AgentDiagnosticSchema.migrationStatements(oldVersion, newVersion, existing).forEach(db::execSQL)
|
||||
}
|
||||
|
||||
private fun columnNames(db: SQLiteDatabase): Set<String> =
|
||||
@@ -171,6 +180,12 @@ 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)
|
||||
event.attemptId?.let { put("attempt_id", it.take(MAX_TEXT_CHARS)) }
|
||||
putNullableLong("device_id", event.deviceId)
|
||||
event.errorCode?.let { put("error_code", it.take(MAX_ERROR_CODE_CHARS)) }
|
||||
event.failureMessage?.let { put("failure_message", sanitizeText(it)) }
|
||||
event.pageEvidence?.let { put("page_evidence", sanitizeText(it)) }
|
||||
event.lastStep?.let { put("last_step", it.take(MAX_TEXT_CHARS)) }
|
||||
put("agent_version", BuildConfig.VERSION_NAME)
|
||||
put("created_at", event.createdAt)
|
||||
}
|
||||
@@ -186,6 +201,53 @@ class AgentDiagnosticStore(context: Context) : SQLiteOpenHelper(context, DATABAS
|
||||
}
|
||||
}
|
||||
|
||||
/** Returns only the allow-listed, structured fields; raw accessibility data is never exported. */
|
||||
@Synchronized
|
||||
fun exportTaskJson(taskId: Long): String {
|
||||
require(taskId > 0)
|
||||
val events = JSONArray()
|
||||
readableDatabase.query(
|
||||
"agent_diagnostic",
|
||||
arrayOf(
|
||||
"task_id", "stage", "reason", "attempt", "elapsed_ms", "attempt_id", "device_id",
|
||||
"error_code", "failure_message", "page_evidence", "last_step", "agent_version", "created_at",
|
||||
),
|
||||
"task_id = ?",
|
||||
arrayOf(taskId.toString()),
|
||||
null,
|
||||
null,
|
||||
"id ASC",
|
||||
).use { cursor ->
|
||||
while (cursor.moveToNext()) {
|
||||
events.put(JSONObject().apply {
|
||||
put("taskId", cursor.getLong(0))
|
||||
put("stage", cursor.getString(1))
|
||||
put("reason", cursor.getString(2))
|
||||
put("attempt", cursor.getInt(3))
|
||||
put("elapsedMs", cursor.getLong(4))
|
||||
putNullable("attemptId", cursor, 5)
|
||||
if (!cursor.isNull(6)) put("deviceId", cursor.getLong(6))
|
||||
putNullable("errorCode", cursor, 7)
|
||||
putNullable("message", cursor, 8)
|
||||
putNullable("pageEvidence", cursor, 9)
|
||||
putNullable("lastStep", cursor, 10)
|
||||
put("agentVersion", cursor.getString(11))
|
||||
put("createdAt", cursor.getLong(12))
|
||||
})
|
||||
}
|
||||
}
|
||||
return JSONObject().put("taskId", taskId).put("events", events).toString()
|
||||
}
|
||||
|
||||
/** Writes a task export into app-specific external storage so it can be pulled over USB. */
|
||||
@Synchronized
|
||||
fun exportTaskJsonFile(taskId: Long, fileName: String = "task-$taskId.json"): java.io.File {
|
||||
val safeName = fileName.replace(Regex("[^A-Za-z0-9._-]"), "_").take(96)
|
||||
val directory = context.getExternalFilesDir("diagnostics") ?: error("诊断导出目录不可用")
|
||||
if (!directory.exists() && !directory.mkdirs()) error("诊断导出目录创建失败")
|
||||
return java.io.File(directory, safeName).apply { writeText(exportTaskJson(taskId), Charsets.UTF_8) }
|
||||
}
|
||||
|
||||
private fun ContentValues.putNullableBoolean(key: String, value: Boolean?) {
|
||||
value?.let { put(key, if (it) 1 else 0) }
|
||||
}
|
||||
@@ -194,11 +256,25 @@ class AgentDiagnosticStore(context: Context) : SQLiteOpenHelper(context, DATABAS
|
||||
value?.let { put(key, it.coerceAtLeast(0)) }
|
||||
}
|
||||
|
||||
private fun ContentValues.putNullableLong(key: String, value: Long?) {
|
||||
value?.let { put(key, it) }
|
||||
}
|
||||
|
||||
private fun JSONObject.putNullable(key: String, cursor: android.database.Cursor, index: Int) {
|
||||
if (!cursor.isNull(index)) put(key, cursor.getString(index))
|
||||
}
|
||||
|
||||
private fun sanitizeText(value: String): String = value
|
||||
.replace(Regex("(?i)(addressSuffix|收货地址|详细地址)\\s*[:=:][^;,,\\]]+"), "[REDACTED]")
|
||||
.take(MAX_TEXT_CHARS)
|
||||
|
||||
companion object {
|
||||
private const val DATABASE_NAME = "goauto_diagnostics.db"
|
||||
private const val DATABASE_VERSION = AgentDiagnosticSchema.VERSION
|
||||
private const val MAX_CLASS_NAME_CHARS = 160
|
||||
private const val MAX_ROW_VALUE_COUNTS_CHARS = 160
|
||||
private const val MAX_TEXT_CHARS = 512
|
||||
private const val MAX_ERROR_CODE_CHARS = 96
|
||||
private val ROW_VALUE_COUNTS_PATTERN = Regex("[0-9]+(?:,[0-9]+)*")
|
||||
private val ALLOWED_ZONES = setOf("top-left", "top-center", "top-right", "middle", "bottom")
|
||||
}
|
||||
|
||||
+67
-1
@@ -445,7 +445,10 @@ class AgentForegroundService : Service() {
|
||||
GoAutoAccessibilityService.instance?.dismissPurchaseResultBubble()
|
||||
acquireTaskWakeLock()
|
||||
var resultSafelyStored = false
|
||||
var diagnosticRecorded = false
|
||||
var activeTask = initial
|
||||
val lastStep = AtomicReference("started")
|
||||
val lastPanelEvidence = AtomicReference<String?>(null)
|
||||
try {
|
||||
val claimed = if (initial.status == "pending") {
|
||||
api.claimPurchaseTask(initial.taskId, UUID.randomUUID().toString(), token)
|
||||
@@ -453,6 +456,7 @@ class AgentForegroundService : Service() {
|
||||
val task = if (claimed.status == "pending") {
|
||||
api.startPurchaseTask(claimed.taskId, UUID.randomUUID().toString(), token)
|
||||
} else claimed
|
||||
activeTask = task
|
||||
check(task.status == "running" && task.taskAttemptId.isNotBlank()) { "采购任务没有有效 attempt" }
|
||||
val snapshotHashValid = task.ruleSnapshotHash.matches(Regex("^[0-9a-f]{64}$"))
|
||||
val snapshotHash = task.ruleSnapshotHash.takeIf { snapshotHashValid } ?: "0".repeat(64)
|
||||
@@ -486,7 +490,10 @@ class AgentForegroundService : Service() {
|
||||
lastStep.set(step)
|
||||
purchaseStore.updateStep(task.taskId, task.taskAttemptId, step)
|
||||
},
|
||||
panelDiagnostic = { evidence -> Log.i("GoAutoPurchasePanel", "task=${task.taskId};$evidence") },
|
||||
panelDiagnostic = { evidence ->
|
||||
lastPanelEvidence.set(evidence)
|
||||
Log.i("GoAutoPurchasePanel", "task=${task.taskId};$evidence")
|
||||
},
|
||||
beforeOrderSubmit = { evidence ->
|
||||
val boundaryRequestId = UUID.randomUUID().toString()
|
||||
val finalEvidence = JSONObject()
|
||||
@@ -526,6 +533,10 @@ class AgentForegroundService : Service() {
|
||||
val payload = purchaseResultPayload(requestId, task.taskAttemptId, outcome)
|
||||
purchaseStore.completeAndEnqueue(task.taskId, task.taskAttemptId, requestId, payload)
|
||||
resultSafelyStored = true
|
||||
if (outcome.resultType == "failed") {
|
||||
recordPurchaseDiagnostic(task, outcome, lastStep.get(), lastPanelEvidence.get())
|
||||
diagnosticRecorded = true
|
||||
}
|
||||
PurchaseResultBubblePolicy.create(
|
||||
taskId = task.taskId,
|
||||
resultType = outcome.resultType,
|
||||
@@ -540,8 +551,24 @@ class AgentForegroundService : Service() {
|
||||
stateStore.update(if (outcome.resultType == "failed") "TASK_ERROR" else "ONLINE", message, tokenStored = true)
|
||||
updateNotification(if (outcome.resultType == "failed") "$taskLabel #${task.taskId} 失败" else "$taskLabel #${task.taskId} 已提交")
|
||||
} catch (error: AgentApiException) {
|
||||
if (!diagnosticRecorded) {
|
||||
recordPurchaseDiagnostic(
|
||||
activeTask,
|
||||
PurchaseExecutionOutcome("failed", error.code, error.message ?: "采购接口调用失败"),
|
||||
lastStep.get(),
|
||||
lastPanelEvidence.get(),
|
||||
)
|
||||
}
|
||||
stateStore.update("TASK_ERROR", "${error.code}:${error.message}", tokenStored = true)
|
||||
} catch (error: Exception) {
|
||||
if (!diagnosticRecorded) {
|
||||
recordPurchaseDiagnostic(
|
||||
activeTask,
|
||||
PurchaseExecutionOutcome("failed", "AGENT_PURCHASE_EXCEPTION", error.message ?: "采购执行异常"),
|
||||
lastStep.get(),
|
||||
lastPanelEvidence.get(),
|
||||
)
|
||||
}
|
||||
stateStore.update("TASK_ERROR", error.message ?: "采购演练执行异常", tokenStored = true)
|
||||
} finally {
|
||||
if (!resultSafelyStored) cancelIdleReturn("采购结果未安全保存")
|
||||
@@ -549,6 +576,45 @@ class AgentForegroundService : Service() {
|
||||
}
|
||||
}
|
||||
|
||||
private fun recordPurchaseDiagnostic(
|
||||
task: PurchaseAgentTask,
|
||||
outcome: PurchaseExecutionOutcome,
|
||||
lastStep: String,
|
||||
panelEvidence: String?,
|
||||
) {
|
||||
if (task.taskId <= 0L) return
|
||||
val deviceId = runCatching { identityStore.credentials()?.deviceId }.getOrNull()
|
||||
val event = AgentDiagnosticEvent(
|
||||
taskId = task.taskId,
|
||||
stage = purchaseDiagnosticStage(lastStep),
|
||||
reason = AgentDiagnosticReason.PURCHASE_FAILURE,
|
||||
attempt = task.attemptNumber,
|
||||
attemptId = task.taskAttemptId.takeIf { it.isNotBlank() },
|
||||
deviceId = deviceId,
|
||||
errorCode = outcome.errorCode,
|
||||
failureMessage = outcome.message,
|
||||
pageEvidence = panelEvidence,
|
||||
lastStep = lastStep,
|
||||
)
|
||||
diagnosticExecutor.execute {
|
||||
runCatching {
|
||||
diagnosticStore.record(event)
|
||||
val export = diagnosticStore.exportTaskJsonFile(task.taskId)
|
||||
Log.i("GoAutoDiagnostic", "purchase diagnostic exported task=${task.taskId};file=${export.absolutePath}")
|
||||
}.onFailure { error ->
|
||||
Log.w("GoAutoDiagnostic", "purchase diagnostic export failed: ${error.javaClass.simpleName}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun purchaseDiagnosticStage(step: String): AgentDiagnosticStage = when (step) {
|
||||
"openProduct" -> AgentDiagnosticStage.DETAIL_ENTRY
|
||||
"openSpecPanel" -> AgentDiagnosticStage.SPEC_PANEL_ENTRY
|
||||
"selectSpec" -> AgentDiagnosticStage.SIZE_DISCOVERY
|
||||
"verifyUnitPrice", "verifyOrderSummary" -> AgentDiagnosticStage.PAGE_STABILITY
|
||||
else -> AgentDiagnosticStage.DETAIL_ENTRY
|
||||
}
|
||||
|
||||
private fun collectPurchaseProbe(accessibility: GoAutoAccessibilityService, task: PurchaseAgentTask, purchaseRule: PurchaseRule): String? {
|
||||
val snapshot = accessibility.capture()
|
||||
val activity = snapshot.activityName ?: return null
|
||||
|
||||
@@ -788,6 +788,21 @@ class PurchaseRehearsalExecutorTest {
|
||||
assertFalse(outcome.message.orEmpty().contains("确认款式"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `changed product page retries a still visible spec entry once`() {
|
||||
val driver = FakePurchaseDriver(
|
||||
entryActionHasEffect = false,
|
||||
entryActionChangesPageWithoutPanel = true,
|
||||
specTapResult = FreshActionResult.SUCCESS,
|
||||
)
|
||||
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
|
||||
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
|
||||
|
||||
assertEquals(outcome.toString(), "rehearsal_completed", outcome.resultType)
|
||||
assertEquals(1, driver.openEntryClickCount)
|
||||
assertEquals(1, driver.specTapCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unchanged spec entry action uses one verified center gesture`() {
|
||||
val driver = FakePurchaseDriver(
|
||||
@@ -846,7 +861,7 @@ class PurchaseRehearsalExecutorTest {
|
||||
|
||||
assertEquals("PURCHASE_SPEC_ENTRY_NOT_FOUND", outcome.errorCode)
|
||||
assertEquals(
|
||||
"specEntryCandidates=0;explicit=0;nested=0;bottomPurchase=0;panelAlreadyOpen=false;reviewPage=false;pageEvidence=true;entryReadyWaitPolls=20;entryReadyWaitMillis=2000",
|
||||
"specEntryCandidates=0;explicit=0;nested=0;bottomPurchase=0;source=none;panelAlreadyOpen=false;reviewPage=false;pageEvidence=true;entryReadyWaitPolls=20;entryReadyWaitMillis=2000",
|
||||
diagnostics.single(),
|
||||
)
|
||||
assertEquals(21, pauses.count { it == 100L })
|
||||
@@ -864,7 +879,7 @@ class PurchaseRehearsalExecutorTest {
|
||||
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 })
|
||||
assertEquals(4, pauses.count { it == 100L })
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -877,7 +892,7 @@ class PurchaseRehearsalExecutorTest {
|
||||
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 })
|
||||
assertEquals(4, pauses.count { it == 100L })
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -1042,7 +1057,7 @@ class PurchaseRehearsalExecutorTest {
|
||||
|
||||
assertEquals("PURCHASE_SPEC_ENTRY_NOT_FOUND", outcome.errorCode)
|
||||
assertEquals(
|
||||
"没有找到安全的商品规格入口 [specEntryCandidates=0;explicit=0;nested=0;bottomPurchase=0;panelAlreadyOpen=false;reviewPage=false;pageEvidence=true;entryReadyWaitPolls=20;entryReadyWaitMillis=2000]",
|
||||
"没有找到安全的商品规格入口 [specEntryCandidates=0;explicit=0;nested=0;bottomPurchase=0;source=none;panelAlreadyOpen=false;reviewPage=false;pageEvidence=true;entryReadyWaitPolls=20;entryReadyWaitMillis=2000]",
|
||||
outcome.message,
|
||||
)
|
||||
|
||||
@@ -1052,7 +1067,7 @@ class PurchaseRehearsalExecutorTest {
|
||||
|
||||
assertEquals("PURCHASE_SPEC_ENTRY_TARGET_AMBIGUOUS", ambiguous.errorCode)
|
||||
assertEquals(
|
||||
"规格入口点击目标不唯一 [specEntryCandidates=1;explicit=1;nested=0;bottomPurchase=0;panelAlreadyOpen=false;reviewPage=false;pageEvidence=true;entryReadyWaitPolls=0;entryReadyWaitMillis=0]",
|
||||
"规格入口点击目标不唯一 [specEntryCandidates=1;explicit=1;nested=0;bottomPurchase=0;source=explicit_selection;panelAlreadyOpen=false;reviewPage=false;pageEvidence=true;entryReadyWaitPolls=1;entryReadyWaitMillis=100]",
|
||||
ambiguous.message,
|
||||
)
|
||||
}
|
||||
@@ -1227,6 +1242,7 @@ class PurchaseRehearsalExecutorTest {
|
||||
private val forcedEntryClickReason: FreshClickReason? = null,
|
||||
private val initialSize: String? = null,
|
||||
private val entryActionHasEffect: Boolean = true,
|
||||
private val entryActionChangesPageWithoutPanel: Boolean = false,
|
||||
private val specTapResult: FreshActionResult = FreshActionResult.FAILED,
|
||||
private val specTapHasEffect: Boolean = true,
|
||||
private val sizeSelectsOnFailedClick: Boolean = false,
|
||||
@@ -1274,6 +1290,7 @@ class PurchaseRehearsalExecutorTest {
|
||||
private var reviewPage = false
|
||||
private var pddCaptureCount = 0
|
||||
private var browserCaptureCount = 0
|
||||
private var entryActionChanged = false
|
||||
private var hiddenColorRestored = false
|
||||
private var horizontalColorPage = 0
|
||||
private var horizontalSizePage = 0
|
||||
@@ -1347,6 +1364,7 @@ class PurchaseRehearsalExecutorTest {
|
||||
} else if (!missingSpecEntry && specEntryReady) {
|
||||
nodes += node("spec", "选择规格", 20, 1000, 900, 1100, clickable = true)
|
||||
}
|
||||
if (entryActionChanged) nodes += node("rerender", "页面已刷新", 20, 1100, 400, 1180)
|
||||
if (includeReviewEntry) nodes += node("review", "商品评价", 20, 1200, 900, 1300, clickable = true)
|
||||
return UiSnapshot(PDD, ACTIVITY, nodes)
|
||||
}
|
||||
@@ -1463,7 +1481,10 @@ class PurchaseRehearsalExecutorTest {
|
||||
if (result == FreshActionResult.SUCCESS || openPddOnFailedClick) browser = false
|
||||
return result
|
||||
}
|
||||
"选择规格", "免拼购买" -> if (entryActionHasEffect) panel = true
|
||||
"选择规格", "免拼购买" -> {
|
||||
if (entryActionChangesPageWithoutPanel) entryActionChanged = true
|
||||
if (entryActionHasEffect) panel = true
|
||||
}
|
||||
in (horizontalSizePages.orEmpty().flatten() + sizes) -> {
|
||||
sizeClickCount++
|
||||
val result = sizeClickResults.removeFirstOrNull() ?: FreshActionResult.SUCCESS
|
||||
@@ -1520,6 +1541,9 @@ class PurchaseRehearsalExecutorTest {
|
||||
return FreshActionResult.SUCCESS
|
||||
}
|
||||
|
||||
val openEntryClickCount: Int
|
||||
get() = clicked.count { it == "选择规格" || it == "免拼购买" }
|
||||
|
||||
override fun inputFresh(target: SnapshotNode, value: String): FreshActionResult {
|
||||
quantity = value.toLong()
|
||||
return FreshActionResult.SUCCESS
|
||||
|
||||
+18
@@ -83,6 +83,24 @@ class AgentDiagnosticStoreMigrationTest {
|
||||
assertEquals(1, rowCount(db))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun v2MigrationAddsPurchaseFailureColumnsWithoutDroppingRows() = withDatabase { db ->
|
||||
db.createStatement().use { statement ->
|
||||
statement.execute(CREATE_V1_TABLE_SQL)
|
||||
statement.execute(
|
||||
"INSERT INTO agent_diagnostic " +
|
||||
"(task_id, stage, reason, attempt, elapsed_ms, agent_version, created_at) " +
|
||||
"VALUES (153, 'SPEC_PANEL_ENTRY', 'PURCHASE_FAILURE', 2, 120, '0.9.99', 1000)",
|
||||
)
|
||||
}
|
||||
AgentDiagnosticSchema.migrationStatements(1, 3, columnNames(db)).forEach { sql ->
|
||||
db.createStatement().use { it.execute(sql) }
|
||||
}
|
||||
assertTrue(columnNames(db).containsAll(AgentDiagnosticSchema.purchaseFailureColumns.keys))
|
||||
assertEquals(1, rowCount(db))
|
||||
assertTrue(AgentDiagnosticSchema.migrationStatements(3, 3, columnNames(db)).isEmpty())
|
||||
}
|
||||
|
||||
private fun migrateV1ToV2(db: Connection) {
|
||||
AgentDiagnosticSchema.v2MigrationStatements(1, 2, columnNames(db)).forEach { sql ->
|
||||
db.createStatement().use { it.execute(sql) }
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: SYB-ERP-Interface-Contract
|
||||
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/SYB-ERP-Interface-Contract.-
|
||||
wiki_revision: ad9adc22e69e48c9a8fd87d7121066eecca55fd6
|
||||
synchronized_at: 2026-09-04T11:30:50Z
|
||||
wiki_revision: df0fe874c7f3f3e9c031f2f793f8f6a0511ace25
|
||||
synchronized_at: 2026-09-07T06:25:27Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 12 顺云宝(SYB)ERP 接口契约
|
||||
@@ -228,6 +228,14 @@ Admin 默认 `max_matches = 10000`,可以在配置中调整;上限针对整
|
||||
读取完整明细并按既有 upsert 保存,但本次同步仍记为失败、明确提示当天未形成
|
||||
稳定快照且不推进游标,下一次继续覆盖今天。任何尝试都不得突破 `max_matches`;
|
||||
网络/业务错误、非法 ID 或不完整明细不属于可放宽的快照漂移。
|
||||
`[必须,#235]` 当天跨页重复 ID 纳入上述最多 3 次列表快照尝试(含首次),
|
||||
不增加另一层重试次数。发现跨页重复后丢弃本次列表,从预检总数和第一页重新开始,
|
||||
使用全新 ID 集合;最后一次仍重复时直接失败,不得去重后按成功或降级数据保存。
|
||||
已经完成的历史日期保持其已有结果,不重复拉取;历史日期重复不适用此恢复。
|
||||
每页先检查非法 ID 和页内重复,再检查跨页重叠;同页同时存在页内重复和跨页重叠时
|
||||
仍作为硬错误停止。原有总数漂移/短页的合法唯一列表降级保存条件保持不变。
|
||||
重复诊断只记录日期、当天尝试序号、首次/当前页码与行号、start、pageSize、
|
||||
expectedTotal 和已获取唯一数量,不记录真实重复 ID、原始响应或个人数据。
|
||||
|
||||
### 4.4 统一日期范围同步与覆盖游标
|
||||
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
package sybimport
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func freezePaginationToday(t *testing.T) {
|
||||
t.Helper()
|
||||
previous := syncNow
|
||||
syncNow = func() time.Time { return time.Date(2026, 8, 29, 12, 0, 0, 0, time.FixedZone("Asia/Shanghai", 8*60*60)) }
|
||||
t.Cleanup(func() { syncNow = previous })
|
||||
}
|
||||
|
||||
func TestTodayCrossPageOverlapRestartsWithIndependentIDs(t *testing.T) {
|
||||
freezePaginationToday(t)
|
||||
f := &fakeSYB{perDay: map[string]int{"2026-08-29": 4}}
|
||||
f.pageIDs = func(_ string, start, call int) []int64 {
|
||||
if call == 2 {
|
||||
return []int64{1001, 1002}
|
||||
}
|
||||
if call > 2 {
|
||||
return []int64{int64(2000 + start), int64(2001 + start)}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
rows, err := loadDailyListWithRecovery(context.Background(), newSyncClient(t, f), "2026-08-29", 2, 4, 100)
|
||||
if err != nil || len(rows) != 4 || f.listTotalCalls != 2 {
|
||||
t.Fatalf("rows=%d totals=%d err=%v", len(rows), f.listTotalCalls, err)
|
||||
}
|
||||
if got := strings.Join(f.listCalls, ","); got != "2026-08-29:0,2026-08-29:2,2026-08-29:0,2026-08-29:2" {
|
||||
t.Fatalf("did not restart at first page: %s", got)
|
||||
}
|
||||
for index, row := range rows {
|
||||
if row.ID != int64(2000+index) {
|
||||
t.Fatal("rows leaked from abandoned attempt")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTodayPersistentOverlapPreservesYesterdayButDoesNotImportToday(t *testing.T) {
|
||||
freezePaginationToday(t)
|
||||
f := &fakeSYB{perDay: map[string]int{"2026-08-28": 2, "2026-08-29": 4}}
|
||||
f.pageIDs = func(date string, start, _ int) []int64 {
|
||||
if date == "2026-08-29" && start == 2 {
|
||||
return []int64{1001, 1002}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
db := newSyncTestDB(t)
|
||||
report, err := Sync(context.Background(), db, newSyncClient(t, f), SyncConfig{PageSize: 2, MaxMatches: 100}, "2026-08-28", "2026-08-29")
|
||||
if err == nil {
|
||||
t.Fatal("overlap was treated as successful sync")
|
||||
}
|
||||
for _, token := range []string{"连续 3 次", "跨页重复", "firstPage=1", "firstRow=2", "page=2", "row=1", "start=2", "pageSize=2", "expectedTotal=4", "unique=2"} {
|
||||
if !strings.Contains(err.Error(), token) {
|
||||
t.Fatalf("missing %s in %v", token, err)
|
||||
}
|
||||
}
|
||||
if strings.Contains(err.Error(), "1001") || strings.Contains(err.Error(), "已保存") {
|
||||
t.Fatalf("unsafe diagnosis/degraded save: %v", err)
|
||||
}
|
||||
if len(f.listCalls) != 7 || f.detailCalls != 1 || report.OrderCount != 2 {
|
||||
t.Fatalf("unexpected retry/import boundary: pages=%d details=%d orders=%d", len(f.listCalls), f.detailCalls, report.OrderCount)
|
||||
}
|
||||
var count int64
|
||||
if e := db.Table("syb_product").Count(&count).Error; e != nil || count != 2 {
|
||||
t.Fatalf("yesterday not preserved: count=%d err=%v", count, e)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPaginationHardErrorsDoNotUseOverlapRecovery(t *testing.T) {
|
||||
freezePaginationToday(t)
|
||||
for _, tc := range []struct {
|
||||
name, date, message string
|
||||
page int
|
||||
ids []int64
|
||||
calls int
|
||||
}{
|
||||
{"same page", "2026-08-29", "同页重复", 0, []int64{1000, 1000}, 1},
|
||||
{"mixed overlap and same page", "2026-08-29", "同页重复", 2, []int64{1001, 1001}, 2},
|
||||
{"overlap and invalid ID", "2026-08-29", "非法 id", 2, []int64{1001, 0}, 2},
|
||||
{"historical overlap", "2026-08-28", "跨页重复", 2, []int64{1001, 1002}, 2},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
f := &fakeSYB{perDay: map[string]int{tc.date: 4}}
|
||||
f.pageIDs = func(_ string, start, _ int) []int64 {
|
||||
if start == tc.page {
|
||||
return tc.ids
|
||||
}
|
||||
return nil
|
||||
}
|
||||
rows, err := loadDailyListWithRecovery(context.Background(), newSyncClient(t, f), tc.date, 2, 4, 100)
|
||||
if rows != nil || err == nil || !strings.Contains(err.Error(), tc.message) || len(f.listCalls) != tc.calls || f.listTotalCalls != 0 {
|
||||
t.Fatalf("unexpected recovery: rows=%d pages=%d totals=%d err=%v", len(rows), len(f.listCalls), f.listTotalCalls, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -358,7 +358,7 @@ func loadDailyListWithRecovery(ctx context.Context, client *sybclient.Client, da
|
||||
}
|
||||
var drift *snapshotDriftError
|
||||
if !errors.As(err, &drift) {
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("今天第 %d/%d 次拉取失败: %w", attempt, maxTodaySnapshotAttempts, err)
|
||||
}
|
||||
last = drift
|
||||
}
|
||||
@@ -370,7 +370,8 @@ func loadDailyListWithRecovery(ctx context.Context, client *sybclient.Client, da
|
||||
|
||||
func loadDailyList(ctx context.Context, client *sybclient.Client, date string, pageSize, expectedTotal int) ([]sybclient.StockRow, error) {
|
||||
rows := make([]sybclient.StockRow, 0, expectedTotal)
|
||||
seen := make(map[int64]struct{}, expectedTotal)
|
||||
type position struct{ page, row int }
|
||||
seen := make(map[int64]position, expectedTotal)
|
||||
|
||||
for start := 0; start < expectedTotal; start += pageSize {
|
||||
pageIndex := start/pageSize + 1
|
||||
@@ -386,14 +387,27 @@ func loadDailyList(ctx context.Context, client *sybclient.Client, date string, p
|
||||
if pageCount != len(page) {
|
||||
return nil, fmt.Errorf("%s 货运单列表第 %d 页响应条数不自洽:total=%d,list=%d", date, pageIndex, pageCount, len(page))
|
||||
}
|
||||
for _, row := range page {
|
||||
// Validate the whole page first: a same-page duplicate must not be
|
||||
// hidden behind an earlier, recoverable cross-page overlap.
|
||||
pageSeen := make(map[int64]int, len(page))
|
||||
for index, row := range page {
|
||||
if row.ID <= 0 {
|
||||
return nil, fmt.Errorf("%s 货运单列表包含非法 id=%d", date, row.ID)
|
||||
}
|
||||
if _, duplicate := seen[row.ID]; duplicate {
|
||||
return nil, fmt.Errorf("%s 货运单列表重复返回 id=%d", date, row.ID)
|
||||
if firstRow, duplicate := pageSeen[row.ID]; duplicate {
|
||||
return nil, fmt.Errorf("%s 货运单列表同页重复 [firstPage=%d,firstRow=%d,page=%d,row=%d,start=%d,pageSize=%d,expectedTotal=%d,unique=%d]",
|
||||
date, pageIndex, firstRow, pageIndex, index+1, start, pageSize, expectedTotal, len(rows))
|
||||
}
|
||||
seen[row.ID] = struct{}{}
|
||||
pageSeen[row.ID] = index + 1
|
||||
}
|
||||
for index, row := range page {
|
||||
if first, duplicate := seen[row.ID]; duplicate {
|
||||
// No rows/valid flag: overlapping pages are never eligible for
|
||||
// the existing final-attempt degraded save, even after deduping.
|
||||
return nil, &snapshotDriftError{message: fmt.Sprintf("%s 货运单列表跨页重复 [firstPage=%d,firstRow=%d,page=%d,row=%d,start=%d,pageSize=%d,expectedTotal=%d,unique=%d]",
|
||||
date, first.page, first.row, pageIndex, index+1, start, pageSize, expectedTotal, len(rows))}
|
||||
}
|
||||
seen[row.ID] = position{page: pageIndex, row: index + 1}
|
||||
rows = append(rows, row)
|
||||
}
|
||||
if len(page) != expectedPageCount {
|
||||
|
||||
@@ -60,6 +60,9 @@ type fakeSYB struct {
|
||||
totalOverride map[int]int
|
||||
shortPageAtIndex int
|
||||
detailDropID int64
|
||||
listCalls []string
|
||||
pageIDs func(date string, start, call int) []int64
|
||||
detailCalls int
|
||||
// shopNames 按货运单序号轮换;留空表示全部用「测试店铺」。
|
||||
shopNames []string
|
||||
// detailShopName 非空时,明细响应里的 shopName 用它覆盖,
|
||||
@@ -96,6 +99,7 @@ func (f *fakeSYB) server(t *testing.T) *httptest.Server {
|
||||
start := int(body["start"].(float64))
|
||||
pageSize := int(body["length"].(float64))
|
||||
total := f.perDay[date]
|
||||
f.listCalls = append(f.listCalls, fmt.Sprintf("%s:%d", date, start))
|
||||
|
||||
rows := []map[string]any{}
|
||||
for i := start; i < total && len(rows) < pageSize; i++ {
|
||||
@@ -108,9 +112,18 @@ func (f *fakeSYB) server(t *testing.T) *httptest.Server {
|
||||
if f.shortPageAtIndex > 0 && start/pageSize+1 == f.shortPageAtIndex && len(rows) > 0 {
|
||||
rows = rows[:len(rows)-1]
|
||||
}
|
||||
if f.pageIDs != nil {
|
||||
if ids := f.pageIDs(date, start, len(f.listCalls)); ids != nil {
|
||||
rows = nil
|
||||
for _, id := range ids {
|
||||
rows = append(rows, map[string]any{"id": id, "code": "TEST", "shopName": f.shopFor(0)})
|
||||
}
|
||||
}
|
||||
}
|
||||
writeEnvelope(w, map[string]any{"list": rows, "total": len(rows)})
|
||||
})
|
||||
mux.HandleFunc("/am/stock/detail/listByStock", func(w http.ResponseWriter, r *http.Request) {
|
||||
f.detailCalls++
|
||||
var body struct {
|
||||
IDs []int64 `json:"ids"`
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user