From 237f2e2a6e22b2a7182bdc3c008b148d6855c5e5 Mon Sep 17 00:00:00 2001 From: ddCat <2695092045@qq.com> Date: Sat, 19 Sep 2026 09:54:27 +0800 Subject: [PATCH] =?UTF-8?q?feat(models):=20=E6=A8=A1=E5=9E=8B=E7=9B=AE?= =?UTF-8?q?=E5=BD=95=E4=B8=8E=E8=AE=A1=E8=B4=B9=20-=20=E6=8C=89=E5=B1=95?= =?UTF-8?q?=E7=A4=BA=E5=90=8D=E5=88=86=E7=BB=84=E5=B9=B6=E9=9A=94=E7=A6=BB?= =?UTF-8?q?=E7=AE=A1=E7=90=86=E5=91=98=E7=A7=AF=E5=88=86=E6=88=90=E6=9C=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/app/admin.go | 16 +- backend/internal/app/admin_channel_test.go | 2 +- backend/internal/app/analytics.go | 16 +- backend/internal/app/api_log_billing_test.go | 10 +- backend/internal/app/channel_models.go | 5 + backend/internal/app/channel_presentation.go | 12 +- .../internal/app/channel_presentation_test.go | 38 ++--- backend/internal/app/credit_cost.go | 70 +++++++++ backend/internal/app/credit_cost_test.go | 142 ++++++++++++++++++ backend/internal/app/finance.go | 14 +- backend/internal/app/model_catalog.go | 29 +--- backend/internal/app/model_catalog_test.go | 6 +- backend/internal/app/model_router.go | 10 +- .../database/channel_credit_cost_test.go | 49 ++++++ backend/internal/database/migrations.go | 23 ++- backend/internal/database/migrations_test.go | 4 +- .../handler/admin_channel_model_response.go | 22 +++ .../handler/credit_cost_routes_test.go | 102 +++++++++++++ backend/internal/handler/finance.go | 8 +- backend/internal/handler/openapi.yaml | 11 +- backend/internal/model/credit_cost.go | 19 +++ backend/internal/model/models_channel.go | 136 ++++++++--------- backend/internal/model/models_finance.go | 13 +- .../repository/channel_presentation.go | 5 +- backend/internal/repository/logical_models.go | 10 +- .../repository/logical_models_test.go | 41 +++++ backend/internal/repository/repository.go | 2 +- backend/internal/service/aliases_consts.go | 1 - .../content/docs/backend/backend-database.mdx | 12 +- docs/content/docs/backend/http-api.mdx | 14 +- docs/content/docs/overview/features.mdx | 4 +- docs/content/docs/progress/pending-test.mdx | 11 +- web/src/components/model-picker.tsx | 2 +- web/src/lib/model-picker-groups.ts | 29 ++-- web/src/lib/user-session.ts | 46 +----- .../pages/admin/channels/channels-page.tsx | 7 - .../components/api-log-detail-drawer.tsx | 3 +- .../components/channel-model-editor-form.ts | 4 + .../admin/components/channel-model-editor.tsx | 4 +- .../channel-model-price-tier-fields.tsx | 3 + .../channel-model-price-tier-form.ts | 22 +++ .../admin/components/credit-cost-fields.tsx | 21 +++ web/src/pages/admin/logs/logs-page.tsx | 5 +- web/src/services/api/auth.ts | 2 + web/src/services/api/logical-models.ts | 4 +- web/src/services/api/wallet.ts | 10 ++ web/src/stores/use-config-store.ts | 1 - .../channel-model-price-tier-form.test.ts | 19 +++ web/test/model-picker-groups.test.ts | 39 +++-- 49 files changed, 802 insertions(+), 276 deletions(-) create mode 100644 backend/internal/app/credit_cost.go create mode 100644 backend/internal/app/credit_cost_test.go create mode 100644 backend/internal/database/channel_credit_cost_test.go create mode 100644 backend/internal/handler/admin_channel_model_response.go create mode 100644 backend/internal/handler/credit_cost_routes_test.go create mode 100644 backend/internal/model/credit_cost.go create mode 100644 web/src/pages/admin/components/credit-cost-fields.tsx diff --git a/backend/internal/app/admin.go b/backend/internal/app/admin.go index aeecbd7e5..ea1cc5cd4 100644 --- a/backend/internal/app/admin.go +++ b/backend/internal/app/admin.go @@ -88,7 +88,6 @@ type AdminReferenceData struct { type ChannelRequest struct { Name string `json:"name"` - PublicAlias *string `json:"publicAlias"` SortOrder *int `json:"sortOrder"` BaseURL string `json:"baseUrl"` APIKey string `json:"apiKey"` @@ -106,7 +105,6 @@ type PublicModelChannel struct { Scope model.ChannelScope `json:"scope"` Enabled bool `json:"enabled"` Name string `json:"name"` - PublicAlias string `json:"publicAlias,omitempty"` SortOrder int `json:"sortOrder"` BaseURL string `json:"baseUrl"` APIKey string `json:"apiKey"` @@ -880,13 +878,6 @@ func (s *Service) channelFromRequest(req ChannelRequest, channel model.ModelChan return channel, err } channel.Name = name - if req.PublicAlias != nil { - alias := strings.TrimSpace(*req.PublicAlias) - if len([]rune(alias)) > 80 { - return channel, BadAuthRequest("前台显示别名不能超过 80 个字符") - } - channel.PublicAlias = alias - } if req.SortOrder != nil { if err := validateChannelSortOrder(*req.SortOrder); err != nil { return channel, err @@ -971,17 +962,12 @@ func publicChannel(channel model.ModelChannel, admin bool, channelModels []model } else if admin { apiKey = channel.APIKey } - name, alias := channel.PublicName(), "" - if admin { - name, alias = channel.Name, channel.PublicAlias - } return PublicModelChannel{ ID: channel.ID, UserID: channel.UserID, Scope: channel.Scope, Enabled: channel.Enabled, - Name: name, - PublicAlias: alias, + Name: channel.Name, SortOrder: channel.SortOrder, BaseURL: baseURL, APIKey: apiKey, diff --git a/backend/internal/app/admin_channel_test.go b/backend/internal/app/admin_channel_test.go index a50650943..b48566999 100644 --- a/backend/internal/app/admin_channel_test.go +++ b/backend/internal/app/admin_channel_test.go @@ -141,7 +141,7 @@ func TestDuplicateSystemChannelCopiesSecretsModelsAndPriceTiers(t *testing.T) { if err != nil { t.Fatalf("DuplicateSystemChannel() error = %v", err) } - if copied.Name != "方舟视频 - 副本" || copied.PublicAlias != source.PublicAlias || copied.Enabled != source.Enabled { + if copied.Name != "方舟视频 - 副本" || copied.Enabled != source.Enabled { t.Fatalf("copied channel summary = %#v", copied) } var stored model.ModelChannel diff --git a/backend/internal/app/analytics.go b/backend/internal/app/analytics.go index cb24aae7e..48a5d4957 100644 --- a/backend/internal/app/analytics.go +++ b/backend/internal/app/analytics.go @@ -285,6 +285,14 @@ func (s *Service) decorateAPICallLogs(logs []model.ApiCallLog) error { if order, exists := billingOrderByID[logs[index].BillingOrderID]; exists && order.UserID == logs[index].UserID { logs[index].BillingAvailable = true logs[index].BillingStatus = order.Status + if order.ChannelID == logs[index].ChannelID { + logs[index].CreditCostConfigured = order.CostPricing.Configured + cost, costErr := billingCreditCost(order) + if costErr != nil { + return costErr + } + logs[index].CreditCostMicrocredits = cost + } if order.Status == model.BillingStatusSettled { logs[index].BillingAmount = order.ActualAmountMicrocredits } else if order.Status != model.BillingStatusRefunded { @@ -403,7 +411,7 @@ func (s *Service) AdminAPICallLogsCSV(actor *model.User, query APICallLogQuery) var buffer bytes.Buffer buffer.WriteString("\xEF\xBB\xBF") writer := csv.NewWriter(&buffer) - _ = writer.Write([]string{"时间", "用户", "用户账号", "渠道", "模型", "能力", "状态", "轮询次数", "耗时毫秒", "输入Token", "输出Token", "缓存Token", "积分计费(微积分)", "积分计费状态", "上游估算费用(微单位)", "币种", "错误码", "错误"}) + _ = writer.Write([]string{"时间", "用户", "用户账号", "渠道", "模型", "能力", "状态", "轮询次数", "耗时毫秒", "输入Token", "输出Token", "缓存Token", "销售价格(微积分)", "积分计费状态", "成本价格(微积分)", "上游估算费用(微单位)", "币种", "错误码", "错误"}) for _, log := range logs { startedAt := log.StartedAt if startedAt.IsZero() { @@ -415,10 +423,14 @@ func (s *Service) AdminAPICallLogsCSV(actor *model.User, query APICallLogQuery) billingStatus = string(log.BillingStatus) } upstreamCost := "" + creditCost := "" + if log.CreditCostMicrocredits != nil { + creditCost = strconv.FormatInt(*log.CreditCostMicrocredits, 10) + } if log.CostAvailable { upstreamCost = strconv.FormatInt(log.EstimatedCostMicros, 10) } - _ = writer.Write([]string{startedAt.UTC().Format(time.RFC3339), log.UserDisplayName, log.UserAccount, log.ChannelName, log.Model, log.Capability, string(log.Status), strconv.Itoa(log.PollCount), strconv.FormatInt(log.DurationMs, 10), strconv.FormatInt(log.InputTokens, 10), strconv.FormatInt(log.OutputTokens, 10), strconv.FormatInt(log.CachedTokens, 10), billingAmount, billingStatus, upstreamCost, log.Currency, log.ErrorCode, log.Error}) + _ = writer.Write([]string{startedAt.UTC().Format(time.RFC3339), log.UserDisplayName, log.UserAccount, log.ChannelName, log.Model, log.Capability, string(log.Status), strconv.Itoa(log.PollCount), strconv.FormatInt(log.DurationMs, 10), strconv.FormatInt(log.InputTokens, 10), strconv.FormatInt(log.OutputTokens, 10), strconv.FormatInt(log.CachedTokens, 10), billingAmount, billingStatus, creditCost, upstreamCost, log.Currency, log.ErrorCode, log.Error}) } writer.Flush() if err := writer.Error(); err != nil { diff --git a/backend/internal/app/api_log_billing_test.go b/backend/internal/app/api_log_billing_test.go index 4533465b6..be7c414cc 100644 --- a/backend/internal/app/api_log_billing_test.go +++ b/backend/internal/app/api_log_billing_test.go @@ -26,7 +26,7 @@ func TestDecorateAPICallLogsUsesBillingOrderSnapshot(t *testing.T) { } orders := []model.BillingOrder{ - {ID: "settled", UserID: "user-1", IdempotencyKey: "settled", Status: model.BillingStatusSettled, ReservedAmountMicrocredits: 1_000_000, ActualAmountMicrocredits: 780_000}, + {ID: "settled", UserID: "user-1", IdempotencyKey: "settled", Status: model.BillingStatusSettled, ReservedAmountMicrocredits: 1_000_000, ActualAmountMicrocredits: 780_000, BillingCostSnapshot: model.BillingCostSnapshot{CostBillingMode: "fixed_request", CostQuantity: 1, CostPricing: model.CreditCostPricing{Configured: true, UnitPriceMicrocredits: 456_789}}}, {ID: "reserved", UserID: "user-1", IdempotencyKey: "reserved", Status: model.BillingStatusReserved, ReservedAmountMicrocredits: 900_000}, {ID: "uncertain", UserID: "user-1", IdempotencyKey: "uncertain", Status: model.BillingStatusUncertain, ReservedAmountMicrocredits: 1_200_000}, {ID: "refunded", UserID: "user-1", IdempotencyKey: "refunded", Status: model.BillingStatusRefunded, ReservedAmountMicrocredits: 650_000, RefundedAmountMicrocredits: 650_000}, @@ -58,6 +58,14 @@ func TestDecorateAPICallLogsUsesBillingOrderSnapshot(t *testing.T) { assertBillingSnapshot(t, logs[4], false, "", 0) assertBillingSnapshot(t, logs[5], false, "", 0) assertBillingSnapshot(t, logs[6], false, "", 0) + if !logs[0].CreditCostConfigured || logs[0].CreditCostMicrocredits == nil || *logs[0].CreditCostMicrocredits != 456_789 { + t.Fatalf("request cost missing: %#v", logs[0]) + } + for _, log := range logs[1:] { + if log.CreditCostConfigured || log.CreditCostMicrocredits != nil { + t.Fatalf("fabricated cost for %s", log.ID) + } + } } func assertBillingSnapshot(t *testing.T, log model.ApiCallLog, available bool, status model.BillingStatus, amount int64) { diff --git a/backend/internal/app/channel_models.go b/backend/internal/app/channel_models.go index 44945896a..b8f2793dd 100644 --- a/backend/internal/app/channel_models.go +++ b/backend/internal/app/channel_models.go @@ -42,6 +42,7 @@ const maxAdminChannelModelBatchDeleteCount = 100 // ChannelModelPriceTierRequest 是系统渠道内某个规格的上游 SKU 与结算价格。 // Resolution="*"、VideoSeconds=0 分别表示任意分辨率和任意时长。 type ChannelModelPriceTierRequest struct { + CostPricing model.CreditCostPricing `json:"costPricing"` // Selector 是 SKU 的规范匹配条件。支持 operation、quality、size、vquality、videoSeconds、imageCount、videoGenerateAudio; // operation 可区分文生/图生/视频生,避免同一分辨率下错误复用价格。 Selector map[string]string `json:"selector"` @@ -493,6 +494,7 @@ func (s *Service) normalizeChannelModelPriceTiers(req ChannelModelRequest, capab } enabled := input.Enabled == nil || *input.Enabled result = append(result, model.ChannelModelPriceTier{ + CostPricing: input.CostPricing, ID: id, SelectorKey: key, SelectorJSON: key, @@ -605,6 +607,9 @@ func normalizeChannelModelTierSelector(capability string, input ChannelModelPric } func validateChannelModelTierPricing(capability string, protocol model.ChannelInterfaceType, billingMode string, input ChannelModelPriceTierRequest) error { + if err := validateCreditCostPricing(capability, billingMode, input.CostPricing); err != nil { + return err + } if billingMode != "fixed_request" && billingMode != "per_second" && billingMode != "token" { return BadAuthRequest("模型计费方式仅支持按次、按秒或 Token") } diff --git a/backend/internal/app/channel_presentation.go b/backend/internal/app/channel_presentation.go index 9d5f2b6fc..a4505dc00 100644 --- a/backend/internal/app/channel_presentation.go +++ b/backend/internal/app/channel_presentation.go @@ -1,14 +1,13 @@ package app import ( - "strings" "time" "infinite-canvas/backend/internal/model" ) func (req ChannelRequest) presentationOnly() bool { - return (req.PublicAlias != nil || req.SortOrder != nil) && req.Name == "" && req.BaseURL == "" && req.APIKey == "" && req.SecretKey == "" && req.ConcurrencyLimit == nil && req.UseGlobalConcurrency == nil && req.Models == nil && req.Headers == nil && req.Enabled == nil + return req.SortOrder != nil && req.Name == "" && req.BaseURL == "" && req.APIKey == "" && req.SecretKey == "" && req.ConcurrencyLimit == nil && req.UseGlobalConcurrency == nil && req.Models == nil && req.Headers == nil && req.Enabled == nil } func (s *Service) updateChannelPresentation(id string, req ChannelRequest) (*PublicModelChannel, error) { @@ -17,14 +16,7 @@ func (s *Service) updateChannelPresentation(id string, req ChannelRequest) (*Pub return nil, err } } - if req.PublicAlias != nil { - alias := strings.TrimSpace(*req.PublicAlias) - if len([]rune(alias)) > 80 { - return nil, BadAuthRequest("前台别名不能超过 80 字") - } - req.PublicAlias = &alias - } - if err := s.repo.UpdateSystemChannelPresentation(id, req.PublicAlias, req.SortOrder, time.Now()); err != nil { + if err := s.repo.UpdateSystemChannelPresentation(id, req.SortOrder, time.Now()); err != nil { return nil, err } s.invalidateRouteCatalog() diff --git a/backend/internal/app/channel_presentation_test.go b/backend/internal/app/channel_presentation_test.go index 0efadb477..5a0e58a8a 100644 --- a/backend/internal/app/channel_presentation_test.go +++ b/backend/internal/app/channel_presentation_test.go @@ -10,12 +10,12 @@ import ( "infinite-canvas/backend/internal/model" ) -func TestChannelPresentationAliasAndPublicOrder(t *testing.T) { +func TestChannelPresentationUsesModelLabelsAndPublicOrder(t *testing.T) { svc, db := newChannelModelTestService(t) svc.dataDir = t.TempDir() admin := &model.User{ID: "admin", Role: model.UserRoleAdmin} for i, id := range []string{"first", "second"} { - channel := model.ModelChannel{ID: id, Name: "内部" + id, Scope: model.ChannelScopeSystem, Enabled: true, BaseURL: "https://dead.invalid/v1", ModelsJSON: `[]`, CreatedAt: time.Unix(int64(i+1), 0)} + channel := model.ModelChannel{ID: id, Name: "渠道" + id, PublicAlias: "已废弃前台名称", Scope: model.ChannelScopeSystem, Enabled: true, BaseURL: "https://dead.invalid/v1", ModelsJSON: `[]`, CreatedAt: time.Unix(int64(i+1), 0)} if err := db.Create(&channel).Error; err != nil { t.Fatal(err) } @@ -27,13 +27,12 @@ func TestChannelPresentationAliasAndPublicOrder(t *testing.T) { t.Fatal(err) } } - alias := " 创作精选 " - updated, err := svc.UpdateSystemChannel(admin, "second", ChannelRequest{PublicAlias: &alias}) + updated, err := svc.UpdateSystemChannel(admin, "second", ChannelRequest{SortOrder: intPtr(0)}) if err != nil { t.Fatal(err) } - if updated.Name != "内部second" || updated.PublicAlias != "创作精选" { - t.Fatalf("admin alias: %#v", updated) + if updated.Name != "渠道second" { + t.Fatalf("admin name: %#v", updated) } if _, err := svc.UpdateSystemChannel(admin, "first", ChannelRequest{SortOrder: intPtr(20)}); err != nil { t.Fatal(err) @@ -42,52 +41,47 @@ func TestChannelPresentationAliasAndPublicOrder(t *testing.T) { if err != nil || total != 2 || len(adminRows) != 1 || adminRows[0].ID != "second" { t.Fatalf("pagination before sort: %v %v %v", adminRows, total, err) } - search, _, err := svc.repo.AdminSystemChannels("精选", "all", 10, 0) + search, _, err := svc.repo.AdminSystemChannels("second", "all", 10, 0) if err != nil || len(search) != 1 { - t.Fatalf("alias search: %v %v", search, err) + t.Fatalf("name search: %v %v", search, err) } catalog, err := svc.publicSystemChannelCatalog(nil) if err != nil { t.Fatal(err) } - if len(catalog) != 2 || catalog[0].ID != "second" || catalog[0].Name != "创作精选" || catalog[0].DisplayName != "创作精选" { + if len(catalog) != 2 || catalog[0].ID != "second" || catalog[0].Name != "渠道second" || catalog[0].DisplayName != "渠道second" { t.Fatalf("public catalog: %#v", catalog) } legacy, err := svc.PublicSystemChannels() if err != nil { t.Fatal(err) } - if legacy[0].Name != "创作精选" { + if legacy[0].Name != "渠道second" { t.Fatalf("public session: %#v", legacy) } encoded, _ := json.Marshal([]any{catalog[0], legacy[0]}) - if strings.Contains(string(encoded), "内部second") { - t.Fatal("internal channel name leaked into public payload") + if strings.Contains(string(encoded), "已废弃前台名称") || strings.Contains(string(encoded), "publicAlias") { + t.Fatal("retired alias leaked into public payload") } if _, err := svc.UpdateSystemChannel(admin, "second", ChannelRequest{SortOrder: intPtr(5)}); err != nil { t.Fatal(err) } stored, _ := svc.repo.AdminSystemChannel("second") - if stored.PublicAlias != "创作精选" { - t.Fatal("partial sort patch cleared alias") + if stored.Name != "渠道second" { + t.Fatal("partial sort patch changed channel name") } - alias = " " - if _, err := svc.UpdateSystemChannel(admin, "second", ChannelRequest{PublicAlias: &alias, SortOrder: intPtr(0)}); err != nil { + if _, err := svc.UpdateSystemChannel(admin, "second", ChannelRequest{SortOrder: intPtr(0)}); err != nil { t.Fatal(err) } catalog, err = svc.publicSystemChannelCatalog(nil) - if err != nil || catalog[0].Name != "内部second" { - t.Fatalf("empty alias fallback: %v %v", catalog, err) + if err != nil || catalog[0].Name != "渠道second" { + t.Fatalf("channel name: %v %v", catalog, err) } for _, invalid := range []int{-1, 1000000} { if _, err := svc.UpdateSystemChannel(admin, "second", ChannelRequest{SortOrder: &invalid}); err == nil { t.Fatal("invalid sort accepted") } } - alias = strings.Repeat("字", 81) - if _, err := svc.UpdateSystemChannel(admin, "second", ChannelRequest{PublicAlias: &alias}); err == nil { - t.Fatal("long alias accepted") - } } func TestChannelModelSortPreservesConfigurationAndOwnership(t *testing.T) { diff --git a/backend/internal/app/credit_cost.go b/backend/internal/app/credit_cost.go new file mode 100644 index 000000000..339920617 --- /dev/null +++ b/backend/internal/app/credit_cost.go @@ -0,0 +1,70 @@ +package app + +import ( + "infinite-canvas/backend/internal/kernel" + "infinite-canvas/backend/internal/model" +) + +func validateCreditCostPricing(capability, billingMode string, cost model.CreditCostPricing) error { + for _, price := range []int64{cost.UnitPriceMicrocredits, cost.InputTokenPriceMicrocredits, cost.OutputTokenPriceMicrocredits, cost.CachedTokenPriceMicrocredits} { + if price < 0 || price > 1_000_000_000_000 { + return BadAuthRequest("积分成本价必须在 0 到 1000000 积分之间") + } + } + if !cost.Configured { + return nil + } + if billingMode != "token" { + capability = "" + } + return validateTokenPrices(capability, cost.InputTokenPriceMicrocredits, cost.OutputTokenPriceMicrocredits, cost.CachedTokenPriceMicrocredits) +} + +func snapshotCreditCost(order *model.BillingOrder, tier *model.ChannelModelPriceTier, quantity int64, estimate tokenBillingEstimate) { + if tier == nil { + return + } + order.CostPricing = tier.CostPricing + order.CostBillingMode = tier.BillingMode + order.CostQuantity = 1 + if tier.BillingMode == "per_second" { + order.CostQuantity = quantity + } + if tier.BillingMode == "token" && estimate.Video != nil { + order.CostVideoFormulaTokens = estimate.Video.FormulaTokens + } +} + +// 成本使用下单快照和真实用量,不套用销售倍率或用户扣费上限。 +// 未结算、未配置或无法确定用量时不编造金额,历史订单也不反查当前价格。 +func billingCreditCost(order model.BillingOrder) (*int64, error) { + if !order.CostPricing.Configured || order.Status != model.BillingStatusSettled { + return nil, nil + } + cost := order.CostPricing + var amount int64 + var err error + switch order.CostBillingMode { + case "fixed_request", "per_second": + amount, err = creditAmount(cost.UnitPriceMicrocredits, order.CostQuantity, 10_000) + case "token": + input, output, cached := order.InputTokens, order.OutputTokens, order.CachedTokens + if !order.UsageAvailable { + if order.Capability != "video" || order.CostVideoFormulaTokens <= 0 { + return nil, nil + } + input, cached, output = 0, 0, order.CostVideoFormulaTokens + } + amount, err = kernel.TokenBillingAmount(10_000, + kernel.TokenBillingTerm{Tokens: max(0, input-cached), PriceMicrocredits: cost.InputTokenPriceMicrocredits}, + kernel.TokenBillingTerm{Tokens: output, PriceMicrocredits: cost.OutputTokenPriceMicrocredits}, + kernel.TokenBillingTerm{Tokens: cached, PriceMicrocredits: cost.CachedTokenPriceMicrocredits}, + ) + default: + return nil, nil + } + if err != nil { + return nil, err + } + return &amount, nil +} diff --git a/backend/internal/app/credit_cost_test.go b/backend/internal/app/credit_cost_test.go new file mode 100644 index 000000000..337df93ca --- /dev/null +++ b/backend/internal/app/credit_cost_test.go @@ -0,0 +1,142 @@ +package app + +import ( + "encoding/json" + "strings" + "testing" + + "infinite-canvas/backend/internal/model" +) + +func TestCreditCostSnapshotPersistsWithoutChangingSalesOrPublicResponses(t *testing.T) { + svc, db := newChannelModelTestService(t) + if err := db.AutoMigrate(&model.SystemSetting{}, &model.BillingOrder{}); err != nil { + t.Fatal(err) + } + admin := &model.User{ID: "admin", Role: model.UserRoleAdmin} + channel := model.ModelChannel{ID: "channel", Scope: model.ChannelScopeSystem, Name: "供应渠道", Enabled: true, ModelsJSON: `[]`} + if err := db.Create(&channel).Error; err != nil { + t.Fatal(err) + } + req := ChannelModelRequest{ + ModelKey: "model-a", DisplayName: "产品模型", ChannelLabel: "优惠渠道", Capability: "text", Protocol: string(model.ChannelInterfaceChatCompletion), + CapabilityConfig: DefaultModelCapabilityConfigForModel(string(model.ChannelInterfaceChatCompletion), "model-a"), + PriceTiers: []ChannelModelPriceTierRequest{{BillingMode: "fixed_request", UnitPriceMicrocredits: 900_000, PriceConfigured: true, CostPricing: model.CreditCostPricing{Configured: true, UnitPriceMicrocredits: 123_456}}}, + } + for _, actor := range []*model.User{nil, {ID: "user", Role: model.UserRoleUser}} { + if _, err := svc.SaveAdminChannelModel(actor, channel.ID, "", req); err == nil { + t.Fatal("non-admin wrote cost") + } + if _, err := svc.AdminChannelModels(actor, channel.ID); err == nil { + t.Fatal("non-admin read cost") + } + if _, err := svc.AdminAPICallLogs(actor, APICallLogQuery{}); err == nil { + t.Fatal("non-admin read request cost") + } + } + saved, err := svc.SaveAdminChannelModel(admin, channel.ID, "", req) + if err != nil { + t.Fatal(err) + } + rows, err := svc.AdminChannelModels(admin, channel.ID) + if err != nil || len(rows) != 1 || rows[0].PriceTiers[0].CostPricing.UnitPriceMicrocredits != 123_456 { + t.Fatalf("persisted cost: %#v %v", rows, err) + } + order, err := svc.newBillingOrder("user", "task", "request", channel.ID, saved.ModelKey, "text", "test", 1, tokenBillingEstimate{}) + if err != nil { + t.Fatal(err) + } + if order.AmountMicrocredits != 900_000 || order.CostPricing.UnitPriceMicrocredits != 123_456 { + t.Fatalf("sales/cost snapshot: %#v", order) + } + if err := db.Create(order).Error; err != nil { + t.Fatal(err) + } + req.PriceTiers[0].CostPricing.UnitPriceMicrocredits = 654_321 + if _, err := svc.SaveAdminChannelModel(admin, channel.ID, saved.ID, req); err != nil { + t.Fatal(err) + } + var historical model.BillingOrder + if err := db.First(&historical, "id = ?", order.ID).Error; err != nil { + t.Fatal(err) + } + historical.Status = model.BillingStatusSettled + cost, err := billingCreditCost(historical) + if err != nil || cost == nil || *cost != 123_456 { + t.Fatalf("historical cost changed: %v %v", cost, err) + } + catalog, err := svc.ModelCatalog(nil) + if err != nil { + t.Fatal(err) + } + public := publicChannel(channel, false, []model.ChannelModel{*saved}) + for _, value := range []any{saved, saved.PriceTiers, order, catalog, public, quoteFromOrder("", order, tokenBillingEstimate{})} { + encoded, err := json.Marshal(value) + if err != nil { + t.Fatal(err) + } + for _, forbidden := range []string{"costPricing", "costConfigured", "CostPricing", "123456", "654321"} { + if strings.Contains(string(encoded), forbidden) { + t.Fatalf("cost leaked into %T: %s", value, encoded) + } + } + } + // The catalog must not query logical models, even if their old feature flag is enabled. + if err := db.Create(&model.SystemSetting{Key: featureAvailabilitySettingKey, ValueJSON: `{"frontendModelsEnabled":true}`}).Error; err != nil { + t.Fatal(err) + } + if err := db.Migrator().DropTable(&model.LogicalModel{}); err != nil { + t.Fatal(err) + } + catalog, err = svc.ModelCatalog(nil) + if err != nil || catalog.Source != ModelCatalogSourceSystem || len(catalog.Channels) != 1 { + t.Fatalf("catalog depends on logical models: %#v %v", catalog, err) + } +} + +func TestBillingCreditCostUsesExactUsageAndIgnoresSalesMultiplier(t *testing.T) { + for _, tc := range []struct { + name, mode, capability string + quantity, input, output, cached, formula, want int64 + usage bool + }{ + {name: "request", mode: "fixed_request", quantity: 1, want: 90_000}, + {name: "seconds", mode: "per_second", quantity: 15, want: 1_350_000}, + {name: "text cached tokens", mode: "token", capability: "text", input: 1_000_000, output: 500_000, cached: 200_000, usage: true, want: 1_820_000}, + {name: "video formula", mode: "token", capability: "video", formula: 250_000, want: 500_000}, + {name: "video provider", mode: "token", capability: "video", output: 100_000, formula: 250_000, usage: true, want: 200_000}, + } { + t.Run(tc.name, func(t *testing.T) { + order := model.BillingOrder{Status: model.BillingStatusSettled, Capability: tc.capability, InputTokens: tc.input, OutputTokens: tc.output, CachedTokens: tc.cached, UsageAvailable: tc.usage, MultiplierBasisPoints: 30_000, ChargeLimitMicrocredits: 1, + BillingCostSnapshot: model.BillingCostSnapshot{CostBillingMode: tc.mode, CostQuantity: tc.quantity, CostVideoFormulaTokens: tc.formula, + CostPricing: model.CreditCostPricing{Configured: true, UnitPriceMicrocredits: 90_000, InputTokenPriceMicrocredits: 1_000_000, OutputTokenPriceMicrocredits: 2_000_000, CachedTokenPriceMicrocredits: 100_000}}} + got, err := billingCreditCost(order) + if err != nil || got == nil || *got != tc.want { + t.Fatalf("cost=%v want=%d err=%v", got, tc.want, err) + } + order.CostPricing = model.CreditCostPricing{Configured: true} + got, err = billingCreditCost(order) + if err != nil || got == nil || *got != 0 { + t.Fatal("explicit zero cost must remain available") + } + for _, status := range []model.BillingStatus{model.BillingStatusReserved, model.BillingStatusRunning, model.BillingStatusRefunded, model.BillingStatusUncertain} { + order.Status = status + got, err = billingCreditCost(order) + if err != nil || got != nil { + t.Fatalf("unsettled cost fabricated for %s", status) + } + } + }) + } +} + +func TestCreditCostRejectsInvalidPrices(t *testing.T) { + for _, price := range []int64{-1, 1_000_000_000_001} { + if err := validateCreditCostPricing("text", "fixed_request", model.CreditCostPricing{Configured: true, UnitPriceMicrocredits: price}); err == nil { + t.Fatalf("accepted cost %d", price) + } + } + if err := validateCreditCostPricing("video", "token", model.CreditCostPricing{Configured: true, InputTokenPriceMicrocredits: 1}); err == nil { + t.Fatal("accepted video input cost") + } +} diff --git a/backend/internal/app/finance.go b/backend/internal/app/finance.go index 9a529f3d0..066d9634f 100644 --- a/backend/internal/app/finance.go +++ b/backend/internal/app/finance.go @@ -531,7 +531,7 @@ func (s *Service) newLogicalModelBillingOrder(userID string, task *model.Task, i if logicalModel.BillingMode == "token" && tokenEstimate.Video != nil { videoFormulaTokens = tokenEstimate.Video.FormulaTokens } - return &model.BillingOrder{ + order := &model.BillingOrder{ ID: newID(), UserID: userID, IdempotencyKey: "task:" + task.ID + ":" + newID(), TaskID: task.ID, ChannelID: channelModel.ChannelID, ChannelModelID: channelModel.ID, Model: logicalModel.Code, Capability: capability, Scene: truncateRunes(firstNonEmpty(strings.TrimSpace(task.Operation), task.Type), 80), BillingMode: logicalModel.BillingMode, PriceVersion: int64(revision.Version), @@ -540,7 +540,11 @@ func (s *Service) newLogicalModelBillingOrder(userID string, task *model.Task, i OutputTokenPriceMicrocredits: logicalModel.OutputPriceMicrocredits, CachedTokenPriceMicrocredits: logicalModel.CachedPriceMicrocredits, VideoFormulaTokens: videoFormulaTokens, Status: model.BillingStatusReserved, - }, nil + } + intent := ModelRequestIntentFromTaskInput(input, task.Type, task.Operation) + priceTierID, _ := config["priceTierId"].(string) + snapshotCreditCost(order, channelModelPriceTierForBilling(*channelModel, priceTierID, capability, intent), billingQuantity(capability, config["videoSeconds"]), tokenEstimate) + return order, nil } func (s *Service) ReserveProxyBilling(userID string, channelID string, modelKey string, capability string, scene string, idempotencyKey string, quantity int64) (*model.BillingOrder, error) { @@ -639,7 +643,7 @@ func (s *Service) newBillingOrderWithPriceTier(userID string, taskID string, ide if tier.BillingMode == "token" && tokenEstimate.Video != nil { videoFormulaTokens = tokenEstimate.Video.FormulaTokens } - return &model.BillingOrder{ + order := &model.BillingOrder{ ID: newID(), UserID: userID, IdempotencyKey: idempotencyKey, TaskID: taskID, ChannelID: channelID, ChannelModelID: item.ID, PriceTierID: tier.ID, PriceTierVersion: tier.PriceVersion, PriceSelectorJSON: tier.SelectorJSON, Model: modelKey, Capability: capability, Scene: truncateRunes(scene, 80), BillingMode: tier.BillingMode, PriceVersion: item.PriceVersion, @@ -648,7 +652,9 @@ func (s *Service) newBillingOrderWithPriceTier(userID string, taskID string, ide OutputTokenPriceMicrocredits: tier.OutputTokenPriceMicrocredits, CachedTokenPriceMicrocredits: tier.CachedTokenPriceMicrocredits, VideoFormulaTokens: videoFormulaTokens, Status: model.BillingStatusReserved, - }, nil + } + snapshotCreditCost(order, tier, requestedQuantity, tokenEstimate) + return order, nil } func channelModelPriceTierForBilling(channelModel model.ChannelModel, priceTierID string, capability string, intents ...ModelRequestIntent) *model.ChannelModelPriceTier { diff --git a/backend/internal/app/model_catalog.go b/backend/internal/app/model_catalog.go index 83d6a6612..d9a9b1018 100644 --- a/backend/internal/app/model_catalog.go +++ b/backend/internal/app/model_catalog.go @@ -8,20 +8,16 @@ import ( "infinite-canvas/backend/internal/model" ) -// ModelCatalogSource 决定 ModelCatalogResponse 中哪一个集合具有语义。 -// frontend 与 system 的数据形状互斥,调用方不能把缺失集合解释成空目录。 +// ModelCatalogSource 标识创作目录来自系统渠道模型。 type ModelCatalogSource string const ( - // ModelCatalogSourceFrontend 表示目录由前台逻辑模型组成。 - ModelCatalogSourceFrontend ModelCatalogSource = "frontend" // ModelCatalogSourceSystem 表示目录由脱敏后的系统渠道与渠道模型组成。 ModelCatalogSourceSystem ModelCatalogSource = "system" ) // ModelCatalogResponse 是创作端模型选择的统一读模型。 -// Source=frontend 时读取 Models;Source=system 时读取 Channels。 -// 两个集合都始终序列化为数组:空目录必须发 [],缺字段会被前端判成畸形响应。 +// Channels 为唯一数据源;Models 固定为空,两个集合始终序列化为数组。 type ModelCatalogResponse struct { Source ModelCatalogSource `json:"source"` Models []PublicLogicalModel `json:"models"` @@ -69,26 +65,11 @@ type PublicChannelModelPriceTier struct { CachedTokenPriceMicrocredits int64 `json:"cachedTokenPriceMicrocredits"` } -// ModelCatalog 按功能开关返回互斥的数据形状:frontend 使用 Models,system 使用 Channels。 +// ModelCatalog 的创作端目录始终来自系统渠道模型,不反查逻辑模型或路由。 // 系统渠道目录只负责安全发布可解释的读模型;任务创建仍会用持久化能力与价格档再次强校验。 func (s *Service) ModelCatalog(intent *ModelRequestIntent) (*ModelCatalogResponse, error) { - frontendEnabled, err := s.FeatureEnabled(FeatureFrontendModels) - if err != nil { - return nil, err - } - // 两个集合都初始化成非 nil 空切片:空目录要发 [],不能因为“没有模型”而丢掉字段。 response := &ModelCatalogResponse{Models: []PublicLogicalModel{}, Channels: []PublicChannelCatalog{}} - if frontendEnabled { - models, err := s.PublicLogicalModels(intent) - if err != nil { - return nil, err - } - response.Source = ModelCatalogSourceFrontend - response.Models = append(response.Models, models...) - return response, nil - } - channels, err := s.publicSystemChannelCatalog(intent) if err != nil { return nil, err @@ -147,8 +128,8 @@ func (s *Service) publicSystemChannelCatalog(intent *ModelRequestIntent) ([]Publ if len(publicModels) > 0 { result = append(result, PublicChannelCatalog{ ID: channel.ID, - Name: channel.PublicName(), - DisplayName: channel.PublicName(), + Name: channel.Name, + DisplayName: channel.Name, SortOrder: channel.SortOrder, Models: publicModels, }) diff --git a/backend/internal/app/model_catalog_test.go b/backend/internal/app/model_catalog_test.go index a7fa29f9a..81cbe78e8 100644 --- a/backend/internal/app/model_catalog_test.go +++ b/backend/internal/app/model_catalog_test.go @@ -11,16 +11,14 @@ import ( "gorm.io/gorm" ) -// 前台模型目录为空时也必须发 models: [],不能因为 omitempty 丢掉字段: -// 前端在 source=frontend 时强制校验 models 是数组,缺字段会被判成畸形响应; -// 而 auth-session-hydrator 会把该异常当成未登录并清掉会话,表现为刷新后跳回登录页。 +// 目录始终来自系统渠道;空目录也要显式返回数组,不能被会话初始化判成畸形响应。 func TestModelCatalogAlwaysSerializesCollectionsAsArrays(t *testing.T) { for _, test := range []struct { name string frontendEnabled bool wantSource string }{ - {name: "空的前台模型目录", frontendEnabled: true, wantSource: "frontend"}, + {name: "逻辑模型开关不改变目录来源", frontendEnabled: true, wantSource: "system"}, {name: "空的系统渠道目录", frontendEnabled: false, wantSource: "system"}, } { t.Run(test.name, func(t *testing.T) { diff --git a/backend/internal/app/model_router.go b/backend/internal/app/model_router.go index 02c58116f..a222f56f5 100644 --- a/backend/internal/app/model_router.go +++ b/backend/internal/app/model_router.go @@ -1203,7 +1203,15 @@ func (s *Service) switchTaskToNextRoute(task *model.Task, attempts []model.Route replacement.Model = logicalModel.Code } previousRouteID := task.RouteID - if err := s.repo.SwitchTaskLogicalRoute(task.ID, previousRouteID, selected.Route.ID, string(encoded), task.BillingOrderID, selected.ChannelModel.ChannelID, selected.ChannelModel.ID, replacement); err != nil { + var costOrder model.BillingOrder + if replacement != nil { + costOrder.BillingCostSnapshot = replacement.BillingCostSnapshot + } else if task.BillingOrderID != "" { + config, _ := nextInput["config"].(map[string]any) + capability := selected.ChannelModel.Capability + snapshotCreditCost(&costOrder, channelModelPriceTierForIntent(selected.ChannelModel, intent), billingQuantity(capability, config["videoSeconds"]), estimateTaskBillingTokens(nextInput, capability)) + } + if err := s.repo.SwitchTaskLogicalRoute(task.ID, previousRouteID, selected.Route.ID, string(encoded), task.BillingOrderID, selected.ChannelModel.ChannelID, selected.ChannelModel.ID, replacement, costOrder.BillingCostSnapshot); err != nil { if errors.Is(err, repository.ErrInsufficientCredits) { return nil, BadAuthRequest("模型服务价格发生变化,当前积分余额不足") } diff --git a/backend/internal/database/channel_credit_cost_test.go b/backend/internal/database/channel_credit_cost_test.go new file mode 100644 index 000000000..7ca3808a7 --- /dev/null +++ b/backend/internal/database/channel_credit_cost_test.go @@ -0,0 +1,49 @@ +package database + +import ( + "testing" + + "infinite-canvas/backend/internal/model" +) + +func TestChannelCreditCostMigrationPreservesSalesAndUnknownHistoricalCosts(t *testing.T) { + db, err := Open(Config{Driver: "sqlite", DSN: ":memory:"}) + if err != nil { + t.Fatal(err) + } + for _, sql := range []string{ + `CREATE TABLE channel_model_price_tiers (id TEXT PRIMARY KEY, unit_price_microcredits INTEGER, deleted_at DATETIME)`, + `CREATE TABLE billing_orders (id TEXT PRIMARY KEY, actual_amount_microcredits INTEGER)`, + `INSERT INTO channel_model_price_tiers VALUES ('tier',900000,NULL)`, + `INSERT INTO billing_orders VALUES ('order',13500000)`, + } { + if err := db.Exec(sql).Error; err != nil { + t.Fatal(err) + } + } + for i := 0; i < 2; i++ { + if err := migrateChannelCreditCost(db); err != nil { + t.Fatal(err) + } + } + var tier model.ChannelModelPriceTier + var order model.BillingOrder + if err := db.First(&tier, "id = ?", "tier").Error; err != nil { + t.Fatal(err) + } + if err := db.First(&order, "id = ?", "order").Error; err != nil { + t.Fatal(err) + } + if tier.UnitPriceMicrocredits != 900000 || order.ActualAmountMicrocredits != 13500000 || tier.CostPricing.Configured || order.CostPricing.Configured || order.CostBillingMode != "" { + t.Fatalf("migration changed history: %#v %#v", tier, order) + } + if err := db.Table("channel_model_price_tiers").Where("id = ?", "tier").Updates(map[string]any{"cost_configured": true, "cost_unit_price_microcredits": 123456}).Error; err != nil { + t.Fatal(err) + } + if err := db.First(&tier, "id = ?", "tier").Error; err != nil { + t.Fatal(err) + } + if !tier.CostPricing.Configured || tier.CostPricing.UnitPriceMicrocredits != 123456 { + t.Fatalf("cost columns not mapped: %#v", tier) + } +} diff --git a/backend/internal/database/migrations.go b/backend/internal/database/migrations.go index c2d0cefec..8f91bb050 100644 --- a/backend/internal/database/migrations.go +++ b/backend/internal/database/migrations.go @@ -11,7 +11,7 @@ import ( "gorm.io/gorm" ) -const CurrentSchemaVersion int64 = 26 +const CurrentSchemaVersion int64 = 27 const baselineSchemaChecksum = "sha256:open-ai-canvas-schema-v1-20260830" const schemaMigrationAppliedAtIndexChecksum = "sha256:schema-migrations-applied-at-index-v2-20260830" @@ -92,6 +92,27 @@ var schemaMigrations = []migration{ {version: 24, name: "channel_model_label", checksum: "sha256:channel-model-label-v24", apply: migrateChannelModelLabel}, {version: 25, name: "video_token_formula_snapshot", checksum: "sha256:video-token-formula-snapshot-v25", apply: migrateVideoTokenFormulaSnapshot}, {version: 26, name: "channel_model_description", checksum: "sha256:channel-model-description-v26", apply: migrateChannelModelDescription}, + {version: 27, name: "channel_credit_cost", checksum: "sha256:channel-credit-cost-v27", apply: migrateChannelCreditCost}, +} + +func migrateChannelCreditCost(tx *gorm.DB) error { + for _, entity := range []any{&model.ChannelModelPriceTier{}, &model.BillingOrder{}} { + for _, column := range []string{"cost_configured", "cost_unit_price_microcredits", "cost_input_token_price_microcredits", "cost_output_token_price_microcredits", "cost_cached_token_price_microcredits"} { + if !tx.Migrator().HasColumn(entity, column) { + if err := tx.Migrator().AddColumn(entity, column); err != nil { + return err + } + } + } + } + for _, column := range []string{"CostBillingMode", "CostQuantity", "CostVideoFormulaTokens"} { + if !tx.Migrator().HasColumn(&model.BillingOrder{}, column) { + if err := tx.Migrator().AddColumn(&model.BillingOrder{}, column); err != nil { + return err + } + } + } + return nil } func migrateChannelModelDescription(tx *gorm.DB) error { diff --git a/backend/internal/database/migrations_test.go b/backend/internal/database/migrations_test.go index a375458e8..b06d2c6a8 100644 --- a/backend/internal/database/migrations_test.go +++ b/backend/internal/database/migrations_test.go @@ -446,7 +446,7 @@ func TestMigrateSchemaV4AddsResourceUploadKeyToExistingSchema(t *testing.T) { if err := db.Exec(`CREATE TABLE resources (id TEXT PRIMARY KEY, user_id TEXT NOT NULL)`).Error; err != nil { t.Fatal(err) } - if err := db.AutoMigrate(&model.ModelChannel{}, &model.ChannelModel{}, &model.BillingOrder{}); err != nil { + if err := db.AutoMigrate(&model.ModelChannel{}, &model.ChannelModel{}, &model.ChannelModelPriceTier{}, &model.BillingOrder{}); err != nil { t.Fatal(err) } if err := db.AutoMigrate(&schemaMigration{}); err != nil { @@ -490,7 +490,7 @@ func TestMigrateSchemaRepairsLegacyAssetFoldersMigrationOrder(t *testing.T) { if err != nil { t.Fatal(err) } - if err := db.AutoMigrate(&model.Resource{}, &model.Asset{}, &model.AssetFolder{}, &model.ModelChannel{}, &model.ChannelModel{}, &model.BillingOrder{}); err != nil { + if err := db.AutoMigrate(&model.Resource{}, &model.Asset{}, &model.AssetFolder{}, &model.ModelChannel{}, &model.ChannelModel{}, &model.ChannelModelPriceTier{}, &model.BillingOrder{}); err != nil { t.Fatal(err) } if err := db.AutoMigrate(&schemaMigration{}); err != nil { diff --git a/backend/internal/handler/admin_channel_model_response.go b/backend/internal/handler/admin_channel_model_response.go new file mode 100644 index 000000000..5858e8741 --- /dev/null +++ b/backend/internal/handler/admin_channel_model_response.go @@ -0,0 +1,22 @@ +package handler + +import "infinite-canvas/backend/internal/model" + +// 成本字段只在已通过 service 管理员校验的模型编辑接口中显式投影。 +type adminChannelModelPriceTier struct { + model.ChannelModelPriceTier + CostPricing model.CreditCostPricing `json:"costPricing"` +} + +type adminChannelModelResponse struct { + model.ChannelModel + PriceTiers []adminChannelModelPriceTier `json:"priceTiers"` +} + +func adminChannelModel(item model.ChannelModel) adminChannelModelResponse { + tiers := make([]adminChannelModelPriceTier, 0, len(item.PriceTiers)) + for _, tier := range item.PriceTiers { + tiers = append(tiers, adminChannelModelPriceTier{ChannelModelPriceTier: tier, CostPricing: tier.CostPricing}) + } + return adminChannelModelResponse{ChannelModel: item, PriceTiers: tiers} +} diff --git a/backend/internal/handler/credit_cost_routes_test.go b/backend/internal/handler/credit_cost_routes_test.go new file mode 100644 index 000000000..f08ec9557 --- /dev/null +++ b/backend/internal/handler/credit_cost_routes_test.go @@ -0,0 +1,102 @@ +package handler + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "infinite-canvas/backend/internal/auth" + "infinite-canvas/backend/internal/model" + "infinite-canvas/backend/internal/repository" + "infinite-canvas/backend/internal/service" + + "github.com/gin-gonic/gin" + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +func TestChannelCostHTTPAuthorizationAndPublicProjection(t *testing.T) { + gin.SetMode(gin.TestMode) + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + if err != nil { + t.Fatal(err) + } + sqlDB, _ := db.DB() + sqlDB.SetMaxOpenConns(1) + t.Cleanup(func() { _ = sqlDB.Close() }) + if err := db.AutoMigrate(&model.User{}, &model.AuthSession{}, &model.ModelChannel{}, &model.ChannelModel{}, &model.ChannelModelPriceTier{}, &model.IDSequence{}, &model.SystemSetting{}); err != nil { + t.Fatal(err) + } + for _, role := range []model.UserRole{model.UserRoleAdmin, model.UserRoleUser} { + id := string(role) + if err := db.Create(&model.User{ID: id, Username: id, Email: id + "@example.invalid", Role: role, Status: model.UserStatusActive}).Error; err != nil { + t.Fatal(err) + } + if err := db.Create(&model.AuthSession{ID: id, UserID: id, TokenHash: auth.HashToken("test-token"), ExpiresAt: time.Now().Add(time.Hour)}).Error; err != nil { + t.Fatal(err) + } + } + channel := model.ModelChannel{ID: "channel", Name: "渠道", Scope: model.ChannelScopeSystem, Enabled: true, ModelsJSON: `[]`} + if err := db.Create(&channel).Error; err != nil { + t.Fatal(err) + } + svc := service.New(repository.New(db), t.TempDir()) + router := gin.New() + RegisterFinanceRoutes(router.Group("/api"), svc) + RegisterModelCatalogRoutes(router.Group("/api"), svc) + request := service.ChannelModelRequest{ModelKey: "text-model", DisplayName: "文本模型", ChannelLabel: "优惠渠道", Capability: "text", Protocol: string(model.ChannelInterfaceChatCompletion), CapabilityConfig: service.DefaultModelCapabilityConfigForModel(string(model.ChannelInterfaceChatCompletion), "text-model"), PriceTiers: []service.ChannelModelPriceTierRequest{{BillingMode: "fixed_request", UnitPriceMicrocredits: 900_000, PriceConfigured: true, CostPricing: model.CreditCostPricing{Configured: true, UnitPriceMicrocredits: 123_456}}}} + body, err := json.Marshal(request) + if err != nil { + t.Fatal(err) + } + call := func(role, method, path string, payload []byte) *httptest.ResponseRecorder { + r := httptest.NewRequest(method, path, bytes.NewReader(payload)) + r.Header.Set("Content-Type", "application/json") + if role != "" { + r.AddCookie(&http.Cookie{Name: service.SessionCookieName, Value: role + ".test-token"}) + } + w := httptest.NewRecorder() + router.ServeHTTP(w, r) + return w + } + for _, method := range []string{http.MethodGet, http.MethodPost} { + for _, role := range []string{"", "user"} { + w := call(role, method, "/api/admin/channels/channel/models", body) + want := http.StatusForbidden + if role == "" { + want = http.StatusUnauthorized + } + if w.Code != want { + t.Fatalf("%s %s: %d %s", role, method, w.Code, w.Body.String()) + } + } + } + for _, method := range []string{http.MethodPost, http.MethodGet} { + w := call("admin", method, "/api/admin/channels/channel/models", body) + if w.Code != http.StatusOK || !strings.Contains(w.Body.String(), `"costPricing":`) || !strings.Contains(w.Body.String(), "123456") { + t.Fatalf("admin cost missing: %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"}`) + if path == "/api/model-catalog" { + method = http.MethodGet + payload = nil + } + if strings.HasSuffix(path, "/quote") { + payload = []byte(`{"channelId":"channel","modelKey":"text-model","intent":{"capability":"text","operation":"text_generation","inputs":{"text":1}}}`) + } + w := call("user", method, path, payload) + if w.Code != http.StatusOK { + t.Fatalf("public %s: %d %s", path, w.Code, w.Body.String()) + } + if strings.Contains(w.Body.String(), "costPricing") || strings.Contains(w.Body.String(), "123456") { + t.Fatalf("public cost leaked: %s", w.Body.String()) + } + } +} diff --git a/backend/internal/handler/finance.go b/backend/internal/handler/finance.go index c73a052d2..6829b1636 100644 --- a/backend/internal/handler/finance.go +++ b/backend/internal/handler/finance.go @@ -205,7 +205,11 @@ func RegisterFinanceRoutes(r *gin.RouterGroup, svc *service.Service) { failService(c, err) return } - ok(c, gin.H{"models": items}) + models := make([]adminChannelModelResponse, 0, len(items)) + for _, item := range items { + models = append(models, adminChannelModel(item)) + } + ok(c, gin.H{"models": models}) }) r.POST("/admin/channels/:id/models/fetch", func(c *gin.Context) { user, err := currentUser(c, svc) @@ -501,5 +505,5 @@ func saveChannelModel(c *gin.Context, svc *service.Service, id string) { failService(c, err) return } - ok(c, gin.H{"model": item}) + ok(c, gin.H{"model": adminChannelModel(*item)}) } diff --git a/backend/internal/handler/openapi.yaml b/backend/internal/handler/openapi.yaml index c88886650..aa8881146 100644 --- a/backend/internal/handler/openapi.yaml +++ b/backend/internal/handler/openapi.yaml @@ -31,11 +31,12 @@ paths: tags: [models] summary: 获取创作端模型目录 description: | - source=system 时读取 channels,source=frontend 时读取 models;两个集合始终为数组。 - 系统渠道模型按渠道聚合展示,渠道内模型保持独立选择及价格;模型请求仍使用 channelId + modelKey 定位。 - channelLabel 是后台渠道模型创建和更新时配置的公开渠道展示名,去除首尾空白后最多 80 字,空字符串表示使用渠道公开名称。 + 固定 source=system,channels 直接来自系统渠道下的模型列表,models 为空数组;不反查逻辑模型。 + 一级跨渠道按 displayName 分组,二级显示 channelLabel;模型请求仍使用 channelId + modelKey 定位。 + channelLabel 是后台渠道模型创建和更新时配置的渠道展示名,去除首尾空白后最多 80 字,空字符串表示使用所属渠道名称。 description 是后台渠道模型创建和更新时填写的公开模型描述,去除首尾空白后最多 500 字;空字符串表示未填写。 - displayName 和 icon 用于渠道内模型菜单,channelLabel 不参与路由、权限或计费。 + displayName 和 icon 用于一级模型菜单,channelLabel 不参与路由、权限或计费。 + 渠道级 publicAlias 已废弃;成本配置和成本快照仅供管理端,不包含在本接口及用户报价中。 responses: "200": description: 当前启用的模型目录 @@ -55,7 +56,7 @@ paths: properties: source: type: string - enum: [system, frontend] + enum: [system] models: type: array items: diff --git a/backend/internal/model/credit_cost.go b/backend/internal/model/credit_cost.go new file mode 100644 index 000000000..6b0994807 --- /dev/null +++ b/backend/internal/model/credit_cost.go @@ -0,0 +1,19 @@ +package model + +// CreditCostPricing 仅供管理员配置和内部核算。持有者必须使用 json:"-", +// 管理端通过专用响应投影公开,禁止随用户目录、报价或账单序列化。 +type CreditCostPricing struct { + Configured bool `json:"configured" gorm:"not null;default:false"` + UnitPriceMicrocredits int64 `json:"unitPriceMicrocredits" gorm:"not null;default:0"` + InputTokenPriceMicrocredits int64 `json:"inputTokenPriceMicrocredits" gorm:"not null;default:0"` + OutputTokenPriceMicrocredits int64 `json:"outputTokenPriceMicrocredits" gorm:"not null;default:0"` + CachedTokenPriceMicrocredits int64 `json:"cachedTokenPriceMicrocredits" gorm:"not null;default:0"` +} + +// BillingCostSnapshot 独立于销售价格,切换供应线路时始终更新为实际执行线路的成本。 +type BillingCostSnapshot struct { + CostPricing CreditCostPricing `json:"-" gorm:"embedded;embeddedPrefix:cost_"` + CostBillingMode string `json:"-" gorm:"size:32;not null;default:''"` + CostQuantity int64 `json:"-" gorm:"not null;default:0"` + CostVideoFormulaTokens int64 `json:"-" gorm:"not null;default:0"` +} diff --git a/backend/internal/model/models_channel.go b/backend/internal/model/models_channel.go index ff84157ee..89dac46a0 100644 --- a/backend/internal/model/models_channel.go +++ b/backend/internal/model/models_channel.go @@ -1,26 +1,26 @@ package model import ( - "strings" "time" "gorm.io/gorm" ) type ModelChannel struct { - ID string `json:"id" gorm:"primaryKey;size:36"` - UserID string `json:"userId" gorm:"index;size:36"` - Scope ChannelScope `json:"scope" gorm:"index;size:24"` - Enabled bool `json:"enabled" gorm:"index"` - Name string `json:"name" gorm:"size:80"` - PublicAlias string `json:"publicAlias" gorm:"size:80;not null;default:''"` - SortOrder int `json:"sortOrder" gorm:"not null;default:0"` - BaseURL string `json:"baseUrl"` - APIKey string `json:"-"` - SecretKey string `json:"-"` - APIFormat string `json:"apiFormat" gorm:"size:24"` - ConcurrencyLimit int `json:"concurrencyLimit"` - ModelsJSON string `json:"modelsJson" gorm:"type:text"` + ID string `json:"id" gorm:"primaryKey;size:36"` + UserID string `json:"userId" gorm:"index;size:36"` + Scope ChannelScope `json:"scope" gorm:"index;size:24"` + Enabled bool `json:"enabled" gorm:"index"` + Name string `json:"name" gorm:"size:80"` + // PublicAlias 仅保留历史列供旧迁移读取,运行时不再使用渠道级前台名称。 + PublicAlias string `json:"-" gorm:"size:80;not null;default:''"` + SortOrder int `json:"sortOrder" gorm:"not null;default:0"` + BaseURL string `json:"baseUrl"` + APIKey string `json:"-"` + SecretKey string `json:"-"` + APIFormat string `json:"apiFormat" gorm:"size:24"` + ConcurrencyLimit int `json:"concurrencyLimit"` + ModelsJSON string `json:"modelsJson" gorm:"type:text"` // RetiredModelsJSON 记录已被一个模型家族吸收的上游 SKU,防止目录拉取时重新创建重复记录。 RetiredModelsJSON string `json:"-" gorm:"type:text"` HeadersJSON string `json:"-" gorm:"type:text"` @@ -29,13 +29,6 @@ type ModelChannel struct { DeletedAt gorm.DeletedAt `json:"-" gorm:"index"` } -func (channel ModelChannel) PublicName() string { - if alias := strings.TrimSpace(channel.PublicAlias); alias != "" { - return alias - } - return channel.Name -} - type ChannelModel struct { ID string `json:"id" gorm:"primaryKey;size:36"` ChannelID string `json:"channelId" gorm:"size:36;index;uniqueIndex:idx_channel_model_key_active,priority:1,where:deleted_at IS NULL"` @@ -73,6 +66,7 @@ type ChannelModel struct { // SelectorKey 是规范化 JSON,用于 PostgreSQL 的活动规格唯一约束。旧的 Resolution / VideoSeconds // 仅保留给历史 API 与升级回填,不能再作为 SKU 唯一性依据。 type ChannelModelPriceTier struct { + CostPricing CreditCostPricing `json:"-" gorm:"embedded;embeddedPrefix:cost_"` ID string `json:"id" gorm:"primaryKey;size:36"` ChannelModelID string `json:"channelModelId" gorm:"size:36;index;uniqueIndex:idx_channel_model_price_tier_active,priority:1,where:deleted_at IS NULL"` SelectorKey string `json:"selectorKey" gorm:"size:500;not null;default:{};uniqueIndex:idx_channel_model_price_tier_active,priority:2,where:deleted_at IS NULL"` @@ -95,53 +89,55 @@ type ChannelModelPriceTier struct { } type ApiCallLog struct { - ID string `json:"id" gorm:"primaryKey;size:36"` - UserID string `json:"userId" gorm:"index;size:36;index:idx_api_logs_user_created,priority:1"` - TraceID string `json:"traceId,omitempty" gorm:"index;size:96"` - RequestID string `json:"requestId,omitempty" gorm:"index;size:96"` - UserDisplayName string `json:"userDisplayName,omitempty" gorm:"-"` - UserAccount string `json:"userAccount,omitempty" gorm:"-"` - ChannelID string `json:"channelId" gorm:"index;size:36;index:idx_api_logs_channel_created,priority:1"` - ChannelName string `json:"channelName" gorm:"-"` - TaskID string `json:"taskId,omitempty" gorm:"index;size:36"` - TaskStatus TaskStatus `json:"taskStatus,omitempty" gorm:"-"` - BillingOrderID string `json:"billingOrderId,omitempty" gorm:"index;size:36"` - BillingStatus BillingStatus `json:"billingStatus,omitempty" gorm:"-"` - BillingAmount int64 `json:"billingAmountMicrocredits" gorm:"-"` - BillingAvailable bool `json:"billingAvailable" gorm:"-"` - Source string `json:"source" gorm:"index;size:64"` - Capability string `json:"capability" gorm:"index;size:32"` - Operation string `json:"operation" gorm:"size:64"` - RequestKind string `json:"requestKind" gorm:"index;size:24"` - Billable bool `json:"billable" gorm:"index"` - APIFormat string `json:"apiFormat" gorm:"size:24"` - Method string `json:"method" gorm:"size:16"` - Path string `json:"path"` - Model string `json:"model" gorm:"size:120;index:idx_api_logs_model_created,priority:1"` - Status ApiCallStatus `json:"status" gorm:"index;size:24;index:idx_api_logs_status_created,priority:1"` - StatusCode int `json:"statusCode"` - DurationMs int64 `json:"durationMs"` - PollCount int `json:"pollCount"` - ProviderStatus string `json:"providerStatus,omitempty" gorm:"size:32"` - InputTokens int64 `json:"inputTokens"` - OutputTokens int64 `json:"outputTokens"` - CachedTokens int64 `json:"cachedTokens"` - UsageAvailable bool `json:"usageAvailable"` - MediaCount int `json:"mediaCount"` - MediaPreviewURL string `json:"mediaPreviewUrl,omitempty" gorm:"-"` - MediaPreviewKind string `json:"mediaPreviewKind,omitempty" gorm:"-"` - VideoSeconds int `json:"videoSeconds"` - ProviderRequestID string `json:"providerRequestId" gorm:"size:160"` - EstimatedCostMicros int64 `json:"estimatedCostMicros"` - CostAvailable bool `json:"costAvailable"` - Currency string `json:"currency" gorm:"size:12"` - ErrorCode string `json:"errorCode,omitempty" gorm:"index;size:80"` - Error string `json:"error"` - ConcurrencyLimit int `json:"concurrencyLimit"` - UpstreamURL string `json:"upstreamUrl"` - RequestContentType string `json:"requestContentType,omitempty" gorm:"size:160"` - RequestBody string `json:"requestBody,omitempty" gorm:"type:text"` - ResponseBody string `json:"responseBody,omitempty" gorm:"type:text"` - StartedAt time.Time `json:"startedAt"` - CreatedAt time.Time `json:"createdAt" gorm:"index;index:idx_api_logs_user_created,priority:2;index:idx_api_logs_channel_created,priority:2;index:idx_api_logs_model_created,priority:2;index:idx_api_logs_status_created,priority:2"` + ID string `json:"id" gorm:"primaryKey;size:36"` + UserID string `json:"userId" gorm:"index;size:36;index:idx_api_logs_user_created,priority:1"` + TraceID string `json:"traceId,omitempty" gorm:"index;size:96"` + RequestID string `json:"requestId,omitempty" gorm:"index;size:96"` + UserDisplayName string `json:"userDisplayName,omitempty" gorm:"-"` + UserAccount string `json:"userAccount,omitempty" gorm:"-"` + ChannelID string `json:"channelId" gorm:"index;size:36;index:idx_api_logs_channel_created,priority:1"` + ChannelName string `json:"channelName" gorm:"-"` + TaskID string `json:"taskId,omitempty" gorm:"index;size:36"` + TaskStatus TaskStatus `json:"taskStatus,omitempty" gorm:"-"` + BillingOrderID string `json:"billingOrderId,omitempty" gorm:"index;size:36"` + BillingStatus BillingStatus `json:"billingStatus,omitempty" gorm:"-"` + BillingAmount int64 `json:"billingAmountMicrocredits" gorm:"-"` + BillingAvailable bool `json:"billingAvailable" gorm:"-"` + CreditCostConfigured bool `json:"creditCostConfigured" gorm:"-"` + CreditCostMicrocredits *int64 `json:"creditCostMicrocredits,omitempty" gorm:"-"` + Source string `json:"source" gorm:"index;size:64"` + Capability string `json:"capability" gorm:"index;size:32"` + Operation string `json:"operation" gorm:"size:64"` + RequestKind string `json:"requestKind" gorm:"index;size:24"` + Billable bool `json:"billable" gorm:"index"` + APIFormat string `json:"apiFormat" gorm:"size:24"` + Method string `json:"method" gorm:"size:16"` + Path string `json:"path"` + Model string `json:"model" gorm:"size:120;index:idx_api_logs_model_created,priority:1"` + Status ApiCallStatus `json:"status" gorm:"index;size:24;index:idx_api_logs_status_created,priority:1"` + StatusCode int `json:"statusCode"` + DurationMs int64 `json:"durationMs"` + PollCount int `json:"pollCount"` + ProviderStatus string `json:"providerStatus,omitempty" gorm:"size:32"` + InputTokens int64 `json:"inputTokens"` + OutputTokens int64 `json:"outputTokens"` + CachedTokens int64 `json:"cachedTokens"` + UsageAvailable bool `json:"usageAvailable"` + MediaCount int `json:"mediaCount"` + MediaPreviewURL string `json:"mediaPreviewUrl,omitempty" gorm:"-"` + MediaPreviewKind string `json:"mediaPreviewKind,omitempty" gorm:"-"` + VideoSeconds int `json:"videoSeconds"` + ProviderRequestID string `json:"providerRequestId" gorm:"size:160"` + EstimatedCostMicros int64 `json:"estimatedCostMicros"` + CostAvailable bool `json:"costAvailable"` + Currency string `json:"currency" gorm:"size:12"` + ErrorCode string `json:"errorCode,omitempty" gorm:"index;size:80"` + Error string `json:"error"` + ConcurrencyLimit int `json:"concurrencyLimit"` + UpstreamURL string `json:"upstreamUrl"` + RequestContentType string `json:"requestContentType,omitempty" gorm:"size:160"` + RequestBody string `json:"requestBody,omitempty" gorm:"type:text"` + ResponseBody string `json:"responseBody,omitempty" gorm:"type:text"` + StartedAt time.Time `json:"startedAt"` + CreatedAt time.Time `json:"createdAt" gorm:"index;index:idx_api_logs_user_created,priority:2;index:idx_api_logs_channel_created,priority:2;index:idx_api_logs_model_created,priority:2;index:idx_api_logs_status_created,priority:2"` } diff --git a/backend/internal/model/models_finance.go b/backend/internal/model/models_finance.go index e85e6be18..801f0bcab 100644 --- a/backend/internal/model/models_finance.go +++ b/backend/internal/model/models_finance.go @@ -33,12 +33,13 @@ type CreditLedgerEntry struct { } type BillingOrder struct { - ID string `json:"id" gorm:"primaryKey;size:36"` - UserID string `json:"userId" gorm:"size:36;index;uniqueIndex:idx_billing_user_idempotency,priority:1"` - IdempotencyKey string `json:"idempotencyKey" gorm:"size:160;uniqueIndex:idx_billing_user_idempotency,priority:2"` - TaskID string `json:"taskId,omitempty" gorm:"index;size:36"` - ChannelID string `json:"channelId" gorm:"index;size:36"` - ChannelModelID string `json:"channelModelId" gorm:"index;size:36"` + BillingCostSnapshot `json:"-" gorm:"embedded"` + ID string `json:"id" gorm:"primaryKey;size:36"` + UserID string `json:"userId" gorm:"size:36;index;uniqueIndex:idx_billing_user_idempotency,priority:1"` + IdempotencyKey string `json:"idempotencyKey" gorm:"size:160;uniqueIndex:idx_billing_user_idempotency,priority:2"` + TaskID string `json:"taskId,omitempty" gorm:"index;size:36"` + ChannelID string `json:"channelId" gorm:"index;size:36"` + ChannelModelID string `json:"channelModelId" gorm:"index;size:36"` // PriceTierID/Version 记录任务实际命中的规格档;金额字段仍是不可变结算快照。 PriceTierID string `json:"priceTierId,omitempty" gorm:"index;size:36"` PriceTierVersion int64 `json:"priceTierVersion"` diff --git a/backend/internal/repository/channel_presentation.go b/backend/internal/repository/channel_presentation.go index 957dbe7e4..974b70a9e 100644 --- a/backend/internal/repository/channel_presentation.go +++ b/backend/internal/repository/channel_presentation.go @@ -8,11 +8,8 @@ import ( "gorm.io/gorm" ) -func (r *Repository) UpdateSystemChannelPresentation(channelID string, alias *string, sortOrder *int, now time.Time) error { +func (r *Repository) UpdateSystemChannelPresentation(channelID string, sortOrder *int, now time.Time) error { updates := map[string]any{"updated_at": now} - if alias != nil { - updates["public_alias"] = *alias - } if sortOrder != nil { updates["sort_order"] = *sortOrder } diff --git a/backend/internal/repository/logical_models.go b/backend/internal/repository/logical_models.go index 38353c860..f2955e635 100644 --- a/backend/internal/repository/logical_models.go +++ b/backend/internal/repository/logical_models.go @@ -278,7 +278,7 @@ func (r *Repository) RouteAttempts(taskID string, routeRun int) ([]model.RouteAt // SwitchTaskLogicalRoute 同时更新任务执行目标和账单。跟随供应价格时会原子调整预留积分, // 保证明确未创建上游任务后的故障切线不会沿用上一条线路的价格快照。 -func (r *Repository) SwitchTaskLogicalRoute(taskID string, expectedRouteID string, routeID string, inputJSON string, billingOrderID string, channelID string, channelModelID string, replacement *model.BillingOrder) error { +func (r *Repository) SwitchTaskLogicalRoute(taskID string, expectedRouteID string, routeID string, inputJSON string, billingOrderID string, channelID string, channelModelID string, replacement *model.BillingOrder, cost model.BillingCostSnapshot) error { return r.db.Transaction(func(tx *gorm.DB) error { updated := tx.Model(&model.Task{}). Where("id = ? AND status = ? AND route_id = ?", taskID, model.TaskStatusRunning, expectedRouteID). @@ -298,6 +298,14 @@ func (r *Repository) SwitchTaskLogicalRoute(taskID string, expectedRouteID strin } now := time.Now() updates := map[string]any{"channel_id": channelID, "channel_model_id": channelModelID, "updated_at": now} + updates["cost_configured"] = cost.CostPricing.Configured + updates["cost_unit_price_microcredits"] = cost.CostPricing.UnitPriceMicrocredits + updates["cost_input_token_price_microcredits"] = cost.CostPricing.InputTokenPriceMicrocredits + updates["cost_output_token_price_microcredits"] = cost.CostPricing.OutputTokenPriceMicrocredits + updates["cost_cached_token_price_microcredits"] = cost.CostPricing.CachedTokenPriceMicrocredits + updates["cost_billing_mode"] = cost.CostBillingMode + updates["cost_quantity"] = cost.CostQuantity + updates["cost_video_formula_tokens"] = cost.CostVideoFormulaTokens if replacement != nil { if replacement.UserID != order.UserID || replacement.TaskID != taskID || replacement.AmountMicrocredits <= 0 { return ErrBillingStateConflict diff --git a/backend/internal/repository/logical_models_test.go b/backend/internal/repository/logical_models_test.go index 26b61f011..07f3fffa8 100644 --- a/backend/internal/repository/logical_models_test.go +++ b/backend/internal/repository/logical_models_test.go @@ -11,6 +11,47 @@ import ( "gorm.io/gorm" ) +func TestSwitchTaskLogicalRouteUpdatesCostWithoutChangingUnifiedSales(t *testing.T) { + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + if err != nil { + t.Fatal(err) + } + if err := db.AutoMigrate(&model.Task{}, &model.BillingOrder{}); err != nil { + t.Fatal(err) + } + task := model.Task{ID: "task", UserID: "user", Status: model.TaskStatusRunning, RouteID: "old-route"} + order := model.BillingOrder{ID: "order", TaskID: task.ID, UserID: task.UserID, Status: model.BillingStatusRunning, ChannelID: "old-channel", AmountMicrocredits: 900_000, ReservedAmountMicrocredits: 900_000, UnitPriceMicrocredits: 900_000, + BillingCostSnapshot: model.BillingCostSnapshot{CostPricing: model.CreditCostPricing{Configured: true, UnitPriceMicrocredits: 500_000}, CostBillingMode: "fixed_request", CostQuantity: 1}} + if err := db.Create(&task).Error; err != nil { + t.Fatal(err) + } + if err := db.Create(&order).Error; err != nil { + t.Fatal(err) + } + repo := New(db) + cost := model.BillingCostSnapshot{CostPricing: model.CreditCostPricing{Configured: true, UnitPriceMicrocredits: 123_456}, CostBillingMode: "per_second", CostQuantity: 15} + if err := repo.SwitchTaskLogicalRoute(task.ID, "old-route", "new-route", "{}", order.ID, "new-channel", "new-model", nil, cost); err != nil { + t.Fatal(err) + } + var saved model.BillingOrder + if err := db.First(&saved, "id = ?", order.ID).Error; err != nil { + t.Fatal(err) + } + if saved.BillingCostSnapshot != cost || saved.ChannelID != "new-channel" || saved.AmountMicrocredits != 900_000 || saved.ReservedAmountMicrocredits != 900_000 || saved.UnitPriceMicrocredits != 900_000 { + t.Fatalf("wrong route snapshot: %#v", saved) + } + // A route without configured costs must clear the previous route's snapshot. + if err := repo.SwitchTaskLogicalRoute(task.ID, "new-route", "unknown-cost-route", "{}", order.ID, "third-channel", "third-model", nil, model.BillingCostSnapshot{}); err != nil { + t.Fatal(err) + } + if err := db.First(&saved, "id = ?", order.ID).Error; err != nil { + t.Fatal(err) + } + if saved.BillingCostSnapshot != (model.BillingCostSnapshot{}) || saved.AmountMicrocredits != 900_000 { + t.Fatalf("stale cost after route switch: %#v", saved) + } +} + func TestSaveLogicalModelBundleAllocatesMonotonicRevisionSequence(t *testing.T) { db, err := gorm.Open(sqlite.Open("file:logical-model-revision-sequence?mode=memory&cache=shared"), &gorm.Config{}) if err != nil { diff --git a/backend/internal/repository/repository.go b/backend/internal/repository/repository.go index 096cb3f0a..200408307 100644 --- a/backend/internal/repository/repository.go +++ b/backend/internal/repository/repository.go @@ -708,7 +708,7 @@ func (r *Repository) AdminSystemChannels(keyword string, status string, limit in query := r.db.Model(&model.ModelChannel{}).Where("scope = ?", model.ChannelScopeSystem) if value := strings.TrimSpace(keyword); value != "" { pattern := "%" + strings.ToLower(value) + "%" - query = query.Where("lower(name) LIKE ? OR lower(public_alias) LIKE ? OR lower(base_url) LIKE ?", pattern, pattern, pattern) + query = query.Where("lower(name) LIKE ? OR lower(base_url) LIKE ?", pattern, pattern) } if status == "enabled" { query = query.Where("enabled = ?", true) diff --git a/backend/internal/service/aliases_consts.go b/backend/internal/service/aliases_consts.go index 042451754..211666984 100644 --- a/backend/internal/service/aliases_consts.go +++ b/backend/internal/service/aliases_consts.go @@ -43,7 +43,6 @@ const ( FeatureSystemPlugins = app.FeatureSystemPlugins FeatureTaskCenter = app.FeatureTaskCenter FeatureTimelineTranscription = app.FeatureTimelineTranscription - ModelCatalogSourceFrontend = app.ModelCatalogSourceFrontend ModelCatalogSourceSystem = app.ModelCatalogSourceSystem PaymentPluginAlipayPage = app.PaymentPluginAlipayPage PaymentPluginWeChatNative = app.PaymentPluginWeChatNative diff --git a/docs/content/docs/backend/backend-database.mdx b/docs/content/docs/backend/backend-database.mdx index 8fe262d8c..fbddaa34b 100644 --- a/docs/content/docs/backend/backend-database.mdx +++ b/docs/content/docs/backend/backend-database.mdx @@ -23,7 +23,7 @@ description: 影策后端核心持久化表与状态边界 容器内命令 `migrate-schema up` 应用缺失迁移,`migrate-schema status` 输出当前和期望版本,`migrate-schema verify` 只检查兼容性。`CANVAS_AUTO_MIGRATE=true` 仅用于开发和旧部署兼容;受管生产部署设置为 `false`,并由 Compose 的一次性 `migrate` 服务先完成迁移。后续迁移必须新增版本,不得修改已经发布迁移的实现或校验和。 -当前结构版本为 26。版本 2 为 `schema_migrations.applied_at` 增加 `idx_schema_migrations_applied_at` 索引;版本 3 `asset_taxonomy_candidate_identity` 为资产候选增加规范化名称和来源,收敛资产分类,并为待确认候选建立项目内部分唯一索引;版本 4 `resource_upload_key` 为资源上传幂等补充 `resources.upload_key` 列和用户维度唯一索引;版本 5 `payment_topup` 创建积分充值、支付通知和对账所需结构;版本 6 `resource_playback_variant` 增加资源播放副本状态;版本 7 `asset_library_folders` 增加用户素材自定义分类及 `assets.folder_id`;版本 8 `logical_model_active_code` 将前台模型 code 的唯一约束限定为未归档记录;版本 9 `channel_presentation` 为渠道添加 `public_alias`(默认空字符串)与 `sort_order`(默认 0),为渠道模型添加 `sort_order`(默认 0),不重写既有凭据、价格或能力配置;版本 10 `creation_runtime` 创建创作运行与提交表,并为任务增加可空且唯一的 `creation_submission_id`,旧任务无需伪造提交标识即可继续使用;版本 11 `cloud_agent_runtime` 创建 `cloud_agent_executions`;版本 12 `agent_token_charge_limit` 为账单增加 Agent Token 收费上限;版本 13 `cloud_agent_canvas_mutation` 持久化 Agent 画布变更;版本 14 `cloud_agent_recovery_control` 增加运行恢复控制字段和旧运行回填;版本 15 `agent_profiles` 创建常驻偏好表;版本 16 `agent_lessons` 创建用户记忆表(含分类、路线 JSON、注入/查阅计数);版本 17 `agent_lessons_owner_index` 为 `(author_user_id, status)` 增加复合索引;版本 18 `agent_memory_settings` 创建按用户隔离的压缩周期与最近一次压缩任务状态;版本 19 `payment_plugin_version` 为已有 `payment_provider_configs` 和 `payment_orders` 补上 `plugin_version`,用于把配置和订单钉在具体插件版本上;版本 20 `banner_announcements` 创建首页常驻滚动通知表;版本 21 `banner_announcement_title_runs` 为通知标题增加样式分段;版本 22 `banner_announcement_notice_type` 增加通知类型底色;版本 23 `canvas_revision_history` 为既有画布回填 `revision = 1` 并新增历史快照与资源引用表。版本 24 `channel_model_label` 为渠道模型增加 `channel_label`(最长 80 字、非空、默认空字符串),不重写模型标识、上游 ID、价格或能力;版本 25 `video_token_formula_snapshot` 为账单增加视频公式用量及用量来源快照。版本 26 `channel_model_description` 为渠道模型增加 `description`(最长 500 字、非空、默认空字符串),供二级选项展示使用说明,不重写身份或价格。已发布版本的实现和校验和不得修改。 +当前结构版本为 27。版本 2 为 `schema_migrations.applied_at` 增加 `idx_schema_migrations_applied_at` 索引;版本 3 `asset_taxonomy_candidate_identity` 为资产候选增加规范化名称和来源,收敛资产分类,并为待确认候选建立项目内部分唯一索引;版本 4 `resource_upload_key` 为资源上传幂等补充 `resources.upload_key` 列和用户维度唯一索引;版本 5 `payment_topup` 创建积分充值、支付通知和对账所需结构;版本 6 `resource_playback_variant` 增加资源播放副本状态;版本 7 `asset_library_folders` 增加用户素材自定义分类及 `assets.folder_id`;版本 8 `logical_model_active_code` 将前台模型 code 的唯一约束限定为未归档记录;版本 9 `channel_presentation` 为渠道添加 `public_alias`(默认空字符串)与 `sort_order`(默认 0),为渠道模型添加 `sort_order`(默认 0),不重写既有凭据、价格或能力配置;版本 10 `creation_runtime` 创建创作运行与提交表,并为任务增加可空且唯一的 `creation_submission_id`,旧任务无需伪造提交标识即可继续使用;版本 11 `cloud_agent_runtime` 创建 `cloud_agent_executions`;版本 12 `agent_token_charge_limit` 为账单增加 Agent Token 收费上限;版本 13 `cloud_agent_canvas_mutation` 持久化 Agent 画布变更;版本 14 `cloud_agent_recovery_control` 增加运行恢复控制字段和旧运行回填;版本 15 `agent_profiles` 创建常驻偏好表;版本 16 `agent_lessons` 创建用户记忆表(含分类、路线 JSON、注入/查阅计数);版本 17 `agent_lessons_owner_index` 为 `(author_user_id, status)` 增加复合索引;版本 18 `agent_memory_settings` 创建按用户隔离的压缩周期与最近一次压缩任务状态;版本 19 `payment_plugin_version` 为已有 `payment_provider_configs` 和 `payment_orders` 补上 `plugin_version`,用于把配置和订单钉在具体插件版本上;版本 20 `banner_announcements` 创建首页常驻滚动通知表;版本 21 `banner_announcement_title_runs` 为通知标题增加样式分段;版本 22 `banner_announcement_notice_type` 增加通知类型底色;版本 23 `canvas_revision_history` 为既有画布回填 `revision = 1` 并新增历史快照与资源引用表。版本 24 `channel_model_label` 为渠道模型增加 `channel_label`(最长 80 字、非空、默认空字符串),不重写模型标识、上游 ID、价格或能力;版本 25 `video_token_formula_snapshot` 为账单增加视频公式用量及用量来源快照。版本 26 `channel_model_description` 为渠道模型增加 `description`(最长 500 字、非空、默认空字符串),供二级选项展示使用说明,不重写身份或价格。版本 27 `channel_credit_cost` 为价格档添加管理员积分成本,并在账单保存执行线路的成本快照;成本不进入普通用户 API。已发布版本的实现和校验和不得修改。 历史发布曾使用版本 6 `asset_library_folders`,之后合并引入了上述 6/7 顺序冲突。迁移器仅在该历史记录的名称和校验和均精确匹配时选择历史顺序:保留版本 6 的素材目录记录及时间,在版本 7 应用 `resource_playback_variant`,再继续版本 8。新数据库及已采用“6 播放副本、7 素材目录”的数据库保持原顺序;状态检查与启动校验采用同一套识别规则。未知名称、被改动的校验和或冲突的版本 7 记录仍会拒绝迁移,不通过删除、改名或伪造已应用记录绕过检查。 @@ -188,10 +188,14 @@ docker compose -f docker-compose.deploy.yml up -d backend web | 表 | 用途 | 关键边界 | | --- | --- | --- | -| `model_channels` | 保存系统渠道与用户渠道的连接配置,以及系统渠道前台别名和排序 | 系统渠道密钥不进入公开目录;公开名称优先使用 `public_alias`,为空时使用原名;按 `sort_order, created_at, id` 升序展示 | -| `channel_models` | 保存系统渠道中的产品模型标识、上游模型 ID、显示名称、渠道展示名、模型描述、模型 Logo、排序、能力、协议和版本状态 | `channel_label` 最长 80 字,空值沿用渠道公开名称;`description` 最长 500 字、非空、默认空字符串,用于二级菜单说明;同一渠道内的产品模型标识仍唯一,跨渠道同能力同标识可在前端归组;模型排序独立保存,不递增价格或能力版本 | +| `model_channels` | 保存系统渠道与用户渠道的连接配置和排序 | 系统渠道密钥不进入公开目录;废弃渠道级前台名称,历史 `public_alias` 列保留但不再用于取名或 API 返回;按 `sort_order, created_at, id` 升序展示 | +| `channel_models` | 保存系统渠道中的调用标识、上游模型 ID、模型展示名、渠道展示名、描述、Logo、排序、能力、协议和版本状态 | 创作目录跨渠道按 `display_name` 分组一级,`channel_label` 为二级、最长 80 字、空值使用所属渠道名称;`description` 最长 500 字、非空、默认空字符串;同一渠道内调用标识唯一,跨渠道归组不依赖调用标识;模型排序不递增价格或能力版本 | | `channel_model_price_tiers` | 保存系统模型按规格匹配的上游 SKU 与价格 | 价格档与渠道模型同事务保存;未启用或未完成定价的规格不进入普通用户目录。档位 `provider_model_key` 非空时优先于渠道模型上游键发往供应商;管理端修改模型级上游 ID 时,与旧值相同的档位键自动级联跟随,其余显式 SKU 保持不变 | -| `logical_models` | 保存前台模型的公开名称、Logo、能力类型和归档状态 | 前台模型与系统渠道模型分别维护展示信息;供应线路通过 revision 和 route 关联;`code` 仅在 `archived_at IS NULL` 的活动记录中唯一,归档后可用同 code 重建目录项 | +| `logical_models` | 保存逻辑模型的名称、Logo、能力类型和归档状态 | 不再作为创作端模型选择目录的数据源;既有任务与供应线路仍通过 revision 和 route 关联;`code` 仅在 `archived_at IS NULL` 的活动记录中唯一 | + +schema v27 为 `channel_model_price_tiers` 和 `billing_orders` 添加 `cost_configured`(非空、默认 false)及 `cost_unit_price_microcredits`、`cost_input_token_price_microcredits`、`cost_output_token_price_microcredits`、`cost_cached_token_price_microcredits`(非空整数、默认 0)。账单另外保存 `cost_billing_mode`(默认空字符串)、`cost_quantity`、`cost_video_formula_tokens`(默认 0),构成下单时不可变成本快照。请求日志成本从内部账单快照计算,非持久字段 `creditCostMicrocredits` 仅供管理端投影;旧订单不反查新价格,不回填假零成本。已有售价、余额和结算金额不变。 + +部署需前后端与 schema v27 一起升级;关闭自动迁移时执行 `migrate-schema up`、`migrate-schema verify`。迁移只新增列,不删除旧数据,可重复校验;没有降级迁移,回滚使用匹配的数据库备份和程序版本,旧程序不记录成本快照。 所有视频协议开放 Token 定价。`channel_model_price_tiers.selector_json` 支持 `videoGenerateAudio: "true" | "false"`,省略表示任意音频;参考视频仍匹配 `operation: "video_to_video"`。视频仅使用输出 Token 单价,输入和缓存价必须为 0,明确配置的零价表示免费。schema v25 为 `billing_orders` 新增 `video_formula_tokens`(非空、默认 0,提交时固化不含余量的公式用量)及 `usage_source`(非空、默认空字符串,`provider` 或 `video_formula`)。缺少有效上游用量的新视频订单按公式快照结算并记录 `usage_available=false`,不伪造上游用量;历史快照为 0 的订单保持旧规则。价格档、费率、倍率、预留金额和既有账单不被迁移重算。前后端和 schema 一起升级,旧程序不支持新公式结算及非 Ark Token 配置,回滚需匹配数据库备份与二进制版本。完整算法见[视频 Token 计费](/docs/backend/video-token-billing)。 diff --git a/docs/content/docs/backend/http-api.mdx b/docs/content/docs/backend/http-api.mdx index d58ca0b55..5a252536a 100644 --- a/docs/content/docs/backend/http-api.mdx +++ b/docs/content/docs/backend/http-api.mdx @@ -15,13 +15,13 @@ description: 信封、错误码与 OpenAPI ## 模型规格报价 -`GET /model-catalog` 在 `source=system` 时返回 `channels[].models[]`。每项的 `modelKey` 是产品模型标识,`displayName` 是产品展示名,`icon` 是模型图标键,`channelLabel` 是可选的渠道展示名(默认空字符串)。后台渠道模型创建与更新请求使用相同的 `channelLabel` 字段;服务端去除首尾空白并限制为 80 字,空字符串表示恢复使用渠道公开名称。 +`GET /model-catalog` 固定返回 `source=system`,直接查询所有启用系统渠道下的渠道模型,目录数据在 `channels[].models[]`,`models` 固定为空数组。不受逻辑模型开关影响,不反查逻辑模型或供应线路。每项的 `modelKey` 是调用标识,`displayName` 是模型展示名,`icon` 是模型图标键,`channelLabel` 是可选的渠道展示名(默认空字符串)。后台渠道模型创建与更新请求使用相同的 `channelLabel` 字段;服务端去除首尾空白并限制为 80 字,空字符串表示使用所属渠道名称。 渠道模型创建、更新、管理列表、公开目录和会话模型价格信息均使用 `description` 表达模型描述。服务端去除首尾空白后限制为 500 字,允许空字符串清除描述;无管理员权限或超长内容拒绝保存。创作端二级选项在悬浮、聚焦或选中时展示该字段,长内容可悬浮查看完整文本;未填写的系统模型不按名称生成宣传文案。描述区域保留固定行高,悬浮不改变菜单项尺寸。 -创作端系统渠道按渠道聚合一级菜单,二级菜单保留该渠道内各模型的独立选择;选中值继续使用 `channelId::modelKey`,报价和生成请求保留该渠道身份。展示名、渠道展示名和 Logo 不参与路由、权限及计费。`source=frontend` 的逻辑模型目录继续按原逻辑模型身份工作,不将逻辑模型的供应线路公开成可选渠道。 +创作端跨渠道按模型 `displayName` 分组为一级菜单,二级菜单使用每条模型的 `channelLabel`,例如 `MiniMax H3 → 秘塔(满血渠道)`。选中值继续使用 `channelId::modelKey`,报价和生成请求保留该渠道身份。展示字段不参与路由、权限及计费。系统渠道的“前台名称/前台显示别名”已移除,管理 API 不再接受或返回 `publicAlias`。 -同一产品在不同渠道中的 `modelKey` 应保持一致,名称与 Logo 也应统一;不同产品版本使用不同标识。已有模型标识不会自动改写。一级顺序按后台渠道和模型排序首次出现的位置确定,二级保持后台渠道顺序;若历史配置的展示名或 Logo 不一致,以稳定渠道身份排序选取元数据,不随当前选择和渠道排序跳变。 +同一产品在不同渠道中使用相同的模型展示名即可归组,`modelKey` 可以不同;相同 `modelKey` 但展示名不同会分为不同组。一级顺序与图标按后台渠道和模型排序首次出现的位置确定,二级保持后台渠道与模型顺序。即使二级展示名相同,也保留独立选项及各自能力、价格,不自动改写历史配置或交换两个展示字段。 以下接口需要登录 Cookie,返回 `data.quote`,仅校验和报价,不创建任务、账单或冻结余额,不调用供应商生成接口。 @@ -36,6 +36,14 @@ description: 信封、错误码与 OpenAPI Token 报价的 `estimated` 为 `true`,`quantity` 是预留 Token 数。参考素材数量不包含时长信息,带参考视频时统一按 15 秒估算并返回 `referenceDurationEstimated: true`。所有视频协议均支持 Token 定价;10% 预留是平台策略,不是费用上限。成功后优先有效上游用量,无有效用量时按提交的公式快照结算,余量不计入公式费用。账单新增 `videoFormulaTokens` 及 `usageSource`(`provider` / `video_formula`),公式结算的 `usageAvailable` 为 `false`,`outputTokens` 表示公式数量。算法、配置与异常用量处理见[视频 Token 计费](/docs/backend/video-token-billing)。 +## 管理员积分成本 + +`GET/POST /admin/channels/:id/models` 与 `PATCH /admin/channels/:id/models/:modelId` 的 `priceTiers[]` 增加 `costPricing`:`configured`、`unitPriceMicrocredits`、`inputTokenPriceMicrocredits`、`outputTokenPriceMicrocredits`、`cachedTokenPriceMicrocredits`。成本沿用该价格档的计费方式;按次/秒使用单位价,文本 Token 按输入/输出/缓存分别定价,视频 Token 只使用输出价。价格为整数微积分,范围为 0 到 1000000000000;`configured=false` 表示未配置,`true` 且价格为 0 表示显式零成本。 + +成本保存到下单时的内部账单快照,不受售价倍率或扣费上限影响,后续改价不重算旧订单。历史逻辑模型任务切换备用线路时,成本更新为实际执行线路的快照,独立于用户售价策略。管理员请求列表、详情与 CSV 同时展示销售金额及成本金额;JSON 增加 `creditCostConfigured` 和可选 `creditCostMicrocredits`。成功结算后按实际计费数量、Token 用量或视频公式快照核算;未结算、已退回、历史无快照或缺少有效用量时不编造成本金额。 + +成本字段仅通过经过管理员权限校验的接口显式返回;普通用户的模型目录、会话模型信息、报价、任务与账单 JSON 均不包含成本配置、成本快照或成本金额。底层价格档和账单成本字段禁止 JSON 序列化,不以页面隐藏代替接口隔离。 + ## 画布保存、历史与恢复 画布内容使用数据库递增的 `revision` 做乐观并发校验;`updatedAt` 仅用于展示,不作为版本依据。新建 `PUT /canvas-projects/:id` 的 `project.revision` 为 0,已有画布必须带上读取到的版本。数据库在同一次条件更新中匹配 ID、用户与版本,成功后返回 `data.project` 摘要及递增后的版本;缺失版本返回 428,陈旧版本返回 409,非法版本返回 400。客户端不能获取新版号后直接重发旧内容。 diff --git a/docs/content/docs/overview/features.mdx b/docs/content/docs/overview/features.mdx index fdce94381..e1d1704b4 100644 --- a/docs/content/docs/overview/features.mdx +++ b/docs/content/docs/overview/features.mdx @@ -93,7 +93,9 @@ description: 当前项目已实现的核心工作台能力与使用入口 ## 系统渠道排序与前台别名 -系统渠道模型选择器按产品模型分组:一级显示模型展示名和原色 Logo,二级显示各渠道的展示名及规格价格。管理员在渠道模型的“基本信息”中配置“渠道展示名”(例如“优惠渠道-993”);留空沿用渠道公开名称。同一能力下相同的产品模型标识跨渠道归为一组,调用仍保留用户选中的具体渠道。收起后显示“模型 · 渠道”,不因另一渠道更便宜而自动切换。图标优先使用图标库的原色版本,缺少彩色版本时使用单色版本;缺少配置时显示通用图标,不猜测产品品牌。 +系统渠道模型选择器直接读取渠道下的模型列表:一级跨渠道按“模型展示名”分组并显示原色 Logo,二级显示该模型的“渠道展示名”及规格售价;留空使用所属渠道名称。模型调用标识不必相同,也不反查逻辑模型。系统渠道的“前台名称”已移除。调用保留用户选中的具体渠道,收起后显示“模型 · 渠道”,不因另一渠道更便宜而自动切换。图标优先使用图标库的原色版本,缺少彩色版本时使用单色版本;缺少配置时显示通用图标,不猜测产品品牌。 + +渠道模型价格档支持管理员单独配置积分成本价;后台请求记录同时显示销售金额和成本金额。成本按下单快照计算,不追随之后改价;普通用户页面及接口不返回成本字段。 后台系统渠道及渠道模型列表通过“设置排序”打开完整列表,支持拖动、上移和下移,点击“保存排序”统一生效;取消不会修改线上顺序。渠道行的“更多”菜单支持复制渠道,复制会生成独立的渠道、模型和价格档,并保留原连接配置与凭证。界面不显示内部排序数字,排序不受筛选及分页影响;并发修改会提示重新加载,避免覆盖其他管理员的调整。系统渠道模式下,用户端模型选择器沿用同一渠道及模型顺序。模型排序独立保存,不改变价格、能力版本或启用状态。 diff --git a/docs/content/docs/progress/pending-test.mdx b/docs/content/docs/progress/pending-test.mdx index 8d255be29..f14df479a 100644 --- a/docs/content/docs/progress/pending-test.mdx +++ b/docs/content/docs/progress/pending-test.mdx @@ -5,6 +5,15 @@ description: 当前项目已实现功能的手工验证清单与未确认边界 # 待测试 +## 模型展示名归组与管理员积分成本 + +创作目录固定直接读取系统渠道模型,一级按模型展示名跨渠道分组,二级按各模型的渠道展示名展示;删除渠道级“前台名称”,不反查逻辑模型。成本配置限定在管理员价格档编辑,按下单快照核算,后台请求记录与 CSV 显示销售价、成本价;普通用户目录、报价和账单不返回成本字段。 + +- [x] 前端生产构建(含类型检查)与 35 项专项通过,覆盖跨渠道展示名分组、独立调用身份、成本回填与微积分换算。后端数据库、仓储、HTTP handler 全套回归通过;全包编译与成本/目录专项通过,覆盖管理员权限、用户接口字段隔离、历史快照、精确用量核算、迁移幂等和备用线路成本切换。 +- [ ] 在真实后台对调历史填反的模型展示名/渠道展示名,确认 MiniMax H3 一级下显示秘塔(满血渠道);同名模型跨渠道归组,不同调用 ID 保持独立。 +- [ ] 在 PostgreSQL 部署验证 schema v27,编辑按次、按秒、Token 成本后刷新回填;生成成功后核对销售金额与成本金额,改价不得影响旧订单。 +- [ ] 用普通用户登录检查网络响应,确认没有成本配置、快照或成本金额;对管理员成本接口的读写均应被拒绝。 + ## 画布删除不再预加载关联素材 登录后的画布删除直接按 ID 请求服务端,不再先进入编辑加载流程,避免历史素材的 MIME 等字段校验阻塞删除。保留账号会话检查、服务端删除失败上抛及成功后清理本地缓存的顺序。 @@ -33,7 +42,7 @@ description: 当前项目已实现功能的手工验证清单与未确认边界 ## 产品模型分组与渠道展示名 -系统渠道目录按能力与产品模型标识分组,二级展示独立渠道名称和价格;后台新增“渠道展示名”,留空使用渠道公开名称。选中值仍为渠道 ID 与模型标识,逻辑模型及个人渠道保持原选择合同。模型 Logo 按需加载原色版本,移除菜单位置染色。 +系统渠道目录按模型展示名分组,二级展示独立渠道名称和价格;“渠道展示名”留空使用所属渠道名称。选中值仍为渠道 ID 与模型调用标识,个人渠道保持原选择合同。模型 Logo 按需加载原色版本,移除菜单位置染色。 - [x] 前端专项覆盖同产品多个渠道、价格透传、名称变化不改调用身份、能力隔离、排序时元数据稳定,以及逻辑模型和个人渠道边界。 - [x] 后端 SQLite 专项覆盖字段保存、公开目录及会话投影、长度与管理员校验;版本 24 迁移重复执行保留旧记录的身份和价格。 diff --git a/web/src/components/model-picker.tsx b/web/src/components/model-picker.tsx index b64832dd4..8a4b1b9c2 100644 --- a/web/src/components/model-picker.tsx +++ b/web/src/components/model-picker.tsx @@ -245,7 +245,7 @@ export function ModelPicker({ theme={theme} creationVariant showConfiguredModelName={showConfiguredModelName} - label={group.kind === "product" || group.kind === "system-channel" ? modelGroup.label : undefined} + label={group.kind === "product" ? modelGroup.label : undefined} requirements={requirements} showPrice={showOptionPrices && creditsEnabled} disabledReason={disabledReason} diff --git a/web/src/lib/model-picker-groups.ts b/web/src/lib/model-picker-groups.ts index c4b9f9ce1..7b09732dc 100644 --- a/web/src/lib/model-picker-groups.ts +++ b/web/src/lib/model-picker-groups.ts @@ -6,7 +6,7 @@ export type ModelPickerGroup = { label: string; icon: string; scope: string; - kind: "product" | "channel" | "system-channel"; + kind: "product" | "channel"; models: DisplayModelGroup[]; }; @@ -20,29 +20,24 @@ export function isDirectSystemModel(config: AiConfig, value: string) { export function modelChannelLabel(config: AiConfig, value: string) { const channel = resolveModelChannel(config, value); const cost = channel.modelCosts?.find((item) => item.model === modelOptionName(value)); - return cost?.channelLabel?.trim() || channel.publicAlias?.trim() || channel.name || "未命名渠道"; + return cost?.channelLabel?.trim() || channel.name || "未命名渠道"; } -// 系统模型按渠道聚合展示,但每个模型仍保留独立的 channelId::modelKey 选择值。 +// 一级按模型展示名跨渠道聚合;二级保留每条渠道模型的独立选择值、能力和售价。 export function groupModelsForPicker(config: AiConfig, options: string[]): ModelPickerGroup[] { const groups = new Map(); for (const channel of config.channels) { const models = options.filter((value) => resolveModelChannel(config, value).id === channel.id); const directModels = models.filter((value) => isDirectSystemModel(config, value)); - if (directModels.length) { - const key = JSON.stringify(["system-channel", channel.id]); - groups.set(key, { - key, - label: channel.name || "未命名渠道", - icon: modelIcon(config, directModels[0]), - scope: "平台服务", - kind: "system-channel", - models: directModels.map((value) => ({ - key: value, - label: configuredModelDisplayName(config, value), - models: [value], - })), - }); + for (const value of directModels) { + const label = configuredModelDisplayName(config, value); + const key = JSON.stringify(["product", label]); + let group = groups.get(key); + if (!group) { + group = { key, label, icon: modelIcon(config, value), scope: "平台服务", kind: "product", models: [] }; + groups.set(key, group); + } + group.models.push({ key: value, label: modelChannelLabel(config, value), models: [value] }); } const otherModels = models.filter((value) => !isDirectSystemModel(config, value)); if (otherModels.length) { diff --git a/web/src/lib/user-session.ts b/web/src/lib/user-session.ts index 332a14ce1..2d656a42b 100644 --- a/web/src/lib/user-session.ts +++ b/web/src/lib/user-session.ts @@ -1,12 +1,12 @@ import { getFeatureAvailability, type AuthSessionPayload } from "@/services/api/auth"; -import { getModelCatalog, type CapabilitySpec, type ModelCatalogResponse, type OptionConstraint, type PublicChannelCatalog, type PublicLogicalModel } from "@/services/api/logical-models"; +import { getModelCatalog, type CapabilitySpec, type ModelCatalogResponse, type OptionConstraint, type PublicChannelCatalog } from "@/services/api/logical-models"; import { localForageStorage } from "@/lib/localforage-storage"; import { appQueryClient } from "@/lib/query-client"; import { scopedLocalStorage, setActiveUserScope } from "@/lib/user-scope"; import { CANVAS_STORE_KEY, flushCanvasStorePersistence, useCanvasStore } from "@/stores/canvas/use-canvas-store"; import { CANVAS_HISTORY_STORE_KEY, useCanvasHistoryStore } from "@/stores/canvas/use-canvas-history-store"; import { ASSET_STORE_KEY, flushAssetStorePersistence, useAssetStore } from "@/stores/use-asset-store"; -import { CONFIG_STORE_KEY, PUBLIC_MODEL_CATALOG_ID, defaultConfig, normalizeConfigSnapshot, useConfigStore, type ModelCapability, type ModelChannel } from "@/stores/use-config-store"; +import { CONFIG_STORE_KEY, defaultConfig, normalizeConfigSnapshot, useConfigStore, type ModelCapability, type ModelChannel } from "@/stores/use-config-store"; import { CREATION_PREFERENCES_STORE_KEY, useCreationPreferencesStore } from "@/stores/use-creation-preferences-store"; import { defaultModelCapabilityConfig, STANDARD_IMAGE_SIZE_VALUES, type ModelCapabilityConfig } from "@/lib/model-capabilities"; import { imageSizeConfigWithPresets } from "@/lib/image-size-presets"; @@ -90,12 +90,8 @@ export async function refreshSystemChannels() { useConfigStore.getState().mergeSystemChannels(modelCatalogChannels(catalog)); } -// 模型目录来源决定数据形状;这里统一做运行时收口,避免畸形响应被当成“空目录”写入用户配置。 +// 目录仅接受系统渠道模型,避免畸形响应被当成空目录写入配置。 function modelCatalogChannels(catalog: ModelCatalogResponse): ModelChannel[] { - if (catalog.source === "frontend") { - if (!Array.isArray(catalog.models)) throw new Error("模型目录响应缺少前台模型列表"); - return managedModelChannels(catalog.models); - } if (catalog.source === "system") { if (!Array.isArray(catalog.channels)) throw new Error("模型目录响应缺少系统渠道列表"); return systemChannelModelChannels(catalog.channels); @@ -103,42 +99,6 @@ function modelCatalogChannels(catalog: ModelCatalogResponse): ModelChannel[] { throw new Error("模型目录响应来源无效"); } -function managedModelChannels(models: PublicLogicalModel[]) { - const availableModels = models.filter((item) => item.available); - if (!availableModels.length) return []; - const managed: ModelChannel = { - id: PUBLIC_MODEL_CATALOG_ID, - name: "平台模型", - baseUrl: "/api", - apiKey: "system", - apiFormat: "openai", - scope: "system", - enabled: true, - models: availableModels.map((item) => item.id), - modelAliases: Object.fromEntries(availableModels.flatMap((item) => (item.legacyModelIds || []).map((legacyID) => [legacyID, item.id]))), - modelCosts: availableModels.map((item) => ({ - model: item.id, - displayName: item.name, - description: item.description, - icon: item.icon, - capability: item.capability, - pricePolicy: item.pricePolicy, - billingMode: item.billingMode, - unitPriceMicrocredits: item.unitPriceMicrocredits, - inputTokenPriceMicrocredits: item.inputPriceMicrocredits, - outputTokenPriceMicrocredits: item.outputPriceMicrocredits, - cachedTokenPriceMicrocredits: item.cachedPriceMicrocredits, - capabilityConfig: projectLogicalCapability(item.capabilitySpec, item.defaultOptions), - logicalModelId: item.id, - logicalCapabilitySpec: item.capabilitySpec, - logicalCapabilityProfiles: item.capabilityProfiles, - logicalPriceTiers: item.priceTiers, - defaultOptions: item.defaultOptions, - })), - }; - return [managed]; -} - // 系统渠道模型转换为前端配置格式 export function systemChannelModelChannels(channels: PublicChannelCatalog[]): ModelChannel[] { return channels.map((channel) => { diff --git a/web/src/pages/admin/channels/channels-page.tsx b/web/src/pages/admin/channels/channels-page.tsx index b2f4ee9fd..e7cae1bcb 100644 --- a/web/src/pages/admin/channels/channels-page.tsx +++ b/web/src/pages/admin/channels/channels-page.tsx @@ -19,7 +19,6 @@ import { ChannelOrderDialog } from "../components/channel-order-dialog"; type ChannelFormValues = { name: string; - publicAlias?: string; baseUrl: string; apiKey?: string; secretKey?: string; @@ -32,7 +31,6 @@ type ChannelFormValues = { export function adminChannelSavePayload(values: ChannelFormValues) { return { name: values.name.trim(), - publicAlias: values.publicAlias?.trim() || "", baseUrl: values.baseUrl.trim(), apiKey: values.apiKey?.trim() || "", secretKey: values.secretKey?.trim() || "", @@ -122,7 +120,6 @@ export default function ChannelsPage() { : { name: "", baseUrl: "", apiKey: "", secretKey: "", headers: [], useGlobalConcurrency: true, concurrencyLimit: undefined, enabled: true }, ); setDrawerOpen(true); - form.setFieldsValue({ publicAlias: channel?.publicAlias || "" }); }; const closeDrawer = () => { @@ -208,7 +205,6 @@ export default function ChannelsPage() { ), }, - { title: "前台名称", width: 160, render: (_, channel) => {channel.publicAlias || channel.name} }, { title: "模型", dataIndex: "models", width: 100, align: "center", render: (models: string[]) => `${models?.length || 0} 个` }, { title: "最大并发", dataIndex: "concurrencyLimit", width: 120, align: "center", render: (value: number) => (value > 0 ? value : 跟随系统) }, { title: "凭证", width: 130, align: "center", render: (_, channel) => }, @@ -359,9 +355,6 @@ export default function ChannelsPage() { - - - diff --git a/web/src/pages/admin/components/api-log-detail-drawer.tsx b/web/src/pages/admin/components/api-log-detail-drawer.tsx index 1ba6035e8..0eba6baee 100644 --- a/web/src/pages/admin/components/api-log-detail-drawer.tsx +++ b/web/src/pages/admin/components/api-log-detail-drawer.tsx @@ -103,7 +103,8 @@ function LogDetail({ log, querying, onQueryProviderTask }: { log: ApiCallLog; qu 未返回 ), ], - ["积分计费", billingText(log)], + ["销售价格(积分)", billingText(log)], + ["成本价格(积分)", log.creditCostMicrocredits !== undefined ? `${formatCredits(log.creditCostMicrocredits)} 积分` : log.creditCostConfigured ? "待核算" : "未配置"], ["上游成本", log.costAvailable ? {log.currency || "USD"} {(log.estimatedCostMicros / 1_000_000).toFixed(6)} : 未配置成本], [ "错误信息", diff --git a/web/src/pages/admin/components/channel-model-editor-form.ts b/web/src/pages/admin/components/channel-model-editor-form.ts index f423046e5..7e2b75b8f 100644 --- a/web/src/pages/admin/components/channel-model-editor-form.ts +++ b/web/src/pages/admin/components/channel-model-editor-form.ts @@ -96,5 +96,9 @@ export function validateChannelModelPrices(values: Pick typeof price !== "number" || !Number.isFinite(price) || price < 0 || price > 1_000_000)) fail("积分价格必须是 0 到 1000000 之间的有效数值"); + if (tier.costConfigured) { + const costs = tier.billingMode !== "token" ? [tier.costUnitPrice] : capability === "video" ? [tier.costOutputTokenPrice] : [tier.costInputTokenPrice, tier.costOutputTokenPrice, tier.costCachedTokenPrice]; + if (costs.some((price) => typeof price !== "number" || !Number.isFinite(price) || price < 0 || price > 1_000_000)) fail("积分成本价必须是 0 到 1000000 之间的有效数值"); + } }); } diff --git a/web/src/pages/admin/components/channel-model-editor.tsx b/web/src/pages/admin/components/channel-model-editor.tsx index eab498a5b..3eb856652 100644 --- a/web/src/pages/admin/components/channel-model-editor.tsx +++ b/web/src/pages/admin/components/channel-model-editor.tsx @@ -247,10 +247,10 @@ export function ChannelModelEditor({ - + - + diff --git a/web/src/pages/admin/components/channel-model-price-tier-fields.tsx b/web/src/pages/admin/components/channel-model-price-tier-fields.tsx index a9c4899fd..2e1259b89 100644 --- a/web/src/pages/admin/components/channel-model-price-tier-fields.tsx +++ b/web/src/pages/admin/components/channel-model-price-tier-fields.tsx @@ -5,6 +5,7 @@ import { modelProtocolSupportsTokenBilling, type ModelProtocol } from "@/lib/mod import type { ModelCapabilityChoice as EditableCapability } from "@/components/model-protocol-picker"; import type { ChannelModelFormValues as FormValues } from "./channel-model-editor-form"; import { normalizeUpstreamModelKey } from "./channel-model-price-tier-form"; +import { CreditCostFields } from "./credit-cost-fields"; export function PriceTierFields({ index, @@ -84,6 +85,7 @@ export function PriceTierFields({
+
销售价格
) : null}
+ {matchMode !== "default" && (
diff --git a/web/src/pages/admin/components/channel-model-price-tier-form.ts b/web/src/pages/admin/components/channel-model-price-tier-form.ts index 9c8657899..89b5921bc 100644 --- a/web/src/pages/admin/components/channel-model-price-tier-form.ts +++ b/web/src/pages/admin/components/channel-model-price-tier-form.ts @@ -23,6 +23,11 @@ export type PriceTierFormValues = { inputTokenPrice: number; outputTokenPrice: number; cachedTokenPrice: number; + costConfigured: boolean; + costUnitPrice: number; + costInputTokenPrice: number; + costOutputTokenPrice: number; + costCachedTokenPrice: number; priceConfigured: boolean; enabled: boolean; }; @@ -43,6 +48,11 @@ export function defaultPriceTier(matchMode: PriceTierMatchMode = "default"): Pri inputTokenPrice: 0, outputTokenPrice: 0, cachedTokenPrice: 0, + costConfigured: false, + costUnitPrice: 0, + costInputTokenPrice: 0, + costOutputTokenPrice: 0, + costCachedTokenPrice: 0, priceConfigured: true, enabled: true, }; @@ -66,6 +76,11 @@ export function priceTierToForm(tier: ChannelModelPriceTier): PriceTierFormValue inputTokenPrice: tier.inputTokenPriceMicrocredits / 1_000_000, outputTokenPrice: tier.outputTokenPriceMicrocredits / 1_000_000, cachedTokenPrice: tier.cachedTokenPriceMicrocredits / 1_000_000, + costConfigured: tier.costPricing?.configured === true, + costUnitPrice: (tier.costPricing?.unitPriceMicrocredits ?? 0) / 1_000_000, + costInputTokenPrice: (tier.costPricing?.inputTokenPriceMicrocredits ?? 0) / 1_000_000, + costOutputTokenPrice: (tier.costPricing?.outputTokenPriceMicrocredits ?? 0) / 1_000_000, + costCachedTokenPrice: (tier.costPricing?.cachedTokenPriceMicrocredits ?? 0) / 1_000_000, priceConfigured: tier.priceConfigured, enabled: tier.enabled, }; @@ -123,6 +138,13 @@ export function priceTierPayloadFromForm(capability: ModelCapabilityChoice, tier inputTokenPriceMicrocredits: videoTokens ? 0 : Math.round((tier.inputTokenPrice || 0) * 1_000_000), outputTokenPriceMicrocredits: Math.round((tier.outputTokenPrice || 0) * 1_000_000), cachedTokenPriceMicrocredits: videoTokens ? 0 : Math.round((tier.cachedTokenPrice || 0) * 1_000_000), + costPricing: { + configured: tier.costConfigured, + unitPriceMicrocredits: Math.round(tier.costUnitPrice * 1_000_000), + inputTokenPriceMicrocredits: videoTokens ? 0 : Math.round(tier.costInputTokenPrice * 1_000_000), + outputTokenPriceMicrocredits: Math.round(tier.costOutputTokenPrice * 1_000_000), + cachedTokenPriceMicrocredits: videoTokens ? 0 : Math.round(tier.costCachedTokenPrice * 1_000_000), + }, priceConfigured: tier.priceConfigured !== false, enabled: tier.enabled !== false, }; diff --git a/web/src/pages/admin/components/credit-cost-fields.tsx b/web/src/pages/admin/components/credit-cost-fields.tsx new file mode 100644 index 000000000..8a4cbf715 --- /dev/null +++ b/web/src/pages/admin/components/credit-cost-fields.tsx @@ -0,0 +1,21 @@ +import { Form, InputNumber, Switch, type FormInstance } from "antd"; +import type { ChannelModelFormValues } from "./channel-model-editor-form"; + +export function CreditCostFields({ index, form, billingMode, isVideo }: { index: number; form: FormInstance; billingMode: string; isVideo: boolean }) { + const configured = Form.useWatch(["priceTiers", index, "costConfigured"], form) === true; + const fields = billingMode === "token" + ? isVideo + ? [["costOutputTokenPrice", "积分 / 百万视频 Token"]] + : [["costInputTokenPrice", "输入 / 百万 Token"], ["costOutputTokenPrice", "输出 / 百万 Token"], ["costCachedTokenPrice", "缓存 / 百万 Token"]] + : [["costUnitPrice", billingMode === "per_second" ? "积分 / 秒" : "积分 / 次"]]; + return
+ + + + {configured ?
+ {fields.map(([field, label]) => + + )} +
: null} +
; +} diff --git a/web/src/pages/admin/logs/logs-page.tsx b/web/src/pages/admin/logs/logs-page.tsx index 2916ae9d8..7a31f42c0 100644 --- a/web/src/pages/admin/logs/logs-page.tsx +++ b/web/src/pages/admin/logs/logs-page.tsx @@ -99,7 +99,7 @@ export default function LogsPage() { ), }, { title: "耗时", dataIndex: "durationMs", width: 112, render: (value) => {formatDuration(value)} }, - { title: "积分计费", width: 145, render: (_, log) => }, + { title: "积分计算", width: 195, render: (_, log) => }, { title: "Tokens", width: 166, @@ -269,7 +269,8 @@ function BillingSummary({ log }: { log: ApiCallLog }) { const statusLabel = ({ settled: "已结算", refunded: "已退回", uncertain: "待核对", running: "运行中", reserved: "已预授权" } as const)[status]; return (
-
{formatCredits(log.billingAmountMicrocredits)} 积分
+
销售价格:{formatCredits(log.billingAmountMicrocredits)} 积分
+
成本价格:{log.creditCostMicrocredits !== undefined ? `${formatCredits(log.creditCostMicrocredits)} 积分` : log.creditCostConfigured ? "待核算" : "未配置"}
{statusLabel}
); diff --git a/web/src/services/api/auth.ts b/web/src/services/api/auth.ts index 2464811e7..5f8233105 100644 --- a/web/src/services/api/auth.ts +++ b/web/src/services/api/auth.ts @@ -87,6 +87,8 @@ export type ApiCallLog = { videoSeconds: number; providerRequestId?: string; estimatedCostMicros: number; + creditCostConfigured?: boolean; + creditCostMicrocredits?: number; costAvailable: boolean; currency?: string; errorCode?: string; diff --git a/web/src/services/api/logical-models.ts b/web/src/services/api/logical-models.ts index 8c21438d0..3f1782b07 100644 --- a/web/src/services/api/logical-models.ts +++ b/web/src/services/api/logical-models.ts @@ -153,7 +153,7 @@ export type ModelQuoteRequest = { intent: ModelRequestIntent; }; -export type ModelCatalogSource = "frontend" | "system"; +export type ModelCatalogSource = "system"; export type PublicChannelCatalog = { id: string; @@ -199,7 +199,7 @@ export type ModelCatalogResponse = { channels?: PublicChannelCatalog[]; }; -// 统一模型目录接口 - 根据 frontendModelsEnabled 开关返回前台模型或系统渠道模型 +// 创作目录直接读取系统渠道模型,不使用逻辑模型及其功能开关。 export function getModelCatalog() { return http.get("/model-catalog"); } diff --git a/web/src/services/api/wallet.ts b/web/src/services/api/wallet.ts index 150a1953f..c6206b96d 100644 --- a/web/src/services/api/wallet.ts +++ b/web/src/services/api/wallet.ts @@ -74,6 +74,8 @@ export type ChannelModel = { }; export type ChannelModelPriceTier = { + /** 仅管理员模型编辑接口返回,不能复制到用户模型目录。 */ + costPricing?: CreditCostPricing; id: string; channelModelId: string; selector: Record; @@ -93,6 +95,14 @@ export type ChannelModelPriceTier = { updatedAt: string; }; +export type CreditCostPricing = { + configured: boolean; + unitPriceMicrocredits: number; + inputTokenPriceMicrocredits: number; + outputTokenPriceMicrocredits: number; + cachedTokenPriceMicrocredits: number; +}; + // 系统渠道模型的写入合同。标量价格只用于兼容旧管理请求;新的后台界面只提交 priceTiers。 export type ChannelModelMutation = { modelKey: string; diff --git a/web/src/stores/use-config-store.ts b/web/src/stores/use-config-store.ts index 69d36c832..e683eb07c 100644 --- a/web/src/stores/use-config-store.ts +++ b/web/src/stores/use-config-store.ts @@ -344,7 +344,6 @@ export const PUBLIC_MODEL_CATALOG_ID = "managed"; export type ModelChannel = { id: string; name: string; - publicAlias?: string; sortOrder?: number; baseUrl: string; apiKey: string; diff --git a/web/test/channel-model-price-tier-form.test.ts b/web/test/channel-model-price-tier-form.test.ts index 33168a3e9..8643d6a2b 100644 --- a/web/test/channel-model-price-tier-form.test.ts +++ b/web/test/channel-model-price-tier-form.test.ts @@ -2,8 +2,27 @@ import { describe, expect, test } from "bun:test"; import { defaultPriceTier, priceTierPayloadFromForm, priceTierResolutionFromForm, priceTierToForm, priceTierVideoSecondsFromForm, skuSelectorFromForm } from "../src/pages/admin/components/channel-model-price-tier-form"; import type { ChannelModelPriceTier } from "../src/services/api/wallet"; +import { validateChannelModelPrices } from "../src/pages/admin/components/channel-model-editor-form"; describe("channel model price tier defaults", () => { + test("cost edit payload preserves independent sale and cost prices including explicit zero", () => { + const tier = { ...defaultPriceTier(), unitPrice: 0.09, costConfigured: true, costUnitPrice: 0.035678 }; + const payload = priceTierPayloadFromForm("video", tier, "MiniMax-H3"); + expect(payload.unitPriceMicrocredits).toBe(90_000); + expect(payload.costPricing).toMatchObject({ configured: true, unitPriceMicrocredits: 35_678 }); + expect(priceTierToForm(payload as ChannelModelPriceTier)).toMatchObject({ unitPrice: 0.09, costConfigured: true, costUnitPrice: 0.035678 }); + expect(priceTierPayloadFromForm("video", { ...tier, costUnitPrice: 0 }, "MiniMax-H3").costPricing).toMatchObject({ configured: true, unitPriceMicrocredits: 0 }); + expect(defaultPriceTier().costConfigured).toBe(false); + for (const costUnitPrice of [-1, NaN, Infinity, 1_000_001]) { + expect(() => validateChannelModelPrices({ capability: "video", protocol: "minimax-video", priceTiers: [{ ...tier, costUnitPrice }] })).toThrow("积分成本价"); + } + }); + + test("video Token cost uses only output price while text cost keeps separate token rates", () => { + const tier = { ...defaultPriceTier(), billingMode: "token" as const, costConfigured: true, costInputTokenPrice: 1.5, costOutputTokenPrice: 3, costCachedTokenPrice: 0.1 }; + expect(priceTierPayloadFromForm("video", tier, "video").costPricing).toMatchObject({ configured: true, inputTokenPriceMicrocredits: 0, outputTokenPriceMicrocredits: 3_000_000, cachedTokenPriceMicrocredits: 0 }); + expect(priceTierPayloadFromForm("text", tier, "text").costPricing).toMatchObject({ inputTokenPriceMicrocredits: 1_500_000, outputTokenPriceMicrocredits: 3_000_000, cachedTokenPriceMicrocredits: 100_000 }); + }); test("creates a usable all-spec fallback price by default", () => { const tier = defaultPriceTier(); diff --git a/web/test/model-picker-groups.test.ts b/web/test/model-picker-groups.test.ts index b675701e1..3d4552e3f 100644 --- a/web/test/model-picker-groups.test.ts +++ b/web/test/model-picker-groups.test.ts @@ -27,19 +27,30 @@ function fixture() { return normalizeConfigSnapshot({ config: { ...defaultConfig, channels: systemChannelModelChannels(channels), model: "b::seedance-2.0", videoModel: "b::seedance-2.0" } }).config; } -test("one system channel exposes every model and preserves prices through catalog normalization", () => { +test("same model display name groups all channels and preserves their prices", () => { const config = fixture(); const groups = groupModelsForPicker(config, selectableModelsByCapability(config, "video")); - expect(groups).toHaveLength(3); + expect(groups).toHaveLength(1); expect(config.channels.map((channel) => channel.modelCosts![0].description)).toEqual(["", "优惠渠道-993的使用说明", "特惠渠道-730的使用说明"]); expect(groups.map((group) => [group.label, group.kind, group.models.map((item) => [item.label, item.models])])).toEqual([ - ["正常渠道", "system-channel", [["Seedance 2.0", ["a::seedance-2.0"]]]], - ["渠道 B", "system-channel", [["Seedance 2.0", ["b::seedance-2.0"]]]], - ["渠道 C", "system-channel", [["Seedance 2.0", ["c::seedance-2.0"]]]], + ["Seedance 2.0", "product", [["正常渠道", ["a::seedance-2.0"]], ["优惠渠道-993", ["b::seedance-2.0"]], ["特惠渠道-730", ["c::seedance-2.0"]]]], ]); expect(config.channels.map((channel) => priceTierSummaryLabel(priceTiersForCurrentSelection(channel.modelCosts![0].logicalPriceTiers!, "video", config)))).toEqual(["0.3 积分/秒", "0.3 积分/秒", "0.2 积分/秒"]); }); +test("grouping uses display name even when model keys differ, without merging channel options", () => { + const channels = systemChannelModelChannels([ + { id: "mita", name: "秘塔", displayName: "秘塔", models: [model("秘塔(满血渠道)", 90_000, "MiniMax H3", "MiniMax H3")] }, + { id: "other", name: "其他供应商", displayName: "其他供应商", models: [model("秘塔(满血渠道)", 80_000, "minimax-h3-alt", "MiniMax H3")] }, + ]); + const config = normalizeConfigSnapshot({ config: { ...defaultConfig, channels } }).config; + const groups = groupModelsForPicker(config, selectableModelsByCapability(config, "video")); + expect(groups).toHaveLength(1); + expect(groups[0].label).toBe("MiniMax H3"); + expect(groups[0].models.map((item) => item.models)).toEqual([["mita::MiniMax H3"], ["other::minimax-h3-alt"]]); + expect(groups[0].models.map((item) => item.label)).toEqual(["秘塔(满血渠道)", "秘塔(满血渠道)"]); +}); + test("selection and quote keep the chosen channel even when another channel is cheaper", () => { const config = fixture(); const value = "b::seedance-2.0"; @@ -51,7 +62,7 @@ test("selection and quote keep the chosen channel even when another channel is c expect(resolveModelRequestConfig(config, value)).toMatchObject({ channelId: "b", model: "seedance-2.0" }); }); -test("system channel aggregation keeps models distinct within one channel", () => { +test("different display names in one channel create distinct first-level groups", () => { const config = fixture(); const extra = systemChannelModelChannels([{ id: "volc", name: "火山引擎", displayName: "火山引擎", models: [ model("Seedance 2 Mini", 300000, "seedance-2-mini", "Seedance 2 Mini"), @@ -59,9 +70,9 @@ test("system channel aggregation keeps models distinct within one channel", () = model("Seedance 2.0", 300000, "seedance-2", "Seedance 2.0"), ] }]); const groups = groupModelsForPicker({ ...config, channels: extra }, selectableModelsByCapability({ ...config, channels: extra }, "video")); - expect(groups).toHaveLength(1); - expect(groups[0]).toMatchObject({ label: "火山引擎", kind: "system-channel" }); - expect(groups[0].models.map((item) => item.label)).toEqual(["Seedance 2 Mini", "Seedance 2.0 Fast", "Seedance 2.0"]); + expect(groups).toHaveLength(3); + expect(groups.map((group) => group.label)).toEqual(["Seedance 2 Mini", "Seedance 2.0 Fast", "Seedance 2.0"]); + expect(groups.every((group) => group.kind === "product" && group.models.length === 1)).toBe(true); }); test("group metadata is stable across channel reorder and current selection", () => { @@ -69,10 +80,10 @@ test("group metadata is stable across channel reorder and current selection", () config.channels[1].modelCosts![0].displayName = "不同展示名"; config.channels[1].modelCosts![0].icon = "Jimeng"; const options = selectableModelsByCapability(config, "video"); - const first = groupModelsForPicker(config, options).find((group) => group.label === "渠道 C")!; - const reordered = groupModelsForPicker({ ...config, channels: [...config.channels].reverse(), model: "c::seedance-2.0" }, options).find((group) => group.label === "渠道 C")!; + const first = groupModelsForPicker(config, options).find((group) => group.label === "不同展示名")!; + const reordered = groupModelsForPicker({ ...config, channels: [...config.channels].reverse(), model: "c::seedance-2.0" }, options).find((group) => group.label === "不同展示名")!; expect([reordered.label, reordered.icon]).toEqual([first.label, first.icon]); - expect(reordered.models[0].models).toEqual(["c::seedance-2.0"]); + expect(reordered.models[0].models).toEqual(["b::seedance-2.0"]); }); test("each channel keeps its own capability restrictions", () => { @@ -83,7 +94,7 @@ test("each channel keeps its own capability restrictions", () => { const requirements = { capability: "video" as const, input: { textCount: 1, imageCount: 1, videoCount: 0, audioCount: 0, characterCount: 0 } }; expect(modelCompatibilityError(config, "a::seedance-2.0", requirements)).not.toBe(""); expect(modelCompatibilityError(config, "b::seedance-2.0", requirements)).toBe(""); - expect(groupModelsForPicker(config, selectableModelsByCapability(config, "video")).every((group) => group.models.length === 1)).toBe(true); + expect(groupModelsForPicker(config, selectableModelsByCapability(config, "video"))[0].models).toHaveLength(3); }); test("managed logical models and personal channels never become system product routes", () => { @@ -92,7 +103,7 @@ test("managed logical models and personal channels never become system product r config.channels[1].modelCosts![0].logicalModelId = "logical-test"; config.channels[2].scope = "user"; const groups = groupModelsForPicker(config, ["a::seedance-2.0", "managed::seedance-2.0", "c::seedance-2.0"]); - expect(groups.map((item) => item.kind)).toEqual(["system-channel", "channel", "channel"]); + expect(groups.map((item) => item.kind)).toEqual(["product", "channel", "channel"]); expect(groups[0].models).toHaveLength(1); expect(groupModelsForPicker(config, [])).toEqual([]); });