Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cc4a55cf60 | ||
|
|
7897d9925b | ||
|
|
16e3d35b8f | ||
|
|
82ed6664b7 | ||
|
|
f334c1dcb0 | ||
|
|
4039fdae2b | ||
|
|
26562fc763 | ||
|
|
d3d3837014 | ||
|
|
a0d2416c49 | ||
|
|
ff8c5361cb |
+67
-3
@@ -176,6 +176,7 @@ class AgentForegroundService : Service() {
|
||||
// Separate, serialized I/O ticks keep history reads and result observation off the
|
||||
// heartbeat scheduler and, especially, off the single purchase execution thread.
|
||||
repurchaseExecutor.scheduleWithFixedDelay(::tickRepurchase, 2, 2, TimeUnit.SECONDS)
|
||||
repurchaseExecutor.scheduleWithFixedDelay(::refreshDeviceBusy, 1, 2, TimeUnit.SECONDS)
|
||||
diagnosticInstance = this
|
||||
}
|
||||
|
||||
@@ -197,6 +198,12 @@ class AgentForegroundService : Service() {
|
||||
return START_STICKY
|
||||
}
|
||||
}
|
||||
if (intent?.action == ACTION_OPEN_PDD_PRODUCT) {
|
||||
val goodsId = intent.getStringExtra(EXTRA_OPEN_PDD_GOODS_ID)
|
||||
val purchase = intent.getBooleanExtra(EXTRA_OPEN_PDD_PURCHASE, false)
|
||||
runCatching { repurchaseExecutor.execute { openPddProduct(goodsId, purchase) } }
|
||||
return START_STICKY
|
||||
}
|
||||
if (intent?.action == ACTION_BACKFILL_STOP) {
|
||||
backfillGuard.cancelled.set(true)
|
||||
return START_STICKY
|
||||
@@ -228,6 +235,7 @@ class AgentForegroundService : Service() {
|
||||
repurchase.requestStop(repurchase.state.roundId)
|
||||
repurchaseExecutor.shutdownNow()
|
||||
repurchaseState = RepurchaseState(message = "服务已停止;不会恢复整轮重购")
|
||||
deviceBusyReason = null
|
||||
backfillGuard.cancelled.set(true)
|
||||
runCatching { connectivityManager.unregisterNetworkCallback(networkCallback) }
|
||||
cancelIdleReturn("服务已停止")
|
||||
@@ -497,9 +505,47 @@ class AgentForegroundService : Service() {
|
||||
updateNotification(state.message, force = true)
|
||||
}
|
||||
|
||||
private fun repurchaseLocalIdle(): Boolean = !working.get() && taskMutex.currentTaskId() == null &&
|
||||
purchaseStore.activeTaskId() == null && purchaseStore.pendingOutbox().isEmpty() &&
|
||||
stateStore.read().currentTaskId == null && !backfillState.running
|
||||
private fun deviceBusyInputs(includeRepurchase: Boolean): DeviceBusyPolicy.Inputs {
|
||||
val mutexTaskId = taskMutex.currentTaskId()
|
||||
return DeviceBusyPolicy.Inputs(
|
||||
repurchaseBusy = includeRepurchase && repurchaseState.busy,
|
||||
backfillRunning = backfillState.running,
|
||||
currentPageCollecting = mutexTaskId == CURRENT_PAGE_RESERVATION_ID,
|
||||
taskActive = working.get() || mutexTaskId != null || purchaseStore.activeTaskId() != null ||
|
||||
stateStore.read().currentTaskId != null,
|
||||
unuploadedPurchase = purchaseStore.pendingOutbox().isNotEmpty(),
|
||||
)
|
||||
}
|
||||
|
||||
// The repurchase round itself asks this question between its tasks, so it must not count its own busy flag.
|
||||
private fun repurchaseLocalIdle(): Boolean = DeviceBusyPolicy.reason(deviceBusyInputs(includeRepurchase = false)) == null
|
||||
|
||||
/** Publishes the read-only busy reason for the history detail buttons; click time re-checks. */
|
||||
private fun refreshDeviceBusy() {
|
||||
runCatching {
|
||||
val reason = if (repurchaseClosed.get()) null else DeviceBusyPolicy.reason(deviceBusyInputs(includeRepurchase = true))
|
||||
if (reason != deviceBusyReason) {
|
||||
deviceBusyReason = reason
|
||||
sendBroadcast(Intent(ACTION_DEVICE_BUSY_STATE).setPackage(packageName))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun openPddProduct(goodsId: String?, purchase: Boolean) {
|
||||
val result = runCatching {
|
||||
PddManualOpenCoordinator(
|
||||
busyReason = { synchronized(taskMutex) { DeviceBusyPolicy.reason(deviceBusyInputs(includeRepurchase = true)) } },
|
||||
invalidateHandoff = probeHandoff::invalidate,
|
||||
launch = { url, preferDirect, diagnostic ->
|
||||
cn.ilapage.goauto.agent.automation.PddLinkLauncher(this).open(url, preferDirect, diagnostic)
|
||||
},
|
||||
).run(goodsId, purchase)
|
||||
}.getOrDefault(PddManualOpenResult.Failed)
|
||||
sendBroadcast(Intent(ACTION_OPEN_PDD_RESULT).apply {
|
||||
setPackage(packageName)
|
||||
putExtra(EXTRA_OPEN_PDD_MESSAGE, PddManualOpenCoordinator.message(result))
|
||||
})
|
||||
}
|
||||
|
||||
private fun repurchaseEnvironment(session: RepurchaseSession): String? {
|
||||
if (repurchaseClosed.get()) return "服务已经停止"
|
||||
@@ -1811,6 +1857,15 @@ class AgentForegroundService : Service() {
|
||||
const val ACTION_BACKFILL_STATE = "cn.ilapage.goauto.agent.BACKFILL_STATE"
|
||||
@Volatile var backfillState = OrderBackfillState()
|
||||
private set
|
||||
const val ACTION_OPEN_PDD_PRODUCT = "cn.ilapage.goauto.agent.OPEN_PDD_PRODUCT"
|
||||
const val ACTION_OPEN_PDD_RESULT = "cn.ilapage.goauto.agent.OPEN_PDD_RESULT"
|
||||
const val ACTION_DEVICE_BUSY_STATE = "cn.ilapage.goauto.agent.DEVICE_BUSY_STATE"
|
||||
const val EXTRA_OPEN_PDD_GOODS_ID = "open_pdd_goods_id"
|
||||
const val EXTRA_OPEN_PDD_PURCHASE = "open_pdd_purchase"
|
||||
const val EXTRA_OPEN_PDD_MESSAGE = "open_pdd_message"
|
||||
/** Null when idle; otherwise the user-facing reason. Updated about every two seconds. */
|
||||
@Volatile var deviceBusyReason: String? = null
|
||||
private set
|
||||
const val ACTION_RECONNECT = "cn.ilapage.goauto.agent.RECONNECT"
|
||||
const val ACTION_CHECK_NOW = "cn.ilapage.goauto.agent.CHECK_NOW"
|
||||
const val ACTION_CHECK_RESULT = "cn.ilapage.goauto.agent.CHECK_RESULT"
|
||||
@@ -1867,6 +1922,15 @@ class AgentForegroundService : Service() {
|
||||
}
|
||||
}
|
||||
|
||||
fun openPddProduct(context: Context, goodsId: String, purchase: Boolean) {
|
||||
val intent = Intent(context, AgentForegroundService::class.java).apply {
|
||||
action = ACTION_OPEN_PDD_PRODUCT
|
||||
putExtra(EXTRA_OPEN_PDD_GOODS_ID, goodsId)
|
||||
putExtra(EXTRA_OPEN_PDD_PURCHASE, purchase)
|
||||
}
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) context.startForegroundService(intent) else context.startService(intent)
|
||||
}
|
||||
|
||||
fun collectCurrentPage(
|
||||
context: Context,
|
||||
requestId: String,
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
package cn.ilapage.goauto.agent.service
|
||||
|
||||
import cn.ilapage.goauto.agent.automation.PddLaunchEvent
|
||||
import cn.ilapage.goauto.agent.automation.PddLaunchPath
|
||||
import cn.ilapage.goauto.agent.automation.PddLaunchStatus
|
||||
|
||||
/** Single source of truth for "the device is busy"; the service and the UI both consume its result. */
|
||||
internal object DeviceBusyPolicy {
|
||||
data class Inputs(
|
||||
val repurchaseBusy: Boolean = false,
|
||||
val backfillRunning: Boolean = false,
|
||||
/** The manual current-page collection holds the task mutex with its own reservation. */
|
||||
val currentPageCollecting: Boolean = false,
|
||||
/** A collection/purchase/image-search task is claimed, running or restored. */
|
||||
val taskActive: Boolean = false,
|
||||
/** Unuploaded purchase results or a purchase waiting for recovery. */
|
||||
val unuploadedPurchase: Boolean = false,
|
||||
)
|
||||
|
||||
const val REPURCHASE = "重购进行中"
|
||||
const val BACKFILL = "订单回填中"
|
||||
const val CURRENT_PAGE = "当前页采集进行中"
|
||||
const val TASK = "设备正在执行任务"
|
||||
const val UNUPLOADED = "有未上传的采购结果"
|
||||
|
||||
/** Null means idle. A collection cooldown is intentionally not an input. */
|
||||
fun reason(inputs: Inputs): String? = when {
|
||||
inputs.repurchaseBusy -> REPURCHASE
|
||||
inputs.backfillRunning -> BACKFILL
|
||||
inputs.currentPageCollecting -> CURRENT_PAGE
|
||||
inputs.taskActive -> TASK
|
||||
inputs.unuploadedPurchase -> UNUPLOADED
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class PddManualOpenResult {
|
||||
object InvalidGoodsId : PddManualOpenResult()
|
||||
data class Busy(val reason: String) : PddManualOpenResult()
|
||||
data class Opened(val fallbackNotice: String?) : PddManualOpenResult()
|
||||
object Failed : PddManualOpenResult()
|
||||
}
|
||||
|
||||
/** Manual "open in PDD" entry. Only opens the product link; it never clicks anything inside PDD. */
|
||||
internal class PddManualOpenCoordinator(
|
||||
private val busyReason: () -> String?,
|
||||
private val invalidateHandoff: () -> Unit,
|
||||
private val launch: (url: String, preferDirect: Boolean, diagnostic: (PddLaunchEvent) -> Unit) -> Boolean,
|
||||
) {
|
||||
fun run(goodsId: String?, purchase: Boolean): PddManualOpenResult {
|
||||
val url = link(goodsId) ?: return PddManualOpenResult.InvalidGoodsId
|
||||
busyReason()?.let { return PddManualOpenResult.Busy(it) }
|
||||
// The opened page is no longer the product a previous probe prepared.
|
||||
invalidateHandoff()
|
||||
val events = mutableListOf<PddLaunchEvent>()
|
||||
val opened = launch(url, purchase) { events += it }
|
||||
return if (opened) PddManualOpenResult.Opened(fallbackNotice(events)) else PddManualOpenResult.Failed
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val OPENED_HINT = "打开期间如有新任务,Agent 会接管屏幕"
|
||||
const val FAILED = "无法打开拼多多链接"
|
||||
const val INVALID = "商品编号无效,无法打开"
|
||||
|
||||
fun link(goodsId: String?): String? =
|
||||
goodsId?.takeIf { it.isNotEmpty() && it.all { c -> c in '0'..'9' } }
|
||||
?.let { "https://mobile.yangkeduo.com/goods.html?goods_id=$it" }
|
||||
|
||||
/** Never contains the URL or an exception message; only the simple exception class name. */
|
||||
fun fallbackNotice(events: List<PddLaunchEvent>): String? {
|
||||
val direct = events.firstOrNull { it.path == PddLaunchPath.DIRECT && it.status != PddLaunchStatus.ACCEPTED }
|
||||
?: return null
|
||||
if (events.none { it.path == PddLaunchPath.BROWSER && it.status == PddLaunchStatus.ACCEPTED }) return null
|
||||
val name = direct.exceptionClass?.substringAfterLast('.')?.takeIf(String::isNotBlank)
|
||||
return "拼多多直接打开失败,已改用浏览器" + (name?.let { "($it)" } ?: "")
|
||||
}
|
||||
|
||||
fun message(result: PddManualOpenResult): String = when (result) {
|
||||
PddManualOpenResult.InvalidGoodsId -> INVALID
|
||||
is PddManualOpenResult.Busy -> "${result.reason},暂不能打开拼多多"
|
||||
is PddManualOpenResult.Opened -> listOfNotNull(result.fallbackNotice, OPENED_HINT).joinToString("\n")
|
||||
PddManualOpenResult.Failed -> FAILED
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -74,6 +74,8 @@ class AgentStatusFragment : Fragment() {
|
||||
private lateinit var mediaPermissionDetail: TextView
|
||||
private lateinit var mediaPermissionAction: android.widget.Button
|
||||
private lateinit var deviceText: TextView
|
||||
private lateinit var deviceWifiText: TextView
|
||||
private lateinit var deviceDebugText: TextView
|
||||
private val mediaPermissionRequestLauncher by lazy {
|
||||
registerForActivityResult(androidx.activity.result.contract.ActivityResultContracts.RequestPermission()) {
|
||||
refreshStatus()
|
||||
@@ -168,6 +170,20 @@ class AgentStatusFragment : Fragment() {
|
||||
}
|
||||
addView(mediaPermissionAction)
|
||||
}))
|
||||
addView(context.card(context.cardColumn().apply {
|
||||
addView(context.label("设备连接", 14f, context.getColor(R.color.agent_text_muted)))
|
||||
deviceWifiText = context.label("Wi-Fi IP:—", 18f, context.getColor(R.color.agent_text), true).apply {
|
||||
setPadding(0, context.dp(6), 0, 0)
|
||||
}
|
||||
deviceDebugText = context.label("无线调试:—", 18f, context.getColor(R.color.agent_text_muted), true).apply {
|
||||
setPadding(0, context.dp(6), 0, 0)
|
||||
}
|
||||
addView(deviceWifiText)
|
||||
addView(deviceDebugText)
|
||||
addView(context.label(DeviceConnectionPolicy.PORT_HINT, 14f, context.getColor(R.color.agent_text_muted)).apply {
|
||||
setPadding(0, context.dp(6), 0, 0)
|
||||
})
|
||||
}))
|
||||
}
|
||||
refreshLayout = SwipeRefreshLayout(context).apply {
|
||||
setColorSchemeResources(R.color.agent_primary_light)
|
||||
@@ -245,6 +261,10 @@ class AgentStatusFragment : Fragment() {
|
||||
private fun refreshStatus() {
|
||||
if (!isAdded || view == null) return
|
||||
val context = requireContext()
|
||||
val deviceConnection = DeviceConnectionPolicy.display(DeviceConnectionReader.read(context))
|
||||
deviceWifiText.text = deviceConnection.wifiText
|
||||
deviceDebugText.text = deviceConnection.debugText
|
||||
deviceDebugText.setTextColor(context.getColor(deviceConnection.debugColor))
|
||||
val state = stateStore.read()
|
||||
val connected = state.code in setOf("ONLINE", "BUSY", "COLLECTION_COOLDOWN")
|
||||
connectionTitle.text = when (state.code) {
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
package cn.ilapage.goauto.agent.ui
|
||||
|
||||
import android.content.Context
|
||||
import android.net.ConnectivityManager
|
||||
import android.net.NetworkCapabilities
|
||||
import android.provider.Settings
|
||||
import androidx.annotation.ColorRes
|
||||
import cn.ilapage.goauto.agent.R
|
||||
import java.net.Inet4Address
|
||||
import java.net.InetAddress
|
||||
|
||||
/** 无线调试开关的读取结果;读取失败为 [UNKNOWN]。 */
|
||||
internal enum class WirelessDebugState { ENABLED, DISABLED, UNKNOWN }
|
||||
|
||||
internal data class DeviceConnectionReading(
|
||||
val wifiIpv4: String?,
|
||||
val wirelessDebug: WirelessDebugState,
|
||||
)
|
||||
|
||||
internal data class DeviceConnectionDisplay(
|
||||
val wifiText: String,
|
||||
val debugText: String,
|
||||
@ColorRes val debugColor: Int,
|
||||
)
|
||||
|
||||
/** 原始读数到显示文案/颜色的纯函数策略,不依赖 Android 系统服务。 */
|
||||
internal object DeviceConnectionPolicy {
|
||||
const val PORT_HINT = "端口请在 开发者选项 → 无线调试 中查看"
|
||||
const val NO_WIFI_TEXT = "未连接 Wi-Fi"
|
||||
|
||||
/** 仅当前网络为 Wi-Fi 时,从地址列表中取第一个非回环 IPv4;否则返回 null。 */
|
||||
fun selectWifiIpv4(isWifi: Boolean, addresses: List<InetAddress>): String? {
|
||||
if (!isWifi) return null
|
||||
return addresses.firstOrNull { it is Inet4Address && !it.isLoopbackAddress }?.hostAddress
|
||||
}
|
||||
|
||||
/** adb_wifi_enabled 原始值:1 开启,0 未开启,其他值或读取失败(null)为未知。 */
|
||||
fun debugState(raw: Int?): WirelessDebugState = when (raw) {
|
||||
1 -> WirelessDebugState.ENABLED
|
||||
0 -> WirelessDebugState.DISABLED
|
||||
else -> WirelessDebugState.UNKNOWN
|
||||
}
|
||||
|
||||
fun display(reading: DeviceConnectionReading): DeviceConnectionDisplay {
|
||||
val (text, color) = when (reading.wirelessDebug) {
|
||||
WirelessDebugState.ENABLED -> "已开启" to R.color.agent_primary_light
|
||||
WirelessDebugState.DISABLED -> "未开启" to R.color.agent_warning
|
||||
WirelessDebugState.UNKNOWN -> "无法读取" to R.color.agent_text_muted
|
||||
}
|
||||
return DeviceConnectionDisplay(
|
||||
wifiText = "Wi-Fi IP:${reading.wifiIpv4 ?: NO_WIFI_TEXT}",
|
||||
debugText = "无线调试:$text",
|
||||
debugColor = color,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** 系统读取的薄封装;任何异常都降级为「未知」,不记录日志、不持久化。 */
|
||||
internal object DeviceConnectionReader {
|
||||
fun read(context: Context): DeviceConnectionReading = DeviceConnectionReading(
|
||||
wifiIpv4 = readWifiIpv4(context),
|
||||
wirelessDebug = DeviceConnectionPolicy.debugState(readDebugRaw(context)),
|
||||
)
|
||||
|
||||
private fun readWifiIpv4(context: Context): String? = try {
|
||||
val manager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager
|
||||
val network = manager?.activeNetwork
|
||||
val isWifi = network?.let { manager.getNetworkCapabilities(it) }
|
||||
?.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) == true
|
||||
val addresses = if (isWifi && manager != null && network != null) {
|
||||
manager.getLinkProperties(network)?.linkAddresses?.map { it.address }.orEmpty()
|
||||
} else {
|
||||
emptyList()
|
||||
}
|
||||
DeviceConnectionPolicy.selectWifiIpv4(isWifi, addresses)
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
|
||||
private fun readDebugRaw(context: Context): Int? = try {
|
||||
Settings.Global.getInt(context.contentResolver, "adb_wifi_enabled", 0)
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,7 @@ import android.widget.HorizontalScrollView
|
||||
import android.widget.ImageView
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.ProgressBar
|
||||
import android.widget.TextView
|
||||
import android.widget.Toast
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
|
||||
@@ -43,6 +44,7 @@ import cn.ilapage.goauto.agent.service.AgentForegroundService
|
||||
import cn.ilapage.goauto.agent.service.AgentSettingsStore
|
||||
import cn.ilapage.goauto.agent.service.AgentStateStore
|
||||
import cn.ilapage.goauto.agent.service.CollectionCooldownPolicy
|
||||
import cn.ilapage.goauto.agent.service.PddManualOpenCoordinator
|
||||
import com.google.android.material.button.MaterialButton
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import com.google.android.material.textfield.TextInputEditText
|
||||
@@ -143,6 +145,9 @@ class TaskHistoryFragment : Fragment() {
|
||||
private val imageLoader = HistoryImageLoader()
|
||||
private val imageRequests = mutableListOf<HistoryImageRequest>()
|
||||
private var currentPageReceiverRegistered = false
|
||||
private var openPddButton: MaterialButton? = null
|
||||
private var openPddReason: TextView? = null
|
||||
private var openPddAwaitingResult = false
|
||||
private var backfillPanel: LinearLayout? = null
|
||||
private var repurchasePanel: LinearLayout? = null
|
||||
private var repurchaseButton: MaterialButton? = null
|
||||
@@ -156,6 +161,17 @@ class TaskHistoryFragment : Fragment() {
|
||||
renderRejections()
|
||||
return
|
||||
}
|
||||
if (intent?.action == AgentForegroundService.ACTION_DEVICE_BUSY_STATE) {
|
||||
applyOpenPddBusy()
|
||||
return
|
||||
}
|
||||
if (intent?.action == AgentForegroundService.ACTION_OPEN_PDD_RESULT) {
|
||||
if (!openPddAwaitingResult) return
|
||||
openPddAwaitingResult = false
|
||||
val message = intent.getStringExtra(AgentForegroundService.EXTRA_OPEN_PDD_MESSAGE).orEmpty()
|
||||
if (message.isNotBlank() && isAdded) Toast.makeText(requireContext(), message, Toast.LENGTH_LONG).show()
|
||||
return
|
||||
}
|
||||
if (intent?.action == AgentForegroundService.ACTION_REPURCHASE_STATE) {
|
||||
renderRepurchase()
|
||||
if (!collection && isResumed && AgentForegroundService.repurchaseState.phase == cn.ilapage.goauto.agent.service.RepurchasePhase.FINISHED) {
|
||||
@@ -743,6 +759,53 @@ class TaskHistoryFragment : Fragment() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Manual "open in PDD" entry. Execution goes through the service so it can re-check busy state and
|
||||
* invalidate the purchase probe handoff; the button never taps anything inside PDD.
|
||||
*/
|
||||
private fun openInPddSection(goodsId: String?, purchase: Boolean): View? {
|
||||
openPddButton = null
|
||||
openPddReason = null
|
||||
if (PddManualOpenCoordinator.link(goodsId) == null) return null
|
||||
val context = requireContext()
|
||||
val id = goodsId.orEmpty()
|
||||
val button = MaterialButton(context, null, com.google.android.material.R.attr.materialButtonOutlinedStyle).apply {
|
||||
text = "在拼多多中打开"
|
||||
minimumHeight = context.dp(48)
|
||||
contentDescription = "在拼多多中打开商品 $id"
|
||||
setOnClickListener {
|
||||
val busy = AgentForegroundService.deviceBusyReason
|
||||
if (busy != null) {
|
||||
applyOpenPddBusy()
|
||||
toast("$busy,暂不能打开拼多多")
|
||||
} else {
|
||||
openPddAwaitingResult = true
|
||||
runCatching { AgentForegroundService.openPddProduct(requireContext(), id, purchase) }
|
||||
.onFailure { openPddAwaitingResult = false; toast(PddManualOpenCoordinator.FAILED) }
|
||||
}
|
||||
}
|
||||
}
|
||||
val reason = context.label("", 12f, context.getColor(R.color.agent_text_muted)).apply { visibility = View.GONE }
|
||||
openPddButton = button
|
||||
openPddReason = reason
|
||||
applyOpenPddBusy()
|
||||
return LinearLayout(context).apply {
|
||||
orientation = LinearLayout.VERTICAL
|
||||
setPadding(0, context.dp(4), 0, context.dp(8))
|
||||
addView(button)
|
||||
addView(reason)
|
||||
}
|
||||
}
|
||||
|
||||
private fun applyOpenPddBusy() {
|
||||
val button = openPddButton ?: return
|
||||
val reasonView = openPddReason ?: return
|
||||
val busy = AgentForegroundService.deviceBusyReason
|
||||
button.isEnabled = busy == null
|
||||
reasonView.text = busy.orEmpty()
|
||||
reasonView.visibility = if (busy == null) View.GONE else View.VISIBLE
|
||||
}
|
||||
|
||||
private fun renderCollectionDetail(detail: CollectionHistoryDetail) {
|
||||
val context = requireContext()
|
||||
val task = detail.task
|
||||
@@ -757,6 +820,7 @@ class TaskHistoryFragment : Fragment() {
|
||||
}
|
||||
val detailCard = context.cardColumn().apply {
|
||||
addView(context.label("#${task.taskId} · 第 ${task.attemptNumber} 次 · ${task.goodsId.ifBlank { "未识别商品" }}", 20f, context.getColor(R.color.agent_text), true))
|
||||
openInPddSection(task.goodsId, purchase = false)?.let(::addView)
|
||||
if (task.source == "agent_current_page") addView(context.label("来源:Agent 当前页面", 13f, context.getColor(R.color.agent_text_muted)))
|
||||
addView(context.label(collectionStatus(task.status), 14f, statusColor(task.status)).apply { setPadding(0, context.dp(4), 0, context.dp(12)) })
|
||||
addView(context.label(info, 14f))
|
||||
@@ -850,6 +914,8 @@ class TaskHistoryFragment : Fragment() {
|
||||
filter.addAction(AgentForegroundService.ACTION_BACKFILL_STATE)
|
||||
filter.addAction(AgentForegroundService.ACTION_REPURCHASE_STATE)
|
||||
filter.addAction(AgentForegroundService.ACTION_PURCHASE_REJECTIONS_CHANGED)
|
||||
filter.addAction(AgentForegroundService.ACTION_DEVICE_BUSY_STATE)
|
||||
filter.addAction(AgentForegroundService.ACTION_OPEN_PDD_RESULT)
|
||||
if (Build.VERSION.SDK_INT >= 33) {
|
||||
requireContext().registerReceiver(currentPageReceiver, filter, Context.RECEIVER_NOT_EXPORTED)
|
||||
} else {
|
||||
@@ -937,9 +1003,8 @@ class TaskHistoryFragment : Fragment() {
|
||||
addView(context.label(PurchaseRejectionPresentation.notice(record) + "\n${record.errorCode}", 14f, context.getColor(R.color.agent_warning)))
|
||||
}))
|
||||
}
|
||||
val infoHead = "蝦皮订单号:${task.shopeeOrderNo.ifBlank { "—" }}\nPDD 商品:${task.pddGoodsId}"
|
||||
val info = buildString {
|
||||
append("蝦皮订单号:${task.shopeeOrderNo.ifBlank { "—" }}\n")
|
||||
append("PDD 商品:${task.pddGoodsId}\n")
|
||||
append("商品标题:${task.pddTitle.ifBlank { "—" }}\n")
|
||||
append("目标规格:${listOf(task.targetColor, task.targetSize).filter(String::isNotBlank).joinToString(" / ").ifBlank { "—" }}\n")
|
||||
append("执行规格:${listOf(task.mappedColor, task.mappedSize).filter(String::isNotBlank).joinToString(" / ").ifBlank { "—" }}\n")
|
||||
@@ -957,6 +1022,8 @@ class TaskHistoryFragment : Fragment() {
|
||||
resultColumn.addView(context.card(context.cardColumn().apply {
|
||||
addView(context.label("CG-${task.taskId}", 20f, context.getColor(R.color.agent_text), true))
|
||||
addView(context.label(purchaseStatus(task.status), 14f, statusColor(task.status)).apply { setPadding(0, context.dp(4), 0, context.dp(12)) })
|
||||
addView(context.label(infoHead, 14f))
|
||||
openInPddSection(task.pddGoodsId, purchase = true)?.let(::addView)
|
||||
addView(context.label(info, 14f))
|
||||
}), collectionCardParams())
|
||||
val lastFailure = detail.lastFailureMessage?.takeIf(String::isNotBlank)
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
package cn.ilapage.goauto.agent
|
||||
|
||||
import cn.ilapage.goauto.agent.ui.DeviceConnectionPolicy
|
||||
import cn.ilapage.goauto.agent.ui.DeviceConnectionReading
|
||||
import cn.ilapage.goauto.agent.ui.WirelessDebugState
|
||||
import java.net.InetAddress
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
|
||||
class DeviceConnectionPolicyTest {
|
||||
private fun ip(text: String) = InetAddress.getByName(text)
|
||||
|
||||
@Test fun wifiWithIpv4ReturnsAddress() {
|
||||
assertEquals("192.168.0.93", DeviceConnectionPolicy.selectWifiIpv4(true, listOf(ip("192.168.0.93"))))
|
||||
}
|
||||
|
||||
@Test fun nonWifiReturnsNull() {
|
||||
assertNull(DeviceConnectionPolicy.selectWifiIpv4(false, listOf(ip("10.0.0.2"))))
|
||||
}
|
||||
|
||||
@Test fun wifiWithOnlyIpv6ReturnsNull() {
|
||||
assertNull(DeviceConnectionPolicy.selectWifiIpv4(true, listOf(ip("2001:db8::1"))))
|
||||
}
|
||||
|
||||
@Test fun wifiWithMultipleAddressesPicksIpv4() {
|
||||
assertEquals(
|
||||
"172.16.1.5",
|
||||
DeviceConnectionPolicy.selectWifiIpv4(true, listOf(ip("2001:db8::1"), ip("172.16.1.5"))),
|
||||
)
|
||||
}
|
||||
|
||||
@Test fun loopbackIpv4IsSkipped() {
|
||||
assertNull(DeviceConnectionPolicy.selectWifiIpv4(true, listOf(ip("127.0.0.1"))))
|
||||
}
|
||||
|
||||
@Test fun debugRawValuesMapToState() {
|
||||
assertEquals(WirelessDebugState.ENABLED, DeviceConnectionPolicy.debugState(1))
|
||||
assertEquals(WirelessDebugState.DISABLED, DeviceConnectionPolicy.debugState(0))
|
||||
assertEquals(WirelessDebugState.UNKNOWN, DeviceConnectionPolicy.debugState(2))
|
||||
assertEquals(WirelessDebugState.UNKNOWN, DeviceConnectionPolicy.debugState(null))
|
||||
}
|
||||
|
||||
@Test fun displayEnabledUsesPrimaryColor() {
|
||||
val d = DeviceConnectionPolicy.display(DeviceConnectionReading("192.168.0.93", WirelessDebugState.ENABLED))
|
||||
assertEquals("Wi-Fi IP:192.168.0.93", d.wifiText)
|
||||
assertEquals("无线调试:已开启", d.debugText)
|
||||
assertEquals(R.color.agent_primary_light, d.debugColor)
|
||||
}
|
||||
|
||||
@Test fun displayDisabledUsesWarningColorAndNoWifiText() {
|
||||
val d = DeviceConnectionPolicy.display(DeviceConnectionReading(null, WirelessDebugState.DISABLED))
|
||||
assertEquals("Wi-Fi IP:未连接 Wi-Fi", d.wifiText)
|
||||
assertEquals("无线调试:未开启", d.debugText)
|
||||
assertEquals(R.color.agent_warning, d.debugColor)
|
||||
}
|
||||
|
||||
@Test fun displayUnknownUsesMutedColor() {
|
||||
val d = DeviceConnectionPolicy.display(DeviceConnectionReading(null, WirelessDebugState.UNKNOWN))
|
||||
assertEquals("无线调试:无法读取", d.debugText)
|
||||
assertEquals(R.color.agent_text_muted, d.debugColor)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
package cn.ilapage.goauto.agent.service
|
||||
|
||||
import cn.ilapage.goauto.agent.automation.PddLaunchEvent
|
||||
import cn.ilapage.goauto.agent.automation.PddLaunchFallback
|
||||
import cn.ilapage.goauto.agent.automation.PddLaunchPath
|
||||
import cn.ilapage.goauto.agent.automation.PddLaunchStatus
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class PddManualOpenTest {
|
||||
private class Harness(
|
||||
val busy: String? = null,
|
||||
val direct: () -> Boolean = { true },
|
||||
val browser: () -> Boolean = { true },
|
||||
) {
|
||||
val calls = mutableListOf<String>()
|
||||
var preferDirect: Boolean? = null
|
||||
val coordinator = PddManualOpenCoordinator(
|
||||
busyReason = { calls += "busy"; busy },
|
||||
invalidateHandoff = { calls += "invalidate" },
|
||||
launch = { _, prefer, diagnostic ->
|
||||
calls += "launch"
|
||||
preferDirect = prefer
|
||||
PddLaunchFallback.open(prefer, direct = { calls += "direct"; direct() },
|
||||
browser = { calls += "browser"; browser() }, diagnostic = diagnostic)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Test fun linkUsesGoodsIdOnly() {
|
||||
assertEquals("https://mobile.yangkeduo.com/goods.html?goods_id=825093359197", PddManualOpenCoordinator.link("825093359197"))
|
||||
}
|
||||
|
||||
@Test fun linkRejectsBlankOrNonDigitGoodsId() {
|
||||
listOf(null, "", " ", "12a", "1 2", "12&x=1", "-1", "123").forEach { assertNull(it, PddManualOpenCoordinator.link(it)) }
|
||||
}
|
||||
|
||||
@Test fun invalidGoodsIdNeitherChecksBusyNorInvalidatesNorOpens() {
|
||||
val h = Harness()
|
||||
assertEquals(PddManualOpenResult.InvalidGoodsId, h.coordinator.run("abc", purchase = false))
|
||||
assertTrue(h.calls.isEmpty())
|
||||
}
|
||||
|
||||
@Test fun collectionButtonOnlyUsesBrowser() {
|
||||
val h = Harness(direct = { error("unexpected") })
|
||||
assertEquals(PddManualOpenResult.Opened(null), h.coordinator.run("1", purchase = false))
|
||||
assertEquals(false, h.preferDirect)
|
||||
assertFalse(h.calls.contains("direct"))
|
||||
assertTrue(h.calls.contains("browser"))
|
||||
}
|
||||
|
||||
@Test fun purchaseButtonPrefersDirectAndStopsWhenAccepted() {
|
||||
val h = Harness(browser = { error("unexpected") })
|
||||
assertEquals(PddManualOpenResult.Opened(null), h.coordinator.run("1", purchase = true))
|
||||
assertEquals(true, h.preferDirect)
|
||||
assertFalse(h.calls.contains("browser"))
|
||||
}
|
||||
|
||||
@Test fun purchaseDirectExceptionFallsBackToBrowserWithSimpleClassName() {
|
||||
val h = Harness(direct = { throw SecurityException("secret url and address") })
|
||||
val result = h.coordinator.run("1", purchase = true)
|
||||
assertEquals(PddManualOpenResult.Opened("拼多多直接打开失败,已改用浏览器(SecurityException)"), result)
|
||||
val text = PddManualOpenCoordinator.message(result)
|
||||
assertFalse(text.contains("secret"))
|
||||
assertFalse(text.contains("http"))
|
||||
assertTrue(text.endsWith(PddManualOpenCoordinator.OPENED_HINT))
|
||||
}
|
||||
|
||||
@Test fun purchaseDirectRejectedFallbackNoticeOmitsParenthesesWithoutClass() {
|
||||
val h = Harness(direct = { false })
|
||||
assertEquals(PddManualOpenResult.Opened("拼多多直接打开失败,已改用浏览器"), h.coordinator.run("1", purchase = true))
|
||||
}
|
||||
|
||||
@Test fun allPathsFailingReportsOpenFailure() {
|
||||
val h = Harness(direct = { throw IllegalStateException("x") }, browser = { false })
|
||||
val result = h.coordinator.run("1", purchase = true)
|
||||
assertEquals(PddManualOpenResult.Failed, result)
|
||||
assertEquals("无法打开拼多多链接", PddManualOpenCoordinator.message(result))
|
||||
}
|
||||
|
||||
@Test fun fallbackNoticeNeedsAcceptedBrowser() {
|
||||
val events = listOf(
|
||||
PddLaunchEvent(PddLaunchPath.DIRECT, PddLaunchStatus.EXCEPTION, "java.lang.SecurityException"),
|
||||
PddLaunchEvent(PddLaunchPath.BROWSER, PddLaunchStatus.REJECTED),
|
||||
)
|
||||
assertNull(PddManualOpenCoordinator.fallbackNotice(events))
|
||||
}
|
||||
|
||||
@Test fun busyAtClickTimeDoesNotInvalidateOrOpen() {
|
||||
val h = Harness(busy = DeviceBusyPolicy.TASK)
|
||||
val result = h.coordinator.run("1", purchase = true)
|
||||
assertEquals(PddManualOpenResult.Busy("设备正在执行任务"), result)
|
||||
assertEquals(listOf("busy"), h.calls)
|
||||
assertEquals("设备正在执行任务,暂不能打开拼多多", PddManualOpenCoordinator.message(result))
|
||||
}
|
||||
|
||||
@Test fun handoffIsInvalidatedBeforeAnyLaunch() {
|
||||
val h = Harness()
|
||||
h.coordinator.run("1", purchase = true)
|
||||
assertEquals(listOf("busy", "invalidate", "launch", "direct"), h.calls)
|
||||
}
|
||||
|
||||
@Test fun handoffIsInvalidatedEvenWhenOpeningFails() {
|
||||
val h = Harness(direct = { false }, browser = { false })
|
||||
h.coordinator.run("1", purchase = true)
|
||||
assertTrue(h.calls.indexOf("invalidate") in 0 until h.calls.indexOf("launch"))
|
||||
}
|
||||
|
||||
@Test fun idleInputsAreNotBusy() {
|
||||
assertNull(DeviceBusyPolicy.reason(DeviceBusyPolicy.Inputs()))
|
||||
}
|
||||
|
||||
@Test fun runningTaskIsBusy() {
|
||||
assertEquals("设备正在执行任务", DeviceBusyPolicy.reason(DeviceBusyPolicy.Inputs(taskActive = true)))
|
||||
}
|
||||
|
||||
@Test fun repurchaseRoundIsBusyIncludingBetweenTasks() {
|
||||
// Between two tasks every task input is false but the round is still busy.
|
||||
assertEquals("重购进行中", DeviceBusyPolicy.reason(DeviceBusyPolicy.Inputs(repurchaseBusy = true)))
|
||||
}
|
||||
|
||||
@Test fun backfillIsBusy() {
|
||||
assertEquals("订单回填中", DeviceBusyPolicy.reason(DeviceBusyPolicy.Inputs(backfillRunning = true)))
|
||||
}
|
||||
|
||||
@Test fun currentPageCollectionIsBusy() {
|
||||
assertEquals("当前页采集进行中", DeviceBusyPolicy.reason(DeviceBusyPolicy.Inputs(currentPageCollecting = true, taskActive = true)))
|
||||
}
|
||||
|
||||
@Test fun unuploadedOrRecoverablePurchaseIsBusy() {
|
||||
assertEquals("有未上传的采购结果", DeviceBusyPolicy.reason(DeviceBusyPolicy.Inputs(unuploadedPurchase = true)))
|
||||
}
|
||||
|
||||
@Test fun repurchaseLocalIdleViewIgnoresOwnRoundButKeepsOtherWork() {
|
||||
// The service builds the repurchase-idle inputs with repurchaseBusy=false: the round never blocks itself.
|
||||
assertNull(DeviceBusyPolicy.reason(DeviceBusyPolicy.Inputs(repurchaseBusy = false)))
|
||||
assertEquals("订单回填中", DeviceBusyPolicy.reason(DeviceBusyPolicy.Inputs(repurchaseBusy = false, backfillRunning = true)))
|
||||
}
|
||||
|
||||
@Test fun collectionCooldownIsNotAnInput() {
|
||||
// A cooldown only blocks claiming new collection work; it leaves every busy input false.
|
||||
assertTrue(DeviceBusyPolicy.Inputs::class.java.declaredFields.none { it.name.contains("cooldown", ignoreCase = true) })
|
||||
}
|
||||
}
|
||||
@@ -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: d427a46283561df71714929c13b7156ee42debea
|
||||
synchronized_at: 2026-10-10T03:47:23Z
|
||||
wiki_revision: 01132608beefbecb40b62284482e3b73822fcc87
|
||||
synchronized_at: 2026-10-12T08:31:15Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
<!-- gitea-wiki-mirror:start -->
|
||||
@@ -220,6 +220,7 @@ Admin 的「创建时间」范围按 SYB 货运单列表的 `t_stock.created`(
|
||||
- 采购规则的 `probeSpecs.dimensionAliases` 是只读规格维度标题的服务端下发事实源;颜色别名可由管理员增加“组合”等 PDD 页面文案,尺码别名单独维护。该字段只影响探测和 AI 匹配输入,不能改变 `selectSpec` 的精确点击目标;旧任务快照缺省时才使用 Agent 内置兼容值。
|
||||
- PDD 采集规则的 `collector.textAliases` 是规格面板、维度标题、选择摘要、评价入口、售罄主商品证据和购买入口等只读识别文案的服务端下发事实源;缺省字段只为旧快照兼容使用 Agent 内置默认值。颜色/尺码分类不得再用代码字面量扩大 `dimensionAliases`。订单和支付文字可以参与只读页面判定,但 Agent 内不可配置的创建订单/确认订单/支付/付款点击拒绝清单始终优先,规则不能放宽;修改地址和收货地址在只读及点击字段中都禁止。
|
||||
- PDD 颜色采集对首屏已识别为多行或网格的颜色区执行动态行发现:每次动作后重新解析全部可见行,单列不做无效横向滑动,并复用规则中的 `specVerticalSwipes` 与 `stableEdgeReads` 有界纵向翻页;颜色结束后规格面板归顶,再进入尺码发现。选项重排或已选项从无障碍节点消失时,不得静默跳过仍未尝试的可用颜色。
|
||||
- #370 第 2 阶段(合并 main `26562fc`,2026-10-12;测试机回归通过,采购机未装机):横向颜色网格每次横滑后按颜色文字与位置逐帧重新定位同一行;多行共用一个横滑容器时用选项位置签名判断网格是否移动,且只有确认起点已在最左端时才跳过逐行蛇形反扫;横滑后固定等待 1.2 秒再读取;滑回最左端使用独立额度(`specHorizontalSwipes` × 2),锚点暂时丢失的重试不计入次数但有连续与累计上限。异常终止保留最早原因,仍不得静默跳过未尝试的可用颜色。只改 Android Agent 采集遍历,不改 Admin 图搜、自动关联和服务端。
|
||||
- 正式采购规则可以调用独立审核的改地址、创建待付款订单和只读核单动作;采集规则和演练规则不能调用。真机首次安装或验证仍需独立人工授权。
|
||||
- 系统当前不提供自动支付、实时屏幕或管理端远程控制。付款、免密支付及任何等价动作不能配置为点击目标;只读识别字段可以包含支付或订单文字。后续若新增支付能力,必须由独立工单和显式控制重新评估。
|
||||
|
||||
@@ -970,3 +971,24 @@ Server实现 `d84728d`、Android实现 `dbe7e98`,main 合并记录见 #378;
|
||||
Android同步诊断不改变#377的心跳→恢复→补传→必要的一次任务不一致心跳→符合条件才领任务的顺序。诊断failure是本轮观察到的同步错误,不等同于业务任务失败;恢复内部暂缓处理的API错误也记录,但不改变原恢复占位、后续flush/claim规则,不增加采购重试或付款动作。
|
||||
|
||||
可恢复诊断存储异常不阻断同步,取消和致命错误不记成假成功。同步记录独立保留24小时/6000轮,不占采集50条额度;旧进行中记录只表示中断/不完整,不伪造原现场、失败原因或成功。报告确认不会清除发送期间新增失败;本地确认失败或响应丢失允许重复,报告不能用于改变设备在线或采购状态。
|
||||
|
||||
## 规格规范化同名冲突与徽标说明合并(#379)
|
||||
|
||||
合并 main `4039fda`(2026-10-12);单元测试覆盖,测试机回归通过;采购1(PDD 8.26.0)徽标形态的真机验证待装机时完成。普通采集与采购规格探测共用同一采集器。
|
||||
|
||||
- 变更 #160 的同名冲突规则:同一维度内不同原文规范化后同名(同帧或跨帧,例如尾价时有时无)时不再整维作废,只保留一个值(先出现的原文及其价格、图片),在 missing 写 `spec_dimension_conflict:<dimensionKey>`,任务为 `completed_partial`。用户接受「重复或合并规格」的风险,不接受「整组没有尺码/颜色」。
|
||||
- 规范化后为空或仍含 `¥/¥` 的值维持整维拒绝,写 `spec_dimension_invalid:<dimensionKey>`,不放宽;服务端规范化防线不变。
|
||||
- 尺码续页路径与正常路径一样去掉尾价,冲突检查在去重之前完成。
|
||||
- #362 截短卡片合并扩展:说明文字形如「截短名[...|…] ¥金额 徽标」(徽标 1–8 个字符,不含货币符号、数字和空白)时,金额须等于外层完整名的尾价,去掉省略号后须为完整名的严格前缀,并满足 #362 全部结构条件,才与外层卡片合并;任一条件不满足行为不变。不维护徽标词表。
|
||||
- 残余风险:两个真实不同、规范化后同名、且从未同屏出现的选项会被合并为一个;同屏出现时下单因 `SPEC_TARGET_AMBIGUOUS` 失败,不会任选。
|
||||
- 未覆盖:说明文字只有徽标、不带价格(如「…零差评」)时仍可能多出一个截短重复颜色,不导致作废。
|
||||
|
||||
## Agent 详情页在拼多多中打开商品(#375)
|
||||
|
||||
合并 main `82ed666`(2026-10-12);测试机真机验证通过,采购机未装机。
|
||||
|
||||
- 采集详情与采购详情显示描边按钮「在拼多多中打开」,用任务商品编号拼 `https://mobile.yangkeduo.com/goods.html?goods_id=<编号>`;编号为空或非纯数字时不显示。用途仅为人工查看商品,不复现后台执行过程。
|
||||
- 采集按钮沿用采集的浏览器打开方式;采购按钮沿用采购方式先直达 PDD,失败回退浏览器,并提示异常简单类名。人工入口不代点浏览器中的「打开拼多多APP」,不在 PDD 内点击任何控件;提示不含链接和异常消息原文。
|
||||
- 打开由 Agent 服务执行:点击时再判断设备是否忙,忙则不打开;不忙时先作废 #373 一次性交接凭证再打开,下一次采购必须重新打开本任务链接。
|
||||
- 设备忙:重购一轮进行中(含两个任务之间)、订单回填中、当前页采集进行中、设备正在执行任务(含图搜)、有未上传或待恢复的采购结果;任务结束后的冷却期不算忙。忙时按钮置灰并显示原因,状态约每 2 秒发布,与 `repurchaseLocalIdle` 共用同一判断。
|
||||
- 不暂停领任务,不改调度、心跳和租约;查看期间来了新任务由 Agent 接管屏幕,打开后提示「打开期间如有新任务,Agent 会接管屏幕」。不上传打开结果,不写日志或诊断。
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Troubleshooting
|
||||
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Troubleshooting
|
||||
wiki_revision: d06fa26d85339641e3d67e5ee6314205173c24c5
|
||||
synchronized_at: 2026-10-10T03:10:51Z
|
||||
wiki_revision: 50f5923623db34cddb9ea152238c11669a25eb95
|
||||
synchronized_at: 2026-10-12T08:31:42Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 故障排查
|
||||
@@ -220,3 +220,27 @@ ORDER BY id;
|
||||
- 一轮中观察到步骤失败(包括恢复内部暂缓处理的500/409),诊断可记failure,即使业务随后恢复连接;这不是采集/采购任务失败,也不能据此重下单。已正常收敛的明确拒收仍按#377处理。
|
||||
- timeout_connect/timeout_response/timeout_unknown根据请求实际阶段分类,未知阶段不猜。诊断故障只输出sync_diagnostic_persistence_failed或sync_diagnostic_context_failed固定提示;不附错误正文、URL或SQL参数。
|
||||
- reported只表示Agent已收到该报告请求的成功响应并在本地确认,不保证Server历史写入成功;响应丢失或确认失败可能重复补报。
|
||||
|
||||
## Android 颜色横向网格遍历(#370 第 2 阶段)
|
||||
|
||||
实现已合并 main(`26562fc`)。遍历过程只写 `Log.i`(标签 `GoAutoCollector`),不落库、不上报;排查时在已授权的 Debug 设备上实时抓取,用完删除:
|
||||
|
||||
```powershell
|
||||
adb -s <serial> logcat -v time -s GoAutoCollector:V
|
||||
```
|
||||
|
||||
- `color-start confirmed= swipes= anchorRetries= end=`:滑回最左端的结果;`confirmed=false` 时不会跳过逐行反扫。
|
||||
- `color-row row= dir= match= moved= others= sharedMoves= sharedBroken= uncertain= signatureReads=`:每次横滑后的行定位与是否移动;`others=moved` 表示多行共用一个横滑容器。
|
||||
- `color-row-end row= end=`:该行结束原因(如 `edge` 到达边缘)。
|
||||
- `color-settle waitedMs=1200`:横滑后的固定等待。
|
||||
- `color-click-miss`:点击目标未生效。
|
||||
- 漏色时先核对同一设备、Agent/PDD 版本和规则快照,再看 `color-row-end` 是否过早出现或 `color-start` 是否未确认。日志缓冲较小时可能被覆盖,缺少日志不能证明没执行。
|
||||
|
||||
## 采购探测报「没有找到可采购的 PDD 颜色或尺码」(#379)
|
||||
|
||||
服务端只看规格候选是否为空,不读 missing,该提示可能掩盖「某维度被整维作废」的真实原因:
|
||||
|
||||
1. 查同一次探测或采集结果的 missing:有 `spec_dimension_invalid:color|size` 表示该维度被整维清空(规范化后为空或仍含货币符号);`spec_dimension_conflict:*` 表示同名冲突已保留一个值,维度仍可用。
|
||||
2. 已知成因(2026-10-12,采购1 / PDD 8.26.0 / 商品 715527668284):颜色卡片的截短说明文字在价格后带「零差评」「最多人选择」等徽标,旧版本无法与外层卡片合并,说明值仍含 `¥` 导致颜色整维作废。#379 后满足条件的徽标说明会合并;同一商品在其他设备或 PDD 版本上可能不出现徽标,不能用测试机结果否定。
|
||||
3. 实时日志 `spec-normalize dim= outcome= reason= path= pass= sameFrame= onlyTrailingPriceDiff= rawVariants= candidates= badgeCaptionMerged=`:`outcome=invalid_rejected` 对应整维作废,`reason=unsafe_currency|empty`;`outcome=conflict_kept` 对应同名冲突保留;`badgeCaptionMerged` 为本帧合并的徽标卡片数。只含计数和枚举,不含规格原文。每维每类只记录首次。
|
||||
4. 若需看规格面板原文,只在用户手动打开面板后做一次只读 uiautomator 导出并过滤地址、电话,用完立即删除;不得为打开面板点击购买类按钮。
|
||||
|
||||
@@ -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: 1dc3c7550c0c4da4291e08b840170d861042e97a
|
||||
synchronized_at: 2026-10-10T03:10:58Z
|
||||
wiki_revision: c05446aecd49fb7234f79f5d5e2df68f244166e9
|
||||
synchronized_at: 2026-10-12T08:31:57Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
<!-- gitea-wiki-mirror:start -->
|
||||
@@ -421,6 +421,7 @@ POST /api/agent/v1/tasks/{taskId}/result
|
||||
- size 规格值只允许剥离位于文本尾部的 ¥ / ¥ 数字价格片段,括号说明、体重区间和其他规格身份文本原样保留;Android 与服务端使用相同语义。
|
||||
- 服务端在结果完整性校验前执行规范化。规范化后为空、同维度重复、仍残留货币符号或无法由最小安全规则确定时,整维不接受,并原子丢弃引用该维度的 SKU;color 被拒时同时丢弃 colorPrices。
|
||||
- 服务端强制将任务收敛为 completed_partial,并在 missing 增加稳定值 spec_dimension_invalid:<dimensionKey>,例如 spec_dimension_invalid:size。客户端应把它展示为对应规格维度无法安全采集,不得视为完整档案,也不得自行猜测或补齐候选。
|
||||
- #379 起 Android 在提交前处理规范化同名冲突:同一维度不同原文规范化后同名时只提交一个值,并在 missing 增加稳定值 `spec_dimension_conflict:<dimensionKey>`(任务为 completed_partial,不新增字段)。它表示该维度可能把规范化后同名的选项合并为一个,维度本身仍可用;残留货币符号或空值仍由 Android 整维清空并写 `spec_dimension_invalid:<dimensionKey>`。服务端只保存该 missing 值、不校验其种类;图搜自动关联白名单仍只含 reviewCount、salesText、shopName。
|
||||
- 被拒维度不得以污染值覆盖商品已有档案;历史任务和既有商品数据不自动清洗,须在修复后的 Agent 上重新采集。
|
||||
|
||||
## Android:提交失败
|
||||
|
||||
@@ -131,6 +131,9 @@ func aiMatchQualificationForDataset(id uint64, dataset batchPreviewDataset) aiMa
|
||||
if !found {
|
||||
return disabled("SYB 商品不存在或已删除")
|
||||
}
|
||||
if sybCancelled(syb) {
|
||||
return disabled(sybCancelledReason)
|
||||
}
|
||||
if syb.PDDExcluded {
|
||||
return disabled("该商品已标记为无需 PDD 采购")
|
||||
}
|
||||
|
||||
@@ -403,6 +403,12 @@ func (s *Service) previewFromDataset(id uint64, dataset batchPreviewDataset, gua
|
||||
// is the single computation shared by BatchPreview, BatchCreate's
|
||||
// pre-check and quick-replace's ValidateQuickReplacement, so this one
|
||||
// check covers all of them.
|
||||
// #381: a cancelled SYB order is blocked the same way and takes precedence
|
||||
// for the reason shown (matches the process stage priority).
|
||||
if sybCancelled(syb) {
|
||||
item.ReasonCode, item.Reason, item.NextAction = CodeSYBCancelled, sybCancelledReason, ""
|
||||
return item
|
||||
}
|
||||
if syb.PDDExcluded {
|
||||
item.ReasonCode, item.Reason, item.NextAction = CodePDDExcluded, "该商品已标记为无需 PDD 采购", ""
|
||||
return item
|
||||
|
||||
@@ -9,6 +9,10 @@ func (item *BatchPreviewItem) applyCollectionEligibility(id uint64, dataset batc
|
||||
item.CollectionDisabledReason = "SYB 商品不存在或已删除"
|
||||
return
|
||||
}
|
||||
if sybCancelled(syb) {
|
||||
item.CollectionDisabledReason = sybCancelledReason
|
||||
return
|
||||
}
|
||||
if syb.PDDExcluded {
|
||||
item.CollectionDisabledReason = "该商品已标记为无需 PDD 采购"
|
||||
return
|
||||
|
||||
@@ -31,7 +31,12 @@ const (
|
||||
// pdd_purchase_excluded: it never needs a PDD purchase. It only yields to
|
||||
// an active return match (checked first, same priority order #338 uses
|
||||
// for the pipeline below it) — see processStageFromDataset.
|
||||
ProcessStagePDDExcluded = "pdd_excluded"
|
||||
ProcessStagePDDExcluded = "pdd_excluded"
|
||||
// ProcessStageSYBCancelled is #381's stage for a cancelled SYB order
|
||||
// (syb_is_cancelled = true). It yields to an existing task/order result
|
||||
// and an active return match, and wins over pdd_excluded. It is not in
|
||||
// returnmatch.participatingStages, so cancelled rows are not matched.
|
||||
ProcessStageSYBCancelled = "syb_cancelled"
|
||||
processActionOpenPDDLink = "open_pdd_link"
|
||||
)
|
||||
|
||||
@@ -49,6 +54,7 @@ var processStageLabels = map[string]string{
|
||||
ProcessStageReturnPending: "退货待确认",
|
||||
ProcessStageReturnUsed: "已用退货",
|
||||
ProcessStagePDDExcluded: "无需采购",
|
||||
ProcessStageSYBCancelled: "已取消",
|
||||
}
|
||||
|
||||
type ProcessStageResult struct {
|
||||
@@ -157,6 +163,11 @@ func processStageFromDataset(id uint64, dataset batchPreviewDataset, preview Bat
|
||||
return stage(ProcessStageReturnPending, "已匹配退货待人工确认,暂不能创建采购任务", "open_return_match")
|
||||
}
|
||||
|
||||
// #381: a cancelled SYB order comes next, before the #340 exclusion mark.
|
||||
if syb, ok := dataset.sybByID[id]; ok && sybCancelled(syb) {
|
||||
return stage(ProcessStageSYBCancelled, "SYB 订单已取消,无需 PDD 采购", "")
|
||||
}
|
||||
|
||||
// #340: an excluded row's mark also only overrides after the active-task
|
||||
// and active-return-match checks above, and before every other pipeline
|
||||
// branch below.
|
||||
|
||||
@@ -29,6 +29,24 @@ func rejectIfActiveReturnMatch(tx *gorm.DB, sybProductID uint64) error {
|
||||
return internal(err)
|
||||
}
|
||||
|
||||
// sybCancelled reports #381's SYB cancelled mark: only an explicit true
|
||||
// blocks; NULL (not fetched) and false do not.
|
||||
func sybCancelled(syb models.SYBProduct) bool {
|
||||
return syb.SYBIsCancelled != nil && *syb.SYBIsCancelled
|
||||
}
|
||||
|
||||
// sybCancelledReason is the shared user-facing reason for #381 blocks.
|
||||
const sybCancelledReason = "SYB 订单已取消,无需采购"
|
||||
|
||||
// rejectIfSYBCancelled implements #381's hard purchase-creation block for a
|
||||
// cancelled SYB order (single and batch share s.Create).
|
||||
func rejectIfSYBCancelled(syb models.SYBProduct) error {
|
||||
if sybCancelled(syb) {
|
||||
return fail(CodeSYBCancelled, sybCancelledReason)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// rejectIfPDDExcluded implements #340's hard purchase-creation block: a SYB
|
||||
// product marked pdd_purchase_excluded must never get a purchase task,
|
||||
// single or batch (BatchCreate calls s.Create per row, so this one check
|
||||
|
||||
@@ -184,6 +184,9 @@ func (s *Service) create(ctx context.Context, req CreateRequest) (models.Purchas
|
||||
if err := rejectIfPDDExcluded(syb); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := rejectIfSYBCancelled(syb); err != nil {
|
||||
return err
|
||||
}
|
||||
if syb.ShopeeProductID == nil {
|
||||
return fail(CodeInvalidRequest, "该商品尚未关联蝦皮商品")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
package purchase
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go-admin/app/goauto/models"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// #381: a cancelled SYB order (syb_is_cancelled = true) must be blocked by the
|
||||
// PDD entry points; NULL and false must not block.
|
||||
|
||||
func setSYBCancelled(t *testing.T, db *gorm.DB, id uint64, value any) {
|
||||
t.Helper()
|
||||
if err := db.Model(&models.SYBProduct{}).Where("id = ?", id).Update("syb_is_cancelled", value).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreate_RejectsWhenSYBCancelled(t *testing.T) {
|
||||
db := testDB(t)
|
||||
s := testService(db)
|
||||
f := seed(t, db, liveCaps(), true)
|
||||
setSYBCancelled(t, db, f.syb.ID, true)
|
||||
|
||||
_, err := createLive(t, s, f)
|
||||
if se, ok := asServiceError(err); !ok || se.Code != CodeSYBCancelled {
|
||||
t.Fatalf("expected CodeSYBCancelled, got %v", err)
|
||||
}
|
||||
var count int64
|
||||
db.Model(&models.PurchaseTask{}).Where("syb_product_id = ?", f.syb.ID).Count(&count)
|
||||
if count != 0 {
|
||||
t.Fatalf("no task may be created, got %d", count)
|
||||
}
|
||||
|
||||
// Excluded as well: creation is still rejected.
|
||||
if err := db.Model(&models.SYBProduct{}).Where("id = ?", f.syb.ID).Update("pdd_purchase_excluded", true).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := createLive(t, s, f); err == nil {
|
||||
t.Fatalf("cancelled+excluded creation must still be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreate_NotBlockedWhenSYBCancelledFalse(t *testing.T) {
|
||||
db := testDB(t)
|
||||
s := testService(db)
|
||||
f := seed(t, db, liveCaps(), true)
|
||||
setSYBCancelled(t, db, f.syb.ID, false)
|
||||
if _, err := createLive(t, s, f); err != nil {
|
||||
t.Fatalf("false must not block: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreate_NotBlockedWhenSYBCancelledNull(t *testing.T) {
|
||||
db := testDB(t)
|
||||
s := testService(db)
|
||||
f := seed(t, db, liveCaps(), true)
|
||||
if f.syb.SYBIsCancelled != nil {
|
||||
t.Fatalf("fixture must have NULL cancel state")
|
||||
}
|
||||
if _, err := createLive(t, s, f); err != nil {
|
||||
t.Fatalf("NULL must not block: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchPreview_CancelledRowReportsReason(t *testing.T) {
|
||||
db := testDB(t)
|
||||
s := testService(db)
|
||||
f := seed(t, db, liveCaps(), true)
|
||||
setCollectedPDDPrice(t, db, f.pdd.ID)
|
||||
setSYBCancelled(t, db, f.syb.ID, true)
|
||||
// Excluded as well: the cancelled reason and stage win.
|
||||
if err := db.Model(&models.SYBProduct{}).Where("id = ?", f.syb.ID).Update("pdd_purchase_excluded", true).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
resp, err := s.BatchPreview(context.Background(), BatchPreviewRequest{SYBProductIDs: []uint64{f.syb.ID}, DeviceID: &f.device.ID})
|
||||
if err != nil || len(resp.Items) != 1 {
|
||||
t.Fatalf("preview: %v %+v", err, resp)
|
||||
}
|
||||
item := resp.Items[0]
|
||||
if item.Eligible || item.CollectionEligible || item.AIMatchEligible {
|
||||
t.Fatalf("cancelled row must not be eligible: %+v", item)
|
||||
}
|
||||
if item.ReasonCode != CodeSYBCancelled || item.Reason != sybCancelledReason {
|
||||
t.Fatalf("expected cancelled reason, got %+v", item)
|
||||
}
|
||||
if item.CollectionDisabledReason != sybCancelledReason || item.AIMatchDisabledReason != sybCancelledReason {
|
||||
t.Fatalf("expected cancelled disabled reasons, got %+v", item)
|
||||
}
|
||||
if item.ProcessStage != ProcessStageSYBCancelled || item.ProcessStageLabel != "已取消" {
|
||||
t.Fatalf("expected syb_cancelled stage, got %+v", item)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchCreate_SkipsCancelledRowNotWholeBatch(t *testing.T) {
|
||||
db := testDB(t)
|
||||
s := testService(db)
|
||||
f1 := seed(t, db, liveCaps(), true)
|
||||
setCollectedPDDPrice(t, db, f1.pdd.ID)
|
||||
syb2 := models.SYBProduct{OrderCode: "SYB-CANCEL-2", DetailID: 2, StockID: 3, ShopeeItemID: f1.shopee.ShopeeItemID, ShopeeProductID: &f1.shopee.ID, ProductTitle: f1.shopee.Title, TargetColor: "黑色", TargetSize: "XL", Quantity: 1, UnitPriceCent: 2000, ParseStatus: models.SYBParseStatusSuccess, RawJSON: `{}`}
|
||||
if err := db.Create(&syb2).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
setSYBCancelled(t, db, f1.syb.ID, true)
|
||||
|
||||
resp, err := s.BatchCreate(context.Background(), BatchCreateRequest{
|
||||
RequestID: uuid.NewString(), SYBProductIDs: []uint64{f1.syb.ID, syb2.ID}, DeviceID: &f1.device.ID,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if resp.CreatedCount != 1 || resp.FailedCount != 1 {
|
||||
t.Fatalf("expected 1 created + 1 failed, got %+v", resp)
|
||||
}
|
||||
for _, item := range resp.Items {
|
||||
if item.SYBProductID == f1.syb.ID && item.Created {
|
||||
t.Fatalf("cancelled row must not be created: %+v", item)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessStage_CancelledPriorityAndRecovery(t *testing.T) {
|
||||
db := testDB(t)
|
||||
s := testService(db)
|
||||
f := seed(t, db, liveCaps(), true)
|
||||
stageOf := func() ProcessStageResult {
|
||||
t.Helper()
|
||||
stages, err := s.ProcessStages(context.Background(), []uint64{f.syb.ID})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return stages[f.syb.ID]
|
||||
}
|
||||
before := stageOf()
|
||||
if before.Stage == ProcessStageSYBCancelled {
|
||||
t.Fatalf("unexpected cancelled stage before marking: %+v", before)
|
||||
}
|
||||
if !ValidProcessStage(ProcessStageSYBCancelled) {
|
||||
t.Fatalf("syb_cancelled must be a valid filter value")
|
||||
}
|
||||
|
||||
setSYBCancelled(t, db, f.syb.ID, true)
|
||||
if got := stageOf(); got.Stage != ProcessStageSYBCancelled || got.Label != "已取消" {
|
||||
t.Fatalf("expected syb_cancelled, got %+v", got)
|
||||
}
|
||||
|
||||
// An active return match wins over cancelled.
|
||||
match := models.ReturnMatch{SYBProductID: f.syb.ID, YeekeReturnItemID: 1, ActiveSYBProductID: &f.syb.ID, Status: models.ReturnMatchStatusMatched, MatchedAt: time.Now()}
|
||||
if err := db.Create(&match).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := stageOf(); got.Stage != ProcessStageReturnPending {
|
||||
t.Fatalf("return match must win over cancelled, got %+v", got)
|
||||
}
|
||||
if err := db.Model(&models.ReturnMatch{}).Where("id = ?", match.ID).
|
||||
Updates(map[string]any{"active_syb_product_id": nil, "status": models.ReturnMatchStatusCancelled}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Recovery: false restores the original stage.
|
||||
setSYBCancelled(t, db, f.syb.ID, false)
|
||||
if got := stageOf(); got.Stage != before.Stage {
|
||||
t.Fatalf("recovery should restore %s, got %+v", before.Stage, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessStage_ExistingTaskWinsOverCancelled(t *testing.T) {
|
||||
db := testDB(t)
|
||||
s := testService(db)
|
||||
f := seed(t, db, liveCaps(), true)
|
||||
setCollectedPDDPrice(t, db, f.pdd.ID)
|
||||
task, err := createLive(t, s, f)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
setSYBCancelled(t, db, f.syb.ID, true)
|
||||
stages, err := s.ProcessStages(context.Background(), []uint64{f.syb.ID})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := stages[f.syb.ID]; got.Stage != ProcessStageTaskCreated || got.ActiveTaskID == nil || *got.ActiveTaskID != task.ID {
|
||||
t.Fatalf("existing task must win over cancelled, got %+v", got)
|
||||
}
|
||||
}
|
||||
@@ -48,7 +48,11 @@ const (
|
||||
// time and is recorded as 「无需 PDD 采购」, so it must never start any
|
||||
// PDD flow, regardless of the rest of its pipeline state.
|
||||
CodePDDExcluded = "PURCHASE_PDD_EXCLUDED"
|
||||
CodeInternal = "INTERNAL_ERROR"
|
||||
// CodeSYBCancelled is returned by the PDD entry points when the SYB order
|
||||
// is cancelled (syb_is_cancelled = true, #381). NULL (unknown) and false
|
||||
// never block. Existing purchase tasks are not affected.
|
||||
CodeSYBCancelled = "PURCHASE_SYB_CANCELLED"
|
||||
CodeInternal = "INTERNAL_ERROR"
|
||||
)
|
||||
|
||||
type ServiceError struct {
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
package returnmatch
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go-admin/app/goauto/models"
|
||||
)
|
||||
|
||||
// #381: a cancelled SYB order has stage syb_cancelled, which is not a
|
||||
// participating stage, so return matching must skip it; recovery (false)
|
||||
// makes it eligible again.
|
||||
func TestBatchMatch_CancelledRowSkippedAndRecovers(t *testing.T) {
|
||||
db := testDB(t)
|
||||
s := NewService(db)
|
||||
s.Now = func() time.Time { return time.Date(2026, 9, 24, 0, 0, 0, 0, time.UTC) }
|
||||
deadline := time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC)
|
||||
syb := seedSYB(t, db, "SYB-CANCEL-1", 1, "白色", "L", time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC))
|
||||
seedReturn(t, db, "白色,L【建議65-75公斤】", &deadline)
|
||||
if err := db.Model(&models.SYBProduct{}).Where("id = ?", syb.ID).Update("syb_is_cancelled", true).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
resp, err := s.BatchMatch(context.Background(), BatchMatchRequest{SYBProductIDs: []uint64{syb.ID}, Operator: "tester"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if resp.MatchedCount != 0 || len(resp.Items) != 1 || resp.Items[0].Matched {
|
||||
t.Fatalf("cancelled row must not be matched: %+v", resp)
|
||||
}
|
||||
|
||||
if err := db.Model(&models.SYBProduct{}).Where("id = ?", syb.ID).Update("syb_is_cancelled", false).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp, err = s.BatchMatch(context.Background(), BatchMatchRequest{SYBProductIDs: []uint64{syb.ID}, Operator: "tester"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if resp.MatchedCount != 1 {
|
||||
t.Fatalf("after recovery the row must be matchable: %+v", resp)
|
||||
}
|
||||
}
|
||||
@@ -159,6 +159,11 @@ func (service *Service) BatchCreateImageSearch(ctx context.Context, request Imag
|
||||
continue
|
||||
}
|
||||
// #340: hard-exclude before grouping/creating any image search task.
|
||||
// #381: cancelled SYB order, same hard block.
|
||||
if row.SYBIsCancelled != nil && *row.SYBIsCancelled {
|
||||
response.Items = append(response.Items, ImageSearchBatchItem{SYBProductIDs: []uint64{id}, Code: "PURCHASE_SYB_CANCELLED", Message: "SYB 订单已取消,无需采购"})
|
||||
continue
|
||||
}
|
||||
if row.PDDExcluded {
|
||||
response.Items = append(response.Items, ImageSearchBatchItem{SYBProductIDs: []uint64{id}, Code: "PURCHASE_PDD_EXCLUDED", Message: "该商品已标记为无需 PDD 采购"})
|
||||
continue
|
||||
|
||||
@@ -42,3 +42,34 @@ func TestBatchCreateImageSearchRejectsExcludedRow(t *testing.T) {
|
||||
t.Fatalf("expected PURCHASE_PDD_EXCLUDED, got %+v", response.Items[0])
|
||||
}
|
||||
}
|
||||
|
||||
// #381: a cancelled SYB order is rejected per row with PURCHASE_SYB_CANCELLED
|
||||
// without fetching an image or creating a task.
|
||||
func TestBatchCreateImageSearchRejectsCancelledRow(t *testing.T) {
|
||||
db := openTaskDatabase(t)
|
||||
rule := models.CollectionRule{Name: "image-search-cancelled", ContentJSON: v2TaskRuleSnapshot()}
|
||||
if err := db.Create(&rule).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
shopee := models.ShopeeProduct{ShopeeItemID: "cancel-1", Title: "t", Currency: "CNY", SpecsJSON: "[]"}
|
||||
if err := db.Create(&shopee).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cancelled := true
|
||||
syb := models.SYBProduct{OrderCode: "ORD-CANCEL", DetailID: 1, StockID: 1, ShopeeItemID: shopee.ShopeeItemID, ShopeeProductID: &shopee.ID, Quantity: 1, UnitPriceCent: 100, ImageURL: "https://example.invalid/c.jpg", ParseStatus: "success", RawJSON: "{}", SYBIsCancelled: &cancelled}
|
||||
if err := db.Create(&syb).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
service := NewService(db)
|
||||
service.FetchImageSearchImage = func(ctx context.Context, url string) (ImageSearchImage, error) {
|
||||
t.Fatalf("must not fetch image for a cancelled row")
|
||||
return ImageSearchImage{}, nil
|
||||
}
|
||||
response, err := service.BatchCreateImageSearch(context.Background(), ImageSearchBatchRequest{RequestID: uuid.NewString(), SYBProductIDs: []uint64{syb.ID}, RuleID: rule.ID})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if response.SuccessCount != 0 || len(response.Items) != 1 || response.Items[0].Code != "PURCHASE_SYB_CANCELLED" {
|
||||
t.Fatalf("expected PURCHASE_SYB_CANCELLED, got %+v", response)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -449,7 +449,7 @@ export default {
|
||||
purchaseDeviceUnavailable() { return !!this.purchaseDialog.deviceId && !this.purchaseDevices.some(device => device.id === this.purchaseDialog.deviceId) },
|
||||
purchaseImageSearchLinkedCount() { return this.purchaseDialog.items.filter(item => item.imageSearchLinked).length },
|
||||
canPurchase() { const roles = this.$store.getters.roles || []; return roles.includes('admin') || roles.includes('purchaser') || roles.includes('after_sales') },
|
||||
processStageOptions() { return [{ value: 'manual_action', label: '待人工处理' }, { value: 'pdd_unlinked', label: '未关联 PDD' }, { value: 'pdd_pending', label: 'PDD 待采集' }, { value: 'pdd_collecting', label: 'PDD 采集中' }, { value: 'pdd_collection_failed', label: 'PDD 采集失败' }, { value: 'color_mapping', label: '规格待匹配' }, { value: 'purchase_ready', label: '可创建采购' }, { value: 'task_created', label: '已创建任务' }, { value: 'purchase_succeeded', label: '采购成功' }, { value: 'order_review', label: '待人工核对' }, { value: 'return_pending', label: '退货待确认' }, { value: 'return_used', label: '已用退货' }, { value: 'pdd_excluded', label: '无需采购' }] },
|
||||
processStageOptions() { return [{ value: 'manual_action', label: '待人工处理' }, { value: 'pdd_unlinked', label: '未关联 PDD' }, { value: 'pdd_pending', label: 'PDD 待采集' }, { value: 'pdd_collecting', label: 'PDD 采集中' }, { value: 'pdd_collection_failed', label: 'PDD 采集失败' }, { value: 'color_mapping', label: '规格待匹配' }, { value: 'purchase_ready', label: '可创建采购' }, { value: 'task_created', label: '已创建任务' }, { value: 'purchase_succeeded', label: '采购成功' }, { value: 'order_review', label: '待人工核对' }, { value: 'return_pending', label: '退货待确认' }, { value: 'return_used', label: '已用退货' }, { value: 'pdd_excluded', label: '无需采购' }, { value: 'syb_cancelled', label: '已取消' }] },
|
||||
returnMatchCandidateIds() { return this.selectedProducts.filter(row => this.isReturnMatchCandidate(row)).map(row => row.id) },
|
||||
aiMatchCandidates() { return this.selectedProducts.filter(row => this.isAIMatchCandidate(row)) },
|
||||
aiMatchButtonOverLimit() { return this.aiMatchCandidates.length > PURCHASE_BATCH_MAX },
|
||||
@@ -572,11 +572,11 @@ export default {
|
||||
if (this.query.page !== this.loadedPage || this.query.pageSize !== this.loadedPageSize) this.load()
|
||||
}, 0)
|
||||
},
|
||||
// #340: 选择「退货待确认/已用退货/无需采购」这些不属于「需 PDD 采购」默认范围
|
||||
// #340/#381: 选择「退货待确认/已用退货/无需采购/已取消」这些不属于「需 PDD 采购」默认范围
|
||||
// 的阶段时,自动把采购类型切到「全部」,否则处理阶段过滤会和采购类型过滤
|
||||
// 的 AND 组合出空列表;用户仍可再手动切回。
|
||||
onProcessStageChange(value) {
|
||||
if (['return_pending', 'return_used', 'pdd_excluded'].includes(value) && this.query.purchaseType !== 'all') {
|
||||
if (['return_pending', 'return_used', 'pdd_excluded', 'syb_cancelled'].includes(value) && this.query.purchaseType !== 'all') {
|
||||
this.query.purchaseType = 'all'
|
||||
}
|
||||
},
|
||||
@@ -614,10 +614,12 @@ export default {
|
||||
// 图搜采集的用途就是给未关联 PDD 的虾皮商品找到 PDD 商品,所以它的可勾选条件
|
||||
// 不能要求已关联——另外三个判据都要求,于是图搜入口对它最该服务的那类商品
|
||||
// 一直不可达(#291)。这里与 imageSearchRows 的过滤条件保持一致。
|
||||
isImageSearchCandidate(row) { return !this.isActivelyReturnMatched(row) && !this.isPDDExcluded(row) && Boolean(row.shopeeProductId && row.imageUrl) },
|
||||
isImageSearchCandidate(row) { return !this.isActivelyReturnMatched(row) && !this.isPDDExcluded(row) && !this.isSYBCancelled(row) && Boolean(row.shopeeProductId && row.imageUrl) },
|
||||
// #340: pdd_purchase_excluded 商品仍可参与退货匹配(服务端 returnmatch 把
|
||||
// pdd_excluded 加入了 participatingStages),但不参与其他任何 PDD 相关操作。
|
||||
isPDDExcluded(row) { return this.purchaseReady(row).processStage === 'pdd_excluded' },
|
||||
// #381: SYB 订单已取消的明细不进入任何 PDD 流程(也不参与退货匹配)。
|
||||
isSYBCancelled(row) { return this.purchaseReady(row).processStage === 'syb_cancelled' },
|
||||
// #338/#340: 参与匹配退货的处理阶段范围(服务端 returnmatch.participatingStages 同一口径)。
|
||||
isReturnMatchCandidate(row) {
|
||||
const participating = new Set(['pdd_unlinked', 'pdd_pending', 'pdd_collecting', 'pdd_collection_failed', 'color_mapping', 'purchase_ready', 'pdd_excluded'])
|
||||
@@ -635,7 +637,7 @@ export default {
|
||||
return `${item.excludedRuleKind === 'char' ? '结构' : '关键词'} ${item.excludedRuleKeyword}`
|
||||
},
|
||||
purchasePriceText(item) { if (item.minUnitPriceCent === undefined || item.maxUnitPriceCent === undefined) return ''; return `允许单价 ¥${(item.minUnitPriceCent / 100).toFixed(2)}~¥${(item.maxUnitPriceCent / 100).toFixed(2)}` },
|
||||
processMeta(stage) { return { manual_action: { label: '待人工处理', type: 'warning' }, pdd_unlinked: { label: '未关联 PDD', type: 'info' }, pdd_pending: { label: 'PDD 待采集', type: 'info' }, pdd_collecting: { label: 'PDD 采集中', type: 'primary' }, pdd_collection_failed: { label: 'PDD 采集失败', type: 'danger' }, color_mapping: { label: '规格待匹配', type: 'warning' }, purchase_ready: { label: '可创建采购', type: 'success' }, task_created: { label: '已创建任务', type: 'primary' }, purchase_succeeded: { label: '采购成功', type: 'success' }, order_review: { label: '待人工核对', type: 'danger' }, return_pending: { label: '退货待确认', type: 'warning' }, return_used: { label: '已用退货', type: 'info' }, pdd_excluded: { label: '无需采购', type: 'info' }}[stage] || { label: '待人工处理', type: 'warning' } },
|
||||
processMeta(stage) { return { manual_action: { label: '待人工处理', type: 'warning' }, pdd_unlinked: { label: '未关联 PDD', type: 'info' }, pdd_pending: { label: 'PDD 待采集', type: 'info' }, pdd_collecting: { label: 'PDD 采集中', type: 'primary' }, pdd_collection_failed: { label: 'PDD 采集失败', type: 'danger' }, color_mapping: { label: '规格待匹配', type: 'warning' }, purchase_ready: { label: '可创建采购', type: 'success' }, task_created: { label: '已创建任务', type: 'primary' }, purchase_succeeded: { label: '采购成功', type: 'success' }, order_review: { label: '待人工核对', type: 'danger' }, return_pending: { label: '退货待确认', type: 'warning' }, return_used: { label: '已用退货', type: 'info' }, pdd_excluded: { label: '无需采购', type: 'info' }, syb_cancelled: { label: '已取消', type: 'info' }}[stage] || { label: '待人工处理', type: 'warning' } },
|
||||
purchaseActionLabel(item) { return { open_pdd_link: '去关联', open_mapping: '去匹配', open_shopee: '查看蝦皮商品', open_pdd: '查看 PDD 商品', open_task: '查看任务', reparse: '查看并处理', select_device: '重新选择设备', refresh: '刷新' }[item.nextAction] || '' },
|
||||
// #339: the page can now show up to 500 rows, but the preview endpoint
|
||||
// (previewPurchaseTasks -> purchase/batch.go maxBatchPurchaseItems) still
|
||||
|
||||
Reference in New Issue
Block a user