feat(android): import private Shopee probe tasks
This commit is contained in:
@@ -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<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 {
|
||||
implementation(project(":task-contract"))
|
||||
|
||||
// AndroidX Core
|
||||
implementation("androidx.core:core-ktx:1.12.0")
|
||||
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 {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -17,3 +17,5 @@ dependencyResolutionManagement {
|
||||
|
||||
rootProject.name = "AutoPilot"
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user