feat(purchase): allow absolute price ceiling (#180)

This commit is contained in:
QiuSW
2026-08-31 21:27:50 +08:00
parent 55ded8b598
commit 9cdb6bb962
15 changed files with 387 additions and 57 deletions
@@ -22,7 +22,7 @@ data class FinalConfirmationEvidence(
data class PurchaseOrderEvidence(val orderNo: String, val submittedAt: String)
data class PurchaseOrderReadFailure(val code: String, val message: String)
class PurchaseLiveException(val code: String, message: String) : IllegalStateException(message)
class PurchaseLiveException(val code: String, message: String, val actualUnitPriceCent: Long? = null) : IllegalStateException(message)
/**
* The only Android component allowed to mutate a shipping address or click the
@@ -87,7 +87,7 @@ class PurchaseLiveAutomation(
fail("PURCHASE_SPEC_NOT_MATCHED", "创建订单前规格复核失败")
}
val price = screen.priceCent ?: fail("RULE_NOT_MATCHED", "创建订单前没有读取到商品单价")
if (price !in input.minUnitPriceCent..input.maxUnitPriceCent) fail("PURCHASE_PRICE_OUT_OF_RANGE", "当前商品单价超出允许范围")
if (price !in input.minUnitPriceCent..input.maxUnitPriceCent) fail("PURCHASE_PRICE_OUT_OF_RANGE", "当前商品单价超出允许范围", price)
val quantities = snapshot.nodes.filter { it.visible && it.enabled && it.className?.endsWith("EditText") == true }.mapNotNull { it.label.toLongOrNull() }
if (quantities.singleOrNull() != input.quantity) fail("PURCHASE_QUANTITY_MISMATCH", "创建订单前数量复核失败")
return FinalConfirmationEvidence(input.goodsId, input.mappedColor, input.mappedSize, input.quantity, price, address.suffix, snapshot.activityName.orEmpty())
@@ -411,7 +411,8 @@ class PurchaseLiveAutomation(
return PurchaseOrderReadFailure("PURCHASE_ORDER_RESULT_UNKNOWN", "订单详情证据不完整,无法唯一确认")
}
private fun fail(code: String, message: String): Nothing = throw PurchaseLiveException(code, message)
private fun fail(code: String, message: String, actualUnitPriceCent: Long? = null): Nothing =
throw PurchaseLiveException(code, message, actualUnitPriceCent)
private companion object {
const val PDD_PACKAGE = "com.xunmeng.pinduoduo"
@@ -98,7 +98,7 @@ class PurchaseRehearsalExecutor(
"无法确认订单是否创建,请人工检查",
actualUnitPriceCent = observedPrice,
)
else failure(error.code, error.message ?: "创建订单前检查失败")
else failure(error.code, error.message ?: "创建订单前检查失败", error.actualUnitPriceCent ?: observedPrice)
}
PurchaseActionType.READ_ORDER_RESULT -> {
if (!irreversibleStarted) failure("PURCHASE_RULE_INVALID", "尚未进入创建订单边界")
@@ -558,7 +558,7 @@ class PurchaseRehearsalExecutor(
screen.problem?.let { return failure(it.code, it.message) }
val price = screen.priceCent ?: return failure("RULE_NOT_MATCHED", "没有读取到商品单价")
return if (price in input.minUnitPriceCent..input.maxUnitPriceCent) null
else failure("PURCHASE_PRICE_OUT_OF_RANGE", "当前商品单价超出允许范围")
else failure("PURCHASE_PRICE_OUT_OF_RANGE", "当前商品单价超出允许范围", price)
}
private fun verifySummary(
@@ -585,7 +585,7 @@ class PurchaseRehearsalExecutor(
if (readQuantity() != input.quantity) return failure("PURCHASE_QUANTITY_MISMATCH", "最终数量复核失败")
val price = screen.priceCent ?: observedPrice ?: return failure("RULE_NOT_MATCHED", "最终价格复核失败")
if (price !in input.minUnitPriceCent..input.maxUnitPriceCent) {
return failure("PURCHASE_PRICE_OUT_OF_RANGE", "当前商品单价超出允许范围")
return failure("PURCHASE_PRICE_OUT_OF_RANGE", "当前商品单价超出允许范围", price)
}
return null
}
@@ -636,7 +636,8 @@ class PurchaseRehearsalExecutor(
}?.singleOrNull()
}.getOrNull()
private fun failure(code: String, message: String) = PurchaseExecutionOutcome("failed", code, message)
private fun failure(code: String, message: String, actualUnitPriceCent: Long? = null) =
PurchaseExecutionOutcome("failed", code, message, actualUnitPriceCent = actualUnitPriceCent)
companion object {
private const val PDD_PACKAGE = "com.xunmeng.pinduoduo"
@@ -36,8 +36,10 @@ data class PurchaseDimensionAliases(
)
data class PurchasePriceGuard(
val minRatioBasisPoints: Int,
val maxRatioBasisPoints: Int,
val enabled: Boolean,
val minRatioBasisPoints: Int?,
val maxRatioBasisPoints: Int?,
val absoluteMaxUnitPriceCent: Long?,
)
data class PurchaseAction(
@@ -118,13 +120,31 @@ object PurchaseRuleParser {
private fun parsePriceGuard(root: JSONObject): PurchasePriceGuard? {
if (!root.has("priceGuard")) return null
val value = root.optJSONObject("priceGuard") ?: invalid("priceGuard 必须是对象")
rejectUnknown(value, setOf("minRatio", "maxRatio"), "priceGuard")
val minRatio = parseRatio(value, "minRatio")
val maxRatio = parseRatio(value, "maxRatio")
if (minRatio !in 1_000..10_000) invalid("priceGuard.minRatio 必须在 0.1..1.0 之间")
if (maxRatio !in 10_000..30_000) invalid("priceGuard.maxRatio 必须在 1.0..3.0 之间")
if (minRatio > maxRatio) invalid("priceGuard.minRatio 不能大于 maxRatio")
return PurchasePriceGuard(minRatio, maxRatio)
rejectUnknown(value, setOf("enabled", "minRatio", "maxRatio", "absoluteMaxUnitPriceCent"), "priceGuard")
val enabled = if (!value.has("enabled")) true else value.get("enabled") as? Boolean
?: invalid("priceGuard.enabled 必须是布尔值")
if (enabled) {
if (value.has("absoluteMaxUnitPriceCent")) invalid("启用价格倍率保护时不能提供 priceGuard.absoluteMaxUnitPriceCent")
val minRatio = parseRatio(value, "minRatio")
val maxRatio = parseRatio(value, "maxRatio")
if (minRatio !in 1_000..10_000) invalid("priceGuard.minRatio 必须在 0.1..1.0 之间")
if (maxRatio !in 10_000..30_000) invalid("priceGuard.maxRatio 必须在 1.0..3.0 之间")
if (minRatio > maxRatio) invalid("priceGuard.minRatio 不能大于 maxRatio")
return PurchasePriceGuard(true, minRatio, maxRatio, null)
}
if (value.has("minRatio") || value.has("maxRatio")) invalid("关闭价格倍率保护时不能提供 priceGuard.minRatio 或 priceGuard.maxRatio")
if (!value.has("absoluteMaxUnitPriceCent") || value.isNull("absoluteMaxUnitPriceCent")) {
invalid("关闭价格倍率保护时必须提供 priceGuard.absoluteMaxUnitPriceCent")
}
val absolute = when (val raw = value.get("absoluteMaxUnitPriceCent")) {
is Byte -> raw.toLong()
is Short -> raw.toLong()
is Int -> raw.toLong()
is Long -> raw
else -> invalid("priceGuard.absoluteMaxUnitPriceCent 必须是整数分")
}
if (absolute !in 1..100_000) invalid("priceGuard.absoluteMaxUnitPriceCent 必须在 1..100000 之间")
return PurchasePriceGuard(false, null, null, absolute)
}
private fun parseRatio(value: JSONObject, name: String): Int {
@@ -30,11 +30,26 @@ class PurchaseRehearsalExecutorTest {
val parsed = PurchaseRuleParser.parse(raw)
assertEquals(PurchaseAgentCapabilities.LIVE_V1, parsed.requiredCapabilities.first())
assertEquals(true, parsed.priceGuard?.enabled)
assertEquals(2000, parsed.priceGuard?.minRatioBasisPoints)
assertEquals(20000, parsed.priceGuard?.maxRatioBasisPoints)
assertEquals(listOf("颜色分类", "颜色", "款式", "颜色款式", "花色", "组合"), parsed.actions[3].dimensionAliases?.color)
}
@Test
fun `shared disabled price guard rule matches Android contract`() {
val raw = requireNotNull(javaClass.classLoader?.getResource("purchase-rule-live-price-guard-disabled-v1.json")) {
"shared disabled price guard fixture is missing"
}.readText(Charsets.UTF_8)
val parsed = PurchaseRuleParser.parse(raw)
assertEquals(false, parsed.priceGuard?.enabled)
assertEquals(null, parsed.priceGuard?.minRatioBasisPoints)
assertEquals(null, parsed.priceGuard?.maxRatioBasisPoints)
assertEquals(5_000L, parsed.priceGuard?.absoluteMaxUnitPriceCent)
}
@Test
fun `rule parameters drive aliases waits and bounded swipes without dangerous clicks`() {
val driver = FakePurchaseDriver()
@@ -57,6 +72,16 @@ class PurchaseRehearsalExecutorTest {
assertTrue(pauses.contains(700))
}
@Test
fun `price range failure reports the observed unit price`() {
val driver = FakePurchaseDriver()
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
.execute(input().copy(maxUnitPriceCent = 1_999), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals("PURCHASE_PRICE_OUT_OF_RANGE", outcome.errorCode)
assertEquals(2_000L, outcome.actualUnitPriceCent)
}
@Test
fun `unsupported capability fails before opening pdd`() {
val driver = FakePurchaseDriver()
@@ -561,9 +586,14 @@ class PurchaseRehearsalExecutorTest {
val rulePrefix = """{"schemaVersion":1,"ruleType":"pddPurchase","requiredCapabilities":["purchase.rehearsal.v1"],"priceGuard":"""
val ruleSuffix = ""","actions":[{"type":"openProduct"}]}"""
val equivalentDecimals = PurchaseRuleParser.parse(rulePrefix + """{"minRatio":0.20000,"maxRatio":2e0}""" + ruleSuffix)
assertEquals(true, equivalentDecimals.priceGuard?.enabled)
assertEquals(2000, equivalentDecimals.priceGuard?.minRatioBasisPoints)
assertEquals(20000, equivalentDecimals.priceGuard?.maxRatioBasisPoints)
val disabled = PurchaseRuleParser.parse(rulePrefix + """{"enabled":false,"absoluteMaxUnitPriceCent":5000}""" + ruleSuffix)
assertEquals(false, disabled.priceGuard?.enabled)
assertEquals(5_000L, disabled.priceGuard?.absoluteMaxUnitPriceCent)
listOf(
"null",
"[]",
@@ -578,6 +608,15 @@ class PurchaseRehearsalExecutorTest {
"""{"minRatio":0.09,"maxRatio":2}""",
"""{"minRatio":0.2,"maxRatio":3.01}""",
"""{"minRatio":1,"maxRatio":0.9}""",
"""{"enabled":false}""",
"""{"enabled":false,"absoluteMaxUnitPriceCent":0}""",
"""{"enabled":false,"absoluteMaxUnitPriceCent":100001}""",
"""{"enabled":false,"absoluteMaxUnitPriceCent":50.5}""",
"""{"enabled":false,"absoluteMaxUnitPriceCent":5000,"minRatio":0.2}""",
"""{"enabled":false,"absoluteMaxUnitPriceCent":5000,"minRatio":null}""",
"""{"enabled":true,"absoluteMaxUnitPriceCent":5000,"minRatio":0.2,"maxRatio":1.5}""",
"""{"enabled":true,"absoluteMaxUnitPriceCent":null,"minRatio":0.2,"maxRatio":1.5}""",
"""{"enabled":"false","absoluteMaxUnitPriceCent":5000}""",
).forEach { guard ->
assertTrue("expected price guard rejection for $guard", runCatching {
PurchaseRuleParser.parse(rulePrefix + guard + ruleSuffix)