Compare commits

..
Author SHA1 Message Date
QiuSWandClaude Opus 5.5 68eccade03 fix(android): pause task claiming when accessibility is not ready (#385)
Agent no longer claims purchase/collection tasks while the accessibility
service is DISABLED or ENABLED_WAITING; tasks stay pending on the server.
Status card, manual-check text, persistent notification, foreground dialog
and a separate high-priority background notification explain the recovery.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
2026-10-13 09:10:09 +08:00
QiuSWandClaude Opus 5.5 d4cf2bbb3e docs: mirror wiki for client key agent release (#384)
Business-Rules-and-Glossary@808db5f, Architecture-and-Code-Map@21d6cb0,
Deployment-and-Operations@7e91eea.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
2026-10-12 23:17:47 +08:00
QiuSWandClaude Opus 5.5 07800cf41e merge: client API key agent release upload and activate (#384)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
2026-10-12 18:08:56 +08:00
10 changed files with 285 additions and 28 deletions
@@ -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
@@ -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))
}
}
+3 -2
View File
@@ -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 为准,不能把业务字段当成人工操作证据。
+13 -2
View File
@@ -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 无法覆盖安装,设为当前前须核对目标设备签名。
+11 -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: 4103d9b6174bdba2886c4916e85e0763ce2dcd53
synchronized_at: 2026-10-12T09:37:52Z
wiki_revision: 7e91eea0ec077f5af813375bdd94d8878e7e16d7
synchronized_at: 2026-10-12T15:16:03Z
<!-- gitea-wiki-mirror:end -->
<!-- gitea-wiki-mirror:start -->
@@ -432,3 +432,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` 并重启。