From b885a4ef668cbd9755c2bb132cf8f0116ec749d1 Mon Sep 17 00:00:00 2001 From: QiuSW <105186638@qq.com> Date: Wed, 26 Aug 2026 09:26:38 +0800 Subject: [PATCH] feat(agent): implement #90 task history --- android/app/build.gradle.kts | 4 +- .../cn/ilapage/goauto/agent/MainActivity.kt | 6 +- .../automation/PurchaseRehearsalExecutor.kt | 5 +- .../goauto/agent/network/AgentApiClient.kt | 116 ++++++ .../agent/service/AgentForegroundService.kt | 1 + .../goauto/agent/ui/TaskHistoryFragment.kt | 383 ++++++++++++++++++ .../agent/ui/TaskPlaceholderFragment.kt | 40 -- .../agent/PurchaseRehearsalExecutorTest.kt | 1 + .../goauto/agent/TaskNumberParserTest.kt | 24 ++ docs/02-architecture-and-code-map.md | 11 +- docs/03-business-rules-and-glossary.md | 11 +- docs/08-agent-api-contract.md | 22 +- server/app/goauto/models/purchase.go | 1 + server/app/goauto/purchase/agent_history.go | 146 +++++++ .../app/goauto/purchase/agent_history_test.go | 69 ++++ server/app/goauto/purchase/handler.go | 42 ++ server/app/goauto/purchase/lifecycle.go | 5 + server/app/goauto/purchase/router.go | 2 + server/app/goauto/purchase/service_test.go | 7 +- server/app/goauto/purchase/types.go | 17 +- server/app/goauto/task/agent_history.go | 186 +++++++++ server/app/goauto/task/agent_history_test.go | 53 +++ server/app/goauto/task/handler.go | 45 ++ server/app/goauto/task/router.go | 2 + ...787690000000_purchase_actual_unit_price.go | 28 ++ 25 files changed, 1165 insertions(+), 62 deletions(-) create mode 100644 android/app/src/main/java/cn/ilapage/goauto/agent/ui/TaskHistoryFragment.kt delete mode 100644 android/app/src/main/java/cn/ilapage/goauto/agent/ui/TaskPlaceholderFragment.kt create mode 100644 android/app/src/test/java/cn/ilapage/goauto/agent/TaskNumberParserTest.kt create mode 100644 server/app/goauto/purchase/agent_history.go create mode 100644 server/app/goauto/purchase/agent_history_test.go create mode 100644 server/app/goauto/task/agent_history.go create mode 100644 server/app/goauto/task/agent_history_test.go create mode 100644 server/cmd/migrate/migration/version-local/1787690000000_purchase_actual_unit_price.go diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 00998c9..9433fdf 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 = 4 - versionName = "0.3.1" + versionCode = 5 + versionName = "0.4.0" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" diff --git a/android/app/src/main/java/cn/ilapage/goauto/agent/MainActivity.kt b/android/app/src/main/java/cn/ilapage/goauto/agent/MainActivity.kt index 29d9908..49f6408 100644 --- a/android/app/src/main/java/cn/ilapage/goauto/agent/MainActivity.kt +++ b/android/app/src/main/java/cn/ilapage/goauto/agent/MainActivity.kt @@ -14,7 +14,7 @@ import cn.ilapage.goauto.agent.service.AgentForegroundService import cn.ilapage.goauto.agent.service.AgentSettingsStore import cn.ilapage.goauto.agent.ui.AgentSettingsFragment import cn.ilapage.goauto.agent.ui.AgentStatusFragment -import cn.ilapage.goauto.agent.ui.TaskPlaceholderFragment +import cn.ilapage.goauto.agent.ui.TaskHistoryFragment import com.google.android.material.bottomnavigation.BottomNavigationView class MainActivity : AppCompatActivity() { @@ -110,8 +110,8 @@ class MainActivity : AppCompatActivity() { } private fun fragmentFor(tabId: Int): Fragment = when (tabId) { - TAB_COLLECTION -> TaskPlaceholderFragment.collection() - TAB_PURCHASE -> TaskPlaceholderFragment.purchase() + TAB_COLLECTION -> TaskHistoryFragment.collection() + TAB_PURCHASE -> TaskHistoryFragment.purchase() TAB_SETTINGS -> AgentSettingsFragment() else -> AgentStatusFragment() } diff --git a/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PurchaseRehearsalExecutor.kt b/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PurchaseRehearsalExecutor.kt index f0678c6..2f03d9c 100644 --- a/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PurchaseRehearsalExecutor.kt +++ b/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PurchaseRehearsalExecutor.kt @@ -34,6 +34,7 @@ data class PurchaseExecutionOutcome( val probedSpecs: String? = null, val pddOrderNo: String? = null, val orderSubmittedAt: String? = null, + val actualUnitPriceCent: Long? = null, ) class PurchaseRehearsalExecutor( @@ -85,7 +86,7 @@ class PurchaseRehearsalExecutor( } PurchaseActionType.READ_ORDER_RESULT -> { if (!irreversibleStarted) failure("PURCHASE_RULE_INVALID", "尚未进入创建订单边界") - else live.readOrderResult()?.let { PurchaseExecutionOutcome("order_created", message = "订单已创建,等待人工检查和支付", pddOrderNo = it.orderNo, orderSubmittedAt = it.submittedAt) } + else live.readOrderResult()?.let { PurchaseExecutionOutcome("order_created", message = "订单已创建,等待人工检查和支付", pddOrderNo = it.orderNo, orderSubmittedAt = it.submittedAt, actualUnitPriceCent = observedPrice) } ?: PurchaseExecutionOutcome("order_result_unknown", "PURCHASE_ORDER_RESULT_UNKNOWN", "无法确认订单是否创建,请人工检查") } } @@ -98,7 +99,7 @@ class PurchaseRehearsalExecutor( applyPostAction(action)?.let { return it } } if (input.phase == "spec_probe") return failure("PURCHASE_RULE_INVALID", "规格探测任务缺少 probeSpecs 动作") - return if (input.executionMode == "rehearsal") PurchaseExecutionOutcome("rehearsal_completed", message = "商品、规格、数量和价格复核完成,已在下单前安全停止") + return if (input.executionMode == "rehearsal") PurchaseExecutionOutcome("rehearsal_completed", message = "商品、规格、数量和价格复核完成,已在下单前安全停止", actualUnitPriceCent = observedPrice) else failure("PURCHASE_RULE_INVALID", "正式采购规则缺少核单动作") } 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 9af9069..d1f3bcb 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 @@ -5,6 +5,7 @@ import org.json.JSONArray import org.json.JSONObject import java.net.HttpURLConnection import java.net.URL +import java.net.URLEncoder import java.util.UUID data class DeviceInfo( @@ -64,6 +65,53 @@ data class PurchaseAgentTask( val leaseVersion: Long, ) +data class CollectionHistoryItem( + val taskId: Long, + val status: String, + val goodsId: String, + val title: String?, + val missingCount: Int, + val errorCode: String?, + val errorMessage: String?, + val finishedAt: String?, + val createdAt: String, +) + +data class HistoryPage(val items: List, val total: Long, val page: Int, val pageSize: Int) +data class HistoryDimension(val key: String, val name: String, val values: List) +data class HistoryColorPrice(val color: String, val priceCent: Long) +data class HistorySku(val specs: Map, val priceCent: Long, val available: Boolean, val complete: Boolean) +data class CollectionHistoryDetail( + val task: CollectionHistoryItem, + val shopName: String?, + val salesText: String?, + val reviewCount: Long?, + val dimensions: List, + val colorPrices: List, + val skus: List, + val missing: List, +) + +data class PurchaseHistoryItem( + val taskId: Long, + val status: String, + val shopeeOrderNo: String, + val pddGoodsId: String, + val pddTitle: String, + val mappedColor: String, + val mappedSize: String, + val quantity: Long, + val actualUnitPriceCent: Long?, + val currency: String, + val pddOrderNo: String?, + val orderSubmittedAt: String?, + val errorCode: String?, + val errorMessage: String?, + val createdAt: String, +) + +data class PurchaseHistoryDetail(val task: PurchaseHistoryItem) + class AgentApiException( val status: Int, val code: String, @@ -169,6 +217,69 @@ 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") + return HistoryPage( + items = data.getJSONArray("items").objects(::collectionHistoryItem), + total = data.getLong("total"), page = data.getInt("page"), pageSize = data.getInt("pageSize"), + ) + } + + fun collectionHistoryDetail(taskId: Long, token: String): CollectionHistoryDetail { + val data = requireNotNull(request("GET", "/api/agent/v1/collection-tasks/$taskId", null, token)).getJSONObject("data") + return CollectionHistoryDetail( + task = collectionHistoryItem(data.getJSONObject("task")), + shopName = data.nullableString("shopName"), salesText = data.nullableString("salesText"), + reviewCount = data.nullableLong("reviewCount"), + dimensions = data.getJSONArray("dimensions").objects { item -> HistoryDimension(item.getString("key"), item.getString("name"), item.getJSONArray("values").strings()) }, + colorPrices = data.getJSONArray("colorPrices").objects { item -> HistoryColorPrice(item.getString("color"), item.getLong("priceCent")) }, + skus = data.getJSONArray("skus").objects { item -> + val specsJson = item.getJSONObject("specs") + val specs = specsJson.keys().asSequence().associateWith(specsJson::getString) + HistorySku(specs, item.getLong("priceCent"), item.getBoolean("available"), item.getBoolean("complete")) + }, + missing = data.getJSONArray("missing").strings(), + ) + } + + 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") + return HistoryPage( + items = data.getJSONArray("items").objects(::purchaseHistoryItem), + total = data.getLong("total"), page = data.getInt("page"), pageSize = data.getInt("pageSize"), + ) + } + + fun purchaseHistoryDetail(taskId: Long, token: String): PurchaseHistoryDetail { + val data = requireNotNull(request("GET", "/api/agent/v1/purchase-tasks/$taskId", null, token)).getJSONObject("data") + return PurchaseHistoryDetail(purchaseHistoryItem(data.getJSONObject("task"))) + } + + private fun historyPath(base: String, page: Int, status: String?, taskNo: String?): String { + val values = mutableListOf("page=${page.coerceAtLeast(1)}", "pageSize=20") + status?.takeIf { it.isNotBlank() }?.let { values += "status=${encode(it)}" } + taskNo?.takeIf { it.isNotBlank() }?.let { values += "taskNo=${encode(it.trim())}" } + return "$base?${values.joinToString("&")}" + } + + private fun collectionHistoryItem(data: JSONObject) = CollectionHistoryItem( + taskId = data.getLong("taskId"), status = data.getString("status"), goodsId = data.getString("goodsId"), + title = data.nullableString("title"), missingCount = data.optInt("missingCount"), + errorCode = data.nullableString("errorCode"), errorMessage = data.nullableString("errorMessage"), + finishedAt = data.nullableString("finishedAt"), createdAt = data.getString("createdAt"), + ) + + private fun purchaseHistoryItem(data: JSONObject) = PurchaseHistoryItem( + taskId = data.getLong("taskId"), status = data.getString("status"), shopeeOrderNo = data.optString("shopeeOrderNo"), + pddGoodsId = data.getString("pddGoodsId"), pddTitle = data.optString("pddTitle"), + mappedColor = data.optString("mappedColor"), mappedSize = data.optString("mappedSize"), quantity = data.getLong("quantity"), + actualUnitPriceCent = data.nullableLong("actualUnitPriceCent"), currency = data.getString("currency"), + pddOrderNo = data.nullableString("pddOrderNo"), orderSubmittedAt = data.nullableString("orderSubmittedAt"), + errorCode = data.nullableString("errorCode"), errorMessage = data.nullableString("errorMessage"), createdAt = data.getString("createdAt"), + ) + + private fun encode(value: String): String = URLEncoder.encode(value, Charsets.UTF_8.name()) + private fun task(data: JSONObject) = AgentTask( taskId = data.getLong("taskId"), pddProductId = data.getLong("pddProductId"), @@ -241,3 +352,8 @@ class AgentApiClient(private val serverUrl: String) { } } } + +private fun JSONArray.objects(mapper: (JSONObject) -> T): List = (0 until length()).map { mapper(getJSONObject(it)) } +private fun JSONArray.strings(): List = (0 until length()).map(::getString) +private fun JSONObject.nullableString(key: String): String? = if (!has(key) || isNull(key)) null else getString(key) +private fun JSONObject.nullableLong(key: String): Long? = if (!has(key) || isNull(key)) null else getLong(key) diff --git a/android/app/src/main/java/cn/ilapage/goauto/agent/service/AgentForegroundService.kt b/android/app/src/main/java/cn/ilapage/goauto/agent/service/AgentForegroundService.kt index d1d9716..56fc306 100644 --- a/android/app/src/main/java/cn/ilapage/goauto/agent/service/AgentForegroundService.kt +++ b/android/app/src/main/java/cn/ilapage/goauto/agent/service/AgentForegroundService.kt @@ -421,6 +421,7 @@ class AgentForegroundService : Service() { outcome.probedSpecs?.let { put("probedSpecs", JSONObject(it)) } outcome.pddOrderNo?.let { put("pddOrderNo", it) } outcome.orderSubmittedAt?.let { put("orderSubmittedAt", it) } + outcome.actualUnitPriceCent?.let { put("actualUnitPriceCent", it) } } .toString() 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 new file mode 100644 index 0000000..ee0331a --- /dev/null +++ b/android/app/src/main/java/cn/ilapage/goauto/agent/ui/TaskHistoryFragment.kt @@ -0,0 +1,383 @@ +package cn.ilapage.goauto.agent.ui + +import android.content.res.ColorStateList +import android.os.Bundle +import android.view.Gravity +import android.view.View +import android.view.ViewGroup +import android.view.inputmethod.EditorInfo +import android.widget.Button +import android.widget.EditText +import android.widget.HorizontalScrollView +import android.widget.LinearLayout +import android.widget.ProgressBar +import androidx.fragment.app.Fragment +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.CollectionHistoryDetail +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.service.AgentSettingsStore +import com.google.android.material.button.MaterialButton +import java.util.Locale + +internal object TaskNumberParser { + fun normalize(raw: String, collection: Boolean): String? { + val trimmed = raw.trim() + if (trimmed.isEmpty()) return null + val number = if (collection) { + trimmed.removePrefix("#").trim() + } else { + if (trimmed.startsWith("CG-", ignoreCase = true)) trimmed.substring(3).trim() else trimmed + } + require(number.matches(Regex("[1-9][0-9]*"))) { + if (collection) "请输入采集任务编号,例如 35 或 #35" else "请输入采购任务编号,例如 12 或 CG-12" + } + return if (collection) "#$number" else "CG-$number" + } +} + +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 searchInput: EditText + private var selectedStatus = "" + private var page = 1 + private var total = 0L + private var requestGeneration = 0 + + override fun onCreateView(inflater: android.view.LayoutInflater, container: ViewGroup?, state: Bundle?): View { + val context = requireContext() + pageColumn = context.column() + pageColumn.addView(context.screenTitle( + if (collection) "采集记录" else "采购记录", + if (collection) "查看当前设备最近 30 天的采集任务" else "当前设备采购记录仅供查看", + )) + 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) + } + + override fun onResume() { + super.onResume() + load() + } + + override fun onDestroyView() { + requestGeneration++ + super.onDestroyView() + } + + private fun buildSearch(): View { + val context = requireContext() + return context.card(context.cardColumn().apply { + addView(context.label(if (collection) "采集任务编号" else "采购任务编号", 14f, context.getColor(R.color.agent_text), true)) + val row = LinearLayout(context).apply { orientation = LinearLayout.HORIZONTAL; gravity = Gravity.CENTER_VERTICAL } + searchInput = EditText(context).apply { + hint = if (collection) "例如 35 或 #35" else "例如 12 或 CG-12" + setHintTextColor(context.getColor(R.color.agent_text_muted)) + setTextColor(context.getColor(R.color.agent_text)) + setSingleLine(true) + inputType = android.text.InputType.TYPE_CLASS_TEXT + imeOptions = EditorInfo.IME_ACTION_SEARCH + minimumHeight = context.dp(48) + setOnEditorActionListener { _, action, _ -> + if (action == EditorInfo.IME_ACTION_SEARCH) { search(); true } else false + } + } + row.addView(searchInput, LinearLayout.LayoutParams(0, context.dp(52), 1f)) + row.addView(MaterialButton(context).apply { + text = "搜索" + minimumHeight = context.dp(48) + setOnClickListener { search() } + }, LinearLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, context.dp(52)).apply { marginStart = context.dp(8) }) + addView(row, row.fullWidth(8)) + }) + } + + private fun buildFilters(): View { + val context = requireContext() + val statuses = if (collection) { + listOf("全部" to "", "执行中" to "running", "失败" to "failed") + } else { + listOf("全部" to "", "执行中" to "running", "成功" to "order_created", "失败" to "failed") + } + val line = LinearLayout(context).apply { orientation = LinearLayout.HORIZONTAL } + statuses.forEach { (label, value) -> + line.addView(filterButton(label, value), LinearLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, context.dp(48)).apply { marginEnd = context.dp(8) }) + } + return HorizontalScrollView(context).apply { + isHorizontalScrollBarEnabled = false + addView(line) + } + } + + private fun filterButton(label: String, value: String): MaterialButton { + val context = requireContext() + return MaterialButton(context, null, com.google.android.material.R.attr.materialButtonOutlinedStyle).apply { + text = label + isAllCaps = false + minimumHeight = context.dp(48) + isSelected = selectedStatus == value + updateFilterColors(this, isSelected) + setOnClickListener { + if (selectedStatus == value) return@setOnClickListener + selectedStatus = value + page = 1 + val parent = parent as? LinearLayout + parent?.let { group -> + (0 until group.childCount).map(group::getChildAt).filterIsInstance().forEach { button -> + val selected = button === this + button.isSelected = selected + updateFilterColors(button, selected) + } + } + load() + } + } + } + + private fun updateFilterColors(button: MaterialButton, selected: Boolean) { + val context = requireContext() + button.backgroundTintList = ColorStateList.valueOf(context.getColor(if (selected) R.color.agent_primary else R.color.agent_surface)) + button.setTextColor(context.getColor(if (selected) R.color.agent_text else R.color.agent_text_muted)) + } + + private fun search() { + val canonical = try { TaskNumberParser.normalize(searchInput.text.toString(), collection) } catch (error: IllegalArgumentException) { + searchInput.error = error.message + return + } + searchInput.error = null + searchInput.setText(canonical.orEmpty()) + page = 1 + load() + } + + private fun load() { + 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) { + showError("设备尚未连接服务端", "请先在设置页保存服务器地址,并等待设备注册成功。") + return + } + val generation = ++requestGeneration + 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 ?: "请检查网络后重新加载。") } } + }.start() + } + + private fun renderPage(result: Any) { + resultColumn.removeAllViews() + when (result) { + is HistoryPage<*> -> { + total = result.total + val items = result.items + if (items.isEmpty()) { showEmpty(); return } + resultColumn.addView(requireContext().label("当前设备 · ${result.total} 条 最近 30 天", 13f, requireContext().getColor(R.color.agent_text_muted))) + items.forEach { item -> + when (item) { + is CollectionHistoryItem -> resultColumn.addView(collectionCard(item), collectionCardParams()) + is PurchaseHistoryItem -> resultColumn.addView(purchaseCard(item), collectionCardParams()) + } + } + addPagination(result.page, result.pageSize) + if (!collection) resultColumn.addView(requireContext().centeredMessage("只读记录", "重新采购、取消订单和支付请在管理端处理。")) + } + } + } + + private fun collectionCardParams() = LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT).apply { topMargin = requireContext().dp(12) } + + private fun collectionCard(item: CollectionHistoryItem): View { + val context = requireContext() + val status = collectionStatus(item.status) + val summary = when { + item.errorMessage != null -> item.errorMessage + item.missingCount > 0 -> "缺失 ${item.missingCount} 项" + else -> item.title ?: "未记录商品标题" + } + return context.card(context.cardColumn().apply { + addView(context.label("#${item.taskId} · ${item.goodsId}", 17f, context.getColor(R.color.agent_text), true)) + addView(context.label("$status · ${formatTime(item.finishedAt ?: item.createdAt)}", 13f, statusColor(item.status)).apply { setPadding(0, context.dp(4), 0, 0) }) + addView(context.label(summary, 14f, context.getColor(R.color.agent_text_muted)).apply { setPadding(0, context.dp(8), 0, 0) }) + }).apply { + isClickable = true; isFocusable = true + contentDescription = "查看采集任务 ${item.taskId} 详情" + setOnClickListener { loadCollectionDetail(item.taskId) } + } + } + + private fun purchaseCard(item: PurchaseHistoryItem): View { + val context = requireContext() + val spec = listOf(item.mappedColor, item.mappedSize).filter(String::isNotBlank).joinToString(" / ").ifBlank { "规格未记录" } + val summary = item.errorMessage ?: "$spec × ${item.quantity} · ${money(item.actualUnitPriceCent, item.currency)}" + return context.card(context.cardColumn().apply { + addView(context.label("CG-${item.taskId} · 订单 ${item.shopeeOrderNo.ifBlank { "—" }}", 17f, context.getColor(R.color.agent_text), true)) + addView(context.label(purchaseStatus(item.status), 13f, statusColor(item.status)).apply { setPadding(0, context.dp(4), 0, 0) }) + addView(context.label(summary, 14f, context.getColor(R.color.agent_text_muted)).apply { setPadding(0, context.dp(8), 0, 0) }) + item.pddOrderNo?.let { addView(context.label("PDD 订单号:$it", 13f, context.getColor(R.color.agent_text_muted))) } + }).apply { + isClickable = true; isFocusable = true + contentDescription = "查看采购任务 ${item.taskId} 详情" + setOnClickListener { loadPurchaseDetail(item.taskId) } + } + } + + private fun loadCollectionDetail(taskId: Long) = loadDetail("正在读取采集详情…") { api, token -> api.collectionHistoryDetail(taskId, token) } + private fun loadPurchaseDetail(taskId: Long) = loadDetail("正在读取采购详情…") { api, token -> api.purchaseHistoryDetail(taskId, token) } + + private fun loadDetail(message: String, request: (AgentApiClient, String) -> Any) { + val context = requireContext() + val credentials = runCatching { SecureDeviceStore(context).credentials() }.getOrNull() ?: return + val serverUrl = AgentSettingsStore(context).serverUrl() + val generation = ++requestGeneration + showLoading(message) + Thread { + runCatching { request(AgentApiClient(serverUrl), credentials.token) } + .onSuccess { detail -> resultColumn.post { if (isAdded && generation == requestGeneration) renderDetail(detail) } } + .onFailure { error -> resultColumn.post { if (isAdded && generation == requestGeneration) showError("详情加载失败", error.message ?: "请稍后重试。") } } + }.start() + } + + private fun renderDetail(detail: Any) { + resultColumn.removeAllViews() + resultColumn.addView(MaterialButton(requireContext(), null, com.google.android.material.R.attr.materialButtonOutlinedStyle).apply { + text = if (collection) "返回采集记录" else "返回采购记录" + minimumHeight = requireContext().dp(48) + setOnClickListener { load() } + }) + when (detail) { + is CollectionHistoryDetail -> renderCollectionDetail(detail) + is PurchaseHistoryDetail -> renderPurchaseDetail(detail) + } + } + + private fun renderCollectionDetail(detail: CollectionHistoryDetail) { + val context = requireContext() + val task = detail.task + val dimensions = detail.dimensions.joinToString("\n") { "${it.name}:${it.values.joinToString("、")}" }.ifBlank { "暂无规格数据" } + val prices = detail.colorPrices.joinToString("\n") { "${it.color} ${money(it.priceCent, "CNY")}" }.ifBlank { "暂无颜色价格" } + val info = buildString { + append("商品标题:${task.title ?: "未采集到"}\n") + append("店铺:${detail.shopName ?: "未采集到"}\n") + append("销量:${detail.salesText ?: "未采集到"}\n") + append("评价数量:${detail.reviewCount?.toString() ?: "未采集到"}\n\n") + append("$prices\n\n$dimensions\n\nSKU:${detail.skus.size} 条") + } + resultColumn.addView(context.card(context.cardColumn().apply { + addView(context.label("#${task.taskId} · ${task.goodsId}", 20f, context.getColor(R.color.agent_text), true)) + addView(context.label(collectionStatus(task.status), 14f, statusColor(task.status)).apply { setPadding(0, context.dp(4), 0, context.dp(12)) }) + addView(context.label(info, 14f)) + }), collectionCardParams()) + if (detail.missing.isNotEmpty()) resultColumn.addView(context.centeredMessage("缺失项", detail.missing.joinToString("、"))) + if (task.errorMessage != null) resultColumn.addView(context.centeredMessage(task.errorMessage, "错误代码:${task.errorCode ?: "—"}")) + } + + private fun renderPurchaseDetail(detail: PurchaseHistoryDetail) { + val context = requireContext() + val task = detail.task + val info = buildString { + append("蝦皮订单号:${task.shopeeOrderNo.ifBlank { "—" }}\n") + append("PDD 商品:${task.pddGoodsId}\n") + append("商品标题:${task.pddTitle.ifBlank { "—" }}\n") + append("目标规格:${listOf(task.mappedColor, task.mappedSize).filter(String::isNotBlank).joinToString(" / ").ifBlank { "—" }}\n") + append("数量:${task.quantity}\n") + append("实际单价:${money(task.actualUnitPriceCent, task.currency)}\n") + append("PDD 订单号:${task.pddOrderNo ?: "—"}\n") + append("下单时间:${task.orderSubmittedAt?.let(::formatTime) ?: "—"}") + } + resultColumn.addView(context.card(context.cardColumn().apply { + addView(context.label("CG-${task.taskId}", 20f, context.getColor(R.color.agent_text), true)) + addView(context.label(purchaseStatus(task.status), 14f, statusColor(task.status)).apply { setPadding(0, context.dp(4), 0, context.dp(12)) }) + addView(context.label(info, 14f)) + }), collectionCardParams()) + if (task.errorMessage != null) resultColumn.addView(context.centeredMessage(task.errorMessage, "错误代码:${task.errorCode ?: "—"}")) + resultColumn.addView(context.centeredMessage("只读详情", "本页没有取消、修改、创建订单或支付入口。")) + } + + private fun addPagination(currentPage: Int, pageSize: Int) { + if (total <= pageSize && currentPage == 1) return + val context = requireContext() + val row = LinearLayout(context).apply { orientation = LinearLayout.HORIZONTAL; gravity = Gravity.CENTER } + row.addView(Button(context).apply { text = "上一页"; isEnabled = currentPage > 1; minimumHeight = context.dp(48); setOnClickListener { page--; load() } }) + row.addView(context.label("第 $currentPage 页", 14f).apply { gravity = Gravity.CENTER }, LinearLayout.LayoutParams(context.dp(96), context.dp(48))) + row.addView(Button(context).apply { text = "下一页"; isEnabled = currentPage * pageSize < total; minimumHeight = context.dp(48); setOnClickListener { page++; load() } }) + resultColumn.addView(row, row.fullWidth(8)) + } + + private fun showLoading(message: String) { + resultColumn.removeAllViews() + resultColumn.addView(requireContext().card(requireContext().cardColumn().apply { + gravity = Gravity.CENTER_HORIZONTAL + addView(ProgressBar(requireContext())) + addView(requireContext().label(message, 14f, requireContext().getColor(R.color.agent_text_muted)).apply { setPadding(0, requireContext().dp(8), 0, 0) }) + })) + } + + 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) searchInput.setText(""); page = 1; load() } + } + + private fun showError(title: String, description: String) = showMessage(title, description, "重新加载") { load() } + + private fun showMessage(title: String, description: String, action: String, onClick: () -> Unit) { + resultColumn.removeAllViews() + resultColumn.addView(requireContext().centeredMessage(title, description)) + val button = MaterialButton(requireContext()).apply { + text = action; minimumHeight = requireContext().dp(48); setOnClickListener { onClick() } + } + resultColumn.addView(button, button.fullWidth()) + } + + private fun collectionStatus(status: String) = when (status) { + "pending" -> "待执行"; "running" -> "执行中"; "completed" -> "已完成" + "completed_partial" -> "部分完成"; "failed" -> "采集失败"; else -> status + } + + private fun purchaseStatus(status: String) = when (status) { + "pending" -> "待执行"; "spec_probe_pending" -> "等待规格匹配"; "running" -> "执行中" + "rehearsal_completed" -> "演练完成"; "order_submit_started" -> "正在确认订单" + "order_created" -> "订单已创建"; "order_result_unknown" -> "等待人工核对" + "failed" -> "采购失败"; "cancelled" -> "已取消"; else -> status + } + + private fun statusColor(status: String): Int = requireContext().getColor(when (status) { + "failed", "order_result_unknown" -> R.color.agent_error + "pending", "running", "spec_probe_pending", "order_submit_started", "completed_partial" -> R.color.agent_warning + else -> R.color.agent_primary_light + }) + + private fun money(cents: Long?, currency: String): String { + if (cents == null) return "未记录" + val symbol = if (currency.equals("CNY", true)) "¥" else "$currency " + return symbol + String.format(Locale.US, "%.2f", cents / 100.0) + } + + private fun formatTime(raw: String): String = raw.replace('T', ' ').take(16) + + companion object { + private const val ARG_COLLECTION = "collection" + fun collection() = TaskHistoryFragment().apply { arguments = Bundle().apply { putBoolean(ARG_COLLECTION, true) } } + fun purchase() = TaskHistoryFragment().apply { arguments = Bundle().apply { putBoolean(ARG_COLLECTION, false) } } + } +} diff --git a/android/app/src/main/java/cn/ilapage/goauto/agent/ui/TaskPlaceholderFragment.kt b/android/app/src/main/java/cn/ilapage/goauto/agent/ui/TaskPlaceholderFragment.kt deleted file mode 100644 index f2537ec..0000000 --- a/android/app/src/main/java/cn/ilapage/goauto/agent/ui/TaskPlaceholderFragment.kt +++ /dev/null @@ -1,40 +0,0 @@ -package cn.ilapage.goauto.agent.ui - -import android.os.Bundle -import android.view.View -import android.view.ViewGroup -import androidx.fragment.app.Fragment - -class TaskPlaceholderFragment : Fragment() { - override fun onCreateView(inflater: android.view.LayoutInflater, container: ViewGroup?, state: Bundle?): View { - val context = requireContext() - val type = requireArguments().getString(ARG_TYPE).orEmpty() - val collection = type == TYPE_COLLECTION - val content = context.column().apply { - addView(context.screenTitle( - if (collection) "采集记录" else "采购记录", - if (collection) "查看当前设备的采集任务" else "查看当前设备的采购任务", - )) - addView(context.centeredMessage( - "记录功能正在准备中", - if (collection) "任务历史、编号搜索和详情将在下一实施工单接入。" - else "采购记录将保持只读,不提供修改订单或支付操作。", - )) - } - return context.page(content) - } - - companion object { - private const val ARG_TYPE = "type" - private const val TYPE_COLLECTION = "collection" - private const val TYPE_PURCHASE = "purchase" - - fun collection() = TaskPlaceholderFragment().apply { - arguments = Bundle().apply { putString(ARG_TYPE, TYPE_COLLECTION) } - } - - fun purchase() = TaskPlaceholderFragment().apply { - arguments = Bundle().apply { putString(ARG_TYPE, TYPE_PURCHASE) } - } - } -} diff --git a/android/app/src/test/java/cn/ilapage/goauto/agent/PurchaseRehearsalExecutorTest.kt b/android/app/src/test/java/cn/ilapage/goauto/agent/PurchaseRehearsalExecutorTest.kt index b22ea80..2917451 100644 --- a/android/app/src/test/java/cn/ilapage/goauto/agent/PurchaseRehearsalExecutorTest.kt +++ b/android/app/src/test/java/cn/ilapage/goauto/agent/PurchaseRehearsalExecutorTest.kt @@ -32,6 +32,7 @@ class PurchaseRehearsalExecutorTest { ).execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported) assertEquals("rehearsal_completed", outcome.resultType) + assertEquals(2_000L, outcome.actualUnitPriceCent) assertEquals(1, openCount) assertEquals(2, driver.swipeCount) assertEquals(2L, driver.quantity) diff --git a/android/app/src/test/java/cn/ilapage/goauto/agent/TaskNumberParserTest.kt b/android/app/src/test/java/cn/ilapage/goauto/agent/TaskNumberParserTest.kt new file mode 100644 index 0000000..bb8fd95 --- /dev/null +++ b/android/app/src/test/java/cn/ilapage/goauto/agent/TaskNumberParserTest.kt @@ -0,0 +1,24 @@ +package cn.ilapage.goauto.agent + +import cn.ilapage.goauto.agent.ui.TaskNumberParser +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertThrows +import org.junit.Test + +class TaskNumberParserTest { + @Test fun collectionNumberAcceptsPlainAndHash() { + assertEquals("#35", TaskNumberParser.normalize(" 35 ", true)) + assertEquals("#35", TaskNumberParser.normalize(" #35 ", true)) + } + + @Test fun purchaseNumberAcceptsPlainAndCaseInsensitivePrefix() { + assertEquals("CG-12", TaskNumberParser.normalize("12", false)) + assertEquals("CG-12", TaskNumberParser.normalize(" cg-12 ", false)) + } + + @Test fun emptyClearsSearchAndInvalidNumberIsRejected() { + assertNull(TaskNumberParser.normalize(" ", true)) + assertThrows(IllegalArgumentException::class.java) { TaskNumberParser.normalize("CG-abc", false) } + } +} diff --git a/docs/02-architecture-and-code-map.md b/docs/02-architecture-and-code-map.md index a627d8a..9d830c0 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: 1c9841a942b5d5682ddada2ee7202e93adc422e4 -synchronized_at: 2026-08-25T09:44:21Z +wiki_revision: 3b7a0cad581f615124aeeee8f8d3baf83e1dca28 +synchronized_at: 2026-08-26T01:17:16Z # 架构与代码地图 @@ -166,3 +166,10 @@ Android Portal/Agent - `IdleReturnCoordinator` 只保存冷却时间、自动化前台包名与无障碍前台切换序号,不保存控件树或截图。用户切换到其他 App 后,即使又返回 PDD,也不会被强制拉回 Agent。 - 返回请求只允许从 Agent 本次自动化停留的 PDD 或受支持浏览器页面发起,并由无障碍服务打开 `MainActivity` 状态 Tab;Android 后台 Activity 启动限制导致失败时只提示手动打开,不重复强拉。 - 任务执行和冷却分别使用带超时的亮屏锁;`MainActivity` 仅在运行态标记要求时设置 `FLAG_KEEP_SCREEN_ON`。任务、新任务接管、取消、返回或服务销毁后统一释放,不绕过设备 PIN、图案或密码。 + +## Agent 任务历史(#90) + +- Android 的采集任务与采购任务 Tab 由 `TaskHistoryFragment` 承载,复用搜索、状态筛选、分页、加载/空/失败状态和只读详情能力。 +- `AgentApiClient` 调用服务端当前设备历史接口;任务编号规范化由客户端先处理,服务端再次校验。 +- 服务端采集与采购模块各自使用 `agent_history.go` 负责 Device Token 设备隔离、最近 30 天过滤、分页和最小化 DTO。 +- `purchase_task.actual_unit_price_cent` 是可空字段,只记录 Agent 实际观察到的采购单价;旧任务不回填。 diff --git a/docs/03-business-rules-and-glossary.md b/docs/03-business-rules-and-glossary.md index 79139e2..99e10f8 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: 6bad462cbf91d592ba95fbe2003402d20a589cc3 -synchronized_at: 2026-08-25T06:21:31Z +wiki_revision: c956def096806541d5060c771504928d121eb127 +synchronized_at: 2026-08-26T01:17:27Z # 业务规则与术语 @@ -224,3 +224,10 @@ synchronized_at: 2026-08-25T06:21:31Z - 同一颜色只有唯一有效价格时才保存颜色价格;出现多个价格时价格留空、PDD 商品保持 `pending` 并报告,禁止取最低价、最高价或平均价。 - 蝦皮只聚合来源已解析的颜色和尺码,未解析内容不猜测。来源 PDD 关联不存在、已删除或无法转换时,蝦皮记录作为冲突跳过,不能静默关联其他商品。 - 默认 dry-run 不写数据库;`--apply` 前必须查看精确影响并再次人工确认。apply 在目标单事务中执行,提交前校验 JSON、唯一键和关联完整性。 + +## Agent 当前设备历史 + +- Agent 只可查看当前 Device Token 对应设备最近 30 天的采集和采购任务,不能跨设备查看。 +- 采集历史可以查看结构化采集结果和错误;采购历史可以查看结构化采购数据和错误,但全部为只读。 +- 历史接口遵循数据最小化:不下发 Device Token、规则快照、PDD URL、收货地址、原始控件树或截图。 +- 实际采购单价只在 Agent 确实观察到时保存;旧任务或未观察到价格时显示“未记录”,不推算、不回填。 diff --git a/docs/08-agent-api-contract.md b/docs/08-agent-api-contract.md index 9c79116..ec7d6ec 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: e9da6212dd12bf200c9484cf1145bb17a3384b4e -synchronized_at: 2026-08-23T15:20:49Z +wiki_revision: 0f83e8f7333f2bec36f7b42f9ab558ec3b1e265e +synchronized_at: 2026-08-26T01:18:14Z # MVP 共享 API 契约 @@ -532,3 +532,21 @@ Android #42/#36 使用本地 SQLite 保存恢复与重传所需的任务、attem | `PURCHASE_ADDRESS_UPDATE_FAILED` | 收货地址修改失败,未创建订单 | | `PURCHASE_ORDER_RESULT_UNKNOWN` | 无法确认订单是否创建,请人工检查 | | `PURCHASE_PAYMENT_FORBIDDEN` | 系统禁止自动付款 | + +## Agent 当前设备任务历史(#90) + +四个只读接口统一使用设备注册所得的 Device Token,只返回该 Token 对应设备最近 30 天内的任务;设备 A 查询设备 B 的任务时按不存在处理,不泄露任务是否存在。 + +```http +GET /api/agent/v1/collection-tasks?page=1&pageSize=20&status=failed&taskNo=%2335 +GET /api/agent/v1/collection-tasks/{taskId} +GET /api/agent/v1/purchase-tasks?page=1&pageSize=20&status=failed&taskNo=CG-12 +GET /api/agent/v1/purchase-tasks/{taskId} +``` + +- `pageSize` 最大为 50;`status` 与 `taskNo` 可以组合过滤。 +- 采集任务编号允许 `35` 或 `#35`,采购任务编号允许 `12` 或不区分大小写的 `CG-12`;服务端按精确编号匹配。 +- 列表和详情不返回 Device Token、URL、收货地址、规则快照或原始控件树;采购详情只读,不提供重试、取消、创建订单或支付入口。 +- 采集详情返回标题、店铺、销量、评价数、规格维度、颜色价格、SKU、缺失项和结构化错误。 +- 采购详情返回蝦皮订单号、PDD 商品、已匹配颜色/尺码、数量、实际单价、PDD 订单号、下单时间和结构化错误。 +- Agent 提交采购结果时可携带 `actualUnitPriceCent`(人民币分,非负)。服务端只保存 Agent 实际观察到的值;历史任务或未观察到价格的结果保持 `null`,客户端显示“未记录”。 diff --git a/server/app/goauto/models/purchase.go b/server/app/goauto/models/purchase.go index 3536dda..2d154e6 100644 --- a/server/app/goauto/models/purchase.go +++ b/server/app/goauto/models/purchase.go @@ -106,6 +106,7 @@ type PurchaseTask struct { Quantity int64 `json:"quantity" gorm:"not null;check:ck_purchase_task_quantity,quantity >= 1"` ReferenceUnitPriceCent int64 `json:"referenceUnitPriceCent" gorm:"not null;check:ck_purchase_task_reference_price,reference_unit_price_cent >= 0"` + ActualUnitPriceCent *int64 `json:"actualUnitPriceCent,omitempty" gorm:"check:ck_purchase_task_actual_price,actual_unit_price_cent IS NULL OR actual_unit_price_cent >= 0"` MinUnitPriceCent int64 `json:"minUnitPriceCent" gorm:"not null;check:ck_purchase_task_min_price,min_unit_price_cent >= 0"` MaxUnitPriceCent int64 `json:"maxUnitPriceCent" gorm:"not null;check:ck_purchase_task_max_price,max_unit_price_cent >= min_unit_price_cent"` Currency string `json:"currency" gorm:"size:3;not null"` diff --git a/server/app/goauto/purchase/agent_history.go b/server/app/goauto/purchase/agent_history.go new file mode 100644 index 0000000..70be7e6 --- /dev/null +++ b/server/app/goauto/purchase/agent_history.go @@ -0,0 +1,146 @@ +package purchase + +import ( + "context" + "errors" + "strconv" + "strings" + "time" + + "go-admin/app/goauto/device" + "go-admin/app/goauto/models" + + "gorm.io/gorm" +) + +const agentPurchaseHistoryDays = 30 + +type AgentHistoryRequest struct { + Page int + PageSize int + Status string + TaskNo string +} + +type AgentPurchaseItem struct { + TaskID uint64 `json:"taskId"` + Status string `json:"status"` + ShopeeOrderNo string `json:"shopeeOrderNo"` + PDDGoodsID string `json:"pddGoodsId"` + PDDTitle string `json:"pddTitle"` + MappedColor string `json:"mappedColor"` + MappedSize string `json:"mappedSize"` + Quantity int64 `json:"quantity"` + ActualUnitPriceCent *int64 `json:"actualUnitPriceCent,omitempty"` + Currency string `json:"currency"` + PDDOrderNo *string `json:"pddOrderNo,omitempty"` + OrderSubmittedAt *time.Time `json:"orderSubmittedAt,omitempty"` + ErrorCode *string `json:"errorCode,omitempty"` + ErrorMessage *string `json:"errorMessage,omitempty"` + CreatedAt time.Time `json:"createdAt"` +} + +type AgentPurchaseList struct { + Items []AgentPurchaseItem `json:"items"` + Total int64 `json:"total"` + Page int `json:"page"` + PageSize int `json:"pageSize"` +} + +type AgentPurchaseDetail struct { + Task AgentPurchaseItem `json:"task"` +} + +func (s *Service) AgentHistory(ctx context.Context, req AgentHistoryRequest, token string) (AgentPurchaseList, error) { + deviceRecord, err := device.NewService(s.DB).Authenticate(ctx, token) + if err != nil { + return AgentPurchaseList{}, err + } + req.Page, req.PageSize = normalizeAgentHistoryPage(req.Page, req.PageSize) + if req.Status != "" && !validPurchaseStatus(req.Status) { + return AgentPurchaseList{}, fail(CodeInvalidRequest, "status 无效") + } + taskID, hasTaskID, err := normalizePurchaseTaskNo(req.TaskNo) + if err != nil { + 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)) + if req.Status != "" { + query = query.Where("status = ?", req.Status) + } + if hasTaskID { + query = query.Where("id = ?", taskID) + } + var total int64 + if err := query.Count(&total).Error; err != nil { + return AgentPurchaseList{}, internal(err) + } + var tasks []models.PurchaseTask + if err := query.Order("created_at DESC, id DESC").Offset((req.Page - 1) * req.PageSize).Limit(req.PageSize).Find(&tasks).Error; err != nil { + return AgentPurchaseList{}, internal(err) + } + items := make([]AgentPurchaseItem, 0, len(tasks)) + for _, task := range tasks { + items = append(items, agentPurchaseItem(task)) + } + return AgentPurchaseList{Items: items, Total: total, Page: req.Page, PageSize: req.PageSize}, 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 { + return AgentPurchaseDetail{}, err + } + var task models.PurchaseTask + err = s.DB.WithContext(ctx). + Where("id = ? AND device_id = ? AND created_at >= ?", taskID, deviceRecord.ID, s.Now().AddDate(0, 0, -agentPurchaseHistoryDays)). + First(&task).Error + if errors.Is(err, gorm.ErrRecordNotFound) || taskID == 0 { + return AgentPurchaseDetail{}, fail(CodeTaskNotFound, "采购任务不存在") + } + if err != nil { + return AgentPurchaseDetail{}, internal(err) + } + return AgentPurchaseDetail{Task: agentPurchaseItem(task)}, nil +} + +func agentPurchaseItem(task models.PurchaseTask) AgentPurchaseItem { + return AgentPurchaseItem{ + TaskID: task.ID, Status: task.Status, ShopeeOrderNo: task.ShopeeOrderNoSnapshot, + PDDGoodsID: task.PDDGoodsIDSnapshot, PDDTitle: task.PDDTitleSnapshot, + MappedColor: task.MappedColorSnapshot, MappedSize: task.MappedSizeSnapshot, + Quantity: task.Quantity, ActualUnitPriceCent: task.ActualUnitPriceCent, Currency: task.Currency, + PDDOrderNo: task.PDDOrderNo, OrderSubmittedAt: task.OrderSubmittedAt, + ErrorCode: task.ErrorCode, ErrorMessage: task.ErrorMessage, CreatedAt: task.CreatedAt, + } +} + +func normalizeAgentHistoryPage(page, pageSize int) (int, int) { + if page < 1 { + page = 1 + } + if pageSize < 1 { + pageSize = 20 + } + if pageSize > 50 { + pageSize = 50 + } + return page, pageSize +} + +func normalizePurchaseTaskNo(raw string) (uint64, bool, error) { + value := strings.TrimSpace(raw) + if value == "" { + return 0, false, nil + } + upper := strings.ToUpper(value) + if strings.HasPrefix(upper, "CG-") { + value = strings.TrimSpace(value[3:]) + } + id, err := strconv.ParseUint(value, 10, 64) + if err != nil || id == 0 { + return 0, false, errors.New("invalid purchase task number") + } + return id, true, nil +} diff --git a/server/app/goauto/purchase/agent_history_test.go b/server/app/goauto/purchase/agent_history_test.go new file mode 100644 index 0000000..364ad3b --- /dev/null +++ b/server/app/goauto/purchase/agent_history_test.go @@ -0,0 +1,69 @@ +package purchase + +import ( + "context" + "encoding/json" + "strconv" + "strings" + "testing" + + "go-admin/app/goauto/device" + "go-admin/app/goauto/models" + + "github.com/google/uuid" + "gorm.io/gorm" +) + +func TestAgentPurchaseHistoryNormalizesNumberAndIsolatesDevice(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) + } + actual := int64(2090) + orderNo := "PDD-100" + if err := db.Session(&gorm.Session{SkipHooks: true}).Model(&models.PurchaseTask{}).Where("id = ?", task.ID).Updates(map[string]any{ + "status": models.PurchaseTaskStatusOrderCreated, + "actual_unit_price_cent": actual, "pdd_order_no": orderNo, + }).Error; err != nil { + t.Fatal(err) + } + + plain, err := service.AgentHistory(context.Background(), AgentHistoryRequest{TaskNo: strconv.FormatUint(task.ID, 10)}, f.token) + if err != nil || plain.Total != 1 || plain.Items[0].ActualUnitPriceCent == nil || *plain.Items[0].ActualUnitPriceCent != actual { + t.Fatalf("plain search: %+v %v", plain, err) + } + prefixed, err := service.AgentHistory(context.Background(), AgentHistoryRequest{TaskNo: " cg-" + strconv.FormatUint(task.ID, 10) + " ", Status: models.PurchaseTaskStatusOrderCreated}, f.token) + if err != nil || prefixed.Total != 1 { + t.Fatalf("prefixed search: %+v %v", prefixed, err) + } + + otherToken := uuid.NewString() + registration := device.NewService(db) + registration.GenerateToken = func() (string, error) { return otherToken, nil } + if _, err := registration.Register(context.Background(), device.RegisterRequest{ + RequestID: uuid.NewString(), InstallID: uuid.NewString(), Name: "other", Manufacturer: "OnePlus", + Model: "Test", AndroidVersion: "14", AgentVersion: "1", PDDVersion: "7", + }, ""); err != nil { + t.Fatal(err) + } + if _, err := service.AgentHistoryDetail(context.Background(), task.ID, otherToken); code(err) != CodeTaskNotFound { + t.Fatalf("other device saw task: %v", err) + } + detail, err := service.AgentHistoryDetail(context.Background(), task.ID, f.token) + if err != nil { + t.Fatal(err) + } + raw, _ := json.Marshal(detail) + if strings.Contains(string(raw), "addressSuffix") || strings.Contains(string(raw), "ruleSnapshot") || strings.Contains(string(raw), "pddUrl") { + t.Fatalf("sensitive task data leaked: %s", raw) + } + if err := db.Exec("UPDATE purchase_task SET created_at = ? WHERE id = ?", service.Now().AddDate(0, 0, -31), task.ID).Error; err != nil { + t.Fatal(err) + } + if _, err := service.AgentHistoryDetail(context.Background(), task.ID, f.token); code(err) != CodeTaskNotFound { + t.Fatalf("expired task was visible: %v", err) + } +} diff --git a/server/app/goauto/purchase/handler.go b/server/app/goauto/purchase/handler.go index fdce62c..1ccf664 100644 --- a/server/app/goauto/purchase/handler.go +++ b/server/app/goauto/purchase/handler.go @@ -176,6 +176,48 @@ func (h Handler) Next(c *gin.Context) { } c.JSON(http.StatusOK, gin.H{"data": p}) } +func (h Handler) AgentHistory(c *gin.Context) { + page, err := positiveAdminQuery(c.Query("page"), 1) + if err != nil { + writeError(c, fail(CodeInvalidRequest, "page 无效")) + return + } + pageSize, err := positiveAdminQuery(c.Query("pageSize"), 20) + if err != nil { + writeError(c, fail(CodeInvalidRequest, "pageSize 无效")) + 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"), + }, bearer(c.GetHeader("Authorization"))) + if err != nil { + writeError(c, err) + return + } + c.Header("Cache-Control", "no-store") + c.JSON(http.StatusOK, gin.H{"data": result}) +} +func (h Handler) AgentHistoryDetail(c *gin.Context) { + id, ok := pathID(c) + if !ok { + return + } + service, serviceOK := h.service(c) + if !serviceOK { + return + } + result, err := service.AgentHistoryDetail(c.Request.Context(), id, bearer(c.GetHeader("Authorization"))) + if err != nil { + writeError(c, err) + return + } + c.Header("Cache-Control", "no-store") + c.JSON(http.StatusOK, gin.H{"data": result}) +} func (h Handler) Claim(c *gin.Context) { h.action(c, (*Service).Claim) } func (h Handler) Start(c *gin.Context) { h.action(c, (*Service).Start) } func (h Handler) OrderSubmitStarted(c *gin.Context) { h.action(c, (*Service).MarkOrderSubmitStarted) } diff --git a/server/app/goauto/purchase/lifecycle.go b/server/app/goauto/purchase/lifecycle.go index 9569599..100b1f3 100644 --- a/server/app/goauto/purchase/lifecycle.go +++ b/server/app/goauto/purchase/lifecycle.go @@ -261,6 +261,9 @@ func (s *Service) SubmitResult(ctx context.Context, taskID uint64, req ResultReq } return TaskPayload{}, fail(CodeResultConflict, "同一次执行已提交不同结果") } + if req.ActualUnitPriceCent != nil && *req.ActualUnitPriceCent < 0 { + return TaskPayload{}, fail(CodeInvalidRequest, "实际单价无效") + } now := s.Now() next := "" switch req.ResultType { @@ -290,6 +293,7 @@ func (s *Service) SubmitResult(ctx context.Context, taskID uint64, req ResultReq } next = models.PurchaseTaskStatusRehearsalCompleted a.Status = models.PurchaseAttemptStatusCompleted + t.ActualUnitPriceCent = req.ActualUnitPriceCent case "order_created": if t.ExecutionMode != models.PurchaseExecutionModeLive || t.Status != models.PurchaseTaskStatusOrderSubmitStarted || strings.TrimSpace(req.PDDOrderNo) == "" || req.OrderSubmittedAt == nil { return TaskPayload{}, fail(CodeInvalidRequest, "订单号或下单时间缺失") @@ -298,6 +302,7 @@ func (s *Service) SubmitResult(ctx context.Context, taskID uint64, req ResultReq a.Status = models.PurchaseAttemptStatusCompleted t.PDDOrderNo = &req.PDDOrderNo t.OrderSubmittedAt = req.OrderSubmittedAt + t.ActualUnitPriceCent = req.ActualUnitPriceCent case "order_result_unknown": if t.ExecutionMode != models.PurchaseExecutionModeLive || t.Status != models.PurchaseTaskStatusOrderSubmitStarted { return TaskPayload{}, fail(CodeStateConflict, "当前任务不能标记订单结果未知") diff --git a/server/app/goauto/purchase/router.go b/server/app/goauto/purchase/router.go index 2d539a7..0a5b24e 100644 --- a/server/app/goauto/purchase/router.go +++ b/server/app/goauto/purchase/router.go @@ -16,7 +16,9 @@ func InitRouter(engine *gin.Engine, auth *jwt.GinJWTMiddleware) { h := Handler{} trust, _ := strconv.ParseBool(os.Getenv("GOAUTO_TRUST_FORWARDED_PROTO")) agent := engine.Group("/api/agent/v1/purchase-tasks").Use(device.RequireHTTPS(config.ApplicationConfig.Mode == "prod", trust)) + agent.GET("", h.AgentHistory) agent.GET("/next", h.Next) + agent.GET("/:taskId", h.AgentHistoryDetail) agent.POST("/:taskId/claim", h.Claim) agent.POST("/:taskId/start", h.Start) agent.POST("/:taskId/order-submit-started", h.OrderSubmitStarted) diff --git a/server/app/goauto/purchase/service_test.go b/server/app/goauto/purchase/service_test.go index 01ba9e6..48b48fe 100644 --- a/server/app/goauto/purchase/service_test.go +++ b/server/app/goauto/purchase/service_test.go @@ -179,7 +179,8 @@ func TestCreateAndLifecycleValidateCapabilitiesAndIdempotentResult(t *testing.T) t.Fatal(err) } submitted := s.Now() - req := ResultRequest{RequestID: uuid.NewString(), TaskAttemptID: start.TaskAttemptID, ResultType: "order_created", PDDOrderNo: "PDD-1", OrderSubmittedAt: &submitted} + actual := int64(2090) + req := ResultRequest{RequestID: uuid.NewString(), TaskAttemptID: start.TaskAttemptID, ResultType: "order_created", PDDOrderNo: "PDD-1", OrderSubmittedAt: &submitted, ActualUnitPriceCent: &actual} first, err := s.SubmitResult(context.Background(), task.ID, req, f.token) if err != nil { t.Fatal(err) @@ -193,6 +194,10 @@ func TestCreateAndLifecycleValidateCapabilitiesAndIdempotentResult(t *testing.T) if count != 1 || first.Status != models.PurchaseTaskStatusOrderCreated { t.Fatalf("duplicate attempt or wrong status: %d %+v", count, first) } + var stored models.PurchaseTask + if err := db.First(&stored, task.ID).Error; err != nil || stored.ActualUnitPriceCent == nil || *stored.ActualUnitPriceCent != actual { + t.Fatalf("actual unit price was not stored: %+v %v", stored, err) + } } func TestCapabilityMismatchAtCreateAndClaim(t *testing.T) { diff --git a/server/app/goauto/purchase/types.go b/server/app/goauto/purchase/types.go index 2fb9b0f..1ee9cd9 100644 --- a/server/app/goauto/purchase/types.go +++ b/server/app/goauto/purchase/types.go @@ -97,14 +97,15 @@ type TaskPayload struct { } type ResultRequest struct { - RequestID string `json:"requestId"` - TaskAttemptID string `json:"taskAttemptId"` - ResultType string `json:"resultType"` - PDDOrderNo string `json:"pddOrderNo,omitempty"` - OrderSubmittedAt *time.Time `json:"orderSubmittedAt,omitempty"` - ErrorCode string `json:"errorCode,omitempty"` - ErrorMessage string `json:"errorMessage,omitempty"` - ProbedSpecs json.RawMessage `json:"probedSpecs,omitempty"` + RequestID string `json:"requestId"` + TaskAttemptID string `json:"taskAttemptId"` + ResultType string `json:"resultType"` + PDDOrderNo string `json:"pddOrderNo,omitempty"` + OrderSubmittedAt *time.Time `json:"orderSubmittedAt,omitempty"` + ActualUnitPriceCent *int64 `json:"actualUnitPriceCent,omitempty"` + ErrorCode string `json:"errorCode,omitempty"` + ErrorMessage string `json:"errorMessage,omitempty"` + ProbedSpecs json.RawMessage `json:"probedSpecs,omitempty"` } type SpecDecisionRequest struct { diff --git a/server/app/goauto/task/agent_history.go b/server/app/goauto/task/agent_history.go new file mode 100644 index 0000000..29ca4cf --- /dev/null +++ b/server/app/goauto/task/agent_history.go @@ -0,0 +1,186 @@ +package task + +import ( + "context" + "encoding/json" + "errors" + "strconv" + "strings" + "time" + + "go-admin/app/goauto/device" + "go-admin/app/goauto/models" + + "gorm.io/gorm" +) + +const agentHistoryDays = 30 + +type AgentHistoryRequest struct { + Page int + PageSize int + Status string + TaskNo string +} + +type AgentCollectionItem struct { + TaskID uint64 `json:"taskId"` + Status string `json:"status"` + GoodsID string `json:"goodsId"` + Title *string `json:"title,omitempty"` + MissingCount int `json:"missingCount"` + ErrorCode *string `json:"errorCode,omitempty"` + ErrorMessage *string `json:"errorMessage,omitempty"` + StartedAt *time.Time `json:"startedAt,omitempty"` + FinishedAt *time.Time `json:"finishedAt,omitempty"` + CreatedAt time.Time `json:"createdAt"` +} + +type AgentCollectionList struct { + Items []AgentCollectionItem `json:"items"` + Total int64 `json:"total"` + Page int `json:"page"` + PageSize int `json:"pageSize"` +} + +type AgentCollectionDetail struct { + Task AgentCollectionItem `json:"task"` + ShopName *string `json:"shopName,omitempty"` + SalesText *string `json:"salesText,omitempty"` + ReviewCount *int64 `json:"reviewCount,omitempty"` + Dimensions []DetailDimension `json:"dimensions"` + ColorPrices []AgentColorPrice `json:"colorPrices"` + SKUs []AgentCollectionSKU `json:"skus"` + Missing []string `json:"missing"` +} + +type AgentColorPrice struct { + Color string `json:"color"` + PriceCent int64 `json:"priceCent"` +} + +type AgentCollectionSKU struct { + Specs map[string]string `json:"specs"` + PriceCent int64 `json:"priceCent"` + Available bool `json:"available"` + Complete bool `json:"complete"` +} + +func (service *Service) AgentHistory(ctx context.Context, request AgentHistoryRequest, token string) (AgentCollectionList, error) { + deviceRecord, err := device.NewService(service.DB).Authenticate(ctx, token) + if err != nil { + return AgentCollectionList{}, err + } + request.Page, request.PageSize = normalizeAgentPage(request.Page, request.PageSize) + if request.Status != "" && !validCollectionStatus(request.Status) { + return AgentCollectionList{}, serviceError(device.CodeInvalidRequest, "status 无效") + } + taskID, hasTaskID, err := normalizeCollectionTaskNo(request.TaskNo) + if err != nil { + 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)) + if request.Status != "" { + query = query.Where("status = ?", request.Status) + } + if hasTaskID { + query = query.Where("id = ?", taskID) + } + var total int64 + if err := query.Count(&total).Error; err != nil { + return AgentCollectionList{}, internalError(err) + } + var records []models.CollectionTask + if err := query.Order("created_at DESC, id DESC").Offset((request.Page - 1) * request.PageSize).Limit(request.PageSize).Find(&records).Error; err != nil { + return AgentCollectionList{}, internalError(err) + } + items := make([]AgentCollectionItem, 0, len(records)) + for _, record := range records { + items = append(items, agentCollectionItem(record)) + } + return AgentCollectionList{Items: items, Total: total, Page: request.Page, PageSize: request.PageSize}, 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 { + return AgentCollectionDetail{}, err + } + var record models.CollectionTask + err = service.DB.WithContext(ctx). + Where("id = ? AND device_id = ? AND created_at >= ?", taskID, deviceRecord.ID, service.Now().AddDate(0, 0, -agentHistoryDays)). + First(&record).Error + if errors.Is(err, gorm.ErrRecordNotFound) || taskID == 0 { + return AgentCollectionDetail{}, serviceError(CodeTaskNotFound, "采集任务不存在") + } + if err != nil { + return AgentCollectionDetail{}, internalError(err) + } + detail, err := service.Detail(ctx, taskID) + if err != nil { + return AgentCollectionDetail{}, err + } + colorPrices := make([]AgentColorPrice, 0, len(detail.ColorPrices)) + for _, value := range detail.ColorPrices { + colorPrices = append(colorPrices, AgentColorPrice{Color: value.Color, PriceCent: value.PriceCent}) + } + skus := make([]AgentCollectionSKU, 0, len(detail.SKUs)) + for _, value := range detail.SKUs { + skus = append(skus, AgentCollectionSKU{Specs: value.Specs, PriceCent: value.PriceCent, Available: value.Available, Complete: value.Complete}) + } + return AgentCollectionDetail{ + Task: agentCollectionItem(record), ShopName: record.ShopName, SalesText: record.SalesText, + ReviewCount: record.ReviewCount, Dimensions: detail.Dimensions, ColorPrices: colorPrices, + SKUs: skus, Missing: detail.Missing, + }, nil +} + +func agentCollectionItem(record models.CollectionTask) AgentCollectionItem { + missing := 0 + if record.MissingJSON != nil { + var values []string + _ = json.Unmarshal([]byte(*record.MissingJSON), &values) + missing = len(values) + } + return AgentCollectionItem{ + TaskID: record.ID, Status: record.Status, GoodsID: record.GoodsIDSnapshot, Title: record.Title, + MissingCount: missing, ErrorCode: record.ErrorCode, ErrorMessage: record.ErrorMessage, + StartedAt: record.StartedAt, FinishedAt: record.FinishedAt, CreatedAt: record.CreatedAt, + } +} + +func normalizeAgentPage(page, pageSize int) (int, int) { + if page < 1 { + page = 1 + } + if pageSize < 1 { + pageSize = 20 + } + if pageSize > 50 { + pageSize = 50 + } + return page, pageSize +} + +func normalizeCollectionTaskNo(raw string) (uint64, bool, error) { + value := strings.TrimSpace(raw) + if value == "" { + return 0, false, nil + } + value = strings.TrimSpace(strings.TrimPrefix(value, "#")) + id, err := strconv.ParseUint(value, 10, 64) + if err != nil || id == 0 { + return 0, false, errors.New("invalid collection task number") + } + return id, true, nil +} + +func validCollectionStatus(status string) bool { + switch status { + case models.TaskStatusPending, models.TaskStatusRunning, models.TaskStatusCompleted, models.TaskStatusCompletedPartial, models.TaskStatusFailed: + return true + default: + return false + } +} diff --git a/server/app/goauto/task/agent_history_test.go b/server/app/goauto/task/agent_history_test.go new file mode 100644 index 0000000..396eb9f --- /dev/null +++ b/server/app/goauto/task/agent_history_test.go @@ -0,0 +1,53 @@ +package task + +import ( + "context" + "encoding/json" + "strconv" + "strings" + "testing" + + "go-admin/app/goauto/models" + "gorm.io/gorm" +) + +func TestAgentHistoryNormalizesNumberAndIsolatesDevice(t *testing.T) { + db := openTaskDatabase(t) + deviceA, tokenA := registerTaskDevice(t, db, "device-a") + deviceB, tokenB := registerTaskDevice(t, db, "device-b") + taskA := createTask(t, db, &deviceA.ID) + taskB := createTask(t, db, &deviceB.ID) + if err := db.Session(&gorm.Session{SkipHooks: true}).Model(&models.CollectionTask{}).Where("id IN ?", []uint64{taskA.ID, taskB.ID}).Updates(map[string]any{ + "status": models.TaskStatusFailed, "active_slot": nil, "device_run_slot": nil, + "error_code": "RULE_NOT_MATCHED", "error_message": "没有找到商品规格入口", + }).Error; err != nil { + t.Fatal(err) + } + + service := newTaskService(db) + plain, err := service.AgentHistory(context.Background(), AgentHistoryRequest{TaskNo: " " + strconv.FormatUint(taskA.ID, 10) + " "}, tokenA) + if err != nil || plain.Total != 1 || plain.Items[0].TaskID != taskA.ID { + t.Fatalf("plain search: %+v %v", plain, err) + } + prefixed, err := service.AgentHistory(context.Background(), AgentHistoryRequest{TaskNo: "#" + strconv.FormatUint(taskA.ID, 10), Status: models.TaskStatusFailed}, tokenA) + if err != nil || prefixed.Total != 1 { + t.Fatalf("prefixed search: %+v %v", prefixed, err) + } + if _, err := service.AgentHistoryDetail(context.Background(), taskA.ID, tokenB); taskErrorCode(t, err) != CodeTaskNotFound { + t.Fatalf("other device saw task: %v", err) + } + detail, err := service.AgentHistoryDetail(context.Background(), taskA.ID, tokenA) + if err != nil || detail.Task.ErrorCode == nil { + t.Fatalf("detail: %+v %v", detail, err) + } + raw, _ := json.Marshal(detail) + if strings.Contains(string(raw), "ruleSnapshot") || strings.Contains(string(raw), "urlSnapshot") { + t.Fatalf("sensitive snapshot leaked: %s", raw) + } + if err := db.Exec("UPDATE collection_task SET created_at = ? WHERE id = ?", service.Now().AddDate(0, 0, -31), taskA.ID).Error; err != nil { + t.Fatal(err) + } + if _, err := service.AgentHistoryDetail(context.Background(), taskA.ID, tokenA); taskErrorCode(t, err) != CodeTaskNotFound { + t.Fatalf("expired task was visible: %v", err) + } +} diff --git a/server/app/goauto/task/handler.go b/server/app/goauto/task/handler.go index 79e24e3..b920fd0 100644 --- a/server/app/goauto/task/handler.go +++ b/server/app/goauto/task/handler.go @@ -35,6 +35,51 @@ func (handler Handler) Next(context *gin.Context) { context.JSON(http.StatusOK, gin.H{"data": payload}) } +func (handler Handler) AgentHistory(context *gin.Context) { + page, err := positiveQuery(context.Query("page"), 1) + if err != nil { + writeError(context, serviceError(device.CodeInvalidRequest, "page 无效")) + return + } + pageSize, err := positiveQuery(context.Query("pageSize"), 20) + if err != nil { + writeError(context, serviceError(device.CodeInvalidRequest, "pageSize 无效")) + 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"), + }, token) + if err != nil { + writeError(context, err) + return + } + context.Header("Cache-Control", "no-store") + context.JSON(http.StatusOK, gin.H{"data": result}) +} + +func (handler Handler) AgentHistoryDetail(context *gin.Context) { + id, err := taskID(context) + if err != nil || id == 0 { + writeError(context, serviceError(device.CodeInvalidRequest, "taskId 无效")) + return + } + service, token, ok := handler.service(context) + if !ok { + return + } + result, err := service.AgentHistoryDetail(context.Request.Context(), id, token) + if err != nil { + writeError(context, err) + return + } + context.Header("Cache-Control", "no-store") + context.JSON(http.StatusOK, gin.H{"data": result}) +} + func (handler Handler) Claim(context *gin.Context) { handler.action(context, (*Service).Claim) } func (handler Handler) Start(context *gin.Context) { handler.action(context, (*Service).Start) } diff --git a/server/app/goauto/task/router.go b/server/app/goauto/task/router.go index 371b843..8d4a654 100644 --- a/server/app/goauto/task/router.go +++ b/server/app/goauto/task/router.go @@ -17,6 +17,8 @@ func InitRouter(engine *gin.Engine, auth *jwt.GinJWTMiddleware) { trustForwardedProto, _ := strconv.ParseBool(os.Getenv("GOAUTO_TRUST_FORWARDED_PROTO")) agent := engine.Group("/api/agent/v1").Use(device.RequireHTTPS(config.ApplicationConfig.Mode == "prod", trustForwardedProto)) agent.GET("/tasks/next", handler.Next) + agent.GET("/collection-tasks", handler.AgentHistory) + agent.GET("/collection-tasks/:taskId", handler.AgentHistoryDetail) agent.POST("/tasks/:taskId/claim", handler.Claim) agent.POST("/tasks/:taskId/start", handler.Start) agent.POST("/tasks/:taskId/result", handler.Result) diff --git a/server/cmd/migrate/migration/version-local/1787690000000_purchase_actual_unit_price.go b/server/cmd/migrate/migration/version-local/1787690000000_purchase_actual_unit_price.go new file mode 100644 index 0000000..2cc1f50 --- /dev/null +++ b/server/cmd/migrate/migration/version-local/1787690000000_purchase_actual_unit_price.go @@ -0,0 +1,28 @@ +package version_local + +import ( + "runtime" + + goautomigrations "go-admin/app/goauto/migrations" + "go-admin/cmd/migrate/migration" + common "go-admin/common/models" + + "gorm.io/gorm" +) + +// #90 adds a nullable execution price. Existing tasks intentionally remain +// NULL because their creation-time reference price is not proof of the price +// observed by the Agent. +func init() { + _, fileName, _, _ := runtime.Caller(0) + migration.Migrate.SetVersion(migration.GetFilename(fileName), migratePurchaseActualUnitPrice) +} + +func migratePurchaseActualUnitPrice(db *gorm.DB, version string) error { + return db.Transaction(func(tx *gorm.DB) error { + if err := goautomigrations.Migrate(tx); err != nil { + return err + } + return tx.Create(&common.Migration{Version: version}).Error + }) +}