diff --git a/app/globals.css b/app/globals.css index 5212c38..d2db898 100644 --- a/app/globals.css +++ b/app/globals.css @@ -3895,6 +3895,18 @@ h3 { align-items: flex-end; } +.billing-pricing-actions { + display: flex; + align-items: center; + justify-content: flex-end; + flex-wrap: wrap; + gap: 12px; +} + +.billing-bulk-price-button { + flex: 0 0 auto; +} + .billing-formula { display: inline-flex; align-items: center; @@ -5815,6 +5827,18 @@ h3 { padding: 20px; } +.billing-bulk-price-explanation, +.billing-bulk-price-hint { + margin: 0; + color: var(--muted); + font-size: 12px; + line-height: 1.6; +} + +.billing-bulk-price-explanation { + color: var(--ink); +} + .billing-modal-field { display: grid; gap: 7px; @@ -6082,6 +6106,11 @@ h3 { align-self: flex-start; } + .billing-pricing-actions { + width: 100%; + justify-content: space-between; + } + } /* UI/UX Pro Max pass: professional creator workspace tokens and interaction layer. */ diff --git a/backend/internal/billing/bulk_service_test.go b/backend/internal/billing/bulk_service_test.go new file mode 100644 index 0000000..c836f9e --- /dev/null +++ b/backend/internal/billing/bulk_service_test.go @@ -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) + } +} diff --git a/backend/internal/billing/defaults.go b/backend/internal/billing/defaults.go index cc5d2ff..2160642 100644 --- a/backend/internal/billing/defaults.go +++ b/backend/internal/billing/defaults.go @@ -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 } diff --git a/backend/internal/billing/http_service.go b/backend/internal/billing/http_service.go index e1a7ca3..3bd4825 100644 --- a/backend/internal/billing/http_service.go +++ b/backend/internal/billing/http_service.go @@ -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) } diff --git a/backend/internal/billing/service.go b/backend/internal/billing/service.go index 982f11c..570547c 100644 --- a/backend/internal/billing/service.go +++ b/backend/internal/billing/service.go @@ -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 { diff --git a/backend/internal/billing/service_test.go b/backend/internal/billing/service_test.go index 8cb4889..6cadb67 100644 --- a/backend/internal/billing/service_test.go +++ b/backend/internal/billing/service_test.go @@ -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++ diff --git a/backend/internal/httpapi/billing.go b/backend/internal/httpapi/billing.go index f286d36..1f85a17 100644 --- a/backend/internal/httpapi/billing.go +++ b/backend/internal/httpapi/billing.go @@ -3,6 +3,7 @@ package httpapi import ( "encoding/json" "errors" + "io" "math" "net/http" "strconv" @@ -232,12 +233,18 @@ func (h *billingHandler) adjust(w http.ResponseWriter, r *http.Request) { writeJSON(w, 200, result) } func (h *billingHandler) prices(w http.ResponseWriter, r *http.Request) { - if !allow(w, r, http.MethodGet) { + if r.Method != http.MethodGet && r.Method != http.MethodPatch { + w.Header().Set("Allow", "GET, PATCH") + writeAPIError(w, http.StatusMethodNotAllowed, "方法不允许。") return } if _, ok := h.authorize(w, r, PlatformSuperAdmin); !ok { return } + if r.Method == http.MethodPatch { + h.updatePrices(w, r) + return + } rules, err := h.service.ListPrices(r.Context()) if err != nil { writeDomainError(w, err) @@ -245,6 +252,32 @@ func (h *billingHandler) prices(w http.ResponseWriter, r *http.Request) { } writeJSON(w, 200, map[string]any{"priceRules": rules}) } + +// Called only after the collection route has authorized a platform super admin. +func (h *billingHandler) updatePrices(w http.ResponseWriter, r *http.Request) { + var body struct { + RuleIDs []string `json:"ruleIds"` + MarkupMultiplier any `json:"markupMultiplier"` + } + decoder := json.NewDecoder(r.Body) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&body); err != nil { + writeAPIError(w, 400, "请提供规则列表和上浮倍率,不能修改标准价格或参数。") + return + } + if err := decoder.Decode(&struct{}{}); err != io.EOF { + writeAPIError(w, 400, "请求必须包含一个有效的 JSON 对象。") + return + } + rules, err := h.service.UpdatePrices(r.Context(), billing.BulkPricePatch{ + RuleIDs: body.RuleIDs, MarkupMultiplier: numberValue(body.MarkupMultiplier), + }) + if err != nil { + writeDomainError(w, err) + return + } + writeJSON(w, 200, map[string]any{"priceRules": rules}) +} func (h *billingHandler) price(w http.ResponseWriter, r *http.Request, id string) { if !allow(w, r, http.MethodPatch) { return diff --git a/backend/internal/httpapi/billing_bulk_test.go b/backend/internal/httpapi/billing_bulk_test.go new file mode 100644 index 0000000..2319f30 --- /dev/null +++ b/backend/internal/httpapi/billing_bulk_test.go @@ -0,0 +1,109 @@ +package httpapi + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "git.nianxx.cn/wangxuming/NianAIGC/backend/internal/billing" + "git.nianxx.cn/wangxuming/NianAIGC/backend/internal/identity" + "git.nianxx.cn/wangxuming/NianAIGC/backend/internal/localstore" +) + +func TestBillingBulkPriceUpdateAndLaterIndividualEdit(t *testing.T) { + service := billing.NewService(localstore.New(), nil) + h := billingTestHandler(t, identity.Session{AuthMode: identity.AuthModeAdmin, User: identity.User{ID: "root", ClientID: "platform", Role: "super_admin"}}, service, nil) + before, err := service.ListPrices(context.Background()) + if err != nil { + t.Fatal(err) + } + ids := make([]string, len(before)) + for i, rule := range before { + ids[i] = rule.ID + } + response := serveJSON(t, h, http.MethodPatch, "/api/admin/billing/prices", map[string]any{"ruleIds": ids, "markupMultiplier": "1.35001"}) + if response.Code != http.StatusOK { + t.Fatalf("status=%d body=%s", response.Code, response.Body.String()) + } + var result struct { + Rules []billing.PriceRule `json:"priceRules"` + } + if err := json.Unmarshal(response.Body.Bytes(), &result); err != nil { + t.Fatal(err) + } + if len(result.Rules) != len(ids) { + t.Fatalf("updated %d rules, want %d", len(result.Rules), len(ids)) + } + for _, rule := range result.Rules { + if rule.MarkupMultiplier != 1.35 { + t.Fatalf("%s markup=%v", rule.ID, rule.MarkupMultiplier) + } + for _, dimension := range rule.Dimensions { + for _, tier := range dimension.Tiers { + if tier.MarkupMultiplier != 1.35 { + t.Fatalf("%s/%s/%v markup=%v", rule.ID, dimension.Key, tier.Value, tier.MarkupMultiplier) + } + } + } + } + id := "base-evolink-gpt-image-2.5-flare" + response = serveJSON(t, h, http.MethodPatch, "/api/admin/billing/prices/"+id, map[string]any{"markupMultiplier": 2, "dimensionKey": "quality", "tierValue": "high"}) + if response.Code != http.StatusOK { + t.Fatalf("single tier status=%d body=%s", response.Code, response.Body.String()) + } + // A new catalog read refreshes defaults and must preserve the single override. + response = serveJSON(t, h, http.MethodGet, "/api/admin/billing/prices", nil) + if response.Code != http.StatusOK { + t.Fatalf("catalog reload status=%d body=%s", response.Code, response.Body.String()) + } + rule, err := service.GetPrice(context.Background(), id) + if err != nil || rule == nil { + t.Fatalf("rule=%v err=%v", rule, err) + } + for _, dimension := range rule.Dimensions { + for _, tier := range dimension.Tiers { + want := 1.35 + if dimension.Key == "quality" && tier.Value == "high" { + want = 2 + } + if tier.MarkupMultiplier != want { + t.Fatalf("%s/%v markup=%v want=%v", dimension.Key, tier.Value, tier.MarkupMultiplier, want) + } + } + } +} + +func TestBillingBulkPriceUpdateRequiresSuperAdminAndValidBody(t *testing.T) { + for _, role := range []string{"user", "organization_admin"} { + t.Run(role, func(t *testing.T) { + service := &billingHTTPServiceStub{} + h := billingTestHandler(t, identity.Session{AuthMode: identity.AuthModeUser, User: identity.User{ID: "user", ClientID: "platform", OrganizationID: "org", Role: role}}, service, nil) + response := serveJSON(t, h, http.MethodPatch, "/api/admin/billing/prices", map[string]any{"ruleIds": []string{"price"}, "markupMultiplier": 2}) + if response.Code != http.StatusForbidden || len(service.bulkPricePatch.RuleIDs) != 0 { + t.Fatalf("status=%d mutation=%+v", response.Code, service.bulkPricePatch) + } + }) + } + for _, body := range []string{ + `{"ruleIds":["price"],"markupMultiplier":2,"standardUnitPriceFen":1}`, + `{"ruleIds":[1],"markupMultiplier":2}`, + `{"ruleIds":"price","markupMultiplier":2}`, + `{"ruleIds":["price"],"markupMultiplier":2} {}`, + `{"ruleIds":`, + } { + t.Run(body, func(t *testing.T) { + service := &billingHTTPServiceStub{} + h := billingTestHandler(t, identity.Session{AuthMode: identity.AuthModeAdmin, User: identity.User{ID: "root", ClientID: "platform", Role: "super_admin"}}, service, nil) + request := httptest.NewRequest(http.MethodPatch, "/api/admin/billing/prices", strings.NewReader(body)) + request.AddCookie(&http.Cookie{Name: identity.SessionCookieName, Value: "signed"}) + response := httptest.NewRecorder() + h.ServeHTTP(response, request) + if response.Code != http.StatusBadRequest || len(service.bulkPricePatch.RuleIDs) != 0 { + t.Fatalf("status=%d mutation=%+v body=%s", response.Code, service.bulkPricePatch, response.Body.String()) + } + }) + } +} diff --git a/backend/internal/httpapi/billing_test.go b/backend/internal/httpapi/billing_test.go index 1b7a52b..f7991c4 100644 --- a/backend/internal/httpapi/billing_test.go +++ b/backend/internal/httpapi/billing_test.go @@ -138,6 +138,7 @@ type billingHTTPServiceStub struct { quote billing.QuoteCommand adjustment billing.AdjustmentCommand pricePatch billing.PricePatch + bulkPricePatch billing.BulkPricePatch price *billing.PriceRule err error } @@ -163,6 +164,10 @@ func (s *billingHTTPServiceStub) UpdatePrice(_ context.Context, _ string, patch s.pricePatch = patch return s.price, s.err } +func (s *billingHTTPServiceStub) UpdatePrices(_ context.Context, patch billing.BulkPricePatch) ([]billing.PriceRule, error) { + s.bulkPricePatch = patch + return []billing.PriceRule{}, s.err +} func (s *billingHTTPServiceStub) Adjust(_ context.Context, command billing.AdjustmentCommand) (billing.AdjustmentResult, error) { s.adjustment = command return billing.AdjustmentResult{Wallet: billing.Wallet{OrganizationID: command.OrganizationID, Currency: billing.CurrencyCNY}}, s.err diff --git a/backend/internal/httpapi/method_compat_test.go b/backend/internal/httpapi/method_compat_test.go index dc8356f..05aa0b4 100644 --- a/backend/internal/httpapi/method_compat_test.go +++ b/backend/internal/httpapi/method_compat_test.go @@ -41,6 +41,7 @@ func TestRouteMethodCompatibilityHandlesOptionsBeforeApplicationAuth(t *testing. }{ {path: "/api/health", allow: "GET, HEAD, OPTIONS"}, {path: "/api/admin/accounts", allow: "DELETE, GET, HEAD, OPTIONS, PATCH, POST, PUT"}, + {path: "/api/admin/billing/prices", allow: "GET, HEAD, OPTIONS, PATCH"}, {path: "/api/v1/jobs", allow: "GET, HEAD, OPTIONS, POST"}, {path: "/api/v1/jobs/job-1/cancel", allow: "OPTIONS, POST"}, {path: "/uploads/2026/08/file.png", allow: "GET, HEAD, OPTIONS"}, diff --git a/backend/internal/httpapi/route_surface.go b/backend/internal/httpapi/route_surface.go index 2ddfd34..f89d91e 100644 --- a/backend/internal/httpapi/route_surface.go +++ b/backend/internal/httpapi/route_surface.go @@ -13,7 +13,7 @@ type RouteSurface struct { } var goRouteSurface = []RouteSurface{ - {"DELETE", "/api/admin/accounts"}, {"GET", "/api/admin/accounts"}, {"PATCH", "/api/admin/accounts"}, {"POST", "/api/admin/accounts"}, {"PUT", "/api/admin/accounts"}, {"POST", "/api/admin/accounts/groups"}, {"POST", "/api/admin/accounts/password"}, {"GET", "/api/admin/billing"}, {"PATCH", "/api/admin/billing/account"}, {"POST", "/api/admin/billing/adjustments"}, {"GET", "/api/admin/billing/prices"}, {"PATCH", "/api/admin/billing/prices/{id}"}, {"DELETE", "/api/admin/organizations"}, {"GET", "/api/admin/organizations"}, {"PATCH", "/api/admin/organizations"}, {"POST", "/api/admin/organizations"}, {"GET", "/api/admin/usage"}, + {"DELETE", "/api/admin/accounts"}, {"GET", "/api/admin/accounts"}, {"PATCH", "/api/admin/accounts"}, {"POST", "/api/admin/accounts"}, {"PUT", "/api/admin/accounts"}, {"POST", "/api/admin/accounts/groups"}, {"POST", "/api/admin/accounts/password"}, {"GET", "/api/admin/billing"}, {"PATCH", "/api/admin/billing/account"}, {"POST", "/api/admin/billing/adjustments"}, {"GET", "/api/admin/billing/prices"}, {"PATCH", "/api/admin/billing/prices"}, {"PATCH", "/api/admin/billing/prices/{id}"}, {"DELETE", "/api/admin/organizations"}, {"GET", "/api/admin/organizations"}, {"PATCH", "/api/admin/organizations"}, {"POST", "/api/admin/organizations"}, {"GET", "/api/admin/usage"}, {"GET", "/api/assets"}, {"POST", "/api/assets"}, {"POST", "/api/assets/upload"}, {"DELETE", "/api/assets/{id}"}, {"GET", "/api/assets/{id}/download"}, {"GET", "/api/auth/callback"}, {"GET", "/api/auth/captcha"}, {"GET", "/api/auth/login"}, {"GET", "/api/auth/logout"}, {"POST", "/api/auth/logout"}, {"GET", "/api/auth/me"}, {"POST", "/api/auth/password"}, {"POST", "/api/auth/password/change"}, {"GET", "/api/billing"}, {"POST", "/api/billing/quote"}, {"GET", "/api/generations/image"}, {"POST", "/api/generations/image"}, {"DELETE", "/api/generations/image/{id}"}, {"GET", "/api/generations/image/{id}"}, {"POST", "/api/generations/image/{id}/retry"}, {"GET", "/api/generations/video"}, {"POST", "/api/generations/video"}, {"DELETE", "/api/generations/video/{id}"}, {"GET", "/api/generations/video/{id}"}, diff --git a/backend/internal/localstore/billing.go b/backend/internal/localstore/billing.go index 850ac56..c311017 100644 --- a/backend/internal/localstore/billing.go +++ b/backend/internal/localstore/billing.go @@ -100,6 +100,33 @@ func (s *Store) UpdateBillingPriceRule(_ context.Context, id string, p billing.P r = cloneRule(r) return &r, nil } +func (s *Store) UpdateBillingPriceRules(_ context.Context, patch billing.BulkPricePatch) ([]billing.PriceRule, error) { + s.mu.Lock() + defer s.mu.Unlock() + if len(patch.RuleIDs) == 0 { + return nil, billing.ErrPriceNotFound + } + for _, id := range patch.RuleIDs { + if _, ok := s.priceRules[id]; !ok { + return nil, billing.ErrPriceNotFound + } + } + now := s.now().UTC().Format(time.RFC3339Nano) + out := make([]billing.PriceRule, 0, len(patch.RuleIDs)) + for _, id := range patch.RuleIDs { + rule := cloneRule(s.priceRules[id]) + rule.MarkupMultiplier = patch.MarkupMultiplier + for di := range rule.Dimensions { + for ti := range rule.Dimensions[di].Tiers { + rule.Dimensions[di].Tiers[ti].MarkupMultiplier = patch.MarkupMultiplier + } + } + rule.UpdatedAt = now + s.priceRules[id] = cloneRule(rule) + out = append(out, cloneRule(rule)) + } + return out, nil +} func (s *Store) SeedBillingPriceRules(_ context.Context, rules []billing.PriceRule) error { s.mu.Lock() defer s.mu.Unlock() diff --git a/backend/internal/localstore/billing_bulk_test.go b/backend/internal/localstore/billing_bulk_test.go new file mode 100644 index 0000000..5552c8c --- /dev/null +++ b/backend/internal/localstore/billing_bulk_test.go @@ -0,0 +1,69 @@ +package localstore_test + +import ( + "context" + "errors" + "testing" + + "git.nianxx.cn/wangxuming/NianAIGC/backend/internal/billing" + "git.nianxx.cn/wangxuming/NianAIGC/backend/internal/localstore" +) + +func TestBulkPriceUpdateIsScopedAtomicAndSurvivesSingleEditAndReseed(t *testing.T) { + ctx := context.Background() + store := localstore.New() + defaults := []billing.PriceRule{ + {ID: "a", Provider: "a", Capability: "image.generate", Unit: billing.UnitImage, StandardUnitPriceFen: 20, MarkupMultiplier: 1.2, Enabled: true, Dimensions: []billing.ParameterDimension{{Key: "quality", Tiers: []billing.ParameterTier{{Value: "low", StandardFactor: 1, MarkupMultiplier: 1.2, Enabled: true}, {Value: "high", StandardFactor: 2, MarkupMultiplier: 1.2, Enabled: false}}}}}, + {ID: "b", Provider: "b", Capability: "image.generate", Unit: billing.UnitImage, StandardUnitPriceFen: 30, MarkupMultiplier: 1.2, Enabled: false, Dimensions: []billing.ParameterDimension{{Key: "quality", Tiers: []billing.ParameterTier{{Value: "low", StandardFactor: 1, MarkupMultiplier: 1.2, Enabled: true}}}}}, + {ID: "c", Provider: "c", Capability: "image.generate", Unit: billing.UnitImage, StandardUnitPriceFen: 40, MarkupMultiplier: 1.2, Enabled: true}, + } + if err := store.SeedBillingPriceRules(ctx, defaults); err != nil { + t.Fatal(err) + } + if _, err := store.UpdateBillingPriceRules(ctx, billing.BulkPricePatch{RuleIDs: []string{"a", "missing"}, MarkupMultiplier: 2}); !errors.Is(err, billing.ErrPriceNotFound) { + t.Fatalf("missing ID error = %v", err) + } + if _, err := store.UpdateBillingPriceRules(ctx, billing.BulkPricePatch{MarkupMultiplier: 2}); !errors.Is(err, billing.ErrPriceNotFound) { + t.Fatalf("empty ID error = %v", err) + } + before, _ := store.GetBillingPriceRule(ctx, "a") + if before.MarkupMultiplier != 1.2 || before.Dimensions[0].Tiers[0].MarkupMultiplier != 1.2 { + t.Fatalf("partial write on missing ID: %+v", before) + } + updated, err := store.UpdateBillingPriceRules(ctx, billing.BulkPricePatch{RuleIDs: []string{"a", "b"}, MarkupMultiplier: 1.8}) + if err != nil || len(updated) != 2 { + t.Fatalf("bulk update = %+v, %v", updated, err) + } + for _, id := range []string{"a", "b"} { + rule, _ := store.GetBillingPriceRule(ctx, id) + if rule.MarkupMultiplier != 1.8 { + t.Fatalf("%s rule markup = %g", id, rule.MarkupMultiplier) + } + for _, dimension := range rule.Dimensions { + for _, tier := range dimension.Tiers { + if tier.MarkupMultiplier != 1.8 { + t.Fatalf("%s tier markup = %g", id, tier.MarkupMultiplier) + } + } + } + } + a, _ := store.GetBillingPriceRule(ctx, "a") + if a.StandardUnitPriceFen != 20 || a.Dimensions[0].Tiers[1].Enabled || a.Dimensions[0].Tiers[1].StandardFactor != 2 { + t.Fatalf("bulk update changed non-markup fields: %+v", a) + } + c, _ := store.GetBillingPriceRule(ctx, "c") + if c.MarkupMultiplier != 1.2 { + t.Fatalf("unselected rule changed: %+v", c) + } + if _, err := store.UpdateBillingPriceRule(ctx, "a", billing.PricePatch{DimensionKey: "quality", TierValue: "low", MarkupMultiplier: 2.3}); err != nil { + t.Fatal(err) + } + if err := store.SeedBillingPriceRules(ctx, defaults); err != nil { + t.Fatal(err) + } + a, _ = store.GetBillingPriceRule(ctx, "a") + b, _ := store.GetBillingPriceRule(ctx, "b") + if a.MarkupMultiplier != 1.8 || a.Dimensions[0].Tiers[0].MarkupMultiplier != 2.3 || a.Dimensions[0].Tiers[1].MarkupMultiplier != 1.8 || b.MarkupMultiplier != 1.8 || b.Dimensions[0].Tiers[0].MarkupMultiplier != 1.8 { + t.Fatalf("single edit or bulk update lost after reseed: a=%+v b=%+v", a, b) + } +} diff --git a/backend/internal/localstore/billing_catalog_refresh_test.go b/backend/internal/localstore/billing_catalog_refresh_test.go new file mode 100644 index 0000000..d6752a3 --- /dev/null +++ b/backend/internal/localstore/billing_catalog_refresh_test.go @@ -0,0 +1,105 @@ +package localstore_test + +import ( + "context" + "testing" + + "git.nianxx.cn/wangxuming/NianAIGC/backend/internal/billing" + "git.nianxx.cn/wangxuming/NianAIGC/backend/internal/localstore" +) + +func TestAdminPriceReadsRefreshOldCatalogWithoutLosingMarkups(t *testing.T) { + for _, test := range []struct { + name string + disabled bool + read func(context.Context, *billing.Service) ([]billing.PriceRule, error) + }{ + {"overview", false, func(ctx context.Context, service *billing.Service) ([]billing.PriceRule, error) { + overview, err := service.AdminOverview(ctx) + return overview.PriceRules, err + }}, + {"prices", false, func(ctx context.Context, service *billing.Service) ([]billing.PriceRule, error) { + return service.ListPrices(ctx) + }}, + {"overview billing disabled", true, func(ctx context.Context, service *billing.Service) ([]billing.PriceRule, error) { + overview, err := service.AdminOverview(ctx) + return overview.PriceRules, err + }}, + {"prices billing disabled", true, func(ctx context.Context, service *billing.Service) ([]billing.PriceRule, error) { + return service.ListPrices(ctx) + }}, + } { + t.Run(test.name, func(t *testing.T) { + ctx := context.Background() + store := localstore.New() + defaults := billing.DefaultBillingPriceRules() + var old billing.PriceRule + for _, rule := range defaults { + if rule.ReqKey == "gpt-image-2" && rule.Provider == "evolink" { + old = rule + break + } + } + if old.ID == "" { + t.Fatal("Image 2 default rule missing") + } + if err := store.SeedBillingPriceRules(ctx, []billing.PriceRule{old}); err != nil { + t.Fatal(err) + } + for _, patch := range []billing.PricePatch{ + {DimensionKey: "quality", TierValue: "medium", MarkupMultiplier: 1.3}, + {DimensionKey: "referenceImageCount", TierValue: "1–4", MarkupMultiplier: 2}, + } { + if _, err := store.UpdateBillingPriceRule(ctx, old.ID, patch); err != nil { + t.Fatal(err) + } + } + service := billing.NewService(store, nil).SetEnabled(!test.disabled) + for read := 0; read < 2; read++ { + rules, err := test.read(ctx, service) + if err != nil { + t.Fatal(err) + } + if len(rules) != len(defaults) { + t.Fatalf("read %d: got %d rules, want %d", read, len(rules), len(defaults)) + } + counts := map[string]int{} + for _, rule := range rules { + if rule.Provider != "evolink" || rule.Capability != "image.generate" { + continue + } + counts[rule.ReqKey]++ + if rule.ReqKey == "gpt-image-2" { + if got := priceTierMarkup(t, rule, "quality", "medium"); got != 1.3 { + t.Fatalf("read %d: saved quality markup = %v, want 1.3", read, got) + } + if got := priceTierMarkup(t, rule, "referenceImageCount", "1–4"); got != 2 { + t.Fatalf("read %d: saved reference markup = %v, want 2", read, got) + } + } + } + for _, model := range []string{"gpt-image-2", "gpt-image-2.5-flare", "gpt-image-2.5-sunburst"} { + if counts[model] != 1 { + t.Fatalf("read %d: %s rule count = %d, want 1", read, model, counts[model]) + } + } + } + }) + } +} + +func priceTierMarkup(t *testing.T, rule billing.PriceRule, dimensionKey, tierValue string) float64 { + t.Helper() + for _, dimension := range rule.Dimensions { + if dimension.Key != dimensionKey { + continue + } + for _, tier := range dimension.Tiers { + if value, ok := tier.Value.(string); ok && value == tierValue { + return tier.MarkupMultiplier + } + } + } + t.Fatalf("%s tier %s missing from %s", dimensionKey, tierValue, rule.ID) + return 0 +} diff --git a/backend/internal/postgres/billing.go b/backend/internal/postgres/billing.go index a5fa79d..d6fa204 100644 --- a/backend/internal/postgres/billing.go +++ b/backend/internal/postgres/billing.go @@ -16,6 +16,37 @@ ORDER BY provider, capability, id` const GetBillingPriceRuleSQL = `SELECT id, provider, capability, req_key, variant_key, unit, standard_unit_price_fen, markup_multiplier, enabled, conditions, quantity_source, priority, note, source, parameter_dimensions, created_at::text, updated_at::text FROM public.billing_price_rules WHERE id = $1::text LIMIT 1` const UpdateBillingPriceRuleSQL = `UPDATE public.billing_price_rules SET markup_multiplier = CASE WHEN $2::text = '' THEN $3::numeric ELSE markup_multiplier END, parameter_dimensions = CASE WHEN $2::text = '' THEN parameter_dimensions ELSE (SELECT jsonb_agg(CASE WHEN dimension->>'key' = $2::text THEN jsonb_set(dimension, '{tiers}', (SELECT jsonb_agg(CASE WHEN tier->>'value' = $4::text THEN jsonb_set(tier, '{markupMultiplier}', to_jsonb($3::numeric), true) ELSE tier END) FROM jsonb_array_elements(dimension->'tiers') tier), true) ELSE dimension END) FROM jsonb_array_elements(parameter_dimensions) dimension) END, updated_at = now() WHERE id = $1::text RETURNING id, provider, capability, req_key, variant_key, unit, standard_unit_price_fen, markup_multiplier, enabled, conditions, quantity_source, priority, note, source, parameter_dimensions, created_at::text, updated_at::text` + +// Lock every requested row first. If even one ID is absent, eligible is false +// and this single statement updates none of them. +const UpdateBillingPriceRulesSQL = `WITH locked AS MATERIALIZED ( + SELECT id FROM public.billing_price_rules WHERE id = ANY($1::text[]) FOR UPDATE +), eligible AS MATERIALIZED ( + SELECT count(*) = cardinality($1::text[]) AS all_present FROM locked +), updated AS ( + UPDATE public.billing_price_rules AS rule SET + markup_multiplier = $2::numeric, + parameter_dimensions = ( + SELECT COALESCE(jsonb_agg( + jsonb_set(dimension.value, '{tiers}', ( + SELECT COALESCE(jsonb_agg( + jsonb_set(tier.value, '{markupMultiplier}', to_jsonb($2::numeric), true) + ORDER BY tier.position + ), '[]'::jsonb) + FROM jsonb_array_elements(COALESCE(dimension.value->'tiers', '[]'::jsonb)) + WITH ORDINALITY AS tier(value, position) + ), true) ORDER BY dimension.position + ), '[]'::jsonb) + FROM jsonb_array_elements(COALESCE(rule.parameter_dimensions, '[]'::jsonb)) + WITH ORDINALITY AS dimension(value, position) + ), + updated_at = now() + WHERE rule.id IN (SELECT id FROM locked) AND (SELECT all_present FROM eligible) + RETURNING id, provider, capability, req_key, variant_key, unit, standard_unit_price_fen, + markup_multiplier, enabled, conditions, quantity_source, priority, note, source, + parameter_dimensions, created_at::text, updated_at::text +) +SELECT * FROM updated ORDER BY id` const GetBillingWalletSQL = `SELECT $1::text, COALESCE(balance_fen, 0), COALESCE(total_recharged_fen, 0), COALESCE(total_charged_fen, 0), COALESCE(updated_at::text, '') FROM public.billing_wallets WHERE organization_id = $1::text UNION ALL SELECT $1::text, 0, 0, 0, '' WHERE NOT EXISTS (SELECT 1 FROM public.billing_wallets WHERE organization_id = $1::text) LIMIT 1` const ListBillingWalletsSQL = `SELECT organization_id, balance_fen, total_recharged_fen, total_charged_fen, updated_at::text FROM public.billing_wallets ORDER BY updated_at DESC` const ListBillingLedgerSQL = `SELECT id, organization_id, COALESCE(account_id, ''), COALESCE(job_id, ''), kind, delta_fen, balance_after_fen, currency, idempotency_key, description, metadata, created_at::text FROM public.billing_ledger WHERE ($1::text = '' OR organization_id = $1::text) AND ($2::text = '' OR account_id = $2::text) ORDER BY created_at DESC LIMIT $3::integer` @@ -87,6 +118,31 @@ func (db *Database) UpdateBillingPriceRule(ctx context.Context, id string, patch rule, err := scanFullPriceRule(rows) return &rule, err } +func (db *Database) UpdateBillingPriceRules(ctx context.Context, patch billing.BulkPricePatch) ([]billing.PriceRule, error) { + if len(patch.RuleIDs) == 0 { + return nil, billing.ErrPriceNotFound + } + rows, err := db.billingQuery(ctx, UpdateBillingPriceRulesSQL, patch.RuleIDs, patch.MarkupMultiplier) + if err != nil { + return nil, err + } + defer rows.Close() + rules := make([]billing.PriceRule, 0, len(patch.RuleIDs)) + for rows.Next() { + rule, err := scanFullPriceRule(rows) + if err != nil { + return nil, err + } + rules = append(rules, rule) + } + if err := rows.Err(); err != nil { + return nil, err + } + if len(rules) != len(patch.RuleIDs) { + return nil, billing.ErrPriceNotFound + } + return rules, nil +} func scanFullPriceRule(rows Rows) (billing.PriceRule, error) { var rule billing.PriceRule var req, variant, quantity, note sql.NullString diff --git a/backend/internal/postgres/billing_bulk_test.go b/backend/internal/postgres/billing_bulk_test.go new file mode 100644 index 0000000..e219de4 --- /dev/null +++ b/backend/internal/postgres/billing_bulk_test.go @@ -0,0 +1,32 @@ +package postgres + +import ( + "context" + "errors" + "reflect" + "strings" + "testing" + + "git.nianxx.cn/wangxuming/NianAIGC/backend/internal/billing" +) + +func TestUpdateBillingPriceRulesUsesSingleGuardedStatement(t *testing.T) { + q := &fakeQuerier{} + db := NewDatabase(Config{Backend: BackendPostgres}, q) + patch := billing.BulkPricePatch{RuleIDs: []string{"first", "second"}, MarkupMultiplier: 1.75} + if _, err := db.UpdateBillingPriceRules(context.Background(), patch); !errors.Is(err, billing.ErrPriceNotFound) { + t.Fatalf("empty result error = %v", err) + } + if q.sql != UpdateBillingPriceRulesSQL || !reflect.DeepEqual(q.args, []any{patch.RuleIDs, patch.MarkupMultiplier}) { + t.Fatalf("query = %q, args = %#v", q.sql, q.args) + } + for _, clause := range []string{"FOR UPDATE", "count(*) = cardinality($1::text[])", "AND (SELECT all_present FROM eligible)", "jsonb_set(tier.value", "UPDATE public.billing_price_rules"} { + if !strings.Contains(q.sql, clause) { + t.Fatalf("missing atomic bulk update clause %q", clause) + } + } + q.sql = "" + if _, err := db.UpdateBillingPriceRules(context.Background(), billing.BulkPricePatch{MarkupMultiplier: 2}); !errors.Is(err, billing.ErrPriceNotFound) || q.sql != "" { + t.Fatalf("empty ID list issued a query: %q, %v", q.sql, err) + } +} diff --git a/components/billing-manager.tsx b/components/billing-manager.tsx index eca2cb1..2a27d5c 100644 --- a/components/billing-manager.tsx +++ b/components/billing-manager.tsx @@ -21,7 +21,7 @@ import { } from "lucide-react"; import { billingUnitLabel, formatBillingAmount } from "@/lib/billing"; import { parseAdminBillingPayload, parseMemberBillingPayload } from "@/lib/client/billing-api"; -import { groupedPriceRuleSections, priceTierEditTarget, type PriceEditTarget } from "@/lib/client/billing-price-catalog"; +import { groupedPriceRuleSections, priceCatalogBulkTarget, priceTierEditTarget, type PriceEditTarget } from "@/lib/client/billing-price-catalog"; import { pulseFeedback, revealChildren, runScopedMotion } from "@/lib/ui/motion"; import type { BillingAccountConfig, BillingParameterDimension, BillingParameterTier, BillingPriceRule, OrganizationWallet } from "@/lib/types"; @@ -121,6 +121,7 @@ export function BillingManager({ isSuperAdmin }: { isSuperAdmin: boolean }) { const [error, setError] = useState(null); const [notice, setNotice] = useState(null); const [priceEditState, setPriceEditState] = useState(null); + const [bulkPriceEditOpen, setBulkPriceEditOpen] = useState(false); const managerRef = useRef(null); const feedbackRef = useRef(null); @@ -242,6 +243,13 @@ export function BillingManager({ isSuperAdmin }: { isSuperAdmin: boolean }) { if (saved) setPriceEditState(null); } + async function saveBulkPriceMultiplier(markupMultiplier: string) { + const ruleIds = priceCatalogBulkTarget(admin?.priceRules || []).ruleIds; + if (!ruleIds.length) return; + const saved = await mutate("/api/admin/billing/prices", { ruleIds, markupMultiplier }, "价格目录中所有模型的倍率已更新,可继续单独调整档位。"); + if (saved) setBulkPriceEditOpen(false); + } + async function mutate(url: string, body: Record, successMessage: string, method = "PATCH"): Promise { setSaving(true); setError(null); @@ -301,12 +309,13 @@ export function BillingManager({ isSuperAdmin }: { isSuperAdmin: boolean }) { {isSuperAdmin && admin && billing ? ( <> {activeTab === "overview" ? : null} - {activeTab === "pricing" ? setPriceEditState({ rule, target })} /> : null} + {activeTab === "pricing" ? setPriceEditState({ rule, target })} onBulkEdit={() => { setError(null); setBulkPriceEditOpen(true); }} /> : null} {activeTab === "balance" ? : null} {activeTab === "account" ? setEditingAccount(true)} onCancel={() => { setEditingAccount(false); setAccountDraft({ ...emptyAccountDraft, ...admin.billingAccount }); }} onChange={(key, value) => setAccountDraft((current) => ({ ...current, [key]: value }))} onSubmit={saveBillingAccount} /> : null} ) : null} {priceEditState ? setPriceEditState(null)} onSubmit={savePriceMultiplier} /> : null} + {bulkPriceEditOpen && admin ? setBulkPriceEditOpen(false)} onSubmit={saveBulkPriceMultiplier} /> : null} ); } @@ -410,9 +419,9 @@ function AdminOverview({ billing, admin, onChangeTab }: { billing: BillingPayloa ; } -function PriceManagement({ admin, saving, onEdit }: { admin: AdminPayload; saving: boolean; onEdit: (rule: BillingPriceRule, target: PriceEditTarget) => void }) { +function PriceManagement({ admin, saving, onEdit, onBulkEdit }: { admin: AdminPayload; saving: boolean; onEdit: (rule: BillingPriceRule, target: PriceEditTarget) => void; onBulkEdit: () => void }) { return
-

价格目录

标准成本×倍率=用户价
+

价格目录

标准成本×倍率=用户价
; } @@ -602,6 +611,73 @@ function PriceMultiplierDialog({ ); } +function BulkPriceMultiplierDialog({ + rules, + saving, + error, + onClose, + onSubmit +}: { + rules: BillingPriceRule[]; + saving: boolean; + error: string | null; + onClose: () => void; + onSubmit: (value: string) => Promise; +}) { + const target = priceCatalogBulkTarget(rules); + const [value, setValue] = useState(target.currentMultiplier === null ? "" : String(target.currentMultiplier)); + const inputRef = useRef(null); + const parsedValue = Number(value); + const valid = value.trim() !== "" && Number.isFinite(parsedValue) && parsedValue >= 1 && parsedValue <= 1000; + + useEffect(() => { + window.requestAnimationFrame(() => inputRef.current?.focus()); + }, []); + + useEffect(() => { + function handleKeyDown(event: globalThis.KeyboardEvent) { + if (event.key === "Escape" && !saving) onClose(); + } + document.addEventListener("keydown", handleKeyDown); + return () => document.removeEventListener("keydown", handleKeyDown); + }, [onClose, saving]); + + function submit(event: FormEvent) { + event.preventDefault(); + if (!valid || saving || !target.ruleIds.length) return; + void onSubmit(value.trim()); + } + + return
{ if (event.target === event.currentTarget && !saving) onClose(); }}> +
event.stopPropagation()}> +
+
+ +

统一调整倍率

整个价格目录 · {target.modelCount} 个模型 · {target.tierCount} 个档位

+
+ +
+
+ {error ?
{error}
: null} +

保存后会将目录中所有模型和档位设为相同倍率,覆盖之前单独设置的倍率。标准成本不变,之后仍可逐档调整。

+ +

组合报价取命中档位中的最高倍率;统一设置后,单独调整某个档位仍受其他命中档位的倍率影响。

+
+ + +
+
+
+
; +} + function priceEditStandardUnitPriceFen(rule: BillingPriceRule, target: PriceEditTarget): number { if (!target.dimensionKey || target.tierValue === undefined) return rule.standardUnitPriceFen; const dimension = rule.parameterDimensions?.find((item) => item.key === target.dimensionKey); diff --git a/contracts/billing/http-v1.json b/contracts/billing/http-v1.json index c8c359e..c5620a1 100644 --- a/contracts/billing/http-v1.json +++ b/contracts/billing/http-v1.json @@ -9,8 +9,10 @@ { "method": "PATCH", "path": "/api/admin/billing/account", "requirement": "super_admin", "configuration": "injected_store" }, { "method": "POST", "path": "/api/admin/billing/adjustments", "requirement": "super_admin" }, { "method": "GET", "path": "/api/admin/billing/prices", "requirement": "super_admin" }, + { "method": "PATCH", "path": "/api/admin/billing/prices", "requirement": "super_admin", "allowedFields": ["ruleIds", "markupMultiplier"], "atomic": true }, { "method": "PATCH", "path": "/api/admin/billing/prices/{id}", "requirement": "super_admin", "allowedFields": ["markupMultiplier", "dimensionKey", "tierValue"] } ], "statuses": { "insufficientBalance": 402, "idempotencyConflict": 409, "unboundOrganization": 422, "infrastructure": 500 }, - "pricePatch": { "minimum": 1, "maximum": 1000, "precision": 4, "tierPairRequired": true } + "pricePatch": { "minimum": 1, "maximum": 1000, "precision": 4, "tierPairRequired": true }, + "bulkPricePatch": { "minimumRules": 1, "maximumRules": 1000, "uniqueIdsRequired": true, "allTiers": true } } diff --git a/contracts/http/route-surface.v1.json b/contracts/http/route-surface.v1.json index cd3d92a..5f4c85a 100644 --- a/contracts/http/route-surface.v1.json +++ b/contracts/http/route-surface.v1.json @@ -12,6 +12,7 @@ { "method": "PATCH", "path": "/api/admin/billing/account" }, { "method": "POST", "path": "/api/admin/billing/adjustments" }, { "method": "GET", "path": "/api/admin/billing/prices" }, + { "method": "PATCH", "path": "/api/admin/billing/prices" }, { "method": "PATCH", "path": "/api/admin/billing/prices/{id}" }, { "method": "DELETE", "path": "/api/admin/organizations" }, { "method": "GET", "path": "/api/admin/organizations" }, diff --git a/docs/API.md b/docs/API.md index c64c17c..4e4337f 100644 --- a/docs/API.md +++ b/docs/API.md @@ -90,6 +90,21 @@ curl -X POST https://你的域名/api/v1/jobs \ 首次加载超管计费中心或提交真实任务时会自动补齐内置标准成本目录,默认倍率为 `1.2×`,已有同服务商/能力/模型/变体规则会同步平台维护的标准成本与参数档案,但保留已配置倍率。视频规则按分辨率匹配 `resolution=480p|720p|1080p|4k`;参数化规则按服务下的参数维度选择档位,标准成本为基础成本乘以各档位系数,组合倍率取所选档位中的最高倍率;最终金额使用整数分并向上取整。EvoLink 默认按固定 `1 USD = 7.20 CNY` 换算,并列出质量、分辨率、画面比例和参考图数量档位;即梦 4.6 使用公开资源包折算值作为平台维护的参考标准,实时价格以火山控制台为准。超级管理员只调整倍率,标准成本和参数档案不通过后台修改。 +### 后台统一调整倍率 + +超级管理员可在「计费中心 → 价格与计费」的价格目录顶部点击「统一调整倍率」,一次将当前目录所有模型、所有参数档位设为同一倍率。此次保存会覆盖已有的单独倍率;保存后仍可在各档位点击「调整倍率」继续修改,刷新目录后会保留这些设置。统一设置是一次批量修改,不会作为额外的倍率叠加到后续报价中;组合报价仍取命中档位的最高倍率。 + +后台会话接口(要求已登录的超级管理员,不能使用开放 API Key): + +```http +PATCH /api/admin/billing/prices +Content-Type: application/json + +{"ruleIds":["base-evolink-gpt-image-2.5-flare","base-evolink-gpt-image-2.5-sunburst"],"markupMultiplier":1.5} +``` + +`ruleIds` 必须显式提供 1–1000 个不重复的规则 ID,倍率范围为 `1–1000`,保存到小数点后四位。接口一次更新所选规则的基础倍率及所有参数档位倍率(包含停用档位),标准成本和启用状态保持原值;任一 ID 不存在时整体失败,不会部分更新。成功返回 `{"priceRules":[...]}`。页面的全目录操作会传入当前目录的全部规则 ID;后续单独调整继续使用 `PATCH /api/admin/billing/prices/{id}`。 + ## 查询任务 查询单个任务: diff --git a/lib/client/billing-price-catalog.ts b/lib/client/billing-price-catalog.ts index e91d7ce..b31cb8a 100644 --- a/lib/client/billing-price-catalog.ts +++ b/lib/client/billing-price-catalog.ts @@ -7,6 +7,29 @@ export type PriceEditTarget = { markupMultiplier: number; }; +export function priceCatalogBulkTarget(rules: BillingPriceRule[]) { + const modelKeys = new Set(); + const ruleIds: string[] = []; + const multipliers: number[] = []; + let tierCount = 0; + + for (const rule of rules) { + ruleIds.push(rule.id); + modelKeys.add([rule.provider, rule.capability, rule.reqKey || ""].join("\u0000")); + multipliers.push(rule.markupMultiplier); + const tiers = rule.parameterDimensions?.flatMap((dimension) => dimension.tiers) || []; + tierCount += tiers.length || 1; + multipliers.push(...tiers.map((tier) => tier.markupMultiplier)); + } + + return { + ruleIds, + modelCount: modelKeys.size, + tierCount, + currentMultiplier: multipliers.length && multipliers.every((value) => value === multipliers[0]) ? multipliers[0] : null + }; +} + export function groupedPriceRuleSections(rules: BillingPriceRule[]) { return rules.map((rule, index) => ({ rule, diff --git a/tests/billing-http-contract.test.ts b/tests/billing-http-contract.test.ts index 4478bd9..1454d8c 100644 --- a/tests/billing-http-contract.test.ts +++ b/tests/billing-http-contract.test.ts @@ -11,6 +11,7 @@ describe("billing HTTP compatibility fixture", () => { }); expect(fixture.routes).toEqual(expect.arrayContaining([ expect.objectContaining({ method: "GET", path: "/api/billing", organizationSource: "refreshed_session" }), + expect.objectContaining({ method: "PATCH", path: "/api/admin/billing/prices", requirement: "super_admin", allowedFields: ["ruleIds", "markupMultiplier"], atomic: true }), expect.objectContaining({ method: "PATCH", path: "/api/admin/billing/prices/{id}", requirement: "super_admin" }) ])); }); diff --git a/tests/billing-price-catalog.test.ts b/tests/billing-price-catalog.test.ts index aa8ea6a..4bc2866 100644 --- a/tests/billing-price-catalog.test.ts +++ b/tests/billing-price-catalog.test.ts @@ -2,7 +2,7 @@ import { createElement } from "react"; import { renderToStaticMarkup } from "react-dom/server"; import { describe, expect, it } from "vitest"; import { PriceCatalog } from "@/components/billing-manager"; -import { groupedPriceRuleSections, priceTierEditTarget } from "@/lib/client/billing-price-catalog"; +import { groupedPriceRuleSections, priceCatalogBulkTarget, priceTierEditTarget } from "@/lib/client/billing-price-catalog"; import type { BillingParameterDimension, BillingPriceRule } from "@/lib/types"; const size: BillingParameterDimension = { @@ -32,6 +32,25 @@ function rule(id: string, overrides: Partial = {}): BillingPri } describe("billing price catalog grouped rules", () => { + it("targets the entire catalog, including disabled rules and every parameter tier", () => { + const sameModel = rule("basic", { parameterDimensions: [size] }); + const disabledBusiness = rule("layers", { enabled: false, conditions: { layerDecomposition: true }, parameterDimensions: [{ ...size, tiers: [size.tiers[0]] }] }); + const video = rule("video", { provider: "seedance", capability: "video.generate", reqKey: "seedance-2.0", unit: "video_second", markupMultiplier: 1.3 }); + expect(priceCatalogBulkTarget([sameModel, disabledBusiness, video])).toEqual({ + ruleIds: ["basic", "layers", "video"], + modelCount: 2, + tierCount: 4, + currentMultiplier: null + }); + expect(priceCatalogBulkTarget([])).toEqual({ ruleIds: [], modelCount: 0, tierCount: 0, currentMultiplier: null }); + }); + + it("only preselects the current multiplier when every rule and tier agrees", () => { + const uniformSize = { ...size, tiers: size.tiers.map((tier) => ({ ...tier, markupMultiplier: 1.2 })) }; + expect(priceCatalogBulkTarget([rule("basic", { parameterDimensions: [uniformSize] }), rule("video")]).currentMultiplier).toBe(1.2); + expect(priceCatalogBulkTarget([rule("basic", { parameterDimensions: [size] })]).currentMultiplier).toBeNull(); + }); + it("keeps separate Seedream businesses and their resolution edit targets", () => { const basic = rule("basic", { parameterDimensions: [size] }); const layers = rule("layers", {