From f79f824ef49f783b7f98773751fd9fa1504e6d0c Mon Sep 17 00:00:00 2001 From: QiuSW <105186638@qq.com> Date: Thu, 27 Aug 2026 09:33:58 +0800 Subject: [PATCH] feat(agent): randomize collection task interval (#102) --- android/README.md | 2 +- .../agent/service/AgentForegroundService.kt | 2 +- .../goauto/agent/service/AgentStateStore.kt | 68 ++++++++++++++--- .../goauto/agent/ui/AgentSettingsFragment.kt | 75 ++++++++++++++----- .../agent/CollectionCooldownPolicyTest.kt | 43 +++++++++-- docs/03-business-rules-and-glossary.md | 10 +-- docs/04-local-development-and-verification.md | 10 +-- 7 files changed, 163 insertions(+), 47 deletions(-) diff --git a/android/README.md b/android/README.md index c1fb0fd..224805d 100644 --- a/android/README.md +++ b/android/README.md @@ -20,7 +20,7 @@ 任务结果成功提交(采购结果也可以先安全写入本地 Outbox)后,Agent 进入 15 秒自动返回窗口。它沿用现有任务轮询依次确认采购、采集队列都为空,并确认用户没有离开本次自动化打开的 PDD/浏览器页面,才返回 Agent 状态页。新任务、网络异常、待恢复的不可逆采购边界或前台应用变化都会取消自动返回。 -采集结果被服务端安全接收后还会按设置页的“采集间隔”等待下一次采集,默认 15 秒、范围 0~600 秒;采购仍可优先执行,手动检查和重新采集不能绕过。该间隔持久化到设备本地,与自动返回窗口相互独立。任务执行、自动返回窗口和采集间隔期间有界保持亮屏,结束后释放;不会自动解锁 PIN、图案或密码。 +采集结果被服务端安全接收后还会按设置页的“采集任务执行间隔”等待下一次采集;起止值均为 0~600 秒整数,默认 `15~15`,每次在闭区间内随机一次。采购仍可优先执行,手动检查和重新采集不能绕过;本次抽中的秒数、开始时间和截止时间会一起持久化,重启或修改设置不会重新随机。该间隔与自动返回窗口相互独立。任务执行、自动返回窗口和采集间隔期间有界保持亮屏,结束后释放;不会自动解锁 PIN、图案或密码。 ## T05 真机验证记录 diff --git a/android/app/src/main/java/cn/ilapage/goauto/agent/service/AgentForegroundService.kt b/android/app/src/main/java/cn/ilapage/goauto/agent/service/AgentForegroundService.kt index 65165ea..4c18332 100644 --- a/android/app/src/main/java/cn/ilapage/goauto/agent/service/AgentForegroundService.kt +++ b/android/app/src/main/java/cn/ilapage/goauto/agent/service/AgentForegroundService.kt @@ -577,7 +577,7 @@ class AgentForegroundService : Service() { beginIdleReturnCooldown() val ticket = CollectionCooldownPolicy.arm( System.currentTimeMillis(), - settingsStore.collectionIntervalSeconds(), + settingsStore.collectionIntervalRange(), ) if (ticket == null) { stateStore.clearCollectionCooldown() diff --git a/android/app/src/main/java/cn/ilapage/goauto/agent/service/AgentStateStore.kt b/android/app/src/main/java/cn/ilapage/goauto/agent/service/AgentStateStore.kt index 6d0de4f..e54b794 100644 --- a/android/app/src/main/java/cn/ilapage/goauto/agent/service/AgentStateStore.kt +++ b/android/app/src/main/java/cn/ilapage/goauto/agent/service/AgentStateStore.kt @@ -3,6 +3,7 @@ package cn.ilapage.goauto.agent.service import android.content.Context import cn.ilapage.goauto.agent.BuildConfig import cn.ilapage.goauto.agent.network.ServerUrlPolicy +import kotlin.random.Random data class AgentState( val code: String, @@ -39,18 +40,38 @@ internal object CollectionIntervalPolicy { } fun stored(value: Int): Int = value.takeIf { it in validRange } ?: DEFAULT_SECONDS + + fun storedRange(startSeconds: Int, endSeconds: Int): CollectionIntervalRange = + if (startSeconds in validRange && endSeconds in validRange && startSeconds <= endSeconds) { + CollectionIntervalRange(startSeconds, endSeconds) + } else { + CollectionIntervalRange(DEFAULT_SECONDS, DEFAULT_SECONDS) + } } +data class CollectionIntervalRange( + val startSeconds: Int, + val endSeconds: Int, +) + internal data class CollectionCooldownTicket( + val startedAtEpochMillis: Long, val untilEpochMillis: Long, val durationSeconds: Int, ) internal object CollectionCooldownPolicy { - fun arm(nowEpochMillis: Long, intervalSeconds: Int): CollectionCooldownTicket? { - val seconds = CollectionIntervalPolicy.stored(intervalSeconds) + fun arm( + nowEpochMillis: Long, + intervalRange: CollectionIntervalRange, + randomSeconds: (Int, Int) -> Int = { start, end -> Random.Default.nextInt(start, end + 1) }, + ): CollectionCooldownTicket? { + val range = CollectionIntervalPolicy.storedRange(intervalRange.startSeconds, intervalRange.endSeconds) + val seconds = randomSeconds(range.startSeconds, range.endSeconds) + require(seconds in range.startSeconds..range.endSeconds) { "随机采集间隔超出配置范围" } if (seconds == 0) return null return CollectionCooldownTicket( + startedAtEpochMillis = nowEpochMillis, untilEpochMillis = nowEpochMillis + seconds * 1_000L, durationSeconds = seconds, ) @@ -63,7 +84,10 @@ internal object CollectionCooldownPolicy { if (remaining <= 0L) return null val durationMillis = duration * 1_000L return if (remaining > durationMillis) { - ticket.copy(untilEpochMillis = nowEpochMillis + durationMillis) + ticket.copy( + startedAtEpochMillis = nowEpochMillis, + untilEpochMillis = nowEpochMillis + durationMillis, + ) } else ticket } @@ -97,13 +121,24 @@ class AgentSettingsStore(context: Context) { fun historyDays(): Int = HistoryRangePolicy.stored(preferences.getInt(HISTORY_DAYS, 7)) - fun collectionIntervalSeconds(): Int = CollectionIntervalPolicy.stored( - preferences.getInt(COLLECTION_INTERVAL_SECONDS, CollectionIntervalPolicy.DEFAULT_SECONDS), - ) + fun collectionIntervalRange(): CollectionIntervalRange { + val legacy = CollectionIntervalPolicy.stored( + preferences.getInt(COLLECTION_INTERVAL_SECONDS, CollectionIntervalPolicy.DEFAULT_SECONDS), + ) + return CollectionIntervalPolicy.storedRange( + preferences.getInt(COLLECTION_INTERVAL_START_SECONDS, legacy), + preferences.getInt(COLLECTION_INTERVAL_END_SECONDS, legacy), + ) + } - fun saveCollectionIntervalSeconds(seconds: Int) { - require(CollectionIntervalPolicy.stored(seconds) == seconds) { "采集间隔无效" } - check(preferences.edit().putInt(COLLECTION_INTERVAL_SECONDS, seconds).commit()) { "无法保存采集间隔" } + fun saveCollectionIntervalRange(range: CollectionIntervalRange) { + require(CollectionIntervalPolicy.storedRange(range.startSeconds, range.endSeconds) == range) { "采集间隔范围无效" } + check(preferences.edit() + .putInt(COLLECTION_INTERVAL_START_SECONDS, range.startSeconds) + .putInt(COLLECTION_INTERVAL_END_SECONDS, range.endSeconds) + .remove(COLLECTION_INTERVAL_SECONDS) + .commit() + ) { "无法保存采集间隔" } } fun saveHistoryDays(days: Int) { @@ -142,6 +177,8 @@ class AgentSettingsStore(context: Context) { const val DEVICE_NAME = "device_name" const val HISTORY_DAYS = "history_days" const val COLLECTION_INTERVAL_SECONDS = "collection_interval_seconds" + const val COLLECTION_INTERVAL_START_SECONDS = "collection_interval_start_seconds" + const val COLLECTION_INTERVAL_END_SECONDS = "collection_interval_end_seconds" const val LAST_HISTORY_SYNC_AT = "last_history_sync_at" } } @@ -195,7 +232,15 @@ class AgentStateStore(context: Context) { @Synchronized internal fun activeCollectionCooldown(nowEpochMillis: Long = System.currentTimeMillis()): CollectionCooldownTicket? { val stored = preferences.getLong(COLLECTION_COOLDOWN_UNTIL, 0L).takeIf { it > 0L }?.let { - CollectionCooldownTicket(it, preferences.getInt(COLLECTION_COOLDOWN_DURATION_SECONDS, 0)) + val duration = preferences.getInt(COLLECTION_COOLDOWN_DURATION_SECONDS, 0) + CollectionCooldownTicket( + startedAtEpochMillis = preferences.getLong( + COLLECTION_COOLDOWN_STARTED_AT, + it - duration * 1_000L, + ), + untilEpochMillis = it, + durationSeconds = duration, + ) } val normalized = CollectionCooldownPolicy.normalize(nowEpochMillis, stored) when { @@ -209,6 +254,7 @@ class AgentStateStore(context: Context) { internal fun saveCollectionCooldown(ticket: CollectionCooldownTicket) { require(ticket.durationSeconds in 1..CollectionIntervalPolicy.MAX_SECONDS) check(preferences.edit() + .putLong(COLLECTION_COOLDOWN_STARTED_AT, ticket.startedAtEpochMillis) .putLong(COLLECTION_COOLDOWN_UNTIL, ticket.untilEpochMillis) .putInt(COLLECTION_COOLDOWN_DURATION_SECONDS, ticket.durationSeconds) .commit() @@ -218,6 +264,7 @@ class AgentStateStore(context: Context) { @Synchronized fun clearCollectionCooldown() { preferences.edit() + .remove(COLLECTION_COOLDOWN_STARTED_AT) .remove(COLLECTION_COOLDOWN_UNTIL) .remove(COLLECTION_COOLDOWN_DURATION_SECONDS) .apply() @@ -233,6 +280,7 @@ class AgentStateStore(context: Context) { const val CURRENT_TASK_ID = "current_task_id" const val CURRENT_TASK_TYPE = "current_task_type" const val KEEP_SCREEN_ON = "keep_screen_on" + const val COLLECTION_COOLDOWN_STARTED_AT = "collection_cooldown_started_at" const val COLLECTION_COOLDOWN_UNTIL = "collection_cooldown_until" const val COLLECTION_COOLDOWN_DURATION_SECONDS = "collection_cooldown_duration_seconds" } diff --git a/android/app/src/main/java/cn/ilapage/goauto/agent/ui/AgentSettingsFragment.kt b/android/app/src/main/java/cn/ilapage/goauto/agent/ui/AgentSettingsFragment.kt index fb57304..6b647b7 100644 --- a/android/app/src/main/java/cn/ilapage/goauto/agent/ui/AgentSettingsFragment.kt +++ b/android/app/src/main/java/cn/ilapage/goauto/agent/ui/AgentSettingsFragment.kt @@ -26,6 +26,7 @@ import cn.ilapage.goauto.agent.persistence.TaskHistoryCache 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.CollectionIntervalRange import cn.ilapage.goauto.agent.service.CollectionIntervalPolicy import cn.ilapage.goauto.agent.service.HistoryRangePolicy import com.google.android.material.button.MaterialButton @@ -53,8 +54,10 @@ class AgentSettingsFragment : Fragment() { private lateinit var disabledReason: TextView private lateinit var diagnostics: TextView private lateinit var accessibilityText: TextView - private lateinit var collectionIntervalLayout: TextInputLayout - private lateinit var collectionIntervalInput: TextInputEditText + private lateinit var collectionIntervalStartLayout: TextInputLayout + private lateinit var collectionIntervalStartInput: TextInputEditText + private lateinit var collectionIntervalEndLayout: TextInputLayout + private lateinit var collectionIntervalEndInput: TextInputEditText private lateinit var collectionIntervalFeedback: TextView private lateinit var historyDaysLayout: TextInputLayout private lateinit var historyDaysInput: TextInputEditText @@ -163,21 +166,45 @@ class AgentSettingsFragment : Fragment() { }, fullWidth(12)) })) addView(context.card(context.cardColumn().apply { - addView(context.label("任务执行", 18f, context.getColor(R.color.agent_text), true)) + addView(context.label("采集任务执行间隔", 18f, context.getColor(R.color.agent_text), true)) + val interval = settingsStore.collectionIntervalRange() val intervalRow = LinearLayout(context).apply { orientation = LinearLayout.HORIZONTAL gravity = Gravity.CENTER_VERTICAL - addView(context.label("采集间隔", 14f, context.getColor(R.color.agent_text), true).apply { + collectionIntervalStartLayout = TextInputLayout(context).apply { + boxBackgroundMode = TextInputLayout.BOX_BACKGROUND_OUTLINE + boxStrokeColor = context.getColor(R.color.agent_primary_light) + isErrorEnabled = true + collectionIntervalStartInput = TextInputEditText(context).apply { + setText(interval.startSeconds.toString()) + setTextColor(context.getColor(R.color.agent_text)) + textSize = 16f + gravity = Gravity.CENTER + setSingleLine(true) + inputType = InputType.TYPE_CLASS_NUMBER + imeOptions = EditorInfo.IME_ACTION_NEXT + minHeight = context.dp(48) + contentDescription = "采集任务执行间隔范围起始值,0 到 600 秒" + } + addView(collectionIntervalStartInput, LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + context.dp(48), + )) + } + addView(collectionIntervalStartLayout, LinearLayout.LayoutParams(context.dp(68), ViewGroup.LayoutParams.WRAP_CONTENT).apply { + marginEnd = context.dp(8) + }) + addView(context.label("~", 16f, context.getColor(R.color.agent_text), true).apply { gravity = Gravity.CENTER_VERTICAL }, LinearLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, context.dp(48)).apply { marginEnd = context.dp(8) }) - collectionIntervalLayout = TextInputLayout(context).apply { + collectionIntervalEndLayout = TextInputLayout(context).apply { boxBackgroundMode = TextInputLayout.BOX_BACKGROUND_OUTLINE boxStrokeColor = context.getColor(R.color.agent_primary_light) isErrorEnabled = true - collectionIntervalInput = TextInputEditText(context).apply { - setText(settingsStore.collectionIntervalSeconds().toString()) + collectionIntervalEndInput = TextInputEditText(context).apply { + setText(interval.endSeconds.toString()) setTextColor(context.getColor(R.color.agent_text)) textSize = 16f gravity = Gravity.CENTER @@ -185,14 +212,14 @@ class AgentSettingsFragment : Fragment() { inputType = InputType.TYPE_CLASS_NUMBER imeOptions = EditorInfo.IME_ACTION_DONE minHeight = context.dp(48) - contentDescription = "采集任务间隔秒数,范围 0 到 600" + contentDescription = "采集任务执行间隔范围结束值,0 到 600 秒" } - addView(collectionIntervalInput, LinearLayout.LayoutParams( + addView(collectionIntervalEndInput, LinearLayout.LayoutParams( ViewGroup.LayoutParams.MATCH_PARENT, context.dp(48), )) } - addView(collectionIntervalLayout, LinearLayout.LayoutParams(context.dp(80), ViewGroup.LayoutParams.WRAP_CONTENT).apply { + addView(collectionIntervalEndLayout, LinearLayout.LayoutParams(context.dp(68), ViewGroup.LayoutParams.WRAP_CONTENT).apply { marginEnd = context.dp(8) }) addView(context.label("秒", 14f, context.getColor(R.color.agent_text), true).apply { @@ -209,7 +236,7 @@ class AgentSettingsFragment : Fragment() { } addView(intervalRow, fullWidth(10)) collectionIntervalFeedback = context.label( - "0 秒表示不等待,最多 600 秒", + "", 14f, context.getColor(R.color.agent_text_muted), ) @@ -406,14 +433,28 @@ class AgentSettingsFragment : Fragment() { } private fun saveCollectionInterval() { - collectionIntervalLayout.error = null - val seconds = CollectionIntervalPolicy.parse(collectionIntervalInput.text?.toString().orEmpty()) - if (seconds == null) { - collectionIntervalLayout.error = "请输入 0~600 的整数" - collectionIntervalInput.requestFocus() + collectionIntervalStartLayout.error = null + collectionIntervalEndLayout.error = null + val startSeconds = CollectionIntervalPolicy.parse(collectionIntervalStartInput.text?.toString().orEmpty()) + if (startSeconds == null) { + collectionIntervalStartLayout.error = "请输入 0~600 的整数" + collectionIntervalStartInput.requestFocus() return } - val saved = runCatching { settingsStore.saveCollectionIntervalSeconds(seconds) } + val endSeconds = CollectionIntervalPolicy.parse(collectionIntervalEndInput.text?.toString().orEmpty()) + if (endSeconds == null) { + collectionIntervalEndLayout.error = "请输入 0~600 的整数" + collectionIntervalEndInput.requestFocus() + return + } + if (startSeconds > endSeconds) { + collectionIntervalStartLayout.error = "左侧数值不能大于右侧数值" + collectionIntervalStartInput.requestFocus() + return + } + val saved = runCatching { + settingsStore.saveCollectionIntervalRange(CollectionIntervalRange(startSeconds, endSeconds)) + } collectionIntervalFeedback.text = saved.fold( onSuccess = { "已保存,下次采集任务结束后生效" }, onFailure = { "保存失败:${friendlyError(it)}" }, diff --git a/android/app/src/test/java/cn/ilapage/goauto/agent/CollectionCooldownPolicyTest.kt b/android/app/src/test/java/cn/ilapage/goauto/agent/CollectionCooldownPolicyTest.kt index 25f272b..35bdf11 100644 --- a/android/app/src/test/java/cn/ilapage/goauto/agent/CollectionCooldownPolicyTest.kt +++ b/android/app/src/test/java/cn/ilapage/goauto/agent/CollectionCooldownPolicyTest.kt @@ -2,6 +2,7 @@ package cn.ilapage.goauto.agent import cn.ilapage.goauto.agent.service.CollectionCooldownPolicy import cn.ilapage.goauto.agent.service.CollectionCooldownTicket +import cn.ilapage.goauto.agent.service.CollectionIntervalRange import cn.ilapage.goauto.agent.service.CollectionIntervalPolicy import cn.ilapage.goauto.agent.service.TaskDispatchDecision import cn.ilapage.goauto.agent.service.TaskDispatchPolicy @@ -18,20 +19,44 @@ class CollectionCooldownPolicyTest { assertNull(CollectionIntervalPolicy.parse("-1")) assertNull(CollectionIntervalPolicy.parse("1.5")) assertNull(CollectionIntervalPolicy.parse("601")) + assertEquals(CollectionIntervalRange(15, 30), CollectionIntervalPolicy.storedRange(15, 30)) + assertEquals(CollectionIntervalRange(15, 15), CollectionIntervalPolicy.storedRange(30, 15)) } @Test - fun `zero disables cooldown and positive interval creates a bounded ticket`() { - assertNull(CollectionCooldownPolicy.arm(1_000L, 0)) + fun `zero disables cooldown and fixed interval creates one durable ticket`() { + assertNull(CollectionCooldownPolicy.arm(1_000L, CollectionIntervalRange(0, 0)) { _, _ -> 0 }) + assertNull(CollectionCooldownPolicy.arm(1_000L, CollectionIntervalRange(0, 15)) { start, _ -> start }) assertEquals( - CollectionCooldownTicket(16_000L, 15), - CollectionCooldownPolicy.arm(1_000L, 15), + CollectionCooldownTicket(1_000L, 16_000L, 15), + CollectionCooldownPolicy.arm(1_000L, CollectionIntervalRange(15, 15)) { start, end -> + assertEquals(15, start) + assertEquals(15, end) + 15 + }, + ) + } + + @Test + fun `random range includes both boundaries without probabilistic assertions`() { + var randomCalls = 0 + assertEquals( + CollectionCooldownTicket(2_000L, 17_000L, 15), + CollectionCooldownPolicy.arm(2_000L, CollectionIntervalRange(15, 30)) { start, _ -> + randomCalls++ + start + }, + ) + assertEquals(1, randomCalls) + assertEquals( + CollectionCooldownTicket(2_000L, 32_000L, 30), + CollectionCooldownPolicy.arm(2_000L, CollectionIntervalRange(15, 30)) { _, end -> end }, ) } @Test fun `remaining seconds round up and expired ticket is cleared`() { - val ticket = CollectionCooldownTicket(16_000L, 15) + val ticket = CollectionCooldownTicket(1_000L, 16_000L, 15) assertEquals(15, CollectionCooldownPolicy.remainingSeconds(1_000L, ticket)) assertEquals(1, CollectionCooldownPolicy.remainingSeconds(15_999L, ticket)) assertEquals(0, CollectionCooldownPolicy.remainingSeconds(16_000L, ticket)) @@ -40,16 +65,18 @@ class CollectionCooldownPolicyTest { @Test fun `clock rollback is clamped to the original interval`() { - val ticket = CollectionCooldownTicket(20_000L, 15) + val ticket = CollectionCooldownTicket(5_000L, 20_000L, 15) assertEquals( - CollectionCooldownTicket(5_000L, 15), + CollectionCooldownTicket(-10_000L, 5_000L, 15), CollectionCooldownPolicy.normalize(-10_000L, ticket), ) } @Test fun `ticket keeps original duration when setting changes later`() { - val ticket = requireNotNull(CollectionCooldownPolicy.arm(10_000L, 20)) + val ticket = requireNotNull( + CollectionCooldownPolicy.arm(10_000L, CollectionIntervalRange(20, 40)) { _, _ -> 20 }, + ) assertEquals(20, ticket.durationSeconds) assertEquals(15, CollectionIntervalPolicy.stored(-1)) assertEquals(20, CollectionCooldownPolicy.remainingSeconds(10_000L, ticket)) diff --git a/docs/03-business-rules-and-glossary.md b/docs/03-business-rules-and-glossary.md index f3d2bf3..d7fe83f 100644 --- a/docs/03-business-rules-and-glossary.md +++ b/docs/03-business-rules-and-glossary.md @@ -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: d234ec2be1a4f6d01a9a46a90a901abdebd229be -synchronized_at: 2026-08-26T14:04:38Z +wiki_revision: 800b2e0845f8cc69759246d33083793df4cbef7f +synchronized_at: 2026-08-27T01:31:41Z # 业务规则与术语 @@ -265,10 +265,10 @@ synchronized_at: 2026-08-26T14:04:38Z ## Agent 采集任务间隔 -- Agent 设置页提供设备本地“采集间隔”,允许 0~600 秒整数,默认 15 秒;0 表示不等待。设置只影响之后结束的采集任务,已开始的间隔不随设置修改。 -- 只有采集任务已经开始且成功向服务端提交完成、部分完成或失败结果后才开始间隔;领取前失败、配置/认证错误或结果未安全提交不开始间隔。采购任务结束不开始采集间隔。 +- Agent 设置页提供设备本地“采集任务执行间隔”,起止值均只接受 0~600 秒整数,默认 `15~15`;每次在闭区间内等概率随机一个整数秒。`0~0` 表示不等待,范围包含 0 时允许本次抽到 0。设置只影响之后结束的采集任务,已开始的间隔不随设置修改。 +- 只有采集任务已经开始且成功向服务端提交完成、部分完成或失败结果后,Agent 才按当时保存的范围随机一次并开始间隔;领取前失败、配置/认证错误或结果未安全提交不随机也不开始间隔。采购任务结束不开始采集间隔。 - 间隔只阻止下一次采集任务的请求、领取和开始;心跳、采购任务优先领取与执行、采购 Outbox、历史刷新和同步继续运行。间隔结束只触发现有调度器一次,不新增轮询器。 - 状态页下拉检查和采集记录“重新采集”不得绕过间隔,统一提示“采集间隔中,还需 N 秒”。 -- 间隔状态持久保存在设备本地,Agent 进程或前台服务重启后继续等待;系统时间回拨时最多按该次原始间隔重新计算,避免无限等待。 +- 本次抽中的秒数、开始时间和截止时间作为同一间隔票据持久保存在设备本地;Agent 进程或前台服务重启、设置修改都只恢复剩余时间,不重新随机。系统时间回拨时最多按该次已抽中的原始秒数截断,避免无限等待。 - 间隔期间保持屏幕常亮;既有“任务结束 15 秒后返回 Agent”是独立机制,返回 Agent 不清除采集间隔。采购任务接管执行时不重复持有间隔亮屏锁,采购完成后若间隔尚未结束则恢复。 - 该设置不上传服务端,不改变采集任务状态、租约、规则快照或服务端接口,不打开 PDD、不修改地址、不创建订单、不支付。 diff --git a/docs/04-local-development-and-verification.md b/docs/04-local-development-and-verification.md index 89dd027..f8dfff7 100644 --- a/docs/04-local-development-and-verification.md +++ b/docs/04-local-development-and-verification.md @@ -2,8 +2,8 @@ generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件) wiki_page: Local-Development-and-Verification wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Local-Development-and-Verification.- -wiki_revision: c31d953495bb18ebfb1c7de0015e987c42dd43e4 -synchronized_at: 2026-08-26T14:05:00Z +wiki_revision: a75499ddf3d238810d1f845050cdd4b46e49297e +synchronized_at: 2026-08-27T01:31:49Z # 本地开发与验证 @@ -243,9 +243,9 @@ adb shell am start -n cn.ilapage.goauto.agent/.MainActivity ### Android Agent 采集任务间隔检查(#102) -- 运行 `cd android && .\\gradlew.bat testDebugUnitTest assembleDebug assembleRelease`;单元测试至少覆盖 0/15/600 秒、越界输入、倒计时向上取整、过期清理、系统时间回拨截断和亮屏策略。 -- 真机覆盖安装前确认设备无活动任务。设置页检查默认 15 秒、0~600 整数校验、保存反馈,以及修改设置不改变已经开始的倒计时。 +- 运行 `cd android && .\\gradlew.bat testDebugUnitTest assembleDebug assembleRelease`;单元测试至少覆盖两个输入的 0/15/600 秒边界、起始值大于结束值、`0~0`、固定范围、包含上下边界的可控随机、倒计时向上取整、过期清理、系统时间回拨截断和亮屏策略。随机源必须可注入,禁止概率性测试。 +- 真机覆盖安装前确认设备无活动任务。设置页检查“采集任务执行间隔”默认 `15~15` 秒、两个 0~600 整数输入、左值不得大于右值、保存反馈,以及修改设置不改变已经开始的倒计时。 - 分别以成功、部分完成和失败的采集任务确认:结果被服务端接收后进入“在线 · 采集间隔中”;状态页下拉和采集记录“重新采集”均显示剩余秒数且不能绕过;间隔结束后现有调度器继续领取下一条采集任务。 - 间隔期间创建采购任务,确认采购仍优先执行;采购结束后若原间隔未到期,采集继续等待。心跳、采购 Outbox、记录刷新与同步不受影响。 -- 间隔期间重启 Agent 前台服务,确认倒计时按设备本地状态恢复;使用 `adb shell dumpsys power` 确认 `:collection-cooldown` WakeLock 有界持有并在到期或服务停止后释放。 +- 将范围设为非固定值并完成一次采集,记录状态页显示的实际剩余秒数;间隔期间重启 Agent 前台服务,确认按同一抽取结果恢复且没有重新随机。使用 `adb shell dumpsys power` 确认 `:collection-cooldown` WakeLock 有界持有并在到期或服务停止后释放。 - 此项验证不要求创建正式采购订单;没有单独授权时不得点击创建订单,永久禁止支付。