feat: add versioned rule capabilities (#22)
This commit is contained in:
+38
@@ -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<AccessibilityNodeInfo>()
|
||||
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) }
|
||||
|
||||
@@ -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<RuleStep>)
|
||||
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<String>,
|
||||
val sizeAliases: List<String>,
|
||||
val timeoutsMs: Map<String, Int>,
|
||||
val limits: Map<String, Int>,
|
||||
)
|
||||
|
||||
data class CollectionRule(
|
||||
val schemaVersion: Int,
|
||||
val steps: List<RuleStep>,
|
||||
val ruleType: String = "legacyCollection",
|
||||
val pageEvidence: PageEvidence? = null,
|
||||
val hooks: Map<HookStage, List<HookAction>> = 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<String> = 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<RuleStep> {
|
||||
items ?: invalid("steps 必须是非空数组")
|
||||
if (items.length() == 0) invalid("steps 必须是非空数组")
|
||||
val ids = mutableSetOf<String>()
|
||||
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<HookStage, List<HookAction>> {
|
||||
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<String> {
|
||||
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<String, IntRange>): Map<String, Int> {
|
||||
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<String>, 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)
|
||||
}
|
||||
|
||||
@@ -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<HookAction>): 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(
|
||||
|
||||
@@ -15,6 +15,7 @@ data class DeviceInfo(
|
||||
val androidVersion: String,
|
||||
val agentVersion: String,
|
||||
val pddVersion: String,
|
||||
val capabilities: List<String> = 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<String> = 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"),
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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<String, List<UiNodeRef>>,
|
||||
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<String>()
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 架构允许未来增加独立采购规则的创建订单能力,但付款能力禁止进入项目。
|
||||
|
||||
@@ -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 列还有数据库检查约束,必须在同一条状态变更语句中更新。
|
||||
|
||||
## 已建立的工程入口
|
||||
|
||||
@@ -19,6 +19,8 @@
|
||||
- 删除采用软删除;删除后不能创建新任务,但已有任务继续执行自身快照。
|
||||
- 商品详情步骤应声明精确 `activityName`,并与包名和唯一控件共同作为页面证据;进入 PDD 登录 Activity 必须返回 `PDD_LOGIN_REQUIRED`,不能提交采集成功。
|
||||
- 唯一文字节点不可点击时,Agent 只可点击其最近的可点击父容器;不得改点兄弟节点或相似文字。
|
||||
- v2 规则使用类型化动作和固定阶段钩子。已由 Agent 支持的选择器、别名、超时、滑动方向和有限次数可以只更新规则;新增动作类型或页面算法才需要升级 Agent。
|
||||
- Agent 可扩展,但规则必须按任务类型授权:采集规则不能创建订单,采购规则未来可以使用独立的创建订单能力,任何规则都不能付款。
|
||||
|
||||
## 采集任务
|
||||
|
||||
@@ -47,6 +49,7 @@
|
||||
- 登录失效、验证码、风控、人机验证和找不到唯一控件时失败并给出具体错误。
|
||||
- 不使用 OCR/VLM 兜底,不猜测缺失数据,不保存原始控件树或截图。
|
||||
- 当前 MVP 不创建订单、不支付、不提供实时屏幕或管理端远程控制。
|
||||
- Agent 的长期能力可以扩展到采购规则中的“创建订单”,但采集规则不能调用该动作;采购必须作为独立高风险 MVP 实施。付款、免密支付及任何等价动作始终禁止。
|
||||
|
||||
## 设备身份与认证
|
||||
|
||||
|
||||
@@ -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` | 设备缺少任务规则要求的版本化能力 | 否 |
|
||||
|
||||
@@ -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 |
|
||||
|
||||
## 延期
|
||||
|
||||
|
||||
@@ -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 平台可以扩展到创建订单,但规则必须声明类型;采集规则无权创建订单,采购规则可以在独立高风险流程中创建订单,任何规则都无权付款。
|
||||
|
||||
## 建议实施顺序
|
||||
|
||||
|
||||
@@ -35,6 +35,9 @@
|
||||
"className": "android.widget.FrameLayout"
|
||||
}
|
||||
},
|
||||
"hooks": {
|
||||
"afterSpecPanelOpen": []
|
||||
},
|
||||
"collector": {
|
||||
"collectorId": "pddProductDetailV1",
|
||||
"specEntryStrategy": "safeBottomSpecEntryV1",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user