增加统一倍率调整按钮
This commit is contained in:
1 parent
be655b6569
commit
4195525dc7
23 files changed
+750
-16
No files matched your search
@@ -0,0 +1,51 @@
|
||||
package billing
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"math"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type bulkStoreStub struct {
|
||||
*quoteStoreStub
|
||||
patch BulkPricePatch
|
||||
err error
|
||||
calls int
|
||||
}
|
||||
|
||||
func (s *bulkStoreStub) UpdateBillingPriceRules(_ context.Context, patch BulkPricePatch) ([]PriceRule, error) {
|
||||
s.calls++
|
||||
s.patch = patch
|
||||
return []PriceRule{{ID: "a"}}, s.err
|
||||
}
|
||||
|
||||
func TestUpdatePricesValidatesExplicitIDsAndNormalizesMultiplier(t *testing.T) {
|
||||
store := &bulkStoreStub{quoteStoreStub: "eStoreStub{}}
|
||||
service := NewService(store, nil)
|
||||
for _, patch := range []BulkPricePatch{
|
||||
{MarkupMultiplier: 1.2},
|
||||
{RuleIDs: []string{""}, MarkupMultiplier: 1.2},
|
||||
{RuleIDs: []string{" a"}, MarkupMultiplier: 1.2},
|
||||
{RuleIDs: []string{"a", "a"}, MarkupMultiplier: 1.2},
|
||||
{RuleIDs: []string{"a"}, MarkupMultiplier: 0.9},
|
||||
{RuleIDs: []string{"a"}, MarkupMultiplier: 1000.1},
|
||||
{RuleIDs: []string{"a"}, MarkupMultiplier: math.NaN()},
|
||||
{RuleIDs: []string{"a"}, MarkupMultiplier: math.Inf(1)},
|
||||
} {
|
||||
if _, err := service.UpdatePrices(context.Background(), patch); HTTPStatus(err) != 400 {
|
||||
t.Fatalf("patch %+v: status = %d, err = %v", patch, HTTPStatus(err), err)
|
||||
}
|
||||
}
|
||||
if store.calls != 0 {
|
||||
t.Fatalf("invalid patch reached store %d times", store.calls)
|
||||
}
|
||||
rules, err := service.UpdatePrices(context.Background(), BulkPricePatch{RuleIDs: []string{"a"}, MarkupMultiplier: 1.234567})
|
||||
if err != nil || len(rules) != 1 || store.patch.MarkupMultiplier != 1.2346 {
|
||||
t.Fatalf("rules = %+v, patch = %+v, err = %v", rules, store.patch, err)
|
||||
}
|
||||
store.err = ErrPriceNotFound
|
||||
if _, err := service.UpdatePrices(context.Background(), BulkPricePatch{RuleIDs: []string{"a"}, MarkupMultiplier: 2}); HTTPStatus(err) != 404 || !errors.Is(err, ErrPriceNotFound) {
|
||||
t.Fatalf("missing rule status = %d, err = %v", HTTPStatus(err), err)
|
||||
}
|
||||
}
|
||||
@@ -14,7 +14,7 @@ const (
|
||||
|
||||
// PriceRuleSeeder is an optional store capability. Existing Store
|
||||
// implementations remain source-compatible and seed-capable stores can make
|
||||
// the official base catalog durable before quotes are read.
|
||||
// the official base catalog durable before quotes or administrator prices are read.
|
||||
type PriceRuleSeeder interface {
|
||||
SeedBillingPriceRules(context.Context, []PriceRule) error
|
||||
}
|
||||
|
||||
@@ -87,6 +87,13 @@ type PricePatch struct {
|
||||
MarkupMultiplier float64
|
||||
DimensionKey, TierValue string
|
||||
}
|
||||
|
||||
// BulkPricePatch replaces the markup on every tier of the explicitly named rules.
|
||||
// An empty ID list never means the entire catalog.
|
||||
type BulkPricePatch struct {
|
||||
RuleIDs []string `json:"ruleIds"`
|
||||
MarkupMultiplier float64 `json:"markupMultiplier"`
|
||||
}
|
||||
type AdjustmentCommand struct {
|
||||
OrganizationID, OperatorID, Direction, Note string
|
||||
AmountFen, DeltaFen int64
|
||||
@@ -113,6 +120,7 @@ type HTTPService interface {
|
||||
ReadService
|
||||
Quote(context.Context, QuoteCommand) (*Quote, error)
|
||||
UpdatePrice(context.Context, string, PricePatch) (*PriceRule, error)
|
||||
UpdatePrices(context.Context, BulkPricePatch) ([]PriceRule, error)
|
||||
Adjust(context.Context, AdjustmentCommand) (AdjustmentResult, error)
|
||||
}
|
||||
|
||||
|
||||
@@ -24,6 +24,11 @@ type Store interface {
|
||||
PostBillingWalletEntry(context.Context, WalletPostParams) (WalletPosting, error)
|
||||
}
|
||||
|
||||
// BulkPriceRuleUpdater is optional so existing Store implementations remain valid.
|
||||
type BulkPriceRuleUpdater interface {
|
||||
UpdateBillingPriceRules(context.Context, BulkPricePatch) ([]PriceRule, error)
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
store Store
|
||||
newID func() string
|
||||
@@ -39,7 +44,8 @@ func NewService(store Store, newID func() string) *Service {
|
||||
|
||||
// SetEnabled configures whether generation quotes are required. The default is
|
||||
// enabled; disabling preserves the historical optional-billing behavior and
|
||||
// does not consult or seed the price catalog.
|
||||
// does not consult or seed the price catalog when quoting. Administrator catalog
|
||||
// reads still refresh defaults so pricing can be configured before billing starts.
|
||||
func (s *Service) SetEnabled(enabled bool) *Service {
|
||||
s.enabled = enabled
|
||||
return s
|
||||
@@ -67,10 +73,8 @@ func (s *Service) Quote(ctx context.Context, command QuoteCommand) (*Quote, erro
|
||||
if !s.enabled {
|
||||
return nil, nil
|
||||
}
|
||||
if seeder, ok := s.store.(PriceRuleSeeder); ok {
|
||||
if err := seeder.SeedBillingPriceRules(ctx, DefaultBillingPriceRules()); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.ensurePriceRules(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rules, err := s.store.ListBillingPriceRules(ctx, false)
|
||||
if err != nil {
|
||||
@@ -155,7 +159,7 @@ func (s *Service) AdminOverview(ctx context.Context) (AdminOverview, error) {
|
||||
if err != nil {
|
||||
return AdminOverview{}, err
|
||||
}
|
||||
rules, err := s.store.ListBillingPriceRules(ctx, true)
|
||||
rules, err := s.ListPrices(ctx)
|
||||
if err != nil {
|
||||
return AdminOverview{}, err
|
||||
}
|
||||
@@ -185,14 +189,56 @@ func (s *Service) AdminOverview(ctx context.Context) (AdminOverview, error) {
|
||||
return AdminOverview{Organizations: organizations, Members: members, Ledger: ledger, PriceRules: rules}, nil
|
||||
}
|
||||
func (s *Service) ListPrices(ctx context.Context) ([]PriceRule, error) {
|
||||
if err := s.ensurePriceRules(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.store.ListBillingPriceRules(ctx, true)
|
||||
}
|
||||
|
||||
func (s *Service) ensurePriceRules(ctx context.Context) error {
|
||||
if seeder, ok := s.store.(PriceRuleSeeder); ok {
|
||||
return seeder.SeedBillingPriceRules(ctx, DefaultBillingPriceRules())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) GetPrice(ctx context.Context, id string) (*PriceRule, error) {
|
||||
return s.store.GetBillingPriceRule(ctx, id)
|
||||
}
|
||||
func (s *Service) UpdatePrice(ctx context.Context, id string, patch PricePatch) (*PriceRule, error) {
|
||||
return s.store.UpdateBillingPriceRule(ctx, id, patch)
|
||||
}
|
||||
func (s *Service) UpdatePrices(ctx context.Context, patch BulkPricePatch) ([]PriceRule, error) {
|
||||
if len(patch.RuleIDs) == 0 || len(patch.RuleIDs) > 1000 {
|
||||
return nil, &StatusError{400, errors.New("请选择 1 至 1000 条计费规则。")}
|
||||
}
|
||||
if err := ValidatePricePatch(&PriceRule{}, PricePatch{MarkupMultiplier: patch.MarkupMultiplier}); err != nil {
|
||||
return nil, &StatusError{400, err}
|
||||
}
|
||||
seen := make(map[string]struct{}, len(patch.RuleIDs))
|
||||
for _, id := range patch.RuleIDs {
|
||||
if strings.TrimSpace(id) == "" || id != strings.TrimSpace(id) {
|
||||
return nil, &StatusError{400, errors.New("计费规则 ID 不能为空或包含首尾空格。")}
|
||||
}
|
||||
if _, duplicate := seen[id]; duplicate {
|
||||
return nil, &StatusError{400, errors.New("计费规则 ID 不能重复。")}
|
||||
}
|
||||
seen[id] = struct{}{}
|
||||
}
|
||||
patch.MarkupMultiplier = math.Round(patch.MarkupMultiplier*10000) / 10000
|
||||
updater, ok := s.store.(BulkPriceRuleUpdater)
|
||||
if !ok {
|
||||
return nil, errors.New("批量调整计费倍率不可用")
|
||||
}
|
||||
if err := s.ensurePriceRules(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rules, err := updater.UpdateBillingPriceRules(ctx, patch)
|
||||
if errors.Is(err, ErrPriceNotFound) {
|
||||
return nil, &StatusError{404, ErrPriceNotFound}
|
||||
}
|
||||
return rules, err
|
||||
}
|
||||
func (s *Service) Adjust(ctx context.Context, command AdjustmentCommand) (AdjustmentResult, error) {
|
||||
exists, err := s.store.BillingOrganizationExists(ctx, command.OrganizationID)
|
||||
if err != nil {
|
||||
|
||||
@@ -2,6 +2,7 @@ package billing
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
@@ -74,6 +75,30 @@ func TestServiceQuoteDisabledReturnsNilWithoutAccessingRules(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceCatalogReadsReturnSeedErrors(t *testing.T) {
|
||||
seedErr := errors.New("catalog refresh failed")
|
||||
for name, read := range map[string]func(*Service) error{
|
||||
"admin overview": func(service *Service) error {
|
||||
_, err := service.AdminOverview(context.Background())
|
||||
return err
|
||||
},
|
||||
"price list": func(service *Service) error {
|
||||
_, err := service.ListPrices(context.Background())
|
||||
return err
|
||||
},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
store := "eStoreStub{seedErr: seedErr}
|
||||
if err := read(NewService(store, nil)); !errors.Is(err, seedErr) {
|
||||
t.Fatalf("error = %v, want %v", err, seedErr)
|
||||
}
|
||||
if store.listCalls != 0 {
|
||||
t.Fatal("returned a stale price list after catalog refresh failed")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceQuoteFreezesConservativeSeedanceReserve(t *testing.T) {
|
||||
store := "eStoreStub{rules: []PriceRule{{
|
||||
ID: "seedance-720", Provider: "seedance", Capability: "video.generate",
|
||||
@@ -230,6 +255,7 @@ type quoteStoreStub struct {
|
||||
rules []PriceRule
|
||||
seeded []PriceRule
|
||||
seedCalls int
|
||||
seedErr error
|
||||
listCalls int
|
||||
failOnAccess bool
|
||||
}
|
||||
@@ -237,7 +263,7 @@ type quoteStoreStub struct {
|
||||
func (s *quoteStoreStub) SeedBillingPriceRules(_ context.Context, rules []PriceRule) error {
|
||||
s.seedCalls++
|
||||
s.seeded = rules
|
||||
return nil
|
||||
return s.seedErr
|
||||
}
|
||||
func (s *quoteStoreStub) ListBillingPriceRules(context.Context, bool) ([]PriceRule, error) {
|
||||
s.listCalls++
|
||||
|
||||
Reference in new issue
Block a user