feat(models): 模型目录与计费 - 按展示名分组并隔离管理员积分成本

This commit is contained in:
ddCat
2026-09-19 09:54:27 +08:00
parent 5ad05efb58
commit 237f2e2a6e
49 changed files with 802 additions and 276 deletions

View File

@@ -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,

View File

@@ -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

View File

@@ -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 {

View File

@@ -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) {

View File

@@ -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")
}

View File

@@ -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()

View File

@@ -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) {

View File

@@ -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
}

View File

@@ -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")
}
}

View File

@@ -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 {

View File

@@ -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,
})

View File

@@ -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) {

View File

@@ -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("模型服务价格发生变化,当前积分余额不足")
}

View File

@@ -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)
}
}

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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}
}

View File

@@ -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())
}
}
}

View File

@@ -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)})
}

View File

@@ -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:

View File

@@ -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"`
}

View File

@@ -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"`
}

View File

@@ -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"`

View File

@@ -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
}

View File

@@ -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

View File

@@ -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 {

View File

@@ -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)

View File

@@ -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