feat(agent): implement #90 task history

This commit is contained in:
QiuSW
2026-08-26 09:26:38 +08:00
parent 21fc14988d
commit b885a4ef66
25 changed files with 1165 additions and 62 deletions
+2 -2
View File
@@ -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"
@@ -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()
}
@@ -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", "正式采购规则缺少核单动作")
}
@@ -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<T>(val items: List<T>, val total: Long, val page: Int, val pageSize: Int)
data class HistoryDimension(val key: String, val name: String, val values: List<String>)
data class HistoryColorPrice(val color: String, val priceCent: Long)
data class HistorySku(val specs: Map<String, String>, 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<HistoryDimension>,
val colorPrices: List<HistoryColorPrice>,
val skus: List<HistorySku>,
val missing: List<String>,
)
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<CollectionHistoryItem> {
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<PurchaseHistoryItem> {
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 <T> JSONArray.objects(mapper: (JSONObject) -> T): List<T> = (0 until length()).map { mapper(getJSONObject(it)) }
private fun JSONArray.strings(): List<String> = (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)
@@ -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()
@@ -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<MaterialButton>().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) } }
}
}
@@ -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) }
}
}
}
@@ -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)
@@ -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) }
}
}
+9 -2
View File
@@ -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
<!-- gitea-wiki-mirror:end -->
# 架构与代码地图
@@ -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 实际观察到的采购单价;旧任务不回填。
+9 -2
View File
@@ -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
<!-- gitea-wiki-mirror:end -->
# 业务规则与术语
@@ -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 确实观察到时保存;旧任务或未观察到价格时显示“未记录”,不推算、不回填。
+20 -2
View File
@@ -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
<!-- gitea-wiki-mirror:end -->
# 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`,客户端显示“未记录”。
+1
View File
@@ -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"`
+146
View File
@@ -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
}
@@ -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)
}
}
+42
View File
@@ -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) }
+5
View File
@@ -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, "当前任务不能标记订单结果未知")
+2
View File
@@ -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)
+6 -1
View File
@@ -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) {
+9 -8
View File
@@ -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 {
+186
View File
@@ -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
}
}
@@ -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)
}
}
+45
View File
@@ -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) }
+2
View File
@@ -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)
@@ -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
})
}