Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d3d3837014 | ||
|
|
a0d2416c49 | ||
|
|
23dafcf73f | ||
|
|
3d5e9646f6 |
+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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,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: Deployment-and-Operations
|
||||
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Deployment-and-Operations.-
|
||||
wiki_revision: b50250e0d61eff0b33f468e7cdf74139fe76bb65
|
||||
synchronized_at: 2026-10-10T03:10:43Z
|
||||
wiki_revision: d08a0dfbb8356f9cd7212d495e4f11beca59e293
|
||||
synchronized_at: 2026-10-10T03:56:35Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
<!-- gitea-wiki-mirror:start -->
|
||||
@@ -409,3 +409,13 @@ Server实现 `d84728d`、Android实现 `dbe7e98`,main 合并记录见 #378;
|
||||
- 本地Android验证(cwd仓库根,JDK17及现有Android SDK):`./android/gradlew.bat -p android :app:testDebugUnitTest --tests '*HeartbeatReport*Test' --tests '*AgentTransportTimeoutTest' --tests '*SyncDiagnostic*Test' --tests '*HandledRecoveryDiagnosticTest' --tests '*AgentDiagnosticStoreMigrationTest' --tests '*AgentSyncCycleTest' :app:assembleDebug :app:compileReleaseUnitTestKotlin`。发布前还应执行#377受影响的outbox、运行占位、互斥、重购等回归;SQLite JDBC验证不等同于真机升级。
|
||||
- Android兼容性测试在`android/build/diagnostics378-client-reports.json`生成纯合成HTTP捕获报告;在server目录将该绝对路径注入`GOAUTO_378_ANDROID_REPORTS`,执行`go test ./app/goauto/device -run TestAndroidGeneratedHeartbeatReports -count=1 -v`,使用真实validator/handler验证三类报告与重放。未设置路径时该项明确skip,不得据此宣称跨端通过;产物不提交Git。
|
||||
- 线上迁移、发布、重启、装机仍需单独授权;本地构建/夹具不代表真机或线上30分钟心跳验收。
|
||||
|
||||
|
||||
## #372 v3 导出店铺列的纯 Web 发布(2026-10-10)
|
||||
|
||||
- 用户授权合并 #372 v3 并部署线上。`feat/372-export-shop` 以 `--no-ff` 合并为 main `3d5e964`;Web 单元测试 93/93 通过,`build:prod` 通过。相对上一线上 Web(`4ad59bd`),Web 只有 v3 的导出改动。
|
||||
- **main 已含 #378 的 Server 代码和迁移,本次没有部署**:线上 Server 仍是 `72a15f8` 构建的二进制。#378 的 Server 发布和迁移需另行授权。
|
||||
- 发布目录 `/home/goauto/releases/20261010-3d5e964-372v3-web`,只替换 dist。goauto-server 与 config 从上一发布逐字复制(cmp/diff 一致),static/temp/var 沿用原目录,旧 js/css 哈希资源保留。index.html SHA256 `b900a40ec4691f0fb2c3d89e1e20826e7ca3205db311a33c24087767a515979a`。
|
||||
- 只原子切换 current,不重启 GoAuto/Nginx;切换前后 PID 4786 不变,服务 active。
|
||||
- 按内容验收:`/` 与 `/login` 返回新 index,10 项 JS/CSS 200,线上导出模块的 SYB 表头含「店铺」,health 200,验证码 code=200,未登录业务接口 401。登录后的实际导出待业务验收。
|
||||
- 回滚:current 原子切回 `/home/goauto/releases/20261010-4ad59bd-372v2-web`,不需要重启。
|
||||
|
||||
Reference in New Issue
Block a user