feat(#130): collect replacement products from Agent

This commit is contained in:
QiuSW
2026-08-28 18:20:24 +08:00
parent 4862d48687
commit 33d4bb17ad
19 changed files with 843 additions and 105 deletions
@@ -43,6 +43,7 @@ data class AgentTask(
val leaseVersion: Long,
val status: String,
val source: String,
val replacementOriginType: String,
)
data class CurrentPageIdentity(
@@ -100,6 +101,11 @@ data class CollectionHistoryDetail(
val colorPrices: List<HistoryColorPrice>,
val skus: List<HistorySku>,
val missing: List<String>,
val replacementEligible: Boolean,
val replacementDisabledReason: String?,
val replacementMappingStatus: String?,
val replacementActivationStatus: String?,
val replacementActivationErrorMessage: String?,
)
data class CollectionResetResult(val taskId: Long, val status: String, val replayed: Boolean)
@@ -132,7 +138,14 @@ data class PurchaseHistoryItem(
val createdAt: String,
)
data class PurchaseHistoryDetail(val task: PurchaseHistoryItem)
data class PurchaseHistoryDetail(
val task: PurchaseHistoryItem,
val replacementEligible: Boolean,
val replacementDisabledReason: String?,
val replacementMappingStatus: String?,
val replacementActivationStatus: String?,
val replacementActivationErrorMessage: String?,
)
class AgentApiException(
val status: Int,
@@ -184,8 +197,19 @@ class AgentApiClient(private val serverUrl: String) {
return task(response.getJSONObject("data"))
}
fun createCurrentPageCollectionTask(requestId: String, token: String): AgentTask {
fun createCurrentPageCollectionTask(
requestId: String,
token: String,
replacementOriginType: String? = null,
replacementOriginTaskId: Long? = null,
): AgentTask {
val payload = JSONObject().put("requestId", requestId)
if (!replacementOriginType.isNullOrBlank() && replacementOriginTaskId != null) {
payload.put(
"replacementOrigin",
JSONObject().put("type", replacementOriginType).put("taskId", replacementOriginTaskId),
)
}
return task(requireNotNull(request("POST", "/api/agent/v1/current-page-collection-tasks", payload, token)).getJSONObject("data"))
}
@@ -275,6 +299,11 @@ class AgentApiClient(private val serverUrl: String) {
HistorySku(specs, item.getLong("priceCent"), item.getBoolean("available"), item.getBoolean("complete"))
},
missing = data.getJSONArray("missing").strings(),
replacementEligible = data.optBoolean("replacementEligible"),
replacementDisabledReason = data.nullableString("replacementDisabledReason"),
replacementMappingStatus = data.nullableString("replacementMappingStatus"),
replacementActivationStatus = data.nullableString("replacementActivationStatus"),
replacementActivationErrorMessage = data.nullableString("replacementActivationErrorMessage"),
)
}
@@ -294,7 +323,14 @@ class AgentApiClient(private val serverUrl: String) {
fun purchaseHistoryDetail(taskId: Long, token: String): PurchaseHistoryDetail {
val data = requireNotNull(request("GET", "/api/agent/v1/purchase-tasks/$taskId", null, token)).getJSONObject("data")
return PurchaseHistoryDetail(purchaseHistoryItem(data.getJSONObject("task")))
return PurchaseHistoryDetail(
task = purchaseHistoryItem(data.getJSONObject("task")),
replacementEligible = data.optBoolean("replacementEligible"),
replacementDisabledReason = data.nullableString("replacementDisabledReason"),
replacementMappingStatus = data.nullableString("replacementMappingStatus"),
replacementActivationStatus = data.nullableString("replacementActivationStatus"),
replacementActivationErrorMessage = data.nullableString("replacementActivationErrorMessage"),
)
}
fun retryPurchaseTask(taskId: Long, requestId: String, token: String): PurchaseRetryResult {
@@ -345,6 +381,7 @@ class AgentApiClient(private val serverUrl: String) {
leaseVersion = data.getLong("leaseVersion"),
status = data.getString("status"),
source = data.optString("source", "admin"),
replacementOriginType = data.optString("replacementOriginType"),
)
private fun purchaseTask(data: JSONObject) = PurchaseAgentTask(
@@ -131,7 +131,11 @@ class AgentForegroundService : Service() {
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())
requestCurrentPageCollection(
intent.getStringExtra(EXTRA_CURRENT_PAGE_REQUEST_ID).orEmpty(),
intent.getStringExtra(EXTRA_REPLACEMENT_ORIGIN_TYPE),
intent.getLongExtra(EXTRA_REPLACEMENT_ORIGIN_TASK_ID, 0L).takeIf { it > 0L },
)
return START_STICKY
}
triggerSync()
@@ -291,18 +295,22 @@ class AgentForegroundService : Service() {
})
}
private fun requestCurrentPageCollection(requestId: String) {
private fun requestCurrentPageCollection(
requestId: String,
replacementOriginType: String?,
replacementOriginTaskId: Long?,
) {
if (requestId.isBlank()) {
publishCurrentPageResult(0L, "采集请求无效,请重试。")
publishCurrentPageResult(0L, "采集请求无效,请重试。", replacementOriginType, replacementOriginTaskId)
return
}
activeCollectionCooldown()?.let { ticket ->
val remaining = CollectionCooldownPolicy.remainingSeconds(System.currentTimeMillis(), ticket)
publishCurrentPageResult(0L, "采集间隔中,还需 $remaining 秒。")
publishCurrentPageResult(0L, "采集间隔中,还需 $remaining 秒。", replacementOriginType, replacementOriginTaskId)
return
}
if (!taskMutex.tryAcquire(CURRENT_PAGE_RESERVATION_ID)) {
publishCurrentPageResult(0L, "设备正在执行任务,请稍后再试。")
publishCurrentPageResult(0L, "设备正在执行任务,请稍后再试。", replacementOriginType, replacementOriginTaskId)
return
}
cancelIdleReturn("正在创建当前页面采集任务")
@@ -318,7 +326,12 @@ class AgentForegroundService : Service() {
throw TaskFailure("CURRENT_PDD_PAGE_NOT_FOUND", "请先在拼多多打开目标商品详情页")
}
val api = AgentApiClient(serverUrl)
val task = api.createCurrentPageCollectionTask(requestId, credentials.token)
val task = api.createCurrentPageCollectionTask(
requestId,
credentials.token,
replacementOriginType,
replacementOriginTaskId,
)
taskId = task.taskId
check(taskMutex.transfer(CURRENT_PAGE_RESERVATION_ID, task.taskId)) {
"当前页面采集本地占用状态不一致"
@@ -329,15 +342,21 @@ class AgentForegroundService : Service() {
publishCurrentPageResult(
task.taskId,
if (outcome.successful) {
"临时采集任务 #${task.taskId} 已结束,请查看采集记录。"
if (task.replacementOriginType.isNotBlank()) {
"已替换,正在匹配规格"
} else {
"临时采集任务 #${task.taskId} 已结束,请查看采集记录。"
}
} else {
currentPageUserMessage(outcome.code.orEmpty(), outcome.message.orEmpty())
},
replacementOriginType,
replacementOriginTaskId,
)
} catch (error: TaskFailure) {
publishCurrentPageResult(taskId, currentPageUserMessage(error.code, error.message ?: "采集失败"))
publishCurrentPageResult(taskId, currentPageUserMessage(error.code, error.message ?: "采集失败"), replacementOriginType, replacementOriginTaskId)
} catch (error: AgentApiException) {
publishCurrentPageResult(taskId, currentPageUserMessage(error.code, error.message))
publishCurrentPageResult(taskId, currentPageUserMessage(error.code, error.message), replacementOriginType, replacementOriginTaskId)
} catch (error: Exception) {
if (taskId > 0L) {
val token = runCatching { identityStore.credentials()?.token }.getOrNull()
@@ -353,7 +372,7 @@ class AgentForegroundService : Service() {
}
}
}
publishCurrentPageResult(taskId, "采集失败,请查看任务详情。")
publishCurrentPageResult(taskId, "采集失败,请查看任务详情。", replacementOriginType, replacementOriginTaskId)
} finally {
if (taskId > 0L) {
runningTaskId.compareAndSet(taskId, null)
@@ -366,13 +385,20 @@ class AgentForegroundService : Service() {
}
}
private fun publishCurrentPageResult(taskId: Long, message: String) {
private fun publishCurrentPageResult(
taskId: Long,
message: String,
replacementOriginType: String? = null,
replacementOriginTaskId: Long? = null,
) {
currentPageResultNotificationUntil.set(SystemClock.elapsedRealtime() + CURRENT_PAGE_RESULT_NOTIFICATION_MILLIS)
updateNotification(message, force = true)
sendBroadcast(Intent(ACTION_CURRENT_PAGE_RESULT).apply {
setPackage(packageName)
putExtra(EXTRA_CURRENT_PAGE_TASK_ID, taskId)
putExtra(EXTRA_CURRENT_PAGE_MESSAGE, message)
replacementOriginType?.let { putExtra(EXTRA_REPLACEMENT_ORIGIN_TYPE, it) }
replacementOriginTaskId?.let { putExtra(EXTRA_REPLACEMENT_ORIGIN_TASK_ID, it) }
})
}
@@ -382,6 +408,7 @@ class AgentForegroundService : Service() {
"CURRENT_PDD_PAGE_NOT_FOUND" -> "没有找到商品详情页,请重新打开商品后再试。"
"PDD_SHARE_UNAVAILABLE", "PDD_COPY_LINK_UNAVAILABLE", "PDD_CLIPBOARD_UNAVAILABLE", "PDD_SHARE_LINK_INVALID" ->
"无法识别商品链接,请确认商品页可以分享后再试。"
"REPLACEMENT_ACTIVATION_FAILED" -> "采集成功,但替换生效失败,请稍后重试。"
else -> fallback.ifBlank { "采集失败,请稍后重试。" }
}
@@ -694,6 +721,10 @@ class AgentForegroundService : Service() {
}
TaskExecutionSummary(false, error.code, error.message)
} catch (error: AgentApiException) {
if (error.code == "REPLACEMENT_ACTIVATION_FAILED") {
resultSafelySubmitted = true
beginPostCollectionCooldowns()
}
stateStore.update("TASK_ERROR", "${error.code}:${error.message}", tokenStored = true)
TaskExecutionSummary(false, error.code, error.message)
} catch (error: Exception) {
@@ -1001,6 +1032,8 @@ class AgentForegroundService : Service() {
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 EXTRA_REPLACEMENT_ORIGIN_TYPE = "replacement_origin_type"
const val EXTRA_REPLACEMENT_ORIGIN_TASK_ID = "replacement_origin_task_id"
const val MANUAL_EMPTY = "empty"
const val MANUAL_COLLECTION_TASK = "collection_task"
const val MANUAL_PURCHASE_TASK = "purchase_task"
@@ -1044,10 +1077,17 @@ class AgentForegroundService : Service() {
}
}
fun collectCurrentPage(context: Context, requestId: String): CurrentPageLaunchResult {
fun collectCurrentPage(
context: Context,
requestId: String,
replacementOriginType: String? = null,
replacementOriginTaskId: Long? = null,
): CurrentPageLaunchResult {
val intent = Intent(context, AgentForegroundService::class.java).apply {
action = ACTION_CURRENT_PAGE_COLLECTION
putExtra(EXTRA_CURRENT_PAGE_REQUEST_ID, requestId)
replacementOriginType?.let { putExtra(EXTRA_REPLACEMENT_ORIGIN_TYPE, it) }
replacementOriginTaskId?.let { putExtra(EXTRA_REPLACEMENT_ORIGIN_TASK_ID, it) }
}
return cn.ilapage.goauto.agent.automation.CurrentPageNavigationPolicy.launchBeforeOptionalBackground(
launch = {
@@ -84,6 +84,19 @@ internal object PurchaseRetryPolicy {
"系统会保留原任务,并创建一个新的采购任务。重试可能创建新的拼多多待付款订单,但系统不会支付。"
}
internal enum class ReplacementPresentation { ACTION, ACTIVATION_FAILED, MATCHING, MATCHED, MANUAL_REQUIRED, HIDDEN }
internal object ReplacementActionPolicy {
fun presentation(eligible: Boolean, mappingStatus: String?, activationStatus: String?): ReplacementPresentation = when {
activationStatus == "failed" -> ReplacementPresentation.ACTIVATION_FAILED
mappingStatus == "matching" -> ReplacementPresentation.MATCHING
mappingStatus == "matched" -> ReplacementPresentation.MATCHED
mappingStatus == "manual_required" -> ReplacementPresentation.MANUAL_REQUIRED
eligible -> ReplacementPresentation.ACTION
else -> ReplacementPresentation.HIDDEN
}
}
internal class TaskDetailState(restoredTaskId: Long? = null) {
var taskId: Long? = restoredTaskId?.takeIf { it > 0L }
private set
@@ -113,18 +126,33 @@ class TaskHistoryFragment : Fragment() {
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
if (intent?.action != AgentForegroundService.ACTION_CURRENT_PAGE_RESULT) return
val message = intent.getStringExtra(AgentForegroundService.EXTRA_CURRENT_PAGE_MESSAGE).orEmpty()
val taskId = intent.getLongExtra(AgentForegroundService.EXTRA_CURRENT_PAGE_TASK_ID, 0L)
val originType = intent.getStringExtra(AgentForegroundService.EXTRA_REPLACEMENT_ORIGIN_TYPE)
val originTaskId = intent.getLongExtra(AgentForegroundService.EXTRA_REPLACEMENT_ORIGIN_TASK_ID, 0L)
val handlesResult = when (originType) {
"collection" -> collection
"purchase" -> !collection
null -> collection
else -> false
}
if (!handlesResult) return
if (message.isNotBlank()) toast(message)
if (taskId > 0L && isAdded) loadCollectionDetail(taskId) else if (isAdded) load()
if (!isAdded) return
when {
originType == "collection" && collection && originTaskId > 0L -> loadCollectionDetail(originTaskId)
originType == "purchase" && !collection && originTaskId > 0L -> loadPurchaseDetail(originTaskId)
originType == null && collection && taskId > 0L -> loadCollectionDetail(taskId)
originType == null && collection -> load()
}
}
}
override fun onCreate(state: Bundle?) {
super.onCreate(state)
detailState = TaskDetailState(state?.getLong(STATE_DETAIL_TASK_ID)?.takeIf { it > 0L })
if (collection) registerCurrentPageReceiver()
registerCurrentPageReceiver()
}
override fun onSaveInstanceState(outState: Bundle) {
@@ -506,24 +534,20 @@ class TaskHistoryFragment : Fragment() {
setOnClickListener { confirmReset(task) }
}, collectionCardParams())
}
renderReplacementAction(
eligible = detail.replacementEligible,
mappingStatus = detail.replacementMappingStatus,
activationStatus = detail.replacementActivationStatus,
activationErrorMessage = detail.replacementActivationErrorMessage,
originType = "collection",
taskId = task.taskId,
taskNo = "#${task.taskId}",
)
}
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
}
val problem = currentPageCollectionProblem()
if (problem != null) {
showCurrentPageBlocked(problem)
return
@@ -533,18 +557,38 @@ class TaskHistoryFragment : Fragment() {
.setMessage("请确认拼多多已停留在目标商品详情页。Agent 将读取当前商品资料和分享链接;不会采购、创建订单或支付。")
.setNegativeButton("取消", null)
.setPositiveButton("开始采集") { _, _ ->
toast("正在创建临时采集任务…")
when (AgentForegroundService.collectCurrentPage(context, UUID.randomUUID().toString())) {
CurrentPageLaunchResult.STARTED -> Unit
CurrentPageLaunchResult.START_FAILED ->
showCurrentPageBlocked("系统未允许启动采集服务,请保持 Agent 在前台后重试。")
CurrentPageLaunchResult.BACKGROUND_FAILED ->
showCurrentPageBlocked("无法将 Agent 切到后台,请手动返回拼多多后重试。")
}
launchCurrentPageCollection()
}
.show()
}
private fun currentPageCollectionProblem(): String? {
collectionCooldownMessage()?.let { return it }
val context = requireContext()
val settings = AgentSettingsStore(context)
val credentials = runCatching { SecureDeviceStore(context).credentials() }.getOrNull()
val accessibility = GoAutoAccessibilityService.instance
return when {
settings.serverUrl().isBlank() || credentials == null -> "设备尚未连接服务端,请先检查设置。"
AccessibilityReadinessDetector.current(context) != AccessibilityReadiness.READY || accessibility == null -> "请先到“设置”开启采集采购助手。"
AgentStateStore(context).read().currentTaskId != null -> "设备正在执行任务,请稍后再试。"
!accessibility.hasRecentPddForeground() -> "请先在拼多多打开目标商品详情页,再切回 Agent。"
else -> null
}
}
private fun launchCurrentPageCollection(originType: String? = null, originTaskId: Long? = null) {
val context = requireContext()
toast(if (originType == null) "正在创建临时采集任务…" else "正在创建替代商品采集任务…")
when (AgentForegroundService.collectCurrentPage(context, UUID.randomUUID().toString(), originType, originTaskId)) {
CurrentPageLaunchResult.STARTED -> Unit
CurrentPageLaunchResult.START_FAILED ->
showCurrentPageBlocked("系统未允许启动采集服务,请保持 Agent 在前台后重试。")
CurrentPageLaunchResult.BACKGROUND_FAILED ->
showCurrentPageBlocked("无法将 Agent 切到后台,请手动返回拼多多后重试。")
}
}
private fun showCurrentPageBlocked(message: String) {
MaterialAlertDialogBuilder(requireContext())
.setTitle("暂时不能采集")
@@ -665,6 +709,58 @@ class TaskHistoryFragment : Fragment() {
} else {
resultColumn.addView(context.centeredMessage("只读详情", "本页没有取消、修改订单或支付入口。"))
}
renderReplacementAction(
eligible = detail.replacementEligible,
mappingStatus = detail.replacementMappingStatus,
activationStatus = detail.replacementActivationStatus,
activationErrorMessage = detail.replacementActivationErrorMessage,
originType = "purchase",
taskId = task.taskId,
taskNo = "CG-${task.taskId}",
)
}
private fun renderReplacementAction(
eligible: Boolean,
mappingStatus: String?,
activationStatus: String?,
activationErrorMessage: String?,
originType: String,
taskId: Long,
taskNo: String,
) {
val context = requireContext()
when (ReplacementActionPolicy.presentation(eligible, mappingStatus, activationStatus)) {
ReplacementPresentation.ACTIVATION_FAILED -> resultColumn.addView(
context.centeredMessage(
"采集成功,但替换生效失败",
activationErrorMessage?.takeIf(String::isNotBlank) ?: "请稍后重试。",
),
)
ReplacementPresentation.MATCHING -> resultColumn.addView(context.centeredMessage("规格匹配中", "替代商品已提交,正在匹配规格。"))
ReplacementPresentation.MATCHED -> resultColumn.addView(context.centeredMessage("规格已匹配", "可继续处理原任务。"))
ReplacementPresentation.MANUAL_REQUIRED -> resultColumn.addView(context.centeredMessage("需要人工处理", "需在 Admin 人工匹配规格。"))
ReplacementPresentation.ACTION -> resultColumn.addView(MaterialButton(context, null, com.google.android.material.R.attr.materialButtonOutlinedStyle).apply {
text = "采集替代商品"
minimumHeight = context.dp(48)
contentDescription = "为任务 $taskNo 采集替代商品"
setOnClickListener { confirmReplacement(originType, taskId, taskNo) }
}, collectionCardParams())
ReplacementPresentation.HIDDEN -> Unit
}
}
private fun confirmReplacement(originType: String, taskId: Long, taskNo: String) {
currentPageCollectionProblem()?.let { problem ->
showCurrentPageBlocked(problem)
return
}
MaterialAlertDialogBuilder(requireContext())
.setTitle("采集替代商品")
.setMessage("用当前商品替换 $taskNo 的失效商品?")
.setNegativeButton("取消", null)
.setPositiveButton("替换") { _, _ -> launchCurrentPageCollection(originType, taskId) }
.show()
}
private fun confirmPurchaseRetry(task: PurchaseHistoryItem) {
@@ -0,0 +1,22 @@
package cn.ilapage.goauto.agent
import cn.ilapage.goauto.agent.ui.ReplacementActionPolicy
import cn.ilapage.goauto.agent.ui.ReplacementPresentation
import org.junit.Assert.assertEquals
import org.junit.Test
class ReplacementActionPolicyTest {
@Test
fun serverEligibilityIsTheOnlyWayToShowTheAction() {
assertEquals(ReplacementPresentation.ACTION, ReplacementActionPolicy.presentation(true, null, null))
assertEquals(ReplacementPresentation.HIDDEN, ReplacementActionPolicy.presentation(false, null, null))
}
@Test
fun workflowStatesTakePriorityOverEligibility() {
assertEquals(ReplacementPresentation.ACTIVATION_FAILED, ReplacementActionPolicy.presentation(true, "matching", "failed"))
assertEquals(ReplacementPresentation.MATCHING, ReplacementActionPolicy.presentation(true, "matching", "activated"))
assertEquals(ReplacementPresentation.MATCHED, ReplacementActionPolicy.presentation(false, "matched", "activated"))
assertEquals(ReplacementPresentation.MANUAL_REQUIRED, ReplacementActionPolicy.presentation(false, "manual_required", "activated"))
}
}
+10 -2
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Business-Rules-and-Glossary
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Business-Rules-and-Glossary.-
wiki_revision: 14d111bf1043f9c31f4f54dbfbca38826e986ab9
synchronized_at: 2026-08-28T09:50:14Z
wiki_revision: 5f829d4d42583b0654d772043047e93a9e1deb79
synchronized_at: 2026-08-28T10:14:45Z
<!-- gitea-wiki-mirror:end -->
# 业务规则与术语
@@ -318,3 +318,11 @@ synchronized_at: 2026-08-28T09:50:14Z
- 用户于 2026-08-28 确认放宽 #46 的 AI 口径:`ai_match` 仅在置信度存在且不低于服务端阈值(默认 0.9)、结果严格属于当前候选、颜色/尺码角色唯一完整、原因非空且写入前输入版本未变化时自动确认。否则不得猜测或选择相近候选,转 `manual_required`。
- 自动确认必须保存来源、实际置信度和脱敏限长原因;人工确认完成后同步对应分项及主表总体状态。worker 不得覆盖已由人工改变的映射。
- 纠错不能简单执行 B→C;必须把原 A→B 记录置为 `superseded`,建立 A→C,并只处理原记录冻结的虾皮商品影响集合。
## Agent 手动采集替代商品(#130)
- 只有同一设备上的失败采集或失败采购任务,且错误码逐字等于 `PDD_LINK_INVALID` 或 `PDD_GOODS_SOLD_OUT` 时,Agent 详情才显示“采集替代商品”;Android 不自行推断资格。
- Agent 创建当前页面采集任务时携带 `replacementOrigin.type`(`collection` / `purchase`)和来源 `taskId`。服务端再次校验设备、任务终态、错误码、源商品、既有替换与进行中的替换流程,并把来源和激活状态持久化到采集任务,保证进程重启后仍可恢复。
- 替代商品采集结果先按普通采集事务完整保存。随后调用 #131 的原子生效流程;生效失败不得回滚或覆盖已采集商品、规格和 SKU,而是记录稳定的 `failed` 激活状态与限长错误,服务启动后可按同一幂等键仅重试生效,不重新采集。
- 任务详情的 `replacementMappingStatus` 必须由来源任务对应的替换分项推导,不能只读取主表总体状态。状态为 `matching` 时等待自动匹配,`manual_required` 时由 Admin 人工处理,`matched` 后才进入 #132 的继续采购流程。
- 替换采集沿用设备级单任务互斥、采集间隔、无障碍安全边界和禁止支付规则;不会增加轮询,也不会创建采购任务或订单。
+37 -2
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Android-Agent-API-Contract
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Android-Agent-API-Contract.-
wiki_revision: 0bbe3f1041a0795a156dcd1205dbe85474e80f8c
synchronized_at: 2026-08-27T10:14:59Z
wiki_revision: 345d3b29ff7532ba4fd48d64aa3309b9d33225af
synchronized_at: 2026-08-28T10:15:31Z
<!-- gitea-wiki-mirror:end -->
# MVP 共享 API 契约
@@ -647,3 +647,38 @@ Content-Type: application/json
- 相同识别 `requestId` 直接返回已确认身份且不再次访问短链;任务首次确认身份后,不允许不同请求覆盖为其他商品。
- 识别后继续复用 `POST /api/agent/v1/tasks/{taskId}/result` 与 `/fail`。结果接口要求任务已绑定身份且结果 goods_id 一致;完成、部分完成和失败继续按既有状态机释放设备槽。
- Agent 历史的任务摘要增加 `source`;`agent_current_page` 展示为“Agent 当前页面”,不新增任务状态。该来源任务不支持重置,失败后由用户从当前商品页重新发起新任务。
### 失败任务采集替代商品(#130)
失败采集详情和失败采购详情增加以下服务端计算字段:
```json
{
"replacementEligible": true,
"replacementDisabledReason": null,
"replacementMappingStatus": "matching",
"replacementActivationStatus": "activated",
"replacementActivationErrorMessage": null
}
```
- `replacementEligible=true` 仅适用于当前 Device Token 对应设备、任务状态为 `failed`,且错误码逐字等于 `PDD_LINK_INVALID` 或 `PDD_GOODS_SOLD_OUT`;其他错误、其他设备、无源商品、已存在生效替换或已有待处理替换均不得由 Android 自行放宽。
- `replacementMappingStatus` 取当前来源对应的分项状态:`matching`、`matched` 或 `manual_required`。采购来源必须限定到该任务的 `shopeeProductId`;不能以替换主表总体状态代替。
- `replacementActivationStatus` 为 `pending`、`activated` 或 `failed`;激活失败时已采集数据仍为完成态,并返回限长的 `replacementActivationErrorMessage`。
创建替代商品采集任务仍调用:
```http
POST /api/agent/v1/current-page-collection-tasks
Authorization: Bearer <device-token>
Content-Type: application/json
{
"requestId": "<uuid>",
"replacementOrigin": {"type": "collection", "taskId": 123}
}
```
`replacementOrigin.type` 只能是 `collection` 或 `purchase`。服务端在创建事务内重新检查资格,并把来源、可选纠错记录和初始 `pending` 激活状态固化到任务;同一 `requestId` 重放时来源必须完全一致。资格变化返回 HTTP 409 和 `REPLACEMENT_ORIGIN_NOT_ELIGIBLE`。
识别与结果提交继续复用当前页面采集接口。结果事务提交后,服务端调用 #131 的登记/纠错并生效流程:成功返回正常完成详情;生效失败返回 HTTP 409、`REPLACEMENT_ACTIVATION_FAILED`,但采集任务、商品、规格和 SKU 已安全保存。服务启动恢复只重试 `pending` / `failed` 的激活步骤,不重新打开 PDD 或重新采集。
+45 -37
View File
@@ -117,43 +117,51 @@ 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:"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"`
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"`
ReplacementOriginType *string `json:"-" gorm:"size:16;index:ix_collection_replacement_origin,priority:1;check:ck_collection_replacement_origin_type,replacement_origin_type IS NULL OR replacement_origin_type IN ('collection','purchase')"`
ReplacementOriginTaskID *uint64 `json:"-" gorm:"index:ix_collection_replacement_origin,priority:2"`
ReplacementCorrectionID *uint64 `json:"-" gorm:"index"`
ReplacementActivationStatus *string `json:"replacementActivationStatus,omitempty" gorm:"size:16;index;check:ck_collection_replacement_activation_status,replacement_activation_status IS NULL OR replacement_activation_status IN ('pending','activated','failed')"`
ReplacementID *uint64 `json:"replacementId,omitempty" gorm:"index"`
ReplacementActivationErrorCode *string `json:"replacementActivationErrorCode,omitempty" gorm:"size:64"`
ReplacementActivationErrorMessage *string `json:"replacementActivationErrorMessage,omitempty" gorm:"size:500"`
ReplacementActivatedAt *time.Time `json:"replacementActivatedAt,omitempty"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
DeletedAt gorm.DeletedAt `json:"-" gorm:"index"`
}
func (CollectionTask) TableName() string { return "collection_task" }
+17 -2
View File
@@ -9,6 +9,7 @@ import (
"go-admin/app/goauto/device"
"go-admin/app/goauto/models"
"go-admin/app/goauto/replacement"
"gorm.io/gorm"
)
@@ -51,7 +52,12 @@ type AgentPurchaseList struct {
}
type AgentPurchaseDetail struct {
Task AgentPurchaseItem `json:"task"`
Task AgentPurchaseItem `json:"task"`
ReplacementEligible bool `json:"replacementEligible"`
ReplacementDisabledReason string `json:"replacementDisabledReason,omitempty"`
ReplacementMappingStatus string `json:"replacementMappingStatus,omitempty"`
ReplacementActivationStatus string `json:"replacementActivationStatus,omitempty"`
ReplacementActivationErrorMessage string `json:"replacementActivationErrorMessage,omitempty"`
}
func (s *Service) AgentHistory(ctx context.Context, req AgentHistoryRequest, token string) (AgentPurchaseList, error) {
@@ -119,7 +125,16 @@ func (s *Service) AgentHistoryDetail(ctx context.Context, taskID uint64, token s
if err != nil {
return AgentPurchaseDetail{}, internal(err)
}
return AgentPurchaseDetail{Task: agentPurchaseItem(task, s.retryQueryEligibility(ctx, task, true))}, nil
inspection, err := replacement.NewService(s.DB).InspectOrigin(ctx, models.ReplacementOriginPurchase, taskID, deviceRecord.ID)
if err != nil {
return AgentPurchaseDetail{}, internal(err)
}
return AgentPurchaseDetail{
Task: agentPurchaseItem(task, s.retryQueryEligibility(ctx, task, true)),
ReplacementEligible: inspection.Eligible, ReplacementDisabledReason: inspection.DisabledReason,
ReplacementMappingStatus: inspection.MappingStatus, ReplacementActivationStatus: inspection.ActivationStatus,
ReplacementActivationErrorMessage: inspection.ActivationErrorMessage,
}, nil
}
func agentPurchaseItem(task models.PurchaseTask, retry retryDecision) AgentPurchaseItem {
+165
View File
@@ -0,0 +1,165 @@
package replacement
import (
"context"
"errors"
"go-admin/app/goauto/models"
"gorm.io/gorm"
)
const (
CodeOriginNotEligible = "REPLACEMENT_ORIGIN_NOT_ELIGIBLE"
ErrorPDDLinkInvalid = "PDD_LINK_INVALID"
ErrorPDDGoodsSoldOut = "PDD_GOODS_SOLD_OUT"
)
type OriginInspection struct {
Eligible bool
DisabledReason string
SourceProductID uint64
ShopeeProductID uint64
MappingStatus string
ActivationStatus string
ActivationErrorMessage string
CorrectionReplacementID uint64
}
func (service *Service) InspectOrigin(ctx context.Context, originType string, taskID, deviceID uint64) (OriginInspection, error) {
if service.DB == nil || taskID == 0 || deviceID == 0 || (originType != models.ReplacementOriginCollection && originType != models.ReplacementOriginPurchase) {
return OriginInspection{}, fail(CodeInvalidRequest, "替换来源无效")
}
result := OriginInspection{}
var status string
var errorCode *string
switch originType {
case models.ReplacementOriginCollection:
var task models.CollectionTask
if err := service.DB.WithContext(ctx).Where("id = ? AND device_id = ?", taskID, deviceID).First(&task).Error; errors.Is(err, gorm.ErrRecordNotFound) {
return OriginInspection{}, fail(CodeOriginTaskNotFound, "来源采集任务不存在")
} else if err != nil {
return OriginInspection{}, internal(err)
}
if task.PDDProductID != nil {
result.SourceProductID = *task.PDDProductID
}
status, errorCode = task.Status, task.ErrorCode
case models.ReplacementOriginPurchase:
var task models.PurchaseTask
if err := service.DB.WithContext(ctx).Where("id = ? AND device_id = ?", taskID, deviceID).First(&task).Error; errors.Is(err, gorm.ErrRecordNotFound) {
return OriginInspection{}, fail(CodeOriginTaskNotFound, "来源采购任务不存在")
} else if err != nil {
return OriginInspection{}, internal(err)
}
result.SourceProductID = task.PDDProductID
if task.ShopeeProductID != nil {
result.ShopeeProductID = *task.ShopeeProductID
}
status, errorCode = task.Status, task.ErrorCode
}
if result.SourceProductID == 0 {
result.DisabledReason = "原任务没有可替换的拼多多商品"
return result, nil
}
if active, found, err := service.activeForOrigin(ctx, result); err != nil {
return OriginInspection{}, err
} else if found {
result.MappingStatus = active
result.DisabledReason = "该商品已经发起替换"
return result, nil
}
var activation models.CollectionTask
if err := service.DB.WithContext(ctx).
Where("replacement_origin_type = ? AND replacement_origin_task_id = ? AND replacement_activation_status IS NOT NULL", originType, taskID).
Order("id DESC").First(&activation).Error; err == nil {
if activation.ReplacementActivationStatus != nil {
result.ActivationStatus = *activation.ReplacementActivationStatus
}
if activation.ReplacementActivationErrorMessage != nil {
result.ActivationErrorMessage = *activation.ReplacementActivationErrorMessage
}
if result.ActivationStatus == "pending" || result.ActivationStatus == "failed" {
result.DisabledReason = result.ActivationErrorMessage
if result.DisabledReason == "" {
result.DisabledReason = "替换正在处理"
}
return result, nil
}
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
return OriginInspection{}, internal(err)
}
if originType == models.ReplacementOriginPurchase && result.ShopeeProductID != 0 {
correctionID, err := service.correctionForOrigin(ctx, result.SourceProductID, result.ShopeeProductID)
if err != nil {
return OriginInspection{}, err
}
result.CorrectionReplacementID = correctionID
}
failed := status == models.TaskStatusFailed || status == models.PurchaseTaskStatusFailed
if !failed {
result.DisabledReason = "只有失败任务可以替换商品"
return result, nil
}
if errorCode == nil || (*errorCode != ErrorPDDLinkInvalid && *errorCode != ErrorPDDGoodsSoldOut) {
result.DisabledReason = "当前失败原因不属于商品失效或售罄"
return result, nil
}
result.Eligible = true
return result, nil
}
func (service *Service) activeForOrigin(ctx context.Context, origin OriginInspection) (string, bool, error) {
var record models.PDDProductReplacement
err := service.DB.WithContext(ctx).Where("source_product_id = ? AND status = ?", origin.SourceProductID, models.ReplacementStatusActive).First(&record).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return "", false, nil
}
if err != nil {
return "", false, internal(err)
}
var items []models.PDDProductReplacementItem
query := service.DB.WithContext(ctx).Where("replacement_id = ?", record.ID)
if origin.ShopeeProductID != 0 {
query = query.Where("shopee_product_id = ?", origin.ShopeeProductID)
}
if err := query.Find(&items).Error; err != nil {
return "", false, internal(err)
}
return derivedItemStatus(items), true, nil
}
func derivedItemStatus(items []models.PDDProductReplacementItem) string {
if len(items) == 0 {
return models.ReplacementItemMappingMatching
}
allMatched := true
for _, item := range items {
if item.MappingStatus == models.ReplacementItemMappingMatching {
return models.ReplacementItemMappingMatching
}
if item.MappingStatus != models.ReplacementItemMappingMatched {
allMatched = false
}
}
if allMatched {
return models.ReplacementItemMappingMatched
}
return models.ReplacementItemMappingManualRequired
}
func (service *Service) correctionForOrigin(ctx context.Context, targetProductID, shopeeProductID uint64) (uint64, error) {
var item models.PDDProductReplacementItem
err := service.DB.WithContext(ctx).
Joins("JOIN pdd_product_replacement r ON r.id = pdd_product_replacement_item.replacement_id").
Where("r.target_product_id = ? AND r.status = ? AND pdd_product_replacement_item.shopee_product_id = ?", targetProductID, models.ReplacementStatusActive, shopeeProductID).
Order("r.id DESC").First(&item).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return 0, nil
}
if err != nil {
return 0, internal(err)
}
return item.ReplacementID, nil
}
@@ -99,6 +99,40 @@ func replacementCode(err error) string {
return ""
}
func TestInspectOriginUsesExactFailureCodesAndDeviceBoundary(t *testing.T) {
fixture := seedReplacementFixture(t)
linkInvalid := ErrorPDDLinkInvalid
if err := fixture.db.Session(&gorm.Session{SkipHooks: true}).Model(&models.CollectionTask{}).Where("id = ?", fixture.origin.ID).Update("error_code", linkInvalid).Error; err != nil {
t.Fatal(err)
}
inspection, err := fixture.service.InspectOrigin(context.Background(), models.ReplacementOriginCollection, fixture.origin.ID, fixture.device.ID)
if err != nil || !inspection.Eligible || inspection.SourceProductID != fixture.source.ID {
t.Fatalf("eligible collection origin: inspection=%+v error=%v", inspection, err)
}
notExact := "PDD_LINK_INVALID_RETRY"
if err := fixture.db.Session(&gorm.Session{SkipHooks: true}).Model(&models.CollectionTask{}).Where("id = ?", fixture.origin.ID).Update("error_code", notExact).Error; err != nil {
t.Fatal(err)
}
inspection, err = fixture.service.InspectOrigin(context.Background(), models.ReplacementOriginCollection, fixture.origin.ID, fixture.device.ID)
if err != nil || inspection.Eligible || inspection.DisabledReason == "" {
t.Fatalf("non-exact code must be disabled: inspection=%+v error=%v", inspection, err)
}
otherDevice := fixture.device
otherDevice.ID = 0
otherDevice.InstallID = uuid.NewString()
otherDevice.TokenDigest = strings.Repeat("a", 64)
if err := fixture.db.Create(&otherDevice).Error; err != nil {
t.Fatal(err)
}
_, err = fixture.service.InspectOrigin(context.Background(), models.ReplacementOriginCollection, fixture.origin.ID, otherDevice.ID)
if replacementCode(err) != CodeOriginTaskNotFound {
t.Fatalf("cross-device origin must be hidden, got %v", err)
}
}
func TestRegisterAndQueryCollectionOrigin(t *testing.T) {
fixture := seedReplacementFixture(t)
request := fixture.request()
+21 -8
View File
@@ -10,6 +10,7 @@ import (
"go-admin/app/goauto/device"
"go-admin/app/goauto/models"
"go-admin/app/goauto/replacement"
"gorm.io/gorm"
)
@@ -46,14 +47,19 @@ type AgentCollectionList struct {
}
type AgentCollectionDetail struct {
Task AgentCollectionItem `json:"task"`
ShopName *string `json:"shopName,omitempty"`
SalesText *string `json:"salesText,omitempty"`
ReviewCount *int64 `json:"reviewCount,omitempty"`
Dimensions []DetailDimension `json:"dimensions"`
ColorPrices []AgentColorPrice `json:"colorPrices"`
SKUs []AgentCollectionSKU `json:"skus"`
Missing []string `json:"missing"`
Task AgentCollectionItem `json:"task"`
ShopName *string `json:"shopName,omitempty"`
SalesText *string `json:"salesText,omitempty"`
ReviewCount *int64 `json:"reviewCount,omitempty"`
Dimensions []DetailDimension `json:"dimensions"`
ColorPrices []AgentColorPrice `json:"colorPrices"`
SKUs []AgentCollectionSKU `json:"skus"`
Missing []string `json:"missing"`
ReplacementEligible bool `json:"replacementEligible"`
ReplacementDisabledReason string `json:"replacementDisabledReason,omitempty"`
ReplacementMappingStatus string `json:"replacementMappingStatus,omitempty"`
ReplacementActivationStatus string `json:"replacementActivationStatus,omitempty"`
ReplacementActivationErrorMessage string `json:"replacementActivationErrorMessage,omitempty"`
}
type AgentColorPrice struct {
@@ -137,6 +143,10 @@ func (service *Service) AgentHistoryDetail(ctx context.Context, taskID uint64, t
if err != nil {
return AgentCollectionDetail{}, err
}
inspection, err := replacement.NewService(service.DB).InspectOrigin(ctx, models.ReplacementOriginCollection, taskID, deviceRecord.ID)
if err != nil {
return AgentCollectionDetail{}, internalError(err)
}
colorPrices := make([]AgentColorPrice, 0, len(detail.ColorPrices))
for _, value := range detail.ColorPrices {
colorPrices = append(colorPrices, AgentColorPrice{Color: value.Color, PriceCent: value.PriceCent})
@@ -149,6 +159,9 @@ func (service *Service) AgentHistoryDetail(ctx context.Context, taskID uint64, t
Task: agentCollectionItem(record), ShopName: record.ShopName, SalesText: record.SalesText,
ReviewCount: record.ReviewCount, Dimensions: detail.Dimensions, ColorPrices: colorPrices,
SKUs: skus, Missing: detail.Missing,
ReplacementEligible: inspection.Eligible, ReplacementDisabledReason: inspection.DisabledReason,
ReplacementMappingStatus: inspection.MappingStatus, ReplacementActivationStatus: inspection.ActivationStatus,
ReplacementActivationErrorMessage: inspection.ActivationErrorMessage,
}, nil
}
+34 -1
View File
@@ -14,6 +14,7 @@ import (
"go-admin/app/goauto/device"
"go-admin/app/goauto/models"
"go-admin/app/goauto/replacement"
"go-admin/app/goauto/rulecontract"
"github.com/google/uuid"
@@ -29,7 +30,13 @@ const (
)
type CurrentPageCreateRequest struct {
RequestID string `json:"requestId"`
RequestID string `json:"requestId"`
ReplacementOrigin *ReplacementOriginRequest `json:"replacementOrigin,omitempty"`
}
type ReplacementOriginRequest struct {
Type string `json:"type"`
TaskID uint64 `json:"taskId"`
}
type CurrentPageIdentifyRequest struct {
@@ -69,6 +76,9 @@ func (service *Service) CreateCurrentPage(ctx context.Context, request CurrentPa
if replay.Source != models.CollectionTaskSourceAgentCurrentPage || replay.DeviceID == nil || *replay.DeviceID != deviceRecord.ID {
return serviceError(CodeTaskStateConflict, "requestId 已被其他任务使用")
}
if !sameReplacementOrigin(replay, request.ReplacementOrigin) {
return serviceError(CodeTaskStateConflict, "requestId 的替换来源不一致")
}
response, err = service.payload(replay, true)
return err
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
@@ -116,6 +126,22 @@ func (service *Service) CreateCurrentPage(ctx context.Context, request CurrentPa
LeaseExpiresAt: &lease, LeaseVersion: 1, CreateRequestID: &request.RequestID,
StartRequestID: &request.RequestID, StartedAt: &now,
}
if request.ReplacementOrigin != nil {
inspection, err := replacement.NewService(tx).InspectOrigin(ctx, request.ReplacementOrigin.Type, request.ReplacementOrigin.TaskID, deviceRecord.ID)
if err != nil {
return err
}
if !inspection.Eligible {
return serviceError(replacement.CodeOriginNotEligible, inspection.DisabledReason)
}
pending := "pending"
task.ReplacementOriginType = &request.ReplacementOrigin.Type
task.ReplacementOriginTaskID = &request.ReplacementOrigin.TaskID
task.ReplacementActivationStatus = &pending
if inspection.CorrectionReplacementID != 0 {
task.ReplacementCorrectionID = &inspection.CorrectionReplacementID
}
}
if err := tx.Create(&task).Error; err != nil {
return internalError(err)
}
@@ -125,6 +151,13 @@ func (service *Service) CreateCurrentPage(ctx context.Context, request CurrentPa
return response, err
}
func sameReplacementOrigin(task models.CollectionTask, origin *ReplacementOriginRequest) bool {
if origin == nil {
return task.ReplacementOriginType == nil && task.ReplacementOriginTaskID == nil
}
return task.ReplacementOriginType != nil && task.ReplacementOriginTaskID != nil && *task.ReplacementOriginType == origin.Type && *task.ReplacementOriginTaskID == origin.TaskID
}
func ensureCurrentPageRule(content string) error {
var header struct {
SchemaVersion int `json:"schemaVersion"`
@@ -134,6 +134,90 @@ func TestCurrentPageTaskRequiresConfiguredRuleAndCapability(t *testing.T) {
}
}
func TestCreateCurrentPagePersistsEligibleReplacementOrigin(t *testing.T) {
db := openTaskDatabase(t)
deviceRecord, token := registerTaskDeviceWithCapabilities(t, db, "replacement-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.Fatal(err)
}
if err := db.Create(&models.AgentManualCollectionSetting{ID: 1, RuleID: rule.ID}).Error; err != nil {
t.Fatal(err)
}
product := models.PDDProduct{GoodsID: "700000000130", URL: "https://mobile.yangkeduo.com/goods.html?goods_id=700000000130", Status: "disabled"}
if err := db.Create(&product).Error; err != nil {
t.Fatal(err)
}
shopee := models.ShopeeProduct{
ShopeeItemID: "shopee-130", Title: "待替换商品", PDDProductID: &product.ID,
SpecsJSON: `[{"name":"颜色","shopeeValue":"黑色","pddValue":"黑色"}]`,
}
if err := db.Create(&shopee).Error; err != nil {
t.Fatal(err)
}
errorCode := "PDD_GOODS_SOLD_OUT"
origin := models.CollectionTask{
PDDProductID: &product.ID, RuleID: rule.ID, DeviceID: &deviceRecord.ID,
Status: models.TaskStatusFailed, ErrorCode: &errorCode, URLSnapshot: product.URL,
GoodsIDSnapshot: product.GoodsID, RuleSnapshot: rule.ContentJSON,
}
if err := db.Create(&origin).Error; err != nil {
t.Fatal(err)
}
requestID := uuid.NewString()
request := CurrentPageCreateRequest{
RequestID: requestID,
ReplacementOrigin: &ReplacementOriginRequest{Type: models.ReplacementOriginCollection, TaskID: origin.ID},
}
created, err := newTaskService(db).CreateCurrentPage(context.Background(), request, token)
if err != nil {
t.Fatalf("create replacement collection: %v", err)
}
var persisted models.CollectionTask
if err := db.First(&persisted, created.TaskID).Error; err != nil {
t.Fatal(err)
}
if persisted.ReplacementOriginType == nil || *persisted.ReplacementOriginType != models.ReplacementOriginCollection ||
persisted.ReplacementOriginTaskID == nil || *persisted.ReplacementOriginTaskID != origin.ID ||
persisted.ReplacementActivationStatus == nil || *persisted.ReplacementActivationStatus != "pending" {
t.Fatalf("replacement context was not persisted: %+v", persisted)
}
replay, err := newTaskService(db).CreateCurrentPage(context.Background(), request, token)
if err != nil || !replay.Replayed || replay.TaskID != created.TaskID {
t.Fatalf("replacement replay mismatch: response=%+v error=%v", replay, err)
}
identity, err := newTaskService(db).IdentifyCurrentPage(context.Background(), created.TaskID, CurrentPageIdentifyRequest{
RequestID: uuid.NewString(), ShareURL: "https://mobile.yangkeduo.com/goods.html?goods_id=700000000131",
}, token)
if err != nil {
t.Fatalf("identify replacement target: %v", err)
}
title := "替代商品"
detail, err := newTaskService(db).SubmitResult(context.Background(), created.TaskID, ResultRequest{
RequestID: uuid.NewString(), Status: models.TaskStatusCompleted,
Product: ResultProduct{PDDGoodsID: identity.GoodsID, Title: &title},
Dimensions: []ResultDimension{{Key: "color", Name: "颜色", Values: []string{"白色"}}},
ColorPrices: []ResultColorPrice{{Color: "白色", PriceCent: 1300}},
SKUs: []ResultSKU{{Specs: map[string]string{"color": "白色"}, PriceCent: 1300, Available: true}},
}, token)
if err != nil || detail.Task.Status != models.TaskStatusCompleted {
t.Fatalf("replacement result: detail=%+v error=%v", detail, err)
}
if err := db.First(&persisted, created.TaskID).Error; err != nil {
t.Fatal(err)
}
if persisted.ReplacementActivationStatus == nil || *persisted.ReplacementActivationStatus != "activated" || persisted.ReplacementID == nil {
t.Fatalf("replacement was not activated: %+v", persisted)
}
if err := db.First(&shopee, shopee.ID).Error; err != nil || shopee.PDDProductID == nil || *shopee.PDDProductID != identity.PDDProductID {
t.Fatalf("shopee product was not relinked: product=%+v error=%v", shopee, 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" {
+3
View File
@@ -10,6 +10,7 @@ import (
"strings"
"go-admin/app/goauto/device"
"go-admin/app/goauto/replacement"
"github.com/gin-gonic/gin"
"github.com/go-admin-team/go-admin-core/sdk/pkg"
@@ -286,6 +287,8 @@ func writeError(context *gin.Context, err error) {
status = http.StatusNotFound
case CodeTaskAlreadyClaimed, CodeTaskAssignedOther, CodeDeviceBusy, CodeDeviceOffline, CodeTaskStateConflict, CodeTaskLeaseExpired, CodeCurrentPageIdentityRequired, CodeCurrentPageIdentityConflict:
status = http.StatusConflict
case replacement.CodeOriginNotEligible, CodeReplacementActivationFailed:
status = http.StatusConflict
case CodeProductTaskActive, CodeProductDisabled:
status = http.StatusConflict
case CodeProductNotFound, CodeRuleNotFound, CodeDeviceNotFound, CodeAgentManualRuleNotConfigured:
@@ -0,0 +1,107 @@
package task
import (
"context"
"errors"
"time"
"go-admin/app/goauto/models"
"go-admin/app/goauto/replacement"
"gorm.io/gorm"
)
const CodeReplacementActivationFailed = "REPLACEMENT_ACTIVATION_FAILED"
func (service *Service) activatePersistedReplacement(ctx context.Context, taskID uint64) error {
var task models.CollectionTask
if err := service.DB.WithContext(ctx).First(&task, taskID).Error; err != nil {
return internalError(err)
}
if task.ReplacementOriginType == nil || task.ReplacementOriginTaskID == nil {
return nil
}
if task.ReplacementActivationStatus != nil && *task.ReplacementActivationStatus == "activated" {
return nil
}
if task.Status != models.TaskStatusCompleted && task.Status != models.TaskStatusCompletedPartial {
return nil
}
if task.PDDProductID == nil || task.DeviceID == nil || task.CreateRequestID == nil {
return service.markReplacementActivationFailed(ctx, task, "REPLACEMENT_CONTEXT_INVALID", "替换采集上下文不完整")
}
inspection, err := replacement.NewService(service.DB).InspectOrigin(ctx, *task.ReplacementOriginType, *task.ReplacementOriginTaskID, *task.DeviceID)
if err != nil {
return service.markReplacementActivationFailed(ctx, task, replacementErrorCode(err), "来源任务重新校验失败")
}
request := replacement.RegisterRequest{
RequestID: *task.CreateRequestID, SourceProductID: inspection.SourceProductID, TargetProductID: *task.PDDProductID,
OriginType: *task.ReplacementOriginType, OriginTaskID: *task.ReplacementOriginTaskID,
TargetCollectionTaskID: task.ID, CreatedByDeviceID: *task.DeviceID,
}
replacementService := replacement.NewService(service.DB)
var result replacement.ActivationResult
if task.ReplacementCorrectionID != nil {
result, err = replacementService.CorrectAndActivate(ctx, *task.ReplacementCorrectionID, request)
} else {
result, err = replacementService.RegisterAndActivate(ctx, request)
}
if err != nil {
return service.markReplacementActivationFailed(ctx, task, replacementErrorCode(err), "采集成功,但替换生效失败")
}
now := service.Now()
activated := "activated"
if err := service.DB.WithContext(ctx).Session(&gorm.Session{SkipHooks: true}).Model(&models.CollectionTask{}).Where("id = ? AND replacement_activation_status <> ?", task.ID, activated).
Updates(map[string]any{"replacement_activation_status": activated, "replacement_id": result.Record.Replacement.ID, "replacement_activation_error_code": nil, "replacement_activation_error_message": nil, "replacement_activated_at": now}).Error; err != nil {
return internalError(err)
}
return nil
}
func (service *Service) markReplacementActivationFailed(ctx context.Context, task models.CollectionTask, code, message string) error {
failed := "failed"
code = compactReplacementText(code, 64)
message = compactReplacementText(message, 500)
if err := service.DB.WithContext(ctx).Session(&gorm.Session{SkipHooks: true}).Model(&models.CollectionTask{}).Where("id = ?", task.ID).Updates(map[string]any{
"replacement_activation_status": failed, "replacement_activation_error_code": code, "replacement_activation_error_message": message,
}).Error; err != nil {
return internalError(err)
}
return &ServiceError{Code: CodeReplacementActivationFailed, Message: message, Retryable: true}
}
func replacementErrorCode(err error) string {
var target *replacement.ServiceError
if errors.As(err, &target) {
return target.Code
}
return CodeReplacementActivationFailed
}
func compactReplacementText(value string, limit int) string {
runes := []rune(value)
if len(runes) > limit {
return string(runes[:limit])
}
return value
}
// RecoverReplacementActivations resumes already-collected replacement tasks.
// It never recollects product data and all writes remain idempotent.
func RecoverReplacementActivations(db *gorm.DB) {
go func() {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
defer cancel()
var tasks []models.CollectionTask
if err := db.WithContext(ctx).Where("replacement_activation_status IN ? AND status IN ?", []string{"pending", "failed"}, []string{models.TaskStatusCompleted, models.TaskStatusCompletedPartial}).Order("id").Find(&tasks).Error; err != nil {
return
}
service := NewService(db)
for _, task := range tasks {
if ctx.Err() != nil {
return
}
_ = service.activatePersistedReplacement(ctx, task.ID)
}
}()
}
+3
View File
@@ -117,6 +117,9 @@ func (service *Service) SubmitResult(ctx context.Context, taskID uint64, request
if err != nil {
return DetailResponse{}, err
}
if err := service.activatePersistedReplacement(ctx, taskID); err != nil {
return DetailResponse{}, err
}
detail, err := service.Detail(ctx, taskID)
detail.Replayed = replayed
return detail, err
+21 -13
View File
@@ -51,18 +51,19 @@ type ActionRequest struct {
}
type TaskPayload struct {
TaskID uint64 `json:"taskId"`
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"`
LeaseExpiresAt *time.Time `json:"leaseExpiresAt,omitempty"`
LeaseVersion uint64 `json:"leaseVersion"`
Status string `json:"status"`
Replayed bool `json:"replayed,omitempty"`
TaskID uint64 `json:"taskId"`
PDDProductID *uint64 `json:"pddProductId"`
URLSnapshot string `json:"urlSnapshot"`
GoodsIDSnapshot string `json:"goodsIdSnapshot"`
Source string `json:"source"`
ReplacementOriginType string `json:"replacementOriginType,omitempty"`
RuleID uint64 `json:"ruleId"`
RuleSnapshot json.RawMessage `json:"ruleSnapshot"`
TimeoutSeconds int `json:"timeoutSeconds"`
LeaseExpiresAt *time.Time `json:"leaseExpiresAt,omitempty"`
LeaseVersion uint64 `json:"leaseVersion"`
Status string `json:"status"`
Replayed bool `json:"replayed,omitempty"`
}
type Service struct {
@@ -290,13 +291,20 @@ 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,
Source: record.Source, ReplacementOriginType: pointerValue(record.ReplacementOriginType),
RuleID: record.RuleID, RuleSnapshot: rule, TimeoutSeconds: timeout,
LeaseExpiresAt: record.LeaseExpiresAt, LeaseVersion: record.LeaseVersion,
Status: record.Status, Replayed: replayed,
}, nil
}
func pointerValue(value *string) string {
if value == nil {
return ""
}
return *value
}
func (service *Service) leaseDuration() time.Duration {
if service.LeaseDuration <= 0 {
return DefaultLeaseDuration
+2
View File
@@ -24,6 +24,7 @@ import (
goautoreplacement "go-admin/app/goauto/replacement"
goautosybimport "go-admin/app/goauto/sybimport"
goautosybinnercode "go-admin/app/goauto/sybinnercode"
goautotask "go-admin/app/goauto/task"
"go-admin/app/jobs"
"go-admin/common/database"
"go-admin/common/global"
@@ -100,6 +101,7 @@ func run() error {
return fmt.Errorf("recover interrupted SYB inner-code writes: %w", err)
}
goautoreplacement.RecoverMatching(db)
goautotask.RecoverReplacementActivations(db)
}
offlineMonitorContext, stopOfflineMonitors := context.WithCancel(context.Background())
defer stopOfflineMonitors()
@@ -0,0 +1,25 @@
package version_local
import (
"runtime"
goautomigrations "go-admin/app/goauto/migrations"
"go-admin/cmd/migrate/migration"
common "go-admin/common/models"
"gorm.io/gorm"
)
func init() {
_, fileName, _, _ := runtime.Caller(0)
migration.Migrate.SetVersion(migration.GetFilename(fileName), migrateAgentProductReplacement)
}
func migrateAgentProductReplacement(db *gorm.DB, version string) error {
return db.Transaction(func(tx *gorm.DB) error {
if err := goautomigrations.Migrate(tx); err != nil {
return err
}
return tx.Create(&common.Migration{Version: version}).Error
})
}