diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts
index 1187a3e..973f003 100644
--- a/android/app/build.gradle.kts
+++ b/android/app/build.gradle.kts
@@ -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"
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index f5ad9ca..fd3b37a 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -16,6 +16,14 @@
android:label="@string/app_name"
android:supportsRtl="true"
android:theme="@style/Theme.GoAutoAgent">
+
diff --git a/android/app/src/main/java/cn/ilapage/goauto/agent/ClipboardRelayActivity.kt b/android/app/src/main/java/cn/ilapage/goauto/agent/ClipboardRelayActivity.kt
new file mode 100644
index 0000000..93daa45
--- /dev/null
+++ b/android/app/src/main/java/cn/ilapage/goauto/agent/ClipboardRelayActivity.kt
@@ -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()
+
+ 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(null)
+ }
+}
diff --git a/android/app/src/main/java/cn/ilapage/goauto/agent/automation/CurrentPageCollection.kt b/android/app/src/main/java/cn/ilapage/goauto/agent/automation/CurrentPageCollection.kt
new file mode 100644
index 0000000..03d07b1
--- /dev/null
+++ b/android/app/src/main/java/cn/ilapage/goauto/agent/automation/CurrentPageCollection.kt
@@ -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, 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, 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 { it.bounds.width.toLong() * it.bounds.height }.thenBy { it.path.length })
+ val distinct = mutableListOf()
+ 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) {
+ 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"
+ }
+}
diff --git a/android/app/src/main/java/cn/ilapage/goauto/agent/automation/GoAutoAccessibilityService.kt b/android/app/src/main/java/cn/ilapage/goauto/agent/automation/GoAutoAccessibilityService.kt
index d79f8e8..d559a1c 100644
--- a/android/app/src/main/java/cn/ilapage/goauto/agent/automation/GoAutoAccessibilityService.kt
+++ b/android/app/src/main/java/cn/ilapage/goauto/agent/automation/GoAutoAccessibilityService.kt
@@ -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
diff --git a/android/app/src/main/java/cn/ilapage/goauto/agent/automation/RuleContract.kt b/android/app/src/main/java/cn/ilapage/goauto/agent/automation/RuleContract.kt
index c1112db..5aee3f3 100644
--- a/android/app/src/main/java/cn/ilapage/goauto/agent/automation/RuleContract.kt
+++ b/android/app/src/main/java/cn/ilapage/goauto/agent/automation/RuleContract.kt
@@ -74,6 +74,14 @@ data class ReopenBrowserRecovery(
val settleMs: Long,
)
+data class CurrentPageIdentityConfig(
+ val shareAliases: List = listOf("分享"),
+ val copyLinkAliases: List = 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,
@@ -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 = 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 {
+ 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? {
diff --git a/android/app/src/main/java/cn/ilapage/goauto/agent/network/AgentApiClient.kt b/android/app/src/main/java/cn/ilapage/goauto/agent/network/AgentApiClient.kt
index 5741efd..59c712a 100644
--- a/android/app/src/main/java/cn/ilapage/goauto/agent/network/AgentApiClient.kt
+++ b/android/app/src/main/java/cn/ilapage/goauto/agent/network/AgentApiClient.kt
@@ -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(
diff --git a/android/app/src/main/java/cn/ilapage/goauto/agent/persistence/TaskHistoryCache.kt b/android/app/src/main/java/cn/ilapage/goauto/agent/persistence/TaskHistoryCache.kt
index 6eec524..1214212 100644
--- a/android/app/src/main/java/cn/ilapage/goauto/agent/persistence/TaskHistoryCache.kt
+++ b/android/app/src/main/java/cn/ilapage/goauto/agent/persistence/TaskHistoryCache.kt
@@ -13,7 +13,7 @@ class TaskHistoryCache(context: Context) {
fun saveCollection(days: Int, items: List) = 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? {
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)
}
diff --git a/android/app/src/main/java/cn/ilapage/goauto/agent/service/AgentForegroundService.kt b/android/app/src/main/java/cn/ilapage/goauto/agent/service/AgentForegroundService.kt
index 4c18332..de8f8fa 100644
--- a/android/app/src/main/java/cn/ilapage/goauto/agent/service/AgentForegroundService.kt
+++ b/android/app/src/main/java/cn/ilapage/goauto/agent/service/AgentForegroundService.kt
@@ -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)
+ }
+ }
}
}
diff --git a/android/app/src/main/java/cn/ilapage/goauto/agent/service/TaskExecutionMutex.kt b/android/app/src/main/java/cn/ilapage/goauto/agent/service/TaskExecutionMutex.kt
index 3ff2c00..835672b 100644
--- a/android/app/src/main/java/cn/ilapage/goauto/agent/service/TaskExecutionMutex.kt
+++ b/android/app/src/main/java/cn/ilapage/goauto/agent/service/TaskExecutionMutex.kt
@@ -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(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
+ }
}
diff --git a/android/app/src/main/java/cn/ilapage/goauto/agent/ui/TaskHistoryFragment.kt b/android/app/src/main/java/cn/ilapage/goauto/agent/ui/TaskHistoryFragment.kt
index 4cb5883..646e657 100644
--- a/android/app/src/main/java/cn/ilapage/goauto/agent/ui/TaskHistoryFragment.kt
+++ b/android/app/src/main/java/cn/ilapage/goauto/agent/ui/TaskHistoryFragment.kt
@@ -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())
diff --git a/android/app/src/main/res/values/styles.xml b/android/app/src/main/res/values/styles.xml
index d8a4d24..03ea873 100644
--- a/android/app/src/main/res/values/styles.xml
+++ b/android/app/src/main/res/values/styles.xml
@@ -10,4 +10,12 @@
- false
- #020617
+
diff --git a/android/app/src/test/java/cn/ilapage/goauto/agent/CollectionResetPolicyTest.kt b/android/app/src/test/java/cn/ilapage/goauto/agent/CollectionResetPolicyTest.kt
index 3ca0ffe..6500242 100644
--- a/android/app/src/test/java/cn/ilapage/goauto/agent/CollectionResetPolicyTest.kt
+++ b/android/app/src/test/java/cn/ilapage/goauto/agent/CollectionResetPolicyTest.kt
@@ -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"))
}
}
diff --git a/android/app/src/test/java/cn/ilapage/goauto/agent/CurrentPageCollectionTest.kt b/android/app/src/test/java/cn/ilapage/goauto/agent/CurrentPageCollectionTest.kt
new file mode 100644
index 0000000..f095400
--- /dev/null
+++ b/android/app/src/test/java/cn/ilapage/goauto/agent/CurrentPageCollectionTest.kt
@@ -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) : PddCollectorDriver {
+ private var index = 0
+ val clicked = mutableListOf()
+ 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,
+ )
+ }
+}
diff --git a/android/app/src/test/java/cn/ilapage/goauto/agent/TaskExecutionMutexTest.kt b/android/app/src/test/java/cn/ilapage/goauto/agent/TaskExecutionMutexTest.kt
index 2522718..35046ee 100644
--- a/android/app/src/test/java/cn/ilapage/goauto/agent/TaskExecutionMutexTest.kt
+++ b/android/app/src/test/java/cn/ilapage/goauto/agent/TaskExecutionMutexTest.kt
@@ -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))
+ }
}
diff --git a/docs/02-architecture-and-code-map.md b/docs/02-architecture-and-code-map.md
index 10d7246..b8c5d47 100644
--- a/docs/02-architecture-and-code-map.md
+++ b/docs/02-architecture-and-code-map.md
@@ -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
# 架构与代码地图
@@ -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 最新档案写回、任务结束返回和采集间隔,不调用浏览器导航,不进入任何采购、地址、创建订单或支付代码。
diff --git a/docs/03-business-rules-and-glossary.md b/docs/03-business-rules-and-glossary.md
index d7fe83f..f612dd9 100644
--- a/docs/03-business-rules-and-glossary.md
+++ b/docs/03-business-rules-and-glossary.md
@@ -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
# 业务规则与术语
@@ -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,不猜测商品身份。
+- 该能力只采集商品资料;不创建采购任务、不选择采购规格、不修改地址、不创建订单,永久禁止支付。
diff --git a/docs/04-local-development-and-verification.md b/docs/04-local-development-and-verification.md
index f8dfff7..dfa6ac0 100644
--- a/docs/04-local-development-and-verification.md
+++ b/docs/04-local-development-and-verification.md
@@ -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
# 本地开发与验证
@@ -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 最新档案一致。
+- 断开网络、离开详情页、制造重复分享入口或剪贴板不可用时,应得到普通人可理解的失败原因并释放设备槽;原始分享文案、剪贴板、控件树和截图不得出现在数据库或日志。
+- 验证期间不得自动搜索或选择相似商品,不得修改虾皮关联,不创建采购任务、不修改地址、不创建订单,永久禁止支付。
diff --git a/docs/08-agent-api-contract.md b/docs/08-agent-api-contract.md
index 3709f2a..5abd8c0 100644
--- a/docs/08-agent-api-contract.md
+++ b/docs/08-agent-api-contract.md
@@ -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
# 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":"","ruleId":3}
+```
+
+- 只允许管理员读取和修改“Agent 手动采集默认规则”;PUT 的 `requestId` 必须为 UUID,重复请求幂等。
+- 规则必须是仍存在的 PDD 商品详情 v2 规则。数据库迁移首次执行时,优先选择最近成功/部分成功任务使用的存活规则;没有成功历史时选择最近更新的存活 v2 规则;仍没有时保持未配置。
+- 创建任务时重新验证规则与设备能力,并把完整规则快照固化到任务;以后修改默认规则不影响已创建任务。
+
+### 创建并占用当前设备
+
+```http
+POST /api/agent/v1/current-page-collection-tasks
+Authorization: Bearer
+Content-Type: application/json
+
+{"requestId":""}
+```
+
+- 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
+Content-Type: application/json
+
+{"requestId":"","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 当前页面”,不新增任务状态。该来源任务不支持重置,失败后由用户从当前商品页重新发起新任务。
diff --git a/docs/rules/pdd-product-detail-v2.proposed.json b/docs/rules/pdd-product-detail-v2.proposed.json
index 1748b9e..ed2002c 100644
--- a/docs/rules/pdd-product-detail-v2.proposed.json
+++ b/docs/rules/pdd-product-detail-v2.proposed.json
@@ -53,6 +53,17 @@
"settleMs": 1000
}
},
+ "currentPageIdentity": {
+ "shareAliases": [
+ "分享"
+ ],
+ "copyLinkAliases": [
+ "复制链接"
+ ],
+ "pageTimeoutMs": 5000,
+ "sharePanelTimeoutMs": 5000,
+ "clipboardTimeoutMs": 5000
+ },
"hooks": {
"afterSpecPanelOpen": []
},
diff --git a/server/app/goauto/device/heartbeat_test.go b/server/app/goauto/device/heartbeat_test.go
index d12fc04..64a9a35 100644
--- a/server/app/goauto/device/heartbeat_test.go
+++ b/server/app/goauto/device/heartbeat_test.go
@@ -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,
}
}
diff --git a/server/app/goauto/migrations/migrate.go b/server/app/goauto/migrations/migrate.go
index 4041f4c..61c56d3 100644
--- a/server/app/goauto/migrations/migrate.go
+++ b/server/app/goauto/migrations/migrate.go
@@ -25,6 +25,7 @@ func MigratedModels() []any {
&models.PurchaseTask{},
&models.PurchaseTaskAttempt{},
&models.CollectionRule{},
+ &models.AgentManualCollectionSetting{},
&models.CollectionTask{},
&models.CollectionDimension{},
&models.CollectionDimensionValue{},
diff --git a/server/app/goauto/migrations/migrate_test.go b/server/app/goauto/migrations/migrate_test.go
index a9645f4..0ffc41a 100644
--- a/server/app/goauto/migrations/migrate_test.go
+++ b/server/app/goauto/migrations/migrate_test.go
@@ -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)
}
diff --git a/server/app/goauto/models/schema.go b/server/app/goauto/models/schema.go
index 2be6a85..41d930f 100644
--- a/server/app/goauto/models/schema.go
+++ b/server/app/goauto/models/schema.go
@@ -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.
diff --git a/server/app/goauto/product/service_test.go b/server/app/goauto/product/service_test.go
index 6ad3aa4..786af48 100644
--- a/server/app/goauto/product/service_test.go
+++ b/server/app/goauto/product/service_test.go
@@ -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 {
diff --git a/server/app/goauto/purchase/batch.go b/server/app/goauto/purchase/batch.go
index c5bea17..382d0e8 100644
--- a/server/app/goauto/purchase/batch.go
+++ b/server/app/goauto/purchase/batch.go
@@ -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
+ }
}
}
}
diff --git a/server/app/goauto/purchase/process_stage_test.go b/server/app/goauto/purchase/process_stage_test.go
index 2376cfd..70ca565 100644
--- a/server/app/goauto/purchase/process_stage_test.go
+++ b/server/app/goauto/purchase/process_stage_test.go
@@ -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 }},
diff --git a/server/app/goauto/rule/agent_manual_setting.go b/server/app/goauto/rule/agent_manual_setting.go
new file mode 100644
index 0000000..404087e
--- /dev/null
+++ b/server/app/goauto/rule/agent_manual_setting.go
@@ -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(¤t, 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
+}
diff --git a/server/app/goauto/rule/handler.go b/server/app/goauto/rule/handler.go
index 1f80d11..3308696 100644
--- a/server/app/goauto/rule/handler.go
+++ b/server/app/goauto/rule/handler.go
@@ -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 {
diff --git a/server/app/goauto/rule/router.go b/server/app/goauto/rule/router.go
index e23e865..fa6dd01 100644
--- a/server/app/goauto/rule/router.go
+++ b/server/app/goauto/rule/router.go
@@ -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)
diff --git a/server/app/goauto/rule/service_test.go b/server/app/goauto/rule/service_test.go
index 3df5003..61e9aca 100644
--- a/server/app/goauto/rule/service_test.go
+++ b/server/app/goauto/rule/service_test.go
@@ -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)
}
diff --git a/server/app/goauto/rulecontract/contract.go b/server/app/goauto/rulecontract/contract.go
index 52fdc60..dcd606c 100644
--- a/server/app/goauto/rulecontract/contract.go
+++ b/server/app/goauto/rulecontract/contract.go
@@ -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("规则整数配置缺失或包含未知字段")
diff --git a/server/app/goauto/rulecontract/contract_test.go b/server/app/goauto/rulecontract/contract_test.go
index c85e763..f463321 100644
--- a/server/app/goauto/rulecontract/contract_test.go
+++ b/server/app/goauto/rulecontract/contract_test.go
@@ -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)
+ }
+ }
+}
diff --git a/server/app/goauto/rulecontract/template.go b/server/app/goauto/rulecontract/template.go
index 25819b0..e910efa 100644
--- a/server/app/goauto/rulecontract/template.go
+++ b/server/app/goauto/rulecontract/template.go
@@ -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":{
diff --git a/server/app/goauto/task/admin_result_test.go b/server/app/goauto/task/admin_result_test.go
index f1f2ea8..a32566e 100644
--- a/server/app/goauto/task/admin_result_test.go
+++ b/server/app/goauto/task/admin_result_test.go
@@ -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 {
diff --git a/server/app/goauto/task/admin_service.go b/server/app/goauto/task/admin_service.go
index 539c7d6..83246b0 100644
--- a/server/app/goauto/task/admin_service.go
+++ b/server/app/goauto/task/admin_service.go
@@ -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,
}
diff --git a/server/app/goauto/task/agent_history.go b/server/app/goauto/task/agent_history.go
index f9ac7a7..8982b56 100644
--- a/server/app/goauto/task/agent_history.go
+++ b/server/app/goauto/task/agent_history.go
@@ -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,
}
diff --git a/server/app/goauto/task/current_page.go b/server/app/goauto/task/current_page.go
new file mode 100644
index 0000000..a5d6f49
--- /dev/null
+++ b/server/app/goauto/task/current_page.go
@@ -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
+}
diff --git a/server/app/goauto/task/current_page_test.go b/server/app/goauto/task/current_page_test.go
new file mode 100644
index 0000000..7fdf35c
--- /dev/null
+++ b/server/app/goauto/task/current_page_test.go
@@ -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)
+ }
+ }
+}
diff --git a/server/app/goauto/task/handler.go b/server/app/goauto/task/handler.go
index 85a476f..67e03e2 100644
--- a/server/app/goauto/task/handler.go
+++ b/server/app/goauto/task/handler.go
@@ -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})
diff --git a/server/app/goauto/task/lifecycle_service.go b/server/app/goauto/task/lifecycle_service.go
index d8297bb..32a0582 100644
--- a/server/app/goauto/task/lifecycle_service.go
+++ b/server/app/goauto/task/lifecycle_service.go
@@ -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, "执行中的任务不能重置")
}
diff --git a/server/app/goauto/task/result_service.go b/server/app/goauto/task/result_service.go
index f446a87..f8c96a6 100644
--- a/server/app/goauto/task/result_service.go
+++ b/server/app/goauto/task/result_service.go
@@ -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{
diff --git a/server/app/goauto/task/router.go b/server/app/goauto/task/router.go
index b146075..e719289 100644
--- a/server/app/goauto/task/router.go
+++ b/server/app/goauto/task/router.go
@@ -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)
diff --git a/server/app/goauto/task/service.go b/server/app/goauto/task/service.go
index e971da1..5729eb6 100644
--- a/server/app/goauto/task/service.go
+++ b/server/app/goauto/task/service.go
@@ -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,
diff --git a/server/app/goauto/task/service_test.go b/server/app/goauto/task/service_test.go
index cfefff5..b5214f8 100644
--- a/server/app/goauto/task/service_test.go
+++ b/server/app/goauto/task/service_test.go
@@ -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 {
diff --git a/server/cmd/migrate/migration/version-local/1787790000000_agent_current_page_collection.go b/server/cmd/migrate/migration/version-local/1787790000000_agent_current_page_collection.go
new file mode 100644
index 0000000..c3b258c
--- /dev/null
+++ b/server/cmd/migrate/migration/version-local/1787790000000_agent_current_page_collection.go
@@ -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
+}