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
@@ -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)
}
}
}