feat: collect current PDD page from Agent (#101)

This commit is contained in:
QiuSW
2026-08-27 10:53:48 +08:00
parent f79f824ef4
commit 28c3cb5138
46 changed files with 1756 additions and 107 deletions
+2 -2
View File
@@ -11,8 +11,8 @@ android {
applicationId = "cn.ilapage.goauto.agent"
minSdk = 23
targetSdk = 34
versionCode = 12
versionName = "0.8.0"
versionCode = 13
versionName = "0.9.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
+8
View File
@@ -16,6 +16,14 @@
android:label="@string/app_name"
android:supportsRtl="true"
android:theme="@style/Theme.GoAutoAgent">
<activity
android:name=".ClipboardRelayActivity"
android:excludeFromRecents="true"
android:exported="false"
android:finishOnTaskLaunch="true"
android:noHistory="true"
android:taskAffinity="cn.ilapage.goauto.agent.clipboard"
android:theme="@style/Theme.GoAutoAgent.ClipboardRelay" />
<activity
android:name=".MainActivity"
android:exported="true">
@@ -0,0 +1,92 @@
package cn.ilapage.goauto.agent
import android.app.Activity
import android.content.ClipDescription
import android.content.ClipboardManager
import android.content.Context
import android.content.Intent
import android.os.Build
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import java.util.UUID
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicReference
class ClipboardRelayActivity : Activity() {
private var completed = false
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
window.setDimAmount(0f)
}
override fun onResume() {
super.onResume()
Handler(Looper.getMainLooper()).postDelayed(::readAndFinish, 120)
}
private fun readAndFinish() {
if (completed) return
completed = true
val requestId = intent.getStringExtra(EXTRA_REQUEST_ID).orEmpty()
val minTimestamp = intent.getLongExtra(EXTRA_MIN_TIMESTAMP, 0L)
val clipboard = getSystemService(ClipboardManager::class.java)
val description = clipboard.primaryClipDescription
val fresh = when {
description == null -> false
Build.VERSION.SDK_INT < Build.VERSION_CODES.O -> true
description.timestamp <= 0L -> false
else -> description.timestamp >= minTimestamp - TIMESTAMP_TOLERANCE_MILLIS
}
val text = if (fresh && description?.hasMimeType(ClipDescription.MIMETYPE_TEXT_PLAIN) == true) {
clipboard.primaryClip?.takeIf { it.itemCount == 1 }?.getItemAt(0)?.text?.toString()?.takeIf { it.length <= MAX_CLIPBOARD_CHARS }
} else null
deliver(requestId, text)
finishAndRemoveTask()
overridePendingTransition(0, 0)
}
companion object {
private const val EXTRA_REQUEST_ID = "clipboard_request_id"
private const val EXTRA_MIN_TIMESTAMP = "clipboard_min_timestamp"
private const val MAX_CLIPBOARD_CHARS = 4096
private const val TIMESTAMP_TOLERANCE_MILLIS = 1_500L
private val pending = ConcurrentHashMap<String, PendingRead>()
fun readFresh(context: Context, minTimestamp: Long, timeoutMillis: Long): CharSequence? {
val requestId = UUID.randomUUID().toString()
val request = PendingRead()
pending[requestId] = request
val started = runCatching {
context.startActivity(Intent(context, ClipboardRelayActivity::class.java).apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_NO_ANIMATION or Intent.FLAG_ACTIVITY_EXCLUDE_FROM_RECENTS)
putExtra(EXTRA_REQUEST_ID, requestId)
putExtra(EXTRA_MIN_TIMESTAMP, minTimestamp)
})
true
}.getOrDefault(false)
if (!started) {
pending.remove(requestId)
return null
}
request.latch.await(timeoutMillis.coerceIn(500L, 10_000L), TimeUnit.MILLISECONDS)
pending.remove(requestId)
return request.value.get()
}
private fun deliver(requestId: String, value: String?) {
pending[requestId]?.let { request ->
request.value.set(value)
request.latch.countDown()
}
}
}
private class PendingRead {
val latch = CountDownLatch(1)
val value = AtomicReference<String?>(null)
}
}
@@ -0,0 +1,132 @@
package cn.ilapage.goauto.agent.automation
import java.net.URI
data class CurrentPageIdentityResult(
val successful: Boolean,
val code: String,
val message: String,
val shareUrl: String? = null,
)
object PddShareLinkExtractor {
private val urlPattern = Regex("https://[^\\s]+", RegexOption.IGNORE_CASE)
private val trailingPunctuation = charArrayOf(')', ']', '}', ')', '】', '」', '』', '》', ',', ',', '.', '。', ';', ';')
fun extract(raw: CharSequence?): String? {
val matches = urlPattern.findAll(raw?.toString().orEmpty()).map { it.value.trimEnd(*trailingPunctuation) }.toList()
val accepted = matches.filter { value ->
val uri = runCatching { URI(value) }.getOrNull() ?: return@filter false
uri.scheme.equals("https", ignoreCase = true) && uri.host?.lowercase() in ALLOWED_HOSTS && uri.userInfo == null && uri.port in setOf(-1, 443)
}.distinct()
return accepted.singleOrNull()
}
private val ALLOWED_HOSTS = setOf("p.pinduoduo.com", "mobile.yangkeduo.com")
}
class CurrentPageIdentityRunner(
private val driver: PddCollectorDriver,
private val readFreshClipboard: (copiedAtMillis: Long, timeoutMillis: Long) -> CharSequence?,
private val now: () -> Long = System::currentTimeMillis,
private val pause: (Long) -> Unit = Thread::sleep,
) {
fun identify(rule: CollectionRule): CurrentPageIdentityResult {
val evidence = rule.pageEvidence ?: return failure("RULE_INVALID", "规则缺少商品页证据")
val config = rule.currentPageIdentity
val detail = waitForPageEvidence(evidence, config.pageTimeoutMs)
?: return failure("CURRENT_PDD_PAGE_NOT_FOUND", "没有找到商品详情页")
detailProblem(detail)?.let { return failure(it.code, it.message) }
val share = uniqueAction(detail, config.shareAliases, topOnly = true)
?: return failure("PDD_SHARE_UNAVAILABLE", "没有找到商品分享入口")
when (driver.clickFresh(share)) {
FreshActionResult.SUCCESS -> Unit
FreshActionResult.AMBIGUOUS -> return failure("PDD_SHARE_UNAVAILABLE", "商品分享入口不唯一")
else -> return failure("PDD_SHARE_UNAVAILABLE", "无法打开商品分享")
}
try {
val panel = waitForAction(config.copyLinkAliases, config.sharePanelTimeoutMs)
?: return failure("PDD_COPY_LINK_UNAVAILABLE", "没有找到复制链接")
val copy = uniqueAction(panel, config.copyLinkAliases, topOnly = false)
?: return failure("PDD_COPY_LINK_UNAVAILABLE", "复制链接入口不唯一")
val copiedAt = now()
when (driver.clickFresh(copy)) {
FreshActionResult.SUCCESS -> Unit
FreshActionResult.AMBIGUOUS -> return failure("PDD_COPY_LINK_UNAVAILABLE", "复制链接入口不唯一")
else -> return failure("PDD_COPY_LINK_UNAVAILABLE", "复制商品链接失败")
}
val raw = readFreshClipboard(copiedAt, config.clipboardTimeoutMs)
?: return failure("PDD_CLIPBOARD_UNAVAILABLE", "无法读取刚复制的商品链接")
val shareUrl = PddShareLinkExtractor.extract(raw)
?: return failure("PDD_SHARE_LINK_INVALID", "无法识别商品链接")
return CurrentPageIdentityResult(true, "OK", "已识别当前商品", shareUrl)
} finally {
closeSharePanel(config.copyLinkAliases)
}
}
private fun waitForPageEvidence(evidence: PageEvidence, timeoutMillis: Long): UiSnapshot? {
val deadline = now() + timeoutMillis
do {
val snapshot = driver.capture()
detailProblem(snapshot)?.let { return snapshot }
if (matchesEvidence(snapshot, evidence)) return snapshot
pause(POLL_MILLIS)
} while (now() <= deadline)
return null
}
private fun waitForAction(aliases: List<String>, timeoutMillis: Long): UiSnapshot? {
val deadline = now() + timeoutMillis
do {
val snapshot = driver.capture()
if (uniqueAction(snapshot, aliases, topOnly = false) != null) return snapshot
pause(POLL_MILLIS)
} while (now() <= deadline)
return null
}
private fun matchesEvidence(snapshot: UiSnapshot, evidence: PageEvidence): Boolean =
snapshot.packageName == evidence.packageName && snapshot.activityName == evidence.activityName && snapshot.nodes.any { node ->
node.visible &&
(evidence.selector.resourceId == null || node.resourceId == evidence.selector.resourceId) &&
(evidence.selector.text == null || node.text == evidence.selector.text) &&
(evidence.selector.contentDescription == null || node.contentDescription == evidence.selector.contentDescription) &&
(evidence.selector.className == null || node.className == evidence.selector.className) &&
(evidence.selector.clickable == null || node.clickable == evidence.selector.clickable)
}
private fun uniqueAction(snapshot: UiSnapshot, aliases: List<String>, topOnly: Boolean): SnapshotNode? {
val screenHeight = snapshot.nodes.maxOfOrNull { it.bounds.bottom } ?: 0
val candidates = snapshot.nodes.filter { node ->
node.visible && node.enabled && node.label in aliases && (!topOnly || screenHeight <= 0 || node.bounds.centerY <= screenHeight * 45 / 100)
}.sortedWith(compareBy<SnapshotNode> { it.bounds.width.toLong() * it.bounds.height }.thenBy { it.path.length })
val distinct = mutableListOf<SnapshotNode>()
candidates.forEach { candidate ->
if (distinct.none { existing ->
existing.label == candidate.label &&
kotlin.math.abs(existing.bounds.centerX - candidate.bounds.centerX) <= 16 &&
kotlin.math.abs(existing.bounds.centerY - candidate.bounds.centerY) <= 16
}
) distinct += candidate
}
return distinct.singleOrNull()
}
private fun detailProblem(snapshot: UiSnapshot): PageProblem? =
PddPageClassifier.classify(snapshot.packageName, snapshot.activityName, snapshot.nodes.filter { it.visible }.map(SnapshotNode::label))
private fun closeSharePanel(copyAliases: List<String>) {
val snapshot = driver.capture()
if (snapshot.packageName == PDD_PACKAGE && snapshot.nodes.any { it.visible && it.label in copyAliases }) {
driver.back()
}
}
private fun failure(code: String, message: String) = CurrentPageIdentityResult(false, code, message)
private companion object {
const val POLL_MILLIS = 100L
const val PDD_PACKAGE = "com.xunmeng.pinduoduo"
}
}
@@ -12,6 +12,7 @@ import android.os.Build
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.os.SystemClock
import android.util.Log
import android.view.accessibility.AccessibilityEvent
import android.view.accessibility.AccessibilityNodeInfo
@@ -23,6 +24,7 @@ import java.util.concurrent.atomic.AtomicLong
class GoAutoAccessibilityService : AccessibilityService(), UiDriver, PddCollectorDriver, PurchaseUiDriver {
private val foregroundRevision = AtomicLong(0L)
@Volatile private var lastForegroundPackage: String? = null
private val lastPddForegroundAt = AtomicLong(0L)
private val activityTracker by lazy {
ActivityEvidenceTracker { packageName, className -> isDeclaredActivity(packageName, className) }
}
@@ -48,6 +50,7 @@ class GoAutoAccessibilityService : AccessibilityService(), UiDriver, PddCollecto
lastForegroundPackage = packageName
foregroundRevision.incrementAndGet()
}
if (packageName == PDD_PACKAGE) lastPddForegroundAt.set(SystemClock.elapsedRealtime())
activityTracker.observe(packageName, event.className?.toString())
}
}
@@ -91,6 +94,23 @@ class GoAutoAccessibilityService : AccessibilityService(), UiDriver, PddCollecto
fun currentForegroundRevision(): Long = foregroundRevision.get()
fun hasRecentPddForeground(maxAgeMillis: Long = 120_000L): Boolean {
val seenAt = lastPddForegroundAt.get()
return seenAt > 0L && SystemClock.elapsedRealtime() - seenAt in 0..maxAgeMillis
}
fun restoreRecentPddPage(timeoutMillis: Long = 5_000L): Boolean {
if (currentPackage() == PDD_PACKAGE) return true
if (currentPackage() != packageName || !hasRecentPddForeground()) return false
if (!performGlobalAction(GLOBAL_ACTION_BACK)) return false
val deadline = SystemClock.elapsedRealtime() + timeoutMillis
do {
if (currentPackage() == PDD_PACKAGE) return true
Thread.sleep(100)
} while (SystemClock.elapsedRealtime() <= deadline)
return false
}
fun openAgentStatus(): Boolean = runCatching {
startActivity(
android.content.Intent(this, cn.ilapage.goauto.agent.MainActivity::class.java).apply {
@@ -477,6 +497,7 @@ class GoAutoAccessibilityService : AccessibilityService(), UiDriver, PddCollecto
}.getOrDefault(false)
companion object {
private const val PDD_PACKAGE = "com.xunmeng.pinduoduo"
@Volatile
var instance: GoAutoAccessibilityService? = null
private set
@@ -74,6 +74,14 @@ data class ReopenBrowserRecovery(
val settleMs: Long,
)
data class CurrentPageIdentityConfig(
val shareAliases: List<String> = listOf("分享"),
val copyLinkAliases: List<String> = listOf("复制链接"),
val pageTimeoutMs: Long = 5_000,
val sharePanelTimeoutMs: Long = 5_000,
val clipboardTimeoutMs: Long = 5_000,
)
data class CollectionRule(
val schemaVersion: Int,
val steps: List<RuleStep>,
@@ -83,6 +91,7 @@ data class CollectionRule(
val collector: PddCollectorConfig? = null,
val transientSoldOutRecovery: TransientSoldOutRecovery? = null,
val reopenBrowserRecovery: ReopenBrowserRecovery? = null,
val currentPageIdentity: CurrentPageIdentityConfig = CurrentPageIdentityConfig(),
)
class RuleValidationException(val code: String, message: String) : IllegalArgumentException(message)
@@ -91,11 +100,13 @@ 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"
const val PDD_CURRENT_PAGE_SHARE_V1 = "collector.pdd.current-page-share.v1"
val supported: List<String> = listOf(
SCHEMA_V2,
SWIPE_V1,
PDD_PRODUCT_DETAIL_V1,
PDD_CURRENT_PAGE_SHARE_V1,
PurchaseAgentCapabilities.REHEARSAL_V1,
PurchaseAgentCapabilities.LIVE_V1,
PurchaseAgentCapabilities.SPEC_PROBE_V1,
@@ -135,7 +146,7 @@ object RuleParser {
}
private fun parseV2(root: JSONObject): CollectionRule {
rejectUnknown(root, setOf("schemaVersion", "ruleType", "navigation", "pageEvidence", "hooks", "navigationRecovery", "pageRecovery", "collector"), "v2 规则")
rejectUnknown(root, setOf("schemaVersion", "ruleType", "navigation", "pageEvidence", "hooks", "navigationRecovery", "pageRecovery", "currentPageIdentity", "collector"), "v2 规则")
if (root.optString("ruleType") != "pddProductDetail") {
invalid("v2 规则必须声明 ruleType=pddProductDetail")
}
@@ -155,8 +166,31 @@ object RuleParser {
val hooks = parseHooks(root.optJSONObject("hooks"))
val navigationRecovery = parseNavigationRecovery(root.optJSONObject("navigationRecovery"))
val recovery = parsePageRecovery(root.optJSONObject("pageRecovery"))
val currentPageIdentity = parseCurrentPageIdentity(root.optJSONObject("currentPageIdentity"))
val collector = parseCollector(root.optJSONObject("collector") ?: invalid("collector 必填"))
return CollectionRule(2, steps, "pddProductDetail", evidence, hooks, collector, recovery, navigationRecovery)
return CollectionRule(2, steps, "pddProductDetail", evidence, hooks, collector, recovery, navigationRecovery, currentPageIdentity)
}
private fun parseCurrentPageIdentity(value: JSONObject?): CurrentPageIdentityConfig {
if (value == null) return CurrentPageIdentityConfig()
rejectUnknown(value, setOf("shareAliases", "copyLinkAliases", "pageTimeoutMs", "sharePanelTimeoutMs", "clipboardTimeoutMs"), "currentPageIdentity")
return CurrentPageIdentityConfig(
shareAliases = parseIdentityAliases(value.optJSONArray("shareAliases"), "shareAliases"),
copyLinkAliases = parseIdentityAliases(value.optJSONArray("copyLinkAliases"), "copyLinkAliases"),
pageTimeoutMs = value.optLong("pageTimeoutMs").also { if (it !in 500..15_000) invalid("currentPageIdentity.pageTimeoutMs 必须为 500..15000") },
sharePanelTimeoutMs = value.optLong("sharePanelTimeoutMs").also { if (it !in 500..10_000) invalid("currentPageIdentity.sharePanelTimeoutMs 必须为 500..10000") },
clipboardTimeoutMs = value.optLong("clipboardTimeoutMs").also { if (it !in 500..10_000) invalid("currentPageIdentity.clipboardTimeoutMs 必须为 500..10000") },
)
}
private fun parseIdentityAliases(items: JSONArray?, label: String): List<String> {
items ?: invalid("currentPageIdentity.$label 必填")
if (items.length() !in 1..8) invalid("currentPageIdentity.$label 必须包含 1..8 项")
val values = (0 until items.length()).map { index ->
items.optString(index).trim().also { if (it.isEmpty() || it.length > 20) invalid("currentPageIdentity.$label 含无效文字") }
}
if (values.distinct().size != values.size) invalid("currentPageIdentity.$label 不能重复")
return values
}
private fun parseNavigationRecovery(value: JSONObject?): ReopenBrowserRecovery? {
@@ -34,7 +34,7 @@ data class HeartbeatResult(
data class AgentTask(
val taskId: Long,
val pddProductId: Long,
val pddProductId: Long?,
val urlSnapshot: String,
val goodsIdSnapshot: String,
val ruleId: Long,
@@ -42,6 +42,15 @@ data class AgentTask(
val timeoutSeconds: Int,
val leaseVersion: Long,
val status: String,
val source: String,
)
data class CurrentPageIdentity(
val taskId: Long,
val pddProductId: Long,
val goodsId: String,
val url: String,
val replayed: Boolean,
)
data class PurchaseAgentTask(
@@ -68,6 +77,7 @@ data class PurchaseAgentTask(
data class CollectionHistoryItem(
val taskId: Long,
val status: String,
val source: String,
val goodsId: String,
val title: String?,
val missingCount: Int,
@@ -174,6 +184,20 @@ class AgentApiClient(private val serverUrl: String) {
return task(response.getJSONObject("data"))
}
fun createCurrentPageCollectionTask(requestId: String, token: String): AgentTask {
val payload = JSONObject().put("requestId", requestId)
return task(requireNotNull(request("POST", "/api/agent/v1/current-page-collection-tasks", payload, token)).getJSONObject("data"))
}
fun identifyCurrentPageCollectionTask(taskId: Long, requestId: String, shareUrl: String, token: String): CurrentPageIdentity {
val payload = JSONObject().put("requestId", requestId).put("shareUrl", shareUrl)
val data = requireNotNull(request("POST", "/api/agent/v1/current-page-collection-tasks/$taskId/identify", payload, token)).getJSONObject("data")
return CurrentPageIdentity(
taskId = data.getLong("taskId"), pddProductId = data.getLong("pddProductId"),
goodsId = data.getString("goodsId"), url = data.getString("url"), replayed = data.optBoolean("replayed"),
)
}
fun claimTask(taskId: Long, requestId: String, token: String): AgentTask {
val payload = JSONObject().put("requestId", requestId)
return task(requireNotNull(request("POST", "/api/agent/v1/tasks/$taskId/claim", payload, token)).getJSONObject("data"))
@@ -291,7 +315,7 @@ class AgentApiClient(private val serverUrl: String) {
}
private fun collectionHistoryItem(data: JSONObject) = CollectionHistoryItem(
taskId = data.getLong("taskId"), status = data.getString("status"), goodsId = data.getString("goodsId"),
taskId = data.getLong("taskId"), status = data.getString("status"), source = data.optString("source", "admin"), goodsId = data.getString("goodsId"),
title = data.nullableString("title"), missingCount = data.optInt("missingCount"),
errorCode = data.nullableString("errorCode"), errorMessage = data.nullableString("errorMessage"),
finishedAt = data.nullableString("finishedAt"), createdAt = data.getString("createdAt"),
@@ -312,7 +336,7 @@ class AgentApiClient(private val serverUrl: String) {
private fun task(data: JSONObject) = AgentTask(
taskId = data.getLong("taskId"),
pddProductId = data.getLong("pddProductId"),
pddProductId = data.nullableLong("pddProductId"),
urlSnapshot = data.getString("urlSnapshot"),
goodsIdSnapshot = data.getString("goodsIdSnapshot"),
ruleId = data.getLong("ruleId"),
@@ -320,6 +344,7 @@ class AgentApiClient(private val serverUrl: String) {
timeoutSeconds = data.optInt("timeoutSeconds", 120),
leaseVersion = data.getLong("leaseVersion"),
status = data.getString("status"),
source = data.optString("source", "admin"),
)
private fun purchaseTask(data: JSONObject) = PurchaseAgentTask(
@@ -13,7 +13,7 @@ class TaskHistoryCache(context: Context) {
fun saveCollection(days: Int, items: List<CollectionHistoryItem>) = save(COLLECTION, days, JSONArray().apply {
items.forEach { item -> put(JSONObject()
.put("taskId", item.taskId).put("status", item.status).put("goodsId", item.goodsId)
.put("taskId", item.taskId).put("status", item.status).put("source", item.source).put("goodsId", item.goodsId)
.putNullable("title", item.title).put("missingCount", item.missingCount)
.putNullable("errorCode", item.errorCode).putNullable("errorMessage", item.errorMessage)
.putNullable("finishedAt", item.finishedAt).put("createdAt", item.createdAt)) }
@@ -33,9 +33,16 @@ class TaskHistoryCache(context: Context) {
fun collection(days: Int, page: Int, status: String?, taskNo: String?): HistoryPage<CollectionHistoryItem>? {
val values = read(COLLECTION, days)?.map { data -> CollectionHistoryItem(
data.getLong("taskId"), data.getString("status"), data.getString("goodsId"), data.nullableString("title"),
data.optInt("missingCount"), data.nullableString("errorCode"), data.nullableString("errorMessage"),
data.nullableString("finishedAt"), data.getString("createdAt"),
taskId = data.getLong("taskId"),
status = data.getString("status"),
source = data.optString("source", "admin"),
goodsId = data.optString("goodsId"),
title = data.nullableString("title"),
missingCount = data.optInt("missingCount"),
errorCode = data.nullableString("errorCode"),
errorMessage = data.nullableString("errorMessage"),
finishedAt = data.nullableString("finishedAt"),
createdAt = data.getString("createdAt"),
) } ?: return null
return page(values.filter { status.isNullOrBlank() || it.status == status }.filterTask(taskNo) { it.taskId }, page)
}
@@ -18,10 +18,12 @@ import android.os.PowerManager
import android.os.SystemClock
import android.util.Log
import cn.ilapage.goauto.agent.BuildConfig
import cn.ilapage.goauto.agent.ClipboardRelayActivity
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.CurrentPageIdentityRunner
import cn.ilapage.goauto.agent.automation.GoAutoAccessibilityService
import cn.ilapage.goauto.agent.automation.PddLinkLauncher
import cn.ilapage.goauto.agent.automation.PddDetailEntryRunner
@@ -108,6 +110,10 @@ class AgentForegroundService : Service() {
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
if (intent?.action == ACTION_RECONNECT) registeredThisProcess.set(false)
if (intent?.action == ACTION_CHECK_NOW) manualCheckRequested.set(true)
if (intent?.action == ACTION_CURRENT_PAGE_COLLECTION) {
requestCurrentPageCollection(intent.getStringExtra(EXTRA_CURRENT_PAGE_REQUEST_ID).orEmpty())
return START_STICKY
}
triggerSync()
return START_STICKY
}
@@ -261,6 +267,91 @@ class AgentForegroundService : Service() {
})
}
private fun requestCurrentPageCollection(requestId: String) {
if (requestId.isBlank()) {
publishCurrentPageResult(0L, "采集请求无效,请重试。")
return
}
activeCollectionCooldown()?.let { ticket ->
val remaining = CollectionCooldownPolicy.remainingSeconds(System.currentTimeMillis(), ticket)
publishCurrentPageResult(0L, "采集间隔中,还需 $remaining 秒。")
return
}
if (!taskMutex.tryAcquire(CURRENT_PAGE_RESERVATION_ID)) {
publishCurrentPageResult(0L, "设备正在执行任务,请稍后再试。")
return
}
cancelIdleReturn("正在创建当前页面采集任务")
taskExecutor.execute {
var taskId = 0L
try {
val serverUrl = ServerUrlPolicy.normalize(settingsStore.serverUrl(), BuildConfig.DEBUG)
val credentials = identityStore.credentials()
?: throw TaskFailure("DEVICE_NOT_CONNECTED", "设备尚未连接服务端,请先检查设置")
val accessibility = GoAutoAccessibilityService.instance
?: throw TaskFailure("ACCESSIBILITY_NOT_READY", "请先到“设置”开启采集采购助手")
if (!accessibility.hasRecentPddForeground()) {
throw TaskFailure("CURRENT_PDD_PAGE_NOT_FOUND", "请先在拼多多打开目标商品详情页")
}
val api = AgentApiClient(serverUrl)
val task = api.createCurrentPageCollectionTask(requestId, credentials.token)
taskId = task.taskId
check(taskMutex.transfer(CURRENT_PAGE_RESERVATION_ID, task.taskId)) {
"当前页面采集本地占用状态不一致"
}
runningTaskId.set(task.taskId)
stateStore.setActiveTask(task.taskId, "collection")
executeTask(api, task, credentials.token)
publishCurrentPageResult(task.taskId, "临时采集任务 #${task.taskId} 已结束,请查看采集记录。")
} catch (error: TaskFailure) {
publishCurrentPageResult(taskId, currentPageUserMessage(error.code, error.message ?: "采集失败"))
} catch (error: AgentApiException) {
publishCurrentPageResult(taskId, currentPageUserMessage(error.code, error.message))
} catch (error: Exception) {
if (taskId > 0L) {
val token = runCatching { identityStore.credentials()?.token }.getOrNull()
if (token != null) {
runCatching {
failSafely(
AgentApiClient(ServerUrlPolicy.normalize(settingsStore.serverUrl(), BuildConfig.DEBUG)),
taskId,
token,
"AGENT_EXECUTION_ERROR",
error.message ?: "Android 执行异常",
)
}
}
}
publishCurrentPageResult(taskId, "采集失败,请查看任务详情。")
} finally {
if (taskId > 0L) {
runningTaskId.compareAndSet(taskId, null)
stateStore.clearActiveTask(taskId)
taskMutex.release(taskId)
}
taskMutex.release(CURRENT_PAGE_RESERVATION_ID)
triggerSync()
}
}
}
private fun publishCurrentPageResult(taskId: Long, message: String) {
sendBroadcast(Intent(ACTION_CURRENT_PAGE_RESULT).apply {
setPackage(packageName)
putExtra(EXTRA_CURRENT_PAGE_TASK_ID, taskId)
putExtra(EXTRA_CURRENT_PAGE_MESSAGE, message)
})
}
private fun currentPageUserMessage(code: String, fallback: String): String = when (code) {
"DEVICE_BUSY" -> "设备正在执行任务,请稍后再试。"
"AGENT_MANUAL_RULE_NOT_CONFIGURED" -> "请先配置 Agent 手动采集规则。"
"CURRENT_PDD_PAGE_NOT_FOUND" -> "没有找到商品详情页,请重新打开商品后再试。"
"PDD_SHARE_UNAVAILABLE", "PDD_COPY_LINK_UNAVAILABLE", "PDD_CLIPBOARD_UNAVAILABLE", "PDD_SHARE_LINK_INVALID" ->
"无法识别商品链接,请确认商品页可以分享后再试。"
else -> fallback.ifBlank { "采集失败,请稍后重试。" }
}
private fun schedulePurchaseTask(api: AgentApiClient, task: PurchaseAgentTask, token: String) {
if (!taskMutex.tryAcquire(task.taskId)) return
runningTaskId.set(task.taskId)
@@ -489,7 +580,37 @@ class AgentForegroundService : Service() {
val rule = try { RuleParser.parse(task.ruleSnapshot) } catch (error: RuleValidationException) {
throw TaskFailure(error.code, error.message ?: "规则快照无效")
}
val result = if (rule.schemaVersion == 2) {
val result = if (task.source == "agent_current_page") {
if (rule.schemaVersion != 2) {
throw TaskFailure("RULE_INVALID", "当前页面采集只支持新版采集规则")
}
if (!accessibility.restoreRecentPddPage()) {
throw TaskFailure("CURRENT_PDD_PAGE_NOT_FOUND", "没有找到商品详情页")
}
val identityResult = CurrentPageIdentityRunner(
driver = accessibility,
readFreshClipboard = { copiedAt, timeout ->
ClipboardRelayActivity.readFresh(this, copiedAt, timeout)
},
).identify(rule)
if (!identityResult.successful) {
throw TaskFailure(identityResult.code, identityResult.message)
}
val identity = try {
api.identifyCurrentPageCollectionTask(
task.taskId,
UUID.randomUUID().toString(),
requireNotNull(identityResult.shareUrl),
token,
)
} catch (error: AgentApiException) {
throw TaskFailure(error.code, error.message)
}
val trace: (String) -> Unit = { message -> Log.i("GoAutoCollector", message) }
val collection = PddProductDetailCollector(accessibility, trace = trace).collect(identity.goodsId, rule)
if (!collection.successful) throw TaskFailure(collection.code, collection.message)
requireNotNull(collection.payload)
} else if (rule.schemaVersion == 2) {
val trace: (String) -> Unit = { message -> Log.i("GoAutoCollector", message) }
val collection = PddDetailEntryRunner(
openLink = { PddLinkLauncher(this).open(task.urlSnapshot) },
@@ -800,7 +921,12 @@ class AgentForegroundService : Service() {
const val ACTION_RECONNECT = "cn.ilapage.goauto.agent.RECONNECT"
const val ACTION_CHECK_NOW = "cn.ilapage.goauto.agent.CHECK_NOW"
const val ACTION_CHECK_RESULT = "cn.ilapage.goauto.agent.CHECK_RESULT"
const val ACTION_CURRENT_PAGE_COLLECTION = "cn.ilapage.goauto.agent.CURRENT_PAGE_COLLECTION"
const val ACTION_CURRENT_PAGE_RESULT = "cn.ilapage.goauto.agent.CURRENT_PAGE_RESULT"
const val EXTRA_CHECK_RESULT = "check_result"
const val EXTRA_CURRENT_PAGE_REQUEST_ID = "current_page_request_id"
const val EXTRA_CURRENT_PAGE_TASK_ID = "current_page_task_id"
const val EXTRA_CURRENT_PAGE_MESSAGE = "current_page_message"
const val MANUAL_EMPTY = "empty"
const val MANUAL_COLLECTION_TASK = "collection_task"
const val MANUAL_PURCHASE_TASK = "purchase_task"
@@ -818,6 +944,7 @@ class AgentForegroundService : Service() {
private const val COLLECTION_COOLDOWN_MAX_MILLIS = CollectionIntervalPolicy.MAX_SECONDS * 1_000L
private const val COOLDOWN_WAKE_GRACE_MILLIS = 5_000L
private const val RETURN_CONFIRM_DELAY_MILLIS = 750L
private const val CURRENT_PAGE_RESERVATION_ID = Long.MAX_VALUE
fun start(context: Context, reconnect: Boolean = false) {
val intent = Intent(context, AgentForegroundService::class.java).apply {
@@ -840,6 +967,18 @@ class AgentForegroundService : Service() {
context.startService(intent)
}
}
fun collectCurrentPage(context: Context, requestId: String) {
val intent = Intent(context, AgentForegroundService::class.java).apply {
action = ACTION_CURRENT_PAGE_COLLECTION
putExtra(EXTRA_CURRENT_PAGE_REQUEST_ID, requestId)
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
context.startForegroundService(intent)
} else {
context.startService(intent)
}
}
}
}
@@ -1,17 +1,27 @@
package cn.ilapage.goauto.agent.service
import java.util.concurrent.atomic.AtomicReference
import java.util.concurrent.atomic.AtomicLong
/** Process-local guard that prevents two collection tasks from executing together. */
class TaskExecutionMutex {
private val activeTask = AtomicReference<Long?>(null)
private val activeTask = AtomicLong(NO_TASK)
fun tryAcquire(taskId: Long): Boolean {
require(taskId > 0L) { "taskId must be positive" }
return activeTask.compareAndSet(null, taskId) || activeTask.get() == taskId
return activeTask.compareAndSet(NO_TASK, taskId) || activeTask.get() == taskId
}
fun currentTaskId(): Long? = activeTask.get()
fun currentTaskId(): Long? = activeTask.get().takeUnless { it == NO_TASK }
fun release(taskId: Long): Boolean = activeTask.compareAndSet(taskId, null)
fun transfer(fromTaskId: Long, toTaskId: Long): Boolean {
require(fromTaskId > 0L) { "fromTaskId must be positive" }
require(toTaskId > 0L) { "toTaskId must be positive" }
return activeTask.compareAndSet(fromTaskId, toTaskId)
}
fun release(taskId: Long): Boolean = activeTask.compareAndSet(taskId, NO_TASK)
private companion object {
const val NO_TASK = 0L
}
}
@@ -1,6 +1,11 @@
package cn.ilapage.goauto.agent.ui
import android.content.res.ColorStateList
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.os.Build
import android.os.Bundle
import android.text.Editable
import android.text.TextWatcher
@@ -18,6 +23,7 @@ import androidx.fragment.app.Fragment
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
import cn.ilapage.goauto.agent.R
import cn.ilapage.goauto.agent.identity.SecureDeviceStore
import cn.ilapage.goauto.agent.automation.GoAutoAccessibilityService
import cn.ilapage.goauto.agent.network.AgentApiClient
import cn.ilapage.goauto.agent.network.CollectionHistoryDetail
import cn.ilapage.goauto.agent.network.CollectionHistoryItem
@@ -59,9 +65,9 @@ internal object TaskSearchInputPolicy {
internal object CollectionResetPolicy {
private val terminalStatuses = setOf("completed", "completed_partial", "failed")
fun isAllowed(status: String): Boolean = status in terminalStatuses
fun isAllowed(status: String, source: String): Boolean = status in terminalStatuses && source != "agent_current_page"
fun showsListAction(status: String): Boolean = status == "failed"
fun showsListAction(status: String, source: String): Boolean = status == "failed" && source != "agent_current_page"
fun confirmationMessage(status: String): String = if (status == "failed") {
"重新采集会清除本任务现有的错误和采集结果,然后重新加入当前设备的任务队列。"
@@ -89,6 +95,21 @@ class TaskHistoryFragment : Fragment() {
private var requestGeneration = 0
private var loading = false
private var pageSignature: String? = null
private var currentPageReceiverRegistered = false
private val currentPageReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
if (intent?.action != AgentForegroundService.ACTION_CURRENT_PAGE_RESULT || !collection) return
val message = intent.getStringExtra(AgentForegroundService.EXTRA_CURRENT_PAGE_MESSAGE).orEmpty()
val taskId = intent.getLongExtra(AgentForegroundService.EXTRA_CURRENT_PAGE_TASK_ID, 0L)
if (message.isNotBlank()) toast(message)
if (taskId > 0L && isAdded) loadCollectionDetail(taskId) else if (isAdded) load()
}
}
override fun onCreate(state: Bundle?) {
super.onCreate(state)
if (collection) registerCurrentPageReceiver()
}
override fun onCreateView(inflater: android.view.LayoutInflater, container: ViewGroup?, state: Bundle?): View {
val context = requireContext()
@@ -122,6 +143,11 @@ class TaskHistoryFragment : Fragment() {
super.onDestroyView()
}
override fun onDestroy() {
unregisterCurrentPageReceiver()
super.onDestroy()
}
private fun buildSearch(): View {
val context = requireContext()
return context.card(context.cardColumn().apply {
@@ -169,6 +195,15 @@ class TaskHistoryFragment : Fragment() {
minimumHeight = context.dp(48)
setOnClickListener { search() }
}, LinearLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, context.dp(48)).apply { marginStart = context.dp(8) })
if (collection) {
row.addView(MaterialButton(context).apply {
text = "采集"
textSize = 14f
minimumHeight = context.dp(48)
contentDescription = "采集当前拼多多商品"
setOnClickListener { confirmCurrentPageCollection() }
}, LinearLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, context.dp(48)).apply { marginStart = context.dp(8) })
}
addView(row, row.fullWidth())
}).apply {
(layoutParams as? LinearLayout.LayoutParams)?.bottomMargin = context.dp(8)
@@ -319,10 +354,11 @@ class TaskHistoryFragment : Fragment() {
else -> item.title ?: "未记录商品标题"
}
return context.card(context.cardColumn().apply {
addView(context.label("#${item.taskId} · ${item.goodsId}", 17f, context.getColor(R.color.agent_text), true))
val goodsLabel = item.goodsId.ifBlank { "未识别商品" }
addView(context.label("#${item.taskId} · $goodsLabel", 17f, context.getColor(R.color.agent_text), true))
addView(context.label("$status · ${formatTime(item.finishedAt ?: item.createdAt)}", 13f, statusColor(item.status)).apply { setPadding(0, context.dp(4), 0, 0) })
addView(context.label(summary, 14f, context.getColor(R.color.agent_text_muted)).apply { setPadding(0, context.dp(8), 0, 0) })
if (CollectionResetPolicy.showsListAction(item.status)) {
if (CollectionResetPolicy.showsListAction(item.status, item.source)) {
addView(LinearLayout(context).apply {
gravity = Gravity.END
addView(MaterialButton(context, null, com.google.android.material.R.attr.materialButtonOutlinedStyle).apply {
@@ -414,13 +450,14 @@ class TaskHistoryFragment : Fragment() {
append("$prices\n\n$dimensions\n\nSKU:${detail.skus.size} 条")
}
resultColumn.addView(context.card(context.cardColumn().apply {
addView(context.label("#${task.taskId} · ${task.goodsId}", 20f, context.getColor(R.color.agent_text), true))
addView(context.label("#${task.taskId} · ${task.goodsId.ifBlank { "未识别商品" }}", 20f, context.getColor(R.color.agent_text), true))
if (task.source == "agent_current_page") addView(context.label("来源:Agent 当前页面", 13f, context.getColor(R.color.agent_text_muted)))
addView(context.label(collectionStatus(task.status), 14f, statusColor(task.status)).apply { setPadding(0, context.dp(4), 0, context.dp(12)) })
addView(context.label(info, 14f))
}), collectionCardParams())
if (detail.missing.isNotEmpty()) resultColumn.addView(context.centeredMessage("缺失项", detail.missing.joinToString("、")))
if (task.errorMessage != null) resultColumn.addView(context.centeredMessage(task.errorMessage, "错误代码:${task.errorCode ?: "—"}"))
if (CollectionResetPolicy.isAllowed(task.status)) {
if (CollectionResetPolicy.isAllowed(task.status, task.source)) {
resultColumn.addView(MaterialButton(context).apply {
text = "重新采集"
minimumHeight = context.dp(48)
@@ -430,6 +467,63 @@ class TaskHistoryFragment : Fragment() {
}
}
private fun confirmCurrentPageCollection() {
collectionCooldownMessage()?.let { message ->
showCurrentPageBlocked(message)
return
}
val context = requireContext()
val settings = AgentSettingsStore(context)
val credentials = runCatching { SecureDeviceStore(context).credentials() }.getOrNull()
val accessibility = GoAutoAccessibilityService.instance
val problem = when {
settings.serverUrl().isBlank() || credentials == null -> "设备尚未连接服务端,请先检查设置。"
AccessibilityReadinessDetector.current(context) != AccessibilityReadiness.READY || accessibility == null -> "请先到“设置”开启采集采购助手。"
AgentStateStore(context).read().currentTaskId != null -> "设备正在执行任务,请稍后再试。"
!accessibility.hasRecentPddForeground() -> "请先在拼多多打开目标商品详情页,再切回 Agent。"
else -> null
}
if (problem != null) {
showCurrentPageBlocked(problem)
return
}
MaterialAlertDialogBuilder(context)
.setTitle("采集当前商品?")
.setMessage("请确认拼多多已停留在目标商品详情页。Agent 将读取当前商品资料和分享链接;不会采购、创建订单或支付。")
.setNegativeButton("取消", null)
.setPositiveButton("开始采集") { _, _ ->
toast("正在创建临时采集任务…")
AgentForegroundService.collectCurrentPage(context, UUID.randomUUID().toString())
}
.show()
}
private fun showCurrentPageBlocked(message: String) {
MaterialAlertDialogBuilder(requireContext())
.setTitle("暂时不能采集")
.setMessage(message)
.setPositiveButton("知道了", null)
.show()
}
private fun registerCurrentPageReceiver() {
if (currentPageReceiverRegistered) return
val filter = IntentFilter(AgentForegroundService.ACTION_CURRENT_PAGE_RESULT)
if (Build.VERSION.SDK_INT >= 33) {
requireContext().registerReceiver(currentPageReceiver, filter, Context.RECEIVER_NOT_EXPORTED)
} else {
@Suppress("DEPRECATION")
requireContext().registerReceiver(currentPageReceiver, filter)
}
currentPageReceiverRegistered = true
}
private fun unregisterCurrentPageReceiver() {
if (!currentPageReceiverRegistered) return
runCatching { requireContext().unregisterReceiver(currentPageReceiver) }
currentPageReceiverRegistered = false
}
private fun confirmReset(task: CollectionHistoryItem) {
collectionCooldownMessage()?.let { message ->
MaterialAlertDialogBuilder(requireContext())
@@ -10,4 +10,12 @@
<item name="android:windowLightStatusBar">false</item>
<item name="android:windowBackground">#020617</item>
</style>
<style name="Theme.GoAutoAgent.ClipboardRelay" parent="android:style/Theme.Translucent.NoTitleBar">
<item name="android:windowIsTranslucent">true</item>
<item name="android:windowIsFloating">true</item>
<item name="android:windowNoTitle">true</item>
<item name="android:windowBackground">@android:color/transparent</item>
<item name="android:backgroundDimEnabled">false</item>
<item name="android:windowDisablePreview">true</item>
</style>
</resources>
@@ -8,11 +8,12 @@ import org.junit.Test
class CollectionResetPolicyTest {
@Test
fun `only collection terminal states can reset`() {
assertTrue(CollectionResetPolicy.isAllowed("completed"))
assertTrue(CollectionResetPolicy.isAllowed("completed_partial"))
assertTrue(CollectionResetPolicy.isAllowed("failed"))
assertFalse(CollectionResetPolicy.isAllowed("pending"))
assertFalse(CollectionResetPolicy.isAllowed("running"))
assertTrue(CollectionResetPolicy.isAllowed("completed", "admin"))
assertTrue(CollectionResetPolicy.isAllowed("completed_partial", "admin"))
assertTrue(CollectionResetPolicy.isAllowed("failed", "admin"))
assertFalse(CollectionResetPolicy.isAllowed("pending", "admin"))
assertFalse(CollectionResetPolicy.isAllowed("running", "admin"))
assertFalse(CollectionResetPolicy.isAllowed("failed", "agent_current_page"))
}
@Test
@@ -23,10 +24,11 @@ class CollectionResetPolicyTest {
@Test
fun `only failed collection tasks show the list reset action`() {
assertTrue(CollectionResetPolicy.showsListAction("failed"))
assertFalse(CollectionResetPolicy.showsListAction("completed"))
assertFalse(CollectionResetPolicy.showsListAction("completed_partial"))
assertFalse(CollectionResetPolicy.showsListAction("pending"))
assertFalse(CollectionResetPolicy.showsListAction("running"))
assertTrue(CollectionResetPolicy.showsListAction("failed", "admin"))
assertFalse(CollectionResetPolicy.showsListAction("completed", "admin"))
assertFalse(CollectionResetPolicy.showsListAction("completed_partial", "admin"))
assertFalse(CollectionResetPolicy.showsListAction("pending", "admin"))
assertFalse(CollectionResetPolicy.showsListAction("running", "admin"))
assertFalse(CollectionResetPolicy.showsListAction("failed", "agent_current_page"))
}
}
@@ -0,0 +1,137 @@
package cn.ilapage.goauto.agent
import cn.ilapage.goauto.agent.automation.CollectionRule
import cn.ilapage.goauto.agent.automation.CurrentPageIdentityConfig
import cn.ilapage.goauto.agent.automation.CurrentPageIdentityRunner
import cn.ilapage.goauto.agent.automation.FreshActionResult
import cn.ilapage.goauto.agent.automation.NodeBounds
import cn.ilapage.goauto.agent.automation.NodeSelector
import cn.ilapage.goauto.agent.automation.PageEvidence
import cn.ilapage.goauto.agent.automation.PddCollectorDriver
import cn.ilapage.goauto.agent.automation.PddShareLinkExtractor
import cn.ilapage.goauto.agent.automation.SnapshotNode
import cn.ilapage.goauto.agent.automation.SwipeDirection
import cn.ilapage.goauto.agent.automation.UiSnapshot
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class CurrentPageCollectionTest {
@Test
fun extractsOneWhitelistedHttpsUrlOnly() {
assertEquals(
"https://mobile.yangkeduo.com/goods.html?goods_id=12345",
PddShareLinkExtractor.extract("商品链接 https://mobile.yangkeduo.com/goods.html?goods_id=12345。"),
)
assertNull(PddShareLinkExtractor.extract("http://mobile.yangkeduo.com/goods.html?goods_id=12345"))
assertNull(PddShareLinkExtractor.extract("https://example.com/goods.html?goods_id=12345"))
assertNull(PddShareLinkExtractor.extract("https://p.pinduoduo.com/a https://p.pinduoduo.com/b"))
}
@Test
fun identifiesCurrentDetailThroughUniqueShareAndFreshClipboard() {
val driver = FakeDriver(listOf(detailSnapshot(), sharePanelSnapshot(), sharePanelSnapshot()))
val result = CurrentPageIdentityRunner(
driver = driver,
readFreshClipboard = { _, _ -> "复制成功 https://p.pinduoduo.com/abc123" },
now = { 1_000L },
pause = {},
).identify(rule())
assertTrue(result.successful)
assertEquals("https://p.pinduoduo.com/abc123", result.shareUrl)
assertEquals(listOf("分享", "复制链接"), driver.clicked)
assertTrue(driver.backCalled)
}
@Test
fun rejectsAmbiguousShareEntryBeforeReadingClipboard() {
val duplicate = detailSnapshot().copy(nodes = detailSnapshot().nodes + node("share-2", "分享", 200, 20, 280, 80))
val driver = FakeDriver(listOf(duplicate))
val result = CurrentPageIdentityRunner(
driver = driver,
readFreshClipboard = { _, _ -> error("clipboard must not be read") },
now = { 1_000L },
pause = {},
).identify(rule())
assertFalse(result.successful)
assertEquals("PDD_SHARE_UNAVAILABLE", result.code)
}
private class FakeDriver(private val snapshots: List<UiSnapshot>) : PddCollectorDriver {
private var index = 0
val clicked = mutableListOf<String>()
var backCalled = false
override fun capture(): UiSnapshot = snapshots[index.coerceAtMost(snapshots.lastIndex)].also { index++ }
override fun clickFresh(target: SnapshotNode): FreshActionResult {
clicked += target.label
return FreshActionResult.SUCCESS
}
override fun swipeSpec(direction: SwipeDirection, anchor: SnapshotNode?) = false
override fun pullDownGoodsPage() = false
override fun back(): Boolean { backCalled = true; return true }
}
private companion object {
const val PDD_PACKAGE = "com.xunmeng.pinduoduo"
const val ACTIVITY = "com.xunmeng.pinduoduo.activity.NewPageActivity"
fun rule() = CollectionRule(
schemaVersion = 2,
steps = emptyList(),
ruleType = "pddProductDetail",
pageEvidence = PageEvidence(PDD_PACKAGE, ACTIVITY, NodeSelector(resourceId = "android:id/content")),
currentPageIdentity = CurrentPageIdentityConfig(
pageTimeoutMs = 500,
sharePanelTimeoutMs = 500,
clipboardTimeoutMs = 500,
),
)
fun detailSnapshot() = UiSnapshot(
PDD_PACKAGE,
ACTIVITY,
listOf(
node("root", "", 0, 0, 1080, 2200, resourceId = "android:id/content"),
node("share", "分享", 900, 20, 1040, 100),
),
)
fun sharePanelSnapshot() = UiSnapshot(
PDD_PACKAGE,
ACTIVITY,
listOf(
node("root", "", 0, 0, 1080, 2200, resourceId = "android:id/content"),
node("copy", "复制链接", 120, 1700, 360, 1800),
),
)
fun node(
path: String,
text: String,
left: Int,
top: Int,
right: Int,
bottom: Int,
resourceId: String? = null,
) = SnapshotNode(
path = path,
parentPath = null,
text = text,
contentDescription = null,
resourceId = resourceId,
className = "android.widget.TextView",
bounds = NodeBounds(left, top, right, bottom),
clickable = true,
scrollable = false,
selected = false,
checked = false,
enabled = true,
visible = true,
)
}
}
@@ -21,4 +21,15 @@ class TaskExecutionMutexTest {
assertNull(mutex.currentTaskId())
assertTrue(mutex.tryAcquire(11L))
}
@Test
fun transfersAReservationToTheCreatedTask() {
val mutex = TaskExecutionMutex()
assertTrue(mutex.tryAcquire(Long.MAX_VALUE))
assertTrue(mutex.transfer(Long.MAX_VALUE, 42L))
assertEquals(42L, mutex.currentTaskId())
assertFalse(mutex.transfer(Long.MAX_VALUE, 43L))
assertTrue(mutex.release(42L))
}
}
+12 -2
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Architecture-and-Code-Map
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Architecture-and-Code-Map.-
wiki_revision: 5385e32c1d860f4890150701df78437493d56e9d
synchronized_at: 2026-08-26T08:05:06Z
wiki_revision: ecfe6f881cb937688547b0fe819734813d93dd1b
synchronized_at: 2026-08-27T02:38:43Z
<!-- gitea-wiki-mirror:end -->
# 架构与代码地图
@@ -207,3 +207,13 @@ Android Portal/Agent
- `TaskHistoryCache` 使用独立 SharedPreferences 保存当前同步范围的列表摘要,网络读取失败时可显示最近同步摘要;Token 继续只保存在 Android Keystore 保护的设备身份存储中,任务详情不离线镜像。
- 服务端两个 Agent 历史列表查询增加 `days=1..30`,并继续在数据库查询中强制设备隔离、分页上限和 30 天最大窗口;详情读取仍保持原 30 天边界。
- 该链路只读,不接入任务领取、执行、重置、重试、PDD 导航、地址修改、创建订单或支付。
## Agent 当前页面临时采集(#101)
- 服务端入口位于 `server/app/goauto/task/current_page.go`:`CreateCurrentPage` 原子完成设备校验、任务创建、规则快照和租约;`IdentifyCurrentPage` 负责白名单短链解析、PDD 商品复用/创建和任务身份绑定。路由为 `/api/agent/v1/current-page-collection-tasks` 及其 `/{taskId}/identify`。
- 默认规则设置位于 `server/app/goauto/rule/agent_manual_setting.go`,使用单行表 `agent_manual_collection_setting`。迁移 `1787790000000_agent_current_page_collection.go` 增加任务来源、识别幂等字段、可空 PDD 外键和默认规则表,并对旧任务回填 `admin`。
- `collection_task.source` 目前只允许 `admin` / `agent_current_page`。当前页面任务创建时由设备运行槽防并发,识别商品后再参与 PDD 商品活动槽;结构化结果仍写入既有任务及规格子表,不新增临时任务表。
- Android 入口仍由 `TaskHistoryFragment` 承载;`AgentForegroundService` 先用 `TaskExecutionMutex` 预占本地串行槽,服务端创建成功后把预占转为真实 taskId,再恢复 PDD、识别身份并调用既有 `PddProductDetailCollector`。
- `CurrentPageIdentityRunner` 负责页面证据、唯一分享/复制入口和面板清理;`ClipboardRelayActivity` 使用独立、不可导出的短生命周期任务在前台读取新鲜剪贴板,完成后移除中转任务并露出原 PDD 页面。原始剪贴板不进入日志、缓存或网络请求。
- v2 规则新增可选 `currentPageIdentity`(分享/复制别名和三个有界超时),旧 v2 规则由 Android 使用安全默认值;新设备能力为 `collector.pdd.current-page-share.v1`。
- 该路径复用既有结果/失败接口、PDD 最新档案写回、任务结束返回和采集间隔,不调用浏览器导航,不进入任何采购、地址、创建订单或支付代码。
+15 -2
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Business-Rules-and-Glossary
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Business-Rules-and-Glossary.-
wiki_revision: 800b2e0845f8cc69759246d33083793df4cbef7f
synchronized_at: 2026-08-27T01:31:41Z
wiki_revision: 579d9b25297d7b404c68aca4a29ef3179555baa9
synchronized_at: 2026-08-27T02:38:52Z
<!-- gitea-wiki-mirror:end -->
# 业务规则与术语
@@ -272,3 +272,16 @@ synchronized_at: 2026-08-27T01:31:41Z
- 本次抽中的秒数、开始时间和截止时间作为同一间隔票据持久保存在设备本地;Agent 进程或前台服务重启、设置修改都只恢复剩余时间,不重新随机。系统时间回拨时最多按该次已抽中的原始秒数截断,避免无限等待。
- 间隔期间保持屏幕常亮;既有“任务结束 15 秒后返回 Agent”是独立机制,返回 Agent 不清除采集间隔。采购任务接管执行时不重复持有间隔亮屏锁,采购完成后若间隔尚未结束则恢复。
- 该设置不上传服务端,不改变采集任务状态、租约、规则快照或服务端接口,不打开 PDD、不修改地址、不创建订单、不支付。
## Agent 当前 PDD 商品页临时采集
- 操作员先人工在 PDD App 打开并停留在目标商品详情页,再从 Agent 采集 Tab 点击“采集”;该入口采集当前页面,不是重置历史任务。
- Agent 在请求服务端前取得采集/采购共用的本地互斥锁。服务端原子校验设备在线、空闲、跨域活动任务、默认规则和能力,创建固定当前设备且已进入 `running` 的标准采集任务。
- Agent 只通过系统返回恢复刚才的 PDD 任务栈,并重新验证包名、精确 Activity 与页面强证据;不得启动浏览器、从桌面重开 PDD、自动搜索或自动选择相似商品。
- 恢复详情页后先按规则唯一点击“分享”与“复制链接”,只读取本次复制后的单条、有限长度剪贴板文本;Android 只保留唯一白名单 URL,服务端再校验短链并确认 goods_id。原始剪贴板、分享文案、控件树和截图都不保存。
- goods_id 已存在时复用唯一 PDD 商品,成功或部分成功结果按既有最新档案覆盖规则更新;不存在时创建 PDD 商品。该流程不自动停用或替换旧商品,也不修改虾皮到 PDD 的关联。
- 管理员必须配置一条存活的 PDD 商品详情 v2 规则作为 Agent 手动采集默认规则;迁移只做一次安全初选,创建任务时仍实时验证规则与设备 `collector.pdd.current-page-share.v1` 能力。
- 当前页面任务身份确认前允许 `pdd_product_id` 为空、URL/goods_id 快照为空;普通管理端任务不放宽。身份确认后不可改成其他商品,结果 goods_id 必须与任务一致。
- 当前页面任务不能使用“重新采集”重置;失败后重新打开目标商品并新建任务。成功提交完成、部分完成或失败结果后,继续进入设备的采集任务执行间隔。
- 分享入口、复制链接、剪贴板、链接、页面或身份不唯一时明确失败并释放任务槽;不使用 OCR/VLM,不猜测商品身份。
- 该能力只采集商品资料;不创建采购任务、不选择采购规格、不修改地址、不创建订单,永久禁止支付。
+12 -2
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Local-Development-and-Verification
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Local-Development-and-Verification.-
wiki_revision: a75499ddf3d238810d1f845050cdd4b46e49297e
synchronized_at: 2026-08-27T01:31:49Z
wiki_revision: 9253f4f7557eeb014032edd7b0645de19a1516f5
synchronized_at: 2026-08-27T02:39:03Z
<!-- gitea-wiki-mirror:end -->
# 本地开发与验证
@@ -249,3 +249,13 @@ adb shell am start -n cn.ilapage.goauto.agent/.MainActivity
- 间隔期间创建采购任务,确认采购仍优先执行;采购结束后若原间隔未到期,采集继续等待。心跳、采购 Outbox、记录刷新与同步不受影响。
- 将范围设为非固定值并完成一次采集,记录状态页显示的实际剩余秒数;间隔期间重启 Agent 前台服务,确认按同一抽取结果恢复且没有重新随机。使用 `adb shell dumpsys power` 确认 `:collection-cooldown` WakeLock 有界持有并在到期或服务停止后释放。
- 此项验证不要求创建正式采购订单;没有单独授权时不得点击创建订单,永久禁止支付。
### Android Agent 0.9.0 当前页面临时采集检查(#101)
- 自动化验证运行 `cd android && .\gradlew.bat testDebugUnitTest assembleDebug`,服务端运行 `cd server && go test ./...`;覆盖本地串行预占转移、分享 URL 白名单、唯一分享/复制入口、默认规则、创建/识别幂等、身份冲突和结果写回。
- 本地 MySQL 8.4 必须先在明确授权后运行 `cd server && go run . migrate -c config/settings.yml`,确认迁移版本 `1787790000000` 已应用;重复执行应报告 0 个新增迁移。迁移保留旧任务并回填来源 `admin`。
- 真机覆盖安装前确认设备空闲、Agent 0.9.0 已上报 `collector.pdd.current-page-share.v1`、无障碍已人工开启,服务端已有 Agent 手动采集默认规则。
- 在 PDD 人工打开已授权的测试商品详情页,切回 Agent 采集 Tab 点击“采集”并确认;验证 Agent 不启动浏览器,只返回原 PDD 页面,依次完成分享、复制链接、goods_id 识别和常规采集。
- 分别验证直链和 PDD 短链;成功/部分成功应显示来源“Agent 当前页面”,相同 goods_id 不产生重复商品,任务详情与 PDD 最新档案一致。
- 断开网络、离开详情页、制造重复分享入口或剪贴板不可用时,应得到普通人可理解的失败原因并释放设备槽;原始分享文案、剪贴板、控件树和截图不得出现在数据库或日志。
- 验证期间不得自动搜索或选择相似商品,不得修改虾皮关联,不创建采购任务、不修改地址、不创建订单,永久禁止支付。
+50 -2
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Android-Agent-API-Contract
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Android-Agent-API-Contract.-
wiki_revision: d9a3260c7d5953df54eb10233586f601cbf4fcc3
synchronized_at: 2026-08-26T09:34:04Z
wiki_revision: 79f7b1e9e9058db1e9453db6f74dbe7219ffb16f
synchronized_at: 2026-08-27T02:39:41Z
<!-- gitea-wiki-mirror:end -->
# MVP 共享 API 契约
@@ -599,3 +599,51 @@ GET /api/agent/v1/purchase-tasks?page=1&pageSize=20&days=7&status=failed&taskNo=
- Agent 采集/采购 Tab 的顶部下拉只重新读取当前 Tab、当前筛选、当前编号和当前页;不领取、执行、重置或重试任务。
- 设置 Tab 可输入最近 1~30 天的任意整数,默认 7 天;空值、非整数或越界时不发起请求并提示“请输入 1~30 天”。一次同步当前设备的采集与采购列表摘要;同步为服务端到 Agent 的单向读取,允许分别成功并显示部分同步结果。
- 本地只缓存列表摘要和同步时间;详情仍按需请求。缓存不得包含 Device Token、完整规则快照、PDD URL、地址、控件树或截图。同步本身不打开 PDD、不修改地址、不创建订单、不支付。
## Agent 当前页面临时采集(#101)
### 管理端默认规则
```http
GET /api/admin/v1/collection-rules/agent-manual-setting
PUT /api/admin/v1/collection-rules/agent-manual-setting
Content-Type: application/json
{"requestId":"<uuid>","ruleId":3}
```
- 只允许管理员读取和修改“Agent 手动采集默认规则”;PUT 的 `requestId` 必须为 UUID,重复请求幂等。
- 规则必须是仍存在的 PDD 商品详情 v2 规则。数据库迁移首次执行时,优先选择最近成功/部分成功任务使用的存活规则;没有成功历史时选择最近更新的存活 v2 规则;仍没有时保持未配置。
- 创建任务时重新验证规则与设备能力,并把完整规则快照固化到任务;以后修改默认规则不影响已创建任务。
### 创建并占用当前设备
```http
POST /api/agent/v1/current-page-collection-tasks
Authorization: Bearer <device-token>
Content-Type: application/json
{"requestId":"<uuid>"}
```
- Device Token 确定设备。服务端在事务中锁定设备,校验在线、空闲、无活动采集/采购任务、默认规则和 `collector.pdd.current-page-share.v1` 能力。
- 成功即创建来源为 `agent_current_page` 的标准采集任务,固定当前设备、状态为 `running`、建立租约并返回规则快照;不再调用普通 `next → claim → start`。
- 同一设备与 `requestId` 重放返回原任务且 `replayed=true`,不创建第二条任务、不续租。
- 身份识别前响应中的 `pddProductId` 为 `null`,`urlSnapshot` 与 `goodsIdSnapshot` 为空字符串;普通 `admin` 来源任务仍必须在创建时具备完整商品身份。
### 识别当前商品
```http
POST /api/agent/v1/current-page-collection-tasks/{taskId}/identify
Authorization: Bearer <device-token>
Content-Type: application/json
{"requestId":"<uuid>","shareUrl":"https://p.pinduoduo.com/..."}
```
- Android 只上传从本次新鲜剪贴板内容中唯一提取出的分享 URL,不上传完整分享文案。
- 服务端仅接受 HTTPS,主机限定 `p.pinduoduo.com` 与 `mobile.yangkeduo.com`,端口只能省略或为 443;短链最多跟随 4 次重定向,每跳重新校验白名单,总超时 10 秒。
- 服务端提取 5~32 位纯数字 `goods_id`,形成标准 URL,并在事务中创建或复用唯一 PDD 商品;同商品已有活动采集任务或身份冲突时拒绝。
- 相同识别 `requestId` 直接返回已确认身份且不再次访问短链;任务首次确认身份后,不允许不同请求覆盖为其他商品。
- 识别后继续复用 `POST /api/agent/v1/tasks/{taskId}/result` 与 `/fail`。结果接口要求任务已绑定身份且结果 goods_id 一致;完成、部分完成和失败继续按既有状态机释放设备槽。
- Agent 历史的任务摘要增加 `source`;`agent_current_page` 展示为“Agent 当前页面”,不新增任务状态。该来源任务不支持重置,失败后由用户从当前商品页重新发起新任务。
@@ -53,6 +53,17 @@
"settleMs": 1000
}
},
"currentPageIdentity": {
"shareAliases": [
"分享"
],
"copyLinkAliases": [
"复制链接"
],
"pageTimeoutMs": 5000,
"sharePanelTimeoutMs": 5000,
"clipboardTimeoutMs": 5000
},
"hooks": {
"afterSpecPanelOpen": []
},
+1 -1
View File
@@ -23,7 +23,7 @@ func registerHeartbeatDevice(t *testing.T, service *Service) (RegisterRequest, R
func newHeartbeatTask(product models.PDDProduct, rule models.CollectionRule, deviceID uint64) models.CollectionTask {
return models.CollectionTask{
PDDProductID: product.ID, RuleID: rule.ID, DeviceID: &deviceID, Status: models.TaskStatusRunning,
PDDProductID: &product.ID, RuleID: rule.ID, DeviceID: &deviceID, Status: models.TaskStatusRunning,
URLSnapshot: product.URL, GoodsIDSnapshot: product.GoodsID, RuleSnapshot: rule.ContentJSON,
}
}
+1
View File
@@ -25,6 +25,7 @@ func MigratedModels() []any {
&models.PurchaseTask{},
&models.PurchaseTaskAttempt{},
&models.CollectionRule{},
&models.AgentManualCollectionSetting{},
&models.CollectionTask{},
&models.CollectionDimension{},
&models.CollectionDimensionValue{},
+3 -3
View File
@@ -52,7 +52,7 @@ func seedTaskInputs(t *testing.T, db *gorm.DB) (models.PDDProduct, models.Collec
func newTask(product models.PDDProduct, rule models.CollectionRule, deviceID *uint64, status string) models.CollectionTask {
return models.CollectionTask{
PDDProductID: product.ID, RuleID: rule.ID, DeviceID: deviceID, Status: status,
PDDProductID: &product.ID, RuleID: rule.ID, DeviceID: deviceID, Status: status,
URLSnapshot: product.URL, GoodsIDSnapshot: product.GoodsID, RuleSnapshot: rule.ContentJSON,
}
}
@@ -63,7 +63,7 @@ func TestMigrationIsIdempotentAndHasExpectedTables(t *testing.T) {
t.Fatalf("second migration: %v", err)
}
for _, table := range []string{
"agent_device", "pdd_product", "shopee_product", "syb_product", "collection_rule", "collection_task",
"agent_device", "pdd_product", "shopee_product", "syb_product", "collection_rule", "agent_manual_collection_setting", "collection_task",
"collection_dimension", "collection_dimension_value", "collection_sku", "collection_sku_value",
} {
if !db.Migrator().HasTable(table) {
@@ -190,7 +190,7 @@ func TestOnlyOneActiveTaskPerProduct(t *testing.T) {
func TestTaskIdempotencyColumnsExist(t *testing.T) {
db := openDatabase(t)
for _, field := range []string{"ClaimRequestID", "StartRequestID"} {
for _, field := range []string{"ClaimRequestID", "StartRequestID", "CreateRequestID", "IdentifyRequestID"} {
if !db.Migrator().HasColumn(&models.CollectionTask{}, field) {
t.Fatalf("collection_task column for %s is missing", field)
}
+62 -34
View File
@@ -17,6 +17,9 @@ const (
TaskStatusCompleted = "completed"
TaskStatusCompletedPartial = "completed_partial"
TaskStatusFailed = "failed"
CollectionTaskSourceAdmin = "admin"
CollectionTaskSourceAgentCurrentPage = "agent_current_page"
)
// AgentDevice identifies one Agent installation. InstallID is generated by the
@@ -113,50 +116,75 @@ func (CollectionRule) TableName() string { return "collection_rule" }
// while value 1 makes the composite unique indexes enforce active-task limits
// consistently on SQLite, MySQL and PostgreSQL.
type CollectionTask struct {
ID uint64 `json:"id" gorm:"primaryKey;autoIncrement"`
PDDProductID uint64 `json:"pddProductId" gorm:"not null;uniqueIndex:ux_collection_task_active_product,priority:1"`
PDDProduct PDDProduct `json:"-" gorm:"constraint:OnUpdate:CASCADE,OnDelete:RESTRICT"`
RuleID uint64 `json:"ruleId" gorm:"not null;index"`
Rule CollectionRule `json:"-" gorm:"constraint:OnUpdate:CASCADE,OnDelete:RESTRICT"`
DeviceID *uint64 `json:"deviceId" gorm:"index;uniqueIndex:ux_collection_task_running_device,priority:1"`
Device *AgentDevice `json:"-"`
Status string `json:"status" gorm:"size:24;not null;index;check:ck_collection_task_status,status IN ('pending','running','completed','completed_partial','failed')"`
ActiveSlot *uint8 `json:"-" gorm:"uniqueIndex:ux_collection_task_active_product,priority:2;check:ck_collection_task_active_slot,(status IN ('pending','running') AND active_slot = 1) OR (status NOT IN ('pending','running') AND active_slot IS NULL)"`
DeviceRunSlot *uint8 `json:"-" gorm:"uniqueIndex:ux_collection_task_running_device,priority:2;check:ck_collection_task_device_run_slot,(status = 'running' AND device_id IS NOT NULL AND device_run_slot = 1) OR (status <> 'running' AND device_run_slot IS NULL)"`
URLSnapshot string `json:"urlSnapshot" gorm:"type:text;not null"`
GoodsIDSnapshot string `json:"goodsIdSnapshot" gorm:"size:32;not null;index"`
RuleSnapshot string `json:"ruleSnapshot" gorm:"type:text;not null"`
LeaseExpiresAt *time.Time `json:"leaseExpiresAt" gorm:"index"`
LeaseVersion uint64 `json:"leaseVersion" gorm:"not null;default:0"`
ClaimRequestID *string `json:"-" gorm:"size:36;uniqueIndex:ux_collection_task_claim_request_id"`
StartRequestID *string `json:"-" gorm:"size:36;uniqueIndex:ux_collection_task_start_request_id"`
CreateRequestID *string `json:"-" gorm:"size:36;uniqueIndex:ux_collection_task_create_request_id"`
ResetRequestID *string `json:"-" gorm:"size:36;uniqueIndex:ux_collection_task_reset_request_id"`
DeleteRequestID *string `json:"-" gorm:"size:36;uniqueIndex:ux_collection_task_delete_request_id"`
FailRequestID *string `json:"-" gorm:"size:36;uniqueIndex:ux_collection_task_fail_request_id"`
Title *string `json:"title" gorm:"size:500"`
ShopName *string `json:"shopName" gorm:"size:255"`
SalesText *string `json:"salesText" gorm:"size:120"`
ReviewCount *int64 `json:"reviewCount"`
MissingJSON *string `json:"missing" gorm:"type:text"`
ResultRequestID *string `json:"resultRequestId" gorm:"size:64;uniqueIndex:ux_collection_task_result_request_id"`
ErrorCode *string `json:"errorCode" gorm:"size:64;index"`
ErrorMessage *string `json:"errorMessage" gorm:"size:1000"`
StartedAt *time.Time `json:"startedAt"`
FinishedAt *time.Time `json:"finishedAt"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
DeletedAt gorm.DeletedAt `json:"-" gorm:"index"`
ID uint64 `json:"id" gorm:"primaryKey;autoIncrement"`
PDDProductID *uint64 `json:"pddProductId" gorm:"uniqueIndex:ux_collection_task_active_product,priority:1"`
PDDProduct *PDDProduct `json:"-" gorm:"constraint:OnUpdate:CASCADE,OnDelete:RESTRICT"`
RuleID uint64 `json:"ruleId" gorm:"not null;index"`
Rule CollectionRule `json:"-" gorm:"constraint:OnUpdate:CASCADE,OnDelete:RESTRICT"`
DeviceID *uint64 `json:"deviceId" gorm:"index;uniqueIndex:ux_collection_task_running_device,priority:1"`
Device *AgentDevice `json:"-"`
Source string `json:"source" gorm:"size:32;not null;default:admin;index;check:ck_collection_task_source,source IN ('admin','agent_current_page')"`
Status string `json:"status" gorm:"size:24;not null;index;check:ck_collection_task_status,status IN ('pending','running','completed','completed_partial','failed')"`
ActiveSlot *uint8 `json:"-" gorm:"uniqueIndex:ux_collection_task_active_product,priority:2;check:ck_collection_task_active_slot,(status IN ('pending','running') AND active_slot = 1) OR (status NOT IN ('pending','running') AND active_slot IS NULL)"`
DeviceRunSlot *uint8 `json:"-" gorm:"uniqueIndex:ux_collection_task_running_device,priority:2;check:ck_collection_task_device_run_slot,(status = 'running' AND device_id IS NOT NULL AND device_run_slot = 1) OR (status <> 'running' AND device_run_slot IS NULL)"`
URLSnapshot string `json:"urlSnapshot" gorm:"type:text;not null"`
GoodsIDSnapshot string `json:"goodsIdSnapshot" gorm:"size:32;not null;index"`
RuleSnapshot string `json:"ruleSnapshot" gorm:"type:text;not null"`
LeaseExpiresAt *time.Time `json:"leaseExpiresAt" gorm:"index"`
LeaseVersion uint64 `json:"leaseVersion" gorm:"not null;default:0"`
ClaimRequestID *string `json:"-" gorm:"size:36;uniqueIndex:ux_collection_task_claim_request_id"`
StartRequestID *string `json:"-" gorm:"size:36;uniqueIndex:ux_collection_task_start_request_id"`
CreateRequestID *string `json:"-" gorm:"size:36;uniqueIndex:ux_collection_task_create_request_id"`
IdentifyRequestID *string `json:"-" gorm:"size:36;uniqueIndex:ux_collection_task_identify_request_id"`
ResetRequestID *string `json:"-" gorm:"size:36;uniqueIndex:ux_collection_task_reset_request_id"`
DeleteRequestID *string `json:"-" gorm:"size:36;uniqueIndex:ux_collection_task_delete_request_id"`
FailRequestID *string `json:"-" gorm:"size:36;uniqueIndex:ux_collection_task_fail_request_id"`
Title *string `json:"title" gorm:"size:500"`
ShopName *string `json:"shopName" gorm:"size:255"`
SalesText *string `json:"salesText" gorm:"size:120"`
ReviewCount *int64 `json:"reviewCount"`
MissingJSON *string `json:"missing" gorm:"type:text"`
ResultRequestID *string `json:"resultRequestId" gorm:"size:64;uniqueIndex:ux_collection_task_result_request_id"`
ErrorCode *string `json:"errorCode" gorm:"size:64;index"`
ErrorMessage *string `json:"errorMessage" gorm:"size:1000"`
StartedAt *time.Time `json:"startedAt"`
FinishedAt *time.Time `json:"finishedAt"`
IdentityResolvedAt *time.Time `json:"identityResolvedAt"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
DeletedAt gorm.DeletedAt `json:"-" gorm:"index"`
}
func (CollectionTask) TableName() string { return "collection_task" }
func (task *CollectionTask) BeforeCreate(_ *gorm.DB) error {
if task.Source == "" {
task.Source = CollectionTaskSourceAdmin
}
return task.syncGuardSlots()
}
// BeforeSave derives the nullable uniqueness guards from the state instead of
// relying on every caller to keep them synchronized.
func (task *CollectionTask) BeforeSave(_ *gorm.DB) error {
return task.syncGuardSlots()
}
// AgentManualCollectionSetting stores the one collection rule selected for
// current-page collection. A singleton row keeps this operational choice out
// of task payloads while every created task still receives an immutable rule
// snapshot.
type AgentManualCollectionSetting struct {
ID uint8 `json:"id" gorm:"primaryKey;autoIncrement:false"`
RuleID uint64 `json:"ruleId" gorm:"not null;index"`
Rule CollectionRule `json:"-" gorm:"constraint:OnUpdate:CASCADE,OnDelete:RESTRICT"`
LastUpdateRequestID *string `json:"-" gorm:"size:36;uniqueIndex:ux_agent_manual_collection_setting_request_id"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
func (AgentManualCollectionSetting) TableName() string { return "agent_manual_collection_setting" }
// SetStatus changes state and synchronizes guard slots. Callers performing a
// column-scoped update must persist status, active_slot and device_run_slot in
// the same statement; the database check constraints reject partial updates.
+3 -3
View File
@@ -85,7 +85,7 @@ func TestUpdateKeepsExistingTaskSnapshot(t *testing.T) {
t.Fatalf("create rule: %v", err)
}
task := models.CollectionTask{
PDDProductID: created.Product.ID, RuleID: rule.ID, Status: models.TaskStatusPending,
PDDProductID: &created.Product.ID, RuleID: rule.ID, Status: models.TaskStatusPending,
URLSnapshot: created.Product.URL, GoodsIDSnapshot: created.Product.GoodsID, RuleSnapshot: rule.ContentJSON,
}
if err := db.Create(&task).Error; err != nil {
@@ -151,7 +151,7 @@ func TestListMarksProductsUnavailableForCollection(t *testing.T) {
t.Fatal(err)
}
active := models.CollectionTask{
PDDProductID: products[2].ID, RuleID: rule.ID, Status: models.TaskStatusPending,
PDDProductID: &products[2].ID, RuleID: rule.ID, Status: models.TaskStatusPending,
URLSnapshot: products[2].URL, GoodsIDSnapshot: products[2].GoodsID, RuleSnapshot: rule.ContentJSON,
}
if err := db.Create(&active).Error; err != nil {
@@ -207,7 +207,7 @@ func TestDetailUsesSameCollectionEligibilityAsList(t *testing.T) {
t.Fatal(err)
}
active := models.CollectionTask{
PDDProductID: products[2].ID, RuleID: rule.ID, Status: models.TaskStatusPending,
PDDProductID: &products[2].ID, RuleID: rule.ID, Status: models.TaskStatusPending,
URLSnapshot: products[2].URL, GoodsIDSnapshot: products[2].GoodsID, RuleSnapshot: rule.ContentJSON,
}
if err := db.Create(&active).Error; err != nil {
+3 -1
View File
@@ -170,7 +170,9 @@ func (s *Service) loadBatchPreviewDataset(ctx context.Context, ids []uint64) (ba
return dataset, err
}
for _, task := range collectionTasks {
dataset.latestCollectionByPDD[task.PDDProductID] = task
if task.PDDProductID != nil {
dataset.latestCollectionByPDD[*task.PDDProductID] = task
}
}
}
}
@@ -43,13 +43,15 @@ func TestProcessStageDerivesAllUserFacingStages(t *testing.T) {
row := d.pddByID[3]
row.Status = "pending"
d.pddByID[3] = row
d.latestCollectionByPDD[3] = models.CollectionTask{ID: 8, PDDProductID: 3, Status: models.TaskStatusRunning}
pddID := uint64(3)
d.latestCollectionByPDD[3] = models.CollectionTask{ID: 8, PDDProductID: &pddID, Status: models.TaskStatusRunning}
}},
{"PDD 采集失败", ProcessStagePDDCollectionFail, "open_pdd", func(d *batchPreviewDataset, _ *BatchPreviewItem) {
row := d.pddByID[3]
row.Status = "pending"
d.pddByID[3] = row
d.latestCollectionByPDD[3] = models.CollectionTask{ID: 8, PDDProductID: 3, Status: models.TaskStatusFailed}
pddID := uint64(3)
d.latestCollectionByPDD[3] = models.CollectionTask{ID: 8, PDDProductID: &pddID, Status: models.TaskStatusFailed}
}},
{"颜色待匹配", ProcessStageColorMapping, "open_mapping", func(_ *batchPreviewDataset, p *BatchPreviewItem) { p.ReasonCode = CodeMappingRequired }},
{"可创建采购", ProcessStagePurchaseReady, "", func(_ *batchPreviewDataset, p *BatchPreviewItem) { p.Eligible = true }},
@@ -0,0 +1,91 @@
package rule
import (
"context"
"encoding/json"
"errors"
"strings"
"go-admin/app/goauto/models"
"go-admin/app/goauto/rulecontract"
"github.com/google/uuid"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
type AgentManualSettingRequest struct {
RequestID string `json:"requestId"`
RuleID uint64 `json:"ruleId"`
}
type AgentManualSettingResponse struct {
Configured bool `json:"configured"`
RuleID uint64 `json:"ruleId,omitempty"`
RuleName string `json:"ruleName,omitempty"`
Replayed bool `json:"replayed,omitempty"`
}
func (service *Service) AgentManualSetting(ctx context.Context) (AgentManualSettingResponse, error) {
var setting models.AgentManualCollectionSetting
if err := service.DB.WithContext(ctx).First(&setting, 1).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return AgentManualSettingResponse{}, nil
}
return AgentManualSettingResponse{}, internalError(err)
}
var record models.CollectionRule
if err := service.DB.WithContext(ctx).First(&record, setting.RuleID).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return AgentManualSettingResponse{}, nil
}
return AgentManualSettingResponse{}, internalError(err)
}
return AgentManualSettingResponse{Configured: true, RuleID: record.ID, RuleName: record.Name}, nil
}
func (service *Service) UpdateAgentManualSetting(ctx context.Context, request AgentManualSettingRequest) (AgentManualSettingResponse, error) {
request.RequestID = strings.TrimSpace(request.RequestID)
if uuid.Validate(request.RequestID) != nil || request.RuleID == 0 {
return AgentManualSettingResponse{}, invalidRequest("requestId 和 ruleId 必须有效")
}
var response AgentManualSettingResponse
err := service.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var existing models.AgentManualCollectionSetting
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&existing, 1).Error; err == nil {
if existing.LastUpdateRequestID != nil && *existing.LastUpdateRequestID == request.RequestID {
var current models.CollectionRule
if err := tx.First(&current, existing.RuleID).Error; err != nil {
return internalError(err)
}
response = AgentManualSettingResponse{Configured: true, RuleID: current.ID, RuleName: current.Name, Replayed: true}
return nil
}
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
return internalError(err)
}
var record models.CollectionRule
if err := tx.First(&record, request.RuleID).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return notFound()
}
return internalError(err)
}
if err := rulecontract.Validate([]byte(record.ContentJSON)); err != nil {
return invalidRule(err.Error())
}
var header struct {
SchemaVersion int `json:"schemaVersion"`
}
if err := json.Unmarshal([]byte(record.ContentJSON), &header); err != nil || header.SchemaVersion != 2 {
return invalidRule("Agent 手动采集默认规则必须是 PDD 商品详情 v2")
}
setting := models.AgentManualCollectionSetting{ID: 1, RuleID: record.ID, LastUpdateRequestID: &request.RequestID}
if err := tx.Save(&setting).Error; err != nil {
return internalError(err)
}
response = AgentManualSettingResponse{Configured: true, RuleID: record.ID, RuleName: record.Name}
return nil
})
return response, err
}
+33
View File
@@ -14,6 +14,39 @@ import (
type Handler struct{ DB *gorm.DB }
func (h Handler) AgentManualSetting(c *gin.Context) {
s, ok := h.service(c)
if !ok {
return
}
result, err := s.AgentManualSetting(c.Request.Context())
if err != nil {
writeError(c, err)
return
}
c.Header("Cache-Control", "no-store")
c.JSON(http.StatusOK, gin.H{"code": http.StatusOK, "data": result})
}
func (h Handler) UpdateAgentManualSetting(c *gin.Context) {
request, err := decode[AgentManualSettingRequest](c)
if err != nil {
writeError(c, invalidRequest("请求 JSON 无效"))
return
}
s, ok := h.service(c)
if !ok {
return
}
result, err := s.UpdateAgentManualSetting(c.Request.Context(), request)
if err != nil {
writeError(c, err)
return
}
c.Header("Cache-Control", "no-store")
c.JSON(http.StatusOK, gin.H{"code": http.StatusOK, "data": result})
}
func (h Handler) Template(c *gin.Context) {
s, ok := h.service(c)
if !ok {
+2
View File
@@ -10,6 +10,8 @@ func InitRouter(engine *gin.Engine, auth *jwt.GinJWTMiddleware) {
handler := Handler{}
admin := engine.Group("/api/admin/v1/collection-rules").Use(auth.MiddlewareFunc()).Use(middleware.AuthCheckRole())
admin.GET("/templates/:templateId", handler.Template)
admin.GET("/agent-manual-setting", middleware.RequireRoleKey("admin"), handler.AgentManualSetting)
admin.PUT("/agent-manual-setting", middleware.RequireRoleKey("admin"), handler.UpdateAgentManualSetting)
admin.GET("", handler.List)
admin.POST("", middleware.RequireRoleKey("admin"), handler.Create)
admin.PATCH("/:ruleId", middleware.RequireRoleKey("admin"), handler.Update)
+34 -1
View File
@@ -83,6 +83,39 @@ func TestPDDTemplateRejectsUnknownCapabilityAndDangerousAction(t *testing.T) {
}
}
func TestAgentManualSettingRequiresV2RuleAndIsIdempotent(t *testing.T) {
db := openRuleDatabase(t)
service := NewService(db)
v1, err := service.Create(context.Background(), saveRequest("legacy", `{"schemaVersion":1,"steps":[]}`))
if err != nil {
t.Fatal(err)
}
if _, err = service.UpdateAgentManualSetting(context.Background(), AgentManualSettingRequest{RequestID: uuid.NewString(), RuleID: v1.Rule.ID}); ruleErrorCode(t, err) != CodeRuleInvalid {
t.Fatalf("legacy rule was accepted as Agent manual default: %v", err)
}
template, err := service.Template("pdd-product-detail-v1")
if err != nil {
t.Fatal(err)
}
v2, err := service.Create(context.Background(), SaveRequest{RequestID: uuid.NewString(), Name: "manual", Content: template.Content})
if err != nil {
t.Fatal(err)
}
request := AgentManualSettingRequest{RequestID: uuid.NewString(), RuleID: v2.Rule.ID}
configured, err := service.UpdateAgentManualSetting(context.Background(), request)
if err != nil || !configured.Configured || configured.RuleID != v2.Rule.ID {
t.Fatalf("configure manual rule: response=%+v error=%v", configured, err)
}
replay, err := service.UpdateAgentManualSetting(context.Background(), request)
if err != nil || !replay.Replayed || replay.RuleID != v2.Rule.ID {
t.Fatalf("manual setting replay: response=%+v error=%v", replay, err)
}
read, err := service.AgentManualSetting(context.Background())
if err != nil || !read.Configured || read.RuleID != v2.Rule.ID {
t.Fatalf("read manual setting: response=%+v error=%v", read, err)
}
}
func TestRuleLifecycleIsIdempotentAndSoftDeletes(t *testing.T) {
db := openRuleDatabase(t)
service := NewService(db)
@@ -99,7 +132,7 @@ func TestRuleLifecycleIsIdempotentAndSoftDeletes(t *testing.T) {
if e = db.Create(&product).Error; e != nil {
t.Fatal(e)
}
task := models.CollectionTask{PDDProductID: product.ID, RuleID: created.Rule.ID, Status: models.TaskStatusPending, URLSnapshot: product.URL, GoodsIDSnapshot: product.GoodsID, RuleSnapshot: string(request.Content)}
task := models.CollectionTask{PDDProductID: &product.ID, RuleID: created.Rule.ID, Status: models.TaskStatusPending, URLSnapshot: product.URL, GoodsIDSnapshot: product.GoodsID, RuleSnapshot: string(request.Content)}
if e = db.Create(&task).Error; e != nil {
t.Fatal(e)
}
+45 -7
View File
@@ -11,9 +11,10 @@ import (
)
const (
CapabilitySchemaV2 = "rule.schema.v2"
CapabilitySwipeV1 = "action.swipe.v1"
CapabilityPDDProductDetailV1 = "collector.pdd.product-detail.v1"
CapabilitySchemaV2 = "rule.schema.v2"
CapabilitySwipeV1 = "action.swipe.v1"
CapabilityPDDProductDetailV1 = "collector.pdd.product-detail.v1"
CapabilityPDDCurrentPageShareV1 = "collector.pdd.current-page-share.v1"
)
var allowedNavigationPackages = map[string]bool{
@@ -95,6 +96,14 @@ type navigationRecovery struct {
ReopenBrowser *reopenBrowserRecovery `json:"reopenBrowser,omitempty"`
}
type currentPageIdentity struct {
ShareAliases []string `json:"shareAliases"`
CopyLinkAliases []string `json:"copyLinkAliases"`
PageTimeoutMS int `json:"pageTimeoutMs"`
SharePanelTimeoutMS int `json:"sharePanelTimeoutMs"`
ClipboardTimeoutMS int `json:"clipboardTimeoutMs"`
}
type v2Rule struct {
SchemaVersion int `json:"schemaVersion"`
RuleType string `json:"ruleType"`
@@ -106,10 +115,11 @@ type v2Rule struct {
ActivityName string `json:"activityName"`
Selector selector `json:"selector"`
} `json:"pageEvidence"`
Hooks map[string][]hookAction `json:"hooks,omitempty"`
NavigationRecovery *navigationRecovery `json:"navigationRecovery,omitempty"`
PageRecovery *pageRecovery `json:"pageRecovery,omitempty"`
Collector collectorConfig `json:"collector"`
Hooks map[string][]hookAction `json:"hooks,omitempty"`
NavigationRecovery *navigationRecovery `json:"navigationRecovery,omitempty"`
PageRecovery *pageRecovery `json:"pageRecovery,omitempty"`
CurrentPageIdentity *currentPageIdentity `json:"currentPageIdentity,omitempty"`
Collector collectorConfig `json:"collector"`
}
// Validate accepts the legacy v1 shape and strictly validates the v2 product
@@ -278,6 +288,19 @@ func parseV2(raw []byte) (v2Rule, error) {
return rule, errors.New("navigationRecovery.reopenBrowser.settleMs 必须为 500..5000")
}
}
if identity := rule.CurrentPageIdentity; identity != nil {
if err := validateAliases(identity.ShareAliases, "currentPageIdentity.shareAliases", 1, 8, 20); err != nil {
return rule, err
}
if err := validateAliases(identity.CopyLinkAliases, "currentPageIdentity.copyLinkAliases", 1, 8, 20); err != nil {
return rule, err
}
if identity.PageTimeoutMS < 500 || identity.PageTimeoutMS > 15000 ||
identity.SharePanelTimeoutMS < 500 || identity.SharePanelTimeoutMS > 10000 ||
identity.ClipboardTimeoutMS < 500 || identity.ClipboardTimeoutMS > 10000 {
return rule, errors.New("currentPageIdentity 超时必须在允许范围内")
}
}
if rule.Collector.CollectorID != "pddProductDetailV1" || rule.Collector.SpecEntryStrategy != "safeBottomSpecEntryV1" || rule.Collector.PriceParser != "pddRmbPriceV1" || rule.Collector.PriceGranularity != "color" {
return rule, errors.New("collector 使用了 Agent 不支持的类型化能力")
}
@@ -305,6 +328,21 @@ func parseV2(raw []byte) (v2Rule, error) {
return rule, nil
}
func validateAliases(values []string, label string, min, max, maxLength int) error {
if len(values) < min || len(values) > max {
return fmt.Errorf("%s 必须包含 %d..%d 项", label, min, max)
}
seen := make(map[string]bool, len(values))
for _, value := range values {
trimmed := strings.TrimSpace(value)
if trimmed == "" || len([]rune(trimmed)) > maxLength || seen[trimmed] {
return fmt.Errorf("%s 含空值、重复项或过长文字", label)
}
seen[trimmed] = true
}
return nil
}
func validateIntMap(values map[string]int, allowed map[string][2]int) error {
if len(values) != len(allowed) {
return errors.New("规则整数配置缺失或包含未知字段")
@@ -107,3 +107,20 @@ func TestV2ValidatesReopenBrowserRecovery(t *testing.T) {
}
}
}
func TestV2ValidatesCurrentPageIdentity(t *testing.T) {
base := validV2(`[]`)
valid := strings.Replace(base, `"hooks"`, `"currentPageIdentity":{"shareAliases":["分享"],"copyLinkAliases":["复制链接"],"pageTimeoutMs":5000,"sharePanelTimeoutMs":5000,"clipboardTimeoutMs":5000},"hooks"`, 1)
if err := Validate([]byte(valid)); err != nil {
t.Fatalf("valid current-page identity rejected: %v", err)
}
for _, invalid := range []string{
strings.Replace(valid, `"shareAliases":["分享"]`, `"shareAliases":[]`, 1),
strings.Replace(valid, `"pageTimeoutMs":5000`, `"pageTimeoutMs":100`, 1),
strings.Replace(valid, `"clipboardTimeoutMs":5000`, `"clipboardTimeoutMs":20000`, 1),
} {
if err := Validate([]byte(invalid)); err == nil {
t.Fatalf("invalid current-page identity accepted: %s", invalid)
}
}
}
@@ -12,6 +12,7 @@ var pddProductDetailTemplate = json.RawMessage(`{
]},
"pageEvidence":{"packageName":"com.xunmeng.pinduoduo","activityName":"com.xunmeng.pinduoduo.activity.NewPageActivity","selector":{"resourceId":"android:id/content","className":"android.widget.FrameLayout"}},
"navigationRecovery":{"reopenBrowser":{"enabled":true,"maxAttempts":1,"settleMs":1000}},
"currentPageIdentity":{"shareAliases":["分享"],"copyLinkAliases":["复制链接"],"pageTimeoutMs":5000,"sharePanelTimeoutMs":5000,"clipboardTimeoutMs":5000},
"pageRecovery":{"transientSoldOut":{"enabled":true,"exactText":"商品已售罄","fallbackTopText":"相似商品","pullDownCount":2,"intervalMs":1000,"settleMs":2000,"maxAttempts":1}},
"hooks":{"afterSpecPanelOpen":[]},
"collector":{
+1 -1
View File
@@ -65,7 +65,7 @@ func TestBatchCreateReturnsPerProductResultsAndReplays(t *testing.T) {
t.Fatal(err)
}
existing := models.CollectionTask{
PDDProductID: products[1].ID, RuleID: rule.ID, Status: models.TaskStatusPending,
PDDProductID: &products[1].ID, RuleID: rule.ID, Status: models.TaskStatusPending,
URLSnapshot: products[1].URL, GoodsIDSnapshot: products[1].GoodsID, RuleSnapshot: rule.ContentJSON,
}
if err := db.Create(&existing).Error; err != nil {
+2 -1
View File
@@ -124,7 +124,8 @@ func (service *Service) Create(ctx context.Context, request CreateRequest) (Crea
return serviceError(CodeProductTaskActive, "该商品已有待执行或执行中的任务")
}
task := models.CollectionTask{
PDDProductID: product.ID, RuleID: rule.ID, DeviceID: request.DeviceID,
PDDProductID: &product.ID, RuleID: rule.ID, DeviceID: request.DeviceID,
Source: models.CollectionTaskSourceAdmin,
Status: models.TaskStatusPending, URLSnapshot: product.URL, GoodsIDSnapshot: product.GoodsID,
RuleSnapshot: rule.ContentJSON, CreateRequestID: &request.RequestID,
}
+2 -1
View File
@@ -27,6 +27,7 @@ type AgentHistoryRequest struct {
type AgentCollectionItem struct {
TaskID uint64 `json:"taskId"`
Status string `json:"status"`
Source string `json:"source"`
GoodsID string `json:"goodsId"`
Title *string `json:"title,omitempty"`
MissingCount int `json:"missingCount"`
@@ -159,7 +160,7 @@ func agentCollectionItem(record models.CollectionTask) AgentCollectionItem {
missing = len(values)
}
return AgentCollectionItem{
TaskID: record.ID, Status: record.Status, GoodsID: record.GoodsIDSnapshot, Title: record.Title,
TaskID: record.ID, Status: record.Status, Source: record.Source, GoodsID: record.GoodsIDSnapshot, Title: record.Title,
MissingCount: missing, ErrorCode: record.ErrorCode, ErrorMessage: record.ErrorMessage,
StartedAt: record.StartedAt, FinishedAt: record.FinishedAt, CreatedAt: record.CreatedAt,
}
+324
View File
@@ -0,0 +1,324 @@
package task
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"regexp"
"strings"
"time"
"go-admin/app/goauto/device"
"go-admin/app/goauto/models"
"go-admin/app/goauto/rulecontract"
"github.com/google/uuid"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
const (
CodeAgentManualRuleNotConfigured = "AGENT_MANUAL_RULE_NOT_CONFIGURED"
CodeCurrentPageIdentityRequired = "CURRENT_PAGE_IDENTITY_REQUIRED"
CodeCurrentPageIdentityConflict = "CURRENT_PAGE_IDENTITY_CONFLICT"
CodePDDShareLinkInvalid = "PDD_SHARE_LINK_INVALID"
)
type CurrentPageCreateRequest struct {
RequestID string `json:"requestId"`
}
type CurrentPageIdentifyRequest struct {
RequestID string `json:"requestId"`
ShareURL string `json:"shareUrl"`
}
type CurrentPageIdentifyResponse struct {
TaskID uint64 `json:"taskId"`
PDDProductID uint64 `json:"pddProductId"`
GoodsID string `json:"goodsId"`
URL string `json:"url"`
Replayed bool `json:"replayed,omitempty"`
}
type ResolvedPDDShare struct {
GoodsID string
URL string
}
func (service *Service) CreateCurrentPage(ctx context.Context, request CurrentPageCreateRequest, token string) (TaskPayload, error) {
request.RequestID = strings.TrimSpace(request.RequestID)
if uuid.Validate(request.RequestID) != nil {
return TaskPayload{}, serviceError(device.CodeInvalidRequest, "requestId 必须是 UUID")
}
var response TaskPayload
err := service.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
deviceRecord, err := device.NewService(tx).Authenticate(ctx, token)
if err != nil {
return err
}
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&deviceRecord, deviceRecord.ID).Error; err != nil {
return internalError(err)
}
var replay models.CollectionTask
if err := tx.Where("create_request_id = ?", request.RequestID).First(&replay).Error; err == nil {
if replay.Source != models.CollectionTaskSourceAgentCurrentPage || replay.DeviceID == nil || *replay.DeviceID != deviceRecord.ID {
return serviceError(CodeTaskStateConflict, "requestId 已被其他任务使用")
}
response, err = service.payload(replay, true)
return err
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
return internalError(err)
}
if deviceRecord.Status != models.DeviceStatusOnline {
return serviceError(CodeDeviceOffline, "设备离线,不能开始采集")
}
var setting models.AgentManualCollectionSetting
if err := tx.First(&setting, 1).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return serviceError(CodeAgentManualRuleNotConfigured, "请先配置 Agent 手动采集规则")
}
return internalError(err)
}
var rule models.CollectionRule
if err := tx.First(&rule, setting.RuleID).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return serviceError(CodeAgentManualRuleNotConfigured, "请先配置 Agent 手动采集规则")
}
return internalError(err)
}
if err := ensureCurrentPageRule(rule.ContentJSON); err != nil {
return err
}
if err := ensureRuleCompatible(deviceRecord, rule.ContentJSON); err != nil {
return err
}
supported, err := device.Supports(deviceRecord, []string{rulecontract.CapabilityPDDCurrentPageShareV1})
if err != nil {
return err
}
if !supported {
return serviceError(CodeDeviceCapabilityMismatch, "当前手机版本不支持采集当前商品")
}
now := service.Now()
if err := ensureDeviceIdleForCurrentPage(tx, deviceRecord.ID, now); err != nil {
return err
}
lease := now.Add(service.leaseDuration())
task := models.CollectionTask{
RuleID: rule.ID, DeviceID: &deviceRecord.ID,
Source: models.CollectionTaskSourceAgentCurrentPage, Status: models.TaskStatusRunning,
URLSnapshot: "", GoodsIDSnapshot: "", RuleSnapshot: rule.ContentJSON,
LeaseExpiresAt: &lease, LeaseVersion: 1, CreateRequestID: &request.RequestID,
StartRequestID: &request.RequestID, StartedAt: &now,
}
if err := tx.Create(&task).Error; err != nil {
return internalError(err)
}
response, err = service.payload(task, false)
return err
})
return response, err
}
func ensureCurrentPageRule(content string) error {
var header struct {
SchemaVersion int `json:"schemaVersion"`
}
if err := json.Unmarshal([]byte(content), &header); err != nil || header.SchemaVersion != 2 {
return serviceError(CodeAgentManualRuleNotConfigured, "Agent 手动采集默认规则不可用,请重新配置")
}
if err := rulecontract.Validate([]byte(content)); err != nil {
return serviceError(CodeAgentManualRuleNotConfigured, "Agent 手动采集默认规则不可用,请重新配置")
}
return nil
}
func ensureDeviceIdleForCurrentPage(tx *gorm.DB, deviceID uint64, now time.Time) error {
var busy int64
if err := tx.Model(&models.CollectionTask{}).
Where("device_id = ? AND (status = ? OR (status = ? AND lease_expires_at > ?))", deviceID, models.TaskStatusRunning, models.TaskStatusPending, now).
Count(&busy).Error; err != nil {
return internalError(err)
}
if busy > 0 {
return serviceError(CodeDeviceBusy, "设备正在执行任务,请稍后再试")
}
if err := tx.Model(&models.PurchaseTask{}).
Where("device_id = ? AND (status IN ? OR (status IN ? AND lease_expires_at > ?))", deviceID,
[]string{models.PurchaseTaskStatusRunning, models.PurchaseTaskStatusOrderSubmitStarted},
[]string{models.PurchaseTaskStatusPending, models.PurchaseTaskStatusSpecProbePending}, now).
Count(&busy).Error; err != nil {
return internalError(err)
}
if busy > 0 {
return serviceError(CodeDeviceBusy, "设备正在执行任务,请稍后再试")
}
return nil
}
func (service *Service) IdentifyCurrentPage(ctx context.Context, taskID uint64, request CurrentPageIdentifyRequest, token string) (CurrentPageIdentifyResponse, error) {
request.RequestID = strings.TrimSpace(request.RequestID)
request.ShareURL = strings.TrimSpace(request.ShareURL)
if taskID == 0 || uuid.Validate(request.RequestID) != nil || request.ShareURL == "" || len(request.ShareURL) > 2048 {
return CurrentPageIdentifyResponse{}, serviceError(device.CodeInvalidRequest, "当前商品识别请求无效")
}
deviceRecord, err := device.NewService(service.DB).Authenticate(ctx, token)
if err != nil {
return CurrentPageIdentifyResponse{}, err
}
var existing models.CollectionTask
if err := service.DB.WithContext(ctx).First(&existing, taskID).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return CurrentPageIdentifyResponse{}, serviceError(CodeTaskNotFound, "任务不存在")
}
return CurrentPageIdentifyResponse{}, internalError(err)
}
if existing.Source != models.CollectionTaskSourceAgentCurrentPage || existing.DeviceID == nil || *existing.DeviceID != deviceRecord.ID {
return CurrentPageIdentifyResponse{}, serviceError(CodeTaskNotFound, "任务不存在")
}
if existing.IdentifyRequestID != nil && *existing.IdentifyRequestID == request.RequestID && existing.PDDProductID != nil {
return CurrentPageIdentifyResponse{
TaskID: existing.ID, PDDProductID: *existing.PDDProductID,
GoodsID: existing.GoodsIDSnapshot, URL: existing.URLSnapshot, Replayed: true,
}, nil
}
resolver := service.ResolveCurrentPageShare
if resolver == nil {
resolver = ResolvePDDShareURL
}
resolved, err := resolver(ctx, request.ShareURL)
if err != nil {
return CurrentPageIdentifyResponse{}, err
}
var response CurrentPageIdentifyResponse
err = service.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&deviceRecord, deviceRecord.ID).Error; err != nil {
return internalError(err)
}
var record models.CollectionTask
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&record, taskID).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return serviceError(CodeTaskNotFound, "任务不存在")
}
return internalError(err)
}
if record.Source != models.CollectionTaskSourceAgentCurrentPage || record.DeviceID == nil || *record.DeviceID != deviceRecord.ID {
return serviceError(CodeTaskNotFound, "任务不存在")
}
if record.IdentifyRequestID != nil && *record.IdentifyRequestID == request.RequestID && record.PDDProductID != nil {
response = CurrentPageIdentifyResponse{TaskID: record.ID, PDDProductID: *record.PDDProductID, GoodsID: record.GoodsIDSnapshot, URL: record.URLSnapshot, Replayed: true}
return nil
}
if record.Status != models.TaskStatusRunning {
return serviceError(CodeTaskStateConflict, "当前采集任务已经结束")
}
if record.PDDProductID != nil {
if record.GoodsIDSnapshot != resolved.GoodsID {
return serviceError(CodeCurrentPageIdentityConflict, "当前商品与任务已识别商品不一致")
}
response = CurrentPageIdentifyResponse{TaskID: record.ID, PDDProductID: *record.PDDProductID, GoodsID: record.GoodsIDSnapshot, URL: record.URLSnapshot, Replayed: true}
return nil
}
var product models.PDDProduct
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("goods_id = ?", resolved.GoodsID).First(&product).Error; err != nil {
if !errors.Is(err, gorm.ErrRecordNotFound) {
return internalError(err)
}
product = models.PDDProduct{GoodsID: resolved.GoodsID, URL: resolved.URL, Status: "pending", SpecsJSON: "[]"}
if err := tx.Create(&product).Error; err != nil {
return internalError(err)
}
}
var active int64
if err := tx.Model(&models.CollectionTask{}).
Where("id <> ? AND pdd_product_id = ? AND status IN ?", record.ID, product.ID, []string{models.TaskStatusPending, models.TaskStatusRunning}).
Count(&active).Error; err != nil {
return internalError(err)
}
if active > 0 {
return serviceError(CodeProductTaskActive, "这个商品已有采集任务,请稍后再试")
}
now := service.Now()
updates := map[string]any{
"pdd_product_id": product.ID, "url_snapshot": resolved.URL, "goods_id_snapshot": resolved.GoodsID,
"identify_request_id": request.RequestID, "identity_resolved_at": now,
}
if err := tx.Session(&gorm.Session{SkipHooks: true}).Model(&models.CollectionTask{}).Where("id = ?", record.ID).Updates(updates).Error; err != nil {
return internalError(err)
}
response = CurrentPageIdentifyResponse{TaskID: record.ID, PDDProductID: product.ID, GoodsID: resolved.GoodsID, URL: resolved.URL}
return nil
})
return response, err
}
var pddGoodsIDPattern = regexp.MustCompile(`^[0-9]{5,32}$`)
func ResolvePDDShareURL(ctx context.Context, raw string) (ResolvedPDDShare, error) {
parsed, err := validatePDDShareURL(raw)
if err != nil {
return ResolvedPDDShare{}, err
}
if goodsID := parsed.Query().Get("goods_id"); goodsID != "" {
return resolvedPDDShare(goodsID)
}
if !strings.EqualFold(parsed.Hostname(), "p.pinduoduo.com") {
return ResolvedPDDShare{}, serviceError(CodePDDShareLinkInvalid, "无法识别商品链接")
}
client := &http.Client{
Timeout: 10 * time.Second,
CheckRedirect: func(request *http.Request, via []*http.Request) error {
if len(via) >= 4 {
return errors.New("too many redirects")
}
_, err := validatePDDShareURL(request.URL.String())
return err
},
}
request, err := http.NewRequestWithContext(ctx, http.MethodGet, parsed.String(), nil)
if err != nil {
return ResolvedPDDShare{}, serviceError(CodePDDShareLinkInvalid, "无法识别商品链接")
}
request.Header.Set("User-Agent", "GoAuto-Agent-Link-Resolver/1.0")
response, err := client.Do(request)
if err != nil {
return ResolvedPDDShare{}, &ServiceError{Code: CodePDDShareLinkInvalid, Message: "商品链接解析失败,请检查网络后重试", Retryable: true, Cause: err}
}
defer response.Body.Close()
_, _ = io.Copy(io.Discard, io.LimitReader(response.Body, 64<<10))
if response.StatusCode < 200 || response.StatusCode >= 400 {
return ResolvedPDDShare{}, serviceError(CodePDDShareLinkInvalid, "无法识别商品链接")
}
finalURL, err := validatePDDShareURL(response.Request.URL.String())
if err != nil {
return ResolvedPDDShare{}, err
}
return resolvedPDDShare(finalURL.Query().Get("goods_id"))
}
func validatePDDShareURL(raw string) (*url.URL, error) {
parsed, err := url.Parse(strings.TrimSpace(raw))
if err != nil || !strings.EqualFold(parsed.Scheme, "https") || parsed.User != nil {
return nil, serviceError(CodePDDShareLinkInvalid, "无法识别商品链接")
}
host := strings.ToLower(parsed.Hostname())
if host != "p.pinduoduo.com" && host != "mobile.yangkeduo.com" {
return nil, serviceError(CodePDDShareLinkInvalid, "无法识别商品链接")
}
if parsed.Port() != "" && parsed.Port() != "443" {
return nil, serviceError(CodePDDShareLinkInvalid, "无法识别商品链接")
}
return parsed, nil
}
func resolvedPDDShare(goodsID string) (ResolvedPDDShare, error) {
if !pddGoodsIDPattern.MatchString(goodsID) {
return ResolvedPDDShare{}, serviceError(CodePDDShareLinkInvalid, "商品链接中没有有效 goods_id")
}
return ResolvedPDDShare{GoodsID: goodsID, URL: fmt.Sprintf("https://mobile.yangkeduo.com/goods.html?goods_id=%s", goodsID)}, nil
}
+147
View File
@@ -0,0 +1,147 @@
package task
import (
"context"
"testing"
"go-admin/app/goauto/models"
"go-admin/app/goauto/rulecontract"
"github.com/google/uuid"
)
func TestCreateAndIdentifyCurrentPageTaskAreIdempotent(t *testing.T) {
db := openTaskDatabase(t)
deviceRecord, token := registerTaskDeviceWithCapabilities(t, db, "current-page-device", []string{
rulecontract.CapabilitySchemaV2,
rulecontract.CapabilityPDDProductDetailV1,
rulecontract.CapabilityPDDCurrentPageShareV1,
})
rule := models.CollectionRule{Name: "current-page-rule", ContentJSON: v2TaskRuleSnapshot()}
if err := db.Create(&rule).Error; err != nil {
t.Fatalf("create rule: %v", err)
}
if err := db.Create(&models.AgentManualCollectionSetting{ID: 1, RuleID: rule.ID}).Error; err != nil {
t.Fatalf("create manual setting: %v", err)
}
service := newTaskService(db)
createRequest := CurrentPageCreateRequest{RequestID: uuid.NewString()}
created, err := service.CreateCurrentPage(context.Background(), createRequest, token)
if err != nil {
t.Fatalf("create current-page task: %v", err)
}
if created.Status != models.TaskStatusRunning || created.Source != models.CollectionTaskSourceAgentCurrentPage || created.PDDProductID != nil {
t.Fatalf("unexpected task payload: %+v", created)
}
if created.URLSnapshot != "" || created.GoodsIDSnapshot != "" || created.LeaseVersion != 1 {
t.Fatalf("current-page task identity must start empty: %+v", created)
}
replay, err := service.CreateCurrentPage(context.Background(), createRequest, token)
if err != nil || !replay.Replayed || replay.TaskID != created.TaskID {
t.Fatalf("create replay mismatch: response=%+v error=%v", replay, err)
}
if err := db.Model(&models.AgentDevice{}).Where("id = ?", deviceRecord.ID).Update("status", models.DeviceStatusOffline).Error; err != nil {
t.Fatalf("mark device offline: %v", err)
}
offlineReplay, err := service.CreateCurrentPage(context.Background(), createRequest, token)
if err != nil || !offlineReplay.Replayed || offlineReplay.TaskID != created.TaskID {
t.Fatalf("offline create replay mismatch: response=%+v error=%v", offlineReplay, err)
}
if err := db.Model(&models.AgentDevice{}).Where("id = ?", deviceRecord.ID).Update("status", models.DeviceStatusOnline).Error; err != nil {
t.Fatalf("restore device online: %v", err)
}
identifyRequest := CurrentPageIdentifyRequest{
RequestID: uuid.NewString(),
ShareURL: "https://mobile.yangkeduo.com/goods.html?goods_id=731370706977",
}
identity, err := service.IdentifyCurrentPage(context.Background(), created.TaskID, identifyRequest, token)
if err != nil {
t.Fatalf("identify current-page task: %v", err)
}
if identity.GoodsID != "731370706977" || identity.PDDProductID == 0 || identity.URL != identifyRequest.ShareURL {
t.Fatalf("unexpected identity: %+v", identity)
}
service.ResolveCurrentPageShare = func(context.Context, string) (ResolvedPDDShare, error) {
t.Fatal("idempotent identify must not resolve the share URL again")
return ResolvedPDDShare{}, nil
}
identifyReplay, err := service.IdentifyCurrentPage(context.Background(), created.TaskID, identifyRequest, token)
if err != nil || !identifyReplay.Replayed || identifyReplay.PDDProductID != identity.PDDProductID {
t.Fatalf("identify replay mismatch: response=%+v error=%v", identifyReplay, err)
}
title, shop := "当前页商品", "测试店铺"
detail, err := service.SubmitResult(context.Background(), created.TaskID, ResultRequest{
RequestID: uuid.NewString(), Status: models.TaskStatusCompleted,
Product: ResultProduct{PDDGoodsID: identity.GoodsID, Title: &title, ShopName: &shop},
Dimensions: []ResultDimension{{Key: "color", Name: "颜色", Values: []string{"黑色"}}},
ColorPrices: []ResultColorPrice{{Color: "黑色", PriceCent: 1200}},
SKUs: []ResultSKU{{Specs: map[string]string{"color": "黑色"}, PriceCent: 1200, Available: true}},
}, token)
if err != nil || detail.Task.Status != models.TaskStatusCompleted {
t.Fatalf("submit current-page result: detail=%+v error=%v", detail, err)
}
var product models.PDDProduct
if err := db.First(&product, identity.PDDProductID).Error; err != nil || product.Title != title || product.Status != "active" {
t.Fatalf("current-page result did not update product: product=%+v error=%v", product, err)
}
var persisted models.CollectionTask
if err := db.First(&persisted, created.TaskID).Error; err != nil {
t.Fatalf("load task: %v", err)
}
if persisted.DeviceID == nil || *persisted.DeviceID != deviceRecord.ID || persisted.PDDProductID == nil {
t.Fatalf("task identity/device was not persisted: %+v", persisted)
}
}
func TestCurrentPageTaskRequiresConfiguredRuleAndCapability(t *testing.T) {
db := openTaskDatabase(t)
_, token := registerTaskDevice(t, db, "legacy-device")
service := newTaskService(db)
_, err := service.CreateCurrentPage(context.Background(), CurrentPageCreateRequest{RequestID: uuid.NewString()}, token)
if taskErrorCode(t, err) != CodeAgentManualRuleNotConfigured {
t.Fatalf("expected missing manual rule, got %v", err)
}
rule := models.CollectionRule{Name: "current-page-rule", ContentJSON: v2TaskRuleSnapshot()}
if err := db.Create(&rule).Error; err != nil {
t.Fatalf("create rule: %v", err)
}
if err := db.Create(&models.AgentManualCollectionSetting{ID: 1, RuleID: rule.ID}).Error; err != nil {
t.Fatalf("create manual setting: %v", err)
}
if err := db.Model(&rule).Update("content_json", `{"schemaVersion":1}`).Error; err != nil {
t.Fatalf("set legacy rule: %v", err)
}
_, err = service.CreateCurrentPage(context.Background(), CurrentPageCreateRequest{RequestID: uuid.NewString()}, token)
if taskErrorCode(t, err) != CodeAgentManualRuleNotConfigured {
t.Fatalf("expected legacy manual rule rejection, got %v", err)
}
if err := db.Model(&rule).Update("content_json", v2TaskRuleSnapshot()).Error; err != nil {
t.Fatalf("restore v2 rule: %v", err)
}
_, err = service.CreateCurrentPage(context.Background(), CurrentPageCreateRequest{RequestID: uuid.NewString()}, token)
if taskErrorCode(t, err) != CodeDeviceCapabilityMismatch {
t.Fatalf("expected capability mismatch, got %v", err)
}
}
func TestResolvePDDShareURLRejectsUnsafeOrAmbiguousIdentity(t *testing.T) {
valid, err := ResolvePDDShareURL(context.Background(), "https://mobile.yangkeduo.com/goods.html?goods_id=972800403573")
if err != nil || valid.GoodsID != "972800403573" {
t.Fatalf("resolve valid direct link: result=%+v error=%v", valid, err)
}
for _, raw := range []string{
"http://mobile.yangkeduo.com/goods.html?goods_id=972800403573",
"https://example.com/goods.html?goods_id=972800403573",
"https://user@mobile.yangkeduo.com/goods.html?goods_id=972800403573",
"https://mobile.yangkeduo.com:8443/goods.html?goods_id=972800403573",
"https://mobile.yangkeduo.com/goods.html?goods_id=abc",
} {
if _, err := ResolvePDDShareURL(context.Background(), raw); taskErrorCode(t, err) != CodePDDShareLinkInvalid {
t.Fatalf("expected unsafe link rejection for %q, got %v", raw, err)
}
}
}
+46 -3
View File
@@ -18,6 +18,49 @@ import (
type Handler struct{ DB *gorm.DB }
func (handler Handler) CreateCurrentPage(context *gin.Context) {
var request CurrentPageCreateRequest
if err := decodeStrict(context, &request); err != nil {
writeError(context, serviceError(device.CodeInvalidRequest, "请求 JSON 无效"))
return
}
service, token, ok := handler.service(context)
if !ok {
return
}
payload, err := service.CreateCurrentPage(context.Request.Context(), request, token)
if err != nil {
writeError(context, err)
return
}
context.Header("Cache-Control", "no-store")
context.JSON(http.StatusCreated, gin.H{"data": payload})
}
func (handler Handler) IdentifyCurrentPage(context *gin.Context) {
id, err := taskID(context)
if err != nil || id == 0 {
writeError(context, serviceError(device.CodeInvalidRequest, "taskId 无效"))
return
}
var request CurrentPageIdentifyRequest
if err := decodeStrict(context, &request); err != nil {
writeError(context, serviceError(device.CodeInvalidRequest, "请求 JSON 无效"))
return
}
service, token, ok := handler.service(context)
if !ok {
return
}
payload, err := service.IdentifyCurrentPage(context.Request.Context(), id, request, token)
if err != nil {
writeError(context, err)
return
}
context.Header("Cache-Control", "no-store")
context.JSON(http.StatusOK, gin.H{"data": payload})
}
func (handler Handler) Next(context *gin.Context) {
service, token, ok := handler.service(context)
if !ok {
@@ -233,7 +276,7 @@ func writeError(context *gin.Context, err error) {
code, message, retryable = deviceError.Code, deviceError.Message, deviceError.Retryable
}
switch code {
case device.CodeInvalidRequest, "RESULT_SPEC_INVALID", "RESULT_GOODS_ID_MISMATCH":
case device.CodeInvalidRequest, "RESULT_SPEC_INVALID", "RESULT_GOODS_ID_MISMATCH", CodePDDShareLinkInvalid:
status = http.StatusUnprocessableEntity
case device.CodeTokenInvalid:
status = http.StatusUnauthorized
@@ -241,11 +284,11 @@ func writeError(context *gin.Context, err error) {
status = http.StatusForbidden
case CodeTaskNotFound:
status = http.StatusNotFound
case CodeTaskAlreadyClaimed, CodeTaskAssignedOther, CodeDeviceBusy, CodeDeviceOffline, CodeTaskStateConflict, CodeTaskLeaseExpired:
case CodeTaskAlreadyClaimed, CodeTaskAssignedOther, CodeDeviceBusy, CodeDeviceOffline, CodeTaskStateConflict, CodeTaskLeaseExpired, CodeCurrentPageIdentityRequired, CodeCurrentPageIdentityConflict:
status = http.StatusConflict
case CodeProductTaskActive, CodeProductDisabled:
status = http.StatusConflict
case CodeProductNotFound, CodeRuleNotFound, CodeDeviceNotFound:
case CodeProductNotFound, CodeRuleNotFound, CodeDeviceNotFound, CodeAgentManualRuleNotConfigured:
status = http.StatusNotFound
}
context.JSON(status, gin.H{"code": code, "message": message, "retryable": retryable})
@@ -70,6 +70,12 @@ func (service *Service) reset(ctx context.Context, taskID uint64, request Action
replayed = true
return nil
}
if record.Source == models.CollectionTaskSourceAgentCurrentPage {
return serviceError(CodeTaskStateConflict, "当前页面采集不能重置,请回到商品页重新发起采集")
}
if record.PDDProductID == nil {
return serviceError(CodeTaskStateConflict, "任务尚未识别商品,不能重置")
}
if record.Status == models.TaskStatusRunning {
return serviceError(CodeTaskStateConflict, "执行中的任务不能重置")
}
+7 -1
View File
@@ -103,6 +103,9 @@ func (service *Service) SubmitResult(ctx context.Context, taskID uint64, request
if err := requireRunningOwner(record, deviceRecord.ID); err != nil {
return err
}
if record.PDDProductID == nil || record.GoodsIDSnapshot == "" {
return serviceError("CURRENT_PAGE_IDENTITY_REQUIRED", "请先识别当前拼多多商品")
}
if request.Product.PDDGoodsID != record.GoodsIDSnapshot {
return serviceError("RESULT_GOODS_ID_MISMATCH", "结果 goods_id 与任务快照不一致")
}
@@ -161,7 +164,10 @@ func persistResult(tx *gorm.DB, record models.CollectionTask, request ResultRequ
if len(request.Missing) > 0 {
status = models.TaskStatusCompletedPartial
}
if err := applyResultToProduct(tx, record.PDDProductID, request, status); err != nil {
if record.PDDProductID == nil {
return serviceError("CURRENT_PAGE_IDENTITY_REQUIRED", "请先识别当前拼多多商品")
}
if err := applyResultToProduct(tx, *record.PDDProductID, request, status); err != nil {
return err
}
updates := map[string]any{
+2
View File
@@ -17,6 +17,8 @@ func InitRouter(engine *gin.Engine, auth *jwt.GinJWTMiddleware) {
trustForwardedProto, _ := strconv.ParseBool(os.Getenv("GOAUTO_TRUST_FORWARDED_PROTO"))
agent := engine.Group("/api/agent/v1").Use(device.RequireHTTPS(config.ApplicationConfig.Mode == "prod", trustForwardedProto))
agent.GET("/tasks/next", handler.Next)
agent.POST("/current-page-collection-tasks", handler.CreateCurrentPage)
agent.POST("/current-page-collection-tasks/:taskId/identify", handler.IdentifyCurrentPage)
agent.GET("/collection-tasks", handler.AgentHistory)
agent.GET("/collection-tasks/:taskId", handler.AgentHistoryDetail)
agent.POST("/collection-tasks/:taskId/reset", handler.AgentReset)
+9 -5
View File
@@ -52,9 +52,10 @@ type ActionRequest struct {
type TaskPayload struct {
TaskID uint64 `json:"taskId"`
PDDProductID uint64 `json:"pddProductId"`
PDDProductID *uint64 `json:"pddProductId"`
URLSnapshot string `json:"urlSnapshot"`
GoodsIDSnapshot string `json:"goodsIdSnapshot"`
Source string `json:"source"`
RuleID uint64 `json:"ruleId"`
RuleSnapshot json.RawMessage `json:"ruleSnapshot"`
TimeoutSeconds int `json:"timeoutSeconds"`
@@ -65,16 +66,18 @@ type TaskPayload struct {
}
type Service struct {
DB *gorm.DB
Now func() time.Time
LeaseDuration time.Duration
TaskTimeout int
DB *gorm.DB
Now func() time.Time
LeaseDuration time.Duration
TaskTimeout int
ResolveCurrentPageShare func(context.Context, string) (ResolvedPDDShare, error)
}
func NewService(db *gorm.DB) *Service {
return &Service{
DB: db, Now: func() time.Time { return time.Now().UTC() },
LeaseDuration: DefaultLeaseDuration, TaskTimeout: DefaultTaskTimeout,
ResolveCurrentPageShare: ResolvePDDShareURL,
}
}
@@ -287,6 +290,7 @@ func (service *Service) payload(record models.CollectionTask, replayed bool) (Ta
return TaskPayload{
TaskID: record.ID, PDDProductID: record.PDDProductID,
URLSnapshot: record.URLSnapshot, GoodsIDSnapshot: record.GoodsIDSnapshot,
Source: record.Source,
RuleID: record.RuleID, RuleSnapshot: rule, TimeoutSeconds: timeout,
LeaseExpiresAt: record.LeaseExpiresAt, LeaseVersion: record.LeaseVersion,
Status: record.Status, Replayed: replayed,
+2 -2
View File
@@ -71,7 +71,7 @@ func createTask(t *testing.T, db *gorm.DB, deviceID *uint64) models.CollectionTa
t.Fatalf("create rule: %v", err)
}
record := models.CollectionTask{
PDDProductID: product.ID, RuleID: rule.ID, DeviceID: deviceID,
PDDProductID: &product.ID, RuleID: rule.ID, DeviceID: deviceID,
Status: models.TaskStatusPending, URLSnapshot: product.URL,
GoodsIDSnapshot: product.GoodsID, RuleSnapshot: rule.ContentJSON,
}
@@ -191,7 +191,7 @@ func TestV2TaskIsOnlyOfferedToCapableDevice(t *testing.T) {
t.Fatal(err)
}
task := models.CollectionTask{
PDDProductID: product.ID, RuleID: rule.ID, Status: models.TaskStatusPending,
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 {
@@ -0,0 +1,55 @@
package version_local
import (
"errors"
"runtime"
goautomigrations "go-admin/app/goauto/migrations"
"go-admin/app/goauto/models"
"go-admin/cmd/migrate/migration"
common "go-admin/common/models"
"gorm.io/gorm"
)
// #101 makes collection_task identity nullable only while an Agent current-page
// task is running, records its source, and creates the singleton default-rule
// setting. Existing tasks are backfilled by the source column default. When a
// deployment already has successful collection history, its most recently used
// live rule is selected once to preserve the working collector after upgrade.
func init() {
_, fileName, _, _ := runtime.Caller(0)
migration.Migrate.SetVersion(migration.GetFilename(fileName), migrateAgentCurrentPageCollection)
}
func migrateAgentCurrentPageCollection(db *gorm.DB, version string) error {
return db.Transaction(func(tx *gorm.DB) error {
if err := goautomigrations.Migrate(tx); err != nil {
return err
}
if err := seedAgentManualCollectionSetting(tx); err != nil {
return err
}
return tx.Create(&common.Migration{Version: version}).Error
})
}
func seedAgentManualCollectionSetting(db *gorm.DB) error {
var count int64
if err := db.Model(&models.AgentManualCollectionSetting{}).Where("id = ?", 1).Count(&count).Error; err != nil || count > 0 {
return err
}
var rule models.CollectionRule
err := db.Where("id = (SELECT rule_id FROM collection_task WHERE status IN ? AND rule_id IN (SELECT id FROM collection_rule WHERE deleted_at IS NULL) ORDER BY id DESC LIMIT 1)",
[]string{models.TaskStatusCompleted, models.TaskStatusCompletedPartial}).First(&rule).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
err = db.Where("content_json LIKE ?", `%\"schemaVersion\":2%`).Order("updated_at DESC, id DESC").First(&rule).Error
}
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil
}
if err != nil {
return err
}
return db.Create(&models.AgentManualCollectionSetting{ID: 1, RuleID: rule.ID}).Error
}