From 6f611a04ef6703723f393ee7f4191b264e5e7221 Mon Sep 17 00:00:00 2001 From: QiuSW <105186638@qq.com> Date: Fri, 28 Aug 2026 23:31:23 +0800 Subject: [PATCH] feat(goauto): collect cropped color images (#133) --- AGENTS.md | 2 +- android/app/build.gradle.kts | 4 +- .../automation/GoAutoAccessibilityService.kt | 75 ++++++++ .../automation/PddProductDetailCollector.kt | 91 ++++++++- .../goauto/agent/network/AgentApiClient.kt | 43 +++++ .../agent/persistence/AgentDiagnosticStore.kt | 7 + .../agent/service/AgentForegroundService.kt | 40 +++- .../res/xml/accessibility_service_config.xml | 1 + .../agent/PddProductDetailCollectorTest.kt | 22 +++ docs/02-architecture-and-code-map.md | 26 ++- docs/03-business-rules-and-glossary.md | 15 +- docs/08-agent-api-contract.md | 40 +++- server/app/goauto/migrations/migrate.go | 1 + server/app/goauto/models/schema.go | 23 +++ server/app/goauto/product/service.go | 22 ++- server/app/goauto/product/service_test.go | 29 +++ server/app/goauto/task/color_image_service.go | 181 ++++++++++++++++++ .../goauto/task/color_image_service_test.go | 88 +++++++++ server/app/goauto/task/handler.go | 40 ++++ server/app/goauto/task/router.go | 1 + server/app/goauto/task/service.go | 1 + .../1787885500000_pdd_color_image.go | 26 +++ web/src/views/goauto/pdd-products/index.vue | 6 +- 23 files changed, 762 insertions(+), 22 deletions(-) create mode 100644 server/app/goauto/task/color_image_service.go create mode 100644 server/app/goauto/task/color_image_service_test.go create mode 100644 server/cmd/migrate/migration/version-local/1787885500000_pdd_color_image.go diff --git a/AGENTS.md b/AGENTS.md index a77f96a..3a6517e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -27,7 +27,7 @@ - 不把密码、Token、Cookie、私钥、PDD 账号凭据、个人数据或生产数据写入代码、日志、工单和文档。例外:经用户于 2026-08-21 明确确认的 #62 内部 AI Provider API Key,可明文保存在专用 `ai_matching_setting` 数据表,并只返回给管理员用于下次查看和替换;它仍不得出现在代码、日志、工单、Wiki、任务快照、采购员接口或 Android 接口中。 - 不执行付款。当前项目不实现任何自动支付动作、入口或测试;后续如需实现,必须单独建单评估,并至少具备显式能力位、服务端开关、单笔金额上限与人工授权四项控制。支付、下单和订单相关文字允许作为只读识别信号出现在采集与采购规则中,用于判断页面形态;任何规则都不得把它们配置为点击目标。 - 当前采集 MVP 只实现 PDD 商品、规则、任务、Android 执行和任务详情。采购是独立的后续高风险 MVP,未通过对应原型和工单门禁前不能混入采集代码;采集规则可以描述订单确认面板的只读特征,这不构成采购代码混入采集。 -- 不保存原始控件树和设备截图;只保存结构化任务日志、错误码、任务规则快照和采集结果。 +- 不保存原始控件树和整屏设备截图;允许保存按规格值节点边界裁剪的商品图片区域,且裁剪结果不得包含账号、地址、订单、支付及其他个人数据。除此之外只保存结构化任务日志、错误码、任务规则快照和采集结果。 - 一台设备同一时刻只执行一个任务;手机离线时当前采集任务失败,默认不重试、不自动换机。 - Android Agent 端:找不到控件、验证码、风控、人机验证或登录失效时明确失败,不使用 OCR/VLM。 - 服务端顺云宝(SYB)登录:允许调用配置的线上 OCR 服务识别登录验证码(见 #48)。验证码图片会离开本项目发送到该服务,更换服务地址前必须重新评估。此例外只适用于 SYB 登录,不扩大到 Agent 端或任何 PDD 相关流程。 diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 1c19347..7532268 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 = 30 - versionName = "0.9.17" + versionCode = 31 + versionName = "0.9.18" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" diff --git a/android/app/src/main/java/cn/ilapage/goauto/agent/automation/GoAutoAccessibilityService.kt b/android/app/src/main/java/cn/ilapage/goauto/agent/automation/GoAutoAccessibilityService.kt index c5e493b..581fc96 100644 --- a/android/app/src/main/java/cn/ilapage/goauto/agent/automation/GoAutoAccessibilityService.kt +++ b/android/app/src/main/java/cn/ilapage/goauto/agent/automation/GoAutoAccessibilityService.kt @@ -8,18 +8,21 @@ import android.content.ComponentName import android.content.pm.PackageManager import android.graphics.Path import android.graphics.Rect +import android.graphics.Bitmap import android.os.Build import android.os.Bundle import android.os.Handler import android.os.Looper import android.os.SystemClock import android.util.Log +import android.view.Display import android.view.accessibility.AccessibilityEvent import android.view.accessibility.AccessibilityNodeInfo import java.util.concurrent.CountDownLatch import java.util.concurrent.TimeUnit import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicLong +import java.io.ByteArrayOutputStream class GoAutoAccessibilityService : AccessibilityService(), UiDriver, PddCollectorDriver, PurchaseUiDriver { private val foregroundRevision = AtomicLong(0L) @@ -205,6 +208,76 @@ class GoAutoAccessibilityService : AccessibilityService(), UiDriver, PddCollecto return UiSnapshot(rootPackage, rootActivity, nodes) } + override fun captureColorImages(bounds: List): List { + if (bounds.isEmpty()) return emptyList() + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R || Looper.myLooper() == Looper.getMainLooper()) { + return bounds.map { ColorImageCaptureResult(ColorImageCaptureStatus.UNSUPPORTED) } + } + val latch = CountDownLatch(1) + var screenshot: Bitmap? = null + var captureFailed = false + takeScreenshot( + Display.DEFAULT_DISPLAY, + mainExecutor, + object : TakeScreenshotCallback { + override fun onSuccess(result: ScreenshotResult) { + val buffer = result.hardwareBuffer + try { + val hardware = Bitmap.wrapHardwareBuffer(buffer, result.colorSpace) + screenshot = hardware?.copy(Bitmap.Config.ARGB_8888, false) + hardware?.recycle() + if (screenshot == null) captureFailed = true + } catch (_: RuntimeException) { + captureFailed = true + } finally { + buffer.close() + latch.countDown() + } + } + + override fun onFailure(errorCode: Int) { + captureFailed = true + latch.countDown() + } + }, + ) + if (!latch.await(3, TimeUnit.SECONDS) || captureFailed) { + screenshot?.recycle() + return bounds.map { ColorImageCaptureResult(ColorImageCaptureStatus.UNSUPPORTED) } + } + val full = screenshot ?: return bounds.map { ColorImageCaptureResult(ColorImageCaptureStatus.UNSUPPORTED) } + return try { + bounds.map { target -> cropColorImage(full, target) } + } finally { + full.recycle() + } + } + + private fun cropColorImage(full: Bitmap, target: NodeBounds): ColorImageCaptureResult { + if (!ColorImageCropPolicy.isInside(full.width, full.height, target)) { + return ColorImageCaptureResult(ColorImageCaptureStatus.CROP_FAILED) + } + val cropped = try { + Bitmap.createBitmap(full, target.left, target.top, target.width, target.height) + } catch (_: RuntimeException) { + return ColorImageCaptureResult(ColorImageCaptureStatus.CROP_FAILED) + } + return try { + val output = ByteArrayOutputStream() + val compressed = cropped.compress(Bitmap.CompressFormat.JPEG, COLOR_IMAGE_JPEG_QUALITY, output) + val bytes = output.toByteArray() + if (!compressed || bytes.isEmpty() || bytes.size > COLOR_IMAGE_MAX_BYTES) { + ColorImageCaptureResult(ColorImageCaptureStatus.COMPRESS_FAILED) + } else { + ColorImageCaptureResult(ColorImageCaptureStatus.SUCCESS, bytes) + } + } catch (_: RuntimeException) { + ColorImageCaptureResult(ColorImageCaptureStatus.COMPRESS_FAILED) + } finally { + cropped.recycle() + } + } + override fun clickFresh(target: SnapshotNode): FreshActionResult = clickFreshDetailed(target).result override fun clickFreshDetailed(target: SnapshotNode): FreshClickOutcome { @@ -551,6 +624,8 @@ class GoAutoAccessibilityService : AccessibilityService(), UiDriver, PddCollecto companion object { private const val PDD_PACKAGE = "com.xunmeng.pinduoduo" + private const val COLOR_IMAGE_JPEG_QUALITY = 85 + private const val COLOR_IMAGE_MAX_BYTES = 512 * 1024 @Volatile var instance: GoAutoAccessibilityService? = null private set diff --git a/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PddProductDetailCollector.kt b/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PddProductDetailCollector.kt index aef4171..d5584ca 100644 --- a/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PddProductDetailCollector.kt +++ b/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PddProductDetailCollector.kt @@ -68,9 +68,26 @@ interface PddCollectorDriver { fun pullDownSpecPanel(anchor: SnapshotNode): Boolean = swipeSpec(SwipeDirection.DOWN, anchor) fun pullDownGoodsPage(): Boolean fun back(): Boolean + fun captureColorImages(bounds: List): List = + bounds.map { ColorImageCaptureResult(ColorImageCaptureStatus.UNSUPPORTED) } } -data class VisibleSpecValue(val text: String, val available: Boolean, val node: SnapshotNode) +enum class ColorImageCaptureStatus { SUCCESS, UNSUPPORTED, CROP_FAILED, COMPRESS_FAILED } +data class ColorImageCaptureResult(val status: ColorImageCaptureStatus, val jpeg: ByteArray? = null) +data class CollectedColorImage(val color: String, val jpeg: ByteArray) + +object ColorImageCropPolicy { + fun isInside(screenWidth: Int, screenHeight: Int, bounds: NodeBounds): Boolean = + screenWidth > 0 && screenHeight > 0 && bounds.width > 0 && bounds.height > 0 && + bounds.left >= 0 && bounds.top >= 0 && bounds.right <= screenWidth && bounds.bottom <= screenHeight +} + +data class VisibleSpecValue( + val text: String, + val available: Boolean, + val node: SnapshotNode, + val imageBounds: NodeBounds? = null, +) data class VisibleDimension(val key: String, val name: String, val values: List) enum class SpecPanelType { UNKNOWN, NORMAL_SCROLLABLE, NON_SCROLLABLE_CONFIRMATION, QUICK_CONFIRMATION, ORDER_CONFIRMATION } @@ -233,7 +250,12 @@ object PddScreenParser { .filterNot { node -> isExcludedOptionLabel(node.label) } .map { val stateText = (listOf(it.label) + descendants(it, visibleNodes).map(SnapshotNode::label)).joinToString(" ") - VisibleSpecValue(it.label, it.enabled && !stateText.containsUnavailableWord(), it) + VisibleSpecValue( + it.label, + it.enabled && !stateText.containsUnavailableWord(), + it, + colorImageBounds(it, visibleNodes), + ) } .toList() .groupBy(VisibleSpecValue::text) @@ -342,6 +364,20 @@ object PddScreenParser { return nodes.filter { it.path.startsWith(prefix) } } + internal fun colorImageBounds(option: SnapshotNode, nodes: List): NodeBounds? { + val expected = option.contentDescription?.replace(Regex("\\s+"), "")?.takeIf(String::isNotEmpty) ?: return null + return descendants(option, nodes).asSequence() + .filter { it.visible && it.className?.endsWith("ImageView") == true } + .filter { it.contentDescription?.replace(Regex("\\s+"), "") == expected } + .filter { + it.bounds.width > 0 && it.bounds.height > 0 && + it.bounds.left >= option.bounds.left && it.bounds.top >= option.bounds.top && + it.bounds.right <= option.bounds.right && it.bounds.bottom <= option.bounds.bottom + } + .maxByOrNull { it.bounds.width.toLong() * it.bounds.height } + ?.bounds + } + private fun isHeading(label: String, config: PddCollectorConfig): Boolean { val compact = label.replace(" ", "").replace(Regex("[((]\\d+[))]$"), "") if (config.textAliases.selection.summaryPrefixes.any(compact::startsWith) || compact in config.textAliases.specPanel.titleAliases) return false @@ -520,6 +556,7 @@ data class PddCollectorResult( val code: String, val message: String, val payload: CollectionPayload? = null, + val colorImages: List = emptyList(), ) private data class StablePriceResult( @@ -826,8 +863,9 @@ class PddProductDetailCollector( val prices = linkedMapOf() val missing = linkedSetOf() val unsupported = linkedSetOf() + val colorImages = linkedMapOf() val specPanelContainer = openedScreen.specPanelContainer - collectColors(goodsId, config, evidence, deadline, specPanelContainer, colors, prices, missing, unsupported)?.let { return it } + collectColors(goodsId, config, evidence, deadline, specPanelContainer, colors, prices, missing, unsupported, colorImages)?.let { return it } moveSpecPanelToTop(goodsId, config, evidence, deadline, specPanelContainer)?.let { return it } val sizes = linkedMapOf() collectSizes(goodsId, config, evidence, deadline, specPanelContainer, sizes, unsupported)?.let { return it } @@ -868,7 +906,13 @@ class PddProductDetailCollector( skus = skus, missing = missing.toList(), ) - return PddCollectorResult(true, "OK", "PDD 商品详情采集完成", payload) + return PddCollectorResult( + true, + "OK", + "PDD 商品详情采集完成", + payload, + colorImages.map { CollectedColorImage(it.key, it.value) }, + ) } private fun collectColors( @@ -881,6 +925,7 @@ class PddProductDetailCollector( prices: LinkedHashMap, missing: MutableSet, unsupported: MutableSet, + colorImages: LinkedHashMap, ): PddCollectorResult? { val diagnosticStartedAt = now() val diagnosticRowValues = linkedMapOf>() @@ -891,6 +936,36 @@ class PddProductDetailCollector( var diagnosticSelectedSummaryPresent = false var diagnosticHorizontalSwipes = 0 var diagnosticTermination = AgentDiagnosticReason.COLOR_FOUND + val imageAttempts = mutableSetOf() + + fun collectVisibleImages(values: List) { + val fresh = values.distinctBy(VisibleSpecValue::text).filter { it.text !in imageAttempts } + if (fresh.isEmpty()) return + fresh.forEach { imageAttempts += it.text } + val located = fresh.filter { it.imageBounds != null } + repeat(fresh.size - located.size) { + recordColorImage(AgentDiagnosticReason.COLOR_IMAGE_LOCATE_FAILED) + } + if (located.isEmpty()) return + val outcomes = driver.captureColorImages(located.map { requireNotNull(it.imageBounds) }) + located.forEachIndexed { index, value -> + val outcome = outcomes.getOrNull(index) ?: ColorImageCaptureResult(ColorImageCaptureStatus.CROP_FAILED) + when (outcome.status) { + ColorImageCaptureStatus.SUCCESS -> outcome.jpeg?.let { + val total = colorImages.values.sumOf(ByteArray::size) + if (colorImages.size >= COLOR_IMAGE_MAX_COUNT || total + it.size > COLOR_IMAGE_MAX_TOTAL_BYTES) { + recordColorImage(AgentDiagnosticReason.COLOR_IMAGE_COMPRESS_FAILED) + } else { + colorImages[value.text] = it + recordColorImage(AgentDiagnosticReason.COLOR_IMAGE_CAPTURED) + } + } ?: recordColorImage(AgentDiagnosticReason.COLOR_IMAGE_COMPRESS_FAILED) + ColorImageCaptureStatus.UNSUPPORTED -> recordColorImage(AgentDiagnosticReason.COLOR_IMAGE_UNSUPPORTED) + ColorImageCaptureStatus.CROP_FAILED -> recordColorImage(AgentDiagnosticReason.COLOR_IMAGE_CROP_FAILED) + ColorImageCaptureStatus.COMPRESS_FAILED -> recordColorImage(AgentDiagnosticReason.COLOR_IMAGE_COMPRESS_FAILED) + } + } + } fun observeColorDiscovery(screen: ParsedPddScreen, rows: List>) { rows.forEachIndexed { index, row -> @@ -902,6 +977,7 @@ class PddProductDetailCollector( diagnosticNonClickableCandidates, visibleNonClickableColorCandidateCount(screen, config, values.map(VisibleSpecValue::text).toSet()), ) + collectVisibleImages(values) } fun finishColorDiscovery(reason: AgentDiagnosticReason = diagnosticTermination) { @@ -1060,6 +1136,11 @@ class PddProductDetailCollector( return null } + private fun recordColorImage(reason: AgentDiagnosticReason) { + if (taskId <= 0) return + diagnostic(AgentDiagnosticEvent(taskId = taskId, stage = AgentDiagnosticStage.COLOR_IMAGE, reason = reason, attempt = 1)) + } + private fun recordColorDiscovery( reason: AgentDiagnosticReason, rowValueCounts: Map, @@ -1473,6 +1554,8 @@ class PddProductDetailCollector( private companion object { const val PRICE_SETTLE_MILLIS = 300L + const val COLOR_IMAGE_MAX_COUNT = 64 + const val COLOR_IMAGE_MAX_TOTAL_BYTES = 8 * 1024 * 1024 } private fun colorRows(screen: ParsedPddScreen): List> { 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 c9958f7..5387d13 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 @@ -7,6 +7,7 @@ import java.net.HttpURLConnection import java.net.URL import java.net.URLEncoder import java.util.UUID +import java.io.ByteArrayOutputStream data class DeviceInfo( val installId: String, @@ -249,6 +250,48 @@ class AgentApiClient(private val serverUrl: String) { requireNotNull(request("POST", "/api/agent/v1/tasks/$taskId/result", payload, token)) } + fun uploadColorImage(taskId: Long, color: String, jpeg: ByteArray, token: String) { + val boundary = "GoAuto-${UUID.randomUUID()}" + val body = ByteArrayOutputStream().apply { + fun writeText(value: String) = write(value.toByteArray(Charsets.UTF_8)) + writeText("--$boundary\r\nContent-Disposition: form-data; name=\"color\"\r\n\r\n") + writeText(color) + writeText("\r\n--$boundary\r\nContent-Disposition: form-data; name=\"file\"; filename=\"color.jpg\"\r\n") + writeText("Content-Type: image/jpeg\r\n\r\n") + write(jpeg) + writeText("\r\n--$boundary--\r\n") + }.toByteArray() + val connection = (URL(serverUrl + "/api/agent/v1/tasks/$taskId/color-images").openConnection() as HttpURLConnection).apply { + requestMethod = "POST" + connectTimeout = 5_000 + readTimeout = 5_000 + doOutput = true + useCaches = false + setFixedLengthStreamingMode(body.size) + setRequestProperty("Content-Type", "multipart/form-data; boundary=$boundary") + setRequestProperty("Accept", "application/json") + setRequestProperty("Cache-Control", "no-store") + setRequestProperty("Authorization", "Bearer $token") + } + try { + connection.outputStream.use { it.write(body) } + val status = connection.responseCode + val stream = if (status in 200..299) connection.inputStream else connection.errorStream + val response = stream?.bufferedReader(Charsets.UTF_8)?.use { it.readText() }.orEmpty() + if (status !in 200..299) { + val json = runCatching { JSONObject(response) }.getOrElse { JSONObject() } + throw AgentApiException( + status, + json.optString("code", "HTTP_$status"), + json.optString("message", "颜色图片上传失败"), + json.optBoolean("retryable", status >= 500), + ) + } + } finally { + connection.disconnect() + } + } + fun submitFailure(taskId: Long, requestId: String, code: String, message: String, token: String) { val payload = JSONObject().put("requestId", requestId).put("code", code).put("message", message.take(1000)) requireNotNull(request("POST", "/api/agent/v1/tasks/$taskId/fail", payload, token)) diff --git a/android/app/src/main/java/cn/ilapage/goauto/agent/persistence/AgentDiagnosticStore.kt b/android/app/src/main/java/cn/ilapage/goauto/agent/persistence/AgentDiagnosticStore.kt index 7d39dbe..64f53a6 100644 --- a/android/app/src/main/java/cn/ilapage/goauto/agent/persistence/AgentDiagnosticStore.kt +++ b/android/app/src/main/java/cn/ilapage/goauto/agent/persistence/AgentDiagnosticStore.kt @@ -10,6 +10,7 @@ enum class AgentDiagnosticStage { DETAIL_ENTRY, SPEC_PANEL_ENTRY, COLOR_DISCOVERY, + COLOR_IMAGE, SIZE_DISCOVERY, PAGE_STABILITY, SHARE_CLICK, @@ -43,6 +44,12 @@ enum class AgentDiagnosticReason { COLOR_SWIPE_FAILED, COLOR_CONTAINER_UNAVAILABLE, COLOR_VALUES_NOT_CLICKABLE, + COLOR_IMAGE_CAPTURED, + COLOR_IMAGE_UNSUPPORTED, + COLOR_IMAGE_LOCATE_FAILED, + COLOR_IMAGE_CROP_FAILED, + COLOR_IMAGE_COMPRESS_FAILED, + COLOR_IMAGE_UPLOAD_FAILED, PACKAGE_MISMATCH, ACTIVITY_MISMATCH, SELECTOR_MISMATCH, diff --git a/android/app/src/main/java/cn/ilapage/goauto/agent/service/AgentForegroundService.kt b/android/app/src/main/java/cn/ilapage/goauto/agent/service/AgentForegroundService.kt index fb64d6b..346e69f 100644 --- a/android/app/src/main/java/cn/ilapage/goauto/agent/service/AgentForegroundService.kt +++ b/android/app/src/main/java/cn/ilapage/goauto/agent/service/AgentForegroundService.kt @@ -53,6 +53,9 @@ import cn.ilapage.goauto.agent.network.ServerUrlPolicy import cn.ilapage.goauto.agent.persistence.PurchaseTaskStore import cn.ilapage.goauto.agent.persistence.PurchaseOutboxUploader import cn.ilapage.goauto.agent.persistence.AgentDiagnosticStore +import cn.ilapage.goauto.agent.persistence.AgentDiagnosticEvent +import cn.ilapage.goauto.agent.persistence.AgentDiagnosticReason +import cn.ilapage.goauto.agent.persistence.AgentDiagnosticStage import cn.ilapage.goauto.agent.persistence.SafeAgentDiagnosticRecorder import org.json.JSONArray import org.json.JSONObject @@ -648,7 +651,7 @@ class AgentForegroundService : Service() { val rule = try { RuleParser.parse(task.ruleSnapshot) } catch (error: RuleValidationException) { throw TaskFailure(error.code, error.message ?: "规则快照无效") } - val result = if (task.source == "agent_current_page") { + val execution = if (task.source == "agent_current_page") { if (rule.schemaVersion != 2) { throw TaskFailure("RULE_INVALID", "当前页面采集只支持新版采集规则") } @@ -690,27 +693,49 @@ class AgentForegroundService : Service() { diagnostic = diagnosticRecorder::record, ).collect(identity.goodsId, rule) if (!collection.successful) throw TaskFailure(collection.code, collection.message) - requireNotNull(collection.payload) + CollectionExecution(requireNotNull(collection.payload), collection.colorImages) } else if (rule.schemaVersion == 2) { val trace: (String) -> Unit = { message -> Log.i("GoAutoCollector", message) } val collection = PddDetailEntryRunner( openLink = { PddLinkLauncher(this).open(task.urlSnapshot) }, navigate = { RuleExecutor(accessibility).execute(rule) }, - collect = { PddProductDetailCollector(accessibility, trace = trace).collect(task.goodsIdSnapshot, rule) }, + collect = { + PddProductDetailCollector( + accessibility, + trace = trace, + taskId = task.taskId, + diagnostic = diagnosticRecorder::record, + ).collect(task.goodsIdSnapshot, rule) + }, trace = trace, ).run(rule.reopenBrowserRecovery) if (!collection.successful) throw TaskFailure(collection.code, collection.message) - requireNotNull(collection.payload) + CollectionExecution(requireNotNull(collection.payload), collection.colorImages) } else { if (!PddLinkLauncher(this).open(task.urlSnapshot)) { throw TaskFailure("PDD_LINK_INVALID", "任务中的 PDD 链接无法打开") } val execution = RuleExecutor(accessibility).execute(rule) if (!execution.successful) throw TaskFailure(execution.code, execution.message) - CollectionAssembler.assemble(task.goodsIdSnapshot, execution.extracted) + CollectionExecution(CollectionAssembler.assemble(task.goodsIdSnapshot, execution.extracted)) } + val result = execution.payload api.submitResult(task.taskId, UUID.randomUUID().toString(), result, token) resultSafelySubmitted = true + for (image in execution.colorImages) { + val failure = runCatching { api.uploadColorImage(task.taskId, image.color, image.jpeg, token) }.exceptionOrNull() + if (failure != null) { + diagnosticRecorder.record( + AgentDiagnosticEvent( + taskId = task.taskId, + stage = AgentDiagnosticStage.COLOR_IMAGE, + reason = AgentDiagnosticReason.COLOR_IMAGE_UPLOAD_FAILED, + attempt = 1, + ), + ) + break + } + } stateStore.update("ONLINE", "任务 #${task.taskId} 已提交:${result.status}", tokenStored = true) beginPostCollectionCooldowns() TaskExecutionSummary(successful = true) @@ -738,6 +763,11 @@ class AgentForegroundService : Service() { } } + private data class CollectionExecution( + val payload: cn.ilapage.goauto.agent.automation.CollectionPayload, + val colorImages: List = emptyList(), + ) + @Suppress("DEPRECATION") private fun acquireTaskWakeLock() { val wakeLock = getSystemService(PowerManager::class.java).newWakeLock( diff --git a/android/app/src/main/res/xml/accessibility_service_config.xml b/android/app/src/main/res/xml/accessibility_service_config.xml index 465c82e..c87be70 100644 --- a/android/app/src/main/res/xml/accessibility_service_config.xml +++ b/android/app/src/main/res/xml/accessibility_service_config.xml @@ -5,5 +5,6 @@ android:accessibilityFlags="flagReportViewIds|flagIncludeNotImportantViews|flagRetrieveInteractiveWindows|flagRequestAccessibilityButton" android:canPerformGestures="true" android:canRetrieveWindowContent="true" + android:canTakeScreenshot="true" android:description="@string/accessibility_service_description" android:notificationTimeout="100" /> diff --git a/android/app/src/test/java/cn/ilapage/goauto/agent/PddProductDetailCollectorTest.kt b/android/app/src/test/java/cn/ilapage/goauto/agent/PddProductDetailCollectorTest.kt index 5a7726d..d0c6807 100644 --- a/android/app/src/test/java/cn/ilapage/goauto/agent/PddProductDetailCollectorTest.kt +++ b/android/app/src/test/java/cn/ilapage/goauto/agent/PddProductDetailCollectorTest.kt @@ -2,6 +2,7 @@ package cn.ilapage.goauto.agent import cn.ilapage.goauto.agent.automation.ActivityEvidenceTracker import cn.ilapage.goauto.agent.automation.CollectionRule +import cn.ilapage.goauto.agent.automation.ColorImageCropPolicy import cn.ilapage.goauto.agent.automation.FreshActionResult import cn.ilapage.goauto.agent.automation.NodeBounds import cn.ilapage.goauto.agent.automation.NodeSelector @@ -25,6 +26,27 @@ import org.junit.Assert.assertTrue import org.junit.Test class PddProductDetailCollectorTest { + @Test + fun `color image crop policy accepts exact node bounds and rejects overflow`() { + assertTrue(ColorImageCropPolicy.isInside(1080, 2200, NodeBounds(36, 1188, 352, 1504))) + assertFalse(ColorImageCropPolicy.isInside(1080, 2200, NodeBounds(-1, 1188, 352, 1504))) + assertFalse(ColorImageCropPolicy.isInside(1080, 2200, NodeBounds(36, 1188, 1081, 1504))) + assertFalse(ColorImageCropPolicy.isInside(1080, 2200, NodeBounds(36, 1188, 36, 1504))) + } + + @Test + fun `color image is located only inside its color card subtree`() { + fun snapshotNode(path: String, parent: String?, description: String, bounds: NodeBounds, className: String) = SnapshotNode( + path, parent, null, description, null, className, bounds, + clickable = true, scrollable = false, selected = false, checked = false, enabled = true, visible = true, + ) + val card = snapshotNode("0/1", "0", " 紫色1700", NodeBounds(36, 1188, 352, 1591), "android.view.ViewGroup") + val matching = snapshotNode("0/1/1", "0/1", " 紫色1700", NodeBounds(36, 1188, 352, 1504), "android.widget.ImageView") + val globalDuplicate = snapshotNode("0/2/1", "0/2", " 紫色1700", NodeBounds(382, 1188, 698, 1504), "android.widget.ImageView") + val openLarge = snapshotNode("0/1/2", "0/1", "打开大图", NodeBounds(36, 1188, 147, 1299), "android.widget.ImageView") + + assertEquals(matching.bounds, PddScreenParser.colorImageBounds(card, listOf(card, matching, globalDuplicate, openLarge))) + } @Test fun pageLevelSoldOutRequiresExactTextAndNoNormalProductEvidence() { val soldOut = UiSnapshot(PDD_PACKAGE, ACTIVITY, listOf( diff --git a/docs/02-architecture-and-code-map.md b/docs/02-architecture-and-code-map.md index 5b3d0e1..41208cf 100644 --- a/docs/02-architecture-and-code-map.md +++ b/docs/02-architecture-and-code-map.md @@ -2,8 +2,8 @@ generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件) wiki_page: Architecture-and-Code-Map wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Architecture-and-Code-Map.- -wiki_revision: 40b34ee81f4207de6797b1a81ded6459b6dd776d -synchronized_at: 2026-08-28T14:09:31Z +wiki_revision: 07e1ba0c8ee38e7758cf142a272af95fdd046523 +synchronized_at: 2026-08-28T15:20:51Z # 架构与代码地图 @@ -246,3 +246,25 @@ Android Portal/Agent - 人工维护虾皮规格映射后,同一事务同步分项状态与主表 `completed` / `completed_partial` 聚合;worker 只更新仍为 `matching` 且商品关联、规格 JSON 未变化的行,不覆盖人工结果。 - 纠错使用 `CorrectAndActivate`:原记录转 `superseded`,新建原始失效商品到新目标的记录,只迁移原 `replacement_item` 冻结的虾皮商品集合,不影响共享中间目标的其他商品。 - 历史采购执行身份继续以 `PDDGoodsIDSnapshot` / `PDDURLSnapshot` 为准;当前采购查询未发现按可变 `pdd_product_id` 聚合历史数据的实现,因此本工单无需改写统计 SQL。 + + +## PDD 颜色图片采集与存储(#133) + +```text +PddProductDetailCollector + -> 颜色卡片子树内锁定同 content-desc ImageView bounds + -> GoAutoAccessibilityService.takeScreenshot(仅内存) + -> 按 bounds 裁剪并压缩 JPEG + -> 先 POST /tasks/{taskId}/result + -> 再逐张 POST /tasks/{taskId}/color-images + -> task.UploadColorImage 校验任务/设备/颜色/JPEG/上限 + -> pdd_product_color_image(PDD 商品 + 颜色唯一,保留最新) + -> static/uploadfile/goauto-color/*.jpg + -> product.Service.Detail 返回 colorImages + -> Admin PDD 商品详情 64×64 缩略图、无图占位与点击预览 +``` + +- 整屏 Bitmap 不进入文件系统、网络、数据库或诊断;截图回调完成后在内存中裁剪,用后回收。 +- `pdd_product_color_image` 保存图片路径、类型、字节数、宽高、来源任务与设备;来源任务继续关联其 `rule_snapshot`。同商品同颜色唯一,更新成功后删除被替换的受控目录旧文件。 +- 上传处理沿用既有 `/static/uploadfile` 静态能力,但使用独立 Agent 受控接口和 `goauto-color` 子目录,不复用公共上传入口。 +- 图片旁路独立于结构化结果状态机。Android 先安全提交结果,再上传图片;任何图片异常只写本地脱敏诊断,不触发失败提交或任务状态回滚。 diff --git a/docs/03-business-rules-and-glossary.md b/docs/03-business-rules-and-glossary.md index e9ed72f..077501f 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: 230c6c7c011843c87b64b9a8bca5f17718c2bede -synchronized_at: 2026-08-28T13:41:08Z +wiki_revision: 79bb0b8452c95ca34d3193f0178199614c997edd +synchronized_at: 2026-08-28T15:21:02Z # 业务规则与术语 @@ -339,3 +339,14 @@ synchronized_at: 2026-08-28T13:41:08Z - Agent 的“继续采购”复用既有 `AgentRetry → BatchRetry → Create`,请求仍只有来源任务号和 `requestId`;不新增采购创建接口。旧失败任务保持不可变,创建的新任务重新固化当前替代商品、规格、价格、规则和设备快照。 - `matching` / `manual_required` 只展示匹配状态;`matched` 但资格不通过时展示服务端下发原因。Admin 批量重试行为不变。 - 真机点击“继续采购”可能创建新的正式采购任务,必须先取得独立人工授权;永久禁止支付。 + + +## 颜色规格图片(#133) + +- 颜色图片是 PDD 商品最新档案的可选数据;缺失、截图不支持、定位、裁剪、压缩或上传失败均不得加入 `missing`,也不得改变 `completed` / `completed_partial` 或既有规格、价格结果。 +- Android Agent 仅在既有颜色遍历中,在颜色卡片子树内查找与该卡片同 `content-desc` 的 `ImageView`;禁止全局查找同名图片节点,不新增颜色点击、页面跳转或用户操作。 +- API 30 及以上设备可以在内存中取得整屏位图,并严格按图片节点边界裁剪为 JPEG(质量 85);整屏位图用后立即回收,不落盘、不上传、不入库。低于 API 30 或截图受限时安全跳过。 +- 单张 JPEG 最大 512 KiB、最长边不超过 1024 像素;Agent 单任务最多保留 64 张、总量最多 8 MiB。结果提交成功后才逐张独立上传,上传不重试到影响任务时长。 +- 服务端只接受该任务已提交颜色维度中的 JPEG,校验 Device Token、任务归属、终态、内容、尺寸和数量;按“PDD 商品 + 颜色值”维护最新一张,记录来源 task、device,并沿任务关联不可变规则快照。 +- 只允许保存裁剪后的商品图片区域;裁剪结果不得包含账号、地址、订单、支付及其他个人数据。原始控件树、XML、整屏截图仍禁止保存。 +- 诊断阶段 `COLOR_IMAGE` 只记录成功及不支持、定位失败、裁剪失败、压缩失败、上传失败分类,不记录颜色文案、坐标或图片内容。 diff --git a/docs/08-agent-api-contract.md b/docs/08-agent-api-contract.md index 26947b0..e623232 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: c7dd62e1d67e725a83bb343d1a25f18a49b68c8e -synchronized_at: 2026-08-28T12:44:25Z +wiki_revision: 95b3e58fc27b2332dde630ffa0d1dab7fc905f80 +synchronized_at: 2026-08-28T15:22:03Z # MVP 共享 API 契约 @@ -702,3 +702,39 @@ Content-Type: application/json - 详情资格检查不调用 AI,也不把重新推导出的候选当成已确认映射。资格不通过时返回普通人可理解的 `continuePurchaseDisabledReason`;`matching` / `manual_required` 继续使用对应状态文字且不显示按钮。 - 点击仍调用既有 `POST /api/agent/v1/purchase-tasks/{taskId}/retry`,请求体仍为 `{"requestId":""}`。AgentRetry 在委托 BatchRetry 前重新校验 replacement item 与继续资格,随后由 BatchRetry/Create 执行最终档案和并发校验;没有新增采购任务创建路径。 - Admin 的 BatchRetry 接口和行为不变。相同 `requestId` 重放返回同一新任务;不同 requestId 再点由最新任务门禁拒绝。 + + +## 采集颜色图片上传(#133) + +结构化采集结果成功提交后,Agent 可为任务已采集的颜色逐张调用: + +```http +POST /api/agent/v1/tasks/{taskId}/color-images +Authorization: Bearer +Content-Type: multipart/form-data + +color=<任务结果中的精确颜色值> +file= +``` + +成功返回 HTTP 201: + +```json +{ + "data": { + "color": "紫色", + "imagePath": "/static/uploadfile/goauto-color/.jpg", + "width": 316, + "height": 316 + } +} +``` + +契约约束: + +- 必须先成功调用 `POST /api/agent/v1/tasks/{taskId}/result`;任务须为当前 Device Token 所属设备的 `completed` 或 `completed_partial` 任务。 +- `color` 必须逐字匹配该任务持久化的 `color` 维度值;服务端不做相近颜色推断。 +- 只接受实际内容为 JPEG 的文件;单张最大 512 KiB,宽高必须为正且均不超过 1024;单任务最多 64 张。 +- 同一 PDD 商品和颜色重复上传以最新图片为准。响应路径供 Admin 商品详情展示,不返回整屏截图、控件树、XML 或个人数据。 +- 接口独立于结果提交。任一上传失败都不得修改任务状态、`missing`、规格、颜色价格或 SKU;Agent 不进行会拖长任务的重试。 +- 典型错误:`COLOR_IMAGE_TOO_LARGE`(HTTP 413)、`COLOR_IMAGE_UNSUPPORTED`(HTTP 415)、`COLOR_IMAGE_INVALID`(HTTP 422)、任务/设备/状态冲突(HTTP 409)。 diff --git a/server/app/goauto/migrations/migrate.go b/server/app/goauto/migrations/migrate.go index a031607..ccffe69 100644 --- a/server/app/goauto/migrations/migrate.go +++ b/server/app/goauto/migrations/migrate.go @@ -39,6 +39,7 @@ func MigratedModels() []any { &models.CollectionDimension{}, &models.CollectionDimensionValue{}, &models.CollectionColorPrice{}, + &models.PDDProductColorImage{}, &models.CollectionSKU{}, &models.CollectionSKUValue{}, &models.PDDProductReplacement{}, diff --git a/server/app/goauto/models/schema.go b/server/app/goauto/models/schema.go index 4d32edc..0b95309 100644 --- a/server/app/goauto/models/schema.go +++ b/server/app/goauto/models/schema.go @@ -257,6 +257,29 @@ type CollectionColorPrice struct { func (CollectionColorPrice) TableName() string { return "collection_color_price" } +// PDDProductColorImage keeps only the latest optional cropped product image +// for one product/color pair. The source task and device preserve the audit +// link to the immutable task rule snapshot without storing a full screenshot. +type PDDProductColorImage struct { + ID uint64 `json:"id" gorm:"primaryKey;autoIncrement"` + PDDProductID uint64 `json:"pddProductId" gorm:"not null;uniqueIndex:ux_pdd_product_color_image,priority:1"` + PDDProduct PDDProduct `json:"-" gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE"` + Color string `json:"color" gorm:"size:255;not null;uniqueIndex:ux_pdd_product_color_image,priority:2"` + ImagePath string `json:"imagePath" gorm:"size:512;not null"` + ContentType string `json:"contentType" gorm:"size:32;not null"` + ByteSize int64 `json:"byteSize" gorm:"not null;check:ck_pdd_product_color_image_size,byte_size > 0"` + Width int `json:"width" gorm:"not null;check:ck_pdd_product_color_image_width,width > 0"` + Height int `json:"height" gorm:"not null;check:ck_pdd_product_color_image_height,height > 0"` + SourceTaskID uint64 `json:"sourceTaskId" gorm:"not null;index"` + SourceTask CollectionTask `json:"-" gorm:"constraint:OnUpdate:CASCADE,OnDelete:RESTRICT"` + SourceDeviceID uint64 `json:"sourceDeviceId" gorm:"not null;index"` + SourceDevice AgentDevice `json:"-" gorm:"constraint:OnUpdate:CASCADE,OnDelete:RESTRICT"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +func (PDDProductColorImage) TableName() string { return "pdd_product_color_image" } + type CollectionSKU struct { ID uint64 `json:"id" gorm:"primaryKey;autoIncrement"` TaskID uint64 `json:"taskId" gorm:"not null;index;uniqueIndex:ux_collection_sku_task_spec,priority:1"` diff --git a/server/app/goauto/product/service.go b/server/app/goauto/product/service.go index db0f51d..939ac15 100644 --- a/server/app/goauto/product/service.go +++ b/server/app/goauto/product/service.go @@ -8,6 +8,7 @@ import ( "net/url" "regexp" "strings" + "time" "go-admin/app/goauto/models" @@ -78,7 +79,16 @@ type ListRequest struct { type ProductView struct { models.PDDProduct - Specs []SpecDimension `json:"specs"` + Specs []SpecDimension `json:"specs"` + ColorImages []ColorImageView `json:"colorImages,omitempty"` +} + +type ColorImageView struct { + Color string `json:"color"` + ImagePath string `json:"imagePath"` + Width int `json:"width"` + Height int `json:"height"` + UpdatedAt time.Time `json:"updatedAt"` } type ListProductView struct { @@ -221,6 +231,16 @@ func (service *Service) Detail(ctx context.Context, productID uint64) (DetailRes if err != nil { return DetailResponse{}, err } + var images []models.PDDProductColorImage + if err := service.DB.WithContext(ctx).Where("pdd_product_id = ?", productID).Order("color ASC").Find(&images).Error; err != nil { + return DetailResponse{}, internalError(err) + } + items[0].ColorImages = make([]ColorImageView, 0, len(images)) + for _, item := range images { + items[0].ColorImages = append(items[0].ColorImages, ColorImageView{ + Color: item.Color, ImagePath: item.ImagePath, Width: item.Width, Height: item.Height, UpdatedAt: item.UpdatedAt, + }) + } return DetailResponse{Product: items[0]}, nil } diff --git a/server/app/goauto/product/service_test.go b/server/app/goauto/product/service_test.go index 786af48..726e74c 100644 --- a/server/app/goauto/product/service_test.go +++ b/server/app/goauto/product/service_test.go @@ -6,6 +6,7 @@ import ( "fmt" "strings" "testing" + "time" "go-admin/app/goauto/migrations" "go-admin/app/goauto/models" @@ -56,6 +57,34 @@ func TestNormalizeURL(t *testing.T) { } } +func TestDetailReturnsLatestColorImages(t *testing.T) { + db := openProductDatabase(t) + product := models.PDDProduct{GoodsID: "729481506342", URL: "https://mobile.yangkeduo.com/goods.html?goods_id=729481506342", SpecsJSON: "[]"} + if err := db.Create(&product).Error; err != nil { + t.Fatal(err) + } + device := models.AgentDevice{InstallID: "detail-image", Name: "device", Manufacturer: "test", Model: "test", AndroidVersion: "14", AgentVersion: "1", PDDVersion: "1", Status: models.DeviceStatusOnline, TokenDigest: "digest", TokenIssuedAt: time.Now()} + if err := db.Create(&device).Error; err != nil { + t.Fatal(err) + } + rule := models.CollectionRule{Name: "rule", ContentJSON: `{}`} + if err := db.Create(&rule).Error; err != nil { + t.Fatal(err) + } + task := models.CollectionTask{PDDProductID: &product.ID, RuleID: rule.ID, DeviceID: &device.ID, Source: models.CollectionTaskSourceAdmin, Status: models.TaskStatusCompleted, URLSnapshot: product.URL, GoodsIDSnapshot: product.GoodsID, RuleSnapshot: `{}`} + if err := db.Create(&task).Error; err != nil { + t.Fatal(err) + } + image := models.PDDProductColorImage{PDDProductID: product.ID, Color: "紫色", ImagePath: "/static/uploadfile/goauto-color/test.jpg", ContentType: "image/jpeg", ByteSize: 100, Width: 316, Height: 316, SourceTaskID: task.ID, SourceDeviceID: device.ID} + if err := db.Create(&image).Error; err != nil { + t.Fatal(err) + } + detail, err := NewService(db).Detail(context.Background(), product.ID) + if err != nil || len(detail.Product.ColorImages) != 1 || detail.Product.ColorImages[0].Color != "紫色" { + t.Fatalf("unexpected detail: %+v error=%v", detail, err) + } +} + func TestCreateIsIdempotentAndGoodsIDIsUnique(t *testing.T) { service := NewService(openProductDatabase(t)) request := SaveRequest{RequestID: uuid.NewString(), URL: "https://mobile.yangkeduo.com/goods.html?goods_id=729481506342&utm_source=test"} diff --git a/server/app/goauto/task/color_image_service.go b/server/app/goauto/task/color_image_service.go new file mode 100644 index 0000000..613a3df --- /dev/null +++ b/server/app/goauto/task/color_image_service.go @@ -0,0 +1,181 @@ +package task + +import ( + "bytes" + "context" + "errors" + "image" + _ "image/jpeg" + "net/http" + "os" + "path/filepath" + "strings" + + "go-admin/app/goauto/device" + "go-admin/app/goauto/models" + + "github.com/google/uuid" + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +const ( + CodeColorImageTooLarge = "COLOR_IMAGE_TOO_LARGE" + CodeColorImageUnsupported = "COLOR_IMAGE_UNSUPPORTED" + CodeColorImageInvalid = "COLOR_IMAGE_INVALID" + colorImageMaxBytes = int64(512 << 10) + colorImageRequestMaxBytes = int64(640 << 10) + colorImageMaxPerTask = int64(64) + colorImageMaxDimension = 1024 + colorImagePublicPrefix = "/static/uploadfile/goauto-color/" +) + +type ColorImageUploadResponse struct { + Color string `json:"color"` + ImagePath string `json:"imagePath"` + Width int `json:"width"` + Height int `json:"height"` +} + +func (service *Service) UploadColorImage(ctx context.Context, taskID uint64, color string, content []byte, token string) (ColorImageUploadResponse, error) { + if color == "" || len([]rune(color)) > 255 { + return ColorImageUploadResponse{}, serviceError(CodeColorImageInvalid, "颜色值无效") + } + if int64(len(content)) > colorImageMaxBytes { + return ColorImageUploadResponse{}, serviceError(CodeColorImageTooLarge, "颜色图片超过 512 KiB") + } + if len(content) == 0 || http.DetectContentType(content) != "image/jpeg" { + return ColorImageUploadResponse{}, serviceError(CodeColorImageUnsupported, "仅接受 JPEG 图片") + } + config, _, err := image.DecodeConfig(bytes.NewReader(content)) + if err != nil || config.Width <= 0 || config.Height <= 0 || config.Width > colorImageMaxDimension || config.Height > colorImageMaxDimension { + return ColorImageUploadResponse{}, serviceError(CodeColorImageUnsupported, "JPEG 图片内容或尺寸无效") + } + if _, _, err := validateColorImageTask(ctx, service.DB.WithContext(ctx), taskID, color, token, false); err != nil { + return ColorImageUploadResponse{}, err + } + + directory := service.ColorImageDirectory + if directory == "" { + directory = filepath.FromSlash("static/uploadfile/goauto-color") + } + if err := os.MkdirAll(directory, 0o755); err != nil { + return ColorImageUploadResponse{}, internalError(err) + } + fileName := uuid.NewString() + ".jpg" + fullPath := filepath.Join(directory, fileName) + temporary, err := os.CreateTemp(directory, ".color-image-*.tmp") + if err != nil { + return ColorImageUploadResponse{}, internalError(err) + } + temporaryPath := temporary.Name() + cleanup := func() { _ = os.Remove(temporaryPath); _ = os.Remove(fullPath) } + if _, err = temporary.Write(content); err != nil { + _ = temporary.Close() + cleanup() + return ColorImageUploadResponse{}, internalError(err) + } + if err = temporary.Close(); err != nil { + cleanup() + return ColorImageUploadResponse{}, internalError(err) + } + if err = os.Rename(temporaryPath, fullPath); err != nil { + cleanup() + return ColorImageUploadResponse{}, internalError(err) + } + + publicPath := colorImagePublicPrefix + fileName + var replacedPath string + err = service.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + deviceRecord, task, validationErr := validateColorImageTask(ctx, tx, taskID, color, token, true) + if validationErr != nil { + return validationErr + } + var uploaded int64 + if countErr := tx.Model(&models.PDDProductColorImage{}).Where("source_task_id = ?", taskID).Count(&uploaded).Error; countErr != nil { + return internalError(countErr) + } + var previous models.PDDProductColorImage + previousErr := tx.Where("pdd_product_id = ? AND color = ?", *task.PDDProductID, color).First(&previous).Error + if previousErr == nil { + replacedPath = previous.ImagePath + } else if !errors.Is(previousErr, gorm.ErrRecordNotFound) { + return internalError(previousErr) + } else if uploaded >= colorImageMaxPerTask { + return serviceError(CodeColorImageTooLarge, "单任务颜色图片数量超过 64 张") + } + record := models.PDDProductColorImage{ + PDDProductID: *task.PDDProductID, Color: color, ImagePath: publicPath, ContentType: "image/jpeg", + ByteSize: int64(len(content)), Width: config.Width, Height: config.Height, + SourceTaskID: task.ID, SourceDeviceID: deviceRecord.ID, + } + if saveErr := tx.Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "pdd_product_id"}, {Name: "color"}}, + DoUpdates: clause.AssignmentColumns([]string{"image_path", "content_type", "byte_size", "width", "height", "source_task_id", "source_device_id", "updated_at"}), + }).Create(&record).Error; saveErr != nil { + return internalError(saveErr) + } + return nil + }) + if err != nil { + cleanup() + return ColorImageUploadResponse{}, err + } + removeReplacedColorImage(directory, replacedPath, publicPath) + return ColorImageUploadResponse{Color: color, ImagePath: publicPath, Width: config.Width, Height: config.Height}, nil +} + +func validateColorImageTask(ctx context.Context, db *gorm.DB, taskID uint64, color, token string, lock bool) (models.AgentDevice, models.CollectionTask, error) { + deviceRecord, err := device.NewService(db).Authenticate(ctx, token) + if err != nil { + return models.AgentDevice{}, models.CollectionTask{}, err + } + query := db + if lock { + query = query.Clauses(clause.Locking{Strength: "UPDATE"}) + } + var task models.CollectionTask + if err := query.First(&task, taskID).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return models.AgentDevice{}, models.CollectionTask{}, serviceError(CodeTaskNotFound, "任务不存在") + } + return models.AgentDevice{}, models.CollectionTask{}, internalError(err) + } + if task.DeviceID == nil || *task.DeviceID != deviceRecord.ID { + return models.AgentDevice{}, models.CollectionTask{}, serviceError(CodeTaskAssignedOther, "任务不属于当前设备") + } + if task.Status != models.TaskStatusCompleted && task.Status != models.TaskStatusCompletedPartial { + return models.AgentDevice{}, models.CollectionTask{}, serviceError(CodeTaskStateConflict, "必须先成功提交采集结果") + } + if task.PDDProductID == nil { + return models.AgentDevice{}, models.CollectionTask{}, serviceError(CodeColorImageInvalid, "任务未关联 PDD 商品") + } + base := db.Table("collection_dimension_value AS value"). + Joins("JOIN collection_dimension AS dimension ON dimension.id = value.dimension_id"). + Where("dimension.task_id = ? AND dimension.key = ?", taskID, "color") + var total, matching int64 + if err := base.Count(&total).Error; err != nil { + return models.AgentDevice{}, models.CollectionTask{}, internalError(err) + } + if total > colorImageMaxPerTask { + return models.AgentDevice{}, models.CollectionTask{}, serviceError(CodeColorImageTooLarge, "单任务颜色图片数量超过 64 张") + } + if err := base.Where("value.value = ?", color).Count(&matching).Error; err != nil { + return models.AgentDevice{}, models.CollectionTask{}, internalError(err) + } + if matching != 1 { + return models.AgentDevice{}, models.CollectionTask{}, serviceError(CodeColorImageInvalid, "颜色值不属于该任务的采集结果") + } + return deviceRecord, task, nil +} + +func removeReplacedColorImage(directory, oldPublicPath, newPublicPath string) { + if oldPublicPath == "" || oldPublicPath == newPublicPath || !strings.HasPrefix(oldPublicPath, colorImagePublicPrefix) { + return + } + name := strings.TrimPrefix(oldPublicPath, colorImagePublicPrefix) + if name == "" || filepath.Base(name) != name { + return + } + _ = os.Remove(filepath.Join(directory, name)) +} diff --git a/server/app/goauto/task/color_image_service_test.go b/server/app/goauto/task/color_image_service_test.go new file mode 100644 index 0000000..19b199a --- /dev/null +++ b/server/app/goauto/task/color_image_service_test.go @@ -0,0 +1,88 @@ +package task + +import ( + "bytes" + "context" + "image" + "image/color" + "image/jpeg" + "os" + "testing" + + "go-admin/app/goauto/models" + + "github.com/google/uuid" +) + +func testJPEG(t *testing.T) []byte { + t.Helper() + canvas := image.NewRGBA(image.Rect(0, 0, 32, 32)) + for y := 0; y < 32; y++ { + for x := 0; x < 32; x++ { + canvas.Set(x, y, color.RGBA{R: 110, G: 40, B: 160, A: 255}) + } + } + var output bytes.Buffer + if err := jpeg.Encode(&output, canvas, &jpeg.Options{Quality: 85}); err != nil { + t.Fatal(err) + } + return output.Bytes() +} + +func completedColorTask(t *testing.T) (*Service, models.CollectionTask, string) { + t.Helper() + db := openTaskDatabase(t) + deviceRecord, token := registerTaskDevice(t, db, "color-image-device") + record := createTask(t, db, &deviceRecord.ID) + service := newTaskService(db) + service.ColorImageDirectory = t.TempDir() + if _, err := service.Claim(context.Background(), record.ID, ActionRequest{RequestID: uuid.NewString()}, token); err != nil { + t.Fatal(err) + } + if _, err := service.Start(context.Background(), record.ID, ActionRequest{RequestID: uuid.NewString()}, token); err != nil { + t.Fatal(err) + } + if _, err := service.SubmitResult(context.Background(), record.ID, ResultRequest{ + RequestID: uuid.NewString(), Status: models.TaskStatusCompleted, + Product: ResultProduct{PDDGoodsID: record.GoodsIDSnapshot}, + Dimensions: []ResultDimension{{Key: "color", Name: "颜色", Values: []string{"紫色"}}}, + ColorPrices: []ResultColorPrice{{Color: "紫色", PriceCent: 1200}}, + SKUs: []ResultSKU{{Specs: map[string]string{"color": "紫色"}, PriceCent: 1200, Available: true}}, + }, token); err != nil { + t.Fatal(err) + } + return service, record, token +} + +func TestUploadColorImageValidatesAndKeepsLatest(t *testing.T) { + service, task, token := completedColorTask(t) + first, err := service.UploadColorImage(context.Background(), task.ID, "紫色", testJPEG(t), token) + if err != nil || first.ImagePath == "" || first.Width != 32 || first.Height != 32 { + t.Fatalf("upload: response=%+v error=%v", first, err) + } + second, err := service.UploadColorImage(context.Background(), task.ID, "紫色", testJPEG(t), token) + if err != nil || second.ImagePath == first.ImagePath { + t.Fatalf("replacement: response=%+v error=%v", second, err) + } + var records []models.PDDProductColorImage + if err := service.DB.Find(&records).Error; err != nil || len(records) != 1 || records[0].SourceTaskID != task.ID { + t.Fatalf("unexpected records: %+v error=%v", records, err) + } + entries, err := os.ReadDir(service.ColorImageDirectory) + if err != nil || len(entries) != 1 { + t.Fatalf("old image was not removed: entries=%d error=%v", len(entries), err) + } +} + +func TestUploadColorImageRejectsUnknownColorAndNonJPEG(t *testing.T) { + service, task, token := completedColorTask(t) + if _, err := service.UploadColorImage(context.Background(), task.ID, "紫色", make([]byte, colorImageMaxBytes+1), token); taskErrorCode(t, err) != CodeColorImageTooLarge { + t.Fatalf("expected oversized image rejection: %v", err) + } + if _, err := service.UploadColorImage(context.Background(), task.ID, "紫色", []byte("not-an-image"), token); taskErrorCode(t, err) != CodeColorImageUnsupported { + t.Fatalf("expected unsupported image: %v", err) + } + if _, err := service.UploadColorImage(context.Background(), task.ID, "蓝色", testJPEG(t), token); taskErrorCode(t, err) != CodeColorImageInvalid { + t.Fatalf("expected unknown color rejection: %v", err) + } +} diff --git a/server/app/goauto/task/handler.go b/server/app/goauto/task/handler.go index 5f45779..3149ebe 100644 --- a/server/app/goauto/task/handler.go +++ b/server/app/goauto/task/handler.go @@ -180,6 +180,42 @@ func (handler Handler) Result(context *gin.Context) { context.JSON(http.StatusOK, gin.H{"data": payload}) } +func (handler Handler) ColorImage(context *gin.Context) { + id, err := taskID(context) + if err != nil || id == 0 { + writeError(context, serviceError(device.CodeInvalidRequest, "taskId 无效")) + return + } + context.Request.Body = http.MaxBytesReader(context.Writer, context.Request.Body, colorImageRequestMaxBytes) + file, err := context.FormFile("file") + if err != nil { + writeError(context, serviceError(device.CodeInvalidRequest, "file 必填")) + return + } + stream, err := file.Open() + if err != nil { + writeError(context, internalError(err)) + return + } + defer stream.Close() + content, err := io.ReadAll(io.LimitReader(stream, colorImageMaxBytes+1)) + if err != nil { + writeError(context, internalError(err)) + return + } + service, token, ok := handler.service(context) + if !ok { + return + } + result, err := service.UploadColorImage(context.Request.Context(), id, strings.TrimSpace(context.PostForm("color")), content, token) + if err != nil { + writeError(context, err) + return + } + context.Header("Cache-Control", "no-store") + context.JSON(http.StatusCreated, gin.H{"data": result}) +} + func (handler Handler) Fail(context *gin.Context) { id, err := taskID(context) if err != nil || id == 0 { @@ -285,6 +321,10 @@ func writeError(context *gin.Context, err error) { status = http.StatusForbidden case CodeTaskNotFound: status = http.StatusNotFound + case CodeColorImageTooLarge: + status = http.StatusRequestEntityTooLarge + case CodeColorImageUnsupported: + status = http.StatusUnsupportedMediaType case CodeTaskAlreadyClaimed, CodeTaskAssignedOther, CodeDeviceBusy, CodeDeviceOffline, CodeTaskStateConflict, CodeTaskLeaseExpired, CodeCurrentPageIdentityRequired, CodeCurrentPageIdentityConflict: status = http.StatusConflict case replacement.CodeOriginNotEligible, CodeReplacementActivationFailed: diff --git a/server/app/goauto/task/router.go b/server/app/goauto/task/router.go index e719289..b1a67ec 100644 --- a/server/app/goauto/task/router.go +++ b/server/app/goauto/task/router.go @@ -25,6 +25,7 @@ func InitRouter(engine *gin.Engine, auth *jwt.GinJWTMiddleware) { agent.POST("/tasks/:taskId/claim", handler.Claim) agent.POST("/tasks/:taskId/start", handler.Start) agent.POST("/tasks/:taskId/result", handler.Result) + agent.POST("/tasks/:taskId/color-images", handler.ColorImage) agent.POST("/tasks/:taskId/fail", handler.Fail) admin := engine.Group("/api/admin/v1/collection-tasks").Use(auth.MiddlewareFunc()).Use(middleware.AuthCheckRole()) admin.GET("", handler.AdminList) diff --git a/server/app/goauto/task/service.go b/server/app/goauto/task/service.go index 0e29510..11bcd52 100644 --- a/server/app/goauto/task/service.go +++ b/server/app/goauto/task/service.go @@ -72,6 +72,7 @@ type Service struct { LeaseDuration time.Duration TaskTimeout int ResolveCurrentPageShare func(context.Context, string) (ResolvedPDDShare, error) + ColorImageDirectory string } func NewService(db *gorm.DB) *Service { diff --git a/server/cmd/migrate/migration/version-local/1787885500000_pdd_color_image.go b/server/cmd/migrate/migration/version-local/1787885500000_pdd_color_image.go new file mode 100644 index 0000000..24413f6 --- /dev/null +++ b/server/cmd/migrate/migration/version-local/1787885500000_pdd_color_image.go @@ -0,0 +1,26 @@ +package version_local + +import ( + "runtime" + + goautomigrations "go-admin/app/goauto/migrations" + "go-admin/cmd/migrate/migration" + common "go-admin/common/models" + + "gorm.io/gorm" +) + +// #133 adds the optional latest cropped color image for each PDD product/color. +func init() { + _, fileName, _, _ := runtime.Caller(0) + migration.Migrate.SetVersion(migration.GetFilename(fileName), migratePDDColorImage) +} + +func migratePDDColorImage(db *gorm.DB, version string) error { + return db.Transaction(func(tx *gorm.DB) error { + if err := goautomigrations.Migrate(tx); err != nil { + return err + } + return tx.Create(&common.Migration{Version: version}).Error + }) +} diff --git a/web/src/views/goauto/pdd-products/index.vue b/web/src/views/goauto/pdd-products/index.vue index 4224968..5476f0d 100644 --- a/web/src/views/goauto/pdd-products/index.vue +++ b/web/src/views/goauto/pdd-products/index.vue @@ -70,7 +70,7 @@ {{ detail.product.url }}

商品规格

-
{{ dimension.name }} {{ roleLabel(dimension.role) }}
+
{{ dimension.name }} {{ roleLabel(dimension.role) }}

颜色图片为采集到的可选数据,缺失不影响规格与价格结果。

@@ -184,7 +184,7 @@ export default { async search() { this.query.page = 1; await this.clearLocation(); await this.load() }, async reset() { this.query = { page: 1, pageSize: 20, keyword: '', status: '' }; await this.clearLocation(); await this.load() }, statusMeta(status) { return { pending: { label: '待完善', type: 'warning' }, active: { label: '正常', type: 'success' }, disabled: { label: '已停用', type: 'info' }}[status] || { label: status || '-', type: 'info' } }, - countText(value) { return value === null || value === undefined ? '-' : Number(value).toLocaleString('zh-CN') }, roleLabel(role) { return { color: '颜色', size: '尺码', other: '其他' }[role] || role }, priceText(value) { return value === null || value === undefined ? '未填写' : `¥${(value / 100).toFixed(2)}` }, + countText(value) { return value === null || value === undefined ? '-' : Number(value).toLocaleString('zh-CN') }, roleLabel(role) { return { color: '颜色', size: '尺码', other: '其他' }[role] || role }, priceText(value) { return value === null || value === undefined ? '未填写' : `¥${(value / 100).toFixed(2)}` }, colorImage(color) { return (this.detail.product?.colorImages || []).find(item => item.color === color) || null }, specsSummary(specs = []) { return specs.length ? specs.map(x => `${x.name} ${x.values.length}项`).join(';') : '待完善' }, priceSummary(specs = []) { const p = specs.filter(x => x.role === 'color').flatMap(x => x.values.map(v => v.priceCent).filter(v => v !== null && v !== undefined)); if (!p.length) return '-'; const min = Math.min(...p); const max = Math.max(...p); return min === max ? this.priceText(min) : `${this.priceText(min)}~${this.priceText(max)}` }, isProductSelectable(row) { return row.collectionSelectable === true }, @@ -252,5 +252,5 @@ export default {