feat(*): 系统渠道与画布 - 支持多规格批量调价及 Agent 入口拖动
This commit is contained in:
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user