feat(purchase): allow absolute price ceiling (#180)
This commit is contained in:
+4
-3
@@ -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"
|
||||
|
||||
+5
-4
@@ -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"
|
||||
|
||||
+29
-9
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user