Compare commits

...
Author SHA1 Message Date
QiuSWandClaude Sonnet 5.5 d3d3837014 feat(android): 采集与采购详情页新增在拼多多中打开按钮 (#375)
- 编号为纯数字时显示描边按钮,忙时置灰并显示原因,随忙状态广播实时更新
- 点击经服务执行,结果 toast 不含链接与异常消息

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
2026-10-12 15:53:47 +08:00
QiuSWandClaude Sonnet 5.5 a0d2416c49 feat(android): 服务端手动打开拼多多商品与统一忙状态 (#375)
- 新增 DeviceBusyPolicy:服务与界面共用的设备忙判断与原因文案,repurchaseLocalIdle 复用(行为不变,冷却期不算忙)
- 新增 PddManualOpenCoordinator:先判忙,再作废交接凭证,再打开;复用 PddLaunchFallback 事件生成提示
- AgentForegroundService:新增打开动作、结果广播,每 2 秒发布只读忙状态;只打开商品链接,不点击 PDD 内控件

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
2026-10-12 15:53:47 +08:00
QiuSWandClaude Opus 5.5 23dafcf73f docs: record #372 v3 web release (#372)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
2026-10-10 11:58:24 +08:00
QiuSWandClaude Opus 5.5 3d5e9646f6 merge: shop column in SYB product export (#372)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
2026-10-10 11:52:01 +08:00
5 changed files with 380 additions and 7 deletions
@@ -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) })
}
}
+12 -2
View File
@@ -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`,不需要重启。