diff --git a/backend/internal/app/channel_model_pricing.go b/backend/internal/app/channel_model_pricing.go new file mode 100644 index 000000000..257a099d3 --- /dev/null +++ b/backend/internal/app/channel_model_pricing.go @@ -0,0 +1,130 @@ +package app + +import ( + "errors" + "fmt" + "sort" + "strings" + + "infinite-canvas/backend/internal/model" + "infinite-canvas/backend/internal/repository" +) + +type ChannelModelRepriceRequest struct { + ModelID string `json:"modelId"` + PriceVersion int64 `json:"priceVersion"` + PriceTiers []ChannelModelTierRepriceRequest `json:"priceTiers"` +} + +type ChannelModelTierRepriceRequest struct { + ID string `json:"id"` + PriceVersion int64 `json:"priceVersion"` + Prices map[string]*int64 `json:"prices"` +} + +// RepriceAdminChannelModels only changes sale prices, never model availability or cost. +func (s *Service) RepriceAdminChannelModels(actor *model.User, channelID string, requests []ChannelModelRepriceRequest) (int, error) { + if err := s.RequireAdmin(actor); err != nil { + return 0, err + } + if _, err := s.adminSystemChannel(channelID); err != nil { + return 0, err + } + if len(requests) == 0 || len(requests) > 100 { + return 0, BadAuthRequest("单次请选择 1 到 100 个渠道模型") + } + selected := make(map[string]ChannelModelRepriceRequest, len(requests)) + for _, request := range requests { + id := strings.TrimSpace(request.ModelID) + if _, duplicate := selected[id]; id == "" || duplicate || request.PriceVersion < 1 { + return 0, BadAuthRequest("渠道模型 ID 或价格版本无效,且模型不能重复") + } + selected[id] = request + } + items, err := s.repo.ChannelModels(channelID, true) + if err != nil { + return 0, err + } + updates := make([]model.ChannelModel, 0, len(selected)) + for _, item := range items { + request, exists := selected[item.ID] + if !exists { + continue + } + if request.PriceVersion != item.PriceVersion { + return 0, BadAuthRequest("模型价格已变化,请刷新后重新调价;本次未修改任何价格") + } + tiers := make(map[string]ChannelModelTierRepriceRequest, len(request.PriceTiers)) + for _, tier := range request.PriceTiers { + if _, duplicate := tiers[tier.ID]; tier.ID == "" || duplicate { + return 0, BadAuthRequest("规格 ID 不能为空或重复") + } + tiers[tier.ID] = tier + } + active := 0 + for index := range item.PriceTiers { + tier := &item.PriceTiers[index] + if !tier.Enabled { + continue + } + active++ + input, exists := tiers[tier.ID] + if !exists || input.PriceVersion != tier.PriceVersion { + return 0, BadAuthRequest("规格价格已变化或提交不完整,请刷新后重新调价") + } + if err := applyChannelModelTierPrices(tier, item.Capability, item.Protocol, input.Prices); err != nil { + return 0, BadAuthRequest(fmt.Sprintf("模型 %s(规格 %s):%s;本次未修改任何价格", item.ModelKey, tier.SelectorKey, err.Error())) + } + } + if active == 0 || active != len(tiers) { + return 0, BadAuthRequest("模型 " + item.ModelKey + " 的规格提交不完整或包含无效规格;本次未修改任何价格") + } + s.applyChannelModelPriceTierSummary(&item, item.PriceTiers) + updates = append(updates, item) + } + if len(updates) != len(selected) { + return 0, BadAuthRequest("所选模型已删除或不属于当前渠道,请刷新后重试") + } + // Consistent lock order for overlapping batch requests. + sort.Slice(updates, func(i, j int) bool { return updates[i].ID < updates[j].ID }) + if err := s.repo.UpdateChannelModelSalePrices(channelID, updates); err != nil { + if errors.Is(err, repository.ErrChannelModelPriceConflict) { + return 0, BadAuthRequest("模型价格已被其他操作修改,请刷新后重试;本次未修改任何价格") + } + return 0, err + } + s.invalidateRouteCatalog() + return len(updates), nil +} + +func applyChannelModelTierPrices(tier *model.ChannelModelPriceTier, capability string, protocol model.ChannelInterfaceType, prices map[string]*int64) error { + if tier.ID == "" || !tier.PriceConfigured || !tier.CostPricing.Configured { + return BadAuthRequest("请先保存完整的规格成本价和销售价配置") + } + if err := validateCreditCostPricing(capability, tier.BillingMode, tier.CostPricing); err != nil { + return err + } + fields := map[string]*int64{"unitPriceMicrocredits": &tier.UnitPriceMicrocredits} + if tier.BillingMode == "token" { + fields = map[string]*int64{"outputTokenPriceMicrocredits": &tier.OutputTokenPriceMicrocredits} + if capability != "video" { + fields["inputTokenPriceMicrocredits"] = &tier.InputTokenPriceMicrocredits + fields["cachedTokenPriceMicrocredits"] = &tier.CachedTokenPriceMicrocredits + } + } + if len(prices) != len(fields) { + return BadAuthRequest("必须提交当前计费方式的全部销售价,且不能包含其他字段") + } + for key, target := range fields { + value, exists := prices[key] + if !exists || value == nil || *value < 0 || *value > 9_007_199_254_740_991 { + return BadAuthRequest("销售价缺失或超出有效范围") + } + *target = *value + } + return validateChannelModelTierPricing(capability, protocol, tier.BillingMode, ChannelModelPriceTierRequest{ + CostPricing: tier.CostPricing, UnitPriceMicrocredits: tier.UnitPriceMicrocredits, + InputTokenPriceMicrocredits: tier.InputTokenPriceMicrocredits, OutputTokenPriceMicrocredits: tier.OutputTokenPriceMicrocredits, + CachedTokenPriceMicrocredits: tier.CachedTokenPriceMicrocredits, + }) +} diff --git a/backend/internal/app/channel_model_pricing_test.go b/backend/internal/app/channel_model_pricing_test.go new file mode 100644 index 000000000..faff99b18 --- /dev/null +++ b/backend/internal/app/channel_model_pricing_test.go @@ -0,0 +1,257 @@ +package app + +import ( + "errors" + "testing" + + "infinite-canvas/backend/internal/model" + "infinite-canvas/backend/internal/repository" + + "gorm.io/gorm" +) + +func seedRepricingModels(t *testing.T) (*Service, *gorm.DB, *model.User) { + t.Helper() + svc, db := newChannelModelTestService(t) + admin := &model.User{ID: "admin", Role: model.UserRoleAdmin} + if err := db.Create(&model.ModelChannel{ID: "channel", UserID: admin.ID, Scope: model.ChannelScopeSystem, Enabled: true}).Error; err != nil { + t.Fatal(err) + } + for _, id := range []string{"a", "b"} { + item := model.ChannelModel{ID: id, ChannelID: "channel", ModelKey: id, DisplayName: id, Capability: "text", Protocol: model.ChannelInterfaceChatCompletion, BillingMode: "fixed_request", Enabled: true, PriceConfigured: true, PriceVersion: 1, UnitPriceMicrocredits: 9} + tier := model.ChannelModelPriceTier{ID: "tier-" + id, ChannelModelID: id, SelectorKey: "{}", SelectorJSON: "{}", Resolution: "*", BillingMode: "fixed_request", Enabled: true, PriceConfigured: true, PriceVersion: 1, UnitPriceMicrocredits: 9, CostPricing: model.CreditCostPricing{Configured: true, UnitPriceMicrocredits: 1_000_000}} + if err := db.Create(&item).Error; err != nil { + t.Fatal(err) + } + if err := db.Create(&tier).Error; err != nil { + t.Fatal(err) + } + } + return svc, db, admin +} + +func repricingRequest(id string, price int64) ChannelModelRepriceRequest { + return ChannelModelRepriceRequest{ModelID: id, PriceVersion: 1, PriceTiers: []ChannelModelTierRepriceRequest{{ID: "tier-" + id, PriceVersion: 1, Prices: map[string]*int64{"unitPriceMicrocredits": &price}}}} +} + +func TestRepriceAdminChannelModelsPreservesFinalManualPrices(t *testing.T) { + svc, db, admin := seedRepricingModels(t) + disabled := model.ChannelModelPriceTier{ID: "disabled", ChannelModelID: "a", SelectorKey: `{"quality":"high"}`, Enabled: false, UnitPriceMicrocredits: 99, PriceVersion: 7} + if err := db.Create(&disabled).Error; err != nil { + t.Fatal(err) + } + if err := db.Model(&model.ChannelModel{}).Where("id = ?", "b").Update("enabled", false).Error; err != nil { + t.Fatal(err) + } + requests := []ChannelModelRepriceRequest{repricingRequest("a", 1_250_000), repricingRequest("b", 1_370_000)} + updated, err := svc.RepriceAdminChannelModels(admin, "channel", requests) + if err != nil || updated != 2 { + t.Fatalf("updated=%d err=%v", updated, err) + } + for _, request := range requests { + item, err := svc.repo.ChannelModelByID("channel", request.ModelID) + if err != nil { + t.Fatal(err) + } + want := *request.PriceTiers[0].Prices["unitPriceMicrocredits"] + if item.UnitPriceMicrocredits != want || item.PriceVersion != 2 || item.Enabled != (item.ID == "a") { + t.Fatalf("model=%+v", item) + } + for _, tier := range item.PriceTiers { + if tier.ID == "disabled" { + if tier.UnitPriceMicrocredits != 99 || tier.PriceVersion != 7 || tier.Enabled { + t.Fatalf("disabled=%+v", tier) + } + } else if tier.UnitPriceMicrocredits != want || tier.PriceVersion != 2 || tier.CostPricing.UnitPriceMicrocredits != 1_000_000 { + t.Fatalf("tier=%+v", tier) + } + } + } + if _, err := svc.RepriceAdminChannelModels(admin, "channel", requests); err == nil { + t.Fatal("stale preview accepted") + } +} + +func TestRepriceAdminChannelModelsMultipleSpecifications(t *testing.T) { + svc, db, admin := seedRepricingModels(t) + if err := db.Model(&model.ChannelModel{}).Where("id = ?", "a").Updates(map[string]any{ + "capability": "image", "protocol": model.ChannelInterfaceOpenAIImage, + }).Error; err != nil { + t.Fatal(err) + } + request := repricingRequest("a", 4_000) + for _, spec := range []struct { + id string + price int64 + }{{"2K", 5_500}, {"4K", 6_000}} { + tier := model.ChannelModelPriceTier{ID: spec.id, ChannelModelID: "a", SelectorKey: `{"vquality":"` + spec.id + `"}`, + SelectorJSON: `{"vquality":"` + spec.id + `"}`, Resolution: spec.id, BillingMode: "fixed_request", + Enabled: true, PriceConfigured: true, PriceVersion: 1, UnitPriceMicrocredits: 9, + CostPricing: model.CreditCostPricing{Configured: true, UnitPriceMicrocredits: 3_000}} + if err := db.Create(&tier).Error; err != nil { + t.Fatal(err) + } + request.PriceTiers = append(request.PriceTiers, ChannelModelTierRepriceRequest{ID: spec.id, PriceVersion: 1, + Prices: map[string]*int64{"unitPriceMicrocredits": &spec.price}}) + } + if updated, err := svc.RepriceAdminChannelModels(admin, "channel", []ChannelModelRepriceRequest{request}); err != nil || updated != 1 { + t.Fatalf("updated=%d err=%v", updated, err) + } + for _, input := range request.PriceTiers { + var tier model.ChannelModelPriceTier + if err := db.First(&tier, "id = ?", input.ID).Error; err != nil { + t.Fatal(err) + } + if tier.UnitPriceMicrocredits != *input.Prices["unitPriceMicrocredits"] || tier.PriceVersion != 2 { + t.Fatalf("specification price was not saved independently: %+v", tier) + } + } +} + +func TestRepriceAdminChannelModelsRejectsWholeBatch(t *testing.T) { + for _, tt := range []struct { + name string + edit func([]ChannelModelRepriceRequest) []ChannelModelRepriceRequest + mutate func(*gorm.DB) error + nonAdmin bool + }{ + {name: "empty", edit: func(_ []ChannelModelRepriceRequest) []ChannelModelRepriceRequest { return nil }}, + {name: "too many", edit: func(_ []ChannelModelRepriceRequest) []ChannelModelRepriceRequest { + return make([]ChannelModelRepriceRequest, 101) + }}, + {name: "duplicate model", edit: func(r []ChannelModelRepriceRequest) []ChannelModelRepriceRequest { return append(r, r[0]) }}, + {name: "empty ID", edit: func(r []ChannelModelRepriceRequest) []ChannelModelRepriceRequest { r[1].ModelID = ""; return r }}, + {name: "stale model", edit: func(r []ChannelModelRepriceRequest) []ChannelModelRepriceRequest { r[1].PriceVersion = 2; return r }}, + {name: "stale tier", edit: func(r []ChannelModelRepriceRequest) []ChannelModelRepriceRequest { + r[1].PriceTiers[0].PriceVersion = 2 + return r + }}, + {name: "missing tier", edit: func(r []ChannelModelRepriceRequest) []ChannelModelRepriceRequest { r[1].PriceTiers = nil; return r }}, + {name: "wrong tier owner", edit: func(r []ChannelModelRepriceRequest) []ChannelModelRepriceRequest { + r[1].PriceTiers[0].ID = "tier-a" + return r + }}, + {name: "duplicate tier", edit: func(r []ChannelModelRepriceRequest) []ChannelModelRepriceRequest { + r[1].PriceTiers = append(r[1].PriceTiers, r[1].PriceTiers[0]) + return r + }}, + {name: "negative price", edit: func(r []ChannelModelRepriceRequest) []ChannelModelRepriceRequest { + r[1] = repricingRequest("b", -1) + return r + }}, + {name: "missing price", edit: func(r []ChannelModelRepriceRequest) []ChannelModelRepriceRequest { + r[1].PriceTiers[0].Prices = nil + return r + }}, + {name: "null price", edit: func(r []ChannelModelRepriceRequest) []ChannelModelRepriceRequest { + r[1].PriceTiers[0].Prices["unitPriceMicrocredits"] = nil + return r + }}, + {name: "unknown field", edit: func(r []ChannelModelRepriceRequest) []ChannelModelRepriceRequest { + r[1].PriceTiers[0].Prices["enabled"] = nil + return r + }}, + {name: "unsafe price", edit: func(r []ChannelModelRepriceRequest) []ChannelModelRepriceRequest { + r[1] = repricingRequest("b", 9_007_199_254_740_992) + return r + }}, + {name: "missing cost", mutate: func(db *gorm.DB) error { + return db.Model(&model.ChannelModelPriceTier{}).Where("id = ?", "tier-b").Update("cost_configured", false).Error + }}, + {name: "disabled tier", mutate: func(db *gorm.DB) error { + return db.Model(&model.ChannelModelPriceTier{}).Where("id = ?", "tier-b").Update("enabled", false).Error + }}, + {name: "cross channel", mutate: func(db *gorm.DB) error { + return db.Model(&model.ChannelModel{}).Where("id = ?", "b").Update("channel_id", "other").Error + }}, + {name: "deleted", mutate: func(db *gorm.DB) error { return db.Delete(&model.ChannelModel{}, "id = ?", "b").Error }}, + {name: "not admin", nonAdmin: true}, + } { + t.Run(tt.name, func(t *testing.T) { + svc, db, actor := seedRepricingModels(t) + requests := []ChannelModelRepriceRequest{repricingRequest("a", 1_250_000), repricingRequest("b", 1_370_000)} + if tt.edit != nil { + requests = tt.edit(requests) + } + if tt.mutate != nil { + if err := tt.mutate(db); err != nil { + t.Fatal(err) + } + } + if tt.nonAdmin { + actor = &model.User{ID: "user"} + } + if count, err := svc.RepriceAdminChannelModels(actor, "channel", requests); err == nil || count != 0 { + t.Fatalf("count=%d err=%v", count, err) + } + item, err := svc.repo.ChannelModelByID("channel", "a") + if err != nil { + t.Fatal(err) + } + if item.PriceVersion != 1 || item.PriceTiers[0].UnitPriceMicrocredits != 9 { + t.Fatal("partial save") + } + }) + } +} + +func TestRepriceTierFieldValidation(t *testing.T) { + for _, capability := range []string{"text", "video"} { + output, input, zero := int64(1_370_000), int64(500_000), int64(0) + tier := model.ChannelModelPriceTier{ID: "t", BillingMode: "token", PriceConfigured: true, CostPricing: model.CreditCostPricing{Configured: true}} + prices := map[string]*int64{"outputTokenPriceMicrocredits": &output} + protocol := model.ChannelInterfaceVolcengineArkVideo + if capability == "text" { + protocol = model.ChannelInterfaceChatCompletion + prices["inputTokenPriceMicrocredits"] = &input + prices["cachedTokenPriceMicrocredits"] = &zero + } + if err := applyChannelModelTierPrices(&tier, capability, protocol, prices); err != nil { + t.Fatal(err) + } + if tier.OutputTokenPriceMicrocredits != output || tier.CachedTokenPriceMicrocredits != 0 { + t.Fatalf("tier=%+v", tier) + } + output = 1_000_000_000_001 + if err := applyChannelModelTierPrices(&tier, capability, protocol, prices); err == nil { + t.Fatal("token price limit missing") + } + } +} + +func TestBatchRepricingRollsBackOnConcurrentChange(t *testing.T) { + for _, target := range []string{"model", "tier"} { + t.Run(target, func(t *testing.T) { + svc, db, _ := seedRepricingModels(t) + items := make([]model.ChannelModel, 0, 2) + for _, id := range []string{"a", "b"} { + item, err := svc.repo.ChannelModelByID("channel", id) + if err != nil { + t.Fatal(err) + } + item.UnitPriceMicrocredits = 123 + item.PriceTiers[0].UnitPriceMicrocredits = 123 + items = append(items, *item) + } + var err error + if target == "model" { + err = db.Model(&model.ChannelModel{}).Where("id = ?", "b").Update("price_version", 2).Error + } else { + err = db.Model(&model.ChannelModelPriceTier{}).Where("id = ?", "tier-b").Update("price_version", 2).Error + } + if err != nil { + t.Fatal(err) + } + if err := svc.repo.UpdateChannelModelSalePrices("channel", items); !errors.Is(err, repository.ErrChannelModelPriceConflict) { + t.Fatalf("error=%v", err) + } + item, err := svc.repo.ChannelModelByID("channel", "a") + if err != nil { + t.Fatal(err) + } + if item.UnitPriceMicrocredits != 9 || item.PriceVersion != 1 || item.PriceTiers[0].UnitPriceMicrocredits != 9 || item.PriceTiers[0].PriceVersion != 1 { + t.Fatal("transaction not rolled back") + } + }) + } +} diff --git a/backend/internal/handler/credit_cost_routes_test.go b/backend/internal/handler/credit_cost_routes_test.go index f08ec9557..2bf2693a7 100644 --- a/backend/internal/handler/credit_cost_routes_test.go +++ b/backend/internal/handler/credit_cost_routes_test.go @@ -81,6 +81,86 @@ func TestChannelCostHTTPAuthorizationAndPublicProjection(t *testing.T) { t.Fatalf("admin cost missing: %d %s", w.Code, w.Body.String()) } } + t.Run("batch repricing HTTP contract", func(t *testing.T) { + var item model.ChannelModel + if err := db.First(&item, "channel_id = ?", channel.ID).Error; err != nil { + t.Fatal(err) + } + path := "/api/admin/channels/channel/models/batch-reprice" + makePayload := func(price any) []byte { + t.Helper() + var latest model.ChannelModel + var tier model.ChannelModelPriceTier + if err := db.First(&latest, "id = ?", item.ID).Error; err != nil { + t.Fatal(err) + } + if err := db.First(&tier, "channel_model_id = ?", item.ID).Error; err != nil { + t.Fatal(err) + } + payload, err := json.Marshal(map[string]any{"models": []any{map[string]any{ + "modelId": item.ID, "priceVersion": latest.PriceVersion, + "priceTiers": []any{map[string]any{"id": tier.ID, "priceVersion": tier.PriceVersion, + "prices": map[string]any{"unitPriceMicrocredits": price}}}, + }}}) + if err != nil { + t.Fatal(err) + } + return payload + } + payload := makePayload(170_000) + for _, role := range []string{"", "user"} { + w := call(role, http.MethodPost, path, payload) + want := http.StatusForbidden + if role == "" { + want = http.StatusUnauthorized + } + if w.Code != want { + t.Fatalf("role %q: %d %s", role, w.Code, w.Body.String()) + } + } + for _, invalid := range []string{ + `{}`, `{"models":[]}`, `{"models":null}`, + `{"models":[{"modelId":"` + item.ID + `","priceVersion":1,"priceTiers":[]}]}`, + string(makePayload(nil)), string(makePayload(-1)), string(makePayload(1.5)), + } { + w := call("admin", http.MethodPost, path, []byte(invalid)) + if w.Code != http.StatusBadRequest { + t.Fatalf("invalid payload: %d %s", w.Code, w.Body.String()) + } + } + w := call("admin", http.MethodPost, path, payload) + if w.Code != http.StatusOK || !strings.Contains(w.Body.String(), `"updated":1`) { + t.Fatalf("reprice: %d %s", w.Code, w.Body.String()) + } + var tier model.ChannelModelPriceTier + if err := db.First(&tier, "channel_model_id = ?", item.ID).Error; err != nil { + t.Fatal(err) + } + if tier.UnitPriceMicrocredits != 170_000 || tier.CostPricing.UnitPriceMicrocredits != 123_456 { + t.Fatalf("unexpected stored tier: %+v", tier) + } + w = call("admin", http.MethodPost, path, payload) + if w.Code != http.StatusBadRequest { + t.Fatalf("stale version: %d %s", w.Code, w.Body.String()) + } + // Explicit zero is a valid final sale price, not a missing field. + payload = makePayload(0) + w = call("admin", http.MethodPost, path, payload) + if w.Code != http.StatusOK { + t.Fatalf("zero target: %d %s", w.Code, w.Body.String()) + } + if err := db.First(&tier, "channel_model_id = ?", item.ID).Error; err != nil { + t.Fatal(err) + } + if tier.UnitPriceMicrocredits != 0 || tier.CostPricing.UnitPriceMicrocredits != 123_456 { + t.Fatalf("zero price not preserved: %+v", tier) + } + payload = makePayload(170_000) + w = call("admin", http.MethodPost, path, payload) + if w.Code != http.StatusOK { + t.Fatalf("restore target: %d %s", w.Code, w.Body.String()) + } + }) for _, path := range []string{"/api/model-catalog", "/api/model-catalog/available", "/api/model-catalog/quote"} { method := http.MethodPost payload := []byte(`{"capability":"text"}`) diff --git a/backend/internal/handler/finance.go b/backend/internal/handler/finance.go index 6829b1636..ad8d8b006 100644 --- a/backend/internal/handler/finance.go +++ b/backend/internal/handler/finance.go @@ -296,6 +296,27 @@ func RegisterFinanceRoutes(r *gin.RouterGroup, svc *service.Service) { } ok(c, gin.H{"deleted": deleted}) }) + r.POST("/admin/channels/:id/models/batch-reprice", func(c *gin.Context) { + user, err := currentUser(c, svc) + if err != nil { + failService(c, err) + return + } + c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 2<<20) + var req struct { + Models []service.ChannelModelRepriceRequest `json:"models" binding:"required"` + } + if err := c.ShouldBindJSON(&req); err != nil { + fail(c, http.StatusBadRequest, err) + return + } + updated, err := svc.RepriceAdminChannelModels(user, c.Param("id"), req.Models) + if err != nil { + failService(c, err) + return + } + ok(c, gin.H{"updated": updated}) + }) r.PATCH("/admin/channels/:id/models/:modelId", func(c *gin.Context) { saveChannelModel(c, svc, c.Param("modelId")) }) diff --git a/backend/internal/handler/finance_routes_test.go b/backend/internal/handler/finance_routes_test.go index 9cc6fe4ab..733415e06 100644 --- a/backend/internal/handler/finance_routes_test.go +++ b/backend/internal/handler/finance_routes_test.go @@ -1,6 +1,8 @@ package handler import ( + "net/http" + "net/http/httptest" "testing" "infinite-canvas/backend/internal/service" @@ -21,3 +23,24 @@ func TestFinanceRoutesExposeChannelModelBatchDelete(t *testing.T) { } t.Fatalf("route %s is not registered", wanted) } + +func TestFinanceRoutesExposeChannelModelBatchReprice(t *testing.T) { + gin.SetMode(gin.TestMode) + router := gin.New() + RegisterFinanceRoutes(router.Group("/api"), &service.Service{}) + const wanted = "POST /api/admin/channels/:id/models/batch-reprice" + found := false + for _, route := range router.Routes() { + if route.Method+" "+route.Path == wanted { + found = true + } + } + if !found { + t.Fatalf("route %s is not registered", wanted) + } + response := httptest.NewRecorder() + router.ServeHTTP(response, httptest.NewRequest(http.MethodPost, "/api/admin/channels/channel/models/batch-reprice", nil)) + if response.Code != http.StatusUnauthorized { + t.Fatalf("unauthenticated status = %d, body = %s", response.Code, response.Body.String()) + } +} diff --git a/backend/internal/handler/openapi.yaml b/backend/internal/handler/openapi.yaml index aa8881146..67ac63951 100644 --- a/backend/internal/handler/openapi.yaml +++ b/backend/internal/handler/openapi.yaml @@ -721,6 +721,78 @@ paths: $ref: "#/components/responses/Error" "404": $ref: "#/components/responses/Error" + /admin/channels/{id}/models/batch-reprice: + post: + tags: [admin] + summary: 批量保存系统渠道模型最终销售价 + description: | + 仅管理员可用。接收浏览器计算或手工修改后的最终售价及原始价格版本,不在服务端重算。 + 前端按成本/(1−利润率)计算,支持0–6位积分小数四舍五入及重新计算。 + 必须提交所选模型全部已启用规格,不改变成本、规格启停或模型启停。 + 当前计费方式的价格字段必须完整且不能多余,价格为非负整数微积分,null无效。 + 缺少成本/售价配置、重复或失效 ID、越权、超限价格、过期版本或并发版本冲突均整批拒绝。 + parameters: + - in: path + name: id + required: true + schema: { type: string } + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [models] + properties: + models: + type: array + minItems: 1 + maxItems: 100 + items: + type: object + required: [modelId, priceVersion, priceTiers] + properties: + modelId: { type: string, minLength: 1 } + priceVersion: { type: integer, format: int64, minimum: 1 } + priceTiers: + type: array + minItems: 1 + items: + type: object + required: [id, priceVersion, prices] + properties: + id: { type: string, minLength: 1 } + priceVersion: { type: integer, format: int64, minimum: 1 } + prices: + type: object + additionalProperties: false + description: 按次/秒仅unit字段;文本Token为输入、输出、缓存;视频Token仅输出。必须完整提交对应字段。 + properties: + unitPriceMicrocredits: { type: integer, format: int64, minimum: 0, maximum: 9007199254740991 } + inputTokenPriceMicrocredits: { type: integer, format: int64, minimum: 0, maximum: 1000000000000 } + outputTokenPriceMicrocredits: { type: integer, format: int64, minimum: 0, maximum: 1000000000000 } + cachedTokenPriceMicrocredits: { type: integer, format: int64, minimum: 0, maximum: 1000000000000 } + example: { models: [{ modelId: MODEL001, priceVersion: 4, priceTiers: [{ id: TIER001, priceVersion: 8, prices: { unitPriceMicrocredits: 1370000 } }] }] } + responses: + "200": + description: 更新成功,data.updated 为更新的模型数量 + content: + application/json: + schema: + type: object + properties: + code: { type: integer, enum: [0] } + msg: { type: string } + data: + type: object + properties: + updated: { type: integer } + "400": + $ref: "#/components/responses/Error" + "401": + $ref: "#/components/responses/Error" + "403": + $ref: "#/components/responses/Error" /admin/agent-lessons: get: tags: [admin] diff --git a/backend/internal/repository/channel_model_pricing.go b/backend/internal/repository/channel_model_pricing.go new file mode 100644 index 000000000..a79bd226c --- /dev/null +++ b/backend/internal/repository/channel_model_pricing.go @@ -0,0 +1,56 @@ +package repository + +import ( + "errors" + + "infinite-canvas/backend/internal/model" + + "gorm.io/gorm" +) + +var ErrChannelModelPriceConflict = errors.New("channel model prices changed concurrently") + +// UpdateChannelModelSalePrices commits the entire selection or nothing. Only +// pricing columns are written; version checks protect the cost snapshot used by app. +func (r *Repository) UpdateChannelModelSalePrices(channelID string, items []model.ChannelModel) error { + return r.db.Transaction(func(tx *gorm.DB) error { + for _, item := range items { + result := tx.Model(&model.ChannelModel{}). + Where("id = ? AND channel_id = ? AND price_version = ? AND updated_at = ?", item.ID, channelID, item.PriceVersion, item.UpdatedAt). + Updates(map[string]any{ + "unit_price_microcredits": item.UnitPriceMicrocredits, + "input_token_price_microcredits": item.InputTokenPriceMicrocredits, + "output_token_price_microcredits": item.OutputTokenPriceMicrocredits, + "cached_token_price_microcredits": item.CachedTokenPriceMicrocredits, + "price_version": item.PriceVersion + 1, + }) + if result.Error != nil { + return result.Error + } + if result.RowsAffected != 1 { + return ErrChannelModelPriceConflict + } + for _, tier := range item.PriceTiers { + if !tier.Enabled { + continue + } + result = tx.Model(&model.ChannelModelPriceTier{}). + Where("id = ? AND channel_model_id = ? AND price_version = ? AND updated_at = ?", tier.ID, item.ID, tier.PriceVersion, tier.UpdatedAt). + Updates(map[string]any{ + "unit_price_microcredits": tier.UnitPriceMicrocredits, + "input_token_price_microcredits": tier.InputTokenPriceMicrocredits, + "output_token_price_microcredits": tier.OutputTokenPriceMicrocredits, + "cached_token_price_microcredits": tier.CachedTokenPriceMicrocredits, + "price_version": tier.PriceVersion + 1, + }) + if result.Error != nil { + return result.Error + } + if result.RowsAffected != 1 { + return ErrChannelModelPriceConflict + } + } + } + return nil + }) +} diff --git a/backend/internal/service/aliases_types.go b/backend/internal/service/aliases_types.go index 75c5c8e05..df34dc858 100644 --- a/backend/internal/service/aliases_types.go +++ b/backend/internal/service/aliases_types.go @@ -98,6 +98,7 @@ type ( ChannelModelCatalogOptions = app.ChannelModelCatalogOptions ChannelModelPriceTierRequest = app.ChannelModelPriceTierRequest ChannelModelRequest = app.ChannelModelRequest + ChannelModelRepriceRequest = app.ChannelModelRepriceRequest ChannelModelQuoteRequest = app.ChannelModelQuoteRequest ChannelModelSortRequest = app.ChannelModelSortRequest ChannelModelsRequest = app.ChannelModelsRequest diff --git a/docs/content/docs/backend/code-map.mdx b/docs/content/docs/backend/code-map.mdx index 1a4a57332..9da179474 100644 --- a/docs/content/docs/backend/code-map.mdx +++ b/docs/content/docs/backend/code-map.mdx @@ -20,6 +20,7 @@ description: 从产品功能快速定位主要代码入口 | 视频 Token 报价与结算 | `web/src/pages/admin/components/channel-model-price-tier-fields.tsx`、`web/src/components/model-picker.tsx` | `lib/model-pricing.ts`、`services/api/logical-models.ts`;后端 `app/video_token_billing.go` 管像素与时长估算,`app/logical_model_quote.go` 复用准入与价格规则,`app/analytics.go` 提取可信用量,`repository/finance.go` 按订单快照退补差额 | | 任务中心 | `web/src/pages/tasks/` | `services/api/task-center.ts`、`services/api/task-text-stream.ts` | | 产品模型与渠道选择 | `web/src/components/model-picker.tsx`、`pages/admin/components/channel-model-editor.tsx` | `lib/model-picker-groups.ts` 只构建跨渠道展示分组;`lib/user-session.ts` 透传渠道展示名;`components/model-logo.tsx` 按需加载原色图标;后端 `app/channel_models.go` 校验写入,`app/model_catalog.go` 发布目录,schema v24 增加 `channel_label` | +| 系统渠道规格利润率与批量调价 | `web/src/pages/admin/components/channel-model-manager.tsx`、`channel-model-reprice-dialog.tsx` | 弹窗按模型分组展示多规格、重新计算与逐项编辑;`channel-model-cost-summary.tsx` 展示价格,`channel-model-pricing.ts` 精确四舍五入并组装最终售价;后端 `handler/finance.go` 接收批量请求,`app/channel_model_pricing.go` 校验权限、最终价格及快照版本,`repository/channel_model_pricing.go` 事务保存及版本冲突检测 | | 我的素材 | `web/src/pages/assets/` | `stores/use-asset-store.ts`、`services/image-storage.ts` | | 技能库 | `web/src/pages/skills/` | `services/api/skills.ts`、`services/skill-runtime.ts`、`components/skills/skill-runtime-picker.tsx` | | 画布列表 | `web/src/pages/canvas/index.tsx` | `stores/canvas/use-canvas-store.ts` | diff --git a/docs/content/docs/backend/http-api.mdx b/docs/content/docs/backend/http-api.mdx index aa9384fc9..9dbc329a4 100644 --- a/docs/content/docs/backend/http-api.mdx +++ b/docs/content/docs/backend/http-api.mdx @@ -15,6 +15,32 @@ description: 信封、错误码与 OpenAPI ## 模型规格报价 +### 管理员批量调价 + +`POST /admin/channels/:id/models/batch-reprice` 接收最终售价及原始价格版本,返回 `{ code: 0, data: { updated: number }, msg }`。仅管理员可调用;单批 1–100 个模型,空 ID、重复 ID 或不属于当前系统渠道的记录整批拒绝。请求结构如下(价格为整数微积分): + +```json +{ + "models": [{ + "modelId": "MODEL001", + "priceVersion": 4, + "priceTiers": [{ + "id": "TIER001", + "priceVersion": 8, + "prices": { "unitPriceMicrocredits": 1370000 } + }] + }] +} +``` + +弹窗一行一个模型,列为模型展示名、渠道展示名(上游模型 ID)、规格、成本价和销售价输入框;多个规格在模型行内纵向对齐。利润率沿用财务口径:`(销售价 − 成本价) / 销售价 × 100%`,并非成本加价率。目标利润率为 0–99.99%,最多两位小数;售价按 `成本价 / (1 − 目标利润率)` 计算,按所选 0–6 位积分小数四舍五入(默认 2 位)。成本 1 积分、目标 20%、保留 2 位时售价为 1.25 积分。修改比例或精度实时计算,也可点击「重新计算」按当前参数覆盖全部手动售价;随后允许逐规格手动修改,保存以最终输入值为准。舍入和手动修改可能使实际利润率偏离目标;售价为零时利润率显示 `—`。 + +每个模型必须提交全部已启用规格(模型本身可停用),且每个规格只提交当前计费方式需要的完整 `prices` 字段:按次/秒为 `unitPriceMicrocredits`;文本 Token 为 `inputTokenPriceMicrocredits`、`outputTokenPriceMicrocredits`、`cachedTokenPriceMicrocredits`;视频 Token 仅为 `outputTokenPriceMicrocredits`。缺失、多余、重复、停用规格、null、负数或非整数价格均拒绝。成本和售价配置必须已保存;成本及 Token 售价上限为 1,000,000 积分,按次/秒售价不得超出 JavaScript 安全整数微积分范围。 + +服务端校验最终售价和模型/规格版本,不重新计算覆盖手工价格。价格快照过期必须刷新后重试。所有模型与价格档在单事务中更新,增加价格版本、失效目录缓存;无效配置、归属错误、超限或并发版本冲突均不部分提交。不改变成本、规格、计费方式和启停状态;未启用规格保持原值。此接口不新增数据表或迁移,不追溯修改已下单的计费快照。 + +### 用户模型目录 + `GET /model-catalog` 固定返回 `source=system`,直接查询所有启用系统渠道下的渠道模型,目录数据在 `channels[].models[]`,`models` 固定为空数组。不受逻辑模型开关影响,不反查逻辑模型或供应线路。每项的 `modelKey` 是调用标识,`displayName` 是模型展示名,`icon` 是模型图标键,`channelLabel` 是可选的渠道展示名(默认空字符串)。后台渠道模型创建与更新请求使用相同的 `channelLabel` 字段;服务端去除首尾空白并限制为 80 字,空字符串表示使用所属渠道名称。 渠道模型创建、更新、管理列表、公开目录和会话模型价格信息均使用 `description` 表达模型描述。服务端去除首尾空白后限制为 500 字,允许空字符串清除描述;无管理员权限或超长内容拒绝保存。创作端二级选项在悬浮、聚焦或选中时展示该字段,长内容可悬浮查看完整文本;未填写的系统模型不按名称生成宣传文案。描述区域保留固定行高,悬浮不改变菜单项尺寸。 diff --git a/docs/content/docs/overview/features.mdx b/docs/content/docs/overview/features.mdx index 0b689f017..9fdfc8ff8 100644 --- a/docs/content/docs/overview/features.mdx +++ b/docs/content/docs/overview/features.mdx @@ -132,6 +132,8 @@ description: 当前项目已实现的核心工作台能力与使用入口 渠道模型价格档支持管理员单独配置积分成本价;后台请求记录同时显示销售金额和成本金额。成本按下单快照计算,不追随之后改价;普通用户页面及接口不返回成本字段。 +系统渠道模型列表按规格显示成本价 / 销售价及利润率;文本输入、输出、缓存 Token 分开呈现。勾选多个模型可统一调价:弹窗一行一个模型,展示模型名、渠道展示名(上游 ID)、规格、成本与可编辑售价,多规格在模型行内纵向对齐。输入目标利润率并选择保留 0–6 位小数(默认 2 位),按 `成本 / (1 − 利润率)` 四舍五入实时计算;也可点击「重新计算」覆盖手动值,再逐项修改。保存最终输入售价,服务端校验价格与版本并原子保存,不改变成本或启停状态。 + 后台系统渠道及渠道模型列表通过“设置排序”打开完整列表,支持拖动、上移和下移,点击“保存排序”统一生效;取消不会修改线上顺序。渠道行的“更多”菜单支持复制渠道,复制会生成独立的渠道、模型和价格档,并保留原连接配置与凭证。界面不显示内部排序数字,排序不受筛选及分页影响;并发修改会提示重新加载,避免覆盖其他管理员的调整。系统渠道模式下,用户端模型选择器沿用同一渠道及模型顺序。模型排序独立保存,不改变价格、能力版本或启用状态。 系统渠道可设置可选的前台别名;为空时显示渠道名,设置后公开目录和用户会话只返回别名,后台仍保留原名。别名最长 80 字,支持后台搜索。前台模型目录模式保留其独立配置。 diff --git a/docs/content/docs/progress/pending-test.mdx b/docs/content/docs/progress/pending-test.mdx index 02aef2084..2f3c4403c 100644 --- a/docs/content/docs/progress/pending-test.mdx +++ b/docs/content/docs/progress/pending-test.mdx @@ -5,10 +5,24 @@ description: 当前项目已实现功能的手工验证清单与未确认边界 # 待测试 +## 画布 Agent 入口拖动 + +- [x] 右下角 Agent 图标支持鼠标和触屏拖动,使用指针捕获与 6px 阈值区分点击和拖动;拖动结束不打开面板,中断或失去捕获时结束手势,不传播到画布。 +- [x] 位置作为独立本机 UI 偏好保存,重新打开画布或收起面板后恢复;限制在可视范围,窗口缩小时自动回位。方向键移动 10px,Shift + 方向键移动 40px,保留键盘点击打开。 +- [x] 前端类型检查与 20 项专项/相邻测试通过,覆盖真实生产 hook 的点击、拖动、取消、丢失捕获、多指隔离、持久化和键盘/缩屏行为。 +- [ ] 本地浏览器访问跳转登录,未完成登录后真实画布验收;需验证鼠标/触屏拖动不带动画布、不误开面板、刷新恢复,以及明暗主题和窄屏边缘显示。专项测试不替代浏览器验收。 + ## 系统模型同页渠道与模型管理 +- [x] 规格列更新为成本价 / 销售价 / 利润率,按次、按秒、文本输入/输出/缓存和视频 Token 分别展示;缺失配置、零售价利润率显示 `—`,亏损显示负利润率。 +- [x] 支持跨页多选最多 100 个模型统一调价。弹窗打开即展示模型,一行一个模型,列为模型展示名、渠道展示名(上游模型 ID)、规格、成本及售价输入框;多规格在模型行内纵向对齐,不拆成多个模型行,规格上游覆盖单独标注。 +- [x] 调价弹窗模型价格列表进一步压缩行高:上下留白 6px、行内文字间距 2px,常规尺寸售价输入框与利润率同排,扩大售价列并保持规格对齐。弹窗居中固定高度 900px(不超过视窗高度减 48px),列表填充剩余空间并内部滚动,短屏允许内容区滚动;紧凑效果与滚动仍待真实页面视觉验收。 +- [x] 目标利润率和 0–6 位小数(默认 2 位)实时重算,旁边增加「重新计算」按钮,按当前参数对所有规格精确四舍五入。重算覆盖手动值并明确提示;之后可逐项修改,提交最终输入售价。服务端强校验归属、成本、价格和模型/规格版本,事务保存,冲突整批回滚,不改成本或启停状态。 +- [x] 前端类型检查、69 项专项及相邻回归测试通过,包含真实表格组件渲染、多规格单模型行、输入框最终值、精度和手动价重算。后端调价与相邻成本/删除测试通过,覆盖真实 HTTP 登录/管理员权限、整数/null/零售价、最终手工售价保存、过期版本拒绝及并发冲突事务回滚。验证使用内存 SQLite,未进行 PostgreSQL 或浏览器验收。 +- [ ] 待登录后的真实页面验收:多选/跨页/切换渠道,修改比例/精度后实时计算,手动编辑后点击「重新计算」恢复所有规格价格,逐项编辑保存、成功刷新与错误保留;覆盖未配置成本、零成本、Token 多计费项、长名称、明暗主题、窄屏、表头与规格对齐及滚动。不操作现有环境真实价格数据。 + - [x] 样式细化:渠道列表改为留白分组与中性选中描边,数量与启停状态分别呈现;模型头部说明成本口径,规格与成本分行,操作列固定在右侧,超过三个规格可展开查看。 -- [x] “规格价格”改为“规格成本价”,仅读取管理员接口已有的 `priceTiers[].costPricing`,不再显示用户售价。支持按次、按秒、文本输入/输出/缓存 Token 和视频 Token;未配置成本明确提示,显式零成本正常显示,异常或缺失数值显示 `—`。不修改后端、计费或用户售价。 +- [x] 规格成本读取管理员接口已有的 `priceTiers[].costPricing`,与销售价分开呈现;未配置成本明确提示,显式零成本正常显示,异常或缺失数值显示 `—`。 - [x] 本轮 49 项专项与相邻回归测试、前端类型检查通过;新增成本摘要真实组件渲染测试,覆盖成本/售价隔离、零成本、缺失/异常值、Token 单位、规格选择器与展开内容。样式细化仍需在登录后的真实页面检查明暗主题、长名称、多规格行高、横向滚动与固定操作列。 - [x] 系统渠道入口改为同页工作区:桌面左侧约 20% 展示渠道搜索、状态筛选、新增和排序,右侧约 80% 展示当前渠道信息与模型管理;移除模型管理的二级页面和返回入口,窄屏改为上下布局。 - [x] 保留渠道编辑、复制、启停、删除及模型拉取、选择导入、编辑、排序和批量删除。新增或复制后自动选中渠道;切换渠道时隔离模型列表、筛选、勾选和编辑状态,渠道与模型加载失败显示重试入口。服务端仍使用原分页接口,左栏分批读取全部渠道,避免默认仅展示前 20 个。 diff --git a/web/src/components/canvas/canvas-cloud-agent-panel.tsx b/web/src/components/canvas/canvas-cloud-agent-panel.tsx index 684933ec6..bc5fc47d3 100644 --- a/web/src/components/canvas/canvas-cloud-agent-panel.tsx +++ b/web/src/components/canvas/canvas-cloud-agent-panel.tsx @@ -32,6 +32,7 @@ import { AgentChatComposer, AgentChatMessage, AgentPlanBar, AgentQuestionBar, Ag import { CanvasAgentSkillLibraryModal } from "./canvas-agent-skill-library-modal"; import { CanvasCloudAgentSettings, agentPermissionLabel, agentPermissionMenuItems, agentPermissionVisual, type AgentContextKey } from "./canvas-cloud-agent-settings"; import { useAgentPanelLayout } from "./use-agent-panel-layout"; +import { useAgentLauncherPosition } from "./use-agent-launcher-position"; import { AgentWelcome } from "./canvas-agent-welcome"; import "./canvas-cloud-agent.css"; @@ -797,17 +798,18 @@ export function CanvasCloudAgentPanel({ canvasId, domainProjectId, nodeCount, re } function AgentLauncher({ theme, statusColor, approvalPending, reducedMotion, onOpen }: { theme: CanvasTheme; statusColor: string; approvalPending: boolean; reducedMotion: boolean; onOpen: () => void }) { + const { position, dragging, handlers } = useAgentLauncherPosition(onOpen); return ( diff --git a/web/src/components/canvas/canvas-cloud-agent.css b/web/src/components/canvas/canvas-cloud-agent.css index eb0e563ce..f54340351 100644 --- a/web/src/components/canvas/canvas-cloud-agent.css +++ b/web/src/components/canvas/canvas-cloud-agent.css @@ -522,10 +522,16 @@ background: transparent; padding: 7px; box-shadow: 0 16px 38px color-mix(in srgb, var(--canvas-agent-launcher-shadow) 64%, transparent); - cursor: pointer; + cursor: grab; + touch-action: none; + user-select: none; isolation: isolate; } +.canvas-agent-launcher.is-dragging { + cursor: grabbing; +} + .canvas-agent-launcher::before { position: absolute; inset: 3px; diff --git a/web/src/components/canvas/use-agent-launcher-position.ts b/web/src/components/canvas/use-agent-launcher-position.ts new file mode 100644 index 000000000..164f5077b --- /dev/null +++ b/web/src/components/canvas/use-agent-launcher-position.ts @@ -0,0 +1,99 @@ +import { useEffect, useRef, useState, type KeyboardEvent, type MouseEvent, type PointerEvent } from "react"; +import { AGENT_LAUNCHER_POSITION_KEY, clampAgentLauncherPosition, moveAgentLauncher, restoreAgentLauncherPosition, type AgentLauncherGesture, type AgentLauncherPosition } from "@/lib/canvas/agent-launcher-position"; + +const viewport = () => ({ width: window.innerWidth, height: window.innerHeight }); + +export function useAgentLauncherPosition(onOpen: () => void) { + const [position, setPosition] = useState(() => { + try { + return restoreAgentLauncherPosition(localStorage.getItem(AGENT_LAUNCHER_POSITION_KEY), viewport()); + } catch (error) { + console.warn("Agent 入口位置无法读取", error); + return restoreAgentLauncherPosition(null, viewport()); + } + }); + const [dragging, setDragging] = useState(false); + const gestureRef = useRef<(AgentLauncherGesture & { pointerId: number }) | null>(null); + const suppressClick = useRef(false); + + const save = (next: AgentLauncherPosition) => { + try { + localStorage.setItem(AGENT_LAUNCHER_POSITION_KEY, JSON.stringify(next)); + } catch (error) { + console.warn("Agent 入口位置无法保存", error); + } + }; + + useEffect(() => { + const resize = () => setPosition((current) => clampAgentLauncherPosition(current, viewport())); + window.addEventListener("resize", resize); + return () => window.removeEventListener("resize", resize); + }, []); + + const onPointerDown = (event: PointerEvent) => { + event.stopPropagation(); + if (event.button !== 0 || !event.isPrimary || gestureRef.current) return; + suppressClick.current = false; + gestureRef.current = { pointerId: event.pointerId, x: event.clientX, y: event.clientY, start: position, dragged: false }; + event.currentTarget.setPointerCapture(event.pointerId); + }; + const onPointerMove = (event: PointerEvent) => { + event.stopPropagation(); + const gesture = gestureRef.current; + if (!gesture || gesture.pointerId !== event.pointerId) return; + const next = moveAgentLauncher(gesture, event.clientX, event.clientY, viewport()); + gesture.dragged = next.dragged; + if (!next.dragged) return; + event.preventDefault(); + suppressClick.current = true; + setDragging(true); + setPosition(next.position); + }; + const finish = (event: PointerEvent, cancelled: boolean) => { + event.stopPropagation(); + const gesture = gestureRef.current; + if (!gesture || gesture.pointerId !== event.pointerId) return; + gestureRef.current = null; + const next = cancelled ? { dragged: gesture.dragged, position: clampAgentLauncherPosition(position, viewport()) } : moveAgentLauncher(gesture, event.clientX, event.clientY, viewport()); + suppressClick.current = cancelled || next.dragged; + setDragging(false); + setPosition(next.position); + if (next.dragged) save(next.position); + if (event.currentTarget.hasPointerCapture(event.pointerId)) event.currentTarget.releasePointerCapture(event.pointerId); + }; + const onClick = (event: MouseEvent) => { + event.stopPropagation(); + // Keyboard/assistive activation remains available after a drag or cancellation. + if (event.detail !== 0 && suppressClick.current) { + event.preventDefault(); + return; + } + onOpen(); + }; + const onKeyDown = (event: KeyboardEvent) => { + event.stopPropagation(); + const delta = event.shiftKey ? 40 : 10; + const offsets: Record = { ArrowLeft: [delta, 0], ArrowRight: [-delta, 0], ArrowUp: [0, delta], ArrowDown: [0, -delta] }; + const offset = offsets[event.key]; + if (!offset) return; + event.preventDefault(); + const next = clampAgentLauncherPosition({ right: position.right + offset[0], bottom: position.bottom + offset[1] }, viewport()); + setPosition(next); + save(next); + }; + + return { + position, + dragging, + handlers: { + onPointerDown, + onPointerMove, + onPointerUp: (event: PointerEvent) => finish(event, false), + onPointerCancel: (event: PointerEvent) => finish(event, true), + onLostPointerCapture: (event: PointerEvent) => finish(event, true), + onClick, + onKeyDown, + onDoubleClick: (event: MouseEvent) => event.stopPropagation(), + }, + }; +} diff --git a/web/src/lib/canvas/agent-launcher-position.ts b/web/src/lib/canvas/agent-launcher-position.ts new file mode 100644 index 000000000..e076771ed --- /dev/null +++ b/web/src/lib/canvas/agent-launcher-position.ts @@ -0,0 +1,38 @@ +export type AgentLauncherPosition = { right: number; bottom: number }; +export type AgentLauncherViewport = { width: number; height: number }; +export type AgentLauncherGesture = { x: number; y: number; start: AgentLauncherPosition; dragged: boolean }; + +export const AGENT_LAUNCHER_POSITION_KEY = "canvas:agent-launcher-position:v1"; +export const AGENT_LAUNCHER_SIZE = 76; +const MARGIN = 20; // Includes the label below the orb. + +export function clampAgentLauncherPosition(position: AgentLauncherPosition, viewport: AgentLauncherViewport): AgentLauncherPosition { + const clamp = (value: number, extent: number) => { + const available = Math.max(0, extent - AGENT_LAUNCHER_SIZE); + const margin = Math.min(MARGIN, available / 2); + return Math.min(Math.max(value, margin), available - margin); + }; + return { right: clamp(position.right, viewport.width), bottom: clamp(position.bottom, viewport.height) }; +} + +export function restoreAgentLauncherPosition(raw: string | null, viewport: AgentLauncherViewport): AgentLauncherPosition { + if (raw) { + try { + const value = JSON.parse(raw); + if (value && Number.isFinite(value.right) && Number.isFinite(value.bottom)) return clampAgentLauncherPosition(value, viewport); + } catch { + // Damaged UI preferences must not hide the launcher. + } + } + return clampAgentLauncherPosition({ right: MARGIN, bottom: MARGIN }, viewport); +} + +export function moveAgentLauncher(gesture: AgentLauncherGesture, x: number, y: number, viewport: AgentLauncherViewport) { + const dx = x - gesture.x; + const dy = y - gesture.y; + const dragged = gesture.dragged || Math.hypot(dx, dy) >= 6; + return { + dragged, + position: clampAgentLauncherPosition(dragged ? { right: gesture.start.right - dx, bottom: gesture.start.bottom - dy } : gesture.start, viewport), + }; +} diff --git a/web/src/pages/admin/channels/channels-page.css b/web/src/pages/admin/channels/channels-page.css index d59e83db1..985d58907 100644 --- a/web/src/pages/admin/channels/channels-page.css +++ b/web/src/pages/admin/channels/channels-page.css @@ -218,6 +218,145 @@ line-height: 1.6; } +.admin-model-reprice { + display: flex; + flex-direction: column; + flex: 1; + min-height: 0; + gap: 16px; + padding-block: 8px; +} + +.admin-model-reprice > :not(.admin-model-reprice-table) { + flex-shrink: 0; +} + +.admin-model-reprice-controls { + display: flex; + align-items: flex-end; + flex-wrap: wrap; + gap: 24px; + padding: 24px; + border-radius: var(--admin-radius); + background: var(--admin-surface-2); +} + +.admin-model-reprice-field { + display: flex; + flex-direction: column; + gap: 10px; + width: 190px; + max-width: 100%; +} + +.admin-model-reprice-field > .ant-input-number { + width: 100%; +} + +.admin-model-reprice-formula, +.admin-model-reprice-identity { + display: flex; + flex-direction: column; + gap: 2px; + overflow-wrap: anywhere; +} + +.admin-model-reprice-sale { + display: flex; + align-items: center; + gap: 8px; +} + +.admin-model-reprice-sale > .ant-input-number { + flex: 0 0 120px; + width: 120px; +} + +.admin-model-reprice-sale > small { + white-space: nowrap; +} + +.admin-model-reprice-formula { + padding-block: 4px; + gap: 8px; +} + +.admin-model-reprice-note, +.admin-model-reprice-formula span, +.admin-model-reprice-identity small, +.admin-model-reprice-sale small, +.admin-model-reprice-list-heading > span { + color: var(--admin-text-muted); + font-size: 12px; + line-height: 1.7; +} + +.admin-model-reprice-note { + margin: 0; +} + +.admin-model-reprice-list-heading { + display: flex; + justify-content: space-between; + flex-wrap: wrap; + gap: 8px; +} + +.admin-model-reprice-table { + flex: 1; + min-height: 160px; + overflow: auto; +} + +.admin-model-reprice-table .ant-table-tbody > tr > td { + padding: 6px 16px; + vertical-align: middle; + overflow-wrap: anywhere; +} + +.admin-model-reprice-table .ant-table-tbody > tr > td.admin-model-reprice-spec-cell { + padding: 0; +} + +.admin-model-reprice-spec-row { + display: grid; + grid-template-columns: minmax(0, 140fr) minmax(0, 160fr) minmax(0, 340fr); + align-items: center; +} + +.admin-model-reprice-spec-row > div { + min-width: 0; + padding: 6px 16px; +} + +.admin-model-reprice-spec-row + .admin-model-reprice-spec-row { + border-top: 1px solid var(--admin-divider); +} + +.admin-model-reprice-no-spec { + padding: 6px 16px; + color: var(--admin-text-muted); +} + +.admin-model-reprice-modal .ant-modal-container { + display: flex; + flex-direction: column; + height: min(900px, calc(100dvh - 48px)); +} + +.admin-model-reprice-modal .ant-modal-header, +.admin-model-reprice-modal .ant-modal-footer { + flex-shrink: 0; +} + +.admin-model-reprice-modal .ant-modal-body { + display: flex; + flex-direction: column; + flex: 1; + min-height: 0; + overflow-y: auto; +} + .admin-model-cost-tier + .admin-model-cost-tier { margin-top: 8px; padding-top: 8px; diff --git a/web/src/pages/admin/components/channel-model-cost-summary.tsx b/web/src/pages/admin/components/channel-model-cost-summary.tsx index 01a0798fa..f7464f31f 100644 --- a/web/src/pages/admin/components/channel-model-cost-summary.tsx +++ b/web/src/pages/admin/components/channel-model-cost-summary.tsx @@ -1,14 +1,14 @@ import type { ChannelModel, ChannelModelPriceTier } from "@/services/api/wallet"; import { AdminStatusBadge } from "./admin-ui"; +import { formatModelMargin, formatModelPrice, modelPriceFields } from "./channel-model-pricing"; export function ChannelModelCostSummary({ item }: { item: ChannelModel }) { - // Cost configuration is independent of the user-facing sale price. const tiers = item.priceTiers?.filter((tier) => tier.enabled) || []; - if (!tiers.length) return ; + if (!tiers.length) return ; const renderTier = (tier: ChannelModelPriceTier) => (
{specificationLabel(tier)}
-
{costLabel(tier, item.capability)}
+
{priceLabel(tier, item.capability)}
); return ( @@ -16,7 +16,7 @@ export function ChannelModelCostSummary({ item }: { item: ChannelModel }) { {tiers.slice(0, 3).map(renderTier)} {tiers.length > 3 ? (
- 其余 {tiers.length - 3} 个规格成本 + 其余 {tiers.length - 3} 个规格价格 {tiers.slice(3).map(renderTier)}
) : null} @@ -24,25 +24,20 @@ export function ChannelModelCostSummary({ item }: { item: ChannelModel }) { ); } -function costLabel(tier: ChannelModelPriceTier, capability: ChannelModel["capability"]) { - const cost = tier.costPricing; - if (!cost?.configured) return 未配置成本; - const format = (value: number) => (Number.isFinite(value) && value >= 0 ? (value / 1_000_000).toLocaleString("zh-CN", { maximumFractionDigits: 6 }) : "—"); - if (tier.billingMode === "token") { - if (capability === "video") return `${format(cost.outputTokenPriceMicrocredits)} 积分 / 百万视频 Token`; +function priceLabel(tier: ChannelModelPriceTier, capability: ChannelModel["capability"]) { + return modelPriceFields(tier, capability).map(({ key, label, unit }) => { + const cost = tier.costPricing?.configured ? tier.costPricing[key] : undefined; + const sale = tier.priceConfigured ? tier[key] : undefined; return ( - <> - - 输入 {format(cost.inputTokenPriceMicrocredits)} · 输出 {format(cost.outputTokenPriceMicrocredits)} · 缓存 {format(cost.cachedTokenPriceMicrocredits)} - - 积分 / 百万 Token - +
+ {label ? `${label} ` : ""}{tier.costPricing?.configured ? formatModelPrice(cost) : "未配置成本"} / {tier.priceConfigured ? formatModelPrice(sale) : "未配置售价"} + 积分 / {unit} · 利润率 {formatModelMargin(cost, sale)} +
); - } - return `${format(cost.unitPriceMicrocredits)} 积分 / ${tier.billingMode === "per_second" ? "秒" : "次"}`; + }); } -function specificationLabel(tier: ChannelModelPriceTier) { +export function specificationLabel(tier: ChannelModelPriceTier) { const selector = tier.selector || {}; const specific = (value?: string) => (value && value !== "*" ? value : ""); const operation = specific(selector.operation); diff --git a/web/src/pages/admin/components/channel-model-manager.tsx b/web/src/pages/admin/components/channel-model-manager.tsx index 5fb25f600..dde51bde8 100644 --- a/web/src/pages/admin/components/channel-model-manager.tsx +++ b/web/src/pages/admin/components/channel-model-manager.tsx @@ -13,12 +13,14 @@ import { ChannelModelEditor } from "./channel-model-editor"; import { AdminBatchBar, AdminDataTable, AdminFilterChip, AdminStatusBadge, AdminTableEmpty } from "./admin-ui"; import { ChannelOrderDialog } from "./channel-order-dialog"; import { ChannelModelCostSummary } from "./channel-model-cost-summary"; +import { ChannelModelRepriceDialog } from "./channel-model-reprice-dialog"; export function ChannelModelManager({ channel, onChanged }: { channel: ModelChannel; onChanged: () => void | Promise }) { const { message, modal } = App.useApp(); const [items, setItems] = useState([]); const [selectedModelIds, setSelectedModelIds] = useState([]); const [deletingSelected, setDeletingSelected] = useState(false); + const [repriceItems, setRepriceItems] = useState([]); const [editing, setEditing] = useState(null); const [loading, setLoading] = useState(true); const [loadError, setLoadError] = useState(""); @@ -70,6 +72,7 @@ export function ChannelModelManager({ channel, onChanged }: { channel: ModelChan setEditing(null); setEditorOpen(false); setSelectedModelIds([]); + setRepriceItems([]); resetFetchPreview(); setKeyword(""); setCapability("all"); @@ -209,7 +212,7 @@ export function ChannelModelManager({ channel, onChanged }: { channel: ModelChan ), }, - { title: "规格成本价", width: 290, render: (_, item) => }, + { title: "规格成本价 / 销售价 / 利润率", width: 310, render: (_, item) => }, { title: "版本", dataIndex: "priceVersion", width: 75, render: (value) => `v${value}` }, { title: "状态", dataIndex: "enabled", width: 85, render: (enabled) => }, { @@ -264,7 +267,7 @@ export function ChannelModelManager({ channel, onChanged }: { channel: ModelChan

模型管理 {items.length}

-

规格展示上游成本,不影响用户售价

+

规格展示成本价 / 销售价及利润率,勾选模型可统一调价

setSelectedModelIds([])}> + @@ -419,6 +425,11 @@ export function ChannelModelManager({ channel, onChanged }: { channel: ModelChan /> } /> + {repriceItems.length > 0 && setRepriceItems([])} onSaved={async () => { + setSelectedModelIds([]); + await reload(); + await onChanged(); + }} />} typeof value === "number" && Number.isSafeInteger(value) && value >= 0; + +export function formatModelPrice(value: number | undefined) { + return validPrice(value) ? (value / 1_000_000).toLocaleString("zh-CN", { maximumFractionDigits: 6 }) : "—"; +} + +export function formatModelMargin(cost: number | undefined, sale: number | undefined) { + if (!validPrice(cost) || !validPrice(sale) || sale === 0) return "—"; + return `${(((sale - cost) * 100) / sale).toLocaleString("zh-CN", { maximumFractionDigits: 2 })}%`; +} + +export function modelPriceFields(tier: ChannelModelPriceTier, capability: ChannelModel["capability"]) { + if (tier.billingMode !== "token") return [{ key: "unitPriceMicrocredits" as const, label: "", unit: tier.billingMode === "per_second" ? "秒" : "次" }]; + if (capability === "video") return [{ key: "outputTokenPriceMicrocredits" as const, label: "视频", unit: "百万视频 Token" }]; + return [ + { key: "inputTokenPriceMicrocredits" as const, label: "输入", unit: "百万 Token" }, + { key: "outputTokenPriceMicrocredits" as const, label: "输出", unit: "百万 Token" }, + { key: "cachedTokenPriceMicrocredits" as const, label: "缓存", unit: "百万 Token" }, + ]; +} + +export function previewModelRepricing(items: ChannelModel[], marginPercent: number | null, decimalPlaces = 2): ChannelModel[] { + if (marginPercent === null || !Number.isFinite(marginPercent) || marginPercent < 0 || marginPercent > 99.99 || Math.abs(marginPercent * 100 - Math.round(marginPercent * 100)) > 1e-8) { + throw new Error("请输入 0% 到 99.99% 的目标利润率,最多两位小数"); + } + if (!items.length || items.length > 100) throw new Error("请选择 1 到 100 个模型"); + if (!Number.isInteger(decimalPlaces) || decimalPlaces < 0 || decimalPlaces > 6) throw new Error("请选择保留 0 到 6 位小数"); + const denominator = BigInt(10_000 - Math.round(marginPercent * 100)); + const quantum = BigInt(10 ** (6 - decimalPlaces)); + return items.map((item) => { + if (!item.priceTiers.some((tier) => tier.enabled)) throw new Error(`${item.displayName || item.modelKey} 没有已启用规格`); + return { + ...item, + priceTiers: item.priceTiers.map((tier) => { + if (!tier.enabled) return tier; + const name = `${item.displayName || item.modelKey}(${tier.selectorKey})`; + if (!tier.id || !tier.priceConfigured || !tier.costPricing?.configured) throw new Error(`${name}:请先保存完整的规格成本价和销售价配置`); + const next = { ...tier }; + for (const { key } of modelPriceFields(tier, item.capability)) { + const cost = tier.costPricing[key]; + if (!validPrice(cost) || cost > 1_000_000_000_000) throw new Error(`${name}:成本价无效`); + // Round once at the selected credit precision, with exact half-up ties. + const numerator = BigInt(cost) * BigInt(10_000); + const divisor = denominator * quantum; + const sale = Number(((numerator * BigInt(2) + divisor) / (divisor * BigInt(2))) * quantum); + if (!validPrice(sale) || (tier.billingMode === "token" && sale > 1_000_000_000_000)) throw new Error(`${name}:调整后售价超过上限`); + next[key] = sale; + } + return next; + }), + }; + }); +} + +export function saleInputValue(microcredits: number) { + if (!validPrice(microcredits)) return null; + const value = BigInt(microcredits); + return `${value / BigInt(1_000_000)}.${String(value % BigInt(1_000_000)).padStart(6, "0")}`.replace(/\.?0+$/, ""); +} + +export function parseSaleInput(value: string | null) { + if (value === null || !/^\d+(?:\.\d{0,6})?$/.test(value)) throw new Error("销售价须为非负数,最多保留 6 位小数"); + const [integer, fraction = ""] = value.split("."); + const amount = Number(BigInt(integer) * BigInt(1_000_000) + BigInt(fraction.padEnd(6, "0"))); + if (!validPrice(amount)) throw new Error("销售价超出有效范围"); + return amount; +} + +export type ModelRepriceRow = { + key: string; + model: ChannelModel; + tier: ChannelModelPriceTier; + field: ReturnType[number]; + sale: string | null; + manual: boolean; +}; + +export function modelRepriceRows(items: ChannelModel[]): ModelRepriceRow[] { + return items.flatMap((model) => + model.priceTiers + .filter((tier) => tier.enabled) + .flatMap((tier) => + modelPriceFields(tier, model.capability).map((field) => ({ + key: `${tier.id}:${field.key}`, + model, + tier, + field, + sale: tier.priceConfigured ? saleInputValue(tier[field.key]) : null, + manual: false, + })), + ), + ); +} + +export function modelRepricePayload(items: ChannelModel[], rows: ModelRepriceRow[]): ChannelModelRepriceInput[] { + if (!items.length || items.length > 100) throw new Error("请选择 1 到 100 个模型"); + const byKey = new Map(rows.map((row) => [row.key, row])); + return items.map((item) => { + const tiers = item.priceTiers.filter((tier) => tier.enabled); + if (!tiers.length) throw new Error(`${item.displayName || item.modelKey} 没有已启用规格`); + return { + modelId: item.id, + priceVersion: item.priceVersion, + priceTiers: tiers.map((tier) => { + if (!tier.id || !tier.priceConfigured || !tier.costPricing?.configured) throw new Error(`${item.displayName || item.modelKey}:请先配置规格成本价和销售价`); + const prices: ChannelModelRepriceInput["priceTiers"][number]["prices"] = {}; + for (const { key } of modelPriceFields(tier, item.capability)) { + const cost = tier.costPricing[key]; + if (!validPrice(cost) || cost > 1_000_000_000_000) throw new Error(`${item.displayName || item.modelKey}:成本价无效`); + const sale = parseSaleInput(byKey.get(`${tier.id}:${key}`)?.sale ?? null); + if (tier.billingMode === "token" && sale > 1_000_000_000_000) throw new Error("Token 售价不能超过 1,000,000 积分"); + prices[key] = sale; + } + return { id: tier.id, priceVersion: tier.priceVersion, prices }; + }), + }; + }); +} diff --git a/web/src/pages/admin/components/channel-model-reprice-dialog.tsx b/web/src/pages/admin/components/channel-model-reprice-dialog.tsx new file mode 100644 index 000000000..7f6ad2014 --- /dev/null +++ b/web/src/pages/admin/components/channel-model-reprice-dialog.tsx @@ -0,0 +1,224 @@ +import { useState } from "react"; +import { Alert, App, Button, InputNumber, Select, Table } from "antd"; +import { AdminModal } from "@/pages/admin/ui/overlays"; +import { repriceAdminChannelModels, type ChannelModel } from "@/services/api/wallet"; +import { specificationLabel } from "./channel-model-cost-summary"; +import { formatModelMargin, formatModelPrice, modelRepricePayload, modelRepriceRows, parseSaleInput, previewModelRepricing, type ModelRepriceRow } from "./channel-model-pricing"; + +export function ChannelModelRepriceDialog({ channelId, channelName, items, onClose, onSaved }: { channelId: string; channelName: string; items: ChannelModel[]; onClose: () => void; onSaved: () => Promise }) { + const { message } = App.useApp(); + const [margin, setMargin] = useState(null); + const [decimalPlaces, setDecimalPlaces] = useState(2); + const [rows, setRows] = useState(() => modelRepriceRows(items)); + const [calculationError, setCalculationError] = useState(""); + const [saving, setSaving] = useState(false); + let validationError = ""; + try { + modelRepricePayload(items, rows); + } catch (cause) { + validationError = cause instanceof Error ? cause.message : "销售价无效"; + } + const original = new Map(modelRepriceRows(items).map((row) => [row.key, row.sale])); + const changed = rows.filter((row) => { + try { + return parseSaleInput(row.sale) !== parseSaleInput(original.get(row.key) ?? null); + } catch { + return false; + } + }).length; + const recalculate = (nextMargin: number | null, places: number) => { + setMargin(nextMargin); + setDecimalPlaces(places); + if (nextMargin === null) { + setCalculationError(""); + return; + } + try { + setRows(modelRepriceRows(previewModelRepricing(items, nextMargin, places))); + setCalculationError(""); + } catch (cause) { + setCalculationError(cause instanceof Error ? cause.message : "无法计算销售价"); + } + }; + const save = async () => { + if (validationError || calculationError || !changed || saving) return; + setSaving(true); + try { + const result = await repriceAdminChannelModels(channelId, modelRepricePayload(items, rows)); + message.success(`已保存 ${result.updated} 个模型的销售价`); + } catch (cause) { + message.error(cause instanceof Error ? cause.message : "统一调价失败"); + setSaving(false); + return; + } + try { + await onSaved(); + } catch { + message.warning("价格已保存,但列表刷新失败,请手动刷新"); + } finally { + setSaving(false); + onClose(); + } + }; + return ( + void save()} + > +
+
+
+ + recalculate(value, decimalPlaces)} min={0} max={99.99} step={1} suffix="%" placeholder="输入百分比" disabled={saving} /> +
+
+ +