From 56443c995b09bf490c3c0ba51b05896ae7aef10f Mon Sep 17 00:00:00 2001 From: QiuSW <105186638@qq.com> Date: Sat, 29 Aug 2026 21:24:55 +0800 Subject: [PATCH] feat(goauto): parameterize purchase price guard (#146) --- server/app/goauto/purchase/batch.go | 25 +++++--- server/app/goauto/purchase/batch_test.go | 14 +++++ server/app/goauto/purchase/process_stage.go | 8 ++- server/app/goauto/purchase/retry.go | 15 +++-- .../app/goauto/purchasecontract/contract.go | 61 +++++++++++++++++-- .../goauto/purchasecontract/contract_test.go | 33 ++++++++++ web/src/views/goauto/purchase-rules/index.vue | 18 +++--- 7 files changed, 149 insertions(+), 25 deletions(-) diff --git a/server/app/goauto/purchase/batch.go b/server/app/goauto/purchase/batch.go index 4b1204e..71568c8 100644 --- a/server/app/goauto/purchase/batch.go +++ b/server/app/goauto/purchase/batch.go @@ -97,7 +97,7 @@ func (s *Service) BatchPreview(ctx context.Context, req BatchPreviewRequest) (Ba items := make([]BatchPreviewItem, 0, len(ids)) eligible := 0 for _, id := range ids { - item := s.previewFromDataset(ctx, id, dataset, false) + item := s.previewFromDataset(ctx, id, dataset, false, purchasecontract.EffectivePriceGuard(currentRule)) item.applyProcessStage(processStageFromDataset(id, dataset, item)) if item.Eligible && deviceErr != nil { item.Eligible = false @@ -227,7 +227,7 @@ func (s *Service) BatchCreate(ctx context.Context, req BatchCreateRequest) (Batc return BatchCreateResponse{}, internal(err) } - preview := s.previewOneDeterministic(ctx, id) + preview := s.previewOneDeterministic(ctx, id, purchasecontract.EffectivePriceGuard(currentRule)) if preview.Eligible && deviceErr != nil { preview.Eligible = false preview.ReasonCode, preview.Reason, preview.NextAction = serviceErrorFields(deviceErr) @@ -290,23 +290,29 @@ func (s *Service) validateBatchDevice(ctx context.Context, deviceID *uint64, rul } func (s *Service) previewOne(ctx context.Context, id uint64) BatchPreviewItem { + _, _, currentRule, ruleErr := purchaserule.CurrentRule(ctx, s.DB, models.PurchaseExecutionModeLive) + if ruleErr != nil { + item := BatchPreviewItem{SYBProductID: id} + item.ReasonCode, item.Reason, item.NextAction = serviceErrorFields(ruleErr) + return item + } dataset, err := s.loadBatchPreviewDataset(ctx, []uint64{id}) if err != nil { item := BatchPreviewItem{SYBProductID: id} item.ReasonCode, item.Reason, item.NextAction = CodeInternal, "服务端处理失败", "retry" return item } - return s.previewFromDataset(ctx, id, dataset, true) + return s.previewFromDataset(ctx, id, dataset, true, purchasecontract.EffectivePriceGuard(currentRule)) } -func (s *Service) previewOneDeterministic(ctx context.Context, id uint64) BatchPreviewItem { +func (s *Service) previewOneDeterministic(ctx context.Context, id uint64, guard purchasecontract.PriceGuard) BatchPreviewItem { dataset, err := s.loadBatchPreviewDataset(ctx, []uint64{id}) if err != nil { item := BatchPreviewItem{SYBProductID: id} item.ReasonCode, item.Reason, item.NextAction = CodeInternal, "服务端处理失败", "retry" return item } - return s.previewFromDataset(ctx, id, dataset, false) + return s.previewFromDataset(ctx, id, dataset, false, guard) } func replayBatchPreview(task models.PurchaseTask) BatchPreviewItem { @@ -329,7 +335,7 @@ func taskPointerValue(value *uint64) uint64 { return *value } -func (s *Service) previewFromDataset(ctx context.Context, id uint64, dataset batchPreviewDataset, allowAI bool) BatchPreviewItem { +func (s *Service) previewFromDataset(ctx context.Context, id uint64, dataset batchPreviewDataset, allowAI bool, guard purchasecontract.PriceGuard) BatchPreviewItem { item := BatchPreviewItem{SYBProductID: id} syb, found := dataset.sybByID[id] if !found { @@ -388,7 +394,7 @@ func (s *Service) previewFromDataset(ctx context.Context, id uint64, dataset bat } } } - reference, minPrice, maxPrice, err := purchasePriceRange(pdd.SpecsJSON, item.MappedColor) + reference, minPrice, maxPrice, err := purchasePriceRange(pdd.SpecsJSON, item.MappedColor, guard) if err != nil { item.ReasonCode, item.Reason, item.NextAction = "PDD_PRICE_MISSING", err.Error(), "open_pdd" return item @@ -417,7 +423,7 @@ func (s *Service) previewFromDataset(ctx context.Context, id uint64, dataset bat return item } -func purchasePriceRange(raw, mappedColor string) (reference, minPrice, maxPrice int64, err error) { +func purchasePriceRange(raw, mappedColor string, guard purchasecontract.PriceGuard) (reference, minPrice, maxPrice int64, err error) { var dimensions []product.SpecDimension if json.Unmarshal([]byte(raw), &dimensions) != nil { return 0, 0, 0, errors.New("拼多多商品规格数据无效,请先重新采集") @@ -444,7 +450,8 @@ func purchasePriceRange(raw, mappedColor string) (reference, minPrice, maxPrice } sort.Slice(prices, func(i, j int) bool { return prices[i] < prices[j] }) low, high := prices[0], prices[len(prices)-1] - return high, low / 5, (high*3 + 1) / 2, nil + minRatio, maxRatio := int64(guard.MinRatio), int64(guard.MaxRatio) + return high, low * minRatio / purchasecontract.RatioScale, (high*maxRatio + purchasecontract.RatioScale - 1) / purchasecontract.RatioScale, nil } func batchItemRequestID(batchRequestID string, sybProductID uint64) string { diff --git a/server/app/goauto/purchase/batch_test.go b/server/app/goauto/purchase/batch_test.go index 60afbed..ee5970a 100644 --- a/server/app/goauto/purchase/batch_test.go +++ b/server/app/goauto/purchase/batch_test.go @@ -43,6 +43,20 @@ func TestBatchPreviewUsesPDDPriceAndExplainsIneligibleRows(t *testing.T) { } } +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)} + 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)} + 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) + } +} + func TestBatchPreviewUsesLinkActionBeforePDDMapping(t *testing.T) { db := testDB(t) fixture := seed(t, db, liveCaps(), true) diff --git a/server/app/goauto/purchase/process_stage.go b/server/app/goauto/purchase/process_stage.go index 2a3cd16..e796009 100644 --- a/server/app/goauto/purchase/process_stage.go +++ b/server/app/goauto/purchase/process_stage.go @@ -5,6 +5,8 @@ import ( "strings" "go-admin/app/goauto/models" + "go-admin/app/goauto/purchasecontract" + "go-admin/app/goauto/purchaserule" ) const ( @@ -61,8 +63,12 @@ func (s *Service) ProcessStages(ctx context.Context, ids []uint64) (map[uint64]P if err != nil { return nil, err } + _, _, currentRule, err := purchaserule.CurrentRule(ctx, s.DB, models.PurchaseExecutionModeLive) + if err != nil { + return nil, err + } for _, id := range ids { - preview := s.previewFromDataset(ctx, id, dataset, false) + preview := s.previewFromDataset(ctx, id, dataset, false, purchasecontract.EffectivePriceGuard(currentRule)) result[id] = processStageFromDataset(id, dataset, preview) } return result, nil diff --git a/server/app/goauto/purchase/retry.go b/server/app/goauto/purchase/retry.go index 4ba3cfb..06c5898 100644 --- a/server/app/goauto/purchase/retry.go +++ b/server/app/goauto/purchase/retry.go @@ -8,6 +8,7 @@ import ( "go-admin/app/goauto/device" "go-admin/app/goauto/models" + "go-admin/app/goauto/purchasecontract" "go-admin/app/goauto/purchaserule" "go-admin/app/goauto/replacement" @@ -254,19 +255,25 @@ func (s *Service) continuePurchaseEligibility(ctx context.Context, task models.P if err != nil { return retryDecision{ReasonCode: CodeInternal, Reason: "服务端处理失败"} } + _, _, currentRule, err := purchaserule.CurrentRule(ctx, s.DB, models.PurchaseExecutionModeLive) + if err != nil { + code, message, _ := serviceErrorFields(err) + return retryDecision{ReasonCode: code, Reason: message} + } + guard := purchasecontract.EffectivePriceGuard(currentRule) syb, found := dataset.sybByID[*task.SYBProductID] if !found || syb.ShopeeProductID == nil { - preview := s.previewFromDataset(ctx, *task.SYBProductID, dataset, false) + preview := s.previewFromDataset(ctx, *task.SYBProductID, dataset, false, guard) return retryDecision{ReasonCode: preview.ReasonCode, Reason: preview.Reason} } shopee, found := dataset.shopeeByID[*syb.ShopeeProductID] if !found || shopee.PDDProductID == nil { - preview := s.previewFromDataset(ctx, *task.SYBProductID, dataset, false) + preview := s.previewFromDataset(ctx, *task.SYBProductID, dataset, false, guard) return retryDecision{ReasonCode: preview.ReasonCode, Reason: preview.Reason} } pdd, found := dataset.pddByID[*shopee.PDDProductID] if !found { - preview := s.previewFromDataset(ctx, *task.SYBProductID, dataset, false) + preview := s.previewFromDataset(ctx, *task.SYBProductID, dataset, false, guard) return retryDecision{ReasonCode: preview.ReasonCode, Reason: preview.Reason} } mappedColor, mappedSize, source := confirmedMappings(shopee.SpecsJSON, syb.TargetColor, syb.TargetSize) @@ -274,7 +281,7 @@ func (s *Service) continuePurchaseEligibility(ctx context.Context, task models.P if source == "unresolved" || !mappingTargetsValid(candidates, syb.TargetColor, syb.TargetSize, mappedColor, mappedSize) { return retryDecision{ReasonCode: CodeMappingRequired, Reason: "规格匹配已失效,请在 Admin 重新确认"} } - preview := s.previewFromDataset(ctx, *task.SYBProductID, dataset, false) + preview := s.previewFromDataset(ctx, *task.SYBProductID, dataset, false, guard) if !preview.Eligible { return retryDecision{ReasonCode: preview.ReasonCode, Reason: preview.Reason} } diff --git a/server/app/goauto/purchasecontract/contract.go b/server/app/goauto/purchasecontract/contract.go index 95b1069..eb788e5 100644 --- a/server/app/goauto/purchasecontract/contract.go +++ b/server/app/goauto/purchasecontract/contract.go @@ -6,6 +6,7 @@ import ( "errors" "fmt" "io" + "math" "regexp" "sort" "strings" @@ -72,10 +73,50 @@ type SwipePlan struct { } type RuleSnapshot struct { - SchemaVersion int `json:"schemaVersion"` - RuleType string `json:"ruleType"` - RequiredCapabilities []string `json:"requiredCapabilities"` - Actions []Action `json:"actions"` + SchemaVersion int `json:"schemaVersion"` + RuleType string `json:"ruleType"` + RequiredCapabilities []string `json:"requiredCapabilities"` + Actions []Action `json:"actions"` + PriceGuard *PriceGuard `json:"priceGuard,omitempty"` +} + +const ( + RatioScale = int64(10_000) + DefaultMinRatio = int64(2_000) + DefaultMaxRatio = int64(15_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"` +} + +type Ratio int64 + +func (r *Ratio) UnmarshalJSON(raw []byte) error { + var value float64 + if err := json.Unmarshal(raw, &value); err != nil || math.IsNaN(value) || math.IsInf(value, 0) { + return errors.New("价格比例必须是数字") + } + scaled := value * float64(RatioScale) + if math.Abs(scaled-math.Round(scaled)) > 1e-7 { + return errors.New("价格比例最多支持 4 位小数") + } + *r = Ratio(math.Round(scaled)) + return nil +} + +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 { + if rule.PriceGuard == nil { + return PriceGuard{MinRatio: Ratio(DefaultMinRatio), MaxRatio: Ratio(DefaultMaxRatio)} + } + return *rule.PriceGuard } func Validate(raw []byte, executionMode string) (RuleSnapshot, error) { @@ -97,6 +138,18 @@ func Validate(raw []byte, executionMode string) (RuleSnapshot, error) { if len(rule.Actions) == 0 || len(rule.Actions) > 64 { 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") + } + } capabilities := make(map[string]bool, len(rule.RequiredCapabilities)) for _, capability := range rule.RequiredCapabilities { if !capabilityPattern.MatchString(capability) { diff --git a/server/app/goauto/purchasecontract/contract_test.go b/server/app/goauto/purchasecontract/contract_test.go index 0f7ab07..9fe12a0 100644 --- a/server/app/goauto/purchasecontract/contract_test.go +++ b/server/app/goauto/purchasecontract/contract_test.go @@ -1,6 +1,7 @@ package purchasecontract import ( + "encoding/json" "reflect" "strings" "testing" @@ -33,6 +34,38 @@ func TestTypeOnlyRuleRemainsCompatible(t *testing.T) { } } +func TestPriceGuardDefaultsAndExactDecimals(t *testing.T) { + legacy, err := Validate(DefaultLiveRule(), "live") + if err != nil { + t.Fatal(err) + } + if guard := EffectivePriceGuard(legacy); 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"}]}`) + rule, err := Validate(raw, "live") + if err != nil { + t.Fatal(err) + } + guard := EffectivePriceGuard(rule) + if int64(guard.MinRatio) != 3333 || int64(guard.MaxRatio) != 22500 { + t.Fatalf("fixed ratios=%+v", guard) + } + encoded, err := json.Marshal(rule) + if err != nil || !strings.Contains(string(encoded), `"minRatio":0.3333`) || !strings.Contains(string(encoded), `"maxRatio":2.25`) { + t.Fatalf("encoded=%s err=%v", encoded, err) + } +} + +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"}]}`) + if _, err := Validate(raw, "live"); err == nil { + t.Fatalf("accepted price guard %s", guard) + } + } +} + func TestParameterizedRuleRejectsUnknownFields(t *testing.T) { tests := []string{ `{"schemaVersion":1,"ruleType":"pddPurchase","requiredCapabilities":["purchase.rehearsal.v1"],"actions":[{"type":"openProduct","selector":{"text":"打开"}}]}`, diff --git a/web/src/views/goauto/purchase-rules/index.vue b/web/src/views/goauto/purchase-rules/index.vue index 2d5fdec..7195f04 100644 --- a/web/src/views/goauto/purchase-rules/index.vue +++ b/web/src/views/goauto/purchase-rules/index.vue @@ -27,6 +27,10 @@ +
+
范围 0.1~1.0,默认 0.2;最低价向下取整到分。
+
范围 1.0~3.0,默认 1.5;最高价向上取整到分。
+
仅支持 schemaVersion=1、ruleType=pddPurchase;服务端会再次执行严格字段和安全动作校验。
@@ -39,22 +43,22 @@ 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'], 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: { minRatio: 0.2, maxRatio: 1.5 }, actions: [{ type: 'openProduct' }, { type: 'verifyProduct' }, { type: 'openSpecPanel' }, { type: 'selectSpec' }, { type: 'setQuantity' }, { type: 'verifyUnitPrice' }, { type: 'verifyOrderSummary' }] } 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: '', 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: { name: '', minRatio: 0.2, maxRatio: 1.5, contentText: '' }, 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) { return `${content.requiredCapabilities?.length || 0} 项能力 · ${content.actions?.length || 0} 个动作` }, - openCreate() { this.dialog = { open: true, saving: false, ruleId: null }; this.form = { name: '', contentText: JSON.stringify(defaultRule, null, 2) } }, - openEdit(row) { this.dialog = { open: true, saving: false, ruleId: row.id }; this.form = { name: row.name, contentText: JSON.stringify(row.content, null, 2) } }, - async save() { const valid = await this.$refs.form.validate().catch(() => false); if (!valid) return; this.dialog.saving = true; const payload = { requestId: crypto.randomUUID(), name: this.form.name.trim(), content: JSON.parse(this.form.contentText) }; 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 } }, + 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 } }, async remove(row) { await ElMessageBox.confirm(`确定删除“${row.name}”吗?已有任务快照不受影响。`, '删除采购规则', { type: 'warning', confirmButtonText: '确认删除', cancelButtonText: '取消' }); await deletePurchaseRule(row.id, { requestId: crypto.randomUUID() }); ElMessage.success('采购规则已删除'); await this.load() } } @@ -62,5 +66,5 @@ export default {