feat: collect current PDD page from Agent (#101)
This commit is contained in:
@@ -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"
|
||||
|
||||
|
||||
@@ -16,6 +16,14 @@
|
||||
android:label="@string/app_name"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.GoAutoAgent">
|
||||
<activity
|
||||
android:name=".ClipboardRelayActivity"
|
||||
android:excludeFromRecents="true"
|
||||
android:exported="false"
|
||||
android:finishOnTaskLaunch="true"
|
||||
android:noHistory="true"
|
||||
android:taskAffinity="cn.ilapage.goauto.agent.clipboard"
|
||||
android:theme="@style/Theme.GoAutoAgent.ClipboardRelay" />
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true">
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
package cn.ilapage.goauto.agent
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.ClipDescription
|
||||
import android.content.ClipboardManager
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.concurrent.atomic.AtomicReference
|
||||
|
||||
class ClipboardRelayActivity : Activity() {
|
||||
private var completed = false
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
window.setDimAmount(0f)
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
Handler(Looper.getMainLooper()).postDelayed(::readAndFinish, 120)
|
||||
}
|
||||
|
||||
private fun readAndFinish() {
|
||||
if (completed) return
|
||||
completed = true
|
||||
val requestId = intent.getStringExtra(EXTRA_REQUEST_ID).orEmpty()
|
||||
val minTimestamp = intent.getLongExtra(EXTRA_MIN_TIMESTAMP, 0L)
|
||||
val clipboard = getSystemService(ClipboardManager::class.java)
|
||||
val description = clipboard.primaryClipDescription
|
||||
val fresh = when {
|
||||
description == null -> false
|
||||
Build.VERSION.SDK_INT < Build.VERSION_CODES.O -> true
|
||||
description.timestamp <= 0L -> false
|
||||
else -> description.timestamp >= minTimestamp - TIMESTAMP_TOLERANCE_MILLIS
|
||||
}
|
||||
val text = if (fresh && description?.hasMimeType(ClipDescription.MIMETYPE_TEXT_PLAIN) == true) {
|
||||
clipboard.primaryClip?.takeIf { it.itemCount == 1 }?.getItemAt(0)?.text?.toString()?.takeIf { it.length <= MAX_CLIPBOARD_CHARS }
|
||||
} else null
|
||||
deliver(requestId, text)
|
||||
finishAndRemoveTask()
|
||||
overridePendingTransition(0, 0)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val EXTRA_REQUEST_ID = "clipboard_request_id"
|
||||
private const val EXTRA_MIN_TIMESTAMP = "clipboard_min_timestamp"
|
||||
private const val MAX_CLIPBOARD_CHARS = 4096
|
||||
private const val TIMESTAMP_TOLERANCE_MILLIS = 1_500L
|
||||
private val pending = ConcurrentHashMap<String, PendingRead>()
|
||||
|
||||
fun readFresh(context: Context, minTimestamp: Long, timeoutMillis: Long): CharSequence? {
|
||||
val requestId = UUID.randomUUID().toString()
|
||||
val request = PendingRead()
|
||||
pending[requestId] = request
|
||||
val started = runCatching {
|
||||
context.startActivity(Intent(context, ClipboardRelayActivity::class.java).apply {
|
||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_NO_ANIMATION or Intent.FLAG_ACTIVITY_EXCLUDE_FROM_RECENTS)
|
||||
putExtra(EXTRA_REQUEST_ID, requestId)
|
||||
putExtra(EXTRA_MIN_TIMESTAMP, minTimestamp)
|
||||
})
|
||||
true
|
||||
}.getOrDefault(false)
|
||||
if (!started) {
|
||||
pending.remove(requestId)
|
||||
return null
|
||||
}
|
||||
request.latch.await(timeoutMillis.coerceIn(500L, 10_000L), TimeUnit.MILLISECONDS)
|
||||
pending.remove(requestId)
|
||||
return request.value.get()
|
||||
}
|
||||
|
||||
private fun deliver(requestId: String, value: String?) {
|
||||
pending[requestId]?.let { request ->
|
||||
request.value.set(value)
|
||||
request.latch.countDown()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class PendingRead {
|
||||
val latch = CountDownLatch(1)
|
||||
val value = AtomicReference<String?>(null)
|
||||
}
|
||||
}
|
||||
+132
@@ -0,0 +1,132 @@
|
||||
package cn.ilapage.goauto.agent.automation
|
||||
|
||||
import java.net.URI
|
||||
|
||||
data class CurrentPageIdentityResult(
|
||||
val successful: Boolean,
|
||||
val code: String,
|
||||
val message: String,
|
||||
val shareUrl: String? = null,
|
||||
)
|
||||
|
||||
object PddShareLinkExtractor {
|
||||
private val urlPattern = Regex("https://[^\\s]+", RegexOption.IGNORE_CASE)
|
||||
private val trailingPunctuation = charArrayOf(')', ']', '}', ')', '】', '」', '』', '》', ',', ',', '.', '。', ';', ';')
|
||||
|
||||
fun extract(raw: CharSequence?): String? {
|
||||
val matches = urlPattern.findAll(raw?.toString().orEmpty()).map { it.value.trimEnd(*trailingPunctuation) }.toList()
|
||||
val accepted = matches.filter { value ->
|
||||
val uri = runCatching { URI(value) }.getOrNull() ?: return@filter false
|
||||
uri.scheme.equals("https", ignoreCase = true) && uri.host?.lowercase() in ALLOWED_HOSTS && uri.userInfo == null && uri.port in setOf(-1, 443)
|
||||
}.distinct()
|
||||
return accepted.singleOrNull()
|
||||
}
|
||||
|
||||
private val ALLOWED_HOSTS = setOf("p.pinduoduo.com", "mobile.yangkeduo.com")
|
||||
}
|
||||
|
||||
class CurrentPageIdentityRunner(
|
||||
private val driver: PddCollectorDriver,
|
||||
private val readFreshClipboard: (copiedAtMillis: Long, timeoutMillis: Long) -> CharSequence?,
|
||||
private val now: () -> Long = System::currentTimeMillis,
|
||||
private val pause: (Long) -> Unit = Thread::sleep,
|
||||
) {
|
||||
fun identify(rule: CollectionRule): CurrentPageIdentityResult {
|
||||
val evidence = rule.pageEvidence ?: return failure("RULE_INVALID", "规则缺少商品页证据")
|
||||
val config = rule.currentPageIdentity
|
||||
val detail = waitForPageEvidence(evidence, config.pageTimeoutMs)
|
||||
?: return failure("CURRENT_PDD_PAGE_NOT_FOUND", "没有找到商品详情页")
|
||||
detailProblem(detail)?.let { return failure(it.code, it.message) }
|
||||
val share = uniqueAction(detail, config.shareAliases, topOnly = true)
|
||||
?: return failure("PDD_SHARE_UNAVAILABLE", "没有找到商品分享入口")
|
||||
when (driver.clickFresh(share)) {
|
||||
FreshActionResult.SUCCESS -> Unit
|
||||
FreshActionResult.AMBIGUOUS -> return failure("PDD_SHARE_UNAVAILABLE", "商品分享入口不唯一")
|
||||
else -> return failure("PDD_SHARE_UNAVAILABLE", "无法打开商品分享")
|
||||
}
|
||||
try {
|
||||
val panel = waitForAction(config.copyLinkAliases, config.sharePanelTimeoutMs)
|
||||
?: return failure("PDD_COPY_LINK_UNAVAILABLE", "没有找到复制链接")
|
||||
val copy = uniqueAction(panel, config.copyLinkAliases, topOnly = false)
|
||||
?: return failure("PDD_COPY_LINK_UNAVAILABLE", "复制链接入口不唯一")
|
||||
val copiedAt = now()
|
||||
when (driver.clickFresh(copy)) {
|
||||
FreshActionResult.SUCCESS -> Unit
|
||||
FreshActionResult.AMBIGUOUS -> return failure("PDD_COPY_LINK_UNAVAILABLE", "复制链接入口不唯一")
|
||||
else -> return failure("PDD_COPY_LINK_UNAVAILABLE", "复制商品链接失败")
|
||||
}
|
||||
val raw = readFreshClipboard(copiedAt, config.clipboardTimeoutMs)
|
||||
?: return failure("PDD_CLIPBOARD_UNAVAILABLE", "无法读取刚复制的商品链接")
|
||||
val shareUrl = PddShareLinkExtractor.extract(raw)
|
||||
?: return failure("PDD_SHARE_LINK_INVALID", "无法识别商品链接")
|
||||
return CurrentPageIdentityResult(true, "OK", "已识别当前商品", shareUrl)
|
||||
} finally {
|
||||
closeSharePanel(config.copyLinkAliases)
|
||||
}
|
||||
}
|
||||
|
||||
private fun waitForPageEvidence(evidence: PageEvidence, timeoutMillis: Long): UiSnapshot? {
|
||||
val deadline = now() + timeoutMillis
|
||||
do {
|
||||
val snapshot = driver.capture()
|
||||
detailProblem(snapshot)?.let { return snapshot }
|
||||
if (matchesEvidence(snapshot, evidence)) return snapshot
|
||||
pause(POLL_MILLIS)
|
||||
} while (now() <= deadline)
|
||||
return null
|
||||
}
|
||||
|
||||
private fun waitForAction(aliases: List<String>, timeoutMillis: Long): UiSnapshot? {
|
||||
val deadline = now() + timeoutMillis
|
||||
do {
|
||||
val snapshot = driver.capture()
|
||||
if (uniqueAction(snapshot, aliases, topOnly = false) != null) return snapshot
|
||||
pause(POLL_MILLIS)
|
||||
} while (now() <= deadline)
|
||||
return null
|
||||
}
|
||||
|
||||
private fun matchesEvidence(snapshot: UiSnapshot, evidence: PageEvidence): Boolean =
|
||||
snapshot.packageName == evidence.packageName && snapshot.activityName == evidence.activityName && snapshot.nodes.any { node ->
|
||||
node.visible &&
|
||||
(evidence.selector.resourceId == null || node.resourceId == evidence.selector.resourceId) &&
|
||||
(evidence.selector.text == null || node.text == evidence.selector.text) &&
|
||||
(evidence.selector.contentDescription == null || node.contentDescription == evidence.selector.contentDescription) &&
|
||||
(evidence.selector.className == null || node.className == evidence.selector.className) &&
|
||||
(evidence.selector.clickable == null || node.clickable == evidence.selector.clickable)
|
||||
}
|
||||
|
||||
private fun uniqueAction(snapshot: UiSnapshot, aliases: List<String>, topOnly: Boolean): SnapshotNode? {
|
||||
val screenHeight = snapshot.nodes.maxOfOrNull { it.bounds.bottom } ?: 0
|
||||
val candidates = snapshot.nodes.filter { node ->
|
||||
node.visible && node.enabled && node.label in aliases && (!topOnly || screenHeight <= 0 || node.bounds.centerY <= screenHeight * 45 / 100)
|
||||
}.sortedWith(compareBy<SnapshotNode> { it.bounds.width.toLong() * it.bounds.height }.thenBy { it.path.length })
|
||||
val distinct = mutableListOf<SnapshotNode>()
|
||||
candidates.forEach { candidate ->
|
||||
if (distinct.none { existing ->
|
||||
existing.label == candidate.label &&
|
||||
kotlin.math.abs(existing.bounds.centerX - candidate.bounds.centerX) <= 16 &&
|
||||
kotlin.math.abs(existing.bounds.centerY - candidate.bounds.centerY) <= 16
|
||||
}
|
||||
) distinct += candidate
|
||||
}
|
||||
return distinct.singleOrNull()
|
||||
}
|
||||
|
||||
private fun detailProblem(snapshot: UiSnapshot): PageProblem? =
|
||||
PddPageClassifier.classify(snapshot.packageName, snapshot.activityName, snapshot.nodes.filter { it.visible }.map(SnapshotNode::label))
|
||||
|
||||
private fun closeSharePanel(copyAliases: List<String>) {
|
||||
val snapshot = driver.capture()
|
||||
if (snapshot.packageName == PDD_PACKAGE && snapshot.nodes.any { it.visible && it.label in copyAliases }) {
|
||||
driver.back()
|
||||
}
|
||||
}
|
||||
|
||||
private fun failure(code: String, message: String) = CurrentPageIdentityResult(false, code, message)
|
||||
|
||||
private companion object {
|
||||
const val POLL_MILLIS = 100L
|
||||
const val PDD_PACKAGE = "com.xunmeng.pinduoduo"
|
||||
}
|
||||
}
|
||||
+21
@@ -12,6 +12,7 @@ import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.os.SystemClock
|
||||
import android.util.Log
|
||||
import android.view.accessibility.AccessibilityEvent
|
||||
import android.view.accessibility.AccessibilityNodeInfo
|
||||
@@ -23,6 +24,7 @@ import java.util.concurrent.atomic.AtomicLong
|
||||
class GoAutoAccessibilityService : AccessibilityService(), UiDriver, PddCollectorDriver, PurchaseUiDriver {
|
||||
private val foregroundRevision = AtomicLong(0L)
|
||||
@Volatile private var lastForegroundPackage: String? = null
|
||||
private val lastPddForegroundAt = AtomicLong(0L)
|
||||
private val activityTracker by lazy {
|
||||
ActivityEvidenceTracker { packageName, className -> isDeclaredActivity(packageName, className) }
|
||||
}
|
||||
@@ -48,6 +50,7 @@ class GoAutoAccessibilityService : AccessibilityService(), UiDriver, PddCollecto
|
||||
lastForegroundPackage = packageName
|
||||
foregroundRevision.incrementAndGet()
|
||||
}
|
||||
if (packageName == PDD_PACKAGE) lastPddForegroundAt.set(SystemClock.elapsedRealtime())
|
||||
activityTracker.observe(packageName, event.className?.toString())
|
||||
}
|
||||
}
|
||||
@@ -91,6 +94,23 @@ class GoAutoAccessibilityService : AccessibilityService(), UiDriver, PddCollecto
|
||||
|
||||
fun currentForegroundRevision(): Long = foregroundRevision.get()
|
||||
|
||||
fun hasRecentPddForeground(maxAgeMillis: Long = 120_000L): Boolean {
|
||||
val seenAt = lastPddForegroundAt.get()
|
||||
return seenAt > 0L && SystemClock.elapsedRealtime() - seenAt in 0..maxAgeMillis
|
||||
}
|
||||
|
||||
fun restoreRecentPddPage(timeoutMillis: Long = 5_000L): Boolean {
|
||||
if (currentPackage() == PDD_PACKAGE) return true
|
||||
if (currentPackage() != packageName || !hasRecentPddForeground()) return false
|
||||
if (!performGlobalAction(GLOBAL_ACTION_BACK)) return false
|
||||
val deadline = SystemClock.elapsedRealtime() + timeoutMillis
|
||||
do {
|
||||
if (currentPackage() == PDD_PACKAGE) return true
|
||||
Thread.sleep(100)
|
||||
} while (SystemClock.elapsedRealtime() <= deadline)
|
||||
return false
|
||||
}
|
||||
|
||||
fun openAgentStatus(): Boolean = runCatching {
|
||||
startActivity(
|
||||
android.content.Intent(this, cn.ilapage.goauto.agent.MainActivity::class.java).apply {
|
||||
@@ -477,6 +497,7 @@ class GoAutoAccessibilityService : AccessibilityService(), UiDriver, PddCollecto
|
||||
}.getOrDefault(false)
|
||||
|
||||
companion object {
|
||||
private const val PDD_PACKAGE = "com.xunmeng.pinduoduo"
|
||||
@Volatile
|
||||
var instance: GoAutoAccessibilityService? = null
|
||||
private set
|
||||
|
||||
@@ -74,6 +74,14 @@ data class ReopenBrowserRecovery(
|
||||
val settleMs: Long,
|
||||
)
|
||||
|
||||
data class CurrentPageIdentityConfig(
|
||||
val shareAliases: List<String> = listOf("分享"),
|
||||
val copyLinkAliases: List<String> = listOf("复制链接"),
|
||||
val pageTimeoutMs: Long = 5_000,
|
||||
val sharePanelTimeoutMs: Long = 5_000,
|
||||
val clipboardTimeoutMs: Long = 5_000,
|
||||
)
|
||||
|
||||
data class CollectionRule(
|
||||
val schemaVersion: Int,
|
||||
val steps: List<RuleStep>,
|
||||
@@ -83,6 +91,7 @@ data class CollectionRule(
|
||||
val collector: PddCollectorConfig? = null,
|
||||
val transientSoldOutRecovery: TransientSoldOutRecovery? = null,
|
||||
val reopenBrowserRecovery: ReopenBrowserRecovery? = null,
|
||||
val currentPageIdentity: CurrentPageIdentityConfig = CurrentPageIdentityConfig(),
|
||||
)
|
||||
|
||||
class RuleValidationException(val code: String, message: String) : IllegalArgumentException(message)
|
||||
@@ -91,11 +100,13 @@ object AgentCapabilities {
|
||||
const val SCHEMA_V2 = "rule.schema.v2"
|
||||
const val SWIPE_V1 = "action.swipe.v1"
|
||||
const val PDD_PRODUCT_DETAIL_V1 = "collector.pdd.product-detail.v1"
|
||||
const val PDD_CURRENT_PAGE_SHARE_V1 = "collector.pdd.current-page-share.v1"
|
||||
|
||||
val supported: List<String> = listOf(
|
||||
SCHEMA_V2,
|
||||
SWIPE_V1,
|
||||
PDD_PRODUCT_DETAIL_V1,
|
||||
PDD_CURRENT_PAGE_SHARE_V1,
|
||||
PurchaseAgentCapabilities.REHEARSAL_V1,
|
||||
PurchaseAgentCapabilities.LIVE_V1,
|
||||
PurchaseAgentCapabilities.SPEC_PROBE_V1,
|
||||
@@ -135,7 +146,7 @@ object RuleParser {
|
||||
}
|
||||
|
||||
private fun parseV2(root: JSONObject): CollectionRule {
|
||||
rejectUnknown(root, setOf("schemaVersion", "ruleType", "navigation", "pageEvidence", "hooks", "navigationRecovery", "pageRecovery", "collector"), "v2 规则")
|
||||
rejectUnknown(root, setOf("schemaVersion", "ruleType", "navigation", "pageEvidence", "hooks", "navigationRecovery", "pageRecovery", "currentPageIdentity", "collector"), "v2 规则")
|
||||
if (root.optString("ruleType") != "pddProductDetail") {
|
||||
invalid("v2 规则必须声明 ruleType=pddProductDetail")
|
||||
}
|
||||
@@ -155,8 +166,31 @@ object RuleParser {
|
||||
val hooks = parseHooks(root.optJSONObject("hooks"))
|
||||
val navigationRecovery = parseNavigationRecovery(root.optJSONObject("navigationRecovery"))
|
||||
val recovery = parsePageRecovery(root.optJSONObject("pageRecovery"))
|
||||
val currentPageIdentity = parseCurrentPageIdentity(root.optJSONObject("currentPageIdentity"))
|
||||
val collector = parseCollector(root.optJSONObject("collector") ?: invalid("collector 必填"))
|
||||
return CollectionRule(2, steps, "pddProductDetail", evidence, hooks, collector, recovery, navigationRecovery)
|
||||
return CollectionRule(2, steps, "pddProductDetail", evidence, hooks, collector, recovery, navigationRecovery, currentPageIdentity)
|
||||
}
|
||||
|
||||
private fun parseCurrentPageIdentity(value: JSONObject?): CurrentPageIdentityConfig {
|
||||
if (value == null) return CurrentPageIdentityConfig()
|
||||
rejectUnknown(value, setOf("shareAliases", "copyLinkAliases", "pageTimeoutMs", "sharePanelTimeoutMs", "clipboardTimeoutMs"), "currentPageIdentity")
|
||||
return CurrentPageIdentityConfig(
|
||||
shareAliases = parseIdentityAliases(value.optJSONArray("shareAliases"), "shareAliases"),
|
||||
copyLinkAliases = parseIdentityAliases(value.optJSONArray("copyLinkAliases"), "copyLinkAliases"),
|
||||
pageTimeoutMs = value.optLong("pageTimeoutMs").also { if (it !in 500..15_000) invalid("currentPageIdentity.pageTimeoutMs 必须为 500..15000") },
|
||||
sharePanelTimeoutMs = value.optLong("sharePanelTimeoutMs").also { if (it !in 500..10_000) invalid("currentPageIdentity.sharePanelTimeoutMs 必须为 500..10000") },
|
||||
clipboardTimeoutMs = value.optLong("clipboardTimeoutMs").also { if (it !in 500..10_000) invalid("currentPageIdentity.clipboardTimeoutMs 必须为 500..10000") },
|
||||
)
|
||||
}
|
||||
|
||||
private fun parseIdentityAliases(items: JSONArray?, label: String): List<String> {
|
||||
items ?: invalid("currentPageIdentity.$label 必填")
|
||||
if (items.length() !in 1..8) invalid("currentPageIdentity.$label 必须包含 1..8 项")
|
||||
val values = (0 until items.length()).map { index ->
|
||||
items.optString(index).trim().also { if (it.isEmpty() || it.length > 20) invalid("currentPageIdentity.$label 含无效文字") }
|
||||
}
|
||||
if (values.distinct().size != values.size) invalid("currentPageIdentity.$label 不能重复")
|
||||
return values
|
||||
}
|
||||
|
||||
private fun parseNavigationRecovery(value: JSONObject?): ReopenBrowserRecovery? {
|
||||
|
||||
@@ -34,7 +34,7 @@ data class HeartbeatResult(
|
||||
|
||||
data class AgentTask(
|
||||
val taskId: Long,
|
||||
val pddProductId: Long,
|
||||
val pddProductId: Long?,
|
||||
val urlSnapshot: String,
|
||||
val goodsIdSnapshot: String,
|
||||
val ruleId: Long,
|
||||
@@ -42,6 +42,15 @@ data class AgentTask(
|
||||
val timeoutSeconds: Int,
|
||||
val leaseVersion: Long,
|
||||
val status: String,
|
||||
val source: String,
|
||||
)
|
||||
|
||||
data class CurrentPageIdentity(
|
||||
val taskId: Long,
|
||||
val pddProductId: Long,
|
||||
val goodsId: String,
|
||||
val url: String,
|
||||
val replayed: Boolean,
|
||||
)
|
||||
|
||||
data class PurchaseAgentTask(
|
||||
@@ -68,6 +77,7 @@ data class PurchaseAgentTask(
|
||||
data class CollectionHistoryItem(
|
||||
val taskId: Long,
|
||||
val status: String,
|
||||
val source: String,
|
||||
val goodsId: String,
|
||||
val title: String?,
|
||||
val missingCount: Int,
|
||||
@@ -174,6 +184,20 @@ class AgentApiClient(private val serverUrl: String) {
|
||||
return task(response.getJSONObject("data"))
|
||||
}
|
||||
|
||||
fun createCurrentPageCollectionTask(requestId: String, token: String): AgentTask {
|
||||
val payload = JSONObject().put("requestId", requestId)
|
||||
return task(requireNotNull(request("POST", "/api/agent/v1/current-page-collection-tasks", payload, token)).getJSONObject("data"))
|
||||
}
|
||||
|
||||
fun identifyCurrentPageCollectionTask(taskId: Long, requestId: String, shareUrl: String, token: String): CurrentPageIdentity {
|
||||
val payload = JSONObject().put("requestId", requestId).put("shareUrl", shareUrl)
|
||||
val data = requireNotNull(request("POST", "/api/agent/v1/current-page-collection-tasks/$taskId/identify", payload, token)).getJSONObject("data")
|
||||
return CurrentPageIdentity(
|
||||
taskId = data.getLong("taskId"), pddProductId = data.getLong("pddProductId"),
|
||||
goodsId = data.getString("goodsId"), url = data.getString("url"), replayed = data.optBoolean("replayed"),
|
||||
)
|
||||
}
|
||||
|
||||
fun claimTask(taskId: Long, requestId: String, token: String): AgentTask {
|
||||
val payload = JSONObject().put("requestId", requestId)
|
||||
return task(requireNotNull(request("POST", "/api/agent/v1/tasks/$taskId/claim", payload, token)).getJSONObject("data"))
|
||||
@@ -291,7 +315,7 @@ class AgentApiClient(private val serverUrl: String) {
|
||||
}
|
||||
|
||||
private fun collectionHistoryItem(data: JSONObject) = CollectionHistoryItem(
|
||||
taskId = data.getLong("taskId"), status = data.getString("status"), goodsId = data.getString("goodsId"),
|
||||
taskId = data.getLong("taskId"), status = data.getString("status"), source = data.optString("source", "admin"), goodsId = data.getString("goodsId"),
|
||||
title = data.nullableString("title"), missingCount = data.optInt("missingCount"),
|
||||
errorCode = data.nullableString("errorCode"), errorMessage = data.nullableString("errorMessage"),
|
||||
finishedAt = data.nullableString("finishedAt"), createdAt = data.getString("createdAt"),
|
||||
@@ -312,7 +336,7 @@ class AgentApiClient(private val serverUrl: String) {
|
||||
|
||||
private fun task(data: JSONObject) = AgentTask(
|
||||
taskId = data.getLong("taskId"),
|
||||
pddProductId = data.getLong("pddProductId"),
|
||||
pddProductId = data.nullableLong("pddProductId"),
|
||||
urlSnapshot = data.getString("urlSnapshot"),
|
||||
goodsIdSnapshot = data.getString("goodsIdSnapshot"),
|
||||
ruleId = data.getLong("ruleId"),
|
||||
@@ -320,6 +344,7 @@ class AgentApiClient(private val serverUrl: String) {
|
||||
timeoutSeconds = data.optInt("timeoutSeconds", 120),
|
||||
leaseVersion = data.getLong("leaseVersion"),
|
||||
status = data.getString("status"),
|
||||
source = data.optString("source", "admin"),
|
||||
)
|
||||
|
||||
private fun purchaseTask(data: JSONObject) = PurchaseAgentTask(
|
||||
|
||||
@@ -13,7 +13,7 @@ class TaskHistoryCache(context: Context) {
|
||||
|
||||
fun saveCollection(days: Int, items: List<CollectionHistoryItem>) = save(COLLECTION, days, JSONArray().apply {
|
||||
items.forEach { item -> put(JSONObject()
|
||||
.put("taskId", item.taskId).put("status", item.status).put("goodsId", item.goodsId)
|
||||
.put("taskId", item.taskId).put("status", item.status).put("source", item.source).put("goodsId", item.goodsId)
|
||||
.putNullable("title", item.title).put("missingCount", item.missingCount)
|
||||
.putNullable("errorCode", item.errorCode).putNullable("errorMessage", item.errorMessage)
|
||||
.putNullable("finishedAt", item.finishedAt).put("createdAt", item.createdAt)) }
|
||||
@@ -33,9 +33,16 @@ class TaskHistoryCache(context: Context) {
|
||||
|
||||
fun collection(days: Int, page: Int, status: String?, taskNo: String?): HistoryPage<CollectionHistoryItem>? {
|
||||
val values = read(COLLECTION, days)?.map { data -> CollectionHistoryItem(
|
||||
data.getLong("taskId"), data.getString("status"), data.getString("goodsId"), data.nullableString("title"),
|
||||
data.optInt("missingCount"), data.nullableString("errorCode"), data.nullableString("errorMessage"),
|
||||
data.nullableString("finishedAt"), data.getString("createdAt"),
|
||||
taskId = data.getLong("taskId"),
|
||||
status = data.getString("status"),
|
||||
source = data.optString("source", "admin"),
|
||||
goodsId = data.optString("goodsId"),
|
||||
title = data.nullableString("title"),
|
||||
missingCount = data.optInt("missingCount"),
|
||||
errorCode = data.nullableString("errorCode"),
|
||||
errorMessage = data.nullableString("errorMessage"),
|
||||
finishedAt = data.nullableString("finishedAt"),
|
||||
createdAt = data.getString("createdAt"),
|
||||
) } ?: return null
|
||||
return page(values.filter { status.isNullOrBlank() || it.status == status }.filterTask(taskNo) { it.taskId }, page)
|
||||
}
|
||||
|
||||
+140
-1
@@ -18,10 +18,12 @@ import android.os.PowerManager
|
||||
import android.os.SystemClock
|
||||
import android.util.Log
|
||||
import cn.ilapage.goauto.agent.BuildConfig
|
||||
import cn.ilapage.goauto.agent.ClipboardRelayActivity
|
||||
import cn.ilapage.goauto.agent.MainActivity
|
||||
import cn.ilapage.goauto.agent.R
|
||||
import cn.ilapage.goauto.agent.automation.CollectionAssembler
|
||||
import cn.ilapage.goauto.agent.automation.AgentCapabilities
|
||||
import cn.ilapage.goauto.agent.automation.CurrentPageIdentityRunner
|
||||
import cn.ilapage.goauto.agent.automation.GoAutoAccessibilityService
|
||||
import cn.ilapage.goauto.agent.automation.PddLinkLauncher
|
||||
import cn.ilapage.goauto.agent.automation.PddDetailEntryRunner
|
||||
@@ -108,6 +110,10 @@ class AgentForegroundService : Service() {
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
if (intent?.action == ACTION_RECONNECT) registeredThisProcess.set(false)
|
||||
if (intent?.action == ACTION_CHECK_NOW) manualCheckRequested.set(true)
|
||||
if (intent?.action == ACTION_CURRENT_PAGE_COLLECTION) {
|
||||
requestCurrentPageCollection(intent.getStringExtra(EXTRA_CURRENT_PAGE_REQUEST_ID).orEmpty())
|
||||
return START_STICKY
|
||||
}
|
||||
triggerSync()
|
||||
return START_STICKY
|
||||
}
|
||||
@@ -261,6 +267,91 @@ class AgentForegroundService : Service() {
|
||||
})
|
||||
}
|
||||
|
||||
private fun requestCurrentPageCollection(requestId: String) {
|
||||
if (requestId.isBlank()) {
|
||||
publishCurrentPageResult(0L, "采集请求无效,请重试。")
|
||||
return
|
||||
}
|
||||
activeCollectionCooldown()?.let { ticket ->
|
||||
val remaining = CollectionCooldownPolicy.remainingSeconds(System.currentTimeMillis(), ticket)
|
||||
publishCurrentPageResult(0L, "采集间隔中,还需 $remaining 秒。")
|
||||
return
|
||||
}
|
||||
if (!taskMutex.tryAcquire(CURRENT_PAGE_RESERVATION_ID)) {
|
||||
publishCurrentPageResult(0L, "设备正在执行任务,请稍后再试。")
|
||||
return
|
||||
}
|
||||
cancelIdleReturn("正在创建当前页面采集任务")
|
||||
taskExecutor.execute {
|
||||
var taskId = 0L
|
||||
try {
|
||||
val serverUrl = ServerUrlPolicy.normalize(settingsStore.serverUrl(), BuildConfig.DEBUG)
|
||||
val credentials = identityStore.credentials()
|
||||
?: throw TaskFailure("DEVICE_NOT_CONNECTED", "设备尚未连接服务端,请先检查设置")
|
||||
val accessibility = GoAutoAccessibilityService.instance
|
||||
?: throw TaskFailure("ACCESSIBILITY_NOT_READY", "请先到“设置”开启采集采购助手")
|
||||
if (!accessibility.hasRecentPddForeground()) {
|
||||
throw TaskFailure("CURRENT_PDD_PAGE_NOT_FOUND", "请先在拼多多打开目标商品详情页")
|
||||
}
|
||||
val api = AgentApiClient(serverUrl)
|
||||
val task = api.createCurrentPageCollectionTask(requestId, credentials.token)
|
||||
taskId = task.taskId
|
||||
check(taskMutex.transfer(CURRENT_PAGE_RESERVATION_ID, task.taskId)) {
|
||||
"当前页面采集本地占用状态不一致"
|
||||
}
|
||||
runningTaskId.set(task.taskId)
|
||||
stateStore.setActiveTask(task.taskId, "collection")
|
||||
executeTask(api, task, credentials.token)
|
||||
publishCurrentPageResult(task.taskId, "临时采集任务 #${task.taskId} 已结束,请查看采集记录。")
|
||||
} catch (error: TaskFailure) {
|
||||
publishCurrentPageResult(taskId, currentPageUserMessage(error.code, error.message ?: "采集失败"))
|
||||
} catch (error: AgentApiException) {
|
||||
publishCurrentPageResult(taskId, currentPageUserMessage(error.code, error.message))
|
||||
} catch (error: Exception) {
|
||||
if (taskId > 0L) {
|
||||
val token = runCatching { identityStore.credentials()?.token }.getOrNull()
|
||||
if (token != null) {
|
||||
runCatching {
|
||||
failSafely(
|
||||
AgentApiClient(ServerUrlPolicy.normalize(settingsStore.serverUrl(), BuildConfig.DEBUG)),
|
||||
taskId,
|
||||
token,
|
||||
"AGENT_EXECUTION_ERROR",
|
||||
error.message ?: "Android 执行异常",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
publishCurrentPageResult(taskId, "采集失败,请查看任务详情。")
|
||||
} finally {
|
||||
if (taskId > 0L) {
|
||||
runningTaskId.compareAndSet(taskId, null)
|
||||
stateStore.clearActiveTask(taskId)
|
||||
taskMutex.release(taskId)
|
||||
}
|
||||
taskMutex.release(CURRENT_PAGE_RESERVATION_ID)
|
||||
triggerSync()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun publishCurrentPageResult(taskId: Long, message: String) {
|
||||
sendBroadcast(Intent(ACTION_CURRENT_PAGE_RESULT).apply {
|
||||
setPackage(packageName)
|
||||
putExtra(EXTRA_CURRENT_PAGE_TASK_ID, taskId)
|
||||
putExtra(EXTRA_CURRENT_PAGE_MESSAGE, message)
|
||||
})
|
||||
}
|
||||
|
||||
private fun currentPageUserMessage(code: String, fallback: String): String = when (code) {
|
||||
"DEVICE_BUSY" -> "设备正在执行任务,请稍后再试。"
|
||||
"AGENT_MANUAL_RULE_NOT_CONFIGURED" -> "请先配置 Agent 手动采集规则。"
|
||||
"CURRENT_PDD_PAGE_NOT_FOUND" -> "没有找到商品详情页,请重新打开商品后再试。"
|
||||
"PDD_SHARE_UNAVAILABLE", "PDD_COPY_LINK_UNAVAILABLE", "PDD_CLIPBOARD_UNAVAILABLE", "PDD_SHARE_LINK_INVALID" ->
|
||||
"无法识别商品链接,请确认商品页可以分享后再试。"
|
||||
else -> fallback.ifBlank { "采集失败,请稍后重试。" }
|
||||
}
|
||||
|
||||
private fun schedulePurchaseTask(api: AgentApiClient, task: PurchaseAgentTask, token: String) {
|
||||
if (!taskMutex.tryAcquire(task.taskId)) return
|
||||
runningTaskId.set(task.taskId)
|
||||
@@ -489,7 +580,37 @@ class AgentForegroundService : Service() {
|
||||
val rule = try { RuleParser.parse(task.ruleSnapshot) } catch (error: RuleValidationException) {
|
||||
throw TaskFailure(error.code, error.message ?: "规则快照无效")
|
||||
}
|
||||
val result = if (rule.schemaVersion == 2) {
|
||||
val result = if (task.source == "agent_current_page") {
|
||||
if (rule.schemaVersion != 2) {
|
||||
throw TaskFailure("RULE_INVALID", "当前页面采集只支持新版采集规则")
|
||||
}
|
||||
if (!accessibility.restoreRecentPddPage()) {
|
||||
throw TaskFailure("CURRENT_PDD_PAGE_NOT_FOUND", "没有找到商品详情页")
|
||||
}
|
||||
val identityResult = CurrentPageIdentityRunner(
|
||||
driver = accessibility,
|
||||
readFreshClipboard = { copiedAt, timeout ->
|
||||
ClipboardRelayActivity.readFresh(this, copiedAt, timeout)
|
||||
},
|
||||
).identify(rule)
|
||||
if (!identityResult.successful) {
|
||||
throw TaskFailure(identityResult.code, identityResult.message)
|
||||
}
|
||||
val identity = try {
|
||||
api.identifyCurrentPageCollectionTask(
|
||||
task.taskId,
|
||||
UUID.randomUUID().toString(),
|
||||
requireNotNull(identityResult.shareUrl),
|
||||
token,
|
||||
)
|
||||
} catch (error: AgentApiException) {
|
||||
throw TaskFailure(error.code, error.message)
|
||||
}
|
||||
val trace: (String) -> Unit = { message -> Log.i("GoAutoCollector", message) }
|
||||
val collection = PddProductDetailCollector(accessibility, trace = trace).collect(identity.goodsId, rule)
|
||||
if (!collection.successful) throw TaskFailure(collection.code, collection.message)
|
||||
requireNotNull(collection.payload)
|
||||
} else if (rule.schemaVersion == 2) {
|
||||
val trace: (String) -> Unit = { message -> Log.i("GoAutoCollector", message) }
|
||||
val collection = PddDetailEntryRunner(
|
||||
openLink = { PddLinkLauncher(this).open(task.urlSnapshot) },
|
||||
@@ -800,7 +921,12 @@ class AgentForegroundService : Service() {
|
||||
const val ACTION_RECONNECT = "cn.ilapage.goauto.agent.RECONNECT"
|
||||
const val ACTION_CHECK_NOW = "cn.ilapage.goauto.agent.CHECK_NOW"
|
||||
const val ACTION_CHECK_RESULT = "cn.ilapage.goauto.agent.CHECK_RESULT"
|
||||
const val ACTION_CURRENT_PAGE_COLLECTION = "cn.ilapage.goauto.agent.CURRENT_PAGE_COLLECTION"
|
||||
const val ACTION_CURRENT_PAGE_RESULT = "cn.ilapage.goauto.agent.CURRENT_PAGE_RESULT"
|
||||
const val EXTRA_CHECK_RESULT = "check_result"
|
||||
const val EXTRA_CURRENT_PAGE_REQUEST_ID = "current_page_request_id"
|
||||
const val EXTRA_CURRENT_PAGE_TASK_ID = "current_page_task_id"
|
||||
const val EXTRA_CURRENT_PAGE_MESSAGE = "current_page_message"
|
||||
const val MANUAL_EMPTY = "empty"
|
||||
const val MANUAL_COLLECTION_TASK = "collection_task"
|
||||
const val MANUAL_PURCHASE_TASK = "purchase_task"
|
||||
@@ -818,6 +944,7 @@ class AgentForegroundService : Service() {
|
||||
private const val COLLECTION_COOLDOWN_MAX_MILLIS = CollectionIntervalPolicy.MAX_SECONDS * 1_000L
|
||||
private const val COOLDOWN_WAKE_GRACE_MILLIS = 5_000L
|
||||
private const val RETURN_CONFIRM_DELAY_MILLIS = 750L
|
||||
private const val CURRENT_PAGE_RESERVATION_ID = Long.MAX_VALUE
|
||||
|
||||
fun start(context: Context, reconnect: Boolean = false) {
|
||||
val intent = Intent(context, AgentForegroundService::class.java).apply {
|
||||
@@ -840,6 +967,18 @@ class AgentForegroundService : Service() {
|
||||
context.startService(intent)
|
||||
}
|
||||
}
|
||||
|
||||
fun collectCurrentPage(context: Context, requestId: String) {
|
||||
val intent = Intent(context, AgentForegroundService::class.java).apply {
|
||||
action = ACTION_CURRENT_PAGE_COLLECTION
|
||||
putExtra(EXTRA_CURRENT_PAGE_REQUEST_ID, requestId)
|
||||
}
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
context.startForegroundService(intent)
|
||||
} else {
|
||||
context.startService(intent)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,17 +1,27 @@
|
||||
package cn.ilapage.goauto.agent.service
|
||||
|
||||
import java.util.concurrent.atomic.AtomicReference
|
||||
import java.util.concurrent.atomic.AtomicLong
|
||||
|
||||
/** Process-local guard that prevents two collection tasks from executing together. */
|
||||
class TaskExecutionMutex {
|
||||
private val activeTask = AtomicReference<Long?>(null)
|
||||
private val activeTask = AtomicLong(NO_TASK)
|
||||
|
||||
fun tryAcquire(taskId: Long): Boolean {
|
||||
require(taskId > 0L) { "taskId must be positive" }
|
||||
return activeTask.compareAndSet(null, taskId) || activeTask.get() == taskId
|
||||
return activeTask.compareAndSet(NO_TASK, taskId) || activeTask.get() == taskId
|
||||
}
|
||||
|
||||
fun currentTaskId(): Long? = activeTask.get()
|
||||
fun currentTaskId(): Long? = activeTask.get().takeUnless { it == NO_TASK }
|
||||
|
||||
fun release(taskId: Long): Boolean = activeTask.compareAndSet(taskId, null)
|
||||
fun transfer(fromTaskId: Long, toTaskId: Long): Boolean {
|
||||
require(fromTaskId > 0L) { "fromTaskId must be positive" }
|
||||
require(toTaskId > 0L) { "toTaskId must be positive" }
|
||||
return activeTask.compareAndSet(fromTaskId, toTaskId)
|
||||
}
|
||||
|
||||
fun release(taskId: Long): Boolean = activeTask.compareAndSet(taskId, NO_TASK)
|
||||
|
||||
private companion object {
|
||||
const val NO_TASK = 0L
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
package cn.ilapage.goauto.agent.ui
|
||||
|
||||
import android.content.res.ColorStateList
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.text.Editable
|
||||
import android.text.TextWatcher
|
||||
@@ -18,6 +23,7 @@ import androidx.fragment.app.Fragment
|
||||
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
|
||||
import cn.ilapage.goauto.agent.R
|
||||
import cn.ilapage.goauto.agent.identity.SecureDeviceStore
|
||||
import cn.ilapage.goauto.agent.automation.GoAutoAccessibilityService
|
||||
import cn.ilapage.goauto.agent.network.AgentApiClient
|
||||
import cn.ilapage.goauto.agent.network.CollectionHistoryDetail
|
||||
import cn.ilapage.goauto.agent.network.CollectionHistoryItem
|
||||
@@ -59,9 +65,9 @@ internal object TaskSearchInputPolicy {
|
||||
internal object CollectionResetPolicy {
|
||||
private val terminalStatuses = setOf("completed", "completed_partial", "failed")
|
||||
|
||||
fun isAllowed(status: String): Boolean = status in terminalStatuses
|
||||
fun isAllowed(status: String, source: String): Boolean = status in terminalStatuses && source != "agent_current_page"
|
||||
|
||||
fun showsListAction(status: String): Boolean = status == "failed"
|
||||
fun showsListAction(status: String, source: String): Boolean = status == "failed" && source != "agent_current_page"
|
||||
|
||||
fun confirmationMessage(status: String): String = if (status == "failed") {
|
||||
"重新采集会清除本任务现有的错误和采集结果,然后重新加入当前设备的任务队列。"
|
||||
@@ -89,6 +95,21 @@ class TaskHistoryFragment : Fragment() {
|
||||
private var requestGeneration = 0
|
||||
private var loading = false
|
||||
private var pageSignature: String? = null
|
||||
private var currentPageReceiverRegistered = false
|
||||
private val currentPageReceiver = object : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context?, intent: Intent?) {
|
||||
if (intent?.action != AgentForegroundService.ACTION_CURRENT_PAGE_RESULT || !collection) return
|
||||
val message = intent.getStringExtra(AgentForegroundService.EXTRA_CURRENT_PAGE_MESSAGE).orEmpty()
|
||||
val taskId = intent.getLongExtra(AgentForegroundService.EXTRA_CURRENT_PAGE_TASK_ID, 0L)
|
||||
if (message.isNotBlank()) toast(message)
|
||||
if (taskId > 0L && isAdded) loadCollectionDetail(taskId) else if (isAdded) load()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreate(state: Bundle?) {
|
||||
super.onCreate(state)
|
||||
if (collection) registerCurrentPageReceiver()
|
||||
}
|
||||
|
||||
override fun onCreateView(inflater: android.view.LayoutInflater, container: ViewGroup?, state: Bundle?): View {
|
||||
val context = requireContext()
|
||||
@@ -122,6 +143,11 @@ class TaskHistoryFragment : Fragment() {
|
||||
super.onDestroyView()
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
unregisterCurrentPageReceiver()
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
private fun buildSearch(): View {
|
||||
val context = requireContext()
|
||||
return context.card(context.cardColumn().apply {
|
||||
@@ -169,6 +195,15 @@ class TaskHistoryFragment : Fragment() {
|
||||
minimumHeight = context.dp(48)
|
||||
setOnClickListener { search() }
|
||||
}, LinearLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, context.dp(48)).apply { marginStart = context.dp(8) })
|
||||
if (collection) {
|
||||
row.addView(MaterialButton(context).apply {
|
||||
text = "采集"
|
||||
textSize = 14f
|
||||
minimumHeight = context.dp(48)
|
||||
contentDescription = "采集当前拼多多商品"
|
||||
setOnClickListener { confirmCurrentPageCollection() }
|
||||
}, LinearLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, context.dp(48)).apply { marginStart = context.dp(8) })
|
||||
}
|
||||
addView(row, row.fullWidth())
|
||||
}).apply {
|
||||
(layoutParams as? LinearLayout.LayoutParams)?.bottomMargin = context.dp(8)
|
||||
@@ -319,10 +354,11 @@ class TaskHistoryFragment : Fragment() {
|
||||
else -> item.title ?: "未记录商品标题"
|
||||
}
|
||||
return context.card(context.cardColumn().apply {
|
||||
addView(context.label("#${item.taskId} · ${item.goodsId}", 17f, context.getColor(R.color.agent_text), true))
|
||||
val goodsLabel = item.goodsId.ifBlank { "未识别商品" }
|
||||
addView(context.label("#${item.taskId} · $goodsLabel", 17f, context.getColor(R.color.agent_text), true))
|
||||
addView(context.label("$status · ${formatTime(item.finishedAt ?: item.createdAt)}", 13f, statusColor(item.status)).apply { setPadding(0, context.dp(4), 0, 0) })
|
||||
addView(context.label(summary, 14f, context.getColor(R.color.agent_text_muted)).apply { setPadding(0, context.dp(8), 0, 0) })
|
||||
if (CollectionResetPolicy.showsListAction(item.status)) {
|
||||
if (CollectionResetPolicy.showsListAction(item.status, item.source)) {
|
||||
addView(LinearLayout(context).apply {
|
||||
gravity = Gravity.END
|
||||
addView(MaterialButton(context, null, com.google.android.material.R.attr.materialButtonOutlinedStyle).apply {
|
||||
@@ -414,13 +450,14 @@ class TaskHistoryFragment : Fragment() {
|
||||
append("$prices\n\n$dimensions\n\nSKU:${detail.skus.size} 条")
|
||||
}
|
||||
resultColumn.addView(context.card(context.cardColumn().apply {
|
||||
addView(context.label("#${task.taskId} · ${task.goodsId}", 20f, context.getColor(R.color.agent_text), true))
|
||||
addView(context.label("#${task.taskId} · ${task.goodsId.ifBlank { "未识别商品" }}", 20f, context.getColor(R.color.agent_text), true))
|
||||
if (task.source == "agent_current_page") addView(context.label("来源:Agent 当前页面", 13f, context.getColor(R.color.agent_text_muted)))
|
||||
addView(context.label(collectionStatus(task.status), 14f, statusColor(task.status)).apply { setPadding(0, context.dp(4), 0, context.dp(12)) })
|
||||
addView(context.label(info, 14f))
|
||||
}), collectionCardParams())
|
||||
if (detail.missing.isNotEmpty()) resultColumn.addView(context.centeredMessage("缺失项", detail.missing.joinToString("、")))
|
||||
if (task.errorMessage != null) resultColumn.addView(context.centeredMessage(task.errorMessage, "错误代码:${task.errorCode ?: "—"}"))
|
||||
if (CollectionResetPolicy.isAllowed(task.status)) {
|
||||
if (CollectionResetPolicy.isAllowed(task.status, task.source)) {
|
||||
resultColumn.addView(MaterialButton(context).apply {
|
||||
text = "重新采集"
|
||||
minimumHeight = context.dp(48)
|
||||
@@ -430,6 +467,63 @@ class TaskHistoryFragment : Fragment() {
|
||||
}
|
||||
}
|
||||
|
||||
private fun confirmCurrentPageCollection() {
|
||||
collectionCooldownMessage()?.let { message ->
|
||||
showCurrentPageBlocked(message)
|
||||
return
|
||||
}
|
||||
val context = requireContext()
|
||||
val settings = AgentSettingsStore(context)
|
||||
val credentials = runCatching { SecureDeviceStore(context).credentials() }.getOrNull()
|
||||
val accessibility = GoAutoAccessibilityService.instance
|
||||
val problem = when {
|
||||
settings.serverUrl().isBlank() || credentials == null -> "设备尚未连接服务端,请先检查设置。"
|
||||
AccessibilityReadinessDetector.current(context) != AccessibilityReadiness.READY || accessibility == null -> "请先到“设置”开启采集采购助手。"
|
||||
AgentStateStore(context).read().currentTaskId != null -> "设备正在执行任务,请稍后再试。"
|
||||
!accessibility.hasRecentPddForeground() -> "请先在拼多多打开目标商品详情页,再切回 Agent。"
|
||||
else -> null
|
||||
}
|
||||
if (problem != null) {
|
||||
showCurrentPageBlocked(problem)
|
||||
return
|
||||
}
|
||||
MaterialAlertDialogBuilder(context)
|
||||
.setTitle("采集当前商品?")
|
||||
.setMessage("请确认拼多多已停留在目标商品详情页。Agent 将读取当前商品资料和分享链接;不会采购、创建订单或支付。")
|
||||
.setNegativeButton("取消", null)
|
||||
.setPositiveButton("开始采集") { _, _ ->
|
||||
toast("正在创建临时采集任务…")
|
||||
AgentForegroundService.collectCurrentPage(context, UUID.randomUUID().toString())
|
||||
}
|
||||
.show()
|
||||
}
|
||||
|
||||
private fun showCurrentPageBlocked(message: String) {
|
||||
MaterialAlertDialogBuilder(requireContext())
|
||||
.setTitle("暂时不能采集")
|
||||
.setMessage(message)
|
||||
.setPositiveButton("知道了", null)
|
||||
.show()
|
||||
}
|
||||
|
||||
private fun registerCurrentPageReceiver() {
|
||||
if (currentPageReceiverRegistered) return
|
||||
val filter = IntentFilter(AgentForegroundService.ACTION_CURRENT_PAGE_RESULT)
|
||||
if (Build.VERSION.SDK_INT >= 33) {
|
||||
requireContext().registerReceiver(currentPageReceiver, filter, Context.RECEIVER_NOT_EXPORTED)
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
requireContext().registerReceiver(currentPageReceiver, filter)
|
||||
}
|
||||
currentPageReceiverRegistered = true
|
||||
}
|
||||
|
||||
private fun unregisterCurrentPageReceiver() {
|
||||
if (!currentPageReceiverRegistered) return
|
||||
runCatching { requireContext().unregisterReceiver(currentPageReceiver) }
|
||||
currentPageReceiverRegistered = false
|
||||
}
|
||||
|
||||
private fun confirmReset(task: CollectionHistoryItem) {
|
||||
collectionCooldownMessage()?.let { message ->
|
||||
MaterialAlertDialogBuilder(requireContext())
|
||||
|
||||
@@ -10,4 +10,12 @@
|
||||
<item name="android:windowLightStatusBar">false</item>
|
||||
<item name="android:windowBackground">#020617</item>
|
||||
</style>
|
||||
<style name="Theme.GoAutoAgent.ClipboardRelay" parent="android:style/Theme.Translucent.NoTitleBar">
|
||||
<item name="android:windowIsTranslucent">true</item>
|
||||
<item name="android:windowIsFloating">true</item>
|
||||
<item name="android:windowNoTitle">true</item>
|
||||
<item name="android:windowBackground">@android:color/transparent</item>
|
||||
<item name="android:backgroundDimEnabled">false</item>
|
||||
<item name="android:windowDisablePreview">true</item>
|
||||
</style>
|
||||
</resources>
|
||||
|
||||
@@ -8,11 +8,12 @@ import org.junit.Test
|
||||
class CollectionResetPolicyTest {
|
||||
@Test
|
||||
fun `only collection terminal states can reset`() {
|
||||
assertTrue(CollectionResetPolicy.isAllowed("completed"))
|
||||
assertTrue(CollectionResetPolicy.isAllowed("completed_partial"))
|
||||
assertTrue(CollectionResetPolicy.isAllowed("failed"))
|
||||
assertFalse(CollectionResetPolicy.isAllowed("pending"))
|
||||
assertFalse(CollectionResetPolicy.isAllowed("running"))
|
||||
assertTrue(CollectionResetPolicy.isAllowed("completed", "admin"))
|
||||
assertTrue(CollectionResetPolicy.isAllowed("completed_partial", "admin"))
|
||||
assertTrue(CollectionResetPolicy.isAllowed("failed", "admin"))
|
||||
assertFalse(CollectionResetPolicy.isAllowed("pending", "admin"))
|
||||
assertFalse(CollectionResetPolicy.isAllowed("running", "admin"))
|
||||
assertFalse(CollectionResetPolicy.isAllowed("failed", "agent_current_page"))
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -23,10 +24,11 @@ class CollectionResetPolicyTest {
|
||||
|
||||
@Test
|
||||
fun `only failed collection tasks show the list reset action`() {
|
||||
assertTrue(CollectionResetPolicy.showsListAction("failed"))
|
||||
assertFalse(CollectionResetPolicy.showsListAction("completed"))
|
||||
assertFalse(CollectionResetPolicy.showsListAction("completed_partial"))
|
||||
assertFalse(CollectionResetPolicy.showsListAction("pending"))
|
||||
assertFalse(CollectionResetPolicy.showsListAction("running"))
|
||||
assertTrue(CollectionResetPolicy.showsListAction("failed", "admin"))
|
||||
assertFalse(CollectionResetPolicy.showsListAction("completed", "admin"))
|
||||
assertFalse(CollectionResetPolicy.showsListAction("completed_partial", "admin"))
|
||||
assertFalse(CollectionResetPolicy.showsListAction("pending", "admin"))
|
||||
assertFalse(CollectionResetPolicy.showsListAction("running", "admin"))
|
||||
assertFalse(CollectionResetPolicy.showsListAction("failed", "agent_current_page"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
package cn.ilapage.goauto.agent
|
||||
|
||||
import cn.ilapage.goauto.agent.automation.CollectionRule
|
||||
import cn.ilapage.goauto.agent.automation.CurrentPageIdentityConfig
|
||||
import cn.ilapage.goauto.agent.automation.CurrentPageIdentityRunner
|
||||
import cn.ilapage.goauto.agent.automation.FreshActionResult
|
||||
import cn.ilapage.goauto.agent.automation.NodeBounds
|
||||
import cn.ilapage.goauto.agent.automation.NodeSelector
|
||||
import cn.ilapage.goauto.agent.automation.PageEvidence
|
||||
import cn.ilapage.goauto.agent.automation.PddCollectorDriver
|
||||
import cn.ilapage.goauto.agent.automation.PddShareLinkExtractor
|
||||
import cn.ilapage.goauto.agent.automation.SnapshotNode
|
||||
import cn.ilapage.goauto.agent.automation.SwipeDirection
|
||||
import cn.ilapage.goauto.agent.automation.UiSnapshot
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class CurrentPageCollectionTest {
|
||||
@Test
|
||||
fun extractsOneWhitelistedHttpsUrlOnly() {
|
||||
assertEquals(
|
||||
"https://mobile.yangkeduo.com/goods.html?goods_id=12345",
|
||||
PddShareLinkExtractor.extract("商品链接 https://mobile.yangkeduo.com/goods.html?goods_id=12345。"),
|
||||
)
|
||||
assertNull(PddShareLinkExtractor.extract("http://mobile.yangkeduo.com/goods.html?goods_id=12345"))
|
||||
assertNull(PddShareLinkExtractor.extract("https://example.com/goods.html?goods_id=12345"))
|
||||
assertNull(PddShareLinkExtractor.extract("https://p.pinduoduo.com/a https://p.pinduoduo.com/b"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun identifiesCurrentDetailThroughUniqueShareAndFreshClipboard() {
|
||||
val driver = FakeDriver(listOf(detailSnapshot(), sharePanelSnapshot(), sharePanelSnapshot()))
|
||||
val result = CurrentPageIdentityRunner(
|
||||
driver = driver,
|
||||
readFreshClipboard = { _, _ -> "复制成功 https://p.pinduoduo.com/abc123" },
|
||||
now = { 1_000L },
|
||||
pause = {},
|
||||
).identify(rule())
|
||||
|
||||
assertTrue(result.successful)
|
||||
assertEquals("https://p.pinduoduo.com/abc123", result.shareUrl)
|
||||
assertEquals(listOf("分享", "复制链接"), driver.clicked)
|
||||
assertTrue(driver.backCalled)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun rejectsAmbiguousShareEntryBeforeReadingClipboard() {
|
||||
val duplicate = detailSnapshot().copy(nodes = detailSnapshot().nodes + node("share-2", "分享", 200, 20, 280, 80))
|
||||
val driver = FakeDriver(listOf(duplicate))
|
||||
val result = CurrentPageIdentityRunner(
|
||||
driver = driver,
|
||||
readFreshClipboard = { _, _ -> error("clipboard must not be read") },
|
||||
now = { 1_000L },
|
||||
pause = {},
|
||||
).identify(rule())
|
||||
|
||||
assertFalse(result.successful)
|
||||
assertEquals("PDD_SHARE_UNAVAILABLE", result.code)
|
||||
}
|
||||
|
||||
private class FakeDriver(private val snapshots: List<UiSnapshot>) : PddCollectorDriver {
|
||||
private var index = 0
|
||||
val clicked = mutableListOf<String>()
|
||||
var backCalled = false
|
||||
|
||||
override fun capture(): UiSnapshot = snapshots[index.coerceAtMost(snapshots.lastIndex)].also { index++ }
|
||||
override fun clickFresh(target: SnapshotNode): FreshActionResult {
|
||||
clicked += target.label
|
||||
return FreshActionResult.SUCCESS
|
||||
}
|
||||
override fun swipeSpec(direction: SwipeDirection, anchor: SnapshotNode?) = false
|
||||
override fun pullDownGoodsPage() = false
|
||||
override fun back(): Boolean { backCalled = true; return true }
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val PDD_PACKAGE = "com.xunmeng.pinduoduo"
|
||||
const val ACTIVITY = "com.xunmeng.pinduoduo.activity.NewPageActivity"
|
||||
|
||||
fun rule() = CollectionRule(
|
||||
schemaVersion = 2,
|
||||
steps = emptyList(),
|
||||
ruleType = "pddProductDetail",
|
||||
pageEvidence = PageEvidence(PDD_PACKAGE, ACTIVITY, NodeSelector(resourceId = "android:id/content")),
|
||||
currentPageIdentity = CurrentPageIdentityConfig(
|
||||
pageTimeoutMs = 500,
|
||||
sharePanelTimeoutMs = 500,
|
||||
clipboardTimeoutMs = 500,
|
||||
),
|
||||
)
|
||||
|
||||
fun detailSnapshot() = UiSnapshot(
|
||||
PDD_PACKAGE,
|
||||
ACTIVITY,
|
||||
listOf(
|
||||
node("root", "", 0, 0, 1080, 2200, resourceId = "android:id/content"),
|
||||
node("share", "分享", 900, 20, 1040, 100),
|
||||
),
|
||||
)
|
||||
|
||||
fun sharePanelSnapshot() = UiSnapshot(
|
||||
PDD_PACKAGE,
|
||||
ACTIVITY,
|
||||
listOf(
|
||||
node("root", "", 0, 0, 1080, 2200, resourceId = "android:id/content"),
|
||||
node("copy", "复制链接", 120, 1700, 360, 1800),
|
||||
),
|
||||
)
|
||||
|
||||
fun node(
|
||||
path: String,
|
||||
text: String,
|
||||
left: Int,
|
||||
top: Int,
|
||||
right: Int,
|
||||
bottom: Int,
|
||||
resourceId: String? = null,
|
||||
) = SnapshotNode(
|
||||
path = path,
|
||||
parentPath = null,
|
||||
text = text,
|
||||
contentDescription = null,
|
||||
resourceId = resourceId,
|
||||
className = "android.widget.TextView",
|
||||
bounds = NodeBounds(left, top, right, bottom),
|
||||
clickable = true,
|
||||
scrollable = false,
|
||||
selected = false,
|
||||
checked = false,
|
||||
enabled = true,
|
||||
visible = true,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -21,4 +21,15 @@ class TaskExecutionMutexTest {
|
||||
assertNull(mutex.currentTaskId())
|
||||
assertTrue(mutex.tryAcquire(11L))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun transfersAReservationToTheCreatedTask() {
|
||||
val mutex = TaskExecutionMutex()
|
||||
|
||||
assertTrue(mutex.tryAcquire(Long.MAX_VALUE))
|
||||
assertTrue(mutex.transfer(Long.MAX_VALUE, 42L))
|
||||
assertEquals(42L, mutex.currentTaskId())
|
||||
assertFalse(mutex.transfer(Long.MAX_VALUE, 43L))
|
||||
assertTrue(mutex.release(42L))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Architecture-and-Code-Map
|
||||
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Architecture-and-Code-Map.-
|
||||
wiki_revision: 5385e32c1d860f4890150701df78437493d56e9d
|
||||
synchronized_at: 2026-08-26T08:05:06Z
|
||||
wiki_revision: ecfe6f881cb937688547b0fe819734813d93dd1b
|
||||
synchronized_at: 2026-08-27T02:38:43Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 架构与代码地图
|
||||
@@ -207,3 +207,13 @@ Android Portal/Agent
|
||||
- `TaskHistoryCache` 使用独立 SharedPreferences 保存当前同步范围的列表摘要,网络读取失败时可显示最近同步摘要;Token 继续只保存在 Android Keystore 保护的设备身份存储中,任务详情不离线镜像。
|
||||
- 服务端两个 Agent 历史列表查询增加 `days=1..30`,并继续在数据库查询中强制设备隔离、分页上限和 30 天最大窗口;详情读取仍保持原 30 天边界。
|
||||
- 该链路只读,不接入任务领取、执行、重置、重试、PDD 导航、地址修改、创建订单或支付。
|
||||
|
||||
## Agent 当前页面临时采集(#101)
|
||||
|
||||
- 服务端入口位于 `server/app/goauto/task/current_page.go`:`CreateCurrentPage` 原子完成设备校验、任务创建、规则快照和租约;`IdentifyCurrentPage` 负责白名单短链解析、PDD 商品复用/创建和任务身份绑定。路由为 `/api/agent/v1/current-page-collection-tasks` 及其 `/{taskId}/identify`。
|
||||
- 默认规则设置位于 `server/app/goauto/rule/agent_manual_setting.go`,使用单行表 `agent_manual_collection_setting`。迁移 `1787790000000_agent_current_page_collection.go` 增加任务来源、识别幂等字段、可空 PDD 外键和默认规则表,并对旧任务回填 `admin`。
|
||||
- `collection_task.source` 目前只允许 `admin` / `agent_current_page`。当前页面任务创建时由设备运行槽防并发,识别商品后再参与 PDD 商品活动槽;结构化结果仍写入既有任务及规格子表,不新增临时任务表。
|
||||
- Android 入口仍由 `TaskHistoryFragment` 承载;`AgentForegroundService` 先用 `TaskExecutionMutex` 预占本地串行槽,服务端创建成功后把预占转为真实 taskId,再恢复 PDD、识别身份并调用既有 `PddProductDetailCollector`。
|
||||
- `CurrentPageIdentityRunner` 负责页面证据、唯一分享/复制入口和面板清理;`ClipboardRelayActivity` 使用独立、不可导出的短生命周期任务在前台读取新鲜剪贴板,完成后移除中转任务并露出原 PDD 页面。原始剪贴板不进入日志、缓存或网络请求。
|
||||
- v2 规则新增可选 `currentPageIdentity`(分享/复制别名和三个有界超时),旧 v2 规则由 Android 使用安全默认值;新设备能力为 `collector.pdd.current-page-share.v1`。
|
||||
- 该路径复用既有结果/失败接口、PDD 最新档案写回、任务结束返回和采集间隔,不调用浏览器导航,不进入任何采购、地址、创建订单或支付代码。
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Business-Rules-and-Glossary
|
||||
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Business-Rules-and-Glossary.-
|
||||
wiki_revision: 800b2e0845f8cc69759246d33083793df4cbef7f
|
||||
synchronized_at: 2026-08-27T01:31:41Z
|
||||
wiki_revision: 579d9b25297d7b404c68aca4a29ef3179555baa9
|
||||
synchronized_at: 2026-08-27T02:38:52Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 业务规则与术语
|
||||
@@ -272,3 +272,16 @@ synchronized_at: 2026-08-27T01:31:41Z
|
||||
- 本次抽中的秒数、开始时间和截止时间作为同一间隔票据持久保存在设备本地;Agent 进程或前台服务重启、设置修改都只恢复剩余时间,不重新随机。系统时间回拨时最多按该次已抽中的原始秒数截断,避免无限等待。
|
||||
- 间隔期间保持屏幕常亮;既有“任务结束 15 秒后返回 Agent”是独立机制,返回 Agent 不清除采集间隔。采购任务接管执行时不重复持有间隔亮屏锁,采购完成后若间隔尚未结束则恢复。
|
||||
- 该设置不上传服务端,不改变采集任务状态、租约、规则快照或服务端接口,不打开 PDD、不修改地址、不创建订单、不支付。
|
||||
|
||||
## Agent 当前 PDD 商品页临时采集
|
||||
|
||||
- 操作员先人工在 PDD App 打开并停留在目标商品详情页,再从 Agent 采集 Tab 点击“采集”;该入口采集当前页面,不是重置历史任务。
|
||||
- Agent 在请求服务端前取得采集/采购共用的本地互斥锁。服务端原子校验设备在线、空闲、跨域活动任务、默认规则和能力,创建固定当前设备且已进入 `running` 的标准采集任务。
|
||||
- Agent 只通过系统返回恢复刚才的 PDD 任务栈,并重新验证包名、精确 Activity 与页面强证据;不得启动浏览器、从桌面重开 PDD、自动搜索或自动选择相似商品。
|
||||
- 恢复详情页后先按规则唯一点击“分享”与“复制链接”,只读取本次复制后的单条、有限长度剪贴板文本;Android 只保留唯一白名单 URL,服务端再校验短链并确认 goods_id。原始剪贴板、分享文案、控件树和截图都不保存。
|
||||
- goods_id 已存在时复用唯一 PDD 商品,成功或部分成功结果按既有最新档案覆盖规则更新;不存在时创建 PDD 商品。该流程不自动停用或替换旧商品,也不修改虾皮到 PDD 的关联。
|
||||
- 管理员必须配置一条存活的 PDD 商品详情 v2 规则作为 Agent 手动采集默认规则;迁移只做一次安全初选,创建任务时仍实时验证规则与设备 `collector.pdd.current-page-share.v1` 能力。
|
||||
- 当前页面任务身份确认前允许 `pdd_product_id` 为空、URL/goods_id 快照为空;普通管理端任务不放宽。身份确认后不可改成其他商品,结果 goods_id 必须与任务一致。
|
||||
- 当前页面任务不能使用“重新采集”重置;失败后重新打开目标商品并新建任务。成功提交完成、部分完成或失败结果后,继续进入设备的采集任务执行间隔。
|
||||
- 分享入口、复制链接、剪贴板、链接、页面或身份不唯一时明确失败并释放任务槽;不使用 OCR/VLM,不猜测商品身份。
|
||||
- 该能力只采集商品资料;不创建采购任务、不选择采购规格、不修改地址、不创建订单,永久禁止支付。
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Local-Development-and-Verification
|
||||
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Local-Development-and-Verification.-
|
||||
wiki_revision: a75499ddf3d238810d1f845050cdd4b46e49297e
|
||||
synchronized_at: 2026-08-27T01:31:49Z
|
||||
wiki_revision: 9253f4f7557eeb014032edd7b0645de19a1516f5
|
||||
synchronized_at: 2026-08-27T02:39:03Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 本地开发与验证
|
||||
@@ -249,3 +249,13 @@ adb shell am start -n cn.ilapage.goauto.agent/.MainActivity
|
||||
- 间隔期间创建采购任务,确认采购仍优先执行;采购结束后若原间隔未到期,采集继续等待。心跳、采购 Outbox、记录刷新与同步不受影响。
|
||||
- 将范围设为非固定值并完成一次采集,记录状态页显示的实际剩余秒数;间隔期间重启 Agent 前台服务,确认按同一抽取结果恢复且没有重新随机。使用 `adb shell dumpsys power` 确认 `:collection-cooldown` WakeLock 有界持有并在到期或服务停止后释放。
|
||||
- 此项验证不要求创建正式采购订单;没有单独授权时不得点击创建订单,永久禁止支付。
|
||||
|
||||
### Android Agent 0.9.0 当前页面临时采集检查(#101)
|
||||
|
||||
- 自动化验证运行 `cd android && .\gradlew.bat testDebugUnitTest assembleDebug`,服务端运行 `cd server && go test ./...`;覆盖本地串行预占转移、分享 URL 白名单、唯一分享/复制入口、默认规则、创建/识别幂等、身份冲突和结果写回。
|
||||
- 本地 MySQL 8.4 必须先在明确授权后运行 `cd server && go run . migrate -c config/settings.yml`,确认迁移版本 `1787790000000` 已应用;重复执行应报告 0 个新增迁移。迁移保留旧任务并回填来源 `admin`。
|
||||
- 真机覆盖安装前确认设备空闲、Agent 0.9.0 已上报 `collector.pdd.current-page-share.v1`、无障碍已人工开启,服务端已有 Agent 手动采集默认规则。
|
||||
- 在 PDD 人工打开已授权的测试商品详情页,切回 Agent 采集 Tab 点击“采集”并确认;验证 Agent 不启动浏览器,只返回原 PDD 页面,依次完成分享、复制链接、goods_id 识别和常规采集。
|
||||
- 分别验证直链和 PDD 短链;成功/部分成功应显示来源“Agent 当前页面”,相同 goods_id 不产生重复商品,任务详情与 PDD 最新档案一致。
|
||||
- 断开网络、离开详情页、制造重复分享入口或剪贴板不可用时,应得到普通人可理解的失败原因并释放设备槽;原始分享文案、剪贴板、控件树和截图不得出现在数据库或日志。
|
||||
- 验证期间不得自动搜索或选择相似商品,不得修改虾皮关联,不创建采购任务、不修改地址、不创建订单,永久禁止支付。
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Android-Agent-API-Contract
|
||||
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Android-Agent-API-Contract.-
|
||||
wiki_revision: d9a3260c7d5953df54eb10233586f601cbf4fcc3
|
||||
synchronized_at: 2026-08-26T09:34:04Z
|
||||
wiki_revision: 79f7b1e9e9058db1e9453db6f74dbe7219ffb16f
|
||||
synchronized_at: 2026-08-27T02:39:41Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# MVP 共享 API 契约
|
||||
@@ -599,3 +599,51 @@ GET /api/agent/v1/purchase-tasks?page=1&pageSize=20&days=7&status=failed&taskNo=
|
||||
- Agent 采集/采购 Tab 的顶部下拉只重新读取当前 Tab、当前筛选、当前编号和当前页;不领取、执行、重置或重试任务。
|
||||
- 设置 Tab 可输入最近 1~30 天的任意整数,默认 7 天;空值、非整数或越界时不发起请求并提示“请输入 1~30 天”。一次同步当前设备的采集与采购列表摘要;同步为服务端到 Agent 的单向读取,允许分别成功并显示部分同步结果。
|
||||
- 本地只缓存列表摘要和同步时间;详情仍按需请求。缓存不得包含 Device Token、完整规则快照、PDD URL、地址、控件树或截图。同步本身不打开 PDD、不修改地址、不创建订单、不支付。
|
||||
|
||||
## Agent 当前页面临时采集(#101)
|
||||
|
||||
### 管理端默认规则
|
||||
|
||||
```http
|
||||
GET /api/admin/v1/collection-rules/agent-manual-setting
|
||||
PUT /api/admin/v1/collection-rules/agent-manual-setting
|
||||
Content-Type: application/json
|
||||
|
||||
{"requestId":"<uuid>","ruleId":3}
|
||||
```
|
||||
|
||||
- 只允许管理员读取和修改“Agent 手动采集默认规则”;PUT 的 `requestId` 必须为 UUID,重复请求幂等。
|
||||
- 规则必须是仍存在的 PDD 商品详情 v2 规则。数据库迁移首次执行时,优先选择最近成功/部分成功任务使用的存活规则;没有成功历史时选择最近更新的存活 v2 规则;仍没有时保持未配置。
|
||||
- 创建任务时重新验证规则与设备能力,并把完整规则快照固化到任务;以后修改默认规则不影响已创建任务。
|
||||
|
||||
### 创建并占用当前设备
|
||||
|
||||
```http
|
||||
POST /api/agent/v1/current-page-collection-tasks
|
||||
Authorization: Bearer <device-token>
|
||||
Content-Type: application/json
|
||||
|
||||
{"requestId":"<uuid>"}
|
||||
```
|
||||
|
||||
- Device Token 确定设备。服务端在事务中锁定设备,校验在线、空闲、无活动采集/采购任务、默认规则和 `collector.pdd.current-page-share.v1` 能力。
|
||||
- 成功即创建来源为 `agent_current_page` 的标准采集任务,固定当前设备、状态为 `running`、建立租约并返回规则快照;不再调用普通 `next → claim → start`。
|
||||
- 同一设备与 `requestId` 重放返回原任务且 `replayed=true`,不创建第二条任务、不续租。
|
||||
- 身份识别前响应中的 `pddProductId` 为 `null`,`urlSnapshot` 与 `goodsIdSnapshot` 为空字符串;普通 `admin` 来源任务仍必须在创建时具备完整商品身份。
|
||||
|
||||
### 识别当前商品
|
||||
|
||||
```http
|
||||
POST /api/agent/v1/current-page-collection-tasks/{taskId}/identify
|
||||
Authorization: Bearer <device-token>
|
||||
Content-Type: application/json
|
||||
|
||||
{"requestId":"<uuid>","shareUrl":"https://p.pinduoduo.com/..."}
|
||||
```
|
||||
|
||||
- Android 只上传从本次新鲜剪贴板内容中唯一提取出的分享 URL,不上传完整分享文案。
|
||||
- 服务端仅接受 HTTPS,主机限定 `p.pinduoduo.com` 与 `mobile.yangkeduo.com`,端口只能省略或为 443;短链最多跟随 4 次重定向,每跳重新校验白名单,总超时 10 秒。
|
||||
- 服务端提取 5~32 位纯数字 `goods_id`,形成标准 URL,并在事务中创建或复用唯一 PDD 商品;同商品已有活动采集任务或身份冲突时拒绝。
|
||||
- 相同识别 `requestId` 直接返回已确认身份且不再次访问短链;任务首次确认身份后,不允许不同请求覆盖为其他商品。
|
||||
- 识别后继续复用 `POST /api/agent/v1/tasks/{taskId}/result` 与 `/fail`。结果接口要求任务已绑定身份且结果 goods_id 一致;完成、部分完成和失败继续按既有状态机释放设备槽。
|
||||
- Agent 历史的任务摘要增加 `source`;`agent_current_page` 展示为“Agent 当前页面”,不新增任务状态。该来源任务不支持重置,失败后由用户从当前商品页重新发起新任务。
|
||||
|
||||
@@ -53,6 +53,17 @@
|
||||
"settleMs": 1000
|
||||
}
|
||||
},
|
||||
"currentPageIdentity": {
|
||||
"shareAliases": [
|
||||
"分享"
|
||||
],
|
||||
"copyLinkAliases": [
|
||||
"复制链接"
|
||||
],
|
||||
"pageTimeoutMs": 5000,
|
||||
"sharePanelTimeoutMs": 5000,
|
||||
"clipboardTimeoutMs": 5000
|
||||
},
|
||||
"hooks": {
|
||||
"afterSpecPanelOpen": []
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ func MigratedModels() []any {
|
||||
&models.PurchaseTask{},
|
||||
&models.PurchaseTaskAttempt{},
|
||||
&models.CollectionRule{},
|
||||
&models.AgentManualCollectionSetting{},
|
||||
&models.CollectionTask{},
|
||||
&models.CollectionDimension{},
|
||||
&models.CollectionDimensionValue{},
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -170,7 +170,9 @@ func (s *Service) loadBatchPreviewDataset(ctx context.Context, ids []uint64) (ba
|
||||
return dataset, err
|
||||
}
|
||||
for _, task := range collectionTasks {
|
||||
dataset.latestCollectionByPDD[task.PDDProductID] = task
|
||||
if task.PDDProductID != nil {
|
||||
dataset.latestCollectionByPDD[*task.PDDProductID] = task
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,13 +43,15 @@ func TestProcessStageDerivesAllUserFacingStages(t *testing.T) {
|
||||
row := d.pddByID[3]
|
||||
row.Status = "pending"
|
||||
d.pddByID[3] = row
|
||||
d.latestCollectionByPDD[3] = models.CollectionTask{ID: 8, PDDProductID: 3, Status: models.TaskStatusRunning}
|
||||
pddID := uint64(3)
|
||||
d.latestCollectionByPDD[3] = models.CollectionTask{ID: 8, PDDProductID: &pddID, Status: models.TaskStatusRunning}
|
||||
}},
|
||||
{"PDD 采集失败", ProcessStagePDDCollectionFail, "open_pdd", func(d *batchPreviewDataset, _ *BatchPreviewItem) {
|
||||
row := d.pddByID[3]
|
||||
row.Status = "pending"
|
||||
d.pddByID[3] = row
|
||||
d.latestCollectionByPDD[3] = models.CollectionTask{ID: 8, PDDProductID: 3, Status: models.TaskStatusFailed}
|
||||
pddID := uint64(3)
|
||||
d.latestCollectionByPDD[3] = models.CollectionTask{ID: 8, PDDProductID: &pddID, Status: models.TaskStatusFailed}
|
||||
}},
|
||||
{"颜色待匹配", ProcessStageColorMapping, "open_mapping", func(_ *batchPreviewDataset, p *BatchPreviewItem) { p.ReasonCode = CodeMappingRequired }},
|
||||
{"可创建采购", ProcessStagePurchaseReady, "", func(_ *batchPreviewDataset, p *BatchPreviewItem) { p.Eligible = true }},
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
package rule
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"go-admin/app/goauto/models"
|
||||
"go-admin/app/goauto/rulecontract"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
type AgentManualSettingRequest struct {
|
||||
RequestID string `json:"requestId"`
|
||||
RuleID uint64 `json:"ruleId"`
|
||||
}
|
||||
|
||||
type AgentManualSettingResponse struct {
|
||||
Configured bool `json:"configured"`
|
||||
RuleID uint64 `json:"ruleId,omitempty"`
|
||||
RuleName string `json:"ruleName,omitempty"`
|
||||
Replayed bool `json:"replayed,omitempty"`
|
||||
}
|
||||
|
||||
func (service *Service) AgentManualSetting(ctx context.Context) (AgentManualSettingResponse, error) {
|
||||
var setting models.AgentManualCollectionSetting
|
||||
if err := service.DB.WithContext(ctx).First(&setting, 1).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return AgentManualSettingResponse{}, nil
|
||||
}
|
||||
return AgentManualSettingResponse{}, internalError(err)
|
||||
}
|
||||
var record models.CollectionRule
|
||||
if err := service.DB.WithContext(ctx).First(&record, setting.RuleID).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return AgentManualSettingResponse{}, nil
|
||||
}
|
||||
return AgentManualSettingResponse{}, internalError(err)
|
||||
}
|
||||
return AgentManualSettingResponse{Configured: true, RuleID: record.ID, RuleName: record.Name}, nil
|
||||
}
|
||||
|
||||
func (service *Service) UpdateAgentManualSetting(ctx context.Context, request AgentManualSettingRequest) (AgentManualSettingResponse, error) {
|
||||
request.RequestID = strings.TrimSpace(request.RequestID)
|
||||
if uuid.Validate(request.RequestID) != nil || request.RuleID == 0 {
|
||||
return AgentManualSettingResponse{}, invalidRequest("requestId 和 ruleId 必须有效")
|
||||
}
|
||||
var response AgentManualSettingResponse
|
||||
err := service.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
var existing models.AgentManualCollectionSetting
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&existing, 1).Error; err == nil {
|
||||
if existing.LastUpdateRequestID != nil && *existing.LastUpdateRequestID == request.RequestID {
|
||||
var current models.CollectionRule
|
||||
if err := tx.First(¤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
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -11,9 +11,10 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
CapabilitySchemaV2 = "rule.schema.v2"
|
||||
CapabilitySwipeV1 = "action.swipe.v1"
|
||||
CapabilityPDDProductDetailV1 = "collector.pdd.product-detail.v1"
|
||||
CapabilitySchemaV2 = "rule.schema.v2"
|
||||
CapabilitySwipeV1 = "action.swipe.v1"
|
||||
CapabilityPDDProductDetailV1 = "collector.pdd.product-detail.v1"
|
||||
CapabilityPDDCurrentPageShareV1 = "collector.pdd.current-page-share.v1"
|
||||
)
|
||||
|
||||
var allowedNavigationPackages = map[string]bool{
|
||||
@@ -95,6 +96,14 @@ type navigationRecovery struct {
|
||||
ReopenBrowser *reopenBrowserRecovery `json:"reopenBrowser,omitempty"`
|
||||
}
|
||||
|
||||
type currentPageIdentity struct {
|
||||
ShareAliases []string `json:"shareAliases"`
|
||||
CopyLinkAliases []string `json:"copyLinkAliases"`
|
||||
PageTimeoutMS int `json:"pageTimeoutMs"`
|
||||
SharePanelTimeoutMS int `json:"sharePanelTimeoutMs"`
|
||||
ClipboardTimeoutMS int `json:"clipboardTimeoutMs"`
|
||||
}
|
||||
|
||||
type v2Rule struct {
|
||||
SchemaVersion int `json:"schemaVersion"`
|
||||
RuleType string `json:"ruleType"`
|
||||
@@ -106,10 +115,11 @@ type v2Rule struct {
|
||||
ActivityName string `json:"activityName"`
|
||||
Selector selector `json:"selector"`
|
||||
} `json:"pageEvidence"`
|
||||
Hooks map[string][]hookAction `json:"hooks,omitempty"`
|
||||
NavigationRecovery *navigationRecovery `json:"navigationRecovery,omitempty"`
|
||||
PageRecovery *pageRecovery `json:"pageRecovery,omitempty"`
|
||||
Collector collectorConfig `json:"collector"`
|
||||
Hooks map[string][]hookAction `json:"hooks,omitempty"`
|
||||
NavigationRecovery *navigationRecovery `json:"navigationRecovery,omitempty"`
|
||||
PageRecovery *pageRecovery `json:"pageRecovery,omitempty"`
|
||||
CurrentPageIdentity *currentPageIdentity `json:"currentPageIdentity,omitempty"`
|
||||
Collector collectorConfig `json:"collector"`
|
||||
}
|
||||
|
||||
// Validate accepts the legacy v1 shape and strictly validates the v2 product
|
||||
@@ -278,6 +288,19 @@ func parseV2(raw []byte) (v2Rule, error) {
|
||||
return rule, errors.New("navigationRecovery.reopenBrowser.settleMs 必须为 500..5000")
|
||||
}
|
||||
}
|
||||
if identity := rule.CurrentPageIdentity; identity != nil {
|
||||
if err := validateAliases(identity.ShareAliases, "currentPageIdentity.shareAliases", 1, 8, 20); err != nil {
|
||||
return rule, err
|
||||
}
|
||||
if err := validateAliases(identity.CopyLinkAliases, "currentPageIdentity.copyLinkAliases", 1, 8, 20); err != nil {
|
||||
return rule, err
|
||||
}
|
||||
if identity.PageTimeoutMS < 500 || identity.PageTimeoutMS > 15000 ||
|
||||
identity.SharePanelTimeoutMS < 500 || identity.SharePanelTimeoutMS > 10000 ||
|
||||
identity.ClipboardTimeoutMS < 500 || identity.ClipboardTimeoutMS > 10000 {
|
||||
return rule, errors.New("currentPageIdentity 超时必须在允许范围内")
|
||||
}
|
||||
}
|
||||
if rule.Collector.CollectorID != "pddProductDetailV1" || rule.Collector.SpecEntryStrategy != "safeBottomSpecEntryV1" || rule.Collector.PriceParser != "pddRmbPriceV1" || rule.Collector.PriceGranularity != "color" {
|
||||
return rule, errors.New("collector 使用了 Agent 不支持的类型化能力")
|
||||
}
|
||||
@@ -305,6 +328,21 @@ func parseV2(raw []byte) (v2Rule, error) {
|
||||
return rule, nil
|
||||
}
|
||||
|
||||
func validateAliases(values []string, label string, min, max, maxLength int) error {
|
||||
if len(values) < min || len(values) > max {
|
||||
return fmt.Errorf("%s 必须包含 %d..%d 项", label, min, max)
|
||||
}
|
||||
seen := make(map[string]bool, len(values))
|
||||
for _, value := range values {
|
||||
trimmed := strings.TrimSpace(value)
|
||||
if trimmed == "" || len([]rune(trimmed)) > maxLength || seen[trimmed] {
|
||||
return fmt.Errorf("%s 含空值、重复项或过长文字", label)
|
||||
}
|
||||
seen[trimmed] = true
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateIntMap(values map[string]int, allowed map[string][2]int) error {
|
||||
if len(values) != len(allowed) {
|
||||
return errors.New("规则整数配置缺失或包含未知字段")
|
||||
|
||||
@@ -107,3 +107,20 @@ func TestV2ValidatesReopenBrowserRecovery(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestV2ValidatesCurrentPageIdentity(t *testing.T) {
|
||||
base := validV2(`[]`)
|
||||
valid := strings.Replace(base, `"hooks"`, `"currentPageIdentity":{"shareAliases":["分享"],"copyLinkAliases":["复制链接"],"pageTimeoutMs":5000,"sharePanelTimeoutMs":5000,"clipboardTimeoutMs":5000},"hooks"`, 1)
|
||||
if err := Validate([]byte(valid)); err != nil {
|
||||
t.Fatalf("valid current-page identity rejected: %v", err)
|
||||
}
|
||||
for _, invalid := range []string{
|
||||
strings.Replace(valid, `"shareAliases":["分享"]`, `"shareAliases":[]`, 1),
|
||||
strings.Replace(valid, `"pageTimeoutMs":5000`, `"pageTimeoutMs":100`, 1),
|
||||
strings.Replace(valid, `"clipboardTimeoutMs":5000`, `"clipboardTimeoutMs":20000`, 1),
|
||||
} {
|
||||
if err := Validate([]byte(invalid)); err == nil {
|
||||
t.Fatalf("invalid current-page identity accepted: %s", invalid)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ var pddProductDetailTemplate = json.RawMessage(`{
|
||||
]},
|
||||
"pageEvidence":{"packageName":"com.xunmeng.pinduoduo","activityName":"com.xunmeng.pinduoduo.activity.NewPageActivity","selector":{"resourceId":"android:id/content","className":"android.widget.FrameLayout"}},
|
||||
"navigationRecovery":{"reopenBrowser":{"enabled":true,"maxAttempts":1,"settleMs":1000}},
|
||||
"currentPageIdentity":{"shareAliases":["分享"],"copyLinkAliases":["复制链接"],"pageTimeoutMs":5000,"sharePanelTimeoutMs":5000,"clipboardTimeoutMs":5000},
|
||||
"pageRecovery":{"transientSoldOut":{"enabled":true,"exactText":"商品已售罄","fallbackTopText":"相似商品","pullDownCount":2,"intervalMs":1000,"settleMs":2000,"maxAttempts":1}},
|
||||
"hooks":{"afterSpecPanelOpen":[]},
|
||||
"collector":{
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,49 @@ import (
|
||||
|
||||
type Handler struct{ DB *gorm.DB }
|
||||
|
||||
func (handler Handler) CreateCurrentPage(context *gin.Context) {
|
||||
var request CurrentPageCreateRequest
|
||||
if err := decodeStrict(context, &request); err != nil {
|
||||
writeError(context, serviceError(device.CodeInvalidRequest, "请求 JSON 无效"))
|
||||
return
|
||||
}
|
||||
service, token, ok := handler.service(context)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
payload, err := service.CreateCurrentPage(context.Request.Context(), request, token)
|
||||
if err != nil {
|
||||
writeError(context, err)
|
||||
return
|
||||
}
|
||||
context.Header("Cache-Control", "no-store")
|
||||
context.JSON(http.StatusCreated, gin.H{"data": payload})
|
||||
}
|
||||
|
||||
func (handler Handler) IdentifyCurrentPage(context *gin.Context) {
|
||||
id, err := taskID(context)
|
||||
if err != nil || id == 0 {
|
||||
writeError(context, serviceError(device.CodeInvalidRequest, "taskId 无效"))
|
||||
return
|
||||
}
|
||||
var request CurrentPageIdentifyRequest
|
||||
if err := decodeStrict(context, &request); err != nil {
|
||||
writeError(context, serviceError(device.CodeInvalidRequest, "请求 JSON 无效"))
|
||||
return
|
||||
}
|
||||
service, token, ok := handler.service(context)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
payload, err := service.IdentifyCurrentPage(context.Request.Context(), id, request, token)
|
||||
if err != nil {
|
||||
writeError(context, err)
|
||||
return
|
||||
}
|
||||
context.Header("Cache-Control", "no-store")
|
||||
context.JSON(http.StatusOK, gin.H{"data": payload})
|
||||
}
|
||||
|
||||
func (handler Handler) Next(context *gin.Context) {
|
||||
service, token, ok := handler.service(context)
|
||||
if !ok {
|
||||
@@ -233,7 +276,7 @@ func writeError(context *gin.Context, err error) {
|
||||
code, message, retryable = deviceError.Code, deviceError.Message, deviceError.Retryable
|
||||
}
|
||||
switch code {
|
||||
case device.CodeInvalidRequest, "RESULT_SPEC_INVALID", "RESULT_GOODS_ID_MISMATCH":
|
||||
case device.CodeInvalidRequest, "RESULT_SPEC_INVALID", "RESULT_GOODS_ID_MISMATCH", CodePDDShareLinkInvalid:
|
||||
status = http.StatusUnprocessableEntity
|
||||
case device.CodeTokenInvalid:
|
||||
status = http.StatusUnauthorized
|
||||
@@ -241,11 +284,11 @@ func writeError(context *gin.Context, err error) {
|
||||
status = http.StatusForbidden
|
||||
case CodeTaskNotFound:
|
||||
status = http.StatusNotFound
|
||||
case CodeTaskAlreadyClaimed, CodeTaskAssignedOther, CodeDeviceBusy, CodeDeviceOffline, CodeTaskStateConflict, CodeTaskLeaseExpired:
|
||||
case CodeTaskAlreadyClaimed, CodeTaskAssignedOther, CodeDeviceBusy, CodeDeviceOffline, CodeTaskStateConflict, CodeTaskLeaseExpired, CodeCurrentPageIdentityRequired, CodeCurrentPageIdentityConflict:
|
||||
status = http.StatusConflict
|
||||
case CodeProductTaskActive, CodeProductDisabled:
|
||||
status = http.StatusConflict
|
||||
case CodeProductNotFound, CodeRuleNotFound, CodeDeviceNotFound:
|
||||
case CodeProductNotFound, CodeRuleNotFound, CodeDeviceNotFound, CodeAgentManualRuleNotConfigured:
|
||||
status = http.StatusNotFound
|
||||
}
|
||||
context.JSON(status, gin.H{"code": code, "message": message, "retryable": retryable})
|
||||
|
||||
@@ -70,6 +70,12 @@ func (service *Service) reset(ctx context.Context, taskID uint64, request Action
|
||||
replayed = true
|
||||
return nil
|
||||
}
|
||||
if record.Source == models.CollectionTaskSourceAgentCurrentPage {
|
||||
return serviceError(CodeTaskStateConflict, "当前页面采集不能重置,请回到商品页重新发起采集")
|
||||
}
|
||||
if record.PDDProductID == nil {
|
||||
return serviceError(CodeTaskStateConflict, "任务尚未识别商品,不能重置")
|
||||
}
|
||||
if record.Status == models.TaskStatusRunning {
|
||||
return serviceError(CodeTaskStateConflict, "执行中的任务不能重置")
|
||||
}
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+55
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user