feat(#130): collect replacement products from Agent
This commit is contained in:
@@ -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(
|
||||
|
||||
+52
-12
@@ -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"))
|
||||
}
|
||||
}
|
||||
@@ -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 的继续采购流程。
|
||||
- 替换采集沿用设备级单任务互斥、采集间隔、无障碍安全边界和禁止支付规则;不会增加轮询,也不会创建采购任务或订单。
|
||||
|
||||
@@ -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 或重新采集。
|
||||
|
||||
@@ -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" }
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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" {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}()
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user