feat(#36): add guarded Android live purchase

This commit is contained in:
QiuSW
2026-08-21 11:06:26 +08:00
parent 7e4f404265
commit 6a0dfed757
15 changed files with 549 additions and 51 deletions
+2 -2
View File
@@ -11,8 +11,8 @@ android {
applicationId = "cn.ilapage.goauto.agent"
minSdk = 23
targetSdk = 34
versionCode = 1
versionName = "0.1.0"
versionCode = 2
versionName = "0.2.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
@@ -184,6 +184,8 @@ class GoAutoAccessibilityService : AccessibilityService(), UiDriver, PddCollecto
return swipeNode(target, direction, durationMs, preferScrollAction = false)
}
override fun backPurchase(): Boolean = performGlobalAction(GLOBAL_ACTION_BACK)
override fun swipeSpec(direction: SwipeDirection, anchor: SnapshotNode?): Boolean {
if (anchor == null) return swipe(SemanticTarget.SPEC_PANEL, direction)
val root = rootInActiveWindow ?: return false
@@ -0,0 +1,220 @@
package cn.ilapage.goauto.agent.automation
import java.text.SimpleDateFormat
import java.util.Locale
import java.util.TimeZone
data class ShippingAddressProof internal constructor(
internal val expectedAddress: String,
val suffix: String,
)
data class FinalConfirmationEvidence(
val goodsId: String,
val mappedColor: String,
val mappedSize: String,
val quantity: Long,
val unitPriceCent: Long,
val addressSuffix: String,
val activityName: String,
)
data class PurchaseOrderEvidence(val orderNo: String, val submittedAt: String)
class PurchaseLiveException(val code: String, message: String) : IllegalStateException(message)
/**
* The only Android component allowed to mutate a shipping address or click the
* final order button. It never exposes payment actions and never persists the
* address or accessibility tree.
*/
class PurchaseLiveAutomation(
private val driver: PurchaseUiDriver,
private val pause: (Long) -> Unit = Thread::sleep,
) {
private var submitAttempted = false
fun updateShippingAddress(addressSuffix: String): ShippingAddressProof {
if (!addressSuffix.matches(Regex("^_cg[1-9][0-9]*$"))) fail("PURCHASE_ADDRESS_UPDATE_FAILED", "采购任务的地址标记无效")
var snapshot = driver.capture()
repeat(4) { attempt ->
pageProblem(snapshot)
val entries = uniqueClickable(snapshot, snapshot.nodes.filter { it.visible && MASKED_PHONE.containsMatchIn(it.label) })
if (entries.size == 1) {
click(entries.single(), "收货地址入口")
snapshot = waitFor { it.nodes.any { node -> node.visible && node.label.replace(" ", "") == "收货地址" } }
return editAndVerifyAddress(snapshot, addressSuffix)
}
if (entries.size > 1) fail("PURCHASE_ADDRESS_UPDATE_FAILED", "收货地址入口不唯一,未创建订单")
if (attempt < 3 && !driver.swipePurchase(SwipeDirection.DOWN, 350)) {
fail("PURCHASE_ADDRESS_UPDATE_FAILED", "没有找到收货地址入口,未创建订单")
}
pause(500)
snapshot = driver.capture()
}
fail("PURCHASE_ADDRESS_UPDATE_FAILED", "没有找到收货地址入口,未创建订单")
}
fun finalConfirmation(input: PurchaseExecutionInput, address: ShippingAddressProof): FinalConfirmationEvidence {
val snapshot = driver.capture()
pageProblem(snapshot)
if (snapshot.packageName != PDD_PACKAGE) fail("PURCHASE_CONFIRMATION_LOST", "最终提交前页面已经变化,禁止创建订单")
if (snapshot.nodes.none { it.visible && it.label.contains(address.expectedAddress) }) {
fail("PURCHASE_ADDRESS_UPDATE_FAILED", "收货地址保存后复核失败,未创建订单")
}
val submit = finalSubmitTargets(snapshot)
if (submit.size != 1) fail("PURCHASE_SUBMIT_TARGET_AMBIGUOUS", "创建订单按钮不是唯一目标,禁止创建订单")
val screen = PddScreenParser.parse(snapshot, PurchaseRehearsalExecutor.DEFAULT_COLLECTOR, input.goodsId, null)
val selected = listOf(input.mappedColor, input.mappedSize).filter(String::isNotBlank)
if (selected.any { value -> screen.selectedSummary?.contains(value) != true && snapshot.nodes.none { it.visible && it.label.contains(value) } }) {
fail("PURCHASE_SPEC_NOT_MATCHED", "创建订单前规格复核失败")
}
val price = screen.priceCent ?: fail("RULE_NOT_MATCHED", "创建订单前没有读取到商品单价")
if (price !in input.minUnitPriceCent..input.maxUnitPriceCent) fail("PURCHASE_PRICE_OUT_OF_RANGE", "当前商品单价超出允许范围")
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", "创建订单前数量复核失败")
return FinalConfirmationEvidence(input.goodsId, input.mappedColor, input.mappedSize, input.quantity, price, address.suffix, snapshot.activityName.orEmpty())
}
fun submitOrderOnce() {
if (submitAttempted) fail("PURCHASE_SUBMIT_ALREADY_ATTEMPTED", "本次采购已经尝试创建订单,禁止再次点击")
val snapshot = driver.capture()
pageProblem(snapshot)
val targets = finalSubmitTargets(snapshot)
if (snapshot.packageName != PDD_PACKAGE || targets.size != 1) {
fail("PURCHASE_SUBMIT_TARGET_AMBIGUOUS", "最终页面或创建订单按钮已变化,禁止创建订单")
}
submitAttempted = true
when (driver.clickFresh(targets.single())) {
FreshActionResult.SUCCESS -> Unit
FreshActionResult.AMBIGUOUS -> fail("PURCHASE_SUBMIT_TARGET_AMBIGUOUS", "创建订单按钮不唯一")
else -> fail("PURCHASE_ORDER_RESULT_UNKNOWN", "创建订单点击结果不明确,请人工检查")
}
}
/** Only reads the current PDD order/payment flow; the sole navigation is one safe Back. */
fun readOrderResult(): PurchaseOrderEvidence? {
val labels = linkedSetOf<String>()
var backedOutOfPayment = false
repeat(60) { index ->
val snapshot = driver.capture()
snapshot.nodes.filter { it.visible }.mapTo(labels) { it.label }
parseOrderEvidence(labels)?.let { return it }
val paymentVisible = labels.any { label -> PAYMENT_MARKERS.any(label::contains) }
if (paymentVisible && !backedOutOfPayment) {
backedOutOfPayment = true
driver.backPurchase()
pause(500)
} else if (index > 0 && index % 15 == 0) {
driver.swipePurchase(SwipeDirection.UP, 400)
}
pause(200)
}
return null
}
private fun editAndVerifyAddress(panel: UiSnapshot, suffix: String): ShippingAddressProof {
val modify = addressModifyTargets(panel)
if (modify.size != 1) fail("PURCHASE_ADDRESS_UPDATE_FAILED", "没有找到唯一的地址修改按钮,未创建订单")
click(modify.single(), "修改地址")
val edit = waitFor { snapshot -> snapshot.nodes.any { it.visible && it.label.replace(" ", "").contains("详细地址") } }
val editors = edit.nodes.filter { it.visible && it.enabled && it.className?.endsWith("EditText") == true && it.label.isNotBlank() }
if (editors.size != 1) fail("PURCHASE_ADDRESS_UPDATE_FAILED", "没有找到唯一的详细地址输入框,未创建订单")
val current = editors.single().label.trim()
val body = current.split(Regex("[-_]"), limit = 2).firstOrNull()?.trim().orEmpty()
if (body.isBlank()) fail("PURCHASE_ADDRESS_UPDATE_FAILED", "当前详细地址无效,未创建订单")
val expected = body + suffix
if (driver.inputFresh(editors.single(), expected) != FreshActionResult.SUCCESS) fail("PURCHASE_ADDRESS_UPDATE_FAILED", "详细地址修改失败,未创建订单")
val stable = waitFor { snapshot -> snapshot.nodes.count { it.visible && it.enabled && it.className?.endsWith("EditText") == true && it.label == expected } == 1 }
val save = uniqueClickable(stable, stable.nodes.filter { it.visible && it.enabled && it.label == "保存" })
if (save.size != 1) fail("PURCHASE_ADDRESS_UPDATE_FAILED", "地址保存按钮不唯一,未创建订单")
click(save.single(), "保存地址")
var saved = waitFor { snapshot ->
snapshot.nodes.any { it.visible && it.label.contains(expected) } || finalSubmitTargets(snapshot).size == 1
}
if (finalSubmitTargets(saved).size != 1) {
if (!driver.backPurchase()) fail("PURCHASE_ADDRESS_UPDATE_FAILED", "地址保存后无法返回订单页面,未创建订单")
saved = waitFor { finalSubmitTargets(it).size == 1 }
}
if (saved.nodes.none { it.visible && it.label.contains(expected) }) fail("PURCHASE_ADDRESS_UPDATE_FAILED", "地址保存后回读不一致,未创建订单")
return ShippingAddressProof(expected, suffix)
}
private fun waitFor(predicate: (UiSnapshot) -> Boolean): UiSnapshot {
repeat(50) {
val snapshot = driver.capture()
pageProblem(snapshot)
if (predicate(snapshot)) return snapshot
pause(200)
}
fail("PURCHASE_ADDRESS_UPDATE_FAILED", "页面切换超时,未创建订单")
}
private fun uniqueClickable(snapshot: UiSnapshot, nodes: List<SnapshotNode>): List<SnapshotNode> {
val byPath = snapshot.nodes.associateBy { it.path }
return nodes.mapNotNull { source ->
var node: SnapshotNode? = source
while (node != null && !node.clickable) node = node.parentPath?.let(byPath::get)
node
}.distinctBy { it.path }
}
private fun addressModifyTargets(snapshot: UiSnapshot): List<SnapshotNode> {
val all = uniqueClickable(snapshot, snapshot.nodes.filter { it.visible && it.label == "修改" })
if (all.size <= 1) return all
val defaults = snapshot.nodes.filter { it.visible && it.label == "已设默认" }
if (defaults.size != 1) return emptyList()
var ancestor = defaults.single().parentPath
while (ancestor != null) {
val currentAncestor = ancestor
val inDefaultCard = all.filter { it.path == currentAncestor || it.path.startsWith("$currentAncestor/") }
if (inDefaultCard.size == 1) return inDefaultCard
ancestor = snapshot.nodes.firstOrNull { it.path == currentAncestor }?.parentPath
}
return emptyList()
}
private fun click(node: SnapshotNode, label: String) {
when (driver.clickFresh(node)) {
FreshActionResult.SUCCESS -> Unit
FreshActionResult.AMBIGUOUS -> fail("RULE_AMBIGUOUS", "$label 不唯一")
else -> fail("RULE_ACTION_FAILED", "$label 点击失败")
}
pause(500)
}
private fun finalSubmitTargets(snapshot: UiSnapshot): List<SnapshotNode> = uniqueClickable(
snapshot,
snapshot.nodes.filter { node -> node.visible && node.enabled && FINAL_SUBMIT_MARKERS.any { node.label == it || node.label.startsWith(it) } },
)
private fun pageProblem(snapshot: UiSnapshot) {
val labels = snapshot.nodes.filter { it.visible }.map { it.label }
PddPageClassifier.classify(snapshot.packageName, snapshot.activityName, labels)?.let { fail(it.code, it.message) }
if (labels.any { label -> PAYMENT_MARKERS.any(label::contains) }) fail("PURCHASE_PAYMENT_FORBIDDEN", "系统禁止自动付款")
}
private fun parseOrderEvidence(labels: Collection<String>): PurchaseOrderEvidence? {
val text = labels.joinToString(" ")
val orderNumbers = ORDER_NO.findAll(text).map { it.groupValues[1] }.distinct().toList()
if (orderNumbers.size != 1 || labels.none { label -> UNPAID_MARKERS.any(label::contains) || PAYMENT_MARKERS.any(label::contains) }) return null
val rawTime = ORDER_TIME.find(text)?.groupValues?.get(1) ?: return null
val normalized = rawTime.replace('年', '-').replace('月', '-').replace("日", "").replace('/', '-').replace('.', '-')
val parsed = listOf("yyyy-MM-dd HH:mm:ss", "yyyy-MM-dd HH:mm").firstNotNullOfOrNull { pattern ->
runCatching { SimpleDateFormat(pattern, Locale.ROOT).apply { isLenient = false; timeZone = TimeZone.getTimeZone("Asia/Shanghai") }.parse(normalized) }.getOrNull()
} ?: return null
val output = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.ROOT).apply { timeZone = TimeZone.getTimeZone("UTC") }.format(parsed)
return PurchaseOrderEvidence(orderNumbers.single(), output)
}
private fun fail(code: String, message: String): Nothing = throw PurchaseLiveException(code, message)
private companion object {
const val PDD_PACKAGE = "com.xunmeng.pinduoduo"
val MASKED_PHONE = Regex("(?<![0-9])[0-9]{3}\\*{4}[0-9]{4}(?![0-9])")
val FINAL_SUBMIT_MARKERS = listOf("提交订单", "现在买,仅", "确认购买")
val PAYMENT_MARKERS = listOf("立即支付", "确认支付", "输入支付密码")
val UNPAID_MARKERS = listOf("待付款", "待支付", "去支付")
val ORDER_NO = Regex("(?:订单编号|订单号)\\s*[::]?\\s*([A-Za-z0-9-]{6,64})")
val ORDER_TIME = Regex("(?:下单时间|创建时间)\\s*[::]?\\s*(20[0-9]{2}[-/.年][0-9]{1,2}[-/.月][0-9]{1,2}日?\\s+[0-9]{1,2}:[0-9]{2}(?::[0-9]{2})?)")
}
}
@@ -8,6 +8,7 @@ interface PurchaseUiDriver {
fun clickFresh(target: SnapshotNode): FreshActionResult
fun inputFresh(target: SnapshotNode, value: String): FreshActionResult
fun swipePurchase(direction: SwipeDirection, durationMs: Long): Boolean
fun backPurchase(): Boolean
}
data class PurchaseExecutionInput(
@@ -21,6 +22,7 @@ data class PurchaseExecutionInput(
val quantity: Long,
val minUnitPriceCent: Long,
val maxUnitPriceCent: Long,
val addressSuffix: String = "",
)
data class PurchaseExecutionOutcome(
@@ -28,6 +30,8 @@ data class PurchaseExecutionOutcome(
val errorCode: String? = null,
val message: String,
val probedSpecs: String? = null,
val pddOrderNo: String? = null,
val orderSubmittedAt: String? = null,
)
class PurchaseRehearsalExecutor(
@@ -36,10 +40,14 @@ class PurchaseRehearsalExecutor(
private val probeSpecs: () -> String?,
private val pause: (Long) -> Unit = Thread::sleep,
private val stepChanged: (String) -> Unit = {},
private val beforeOrderSubmit: (FinalConfirmationEvidence) -> Unit = { throw PurchaseLiveException("PURCHASE_MODE_NOT_ALLOWED", "当前执行器没有正式采购授权") },
) {
fun execute(input: PurchaseExecutionInput, rule: PurchaseRule, supportedCapabilities: Set<String>): PurchaseExecutionOutcome {
validateBeforeDeviceAction(input, rule, supportedCapabilities)?.let { return it }
var observedPrice: Long? = null
var addressProof: ShippingAddressProof? = null
var irreversibleStarted = false
val live = PurchaseLiveAutomation(driver, pause)
for (action in rule.actions) {
stepChanged(action.type.wireName)
val failure = when (action.type) {
@@ -56,6 +64,28 @@ class PurchaseRehearsalExecutor(
if (input.phase == "spec_probe") return probeOutcome()
null
}
PurchaseActionType.UPDATE_SHIPPING_ADDRESS -> try {
addressProof = live.updateShippingAddress(input.addressSuffix)
null
} catch (error: PurchaseLiveException) {
failure(error.code, error.message ?: "收货地址修改失败,未创建订单")
}
PurchaseActionType.CREATE_ORDER -> try {
val proof = addressProof ?: throw PurchaseLiveException("PURCHASE_ADDRESS_UPDATE_FAILED", "收货地址尚未完成验证,未创建订单")
val evidence = live.finalConfirmation(input, proof)
beforeOrderSubmit(evidence)
irreversibleStarted = true
live.submitOrderOnce()
null
} catch (error: PurchaseLiveException) {
if (irreversibleStarted) PurchaseExecutionOutcome("order_result_unknown", "PURCHASE_ORDER_RESULT_UNKNOWN", "无法确认订单是否创建,请人工检查")
else failure(error.code, error.message ?: "创建订单前检查失败")
}
PurchaseActionType.READ_ORDER_RESULT -> {
if (!irreversibleStarted) failure("PURCHASE_RULE_INVALID", "尚未进入创建订单边界")
else live.readOrderResult()?.let { PurchaseExecutionOutcome("order_created", message = "订单已创建,等待人工检查和支付", pddOrderNo = it.orderNo, orderSubmittedAt = it.submittedAt) }
?: PurchaseExecutionOutcome("order_result_unknown", "PURCHASE_ORDER_RESULT_UNKNOWN", "无法确认订单是否创建,请人工检查")
}
}
if (failure != null) {
if (failure.errorCode == "PURCHASE_SPEC_NOT_MATCHED" && PurchaseActionType.PROBE_SPECS in rule.actions.map { it.type }) {
@@ -66,7 +96,8 @@ class PurchaseRehearsalExecutor(
applyPostAction(action)?.let { return it }
}
if (input.phase == "spec_probe") return failure("PURCHASE_RULE_INVALID", "规格探测任务缺少 probeSpecs 动作")
return PurchaseExecutionOutcome("rehearsal_completed", message = "商品、规格、数量和价格复核完成,已在下单前安全停止")
return if (input.executionMode == "rehearsal") PurchaseExecutionOutcome("rehearsal_completed", message = "商品、规格、数量和价格复核完成,已在下单前安全停止")
else failure("PURCHASE_RULE_INVALID", "正式采购规则缺少核单动作")
}
private fun validateBeforeDeviceAction(
@@ -74,7 +105,9 @@ class PurchaseRehearsalExecutor(
rule: PurchaseRule,
supported: Set<String>,
): PurchaseExecutionOutcome? {
if (input.executionMode != "rehearsal") return failure("PURCHASE_MODE_NOT_ALLOWED", "当前任务不是安全演练任务")
if (input.executionMode !in setOf("rehearsal", "live")) return failure("PURCHASE_MODE_NOT_ALLOWED", "当前任务模式不可用")
val baseCapability = if (input.executionMode == "live") PurchaseAgentCapabilities.LIVE_V1 else PurchaseAgentCapabilities.REHEARSAL_V1
if (baseCapability !in rule.requiredCapabilities) return failure("AGENT_CAPABILITY_MISMATCH", "采购规则与任务模式不一致")
val missing = rule.requiredCapabilities.filterNot(supported::contains)
if (missing.isNotEmpty()) return failure("AGENT_CAPABILITY_MISMATCH", "当前手机版本不支持这个任务:${missing.joinToString()}")
if (input.quantity !in 1..999 || input.minUnitPriceCent < 0 || input.maxUnitPriceCent < input.minUnitPriceCent) {
@@ -93,19 +126,24 @@ class PurchaseRehearsalExecutor(
PurchaseActionType.SET_QUANTITY,
PurchaseActionType.VERIFY_UNIT_PRICE,
PurchaseActionType.VERIFY_ORDER_SUMMARY,
PurchaseActionType.PROBE_SPECS,
PurchaseActionType.UPDATE_SHIPPING_ADDRESS,
PurchaseActionType.CREATE_ORDER,
PurchaseActionType.READ_ORDER_RESULT,
)
if (types.zipWithNext().any { (first, second) -> ordered.indexOf(first) >= ordered.indexOf(second) }) {
if (types.filterNot { it == PurchaseActionType.PROBE_SPECS }.zipWithNext().any { (first, second) -> ordered.indexOf(first) >= ordered.indexOf(second) }) {
return failure("PURCHASE_RULE_INVALID", "采购规则动作顺序无效")
}
val required = if (input.phase == "spec_probe") {
setOf(PurchaseActionType.OPEN_PRODUCT, PurchaseActionType.VERIFY_PRODUCT, PurchaseActionType.OPEN_SPEC_PANEL, PurchaseActionType.PROBE_SPECS)
} else {
setOf(
buildSet {
addAll(setOf(
PurchaseActionType.OPEN_PRODUCT, PurchaseActionType.VERIFY_PRODUCT, PurchaseActionType.OPEN_SPEC_PANEL,
PurchaseActionType.SELECT_SPEC, PurchaseActionType.SET_QUANTITY, PurchaseActionType.VERIFY_UNIT_PRICE,
PurchaseActionType.VERIFY_ORDER_SUMMARY,
)
))
if (input.executionMode == "live") addAll(setOf(PurchaseActionType.UPDATE_SHIPPING_ADDRESS, PurchaseActionType.CREATE_ORDER, PurchaseActionType.READ_ORDER_RESULT))
}
}
if (!types.containsAll(required)) return failure("PURCHASE_RULE_INVALID", "采购规则缺少安全演练必要动作")
if (input.phase !in setOf("purchase", "spec_probe")) return failure("PURCHASE_RULE_INVALID", "任务执行阶段无效")
@@ -195,8 +233,9 @@ class PurchaseRehearsalExecutor(
}
if (!selected) return failure("PURCHASE_SPEC_NOT_MATCHED", "规格 $target 未能精确选中")
}
if (rule.requiredCapabilities.none { it == PurchaseAgentCapabilities.REHEARSAL_V1 }) {
return failure("AGENT_CAPABILITY_MISMATCH", "规则缺少采购演练能力")
val requiredCapability = if (input.executionMode == "live") PurchaseAgentCapabilities.LIVE_V1 else PurchaseAgentCapabilities.REHEARSAL_V1
if (rule.requiredCapabilities.none { it == requiredCapability }) {
return failure("AGENT_CAPABILITY_MISMATCH", "规则缺少当前采购模式能力")
}
return null
}
@@ -11,7 +11,10 @@ enum class PurchaseActionType(val wireName: String) {
SET_QUANTITY("setQuantity"),
VERIFY_UNIT_PRICE("verifyUnitPrice"),
VERIFY_ORDER_SUMMARY("verifyOrderSummary"),
PROBE_SPECS("probeSpecs");
PROBE_SPECS("probeSpecs"),
UPDATE_SHIPPING_ADDRESS("updateShippingAddress"),
CREATE_ORDER("createOrder"),
READ_ORDER_RESULT("readOrderResult");
companion object {
fun fromWire(value: String): PurchaseActionType? = entries.firstOrNull { it.wireName == value }
@@ -39,9 +42,12 @@ data class PurchaseRule(
object PurchaseAgentCapabilities {
const val REHEARSAL_V1 = "purchase.rehearsal.v1"
const val LIVE_V1 = "purchase.live.v1"
const val SPEC_PROBE_V1 = "purchase.spec-probe.v1"
const val ADDRESS_UPDATE_V1 = "purchase.address-update.v1"
const val ORDER_CREATE_V1 = "purchase.order-create.v1"
val supported = setOf(REHEARSAL_V1, SPEC_PROBE_V1)
val supported = setOf(REHEARSAL_V1, LIVE_V1, SPEC_PROBE_V1, ADDRESS_UPDATE_V1, ORDER_CREATE_V1)
}
object PurchaseRuleParser {
@@ -66,15 +72,31 @@ object PurchaseRuleParser {
invalid("采购规则必须声明 schemaVersion=1、ruleType=pddPurchase")
}
val capabilities = parseCapabilities(root.optJSONArray("requiredCapabilities"))
if (PurchaseAgentCapabilities.REHEARSAL_V1 !in capabilities) {
invalid("演练规则缺少能力 ${PurchaseAgentCapabilities.REHEARSAL_V1}")
}
val live = PurchaseAgentCapabilities.LIVE_V1 in capabilities
val rehearsal = PurchaseAgentCapabilities.REHEARSAL_V1 in capabilities
if (live == rehearsal) invalid("采购规则必须且只能声明一种执行模式能力")
val items = root.optJSONArray("actions") ?: invalid("actions 必须是非空数组")
if (items.length() !in 1..64) invalid("actions 必须包含 1..64 个动作")
val actions = (0 until items.length()).map { index -> parseAction(items, index) }
if (actions.any { it.type == PurchaseActionType.PROBE_SPECS } && PurchaseAgentCapabilities.SPEC_PROBE_V1 !in capabilities) {
invalid("probeSpecs 缺少能力 ${PurchaseAgentCapabilities.SPEC_PROBE_V1}")
}
actions.forEach { action ->
when (action.type) {
PurchaseActionType.UPDATE_SHIPPING_ADDRESS -> {
if (!live || PurchaseAgentCapabilities.ADDRESS_UPDATE_V1 !in capabilities) invalid("修改地址动作缺少正式采购能力")
if (action.textAliases != null || action.waitAfterMs != 0L || action.swipeAfter != null) invalid("修改地址动作不支持参数化")
}
PurchaseActionType.CREATE_ORDER -> {
if (!live || PurchaseAgentCapabilities.ORDER_CREATE_V1 !in capabilities) invalid("创建订单动作缺少正式采购能力")
if (action.textAliases != null || action.waitAfterMs != 0L || action.swipeAfter != null) invalid("创建订单动作不支持参数化")
}
PurchaseActionType.READ_ORDER_RESULT -> {
if (!live || action.textAliases != null || action.waitAfterMs != 0L || action.swipeAfter != null) invalid("核单动作只允许用于无参数正式采购规则")
}
else -> Unit
}
}
return PurchaseRule(capabilities, actions)
}
@@ -97,7 +97,10 @@ object AgentCapabilities {
SWIPE_V1,
PDD_PRODUCT_DETAIL_V1,
PurchaseAgentCapabilities.REHEARSAL_V1,
PurchaseAgentCapabilities.LIVE_V1,
PurchaseAgentCapabilities.SPEC_PROBE_V1,
PurchaseAgentCapabilities.ADDRESS_UPDATE_V1,
PurchaseAgentCapabilities.ORDER_CREATE_V1,
)
}
@@ -58,6 +58,7 @@ data class PurchaseAgentTask(
val minUnitPriceCent: Long,
val maxUnitPriceCent: Long,
val currency: String,
val addressSuffix: String,
val ruleSnapshot: String,
val ruleSnapshotHash: String,
val leaseVersion: Long,
@@ -154,6 +155,11 @@ class AgentApiClient(private val serverUrl: String) {
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 {
val payload = JSONObject().put("requestId", requestId)
return purchaseTask(requireNotNull(request("POST", "/api/agent/v1/purchase-tasks/$taskId/order-submit-started", payload, token)).getJSONObject("data"))
}
fun submitPurchaseResult(taskId: Long, payloadJson: String, token: String) {
val payload = JSONObject(payloadJson)
requireNotNull(request("POST", "/api/agent/v1/purchase-tasks/$taskId/result", payload, token))
@@ -186,6 +192,7 @@ class AgentApiClient(private val serverUrl: String) {
minUnitPriceCent = data.getLong("minUnitPriceCent"),
maxUnitPriceCent = data.getLong("maxUnitPriceCent"),
currency = data.getString("currency"),
addressSuffix = data.optString("addressSuffix"),
ruleSnapshot = data.getJSONObject("ruleSnapshot").toString(),
ruleSnapshotHash = data.optString("ruleSnapshotHash"),
leaseVersion = data.getLong("leaseVersion"),
@@ -9,6 +9,9 @@ data class InterruptedPurchaseAttempt(
val taskId: Long,
val attemptId: String,
val ruleSnapshotHash: String,
val status: String,
val orderSubmitRequestId: String?,
val finalConfirmationJson: String?,
)
data class PendingPurchaseOutbox(
@@ -31,7 +34,10 @@ class PurchaseTaskStore(context: Context) : SQLiteOpenHelper(context, DATABASE_N
result_json TEXT,
upload_status TEXT NOT NULL,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
updated_at INTEGER NOT NULL,
order_submit_request_id TEXT,
final_confirmation_json TEXT,
irreversible_at INTEGER
)""".trimIndent(),
)
db.execSQL(
@@ -62,7 +68,13 @@ class PurchaseTaskStore(context: Context) : SQLiteOpenHelper(context, DATABASE_N
db.execSQL("CREATE INDEX idx_purchase_attempt_task ON purchase_attempt(task_id, created_at)")
}
override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) = Unit
override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) {
if (oldVersion < 2) {
db.execSQL("ALTER TABLE purchase_task ADD COLUMN order_submit_request_id TEXT")
db.execSQL("ALTER TABLE purchase_task ADD COLUMN final_confirmation_json TEXT")
db.execSQL("ALTER TABLE purchase_task ADD COLUMN irreversible_at INTEGER")
}
}
@Synchronized
fun recordRunning(taskId: Long, attemptId: String, ruleSnapshotHash: String) {
@@ -113,6 +125,32 @@ class PurchaseTaskStore(context: Context) : SQLiteOpenHelper(context, DATABASE_N
writableDatabase.update("purchase_attempt", values, "attempt_id=? AND status=?", arrayOf(attemptId, STATUS_RUNNING))
}
/** Persists the irreversible boundary before the server marker and final click. */
@Synchronized
fun markOrderSubmitStarted(taskId: Long, attemptId: String, requestId: String, finalConfirmationJson: String) {
require(requestId.isNotBlank() && finalConfirmationJson.isNotBlank())
val now = System.currentTimeMillis()
writableDatabase.beginTransaction()
try {
val values = ContentValues().apply {
put("current_step", STATUS_ORDER_SUBMIT_STARTED)
put("status", STATUS_ORDER_SUBMIT_STARTED)
put("order_submit_request_id", requestId)
put("final_confirmation_json", finalConfirmationJson)
put("irreversible_at", now)
put("updated_at", now)
}
check(writableDatabase.update("purchase_task", values, "task_id=? AND attempt_id=? AND status=?", arrayOf(taskId.toString(), attemptId, STATUS_RUNNING)) == 1) {
"本地采购任务不在可提交状态"
}
val attempt = ContentValues().apply { put("current_step", STATUS_ORDER_SUBMIT_STARTED); put("updated_at", now) }
check(writableDatabase.update("purchase_attempt", attempt, "attempt_id=? AND status=?", arrayOf(attemptId, STATUS_RUNNING)) == 1)
writableDatabase.setTransactionSuccessful()
} finally {
writableDatabase.endTransaction()
}
}
/** Saves the final result and its upload request atomically. */
@Synchronized
fun completeAndEnqueue(taskId: Long, attemptId: String, requestId: String, payloadJson: String) {
@@ -154,16 +192,16 @@ class PurchaseTaskStore(context: Context) : SQLiteOpenHelper(context, DATABASE_N
@Synchronized
fun interruptedAttempts(): List<InterruptedPurchaseAttempt> = readableDatabase.query(
"purchase_task",
arrayOf("task_id", "attempt_id", "rule_snapshot_hash"),
"status=? AND upload_status=?",
arrayOf(STATUS_RUNNING, UPLOAD_NONE),
arrayOf("task_id", "attempt_id", "rule_snapshot_hash", "status", "order_submit_request_id", "final_confirmation_json"),
"status IN (?,?) AND upload_status=?",
arrayOf(STATUS_RUNNING, STATUS_ORDER_SUBMIT_STARTED, UPLOAD_NONE),
null,
null,
"updated_at ASC",
).use { cursor ->
buildList {
while (cursor.moveToNext()) {
add(InterruptedPurchaseAttempt(cursor.getLong(0), cursor.getString(1), cursor.getString(2)))
add(InterruptedPurchaseAttempt(cursor.getLong(0), cursor.getString(1), cursor.getString(2), cursor.getString(3), cursor.getString(4), cursor.getString(5)))
}
}
}
@@ -187,8 +225,8 @@ class PurchaseTaskStore(context: Context) : SQLiteOpenHelper(context, DATABASE_N
@Synchronized
fun activeTaskId(): Long? = readableDatabase.rawQuery(
"SELECT task_id FROM purchase_task WHERE status=? OR upload_status=? ORDER BY updated_at ASC LIMIT 1",
arrayOf(STATUS_RUNNING, UPLOAD_PENDING),
"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),
).use { cursor -> if (cursor.moveToFirst()) cursor.getLong(0) else null }
@Synchronized
@@ -207,8 +245,9 @@ class PurchaseTaskStore(context: Context) : SQLiteOpenHelper(context, DATABASE_N
companion object {
private const val DATABASE_NAME = "goauto_purchase.db"
private const val DATABASE_VERSION = 1
private const val DATABASE_VERSION = 2
private const val STATUS_RUNNING = "running"
const val STATUS_ORDER_SUBMIT_STARTED = "order_submit_started"
private const val STATUS_COMPLETED = "completed"
private const val UPLOAD_NONE = "none"
private const val UPLOAD_PENDING = "pending"
@@ -31,6 +31,7 @@ import cn.ilapage.goauto.agent.automation.CollectionRule
import cn.ilapage.goauto.agent.automation.PurchaseAgentCapabilities
import cn.ilapage.goauto.agent.automation.PurchaseExecutionInput
import cn.ilapage.goauto.agent.automation.PurchaseExecutionOutcome
import cn.ilapage.goauto.agent.automation.PurchaseLiveAutomation
import cn.ilapage.goauto.agent.automation.PurchaseRehearsalExecutor
import cn.ilapage.goauto.agent.automation.PurchaseRule
import cn.ilapage.goauto.agent.automation.PurchaseRuleParser
@@ -138,7 +139,7 @@ class AgentForegroundService : Service() {
val activeCredentials = credentials ?: error("设备尚未取得认证凭据")
if (taskMutex.currentTaskId() == null) {
recoverInterruptedPurchases()
recoverInterruptedPurchases(api, activeCredentials.token)
flushPurchaseOutbox(api, activeCredentials.token)
runningTaskId.set(purchaseStore.activeTaskId())
if (runningTaskId.get() == null) {
@@ -189,7 +190,7 @@ class AgentForegroundService : Service() {
private fun scheduleTask(api: AgentApiClient, token: String) {
if (taskMutex.currentTaskId() != null) return
recoverInterruptedPurchases()
recoverInterruptedPurchases(api, token)
flushPurchaseOutbox(api, token)
val purchaseTask = api.nextPurchaseTask(token)
if (purchaseTask != null) {
@@ -235,8 +236,9 @@ class AgentForegroundService : Service() {
val snapshotHashValid = task.ruleSnapshotHash.matches(Regex("^[0-9a-f]{64}$"))
val snapshotHash = task.ruleSnapshotHash.takeIf { snapshotHashValid } ?: "0".repeat(64)
purchaseStore.recordRunning(task.taskId, task.taskAttemptId, snapshotHash)
stateStore.update("BUSY", "正在安全演练采购任务 #${task.taskId}", tokenStored = true)
updateNotification("采购演练 #${task.taskId}")
val taskLabel = if (task.executionMode == "live") "正式采购" else "采购演练"
stateStore.update("BUSY", "正在执行${taskLabel}任务 #${task.taskId}", tokenStored = true)
updateNotification("$taskLabel #${task.taskId}")
val outcome = if (!snapshotHashValid) {
PurchaseExecutionOutcome("failed", "PURCHASE_RULE_INVALID", "采购规则快照哈希无效")
@@ -260,6 +262,20 @@ class AgentForegroundService : Service() {
openLink = { PddLinkLauncher(this).open(it) },
probeSpecs = { collectPurchaseProbe(accessibility, task) },
stepChanged = { step -> purchaseStore.updateStep(task.taskId, task.taskAttemptId, step) },
beforeOrderSubmit = { evidence ->
val boundaryRequestId = UUID.randomUUID().toString()
val finalEvidence = JSONObject()
.put("goodsId", evidence.goodsId)
.put("mappedColor", evidence.mappedColor)
.put("mappedSize", evidence.mappedSize)
.put("quantity", evidence.quantity)
.put("unitPriceCent", evidence.unitPriceCent)
.put("addressSuffix", evidence.addressSuffix)
.put("activityName", evidence.activityName)
.toString()
purchaseStore.markOrderSubmitStarted(task.taskId, task.taskAttemptId, boundaryRequestId, finalEvidence)
api.markPurchaseOrderSubmitStarted(task.taskId, boundaryRequestId, token)
},
).execute(
PurchaseExecutionInput(
taskId = task.taskId,
@@ -272,6 +288,7 @@ class AgentForegroundService : Service() {
quantity = task.quantity,
minUnitPriceCent = task.minUnitPriceCent,
maxUnitPriceCent = task.maxUnitPriceCent,
addressSuffix = task.addressSuffix,
),
parsedRule,
PurchaseAgentCapabilities.supported,
@@ -285,7 +302,7 @@ class AgentForegroundService : Service() {
flushPurchaseOutbox(api, token)
val message = 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") "采购演练 #${task.taskId} 失败" else "采购演练 #${task.taskId} 已提交")
updateNotification(if (outcome.resultType == "failed") "$taskLabel #${task.taskId} 失败" else "$taskLabel #${task.taskId} 已提交")
} catch (error: AgentApiException) {
stateStore.update("TASK_ERROR", "${error.code}:${error.message}", tokenStored = true)
} catch (error: Exception) {
@@ -322,13 +339,31 @@ class AgentForegroundService : Service() {
.toString()
}
private fun recoverInterruptedPurchases() {
private fun recoverInterruptedPurchases(api: AgentApiClient, token: String) {
purchaseStore.interruptedAttempts().forEach { interrupted ->
if (interrupted.status == PurchaseTaskStore.STATUS_ORDER_SUBMIT_STARTED) {
val boundaryRequestId = interrupted.orderSubmitRequestId ?: return@forEach
try {
api.markPurchaseOrderSubmitStarted(interrupted.taskId, boundaryRequestId, token)
val evidence = GoAutoAccessibilityService.instance?.let { PurchaseLiveAutomation(it).readOrderResult() }
val outcome = if (evidence == null) {
PurchaseExecutionOutcome("order_result_unknown", "PURCHASE_ORDER_RESULT_UNKNOWN", "无法确认订单是否创建,请人工检查")
} else {
PurchaseExecutionOutcome("order_created", message = "订单已创建,等待人工检查和支付", pddOrderNo = evidence.orderNo, orderSubmittedAt = evidence.submittedAt)
}
val requestId = UUID.randomUUID().toString()
purchaseStore.completeAndEnqueue(interrupted.taskId, interrupted.attemptId, requestId, purchaseResultPayload(requestId, interrupted.attemptId, outcome))
} catch (_: AgentApiException) {
// Keep the local irreversible marker. The same server request ID
// is replayed after connectivity returns; the order click is never repeated.
}
return@forEach
}
val requestId = UUID.randomUUID().toString()
val outcome = PurchaseExecutionOutcome(
"failed",
"AGENT_RESTARTED_DURING_EXECUTION",
"手机服务在演练执行中重启,已停止任务且不会重复操作拼多多",
"手机服务在创建订单前重启,任务已停止且不会重复操作拼多多",
)
purchaseStore.completeAndEnqueue(
interrupted.taskId,
@@ -359,6 +394,8 @@ class AgentForegroundService : Service() {
put("errorMessage", outcome.message.take(1000))
}
outcome.probedSpecs?.let { put("probedSpecs", JSONObject(it)) }
outcome.pddOrderNo?.let { put("pddOrderNo", it) }
outcome.orderSubmittedAt?.let { put("orderSubmittedAt", it) }
}
.toString()
@@ -0,0 +1,106 @@
package cn.ilapage.goauto.agent
import cn.ilapage.goauto.agent.automation.FreshActionResult
import cn.ilapage.goauto.agent.automation.NodeBounds
import cn.ilapage.goauto.agent.automation.PurchaseExecutionInput
import cn.ilapage.goauto.agent.automation.PurchaseLiveAutomation
import cn.ilapage.goauto.agent.automation.PurchaseLiveException
import cn.ilapage.goauto.agent.automation.PurchaseRuleParser
import cn.ilapage.goauto.agent.automation.PurchaseUiDriver
import cn.ilapage.goauto.agent.automation.SnapshotNode
import cn.ilapage.goauto.agent.automation.SwipeDirection
import cn.ilapage.goauto.agent.automation.UiSnapshot
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class PurchaseLiveAutomationTest {
@Test
fun `address is retagged verified and final order button can only be clicked once`() {
val driver = LiveDriver()
val automation = PurchaseLiveAutomation(driver, pause = {})
val address = automation.updateShippingAddress("_cg11")
assertEquals("广东省广州市天园街道骏景花园骏晖轩1202_cg11", address.expectedAddress)
val final = automation.finalConfirmation(input(), address)
assertEquals("_cg11", final.addressSuffix)
automation.submitOrderOnce()
assertEquals(1, driver.submitClicks)
val error = runCatching { automation.submitOrderOnce() }.exceptionOrNull() as PurchaseLiveException
assertEquals("PURCHASE_SUBMIT_ALREADY_ATTEMPTED", error.code)
assertEquals(1, driver.submitClicks)
val order = automation.readOrderResult()
assertEquals("PDD-202608210001", order?.orderNo)
assertEquals("2026-08-21T02:30:00Z", order?.submittedAt)
assertFalse(driver.clicked.any { it.contains("支付") })
}
@Test
fun `live rule requires reviewed capabilities and payment stays forbidden`() {
val live = PurchaseRuleParser.parse(LIVE_RULE)
assertTrue(live.actions.any { it.type.wireName == "createOrder" })
val rehearsalWithOrder = LIVE_RULE.replace("purchase.live.v1", "purchase.rehearsal.v1")
assertTrue(runCatching { PurchaseRuleParser.parse(rehearsalWithOrder) }.exceptionOrNull() is PurchaseLiveException ||
runCatching { PurchaseRuleParser.parse(rehearsalWithOrder) }.isFailure)
assertTrue(runCatching { PurchaseRuleParser.parse(LIVE_RULE.replace("readOrderResult", "pay")) }.isFailure)
}
private fun input() = PurchaseExecutionInput(
taskId = 11,
executionMode = "live",
phase = "purchase",
url = "https://mobile.yangkeduo.com/goods.html?goods_id=719834019024",
goodsId = "719834019024",
mappedColor = "黑色",
mappedSize = "XL",
quantity = 2,
minUnitPriceCent = 1_000,
maxUnitPriceCent = 3_000,
addressSuffix = "_cg11",
)
private class LiveDriver : PurchaseUiDriver {
private var page = "confirmation"
private var address = "广东省广州市天园街道骏景花园骏晖轩1202-old"
val clicked = mutableListOf<String>()
var submitClicks = 0
override fun capture(): UiSnapshot = when (page) {
"panel" -> snapshot(listOf(node("title", "收货地址"), node("modify", "修改", clickable = true), node("address", address)))
"edit" -> snapshot(listOf(node("title", "修改收货地址"), node("detail", "详细地址"), node("editor", address, className = "android.widget.EditText"), node("save", "保存", clickable = true)))
"order" -> snapshot(listOf(node("status", "待付款"), node("order", "订单号:PDD-202608210001"), node("time", "下单时间:2026-08-21 10:30:00"), node("pay", "立即支付", clickable = true)))
else -> snapshot(listOf(
node("phone", "138****5678", clickable = true), node("address", address),
node("price", "¥20.00"), node("selected", "已选 黑色 XL"),
node("quantity", "2", className = "android.widget.EditText"), node("submit", "提交订单", clickable = true),
))
}
override fun clickFresh(target: SnapshotNode): FreshActionResult {
clicked += target.label
when (target.label) {
"138****5678" -> page = "panel"
"修改" -> page = "edit"
"保存" -> page = "panel"
"提交订单" -> { submitClicks++; page = "order" }
}
return FreshActionResult.SUCCESS
}
override fun inputFresh(target: SnapshotNode, value: String): FreshActionResult { address = value; return FreshActionResult.SUCCESS }
override fun swipePurchase(direction: SwipeDirection, durationMs: Long): Boolean = true
override fun backPurchase(): Boolean { page = "confirmation"; return true }
private fun snapshot(nodes: List<SnapshotNode>) = UiSnapshot(PDD, ACTIVITY, nodes)
private fun node(path: String, text: String, clickable: Boolean = false, className: String = "android.widget.TextView") =
SnapshotNode(path, null, text, null, null, className, NodeBounds(0, 0, 500, 100), clickable, false, false, false, true, true)
}
private companion object {
const val PDD = "com.xunmeng.pinduoduo"
const val ACTIVITY = "com.xunmeng.pinduoduo.activity.NewPageActivity"
const val LIVE_RULE = """{"schemaVersion":1,"ruleType":"pddPurchase","requiredCapabilities":["purchase.live.v1","purchase.address-update.v1","purchase.order-create.v1","purchase.spec-probe.v1"],"actions":[{"type":"openProduct"},{"type":"verifyProduct"},{"type":"openSpecPanel"},{"type":"probeSpecs"},{"type":"selectSpec"},{"type":"setQuantity"},{"type":"verifyUnitPrice"},{"type":"verifyOrderSummary"},{"type":"updateShippingAddress"},{"type":"createOrder"},{"type":"readOrderResult"}]}"""
}
}
@@ -92,6 +92,15 @@ class PurchaseRehearsalExecutorTest {
assertFalse(opened)
}
@Test
fun `live rule spec probe returns before address or order actions`() {
val driver = FakePurchaseDriver()
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { "{\"dimensions\":[]}" }, pause = {})
.execute(input().copy(executionMode = "live", phase = "spec_probe", addressSuffix = "_cg42"), PurchaseRuleParser.parse(liveRule()), PurchaseAgentCapabilities.supported)
assertEquals("spec_probe_completed", outcome.resultType)
assertFalse(driver.clicked.any { it.contains("订单") || it.contains("地址") || it.contains("支付") })
}
@Test
fun `parser keeps type only compatibility and rejects dangerous or unknown fields`() {
val legacy = """{"schemaVersion":1,"ruleType":"pddPurchase","requiredCapabilities":["purchase.rehearsal.v1"],"actions":[{"type":"openProduct"}]}"""
@@ -166,6 +175,12 @@ class PurchaseRehearsalExecutorTest {
]
}"""
private fun liveRule() = """{
"schemaVersion":1,"ruleType":"pddPurchase",
"requiredCapabilities":["purchase.live.v1","purchase.address-update.v1","purchase.order-create.v1","purchase.spec-probe.v1"],
"actions":[{"type":"openProduct"},{"type":"verifyProduct"},{"type":"openSpecPanel"},{"type":"probeSpecs"},{"type":"selectSpec"},{"type":"setQuantity"},{"type":"verifyUnitPrice"},{"type":"verifyOrderSummary"},{"type":"updateShippingAddress"},{"type":"createOrder"},{"type":"readOrderResult"}]
}"""
private class FakePurchaseDriver(
private val colors: List<String> = listOf("黑色"),
private val priceCent: Long = 2_000,
@@ -235,6 +250,8 @@ class PurchaseRehearsalExecutorTest {
return true
}
override fun backPurchase(): Boolean = true
private fun node(
path: String,
text: String,
+4 -4
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Project-Profile
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Project-Profile.-
wiki_revision: dd7a5eee5576e626bb9078117880e74e4d3a2d5c
synchronized_at: 2026-08-21T01:43:04Z
wiki_revision: b882c9ed3995bcafd103801a1893191b86cc3382
synchronized_at: 2026-08-21T03:03:24Z
<!-- gitea-wiki-mirror:end -->
# 项目档案
@@ -17,7 +17,7 @@ synchronized_at: 2026-08-21T01:43:04Z
| 主要使用者 | 管理员、采购人员、开发维护者 |
| Gitea 仓库 | `OPC/goauto` |
| 默认分支 | `main` |
| 当前已实施范围 | PDD 商品与采集闭环、虾皮/SYB 商品档案与导入、采购任务服务、Admin 采购管理与人工处理、Android 采购演练与持久结果上报 |
| 当前已实施范围 | PDD 商品与采集闭环、虾皮/SYB 商品档案与导入、采购任务服务、Admin 采购管理与人工处理、Android 采购演练,以及 #36 已实现但尚未真机授权验证的正式地址/待付款订单能力 |
| 后续设计范围 | 从 SYB 创建采购任务、创建待付款订单、物流采集与自动回填 |
| 预计规模 | 20 台 Android;每天约 100 个采集任务、200 个采购任务 |
@@ -70,4 +70,4 @@ synchronized_at: 2026-08-21T01:43:04Z
#40、#41、#49~#52 已于 2026-08-20 通过用户验收,虾皮/SYB 商品档案、店铺过滤、异步同步记录、列表布局和 MySQL 同步缺陷修复均已完成。
#33、#34、#42 已于 2026-08-20 通过用户验收,采购数据契约、服务端状态机和 Android 安全演练基线已经完成。#42 只演练商品、规格、数量和价格,使用本地任务/attempt/Outbox 保证结果重传不重复操作 PDD,不改地址、不创建订单。#35、#55、#56 已于 2026-08-21 通过用户验收,Admin 采购管理、列表与详情查询以及 go-admin 成功响应兼容已经完成;#36 正式创建待付款订单仍属于后续高风险工单。
#33、#34、#42 已于 2026-08-20 通过用户验收,采购数据契约、服务端状态机和 Android 安全演练基线已经完成。#35、#55、#56 已于 2026-08-21 通过用户验收,Admin 采购管理、列表与详情查询以及 go-admin 成功响应兼容已经完成。#36 已在 Android Agent 0.2.0 实现正式地址后缀、不可逆门禁、一次性创建待付款订单和只读核单;自动化测试与 APK 构建已通过,但未安装到真机、未修改真实地址、未点击真实创建订单,等待用户独立授权验证。
+4 -3
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Architecture-and-Code-Map
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Architecture-and-Code-Map.-
wiki_revision: bea774951bc16ac3adba62ff4fdc2c4e750a0884
synchronized_at: 2026-08-21T02:11:49Z
wiki_revision: a582b9ed5712d0c1fceeb5530f60a3d746cbbd24
synchronized_at: 2026-08-21T03:03:32Z
<!-- gitea-wiki-mirror:end -->
# 架构与代码地图
@@ -46,6 +46,7 @@ Android Portal/Agent
- 指定设备只能由该设备领取;未指定任务由在线空闲设备原子领取。
- 重置在事务中清空原结果,保留 URL、goods_id、规则和设备快照,状态恢复为 `pending`。
- Android 本地互斥与服务端原子领取共同保证单设备串行。
- 正式采购在 Android 本地 SQLite 事务中先保存不可逆状态、稳定服务端请求 ID 和脱敏最终确认快照,再通知服务端并只允许一次创建订单点击;重启后只重放服务端标记、只读核单或上报,不再次点击。
- 原始控件树和截图不持久化;Android Agent 端第一期不使用 OCR/VLM。服务端 SYB 登录验证码识别是唯一例外,见 [#48](https://git.ilapage.cn/OPC/goauto/issues/48)。
## 最小业务数据
@@ -127,7 +128,7 @@ Android Portal/Agent
| SYB 异步导入、当前页采购选择/确认/逐条结果与同步记录页面 | `web/src/views/goauto/syb-products/`、`web/src/views/goauto/syb-sync-runs/`、`web/src/api/goauto/syb-products.js`、`web/src/api/goauto/purchase-tasks.js`;确认原型见 #44 设计证据,导入原型快照为 `prototypes/50/v2/index.html` |
| Android Agent 基线 | `android/app/src/main/java/cn/ilapage/goauto/agent/` |
| Android 无障碍规则执行 | `android/app/src/main/java/cn/ilapage/goauto/agent/automation/` |
| Android 采购演练规则解释器 | `android/app/src/main/java/cn/ilapage/goauto/agent/automation/PurchaseRuleContract.kt`、`PurchaseRehearsalExecutor.kt` |
| Android 采购规则解释器与正式地址/订单动作 | `android/app/src/main/java/cn/ilapage/goauto/agent/automation/PurchaseRuleContract.kt`、`PurchaseRehearsalExecutor.kt`、`PurchaseLiveAutomation.kt` |
| Android 采购任务、attempt 与 Outbox 持久化 | `android/app/src/main/java/cn/ilapage/goauto/agent/persistence/`;调度入口 `service/AgentForegroundService.kt` |
| 三端统一验证 | `scripts/verify.ps1` |
+9 -8
View File
@@ -2,15 +2,15 @@
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: 6f0a0c4d36c7101b5e81660363865f2e75759584
synchronized_at: 2026-08-21T02:11:53Z
wiki_revision: f0e414f176cd42e0ec79fcabe46c5497e4294e9a
synchronized_at: 2026-08-21T03:03:36Z
<!-- gitea-wiki-mirror:end -->
# 业务规则与术语
## 当前范围
当前已实施范围包含 PDD 商品最新档案与采集闭环、虾皮商品档案、SYB 商品与后台导入、采购任务服务、Admin 采购任务与人工处理页面,以及 Android 采购演练与持久结果上报。真实创建待付款订单、物流采集和自动回填仍由独立工单推进。
当前已实施范围包含 PDD 商品最新档案与采集闭环、虾皮商品档案、SYB 商品与后台导入、采购任务服务、Admin 采购任务与人工处理页面、Android 采购演练,以及 #36 的正式地址后缀与创建待付款订单能力。#36 尚未获得真机创建订单授权和验收;物流采集与自动回填仍由独立工单推进。
## PDD 商品
@@ -116,9 +116,10 @@ synchronized_at: 2026-08-21T02:11:53Z
- Admin 从 SYB 当前页选择商品,可在“创建采购”和“重新解析”两种批量用途间切换;采购模式只允许选择预检合格行,表头全选不跨页。批量预检和正式创建均最多 100 条并逐条重新校验,每条 SYB 明细只创建一个独立任务;部分失败不回滚其他成功项。
- 批量创建使用服务端内置并经契约校验的正式采购规则,每条任务保存不可变快照;设备默认人工指定,也可以留空由符合能力的空闲设备领取。
- 批量创建的价格保护来自 PDD 商品档案而不是 SYB/Shopee 的 TWD 售价:已确认颜色映射时按该颜色人民币价格计算;待规格探测时用可用颜色最低价的 0.2 倍到最高价的 1.5 倍,参考价取最高价。没有可用颜色价格时不能创建。
- 地址后缀为 `_cg{purchase_task.id}`。修改失败时禁止创建订单;任务与订单正式关联仍以完整 PDD 订单号为准。
- 点击创建订单前必须先保存 `order_submit_started` 和不可逆时间;结果不明时进入 `order_result_unknown`,禁止自动再次点击。
- Agent 本地 Room/Outbox 负责断网和重启恢复,服务端以 `task_id + task_attempt_id` 幂等接收并保存最终事实。
- 地址后缀为 `_cg{purchase_task.id}`。Agent 只在唯一地址入口、唯一修改按钮和唯一详细地址输入框均成立时修改;按首个 `-` 或 `_` 截取地址主体后追加当前后缀,保存后必须回读完整新地址。修改或回读失败时禁止创建订单,地址全文和控件树不落库。
- 点击创建订单前,Agent 必须先在本地事务保存 `order_submit_started`、不可逆时间、稳定请求 ID 和不含地址全文的最终确认快照,再用同一请求 ID通知服务端;两侧成功后才允许精确点击唯一创建订单按钮一次。
- 进入不可逆边界后,进程重启、断网、点击结果不明或无法取得唯一订单号/下单时间时只允许只读核单并进入 `order_result_unknown`,禁止再次点击;任务与订单正式关联仍以完整 PDD 订单号为准。
- Agent 本地 SQLite/Outbox 负责断网和重启恢复,服务端以 `task_id + task_attempt_id` 幂等接收并保存最终事实。
- 人工支付复核只记录 `paid` / `unpaid`;系统不执行或识别支付。快递单号与回填状态属于采购任务,后续物流工单实现。
- 采购任务领取时同时占用设备租约和可选 PDD 账号租约;租约过期后才可释放并重新领取。设备还存在采集任务时不能领取采购任务。
- 规格映射不完整、PDD 档案为待采集或没有规格时,规则必须具有 `purchase.spec-probe.v1`;第一趟只探测规格并释放租约,服务端固化同一 attempt 的决策后才派发第二趟。无匹配结果明确失败。
@@ -134,8 +135,8 @@ synchronized_at: 2026-08-21T02:11:53Z
- Android Agent 端不使用 OCR/VLM 兜底,不保存原始控件树或截图。采集阶段不猜测缺失数据。
- 服务端顺云宝(SYB)登录例外:允许调用配置的线上 OCR 服务识别登录验证码(见 [#48](https://git.ilapage.cn/OPC/goauto/issues/48))。验证码图片会离开本项目发送到该服务,更换服务地址前必须重新评估。此例外只适用于 SYB 登录,不扩大到 Agent 端或任何 PDD 相关流程。
- 采购阶段的规格匹配由服务端决策(见 [#46](https://git.ilapage.cn/OPC/goauto/issues/46)):Agent 本地不得自行猜测规格或点击相近候选,只执行服务端下发的精确规格;AI 无匹配结果时明确失败。
- 当前 MVP 不创建订单、不支付、不提供实时屏幕或管理端远程控制。
- Agent 的长期能力可以扩展到采购规则中的“创建订单”,但采集规则不能调用该动作;采购必须作为独立高风险 MVP 实施。付款、免密支付及任何等价动作始终禁止。
- 正式采购规则可以调用独立审核的改地址、创建待付款订单和只读核单动作;采集规则和演练规则不能调用。真机首次安装或验证仍需独立人工授权。
- 系统不提供自动支付、实时屏幕或管理端远程控制。付款、免密支付及任何等价动作始终禁止。
## 设备身份与认证
+9 -5
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Android-Agent-API-Contract
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Android-Agent-API-Contract.-
wiki_revision: 8fa791cf3f249096ddad2ede02bf13251f963407
synchronized_at: 2026-08-21T02:15:58Z
wiki_revision: 440e5c2e9e6a67e532d09301d617849a3df05297
synchronized_at: 2026-08-21T03:03:51Z
<!-- gitea-wiki-mirror:end -->
# MVP 共享 API 契约
@@ -369,9 +369,9 @@ POST /api/agent/v1/tasks/{taskId}/fail
| `DEVICE_BUSY` | 设备已有活动任务 | 否 |
| `DEVICE_CAPABILITY_MISMATCH` | 设备缺少任务规则要求的版本化能力 | 否 |
## 采购任务共享契约(#33、#34、#42、#44、#53)
## 采购任务共享契约(#33、#34、#36、#42、#44、#53)
本节固定采购域的数据和接口边界;`purchase_task`、`purchase_task_attempt`、规则校验、服务端 HTTP 状态机、Admin 已有任务管理、SYB 创建入口及 Android 演练执行已经落地。正式地址/订单动作仍由后续高风险工单实现。任何实现都不得扩展为自动支付。
本节固定采购域的数据和接口边界;`purchase_task`、`purchase_task_attempt`、规则校验、服务端 HTTP 状态机、Admin 已有任务管理、SYB 创建入口、Android 演练执行及 #36 正式地址/订单动作已经落地。#36 尚未获得真机创建订单授权和验收。任何实现都不得扩展为自动支付。
### 任务与快照
@@ -488,7 +488,11 @@ Admin 列表与详情由 #35 实现;#44 在 SYB 商品列表提供单条/当
结果提交至少关联 `taskId`、`taskAttemptId`、`deviceId`、规则快照哈希和结构化结果。相同 attempt 的相同结果重复提交返回同一事实;不同内容拒绝覆盖。慢路径第一趟提交规格后释放设备与已知账号租约,任务进入 `spec_probe_pending`;服务端固化同一 attempt 的 AI/人工决策后,第二趟使用新的 attempt 重新派发。无匹配结果则明确失败。`order_result_unknown` 只允许管理员或采购员人工解除,永不自动重派。
Android #42 使用本地 SQLite 保存恢复与重传所需的任务、attempt 和 Outbox;最终结果和 Outbox 在同一事务写入。断网或重启后只重发已保存的同一 requestId 和结果,不重新执行 PDD 操作。演练执行器只解释服务端规则快照中的受限类型化动作和参数,精确选择规格、设置数量、校验实际单价并停在安全点;不支持的能力在打开 PDD 前失败,地址修改、创建订单和支付动作均被拒绝。服务端数据库仍是最终事实来源;双方均不保存原始控件树、截图、PDD 凭据或完整收货地址。
Android #42/#36 使用本地 SQLite 保存恢复与重传所需的任务、attempt 和 Outbox;最终结果和 Outbox 在同一事务写入。演练执行器仍在安全点停止。Agent 0.2.0 另外声明 `purchase.live.v1`、`purchase.address-update.v1` 和 `purchase.order-create.v1`,仅在 `live` 规则同时包含受限 `updateShippingAddress`、`createOrder`、`readOrderResult` 动作时启用。
正式地址动作通过脱敏手机号结构定位唯一地址入口,精确选择唯一修改按钮和详细地址输入框;按首个 `-` 或 `_` 截断旧后缀,追加任务 `addressSuffix`,保存并回读完整新地址。地址全文只在本次执行内存中存在,不进入本地最终确认快照、日志或服务端。
创建订单前先在本地事务保存 `order_submit_started`、不可逆时间、稳定的 `orderSubmitRequestId` 和脱敏最终确认快照,再调用服务端同名接口;只有两侧标记完成并重新校验商品、规格、数量、单价、地址后缀、PDD 包/Activity 与唯一创建订单按钮后,才点击一次。重启时重放同一标记请求并只读核单;无法取得唯一未付款订单号和 PDD 下单时间时提交 `order_result_unknown`。支付文字仅用于识别未付款/离开支付页,永不点击。服务端数据库仍是最终事实来源;双方均不保存原始控件树、截图、PDD 凭据或完整收货地址。
| 错误码 | 普通提示 |
|---|---|