feat: add versioned rule capabilities (#22)

This commit is contained in:
QiuSW
2026-08-15 15:01:19 +08:00
parent 4b38a876de
commit 9d4a0b9845
28 changed files with 1005 additions and 85 deletions
@@ -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 }
}
}
+1 -1
View File
@@ -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 架构允许未来增加独立采购规则的创建订单能力,但付款能力禁止进入项目。
+3 -1
View File
@@ -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 列还有数据库检查约束,必须在同一条状态变更语句中更新。
## 已建立的工程入口
+3
View File
@@ -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
View File
@@ -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` | 设备缺少任务规则要求的版本化能力 | 否 |
+4
View File
@@ -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 |
## 延期
+29 -5
View File
@@ -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",
+72
View File
@@ -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
}
+16 -7
View File
@@ -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)
+7 -1
View File
@@ -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,
}
+21 -9
View File
@@ -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
}
+22
View File
@@ -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)
+1
View File
@@ -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"`
+3 -3
View File
@@ -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 {
+268
View File
@@ -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)
}
}
+3
View File
@@ -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,
+54 -13
View File
@@ -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}
}
+66 -1
View File
@@ -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
})
}