Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fc5d9edfa6 | ||
|
|
59a483f53a | ||
|
|
328afde919 | ||
|
|
afa03ef3e4 | ||
|
|
71165b1fe7 | ||
|
|
7c0d37b512 | ||
|
|
68eccade03 | ||
|
|
d4cf2bbb3e | ||
|
|
07800cf41e |
@@ -11,8 +11,8 @@ android {
|
||||
applicationId = "cn.ilapage.goauto.agent"
|
||||
minSdk = 23
|
||||
targetSdk = 34
|
||||
versionCode = 83
|
||||
versionName = "0.9.70"
|
||||
versionCode = 84
|
||||
versionName = "0.9.71"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
|
||||
|
||||
@@ -12,11 +12,15 @@ import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.fragment.app.Fragment
|
||||
import cn.ilapage.goauto.agent.service.AgentForegroundService
|
||||
import cn.ilapage.goauto.agent.service.AgentSettingsStore
|
||||
import cn.ilapage.goauto.agent.ui.AccessibilityPauseText
|
||||
import cn.ilapage.goauto.agent.ui.AccessibilityReadiness
|
||||
import cn.ilapage.goauto.agent.ui.AgentSettingsFragment
|
||||
import cn.ilapage.goauto.agent.ui.AgentUiVisibility
|
||||
import cn.ilapage.goauto.agent.ui.AgentStatusFragment
|
||||
import cn.ilapage.goauto.agent.ui.TaskHistoryFragment
|
||||
import cn.ilapage.goauto.agent.update.AgentAppUpdateManager
|
||||
import com.google.android.material.bottomnavigation.BottomNavigationView
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
|
||||
class MainActivity : AppCompatActivity() {
|
||||
private var selectedTab = TAB_STATUS
|
||||
@@ -30,6 +34,18 @@ class MainActivity : AppCompatActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
private var accessibilityDialog: androidx.appcompat.app.AlertDialog? = null
|
||||
private val accessibilityReceiver = object : android.content.BroadcastReceiver() {
|
||||
override fun onReceive(context: android.content.Context?, intent: android.content.Intent?) {
|
||||
if (intent?.action != AgentForegroundService.ACTION_ACCESSIBILITY_ALERT) return
|
||||
val readiness = runCatching {
|
||||
AccessibilityReadiness.valueOf(intent.getStringExtra(AgentForegroundService.EXTRA_ACCESSIBILITY_READINESS).orEmpty())
|
||||
}.getOrNull() ?: return
|
||||
showAccessibilityDialog(readiness)
|
||||
}
|
||||
}
|
||||
private var accessibilityReceiverRegistered = false
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
selectedTab = AgentTabSelectionPolicy.initialTab(
|
||||
@@ -53,9 +69,15 @@ class MainActivity : AppCompatActivity() {
|
||||
cn.ilapage.goauto.agent.automation.GoAutoAccessibilityService.instance?.dismissPurchaseResultBubble()
|
||||
screenPolicyHandler.removeCallbacks(screenPolicyRefresh)
|
||||
screenPolicyHandler.post(screenPolicyRefresh)
|
||||
AgentUiVisibility.resumed = true
|
||||
registerAccessibilityReceiver()
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
AgentUiVisibility.resumed = false
|
||||
unregisterAccessibilityReceiver()
|
||||
accessibilityDialog?.dismiss()
|
||||
accessibilityDialog = null
|
||||
screenPolicyHandler.removeCallbacks(screenPolicyRefresh)
|
||||
super.onPause()
|
||||
}
|
||||
@@ -65,6 +87,38 @@ class MainActivity : AppCompatActivity() {
|
||||
super.onSaveInstanceState(outState)
|
||||
}
|
||||
|
||||
private fun showAccessibilityDialog(readiness: AccessibilityReadiness) {
|
||||
accessibilityDialog?.dismiss()
|
||||
accessibilityDialog = null
|
||||
val detail = AccessibilityPauseText.recovery(readiness) ?: return // READY:恢复,仅关闭弹窗
|
||||
accessibilityDialog = MaterialAlertDialogBuilder(this)
|
||||
.setTitle(AccessibilityPauseText.TITLE)
|
||||
.setMessage(detail)
|
||||
.setNegativeButton("稍后", null)
|
||||
.setPositiveButton("去设置") { _, _ ->
|
||||
startActivity(android.content.Intent(android.provider.Settings.ACTION_ACCESSIBILITY_SETTINGS))
|
||||
}
|
||||
.show()
|
||||
}
|
||||
|
||||
private fun registerAccessibilityReceiver() {
|
||||
if (accessibilityReceiverRegistered) return
|
||||
val filter = android.content.IntentFilter(AgentForegroundService.ACTION_ACCESSIBILITY_ALERT)
|
||||
if (Build.VERSION.SDK_INT >= 33) {
|
||||
registerReceiver(accessibilityReceiver, filter, RECEIVER_NOT_EXPORTED)
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
registerReceiver(accessibilityReceiver, filter)
|
||||
}
|
||||
accessibilityReceiverRegistered = true
|
||||
}
|
||||
|
||||
private fun unregisterAccessibilityReceiver() {
|
||||
if (!accessibilityReceiverRegistered) return
|
||||
runCatching { unregisterReceiver(accessibilityReceiver) }
|
||||
accessibilityReceiverRegistered = false
|
||||
}
|
||||
|
||||
private fun buildContent(): LinearLayout {
|
||||
val content = LinearLayout(this).apply {
|
||||
orientation = LinearLayout.VERTICAL
|
||||
|
||||
@@ -166,7 +166,6 @@ class AgentDiagnosticStore(context: Context) : SQLiteOpenHelper(context, DATABAS
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun record(event: AgentDiagnosticEvent) {
|
||||
require(event.taskId > 0)
|
||||
require(event.taskType in setOf("collection", "purchase"))
|
||||
|
||||
@@ -79,6 +79,13 @@ import cn.ilapage.goauto.agent.diagnostics.AccessibilitySnapshotSource
|
||||
import cn.ilapage.goauto.agent.diagnostics.FailureSnapshotCapture
|
||||
import cn.ilapage.goauto.agent.diagnostics.FailureSnapshotPolicy
|
||||
import cn.ilapage.goauto.agent.diagnostics.SnapshotContext
|
||||
import cn.ilapage.goauto.agent.ui.AccessibilityAlertAction
|
||||
import cn.ilapage.goauto.agent.ui.AccessibilityAlertTracker
|
||||
import cn.ilapage.goauto.agent.ui.AccessibilityDispatchGate
|
||||
import cn.ilapage.goauto.agent.ui.AccessibilityPauseText
|
||||
import cn.ilapage.goauto.agent.ui.AccessibilityReadiness
|
||||
import cn.ilapage.goauto.agent.ui.AccessibilityReadinessDetector
|
||||
import cn.ilapage.goauto.agent.ui.AgentUiVisibility
|
||||
import cn.ilapage.goauto.agent.ui.PurchaseResultBubblePolicy
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
@@ -103,6 +110,7 @@ class AgentForegroundService : Service() {
|
||||
private var repurchaseSession: RepurchaseSession? = null
|
||||
private val repurchaseEvidence = java.util.concurrent.ConcurrentHashMap<Long, java.util.concurrent.ConcurrentHashMap<Long, RepurchaseExecutionEvidence>>()
|
||||
private val taskMutex = TaskExecutionMutex()
|
||||
private val accessibilityAlerts = AccessibilityAlertTracker()
|
||||
private val probeHandoff = PurchaseProbeHandoff()
|
||||
private val backfillGuard = OrderBackfillGuard(taskMutex)
|
||||
private val runningTaskId = AtomicReference<Long?>(null)
|
||||
@@ -443,6 +451,15 @@ class AgentForegroundService : Service() {
|
||||
|
||||
private fun scheduleTask(api: AgentApiClient, token: String): String {
|
||||
if (taskMutex.currentTaskId() != null) return MANUAL_BUSY
|
||||
val readiness = AccessibilityReadinessDetector.current(this)
|
||||
handleAccessibilityAlert(readiness)
|
||||
if (!AccessibilityDispatchGate.allowsClaiming(readiness)) {
|
||||
// 不调用任何领取/查询下一任务接口,任务留在服务端 pending;恢复后下一轮同步自动继续。
|
||||
cancelIdleReturn("无障碍未就绪")
|
||||
stateStore.update("ONLINE", AccessibilityPauseText.TITLE, tokenStored = true)
|
||||
updateNotification(AccessibilityPauseText.TITLE)
|
||||
return MANUAL_ACCESSIBILITY_NOT_READY
|
||||
}
|
||||
val collectionCooldown = activeCollectionCooldown()
|
||||
val purchaseTask = api.nextPurchaseTask(token)
|
||||
when (TaskDispatchPolicy.decide(purchaseTask?.status, collectionCooldown != null)) {
|
||||
@@ -489,6 +506,50 @@ class AgentForegroundService : Service() {
|
||||
return MANUAL_COLLECTION_TASK
|
||||
}
|
||||
|
||||
private fun handleAccessibilityAlert(readiness: AccessibilityReadiness) {
|
||||
val action = accessibilityAlerts.onReadiness(readiness)
|
||||
if (action == AccessibilityAlertAction.NONE) return
|
||||
val manager = getSystemService(NotificationManager::class.java)
|
||||
if (action == AccessibilityAlertAction.CANCEL) {
|
||||
manager.cancel(ACCESSIBILITY_ALERT_NOTIFICATION_ID)
|
||||
} else if (!AgentUiVisibility.resumed) {
|
||||
postAccessibilityAlertNotification(manager, readiness)
|
||||
}
|
||||
// 前台时由 MainActivity 收到广播后弹窗;恢复时同一广播让它关闭弹窗。
|
||||
sendBroadcast(Intent(ACTION_ACCESSIBILITY_ALERT).apply {
|
||||
setPackage(packageName)
|
||||
putExtra(EXTRA_ACCESSIBILITY_READINESS, readiness.name)
|
||||
})
|
||||
}
|
||||
|
||||
private fun postAccessibilityAlertNotification(manager: NotificationManager, readiness: AccessibilityReadiness) {
|
||||
// 通知权限被拒时静默跳过,不新增请求流程。
|
||||
if (!manager.areNotificationsEnabled()) return
|
||||
runCatching {
|
||||
val pendingIntent = PendingIntent.getActivity(
|
||||
this, 1, Intent(this, MainActivity::class.java),
|
||||
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
|
||||
)
|
||||
val builder = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
Notification.Builder(this, ACCESSIBILITY_ALERT_CHANNEL_ID)
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
Notification.Builder(this).setPriority(Notification.PRIORITY_HIGH)
|
||||
}
|
||||
val detail = AccessibilityPauseText.recovery(readiness)
|
||||
manager.notify(
|
||||
ACCESSIBILITY_ALERT_NOTIFICATION_ID,
|
||||
builder.setSmallIcon(android.R.drawable.stat_sys_warning)
|
||||
.setContentTitle(AccessibilityPauseText.TITLE)
|
||||
.setContentText(detail)
|
||||
.setStyle(Notification.BigTextStyle().bigText(detail))
|
||||
.setContentIntent(pendingIntent)
|
||||
.setAutoCancel(true)
|
||||
.build(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun publishManualCheckResult(result: String) {
|
||||
sendBroadcast(Intent(ACTION_CHECK_RESULT).apply {
|
||||
setPackage(packageName)
|
||||
@@ -1760,6 +1821,9 @@ class AgentForegroundService : Service() {
|
||||
manager.createNotificationChannel(
|
||||
NotificationChannel(CHANNEL_ID, "Agent 连接与心跳", NotificationManager.IMPORTANCE_LOW),
|
||||
)
|
||||
manager.createNotificationChannel(
|
||||
NotificationChannel(ACCESSIBILITY_ALERT_CHANNEL_ID, "无障碍未就绪提醒", NotificationManager.IMPORTANCE_HIGH),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1877,6 +1941,9 @@ class AgentForegroundService : Service() {
|
||||
const val EXTRA_CURRENT_PAGE_MESSAGE = "current_page_message"
|
||||
const val EXTRA_REPLACEMENT_ORIGIN_TYPE = "replacement_origin_type"
|
||||
const val EXTRA_REPLACEMENT_ORIGIN_TASK_ID = "replacement_origin_task_id"
|
||||
const val ACTION_ACCESSIBILITY_ALERT = "cn.ilapage.goauto.agent.ACCESSIBILITY_ALERT"
|
||||
const val EXTRA_ACCESSIBILITY_READINESS = "accessibility_readiness"
|
||||
const val MANUAL_ACCESSIBILITY_NOT_READY = "accessibility_not_ready"
|
||||
const val MANUAL_EMPTY = "empty"
|
||||
const val MANUAL_COLLECTION_TASK = "collection_task"
|
||||
const val MANUAL_PURCHASE_TASK = "purchase_task"
|
||||
@@ -1889,6 +1956,8 @@ class AgentForegroundService : Service() {
|
||||
const val MANUAL_COLLECTION_COOLDOWN_PREFIX = "collection_cooldown:"
|
||||
private const val CHANNEL_ID = "agent_connection"
|
||||
private const val NOTIFICATION_ID = 1001
|
||||
private const val ACCESSIBILITY_ALERT_CHANNEL_ID = "agent_accessibility_alert"
|
||||
private const val ACCESSIBILITY_ALERT_NOTIFICATION_ID = 1002
|
||||
private const val HEARTBEAT_SECONDS = 15L
|
||||
private const val TASK_WAKE_LOCK_TIMEOUT_MILLIS = 5 * 60 * 1000L
|
||||
private const val IDLE_RETURN_WAKE_LOCK_TIMEOUT_MILLIS = 30_000L
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
package cn.ilapage.goauto.agent.ui
|
||||
|
||||
/** 无障碍未就绪时暂停领取任务(#385)。纯策略,便于单元测试。 */
|
||||
object AccessibilityDispatchGate {
|
||||
fun allowsClaiming(readiness: AccessibilityReadiness): Boolean = readiness == AccessibilityReadiness.READY
|
||||
}
|
||||
|
||||
/** 暂停提示的文案,状态 Tab、手动检查、弹窗和通知共用。 */
|
||||
object AccessibilityPauseText {
|
||||
const val TITLE = "无障碍未就绪,已暂停领取任务"
|
||||
|
||||
fun recovery(readiness: AccessibilityReadiness): String? = when (readiness) {
|
||||
AccessibilityReadiness.READY -> null
|
||||
AccessibilityReadiness.ENABLED_WAITING -> "请到 设置 → 无障碍,把 GoAuto 关闭后重新打开"
|
||||
AccessibilityReadiness.DISABLED -> "请到 设置 → 无障碍 开启 GoAuto"
|
||||
}
|
||||
}
|
||||
|
||||
enum class AccessibilityAlertAction { NONE, PROMPT, CANCEL }
|
||||
|
||||
/**
|
||||
* 只在「就绪 → 未就绪」时提示一次;持续未就绪(含 DISABLED 与 ENABLED_WAITING 互转)不重复;
|
||||
* 恢复就绪时撤销提示,之后再次失效重新提示。进程内状态,不持久化。
|
||||
* 进程启动后的第一次观察视为转变:首次未就绪会提示,首次就绪会撤销可能残留的旧通知。
|
||||
*/
|
||||
class AccessibilityAlertTracker {
|
||||
private var previous: AccessibilityReadiness? = null
|
||||
|
||||
@Synchronized
|
||||
fun onReadiness(current: AccessibilityReadiness): AccessibilityAlertAction {
|
||||
val last = previous
|
||||
previous = current
|
||||
val ready = current == AccessibilityReadiness.READY
|
||||
return when {
|
||||
ready && last != AccessibilityReadiness.READY -> AccessibilityAlertAction.CANCEL
|
||||
!ready && (last == null || last == AccessibilityReadiness.READY) -> AccessibilityAlertAction.PROMPT
|
||||
else -> AccessibilityAlertAction.NONE
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Agent 界面是否在前台(MainActivity onResume/onPause 维护),服务据此选择弹窗或通知。 */
|
||||
object AgentUiVisibility {
|
||||
@Volatile var resumed: Boolean = false
|
||||
}
|
||||
@@ -23,7 +23,7 @@ import java.util.Date
|
||||
internal object ManualTaskCheckPolicy {
|
||||
fun blockedMessage(stateCode: String, currentTaskId: Long?, readiness: AccessibilityReadiness): String? = when {
|
||||
currentTaskId != null || stateCode == "BUSY" -> "当前任务执行中,无需重复检查"
|
||||
readiness != AccessibilityReadiness.READY -> "请先到“设置”开启采集采购助手"
|
||||
readiness != AccessibilityReadiness.READY -> AccessibilityPauseText.recovery(readiness)
|
||||
stateCode == "CONFIG_REQUIRED" -> "请先到“设置”配置服务地址"
|
||||
stateCode == "AUTH_ERROR" -> "设备身份校验失败,请先检查设置"
|
||||
else -> null
|
||||
@@ -38,6 +38,7 @@ internal object ManualTaskCheckPolicy {
|
||||
result == AgentForegroundService.MANUAL_EMPTY -> "暂无新任务,Agent 会继续自动检查"
|
||||
result == AgentForegroundService.MANUAL_COLLECTION_TASK -> "已领取采集任务"
|
||||
result == AgentForegroundService.MANUAL_PURCHASE_TASK -> "已领取采购任务"
|
||||
result == AgentForegroundService.MANUAL_ACCESSIBILITY_NOT_READY -> AccessibilityPauseText.TITLE
|
||||
result == AgentForegroundService.MANUAL_BUSY -> "当前任务执行中,无需重复检查"
|
||||
result == AgentForegroundService.MANUAL_CONFIG_REQUIRED -> "请先到“设置”配置服务地址"
|
||||
result == AgentForegroundService.MANUAL_AUTH_ERROR -> "设备身份校验失败,请先检查设置"
|
||||
@@ -52,10 +53,17 @@ internal object AgentStatusTextPolicy {
|
||||
"最近心跳:$heartbeat",
|
||||
).joinToString("\n")
|
||||
|
||||
fun accessibilityDetail(readiness: AccessibilityReadiness): String? = when (readiness) {
|
||||
AccessibilityReadiness.READY -> null
|
||||
AccessibilityReadiness.ENABLED_WAITING -> "请到“设置”检查无障碍服务"
|
||||
AccessibilityReadiness.DISABLED -> "请到“设置”开启"
|
||||
fun accessibilityDetail(readiness: AccessibilityReadiness): String? = AccessibilityPauseText.recovery(readiness)
|
||||
|
||||
fun accessibilityTitle(readiness: AccessibilityReadiness): String = when (readiness) {
|
||||
AccessibilityReadiness.READY -> "已开启"
|
||||
AccessibilityReadiness.ENABLED_WAITING -> "未连接,任务已暂停"
|
||||
AccessibilityReadiness.DISABLED -> "未开启,任务已暂停"
|
||||
}
|
||||
|
||||
fun accessibilityColor(readiness: AccessibilityReadiness): Int = when (readiness) {
|
||||
AccessibilityReadiness.READY -> R.color.agent_primary_light
|
||||
else -> R.color.agent_error
|
||||
}
|
||||
}
|
||||
|
||||
@@ -109,6 +117,7 @@ class AgentStatusFragment : Fragment() {
|
||||
AgentForegroundService.MANUAL_CONFIG_REQUIRED,
|
||||
AgentForegroundService.MANUAL_NETWORK_ERROR,
|
||||
AgentForegroundService.MANUAL_ERROR,
|
||||
AgentForegroundService.MANUAL_ACCESSIBILITY_NOT_READY,
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -286,20 +295,8 @@ class AgentStatusFragment : Fragment() {
|
||||
"${if (purchase) "采购" else "采集"}任务 ${if (purchase) "CG-" else "#"}$id"
|
||||
} ?: "等待任务"
|
||||
val readiness = AccessibilityReadinessDetector.current(context)
|
||||
when (readiness) {
|
||||
AccessibilityReadiness.READY -> {
|
||||
accessibilityTitle.text = "已开启"
|
||||
accessibilityTitle.setTextColor(context.getColor(R.color.agent_primary_light))
|
||||
}
|
||||
AccessibilityReadiness.ENABLED_WAITING -> {
|
||||
accessibilityTitle.text = "等待连接"
|
||||
accessibilityTitle.setTextColor(context.getColor(R.color.agent_warning))
|
||||
}
|
||||
AccessibilityReadiness.DISABLED -> {
|
||||
accessibilityTitle.text = "未开启"
|
||||
accessibilityTitle.setTextColor(context.getColor(R.color.agent_error))
|
||||
}
|
||||
}
|
||||
accessibilityTitle.text = AgentStatusTextPolicy.accessibilityTitle(readiness)
|
||||
accessibilityTitle.setTextColor(context.getColor(AgentStatusTextPolicy.accessibilityColor(readiness)))
|
||||
accessibilityDetail.text = AgentStatusTextPolicy.accessibilityDetail(readiness).orEmpty()
|
||||
accessibilityDetail.visibility = if (accessibilityDetail.text.isEmpty()) View.GONE else View.VISIBLE
|
||||
val mediaReadiness = MediaPermissionPolicy.current(context)
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
package cn.ilapage.goauto.agent
|
||||
|
||||
import cn.ilapage.goauto.agent.ui.AccessibilityAlertAction
|
||||
import cn.ilapage.goauto.agent.ui.AccessibilityAlertTracker
|
||||
import cn.ilapage.goauto.agent.ui.AccessibilityDispatchGate
|
||||
import cn.ilapage.goauto.agent.ui.AccessibilityReadiness
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class AccessibilityDispatchGateTest {
|
||||
@Test fun pausesClaimingWhenDisabledOrWaiting() {
|
||||
assertFalse(AccessibilityDispatchGate.allowsClaiming(AccessibilityReadiness.DISABLED))
|
||||
assertFalse(AccessibilityDispatchGate.allowsClaiming(AccessibilityReadiness.ENABLED_WAITING))
|
||||
}
|
||||
|
||||
@Test fun allowsClaimingWhenReady() {
|
||||
assertTrue(AccessibilityDispatchGate.allowsClaiming(AccessibilityReadiness.READY))
|
||||
}
|
||||
|
||||
@Test fun promptsOnceWhenReadyBecomesNotReady() {
|
||||
val tracker = AccessibilityAlertTracker()
|
||||
assertEquals(AccessibilityAlertAction.CANCEL, tracker.onReadiness(AccessibilityReadiness.READY))
|
||||
assertEquals(AccessibilityAlertAction.NONE, tracker.onReadiness(AccessibilityReadiness.READY))
|
||||
assertEquals(AccessibilityAlertAction.PROMPT, tracker.onReadiness(AccessibilityReadiness.ENABLED_WAITING))
|
||||
assertEquals(AccessibilityAlertAction.NONE, tracker.onReadiness(AccessibilityReadiness.ENABLED_WAITING))
|
||||
assertEquals(AccessibilityAlertAction.NONE, tracker.onReadiness(AccessibilityReadiness.ENABLED_WAITING))
|
||||
}
|
||||
|
||||
@Test fun switchingBetweenNotReadyStatesDoesNotPromptAgain() {
|
||||
val tracker = AccessibilityAlertTracker()
|
||||
tracker.onReadiness(AccessibilityReadiness.READY)
|
||||
assertEquals(AccessibilityAlertAction.PROMPT, tracker.onReadiness(AccessibilityReadiness.DISABLED))
|
||||
assertEquals(AccessibilityAlertAction.NONE, tracker.onReadiness(AccessibilityReadiness.ENABLED_WAITING))
|
||||
assertEquals(AccessibilityAlertAction.NONE, tracker.onReadiness(AccessibilityReadiness.DISABLED))
|
||||
}
|
||||
|
||||
@Test fun recoveryCancelsAndNextFailurePromptsAgain() {
|
||||
val tracker = AccessibilityAlertTracker()
|
||||
tracker.onReadiness(AccessibilityReadiness.READY)
|
||||
tracker.onReadiness(AccessibilityReadiness.DISABLED)
|
||||
assertEquals(AccessibilityAlertAction.CANCEL, tracker.onReadiness(AccessibilityReadiness.READY))
|
||||
assertEquals(AccessibilityAlertAction.PROMPT, tracker.onReadiness(AccessibilityReadiness.ENABLED_WAITING))
|
||||
}
|
||||
|
||||
@Test fun firstObservationAfterProcessStartPromptsWhenNotReady() {
|
||||
assertEquals(
|
||||
AccessibilityAlertAction.PROMPT,
|
||||
AccessibilityAlertTracker().onReadiness(AccessibilityReadiness.ENABLED_WAITING),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
package cn.ilapage.goauto.agent
|
||||
|
||||
import cn.ilapage.goauto.agent.service.AgentForegroundService
|
||||
import cn.ilapage.goauto.agent.ui.AccessibilityReadiness
|
||||
import cn.ilapage.goauto.agent.ui.AccessibilityReadinessResolver
|
||||
import cn.ilapage.goauto.agent.ui.ManualTaskCheckPolicy
|
||||
@@ -46,9 +47,13 @@ class AccessibilityReadinessTest {
|
||||
ManualTaskCheckPolicy.blockedMessage("BUSY", 35L, AccessibilityReadiness.READY),
|
||||
)
|
||||
assertEquals(
|
||||
"请先到“设置”开启采集采购助手",
|
||||
"请到 设置 → 无障碍 开启 GoAuto",
|
||||
ManualTaskCheckPolicy.blockedMessage("ONLINE", null, AccessibilityReadiness.DISABLED),
|
||||
)
|
||||
assertEquals(
|
||||
"请到 设置 → 无障碍,把 GoAuto 关闭后重新打开",
|
||||
ManualTaskCheckPolicy.blockedMessage("ONLINE", null, AccessibilityReadiness.ENABLED_WAITING),
|
||||
)
|
||||
assertEquals(null, ManualTaskCheckPolicy.blockedMessage("ONLINE", null, AccessibilityReadiness.READY))
|
||||
}
|
||||
|
||||
@@ -59,5 +64,9 @@ class AccessibilityReadinessTest {
|
||||
assertEquals("已领取采购任务", ManualTaskCheckPolicy.resultMessage("purchase_task"))
|
||||
assertEquals("采集间隔中,还需 12 秒", ManualTaskCheckPolicy.resultMessage("collection_cooldown:12"))
|
||||
assertEquals("检查失败,Agent 将继续自动重试", ManualTaskCheckPolicy.resultMessage("network_error"))
|
||||
assertEquals(
|
||||
"无障碍未就绪,已暂停领取任务",
|
||||
ManualTaskCheckPolicy.resultMessage(AgentForegroundService.MANUAL_ACCESSIBILITY_NOT_READY),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,12 +25,21 @@ class AgentStatusTextPolicyTest {
|
||||
@Test fun hidesReadyDetailButKeepsRecoveryMessages() {
|
||||
assertNull(AgentStatusTextPolicy.accessibilityDetail(AccessibilityReadiness.READY))
|
||||
assertEquals(
|
||||
"请到“设置”检查无障碍服务",
|
||||
"请到 设置 → 无障碍,把 GoAuto 关闭后重新打开",
|
||||
AgentStatusTextPolicy.accessibilityDetail(AccessibilityReadiness.ENABLED_WAITING),
|
||||
)
|
||||
assertEquals(
|
||||
"请到“设置”开启",
|
||||
"请到 设置 → 无障碍 开启 GoAuto",
|
||||
AgentStatusTextPolicy.accessibilityDetail(AccessibilityReadiness.DISABLED),
|
||||
)
|
||||
}
|
||||
|
||||
@Test fun accessibilityCardTitleAndColorFollowReadiness() {
|
||||
assertEquals("已开启", AgentStatusTextPolicy.accessibilityTitle(AccessibilityReadiness.READY))
|
||||
assertEquals("未开启,任务已暂停", AgentStatusTextPolicy.accessibilityTitle(AccessibilityReadiness.DISABLED))
|
||||
assertEquals("未连接,任务已暂停", AgentStatusTextPolicy.accessibilityTitle(AccessibilityReadiness.ENABLED_WAITING))
|
||||
assertEquals(R.color.agent_primary_light, AgentStatusTextPolicy.accessibilityColor(AccessibilityReadiness.READY))
|
||||
assertEquals(R.color.agent_error, AgentStatusTextPolicy.accessibilityColor(AccessibilityReadiness.DISABLED))
|
||||
assertEquals(R.color.agent_error, AgentStatusTextPolicy.accessibilityColor(AccessibilityReadiness.ENABLED_WAITING))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Architecture-and-Code-Map
|
||||
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Architecture-and-Code-Map.-
|
||||
wiki_revision: 72fa776c9adb4993c84918494b37516f3156d354
|
||||
synchronized_at: 2026-10-12T09:37:33Z
|
||||
wiki_revision: 21d6cb0a4d8829ac2dbc4947e7ef7718f237fd2d
|
||||
synchronized_at: 2026-10-12T15:15:52Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
<!-- gitea-wiki-mirror:start -->
|
||||
@@ -361,6 +361,7 @@ PddProductDetailCollector
|
||||
|
||||
- `server/app/goauto/clientkey` 管理独立密钥、授权及审计;`clientapi/routes.go` 的显式 Inventory 将 `/api/client/v1` 映射到既有业务处理器,绝不转发任意 Admin 路由。
|
||||
- `clientapi/gateway.go` 默认接受 HTTP 与 HTTPS,无协议开关或转发协议头门禁,按 Bearer 密钥、模块及能力顺序校验,每次请求读取数据库,无授权缓存。`common/clientprincipal` 传递独立客户端身份,不生成或伪装管理员 JWT。
|
||||
- #384:`Inventory` 新增 `GET/POST /api/client/v1/agent-app-releases`(devices 模块 `release_upload`)与 `PUT /api/client/v1/agent-app-releases/current`(`release_activate`),复用 `apprelease.Handler`;不注册下载。`gateway.go` `bodyLimit` 只对 APK 上传放宽到 `apprelease.MaxAPKBytes`+1MB,其余端点仍为 16MB;`apprelease/handler.go` `operatorID` 对客户端请求取 `AuthorizedBy`。Web `views/goauto/client-keys` 动作标签与 `release_activate` 风险提示。
|
||||
- `client_api_key` 保存名称、随机 256 位密钥的 SHA-256 摘要、前缀、授权 JSON、启用状态、版本、创建/修改人和最后使用时间;完整密钥仅创建响应一次返回。`client_api_key_audit` 保存管理变更和客户端请求元数据,不保存请求正文、查询参数、响应正文或凭据。
|
||||
- 编辑与停用采用启用状态和 version 条件更新,并与变更审计同事务提交;冲突返回 409。业务执行前先持久化请求审计意图,失败则不执行业务。完成后更新状态;更新失败或进程中断可能留下 status=0,表示结果待核对,不能据此自动重放。
|
||||
- 部分既有业务操作人字段使用该密钥最近授权管理员的 ID 兼容现有外键;实际调用方以独立审计的 key_id 为准,不能把业务字段当成人工操作证据。
|
||||
|
||||
@@ -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: c8aa5a4716d0b9a462677b1276a7362d7227c95c
|
||||
synchronized_at: 2026-10-12T09:37:39Z
|
||||
wiki_revision: 808db5fb272804ca2320a629efb89cd8deb9cbb9
|
||||
synchronized_at: 2026-10-12T15:15:56Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
<!-- gitea-wiki-mirror:start -->
|
||||
@@ -428,6 +428,7 @@ Admin 的「创建时间」范围按 SYB 货运单列表的 `t_stock.created`(
|
||||
- Agent 采购历史必须分开显示任务原始目标规格与服务端最终映射的执行规格;映射缺失不等于目标规格缺失。本地历史缓存同时保存两组字段,自动化始终只消费执行规格。
|
||||
- “采购规则”是“采采管理”下与“采集规则”同级的管理员菜单,采购员角色没有菜单和 API 权限。
|
||||
- Agent APK 版本由管理员在设备页抽屉中上传、核对并显式设为当前。服务端从 APK Manifest 读取整数 `versionCode` 和 `versionName`,保存 SHA-256、大小、说明和创建人;APK 位于非公开目录,Admin 与设备下载均需认证。
|
||||
- #384 起,客户端 API Key 也可经显式授权上传与设为当前(见「客户端密钥上传 Agent 版本(#384)」);Admin 流程不变。
|
||||
- Agent 只以整数 `versionCode` 判断更新。启动时静默检查一次,设置页可手动检查、显示下载进度并取消;下载完成必须校验大小和 SHA-256。设备有活动任务时禁止检查、下载和安装;安装始终交给 Android 系统确认,项目不绕过未知来源权限。
|
||||
|
||||
## Android 采购规格动作安全执行(#214)
|
||||
@@ -1014,3 +1015,13 @@ Android同步诊断不改变#377的心跳→恢复→补传→必要的一次任
|
||||
## Admin 采集任务按设备筛选(#383)
|
||||
|
||||
合并 main `15bab9c`,2026-10-12 随发布上线,待业务验收。采集任务页新增「设备」筛选(含已停用设备),使用服务端既有 `deviceId` 参数;选择设备时「取消未开始任务」按钮禁用,因为批量取消接口不支持设备维度,避免取消范围超出当前列表。
|
||||
|
||||
## 客户端密钥上传 Agent 版本(#384)
|
||||
|
||||
合并 main `07800cf`,2026-10-12 发布上线,待业务验收。
|
||||
|
||||
- `devices` 模块新增两个独立动作,必须在密钥上显式勾选,默认不授予;只读或读写授权不会获得:
|
||||
- `release_upload`「上传 Agent 版本」:查看版本列表、上传 APK;
|
||||
- `release_activate`「设置当前 Agent 版本」:把已上传版本设为当前。设为当前后所有设备启动或手动检查时会提示更新,授权页对此显示风险提示。
|
||||
- 不开放 APK 下载给客户端 API。上传沿用 Admin 的 Manifest 解析、SHA-256、大小与 versionCode 唯一校验;创建人记为授权该密钥的账号。每次调用写客户端审计,不记录文件内容。
|
||||
- 设备端安装仍由 Android 系统人工确认;签名与已安装版本不一致的 APK 无法覆盖安装,设为当前前须核对目标设备签名。
|
||||
|
||||
@@ -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: 4103d9b6174bdba2886c4916e85e0763ce2dcd53
|
||||
synchronized_at: 2026-10-12T09:37:52Z
|
||||
wiki_revision: 37630e95b7e03248546467826525f45921033f74
|
||||
synchronized_at: 2026-10-13T01:37:19Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
<!-- gitea-wiki-mirror:start -->
|
||||
@@ -347,6 +347,25 @@ Provider 故障日志只允许记录调用关联 ID、操作类型、耗时、
|
||||
- 换机生成的调试签名与旧机不同会导致 `INSTALL_FAILED_UPDATE_INCOMPATIBLE`;不可为更新擅自卸载。用户提供的原调试签名已与手机证书核对一致,私有文件位于 `D:/OPC/goauto/debug.keystore`,仅在本地Git排除规则中忽略,绝不提交。应另行私密备份,不能重新生成替代。
|
||||
- 当前Windows用户级 `C:/Users/Qiu/.gradle/init.d/goauto-debug-signing.gradle` 使用Android Components的 `finalizeDsl`,仅对applicationId `cn.ilapage.goauto.agent` 的debug签名指定原文件;其他项目及release配置不变。普通Gradle调试构建自动生效,文件缺失则明确失败,不退回新签名。更换电脑或GRADLE_USER_HOME时需带入相同私有文件及配置;不要把本机设置当作仓库内或其他电脑已配置。构建后用signingReport与apksigner复核,再以install -r更新。
|
||||
|
||||
### Agent 装机签名对照(2026-10-13 核对)
|
||||
|
||||
采购机和测试机上已装的 Agent 签名不同,装包前必须按目标设备选对签名:
|
||||
|
||||
| 设备 | 序列号 | 已装证书 SHA-256 | 签名文件 |
|
||||
|---|---|---|---|
|
||||
| 采购1(PKG110) | `3B65BD02H7F00000` | `bf86d7465c6092be74ce9c4187eb30c9a7d045c3b91891a547e2598429766dec` | `D:/OPC/goauto/debug.keystore`(私有,不提交) |
|
||||
| 采购2(PLY110) | `3B663A00JW500000` | 同采购1 | 同采购1 |
|
||||
| 测试机(PLY110) | `3B668T01GF400000` | `3eab2527dc3ce592fe22f2f8cc31e4d0fa07dd1fa54b6209948ea6ce9cd7c4f6` | `C:/Users/Qiu/.android/debug.keystore` |
|
||||
|
||||
- 本机 Gradle 调试构建产物已经是采购机签名(由上面的 init 脚本强制),可以直接装采购机;装测试机前需用 apksigner 以测试机签名改签。
|
||||
- 装包前逐台核对:
|
||||
- `ro.serialno` 确认目标设备(USB 连接的手机不一定是测试机);
|
||||
- 用 `pm path` 拉取已装 `base.apk`,比较它与待装 APK 的 `apksigner verify --print-certs`;
|
||||
- 设备空闲,没有待执行或执行中的任务。
|
||||
- 签名不一致会报 `INSTALL_FAILED_UPDATE_INCOMPATIBLE`,**不得卸载重装**:卸载会清掉设备凭据和本地采购数据。
|
||||
- 装完后手动打开 Agent,等待心跳恢复;无障碍显示未连接时,在系统设置中把 GoAuto 关闭后重新打开(#385)。
|
||||
- 2026-10-13 记录:0.9.71(84,main `328afde`)测试机包 SHA256 `10ad5a2b898230897b15a54f73212ae56a11fcf2f73beffc0392321699d7cf00`,采购1 包 SHA256 `3004cda3784182381e98ce9953cabcdba4017eb535c8578a2b317b3a7303e6ab`,均以 `install -r` 覆盖安装,未卸载。
|
||||
|
||||
|
||||
## Android 商品导航诊断(#373,诊断阶段)
|
||||
|
||||
@@ -432,3 +451,12 @@ Server实现 `d84728d`、Android实现 `dbe7e98`,main 合并记录见 #378;
|
||||
- 迁移前与切换前确认无执行中的采集/采购/SYB 同步/AI 解析/自动匹配;1 个 pending 采购任务保持原状。current 原子切换后重启 `goauto.service`,active;Nginx 未改。
|
||||
- 按内容验收:`/`、`/login` 返回新 index(含 `id="app"`、无欢迎页),10 项 JS/CSS 200,验证码 code=200,未登录访问 `yeeke-returns`、`purchase-tasks/filter-options`、带 `deviceId=unassigned` 的采购列表均为业务码 401;重启后日志无 panic/fatal/1146/1054;采购1、采购2 心跳恢复在线,`agent_heartbeat_log` 开始写入。
|
||||
- 回滚:current 原子切回 `/home/goauto/releases/20261010-3d5e964-372v3-web` 并重启;新增表与列保留不影响旧代码,不回退数据库。
|
||||
|
||||
## #384 客户端密钥上传 Agent 版本发布(2026-10-12)
|
||||
|
||||
- 用户授权建单、实施并部署上线。main `07800cf`(`19484f0` 服务端、`6107c23` Web),无迁移。clientapi/apprelease/clientkey/access 测试、jest client-keys、Linux 构建与 `build:prod` 通过。
|
||||
- 发布目录 `/home/goauto/releases/20261012-07800cf-384`:goauto-server SHA256 `58f4a0a49f203f46a948262e771841993442bc66999126d5e82ed98d56b62131`(运行进程一致),index.html SHA256 `67c5fcee7063c2d43b61d7ca301390437c16b5f0c34ecaf1072f77fba350098a`;config 一致,static/temp/var 沿用,旧哈希资源保留。无迁移,未做数据库备份。
|
||||
- 发布时有 1 个采购执行中、32 个排队。按用户要求用服务器脚本 `/root/goauto-switch-384.sh` 等待:采集与采购执行中数量为 0,10 秒后复查仍为 0,再立即确认一次后原子切换 current 并重启(最长等待 3 小时,超时不切换)。18:34:38 切换,`goauto.service` active,日志 `/home/goauto/backups/20261012-384-switch.log`。
|
||||
- 按内容验收:`/`、`/login` 新 index,10 项 JS/CSS 200,验证码 200,未登录 Admin 接口 401;`/api/client/v1/agent-app-releases` 的 GET/PUT 无密钥 401;下载路径 404(未开放);日志无 panic/fatal/1146/1054。
|
||||
- Nginx 9527 vhost `client_max_body_size 100m`,超过 100MB 的 APK 会在到达服务前被拒。
|
||||
- 回滚:current 切回 `/home/goauto/releases/20261012-15bab9c-381-382-383` 并重启。
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
|
||||
)
|
||||
|
||||
func (handler Handler) AdminList(c *gin.Context) {
|
||||
@@ -176,7 +177,7 @@ func (handler Handler) AdminCancel(c *gin.Context) {
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
response, err := service.Cancel(c.Request.Context(), id, request)
|
||||
response, err := service.CancelOwnedBy(c.Request.Context(), id, request, ownerScope(c))
|
||||
if err != nil {
|
||||
writeError(c, err)
|
||||
return
|
||||
@@ -194,7 +195,7 @@ func (handler Handler) AdminBatchCancel(c *gin.Context) {
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
response, err := service.BatchCancel(c.Request.Context(), request)
|
||||
response, err := service.BatchCancelOwnedBy(c.Request.Context(), request, ownerScope(c))
|
||||
if err != nil {
|
||||
writeError(c, err)
|
||||
return
|
||||
@@ -202,6 +203,37 @@ func (handler Handler) AdminBatchCancel(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"code": 200, "data": response})
|
||||
}
|
||||
|
||||
// ownerScope 返回取消操作的归属限制:管理员返回 nil(不限制),其他角色返回
|
||||
// 当前用户 id(取不到时为 0,此时任何设备都不算本人设备)。读取方式与
|
||||
// device/handler.go 一致(JWT rolekey / identity,#333)。
|
||||
func ownerScope(c *gin.Context) *uint64 {
|
||||
if role, _ := jwt.ExtractClaims(c)["rolekey"].(string); role == "admin" {
|
||||
return nil
|
||||
}
|
||||
id := currentUserID(c)
|
||||
return &id
|
||||
}
|
||||
|
||||
func currentUserID(c *gin.Context) uint64 {
|
||||
switch id := jwt.ExtractClaims(c)["identity"].(type) {
|
||||
case float64:
|
||||
if id > 0 {
|
||||
return uint64(id)
|
||||
}
|
||||
case int:
|
||||
if id > 0 {
|
||||
return uint64(id)
|
||||
}
|
||||
case int64:
|
||||
if id > 0 {
|
||||
return uint64(id)
|
||||
}
|
||||
case uint64:
|
||||
return id
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func decodeStrict(c *gin.Context, destination any) error {
|
||||
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 1<<20)
|
||||
decoder := json.NewDecoder(c.Request.Body)
|
||||
|
||||
@@ -13,6 +13,20 @@ import (
|
||||
|
||||
const maxBatchCancelItems = 500
|
||||
|
||||
const messageNotOwnDevice = "只能取消归属自己设备的任务"
|
||||
|
||||
// deviceOwnedBy 判断设备是否归属指定用户;设备为空、不存在或未归属都视为否。
|
||||
func deviceOwnedBy(db *gorm.DB, deviceID *uint64, ownerUserID uint64) (bool, error) {
|
||||
if deviceID == nil || *deviceID == 0 || ownerUserID == 0 {
|
||||
return false, nil
|
||||
}
|
||||
var count int64
|
||||
if err := db.Model(&models.AgentDevice{}).Where("id = ? AND owner_user_id = ?", *deviceID, ownerUserID).Count(&count).Error; err != nil {
|
||||
return false, internalError(err)
|
||||
}
|
||||
return count > 0, nil
|
||||
}
|
||||
|
||||
// CancelResponse describes the outcome of cancelling one pending collection task.
|
||||
type CancelResponse struct {
|
||||
TaskID uint64 `json:"taskId"`
|
||||
@@ -31,6 +45,9 @@ type BatchCancelRequest struct {
|
||||
// 筛出两条、按钮却取消三十几条——那正是当初放弃「两个固定按钮」、改用
|
||||
// 「筛选 + 一个按钮」想避免的事(#298)。
|
||||
GoodsID string `json:"goodsId,omitempty"`
|
||||
// DeviceID 按设备限定取消范围(#387)。管理员可不传(范围与之前一致,含
|
||||
// 未指定设备的任务);非管理员必须传,且必须是归属自己的设备。
|
||||
DeviceID uint64 `json:"deviceId,omitempty"`
|
||||
}
|
||||
|
||||
type BatchCancelSkippedItem struct {
|
||||
@@ -57,6 +74,13 @@ type BatchCancelResponse struct {
|
||||
// failure #292 already paid for once. The semantics are "stop what has not
|
||||
// started, let what is running finish".
|
||||
func (service *Service) Cancel(ctx context.Context, taskID uint64, request ActionRequest) (CancelResponse, error) {
|
||||
return service.CancelOwnedBy(ctx, taskID, request, nil)
|
||||
}
|
||||
|
||||
// CancelOwnedBy 与 Cancel 相同,但 ownerUserID 非空时只允许取消归属该用户设备
|
||||
// 的任务(#387)。未指定设备、设备不存在或归属他人的任务一律返回同一个拒绝,
|
||||
// 不区分任务当前状态,避免向采购员泄露不属于他的任务信息。
|
||||
func (service *Service) CancelOwnedBy(ctx context.Context, taskID uint64, request ActionRequest, ownerUserID *uint64) (CancelResponse, error) {
|
||||
if err := validateAction(taskID, request); err != nil {
|
||||
return CancelResponse{}, err
|
||||
}
|
||||
@@ -69,6 +93,15 @@ func (service *Service) Cancel(ctx context.Context, taskID uint64, request Actio
|
||||
}
|
||||
return internalError(err)
|
||||
}
|
||||
if ownerUserID != nil {
|
||||
owned, err := deviceOwnedBy(tx, record.DeviceID, *ownerUserID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !owned {
|
||||
return serviceError(CodeForbidden, messageNotOwnDevice)
|
||||
}
|
||||
}
|
||||
if record.Status == models.TaskStatusCancelled {
|
||||
response = CancelResponse{TaskID: taskID, Cancelled: true}
|
||||
return nil
|
||||
@@ -88,11 +121,15 @@ func (service *Service) Cancel(ctx context.Context, taskID uint64, request Actio
|
||||
// 只看 status 会把"已被领取、马上要开始"的任务误判成可取消。
|
||||
// RowsAffected 为 0 说明任务已被领取或状态已变化,取消必须失败。
|
||||
now := service.Now()
|
||||
result := tx.Session(&gorm.Session{SkipHooks: true}).Model(&models.CollectionTask{}).
|
||||
Where("id = ? AND status = ? AND (lease_expires_at IS NULL OR lease_expires_at <= ?)", record.ID, models.TaskStatusPending, now).
|
||||
Updates(map[string]any{
|
||||
"status": models.TaskStatusCancelled, "active_slot": nil, "device_run_slot": nil,
|
||||
})
|
||||
update := tx.Session(&gorm.Session{SkipHooks: true}).Model(&models.CollectionTask{}).
|
||||
Where("id = ? AND status = ? AND (lease_expires_at IS NULL OR lease_expires_at <= ?)", record.ID, models.TaskStatusPending, now)
|
||||
if ownerUserID != nil {
|
||||
// 上面已校验归属;这里在写入条件里再兜一层,防止校验与更新之间归属变化。
|
||||
update = update.Where("device_id IN (SELECT id FROM agent_device WHERE owner_user_id = ?)", *ownerUserID)
|
||||
}
|
||||
result := update.Updates(map[string]any{
|
||||
"status": models.TaskStatusCancelled, "active_slot": nil, "device_run_slot": nil,
|
||||
})
|
||||
if result.Error != nil {
|
||||
return internalError(result.Error)
|
||||
}
|
||||
@@ -115,6 +152,25 @@ func (service *Service) Cancel(ctx context.Context, taskID uint64, request Actio
|
||||
// 回滚已经成功取消的其它任务。采购员要的是「能停多少停多少」,而不是全有或
|
||||
// 全无——批量越大,中途有一条被领走的概率越高。
|
||||
func (service *Service) BatchCancel(ctx context.Context, request BatchCancelRequest) (BatchCancelResponse, error) {
|
||||
return service.BatchCancelOwnedBy(ctx, request, nil)
|
||||
}
|
||||
|
||||
// BatchCancelOwnedBy 与 BatchCancel 相同,但 ownerUserID 非空(非管理员)时
|
||||
// 必须指定 DeviceID,且该设备必须归属 ownerUserID,否则不取消任何任务(#387)。
|
||||
func (service *Service) BatchCancelOwnedBy(ctx context.Context, request BatchCancelRequest, ownerUserID *uint64) (BatchCancelResponse, error) {
|
||||
if ownerUserID != nil {
|
||||
if request.DeviceID == 0 {
|
||||
return BatchCancelResponse{}, serviceError(device.CodeInvalidRequest, "请先选择设备")
|
||||
}
|
||||
deviceID := request.DeviceID
|
||||
owned, err := deviceOwnedBy(service.DB.WithContext(ctx), &deviceID, *ownerUserID)
|
||||
if err != nil {
|
||||
return BatchCancelResponse{}, err
|
||||
}
|
||||
if !owned {
|
||||
return BatchCancelResponse{}, serviceError(CodeForbidden, messageNotOwnDevice)
|
||||
}
|
||||
}
|
||||
source := strings.TrimSpace(request.Source)
|
||||
if source != "" && source != models.CollectionTaskSourceAdmin && source != models.CollectionTaskSourceAgentCurrentPage && source != models.CollectionTaskSourceImageSearch {
|
||||
return BatchCancelResponse{}, serviceError(device.CodeInvalidRequest, "source 无效")
|
||||
@@ -135,6 +191,9 @@ func (service *Service) BatchCancel(ctx context.Context, request BatchCancelRequ
|
||||
if goodsID := strings.TrimSpace(request.GoodsID); goodsID != "" {
|
||||
query = query.Where("goods_id_snapshot LIKE ?", "%"+goodsID+"%")
|
||||
}
|
||||
if request.DeviceID > 0 {
|
||||
query = query.Where("device_id = ?", request.DeviceID)
|
||||
}
|
||||
var candidates []models.CollectionTask
|
||||
// 多取一条用于判断范围内是否还有未处理的任务,多出来的那条不参与取消。
|
||||
if err := query.Order("id ASC").Limit(maxBatchCancelItems + 1).Find(&candidates).Error; err != nil {
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
package task
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"go-admin/app/goauto/models"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// #387:批量取消按设备限定,单条/批量取消对非管理员强制设备归属。
|
||||
|
||||
func ownDevice(t *testing.T, db *gorm.DB, name string, owner uint64) models.AgentDevice {
|
||||
t.Helper()
|
||||
record, _ := registerTaskDevice(t, db, name)
|
||||
if owner > 0 {
|
||||
if err := db.Model(&models.AgentDevice{}).Where("id = ?", record.ID).Update("owner_user_id", owner).Error; err != nil {
|
||||
t.Fatalf("set owner: %v", err)
|
||||
}
|
||||
}
|
||||
return record
|
||||
}
|
||||
|
||||
func taskStatus(t *testing.T, db *gorm.DB, id uint64) string {
|
||||
t.Helper()
|
||||
var stored models.CollectionTask
|
||||
if err := db.First(&stored, id).Error; err != nil {
|
||||
t.Fatalf("load task: %v", err)
|
||||
}
|
||||
return stored.Status
|
||||
}
|
||||
|
||||
func callCancel(t *testing.T, db *gorm.DB, claims jwt.MapClaims, path string, body any) (*httptest.ResponseRecorder, map[string]any) {
|
||||
t.Helper()
|
||||
gin.SetMode(gin.TestMode)
|
||||
engine := gin.New()
|
||||
handler := Handler{DB: db}
|
||||
setClaims := func(c *gin.Context) {
|
||||
c.Set(jwt.JwtPayloadKey, claims)
|
||||
c.Set("userId", 0)
|
||||
c.Next()
|
||||
}
|
||||
engine.POST("/batch-cancel", setClaims, handler.AdminBatchCancel)
|
||||
engine.POST("/:taskId/cancel", setClaims, handler.AdminCancel)
|
||||
payload, _ := json.Marshal(body)
|
||||
recorder := httptest.NewRecorder()
|
||||
engine.ServeHTTP(recorder, httptest.NewRequest(http.MethodPost, path, bytes.NewReader(payload)))
|
||||
var decoded map[string]any
|
||||
_ = json.Unmarshal(recorder.Body.Bytes(), &decoded)
|
||||
return recorder, decoded
|
||||
}
|
||||
|
||||
func purchaser(id float64) jwt.MapClaims {
|
||||
return jwt.MapClaims{"rolekey": "purchaser", "identity": id}
|
||||
}
|
||||
|
||||
func adminClaims() jwt.MapClaims { return jwt.MapClaims{"rolekey": "admin", "identity": float64(1)} }
|
||||
|
||||
func TestBatchCancelWithDeviceIDOnlyCancelsThatDevice(t *testing.T) {
|
||||
db := openTaskDatabase(t)
|
||||
service := newTaskService(db)
|
||||
one := ownDevice(t, db, "one", 3)
|
||||
two := ownDevice(t, db, "two", 3)
|
||||
onOne := createTask(t, db, &one.ID)
|
||||
onTwo := createTask(t, db, &two.ID)
|
||||
unassigned := createTask(t, db, nil)
|
||||
|
||||
response, err := service.BatchCancel(context.Background(), BatchCancelRequest{DeviceID: one.ID})
|
||||
if err != nil {
|
||||
t.Fatalf("batch cancel: %v", err)
|
||||
}
|
||||
if response.CancelledCount != 1 || response.CancelledIDs[0] != onOne.ID {
|
||||
t.Fatalf("expected only device one task cancelled, got %+v", response)
|
||||
}
|
||||
if taskStatus(t, db, onTwo.ID) != models.TaskStatusPending || taskStatus(t, db, unassigned.ID) != models.TaskStatusPending {
|
||||
t.Fatalf("other device and unassigned tasks must stay pending")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchCancelWithoutDeviceIDKeepsFullScope(t *testing.T) {
|
||||
db := openTaskDatabase(t)
|
||||
service := newTaskService(db)
|
||||
one := ownDevice(t, db, "one", 3)
|
||||
createTask(t, db, &one.ID)
|
||||
createTask(t, db, nil)
|
||||
|
||||
response, err := service.BatchCancel(context.Background(), BatchCancelRequest{})
|
||||
if err != nil {
|
||||
t.Fatalf("batch cancel: %v", err)
|
||||
}
|
||||
if response.CancelledCount != 2 {
|
||||
t.Fatalf("expected both tasks cancelled including unassigned, got %+v", response)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchCancelPurchaserRequiresDeviceID(t *testing.T) {
|
||||
db := openTaskDatabase(t)
|
||||
one := ownDevice(t, db, "one", 3)
|
||||
record := createTask(t, db, &one.ID)
|
||||
|
||||
for _, body := range []map[string]any{{}, {"deviceId": 0}} {
|
||||
recorder, decoded := callCancel(t, db, purchaser(3), "/batch-cancel", body)
|
||||
if recorder.Code != http.StatusUnprocessableEntity || decoded["code"] != "INVALID_REQUEST" || decoded["message"] != "请先选择设备" {
|
||||
t.Fatalf("expected INVALID_REQUEST, got %d %v", recorder.Code, decoded)
|
||||
}
|
||||
}
|
||||
if taskStatus(t, db, record.ID) != models.TaskStatusPending {
|
||||
t.Fatalf("no task may be cancelled")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchCancelPurchaserOwnDeviceSucceeds(t *testing.T) {
|
||||
db := openTaskDatabase(t)
|
||||
own := ownDevice(t, db, "own", 3)
|
||||
other := ownDevice(t, db, "other", 4)
|
||||
mine := createTask(t, db, &own.ID)
|
||||
theirs := createTask(t, db, &other.ID)
|
||||
|
||||
recorder, _ := callCancel(t, db, purchaser(3), "/batch-cancel", map[string]any{"deviceId": own.ID})
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("status=%d body=%s", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
if taskStatus(t, db, mine.ID) != models.TaskStatusCancelled || taskStatus(t, db, theirs.ID) != models.TaskStatusPending {
|
||||
t.Fatalf("expected only own device task cancelled")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchCancelPurchaserRejectsForeignUnownedAndMissingDevices(t *testing.T) {
|
||||
db := openTaskDatabase(t)
|
||||
foreign := ownDevice(t, db, "foreign", 4)
|
||||
unowned := ownDevice(t, db, "unowned", 0)
|
||||
foreignTask := createTask(t, db, &foreign.ID)
|
||||
unownedTask := createTask(t, db, &unowned.ID)
|
||||
|
||||
for name, id := range map[string]uint64{"foreign": foreign.ID, "unowned": unowned.ID, "missing": 999999} {
|
||||
recorder, decoded := callCancel(t, db, purchaser(3), "/batch-cancel", map[string]any{"deviceId": id})
|
||||
if recorder.Code != http.StatusForbidden || decoded["code"] != "FORBIDDEN" {
|
||||
t.Fatalf("%s: expected 403 FORBIDDEN, got %d %v", name, recorder.Code, decoded)
|
||||
}
|
||||
}
|
||||
if taskStatus(t, db, foreignTask.ID) != models.TaskStatusPending || taskStatus(t, db, unownedTask.ID) != models.TaskStatusPending {
|
||||
t.Fatalf("rejected requests must not cancel anything")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchCancelMissingIdentityIsRejected(t *testing.T) {
|
||||
db := openTaskDatabase(t)
|
||||
unowned := ownDevice(t, db, "unowned", 0)
|
||||
record := createTask(t, db, &unowned.ID)
|
||||
recorder, _ := callCancel(t, db, jwt.MapClaims{"rolekey": "purchaser"}, "/batch-cancel", map[string]any{"deviceId": unowned.ID})
|
||||
if recorder.Code != http.StatusForbidden || taskStatus(t, db, record.ID) != models.TaskStatusPending {
|
||||
t.Fatalf("expected rejection, got %d", recorder.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchCancelAdminAnyDeviceAndUnassigned(t *testing.T) {
|
||||
db := openTaskDatabase(t)
|
||||
foreign := ownDevice(t, db, "foreign", 4)
|
||||
onForeign := createTask(t, db, &foreign.ID)
|
||||
unassigned := createTask(t, db, nil)
|
||||
|
||||
recorder, _ := callCancel(t, db, adminClaims(), "/batch-cancel", map[string]any{"deviceId": foreign.ID})
|
||||
if recorder.Code != http.StatusOK || taskStatus(t, db, onForeign.ID) != models.TaskStatusCancelled || taskStatus(t, db, unassigned.ID) != models.TaskStatusPending {
|
||||
t.Fatalf("admin with deviceId should cancel only that device, code=%d", recorder.Code)
|
||||
}
|
||||
recorder, _ = callCancel(t, db, adminClaims(), "/batch-cancel", map[string]any{})
|
||||
if recorder.Code != http.StatusOK || taskStatus(t, db, unassigned.ID) != models.TaskStatusCancelled {
|
||||
t.Fatalf("admin without deviceId should cancel unassigned tasks, code=%d", recorder.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSingleCancelPurchaserOwnership(t *testing.T) {
|
||||
db := openTaskDatabase(t)
|
||||
own := ownDevice(t, db, "own", 3)
|
||||
foreign := ownDevice(t, db, "foreign", 4)
|
||||
mine := createTask(t, db, &own.ID)
|
||||
theirs := createTask(t, db, &foreign.ID)
|
||||
unassigned := createTask(t, db, nil)
|
||||
body := map[string]any{"requestId": uuid.NewString()}
|
||||
|
||||
for name, id := range map[string]uint64{"foreign": theirs.ID, "unassigned": unassigned.ID} {
|
||||
recorder, decoded := callCancel(t, db, purchaser(3), fmt.Sprintf("/%d/cancel", id), body)
|
||||
if recorder.Code != http.StatusForbidden || decoded["code"] != "FORBIDDEN" {
|
||||
t.Fatalf("%s: expected 403, got %d %v", name, recorder.Code, decoded)
|
||||
}
|
||||
if taskStatus(t, db, id) != models.TaskStatusPending {
|
||||
t.Fatalf("%s: status must be unchanged", name)
|
||||
}
|
||||
}
|
||||
recorder, _ := callCancel(t, db, purchaser(3), fmt.Sprintf("/%d/cancel", mine.ID), body)
|
||||
if recorder.Code != http.StatusOK || taskStatus(t, db, mine.ID) != models.TaskStatusCancelled {
|
||||
t.Fatalf("own device task should cancel, code=%d body=%s", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
// 他人设备上已取消的任务也不能通过幂等分支探测。
|
||||
if _, err := newTaskService(db).Cancel(context.Background(), theirs.ID, ActionRequest{RequestID: uuid.NewString()}); err != nil {
|
||||
t.Fatalf("seed: %v", err)
|
||||
}
|
||||
recorder, _ = callCancel(t, db, purchaser(3), fmt.Sprintf("/%d/cancel", theirs.ID), body)
|
||||
if recorder.Code != http.StatusForbidden {
|
||||
t.Fatalf("foreign cancelled task must still be forbidden, got %d", recorder.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSingleCancelAdminUnaffected(t *testing.T) {
|
||||
db := openTaskDatabase(t)
|
||||
foreign := ownDevice(t, db, "foreign", 4)
|
||||
theirs := createTask(t, db, &foreign.ID)
|
||||
unassigned := createTask(t, db, nil)
|
||||
for _, id := range []uint64{theirs.ID, unassigned.ID} {
|
||||
recorder, _ := callCancel(t, db, adminClaims(), fmt.Sprintf("/%d/cancel", id), map[string]any{"requestId": uuid.NewString()})
|
||||
if recorder.Code != http.StatusOK || taskStatus(t, db, id) != models.TaskStatusCancelled {
|
||||
t.Fatalf("admin cancel of %d failed: %d", id, recorder.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -317,7 +317,7 @@ func writeError(context *gin.Context, err error) {
|
||||
status = http.StatusUnprocessableEntity
|
||||
case device.CodeTokenInvalid:
|
||||
status = http.StatusUnauthorized
|
||||
case device.CodeDeviceDisabled:
|
||||
case device.CodeDeviceDisabled, CodeForbidden:
|
||||
status = http.StatusForbidden
|
||||
case CodeTaskNotFound:
|
||||
status = http.StatusNotFound
|
||||
|
||||
@@ -20,6 +20,7 @@ const (
|
||||
DefaultLeaseDuration = 2 * time.Minute
|
||||
DefaultTaskTimeout = 120
|
||||
|
||||
CodeForbidden = "FORBIDDEN"
|
||||
CodeTaskNotFound = "TASK_NOT_FOUND"
|
||||
CodeTaskAlreadyClaimed = "TASK_ALREADY_CLAIMED"
|
||||
CodeTaskAssignedOther = "TASK_ASSIGNED_OTHER_DEVICE"
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
<el-form-item>
|
||||
<el-button type="primary" :icon="Search" @click="handleQuery">查询</el-button>
|
||||
<el-button :icon="RefreshLeft" @click="resetQuery">重置</el-button>
|
||||
<el-button type="danger" plain :loading="cancelBatch.loading" :disabled="pendingCancelCount === 0" @click="openCancelBatch">取消未开始的任务<span class="action-count">{{ pendingCancelCount }}</span></el-button>
|
||||
<el-button type="danger" plain :loading="cancelBatch.loading" :disabled="!cancelNeedsDevice && pendingCancelCount === 0" @click="openCancelBatch">取消未开始的任务<span v-if="!cancelNeedsDevice" class="action-count">{{ pendingCancelCount }}</span></el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<el-table v-loading="loading" :data="tasks" border stripe empty-text="暂无采集任务">
|
||||
@@ -47,7 +47,7 @@
|
||||
|
||||
<el-dialog v-model="cancelBatch.open" title="取消未开始的任务" width="680px" :close-on-click-modal="false">
|
||||
<el-alert
|
||||
:title="`将取消整个筛选条件(来源:${sourceLabel(query.source) || '全部'};状态:${statusLabel(query.status) || '全部'};Goods ID:${query.goodsId || '全部'})下所有待执行任务,不只是当前页。`"
|
||||
:title="`将取消${cancelDeviceLabel}上、整个筛选条件(来源:${sourceLabel(query.source) || '全部'};状态:${statusLabel(query.status) || '全部'};Goods ID:${query.goodsId || '全部'})下的所有待执行任务,不只是当前页。`"
|
||||
type="warning" :closable="false" show-icon class="dialog-alert" />
|
||||
<div v-loading="cancelBatch.previewLoading">
|
||||
<p>共 {{ cancelBatch.previewTotal }} 个待执行任务将被取消{{ cancelBatch.previewTotal > cancelBatch.preview.length ? `(仅列出前 ${cancelBatch.preview.length} 个)` : '' }}:</p>
|
||||
@@ -114,6 +114,16 @@ export default {
|
||||
cancelBatch: { open: false, previewLoading: false, submitting: false, preview: [], previewTotal: 0 }
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
isAdmin() { return (this.$store.getters.roles || []).includes('admin') },
|
||||
// #387:采购员未选设备时不能批量取消(服务端同样拒绝);管理员不受限。
|
||||
cancelNeedsDevice() { return !this.isAdmin && !this.query.deviceId },
|
||||
cancelDeviceLabel() {
|
||||
if (!this.query.deviceId) return '全部设备(含未指定设备)'
|
||||
const found = this.deviceFilterOptions.find(item => item.id === this.query.deviceId)
|
||||
return `设备「${found ? found.name : this.query.deviceId}」`
|
||||
}
|
||||
},
|
||||
created() { this.getList(); this.refreshPendingCancelCount(); this.loadDeviceFilterOptions() },
|
||||
watch: {
|
||||
// `[必须]` 取消范围必须与眼前的列表一致,三个筛选项都要联动。少了 Goods ID
|
||||
@@ -135,20 +145,21 @@ export default {
|
||||
// 取消范围只对「状态筛选为空或恰好是待执行」才有意义:其它状态下这个筛选
|
||||
// 条件里不可能存在待执行任务,角标应该直接显示 0 并禁用按钮,而不是拿一个
|
||||
// 与实际可取消数量无关的数字误导采购员。
|
||||
// 批量取消接口不支持设备维度;选了设备筛选时禁用,避免取消范围超出眼前列表。
|
||||
cancelScopeStatus() { if (this.query.deviceId) return null; return this.query.status === '' || this.query.status === 'pending' ? 'pending' : null },
|
||||
// #387:设备维度也参与取消范围;采购员必须先选设备,且服务端只允许取消本人设备的任务。
|
||||
cancelScopeStatus() { return this.query.status === '' || this.query.status === 'pending' ? 'pending' : null },
|
||||
async refreshPendingCancelCount() {
|
||||
const status = this.cancelScopeStatus()
|
||||
if (!status) { this.pendingCancelCount = 0; return }
|
||||
const r = await listCollectionTasks({ page: 1, pageSize: 1, status, source: this.query.source || undefined, goodsId: this.query.goodsId || undefined })
|
||||
if (!status || this.cancelNeedsDevice) { this.pendingCancelCount = 0; return }
|
||||
const r = await listCollectionTasks({ page: 1, pageSize: 1, status, deviceId: this.query.deviceId || undefined, source: this.query.source || undefined, goodsId: this.query.goodsId || undefined })
|
||||
this.pendingCancelCount = r.data.total
|
||||
},
|
||||
async openCancelBatch() {
|
||||
if (this.cancelNeedsDevice) { ElMessage.warning('请先在「设备」筛选中选择要取消任务的设备'); return }
|
||||
const status = this.cancelScopeStatus()
|
||||
if (!status) return
|
||||
this.cancelBatch = { open: true, previewLoading: true, submitting: false, preview: [], previewTotal: 0 }
|
||||
try {
|
||||
const r = await listCollectionTasks({ page: 1, pageSize: 50, status, source: this.query.source || undefined, goodsId: this.query.goodsId || undefined })
|
||||
const r = await listCollectionTasks({ page: 1, pageSize: 50, status, deviceId: this.query.deviceId || undefined, source: this.query.source || undefined, goodsId: this.query.goodsId || undefined })
|
||||
this.cancelBatch.preview = r.data.items
|
||||
this.cancelBatch.previewTotal = r.data.total
|
||||
} finally { this.cancelBatch.previewLoading = false }
|
||||
@@ -158,7 +169,7 @@ export default {
|
||||
if (!status) return
|
||||
this.cancelBatch.submitting = true
|
||||
try {
|
||||
const response = await batchCancelCollectionTasks({ source: this.query.source || undefined, status, goodsId: this.query.goodsId || undefined })
|
||||
const response = await batchCancelCollectionTasks({ source: this.query.source || undefined, status, deviceId: this.query.deviceId || undefined, goodsId: this.query.goodsId || undefined })
|
||||
const skippedText = response.data.skipped?.length ? `,${response.data.skipped.length} 个因执行中或已被设备领取而跳过(任务号:${response.data.skipped.map(item => item.taskId).join('、')})` : ''
|
||||
ElMessage.success(`已取消 ${response.data.cancelledCount} 个任务${skippedText}`)
|
||||
if (response.data.hasMore) {
|
||||
|
||||
Reference in New Issue
Block a user