feat(android): import private Shopee probe tasks

This commit is contained in:
QiuSW
2026-07-25 19:06:28 +08:00
parent d57c4ae2d4
commit 1c1c158149
25 changed files with 1034 additions and 34 deletions
+15 -2
View File
@@ -41,6 +41,19 @@ Android 8.0(API 26),目标为 Android 14(API 34)。具体版本和本
[`docs/03-tech-stack.md`](docs/03-tech-stack.md) 与 [`docs/03-tech-stack.md`](docs/03-tech-stack.md) 与
[`docs/current-state.md`](docs/current-state.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) - [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/current-state.md)
- [完整文档导航](docs/README.md) - [完整文档导航](docs/README.md)
Roubao Android 基线已经接入并完成 Debug APK 构建和真机启动;Go 后端尚未建立。 Android Phase 0 已完成构建、真机设备就绪、workflow 测试和私有样本导入;Go 后端
真实状态以 [`docs/current-state.md`](docs/current-state.md) 为准。 尚未建立。真实状态以 [`docs/current-state.md`](docs/current-state.md) 为准。
+16
View File
@@ -53,9 +53,25 @@ android {
excludes += "/META-INF/{AL2.0,LGPL2.1}" 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<Sync>("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 { dependencies {
implementation(project(":task-contract"))
// AndroidX Core // AndroidX Core
implementation("androidx.core:core-ktx:1.12.0") implementation("androidx.core:core-ktx:1.12.0")
implementation("androidx.core:core-splashscreen:1.0.1") implementation("androidx.core:core-splashscreen:1.0.1")
@@ -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<ProbeTask> 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"
}
}
@@ -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<WorkflowStep>
) {
suspend fun runNext(): ProbeWorkflowResult {
val task = taskSource.nextTask() ?: return ProbeWorkflowResult.NoTask
return ProbeWorkflowResult.Executed(
probeId = task.probeId,
report = workflowRunner.run(stepsForTask(task))
)
}
}
@@ -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)
)
)
}
+1
View File
@@ -2,4 +2,5 @@
plugins { plugins {
id("com.android.application") version "8.2.0" apply false 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.android") version "1.9.20" apply false
id("org.jetbrains.kotlin.jvm") version "1.9.20" apply false
} }
+2
View File
@@ -17,3 +17,5 @@ dependencyResolutionManagement {
rootProject.name = "AutoPilot" rootProject.name = "AutoPilot"
include(":app") include(":app")
include(":task-contract")
include(":tools:shopee-importer")
@@ -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()
}
@@ -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 == ".." }
}
@@ -0,0 +1,62 @@
package com.roubao.task
import org.json.JSONArray
import org.json.JSONObject
object ProbeTaskJson {
fun encode(tasks: Collection<ProbeTask>): 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<ProbeTask> {
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")
)
)
}
}
@@ -0,0 +1,5 @@
package com.roubao.task
fun interface TaskSource {
suspend fun nextTask(): ProbeTask?
}
@@ -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))))
}
}
@@ -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<JavaExec>("run") {
workingDir(rootProject.projectDir)
}
tasks.test {
useJUnit()
}
@@ -0,0 +1,42 @@
package com.roubao.tools.shopee
import java.io.PrintStream
import java.nio.file.Path
object ImporterCli {
fun run(args: Array<String>, 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<String>): Map<String, String> {
if (args.size != 4) {
throw FixtureImportException(ImportErrorCode.INVALID_ARGUMENTS)
}
val parsed = linkedMapOf<String, String>()
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
}
}
@@ -0,0 +1,7 @@
package com.roubao.tools.shopee
import kotlin.system.exitProcess
fun main(args: Array<String>) {
exitProcess(ImporterCli.run(args, System.out, System.err))
}
@@ -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<Path>): 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>): 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<Path> =
Files.list(directory).use { files ->
files.filter(Files::isRegularFile).toList()
}
internal fun validateUniqueOrderNumbers(orderNumbers: Collection<String>) {
val unique = HashSet<String>()
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")
}
}
@@ -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)
}
}
}
+2 -1
View File
@@ -51,7 +51,8 @@
## 当前阶段与优先路径 ## 当前阶段与优先路径
当前处于“Android 可运行基线完成、设备和 workflow 验证准备”阶段。 当前已完成 Phase 0:Android 可运行、设备就绪、workflow 测试和私有样本导入基线。
下一步是 T-101,用固定脱敏搜索词跑通拼多多关键词搜索并验证安全停止。
严格按以下顺序推进: 严格按以下顺序推进:
+6 -5
View File
@@ -86,9 +86,10 @@
| `quantity` | 文本 | 必须为正整数;不得由模型改写。 | | `quantity` | 文本 | 必须为正整数;不得由模型改写。 |
| `reference_image` | 同名图片 | 必填;缺失或匹配到多张时拒绝导入。 | | `reference_image` | 同名图片 | 必填;缺失或匹配到多张时拒绝导入。 |
正式私有样本目录、是否允许 JPEG 以外的图片,以及一张订单包含多个 SKU 时的文本 T-004 已固定首版规则:推荐私有目录为被 Git 忽略的 `private-fixtures/shopee/`,
和图片命名规则仍待 `T-004` 固定。解析器不得根据模糊内容猜测字段,也不得静默选择 导入命令仍要求显式目录;图片扩展名允许 `.jpg`/`.jpeg`,实际内容必须是唯一且可
重名图片;遇到不是上述四行格式的文件应明确失败,除非先通过新样例扩展契约。 解码的 JPEG;一张订单只允许一个 SKU 和一张参考图。多 SKU 或其他图片类型必须先
通过新样例扩展契约。解析器不得根据模糊内容猜测字段,也不得静默选择重名图片。
## 七、MVP 验收标准 ## 七、MVP 验收标准
@@ -137,8 +138,8 @@
- Roubao 上游仓库、许可证和远端构建版本已核实;源码导入、分支选择和本机构建仍由 - Roubao 上游仓库、许可证和远端构建版本已核实;源码导入、分支选择和本机构建仍由
`T-001` 完成。 `T-001` 完成。
- 首份蝦皮样本格式已核实;正式私有目录、更多图片类型和一单多 SKU 规则仍需在 - 首份蝦皮样本已由 T-004 真实导入验证;当前只支持单 SKU 和 JPEG,扩展格式需新
`T-004` 固定。 样例和独立任务。
- 拼多多版本、页面结构、账号登录状态和测试设备尚未形成可复现基线。 - 拼多多版本、页面结构、账号登录状态和测试设备尚未形成可复现基线。
- VLM 厂商、模型、成本上限、数据留存地区和图片隐私规则待确认。 - VLM 厂商、模型、成本上限、数据留存地区和图片隐私规则待确认。
- 拼多多平台条款、自动化允许范围和账号风控需要业务方确认;项目不实现绕过措施。 - 拼多多平台条款、自动化允许范围和账号风控需要业务方确认;项目不实现绕过措施。
+1 -1
View File
@@ -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 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 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。 | | 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 长任务 | 前台服务 + 持续通知 | 计划采用 | 降低执行中被系统挂起的风险,仍需处理进程死亡恢复。 | | Android 长任务 | 前台服务 + 持续通知 | 计划采用 | 降低执行中被系统挂起的风险,仍需处理进程死亡恢复。 |
| 后端语言 | Go 1.23.0 | MVP 已定 | 与现有本机工具链一致;构建测试必须设置 `GOTOOLCHAIN=local` 防止静默升级。 | | 后端语言 | Go 1.23.0 | MVP 已定 | 与现有本机工具链一致;构建测试必须设置 `GOTOOLCHAIN=local` 防止静默升级。 |
| 后端骨架 | Go Blueprint v0.10.11 生成的最小 Gin + SQLite 工程 | MVP 已定 | 只作为一次性脚手架输入;生成后立即重写版本约束。 | | 后端骨架 | Go Blueprint v0.10.11 生成的最小 Gin + SQLite 工程 | MVP 已定 | 只作为一次性脚手架输入;生成后立即重写版本约束。 |
+11
View File
@@ -141,6 +141,17 @@ Debug 构建或测试装载。导入必须:
- 不把完整订单号、店铺名、原图路径写入普通日志。 - 不把完整订单号、店铺名、原图路径写入普通日志。
- 仅在用户明确配置 VLM 后发送必要的标题、SKU 和参考图;不发送订单号或店铺名。 - 仅在用户明确配置 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 业务闭环 ### 3.2 MVP 业务闭环
```text ```text
+2
View File
@@ -77,6 +77,8 @@
- API 权限、事务、幂等、文件校验:集成测试。 - API 权限、事务、幂等、文件校验:集成测试。
- VLM adapter:固定 fixture/契约测试,不让普通测试依赖真实付费 API。 - VLM adapter:固定 fixture/契约测试,不让普通测试依赖真实付费 API。
- 蝦皮文件导入:覆盖同名配对、缺图、重复图片、未知格式、非法数量和敏感日志检查。 - 蝦皮文件导入:覆盖同名配对、缺图、重复图片、未知格式、非法数量和敏感日志检查。
- 私有 ProbeTask 只允许通过 `-PprobeFixturesDir` 注入 Debug APK;验证后必须再做
一次不带属性的普通构建,并确认 APK 不含 `assets/probe-fixtures/`。
- Android workflow:用 fake automation/AI client 测状态和安全停止。 - Android workflow:用 fake automation/AI client 测状态和安全停止。
- 拼多多真实流程:指定版本测试机手工 smoke,保存脱敏截图和步骤证据。 - 拼多多真实流程:指定版本测试机手工 smoke,保存脱敏截图和步骤证据。
- UI:覆盖默认、加载、空、错误、权限和中断状态。 - UI:覆盖默认、加载、空、错误、权限和中断状态。
+13 -10
View File
@@ -5,30 +5,30 @@
## 当前快照 ## 当前快照
- 日期:2026-07-25 - 日期:2026-07-25
- 阶段:T-003 Android workflow 测试骨架完成,准备执行 T-004 - 阶段:Phase 0 完成,准备执行 T-101 拼多多关键词搜索探针
- Git:当前分支为 `main`;T-001 至 T-003 均已纳入 Git 历史 - Git:当前分支为 `main`;T-001 至 T-004 均已纳入 Git 历史
- 生产代码:`android-buyer/` 已接入 Roubao Android 源码 - 生产代码:`android-buyer/` 已接入 Roubao Android 源码
- Android:固定 `main@c8a6d7f03422eb01744b01f3ee77bf7757741f7e`;MIT 许可证已保留 - Android:固定 `main@c8a6d7f03422eb01744b01f3ee77bf7757741f7e`;MIT 许可证已保留
- 后端:已决定使用 Go 1.23.0 + Gin 1.11.0;Go Blueprint v0.10.11 骨架尚未接入 - 后端:已决定使用 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、 - 本机 Android 工具:JDK 17.0.13、Command-line Tools 22.0、SDK 34、
Build Tools 34.0.0、Platform Tools/ADB 37.0.0;用户级 SDK 环境变量已设置 Build Tools 34.0.0、Platform Tools/ADB 37.0.0;用户级 SDK 环境变量已设置
- Android Studio:未安装;`winget` 静默安装卡住后已终止,不阻塞命令行构建 - Android Studio:未安装;`winget` 静默安装卡住后已终止,不阻塞命令行构建
- 测试:`lintDebug test assembleDebug` 成功;T-002/T-003 共 11 个纯 Kotlin - 测试:`lintDebug test assembleDebug` 成功;App 两个变体、task contract 和导入器
测试,Debug/Release 两个变体共执行 22 次且全部通过 共执行 38 次测试,0 failure、0 error、0 skipped
- Workflow:纯 Kotlin runner 已支持步骤 timeout、最多 3 次 retry、安全阻塞、 - Workflow:纯 Kotlin runner 已支持步骤 timeout、最多 3 次 retry、安全阻塞、
用户停止和单 runner 并发拒绝;尚未连接真实拼多多动作 用户停止和单 runner 并发拒绝;尚未连接真实拼多多动作
- TaskSource:严格 CLI 已生成并验证真实私有 ProbeTask;默认 APK 不含私有 fixture
- 测试设备:OnePlus PKG110,Android 16/API 36;肉包 `1.4.2 (7)`;拼多多 - 测试设备:OnePlus PKG110,Android 16/API 36;肉包 `1.4.2 (7)`;拼多多
`8.17.0 (81700)` `8.17.0 (81700)`
- 设备就绪:肉包采购无障碍已启用并连接;可观察前台包名;拼多多首页未发现登录、 - 设备就绪:肉包采购无障碍已启用并连接;可观察前台包名;拼多多首页未发现登录、
验证码或风控文案,但该结果不等于账号已确认登录 验证码或风控文案,但该结果不等于账号已确认登录
- 版本控制内数据:只有 `deepseek总结.txt` 背景摘要和本套项目文档 - 版本控制内测试数据:仅有脱敏、运行时生成的单元测试 fixture;没有真实订单内容
- 本地私有样本:仓库根目录存在一组未跟踪、已本地排除的同名蝦皮文本/JPEG; - 本地私有样本:仓库根目录存在一组未跟踪、已本地排除的同名蝦皮文本/JPEG;
已核实 UTF-8 四行字段格式和图片可解码,真实内容未纳入 Git 已用 CLI 真实导入并逐字段/图片哈希验证,生成物位于被忽略的 `.local/`
- 标准启动路径:`$env:RUN_START_COMMAND="1"; .\init.ps1` - 标准启动路径:`$env:RUN_START_COMMAND="1"; .\init.ps1`
- 标准验证路径:`.\init.ps1` - 标准验证路径:`.\init.ps1`
- 当前 blocker:拼多多账号是否满足后续搜索流程仍需在 T-101 用页面状态确认; - 当前 blocker:拼多多账号是否满足后续搜索流程仍需在 T-101 用页面状态确认;
蝦皮样本正式私有目录、更多图片类型和一单多 SKU 规则未确认;VLM 供应商和 当前只支持单 SKU/JPEG;VLM 供应商和测试凭证未确认
测试凭证未确认
## 当前目录 ## 当前目录
@@ -39,16 +39,19 @@
| `docs/tasks/T-001.md` | DONE | Android 可构建、可安装、可启动基线 | | `docs/tasks/T-001.md` | DONE | Android 可构建、可安装、可启动基线 |
| `docs/tasks/T-002.md` | DONE | 设备版本、无障碍、前台和登录阻塞就绪检查 | | `docs/tasks/T-002.md` | DONE | 设备版本、无障碍、前台和登录阻塞就绪检查 |
| `docs/tasks/T-003.md` | DONE | 可注入 Fake automation 的受限 workflow runner | | `docs/tasks/T-003.md` | DONE | 可注入 Fake automation 的受限 workflow runner |
| `docs/tasks/T-004.md` | DONE | 私有蝦皮文本/JPEG 严格导入和 Debug TaskSource |
| `deepseek总结.txt` | 已有 | 历史讨论摘要,不是正式需求权威 | | `deepseek总结.txt` | 已有 | 历史讨论摘要,不是正式需求权威 |
| `android-buyer/` | 已有 | Roubao `main` 固定 commit 的 Android 基线 | | `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 和数据目标目录 | | `backend-api/` | 待建 | Go-Gin、管理 Web 和数据目标目录 |
| `init.ps1` / `init.sh` | 已验证 | Android 构建入口;可选真机安装启动 | | `init.ps1` / `init.sh` | 已验证 | Android 构建入口;可选真机安装启动 |
## 任务摘要 ## 任务摘要
- 已完成:T-001 Android 基线;T-002 设备就绪检查;T-003 workflow 测试骨架。 - 已完成:T-001 至 T-004,Phase 0 可运行和输入基线。
- 正在进行:无。 - 正在进行:无。
- 下一个可领取任务:T-004 导入本机蝦皮订单验证样本。 - 下一个可领取任务:T-101 固定任务跑通拼多多关键词搜索。
## 当前可运行内容 ## 当前可运行内容
+55 -15
View File
@@ -3,20 +3,29 @@ id: T-004
title: 导入本机蝦皮订单验证样本 title: 导入本机蝦皮订单验证样本
phase: 0 phase: 0
deps: [T-001] deps: [T-001]
status: TODO status: DONE
created: 2026-07-25 created: 2026-07-25
context_ref: null context_ref: d57c4ae2d42c6e48a2d0ad0e9f295cd354dc911a
work_branch: null work_branch: main
write_paths: write_paths:
- docs/tasks/T-004.md - docs/tasks/T-004.md
- android-buyer/build.gradle.kts
- android-buyer/settings.gradle.kts
- android-buyer/task-contract/**
- android-buyer/tools/** - 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/debug/**
- android-buyer/app/src/test/** - android-buyer/app/src/test/**
- .gitignore - .gitignore
- README.md
- docs/00-ai-start-here.md
- docs/02-requirements.md
- docs/03-tech-stack.md - docs/03-tech-stack.md
- docs/04-architecture.md - docs/04-architecture.md
- docs/05-coding-rules.md - docs/05-coding-rules.md
- docs/current-state.md - docs/current-state.md
- progress.md
--- ---
## 问题 / 背景 ## 问题 / 背景
@@ -39,8 +48,10 @@ JPEG。正式私有目录、额外图片类型和一单多 SKU 规则仍未固
## 方案 ## 方案
1. 将当前根目录样例迁移或配置到明确的私有样本目录,保持 Git 忽略。 1. 正式私有目录约定为仓库根目录 `private-fixtures/shopee/`,工具仍要求显式
2. 以已核实的 UTF-8 四行标签和 JPEG 为首版契约;一单多 SKU 不明确前拒绝导入。 `--input`;当前根目录样例保持原位并继续通过本地 exclude 隔离。
2. 以已核实的 UTF-8 四行标签和 JPEG 内容为首版契约;允许 `.jpg`/`.jpeg` 扩展名,
一张订单只允许一行 SKU 和一张参考图。
3. 实现开发机导入工具,按文件主名精确配对文本和唯一参考图。 3. 实现开发机导入工具,按文件主名精确配对文本和唯一参考图。
4. 校验订单号、店铺名、标题、SKU、正整数数量、图片存在且可解码。 4. 校验订单号、店铺名、标题、SKU、正整数数量、图片存在且可解码。
5. 生成与未来 HTTP `TaskSource` 同构的 `ProbeTask`,输出到被 Git 忽略的 `.local/` 5. 生成与未来 HTTP `TaskSource` 同构的 `ProbeTask`,输出到被 Git 忽略的 `.local/`
@@ -49,13 +60,13 @@ JPEG。正式私有目录、额外图片类型和一单多 SKU 规则仍未固
## 验收要点 ## 验收要点
- [ ] 一组合法脱敏文本和参考图能稳定生成一个 `ProbeTask`。 - [x] 一组合法脱敏文本和参考图能稳定生成一个 `ProbeTask`。
- [ ] `source_order_no` 来自文件主名;标题、SKU 和数量与原文完全一致。 - [x] `source_order_no` 来自文件主名;标题、SKU 和数量与原文完全一致。
- [ ] 缺失或匹配到多张参考图、必填字段缺失、数量非法时导入明确失败。 - [x] 缺失或匹配到多张参考图、必填字段缺失、数量非法时导入明确失败。
- [ ] Android workflow 通过 `TaskSource` 消费结果,不依赖 Windows 路径或导入器类型。 - [x] Android workflow 通过 `TaskSource` 消费结果,不依赖 Windows 路径或导入器类型。
- [ ] 真实订单文件、图片、生成物和本机绝对路径均未进入 Git。 - [x] 真实订单文件、图片、生成物和本机绝对路径均未进入 Git。
- [ ] 日志不包含完整订单号、店铺名或原图绝对路径。 - [x] 日志不包含完整订单号、店铺名或原图绝对路径。
- [ ] 普通自动化测试不调用真实付费 VLM。 - [x] 普通自动化测试不调用真实付费 VLM。
## 边界 ## 边界
@@ -66,6 +77,35 @@ JPEG。正式私有目录、额外图片类型和一单多 SKU 规则仍未固
## 执行记录 ## 执行记录
尚未开始,依赖 T-001 已满足。前置检查已确认一组同名文本/JPEG 存在,文本字段结构 ### 2026-07-25:任务开始
和图片解码有效;未记录或提交实际订单号、店铺名、商品内容及图片。实现时仍需确定
正式私有目录和一单多 SKU 规则。 - 基于 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。
+8
View File
@@ -61,3 +61,11 @@
有限 retry、安全阻塞、用户停止和并发拒绝。 有限 retry、安全阻塞、用户停止和并发拒绝。
- 影响:T-101 至 T-104 可以把真实拼多多动作接到稳定状态机上,自动化异常不再依赖 - 影响:T-101 至 T-104 可以把真实拼多多动作接到稳定状态机上,自动化异常不再依赖
真机人工判断。 真机人工判断。
## 2026-07-25 私有蝦皮样本导入基线
- 类型:阶段完成
- 内容:完成 T-004;建立共享 ProbeTask/TaskSource、严格开发机导入 CLI 和显式
Debug fixture 注入,真实样本逐字段与图片哈希验证通过。
- 影响:Phase 0 完成;T-101 可先使用固定搜索词验证真实拼多多操作,T-103 可直接
消费同一私有任务契约接入 VLM。