feat: sync agent task history (#99)
This commit is contained in:
@@ -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"
|
||||
|
||||
|
||||
@@ -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<CollectionHistoryItem> {
|
||||
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<CollectionHistoryItem> {
|
||||
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<PurchaseHistoryItem> {
|
||||
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<PurchaseHistoryItem> {
|
||||
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("&")}"
|
||||
|
||||
@@ -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<CollectionHistoryItem>) = 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<PurchaseHistoryItem>) = 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<CollectionHistoryItem>? {
|
||||
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<PurchaseHistoryItem>? {
|
||||
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<JSONObject>? {
|
||||
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 <T> List<T>.filterTask(raw: String?, id: (T) -> Long): List<T> {
|
||||
val number = raw.orEmpty().filter(Char::isDigit).toLongOrNull() ?: return this
|
||||
return filter { id(it) == number }
|
||||
}
|
||||
|
||||
private fun <T> page(items: List<T>, page: Int): HistoryPage<T> {
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<Int, MaterialButton>()
|
||||
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<CollectionHistoryItem> {
|
||||
val items = mutableListOf<CollectionHistoryItem>()
|
||||
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<PurchaseHistoryItem> {
|
||||
val items = mutableListOf<PurchaseHistoryItem>()
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) } }
|
||||
|
||||
@@ -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)) }
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 架构与代码地图
|
||||
@@ -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 导航、地址修改、创建订单或支付。
|
||||
|
||||
@@ -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
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 业务规则与术语
|
||||
@@ -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、不修改地址、不创建订单、不支付。
|
||||
|
||||
@@ -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
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 本地开发与验证
|
||||
@@ -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、创建订单或支付。
|
||||
|
||||
@@ -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
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 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、不修改地址、不创建订单、不支付。
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user