diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 1c5a5a3..4223099 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -11,8 +11,8 @@ android { applicationId = "cn.ilapage.goauto.agent" minSdk = 23 targetSdk = 34 - versionCode = 49 - versionName = "0.9.36" + versionCode = 50 + versionName = "0.9.37" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" diff --git a/android/app/src/main/java/cn/ilapage/goauto/agent/network/AgentApiClient.kt b/android/app/src/main/java/cn/ilapage/goauto/agent/network/AgentApiClient.kt index 13e9e1c..f14a4c5 100644 --- a/android/app/src/main/java/cn/ilapage/goauto/agent/network/AgentApiClient.kt +++ b/android/app/src/main/java/cn/ilapage/goauto/agent/network/AgentApiClient.kt @@ -111,6 +111,7 @@ data class CollectionHistoryItem( data class HistoryPage(val items: List, val total: Long, val page: Int, val pageSize: Int) data class HistoryDimension(val key: String, val name: String, val values: List) data class HistoryColorPrice(val color: String, val priceCent: Long) +data class HistoryColorImage(val color: String, val imagePath: String, val width: Int, val height: Int) data class HistorySku(val specs: Map, val priceCent: Long, val available: Boolean, val complete: Boolean) data class CollectionHistoryDetail( val task: CollectionHistoryItem, @@ -120,6 +121,7 @@ data class CollectionHistoryDetail( val reviewCount: Long?, val dimensions: List, val colorPrices: List, + val colorImages: List, val skus: List, val missing: List, val replacementEligible: Boolean, @@ -455,6 +457,7 @@ class AgentApiClient(private val serverUrl: String) { 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")) }, + colorImages = data.historyColorImages(), skus = data.getJSONArray("skus").objects { item -> val specsJson = item.getJSONObject("specs") val specs = specsJson.keys().asSequence().associateWith(specsJson::getString) @@ -633,3 +636,6 @@ private fun JSONObject.nullableString(key: String): String? = if (!has(key) || i private fun JSONObject.nullableLong(key: String): Long? = if (!has(key) || isNull(key)) null else getLong(key) internal fun JSONObject.stringArrayOrEmpty(key: String): List = optJSONArray(key)?.strings().orEmpty() +internal fun JSONObject.historyColorImages(): List = optJSONArray("colorImages")?.objects { item -> + HistoryColorImage(item.getString("color"), item.getString("imagePath"), item.getInt("width"), item.getInt("height")) +}.orEmpty() diff --git a/android/app/src/main/java/cn/ilapage/goauto/agent/ui/HistoryImageLoader.kt b/android/app/src/main/java/cn/ilapage/goauto/agent/ui/HistoryImageLoader.kt new file mode 100644 index 0000000..3fe295b --- /dev/null +++ b/android/app/src/main/java/cn/ilapage/goauto/agent/ui/HistoryImageLoader.kt @@ -0,0 +1,115 @@ +package cn.ilapage.goauto.agent.ui + +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.os.Handler +import android.os.Looper +import android.util.LruCache +import java.io.ByteArrayOutputStream +import java.net.HttpURLConnection +import java.net.URL +import java.util.concurrent.Executors +import java.util.concurrent.atomic.AtomicBoolean + +internal object HistoryImagePolicy { + private const val COLOR_IMAGE_PREFIX = "/static/uploadfile/goauto-color/" + + fun resolveUrl(serverUrl: String, imagePath: String): String? { + if (!imagePath.startsWith(COLOR_IMAGE_PREFIX) || imagePath.contains("..")) return null + return runCatching { + val base = URL(serverUrl.trim().trimEnd('/') + "/") + if (base.protocol !in setOf("http", "https")) return null + URL(base, imagePath).toExternalForm() + }.getOrNull() + } + + fun sampleSize(width: Int, height: Int, targetPixels: Int): Int { + if (width <= 0 || height <= 0 || targetPixels <= 0) return 1 + var sample = 1 + while (width / (sample * 2) >= targetPixels && height / (sample * 2) >= targetPixels) sample *= 2 + return sample + } +} + +internal sealed class HistoryImageResult { + data class Success(val bitmap: Bitmap) : HistoryImageResult() + data object Failure : HistoryImageResult() +} + +internal fun interface HistoryImageRequest { + fun cancel() +} + +internal class HistoryImageLoader { + private val executor = Executors.newFixedThreadPool(3) + private val main = Handler(Looper.getMainLooper()) + private val closed = AtomicBoolean(false) + private val cache = object : LruCache((Runtime.getRuntime().maxMemory() / 16).coerceAtMost(Int.MAX_VALUE.toLong()).toInt()) { + override fun sizeOf(key: String, value: Bitmap): Int = value.byteCount + } + + fun load(url: String, targetPixels: Int, callback: (HistoryImageResult) -> Unit): HistoryImageRequest { + val cancelled = AtomicBoolean(false) + val cacheKey = "$url@$targetPixels" + cache.get(cacheKey)?.let { bitmap -> + main.post { if (!cancelled.get() && !closed.get()) callback(HistoryImageResult.Success(bitmap)) } + return HistoryImageRequest { cancelled.set(true) } + } + executor.execute { + val result = runCatching { download(url, targetPixels) } + .fold({ bitmap -> HistoryImageResult.Success(bitmap) }, { HistoryImageResult.Failure }) + if (result is HistoryImageResult.Success) cache.put(cacheKey, result.bitmap) + main.post { if (!cancelled.get() && !closed.get()) callback(result) } + } + return HistoryImageRequest { cancelled.set(true) } + } + + fun close() { + if (!closed.compareAndSet(false, true)) return + executor.shutdownNow() + cache.evictAll() + } + + private fun download(rawUrl: String, targetPixels: Int): Bitmap { + val connection = URL(rawUrl).openConnection() as HttpURLConnection + try { + connection.requestMethod = "GET" + connection.connectTimeout = 5_000 + connection.readTimeout = 8_000 + connection.instanceFollowRedirects = false + connection.setRequestProperty("Accept", "image/*") + val status = connection.responseCode + require(status in 200..299) + val contentType = connection.contentType.orEmpty().substringBefore(';').trim().lowercase() + require(contentType.startsWith("image/")) + require(connection.contentLengthLong <= MAX_IMAGE_BYTES || connection.contentLengthLong < 0) + val bytes = connection.inputStream.use { input -> + val output = ByteArrayOutputStream() + val buffer = ByteArray(16 * 1024) + var total = 0 + while (true) { + val read = input.read(buffer) + if (read < 0) break + total += read + require(total <= MAX_IMAGE_BYTES) + output.write(buffer, 0, read) + } + output.toByteArray() + } + val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true } + BitmapFactory.decodeByteArray(bytes, 0, bytes.size, bounds) + require(bounds.outWidth > 0 && bounds.outHeight > 0) + val options = BitmapFactory.Options().apply { + inSampleSize = HistoryImagePolicy.sampleSize(bounds.outWidth, bounds.outHeight, targetPixels) + inPreferredConfig = Bitmap.Config.ARGB_8888 + } + return requireNotNull(BitmapFactory.decodeByteArray(bytes, 0, bytes.size, options)) + } finally { + connection.disconnect() + } + } + + private companion object { + const val MAX_IMAGE_BYTES = 5 * 1024 * 1024 + } +} diff --git a/android/app/src/main/java/cn/ilapage/goauto/agent/ui/TaskHistoryFragment.kt b/android/app/src/main/java/cn/ilapage/goauto/agent/ui/TaskHistoryFragment.kt index 3bdb277..0407254 100644 --- a/android/app/src/main/java/cn/ilapage/goauto/agent/ui/TaskHistoryFragment.kt +++ b/android/app/src/main/java/cn/ilapage/goauto/agent/ui/TaskHistoryFragment.kt @@ -5,6 +5,7 @@ import android.content.BroadcastReceiver import android.content.Context import android.content.Intent import android.content.IntentFilter +import android.graphics.drawable.GradientDrawable import android.os.Build import android.os.Bundle import android.text.Editable @@ -15,7 +16,10 @@ import android.view.ViewGroup import android.view.inputmethod.EditorInfo import android.widget.Button import android.widget.EditText +import android.widget.FrameLayout +import android.widget.GridLayout import android.widget.HorizontalScrollView +import android.widget.ImageView import android.widget.LinearLayout import android.widget.ProgressBar import android.widget.Toast @@ -29,6 +33,7 @@ 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.HistoryColorImage import cn.ilapage.goauto.agent.network.PurchaseHistoryDetail import cn.ilapage.goauto.agent.network.PurchaseHistoryItem import cn.ilapage.goauto.agent.network.PurchaseResetResult @@ -87,15 +92,18 @@ internal object PurchaseRetryPolicy { } } -internal enum class ReplacementPresentation { ACTION, ACTIVATION_FAILED, MATCHING, MATCHED, MANUAL_REQUIRED, HIDDEN } +internal enum class ReplacementPresentation { ACTION, ACTIVATION_FAILED, MATCHING, MATCHED, MANUAL_REQUIRED, PURCHASE_IN_PROGRESS, HIDDEN } internal object ReplacementActionPolicy { - fun presentation(eligible: Boolean, mappingStatus: String?, activationStatus: String?): ReplacementPresentation = when { + const val PURCHASE_IN_PROGRESS_REASON = "该商品有采购任务正在执行或结果待核对,暂不能替换,请稍后重试" + + fun presentation(eligible: Boolean, mappingStatus: String?, activationStatus: String?, disabledReason: String? = null): ReplacementPresentation = when { activationStatus == "failed" -> ReplacementPresentation.ACTIVATION_FAILED mappingStatus == "matching" -> ReplacementPresentation.MATCHING mappingStatus == "matched" -> ReplacementPresentation.MATCHED mappingStatus == "manual_required" -> ReplacementPresentation.MANUAL_REQUIRED eligible -> ReplacementPresentation.ACTION + disabledReason == PURCHASE_IN_PROGRESS_REASON -> ReplacementPresentation.PURCHASE_IN_PROGRESS else -> ReplacementPresentation.HIDDEN } } @@ -130,6 +138,8 @@ class TaskHistoryFragment : Fragment() { private var loading = false private var pageSignature: String? = null private var detailState = TaskDetailState() + private val imageLoader = HistoryImageLoader() + private val imageRequests = mutableListOf() private var currentPageReceiverRegistered = false private val currentPageReceiver = object : BroadcastReceiver() { override fun onReceive(context: Context?, intent: Intent?) { @@ -198,11 +208,13 @@ class TaskHistoryFragment : Fragment() { override fun onDestroyView() { requestGeneration++ + cancelImageRequests() super.onDestroyView() } override fun onDestroy() { unregisterCurrentPageReceiver() + imageLoader.close() super.onDestroy() } @@ -503,6 +515,7 @@ class TaskHistoryFragment : Fragment() { private fun renderDetail(detail: Any) { if (this::swipeRefresh.isInitialized) swipeRefresh.isEnabled = false + cancelImageRequests() resultColumn.removeAllViews() resultColumn.addView(MaterialButton(requireContext(), null, com.google.android.material.R.attr.materialButtonOutlinedStyle).apply { text = if (collection) "返回采集记录" else "返回采购记录" @@ -527,12 +540,14 @@ class TaskHistoryFragment : Fragment() { append("评价数量:${detail.reviewCount?.toString() ?: "未采集到"}\n\n") append("$prices\n\n$dimensions\n\nSKU:${detail.skus.size} 条") } - resultColumn.addView(context.card(context.cardColumn().apply { + val detailCard = context.cardColumn().apply { addView(context.label("#${task.taskId} · 第 ${task.attemptNumber} 次 · ${task.goodsId.ifBlank { "未识别商品" }}", 20f, context.getColor(R.color.agent_text), true)) 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)) - }), collectionCardParams()) + addView(colorImageSection(detail.colorImages, AgentSettingsStore(context).serverUrl())) + } + resultColumn.addView(context.card(detailCard), collectionCardParams()) if (detail.missing.isNotEmpty()) resultColumn.addView(context.centeredMessage("缺失项", detail.missing.joinToString("、"))) if (task.errorMessage != null) resultColumn.addView(context.centeredMessage(task.errorMessage, "错误代码:${task.errorCode ?: "—"}")) if (detail.attempts.isNotEmpty()) { @@ -555,6 +570,7 @@ class TaskHistoryFragment : Fragment() { mappingStatus = detail.replacementMappingStatus, activationStatus = detail.replacementActivationStatus, activationErrorMessage = detail.replacementActivationErrorMessage, + disabledReason = detail.replacementDisabledReason, originType = "collection", taskId = task.taskId, taskNo = "#${task.taskId}", @@ -744,6 +760,7 @@ class TaskHistoryFragment : Fragment() { mappingStatus = detail.replacementMappingStatus, activationStatus = detail.replacementActivationStatus, activationErrorMessage = detail.replacementActivationErrorMessage, + disabledReason = detail.replacementDisabledReason, originType = "purchase", taskId = task.taskId, taskNo = "CG-${task.taskId}", @@ -758,6 +775,7 @@ class TaskHistoryFragment : Fragment() { mappingStatus: String?, activationStatus: String?, activationErrorMessage: String?, + disabledReason: String?, originType: String, taskId: Long, taskNo: String, @@ -775,7 +793,7 @@ class TaskHistoryFragment : Fragment() { }, collectionCardParams()) return } - when (ReplacementActionPolicy.presentation(eligible, mappingStatus, activationStatus)) { + when (ReplacementActionPolicy.presentation(eligible, mappingStatus, activationStatus, disabledReason)) { ReplacementPresentation.ACTIVATION_FAILED -> resultColumn.addView( context.centeredMessage( "采集成功,但替换生效失败", @@ -796,26 +814,34 @@ class TaskHistoryFragment : Fragment() { } } ReplacementPresentation.MANUAL_REQUIRED -> resultColumn.addView(context.centeredMessage("需要人工处理", "需在 Admin 人工匹配规格。")) + ReplacementPresentation.PURCHASE_IN_PROGRESS -> resultColumn.addView( + context.centeredMessage("暂不能替换", "该商品下有采购任务正在执行或订单结果待核对,任务结束后可再发起替换。"), + ) ReplacementPresentation.ACTION -> resultColumn.addView(MaterialButton(context, null, com.google.android.material.R.attr.materialButtonOutlinedStyle).apply { text = "采集替代商品" minimumHeight = context.dp(48) contentDescription = "为任务 $taskNo 采集替代商品" - setOnClickListener { confirmReplacement(originType, taskId, taskNo) } + setOnClickListener { confirmReplacement(originType, taskId) } }, collectionCardParams()) ReplacementPresentation.HIDDEN -> Unit } } - private fun confirmReplacement(originType: String, taskId: Long, taskNo: String) { + private fun confirmReplacement(originType: String, taskId: Long) { currentPageCollectionProblem()?.let { problem -> showCurrentPageBlocked(problem) return } MaterialAlertDialogBuilder(requireContext()) .setTitle("采集替代商品") - .setMessage("用当前商品替换 $taskNo 的失效商品?") + .setMessage( + "用当前商品替换关联的拼多多商品?将会:\n" + + "· 把所有关联该商品的虾皮商品一并改指向新商品,原有规格映射清空,需重新匹配\n" + + "· 取消该商品下尚未开始执行的采购任务\n" + + "原商品会被停用,此操作暂不能自动撤销。", + ) .setNegativeButton("取消", null) - .setPositiveButton("替换") { _, _ -> launchCurrentPageCollection(originType, taskId) } + .setPositiveButton("确认替换") { _, _ -> launchCurrentPageCollection(originType, taskId) } .show() } @@ -964,6 +990,159 @@ class TaskHistoryFragment : Fragment() { else -> R.color.agent_primary_light }) + private fun colorImageSection(images: List, serverUrl: String): View { + val context = requireContext() + return LinearLayout(context).apply { + orientation = LinearLayout.VERTICAL + setPadding(0, context.dp(20), 0, 0) + addView(context.label("颜色图片", 16f, context.getColor(R.color.agent_text), true)) + if (images.isEmpty()) { + addView(context.label("暂无颜色图片", 14f, context.getColor(R.color.agent_text_muted)).apply { + setPadding(0, context.dp(8), 0, 0) + }) + addView(context.label("同色图片被后续采集覆盖后,也不会在本任务中显示。", 12f, context.getColor(R.color.agent_text_muted)).apply { + setPadding(0, context.dp(4), 0, 0) + }) + return@apply + } + addView(GridLayout(context).apply { + columnCount = 3 + alignmentMode = GridLayout.ALIGN_BOUNDS + useDefaultMargins = false + setPadding(0, context.dp(8), 0, 0) + images.forEach { image -> + addView(colorImageTile(image, serverUrl), GridLayout.LayoutParams().apply { + width = 0 + height = ViewGroup.LayoutParams.WRAP_CONTENT + columnSpec = GridLayout.spec(GridLayout.UNDEFINED, 1f) + setMargins(context.dp(4), context.dp(4), context.dp(4), context.dp(8)) + }) + } + }, fullWidth()) + } + } + + private fun colorImageTile(item: HistoryColorImage, serverUrl: String): View { + val context = requireContext() + val preview = FrameLayout(context).apply { + val shape = GradientDrawable().apply { + setColor(context.getColor(R.color.agent_surface_high)) + cornerRadius = context.dp(8).toFloat() + } + background = shape + clipToOutline = true + minimumWidth = context.dp(88) + minimumHeight = context.dp(88) + isFocusable = true + isClickable = false + contentDescription = "${item.color}图片正在加载" + val attributes = context.obtainStyledAttributes(intArrayOf(android.R.attr.selectableItemBackground)) + foreground = attributes.getDrawable(0) + attributes.recycle() + } + val bitmapView = ImageView(context).apply { + visibility = View.INVISIBLE + scaleType = ImageView.ScaleType.CENTER_CROP + importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO + } + val progress = ProgressBar(context) + val failure = context.label("图片加载\n失败", 12f, context.getColor(R.color.agent_text_muted), true).apply { + gravity = Gravity.CENTER + visibility = View.GONE + importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO + } + preview.addView(bitmapView, FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)) + preview.addView(progress, FrameLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT, Gravity.CENTER)) + preview.addView(failure, FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT, Gravity.CENTER)) + val resolved = HistoryImagePolicy.resolveUrl(serverUrl, item.imagePath) + if (resolved == null) { + progress.visibility = View.GONE + failure.visibility = View.VISIBLE + preview.contentDescription = "${item.color}图片加载失败" + } else { + imageRequests += imageLoader.load(resolved, context.dp(176)) { result -> + if (!isAdded) return@load + progress.visibility = View.GONE + when (result) { + is HistoryImageResult.Success -> { + bitmapView.setImageBitmap(result.bitmap) + bitmapView.visibility = View.VISIBLE + failure.visibility = View.GONE + preview.isClickable = true + preview.contentDescription = "${item.color},查看大图" + preview.setOnClickListener { showHistoryImageDialog(item, resolved) } + } + HistoryImageResult.Failure -> { + bitmapView.visibility = View.INVISIBLE + failure.visibility = View.VISIBLE + preview.contentDescription = "${item.color}图片加载失败" + } + } + } + } + return LinearLayout(context).apply { + orientation = LinearLayout.VERTICAL + gravity = Gravity.CENTER_HORIZONTAL + addView(preview, LinearLayout.LayoutParams(context.dp(88), context.dp(88))) + addView(context.label(item.color, 13f, context.getColor(R.color.agent_text)).apply { + gravity = Gravity.CENTER + maxLines = 2 + setPadding(0, context.dp(6), 0, 0) + }, fullWidth()) + } + } + + private fun showHistoryImageDialog(item: HistoryColorImage, resolvedUrl: String) { + val context = requireContext() + val container = FrameLayout(context).apply { + setBackgroundColor(context.getColor(R.color.agent_surface_high)) + setPadding(context.dp(8), context.dp(8), context.dp(8), context.dp(8)) + } + val image = ImageView(context).apply { + scaleType = ImageView.ScaleType.FIT_CENTER + visibility = View.INVISIBLE + contentDescription = "${item.color}颜色图片" + } + val progress = ProgressBar(context) + val failure = context.label("图片加载失败,请关闭后重试。", 14f, context.getColor(R.color.agent_text_muted), true).apply { + gravity = Gravity.CENTER + visibility = View.GONE + } + container.addView(image, FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)) + container.addView(progress, FrameLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT, Gravity.CENTER)) + container.addView(failure, FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT, Gravity.CENTER)) + val target = (context.resources.displayMetrics.widthPixels - context.dp(48)).coerceAtMost(context.dp(720)) + val request = imageLoader.load(resolvedUrl, target) { result -> + progress.visibility = View.GONE + when (result) { + is HistoryImageResult.Success -> { + image.setImageBitmap(result.bitmap) + image.visibility = View.VISIBLE + } + HistoryImageResult.Failure -> failure.visibility = View.VISIBLE + } + } + imageRequests += request + MaterialAlertDialogBuilder(context) + .setTitle(item.color) + .setView(container) + .setNegativeButton("关闭", null) + .create() + .apply { + setCanceledOnTouchOutside(true) + setOnDismissListener { request.cancel() } + show() + container.layoutParams = container.layoutParams?.apply { + height = (context.resources.displayMetrics.heightPixels * 0.62f).toInt().coerceAtMost(context.dp(560)) + } + } + } + + private fun cancelImageRequests() { + imageRequests.forEach(HistoryImageRequest::cancel) + imageRequests.clear() + } + private fun money(cents: Long?, currency: String): String { if (cents == null) return "未记录" val symbol = if (currency.equals("CNY", true)) "¥" else "$currency " diff --git a/android/app/src/test/java/cn/ilapage/goauto/agent/AgentApiClientJsonTest.kt b/android/app/src/test/java/cn/ilapage/goauto/agent/AgentApiClientJsonTest.kt index 2418f7c..e0e5531 100644 --- a/android/app/src/test/java/cn/ilapage/goauto/agent/AgentApiClientJsonTest.kt +++ b/android/app/src/test/java/cn/ilapage/goauto/agent/AgentApiClientJsonTest.kt @@ -1,6 +1,7 @@ package cn.ilapage.goauto.agent import cn.ilapage.goauto.agent.network.stringArrayOrEmpty +import cn.ilapage.goauto.agent.network.historyColorImages import org.json.JSONArray import org.json.JSONObject import org.junit.Assert.assertEquals @@ -19,4 +20,19 @@ class AgentApiClientJsonTest { assertEquals(emptyList(), JSONObject().put("missing", JSONObject.NULL).stringArrayOrEmpty("missing")) assertEquals(emptyList(), JSONObject().stringArrayOrEmpty("missing")) } + + @Test + fun `history color images parse fields and preserve empty compatibility`() { + val data = JSONObject().put("colorImages", JSONArray().put(JSONObject() + .put("color", "红色") + .put("imagePath", "/static/uploadfile/goauto-color/red.jpg") + .put("width", 300) + .put("height", 280))) + + val images = data.historyColorImages() + assertEquals(1, images.size) + assertEquals("红色", images.single().color) + assertEquals(300, images.single().width) + assertEquals(emptyList(), JSONObject().historyColorImages()) + } } diff --git a/android/app/src/test/java/cn/ilapage/goauto/agent/HistoryImagePolicyTest.kt b/android/app/src/test/java/cn/ilapage/goauto/agent/HistoryImagePolicyTest.kt new file mode 100644 index 0000000..6ffa72c --- /dev/null +++ b/android/app/src/test/java/cn/ilapage/goauto/agent/HistoryImagePolicyTest.kt @@ -0,0 +1,26 @@ +package cn.ilapage.goauto.agent + +import cn.ilapage.goauto.agent.ui.HistoryImagePolicy +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class HistoryImagePolicyTest { + @Test + fun `resolves only configured color image paths`() { + assertEquals( + "http://192.0.2.1:8010/static/uploadfile/goauto-color/red.jpg", + HistoryImagePolicy.resolveUrl("http://192.0.2.1:8010", "/static/uploadfile/goauto-color/red.jpg"), + ) + assertNull(HistoryImagePolicy.resolveUrl("http://192.0.2.1:8010", "https://example.com/red.jpg")) + assertNull(HistoryImagePolicy.resolveUrl("http://192.0.2.1:8010", "/static/uploadfile/goauto-color/../secret")) + assertNull(HistoryImagePolicy.resolveUrl("file:///tmp", "/static/uploadfile/goauto-color/red.jpg")) + } + + @Test + fun `sampling keeps a power of two near the target`() { + assertEquals(4, HistoryImagePolicy.sampleSize(1600, 1200, 256)) + assertEquals(1, HistoryImagePolicy.sampleSize(300, 280, 256)) + assertEquals(1, HistoryImagePolicy.sampleSize(0, 0, 256)) + } +} diff --git a/android/app/src/test/java/cn/ilapage/goauto/agent/ReplacementActionPolicyTest.kt b/android/app/src/test/java/cn/ilapage/goauto/agent/ReplacementActionPolicyTest.kt index 9fe3363..ea0e96f 100644 --- a/android/app/src/test/java/cn/ilapage/goauto/agent/ReplacementActionPolicyTest.kt +++ b/android/app/src/test/java/cn/ilapage/goauto/agent/ReplacementActionPolicyTest.kt @@ -19,6 +19,10 @@ class ReplacementActionPolicyTest { fun serverEligibilityIsTheOnlyWayToShowTheAction() { assertEquals(ReplacementPresentation.ACTION, ReplacementActionPolicy.presentation(true, null, null)) assertEquals(ReplacementPresentation.HIDDEN, ReplacementActionPolicy.presentation(false, null, null)) + assertEquals( + ReplacementPresentation.PURCHASE_IN_PROGRESS, + ReplacementActionPolicy.presentation(false, null, null, ReplacementActionPolicy.PURCHASE_IN_PROGRESS_REASON), + ) } @Test diff --git a/docs/03-business-rules-and-glossary.md b/docs/03-business-rules-and-glossary.md index 22bb1cf..6badf7a 100644 --- a/docs/03-business-rules-and-glossary.md +++ b/docs/03-business-rules-and-glossary.md @@ -2,8 +2,8 @@ generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件) wiki_page: Business-Rules-and-Glossary wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Business-Rules-and-Glossary.- -wiki_revision: 51de401da3555517a5e560ca72e064fb11bd8d89 -synchronized_at: 2026-09-01T00:58:04Z +wiki_revision: 36d7285beb3f0c8441e38b4e9e81e5cf7256b603 +synchronized_at: 2026-09-01T01:50:05Z # 业务规则与术语 @@ -340,8 +340,8 @@ synchronized_at: 2026-09-01T00:58:04Z ## Agent 手动采集替代商品(#130) -- 只有同一设备上的失败采集或失败采购任务,且错误码逐字等于 `PDD_LINK_INVALID` 或 `PDD_GOODS_SOLD_OUT` 时,Agent 详情才显示“采集替代商品”;Android 不自行推断资格。 -- Agent 创建当前页面采集任务时携带 `replacementOrigin.type`(`collection` / `purchase`)和来源 `taskId`。服务端再次校验设备、任务终态、错误码、源商品、既有替换与进行中的替换流程,并把来源和激活状态持久化到采集任务,保证进程重启后仍可恢复。 +- 采集来源不再按任务状态或错误码白名单限制“采集替代商品”:同一设备的采集任务只要仍关联 PDD 商品、该商品没有进行中的替换,且名下不存在 `running`、`order_submit_started`、`order_result_unknown` 采购任务,即可由人工判断是否替换。命中这三种采购状态时必须硬拦截并提示等待任务结束,不提供强制绕过。采购来源仍只允许失败任务且错误码逐字等于 `PDD_LINK_INVALID` 或 `PDD_GOODS_SOLD_OUT`;Android 只呈现服务端资格,不自行放宽。 +- Agent 创建当前页面采集任务时携带 `replacementOrigin.type`(`collection` / `purchase`)和来源 `taskId`。服务端再次校验设备、来源对应的资格边界、源商品、既有替换与进行中的替换流程,并把来源和激活状态持久化到采集任务,保证进程重启后仍可恢复。采集来源确认替换时必须明确提示:所有关联虾皮商品会改指新商品并清空规格映射、尚未开始的采购任务会取消、原商品会停用且暂不能自动撤销。 - 替代商品采集结果先按普通采集事务完整保存。随后调用 #131 的原子生效流程;生效失败不得回滚或覆盖已采集商品、规格和 SKU,而是记录稳定的 `failed` 激活状态与限长错误,服务启动后可按同一幂等键仅重试生效,不重新采集。 - 任务详情的 `replacementMappingStatus` 必须由来源任务对应的替换分项推导,不能只读取主表总体状态。状态为 `matching` 时等待自动匹配,`manual_required` 时由 Admin 人工处理,`matched` 后才进入 #132 的继续采购流程。 - 替换采集沿用设备级单任务互斥、采集间隔、无障碍安全边界和禁止支付规则;不会增加轮询,也不会创建采购任务或订单。 @@ -364,6 +364,7 @@ synchronized_at: 2026-09-01T00:58:04Z - 服务端只接受该任务已提交颜色维度中的 JPEG,校验 Device Token、任务归属、终态、内容、尺寸和数量;按“PDD 商品 + 颜色值”维护最新一张,记录来源 task、device,并沿任务关联不可变规则快照。 - 只允许保存裁剪后的商品图片区域;裁剪结果不得包含账号、地址、订单、支付及其他个人数据。原始控件树、XML、整屏截图仍禁止保存。 - 诊断阶段 `COLOR_IMAGE` 只记录成功及不支持、定位失败、裁剪失败、压缩失败、上传失败分类,不记录颜色文案、坐标或图片内容。 +- Agent 采集任务详情只返回并展示当前仍以该任务为 `source_task_id` 的颜色图片,按颜色分组提供缩略图、明确空态/加载失败占位和只读放大查看;同商品同颜色被后续任务覆盖后,旧任务详情不再显示该图。图片仍沿用既有受控商品裁剪文件,不新增整屏截图、原始控件树、下载、分享或编辑能力。 ## Admin 采集采购与采采管理导航(#142) diff --git a/docs/08-agent-api-contract.md b/docs/08-agent-api-contract.md index c1a0c56..53131dd 100644 --- a/docs/08-agent-api-contract.md +++ b/docs/08-agent-api-contract.md @@ -2,8 +2,8 @@ generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件) wiki_page: Android-Agent-API-Contract wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Android-Agent-API-Contract.- -wiki_revision: 94a99754058be22ec1fbb4e2e661b91a23fc026d -synchronized_at: 2026-09-01T00:58:35Z +wiki_revision: 0f448673dbec00a351c397d30926f0c5090a1141 +synchronized_at: 2026-09-01T01:50:29Z # MVP 共享 API 契约 @@ -583,7 +583,7 @@ GET /api/agent/v1/purchase-tasks/{taskId} - `pageSize` 最大为 50;`status` 与 `taskNo` 可以组合过滤。 - 采集任务编号允许 `35` 或 `#35`,采购任务编号允许 `12` 或不区分大小写的 `CG-12`;服务端按精确编号匹配。 - 列表和详情不返回 Device Token、URL、收货地址、规则快照或原始控件树。采购项额外返回服务端计算的 `retryable` 和可选 `retryDisabledReason`;除受控失败重试外,不提供取消、修改既有订单或支付入口。 -- 采集摘要返回当前 `attemptNumber`;采集详情返回标题、店铺、销量、评价数、规格维度、颜色价格、SKU、缺失项、结构化错误,以及不含规则快照的历史 attempt 序号、状态、规则 ID、错误和时间摘要。 +- 采集摘要返回当前 `attemptNumber`;采集详情返回标题、店铺、销量、评价数、规格维度、颜色价格、SKU、缺失项、结构化错误,以及不含规则快照的历史 attempt 序号、状态、规则 ID、错误和时间摘要。详情另返回 `colorImages` 数组,元素为 `{color,imagePath,width,height}`,只包含 `source_task_id` 等于当前任务的颜色图片并按颜色排序;无图时必须为 `[]`。`imagePath` 是既有 `/static/uploadfile/goauto-color/` 相对路径,Agent 使用已配置服务端 Origin 加载,加载失败不得影响其他详情字段。 - 采购列表和详情分别返回任务目标规格 `targetColor` / `targetSize` 与最终执行规格 `mappedColor` / `mappedSize`。Agent 界面必须分开展示;自动化只执行服务端下发的 `mapped*` 精确规格,映射为空时仍可查看原始目标,不得以目标值替代执行值。详情另返回蝦皮订单号、PDD 商品、数量、实际单价、PDD 订单号、下单时间和结构化错误。 - Agent 提交采购结果时可携带 `actualUnitPriceCent`(人民币分,非负)。服务端只保存 Agent 实际观察到的值;历史任务或未观察到价格的结果保持 `null`,客户端显示“未记录”。 @@ -733,7 +733,7 @@ Content-Type: application/json } ``` -- `replacementEligible=true` 仅适用于当前 Device Token 对应设备、任务状态为 `failed`,且错误码逐字等于 `PDD_LINK_INVALID` 或 `PDD_GOODS_SOLD_OUT`;其他错误、其他设备、无源商品、已存在生效替换或已有待处理替换均不得由 Android 自行放宽。 +- 采集来源的 `replacementEligible=true` 不再要求任务为 `failed`,也不检查错误码;当前 Device Token 对应设备、源商品存在、没有生效或待处理替换,且该商品名下不存在 `running`、`order_submit_started`、`order_result_unknown` 采购任务时即可返回。命中采购硬拦截时 `replacementDisabledReason` 明确说明正在执行或结果待核对。采购来源仍要求失败任务且错误码逐字等于 `PDD_LINK_INVALID` 或 `PDD_GOODS_SOLD_OUT`;其他设备、无源商品或进行中的替换均不得由 Android 自行放宽。 - `replacementMappingStatus` 取当前来源对应的分项状态:`matching`、`matched` 或 `manual_required`。采购来源必须限定到该任务的 `shopeeProductId`;不能以替换主表总体状态代替。 - `replacementActivationStatus` 为 `pending`、`activated` 或 `failed`;激活失败时已采集数据仍为完成态,并返回限长的 `replacementActivationErrorMessage`。 diff --git a/server/app/goauto/replacement/origin.go b/server/app/goauto/replacement/origin.go index a31bb77..6ab472a 100644 --- a/server/app/goauto/replacement/origin.go +++ b/server/app/goauto/replacement/origin.go @@ -12,8 +12,7 @@ import ( const ( CodeOriginNotEligible = "REPLACEMENT_ORIGIN_NOT_ELIGIBLE" - ErrorPDDLinkInvalid = "PDD_LINK_INVALID" - ErrorPDDGoodsSoldOut = "PDD_GOODS_SOLD_OUT" + collectionPurchaseInProgressReason = "该商品有采购任务正在执行或结果待核对,暂不能替换,请稍后重试" ) type OriginInspection struct { @@ -32,8 +31,8 @@ func (service *Service) InspectOrigin(ctx context.Context, originType string, ta return OriginInspection{}, fail(CodeInvalidRequest, "替换来源无效") } result := OriginInspection{} - var status string - var errorCode *string + var purchaseStatus string + var purchaseErrorCode *string switch originType { case models.ReplacementOriginCollection: var task models.CollectionTask @@ -45,7 +44,6 @@ func (service *Service) InspectOrigin(ctx context.Context, originType string, ta if task.PDDProductID != nil { result.SourceProductID = *task.PDDProductID } - status, errorCode = task.Status, task.ErrorCode case models.ReplacementOriginPurchase: var task models.PurchaseTask if err := service.DB.WithContext(ctx).Where("id = ? AND device_id = ?", taskID, deviceID).First(&task).Error; errors.Is(err, gorm.ErrRecordNotFound) { @@ -61,7 +59,7 @@ func (service *Service) InspectOrigin(ctx context.Context, originType string, ta if task.ShopeeProductID != nil { result.ShopeeProductID = *task.ShopeeProductID } - status, errorCode = task.Status, task.ErrorCode + purchaseStatus, purchaseErrorCode = task.Status, task.ErrorCode } if result.SourceProductID == 0 { result.DisabledReason = "原任务没有可替换的拼多多商品" @@ -101,12 +99,28 @@ func (service *Service) InspectOrigin(ctx context.Context, originType string, ta } result.CorrectionReplacementID = correctionID } - failed := status == models.TaskStatusFailed || status == models.PurchaseTaskStatusFailed - if !failed { + if originType == models.ReplacementOriginCollection { + var count int64 + if err := service.DB.WithContext(ctx).Model(&models.PurchaseTask{}). + Where("pdd_product_id = ? AND status IN ?", result.SourceProductID, []string{ + models.PurchaseTaskStatusRunning, + models.PurchaseTaskStatusOrderSubmitStarted, + models.PurchaseTaskStatusOrderResultUnknown, + }).Count(&count).Error; err != nil { + return OriginInspection{}, internal(err) + } + if count > 0 { + result.DisabledReason = collectionPurchaseInProgressReason + return result, nil + } + result.Eligible = true + return result, nil + } + if purchaseStatus != models.PurchaseTaskStatusFailed { result.DisabledReason = "只有失败任务可以替换商品" return result, nil } - if errorCode == nil || (*errorCode != ErrorPDDLinkInvalid && *errorCode != ErrorPDDGoodsSoldOut) { + if purchaseErrorCode == nil || (*purchaseErrorCode != "PDD_LINK_INVALID" && *purchaseErrorCode != "PDD_GOODS_SOLD_OUT") { result.DisabledReason = "当前失败原因不属于商品失效或售罄" return result, nil } diff --git a/server/app/goauto/replacement/service_test.go b/server/app/goauto/replacement/service_test.go index 0b46e04..61833fb 100644 --- a/server/app/goauto/replacement/service_test.go +++ b/server/app/goauto/replacement/service_test.go @@ -99,10 +99,11 @@ func replacementCode(err error) string { return "" } -func TestInspectOriginUsesExactFailureCodesAndDeviceBoundary(t *testing.T) { +func TestInspectCollectionOriginAllowsAnyTaskStatusAndFailureCode(t *testing.T) { fixture := seedReplacementFixture(t) - linkInvalid := ErrorPDDLinkInvalid - if err := fixture.db.Session(&gorm.Session{SkipHooks: true}).Model(&models.CollectionTask{}).Where("id = ?", fixture.origin.ID).Update("error_code", linkInvalid).Error; err != nil { + genericFailure := "RULE_NOT_MATCHED" + if err := fixture.db.Session(&gorm.Session{SkipHooks: true}).Model(&models.CollectionTask{}).Where("id = ?", fixture.origin.ID). + Updates(map[string]any{"status": models.TaskStatusCompleted, "error_code": genericFailure}).Error; err != nil { t.Fatal(err) } @@ -111,15 +112,6 @@ func TestInspectOriginUsesExactFailureCodesAndDeviceBoundary(t *testing.T) { t.Fatalf("eligible collection origin: inspection=%+v error=%v", inspection, err) } - notExact := "PDD_LINK_INVALID_RETRY" - if err := fixture.db.Session(&gorm.Session{SkipHooks: true}).Model(&models.CollectionTask{}).Where("id = ?", fixture.origin.ID).Update("error_code", notExact).Error; err != nil { - t.Fatal(err) - } - inspection, err = fixture.service.InspectOrigin(context.Background(), models.ReplacementOriginCollection, fixture.origin.ID, fixture.device.ID) - if err != nil || inspection.Eligible || inspection.DisabledReason == "" { - t.Fatalf("non-exact code must be disabled: inspection=%+v error=%v", inspection, err) - } - otherDevice := fixture.device otherDevice.ID = 0 otherDevice.InstallID = uuid.NewString() @@ -133,6 +125,96 @@ func TestInspectOriginUsesExactFailureCodesAndDeviceBoundary(t *testing.T) { } } +func TestInspectCollectionOriginBlocksPurchasesInProgress(t *testing.T) { + blockedStatuses := []string{ + models.PurchaseTaskStatusRunning, + models.PurchaseTaskStatusOrderSubmitStarted, + models.PurchaseTaskStatusOrderResultUnknown, + } + for _, status := range blockedStatuses { + t.Run(status, func(t *testing.T) { + fixture := seedReplacementFixture(t) + shopee := models.ShopeeProduct{ShopeeItemID: "SP-BLOCKED", PDDProductID: &fixture.source.ID, SpecsJSON: "[]", Currency: "CNY"} + if err := fixture.db.Create(&shopee).Error; err != nil { + t.Fatal(err) + } + syb := models.SYBProduct{OrderCode: "SYB-BLOCKED", DetailID: 1, StockID: 1, ShopeeItemID: shopee.ShopeeItemID, ShopeeProductID: &shopee.ID, Quantity: 1, UnitPriceCent: 100, ParseStatus: models.SYBParseStatusSuccess, RawJSON: `{}`} + if err := fixture.db.Create(&syb).Error; err != nil { + t.Fatal(err) + } + purchase := models.PurchaseTask{ + SYBProductID: &syb.ID, ShopeeProductID: &shopee.ID, PDDProductID: fixture.source.ID, DeviceID: &fixture.device.ID, + ExecutionMode: models.PurchaseExecutionModeLive, Status: status, + ShopeeItemIDSnapshot: "SP-BLOCKED", PDDURLSnapshot: fixture.source.URL, PDDGoodsIDSnapshot: fixture.source.GoodsID, + SpecDecisionSnapshot: `{}`, Quantity: 1, Currency: "CNY", RuleType: "pddPurchase", RuleSchemaVersion: 1, + RequiredCapabilitiesJSON: `[]`, RuleSnapshot: `{}`, CreateRequestID: uuid.NewString(), + PaymentReviewStatus: models.PurchasePaymentReviewPending, LogisticsStatus: models.PurchaseLogisticsStatusPending, + WritebackStatus: models.PurchaseWritebackStatusNotSelected, + } + if err := fixture.db.Create(&purchase).Error; err != nil { + t.Fatal(err) + } + + inspection, err := fixture.service.InspectOrigin(context.Background(), models.ReplacementOriginCollection, fixture.origin.ID, fixture.device.ID) + if err != nil || inspection.Eligible || inspection.DisabledReason != collectionPurchaseInProgressReason { + t.Fatalf("status=%s inspection=%+v error=%v", status, inspection, err) + } + + if err := fixture.db.Session(&gorm.Session{SkipHooks: true}).Model(&purchase).Update("status", models.PurchaseTaskStatusFailed).Error; err != nil { + t.Fatal(err) + } + inspection, err = fixture.service.InspectOrigin(context.Background(), models.ReplacementOriginCollection, fixture.origin.ID, fixture.device.ID) + if err != nil || !inspection.Eligible { + t.Fatalf("terminal purchase must restore eligibility: inspection=%+v error=%v", inspection, err) + } + }) + } +} + +func TestInspectPurchaseOriginKeepsFailureStatusAndCodeRequirements(t *testing.T) { + fixture := seedReplacementFixture(t) + shopee := models.ShopeeProduct{ShopeeItemID: "SP-PURCHASE", PDDProductID: &fixture.source.ID, SpecsJSON: "[]", Currency: "CNY"} + if err := fixture.db.Create(&shopee).Error; err != nil { + t.Fatal(err) + } + syb := models.SYBProduct{OrderCode: "SYB-PURCHASE", DetailID: 1, StockID: 1, ShopeeItemID: shopee.ShopeeItemID, ShopeeProductID: &shopee.ID, Quantity: 1, UnitPriceCent: 100, ParseStatus: models.SYBParseStatusSuccess, RawJSON: `{}`} + if err := fixture.db.Create(&syb).Error; err != nil { + t.Fatal(err) + } + errorCode := "PDD_LINK_INVALID" + purchase := models.PurchaseTask{ + SYBProductID: &syb.ID, ShopeeProductID: &shopee.ID, PDDProductID: fixture.source.ID, DeviceID: &fixture.device.ID, + ExecutionMode: models.PurchaseExecutionModeLive, Status: models.PurchaseTaskStatusFailed, ErrorCode: &errorCode, + ShopeeItemIDSnapshot: shopee.ShopeeItemID, PDDURLSnapshot: fixture.source.URL, PDDGoodsIDSnapshot: fixture.source.GoodsID, + SpecDecisionSnapshot: `{}`, Quantity: 1, Currency: "CNY", RuleType: "pddPurchase", RuleSchemaVersion: 1, + RequiredCapabilitiesJSON: `[]`, RuleSnapshot: `{}`, CreateRequestID: uuid.NewString(), + PaymentReviewStatus: models.PurchasePaymentReviewPending, LogisticsStatus: models.PurchaseLogisticsStatusPending, + WritebackStatus: models.PurchaseWritebackStatusNotSelected, + } + if err := fixture.db.Create(&purchase).Error; err != nil { + t.Fatal(err) + } + + inspection, err := fixture.service.InspectOrigin(context.Background(), models.ReplacementOriginPurchase, purchase.ID, fixture.device.ID) + if err != nil || !inspection.Eligible { + t.Fatalf("eligible purchase origin: inspection=%+v error=%v", inspection, err) + } + if err := fixture.db.Session(&gorm.Session{SkipHooks: true}).Model(&purchase).Updates(map[string]any{"error_code": "RULE_NOT_MATCHED"}).Error; err != nil { + t.Fatal(err) + } + inspection, err = fixture.service.InspectOrigin(context.Background(), models.ReplacementOriginPurchase, purchase.ID, fixture.device.ID) + if err != nil || inspection.Eligible || inspection.DisabledReason != "当前失败原因不属于商品失效或售罄" { + t.Fatalf("purchase failure code boundary changed: inspection=%+v error=%v", inspection, err) + } + if err := fixture.db.Session(&gorm.Session{SkipHooks: true}).Model(&purchase).Updates(map[string]any{"status": models.PurchaseTaskStatusOrderCreated, "error_code": errorCode}).Error; err != nil { + t.Fatal(err) + } + inspection, err = fixture.service.InspectOrigin(context.Background(), models.ReplacementOriginPurchase, purchase.ID, fixture.device.ID) + if err != nil || inspection.Eligible || inspection.DisabledReason != "只有失败任务可以替换商品" { + t.Fatalf("purchase status boundary changed: inspection=%+v error=%v", inspection, err) + } +} + func TestRegisterAndQueryCollectionOrigin(t *testing.T) { fixture := seedReplacementFixture(t) request := fixture.request() diff --git a/server/app/goauto/task/agent_history.go b/server/app/goauto/task/agent_history.go index e379434..7b851aa 100644 --- a/server/app/goauto/task/agent_history.go +++ b/server/app/goauto/task/agent_history.go @@ -54,6 +54,7 @@ type AgentCollectionDetail struct { ReviewCount *int64 `json:"reviewCount,omitempty"` Dimensions []DetailDimension `json:"dimensions"` ColorPrices []AgentColorPrice `json:"colorPrices"` + ColorImages []AgentColorImage `json:"colorImages"` SKUs []AgentCollectionSKU `json:"skus"` Missing []string `json:"missing"` ReplacementEligible bool `json:"replacementEligible"` @@ -79,6 +80,13 @@ type AgentColorPrice struct { PriceCent int64 `json:"priceCent"` } +type AgentColorImage struct { + Color string `json:"color"` + ImagePath string `json:"imagePath"` + Width int `json:"width"` + Height int `json:"height"` +} + type AgentCollectionSKU struct { Specs map[string]string `json:"specs"` PriceCent int64 `json:"priceCent"` @@ -163,6 +171,14 @@ func (service *Service) AgentHistoryDetail(ctx context.Context, taskID uint64, t for _, value := range detail.ColorPrices { colorPrices = append(colorPrices, AgentColorPrice{Color: value.Color, PriceCent: value.PriceCent}) } + var imageRows []models.PDDProductColorImage + if err := service.DB.WithContext(ctx).Where("source_task_id = ?", taskID).Order("color ASC").Find(&imageRows).Error; err != nil { + return AgentCollectionDetail{}, internalError(err) + } + colorImages := make([]AgentColorImage, 0, len(imageRows)) + for _, value := range imageRows { + colorImages = append(colorImages, AgentColorImage{Color: value.Color, ImagePath: value.ImagePath, Width: value.Width, Height: value.Height}) + } 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}) @@ -177,7 +193,7 @@ func (service *Service) AgentHistoryDetail(ctx context.Context, taskID uint64, t } return AgentCollectionDetail{ Task: agentCollectionItem(record), Attempts: attempts, ShopName: record.ShopName, SalesText: record.SalesText, - ReviewCount: record.ReviewCount, Dimensions: detail.Dimensions, ColorPrices: colorPrices, + ReviewCount: record.ReviewCount, Dimensions: detail.Dimensions, ColorPrices: colorPrices, ColorImages: colorImages, SKUs: skus, Missing: detail.Missing, ReplacementEligible: inspection.Eligible, ReplacementDisabledReason: inspection.DisabledReason, ReplacementMappingStatus: inspection.MappingStatus, ReplacementActivationStatus: inspection.ActivationStatus, diff --git a/server/app/goauto/task/agent_history_test.go b/server/app/goauto/task/agent_history_test.go index cc82556..cfd9d52 100644 --- a/server/app/goauto/task/agent_history_test.go +++ b/server/app/goauto/task/agent_history_test.go @@ -95,3 +95,40 @@ func TestAgentHistoryDetailReturnsArrayForLegacyNullMissing(t *testing.T) { t.Fatalf("history detail did not preserve array contract: %s %v", raw, err) } } + +func TestAgentHistoryDetailReturnsOnlyImagesOwnedByTask(t *testing.T) { + db := openTaskDatabase(t) + deviceRecord, token := registerTaskDevice(t, db, "history-color-images") + taskA := createTask(t, db, &deviceRecord.ID) + taskB := createTask(t, db, &deviceRecord.ID) + if taskA.PDDProductID == nil || taskB.PDDProductID == nil { + t.Fatal("collection tasks must reference products") + } + images := []models.PDDProductColorImage{ + {PDDProductID: *taskA.PDDProductID, Color: "红色", ImagePath: "/static/uploadfile/goauto-color/red.jpg", ContentType: "image/jpeg", ByteSize: 120, Width: 300, Height: 300, SourceTaskID: taskA.ID, SourceDeviceID: deviceRecord.ID}, + {PDDProductID: *taskB.PDDProductID, Color: "蓝色", ImagePath: "/static/uploadfile/goauto-color/blue.jpg", ContentType: "image/jpeg", ByteSize: 140, Width: 320, Height: 280, SourceTaskID: taskB.ID, SourceDeviceID: deviceRecord.ID}, + } + if err := db.Create(&images).Error; err != nil { + t.Fatal(err) + } + + detail, err := newTaskService(db).AgentHistoryDetail(context.Background(), taskA.ID, token) + if err != nil { + t.Fatal(err) + } + if len(detail.ColorImages) != 1 || detail.ColorImages[0].Color != "红色" || detail.ColorImages[0].ImagePath != images[0].ImagePath { + t.Fatalf("unexpected task images: %#v", detail.ColorImages) + } + + if err := db.Model(&images[0]).Update("source_task_id", taskB.ID).Error; err != nil { + t.Fatal(err) + } + detail, err = newTaskService(db).AgentHistoryDetail(context.Background(), taskA.ID, token) + if err != nil || detail.ColorImages == nil || len(detail.ColorImages) != 0 { + t.Fatalf("overwritten image must disappear from old task: %#v %v", detail.ColorImages, err) + } + raw, err := json.Marshal(detail) + if err != nil || !strings.Contains(string(raw), `"colorImages":[]`) { + t.Fatalf("empty image array contract changed: %s %v", raw, err) + } +}