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
+6 -3
View File
@@ -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
}
+41 -2
View File
@@ -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) {
+1
View File
@@ -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 无效")
}
+3 -3
View File
@@ -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) {
@@ -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)
+78 -17
View File
@@ -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))
@@ -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"}]}`)