feat(goauto): collect cropped color images (#133)

This commit is contained in:
QiuSW
2026-08-28 23:31:23 +08:00
parent 5feb4e8571
commit 6f611a04ef
23 changed files with 762 additions and 22 deletions
+1 -1
View File
@@ -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 相关流程。
+2 -2
View File
@@ -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"
@@ -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<NodeBounds>): List<ColorImageCaptureResult> {
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
@@ -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<NodeBounds>): List<ColorImageCaptureResult> =
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<VisibleSpecValue>)
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<SnapshotNode>): 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<CollectedColorImage> = emptyList(),
)
private data class StablePriceResult(
@@ -826,8 +863,9 @@ class PddProductDetailCollector(
val prices = linkedMapOf<String, Long>()
val missing = linkedSetOf<String>()
val unsupported = linkedSetOf<String>()
val colorImages = linkedMapOf<String, ByteArray>()
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<String, Boolean>()
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<String, Long>,
missing: MutableSet<String>,
unsupported: MutableSet<String>,
colorImages: LinkedHashMap<String, ByteArray>,
): PddCollectorResult? {
val diagnosticStartedAt = now()
val diagnosticRowValues = linkedMapOf<Int, MutableSet<String>>()
@@ -891,6 +936,36 @@ class PddProductDetailCollector(
var diagnosticSelectedSummaryPresent = false
var diagnosticHorizontalSwipes = 0
var diagnosticTermination = AgentDiagnosticReason.COLOR_FOUND
val imageAttempts = mutableSetOf<String>()
fun collectVisibleImages(values: List<VisibleSpecValue>) {
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<List<VisibleSpecValue>>) {
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<Int, Int>,
@@ -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<List<VisibleSpecValue>> {
@@ -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))
@@ -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,
@@ -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<cn.ilapage.goauto.agent.automation.CollectedColorImage> = emptyList(),
)
@Suppress("DEPRECATION")
private fun acquireTaskWakeLock() {
val wakeLock = getSystemService(PowerManager::class.java).newWakeLock(
@@ -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" />
@@ -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(
+24 -2
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Architecture-and-Code-Map
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Architecture-and-Code-Map.-
wiki_revision: 40b34ee81f4207de6797b1a81ded6459b6dd776d
synchronized_at: 2026-08-28T14:09:31Z
wiki_revision: 07e1ba0c8ee38e7758cf142a272af95fdd046523
synchronized_at: 2026-08-28T15:20:51Z
<!-- gitea-wiki-mirror:end -->
# 架构与代码地图
@@ -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 先安全提交结果,再上传图片;任何图片异常只写本地脱敏诊断,不触发失败提交或任务状态回滚。
+13 -2
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Business-Rules-and-Glossary
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Business-Rules-and-Glossary.-
wiki_revision: 230c6c7c011843c87b64b9a8bca5f17718c2bede
synchronized_at: 2026-08-28T13:41:08Z
wiki_revision: 79bb0b8452c95ca34d3193f0178199614c997edd
synchronized_at: 2026-08-28T15:21:02Z
<!-- gitea-wiki-mirror:end -->
# 业务规则与术语
@@ -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` 只记录成功及不支持、定位失败、裁剪失败、压缩失败、上传失败分类,不记录颜色文案、坐标或图片内容。
+38 -2
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Android-Agent-API-Contract
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Android-Agent-API-Contract.-
wiki_revision: c7dd62e1d67e725a83bb343d1a25f18a49b68c8e
synchronized_at: 2026-08-28T12:44:25Z
wiki_revision: 95b3e58fc27b2332dde630ffa0d1dab7fc905f80
synchronized_at: 2026-08-28T15:22:03Z
<!-- gitea-wiki-mirror:end -->
# MVP 共享 API 契约
@@ -702,3 +702,39 @@ Content-Type: application/json
- 详情资格检查不调用 AI,也不把重新推导出的候选当成已确认映射。资格不通过时返回普通人可理解的 `continuePurchaseDisabledReason`;`matching` / `manual_required` 继续使用对应状态文字且不显示按钮。
- 点击仍调用既有 `POST /api/agent/v1/purchase-tasks/{taskId}/retry`,请求体仍为 `{"requestId":"<uuid>"}`。AgentRetry 在委托 BatchRetry 前重新校验 replacement item 与继续资格,随后由 BatchRetry/Create 执行最终档案和并发校验;没有新增采购任务创建路径。
- Admin 的 BatchRetry 接口和行为不变。相同 `requestId` 重放返回同一新任务;不同 requestId 再点由最新任务门禁拒绝。
## 采集颜色图片上传(#133)
结构化采集结果成功提交后,Agent 可为任务已采集的颜色逐张调用:
```http
POST /api/agent/v1/tasks/{taskId}/color-images
Authorization: Bearer <device-token>
Content-Type: multipart/form-data
color=<任务结果中的精确颜色值>
file=<JPEG 二进制>
```
成功返回 HTTP 201:
```json
{
"data": {
"color": "紫色",
"imagePath": "/static/uploadfile/goauto-color/<uuid>.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)。
+1
View File
@@ -39,6 +39,7 @@ func MigratedModels() []any {
&models.CollectionDimension{},
&models.CollectionDimensionValue{},
&models.CollectionColorPrice{},
&models.PDDProductColorImage{},
&models.CollectionSKU{},
&models.CollectionSKUValue{},
&models.PDDProductReplacement{},
+23
View File
@@ -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"`
+21 -1
View File
@@ -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
}
+29
View File
@@ -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"}
@@ -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))
}
@@ -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)
}
}
+40
View File
@@ -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:
+1
View File
@@ -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)
+1
View File
@@ -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 {
@@ -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
})
}
+3 -3
View File
@@ -70,7 +70,7 @@
<el-descriptions-item label="商品链接" :span="2"><a class="link" :href="detail.product.url" target="_blank" rel="noopener noreferrer">{{ detail.product.url }}</a></el-descriptions-item>
</el-descriptions>
<h3 class="section-title">商品规格</h3><el-empty v-if="!detail.product.specs.length" description="尚未维护规格" />
<div v-for="dimension in detail.product.specs" :key="dimension.name" class="spec-block"><div class="spec-title">{{ dimension.name }} <el-tag size="small" effect="plain">{{ roleLabel(dimension.role) }}</el-tag></div><el-table :data="dimension.values" border size="small"><el-table-column label="规格值" prop="name" /><el-table-column label="是否可选择" width="120"><template #default="{ row }">{{ row.selectable ? '可选择' : '不可选择' }}</template></el-table-column><el-table-column v-if="dimension.role === 'color'" label="价格" width="130"><template #default="{ row }">{{ priceText(row.priceCent) }}</template></el-table-column></el-table></div>
<div v-for="dimension in detail.product.specs" :key="dimension.name" class="spec-block"><div class="spec-title">{{ dimension.name }} <el-tag size="small" effect="plain">{{ roleLabel(dimension.role) }}</el-tag></div><el-table :data="dimension.values" border size="small"><el-table-column v-if="dimension.role === 'color'" label="颜色图片" width="96" align="center"><template #default="{ row }"><el-image v-if="colorImage(row.name)" class="color-thumb" :src="colorImage(row.name).imagePath" :preview-src-list="[colorImage(row.name).imagePath]" :alt="`${row.name}颜色商品图`" fit="cover" lazy preview-teleported /><span v-else class="color-image-placeholder" role="img" :aria-label="`${row.name}暂无图片`">暂无图片</span></template></el-table-column><el-table-column label="规格值" prop="name" /><el-table-column label="是否可选择" width="120"><template #default="{ row }">{{ row.selectable ? '可选择' : '不可选择' }}</template></el-table-column><el-table-column v-if="dimension.role === 'color'" label="价格" width="130"><template #default="{ row }">{{ priceText(row.priceCent) }}</template></el-table-column></el-table><p v-if="dimension.role === 'color'" class="color-image-note">颜色图片为采集到的可选数据,缺失不影响规格与价格结果。</p></div>
</template>
</div>
</el-drawer>
@@ -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 {
</script>
<style lang="scss" scoped>
.page-card { min-height: calc(100vh - 124px); }.page-heading { display:flex;align-items:flex-start;justify-content:space-between;gap:16px;margin-bottom:24px;h1{margin:0 0 6px;font-size:24px;color:#1f2937}p{margin:0;color:#606266;line-height:1.5}}.search-form{padding:16px 16px 0;margin-bottom:16px;border:1px solid #e5e7eb;border-radius:8px;background:#f8fafc}.location-notice{margin-bottom:12px}.batch-toolbar{display:flex;align-items:center;gap:12px;min-height:48px;padding:8px 12px;margin-bottom:12px;border:1px solid #dbeafe;border-radius:8px;background:#f8fafc}.batch-toolbar strong{font-variant-numeric:tabular-nums;color:#1e40af}.primary{font-weight:600;color:#1f2937}.muted{font-size:12px;color:#909399}.collection-ready{margin-top:4px;font-size:12px;color:#1677ff}.notice{margin-bottom:20px}.batch-summary{display:flex;align-items:center;gap:12px;margin-bottom:16px;color:#606266}.batch-form{display:grid;grid-template-columns:1fr 1fr;gap:20px}.drawer-body{padding:0 4px 24px}.drawer-actions,.section-heading,.dimension-header{display:flex;align-items:center;gap:12px}.drawer-actions,.section-heading{justify-content:space-between}.drawer-actions>div{display:flex;gap:8px}.section-title{margin:28px 0 14px}.spec-block{margin-bottom:20px}.spec-title{display:flex;align-items:center;gap:8px;margin-bottom:8px;font-weight:600}.link{color:#1677ff;overflow-wrap:anywhere}.form-grid{display:grid;grid-template-columns:1fr 1fr;gap:0 20px}.stock-help{margin:0 0 14px;color:#606266;font-size:13px;line-height:1.6}.stock-boundary{margin-bottom:0}.section-heading{margin:12px 0;h3{margin:0}}.dimension-card{margin-bottom:16px}.dimension-header{margin-bottom:14px}.dimension-header .el-input{flex:1}.add-value{margin-top:12px}@media(max-width:768px){.page-heading,.dimension-header{align-items:stretch;flex-direction:column}.batch-toolbar,.batch-summary{align-items:flex-start;flex-direction:column}.batch-form,.form-grid{grid-template-columns:1fr}}
.page-card { min-height: calc(100vh - 124px); }.page-heading { display:flex;align-items:flex-start;justify-content:space-between;gap:16px;margin-bottom:24px;h1{margin:0 0 6px;font-size:24px;color:#1f2937}p{margin:0;color:#606266;line-height:1.5}}.search-form{padding:16px 16px 0;margin-bottom:16px;border:1px solid #e5e7eb;border-radius:8px;background:#f8fafc}.location-notice{margin-bottom:12px}.batch-toolbar{display:flex;align-items:center;gap:12px;min-height:48px;padding:8px 12px;margin-bottom:12px;border:1px solid #dbeafe;border-radius:8px;background:#f8fafc}.batch-toolbar strong{font-variant-numeric:tabular-nums;color:#1e40af}.primary{font-weight:600;color:#1f2937}.muted{font-size:12px;color:#909399}.collection-ready{margin-top:4px;font-size:12px;color:#1677ff}.notice{margin-bottom:20px}.batch-summary{display:flex;align-items:center;gap:12px;margin-bottom:16px;color:#606266}.batch-form{display:grid;grid-template-columns:1fr 1fr;gap:20px}.drawer-body{padding:0 4px 24px}.drawer-actions,.section-heading,.dimension-header{display:flex;align-items:center;gap:12px}.drawer-actions,.section-heading{justify-content:space-between}.drawer-actions>div{display:flex;gap:8px}.section-title{margin:28px 0 14px}.spec-block{margin-bottom:20px}.spec-title{display:flex;align-items:center;gap:8px;margin-bottom:8px;font-weight:600}.color-thumb,.color-image-placeholder{display:inline-flex;width:64px;height:64px;border-radius:6px}.color-thumb{cursor:zoom-in}.color-image-placeholder{align-items:center;justify-content:center;box-sizing:border-box;border:1px dashed #dcdfe6;background:#f5f7fa;color:#909399;font-size:12px}.color-image-note{margin:8px 0 0;color:#909399;font-size:12px;line-height:1.5}.link{color:#1677ff;overflow-wrap:anywhere}.form-grid{display:grid;grid-template-columns:1fr 1fr;gap:0 20px}.stock-help{margin:0 0 14px;color:#606266;font-size:13px;line-height:1.6}.stock-boundary{margin-bottom:0}.section-heading{margin:12px 0;h3{margin:0}}.dimension-card{margin-bottom:16px}.dimension-header{margin-bottom:14px}.dimension-header .el-input{flex:1}.add-value{margin-top:12px}@media(max-width:768px){.page-heading,.dimension-header{align-items:stretch;flex-direction:column}.batch-toolbar,.batch-summary{align-items:flex-start;flex-direction:column}.batch-form,.form-grid{grid-template-columns:1fr}}
</style>