feat(agent): implement #90 task history
This commit is contained in:
@@ -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()
|
||||
}
|
||||
|
||||
+3
-2
@@ -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) }
|
||||
}
|
||||
}
|
||||
@@ -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 实际观察到的采购单价;旧任务不回填。
|
||||
|
||||
@@ -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 确实观察到时保存;旧任务或未观察到价格时显示“未记录”,不推算、不回填。
|
||||
|
||||
@@ -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`,客户端显示“未记录”。
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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) }
|
||||
|
||||
@@ -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, "当前任务不能标记订单结果未知")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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) }
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
+28
@@ -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
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user