feat(*): 系统渠道与画布 - 支持多规格批量调价及 Agent 入口拖动
This commit is contained in:
1 parent
9afe061d79
commit
8d94bde1f4
26 files changed
+1761
-44
No files matched your search
@@ -0,0 +1,130 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"infinite-canvas/backend/internal/model"
|
||||
"infinite-canvas/backend/internal/repository"
|
||||
)
|
||||
|
||||
type ChannelModelRepriceRequest struct {
|
||||
ModelID string `json:"modelId"`
|
||||
PriceVersion int64 `json:"priceVersion"`
|
||||
PriceTiers []ChannelModelTierRepriceRequest `json:"priceTiers"`
|
||||
}
|
||||
|
||||
type ChannelModelTierRepriceRequest struct {
|
||||
ID string `json:"id"`
|
||||
PriceVersion int64 `json:"priceVersion"`
|
||||
Prices map[string]*int64 `json:"prices"`
|
||||
}
|
||||
|
||||
// RepriceAdminChannelModels only changes sale prices, never model availability or cost.
|
||||
func (s *Service) RepriceAdminChannelModels(actor *model.User, channelID string, requests []ChannelModelRepriceRequest) (int, error) {
|
||||
if err := s.RequireAdmin(actor); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if _, err := s.adminSystemChannel(channelID); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if len(requests) == 0 || len(requests) > 100 {
|
||||
return 0, BadAuthRequest("单次请选择 1 到 100 个渠道模型")
|
||||
}
|
||||
selected := make(map[string]ChannelModelRepriceRequest, len(requests))
|
||||
for _, request := range requests {
|
||||
id := strings.TrimSpace(request.ModelID)
|
||||
if _, duplicate := selected[id]; id == "" || duplicate || request.PriceVersion < 1 {
|
||||
return 0, BadAuthRequest("渠道模型 ID 或价格版本无效,且模型不能重复")
|
||||
}
|
||||
selected[id] = request
|
||||
}
|
||||
items, err := s.repo.ChannelModels(channelID, true)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
updates := make([]model.ChannelModel, 0, len(selected))
|
||||
for _, item := range items {
|
||||
request, exists := selected[item.ID]
|
||||
if !exists {
|
||||
continue
|
||||
}
|
||||
if request.PriceVersion != item.PriceVersion {
|
||||
return 0, BadAuthRequest("模型价格已变化,请刷新后重新调价;本次未修改任何价格")
|
||||
}
|
||||
tiers := make(map[string]ChannelModelTierRepriceRequest, len(request.PriceTiers))
|
||||
for _, tier := range request.PriceTiers {
|
||||
if _, duplicate := tiers[tier.ID]; tier.ID == "" || duplicate {
|
||||
return 0, BadAuthRequest("规格 ID 不能为空或重复")
|
||||
}
|
||||
tiers[tier.ID] = tier
|
||||
}
|
||||
active := 0
|
||||
for index := range item.PriceTiers {
|
||||
tier := &item.PriceTiers[index]
|
||||
if !tier.Enabled {
|
||||
continue
|
||||
}
|
||||
active++
|
||||
input, exists := tiers[tier.ID]
|
||||
if !exists || input.PriceVersion != tier.PriceVersion {
|
||||
return 0, BadAuthRequest("规格价格已变化或提交不完整,请刷新后重新调价")
|
||||
}
|
||||
if err := applyChannelModelTierPrices(tier, item.Capability, item.Protocol, input.Prices); err != nil {
|
||||
return 0, BadAuthRequest(fmt.Sprintf("模型 %s(规格 %s):%s;本次未修改任何价格", item.ModelKey, tier.SelectorKey, err.Error()))
|
||||
}
|
||||
}
|
||||
if active == 0 || active != len(tiers) {
|
||||
return 0, BadAuthRequest("模型 " + item.ModelKey + " 的规格提交不完整或包含无效规格;本次未修改任何价格")
|
||||
}
|
||||
s.applyChannelModelPriceTierSummary(&item, item.PriceTiers)
|
||||
updates = append(updates, item)
|
||||
}
|
||||
if len(updates) != len(selected) {
|
||||
return 0, BadAuthRequest("所选模型已删除或不属于当前渠道,请刷新后重试")
|
||||
}
|
||||
// Consistent lock order for overlapping batch requests.
|
||||
sort.Slice(updates, func(i, j int) bool { return updates[i].ID < updates[j].ID })
|
||||
if err := s.repo.UpdateChannelModelSalePrices(channelID, updates); err != nil {
|
||||
if errors.Is(err, repository.ErrChannelModelPriceConflict) {
|
||||
return 0, BadAuthRequest("模型价格已被其他操作修改,请刷新后重试;本次未修改任何价格")
|
||||
}
|
||||
return 0, err
|
||||
}
|
||||
s.invalidateRouteCatalog()
|
||||
return len(updates), nil
|
||||
}
|
||||
|
||||
func applyChannelModelTierPrices(tier *model.ChannelModelPriceTier, capability string, protocol model.ChannelInterfaceType, prices map[string]*int64) error {
|
||||
if tier.ID == "" || !tier.PriceConfigured || !tier.CostPricing.Configured {
|
||||
return BadAuthRequest("请先保存完整的规格成本价和销售价配置")
|
||||
}
|
||||
if err := validateCreditCostPricing(capability, tier.BillingMode, tier.CostPricing); err != nil {
|
||||
return err
|
||||
}
|
||||
fields := map[string]*int64{"unitPriceMicrocredits": &tier.UnitPriceMicrocredits}
|
||||
if tier.BillingMode == "token" {
|
||||
fields = map[string]*int64{"outputTokenPriceMicrocredits": &tier.OutputTokenPriceMicrocredits}
|
||||
if capability != "video" {
|
||||
fields["inputTokenPriceMicrocredits"] = &tier.InputTokenPriceMicrocredits
|
||||
fields["cachedTokenPriceMicrocredits"] = &tier.CachedTokenPriceMicrocredits
|
||||
}
|
||||
}
|
||||
if len(prices) != len(fields) {
|
||||
return BadAuthRequest("必须提交当前计费方式的全部销售价,且不能包含其他字段")
|
||||
}
|
||||
for key, target := range fields {
|
||||
value, exists := prices[key]
|
||||
if !exists || value == nil || *value < 0 || *value > 9_007_199_254_740_991 {
|
||||
return BadAuthRequest("销售价缺失或超出有效范围")
|
||||
}
|
||||
*target = *value
|
||||
}
|
||||
return validateChannelModelTierPricing(capability, protocol, tier.BillingMode, ChannelModelPriceTierRequest{
|
||||
CostPricing: tier.CostPricing, UnitPriceMicrocredits: tier.UnitPriceMicrocredits,
|
||||
InputTokenPriceMicrocredits: tier.InputTokenPriceMicrocredits, OutputTokenPriceMicrocredits: tier.OutputTokenPriceMicrocredits,
|
||||
CachedTokenPriceMicrocredits: tier.CachedTokenPriceMicrocredits,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"infinite-canvas/backend/internal/model"
|
||||
"infinite-canvas/backend/internal/repository"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func seedRepricingModels(t *testing.T) (*Service, *gorm.DB, *model.User) {
|
||||
t.Helper()
|
||||
svc, db := newChannelModelTestService(t)
|
||||
admin := &model.User{ID: "admin", Role: model.UserRoleAdmin}
|
||||
if err := db.Create(&model.ModelChannel{ID: "channel", UserID: admin.ID, Scope: model.ChannelScopeSystem, Enabled: true}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, id := range []string{"a", "b"} {
|
||||
item := model.ChannelModel{ID: id, ChannelID: "channel", ModelKey: id, DisplayName: id, Capability: "text", Protocol: model.ChannelInterfaceChatCompletion, BillingMode: "fixed_request", Enabled: true, PriceConfigured: true, PriceVersion: 1, UnitPriceMicrocredits: 9}
|
||||
tier := model.ChannelModelPriceTier{ID: "tier-" + id, ChannelModelID: id, SelectorKey: "{}", SelectorJSON: "{}", Resolution: "*", BillingMode: "fixed_request", Enabled: true, PriceConfigured: true, PriceVersion: 1, UnitPriceMicrocredits: 9, CostPricing: model.CreditCostPricing{Configured: true, UnitPriceMicrocredits: 1_000_000}}
|
||||
if err := db.Create(&item).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Create(&tier).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
return svc, db, admin
|
||||
}
|
||||
|
||||
func repricingRequest(id string, price int64) ChannelModelRepriceRequest {
|
||||
return ChannelModelRepriceRequest{ModelID: id, PriceVersion: 1, PriceTiers: []ChannelModelTierRepriceRequest{{ID: "tier-" + id, PriceVersion: 1, Prices: map[string]*int64{"unitPriceMicrocredits": &price}}}}
|
||||
}
|
||||
|
||||
func TestRepriceAdminChannelModelsPreservesFinalManualPrices(t *testing.T) {
|
||||
svc, db, admin := seedRepricingModels(t)
|
||||
disabled := model.ChannelModelPriceTier{ID: "disabled", ChannelModelID: "a", SelectorKey: `{"quality":"high"}`, Enabled: false, UnitPriceMicrocredits: 99, PriceVersion: 7}
|
||||
if err := db.Create(&disabled).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Model(&model.ChannelModel{}).Where("id = ?", "b").Update("enabled", false).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
requests := []ChannelModelRepriceRequest{repricingRequest("a", 1_250_000), repricingRequest("b", 1_370_000)}
|
||||
updated, err := svc.RepriceAdminChannelModels(admin, "channel", requests)
|
||||
if err != nil || updated != 2 {
|
||||
t.Fatalf("updated=%d err=%v", updated, err)
|
||||
}
|
||||
for _, request := range requests {
|
||||
item, err := svc.repo.ChannelModelByID("channel", request.ModelID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := *request.PriceTiers[0].Prices["unitPriceMicrocredits"]
|
||||
if item.UnitPriceMicrocredits != want || item.PriceVersion != 2 || item.Enabled != (item.ID == "a") {
|
||||
t.Fatalf("model=%+v", item)
|
||||
}
|
||||
for _, tier := range item.PriceTiers {
|
||||
if tier.ID == "disabled" {
|
||||
if tier.UnitPriceMicrocredits != 99 || tier.PriceVersion != 7 || tier.Enabled {
|
||||
t.Fatalf("disabled=%+v", tier)
|
||||
}
|
||||
} else if tier.UnitPriceMicrocredits != want || tier.PriceVersion != 2 || tier.CostPricing.UnitPriceMicrocredits != 1_000_000 {
|
||||
t.Fatalf("tier=%+v", tier)
|
||||
}
|
||||
}
|
||||
}
|
||||
if _, err := svc.RepriceAdminChannelModels(admin, "channel", requests); err == nil {
|
||||
t.Fatal("stale preview accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepriceAdminChannelModelsMultipleSpecifications(t *testing.T) {
|
||||
svc, db, admin := seedRepricingModels(t)
|
||||
if err := db.Model(&model.ChannelModel{}).Where("id = ?", "a").Updates(map[string]any{
|
||||
"capability": "image", "protocol": model.ChannelInterfaceOpenAIImage,
|
||||
}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
request := repricingRequest("a", 4_000)
|
||||
for _, spec := range []struct {
|
||||
id string
|
||||
price int64
|
||||
}{{"2K", 5_500}, {"4K", 6_000}} {
|
||||
tier := model.ChannelModelPriceTier{ID: spec.id, ChannelModelID: "a", SelectorKey: `{"vquality":"` + spec.id + `"}`,
|
||||
SelectorJSON: `{"vquality":"` + spec.id + `"}`, Resolution: spec.id, BillingMode: "fixed_request",
|
||||
Enabled: true, PriceConfigured: true, PriceVersion: 1, UnitPriceMicrocredits: 9,
|
||||
CostPricing: model.CreditCostPricing{Configured: true, UnitPriceMicrocredits: 3_000}}
|
||||
if err := db.Create(&tier).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
request.PriceTiers = append(request.PriceTiers, ChannelModelTierRepriceRequest{ID: spec.id, PriceVersion: 1,
|
||||
Prices: map[string]*int64{"unitPriceMicrocredits": &spec.price}})
|
||||
}
|
||||
if updated, err := svc.RepriceAdminChannelModels(admin, "channel", []ChannelModelRepriceRequest{request}); err != nil || updated != 1 {
|
||||
t.Fatalf("updated=%d err=%v", updated, err)
|
||||
}
|
||||
for _, input := range request.PriceTiers {
|
||||
var tier model.ChannelModelPriceTier
|
||||
if err := db.First(&tier, "id = ?", input.ID).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if tier.UnitPriceMicrocredits != *input.Prices["unitPriceMicrocredits"] || tier.PriceVersion != 2 {
|
||||
t.Fatalf("specification price was not saved independently: %+v", tier)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepriceAdminChannelModelsRejectsWholeBatch(t *testing.T) {
|
||||
for _, tt := range []struct {
|
||||
name string
|
||||
edit func([]ChannelModelRepriceRequest) []ChannelModelRepriceRequest
|
||||
mutate func(*gorm.DB) error
|
||||
nonAdmin bool
|
||||
}{
|
||||
{name: "empty", edit: func(_ []ChannelModelRepriceRequest) []ChannelModelRepriceRequest { return nil }},
|
||||
{name: "too many", edit: func(_ []ChannelModelRepriceRequest) []ChannelModelRepriceRequest {
|
||||
return make([]ChannelModelRepriceRequest, 101)
|
||||
}},
|
||||
{name: "duplicate model", edit: func(r []ChannelModelRepriceRequest) []ChannelModelRepriceRequest { return append(r, r[0]) }},
|
||||
{name: "empty ID", edit: func(r []ChannelModelRepriceRequest) []ChannelModelRepriceRequest { r[1].ModelID = ""; return r }},
|
||||
{name: "stale model", edit: func(r []ChannelModelRepriceRequest) []ChannelModelRepriceRequest { r[1].PriceVersion = 2; return r }},
|
||||
{name: "stale tier", edit: func(r []ChannelModelRepriceRequest) []ChannelModelRepriceRequest {
|
||||
r[1].PriceTiers[0].PriceVersion = 2
|
||||
return r
|
||||
}},
|
||||
{name: "missing tier", edit: func(r []ChannelModelRepriceRequest) []ChannelModelRepriceRequest { r[1].PriceTiers = nil; return r }},
|
||||
{name: "wrong tier owner", edit: func(r []ChannelModelRepriceRequest) []ChannelModelRepriceRequest {
|
||||
r[1].PriceTiers[0].ID = "tier-a"
|
||||
return r
|
||||
}},
|
||||
{name: "duplicate tier", edit: func(r []ChannelModelRepriceRequest) []ChannelModelRepriceRequest {
|
||||
r[1].PriceTiers = append(r[1].PriceTiers, r[1].PriceTiers[0])
|
||||
return r
|
||||
}},
|
||||
{name: "negative price", edit: func(r []ChannelModelRepriceRequest) []ChannelModelRepriceRequest {
|
||||
r[1] = repricingRequest("b", -1)
|
||||
return r
|
||||
}},
|
||||
{name: "missing price", edit: func(r []ChannelModelRepriceRequest) []ChannelModelRepriceRequest {
|
||||
r[1].PriceTiers[0].Prices = nil
|
||||
return r
|
||||
}},
|
||||
{name: "null price", edit: func(r []ChannelModelRepriceRequest) []ChannelModelRepriceRequest {
|
||||
r[1].PriceTiers[0].Prices["unitPriceMicrocredits"] = nil
|
||||
return r
|
||||
}},
|
||||
{name: "unknown field", edit: func(r []ChannelModelRepriceRequest) []ChannelModelRepriceRequest {
|
||||
r[1].PriceTiers[0].Prices["enabled"] = nil
|
||||
return r
|
||||
}},
|
||||
{name: "unsafe price", edit: func(r []ChannelModelRepriceRequest) []ChannelModelRepriceRequest {
|
||||
r[1] = repricingRequest("b", 9_007_199_254_740_992)
|
||||
return r
|
||||
}},
|
||||
{name: "missing cost", mutate: func(db *gorm.DB) error {
|
||||
return db.Model(&model.ChannelModelPriceTier{}).Where("id = ?", "tier-b").Update("cost_configured", false).Error
|
||||
}},
|
||||
{name: "disabled tier", mutate: func(db *gorm.DB) error {
|
||||
return db.Model(&model.ChannelModelPriceTier{}).Where("id = ?", "tier-b").Update("enabled", false).Error
|
||||
}},
|
||||
{name: "cross channel", mutate: func(db *gorm.DB) error {
|
||||
return db.Model(&model.ChannelModel{}).Where("id = ?", "b").Update("channel_id", "other").Error
|
||||
}},
|
||||
{name: "deleted", mutate: func(db *gorm.DB) error { return db.Delete(&model.ChannelModel{}, "id = ?", "b").Error }},
|
||||
{name: "not admin", nonAdmin: true},
|
||||
} {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
svc, db, actor := seedRepricingModels(t)
|
||||
requests := []ChannelModelRepriceRequest{repricingRequest("a", 1_250_000), repricingRequest("b", 1_370_000)}
|
||||
if tt.edit != nil {
|
||||
requests = tt.edit(requests)
|
||||
}
|
||||
if tt.mutate != nil {
|
||||
if err := tt.mutate(db); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if tt.nonAdmin {
|
||||
actor = &model.User{ID: "user"}
|
||||
}
|
||||
if count, err := svc.RepriceAdminChannelModels(actor, "channel", requests); err == nil || count != 0 {
|
||||
t.Fatalf("count=%d err=%v", count, err)
|
||||
}
|
||||
item, err := svc.repo.ChannelModelByID("channel", "a")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if item.PriceVersion != 1 || item.PriceTiers[0].UnitPriceMicrocredits != 9 {
|
||||
t.Fatal("partial save")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepriceTierFieldValidation(t *testing.T) {
|
||||
for _, capability := range []string{"text", "video"} {
|
||||
output, input, zero := int64(1_370_000), int64(500_000), int64(0)
|
||||
tier := model.ChannelModelPriceTier{ID: "t", BillingMode: "token", PriceConfigured: true, CostPricing: model.CreditCostPricing{Configured: true}}
|
||||
prices := map[string]*int64{"outputTokenPriceMicrocredits": &output}
|
||||
protocol := model.ChannelInterfaceVolcengineArkVideo
|
||||
if capability == "text" {
|
||||
protocol = model.ChannelInterfaceChatCompletion
|
||||
prices["inputTokenPriceMicrocredits"] = &input
|
||||
prices["cachedTokenPriceMicrocredits"] = &zero
|
||||
}
|
||||
if err := applyChannelModelTierPrices(&tier, capability, protocol, prices); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if tier.OutputTokenPriceMicrocredits != output || tier.CachedTokenPriceMicrocredits != 0 {
|
||||
t.Fatalf("tier=%+v", tier)
|
||||
}
|
||||
output = 1_000_000_000_001
|
||||
if err := applyChannelModelTierPrices(&tier, capability, protocol, prices); err == nil {
|
||||
t.Fatal("token price limit missing")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchRepricingRollsBackOnConcurrentChange(t *testing.T) {
|
||||
for _, target := range []string{"model", "tier"} {
|
||||
t.Run(target, func(t *testing.T) {
|
||||
svc, db, _ := seedRepricingModels(t)
|
||||
items := make([]model.ChannelModel, 0, 2)
|
||||
for _, id := range []string{"a", "b"} {
|
||||
item, err := svc.repo.ChannelModelByID("channel", id)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
item.UnitPriceMicrocredits = 123
|
||||
item.PriceTiers[0].UnitPriceMicrocredits = 123
|
||||
items = append(items, *item)
|
||||
}
|
||||
var err error
|
||||
if target == "model" {
|
||||
err = db.Model(&model.ChannelModel{}).Where("id = ?", "b").Update("price_version", 2).Error
|
||||
} else {
|
||||
err = db.Model(&model.ChannelModelPriceTier{}).Where("id = ?", "tier-b").Update("price_version", 2).Error
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := svc.repo.UpdateChannelModelSalePrices("channel", items); !errors.Is(err, repository.ErrChannelModelPriceConflict) {
|
||||
t.Fatalf("error=%v", err)
|
||||
}
|
||||
item, err := svc.repo.ChannelModelByID("channel", "a")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if item.UnitPriceMicrocredits != 9 || item.PriceVersion != 1 || item.PriceTiers[0].UnitPriceMicrocredits != 9 || item.PriceTiers[0].PriceVersion != 1 {
|
||||
t.Fatal("transaction not rolled back")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -81,6 +81,86 @@ func TestChannelCostHTTPAuthorizationAndPublicProjection(t *testing.T) {
|
||||
t.Fatalf("admin cost missing: %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
t.Run("batch repricing HTTP contract", func(t *testing.T) {
|
||||
var item model.ChannelModel
|
||||
if err := db.First(&item, "channel_id = ?", channel.ID).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
path := "/api/admin/channels/channel/models/batch-reprice"
|
||||
makePayload := func(price any) []byte {
|
||||
t.Helper()
|
||||
var latest model.ChannelModel
|
||||
var tier model.ChannelModelPriceTier
|
||||
if err := db.First(&latest, "id = ?", item.ID).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.First(&tier, "channel_model_id = ?", item.ID).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
payload, err := json.Marshal(map[string]any{"models": []any{map[string]any{
|
||||
"modelId": item.ID, "priceVersion": latest.PriceVersion,
|
||||
"priceTiers": []any{map[string]any{"id": tier.ID, "priceVersion": tier.PriceVersion,
|
||||
"prices": map[string]any{"unitPriceMicrocredits": price}}},
|
||||
}}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return payload
|
||||
}
|
||||
payload := makePayload(170_000)
|
||||
for _, role := range []string{"", "user"} {
|
||||
w := call(role, http.MethodPost, path, payload)
|
||||
want := http.StatusForbidden
|
||||
if role == "" {
|
||||
want = http.StatusUnauthorized
|
||||
}
|
||||
if w.Code != want {
|
||||
t.Fatalf("role %q: %d %s", role, w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
for _, invalid := range []string{
|
||||
`{}`, `{"models":[]}`, `{"models":null}`,
|
||||
`{"models":[{"modelId":"` + item.ID + `","priceVersion":1,"priceTiers":[]}]}`,
|
||||
string(makePayload(nil)), string(makePayload(-1)), string(makePayload(1.5)),
|
||||
} {
|
||||
w := call("admin", http.MethodPost, path, []byte(invalid))
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("invalid payload: %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
w := call("admin", http.MethodPost, path, payload)
|
||||
if w.Code != http.StatusOK || !strings.Contains(w.Body.String(), `"updated":1`) {
|
||||
t.Fatalf("reprice: %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
var tier model.ChannelModelPriceTier
|
||||
if err := db.First(&tier, "channel_model_id = ?", item.ID).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if tier.UnitPriceMicrocredits != 170_000 || tier.CostPricing.UnitPriceMicrocredits != 123_456 {
|
||||
t.Fatalf("unexpected stored tier: %+v", tier)
|
||||
}
|
||||
w = call("admin", http.MethodPost, path, payload)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("stale version: %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
// Explicit zero is a valid final sale price, not a missing field.
|
||||
payload = makePayload(0)
|
||||
w = call("admin", http.MethodPost, path, payload)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("zero target: %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
if err := db.First(&tier, "channel_model_id = ?", item.ID).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if tier.UnitPriceMicrocredits != 0 || tier.CostPricing.UnitPriceMicrocredits != 123_456 {
|
||||
t.Fatalf("zero price not preserved: %+v", tier)
|
||||
}
|
||||
payload = makePayload(170_000)
|
||||
w = call("admin", http.MethodPost, path, payload)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("restore target: %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
})
|
||||
for _, path := range []string{"/api/model-catalog", "/api/model-catalog/available", "/api/model-catalog/quote"} {
|
||||
method := http.MethodPost
|
||||
payload := []byte(`{"capability":"text"}`)
|
||||
|
||||
@@ -296,6 +296,27 @@ func RegisterFinanceRoutes(r *gin.RouterGroup, svc *service.Service) {
|
||||
}
|
||||
ok(c, gin.H{"deleted": deleted})
|
||||
})
|
||||
r.POST("/admin/channels/:id/models/batch-reprice", func(c *gin.Context) {
|
||||
user, err := currentUser(c, svc)
|
||||
if err != nil {
|
||||
failService(c, err)
|
||||
return
|
||||
}
|
||||
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 2<<20)
|
||||
var req struct {
|
||||
Models []service.ChannelModelRepriceRequest `json:"models" binding:"required"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
fail(c, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
updated, err := svc.RepriceAdminChannelModels(user, c.Param("id"), req.Models)
|
||||
if err != nil {
|
||||
failService(c, err)
|
||||
return
|
||||
}
|
||||
ok(c, gin.H{"updated": updated})
|
||||
})
|
||||
r.PATCH("/admin/channels/:id/models/:modelId", func(c *gin.Context) {
|
||||
saveChannelModel(c, svc, c.Param("modelId"))
|
||||
})
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"infinite-canvas/backend/internal/service"
|
||||
@@ -21,3 +23,24 @@ func TestFinanceRoutesExposeChannelModelBatchDelete(t *testing.T) {
|
||||
}
|
||||
t.Fatalf("route %s is not registered", wanted)
|
||||
}
|
||||
|
||||
func TestFinanceRoutesExposeChannelModelBatchReprice(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
router := gin.New()
|
||||
RegisterFinanceRoutes(router.Group("/api"), &service.Service{})
|
||||
const wanted = "POST /api/admin/channels/:id/models/batch-reprice"
|
||||
found := false
|
||||
for _, route := range router.Routes() {
|
||||
if route.Method+" "+route.Path == wanted {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("route %s is not registered", wanted)
|
||||
}
|
||||
response := httptest.NewRecorder()
|
||||
router.ServeHTTP(response, httptest.NewRequest(http.MethodPost, "/api/admin/channels/channel/models/batch-reprice", nil))
|
||||
if response.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("unauthenticated status = %d, body = %s", response.Code, response.Body.String())
|
||||
}
|
||||
}
|
||||
@@ -721,6 +721,78 @@ paths:
|
||||
$ref: "#/components/responses/Error"
|
||||
"404":
|
||||
$ref: "#/components/responses/Error"
|
||||
/admin/channels/{id}/models/batch-reprice:
|
||||
post:
|
||||
tags: [admin]
|
||||
summary: 批量保存系统渠道模型最终销售价
|
||||
description: |
|
||||
仅管理员可用。接收浏览器计算或手工修改后的最终售价及原始价格版本,不在服务端重算。
|
||||
前端按成本/(1−利润率)计算,支持0–6位积分小数四舍五入及重新计算。
|
||||
必须提交所选模型全部已启用规格,不改变成本、规格启停或模型启停。
|
||||
当前计费方式的价格字段必须完整且不能多余,价格为非负整数微积分,null无效。
|
||||
缺少成本/售价配置、重复或失效 ID、越权、超限价格、过期版本或并发版本冲突均整批拒绝。
|
||||
parameters:
|
||||
- in: path
|
||||
name: id
|
||||
required: true
|
||||
schema: { type: string }
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [models]
|
||||
properties:
|
||||
models:
|
||||
type: array
|
||||
minItems: 1
|
||||
maxItems: 100
|
||||
items:
|
||||
type: object
|
||||
required: [modelId, priceVersion, priceTiers]
|
||||
properties:
|
||||
modelId: { type: string, minLength: 1 }
|
||||
priceVersion: { type: integer, format: int64, minimum: 1 }
|
||||
priceTiers:
|
||||
type: array
|
||||
minItems: 1
|
||||
items:
|
||||
type: object
|
||||
required: [id, priceVersion, prices]
|
||||
properties:
|
||||
id: { type: string, minLength: 1 }
|
||||
priceVersion: { type: integer, format: int64, minimum: 1 }
|
||||
prices:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
description: 按次/秒仅unit字段;文本Token为输入、输出、缓存;视频Token仅输出。必须完整提交对应字段。
|
||||
properties:
|
||||
unitPriceMicrocredits: { type: integer, format: int64, minimum: 0, maximum: 9007199254740991 }
|
||||
inputTokenPriceMicrocredits: { type: integer, format: int64, minimum: 0, maximum: 1000000000000 }
|
||||
outputTokenPriceMicrocredits: { type: integer, format: int64, minimum: 0, maximum: 1000000000000 }
|
||||
cachedTokenPriceMicrocredits: { type: integer, format: int64, minimum: 0, maximum: 1000000000000 }
|
||||
example: { models: [{ modelId: MODEL001, priceVersion: 4, priceTiers: [{ id: TIER001, priceVersion: 8, prices: { unitPriceMicrocredits: 1370000 } }] }] }
|
||||
responses:
|
||||
"200":
|
||||
description: 更新成功,data.updated 为更新的模型数量
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
code: { type: integer, enum: [0] }
|
||||
msg: { type: string }
|
||||
data:
|
||||
type: object
|
||||
properties:
|
||||
updated: { type: integer }
|
||||
"400":
|
||||
$ref: "#/components/responses/Error"
|
||||
"401":
|
||||
$ref: "#/components/responses/Error"
|
||||
"403":
|
||||
$ref: "#/components/responses/Error"
|
||||
/admin/agent-lessons:
|
||||
get:
|
||||
tags: [admin]
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"infinite-canvas/backend/internal/model"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
var ErrChannelModelPriceConflict = errors.New("channel model prices changed concurrently")
|
||||
|
||||
// UpdateChannelModelSalePrices commits the entire selection or nothing. Only
|
||||
// pricing columns are written; version checks protect the cost snapshot used by app.
|
||||
func (r *Repository) UpdateChannelModelSalePrices(channelID string, items []model.ChannelModel) error {
|
||||
return r.db.Transaction(func(tx *gorm.DB) error {
|
||||
for _, item := range items {
|
||||
result := tx.Model(&model.ChannelModel{}).
|
||||
Where("id = ? AND channel_id = ? AND price_version = ? AND updated_at = ?", item.ID, channelID, item.PriceVersion, item.UpdatedAt).
|
||||
Updates(map[string]any{
|
||||
"unit_price_microcredits": item.UnitPriceMicrocredits,
|
||||
"input_token_price_microcredits": item.InputTokenPriceMicrocredits,
|
||||
"output_token_price_microcredits": item.OutputTokenPriceMicrocredits,
|
||||
"cached_token_price_microcredits": item.CachedTokenPriceMicrocredits,
|
||||
"price_version": item.PriceVersion + 1,
|
||||
})
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
if result.RowsAffected != 1 {
|
||||
return ErrChannelModelPriceConflict
|
||||
}
|
||||
for _, tier := range item.PriceTiers {
|
||||
if !tier.Enabled {
|
||||
continue
|
||||
}
|
||||
result = tx.Model(&model.ChannelModelPriceTier{}).
|
||||
Where("id = ? AND channel_model_id = ? AND price_version = ? AND updated_at = ?", tier.ID, item.ID, tier.PriceVersion, tier.UpdatedAt).
|
||||
Updates(map[string]any{
|
||||
"unit_price_microcredits": tier.UnitPriceMicrocredits,
|
||||
"input_token_price_microcredits": tier.InputTokenPriceMicrocredits,
|
||||
"output_token_price_microcredits": tier.OutputTokenPriceMicrocredits,
|
||||
"cached_token_price_microcredits": tier.CachedTokenPriceMicrocredits,
|
||||
"price_version": tier.PriceVersion + 1,
|
||||
})
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
if result.RowsAffected != 1 {
|
||||
return ErrChannelModelPriceConflict
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
@@ -98,6 +98,7 @@ type (
|
||||
ChannelModelCatalogOptions = app.ChannelModelCatalogOptions
|
||||
ChannelModelPriceTierRequest = app.ChannelModelPriceTierRequest
|
||||
ChannelModelRequest = app.ChannelModelRequest
|
||||
ChannelModelRepriceRequest = app.ChannelModelRepriceRequest
|
||||
ChannelModelQuoteRequest = app.ChannelModelQuoteRequest
|
||||
ChannelModelSortRequest = app.ChannelModelSortRequest
|
||||
ChannelModelsRequest = app.ChannelModelsRequest
|
||||
|
||||
Reference in new issue
Block a user