From 1c1c1581493af2d04433012064f44f58c1f1d56b Mon Sep 17 00:00:00 2001 From: QiuSW <105186638@qq.com> Date: Sat, 25 Jul 2026 19:06:28 +0800 Subject: [PATCH] feat(android): import private Shopee probe tasks --- README.md | 17 +- android-buyer/app/build.gradle.kts | 16 + .../autopilot/task/FixtureTaskSource.kt | 35 +++ .../workflow/ProbeWorkflowCoordinator.kt | 27 ++ .../workflow/ProbeWorkflowCoordinatorTest.kt | 63 ++++ android-buyer/build.gradle.kts | 1 + android-buyer/settings.gradle.kts | 2 + android-buyer/task-contract/build.gradle.kts | 16 + .../main/kotlin/com/roubao/task/ProbeTask.kt | 56 ++++ .../kotlin/com/roubao/task/ProbeTaskJson.kt | 62 ++++ .../main/kotlin/com/roubao/task/TaskSource.kt | 5 + .../com/roubao/task/ProbeTaskJsonTest.kt | 26 ++ .../tools/shopee-importer/build.gradle.kts | 25 ++ .../com/roubao/tools/shopee/ImporterCli.kt | 42 +++ .../kotlin/com/roubao/tools/shopee/Main.kt | 7 + .../tools/shopee/ShopeeFixtureImporter.kt | 297 ++++++++++++++++++ .../tools/shopee/ShopeeFixtureImporterTest.kt | 241 ++++++++++++++ docs/00-ai-start-here.md | 3 +- docs/02-requirements.md | 11 +- docs/03-tech-stack.md | 2 +- docs/04-architecture.md | 11 + docs/05-coding-rules.md | 2 + docs/current-state.md | 23 +- docs/tasks/T-004.md | 70 ++++- progress.md | 8 + 25 files changed, 1034 insertions(+), 34 deletions(-) create mode 100644 android-buyer/app/src/debug/java/com/roubao/autopilot/task/FixtureTaskSource.kt create mode 100644 android-buyer/app/src/main/java/com/roubao/autopilot/workflow/ProbeWorkflowCoordinator.kt create mode 100644 android-buyer/app/src/test/java/com/roubao/autopilot/workflow/ProbeWorkflowCoordinatorTest.kt create mode 100644 android-buyer/task-contract/build.gradle.kts create mode 100644 android-buyer/task-contract/src/main/kotlin/com/roubao/task/ProbeTask.kt create mode 100644 android-buyer/task-contract/src/main/kotlin/com/roubao/task/ProbeTaskJson.kt create mode 100644 android-buyer/task-contract/src/main/kotlin/com/roubao/task/TaskSource.kt create mode 100644 android-buyer/task-contract/src/test/kotlin/com/roubao/task/ProbeTaskJsonTest.kt create mode 100644 android-buyer/tools/shopee-importer/build.gradle.kts create mode 100644 android-buyer/tools/shopee-importer/src/main/kotlin/com/roubao/tools/shopee/ImporterCli.kt create mode 100644 android-buyer/tools/shopee-importer/src/main/kotlin/com/roubao/tools/shopee/Main.kt create mode 100644 android-buyer/tools/shopee-importer/src/main/kotlin/com/roubao/tools/shopee/ShopeeFixtureImporter.kt create mode 100644 android-buyer/tools/shopee-importer/src/test/kotlin/com/roubao/tools/shopee/ShopeeFixtureImporterTest.kt diff --git a/README.md b/README.md index 91fe31b..34670fa 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,19 @@ Android 8.0(API 26),目标为 Android 14(API 34)。具体版本和本 [`docs/03-tech-stack.md`](docs/03-tech-stack.md) 与 [`docs/current-state.md`](docs/current-state.md)。 +## 私有样本导入 + +```powershell +Set-Location android-buyer +.\gradlew.bat :tools:shopee-importer:run ` + --args="--input ../private-fixtures/shopee --output ../.local/probe-import" +.\gradlew.bat :app:assembleDebug ` + '-PprobeFixturesDir=../.local/probe-import' +``` + +普通 `.\init.ps1` 或不带 `-PprobeFixturesDir` 的 Debug 构建会清除先前注入的私有 +fixture,默认 APK 不携带真实订单资料。 + ## 文档入口 - [AI 开发入口](docs/00-ai-start-here.md) @@ -53,5 +66,5 @@ Android 8.0(API 26),目标为 Android 14(API 34)。具体版本和本 - [当前实现状态](docs/current-state.md) - [完整文档导航](docs/README.md) -Roubao Android 基线已经接入并完成 Debug APK 构建和真机启动;Go 后端尚未建立。 -真实状态以 [`docs/current-state.md`](docs/current-state.md) 为准。 +Android Phase 0 已完成构建、真机设备就绪、workflow 测试和私有样本导入;Go 后端 +尚未建立。真实状态以 [`docs/current-state.md`](docs/current-state.md) 为准。 diff --git a/android-buyer/app/build.gradle.kts b/android-buyer/app/build.gradle.kts index 089a197..3d94e50 100644 --- a/android-buyer/app/build.gradle.kts +++ b/android-buyer/app/build.gradle.kts @@ -53,9 +53,25 @@ android { excludes += "/META-INF/{AL2.0,LGPL2.1}" } } + + val generatedProbeAssets = layout.buildDirectory.dir("generated/probeFixtures") + sourceSets.getByName("debug").assets.srcDir(generatedProbeAssets) + + val configuredProbeFixtures = providers.gradleProperty("probeFixturesDir") + val syncProbeFixtures = tasks.register("syncProbeFixtures") { + if (configuredProbeFixtures.isPresent) { + from(rootProject.file(configuredProbeFixtures.get())) + } + into(generatedProbeAssets.map { it.dir("probe-fixtures") }) + } + tasks.matching { it.name == "mergeDebugAssets" }.configureEach { + dependsOn(syncProbeFixtures) + } } dependencies { + implementation(project(":task-contract")) + // AndroidX Core implementation("androidx.core:core-ktx:1.12.0") implementation("androidx.core:core-splashscreen:1.0.1") diff --git a/android-buyer/app/src/debug/java/com/roubao/autopilot/task/FixtureTaskSource.kt b/android-buyer/app/src/debug/java/com/roubao/autopilot/task/FixtureTaskSource.kt new file mode 100644 index 0000000..0f4b79c --- /dev/null +++ b/android-buyer/app/src/debug/java/com/roubao/autopilot/task/FixtureTaskSource.kt @@ -0,0 +1,35 @@ +package com.roubao.autopilot.task + +import android.content.Context +import com.roubao.task.ProbeTask +import com.roubao.task.ProbeTaskJson +import com.roubao.task.TaskSource +import java.nio.charset.StandardCharsets +import java.util.concurrent.atomic.AtomicInteger + +class FixtureTaskSource( + context: Context, + private val taskDocumentAsset: String = DEFAULT_TASK_DOCUMENT_ASSET +) : TaskSource { + private val assetManager = context.applicationContext.assets + private val nextIndex = AtomicInteger(0) + private val tasks: List by lazy(LazyThreadSafetyMode.SYNCHRONIZED) { + val payload = assetManager.open(taskDocumentAsset).bufferedReader( + StandardCharsets.UTF_8 + ).use { it.readText() } + ProbeTaskJson.decode(payload) + } + + override suspend fun nextTask(): ProbeTask? { + val index = nextIndex.getAndIncrement() + return tasks.getOrNull(index) + } + + fun referenceImageAsset(task: ProbeTask): String = + "$FIXTURE_ASSET_ROOT/${task.referenceImage.relativePath}" + + companion object { + private const val FIXTURE_ASSET_ROOT = "probe-fixtures" + private const val DEFAULT_TASK_DOCUMENT_ASSET = "$FIXTURE_ASSET_ROOT/tasks.json" + } +} diff --git a/android-buyer/app/src/main/java/com/roubao/autopilot/workflow/ProbeWorkflowCoordinator.kt b/android-buyer/app/src/main/java/com/roubao/autopilot/workflow/ProbeWorkflowCoordinator.kt new file mode 100644 index 0000000..a55d896 --- /dev/null +++ b/android-buyer/app/src/main/java/com/roubao/autopilot/workflow/ProbeWorkflowCoordinator.kt @@ -0,0 +1,27 @@ +package com.roubao.autopilot.workflow + +import com.roubao.task.ProbeTask +import com.roubao.task.TaskSource + +sealed interface ProbeWorkflowResult { + data object NoTask : ProbeWorkflowResult + + data class Executed( + val probeId: String, + val report: WorkflowReport + ) : ProbeWorkflowResult +} + +class ProbeWorkflowCoordinator( + private val taskSource: TaskSource, + private val workflowRunner: WorkflowRunner, + private val stepsForTask: (ProbeTask) -> List +) { + suspend fun runNext(): ProbeWorkflowResult { + val task = taskSource.nextTask() ?: return ProbeWorkflowResult.NoTask + return ProbeWorkflowResult.Executed( + probeId = task.probeId, + report = workflowRunner.run(stepsForTask(task)) + ) + } +} diff --git a/android-buyer/app/src/test/java/com/roubao/autopilot/workflow/ProbeWorkflowCoordinatorTest.kt b/android-buyer/app/src/test/java/com/roubao/autopilot/workflow/ProbeWorkflowCoordinatorTest.kt new file mode 100644 index 0000000..df4df44 --- /dev/null +++ b/android-buyer/app/src/test/java/com/roubao/autopilot/workflow/ProbeWorkflowCoordinatorTest.kt @@ -0,0 +1,63 @@ +package com.roubao.autopilot.workflow + +import com.roubao.task.ProbeReferenceImage +import com.roubao.task.ProbeTask +import com.roubao.task.TaskSource +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Test + +class ProbeWorkflowCoordinatorTest { + @Test + fun `workflow consumes task source without knowing fixture implementation`() = runTest { + val expectedTask = probeTask() + val source = TaskSource { expectedTask } + val runner = WorkflowRunner { AutomationResult.Success } + val coordinator = ProbeWorkflowCoordinator( + taskSource = source, + workflowRunner = runner, + stepsForTask = { task -> + assertEquals(expectedTask.title, task.title) + assertEquals(expectedTask.sku, task.sku) + assertEquals(expectedTask.quantity, task.quantity) + listOf(WorkflowStep("prepare_search", timeoutMillis = 1_000)) + } + ) + + val result = coordinator.runNext() as ProbeWorkflowResult.Executed + + assertEquals(expectedTask.probeId, result.probeId) + assertEquals(WorkflowState.SUCCEEDED, result.report.state) + } + + @Test + fun `empty task source does not start workflow`() = runTest { + var automationCalls = 0 + val coordinator = ProbeWorkflowCoordinator( + taskSource = TaskSource { null }, + workflowRunner = WorkflowRunner { + automationCalls += 1 + AutomationResult.Success + }, + stepsForTask = { listOf(WorkflowStep("unused", timeoutMillis = 1_000)) } + ) + + assertEquals(ProbeWorkflowResult.NoTask, coordinator.runNext()) + assertEquals(0, automationCalls) + } + + private fun probeTask() = ProbeTask( + probeId = "probe_0123456789abcdef", + sourceOrderNo = "ORDER_000001", + sourceStoreName = "Test Store", + title = "Test Product", + sku = "SKU-001", + quantity = 1, + referenceImage = ProbeReferenceImage( + relativePath = "assets/0123456789abcdef.jpg", + mediaType = "image/jpeg", + sizeBytes = 123, + sha256 = "a".repeat(64) + ) + ) +} diff --git a/android-buyer/build.gradle.kts b/android-buyer/build.gradle.kts index 74f28a6..f269b08 100644 --- a/android-buyer/build.gradle.kts +++ b/android-buyer/build.gradle.kts @@ -2,4 +2,5 @@ plugins { id("com.android.application") version "8.2.0" apply false id("org.jetbrains.kotlin.android") version "1.9.20" apply false + id("org.jetbrains.kotlin.jvm") version "1.9.20" apply false } diff --git a/android-buyer/settings.gradle.kts b/android-buyer/settings.gradle.kts index 488c65b..bc12c08 100644 --- a/android-buyer/settings.gradle.kts +++ b/android-buyer/settings.gradle.kts @@ -17,3 +17,5 @@ dependencyResolutionManagement { rootProject.name = "AutoPilot" include(":app") +include(":task-contract") +include(":tools:shopee-importer") diff --git a/android-buyer/task-contract/build.gradle.kts b/android-buyer/task-contract/build.gradle.kts new file mode 100644 index 0000000..599cb21 --- /dev/null +++ b/android-buyer/task-contract/build.gradle.kts @@ -0,0 +1,16 @@ +plugins { + id("org.jetbrains.kotlin.jvm") +} + +kotlin { + jvmToolchain(17) +} + +dependencies { + implementation("org.json:json:20231013") + testImplementation("junit:junit:4.13.2") +} + +tasks.test { + useJUnit() +} diff --git a/android-buyer/task-contract/src/main/kotlin/com/roubao/task/ProbeTask.kt b/android-buyer/task-contract/src/main/kotlin/com/roubao/task/ProbeTask.kt new file mode 100644 index 0000000..9b061c8 --- /dev/null +++ b/android-buyer/task-contract/src/main/kotlin/com/roubao/task/ProbeTask.kt @@ -0,0 +1,56 @@ +package com.roubao.task + +private val SHA256_PATTERN = Regex("[0-9a-f]{64}") + +data class ProbeReferenceImage( + val relativePath: String, + val mediaType: String, + val sizeBytes: Long, + val sha256: String +) { + init { + require(isSafeRelativePath(relativePath)) { + "Reference image path must be a safe relative path" + } + require(mediaType == "image/jpeg") { "Only JPEG reference images are supported" } + require(sizeBytes > 0) { "Reference image size must be positive" } + require(SHA256_PATTERN.matches(sha256)) { "Reference image SHA-256 is invalid" } + } +} + +data class ProbeTask( + val schemaVersion: Int = CURRENT_SCHEMA_VERSION, + val probeId: String, + val sourceOrderNo: String, + val sourceStoreName: String, + val title: String, + val sku: String, + val quantity: Int, + val referenceImage: ProbeReferenceImage +) { + init { + require(schemaVersion == CURRENT_SCHEMA_VERSION) { + "Unsupported ProbeTask schema version" + } + require(probeId.isNotBlank()) { "Probe task id must not be blank" } + require(sourceOrderNo.isNotBlank()) { "Source order number must not be blank" } + require(sourceStoreName.isNotBlank()) { "Source store name must not be blank" } + require(title.isNotBlank()) { "Title must not be blank" } + require(sku.isNotBlank()) { "SKU must not be blank" } + require(quantity > 0) { "Quantity must be positive" } + } + + companion object { + const val CURRENT_SCHEMA_VERSION = 1 + } +} + +private fun isSafeRelativePath(path: String): Boolean { + if (path.isBlank() || path.startsWith("/") || path.startsWith("\\")) { + return false + } + if ('\\' in path) { + return false + } + return path.split('/').none { it.isBlank() || it == "." || it == ".." } +} diff --git a/android-buyer/task-contract/src/main/kotlin/com/roubao/task/ProbeTaskJson.kt b/android-buyer/task-contract/src/main/kotlin/com/roubao/task/ProbeTaskJson.kt new file mode 100644 index 0000000..193cec1 --- /dev/null +++ b/android-buyer/task-contract/src/main/kotlin/com/roubao/task/ProbeTaskJson.kt @@ -0,0 +1,62 @@ +package com.roubao.task + +import org.json.JSONArray +import org.json.JSONObject + +object ProbeTaskJson { + fun encode(tasks: Collection): String { + val root = JSONObject() + root.put("schema_version", ProbeTask.CURRENT_SCHEMA_VERSION) + root.put("tasks", JSONArray().apply { + tasks.forEach { put(it.toJson()) } + }) + return root.toString(2) + "\n" + } + + fun decode(payload: String): List { + val root = JSONObject(payload) + require(root.getInt("schema_version") == ProbeTask.CURRENT_SCHEMA_VERSION) { + "Unsupported ProbeTask document schema version" + } + val tasks = root.getJSONArray("tasks") + return buildList(tasks.length()) { + for (index in 0 until tasks.length()) { + add(tasks.getJSONObject(index).toProbeTask()) + } + } + } + + private fun ProbeTask.toJson(): JSONObject = JSONObject() + .put("probe_id", probeId) + .put("source_order_no", sourceOrderNo) + .put("source_store_name", sourceStoreName) + .put("title", title) + .put("sku", sku) + .put("quantity", quantity) + .put( + "reference_image", + JSONObject() + .put("relative_path", referenceImage.relativePath) + .put("media_type", referenceImage.mediaType) + .put("size_bytes", referenceImage.sizeBytes) + .put("sha256", referenceImage.sha256) + ) + + private fun JSONObject.toProbeTask(): ProbeTask { + val image = getJSONObject("reference_image") + return ProbeTask( + probeId = getString("probe_id"), + sourceOrderNo = getString("source_order_no"), + sourceStoreName = getString("source_store_name"), + title = getString("title"), + sku = getString("sku"), + quantity = getInt("quantity"), + referenceImage = ProbeReferenceImage( + relativePath = image.getString("relative_path"), + mediaType = image.getString("media_type"), + sizeBytes = image.getLong("size_bytes"), + sha256 = image.getString("sha256") + ) + ) + } +} diff --git a/android-buyer/task-contract/src/main/kotlin/com/roubao/task/TaskSource.kt b/android-buyer/task-contract/src/main/kotlin/com/roubao/task/TaskSource.kt new file mode 100644 index 0000000..d545190 --- /dev/null +++ b/android-buyer/task-contract/src/main/kotlin/com/roubao/task/TaskSource.kt @@ -0,0 +1,5 @@ +package com.roubao.task + +fun interface TaskSource { + suspend fun nextTask(): ProbeTask? +} diff --git a/android-buyer/task-contract/src/test/kotlin/com/roubao/task/ProbeTaskJsonTest.kt b/android-buyer/task-contract/src/test/kotlin/com/roubao/task/ProbeTaskJsonTest.kt new file mode 100644 index 0000000..136bff3 --- /dev/null +++ b/android-buyer/task-contract/src/test/kotlin/com/roubao/task/ProbeTaskJsonTest.kt @@ -0,0 +1,26 @@ +package com.roubao.task + +import org.junit.Assert.assertEquals +import org.junit.Test + +class ProbeTaskJsonTest { + @Test + fun `task document round trips without changing authoritative fields`() { + val task = ProbeTask( + probeId = "probe_0123456789abcdef", + sourceOrderNo = "ORDER_000001", + sourceStoreName = "Test Store", + title = "Test Product", + sku = "SKU-BLACK", + quantity = 2, + referenceImage = ProbeReferenceImage( + relativePath = "assets/0123456789abcdef.jpg", + mediaType = "image/jpeg", + sizeBytes = 123, + sha256 = "a".repeat(64) + ) + ) + + assertEquals(listOf(task), ProbeTaskJson.decode(ProbeTaskJson.encode(listOf(task)))) + } +} diff --git a/android-buyer/tools/shopee-importer/build.gradle.kts b/android-buyer/tools/shopee-importer/build.gradle.kts new file mode 100644 index 0000000..4638116 --- /dev/null +++ b/android-buyer/tools/shopee-importer/build.gradle.kts @@ -0,0 +1,25 @@ +plugins { + id("org.jetbrains.kotlin.jvm") + application +} + +kotlin { + jvmToolchain(17) +} + +dependencies { + implementation(project(":task-contract")) + testImplementation("junit:junit:4.13.2") +} + +application { + mainClass.set("com.roubao.tools.shopee.MainKt") +} + +tasks.named("run") { + workingDir(rootProject.projectDir) +} + +tasks.test { + useJUnit() +} diff --git a/android-buyer/tools/shopee-importer/src/main/kotlin/com/roubao/tools/shopee/ImporterCli.kt b/android-buyer/tools/shopee-importer/src/main/kotlin/com/roubao/tools/shopee/ImporterCli.kt new file mode 100644 index 0000000..d85a2ec --- /dev/null +++ b/android-buyer/tools/shopee-importer/src/main/kotlin/com/roubao/tools/shopee/ImporterCli.kt @@ -0,0 +1,42 @@ +package com.roubao.tools.shopee + +import java.io.PrintStream +import java.nio.file.Path + +object ImporterCli { + fun run(args: Array, out: PrintStream, err: PrintStream): Int = + try { + val arguments = parseArguments(args) + val summary = ShopeeFixtureImporter().importDirectory( + inputDirectory = Path.of(arguments.getValue("--input")), + outputDirectory = Path.of(arguments.getValue("--output")) + ) + out.println("Imported ${summary.taskCount} task(s).") + 0 + } catch (error: FixtureImportException) { + err.println("Import failed [${error.code.name}].") + 2 + } catch (_: Exception) { + err.println("Import failed [${ImportErrorCode.IO_ERROR.name}].") + 2 + } + + private fun parseArguments(args: Array): Map { + if (args.size != 4) { + throw FixtureImportException(ImportErrorCode.INVALID_ARGUMENTS) + } + val parsed = linkedMapOf() + args.toList().chunked(2).forEach { pair -> + val key = pair[0] + val value = pair[1] + if (key !in setOf("--input", "--output") || value.isBlank() || key in parsed) { + throw FixtureImportException(ImportErrorCode.INVALID_ARGUMENTS) + } + parsed[key] = value + } + if (parsed.keys != setOf("--input", "--output")) { + throw FixtureImportException(ImportErrorCode.INVALID_ARGUMENTS) + } + return parsed + } +} diff --git a/android-buyer/tools/shopee-importer/src/main/kotlin/com/roubao/tools/shopee/Main.kt b/android-buyer/tools/shopee-importer/src/main/kotlin/com/roubao/tools/shopee/Main.kt new file mode 100644 index 0000000..bd08d3f --- /dev/null +++ b/android-buyer/tools/shopee-importer/src/main/kotlin/com/roubao/tools/shopee/Main.kt @@ -0,0 +1,7 @@ +package com.roubao.tools.shopee + +import kotlin.system.exitProcess + +fun main(args: Array) { + exitProcess(ImporterCli.run(args, System.out, System.err)) +} diff --git a/android-buyer/tools/shopee-importer/src/main/kotlin/com/roubao/tools/shopee/ShopeeFixtureImporter.kt b/android-buyer/tools/shopee-importer/src/main/kotlin/com/roubao/tools/shopee/ShopeeFixtureImporter.kt new file mode 100644 index 0000000..b1ec71e --- /dev/null +++ b/android-buyer/tools/shopee-importer/src/main/kotlin/com/roubao/tools/shopee/ShopeeFixtureImporter.kt @@ -0,0 +1,297 @@ +package com.roubao.tools.shopee + +import com.roubao.task.ProbeReferenceImage +import com.roubao.task.ProbeTask +import com.roubao.task.ProbeTaskJson +import java.awt.image.BufferedImage +import java.io.ByteArrayInputStream +import java.nio.ByteBuffer +import java.nio.charset.CharacterCodingException +import java.nio.charset.CodingErrorAction +import java.nio.charset.StandardCharsets +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.StandardCopyOption +import java.security.MessageDigest +import java.util.Comparator +import javax.imageio.ImageIO + +enum class ImportErrorCode { + INVALID_ARGUMENTS, + INPUT_NOT_DIRECTORY, + NO_ORDERS, + DUPLICATE_ORDER, + TEXT_TOO_LARGE, + INVALID_UTF8, + UTF8_BOM_NOT_ALLOWED, + INVALID_TEXT_FORMAT, + MISSING_FIELD, + INVALID_QUANTITY, + MISSING_IMAGE, + MULTIPLE_IMAGES, + UNSUPPORTED_IMAGE_TYPE, + IMAGE_TOO_LARGE, + INVALID_IMAGE, + OUTPUT_NOT_OWNED, + OUTPUT_EQUALS_INPUT, + IO_ERROR +} + +class FixtureImportException( + val code: ImportErrorCode +) : RuntimeException(code.name) + +data class ImportSummary( + val taskCount: Int +) + +class ShopeeFixtureImporter { + fun importDirectory(inputDirectory: Path, outputDirectory: Path): ImportSummary = + try { + importChecked( + inputDirectory.toAbsolutePath().normalize(), + outputDirectory.toAbsolutePath().normalize() + ) + } catch (error: FixtureImportException) { + throw error + } catch (_: Exception) { + throw FixtureImportException(ImportErrorCode.IO_ERROR) + } + + private fun importChecked(inputDirectory: Path, outputDirectory: Path): ImportSummary { + if (!Files.isDirectory(inputDirectory)) { + throw FixtureImportException(ImportErrorCode.INPUT_NOT_DIRECTORY) + } + if (inputDirectory == outputDirectory) { + throw FixtureImportException(ImportErrorCode.OUTPUT_EQUALS_INPUT) + } + + val textFiles = listRegularFiles(inputDirectory) + .filter { extension(it) == "txt" } + .filter { ORDER_NAME_PATTERN.matches(stem(it)) } + .sortedBy { it.fileName.toString().lowercase() } + + if (textFiles.isEmpty()) { + throw FixtureImportException(ImportErrorCode.NO_ORDERS) + } + validateUniqueOrderNumbers(textFiles.map(::stem)) + + val inputFiles = listRegularFiles(inputDirectory) + val imported = textFiles.map { textFile -> + parseCandidate(textFile, inputFiles) + } + + prepareOwnedOutput(outputDirectory) + val assetsDirectory = outputDirectory.resolve(ASSETS_DIRECTORY) + Files.createDirectories(assetsDirectory) + + imported.forEach { candidate -> + val target = outputDirectory.resolve(candidate.task.referenceImage.relativePath) + Files.write(target, candidate.imageBytes) + } + Files.writeString( + outputDirectory.resolve(TASK_DOCUMENT), + ProbeTaskJson.encode(imported.map { it.task }), + StandardCharsets.UTF_8 + ) + + return ImportSummary(taskCount = imported.size) + } + + private fun parseCandidate(textFile: Path, inputFiles: List): ImportedCandidate { + val orderNumber = stem(textFile) + val parsed = parseOrderText(textFile) + val imageFile = findImage(orderNumber, inputFiles) + val imageBytes = readAndValidateJpeg(imageFile) + val anonymousId = sha256Hex( + "probe-task:$orderNumber".toByteArray(StandardCharsets.UTF_8) + ).take(16) + + return ImportedCandidate( + task = ProbeTask( + probeId = "probe_$anonymousId", + sourceOrderNo = orderNumber, + sourceStoreName = parsed.storeName, + title = parsed.title, + sku = parsed.sku, + quantity = parsed.quantity, + referenceImage = ProbeReferenceImage( + relativePath = "$ASSETS_DIRECTORY/$anonymousId.jpg", + mediaType = "image/jpeg", + sizeBytes = imageBytes.size.toLong(), + sha256 = sha256Hex(imageBytes) + ) + ), + imageBytes = imageBytes + ) + } + + private fun parseOrderText(path: Path): ParsedOrderText { + val size = Files.size(path) + if (size <= 0 || size > MAX_TEXT_BYTES) { + throw FixtureImportException(ImportErrorCode.TEXT_TOO_LARGE) + } + + val bytes = Files.readAllBytes(path) + val decoder = StandardCharsets.UTF_8.newDecoder() + .onMalformedInput(CodingErrorAction.REPORT) + .onUnmappableCharacter(CodingErrorAction.REPORT) + val text = try { + decoder.decode(ByteBuffer.wrap(bytes)).toString() + } catch (_: CharacterCodingException) { + throw FixtureImportException(ImportErrorCode.INVALID_UTF8) + } + if (text.startsWith('\uFEFF')) { + throw FixtureImportException(ImportErrorCode.UTF8_BOM_NOT_ALLOWED) + } + + val lines = text + .replace("\r\n", "\n") + .replace('\r', '\n') + .split('\n') + .toMutableList() + while (lines.lastOrNull()?.isEmpty() == true) { + lines.removeLast() + } + if (lines.size != EXPECTED_LABELS.size) { + throw FixtureImportException(ImportErrorCode.INVALID_TEXT_FORMAT) + } + + val values = EXPECTED_LABELS.mapIndexed { index, label -> + parseLabeledValue(lines[index], label) + } + val quantity = values[3].toIntOrNull() + ?.takeIf { it > 0 } + ?: throw FixtureImportException(ImportErrorCode.INVALID_QUANTITY) + + return ParsedOrderText( + storeName = values[0], + title = values[1], + sku = values[2], + quantity = quantity + ) + } + + private fun parseLabeledValue(line: String, expectedLabel: String): String { + val prefix = "$expectedLabel:" + if (!line.startsWith(prefix)) { + throw FixtureImportException(ImportErrorCode.INVALID_TEXT_FORMAT) + } + val value = line.substring(prefix.length) + if (value.isBlank()) { + throw FixtureImportException(ImportErrorCode.MISSING_FIELD) + } + return value + } + + private fun findImage(orderNumber: String, inputFiles: List): Path { + val matchingImages = inputFiles.filter { file -> + stem(file).equals(orderNumber, ignoreCase = true) && + extension(file) in RECOGNIZED_IMAGE_EXTENSIONS + } + if (matchingImages.isEmpty()) { + throw FixtureImportException(ImportErrorCode.MISSING_IMAGE) + } + if (matchingImages.any { extension(it) !in SUPPORTED_IMAGE_EXTENSIONS }) { + throw FixtureImportException(ImportErrorCode.UNSUPPORTED_IMAGE_TYPE) + } + if (matchingImages.size != 1) { + throw FixtureImportException(ImportErrorCode.MULTIPLE_IMAGES) + } + return matchingImages.single() + } + + private fun readAndValidateJpeg(path: Path): ByteArray { + val size = Files.size(path) + if (size <= 0 || size > MAX_IMAGE_BYTES) { + throw FixtureImportException(ImportErrorCode.IMAGE_TOO_LARGE) + } + val bytes = Files.readAllBytes(path) + val hasJpegMagic = bytes.size >= 3 && + bytes[0] == 0xff.toByte() && + bytes[1] == 0xd8.toByte() && + bytes[2] == 0xff.toByte() + val image: BufferedImage? = if (hasJpegMagic) { + runCatching { + ImageIO.read(ByteArrayInputStream(bytes)) + }.getOrNull() + } else { + null + } + if (image == null || image.width <= 0 || image.height <= 0) { + throw FixtureImportException(ImportErrorCode.INVALID_IMAGE) + } + return bytes + } + + private fun prepareOwnedOutput(outputDirectory: Path) { + if (Files.exists(outputDirectory)) { + if (!Files.isDirectory(outputDirectory)) { + throw FixtureImportException(ImportErrorCode.OUTPUT_NOT_OWNED) + } + val hasEntries = Files.list(outputDirectory).use { it.findAny().isPresent } + if (hasEntries && !Files.isRegularFile(outputDirectory.resolve(OUTPUT_MARKER))) { + throw FixtureImportException(ImportErrorCode.OUTPUT_NOT_OWNED) + } + Files.walk(outputDirectory).use { paths -> + paths.sorted(Comparator.reverseOrder()).forEach { Files.delete(it) } + } + } + Files.createDirectories(outputDirectory) + Files.writeString( + outputDirectory.resolve(OUTPUT_MARKER), + "cmroubao-probe-output-v1\n", + StandardCharsets.US_ASCII + ) + } + + private fun listRegularFiles(directory: Path): List = + Files.list(directory).use { files -> + files.filter(Files::isRegularFile).toList() + } + + internal fun validateUniqueOrderNumbers(orderNumbers: Collection) { + val unique = HashSet() + orderNumbers.forEach { orderNumber -> + if (!unique.add(orderNumber.lowercase())) { + throw FixtureImportException(ImportErrorCode.DUPLICATE_ORDER) + } + } + } + + private fun stem(path: Path): String = + path.fileName.toString().substringBeforeLast('.') + + private fun extension(path: Path): String = + path.fileName.toString().substringAfterLast('.', missingDelimiterValue = "").lowercase() + + private fun sha256Hex(bytes: ByteArray): String = + MessageDigest.getInstance("SHA-256") + .digest(bytes) + .joinToString(separator = "") { "%02x".format(it) } + + private data class ParsedOrderText( + val storeName: String, + val title: String, + val sku: String, + val quantity: Int + ) + + private data class ImportedCandidate( + val task: ProbeTask, + val imageBytes: ByteArray + ) + + companion object { + private const val MAX_TEXT_BYTES = 64L * 1024 + private const val MAX_IMAGE_BYTES = 20L * 1024 * 1024 + private const val ASSETS_DIRECTORY = "assets" + private const val TASK_DOCUMENT = "tasks.json" + private const val OUTPUT_MARKER = ".cmroubao-probe-output" + private val ORDER_NAME_PATTERN = Regex("[A-Za-z0-9][A-Za-z0-9_-]{5,63}") + private val EXPECTED_LABELS = listOf("店铺名", "商品标题", "SKU", "数量") + private val SUPPORTED_IMAGE_EXTENSIONS = setOf("jpg", "jpeg") + private val RECOGNIZED_IMAGE_EXTENSIONS = + SUPPORTED_IMAGE_EXTENSIONS + setOf("png", "webp", "gif", "bmp") + } +} diff --git a/android-buyer/tools/shopee-importer/src/test/kotlin/com/roubao/tools/shopee/ShopeeFixtureImporterTest.kt b/android-buyer/tools/shopee-importer/src/test/kotlin/com/roubao/tools/shopee/ShopeeFixtureImporterTest.kt new file mode 100644 index 0000000..1a217c8 --- /dev/null +++ b/android-buyer/tools/shopee-importer/src/test/kotlin/com/roubao/tools/shopee/ShopeeFixtureImporterTest.kt @@ -0,0 +1,241 @@ +package com.roubao.tools.shopee + +import com.roubao.task.ProbeTaskJson +import java.awt.image.BufferedImage +import java.io.ByteArrayOutputStream +import java.io.PrintStream +import java.nio.charset.StandardCharsets +import java.nio.file.Files +import java.nio.file.Path +import javax.imageio.ImageIO +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder + +class ShopeeFixtureImporterTest { + @get:Rule + val temporaryFolder = TemporaryFolder() + + @Test + fun `valid pair generates task with exact authoritative fields`() { + val input = newDirectory("valid-input") + val output = newPath("valid-output") + val orderNumber = "ORDER_000001" + writeOrder( + directory = input, + orderNumber = orderNumber, + storeName = "Test Store", + title = "Test Product", + sku = "SKU-BLACK", + quantity = "2" + ) + writeImage(input.resolve("$orderNumber.jpg"), "jpg") + + val summary = ShopeeFixtureImporter().importDirectory(input, output) + val tasks = ProbeTaskJson.decode(Files.readString(output.resolve("tasks.json"))) + + assertEquals(1, summary.taskCount) + assertEquals(1, tasks.size) + assertEquals(orderNumber, tasks.single().sourceOrderNo) + assertEquals("Test Store", tasks.single().sourceStoreName) + assertEquals("Test Product", tasks.single().title) + assertEquals("SKU-BLACK", tasks.single().sku) + assertEquals(2, tasks.single().quantity) + assertTrue(Files.isRegularFile(output.resolve(tasks.single().referenceImage.relativePath))) + assertFalse(tasks.single().referenceImage.relativePath.contains(orderNumber)) + } + + @Test + fun `missing image is rejected`() { + val input = newDirectory("missing-image") + writeOrder(input, "ORDER_000002") + + assertImportError(ImportErrorCode.MISSING_IMAGE) { + ShopeeFixtureImporter().importDirectory(input, newPath("missing-image-output")) + } + } + + @Test + fun `multiple jpeg images are rejected`() { + val input = newDirectory("multiple-images") + val orderNumber = "ORDER_000003" + writeOrder(input, orderNumber) + writeImage(input.resolve("$orderNumber.jpg"), "jpg") + writeImage(input.resolve("$orderNumber.jpeg"), "jpg") + + assertImportError(ImportErrorCode.MULTIPLE_IMAGES) { + ShopeeFixtureImporter().importDirectory(input, newPath("multiple-images-output")) + } + } + + @Test + fun `unsupported image type is rejected`() { + val input = newDirectory("unsupported-image") + val orderNumber = "ORDER_000004" + writeOrder(input, orderNumber) + writeImage(input.resolve("$orderNumber.png"), "png") + + assertImportError(ImportErrorCode.UNSUPPORTED_IMAGE_TYPE) { + ShopeeFixtureImporter().importDirectory(input, newPath("unsupported-image-output")) + } + } + + @Test + fun `invalid utf8 is rejected`() { + val input = newDirectory("invalid-utf8") + val orderNumber = "ORDER_000005" + Files.write(input.resolve("$orderNumber.txt"), byteArrayOf(0xc3.toByte(), 0x28)) + writeImage(input.resolve("$orderNumber.jpg"), "jpg") + + assertImportError(ImportErrorCode.INVALID_UTF8) { + ShopeeFixtureImporter().importDirectory(input, newPath("invalid-utf8-output")) + } + } + + @Test + fun `missing required field is rejected`() { + val input = newDirectory("missing-field") + val orderNumber = "ORDER_000006" + writeOrder(input, orderNumber, sku = "") + writeImage(input.resolve("$orderNumber.jpg"), "jpg") + + assertImportError(ImportErrorCode.MISSING_FIELD) { + ShopeeFixtureImporter().importDirectory(input, newPath("missing-field-output")) + } + } + + @Test + fun `unknown four line label is rejected`() { + val input = newDirectory("unknown-label") + val orderNumber = "ORDER_000011" + Files.writeString( + input.resolve("$orderNumber.txt"), + """ + 店铺名:Test Store + 商品标题:Test Product + 规格:SKU-001 + 数量:1 + """.trimIndent() + "\n", + StandardCharsets.UTF_8 + ) + writeImage(input.resolve("$orderNumber.jpg"), "jpg") + + assertImportError(ImportErrorCode.INVALID_TEXT_FORMAT) { + ShopeeFixtureImporter().importDirectory(input, newPath("unknown-label-output")) + } + } + + @Test + fun `non positive quantity is rejected`() { + val input = newDirectory("invalid-quantity") + val orderNumber = "ORDER_000007" + writeOrder(input, orderNumber, quantity = "0") + writeImage(input.resolve("$orderNumber.jpg"), "jpg") + + assertImportError(ImportErrorCode.INVALID_QUANTITY) { + ShopeeFixtureImporter().importDirectory(input, newPath("invalid-quantity-output")) + } + } + + @Test + fun `corrupt jpeg is rejected`() { + val input = newDirectory("corrupt-image") + val orderNumber = "ORDER_000008" + writeOrder(input, orderNumber) + Files.write( + input.resolve("$orderNumber.jpg"), + byteArrayOf(0xff.toByte(), 0xd8.toByte(), 0xff.toByte(), 0x00) + ) + + assertImportError(ImportErrorCode.INVALID_IMAGE) { + ShopeeFixtureImporter().importDirectory(input, newPath("corrupt-image-output")) + } + } + + @Test + fun `duplicate order numbers are case insensitive`() { + assertImportError(ImportErrorCode.DUPLICATE_ORDER) { + ShopeeFixtureImporter().validateUniqueOrderNumbers( + listOf("ORDER_000009", "order_000009") + ) + } + } + + @Test + fun `cli output does not disclose private identifiers or source path`() { + val input = newDirectory("private-source") + val output = newPath("private-output") + val orderNumber = "ORDER_000010" + val storeName = "Private Test Store" + writeOrder(input, orderNumber, storeName = storeName) + writeImage(input.resolve("$orderNumber.jpg"), "jpg") + val stdout = ByteArrayOutputStream() + val stderr = ByteArrayOutputStream() + + val exitCode = ImporterCli.run( + arrayOf( + "--input", + input.toString(), + "--output", + output.toString() + ), + PrintStream(stdout, true, StandardCharsets.UTF_8), + PrintStream(stderr, true, StandardCharsets.UTF_8) + ) + val combinedOutput = stdout.toString(StandardCharsets.UTF_8) + + stderr.toString(StandardCharsets.UTF_8) + + assertEquals(0, exitCode) + assertTrue(combinedOutput.contains("Imported 1 task(s).")) + assertFalse(combinedOutput.contains(orderNumber)) + assertFalse(combinedOutput.contains(storeName)) + assertFalse(combinedOutput.contains(input.toAbsolutePath().toString())) + } + + private fun writeOrder( + directory: Path, + orderNumber: String, + storeName: String = "Test Store", + title: String = "Test Product", + sku: String = "SKU-001", + quantity: String = "1" + ) { + Files.writeString( + directory.resolve("$orderNumber.txt"), + """ + 店铺名:$storeName + 商品标题:$title + SKU:$sku + 数量:$quantity + """.trimIndent() + "\n", + StandardCharsets.UTF_8 + ) + } + + private fun writeImage(path: Path, format: String) { + val image = BufferedImage(4, 4, BufferedImage.TYPE_INT_RGB) + image.setRGB(0, 0, 0x336699) + assertTrue(ImageIO.write(image, format, path.toFile())) + } + + private fun newDirectory(name: String): Path = + temporaryFolder.newFolder(name).toPath() + + private fun newPath(name: String): Path = + temporaryFolder.root.toPath().resolve(name) + + private fun assertImportError( + expected: ImportErrorCode, + block: () -> Unit + ) { + try { + block() + throw AssertionError("Expected import error $expected") + } catch (error: FixtureImportException) { + assertEquals(expected, error.code) + } + } +} diff --git a/docs/00-ai-start-here.md b/docs/00-ai-start-here.md index d9066dc..29459ca 100644 --- a/docs/00-ai-start-here.md +++ b/docs/00-ai-start-here.md @@ -51,7 +51,8 @@ ## 当前阶段与优先路径 -当前处于“Android 可运行基线完成、设备和 workflow 验证准备”阶段。 +当前已完成 Phase 0:Android 可运行、设备就绪、workflow 测试和私有样本导入基线。 +下一步是 T-101,用固定脱敏搜索词跑通拼多多关键词搜索并验证安全停止。 严格按以下顺序推进: diff --git a/docs/02-requirements.md b/docs/02-requirements.md index 6ff59a1..2c0a35a 100644 --- a/docs/02-requirements.md +++ b/docs/02-requirements.md @@ -86,9 +86,10 @@ | `quantity` | 文本 | 必须为正整数;不得由模型改写。 | | `reference_image` | 同名图片 | 必填;缺失或匹配到多张时拒绝导入。 | -正式私有样本目录、是否允许 JPEG 以外的图片,以及一张订单包含多个 SKU 时的文本 -和图片命名规则仍待 `T-004` 固定。解析器不得根据模糊内容猜测字段,也不得静默选择 -重名图片;遇到不是上述四行格式的文件应明确失败,除非先通过新样例扩展契约。 +T-004 已固定首版规则:推荐私有目录为被 Git 忽略的 `private-fixtures/shopee/`, +导入命令仍要求显式目录;图片扩展名允许 `.jpg`/`.jpeg`,实际内容必须是唯一且可 +解码的 JPEG;一张订单只允许一个 SKU 和一张参考图。多 SKU 或其他图片类型必须先 +通过新样例扩展契约。解析器不得根据模糊内容猜测字段,也不得静默选择重名图片。 ## 七、MVP 验收标准 @@ -137,8 +138,8 @@ - Roubao 上游仓库、许可证和远端构建版本已核实;源码导入、分支选择和本机构建仍由 `T-001` 完成。 -- 首份蝦皮样本格式已核实;正式私有目录、更多图片类型和一单多 SKU 规则仍需在 - `T-004` 固定。 +- 首份蝦皮样本已由 T-004 真实导入验证;当前只支持单 SKU 和 JPEG,扩展格式需新 + 样例和独立任务。 - 拼多多版本、页面结构、账号登录状态和测试设备尚未形成可复现基线。 - VLM 厂商、模型、成本上限、数据留存地区和图片隐私规则待确认。 - 拼多多平台条款、自动化允许范围和账号风控需要业务方确认;项目不实现绕过措施。 diff --git a/docs/03-tech-stack.md b/docs/03-tech-stack.md index 40a462e..a86ad3a 100644 --- a/docs/03-tech-stack.md +++ b/docs/03-tech-stack.md @@ -12,7 +12,7 @@ | Android SDK | compileSdk/targetSdk 34;minSdk 26;SDK Build Tools 34.0.0 | 已验证 | 支持 Android 8.0+;本机使用 Command-line Tools 22.0。 | | Android UI | Jetpack Compose + Material 3;Compose Compiler 1.5.5 | 上游已核实 | Compose BOM 为 2023.10.01。 | | Android 自动化 | 项目目标以 `AccessibilityService` 为主,Shizuku 为兼容/增强路径 | 就绪观察基线已实现,动作待实现 | T-002 已实现只读前台/登录阻塞观察;搜索、点击和安全动作门禁由 T-101 开始实现。上游 `main` 仍保留 Shizuku 13.1.5。 | -| 第一层任务源 | UTF-8 四行蝦皮订单文本 + 同订单号 JPEG | 首份样例已核实,待实现 | 使用 Debug/测试专用 `TaskSource`;原始数据和生成物不得提交。 | +| 第一层任务源 | UTF-8 无 BOM 四行蝦皮订单文本 + 同订单号 JPEG | 已实现 | `task-contract` 共享 `ProbeTask/TaskSource`;CLI 输出到 `.local/`,只有显式 Debug 属性才注入 APK,默认构建会清除私有资产。 | | Android 长任务 | 前台服务 + 持续通知 | 计划采用 | 降低执行中被系统挂起的风险,仍需处理进程死亡恢复。 | | 后端语言 | Go 1.23.0 | MVP 已定 | 与现有本机工具链一致;构建测试必须设置 `GOTOOLCHAIN=local` 防止静默升级。 | | 后端骨架 | Go Blueprint v0.10.11 生成的最小 Gin + SQLite 工程 | MVP 已定 | 只作为一次性脚手架输入;生成后立即重写版本约束。 | diff --git a/docs/04-architecture.md b/docs/04-architecture.md index f284ae1..2f4330b 100644 --- a/docs/04-architecture.md +++ b/docs/04-architecture.md @@ -141,6 +141,17 @@ Debug 构建或测试装载。导入必须: - 不把完整订单号、店铺名、原图路径写入普通日志。 - 仅在用户明确配置 VLM 后发送必要的标题、SKU 和参考图;不发送订单号或店铺名。 +实现边界: + +- `task-contract/`:纯 Kotlin `ProbeTask`、`TaskSource` 和版本化 JSON codec。 +- `tools/shopee-importer/`:开发机 CLI,严格校验并生成匿名资产名。 +- `app/src/debug/.../FixtureTaskSource`:只在 Debug 代码中读取 + `assets/probe-fixtures/tasks.json`。 +- `ProbeWorkflowCoordinator`:只依赖 `TaskSource`,未来可无缝替换 `HttpTaskSource`。 + +私有 fixture 只有在构建显式设置 `-PprobeFixturesDir` 时注入 Debug APK;普通构建 +必须同步清空生成资产,防止上一次真实样本残留。 + ### 3.2 MVP 业务闭环 ```text diff --git a/docs/05-coding-rules.md b/docs/05-coding-rules.md index 93e47b5..e0f110a 100644 --- a/docs/05-coding-rules.md +++ b/docs/05-coding-rules.md @@ -77,6 +77,8 @@ - API 权限、事务、幂等、文件校验:集成测试。 - VLM adapter:固定 fixture/契约测试,不让普通测试依赖真实付费 API。 - 蝦皮文件导入:覆盖同名配对、缺图、重复图片、未知格式、非法数量和敏感日志检查。 +- 私有 ProbeTask 只允许通过 `-PprobeFixturesDir` 注入 Debug APK;验证后必须再做 + 一次不带属性的普通构建,并确认 APK 不含 `assets/probe-fixtures/`。 - Android workflow:用 fake automation/AI client 测状态和安全停止。 - 拼多多真实流程:指定版本测试机手工 smoke,保存脱敏截图和步骤证据。 - UI:覆盖默认、加载、空、错误、权限和中断状态。 diff --git a/docs/current-state.md b/docs/current-state.md index b8071c2..39d126a 100644 --- a/docs/current-state.md +++ b/docs/current-state.md @@ -5,30 +5,30 @@ ## 当前快照 - 日期:2026-07-25 -- 阶段:T-003 Android workflow 测试骨架完成,准备执行 T-004 -- Git:当前分支为 `main`;T-001 至 T-003 均已纳入 Git 历史 +- 阶段:Phase 0 完成,准备执行 T-101 拼多多关键词搜索探针 +- Git:当前分支为 `main`;T-001 至 T-004 均已纳入 Git 历史 - 生产代码:`android-buyer/` 已接入 Roubao Android 源码 - Android:固定 `main@c8a6d7f03422eb01744b01f3ee77bf7757741f7e`;MIT 许可证已保留 - 后端:已决定使用 Go 1.23.0 + Gin 1.11.0;Go Blueprint v0.10.11 骨架尚未接入 - 本机 Android 工具:JDK 17.0.13、Command-line Tools 22.0、SDK 34、 Build Tools 34.0.0、Platform Tools/ADB 37.0.0;用户级 SDK 环境变量已设置 - Android Studio:未安装;`winget` 静默安装卡住后已终止,不阻塞命令行构建 -- 测试:`lintDebug test assembleDebug` 成功;T-002/T-003 共 11 个纯 Kotlin - 测试,Debug/Release 两个变体共执行 22 次且全部通过 +- 测试:`lintDebug test assembleDebug` 成功;App 两个变体、task contract 和导入器 + 共执行 38 次测试,0 failure、0 error、0 skipped - Workflow:纯 Kotlin runner 已支持步骤 timeout、最多 3 次 retry、安全阻塞、 用户停止和单 runner 并发拒绝;尚未连接真实拼多多动作 +- TaskSource:严格 CLI 已生成并验证真实私有 ProbeTask;默认 APK 不含私有 fixture - 测试设备:OnePlus PKG110,Android 16/API 36;肉包 `1.4.2 (7)`;拼多多 `8.17.0 (81700)` - 设备就绪:肉包采购无障碍已启用并连接;可观察前台包名;拼多多首页未发现登录、 验证码或风控文案,但该结果不等于账号已确认登录 -- 版本控制内数据:只有 `deepseek总结.txt` 背景摘要和本套项目文档 +- 版本控制内测试数据:仅有脱敏、运行时生成的单元测试 fixture;没有真实订单内容 - 本地私有样本:仓库根目录存在一组未跟踪、已本地排除的同名蝦皮文本/JPEG; - 已核实 UTF-8 四行字段格式和图片可解码,真实内容未纳入 Git + 已用 CLI 真实导入并逐字段/图片哈希验证,生成物位于被忽略的 `.local/` - 标准启动路径:`$env:RUN_START_COMMAND="1"; .\init.ps1` - 标准验证路径:`.\init.ps1` - 当前 blocker:拼多多账号是否满足后续搜索流程仍需在 T-101 用页面状态确认; - 蝦皮样本正式私有目录、更多图片类型和一单多 SKU 规则未确认;VLM 供应商和 - 测试凭证未确认 + 当前只支持单 SKU/JPEG;VLM 供应商和测试凭证未确认 ## 当前目录 @@ -39,16 +39,19 @@ | `docs/tasks/T-001.md` | DONE | Android 可构建、可安装、可启动基线 | | `docs/tasks/T-002.md` | DONE | 设备版本、无障碍、前台和登录阻塞就绪检查 | | `docs/tasks/T-003.md` | DONE | 可注入 Fake automation 的受限 workflow runner | +| `docs/tasks/T-004.md` | DONE | 私有蝦皮文本/JPEG 严格导入和 Debug TaskSource | | `deepseek总结.txt` | 已有 | 历史讨论摘要,不是正式需求权威 | | `android-buyer/` | 已有 | Roubao `main` 固定 commit 的 Android 基线 | +| `android-buyer/task-contract/` | 已有 | Android/CLI 共享 ProbeTask 与 TaskSource | +| `android-buyer/tools/shopee-importer/` | 已有 | 开发机私有 fixture 导入 CLI | | `backend-api/` | 待建 | Go-Gin、管理 Web 和数据目标目录 | | `init.ps1` / `init.sh` | 已验证 | Android 构建入口;可选真机安装启动 | ## 任务摘要 -- 已完成:T-001 Android 基线;T-002 设备就绪检查;T-003 workflow 测试骨架。 +- 已完成:T-001 至 T-004,Phase 0 可运行和输入基线。 - 正在进行:无。 -- 下一个可领取任务:T-004 导入本机蝦皮订单验证样本。 +- 下一个可领取任务:T-101 固定任务跑通拼多多关键词搜索。 ## 当前可运行内容 diff --git a/docs/tasks/T-004.md b/docs/tasks/T-004.md index cf5d576..682451e 100644 --- a/docs/tasks/T-004.md +++ b/docs/tasks/T-004.md @@ -3,20 +3,29 @@ id: T-004 title: 导入本机蝦皮订单验证样本 phase: 0 deps: [T-001] -status: TODO +status: DONE created: 2026-07-25 -context_ref: null -work_branch: null +context_ref: d57c4ae2d42c6e48a2d0ad0e9f295cd354dc911a +work_branch: main write_paths: - docs/tasks/T-004.md + - android-buyer/build.gradle.kts + - android-buyer/settings.gradle.kts + - android-buyer/task-contract/** - android-buyer/tools/** + - android-buyer/app/build.gradle.kts + - android-buyer/app/src/main/java/com/roubao/autopilot/workflow/** - android-buyer/app/src/debug/** - android-buyer/app/src/test/** - .gitignore + - README.md + - docs/00-ai-start-here.md + - docs/02-requirements.md - docs/03-tech-stack.md - docs/04-architecture.md - docs/05-coding-rules.md - docs/current-state.md + - progress.md --- ## 问题 / 背景 @@ -39,8 +48,10 @@ JPEG。正式私有目录、额外图片类型和一单多 SKU 规则仍未固 ## 方案 -1. 将当前根目录样例迁移或配置到明确的私有样本目录,保持 Git 忽略。 -2. 以已核实的 UTF-8 四行标签和 JPEG 为首版契约;一单多 SKU 不明确前拒绝导入。 +1. 正式私有目录约定为仓库根目录 `private-fixtures/shopee/`,工具仍要求显式 + `--input`;当前根目录样例保持原位并继续通过本地 exclude 隔离。 +2. 以已核实的 UTF-8 四行标签和 JPEG 内容为首版契约;允许 `.jpg`/`.jpeg` 扩展名, + 一张订单只允许一行 SKU 和一张参考图。 3. 实现开发机导入工具,按文件主名精确配对文本和唯一参考图。 4. 校验订单号、店铺名、标题、SKU、正整数数量、图片存在且可解码。 5. 生成与未来 HTTP `TaskSource` 同构的 `ProbeTask`,输出到被 Git 忽略的 `.local/` @@ -49,13 +60,13 @@ JPEG。正式私有目录、额外图片类型和一单多 SKU 规则仍未固 ## 验收要点 -- [ ] 一组合法脱敏文本和参考图能稳定生成一个 `ProbeTask`。 -- [ ] `source_order_no` 来自文件主名;标题、SKU 和数量与原文完全一致。 -- [ ] 缺失或匹配到多张参考图、必填字段缺失、数量非法时导入明确失败。 -- [ ] Android workflow 通过 `TaskSource` 消费结果,不依赖 Windows 路径或导入器类型。 -- [ ] 真实订单文件、图片、生成物和本机绝对路径均未进入 Git。 -- [ ] 日志不包含完整订单号、店铺名或原图绝对路径。 -- [ ] 普通自动化测试不调用真实付费 VLM。 +- [x] 一组合法脱敏文本和参考图能稳定生成一个 `ProbeTask`。 +- [x] `source_order_no` 来自文件主名;标题、SKU 和数量与原文完全一致。 +- [x] 缺失或匹配到多张参考图、必填字段缺失、数量非法时导入明确失败。 +- [x] Android workflow 通过 `TaskSource` 消费结果,不依赖 Windows 路径或导入器类型。 +- [x] 真实订单文件、图片、生成物和本机绝对路径均未进入 Git。 +- [x] 日志不包含完整订单号、店铺名或原图绝对路径。 +- [x] 普通自动化测试不调用真实付费 VLM。 ## 边界 @@ -66,6 +77,35 @@ JPEG。正式私有目录、额外图片类型和一单多 SKU 规则仍未固 ## 执行记录 -尚未开始,依赖 T-001 已满足。前置检查已确认一组同名文本/JPEG 存在,文本字段结构 -和图片解码有效;未记录或提交实际订单号、店铺名、商品内容及图片。实现时仍需确定 -正式私有目录和一单多 SKU 规则。 +### 2026-07-25:任务开始 + +- 基于 T-003 提交 `d57c4ae` 开始,依赖 T-001 已满足。 +- 前置检查确认一组同名文本/JPEG 存在,文本字段结构和图片解码有效;未记录或提交 + 实际订单号、店铺名、商品内容及图片。 +- 固定首版规则:推荐私有目录为 `private-fixtures/shopee/`;只接收实际内容为 JPEG + 的 `.jpg`/`.jpeg`;一张订单只有一个 SKU 和一张参考图,多 SKU 必须先扩展契约。 + +### 2026-07-25:实现和验证完成 + +- 新增纯 Kotlin `task-contract` 模块,统一定义 `ProbeTask`、`ProbeReferenceImage`、 + `TaskSource` 和版本化 JSON codec;Android 与开发机工具使用同一模型。 +- 新增 `tools:shopee-importer` CLI。它要求显式输入/输出目录,严格解析 UTF-8 无 BOM + 四行标签,只接受唯一且可解码的 JPEG,限制文本 64 KiB、图片 20 MiB,并为错误 + 返回稳定 code。 +- 生成物使用匿名 `probe_id` 和匿名图片文件名,但私有 JSON 内保留权威 + `source_order_no`、店铺名、标题、SKU、数量;参考图保存大小和 SHA-256。 +- 输出目录必须为空或带工具 marker,避免清理任意目录;CLI 普通输出只有导入数量, + 不打印完整订单号、店铺名或源路径。 +- 新增 Debug `FixtureTaskSource` 和 `ProbeWorkflowCoordinator`。workflow 只依赖 + `TaskSource`,不知道任务来自文件还是未来 HTTP。 +- `syncProbeFixtures` 仅在显式传入 `-PprobeFixturesDir` 时把私有生成物放入 Debug + 构建资产;不带属性的普通构建会清空先前生成资产。 +- 脱敏测试覆盖 JSON round-trip、合法导入、缺图、多图、未知图片类型、非法 UTF-8、 + 缺字段、非法数量、损坏 JPEG、大小写重复订单号、敏感 CLI 输出和 Android + TaskSource/workflow 边界。 +- 真实本地样本运行成功并生成 1 个任务;自动比较确认订单号、店铺名、标题、SKU、 + 数量逐字段相等,参考图复制前后 SHA-256 相等。比较结果只记录布尔值,未输出内容。 +- 带私有属性的 Debug APK 包含 1 个任务文档和 1 张匿名 JPEG;随后默认重建确认 APK + 中 `probe-fixtures` 条目为 0,生成资产也已清除。 +- 全量 `lintDebug test assembleDebug` 成功;三个模块共 38 次测试执行, + 0 failure、0 error、0 skipped。 diff --git a/progress.md b/progress.md index 25800f6..bc87d8d 100644 --- a/progress.md +++ b/progress.md @@ -61,3 +61,11 @@ 有限 retry、安全阻塞、用户停止和并发拒绝。 - 影响:T-101 至 T-104 可以把真实拼多多动作接到稳定状态机上,自动化异常不再依赖 真机人工判断。 + +## 2026-07-25 私有蝦皮样本导入基线 + +- 类型:阶段完成 +- 内容:完成 T-004;建立共享 ProbeTask/TaskSource、严格开发机导入 CLI 和显式 + Debug fixture 注入,真实样本逐字段与图片哈希验证通过。 +- 影响:Phase 0 完成;T-101 可先使用固定搜索词验证真实拼多多操作,T-103 可直接 + 消费同一私有任务契约接入 VLM。