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 +}