diff --git a/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PurchaseLiveAutomation.kt b/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PurchaseLiveAutomation.kt index f38f1db..9133065 100644 --- a/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PurchaseLiveAutomation.kt +++ b/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PurchaseLiveAutomation.kt @@ -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" diff --git a/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PurchaseRehearsalExecutor.kt b/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PurchaseRehearsalExecutor.kt index bd7dea1..3622eb4 100644 --- a/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PurchaseRehearsalExecutor.kt +++ b/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PurchaseRehearsalExecutor.kt @@ -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" diff --git a/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PurchaseRuleContract.kt b/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PurchaseRuleContract.kt index cfb1c06..6e69dff 100644 --- a/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PurchaseRuleContract.kt +++ b/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PurchaseRuleContract.kt @@ -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 { diff --git a/android/app/src/test/java/cn/ilapage/goauto/agent/PurchaseRehearsalExecutorTest.kt b/android/app/src/test/java/cn/ilapage/goauto/agent/PurchaseRehearsalExecutorTest.kt index 9de4a92..a841e64 100644 --- a/android/app/src/test/java/cn/ilapage/goauto/agent/PurchaseRehearsalExecutorTest.kt +++ b/android/app/src/test/java/cn/ilapage/goauto/agent/PurchaseRehearsalExecutorTest.kt @@ -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) diff --git a/contracts/purchase-rule-live-price-guard-disabled-v1.json b/contracts/purchase-rule-live-price-guard-disabled-v1.json new file mode 100644 index 0000000..b367785 --- /dev/null +++ b/contracts/purchase-rule-live-price-guard-disabled-v1.json @@ -0,0 +1,33 @@ +{ + "schemaVersion": 1, + "ruleType": "pddPurchase", + "requiredCapabilities": [ + "purchase.live.v1", + "purchase.address-update.v1", + "purchase.order-create.v1", + "purchase.spec-probe.v1" + ], + "priceGuard": { + "enabled": false, + "absoluteMaxUnitPriceCent": 5000 + }, + "actions": [ + { "type": "openProduct" }, + { "type": "verifyProduct" }, + { "type": "openSpecPanel" }, + { + "type": "probeSpecs", + "dimensionAliases": { + "color": ["颜色分类", "颜色", "款式", "颜色款式", "花色", "组合"], + "size": ["尺码", "尺寸", "套餐", "参考分类"] + } + }, + { "type": "selectSpec" }, + { "type": "setQuantity" }, + { "type": "verifyUnitPrice" }, + { "type": "verifyOrderSummary" }, + { "type": "updateShippingAddress" }, + { "type": "createOrder" }, + { "type": "readOrderResult" } + ] +} diff --git a/docs/03-business-rules-and-glossary.md b/docs/03-business-rules-and-glossary.md index 36c29c3..c8e648b 100644 --- a/docs/03-business-rules-and-glossary.md +++ b/docs/03-business-rules-and-glossary.md @@ -2,8 +2,8 @@ generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件) wiki_page: Business-Rules-and-Glossary wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Business-Rules-and-Glossary.- -wiki_revision: 61b266de8fe17554a736e9a084b15c7f9eb777c2 -synchronized_at: 2026-08-31T09:01:46Z +wiki_revision: 85e881bd4fd1ebffb6af24d0f2f7512dfeda2a16 +synchronized_at: 2026-08-31T13:17:16Z @@ -382,6 +382,10 @@ synchronized_at: 2026-08-29T01:39:44Z ## 采购规则与 Agent 版本管理 - `purchase_rule` 保存管理员维护的采购规则;单例 `purchase_rule_setting` 指向当前规则。迁移以现有默认正式规则原字节播种首条记录。新建、批量预检、批量创建和安全重试读取当前规则;已有采购任务始终执行自身快照。 +- `priceGuard` 缺省或 `enabled=true` 时继续按参考价倍率保护:`minRatio` 为 0.1~1.0,`maxRatio` 为 1.0~3.0;旧规则未声明 `enabled` 时按启用处理。 +- 管理员可以设置 `priceGuard.enabled=false` 关闭参考价倍率保护,但必须同时配置整数分 `absoluteMaxUnitPriceCent`,范围 1~100000(¥0.01~¥1000.00),且不能混入倍率字段。服务端仍保留参考价用于预检和审计,并把新任务价格范围固化为 0~绝对上限;不存在无金额上限的采购模式。 +- Admin 保存关闭倍率保护的规则前必须二次确认,并在列表摘要和切换当前规则确认中显示绝对上限。规则编辑只影响新任务;既有任务继续使用自身规则和业务价格快照,安全的同任务重试也不得悄悄扩大原价格范围。 +- Android 始终以任务固化的 `minUnitPriceCent` / `maxUnitPriceCent` 为执行边界,在核价、订单摘要和创建订单前 fail-closed 校验;价格越界失败应回传实际观察单价供服务端保存和诊断。支付仍永久禁止。 - Agent 采购历史必须分开显示任务原始目标规格与服务端最终映射的执行规格;映射缺失不等于目标规格缺失。本地历史缓存同时保存两组字段,自动化始终只消费执行规格。 - “采购规则”是“采采管理”下与“采集规则”同级的管理员菜单,采购员角色没有菜单和 API 权限。 - Agent APK 版本由管理员在设备页抽屉中上传、核对并显式设为当前。服务端从 APK Manifest 读取整数 `versionCode` 和 `versionName`,保存 SHA-256、大小、说明和创建人;APK 位于非公开目录,Admin 与设备下载均需认证。 diff --git a/docs/08-agent-api-contract.md b/docs/08-agent-api-contract.md index 67ea529..e34e6a7 100644 --- a/docs/08-agent-api-contract.md +++ b/docs/08-agent-api-contract.md @@ -2,8 +2,8 @@ generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件) wiki_page: Android-Agent-API-Contract wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Android-Agent-API-Contract.- -wiki_revision: 8b0d54fcaa649f9b5b4eebabba8f02ff6128a431 -synchronized_at: 2026-08-31T09:00:48Z +wiki_revision: bf61d3493fc4cd16649c0564b87580a5b78704aa +synchronized_at: 2026-08-31T13:18:18Z @@ -825,6 +825,21 @@ file= | `GET` / `PUT` | `/api/admin/v1/purchase-rules/current` | 查看或按 `requestId + ruleId` 幂等切换当前规则 | 新采购任务、批量预检、批量创建、批量重试和安全的同任务重试读取当前规则;当前设置缺失、规则已删除或内容不通过正式采购契约时返回明确错误,不回退到内置常量。既有任务的 `ruleSnapshot` 不变。所有接口仅管理员可用。 +`priceGuard` 支持以下两种互斥形态: + +```json +{"enabled":true,"minRatio":0.2,"maxRatio":1.5} +``` + +```json +{"enabled":false,"absoluteMaxUnitPriceCent":5000} +``` + +- `enabled` 缺省时视为 `true`,兼容既有倍率规则;整个 `priceGuard` 缺省时继续使用默认倍率 0.2~1.5。 +- 启用时必须提供 `minRatio`(0.1~1.0)与 `maxRatio`(1.0~3.0),最多 4 位小数且最低不得大于最高;不得提供绝对上限。 +- 关闭时不得提供倍率字段,必须提供整数分 `absoluteMaxUnitPriceCent`,范围 1~100000。服务端仍从精确映射颜色读取参考价用于预检和审计,但新任务固化 `minUnitPriceCent=0`、`maxUnitPriceCent=absoluteMaxUnitPriceCent`。 +- Android 严格解析两种形态,但不根据规则自行重算价格范围,只执行任务下发的最小/最大单价;未知字段、混用字段、缺少绝对上限或越界值均以 `PURCHASE_RULE_INVALID` 拒绝。 +- `PURCHASE_PRICE_OUT_OF_RANGE` 失败结果应携带本次观察到的 `actualUnitPriceCent`;服务端保存该值用于任务历史诊断。该字段不授权 Agent 放宽任务边界。 ## Agent 应用版本接口 diff --git a/server/app/goauto/purchase/batch.go b/server/app/goauto/purchase/batch.go index 8a06ee4..735a410 100644 --- a/server/app/goauto/purchase/batch.go +++ b/server/app/goauto/purchase/batch.go @@ -313,7 +313,7 @@ func (s *Service) previewOne(ctx context.Context, id uint64) BatchPreviewItem { return s.previewFromDataset(ctx, id, dataset, true, purchasecontract.EffectivePriceGuard(currentRule)) } -func (s *Service) previewOneDeterministic(ctx context.Context, id uint64, guard purchasecontract.PriceGuard) BatchPreviewItem { +func (s *Service) previewOneDeterministic(ctx context.Context, id uint64, guard purchasecontract.PriceLimits) BatchPreviewItem { dataset, err := s.loadBatchPreviewDataset(ctx, []uint64{id}) if err != nil { item := BatchPreviewItem{SYBProductID: id} @@ -343,7 +343,7 @@ func taskPointerValue(value *uint64) uint64 { return *value } -func (s *Service) previewFromDataset(ctx context.Context, id uint64, dataset batchPreviewDataset, allowAI bool, guard purchasecontract.PriceGuard) BatchPreviewItem { +func (s *Service) previewFromDataset(ctx context.Context, id uint64, dataset batchPreviewDataset, allowAI bool, guard purchasecontract.PriceLimits) BatchPreviewItem { item := BatchPreviewItem{SYBProductID: id} syb, found := dataset.sybByID[id] if !found { @@ -431,7 +431,7 @@ func (s *Service) previewFromDataset(ctx context.Context, id uint64, dataset bat return item } -func purchasePriceRange(raw, mappedColor string, guard purchasecontract.PriceGuard) (reference, minPrice, maxPrice int64, err error) { +func purchasePriceRange(raw, mappedColor string, guard purchasecontract.PriceLimits) (reference, minPrice, maxPrice int64, err error) { var dimensions []productspec.Dimension if json.Unmarshal([]byte(raw), &dimensions) != nil { return 0, 0, 0, errors.New("拼多多商品规格数据无效,请先重新采集") @@ -458,6 +458,9 @@ func purchasePriceRange(raw, mappedColor string, guard purchasecontract.PriceGua } sort.Slice(prices, func(i, j int) bool { return prices[i] < prices[j] }) low, high := prices[0], prices[len(prices)-1] + if !guard.Enabled { + return high, 0, guard.AbsoluteMaxUnitPriceCent, nil + } minRatio, maxRatio := int64(guard.MinRatio), int64(guard.MaxRatio) return high, low * minRatio / purchasecontract.RatioScale, (high*maxRatio + purchasecontract.RatioScale - 1) / purchasecontract.RatioScale, nil } diff --git a/server/app/goauto/purchase/batch_test.go b/server/app/goauto/purchase/batch_test.go index ee5970a..7675394 100644 --- a/server/app/goauto/purchase/batch_test.go +++ b/server/app/goauto/purchase/batch_test.go @@ -2,6 +2,7 @@ package purchase import ( "context" + "encoding/json" "testing" "go-admin/app/goauto/models" @@ -43,18 +44,56 @@ func TestBatchPreviewUsesPDDPriceAndExplainsIneligibleRows(t *testing.T) { } } +func TestBatchCreateFreezesAbsolutePriceCeilingWhenRatiosAreDisabled(t *testing.T) { + db := testDB(t) + fixture := seed(t, db, liveCaps(), true) + setCollectedPDDPrice(t, db, fixture.pdd.ID) + var rule map[string]any + if err := json.Unmarshal(purchasecontract.DefaultLiveRule(), &rule); err != nil { + t.Fatal(err) + } + rule["priceGuard"] = map[string]any{"enabled": false, "absoluteMaxUnitPriceCent": 5000} + rawRule, err := json.Marshal(rule) + if err != nil { + t.Fatal(err) + } + if err := db.Model(&models.PurchaseRule{}).Where("deleted_at IS NULL").Update("content_json", string(rawRule)).Error; err != nil { + t.Fatal(err) + } + preview, err := testService(db).BatchPreview(context.Background(), BatchPreviewRequest{SYBProductIDs: []uint64{fixture.syb.ID}, DeviceID: &fixture.device.ID}) + if err != nil { + t.Fatal(err) + } + if len(preview.Items) != 1 || !preview.Items[0].Eligible || preview.Items[0].MinUnitPriceCent != 0 || preview.Items[0].MaxUnitPriceCent != 5000 { + t.Fatalf("disabled preview range mismatch: %+v", preview) + } + created, err := testService(db).BatchCreate(context.Background(), BatchCreateRequest{RequestID: uuid.NewString(), SYBProductIDs: []uint64{fixture.syb.ID}, DeviceID: &fixture.device.ID}) + if err != nil || created.CreatedCount != 1 || created.Items[0].TaskID == nil { + t.Fatalf("disabled batch create failed: %+v %v", created, err) + } + var task models.PurchaseTask + if err := db.First(&task, *created.Items[0].TaskID).Error; err != nil || task.MinUnitPriceCent != 0 || task.MaxUnitPriceCent != 5000 { + t.Fatalf("absolute task range was not frozen: %+v %v", task, err) + } +} + func TestPurchasePriceRangeUsesExactConfiguredRatiosAndLegacyDefaults(t *testing.T) { raw := `[{"name":"颜色","role":"color","values":[{"name":"黑色","selectable":true,"priceCent":1001},{"name":"白色","selectable":true,"priceCent":1003}]}]` - legacy := purchasecontract.PriceGuard{MinRatio: purchasecontract.Ratio(purchasecontract.DefaultMinRatio), MaxRatio: purchasecontract.Ratio(purchasecontract.DefaultMaxRatio)} + legacy := purchasecontract.PriceLimits{Enabled: true, MinRatio: purchasecontract.Ratio(purchasecontract.DefaultMinRatio), MaxRatio: purchasecontract.Ratio(purchasecontract.DefaultMaxRatio)} ref, minPrice, maxPrice, err := purchasePriceRange(raw, "", legacy) if err != nil || ref != 1003 || minPrice != 200 || maxPrice != 1505 { t.Fatalf("legacy range=%d,%d,%d err=%v", ref, minPrice, maxPrice, err) } - custom := purchasecontract.PriceGuard{MinRatio: purchasecontract.Ratio(3333), MaxRatio: purchasecontract.Ratio(22500)} + custom := purchasecontract.PriceLimits{Enabled: true, MinRatio: purchasecontract.Ratio(3333), MaxRatio: purchasecontract.Ratio(22500)} ref, minPrice, maxPrice, err = purchasePriceRange(raw, "", custom) if err != nil || ref != 1003 || minPrice != 333 || maxPrice != 2257 { t.Fatalf("custom range=%d,%d,%d err=%v", ref, minPrice, maxPrice, err) } + disabled := purchasecontract.PriceLimits{Enabled: false, AbsoluteMaxUnitPriceCent: 5000} + ref, minPrice, maxPrice, err = purchasePriceRange(raw, "", disabled) + if err != nil || ref != 1003 || minPrice != 0 || maxPrice != 5000 { + t.Fatalf("absolute range=%d,%d,%d err=%v", ref, minPrice, maxPrice, err) + } } func TestBatchPreviewUsesLinkActionBeforePDDMapping(t *testing.T) { diff --git a/server/app/goauto/purchase/lifecycle.go b/server/app/goauto/purchase/lifecycle.go index 82d2e82..c358eb3 100644 --- a/server/app/goauto/purchase/lifecycle.go +++ b/server/app/goauto/purchase/lifecycle.go @@ -386,6 +386,7 @@ func (s *Service) SubmitResult(ctx context.Context, taskID uint64, req ResultReq a.Status = models.PurchaseAttemptStatusFailed t.ErrorCode = &req.ErrorCode t.ErrorMessage = &req.ErrorMessage + t.ActualUnitPriceCent = req.ActualUnitPriceCent default: return TaskPayload{}, fail(CodeInvalidRequest, "resultType 无效") } diff --git a/server/app/goauto/purchase/process_stage.go b/server/app/goauto/purchase/process_stage.go index 297a7b9..a476e0e 100644 --- a/server/app/goauto/purchase/process_stage.go +++ b/server/app/goauto/purchase/process_stage.go @@ -81,11 +81,11 @@ func (s *Service) ProcessStages(ctx context.Context, ids []uint64) (map[uint64]P return result, nil } -func (s *Service) readOnlyPriceGuard(ctx context.Context) (purchasecontract.PriceGuard, string, error) { +func (s *Service) readOnlyPriceGuard(ctx context.Context) (purchasecontract.PriceLimits, string, error) { _, _, currentRule, err := purchaserule.CurrentRule(ctx, s.DB, models.PurchaseExecutionModeLive) return readOnlyRuleResult(currentRule, err) } -func readOnlyRuleResult(currentRule purchasecontract.RuleSnapshot, err error) (purchasecontract.PriceGuard, string, error) { +func readOnlyRuleResult(currentRule purchasecontract.RuleSnapshot, err error) (purchasecontract.PriceLimits, string, error) { if err == nil { return purchasecontract.EffectivePriceGuard(currentRule), "", nil } @@ -93,7 +93,7 @@ func readOnlyRuleResult(currentRule purchasecontract.RuleSnapshot, err error) (p if errors.As(err, &serviceErr) && (serviceErr.Code == purchaserule.CodeCurrentRuleMissing || serviceErr.Code == purchaserule.CodeRuleInvalid) { return purchasecontract.EffectivePriceGuard(purchasecontract.RuleSnapshot{}), serviceErr.Message, nil } - return purchasecontract.PriceGuard{}, "", err + return purchasecontract.PriceLimits{}, "", err } func (item *BatchPreviewItem) applyProcessStage(stage ProcessStageResult) { diff --git a/server/app/goauto/purchase/service_test.go b/server/app/goauto/purchase/service_test.go index 15cfe01..810c435 100644 --- a/server/app/goauto/purchase/service_test.go +++ b/server/app/goauto/purchase/service_test.go @@ -247,6 +247,35 @@ func TestCreateAndLifecycleValidateCapabilitiesAndIdempotentResult(t *testing.T) } } +func TestFailedResultStoresObservedUnitPrice(t *testing.T) { + db := testDB(t) + f := seed(t, db, liveCaps(), true) + s := testService(db) + task, err := createLive(t, s, f) + if err != nil { + t.Fatal(err) + } + if _, err = s.Claim(context.Background(), task.ID, ActionRequest{RequestID: uuid.NewString()}, f.token); err != nil { + t.Fatal(err) + } + started, err := s.Start(context.Background(), task.ID, ActionRequest{RequestID: uuid.NewString()}, f.token) + if err != nil { + t.Fatal(err) + } + actual := int64(368) + result, err := s.SubmitResult(context.Background(), task.ID, ResultRequest{ + RequestID: uuid.NewString(), TaskAttemptID: started.TaskAttemptID, ResultType: "failed", + ErrorCode: "PURCHASE_PRICE_OUT_OF_RANGE", ErrorMessage: "当前商品单价超出允许范围", ActualUnitPriceCent: &actual, + }, f.token) + if err != nil || result.Status != models.PurchaseTaskStatusFailed { + t.Fatalf("failed result rejected: %+v %v", result, err) + } + var stored models.PurchaseTask + if err := db.First(&stored, task.ID).Error; err != nil || stored.ActualUnitPriceCent == nil || *stored.ActualUnitPriceCent != actual { + t.Fatalf("failed actual unit price was not stored: %+v %v", stored, err) + } +} + func TestCapabilityMismatchAtCreateAndClaim(t *testing.T) { db := testDB(t) f := seed(t, db, []string{purchasecontract.CapabilityPurchaseLiveV1}, true) diff --git a/server/app/goauto/purchasecontract/contract.go b/server/app/goauto/purchasecontract/contract.go index 972b273..910db76 100644 --- a/server/app/goauto/purchasecontract/contract.go +++ b/server/app/goauto/purchasecontract/contract.go @@ -87,16 +87,56 @@ type RuleSnapshot struct { } const ( - RatioScale = int64(10_000) - DefaultMinRatio = int64(2_000) - DefaultMaxRatio = int64(15_000) + RatioScale = int64(10_000) + DefaultMinRatio = int64(2_000) + DefaultMaxRatio = int64(15_000) + MaxAbsoluteUnitPriceCent = int64(100_000) ) // PriceGuard uses fixed basis points internally so cent calculations never // depend on binary floating-point rounding. JSON remains a regular decimal. type PriceGuard struct { - MinRatio Ratio `json:"minRatio"` - MaxRatio Ratio `json:"maxRatio"` + Enabled *bool `json:"enabled,omitempty"` + MinRatio *Ratio `json:"minRatio,omitempty"` + MaxRatio *Ratio `json:"maxRatio,omitempty"` + AbsoluteMaxUnitPriceCent *int64 `json:"absoluteMaxUnitPriceCent,omitempty"` + enabledSet bool + minRatioSet bool + maxRatioSet bool + absoluteMaxSet bool +} + +func (g *PriceGuard) UnmarshalJSON(raw []byte) error { + type wire PriceGuard + decoder := json.NewDecoder(bytes.NewReader(raw)) + decoder.DisallowUnknownFields() + var value wire + if err := decoder.Decode(&value); err != nil { + return err + } + if err := ensureEOF(decoder); err != nil { + return err + } + var fields map[string]json.RawMessage + if err := json.Unmarshal(raw, &fields); err != nil { + return err + } + if enabled, exists := fields["enabled"]; exists && bytes.Equal(bytes.TrimSpace(enabled), []byte("null")) { + return errors.New("priceGuard.enabled 必须是布尔值") + } + *g = PriceGuard(value) + _, g.enabledSet = fields["enabled"] + _, g.minRatioSet = fields["minRatio"] + _, g.maxRatioSet = fields["maxRatio"] + _, g.absoluteMaxSet = fields["absoluteMaxUnitPriceCent"] + return nil +} + +type PriceLimits struct { + Enabled bool + MinRatio Ratio + MaxRatio Ratio + AbsoluteMaxUnitPriceCent int64 } type Ratio int64 @@ -118,11 +158,16 @@ func (r Ratio) MarshalJSON() ([]byte, error) { return []byte(strings.TrimRight(strings.TrimRight(fmt.Sprintf("%.4f", float64(r)/float64(RatioScale)), "0"), ".")), nil } -func EffectivePriceGuard(rule RuleSnapshot) PriceGuard { +func EffectivePriceGuard(rule RuleSnapshot) PriceLimits { if rule.PriceGuard == nil { - return PriceGuard{MinRatio: Ratio(DefaultMinRatio), MaxRatio: Ratio(DefaultMaxRatio)} + return PriceLimits{Enabled: true, MinRatio: Ratio(DefaultMinRatio), MaxRatio: Ratio(DefaultMaxRatio)} } - return *rule.PriceGuard + guard := rule.PriceGuard + enabled := guard.Enabled == nil || *guard.Enabled + if !enabled { + return PriceLimits{Enabled: false, AbsoluteMaxUnitPriceCent: *guard.AbsoluteMaxUnitPriceCent} + } + return PriceLimits{Enabled: true, MinRatio: *guard.MinRatio, MaxRatio: *guard.MaxRatio} } func Validate(raw []byte, executionMode string) (RuleSnapshot, error) { @@ -145,15 +190,31 @@ func Validate(raw []byte, executionMode string) (RuleSnapshot, error) { return rule, errors.New("actions 必须包含 1..64 个类型化动作") } if rule.PriceGuard != nil { - minRatio, maxRatio := int64(rule.PriceGuard.MinRatio), int64(rule.PriceGuard.MaxRatio) - if minRatio < 1_000 || minRatio > 10_000 { - return rule, errors.New("priceGuard.minRatio 必须在 0.1..1.0 之间") - } - if maxRatio < 10_000 || maxRatio > 30_000 { - return rule, errors.New("priceGuard.maxRatio 必须在 1.0..3.0 之间") - } - if minRatio > maxRatio { - return rule, errors.New("priceGuard.minRatio 不能大于 maxRatio") + enabled := rule.PriceGuard.Enabled == nil || *rule.PriceGuard.Enabled + if enabled { + if rule.PriceGuard.MinRatio == nil || rule.PriceGuard.MaxRatio == nil { + return rule, errors.New("启用价格倍率保护时必须提供 priceGuard.minRatio 和 priceGuard.maxRatio") + } + if rule.PriceGuard.absoluteMaxSet { + return rule, errors.New("启用价格倍率保护时不能提供 priceGuard.absoluteMaxUnitPriceCent") + } + minRatio, maxRatio := int64(*rule.PriceGuard.MinRatio), int64(*rule.PriceGuard.MaxRatio) + if minRatio < 1_000 || minRatio > 10_000 { + return rule, errors.New("priceGuard.minRatio 必须在 0.1..1.0 之间") + } + if maxRatio < 10_000 || maxRatio > 30_000 { + return rule, errors.New("priceGuard.maxRatio 必须在 1.0..3.0 之间") + } + if minRatio > maxRatio { + return rule, errors.New("priceGuard.minRatio 不能大于 maxRatio") + } + } else { + if rule.PriceGuard.minRatioSet || rule.PriceGuard.maxRatioSet { + return rule, errors.New("关闭价格倍率保护时不能提供 priceGuard.minRatio 或 priceGuard.maxRatio") + } + if rule.PriceGuard.AbsoluteMaxUnitPriceCent == nil || *rule.PriceGuard.AbsoluteMaxUnitPriceCent < 1 || *rule.PriceGuard.AbsoluteMaxUnitPriceCent > MaxAbsoluteUnitPriceCent { + return rule, fmt.Errorf("关闭价格倍率保护时 priceGuard.absoluteMaxUnitPriceCent 必须在 1..%d 之间", MaxAbsoluteUnitPriceCent) + } } } capabilities := make(map[string]bool, len(rule.RequiredCapabilities)) diff --git a/server/app/goauto/purchasecontract/contract_test.go b/server/app/goauto/purchasecontract/contract_test.go index 60b549d..ef4dcb6 100644 --- a/server/app/goauto/purchasecontract/contract_test.go +++ b/server/app/goauto/purchasecontract/contract_test.go @@ -24,11 +24,30 @@ func TestSharedLiveRuleFixtureMatchesServerContract(t *testing.T) { t.Fatalf("shared live rule rejected: %v", err) } guard := EffectivePriceGuard(rule) - if int64(guard.MinRatio) != 2000 || int64(guard.MaxRatio) != 20000 { + if !guard.Enabled || int64(guard.MinRatio) != 2000 || int64(guard.MaxRatio) != 20000 { t.Fatalf("shared price guard=%+v", guard) } } +func TestSharedDisabledPriceGuardFixtureMatchesServerContract(t *testing.T) { + _, currentFile, _, ok := runtime.Caller(0) + if !ok { + t.Fatal("cannot locate shared purchase rule fixture") + } + raw, err := os.ReadFile(filepath.Join(filepath.Dir(currentFile), "../../../../contracts/purchase-rule-live-price-guard-disabled-v1.json")) + if err != nil { + t.Fatal(err) + } + rule, err := Validate(raw, "live") + if err != nil { + t.Fatalf("shared disabled price guard rule rejected: %v", err) + } + guard := EffectivePriceGuard(rule) + if guard.Enabled || guard.AbsoluteMaxUnitPriceCent != 5000 { + t.Fatalf("shared disabled price guard=%+v", guard) + } +} + func TestRehearsalRuleAcceptsBoundedParameters(t *testing.T) { raw := []byte(`{"schemaVersion":1,"ruleType":"pddPurchase","requiredCapabilities":["purchase.rehearsal.v1","purchase.spec-probe.v1"],"actions":[{"type":"openProduct","textAliases":["打开拼多多APP","打开"],"waitAfterMs":1000},{"type":"openSpecPanel","textAliases":["选择规格"],"swipeAfter":{"direction":"up","count":2,"durationMs":500,"intervalMs":1000}},{"type":"probeSpecs","dimensionAliases":{"color":["颜色分类","组合"],"size":["尺码"]},"swipeAfter":{"direction":"left","count":3,"durationMs":300}}]}`) rule, err := Validate(raw, "rehearsal") @@ -64,7 +83,7 @@ func TestPriceGuardDefaultsAndExactDecimals(t *testing.T) { if err != nil { t.Fatal(err) } - if guard := EffectivePriceGuard(legacy); int64(guard.MinRatio) != 2000 || int64(guard.MaxRatio) != 15000 { + if guard := EffectivePriceGuard(legacy); !guard.Enabled || int64(guard.MinRatio) != 2000 || int64(guard.MaxRatio) != 15000 { t.Fatalf("legacy defaults=%+v", guard) } raw := []byte(`{"schemaVersion":1,"ruleType":"pddPurchase","requiredCapabilities":["purchase.live.v1"],"priceGuard":{"minRatio":0.3333,"maxRatio":2.25},"actions":[{"type":"openProduct"}]}`) @@ -73,7 +92,7 @@ func TestPriceGuardDefaultsAndExactDecimals(t *testing.T) { t.Fatal(err) } guard := EffectivePriceGuard(rule) - if int64(guard.MinRatio) != 3333 || int64(guard.MaxRatio) != 22500 { + if !guard.Enabled || int64(guard.MinRatio) != 3333 || int64(guard.MaxRatio) != 22500 { t.Fatalf("fixed ratios=%+v", guard) } encoded, err := json.Marshal(rule) @@ -82,6 +101,42 @@ func TestPriceGuardDefaultsAndExactDecimals(t *testing.T) { } } +func TestPriceGuardCanDisableRatiosWithAbsoluteCeiling(t *testing.T) { + raw := []byte(`{"schemaVersion":1,"ruleType":"pddPurchase","requiredCapabilities":["purchase.live.v1"],"priceGuard":{"enabled":false,"absoluteMaxUnitPriceCent":5000},"actions":[{"type":"openProduct"}]}`) + rule, err := Validate(raw, "live") + if err != nil { + t.Fatal(err) + } + guard := EffectivePriceGuard(rule) + if guard.Enabled || guard.AbsoluteMaxUnitPriceCent != 5000 || guard.MinRatio != 0 || guard.MaxRatio != 0 { + t.Fatalf("disabled price guard=%+v", guard) + } + encoded, err := json.Marshal(rule) + if err != nil || !strings.Contains(string(encoded), `"enabled":false`) || !strings.Contains(string(encoded), `"absoluteMaxUnitPriceCent":5000`) { + t.Fatalf("encoded=%s err=%v", encoded, err) + } +} + +func TestPriceGuardRejectsUnsafeDisabledShapes(t *testing.T) { + for _, guard := range []string{ + `{"enabled":false}`, + `{"enabled":null,"minRatio":0.2,"maxRatio":1.5}`, + `{"enabled":"false","absoluteMaxUnitPriceCent":5000}`, + `{"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}`, + } { + raw := []byte(`{"schemaVersion":1,"ruleType":"pddPurchase","requiredCapabilities":["purchase.live.v1"],"priceGuard":` + guard + `,"actions":[{"type":"openProduct"}]}`) + if _, err := Validate(raw, "live"); err == nil { + t.Fatalf("accepted price guard %s", guard) + } + } +} + func TestPriceGuardRejectsUnsafeBounds(t *testing.T) { for _, guard := range []string{`{"minRatio":0.09,"maxRatio":1.5}`, `{"minRatio":0.2,"maxRatio":3.01}`, `{"minRatio":1,"maxRatio":0.9}`, `{"minRatio":0.12345,"maxRatio":1.5}`} { raw := []byte(`{"schemaVersion":1,"ruleType":"pddPurchase","requiredCapabilities":["purchase.live.v1"],"priceGuard":` + guard + `,"actions":[{"type":"openProduct"}]}`) diff --git a/web/src/views/goauto/purchase-rules/index.vue b/web/src/views/goauto/purchase-rules/index.vue index 7195f04..256aab5 100644 --- a/web/src/views/goauto/purchase-rules/index.vue +++ b/web/src/views/goauto/purchase-rules/index.vue @@ -27,10 +27,18 @@ -
+ + +
关闭后不再按采集参考价计算倍率范围,但仍强制校验单件绝对最高价。
+
+
范围 0.1~1.0,默认 0.2;最低价向下取整到分。
范围 1.0~3.0,默认 1.5;最高价向上取整到分。
+
+ +
必填,范围 ¥0.01~¥1000.00;Android 创建订单前仍会严格校验。
+
仅支持 schemaVersion=1、ruleType=pddPurchase;服务端会再次执行严格字段和安全动作校验。
@@ -43,28 +51,49 @@ import { ElMessage, ElMessageBox } from 'element-plus' import { Plus, RefreshLeft, Search } from '@element-plus/icons-vue' import { createPurchaseRule, deletePurchaseRule, listPurchaseRules, setCurrentPurchaseRule, updatePurchaseRule } from '@/api/goauto/purchase-rules' -const defaultRule = { schemaVersion: 1, ruleType: 'pddPurchase', requiredCapabilities: ['purchase.live.v1'], priceGuard: { minRatio: 0.2, maxRatio: 1.5 }, actions: [{ type: 'openProduct' }, { type: 'verifyProduct' }, { type: 'openSpecPanel' }, { type: 'selectSpec' }, { type: 'setQuantity' }, { type: 'verifyUnitPrice' }, { type: 'verifyOrderSummary' }] } +const defaultRule = { schemaVersion: 1, ruleType: 'pddPurchase', requiredCapabilities: ['purchase.live.v1'], priceGuard: { enabled: true, minRatio: 0.2, maxRatio: 1.5 }, actions: [{ type: 'openProduct' }, { type: 'verifyProduct' }, { type: 'openSpecPanel' }, { type: 'selectSpec' }, { type: 'setQuantity' }, { type: 'verifyUnitPrice' }, { type: 'verifyOrderSummary' }] } +const defaultForm = () => ({ name: '', priceGuardEnabled: true, minRatio: 0.2, maxRatio: 1.5, absoluteMaxUnitPriceYuan: 50, contentText: '' }) export default { name: 'GoAutoPurchaseRules', setup() { return { Plus, RefreshLeft, Search } }, data() { const jsonRule = (_r, value, callback) => { try { const parsed = JSON.parse(value); if (!parsed || Array.isArray(parsed) || parsed.schemaVersion !== 1 || parsed.ruleType !== 'pddPurchase') return callback(new Error('请输入有效的 PDD 采购规则 JSON')); callback() } catch (_) { callback(new Error('请输入有效 JSON')) } } - return { loading: false, switching: null, items: [], total: 0, query: { page: 1, pageSize: 20, name: '' }, dialog: { open: false, saving: false, ruleId: null }, form: { name: '', minRatio: 0.2, maxRatio: 1.5, contentText: '' }, rules: { name: [{ required: true, message: '请输入规则名称', trigger: 'blur' }], contentText: [{ required: true, validator: jsonRule, trigger: 'blur' }] }} + return { loading: false, switching: null, items: [], total: 0, query: { page: 1, pageSize: 20, name: '' }, dialog: { open: false, saving: false, ruleId: null }, form: defaultForm(), rules: { name: [{ required: true, message: '请输入规则名称', trigger: 'blur' }], contentText: [{ required: true, validator: jsonRule, trigger: 'blur' }] }} }, created() { this.load() }, methods: { async load() { this.loading = true; try { const response = await listPurchaseRules(this.query); this.items = response.data.items; this.total = response.data.total } finally { this.loading = false } }, search() { this.query.page = 1; this.load() }, reset() { this.query = { page: 1, pageSize: 20, name: '' }; this.load() }, - summary(content) { const guard = content.priceGuard || { minRatio: 0.2, maxRatio: 1.5 }; return `${content.requiredCapabilities?.length || 0} 项能力 · ${content.actions?.length || 0} 个动作 · 价格 ${guard.minRatio}~${guard.maxRatio} 倍` }, - openCreate() { this.dialog = { open: true, saving: false, ruleId: null }; this.form = { name: '', minRatio: 0.2, maxRatio: 1.5, contentText: JSON.stringify(defaultRule, null, 2) } }, - openEdit(row) { const guard = row.content.priceGuard || { minRatio: 0.2, maxRatio: 1.5 }; this.dialog = { open: true, saving: false, ruleId: row.id }; this.form = { name: row.name, minRatio: guard.minRatio, maxRatio: guard.maxRatio, contentText: JSON.stringify(row.content, null, 2) } }, - async save() { const valid = await this.$refs.form.validate().catch(() => false); if (!valid) return; if (this.form.minRatio > this.form.maxRatio) { ElMessage.error('最低价比例不能大于最高价比例'); return } this.dialog.saving = true; const content = JSON.parse(this.form.contentText); content.priceGuard = { minRatio: this.form.minRatio, maxRatio: this.form.maxRatio }; const payload = { requestId: crypto.randomUUID(), name: this.form.name.trim(), content }; try { if (this.dialog.ruleId) await updatePurchaseRule(this.dialog.ruleId, payload); else await createPurchaseRule(payload); ElMessage.success('采购规则已保存'); this.dialog.open = false; await this.load() } finally { this.dialog.saving = false } }, - async setCurrent(row) { await ElMessageBox.confirm(`设为当前后,新建及安全重试的采购任务将使用“${row.name}”。`, '切换当前采购规则', { type: 'warning', confirmButtonText: '确认切换', cancelButtonText: '取消' }); this.switching = row.id; try { await setCurrentPurchaseRule({ requestId: crypto.randomUUID(), ruleId: row.id }); ElMessage.success('当前采购规则已切换'); await this.load() } finally { this.switching = null } }, + summary(content) { const guard = content.priceGuard || { minRatio: 0.2, maxRatio: 1.5 }; const price = guard.enabled === false ? `倍率保护关闭 · 单件上限 ¥${this.formatYuan(guard.absoluteMaxUnitPriceCent)}` : `价格 ${guard.minRatio}~${guard.maxRatio} 倍`; return `${content.requiredCapabilities?.length || 0} 项能力 · ${content.actions?.length || 0} 个动作 · ${price}` }, + formatYuan(cent) { return (Number(cent || 0) / 100).toFixed(2) }, + openCreate() { this.dialog = { open: true, saving: false, ruleId: null }; this.form = { ...defaultForm(), contentText: JSON.stringify(defaultRule, null, 2) } }, + openEdit(row) { const guard = row.content.priceGuard || { minRatio: 0.2, maxRatio: 1.5 }; const enabled = guard.enabled !== false; this.dialog = { open: true, saving: false, ruleId: row.id }; this.form = { name: row.name, priceGuardEnabled: enabled, minRatio: guard.minRatio ?? 0.2, maxRatio: guard.maxRatio ?? 1.5, absoluteMaxUnitPriceYuan: enabled ? 50 : guard.absoluteMaxUnitPriceCent / 100, contentText: JSON.stringify(row.content, null, 2) } }, + async save() { + const valid = await this.$refs.form.validate().catch(() => false) + if (!valid) return + if (this.form.priceGuardEnabled && this.form.minRatio > this.form.maxRatio) { ElMessage.error('最低价比例不能大于最高价比例'); return } + const absoluteYuan = Number(this.form.absoluteMaxUnitPriceYuan) + const absoluteCent = Math.round(absoluteYuan * 100) + if (!this.form.priceGuardEnabled) { + if (!Number.isFinite(absoluteYuan) || absoluteYuan < 0.01 || absoluteYuan > 1000 || Math.abs(absoluteYuan * 100 - absoluteCent) > 1e-7) { ElMessage.error('单件绝对最高价必须为 ¥0.01~¥1000.00,最多两位小数'); return } + const confirmed = await ElMessageBox.confirm(`关闭后,新任务不再校验参考价倍率,仅校验单件绝对最高价 ¥${absoluteYuan.toFixed(2)}。已有任务不变。`, '确认关闭参考价倍率保护', { type: 'error', confirmButtonText: '确认关闭并保存', cancelButtonText: '取消' }).then(() => true).catch(() => false) + if (!confirmed) return + } + this.dialog.saving = true + const content = JSON.parse(this.form.contentText) + content.priceGuard = this.form.priceGuardEnabled ? { enabled: true, minRatio: this.form.minRatio, maxRatio: this.form.maxRatio } : { enabled: false, absoluteMaxUnitPriceCent: absoluteCent } + const payload = { requestId: crypto.randomUUID(), name: this.form.name.trim(), content } + try { + if (this.dialog.ruleId) await updatePurchaseRule(this.dialog.ruleId, payload); else await createPurchaseRule(payload) + ElMessage.success('采购规则已保存'); this.dialog.open = false; await this.load() + } finally { this.dialog.saving = false } + }, + async setCurrent(row) { const guard = row.content.priceGuard; const risk = guard?.enabled === false ? `该规则已关闭参考价倍率保护,单件绝对最高价为 ¥${this.formatYuan(guard.absoluteMaxUnitPriceCent)}。` : ''; await ElMessageBox.confirm(`设为当前后,新建及安全重试的采购任务将使用“${row.name}”。${risk}`, '切换当前采购规则', { type: guard?.enabled === false ? 'error' : 'warning', confirmButtonText: '确认切换', cancelButtonText: '取消' }); this.switching = row.id; try { await setCurrentPurchaseRule({ requestId: crypto.randomUUID(), ruleId: row.id }); ElMessage.success('当前采购规则已切换'); await this.load() } finally { this.switching = null } }, async remove(row) { await ElMessageBox.confirm(`确定删除“${row.name}”吗?已有任务快照不受影响。`, '删除采购规则', { type: 'warning', confirmButtonText: '确认删除', cancelButtonText: '取消' }); await deletePurchaseRule(row.id, { requestId: crypto.randomUUID() }); ElMessage.success('采购规则已删除'); await this.load() } } }