Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4a669bca86 | ||
|
|
309dce3627 | ||
|
|
8b75a95d67 |
+16
-1
@@ -1,15 +1,30 @@
|
||||
package cn.ilapage.goauto.agent.persistence
|
||||
|
||||
import cn.ilapage.goauto.agent.network.AgentApiException
|
||||
|
||||
object PurchaseRejectionPolicy {
|
||||
val codes = setOf("PURCHASE_STATE_CONFLICT", "PURCHASE_LEASE_EXPIRED", "PURCHASE_RESULT_CONFLICT")
|
||||
fun terminalCode(error: Exception): String? = (error as? AgentApiException)
|
||||
?.takeIf { it.status == 409 && it.code in codes }?.code
|
||||
}
|
||||
|
||||
/** Uploads already-persisted results only; it never invokes device automation. */
|
||||
class PurchaseOutboxUploader(
|
||||
private val pending: () -> List<PendingPurchaseOutbox>,
|
||||
private val submit: (PendingPurchaseOutbox) -> Unit,
|
||||
private val markUploaded: (PendingPurchaseOutbox) -> Unit,
|
||||
private val afterUploaded: (PendingPurchaseOutbox) -> Unit = {},
|
||||
private val markRejected: (PendingPurchaseOutbox, String) -> Unit = { _, _ -> error("Rejection persistence not configured") },
|
||||
) {
|
||||
fun flush() {
|
||||
pending().forEach { item ->
|
||||
submit(item)
|
||||
try {
|
||||
submit(item)
|
||||
} catch (error: Exception) {
|
||||
val code = PurchaseRejectionPolicy.terminalCode(error) ?: throw error
|
||||
markRejected(item, code)
|
||||
return@forEach
|
||||
}
|
||||
markUploaded(item)
|
||||
afterUploaded(item)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
package cn.ilapage.goauto.agent.persistence
|
||||
|
||||
data class RejectedPurchaseResult(
|
||||
val id: Long, val taskId: Long, val attemptId: String, val payloadJson: String,
|
||||
val rejectedAt: Long, val errorCode: String, val acknowledgedAt: Long?,
|
||||
)
|
||||
|
||||
internal object PurchaseRejectionSql {
|
||||
val migration = listOf(
|
||||
"ALTER TABLE purchase_outbox ADD COLUMN rejected_at INTEGER",
|
||||
"ALTER TABLE purchase_outbox ADD COLUMN rejection_error_code TEXT",
|
||||
"ALTER TABLE purchase_outbox ADD COLUMN acknowledged_at INTEGER",
|
||||
)
|
||||
const val activeTask = "SELECT task_id FROM purchase_task WHERE upload_status != 'rejected' AND (status IN ('running','order_submit_started') OR upload_status='pending') ORDER BY updated_at ASC LIMIT 1"
|
||||
const val acknowledge = "UPDATE purchase_outbox SET acknowledged_at=? WHERE id=? AND upload_status='rejected' AND acknowledged_at IS NULL"
|
||||
|
||||
/** The caller must wrap both writes in one transaction. Zero task rows means a newer attempt replaced it. */
|
||||
fun reject(item: PendingPurchaseOutbox, code: String, now: Long, update: (String, List<Any>) -> Int) {
|
||||
require(code in PurchaseRejectionPolicy.codes)
|
||||
check(update(
|
||||
"UPDATE purchase_outbox SET upload_status='rejected',rejected_at=?,rejection_error_code=?,updated_at=? WHERE id=? AND task_id=? AND attempt_id=? AND upload_status='pending'",
|
||||
listOf(now, code, now, item.id, item.taskId, item.attemptId),
|
||||
) == 1)
|
||||
update("UPDATE purchase_task SET upload_status='rejected',updated_at=? WHERE task_id=? AND attempt_id=?",
|
||||
listOf(now, item.taskId, item.attemptId))
|
||||
}
|
||||
}
|
||||
@@ -66,6 +66,7 @@ class PurchaseTaskStore(context: Context) : SQLiteOpenHelper(context, DATABASE_N
|
||||
)
|
||||
db.execSQL("CREATE INDEX idx_purchase_outbox_pending ON purchase_outbox(upload_status, id)")
|
||||
db.execSQL("CREATE INDEX idx_purchase_attempt_task ON purchase_attempt(task_id, created_at)")
|
||||
PurchaseRejectionSql.migration.forEach(db::execSQL)
|
||||
}
|
||||
|
||||
override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) {
|
||||
@@ -74,6 +75,7 @@ class PurchaseTaskStore(context: Context) : SQLiteOpenHelper(context, DATABASE_N
|
||||
db.execSQL("ALTER TABLE purchase_task ADD COLUMN final_confirmation_json TEXT")
|
||||
db.execSQL("ALTER TABLE purchase_task ADD COLUMN irreversible_at INTEGER")
|
||||
}
|
||||
if (oldVersion < 3 && newVersion >= 3) PurchaseRejectionSql.migration.forEach(db::execSQL)
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
@@ -225,8 +227,8 @@ class PurchaseTaskStore(context: Context) : SQLiteOpenHelper(context, DATABASE_N
|
||||
|
||||
@Synchronized
|
||||
fun activeTaskId(): Long? = readableDatabase.rawQuery(
|
||||
"SELECT task_id FROM purchase_task WHERE status IN (?,?) OR upload_status=? ORDER BY updated_at ASC LIMIT 1",
|
||||
arrayOf(STATUS_RUNNING, STATUS_ORDER_SUBMIT_STARTED, UPLOAD_PENDING),
|
||||
PurchaseRejectionSql.activeTask,
|
||||
null,
|
||||
).use { cursor -> if (cursor.moveToFirst()) cursor.getLong(0) else null }
|
||||
|
||||
@Synchronized
|
||||
@@ -243,9 +245,53 @@ class PurchaseTaskStore(context: Context) : SQLiteOpenHelper(context, DATABASE_N
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun markRejected(item: PendingPurchaseOutbox, code: String) {
|
||||
val db = writableDatabase
|
||||
db.beginTransaction()
|
||||
try {
|
||||
PurchaseRejectionSql.reject(item, code, System.currentTimeMillis()) { sql, args ->
|
||||
db.compileStatement(sql).use { statement ->
|
||||
args.forEachIndexed { index, value ->
|
||||
if (value is Long) statement.bindLong(index + 1, value)
|
||||
else statement.bindString(index + 1, value.toString())
|
||||
}
|
||||
statement.executeUpdateDelete()
|
||||
}
|
||||
}
|
||||
db.setTransactionSuccessful()
|
||||
} finally { db.endTransaction() }
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun rejectedResults(): List<RejectedPurchaseResult> = readableDatabase.rawQuery(
|
||||
"SELECT id,task_id,attempt_id,payload_json,rejected_at,rejection_error_code,acknowledged_at FROM purchase_outbox WHERE upload_status='rejected' ORDER BY id DESC", null,
|
||||
).use { cursor -> buildList {
|
||||
while (cursor.moveToNext()) add(RejectedPurchaseResult(
|
||||
cursor.getLong(0), cursor.getLong(1), cursor.getString(2), cursor.getString(3),
|
||||
cursor.getLong(4), cursor.getString(5), if (cursor.isNull(6)) null else cursor.getLong(6),
|
||||
))
|
||||
} }
|
||||
|
||||
@Synchronized
|
||||
fun rejectedCount(): Int = readableDatabase.rawQuery(
|
||||
"SELECT COUNT(*) FROM purchase_outbox WHERE upload_status='rejected'", null,
|
||||
).use { it.moveToFirst(); it.getInt(0) }
|
||||
|
||||
@Synchronized
|
||||
fun isCurrentAttemptRejected(taskId: Long, attemptId: String): Boolean = readableDatabase.rawQuery(
|
||||
"SELECT 1 FROM purchase_task WHERE task_id=? AND attempt_id=? AND upload_status='rejected'",
|
||||
arrayOf(taskId.toString(), attemptId),
|
||||
).use { it.moveToFirst() }
|
||||
|
||||
@Synchronized
|
||||
fun acknowledgeRejection(outboxId: Long) {
|
||||
writableDatabase.execSQL(PurchaseRejectionSql.acknowledge, arrayOf(System.currentTimeMillis(), outboxId))
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val DATABASE_NAME = "goauto_purchase.db"
|
||||
private const val DATABASE_VERSION = 2
|
||||
private const val DATABASE_VERSION = 3
|
||||
private const val STATUS_RUNNING = "running"
|
||||
const val STATUS_ORDER_SUBMIT_STARTED = "order_submit_started"
|
||||
private const val STATUS_COMPLETED = "completed"
|
||||
|
||||
+57
-27
@@ -204,6 +204,7 @@ class AgentForegroundService : Service() {
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
stateStore.stop()
|
||||
probeHandoff.invalidate()
|
||||
if (diagnosticInstance === this) diagnosticInstance = null
|
||||
repurchaseClosed.set(true)
|
||||
@@ -268,12 +269,13 @@ class AgentForegroundService : Service() {
|
||||
}
|
||||
|
||||
private fun synchronizeAgent(): String {
|
||||
var heartbeatSucceeded = false
|
||||
return try {
|
||||
val serverUrl = ServerUrlPolicy.normalize(settingsStore.serverUrl())
|
||||
val api = AgentApiClient(serverUrl)
|
||||
var credentials = identityStore.credentials()
|
||||
stateStore.update("CONNECTING", "正在连接并校验设备身份", credentials?.deviceId ?: 0L, credentials != null)
|
||||
updateNotification("正在连接")
|
||||
stateStore.update("CONNECTING", "正在同步", credentials?.deviceId ?: 0L, credentials != null, connection = true)
|
||||
updateNotification("正在同步")
|
||||
|
||||
if (!registeredThisProcess.get()) {
|
||||
val registration = api.register(deviceInfo(), credentials?.token)
|
||||
@@ -290,17 +292,30 @@ class AgentForegroundService : Service() {
|
||||
}
|
||||
|
||||
val activeCredentials = credentials ?: error("设备尚未取得认证凭据")
|
||||
if (taskMutex.currentTaskId() == null) {
|
||||
recoverInterruptedPurchases(api, activeCredentials.token)
|
||||
flushPurchaseOutbox(api, activeCredentials.token)
|
||||
runningTaskId.set(purchaseStore.activeTaskId())
|
||||
val sync = AgentSyncCycle(
|
||||
heartbeat = { currentTaskId ->
|
||||
api.heartbeat(activeCredentials.token, currentTaskId, AgentCapabilities.supported).also {
|
||||
check(it.deviceId == activeCredentials.deviceId) { "心跳设备身份不一致" }
|
||||
}
|
||||
},
|
||||
activeTaskId = {
|
||||
if (taskMutex.currentTaskId() != null) runningTaskId.get()
|
||||
else purchaseStore.activeTaskId().also(runningTaskId::set)
|
||||
},
|
||||
executionActive = { taskMutex.currentTaskId() != null },
|
||||
recover = { recoverInterruptedPurchases(api, activeCredentials.token) },
|
||||
flush = { flushPurchaseOutbox(api, activeCredentials.token) },
|
||||
hasPending = { purchaseStore.pendingOutbox().isNotEmpty() },
|
||||
).run()
|
||||
if (sync.failureCode != null) {
|
||||
cancelIdleReturn("同步未完成")
|
||||
val message = cn.ilapage.goauto.agent.ui.PurchaseRejectionPresentation.connection(sync.failureCode, 0)
|
||||
stateStore.update(sync.failureCode, message, activeCredentials.deviceId, true, connection = true)
|
||||
updateNotification(message)
|
||||
return if (sync.failureCode == "AUTH_ERROR") MANUAL_AUTH_ERROR else MANUAL_NETWORK_ERROR
|
||||
}
|
||||
val heartbeat = api.heartbeat(
|
||||
activeCredentials.token,
|
||||
currentTaskId = runningTaskId.get(),
|
||||
capabilities = AgentCapabilities.supported,
|
||||
)
|
||||
check(heartbeat.deviceId == activeCredentials.deviceId) { "心跳返回了不同的设备身份" }
|
||||
val heartbeat = requireNotNull(sync.heartbeat)
|
||||
heartbeatSucceeded = true
|
||||
stateStore.update(
|
||||
code = if (heartbeat.busy) "BUSY" else "ONLINE",
|
||||
message = if (heartbeat.busy) "设备在线,正在执行任务" else "设备在线空闲",
|
||||
@@ -309,30 +324,32 @@ class AgentForegroundService : Service() {
|
||||
heartbeat = true,
|
||||
)
|
||||
updateNotification(if (heartbeat.busy) "在线 · 执行中" else "在线 · 空闲")
|
||||
scheduleTask(api, activeCredentials.token)
|
||||
if (sync.canClaim) scheduleTask(api, activeCredentials.token) else MANUAL_BUSY
|
||||
} catch (error: AgentApiException) {
|
||||
cancelIdleReturn("网络或服务端请求失败")
|
||||
val authenticationError = error.code in setOf(
|
||||
"DEVICE_TOKEN_INVALID", "DEVICE_INSTALL_ID_CONFLICT", "DEVICE_DISABLED",
|
||||
)
|
||||
val authenticationError = isAgentAuthenticationError(error)
|
||||
val connectionFailure = syncConnectionFailureCode(error, heartbeatSucceeded)
|
||||
val code = connectionFailure ?: "TASK_ERROR"
|
||||
val storedCredentials = runCatching { identityStore.credentials() }.getOrNull()
|
||||
stateStore.update(
|
||||
code = if (authenticationError) "AUTH_ERROR" else "NETWORK_ERROR",
|
||||
message = "${error.code}:${error.message}",
|
||||
code = code,
|
||||
message = if (connectionFailure == null) "任务同步暂未完成,请稍后重试" else cn.ilapage.goauto.agent.ui.PurchaseRejectionPresentation.connection(code, 0),
|
||||
deviceId = storedCredentials?.deviceId ?: 0L,
|
||||
tokenStored = storedCredentials != null,
|
||||
connection = connectionFailure != null,
|
||||
)
|
||||
updateNotification(if (authenticationError) "设备认证失败" else "连接失败,等待网络恢复")
|
||||
updateNotification(if (authenticationError) "设备认证失败" else if (heartbeatSucceeded) "任务同步暂未完成" else "连接失败,等待恢复")
|
||||
if (authenticationError) MANUAL_AUTH_ERROR else MANUAL_NETWORK_ERROR
|
||||
} catch (error: Exception) {
|
||||
cancelIdleReturn("Agent 运行异常")
|
||||
val configured = settingsStore.serverUrl().isNotBlank()
|
||||
val storedCredentials = runCatching { identityStore.credentials() }.getOrNull()
|
||||
stateStore.update(
|
||||
code = if (configured) "ERROR" else "CONFIG_REQUIRED",
|
||||
message = error.message ?: "Agent 运行失败",
|
||||
code = if (heartbeatSucceeded) "TASK_ERROR" else if (configured) "ERROR" else "CONFIG_REQUIRED",
|
||||
message = if (configured) "同步暂未完成,请稍后重试" else "请配置服务端",
|
||||
deviceId = storedCredentials?.deviceId ?: 0L,
|
||||
tokenStored = storedCredentials != null,
|
||||
connection = !heartbeatSucceeded,
|
||||
)
|
||||
updateNotification(if (configured) "运行异常" else "等待配置服务端")
|
||||
if (configured) MANUAL_ERROR else MANUAL_CONFIG_REQUIRED
|
||||
@@ -341,8 +358,6 @@ class AgentForegroundService : Service() {
|
||||
|
||||
private fun scheduleTask(api: AgentApiClient, token: String): String {
|
||||
if (taskMutex.currentTaskId() != null) return MANUAL_BUSY
|
||||
recoverInterruptedPurchases(api, token)
|
||||
flushPurchaseOutbox(api, token)
|
||||
val collectionCooldown = activeCollectionCooldown()
|
||||
val purchaseTask = api.nextPurchaseTask(token)
|
||||
when (TaskDispatchPolicy.decide(purchaseTask?.status, collectionCooldown != null)) {
|
||||
@@ -607,7 +622,7 @@ class AgentForegroundService : Service() {
|
||||
publishCurrentPageResult(0L, "采集间隔中,还需 $remaining 秒。", replacementOriginType, replacementOriginTaskId)
|
||||
return
|
||||
}
|
||||
if (!taskMutex.tryAcquire(CURRENT_PAGE_RESERVATION_ID)) {
|
||||
if (!tryAcquireCurrentPage(taskMutex, working, CURRENT_PAGE_RESERVATION_ID)) {
|
||||
publishCurrentPageResult(0L, "设备正在执行任务,请稍后再试。", replacementOriginType, replacementOriginTaskId)
|
||||
return
|
||||
}
|
||||
@@ -937,9 +952,10 @@ class AgentForegroundService : Service() {
|
||||
)
|
||||
}
|
||||
}
|
||||
val message = if (outcome.resultType == "failed") "${outcome.errorCode}:${outcome.message}" else outcome.message
|
||||
val rejected = purchaseStore.rejectedResults().any { it.taskId == task.taskId && it.attemptId == task.taskAttemptId }
|
||||
val message = if (rejected) "采购结果被服务端拒收,请在采购页或设置中查看" else if (outcome.resultType == "failed") "${outcome.errorCode}:${outcome.message}" else outcome.message
|
||||
stateStore.update(if (outcome.resultType == "failed") "TASK_ERROR" else "ONLINE", message, tokenStored = true)
|
||||
updateNotification(if (outcome.resultType == "failed") "$taskLabel #${task.taskId} 失败" else "$taskLabel #${task.taskId} 已提交")
|
||||
updateNotification(if (rejected) "$taskLabel #${task.taskId} 结果被拒收" else if (outcome.resultType == "failed") "$taskLabel #${task.taskId} 失败" else "$taskLabel #${task.taskId} 已提交")
|
||||
} catch (error: AgentApiException) {
|
||||
failureSnapshot("failed", error.code.takeIf { it.matches(Regex("[A-Z][A-Z0-9_]{0,63}")) } ?: "PURCHASE_API_FAILED")
|
||||
stateStore.update("TASK_ERROR", "${error.code}:${error.message}", tokenStored = true)
|
||||
@@ -1029,9 +1045,10 @@ class AgentForegroundService : Service() {
|
||||
}
|
||||
val requestId = UUID.randomUUID().toString()
|
||||
purchaseStore.completeAndEnqueue(interrupted.taskId, interrupted.attemptId, requestId, purchaseResultPayload(requestId, interrupted.attemptId, outcome))
|
||||
} catch (_: AgentApiException) {
|
||||
} catch (error: AgentApiException) {
|
||||
// Keep the local irreversible marker. The same server request ID
|
||||
// is replayed after connectivity returns; the order click is never repeated.
|
||||
if (isAgentAuthenticationError(error)) throw error
|
||||
}
|
||||
return@forEach
|
||||
}
|
||||
@@ -1066,6 +1083,18 @@ class AgentForegroundService : Service() {
|
||||
runCatching { onFirstSubmitted(item, requireNotNull(acknowledgement)) }
|
||||
.onFailure { probeHandoff.invalidate(); logDiagnosticPersistenceFailure(it) }
|
||||
},
|
||||
markRejected = { item, code ->
|
||||
probeHandoff.invalidate()
|
||||
purchaseStore.markRejected(item, code)
|
||||
synchronized(taskMutex) {
|
||||
val runtime = stateStore.read()
|
||||
if (shouldClearRejectedPurchase(item.taskId, runtime.currentTaskId, runtime.currentTaskType,
|
||||
taskMutex.currentTaskId(), purchaseStore.isCurrentAttemptRejected(item.taskId, item.attemptId))) {
|
||||
stateStore.clearActiveTask(item.taskId)
|
||||
}
|
||||
}
|
||||
sendBroadcast(Intent(ACTION_PURCHASE_REJECTIONS_CHANGED).setPackage(packageName))
|
||||
},
|
||||
).flush()
|
||||
runningTaskId.set(purchaseStore.activeTaskId())
|
||||
}
|
||||
@@ -1693,6 +1722,7 @@ class AgentForegroundService : Service() {
|
||||
const val ACTION_REPURCHASE_CONFIRM = "cn.ilapage.goauto.agent.REPURCHASE_CONFIRM"
|
||||
const val ACTION_REPURCHASE_STOP = "cn.ilapage.goauto.agent.REPURCHASE_STOP"
|
||||
const val ACTION_REPURCHASE_STATE = "cn.ilapage.goauto.agent.REPURCHASE_STATE"
|
||||
const val ACTION_PURCHASE_REJECTIONS_CHANGED = "cn.ilapage.goauto.agent.PURCHASE_REJECTIONS_CHANGED"
|
||||
@Volatile var repurchaseState = RepurchaseState()
|
||||
private set
|
||||
const val ACTION_BACKFILL_START = "cn.ilapage.goauto.agent.BACKFILL_START"
|
||||
|
||||
@@ -191,17 +191,27 @@ class AgentSettingsStore(context: Context) {
|
||||
|
||||
class AgentStateStore(context: Context) {
|
||||
private val preferences = context.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE)
|
||||
private val publicationGate = AgentStatePublicationGate()
|
||||
|
||||
fun update(code: String, message: String, deviceId: Long? = null, tokenStored: Boolean? = null, heartbeat: Boolean = false) {
|
||||
fun update(code: String, message: String, deviceId: Long? = null, tokenStored: Boolean? = null, heartbeat: Boolean = false, connection: Boolean = false) = publicationGate.publish {
|
||||
val editor = preferences.edit()
|
||||
.putString(STATE_CODE, code)
|
||||
.putString(STATE_MESSAGE, message)
|
||||
deviceId?.let { editor.putLong(DEVICE_ID, it) }
|
||||
tokenStored?.let { editor.putBoolean(TOKEN_STORED, it) }
|
||||
if (heartbeat) editor.putLong(LAST_HEARTBEAT, System.currentTimeMillis())
|
||||
if (heartbeat || connection) editor.putString(CONNECTION_CODE, code)
|
||||
editor.apply()
|
||||
}
|
||||
|
||||
/** This service instance cannot publish a late heartbeat over STOPPED or a restarted service. */
|
||||
fun stop() = publicationGate.stop {
|
||||
preferences.edit().putString(STATE_CODE, "STOPPED").putString(CONNECTION_CODE, "STOPPED")
|
||||
.putString(STATE_MESSAGE, "前台服务已停止").apply()
|
||||
}
|
||||
|
||||
fun connectionCode(): String = preferences.getString(CONNECTION_CODE, "STOPPED") ?: "STOPPED"
|
||||
|
||||
fun read(): AgentState = AgentState(
|
||||
code = preferences.getString(STATE_CODE, "STOPPED") ?: "STOPPED",
|
||||
message = preferences.getString(STATE_MESSAGE, "前台服务尚未启动") ?: "前台服务尚未启动",
|
||||
@@ -279,6 +289,7 @@ class AgentStateStore(context: Context) {
|
||||
private companion object {
|
||||
const val PREFERENCES = "goauto_agent_runtime"
|
||||
const val STATE_CODE = "state_code"
|
||||
const val CONNECTION_CODE = "connection_code"
|
||||
const val STATE_MESSAGE = "state_message"
|
||||
const val DEVICE_ID = "device_id"
|
||||
const val LAST_HEARTBEAT = "last_heartbeat"
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
package cn.ilapage.goauto.agent.service
|
||||
|
||||
import cn.ilapage.goauto.agent.network.AgentApiException
|
||||
import cn.ilapage.goauto.agent.network.HeartbeatResult
|
||||
|
||||
internal fun isAgentAuthenticationError(error: Throwable?): Boolean = error is AgentApiException &&
|
||||
(error.status in setOf(401, 403) || error.code in setOf("DEVICE_TOKEN_INVALID", "DEVICE_INSTALL_ID_CONFLICT", "DEVICE_DISABLED"))
|
||||
|
||||
internal fun syncConnectionFailureCode(error: Throwable, heartbeatSucceeded: Boolean): String? = when {
|
||||
isAgentAuthenticationError(error) -> "AUTH_ERROR"
|
||||
heartbeatSucceeded -> null
|
||||
error is AgentApiException && error.code == "DEVICE_TASK_MISMATCH" -> "TASK_MISMATCH"
|
||||
error is AgentApiException && error.status >= 500 -> "SERVER_ERROR"
|
||||
else -> "NETWORK_ERROR"
|
||||
}
|
||||
|
||||
internal data class AgentSyncResult(
|
||||
val heartbeat: HeartbeatResult?,
|
||||
val failureCode: String?,
|
||||
val canClaim: Boolean,
|
||||
)
|
||||
|
||||
/** Run under the service's existing sync exclusion; device execution keeps its task mutex. */
|
||||
internal class AgentSyncCycle(
|
||||
private val heartbeat: (Long?) -> HeartbeatResult,
|
||||
private val activeTaskId: () -> Long?,
|
||||
private val executionActive: () -> Boolean,
|
||||
private val recover: () -> Unit,
|
||||
private val flush: () -> Unit,
|
||||
private val hasPending: () -> Boolean,
|
||||
) {
|
||||
fun run(): AgentSyncResult {
|
||||
var pulse = runCatching { heartbeat(activeTaskId()) }
|
||||
var authFailure = isAgentAuthenticationError(pulse.exceptionOrNull())
|
||||
var recovered = false
|
||||
var flushed = false
|
||||
if (!authFailure && !executionActive()) {
|
||||
val recovery = runCatching(recover)
|
||||
recovered = recovery.isSuccess
|
||||
authFailure = isAgentAuthenticationError(recovery.exceptionOrNull())
|
||||
if (!authFailure) {
|
||||
val upload = runCatching(flush)
|
||||
flushed = upload.isSuccess
|
||||
authFailure = isAgentAuthenticationError(upload.exceptionOrNull())
|
||||
}
|
||||
if (!authFailure && (pulse.exceptionOrNull() as? AgentApiException)?.code == "DEVICE_TASK_MISMATCH") {
|
||||
pulse = runCatching { heartbeat(activeTaskId()) }
|
||||
}
|
||||
}
|
||||
val failure = pulse.exceptionOrNull()
|
||||
return AgentSyncResult(
|
||||
pulse.getOrNull(),
|
||||
when {
|
||||
authFailure -> "AUTH_ERROR"
|
||||
failure == null -> null
|
||||
isAgentAuthenticationError(failure) -> "AUTH_ERROR"
|
||||
(failure as? AgentApiException)?.code == "DEVICE_TASK_MISMATCH" -> "TASK_MISMATCH"
|
||||
failure is AgentApiException && failure.status >= 500 -> "SERVER_ERROR"
|
||||
else -> "NETWORK_ERROR"
|
||||
},
|
||||
pulse.isSuccess && !authFailure && recovered && flushed && !executionActive() && !hasPending() && activeTaskId() == null,
|
||||
)
|
||||
}
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
package cn.ilapage.goauto.agent.service
|
||||
|
||||
internal fun shouldClearRejectedPurchase(
|
||||
rejectedTaskId: Long, runtimeTaskId: Long?, runtimeTaskType: String?,
|
||||
executingTaskId: Long?, currentAttemptRejected: Boolean,
|
||||
): Boolean = executingTaskId == null && runtimeTaskId == rejectedTaskId &&
|
||||
runtimeTaskType == "purchase" && currentAttemptRejected
|
||||
|
||||
internal class AgentStatePublicationGate {
|
||||
private var stopped = false
|
||||
@Synchronized fun publish(write: () -> Unit) { if (!stopped) write() }
|
||||
@Synchronized fun stop(write: () -> Unit) { stopped = true; write() }
|
||||
}
|
||||
|
||||
internal fun tryAcquireCurrentPage(mutex: TaskExecutionMutex, syncing: java.util.concurrent.atomic.AtomicBoolean, reservation: Long): Boolean =
|
||||
synchronized(mutex) { !syncing.get() && mutex.tryAcquire(reservation) }
|
||||
@@ -24,6 +24,7 @@ import cn.ilapage.goauto.agent.network.CollectionHistoryItem
|
||||
import cn.ilapage.goauto.agent.network.PurchaseHistoryItem
|
||||
import cn.ilapage.goauto.agent.network.ServerUrlPolicy
|
||||
import cn.ilapage.goauto.agent.persistence.TaskHistoryCache
|
||||
import cn.ilapage.goauto.agent.persistence.PurchaseTaskStore
|
||||
import cn.ilapage.goauto.agent.service.AgentForegroundService
|
||||
import cn.ilapage.goauto.agent.service.AgentSettingsStore
|
||||
import cn.ilapage.goauto.agent.service.AgentStateStore
|
||||
@@ -58,6 +59,7 @@ class AgentSettingsFragment : Fragment() {
|
||||
private lateinit var connectionFeedback: TextView
|
||||
private lateinit var disabledReason: TextView
|
||||
private lateinit var diagnostics: TextView
|
||||
private lateinit var rejectedResultsButton: MaterialButton
|
||||
private lateinit var accessibilityText: TextView
|
||||
private lateinit var collectionIntervalStartLayout: TextInputLayout
|
||||
private lateinit var collectionIntervalStartInput: TextInputEditText
|
||||
@@ -326,6 +328,20 @@ class AgentSettingsFragment : Fragment() {
|
||||
diagnostics = context.label("—", 14f, context.getColor(R.color.agent_text))
|
||||
diagnostics.setPadding(0, context.dp(10), 0, 0)
|
||||
addView(diagnostics)
|
||||
rejectedResultsButton = MaterialButton(context).apply {
|
||||
text = "查看拒收结果"
|
||||
minHeight = context.dp(48)
|
||||
setOnClickListener {
|
||||
val records = PurchaseTaskStore(context).use { it.rejectedResults() }
|
||||
MaterialAlertDialogBuilder(context).setTitle("采购结果被服务端拒收")
|
||||
.setMessage(records.joinToString("\n\n") {
|
||||
PurchaseRejectionPresentation.notice(it) + "\n${it.errorCode}" +
|
||||
if (it.acknowledgedAt != null) "\n已核对(仅本机记录)" else ""
|
||||
}.ifBlank { "暂无拒收结果" })
|
||||
.setPositiveButton("知道了", null).show()
|
||||
}
|
||||
}
|
||||
addView(rejectedResultsButton, fullWidth(8))
|
||||
}))
|
||||
}
|
||||
return context.page(content)
|
||||
@@ -669,12 +685,14 @@ class AgentSettingsFragment : Fragment() {
|
||||
disabledReason.text = if (busy) "任务执行中,暂时不能修改服务器或设备名称。" else ""
|
||||
|
||||
val installId = runCatching { identityStore.installId() }.getOrElse { "读取失败" }
|
||||
val rejectedCount = PurchaseTaskStore(context).use { it.rejectedCount() }
|
||||
rejectedResultsButton.visibility = if (rejectedCount > 0) View.VISIBLE else View.GONE
|
||||
diagnostics.text = buildString {
|
||||
append("installId:$installId\n")
|
||||
append("Device Token:${if (state.tokenStored) "已配置" else "未配置"}\n")
|
||||
append("注册状态:${if (state.deviceId > 0) "已注册(设备 ${state.deviceId})" else "未注册"}\n")
|
||||
append("Agent 版本:${BuildConfig.VERSION_NAME}\n")
|
||||
append("服务端连接:${if (state.code in setOf("ONLINE", "BUSY", "COLLECTION_COOLDOWN")) "已连接" else "未连接"}\n")
|
||||
append("服务端连接:${PurchaseRejectionPresentation.connection(stateStore.connectionCode(), rejectedCount)}\n")
|
||||
append("保持屏幕常亮:${if (state.keepScreenOn) "已开启" else "仅在任务执行或采集间隔时开启"}")
|
||||
}
|
||||
accessibilityText.text = when (AccessibilityReadinessDetector.current(context)) {
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
package cn.ilapage.goauto.agent.ui
|
||||
|
||||
import cn.ilapage.goauto.agent.persistence.RejectedPurchaseResult
|
||||
import org.json.JSONObject
|
||||
|
||||
internal object PurchaseRejectionPresentation {
|
||||
private fun payload(item: RejectedPurchaseResult) = runCatching { JSONObject(item.payloadJson) }.getOrNull()
|
||||
private fun orderNo(item: RejectedPurchaseResult): String? = payload(item)?.optString("pddOrderNo")
|
||||
?.takeIf { it.matches(Regex("[0-9A-Za-z-]{1,80}")) }
|
||||
fun showBanner(item: RejectedPurchaseResult): Boolean = item.acknowledgedAt == null &&
|
||||
(payload(item)?.optString("resultType") in setOf("order_created", "order_result_unknown") ||
|
||||
!payload(item)?.optString("pddOrderNo").isNullOrBlank())
|
||||
fun notice(item: RejectedPurchaseResult): String = buildString {
|
||||
append("采购任务 #${item.taskId} 的结果被服务端拒收")
|
||||
orderNo(item)?.let { append("\n拼多多订单号:$it") }
|
||||
append("\n请人工核对拼多多订单及后台任务,避免重复采购")
|
||||
}
|
||||
fun connection(code: String, rejected: Int): String = when (code) {
|
||||
"CONNECTING" -> "正在同步"
|
||||
"ONLINE", "BUSY", "COLLECTION_COOLDOWN", "TASK_ERROR" -> if (rejected > 0) "已连接 · 有 $rejected 条采购结果被服务端拒收" else "已连接"
|
||||
"AUTH_ERROR" -> "未连接 · 设备认证失败"
|
||||
"TASK_MISMATCH" -> "未连接 · 设备任务状态不一致"
|
||||
"NETWORK_ERROR" -> "未连接 · 网络暂不可用"
|
||||
"SERVER_ERROR" -> "未连接 · 服务端暂不可用"
|
||||
"CONFIG_REQUIRED" -> "未连接 · 请配置服务端"
|
||||
else -> "未连接 · 同步暂未完成"
|
||||
}
|
||||
}
|
||||
@@ -37,6 +37,8 @@ import cn.ilapage.goauto.agent.network.HistoryColorImage
|
||||
import cn.ilapage.goauto.agent.network.PurchaseHistoryDetail
|
||||
import cn.ilapage.goauto.agent.network.PurchaseHistoryItem
|
||||
import cn.ilapage.goauto.agent.persistence.TaskHistoryCache
|
||||
import cn.ilapage.goauto.agent.persistence.PurchaseTaskStore
|
||||
import cn.ilapage.goauto.agent.persistence.RejectedPurchaseResult
|
||||
import cn.ilapage.goauto.agent.service.AgentForegroundService
|
||||
import cn.ilapage.goauto.agent.service.AgentSettingsStore
|
||||
import cn.ilapage.goauto.agent.service.AgentStateStore
|
||||
@@ -146,8 +148,14 @@ class TaskHistoryFragment : Fragment() {
|
||||
private var repurchaseButton: MaterialButton? = null
|
||||
private var repurchaseDialog: androidx.appcompat.app.AlertDialog? = null
|
||||
private var repurchaseDialogRound: String? = null
|
||||
private var rejectionPanel: LinearLayout? = null
|
||||
private var displayedRejections: List<RejectedPurchaseResult>? = null
|
||||
private val currentPageReceiver = object : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context?, intent: Intent?) {
|
||||
if (intent?.action == AgentForegroundService.ACTION_PURCHASE_REJECTIONS_CHANGED) {
|
||||
renderRejections()
|
||||
return
|
||||
}
|
||||
if (intent?.action == AgentForegroundService.ACTION_REPURCHASE_STATE) {
|
||||
renderRepurchase()
|
||||
if (!collection && isResumed && AgentForegroundService.repurchaseState.phase == cn.ilapage.goauto.agent.service.RepurchasePhase.FINISHED) {
|
||||
@@ -200,6 +208,10 @@ class TaskHistoryFragment : Fragment() {
|
||||
val context = requireContext()
|
||||
pageColumn = context.column()
|
||||
pageColumn.addView(context.screenTitle(if (collection) "采集记录" else "采购记录"))
|
||||
if (!collection) {
|
||||
rejectionPanel = context.column(0)
|
||||
pageColumn.addView(rejectionPanel)
|
||||
}
|
||||
pageColumn.addView(buildSearch())
|
||||
if (!collection) {
|
||||
repurchasePanel = context.column(0)
|
||||
@@ -228,6 +240,7 @@ class TaskHistoryFragment : Fragment() {
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
renderRejections()
|
||||
renderRepurchase()
|
||||
detailState.taskId?.let { taskId ->
|
||||
if (collection) loadCollectionDetail(taskId) else loadPurchaseDetail(taskId)
|
||||
@@ -235,6 +248,8 @@ class TaskHistoryFragment : Fragment() {
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
rejectionPanel = null
|
||||
displayedRejections = null
|
||||
repurchaseDialog?.setOnDismissListener(null)
|
||||
repurchaseDialog?.dismiss()
|
||||
repurchaseDialog = null
|
||||
@@ -247,6 +262,29 @@ class TaskHistoryFragment : Fragment() {
|
||||
super.onDestroyView()
|
||||
}
|
||||
|
||||
private fun renderRejections() {
|
||||
val panel = rejectionPanel ?: return
|
||||
val context = context ?: return
|
||||
val records = PurchaseTaskStore(context).use { it.rejectedResults() }.filter(PurchaseRejectionPresentation::showBanner)
|
||||
if (records == displayedRejections) return
|
||||
displayedRejections = records
|
||||
panel.removeAllViews()
|
||||
records.forEach { record ->
|
||||
panel.addView(context.card(context.cardColumn().apply {
|
||||
addView(context.label(PurchaseRejectionPresentation.notice(record), 14f, context.getColor(R.color.agent_warning), true))
|
||||
addView(MaterialButton(context).apply {
|
||||
text = "已核对"
|
||||
minHeight = context.dp(48)
|
||||
contentDescription = "采购任务 #${record.taskId} 已核对,仅隐藏本机提醒"
|
||||
setOnClickListener {
|
||||
val saved = runCatching { PurchaseTaskStore(context).use { it.acknowledgeRejection(record.id) } }.isSuccess
|
||||
if (saved) renderRejections() else toast("暂时无法保存核对记录,请稍后重试")
|
||||
}
|
||||
}, fullWidth(8))
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
unregisterCurrentPageReceiver()
|
||||
imageLoader.close()
|
||||
@@ -811,6 +849,7 @@ class TaskHistoryFragment : Fragment() {
|
||||
val filter = IntentFilter(AgentForegroundService.ACTION_CURRENT_PAGE_RESULT)
|
||||
filter.addAction(AgentForegroundService.ACTION_BACKFILL_STATE)
|
||||
filter.addAction(AgentForegroundService.ACTION_REPURCHASE_STATE)
|
||||
filter.addAction(AgentForegroundService.ACTION_PURCHASE_REJECTIONS_CHANGED)
|
||||
if (Build.VERSION.SDK_INT >= 33) {
|
||||
requireContext().registerReceiver(currentPageReceiver, filter, Context.RECEIVER_NOT_EXPORTED)
|
||||
} else {
|
||||
@@ -893,6 +932,11 @@ class TaskHistoryFragment : Fragment() {
|
||||
private fun renderPurchaseDetail(detail: PurchaseHistoryDetail) {
|
||||
val context = requireContext()
|
||||
val task = detail.task
|
||||
PurchaseTaskStore(context).use { it.rejectedResults() }.filter { it.taskId == task.taskId }.forEach { record ->
|
||||
resultColumn.addView(context.card(context.cardColumn().apply {
|
||||
addView(context.label(PurchaseRejectionPresentation.notice(record) + "\n${record.errorCode}", 14f, context.getColor(R.color.agent_warning)))
|
||||
}))
|
||||
}
|
||||
val info = buildString {
|
||||
append("蝦皮订单号:${task.shopeeOrderNo.ifBlank { "—" }}\n")
|
||||
append("PDD 商品:${task.pddGoodsId}\n")
|
||||
|
||||
@@ -8,6 +8,22 @@ import org.junit.Test
|
||||
class PurchaseOutboxHandoffTest {
|
||||
private val item = PendingPurchaseOutbox(1, 42, "probe", "request", "{}")
|
||||
|
||||
@Test fun `terminal conflict does not block later pending result`() {
|
||||
val submitted = mutableListOf<Long>()
|
||||
val uploaded = mutableListOf<Long>()
|
||||
val result = runCatching {
|
||||
PurchaseOutboxUploader(
|
||||
{ listOf(item, item.copy(id = 2, taskId = 43)) },
|
||||
{ submitted += it.id; if (it.id == 1L) throw cn.ilapage.goauto.agent.network.AgentApiException(409, "PURCHASE_STATE_CONFLICT", "untrusted", false) },
|
||||
{ uploaded += it.id },
|
||||
markRejected = { _, _ -> },
|
||||
).flush()
|
||||
}
|
||||
assertTrue(result.isSuccess)
|
||||
assertEquals(listOf(1L, 2L), submitted)
|
||||
assertEquals(listOf(2L), uploaded)
|
||||
}
|
||||
|
||||
@Test fun `handoff callback runs only after successful submit and local upload mark`() {
|
||||
for (failure in listOf("submit", "mark", "none")) {
|
||||
val events = mutableListOf<String>()
|
||||
@@ -31,4 +47,37 @@ class PurchaseOutboxHandoffTest {
|
||||
PurchaseOutboxUploader({ listOf(item) }, { submitted++ }, {}).flush()
|
||||
assertEquals(1, submitted)
|
||||
}
|
||||
|
||||
@Test fun `only whitelisted 409 is terminal independent of retryable`() {
|
||||
for (code in listOf("PURCHASE_STATE_CONFLICT", "PURCHASE_LEASE_EXPIRED", "PURCHASE_RESULT_CONFLICT", "OTHER")) {
|
||||
for (status in listOf(409, 401, 403, 500)) for (retryable in listOf(false, true)) {
|
||||
val events = mutableListOf<String>()
|
||||
val result = runCatching {
|
||||
PurchaseOutboxUploader({ listOf(item) },
|
||||
{ throw cn.ilapage.goauto.agent.network.AgentApiException(status, code, "secret URL", retryable) },
|
||||
{ events += "uploaded" }, { events += "handoff" },
|
||||
{ _, fixedCode -> events += fixedCode },
|
||||
).flush()
|
||||
}
|
||||
val rejected = status == 409 && code != "OTHER"
|
||||
assertEquals(rejected, result.isSuccess)
|
||||
assertEquals(if (rejected) listOf(code) else emptyList<String>(), events)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun `network and rejected persistence failures retain pending and stop flush`() {
|
||||
for (diskFailure in listOf(false, true)) {
|
||||
val events = mutableListOf<String>()
|
||||
val result = runCatching {
|
||||
PurchaseOutboxUploader({ listOf(item, item.copy(id = 2)) },
|
||||
{ events += "submit"; if (diskFailure) throw cn.ilapage.goauto.agent.network.AgentApiException(409, "PURCHASE_STATE_CONFLICT", "raw", false) else error("network") },
|
||||
{ events += "uploaded" }, { events += "handoff" },
|
||||
{ _, _ -> events += "reject"; error("disk") },
|
||||
).flush()
|
||||
}
|
||||
assertTrue(result.isFailure)
|
||||
assertEquals(if (diskFailure) listOf("submit", "reject") else listOf("submit"), events)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
package cn.ilapage.goauto.agent
|
||||
|
||||
import cn.ilapage.goauto.agent.network.AgentApiClient
|
||||
import cn.ilapage.goauto.agent.network.AgentApiException
|
||||
import cn.ilapage.goauto.agent.persistence.PendingPurchaseOutbox
|
||||
import cn.ilapage.goauto.agent.persistence.PurchaseOutboxUploader
|
||||
import java.net.ServerSocket
|
||||
import java.util.concurrent.Executors
|
||||
import java.util.concurrent.TimeUnit
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Test
|
||||
|
||||
class PurchaseOutboxHttpTest {
|
||||
@Test fun `missing retryable is not evidence that an unknown 409 is terminal`() {
|
||||
for (code in listOf("PURCHASE_STATE_CONFLICT", "PURCHASE_LEASE_EXPIRED", "PURCHASE_RESULT_CONFLICT", "UNKNOWN_CONFLICT")) {
|
||||
ServerSocket(0).use { server ->
|
||||
server.soTimeout = 3000
|
||||
val executor = Executors.newSingleThreadExecutor()
|
||||
val serving = executor.submit {
|
||||
server.accept().use { socket ->
|
||||
socket.soTimeout = 3000
|
||||
val reader = socket.getInputStream().bufferedReader()
|
||||
var contentLength = 0
|
||||
while (true) {
|
||||
val line = reader.readLine() ?: break
|
||||
if (line.isEmpty()) break
|
||||
if (line.startsWith("Content-Length:", true)) contentLength = line.substringAfter(':').trim().toInt()
|
||||
}
|
||||
repeat(contentLength) { check(reader.read() >= 0) }
|
||||
val response = """{"code":"$code","message":"untrusted diagnostic"}"""
|
||||
socket.getOutputStream().write(("HTTP/1.1 409 Conflict\r\nContent-Type: application/json\r\nContent-Length: ${response.toByteArray().size}\r\nConnection: close\r\n\r\n$response").toByteArray())
|
||||
}
|
||||
}
|
||||
try {
|
||||
val api = AgentApiClient("http://127.0.0.1:${server.localPort}")
|
||||
var pending = true
|
||||
val result = runCatching {
|
||||
PurchaseOutboxUploader(
|
||||
{ listOf(PendingPurchaseOutbox(1,42,"probe","request","{}")) },
|
||||
{ api.submitPurchaseResult(it.taskId,it.payloadJson,"synthetic") },
|
||||
{ fail("409 is never uploaded") },
|
||||
{ fail("409 is never a handoff") },
|
||||
{ _, _ -> pending = false },
|
||||
).flush()
|
||||
}
|
||||
assertEquals(code == "UNKNOWN_CONFLICT", pending)
|
||||
assertEquals(code != "UNKNOWN_CONFLICT", result.isSuccess)
|
||||
if (pending) assertFalse((result.exceptionOrNull() as AgentApiException).retryable)
|
||||
serving.get(5, TimeUnit.SECONDS)
|
||||
} finally { executor.shutdownNow() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
package cn.ilapage.goauto.agent
|
||||
|
||||
import cn.ilapage.goauto.agent.persistence.RejectedPurchaseResult
|
||||
import cn.ilapage.goauto.agent.ui.PurchaseRejectionPresentation
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Test
|
||||
|
||||
class PurchaseRejectionPresentationTest {
|
||||
private fun item(payload: String, acknowledged: Long? = null) = RejectedPurchaseResult(1,42,"attempt",payload,100,"PURCHASE_STATE_CONFLICT",acknowledged)
|
||||
@Test fun `only unacknowledged order evidence appears on procurement banner`() {
|
||||
for (payload in listOf("""{"resultType":"order_created"}""", """{"resultType":"order_result_unknown"}""", """{"resultType":"failed","pddOrderNo":"123-456"}""")) {
|
||||
assertTrue(PurchaseRejectionPresentation.showBanner(item(payload)))
|
||||
assertFalse(PurchaseRejectionPresentation.showBanner(item(payload, 200)))
|
||||
}
|
||||
assertFalse(PurchaseRejectionPresentation.showBanner(item("""{"resultType":"spec_probe_completed"}""")))
|
||||
assertFalse(PurchaseRejectionPresentation.showBanner(item("""{"resultType":"spec_probe_completed","pddOrderNo":null}""")))
|
||||
}
|
||||
@Test fun `notice includes task optional order and fixed review instruction without raw errors`() {
|
||||
val message = PurchaseRejectionPresentation.notice(item("""{"resultType":"order_created","pddOrderNo":"123-456","message":"https://secret"}"""))
|
||||
assertTrue(message.contains("#42")); assertTrue(message.contains("123-456"))
|
||||
assertTrue(message.contains("请人工核对拼多多订单及后台任务,避免重复采购"))
|
||||
assertFalse(message.contains("secret"))
|
||||
}
|
||||
@Test fun `connection classifies sync heartbeat and rejected separately`() {
|
||||
assertEquals("正在同步", PurchaseRejectionPresentation.connection("CONNECTING", 2))
|
||||
assertEquals("已连接 · 有 2 条采购结果被服务端拒收", PurchaseRejectionPresentation.connection("ONLINE", 2))
|
||||
assertEquals("未连接 · 设备认证失败", PurchaseRejectionPresentation.connection("AUTH_ERROR", 2))
|
||||
assertEquals("未连接 · 设备任务状态不一致", PurchaseRejectionPresentation.connection("TASK_MISMATCH", 2))
|
||||
assertEquals("未连接 · 网络暂不可用", PurchaseRejectionPresentation.connection("NETWORK_ERROR", 2))
|
||||
assertEquals("未连接 · 服务端暂不可用", PurchaseRejectionPresentation.connection("SERVER_ERROR", 2))
|
||||
}
|
||||
}
|
||||
+100
@@ -0,0 +1,100 @@
|
||||
package cn.ilapage.goauto.agent.persistence
|
||||
|
||||
import java.sql.Connection
|
||||
import java.sql.DriverManager
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Test
|
||||
|
||||
class PurchaseRejectionStoreTest {
|
||||
@Test fun `v2 upgrade adds exactly three nullable columns and preserves all old data`() = database { db ->
|
||||
val before = rows(db, "SELECT * FROM purchase_outbox")
|
||||
val taskBefore = rows(db, "SELECT * FROM purchase_task")
|
||||
migrate(db)
|
||||
assertEquals(3, PurchaseRejectionSql.migration.size)
|
||||
assertEquals(taskBefore, rows(db, "SELECT * FROM purchase_task"))
|
||||
assertEquals(before, rows(db, "SELECT id, task_id, attempt_id, request_id, payload_json, upload_status, created_at, updated_at FROM purchase_outbox"))
|
||||
assertEquals(listOf(listOf(null, null, null)), rows(db, "SELECT rejected_at,rejection_error_code,acknowledged_at FROM purchase_outbox"))
|
||||
val columns = rows(db, "PRAGMA table_info(purchase_outbox)").takeLast(3)
|
||||
assertTrue(columns.all { it[3] == "0" && it[4] == null })
|
||||
}
|
||||
|
||||
@Test fun `reject is atomic preserves payload excludes pending and targets current attempt only`() = database { db ->
|
||||
migrate(db)
|
||||
reject(db)
|
||||
assertEquals(listOf(listOf("rejected", "payload", "100", "PURCHASE_STATE_CONFLICT")), rows(db, "SELECT upload_status,payload_json,rejected_at,rejection_error_code FROM purchase_outbox"))
|
||||
assertEquals(listOf(listOf("completed", "rejected", "payload")), rows(db, "SELECT status,upload_status,result_json FROM purchase_task"))
|
||||
assertTrue(rows(db, PurchaseRejectionSql.activeTask).isEmpty())
|
||||
assertTrue(rows(db, "SELECT id FROM purchase_outbox WHERE upload_status='pending'").isEmpty())
|
||||
}
|
||||
|
||||
@Test fun `old outbox rejection cannot overwrite a newer task attempt`() = database { db ->
|
||||
migrate(db)
|
||||
db.createStatement().use { it.execute("UPDATE purchase_task SET attempt_id='new',status='running',upload_status='none'") }
|
||||
reject(db)
|
||||
assertEquals(listOf(listOf("new", "running", "none")), rows(db, "SELECT attempt_id,status,upload_status FROM purchase_task"))
|
||||
}
|
||||
|
||||
@Test fun `rejected record never counts active even if legacy local status says running`() = database { db ->
|
||||
migrate(db); reject(db)
|
||||
db.createStatement().use { it.execute("UPDATE purchase_task SET status='running'") }
|
||||
assertTrue(rows(db, PurchaseRejectionSql.activeTask).isEmpty())
|
||||
}
|
||||
|
||||
@Test fun `invalid whitelist and mismatched outbox identity cannot change evidence`() = database { db ->
|
||||
migrate(db)
|
||||
for ((item, code) in listOf(
|
||||
PendingPurchaseOutbox(1,42,"old","request","payload") to "UNKNOWN",
|
||||
PendingPurchaseOutbox(1,42,"new","request","payload") to "PURCHASE_STATE_CONFLICT",
|
||||
)) {
|
||||
assertTrue(runCatching { PurchaseRejectionSql.reject(item, code, 100) { sql, args -> update(db, sql, args) } }.isFailure)
|
||||
}
|
||||
assertEquals(listOf(listOf("pending", null, null)), rows(db, "SELECT upload_status,rejected_at,rejection_error_code FROM purchase_outbox"))
|
||||
}
|
||||
|
||||
@Test fun `task update failure rolls back rejection evidence too`() = database { db ->
|
||||
migrate(db)
|
||||
db.createStatement().use { it.execute("CREATE TRIGGER fail_task BEFORE UPDATE ON purchase_task BEGIN SELECT RAISE(ABORT, 'test'); END") }
|
||||
assertTrue(runCatching { reject(db) }.isFailure)
|
||||
assertEquals(listOf(listOf("pending", null, null)), rows(db, "SELECT upload_status,rejected_at,rejection_error_code FROM purchase_outbox"))
|
||||
assertEquals(listOf(listOf("pending")), rows(db, "SELECT upload_status FROM purchase_task"))
|
||||
}
|
||||
|
||||
@Test fun `ack survives database reopen and changes no payload or task state`() {
|
||||
val file = java.io.File.createTempFile("purchase-rejection-", ".db")
|
||||
try {
|
||||
connect(file.absolutePath).use { db ->
|
||||
seed(db); migrate(db); reject(db)
|
||||
update(db, PurchaseRejectionSql.acknowledge, listOf(200L, 1L))
|
||||
}
|
||||
connect(file.absolutePath).use { db ->
|
||||
assertEquals(listOf(listOf("200", "payload", "rejected")), rows(db, "SELECT acknowledged_at,payload_json,upload_status FROM purchase_outbox"))
|
||||
assertEquals(listOf(listOf("completed", "rejected")), rows(db, "SELECT status,upload_status FROM purchase_task"))
|
||||
}
|
||||
} finally { check(file.delete()) }
|
||||
}
|
||||
|
||||
private fun reject(db: Connection) {
|
||||
db.autoCommit = false
|
||||
try {
|
||||
PurchaseRejectionSql.reject(PendingPurchaseOutbox(1,42,"old","request","payload"), "PURCHASE_STATE_CONFLICT", 100) { sql, args -> update(db, sql, args) }
|
||||
db.commit()
|
||||
} catch (error: Exception) { db.rollback(); throw error }
|
||||
finally { db.autoCommit = true }
|
||||
}
|
||||
private fun update(db: Connection, sql: String, args: List<Any>): Int = db.prepareStatement(sql).use { statement ->
|
||||
args.forEachIndexed { index, arg -> statement.setObject(index + 1, arg) }
|
||||
statement.executeUpdate()
|
||||
}
|
||||
private fun migrate(db: Connection) = PurchaseRejectionSql.migration.forEach { sql -> db.createStatement().use { it.execute(sql) } }
|
||||
private fun connect(path: String): Connection { Class.forName("org.sqlite.JDBC"); return DriverManager.getConnection("jdbc:sqlite:$path") }
|
||||
private fun database(block: (Connection) -> Unit) = connect(":memory:").use { seed(it); block(it) }
|
||||
private fun rows(db: Connection, sql: String): List<List<String?>> = db.createStatement().use { statement ->
|
||||
statement.executeQuery(sql).use { r -> buildList { while(r.next()) add((1..r.metaData.columnCount).map { r.getString(it) }) } }
|
||||
}
|
||||
private fun seed(db: Connection) = db.createStatement().use {
|
||||
it.execute("CREATE TABLE purchase_task (task_id INTEGER PRIMARY KEY,attempt_id TEXT NOT NULL,rule_snapshot_hash TEXT NOT NULL,current_step TEXT NOT NULL,status TEXT NOT NULL,result_json TEXT,upload_status TEXT NOT NULL,created_at INTEGER NOT NULL,updated_at INTEGER NOT NULL,order_submit_request_id TEXT,final_confirmation_json TEXT,irreversible_at INTEGER)")
|
||||
it.execute("CREATE TABLE purchase_outbox (id INTEGER PRIMARY KEY AUTOINCREMENT,task_id INTEGER NOT NULL,attempt_id TEXT NOT NULL,request_id TEXT NOT NULL UNIQUE,payload_json TEXT NOT NULL,upload_status TEXT NOT NULL,created_at INTEGER NOT NULL,updated_at INTEGER NOT NULL)")
|
||||
it.execute("INSERT INTO purchase_task VALUES (42,'old','hash','submit_result','completed','payload','pending',1,2,NULL,NULL,NULL)")
|
||||
it.execute("INSERT INTO purchase_outbox VALUES (1,42,'old','request','payload','pending',1,2)")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
package cn.ilapage.goauto.agent.service
|
||||
|
||||
import cn.ilapage.goauto.agent.network.AgentApiException
|
||||
import cn.ilapage.goauto.agent.network.HeartbeatResult
|
||||
import cn.ilapage.goauto.agent.persistence.PendingPurchaseOutbox
|
||||
import cn.ilapage.goauto.agent.persistence.PurchaseOutboxUploader
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Test
|
||||
|
||||
class AgentSyncCycleTest {
|
||||
private val online = HeartbeatResult(7, true, false, 15)
|
||||
private fun api(status: Int, code: String) = AgentApiException(status, code, "untrusted URL", false)
|
||||
|
||||
@Test fun `stale completed probe heartbeat mismatch rejected then refreshed heartbeat permits next claim`() {
|
||||
val events = mutableListOf<String>()
|
||||
var active: Long? = 42
|
||||
var pending = true
|
||||
val result = AgentSyncCycle(
|
||||
heartbeat = { id -> events += "heartbeat:$id"; if (id != null) throw api(409, "DEVICE_TASK_MISMATCH") else online },
|
||||
activeTaskId = { active }, executionActive = { false },
|
||||
recover = { events += "recover" },
|
||||
flush = {
|
||||
PurchaseOutboxUploader(
|
||||
{ listOf(PendingPurchaseOutbox(1,42,"probe","request","{}")) },
|
||||
{ throw api(409,"PURCHASE_STATE_CONFLICT") },
|
||||
{ fail("rejected probe must not be marked uploaded") },
|
||||
{ fail("rejected probe must not establish handoff") },
|
||||
{ _, _ -> events += "reject-probe"; pending = false; active = null },
|
||||
).flush()
|
||||
},
|
||||
hasPending = { pending },
|
||||
).run()
|
||||
if (result.canClaim) events += "claim"
|
||||
assertEquals(listOf("heartbeat:42", "recover", "reject-probe", "heartbeat:null", "claim"), events)
|
||||
assertEquals(online, result.heartbeat)
|
||||
}
|
||||
|
||||
@Test fun `authentication errors skip recovery and flush including unrecognized HTTP 401 and 403`() {
|
||||
for ((status, code) in listOf(401 to "OTHER", 403 to "OTHER", 409 to "DEVICE_TOKEN_INVALID", 409 to "DEVICE_INSTALL_ID_CONFLICT", 409 to "DEVICE_DISABLED")) {
|
||||
val result = AgentSyncCycle({ throw api(status, code) }, { 42 }, { false },
|
||||
{ fail("recovery after auth failure") }, { fail("flush after auth failure") }, { true }).run()
|
||||
assertFalse(result.canClaim)
|
||||
assertEquals("AUTH_ERROR", result.failureCode)
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun `heartbeat survives recovery or flush failure but claiming stops`() {
|
||||
for (recoverFails in listOf(false, true)) {
|
||||
var flushed = false
|
||||
val result = AgentSyncCycle({ online }, { null }, { false },
|
||||
{ if (recoverFails) error("disk") },
|
||||
{ flushed = true; if (!recoverFails) throw api(500, "INTERNAL") }, { false }).run()
|
||||
assertTrue(flushed)
|
||||
assertEquals(online, result.heartbeat)
|
||||
assertFalse(result.canClaim)
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun `failed heartbeat still flushes but never claims and mismatch retries only once`() {
|
||||
for (code in listOf("DEVICE_TASK_MISMATCH", "INTERNAL")) {
|
||||
var heartbeats = 0
|
||||
var flushed = false
|
||||
val result = AgentSyncCycle({ heartbeats++; throw api(409, code) }, { null }, { false }, {},
|
||||
{ flushed = true }, { false }).run()
|
||||
assertTrue(flushed)
|
||||
assertEquals(if (code == "DEVICE_TASK_MISMATCH") 2 else 1, heartbeats)
|
||||
assertFalse(result.canClaim)
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun `network failure leaves pending even if heartbeat succeeds`() {
|
||||
var pending = true
|
||||
val result = AgentSyncCycle({ online }, { 42 }, { false }, {}, { throw java.io.IOException("network") }, { pending }).run()
|
||||
assertTrue(pending)
|
||||
assertEquals(online, result.heartbeat)
|
||||
assertFalse(result.canClaim)
|
||||
}
|
||||
|
||||
@Test fun `active execution sends heartbeat without recovery or flush`() {
|
||||
val result = AgentSyncCycle({ online }, { 42 }, { true }, { fail("recover") }, { fail("flush") }, { false }).run()
|
||||
assertEquals(online, result.heartbeat)
|
||||
assertFalse(result.canClaim)
|
||||
}
|
||||
|
||||
@Test fun `retained irreversible local task prevents claim even without outbox`() {
|
||||
val result = AgentSyncCycle({ online }, { 42 }, { false }, {}, {}, { false }).run()
|
||||
assertFalse(result.canClaim)
|
||||
}
|
||||
|
||||
@Test fun `authentication failure during recovery or flush stops round without heartbeat retry`() {
|
||||
for (duringRecovery in listOf(true, false)) {
|
||||
var heartbeats = 0
|
||||
var flushes = 0
|
||||
val result = AgentSyncCycle({ heartbeats++; throw api(409,"DEVICE_TASK_MISMATCH") }, { 42 }, { false },
|
||||
{ if (duringRecovery) throw api(401,"OTHER") },
|
||||
{ flushes++; throw api(403,"OTHER") }, { true }).run()
|
||||
assertEquals(1, heartbeats)
|
||||
assertEquals(if (duringRecovery) 0 else 1, flushes)
|
||||
assertEquals("AUTH_ERROR", result.failureCode)
|
||||
assertFalse(result.canClaim)
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun `server failure is classified separately from transport failure`() {
|
||||
val result = AgentSyncCycle({ throw api(503,"UNAVAILABLE") }, { null }, { false }, {}, {}, { false }).run()
|
||||
assertEquals("SERVER_ERROR", result.failureCode)
|
||||
}
|
||||
|
||||
@Test fun `claim failure cannot erase successful heartbeat except authentication`() {
|
||||
assertNull(syncConnectionFailureCode(api(409,"PURCHASE_STATE_CONFLICT"), true))
|
||||
assertNull(syncConnectionFailureCode(IllegalStateException("disk"), true))
|
||||
assertNull(syncConnectionFailureCode(api(503,"INTERNAL"), true))
|
||||
assertEquals("AUTH_ERROR", syncConnectionFailureCode(api(401,"OTHER"), true))
|
||||
}
|
||||
}
|
||||
+59
@@ -0,0 +1,59 @@
|
||||
package cn.ilapage.goauto.agent.service
|
||||
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Test
|
||||
|
||||
class PurchaseRuntimeReconciliationTest {
|
||||
@Test fun `manual current page cannot enter while sync owns recovery and flush`() {
|
||||
val mutex = TaskExecutionMutex()
|
||||
val syncing = java.util.concurrent.atomic.AtomicBoolean(false)
|
||||
val syncEntered = java.util.concurrent.CountDownLatch(1)
|
||||
val finishSync = java.util.concurrent.CountDownLatch(1)
|
||||
val worker = java.util.concurrent.Executors.newSingleThreadExecutor()
|
||||
val future = worker.submit {
|
||||
synchronized(mutex) { assertTrue(syncing.compareAndSet(false, true)) }
|
||||
syncEntered.countDown()
|
||||
check(finishSync.await(3, java.util.concurrent.TimeUnit.SECONDS))
|
||||
syncing.set(false)
|
||||
}
|
||||
try {
|
||||
assertTrue(syncEntered.await(3, java.util.concurrent.TimeUnit.SECONDS))
|
||||
assertFalse(tryAcquireCurrentPage(mutex, syncing, Long.MAX_VALUE))
|
||||
assertNull(mutex.currentTaskId())
|
||||
finishSync.countDown()
|
||||
future.get(3, java.util.concurrent.TimeUnit.SECONDS)
|
||||
assertTrue(tryAcquireCurrentPage(mutex, syncing, Long.MAX_VALUE))
|
||||
} finally { finishSync.countDown(); worker.shutdownNow() }
|
||||
}
|
||||
|
||||
@Test fun `reboot restored rejected task clears runtime so repurchase becomes idle`() {
|
||||
var runtimeTaskId: Long? = 42
|
||||
val currentDatabaseAttemptRejected = true
|
||||
if (shouldClearRejectedPurchase(42, runtimeTaskId, "purchase", null, currentDatabaseAttemptRejected)) runtimeTaskId = null
|
||||
assertNull(runtimeTaskId)
|
||||
assertTrue(runtimeTaskId == null) // final runtime predicate in repurchaseLocalIdle
|
||||
}
|
||||
|
||||
@Test fun `another task collection active executor or newer attempt cannot be cleared`() {
|
||||
assertFalse(shouldClearRejectedPurchase(42, 43, "purchase", null, true))
|
||||
assertFalse(shouldClearRejectedPurchase(42, 42, "collection", null, true))
|
||||
assertFalse(shouldClearRejectedPurchase(42, 42, "purchase", 42, true))
|
||||
assertFalse(shouldClearRejectedPurchase(42, 42, "purchase", 43, true))
|
||||
assertFalse(shouldClearRejectedPurchase(42, 42, "purchase", null, false))
|
||||
}
|
||||
|
||||
@Test fun `stopped service remains stopped after an in flight heartbeat completes`() {
|
||||
val gate = AgentStatePublicationGate()
|
||||
var connection = "CONNECTING"
|
||||
gate.publish { connection = "ONLINE" }
|
||||
assertEquals("ONLINE", connection)
|
||||
gate.stop { connection = "STOPPED" }
|
||||
gate.publish { connection = "ONLINE" }
|
||||
assertEquals("STOPPED", connection)
|
||||
val restartedService = AgentStatePublicationGate()
|
||||
restartedService.publish { connection = "CONNECTING" }
|
||||
assertEquals("CONNECTING", connection)
|
||||
gate.publish { connection = "ONLINE" }
|
||||
assertEquals("CONNECTING", connection)
|
||||
}
|
||||
}
|
||||
@@ -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: a47b6b3b7ef7745417d1c2c88932f5bb12228f27
|
||||
synchronized_at: 2026-10-09T07:57:47Z
|
||||
wiki_revision: 7cdfe55383055e0f7611cd153e8d813d32f08d91
|
||||
synchronized_at: 2026-10-09T10:28:15Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
<!-- gitea-wiki-mirror:start -->
|
||||
@@ -905,6 +905,8 @@ Android 0.9.64 / versionCode 77,源码 `6550b9f`(分支实现,尚未安装
|
||||
|
||||
## 下单前最终规格复核(#374 v3)
|
||||
|
||||
> 以下为 #374 版本行为;包含 #376 的分支构建已按本文后续“采购改地址前复核与提交提示检查”章节替代最终复核策略,尚未合并或发布。不把新规则归于未更新的旧 APK。
|
||||
|
||||
实现绑定 `6f1af70d0d2a4c70c31ba42e05c4321f08c6a78e`(原独立工单分支基于 `e26743c`);2026-10-09 已与 #373 集成并推送 main `555a954605a83c12457ed1ada86eb732fe3bb023`。集成版本同时包含导航等待与最终规格复核,未安装或发布,真机采购未验收。
|
||||
|
||||
- 仅替换 PurchaseLiveAutomation.finalConfirmation 的最终规格检查,不修改之前的规格选择/verifySummary、地址、单价、数量、提交目标与不可逆边界。没有 Server/Web、API 或数据库变化。
|
||||
@@ -916,3 +918,31 @@ Android 0.9.64 / versionCode 77,源码 `6550b9f`(分支实现,尚未安装
|
||||
- 多个不一致的可信摘要、已知组合歧义均拒绝。单个不匹配的摘要不能补证缺失维度;若所有必需维度已有当前精确选中证明且无上述冲突,则按选中证明优先通过。不声称候选缺失时能证明全部潜在 SKU 唯一性。
|
||||
- 拒绝仍为 PURCHASE_SPEC_NOT_MATCHED,错误信息与诊断只附固定 dimension/stage/summaryPresent/selectedFound/conflict,不附页面文字、规格原文或地址。原本靠任意可见控件含有规格子串的通过路径已移除。
|
||||
- 自动化证据:8 个相关套件共366项通过,0失败/错误/跳过;debug构建、release单测源码编译通过。完整单测因未修改的图搜返回测试忙循环而中止,不能宣称全量通过。结构夹具依据为 SpecPanelFixtures.sheet/taskOptionDedupSheet 及 PddProductDetailCollectorTest.prefixlessPanel;未执行真实采购,不以合成测试代替真机效果。
|
||||
|
||||
## 采购改地址前复核与提交提示检查(#376 v3)
|
||||
|
||||
实现绑定工单分支 `fix/376-pre-address-spec-verification` 提交 `9c7357609cee7c73b57edd030136ed12ef9485b6`(2026-10-09),尚未合并 main 或发布;此规则仅适用于包含 #376 的 Android 构建,旧版 #374 行为不因此改变。没有 Server/Web、业务库迁移或 API 边界协议变化。
|
||||
|
||||
- 改地址前,verifySummary 在每个目标维度和既有重定位滑动后的当前面板中检查未选提示。面板摘要的“请选择/請選擇”或唯一提交按钮的选择提示导致 PURCHASE_SPEC_SELECTION_UNCONFIRMED、reason=selection_prompt,停止后续地址动作;无唯一提交按钮时仍可检查摘要,不增加改地址前缺少按钮的门禁。
|
||||
- 本次选择历史证明仅用于既有可见性缺口;目标当前可见、可选却未选中,同维无其他选中项且没有已选摘要时,不再由历史证明路径放行,reason=target_visible_unselected。目标不可见时保留原历史证明行为;不暴露选中属性且无摘要的布局可能提前失败。此前规格选择点击、数量设置、改地址前价格/数量复核不变。
|
||||
- 修改地址后的 finalConfirmation 不再比较颜色/尺码值,不要求选中证据,也不核对当前数量;保留地址、提交目标唯一性和单价区间检查。摘要未选提示在此只记录,不拦截。唯一提交按钮的完整文字去空白后匹配 `^(选择|選擇).+(后|後)[,,](提交订单|提交訂單)$` 才新增拒绝 PURCHASE_SPEC_NOT_MATCHED、finalSpec;stage=selection_prompt。
|
||||
- 按钮来源沿用原面板底部行 row 和点击文字节点 chosen;提示只组合该行内可见、自身带文字且没有带文字子孙、最近可点击祖先恰为 row 的节点,按 top/left 排序,不重复拼父子汇总、不跨独立按钮/摘要/规格列表或不可见节点。实际点击目标、行/叶并列选择和支付排除保持不变;enabled=true 不是已选规格的证明。
|
||||
- finalConfirmation 位于 beforeOrderSubmit 之前,拒绝时不写提交边界、不点击,普通失败可让重购继续。submitOrderOnce 重新取屏后的同类提示只作兜底,零点击但边界已写入,仍为 order_result_unknown,重购停止等待人工核对;不移动持久化边界、不增加重试或付款。
|
||||
- 本地 final_confirmation_json 新记录以 targetQuantity 表示下发数量,追加 observedQuantity(唯一可见可用数字数量输入,否则 null)、selectedSummaryPresent 与 summaryPromptPresent。仅记录数字/布尔观察值,不新增页面文字或个人信息;不用于拦截、不上传,旧记录不改写。
|
||||
- 用户明确接受剩余风险:修改地址后规格被换成其他值,或数量重置,但按钮仍正常时会放行;价格与地址检查不能替代规格/数量一致性。观察值仅供事后核对,不证明采购结果正确。缺少提示也不构成 PDD 已选正确的正向证明。
|
||||
- 不新增分享、OCR/VLM、滑动取证、自动重新选规格或付款;合成测试与 APK 构建不代表真实订单验证。安装、真实采购、合并和发布须按相应授权执行。
|
||||
|
||||
验证:19 个相关套件共418项通过,0失败/错误/跳过;Debug APK与release单测源码编译通过。执行器测试模拟提交边界回调,并验证本地JSON序列化;未执行真实SQLite/服务端边界端到端或真实采购验证。不宣称全量Android测试通过(既有图搜返回忙循环用例不在本次回归范围)。
|
||||
|
||||
## Android 采购结果明确拒收恢复(#377)
|
||||
|
||||
实现位于工单分支 fix/377-rejected-purchase-outbox(实现提交 8b75a95,运行占位与互斥补齐 309dce3);仅 Android,未合并 main、未安装或发布,真机恢复尚待独立授权验证。
|
||||
|
||||
- 每轮先发心跳,再在无任务执行锁时恢复中断任务及补传。心跳任务不一致不能挡住拒收收敛;收尾后只额外重发一次心跳。认证错误停止补传,只有最终心跳成功、没有未完成本地任务/待上传结果且收尾正常才领取新任务。不放宽服务端任务身份校验,不修改心跳间隔或离线阈值。
|
||||
- 只有 HTTP 409 且错误码精确属于 PURCHASE_STATE_CONFLICT、PURCHASE_LEASE_EXPIRED、PURCHASE_RESULT_CONFLICT 才视为明确拒收。未知 409、网络异常、5xx 及认证错误不按拒收处理;缺少 retryable 不构成拒收证据。
|
||||
- 本地拒收使用 rejected,不伪装 sent。按 outbox 与 task/attempt 归属事务保存,保留原 payload;不再自动补传该条,继续处理后续结果。拒收不触发上传成功或规格探测交接,交接凭证失效。拒收记录不再充当待上传占位;不改变服务端采购结果和原重购停止边界。
|
||||
- order_created、order_result_unknown 或带订单号的拒收结果在采购页持续提示人工核对。“已核对”只持久隐藏提醒,不删除结果、不调用服务端、不允许再次下单;非订单拒收仍可在设置及任务详情查看。
|
||||
- goauto_purchase.db v2→v3 只追加拒收时间、白名单错误码及人工核对时间三个可空列,旧结果与记录保持不变;v1 升级仍经过原 v2 追加列。旧版 SQLiteOpenHelper 不保证能打开 v3,不能靠卸载清数据回退,优先使用保留 v3 schema 的修复版本。
|
||||
- 设置页区分同步中、真实心跳失败、已连接但有拒收;新说明采用固定文案,不展示任意服务端错误消息。本单只修复明确拒收后的持续阻塞,不代表最初心跳中断的原因已查明,历史诊断仍属于 #378。
|
||||
|
||||
- 拒收后只在执行锁空闲、当前运行标记属于同一采购任务且本地当前 attempt 已拒收时清理恢复占位,不清除其他采集/采购或新 attempt;手动当前页采集与同步共用既有 working/任务锁门禁。服务停止写 STOPPED,原服务实例的迟到状态更新不覆盖它。
|
||||
|
||||
@@ -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: af209183f85200ce53f79f82ab682e17bb0aa942
|
||||
synchronized_at: 2026-10-09T10:28:29Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 故障排查
|
||||
@@ -158,3 +158,21 @@ adb -s <serial> shell run-as cn.ilapage.goauto.agent sqlite3 -readonly databases
|
||||
```
|
||||
|
||||
排查须绑定同一设备、Agent/PDD 版本、任务与规则快照,并对照同次 App 布局。网页竖排与 App 横排不是同一复现场景;当前横排只能做横向兼容性回归。真机测试结果及待验证项以 #370 工单为准。
|
||||
|
||||
## 采购结果拒收后设备持续离线(#377)
|
||||
|
||||
修复绑定工单分支 fix/377-rejected-purchase-outbox 的 8b75a95、309dce3;尚未合并 main、安装或发布。自动化覆盖不等于真实设备验收,不能假定线上手机已具备本节恢复行为。
|
||||
|
||||
旧版同步在心跳之前补传,一条结果被服务端永久拒收就能让每轮跳过心跳及领任务。离线之后本地仍携带旧任务 ID,又可能被心跳的 DEVICE_TASK_MISMATCH 拒绝;仅把心跳前置不足以恢复。#377 先发心跳,仍进行有界拒收收尾,再在任务不一致时重发一次心跳;认证失败不会继续收尾,执行中的任务不会被恢复流程并发接管。
|
||||
|
||||
排查步骤:
|
||||
|
||||
1. 核实 APK 对应提交,不仅看版本名(本次未改 versionCode/versionName)。设置页“正在同步”不代表断线;真实心跳网络、服务端或认证失败使用固定原因说明;任务处理错误不再覆盖此前成功的连接状态。
|
||||
2. “已连接 · 有 N 条采购结果被服务端拒收”表示本地已有 rejected 结果,不等于服务端接收成功。可在设置页“查看拒收结果”和对应采购详情查看;有订单证据的结果还在采购页顶部持续提醒。
|
||||
3. 人工核对 PDD 实际订单及后台任务。需要补录订单时沿用 Admin 原有人工流程;Agent 的“已核对”只隐藏这条本机提醒,不能代替后台保存、恢复回填、解锁重试或完成任务。原结果/订单号与核对时间继续保存。
|
||||
4. 未知 409、网络失败或 5xx 保持 pending;如果仍卡住,核对错误分类及原任务状态,不自动把 pending 改成 sent。不反复重试真实采购来试探是否已下单。
|
||||
5. 早期现场曾经另获授权,通过同时修改 outbox 与本地任务 upload_status=sent 恢复设备,但这不代表服务端已收件,也会把订单号留在手机。它只是历史应急处置,不是新版运维步骤,不应复制到脚本或作为常规修复。
|
||||
|
||||
数据库升级仅限手机私有 goauto_purchase.db:v3 在 purchase_outbox 添加 rejected_at、rejection_error_code、acknowledged_at;保留原始 payload 和任务数据。不得把整库、订单/地址内容上传到工单、Wiki、普通日志或 SynapBus。旧 APK 不保证能够降级打开 v3;回退优先使用保留 v3 的兼容修复构建,不卸载清数据、不擅自降低库版本。
|
||||
|
||||
已验证 SQLite JDBC 执行同一生产迁移/拒收/核对 SQL、事务回滚及文件重开;尚未执行 Android SQLiteOpenHelper 仪器测试、真机界面或真实采购。最初心跳和结果同时中断的原因仍未查明,#378 的诊断计划不属于本修复。
|
||||
|
||||
Reference in New Issue
Block a user