From eec63a06532897d58c436bcc0f375c17372aced2 Mon Sep 17 00:00:00 2001 From: QiuSW <105186638@qq.com> Date: Wed, 26 Aug 2026 16:10:12 +0800 Subject: [PATCH] feat: sync agent task history (#99) --- android/app/build.gradle.kts | 4 +- .../goauto/agent/network/AgentApiClient.kt | 13 +- .../agent/persistence/TaskHistoryCache.kt | 88 +++++++++++ .../goauto/agent/service/AgentStateStore.kt | 20 +++ .../goauto/agent/ui/AgentSettingsFragment.kt | 141 ++++++++++++++++++ .../goauto/agent/ui/TaskHistoryFragment.kt | 96 ++++++++++-- .../goauto/agent/HistoryRangePolicyTest.kt | 15 ++ docs/02-architecture-and-code-map.md | 12 +- docs/03-business-rules-and-glossary.md | 12 +- docs/04-local-development-and-verification.md | 12 +- docs/08-agent-api-contract.md | 19 ++- server/app/goauto/purchase/agent_history.go | 17 ++- .../app/goauto/purchase/agent_history_test.go | 24 +++ server/app/goauto/purchase/handler.go | 7 +- server/app/goauto/task/agent_history.go | 17 ++- server/app/goauto/task/agent_history_test.go | 22 +++ server/app/goauto/task/handler.go | 7 +- 17 files changed, 492 insertions(+), 34 deletions(-) create mode 100644 android/app/src/main/java/cn/ilapage/goauto/agent/persistence/TaskHistoryCache.kt create mode 100644 android/app/src/test/java/cn/ilapage/goauto/agent/HistoryRangePolicyTest.kt diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index c8504f2..1187a3e 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -11,8 +11,8 @@ android { applicationId = "cn.ilapage.goauto.agent" minSdk = 23 targetSdk = 34 - versionCode = 11 - versionName = "0.7.0" + versionCode = 12 + versionName = "0.8.0" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" diff --git a/android/app/src/main/java/cn/ilapage/goauto/agent/network/AgentApiClient.kt b/android/app/src/main/java/cn/ilapage/goauto/agent/network/AgentApiClient.kt index d79b8c4..5741efd 100644 --- a/android/app/src/main/java/cn/ilapage/goauto/agent/network/AgentApiClient.kt +++ b/android/app/src/main/java/cn/ilapage/goauto/agent/network/AgentApiClient.kt @@ -229,8 +229,8 @@ class AgentApiClient(private val serverUrl: String) { requireNotNull(request("POST", "/api/agent/v1/purchase-tasks/$taskId/result", payload, token)) } - fun collectionHistory(token: String, page: Int, status: String?, taskNo: String?): HistoryPage { - val data = requireNotNull(request("GET", historyPath("/api/agent/v1/collection-tasks", page, status, taskNo), null, token)).getJSONObject("data") + fun collectionHistory(token: String, page: Int, status: String?, taskNo: String?, days: Int = 30, pageSize: Int = 20): HistoryPage { + val data = requireNotNull(request("GET", historyPath("/api/agent/v1/collection-tasks", page, status, taskNo, days, pageSize), null, token)).getJSONObject("data") return HistoryPage( items = data.getJSONArray("items").objects(::collectionHistoryItem), total = data.getLong("total"), page = data.getInt("page"), pageSize = data.getInt("pageSize"), @@ -260,8 +260,8 @@ class AgentApiClient(private val serverUrl: String) { return CollectionResetResult(data.getLong("taskId"), data.getString("status"), data.optBoolean("replayed")) } - fun purchaseHistory(token: String, page: Int, status: String?, taskNo: String?): HistoryPage { - val data = requireNotNull(request("GET", historyPath("/api/agent/v1/purchase-tasks", page, status, taskNo), null, token)).getJSONObject("data") + fun purchaseHistory(token: String, page: Int, status: String?, taskNo: String?, days: Int = 30, pageSize: Int = 20): HistoryPage { + val data = requireNotNull(request("GET", historyPath("/api/agent/v1/purchase-tasks", page, status, taskNo, days, pageSize), null, token)).getJSONObject("data") return HistoryPage( items = data.getJSONArray("items").objects(::purchaseHistoryItem), total = data.getLong("total"), page = data.getInt("page"), pageSize = data.getInt("pageSize"), @@ -282,8 +282,9 @@ class AgentApiClient(private val serverUrl: String) { ) } - private fun historyPath(base: String, page: Int, status: String?, taskNo: String?): String { - val values = mutableListOf("page=${page.coerceAtLeast(1)}", "pageSize=20") + private fun historyPath(base: String, page: Int, status: String?, taskNo: String?, days: Int, pageSize: Int): String { + require(days in 1..30) { "记录范围必须在 1 到 30 天之间" } + val values = mutableListOf("page=${page.coerceAtLeast(1)}", "pageSize=${pageSize.coerceIn(1, 50)}", "days=$days") status?.takeIf { it.isNotBlank() }?.let { values += "status=${encode(it)}" } taskNo?.takeIf { it.isNotBlank() }?.let { values += "taskNo=${encode(it.trim())}" } return "$base?${values.joinToString("&")}" diff --git a/android/app/src/main/java/cn/ilapage/goauto/agent/persistence/TaskHistoryCache.kt b/android/app/src/main/java/cn/ilapage/goauto/agent/persistence/TaskHistoryCache.kt new file mode 100644 index 0000000..6eec524 --- /dev/null +++ b/android/app/src/main/java/cn/ilapage/goauto/agent/persistence/TaskHistoryCache.kt @@ -0,0 +1,88 @@ +package cn.ilapage.goauto.agent.persistence + +import android.content.Context +import cn.ilapage.goauto.agent.network.CollectionHistoryItem +import cn.ilapage.goauto.agent.network.HistoryPage +import cn.ilapage.goauto.agent.network.PurchaseHistoryItem +import org.json.JSONArray +import org.json.JSONObject + +/** Stores only task-list summaries for the current device. Tokens and task details are never cached here. */ +class TaskHistoryCache(context: Context) { + private val preferences = context.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE) + + fun saveCollection(days: Int, items: List) = save(COLLECTION, days, JSONArray().apply { + items.forEach { item -> put(JSONObject() + .put("taskId", item.taskId).put("status", item.status).put("goodsId", item.goodsId) + .putNullable("title", item.title).put("missingCount", item.missingCount) + .putNullable("errorCode", item.errorCode).putNullable("errorMessage", item.errorMessage) + .putNullable("finishedAt", item.finishedAt).put("createdAt", item.createdAt)) } + }) + + fun savePurchase(days: Int, items: List) = save(PURCHASE, days, JSONArray().apply { + items.forEach { item -> put(JSONObject() + .put("taskId", item.taskId).put("status", item.status).put("shopeeOrderNo", item.shopeeOrderNo) + .put("pddGoodsId", item.pddGoodsId).put("pddTitle", item.pddTitle) + .put("mappedColor", item.mappedColor).put("mappedSize", item.mappedSize).put("quantity", item.quantity) + .putNullable("actualUnitPriceCent", item.actualUnitPriceCent).put("currency", item.currency) + .putNullable("pddOrderNo", item.pddOrderNo).putNullable("orderSubmittedAt", item.orderSubmittedAt) + .putNullable("errorCode", item.errorCode).putNullable("errorMessage", item.errorMessage) + .put("retryable", item.retryable).putNullable("retryDisabledReason", item.retryDisabledReason) + .put("createdAt", item.createdAt)) } + }) + + fun collection(days: Int, page: Int, status: String?, taskNo: String?): HistoryPage? { + val values = read(COLLECTION, days)?.map { data -> CollectionHistoryItem( + data.getLong("taskId"), data.getString("status"), data.getString("goodsId"), data.nullableString("title"), + data.optInt("missingCount"), data.nullableString("errorCode"), data.nullableString("errorMessage"), + data.nullableString("finishedAt"), data.getString("createdAt"), + ) } ?: return null + return page(values.filter { status.isNullOrBlank() || it.status == status }.filterTask(taskNo) { it.taskId }, page) + } + + fun purchase(days: Int, page: Int, status: String?, taskNo: String?): HistoryPage? { + val values = read(PURCHASE, days)?.map { data -> PurchaseHistoryItem( + data.getLong("taskId"), data.getString("status"), data.optString("shopeeOrderNo"), data.getString("pddGoodsId"), + data.optString("pddTitle"), data.optString("mappedColor"), data.optString("mappedSize"), data.getLong("quantity"), + data.nullableLong("actualUnitPriceCent"), data.optString("currency"), data.nullableString("pddOrderNo"), + data.nullableString("orderSubmittedAt"), data.nullableString("errorCode"), data.nullableString("errorMessage"), + data.optBoolean("retryable"), data.nullableString("retryDisabledReason"), data.getString("createdAt"), + ) } ?: return null + return page(values.filter { status.isNullOrBlank() || it.status == status }.filterTask(taskNo) { it.taskId }, page) + } + + private fun save(key: String, days: Int, values: JSONArray) { + require(days in 1..30) + check(preferences.edit().putInt("${key}_days", days).putString(key, values.toString()).commit()) { "无法保存任务记录" } + } + + private fun read(key: String, days: Int): List? { + if (preferences.getInt("${key}_days", 0) != days) return null + val raw = preferences.getString(key, null) ?: return null + val array = runCatching { JSONArray(raw) }.getOrNull() ?: return null + return (0 until array.length()).map(array::getJSONObject) + } + + private fun List.filterTask(raw: String?, id: (T) -> Long): List { + val number = raw.orEmpty().filter(Char::isDigit).toLongOrNull() ?: return this + return filter { id(it) == number } + } + + private fun page(items: List, page: Int): HistoryPage { + val current = page.coerceAtLeast(1) + val from = ((current - 1) * PAGE_SIZE).coerceAtMost(items.size) + val to = (from + PAGE_SIZE).coerceAtMost(items.size) + return HistoryPage(items.subList(from, to), items.size.toLong(), current, PAGE_SIZE) + } + + private fun JSONObject.putNullable(key: String, value: Any?): JSONObject = put(key, value ?: JSONObject.NULL) + private fun JSONObject.nullableString(key: String): String? = if (isNull(key)) null else optString(key).takeIf(String::isNotBlank) + private fun JSONObject.nullableLong(key: String): Long? = if (isNull(key)) null else optLong(key) + + private companion object { + const val PREFERENCES = "goauto_task_history_cache" + const val COLLECTION = "collection" + const val PURCHASE = "purchase" + const val PAGE_SIZE = 20 + } +} 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 27d245b..8707ab9 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 @@ -15,6 +15,11 @@ data class AgentState( val keepScreenOn: Boolean, ) +internal object HistoryRangePolicy { + val options = setOf(1, 3, 7, 15, 30) + fun stored(value: Int): Int = value.takeIf { it in options } ?: 7 +} + class AgentSettingsStore(context: Context) { private val preferences = context.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE) @@ -23,6 +28,19 @@ class AgentSettingsStore(context: Context) { fun deviceName(): String = preferences.getString(DEVICE_NAME, "").orEmpty() + fun historyDays(): Int = HistoryRangePolicy.stored(preferences.getInt(HISTORY_DAYS, 7)) + + fun saveHistoryDays(days: Int) { + require(days in HistoryRangePolicy.options) { "记录范围无效" } + check(preferences.edit().putInt(HISTORY_DAYS, days).commit()) { "无法保存记录范围" } + } + + fun lastHistorySyncAt(): Long = preferences.getLong(LAST_HISTORY_SYNC_AT, 0L) + + fun saveLastHistorySyncAt(value: Long) { + preferences.edit().putLong(LAST_HISTORY_SYNC_AT, value).apply() + } + fun saveServerUrl(value: String): String { val normalized = ServerUrlPolicy.normalize(value, BuildConfig.DEBUG) check(preferences.edit().putString(SERVER_URL, normalized).commit()) { "无法保存服务端地址" } @@ -46,6 +64,8 @@ class AgentSettingsStore(context: Context) { const val PREFERENCES = "goauto_agent_settings" const val SERVER_URL = "server_url" const val DEVICE_NAME = "device_name" + const val HISTORY_DAYS = "history_days" + const val LAST_HISTORY_SYNC_AT = "last_history_sync_at" } } 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 b7a080a..0c5069c 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 @@ -10,6 +10,7 @@ import android.provider.Settings import android.view.View import android.view.ViewGroup import android.view.inputmethod.EditorInfo +import android.widget.HorizontalScrollView import android.widget.LinearLayout import android.widget.TextView import androidx.fragment.app.Fragment @@ -17,7 +18,10 @@ import cn.ilapage.goauto.agent.BuildConfig import cn.ilapage.goauto.agent.R import cn.ilapage.goauto.agent.identity.SecureDeviceStore import cn.ilapage.goauto.agent.network.AgentApiClient +import cn.ilapage.goauto.agent.network.CollectionHistoryItem +import cn.ilapage.goauto.agent.network.PurchaseHistoryItem import cn.ilapage.goauto.agent.network.ServerUrlPolicy +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 @@ -26,6 +30,9 @@ import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.textfield.TextInputEditText import com.google.android.material.textfield.TextInputLayout import java.util.concurrent.Executors +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale class AgentSettingsFragment : Fragment() { private val handler = Handler(Looper.getMainLooper()) @@ -43,7 +50,11 @@ class AgentSettingsFragment : Fragment() { private lateinit var disabledReason: TextView private lateinit var diagnostics: TextView private lateinit var accessibilityText: TextView + private lateinit var historySyncButton: MaterialButton + private lateinit var historySyncFeedback: TextView + private val historyDayButtons = linkedMapOf() private var testing = false + private var syncingHistory = false private val refresh = object : Runnable { override fun run() { refreshDiagnostics() @@ -134,6 +145,39 @@ class AgentSettingsFragment : Fragment() { disabledReason.setPadding(0, context.dp(8), 0, 0) addView(disabledReason) })) + addView(context.card(context.cardColumn().apply { + addView(context.label("任务记录", 18f, context.getColor(R.color.agent_text), true)) + addView(context.label("同步这台设备的采集和采购记录", 14f, context.getColor(R.color.agent_text_muted)).apply { + setPadding(0, context.dp(6), 0, 0) + }) + val dayRow = LinearLayout(context).apply { orientation = LinearLayout.HORIZONTAL } + listOf(1, 3, 7, 15, 30).forEach { days -> + val button = MaterialButton(context, null, com.google.android.material.R.attr.materialButtonOutlinedStyle).apply { + text = "${days}天" + minHeight = context.dp(48) + contentDescription = "同步最近 $days 天任务记录" + setOnClickListener { selectHistoryDays(days) } + } + historyDayButtons[days] = button + dayRow.addView(button, LinearLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, context.dp(48)).apply { + marginEnd = context.dp(8) + }) + } + addView(HorizontalScrollView(context).apply { + isHorizontalScrollBarEnabled = false + addView(dayRow) + }, fullWidth(10)) + historySyncButton = MaterialButton(context).apply { + text = "同步记录" + minHeight = context.dp(48) + setOnClickListener { syncHistory() } + } + addView(historySyncButton, fullWidth(8)) + historySyncFeedback = context.label(historySyncSummary(), 14f, context.getColor(R.color.agent_text_muted)) + historySyncFeedback.setPadding(0, context.dp(8), 0, 0) + addView(historySyncFeedback) + selectHistoryDays(settingsStore.historyDays(), persist = false) + })) addView(context.card(context.cardColumn().apply { addView(context.label("设备与诊断", 18f, context.getColor(R.color.agent_text), true)) diagnostics = context.label("—", 14f, context.getColor(R.color.agent_text)) @@ -227,6 +271,102 @@ class AgentSettingsFragment : Fragment() { refreshDiagnostics() } + private fun selectHistoryDays(days: Int, persist: Boolean = true) { + if (syncingHistory) return + if (persist) settingsStore.saveHistoryDays(days) + historyDayButtons.forEach { (value, button) -> + val selected = value == days + button.isSelected = selected + button.backgroundTintList = ColorStateList.valueOf(requireContext().getColor( + if (selected) R.color.agent_primary else R.color.agent_surface, + )) + button.setTextColor(requireContext().getColor(if (selected) R.color.agent_text else R.color.agent_text_muted)) + } + } + + private fun syncHistory() { + val serverUrl = settingsStore.serverUrl() + val credentials = runCatching { identityStore.credentials() }.getOrNull() + if (serverUrl.isBlank() || credentials == null) { + historySyncFeedback.text = "同步失败:请先连接服务端" + historySyncFeedback.setTextColor(requireContext().getColor(R.color.agent_error)) + return + } + val days = settingsStore.historyDays() + syncingHistory = true + historySyncButton.isEnabled = false + historySyncButton.text = "正在同步…" + historySyncFeedback.text = "正在同步最近 $days 天记录…" + historyDayButtons.values.forEach { it.isEnabled = false } + val cache = TaskHistoryCache(requireContext().applicationContext) + executor.execute { + val api = AgentApiClient(serverUrl) + val collections = runCatching { + fetchCollectionHistory(api, credentials.token, days).also { cache.saveCollection(days, it) } + } + val purchases = runCatching { + fetchPurchaseHistory(api, credentials.token, days).also { cache.savePurchase(days, it) } + } + if (collections.isSuccess || purchases.isSuccess) settingsStore.saveLastHistorySyncAt(System.currentTimeMillis()) + activity?.runOnUiThread { + if (!isAdded || view == null) return@runOnUiThread + syncingHistory = false + historySyncButton.isEnabled = true + historySyncButton.text = "同步记录" + historyDayButtons.values.forEach { it.isEnabled = true } + val message = when { + collections.isSuccess && purchases.isSuccess -> { + val collectionCount = collections.getOrThrow().size + val purchaseCount = purchases.getOrThrow().size + if (collectionCount + purchaseCount == 0) "同步完成:所选范围内暂无记录" + else "同步完成:采集 ${collectionCount} 条,采购 ${purchaseCount} 条" + } + collections.isSuccess -> "部分同步完成:采集已更新,采购同步失败" + purchases.isSuccess -> "部分同步完成:采购已更新,采集同步失败" + else -> "同步失败,请检查网络后重试" + } + historySyncFeedback.text = "$message\n${historySyncSummary()}" + historySyncFeedback.setTextColor(requireContext().getColor( + if (collections.isSuccess && purchases.isSuccess) R.color.agent_primary_light + else if (collections.isSuccess || purchases.isSuccess) R.color.agent_warning else R.color.agent_error, + )) + } + } + } + + private fun fetchCollectionHistory(api: AgentApiClient, token: String, days: Int): List { + val items = mutableListOf() + var page = 1 + var total = 0L + do { + val result = api.collectionHistory(token, page, null, null, days, 50) + items += result.items + total = result.total + page++ + } while (items.size < total && page <= MAX_SYNC_PAGES) + check(items.size >= total) { "采集记录过多,请缩小同步范围" } + return items + } + + private fun fetchPurchaseHistory(api: AgentApiClient, token: String, days: Int): List { + val items = mutableListOf() + var page = 1 + var total = 0L + do { + val result = api.purchaseHistory(token, page, null, null, days, 50) + items += result.items + total = result.total + page++ + } while (items.size < total && page <= MAX_SYNC_PAGES) + check(items.size >= total) { "采购记录过多,请缩小同步范围" } + return items + } + + private fun historySyncSummary(): String { + val timestamp = settingsStore.lastHistorySyncAt() + return if (timestamp <= 0) "尚未同步" else "最近同步:${SimpleDateFormat("MM-dd HH:mm", Locale.getDefault()).format(Date(timestamp))}" + } + private fun validateInputs(requireName: Boolean): String? { serverLayout.error = null nameLayout.error = null @@ -282,5 +422,6 @@ class AgentSettingsFragment : Fragment() { private companion object { const val REFRESH_MILLIS = 1_000L + const val MAX_SYNC_PAGES = 100 } } diff --git a/android/app/src/main/java/cn/ilapage/goauto/agent/ui/TaskHistoryFragment.kt b/android/app/src/main/java/cn/ilapage/goauto/agent/ui/TaskHistoryFragment.kt index e1d3df0..918c36d 100644 --- a/android/app/src/main/java/cn/ilapage/goauto/agent/ui/TaskHistoryFragment.kt +++ b/android/app/src/main/java/cn/ilapage/goauto/agent/ui/TaskHistoryFragment.kt @@ -13,7 +13,9 @@ import android.widget.EditText import android.widget.HorizontalScrollView import android.widget.LinearLayout import android.widget.ProgressBar +import android.widget.Toast import androidx.fragment.app.Fragment +import androidx.swiperefreshlayout.widget.SwipeRefreshLayout import cn.ilapage.goauto.agent.R import cn.ilapage.goauto.agent.identity.SecureDeviceStore import cn.ilapage.goauto.agent.network.AgentApiClient @@ -22,6 +24,7 @@ import cn.ilapage.goauto.agent.network.CollectionHistoryItem import cn.ilapage.goauto.agent.network.HistoryPage import cn.ilapage.goauto.agent.network.PurchaseHistoryDetail import cn.ilapage.goauto.agent.network.PurchaseHistoryItem +import cn.ilapage.goauto.agent.persistence.TaskHistoryCache import cn.ilapage.goauto.agent.service.AgentForegroundService import cn.ilapage.goauto.agent.service.AgentSettingsStore import com.google.android.material.button.MaterialButton @@ -76,24 +79,39 @@ class TaskHistoryFragment : Fragment() { private val collection: Boolean get() = requireArguments().getBoolean(ARG_COLLECTION) private lateinit var pageColumn: LinearLayout private lateinit var resultColumn: LinearLayout + private lateinit var swipeRefresh: SwipeRefreshLayout private lateinit var searchInput: EditText private var selectedStatus = "" private var page = 1 private var total = 0L private var requestGeneration = 0 + private var loading = false + private var pageSignature: String? = null override fun onCreateView(inflater: android.view.LayoutInflater, container: ViewGroup?, state: Bundle?): View { val context = requireContext() pageColumn = context.column() + val historyDays = AgentSettingsStore(context).historyDays() pageColumn.addView(context.screenTitle( if (collection) "采集记录" else "采购记录", - if (collection) "查看当前设备最近 30 天的采集任务" else "当前设备采购记录仅供查看", + if (collection) "查看当前设备最近 $historyDays 天的采集任务" else "当前设备最近 $historyDays 天采购记录", )) pageColumn.addView(buildSearch()) pageColumn.addView(buildFilters()) resultColumn = context.column(0).apply { setPadding(0, context.dp(12), 0, 0) } pageColumn.addView(resultColumn, resultColumn.fullWidth()) - return context.page(pageColumn) + return SwipeRefreshLayout(context).also { swipe -> + swipeRefresh = swipe + swipe.setColorSchemeResources(R.color.agent_primary_light) + swipe.setProgressBackgroundColorSchemeResource(R.color.agent_surface) + swipe.setOnRefreshListener { + if (loading) swipe.isRefreshing = false else { + toast("正在刷新…") + load(pullRefresh = true) + } + } + swipe.addView(context.page(pageColumn), ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)) + } } override fun onResume() { @@ -225,35 +243,61 @@ class TaskHistoryFragment : Fragment() { load() } - private fun load() { + private fun load(pullRefresh: Boolean = false) { if (!this::resultColumn.isInitialized) return val context = requireContext() val settings = AgentSettingsStore(context) val credentials = runCatching { SecureDeviceStore(context).credentials() }.getOrNull() if (settings.serverUrl().isBlank() || credentials == null) { + finishRefresh() showError("设备尚未连接服务端", "请先在设置页保存服务器地址,并等待设备注册成功。") return } + val days = settings.historyDays() val generation = ++requestGeneration - showLoading(if (searchInput.text.isNullOrBlank()) "正在读取当前设备的任务记录…" else "正在搜索任务…") + loading = true + if (!pullRefresh) showLoading(if (searchInput.text.isNullOrBlank()) "正在读取当前设备的任务记录…" else "正在搜索任务…") Thread { runCatching { val api = AgentApiClient(settings.serverUrl()) - if (collection) api.collectionHistory(credentials.token, page, selectedStatus.takeIf(String::isNotBlank), searchInput.text.toString()) - else api.purchaseHistory(credentials.token, page, selectedStatus.takeIf(String::isNotBlank), searchInput.text.toString()) - }.onSuccess { result -> resultColumn.post { if (isAdded && generation == requestGeneration) renderPage(result) } } - .onFailure { error -> resultColumn.post { if (isAdded && generation == requestGeneration) showError("暂时无法读取任务记录", error.message ?: "请检查网络后重新加载。") } } + if (collection) api.collectionHistory(credentials.token, page, selectedStatus.takeIf(String::isNotBlank), searchInput.text.toString(), days) + else api.purchaseHistory(credentials.token, page, selectedStatus.takeIf(String::isNotBlank), searchInput.text.toString(), days) + }.onSuccess { result -> resultColumn.post { + if (!isAdded || generation != requestGeneration) return@post + val changed = pageSignature != signature(result) + renderPage(result) + if (pullRefresh) toast(if (changed) "已更新" else "已是最新") + finishRefresh() + } }.onFailure { error -> resultColumn.post { + if (!isAdded || generation != requestGeneration) return@post + if (pullRefresh) { + toast("刷新失败,请重试") + } else { + val cached = TaskHistoryCache(requireContext()).let { cache -> + if (collection) cache.collection(days, page, selectedStatus.takeIf(String::isNotBlank), searchInput.text.toString()) + else cache.purchase(days, page, selectedStatus.takeIf(String::isNotBlank), searchInput.text.toString()) + } + if (cached != null) { + renderPage(cached) + toast("网络不可用,显示最近同步记录") + } else showError("暂时无法读取任务记录", error.message ?: "请检查网络后重新加载。") + } + finishRefresh() + } } }.start() } private fun renderPage(result: Any) { + if (this::swipeRefresh.isInitialized) swipeRefresh.isEnabled = true resultColumn.removeAllViews() when (result) { is HistoryPage<*> -> { total = result.total + pageSignature = signature(result) val items = result.items if (items.isEmpty()) { showEmpty(); return } - resultColumn.addView(requireContext().label("当前设备 · ${result.total} 条 最近 30 天", 13f, requireContext().getColor(R.color.agent_text_muted))) + val days = AgentSettingsStore(requireContext()).historyDays() + resultColumn.addView(requireContext().label("当前设备 · ${result.total} 条 最近 $days 天", 13f, requireContext().getColor(R.color.agent_text_muted))) items.forEach { item -> when (item) { is CollectionHistoryItem -> resultColumn.addView(collectionCard(item), collectionCardParams()) @@ -346,6 +390,7 @@ class TaskHistoryFragment : Fragment() { } private fun renderDetail(detail: Any) { + if (this::swipeRefresh.isInitialized) swipeRefresh.isEnabled = false resultColumn.removeAllViews() resultColumn.addView(MaterialButton(requireContext(), null, com.google.android.material.R.attr.materialButtonOutlinedStyle).apply { text = if (collection) "返回采集记录" else "返回采购记录" @@ -541,11 +586,16 @@ class TaskHistoryFragment : Fragment() { private fun showEmpty() { val searching = searchInput.text.toString().isNotBlank() - showMessage( - if (searching) "没有找到${if (collection) "采集" else "采购"}任务 ${searchInput.text}" else "暂无任务记录", - if (searching) "搜索范围仅限当前设备。请检查编号,或清空搜索查看全部记录。" else "新任务由服务端下发,Agent 会自动领取。", - if (searching) "清空搜索" else "刷新", - ) { if (searching) clearSearch() else { page = 1; load() } } + if (searching) { + showMessage( + "没有找到${if (collection) "采集" else "采购"}任务 ${searchInput.text}", + "搜索范围仅限当前设备。请检查编号,或清空搜索查看全部记录。", + "清空搜索", + ) { clearSearch() } + } else { + resultColumn.removeAllViews() + resultColumn.addView(requireContext().centeredMessage("暂无任务记录", "下拉可重新读取当前设备记录。")) + } } private fun showError(title: String, description: String) = showMessage(title, description, "重新加载") { load() } @@ -585,6 +635,24 @@ class TaskHistoryFragment : Fragment() { private fun formatTime(raw: String): String = raw.replace('T', ' ').take(16) + private fun signature(result: Any): String = when (result) { + is HistoryPage<*> -> "${result.total}:${result.page}:${result.items.joinToString("|") { item -> + when (item) { + is CollectionHistoryItem -> "${item.taskId}:${item.status}:${item.finishedAt}:${item.errorCode}:${item.missingCount}" + is PurchaseHistoryItem -> "${item.taskId}:${item.status}:${item.pddOrderNo}:${item.errorCode}" + else -> item.hashCode().toString() + } + }}" + else -> result.hashCode().toString() + } + + private fun finishRefresh() { + loading = false + if (this::swipeRefresh.isInitialized) swipeRefresh.isRefreshing = false + } + + private fun toast(message: String) = Toast.makeText(requireContext(), message, Toast.LENGTH_SHORT).show() + companion object { private const val ARG_COLLECTION = "collection" fun collection() = TaskHistoryFragment().apply { arguments = Bundle().apply { putBoolean(ARG_COLLECTION, true) } } diff --git a/android/app/src/test/java/cn/ilapage/goauto/agent/HistoryRangePolicyTest.kt b/android/app/src/test/java/cn/ilapage/goauto/agent/HistoryRangePolicyTest.kt new file mode 100644 index 0000000..027229b --- /dev/null +++ b/android/app/src/test/java/cn/ilapage/goauto/agent/HistoryRangePolicyTest.kt @@ -0,0 +1,15 @@ +package cn.ilapage.goauto.agent + +import cn.ilapage.goauto.agent.service.HistoryRangePolicy +import org.junit.Assert.assertEquals +import org.junit.Test + +class HistoryRangePolicyTest { + @Test fun acceptsApprovedRanges() { + listOf(1, 3, 7, 15, 30).forEach { assertEquals(it, HistoryRangePolicy.stored(it)) } + } + + @Test fun fallsBackToSevenDays() { + listOf(-1, 0, 2, 31).forEach { assertEquals(7, HistoryRangePolicy.stored(it)) } + } +} diff --git a/docs/02-architecture-and-code-map.md b/docs/02-architecture-and-code-map.md index b061c74..10d7246 100644 --- a/docs/02-architecture-and-code-map.md +++ b/docs/02-architecture-and-code-map.md @@ -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: 18b8c41f545d2c2e883913a57647680a87a570b6 -synchronized_at: 2026-08-26T07:52:55Z +wiki_revision: 5385e32c1d860f4890150701df78437493d56e9d +synchronized_at: 2026-08-26T08:05:06Z # 架构与代码地图 @@ -199,3 +199,11 @@ Android Portal/Agent - 手动请求在已有同步运行时保留一个待处理标记,当前同步结束后再执行一次;重复手势不会并发请求。 - 结果通过仅限当前应用包的广播返回状态页,区分无任务、采集任务、采购任务、设备忙、配置/认证和网络错误。状态页无障碍信息只读,唯一系统设置入口保留在设置 Tab。 - 手动检查不创建、重置或重试任务,不绕过无障碍与身份校验,不打开 PDD,不修改地址、不创建订单、不支付。 + +## Agent 任务记录刷新与摘要缓存(#99) + +- Android 的采集/采购历史页使用单个 `SwipeRefreshLayout` 包裹既有滚动页,只在列表顶部下拉刷新当前查询;请求中合并重复手势,加载失败页仍保留可见重试入口。 +- 设置页保存 1/3/7/15/30 天记录范围(默认 7 天),用当前 Device Token 分页读取采集与采购摘要。两类读取独立结算,支持完整、空、部分和失败反馈。 +- `TaskHistoryCache` 使用独立 SharedPreferences 保存当前同步范围的列表摘要,网络读取失败时可显示最近同步摘要;Token 继续只保存在 Android Keystore 保护的设备身份存储中,任务详情不离线镜像。 +- 服务端两个 Agent 历史列表查询增加 `days=1..30`,并继续在数据库查询中强制设备隔离、分页上限和 30 天最大窗口;详情读取仍保持原 30 天边界。 +- 该链路只读,不接入任务领取、执行、重置、重试、PDD 导航、地址修改、创建订单或支付。 diff --git a/docs/03-business-rules-and-glossary.md b/docs/03-business-rules-and-glossary.md index 7f8038f..c786c8b 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: b2c0e1856e0eb21feadc1997896f74b215b92093 -synchronized_at: 2026-08-26T07:53:01Z +wiki_revision: b5b464d6873cfa090e50492ab0066a44e0ffeb9a +synchronized_at: 2026-08-26T08:05:13Z # 业务规则与术语 @@ -254,3 +254,11 @@ synchronized_at: 2026-08-26T07:53:01Z - 检查中合并重复请求;无任务、领取采集、领取采购、设备忙和网络失败都有文字反馈。网络失败后退出手动刷新状态,既有自动检查继续运行。 - 状态 Tab 只读显示无障碍状态;“打开无障碍设置”只保留在设置 Tab。 - 手动检查不创建、重置或重试任务,不修改任务资格,不打开 PDD、不修改地址、不创建订单、不支付。 + +## Agent 任务记录刷新与同步 + +- 采集和采购 Tab 顶部下拉只刷新当前 Tab、当前筛选、当前编号搜索和当前页;不领取新任务,也不执行、重置、重新采集或重新采购。加载失败页必须保留可见重试入口,空列表也允许下拉。 +- 设置页记录范围仅允许最近 1、3、7、15、30 天,默认 7 天、最大 30 天;同步只读取当前 Device Token 对应设备的采集与采购摘要。 +- 两类记录可以独立成功;界面必须明确显示同步完成、范围内无记录、部分完成或失败以及最近同步时间。 +- 本地仅保存列表摘要和同步时间,详情仍按需读取;不缓存 Token、完整规则快照、PDD URL、地址、控件树或截图。 +- 记录同步是服务端到 Agent 的单向只读能力,不打开 PDD、不修改地址、不创建订单、不支付。 diff --git a/docs/04-local-development-and-verification.md b/docs/04-local-development-and-verification.md index 9172ac8..bb4b4db 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: 894a29a22c9a328027bc9283c97b4c157afc1cd3 -synchronized_at: 2026-08-26T07:53:08Z +wiki_revision: dabfb23f64bc19bee61f104cb59b21cd000f8d51 +synchronized_at: 2026-08-26T08:05:18Z # 本地开发与验证 @@ -232,3 +232,11 @@ adb shell am start -n cn.ilapage.goauto.agent/.MainActivity - 使用服务端日志或任务状态确认手动动作复用了既有 `next → claim → start` 调度;自动轮询仍为 15 秒、采购优先和单设备串行。 - 状态页不再提供无障碍设置按钮或重复设备信息卡;设备身份只显示简短名称与编号,唯一无障碍入口位于设置 Tab。 - 无授权任务时只验证空队列、忙碌、无障碍和网络反馈,不创建正式采购任务或订单,永久禁止支付。 + +### Android Agent 0.8.0 任务记录刷新与同步检查(#99) + +- 运行 `cd android && .\\gradlew.bat testDebugUnitTest assembleDebug assembleRelease`,服务端运行 `go test ./app/goauto/task ./app/goauto/purchase`;确认 `days` 省略时仍为 30,1/3/7/15/30 天有效,0、负数和大于 30 均拒绝。 +- 真机覆盖安装前确认设备无活动任务。采集、采购 Tab 在列表顶部下拉应只刷新当前筛选、编号和当前页;重复下拉不得并发,成功、无变化和失败均有文字反馈,失败页仍有“重新加载”。 +- 设置页默认 7 天,可切换 1/3/7/15/30 天;分别检查完整成功、范围内无记录、单类失败和全部失败,按钮在同步中禁用并在结束后恢复。 +- 断网后打开有缓存的同范围列表,应显示最近同步摘要;任务详情仍需联网读取。检查应用数据中不存在 Device Token 明文、完整规则、PDD URL、地址、控件树或截图。 +- 只读刷新和同步无需创建正式任务;验收不得借此触发重新采集、采购重试、PDD、创建订单或支付。 diff --git a/docs/08-agent-api-contract.md b/docs/08-agent-api-contract.md index a863d46..c4729f7 100644 --- a/docs/08-agent-api-contract.md +++ b/docs/08-agent-api-contract.md @@ -2,8 +2,8 @@ generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件) wiki_page: Android-Agent-API-Contract wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Android-Agent-API-Contract.- -wiki_revision: 4d953447be50aca5abbef5f208d024a516c8b60b -synchronized_at: 2026-08-26T06:30:08Z +wiki_revision: 7e00ef2f741233ccf13d94f7ad31611a85bcd66e +synchronized_at: 2026-08-26T08:05:38Z # MVP 共享 API 契约 @@ -584,3 +584,18 @@ Content-Type: application/json - 成功响应 `data` 返回 `sourceTaskId`、`sourceTaskNo`、新 `taskId`、新 `taskNo` 和 `replayed`。旧任务不清空;新任务为固定当前设备的 `pending`,并使用新地址后缀。 - `requestId` 必须是 UUID;相同请求重放返回同一新任务,不重复创建。请求体不得指定设备、规格、规则、地址、订单或支付参数。 - Android 必须使用服务端 `retryable` 决定是否展示入口,提交前明确提示可能产生新的待付款订单且系统不会支付。真机调用属于可能创建订单的高风险验证,必须先取得人工授权。 + +## Agent 任务记录范围与同步(#99) + +采集、采购任务列表接口新增可选查询参数 `days`: + +```http +GET /api/agent/v1/collection-tasks?page=1&pageSize=20&days=7&status=failed&taskNo=%2335 +GET /api/agent/v1/purchase-tasks?page=1&pageSize=20&days=7&status=failed&taskNo=CG-12 +``` + +- `days` 只允许 1–30 的正整数;省略时为 30,保持旧客户端兼容。详情接口仍限制最近 30 天。 +- 记录范围、状态、任务编号和分页只作用于当前 Device Token 对应设备,不能查询其他设备。 +- Agent 采集/采购 Tab 的顶部下拉只重新读取当前 Tab、当前筛选、当前编号和当前页;不领取、执行、重置或重试任务。 +- 设置 Tab 可选择最近 1、3、7、15、30 天,默认 7 天,一次同步当前设备的采集与采购列表摘要。同步为服务端到 Agent 的单向读取,允许分别成功并显示部分同步结果。 +- 本地只缓存列表摘要和同步时间;详情仍按需请求。缓存不得包含 Device Token、完整规则快照、PDD URL、地址、控件树或截图。同步本身不打开 PDD、不修改地址、不创建订单、不支付。 diff --git a/server/app/goauto/purchase/agent_history.go b/server/app/goauto/purchase/agent_history.go index 4d30f2a..e66b46e 100644 --- a/server/app/goauto/purchase/agent_history.go +++ b/server/app/goauto/purchase/agent_history.go @@ -18,6 +18,7 @@ const agentPurchaseHistoryDays = 30 type AgentHistoryRequest struct { Page int PageSize int + Days int Status string TaskNo string } @@ -59,6 +60,10 @@ func (s *Service) AgentHistory(ctx context.Context, req AgentHistoryRequest, tok return AgentPurchaseList{}, err } req.Page, req.PageSize = normalizeAgentHistoryPage(req.Page, req.PageSize) + req.Days, err = normalizeAgentPurchaseHistoryDays(req.Days) + if err != nil { + return AgentPurchaseList{}, fail(CodeInvalidRequest, "days 必须在 1 到 30 之间") + } if req.Status != "" && !validPurchaseStatus(req.Status) { return AgentPurchaseList{}, fail(CodeInvalidRequest, "status 无效") } @@ -67,7 +72,7 @@ func (s *Service) AgentHistory(ctx context.Context, req AgentHistoryRequest, tok return AgentPurchaseList{}, fail(CodeInvalidRequest, "采购任务编号无效") } query := s.DB.WithContext(ctx).Model(&models.PurchaseTask{}). - Where("device_id = ? AND created_at >= ?", deviceRecord.ID, s.Now().AddDate(0, 0, -agentPurchaseHistoryDays)) + Where("device_id = ? AND created_at >= ?", deviceRecord.ID, s.Now().AddDate(0, 0, -req.Days)) if req.Status != "" { query = query.Where("status = ?", req.Status) } @@ -89,6 +94,16 @@ func (s *Service) AgentHistory(ctx context.Context, req AgentHistoryRequest, tok return AgentPurchaseList{Items: items, Total: total, Page: req.Page, PageSize: req.PageSize}, nil } +func normalizeAgentPurchaseHistoryDays(days int) (int, error) { + if days == 0 { + return agentPurchaseHistoryDays, nil + } + if days < 1 || days > agentPurchaseHistoryDays { + return 0, errors.New("invalid agent purchase history days") + } + return days, nil +} + func (s *Service) AgentHistoryDetail(ctx context.Context, taskID uint64, token string) (AgentPurchaseDetail, error) { deviceRecord, err := device.NewService(s.DB).Authenticate(ctx, token) if err != nil { diff --git a/server/app/goauto/purchase/agent_history_test.go b/server/app/goauto/purchase/agent_history_test.go index 364ad3b..7f8bab2 100644 --- a/server/app/goauto/purchase/agent_history_test.go +++ b/server/app/goauto/purchase/agent_history_test.go @@ -67,3 +67,27 @@ func TestAgentPurchaseHistoryNormalizesNumberAndIsolatesDevice(t *testing.T) { t.Fatalf("expired task was visible: %v", err) } } + +func TestAgentPurchaseHistoryRespectsRequestedDays(t *testing.T) { + db := testDB(t) + f := seed(t, db, liveCaps(), true) + service := testService(db) + task, err := createLive(t, service, f) + if err != nil { + t.Fatal(err) + } + if err := db.Exec("UPDATE purchase_task SET created_at = ? WHERE id = ?", service.Now().AddDate(0, 0, -2), task.ID).Error; err != nil { + t.Fatal(err) + } + oneDay, err := service.AgentHistory(context.Background(), AgentHistoryRequest{Days: 1}, f.token) + if err != nil || oneDay.Total != 0 { + t.Fatalf("one-day history: %+v %v", oneDay, err) + } + threeDays, err := service.AgentHistory(context.Background(), AgentHistoryRequest{Days: 3}, f.token) + if err != nil || threeDays.Total != 1 { + t.Fatalf("three-day history: %+v %v", threeDays, err) + } + if _, err := service.AgentHistory(context.Background(), AgentHistoryRequest{Days: 31}, f.token); code(err) != CodeInvalidRequest { + t.Fatalf("invalid days was accepted: %v", err) + } +} diff --git a/server/app/goauto/purchase/handler.go b/server/app/goauto/purchase/handler.go index 98b0e47..2add9a2 100644 --- a/server/app/goauto/purchase/handler.go +++ b/server/app/goauto/purchase/handler.go @@ -187,12 +187,17 @@ func (h Handler) AgentHistory(c *gin.Context) { writeError(c, fail(CodeInvalidRequest, "pageSize 无效")) return } + days, err := positiveAdminQuery(c.Query("days"), agentPurchaseHistoryDays) + if err != nil || days > agentPurchaseHistoryDays { + writeError(c, fail(CodeInvalidRequest, "days 必须在 1 到 30 之间")) + return + } service, ok := h.service(c) if !ok { return } result, err := service.AgentHistory(c.Request.Context(), AgentHistoryRequest{ - Page: page, PageSize: pageSize, Status: strings.TrimSpace(c.Query("status")), TaskNo: c.Query("taskNo"), + Page: page, PageSize: pageSize, Days: days, Status: strings.TrimSpace(c.Query("status")), TaskNo: c.Query("taskNo"), }, bearer(c.GetHeader("Authorization"))) if err != nil { writeError(c, err) diff --git a/server/app/goauto/task/agent_history.go b/server/app/goauto/task/agent_history.go index 29ca4cf..f9ac7a7 100644 --- a/server/app/goauto/task/agent_history.go +++ b/server/app/goauto/task/agent_history.go @@ -19,6 +19,7 @@ const agentHistoryDays = 30 type AgentHistoryRequest struct { Page int PageSize int + Days int Status string TaskNo string } @@ -72,6 +73,10 @@ func (service *Service) AgentHistory(ctx context.Context, request AgentHistoryRe return AgentCollectionList{}, err } request.Page, request.PageSize = normalizeAgentPage(request.Page, request.PageSize) + request.Days, err = normalizeAgentHistoryDays(request.Days) + if err != nil { + return AgentCollectionList{}, serviceError(device.CodeInvalidRequest, "days 必须在 1 到 30 之间") + } if request.Status != "" && !validCollectionStatus(request.Status) { return AgentCollectionList{}, serviceError(device.CodeInvalidRequest, "status 无效") } @@ -80,7 +85,7 @@ func (service *Service) AgentHistory(ctx context.Context, request AgentHistoryRe return AgentCollectionList{}, serviceError(device.CodeInvalidRequest, "采集任务编号无效") } query := service.DB.WithContext(ctx).Model(&models.CollectionTask{}). - Where("device_id = ? AND created_at >= ?", deviceRecord.ID, service.Now().AddDate(0, 0, -agentHistoryDays)) + Where("device_id = ? AND created_at >= ?", deviceRecord.ID, service.Now().AddDate(0, 0, -request.Days)) if request.Status != "" { query = query.Where("status = ?", request.Status) } @@ -102,6 +107,16 @@ func (service *Service) AgentHistory(ctx context.Context, request AgentHistoryRe return AgentCollectionList{Items: items, Total: total, Page: request.Page, PageSize: request.PageSize}, nil } +func normalizeAgentHistoryDays(days int) (int, error) { + if days == 0 { + return agentHistoryDays, nil + } + if days < 1 || days > agentHistoryDays { + return 0, errors.New("invalid agent history days") + } + return days, nil +} + func (service *Service) AgentHistoryDetail(ctx context.Context, taskID uint64, token string) (AgentCollectionDetail, error) { deviceRecord, err := device.NewService(service.DB).Authenticate(ctx, token) if err != nil { diff --git a/server/app/goauto/task/agent_history_test.go b/server/app/goauto/task/agent_history_test.go index 396eb9f..5e3c6da 100644 --- a/server/app/goauto/task/agent_history_test.go +++ b/server/app/goauto/task/agent_history_test.go @@ -7,6 +7,7 @@ import ( "strings" "testing" + "go-admin/app/goauto/device" "go-admin/app/goauto/models" "gorm.io/gorm" ) @@ -51,3 +52,24 @@ func TestAgentHistoryNormalizesNumberAndIsolatesDevice(t *testing.T) { t.Fatalf("expired task was visible: %v", err) } } + +func TestAgentHistoryRespectsRequestedDays(t *testing.T) { + db := openTaskDatabase(t) + deviceRecord, token := registerTaskDevice(t, db, "history-days") + record := createTask(t, db, &deviceRecord.ID) + service := newTaskService(db) + if err := db.Exec("UPDATE collection_task SET created_at = ? WHERE id = ?", service.Now().AddDate(0, 0, -2), record.ID).Error; err != nil { + t.Fatal(err) + } + oneDay, err := service.AgentHistory(context.Background(), AgentHistoryRequest{Days: 1}, token) + if err != nil || oneDay.Total != 0 { + t.Fatalf("one-day history: %+v %v", oneDay, err) + } + threeDays, err := service.AgentHistory(context.Background(), AgentHistoryRequest{Days: 3}, token) + if err != nil || threeDays.Total != 1 { + t.Fatalf("three-day history: %+v %v", threeDays, err) + } + if _, err := service.AgentHistory(context.Background(), AgentHistoryRequest{Days: 31}, token); taskErrorCode(t, err) != device.CodeInvalidRequest { + t.Fatalf("invalid days was accepted: %v", err) + } +} diff --git a/server/app/goauto/task/handler.go b/server/app/goauto/task/handler.go index e2421e4..85a476f 100644 --- a/server/app/goauto/task/handler.go +++ b/server/app/goauto/task/handler.go @@ -46,12 +46,17 @@ func (handler Handler) AgentHistory(context *gin.Context) { writeError(context, serviceError(device.CodeInvalidRequest, "pageSize 无效")) return } + days, err := positiveQuery(context.Query("days"), agentHistoryDays) + if err != nil || days > agentHistoryDays { + writeError(context, serviceError(device.CodeInvalidRequest, "days 必须在 1 到 30 之间")) + return + } service, token, ok := handler.service(context) if !ok { return } result, err := service.AgentHistory(context.Request.Context(), AgentHistoryRequest{ - Page: page, PageSize: pageSize, Status: strings.TrimSpace(context.Query("status")), TaskNo: context.Query("taskNo"), + Page: page, PageSize: pageSize, Days: days, Status: strings.TrimSpace(context.Query("status")), TaskNo: context.Query("taskNo"), }, token) if err != nil { writeError(context, err)