feat(android): import private Shopee probe tasks
This commit is contained in:
@@ -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) 为准。
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
+27
@@ -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))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
+63
@@ -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)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
+42
@@ -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))
|
||||||
|
}
|
||||||
+297
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
+241
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -51,7 +51,8 @@
|
|||||||
|
|
||||||
## 当前阶段与优先路径
|
## 当前阶段与优先路径
|
||||||
|
|
||||||
当前处于“Android 可运行基线完成、设备和 workflow 验证准备”阶段。
|
当前已完成 Phase 0:Android 可运行、设备就绪、workflow 测试和私有样本导入基线。
|
||||||
|
下一步是 T-101,用固定脱敏搜索词跑通拼多多关键词搜索并验证安全停止。
|
||||||
|
|
||||||
严格按以下顺序推进:
|
严格按以下顺序推进:
|
||||||
|
|
||||||
|
|||||||
@@ -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 厂商、模型、成本上限、数据留存地区和图片隐私规则待确认。
|
||||||
- 拼多多平台条款、自动化允许范围和账号风控需要业务方确认;项目不实现绕过措施。
|
- 拼多多平台条款、自动化允许范围和账号风控需要业务方确认;项目不实现绕过措施。
|
||||||
|
|||||||
@@ -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 已定 | 只作为一次性脚手架输入;生成后立即重写版本约束。 |
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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。
|
||||||
|
|||||||
@@ -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。
|
||||||
|
|||||||
Reference in New Issue
Block a user