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

This commit is contained in:
ddCat
2026-09-20 11:18:19 +08:00
parent 9afe061d79
commit 8d94bde1f4
26 changed files with 1761 additions and 44 deletions

View File

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

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

View File

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

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]