feat(agent): randomize collection task interval (#102)
This commit is contained in:
+1
-1
@@ -20,7 +20,7 @@
|
||||
|
||||
任务结果成功提交(采购结果也可以先安全写入本地 Outbox)后,Agent 进入 15 秒自动返回窗口。它沿用现有任务轮询依次确认采购、采集队列都为空,并确认用户没有离开本次自动化打开的 PDD/浏览器页面,才返回 Agent 状态页。新任务、网络异常、待恢复的不可逆采购边界或前台应用变化都会取消自动返回。
|
||||
|
||||
采集结果被服务端安全接收后还会按设置页的“采集间隔”等待下一次采集,默认 15 秒、范围 0~600 秒;采购仍可优先执行,手动检查和重新采集不能绕过。该间隔持久化到设备本地,与自动返回窗口相互独立。任务执行、自动返回窗口和采集间隔期间有界保持亮屏,结束后释放;不会自动解锁 PIN、图案或密码。
|
||||
采集结果被服务端安全接收后还会按设置页的“采集任务执行间隔”等待下一次采集;起止值均为 0~600 秒整数,默认 `15~15`,每次在闭区间内随机一次。采购仍可优先执行,手动检查和重新采集不能绕过;本次抽中的秒数、开始时间和截止时间会一起持久化,重启或修改设置不会重新随机。该间隔与自动返回窗口相互独立。任务执行、自动返回窗口和采集间隔期间有界保持亮屏,结束后释放;不会自动解锁 PIN、图案或密码。
|
||||
|
||||
## T05 真机验证记录
|
||||
|
||||
|
||||
@@ -577,7 +577,7 @@ class AgentForegroundService : Service() {
|
||||
beginIdleReturnCooldown()
|
||||
val ticket = CollectionCooldownPolicy.arm(
|
||||
System.currentTimeMillis(),
|
||||
settingsStore.collectionIntervalSeconds(),
|
||||
settingsStore.collectionIntervalRange(),
|
||||
)
|
||||
if (ticket == null) {
|
||||
stateStore.clearCollectionCooldown()
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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)}" },
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 业务规则与术语
|
||||
@@ -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、不修改地址、不创建订单、不支付。
|
||||
|
||||
@@ -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
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 本地开发与验证
|
||||
@@ -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 有界持有并在到期或服务停止后释放。
|
||||
- 此项验证不要求创建正式采购订单;没有单独授权时不得点击创建订单,永久禁止支付。
|
||||
|
||||
Reference in New Issue
Block a user