Compare commits

...
Author SHA1 Message Date
QiuSWandClaude Sonnet 5.5 9e54263ee6 feat(web): 采购管理增加设备/创建人筛选与创建人展示 (#382)
- 筛选区新增「设备」「创建人」可清空可搜索下拉,选项来自 filter-options,含未分派/Agent 发起/未记录;参与查询、重置与导出
- 表格在设备列后新增「创建人」列,详情新增创建人与创建来源

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
2026-10-12 17:17:08 +08:00
QiuSWandClaude Sonnet 5.5 ac7f81141c feat(purchase): 记录采购任务创建人与来源,列表增加设备/创建人筛选 (#382)
- 新建时写入一次创建人与来源:Admin/备货/批量创建=admin,客户端密钥=client_api(授权人),Agent 重试=agent(无创建人);幂等重放不改写
- AdminList 新增 deviceId(含 unassigned)与 createdBy(含 agent/unrecorded)筛选,非法值返回 CodeInvalidRequest
- 列表与详情返回 createdBy/createdByName/createdSource
- 新增 GET /purchase-tasks/filter-options 返回实际出现过的设备与创建人

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
2026-10-12 17:14:51 +08:00
QiuSWandClaude Sonnet 5.5 6cc3bb98d0 feat(db): purchase_task 新增 created_by/created_source 迁移 (#382)
只新增可空 created_by(索引)与非空默认空串的 created_source,不回填历史行;
迁移按列/索引存在性守卫,可重复执行。

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
2026-10-12 17:14:50 +08:00
QiuSWandClaude Opus 5.5 16e3d35b8f docs: mirror wiki for color grid, spec conflict and open-in-PDD (#370 #379 #375)
Business-Rules-and-Glossary@0113260, Android-Agent-API-Contract@c05446a,
Troubleshooting@50f5923.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
2026-10-12 16:34:50 +08:00
QiuSWandClaude Opus 5.5 82ed6664b7 merge: open product in PDD from task details (#375)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
2026-10-12 16:09:17 +08:00
QiuSWandClaude Opus 5.5 f334c1dcb0 merge: device connection card on status tab (#380)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
2026-10-12 16:09:17 +08:00
QiuSWandClaude Opus 5.5 4039fdae2b merge: keep conflicted spec values and merge badge captions (#379)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
2026-10-12 16:09:16 +08:00
QiuSWandClaude Opus 5.5 26562fc763 merge: horizontal color grid traversal (#370)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
2026-10-12 16:09:16 +08:00
QiuSWandClaude Sonnet 5.5 d3d3837014 feat(android): 采集与采购详情页新增在拼多多中打开按钮 (#375)
- 编号为纯数字时显示描边按钮,忙时置灰并显示原因,随忙状态广播实时更新
- 点击经服务执行,结果 toast 不含链接与异常消息

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
2026-10-12 15:53:47 +08:00
QiuSWandClaude Sonnet 5.5 a0d2416c49 feat(android): 服务端手动打开拼多多商品与统一忙状态 (#375)
- 新增 DeviceBusyPolicy:服务与界面共用的设备忙判断与原因文案,repurchaseLocalIdle 复用(行为不变,冷却期不算忙)
- 新增 PddManualOpenCoordinator:先判忙,再作废交接凭证,再打开;复用 PddLaunchFallback 事件生成提示
- AgentForegroundService:新增打开动作、结果广播,每 2 秒发布只读忙状态;只打开商品链接,不点击 PDD 内控件

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
2026-10-12 15:53:47 +08:00
QiuSWandClaude Sonnet 5.5 ff8c5361cb feat(android): 状态页新增设备连接卡片 (#380)
显示当前 Wi-Fi IPv4 与无线调试开关状态(adb_wifi_enabled),读取失败显示无法读取;
端口仅给出固定查看提示。读数到文案/颜色为纯函数策略,不持久化、不上报、不写日志。

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
2026-10-12 15:27:26 +08:00
23 changed files with 1347 additions and 19 deletions
@@ -176,6 +176,7 @@ class AgentForegroundService : Service() {
// Separate, serialized I/O ticks keep history reads and result observation off the
// heartbeat scheduler and, especially, off the single purchase execution thread.
repurchaseExecutor.scheduleWithFixedDelay(::tickRepurchase, 2, 2, TimeUnit.SECONDS)
repurchaseExecutor.scheduleWithFixedDelay(::refreshDeviceBusy, 1, 2, TimeUnit.SECONDS)
diagnosticInstance = this
}
@@ -197,6 +198,12 @@ class AgentForegroundService : Service() {
return START_STICKY
}
}
if (intent?.action == ACTION_OPEN_PDD_PRODUCT) {
val goodsId = intent.getStringExtra(EXTRA_OPEN_PDD_GOODS_ID)
val purchase = intent.getBooleanExtra(EXTRA_OPEN_PDD_PURCHASE, false)
runCatching { repurchaseExecutor.execute { openPddProduct(goodsId, purchase) } }
return START_STICKY
}
if (intent?.action == ACTION_BACKFILL_STOP) {
backfillGuard.cancelled.set(true)
return START_STICKY
@@ -228,6 +235,7 @@ class AgentForegroundService : Service() {
repurchase.requestStop(repurchase.state.roundId)
repurchaseExecutor.shutdownNow()
repurchaseState = RepurchaseState(message = "服务已停止;不会恢复整轮重购")
deviceBusyReason = null
backfillGuard.cancelled.set(true)
runCatching { connectivityManager.unregisterNetworkCallback(networkCallback) }
cancelIdleReturn("服务已停止")
@@ -497,9 +505,47 @@ class AgentForegroundService : Service() {
updateNotification(state.message, force = true)
}
private fun repurchaseLocalIdle(): Boolean = !working.get() && taskMutex.currentTaskId() == null &&
purchaseStore.activeTaskId() == null && purchaseStore.pendingOutbox().isEmpty() &&
stateStore.read().currentTaskId == null && !backfillState.running
private fun deviceBusyInputs(includeRepurchase: Boolean): DeviceBusyPolicy.Inputs {
val mutexTaskId = taskMutex.currentTaskId()
return DeviceBusyPolicy.Inputs(
repurchaseBusy = includeRepurchase && repurchaseState.busy,
backfillRunning = backfillState.running,
currentPageCollecting = mutexTaskId == CURRENT_PAGE_RESERVATION_ID,
taskActive = working.get() || mutexTaskId != null || purchaseStore.activeTaskId() != null ||
stateStore.read().currentTaskId != null,
unuploadedPurchase = purchaseStore.pendingOutbox().isNotEmpty(),
)
}
// The repurchase round itself asks this question between its tasks, so it must not count its own busy flag.
private fun repurchaseLocalIdle(): Boolean = DeviceBusyPolicy.reason(deviceBusyInputs(includeRepurchase = false)) == null
/** Publishes the read-only busy reason for the history detail buttons; click time re-checks. */
private fun refreshDeviceBusy() {
runCatching {
val reason = if (repurchaseClosed.get()) null else DeviceBusyPolicy.reason(deviceBusyInputs(includeRepurchase = true))
if (reason != deviceBusyReason) {
deviceBusyReason = reason
sendBroadcast(Intent(ACTION_DEVICE_BUSY_STATE).setPackage(packageName))
}
}
}
private fun openPddProduct(goodsId: String?, purchase: Boolean) {
val result = runCatching {
PddManualOpenCoordinator(
busyReason = { synchronized(taskMutex) { DeviceBusyPolicy.reason(deviceBusyInputs(includeRepurchase = true)) } },
invalidateHandoff = probeHandoff::invalidate,
launch = { url, preferDirect, diagnostic ->
cn.ilapage.goauto.agent.automation.PddLinkLauncher(this).open(url, preferDirect, diagnostic)
},
).run(goodsId, purchase)
}.getOrDefault(PddManualOpenResult.Failed)
sendBroadcast(Intent(ACTION_OPEN_PDD_RESULT).apply {
setPackage(packageName)
putExtra(EXTRA_OPEN_PDD_MESSAGE, PddManualOpenCoordinator.message(result))
})
}
private fun repurchaseEnvironment(session: RepurchaseSession): String? {
if (repurchaseClosed.get()) return "服务已经停止"
@@ -1811,6 +1857,15 @@ class AgentForegroundService : Service() {
const val ACTION_BACKFILL_STATE = "cn.ilapage.goauto.agent.BACKFILL_STATE"
@Volatile var backfillState = OrderBackfillState()
private set
const val ACTION_OPEN_PDD_PRODUCT = "cn.ilapage.goauto.agent.OPEN_PDD_PRODUCT"
const val ACTION_OPEN_PDD_RESULT = "cn.ilapage.goauto.agent.OPEN_PDD_RESULT"
const val ACTION_DEVICE_BUSY_STATE = "cn.ilapage.goauto.agent.DEVICE_BUSY_STATE"
const val EXTRA_OPEN_PDD_GOODS_ID = "open_pdd_goods_id"
const val EXTRA_OPEN_PDD_PURCHASE = "open_pdd_purchase"
const val EXTRA_OPEN_PDD_MESSAGE = "open_pdd_message"
/** Null when idle; otherwise the user-facing reason. Updated about every two seconds. */
@Volatile var deviceBusyReason: String? = null
private set
const val ACTION_RECONNECT = "cn.ilapage.goauto.agent.RECONNECT"
const val ACTION_CHECK_NOW = "cn.ilapage.goauto.agent.CHECK_NOW"
const val ACTION_CHECK_RESULT = "cn.ilapage.goauto.agent.CHECK_RESULT"
@@ -1867,6 +1922,15 @@ class AgentForegroundService : Service() {
}
}
fun openPddProduct(context: Context, goodsId: String, purchase: Boolean) {
val intent = Intent(context, AgentForegroundService::class.java).apply {
action = ACTION_OPEN_PDD_PRODUCT
putExtra(EXTRA_OPEN_PDD_GOODS_ID, goodsId)
putExtra(EXTRA_OPEN_PDD_PURCHASE, purchase)
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) context.startForegroundService(intent) else context.startService(intent)
}
fun collectCurrentPage(
context: Context,
requestId: String,
@@ -0,0 +1,85 @@
package cn.ilapage.goauto.agent.service
import cn.ilapage.goauto.agent.automation.PddLaunchEvent
import cn.ilapage.goauto.agent.automation.PddLaunchPath
import cn.ilapage.goauto.agent.automation.PddLaunchStatus
/** Single source of truth for "the device is busy"; the service and the UI both consume its result. */
internal object DeviceBusyPolicy {
data class Inputs(
val repurchaseBusy: Boolean = false,
val backfillRunning: Boolean = false,
/** The manual current-page collection holds the task mutex with its own reservation. */
val currentPageCollecting: Boolean = false,
/** A collection/purchase/image-search task is claimed, running or restored. */
val taskActive: Boolean = false,
/** Unuploaded purchase results or a purchase waiting for recovery. */
val unuploadedPurchase: Boolean = false,
)
const val REPURCHASE = "重购进行中"
const val BACKFILL = "订单回填中"
const val CURRENT_PAGE = "当前页采集进行中"
const val TASK = "设备正在执行任务"
const val UNUPLOADED = "有未上传的采购结果"
/** Null means idle. A collection cooldown is intentionally not an input. */
fun reason(inputs: Inputs): String? = when {
inputs.repurchaseBusy -> REPURCHASE
inputs.backfillRunning -> BACKFILL
inputs.currentPageCollecting -> CURRENT_PAGE
inputs.taskActive -> TASK
inputs.unuploadedPurchase -> UNUPLOADED
else -> null
}
}
internal sealed class PddManualOpenResult {
object InvalidGoodsId : PddManualOpenResult()
data class Busy(val reason: String) : PddManualOpenResult()
data class Opened(val fallbackNotice: String?) : PddManualOpenResult()
object Failed : PddManualOpenResult()
}
/** Manual "open in PDD" entry. Only opens the product link; it never clicks anything inside PDD. */
internal class PddManualOpenCoordinator(
private val busyReason: () -> String?,
private val invalidateHandoff: () -> Unit,
private val launch: (url: String, preferDirect: Boolean, diagnostic: (PddLaunchEvent) -> Unit) -> Boolean,
) {
fun run(goodsId: String?, purchase: Boolean): PddManualOpenResult {
val url = link(goodsId) ?: return PddManualOpenResult.InvalidGoodsId
busyReason()?.let { return PddManualOpenResult.Busy(it) }
// The opened page is no longer the product a previous probe prepared.
invalidateHandoff()
val events = mutableListOf<PddLaunchEvent>()
val opened = launch(url, purchase) { events += it }
return if (opened) PddManualOpenResult.Opened(fallbackNotice(events)) else PddManualOpenResult.Failed
}
companion object {
const val OPENED_HINT = "打开期间如有新任务,Agent 会接管屏幕"
const val FAILED = "无法打开拼多多链接"
const val INVALID = "商品编号无效,无法打开"
fun link(goodsId: String?): String? =
goodsId?.takeIf { it.isNotEmpty() && it.all { c -> c in '0'..'9' } }
?.let { "https://mobile.yangkeduo.com/goods.html?goods_id=$it" }
/** Never contains the URL or an exception message; only the simple exception class name. */
fun fallbackNotice(events: List<PddLaunchEvent>): String? {
val direct = events.firstOrNull { it.path == PddLaunchPath.DIRECT && it.status != PddLaunchStatus.ACCEPTED }
?: return null
if (events.none { it.path == PddLaunchPath.BROWSER && it.status == PddLaunchStatus.ACCEPTED }) return null
val name = direct.exceptionClass?.substringAfterLast('.')?.takeIf(String::isNotBlank)
return "拼多多直接打开失败,已改用浏览器" + (name?.let { "($it)" } ?: "")
}
fun message(result: PddManualOpenResult): String = when (result) {
PddManualOpenResult.InvalidGoodsId -> INVALID
is PddManualOpenResult.Busy -> "${result.reason},暂不能打开拼多多"
is PddManualOpenResult.Opened -> listOfNotNull(result.fallbackNotice, OPENED_HINT).joinToString("\n")
PddManualOpenResult.Failed -> FAILED
}
}
}
@@ -74,6 +74,8 @@ class AgentStatusFragment : Fragment() {
private lateinit var mediaPermissionDetail: TextView
private lateinit var mediaPermissionAction: android.widget.Button
private lateinit var deviceText: TextView
private lateinit var deviceWifiText: TextView
private lateinit var deviceDebugText: TextView
private val mediaPermissionRequestLauncher by lazy {
registerForActivityResult(androidx.activity.result.contract.ActivityResultContracts.RequestPermission()) {
refreshStatus()
@@ -168,6 +170,20 @@ class AgentStatusFragment : Fragment() {
}
addView(mediaPermissionAction)
}))
addView(context.card(context.cardColumn().apply {
addView(context.label("设备连接", 14f, context.getColor(R.color.agent_text_muted)))
deviceWifiText = context.label("Wi-Fi IP:—", 18f, context.getColor(R.color.agent_text), true).apply {
setPadding(0, context.dp(6), 0, 0)
}
deviceDebugText = context.label("无线调试:—", 18f, context.getColor(R.color.agent_text_muted), true).apply {
setPadding(0, context.dp(6), 0, 0)
}
addView(deviceWifiText)
addView(deviceDebugText)
addView(context.label(DeviceConnectionPolicy.PORT_HINT, 14f, context.getColor(R.color.agent_text_muted)).apply {
setPadding(0, context.dp(6), 0, 0)
})
}))
}
refreshLayout = SwipeRefreshLayout(context).apply {
setColorSchemeResources(R.color.agent_primary_light)
@@ -245,6 +261,10 @@ class AgentStatusFragment : Fragment() {
private fun refreshStatus() {
if (!isAdded || view == null) return
val context = requireContext()
val deviceConnection = DeviceConnectionPolicy.display(DeviceConnectionReader.read(context))
deviceWifiText.text = deviceConnection.wifiText
deviceDebugText.text = deviceConnection.debugText
deviceDebugText.setTextColor(context.getColor(deviceConnection.debugColor))
val state = stateStore.read()
val connected = state.code in setOf("ONLINE", "BUSY", "COLLECTION_COOLDOWN")
connectionTitle.text = when (state.code) {
@@ -0,0 +1,85 @@
package cn.ilapage.goauto.agent.ui
import android.content.Context
import android.net.ConnectivityManager
import android.net.NetworkCapabilities
import android.provider.Settings
import androidx.annotation.ColorRes
import cn.ilapage.goauto.agent.R
import java.net.Inet4Address
import java.net.InetAddress
/** 无线调试开关的读取结果;读取失败为 [UNKNOWN]。 */
internal enum class WirelessDebugState { ENABLED, DISABLED, UNKNOWN }
internal data class DeviceConnectionReading(
val wifiIpv4: String?,
val wirelessDebug: WirelessDebugState,
)
internal data class DeviceConnectionDisplay(
val wifiText: String,
val debugText: String,
@ColorRes val debugColor: Int,
)
/** 原始读数到显示文案/颜色的纯函数策略,不依赖 Android 系统服务。 */
internal object DeviceConnectionPolicy {
const val PORT_HINT = "端口请在 开发者选项 → 无线调试 中查看"
const val NO_WIFI_TEXT = "未连接 Wi-Fi"
/** 仅当前网络为 Wi-Fi 时,从地址列表中取第一个非回环 IPv4;否则返回 null。 */
fun selectWifiIpv4(isWifi: Boolean, addresses: List<InetAddress>): String? {
if (!isWifi) return null
return addresses.firstOrNull { it is Inet4Address && !it.isLoopbackAddress }?.hostAddress
}
/** adb_wifi_enabled 原始值:1 开启,0 未开启,其他值或读取失败(null)为未知。 */
fun debugState(raw: Int?): WirelessDebugState = when (raw) {
1 -> WirelessDebugState.ENABLED
0 -> WirelessDebugState.DISABLED
else -> WirelessDebugState.UNKNOWN
}
fun display(reading: DeviceConnectionReading): DeviceConnectionDisplay {
val (text, color) = when (reading.wirelessDebug) {
WirelessDebugState.ENABLED -> "已开启" to R.color.agent_primary_light
WirelessDebugState.DISABLED -> "未开启" to R.color.agent_warning
WirelessDebugState.UNKNOWN -> "无法读取" to R.color.agent_text_muted
}
return DeviceConnectionDisplay(
wifiText = "Wi-Fi IP:${reading.wifiIpv4 ?: NO_WIFI_TEXT}",
debugText = "无线调试:$text",
debugColor = color,
)
}
}
/** 系统读取的薄封装;任何异常都降级为「未知」,不记录日志、不持久化。 */
internal object DeviceConnectionReader {
fun read(context: Context): DeviceConnectionReading = DeviceConnectionReading(
wifiIpv4 = readWifiIpv4(context),
wirelessDebug = DeviceConnectionPolicy.debugState(readDebugRaw(context)),
)
private fun readWifiIpv4(context: Context): String? = try {
val manager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager
val network = manager?.activeNetwork
val isWifi = network?.let { manager.getNetworkCapabilities(it) }
?.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) == true
val addresses = if (isWifi && manager != null && network != null) {
manager.getLinkProperties(network)?.linkAddresses?.map { it.address }.orEmpty()
} else {
emptyList()
}
DeviceConnectionPolicy.selectWifiIpv4(isWifi, addresses)
} catch (_: Exception) {
null
}
private fun readDebugRaw(context: Context): Int? = try {
Settings.Global.getInt(context.contentResolver, "adb_wifi_enabled", 0)
} catch (_: Exception) {
null
}
}
@@ -22,6 +22,7 @@ import android.widget.HorizontalScrollView
import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.ProgressBar
import android.widget.TextView
import android.widget.Toast
import androidx.fragment.app.Fragment
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
@@ -43,6 +44,7 @@ import cn.ilapage.goauto.agent.service.AgentForegroundService
import cn.ilapage.goauto.agent.service.AgentSettingsStore
import cn.ilapage.goauto.agent.service.AgentStateStore
import cn.ilapage.goauto.agent.service.CollectionCooldownPolicy
import cn.ilapage.goauto.agent.service.PddManualOpenCoordinator
import com.google.android.material.button.MaterialButton
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.textfield.TextInputEditText
@@ -143,6 +145,9 @@ class TaskHistoryFragment : Fragment() {
private val imageLoader = HistoryImageLoader()
private val imageRequests = mutableListOf<HistoryImageRequest>()
private var currentPageReceiverRegistered = false
private var openPddButton: MaterialButton? = null
private var openPddReason: TextView? = null
private var openPddAwaitingResult = false
private var backfillPanel: LinearLayout? = null
private var repurchasePanel: LinearLayout? = null
private var repurchaseButton: MaterialButton? = null
@@ -156,6 +161,17 @@ class TaskHistoryFragment : Fragment() {
renderRejections()
return
}
if (intent?.action == AgentForegroundService.ACTION_DEVICE_BUSY_STATE) {
applyOpenPddBusy()
return
}
if (intent?.action == AgentForegroundService.ACTION_OPEN_PDD_RESULT) {
if (!openPddAwaitingResult) return
openPddAwaitingResult = false
val message = intent.getStringExtra(AgentForegroundService.EXTRA_OPEN_PDD_MESSAGE).orEmpty()
if (message.isNotBlank() && isAdded) Toast.makeText(requireContext(), message, Toast.LENGTH_LONG).show()
return
}
if (intent?.action == AgentForegroundService.ACTION_REPURCHASE_STATE) {
renderRepurchase()
if (!collection && isResumed && AgentForegroundService.repurchaseState.phase == cn.ilapage.goauto.agent.service.RepurchasePhase.FINISHED) {
@@ -743,6 +759,53 @@ class TaskHistoryFragment : Fragment() {
}
}
/**
* Manual "open in PDD" entry. Execution goes through the service so it can re-check busy state and
* invalidate the purchase probe handoff; the button never taps anything inside PDD.
*/
private fun openInPddSection(goodsId: String?, purchase: Boolean): View? {
openPddButton = null
openPddReason = null
if (PddManualOpenCoordinator.link(goodsId) == null) return null
val context = requireContext()
val id = goodsId.orEmpty()
val button = MaterialButton(context, null, com.google.android.material.R.attr.materialButtonOutlinedStyle).apply {
text = "在拼多多中打开"
minimumHeight = context.dp(48)
contentDescription = "在拼多多中打开商品 $id"
setOnClickListener {
val busy = AgentForegroundService.deviceBusyReason
if (busy != null) {
applyOpenPddBusy()
toast("$busy,暂不能打开拼多多")
} else {
openPddAwaitingResult = true
runCatching { AgentForegroundService.openPddProduct(requireContext(), id, purchase) }
.onFailure { openPddAwaitingResult = false; toast(PddManualOpenCoordinator.FAILED) }
}
}
}
val reason = context.label("", 12f, context.getColor(R.color.agent_text_muted)).apply { visibility = View.GONE }
openPddButton = button
openPddReason = reason
applyOpenPddBusy()
return LinearLayout(context).apply {
orientation = LinearLayout.VERTICAL
setPadding(0, context.dp(4), 0, context.dp(8))
addView(button)
addView(reason)
}
}
private fun applyOpenPddBusy() {
val button = openPddButton ?: return
val reasonView = openPddReason ?: return
val busy = AgentForegroundService.deviceBusyReason
button.isEnabled = busy == null
reasonView.text = busy.orEmpty()
reasonView.visibility = if (busy == null) View.GONE else View.VISIBLE
}
private fun renderCollectionDetail(detail: CollectionHistoryDetail) {
val context = requireContext()
val task = detail.task
@@ -757,6 +820,7 @@ class TaskHistoryFragment : Fragment() {
}
val detailCard = context.cardColumn().apply {
addView(context.label("#${task.taskId} · 第 ${task.attemptNumber} 次 · ${task.goodsId.ifBlank { "未识别商品" }}", 20f, context.getColor(R.color.agent_text), true))
openInPddSection(task.goodsId, purchase = false)?.let(::addView)
if (task.source == "agent_current_page") addView(context.label("来源:Agent 当前页面", 13f, context.getColor(R.color.agent_text_muted)))
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))
@@ -850,6 +914,8 @@ class TaskHistoryFragment : Fragment() {
filter.addAction(AgentForegroundService.ACTION_BACKFILL_STATE)
filter.addAction(AgentForegroundService.ACTION_REPURCHASE_STATE)
filter.addAction(AgentForegroundService.ACTION_PURCHASE_REJECTIONS_CHANGED)
filter.addAction(AgentForegroundService.ACTION_DEVICE_BUSY_STATE)
filter.addAction(AgentForegroundService.ACTION_OPEN_PDD_RESULT)
if (Build.VERSION.SDK_INT >= 33) {
requireContext().registerReceiver(currentPageReceiver, filter, Context.RECEIVER_NOT_EXPORTED)
} else {
@@ -937,9 +1003,8 @@ class TaskHistoryFragment : Fragment() {
addView(context.label(PurchaseRejectionPresentation.notice(record) + "\n${record.errorCode}", 14f, context.getColor(R.color.agent_warning)))
}))
}
val infoHead = "蝦皮订单号:${task.shopeeOrderNo.ifBlank { "—" }}\nPDD 商品:${task.pddGoodsId}"
val info = buildString {
append("蝦皮订单号:${task.shopeeOrderNo.ifBlank { "—" }}\n")
append("PDD 商品:${task.pddGoodsId}\n")
append("商品标题:${task.pddTitle.ifBlank { "—" }}\n")
append("目标规格:${listOf(task.targetColor, task.targetSize).filter(String::isNotBlank).joinToString(" / ").ifBlank { "—" }}\n")
append("执行规格:${listOf(task.mappedColor, task.mappedSize).filter(String::isNotBlank).joinToString(" / ").ifBlank { "—" }}\n")
@@ -957,6 +1022,8 @@ class TaskHistoryFragment : Fragment() {
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(infoHead, 14f))
openInPddSection(task.pddGoodsId, purchase = true)?.let(::addView)
addView(context.label(info, 14f))
}), collectionCardParams())
val lastFailure = detail.lastFailureMessage?.takeIf(String::isNotBlank)
@@ -0,0 +1,63 @@
package cn.ilapage.goauto.agent
import cn.ilapage.goauto.agent.ui.DeviceConnectionPolicy
import cn.ilapage.goauto.agent.ui.DeviceConnectionReading
import cn.ilapage.goauto.agent.ui.WirelessDebugState
import java.net.InetAddress
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
class DeviceConnectionPolicyTest {
private fun ip(text: String) = InetAddress.getByName(text)
@Test fun wifiWithIpv4ReturnsAddress() {
assertEquals("192.168.0.93", DeviceConnectionPolicy.selectWifiIpv4(true, listOf(ip("192.168.0.93"))))
}
@Test fun nonWifiReturnsNull() {
assertNull(DeviceConnectionPolicy.selectWifiIpv4(false, listOf(ip("10.0.0.2"))))
}
@Test fun wifiWithOnlyIpv6ReturnsNull() {
assertNull(DeviceConnectionPolicy.selectWifiIpv4(true, listOf(ip("2001:db8::1"))))
}
@Test fun wifiWithMultipleAddressesPicksIpv4() {
assertEquals(
"172.16.1.5",
DeviceConnectionPolicy.selectWifiIpv4(true, listOf(ip("2001:db8::1"), ip("172.16.1.5"))),
)
}
@Test fun loopbackIpv4IsSkipped() {
assertNull(DeviceConnectionPolicy.selectWifiIpv4(true, listOf(ip("127.0.0.1"))))
}
@Test fun debugRawValuesMapToState() {
assertEquals(WirelessDebugState.ENABLED, DeviceConnectionPolicy.debugState(1))
assertEquals(WirelessDebugState.DISABLED, DeviceConnectionPolicy.debugState(0))
assertEquals(WirelessDebugState.UNKNOWN, DeviceConnectionPolicy.debugState(2))
assertEquals(WirelessDebugState.UNKNOWN, DeviceConnectionPolicy.debugState(null))
}
@Test fun displayEnabledUsesPrimaryColor() {
val d = DeviceConnectionPolicy.display(DeviceConnectionReading("192.168.0.93", WirelessDebugState.ENABLED))
assertEquals("Wi-Fi IP:192.168.0.93", d.wifiText)
assertEquals("无线调试:已开启", d.debugText)
assertEquals(R.color.agent_primary_light, d.debugColor)
}
@Test fun displayDisabledUsesWarningColorAndNoWifiText() {
val d = DeviceConnectionPolicy.display(DeviceConnectionReading(null, WirelessDebugState.DISABLED))
assertEquals("Wi-Fi IP:未连接 Wi-Fi", d.wifiText)
assertEquals("无线调试:未开启", d.debugText)
assertEquals(R.color.agent_warning, d.debugColor)
}
@Test fun displayUnknownUsesMutedColor() {
val d = DeviceConnectionPolicy.display(DeviceConnectionReading(null, WirelessDebugState.UNKNOWN))
assertEquals("无线调试:无法读取", d.debugText)
assertEquals(R.color.agent_text_muted, d.debugColor)
}
}
@@ -0,0 +1,147 @@
package cn.ilapage.goauto.agent.service
import cn.ilapage.goauto.agent.automation.PddLaunchEvent
import cn.ilapage.goauto.agent.automation.PddLaunchFallback
import cn.ilapage.goauto.agent.automation.PddLaunchPath
import cn.ilapage.goauto.agent.automation.PddLaunchStatus
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class PddManualOpenTest {
private class Harness(
val busy: String? = null,
val direct: () -> Boolean = { true },
val browser: () -> Boolean = { true },
) {
val calls = mutableListOf<String>()
var preferDirect: Boolean? = null
val coordinator = PddManualOpenCoordinator(
busyReason = { calls += "busy"; busy },
invalidateHandoff = { calls += "invalidate" },
launch = { _, prefer, diagnostic ->
calls += "launch"
preferDirect = prefer
PddLaunchFallback.open(prefer, direct = { calls += "direct"; direct() },
browser = { calls += "browser"; browser() }, diagnostic = diagnostic)
},
)
}
@Test fun linkUsesGoodsIdOnly() {
assertEquals("https://mobile.yangkeduo.com/goods.html?goods_id=825093359197", PddManualOpenCoordinator.link("825093359197"))
}
@Test fun linkRejectsBlankOrNonDigitGoodsId() {
listOf(null, "", " ", "12a", "1 2", "12&x=1", "-1", "123").forEach { assertNull(it, PddManualOpenCoordinator.link(it)) }
}
@Test fun invalidGoodsIdNeitherChecksBusyNorInvalidatesNorOpens() {
val h = Harness()
assertEquals(PddManualOpenResult.InvalidGoodsId, h.coordinator.run("abc", purchase = false))
assertTrue(h.calls.isEmpty())
}
@Test fun collectionButtonOnlyUsesBrowser() {
val h = Harness(direct = { error("unexpected") })
assertEquals(PddManualOpenResult.Opened(null), h.coordinator.run("1", purchase = false))
assertEquals(false, h.preferDirect)
assertFalse(h.calls.contains("direct"))
assertTrue(h.calls.contains("browser"))
}
@Test fun purchaseButtonPrefersDirectAndStopsWhenAccepted() {
val h = Harness(browser = { error("unexpected") })
assertEquals(PddManualOpenResult.Opened(null), h.coordinator.run("1", purchase = true))
assertEquals(true, h.preferDirect)
assertFalse(h.calls.contains("browser"))
}
@Test fun purchaseDirectExceptionFallsBackToBrowserWithSimpleClassName() {
val h = Harness(direct = { throw SecurityException("secret url and address") })
val result = h.coordinator.run("1", purchase = true)
assertEquals(PddManualOpenResult.Opened("拼多多直接打开失败,已改用浏览器(SecurityException)"), result)
val text = PddManualOpenCoordinator.message(result)
assertFalse(text.contains("secret"))
assertFalse(text.contains("http"))
assertTrue(text.endsWith(PddManualOpenCoordinator.OPENED_HINT))
}
@Test fun purchaseDirectRejectedFallbackNoticeOmitsParenthesesWithoutClass() {
val h = Harness(direct = { false })
assertEquals(PddManualOpenResult.Opened("拼多多直接打开失败,已改用浏览器"), h.coordinator.run("1", purchase = true))
}
@Test fun allPathsFailingReportsOpenFailure() {
val h = Harness(direct = { throw IllegalStateException("x") }, browser = { false })
val result = h.coordinator.run("1", purchase = true)
assertEquals(PddManualOpenResult.Failed, result)
assertEquals("无法打开拼多多链接", PddManualOpenCoordinator.message(result))
}
@Test fun fallbackNoticeNeedsAcceptedBrowser() {
val events = listOf(
PddLaunchEvent(PddLaunchPath.DIRECT, PddLaunchStatus.EXCEPTION, "java.lang.SecurityException"),
PddLaunchEvent(PddLaunchPath.BROWSER, PddLaunchStatus.REJECTED),
)
assertNull(PddManualOpenCoordinator.fallbackNotice(events))
}
@Test fun busyAtClickTimeDoesNotInvalidateOrOpen() {
val h = Harness(busy = DeviceBusyPolicy.TASK)
val result = h.coordinator.run("1", purchase = true)
assertEquals(PddManualOpenResult.Busy("设备正在执行任务"), result)
assertEquals(listOf("busy"), h.calls)
assertEquals("设备正在执行任务,暂不能打开拼多多", PddManualOpenCoordinator.message(result))
}
@Test fun handoffIsInvalidatedBeforeAnyLaunch() {
val h = Harness()
h.coordinator.run("1", purchase = true)
assertEquals(listOf("busy", "invalidate", "launch", "direct"), h.calls)
}
@Test fun handoffIsInvalidatedEvenWhenOpeningFails() {
val h = Harness(direct = { false }, browser = { false })
h.coordinator.run("1", purchase = true)
assertTrue(h.calls.indexOf("invalidate") in 0 until h.calls.indexOf("launch"))
}
@Test fun idleInputsAreNotBusy() {
assertNull(DeviceBusyPolicy.reason(DeviceBusyPolicy.Inputs()))
}
@Test fun runningTaskIsBusy() {
assertEquals("设备正在执行任务", DeviceBusyPolicy.reason(DeviceBusyPolicy.Inputs(taskActive = true)))
}
@Test fun repurchaseRoundIsBusyIncludingBetweenTasks() {
// Between two tasks every task input is false but the round is still busy.
assertEquals("重购进行中", DeviceBusyPolicy.reason(DeviceBusyPolicy.Inputs(repurchaseBusy = true)))
}
@Test fun backfillIsBusy() {
assertEquals("订单回填中", DeviceBusyPolicy.reason(DeviceBusyPolicy.Inputs(backfillRunning = true)))
}
@Test fun currentPageCollectionIsBusy() {
assertEquals("当前页采集进行中", DeviceBusyPolicy.reason(DeviceBusyPolicy.Inputs(currentPageCollecting = true, taskActive = true)))
}
@Test fun unuploadedOrRecoverablePurchaseIsBusy() {
assertEquals("有未上传的采购结果", DeviceBusyPolicy.reason(DeviceBusyPolicy.Inputs(unuploadedPurchase = true)))
}
@Test fun repurchaseLocalIdleViewIgnoresOwnRoundButKeepsOtherWork() {
// The service builds the repurchase-idle inputs with repurchaseBusy=false: the round never blocks itself.
assertNull(DeviceBusyPolicy.reason(DeviceBusyPolicy.Inputs(repurchaseBusy = false)))
assertEquals("订单回填中", DeviceBusyPolicy.reason(DeviceBusyPolicy.Inputs(repurchaseBusy = false, backfillRunning = true)))
}
@Test fun collectionCooldownIsNotAnInput() {
// A cooldown only blocks claiming new collection work; it leaves every busy input false.
assertTrue(DeviceBusyPolicy.Inputs::class.java.declaredFields.none { it.name.contains("cooldown", ignoreCase = true) })
}
}
+24 -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: d427a46283561df71714929c13b7156ee42debea
synchronized_at: 2026-10-10T03:47:23Z
wiki_revision: 01132608beefbecb40b62284482e3b73822fcc87
synchronized_at: 2026-10-12T08:31:15Z
<!-- gitea-wiki-mirror:end -->
<!-- gitea-wiki-mirror:start -->
@@ -220,6 +220,7 @@ Admin 的「创建时间」范围按 SYB 货运单列表的 `t_stock.created`(
- 采购规则的 `probeSpecs.dimensionAliases` 是只读规格维度标题的服务端下发事实源;颜色别名可由管理员增加“组合”等 PDD 页面文案,尺码别名单独维护。该字段只影响探测和 AI 匹配输入,不能改变 `selectSpec` 的精确点击目标;旧任务快照缺省时才使用 Agent 内置兼容值。
- PDD 采集规则的 `collector.textAliases` 是规格面板、维度标题、选择摘要、评价入口、售罄主商品证据和购买入口等只读识别文案的服务端下发事实源;缺省字段只为旧快照兼容使用 Agent 内置默认值。颜色/尺码分类不得再用代码字面量扩大 `dimensionAliases`。订单和支付文字可以参与只读页面判定,但 Agent 内不可配置的创建订单/确认订单/支付/付款点击拒绝清单始终优先,规则不能放宽;修改地址和收货地址在只读及点击字段中都禁止。
- PDD 颜色采集对首屏已识别为多行或网格的颜色区执行动态行发现:每次动作后重新解析全部可见行,单列不做无效横向滑动,并复用规则中的 `specVerticalSwipes` 与 `stableEdgeReads` 有界纵向翻页;颜色结束后规格面板归顶,再进入尺码发现。选项重排或已选项从无障碍节点消失时,不得静默跳过仍未尝试的可用颜色。
- #370 第 2 阶段(合并 main `26562fc`,2026-10-12;测试机回归通过,采购机未装机):横向颜色网格每次横滑后按颜色文字与位置逐帧重新定位同一行;多行共用一个横滑容器时用选项位置签名判断网格是否移动,且只有确认起点已在最左端时才跳过逐行蛇形反扫;横滑后固定等待 1.2 秒再读取;滑回最左端使用独立额度(`specHorizontalSwipes` × 2),锚点暂时丢失的重试不计入次数但有连续与累计上限。异常终止保留最早原因,仍不得静默跳过未尝试的可用颜色。只改 Android Agent 采集遍历,不改 Admin 图搜、自动关联和服务端。
- 正式采购规则可以调用独立审核的改地址、创建待付款订单和只读核单动作;采集规则和演练规则不能调用。真机首次安装或验证仍需独立人工授权。
- 系统当前不提供自动支付、实时屏幕或管理端远程控制。付款、免密支付及任何等价动作不能配置为点击目标;只读识别字段可以包含支付或订单文字。后续若新增支付能力,必须由独立工单和显式控制重新评估。
@@ -970,3 +971,24 @@ Server实现 `d84728d`、Android实现 `dbe7e98`,main 合并记录见 #378;
Android同步诊断不改变#377的心跳→恢复→补传→必要的一次任务不一致心跳→符合条件才领任务的顺序。诊断failure是本轮观察到的同步错误,不等同于业务任务失败;恢复内部暂缓处理的API错误也记录,但不改变原恢复占位、后续flush/claim规则,不增加采购重试或付款动作。
可恢复诊断存储异常不阻断同步,取消和致命错误不记成假成功。同步记录独立保留24小时/6000轮,不占采集50条额度;旧进行中记录只表示中断/不完整,不伪造原现场、失败原因或成功。报告确认不会清除发送期间新增失败;本地确认失败或响应丢失允许重复,报告不能用于改变设备在线或采购状态。
## 规格规范化同名冲突与徽标说明合并(#379)
合并 main `4039fda`(2026-10-12);单元测试覆盖,测试机回归通过;采购1(PDD 8.26.0)徽标形态的真机验证待装机时完成。普通采集与采购规格探测共用同一采集器。
- 变更 #160 的同名冲突规则:同一维度内不同原文规范化后同名(同帧或跨帧,例如尾价时有时无)时不再整维作废,只保留一个值(先出现的原文及其价格、图片),在 missing 写 `spec_dimension_conflict:<dimensionKey>`,任务为 `completed_partial`。用户接受「重复或合并规格」的风险,不接受「整组没有尺码/颜色」。
- 规范化后为空或仍含 `¥/¥` 的值维持整维拒绝,写 `spec_dimension_invalid:<dimensionKey>`,不放宽;服务端规范化防线不变。
- 尺码续页路径与正常路径一样去掉尾价,冲突检查在去重之前完成。
- #362 截短卡片合并扩展:说明文字形如「截短名[...|…] ¥金额 徽标」(徽标 1–8 个字符,不含货币符号、数字和空白)时,金额须等于外层完整名的尾价,去掉省略号后须为完整名的严格前缀,并满足 #362 全部结构条件,才与外层卡片合并;任一条件不满足行为不变。不维护徽标词表。
- 残余风险:两个真实不同、规范化后同名、且从未同屏出现的选项会被合并为一个;同屏出现时下单因 `SPEC_TARGET_AMBIGUOUS` 失败,不会任选。
- 未覆盖:说明文字只有徽标、不带价格(如「…零差评」)时仍可能多出一个截短重复颜色,不导致作废。
## Agent 详情页在拼多多中打开商品(#375)
合并 main `82ed666`(2026-10-12);测试机真机验证通过,采购机未装机。
- 采集详情与采购详情显示描边按钮「在拼多多中打开」,用任务商品编号拼 `https://mobile.yangkeduo.com/goods.html?goods_id=<编号>`;编号为空或非纯数字时不显示。用途仅为人工查看商品,不复现后台执行过程。
- 采集按钮沿用采集的浏览器打开方式;采购按钮沿用采购方式先直达 PDD,失败回退浏览器,并提示异常简单类名。人工入口不代点浏览器中的「打开拼多多APP」,不在 PDD 内点击任何控件;提示不含链接和异常消息原文。
- 打开由 Agent 服务执行:点击时再判断设备是否忙,忙则不打开;不忙时先作废 #373 一次性交接凭证再打开,下一次采购必须重新打开本任务链接。
- 设备忙:重购一轮进行中(含两个任务之间)、订单回填中、当前页采集进行中、设备正在执行任务(含图搜)、有未上传或待恢复的采购结果;任务结束后的冷却期不算忙。忙时按钮置灰并显示原因,状态约每 2 秒发布,与 `repurchaseLocalIdle` 共用同一判断。
- 不暂停领任务,不改调度、心跳和租约;查看期间来了新任务由 Agent 接管屏幕,打开后提示「打开期间如有新任务,Agent 会接管屏幕」。不上传打开结果,不写日志或诊断。
+26 -2
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Troubleshooting
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Troubleshooting
wiki_revision: d06fa26d85339641e3d67e5ee6314205173c24c5
synchronized_at: 2026-10-10T03:10:51Z
wiki_revision: 50f5923623db34cddb9ea152238c11669a25eb95
synchronized_at: 2026-10-12T08:31:42Z
<!-- gitea-wiki-mirror:end -->
# 故障排查
@@ -220,3 +220,27 @@ ORDER BY id;
- 一轮中观察到步骤失败(包括恢复内部暂缓处理的500/409),诊断可记failure,即使业务随后恢复连接;这不是采集/采购任务失败,也不能据此重下单。已正常收敛的明确拒收仍按#377处理。
- timeout_connect/timeout_response/timeout_unknown根据请求实际阶段分类,未知阶段不猜。诊断故障只输出sync_diagnostic_persistence_failed或sync_diagnostic_context_failed固定提示;不附错误正文、URL或SQL参数。
- reported只表示Agent已收到该报告请求的成功响应并在本地确认,不保证Server历史写入成功;响应丢失或确认失败可能重复补报。
## Android 颜色横向网格遍历(#370 第 2 阶段)
实现已合并 main(`26562fc`)。遍历过程只写 `Log.i`(标签 `GoAutoCollector`),不落库、不上报;排查时在已授权的 Debug 设备上实时抓取,用完删除:
```powershell
adb -s <serial> logcat -v time -s GoAutoCollector:V
```
- `color-start confirmed= swipes= anchorRetries= end=`:滑回最左端的结果;`confirmed=false` 时不会跳过逐行反扫。
- `color-row row= dir= match= moved= others= sharedMoves= sharedBroken= uncertain= signatureReads=`:每次横滑后的行定位与是否移动;`others=moved` 表示多行共用一个横滑容器。
- `color-row-end row= end=`:该行结束原因(如 `edge` 到达边缘)。
- `color-settle waitedMs=1200`:横滑后的固定等待。
- `color-click-miss`:点击目标未生效。
- 漏色时先核对同一设备、Agent/PDD 版本和规则快照,再看 `color-row-end` 是否过早出现或 `color-start` 是否未确认。日志缓冲较小时可能被覆盖,缺少日志不能证明没执行。
## 采购探测报「没有找到可采购的 PDD 颜色或尺码」(#379)
服务端只看规格候选是否为空,不读 missing,该提示可能掩盖「某维度被整维作废」的真实原因:
1. 查同一次探测或采集结果的 missing:有 `spec_dimension_invalid:color|size` 表示该维度被整维清空(规范化后为空或仍含货币符号);`spec_dimension_conflict:*` 表示同名冲突已保留一个值,维度仍可用。
2. 已知成因(2026-10-12,采购1 / PDD 8.26.0 / 商品 715527668284):颜色卡片的截短说明文字在价格后带「零差评」「最多人选择」等徽标,旧版本无法与外层卡片合并,说明值仍含 `¥` 导致颜色整维作废。#379 后满足条件的徽标说明会合并;同一商品在其他设备或 PDD 版本上可能不出现徽标,不能用测试机结果否定。
3. 实时日志 `spec-normalize dim= outcome= reason= path= pass= sameFrame= onlyTrailingPriceDiff= rawVariants= candidates= badgeCaptionMerged=`:`outcome=invalid_rejected` 对应整维作废,`reason=unsafe_currency|empty`;`outcome=conflict_kept` 对应同名冲突保留;`badgeCaptionMerged` 为本帧合并的徽标卡片数。只含计数和枚举,不含规格原文。每维每类只记录首次。
4. 若需看规格面板原文,只在用户手动打开面板后做一次只读 uiautomator 导出并过滤地址、电话,用完立即删除;不得为打开面板点击购买类按钮。
+3 -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: 1dc3c7550c0c4da4291e08b840170d861042e97a
synchronized_at: 2026-10-10T03:10:58Z
wiki_revision: c05446aecd49fb7234f79f5d5e2df68f244166e9
synchronized_at: 2026-10-12T08:31:57Z
<!-- gitea-wiki-mirror:end -->
<!-- gitea-wiki-mirror:start -->
@@ -421,6 +421,7 @@ POST /api/agent/v1/tasks/{taskId}/result
- size 规格值只允许剥离位于文本尾部的 ¥ / ¥ 数字价格片段,括号说明、体重区间和其他规格身份文本原样保留;Android 与服务端使用相同语义。
- 服务端在结果完整性校验前执行规范化。规范化后为空、同维度重复、仍残留货币符号或无法由最小安全规则确定时,整维不接受,并原子丢弃引用该维度的 SKU;color 被拒时同时丢弃 colorPrices。
- 服务端强制将任务收敛为 completed_partial,并在 missing 增加稳定值 spec_dimension_invalid:<dimensionKey>,例如 spec_dimension_invalid:size。客户端应把它展示为对应规格维度无法安全采集,不得视为完整档案,也不得自行猜测或补齐候选。
- #379 起 Android 在提交前处理规范化同名冲突:同一维度不同原文规范化后同名时只提交一个值,并在 missing 增加稳定值 `spec_dimension_conflict:<dimensionKey>`(任务为 completed_partial,不新增字段)。它表示该维度可能把规范化后同名的选项合并为一个,维度本身仍可用;残留货币符号或空值仍由 Android 整维清空并写 `spec_dimension_invalid:<dimensionKey>`。服务端只保存该 missing 值、不校验其种类;图搜自动关联白名单仍只含 reviewCount、salesText、shopName。
- 被拒维度不得以污染值覆盖商品已有档案;历史任务和既有商品数据不自动清洗,须在修复后的 Agent 上重新采集。
## Android:提交失败
+7 -2
View File
@@ -170,8 +170,13 @@ type PurchaseTask struct {
CancelReason *string `json:"cancelReason" gorm:"size:500"`
ErrorCode *string `json:"errorCode" gorm:"size:64;index"`
ErrorMessage *string `json:"errorMessage" gorm:"size:1000"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
// CreatedBy/CreatedSource 只在新建任务时写入一次(#382):admin=管理端账号,
// client_api=客户端密钥的授权人,agent=设备重试(CreatedBy 为空);空串表示
// 迁移前的历史任务,不回填。
CreatedBy *uint64 `json:"createdBy" gorm:"index"`
CreatedSource string `json:"createdSource" gorm:"size:16;not null;default:''"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
func (PurchaseTask) TableName() string { return "purchase_task" }
+161 -1
View File
@@ -4,6 +4,8 @@ import (
"context"
"encoding/json"
"errors"
"sort"
"strconv"
"strings"
"time"
"unicode"
@@ -29,6 +31,35 @@ type AdminListRequest struct {
// SYB products createdFrom/createdTo filter) over order_submitted_at.
OrderSubmittedFrom string
OrderSubmittedTo string
// DeviceID is a numeric device id or "unassigned" (#382).
DeviceID string
// CreatedBy is a numeric user id, "agent" or "unrecorded" (#382).
CreatedBy string
}
const (
adminFilterUnassigned = "unassigned"
adminFilterAgent = "agent"
adminFilterUnrecorded = "unrecorded"
)
// parseAdminIDFilter returns (id, keyword, error); at most one of id/keyword is
// set. Empty input means the filter is absent.
func parseAdminIDFilter(raw, label string, keywords ...string) (uint64, string, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return 0, "", nil
}
for _, keyword := range keywords {
if raw == keyword {
return 0, keyword, nil
}
}
id, err := strconv.ParseUint(raw, 10, 64)
if err != nil || id == 0 {
return 0, "", fail(CodeInvalidRequest, label+" 无效")
}
return id, "", nil
}
// adminOrderDayLocation matches the SYB products page's createdFrom/createdTo
@@ -78,6 +109,9 @@ type AdminTaskItem struct {
PDDProductID uint64 `json:"pddProductId"`
DeviceID *uint64 `json:"deviceId,omitempty"`
DeviceName string `json:"deviceName,omitempty"`
CreatedBy *uint64 `json:"createdBy,omitempty"`
CreatedByName string `json:"createdByName,omitempty"`
CreatedSource string `json:"createdSource"`
ShopeeItemIDSnapshot string `json:"shopeeItemIdSnapshot"`
ShopeeOrderNoSnapshot string `json:"shopeeOrderNoSnapshot"`
ShopeeTitleSnapshot string `json:"shopeeTitleSnapshot"`
@@ -219,6 +253,14 @@ func (s *Service) AdminList(ctx context.Context, req AdminListRequest) (AdminLis
if err != nil {
return AdminListResponse{}, err
}
deviceID, deviceKeyword, err := parseAdminIDFilter(req.DeviceID, "deviceId", adminFilterUnassigned)
if err != nil {
return AdminListResponse{}, err
}
createdByID, createdByKeyword, err := parseAdminIDFilter(req.CreatedBy, "createdBy", adminFilterAgent, adminFilterUnrecorded)
if err != nil {
return AdminListResponse{}, err
}
query := s.DB.WithContext(ctx).Model(&models.PurchaseTask{})
if submittedFrom != nil {
query = query.Where("order_submitted_at >= ?", *submittedFrom)
@@ -229,6 +271,18 @@ func (s *Service) AdminList(ctx context.Context, req AdminListRequest) (AdminLis
if req.TaskID > 0 {
query = query.Where("id = ?", req.TaskID)
}
if deviceID > 0 {
query = query.Where("device_id = ?", deviceID)
} else if deviceKeyword == adminFilterUnassigned {
query = query.Where("device_id IS NULL")
}
if createdByID > 0 {
query = query.Where("created_by = ?", createdByID)
} else if createdByKeyword == adminFilterAgent {
query = query.Where("created_source = ?", CreatedSourceAgent)
} else if createdByKeyword == adminFilterUnrecorded {
query = query.Where("created_source = ?", "")
}
if req.Status == adminStatusSYBWritebackSucceeded {
query = query.Where("id IN (?)", s.DB.WithContext(ctx).Model(&models.PurchaseOrderWriteback{}).Select("purchase_task_id").Where("status = ?", "succeeded"))
} else if req.Status != "" {
@@ -264,6 +318,10 @@ func (s *Service) AdminList(ctx context.Context, req AdminListRequest) (AdminLis
if err != nil {
return AdminListResponse{}, err
}
creatorNames, err := loadCreatorNames(s.DB.WithContext(ctx), tasks)
if err != nil {
return AdminListResponse{}, err
}
matching, err := s.matchingViews(ctx, tasks)
if err != nil {
return AdminListResponse{}, err
@@ -279,6 +337,7 @@ func (s *Service) AdminList(ctx context.Context, req AdminListRequest) (AdminLis
}
for _, task := range tasks {
item := adminTaskItem(task, deviceNames, s.retryQueryEligibility(ctx, task, true))
applyCreatorName(&item, creatorNames)
item.OrderWriteback = writebacks[task.ID]
item.Matching = matching[task.ID]
if task.ShopeeProductID != nil {
@@ -327,6 +386,10 @@ func (s *Service) AdminDetail(ctx context.Context, taskID uint64) (AdminDetailRe
if err != nil {
return AdminDetailResponse{}, err
}
creatorNames, err := loadCreatorNames(s.DB.WithContext(ctx), []models.PurchaseTask{task})
if err != nil {
return AdminDetailResponse{}, err
}
var attempts []models.PurchaseTaskAttempt
if err := s.DB.WithContext(ctx).Where("task_id = ?", task.ID).Order("attempt_number ASC, id ASC").Find(&attempts).Error; err != nil {
return AdminDetailResponse{}, internal(err)
@@ -345,6 +408,7 @@ func (s *Service) AdminDetail(ctx context.Context, taskID uint64) (AdminDetailRe
return AdminDetailResponse{}, matchErr
}
item := adminTaskItem(task, deviceNames, s.retryQueryEligibility(ctx, task, true))
applyCreatorName(&item, creatorNames)
writebacks, wbErr := s.OrderWritebackViews(ctx, []models.PurchaseTask{task})
if wbErr != nil {
return AdminDetailResponse{}, wbErr
@@ -384,11 +448,107 @@ func loadDeviceNames(db *gorm.DB, tasks []models.PurchaseTask) (map[uint64]strin
return names, nil
}
// userDisplayNames returns sys_user display names (nickname first, then
// username) for the given ids. Ids without a user row are absent from the map.
func userDisplayNames(db *gorm.DB, ids []uint64) (map[uint64]string, error) {
names := make(map[uint64]string, len(ids))
if len(ids) == 0 {
return names, nil
}
var rows []struct {
UserID uint64
Username string
NickName string
}
if err := db.Table("sys_user").Select("user_id, username, nick_name").Where("user_id IN ?", ids).Scan(&rows).Error; err != nil {
return nil, internal(err)
}
for _, row := range rows {
name := strings.TrimSpace(row.NickName)
if name == "" {
name = strings.TrimSpace(row.Username)
}
names[row.UserID] = name
}
return names, nil
}
func loadCreatorNames(db *gorm.DB, tasks []models.PurchaseTask) (map[uint64]string, error) {
ids := make([]uint64, 0, len(tasks))
seen := make(map[uint64]bool)
for _, task := range tasks {
if task.CreatedBy != nil && !seen[*task.CreatedBy] {
ids = append(ids, *task.CreatedBy)
seen[*task.CreatedBy] = true
}
}
return userDisplayNames(db, ids)
}
func applyCreatorName(item *AdminTaskItem, names map[uint64]string) {
if item.CreatedBy != nil {
item.CreatedByName = names[*item.CreatedBy]
}
}
// AdminFilterOption lists the devices and creators that actually appear on
// purchase tasks, so purchasers need no access to device or user management.
type AdminFilterOption struct {
ID uint64 `json:"id"`
Name string `json:"name"`
}
type AdminFilterOptionsResponse struct {
Devices []AdminFilterOption `json:"devices"`
Creators []AdminFilterOption `json:"creators"`
}
func (s *Service) AdminFilterOptions(ctx context.Context) (AdminFilterOptionsResponse, error) {
db := s.DB.WithContext(ctx)
var deviceIDs, creatorIDs []uint64
if err := db.Model(&models.PurchaseTask{}).Where("device_id IS NOT NULL").Distinct().Pluck("device_id", &deviceIDs).Error; err != nil {
return AdminFilterOptionsResponse{}, internal(err)
}
if err := db.Model(&models.PurchaseTask{}).Where("created_by IS NOT NULL").Distinct().Pluck("created_by", &creatorIDs).Error; err != nil {
return AdminFilterOptionsResponse{}, internal(err)
}
result := AdminFilterOptionsResponse{Devices: []AdminFilterOption{}, Creators: []AdminFilterOption{}}
if len(deviceIDs) > 0 {
var devices []models.AgentDevice
if err := db.Select("id, name").Where("id IN ?", deviceIDs).Find(&devices).Error; err != nil {
return AdminFilterOptionsResponse{}, internal(err)
}
for _, device := range devices {
result.Devices = append(result.Devices, AdminFilterOption{ID: device.ID, Name: device.Name})
}
}
names, err := userDisplayNames(db, creatorIDs)
if err != nil {
return AdminFilterOptionsResponse{}, err
}
for _, id := range creatorIDs {
name := names[id]
if name == "" {
name = "用户#" + strconv.FormatUint(id, 10)
}
result.Creators = append(result.Creators, AdminFilterOption{ID: id, Name: name})
}
for _, options := range [][]AdminFilterOption{result.Devices, result.Creators} {
sort.Slice(options, func(i, j int) bool {
if options[i].Name != options[j].Name {
return options[i].Name < options[j].Name
}
return options[i].ID < options[j].ID
})
}
return result, nil
}
func adminTaskItem(task models.PurchaseTask, deviceNames map[uint64]string, retry retryDecision) AdminTaskItem {
item := AdminTaskItem{
ID: task.ID, TaskType: task.TaskType, ExecutionMode: task.ExecutionMode, Status: task.Status,
SYBProductID: task.SYBProductID, ShopeeProductID: task.ShopeeProductID, PDDProductID: task.PDDProductID,
DeviceID: task.DeviceID, ShopeeItemIDSnapshot: task.ShopeeItemIDSnapshot, ShopeeOrderNoSnapshot: task.ShopeeOrderNoSnapshot,
DeviceID: task.DeviceID, CreatedBy: task.CreatedBy, CreatedSource: task.CreatedSource, ShopeeItemIDSnapshot: task.ShopeeItemIDSnapshot, ShopeeOrderNoSnapshot: task.ShopeeOrderNoSnapshot,
ShopeeTitleSnapshot: task.ShopeeTitleSnapshot, ShopeeShopNameSnapshot: task.ShopeeShopNameSnapshot,
PDDGoodsIDSnapshot: task.PDDGoodsIDSnapshot, PDDTitleSnapshot: task.PDDTitleSnapshot,
TargetColorSnapshot: task.TargetColorSnapshot, TargetSizeSnapshot: task.TargetSizeSnapshot,
+2 -1
View File
@@ -29,6 +29,7 @@ type BatchCreateRequest struct {
RequestID string `json:"requestId"`
SYBProductIDs []uint64 `json:"sybProductIds"`
DeviceID *uint64 `json:"deviceId,omitempty"`
creator creatorStamp
}
type BatchPreviewItem struct {
@@ -313,7 +314,7 @@ func (s *Service) BatchCreate(ctx context.Context, req BatchCreateRequest) (Batc
SYBProductID: &sybID, DeviceID: req.DeviceID,
ReferenceUnitPriceCent: preview.ReferenceUnitPriceCent,
MinUnitPriceCent: preview.MinUnitPriceCent, MaxUnitPriceCent: preview.MaxUnitPriceCent,
Currency: preview.Currency, RuleSnapshot: rawRule,
Currency: preview.Currency, RuleSnapshot: rawRule, creator: req.creator,
})
if createErr != nil {
preview.Eligible = false
@@ -0,0 +1,417 @@
package purchase
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"go-admin/app/goauto/models"
"go-admin/app/goauto/purchasecontract"
"go-admin/common/clientprincipal"
"github.com/gin-gonic/gin"
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
"github.com/google/uuid"
"gorm.io/gorm"
)
func ensureSysUserTable(t *testing.T, db *gorm.DB) {
t.Helper()
if err := db.Exec("CREATE TABLE IF NOT EXISTS sys_user (user_id integer primary key, username text, nick_name text)").Error; err != nil {
t.Fatal(err)
}
}
func addSysUser(t *testing.T, db *gorm.DB, id uint64, username, nick string) {
t.Helper()
ensureSysUserTable(t, db)
if err := db.Exec("INSERT INTO sys_user(user_id, username, nick_name) VALUES(?,?,?)", id, username, nick).Error; err != nil {
t.Fatal(err)
}
}
func stockRequest(f fixture) StockCreateRequest {
return StockCreateRequest{
RequestID: uuid.NewString(), ExecutionMode: models.PurchaseExecutionModeLive,
PDDProductID: f.pdd.ID, DeviceID: &f.device.ID, Color: "黑色", Size: "XL",
Quantity: 1, MinUnitPriceCent: 900, MaxUnitPriceCent: 1500,
}
}
func stockFixture(t *testing.T, db *gorm.DB, f fixture) {
t.Helper()
if err := db.Model(&models.PDDProduct{}).Where("id = ?", f.pdd.ID).Update("specs_json", `[{"name":"颜色","role":"color","values":[{"name":"黑色","selectable":true,"priceCent":1200}]},{"name":"尺码","role":"size","values":[{"name":"XL","selectable":true}]}]`).Error; err != nil {
t.Fatal(err)
}
}
func extraDevice(name string) models.AgentDevice {
return models.AgentDevice{Name: name, InstallID: uuid.NewString(), Manufacturer: "T", Model: "T", AndroidVersion: "15", AgentVersion: "1", PDDVersion: "7",
Status: "online", TokenDigest: uuid.NewString(), TokenIssuedAt: time.Now()}
}
func uptr(v uint64) *uint64 { return &v }
func TestCreatorFromContextMapsCallerToSourceAndUser(t *testing.T) {
gin.SetMode(gin.TestMode)
newCtx := func() *gin.Context {
c, _ := gin.CreateTestContext(httptest.NewRecorder())
c.Request = httptest.NewRequest(http.MethodPost, "/", nil)
return c
}
jwtCtx := newCtx()
jwtCtx.Set("JWT_PAYLOAD", jwt.MapClaims{"identity": float64(7), "rolekey": "purchaser"})
if got := creatorFromContext(jwtCtx); got.Source != CreatedSourceAdmin || got.By == nil || *got.By != 7 {
t.Fatalf("jwt creator: %+v", got)
}
clientCtx := newCtx()
clientprincipal.Set(clientCtx, clientprincipal.Identity{KeyID: 3, AuthorizedBy: 9})
if got := creatorFromContext(clientCtx); got.Source != CreatedSourceClientAPI || got.By == nil || *got.By != 9 {
t.Fatalf("client creator: %+v", got)
}
anonymous := newCtx()
if got := creatorFromContext(anonymous); got.Source != CreatedSourceAdmin || got.By != nil {
t.Fatalf("zero operator must be NULL: %+v", got)
}
}
func TestCreateEntriesStampCreatorOnceAndReplayKeepsIt(t *testing.T) {
db := testDB(t)
f := seed(t, db, liveCaps(), true)
stockFixture(t, db, f)
service := testService(db)
req := stockRequest(f)
req.creator = creatorStamp{By: uptr(11), Source: CreatedSourceAdmin}
first, replayed, err := service.CreateStock(context.Background(), req)
if err != nil || replayed {
t.Fatalf("create stock: %v replayed=%v", err, replayed)
}
if first.CreatedBy == nil || *first.CreatedBy != 11 || first.CreatedSource != CreatedSourceAdmin {
t.Fatalf("stock creator not stamped: %+v %v", first.CreatedBy, first.CreatedSource)
}
req.creator = creatorStamp{By: uptr(99), Source: CreatedSourceClientAPI}
replay, replayed, err := service.CreateStock(context.Background(), req)
if err != nil || !replayed || replay.ID != first.ID {
t.Fatalf("stock replay: %v", err)
}
var stored models.PurchaseTask
if err = db.First(&stored, first.ID).Error; err != nil {
t.Fatal(err)
}
if stored.CreatedBy == nil || *stored.CreatedBy != 11 || stored.CreatedSource != CreatedSourceAdmin {
t.Fatalf("replay rewrote creator: %+v %v", stored.CreatedBy, stored.CreatedSource)
}
single, err := createLive(t, service, f)
if err != nil {
t.Fatal(err)
}
if single.CreatedBy != nil || single.CreatedSource != "" {
t.Fatalf("unstamped create must stay unrecorded: %+v %q", single.CreatedBy, single.CreatedSource)
}
}
func TestBatchCreateStampsCreatorAndReplayDoesNotRewrite(t *testing.T) {
db := testDB(t)
f := seed(t, db, liveCaps(), true)
setCollectedPDDPrice(t, db, f.pdd.ID)
service := testService(db)
request := BatchCreateRequest{RequestID: uuid.NewString(), SYBProductIDs: []uint64{f.syb.ID}, DeviceID: &f.device.ID,
creator: creatorStamp{By: uptr(21), Source: CreatedSourceClientAPI}}
first, err := service.BatchCreate(context.Background(), request)
if err != nil || first.CreatedCount != 1 || first.Items[0].TaskID == nil {
t.Fatalf("batch create: %+v %v", first, err)
}
request.creator = creatorStamp{By: uptr(22), Source: CreatedSourceAdmin}
replay, err := service.BatchCreate(context.Background(), request)
if err != nil || replay.CreatedCount != 1 || !replay.Items[0].Replayed {
t.Fatalf("batch replay: %+v %v", replay, err)
}
var task models.PurchaseTask
if err = db.First(&task, *first.Items[0].TaskID).Error; err != nil {
t.Fatal(err)
}
if task.CreatedBy == nil || *task.CreatedBy != 21 || task.CreatedSource != CreatedSourceClientAPI {
t.Fatalf("batch creator: %+v %q", task.CreatedBy, task.CreatedSource)
}
}
func TestAgentRetryTaskIsAgentSourceWithoutCreator(t *testing.T) {
db := testDB(t)
f := seed(t, db, liveCaps(), true)
setCollectedPDDPrice(t, db, f.pdd.ID)
service := testService(db)
failed := failedLiveTask(t, db, service, f)
if err := db.Model(&models.SYBProduct{}).Where("id = ?", f.syb.ID).Updates(map[string]any{"target_color": "当前黑色", "target_size": "均碼"}).Error; err != nil {
t.Fatal(err)
}
result, err := service.AgentRetry(context.Background(), failed.ID, AgentRetryRequest{RequestID: uuid.NewString()}, f.token)
if err != nil {
t.Fatal(err)
}
var created, source models.PurchaseTask
if err = db.First(&created, result.TaskID).Error; err != nil {
t.Fatal(err)
}
if created.CreatedBy != nil || created.CreatedSource != CreatedSourceAgent {
t.Fatalf("agent retry creator: %+v %q", created.CreatedBy, created.CreatedSource)
}
if err = db.First(&source, failed.ID).Error; err != nil || source.CreatedSource != "" {
t.Fatalf("source task must be untouched: %q %v", source.CreatedSource, err)
}
}
func callAdminHandler(t *testing.T, db *gorm.DB, handler func(Handler, *gin.Context), method, target, body string, setup func(*gin.Context)) *httptest.ResponseRecorder {
t.Helper()
gin.SetMode(gin.TestMode)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest(method, target, strings.NewReader(body))
c.Request.Header.Set("Content-Type", "application/json")
setup(c)
handler(Handler{DB: db}, c)
return w
}
func TestAdminCreateStockHandlerStampsJWTAndClientCreators(t *testing.T) {
db := testDB(t)
f := seed(t, db, liveCaps(), true)
stockFixture(t, db, f)
body := func() string {
raw, _ := json.Marshal(stockRequest(f))
return string(raw)
}
for _, tc := range []struct {
name string
setup func(*gin.Context)
by uint64
source string
}{
{"admin", func(c *gin.Context) {
c.Set("JWT_PAYLOAD", jwt.MapClaims{"identity": float64(5), "rolekey": "purchaser"})
}, 5, CreatedSourceAdmin},
{"client", func(c *gin.Context) {
clientprincipal.Set(c, clientprincipal.Identity{KeyID: 1, AuthorizedBy: 8})
c.Set("JWT_PAYLOAD", jwt.MapClaims{"rolekey": "admin"})
}, 8, CreatedSourceClientAPI},
} {
t.Run(tc.name, func(t *testing.T) {
w := callAdminHandler(t, db, Handler.AdminCreateStock, http.MethodPost, "/api/admin/v1/purchase-tasks/stock", body(), tc.setup)
var out struct {
Data models.PurchaseTask `json:"data"`
}
if err := json.Unmarshal(w.Body.Bytes(), &out); err != nil || w.Code != http.StatusOK || out.Data.ID == 0 {
t.Fatalf("%d %s", w.Code, w.Body.String())
}
var stored models.PurchaseTask
if err := db.First(&stored, out.Data.ID).Error; err != nil {
t.Fatal(err)
}
if stored.CreatedBy == nil || *stored.CreatedBy != tc.by || stored.CreatedSource != tc.source {
t.Fatalf("stored creator: %+v %q", stored.CreatedBy, stored.CreatedSource)
}
})
}
}
// seedCreatorRows inserts tasks with distinct devices/creators/sources.
func seedCreatorRows(t *testing.T, db *gorm.DB, f fixture) (devB models.AgentDevice, ids map[string]uint64) {
t.Helper()
s := testService(db)
base := createAdminQueryTask(t, s, f)
devB = extraDevice("Pixel")
if err := db.Session(&gorm.Session{SkipHooks: true}).Create(&devB).Error; err != nil {
t.Fatal(err)
}
ids = map[string]uint64{}
add := func(key string, device *uint64, by *uint64, source string, status string) {
row := base
row.ID = 0
row.CreateRequestID = uuid.NewString()
row.DeviceID, row.CreatedBy, row.CreatedSource, row.Status = device, by, source, status
if err := db.Session(&gorm.Session{SkipHooks: true}).Create(&row).Error; err != nil {
t.Fatal(err)
}
ids[key] = row.ID
}
add("adminA_devA", &f.device.ID, uptr(1), CreatedSourceAdmin, models.PurchaseTaskStatusFailed)
add("adminA_devB", &devB.ID, uptr(1), CreatedSourceAdmin, models.PurchaseTaskStatusFailed)
add("adminB_unassigned", nil, uptr(2), CreatedSourceAdmin, models.PurchaseTaskStatusCancelled)
add("agent_devA", &f.device.ID, nil, CreatedSourceAgent, models.PurchaseTaskStatusFailed)
add("legacy_devB", &devB.ID, nil, "", models.PurchaseTaskStatusFailed)
if err := db.Session(&gorm.Session{SkipHooks: true}).Model(&models.PurchaseTask{}).Where("id = ?", base.ID).Update("created_source", "").Error; err != nil {
t.Fatal(err)
}
ids["base"] = base.ID
return devB, ids
}
func listIDs(t *testing.T, s *Service, req AdminListRequest) map[uint64]bool {
t.Helper()
out, err := s.AdminList(context.Background(), req)
if err != nil {
t.Fatal(err)
}
got := map[uint64]bool{}
for _, item := range out.Items {
got[item.ID] = true
}
return got
}
func sameIDs(t *testing.T, got map[uint64]bool, ids map[string]uint64, keys ...string) {
t.Helper()
if len(got) != len(keys) {
t.Fatalf("got %v want keys %v (%v)", got, keys, ids)
}
for _, key := range keys {
if !got[ids[key]] {
t.Fatalf("missing %s in %v (%v)", key, got, ids)
}
}
}
func TestAdminListDeviceAndCreatedByFilters(t *testing.T) {
db := testDB(t)
f := seed(t, db, []string{purchasecontract.CapabilityPurchaseRehearsalV1}, true)
addSysUser(t, db, 1, "alice", "爱丽丝")
addSysUser(t, db, 2, "bob", "")
devB, ids := seedCreatorRows(t, db, f)
s := testService(db)
devA := f.device.ID
sameIDs(t, listIDs(t, s, AdminListRequest{DeviceID: itoa(devA)}), ids, "base", "adminA_devA", "agent_devA")
sameIDs(t, listIDs(t, s, AdminListRequest{DeviceID: itoa(devB.ID)}), ids, "adminA_devB", "legacy_devB")
sameIDs(t, listIDs(t, s, AdminListRequest{DeviceID: "unassigned"}), ids, "adminB_unassigned")
sameIDs(t, listIDs(t, s, AdminListRequest{CreatedBy: "1"}), ids, "adminA_devA", "adminA_devB")
sameIDs(t, listIDs(t, s, AdminListRequest{CreatedBy: "agent"}), ids, "agent_devA")
sameIDs(t, listIDs(t, s, AdminListRequest{CreatedBy: "unrecorded"}), ids, "base", "legacy_devB")
// Combined with each other and with existing filters.
sameIDs(t, listIDs(t, s, AdminListRequest{DeviceID: itoa(devB.ID), CreatedBy: "1"}), ids, "adminA_devB")
sameIDs(t, listIDs(t, s, AdminListRequest{DeviceID: itoa(devA), CreatedBy: "unrecorded"}), ids, "base")
sameIDs(t, listIDs(t, s, AdminListRequest{CreatedBy: "2", Status: models.PurchaseTaskStatusCancelled, DeviceID: "unassigned"}), ids, "adminB_unassigned")
sameIDs(t, listIDs(t, s, AdminListRequest{CreatedBy: "1", Status: models.PurchaseTaskStatusCancelled}), ids)
for _, bad := range []AdminListRequest{
{DeviceID: "abc"}, {DeviceID: "0"}, {DeviceID: "-1"}, {DeviceID: "agent"},
{CreatedBy: "abc"}, {CreatedBy: "0"}, {CreatedBy: "unassigned"},
} {
if _, err := s.AdminList(context.Background(), bad); code(err) != CodeInvalidRequest {
t.Fatalf("%+v accepted: %v", bad, err)
}
}
out, err := s.AdminList(context.Background(), AdminListRequest{PageSize: 100})
if err != nil {
t.Fatal(err)
}
for _, item := range out.Items {
switch item.ID {
case ids["adminA_devA"]:
if item.CreatedBy == nil || *item.CreatedBy != 1 || item.CreatedByName != "爱丽丝" || item.CreatedSource != CreatedSourceAdmin {
t.Fatalf("nick name item: %+v", item)
}
case ids["adminB_unassigned"]:
if item.CreatedByName != "bob" {
t.Fatalf("username fallback: %q", item.CreatedByName)
}
case ids["agent_devA"]:
if item.CreatedBy != nil || item.CreatedSource != CreatedSourceAgent || item.CreatedByName != "" {
t.Fatalf("agent item: %+v", item)
}
case ids["legacy_devB"]:
if item.CreatedSource != "" {
t.Fatalf("legacy item: %+v", item)
}
}
}
detail, err := s.AdminDetail(context.Background(), ids["adminA_devA"])
if err != nil || detail.Task.CreatedByName != "爱丽丝" || detail.Task.CreatedSource != CreatedSourceAdmin {
t.Fatalf("detail creator: %+v %v", detail.Task, err)
}
}
func itoa(v uint64) string {
raw, _ := json.Marshal(v)
return string(raw)
}
func TestAdminListHandlerRejectsInvalidDeviceAndCreatedBy(t *testing.T) {
db := testDB(t)
for _, query := range []string{"deviceId=abc", "createdBy=abc", "deviceId=0"} {
w := callAdminHandler(t, db, Handler.AdminList, http.MethodGet, "/api/admin/v1/purchase-tasks?"+query, "", func(c *gin.Context) {
c.Set("JWT_PAYLOAD", jwt.MapClaims{"rolekey": "purchaser"})
})
if w.Code != http.StatusUnprocessableEntity || !strings.Contains(w.Body.String(), CodeInvalidRequest) {
t.Fatalf("%s: %d %s", query, w.Code, w.Body.String())
}
}
}
func TestAdminFilterOptionsListsOnlyUsedDevicesAndCreatorsSorted(t *testing.T) {
db := testDB(t)
f := seed(t, db, []string{purchasecontract.CapabilityPurchaseRehearsalV1}, true)
addSysUser(t, db, 1, "zed", "")
addSysUser(t, db, 2, "amy", "Amy")
addSysUser(t, db, 3, "unused", "Unused")
devB, ids := seedCreatorRows(t, db, f)
_ = ids
unused := extraDevice("Unused")
if err := db.Session(&gorm.Session{SkipHooks: true}).Create(&unused).Error; err != nil {
t.Fatal(err)
}
out, err := testService(db).AdminFilterOptions(context.Background())
if err != nil {
t.Fatal(err)
}
if len(out.Devices) != 2 || out.Devices[0].Name != "Pixel" || out.Devices[0].ID != devB.ID || out.Devices[1].ID != f.device.ID {
t.Fatalf("devices: %+v", out.Devices)
}
if len(out.Creators) != 2 || out.Creators[0].Name != "Amy" || out.Creators[0].ID != 2 || out.Creators[1].Name != "zed" {
t.Fatalf("creators: %+v", out.Creators)
}
}
func TestAdminFilterOptionsEmptyReturnsEmptyArrays(t *testing.T) {
empty, err := testService(testDB(t)).AdminFilterOptions(context.Background())
if err != nil || empty.Devices == nil || empty.Creators == nil || len(empty.Devices)+len(empty.Creators) != 0 {
t.Fatalf("empty options must be empty arrays: %+v %v", empty, err)
}
}
func TestAdminFilterOptionsHandlerAccess(t *testing.T) {
db := testDB(t)
for _, role := range []string{"admin", "purchaser", "after_sales", "other", ""} {
w := callAdminHandler(t, db, Handler.AdminFilterOptions, http.MethodGet, "/api/admin/v1/purchase-tasks/filter-options", "", func(c *gin.Context) {
c.Set("JWT_PAYLOAD", jwt.MapClaims{"rolekey": role})
})
want := role == "admin" || role == "purchaser" || role == "after_sales"
if want && w.Code != http.StatusOK {
t.Fatalf("role %q denied: %d %s", role, w.Code, w.Body.String())
}
if !want && w.Code != http.StatusForbidden {
t.Fatalf("role %q allowed: %d", role, w.Code)
}
}
}
func TestFilterOptionsRouteDoesNotCollideWithTaskID(t *testing.T) {
gin.SetMode(gin.TestMode)
engine := gin.New()
group := engine.Group("/api/admin/v1/purchase-tasks")
group.GET("", func(c *gin.Context) { c.String(200, "list") })
group.GET("/filter-options", func(c *gin.Context) { c.String(200, "options") })
group.GET("/:taskId", func(c *gin.Context) { c.String(200, "detail") })
for path, want := range map[string]string{"/api/admin/v1/purchase-tasks/filter-options": "options", "/api/admin/v1/purchase-tasks/12": "detail"} {
w := httptest.NewRecorder()
engine.ServeHTTP(w, httptest.NewRequest(http.MethodGet, path, nil))
if w.Body.String() != want {
t.Fatalf("%s routed to %q", path, w.Body.String())
}
}
}
+35
View File
@@ -58,6 +58,8 @@ func (h Handler) AdminList(c *gin.Context) {
PDDOrderNo: strings.TrimSpace(c.Query("pddOrderNo")),
OrderSubmittedFrom: c.Query("orderSubmittedFrom"),
OrderSubmittedTo: c.Query("orderSubmittedTo"),
DeviceID: c.Query("deviceId"),
CreatedBy: c.Query("createdBy"),
})
if err != nil {
writeError(c, err)
@@ -66,6 +68,22 @@ func (h Handler) AdminList(c *gin.Context) {
writeAdminData(c, response)
}
func (h Handler) AdminFilterOptions(c *gin.Context) {
if !allowedOperator(c) {
return
}
service, ok := h.service(c)
if !ok {
return
}
response, err := service.AdminFilterOptions(c.Request.Context())
if err != nil {
writeError(c, err)
return
}
writeAdminData(c, response)
}
func (h Handler) AdminCreateStock(c *gin.Context) {
if !allowedOperator(c) {
return
@@ -78,6 +96,7 @@ func (h Handler) AdminCreateStock(c *gin.Context) {
if !ok {
return
}
req.creator = creatorFromContext(c)
record, replayed, err := service.CreateStock(c.Request.Context(), req)
if err != nil {
writeError(c, err)
@@ -118,6 +137,7 @@ func (h Handler) AdminCreate(c *gin.Context) {
if !ok {
return
}
req.creator = creatorFromContext(c)
record, replayed, err := service.Create(c.Request.Context(), req)
if err != nil {
writeError(c, err)
@@ -178,6 +198,7 @@ func (h Handler) AdminBatchCreate(c *gin.Context) {
if !ok {
return
}
req.creator = creatorFromContext(c)
response, err := service.BatchCreate(c.Request.Context(), req)
if err != nil {
writeError(c, err)
@@ -550,6 +571,20 @@ func writeError(c *gin.Context, err error) {
}
c.JSON(status, gin.H{"code": code, "message": msg, "retryable": retryable})
}
// creatorFromContext maps the authenticated caller to the #382 creator stamp.
// A zero operator id is stored as NULL rather than a fake user.
func creatorFromContext(c *gin.Context) creatorStamp {
stamp := creatorStamp{Source: CreatedSourceAdmin}
if _, ok := clientprincipal.Get(c); ok {
stamp.Source = CreatedSourceClientAPI
}
if id := operatorID(c); id > 0 {
stamp.By = &id
}
return stamp
}
func operatorID(c *gin.Context) uint64 {
if id, ok := clientprincipal.Get(c); ok {
return id.AuthorizedBy
+1
View File
@@ -196,6 +196,7 @@ func (s *Service) batchRetryCreate(ctx context.Context, req BatchRetryRequest) (
MaxUnitPriceCent: candidate.preview.MaxUnitPriceCent,
Currency: candidate.preview.Currency,
RuleSnapshot: rawRule,
creator: creatorStamp{Source: CreatedSourceAgent},
})
item := response.Items[candidate.resultIndex]
if createErr != nil {
+1
View File
@@ -29,6 +29,7 @@ func InitRouter(engine *gin.Engine, auth *jwt.GinJWTMiddleware) {
agent.POST("/:taskId/attempts/:attemptId/failure-snapshot", h.UploadFailureSnapshot)
admin := engine.Group("/api/admin/v1/purchase-tasks").Use(auth.MiddlewareFunc()).Use(middleware.AuthCheckRole())
admin.GET("", h.AdminList)
admin.GET("/filter-options", h.AdminFilterOptions)
admin.POST("/batch-preview", h.AdminBatchPreview)
admin.POST("/batch-spec-match", h.AdminBatchSpecMatch)
admin.POST("/batch", h.AdminBatchCreate)
+2 -1
View File
@@ -346,6 +346,7 @@ func (s *Service) create(ctx context.Context, req CreateRequest) (models.Purchas
ExecutionMode: req.ExecutionMode, Status: models.PurchaseTaskStatusPending, ShopeeItemIDSnapshot: shopee.ShopeeItemID, ShopeeOrderNoSnapshot: shopeeOrderNoSnapshot, ShopeeTitleSnapshot: shopee.Title, ShopeeShopNameSnapshot: shopee.ShopName,
PDDURLSnapshot: pdd.URL, PDDGoodsIDSnapshot: pdd.GoodsID, PDDTitleSnapshot: pdd.Title, TargetColorSnapshot: targetColor, TargetSizeSnapshot: targetSize, MappedColorSnapshot: mappedColor, MappedSizeSnapshot: mappedSize, SpecSource: specSource, SpecDecisionSnapshot: decisionSnapshot,
Quantity: quantity, ReferenceUnitPriceCent: ref, MinUnitPriceCent: minPrice, MaxUnitPriceCent: maxPrice, Currency: currency, RuleType: rule.RuleType, RuleSchemaVersion: rule.SchemaVersion, RequiredCapabilitiesJSON: string(required), RuleSnapshot: string(req.RuleSnapshot), CreateRequestID: req.RequestID,
CreatedBy: req.creator.By, CreatedSource: req.creator.Source,
PaymentReviewStatus: models.PurchasePaymentReviewPending, LogisticsStatus: models.PurchaseLogisticsStatusPending, WritebackStatus: models.PurchaseWritebackStatusNotSelected}
if err := tx.Create(&out).Error; err != nil {
if isDuplicate(err) {
@@ -374,7 +375,7 @@ func (s *Service) CreateStock(ctx context.Context, req StockCreateRequest) (mode
DeviceID: req.DeviceID, PDDAccountID: req.PDDAccountID,
TargetColor: req.Color, TargetSize: req.Size, Quantity: req.Quantity,
MinUnitPriceCent: req.MinUnitPriceCent, MaxUnitPriceCent: req.MaxUnitPriceCent,
Currency: "CNY", RuleSnapshot: rule,
Currency: "CNY", RuleSnapshot: rule, creator: req.creator,
})
}
+16
View File
@@ -92,8 +92,23 @@ type CreateRequest struct {
// immutable source-task value instead of accepting a client-supplied
// identity snapshot.
shopeeOrderNoSnapshotOverride string
// creator is server-only (#382): written once when a task row is created.
creator creatorStamp
}
// creatorStamp records who created a purchase task. It is never decoded from
// request bodies; the handler or the internal caller fills it.
type creatorStamp struct {
By *uint64
Source string
}
const (
CreatedSourceAdmin = "admin"
CreatedSourceClientAPI = "client_api"
CreatedSourceAgent = "agent"
)
// StockCreateRequest is intentionally detached from SYB/Shopee identities.
// The server supplies the reviewed purchase rule and derives the immutable
// reference price from the selected color in the collected PDD archive.
@@ -108,6 +123,7 @@ type StockCreateRequest struct {
Quantity int64 `json:"quantity"`
MinUnitPriceCent int64 `json:"minUnitPriceCent"`
MaxUnitPriceCent int64 `json:"maxUnitPriceCent"`
creator creatorStamp
}
type MatchingView struct {
@@ -0,0 +1,37 @@
package version_local
import (
"runtime"
"go-admin/app/goauto/models"
"go-admin/cmd/migrate/migration"
common "go-admin/common/models"
"gorm.io/gorm"
)
func init() {
_, file, _, _ := runtime.Caller(0)
migration.Migrate.SetVersion(migration.GetFilename(file), MigratePurchaseTaskCreator)
}
// MigratePurchaseTaskCreator only adds the #382 creator columns to
// purchase_task: nullable created_by (indexed) and created_source defaulting
// to the empty string. Existing rows are not rewritten or backfilled. Each step is guarded so
// a partial run (MySQL DDL commits implicitly) can be re-run safely.
func MigratePurchaseTaskCreator(db *gorm.DB, version string) error {
m := db.Migrator()
for _, column := range []string{"CreatedBy", "CreatedSource"} {
if !m.HasColumn(&models.PurchaseTask{}, column) {
if err := m.AddColumn(&models.PurchaseTask{}, column); err != nil {
return err
}
}
}
if !m.HasIndex(&models.PurchaseTask{}, "idx_purchase_task_created_by") {
if err := m.CreateIndex(&models.PurchaseTask{}, "CreatedBy"); err != nil {
return err
}
}
return db.Where("version = ?", version).FirstOrCreate(&common.Migration{Version: version}).Error
}
@@ -0,0 +1,59 @@
package version_local
import (
"testing"
"go-admin/app/goauto/models"
common "go-admin/common/models"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
func TestMigratePurchaseTaskCreatorAddsColumnsKeepsLegacyRowsAndIsIdempotent(t *testing.T) {
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
if err != nil {
t.Fatal(err)
}
conn, _ := db.DB()
defer conn.Close()
if err = db.AutoMigrate(&common.Migration{}); err != nil {
t.Fatal(err)
}
if err = db.Exec("CREATE TABLE purchase_task (id integer primary key, status text NOT NULL)").Error; err != nil {
t.Fatal(err)
}
if err = db.Exec("INSERT INTO purchase_task(id,status) VALUES(1,'failed')").Error; err != nil {
t.Fatal(err)
}
for i := 0; i < 2; i++ {
if err = MigratePurchaseTaskCreator(db, "test_382"); err != nil {
t.Fatal(err)
}
}
for _, column := range []string{"CreatedBy", "CreatedSource"} {
if !db.Migrator().HasColumn(&models.PurchaseTask{}, column) {
t.Fatalf("missing column %s", column)
}
}
if !db.Migrator().HasIndex(&models.PurchaseTask{}, "idx_purchase_task_created_by") {
t.Fatal("missing created_by index")
}
var row struct {
Status string
CreatedBy *uint64
CreatedSource string
}
if err = db.Table("purchase_task").Select("status, created_by, created_source").Where("id = 1").Scan(&row).Error; err != nil {
t.Fatal(err)
}
if row.Status != "failed" || row.CreatedBy != nil || row.CreatedSource != "" {
t.Fatalf("legacy row changed: %+v", row)
}
var count int64
db.Model(&common.Migration{}).Where("version = ?", "test_382").Count(&count)
if count != 1 {
t.Fatal("version not idempotent")
}
}
+1
View File
@@ -19,6 +19,7 @@ export function listPurchaseTasks(params) {
}
})
}
export function getPurchaseTaskFilterOptions() { return request({ url: '/api/admin/v1/purchase-tasks/filter-options', method: 'get' }) }
export function getPurchaseTask(taskId) { return request({ url: `/api/admin/v1/purchase-tasks/${taskId}`, method: 'get' }) }
export function previewPurchaseTasks(data, options = {}) { return request({ url: '/api/admin/v1/purchase-tasks/batch-preview', method: 'post', data, ...options }) }
export function matchPurchaseSpecsBatch(data) { return request({ url: '/api/admin/v1/purchase-tasks/batch-spec-match', method: 'post', data, timeout: 610000 }) }
+19 -3
View File
@@ -14,6 +14,8 @@
</div>
</el-form-item>
<el-form-item label="状态"><el-select v-model="query.status" clearable placeholder="全部状态" style="width:160px"><el-option v-for="item in statuses" :key="item.value" :label="item.label" :value="item.value" /></el-select></el-form-item>
<el-form-item label="设备"><el-select v-model="query.deviceId" clearable filterable placeholder="全部设备" style="width:170px"><el-option label="未分派" value="unassigned" /><el-option v-for="item in filterOptions.devices" :key="item.id" :label="item.name" :value="String(item.id)" /></el-select></el-form-item>
<el-form-item label="创建人"><el-select v-model="query.createdBy" clearable filterable placeholder="全部创建人" style="width:170px"><el-option label="Agent 发起" value="agent" /><el-option label="未记录" value="unrecorded" /><el-option v-for="item in filterOptions.creators" :key="item.id" :label="item.name" :value="String(item.id)" /></el-select></el-form-item>
<el-form-item label="SYB 商品"><el-input v-model="query.sybProductId" class="syb-input" clearable placeholder="商品 ID" @keyup.enter="search" /></el-form-item>
<el-form-item label="PDD 订单号"><el-input v-model="query.pddOrderNo" class="order-input" clearable placeholder="订单号" @keyup.enter="search" /></el-form-item>
<el-form-item label="下单日期"><el-date-picker v-model="query.orderSubmittedRange" type="daterange" value-format="YYYY-MM-DD" range-separator="至" start-placeholder="开始日期" end-placeholder="结束日期" clearable style="width:260px" /></el-form-item>
@@ -30,6 +32,7 @@
<el-table-column label="PDD 商品" min-width="190"><template #default="{ row }"><div class="primary ellipsis">{{ row.pddTitleSnapshot || '标题未记录' }}</div><div class="muted">goods_id {{ row.pddGoodsIdSnapshot || '—' }}</div></template></el-table-column>
<el-table-column label="采购规格 / 数量" min-width="190"><template #default="{ row }"><div>{{ specText(row.mappedColorSnapshot, row.mappedSizeSnapshot) }}</div><div class="muted">目标:{{ specText(row.targetColorSnapshot, row.targetSizeSnapshot) }} · {{ row.quantity }} 件</div></template></el-table-column>
<el-table-column label="设备" min-width="140"><template #default="{ row }"><div>{{ row.deviceName || '尚未分派' }}</div><div class="muted">账号:{{ row.pddAccountRefSnapshot || '不强制记录' }}</div></template></el-table-column>
<el-table-column label="创建人" min-width="110"><template #default="{ row }">{{ creatorLabel(row) }}</template></el-table-column>
<el-table-column label="状态" width="150" align="center"><template #default="{ row }"><el-tag :type="statusType(row.status)">{{ statusLabel(row.status) }}</el-tag><el-tooltip v-if="row.status === 'failed' && !row.retryable" :content="row.retryDisabledReason || '当前任务不能重试'" placement="top"><span class="retry-disabled" tabindex="0">不可重试</span></el-tooltip></template></el-table-column>
<el-table-column label="规格匹配" min-width="150"><template #default="{ row }"><el-tag :type="matchingType(row.matching?.status)" effect="plain">{{ matchingLabel(row.matching?.status) }}</el-tag><div v-if="row.matching && !row.matching.executable" class="muted">{{ row.matching.reason || '完成前不可执行' }}</div></template></el-table-column>
<el-table-column label="订单 / 支付" min-width="170"><template #default="{ row }"><div>{{ row.pddOrderNo || '尚未取得订单号' }}</div><div v-if="row.pddOrderNo" class="muted">{{ orderInformationComplete(row) ? '信息完整' : '订单信息待补全' }}</div><div class="muted">{{ paymentLabel(row.paymentReviewStatus) }}</div></template></el-table-column>
@@ -56,6 +59,7 @@
<el-descriptions-item label="数量">{{ detail.task.quantity }} 件</el-descriptions-item><el-descriptions-item label="映射来源">{{ specSourceLabel(detail.task.specSource) }} <el-button v-if="detail.task.shopeeProductId" type="primary" link @click="openShopeeMapping">查看映射</el-button></el-descriptions-item>
<el-descriptions-item label="允许单价">{{ priceText(detail.task.minUnitPriceCent, detail.task.currency) }} ~ {{ priceText(detail.task.maxUnitPriceCent, detail.task.currency) }}</el-descriptions-item><el-descriptions-item label="参考单价">{{ priceText(detail.task.referenceUnitPriceCent, detail.task.currency) }}</el-descriptions-item>
<el-descriptions-item label="设备">{{ detail.task.deviceName || '尚未分派' }}</el-descriptions-item><el-descriptions-item label="地址后缀">{{ detail.task.addressSuffix || '未生成' }}</el-descriptions-item>
<el-descriptions-item label="创建人">{{ creatorLabel(detail.task) }}</el-descriptions-item><el-descriptions-item label="创建来源">{{ createdSourceLabel(detail.task.createdSource) }}</el-descriptions-item>
</el-descriptions></section>
<section v-if="detail.matching"><h3>规格匹配</h3>
@@ -153,7 +157,7 @@
import { parsePurchaseOrderFilter } from '@/utils/purchase-order-filter'
import { ElMessage, ElMessageBox } from 'element-plus'
import { RefreshLeft, Search } from '@element-plus/icons-vue'
import { authorizeRepurchase, cancelPurchaseTask, choosePurchaseMatching, getPurchaseTask, listPurchaseTasks, requeuePurchaseMatching, resolveUnknownPurchaseTask, retryPurchaseTasksBatch, reviewPurchasePayment, selectPurchaseWriteback, writebackPurchaseOrderNumbers } from '@/api/goauto/purchase-tasks'
import { authorizeRepurchase, cancelPurchaseTask, choosePurchaseMatching, getPurchaseTask, getPurchaseTaskFilterOptions, listPurchaseTasks, requeuePurchaseMatching, resolveUnknownPurchaseTask, retryPurchaseTasksBatch, reviewPurchasePayment, selectPurchaseWriteback, writebackPurchaseOrderNumbers } from '@/api/goauto/purchase-tasks'
import { createRequestId } from '@/utils/request-id'
import { saveExcel } from '@/utils/save-excel'
import { EXPORT_MAX_ROWS, PURCHASE_EXPORT_HEADER, PURCHASE_EXPORT_NUMBER_COLS, exportFailureMessage, purchaseExportRow, runListExport } from '@/utils/list-export'
@@ -175,7 +179,8 @@ export default {
exporting: false, exportProgress: { done: 0, steps: 0 },
detailSession: 0, snapshots: { items: {}, loading: false, error: false },
orderWriteback: { open: false, saving: false, items: [] },
query: { page: 1, pageSize: 20, taskId: '', taskType: '', shopeeOrderNo: '', status: '', executionMode: 'live', sybProductId: '', pddOrderNo: '', orderSubmittedRange: [] },
query: { page: 1, pageSize: 20, taskId: '', taskType: '', shopeeOrderNo: '', status: '', executionMode: 'live', sybProductId: '', pddOrderNo: '', orderSubmittedRange: [], deviceId: '', createdBy: '' },
filterOptions: { devices: [], creators: [] },
detail: { open: false, loading: false, task: null, attempts: [], matching: null },
matchingAction: { saving: false }, matchingDialog: { open: false, saving: false, color: '', size: '' },
retrySelection: [],
@@ -214,6 +219,7 @@ export default {
beforeUnmount() { this.clearSnapshots() },
deactivated() { this.detail.open = false; this.clearSnapshots() },
created() {
this.loadFilterOptions()
const taskId = Number(this.$route.query.taskId)
if (Number.isInteger(taskId) && taskId > 0) {
this.query.taskId = String(taskId)
@@ -241,7 +247,17 @@ export default {
return params
},
search() { this.query.page = 1; this.load() },
reset() { this.query = { page: 1, pageSize: 20, taskId: '', taskType: '', shopeeOrderNo: '', status: '', executionMode: 'live', sybProductId: '', pddOrderNo: '', orderSubmittedRange: [] }; this.load() },
reset() { this.query = { page: 1, pageSize: 20, taskId: '', taskType: '', shopeeOrderNo: '', status: '', executionMode: 'live', sybProductId: '', pddOrderNo: '', orderSubmittedRange: [], deviceId: '', createdBy: '' }; this.load() },
// #382: options only list devices/creators that appear on purchase tasks; failure leaves the static options usable.
async loadFilterOptions() {
try { const r = await getPurchaseTaskFilterOptions(); this.filterOptions = { devices: r.data.devices || [], creators: r.data.creators || [] } } catch (error) { this.filterOptions = { devices: [], creators: [] } }
},
createdSourceLabel(source) { return { admin: '管理端', client_api: '客户端接口', agent: 'Agent' }[source] || '未记录' },
creatorLabel(task) {
if (task.createdSource === 'agent') return 'Agent'
if (!task.createdSource) return '未记录'
return task.createdByName || (task.createdBy ? `用户 #${task.createdBy}` : '未记录')
},
// #372: export every page of the filters as they are at click time (params are copied once).
async exportExcel() {
if (this.exporting || this.orderFilter.error) return