From 9d4a0b9845b76d8e85ebd27c46b05ca89cd37fa8 Mon Sep 17 00:00:00 2001 From: QiuSW <105186638@qq.com> Date: Sat, 15 Aug 2026 15:01:19 +0800 Subject: [PATCH] feat: add versioned rule capabilities (#22) --- .../automation/GoAutoAccessibilityService.kt | 38 +++ .../goauto/agent/automation/RuleContract.kt | 220 +++++++++++--- .../goauto/agent/automation/RuleExecutor.kt | 18 ++ .../goauto/agent/network/AgentApiClient.kt | 5 +- .../agent/service/AgentForegroundService.kt | 8 +- .../ilapage/goauto/agent/RuleExecutorTest.kt | 38 +++ docs/00-project-profile.md | 2 +- docs/02-architecture-and-code-map.md | 4 +- docs/03-business-rules-and-glossary.md | 3 + docs/08-agent-api-contract.md | 38 ++- docs/09-delivery-issues.md | 4 + docs/11-pdd-detail-rule-migration-analysis.md | 34 ++- .../rules/pdd-product-detail-v2.proposed.json | 3 + server/app/goauto/device/capabilities.go | 72 +++++ server/app/goauto/device/heartbeat.go | 23 +- server/app/goauto/device/heartbeat_test.go | 21 ++ server/app/goauto/device/list.go | 8 +- server/app/goauto/device/service.go | 30 +- server/app/goauto/device/service_test.go | 22 ++ server/app/goauto/migrations/migrate_test.go | 3 + server/app/goauto/models/schema.go | 1 + server/app/goauto/rule/service.go | 6 +- server/app/goauto/rulecontract/contract.go | 268 ++++++++++++++++++ .../app/goauto/rulecontract/contract_test.go | 57 ++++ server/app/goauto/task/admin_service.go | 3 + server/app/goauto/task/service.go | 67 ++++- server/app/goauto/task/service_test.go | 67 ++++- .../1786700400000_device_capabilities.go | 27 ++ 28 files changed, 1005 insertions(+), 85 deletions(-) create mode 100644 server/app/goauto/device/capabilities.go create mode 100644 server/app/goauto/rulecontract/contract.go create mode 100644 server/app/goauto/rulecontract/contract_test.go create mode 100644 server/cmd/migrate/migration/version-local/1786700400000_device_capabilities.go diff --git a/android/app/src/main/java/cn/ilapage/goauto/agent/automation/GoAutoAccessibilityService.kt b/android/app/src/main/java/cn/ilapage/goauto/agent/automation/GoAutoAccessibilityService.kt index 79cd22b..4f5326c 100644 --- a/android/app/src/main/java/cn/ilapage/goauto/agent/automation/GoAutoAccessibilityService.kt +++ b/android/app/src/main/java/cn/ilapage/goauto/agent/automation/GoAutoAccessibilityService.kt @@ -2,6 +2,9 @@ package cn.ilapage.goauto.agent.automation import android.accessibilityservice.AccessibilityService import android.accessibilityservice.AccessibilityServiceInfo +import android.accessibilityservice.GestureDescription +import android.graphics.Path +import android.graphics.Rect import android.os.Bundle import android.view.accessibility.AccessibilityEvent import android.view.accessibility.AccessibilityNodeInfo @@ -69,6 +72,41 @@ class GoAutoAccessibilityService : AccessibilityService(), UiDriver { override fun back(): Boolean = performGlobalAction(GLOBAL_ACTION_BACK) + override fun swipe(target: SemanticTarget, direction: SwipeDirection): Boolean { + if (target != SemanticTarget.SPEC_PANEL) return false + val root = rootInActiveWindow ?: return false + val candidates = mutableListOf() + walk(root) { node -> + if (node.isVisibleToUser && node.isScrollable) candidates += node + } + val node = candidates.maxByOrNull { candidate -> + Rect().also(candidate::getBoundsInScreen).let { it.width().toLong() * it.height().toLong() } + } ?: return false + val bounds = Rect().also(node::getBoundsInScreen) + if (bounds.width() < 2 || bounds.height() < 2) return false + val left = bounds.left + bounds.width() * 25 / 100 + val right = bounds.left + bounds.width() * 75 / 100 + val top = bounds.top + bounds.height() * 25 / 100 + val bottom = bounds.top + bounds.height() * 75 / 100 + val centerX = bounds.centerX() + val centerY = bounds.centerY() + val (startX, startY, endX, endY) = when (direction) { + SwipeDirection.UP -> listOf(centerX, bottom, centerX, top) + SwipeDirection.DOWN -> listOf(centerX, top, centerX, bottom) + SwipeDirection.LEFT -> listOf(right, centerY, left, centerY) + SwipeDirection.RIGHT -> listOf(left, centerY, right, centerY) + } + val path = Path().apply { + moveTo(startX.toFloat(), startY.toFloat()) + lineTo(endX.toFloat(), endY.toFloat()) + } + return dispatchGesture( + GestureDescription.Builder().addStroke(GestureDescription.StrokeDescription(path, 0, 450)).build(), + null, + null, + ) + } + private fun walk(node: AccessibilityNodeInfo, visit: (AccessibilityNodeInfo) -> Unit) { visit(node) for (index in 0 until node.childCount) node.getChild(index)?.let { walk(it, visit) } diff --git a/android/app/src/main/java/cn/ilapage/goauto/agent/automation/RuleContract.kt b/android/app/src/main/java/cn/ilapage/goauto/agent/automation/RuleContract.kt index e343f70..0faff59 100644 --- a/android/app/src/main/java/cn/ilapage/goauto/agent/automation/RuleContract.kt +++ b/android/app/src/main/java/cn/ilapage/goauto/agent/automation/RuleContract.kt @@ -1,8 +1,13 @@ package cn.ilapage.goauto.agent.automation +import org.json.JSONArray import org.json.JSONObject enum class RuleAction { WAIT, CLICK, INPUT, BACK, EXTRACT } +enum class HookStage { AFTER_SPEC_PANEL_OPEN } +enum class HookActionType { SWIPE } +enum class SemanticTarget { SPEC_PANEL } +enum class SwipeDirection { UP, DOWN, LEFT, RIGHT } data class NodeSelector( val resourceId: String? = null, @@ -28,20 +33,63 @@ data class RuleStep( val optional: Boolean, ) -data class CollectionRule(val schemaVersion: Int, val steps: List) +data class HookAction( + val action: HookActionType, + val target: SemanticTarget, + val direction: SwipeDirection, + val count: Int, + val settleMs: Long, +) + +data class PageEvidence( + val packageName: String, + val activityName: String, + val selector: NodeSelector, +) + +data class PddCollectorConfig( + val collectorId: String, + val specEntryStrategy: String, + val priceParser: String, + val priceGranularity: String, + val colorAliases: List, + val sizeAliases: List, + val timeoutsMs: Map, + val limits: Map, +) + +data class CollectionRule( + val schemaVersion: Int, + val steps: List, + val ruleType: String = "legacyCollection", + val pageEvidence: PageEvidence? = null, + val hooks: Map> = emptyMap(), + val collector: PddCollectorConfig? = null, +) class RuleValidationException(val code: String, message: String) : IllegalArgumentException(message) +object AgentCapabilities { + const val SCHEMA_V2 = "rule.schema.v2" + const val SWIPE_V1 = "action.swipe.v1" + const val PDD_PRODUCT_DETAIL_V1 = "collector.pdd.product-detail.v1" + + // T20 provides the contract and swipe primitive. T21 adds the collector + // capability only after the complete state machine is wired in. + val supported: List = listOf(SCHEMA_V2, SWIPE_V1) +} + object RuleParser { private const val PDD_PACKAGE = "com.xunmeng.pinduoduo" private val allowedPackages = setOf( - "com.xunmeng.pinduoduo", + PDD_PACKAGE, "com.android.chrome", "com.heytap.browser", "com.android.browser", "android", ) - private val forbiddenWords = listOf("立即支付", "确认支付", "付款", "提交订单", "免密支付", "pay now") + private val allowedOpenTargets = setOf("打开拼多多APP", "打开拼多多 App", "打开") + private val forbiddenCollectionWords = listOf("提交订单", "创建订单", "立即支付", "确认支付", "付款", "免密支付", "pay now") fun parse(raw: String): CollectionRule { val root = try { @@ -49,52 +97,77 @@ object RuleParser { } catch (_: Exception) { throw RuleValidationException("RULE_INVALID", "规则不是有效的 JSON 对象") } - if (root.optInt("schemaVersion", -1) != 1) { - throw RuleValidationException("RULE_VERSION_UNSUPPORTED", "仅支持 schemaVersion 1") + return when (root.optInt("schemaVersion", -1)) { + 1 -> parseV1(root) + 2 -> parseV2(root) + else -> throw RuleValidationException("RULE_VERSION_UNSUPPORTED", "仅支持 schemaVersion 1 或 2") } - val defaultPackage = root.optString("packageName").takeIf { it.isNotBlank() } - val items = root.optJSONArray("steps") - ?: throw RuleValidationException("RULE_INVALID", "steps 必须是非空数组") - if (items.length() == 0) throw RuleValidationException("RULE_INVALID", "steps 必须是非空数组") + } + + private fun parseV1(root: JSONObject): CollectionRule { + val steps = parseSteps(root.optJSONArray("steps"), root.stringOrNull("packageName"), allowPddActions = true) + return CollectionRule(1, steps) + } + + private fun parseV2(root: JSONObject): CollectionRule { + rejectUnknown(root, setOf("schemaVersion", "ruleType", "navigation", "pageEvidence", "hooks", "collector"), "v2 规则") + if (root.optString("ruleType") != "pddProductDetail") { + invalid("v2 规则必须声明 ruleType=pddProductDetail") + } + val navigation = root.optJSONObject("navigation") ?: invalid("navigation 必填") + rejectUnknown(navigation, setOf("steps"), "navigation") + val steps = parseSteps(navigation.optJSONArray("steps"), null, allowPddActions = false) + val evidenceJson = root.optJSONObject("pageEvidence") ?: invalid("pageEvidence 必填") + rejectUnknown(evidenceJson, setOf("packageName", "activityName", "selector"), "pageEvidence") + val evidence = PageEvidence( + packageName = evidenceJson.optString("packageName"), + activityName = evidenceJson.optString("activityName").trim(), + selector = parseSelector(evidenceJson.optJSONObject("selector")) ?: invalid("pageEvidence.selector 必填"), + ) + if (evidence.packageName != PDD_PACKAGE || evidence.activityName.isBlank() || evidence.selector.isEmpty()) { + invalid("pageEvidence 必须包含 PDD 包名、精确 Activity 和非空 selector") + } + val hooks = parseHooks(root.optJSONObject("hooks")) + val collector = parseCollector(root.optJSONObject("collector") ?: invalid("collector 必填")) + return CollectionRule(2, steps, "pddProductDetail", evidence, hooks, collector) + } + + private fun parseSteps(items: JSONArray?, defaultPackage: String?, allowPddActions: Boolean): List { + items ?: invalid("steps 必须是非空数组") + if (items.length() == 0) invalid("steps 必须是非空数组") val ids = mutableSetOf() - val steps = (0 until items.length()).map { index -> - val item = items.optJSONObject(index) - ?: throw RuleValidationException("RULE_INVALID", "steps[$index] 必须是对象") + return (0 until items.length()).map { index -> + val item = items.optJSONObject(index) ?: invalid("steps[$index] 必须是对象") val id = item.optString("id").trim() - if (id.isBlank() || !ids.add(id)) throw RuleValidationException("RULE_INVALID", "步骤 id 为空或重复") + if (id.isBlank() || !ids.add(id)) invalid("步骤 id 为空或重复") val action = runCatching { RuleAction.valueOf(item.optString("action").uppercase()) } .getOrElse { throw RuleValidationException("RULE_ACTION_NOT_ALLOWED", "步骤 $id 的 action 不受支持") } - val packageName = item.optString("packageName").takeIf { it.isNotBlank() } ?: defaultPackage + if (!allowPddActions && action !in setOf(RuleAction.WAIT, RuleAction.CLICK, RuleAction.BACK)) { + throw RuleValidationException("RULE_ACTION_NOT_ALLOWED", "v2 导航步骤 $id 的 action 不受支持") + } + val packageName = item.stringOrNull("packageName") ?: defaultPackage if (packageName !in allowedPackages) { throw RuleValidationException("RULE_PACKAGE_NOT_ALLOWED", "步骤 $id 的应用包不在白名单") } - val selectorJson = item.optJSONObject("selector") - val selector = selectorJson?.let { - NodeSelector( - resourceId = it.stringOrNull("resourceId"), - text = it.stringOrNull("text"), - contentDescription = it.stringOrNull("contentDescription"), - className = it.stringOrNull("className"), - clickable = if (it.has("clickable")) it.getBoolean("clickable") else null, - ) - } - if (action != RuleAction.BACK && (selector == null || selector.isEmpty())) { - throw RuleValidationException("RULE_INVALID", "步骤 $id 缺少 selector") + val selector = parseSelector(item.optJSONObject("selector")) + if (action != RuleAction.BACK && (selector == null || selector.isEmpty())) invalid("步骤 $id 缺少 selector") + if (!allowPddActions && action == RuleAction.CLICK && packageName == PDD_PACKAGE) { + throw RuleValidationException("RULE_ACTION_NOT_ALLOWED", "v2 导航步骤 $id 不能点击 PDD 页面") } if (action == RuleAction.CLICK && packageName != PDD_PACKAGE) { val target = selector?.text ?: selector?.contentDescription - if (target !in setOf("打开拼多多APP", "打开拼多多 App", "打开")) { + if (target !in allowedOpenTargets) { throw RuleValidationException("RULE_ACTION_NOT_ALLOWED", "步骤 $id 不是允许的打开拼多多动作") } } val value = item.stringOrNull("value") val field = item.stringOrNull("field") - if (action == RuleAction.INPUT && value == null) throw RuleValidationException("RULE_INVALID", "输入步骤 $id 缺少 value") - if (action == RuleAction.EXTRACT && field == null) throw RuleValidationException("RULE_INVALID", "采集步骤 $id 缺少 field") + if (action == RuleAction.INPUT && value == null) invalid("输入步骤 $id 缺少 value") + if (action == RuleAction.EXTRACT && field == null) invalid("采集步骤 $id 缺少 field") val safetyText = listOf(id, selector?.text, selector?.contentDescription, value, field) .filterNotNull().joinToString(" ").lowercase() - if (forbiddenWords.any(safetyText::contains)) { - throw RuleValidationException("FORBIDDEN_ACTION", "步骤 $id 涉及支付或下单操作") + if (forbiddenCollectionWords.any(safetyText::contains)) { + throw RuleValidationException("FORBIDDEN_ACTION", "采集步骤 $id 涉及创建订单或付款操作") } RuleStep( id = id, @@ -105,13 +178,90 @@ object RuleParser { selector = selector, value = value, field = field, - timeoutMs = item.optLong("timeoutMs", 5_000).coerceIn(100, 30_000), + timeoutMs = item.optLong("timeoutMs", 5_000).also { + if (it !in 100..30_000) invalid("步骤 $id 的 timeoutMs 必须为 100..30000") + }, optional = item.optBoolean("optional", false), ) } - return CollectionRule(1, steps) } - private fun JSONObject.stringOrNull(name: String): String? = - optString(name).trim().takeIf { it.isNotBlank() } + private fun parseHooks(value: JSONObject?): Map> { + if (value == null) return emptyMap() + rejectUnknown(value, setOf("afterSpecPanelOpen"), "hooks") + val items = value.optJSONArray("afterSpecPanelOpen") ?: return emptyMap() + if (items.length() > 8) invalid("单个 hook 最多 8 个动作") + val actions = (0 until items.length()).map { index -> + val item = items.optJSONObject(index) ?: invalid("hook[$index] 必须是对象") + rejectUnknown(item, setOf("action", "target", "direction", "count", "settleMs"), "hook[$index]") + if (item.optString("action") != "swipe" || item.optString("target") != "specPanel") { + throw RuleValidationException("RULE_ACTION_NOT_ALLOWED", "hook 只允许对 specPanel 执行 swipe") + } + val direction = runCatching { SwipeDirection.valueOf(item.optString("direction").uppercase()) } + .getOrElse { invalid("swipe.direction 不受支持") } + val count = item.optInt("count", -1) + val settleMs = item.optLong("settleMs", 0) + if (count !in 1..5) invalid("swipe.count 必须为 1..5") + if (settleMs !in 0..2_000) invalid("swipe.settleMs 必须为 0..2000") + HookAction(HookActionType.SWIPE, SemanticTarget.SPEC_PANEL, direction, count, settleMs) + } + return if (actions.isEmpty()) emptyMap() else mapOf(HookStage.AFTER_SPEC_PANEL_OPEN to actions) + } + + private fun parseCollector(value: JSONObject): PddCollectorConfig { + rejectUnknown(value, setOf("collectorId", "specEntryStrategy", "priceParser", "priceGranularity", "dimensionAliases", "timeoutsMs", "limits"), "collector") + if (value.optString("collectorId") != "pddProductDetailV1" || + value.optString("specEntryStrategy") != "safeBottomSpecEntryV1" || + value.optString("priceParser") != "pddRmbPriceV1" || + value.optString("priceGranularity") != "color" + ) invalid("collector 使用了 Agent 不支持的类型化能力") + val aliases = value.optJSONObject("dimensionAliases") ?: invalid("dimensionAliases 必填") + rejectUnknown(aliases, setOf("color", "size"), "dimensionAliases") + val colorAliases = parseAliases(aliases.optJSONArray("color"), "color") + val sizeAliases = parseAliases(aliases.optJSONArray("size"), "size") + val timeouts = parseBoundedMap( + value.optJSONObject("timeoutsMs") ?: invalid("timeoutsMs 必填"), + mapOf("page" to 100..60_000, "specPanel" to 100..30_000, "selection" to 100..10_000, "price" to 100..10_000, "overall" to 1_000..600_000), + ) + val limits = parseBoundedMap( + value.optJSONObject("limits") ?: invalid("limits 必填"), + mapOf("goodsPageVerticalSwipes" to 0..10, "specHorizontalSwipes" to 0..30, "specVerticalSwipes" to 0..30, "stableEdgeReads" to 1..5, "stablePriceReads" to 2..5, "maxSkuCount" to 1..2_000), + ) + return PddCollectorConfig("pddProductDetailV1", "safeBottomSpecEntryV1", "pddRmbPriceV1", "color", colorAliases, sizeAliases, timeouts, limits) + } + + private fun parseAliases(items: JSONArray?, name: String): List { + items ?: invalid("dimensionAliases.$name 必填") + if (items.length() !in 1..20) invalid("dimensionAliases.$name 必须包含 1..20 项") + return (0 until items.length()).map { index -> + items.optString(index).trim().also { if (it.isEmpty() || it.length > 30) invalid("dimensionAliases.$name 含无效别名") } + }.distinct() + } + + private fun parseBoundedMap(value: JSONObject, allowed: Map): Map { + rejectUnknown(value, allowed.keys, "整数配置") + if (value.length() != allowed.size) invalid("规则整数配置缺失") + return allowed.mapValues { (key, range) -> + value.optInt(key, Int.MIN_VALUE).also { if (it !in range) invalid("$key 必须为 ${range.first}..${range.last}") } + } + } + + private fun parseSelector(value: JSONObject?): NodeSelector? = value?.let { + rejectUnknown(it, setOf("resourceId", "text", "contentDescription", "className", "clickable"), "selector") + NodeSelector( + resourceId = it.stringOrNull("resourceId"), + text = it.stringOrNull("text"), + contentDescription = it.stringOrNull("contentDescription"), + className = it.stringOrNull("className"), + clickable = if (it.has("clickable")) it.getBoolean("clickable") else null, + ) + } + + private fun rejectUnknown(value: JSONObject, allowed: Set, label: String) { + val unknown = value.keys().asSequence().filterNot(allowed::contains).toList() + if (unknown.isNotEmpty()) invalid("$label 包含未知字段: ${unknown.joinToString()}") + } + + private fun JSONObject.stringOrNull(name: String): String? = optString(name).trim().takeIf { it.isNotBlank() } + private fun invalid(message: String): Nothing = throw RuleValidationException("RULE_INVALID", message) } diff --git a/android/app/src/main/java/cn/ilapage/goauto/agent/automation/RuleExecutor.kt b/android/app/src/main/java/cn/ilapage/goauto/agent/automation/RuleExecutor.kt index 17b6439..5d1a664 100644 --- a/android/app/src/main/java/cn/ilapage/goauto/agent/automation/RuleExecutor.kt +++ b/android/app/src/main/java/cn/ilapage/goauto/agent/automation/RuleExecutor.kt @@ -10,6 +10,24 @@ interface UiDriver { fun click(node: UiNodeRef): Boolean fun input(node: UiNodeRef, value: String): Boolean fun back(): Boolean + fun swipe(target: SemanticTarget, direction: SwipeDirection): Boolean +} + +class HookExecutor( + private val driver: UiDriver, + private val pause: (Long) -> Unit = Thread::sleep, +) { + fun execute(actions: List): RuleExecutionResult { + actions.forEachIndexed { index, action -> + repeat(action.count) { + if (!driver.swipe(action.target, action.direction)) { + return RuleExecutionResult(false, "RULE_ACTION_FAILED", "hook 动作 ${index + 1} 执行失败") + } + if (action.settleMs > 0) pause(action.settleMs) + } + } + return RuleExecutionResult(true, "OK", "hook 执行完成") + } } data class RuleExecutionResult( diff --git a/android/app/src/main/java/cn/ilapage/goauto/agent/network/AgentApiClient.kt b/android/app/src/main/java/cn/ilapage/goauto/agent/network/AgentApiClient.kt index 6f31bdf..5d9a59e 100644 --- a/android/app/src/main/java/cn/ilapage/goauto/agent/network/AgentApiClient.kt +++ b/android/app/src/main/java/cn/ilapage/goauto/agent/network/AgentApiClient.kt @@ -15,6 +15,7 @@ data class DeviceInfo( val androidVersion: String, val agentVersion: String, val pddVersion: String, + val capabilities: List = emptyList(), ) data class RegistrationResult( @@ -60,6 +61,7 @@ class AgentApiClient(private val serverUrl: String) { .put("androidVersion", info.androidVersion) .put("agentVersion", info.agentVersion) .put("pddVersion", info.pddVersion) + .put("capabilities", JSONArray(info.capabilities)) val data = post("/api/agent/v1/register", payload, token).getJSONObject("data") return RegistrationResult( deviceId = data.getLong("deviceId"), @@ -68,10 +70,11 @@ class AgentApiClient(private val serverUrl: String) { ) } - fun heartbeat(token: String, currentTaskId: Long?): HeartbeatResult { + fun heartbeat(token: String, currentTaskId: Long?, capabilities: List = emptyList()): HeartbeatResult { val payload = JSONObject() .put("requestId", UUID.randomUUID().toString()) .put("currentTaskId", currentTaskId ?: JSONObject.NULL) + .put("capabilities", JSONArray(capabilities)) val data = post("/api/agent/v1/heartbeat", payload, token).getJSONObject("data") return HeartbeatResult( deviceId = data.getLong("deviceId"), diff --git a/android/app/src/main/java/cn/ilapage/goauto/agent/service/AgentForegroundService.kt b/android/app/src/main/java/cn/ilapage/goauto/agent/service/AgentForegroundService.kt index ef5c304..920ca16 100644 --- a/android/app/src/main/java/cn/ilapage/goauto/agent/service/AgentForegroundService.kt +++ b/android/app/src/main/java/cn/ilapage/goauto/agent/service/AgentForegroundService.kt @@ -19,6 +19,7 @@ import cn.ilapage.goauto.agent.BuildConfig import cn.ilapage.goauto.agent.MainActivity import cn.ilapage.goauto.agent.R import cn.ilapage.goauto.agent.automation.CollectionAssembler +import cn.ilapage.goauto.agent.automation.AgentCapabilities import cn.ilapage.goauto.agent.automation.GoAutoAccessibilityService import cn.ilapage.goauto.agent.automation.PddLinkLauncher import cn.ilapage.goauto.agent.automation.RuleExecutor @@ -115,7 +116,11 @@ class AgentForegroundService : Service() { } val activeCredentials = credentials ?: error("设备尚未取得认证凭据") - val heartbeat = api.heartbeat(activeCredentials.token, currentTaskId = runningTaskId.get()) + val heartbeat = api.heartbeat( + activeCredentials.token, + currentTaskId = runningTaskId.get(), + capabilities = AgentCapabilities.supported, + ) check(heartbeat.deviceId == activeCredentials.deviceId) { "心跳返回了不同的设备身份" } stateStore.update( code = if (heartbeat.busy) "BUSY" else "ONLINE", @@ -235,6 +240,7 @@ class AgentForegroundService : Service() { androidVersion = Build.VERSION.RELEASE.take(32), agentVersion = BuildConfig.VERSION_NAME.take(32), pddVersion = installedVersion("com.xunmeng.pinduoduo").take(32), + capabilities = AgentCapabilities.supported, ) @Suppress("DEPRECATION") diff --git a/android/app/src/test/java/cn/ilapage/goauto/agent/RuleExecutorTest.kt b/android/app/src/test/java/cn/ilapage/goauto/agent/RuleExecutorTest.kt index 2e7174b..5cde03b 100644 --- a/android/app/src/test/java/cn/ilapage/goauto/agent/RuleExecutorTest.kt +++ b/android/app/src/test/java/cn/ilapage/goauto/agent/RuleExecutorTest.kt @@ -1,9 +1,13 @@ package cn.ilapage.goauto.agent import cn.ilapage.goauto.agent.automation.NodeSelector +import cn.ilapage.goauto.agent.automation.HookExecutor +import cn.ilapage.goauto.agent.automation.HookStage import cn.ilapage.goauto.agent.automation.RuleExecutor import cn.ilapage.goauto.agent.automation.RuleParser import cn.ilapage.goauto.agent.automation.RuleValidationException +import cn.ilapage.goauto.agent.automation.SemanticTarget +import cn.ilapage.goauto.agent.automation.SwipeDirection import cn.ilapage.goauto.agent.automation.UiDriver import cn.ilapage.goauto.agent.automation.UiNodeRef import org.junit.Assert.assertEquals @@ -80,11 +84,44 @@ class RuleExecutorTest { assertEquals("RULE_NOT_MATCHED", result.code) } + @Test + fun `v2 hook expresses two bounded spec panel swipes`() { + val rule = RuleParser.parse(validV2Rule()) + val driver = FakeDriver(emptyMap()) + + val result = HookExecutor(driver, pause = {}).execute( + rule.hooks.getValue(HookStage.AFTER_SPEC_PANEL_OPEN), + ) + + assertTrue(result.successful) + assertEquals(2, driver.swipeCount) + } + + @Test + fun `v2 rejects unknown hook target and unbounded count`() { + listOf( + validV2Rule().replace("\"specPanel\"", "\"screen\""), + validV2Rule().replace("\"count\":2", "\"count\":6"), + ).forEach { raw -> + val error = runCatching { RuleParser.parse(raw) }.exceptionOrNull() + assertTrue(error is RuleValidationException) + } + } + + private fun validV2Rule() = """{ + "schemaVersion":2,"ruleType":"pddProductDetail", + "navigation":{"steps":[{"id":"open","packageName":"com.heytap.browser","action":"click","selector":{"text":"打开拼多多APP"},"timeoutMs":1000,"optional":true}]}, + "pageEvidence":{"packageName":"com.xunmeng.pinduoduo","activityName":"com.xunmeng.pinduoduo.activity.NewPageActivity","selector":{"resourceId":"android:id/content"}}, + "hooks":{"afterSpecPanelOpen":[{"action":"swipe","target":"specPanel","direction":"up","count":2,"settleMs":350}]}, + "collector":{"collectorId":"pddProductDetailV1","specEntryStrategy":"safeBottomSpecEntryV1","priceParser":"pddRmbPriceV1","priceGranularity":"color","dimensionAliases":{"color":["颜色"],"size":["尺码"]},"timeoutsMs":{"page":30000,"specPanel":10000,"selection":2000,"price":2000,"overall":180000},"limits":{"goodsPageVerticalSwipes":3,"specHorizontalSwipes":12,"specVerticalSwipes":12,"stableEdgeReads":2,"stablePriceReads":2,"maxSkuCount":500}} + }""" + private class FakeDriver( private val nodes: Map>, private val activity: String = "com.xunmeng.pinduoduo.activity.NewPageActivity", ) : UiDriver { var clickCount = 0 + var swipeCount = 0 override fun currentPackage() = "com.xunmeng.pinduoduo" override fun currentActivity() = activity override fun visibleTexts() = emptyList() @@ -92,5 +129,6 @@ class RuleExecutorTest { override fun click(node: UiNodeRef): Boolean { clickCount += 1; return true } override fun input(node: UiNodeRef, value: String) = true override fun back() = true + override fun swipe(target: SemanticTarget, direction: SwipeDirection): Boolean { swipeCount += 1; return true } } } diff --git a/docs/00-project-profile.md b/docs/00-project-profile.md index 57c9d00..ea160a4 100644 --- a/docs/00-project-profile.md +++ b/docs/00-project-profile.md @@ -32,4 +32,4 @@ ## 当前阶段 -当前 MVP 的 T01~T07、T09~T18 均已完成实现、验证并由用户验收。T08(只读实时屏幕)已延期且未实施,不属于当前 MVP。T17 已在一加/ColorOS 真机完成指定设备领取、空闲领取、PDD 商品详情页到达和部分结果提交验证,不包含华为兼容。当前范围仍只包含从人工添加 PDD URL 到任务详情查看结果的最小闭环,任何相邻业务必须另建 MVP 并重新确认。 +当前 MVP 的 T01~T07、T09~T19 均已完成实现、验证并由用户验收。T08(只读实时屏幕)已延期且未实施,不属于当前 MVP。T17 已在一加/ColorOS 真机完成指定设备领取、空闲领取、PDD 商品详情页到达和部分结果提交验证,不包含华为兼容。T20 正在实现 v2 规则契约和设备能力协商,随后由 T21~T23 完成采集器、管理端规则模板和真机验收。当前实施范围仍是采集闭环;Agent 架构允许未来增加独立采购规则的创建订单能力,但付款能力禁止进入项目。 diff --git a/docs/02-architecture-and-code-map.md b/docs/02-architecture-and-code-map.md index 58106af..de1465e 100644 --- a/docs/02-architecture-and-code-map.md +++ b/docs/02-architecture-and-code-map.md @@ -43,7 +43,7 @@ Android Portal/Agent | 表 | 必要内容 | |---|---| -| `agent_device` | 唯一 `install_id`、设备信息、状态、Token 摘要、最后心跳 | +| `agent_device` | 唯一 `install_id`、设备信息、状态、Token 摘要、版本化能力、最后心跳 | | `pdd_product` | `id`、唯一 `goods_id`、当前 `url`、创建/更新时间 | | `collection_rule` | `id`、`name`、`content_json`、创建/更新时间、`deleted_at` | | `collection_task` | 商品/设备外键、五态状态、URL/goods_id/规则快照、租约、结果摘要、错误和时间 | @@ -55,6 +55,8 @@ Android Portal/Agent `collection_task` 的状态仅为 `pending`、`running`、`completed`、`completed_partial`、`failed`。设备身份和心跳表属于 Agent 领取任务的必要基础,不承载 PDD 业务数据。 +设备以 JSON 数组保存最后一次注册或心跳上报的版本化能力。v1 任务兼容未上报能力的旧 Agent;v2 任务在创建指定设备任务、获取下一任务、领取和开始四个边界重复校验能力,避免旧 APK 执行未知规则。 + 数据库使用两个可空 guard 列表达跨数据库唯一约束:活动任务的 `active_slot=1`,运行中设备的 `device_run_slot=1`;终态记录对应列为 `NULL`。复合唯一索引据此保证同商品最多一个活动任务、同设备最多一个运行中任务,同时允许保留任意数量的终态历史任务。状态与 guard 列还有数据库检查约束,必须在同一条状态变更语句中更新。 ## 已建立的工程入口 diff --git a/docs/03-business-rules-and-glossary.md b/docs/03-business-rules-and-glossary.md index eef5c90..7811d36 100644 --- a/docs/03-business-rules-and-glossary.md +++ b/docs/03-business-rules-and-glossary.md @@ -19,6 +19,8 @@ - 删除采用软删除;删除后不能创建新任务,但已有任务继续执行自身快照。 - 商品详情步骤应声明精确 `activityName`,并与包名和唯一控件共同作为页面证据;进入 PDD 登录 Activity 必须返回 `PDD_LOGIN_REQUIRED`,不能提交采集成功。 - 唯一文字节点不可点击时,Agent 只可点击其最近的可点击父容器;不得改点兄弟节点或相似文字。 +- v2 规则使用类型化动作和固定阶段钩子。已由 Agent 支持的选择器、别名、超时、滑动方向和有限次数可以只更新规则;新增动作类型或页面算法才需要升级 Agent。 +- Agent 可扩展,但规则必须按任务类型授权:采集规则不能创建订单,采购规则未来可以使用独立的创建订单能力,任何规则都不能付款。 ## 采集任务 @@ -47,6 +49,7 @@ - 登录失效、验证码、风控、人机验证和找不到唯一控件时失败并给出具体错误。 - 不使用 OCR/VLM 兜底,不猜测缺失数据,不保存原始控件树或截图。 - 当前 MVP 不创建订单、不支付、不提供实时屏幕或管理端远程控制。 +- Agent 的长期能力可以扩展到采购规则中的“创建订单”,但采集规则不能调用该动作;采购必须作为独立高风险 MVP 实施。付款、免密支付及任何等价动作始终禁止。 ## 设备身份与认证 diff --git a/docs/08-agent-api-contract.md b/docs/08-agent-api-contract.md index ade707b..ea89bc8 100644 --- a/docs/08-agent-api-contract.md +++ b/docs/08-agent-api-contract.md @@ -31,7 +31,7 @@ GET /api/admin/v1/collection-rules 规则只有 `name` 和 `content` 等当前值,创建成功后立即可用。删除为软删除;已删除规则不能创建新任务,已有任务继续使用任务内快照。 -`content` 使用以下 `schemaVersion: 1` 契约: +`content` 支持原有 `schemaVersion: 1` 线性步骤和 `schemaVersion: 2` 类型化采集器契约。v1 契约如下: ```json { @@ -61,6 +61,25 @@ GET /api/admin/v1/collection-rules - 规则解析阶段拒绝支付、付款、提交订单等危险目标。完整控件树只在 Android 内存中用于匹配,不保存也不上传。 - 浏览器和 Android 系统包中的 `click` 只允许精确目标 `打开拼多多APP`、`打开拼多多 App` 或 `打开`;其它点击在规则解析阶段拒绝。Android 先以浏览器打开服务端已规范化的 PDD URL,再由这些受控步骤进入拼多多。 +### v2 PDD 商品详情规则 + +v2 完整示例见 [PDD 商品详情规则](rules/pdd-product-detail-v2.proposed.json)。规则声明: + +- `ruleType: pddProductDetail`。 +- `navigation.steps`:只负责浏览器和系统确认层;不能点击 PDD 页面。 +- `pageEvidence`:精确的 PDD 包名、Activity 和非空节点证据。 +- `collector.collectorId: pddProductDetailV1`:引用 Agent 中经过测试的类型化能力,不下发可执行代码。 +- `hooks.afterSpecPanelOpen`:固定阶段的安全动作;当前只允许对语义目标 `specPanel` 执行 `swipe`,方向为上下左右、单动作次数 1~5、等待 0~2000 毫秒,单阶段最多 8 个动作。 +- `collector.dimensionAliases`、`timeoutsMs` 和 `limits`:分别管理规格标题别名、超时和遍历/SKU 上限。 + +例如规格面板打开后向上滑动两次,只修改规则: + +```json +{"hooks":{"afterSpecPanelOpen":[{"action":"swipe","target":"specPanel","direction":"up","count":2,"settleMs":350}]}} +``` + +Agent 使用可扩展的类型化动作注册表,而不是任意脚本。采集规则不能创建订单;未来采购规则可以引用单独审核的创建订单能力,但任何规则都不能执行付款。 + ## 管理端:采集任务 ```http @@ -110,12 +129,13 @@ POST /api/agent/v1/heartbeat { "requestId": "uuid", "installId": "uuid", - "name": "HUAWEI-01", - "manufacturer": "HUAWEI", - "model": "Mate 60", - "androidVersion": "14", + "name": "OPPO-PKG110", + "manufacturer": "OPPO", + "model": "PKG110", + "androidVersion": "16", "agentVersion": "0.1.0", - "pddVersion": "7.72.0" + "pddVersion": "7.72.0", + "capabilities": ["rule.schema.v2", "action.swipe.v1", "collector.pdd.product-detail.v1"] } ``` @@ -124,6 +144,7 @@ POST /api/agent/v1/heartbeat - 已存在 `installId` 的新请求必须携带该设备的 Bearer Token,认证成功后幂等更新设备信息,不轮换 Token。 - 缺少或使用错误/已吊销 Token 返回 HTTP 409 和 `DEVICE_INSTALL_ID_CONFLICT`。 - 生产模式只接受 TLS。只有服务部署在可信反向代理之后并显式设置 `GOAUTO_TRUST_FORWARDED_PROTO=true` 时,服务端才接受代理的 `X-Forwarded-Proto: https`;不得在服务直接暴露公网时开启。注册接口另有单实例、按直连来源 IP 的基础限流,网关仍须设置共享限流。 +- `capabilities` 最多 32 项,使用小写的版本化能力名。旧 Agent 可以不提交该字段并继续执行 v1;v2 任务只能由包含规则所需全部能力的设备领取。 管理员设备动作: @@ -139,7 +160,8 @@ POST /api/admin/v1/devices/{deviceId}/token/revoke ```json { "requestId": "uuid", - "currentTaskId": "task-id-or-null" + "currentTaskId": "task-id-or-null", + "capabilities": ["rule.schema.v2", "action.swipe.v1"] } ``` @@ -163,6 +185,7 @@ POST /api/agent/v1/tasks/{taskId}/start 2. `deviceId` 为空的任务可由在线且没有活动任务的设备领取。 3. `claim` 在一个数据库事务中设置设备和租约,竞争失败返回 `TASK_ALREADY_CLAIMED`。 4. 设备已有活动任务时返回 `DEVICE_BUSY`。 +5. 指定设备和领取设备必须具备规则快照要求的全部能力;不兼容时返回 `DEVICE_CAPABILITY_MISMATCH`。未指定设备任务会跳过不兼容设备。 `claim` 保持任务为 `pending`,写入设备、两分钟领取租约和递增的 `leaseVersion`;`start` 只接受当前设备持有的有效租约,将状态原子改为 `running`、记录开始时间并续租。Android 进程还必须以本地互斥锁确保同一时刻只有一个任务进入执行器。 @@ -234,3 +257,4 @@ POST /api/agent/v1/tasks/{taskId}/fail | `RULE_AMBIGUOUS` | 规则同时匹配多个控件 | 否 | | `TASK_ALREADY_CLAIMED` | 未指定任务已被其他设备领取 | 否 | | `DEVICE_BUSY` | 设备已有活动任务 | 否 | +| `DEVICE_CAPABILITY_MISMATCH` | 设备缺少任务规则要求的版本化能力 | 否 | diff --git a/docs/09-delivery-issues.md b/docs/09-delivery-issues.md index 73ba55c..3015b0c 100644 --- a/docs/09-delivery-issues.md +++ b/docs/09-delivery-issues.md @@ -27,6 +27,10 @@ | T17 | [#19](https://git.ilapage.cn/OPC/goauto/issues/19) | 一加最小闭环验收 | T05、T06、T09~T16 | | T18 | [#20](https://git.ilapage.cn/OPC/goauto/issues/20) | 本地 config.yaml 数据库启动配置 | T01 | | T19 | [#21](https://git.ilapage.cn/OPC/goauto/issues/21) | PDD 商品详情颜色价格与尺码采集规则 | T11、T12、T13、T17 | +| T20 | [#22](https://git.ilapage.cn/OPC/goauto/issues/22) | v2 规则契约、阶段钩子与能力协商 | T19 | +| T21 | [#23](https://git.ilapage.cn/OPC/goauto/issues/23) | Android PDD 商品详情采集器 | T20 | +| T22 | [#24](https://git.ilapage.cn/OPC/goauto/issues/24) | 内置规则模板与管理端校验 | T20、T21 | +| T23 | [#25](https://git.ilapage.cn/OPC/goauto/issues/25) | v2 一加真机验收 | T20~T22 | ## 延期 diff --git a/docs/11-pdd-detail-rule-migration-analysis.md b/docs/11-pdd-detail-rule-migration-analysis.md index b25e501..c269ab8 100644 --- a/docs/11-pdd-detail-rule-migration-analysis.md +++ b/docs/11-pdd-detail-rule-migration-analysis.md @@ -4,7 +4,7 @@ 从 `D:\chengma\cmautobuy\client` 迁移这套能力是合理的,但不能把其中的文字、resource-id 或坐标直接拼成 GoAuto `schemaVersion: 1` 规则。源项目的可靠性来自一套有状态采集算法,而 GoAuto v1 目前只支持唯一节点上的单次 `wait`、`click`、`input`、`back` 和 `extract`。 -推荐新增 `schemaVersion: 2` 的受限高层采集动作:服务端规则管理页面证据、规格别名、超时和遍历上限;Android Agent 固化遍历、选中确认、稳定采价和禁止下单/支付的算法。规则草案见 [pdd-product-detail-v2.proposed.json](rules/pdd-product-detail-v2.proposed.json)。该文件目前是设计输入,v2 执行器完成前不能导入管理端。 +推荐新增 `schemaVersion: 2`、可扩展的类型化动作注册表和高层采集动作:服务端规则管理页面证据、规格别名、阶段钩子、超时和遍历上限;Android Agent 提供经过测试的动作实现,并由策略层按 `ruleType` 授权。规则草案见 [pdd-product-detail-v2.proposed.json](rules/pdd-product-detail-v2.proposed.json)。该文件目前是设计输入,v2 执行器完成前不能导入管理端。 ## 源项目实际做法 @@ -53,9 +53,11 @@ Android 受限状态机 结构化结果(不含原始控件树和截图) ``` -### 为什么不做通用脚本 DSL +### 为什么采用可扩展动作注册表,而不是任意脚本 DSL -把循环、任意正则、坐标点击和条件跳转都开放给服务端,会使规则成为远程执行脚本:难以静态验证、难以保证不会触发下单,并且每条规则都要重复实现边界和超时。高层采集器只暴露有限配置,既保留集中管理,又能在 Android 内统一测试安全状态机。 +Agent 不限制为“只能采集”,而是注册带版本的类型化能力,例如 `swipe.v1`、`pddProductDetail.v1` 和未来的 `pddCreateOrder.v1`。规则可以组合 Agent 已声明支持的动作,因此增加一次已支持的滑动不需要升级 APK;只有出现新动作类型或新页面算法时才升级 Agent。 + +把任意脚本、任意坐标和不受约束的循环开放给服务端仍不可取:它们无法静态校验,可能绕过任务类型边界,也很难证明不会误触付款。类型化动作注册表保留扩展性,同时让服务端校验参数,让 Android 策略层做最终授权。 ### 服务端可配置内容 @@ -64,25 +66,47 @@ Android 受限状态机 - 颜色、尺码标题的精确别名列表。 - 页面、选中、价格稳定超时。 - 最大商品页纵向滑动、规格横向/纵向滑动和最大 SKU 数量。 +- 固定阶段的安全钩子;当前支持语义目标、方向、次数和动作后等待。 + +例如规格面板打开后向上滑动两次,只需更新规则,不需要修改 Agent: + +```json +{ + "hooks": { + "afterSpecPanelOpen": [ + { + "action": "swipe", + "target": "specPanel", + "direction": "up", + "count": 2, + "settleMs": 350 + } + ] + } +} +``` ### Android 固定内容 -- 规格入口只能使用 `safeBottomSpecEntryV1` 安全策略,禁止规则提供任意坐标。 +- 动作注册表与参数边界;规则只能引用 Agent 声明支持的能力,不能提交可执行代码。 +- 规格入口默认使用 `safeBottomSpecEntryV1` 策略,不接受规则提供任意坐标。 - 价格只能使用固定人民币解析器,金额输出为整数分。 - 每次颜色点击后必须重新获取内存控件树;不能复用旧节点。 - 若页面暴露 `selected`/`checked` 或“已选”摘要,必须据此确认颜色已选中。 - 价格必须连续两次读取一致才算该颜色的价格。 - 尺码只读,不点击;禁止任何提交订单和支付动作。 - 每个循环都有次数和总时长上限。 +- `collection` 规则不能调用创建订单动作;未来 `purchase` 规则可以调用专用的 `pddCreateOrderV1`,但付款相关动作在最底层始终拒绝。 ## 相对源项目的优化 1. **不复制 XML 解析实现。** GoAuto 直接把 `AccessibilityNodeInfo` 投影为仅存在内存的不可变树模型,避免序列化、落盘和再次解析 XML。 2. **保留浏览器链。** 源项目直接深链,GoAuto 则继续验证浏览器提示、系统确认和最终 PDD Activity,符合当前设备部署方式。 -3. **能力协商。** 设备注册/心跳应上报 `collector.pdd.product-detail.v1`;服务端不能把 v2 任务发给旧 APK。只比较 `agentVersion` 不够可靠。 +3. **能力协商。** 设备注册/心跳应上报 `rule.schema.v2`、`action.swipe.v1` 和 `collector.pdd.product-detail.v1`;服务端不能把 v2 任务发给旧 APK。只比较 `agentVersion` 不够可靠。 4. **第三维不静默合并。** 若出现颜色、尺码之外的维度,保存已确认的数据并提交 `completed_partial`,在 `missing` 标记不支持维度;不伪造完整 SKU。源项目当前直接失败,和 GoAuto“数据不齐仍提交”的规则不完全一致。 5. **缺价不伪造 SKU。** 颜色仍进入维度,缺少稳定价格时记录 `price:<颜色>`;不为该颜色生成带虚构价格的 SKU。 6. **规则能力版本独立。** `schemaVersion` 管契约形状,`collectorId` 管 Android 算法能力,便于以后修复页面适配而不破坏已有任务快照。 +7. **采集与采购分权。** Agent 平台可以扩展到创建订单,但规则必须声明类型;采集规则无权创建订单,采购规则可以在独立高风险流程中创建订单,任何规则都无权付款。 ## 建议实施顺序 diff --git a/docs/rules/pdd-product-detail-v2.proposed.json b/docs/rules/pdd-product-detail-v2.proposed.json index 3c279d9..65581d1 100644 --- a/docs/rules/pdd-product-detail-v2.proposed.json +++ b/docs/rules/pdd-product-detail-v2.proposed.json @@ -35,6 +35,9 @@ "className": "android.widget.FrameLayout" } }, + "hooks": { + "afterSpecPanelOpen": [] + }, "collector": { "collectorId": "pddProductDetailV1", "specEntryStrategy": "safeBottomSpecEntryV1", diff --git a/server/app/goauto/device/capabilities.go b/server/app/goauto/device/capabilities.go new file mode 100644 index 0000000..8c7991b --- /dev/null +++ b/server/app/goauto/device/capabilities.go @@ -0,0 +1,72 @@ +package device + +import ( + "encoding/json" + "fmt" + "regexp" + "sort" + "strings" + + "go-admin/app/goauto/models" +) + +var capabilityPattern = regexp.MustCompile(`^[a-z][a-z0-9.-]{0,79}$`) + +func normalizeCapabilities(values []string) ([]string, error) { + if len(values) > 32 { + return nil, invalidRequest("capabilities 最多 32 项") + } + set := make(map[string]bool, len(values)) + for _, raw := range values { + value := strings.ToLower(strings.TrimSpace(raw)) + if !capabilityPattern.MatchString(value) { + return nil, invalidRequest(fmt.Sprintf("capability 格式无效: %s", raw)) + } + set[value] = true + } + result := make([]string, 0, len(set)) + for value := range set { + result = append(result, value) + } + sort.Strings(result) + return result, nil +} + +func encodeCapabilities(values []string) string { + if values == nil { + values = []string{} + } + raw, _ := json.Marshal(values) + return string(raw) +} + +func Capabilities(record models.AgentDevice) ([]string, error) { + var result []string + if record.CapabilitiesJSON == "" { + return []string{}, nil + } + if err := json.Unmarshal([]byte(record.CapabilitiesJSON), &result); err != nil { + return nil, internalError(fmt.Errorf("device capabilities invalid: %w", err)) + } + return result, nil +} + +func Supports(record models.AgentDevice, required []string) (bool, error) { + if len(required) == 0 { + return true, nil + } + values, err := Capabilities(record) + if err != nil { + return false, err + } + have := make(map[string]bool, len(values)) + for _, value := range values { + have[value] = true + } + for _, value := range required { + if !have[value] { + return false, nil + } + } + return true, nil +} diff --git a/server/app/goauto/device/heartbeat.go b/server/app/goauto/device/heartbeat.go index 9687ab5..c015eeb 100644 --- a/server/app/goauto/device/heartbeat.go +++ b/server/app/goauto/device/heartbeat.go @@ -18,8 +18,9 @@ const ( ) type HeartbeatRequest struct { - RequestID string `json:"requestId"` - CurrentTaskID *uint64 `json:"currentTaskId"` + RequestID string `json:"requestId"` + CurrentTaskID *uint64 `json:"currentTaskId"` + Capabilities []string `json:"capabilities,omitempty"` } type HeartbeatResponse struct { @@ -40,12 +41,16 @@ func (service *Service) Heartbeat(ctx context.Context, request HeartbeatRequest, if presentedToken == "" { return HeartbeatResponse{}, tokenInvalidError() } + normalizedCapabilities, err := normalizeCapabilities(request.Capabilities) + if err != nil { + return HeartbeatResponse{}, err + } interval := service.HeartbeatInterval if interval <= 0 { interval = DefaultHeartbeatIntervalSeconds } var response HeartbeatResponse - err := service.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + err = service.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error { var device models.AgentDevice digest := tokenDigest(presentedToken) if err := tx.Where("token_digest = ? AND token_revoked_at IS NULL", digest).First(&device).Error; err != nil { @@ -72,12 +77,16 @@ func (service *Service) Heartbeat(ctx context.Context, request HeartbeatRequest, if replayed && device.LastHeartbeatAt != nil { now = *device.LastHeartbeatAt } else { + updates := map[string]any{ + "status": models.DeviceStatusOnline, "last_heartbeat_at": now, + "last_heartbeat_request_id": request.RequestID, + } + if request.Capabilities != nil { + updates["capabilities_json"] = encodeCapabilities(normalizedCapabilities) + } result := tx.Model(&models.AgentDevice{}). Where("id = ? AND token_revoked_at IS NULL AND status <> ?", device.ID, models.DeviceStatusDisabled). - Updates(map[string]any{ - "status": models.DeviceStatusOnline, "last_heartbeat_at": now, - "last_heartbeat_request_id": request.RequestID, - }) + Updates(updates) if result.Error != nil { return internalError(result.Error) } diff --git a/server/app/goauto/device/heartbeat_test.go b/server/app/goauto/device/heartbeat_test.go index 968ec81..fc4a991 100644 --- a/server/app/goauto/device/heartbeat_test.go +++ b/server/app/goauto/device/heartbeat_test.go @@ -51,6 +51,27 @@ func TestHeartbeatAuthenticatesAndIsIdempotent(t *testing.T) { } } +func TestHeartbeatRefreshesCapabilities(t *testing.T) { + db := openTestDatabase(t) + service := newTestService(t, db) + _, registered := registerHeartbeatDevice(t, service) + request := HeartbeatRequest{ + RequestID: uuid.NewString(), + Capabilities: []string{"rule.schema.v2", "action.swipe.v1"}, + } + if _, err := service.Heartbeat(context.Background(), request, testDeviceToken); err != nil { + t.Fatalf("heartbeat: %v", err) + } + var stored models.AgentDevice + if err := db.First(&stored, registered.DeviceID).Error; err != nil { + t.Fatalf("load device: %v", err) + } + capabilities, err := Capabilities(stored) + if err != nil || len(capabilities) != 2 { + t.Fatalf("capabilities not refreshed: %v %v", capabilities, err) + } +} + func TestHeartbeatRequiresCurrentTaskToMatchServer(t *testing.T) { db := openTestDatabase(t) service := newTestService(t, db) diff --git a/server/app/goauto/device/list.go b/server/app/goauto/device/list.go index d368791..ad72ef9 100644 --- a/server/app/goauto/device/list.go +++ b/server/app/goauto/device/list.go @@ -27,6 +27,7 @@ type DeviceListItem struct { AndroidVersion string `json:"androidVersion"` AgentVersion string `json:"agentVersion"` PDDVersion string `json:"pddVersion"` + Capabilities []string `json:"capabilities"` Status string `json:"status"` Busy bool `json:"busy"` Selectable bool `json:"selectable"` @@ -100,10 +101,15 @@ func (service *Service) List(ctx context.Context, request ListRequest) (DeviceLi currentTasks[task.DeviceID] = task.ID } for _, record := range records { + capabilities, err := Capabilities(record) + if err != nil { + return DeviceListResponse{}, err + } item := DeviceListItem{ ID: record.ID, Name: record.Name, Manufacturer: record.Manufacturer, Model: record.Model, AndroidVersion: record.AndroidVersion, AgentVersion: record.AgentVersion, PDDVersion: record.PDDVersion, - Status: record.Status, LastHeartbeatAt: record.LastHeartbeatAt, + Capabilities: capabilities, + Status: record.Status, LastHeartbeatAt: record.LastHeartbeatAt, TokenRevoked: record.TokenRevokedAt != nil, CreatedAt: record.CreatedAt, UpdatedAt: record.UpdatedAt, } diff --git a/server/app/goauto/device/service.go b/server/app/goauto/device/service.go index 0aee6f4..95d5681 100644 --- a/server/app/goauto/device/service.go +++ b/server/app/goauto/device/service.go @@ -47,14 +47,15 @@ func (err *ServiceError) Error() string { func (err *ServiceError) Unwrap() error { return err.Cause } type RegisterRequest struct { - RequestID string `json:"requestId"` - InstallID string `json:"installId"` - Name string `json:"name"` - Manufacturer string `json:"manufacturer"` - Model string `json:"model"` - AndroidVersion string `json:"androidVersion"` - AgentVersion string `json:"agentVersion"` - PDDVersion string `json:"pddVersion"` + RequestID string `json:"requestId"` + InstallID string `json:"installId"` + Name string `json:"name"` + Manufacturer string `json:"manufacturer"` + Model string `json:"model"` + AndroidVersion string `json:"androidVersion"` + AgentVersion string `json:"agentVersion"` + PDDVersion string `json:"pddVersion"` + Capabilities []string `json:"capabilities,omitempty"` } type RegisterResponse struct { @@ -171,6 +172,9 @@ func (service *Service) Register(ctx context.Context, request RegisterRequest, p "android_version": request.AndroidVersion, "agent_version": request.AgentVersion, "pdd_version": request.PDDVersion, "last_register_request_id": request.RequestID, } + if request.Capabilities != nil { + updates["capabilities_json"] = encodeCapabilities(request.Capabilities) + } result := db.Model(&models.AgentDevice{}). Where("id = ? AND token_digest = ? AND token_revoked_at IS NULL", existing.ID, existing.TokenDigest). Updates(updates) @@ -197,7 +201,7 @@ func (service *Service) Register(ctx context.Context, request RegisterRequest, p device := models.AgentDevice{ InstallID: request.InstallID, Name: request.Name, Manufacturer: request.Manufacturer, Model: request.Model, AndroidVersion: request.AndroidVersion, AgentVersion: request.AgentVersion, - PDDVersion: request.PDDVersion, Status: models.DeviceStatusOnline, TokenDigest: tokenDigest(token), + PDDVersion: request.PDDVersion, CapabilitiesJSON: encodeCapabilities(request.Capabilities), Status: models.DeviceStatusOnline, TokenDigest: tokenDigest(token), TokenIssuedAt: now, LastRegisterRequestID: &request.RequestID, } if err := db.Create(&device).Error; err != nil { @@ -269,6 +273,11 @@ func normalizeRegisterRequest(request RegisterRequest) RegisterRequest { request.AndroidVersion = strings.TrimSpace(request.AndroidVersion) request.AgentVersion = strings.TrimSpace(request.AgentVersion) request.PDDVersion = strings.TrimSpace(request.PDDVersion) + if request.Capabilities != nil { + if normalized, err := normalizeCapabilities(request.Capabilities); err == nil { + request.Capabilities = normalized + } + } return request } @@ -293,6 +302,9 @@ func validateRegisterRequest(request RegisterRequest) error { return invalidRequest(fmt.Sprintf("%s 必填且长度不能超过 %d", field.name, field.max)) } } + if _, err := normalizeCapabilities(request.Capabilities); err != nil { + return err + } return nil } diff --git a/server/app/goauto/device/service_test.go b/server/app/goauto/device/service_test.go index e735084..cb88a51 100644 --- a/server/app/goauto/device/service_test.go +++ b/server/app/goauto/device/service_test.go @@ -80,6 +80,28 @@ func TestFirstRegistrationCreatesEnabledDeviceAndReturnsTokenOnce(t *testing.T) } } +func TestRegistrationNormalizesAndStoresCapabilities(t *testing.T) { + db := openTestDatabase(t) + service := newTestService(t, db) + request := validRegisterRequest() + request.Capabilities = []string{"ACTION.SWIPE.V1", "rule.schema.v2", "action.swipe.v1"} + response, err := service.Register(context.Background(), request, "") + if err != nil { + t.Fatalf("register: %v", err) + } + var stored models.AgentDevice + if err := db.First(&stored, response.DeviceID).Error; err != nil { + t.Fatalf("load device: %v", err) + } + capabilities, err := Capabilities(stored) + if err != nil { + t.Fatalf("decode capabilities: %v", err) + } + if strings.Join(capabilities, ",") != "action.swipe.v1,rule.schema.v2" { + t.Fatalf("capabilities were not normalized: %v", capabilities) + } +} + func TestSameRequestIDIsIdempotentWithoutReturningTokenAgain(t *testing.T) { db := openTestDatabase(t) service := newTestService(t, db) diff --git a/server/app/goauto/migrations/migrate_test.go b/server/app/goauto/migrations/migrate_test.go index 458da90..58c3a50 100644 --- a/server/app/goauto/migrations/migrate_test.go +++ b/server/app/goauto/migrations/migrate_test.go @@ -135,6 +135,9 @@ func TestDeviceSchemaStoresOnlyTokenDigest(t *testing.T) { if !names["token_digest"] { t.Fatal("token_digest column missing") } + if !names["capabilities_json"] { + t.Fatal("capabilities_json column missing") + } for _, forbidden := range []string{"token", "device_token", "raw_token"} { if names[forbidden] { t.Fatalf("raw token column must not exist: %s", forbidden) diff --git a/server/app/goauto/models/schema.go b/server/app/goauto/models/schema.go index 66c61e2..123f708 100644 --- a/server/app/goauto/models/schema.go +++ b/server/app/goauto/models/schema.go @@ -31,6 +31,7 @@ type AgentDevice struct { AndroidVersion string `json:"androidVersion" gorm:"size:32;not null"` AgentVersion string `json:"agentVersion" gorm:"size:32;not null"` PDDVersion string `json:"pddVersion" gorm:"size:32;not null"` + CapabilitiesJSON string `json:"-" gorm:"size:4096;not null;default:'[]'"` Status string `json:"status" gorm:"size:16;not null;index;check:ck_agent_device_status,status IN ('online','offline','disabled')"` TokenDigest string `json:"-" gorm:"size:64;not null;uniqueIndex:ux_agent_device_token_digest"` TokenIssuedAt time.Time `json:"tokenIssuedAt" gorm:"not null"` diff --git a/server/app/goauto/rule/service.go b/server/app/goauto/rule/service.go index b52d626..f33c639 100644 --- a/server/app/goauto/rule/service.go +++ b/server/app/goauto/rule/service.go @@ -8,6 +8,7 @@ import ( "strings" "go-admin/app/goauto/models" + "go-admin/app/goauto/rulecontract" "github.com/google/uuid" "gorm.io/gorm" @@ -194,9 +195,8 @@ func validateSave(request SaveRequest) (string, string, error) { if err := json.Unmarshal(request.Content, &object); err != nil || object == nil { return "", "", invalidRule("content 必须是 JSON 对象") } - var version int - if raw, ok := object["schemaVersion"]; !ok || json.Unmarshal(raw, &version) != nil || version != 1 { - return "", "", invalidRule("content.schemaVersion 必须为 1") + if err := rulecontract.Validate(request.Content); err != nil { + return "", "", invalidRule(err.Error()) } normalized, err := json.Marshal(object) if err != nil { diff --git a/server/app/goauto/rulecontract/contract.go b/server/app/goauto/rulecontract/contract.go new file mode 100644 index 0000000..85410ae --- /dev/null +++ b/server/app/goauto/rulecontract/contract.go @@ -0,0 +1,268 @@ +package rulecontract + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "sort" + "strings" +) + +const ( + CapabilitySchemaV2 = "rule.schema.v2" + CapabilitySwipeV1 = "action.swipe.v1" + CapabilityPDDProductDetailV1 = "collector.pdd.product-detail.v1" +) + +var allowedNavigationPackages = map[string]bool{ + "com.xunmeng.pinduoduo": true, + "com.android.chrome": true, + "com.heytap.browser": true, + "com.android.browser": true, + "android": true, +} + +var allowedOpenTargets = map[string]bool{ + "打开拼多多APP": true, + "打开拼多多 App": true, + "打开": true, +} + +type selector struct { + ResourceID string `json:"resourceId,omitempty"` + Text string `json:"text,omitempty"` + ContentDescription string `json:"contentDescription,omitempty"` + ClassName string `json:"className,omitempty"` + Clickable *bool `json:"clickable,omitempty"` +} + +func (value selector) empty() bool { + return value.ResourceID == "" && value.Text == "" && value.ContentDescription == "" && value.ClassName == "" && value.Clickable == nil +} + +type navigationStep struct { + ID string `json:"id"` + Page string `json:"page,omitempty"` + PackageName string `json:"packageName"` + ActivityName string `json:"activityName,omitempty"` + Action string `json:"action"` + Selector *selector `json:"selector,omitempty"` + TimeoutMS int `json:"timeoutMs,omitempty"` + Optional bool `json:"optional,omitempty"` +} + +type hookAction struct { + Action string `json:"action"` + Target string `json:"target"` + Direction string `json:"direction"` + Count int `json:"count"` + SettleMS int `json:"settleMs,omitempty"` +} + +type collectorConfig struct { + CollectorID string `json:"collectorId"` + SpecEntryStrategy string `json:"specEntryStrategy"` + PriceParser string `json:"priceParser"` + PriceGranularity string `json:"priceGranularity"` + DimensionAliases map[string][]string `json:"dimensionAliases"` + TimeoutsMS map[string]int `json:"timeoutsMs"` + Limits map[string]int `json:"limits"` +} + +type v2Rule struct { + SchemaVersion int `json:"schemaVersion"` + RuleType string `json:"ruleType"` + Navigation struct { + Steps []navigationStep `json:"steps"` + } `json:"navigation"` + PageEvidence struct { + PackageName string `json:"packageName"` + ActivityName string `json:"activityName"` + Selector selector `json:"selector"` + } `json:"pageEvidence"` + Hooks map[string][]hookAction `json:"hooks,omitempty"` + Collector collectorConfig `json:"collector"` +} + +// Validate accepts the legacy v1 shape and strictly validates the v2 product +// detail contract. Android remains the final enforcement point for every +// device-side action. +func Validate(raw []byte) error { + var header struct { + SchemaVersion int `json:"schemaVersion"` + } + if err := json.Unmarshal(raw, &header); err != nil { + return errors.New("content 必须是 JSON 对象") + } + switch header.SchemaVersion { + case 1: + return nil + case 2: + _, err := parseV2(raw) + return err + default: + return errors.New("content.schemaVersion 只支持 1 或 2") + } +} + +// RequiredCapabilities derives scheduling requirements from an immutable rule +// snapshot. V1 deliberately has no capability requirement for compatibility +// with devices registered before capability reporting existed. +func RequiredCapabilities(raw string) ([]string, error) { + var header struct { + SchemaVersion int `json:"schemaVersion"` + } + if err := json.Unmarshal([]byte(raw), &header); err != nil { + return nil, errors.New("规则快照不是有效 JSON") + } + if header.SchemaVersion <= 1 { + return nil, nil + } + rule, err := parseV2([]byte(raw)) + if err != nil { + return nil, err + } + required := map[string]bool{ + CapabilitySchemaV2: true, + CapabilityPDDProductDetailV1: true, + } + for _, actions := range rule.Hooks { + if len(actions) > 0 { + required[CapabilitySwipeV1] = true + } + } + result := make([]string, 0, len(required)) + for capability := range required { + result = append(result, capability) + } + sort.Strings(result) + return result, nil +} + +func parseV2(raw []byte) (v2Rule, error) { + var rule v2Rule + decoder := json.NewDecoder(bytes.NewReader(raw)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&rule); err != nil { + return rule, fmt.Errorf("v2 规则字段无效: %w", err) + } + if err := ensureEOF(decoder); err != nil { + return rule, err + } + if rule.SchemaVersion != 2 || rule.RuleType != "pddProductDetail" { + return rule, errors.New("v2 规则必须声明 ruleType=pddProductDetail") + } + if len(rule.Navigation.Steps) == 0 { + return rule, errors.New("navigation.steps 必须是非空数组") + } + ids := map[string]bool{} + for index, step := range rule.Navigation.Steps { + if strings.TrimSpace(step.ID) == "" || ids[step.ID] { + return rule, fmt.Errorf("navigation.steps[%d].id 为空或重复", index) + } + ids[step.ID] = true + if !allowedNavigationPackages[step.PackageName] { + return rule, fmt.Errorf("步骤 %s 的应用包不在白名单", step.ID) + } + if step.Action != "click" && step.Action != "wait" && step.Action != "back" { + return rule, fmt.Errorf("步骤 %s 的 action 不受支持", step.ID) + } + if step.Action != "back" && (step.Selector == nil || step.Selector.empty()) { + return rule, fmt.Errorf("步骤 %s 缺少 selector", step.ID) + } + if step.Action == "click" && step.PackageName == "com.xunmeng.pinduoduo" { + return rule, fmt.Errorf("v2 导航步骤 %s 不能点击 PDD 页面", step.ID) + } + if step.Action == "click" && step.PackageName != "com.xunmeng.pinduoduo" { + target := "" + if step.Selector != nil { + target = step.Selector.Text + if target == "" { + target = step.Selector.ContentDescription + } + } + if !allowedOpenTargets[target] { + return rule, fmt.Errorf("步骤 %s 不是允许的打开拼多多动作", step.ID) + } + } + if step.TimeoutMS != 0 && (step.TimeoutMS < 100 || step.TimeoutMS > 30000) { + return rule, fmt.Errorf("步骤 %s 的 timeoutMs 必须为 100..30000", step.ID) + } + } + if rule.PageEvidence.PackageName != "com.xunmeng.pinduoduo" || strings.TrimSpace(rule.PageEvidence.ActivityName) == "" || rule.PageEvidence.Selector.empty() { + return rule, errors.New("pageEvidence 必须包含 PDD 包名、精确 Activity 和非空 selector") + } + if len(rule.Hooks) > 1 { + return rule, errors.New("hooks 只允许 afterSpecPanelOpen") + } + for stage, actions := range rule.Hooks { + if stage != "afterSpecPanelOpen" { + return rule, fmt.Errorf("不支持的 hook 阶段: %s", stage) + } + if len(actions) > 8 { + return rule, errors.New("单个 hook 最多 8 个动作") + } + for _, action := range actions { + if action.Action != "swipe" || action.Target != "specPanel" { + return rule, errors.New("hook 只允许对 specPanel 执行 swipe") + } + if action.Direction != "up" && action.Direction != "down" && action.Direction != "left" && action.Direction != "right" { + return rule, errors.New("swipe.direction 不受支持") + } + if action.Count < 1 || action.Count > 5 { + return rule, errors.New("swipe.count 必须为 1..5") + } + if action.SettleMS < 0 || action.SettleMS > 2000 { + return rule, errors.New("swipe.settleMs 必须为 0..2000") + } + } + } + if rule.Collector.CollectorID != "pddProductDetailV1" || rule.Collector.SpecEntryStrategy != "safeBottomSpecEntryV1" || rule.Collector.PriceParser != "pddRmbPriceV1" || rule.Collector.PriceGranularity != "color" { + return rule, errors.New("collector 使用了 Agent 不支持的类型化能力") + } + for _, key := range []string{"color", "size"} { + aliases := rule.Collector.DimensionAliases[key] + if len(aliases) == 0 || len(aliases) > 20 { + return rule, fmt.Errorf("dimensionAliases.%s 必须包含 1..20 项", key) + } + for _, alias := range aliases { + if strings.TrimSpace(alias) == "" || len([]rune(alias)) > 30 { + return rule, fmt.Errorf("dimensionAliases.%s 含无效别名", key) + } + } + } + if err := validateIntMap(rule.Collector.TimeoutsMS, map[string][2]int{ + "page": {100, 60000}, "specPanel": {100, 30000}, "selection": {100, 10000}, "price": {100, 10000}, "overall": {1000, 600000}, + }); err != nil { + return rule, err + } + if err := validateIntMap(rule.Collector.Limits, map[string][2]int{ + "goodsPageVerticalSwipes": {0, 10}, "specHorizontalSwipes": {0, 30}, "specVerticalSwipes": {0, 30}, "stableEdgeReads": {1, 5}, "stablePriceReads": {2, 5}, "maxSkuCount": {1, 2000}, + }); err != nil { + return rule, err + } + return rule, nil +} + +func validateIntMap(values map[string]int, allowed map[string][2]int) error { + if len(values) != len(allowed) { + return errors.New("规则整数配置缺失或包含未知字段") + } + for key, bounds := range allowed { + value, ok := values[key] + if !ok || value < bounds[0] || value > bounds[1] { + return fmt.Errorf("%s 必须为 %d..%d", key, bounds[0], bounds[1]) + } + } + return nil +} + +func ensureEOF(decoder *json.Decoder) error { + var extra any + if err := decoder.Decode(&extra); err != io.EOF { + return errors.New("规则 JSON 只能包含一个对象") + } + return nil +} diff --git a/server/app/goauto/rulecontract/contract_test.go b/server/app/goauto/rulecontract/contract_test.go new file mode 100644 index 0000000..b6386c4 --- /dev/null +++ b/server/app/goauto/rulecontract/contract_test.go @@ -0,0 +1,57 @@ +package rulecontract + +import ( + "strings" + "testing" +) + +func validV2(hook string) string { + return `{ + "schemaVersion":2,"ruleType":"pddProductDetail", + "navigation":{"steps":[{"id":"open","packageName":"com.heytap.browser","action":"click","selector":{"text":"打开拼多多APP"},"timeoutMs":1000,"optional":true}]}, + "pageEvidence":{"packageName":"com.xunmeng.pinduoduo","activityName":"com.xunmeng.pinduoduo.activity.NewPageActivity","selector":{"resourceId":"android:id/content"}}, + "hooks":{"afterSpecPanelOpen":` + hook + `}, + "collector":{"collectorId":"pddProductDetailV1","specEntryStrategy":"safeBottomSpecEntryV1","priceParser":"pddRmbPriceV1","priceGranularity":"color", + "dimensionAliases":{"color":["颜色"],"size":["尺码"]}, + "timeoutsMs":{"page":30000,"specPanel":10000,"selection":2000,"price":2000,"overall":180000}, + "limits":{"goodsPageVerticalSwipes":3,"specHorizontalSwipes":12,"specVerticalSwipes":12,"stableEdgeReads":2,"stablePriceReads":2,"maxSkuCount":500}} +}` +} + +func TestV2ContractAndCapabilities(t *testing.T) { + raw := validV2(`[{"action":"swipe","target":"specPanel","direction":"up","count":2,"settleMs":350}]`) + if err := Validate([]byte(raw)); err != nil { + t.Fatalf("valid v2 rule rejected: %v", err) + } + required, err := RequiredCapabilities(raw) + if err != nil { + t.Fatalf("requirements: %v", err) + } + joined := strings.Join(required, ",") + for _, value := range []string{CapabilitySchemaV2, CapabilitySwipeV1, CapabilityPDDProductDetailV1} { + if !strings.Contains(joined, value) { + t.Fatalf("missing capability %s in %v", value, required) + } + } +} + +func TestV2RejectsUnboundedOrUnknownHookActions(t *testing.T) { + invalid := []string{ + `[{"action":"tap","target":"specPanel","direction":"up","count":1}]`, + `[{"action":"swipe","target":"screen","direction":"up","count":1}]`, + `[{"action":"swipe","target":"specPanel","direction":"up","count":6}]`, + `[{"action":"swipe","target":"specPanel","direction":"diagonal","count":1}]`, + } + for _, hook := range invalid { + if err := Validate([]byte(validV2(hook))); err == nil { + t.Fatalf("invalid hook accepted: %s", hook) + } + } +} + +func TestLegacyV1RequiresNoReportedCapabilities(t *testing.T) { + required, err := RequiredCapabilities(`{"schemaVersion":1,"steps":[]}`) + if err != nil || len(required) != 0 { + t.Fatalf("legacy capability behavior changed: %v %v", required, err) + } +} diff --git a/server/app/goauto/task/admin_service.go b/server/app/goauto/task/admin_service.go index 09d4c5e..0d39124 100644 --- a/server/app/goauto/task/admin_service.go +++ b/server/app/goauto/task/admin_service.go @@ -84,6 +84,9 @@ func (service *Service) Create(ctx context.Context, request CreateRequest) (Crea if err := tx.First(&target, *request.DeviceID).Error; err != nil || target.Status == models.DeviceStatusDisabled { return serviceError(CodeDeviceNotFound, "设备不存在或已停用") } + if err := ensureRuleCompatible(target, rule.ContentJSON); err != nil { + return err + } } var active int64 if err := tx.Model(&models.CollectionTask{}).Where("pdd_product_id = ? AND status IN ?", product.ID, diff --git a/server/app/goauto/task/service.go b/server/app/goauto/task/service.go index 6ee0394..e971da1 100644 --- a/server/app/goauto/task/service.go +++ b/server/app/goauto/task/service.go @@ -9,6 +9,7 @@ import ( "go-admin/app/goauto/device" "go-admin/app/goauto/models" + "go-admin/app/goauto/rulecontract" "github.com/google/uuid" "gorm.io/gorm" @@ -19,13 +20,14 @@ const ( DefaultLeaseDuration = 2 * time.Minute DefaultTaskTimeout = 120 - CodeTaskNotFound = "TASK_NOT_FOUND" - CodeTaskAlreadyClaimed = "TASK_ALREADY_CLAIMED" - CodeTaskAssignedOther = "TASK_ASSIGNED_OTHER_DEVICE" - CodeDeviceBusy = "DEVICE_BUSY" - CodeDeviceOffline = "DEVICE_OFFLINE" - CodeTaskStateConflict = "TASK_STATE_CONFLICT" - CodeTaskLeaseExpired = "TASK_LEASE_EXPIRED" + CodeTaskNotFound = "TASK_NOT_FOUND" + CodeTaskAlreadyClaimed = "TASK_ALREADY_CLAIMED" + CodeTaskAssignedOther = "TASK_ASSIGNED_OTHER_DEVICE" + CodeDeviceBusy = "DEVICE_BUSY" + CodeDeviceOffline = "DEVICE_OFFLINE" + CodeTaskStateConflict = "TASK_STATE_CONFLICT" + CodeTaskLeaseExpired = "TASK_LEASE_EXPIRED" + CodeDeviceCapabilityMismatch = "DEVICE_CAPABILITY_MISMATCH" ) type ServiceError struct { @@ -99,23 +101,33 @@ func (service *Service) Next(ctx context.Context, token string) (*TaskPayload, e Where("device_id = ? AND status = ? AND lease_expires_at > ?", deviceRecord.ID, models.TaskStatusPending, now). Order("created_at ASC, id ASC").First(&record).Error if err == nil { + if err := ensureRuleCompatible(deviceRecord, record.RuleSnapshot); err != nil { + return nil, err + } payload, payloadErr := service.payload(record, false) return &payload, payloadErr } if !errors.Is(err, gorm.ErrRecordNotFound) { return nil, internalError(err) } + var candidates []models.CollectionTask err = service.DB.WithContext(ctx). Where("status = ? AND (lease_expires_at IS NULL OR lease_expires_at <= ?) AND (device_id = ? OR device_id IS NULL)", models.TaskStatusPending, now, deviceRecord.ID). - Order("CASE WHEN device_id IS NULL THEN 1 ELSE 0 END, created_at ASC, id ASC").First(&record).Error - if errors.Is(err, gorm.ErrRecordNotFound) { - return nil, nil - } + Order("CASE WHEN device_id IS NULL THEN 1 ELSE 0 END, created_at ASC, id ASC").Limit(100).Find(&candidates).Error if err != nil { return nil, internalError(err) } - payload, payloadErr := service.payload(record, false) - return &payload, payloadErr + for _, candidate := range candidates { + compatible, compatibilityErr := ruleCompatible(deviceRecord, candidate.RuleSnapshot) + if compatibilityErr != nil { + return nil, compatibilityErr + } + if compatible { + payload, payloadErr := service.payload(candidate, false) + return &payload, payloadErr + } + } + return nil, nil } func (service *Service) Claim(ctx context.Context, taskID uint64, request ActionRequest, token string) (TaskPayload, error) { @@ -149,6 +161,9 @@ func (service *Service) Claim(ctx context.Context, taskID uint64, request Action if record.Status != models.TaskStatusPending { return serviceError(CodeTaskStateConflict, "只有待执行任务可以领取") } + if err := ensureRuleCompatible(deviceRecord, record.RuleSnapshot); err != nil { + return err + } now := service.Now() if record.LeaseExpiresAt != nil && record.LeaseExpiresAt.After(now) { return serviceError(CodeTaskAlreadyClaimed, "任务已被领取") @@ -214,6 +229,9 @@ func (service *Service) Start(ctx context.Context, taskID uint64, request Action if record.Status != models.TaskStatusPending { return serviceError(CodeTaskStateConflict, "任务当前状态不能开始") } + if err := ensureRuleCompatible(deviceRecord, record.RuleSnapshot); err != nil { + return err + } if record.DeviceID == nil || *record.DeviceID != deviceRecord.ID { return serviceError(CodeTaskAssignedOther, "任务不属于当前设备") } @@ -292,6 +310,29 @@ func validateAction(taskID uint64, request ActionRequest) error { return nil } +func ruleCompatible(record models.AgentDevice, snapshot string) (bool, error) { + required, err := rulecontract.RequiredCapabilities(snapshot) + if err != nil { + return false, internalError(err) + } + compatible, err := device.Supports(record, required) + if err != nil { + return false, err + } + return compatible, nil +} + +func ensureRuleCompatible(record models.AgentDevice, snapshot string) error { + compatible, err := ruleCompatible(record, snapshot) + if err != nil { + return err + } + if !compatible { + return serviceError(CodeDeviceCapabilityMismatch, "设备不支持任务规则所需能力") + } + return nil +} + func serviceError(code, message string) error { return &ServiceError{Code: code, Message: message, Retryable: false} } diff --git a/server/app/goauto/task/service_test.go b/server/app/goauto/task/service_test.go index 10f39e1..cfefff5 100644 --- a/server/app/goauto/task/service_test.go +++ b/server/app/goauto/task/service_test.go @@ -32,6 +32,10 @@ func openTaskDatabase(t *testing.T) *gorm.DB { } func registerTaskDevice(t *testing.T, db *gorm.DB, name string) (models.AgentDevice, string) { + return registerTaskDeviceWithCapabilities(t, db, name, nil) +} + +func registerTaskDeviceWithCapabilities(t *testing.T, db *gorm.DB, name string, capabilities []string) (models.AgentDevice, string) { t.Helper() token := uuid.NewString() registration := device.NewService(db) @@ -39,7 +43,7 @@ func registerTaskDevice(t *testing.T, db *gorm.DB, name string) (models.AgentDev response, err := registration.Register(context.Background(), device.RegisterRequest{ RequestID: uuid.NewString(), InstallID: uuid.NewString(), Name: name, Manufacturer: "HUAWEI", Model: "Test", AndroidVersion: "14", - AgentVersion: "0.1.0", PDDVersion: "7.72.0", + AgentVersion: "0.1.0", PDDVersion: "7.72.0", Capabilities: capabilities, }, "") if err != nil { t.Fatalf("register device: %v", err) @@ -51,6 +55,10 @@ func registerTaskDevice(t *testing.T, db *gorm.DB, name string) (models.AgentDev return record, token } +func v2TaskRuleSnapshot() string { + return `{"schemaVersion":2,"ruleType":"pddProductDetail","navigation":{"steps":[{"id":"open","packageName":"com.heytap.browser","action":"click","selector":{"text":"打开拼多多APP"},"timeoutMs":1000,"optional":true}]},"pageEvidence":{"packageName":"com.xunmeng.pinduoduo","activityName":"com.xunmeng.pinduoduo.activity.NewPageActivity","selector":{"resourceId":"android:id/content"}},"hooks":{"afterSpecPanelOpen":[]},"collector":{"collectorId":"pddProductDetailV1","specEntryStrategy":"safeBottomSpecEntryV1","priceParser":"pddRmbPriceV1","priceGranularity":"color","dimensionAliases":{"color":["颜色"],"size":["尺码"]},"timeoutsMs":{"page":30000,"specPanel":10000,"selection":2000,"price":2000,"overall":180000},"limits":{"goodsPageVerticalSwipes":3,"specHorizontalSwipes":12,"specVerticalSwipes":12,"stableEdgeReads":2,"stablePriceReads":2,"maxSkuCount":500}}}` +} + func createTask(t *testing.T, db *gorm.DB, deviceID *uint64) models.CollectionTask { t.Helper() goodsID := strings.ReplaceAll(uuid.NewString(), "-", "") @@ -166,3 +174,60 @@ func TestStartRequiresLiveLease(t *testing.T) { t.Fatalf("expected expired lease, got %v", err) } } + +func TestV2TaskIsOnlyOfferedToCapableDevice(t *testing.T) { + db := openTaskDatabase(t) + _, legacyToken := registerTaskDevice(t, db, "legacy-device") + capable, capableToken := registerTaskDeviceWithCapabilities(t, db, "capable-device", []string{ + "rule.schema.v2", "collector.pdd.product-detail.v1", + }) + goodsID := strings.ReplaceAll(uuid.NewString(), "-", "") + product := models.PDDProduct{GoodsID: goodsID, URL: "https://mobile.yangkeduo.com/goods.html?goods_id=" + goodsID} + rule := models.CollectionRule{Name: "v2", ContentJSON: v2TaskRuleSnapshot()} + if err := db.Create(&product).Error; err != nil { + t.Fatal(err) + } + if err := db.Create(&rule).Error; err != nil { + t.Fatal(err) + } + task := models.CollectionTask{ + PDDProductID: product.ID, RuleID: rule.ID, Status: models.TaskStatusPending, + URLSnapshot: product.URL, GoodsIDSnapshot: goodsID, RuleSnapshot: rule.ContentJSON, + } + if err := db.Create(&task).Error; err != nil { + t.Fatal(err) + } + service := newTaskService(db) + + if next, err := service.Next(context.Background(), legacyToken); err != nil || next != nil { + t.Fatalf("legacy device received v2 task: %+v %v", next, err) + } + if _, err := service.Claim(context.Background(), task.ID, ActionRequest{RequestID: uuid.NewString()}, legacyToken); taskErrorCode(t, err) != CodeDeviceCapabilityMismatch { + t.Fatalf("legacy claim did not fail with capability mismatch: %v", err) + } + next, err := service.Next(context.Background(), capableToken) + if err != nil || next == nil || next.TaskID != task.ID { + t.Fatalf("capable device did not receive v2 task: %+v %v", next, err) + } + _ = capable +} + +func TestAdminCreateRejectsAssignedDeviceWithoutRuleCapabilities(t *testing.T) { + db := openTaskDatabase(t) + legacy, _ := registerTaskDevice(t, db, "legacy-device") + goodsID := strings.ReplaceAll(uuid.NewString(), "-", "") + product := models.PDDProduct{GoodsID: goodsID, URL: "https://mobile.yangkeduo.com/goods.html?goods_id=" + goodsID} + rule := models.CollectionRule{Name: "v2", ContentJSON: v2TaskRuleSnapshot()} + if err := db.Create(&product).Error; err != nil { + t.Fatal(err) + } + if err := db.Create(&rule).Error; err != nil { + t.Fatal(err) + } + _, err := newTaskService(db).Create(context.Background(), CreateRequest{ + RequestID: uuid.NewString(), PDDProductID: product.ID, RuleID: rule.ID, DeviceID: &legacy.ID, + }) + if taskErrorCode(t, err) != CodeDeviceCapabilityMismatch { + t.Fatalf("incompatible assigned device was accepted: %v", err) + } +} diff --git a/server/cmd/migrate/migration/version-local/1786700400000_device_capabilities.go b/server/cmd/migrate/migration/version-local/1786700400000_device_capabilities.go new file mode 100644 index 0000000..cd8c055 --- /dev/null +++ b/server/cmd/migrate/migration/version-local/1786700400000_device_capabilities.go @@ -0,0 +1,27 @@ +package version_local + +import ( + "runtime" + + goautomigrations "go-admin/app/goauto/migrations" + "go-admin/cmd/migrate/migration" + common "go-admin/common/models" + + "gorm.io/gorm" +) + +// This version adds the device capability snapshot used to keep old Agents on +// v1 tasks while scheduling v2 rules only to compatible Agent versions. +func init() { + _, fileName, _, _ := runtime.Caller(0) + migration.Migrate.SetVersion(migration.GetFilename(fileName), migrateDeviceCapabilities) +} + +func migrateDeviceCapabilities(db *gorm.DB, version string) error { + return db.Transaction(func(tx *gorm.DB) error { + if err := goautomigrations.Migrate(tx); err != nil { + return err + } + return tx.Create(&common.Migration{Version: version}).Error + }) +}