feat(*): 系统渠道与画布 - 支持多规格批量调价及 Agent 入口拖动

This commit is contained in:
ddCat committed 2026-09-20 11:18:19 +08:00
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"}`)
+21
View File
@@ -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())
}
}
+72
View File
@@ -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