Compare commits

...
Author SHA1 Message Date
QiuSWandClaude Opus 5.5 4aae245829 docs: mirror wiki for #391 #393 release
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
2026-10-14 12:10:13 +08:00
QiuSWandClaude Opus 5.5 df640ecd81 docs: mirror wiki for #392 #393
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
2026-10-14 11:38:36 +08:00
QiuSWandClaude Opus 5.5 6a9c823fe1 merge: pass final spec check on no pending prompt and record it (#393)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
2026-10-14 11:25:34 +08:00
QiuSWandClaude Opus 5.5 d1094a449c merge: reread order amount once when first order evidence lacks it (#392)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
2026-10-14 11:25:34 +08:00
QiuSWandClaude Sonnet 5.5 bf8f0a1153 fix(android): reread order amount once when first order evidence lacks it (#392)
When the first confirmed order evidence has no payable amount, pause about
1s and take one passive capture (no click/back/swipe/foreground). A PDD frame
with order/unpaid context is merged into the accumulated labels; the amount is
adopted only if the order number matches and the amount is a single valid
value. Any other outcome keeps the first evidence. Amounts including 0 skip
the reread. Diagnostics emit enum-only orderAmountReread outcomes.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
2026-10-14 11:24:27 +08:00
QiuSWandClaude Sonnet 5.5 8be6b05b31 fix(android,server): pass final spec check on no pending prompt and record it (#393)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
2026-10-14 11:24:25 +08:00
QiuSWandClaude Opus 5.5 9f918b05e2 docs: mirror wiki for #391
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
2026-10-14 10:28:42 +08:00
QiuSWandClaude Opus 5.5 0cb674a88a merge: strip leading # in selected summary and record final spec proof (#391)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
2026-10-14 10:16:56 +08:00
QiuSWandClaude Sonnet 5.5 f62f071e63 fix(android,server): accept exact selected summary as final spec proof and record it (#391)
Final spec verifier now strips one leading '#'/full-width '#' after the
"已选" prefix and optional colon, so "已选: #颜色 尺码" summaries count as
proof again; verify() returns selected_option/selected_summary. The agent
sends it as optional specProof on order-submit-started; the server stores it
in nullable purchase_task.order_spec_proof (unknown/missing -> NULL, replay
keeps the stored value, claim/start still reject unknown fields).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
2026-10-14 10:14:23 +08:00
QiuSWandClaude Opus 5.5 ccd650f2e3 docs: mirror wiki for #390 merge
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
2026-10-14 09:39:48 +08:00
QiuSWandClaude Opus 5.5 5282d65a45 merge: accept near-square purchase scroll panels (#390)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
2026-10-14 09:28:16 +08:00
QiuSW 0dfe8406c4 docs: sync cancellation and purchase scroll contracts (#389 #390) 2026-10-13 18:05:05 +08:00
QiuSW a3c05a751b fix(android): accept near-square purchase scroll panels (#390) 2026-10-13 18:01:50 +08:00
QiuSWandClaude Opus 5.5 b2285c8394 docs: mirror wiki for #388
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
2026-10-13 16:27:07 +08:00
QiuSWandClaude Opus 5.5 496bf803f5 merge: accept evidence-complete quick confirmation panel after address save (#388)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
2026-10-13 16:21:41 +08:00
20 changed files with 1051 additions and 36 deletions
+2 -2
View File
@@ -11,8 +11,8 @@ android {
applicationId = "cn.ilapage.goauto.agent" applicationId = "cn.ilapage.goauto.agent"
minSdk = 23 minSdk = 23
targetSdk = 34 targetSdk = 34
versionCode = 85 versionCode = 88
versionName = "0.9.72" versionName = "0.9.75"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
@@ -4,7 +4,7 @@ package cn.ilapage.goauto.agent.automation
internal object PurchaseFinalSpecVerifier { internal object PurchaseFinalSpecVerifier {
private fun normalize(value: String) = SpecValueNormalizer.normalizeFinalConfirmation(value) private fun normalize(value: String) = SpecValueNormalizer.normalizeFinalConfirmation(value)
fun verify(snapshot: UiSnapshot, input: PurchaseExecutionInput, diagnostic: (String) -> Unit) { fun verify(snapshot: UiSnapshot, input: PurchaseExecutionInput, diagnostic: (String) -> Unit): String {
val config = PurchaseRehearsalExecutor.DEFAULT_COLLECTOR val config = PurchaseRehearsalExecutor.DEFAULT_COLLECTOR
val byPath = snapshot.nodes.associateBy(SnapshotNode::path) val byPath = snapshot.nodes.associateBy(SnapshotNode::path)
fun within(node: SnapshotNode, container: SnapshotNode): Boolean { fun within(node: SnapshotNode, container: SnapshotNode): Boolean {
@@ -61,6 +61,9 @@ internal object PurchaseFinalSpecVerifier {
val prefix = config.textAliases.selection.selectedPrefixes.firstOrNull(trimmed::startsWith) val prefix = config.textAliases.selection.selectedPrefixes.firstOrNull(trimmed::startsWith)
val value = if (prefix == null) trimmed else trimmed.removePrefix(prefix).trimStart().let { val value = if (prefix == null) trimmed else trimmed.removePrefix(prefix).trimStart().let {
if (it.startsWith(':') || it.startsWith(':')) it.drop(1) else it if (it.startsWith(':') || it.startsWith(':')) it.drop(1) else it
}.trimStart().let {
// #391: 采购1 的摘要写作「已选: #颜色 尺码」;只去掉开头这一个 '#',值内部的不动。
if (it.startsWith('#') || it.startsWith('#')) it.drop(1) else it
} }
normalize(value) normalize(value)
}.distinct() }.distinct()
@@ -96,8 +99,36 @@ internal object PurchaseFinalSpecVerifier {
"$color $size" == summary && (color != targets["color"] || size != targets["size"]) "$color $size" == summary && (color != targets["color"] || size != targets["size"])
} }) reject("color", "composition_ambiguity", selectedProof.isNotEmpty(), conflict = true) } }) reject("color", "composition_ambiguity", selectedProof.isNotEmpty(), conflict = true)
} }
targets.keys.forEach { dimension -> // #393: 无「已选」摘要时,若整个窗口没有任何待选提示(「请选择」类前缀),且目标选项不在屏幕上,
if (dimension !in selectedProof && summary != expected) reject(dimension, "missing_proof") // 以「无待选提示」放行该维度。其余拒绝条件不变;依据单独标记供抽查。
val selection = config.textAliases.selection
val pendingPrefixes = selection.summaryPrefixes.filter { it !in selection.selectedPrefixes }
val pendingPrompt = snapshot.nodes.any { node ->
node.visible && listOfNotNull(node.text, node.contentDescription).any { raw ->
val compact = raw.filterNot(Char::isWhitespace)
pendingPrefixes.any { compact.startsWith(it.filterNot(Char::isWhitespace)) }
}
}
var passedByNoPendingPrompt = false
targets.forEach { (dimension, target) ->
if (dimension !in selectedProof && summary != expected) {
val targetVisible = dimensions[dimension].orEmpty().any { normalize(it.text) == target }
if (summaries.isEmpty() && !pendingPrompt && !targetVisible && scoped != null) {
passedByNoPendingPrompt = true
} else {
// 单独一条诊断,保持 finalSpec 拒绝详情格式不变。
diagnostic(
"finalSpecPending;dimension=$dimension;pendingPrompt=${if (pendingPrompt) 1 else 0};" +
"targetVisible=${if (targetVisible) 1 else 0}",
)
reject(dimension, "missing_proof")
}
}
}
return when {
passedByNoPendingPrompt -> PurchaseSpecProof.NO_PENDING_PROMPT
targets.keys.all { it in selectedProof } -> PurchaseSpecProof.SELECTED_OPTION
else -> PurchaseSpecProof.SELECTED_SUMMARY
} }
} }
@@ -3,6 +3,7 @@ package cn.ilapage.goauto.agent.automation
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.Locale import java.util.Locale
import java.util.TimeZone import java.util.TimeZone
import kotlin.coroutines.cancellation.CancellationException
data class ShippingAddressProof internal constructor( data class ShippingAddressProof internal constructor(
internal val expectedAddress: String, internal val expectedAddress: String,
@@ -17,8 +18,16 @@ data class FinalConfirmationEvidence(
val unitPriceCent: Long, val unitPriceCent: Long,
val addressSuffix: String, val addressSuffix: String,
val activityName: String, val activityName: String,
/** #391: 最终规格复核依据,取值见 [PurchaseSpecProof]。 */
val specProof: String = PurchaseSpecProof.SELECTED_OPTION,
) )
object PurchaseSpecProof {
const val SELECTED_SUMMARY = "selected_summary"
const val SELECTED_OPTION = "selected_option"
const val NO_PENDING_PROMPT = "no_pending_prompt"
}
data class PurchaseOrderEvidence(val orderNo: String, val submittedAt: String, val pddOrderAmountCent: Long? = null) data class PurchaseOrderEvidence(val orderNo: String, val submittedAt: String, val pddOrderAmountCent: Long? = null)
data class PurchaseOrderReadFailure( data class PurchaseOrderReadFailure(
val code: String, val code: String,
@@ -321,12 +330,13 @@ class PurchaseLiveAutomation(
val submit = finalSubmitTargets(snapshot) val submit = finalSubmitTargets(snapshot)
if (submit.size != 1) fail("PURCHASE_SUBMIT_TARGET_AMBIGUOUS", "创建订单按钮不是唯一目标,禁止创建订单") if (submit.size != 1) fail("PURCHASE_SUBMIT_TARGET_AMBIGUOUS", "创建订单按钮不是唯一目标,禁止创建订单")
val screen = PddScreenParser.parse(snapshot, PurchaseRehearsalExecutor.DEFAULT_COLLECTOR, input.goodsId, null) val screen = PddScreenParser.parse(snapshot, PurchaseRehearsalExecutor.DEFAULT_COLLECTOR, input.goodsId, null)
PurchaseFinalSpecVerifier.verify(snapshot, input, panelDiagnostic) val specProof = PurchaseFinalSpecVerifier.verify(snapshot, input, panelDiagnostic)
panelDiagnostic("finalSpecProof=$specProof")
val price = screen.priceCent ?: fail("RULE_NOT_MATCHED", "创建订单前没有读取到商品单价") val price = screen.priceCent ?: fail("RULE_NOT_MATCHED", "创建订单前没有读取到商品单价")
if (price !in input.minUnitPriceCent..input.maxUnitPriceCent) fail("PURCHASE_PRICE_OUT_OF_RANGE", "当前商品单价超出允许范围", price) if (price !in input.minUnitPriceCent..input.maxUnitPriceCent) fail("PURCHASE_PRICE_OUT_OF_RANGE", "当前商品单价超出允许范围", price)
val quantities = snapshot.nodes.filter { it.visible && it.enabled && it.className?.endsWith("EditText") == true }.mapNotNull { it.label.toLongOrNull() } val quantities = snapshot.nodes.filter { it.visible && it.enabled && it.className?.endsWith("EditText") == true }.mapNotNull { it.label.toLongOrNull() }
if (quantities.singleOrNull() != input.quantity) fail("PURCHASE_QUANTITY_MISMATCH", "创建订单前数量复核失败") if (quantities.singleOrNull() != input.quantity) fail("PURCHASE_QUANTITY_MISMATCH", "创建订单前数量复核失败")
return FinalConfirmationEvidence(input.goodsId, input.mappedColor, input.mappedSize, input.quantity, price, address.suffix, snapshot.activityName.orEmpty()) return FinalConfirmationEvidence(input.goodsId, input.mappedColor, input.mappedSize, input.quantity, price, address.suffix, snapshot.activityName.orEmpty(), specProof)
} }
fun submitOrderOnce() { fun submitOrderOnce() {
@@ -508,7 +518,10 @@ class PurchaseLiveAutomation(
continue continue
} }
currentLabels.forEach(labels::add) currentLabels.forEach(labels::add)
parseOrderEvidence(labels)?.let { return it } parseOrderEvidence(labels)?.let { first ->
// A known amount (including 0) is returned unchanged: no extra pause or capture.
return if (first.pddOrderAmountCent != null) first else rereadMissingOrderAmount(first, labels)
}
if (unpaidStartSample != null && unpaidSwipes < ORDER_RESULT_UNPAID_MAX_SWIPES) { if (unpaidStartSample != null && unpaidSwipes < ORDER_RESULT_UNPAID_MAX_SWIPES) {
unpaidSwipes++ unpaidSwipes++
driver.swipePurchase(SwipeDirection.UP, 400) driver.swipePurchase(SwipeDirection.UP, 400)
@@ -522,6 +535,61 @@ class PurchaseLiveAutomation(
} }
} }
/**
* One passive re-sample when confirmed order evidence lacks the payable amount. It never re-enters the
* reading loop and performs no click/back/swipe/foreground action. Any problem keeps the first evidence.
*/
private fun rereadMissingOrderAmount(first: PurchaseOrderEvidence, labels: Set<String>): PurchaseOrderEvidence {
var outcome = "error"
var result = first
try {
pause(ORDER_AMOUNT_REREAD_DELAY_MS)
val snapshot = driver.capture()
val currentLabels = snapshot.nodes.filter { it.visible }.map { it.label }
val eligible = snapshot.packageName == PDD_PACKAGE && currentLabels.any { label ->
ORDER_CONTEXT_MARKERS.any(label::contains) || UNPAID_MARKERS.any(label::contains)
}
if (!eligible) {
outcome = "ignored_frame"
} else {
val union = LinkedHashSet(labels).apply { addAll(currentLabels) }
val merged = parseOrderEvidence(union)
val amount = merged?.pddOrderAmountCent
if (merged == null || merged.orderNo != first.orderNo) {
outcome = "order_mismatch"
} else if (amount != null) {
outcome = "filled"
result = first.copy(pddOrderAmountCent = amount)
} else {
outcome = payableAmountMissingCategory(union)
}
}
} catch (error: InterruptedException) {
Thread.currentThread().interrupt()
} catch (error: CancellationException) {
throw error
} catch (error: Exception) {
outcome = "error"
}
runCatching { panelDiagnostic("orderAmountReread;outcome=$outcome") }
return result
}
/** Mirrors [parsePayableAmount] null reasons for diagnostics; contains no text, amounts or order numbers. */
private fun payableAmountMissingCategory(labels: Collection<String>): String {
val amounts = labels.flatMap { label ->
PAYABLE_AMOUNT.findAll(label).map { match ->
runCatching { match.groupValues[1].toBigDecimal().movePointRight(2).longValueExact() }.getOrNull()
}.toList()
}
return when {
amounts.isEmpty() -> "no_payable_label"
amounts.any { it == null } -> "invalid"
amounts.distinct().size > 1 -> "conflict"
else -> "invalid"
}
}
// orderEvidenceFailure 之外的路径(选择器卡住、微信恢复超时等)同样要带上 // orderEvidenceFailure 之外的路径(选择器卡住、微信恢复超时等)同样要带上
// 已经采集到的 paymentPageObserved,不能让 unknown() 之外的 return 漏标。 // 已经采集到的 paymentPageObserved,不能让 unknown() 之外的 return 漏标。
@@ -797,7 +865,7 @@ class PurchaseLiveAutomation(
val screenHeight = snapshot.nodes.maxOfOrNull { it.bounds.bottom }?.coerceAtLeast(1) ?: 1 val screenHeight = snapshot.nodes.maxOfOrNull { it.bounds.bottom }?.coerceAtLeast(1) ?: 1
return snapshot.nodes.filter { node -> return snapshot.nodes.filter { node ->
node.visible && node.enabled && node.scrollable && node.visible && node.enabled && node.scrollable &&
node.bounds.height >= node.bounds.width && node.bounds.height * 100 >= node.bounds.width * PANEL_SCROLL_MIN_HEIGHT_TO_WIDTH_PERCENT &&
node.bounds.width * 100 >= screenWidth * 60 && node.bounds.width * 100 >= screenWidth * 60 &&
node.bounds.height * 100 >= screenHeight * 20 node.bounds.height * 100 >= screenHeight * 20
}.distinctBy { it.path } }.distinctBy { it.path }
@@ -1150,9 +1218,12 @@ class PurchaseLiveAutomation(
const val ORDER_RESULT_UNPAID_SETTLE_MS = 500L const val ORDER_RESULT_UNPAID_SETTLE_MS = 500L
const val ORDER_RESULT_SCROLL_SAMPLE_INTERVAL = 15 const val ORDER_RESULT_SCROLL_SAMPLE_INTERVAL = 15
const val ORDER_RESULT_SAMPLE_INTERVAL_MS = 200L const val ORDER_RESULT_SAMPLE_INTERVAL_MS = 200L
const val ORDER_AMOUNT_REREAD_DELAY_MS = 1_000L
const val SPEC_CONFIRMATION_MAX_SAMPLES = 20 const val SPEC_CONFIRMATION_MAX_SAMPLES = 20
const val SPEC_CONFIRMATION_SAMPLE_INTERVAL_MS = 100L const val SPEC_CONFIRMATION_SAMPLE_INTERVAL_MS = 100L
const val ADDRESS_ENTRY_STABLE_INTERVAL_MS = 200L const val ADDRESS_ENTRY_STABLE_INTERVAL_MS = 200L
// #390: near-square purchase lists can be shorter than their width; exclude flat strips.
const val PANEL_SCROLL_MIN_HEIGHT_TO_WIDTH_PERCENT = 80
const val ADDRESS_CARD_OVERLAP_PERCENT = 70 const val ADDRESS_CARD_OVERLAP_PERCENT = 70
const val ADDRESS_SEMANTIC_MIN_LENGTH = 4 const val ADDRESS_SEMANTIC_MIN_LENGTH = 4
val ADDRESS_CARD_GENERIC_LABELS = setOf("收货地址", "修改", "默认地址", "默认") val ADDRESS_CARD_GENERIC_LABELS = setOf("收货地址", "修改", "默认地址", "默认")
@@ -538,8 +538,10 @@ class AgentApiClient(
return purchaseTask(requireNotNull(request("POST", "/api/agent/v1/purchase-tasks/$taskId/start", payload, token)).getJSONObject("data")) return purchaseTask(requireNotNull(request("POST", "/api/agent/v1/purchase-tasks/$taskId/start", payload, token)).getJSONObject("data"))
} }
fun markPurchaseOrderSubmitStarted(taskId: Long, requestId: String, token: String): PurchaseAgentTask { fun markPurchaseOrderSubmitStarted(taskId: Long, requestId: String, token: String, specProof: String? = null): PurchaseAgentTask {
val payload = JSONObject().put("requestId", requestId) val payload = JSONObject().put("requestId", requestId)
// #391: 可选依据;恢复路径拿不到时不带该字段,服务端存 NULL。
if (!specProof.isNullOrBlank()) payload.put("specProof", specProof)
return purchaseTask(requireNotNull(request("POST", "/api/agent/v1/purchase-tasks/$taskId/order-submit-started", payload, token)).getJSONObject("data")) return purchaseTask(requireNotNull(request("POST", "/api/agent/v1/purchase-tasks/$taskId/order-submit-started", payload, token)).getJSONObject("data"))
} }
@@ -1074,9 +1074,10 @@ class AgentForegroundService : Service() {
.put("unitPriceCent", evidence.unitPriceCent) .put("unitPriceCent", evidence.unitPriceCent)
.put("addressSuffix", evidence.addressSuffix) .put("addressSuffix", evidence.addressSuffix)
.put("activityName", evidence.activityName) .put("activityName", evidence.activityName)
.put("specProof", evidence.specProof)
.toString() .toString()
purchaseStore.markOrderSubmitStarted(task.taskId, task.taskAttemptId, boundaryRequestId, finalEvidence) purchaseStore.markOrderSubmitStarted(task.taskId, task.taskAttemptId, boundaryRequestId, finalEvidence)
api.markPurchaseOrderSubmitStarted(task.taskId, boundaryRequestId, token) api.markPurchaseOrderSubmitStarted(task.taskId, boundaryRequestId, token, evidence.specProof)
}, },
).execute( ).execute(
PurchaseExecutionInput( PurchaseExecutionInput(
@@ -6,6 +6,7 @@ import cn.ilapage.goauto.agent.automation.FreshClickReason
import cn.ilapage.goauto.agent.automation.NodeBounds import cn.ilapage.goauto.agent.automation.NodeBounds
import cn.ilapage.goauto.agent.automation.PurchaseExecutionInput import cn.ilapage.goauto.agent.automation.PurchaseExecutionInput
import cn.ilapage.goauto.agent.automation.PurchaseLiveAutomation import cn.ilapage.goauto.agent.automation.PurchaseLiveAutomation
import cn.ilapage.goauto.agent.automation.PurchaseOrderEvidence
import cn.ilapage.goauto.agent.automation.PurchaseLiveException import cn.ilapage.goauto.agent.automation.PurchaseLiveException
import cn.ilapage.goauto.agent.automation.PurchaseRuleParser import cn.ilapage.goauto.agent.automation.PurchaseRuleParser
import cn.ilapage.goauto.agent.automation.PurchaseUiDriver import cn.ilapage.goauto.agent.automation.PurchaseUiDriver
@@ -76,7 +77,7 @@ class PurchaseLiveAutomationTest {
assertEquals("PDD-DEMO-365", order?.orderNo) assertEquals("PDD-DEMO-365", order?.orderNo)
assertEquals("2026-10-08T03:21:43Z", order?.submittedAt) assertEquals("2026-10-08T03:21:43Z", order?.submittedAt)
assertEquals(140, observationDriver.captures) assertEquals(141, observationDriver.captures) // 140 + one #392 amount reread
val elapsedAfterBack = observationDriver.elapsed - observationDriver.backCompletedAt!! val elapsedAfterBack = observationDriver.elapsed - observationDriver.backCompletedAt!!
assertTrue(elapsedAfterBack in 28_000L until 30_000L) assertTrue(elapsedAfterBack in 28_000L until 30_000L)
assertTrue(elapsedAfterBack + observationDriver.backDuration > 30_000) assertTrue(elapsedAfterBack + observationDriver.backDuration > 30_000)
@@ -101,7 +102,7 @@ class PurchaseLiveAutomationTest {
val order = observationAutomation(driver).readOrderResult() val order = observationAutomation(driver).readOrderResult()
assertEquals("PDD-DEMO-365", order?.orderNo) assertEquals("PDD-DEMO-365", order?.orderNo)
assertEquals(100, driver.captures) assertEquals(101, driver.captures) // 100 + one #392 amount reread
assertEquals(1, driver.backs) assertEquals(1, driver.backs)
assertEquals(0, driver.swipes) assertEquals(0, driver.swipes)
assertEquals(0, driver.clicks) assertEquals(0, driver.clicks)
@@ -169,7 +170,7 @@ class PurchaseLiveAutomationTest {
}) } }) }
assertEquals("PDD-DEMO-365", observationAutomation(driver).readOrderResult()?.orderNo) assertEquals("PDD-DEMO-365", observationAutomation(driver).readOrderResult()?.orderNo)
assertEquals(100, driver.captures) assertEquals(101, driver.captures) // 100 + one #392 amount reread
assertObservationActions(driver, backs = 1) assertObservationActions(driver, backs = 1)
} }
@@ -286,6 +287,190 @@ class PurchaseLiveAutomationTest {
} }
} }
// ---- #392: one passive re-read when confirmed order evidence lacks the payable amount ----
private val baseOrderLabels = listOf("订单号:PDD-DEMO-392", "下单时间:2026-10-14 11:21:43", "待付款")
private fun rereadFrame(pkg: String?, activity: String, labels: List<String>) = UiSnapshot(
pkg, activity,
labels.map { text ->
SnapshotNode(text, null, text, null, null, "android.widget.TextView",
NodeBounds(0, 100, 500, 180), true, false, false, false, true, true)
},
)
private class RereadRun(
val order: PurchaseOrderEvidence?,
val driver: OrderObservationDriver,
val pauses: List<Long>,
val diagnostics: List<String>,
)
private fun runReread(
failOnSecondCapture: Boolean = false,
second: () -> UiSnapshot = { rereadFrame(PDD, ACTIVITY, baseOrderLabels) },
first: List<String> = baseOrderLabels,
): RereadRun {
val driver = OrderObservationDriver { sample ->
if (sample == 1) rereadFrame(PDD, ACTIVITY, first)
else if (failOnSecondCapture) throw IllegalStateException("capture failed")
else second()
}
val pauses = mutableListOf<Long>()
val diagnostics = mutableListOf<String>()
val order = PurchaseLiveAutomation(
driver,
pause = { pauses += it; driver.elapsed += it },
panelDiagnostic = { diagnostics += it },
monotonicClockMs = { driver.elapsed },
).readOrderResult()
return RereadRun(order, driver, pauses, diagnostics)
}
private fun assertNoRereadActions(run: RereadRun) {
assertEquals(0, run.driver.clicks)
assertEquals(0, run.driver.backs)
assertEquals(0, run.driver.swipes)
assertEquals(0, run.driver.foregroundRequests)
}
@Test
fun `reread skipped when first evidence has amount including zero`() {
for ((amount, expected) in listOf("应付:13.20元" to 1320L, "应付:0元" to 0L)) {
val run = runReread(first = baseOrderLabels + amount)
assertEquals(expected, run.order?.pddOrderAmountCent)
assertEquals(1, run.driver.captures)
assertEquals(emptyList<Long>(), run.pauses)
assertTrue(run.diagnostics.isEmpty())
assertNoRereadActions(run)
}
}
@Test
fun `reread fills amount keeping first order number and time with exactly one pause and capture`() {
val run = runReread(second = { rereadFrame(PDD, ACTIVITY, listOf("应付:13.20元", "待付款")) })
assertEquals("PDD-DEMO-392", run.order?.orderNo)
assertEquals("2026-10-14T03:21:43Z", run.order?.submittedAt)
assertEquals(1320L, run.order?.pddOrderAmountCent)
assertEquals(2, run.driver.captures)
assertEquals(listOf(1_000L), run.pauses)
assertEquals(listOf("orderAmountReread;outcome=filled"), run.diagnostics)
assertNoRereadActions(run)
}
@Test
fun `reread without amount keeps first evidence and does not loop`() {
val run = runReread()
assertEquals("PDD-DEMO-392", run.order?.orderNo)
assertEquals(null, run.order?.pddOrderAmountCent)
assertEquals(2, run.driver.captures)
assertEquals(listOf(1_000L), run.pauses)
assertEquals(listOf("orderAmountReread;outcome=no_payable_label"), run.diagnostics)
assertNoRereadActions(run)
}
@Test
fun `reread ignores new amount when frame shows another order number`() {
val run = runReread(second = { rereadFrame(PDD, ACTIVITY, listOf("订单号:PDD-DEMO-OTHER", "应付:13元", "待付款")) })
assertEquals("PDD-DEMO-392", run.order?.orderNo)
assertEquals("2026-10-14T03:21:43Z", run.order?.submittedAt)
assertEquals(null, run.order?.pddOrderAmountCent)
assertEquals(listOf("orderAmountReread;outcome=order_mismatch"), run.diagnostics)
assertNoRereadActions(run)
}
@Test
fun `reread ignores wechat chooser pure payment foreign package and empty window without actions`() {
val frames = listOf(
"wechat" to rereadFrame("com.tencent.mm", ACTIVITY, listOf("应付:13元", "订单号:PDD-DEMO-392", "待付款")),
"chooser" to rereadFrame("android", "com.android.internal.app.ChooserActivity", listOf("选择要使用的应用", "微信", "应付:13元")),
"payment" to rereadFrame(PDD, "com.xunmeng.pinduoduo.app_pay.core.PayActivity", listOf("应付:13元", "立即支付")),
"foreign" to rereadFrame("com.example.unexpected", ACTIVITY, listOf("应付:13元", "订单号:PDD-DEMO-392", "待付款")),
"empty" to rereadFrame(null, ACTIVITY, emptyList()),
)
for ((name, frame) in frames) {
val run = runReread(second = { frame })
assertEquals(name, "PDD-DEMO-392", run.order?.orderNo)
assertEquals(name, null, run.order?.pddOrderAmountCent)
assertEquals(name, 2, run.driver.captures)
assertEquals(name, listOf("orderAmountReread;outcome=ignored_frame"), run.diagnostics)
assertNoRereadActions(run)
}
}
@Test
fun `reread keeps first conflicting amounts but dedupes identical parent child repeats`() {
val conflict = runReread(
first = baseOrderLabels + listOf("应付:13元", "应付:14元"),
second = { rereadFrame(PDD, ACTIVITY, listOf("应付:13元", "待付款")) },
)
assertEquals("PDD-DEMO-392", conflict.order?.orderNo)
assertEquals(null, conflict.order?.pddOrderAmountCent)
assertEquals(listOf("orderAmountReread;outcome=conflict"), conflict.diagnostics)
assertNoRereadActions(conflict)
val repeated = runReread(second = { rereadFrame(PDD, ACTIVITY, listOf("应付:13元", "应付:13元", "待付款")) })
assertEquals(1300L, repeated.order?.pddOrderAmountCent)
}
@Test
fun `reread overflow amount stays empty and is classified invalid`() {
val run = runReread(second = { rereadFrame(PDD, ACTIVITY, listOf("应付:999999999999999999999元", "待付款")) })
assertEquals("PDD-DEMO-392", run.order?.orderNo)
assertEquals(null, run.order?.pddOrderAmountCent)
assertEquals(listOf("orderAmountReread;outcome=invalid"), run.diagnostics)
}
@Test
fun `reread capture exception returns first evidence`() {
val run = runReread(failOnSecondCapture = true)
assertEquals("PDD-DEMO-392", run.order?.orderNo)
assertEquals("2026-10-14T03:21:43Z", run.order?.submittedAt)
assertEquals(null, run.order?.pddOrderAmountCent)
assertEquals(listOf("orderAmountReread;outcome=error"), run.diagnostics)
assertNoRereadActions(run)
}
@Test
fun `reread diagnostic exception does not affect filled result`() {
val driver = OrderObservationDriver { sample ->
rereadFrame(PDD, ACTIVITY, if (sample == 1) baseOrderLabels else listOf("应付:5元", "待付款"))
}
val order = PurchaseLiveAutomation(driver, pause = {}, panelDiagnostic = { throw IllegalStateException("diag") }).readOrderResult()
assertEquals(500L, order?.pddOrderAmountCent)
assertEquals("PDD-DEMO-392", order?.orderNo)
}
@Test
fun `reread interrupt restores flag and returns first evidence`() {
val driver = OrderObservationDriver { rereadFrame(PDD, ACTIVITY, baseOrderLabels) }
val automation = PurchaseLiveAutomation(driver, pause = { throw InterruptedException() })
try {
val order = automation.readOrderResult()
assertEquals("PDD-DEMO-392", order?.orderNo)
assertTrue(Thread.currentThread().isInterrupted)
} finally {
Thread.interrupted()
}
}
@Test
fun `reread is not triggered when first order evidence is absent`() {
val driver = OrderObservationDriver { observationPage("incomplete") }
val pauses = mutableListOf<Long>()
val diagnostics = mutableListOf<String>()
val automation = PurchaseLiveAutomation(
driver,
pause = { pauses += it; driver.elapsed += it },
panelDiagnostic = { diagnostics += it },
monotonicClockMs = { driver.elapsed },
)
assertEquals(null, automation.readOrderResult())
assertTrue(diagnostics.isEmpty())
assertFalse(pauses.contains(1_000L))
assertEquals(60, driver.captures)
}
private fun observationAutomation(driver: OrderObservationDriver, pauseOverhead: Long = 0) = PurchaseLiveAutomation( private fun observationAutomation(driver: OrderObservationDriver, pauseOverhead: Long = 0) = PurchaseLiveAutomation(
driver, driver,
pause = { driver.elapsed += it + pauseOverhead }, pause = { driver.elapsed += it + pauseOverhead },
@@ -1182,7 +1367,7 @@ class PurchaseLiveAutomationTest {
assertEquals("PDD-202608210001", order?.orderNo) assertEquals("PDD-202608210001", order?.orderNo)
assertEquals(1, driver.pddRestoreCount) assertEquals(1, driver.pddRestoreCount)
assertEquals(13, driver.postSubmitCaptureCount) assertEquals(14, driver.postSubmitCaptureCount) // 13 + one #392 amount reread
assertEquals(0, driver.postSubmitBackCount) assertEquals(0, driver.postSubmitBackCount)
assertFalse(driver.clicked.any { it.contains("微信") || it.contains("登录") || it.contains("支付") }) assertFalse(driver.clicked.any { it.contains("微信") || it.contains("登录") || it.contains("支付") })
} }
@@ -1302,6 +1487,113 @@ class PurchaseLiveAutomationTest {
assertEquals(0, driver.genericSwipes) assertEquals(0, driver.genericSwipes)
} }
@Test
fun `near square purchase panel reveals collapsed address with one bounded swipe`() {
assertClippedPanelAddressRevealed(NodeBounds(0, 400, 1080, 1478))
}
@Test
fun `purchase panel accepts exactly eighty percent height to width`() {
assertClippedPanelAddressRevealed(NodeBounds(0, 400, 1080, 1264))
}
private fun assertClippedPanelAddressRevealed(bounds: NodeBounds) {
val driver = LiveDriver(addressClipped = true, clippedPanelBounds = bounds)
val initial = driver.capture()
val collapsedAddress = initial.nodes.single { it.path == "panel/collapsed-address" }
assertEquals(0, collapsedAddress.bounds.height)
assertFalse(collapsedAddress.visible)
assertEquals(2376, initial.nodes.maxOf { it.bounds.bottom })
val proof = PurchaseLiveAutomation(driver, pause = {}).updateShippingAddress("_cg390")
assertTrue(proof.expectedAddress.endsWith("_cg390"))
assertEquals(listOf(Triple(bounds, SwipeDirection.DOWN, 350L)), driver.scopedSwipeRequests)
assertEquals(1, driver.addressPathClicks)
assertEquals(1, driver.inputCount)
assertEquals(0, driver.genericSwipes + driver.submitClicks)
}
@Test
fun `purchase panel rejects height just below eighty percent and flat horizontal strip`() {
for (height in listOf(863, 400)) {
val driver = LiveDriver(addressClipped = true, clippedPanelBounds = NodeBounds(0, 400, 1080, 400 + height))
val error = runCatching { PurchaseLiveAutomation(driver, pause = {}).updateShippingAddress("_cg390") }
.exceptionOrNull() as PurchaseLiveException
assertEquals("PURCHASE_ADDRESS_ENTRY_AMBIGUOUS", error.code)
assertEquals(0, driver.scopedSwipes + driver.genericSwipes + driver.inputCount + driver.submitClicks)
assertTrue(driver.clicked.isEmpty())
}
}
@Test
fun `purchase panel still requires sixty percent screen width and twenty percent screen height`() {
for (bounds in listOf(NodeBounds(0, 400, 647, 1478), NodeBounds(0, 400, 648, 999))) {
val driver = LiveDriver(addressClipped = true, clippedPanelBounds = bounds, clippedScreenHeight = 3000)
val error = runCatching { PurchaseLiveAutomation(driver, pause = {}).updateShippingAddress("_cg390") }
.exceptionOrNull() as PurchaseLiveException
assertEquals("PURCHASE_ADDRESS_ENTRY_AMBIGUOUS", error.code)
assertEquals(0, driver.scopedSwipes + driver.genericSwipes + driver.inputCount + driver.submitClicks)
assertTrue(driver.clicked.isEmpty())
}
}
@Test
fun `two near square purchase panels remain ambiguous without swiping`() {
val driver = LiveDriver(addressClipped = true, duplicatePanels = true, clippedPanelBounds = NodeBounds(0, 400, 1080, 1478))
val error = runCatching { PurchaseLiveAutomation(driver, pause = {}).updateShippingAddress("_cg390") }
.exceptionOrNull() as PurchaseLiveException
assertEquals("PURCHASE_ADDRESS_ENTRY_AMBIGUOUS", error.code)
assertEquals(0, driver.scopedSwipes + driver.genericSwipes + driver.inputCount + driver.submitClicks)
assertTrue(driver.clicked.isEmpty())
}
@Test
fun `near square purchase panel stops after unchanged viewport without address`() {
assertClippedPanelTimeout(viewportChanges = false, expectedSwipes = 2)
}
@Test
fun `near square purchase panel stops within five rounds without address`() {
assertClippedPanelTimeout(viewportChanges = true, expectedSwipes = 4)
}
private fun assertClippedPanelTimeout(viewportChanges: Boolean, expectedSwipes: Int) {
val bounds = NodeBounds(0, 400, 1080, 1478)
val driver = LiveDriver(
addressClipped = true, clippedPanelBounds = bounds,
keepAddressClipped = true, clippedViewportChanges = viewportChanges,
)
val error = runCatching { PurchaseLiveAutomation(driver, pause = {}).updateShippingAddress("_cg390") }
.exceptionOrNull() as PurchaseLiveException
assertEquals("PURCHASE_ADDRESS_PANEL_TIMEOUT", error.code)
assertEquals(List(expectedSwipes) { Triple(bounds, SwipeDirection.DOWN, 350L) }, driver.scopedSwipeRequests)
assertEquals(0, driver.genericSwipes + driver.inputCount + driver.submitClicks)
assertTrue(driver.clicked.isEmpty())
}
@Test
fun `saved address evidence is restored through near square purchase panel`() {
val bounds = NodeBounds(0, 400, 1080, 1478)
val driver = LiveDriver(saveReturnsToSpecPanel = true, clippedPanelBounds = bounds)
val automation = PurchaseLiveAutomation(driver, pause = {})
val proof = automation.updateShippingAddress("_cg390")
val final = automation.finalConfirmation(input().copy(addressSuffix = "_cg390"), proof)
assertEquals("_cg390", final.addressSuffix)
assertEquals(listOf(Triple(bounds, SwipeDirection.DOWN, 350L)), driver.scopedSwipeRequests)
assertEquals(0, driver.backCount + driver.genericSwipes + driver.submitClicks)
}
@Test @Test
fun `ambiguous purchase panels fail without swiping or clicking`() { fun `ambiguous purchase panels fail without swiping or clicking`() {
val driver = LiveDriver(addressClipped = true, duplicatePanels = true) val driver = LiveDriver(addressClipped = true, duplicatePanels = true)
@@ -1448,6 +1740,10 @@ class PurchaseLiveAutomationTest {
private class LiveDriver( private class LiveDriver(
private val addressClipped: Boolean = false, private val addressClipped: Boolean = false,
private val duplicatePanels: Boolean = false, private val duplicatePanels: Boolean = false,
private val clippedPanelBounds: NodeBounds? = null,
private val clippedScreenHeight: Int = 2376,
private val keepAddressClipped: Boolean = false,
private val clippedViewportChanges: Boolean = false,
private val chooserAfterSubmit: Boolean = false, private val chooserAfterSubmit: Boolean = false,
private val trustedChooser: Boolean = true, private val trustedChooser: Boolean = true,
private val splitConfirmationAddress: Boolean = false, private val splitConfirmationAddress: Boolean = false,
@@ -1480,6 +1776,7 @@ class PurchaseLiveAutomationTest {
val clicked = mutableListOf<String>() val clicked = mutableListOf<String>()
var submitClicks = 0 var submitClicks = 0
var scopedSwipes = 0 var scopedSwipes = 0
val scopedSwipeRequests = mutableListOf<Triple<NodeBounds, SwipeDirection, Long>>()
var genericSwipes = 0 var genericSwipes = 0
var addressTaps = 0 var addressTaps = 0
var addressPathClicks = 0 var addressPathClicks = 0
@@ -1500,7 +1797,21 @@ class PurchaseLiveAutomationTest {
postSubmitCaptureCount++ postSubmitCaptureCount++
if (pendingPostSubmitPages.isNotEmpty()) page = pendingPostSubmitPages.removeAt(0) if (pendingPostSubmitPages.isNotEmpty()) page = pendingPostSubmitPages.removeAt(0)
} }
val snapshot = currentSnapshot() val snapshot = currentSnapshot().let { original ->
if (clippedPanelBounds != null && ((page == "confirmation" && !addressVisible) || page == "post-save-spec")) {
original.copy(nodes = original.nodes.map {
when (it.path) {
"root" -> it.copy(bounds = NodeBounds(0, 0, 1080, clippedScreenHeight))
"panel", "panel2" -> it.copy(bounds = clippedPanelBounds)
else -> it
}
} + node("panel/collapsed-address", "138****5678", parentPath = "panel",
bounds = NodeBounds(20, 400, 400, 400)).copy(visible = false) +
node("panel/viewport-marker", "", parentPath = "panel", bounds = NodeBounds(
20, 800 + if (clippedViewportChanges) scopedSwipes else 0, 300, 850,
)))
} else original
}
if (page == "edit" && inputCount == 0) { if (page == "edit" && inputCount == 0) {
initialEditCaptures++ initialEditCaptures++
return initialEditFrame(snapshot, initialEditCaptures).also { lastInitialEditSnapshot = it } return initialEditFrame(snapshot, initialEditCaptures).also { lastInitialEditSnapshot = it }
@@ -1691,7 +2002,8 @@ class PurchaseLiveAutomationTest {
} }
override fun swipePurchaseIn(target: SnapshotNode, direction: SwipeDirection, durationMs: Long): Boolean { override fun swipePurchaseIn(target: SnapshotNode, direction: SwipeDirection, durationMs: Long): Boolean {
scopedSwipes++ scopedSwipes++
if (target.path == "panel" && direction == SwipeDirection.DOWN) addressVisible = true scopedSwipeRequests += Triple(target.bounds, direction, durationMs)
if (target.path == "panel" && direction == SwipeDirection.DOWN && !keepAddressClipped) addressVisible = true
if (page == "post-save-spec" && direction == SwipeDirection.DOWN && !savedSpecPanelRecoveryStuck) page = "confirmation" if (page == "post-save-spec" && direction == SwipeDirection.DOWN && !savedSpecPanelRecoveryStuck) page = "confirmation"
return true return true
} }
@@ -0,0 +1,130 @@
package cn.ilapage.goauto.agent
import cn.ilapage.goauto.agent.automation.*
import org.junit.Assert.*
import org.junit.Test
/** #393: 无「已选」摘要且无「请选择」时,以无待选提示通过。所有规格与地址均为合成数据。 */
class PurchaseNoPendingPromptProofTest {
// 颜色目标不在屏幕上;尺码目标 XL 可见。
private fun noPrompt(sizeSelected: Boolean = true, extraNode: String? = null): UiSnapshot {
var snapshot = sheet(null, colors = listOf("其他颜色"), sizes = listOf("XL"))
if (sizeSelected) snapshot = snapshot.selected("size/o0/t")
if (extraNode != null) {
val base = snapshot.nodes.first { it.path == "r/sheet/body/summary" }
snapshot = snapshot.copy(nodes = snapshot.nodes + base.copy(path = "r/sheet/body/extra", text = extraNode, visible = true))
}
return snapshot
}
@Test fun `no summary no prompt and offscreen target passes as no_pending_prompt and submits once`() {
val driver = Driver(noPrompt())
val automation = automation(driver)
val evidence = automation.finalConfirmation(input("黑色", "XL"), PROOF)
assertEquals("no_pending_prompt", evidence.specProof)
assertTrue(driver.clicked.isEmpty())
automation.submitOrderOnce()
assertEquals(1, driver.clicked.size)
val error = runCatching { automation.submitOrderOnce() }.exceptionOrNull() as PurchaseLiveException
assertEquals("PURCHASE_SUBMIT_ALREADY_ATTEMPTED", error.code)
assertEquals(1, driver.clicked.size)
}
@Test fun `pending prompt anywhere rejects including traditional`() {
for (text in listOf("请选择 颜色", "请选择: 颜色", " 请 选择颜色", "請選擇 顏色")) {
rejectsMissingProof(noPrompt(extraNode = text), input("黑色", "XL"), text)
}
// 位于摘要节点上的「请选择」同样拒绝
rejectsMissingProof(sheet("请选择 颜色", colors = listOf("其他颜色")).selected("size/o0/t"), input("黑色", "XL"), "summary")
}
@Test fun `target visible but unselected rejects`() {
val snapshot = sheet(null, colors = listOf("黑色"), sizes = listOf("XL")).selected("size/o0/t")
val error = rejects(snapshot, input("黑色", "XL"))
assertTrue(error.message.orEmpty(), error.message.orEmpty().contains("stage=missing_proof"))
}
@Test fun `summary present but different rejects`() {
val snapshot = sheet("已选: 白色 XL", colors = listOf("其他颜色")).selected("size/o0/t")
rejectsMissingProof(snapshot, input("黑色", "XL"), "summary")
}
@Test fun `visible selected option conflicting with target rejects as selected_conflict`() {
val snapshot = sheet(null, colors = listOf("其他颜色"), sizes = listOf("XL")).selected("color/o0/img").selected("size/o0/t")
assertTrue(rejects(snapshot, input("黑色", "XL")).message.orEmpty().contains("stage=selected_conflict"))
}
@Test fun `price and quantity are still enforced after no pending prompt`() {
for ((expected, task) in listOf(
"PURCHASE_QUANTITY_MISMATCH" to input("黑色", "XL").copy(quantity = 2),
"PURCHASE_PRICE_OUT_OF_RANGE" to input("黑色", "XL").copy(maxUnitPriceCent = 1),
)) {
val driver = Driver(noPrompt())
val error = runCatching { automation(driver).finalConfirmation(task, PROOF) }.exceptionOrNull() as PurchaseLiveException
assertEquals(expected, error.code)
assertTrue(driver.clicked.isEmpty())
}
}
@Test fun `391 proofs are unchanged`() {
val selected = sheet(null).selected("color/o0/img").selected("size/o0/t")
assertEquals("selected_option", check(selected, input("黑色", "XL")).specProof)
val summary = sheet("已选: #黑色 XL", colors = listOf("其他颜色"), sizes = listOf("其他尺码"))
assertEquals("selected_summary", check(summary, input("黑色", "XL")).specProof)
}
private fun rejectsMissingProof(snapshot: UiSnapshot, input: PurchaseExecutionInput, label: String) {
val message = rejects(snapshot, input).message.orEmpty()
assertTrue("$label: $message", message.contains("stage=missing_proof"))
}
private fun automation(driver: PurchaseUiDriver, diagnostics: MutableList<String> = mutableListOf()) =
PurchaseLiveAutomation(driver, pause = {}, panelDiagnostic = diagnostics::add)
private fun check(snapshot: UiSnapshot, input: PurchaseExecutionInput, diagnostics: MutableList<String> = mutableListOf()) =
automation(Driver(snapshot), diagnostics).finalConfirmation(input, PROOF)
private fun rejects(snapshot: UiSnapshot, input: PurchaseExecutionInput): PurchaseLiveException {
val driver = Driver(snapshot)
val error = runCatching { automation(driver).finalConfirmation(input, PROOF) }.exceptionOrNull() as PurchaseLiveException
assertEquals("PURCHASE_SPEC_NOT_MATCHED", error.code)
assertTrue(driver.clicked.isEmpty())
return error
}
private fun sheet(
summary: String?, colors: List<String> = listOf("黑色"), sizes: List<String> = listOf("XL"),
): UiSnapshot = SpecPanelFixtures.sheet(SpecPanelFixtures.Sheet(colorLabels = colors, sizeLabels = sizes))
.mapNodes { when (it.path) {
"r/sheet/body/summary" -> it.copy(text = summary, visible = summary != null)
"r/sheet/body/addr/a/detail" -> it.copy(text = ADDRESS)
else -> it
} }
private fun UiSnapshot.mapNodes(transform: (SnapshotNode) -> SnapshotNode) = copy(nodes = nodes.map(transform))
private fun UiSnapshot.selected(suffix: String) = mapNodes {
if (it.path.endsWith(suffix)) it.copy(selected = true) else it
}
private fun input(color: String, size: String) = PurchaseExecutionInput(
taskId = 393, executionMode = "live", phase = "purchase", url = "https://mobile.yangkeduo.com/goods.html?goods_id=393",
goodsId = "393", mappedColor = color, mappedSize = size, quantity = 1,
minUnitPriceCent = 1, maxUnitPriceCent = 3000, addressSuffix = "_cg393",
)
private class Driver(private val snapshot: UiSnapshot) : PurchaseUiDriver {
val clicked = mutableListOf<String>()
override fun capture() = snapshot
override fun clickFresh(target: SnapshotNode): FreshActionResult { clicked += target.path; return FreshActionResult.SUCCESS }
override fun tapPurchaseFresh(target: SnapshotNode): FreshActionResult = error("unexpected tap")
override fun inputFresh(target: SnapshotNode, value: String): FreshActionResult = error("unexpected input")
override fun swipePurchase(direction: SwipeDirection, durationMs: Long): Boolean = error("unexpected swipe")
override fun swipePurchaseIn(target: SnapshotNode, direction: SwipeDirection, durationMs: Long): Boolean = error("unexpected scoped swipe")
override fun backPurchase(): Boolean = error("unexpected back")
}
private companion object {
const val ADDRESS = "示例区示例路391号_cg393"
val PROOF = ShippingAddressProof(ADDRESS, "_cg393")
}
}
@@ -0,0 +1,152 @@
package cn.ilapage.goauto.agent
import cn.ilapage.goauto.agent.automation.*
import org.junit.Assert.*
import org.junit.Test
/** #391: the final verifier reads "已选: #颜色 尺码" summaries. All specs and addresses are synthetic. */
class PurchaseSelectedSummaryProofTest {
private val offscreen = Pair(listOf("其他颜色"), listOf("其他尺码"))
private fun offscreenSheet(summary: String?) = sheet(summary, colors = offscreen.first, sizes = offscreen.second)
// 1. '#' before the value, off-screen options -> summary proof, one click.
@Test fun `hash prefixed summary with offscreen options passes as selected_summary and submits once`() {
val driver = Driver(offscreenSheet("已选: #黑色[九分]小个子 S 建议80-90斤"))
val automation = automation(driver)
val evidence = automation.finalConfirmation(input("黑色【九分】小个子", "S 建议80-90斤"), PROOF)
assertEquals("selected_summary", evidence.specProof)
assertTrue(driver.clicked.isEmpty())
automation.submitOrderOnce()
assertEquals(1, driver.clicked.size)
val error = runCatching { automation.submitOrderOnce() }.exceptionOrNull() as PurchaseLiveException
assertEquals("PURCHASE_SUBMIT_ALREADY_ATTEMPTED", error.code)
assertEquals(1, driver.clicked.size)
}
// 2. Full-width hash, colon and whitespace variants; summary without '#' unchanged.
@Test fun `hash and colon variants pass and plain summary is unchanged`() {
for (summary in listOf("已选: #黑色 XL", "已选:#黑色 XL", "已选:#黑色 XL", "已选 # 黑色 XL", "已选:黑色 XL", "已选 黑色 XL")) {
assertEquals(summary, "selected_summary", check(offscreenSheet(summary), input("黑色", "XL")).specProof)
}
}
// 3. Only the leading '#' is removed.
@Test fun `inner hash is preserved and double leading hash is not matched`() {
assertEquals("selected_summary", check(offscreenSheet("已选:红色#2 XL"), input("红色#2", "XL")).specProof)
assertEquals("selected_summary", check(offscreenSheet("已选: #红色#2 XL"), input("红色#2", "XL")).specProof)
rejectsMissingProof(offscreenSheet("已选: ##黑色 XL"), input("黑色", "XL"))
}
// 4. Longer color still rejected.
@Test fun `hash summary with longer color is still rejected`() {
rejectsMissingProof(offscreenSheet("已选: #黑色加绒 XL"), input("黑色", "XL"))
}
// 5. Original protections still apply with a '#' summary.
@Test fun `selected conflict and composition ambiguity still reject under hash summary`() {
rejects(sheet("已选: #黑色 XL", colors = listOf("黑色", "白色")).selected("color/o1"), input("黑色", "XL"))
rejects(
sheet("已选: #黑色 加绒 XL", colors = listOf("黑色", "黑色 加绒"), sizes = listOf("加绒 XL", "XL")),
input("黑色", "加绒 XL"),
)
}
// 6. Quantity and price are still checked, with no click.
@Test fun `summary proof still enforces quantity and price without clicking`() {
val snapshot = offscreenSheet("已选: #黑色 XL")
for ((expected, task) in listOf(
"PURCHASE_QUANTITY_MISMATCH" to input("黑色", "XL").copy(quantity = 2),
"PURCHASE_PRICE_OUT_OF_RANGE" to input("黑色", "XL").copy(maxUnitPriceCent = 1),
)) {
val driver = Driver(snapshot)
val error = runCatching { automation(driver).finalConfirmation(task, PROOF) }.exceptionOrNull() as PurchaseLiveException
assertEquals(expected, error.code)
assertTrue(driver.clicked.isEmpty())
}
}
// 7. Selected visible options -> selected_option.
@Test fun `selected visible options pass as selected_option`() {
val snapshot = sheet(null).selected("color/o0/img").selected("size/o0/t")
val diagnostics = mutableListOf<String>()
assertEquals("selected_option", check(snapshot, input("黑色", "XL"), diagnostics).specProof)
assertTrue(diagnostics.toString(), diagnostics.contains("finalSpecProof=selected_option"))
}
// 8. The order-submit-started request carries specProof only when provided.
@Test fun `order submit started request body carries specProof`() {
fun body(specProof: String?): String {
val out = java.io.ByteArrayOutputStream()
val connection = object : java.net.HttpURLConnection(java.net.URL("http://127.0.0.1")) {
override fun connect() {}
override fun disconnect() {}
override fun usingProxy() = false
override fun getOutputStream(): java.io.OutputStream = out
override fun getResponseCode(): Int = 500
override fun getErrorStream(): java.io.InputStream = "{}".byteInputStream()
}
val api = cn.ilapage.goauto.agent.network.AgentApiClient("http://127.0.0.1", connectionFactory = { connection })
runCatching { api.markPurchaseOrderSubmitStarted(1, "req-1", "synthetic", specProof) }
return out.toString(Charsets.UTF_8.name())
}
val withProof = org.json.JSONObject(body("selected_summary"))
assertEquals("req-1", withProof.getString("requestId"))
assertEquals("selected_summary", withProof.getString("specProof"))
assertFalse(org.json.JSONObject(body(null)).has("specProof"))
}
private fun automation(driver: PurchaseUiDriver, diagnostics: MutableList<String> = mutableListOf()) =
PurchaseLiveAutomation(driver, pause = {}, panelDiagnostic = diagnostics::add)
private fun check(snapshot: UiSnapshot, input: PurchaseExecutionInput, diagnostics: MutableList<String> = mutableListOf()) =
automation(Driver(snapshot), diagnostics).finalConfirmation(input, PROOF)
private fun rejects(snapshot: UiSnapshot, input: PurchaseExecutionInput): PurchaseLiveException {
val driver = Driver(snapshot)
val error = runCatching { automation(driver).finalConfirmation(input, PROOF) }.exceptionOrNull() as PurchaseLiveException
assertEquals("PURCHASE_SPEC_NOT_MATCHED", error.code)
assertTrue(driver.clicked.isEmpty())
return error
}
private fun rejectsMissingProof(snapshot: UiSnapshot, input: PurchaseExecutionInput) {
assertTrue(rejects(snapshot, input).message.orEmpty().contains("stage=missing_proof"))
}
private fun sheet(
summary: String?, colors: List<String> = listOf("黑色"), sizes: List<String> = listOf("XL"),
): UiSnapshot = SpecPanelFixtures.sheet(SpecPanelFixtures.Sheet(colorLabels = colors, sizeLabels = sizes))
.mapNodes { when (it.path) {
"r/sheet/body/summary" -> it.copy(text = summary, visible = summary != null)
"r/sheet/body/addr/a/detail" -> it.copy(text = ADDRESS)
else -> it
} }
private fun UiSnapshot.mapNodes(transform: (SnapshotNode) -> SnapshotNode) = copy(nodes = nodes.map(transform))
private fun UiSnapshot.selected(suffix: String) = mapNodes {
if (it.path.endsWith(suffix)) it.copy(selected = true) else it
}
private fun input(color: String, size: String) = PurchaseExecutionInput(
taskId = 391, executionMode = "live", phase = "purchase", url = "https://mobile.yangkeduo.com/goods.html?goods_id=391",
goodsId = "391", mappedColor = color, mappedSize = size, quantity = 1,
minUnitPriceCent = 1, maxUnitPriceCent = 3000, addressSuffix = "_cg391",
)
private class Driver(private val snapshot: UiSnapshot) : PurchaseUiDriver {
val clicked = mutableListOf<String>()
override fun capture() = snapshot
override fun clickFresh(target: SnapshotNode): FreshActionResult { clicked += target.path; return FreshActionResult.SUCCESS }
override fun tapPurchaseFresh(target: SnapshotNode): FreshActionResult = error("unexpected tap")
override fun inputFresh(target: SnapshotNode, value: String): FreshActionResult = error("unexpected input")
override fun swipePurchase(direction: SwipeDirection, durationMs: Long): Boolean = error("unexpected swipe")
override fun swipePurchaseIn(target: SnapshotNode, direction: SwipeDirection, durationMs: Long): Boolean = error("unexpected scoped swipe")
override fun backPurchase(): Boolean = error("unexpected back")
}
private companion object {
const val ADDRESS = "示例区示例路391号_cg391"
val PROOF = ShippingAddressProof(ADDRESS, "_cg391")
}
}
+3 -2
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件) generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Architecture-and-Code-Map wiki_page: Architecture-and-Code-Map
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Architecture-and-Code-Map.- wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Architecture-and-Code-Map.-
wiki_revision: 21d6cb0a4d8829ac2dbc4947e7ef7718f237fd2d wiki_revision: 9575d9a43faa8cc5e814e27e34755c77f97b1e84
synchronized_at: 2026-10-12T15:15:52Z synchronized_at: 2026-10-14T02:24:50Z
<!-- gitea-wiki-mirror:end --> <!-- gitea-wiki-mirror:end -->
<!-- gitea-wiki-mirror:start --> <!-- gitea-wiki-mirror:start -->
@@ -152,6 +152,7 @@ Android Portal/Agent
| Admin 采购任务列表、详情与人工处理 | `web/src/views/goauto/purchase-tasks/`、`web/src/api/goauto/purchase-tasks.js`;创建入口不在本模块 | | Admin 采购任务列表、详情与人工处理 | `web/src/views/goauto/purchase-tasks/`、`web/src/api/goauto/purchase-tasks.js`;创建入口不在本模块 |
| 采购任务创建人与列表筛选(#382) | 迁移 `1791700000000_purchase_task_creator.go` 新增 `purchase_task.created_by`(索引)、`created_source`;`purchase/handler.go` `creatorFromContext` 在 Admin 创建/备货/批量创建与客户端密钥请求时写入,Agent 重试路径 `retry.go` `batchRetryCreate` 写 `agent`;`GET /api/admin/v1/purchase-tasks` 新增 `deviceId`(数字或 `unassigned`)、`createdBy`(数字、`agent`、`unrecorded`),列表/详情返回 `createdBy`、`createdByName`、`createdSource`;`GET /api/admin/v1/purchase-tasks/filter-options`(`admin_query.go`)返回任务中出现过的设备与创建人 | | 采购任务创建人与列表筛选(#382) | 迁移 `1791700000000_purchase_task_creator.go` 新增 `purchase_task.created_by`(索引)、`created_source`;`purchase/handler.go` `creatorFromContext` 在 Admin 创建/备货/批量创建与客户端密钥请求时写入,Agent 重试路径 `retry.go` `batchRetryCreate` 写 `agent`;`GET /api/admin/v1/purchase-tasks` 新增 `deviceId`(数字或 `unassigned`)、`createdBy`(数字、`agent`、`unrecorded`),列表/详情返回 `createdBy`、`createdByName`、`createdSource`;`GET /api/admin/v1/purchase-tasks/filter-options`(`admin_query.go`)返回任务中出现过的设备与创建人 |
| SYB 已取消订单拦截(#381) | `purchase/return_match_block.go` `sybCancelled`/`rejectIfSYBCancelled`,错误码 `PURCHASE_SYB_CANCELLED`;拦截点:`service.go` 创建、`batch.go` 预览、`ai_match_eligibility.go`、`collection_eligibility.go`、`task/image_search.go`、`process_stage.go`(阶段 `syb_cancelled`);`returnmatch.participatingStages` 不含该阶段 | | SYB 已取消订单拦截(#381) | `purchase/return_match_block.go` `sybCancelled`/`rejectIfSYBCancelled`,错误码 `PURCHASE_SYB_CANCELLED`;拦截点:`service.go` 创建、`batch.go` 预览、`ai_match_eligibility.go`、`collection_eligibility.go`、`task/image_search.go`、`process_stage.go`(阶段 `syb_cancelled`);`returnmatch.participatingStages` 不含该阶段 |
| 最终规格依据标记(#391) | 迁移 `1791800000000_purchase_order_spec_proof.go` 新增可空列 `purchase_task.order_spec_proof`(`models.PurchaseTask.OrderSpecProof`,不进 JSON 输出);`purchase/handler.go` `OrderSubmitStarted` 使用 `OrderSubmitStartedRequest`,`lifecycle.go` `MarkOrderSubmitStartedWithProof` 在标记下单开始时写入;Android `PurchaseFinalSpecVerifier.verify` 返回依据,`FinalConfirmationEvidence.specProof` 经 `AgentApiClient.markPurchaseOrderSubmitStarted` 上报 |
| Admin AI 规格匹配设置 | `web/src/views/goauto/ai-matching-settings/`、`web/src/api/goauto/ai-matching-settings.js`;管理员可查看、维护和测试 Provider(包括内部明文 API Key);采购员菜单硬排除,仍只可通过受控 API 查看启用状态 | | Admin AI 规格匹配设置 | `web/src/views/goauto/ai-matching-settings/`、`web/src/api/goauto/ai-matching-settings.js`;管理员可查看、维护和测试 Provider(包括内部明文 API Key);采购员菜单硬排除,仍只可通过受控 API 查看启用状态 |
| GoAuto 系统菜单与采购员权限基线 | `server/app/goauto/access/` 统一声明 12 个模块、路由元数据和 Admin API 权限矩阵;迁移 `1787885400000_goauto_menus.go` 幂等维护 `sys_menu`、`sys_menu_api_rule`、采购员默认菜单绑定和 Casbin 固定白名单。`web/src/router/index.js` 只保留公共路由,`web/src/store/modules/permission.js` 在登录时根据 `GET /api/v1/menurole` 返回结果注册业务路由与侧栏;退出、凭据异常和切换角色会清除旧动态路由,未授权深链接回退工作台。菜单可见性与 API 授权彼此独立 | | GoAuto 系统菜单与采购员权限基线 | `server/app/goauto/access/` 统一声明 12 个模块、路由元数据和 Admin API 权限矩阵;迁移 `1787885400000_goauto_menus.go` 幂等维护 `sys_menu`、`sys_menu_api_rule`、采购员默认菜单绑定和 Casbin 固定白名单。`web/src/router/index.js` 只保留公共路由,`web/src/store/modules/permission.js` 在登录时根据 `GET /api/v1/menurole` 返回结果注册业务路由与侧栏;退出、凭据异常和切换角色会清除旧动态路由,未授权深链接回退工作台。菜单可见性与 API 授权彼此独立 |
| Admin 失败采购任务批量重试 | `POST /api/admin/v1/purchase-tasks/batch-retry`;服务端 `server/app/goauto/purchase/retry.go` 负责资格判定、逐项幂等创建与部分成功结果,Admin 页面只允许选择服务端标记可重试的行;不修改 Android Agent | | Admin 失败采购任务批量重试 | `POST /api/admin/v1/purchase-tasks/batch-retry`;服务端 `server/app/goauto/purchase/retry.go` 负责资格判定、逐项幂等创建与部分成功结果,Admin 页面只允许选择服务端标记可重试的行;不修改 Android Agent |
+65 -4
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件) generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Business-Rules-and-Glossary wiki_page: Business-Rules-and-Glossary
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Business-Rules-and-Glossary.- wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Business-Rules-and-Glossary.-
wiki_revision: 5299f7358c54dfcd20ba2a1389946c11e0d8eedb wiki_revision: a36404c6e017b5b3661fe44cbfaf3c334147a0a8
synchronized_at: 2026-10-13T03:19:48Z synchronized_at: 2026-10-14T03:36:47Z
<!-- gitea-wiki-mirror:end --> <!-- gitea-wiki-mirror:end -->
<!-- gitea-wiki-mirror:start --> <!-- gitea-wiki-mirror:start -->
@@ -721,6 +721,16 @@ SYB 回填与资料完整性独立:正式SYB任务已有单号且仍关联有
### 采购手机环境前提 ### 采购手机环境前提
执行采购的 Android 手机必须:PDD App 已登录、微信已登录、PDD 已设置默认收货地址、PDD 默认支付方式为微信支付。规格面板的地址与支付证据依赖该前提;不为支付宝等其他支付方式补充识别别名(有意决定)。条件不满足时 Agent 明确失败,不自动登录、不切换支付方式、不填写地址。 执行采购的 Android 手机必须:PDD App 已登录、微信已登录、PDD 已设置默认收货地址、PDD 默认支付方式为微信支付。规格面板的地址与支付证据依赖该前提;不为支付宝等其他支付方式补充识别别名(有意决定)。条件不满足时 Agent 明确失败,不自动登录、不切换支付方式、不填写地址。
## 采购接受证据齐全的快捷确认面板(#388)
实现 `0d21ca9`,合并 `496bf80`(2026-10-13,Android 0.9.72/85);尚未装机,真机采购效果未验证。
- 背景:地址保存后返回的规格面板可能带「已选」摘要和底部「现在买,仅 ¥…」按钮,被面板解析归为 `QUICK_CONFIRMATION`。采购原先只接受 `NORMAL_SCROLLABLE`、`NON_SCROLLABLE_CONFIRMATION`、`ORDER_CONFIRMATION` 和 `REQUIRED_EVIDENCE`,因此地址已保存成功也会以 `PURCHASE_ADDRESS_SAVE_TIMEOUT`「地址保存后无法返回订单页面」失败(14 份失败快照离线回放证实)。
- 规则:采购侧把 `QUICK_CONFIRMATION` 视为可下单面板,**仅当**同时满足 #331 必需证据:收货地址入口、支付方式入口和唯一可用的数量输入框都在同一规格面板容器内(与 `REQUIRED_EVIDENCE` 同一门槛)。缺任何一项仍不接受。
- 该判断用于地址保存后识别面板、最终确认和点击下单前的按钮定位;保存后直接停在证据完整的快捷确认面板时,会按最终证据直接通过,不再按返回键。
- 不变:采集流程中 `QUICK_CONFIRMATION` 的含义和处理;地址后缀须唯一;下单目标仍只能是面板容器内最底部的可点击整行,含支付方式别名或支付字样即拒绝;规格、单价、数量复核和只点击一次下单均不放宽。
- 诊断:按返回键后仍无法确认时,失败提示末尾附 `[stage=address_save_back;type=…;<必需证据摘要>;saved=0|1;submitTargets=N]`,只含类型、计数和 0/1,不含页面文字。
## 管理端售后角色(#341) ## 管理端售后角色(#341)
- 管理端固定增加 `after_sales`(显示名“售后”)业务角色。售后账号拥有采购员当前权限,并额外可访问“yeeke 退货包裹”和“yeeke 同步记录”两个模块及其接口。 - 管理端固定增加 `after_sales`(显示名“售后”)业务角色。售后账号拥有采购员当前权限,并额外可访问“yeeke 退货包裹”和“yeeke 同步记录”两个模块及其接口。
@@ -802,7 +812,7 @@ Android 0.9.64 / versionCode 77,源码 `6550b9f`(分支实现,尚未安装
实现绑定 `d6639a4`;2026-10-05 已合并 main `4567a24`,在线迁移并发布至 122.228.200.167。旧记录保持未获取,随后按既有正常同步范围更新;不新增采购拦截。 实现绑定 `d6639a4`;2026-10-05 已合并 main `4567a24`,在线迁移并发布至 122.228.200.167。旧记录保持未获取,随后按既有正常同步范围更新;不新增采购拦截。
- “SYB状态”仅显示来源取消标记:`isCancel=1` 为“已取消”,`0` 为“未取消”,没有有效证据为“未获取”。“未取消”不等于可采购,不表达其他 SYB 业务状态,不由 status/orderStatus、规格解析或 PDD 排除标记推断。 - “SYB状态”仅显示来源取消标记:`isCancel=1/2` 均为“已取消”(#389;仅包含该修复的服务端版本),`0` 为“未取消”,没有有效证据为“未获取”。“未取消”不等于可采购,不表达其他 SYB 业务状态,不由 status/orderStatus、规格解析或 PDD 排除标记推断。
- 正常同步成功取得有效值后覆盖原值,允许由取消变回未取消;失败、字段缺失/null、未知值或空页不改变既有取消状态与取得时间。历史记录不默认未取消。 - 正常同步成功取得有效值后覆盖原值,允许由取消变回未取消;失败、字段缺失/null、未知值或空页不改变既有取消状态与取得时间。历史记录不默认未取消。
- 仅更新同步范围内实际导入的明细;超出同步创建日期范围的旧订单保留上次状态,未经同步的显示未获取。此列不是实时核验,列表加载不会请求 SYB。 - 仅更新同步范围内实际导入的明细;超出同步创建日期范围的旧订单保留上次状态,未经同步的显示未获取。此列不是实时核验,列表加载不会请求 SYB。
- 本单严格只做展示:不修改创建、批量、重试、Claim/Start、下单边界、采购资格或任务状态,不自动拦截已取消订单,不影响已存在 PDD 订单或回填事实。后续采购拦截已由 #381 实现(见「SYB 已取消订单不进入 PDD 流程(#381)」)。 - 本单严格只做展示:不修改创建、批量、重试、Claim/Start、下单边界、采购资格或任务状态,不自动拦截已取消订单,不影响已存在 PDD 订单或回填事实。后续采购拦截已由 #381 实现(见「SYB 已取消订单不进入 PDD 流程(#381)」)。
@@ -998,7 +1008,7 @@ Android同步诊断不改变#377的心跳→恢复→补传→必要的一次任
合并 main `c5709a0`,2026-10-12 随 `15bab9c` 发布到线上(Server + Web),待业务验收。 合并 main `c5709a0`,2026-10-12 随 `15bab9c` 发布到线上(Server + Web),待业务验收。
- `syb_product.syb_is_cancelled = true`(同步取得 `isCancel=1`)的明细:不能创建采购任务(`PURCHASE_SYB_CANCELLED`「SYB 订单已取消,无需采购」),批量采购预览、AI 规格匹配资格、批量采集资格给出同一原因,图搜采集批量逐项拒绝,不参与退货匹配。NULL(未获取)与 false 不拦截,不推断。 - `syb_product.syb_is_cancelled = true`(包含 #389 的版本同步取得 `isCancel=1/2`;旧版只识别 1)的明细:不能创建采购任务(`PURCHASE_SYB_CANCELLED`「SYB 订单已取消,无需采购」),批量采购预览、AI 规格匹配资格、批量采集资格给出同一原因,图搜采集批量逐项拒绝,不参与退货匹配。NULL(未获取)与 false 不拦截,不推断。
- 处理阶段新增 `syb_cancelled`「已取消」,优先级:已有任务或订单结果 → 退货匹配 → 已取消 → 无需采购(#340)→ 其余;同时被过滤规则标记的行显示「已取消」,创建接口仍拒绝(返回码可能是 `PURCHASE_PDD_EXCLUDED`)。 - 处理阶段新增 `syb_cancelled`「已取消」,优先级:已有任务或订单结果 → 退货匹配 → 已取消 → 无需采购(#340)→ 其余;同时被过滤规则标记的行显示「已取消」,创建接口仍拒绝(返回码可能是 `PURCHASE_PDD_EXCLUDED`)。
- 同步照常导入并更新取消状态;订单恢复为未取消后阶段与资格自动恢复。 - 同步照常导入并更新取消状态;订单恢复为未取消后阶段与资格自动恢复。
- 不拦截已存在的采购任务(Claim/Start、下单边界与状态机不变);已下单的已取消明细需人工售后。 - 不拦截已存在的采购任务(Claim/Start、下单边界与状态机不变);已下单的已取消明细需人工售后。
@@ -1035,3 +1045,54 @@ Android同步诊断不改变#377的心跳→恢复→补传→必要的一次任
- `release_activate`「设置当前 Agent 版本」:把已上传版本设为当前。设为当前后所有设备启动或手动检查时会提示更新,授权页对此显示风险提示。 - `release_activate`「设置当前 Agent 版本」:把已上传版本设为当前。设为当前后所有设备启动或手动检查时会提示更新,授权页对此显示风险提示。
- 不开放 APK 下载给客户端 API。上传沿用 Admin 的 Manifest 解析、SHA-256、大小与 versionCode 唯一校验;创建人记为授权该密钥的账号。每次调用写客户端审计,不记录文件内容。 - 不开放 APK 下载给客户端 API。上传沿用 Admin 的 Manifest 解析、SHA-256、大小与 versionCode 唯一校验;创建人记为授权该密钥的账号。每次调用写客户端审计,不记录文件内容。
- 设备端安装仍由 Android 系统人工确认;签名与已安装版本不一致的 APK 无法覆盖安装,设为当前前须核对目标设备签名。 - 设备端安装仍由 Android 系统人工确认;签名与已安装版本不一致的 APK 无法覆盖安装,设为当前前须核对目标设备签名。
## SYB 数值取消标记兼容(#389)
实现绑定 `5f329d231157c62f92e2c1348e57ae7ca561fef3`,工单分支 `fix/389-syb-cancel-enum`;未合并 main、未发布,线上现有明细尚未因此修正。
- 来源货运单列表数值 `isCancel`:0「正常」→ false,1「取消」/2「已取消」→ true;同单明细共用该订单级标记。只接受原有 JSON 数字解码,不将其他非零值、字符串、布尔或缺失/null 当作取消。未知值保留已存状态及同步时间。
- 按用户 2026-10-13 确认,以 SYB 取消标记为准,即使平台 `orderStatus` 仍是待出货、已发货或运送中,1/2 仍表示本系统的已取消;反之,只有平台“申请取消”文字而数值为 0 时不推断取消。前端枚举及整单取消动作可证明 2 的含义,不推测 1 必定由哪个平台生成。
- 已知值随既有同步更新取消状态及取得时间,0 可恢复未取消。#381 的新建/展示期限制沿用,不新增领取/开始/下单边界拦截,不自动停止已有 pending/running 采购任务。日期范围和定时周期不变。
- 线上有界重同步须单独授权;此前在受控环境保留受影响明细原值与时间的最小恢复依据,同步后只读报告新增已取消且仍有未完成任务的明细,由用户决定处理。上述生产数据不写入仓库、工单、Wiki 或消息。
- 回退代码不会自动恢复已经同步的 true;后续纠正须有来源事实及独立授权,不通过一律映射 false 来掩盖错误。
## 采购规格面板近正方形滚动区域(#390)
实现 `a3c05a7`,2026-10-14 合并 main `5282d65`(Agent `0.9.73 / code86`,包含 #388);未安装或发布,真机展开顶部地址区域的效果未验证。
- 仅采购 `PurchaseLiveAutomation.purchasePanelScrollTargets` 的形状条件调整为“高度不小于宽度的 80%”,用于改地址前寻找入口、保存地址后恢复当前面板证据。不是所有纵向滚动容器的通用规则,普通采集器不改。
- 继续要求节点可见、启用、可滚动,宽度至少为视口估算宽度的 60%、高度至少为视口估算高度的 20%,且滚动候选必须唯一。扁平横条、尺寸不足及多候选仍明确失败,不选择最大区域兜底。
- 向下滑动的方向、距离、时长、最多五轮采样和视口不变停止规则保持原样;两个调用点的原有失败路径均保留。地址编辑/保存、规格、金额、提交按钮与一次性下单检查不改,不涉及付款。
- 合成回归与失败现场的只读结构尺寸核对只能证明该几何候选不再被错误排除,不能证明在真实 PDD 页面内滑动一定能展开顶部地址。安装及实际采购须另行授权。
## 最终规格复核的「已选」摘要 `#` 前缀与规格依据标记(#391)
实现 `f62f071`,2026-10-14 合并 main `0cb674a`(Agent `0.9.74 / code87`,含 #388、#390;Server 含迁移 `1791800000000_purchase_order_spec_proof.go`);未发布、未迁移、未安装。
- 改完地址后的最终规格复核(`PurchaseFinalSpecVerifier`)原本就接受面板内的「已选」摘要作为依据:摘要经 `normalizeFinalConfirmation` 后须恰好等于「颜色 尺码」(单空格分隔)。采购1 的摘要写作「已选: #颜色 尺码」,解析时只去掉前缀和一个冒号,多出的 `#` 使比较失败(`stage=missing_proof;summaryPresent=1`)。
- 现在解析时,在去掉前缀和可选冒号之后,再去掉开头的空白和**一个** `#` 或 `#`。值内部的 `#` 不动,连续两个 `#` 只去一个。其他判定不变:只读面板内摘要;选中冲突、候选重复、不可选、组合歧义、摘要冲突照旧拒绝;单价、数量、地址、下单按钮检查照旧。
- 规格依据:每个目标维度都有选中选项作证时记 `selected_option`;至少一个维度只靠摘要通过时记 `selected_summary`。Agent 在 `order-submit-started` 请求中携带 `specProof`,服务端写入 `purchase_task.order_spec_proof`;缺失或未知值存 NULL。中断恢复路径不携带,旧 Agent 为 NULL。
- 抽查:装机试运行一段时间后,只读列出 `order_spec_proof='selected_summary'` 的已下单任务,人工对照 PDD 订单的实际规格,判断有无下错。
- 发布顺序:**先服务端(含迁移)、后 Agent**。服务端严格拒绝未知字段,新 Agent 连旧服务端时 `order-submit-started` 会失败;此时不会下单,但所有采购都会失败。回退顺序相反。
- 采购2 部分面板样式不显示「已选」摘要,这类失败本单不能救回。
## 最终规格复核「无待选提示」通过条件(#393)
实现 `8be6b05`,2026-10-14 合并 main `6a9c823`(Agent `0.9.75 / code88`,与 #392 同批);服务端未发布,仅安装到测试机。
- 用户决定:采购员反馈多数失败卡在改地址返回规格面板后的复核,要求放松;在三个选项中选择「没有『请选择』就通过」。
- 改完地址后的最终规格复核(`PurchaseFinalSpecVerifier`)中,某维度既没有选中选项作证、也没有匹配的「已选」摘要时,**同时满足**以下条件可放行该维度:面板范围内没有「已选」摘要;整个窗口所有可见节点(文字和描述)去掉空白后,都不以待选前缀开头(`summaryPrefixes` 减 `selectedPrefixes`,即「请选择」「請選擇」);该维度的目标选项不在屏幕上;面板范围已识别。目标选项可见但未选中、有「请选择」、有「已选」但内容不一致时,仍拒绝。
- 原有拒绝条件不变:选中冲突、候选重复、不可选、摘要冲突、组合歧义。单价、数量、地址、下单按钮检查照旧。
- 依据:任一维度按此放行时为 `no_pending_prompt`,上报到 `order-submit-started` 的 `specProof` 并写入 `purchase_task.order_spec_proof`。拒绝时另外输出诊断 `finalSpecPending;dimension=..;pendingPrompt=0|1;targetVisible=0|1`。
- 依据现场:10-13 无摘要的 10 份快照中,采购2 的这类面板选全后不显示「已选」,未选全时显示「请选择」。离线回放:8 份按 `no_pending_prompt` 通过,2 份「请选择」(选择丢失)仍拒绝;另 11 份有摘要的仍为 `selected_summary`。
- 剩余风险:只能证明「已选全」和「屏幕上无矛盾」,依赖改地址前那次复核确认选对。若改址后 PDD 换成另一套完整规格且该选项恰在屏幕外,可能下错;试运行期间按 `order_spec_proof` 为 `no_pending_prompt`、`selected_summary` 的订单人工抽查。
## 核单缺少订单金额时的一次补读(#392)
实现 `bf8f0a1`,2026-10-14 合并 main `d1094a4`(随 Agent `0.9.75 / code88`);仅安装到测试机,真机效果未验证。
- `readOrderResult` 首次取得订单号和下单时间、但应付金额为空时,不重入原核单循环,只做一次约 1 秒的等待和一次读屏(`ORDER_AMOUNT_REREAD_DELAY_MS`)。首次已有金额(包括 0)时不等待、不补读。
- 补读帧必须是 PDD 页面,且有订单或待付款上下文;否则忽略(微信、系统选择页、陌生包、空窗口)。合格帧与本次核单累积的文字取并集后重新解析:订单号与首次相同、金额唯一合法时才补上金额;订单号和时间始终用首次值。出现其他订单号、金额冲突、溢出、仍无金额或读取异常时,保留首次结果,金额留空。
- 补读路径不点击、不返回、不滑动、不拉前台,不调用 `pageProblem`,不会把已确认的订单降级为失败或未知。诊断只输出 `orderAmountReread;outcome=filled|ignored_frame|order_mismatch|no_payable_label|invalid|conflict|error`。
- 只缓解金额迟到;文案不兼容或跨节点拆分仍会缺金额。近 7 天约 1.3% 的成功订单缺金额。
+3 -3
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件) generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Android-Agent-API-Contract wiki_page: Android-Agent-API-Contract
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Android-Agent-API-Contract.- wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Android-Agent-API-Contract.-
wiki_revision: eb957f366d0561df9af8c89b63c6a2406b3acf6c wiki_revision: ef16dd58b083576b6b58ff3ff0ce5905d1c258ed
synchronized_at: 2026-10-13T03:20:31Z synchronized_at: 2026-10-14T03:37:31Z
<!-- gitea-wiki-mirror:end --> <!-- gitea-wiki-mirror:end -->
<!-- gitea-wiki-mirror:start --> <!-- gitea-wiki-mirror:start -->
@@ -592,7 +592,7 @@ Admin 列表与详情由 #35 实现;#67 增加 `shopeeOrderNoSnapshot` 的列
| `GET` | `/api/agent/v1/purchase-tasks/next` | 优先返回当前设备的运行任务;存在 `spec_probe_pending` 时返回同一等待任务以阻止其他任务插队,否则返回能力兼容的指定任务或空闲任务 | | `GET` | `/api/agent/v1/purchase-tasks/next` | 优先返回当前设备的运行任务;存在 `spec_probe_pending` 时返回同一等待任务以阻止其他任务插队,否则返回能力兼容的指定任务或空闲任务 |
| `POST` | `/api/agent/v1/purchase-tasks/{taskId}/claim` | `requestId` 原子领取,并建立设备/可选账号租约 | | `POST` | `/api/agent/v1/purchase-tasks/{taskId}/claim` | `requestId` 原子领取,并建立设备/可选账号租约 |
| `POST` | `/api/agent/v1/purchase-tasks/{taskId}/start` | 创建不可变 `taskAttemptId` | | `POST` | `/api/agent/v1/purchase-tasks/{taskId}/start` | 创建不可变 `taskAttemptId` |
| `POST` | `/api/agent/v1/purchase-tasks/{taskId}/order-submit-started` | 创建订单前先落不可逆标记;演练任务和 `spec_probe` attempt 永远拒绝 | | `POST` | `/api/agent/v1/purchase-tasks/{taskId}/order-submit-started` | 创建订单前先落不可逆标记;演练任务和 `spec_probe` attempt 永远拒绝。请求体 `requestId` 必填;`specProof` 可选(#391),取值 `selected_summary`(最终规格复核凭「已选」摘要)、`selected_option`(凭选中选项)或 `no_pending_prompt`(#393:无「已选」且无「请选择」、目标不在屏幕时放行),缺失或未知值存 NULL、不拒绝;同一 `requestId` 重放不改已存值;其他未知字段仍拒绝。`claim`、`start` 不接受 `specProof`。响应不返回该字段 |
| `POST` | `/api/agent/v1/purchase-tasks/{taskId}/result` | 请求体携带 `taskAttemptId` 和 `requestId`;幂等提交演练、规格探测、订单或失败结果 | | `POST` | `/api/agent/v1/purchase-tasks/{taskId}/result` | 请求体携带 `taskAttemptId` 和 `requestId`;幂等提交演练、规格探测、订单或失败结果 |
自 #215 起,新建 SYB 采购任务不再从 PDD 档案创建持久匹配工作项,也不在首次派发前调用外部 AI;部署前已存在的 `purchase_spec_match_work_item` 继续按原状态兼容处理。新任务首次 `start` 固定得到 `phase=spec_probe`,Android 通过浏览器打开任务链接一次并经既有结果字段回传当次候选;匹配成功后的第二次 `start` 才得到 `phase=purchase` 和服务端固化的精确 PDD 原始标签。连续第二阶段仅在当前 PDD 页面仍有商品页或规格面板强证据时复用首趟页面、不再次打开浏览器链接;手动同任务重试,或当前为 Agent、其他应用及缺少上述强证据时,Android 重新打开任务 `urlSnapshot`。两种路径都不以标题、goodsId 或页面指纹做严格同页校验,仍必须通过 PDD 包名和商品/规格/订单页面结构安全证据。Android 不接收 AI 配置或自由决策权限。 自 #215 起,新建 SYB 采购任务不再从 PDD 档案创建持久匹配工作项,也不在首次派发前调用外部 AI;部署前已存在的 `purchase_spec_match_work_item` 继续按原状态兼容处理。新任务首次 `start` 固定得到 `phase=spec_probe`,Android 通过浏览器打开任务链接一次并经既有结果字段回传当次候选;匹配成功后的第二次 `start` 才得到 `phase=purchase` 和服务端固化的精确 PDD 原始标签。连续第二阶段仅在当前 PDD 页面仍有商品页或规格面板强证据时复用首趟页面、不再次打开浏览器链接;手动同任务重试,或当前为 Agent、其他应用及缺少上述强证据时,Android 重新打开任务 `urlSnapshot`。两种路径都不以标题、goodsId 或页面指纹做严格同页校验,仍必须通过 PDD 包名和商品/规格/订单页面结构安全证据。Android 不接收 AI 配置或自由决策权限。
+10 -3
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件) generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: SYB-ERP-Interface-Contract wiki_page: SYB-ERP-Interface-Contract
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/SYB-ERP-Interface-Contract.- wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/SYB-ERP-Interface-Contract.-
wiki_revision: 5979a14250c96c3c92348a947c2bc8d3a078c9f4 wiki_revision: 3f44b3ed91ff19c592bba4e2dbbef28d971633da
synchronized_at: 2026-09-29T08:18:40Z synchronized_at: 2026-10-13T10:03:50Z
<!-- gitea-wiki-mirror:end --> <!-- gitea-wiki-mirror:end -->
# 12 顺云宝(SYB)ERP 接口契约 # 12 顺云宝(SYB)ERP 接口契约
@@ -272,7 +272,7 @@ ID,或某张货运单返回空商品明细,都视为不完整并停止当前
| `shopName` | `<店铺名>` | 蝦皮店铺 | | `shopName` | `<店铺名>` | 蝦皮店铺 |
| `productName` | `純棉上衣` | **只有一个商品名**,一单多商品时不完整 | | `productName` | `純棉上衣` | **只有一个商品名**,一单多商品时不完整 |
| `orderQty` / `detailQty` | `2` / `2` | 商品件数 | | `orderQty` / `detailQty` | `2` / `2` | 商品件数 |
| `isCancel` | `0` | 是否取消 | | `isCancel` | `0` / `1` / `2` | 订单级取消标记:0 正常,1 取消,2 已取消。包含 #389 的服务端将 1/2 均映射 true、0 映射 false;未知值保留旧值,不从 status/orderStatus 推断。旧版只识别 0/1。 |
| `expCompany` | `蝦皮店到店` | 物流方式 | | `expCompany` | `蝦皮店到店` | 物流方式 |
| `receiver` / `receiverTel` / `receiverAddr` | — | **收件人信息,属个人数据** | | `receiver` / `receiverTel` / `receiverAddr` | — | **收件人信息,属个人数据** |
@@ -709,3 +709,10 @@ POST /am/stock/detail/updateDetailPurchaseCode,无JSON body;query:id=<stoc
客户端每次处理最多一次写请求,并拒绝HTTP重定向重发;超时/连接中断/5xx/损坏响应作为结果未知。无论写响应成功或未知,都须按 stockId + detailId 唯一回读目标;相同单号+pdd才确认为成功,不同已有单号或其他非空平台记冲突且不覆盖。写前为空才写;目标缺失/重复/字段类型异常不得猜测选择。未知且未回读确认时持久化unknown,人工补偿前再次回读,不使用自动写重试。 客户端每次处理最多一次写请求,并拒绝HTTP重定向重发;超时/连接中断/5xx/损坏响应作为结果未知。无论写响应成功或未知,都须按 stockId + detailId 唯一回读目标;相同单号+pdd才确认为成功,不同已有单号或其他非空平台记冲突且不覆盖。写前为空才写;目标缺失/重复/字段类型异常不得猜测选择。未知且未回读确认时持久化unknown,人工补偿前再次回读,不使用自动写重试。
GoAuto自身通过持久租约串行,但SYB没有CAS证据,无法原子隔离系统外客户端;不能保证任意迟到请求已终止。真实接口写入需另行明确授权;当前验证仅fake/httptest。 GoAuto自身通过持久租约串行,但SYB没有CAS证据,无法原子隔离系统外客户端;不能保证任意迟到请求已终止。真实接口写入需另行明确授权;当前验证仅fake/httptest。
### isCancel 枚举核验(#389,2026-10-13)
来源前端 `/sys/js/app.226509c4.js` 的 `cances` 定义 0「正常」、1「取消」、2「已取消」;`/sys/js/stock.b4a33aea.js` 用该枚举渲染状态,整单取消对选中的货运单 ID 设值 2,恢复设值 0,平台取消是另一项可选动作。此次只读核验,没有调用取消或恢复接口。该事实不构成本系统新增调用这些写接口的授权。
经用户确认,GoAuto 的取消判断以该数值为准,不受平台订单/物流文字影响;含 #389 的实现 `5f329d231157c62f92e2c1348e57ae7ca561fef3` 尚未合并发布。字段类型、同步日期范围、数据库及下游 API 不变;来源未知值不覆盖既有状态和时间,不能靠重新同步未知值撤销已写入的 true。
+15 -2
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件) generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Deployment-and-Operations wiki_page: Deployment-and-Operations
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Deployment-and-Operations.- wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Deployment-and-Operations.-
wiki_revision: 19eaaa2853544e73f4abc5b788b571377e87296b wiki_revision: f240867d7d664855c9e45930c092a11600b7cb8c
synchronized_at: 2026-10-13T03:49:33Z synchronized_at: 2026-10-14T04:06:57Z
<!-- gitea-wiki-mirror:end --> <!-- gitea-wiki-mirror:end -->
<!-- gitea-wiki-mirror:start --> <!-- gitea-wiki-mirror:start -->
@@ -469,3 +469,16 @@ Server实现 `d84728d`、Android实现 `dbe7e98`,main 合并记录见 #378;
- 按内容验收:`/`、`/login` 返回新 index(含 `id="app"`、无欢迎页),10 项 JS/CSS 与新采集任务页资源均 200,验证码 code=200,未登录访问 `collection-tasks/batch-cancel`、`yeeke-returns` 为业务码 401;重启后日志无 panic/fatal/1146/1054;采购1、采购2 心跳恢复在线(Agent 0.9.71)。 - 按内容验收:`/`、`/login` 返回新 index(含 `id="app"`、无欢迎页),10 项 JS/CSS 与新采集任务页资源均 200,验证码 code=200,未登录访问 `collection-tasks/batch-cancel`、`yeeke-returns` 为业务码 401;重启后日志无 panic/fatal/1146/1054;采购1、采购2 心跳恢复在线(Agent 0.9.71)。
- 未做:线上管理员与采购员账号的功能检查(待用户验收)。 - 未做:线上管理员与采购员账号的功能检查(待用户验收)。
- 回滚:current 原子切回 `/home/goauto/releases/20261012-07800cf-384` 并重启(先确认无执行中任务);无数据库变化。 - 回滚:current 原子切回 `/home/goauto/releases/20261012-07800cf-384` 并重启(先确认无执行中任务);无数据库变化。
## #391/#393 Server 发布与迁移(2026-10-14)
- 起因:采购1、采购2 先装上 Agent 0.9.75(含 #391/#393,会在 `order-submit-started` 中携带 `specProof`),线上 Server 还是 `5183df3`,严格解码拒绝该字段(`PURCHASE_INVALID_REQUEST`)。Agent 因此不点击下单,但本地已记录「开始下单」,下一轮按「恢复中断的下单」重发了不带 `specProof` 的标记,服务端随即落下不可逆标记;之后找不到订单页,任务被误标为 `order_result_unknown`。受影响任务:采购1 3054、3055、3056;采购2 3057、3058、3060、3061、3063;3062 同样被误标,后在取消排队任务时改为 `cancelled`。现场快照表明失败时页面仍停在 PDD 规格面板,没有订单页证据,推断没有下单;仍需人工在 PDD 待付款中核对。
- `[必须]` 发布顺序:带 Agent 接口新字段的版本,**先发 Server,再装 Agent**;回退时先回退 Agent,再回退 Server。
- 用户授权「1」(发布服务端)以及「你先取消掉线上采购2的任务,再继续」。采购2 在 11:55 下发的排队任务 3064–3078 用临时程序调用 `purchase.Service.Cancel` 取消(与 `5183df3` 模型一致;用 main 模型构建的第一版因缺少新列查询报 1054,事务回滚,没有写入数据),原因写为「服务端与 Agent 0.9.75 版本不匹配,发布服务端前暂停」,操作人 `1`。临时程序用后删除,未提交。
- main `df640ec`。相对线上 `5183df3`,Server 只有 #391、#393 的改动(`order-submit-started` 独立请求结构、`specProof` 枚举、迁移 `1791800000000`),Web 无变化。`go test -count=1 ./app/goauto/purchase/... ./cmd/migrate/...` 通过;Linux amd64 `CGO_ENABLED=0` 构建 goauto-server,SHA256 `f042114a2cc0d9e85a0ea3da4d4c5f9e86010e48f052a44baf99ccd89f6aac74`。
- 发布目录 `/home/goauto/releases/20261014-df640ec-391-393`:由上一发布 `cp -a` 复制,只替换 goauto-server 并写入 RELEASE。config、dist 与上一发布 diff 一致,static/temp/var 链接沿用,目录 0755,已用 `www` 验证可读。
- 备份:`/home/goauto/backups/20261014-391-393/database.sql`(约 121 MB,目录 0700、文件 0600,`mysqldump --single-transaction --no-tablespaces --routines`,退出码 0,末尾含 `Dump completed`)。备份脚本 `/root/goauto-backup-20261014.sh` 在运行时读取 `GOAUTO_DB_DSN`,不输出凭据。
- 迁移:确认没有执行中的采集、采购任务后,用新发布目录执行 `/root/goauto-migrate-20261012.sh`,退出码 0,日志 `migrate.log`(0600)。回读:`sys_migration` 新增 `1791800000000`;`purchase_task.order_spec_proof` 为 `varchar(32)`、可空、默认 NULL;3068 行完好,新列全部为 NULL。
- 切换:`/root/goauto-switch-393.sh`(由 #387 脚本改目录和日志而来)后台运行。12:04:13 开始等待,12:04:29 原子切换 current 并重启,`goauto.service` active,运行中的程序哈希与构建一致;日志 `switch.log`。部署期间 SSH 多次被服务器断开,切换脚本因此改为 `nohup setsid` 后台运行,再轮询日志。
- 按内容验收:`/`、`/login` 返回 index(SHA256 前缀 `dfd9dadf`,与上一发布相同,含 `id="app"`、无欢迎页),10 项 JS/CSS 200,验证码 code=200;未带凭证访问 Agent 的 `order-submit-started` 返回 `DEVICE_TOKEN_INVALID`,访问 Admin 采购列表返回 401;重启后日志中 panic、fatal、1146、1054 为 0。12:05 时采购1、采购2 都在线(Agent 0.9.75)。
- 回滚:先确认没有执行中的任务,并把采购机回退到不带 `specProof` 的 Agent(0.9.73 或更早),再把 current 原子切回 `/home/goauto/releases/20261013-5183df3-387` 并重启。新增列保留,不影响旧代码,不回退数据库。
+9 -2
View File
@@ -24,6 +24,10 @@ const (
PurchaseTaskStatusFailed = "failed" PurchaseTaskStatusFailed = "failed"
PurchaseTaskStatusCancelled = "cancelled" PurchaseTaskStatusCancelled = "cancelled"
PurchaseOrderSpecProofSelectedSummary = "selected_summary"
PurchaseOrderSpecProofSelectedOption = "selected_option"
PurchaseOrderSpecProofNoPendingPrompt = "no_pending_prompt"
PurchaseAttemptPhaseSpecProbe = "spec_probe" PurchaseAttemptPhaseSpecProbe = "spec_probe"
PurchaseAttemptPhasePurchase = "purchase" PurchaseAttemptPhasePurchase = "purchase"
@@ -135,8 +139,11 @@ type PurchaseTask struct {
CreateRequestID string `json:"-" gorm:"size:36;not null;uniqueIndex:ux_purchase_task_create_request_id"` CreateRequestID string `json:"-" gorm:"size:36;not null;uniqueIndex:ux_purchase_task_create_request_id"`
ClaimRequestID *string `json:"-" gorm:"size:36;uniqueIndex:ux_purchase_task_claim_request_id"` ClaimRequestID *string `json:"-" gorm:"size:36;uniqueIndex:ux_purchase_task_claim_request_id"`
OrderSubmitRequestID *string `json:"-" gorm:"size:64;uniqueIndex:ux_purchase_task_order_submit_request_id"` OrderSubmitRequestID *string `json:"-" gorm:"size:64;uniqueIndex:ux_purchase_task_order_submit_request_id"`
StatusVersion uint64 `json:"statusVersion" gorm:"not null;default:1"` // OrderSpecProof 记录下单前最终规格复核的依据(#391):selected_summary=凭「已选」
StatusChangedAt time.Time `json:"statusChangedAt" gorm:"not null"` // 摘要,selected_option=原检查;NULL=旧 Agent 或未提供。仅供人工抽查,不进任何 JSON 输出。
OrderSpecProof *string `json:"-" gorm:"size:32"`
StatusVersion uint64 `json:"statusVersion" gorm:"not null;default:1"`
StatusChangedAt time.Time `json:"statusChangedAt" gorm:"not null"`
PDDOrderNo *string `json:"pddOrderNo" gorm:"size:100;index"` PDDOrderNo *string `json:"pddOrderNo" gorm:"size:100;index"`
OrderSubmittedAt *time.Time `json:"orderSubmittedAt"` OrderSubmittedAt *time.Time `json:"orderSubmittedAt"`
+22 -3
View File
@@ -331,9 +331,28 @@ func (h Handler) AgentReset(c *gin.Context) {
c.Header("Cache-Control", "no-store") c.Header("Cache-Control", "no-store")
c.JSON(http.StatusOK, gin.H{"data": result}) c.JSON(http.StatusOK, gin.H{"data": result})
} }
func (h Handler) Claim(c *gin.Context) { h.action(c, (*Service).Claim) } func (h Handler) Claim(c *gin.Context) { h.action(c, (*Service).Claim) }
func (h Handler) Start(c *gin.Context) { h.action(c, (*Service).Start) } func (h Handler) Start(c *gin.Context) { h.action(c, (*Service).Start) }
func (h Handler) OrderSubmitStarted(c *gin.Context) { h.action(c, (*Service).MarkOrderSubmitStarted) } func (h Handler) OrderSubmitStarted(c *gin.Context) {
id, ok := pathID(c)
if !ok {
return
}
var req OrderSubmitStartedRequest
if !decode(c, &req) {
return
}
service, serviceOK := h.service(c)
if !serviceOK {
return
}
p, err := service.MarkOrderSubmitStartedWithProof(c.Request.Context(), id, req, bearer(c.GetHeader("Authorization")))
if err != nil {
writeError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"data": p})
}
func (h Handler) Result(c *gin.Context) { func (h Handler) Result(c *gin.Context) {
id, ok := pathID(c) id, ok := pathID(c)
if !ok { if !ok {
+10
View File
@@ -274,6 +274,12 @@ func purchaseRuleSnapshotHash(snapshot string) string {
} }
func (s *Service) MarkOrderSubmitStarted(ctx context.Context, taskID uint64, req ActionRequest, token string) (TaskPayload, error) { func (s *Service) MarkOrderSubmitStarted(ctx context.Context, taskID uint64, req ActionRequest, token string) (TaskPayload, error) {
return s.MarkOrderSubmitStartedWithProof(ctx, taskID, OrderSubmitStartedRequest{RequestID: req.RequestID}, token)
}
// MarkOrderSubmitStartedWithProof 在标记下单开始的同一次保存里记录最终规格依据(#391)。
// 依据只认已知枚举,缺失或未知值存 NULL,不影响下单边界标记;重放不改已存值。
func (s *Service) MarkOrderSubmitStartedWithProof(ctx context.Context, taskID uint64, req OrderSubmitStartedRequest, token string) (TaskPayload, error) {
return s.withRunning(ctx, taskID, token, func(tx *gorm.DB, t *models.PurchaseTask, a *models.PurchaseTaskAttempt, d models.AgentDevice) (TaskPayload, error) { return s.withRunning(ctx, taskID, token, func(tx *gorm.DB, t *models.PurchaseTask, a *models.PurchaseTaskAttempt, d models.AgentDevice) (TaskPayload, error) {
if t.ExecutionMode != models.PurchaseExecutionModeLive { if t.ExecutionMode != models.PurchaseExecutionModeLive {
return TaskPayload{}, fail(CodeStateConflict, "演练任务不能创建订单") return TaskPayload{}, fail(CodeStateConflict, "演练任务不能创建订单")
@@ -295,6 +301,10 @@ func (s *Service) MarkOrderSubmitStarted(ctx context.Context, taskID uint64, req
return TaskPayload{}, internal(e) return TaskPayload{}, internal(e)
} }
t.OrderSubmitRequestID = &req.RequestID t.OrderSubmitRequestID = &req.RequestID
if proof := req.SpecProof; proof == models.PurchaseOrderSpecProofSelectedSummary || proof == models.PurchaseOrderSpecProofSelectedOption ||
proof == models.PurchaseOrderSpecProofNoPendingPrompt {
t.OrderSpecProof = &proof
}
t.IrreversibleAt = &now t.IrreversibleAt = &now
t.StatusVersion++ t.StatusVersion++
t.StatusChangedAt = now t.StatusChangedAt = now
@@ -0,0 +1,109 @@
package purchase
import (
"bytes"
"context"
"net/http"
"net/http/httptest"
"strconv"
"testing"
"time"
"go-admin/app/goauto/models"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"gorm.io/gorm"
)
func TestMarkOrderSubmitStartedRecordsSpecProof(t *testing.T) {
for _, tc := range []struct {
name string
proof string
want *string
}{
{"summary", "selected_summary", ptrString("selected_summary")},
{"no pending prompt", "no_pending_prompt", ptrString("no_pending_prompt")},
{"option", "selected_option", ptrString("selected_option")},
{"missing", "", nil},
{"unknown", "something_else", nil},
} {
t.Run(tc.name, func(t *testing.T) {
db := testDB(t)
f := seed(t, db, liveCaps(), true)
s := testService(db)
task, err := createLive(t, s, f)
if err != nil {
t.Fatal(err)
}
startLivePurchaseAfterProbe(t, s, f, task)
reqID := uuid.NewString()
if _, err = s.MarkOrderSubmitStartedWithProof(context.Background(), task.ID, OrderSubmitStartedRequest{RequestID: reqID, SpecProof: tc.proof}, f.token); err != nil {
t.Fatal(err)
}
assertSpecProof(t, db, task.ID, tc.want)
// Same requestId replay with a different proof must not change the stored value.
replay, err := s.MarkOrderSubmitStartedWithProof(context.Background(), task.ID, OrderSubmitStartedRequest{RequestID: reqID, SpecProof: "selected_option"}, f.token)
if err != nil || !replay.Replayed {
t.Fatalf("replay failed: %+v %v", replay, err)
}
assertSpecProof(t, db, task.ID, tc.want)
})
}
}
func TestOrderSubmitStartedHandlerSpecProofAndStrictDecode(t *testing.T) {
db := testDB(t)
f := seed(t, db, liveCaps(), true)
s := testService(db)
task, err := createLive(t, s, f)
if err != nil {
t.Fatal(err)
}
startLivePurchaseAfterProbe(t, s, f, task)
// The handler builds its own service with the real clock; keep the lease valid.
future := time.Now().Add(time.Hour)
if err = db.Session(&gorm.Session{SkipHooks: true}).Model(&models.PurchaseTask{}).Where("id = ?", task.ID).Update("lease_expires_at", future).Error; err != nil {
t.Fatal(err)
}
h := Handler{DB: db}
post := func(fn gin.HandlerFunc, body string) *httptest.ResponseRecorder {
gin.SetMode(gin.TestMode)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "taskId", Value: strconv.FormatUint(task.ID, 10)}}
c.Request = httptest.NewRequest(http.MethodPost, "/", bytes.NewBufferString(body))
c.Request.Header.Set("Authorization", "Bearer "+f.token)
fn(c)
return w
}
// Claim/Start keep rejecting specProof as an unknown field.
for name, fn := range map[string]gin.HandlerFunc{"claim": h.Claim, "start": h.Start} {
if w := post(fn, `{"requestId":"`+uuid.NewString()+`","specProof":"selected_summary"}`); w.Code != http.StatusUnprocessableEntity {
t.Fatalf("%s accepted specProof: %d %s", name, w.Code, w.Body.String())
}
}
// Unknown extra fields are still rejected on order-submit-started.
if w := post(h.OrderSubmitStarted, `{"requestId":"`+uuid.NewString()+`","other":1}`); w.Code != http.StatusUnprocessableEntity {
t.Fatalf("unknown field accepted: %d", w.Code)
}
w := post(h.OrderSubmitStarted, `{"requestId":"`+uuid.NewString()+`","specProof":"selected_summary"}`)
if w.Code != http.StatusOK {
t.Fatalf("order-submit-started failed: %d %s", w.Code, w.Body.String())
}
assertSpecProof(t, db, task.ID, ptrString("selected_summary"))
if bytes.Contains(w.Body.Bytes(), []byte("orderSpecProof")) || bytes.Contains(w.Body.Bytes(), []byte("specProof")) {
t.Fatalf("proof leaked into response: %s", w.Body.String())
}
}
func assertSpecProof(t *testing.T, db *gorm.DB, id uint64, want *string) {
t.Helper()
var saved models.PurchaseTask
if err := db.First(&saved, id).Error; err != nil {
t.Fatal(err)
}
if (saved.OrderSpecProof == nil) != (want == nil) || (want != nil && *saved.OrderSpecProof != *want) {
t.Fatalf("order_spec_proof=%v want %v", saved.OrderSpecProof, want)
}
}
+7
View File
@@ -154,6 +154,13 @@ type ActionRequest struct {
RequestID string `json:"requestId"` RequestID string `json:"requestId"`
} }
// OrderSubmitStartedRequest 只用于 order-submit-started;specProof 可选(#391),
// 领取、开始接口仍用 ActionRequest,不接受该字段。
type OrderSubmitStartedRequest struct {
RequestID string `json:"requestId"`
SpecProof string `json:"specProof,omitempty"`
}
type TaskPayload struct { type TaskPayload struct {
TaskID uint64 `json:"taskId"` TaskID uint64 `json:"taskId"`
TaskAttemptID string `json:"taskAttemptId,omitempty"` TaskAttemptID string `json:"taskAttemptId,omitempty"`
@@ -0,0 +1,29 @@
package version_local
import (
"runtime"
"go-admin/app/goauto/models"
"go-admin/cmd/migrate/migration"
common "go-admin/common/models"
"gorm.io/gorm"
)
func init() {
_, file, _, _ := runtime.Caller(0)
migration.Migrate.SetVersion(migration.GetFilename(file), MigratePurchaseOrderSpecProof)
}
// MigratePurchaseOrderSpecProof only adds the nullable #391 order_spec_proof
// column to purchase_task. Existing rows are not rewritten. The step is
// guarded so a partial run can be re-run safely.
func MigratePurchaseOrderSpecProof(db *gorm.DB, version string) error {
m := db.Migrator()
if !m.HasColumn(&models.PurchaseTask{}, "OrderSpecProof") {
if err := m.AddColumn(&models.PurchaseTask{}, "OrderSpecProof"); err != nil {
return err
}
}
return db.Where("version = ?", version).FirstOrCreate(&common.Migration{Version: version}).Error
}
@@ -0,0 +1,53 @@
package version_local
import (
"testing"
"go-admin/app/goauto/models"
common "go-admin/common/models"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
func TestMigratePurchaseOrderSpecProofAddsNullableColumnAndIsIdempotent(t *testing.T) {
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
if err != nil {
t.Fatal(err)
}
conn, _ := db.DB()
defer conn.Close()
if err = db.AutoMigrate(&common.Migration{}); err != nil {
t.Fatal(err)
}
if err = db.Exec("CREATE TABLE purchase_task (id integer primary key, status text NOT NULL)").Error; err != nil {
t.Fatal(err)
}
if err = db.Exec("INSERT INTO purchase_task(id,status) VALUES(1,'failed')").Error; err != nil {
t.Fatal(err)
}
for i := 0; i < 2; i++ {
if err = MigratePurchaseOrderSpecProof(db, "test_391"); err != nil {
t.Fatal(err)
}
}
if !db.Migrator().HasColumn(&models.PurchaseTask{}, "OrderSpecProof") {
t.Fatal("missing order_spec_proof column")
}
var row struct {
Status string
OrderSpecProof *string
}
if err = db.Table("purchase_task").Select("status, order_spec_proof").Where("id = 1").Scan(&row).Error; err != nil {
t.Fatal(err)
}
if row.Status != "failed" || row.OrderSpecProof != nil {
t.Fatalf("legacy row changed: %+v", row)
}
var count int64
db.Model(&common.Migration{}).Where("version = ?", "test_391").Count(&count)
if count != 1 {
t.Fatal("version not idempotent")
}
}